diff --git a/README.md b/README.md index 9f3359d..fd831ec 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,28 @@ systemctl --user restart dsh # or restart `dsh web` however you run it `deploy/dsh-web.user.service` is a ready-made systemd user unit for a rootless `dsh web` (127.0.0.1:3081). +### macOS: one-line slave install + +Create a pairing code on the master (Settings → *Remote devices* → **Create pairing code**). Then, in Terminal on the Mac: + +```bash +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 XXXX-XXXX-XXXX-XXXX --name my-mac +``` + +No sudo and no Homebrew. The script: + +- installs Node 22 to `~/.local/opt/node` (checksum-verified), and DSH and pnpm to `~/.local`; +- puts dsh-remote in `~/.dsh/plugins/dsh-remote` and registers it as a slave that accepts `OMNI_API_KEY` and `FORGE_CREDENTIALS` (`--accept` changes the list); +- pairs with the code (`deploy/pair.mjs`, no running DSH needed); +- installs the LaunchAgent `dev.rizzy.dsh-web`: `dsh web` on 127.0.0.1:3081, started at login and restarted if it dies. Logs go to `~/.dsh/logs/`. + +On first link-up the Mac pulls the master's plugins and config. With `sync.restartOnSync: true`, set by the script, DSH restarts itself once so new plugins load. It waits until no session is running and restarts at most once per 10 minutes. + +Re-running the script updates everything and keeps the pairing. To remove it: `launchctl bootout gui/$(id -u)/dev.rizzy.dsh-web`, then delete `~/Library/LaunchAgents/dev.rizzy.dsh-web.plist`, `~/.dsh` and `~/.local/opt/node`. + +Away from home, use `--master https://dsh.rizzy.dev` once the edge exempts `/dsh-remote/link` and `/dsh-remote/enroll` from Authentik (see below). + ## Pair a device 1. **Master:** Settings → *Remote devices* → **Create pairing code** (single use, valid 15 min). @@ -86,6 +108,7 @@ GitHub `gh` logins are not shipped. publicUrl: https://dsh.rizzy.dev sync: auto: true # slave: sync on link-up and when the master pushes + restartOnSync: false # slave: restart DSH itself after plugins change (needs a service manager) allow: [] # slave: only these bundles (exact or prefix*) deny: [] # slave: never these bundles (wins over allow) secrets: [] # slave: credential refs it accepts from the master (+ FORGE_CREDENTIALS) diff --git a/deploy/install-local.mjs b/deploy/install-local.mjs index fd82638..a1d886e 100644 --- a/deploy/install-local.mjs +++ b/deploy/install-local.mjs @@ -42,6 +42,7 @@ try { patch = fs.readFileSync(patchFile, 'utf8') } catch { patch = '' } if (!/^- id: dsh-remote\b/m.test(patch)) { const role = opt('role', 'auto') const name = opt('name', '') + const accept = String(opt('accept', '')).split(',').map((s) => s.trim()).filter((s) => /^[A-Z][A-Z0-9_]*$/.test(s)) const block = [ '# dsh-remote (installed by deploy/install-local.mjs)', '- id: dsh-remote', @@ -50,9 +51,10 @@ if (!/^- id: dsh-remote\b/m.test(patch)) { ` deviceName: ${JSON.stringify(name)}`, ' sync:', ' auto: true', + ` restartOnSync: ${args.includes('--restart-on-sync') ? 'true' : 'false'}`, ' allow: []', ' deny: []', - ' secrets: []', + accept.length ? ` secrets:\n${accept.map((s) => ` - ${s}`).join('\n')}` : ' secrets: []', '', ].join('\n') // An empty/comment-only patch file fails DSH boot, so only append real entries. diff --git a/deploy/install-mac.sh b/deploy/install-mac.sh new file mode 100644 index 0000000..c776bcb --- /dev/null +++ b/deploy/install-mac.sh @@ -0,0 +1,149 @@ +#!/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) +# 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 +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 ;; + -h|--help) sed -n '2,17p' "$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" +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 <] +// The state file defaults to $DSH_HOME/profiles/web/dsh-remote/state.json. Restart DSH after. +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' + +const args = process.argv.slice(2) +const opt = (k, d) => { const i = args.indexOf(`--${k}`); return i >= 0 ? args[i + 1] : d } +const master = String(opt('master', '')).replace(/\/+$/, '') +const code = opt('code', '') +const name = opt('name', os.hostname()) +const home = process.env.DSH_HOME || path.join(os.homedir(), '.dsh') +const stateFile = opt('state', path.join(home, 'profiles', opt('profile', 'web'), 'dsh-remote', 'state.json')) +if (!/^https?:\/\//.test(master) || !code) { + console.error('usage: pair.mjs --master --code [--name ]') + process.exit(2) +} + +const slug = (s) => String(s || '').toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40) || 'device' +let state = {} +try { state = JSON.parse(fs.readFileSync(stateFile, 'utf8')) } catch { state = {} } +state.self ||= { id: slug(name), name, createdAt: Date.now() } +state.self.name = name + +const r = await fetch(`${master}/dsh-remote/enroll`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ code: code.trim().toUpperCase(), id: state.self.id, name, platform: `${process.platform}-${process.arch}` }), + redirect: 'manual', +}) +let j = {} +try { j = await r.json() } catch { j = {} } +if (r.status >= 300 && r.status < 400) { + console.error(`pairing failed: ${master} redirected (${r.headers.get('location')?.slice(0, 80)}…). A login proxy (e.g. Authentik) is in front of /dsh-remote/enroll; use the LAN URL or exempt that path.`) + process.exit(1) +} +if (!r.ok) { console.error(`pairing failed: ${j.error || `HTTP ${r.status}`}`); process.exit(1) } + +state.master = { url: master, deviceId: j.deviceId, key: j.key, master: j.master, pairedAt: Date.now() } +state.self.id = j.deviceId +fs.mkdirSync(path.dirname(stateFile), { recursive: true, mode: 0o700 }) +const tmp = `${stateFile}.${process.pid}.tmp` +fs.writeFileSync(tmp, JSON.stringify(state, null, 2), { mode: 0o600 }) +fs.renameSync(tmp, stateFile) +console.log(`paired as "${j.deviceId}" with master ${j.master?.name || master}`) diff --git a/index.js b/index.js index ddc596b..3336ff8 100644 --- a/index.js +++ b/index.js @@ -555,11 +555,30 @@ export function apply(ctx, config = {}) { lastSync = result result.restartRequired = profileChanged notify({ type: 'sync', result }) + // Unattended slaves (LaunchAgent KeepAlive / systemd Restart=always): restart DSH so + // newly synced plugins load. Only when no session is running, and at most once per + // 10 minutes so a broken plugin can't cause a restart loop. + if (profileChanged && config.sync?.restartOnSync) scheduleSelfRestart('plugins changed by sync') return result })() try { return await syncing } finally { syncing = null } } + function scheduleSelfRestart(reason) { + const last = Number(store.data.lastSelfRestart || 0) + if (Date.now() - last < 10 * 60 * 1000) { log.warn(`dsh-remote: restart needed (${reason}) but skipped: restarted <10 min ago`); return } + const attempt = async (tries) => { + let busy = false + try { busy = (await listLocalSessions()).items.some((s) => s.running) } catch { busy = false } + if (busy && tries < 60) { setTimeout(() => attempt(tries + 1), 30000).unref?.(); return } + store.data.lastSelfRestart = Date.now() + store.save() + log.warn(`dsh-remote: restarting DSH (${reason}); the service manager brings it back`) + setTimeout(() => process.kill(process.pid, 'SIGTERM'), 1500).unref?.() + } + setTimeout(() => attempt(0), 3000).unref?.() + } + // ------------------------------------------------------------------ HTTP API const route = (path, methods, handler) => ctx.effect(() => ctx.webServer.register({ kind: 'exact', diff --git a/package.json b/package.json index 2fccae5..6a4b068 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@local/dsh-remote", - "version": "0.2.0", + "version": "0.3.0", "description": "Claude-Desktop-style remote control for DSH: master/slave link, every device's sessions on every device (/r//), plugin + config sync with opt-in secrets.", "private": true, "type": "module",