#!/usr/bin/env bash # install-skill.sh v2.0 — Install instbox into the OpenClaw workspace skills directory # # Usage: # # One-click install from dispatcher server (recommended) # curl -fsSL https:///skills/install-script | bash # # # With a direct DISPATCHER_URL when running locally # DISPATCHER_URL=https:// bash install-skill.sh # # # Custom install directory # DISPATCHER_URL=https:// bash install-skill.sh --dir /custom/skills # # # Force reinstall even if the same version is already present # DISPATCHER_URL=https:// bash install-skill.sh --force # # # Only install when the remote has a newer version # DISPATCHER_URL=https:// bash install-skill.sh --upgrade # # # Preview what would happen without making any changes # DISPATCHER_URL=https:// bash install-skill.sh --dry-run set -euo pipefail # ── Script version ───────────────────────────────────────────────────────────── SCRIPT_VERSION="2.0.0" # ── Argument parsing ─────────────────────────────────────────────────────────── CUSTOM_DIR="" OPT_FORCE=0 OPT_UPGRADE=0 OPT_DRY_RUN=0 OPT_QUIET=0 NO_COLOR="${NO_COLOR:-0}" while [[ $# -gt 0 ]]; do case "$1" in --dir) CUSTOM_DIR="$2"; shift 2 ;; --force) OPT_FORCE=1; shift ;; --upgrade) OPT_UPGRADE=1; shift ;; --dry-run) OPT_DRY_RUN=1; shift ;; --quiet|-q) OPT_QUIET=1; shift ;; --no-color) NO_COLOR=1; shift ;; --version) echo "install-skill.sh v${SCRIPT_VERSION}"; exit 0 ;; --help|-h) cat < Custom skills directory (default: ~/.openclaw/workspace/skills) --force Reinstall even if the same version is already installed --upgrade Skip if the installed version is already up to date --dry-run Show what would happen without making any changes --quiet, -q Suppress informational output --no-color Disable colored output --version Print script version and exit Environment variables: DISPATCHER_URL Base URL of the dispatcher server (required when running directly) SKILLS_DIR Override the default skills directory EOF exit 0 ;; *) echo "Unknown argument: $1" >&2; exit 1 ;; esac done # ── Color helpers ────────────────────────────────────────────────────────────── if [[ "$NO_COLOR" -eq 0 ]] && [[ -t 1 ]] && command -v tput &>/dev/null; then C_RESET=$(tput sgr0 2>/dev/null || true) C_BOLD=$(tput bold 2>/dev/null || true) C_GREEN=$(tput setaf 2 2>/dev/null || true) C_YELLOW=$(tput setaf 3 2>/dev/null || true) C_RED=$(tput setaf 1 2>/dev/null || true) C_CYAN=$(tput setaf 6 2>/dev/null || true) else C_RESET="" C_BOLD="" C_GREEN="" C_YELLOW="" C_RED="" C_CYAN="" fi # ── Logging ──────────────────────────────────────────────────────────────────── TAG="[install-skill]" log() { [[ "$OPT_QUIET" -eq 0 ]] && echo "${TAG} $*" || true; } log_ok() { [[ "$OPT_QUIET" -eq 0 ]] && echo "${TAG} ${C_GREEN}✓${C_RESET} $*" || true; } log_step() { [[ "$OPT_QUIET" -eq 0 ]] && echo "${TAG} ${C_BOLD}${C_CYAN}$*${C_RESET}" || true; } log_warn() { echo "${TAG} ${C_YELLOW}⚠${C_RESET} $*" >&2; } log_err() { echo "${TAG} ${C_RED}✗${C_RESET} $*" >&2; } dryrun() { echo "${TAG} ${C_YELLOW}[dry-run]${C_RESET} $*"; } # ── Configuration ────────────────────────────────────────────────────────────── SKILL_NAME="instbox" BASE_URL="${DISPATCHER_URL:-https://instbox.anispark.ai}" BASE_URL="${BASE_URL%/}" # Strip any path prefix injected by reverse proxies; keep scheme://host[:port] only BASE_URL=$(echo "$BASE_URL" | awk -F/ '{print $1"//"$3}') TOKEN="${INSTBOX_TOKEN:-}" DOWNLOAD_URL="${BASE_URL}/skills/${SKILL_NAME}.tar.gz?t=$(date +%s)" MAX_RETRIES=3 # Install directory: --dir arg > SKILLS_DIR env > default OpenClaw workspace if [[ -n "$CUSTOM_DIR" ]]; then SKILLS_DIR="$CUSTOM_DIR" elif [[ -z "${SKILLS_DIR:-}" ]]; then SKILLS_DIR="${HOME}/.openclaw/workspace/skills" fi INSTALL_DIR="${SKILLS_DIR}/${SKILL_NAME}" TMP_TAR="${SKILLS_DIR}/.${SKILL_NAME}.download.tar.gz" TMP_EXTRACT="${SKILLS_DIR}/.${SKILL_NAME}.extract.tmp" BACKUP_DIR="${SKILLS_DIR}/.${SKILL_NAME}.backup" # ── Version comparison helper ────────────────────────────────────────────────── # Returns 0 (true) if version $1 >= version $2 (dot-separated, up to 4 fields) version_gte() { local a="$1" b="$2" local IFS=. read -ra va <<< "$a" read -ra vb <<< "$b" local i na nb for i in 0 1 2 3; do na="${va[$i]:-0}" nb="${vb[$i]:-0}" if [ "$na" -gt "$nb" ]; then return 0; fi if [ "$na" -lt "$nb" ]; then return 1; fi done return 0 # equal } # ── Preflight banner ─────────────────────────────────────────────────────────── log "════════════════════════════════════════" log " instbox Installer v${SCRIPT_VERSION}" log "════════════════════════════════════════" log " Source : ${BASE_URL}" log " Target : ${SKILLS_DIR}" [[ "$OPT_DRY_RUN" -eq 1 ]] && log " Mode : ${C_YELLOW}DRY RUN${C_RESET} (no changes will be made)" [[ "$OPT_FORCE" -eq 1 ]] && log " Mode : ${C_YELLOW}FORCE${C_RESET} (reinstall regardless of version)" [[ "$OPT_UPGRADE" -eq 1 ]] && log " Mode : ${C_CYAN}UPGRADE${C_RESET} (skip if already up to date)" log "════════════════════════════════════════" # ── Preflight: Node.js ───────────────────────────────────────────────────────── if ! command -v node &>/dev/null; then log_err "Node.js not found. Install Node.js >= 18 from https://nodejs.org/ and try again." exit 1 fi NODE_MAJOR=$(node -e "process.stdout.write(String(process.versions.node.split('.')[0]))") if [ "$NODE_MAJOR" -lt 18 ]; then log_err "Node.js >= 18 required (found $(node --version)). Upgrade from https://nodejs.org/" exit 1 fi log_ok "Node.js $(node --version)" # ── Preflight: downloader ────────────────────────────────────────────────────── if command -v curl &>/dev/null; then DOWNLOADER="curl" elif command -v wget &>/dev/null; then DOWNLOADER="wget" else log_err "curl or wget is required but neither was found." exit 1 fi log_ok "Downloader: ${DOWNLOADER}" # ── Version check ────────────────────────────────────────────────────────────── INSTALLED_VERSION="" if [[ -f "${INSTALL_DIR}/package.json" ]]; then INSTALLED_VERSION=$(node -e \ "try{process.stdout.write(require('${INSTALL_DIR}/package.json').version)}catch(e){}" \ 2>/dev/null || true) fi if [[ -n "$INSTALLED_VERSION" ]]; then log_ok "Detected existing installation: v${INSTALLED_VERSION}" else log " No existing installation found" fi # --upgrade: fetch remote version and skip if already up to date if [[ "$OPT_UPGRADE" -eq 1 ]] && [[ "$OPT_FORCE" -eq 0 ]] && [[ -n "$INSTALLED_VERSION" ]]; then log " Checking remote version..." REMOTE_VERSION="" if [[ "$DOWNLOADER" == "curl" ]]; then REMOTE_VERSION=$(curl -fsSL --max-time 10 "${BASE_URL}/api/info" 2>/dev/null \ | node -e "let d='';process.stdin.on('data',c=>d+=c).on('end',()=>{try{process.stdout.write(JSON.parse(d).skillVersion||'')}catch(e){}})" \ 2>/dev/null || true) else REMOTE_VERSION=$(wget -qO- --timeout=10 "${BASE_URL}/api/info" 2>/dev/null \ | node -e "let d='';process.stdin.on('data',c=>d+=c).on('end',()=>{try{process.stdout.write(JSON.parse(d).skillVersion||'')}catch(e){}})" \ 2>/dev/null || true) fi if [[ -n "$REMOTE_VERSION" ]]; then log_ok "Remote version: v${REMOTE_VERSION}" if version_gte "$INSTALLED_VERSION" "$REMOTE_VERSION"; then log_ok "Already up to date (v${INSTALLED_VERSION}). Use --force to reinstall." exit 0 fi log " Upgrade available: v${INSTALLED_VERSION} → v${REMOTE_VERSION}" else log_warn "Could not determine remote version — proceeding with install." fi fi # ── Dry-run exit ─────────────────────────────────────────────────────────────── if [[ "$OPT_DRY_RUN" -eq 1 ]]; then dryrun "Would download: ${DOWNLOAD_URL}" dryrun "Would install to: ${INSTALL_DIR}" [[ -n "$INSTALLED_VERSION" ]] && dryrun "Would replace v${INSTALLED_VERSION}" exit 0 fi # ── Exit trap: cleanup temp files and roll back on failure ───────────────────── _exit_handler() { local ec=$? rm -f "$TMP_TAR" 2>/dev/null || true rm -rf "$TMP_EXTRACT" 2>/dev/null || true if [[ $ec -ne 0 ]] && [[ -d "$BACKUP_DIR" ]]; then log_warn "Installation failed — restoring previous version..." rm -rf "$INSTALL_DIR" 2>/dev/null || true if mv "$BACKUP_DIR" "$INSTALL_DIR" 2>/dev/null; then log_warn "Rollback complete. Previous version restored." else log_err "Rollback failed. Backup left at: ${BACKUP_DIR}" fi else rm -rf "$BACKUP_DIR" 2>/dev/null || true fi exit $ec } trap '_exit_handler' EXIT # ── Step 1: Prepare directories ──────────────────────────────────────────────── log_step "[1/4] Preparing install directory..." mkdir -p "$SKILLS_DIR" log_ok "Skills directory ready: ${SKILLS_DIR}" # ── Step 2: Download with retry ──────────────────────────────────────────────── log_step "[2/4] Downloading ${SKILL_NAME}..." ATTEMPT=0 DOWNLOAD_OK=0 while [ "$ATTEMPT" -lt "$MAX_RETRIES" ]; do ATTEMPT=$(( ATTEMPT + 1 )) log " Attempt ${ATTEMPT}/${MAX_RETRIES}..." if [[ "$DOWNLOADER" == "curl" ]]; then curl -fsSL --max-time 60 "$DOWNLOAD_URL" -o "$TMP_TAR" 2>/dev/null && DOWNLOAD_OK=1 && break else wget -q --timeout=60 "$DOWNLOAD_URL" -O "$TMP_TAR" 2>/dev/null && DOWNLOAD_OK=1 && break fi if [ "$ATTEMPT" -lt "$MAX_RETRIES" ]; then log_warn "Download failed, retrying in 3s..." sleep 3 fi done if [ "$DOWNLOAD_OK" -eq 0 ]; then log_err "Download failed after ${MAX_RETRIES} attempts." log_err "Verify the dispatcher server is reachable: ${BASE_URL}" exit 1 fi log_ok "Downloaded ($(du -sh "$TMP_TAR" 2>/dev/null | cut -f1 || echo "?"))" # ── Step 3: Validate archive ─────────────────────────────────────────────────── log_step "[3/4] Validating archive..." if ! tar -tzf "$TMP_TAR" > /dev/null 2>&1; then log_err "Archive is corrupt or the download was incomplete." exit 1 fi if ! tar -tzf "$TMP_TAR" | grep -q "bin/instbox.js"; then log_err "Archive does not contain expected binary 'bin/instbox.js'. Refusing to install." exit 1 fi log_ok "Archive validated" # ── Step 4: Atomic install ───────────────────────────────────────────────────── log_step "[4/4] Installing..." # Preserve user .env before replacing the installation SAVED_ENV="" if [[ -f "${INSTALL_DIR}/.env" ]]; then SAVED_ENV=$(mktemp) cp "${INSTALL_DIR}/.env" "$SAVED_ENV" log_ok "Preserved user .env" fi # Back up current installation for rollback if [[ -d "$INSTALL_DIR" ]]; then rm -rf "$BACKUP_DIR" 2>/dev/null || true cp -r "$INSTALL_DIR" "$BACKUP_DIR" log_ok "Backup created" fi # Extract to temp directory first (safe: old version untouched until proven good) rm -rf "$TMP_EXTRACT" mkdir -p "$TMP_EXTRACT" tar -xzf "$TMP_TAR" -C "$TMP_EXTRACT" # Locate the extracted skill root (handles archives with or without a wrapper dir) EXTRACTED_ROOT="" if [[ -f "${TMP_EXTRACT}/${SKILL_NAME}/bin/instbox.js" ]]; then EXTRACTED_ROOT="${TMP_EXTRACT}/${SKILL_NAME}" elif [[ -f "${TMP_EXTRACT}/bin/instbox.js" ]]; then EXTRACTED_ROOT="$TMP_EXTRACT" else for d in "${TMP_EXTRACT}"/*/; do if [[ -f "${d}bin/instbox.js" ]]; then EXTRACTED_ROOT="${d%/}"; break fi done fi if [[ -z "$EXTRACTED_ROOT" ]]; then log_err "Cannot locate skill root inside extracted archive." exit 1 fi # Atomic swap: remove old, move new into place rm -rf "$INSTALL_DIR" mv "$EXTRACTED_ROOT" "$INSTALL_DIR" # Restore user .env (overrides any default bundled in the package) if [[ -n "$SAVED_ENV" ]]; then cp "$SAVED_ENV" "${INSTALL_DIR}/.env" rm -f "$SAVED_ENV" log_ok "Restored user .env" fi # Ensure the binary is executable chmod +x "${INSTALL_DIR}/bin/instbox.js" 2>/dev/null || true # Write/update BASE_URL and TOKEN in .env — always overwrite with the installation domain ENV_FILE="${INSTALL_DIR}/.env" _has_token() { [[ -n "$TOKEN" ]] && [[ "$TOKEN" != "__TOKEN__" ]]; } _upsert_env_key() { local key="$1" val="$2" file="$3" if grep -q "^${key}=" "$file" 2>/dev/null; then sed -i.bak "s|^${key}=.*|${key}=${val}|" "$file" rm -f "${file}.bak" 2>/dev/null || true else echo "${key}=${val}" >> "$file" fi } if [[ -f "$ENV_FILE" ]]; then _upsert_env_key "BASE_URL" "$BASE_URL" "$ENV_FILE" log_ok "Updated BASE_URL=${BASE_URL} in .env" if _has_token; then _upsert_env_key "TOKEN" "$TOKEN" "$ENV_FILE" log_ok "Updated TOKEN in .env" fi else # First install — create .env printf 'BASE_URL=%s\n' "$BASE_URL" > "$ENV_FILE" if _has_token; then printf 'TOKEN=%s\n' "$TOKEN" >> "$ENV_FILE" fi chmod 600 "$ENV_FILE" 2>/dev/null || true log_ok "Created .env with BASE_URL=${BASE_URL}$(if _has_token; then echo ' and TOKEN'; fi)" fi # ── Post-install verification ────────────────────────────────────────────────── NEW_VERSION=$(node -e \ "try{process.stdout.write(require('${INSTALL_DIR}/package.json').version)}catch(e){process.stdout.write('unknown')}" \ 2>/dev/null || echo "unknown") if ! node "${INSTALL_DIR}/bin/instbox.js" --version > /dev/null 2>&1; then log_err "Post-install check failed: binary did not execute successfully." exit 1 fi log_ok "Binary verified (v${NEW_VERSION})" # ── PATH advisory ────────────────────────────────────────────────────────────── BIN_DIR="${INSTALL_DIR}/bin" if ! command -v instbox &>/dev/null && [[ ":${PATH}:" != *":${BIN_DIR}:"* ]]; then log "" log_warn "instbox is not on your PATH." log " Add it permanently:" log "" log " ${C_CYAN}echo 'export PATH=\"${BIN_DIR}:\$PATH\"' >> ~/.bashrc && source ~/.bashrc${C_RESET}" log "" log " (Replace ~/.bashrc with ~/.zshrc if you use zsh.)" log " Or invoke directly with the full path for now:" log " ${C_CYAN}node ${INSTALL_DIR}/bin/instbox.js --version${C_RESET}" fi # ── Done ─────────────────────────────────────────────────────────────────────── log "" log "════════════════════════════════════════" log_ok "Installation complete! Version: v${NEW_VERSION}" log "" log " Next steps:" log " 1. Restart OpenClaw gateway to load the skill:" log " ${C_CYAN}openclaw gateway restart${C_RESET}" log " 2. Verify the skill is loaded:" log " ${C_CYAN}openclaw skills list${C_RESET}" log "════════════════════════════════════════"