#!/bin/sh # Install the Scalattice developer CLI. # # Usage: # curl -fsSL https://scalattice.cloud/install/cli | sh # curl -fsSL https://scalattice.cloud/install/cli | sh -s -- --yes # # This wraps the public npm package `scalattice-cli` (command: `scalattice`). # If Node.js 18+ is missing, a private runtime is installed under the # Scalattice data dir so the first curl | sh works on Windows too. # Prefer `npm install -g scalattice-cli@latest` if you already use Node. # Replaces older `scalattice` binaries this user (or sudo) can write. # # Links: # https://www.npmjs.com/package/scalattice-cli # https://github.com/scalattice/scalattice-cli # https://scalattice.com/cli/ set -eu PKG_NAME="scalattice-cli" NPM_FLAGS="--no-fund --no-audit --prefer-online" NODE_DIST="https://nodejs.org/dist/latest-v22.x" ASSUME_YES=0 USED_PORTABLE=0 NODE_HOME="" while [ $# -gt 0 ]; do case "$1" in -y|--yes) ASSUME_YES=1 shift ;; -h|--help) echo "Usage: curl -fsSL https://scalattice.cloud/install/cli | sh" echo " curl -fsSL https://scalattice.cloud/install/cli | sh -s -- --yes" echo "" echo "Installs scalattice-cli. Downloads a local Node.js 22 runtime if needed." echo "Equivalent when Node is already on PATH: npm install -g scalattice-cli@latest" exit 0 ;; *) echo "Unknown option: $1" >&2 exit 1 ;; esac done need_cmd() { if ! command -v "$1" >/dev/null 2>&1; then echo "Missing required command: $1" >&2 return 1 fi return 0 } is_windows() { if [ "${OS:-}" = "Windows_NT" ]; then return 0 fi case "$(uname -s 2>/dev/null || echo unknown)" in MINGW*|MSYS*|CYGWIN*) return 0 ;; esac return 1 } # WSL PATH includes Windows AppData. Never rewrite those from Linux. is_windows_fs_path() { case "$1" in *.cmd|*.bat|*.exe|*.COM|*.com) return 0 ;; /mnt/[a-zA-Z]/*) return 0 ;; */AppData/*|*/AppData\\*|*/Application\ Data/*) return 0 ;; [A-Za-z]:[\\/]*) return 0 ;; esac return 1 } win_path() { if command -v cygpath >/dev/null 2>&1; then cygpath -w "$1" return fi _p="$1" case "$_p" in /[a-zA-Z]/*) _drive=$(printf '%s' "$_p" | cut -c2 | tr 'a-z' 'A-Z') _rest=$(printf '%s' "$_p" | cut -c4- | tr '/' '\\') printf '%s:\\%s\n' "$_drive" "$_rest" ;; *) printf '%s\n' "$_p" ;; esac } slt_home() { if is_windows; then _h="${LOCALAPPDATA:-$HOME/AppData/Local}/scalattice" if command -v cygpath >/dev/null 2>&1; then cygpath -u "$_h" return fi printf '%s\n' "$_h" else printf '%s\n' "${XDG_DATA_HOME:-$HOME/.local/share}/scalattice" fi } node_ok() { command -v node >/dev/null 2>&1 || return 1 command -v npm >/dev/null 2>&1 || return 1 _maj="$(node -p "process.versions.node.split('.')[0]" 2>/dev/null || echo 0)" [ "$_maj" -ge 18 ] } node_pack() { if is_windows; then case "${PROCESSOR_ARCHITECTURE:-}${PROCESSOR_ARCHITEW6432:-}" in *ARM64*|*arm64*) printf '%s\n' 'win-arm64.zip' ;; *) printf '%s\n' 'win-x64.zip' ;; esac return fi _sys="$(uname -s 2>/dev/null || echo Linux)" _arch="$(uname -m 2>/dev/null || echo x86_64)" case "$_sys" in Darwin) case "$_arch" in arm64) printf '%s\n' 'darwin-arm64.tar.gz' ;; *) printf '%s\n' 'darwin-x64.tar.gz' ;; esac ;; *) case "$_arch" in aarch64|arm64) printf '%s\n' 'linux-arm64.tar.gz' ;; *) printf '%s\n' 'linux-x64.tar.gz' ;; esac ;; esac } node_asset_url() { _pack="$1" _sums="$(curl -fsSL "$NODE_DIST/SHASUMS256.txt")" || return 1 _name="$(printf '%s\n' "$_sums" | awk -v p="$_pack" '$2 ~ p"$" { print $2; exit }')" [ -n "$_name" ] || return 1 printf '%s/%s\n' "$NODE_DIST" "$_name" } extract_zip() { _zip="$1" _dest="$2" mkdir -p "$_dest" if command -v unzip >/dev/null 2>&1; then unzip -qo "$_zip" -d "$_dest" return fi if command -v powershell.exe >/dev/null 2>&1; then powershell.exe -NoProfile -Command "Expand-Archive -LiteralPath '$(win_path "$_zip")' -DestinationPath '$(win_path "$_dest")' -Force" return fi echo "Need unzip or PowerShell to extract Node.js." >&2 return 1 } install_portable_node() { if ! need_cmd curl; then echo "Install curl, then re-run this installer." >&2 return 1 fi _home="$(slt_home)" NODE_HOME="$_home/runtime/node" _pack="$(node_pack)" echo "Node.js 18+ not found. Installing a private runtime for the CLI..." _url="$(node_asset_url "$_pack")" || { echo "Could not look up Node.js at $NODE_DIST." >&2 return 1 } _tmp="$(mktemp -d 2>/dev/null || mktemp -d -t slt-node)" trap 'rm -rf "$_tmp"' EXIT _archive="$_tmp/node-dist" echo "Downloading $_url" curl -fL "$_url" -o "$_archive" || return 1 mkdir -p "$_tmp/out" "$NODE_HOME" case "$_pack" in *.zip) extract_zip "$_archive" "$_tmp/out" || return 1 ;; *) tar -xzf "$_archive" -C "$_tmp/out" || return 1 ;; esac _top="" for _cand in "$_tmp/out"/node-v*; do if [ -d "$_cand" ]; then _top="$_cand" break fi done if [ -z "$_top" ]; then echo "Node.js archive did not contain a runtime directory." >&2 return 1 fi rm -rf "$NODE_HOME" mkdir -p "$(dirname "$NODE_HOME")" mv "$_top" "$NODE_HOME" rm -rf "$_tmp" trap - EXIT PATH="$NODE_HOME:$NODE_HOME/bin:$PATH" export PATH hash -r 2>/dev/null || true if ! node_ok; then echo "Installed a Node runtime at $NODE_HOME, but node/npm still do not work." >&2 return 1 fi USED_PORTABLE=1 echo "Using Node $(node -v) from $NODE_HOME" } add_win_user_path() { _dir="$1" [ -n "$_dir" ] || return 0 is_windows || return 0 command -v powershell.exe >/dev/null 2>&1 || return 0 _windir="$(win_path "$_dir")" powershell.exe -NoProfile -Command " \$dir = [string]'$_windir' \$cur = [Environment]::GetEnvironmentVariable('Path', 'User') if ([string]::IsNullOrEmpty(\$cur)) { \$cur = '' } \$parts = @(\$cur -split ';' | Where-Object { \$_ -and \$_.Trim() -ne '' }) if (\$parts -notcontains \$dir) { \$next = @(\$parts + \$dir) -join ';' [Environment]::SetEnvironmentVariable('Path', \$next, 'User') } " || true } write_unix_wrapper() { _prefix="$1" _node_home="$2" _cli="" for _cand in \ "$_prefix/lib/node_modules/scalattice-cli/bin/scalattice.js" \ "$_prefix/node_modules/scalattice-cli/bin/scalattice.js" do if [ -f "$_cand" ]; then _cli="$_cand" break fi done [ -n "$_cli" ] || return 0 _node="$_node_home/bin/node" if [ ! -x "$_node" ]; then _node="$_node_home/node" fi [ -x "$_node" ] || return 0 _dir="$_prefix/bin" mkdir -p "$_dir" rm -f "$_dir/scalattice" printf '%s\n' "#!/bin/sh" "export PATH=\"$_node_home/bin:$_node_home:\$PATH\"" "exec \"$_node\" \"$_cli\" \"\$@\"" > "$_dir/scalattice" chmod +x "$_dir/scalattice" } write_win_wrappers() { _prefix="$1" _node_home="$2" _cli="" for _cand in \ "$_prefix/node_modules/scalattice-cli/bin/scalattice.js" \ "$_prefix/lib/node_modules/scalattice-cli/bin/scalattice.js" do if [ -f "$_cand" ]; then _cli="$_cand" break fi done [ -n "$_cli" ] || return 0 _node="$_node_home/node.exe" if [ ! -f "$_node" ]; then _node="$_node_home/node" fi _node_dir="$(dirname "$_node")" mkdir -p "$_prefix" rm -f "$_prefix/scalattice.cmd" "$_prefix/scalattice" printf '%s\n' "@echo off" "setlocal" "set \"PATH=$(win_path "$_node_dir");%PATH%\"" "\"$(win_path "$_node")\" \"$(win_path "$_cli")\" %*" > "$_prefix/scalattice.cmd" printf '%s\n' "#!/bin/sh" "export PATH=\"$_node_dir:\$PATH\"" "exec \"$_node\" \"$_cli\" \"\$@\"" > "$_prefix/scalattice" chmod +x "$_prefix/scalattice" 2>/dev/null || true } if ! need_cmd curl; then echo "Install curl, then re-run this installer." >&2 exit 1 fi use_portable_node() { NODE_HOME="$(slt_home)/runtime/node" if [ ! -x "$NODE_HOME/bin/node" ] && [ ! -x "$NODE_HOME/node" ] && [ ! -f "$NODE_HOME/node.exe" ]; then return 1 fi PATH="$NODE_HOME:$NODE_HOME/bin:$PATH" export PATH hash -r 2>/dev/null || true if ! node_ok; then return 1 fi USED_PORTABLE=1 echo "Using Node $(node -v) from $NODE_HOME" return 0 } if ! node_ok; then if ! use_portable_node && ! install_portable_node; then echo "" >&2 echo "Could not install a local Node.js runtime automatically." >&2 echo "Install Node.js 18+ from https://nodejs.org, open a new terminal, then re-run:" >&2 echo " curl -fsSL https://scalattice.cloud/install/cli | sh" >&2 exit 1 fi fi NPM_BIN="$(command -v npm)" DEFAULT_PREFIX="$(npm config get prefix 2>/dev/null || true)" abs_bin() { _d="$(dirname "$1")" _f="$(basename "$1")" _d="$(cd "$_d" 2>/dev/null && pwd)" || return 1 printf '%s/%s\n' "$_d" "$_f" } prefix_of() { _d="$(dirname "$1")" cd "$_d/.." 2>/dev/null && pwd } writable_prefix() { _p="$1" [ -n "$_p" ] || return 1 [ -w "$_p" ] || return 1 if [ -e "$_p/bin" ] && [ ! -w "$_p/bin" ]; then return 1 fi return 0 } emit_bin() { [ -x "$1" ] || [ -f "$1" ] || return 0 if is_windows_fs_path "$1"; then return 0 fi abs_bin "$1" 2>/dev/null || printf '%s\n' "$1" } list_old_bins() { OLDIFS="$IFS" IFS=: for d in $PATH; do IFS="$OLDIFS" [ -n "$d" ] || continue emit_bin "$d/scalattice" emit_bin "$d/scalattice.cmd" IFS=: done IFS="$OLDIFS" emit_bin "$HOME/.local/bin/scalattice" emit_bin "$(slt_home)/scalattice" emit_bin "$(slt_home)/scalattice.cmd" if [ -n "$DEFAULT_PREFIX" ]; then emit_bin "$DEFAULT_PREFIX/bin/scalattice" emit_bin "$DEFAULT_PREFIX/scalattice" emit_bin "$DEFAULT_PREFIX/scalattice.cmd" fi if [ -d "${NVM_DIR:-$HOME/.nvm}/versions/node" ]; then for b in "${NVM_DIR:-$HOME/.nvm}"/versions/node/*/bin/scalattice; do emit_bin "$b" done fi } run_sudo() { if is_windows; then "$@" return fi if [ "$(id -u 2>/dev/null || echo 1)" -eq 0 ]; then "$@" return fi if [ -c /dev/tty ]; then sudo "$@" /dev/tty 2>/dev/tty else sudo -n "$@" fi } npm_prefix_arg() { if is_windows; then win_path "$1" else printf '%s\n' "$1" fi } npm_install() { _prefix="$1" _sudo="$2" _prefix_arg="$(npm_prefix_arg "$_prefix")" if [ "$_sudo" = "1" ]; then echo "Installing ${PKG_NAME}@latest to ${_prefix} (sudo)..." run_sudo "$NPM_BIN" install -g ${NPM_FLAGS} --prefix "$_prefix_arg" "${PKG_NAME}@latest" else echo "Installing ${PKG_NAME}@latest to ${_prefix}..." # shellcheck disable=SC2086 "$NPM_BIN" install -g ${NPM_FLAGS} --prefix "$_prefix_arg" "${PKG_NAME}@latest" fi } replace_with() { _src="$1" _dest="$2" _src_abs="$(abs_bin "$_src" 2>/dev/null || printf '%s\n' "$_src")" _dest_abs="$(abs_bin "$_dest" 2>/dev/null || printf '%s\n' "$_dest")" if [ "$_src_abs" = "$_dest_abs" ]; then return 0 fi if is_windows_fs_path "$_dest_abs"; then return 0 fi echo "Replacing $_dest_abs with $_src_abs" if [ -w "$(dirname "$_dest_abs")" ]; then rm -f "$_dest_abs" || return 1 ln -sfn "$_src_abs" "$_dest_abs" || return 1 else run_sudo rm -f "$_dest_abs" || return 1 run_sudo ln -sfn "$_src_abs" "$_dest_abs" || return 1 fi } EXISTING="$(command -v scalattice 2>/dev/null || true)" if [ -n "$EXISTING" ] && [ ! -x "$EXISTING" ] && [ ! -f "$EXISTING" ]; then EXISTING="" fi if [ -n "$EXISTING" ] && is_windows_fs_path "$EXISTING"; then EXISTING="" fi OLD_BINS="$(list_old_bins | sort -u || true)" TARGET_PREFIX="" USE_SUDO=0 if [ "$USED_PORTABLE" = 1 ]; then if is_windows; then TARGET_PREFIX="$(slt_home)" else TARGET_PREFIX="$HOME/.local" fi mkdir -p "$TARGET_PREFIX" elif [ -n "$EXISTING" ]; then EXISTING_PREFIX="$(prefix_of "$EXISTING" || true)" if writable_prefix "$EXISTING_PREFIX" || { ! is_windows && [ "$(id -u 2>/dev/null || echo 1)" -eq 0 ]; }; then TARGET_PREFIX="$EXISTING_PREFIX" fi fi if [ -z "$TARGET_PREFIX" ]; then if writable_prefix "$DEFAULT_PREFIX" || { ! is_windows && [ "$(id -u 2>/dev/null || echo 1)" -eq 0 ]; }; then TARGET_PREFIX="$DEFAULT_PREFIX" else if is_windows; then TARGET_PREFIX="$(slt_home)" else TARGET_PREFIX="$HOME/.local" fi mkdir -p "$TARGET_PREFIX" fi fi if ! npm_install "$TARGET_PREFIX" "$USE_SUDO"; then if [ "$TARGET_PREFIX" != "$HOME/.local" ] && [ "$TARGET_PREFIX" != "$(slt_home)" ]; then echo "Falling back to a user install..." if is_windows; then TARGET_PREFIX="$(slt_home)" else TARGET_PREFIX="$HOME/.local" fi mkdir -p "$TARGET_PREFIX" npm_install "$TARGET_PREFIX" 0 else echo "CLI install failed." >&2 exit 1 fi fi if [ "$USED_PORTABLE" = 1 ]; then if is_windows; then write_win_wrappers "$TARGET_PREFIX" "$NODE_HOME" add_win_user_path "$TARGET_PREFIX" else write_unix_wrapper "$TARGET_PREFIX" "$NODE_HOME" fi fi NEW_BIN="" for _cand in \ "$TARGET_PREFIX/bin/scalattice" \ "$TARGET_PREFIX/scalattice" \ "$TARGET_PREFIX/scalattice.cmd" do if [ -x "$_cand" ] || [ -f "$_cand" ]; then NEW_BIN="$_cand" break fi done if [ -z "$NEW_BIN" ]; then echo "Install finished, but scalattice was not found under $TARGET_PREFIX." >&2 exit 1 fi PATH="$TARGET_PREFIX/bin:$TARGET_PREFIX:$HOME/.local/bin:$PATH" export PATH hash -r 2>/dev/null || true if ! is_windows; then printf '%s\n' "$OLD_BINS" | while IFS= read -r old; do [ -n "$old" ] || continue [ -e "$old" ] || [ -L "$old" ] || continue if is_windows_fs_path "$old"; then continue fi replace_with "$NEW_BIN" "$old" || echo "Could not replace $old" >&2 done tries=0 while [ "$tries" -lt 8 ]; do hash -r 2>/dev/null || true winner="$(command -v scalattice 2>/dev/null || true)" [ -n "$winner" ] || break if is_windows_fs_path "$winner"; then break fi winner_abs="$(abs_bin "$winner" 2>/dev/null || printf '%s\n' "$winner")" new_abs="$(abs_bin "$NEW_BIN" 2>/dev/null || printf '%s\n' "$NEW_BIN")" if [ "$winner_abs" = "$new_abs" ]; then break fi if [ -x "$winner" ] || [ -L "$winner" ]; then replace_with "$NEW_BIN" "$winner" || break else break fi tries=$((tries + 1)) done fi echo "CLI ready: $NEW_BIN" WINNER="$(command -v scalattice 2>/dev/null || true)" if [ -n "$WINNER" ] && ! is_windows; then if is_windows_fs_path "$WINNER"; then WINNER="" fi fi if [ -n "$WINNER" ] && ! is_windows; then winner_abs="$(abs_bin "$WINNER" 2>/dev/null || printf '%s\n' "$WINNER")" new_abs="$(abs_bin "$NEW_BIN" 2>/dev/null || printf '%s\n' "$NEW_BIN")" if [ "$winner_abs" != "$new_abs" ]; then echo "" echo "An older scalattice is still first on PATH: $WINNER" echo "Replacing it needs sudo:" echo " sudo ln -sfn $NEW_BIN $WINNER" fi fi echo "" echo "Next:" echo " scalattice # interactive prompt" echo " eval \"\$(scalattice init)\"" echo " scalattice credits" if is_windows; then echo "" echo "If this Command Prompt still says scalattice is not recognized, open a new one." echo "This install added $(win_path "$TARGET_PREFIX") to your user PATH." fi echo "" echo "Optional MCP (Cursor / Claude Desktop): run \`scalattice mcp\` as the server command." if [ "$ASSUME_YES" -eq 0 ]; then echo "Docs: https://scalattice.cloud/docs/developers#cli" fi