#!/usr/bin/env bash # Install DeepSeek Harness + dsh-remote on a Mac as a slave of a dsh-remote master. # No sudo, no Homebrew: Node goes to ~/.local/opt/node, DSH to ~/.local, and DSH runs as a # LaunchAgent (starts at login, restarts on crash) on 127.0.0.1:3081. # # curl -fsSL https://git.rizzy.dev/DSH-Plugins/dsh-remote/raw/branch/main/deploy/install-mac.sh \ # | bash -s -- --master http://192.168.0.111:3080 --code ABCD-EFGH-JKLM-NPQR --name my-mac # # Options: # --master URL master DSH (LAN http://… or public https://…) [required] # --code CODE one-time pairing code (master → Settings → Remote devices) [required unless already paired] # --name NAME device name shown everywhere (default: the Mac's ComputerName) # --port PORT local DSH port (default 3081) # --accept LIST comma list of secrets to accept from the master (default OMNI_API_KEY,FORGE_CREDENTIALS) # --no-service don't install the LaunchAgent (run `dsh web` yourself) # --edge MODE … the master sits behind an auth proxy: basic|bearer|oauth2|headers, with # --edge-user, --edge-token-url, --edge-client-id, --edge-client-secret, # --edge-header "Name: value" (secrets are prompted for, never in argv) # Re-running is safe: it updates Node/DSH/dsh-remote and keeps the existing pairing. set -euo pipefail MASTER="" CODE="" NAME="" PORT=3081 ACCEPT="OMNI_API_KEY,FORGE_CREDENTIALS" SERVICE=1 EDGE_ARGS=() NODE_MAJOR=22 REPO_TGZ="https://git.rizzy.dev/DSH-Plugins/dsh-remote/archive/main.tar.gz" while [ $# -gt 0 ]; do case "$1" in --master) MASTER="${2%/}"; shift 2 ;; --code) CODE="$2"; shift 2 ;; --name) NAME="$2"; shift 2 ;; --port) PORT="$2"; shift 2 ;; --accept) ACCEPT="$2"; shift 2 ;; --no-service) SERVICE=0; shift ;; --edge|--edge-user|--edge-token-url|--edge-client-id|--edge-scope|--edge-header) EDGE_ARGS+=("$1" "$2"); shift 2 ;; --edge-client-secret) EDGE_ARGS+=("$1"); shift ;; -h|--help) sed -n '2,19p' "$0" 2>/dev/null || true; exit 0 ;; *) echo "unknown option: $1" >&2; exit 2 ;; esac done say() { printf '\033[1;34m==>\033[0m %s\n' "$*"; } die() { printf '\033[1;31merror:\033[0m %s\n' "$*" >&2; exit 1; } [ "$(uname -s)" = Darwin ] || die "this installer is for macOS (use deploy/install-local.mjs elsewhere)" [ -n "$MASTER" ] || die "--master is required (e.g. http://192.168.0.111:3080)" [ -n "$NAME" ] || NAME="$(scutil --get ComputerName 2>/dev/null || hostname -s)" PREFIX="$HOME/.local" DSH_HOME="${DSH_HOME:-$HOME/.dsh}" PROFILE="$DSH_HOME/profiles/web" PLUGIN_DIR="$DSH_HOME/plugins/dsh-remote" export PATH="$PREFIX/bin:$PREFIX/opt/node/bin:$PATH" mkdir -p "$PREFIX/bin" "$PREFIX/opt" "$DSH_HOME/plugins" "$DSH_HOME/logs" # 1. Master reachable? say "Checking master $MASTER" code=$(curl -s -o /dev/null -w '%{http_code}' -m 10 "$MASTER/" || true) case "$code" in 2*|3*|401|403) ;; *) die "master $MASTER not reachable (HTTP ${code:-none})" ;; esac # 2. Node 22 (official tarball, per-user). have_node() { command -v node >/dev/null && [ "$(node -p 'process.versions.node.split(".")[0]')" -ge "$NODE_MAJOR" ] 2>/dev/null; } if have_node; then say "Node $(node -v) already available" else arch=$(uname -m); [ "$arch" = arm64 ] || arch=x64 base="https://nodejs.org/dist/latest-v${NODE_MAJOR}.x" file=$(curl -fsSL "$base/SHASUMS256.txt" | awk -v a="darwin-$arch.tar.gz" '$2 ~ a"$" {print $2; exit}') sum=$(curl -fsSL "$base/SHASUMS256.txt" | awk -v f="$file" '$2==f {print $1}') [ -n "$file" ] || die "could not find a Node $NODE_MAJOR build for darwin-$arch" say "Installing $file into $PREFIX/opt/node" tmp=$(mktemp -d) curl -fsSL "$base/$file" -o "$tmp/node.tgz" echo "$sum $tmp/node.tgz" | shasum -a 256 -c - >/dev/null || die "Node checksum mismatch" rm -rf "$PREFIX/opt/node.new" && mkdir -p "$PREFIX/opt/node.new" tar -xzf "$tmp/node.tgz" -C "$PREFIX/opt/node.new" --strip-components 1 rm -rf "$PREFIX/opt/node" && mv "$PREFIX/opt/node.new" "$PREFIX/opt/node" rm -rf "$tmp" for b in node npm npx corepack; do ln -sf "$PREFIX/opt/node/bin/$b" "$PREFIX/bin/$b"; done fi npm config set prefix "$PREFIX" >/dev/null # 3. DSH + pnpm (DSH's plugin manager installs registry plugins with pnpm). say "Installing @deepseek-ai/dsh and pnpm" npm install -g --no-fund --no-audit @deepseek-ai/dsh pnpm@10 >/dev/null command -v dsh >/dev/null || die "dsh not on PATH after install" say "$(dsh --version 2>/dev/null | head -1 | sed 's/^/dsh /')" # 4. Initialize the web profile (no server started). [ -f "$PROFILE/package.json" ] || dsh web --dump-config >/dev/null 2>&1 || true [ -f "$PROFILE/package.json" ] || die "could not initialize $PROFILE" # 5. dsh-remote plugin. say "Installing dsh-remote into $PLUGIN_DIR" tmp=$(mktemp -d) curl -fsSL "$REPO_TGZ" -o "$tmp/r.tgz" mkdir -p "$tmp/x" && tar -xzf "$tmp/r.tgz" -C "$tmp/x" rm -rf "$PLUGIN_DIR.new" && mv "$tmp/x/dsh-remote" "$PLUGIN_DIR.new" rm -rf "$PLUGIN_DIR" && mv "$PLUGIN_DIR.new" "$PLUGIN_DIR" rm -rf "$tmp" RESTART_FLAG=""; [ "$SERVICE" = 1 ] && RESTART_FLAG="--restart-on-sync" node "$PLUGIN_DIR/deploy/install-local.mjs" --role slave --name "$NAME" --accept "$ACCEPT" $RESTART_FLAG >/dev/null # 6. Pair (exchange the one-time code for a device key) unless already paired. STATE="$PROFILE/dsh-remote/state.json" if [ -f "$STATE" ] && grep -q '"deviceId"' "$STATE"; then say "Already paired (keeping $STATE)" else [ -n "$CODE" ] || die "--code is required for the first pairing (master → Settings → Remote devices → Create pairing code)" say "Pairing with $MASTER as \"$NAME\"" node "$PLUGIN_DIR/deploy/pair.mjs" --master "$MASTER" --code "$CODE" --name "$NAME" --state "$STATE" ${EDGE_ARGS[@]+"${EDGE_ARGS[@]}"} fi # 7. LaunchAgent: dsh web on 127.0.0.1:$PORT, at login, restarted on exit. if [ "$SERVICE" = 1 ]; then LABEL="dev.rizzy.dsh-web" PLIST="$HOME/Library/LaunchAgents/$LABEL.plist" mkdir -p "$HOME/Library/LaunchAgents" cat > "$PLIST" < Label$LABEL ProgramArguments $PREFIX/bin/dshweb --host127.0.0.1 --port$PORT--no-open WorkingDirectory$HOME EnvironmentVariables PATH$PREFIX/bin:$PREFIX/opt/node/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin DSH_HOME$DSH_HOME RunAtLoad KeepAlive StandardOutPath$DSH_HOME/logs/dsh-web.out.log StandardErrorPath$DSH_HOME/logs/dsh-web.err.log PLIST launchctl bootout "gui/$(id -u)/$LABEL" 2>/dev/null || true launchctl bootstrap "gui/$(id -u)" "$PLIST" say "Starting DSH (LaunchAgent $LABEL)" for _ in $(seq 1 60); do curl -s -m 2 -o /dev/null "http://127.0.0.1:$PORT/" && break; sleep 1; done curl -s -m 2 -o /dev/null "http://127.0.0.1:$PORT/" || die "DSH did not come up; see $DSH_HOME/logs/dsh-web.err.log" url=$(grep -Eo "http://127\.0\.0\.1:$PORT/\?token=[A-Za-z0-9_-]+" "$DSH_HOME/logs/dsh-web.out.log" 2>/dev/null | tail -1 || true) fi cat <