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:
1 parent
d1e134196d
commit
08202cad67
6 files changed
+243
-2
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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
|
||||
@@ -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}`)
|
||||
Reference in new issue
Block a user