#!/usr/bin/env bash
# ─── Mosaic Stack Installer / Upgrader ────────────────────────────────────────
#
# Installs both components:
#   1. Mosaic framework   → ~/.config/mosaic/  (bash launcher, guides, runtime configs, tools)
#   2. @mosaicstack/mosaic (npm) → ~/.npm-global/     (CLI, TUI, gateway client, wizard)
#
# Quick:  curl -fsSL https://mosaicstack.dev/install.sh | bash
# Direct: bash <(curl -fsSL https://git.mosaicstack.dev/mosaicstack/stack/raw/branch/main/tools/install.sh)
#
# Remote install (alternative — use -s -- to pass flags):
#   curl -fsSL https://git.mosaicstack.dev/mosaicstack/stack/raw/branch/main/tools/install.sh | bash -s --
#
# Flags:
#   --check           Version check only, no install
#   --framework       Install/upgrade framework only (skip npm CLI)
#   --cli             Install/upgrade npm CLI only (skip framework)
#   --ref <branch>    Git ref for framework archive (default: main)
#   --next            Prerelease lane: try fast npm @next install for CLI +
#                     gateway from the Gitea registry, then fall back to a
#                     source build at next if unavailable. Explicit
#                     --ref/MOSAIC_REF wins and uses the source path.
#   --dev             Build CLI + gateway FROM SOURCE at --ref instead of the
#                     registry @latest. Zero registry writes — packs local
#                     tarballs and installs them globally. Use to test a branch
#                     end-to-end before cutting a release.
#   --yes             Accept all defaults; headless/non-interactive install
#   --no-auto-launch  Skip automatic mosaic wizard + gateway install on first install
#   --uninstall       Reverse the install: remove framework dir, CLI package, and npmrc line
#
# Environment:
#   MOSAIC_HOME         — framework install dir    (default: ~/.config/mosaic)
#   MOSAIC_REGISTRY     — npm registry URL          (default: Gitea instance)
#   MOSAIC_SCOPE        — npm scope                 (default: @mosaicstack)
#   MOSAIC_PREFIX       — npm global prefix          (default: ~/.npm-global)
#   MOSAIC_NO_COLOR     — disable colour             (set to 1)
#   MOSAIC_REF          — git ref for framework      (default: main)
#   MOSAIC_NEXT         — equivalent to --next        (set to 1)
#   MOSAIC_DEV          — equivalent to --dev         (set to 1)
#   MOSAIC_ASSUME_YES   — equivalent to --yes        (set to 1)
# ──────────────────────────────────────────────────────────────────────────────
#
# Wrapped in main() for safe curl-pipe usage.

set -euo pipefail

main() {

# ─── parse flags ──────────────────────────────────────────────────────────────
FLAG_CHECK=false
FLAG_FRAMEWORK=true
FLAG_CLI=true
FLAG_NO_AUTO_LAUNCH=false
FLAG_YES=false
FLAG_UNINSTALL=false
FLAG_DEV=false
FLAG_NEXT=false
GIT_REF="${MOSAIC_REF:-main}"
GIT_REF_EXPLICIT=false
if [[ -n "${MOSAIC_REF:-}" ]]; then
  GIT_REF_EXPLICIT=true
fi

# MOSAIC_ASSUME_YES env var acts the same as --yes
if [[ "${MOSAIC_ASSUME_YES:-0}" == "1" ]]; then
  FLAG_YES=true
fi

# MOSAIC_DEV env var acts the same as --dev
if [[ "${MOSAIC_DEV:-0}" == "1" ]]; then
  FLAG_DEV=true
fi

# MOSAIC_NEXT env var acts the same as --next: fast npm @next install with
# source fallback from the permanent next integration branch unless
# MOSAIC_REF/--ref explicitly wins.
if [[ "${MOSAIC_NEXT:-0}" == "1" ]]; then
  FLAG_NEXT=true
  if [[ "$GIT_REF_EXPLICIT" == "false" ]]; then
    GIT_REF="next"
  fi
fi

installer_usage() {
  printf 'Usage: install.sh [--check] [--framework] [--cli] [--ref <branch>] [--next] [--dev] [--yes|-y] [--no-auto-launch] [--uninstall]\n' >&2
}

while [[ $# -gt 0 ]]; do
  case "$1" in
    --check)          FLAG_CHECK=true; shift ;;
    --framework)      FLAG_CLI=false; shift ;;
    --cli)            FLAG_FRAMEWORK=false; shift ;;
    --ref)
      if [[ $# -lt 2 ]] || [[ -z "$2" ]]; then
        printf 'Error: Missing value for --ref\n' >&2
        installer_usage
        exit 2
      fi
      if [[ "$2" == -* ]]; then
        printf 'Error: Unknown argument: %s\n' "$2" >&2
        installer_usage
        exit 2
      fi
      GIT_REF="$2"
      GIT_REF_EXPLICIT=true
      shift 2
      ;;
    --dev)            FLAG_DEV=true; shift ;;
    --next)           FLAG_NEXT=true; if [[ "$GIT_REF_EXPLICIT" == "false" ]]; then GIT_REF="next"; fi; shift ;;
    --yes|-y)         FLAG_YES=true; shift ;;
    --no-auto-launch) FLAG_NO_AUTO_LAUNCH=true; shift ;;
    --uninstall)      FLAG_UNINSTALL=true; shift ;;
    *)
      printf 'Error: Unknown argument: %s\n' "$1" >&2
      installer_usage
      exit 2
      ;;
  esac
done

# Explicit refs represent a request for that exact source tree. Keep --next as
# a lane selector, but do not install the registry @next package for a different
# ref than the permanent next branch.
if [[ "$FLAG_NEXT" == "true" && "$GIT_REF_EXPLICIT" == "true" ]]; then
  FLAG_DEV=true
fi

if [[ "$FLAG_YES" == "true" ]]; then
  export MOSAIC_ASSUME_YES=1
fi

# ─── constants ────────────────────────────────────────────────────────────────
MOSAIC_HOME="${MOSAIC_HOME:-$HOME/.config/mosaic}"
REGISTRY="${MOSAIC_REGISTRY:-https://git.mosaicstack.dev/api/packages/mosaicstack/npm/}"
SCOPE="${MOSAIC_SCOPE:-@mosaicstack}"
PREFIX="${MOSAIC_PREFIX:-$HOME/.npm-global}"
CLI_PKG="${SCOPE}/mosaic"
GATEWAY_PKG="${SCOPE}/gateway"
REPO_BASE="https://git.mosaicstack.dev/mosaicstack/stack"
ARCHIVE_URL="${REPO_BASE}/archive/${GIT_REF}.tar.gz"

# In dev (build-from-source) mode the gateway is installed globally from a
# locally-built tarball. Tell the wizard / gateway-config stage NOT to overwrite
# it with the registry @latest build (honored by gatewayConfigStage).
if [[ "$FLAG_DEV" == "true" ]]; then
  export MOSAIC_GATEWAY_SKIP_NPM_INSTALL=1
fi

# Shared monorepo checkout (populated on demand by ensure_monorepo).
WORK_DIR=""
EXTRACTED_DIR=""

newest_matching_file() {
  local dir="$1"
  local pattern="$2"
  local matches=()
  [[ -d "$dir" ]] || return 0
  shopt -s nullglob
  # shellcheck disable=SC2206 # Intentional glob expansion for caller-provided file pattern.
  matches=("$dir"/$pattern)
  shopt -u nullglob
  [[ "${#matches[@]}" -gt 0 ]] || return 0
  # shellcheck disable=SC2012 # Need portable mtime sorting across Linux/macOS.
  # pipefail-safe: take the first line via process substitution; piping into
  # an early-exiting consumer would inherit ls's exit status under pipefail (#1099).
  local newest=""
  while IFS= read -r newest; do break; done < <(ls -1t "${matches[@]}" 2>/dev/null)
  if [[ -n "$newest" ]]; then printf '%s\n' "$newest"; fi
}

# ─── uninstall path ───────────────────────────────────────────────────────────
# Shell-level uninstall for when the CLI is broken or not available.
# Handles: framework directory, npm CLI package, npmrc scope line.
# Gateway teardown: if mosaic CLI is still available, delegates to it.
# Does NOT touch gateway DB/storage — user must handle that separately.

if [[ "$FLAG_UNINSTALL" == "true" ]]; then
  echo ""
  echo "${BOLD:-}Mosaic Uninstaller (shell fallback)${RESET:-}"
  echo ""

  SCOPE_LINE="${SCOPE:-@mosaicstack}:registry=${REGISTRY:-https://git.mosaicstack.dev/api/packages/mosaicstack/npm/}"
  NPMRC_FILE="$HOME/.npmrc"

  # Gateway: try mosaic CLI first, then check pid file
  if command -v mosaic &>/dev/null; then
    echo "${B:-}ℹ${RESET:-}  Attempting gateway uninstall via mosaic CLI…"
    if mosaic gateway uninstall --yes 2>/dev/null; then
      echo "${G:-}✔${RESET:-}  Gateway uninstalled via CLI."
    else
      echo "${Y:-}⚠${RESET:-}  Gateway uninstall via CLI failed or not installed — skipping."
    fi
  else
    # Look for pid file and stop daemon if running
    GATEWAY_HOME="${MOSAIC_HOME:-$HOME/.config/mosaic}/../mosaic-gateway"
    PID_FILE="$GATEWAY_HOME/gateway.pid"
    if [[ -f "$PID_FILE" ]]; then
      PID="$(cat "$PID_FILE" 2>/dev/null || true)"
      if [[ -n "$PID" ]] && kill -0 "$PID" 2>/dev/null; then
        echo "${B:-}ℹ${RESET:-}  Stopping gateway daemon (pid $PID)…"
        kill "$PID" 2>/dev/null || true
        sleep 1
      fi
    fi
    echo "${Y:-}⚠${RESET:-}  mosaic CLI not found — skipping full gateway teardown."
    echo "     Run 'mosaic gateway uninstall' separately if the CLI is available."
  fi

  # Framework directory
  if [[ -d "$MOSAIC_HOME" ]]; then
    echo "${B:-}ℹ${RESET:-}  Removing framework: $MOSAIC_HOME"
    rm -rf "$MOSAIC_HOME"
    echo "${G:-}✔${RESET:-}  Framework removed."
  else
    echo "${Y:-}⚠${RESET:-}  Framework directory not found: $MOSAIC_HOME"
  fi

  # Runtime assets: restore backups or remove managed copies
  echo "${B:-}ℹ${RESET:-}  Reversing runtime asset copies…"
  declare -a RUNTIME_DESTS=(
    "$HOME/.claude/CLAUDE.md"
    "$HOME/.claude/settings.json"
    "$HOME/.claude/hooks-config.json"
    "$HOME/.claude/context7-integration.md"
    "$HOME/.config/opencode/AGENTS.md"
    "$HOME/.codex/instructions.md"
  )
  for dest in "${RUNTIME_DESTS[@]}"; do
    base="$(basename "$dest")"
    dir="$(dirname "$dest")"
    # Find most recent backup
    backup=""
    if [[ -d "$dir" ]]; then
      backup="$(newest_matching_file "$dir" "${base}.mosaic-bak-*")"
    fi
    if [[ -n "$backup" ]] && [[ -f "$backup" ]]; then
      cp "$backup" "$dest"
      rm -f "$backup"
      echo "  Restored: $dest"
    elif [[ -f "$dest" ]]; then
      rm -f "$dest"
      echo "  Removed:  $dest"
    fi
  done

  # npmrc scope line
  if [[ -f "$NPMRC_FILE" ]] && grep -qF "$SCOPE_LINE" "$NPMRC_FILE" 2>/dev/null; then
    echo "${B:-}ℹ${RESET:-}  Removing $SCOPE_LINE from $NPMRC_FILE…"
    # Use sed to remove the exact line (in-place, portable)
    if sed -i.mosaic-uninstall-bak "\|^${SCOPE_LINE}\$|d" "$NPMRC_FILE" 2>/dev/null; then
      rm -f "${NPMRC_FILE}.mosaic-uninstall-bak"
      echo "${G:-}✔${RESET:-}  npmrc entry removed."
    else
      # BSD sed syntax (macOS)
      sed -i '' "\|^${SCOPE_LINE}\$|d" "$NPMRC_FILE" 2>/dev/null || \
        echo "${Y:-}⚠${RESET:-}  Could not auto-remove npmrc line — remove it manually: $SCOPE_LINE"
    fi
  fi

  # npm CLI package
  echo "${B:-}ℹ${RESET:-}  Uninstalling npm package: ${CLI_PKG}…"
  if npm uninstall -g "${CLI_PKG}" --prefix="$PREFIX" 2>&1 | sed 's/^/  /'; then
    echo "${G:-}✔${RESET:-}  CLI package removed."
  else
    echo "${Y:-}⚠${RESET:-}  npm uninstall failed — you may need to run manually:"
    echo "     npm uninstall -g ${CLI_PKG}"
  fi

  echo ""
  echo "${G:-}✔${RESET:-}  Uninstall complete."
  exit 0
fi

# ─── colours ──────────────────────────────────────────────────────────────────
if [[ "${MOSAIC_NO_COLOR:-0}" == "1" ]] || ! [[ -t 1 ]]; then
  R="" G="" Y="" B="" C="" DIM="" BOLD="" RESET=""
else
  R=$'\033[0;31m' G=$'\033[0;32m' Y=$'\033[0;33m'
  B=$'\033[0;34m' C=$'\033[0;36m' DIM=$'\033[2m'
  BOLD=$'\033[1m' RESET=$'\033[0m'
fi

info()  { echo "${B}ℹ${RESET}  $*"; }
ok()    { echo "${G}✔${RESET}  $*"; }
warn()  { echo "${Y}⚠${RESET}  $*"; }
fail()  { echo "${R}✖${RESET}  $*" >&2; }
dim()   { echo "${DIM}$*${RESET}"; }
step()  { printf '\n%s%s%s\n' "$BOLD" "$*" "$RESET"; }

is_next_registry_lane() {
  [[ "$FLAG_NEXT" == "true" && "$FLAG_DEV" == "false" && "$GIT_REF" == "next" && "$GIT_REF_EXPLICIT" == "false" ]]
}

source_ref_details() {
  if is_next_registry_lane; then
    echo "ref: next, --next prerelease lane"
  elif [[ "$FLAG_NEXT" == "true" && "$GIT_REF" == "next" ]]; then
    echo "ref: next, --next prerelease lane (build-from-source)"
  elif [[ "$FLAG_NEXT" == "true" ]]; then
    echo "ref: ${GIT_REF}, --next requested, explicit ref wins"
  else
    echo "ref: ${GIT_REF}"
  fi
}

# ─── helpers ──────────────────────────────────────────────────────────────────

require_cmd() {
  if ! command -v "$1" &>/dev/null; then
    fail "Required command not found: $1"
    echo "  Install it and re-run this script."
    exit 1
  fi
}

# True if any shell rc file already puts $1 on PATH.
#
# Each file is tested for existence first and grepped one at a time, rather than
# handed to a single `grep -qs ... "${rc_files[@]}"`. Handing grep a missing file
# makes the exit status implementation-defined: GNU grep 3.11 returns 0 when -q
# matched an earlier file, ugrep 7.5 returns 2 for the missing one regardless.
# On the 2 path the caller reads "not present yet" and appends a duplicate PATH
# line on every single install.
path_entry_exists() {
  local dir="$1" rc_file
  for rc_file in "$HOME/.profile" "$HOME/.zshenv" "$HOME/.zshrc" "$HOME/.bashrc"; do
    if [[ -f "$rc_file" ]] && grep -qF "$dir" "$rc_file"; then
      return 0
    fi
  done
  return 1
}

# Append `export PATH="$1:$PATH"` to the shell profile so $1 survives this
# process. An `export` here reaches only the installer; every directory the
# install leaves behind has to be written down somewhere a later shell reads.
#
# Deliberately NOT ~/.bashrc: Debian's default .bashrc returns early for
# non-interactive shells, so a PATH line appended to the bottom of it is
# unreachable to `bash -lc`, to systemd units, and to every agent seat — the
# exact consumers that need these binaries. ~/.profile is read by login shells
# and Debian's .profile sources .bashrc for interactive ones, so a single line
# there reaches both. For zsh the always-sourced file is .zshenv, not .zshrc.
#
# $1 = directory to add, $2 = label for the comment line.
# Returns 1 (having warned) if the profile could not be written.
persist_on_path() {
  local dir="$1" label="$2" profile

  if path_entry_exists "$dir"; then
    return 0
  fi

  if [[ -n "${ZSH_VERSION:-}" ]] || [[ "$(basename "${SHELL:-}")" == "zsh" ]]; then
    profile="$HOME/.zshenv"
  else
    profile="$HOME/.profile"
  fi

  # Probe writability in a subshell. A redirection failure on a special built-in
  # aborts the shell it runs in, so it has to be a child; and the redirection on
  # the subshell is what silences the "Permission denied" the shell would
  # otherwise print ahead of our own message.
  if ! ( : >>"$profile" ) 2>/dev/null; then
    warn "$dir is not on your PATH and $profile could not be written"
    dim "  Add to your shell rc:  export PATH=\"$dir:\$PATH\""
    return 1
  fi

  {
    echo ""
    echo "# $label"
    echo "export PATH=\"$dir:\$PATH\""
  } >>"$profile"
  ok "Added $dir to PATH in $profile"
  return 0
}

# Persist $PREFIX/bin on PATH instead of only warning about it.
#
# The warning it replaces was the last step of an otherwise successful install,
# so the installer reported success and left `mosaic: command not found` — an
# unattended install had no operator to read the advice and act on it.
ensure_prefix_on_path() {
  if [[ ":$PATH:" == *":$PREFIX/bin:"* ]]; then
    return
  fi

  if path_entry_exists "$PREFIX/bin"; then
    warn "$PREFIX/bin is in your shell profile but not in this shell"
  elif ! persist_on_path "$PREFIX/bin" "Mosaic CLI"; then
    return
  fi
  dim "  Run:  export PATH=\"$PREFIX/bin:\$PATH\"   (or start a new login shell)"
}

# Fleet transport binary (#1240).
#
# `mosaic fleet --help` reads "Manage the local Mosaic tmux fleet" and every
# roster the CLI scaffolds sets `transport: tmux`, but nothing in this script
# provides tmux and, until now, nothing in it mentioned tmux at all. A
# greenfield host came out of this installer able to install a fleet, start a
# fleet, and run no seat — the operator's first signal was `mosaic fleet ps`.
#
# Not a `require_cmd`: tmux is required by the fleet, not by mosaic. Plenty of
# hosts install this to run `mosaic claude` and will never scaffold a roster,
# and failing their install over a binary they do not need would be wrong. It
# is a warning that names precisely what it blocks.
#
# `tools/_scripts/mosaic-doctor` carries a deliberately parallel check, so the
# same host state gets the same answer from an audit as from an install. They
# are separate implementations because this one has to work before the
# framework's scripts are guaranteed to be on disk; keep their wording in step.
check_fleet_transport() {
  local transport=tmux
  local roster="$MOSAIC_HOME/fleet/roster.yaml"
  local declared=""

  if [[ -f "$roster" ]]; then
    # pipefail-safe: consume the first match via process substitution, then
    # strip quotes/CR and keep the first field without piping into a consumer (#1099).
    local first_transport=""
    while IFS= read -r first_transport; do break; done < \
      <(sed -n 's/^[[:space:]]*transport:[[:space:]]*//p' "$roster")
    if [[ -n "$first_transport" ]]; then
      declared="${first_transport//\"/}"
      declared="${declared//\'/}"
      declared="${declared//$'\r'/}"
      declared="${declared%%[[:space:]]*}"
      [[ -n "$declared" ]] && transport="$declared"
    fi
  fi

  command -v "$transport" &>/dev/null && return 0

  warn "Fleet transport '$transport' is not installed."
  echo "  The Mosaic fleet runs its agent seats inside $transport. Without it,"
  echo "  ${C}mosaic fleet start${RESET} reports success and no seat comes up."
  echo "  Install it before using the fleet, e.g. ${C}sudo apt-get install -y $transport${RESET}"
  echo "  (this does not affect ${C}mosaic claude${RESET} or the other single-runtime commands)."
}

installed_cli_version() {
  local json
  json="$(npm ls -g --depth=0 --json --prefix="$PREFIX" 2>/dev/null)" || true
  if [[ -n "$json" ]]; then
    node -e "
      const d = JSON.parse(process.argv[1]);
      const v = d?.dependencies?.['${CLI_PKG}']?.version ?? '';
      process.stdout.write(v);
    " "$json" 2>/dev/null || true
  fi
}

installed_gateway_version() {
  local json
  json="$(npm ls -g --depth=0 --json --prefix="$PREFIX" 2>/dev/null)" || true
  if [[ -n "$json" ]]; then
    node -e "
      const d = JSON.parse(process.argv[1]);
      const v = d?.dependencies?.['${GATEWAY_PKG}']?.version ?? '';
      process.stdout.write(v);
    " "$json" 2>/dev/null || true
  fi
}

latest_cli_version() {
  npm view "${CLI_PKG}" version --registry="$REGISTRY" 2>/dev/null || true
}

next_cli_version() {
  npm view "${CLI_PKG}@next" version --registry="$REGISTRY" 2>/dev/null || true
}

next_gateway_version() {
  npm view "${GATEWAY_PKG}@next" version --registry="$REGISTRY" 2>/dev/null || true
}

next_pipeline_suffix() {
  printf '%s' "$1" | sed -n 's/.*-next\.\([0-9][0-9]*\)$/\1/p'
}

next_versions_share_pipeline() {
  local cli_next="$1"
  local gateway_next="$2"
  local cli_pipeline gateway_pipeline
  cli_pipeline="$(next_pipeline_suffix "$cli_next")"
  gateway_pipeline="$(next_pipeline_suffix "$gateway_next")"
  [[ -n "$cli_pipeline" && -n "$gateway_pipeline" && "$cli_pipeline" == "$gateway_pipeline" ]]
}

version_lt() {
  node -e "
    const a=process.argv[1], b=process.argv[2];
    const sp = v => { const i=v.indexOf('-'); return i===-1 ? [v,null] : [v.slice(0,i),v.slice(i+1)]; };
    const [cA,pA]=sp(a.replace(/^v/,'')), [cB,pB]=sp(b.replace(/^v/,''));
    const nA=cA.split('.').map(Number), nB=cB.split('.').map(Number);
    for(let i=0;i<3;i++){if((nA[i]||0)<(nB[i]||0))process.exit(0);if((nA[i]||0)>(nB[i]||0))process.exit(1);}
    if(pA!==null&&pB===null)process.exit(0);
    if(pA===null)process.exit(1);
    if(pA<pB)process.exit(0);
    process.exit(1);
  " "$1" "$2" 2>/dev/null
}

framework_version() {
  # Read framework schema version stamp
  local vf="$MOSAIC_HOME/.framework-version"
  if [[ -f "$vf" ]]; then
    cat "$vf" 2>/dev/null || true
  fi
}

# Download + extract the monorepo archive at $GIT_REF exactly once per run.
# Sets the script-level EXTRACTED_DIR to the repo root. Reused by both the
# framework install (Part 1) and the dev build-from-source path (Part 2).
ensure_monorepo() {
  if [[ -n "$EXTRACTED_DIR" ]] && [[ -d "$EXTRACTED_DIR" ]]; then
    return 0
  fi

  require_cmd tar

  WORK_DIR="$(mktemp -d "${TMPDIR:-/tmp}/mosaic-install-XXXXXX")"
  # shellcheck disable=SC2317
  cleanup_work() { [[ -n "$WORK_DIR" ]] && rm -rf "$WORK_DIR"; }
  trap cleanup_work EXIT

  info "Downloading source from ${GIT_REF}…"
  if command -v curl &>/dev/null; then
    curl -fsSL "$ARCHIVE_URL" | tar xz -C "$WORK_DIR"
  elif command -v wget &>/dev/null; then
    wget -qO- "$ARCHIVE_URL" | tar xz -C "$WORK_DIR"
  else
    fail "curl or wget required to download source."
    exit 1
  fi

  # Gitea archives extract to exactly one <repo-name>/ inside the work dir.
  # Read the complete population so a malformed multi-root archive reaches the
  # named diagnostic instead of aborting on an upstream SIGPIPE under pipefail.
  local -a extracted_dirs=()
  mapfile -d '' -t extracted_dirs < <(find "$WORK_DIR" -maxdepth 1 -mindepth 1 -type d -print0)
  if [[ "${#extracted_dirs[@]}" -ne 1 ]] || [[ ! -d "${extracted_dirs[0]:-}" ]]; then
    fail "Could not locate exactly one extracted source directory in archive."
    ls -la "$WORK_DIR" >&2
    exit 1
  fi
  EXTRACTED_DIR="${extracted_dirs[0]}"
}

# Build @mosaicstack/mosaic + @mosaicstack/gateway from source and install both
# globally from locally-packed tarballs. ZERO registry writes. Workspace deps
# (brain/config/db/…) are pulled from the registry at the versions pinned in
# each package.json — `pnpm pack` rewrites `workspace:*` to those versions.
install_cli_from_source() {
  local src="$EXTRACTED_DIR"
  local out_dir="$WORK_DIR/dist-tarballs"
  mkdir -p "$out_dir"

  # pnpm via corepack (ships with Node >= 16.9; required by Node >= 20 preflight).
  # Pin to the repo's packageManager version so the build matches CI. Surface
  # corepack failures so the fresh-machine case gives an actionable error
  # instead of a bare "command not found".
  if ! command -v pnpm &>/dev/null; then
    info "Activating pnpm via corepack…"
    corepack enable 2>&1 | sed 's/^/  /' || warn "corepack enable failed — pnpm may need manual install."
    corepack prepare pnpm@10.6.2 --activate 2>&1 | sed 's/^/  /' \
      || warn "corepack prepare failed — pnpm may need manual install."
  fi
  if ! command -v pnpm &>/dev/null; then
    fail "pnpm not available after corepack activation."
    echo "  Install pnpm manually (https://pnpm.io/installation) and re-run with --dev."
    exit 1
  fi

  info "Installing workspace dependencies (pnpm install)…"
  ( cd "$src" && pnpm install ) 2>&1 | sed 's/^/  /'

  info "Building CLI + gateway from source…"
  ( cd "$src" && pnpm --filter "@mosaicstack/mosaic..." --filter "@mosaicstack/gateway..." run build ) 2>&1 | sed 's/^/  /'

  info "Packing local tarballs…"
  ( cd "$src/packages/mosaic" && pnpm pack --pack-destination "$out_dir" ) 2>&1 | sed 's/^/  /'
  ( cd "$src/apps/gateway"    && pnpm pack --pack-destination "$out_dir" ) 2>&1 | sed 's/^/  /'

  local cli_tgz gw_tgz
  cli_tgz="$(newest_matching_file "$out_dir" 'mosaicstack-mosaic-*.tgz')"
  gw_tgz="$(newest_matching_file "$out_dir" 'mosaicstack-gateway-*.tgz')"

  if [[ ! -f "$cli_tgz" ]]; then
    fail "CLI tarball was not produced by pnpm pack."
    exit 1
  fi
  if [[ ! -f "$gw_tgz" ]]; then
    fail "Gateway tarball was not produced by pnpm pack."
    exit 1
  fi

  # Gateway first so it is present globally before the CLI's wizard runs (which
  # skips its own gateway install via MOSAIC_GATEWAY_SKIP_NPM_INSTALL=1).
  info "Installing gateway from source tarball (global)…"
  npm install -g "$gw_tgz" --prefix="$PREFIX" 2>&1 | sed 's/^/  /'

  info "Installing CLI from source tarball (global)…"
  npm install -g "$cli_tgz" --prefix="$PREFIX" 2>&1 | sed 's/^/  /'

  ok "Installed from source: CLI $(installed_cli_version)"
}

install_next_cli_from_registry() {
  local cli_next gateway_next
  cli_next="$(next_cli_version)"
  gateway_next="$(next_gateway_version)"

  if [[ -z "$cli_next" ]]; then
    warn "${CLI_PKG}@next is unavailable from $REGISTRY."
    return 1
  fi
  if [[ -z "$gateway_next" ]]; then
    warn "${GATEWAY_PKG}@next is unavailable from $REGISTRY."
    return 1
  fi

  if ! next_versions_share_pipeline "$cli_next" "$gateway_next"; then
    warn "@next CLI/gateway versions do not share a pipeline suffix (${cli_next}, ${gateway_next})."
    return 1
  fi

  info "Installing ${CLI_PKG}@${cli_next} from registry…"
  if ! npm install -g "${CLI_PKG}@${cli_next}" --prefix="$PREFIX" 2>&1 | sed 's/^/  /'; then
    warn "Fast CLI @next install failed."
    return 1
  fi

  info "Installing ${GATEWAY_PKG}@${gateway_next} from registry…"
  if ! npm install -g "${GATEWAY_PKG}@${gateway_next}" --prefix="$PREFIX" 2>&1 | sed 's/^/  /'; then
    warn "Fast gateway @next install failed."
    return 1
  fi

  local installed_cli installed_gateway
  installed_cli="$(installed_cli_version)"
  installed_gateway="$(installed_gateway_version)"
  if [[ "$installed_cli" != "$cli_next" || "$installed_gateway" != "$gateway_next" ]]; then
    warn "Installed @next versions did not match resolved versions (CLI: ${installed_cli:-missing}, gateway: ${installed_gateway:-missing})."
    return 1
  fi

  export MOSAIC_GATEWAY_SKIP_NPM_INSTALL=1
  ok "Installed @next packages: CLI ${installed_cli}, gateway ${installed_gateway}"
}

# ─── node bootstrap ───────────────────────────────────────────────────────────
#
# Nothing on a greenfield host installs Node.js, yet this installer and the CLI
# it installs both hard-require it. Measured on a clean Debian 13 image: the
# installer stopped at `require_cmd node` with "Required command not found" and
# nothing was installed, with no hint of how to proceed.
#
# Inlined rather than factored into a sibling file on purpose: this script is
# fetched standalone by curl and has nothing to source.
#
# No-op when a suitable node is already on PATH, so it never fights an
# operator's nvm/fnm/distro node.

NODE_ROOT="${MOSAIC_NODE_ROOT:-$HOME/.mosaic/node}"
NODE_BOOTSTRAP_VERSION="${MOSAIC_NODE_VERSION:-v22.23.2}"
NODE_MIN_MAJOR="${MOSAIC_NODE_MIN_MAJOR:-20}"
NODE_DIST_BASE="${MOSAIC_NODE_DIST_BASE:-https://nodejs.org/dist}"

# Major version of the node at $1, or empty if it will not run.
node_major_of() {
  local candidate="$1" version
  version="$("$candidate" -e 'process.stdout.write(process.versions.node)' 2>/dev/null)" || return 0
  printf '%s' "${version%%.*}"
}

node_is_suitable() {
  local major
  major="$(node_major_of "$1")"
  [[ -n "$major" ]] && [[ "$major" -ge "$NODE_MIN_MAJOR" ]]
}

install_node() {
  local node_os node_arch tarball release_url work_dir extracted target node_bin

  case "$(uname -s)" in
    Linux)  node_os="linux" ;;
    Darwin) node_os="darwin" ;;
    *) fail "Unsupported OS '$(uname -s)'. Install Node.js >= $NODE_MIN_MAJOR manually."; return 1 ;;
  esac

  # Linux here means glibc. Node's official linux-x64 build is dynamically
  # linked against glibc, so on musl (Alpine) the binary will not exec — but it
  # fails visibly: node_is_suitable rejects it and ensure_node exits with
  # "install Node.js manually". No silent breakage, just a wasted download.
  # A musl host needs the unofficial build, which is out of scope here.
  case "$(uname -m)" in
    x86_64|amd64)  node_arch="x64" ;;
    aarch64|arm64) node_arch="arm64" ;;
    armv7l)        node_arch="armv7l" ;;
    *) fail "Unsupported architecture '$(uname -m)'. Install Node.js >= $NODE_MIN_MAJOR manually."; return 1 ;;
  esac

  # .tar.gz rather than the smaller .tar.xz: gzip is universally present, xz is
  # not, and a minimal image is exactly the case this exists to handle.
  tarball="node-${NODE_BOOTSTRAP_VERSION}-${node_os}-${node_arch}.tar.gz"
  release_url="${NODE_DIST_BASE}/${NODE_BOOTSTRAP_VERSION}"

  work_dir="$(mktemp -d "${TMPDIR:-/tmp}/mosaic-node-XXXXXX")"

  info "Installing Node.js $NODE_BOOTSTRAP_VERSION ($node_os-$node_arch) to $NODE_ROOT…"

  if ! curl -fsSL "${release_url}/${tarball}" -o "$work_dir/$tarball"; then
    fail "Download failed: ${release_url}/${tarball}"
    rm -rf "$work_dir"; return 1
  fi

  # Trust assumption, stated so nobody has to infer it: this verifies INTEGRITY
  # (the tarball matches the manifest), not AUTHENTICITY (the manifest is
  # genuinely Node's). The only thing establishing that is TLS to
  # $NODE_DIST_BASE. Node publishes SHASUMS256.txt.sig signed by its release
  # keys and we do not check it, which is on par with nvm but means pointing
  # MOSAIC_NODE_DIST_BASE at an untrusted mirror has no signature backstop.
  # Tracked as a hardening follow-up (raised by scooby in the #1229 review).
  if ! curl -fsSL "${release_url}/SHASUMS256.txt" -o "$work_dir/SHASUMS256.txt"; then
    fail "Could not fetch SHASUMS256.txt; refusing to install an unverified runtime."
    rm -rf "$work_dir"; return 1
  fi

  # Keep only our artifact's line, so a missing entry is an error not a pass.
  if ! grep " ${tarball}\$" "$work_dir/SHASUMS256.txt" >"$work_dir/expected.sha256"; then
    fail "$tarball has no entry in SHASUMS256.txt; refusing to install."
    rm -rf "$work_dir"; return 1
  fi

  if ! (cd "$work_dir" && verify_sha256 expected.sha256); then
    fail "Checksum mismatch for $tarball; refusing to install."
    rm -rf "$work_dir"; return 1
  fi
  ok "Checksum verified"

  tar xzf "$work_dir/$tarball" -C "$work_dir"
  extracted="$work_dir/node-${NODE_BOOTSTRAP_VERSION}-${node_os}-${node_arch}"
  if [[ ! -x "$extracted/bin/node" ]]; then
    fail "Extracted archive has no bin/node"
    rm -rf "$work_dir"; return 1
  fi

  mkdir -p "$NODE_ROOT"
  target="$NODE_ROOT/$NODE_BOOTSTRAP_VERSION"
  rm -rf "$target.incoming"
  mv "$extracted" "$target.incoming"
  rm -rf "$target"
  mv "$target.incoming" "$target"
  ln -sfn "$NODE_BOOTSTRAP_VERSION" "$NODE_ROOT/current"
  rm -rf "$work_dir"

  node_bin="$NODE_ROOT/current/bin"
  if ! node_is_suitable "$node_bin/node"; then
    fail "Installed node at $node_bin/node did not run"
    return 1
  fi

  export PATH="$node_bin:$PATH"
  ok "Node.js $(node -v) installed with npm $(npm -v 2>/dev/null || echo '?')"
  return 0
}

# Make the Mosaic-managed Node reachable from the next shell as well as this
# one. Measured on a greenfield canary run: without this the install finished
# rc=0, wrote $PREFIX/bin to ~/.profile, and the next login shell found `mosaic`
# and then died on `env: 'node': No such file or directory` — the CLI is a Node
# script, so a CLI on PATH without its runtime is a successful install that
# produces a broken command.
persist_node_on_path() {
  persist_on_path "$NODE_ROOT/current/bin" "Mosaic-managed Node.js" || true
}

ensure_node() {
  if command -v node &>/dev/null && node_is_suitable node; then
    return 0
  fi

  # A previous run may have installed one that is not on this shell's PATH.
  if node_is_suitable "$NODE_ROOT/current/bin/node"; then
    export PATH="$NODE_ROOT/current/bin:$PATH"
    persist_node_on_path
    return 0
  fi

  if [[ "${MOSAIC_SKIP_NODE_BOOTSTRAP:-0}" == "1" ]]; then
    fail "No suitable Node.js and MOSAIC_SKIP_NODE_BOOTSTRAP=1; refusing to download."
    echo "  Install Node.js >= $NODE_MIN_MAJOR yourself, then re-run this script."
    exit 1
  fi

  require_cmd curl
  require_cmd tar

  # sha256sum on Linux, shasum on macOS. Verification is not optional: without a
  # checksum this would install an unauthenticated runtime.
  if command -v sha256sum &>/dev/null; then
    verify_sha256() { sha256sum -c --status "$1"; }
  elif command -v shasum &>/dev/null; then
    verify_sha256() { shasum -a 256 -c --status "$1"; }
  else
    fail "sha256sum or shasum required to verify the Node.js download"
    exit 1
  fi

  if ! install_node; then
    fail "Could not bootstrap Node.js. Install Node.js >= $NODE_MIN_MAJOR and re-run."
    exit 1
  fi
  persist_node_on_path
}

# ─── preflight ────────────────────────────────────────────────────────────────

ensure_node
require_cmd node
require_cmd npm

NODE_MAJOR="$(node -e 'process.stdout.write(String(process.versions.node.split(".")[0]))')"
if [[ "$NODE_MAJOR" -lt 20 ]]; then
  fail "Node.js >= 20 required (found v$(node --version))"
  exit 1
fi
if [[ "$FLAG_NEXT" == "true" && "$NODE_MAJOR" -lt 22 ]]; then
  fail "Node.js >= 22 required for the --next lane (found v$(node --version))"
  exit 1
fi

echo ""
echo "${BOLD}Mosaic Stack Installer${RESET}"
echo ""

# ═══════════════════════════════════════════════════════════════════════════════
# PART 1: Framework (bash launcher + guides + runtime configs + tools)
# ═══════════════════════════════════════════════════════════════════════════════

if [[ "$FLAG_FRAMEWORK" == "true" ]]; then
  step "Framework (~/.config/mosaic)"

  FRAMEWORK_CURRENT="$(framework_version)"
  HAS_FRAMEWORK=false
  [[ -f "$MOSAIC_HOME/AGENTS.md" ]] || [[ -f "$MOSAIC_HOME/.framework-version" ]] && HAS_FRAMEWORK=true

  if [[ -n "$FRAMEWORK_CURRENT" ]]; then
    dim "  Installed: framework v${FRAMEWORK_CURRENT}"
  elif [[ "$HAS_FRAMEWORK" == "true" ]]; then
    dim "  Installed: framework (version unknown)"
  else
    dim "  Installed: (none)"
  fi
  dim "  Source:    ${REPO_BASE} ($(source_ref_details))"
  echo ""

  if [[ "$FLAG_CHECK" == "true" ]]; then
    if [[ "$HAS_FRAMEWORK" == "true" ]]; then
      ok "Framework is installed."
    else
      warn "Framework not installed."
    fi
  else
    # Download repo archive and extract framework (shared with the dev build)
    ensure_monorepo
    FRAMEWORK_SRC="$EXTRACTED_DIR/packages/mosaic/framework"

    if [[ ! -d "$FRAMEWORK_SRC" ]]; then
      fail "Framework not found in archive at packages/mosaic/framework/"
      fail "Archive contents:"
      ls -la "$WORK_DIR" >&2
      exit 1
    fi

    # Run the framework's own install.sh (handles keep/overwrite for SOUL.md etc.)
    info "Installing framework to ${MOSAIC_HOME}…"
    MOSAIC_INSTALL_MODE="${MOSAIC_INSTALL_MODE:-keep}" \
      MOSAIC_ALLOW_MISSING_SEQUENTIAL_THINKING=1 \
      MOSAIC_SKIP_SKILLS_SYNC="${MOSAIC_SKIP_SKILLS_SYNC:-0}" \
      bash "$FRAMEWORK_SRC/install.sh"

    ok "Framework installed"
    echo ""

    # Framework bin is no longer needed on PATH — the npm CLI delegates
    # to mosaic-launch directly via its absolute path.
  fi
fi

# ═══════════════════════════════════════════════════════════════════════════════
# PART 2: @mosaicstack/mosaic (npm — TUI, gateway client, wizard, CLI)
# ═══════════════════════════════════════════════════════════════════════════════

if [[ "$FLAG_CLI" == "true" ]]; then
  step "@mosaicstack/mosaic (npm package)"

  # Ensure prefix dir
  if [[ ! -d "$PREFIX" ]]; then
    info "Creating global prefix directory: $PREFIX"
    mkdir -p "$PREFIX"/{bin,lib}
  fi

  # Ensure npmrc scope mapping
  NPMRC="$HOME/.npmrc"
  SCOPE_LINE="${SCOPE}:registry=${REGISTRY}"

  if ! grep -qF "$SCOPE_LINE" "$NPMRC" 2>/dev/null; then
    info "Adding ${SCOPE} registry to $NPMRC"
    echo "$SCOPE_LINE" >> "$NPMRC"
    ok "Registry configured"
  fi

  if ! grep -qF "prefix=$PREFIX" "$NPMRC" 2>/dev/null; then
    if ! grep -q '^prefix=' "$NPMRC" 2>/dev/null; then
      echo "prefix=$PREFIX" >> "$NPMRC"
      info "Set npm global prefix to $PREFIX"
    fi
  fi

  CURRENT="$(installed_cli_version)"
  NEXT_GATEWAY=""
  if [[ "$FLAG_DEV" == "true" ]]; then
    LATEST=""
  elif is_next_registry_lane; then
    LATEST="$(next_cli_version)"
    NEXT_GATEWAY="$(next_gateway_version)"
  else
    LATEST="$(latest_cli_version)"
  fi

  if [[ -n "$CURRENT" ]]; then
    dim "  Installed: ${CLI_PKG}@${CURRENT}"
  else
    dim "  Installed: (none)"
  fi

  if [[ "$FLAG_DEV" == "true" ]]; then
    dim "  Source:    ${REPO_BASE} ($(source_ref_details), build-from-source)"
  elif is_next_registry_lane; then
    if [[ -n "$LATEST" ]]; then
      dim "  Next CLI:  ${CLI_PKG}@${LATEST}"
    else
      dim "  Next CLI:  (registry @next unreachable)"
    fi
    if [[ -n "$NEXT_GATEWAY" ]]; then
      dim "  Next GW:   ${GATEWAY_PKG}@${NEXT_GATEWAY}"
    else
      dim "  Next GW:   (registry @next unreachable)"
    fi
    dim "  Fallback:  ${REPO_BASE} (ref: next, build-from-source)"
  elif [[ -n "$LATEST" ]]; then
    dim "  Latest:    ${CLI_PKG}@${LATEST}"
  else
    dim "  Latest:    (registry unreachable)"
  fi
  echo ""

  if [[ "$FLAG_CHECK" == "true" ]]; then
    if [[ "$FLAG_DEV" == "true" ]]; then
      info "Dev mode: installed version is ${CURRENT:-(none)} (no registry comparison)."
    elif is_next_registry_lane; then
      if [[ -n "$LATEST" && -n "$NEXT_GATEWAY" ]] && next_versions_share_pipeline "$LATEST" "$NEXT_GATEWAY"; then
        ok "@next registry lane available: ${CLI_PKG}@${LATEST}, ${GATEWAY_PKG}@${NEXT_GATEWAY}."
      else
        warn "@next registry lane incomplete, mismatched, or unreachable; --next would fall back to source."
      fi
    elif [[ -z "$LATEST" ]]; then
      warn "Could not reach registry."
    elif [[ -z "$CURRENT" ]]; then
      warn "Not installed."
    elif [[ "$CURRENT" == "$LATEST" ]]; then
      ok "Up to date."
    elif version_lt "$CURRENT" "$LATEST"; then
      warn "Update available: $CURRENT → $LATEST"
    else
      ok "Up to date (or ahead of registry)."
    fi
  elif [[ "$FLAG_DEV" == "true" ]]; then
    info "Dev mode — building CLI + gateway from source at ref ${GIT_REF}…"
    ensure_monorepo
    install_cli_from_source

    ensure_prefix_on_path
  elif is_next_registry_lane; then
    info "Next mode — trying fast npm @next install from ${REGISTRY}…"
    if install_next_cli_from_registry; then
      :
    else
      warn "Falling back to source build at ref ${GIT_REF}; --next will not hard-fail on registry issues."
      unset MOSAIC_GATEWAY_SKIP_NPM_INSTALL
      ensure_monorepo
      install_cli_from_source
      export MOSAIC_GATEWAY_SKIP_NPM_INSTALL=1
    fi

    ensure_prefix_on_path
  else
    if [[ -z "$LATEST" ]]; then
      warn "Could not reach registry at $REGISTRY — skipping npm CLI."
    elif [[ -z "$CURRENT" ]]; then
      info "Installing ${CLI_PKG}@${LATEST}…"
      npm install -g "${CLI_PKG}@${LATEST}" --prefix="$PREFIX" 2>&1 | sed 's/^/  /'
      ok "CLI installed: $(installed_cli_version)"
    elif [[ "$CURRENT" == "$LATEST" ]]; then
      ok "Already at latest version ($LATEST)."
    elif version_lt "$CURRENT" "$LATEST"; then
      info "Upgrading ${CLI_PKG}: $CURRENT → $LATEST…"
      npm install -g "${CLI_PKG}@${LATEST}" --prefix="$PREFIX" 2>&1 | sed 's/^/  /'
      ok "CLI upgraded: $(installed_cli_version)"
    else
      ok "CLI is at or ahead of registry ($CURRENT ≥ $LATEST)."
    fi

    ensure_prefix_on_path
  fi
fi

# ═══════════════════════════════════════════════════════════════════════════════
# Summary
# ═══════════════════════════════════════════════════════════════════════════════

if [[ "$FLAG_CHECK" == "false" ]]; then
  step "Summary"

  echo "  ${BOLD}mosaic:${RESET}          $PREFIX/bin/mosaic"
  dim "  Framework data:  $MOSAIC_HOME/"
  echo ""

  # First install guidance / auto-launch
  if [[ ! -f "$MOSAIC_HOME/SOUL.md" ]]; then
    echo ""
    if [[ "$FLAG_NO_AUTO_LAUNCH" == "false" ]] && [[ -t 0 ]] && [[ -t 1 ]]; then
      # Interactive TTY and auto-launch not suppressed: run the unified wizard.
      # `mosaic wizard` now runs the full first-run flow end-to-end: identity
      # setup → runtimes → hooks preview → skills → finalize → gateway
      # config → admin bootstrap. No second call needed.
      info "First install detected — launching unified setup wizard…"
      echo ""

      MOSAIC_BIN="$PREFIX/bin/mosaic"

      if ! command -v "$MOSAIC_BIN" &>/dev/null && ! command -v mosaic &>/dev/null; then
        warn "mosaic binary not found on PATH — skipping auto-launch."
        warn "Add $PREFIX/bin to PATH and run: mosaic wizard"
      else
        # Prefer the absolute path from the prefix we just installed to
        MOSAIC_CMD="mosaic"
        if [[ -x "$MOSAIC_BIN" ]]; then
          MOSAIC_CMD="$MOSAIC_BIN"
        fi

        if "$MOSAIC_CMD" wizard; then
          ok "Wizard complete."
        else
          fail "Wizard failed; installation is incomplete."
          echo "  Completed: framework and CLI installation"
          echo "  Failed: gateway configuration or admin bootstrap"
          echo "  You can retry with: ${C}mosaic wizard${RESET}"
          echo "  Or run gateway install alone: ${C}mosaic gateway install${RESET}"
          exit 1
        fi
      fi
    else
      # Non-interactive or --no-auto-launch: print guidance only
      info "First install detected. Set up your agent identity:"
      echo "  ${C}mosaic wizard${RESET}   (unified first-run wizard — identity + gateway + admin)"
      echo "  ${C}mosaic gateway install${RESET}   (standalone gateway (re)configure)"
    fi
  fi

  # ── Write install manifest ──────────────────────────────────────────────────
  # Records what was mutated so that `mosaic uninstall` can precisely reverse it.
  # Written last (after all mutations) so an incomplete install leaves no manifest.
  MANIFEST_PATH="$MOSAIC_HOME/.install-manifest.json"
  MANIFEST_CLI_VERSION="$(installed_cli_version)"
  MANIFEST_FW_VERSION="$(framework_version)"
  MANIFEST_SCOPE_LINE="${SCOPE}:registry=${REGISTRY}"
  MANIFEST_TS="$(date -u +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null || date -u +"%Y-%m-%dT%H:%M:%SZ")"

  # Build runtimeAssetCopies array by scanning known destinations for backups
  collect_runtime_copies() {
    local home_dir="$HOME"
    local copies="[]"
    local dests=(
      "$home_dir/.claude/CLAUDE.md"
      "$home_dir/.claude/settings.json"
      "$home_dir/.claude/hooks-config.json"
      "$home_dir/.claude/context7-integration.md"
      "$home_dir/.config/opencode/AGENTS.md"
      "$home_dir/.codex/instructions.md"
    )
    copies="["
    local first=true
    for dest in "${dests[@]}"; do
      [[ -f "$dest" ]] || continue
      local base dir backup_path backup_val
      base="$(basename "$dest")"
      dir="$(dirname "$dest")"
      backup_path="$(newest_matching_file "$dir" "${base}.mosaic-bak-*")"
      if [[ -n "$backup_path" ]]; then
        backup_val="\"$backup_path\""
      else
        backup_val="null"
      fi
      if [[ "$first" == "true" ]]; then
        first=false
      else
        copies="$copies,"
      fi
      copies="$copies{\"source\":\"\",\"dest\":\"$dest\",\"backup\":$backup_val}"
    done
    copies="$copies]"
    echo "$copies"
  }

  RUNTIME_COPIES="$(collect_runtime_copies)"

  # Check whether the npmrc line was present (we may have added it above)
  NPMRC_LINES_JSON="[]"
  if grep -qF "$MANIFEST_SCOPE_LINE" "$HOME/.npmrc" 2>/dev/null; then
    NPMRC_LINES_JSON="[\"$MANIFEST_SCOPE_LINE\"]"
  fi

  if node -e "
    const fs = require('fs');
    const path = require('path');
    const p = process.argv[1];
    const m = {
      version: 1,
      installedAt: process.argv[2],
      cliVersion: process.argv[3] || '(unknown)',
      frameworkVersion: parseInt(process.argv[4] || '0', 10),
      mutations: {
        directories: [path.dirname(p)],
        npmGlobalPackages: ['@mosaicstack/mosaic'],
        npmrcLines: JSON.parse(process.argv[5]),
        shellProfileEdits: [],
        runtimeAssetCopies: JSON.parse(process.argv[6]),
      }
    };
    fs.mkdirSync(path.dirname(p), { recursive: true });
    fs.writeFileSync(p, JSON.stringify(m, null, 2) + '\\n', { mode: 0o600 });
  " \
    "$MANIFEST_PATH" \
    "$MANIFEST_TS" \
    "$MANIFEST_CLI_VERSION" \
    "$MANIFEST_FW_VERSION" \
    "$NPMRC_LINES_JSON" \
    "$RUNTIME_COPIES" 2>/dev/null; then
    ok "Install manifest written: $MANIFEST_PATH"
  else
    warn "Could not write install manifest (non-fatal)"
  fi

  echo ""
  ok "Done."
fi

# Fleet readiness (#1240). Runs in both normal and --check mode: "what is the
# state of this host" is exactly the question --check is asked, and a host that
# cannot run a seat should not have to discover it from `fleet ps`.
check_fleet_transport

} # end main

main "$@"
