@geekbeer/minion 3.43.0 → 3.49.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/core/config.js +3 -1
- package/core/lib/board-task-context.js +87 -0
- package/core/lib/board-task-poller.js +210 -0
- package/core/lib/concurrency-manager.js +56 -0
- package/core/lib/dag-step-poller.js +16 -10
- package/core/lib/end-of-day.js +20 -6
- package/core/lib/platform.js +39 -19
- package/core/routes/daemons.js +2 -0
- package/core/routes/diagnose.js +27 -2
- package/docs/api-reference.md +73 -1
- package/linux/board-task-runner.js +227 -0
- package/linux/routes/chat.js +26 -6
- package/linux/server.js +5 -0
- package/mac/bin/hq +4 -0
- package/mac/board-task-runner.js +4 -0
- package/mac/lib/process-manager.js +109 -0
- package/mac/minion-cli.sh +1353 -0
- package/mac/routes/chat.js +7 -0
- package/mac/routes/commands.js +119 -0
- package/mac/routes/config.js +8 -0
- package/mac/routes/directives.js +6 -0
- package/mac/routes/files.js +6 -0
- package/mac/routes/terminal.js +7 -0
- package/mac/routine-runner.js +4 -0
- package/mac/server.js +413 -0
- package/mac/terminal-proxy.js +6 -0
- package/mac/vnc-auth-proxy.js +402 -0
- package/mac/workflow-runner.js +7 -0
- package/package.json +6 -2
- package/postinstall.js +33 -12
- package/rules/core.md +30 -0
- package/win/board-task-runner.js +181 -0
- package/win/routes/chat.js +24 -6
- package/win/routes/terminal.js +8 -0
- package/win/server.js +5 -0
- package/win/wsl-session-server.js +136 -1
|
@@ -0,0 +1,1353 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
#
|
|
3
|
+
# Minion Agent CLI (macOS) - @geekbeer/minion
|
|
4
|
+
# CLI tool for setting up and managing the minion agent on macOS via launchd.
|
|
5
|
+
#
|
|
6
|
+
# Usage:
|
|
7
|
+
# sudo minion-cli-mac setup --user <USERNAME> # System setup (root): user, sudoers, Screen Sharing, plists
|
|
8
|
+
# minion-cli-mac configure [options] # Connect to HQ, deploy skills, restart agent (run as agent user)
|
|
9
|
+
# sudo minion-cli-mac uninstall [--keep-data] # Remove agent and services (root)
|
|
10
|
+
# minion-cli-mac start # Bootstrap agent LaunchAgents
|
|
11
|
+
# minion-cli-mac stop # Bootout agent LaunchAgents
|
|
12
|
+
# minion-cli-mac restart # Kickstart agent LaunchAgent
|
|
13
|
+
# minion-cli-mac status # Get current status
|
|
14
|
+
# minion-cli-mac health # Health check
|
|
15
|
+
# minion-cli-mac daemons # Daemon status
|
|
16
|
+
# minion-cli-mac diagnose # Run full service diagnostics
|
|
17
|
+
# minion-cli-mac set-status busy "Running X" # Set status and task
|
|
18
|
+
#
|
|
19
|
+
# Setup options:
|
|
20
|
+
# --user <USERNAME> Target user for the agent (required)
|
|
21
|
+
#
|
|
22
|
+
# Configure options:
|
|
23
|
+
# --hq-url <URL> HQ server URL (required)
|
|
24
|
+
# --minion-id <UUID> Minion ID (required)
|
|
25
|
+
# --api-token <TOKEN> API token (required)
|
|
26
|
+
# --setup-tunnel Set up cloudflared tunnel as a LaunchAgent
|
|
27
|
+
|
|
28
|
+
set -euo pipefail
|
|
29
|
+
|
|
30
|
+
# Set MINION_TRACE=1 to enable shell tracing (every command printed to stderr
|
|
31
|
+
# with a line number prefix). Useful when setup hangs and the standard output
|
|
32
|
+
# does not pinpoint the culprit.
|
|
33
|
+
#
|
|
34
|
+
# To capture setup output to a log file while preserving live terminal output,
|
|
35
|
+
# use macOS's built-in `script` command (it allocates a pty so output stays
|
|
36
|
+
# line-buffered):
|
|
37
|
+
#
|
|
38
|
+
# script -q /tmp/minion-setup.log sudo minion-cli-mac setup --user minion
|
|
39
|
+
#
|
|
40
|
+
# Avoid `... | tee log` — piping converts stdout to a pipe, which can buffer
|
|
41
|
+
# output and make setup appear frozen even when it is making progress.
|
|
42
|
+
if [ "${MINION_TRACE:-0}" = "1" ]; then
|
|
43
|
+
PS4='+ ${BASH_SOURCE##*/}:${LINENO}: '
|
|
44
|
+
set -x
|
|
45
|
+
fi
|
|
46
|
+
|
|
47
|
+
# ============================================================
|
|
48
|
+
# Globals & helpers
|
|
49
|
+
# ============================================================
|
|
50
|
+
|
|
51
|
+
# Default target user is whoever invoked the script, refined per subcommand.
|
|
52
|
+
if [ "$(id -u)" -eq 0 ]; then
|
|
53
|
+
# When running via sudo, prefer SUDO_USER over root for ergonomics.
|
|
54
|
+
TARGET_USER="${SUDO_USER:-root}"
|
|
55
|
+
else
|
|
56
|
+
TARGET_USER="$(whoami)"
|
|
57
|
+
fi
|
|
58
|
+
TARGET_HOME="$(eval echo "~${TARGET_USER}")"
|
|
59
|
+
RUN_AS=""
|
|
60
|
+
|
|
61
|
+
# Use sudo only when not running as root
|
|
62
|
+
SUDO=""
|
|
63
|
+
if [ "$(id -u)" -ne 0 ] && command -v sudo &>/dev/null; then
|
|
64
|
+
SUDO="sudo"
|
|
65
|
+
fi
|
|
66
|
+
|
|
67
|
+
# Resolve CLI version from package.json
|
|
68
|
+
CLI_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
69
|
+
CLI_VERSION="$(node -p "require('${CLI_DIR}/../package.json').version" 2>/dev/null || echo "unknown")"
|
|
70
|
+
|
|
71
|
+
AGENT_URL="${MINION_AGENT_URL:-http://localhost:8080}"
|
|
72
|
+
|
|
73
|
+
# Brew prefix detection (Apple Silicon vs Intel)
|
|
74
|
+
detect_brew_prefix() {
|
|
75
|
+
if command -v brew &>/dev/null; then
|
|
76
|
+
brew --prefix 2>/dev/null
|
|
77
|
+
return
|
|
78
|
+
fi
|
|
79
|
+
if [ -d /opt/homebrew ]; then echo "/opt/homebrew"; return; fi
|
|
80
|
+
if [ -d /usr/local/Homebrew ]; then echo "/usr/local"; return; fi
|
|
81
|
+
echo ""
|
|
82
|
+
}
|
|
83
|
+
BREW_PREFIX="$(detect_brew_prefix)"
|
|
84
|
+
|
|
85
|
+
# Resolve a user's home directory using dscl (macOS-native; getent does not exist).
|
|
86
|
+
resolve_user_home() {
|
|
87
|
+
local user="$1"
|
|
88
|
+
dscl . -read "/Users/${user}" NFSHomeDirectory 2>/dev/null | awk '{print $2}'
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
# Check whether a user exists.
|
|
92
|
+
user_exists() {
|
|
93
|
+
dscl . -read "/Users/$1" >/dev/null 2>&1
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
# Get a user's UID via dscl (since Linux's `id -u <user>` exists on macOS too,
|
|
97
|
+
# but dscl is the canonical macOS lookup that works for any user, including
|
|
98
|
+
# pre-creation validation — wrap both for robustness).
|
|
99
|
+
resolve_uid() {
|
|
100
|
+
local user="$1"
|
|
101
|
+
id -u "$user" 2>/dev/null || dscl . -read "/Users/${user}" UniqueID 2>/dev/null | awk '{print $2}'
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
# Generate a random alphanumeric string of length $1.
|
|
105
|
+
#
|
|
106
|
+
# We deliberately avoid `tr -dc 'A-Za-z0-9' </dev/urandom | head -c N` which
|
|
107
|
+
# is the typical Linux idiom but hangs intermittently on macOS: BSD `tr`
|
|
108
|
+
# blocks in read() on /dev/urandom and does not always honor SIGPIPE when
|
|
109
|
+
# `head` exits, leaving the script stuck. Reading a fixed number of bytes
|
|
110
|
+
# upfront and base64-encoding sidesteps the SIGPIPE timing entirely.
|
|
111
|
+
gen_random_string() {
|
|
112
|
+
local len="$1"
|
|
113
|
+
# base64 of 48 bytes = 64 chars; that's plenty even after stripping
|
|
114
|
+
# +/= and newlines for any practical len <= 48.
|
|
115
|
+
LC_ALL=C dd if=/dev/urandom bs=48 count=1 2>/dev/null | base64 | LC_ALL=C tr -d '+/=\n' | cut -c1-"$len"
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
# Require root for system management commands.
|
|
119
|
+
require_root() {
|
|
120
|
+
if [ "$(id -u)" -ne 0 ]; then
|
|
121
|
+
echo "Error: 'minion-cli-mac $1' requires root privileges."
|
|
122
|
+
echo "Usage: sudo minion-cli-mac $1"
|
|
123
|
+
exit 1
|
|
124
|
+
fi
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
# Detect LAN IPv4 address (best-effort)
|
|
128
|
+
detect_lan_ip() {
|
|
129
|
+
ipconfig getifaddr en0 2>/dev/null \
|
|
130
|
+
|| ipconfig getifaddr en1 2>/dev/null \
|
|
131
|
+
|| ifconfig 2>/dev/null | awk '/inet / && $2 != "127.0.0.1" {print $2; exit}'
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
# Auto-load .env so that API_TOKEN etc. are available in interactive shells
|
|
135
|
+
ENV_FILE="${TARGET_HOME}/.minion/.env"
|
|
136
|
+
if [ -f "$ENV_FILE" ] && [ -r "$ENV_FILE" ]; then
|
|
137
|
+
while IFS='=' read -r key value; do
|
|
138
|
+
[[ "$key" =~ ^#.*$ || -z "$key" ]] && continue
|
|
139
|
+
if [ -z "${!key:-}" ]; then
|
|
140
|
+
export "$key=$value"
|
|
141
|
+
fi
|
|
142
|
+
done < "$ENV_FILE"
|
|
143
|
+
fi
|
|
144
|
+
|
|
145
|
+
# ============================================================
|
|
146
|
+
# launchctl helpers
|
|
147
|
+
# ============================================================
|
|
148
|
+
|
|
149
|
+
# Path to a user's LaunchAgent plist.
|
|
150
|
+
plist_path() {
|
|
151
|
+
local user="$1"
|
|
152
|
+
local label="$2"
|
|
153
|
+
echo "$(resolve_user_home "$user")/Library/LaunchAgents/${label}.plist"
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
# Domain target for `launchctl` (gui/<uid>/<label>).
|
|
157
|
+
launchd_target() {
|
|
158
|
+
local user="$1"
|
|
159
|
+
local label="$2"
|
|
160
|
+
local uid
|
|
161
|
+
uid="$(resolve_uid "$user")"
|
|
162
|
+
echo "gui/${uid}/${label}"
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
# Bootstrap a LaunchAgent for a target user (idempotent).
|
|
166
|
+
bootstrap_launch_agent() {
|
|
167
|
+
local user="$1"
|
|
168
|
+
local label="$2"
|
|
169
|
+
local plist
|
|
170
|
+
plist="$(plist_path "$user" "$label")"
|
|
171
|
+
local uid
|
|
172
|
+
uid="$(resolve_uid "$user")"
|
|
173
|
+
|
|
174
|
+
if [ ! -f "$plist" ]; then
|
|
175
|
+
echo " WARNING: plist not found: $plist"
|
|
176
|
+
return 1
|
|
177
|
+
fi
|
|
178
|
+
|
|
179
|
+
# `launchctl bootstrap gui/<uid>` requires that GUI session to exist (i.e.,
|
|
180
|
+
# the user must be logged in). If not logged in, the plist still loads on
|
|
181
|
+
# next login because it lives in ~/Library/LaunchAgents.
|
|
182
|
+
if [ "$(id -u)" -eq 0 ]; then
|
|
183
|
+
sudo -u "$user" launchctl bootstrap "gui/${uid}" "$plist" 2>/dev/null || return 1
|
|
184
|
+
else
|
|
185
|
+
launchctl bootstrap "gui/${uid}" "$plist" 2>/dev/null || return 1
|
|
186
|
+
fi
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
# Bootout (unload) a LaunchAgent for a target user (idempotent).
|
|
190
|
+
bootout_launch_agent() {
|
|
191
|
+
local user="$1"
|
|
192
|
+
local label="$2"
|
|
193
|
+
local target
|
|
194
|
+
target="$(launchd_target "$user" "$label")"
|
|
195
|
+
if [ "$(id -u)" -eq 0 ]; then
|
|
196
|
+
sudo -u "$user" launchctl bootout "$target" 2>/dev/null || true
|
|
197
|
+
else
|
|
198
|
+
launchctl bootout "$target" 2>/dev/null || true
|
|
199
|
+
fi
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
# Kickstart-restart a LaunchAgent.
|
|
203
|
+
kickstart_launch_agent() {
|
|
204
|
+
local user="$1"
|
|
205
|
+
local label="$2"
|
|
206
|
+
local target
|
|
207
|
+
target="$(launchd_target "$user" "$label")"
|
|
208
|
+
if [ "$(id -u)" -eq 0 ]; then
|
|
209
|
+
sudo -u "$user" launchctl kickstart -k "$target"
|
|
210
|
+
else
|
|
211
|
+
launchctl kickstart -k "$target"
|
|
212
|
+
fi
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
# ============================================================
|
|
216
|
+
# Admin-context helper (Homebrew refuses to run as root)
|
|
217
|
+
# ============================================================
|
|
218
|
+
#
|
|
219
|
+
# Setup runs via `sudo`, so $UID is 0. But Homebrew explicitly refuses to
|
|
220
|
+
# operate as root (it would give every build script unrestricted system
|
|
221
|
+
# access). We must drop back to the user who invoked sudo — which is also
|
|
222
|
+
# the user who installed Homebrew.
|
|
223
|
+
|
|
224
|
+
# Resolve the non-root admin user who invoked the script.
|
|
225
|
+
resolve_admin_user() {
|
|
226
|
+
if [ "$(id -u)" -ne 0 ]; then
|
|
227
|
+
whoami
|
|
228
|
+
return
|
|
229
|
+
fi
|
|
230
|
+
if [ -n "${SUDO_USER:-}" ] && [ "$SUDO_USER" != "root" ]; then
|
|
231
|
+
echo "$SUDO_USER"
|
|
232
|
+
return
|
|
233
|
+
fi
|
|
234
|
+
# Fall back to the brew binary's owner (whoever installed Homebrew)
|
|
235
|
+
if command -v brew &>/dev/null; then
|
|
236
|
+
stat -f%Su "$(command -v brew)" 2>/dev/null && return
|
|
237
|
+
fi
|
|
238
|
+
echo ""
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
# Run a command as the admin (non-root) user. Fails loudly if no admin user
|
|
242
|
+
# can be resolved (e.g., logged in directly as root with no Homebrew).
|
|
243
|
+
run_as_admin() {
|
|
244
|
+
local admin
|
|
245
|
+
admin="$(resolve_admin_user)"
|
|
246
|
+
if [ -z "$admin" ] || [ "$admin" = "root" ]; then
|
|
247
|
+
echo "ERROR: cannot determine the non-root user to run '$*' as." >&2
|
|
248
|
+
echo " Setup must be invoked via sudo from a regular admin account" >&2
|
|
249
|
+
echo " (e.g., 'sudo minion-cli-mac setup --user minion')." >&2
|
|
250
|
+
exit 1
|
|
251
|
+
fi
|
|
252
|
+
if [ "$admin" = "$(whoami)" ]; then
|
|
253
|
+
"$@"
|
|
254
|
+
else
|
|
255
|
+
sudo -u "$admin" -H "$@"
|
|
256
|
+
fi
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
# Run a command as the target user. The `-H` flag is critical: without it,
|
|
260
|
+
# sudo preserves HOME from the caller (e.g. /Users/yunoda when setup is run
|
|
261
|
+
# via `sudo minion-cli-mac`), causing tools like pipx and the claude
|
|
262
|
+
# installer to write into the wrong user's home directory.
|
|
263
|
+
run_as_target() {
|
|
264
|
+
sudo -u "$TARGET_USER" -H "$@"
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
# Service control helper for default agent label.
|
|
268
|
+
LABEL_AGENT="com.geekbeer.minion"
|
|
269
|
+
LABEL_TMUX_INIT="com.geekbeer.tmux-init"
|
|
270
|
+
LABEL_WEBSOCKIFY="com.geekbeer.websockify" # legacy; kept for uninstall path
|
|
271
|
+
LABEL_VNC_PROXY="com.geekbeer.vnc-proxy"
|
|
272
|
+
LABEL_CLOUDFLARED="com.geekbeer.cloudflared"
|
|
273
|
+
|
|
274
|
+
svc_control() {
|
|
275
|
+
local action="$1"
|
|
276
|
+
local user="${2:-$TARGET_USER}"
|
|
277
|
+
case "$action" in
|
|
278
|
+
start) bootstrap_launch_agent "$user" "$LABEL_AGENT" ;;
|
|
279
|
+
stop) bootout_launch_agent "$user" "$LABEL_AGENT" ;;
|
|
280
|
+
restart) kickstart_launch_agent "$user" "$LABEL_AGENT" ;;
|
|
281
|
+
*) echo "Unknown svc_control action: $action"; exit 1 ;;
|
|
282
|
+
esac
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
# ============================================================
|
|
286
|
+
# Plist generation
|
|
287
|
+
# ============================================================
|
|
288
|
+
|
|
289
|
+
# Write a LaunchAgent plist file. Args:
|
|
290
|
+
# $1 = path
|
|
291
|
+
# $2 = body (XML, between <dict>...</dict>)
|
|
292
|
+
write_plist() {
|
|
293
|
+
local path="$1"
|
|
294
|
+
local body="$2"
|
|
295
|
+
$SUDO mkdir -p "$(dirname "$path")"
|
|
296
|
+
$SUDO tee "$path" > /dev/null <<PLISTEOF
|
|
297
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
298
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
299
|
+
<plist version="1.0">
|
|
300
|
+
<dict>
|
|
301
|
+
${body}
|
|
302
|
+
</dict>
|
|
303
|
+
</plist>
|
|
304
|
+
PLISTEOF
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
# ============================================================
|
|
308
|
+
# setup subcommand
|
|
309
|
+
# ============================================================
|
|
310
|
+
do_setup() {
|
|
311
|
+
require_root setup
|
|
312
|
+
|
|
313
|
+
local CLI_USER=""
|
|
314
|
+
while [[ $# -gt 0 ]]; do
|
|
315
|
+
case "$1" in
|
|
316
|
+
--user) CLI_USER="$2"; shift 2 ;;
|
|
317
|
+
--non-interactive) shift ;;
|
|
318
|
+
*) echo "Unknown option: $1"; echo "Usage: sudo minion-cli-mac setup --user <USERNAME>"; exit 1 ;;
|
|
319
|
+
esac
|
|
320
|
+
done
|
|
321
|
+
|
|
322
|
+
if [ -z "$CLI_USER" ]; then
|
|
323
|
+
echo "ERROR: --user flag is required."
|
|
324
|
+
echo "Usage: sudo minion-cli-mac setup --user <USERNAME>"
|
|
325
|
+
echo ""
|
|
326
|
+
echo "If the user doesn't exist yet, the script will create it for you."
|
|
327
|
+
exit 1
|
|
328
|
+
fi
|
|
329
|
+
|
|
330
|
+
TARGET_USER="$CLI_USER"
|
|
331
|
+
|
|
332
|
+
echo "========================================="
|
|
333
|
+
echo " @geekbeer/minion macOS Setup"
|
|
334
|
+
echo "========================================="
|
|
335
|
+
echo "Target user: $TARGET_USER"
|
|
336
|
+
echo "macOS: $(sw_vers -productVersion 2>/dev/null || echo unknown)"
|
|
337
|
+
echo "Arch: $(uname -m)"
|
|
338
|
+
echo "Brew prefix: ${BREW_PREFIX:-NOT FOUND}"
|
|
339
|
+
echo ""
|
|
340
|
+
|
|
341
|
+
if [ -z "$BREW_PREFIX" ] || ! command -v brew &>/dev/null; then
|
|
342
|
+
echo "ERROR: Homebrew is not installed."
|
|
343
|
+
echo ""
|
|
344
|
+
echo "Install it first:"
|
|
345
|
+
echo " /bin/bash -c \"\$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)\""
|
|
346
|
+
echo ""
|
|
347
|
+
echo "Then re-run setup."
|
|
348
|
+
exit 1
|
|
349
|
+
fi
|
|
350
|
+
|
|
351
|
+
local TOTAL_STEPS=14
|
|
352
|
+
|
|
353
|
+
# Step 1: Create target user if missing
|
|
354
|
+
echo "[1/${TOTAL_STEPS}] Verifying target user '$TARGET_USER'..."
|
|
355
|
+
if user_exists "$TARGET_USER"; then
|
|
356
|
+
# Verify the existing user has a usable password (else auto-login + VNC will fail)
|
|
357
|
+
if dscl . -read "/Users/${TARGET_USER}" Password 2>/dev/null | grep -q '^Password: \*$'; then
|
|
358
|
+
echo " -> User exists but has NO PASSWORD set."
|
|
359
|
+
echo " macOS auto-login and Screen Sharing both require a password."
|
|
360
|
+
echo " Set one with: sudo dscl . -passwd /Users/${TARGET_USER}"
|
|
361
|
+
echo " Then re-run setup."
|
|
362
|
+
exit 1
|
|
363
|
+
fi
|
|
364
|
+
echo " -> User already exists"
|
|
365
|
+
else
|
|
366
|
+
echo " -> User does not exist; creating..."
|
|
367
|
+
# Prompt for a password: macOS auto-login + Screen Sharing both REQUIRE a real
|
|
368
|
+
# password. sysadminctl's `-password -` (read from stdin) is unreliable inside
|
|
369
|
+
# sudo subshells, so we prompt via /dev/tty and pass the password as a literal.
|
|
370
|
+
if [ ! -t 0 ] && [ ! -r /dev/tty ]; then
|
|
371
|
+
echo " ERROR: cannot prompt for password (non-interactive shell)."
|
|
372
|
+
echo " Run setup from an interactive terminal, or pre-create the user:"
|
|
373
|
+
echo " sudo sysadminctl -addUser ${TARGET_USER} -fullName 'Minion Agent' -password 'YOUR_PASSWORD'"
|
|
374
|
+
exit 1
|
|
375
|
+
fi
|
|
376
|
+
|
|
377
|
+
local PW="" PW2=""
|
|
378
|
+
while :; do
|
|
379
|
+
printf " Set initial password for '%s' (required for auto-login + VNC): " "$TARGET_USER" > /dev/tty
|
|
380
|
+
IFS= read -r -s PW < /dev/tty
|
|
381
|
+
echo "" > /dev/tty
|
|
382
|
+
printf " Confirm: " > /dev/tty
|
|
383
|
+
IFS= read -r -s PW2 < /dev/tty
|
|
384
|
+
echo "" > /dev/tty
|
|
385
|
+
if [ -z "$PW" ]; then
|
|
386
|
+
echo " ERROR: password cannot be empty. Try again." > /dev/tty
|
|
387
|
+
continue
|
|
388
|
+
fi
|
|
389
|
+
if [ "$PW" != "$PW2" ]; then
|
|
390
|
+
echo " ERROR: passwords do not match. Try again." > /dev/tty
|
|
391
|
+
continue
|
|
392
|
+
fi
|
|
393
|
+
break
|
|
394
|
+
done
|
|
395
|
+
|
|
396
|
+
if ! sysadminctl -addUser "$TARGET_USER" -fullName "Minion Agent" -password "$PW" 2>&1; then
|
|
397
|
+
unset PW PW2
|
|
398
|
+
echo " ERROR: sysadminctl failed to create user"
|
|
399
|
+
exit 1
|
|
400
|
+
fi
|
|
401
|
+
unset PW PW2
|
|
402
|
+
|
|
403
|
+
# Sanity check: confirm the user was actually created with a password
|
|
404
|
+
if ! user_exists "$TARGET_USER"; then
|
|
405
|
+
echo " ERROR: user creation appeared to succeed but '$TARGET_USER' does not exist"
|
|
406
|
+
exit 1
|
|
407
|
+
fi
|
|
408
|
+
if dscl . -read "/Users/${TARGET_USER}" Password 2>/dev/null | grep -q '^Password: \*$'; then
|
|
409
|
+
echo " ERROR: user was created but has no password set (sysadminctl issue)"
|
|
410
|
+
echo " Set it manually: sudo dscl . -passwd /Users/${TARGET_USER}"
|
|
411
|
+
exit 1
|
|
412
|
+
fi
|
|
413
|
+
echo " -> User '$TARGET_USER' created"
|
|
414
|
+
fi
|
|
415
|
+
|
|
416
|
+
TARGET_HOME="$(resolve_user_home "$TARGET_USER")"
|
|
417
|
+
if [ -z "$TARGET_HOME" ]; then
|
|
418
|
+
echo "ERROR: Failed to resolve home directory for '$TARGET_USER'"
|
|
419
|
+
exit 1
|
|
420
|
+
fi
|
|
421
|
+
echo " Home: $TARGET_HOME"
|
|
422
|
+
|
|
423
|
+
# FileVault warning
|
|
424
|
+
if fdesetup status 2>/dev/null | grep -q "FileVault is On"; then
|
|
425
|
+
echo ""
|
|
426
|
+
echo " ⚠️ FileVault is ENABLED. macOS does NOT permit auto-login when"
|
|
427
|
+
echo " FileVault is on, which means the minion user will not log in"
|
|
428
|
+
echo " automatically at boot — and Screen Sharing requires a logged-in"
|
|
429
|
+
echo " GUI session. Disable FileVault, or accept that the agent will"
|
|
430
|
+
echo " only run after manual login."
|
|
431
|
+
echo ""
|
|
432
|
+
fi
|
|
433
|
+
|
|
434
|
+
# Step 2: Install Homebrew dependencies (as the admin who invoked sudo;
|
|
435
|
+
# Homebrew refuses to run as root)
|
|
436
|
+
echo "[2/${TOTAL_STEPS}] Installing dependencies via Homebrew..."
|
|
437
|
+
local ADMIN_USER
|
|
438
|
+
ADMIN_USER="$(resolve_admin_user)"
|
|
439
|
+
echo " Running brew as: $ADMIN_USER"
|
|
440
|
+
# - cloudflared: bundled here so `configure --setup-tunnel` works without
|
|
441
|
+
# bouncing back to admin (the minion user can't run brew install).
|
|
442
|
+
# NOTE: websockify and noVNC web assets are NO LONGER required as of v3.47.8.
|
|
443
|
+
# VNC is bridged by mac/vnc-auth-proxy.js (Node, bundled with the
|
|
444
|
+
# package), which performs RFB auth against macOS Screen Sharing
|
|
445
|
+
# using a password stored only in ~/.minion/.env. The HQ-side noVNC
|
|
446
|
+
# supplies the web assets.
|
|
447
|
+
local BREW_PKGS=(tmux ttyd jq node cloudflared)
|
|
448
|
+
for pkg in "${BREW_PKGS[@]}"; do
|
|
449
|
+
if run_as_admin brew list --formula "$pkg" >/dev/null 2>&1; then
|
|
450
|
+
echo " -> $pkg already installed"
|
|
451
|
+
else
|
|
452
|
+
echo " -> installing $pkg..."
|
|
453
|
+
run_as_admin brew install "$pkg"
|
|
454
|
+
fi
|
|
455
|
+
done
|
|
456
|
+
|
|
457
|
+
# Step 3: Install Claude Code (as target user)
|
|
458
|
+
echo "[3/${TOTAL_STEPS}] Installing Claude Code (as $TARGET_USER)..."
|
|
459
|
+
if run_as_target bash -lc 'command -v claude' &>/dev/null; then
|
|
460
|
+
echo " -> Claude Code already installed"
|
|
461
|
+
else
|
|
462
|
+
run_as_target bash -lc 'curl -fsSL https://claude.ai/install.sh | bash' || {
|
|
463
|
+
echo " WARNING: Claude Code installation may have failed"
|
|
464
|
+
}
|
|
465
|
+
fi
|
|
466
|
+
echo " IMPORTANT: Authenticate later by logging in via Screen Sharing and running: claude"
|
|
467
|
+
|
|
468
|
+
# Step 4: Install Gemini CLI (run as admin to avoid root-owned files in
|
|
469
|
+
# Homebrew Node's /opt/homebrew/lib/node_modules/, which is brew-owned)
|
|
470
|
+
echo "[4/${TOTAL_STEPS}] Installing Gemini CLI..."
|
|
471
|
+
if command -v gemini &>/dev/null; then
|
|
472
|
+
echo " -> Gemini CLI already installed"
|
|
473
|
+
else
|
|
474
|
+
run_as_admin npm install -g @google/gemini-cli || echo " WARNING: Gemini CLI install failed; continuing"
|
|
475
|
+
fi
|
|
476
|
+
|
|
477
|
+
# Step 5: Create data directory under target user's home
|
|
478
|
+
echo "[5/${TOTAL_STEPS}] Creating ${TARGET_HOME}/.minion/..."
|
|
479
|
+
run_as_target mkdir -p "${TARGET_HOME}/.minion" "${TARGET_HOME}/.minion/logs" "${TARGET_HOME}/files"
|
|
480
|
+
echo " -> ${TARGET_HOME}/.minion/ ready"
|
|
481
|
+
|
|
482
|
+
# Step 6: Ensure Homebrew shellenv is loaded for the target user's login shells.
|
|
483
|
+
# Freshly-created macOS users get an empty $PATH (no Homebrew bin), so the
|
|
484
|
+
# `minion-cli-mac configure` step in the next phase fails with "command not
|
|
485
|
+
# found" unless the user logs in via a shell that has been bootstrapped.
|
|
486
|
+
# zsh is the default login shell on modern macOS, so .zprofile is sufficient.
|
|
487
|
+
echo "[6/${TOTAL_STEPS}] Configuring shell PATH for ${TARGET_USER}..."
|
|
488
|
+
local ZPROFILE="${TARGET_HOME}/.zprofile"
|
|
489
|
+
run_as_target touch "$ZPROFILE"
|
|
490
|
+
if run_as_target grep -qF "brew shellenv" "$ZPROFILE"; then
|
|
491
|
+
echo " -> brew shellenv already present in $ZPROFILE"
|
|
492
|
+
else
|
|
493
|
+
run_as_target tee -a "$ZPROFILE" > /dev/null <<EOF
|
|
494
|
+
|
|
495
|
+
# Added by minion-cli-mac setup — exposes Homebrew + npm-global bins
|
|
496
|
+
eval "\$(${BREW_PREFIX}/bin/brew shellenv)"
|
|
497
|
+
EOF
|
|
498
|
+
echo " -> appended brew shellenv to $ZPROFILE"
|
|
499
|
+
fi
|
|
500
|
+
|
|
501
|
+
# Step 7: Generate default .env
|
|
502
|
+
# Includes a randomly-generated VNC_PASSWORD that is shared between the
|
|
503
|
+
# macOS Screen Sharing config (set in Step 11) and the Node-based VNC auth
|
|
504
|
+
# proxy (mac/vnc-auth-proxy.js). The password never leaves this machine —
|
|
505
|
+
# HQ does not need it because the proxy authenticates locally on its behalf.
|
|
506
|
+
echo "[7/${TOTAL_STEPS}] Generating .env file..."
|
|
507
|
+
echo " ENV_PATH: ${TARGET_HOME}/.minion/.env"
|
|
508
|
+
if [ ! -f "${TARGET_HOME}/.minion/.env" ]; then
|
|
509
|
+
echo " (.env does not exist; creating fresh)"
|
|
510
|
+
echo " -> generating VNC password..."
|
|
511
|
+
local VNC_PW
|
|
512
|
+
VNC_PW="$(gen_random_string 24)"
|
|
513
|
+
if [ -z "$VNC_PW" ]; then
|
|
514
|
+
echo " ERROR: gen_random_string produced empty value; aborting"
|
|
515
|
+
exit 1
|
|
516
|
+
fi
|
|
517
|
+
echo " -> VNC password generated (${#VNC_PW} chars)"
|
|
518
|
+
echo " -> writing .env via tee (as ${TARGET_USER})..."
|
|
519
|
+
# Pre-create the directory so `tee` doesn't trip on a missing path.
|
|
520
|
+
run_as_target mkdir -p "${TARGET_HOME}/.minion"
|
|
521
|
+
run_as_target tee "${TARGET_HOME}/.minion/.env" > /dev/null <<EEOF
|
|
522
|
+
# Minion Agent Configuration
|
|
523
|
+
# Generated by minion-cli-mac setup
|
|
524
|
+
|
|
525
|
+
AGENT_PORT=8080
|
|
526
|
+
MINION_USER=${TARGET_USER}
|
|
527
|
+
REFLECTION_TIME=03:00
|
|
528
|
+
|
|
529
|
+
# VNC password used by mac/vnc-auth-proxy.js to authenticate with
|
|
530
|
+
# macOS Screen Sharing. Never sent to HQ.
|
|
531
|
+
VNC_PASSWORD=${VNC_PW}
|
|
532
|
+
EEOF
|
|
533
|
+
echo " -> tee complete; setting mode 0600..."
|
|
534
|
+
run_as_target chmod 600 "${TARGET_HOME}/.minion/.env"
|
|
535
|
+
echo " -> ${TARGET_HOME}/.minion/.env created (mode 0600, VNC password generated)"
|
|
536
|
+
else
|
|
537
|
+
echo " (.env exists; checking for VNC_PASSWORD line)"
|
|
538
|
+
if ! run_as_target grep -q '^VNC_PASSWORD=' "${TARGET_HOME}/.minion/.env"; then
|
|
539
|
+
echo " -> VNC_PASSWORD missing; generating and appending"
|
|
540
|
+
local VNC_PW
|
|
541
|
+
VNC_PW="$(gen_random_string 24)"
|
|
542
|
+
run_as_target tee -a "${TARGET_HOME}/.minion/.env" > /dev/null <<EEOF
|
|
543
|
+
|
|
544
|
+
# VNC password used by mac/vnc-auth-proxy.js (added by setup upgrade)
|
|
545
|
+
VNC_PASSWORD=${VNC_PW}
|
|
546
|
+
EEOF
|
|
547
|
+
echo " -> appended VNC_PASSWORD to existing .env"
|
|
548
|
+
else
|
|
549
|
+
echo " -> ${TARGET_HOME}/.minion/.env already exists, preserving"
|
|
550
|
+
fi
|
|
551
|
+
run_as_target chmod 600 "${TARGET_HOME}/.minion/.env"
|
|
552
|
+
fi
|
|
553
|
+
|
|
554
|
+
# Step 8: Configure sudoers for agent user
|
|
555
|
+
echo "[8/${TOTAL_STEPS}] Configuring sudoers..."
|
|
556
|
+
local NPM_BIN BREW_BIN LAUNCHCTL_BIN
|
|
557
|
+
NPM_BIN="$(command -v npm)"
|
|
558
|
+
BREW_BIN="${BREW_PREFIX}/bin/brew"
|
|
559
|
+
LAUNCHCTL_BIN="/bin/launchctl"
|
|
560
|
+
local SUDOERS_FILE="/etc/sudoers.d/minion-agent"
|
|
561
|
+
|
|
562
|
+
{
|
|
563
|
+
echo "Defaults:${TARGET_USER} passwd_tries=0"
|
|
564
|
+
echo ""
|
|
565
|
+
echo "${TARGET_USER} ALL=(root) NOPASSWD: ${NPM_BIN} install -g @geekbeer/minion@latest"
|
|
566
|
+
echo "${TARGET_USER} ALL=(root) NOPASSWD: ${NPM_BIN} install -g @geekbeer/minion@latest --registry *"
|
|
567
|
+
echo "${TARGET_USER} ALL=(root) NOPASSWD: ${BREW_BIN} install *"
|
|
568
|
+
echo "${TARGET_USER} ALL=(root) NOPASSWD: ${BREW_BIN} upgrade *"
|
|
569
|
+
echo "${TARGET_USER} ALL=(root) NOPASSWD: ${LAUNCHCTL_BIN} kickstart -k *"
|
|
570
|
+
echo "${TARGET_USER} ALL=(root) NOPASSWD: ${LAUNCHCTL_BIN} bootstrap *"
|
|
571
|
+
echo "${TARGET_USER} ALL=(root) NOPASSWD: ${LAUNCHCTL_BIN} bootout *"
|
|
572
|
+
} > "$SUDOERS_FILE"
|
|
573
|
+
chmod 440 "$SUDOERS_FILE"
|
|
574
|
+
if visudo -c -f "$SUDOERS_FILE" >/dev/null; then
|
|
575
|
+
echo " -> $SUDOERS_FILE created and validated"
|
|
576
|
+
else
|
|
577
|
+
echo " ERROR: sudoers file failed validation; removing"
|
|
578
|
+
rm -f "$SUDOERS_FILE"
|
|
579
|
+
exit 1
|
|
580
|
+
fi
|
|
581
|
+
|
|
582
|
+
# Step 9: Locate installed @geekbeer/minion server.js
|
|
583
|
+
echo "[9/${TOTAL_STEPS}] Locating @geekbeer/minion installation..."
|
|
584
|
+
local NPM_ROOT SERVER_PATH
|
|
585
|
+
NPM_ROOT="$(npm root -g)"
|
|
586
|
+
SERVER_PATH="${NPM_ROOT}/@geekbeer/minion/mac/server.js"
|
|
587
|
+
if [ ! -f "$SERVER_PATH" ]; then
|
|
588
|
+
echo " ERROR: server.js not found at $SERVER_PATH"
|
|
589
|
+
echo " Run: sudo npm install -g @geekbeer/minion"
|
|
590
|
+
exit 1
|
|
591
|
+
fi
|
|
592
|
+
echo " -> $SERVER_PATH"
|
|
593
|
+
|
|
594
|
+
local NODE_BIN VNC_PROXY_PATH
|
|
595
|
+
NODE_BIN="$(command -v node)"
|
|
596
|
+
VNC_PROXY_PATH="${NPM_ROOT}/@geekbeer/minion/mac/vnc-auth-proxy.js"
|
|
597
|
+
if [ ! -f "$VNC_PROXY_PATH" ]; then
|
|
598
|
+
echo " ERROR: vnc-auth-proxy.js not found at $VNC_PROXY_PATH"
|
|
599
|
+
echo " Re-install the package: sudo npm install -g @geekbeer/minion@latest"
|
|
600
|
+
exit 1
|
|
601
|
+
fi
|
|
602
|
+
|
|
603
|
+
# Step 10: Generate LaunchAgent plists
|
|
604
|
+
echo "[10/${TOTAL_STEPS}] Generating LaunchAgent plists..."
|
|
605
|
+
local LA_DIR="${TARGET_HOME}/Library/LaunchAgents"
|
|
606
|
+
run_as_target mkdir -p "$LA_DIR"
|
|
607
|
+
|
|
608
|
+
# 9a: tmux-init plist (one-shot at login: ensures `main` session exists)
|
|
609
|
+
write_plist "${LA_DIR}/${LABEL_TMUX_INIT}.plist" " <key>Label</key>
|
|
610
|
+
<string>${LABEL_TMUX_INIT}</string>
|
|
611
|
+
<key>RunAtLoad</key>
|
|
612
|
+
<true/>
|
|
613
|
+
<key>KeepAlive</key>
|
|
614
|
+
<false/>
|
|
615
|
+
<key>ProgramArguments</key>
|
|
616
|
+
<array>
|
|
617
|
+
<string>/bin/bash</string>
|
|
618
|
+
<string>-lc</string>
|
|
619
|
+
<string>tmux has-session -t main 2>/dev/null || tmux new-session -d -s main; tmux set-option -g mouse on</string>
|
|
620
|
+
</array>
|
|
621
|
+
<key>EnvironmentVariables</key>
|
|
622
|
+
<dict>
|
|
623
|
+
<key>PATH</key>
|
|
624
|
+
<string>${BREW_PREFIX}/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
|
|
625
|
+
</dict>
|
|
626
|
+
<key>StandardOutPath</key>
|
|
627
|
+
<string>${TARGET_HOME}/.minion/logs/tmux-init.out.log</string>
|
|
628
|
+
<key>StandardErrorPath</key>
|
|
629
|
+
<string>${TARGET_HOME}/.minion/logs/tmux-init.err.log</string>"
|
|
630
|
+
echo " -> ${LABEL_TMUX_INIT}.plist"
|
|
631
|
+
|
|
632
|
+
# 9b: agent plist
|
|
633
|
+
write_plist "${LA_DIR}/${LABEL_AGENT}.plist" " <key>Label</key>
|
|
634
|
+
<string>${LABEL_AGENT}</string>
|
|
635
|
+
<key>RunAtLoad</key>
|
|
636
|
+
<true/>
|
|
637
|
+
<key>KeepAlive</key>
|
|
638
|
+
<dict>
|
|
639
|
+
<key>SuccessfulExit</key>
|
|
640
|
+
<false/>
|
|
641
|
+
</dict>
|
|
642
|
+
<key>ThrottleInterval</key>
|
|
643
|
+
<integer>10</integer>
|
|
644
|
+
<key>ProgramArguments</key>
|
|
645
|
+
<array>
|
|
646
|
+
<string>${NODE_BIN}</string>
|
|
647
|
+
<string>${SERVER_PATH}</string>
|
|
648
|
+
</array>
|
|
649
|
+
<key>WorkingDirectory</key>
|
|
650
|
+
<string>${TARGET_HOME}/.minion</string>
|
|
651
|
+
<key>EnvironmentVariables</key>
|
|
652
|
+
<dict>
|
|
653
|
+
<key>HOME</key>
|
|
654
|
+
<string>${TARGET_HOME}</string>
|
|
655
|
+
<key>PATH</key>
|
|
656
|
+
<string>${BREW_PREFIX}/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:${TARGET_HOME}/.local/bin:${TARGET_HOME}/.claude/bin</string>
|
|
657
|
+
<key>MINION_USER</key>
|
|
658
|
+
<string>${TARGET_USER}</string>
|
|
659
|
+
</dict>
|
|
660
|
+
<key>StandardOutPath</key>
|
|
661
|
+
<string>${TARGET_HOME}/.minion/logs/agent.out.log</string>
|
|
662
|
+
<key>StandardErrorPath</key>
|
|
663
|
+
<string>${TARGET_HOME}/.minion/logs/agent.err.log</string>"
|
|
664
|
+
echo " -> ${LABEL_AGENT}.plist"
|
|
665
|
+
|
|
666
|
+
# 9c: vnc-proxy plist (Node-based RFB auth proxy, replaces websockify as of v3.48.0).
|
|
667
|
+
# --openssl-legacy-provider is REQUIRED: VNC Auth (RFB security type 2) uses
|
|
668
|
+
# DES, which OpenSSL 3 (shipped with Node 17+) removed from the default
|
|
669
|
+
# provider. Without this flag the proxy fails at challenge encryption.
|
|
670
|
+
write_plist "${LA_DIR}/${LABEL_VNC_PROXY}.plist" " <key>Label</key>
|
|
671
|
+
<string>${LABEL_VNC_PROXY}</string>
|
|
672
|
+
<key>RunAtLoad</key>
|
|
673
|
+
<true/>
|
|
674
|
+
<key>KeepAlive</key>
|
|
675
|
+
<true/>
|
|
676
|
+
<key>ThrottleInterval</key>
|
|
677
|
+
<integer>5</integer>
|
|
678
|
+
<key>ProgramArguments</key>
|
|
679
|
+
<array>
|
|
680
|
+
<string>${NODE_BIN}</string>
|
|
681
|
+
<string>--openssl-legacy-provider</string>
|
|
682
|
+
<string>${VNC_PROXY_PATH}</string>
|
|
683
|
+
</array>
|
|
684
|
+
<key>EnvironmentVariables</key>
|
|
685
|
+
<dict>
|
|
686
|
+
<key>HOME</key>
|
|
687
|
+
<string>${TARGET_HOME}</string>
|
|
688
|
+
<key>PATH</key>
|
|
689
|
+
<string>${BREW_PREFIX}/bin:/usr/local/bin:/usr/bin:/bin</string>
|
|
690
|
+
</dict>
|
|
691
|
+
<key>StandardOutPath</key>
|
|
692
|
+
<string>${TARGET_HOME}/.minion/logs/vnc-proxy.out.log</string>
|
|
693
|
+
<key>StandardErrorPath</key>
|
|
694
|
+
<string>${TARGET_HOME}/.minion/logs/vnc-proxy.err.log</string>"
|
|
695
|
+
echo " -> ${LABEL_VNC_PROXY}.plist"
|
|
696
|
+
|
|
697
|
+
# Migrate from old websockify plist if present (upgrade path from < 3.47.8).
|
|
698
|
+
# Bootout the old service and remove its plist so the user is not left with
|
|
699
|
+
# two services racing for port 6080.
|
|
700
|
+
if [ -f "${LA_DIR}/${LABEL_WEBSOCKIFY}.plist" ]; then
|
|
701
|
+
echo " -> migrating: removing legacy ${LABEL_WEBSOCKIFY}.plist"
|
|
702
|
+
bootout_launch_agent "$TARGET_USER" "$LABEL_WEBSOCKIFY" 2>/dev/null || true
|
|
703
|
+
rm -f "${LA_DIR}/${LABEL_WEBSOCKIFY}.plist"
|
|
704
|
+
fi
|
|
705
|
+
|
|
706
|
+
# Set ownership on plists
|
|
707
|
+
chown -R "${TARGET_USER}:staff" "${TARGET_HOME}/Library/LaunchAgents"
|
|
708
|
+
|
|
709
|
+
# Step 11: Enable native Screen Sharing + set VNC password for the proxy
|
|
710
|
+
echo "[11/${TOTAL_STEPS}] Enabling native macOS Screen Sharing..."
|
|
711
|
+
launchctl enable system/com.apple.screensharing 2>/dev/null || true
|
|
712
|
+
launchctl kickstart -k system/com.apple.screensharing 2>/dev/null || true
|
|
713
|
+
echo " -> Screen Sharing daemon enabled (port 5900)"
|
|
714
|
+
|
|
715
|
+
# Configure VNC password via Apple Remote Desktop's `kickstart` (NOT launchctl
|
|
716
|
+
# kickstart — same name, different binary). This enables VNC Auth security
|
|
717
|
+
# type 2 on screensharingd, which the bundled mac/vnc-auth-proxy.js then
|
|
718
|
+
# uses to authenticate. Runs as root (we are root).
|
|
719
|
+
local VNC_PW_FROM_ENV=""
|
|
720
|
+
if [ -f "${TARGET_HOME}/.minion/.env" ]; then
|
|
721
|
+
VNC_PW_FROM_ENV="$(awk -F'=' '/^VNC_PASSWORD=/{print $2; exit}' "${TARGET_HOME}/.minion/.env")"
|
|
722
|
+
fi
|
|
723
|
+
local ARD_KICKSTART="/System/Library/CoreServices/RemoteManagement/ARDAgent.app/Contents/Resources/kickstart"
|
|
724
|
+
if [ -n "$VNC_PW_FROM_ENV" ] && [ -x "$ARD_KICKSTART" ]; then
|
|
725
|
+
# macOS Screen Sharing truncates VNC password to 8 chars; the proxy
|
|
726
|
+
# sends the same truncated value, so they match.
|
|
727
|
+
"$ARD_KICKSTART" -configure -clientopts \
|
|
728
|
+
-setvnclegacy -vnclegacy yes \
|
|
729
|
+
-setvncpw -vncpw "$VNC_PW_FROM_ENV" >/dev/null 2>&1 \
|
|
730
|
+
&& echo " -> VNC password installed via ARD kickstart" \
|
|
731
|
+
|| echo " WARNING: ARD kickstart failed; VNC may require manual System Settings configuration"
|
|
732
|
+
else
|
|
733
|
+
echo " WARNING: VNC_PASSWORD not found in .env or ARD kickstart unavailable; VNC may not authenticate"
|
|
734
|
+
fi
|
|
735
|
+
|
|
736
|
+
# Note: System Settings -> General -> Sharing -> Screen Sharing toggle still
|
|
737
|
+
# requires manual user action on modern macOS (Ventura+) due to TCC. The
|
|
738
|
+
# final summary instructs the user to verify this.
|
|
739
|
+
|
|
740
|
+
# Add target user to access_screensharing group
|
|
741
|
+
if dseditgroup -o read com.apple.access_screensharing >/dev/null 2>&1; then
|
|
742
|
+
if dseditgroup -o checkmember -m "$TARGET_USER" com.apple.access_screensharing >/dev/null 2>&1; then
|
|
743
|
+
echo " -> User '$TARGET_USER' is already in com.apple.access_screensharing"
|
|
744
|
+
else
|
|
745
|
+
dseditgroup -o edit -a "$TARGET_USER" -t user com.apple.access_screensharing
|
|
746
|
+
echo " -> Added '$TARGET_USER' to com.apple.access_screensharing"
|
|
747
|
+
fi
|
|
748
|
+
else
|
|
749
|
+
# Group doesn't exist (rare); fall back to dscl
|
|
750
|
+
dscl . append /Groups/com.apple.access_screensharing GroupMembership "$TARGET_USER" 2>/dev/null || true
|
|
751
|
+
echo " -> Added '$TARGET_USER' to com.apple.access_screensharing (via dscl)"
|
|
752
|
+
fi
|
|
753
|
+
|
|
754
|
+
# Step 12: Disable screen lock, screen saver, and display sleep.
|
|
755
|
+
# VNC is the primary way humans observe and intervene with the agent. Without
|
|
756
|
+
# these settings macOS locks the screen and blanks the display after a few
|
|
757
|
+
# minutes, requiring a password (which the human operator may not have) to
|
|
758
|
+
# resume work. The agent runs unattended on a dedicated machine, so the
|
|
759
|
+
# security trade-off is acceptable; physical access to the machine is the
|
|
760
|
+
# real boundary.
|
|
761
|
+
echo "[12/${TOTAL_STEPS}] Disabling screen lock and display sleep..."
|
|
762
|
+
|
|
763
|
+
# 12a: Disable "Require password after sleep or screen saver begins".
|
|
764
|
+
# `defaults` (vs `sysadminctl -screenLock`) avoids needing the user's
|
|
765
|
+
# password and writes to the same per-user preference domain.
|
|
766
|
+
run_as_target defaults write com.apple.screensaver askForPassword -int 0 \
|
|
767
|
+
&& echo " -> askForPassword=0 (no password after sleep/screensaver)" \
|
|
768
|
+
|| echo " WARNING: failed to write com.apple.screensaver askForPassword"
|
|
769
|
+
run_as_target defaults write com.apple.screensaver askForPasswordDelay -int 0 2>/dev/null || true
|
|
770
|
+
|
|
771
|
+
# 12b: Disable screensaver activation entirely (idleTime=0 means never).
|
|
772
|
+
# Must use -currentHost; the screensaver domain is host-scoped.
|
|
773
|
+
run_as_target defaults -currentHost write com.apple.screensaver idleTime -int 0 \
|
|
774
|
+
&& echo " -> screensaver idleTime=0 (never activate)" \
|
|
775
|
+
|| echo " WARNING: failed to disable screensaver"
|
|
776
|
+
|
|
777
|
+
# 12c: Disable display sleep system-wide. Requires root (we are root here).
|
|
778
|
+
# -a applies to all power sources (AC, battery, UPS).
|
|
779
|
+
if pmset -a displaysleep 0 >/dev/null 2>&1; then
|
|
780
|
+
echo " -> pmset displaysleep=0 (display never sleeps)"
|
|
781
|
+
else
|
|
782
|
+
echo " WARNING: pmset displaysleep=0 failed"
|
|
783
|
+
fi
|
|
784
|
+
|
|
785
|
+
# Step 13: Bootstrap LaunchAgents (best-effort; only works if user is logged in)
|
|
786
|
+
echo "[13/${TOTAL_STEPS}] Bootstrapping LaunchAgents..."
|
|
787
|
+
local TARGET_UID
|
|
788
|
+
TARGET_UID="$(resolve_uid "$TARGET_USER")"
|
|
789
|
+
# Detect whether the target user has an active GUI session
|
|
790
|
+
if launchctl print "gui/${TARGET_UID}" >/dev/null 2>&1; then
|
|
791
|
+
for label in "$LABEL_TMUX_INIT" "$LABEL_AGENT" "$LABEL_VNC_PROXY"; do
|
|
792
|
+
if bootstrap_launch_agent "$TARGET_USER" "$label"; then
|
|
793
|
+
echo " -> bootstrapped: $label"
|
|
794
|
+
else
|
|
795
|
+
echo " -> already bootstrapped or failed: $label (will load on next login)"
|
|
796
|
+
fi
|
|
797
|
+
done
|
|
798
|
+
else
|
|
799
|
+
echo " -> User '$TARGET_USER' is not currently logged in to a GUI session."
|
|
800
|
+
echo " LaunchAgents will load automatically on next login."
|
|
801
|
+
fi
|
|
802
|
+
|
|
803
|
+
# Step 14: Deploy bundled skills, rules
|
|
804
|
+
echo "[14/${TOTAL_STEPS}] Deploying bundled skills and rules..."
|
|
805
|
+
local BUNDLED_SKILLS_DIR="${NPM_ROOT}/@geekbeer/minion/skills"
|
|
806
|
+
local CLAUDE_SKILLS_DIR="${TARGET_HOME}/.claude/skills"
|
|
807
|
+
if [ -d "$BUNDLED_SKILLS_DIR" ]; then
|
|
808
|
+
run_as_target mkdir -p "$CLAUDE_SKILLS_DIR"
|
|
809
|
+
for skill_dir in "$BUNDLED_SKILLS_DIR"/*/; do
|
|
810
|
+
[ -d "$skill_dir" ] || continue
|
|
811
|
+
local skill_name
|
|
812
|
+
skill_name="$(basename "$skill_dir")"
|
|
813
|
+
run_as_target cp -r "$skill_dir" "${CLAUDE_SKILLS_DIR}/${skill_name}"
|
|
814
|
+
echo " -> Deployed skill: $skill_name"
|
|
815
|
+
done
|
|
816
|
+
else
|
|
817
|
+
echo " -> No bundled skills found"
|
|
818
|
+
fi
|
|
819
|
+
|
|
820
|
+
local BUNDLED_RULES_DIR="${NPM_ROOT}/@geekbeer/minion/rules"
|
|
821
|
+
local CLAUDE_RULES_DIR="${TARGET_HOME}/.claude/rules"
|
|
822
|
+
if [ -d "$BUNDLED_RULES_DIR" ]; then
|
|
823
|
+
run_as_target mkdir -p "$CLAUDE_RULES_DIR"
|
|
824
|
+
run_as_target cp "$BUNDLED_RULES_DIR/core.md" "$CLAUDE_RULES_DIR/core.md"
|
|
825
|
+
echo " -> Deployed rules: core.md"
|
|
826
|
+
fi
|
|
827
|
+
|
|
828
|
+
# Final summary & manual steps
|
|
829
|
+
echo ""
|
|
830
|
+
echo "========================================="
|
|
831
|
+
echo " Setup Complete!"
|
|
832
|
+
echo "========================================="
|
|
833
|
+
echo ""
|
|
834
|
+
echo "Agent user: $TARGET_USER ($TARGET_HOME)"
|
|
835
|
+
echo "Plists: $LA_DIR/"
|
|
836
|
+
echo ""
|
|
837
|
+
echo "Manual steps remaining:"
|
|
838
|
+
echo " 1. (Required) Enable Screen Sharing in System Settings:"
|
|
839
|
+
echo " System Settings -> General -> Sharing -> Screen Sharing -> ON"
|
|
840
|
+
echo " (launchctl enable is silently ignored on Ventura+ due to TCC.)"
|
|
841
|
+
echo " Verify with: lsof -nP -iTCP:5900 -sTCP:LISTEN"
|
|
842
|
+
echo ""
|
|
843
|
+
echo " 2. (Required for production / persistent VNC) Set auto-login for '$TARGET_USER':"
|
|
844
|
+
echo " System Settings -> Users & Groups -> Automatically log in as -> $TARGET_USER"
|
|
845
|
+
echo " (LaunchAgents only run while the user has a GUI session."
|
|
846
|
+
echo " Auto-login requires FileVault to be OFF.)"
|
|
847
|
+
echo ""
|
|
848
|
+
echo " 3. Switch to the agent user with a login shell so brew/npm bins are on PATH:"
|
|
849
|
+
echo " sudo su - $TARGET_USER"
|
|
850
|
+
echo ""
|
|
851
|
+
echo " 4. Connect to HQ (as $TARGET_USER, no sudo needed):"
|
|
852
|
+
echo " minion-cli-mac configure \\"
|
|
853
|
+
echo " --hq-url <HQ_URL> \\"
|
|
854
|
+
echo " --minion-id <MINION_ID> \\"
|
|
855
|
+
echo " --api-token <API_TOKEN>"
|
|
856
|
+
echo ""
|
|
857
|
+
echo "Health check: minion-cli-mac health"
|
|
858
|
+
echo "Diagnose: minion-cli-mac diagnose"
|
|
859
|
+
echo ""
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
# ============================================================
|
|
863
|
+
# configure subcommand
|
|
864
|
+
# ============================================================
|
|
865
|
+
do_configure() {
|
|
866
|
+
local HQ_URL=""
|
|
867
|
+
local MINION_ID=""
|
|
868
|
+
local API_TOKEN=""
|
|
869
|
+
local SETUP_TUNNEL=false
|
|
870
|
+
|
|
871
|
+
while [[ $# -gt 0 ]]; do
|
|
872
|
+
case "$1" in
|
|
873
|
+
--hq-url) HQ_URL="$2"; shift 2 ;;
|
|
874
|
+
--minion-id) MINION_ID="$2"; shift 2 ;;
|
|
875
|
+
--api-token) API_TOKEN="$2"; shift 2 ;;
|
|
876
|
+
--setup-tunnel) SETUP_TUNNEL=true; shift ;;
|
|
877
|
+
--non-interactive) shift ;;
|
|
878
|
+
*) echo "Unknown option: $1"; exit 1 ;;
|
|
879
|
+
esac
|
|
880
|
+
done
|
|
881
|
+
|
|
882
|
+
if [ -z "$HQ_URL" ] || [ -z "$MINION_ID" ] || [ -z "$API_TOKEN" ]; then
|
|
883
|
+
echo "ERROR: --hq-url, --minion-id, --api-token are all required"
|
|
884
|
+
exit 1
|
|
885
|
+
fi
|
|
886
|
+
|
|
887
|
+
# configure runs as the agent user (no sudo). Resolve TARGET_USER from .env if needed.
|
|
888
|
+
local CFG_USER CFG_HOME
|
|
889
|
+
CFG_USER="$(whoami)"
|
|
890
|
+
CFG_HOME="$HOME"
|
|
891
|
+
|
|
892
|
+
if [ ! -f "${CFG_HOME}/.minion/.env" ]; then
|
|
893
|
+
echo "ERROR: ${CFG_HOME}/.minion/.env not found."
|
|
894
|
+
echo "Run 'sudo minion-cli-mac setup --user $CFG_USER' first (from an admin account)."
|
|
895
|
+
exit 1
|
|
896
|
+
fi
|
|
897
|
+
|
|
898
|
+
local TOTAL_STEPS=4
|
|
899
|
+
if [ "$SETUP_TUNNEL" = true ]; then TOTAL_STEPS=5; fi
|
|
900
|
+
|
|
901
|
+
echo "========================================="
|
|
902
|
+
echo " @geekbeer/minion Configure (macOS)"
|
|
903
|
+
echo "========================================="
|
|
904
|
+
echo "User: $CFG_USER"
|
|
905
|
+
echo "HQ: $HQ_URL"
|
|
906
|
+
echo "Minion ID: $MINION_ID"
|
|
907
|
+
[ "$SETUP_TUNNEL" = true ] && echo "Tunnel: Enabled"
|
|
908
|
+
echo ""
|
|
909
|
+
|
|
910
|
+
# Step 1: Update .env
|
|
911
|
+
echo "[1/${TOTAL_STEPS}] Writing HQ credentials to .env..."
|
|
912
|
+
local TMP_ENV
|
|
913
|
+
TMP_ENV="$(mktemp)"
|
|
914
|
+
{
|
|
915
|
+
echo "# Minion Agent Configuration"
|
|
916
|
+
echo "# Configured by minion-cli-mac configure"
|
|
917
|
+
echo ""
|
|
918
|
+
echo "HQ_URL=${HQ_URL}"
|
|
919
|
+
echo "API_TOKEN=${API_TOKEN}"
|
|
920
|
+
echo "MINION_ID=${MINION_ID}"
|
|
921
|
+
if [ -f "${CFG_HOME}/.minion/.env" ]; then
|
|
922
|
+
while IFS='=' read -r key value; do
|
|
923
|
+
[[ -z "$key" || "$key" == \#* ]] && continue
|
|
924
|
+
case "$key" in
|
|
925
|
+
HQ_URL|API_TOKEN|MINION_ID) ;;
|
|
926
|
+
*) echo "${key}=${value}" ;;
|
|
927
|
+
esac
|
|
928
|
+
done < "${CFG_HOME}/.minion/.env"
|
|
929
|
+
fi
|
|
930
|
+
} > "$TMP_ENV"
|
|
931
|
+
mv "$TMP_ENV" "${CFG_HOME}/.minion/.env"
|
|
932
|
+
echo " -> ${CFG_HOME}/.minion/.env updated"
|
|
933
|
+
|
|
934
|
+
# Step 2: Deploy bundled skills/rules
|
|
935
|
+
echo "[2/${TOTAL_STEPS}] Deploying bundled assets..."
|
|
936
|
+
local NPM_ROOT
|
|
937
|
+
NPM_ROOT="$(npm root -g 2>/dev/null || echo "")"
|
|
938
|
+
local BUNDLED_SKILLS_DIR="${NPM_ROOT}/@geekbeer/minion/skills"
|
|
939
|
+
local CLAUDE_SKILLS_DIR="${CFG_HOME}/.claude/skills"
|
|
940
|
+
if [ -d "$BUNDLED_SKILLS_DIR" ]; then
|
|
941
|
+
mkdir -p "$CLAUDE_SKILLS_DIR"
|
|
942
|
+
for skill_dir in "$BUNDLED_SKILLS_DIR"/*/; do
|
|
943
|
+
[ -d "$skill_dir" ] || continue
|
|
944
|
+
local skill_name
|
|
945
|
+
skill_name="$(basename "$skill_dir")"
|
|
946
|
+
cp -r "$skill_dir" "${CLAUDE_SKILLS_DIR}/${skill_name}"
|
|
947
|
+
echo " -> Deployed skill: $skill_name"
|
|
948
|
+
done
|
|
949
|
+
fi
|
|
950
|
+
|
|
951
|
+
local BUNDLED_RULES_DIR="${NPM_ROOT}/@geekbeer/minion/rules"
|
|
952
|
+
local CLAUDE_RULES_DIR="${CFG_HOME}/.claude/rules"
|
|
953
|
+
if [ -d "$BUNDLED_RULES_DIR" ]; then
|
|
954
|
+
mkdir -p "$CLAUDE_RULES_DIR"
|
|
955
|
+
cp "$BUNDLED_RULES_DIR/core.md" "$CLAUDE_RULES_DIR/core.md"
|
|
956
|
+
echo " -> Deployed rules: core.md"
|
|
957
|
+
fi
|
|
958
|
+
|
|
959
|
+
# Step 3 (optional): Cloudflare Tunnel
|
|
960
|
+
local CURRENT_STEP=3
|
|
961
|
+
if [ "$SETUP_TUNNEL" = true ]; then
|
|
962
|
+
echo "[${CURRENT_STEP}/${TOTAL_STEPS}] Setting up Cloudflare Tunnel..."
|
|
963
|
+
if ! command -v cloudflared &>/dev/null; then
|
|
964
|
+
echo " ERROR: cloudflared is not installed."
|
|
965
|
+
echo " The minion user cannot install brew packages."
|
|
966
|
+
echo " Re-run setup as admin to install cloudflared:"
|
|
967
|
+
echo " sudo minion-cli-mac setup --user $CFG_USER"
|
|
968
|
+
exit 1
|
|
969
|
+
fi
|
|
970
|
+
|
|
971
|
+
local TUNNEL_DATA
|
|
972
|
+
TUNNEL_DATA="$(curl -sfL -H "Authorization: Bearer ${API_TOKEN}" "${HQ_URL}/api/minion/tunnel-credentials" 2>&1)" || true
|
|
973
|
+
|
|
974
|
+
if [ -z "$TUNNEL_DATA" ] || ! echo "$TUNNEL_DATA" | jq -e '.tunnel_id' > /dev/null 2>&1; then
|
|
975
|
+
echo " ERROR: Failed to fetch tunnel credentials from HQ; skipping tunnel setup"
|
|
976
|
+
else
|
|
977
|
+
local TUNNEL_ID CREDS_JSON CONFIG_YML
|
|
978
|
+
TUNNEL_ID="$(echo "$TUNNEL_DATA" | jq -r '.tunnel_id')"
|
|
979
|
+
CREDS_JSON="$(echo "$TUNNEL_DATA" | jq -r '.credentials_json')"
|
|
980
|
+
CONFIG_YML="$(echo "$TUNNEL_DATA" | jq -r '.config_yml')"
|
|
981
|
+
|
|
982
|
+
mkdir -p "${CFG_HOME}/.cloudflared"
|
|
983
|
+
echo "$CREDS_JSON" > "${CFG_HOME}/.cloudflared/${TUNNEL_ID}.json"
|
|
984
|
+
chmod 600 "${CFG_HOME}/.cloudflared/${TUNNEL_ID}.json"
|
|
985
|
+
echo "$CONFIG_YML" > "${CFG_HOME}/.cloudflared/config.yml"
|
|
986
|
+
|
|
987
|
+
local CLOUDFLARED_BIN
|
|
988
|
+
CLOUDFLARED_BIN="$(command -v cloudflared)"
|
|
989
|
+
local LA_DIR="${CFG_HOME}/Library/LaunchAgents"
|
|
990
|
+
mkdir -p "$LA_DIR"
|
|
991
|
+
cat > "${LA_DIR}/${LABEL_CLOUDFLARED}.plist" <<CFEOF
|
|
992
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
993
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
994
|
+
<plist version="1.0">
|
|
995
|
+
<dict>
|
|
996
|
+
<key>Label</key>
|
|
997
|
+
<string>${LABEL_CLOUDFLARED}</string>
|
|
998
|
+
<key>RunAtLoad</key>
|
|
999
|
+
<true/>
|
|
1000
|
+
<key>KeepAlive</key>
|
|
1001
|
+
<true/>
|
|
1002
|
+
<key>ProgramArguments</key>
|
|
1003
|
+
<array>
|
|
1004
|
+
<string>${CLOUDFLARED_BIN}</string>
|
|
1005
|
+
<string>tunnel</string>
|
|
1006
|
+
<string>--config</string>
|
|
1007
|
+
<string>${CFG_HOME}/.cloudflared/config.yml</string>
|
|
1008
|
+
<string>run</string>
|
|
1009
|
+
</array>
|
|
1010
|
+
<key>StandardOutPath</key>
|
|
1011
|
+
<string>${CFG_HOME}/.minion/logs/cloudflared.out.log</string>
|
|
1012
|
+
<key>StandardErrorPath</key>
|
|
1013
|
+
<string>${CFG_HOME}/.minion/logs/cloudflared.err.log</string>
|
|
1014
|
+
</dict>
|
|
1015
|
+
</plist>
|
|
1016
|
+
CFEOF
|
|
1017
|
+
bootstrap_launch_agent "$CFG_USER" "$LABEL_CLOUDFLARED" || true
|
|
1018
|
+
echo " -> cloudflared LaunchAgent installed and bootstrapped"
|
|
1019
|
+
fi
|
|
1020
|
+
CURRENT_STEP=$((CURRENT_STEP + 1))
|
|
1021
|
+
fi
|
|
1022
|
+
|
|
1023
|
+
# Step N-1: Restart agent
|
|
1024
|
+
echo "[$((TOTAL_STEPS - 1))/${TOTAL_STEPS}] Restarting agent..."
|
|
1025
|
+
if launchctl print "$(launchd_target "$CFG_USER" "$LABEL_AGENT")" >/dev/null 2>&1; then
|
|
1026
|
+
kickstart_launch_agent "$CFG_USER" "$LABEL_AGENT" || true
|
|
1027
|
+
echo " -> minion-agent restarted (launchd)"
|
|
1028
|
+
else
|
|
1029
|
+
bootstrap_launch_agent "$CFG_USER" "$LABEL_AGENT" || true
|
|
1030
|
+
echo " -> minion-agent bootstrapped (launchd)"
|
|
1031
|
+
fi
|
|
1032
|
+
|
|
1033
|
+
# Step N: Health check
|
|
1034
|
+
echo "[${TOTAL_STEPS}/${TOTAL_STEPS}] Verifying agent health..."
|
|
1035
|
+
local HEALTH_OK=false
|
|
1036
|
+
for i in $(seq 1 5); do
|
|
1037
|
+
if curl -sf http://localhost:8080/api/health > /dev/null 2>&1; then
|
|
1038
|
+
HEALTH_OK=true
|
|
1039
|
+
break
|
|
1040
|
+
fi
|
|
1041
|
+
sleep 2
|
|
1042
|
+
done
|
|
1043
|
+
if [ "$HEALTH_OK" = true ]; then
|
|
1044
|
+
echo " -> Agent is healthy"
|
|
1045
|
+
else
|
|
1046
|
+
echo " WARNING: Health check failed after 5 attempts"
|
|
1047
|
+
echo " Logs: tail -f ${CFG_HOME}/.minion/logs/agent.err.log"
|
|
1048
|
+
fi
|
|
1049
|
+
|
|
1050
|
+
# Notify HQ with machine specs
|
|
1051
|
+
echo ""
|
|
1052
|
+
echo "Notifying HQ..."
|
|
1053
|
+
local LAN_IP HOSTNAME_VAL
|
|
1054
|
+
LAN_IP="$(detect_lan_ip)"
|
|
1055
|
+
HOSTNAME_VAL="$(hostname 2>/dev/null || echo "")"
|
|
1056
|
+
|
|
1057
|
+
local CPU_MODEL CPU_CORES MEMORY_MB DISK_GB OS_INFO ARCH_INFO
|
|
1058
|
+
CPU_MODEL="$(sysctl -n machdep.cpu.brand_string 2>/dev/null || echo unknown)"
|
|
1059
|
+
CPU_CORES="$(sysctl -n hw.ncpu 2>/dev/null || echo 0)"
|
|
1060
|
+
local MEMORY_BYTES
|
|
1061
|
+
MEMORY_BYTES="$(sysctl -n hw.memsize 2>/dev/null || echo 0)"
|
|
1062
|
+
MEMORY_MB="$(( MEMORY_BYTES / 1024 / 1024 ))"
|
|
1063
|
+
DISK_GB="$(df -g / 2>/dev/null | awk 'NR==2 {print $2}')"
|
|
1064
|
+
OS_INFO="macOS $(sw_vers -productVersion 2>/dev/null || echo unknown)"
|
|
1065
|
+
ARCH_INFO="$(uname -m 2>/dev/null || echo unknown)"
|
|
1066
|
+
|
|
1067
|
+
local MACHINE_SPECS
|
|
1068
|
+
MACHINE_SPECS=$(cat <<SPECEOF
|
|
1069
|
+
{"cpu_model":"${CPU_MODEL}","cpu_cores":${CPU_CORES},"memory_mb":${MEMORY_MB},"disk_gb":${DISK_GB:-0},"os":"${OS_INFO}","arch":"${ARCH_INFO}"}
|
|
1070
|
+
SPECEOF
|
|
1071
|
+
)
|
|
1072
|
+
|
|
1073
|
+
local BODY
|
|
1074
|
+
if [ -n "$LAN_IP" ]; then
|
|
1075
|
+
BODY="{\"internal_ip_address\":\"${LAN_IP}\",\"ip_address\":\"${LAN_IP}\",\"machine_specs\":${MACHINE_SPECS}}"
|
|
1076
|
+
else
|
|
1077
|
+
BODY="{\"internal_ip_address\":\"${HOSTNAME_VAL}\",\"machine_specs\":${MACHINE_SPECS}}"
|
|
1078
|
+
fi
|
|
1079
|
+
|
|
1080
|
+
local NOTIFY_RESPONSE
|
|
1081
|
+
NOTIFY_RESPONSE="$(curl -sfL -X POST "${HQ_URL}/api/minion/setup-complete" \
|
|
1082
|
+
-H "Content-Type: application/json" \
|
|
1083
|
+
-H "Authorization: Bearer ${API_TOKEN}" \
|
|
1084
|
+
-d "$BODY" 2>&1)" || true
|
|
1085
|
+
|
|
1086
|
+
if echo "$NOTIFY_RESPONSE" | grep -q '"success":true' 2>/dev/null; then
|
|
1087
|
+
echo " -> HQ notified successfully${LAN_IP:+ (LAN IP: ${LAN_IP})}"
|
|
1088
|
+
else
|
|
1089
|
+
echo " -> Skipped (heartbeat will notify HQ within 30s)"
|
|
1090
|
+
fi
|
|
1091
|
+
|
|
1092
|
+
echo ""
|
|
1093
|
+
echo "========================================="
|
|
1094
|
+
echo " Configure Complete!"
|
|
1095
|
+
echo "========================================="
|
|
1096
|
+
echo ""
|
|
1097
|
+
echo "Useful commands:"
|
|
1098
|
+
echo " minion-cli-mac status # Agent status"
|
|
1099
|
+
echo " minion-cli-mac health # Health check"
|
|
1100
|
+
echo " minion-cli-mac restart # Restart agent"
|
|
1101
|
+
echo " tail -f ${CFG_HOME}/.minion/logs/agent.err.log # Agent logs"
|
|
1102
|
+
echo ""
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
# ============================================================
|
|
1106
|
+
# uninstall subcommand
|
|
1107
|
+
# ============================================================
|
|
1108
|
+
do_uninstall() {
|
|
1109
|
+
require_root uninstall
|
|
1110
|
+
|
|
1111
|
+
local KEEP_DATA=false
|
|
1112
|
+
local CLI_USER=""
|
|
1113
|
+
while [[ $# -gt 0 ]]; do
|
|
1114
|
+
case "$1" in
|
|
1115
|
+
--keep-data) KEEP_DATA=true; shift ;;
|
|
1116
|
+
--user) CLI_USER="$2"; shift 2 ;;
|
|
1117
|
+
*) echo "Unknown option: $1"; exit 1 ;;
|
|
1118
|
+
esac
|
|
1119
|
+
done
|
|
1120
|
+
|
|
1121
|
+
# If --user not provided, try to read from any existing /etc/sudoers.d/minion-agent
|
|
1122
|
+
if [ -z "$CLI_USER" ] && [ -f /etc/sudoers.d/minion-agent ]; then
|
|
1123
|
+
CLI_USER="$(grep -m1 '^Defaults:' /etc/sudoers.d/minion-agent 2>/dev/null | sed 's/Defaults://; s/[[:space:]].*//')"
|
|
1124
|
+
fi
|
|
1125
|
+
if [ -z "$CLI_USER" ]; then
|
|
1126
|
+
echo "ERROR: cannot determine target user; pass --user <USERNAME>"
|
|
1127
|
+
exit 1
|
|
1128
|
+
fi
|
|
1129
|
+
|
|
1130
|
+
local UN_HOME
|
|
1131
|
+
UN_HOME="$(resolve_user_home "$CLI_USER" 2>/dev/null || echo "")"
|
|
1132
|
+
if [ -z "$UN_HOME" ]; then
|
|
1133
|
+
echo " WARNING: User '$CLI_USER' does not exist; will only clean system-level state"
|
|
1134
|
+
fi
|
|
1135
|
+
|
|
1136
|
+
echo "========================================="
|
|
1137
|
+
echo " @geekbeer/minion Uninstall (macOS)"
|
|
1138
|
+
echo "========================================="
|
|
1139
|
+
echo ""
|
|
1140
|
+
echo "Target user: $CLI_USER"
|
|
1141
|
+
echo "Target home: ${UN_HOME:-<missing>}"
|
|
1142
|
+
if [ "$KEEP_DATA" = true ]; then
|
|
1143
|
+
echo " --keep-data: ${UN_HOME}/.minion will be preserved."
|
|
1144
|
+
else
|
|
1145
|
+
echo " ${UN_HOME}/.minion will be deleted."
|
|
1146
|
+
fi
|
|
1147
|
+
echo ""
|
|
1148
|
+
echo -n "Type 'yes' to continue: "
|
|
1149
|
+
read -r CONFIRM
|
|
1150
|
+
if [ "$CONFIRM" != "yes" ]; then
|
|
1151
|
+
echo "Uninstall cancelled."
|
|
1152
|
+
exit 0
|
|
1153
|
+
fi
|
|
1154
|
+
|
|
1155
|
+
# Step 1: Bootout LaunchAgents
|
|
1156
|
+
echo "[1/4] Removing LaunchAgents..."
|
|
1157
|
+
if [ -n "$UN_HOME" ]; then
|
|
1158
|
+
for label in "$LABEL_AGENT" "$LABEL_TMUX_INIT" "$LABEL_VNC_PROXY" "$LABEL_WEBSOCKIFY" "$LABEL_CLOUDFLARED"; do
|
|
1159
|
+
bootout_launch_agent "$CLI_USER" "$label"
|
|
1160
|
+
local plist="${UN_HOME}/Library/LaunchAgents/${label}.plist"
|
|
1161
|
+
if [ -f "$plist" ]; then
|
|
1162
|
+
rm -f "$plist"
|
|
1163
|
+
echo " -> Removed $plist"
|
|
1164
|
+
fi
|
|
1165
|
+
done
|
|
1166
|
+
fi
|
|
1167
|
+
|
|
1168
|
+
# Step 2: Remove sudoers
|
|
1169
|
+
echo "[2/4] Removing sudoers configuration..."
|
|
1170
|
+
if [ -f /etc/sudoers.d/minion-agent ]; then
|
|
1171
|
+
rm -f /etc/sudoers.d/minion-agent
|
|
1172
|
+
echo " -> Removed /etc/sudoers.d/minion-agent"
|
|
1173
|
+
fi
|
|
1174
|
+
|
|
1175
|
+
# Step 3: Remove data dir & cloudflared config
|
|
1176
|
+
echo "[3/4] Removing user data..."
|
|
1177
|
+
if [ -n "$UN_HOME" ]; then
|
|
1178
|
+
if [ "$KEEP_DATA" = false ] && [ -d "${UN_HOME}/.minion" ]; then
|
|
1179
|
+
rm -rf "${UN_HOME}/.minion"
|
|
1180
|
+
echo " -> Removed ${UN_HOME}/.minion"
|
|
1181
|
+
fi
|
|
1182
|
+
if [ -d "${UN_HOME}/.cloudflared" ]; then
|
|
1183
|
+
rm -rf "${UN_HOME}/.cloudflared"
|
|
1184
|
+
echo " -> Removed ${UN_HOME}/.cloudflared"
|
|
1185
|
+
fi
|
|
1186
|
+
fi
|
|
1187
|
+
|
|
1188
|
+
# Step 4: Remove user from screen sharing group (do NOT delete user)
|
|
1189
|
+
echo "[4/4] Removing user from com.apple.access_screensharing..."
|
|
1190
|
+
dseditgroup -o edit -d "$CLI_USER" -t user com.apple.access_screensharing 2>/dev/null || true
|
|
1191
|
+
echo " -> Done"
|
|
1192
|
+
|
|
1193
|
+
echo ""
|
|
1194
|
+
echo "Uninstall complete."
|
|
1195
|
+
echo " • Native Screen Sharing service is left enabled (it is system-managed)."
|
|
1196
|
+
echo " • The '$CLI_USER' user account was NOT deleted; remove with:"
|
|
1197
|
+
echo " sudo sysadminctl -deleteUser $CLI_USER"
|
|
1198
|
+
echo " • Brew packages (tmux, ttyd, websockify, cloudflared, etc.) were NOT removed."
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
# ============================================================
|
|
1202
|
+
# Main dispatch
|
|
1203
|
+
# ============================================================
|
|
1204
|
+
case "${1:-}" in
|
|
1205
|
+
--version|-v)
|
|
1206
|
+
echo "@geekbeer/minion (macOS) v${CLI_VERSION}"
|
|
1207
|
+
;;
|
|
1208
|
+
|
|
1209
|
+
setup)
|
|
1210
|
+
shift
|
|
1211
|
+
do_setup "$@"
|
|
1212
|
+
;;
|
|
1213
|
+
|
|
1214
|
+
configure|reconfigure)
|
|
1215
|
+
shift
|
|
1216
|
+
do_configure "$@"
|
|
1217
|
+
;;
|
|
1218
|
+
|
|
1219
|
+
uninstall)
|
|
1220
|
+
shift
|
|
1221
|
+
do_uninstall "$@"
|
|
1222
|
+
;;
|
|
1223
|
+
|
|
1224
|
+
status)
|
|
1225
|
+
curl -s "$AGENT_URL/api/status" | jq .
|
|
1226
|
+
;;
|
|
1227
|
+
|
|
1228
|
+
set-status)
|
|
1229
|
+
STATUS="${2:-online}"
|
|
1230
|
+
TASK="${3:-}"
|
|
1231
|
+
if [ -n "$TASK" ]; then
|
|
1232
|
+
curl -s -X POST "$AGENT_URL/api/status" \
|
|
1233
|
+
-H "Content-Type: application/json" \
|
|
1234
|
+
-d "{\"status\": \"$STATUS\", \"current_task\": \"$TASK\"}" | jq .
|
|
1235
|
+
else
|
|
1236
|
+
curl -s -X POST "$AGENT_URL/api/status" \
|
|
1237
|
+
-H "Content-Type: application/json" \
|
|
1238
|
+
-d "{\"status\": \"$STATUS\", \"current_task\": null}" | jq .
|
|
1239
|
+
fi
|
|
1240
|
+
;;
|
|
1241
|
+
|
|
1242
|
+
health)
|
|
1243
|
+
curl -s "$AGENT_URL/api/health" | jq .
|
|
1244
|
+
;;
|
|
1245
|
+
|
|
1246
|
+
daemons)
|
|
1247
|
+
curl -s "$AGENT_URL/api/daemons/status" | jq .
|
|
1248
|
+
;;
|
|
1249
|
+
|
|
1250
|
+
diagnose)
|
|
1251
|
+
echo "Running diagnostics..."
|
|
1252
|
+
echo ""
|
|
1253
|
+
RESULT=$(curl -s --max-time 15 "$AGENT_URL/api/diagnose" 2>/dev/null) || {
|
|
1254
|
+
echo "FAIL: Cannot reach minion agent at $AGENT_URL"
|
|
1255
|
+
echo " Is the agent running? Try: minion-cli-mac start"
|
|
1256
|
+
exit 1
|
|
1257
|
+
}
|
|
1258
|
+
SUMMARY=$(echo "$RESULT" | jq -r '.summary // "UNKNOWN"')
|
|
1259
|
+
VERSION=$(echo "$RESULT" | jq -r '.version // "?"')
|
|
1260
|
+
PLATFORM=$(echo "$RESULT" | jq -r '.platform // "?"')
|
|
1261
|
+
echo "=== Minion Diagnostics (v${VERSION}, ${PLATFORM}) ==="
|
|
1262
|
+
echo ""
|
|
1263
|
+
for CHECK in agent hq tunnel vnc terminal llm env; do
|
|
1264
|
+
OK=$(echo "$RESULT" | jq -r ".checks.${CHECK}.ok")
|
|
1265
|
+
DETAILS=$(echo "$RESULT" | jq -r ".checks.${CHECK}.details // \"\"")
|
|
1266
|
+
CHECK_UPPER=$(echo "$CHECK" | tr '[:lower:]' '[:upper:]')
|
|
1267
|
+
if [ "$OK" = "true" ]; then
|
|
1268
|
+
printf " \033[32m[PASS]\033[0m %-10s %s\n" "$CHECK_UPPER" "$DETAILS"
|
|
1269
|
+
else
|
|
1270
|
+
printf " \033[31m[FAIL]\033[0m %-10s %s\n" "$CHECK_UPPER" "$DETAILS"
|
|
1271
|
+
fi
|
|
1272
|
+
done
|
|
1273
|
+
echo ""
|
|
1274
|
+
if [ "$SUMMARY" = "ALL OK" ]; then
|
|
1275
|
+
echo -e "\033[32m$SUMMARY\033[0m"
|
|
1276
|
+
else
|
|
1277
|
+
echo -e "\033[33m$SUMMARY\033[0m"
|
|
1278
|
+
fi
|
|
1279
|
+
;;
|
|
1280
|
+
|
|
1281
|
+
start)
|
|
1282
|
+
svc_control start "${TARGET_USER}"
|
|
1283
|
+
echo "minion-agent started (launchd, user=$TARGET_USER)"
|
|
1284
|
+
;;
|
|
1285
|
+
|
|
1286
|
+
stop)
|
|
1287
|
+
svc_control stop "${TARGET_USER}"
|
|
1288
|
+
echo "minion-agent stopped (launchd, user=$TARGET_USER)"
|
|
1289
|
+
;;
|
|
1290
|
+
|
|
1291
|
+
restart)
|
|
1292
|
+
svc_control restart "${TARGET_USER}"
|
|
1293
|
+
echo "minion-agent restarted (launchd, user=$TARGET_USER)"
|
|
1294
|
+
;;
|
|
1295
|
+
|
|
1296
|
+
skill)
|
|
1297
|
+
shift
|
|
1298
|
+
SKILL_CMD="${1:-}"
|
|
1299
|
+
shift || true
|
|
1300
|
+
case "$SKILL_CMD" in
|
|
1301
|
+
push)
|
|
1302
|
+
SKILL_NAME="${1:-}"
|
|
1303
|
+
if [ -z "$SKILL_NAME" ]; then echo "Usage: minion-cli-mac skill push <skill-name>"; exit 1; fi
|
|
1304
|
+
curl -s -X POST "$AGENT_URL/api/skills/push/$SKILL_NAME" -H "Authorization: Bearer $API_TOKEN" | jq .
|
|
1305
|
+
;;
|
|
1306
|
+
fetch)
|
|
1307
|
+
SKILL_NAME="${1:-}"
|
|
1308
|
+
if [ -z "$SKILL_NAME" ]; then echo "Usage: minion-cli-mac skill fetch <skill-name>"; exit 1; fi
|
|
1309
|
+
curl -s -X POST "$AGENT_URL/api/skills/fetch/$SKILL_NAME" -H "Authorization: Bearer $API_TOKEN" | jq .
|
|
1310
|
+
;;
|
|
1311
|
+
list)
|
|
1312
|
+
FLAG="${1:-}"
|
|
1313
|
+
if [ "$FLAG" = "--local" ]; then
|
|
1314
|
+
curl -s "$AGENT_URL/api/list-skills" -H "Authorization: Bearer $API_TOKEN" | jq .
|
|
1315
|
+
else
|
|
1316
|
+
curl -s "$AGENT_URL/api/skills/remote" -H "Authorization: Bearer $API_TOKEN" | jq .
|
|
1317
|
+
fi
|
|
1318
|
+
;;
|
|
1319
|
+
*)
|
|
1320
|
+
echo "Usage: minion-cli-mac skill <push|fetch|list>"
|
|
1321
|
+
;;
|
|
1322
|
+
esac
|
|
1323
|
+
;;
|
|
1324
|
+
|
|
1325
|
+
*)
|
|
1326
|
+
echo "Minion Agent CLI (@geekbeer/minion macOS) v${CLI_VERSION}"
|
|
1327
|
+
echo ""
|
|
1328
|
+
echo "Usage:"
|
|
1329
|
+
echo " sudo minion-cli-mac setup --user <USERNAME> # System setup (root): user, sudoers, Screen Sharing, plists"
|
|
1330
|
+
echo " minion-cli-mac configure [options] # Connect to HQ (run as agent user)"
|
|
1331
|
+
echo " sudo minion-cli-mac uninstall [options] # Remove agent and services (root)"
|
|
1332
|
+
echo " minion-cli-mac start # Bootstrap agent LaunchAgent"
|
|
1333
|
+
echo " minion-cli-mac stop # Bootout agent LaunchAgent"
|
|
1334
|
+
echo " minion-cli-mac restart # Kickstart agent LaunchAgent"
|
|
1335
|
+
echo " minion-cli-mac status # Get current status"
|
|
1336
|
+
echo " minion-cli-mac health # Health check"
|
|
1337
|
+
echo " minion-cli-mac diagnose # Run full service diagnostics"
|
|
1338
|
+
echo " minion-cli-mac set-status <status> [task] # Set status and optional task"
|
|
1339
|
+
echo " minion-cli-mac skill <push|fetch|list> # Manage skills with HQ"
|
|
1340
|
+
echo " minion-cli-mac --version # Show version"
|
|
1341
|
+
echo ""
|
|
1342
|
+
echo "Configure options:"
|
|
1343
|
+
echo " --hq-url <URL> HQ server URL (required)"
|
|
1344
|
+
echo " --minion-id <UUID> Minion ID (required)"
|
|
1345
|
+
echo " --api-token <TOKEN> API token (required)"
|
|
1346
|
+
echo " --setup-tunnel Set up cloudflared tunnel"
|
|
1347
|
+
echo ""
|
|
1348
|
+
echo "Status values: online, offline, busy"
|
|
1349
|
+
echo ""
|
|
1350
|
+
echo "Environment:"
|
|
1351
|
+
echo " MINION_AGENT_URL Agent URL (default: http://localhost:8080)"
|
|
1352
|
+
;;
|
|
1353
|
+
esac
|