dsh-remote 0.3.0: one-line macOS slave install

- deploy/install-mac.sh: per-user Node 22 (checksum-verified), DSH + pnpm in
  ~/.local, dsh-remote from the repo archive, pairing, LaunchAgent dsh web
- deploy/pair.mjs: pair without a running DSH (detects login-proxy redirects)
- install-local.mjs: --accept secrets list, --restart-on-sync
- sync.restartOnSync: slave restarts itself after synced plugins change
  (waits for idle sessions, at most once per 10 min)
This commit is contained in:
lyuksovannyy committed 2026-10-11 12:00:08 +00:00
1 parent d1e134196d
commit 08202cad67
6 files changed
+243 -2

No files matched your search

+23
View File
@@ -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). `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 ## Pair a device
1. **Master:** Settings → *Remote devices* → **Create pairing code** (single use, valid 15 min). 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 publicUrl: https://dsh.rizzy.dev
sync: sync:
auto: true # slave: sync on link-up and when the master pushes 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*) allow: [] # slave: only these bundles (exact or prefix*)
deny: [] # slave: never these bundles (wins over allow) deny: [] # slave: never these bundles (wins over allow)
secrets: [] # slave: credential refs it accepts from the master (+ FORGE_CREDENTIALS) secrets: [] # slave: credential refs it accepts from the master (+ FORGE_CREDENTIALS)
+3 -1
View File
@@ -42,6 +42,7 @@ try { patch = fs.readFileSync(patchFile, 'utf8') } catch { patch = '' }
if (!/^- id: dsh-remote\b/m.test(patch)) { if (!/^- id: dsh-remote\b/m.test(patch)) {
const role = opt('role', 'auto') const role = opt('role', 'auto')
const name = opt('name', '') 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 = [ const block = [
'# dsh-remote (installed by deploy/install-local.mjs)', '# dsh-remote (installed by deploy/install-local.mjs)',
'- id: dsh-remote', '- id: dsh-remote',
@@ -50,9 +51,10 @@ if (!/^- id: dsh-remote\b/m.test(patch)) {
` deviceName: ${JSON.stringify(name)}`, ` deviceName: ${JSON.stringify(name)}`,
' sync:', ' sync:',
' auto: true', ' auto: true',
` restartOnSync: ${args.includes('--restart-on-sync') ? 'true' : 'false'}`,
' allow: []', ' allow: []',
' deny: []', ' deny: []',
' secrets: []', accept.length ? ` secrets:\n${accept.map((s) => ` - ${s}`).join('\n')}` : ' secrets: []',
'', '',
].join('\n') ].join('\n')
// An empty/comment-only patch file fails DSH boot, so only append real entries. // An empty/comment-only patch file fails DSH boot, so only append real entries.
+149
View File
@@ -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" <<PLIST
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"><dict>
<key>Label</key><string>$LABEL</string>
<key>ProgramArguments</key><array>
<string>$PREFIX/bin/dsh</string><string>web</string>
<string>--host</string><string>127.0.0.1</string>
<string>--port</string><string>$PORT</string><string>--no-open</string>
</array>
<key>WorkingDirectory</key><string>$HOME</string>
<key>EnvironmentVariables</key><dict>
<key>PATH</key><string>$PREFIX/bin:$PREFIX/opt/node/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
<key>DSH_HOME</key><string>$DSH_HOME</string>
</dict>
<key>RunAtLoad</key><true/>
<key>KeepAlive</key><true/>
<key>StandardOutPath</key><string>$DSH_HOME/logs/dsh-web.out.log</string>
<key>StandardErrorPath</key><string>$DSH_HOME/logs/dsh-web.err.log</string>
</dict></plist>
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 <<DONE
$(say "Done: \"$NAME\" is a dsh-remote slave of $MASTER")
Open DSH on this Mac: ${url:-http://127.0.0.1:$PORT/ (run: dsh web, it prints a login link)}
Sidebar → Devices shows this Mac, the master and every other device.
First link-up syncs plugins + config from the master; DSH restarts itself once
if new plugins arrived (or run: launchctl kickstart -k gui/\$(id -u)/dev.rizzy.dsh-web).
On the master: Settings → Remote devices → "$NAME" → Sync policy to allow secrets.
Logs: $DSH_HOME/logs/dsh-web.{out,err}.log
DONE
+48
View File
@@ -0,0 +1,48 @@
#!/usr/bin/env node
// Pair this machine with a dsh-remote master WITHOUT a running local DSH: exchange a
// one-time pairing code for a device key and write the slave link state.
// node deploy/pair.mjs --master http://192.168.0.111:3080 --code ABCD-... --name my-mac [--state <file>]
// 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 <http(s)://…> --code <pairing code> [--name <device 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}`)
+19
View File
@@ -555,11 +555,30 @@ export function apply(ctx, config = {}) {
lastSync = result lastSync = result
result.restartRequired = profileChanged result.restartRequired = profileChanged
notify({ type: 'sync', result }) 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 return result
})() })()
try { return await syncing } finally { syncing = null } 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 // ------------------------------------------------------------------ HTTP API
const route = (path, methods, handler) => ctx.effect(() => ctx.webServer.register({ const route = (path, methods, handler) => ctx.effect(() => ctx.webServer.register({
kind: 'exact', kind: 'exact',
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@local/dsh-remote", "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/<device>/), plugin + config sync with opt-in secrets.", "description": "Claude-Desktop-style remote control for DSH: master/slave link, every device's sessions on every device (/r/<device>/), plugin + config sync with opt-in secrets.",
"private": true, "private": true,
"type": "module", "type": "module",