diff --git a/bin/lib/policies.js b/bin/lib/policies.js index 1e3bb8f34..6a9accc4f 100644 --- a/bin/lib/policies.js +++ b/bin/lib/policies.js @@ -6,6 +6,7 @@ const fs = require("fs"); const path = require("path"); const os = require("os"); +const readline = require("readline"); const YAML = require("yaml"); const { ROOT, run, runCapture, shellQuote } = require("./runner"); const registry = require("./registry"); @@ -288,6 +289,53 @@ function getAppliedPresets(sandboxName) { return sandbox ? sandbox.policies || [] : []; } +function selectFromList(items, { applied = [] } = {}) { + return new Promise((resolve) => { + process.stderr.write("\n Available presets:\n"); + items.forEach((item, i) => { + const marker = applied.includes(item.name) ? "●" : "○"; + const description = item.description ? ` — ${item.description}` : ""; + process.stderr.write(` ${i + 1}) ${marker} ${item.name}${description}\n`); + }); + process.stderr.write("\n ● applied, ○ not applied\n\n"); + const defaultIdx = items.findIndex((item) => !applied.includes(item.name)); + const defaultNum = defaultIdx >= 0 ? defaultIdx + 1 : null; + const question = defaultNum ? ` Choose preset [${defaultNum}]: ` : " Choose preset: "; + const rl = readline.createInterface({ input: process.stdin, output: process.stderr }); + rl.question(question, (answer) => { + rl.close(); + if (!process.stdin.isTTY) { + if (typeof process.stdin.pause === "function") process.stdin.pause(); + if (typeof process.stdin.unref === "function") process.stdin.unref(); + } + const trimmed = answer.trim(); + const effectiveInput = trimmed || (defaultNum ? String(defaultNum) : ""); + if (!effectiveInput) { + resolve(null); + return; + } + if (!/^\d+$/.test(effectiveInput)) { + process.stderr.write("\n Invalid preset number.\n"); + resolve(null); + return; + } + const num = Number(effectiveInput); + const item = items[num - 1]; + if (!item) { + process.stderr.write("\n Invalid preset number.\n"); + resolve(null); + return; + } + if (applied.includes(item.name)) { + process.stderr.write(`\n Preset '${item.name}' is already applied.\n`); + resolve(null); + return; + } + resolve(item.name); + }); + }); +} + module.exports = { PRESETS_DIR, listPresets, @@ -300,4 +348,5 @@ module.exports = { mergePresetIntoPolicy, applyPreset, getAppliedPresets, + selectFromList, }; diff --git a/bin/lib/telegram-api.js b/bin/lib/telegram-api.js new file mode 100644 index 000000000..3086677ec --- /dev/null +++ b/bin/lib/telegram-api.js @@ -0,0 +1,77 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** + * Telegram Bot API client with socket timeout protection. + * + * Exported so both the bridge script and tests use the same implementation. + */ + +const https = require("https"); + +const DEFAULT_TIMEOUT_MS = 60000; + +/** + * Call a Telegram Bot API method. + * + * @param {string} token - Bot token from BotFather + * @param {string} method - API method name (e.g. "getUpdates") + * @param {object} body - JSON-serialisable request body + * @param {object} [opts] + * @param {number} [opts.timeout] - socket idle timeout in ms (default 60 000) + * @param {string} [opts.hostname] - override hostname (useful for tests) + * @param {number} [opts.port] - override port (useful for tests) + * @param {boolean} [opts.rejectUnauthorized] - TLS cert check (default true) + * @returns {Promise} parsed JSON response + */ +function tgApi(token, method, body, opts = {}) { + const { + timeout = DEFAULT_TIMEOUT_MS, + hostname = "api.telegram.org", + port, + rejectUnauthorized, + } = opts; + + return new Promise((resolve, reject) => { + let settled = false; + const settle = (fn, value) => { + if (settled) return; + settled = true; + fn(value); + }; + + const data = JSON.stringify(body); + const reqOpts = { + hostname, + path: `/bot${token}/${method}`, + method: "POST", + timeout, + headers: { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(data) }, + }; + if (port != null) reqOpts.port = port; + if (rejectUnauthorized != null) reqOpts.rejectUnauthorized = rejectUnauthorized; + + const req = https.request(reqOpts, (res) => { + let buf = ""; + res.setEncoding("utf8"); + res.on("data", (c) => (buf += c)); + res.on("aborted", () => settle(reject, new Error(`Telegram API ${method} response aborted`))); + res.on("error", (err) => settle(reject, err)); + res.on("end", () => { + try { + settle(resolve, JSON.parse(buf)); + } catch { + settle(resolve, { ok: false, error: buf }); + } + }); + }); + req.on("timeout", () => { + req.destroy(new Error(`Telegram API ${method} timed out`)); + }); + req.on("error", (err) => settle(reject, err)); + req.write(data); + req.end(); + }); +} + +module.exports = { tgApi, DEFAULT_TIMEOUT_MS }; diff --git a/bin/nemoclaw.js b/bin/nemoclaw.js index 63013a67e..8a613c3c1 100755 --- a/bin/nemoclaw.js +++ b/bin/nemoclaw.js @@ -1109,16 +1109,8 @@ async function sandboxPolicyAdd(sandboxName) { const allPresets = policies.listPresets(); const applied = policies.getAppliedPresets(sandboxName); - console.log(""); - console.log(" Available presets:"); - allPresets.forEach((p) => { - const marker = applied.includes(p.name) ? "●" : "○"; - console.log(` ${marker} ${p.name} — ${p.description}`); - }); - console.log(""); - const { prompt: askPrompt } = require("./lib/credentials"); - const answer = await askPrompt(" Preset to apply: "); + const answer = await policies.selectFromList(allPresets, { applied }); if (!answer) return; const confirm = await askPrompt(` Apply '${answer}' to sandbox '${sandboxName}'? [Y/n]: `); diff --git a/install.sh b/install.sh index 113ccc186..fa6ea06a8 100755 --- a/install.sh +++ b/install.sh @@ -2,1002 +2,119 @@ # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # -# NemoClaw installer — installs Node.js, Ollama (if GPU present), and NemoClaw. +# Thin bootstrap for the NemoClaw installer. +# Public curl|bash installs should select a ref once, clone that ref, then +# execute installer logic from that same clone. Historical tags that predate +# the extracted payload fall back to their own root install.sh. set -euo pipefail -# Global cleanup state — ensures background processes are killed and temp files -# are removed on any exit path (set -e, unhandled signal, unexpected error). -_cleanup_pids=() -_cleanup_files=() -_global_cleanup() { - for pid in "${_cleanup_pids[@]:-}"; do - kill "$pid" 2>/dev/null || true - done - for f in "${_cleanup_files[@]:-}"; do - rm -f "$f" 2>/dev/null || true - done -} -trap _global_cleanup EXIT - SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)" -DEFAULT_NEMOCLAW_VERSION="0.1.0" -TOTAL_STEPS=3 - -resolve_installer_version() { - # Prefer git tags (works in dev clones and CI) - if command -v git &>/dev/null && [[ -d "${SCRIPT_DIR}/.git" ]]; then - local git_ver="" - if git_ver="$(git -C "$SCRIPT_DIR" describe --tags --match 'v*' 2>/dev/null)"; then - git_ver="${git_ver#v}" - if [[ -n "$git_ver" ]]; then - printf "%s" "$git_ver" - return - fi - fi - fi - # Fall back to .version file (stamped during install) - if [[ -f "${SCRIPT_DIR}/.version" ]]; then - local file_ver - file_ver="$(cat "${SCRIPT_DIR}/.version")" - if [[ -n "$file_ver" ]]; then - printf "%s" "$file_ver" - return - fi - fi - # Last resort: package.json - local package_json="${SCRIPT_DIR}/package.json" - local version="" - if [[ -f "$package_json" ]]; then - version="$(sed -nE 's/^[[:space:]]*"version":[[:space:]]*"([^"]+)".*/\1/p' "$package_json" | head -1)" - fi - printf "%s" "${version:-$DEFAULT_NEMOCLAW_VERSION}" -} - -NEMOCLAW_VERSION="$(resolve_installer_version)" - -installer_version_for_display() { - if [[ -z "${NEMOCLAW_VERSION:-}" || "${NEMOCLAW_VERSION}" == "${DEFAULT_NEMOCLAW_VERSION}" ]]; then - printf "" - return - fi - printf " v%s" "$NEMOCLAW_VERSION" -} +LOCAL_PAYLOAD="${SCRIPT_DIR}/scripts/install.sh" +BOOTSTRAP_TMPDIR="" +PAYLOAD_MARKER="NEMOCLAW_VERSIONED_INSTALLER_PAYLOAD=1" -# Resolve which Git ref to install from. -# Priority: NEMOCLAW_INSTALL_TAG env var > "latest" tag. resolve_release_tag() { - # Allow explicit override (for CI, pinning, or testing). - # Otherwise default to the "latest" tag, which we maintain to point at - # the commit we want everybody to install. printf "%s" "${NEMOCLAW_INSTALL_TAG:-latest}" } -# --------------------------------------------------------------------------- -# Color / style — disabled when NO_COLOR is set or stdout is not a TTY. -# Uses exact NVIDIA green #76B900 on truecolor terminals; 256-color otherwise. -# --------------------------------------------------------------------------- -if [[ -z "${NO_COLOR:-}" && -t 1 ]]; then - if [[ "${COLORTERM:-}" == "truecolor" || "${COLORTERM:-}" == "24bit" ]]; then - C_GREEN=$'\033[38;2;118;185;0m' # #76B900 — exact NVIDIA green - else - C_GREEN=$'\033[38;5;148m' # closest 256-color on dark backgrounds - fi - C_BOLD=$'\033[1m' - C_DIM=$'\033[2m' - C_RED=$'\033[1;31m' - C_YELLOW=$'\033[1;33m' - C_CYAN=$'\033[1;36m' - C_RESET=$'\033[0m' -else - C_GREEN='' C_BOLD='' C_DIM='' C_RED='' C_YELLOW='' C_CYAN='' C_RESET='' -fi - -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- -info() { printf "${C_CYAN}[INFO]${C_RESET} %s\n" "$*"; } -warn() { printf "${C_YELLOW}[WARN]${C_RESET} %s\n" "$*"; } -error() { - printf "${C_RED}[ERROR]${C_RESET} %s\n" "$*" >&2 - exit 1 -} -ok() { printf " ${C_GREEN}✓${C_RESET} %s\n" "$*"; } - verify_downloaded_script() { - local file="$1" label="${2:-script}" - if [ ! -s "$file" ]; then - error "$label installer download is empty or missing" + local file="$1" label="${2:-installer}" + if [[ ! -s "$file" ]]; then + printf "[ERROR] %s download is empty or missing\n" "$label" >&2 + exit 1 fi if ! head -1 "$file" | grep -qE '^#!.*(sh|bash)'; then - error "$label installer does not start with a shell shebang — possible download corruption" - fi - local hash - if command -v sha256sum >/dev/null 2>&1; then - hash="$(sha256sum "$file" | awk '{print $1}')" - elif command -v shasum >/dev/null 2>&1; then - hash="$(shasum -a 256 "$file" | awk '{print $1}')" - fi - if [ -n "${hash:-}" ]; then - info "$label installer SHA-256: $hash" + printf "[ERROR] %s does not start with a shell shebang\n" "$label" >&2 + exit 1 fi } -resolve_default_sandbox_name() { - local registry_file="${HOME}/.nemoclaw/sandboxes.json" - local sandbox_name="${NEMOCLAW_SANDBOX_NAME:-}" - - if [[ -z "$sandbox_name" && -f "$registry_file" ]] && command_exists node; then - sandbox_name="$( - node -e ' - const fs = require("fs"); - const file = process.argv[1]; - try { - const data = JSON.parse(fs.readFileSync(file, "utf8")); - const sandboxes = data.sandboxes || {}; - const preferred = data.defaultSandbox; - const name = (preferred && sandboxes[preferred] && preferred) || Object.keys(sandboxes)[0] || ""; - process.stdout.write(name); - } catch {} - ' "$registry_file" 2>/dev/null || true - )" - fi - - printf "%s" "${sandbox_name:-my-assistant}" +has_payload_marker() { + local file="$1" + [[ -f "$file" ]] && grep -q "$PAYLOAD_MARKER" "$file" } -# step N "Description" — numbered section header -step() { - local n=$1 msg=$2 - printf "\n${C_GREEN}[%s/%s]${C_RESET} ${C_BOLD}%s${C_RESET}\n" \ - "$n" "$TOTAL_STEPS" "$msg" - printf " ${C_DIM}──────────────────────────────────────────────────${C_RESET}\n" -} +exec_installer_from_ref() { + local ref="$1" + shift -print_banner() { - local version_suffix - version_suffix="$(installer_version_for_display)" - printf "\n" - # ANSI Shadow ASCII art — hand-crafted, no figlet dependency - printf " ${C_GREEN}${C_BOLD} ███╗ ██╗███████╗███╗ ███╗ ██████╗ ██████╗██╗ █████╗ ██╗ ██╗${C_RESET}\n" - printf " ${C_GREEN}${C_BOLD} ████╗ ██║██╔════╝████╗ ████║██╔═══██╗██╔════╝██║ ██╔══██╗██║ ██║${C_RESET}\n" - printf " ${C_GREEN}${C_BOLD} ██╔██╗ ██║█████╗ ██╔████╔██║██║ ██║██║ ██║ ███████║██║ █╗ ██║${C_RESET}\n" - printf " ${C_GREEN}${C_BOLD} ██║╚██╗██║██╔══╝ ██║╚██╔╝██║██║ ██║██║ ██║ ██╔══██║██║███╗██║${C_RESET}\n" - printf " ${C_GREEN}${C_BOLD} ██║ ╚████║███████╗██║ ╚═╝ ██║╚██████╔╝╚██████╗███████╗██║ ██║╚███╔███╔╝${C_RESET}\n" - printf " ${C_GREEN}${C_BOLD} ╚═╝ ╚═══╝╚══════╝╚═╝ ╚═╝ ╚═════╝ ╚═════╝╚══════╝╚═╝ ╚═╝ ╚══╝╚══╝${C_RESET}\n" - printf "\n" - printf " ${C_DIM}Launch OpenClaw in an OpenShell sandbox.%s${C_RESET}\n" "$version_suffix" - printf "\n" -} + local tmpdir source_root payload_script legacy_script + tmpdir="$(mktemp -d)" + BOOTSTRAP_TMPDIR="$tmpdir" + trap 'rm -rf "${BOOTSTRAP_TMPDIR:-}"' EXIT + source_root="${tmpdir}/source" -print_done() { - local elapsed=$((SECONDS - _INSTALL_START)) - local _needs_reload=false - needs_shell_reload && _needs_reload=true + git -c advice.detachedHead=false clone --quiet --depth 1 --branch "$ref" \ + https://github.com/NVIDIA/NemoClaw.git "$source_root" - info "=== Installation complete ===" - printf "\n" - printf " ${C_GREEN}${C_BOLD}NemoClaw${C_RESET} ${C_DIM}(%ss)${C_RESET}\n" "$elapsed" - printf "\n" - if [[ "$ONBOARD_RAN" == true ]]; then - local sandbox_name - sandbox_name="$(resolve_default_sandbox_name)" - printf " ${C_GREEN}Your OpenClaw Sandbox is live.${C_RESET}\n" - printf " ${C_DIM}Sandbox in, break things, and tell us what you find.${C_RESET}\n" - printf "\n" - printf " ${C_GREEN}Next:${C_RESET}\n" - if [[ "$_needs_reload" == true ]]; then - printf " %s$%s source %s\n" "$C_GREEN" "$C_RESET" "$(detect_shell_profile)" - fi - printf " %s$%s nemoclaw %s connect\n" "$C_GREEN" "$C_RESET" "$sandbox_name" - printf " %ssandbox@%s$%s openclaw tui\n" "$C_GREEN" "$sandbox_name" "$C_RESET" - elif [[ "$NEMOCLAW_READY_NOW" == true ]]; then - printf " ${C_GREEN}NemoClaw CLI is installed.${C_RESET}\n" - printf " ${C_DIM}Onboarding has not run yet.${C_RESET}\n" - printf "\n" - printf " ${C_GREEN}Next:${C_RESET}\n" - if [[ "$_needs_reload" == true ]]; then - printf " %s$%s source %s\n" "$C_GREEN" "$C_RESET" "$(detect_shell_profile)" - fi - printf " %s$%s nemoclaw onboard\n" "$C_GREEN" "$C_RESET" - else - printf " ${C_GREEN}NemoClaw CLI is installed.${C_RESET}\n" - printf " ${C_DIM}Onboarding did not run because this shell cannot resolve 'nemoclaw' yet.${C_RESET}\n" - printf "\n" - printf " ${C_GREEN}Next:${C_RESET}\n" - if [[ -n "$NEMOCLAW_RECOVERY_EXPORT_DIR" ]]; then - printf " %s$%s export PATH=\"%s:\$PATH\"\n" "$C_GREEN" "$C_RESET" "$NEMOCLAW_RECOVERY_EXPORT_DIR" - fi - if [[ -n "$NEMOCLAW_RECOVERY_PROFILE" ]]; then - printf " %s$%s source %s\n" "$C_GREEN" "$C_RESET" "$NEMOCLAW_RECOVERY_PROFILE" - fi - printf " %s$%s nemoclaw onboard\n" "$C_GREEN" "$C_RESET" + payload_script="${source_root}/scripts/install.sh" + legacy_script="${source_root}/install.sh" + + if has_payload_marker "$payload_script"; then + verify_downloaded_script "$payload_script" "versioned installer" + NEMOCLAW_INSTALL_REF="$ref" NEMOCLAW_INSTALL_TAG="$ref" NEMOCLAW_REPO_ROOT="$source_root" \ + bash "$payload_script" "$@" + return fi - printf "\n" - printf " ${C_BOLD}GitHub${C_RESET} ${C_DIM}https://github.com/nvidia/nemoclaw${C_RESET}\n" - printf " ${C_BOLD}Docs${C_RESET} ${C_DIM}https://docs.nvidia.com/nemoclaw/latest/${C_RESET}\n" - printf "\n" + + verify_downloaded_script "$legacy_script" "legacy installer" + NEMOCLAW_INSTALL_TAG="$ref" bash "$legacy_script" "$@" +} + +bootstrap_version() { + printf "nemoclaw-installer\n" } -usage() { - local version_suffix - version_suffix="$(installer_version_for_display)" +bootstrap_usage() { printf "\n" - printf " ${C_BOLD}NemoClaw Installer${C_RESET}${C_DIM}%s${C_RESET}\n\n" "$version_suffix" - printf " ${C_DIM}Usage:${C_RESET}\n" + printf " NemoClaw Installer\n\n" + printf " Usage:\n" printf " curl -fsSL https://www.nvidia.com/nemoclaw.sh | bash\n" printf " curl -fsSL https://www.nvidia.com/nemoclaw.sh | bash -s -- [options]\n\n" - printf " ${C_DIM}Options:${C_RESET}\n" + printf " Options:\n" printf " --non-interactive Skip prompts (uses env vars / defaults)\n" printf " --yes-i-accept-third-party-software Accept the third-party software notice in non-interactive mode\n" printf " --version, -v Print installer version and exit\n" printf " --help, -h Show this help message and exit\n\n" - printf " ${C_DIM}Environment:${C_RESET}\n" - printf " NVIDIA_API_KEY API key (skips credential prompt)\n" - printf " NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE=1 Same as --yes-i-accept-third-party-software\n" - printf " NEMOCLAW_NON_INTERACTIVE=1 Same as --non-interactive\n" - printf " NEMOCLAW_SANDBOX_NAME Sandbox name to create/use\n" - printf " NEMOCLAW_RECREATE_SANDBOX=1 Recreate an existing sandbox\n" + printf " Environment:\n" printf " NEMOCLAW_INSTALL_TAG Git ref to install (default: latest release)\n" - printf " NEMOCLAW_PROVIDER cloud | ollama | nim | vllm\n" - printf " NEMOCLAW_MODEL Inference model to configure\n" - printf " NEMOCLAW_POLICY_MODE suggested | custom | skip\n" - printf " NEMOCLAW_POLICY_PRESETS Comma-separated policy presets\n" - printf " NEMOCLAW_EXPERIMENTAL=1 Show experimental/local options\n" - printf " CHAT_UI_URL Chat UI URL to open after setup\n" - printf " DISCORD_BOT_TOKEN Auto-enable Discord policy support\n" - printf " SLACK_BOT_TOKEN Auto-enable Slack policy support\n" - printf " TELEGRAM_BOT_TOKEN Auto-enable Telegram policy support\n" + printf " NEMOCLAW_NON_INTERACTIVE=1 Same as --non-interactive\n" + printf " NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE=1 Same as --yes-i-accept-third-party-software\n" + printf " NEMOCLAW_SANDBOX_NAME Sandbox name to create/use\n" + printf " NEMOCLAW_PROVIDER cloud | ollama | nim | vllm\n" + printf " NEMOCLAW_POLICY_MODE suggested | custom | skip\n" printf "\n" } -show_usage_notice() { - local source_root="${NEMOCLAW_SOURCE_ROOT:-$SCRIPT_DIR}" - local notice_script="${source_root}/bin/lib/usage-notice.js" - if [[ ! -f "$notice_script" ]]; then - notice_script="${SCRIPT_DIR}/bin/lib/usage-notice.js" - fi - local -a notice_cmd=(node "$notice_script") - if [ "${NON_INTERACTIVE:-}" = "1" ]; then - notice_cmd+=(--non-interactive) - if [ "${ACCEPT_THIRD_PARTY_SOFTWARE:-}" = "1" ]; then - notice_cmd+=(--yes-i-accept-third-party-software) - fi - "${notice_cmd[@]}" - elif [ -t 0 ]; then - "${notice_cmd[@]}" - elif exec 3"$log" 2>&1 & - local pid=$! i=0 - local frames=('⠋' '⠙' '⠹' '⠸' '⠼' '⠴' '⠦' '⠧' '⠇' '⠏') - - # Register with global cleanup so any exit path reaps the child and temp file. - _cleanup_pids+=("$pid") - _cleanup_files+=("$log") - - # Ensure Ctrl+C kills the background process and cleans up the temp file. - trap 'kill "$pid" 2>/dev/null; rm -f "$log"; exit 130' INT TERM - - while kill -0 "$pid" 2>/dev/null; do - printf "\r ${C_GREEN}%s${C_RESET} %s" "${frames[$((i++ % 10))]}" "$msg" - sleep 0.08 - done - - # Restore default signal handling after the background process exits. - trap - INT TERM - - if wait "$pid"; then - local status=0 - else - local status=$? - fi - - if [[ $status -eq 0 ]]; then - printf "\r ${C_GREEN}✓${C_RESET} %s\n" "$msg" - else - printf "\r ${C_RED}✗${C_RESET} %s\n\n" "$msg" - cat "$log" >&2 - printf "\n" - fi - rm -f "$log" - - # Deregister only after cleanup actions are complete, so the global EXIT - # trap still covers this pid/log if a signal arrives before this point. - _cleanup_pids=("${_cleanup_pids[@]/$pid/}") - _cleanup_files=("${_cleanup_files[@]/$log/}") - return $status -} - -command_exists() { command -v "$1" &>/dev/null; } - -MIN_NODE_VERSION="22.16.0" -MIN_NPM_MAJOR=10 -RUNTIME_REQUIREMENT_MSG="NemoClaw requires Node.js >=${MIN_NODE_VERSION} and npm >=${MIN_NPM_MAJOR}." -NEMOCLAW_SHIM_DIR="${HOME}/.local/bin" -ORIGINAL_PATH="${PATH:-}" -NEMOCLAW_READY_NOW=false -NEMOCLAW_RECOVERY_PROFILE="" -NEMOCLAW_RECOVERY_EXPORT_DIR="" -NEMOCLAW_SOURCE_ROOT="$SCRIPT_DIR" -ONBOARD_RAN=false - -# Compare two semver strings (major.minor.patch). Returns 0 if $1 >= $2. -# Rejects prerelease suffixes (e.g. "22.16.0-rc.1") to avoid arithmetic errors. -version_gte() { - [[ "$1" =~ ^[0-9]+(\.[0-9]+){0,2}$ ]] || return 1 - [[ "$2" =~ ^[0-9]+(\.[0-9]+){0,2}$ ]] || return 1 - local -a a b - IFS=. read -ra a <<<"$1" - IFS=. read -ra b <<<"$2" - for i in 0 1 2; do - local ai=${a[$i]:-0} bi=${b[$i]:-0} - if ((ai > bi)); then return 0; fi - if ((ai < bi)); then return 1; fi - done - return 0 -} - -# Ensure nvm environment is loaded in the current shell. -# Skip if node is already on PATH — sourcing nvm.sh can reset PATH and -# override the caller's node/npm (e.g. in test environments with stubs). -# Pass --force to load nvm even when node is on PATH (needed when upgrading). -ensure_nvm_loaded() { - if [[ "${1:-}" != "--force" ]]; then - command -v node &>/dev/null && return 0 - fi - if [[ -z "${NVM_DIR:-}" ]]; then - export NVM_DIR="$HOME/.nvm" - fi - if [[ -s "$NVM_DIR/nvm.sh" ]]; then - \. "$NVM_DIR/nvm.sh" - fi -} - -detect_shell_profile() { - local profile="$HOME/.bashrc" - case "$(basename "${SHELL:-}")" in - zsh) - profile="$HOME/.zshrc" - ;; - fish) - profile="$HOME/.config/fish/config.fish" - ;; - tcsh) - profile="$HOME/.tcshrc" - ;; - csh) - profile="$HOME/.cshrc" - ;; - *) - if [[ ! -f "$HOME/.bashrc" && -f "$HOME/.profile" ]]; then - profile="$HOME/.profile" - fi - ;; - esac - printf "%s" "$profile" -} - -# Refresh PATH so that npm global bin is discoverable. -# After nvm installs Node.js the global bin lives under the nvm prefix, -# which may not yet be on PATH in the current session. -refresh_path() { - ensure_nvm_loaded - - local npm_bin - npm_bin="$(npm config get prefix 2>/dev/null)/bin" || true - if [[ -n "$npm_bin" && -d "$npm_bin" && ":$PATH:" != *":$npm_bin:"* ]]; then - export PATH="$npm_bin:$PATH" - fi - - if [[ -d "$NEMOCLAW_SHIM_DIR" && ":$PATH:" != *":$NEMOCLAW_SHIM_DIR:"* ]]; then - export PATH="$NEMOCLAW_SHIM_DIR:$PATH" - fi -} - -ensure_nemoclaw_shim() { - local npm_bin shim_path - npm_bin="$(npm config get prefix 2>/dev/null)/bin" || true - shim_path="${NEMOCLAW_SHIM_DIR}/nemoclaw" - - if [[ -z "$npm_bin" || ! -x "$npm_bin/nemoclaw" ]]; then - return 1 - fi - - if [[ ":$ORIGINAL_PATH:" == *":$npm_bin:"* ]] || [[ ":$ORIGINAL_PATH:" == *":$NEMOCLAW_SHIM_DIR:"* ]]; then - return 0 - fi - - mkdir -p "$NEMOCLAW_SHIM_DIR" - ln -sfn "$npm_bin/nemoclaw" "$shim_path" - refresh_path - ensure_local_bin_in_profile - info "Created user-local shim at $shim_path" - return 0 -} - -# Detect whether the parent shell likely needs a reload after install. -# When running via `curl | bash`, the installer executes in a subprocess. -# Even when the bin directory is already in PATH, the parent shell may have -# stale bash hash-table entries pointing to a previously deleted binary -# (e.g. upgrade/reinstall after `rm $(which nemoclaw)`). Sourcing the -# shell profile reassigns PATH which clears the hash table, so we always -# recommend it when the installer verified nemoclaw in the subprocess. -needs_shell_reload() { - [[ "$NEMOCLAW_READY_NOW" != true ]] && return 1 - return 0 -} - -# Add ~/.local/bin (and for fish, the nvm node bin) to the user's shell -# profile PATH so that nemoclaw, openshell, and any future tools installed -# there are discoverable in new terminal sessions. -# Idempotent — skips if the marker comment is already present. -ensure_local_bin_in_profile() { - local profile - profile="$(detect_shell_profile)" - [[ -n "$profile" ]] || return 0 - - # Already present — nothing to do. - if [[ -f "$profile" ]] && grep -qF '# NemoClaw PATH setup' "$profile" 2>/dev/null; then - return 0 - fi - - local shell_name - shell_name="$(basename "${SHELL:-bash}")" - - local local_bin="$NEMOCLAW_SHIM_DIR" - - case "$shell_name" in - fish) - # fish needs both ~/.local/bin and the nvm node bin (nvm doesn't support fish). - local node_bin="" - node_bin="$(command -v node 2>/dev/null)" || true - if [[ -n "$node_bin" ]]; then - node_bin="$(dirname "$node_bin")" - fi - { - printf '\n# NemoClaw PATH setup\n' - printf 'fish_add_path --path --append "%s"\n' "$local_bin" - if [[ -n "$node_bin" ]]; then - printf 'fish_add_path --path --append "%s"\n' "$node_bin" - fi - printf '# end NemoClaw PATH setup\n' - } >>"$profile" - ;; - tcsh | csh) - { - printf '\n# NemoClaw PATH setup\n' - # shellcheck disable=SC2016 - printf 'setenv PATH "%s:${PATH}"\n' "$local_bin" - printf '# end NemoClaw PATH setup\n' - } >>"$profile" - ;; - *) - # bash, zsh, and others — nvm already handles node PATH for these shells. - { - printf '\n# NemoClaw PATH setup\n' - # shellcheck disable=SC2016 - printf 'export PATH="%s:$PATH"\n' "$local_bin" - printf '# end NemoClaw PATH setup\n' - } >>"$profile" - ;; - esac -} - -version_major() { - printf '%s\n' "${1#v}" | cut -d. -f1 -} - -ensure_supported_runtime() { - command_exists node || error "${RUNTIME_REQUIREMENT_MSG} Node.js was not found on PATH." - command_exists npm || error "${RUNTIME_REQUIREMENT_MSG} npm was not found on PATH." - - local node_version npm_version node_major npm_major - node_version="$(node --version 2>/dev/null || true)" - npm_version="$(npm --version 2>/dev/null || true)" - node_major="$(version_major "$node_version")" - npm_major="$(version_major "$npm_version")" - - [[ "$node_major" =~ ^[0-9]+$ ]] || error "Could not determine Node.js version from '${node_version}'. ${RUNTIME_REQUIREMENT_MSG}" - [[ "$npm_major" =~ ^[0-9]+$ ]] || error "Could not determine npm version from '${npm_version}'. ${RUNTIME_REQUIREMENT_MSG}" - - if ! version_gte "${node_version#v}" "$MIN_NODE_VERSION" || ((npm_major < MIN_NPM_MAJOR)); then - error "Unsupported runtime detected: Node.js ${node_version:-unknown}, npm ${npm_version:-unknown}. ${RUNTIME_REQUIREMENT_MSG} Upgrade Node.js and rerun the installer." - fi - - info "Runtime OK: Node.js ${node_version}, npm ${npm_version}" -} - -# --------------------------------------------------------------------------- -# 1. Node.js -# --------------------------------------------------------------------------- -install_nodejs() { - if command_exists node; then - local current_version current_npm_major - current_version="$(node --version 2>/dev/null || true)" - current_npm_major="$(version_major "$(npm --version 2>/dev/null || echo 0)")" - if version_gte "${current_version#v}" "$MIN_NODE_VERSION" \ - && [[ "$current_npm_major" =~ ^[0-9]+$ ]] \ - && ((current_npm_major >= MIN_NPM_MAJOR)); then - info "Node.js found: ${current_version}" - return - fi - warn "Node.js ${current_version}, npm major ${current_npm_major:-unknown} found but NemoClaw requires Node.js >=${MIN_NODE_VERSION} and npm >=${MIN_NPM_MAJOR} — upgrading via nvm…" - else - info "Node.js not found — installing via nvm…" - fi - # IMPORTANT: update NVM_SHA256 when changing NVM_VERSION - local NVM_VERSION="v0.40.4" - local NVM_SHA256="4b7412c49960c7d31e8df72da90c1fb5b8cccb419ac99537b737028d497aba4f" - local nvm_tmp - nvm_tmp="$(mktemp)" - curl -fsSL "https://raw.githubusercontent.com/nvm-sh/nvm/${NVM_VERSION}/install.sh" -o "$nvm_tmp" \ - || { - rm -f "$nvm_tmp" - error "Failed to download nvm installer" - } - local actual_hash - if command_exists sha256sum; then - actual_hash="$(sha256sum "$nvm_tmp" | awk '{print $1}')" - elif command_exists shasum; then - actual_hash="$(shasum -a 256 "$nvm_tmp" | awk '{print $1}')" - else - warn "No SHA-256 tool found — skipping nvm integrity check" - actual_hash="$NVM_SHA256" # allow execution - fi - if [[ "$actual_hash" != "$NVM_SHA256" ]]; then - rm -f "$nvm_tmp" - error "nvm installer integrity check failed\n Expected: $NVM_SHA256\n Actual: $actual_hash" - fi - info "nvm installer integrity verified" - spin "Installing nvm..." bash "$nvm_tmp" - rm -f "$nvm_tmp" - ensure_nvm_loaded --force - spin "Installing Node.js 22..." bash -c ". \"$NVM_DIR/nvm.sh\" && nvm install 22 --no-progress" - ensure_nvm_loaded --force - nvm use 22 --silent - nvm alias default 22 2>/dev/null || true - info "Node.js installed: $(node --version)" -} - -# --------------------------------------------------------------------------- -# 2. Ollama -# --------------------------------------------------------------------------- -OLLAMA_MIN_VERSION="0.18.0" - -get_ollama_version() { - # `ollama --version` outputs something like "ollama version 0.18.0" - ollama --version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1 -} - -detect_gpu() { - # Returns 0 if a GPU is detected - if command_exists nvidia-smi; then - nvidia-smi &>/dev/null && return 0 - fi - return 1 -} - -get_vram_mb() { - # Returns total VRAM in MiB (NVIDIA only). Falls back to 0. - if command_exists nvidia-smi; then - nvidia-smi --query-gpu=memory.total --format=csv,noheader,nounits 2>/dev/null \ - | awk '{s += $1} END {print s+0}' - return - fi - # macOS — report unified memory as VRAM - if [[ "$(uname -s)" == "Darwin" ]] && command_exists sysctl; then - local bytes - bytes=$(sysctl -n hw.memsize 2>/dev/null || echo 0) - echo $((bytes / 1024 / 1024)) - return - fi - echo 0 -} - -install_or_upgrade_ollama() { - if detect_gpu && command_exists ollama; then - local current - current=$(get_ollama_version) - if [[ -n "$current" ]] && version_gte "$current" "$OLLAMA_MIN_VERSION"; then - info "Ollama v${current} meets minimum requirement (>= v${OLLAMA_MIN_VERSION})" - else - info "Ollama v${current:-unknown} is below v${OLLAMA_MIN_VERSION} — upgrading…" - ( - tmpdir="$(mktemp -d)" - trap 'rm -rf "$tmpdir"' EXIT - curl -fsSL https://ollama.com/install.sh -o "$tmpdir/install_ollama.sh" - verify_downloaded_script "$tmpdir/install_ollama.sh" "Ollama" - sh "$tmpdir/install_ollama.sh" - ) - info "Ollama upgraded to $(get_ollama_version)" - fi - else - # No ollama — only install if a GPU is present - if detect_gpu; then - info "GPU detected — installing Ollama…" - ( - tmpdir="$(mktemp -d)" - trap 'rm -rf "$tmpdir"' EXIT - curl -fsSL https://ollama.com/install.sh -o "$tmpdir/install_ollama.sh" - verify_downloaded_script "$tmpdir/install_ollama.sh" "Ollama" - sh "$tmpdir/install_ollama.sh" - ) - info "Ollama installed: v$(get_ollama_version)" - else - warn "No GPU detected — skipping Ollama installation." - return - fi - fi - - # Pull the appropriate model based on VRAM - local vram_mb - vram_mb=$(get_vram_mb) - local vram_gb=$((vram_mb / 1024)) - info "Detected ${vram_gb} GB VRAM" - - if ((vram_gb >= 120)); then - info "Pulling nemotron-3-super:120b…" - ollama pull nemotron-3-super:120b - else - info "Pulling nemotron-3-nano:30b…" - ollama pull nemotron-3-nano:30b - fi -} - -# --------------------------------------------------------------------------- -# Fix npm permissions for global installs (Linux only). -# If the npm global prefix points to a system directory (e.g. /usr or -# /usr/local) the user likely lacks write permissions and npm link will fail -# with EACCES. Redirect the prefix to ~/.npm-global so the install succeeds -# without sudo. -# --------------------------------------------------------------------------- -fix_npm_permissions() { - if [[ "$(uname -s)" != "Linux" ]]; then - return 0 - fi - - local npm_prefix - npm_prefix="$(npm config get prefix 2>/dev/null || true)" - if [[ -z "$npm_prefix" ]]; then - return 0 - fi - - if [[ -w "$npm_prefix" || -w "$npm_prefix/lib" ]]; then - return 0 - fi - - info "npm global prefix '${npm_prefix}' is not writable — configuring user-local installs" - mkdir -p "$HOME/.npm-global" - npm config set prefix "$HOME/.npm-global" - - # shellcheck disable=SC2016 - local path_line='export PATH="$HOME/.npm-global/bin:$PATH"' - for rc in "$HOME/.bashrc" "$HOME/.zshrc"; do - if [[ -f "$rc" ]] && ! grep -q ".npm-global" "$rc"; then - printf '\n# Added by NemoClaw installer\n%s\n' "$path_line" >>"$rc" - fi - done - - export PATH="$HOME/.npm-global/bin:$PATH" - ok "npm configured for user-local installs (~/.npm-global)" -} - -# --------------------------------------------------------------------------- -# 3. NemoClaw -# --------------------------------------------------------------------------- -# Work around openclaw tarball missing directory entries (GH-503). -# npm's tar extractor hard-fails because the tarball is missing directory -# entries for extensions/, skills/, and dist/plugin-sdk/config/. System tar -# handles this fine. We pre-extract openclaw into node_modules BEFORE npm -# install so npm sees the dependency is already satisfied and skips it. -pre_extract_openclaw() { - local install_dir="$1" - local openclaw_version - openclaw_version="$(resolve_openclaw_version "$install_dir")" - - if [[ -z "$openclaw_version" ]]; then - warn "Could not determine openclaw version — skipping pre-extraction" - return 1 - fi - - info "Pre-extracting openclaw@${openclaw_version} with system tar (GH-503 workaround)…" - local tmpdir - tmpdir="$(mktemp -d)" - if npm pack "openclaw@${openclaw_version}" --pack-destination "$tmpdir" >/dev/null 2>&1; then - local tgz - tgz="$(find "$tmpdir" -maxdepth 1 -name 'openclaw-*.tgz' -print -quit)" - if [[ -n "$tgz" && -f "$tgz" ]]; then - if mkdir -p "${install_dir}/node_modules/openclaw" \ - && tar xzf "$tgz" -C "${install_dir}/node_modules/openclaw" --strip-components=1; then - info "openclaw pre-extracted successfully" - else - warn "Failed to extract openclaw tarball" - rm -rf "$tmpdir" - return 1 - fi - else - warn "npm pack succeeded but tarball not found" - rm -rf "$tmpdir" - return 1 - fi - else - warn "Failed to download openclaw tarball" - rm -rf "$tmpdir" - return 1 - fi - rm -rf "$tmpdir" -} - -resolve_openclaw_version() { - local install_dir="$1" - local package_json dockerfile_base resolved_version - - package_json="${install_dir}/package.json" - dockerfile_base="${install_dir}/Dockerfile.base" - - if [[ -f "$package_json" ]]; then - resolved_version="$( - node -e "const v = require('${package_json}').dependencies?.openclaw; if (v) console.log(v)" \ - 2>/dev/null || true - )" - if [[ -n "$resolved_version" ]]; then - printf '%s\n' "$resolved_version" - return 0 - fi - fi - - if [[ -f "$dockerfile_base" ]]; then - awk ' - match($0, /openclaw@[0-9][0-9.]+/) { - print substr($0, RSTART + 9, RLENGTH - 9) - exit - } - ' "$dockerfile_base" - fi -} - -install_nemoclaw() { - command_exists git || error "git was not found on PATH." - if [[ -f "./package.json" ]] && grep -q '"name": "nemoclaw"' ./package.json 2>/dev/null; then - info "NemoClaw package.json found in current directory — installing from source…" - NEMOCLAW_SOURCE_ROOT="$(pwd)" - spin "Preparing OpenClaw package" bash -c "$(declare -f info warn resolve_openclaw_version pre_extract_openclaw); pre_extract_openclaw \"\$1\"" _ "$(pwd)" \ - || warn "Pre-extraction failed — npm install may fail if openclaw tarball is broken" - spin "Installing NemoClaw dependencies" npm install --ignore-scripts - spin "Building NemoClaw CLI modules" npm run --if-present build:cli - spin "Building NemoClaw plugin" bash -c 'cd nemoclaw && npm install --ignore-scripts && npm run build' - spin "Linking NemoClaw CLI" npm link - else - info "Installing NemoClaw from GitHub…" - # Resolve the latest release tag so we never install raw main. - local release_ref - release_ref="$(resolve_release_tag)" - info "Resolved install ref: ${release_ref}" - # Clone first so we can pre-extract openclaw before npm install (GH-503). - # npm install -g git+https://... does this internally but we can't hook - # into its extraction pipeline, so we do it ourselves. - local nemoclaw_src="${HOME}/.nemoclaw/source" - rm -rf "$nemoclaw_src" - mkdir -p "$(dirname "$nemoclaw_src")" - NEMOCLAW_SOURCE_ROOT="$nemoclaw_src" - spin "Cloning NemoClaw source" git clone --depth 1 --branch "$release_ref" https://github.com/NVIDIA/NemoClaw.git "$nemoclaw_src" - # Fetch version tags into the shallow clone so `git describe --tags - # --match "v*"` works at runtime (the shallow clone only has the - # single ref we asked for). - git -C "$nemoclaw_src" fetch --depth=1 origin 'refs/tags/v*:refs/tags/v*' 2>/dev/null || true - # Also stamp .version as a fallback for environments where git is - # unavailable or tags are pruned later. - git -C "$nemoclaw_src" describe --tags --match 'v*' 2>/dev/null \ - | sed 's/^v//' >"$nemoclaw_src/.version" || true - spin "Preparing OpenClaw package" bash -c "$(declare -f info warn resolve_openclaw_version pre_extract_openclaw); pre_extract_openclaw \"\$1\"" _ "$nemoclaw_src" \ - || warn "Pre-extraction failed — npm install may fail if openclaw tarball is broken" - spin "Installing NemoClaw dependencies" bash -c "cd \"$nemoclaw_src\" && npm install --ignore-scripts" - spin "Building NemoClaw CLI modules" bash -c "cd \"$nemoclaw_src\" && npm run --if-present build:cli" - spin "Building NemoClaw plugin" bash -c "cd \"$nemoclaw_src\"/nemoclaw && npm install --ignore-scripts && npm run build" - spin "Linking NemoClaw CLI" bash -c "cd \"$nemoclaw_src\" && npm link" - fi - - refresh_path - ensure_nemoclaw_shim || true -} - -# --------------------------------------------------------------------------- -# 4. Verify -# --------------------------------------------------------------------------- -verify_nemoclaw() { - if command_exists nemoclaw; then - NEMOCLAW_READY_NOW=true - ensure_nemoclaw_shim || true - info "Verified: nemoclaw is available at $(command -v nemoclaw)" - return 0 - fi - - local npm_bin - npm_bin="$(npm config get prefix 2>/dev/null)/bin" || true - - if [[ -n "$npm_bin" && -x "$npm_bin/nemoclaw" ]]; then - ensure_nemoclaw_shim || true - if command_exists nemoclaw; then - NEMOCLAW_READY_NOW=true - info "Verified: nemoclaw is available at $(command -v nemoclaw)" - return 0 - fi - - NEMOCLAW_RECOVERY_PROFILE="$(detect_shell_profile)" - if [[ -x "$NEMOCLAW_SHIM_DIR/nemoclaw" ]]; then - NEMOCLAW_RECOVERY_EXPORT_DIR="$NEMOCLAW_SHIM_DIR" - else - NEMOCLAW_RECOVERY_EXPORT_DIR="$npm_bin" - fi - warn "Found nemoclaw at $npm_bin/nemoclaw but this shell still cannot resolve it." - warn "Onboarding will be skipped until PATH is updated." - return 0 - else - warn "Could not locate the nemoclaw executable." - warn "Try running: npm install -g git+https://github.com/NVIDIA/NemoClaw.git" - fi - - error "Installation failed: nemoclaw binary not found." -} - -# --------------------------------------------------------------------------- -# 5. Onboard -# --------------------------------------------------------------------------- -run_onboard() { - show_usage_notice - info "Running nemoclaw onboard…" - local -a onboard_cmd=(onboard) - if command_exists node && [[ -f "${HOME}/.nemoclaw/onboard-session.json" ]]; then - if node -e ' - const fs = require("fs"); - const file = process.argv[1]; - try { - const data = JSON.parse(fs.readFileSync(file, "utf8")); - const resumable = data && data.resumable !== false; - const status = data && data.status; - process.exit(resumable && status && status !== "complete" ? 0 : 1); - } catch { - process.exit(1); - } - ' "${HOME}/.nemoclaw/onboard-session.json"; then - info "Found an interrupted onboarding session — resuming it." - onboard_cmd+=(--resume) - fi - fi - if [ "${NON_INTERACTIVE:-}" = "1" ]; then - onboard_cmd+=(--non-interactive) - if [ "${ACCEPT_THIRD_PARTY_SOFTWARE:-}" = "1" ]; then - onboard_cmd+=(--yes-i-accept-third-party-software) - fi - nemoclaw "${onboard_cmd[@]}" - elif [ -t 0 ]; then - nemoclaw "${onboard_cmd[@]}" - elif exec 3/dev/null || true + done + for f in "${_cleanup_files[@]:-}"; do + rm -f "$f" 2>/dev/null || true + done +} +trap _global_cleanup EXIT + SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)" -ROOT_INSTALLER="${SCRIPT_DIR%/scripts}/install.sh" -warn_legacy_path() { - cat >&2 </dev/null && [[ -d "${repo_root}/.git" ]]; then + local git_ver="" + if git_ver="$(git -C "$repo_root" describe --tags --match 'v*' 2>/dev/null)"; then + git_ver="${git_ver#v}" + if [[ -n "$git_ver" ]]; then + printf "%s" "$git_ver" + return + fi + fi + fi + # Fall back to .version file (stamped during install) + if [[ -f "${repo_root}/.version" ]]; then + local file_ver + file_ver="$(cat "${repo_root}/.version")" + if [[ -n "$file_ver" ]]; then + printf "%s" "$file_ver" + return + fi + fi + # Last resort: package.json + local package_json="${repo_root}/package.json" + local version="" + if [[ -f "$package_json" ]]; then + version="$(sed -nE 's/^[[:space:]]*"version":[[:space:]]*"([^"]+)".*/\1/p' "$package_json" | head -1)" + fi + printf "%s" "${version:-$DEFAULT_NEMOCLAW_VERSION}" } -warn_legacy_path +NEMOCLAW_VERSION="$(resolve_installer_version)" -if [[ ! -f "$ROOT_INSTALLER" ]]; then - cat <&2 -[install] scripts/install.sh only works from a NemoClaw repository checkout. -[install] supported installer: ${ROOT_INSTALLER_URL} -EOF - exit 1 +installer_version_for_display() { + if [[ -z "${NEMOCLAW_VERSION:-}" || "${NEMOCLAW_VERSION}" == "${DEFAULT_NEMOCLAW_VERSION}" ]]; then + printf "" + return + fi + printf " v%s" "$NEMOCLAW_VERSION" +} + +# Resolve which Git ref to install from. +# Priority: NEMOCLAW_INSTALL_TAG env var > "latest" tag. +resolve_release_tag() { + if [[ -n "${NEMOCLAW_INSTALL_REF:-}" ]]; then + printf "%s" "${NEMOCLAW_INSTALL_REF}" + return + fi + # Allow explicit override (for CI, pinning, or testing). + # Otherwise default to the "latest" tag, which we maintain to point at + # the commit we want everybody to install. + printf "%s" "${NEMOCLAW_INSTALL_TAG:-latest}" +} + +# --------------------------------------------------------------------------- +# Color / style — disabled when NO_COLOR is set or stdout is not a TTY. +# Uses exact NVIDIA green #76B900 on truecolor terminals; 256-color otherwise. +# --------------------------------------------------------------------------- +if [[ -z "${NO_COLOR:-}" && -t 1 ]]; then + if [[ "${COLORTERM:-}" == "truecolor" || "${COLORTERM:-}" == "24bit" ]]; then + C_GREEN=$'\033[38;2;118;185;0m' # #76B900 — exact NVIDIA green + else + C_GREEN=$'\033[38;5;148m' # closest 256-color on dark backgrounds + fi + C_BOLD=$'\033[1m' + C_DIM=$'\033[2m' + C_RED=$'\033[1;31m' + C_YELLOW=$'\033[1;33m' + C_CYAN=$'\033[1;36m' + C_RESET=$'\033[0m' +else + C_GREEN='' C_BOLD='' C_DIM='' C_RED='' C_YELLOW='' C_CYAN='' C_RESET='' fi -exec bash "$ROOT_INSTALLER" "$@" +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- +info() { printf "${C_CYAN}[INFO]${C_RESET} %s\n" "$*"; } +warn() { printf "${C_YELLOW}[WARN]${C_RESET} %s\n" "$*"; } +error() { + printf "${C_RED}[ERROR]${C_RESET} %s\n" "$*" >&2 + exit 1 +} +ok() { printf " ${C_GREEN}✓${C_RESET} %s\n" "$*"; } + +verify_downloaded_script() { + local file="$1" label="${2:-script}" + if [ ! -s "$file" ]; then + error "$label installer download is empty or missing" + fi + if ! head -1 "$file" | grep -qE '^#!.*(sh|bash)'; then + error "$label installer does not start with a shell shebang — possible download corruption" + fi + local hash + if command -v sha256sum >/dev/null 2>&1; then + hash="$(sha256sum "$file" | awk '{print $1}')" + elif command -v shasum >/dev/null 2>&1; then + hash="$(shasum -a 256 "$file" | awk '{print $1}')" + fi + if [ -n "${hash:-}" ]; then + info "$label installer SHA-256: $hash" + fi +} + +resolve_default_sandbox_name() { + local registry_file="${HOME}/.nemoclaw/sandboxes.json" + local sandbox_name="${NEMOCLAW_SANDBOX_NAME:-}" + + if [[ -z "$sandbox_name" && -f "$registry_file" ]] && command_exists node; then + sandbox_name="$( + node -e ' + const fs = require("fs"); + const file = process.argv[1]; + try { + const data = JSON.parse(fs.readFileSync(file, "utf8")); + const sandboxes = data.sandboxes || {}; + const preferred = data.defaultSandbox; + const name = (preferred && sandboxes[preferred] && preferred) || Object.keys(sandboxes)[0] || ""; + process.stdout.write(name); + } catch {} + ' "$registry_file" 2>/dev/null || true + )" + fi + + printf "%s" "${sandbox_name:-my-assistant}" +} + +# step N "Description" — numbered section header +step() { + local n=$1 msg=$2 + printf "\n${C_GREEN}[%s/%s]${C_RESET} ${C_BOLD}%s${C_RESET}\n" \ + "$n" "$TOTAL_STEPS" "$msg" + printf " ${C_DIM}──────────────────────────────────────────────────${C_RESET}\n" +} + +print_banner() { + local version_suffix + version_suffix="$(installer_version_for_display)" + printf "\n" + # ANSI Shadow ASCII art — hand-crafted, no figlet dependency + printf " ${C_GREEN}${C_BOLD} ███╗ ██╗███████╗███╗ ███╗ ██████╗ ██████╗██╗ █████╗ ██╗ ██╗${C_RESET}\n" + printf " ${C_GREEN}${C_BOLD} ████╗ ██║██╔════╝████╗ ████║██╔═══██╗██╔════╝██║ ██╔══██╗██║ ██║${C_RESET}\n" + printf " ${C_GREEN}${C_BOLD} ██╔██╗ ██║█████╗ ██╔████╔██║██║ ██║██║ ██║ ███████║██║ █╗ ██║${C_RESET}\n" + printf " ${C_GREEN}${C_BOLD} ██║╚██╗██║██╔══╝ ██║╚██╔╝██║██║ ██║██║ ██║ ██╔══██║██║███╗██║${C_RESET}\n" + printf " ${C_GREEN}${C_BOLD} ██║ ╚████║███████╗██║ ╚═╝ ██║╚██████╔╝╚██████╗███████╗██║ ██║╚███╔███╔╝${C_RESET}\n" + printf " ${C_GREEN}${C_BOLD} ╚═╝ ╚═══╝╚══════╝╚═╝ ╚═╝ ╚═════╝ ╚═════╝╚══════╝╚═╝ ╚═╝ ╚══╝╚══╝${C_RESET}\n" + printf "\n" + printf " ${C_DIM}Launch OpenClaw in an OpenShell sandbox.%s${C_RESET}\n" "$version_suffix" + printf "\n" +} + +print_done() { + local elapsed=$((SECONDS - _INSTALL_START)) + local _needs_reload=false + needs_shell_reload && _needs_reload=true + + info "=== Installation complete ===" + printf "\n" + printf " ${C_GREEN}${C_BOLD}NemoClaw${C_RESET} ${C_DIM}(%ss)${C_RESET}\n" "$elapsed" + printf "\n" + if [[ "$ONBOARD_RAN" == true ]]; then + local sandbox_name + sandbox_name="$(resolve_default_sandbox_name)" + printf " ${C_GREEN}Your OpenClaw Sandbox is live.${C_RESET}\n" + printf " ${C_DIM}Sandbox in, break things, and tell us what you find.${C_RESET}\n" + printf "\n" + printf " ${C_GREEN}Next:${C_RESET}\n" + if [[ "$_needs_reload" == true ]]; then + printf " %s$%s source %s\n" "$C_GREEN" "$C_RESET" "$(detect_shell_profile)" + fi + printf " %s$%s nemoclaw %s connect\n" "$C_GREEN" "$C_RESET" "$sandbox_name" + printf " %ssandbox@%s$%s openclaw tui\n" "$C_GREEN" "$sandbox_name" "$C_RESET" + elif [[ "$NEMOCLAW_READY_NOW" == true ]]; then + printf " ${C_GREEN}NemoClaw CLI is installed.${C_RESET}\n" + printf " ${C_DIM}Onboarding has not run yet.${C_RESET}\n" + printf "\n" + printf " ${C_GREEN}Next:${C_RESET}\n" + if [[ "$_needs_reload" == true ]]; then + printf " %s$%s source %s\n" "$C_GREEN" "$C_RESET" "$(detect_shell_profile)" + fi + printf " %s$%s nemoclaw onboard\n" "$C_GREEN" "$C_RESET" + else + printf " ${C_GREEN}NemoClaw CLI is installed.${C_RESET}\n" + printf " ${C_DIM}Onboarding did not run because this shell cannot resolve 'nemoclaw' yet.${C_RESET}\n" + printf "\n" + printf " ${C_GREEN}Next:${C_RESET}\n" + if [[ -n "$NEMOCLAW_RECOVERY_EXPORT_DIR" ]]; then + printf " %s$%s export PATH=\"%s:\$PATH\"\n" "$C_GREEN" "$C_RESET" "$NEMOCLAW_RECOVERY_EXPORT_DIR" + fi + if [[ -n "$NEMOCLAW_RECOVERY_PROFILE" ]]; then + printf " %s$%s source %s\n" "$C_GREEN" "$C_RESET" "$NEMOCLAW_RECOVERY_PROFILE" + fi + printf " %s$%s nemoclaw onboard\n" "$C_GREEN" "$C_RESET" + fi + printf "\n" + printf " ${C_BOLD}GitHub${C_RESET} ${C_DIM}https://github.com/nvidia/nemoclaw${C_RESET}\n" + printf " ${C_BOLD}Docs${C_RESET} ${C_DIM}https://docs.nvidia.com/nemoclaw/latest/${C_RESET}\n" + printf "\n" +} + +usage() { + local version_suffix + version_suffix="$(installer_version_for_display)" + printf "\n" + printf " ${C_BOLD}NemoClaw Installer${C_RESET}${C_DIM}%s${C_RESET}\n\n" "$version_suffix" + printf " ${C_DIM}Usage:${C_RESET}\n" + printf " curl -fsSL https://www.nvidia.com/nemoclaw.sh | bash\n" + printf " curl -fsSL https://www.nvidia.com/nemoclaw.sh | bash -s -- [options]\n\n" + printf " ${C_DIM}Options:${C_RESET}\n" + printf " --non-interactive Skip prompts (uses env vars / defaults)\n" + printf " --yes-i-accept-third-party-software Accept the third-party software notice in non-interactive mode\n" + printf " --version, -v Print installer version and exit\n" + printf " --help, -h Show this help message and exit\n\n" + printf " ${C_DIM}Environment:${C_RESET}\n" + printf " NVIDIA_API_KEY API key (skips credential prompt)\n" + printf " NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE=1 Same as --yes-i-accept-third-party-software\n" + printf " NEMOCLAW_NON_INTERACTIVE=1 Same as --non-interactive\n" + printf " NEMOCLAW_SANDBOX_NAME Sandbox name to create/use\n" + printf " NEMOCLAW_RECREATE_SANDBOX=1 Recreate an existing sandbox\n" + printf " NEMOCLAW_INSTALL_TAG Git ref to install (default: latest release)\n" + printf " NEMOCLAW_PROVIDER cloud | ollama | nim | vllm\n" + printf " NEMOCLAW_MODEL Inference model to configure\n" + printf " NEMOCLAW_POLICY_MODE suggested | custom | skip\n" + printf " NEMOCLAW_POLICY_PRESETS Comma-separated policy presets\n" + printf " NEMOCLAW_EXPERIMENTAL=1 Show experimental/local options\n" + printf " CHAT_UI_URL Chat UI URL to open after setup\n" + printf " DISCORD_BOT_TOKEN Auto-enable Discord policy support\n" + printf " SLACK_BOT_TOKEN Auto-enable Slack policy support\n" + printf " TELEGRAM_BOT_TOKEN Auto-enable Telegram policy support\n" + printf "\n" +} + +show_usage_notice() { + local repo_root + repo_root="$(resolve_repo_root)" + local source_root="${NEMOCLAW_SOURCE_ROOT:-$repo_root}" + local notice_script="${source_root}/bin/lib/usage-notice.js" + if [[ ! -f "$notice_script" ]]; then + notice_script="${repo_root}/bin/lib/usage-notice.js" + fi + local -a notice_cmd=(node "$notice_script") + if [ "${NON_INTERACTIVE:-}" = "1" ]; then + notice_cmd+=(--non-interactive) + if [ "${ACCEPT_THIRD_PARTY_SOFTWARE:-}" = "1" ]; then + notice_cmd+=(--yes-i-accept-third-party-software) + fi + "${notice_cmd[@]}" + elif [ -t 0 ]; then + "${notice_cmd[@]}" + elif exec 3"$log" 2>&1 & + local pid=$! i=0 + local status + local frames=('⠋' '⠙' '⠹' '⠸' '⠼' '⠴' '⠦' '⠧' '⠇' '⠏') + + # Register with global cleanup so any exit path reaps the child and temp file. + _cleanup_pids+=("$pid") + _cleanup_files+=("$log") + + # Ensure Ctrl+C kills the background process and cleans up the temp file. + trap 'kill "$pid" 2>/dev/null; rm -f "$log"; exit 130' INT TERM + + while kill -0 "$pid" 2>/dev/null; do + printf "\r ${C_GREEN}%s${C_RESET} %s" "${frames[$((i++ % 10))]}" "$msg" + sleep 0.08 + done + + # Restore default signal handling after the background process exits. + trap - INT TERM + + if wait "$pid"; then + status=0 + else + status=$? + fi + + if [[ $status -eq 0 ]]; then + printf "\r ${C_GREEN}✓${C_RESET} %s\n" "$msg" + else + printf "\r ${C_RED}✗${C_RESET} %s\n\n" "$msg" + cat "$log" >&2 + printf "\n" + fi + rm -f "$log" + + # Deregister only after cleanup actions are complete, so the global EXIT + # trap still covers this pid/log if a signal arrives before this point. + _cleanup_pids=("${_cleanup_pids[@]/$pid/}") + _cleanup_files=("${_cleanup_files[@]/$log/}") + return $status +} + +command_exists() { command -v "$1" &>/dev/null; } + +MIN_NODE_VERSION="22.16.0" +MIN_NPM_MAJOR=10 +RUNTIME_REQUIREMENT_MSG="NemoClaw requires Node.js >=${MIN_NODE_VERSION} and npm >=${MIN_NPM_MAJOR}." +NEMOCLAW_SHIM_DIR="${HOME}/.local/bin" +ORIGINAL_PATH="${PATH:-}" +NEMOCLAW_READY_NOW=false +NEMOCLAW_RECOVERY_PROFILE="" +NEMOCLAW_RECOVERY_EXPORT_DIR="" +NEMOCLAW_SOURCE_ROOT="$(resolve_repo_root)" +ONBOARD_RAN=false + +# Compare two semver strings (major.minor.patch). Returns 0 if $1 >= $2. +# Rejects prerelease suffixes (e.g. "22.16.0-rc.1") to avoid arithmetic errors. +version_gte() { + [[ "$1" =~ ^[0-9]+(\.[0-9]+){0,2}$ ]] || return 1 + [[ "$2" =~ ^[0-9]+(\.[0-9]+){0,2}$ ]] || return 1 + local -a a b + IFS=. read -ra a <<<"$1" + IFS=. read -ra b <<<"$2" + for i in 0 1 2; do + local ai=${a[$i]:-0} bi=${b[$i]:-0} + if ((ai > bi)); then return 0; fi + if ((ai < bi)); then return 1; fi + done + return 0 +} + +# Ensure nvm environment is loaded in the current shell. +# Skip if node is already on PATH — sourcing nvm.sh can reset PATH and +# override the caller's node/npm (e.g. in test environments with stubs). +# Pass --force to load nvm even when node is on PATH (needed when upgrading). +ensure_nvm_loaded() { + if [[ "${1:-}" != "--force" ]]; then + command -v node &>/dev/null && return 0 + fi + if [[ -z "${NVM_DIR:-}" ]]; then + export NVM_DIR="$HOME/.nvm" + fi + if [[ -s "$NVM_DIR/nvm.sh" ]]; then + \. "$NVM_DIR/nvm.sh" + fi +} + +detect_shell_profile() { + local profile="$HOME/.bashrc" + case "$(basename "${SHELL:-}")" in + zsh) + profile="$HOME/.zshrc" + ;; + fish) + profile="$HOME/.config/fish/config.fish" + ;; + tcsh) + profile="$HOME/.tcshrc" + ;; + csh) + profile="$HOME/.cshrc" + ;; + *) + if [[ ! -f "$HOME/.bashrc" && -f "$HOME/.profile" ]]; then + profile="$HOME/.profile" + fi + ;; + esac + printf "%s" "$profile" +} + +# Refresh PATH so that npm global bin is discoverable. +# After nvm installs Node.js the global bin lives under the nvm prefix, +# which may not yet be on PATH in the current session. +refresh_path() { + ensure_nvm_loaded + + local npm_bin + npm_bin="$(npm config get prefix 2>/dev/null)/bin" || true + if [[ -n "$npm_bin" && -d "$npm_bin" && ":$PATH:" != *":$npm_bin:"* ]]; then + export PATH="$npm_bin:$PATH" + fi + + if [[ -d "$NEMOCLAW_SHIM_DIR" && ":$PATH:" != *":$NEMOCLAW_SHIM_DIR:"* ]]; then + export PATH="$NEMOCLAW_SHIM_DIR:$PATH" + fi +} + +ensure_nemoclaw_shim() { + local npm_bin shim_path + npm_bin="$(npm config get prefix 2>/dev/null)/bin" || true + shim_path="${NEMOCLAW_SHIM_DIR}/nemoclaw" + + if [[ -z "$npm_bin" || ! -x "$npm_bin/nemoclaw" ]]; then + return 1 + fi + + if [[ ":$ORIGINAL_PATH:" == *":$npm_bin:"* ]] || [[ ":$ORIGINAL_PATH:" == *":$NEMOCLAW_SHIM_DIR:"* ]]; then + return 0 + fi + + mkdir -p "$NEMOCLAW_SHIM_DIR" + ln -sfn "$npm_bin/nemoclaw" "$shim_path" + refresh_path + ensure_local_bin_in_profile + info "Created user-local shim at $shim_path" + return 0 +} + +# Detect whether the parent shell likely needs a reload after install. +# When running via `curl | bash`, the installer executes in a subprocess. +# Even when the bin directory is already in PATH, the parent shell may have +# stale bash hash-table entries pointing to a previously deleted binary +# (e.g. upgrade/reinstall after `rm $(which nemoclaw)`). Sourcing the +# shell profile reassigns PATH which clears the hash table, so we always +# recommend it when the installer verified nemoclaw in the subprocess. +needs_shell_reload() { + [[ "$NEMOCLAW_READY_NOW" != true ]] && return 1 + return 0 +} + +# Add ~/.local/bin (and for fish, the nvm node bin) to the user's shell +# profile PATH so that nemoclaw, openshell, and any future tools installed +# there are discoverable in new terminal sessions. +# Idempotent — skips if the marker comment is already present. +ensure_local_bin_in_profile() { + local profile + profile="$(detect_shell_profile)" + [[ -n "$profile" ]] || return 0 + + # Already present — nothing to do. + if [[ -f "$profile" ]] && grep -qF '# NemoClaw PATH setup' "$profile" 2>/dev/null; then + return 0 + fi + + local shell_name + shell_name="$(basename "${SHELL:-bash}")" + + local local_bin="$NEMOCLAW_SHIM_DIR" + + case "$shell_name" in + fish) + # fish needs both ~/.local/bin and the nvm node bin (nvm doesn't support fish). + local node_bin="" + node_bin="$(command -v node 2>/dev/null)" || true + if [[ -n "$node_bin" ]]; then + node_bin="$(dirname "$node_bin")" + fi + { + printf '\n# NemoClaw PATH setup\n' + printf 'fish_add_path --path --append "%s"\n' "$local_bin" + if [[ -n "$node_bin" ]]; then + printf 'fish_add_path --path --append "%s"\n' "$node_bin" + fi + printf '# end NemoClaw PATH setup\n' + } >>"$profile" + ;; + tcsh | csh) + { + printf '\n# NemoClaw PATH setup\n' + # shellcheck disable=SC2016 + printf 'setenv PATH "%s:${PATH}"\n' "$local_bin" + printf '# end NemoClaw PATH setup\n' + } >>"$profile" + ;; + *) + # bash, zsh, and others — nvm already handles node PATH for these shells. + { + printf '\n# NemoClaw PATH setup\n' + # shellcheck disable=SC2016 + printf 'export PATH="%s:$PATH"\n' "$local_bin" + printf '# end NemoClaw PATH setup\n' + } >>"$profile" + ;; + esac +} + +version_major() { + printf '%s\n' "${1#v}" | cut -d. -f1 +} + +ensure_supported_runtime() { + command_exists node || error "${RUNTIME_REQUIREMENT_MSG} Node.js was not found on PATH." + command_exists npm || error "${RUNTIME_REQUIREMENT_MSG} npm was not found on PATH." + + local node_version npm_version node_major npm_major + node_version="$(node --version 2>/dev/null || true)" + npm_version="$(npm --version 2>/dev/null || true)" + node_major="$(version_major "$node_version")" + npm_major="$(version_major "$npm_version")" + + [[ "$node_major" =~ ^[0-9]+$ ]] || error "Could not determine Node.js version from '${node_version}'. ${RUNTIME_REQUIREMENT_MSG}" + [[ "$npm_major" =~ ^[0-9]+$ ]] || error "Could not determine npm version from '${npm_version}'. ${RUNTIME_REQUIREMENT_MSG}" + + if ! version_gte "${node_version#v}" "$MIN_NODE_VERSION" || ((npm_major < MIN_NPM_MAJOR)); then + error "Unsupported runtime detected: Node.js ${node_version:-unknown}, npm ${npm_version:-unknown}. ${RUNTIME_REQUIREMENT_MSG} Upgrade Node.js and rerun the installer." + fi + + info "Runtime OK: Node.js ${node_version}, npm ${npm_version}" +} + +# --------------------------------------------------------------------------- +# 1. Node.js +# --------------------------------------------------------------------------- +install_nodejs() { + if command_exists node; then + local current_version current_npm_major + current_version="$(node --version 2>/dev/null || true)" + current_npm_major="$(version_major "$(npm --version 2>/dev/null || echo 0)")" + if version_gte "${current_version#v}" "$MIN_NODE_VERSION" \ + && [[ "$current_npm_major" =~ ^[0-9]+$ ]] \ + && ((current_npm_major >= MIN_NPM_MAJOR)); then + info "Node.js found: ${current_version}" + return + fi + warn "Node.js ${current_version}, npm major ${current_npm_major:-unknown} found but NemoClaw requires Node.js >=${MIN_NODE_VERSION} and npm >=${MIN_NPM_MAJOR} — upgrading via nvm…" + else + info "Node.js not found — installing via nvm…" + fi + # IMPORTANT: update NVM_SHA256 when changing NVM_VERSION + local NVM_VERSION="v0.40.4" + local NVM_SHA256="4b7412c49960c7d31e8df72da90c1fb5b8cccb419ac99537b737028d497aba4f" + local nvm_tmp + nvm_tmp="$(mktemp)" + curl -fsSL "https://raw.githubusercontent.com/nvm-sh/nvm/${NVM_VERSION}/install.sh" -o "$nvm_tmp" \ + || { + rm -f "$nvm_tmp" + error "Failed to download nvm installer" + } + local actual_hash + if command_exists sha256sum; then + actual_hash="$(sha256sum "$nvm_tmp" | awk '{print $1}')" + elif command_exists shasum; then + actual_hash="$(shasum -a 256 "$nvm_tmp" | awk '{print $1}')" + else + warn "No SHA-256 tool found — skipping nvm integrity check" + actual_hash="$NVM_SHA256" # allow execution + fi + if [[ "$actual_hash" != "$NVM_SHA256" ]]; then + rm -f "$nvm_tmp" + error "nvm installer integrity check failed\n Expected: $NVM_SHA256\n Actual: $actual_hash" + fi + info "nvm installer integrity verified" + spin "Installing nvm..." bash "$nvm_tmp" + rm -f "$nvm_tmp" + ensure_nvm_loaded --force + spin "Installing Node.js 22..." bash -c ". \"$NVM_DIR/nvm.sh\" && nvm install 22 --no-progress" + ensure_nvm_loaded --force + nvm use 22 --silent + nvm alias default 22 2>/dev/null || true + info "Node.js installed: $(node --version)" +} + +# --------------------------------------------------------------------------- +# 2. Ollama +# --------------------------------------------------------------------------- +OLLAMA_MIN_VERSION="0.18.0" + +get_ollama_version() { + # `ollama --version` outputs something like "ollama version 0.18.0" + ollama --version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1 +} + +detect_gpu() { + # Returns 0 if a GPU is detected + if command_exists nvidia-smi; then + nvidia-smi &>/dev/null && return 0 + fi + return 1 +} + +get_vram_mb() { + # Returns total VRAM in MiB (NVIDIA only). Falls back to 0. + if command_exists nvidia-smi; then + nvidia-smi --query-gpu=memory.total --format=csv,noheader,nounits 2>/dev/null \ + | awk '{s += $1} END {print s+0}' + return + fi + # macOS — report unified memory as VRAM + if [[ "$(uname -s)" == "Darwin" ]] && command_exists sysctl; then + local bytes + bytes=$(sysctl -n hw.memsize 2>/dev/null || echo 0) + echo $((bytes / 1024 / 1024)) + return + fi + echo 0 +} + +install_or_upgrade_ollama() { + if detect_gpu && command_exists ollama; then + local current + current=$(get_ollama_version) + if [[ -n "$current" ]] && version_gte "$current" "$OLLAMA_MIN_VERSION"; then + info "Ollama v${current} meets minimum requirement (>= v${OLLAMA_MIN_VERSION})" + else + info "Ollama v${current:-unknown} is below v${OLLAMA_MIN_VERSION} — upgrading…" + ( + tmpdir="$(mktemp -d)" + trap 'rm -rf "$tmpdir"' EXIT + curl -fsSL https://ollama.com/install.sh -o "$tmpdir/install_ollama.sh" + verify_downloaded_script "$tmpdir/install_ollama.sh" "Ollama" + sh "$tmpdir/install_ollama.sh" + ) + info "Ollama upgraded to $(get_ollama_version)" + fi + else + # No ollama — only install if a GPU is present + if detect_gpu; then + info "GPU detected — installing Ollama…" + ( + tmpdir="$(mktemp -d)" + trap 'rm -rf "$tmpdir"' EXIT + curl -fsSL https://ollama.com/install.sh -o "$tmpdir/install_ollama.sh" + verify_downloaded_script "$tmpdir/install_ollama.sh" "Ollama" + sh "$tmpdir/install_ollama.sh" + ) + info "Ollama installed: v$(get_ollama_version)" + else + warn "No GPU detected — skipping Ollama installation." + return + fi + fi + + # Pull the appropriate model based on VRAM + local vram_mb + vram_mb=$(get_vram_mb) + local vram_gb=$((vram_mb / 1024)) + info "Detected ${vram_gb} GB VRAM" + + if ((vram_gb >= 120)); then + info "Pulling nemotron-3-super:120b…" + ollama pull nemotron-3-super:120b + else + info "Pulling nemotron-3-nano:30b…" + ollama pull nemotron-3-nano:30b + fi +} + +# --------------------------------------------------------------------------- +# Fix npm permissions for global installs (Linux only). +# If the npm global prefix points to a system directory (e.g. /usr or +# /usr/local) the user likely lacks write permissions and npm link will fail +# with EACCES. Redirect the prefix to ~/.npm-global so the install succeeds +# without sudo. +# --------------------------------------------------------------------------- +fix_npm_permissions() { + if [[ "$(uname -s)" != "Linux" ]]; then + return 0 + fi + + local npm_prefix + npm_prefix="$(npm config get prefix 2>/dev/null || true)" + if [[ -z "$npm_prefix" ]]; then + return 0 + fi + + if [[ -w "$npm_prefix" || -w "$npm_prefix/lib" ]]; then + return 0 + fi + + info "npm global prefix '${npm_prefix}' is not writable — configuring user-local installs" + mkdir -p "$HOME/.npm-global" + npm config set prefix "$HOME/.npm-global" + + # shellcheck disable=SC2016 + local path_line='export PATH="$HOME/.npm-global/bin:$PATH"' + for rc in "$HOME/.bashrc" "$HOME/.zshrc"; do + if [[ -f "$rc" ]] && ! grep -q ".npm-global" "$rc"; then + printf '\n# Added by NemoClaw installer\n%s\n' "$path_line" >>"$rc" + fi + done + + export PATH="$HOME/.npm-global/bin:$PATH" + ok "npm configured for user-local installs (~/.npm-global)" +} + +# --------------------------------------------------------------------------- +# 3. NemoClaw +# --------------------------------------------------------------------------- +# Work around openclaw tarball missing directory entries (GH-503). +# npm's tar extractor hard-fails because the tarball is missing directory +# entries for extensions/, skills/, and dist/plugin-sdk/config/. System tar +# handles this fine. We pre-extract openclaw into node_modules BEFORE npm +# install so npm sees the dependency is already satisfied and skips it. +pre_extract_openclaw() { + local install_dir="$1" + local openclaw_version + openclaw_version="$(resolve_openclaw_version "$install_dir")" + + if [[ -z "$openclaw_version" ]]; then + warn "Could not determine openclaw version — skipping pre-extraction" + return 1 + fi + + info "Pre-extracting openclaw@${openclaw_version} with system tar (GH-503 workaround)…" + local tmpdir + tmpdir="$(mktemp -d)" + if npm pack "openclaw@${openclaw_version}" --pack-destination "$tmpdir" >/dev/null 2>&1; then + local tgz + tgz="$(find "$tmpdir" -maxdepth 1 -name 'openclaw-*.tgz' -print -quit)" + if [[ -n "$tgz" && -f "$tgz" ]]; then + if mkdir -p "${install_dir}/node_modules/openclaw" \ + && tar xzf "$tgz" -C "${install_dir}/node_modules/openclaw" --strip-components=1; then + info "openclaw pre-extracted successfully" + else + warn "Failed to extract openclaw tarball" + rm -rf "$tmpdir" + return 1 + fi + else + warn "npm pack succeeded but tarball not found" + rm -rf "$tmpdir" + return 1 + fi + else + warn "Failed to download openclaw tarball" + rm -rf "$tmpdir" + return 1 + fi + rm -rf "$tmpdir" +} + +resolve_openclaw_version() { + local install_dir="$1" + local package_json dockerfile_base resolved_version + + package_json="${install_dir}/package.json" + dockerfile_base="${install_dir}/Dockerfile.base" + + if [[ -f "$package_json" ]]; then + resolved_version="$( + node -e "const v = require('${package_json}').dependencies?.openclaw; if (v) console.log(v)" \ + 2>/dev/null || true + )" + if [[ -n "$resolved_version" ]]; then + printf '%s\n' "$resolved_version" + return 0 + fi + fi + + if [[ -f "$dockerfile_base" ]]; then + awk ' + match($0, /openclaw@[0-9][0-9.]+/) { + print substr($0, RSTART + 9, RLENGTH - 9) + exit + } + ' "$dockerfile_base" + fi +} + +install_nemoclaw() { + command_exists git || error "git was not found on PATH." + local repo_root package_json + repo_root="$(resolve_repo_root)" + package_json="${repo_root}/package.json" + if [[ -f "$package_json" ]] && grep -q '"name"[[:space:]]*:[[:space:]]*"nemoclaw"' "$package_json" 2>/dev/null; then + info "NemoClaw package.json found in the selected source checkout — installing from source…" + NEMOCLAW_SOURCE_ROOT="$repo_root" + spin "Preparing OpenClaw package" bash -c "$(declare -f info warn resolve_openclaw_version pre_extract_openclaw); pre_extract_openclaw \"\$1\"" _ "$NEMOCLAW_SOURCE_ROOT" \ + || warn "Pre-extraction failed — npm install may fail if openclaw tarball is broken" + spin "Installing NemoClaw dependencies" bash -c "cd \"$NEMOCLAW_SOURCE_ROOT\" && npm install --ignore-scripts" + spin "Building NemoClaw CLI modules" bash -c "cd \"$NEMOCLAW_SOURCE_ROOT\" && npm run --if-present build:cli" + spin "Building NemoClaw plugin" bash -c "cd \"$NEMOCLAW_SOURCE_ROOT\"/nemoclaw && npm install --ignore-scripts && npm run build" + spin "Linking NemoClaw CLI" bash -c "cd \"$NEMOCLAW_SOURCE_ROOT\" && npm link" + else + info "Installing NemoClaw from GitHub…" + # Resolve the latest release tag so we never install raw main. + local release_ref + release_ref="$(resolve_release_tag)" + info "Resolved install ref: ${release_ref}" + # Clone first so we can pre-extract openclaw before npm install (GH-503). + # npm install -g git+https://... does this internally but we can't hook + # into its extraction pipeline, so we do it ourselves. + local nemoclaw_src="${HOME}/.nemoclaw/source" + rm -rf "$nemoclaw_src" + mkdir -p "$(dirname "$nemoclaw_src")" + NEMOCLAW_SOURCE_ROOT="$nemoclaw_src" + spin "Cloning NemoClaw source" git clone --depth 1 --branch "$release_ref" https://github.com/NVIDIA/NemoClaw.git "$nemoclaw_src" + # Fetch version tags into the shallow clone so `git describe --tags + # --match "v*"` works at runtime (the shallow clone only has the + # single ref we asked for). + git -C "$nemoclaw_src" fetch --depth=1 origin 'refs/tags/v*:refs/tags/v*' 2>/dev/null || true + # Also stamp .version as a fallback for environments where git is + # unavailable or tags are pruned later. + git -C "$nemoclaw_src" describe --tags --match 'v*' 2>/dev/null \ + | sed 's/^v//' >"$nemoclaw_src/.version" || true + spin "Preparing OpenClaw package" bash -c "$(declare -f info warn resolve_openclaw_version pre_extract_openclaw); pre_extract_openclaw \"\$1\"" _ "$nemoclaw_src" \ + || warn "Pre-extraction failed — npm install may fail if openclaw tarball is broken" + spin "Installing NemoClaw dependencies" bash -c "cd \"$nemoclaw_src\" && npm install --ignore-scripts" + spin "Building NemoClaw CLI modules" bash -c "cd \"$nemoclaw_src\" && npm run --if-present build:cli" + spin "Building NemoClaw plugin" bash -c "cd \"$nemoclaw_src\"/nemoclaw && npm install --ignore-scripts && npm run build" + spin "Linking NemoClaw CLI" bash -c "cd \"$nemoclaw_src\" && npm link" + fi + + refresh_path + ensure_nemoclaw_shim || true +} + +# --------------------------------------------------------------------------- +# 4. Verify +# --------------------------------------------------------------------------- +verify_nemoclaw() { + if command_exists nemoclaw; then + NEMOCLAW_READY_NOW=true + ensure_nemoclaw_shim || true + info "Verified: nemoclaw is available at $(command -v nemoclaw)" + return 0 + fi + + local npm_bin + npm_bin="$(npm config get prefix 2>/dev/null)/bin" || true + + if [[ -n "$npm_bin" && -x "$npm_bin/nemoclaw" ]]; then + ensure_nemoclaw_shim || true + if command_exists nemoclaw; then + NEMOCLAW_READY_NOW=true + info "Verified: nemoclaw is available at $(command -v nemoclaw)" + return 0 + fi + + NEMOCLAW_RECOVERY_PROFILE="$(detect_shell_profile)" + if [[ -x "$NEMOCLAW_SHIM_DIR/nemoclaw" ]]; then + NEMOCLAW_RECOVERY_EXPORT_DIR="$NEMOCLAW_SHIM_DIR" + else + NEMOCLAW_RECOVERY_EXPORT_DIR="$npm_bin" + fi + warn "Found nemoclaw at $npm_bin/nemoclaw but this shell still cannot resolve it." + warn "Onboarding will be skipped until PATH is updated." + return 0 + else + warn "Could not locate the nemoclaw executable." + warn "Try running: npm install -g git+https://github.com/NVIDIA/NemoClaw.git" + fi + + error "Installation failed: nemoclaw binary not found." +} + +# --------------------------------------------------------------------------- +# 5. Onboard +# --------------------------------------------------------------------------- +run_onboard() { + show_usage_notice + info "Running nemoclaw onboard…" + local -a onboard_cmd=(onboard) + if command_exists node && [[ -f "${HOME}/.nemoclaw/onboard-session.json" ]]; then + if node -e ' + const fs = require("fs"); + const file = process.argv[1]; + try { + const data = JSON.parse(fs.readFileSync(file, "utf8")); + const resumable = data && data.resumable !== false; + const status = data && data.status; + process.exit(resumable && status && status !== "complete" ? 0 : 1); + } catch { + process.exit(1); + } + ' "${HOME}/.nemoclaw/onboard-session.json"; then + info "Found an interrupted onboarding session — resuming it." + onboard_cmd+=(--resume) + fi + fi + if [ "${NON_INTERACTIVE:-}" = "1" ]; then + onboard_cmd+=(--non-interactive) + if [ "${ACCEPT_THIRD_PARTY_SOFTWARE:-}" = "1" ]; then + onboard_cmd+=(--yes-i-accept-third-party-software) + fi + nemoclaw "${onboard_cmd[@]}" + elif [ -t 0 ]; then + nemoclaw "${onboard_cmd[@]}" + elif exec 3 { - const data = JSON.stringify(body); - const req = https.request( - { - hostname: "api.telegram.org", - path: `/bot${TOKEN}/${method}`, - method: "POST", - headers: { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(data) }, - }, - (res) => { - let buf = ""; - res.on("data", (c) => (buf += c)); - res.on("end", () => { - try { resolve(JSON.parse(buf)); } catch { resolve({ ok: false, error: buf }); } - }); - }, - ); - req.on("error", reject); - req.write(data); - req.end(); - }); + return tgApiRaw(TOKEN, method, body); } async function sendMessage(chatId, text, replyTo) { diff --git a/test/install-preflight.test.js b/test/install-preflight.test.js index eac1dee75..9be405954 100644 --- a/test/install-preflight.test.js +++ b/test/install-preflight.test.js @@ -9,7 +9,7 @@ import { spawnSync } from "node:child_process"; const INSTALLER = path.join(import.meta.dirname, "..", "install.sh"); const CURL_PIPE_INSTALLER = path.join(import.meta.dirname, "..", "install.sh"); -const LEGACY_INSTALLER_WRAPPER = path.join(import.meta.dirname, "..", "scripts", "install.sh"); +const INSTALLER_PAYLOAD = path.join(import.meta.dirname, "..", "scripts", "install.sh"); const GITHUB_INSTALL_URL = "git+https://github.com/NVIDIA/NemoClaw.git"; const TEST_SYSTEM_PATH = "/usr/bin:/bin"; @@ -112,7 +112,7 @@ exit 1 expect(output).toMatch(/Failed to download nvm installer/); }); - it("uses the HTTPS GitHub fallback when not installing from a repo checkout", () => { + it("treats the installer script's checkout as the source root even when cwd is elsewhere", () => { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-install-fallback-")); const fakeBin = path.join(tmp, "bin"); const prefix = path.join(tmp, "prefix"); @@ -142,6 +142,9 @@ exit 99 path.join(fakeBin, "git"), `#!/usr/bin/env bash printf '%s\\n' "$*" >> "$GIT_LOG_PATH" +if [ "\${1:-}" = "-c" ]; then + shift 2 +fi if [ "$1" = "clone" ]; then target="\${@: -1}" mkdir -p "$target/nemoclaw" @@ -209,7 +212,9 @@ exit 98 }); expect(result.status).toBe(0); - expect(fs.readFileSync(gitLog, "utf-8")).toMatch(/clone.*NemoClaw\.git/); + const gitCalls = fs.readFileSync(gitLog, "utf-8"); + expect(gitCalls).not.toMatch(/clone/); + expect(gitCalls).not.toMatch(/fetch/); }, 60_000); it("prints the HTTPS GitHub remediation when the binary is missing", () => { @@ -237,6 +242,9 @@ exit 99 writeExecutable( path.join(fakeBin, "git"), `#!/usr/bin/env bash +if [ "\${1:-}" = "-c" ]; then + shift 2 +fi if [ "$1" = "clone" ]; then target="\${@: -1}" mkdir -p "$target/nemoclaw" @@ -296,23 +304,22 @@ exit 98 expect(output).not.toMatch(/npm install -g nemoclaw/); }); - it("legacy scripts/install.sh delegates to the root installer from a repo checkout", () => { - const result = spawnSync("bash", [LEGACY_INSTALLER_WRAPPER, "--help"], { + it("scripts/install.sh runs as the installer from a repo checkout", () => { + const result = spawnSync("bash", [INSTALLER_PAYLOAD, "--help"], { cwd: path.join(import.meta.dirname, ".."), encoding: "utf-8", }); const output = `${result.stdout}${result.stderr}`; expect(result.status).toBe(0); - expect(output).toMatch(/deprecated compatibility wrapper/); - expect(output).toMatch(/https:\/\/www\.nvidia\.com\/nemoclaw\.sh/); expect(output).toMatch(/NemoClaw Installer/); + expect(output).not.toMatch(/deprecated compatibility wrapper/); }); - it("legacy scripts/install.sh fails clearly when run without the repo root installer", () => { - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-legacy-installer-stdin-")); - const scriptContents = fs.readFileSync(LEGACY_INSTALLER_WRAPPER, "utf-8"); - const result = spawnSync("bash", [], { + it("scripts/install.sh --help works when run directly outside a repo checkout", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-installer-payload-stdin-")); + const scriptContents = fs.readFileSync(INSTALLER_PAYLOAD, "utf-8"); + const result = spawnSync("bash", ["-s", "--", "--help"], { cwd: tmp, input: scriptContents, encoding: "utf-8", @@ -324,11 +331,9 @@ exit 98 }); const output = `${result.stdout}${result.stderr}`; - expect(result.status).not.toBe(0); - expect(output).toMatch(/deprecated compatibility wrapper/); - expect(output).toMatch(/supported installer/); - expect(output).toMatch(/https:\/\/www\.nvidia\.com\/nemoclaw\.sh/); - expect(output).toMatch(/only works from a NemoClaw repository checkout/); + expect(result.status).toBe(0); + expect(output).toMatch(/NemoClaw Installer/); + expect(output).not.toMatch(/deprecated compatibility wrapper/); }); it("--help exits 0 and shows install usage", () => { @@ -643,6 +648,9 @@ fi`, writeExecutable( path.join(fakeBin, "git"), `#!/usr/bin/env bash +if [ "\${1:-}" = "-c" ]; then + shift 2 +fi if [ "$1" = "clone" ]; then target="\${@: -1}" mkdir -p "$target/nemoclaw" @@ -710,6 +718,9 @@ exit 99 writeExecutable( path.join(fakeBin, "git"), `#!/usr/bin/env bash +if [ "\${1:-}" = "-c" ]; then + shift 2 +fi if [ "$1" = "clone" ]; then target="\${@: -1}" mkdir -p "$target/nemoclaw" @@ -833,6 +844,9 @@ exit 99 writeExecutable( path.join(fakeBin, "git"), `#!/usr/bin/env bash +if [ "\${1:-}" = "-c" ]; then + shift 2 +fi if [ "$1" = "clone" ]; then target="\${@: -1}" mkdir -p "$target/nemoclaw" @@ -1124,7 +1138,7 @@ exit 0`, expect(`${result.stdout}${result.stderr}`).not.toMatch(/curl should not be called/); }); - it("full install: git clone receives --branch with the resolved release tag", () => { + it("repo-checkout install does not clone a separate ref even when cwd is elsewhere", () => { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-install-tag-e2e-")); const fakeBin = path.join(tmp, "bin"); const prefix = path.join(tmp, "prefix"); @@ -1144,6 +1158,9 @@ exit 0`, path.join(fakeBin, "git"), `#!/usr/bin/env bash printf '%s\\n' "$*" >> "$GIT_LOG_PATH" +if [ "\${1:-}" = "-c" ]; then + shift 2 +fi if [ "$1" = "clone" ]; then target="\${@: -1}" mkdir -p "$target/nemoclaw" @@ -1185,7 +1202,8 @@ fi`, expect(result.status).toBe(0); const gitCalls = fs.readFileSync(gitLog, "utf-8"); - expect(gitCalls).toMatch(/--branch latest/); + expect(gitCalls).not.toMatch(/clone/); + expect(gitCalls).not.toMatch(/fetch/); }); }); @@ -1594,13 +1612,16 @@ exit 0`, return { fakeBin, prefix, gitLog }; } - it("git clone receives --branch latest by default", () => { + it("repo-checkout install ignores release-tag cloning when invoked by path", () => { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-curl-pipe-tag-e2e-")); const { fakeBin, prefix, gitLog } = buildCurlPipeEnv(tmp, { curlStub: `#!/usr/bin/env bash /usr/bin/curl "$@"`, gitStub: `#!/usr/bin/env bash printf '%s\\n' "$*" >> "$GIT_LOG_PATH" +if [ "\${1:-}" = "-c" ]; then + shift 2 +fi if [ "$1" = "clone" ]; then target="\${@: -1}" mkdir -p "$target/nemoclaw" @@ -1627,10 +1648,11 @@ exit 0`, expect(result.status).toBe(0); const gitCalls = fs.readFileSync(gitLog, "utf-8"); - expect(gitCalls).toMatch(/--branch latest/); + expect(gitCalls).not.toMatch(/clone/); + expect(gitCalls).not.toMatch(/fetch/); }); - it("uses NEMOCLAW_INSTALL_TAG override without calling the API", () => { + it("repo-checkout install ignores NEMOCLAW_INSTALL_TAG when invoked by path", () => { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-curl-pipe-tag-override-")); const { fakeBin, prefix, gitLog } = buildCurlPipeEnv(tmp, { curlStub: `#!/usr/bin/env bash @@ -1643,6 +1665,9 @@ done /usr/bin/curl "$@"`, gitStub: `#!/usr/bin/env bash printf '%s\\n' "$*" >> "$GIT_LOG_PATH" +if [ "\${1:-}" = "-c" ]; then + shift 2 +fi if [ "$1" = "clone" ]; then target="\${@: -1}" mkdir -p "$target/nemoclaw" @@ -1670,20 +1695,75 @@ exit 0`, expect(result.status).toBe(0); const gitCalls = fs.readFileSync(gitLog, "utf-8"); - expect(gitCalls).toMatch(/--branch v0\.2\.0/); - // Confirm the releases API was NOT called + expect(gitCalls).not.toMatch(/clone/); + expect(gitCalls).not.toMatch(/fetch/); expect(`${result.stdout}${result.stderr}`).not.toMatch(/curl should not hit the releases API/); }); + it("falls back to the legacy root installer when the selected ref only has the old scripts/install.sh wrapper", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-curl-pipe-legacy-ref-")); + const legacyLog = path.join(tmp, "legacy.log"); + const { fakeBin, prefix } = buildCurlPipeEnv(tmp, { + curlStub: `#!/usr/bin/env bash +/usr/bin/curl "$@"`, + gitStub: `#!/usr/bin/env bash +if [ "\${1:-}" = "-c" ]; then + shift 2 +fi +if [ "$1" = "clone" ]; then + target="\${@: -1}" + mkdir -p "$target/scripts" + cat > "$target/scripts/install.sh" <<'EOS' +#!/usr/bin/env bash +set -euo pipefail +echo legacy-wrapper >&2 +exit 97 +EOS + chmod +x "$target/scripts/install.sh" + cat > "$target/install.sh" <<'EOS' +#!/usr/bin/env bash +set -euo pipefail +printf '%s\n' "\${NEMOCLAW_INSTALL_TAG:-unset}" > "\${LEGACY_LOG_PATH:?}" +EOS + chmod +x "$target/install.sh" + exit 0 +fi +exit 0`, + }); + + const installerInput = fs.readFileSync(CURL_PIPE_INSTALLER, "utf-8"); + const result = spawnSync("bash", [], { + cwd: tmp, + input: installerInput, + encoding: "utf-8", + env: { + ...process.env, + HOME: tmp, + PATH: `${fakeBin}:${TEST_SYSTEM_PATH}`, + NEMOCLAW_INSTALL_TAG: "v0.0.1", + NEMOCLAW_NON_INTERACTIVE: "1", + NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE: "1", + NPM_PREFIX: prefix, + LEGACY_LOG_PATH: legacyLog, + }, + }); + + expect(result.status).toBe(0); + expect(fs.readFileSync(legacyLog, "utf-8")).toMatch(/^v0\.0\.1\s*$/); + }); + it("resolves the usage notice helper from the cloned source during piped installs", () => { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-curl-pipe-usage-notice-")); const { fakeBin, prefix } = buildCurlPipeEnv(tmp, { curlStub: `#!/usr/bin/env bash /usr/bin/curl "$@"`, gitStub: `#!/usr/bin/env bash +if [ "\${1:-}" = "-c" ]; then + shift 2 +fi if [ "$1" = "clone" ]; then target="\${@: -1}" - mkdir -p "$target/nemoclaw" "$target/bin/lib" + mkdir -p "$target/nemoclaw" "$target/bin/lib" "$target/scripts" echo '{"name":"nemoclaw","version":"0.5.0","dependencies":{"openclaw":"2026.3.11"}}' > "$target/package.json" echo '{"name":"nemoclaw-plugin","version":"0.5.0"}' > "$target/nemoclaw/package.json" cat > "$target/bin/lib/usage-notice.js" <<'EOS' @@ -1691,6 +1771,13 @@ if [ "$1" = "clone" ]; then process.exit(0) EOS chmod +x "$target/bin/lib/usage-notice.js" + cat > "$target/scripts/install.sh" <<'EOS' +#!/usr/bin/env bash +set -euo pipefail +# NEMOCLAW_VERSIONED_INSTALLER_PAYLOAD=1 +node "$NEMOCLAW_REPO_ROOT/bin/lib/usage-notice.js" +EOS + chmod +x "$target/scripts/install.sh" exit 0 fi exit 0`, diff --git a/test/policies.test.js b/test/policies.test.js index f57f8d2d9..43e820dfa 100644 --- a/test/policies.test.js +++ b/test/policies.test.js @@ -2,9 +2,95 @@ // SPDX-License-Identifier: Apache-2.0 import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; import { describe, it, expect } from "vitest"; +import { spawnSync } from "node:child_process"; import policies from "../bin/lib/policies"; +const REPO_ROOT = path.join(import.meta.dirname, ".."); +const CLI_PATH = JSON.stringify(path.join(REPO_ROOT, "bin", "nemoclaw.js")); +const CREDENTIALS_PATH = JSON.stringify(path.join(REPO_ROOT, "bin", "lib", "credentials.js")); +const POLICIES_PATH = JSON.stringify(path.join(REPO_ROOT, "bin", "lib", "policies.js")); +const REGISTRY_PATH = JSON.stringify(path.join(REPO_ROOT, "bin", "lib", "registry.js")); +const SELECT_FROM_LIST_ITEMS = [ + { name: "npm", description: "npm and Yarn registry access" }, + { name: "pypi", description: "Python Package Index (PyPI) access" }, +]; + +function runPolicyAdd(confirmAnswer) { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-policy-add-")); + const scriptPath = path.join(tmpDir, "policy-add-check.js"); + const script = String.raw` +const registry = require(${REGISTRY_PATH}); +const policies = require(${POLICIES_PATH}); +const credentials = require(${CREDENTIALS_PATH}); +const calls = []; +policies.selectFromList = async () => "pypi"; +credentials.prompt = async (message) => { + calls.push({ type: "prompt", message }); + return ${JSON.stringify(confirmAnswer)}; +}; +registry.getSandbox = (name) => (name === "test-sandbox" ? { name } : null); +registry.listSandboxes = () => ({ sandboxes: [{ name: "test-sandbox" }] }); +policies.listPresets = () => [ + { name: "npm", description: "npm and Yarn registry access" }, + { name: "pypi", description: "Python Package Index (PyPI) access" }, +]; +policies.getAppliedPresets = () => []; +policies.applyPreset = (sandboxName, presetName) => { + calls.push({ type: "apply", sandboxName, presetName }); +}; +process.argv = ["node", "nemoclaw.js", "test-sandbox", "policy-add"]; +require(${CLI_PATH}); +setImmediate(() => { + process.stdout.write(JSON.stringify(calls)); +}); +`; + + fs.writeFileSync(scriptPath, script); + + return spawnSync(process.execPath, [scriptPath], { + cwd: REPO_ROOT, + encoding: "utf-8", + env: { + ...process.env, + HOME: tmpDir, + }, + }); +} + +function runSelectFromList(input, { applied = [] } = {}) { + const script = String.raw` +const { selectFromList } = require(${POLICIES_PATH}); +const items = JSON.parse(process.env.NEMOCLAW_TEST_ITEMS); +const options = JSON.parse(process.env.NEMOCLAW_TEST_OPTIONS || "{}"); + +selectFromList(items, options) + .then((value) => { + process.stdout.write(String(value) + "\n"); + }) + .catch((error) => { + const message = error && error.message ? error.message : String(error); + process.stderr.write(message); + process.exit(1); + }); +`; + + return spawnSync(process.execPath, ["-e", script], { + cwd: REPO_ROOT, + encoding: "utf-8", + timeout: 5000, + input, + env: { + ...process.env, + NEMOCLAW_TEST_ITEMS: JSON.stringify(SELECT_FROM_LIST_ITEMS), + NEMOCLAW_TEST_OPTIONS: JSON.stringify({ applied }), + }, + }); +} + describe("policies", () => { describe("listPresets", () => { it("returns all 9 presets", () => { @@ -426,4 +512,96 @@ describe("policies", () => { } }); }); + + describe("selectFromList", () => { + it("returns preset name by number from stdin input", () => { + const result = runSelectFromList("1\n"); + + expect(result.status).toBe(0); + expect(result.stdout.trim()).toBe("npm"); + expect(result.stderr).toContain("Choose preset [1]:"); + }); + + it("uses the first preset as the default when input is empty", () => { + const result = runSelectFromList("\n"); + + expect(result.status).toBe(0); + expect(result.stderr).toContain("Choose preset [1]:"); + expect(result.stdout.trim()).toBe("npm"); + }); + + it("defaults to the first not-applied preset", () => { + const result = runSelectFromList("\n", { applied: ["npm"] }); + + expect(result.status).toBe(0); + expect(result.stderr).toContain("Choose preset [2]:"); + expect(result.stdout.trim()).toBe("pypi"); + }); + + it("rejects selecting an already-applied preset", () => { + const result = runSelectFromList("1\n", { applied: ["npm"] }); + + expect(result.status).toBe(0); + expect(result.stderr).toContain("Preset 'npm' is already applied."); + expect(result.stdout.trim()).toBe("null"); + }); + + it("rejects out-of-range preset number", () => { + const result = runSelectFromList("99\n"); + + expect(result.status).toBe(0); + expect(result.stderr).toContain("Invalid preset number."); + expect(result.stdout.trim()).toBe("null"); + }); + + it("rejects non-numeric preset input", () => { + const result = runSelectFromList("npm\n"); + + expect(result.status).toBe(0); + expect(result.stderr).toContain("Invalid preset number."); + expect(result.stdout.trim()).toBe("null"); + }); + + it("prints numbered list with applied markers, legend, and default prompt", () => { + const result = runSelectFromList("2\n", { applied: ["npm"] }); + + expect(result.status).toBe(0); + expect(result.stderr).toMatch(/Available presets:/); + expect(result.stderr).toMatch(/1\) ● npm — npm and Yarn registry access/); + expect(result.stderr).toMatch(/2\) ○ pypi — Python Package Index \(PyPI\) access/); + expect(result.stderr).toMatch(/● applied, ○ not applied/); + expect(result.stderr).toMatch(/Choose preset \[2\]:/); + expect(result.stdout.trim()).toBe("pypi"); + }); + }); + + describe("policy-add confirmation", () => { + it("prompts for confirmation before applying a preset", () => { + const result = runPolicyAdd("y"); + + expect(result.status).toBe(0); + const calls = JSON.parse(result.stdout.trim()); + expect(calls).toContainEqual({ + type: "prompt", + message: " Apply 'pypi' to sandbox 'test-sandbox'? [Y/n]: ", + }); + expect(calls).toContainEqual({ + type: "apply", + sandboxName: "test-sandbox", + presetName: "pypi", + }); + }); + + it("skips applying the preset when confirmation is declined", () => { + const result = runPolicyAdd("n"); + + expect(result.status).toBe(0); + const calls = JSON.parse(result.stdout.trim()); + expect(calls).toContainEqual({ + type: "prompt", + message: " Apply 'pypi' to sandbox 'test-sandbox'? [Y/n]: ", + }); + expect(calls.some((call) => call.type === "apply")).toBeFalsy(); + }); + }); }); diff --git a/test/telegram-api.test.js b/test/telegram-api.test.js new file mode 100644 index 000000000..c6424b7cf --- /dev/null +++ b/test/telegram-api.test.js @@ -0,0 +1,179 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** + * Tests for bin/lib/telegram-api.js — the shared Telegram API client. + * + * Uses local TLS servers to simulate Telegram API behavior without + * hitting the real API. Verifies socket timeout, recovery, and error + * handling using the actual production tgApi function. + */ + +import { describe, it, expect, afterEach } from "vitest"; +import { createRequire } from "node:module"; +import https from "node:https"; +import net from "node:net"; +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; + +const require = createRequire(import.meta.url); +const { tgApi } = require("../bin/lib/telegram-api"); + +// ── Self-signed cert for local test servers ────────────────────────── +const tmpDir = fs.mkdtempSync("/tmp/tg-api-test-"); +const keyPath = path.join(tmpDir, "key.pem"); +const certPath = path.join(tmpDir, "cert.pem"); +execFileSync( + "openssl", + [ + "req", + "-x509", + "-newkey", + "rsa:2048", + "-keyout", + keyPath, + "-out", + certPath, + "-days", + "1", + "-nodes", + "-subj", + "/CN=localhost", + ], + { stdio: "ignore" }, +); +const key = fs.readFileSync(keyPath); +const cert = fs.readFileSync(certPath); +fs.rmSync(tmpDir, { recursive: true }); + +// ── Helpers ────────────────────────────────────────────────────────── +const servers = []; + +/** + * Create a local HTTPS test server with the given request handler. + * + * @param {import("http").RequestListener} handler - Node.js (req, res) request handler + * @returns {Promise<{server: import("https").Server, port: number}>} + */ +function createServer(handler) { + return new Promise((resolve) => { + const server = https.createServer({ key, cert }, handler); + server.listen(0, "127.0.0.1", () => { + servers.push(server); + const addr = server.address(); + const port = /** @type {import("net").AddressInfo} */ (addr).port; + resolve({ server, port }); + }); + }); +} + +/** Build opts that point tgApi at a local test server. */ +function localOpts(port, timeoutMs = 2000) { + return { hostname: "127.0.0.1", port, timeout: timeoutMs, rejectUnauthorized: false }; +} + +afterEach(async () => { + const toClose = servers.splice(0, servers.length); + await Promise.all( + toClose.map( + (s) => + new Promise((resolve) => { + if (s.closeAllConnections) s.closeAllConnections(); + s.close(() => resolve()); + }), + ), + ); +}); + +// ── Tests ──────────────────────────────────────────────────────────── + +describe("tgApi (bin/lib/telegram-api)", () => { + it("resolves normally when server responds promptly", async () => { + const { port } = await createServer((_req, res) => { + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ ok: true, result: { update_id: 1 } })); + }); + + const result = await tgApi("fake-token", "getUpdates", { offset: 0 }, localOpts(port)); + expect(result.ok).toBe(true); + }); + + it("rejects with timeout when server hangs (simulates network drop)", async () => { + const { port } = await createServer(() => { + // never respond — simulates dead TCP connection + }); + + const start = Date.now(); + await expect( + tgApi("fake-token", "getUpdates", { offset: 0 }, localOpts(port, 1000)), + ).rejects.toThrow("timed out"); + const elapsed = Date.now() - start; + expect(elapsed).toBeGreaterThanOrEqual(900); + expect(elapsed).toBeLessThan(5000); + }); + + it("timeout fires within expected window", async () => { + const { port } = await createServer(() => { + /* never respond */ + }); + + const start = Date.now(); + await expect( + tgApi("fake-token", "getUpdates", { offset: 0 }, localOpts(port, 500)), + ).rejects.toThrow("timed out"); + const elapsed = Date.now() - start; + expect(elapsed).toBeGreaterThanOrEqual(450); + expect(elapsed).toBeLessThan(2000); + }); + + it("poll loop recovers after timeout", async () => { + let reqCount = 0; + const { port } = await createServer((_req, res) => { + reqCount++; + if (reqCount === 1) return; // first: hang + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ ok: true, result: [] })); + }); + + // First call: timeout + await expect( + tgApi("fake-token", "getUpdates", { offset: 0 }, localOpts(port, 500)), + ).rejects.toThrow("timed out"); + + // Second call: should succeed (poll loop recovery) + const result = await tgApi("fake-token", "getUpdates", { offset: 0 }, localOpts(port, 500)); + expect(result.ok).toBe(true); + }); + + it("rejects when server closes connection mid-response", async () => { + const { port } = await createServer((req, res) => { + res.writeHead(200, { "Content-Type": "application/json" }); + res.write('{"ok":'); + setTimeout(() => req.socket.destroy(), 50); + }); + + // With res.on("aborted") and res.on("error") handlers, a + // mid-response socket destroy now rejects instead of hanging. + const result = await Promise.race([ + tgApi("fake-token", "getUpdates", { offset: 0 }, localOpts(port, 2000)) + .then(() => "resolved") + .catch(() => "rejected"), + new Promise((r) => setTimeout(() => r("hung"), 3000)), + ]); + expect(result).not.toBe("hung"); + }); + + it("handles connection refused (server down)", async () => { + const tempServer = net.createServer(); + await new Promise((r) => tempServer.listen(0, "127.0.0.1", () => r())); + const port = /** @type {import("net").AddressInfo} */ (tempServer.address()).port; + await new Promise((resolve, reject) => + tempServer.close((err) => (err ? reject(err) : resolve())), + ); + + await expect( + tgApi("fake-token", "getUpdates", { offset: 0 }, localOpts(port, 2000)), + ).rejects.toThrow(); + }); +});