@geekbeer/minion 1.0.0

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/.env.example ADDED
@@ -0,0 +1,22 @@
1
+ # @geekbeer/minion - Agent Configuration
2
+ # Copy this file to .env and fill in your values
3
+ #
4
+ # If HQ_URL and API_TOKEN are omitted, the agent runs in standalone mode
5
+ # (no heartbeat, no HQ communication).
6
+
7
+ # HQ URL (optional) - The URL of the HQ server
8
+ # For managed service: https://minion.geekbeer.co.jp
9
+ # For local Docker development: http://host.docker.internal:3000
10
+ HQ_URL=
11
+
12
+ # API Token (optional) - Get this from the HQ dashboard after creating a minion
13
+ API_TOKEN=
14
+
15
+ # Minion ID (optional) - The minion's UUID assigned by HQ
16
+ MINION_ID=
17
+
18
+ # Agent port (optional, default: 3001)
19
+ AGENT_PORT=3001
20
+
21
+ # Heartbeat interval in seconds (optional, default: 30)
22
+ HEARTBEAT_INTERVAL=30
package/api.js ADDED
@@ -0,0 +1,76 @@
1
+ /**
2
+ * API Client for Minion Agent
3
+ * Communicates with the Minion management API
4
+ *
5
+ * In standalone mode (no HQ configured), all API calls are no-ops.
6
+ */
7
+
8
+ const { config, isHqConfigured } = require('./config')
9
+
10
+ /**
11
+ * Send HTTP request to the HQ server
12
+ * @param {string} endpoint - API endpoint path
13
+ * @param {object} options - Fetch options
14
+ */
15
+ async function request(endpoint, options = {}) {
16
+ if (!isHqConfigured()) {
17
+ return { skipped: true, reason: 'HQ not configured' }
18
+ }
19
+
20
+ const url = `${config.HQ_URL}/api/minion${endpoint}`
21
+
22
+ const response = await fetch(url, {
23
+ ...options,
24
+ headers: {
25
+ 'Content-Type': 'application/json',
26
+ 'Authorization': `Bearer ${config.API_TOKEN}`,
27
+ ...options.headers,
28
+ },
29
+ })
30
+
31
+ const data = await response.json()
32
+
33
+ if (!response.ok) {
34
+ throw new Error(data.error || `API request failed: ${response.status}`)
35
+ }
36
+
37
+ return data
38
+ }
39
+
40
+ /**
41
+ * Send heartbeat with status update
42
+ * @param {string} status - 'online' | 'offline' | 'busy'
43
+ * @param {string|null} currentTask - Current task description
44
+ */
45
+ async function sendHeartbeat(status, currentTask = null) {
46
+ return request('/heartbeat', {
47
+ method: 'POST',
48
+ body: JSON.stringify({
49
+ status,
50
+ current_task: currentTask,
51
+ }),
52
+ })
53
+ }
54
+
55
+ /**
56
+ * Send a log entry
57
+ * @param {string} message - Log message
58
+ * @param {'info'|'warning'|'error'} level - Log level
59
+ * @param {object|null} metadata - Additional metadata
60
+ */
61
+ async function log(message, level = 'info', metadata = null) {
62
+ return request('/log', {
63
+ method: 'POST',
64
+ body: JSON.stringify({
65
+ message,
66
+ level,
67
+ metadata,
68
+ }),
69
+ })
70
+ }
71
+
72
+ module.exports = {
73
+ request,
74
+ sendHeartbeat,
75
+ log,
76
+ }
package/config.js ADDED
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Minion Agent Configuration
3
+ *
4
+ * Optional environment variables (for HQ connection):
5
+ * - HQ_URL: The URL of the HQ server (e.g., https://minion.geekbeer.co.jp)
6
+ * - API_TOKEN: The API token for this minion (obtained from dashboard)
7
+ * - MINION_ID: The minion's UUID (assigned by HQ)
8
+ *
9
+ * If HQ_URL and API_TOKEN are not set, the agent runs in standalone mode
10
+ * (no heartbeat, no HQ communication).
11
+ *
12
+ * Other optional environment variables:
13
+ * - AGENT_PORT: Port for the local agent server (default: 3001)
14
+ * - HEARTBEAT_INTERVAL: Interval between heartbeats in seconds (default: 30)
15
+ */
16
+
17
+ const config = {
18
+ // HQ Server Configuration (optional - omit for standalone mode)
19
+ HQ_URL: process.env.HQ_URL || '',
20
+ API_TOKEN: process.env.API_TOKEN || '',
21
+ MINION_ID: process.env.MINION_ID || '',
22
+
23
+ // Server settings
24
+ AGENT_PORT: parseInt(process.env.AGENT_PORT, 10) || 3001,
25
+
26
+ // Heartbeat interval in seconds
27
+ HEARTBEAT_INTERVAL: parseInt(process.env.HEARTBEAT_INTERVAL, 10) || 30,
28
+ }
29
+
30
+ /**
31
+ * Check if HQ connection is configured
32
+ */
33
+ function isHqConfigured() {
34
+ return !!(config.HQ_URL && config.API_TOKEN)
35
+ }
36
+
37
+ /**
38
+ * Validate configuration and log mode
39
+ */
40
+ function validate() {
41
+ if (isHqConfigured()) {
42
+ console.log(`[Config] HQ configured: ${config.HQ_URL}`)
43
+ } else {
44
+ console.log('[Config] HQ not configured, running in standalone mode')
45
+ }
46
+ }
47
+
48
+ module.exports = { config, validate, isHqConfigured }
package/heartbeat.js ADDED
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Heartbeat Manager for Minion Agent
3
+ * Sends periodic heartbeats to the API
4
+ */
5
+
6
+ const { config } = require('./config')
7
+ const api = require('./api')
8
+
9
+ // Current state
10
+ let currentStatus = 'online'
11
+ let currentTask = null
12
+ let intervalId = null
13
+
14
+ /**
15
+ * Start the heartbeat loop
16
+ */
17
+ function start() {
18
+ console.log(`[Heartbeat] Starting with interval: ${config.HEARTBEAT_INTERVAL}s`)
19
+
20
+ // Send initial heartbeat
21
+ sendHeartbeat()
22
+
23
+ // Schedule periodic heartbeats
24
+ intervalId = setInterval(sendHeartbeat, config.HEARTBEAT_INTERVAL * 1000)
25
+ }
26
+
27
+ /**
28
+ * Stop the heartbeat loop
29
+ */
30
+ function stop() {
31
+ if (intervalId) {
32
+ clearInterval(intervalId)
33
+ intervalId = null
34
+ console.log('[Heartbeat] Stopped')
35
+ }
36
+ }
37
+
38
+ /**
39
+ * Send a heartbeat to the API
40
+ */
41
+ async function sendHeartbeat() {
42
+ try {
43
+ await api.sendHeartbeat(currentStatus, currentTask)
44
+ console.log(`[Heartbeat] Sent: status=${currentStatus}, task=${currentTask || 'none'}`)
45
+ } catch (error) {
46
+ console.error(`[Heartbeat] Failed: ${error.message}`)
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Set the current status
52
+ * @param {'online'|'offline'|'busy'} status
53
+ */
54
+ function setStatus(status) {
55
+ if (!['online', 'offline', 'busy'].includes(status)) {
56
+ throw new Error(`Invalid status: ${status}`)
57
+ }
58
+ currentStatus = status
59
+ }
60
+
61
+ /**
62
+ * Set the current task
63
+ * @param {string|null} task
64
+ */
65
+ function setTask(task) {
66
+ currentTask = task || null
67
+ }
68
+
69
+ /**
70
+ * Get the current status
71
+ */
72
+ function getStatus() {
73
+ return currentStatus
74
+ }
75
+
76
+ /**
77
+ * Get the current task
78
+ */
79
+ function getTask() {
80
+ return currentTask
81
+ }
82
+
83
+ module.exports = {
84
+ start,
85
+ stop,
86
+ sendHeartbeat,
87
+ setStatus,
88
+ setTask,
89
+ getStatus,
90
+ getTask,
91
+ }
package/minion-cli.sh ADDED
@@ -0,0 +1,445 @@
1
+ #!/usr/bin/env bash
2
+ #
3
+ # Minion Agent CLI (@geekbeer/minion)
4
+ # CLI tool for interacting with and setting up the minion agent
5
+ #
6
+ # Usage:
7
+ # minion-cli setup [options] # Set up minion agent service
8
+ # minion-cli start # Start agent service
9
+ # minion-cli stop # Stop agent service
10
+ # minion-cli restart # Restart agent service
11
+ # minion-cli status # Get current status
12
+ # minion-cli health # Health check
13
+ # minion-cli set-status busy "Running X" # Set status and task
14
+ # minion-cli set-status online # Set status only
15
+ # minion-cli log "Message" [level] [skill] # Send log entry
16
+ #
17
+ # Setup options:
18
+ # --hq-url <URL> HQ server URL (optional, omit for standalone mode)
19
+ # --minion-id <UUID> Minion ID (optional)
20
+ # --api-token <TOKEN> API token (optional)
21
+ # --setup-tunnel Set up cloudflared tunnel (requires --hq-url and --api-token)
22
+
23
+ set -euo pipefail
24
+
25
+ # Resolve version from package.json (installed location)
26
+ CLI_DIR="$(cd "$(dirname "$(readlink -f "$0")")" && pwd)"
27
+ CLI_VERSION="$(node -p "require('${CLI_DIR}/package.json').version")"
28
+
29
+ # Use sudo only when not running as root
30
+ SUDO=""
31
+ if [ "$(id -u)" -ne 0 ] && command -v sudo &>/dev/null; then
32
+ SUDO="sudo"
33
+ fi
34
+
35
+ # Detect process manager: systemd or supervisord
36
+ # systemd takes priority when both are available (standard Linux VPS)
37
+ SERVICE_NAME="minion-agent"
38
+ if command -v systemctl &>/dev/null && systemctl --version &>/dev/null 2>&1; then
39
+ PROC_MGR="systemd"
40
+ elif command -v supervisorctl &>/dev/null; then
41
+ PROC_MGR="supervisord"
42
+ else
43
+ PROC_MGR=""
44
+ fi
45
+
46
+ # Service control helper
47
+ svc_control() {
48
+ local action="$1"
49
+ case "$PROC_MGR" in
50
+ systemd)
51
+ $SUDO systemctl "$action" "$SERVICE_NAME"
52
+ ;;
53
+ supervisord)
54
+ $SUDO supervisorctl "$action" "$SERVICE_NAME"
55
+ ;;
56
+ *)
57
+ echo "Error: No supported process manager found (systemd or supervisord)"
58
+ exit 1
59
+ ;;
60
+ esac
61
+ }
62
+
63
+ AGENT_URL="${MINION_AGENT_URL:-http://localhost:3001}"
64
+
65
+ # ============================================================
66
+ # setup subcommand
67
+ # ============================================================
68
+ do_setup() {
69
+ local HQ_URL=""
70
+ local MINION_ID=""
71
+ local API_TOKEN=""
72
+ local SETUP_TUNNEL=false
73
+
74
+ # Parse arguments
75
+ while [[ $# -gt 0 ]]; do
76
+ case "$1" in
77
+ --hq-url)
78
+ HQ_URL="$2"
79
+ shift 2
80
+ ;;
81
+ --minion-id)
82
+ MINION_ID="$2"
83
+ shift 2
84
+ ;;
85
+ --api-token)
86
+ API_TOKEN="$2"
87
+ shift 2
88
+ ;;
89
+ --setup-tunnel)
90
+ SETUP_TUNNEL=true
91
+ shift
92
+ ;;
93
+ *)
94
+ echo "Unknown option: $1"
95
+ echo "Usage: minion-cli setup [--hq-url <URL>] [--minion-id <UUID>] [--api-token <TOKEN>] [--setup-tunnel]"
96
+ exit 1
97
+ ;;
98
+ esac
99
+ done
100
+
101
+ echo "========================================="
102
+ echo " @geekbeer/minion Setup"
103
+ echo "========================================="
104
+
105
+ if [ -n "$HQ_URL" ]; then
106
+ echo "Mode: Connected to HQ ($HQ_URL)"
107
+ else
108
+ echo "Mode: Standalone (no HQ connection)"
109
+ fi
110
+ if [ "$SETUP_TUNNEL" = true ]; then
111
+ echo "Tunnel: Enabled"
112
+ fi
113
+ echo ""
114
+
115
+ local TOTAL_STEPS=5
116
+ if [ "$SETUP_TUNNEL" = true ]; then
117
+ TOTAL_STEPS=6
118
+ fi
119
+
120
+ # Step 1: Create config directory
121
+ echo "[1/${TOTAL_STEPS}] Creating config directory..."
122
+ $SUDO mkdir -p /opt/minion-agent
123
+ echo " -> /opt/minion-agent/ created"
124
+
125
+ # Step 2: Generate .env file
126
+ echo "[2/${TOTAL_STEPS}] Generating .env file..."
127
+ local ENV_CONTENT=""
128
+ ENV_CONTENT+="# Minion Agent Configuration\n"
129
+ ENV_CONTENT+="# Generated by minion-cli setup\n\n"
130
+
131
+ if [ -n "$HQ_URL" ]; then
132
+ ENV_CONTENT+="HQ_URL=${HQ_URL}\n"
133
+ fi
134
+ if [ -n "$API_TOKEN" ]; then
135
+ ENV_CONTENT+="API_TOKEN=${API_TOKEN}\n"
136
+ fi
137
+ if [ -n "$MINION_ID" ]; then
138
+ ENV_CONTENT+="MINION_ID=${MINION_ID}\n"
139
+ fi
140
+
141
+ ENV_CONTENT+="AGENT_PORT=3001\n"
142
+ ENV_CONTENT+="HEARTBEAT_INTERVAL=30\n"
143
+
144
+ echo -e "$ENV_CONTENT" | $SUDO tee /opt/minion-agent/.env > /dev/null
145
+ echo " -> /opt/minion-agent/.env generated"
146
+
147
+ # Step 3: Create service configuration
148
+ echo "[3/${TOTAL_STEPS}] Creating service configuration ($PROC_MGR)..."
149
+ local NPM_ROOT
150
+ NPM_ROOT="$(npm root -g)"
151
+ local SERVER_PATH="${NPM_ROOT}/@geekbeer/minion/server.js"
152
+
153
+ if [ ! -f "$SERVER_PATH" ]; then
154
+ echo " ERROR: server.js not found at $SERVER_PATH"
155
+ echo " Please run: npm install -g @geekbeer/minion"
156
+ exit 1
157
+ fi
158
+
159
+ case "$PROC_MGR" in
160
+ systemd)
161
+ $SUDO tee /etc/systemd/system/minion-agent.service > /dev/null <<SVCEOF
162
+ [Unit]
163
+ Description=Minion Agent API (@geekbeer/minion)
164
+ After=network.target
165
+
166
+ [Service]
167
+ Type=simple
168
+ User=root
169
+ WorkingDirectory=/opt/minion-agent
170
+ ExecStart=/usr/bin/node ${SERVER_PATH}
171
+ Restart=always
172
+ RestartSec=10
173
+ EnvironmentFile=/opt/minion-agent/.env
174
+
175
+ [Install]
176
+ WantedBy=multi-user.target
177
+ SVCEOF
178
+ echo " -> /etc/systemd/system/minion-agent.service created"
179
+ ;;
180
+
181
+ supervisord)
182
+ # Build environment line from .env values
183
+ local ENV_LINE="environment="
184
+ local ENV_PAIRS=()
185
+ while IFS='=' read -r key value; do
186
+ [[ -z "$key" || "$key" == \#* ]] && continue
187
+ ENV_PAIRS+=("${key}=\"${value}\"")
188
+ done < /opt/minion-agent/.env
189
+ ENV_LINE+="$(IFS=,; echo "${ENV_PAIRS[*]}")"
190
+
191
+ $SUDO tee /etc/supervisor/conf.d/minion-agent.conf > /dev/null <<SUPEOF
192
+ [program:minion-agent]
193
+ command=/usr/bin/node ${SERVER_PATH}
194
+ directory=/opt/minion-agent
195
+ ${ENV_LINE}
196
+ autorestart=true
197
+ priority=500
198
+ startsecs=3
199
+ stdout_logfile=/var/log/supervisor/minion-agent.log
200
+ stderr_logfile=/var/log/supervisor/minion-agent.log
201
+ SUPEOF
202
+ echo " -> /etc/supervisor/conf.d/minion-agent.conf created"
203
+ ;;
204
+
205
+ *)
206
+ echo " ERROR: No supported process manager found (systemd or supervisord)"
207
+ exit 1
208
+ ;;
209
+ esac
210
+
211
+ # Step 4: Enable and start service
212
+ echo "[4/${TOTAL_STEPS}] Starting minion-agent service..."
213
+ case "$PROC_MGR" in
214
+ systemd)
215
+ $SUDO systemctl daemon-reload
216
+ $SUDO systemctl enable minion-agent
217
+ $SUDO systemctl start minion-agent
218
+ ;;
219
+ supervisord)
220
+ if $SUDO supervisorctl status &>/dev/null; then
221
+ $SUDO supervisorctl reread
222
+ $SUDO supervisorctl update
223
+ else
224
+ echo " -> supervisord not running; config will be loaded on next start"
225
+ fi
226
+ ;;
227
+ esac
228
+ echo " -> minion-agent service started ($PROC_MGR)"
229
+
230
+ # Step 5: Health check with retry
231
+ echo "[5/${TOTAL_STEPS}] Running health check..."
232
+ local RETRIES=5
233
+ local DELAY=2
234
+ local SUCCESS=false
235
+
236
+ for i in $(seq 1 $RETRIES); do
237
+ if curl -sf http://localhost:3001/api/health > /dev/null 2>&1; then
238
+ SUCCESS=true
239
+ break
240
+ fi
241
+ echo " Waiting for agent to start... (attempt $i/$RETRIES)"
242
+ sleep $DELAY
243
+ done
244
+
245
+ if [ "$SUCCESS" = true ]; then
246
+ echo " -> Health check passed"
247
+ else
248
+ echo " WARNING: Health check failed after $RETRIES attempts"
249
+ if [ "$PROC_MGR" = "systemd" ]; then
250
+ echo " Check logs with: journalctl -u minion-agent -f"
251
+ else
252
+ echo " Check logs with: tail -f /var/log/supervisor/minion-agent.log"
253
+ fi
254
+ fi
255
+
256
+ # Notify HQ if configured
257
+ if [ -n "$HQ_URL" ] && [ -n "$API_TOKEN" ]; then
258
+ echo ""
259
+ echo "Notifying HQ of setup completion..."
260
+ local NOTIFY_RESPONSE
261
+ NOTIFY_RESPONSE=$(curl -sf -X POST "${HQ_URL}/api/minion/setup-complete" \
262
+ -H "Content-Type: application/json" \
263
+ -H "Authorization: Bearer ${API_TOKEN}" \
264
+ -d "{}" 2>&1) || true
265
+
266
+ if echo "$NOTIFY_RESPONSE" | grep -q '"success":true' 2>/dev/null; then
267
+ echo " -> HQ notified successfully"
268
+ else
269
+ echo " -> HQ notification skipped (HQ may not be reachable)"
270
+ fi
271
+ fi
272
+
273
+ # Step 6 (optional): Cloudflare Tunnel setup
274
+ if [ "$SETUP_TUNNEL" = true ]; then
275
+ echo ""
276
+ echo "[6/${TOTAL_STEPS}] Setting up Cloudflare Tunnel..."
277
+
278
+ if [ -z "$HQ_URL" ] || [ -z "$API_TOKEN" ]; then
279
+ echo " ERROR: --setup-tunnel requires --hq-url and --api-token"
280
+ exit 1
281
+ fi
282
+
283
+ # Install cloudflared if not present
284
+ if ! command -v cloudflared &>/dev/null; then
285
+ echo " Installing cloudflared..."
286
+ curl -fsSL https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64.deb \
287
+ -o /tmp/cloudflared.deb
288
+ $SUDO dpkg -i /tmp/cloudflared.deb
289
+ rm -f /tmp/cloudflared.deb
290
+ else
291
+ echo " -> cloudflared already installed"
292
+ fi
293
+
294
+ # Fetch tunnel config from HQ API
295
+ # Response contains credentials_json and config_yml (generated server-side)
296
+ echo " Fetching tunnel configuration from HQ..."
297
+ local TUNNEL_DATA
298
+ TUNNEL_DATA=$(curl -sf -H "Authorization: Bearer ${API_TOKEN}" \
299
+ "${HQ_URL}/api/minion/tunnel-credentials" 2>&1) || true
300
+
301
+ if [ -z "$TUNNEL_DATA" ] || ! echo "$TUNNEL_DATA" | jq -e '.tunnel_id' > /dev/null 2>&1; then
302
+ echo " ERROR: Failed to fetch tunnel credentials from HQ"
303
+ echo " Tunnel may not be configured for this minion yet"
304
+ echo " Skipping tunnel setup (agent is running without tunnel)"
305
+ else
306
+ local TUNNEL_ID CREDS_JSON CONFIG_YML
307
+ TUNNEL_ID=$(echo "$TUNNEL_DATA" | jq -r '.tunnel_id')
308
+ CREDS_JSON=$(echo "$TUNNEL_DATA" | jq -r '.credentials_json')
309
+ CONFIG_YML=$(echo "$TUNNEL_DATA" | jq -r '.config_yml')
310
+
311
+ # Save credentials file (content from HQ, saved as-is)
312
+ $SUDO mkdir -p /etc/cloudflared
313
+ echo "$CREDS_JSON" | $SUDO tee "/etc/cloudflared/${TUNNEL_ID}.json" > /dev/null
314
+ $SUDO chmod 600 "/etc/cloudflared/${TUNNEL_ID}.json"
315
+ $SUDO chown root:root "/etc/cloudflared/${TUNNEL_ID}.json"
316
+ echo " -> /etc/cloudflared/${TUNNEL_ID}.json saved"
317
+
318
+ # Save config file (content from HQ, saved as-is)
319
+ echo "$CONFIG_YML" | $SUDO tee /etc/cloudflared/config.yml > /dev/null
320
+ echo " -> /etc/cloudflared/config.yml saved"
321
+
322
+ # Install and start cloudflared service
323
+ $SUDO cloudflared service install 2>/dev/null || true
324
+ $SUDO systemctl enable cloudflared 2>/dev/null || true
325
+ $SUDO systemctl start cloudflared 2>/dev/null || true
326
+ echo " -> cloudflared tunnel configured and started"
327
+ fi
328
+ fi
329
+
330
+ echo ""
331
+ echo "========================================="
332
+ echo " Setup Complete!"
333
+ echo "========================================="
334
+ echo ""
335
+ echo "Useful commands:"
336
+ echo " minion-cli status # Agent status"
337
+ echo " minion-cli health # Health check"
338
+ echo " minion-cli restart # Restart agent"
339
+ echo " minion-cli stop # Stop agent"
340
+ if [ "$PROC_MGR" = "systemd" ]; then
341
+ echo " journalctl -u minion-agent -f # View logs"
342
+ else
343
+ echo " tail -f /var/log/supervisor/minion-agent.log # View logs"
344
+ fi
345
+ }
346
+
347
+ # ============================================================
348
+ # Main command dispatch
349
+ # ============================================================
350
+ case "${1:-}" in
351
+ --version|-v)
352
+ echo "@geekbeer/minion v${CLI_VERSION}"
353
+ ;;
354
+
355
+ setup)
356
+ shift
357
+ do_setup "$@"
358
+ ;;
359
+
360
+ status)
361
+ curl -s "$AGENT_URL/api/status" | jq .
362
+ ;;
363
+
364
+ set-status)
365
+ STATUS="${2:-online}"
366
+ TASK="${3:-}"
367
+
368
+ if [ -n "$TASK" ]; then
369
+ curl -s -X POST "$AGENT_URL/api/status" \
370
+ -H "Content-Type: application/json" \
371
+ -d "{\"status\": \"$STATUS\", \"current_task\": \"$TASK\"}" | jq .
372
+ else
373
+ curl -s -X POST "$AGENT_URL/api/status" \
374
+ -H "Content-Type: application/json" \
375
+ -d "{\"status\": \"$STATUS\", \"current_task\": null}" | jq .
376
+ fi
377
+ ;;
378
+
379
+ log)
380
+ MESSAGE="${2:-}"
381
+ LEVEL="${3:-info}"
382
+ SKILL="${4:-}"
383
+
384
+ if [ -z "$MESSAGE" ]; then
385
+ echo "Usage: minion-cli log \"message\" [level] [skill_name]"
386
+ exit 1
387
+ fi
388
+
389
+ if [ -n "$SKILL" ]; then
390
+ curl -s -X POST "$AGENT_URL/api/log" \
391
+ -H "Content-Type: application/json" \
392
+ -d "{\"message\": \"$MESSAGE\", \"level\": \"$LEVEL\", \"skill_name\": \"$SKILL\"}" | jq .
393
+ else
394
+ curl -s -X POST "$AGENT_URL/api/log" \
395
+ -H "Content-Type: application/json" \
396
+ -d "{\"message\": \"$MESSAGE\", \"level\": \"$LEVEL\"}" | jq .
397
+ fi
398
+ ;;
399
+
400
+ health)
401
+ curl -s "$AGENT_URL/api/health" | jq .
402
+ ;;
403
+
404
+ start)
405
+ svc_control start
406
+ echo "minion-agent started ($PROC_MGR)"
407
+ ;;
408
+
409
+ stop)
410
+ svc_control stop
411
+ echo "minion-agent stopped ($PROC_MGR)"
412
+ ;;
413
+
414
+ restart)
415
+ svc_control restart
416
+ echo "minion-agent restarted ($PROC_MGR)"
417
+ ;;
418
+
419
+ *)
420
+ echo "Minion Agent CLI (@geekbeer/minion) v${CLI_VERSION}"
421
+ echo ""
422
+ echo "Usage:"
423
+ echo " minion-cli setup [options] # Set up agent service"
424
+ echo " minion-cli start # Start agent service"
425
+ echo " minion-cli stop # Stop agent service"
426
+ echo " minion-cli restart # Restart agent service"
427
+ echo " minion-cli status # Get current status"
428
+ echo " minion-cli health # Health check"
429
+ echo " minion-cli set-status <status> [task] # Set status and optional task"
430
+ echo " minion-cli log <message> [level] [skill] # Send log entry"
431
+ echo " minion-cli --version # Show version"
432
+ echo ""
433
+ echo "Setup options:"
434
+ echo " --hq-url <URL> HQ server URL (optional)"
435
+ echo " --minion-id <UUID> Minion ID (optional)"
436
+ echo " --api-token <TOKEN> API token (optional)"
437
+ echo " --setup-tunnel Set up cloudflared tunnel (requires --hq-url, --api-token)"
438
+ echo ""
439
+ echo "Status values: online, offline, busy"
440
+ echo "Log levels: info, warn, error"
441
+ echo ""
442
+ echo "Environment:"
443
+ echo " MINION_AGENT_URL Agent URL (default: http://localhost:3001)"
444
+ ;;
445
+ esac
package/package.json ADDED
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "@geekbeer/minion",
3
+ "version": "1.0.0",
4
+ "description": "AI Agent runtime for Minion - manages heartbeat, status, and skill deployment on VPS",
5
+ "main": "server.js",
6
+ "bin": {
7
+ "minion-cli": "./minion-cli.sh"
8
+ },
9
+ "files": [
10
+ "server.js",
11
+ "config.js",
12
+ "heartbeat.js",
13
+ "api.js",
14
+ "minion-cli.sh",
15
+ ".env.example"
16
+ ],
17
+ "scripts": {
18
+ "start": "node server.js"
19
+ },
20
+ "dependencies": {
21
+ "fastify": "^5.2.2"
22
+ },
23
+ "engines": {
24
+ "node": ">=20.0.0"
25
+ },
26
+ "publishConfig": {
27
+ "access": "public"
28
+ },
29
+ "keywords": [
30
+ "ai-agent",
31
+ "minion",
32
+ "claude",
33
+ "automation"
34
+ ],
35
+ "license": "MIT"
36
+ }
package/server.js ADDED
@@ -0,0 +1,563 @@
1
+ /**
2
+ * Minion Agent HTTP Server
3
+ * Provides local status reporting and control endpoints
4
+ */
5
+
6
+ const { exec, execSync } = require('child_process')
7
+ const { promisify } = require('util')
8
+ const fs = require('fs').promises
9
+ const path = require('path')
10
+ const os = require('os')
11
+ const execAsync = promisify(exec)
12
+
13
+ const fastify = require('fastify')({ logger: true })
14
+ const { config, validate, isHqConfigured } = require('./config')
15
+ const heartbeat = require('./heartbeat')
16
+ const api = require('./api')
17
+
18
+ // Detect process manager (matching minion-cli.sh logic)
19
+ function detectProcessManager() {
20
+ try {
21
+ execSync('systemctl --version', { stdio: 'ignore' })
22
+ return 'systemd'
23
+ } catch {
24
+ // systemd not available
25
+ }
26
+ try {
27
+ execSync('which supervisorctl', { stdio: 'ignore' })
28
+ return 'supervisord'
29
+ } catch {
30
+ // supervisord not available
31
+ }
32
+ return 'standalone'
33
+ }
34
+
35
+ const PROC_MGR = detectProcessManager()
36
+
37
+ // Use sudo only when not running as root
38
+ const SUDO = process.getuid && process.getuid() !== 0 ? 'sudo ' : ''
39
+
40
+ // Build allowed commands based on detected process manager
41
+ function buildAllowedCommands() {
42
+ const commands = {}
43
+
44
+ if (PROC_MGR === 'systemd') {
45
+ commands['restart-agent'] = {
46
+ description: 'Restart the minion agent service',
47
+ command: `${SUDO}systemctl restart minion-agent`,
48
+ deferred: true,
49
+ }
50
+ commands['update-agent'] = {
51
+ description: 'Update @geekbeer/minion to latest version and restart',
52
+ command: `npm update -g @geekbeer/minion && ${SUDO}systemctl restart minion-agent`,
53
+ deferred: true,
54
+ }
55
+ commands['restart-display'] = {
56
+ description: 'Restart Xvfb and noVNC services',
57
+ command: `${SUDO}systemctl restart xvfb novnc`,
58
+ }
59
+ commands['status-services'] = {
60
+ description: 'Check status of all services',
61
+ command: 'systemctl status minion-agent xvfb novnc --no-pager',
62
+ }
63
+ } else if (PROC_MGR === 'supervisord') {
64
+ commands['restart-agent'] = {
65
+ description: 'Restart the minion agent service',
66
+ command: `${SUDO}supervisorctl restart minion-agent`,
67
+ deferred: true,
68
+ }
69
+ commands['update-agent'] = {
70
+ description: 'Update @geekbeer/minion to latest version and restart',
71
+ command: `npm update -g @geekbeer/minion && ${SUDO}supervisorctl restart minion-agent`,
72
+ deferred: true,
73
+ }
74
+ commands['restart-display'] = {
75
+ description: 'Restart Xvfb, x11vnc and noVNC services',
76
+ command: `${SUDO}supervisorctl restart xvfb fluxbox x11vnc novnc`,
77
+ }
78
+ commands['status-services'] = {
79
+ description: 'Check status of all services',
80
+ command: `${SUDO}supervisorctl status`,
81
+ }
82
+ } else {
83
+ // Standalone mode: limited commands
84
+ commands['update-agent'] = {
85
+ description: 'Update @geekbeer/minion to latest version',
86
+ command: 'npm update -g @geekbeer/minion',
87
+ }
88
+ commands['status-services'] = {
89
+ description: 'Show agent process info',
90
+ command: 'echo "Process Manager: standalone (no systemd/supervisord)" && echo "Agent PID: $$" && echo "Node version: $(node -v)" && echo "Uptime: $(ps -o etime= -p $$)"',
91
+ }
92
+ }
93
+
94
+ return commands
95
+ }
96
+
97
+ const ALLOWED_COMMANDS = buildAllowedCommands()
98
+
99
+ /**
100
+ * Verify API token from Authorization header
101
+ */
102
+ function verifyToken(request) {
103
+ const authHeader = request.headers.authorization
104
+ if (!authHeader?.startsWith('Bearer ')) {
105
+ return false
106
+ }
107
+ const token = authHeader.substring(7)
108
+ return token === config.API_TOKEN
109
+ }
110
+
111
+ // Validate configuration before starting
112
+ validate()
113
+ console.log(`[Config] Process manager: ${PROC_MGR}`)
114
+ console.log(`[Config] Available commands: ${Object.keys(ALLOWED_COMMANDS).join(', ')}`)
115
+
116
+ // Health check endpoint
117
+ fastify.get('/api/health', async () => {
118
+ return {
119
+ status: 'ok',
120
+ timestamp: new Date().toISOString(),
121
+ }
122
+ })
123
+
124
+ // Get current status
125
+ fastify.get('/api/status', async () => {
126
+ return {
127
+ status: heartbeat.getStatus(),
128
+ current_task: heartbeat.getTask(),
129
+ uptime: process.uptime(),
130
+ timestamp: new Date().toISOString(),
131
+ }
132
+ })
133
+
134
+ // Update status
135
+ fastify.post('/api/status', async (request, reply) => {
136
+ const { status, current_task } = request.body || {}
137
+
138
+ try {
139
+ if (status) {
140
+ heartbeat.setStatus(status)
141
+ }
142
+
143
+ if (current_task !== undefined) {
144
+ heartbeat.setTask(current_task)
145
+ }
146
+
147
+ // Immediately send heartbeat with new status
148
+ await heartbeat.sendHeartbeat()
149
+
150
+ return { success: true }
151
+ } catch (error) {
152
+ reply.code(400)
153
+ return { success: false, error: error.message }
154
+ }
155
+ })
156
+
157
+ // Send log entry
158
+ fastify.post('/api/log', async (request, reply) => {
159
+ const { message, level = 'info', metadata } = request.body || {}
160
+
161
+ if (!message) {
162
+ reply.code(400)
163
+ return { success: false, error: 'message is required' }
164
+ }
165
+
166
+ try {
167
+ await api.log(message, level, metadata)
168
+ return { success: true }
169
+ } catch (error) {
170
+ reply.code(500)
171
+ return { success: false, error: error.message }
172
+ }
173
+ })
174
+
175
+ // List available commands
176
+ fastify.get('/api/commands', async (request, reply) => {
177
+ // Require authentication
178
+ if (!verifyToken(request)) {
179
+ reply.code(401)
180
+ return { success: false, error: 'Unauthorized' }
181
+ }
182
+
183
+ return {
184
+ commands: Object.entries(ALLOWED_COMMANDS).map(([name, info]) => ({
185
+ name,
186
+ description: info.description,
187
+ })),
188
+ }
189
+ })
190
+
191
+ // Change VNC password
192
+ fastify.post('/api/vnc-password', async (request, reply) => {
193
+ // Require authentication
194
+ if (!verifyToken(request)) {
195
+ reply.code(401)
196
+ return { success: false, error: 'Unauthorized' }
197
+ }
198
+
199
+ const { password } = request.body || {}
200
+
201
+ if (!password || password.length < 1) {
202
+ reply.code(400)
203
+ return { success: false, error: 'password is required' }
204
+ }
205
+
206
+ console.log('[VNC] Changing VNC password')
207
+
208
+ try {
209
+ // Log password change to HQ
210
+ await api.log('Changing VNC password', 'info')
211
+
212
+ // Escape single quotes in password for shell safety
213
+ const escapedPassword = password.replace(/'/g, "'\\''")
214
+
215
+ // Use x11vnc to store the new password
216
+ await execAsync(
217
+ `x11vnc -storepasswd '${escapedPassword}' ~/.vnc/passwd`,
218
+ { timeout: 10000 }
219
+ )
220
+
221
+ // Restart x11vnc to apply the new password
222
+ if (PROC_MGR === 'systemd') {
223
+ await execAsync(`${SUDO}systemctl restart x11vnc`, { timeout: 10000 })
224
+ } else if (PROC_MGR === 'supervisord') {
225
+ await execAsync(`${SUDO}supervisorctl restart x11vnc`, { timeout: 10000 })
226
+ }
227
+
228
+ console.log('[VNC] Password changed successfully')
229
+
230
+ return {
231
+ success: true,
232
+ message: 'VNC password changed successfully',
233
+ }
234
+ } catch (error) {
235
+ console.error(`[VNC] Failed to change password: ${error.message}`)
236
+
237
+ // Log error to HQ
238
+ await api.log(`Failed to change VNC password: ${error.message}`, 'error')
239
+
240
+ reply.code(500)
241
+ return {
242
+ success: false,
243
+ error: error.message,
244
+ }
245
+ }
246
+ })
247
+
248
+ // Execute a whitelisted command
249
+ fastify.post('/api/command', async (request, reply) => {
250
+ // Require authentication
251
+ if (!verifyToken(request)) {
252
+ reply.code(401)
253
+ return { success: false, error: 'Unauthorized' }
254
+ }
255
+
256
+ const { command } = request.body || {}
257
+
258
+ if (!command) {
259
+ reply.code(400)
260
+ return { success: false, error: 'command is required' }
261
+ }
262
+
263
+ // Check if command is in whitelist
264
+ const allowedCommand = ALLOWED_COMMANDS[command]
265
+ if (!allowedCommand) {
266
+ reply.code(403)
267
+ return {
268
+ success: false,
269
+ error: `Command '${command}' is not allowed`,
270
+ allowed_commands: Object.keys(ALLOWED_COMMANDS),
271
+ }
272
+ }
273
+
274
+ console.log(`[Command] Executing: ${command}`)
275
+
276
+ // Log command execution to HQ
277
+ await api.log(`Executing command: ${command}`, 'info', { command })
278
+
279
+ // Deferred commands (e.g. restart-agent) kill the current process,
280
+ // so respond first and execute after a short delay.
281
+ if (allowedCommand.deferred) {
282
+ console.log(`[Command] Scheduling deferred command: ${command}`)
283
+ setTimeout(() => {
284
+ exec(allowedCommand.command, { timeout: 60000 }, (err) => {
285
+ if (err) console.error(`[Command] Deferred command failed: ${command} - ${err.message}`)
286
+ else console.log(`[Command] Deferred command completed: ${command}`)
287
+ })
288
+ }, 1000)
289
+
290
+ return {
291
+ success: true,
292
+ command,
293
+ output: 'Command scheduled for execution',
294
+ deferred: true,
295
+ }
296
+ }
297
+
298
+ try {
299
+ const { stdout, stderr } = await execAsync(allowedCommand.command, {
300
+ timeout: 60000, // 60 second timeout
301
+ })
302
+
303
+ console.log(`[Command] Success: ${command}`)
304
+
305
+ return {
306
+ success: true,
307
+ command,
308
+ output: stdout,
309
+ stderr: stderr || undefined,
310
+ }
311
+ } catch (error) {
312
+ console.error(`[Command] Failed: ${command} - ${error.message}`)
313
+
314
+ // Log error to HQ
315
+ await api.log(`Command failed: ${command} - ${error.message}`, 'error', { command, error: error.message })
316
+
317
+ reply.code(500)
318
+ return {
319
+ success: false,
320
+ command,
321
+ error: error.message,
322
+ stdout: error.stdout,
323
+ stderr: error.stderr,
324
+ }
325
+ }
326
+ })
327
+
328
+ // List deployed skills from local .claude/skills directory
329
+ fastify.get('/api/list-skills', async (request, reply) => {
330
+ // Require authentication
331
+ if (!verifyToken(request)) {
332
+ reply.code(401)
333
+ return { success: false, error: 'Unauthorized' }
334
+ }
335
+
336
+ console.log('[Skills] Listing deployed skills')
337
+
338
+ try {
339
+ const homeDir = os.homedir()
340
+ const skillsDir = path.join(homeDir, '.claude', 'skills')
341
+
342
+ // Check if skills directory exists
343
+ try {
344
+ await fs.access(skillsDir)
345
+ } catch {
346
+ // Directory doesn't exist, return empty list
347
+ console.log('[Skills] Skills directory does not exist, returning empty list')
348
+ return { success: true, skills: [] }
349
+ }
350
+
351
+ // Read directory contents
352
+ const entries = await fs.readdir(skillsDir, { withFileTypes: true })
353
+
354
+ // Filter to only directories that contain SKILL.md
355
+ const skills = []
356
+ for (const entry of entries) {
357
+ if (entry.isDirectory()) {
358
+ const skillMdPath = path.join(skillsDir, entry.name, 'SKILL.md')
359
+ try {
360
+ await fs.access(skillMdPath)
361
+ skills.push(entry.name)
362
+ } catch {
363
+ // SKILL.md doesn't exist, skip this directory
364
+ }
365
+ }
366
+ }
367
+
368
+ console.log(`[Skills] Found ${skills.length} deployed skills: ${skills.join(', ') || '(none)'}`)
369
+
370
+ return { success: true, skills }
371
+ } catch (error) {
372
+ console.error(`[Skills] Failed to list skills: ${error.message}`)
373
+ reply.code(500)
374
+ return { success: false, error: error.message }
375
+ }
376
+ })
377
+
378
+ // Deploy skill to local .claude/skills directory
379
+ fastify.post('/api/deploy-skill', async (request, reply) => {
380
+ // Require authentication
381
+ if (!verifyToken(request)) {
382
+ reply.code(401)
383
+ return { success: false, error: 'Unauthorized' }
384
+ }
385
+
386
+ const { name, content, references = [] } = request.body || {}
387
+
388
+ if (!name || !content) {
389
+ reply.code(400)
390
+ return { success: false, error: 'name and content are required' }
391
+ }
392
+
393
+ // Validate skill name (URL-safe slug)
394
+ if (!/^[a-z0-9-]+$/.test(name)) {
395
+ reply.code(400)
396
+ return { success: false, error: 'Skill name must be URL-safe (lowercase letters, numbers, and hyphens only)' }
397
+ }
398
+
399
+ console.log(`[Deploy] Deploying skill: ${name}`)
400
+
401
+ try {
402
+ // Log deployment to HQ
403
+ await api.log(`Deploying skill: ${name}`, 'info', { skill: name })
404
+
405
+ // Create skill directory in ~/.claude/skills/
406
+ const homeDir = os.homedir()
407
+ const skillDir = path.join(homeDir, '.claude', 'skills', name)
408
+ const referencesDir = path.join(skillDir, 'references')
409
+
410
+ // Create directories
411
+ await fs.mkdir(skillDir, { recursive: true })
412
+ await fs.mkdir(referencesDir, { recursive: true })
413
+
414
+ // Write SKILL.md with frontmatter
415
+ const skillContent = `---
416
+ name: ${name}
417
+ description: Deployed from HQ
418
+ ---
419
+
420
+ ${content}`
421
+ await fs.writeFile(path.join(skillDir, 'SKILL.md'), skillContent, 'utf-8')
422
+
423
+ // Write reference files
424
+ for (const ref of references) {
425
+ if (ref.filename && ref.content) {
426
+ // Sanitize filename to prevent directory traversal
427
+ const safeFilename = path.basename(ref.filename)
428
+ await fs.writeFile(path.join(referencesDir, safeFilename), ref.content, 'utf-8')
429
+ }
430
+ }
431
+
432
+ console.log(`[Deploy] Skill deployed successfully: ${name}`)
433
+
434
+ return {
435
+ success: true,
436
+ message: `Skill "${name}" deployed successfully`,
437
+ path: skillDir,
438
+ references_count: references.length,
439
+ }
440
+ } catch (error) {
441
+ console.error(`[Deploy] Failed to deploy skill: ${error.message}`)
442
+
443
+ // Log error to HQ
444
+ await api.log(`Failed to deploy skill ${name}: ${error.message}`, 'error', { skill: name, error: error.message })
445
+
446
+ reply.code(500)
447
+ return {
448
+ success: false,
449
+ error: error.message,
450
+ }
451
+ }
452
+ })
453
+
454
+ // Graceful shutdown
455
+ async function shutdown(signal) {
456
+ console.log(`[Server] Received ${signal}, shutting down...`)
457
+
458
+ // Send final offline heartbeat if HQ is configured
459
+ if (isHqConfigured()) {
460
+ heartbeat.setStatus('offline')
461
+ try {
462
+ await heartbeat.sendHeartbeat()
463
+ console.log('[Server] Status set to offline')
464
+ } catch (error) {
465
+ console.error(`[Server] Failed to update status: ${error.message}`)
466
+ }
467
+ heartbeat.stop()
468
+ }
469
+
470
+ // Close server
471
+ await fastify.close()
472
+ process.exit(0)
473
+ }
474
+
475
+ process.on('SIGTERM', () => shutdown('SIGTERM'))
476
+ process.on('SIGINT', () => shutdown('SIGINT'))
477
+
478
+ // Sync VNC password from HQ
479
+ async function syncVncPassword() {
480
+ console.log('[VNC] Syncing VNC password from HQ...')
481
+
482
+ try {
483
+ const url = `${config.HQ_URL}/api/minion/vnc-password`
484
+ const response = await fetch(url, {
485
+ method: 'GET',
486
+ headers: {
487
+ 'Authorization': `Bearer ${config.API_TOKEN}`,
488
+ },
489
+ })
490
+
491
+ if (!response.ok) {
492
+ const errorText = await response.text()
493
+ console.error(`[VNC] Failed to get VNC password from HQ: ${response.status} - ${errorText}`)
494
+ return false
495
+ }
496
+
497
+ const data = await response.json()
498
+ const vncPassword = data.vnc_password
499
+
500
+ if (!vncPassword) {
501
+ console.log('[VNC] No VNC password set in HQ, using default')
502
+ return true
503
+ }
504
+
505
+ console.log('[VNC] Got VNC password from HQ, applying...')
506
+
507
+ // Escape single quotes for shell safety
508
+ const escapedPassword = vncPassword.replace(/'/g, "'\\''")
509
+
510
+ // Ensure .vnc directory exists and set password
511
+ const homeDir = os.homedir()
512
+ const vncDir = path.join(homeDir, '.vnc')
513
+ const vncPasswdPath = path.join(vncDir, 'passwd')
514
+ await execAsync(`mkdir -p ${vncDir}`, { timeout: 5000 })
515
+ await execAsync(
516
+ `x11vnc -storepasswd '${escapedPassword}' ${vncPasswdPath}`,
517
+ { timeout: 10000 }
518
+ )
519
+ await execAsync(`chmod 600 ${vncPasswdPath}`, { timeout: 5000 })
520
+
521
+ // Restart x11vnc to apply the new password
522
+ if (PROC_MGR === 'systemd') {
523
+ await execAsync(`${SUDO}systemctl restart x11vnc`, { timeout: 10000 })
524
+ } else if (PROC_MGR === 'supervisord') {
525
+ await execAsync(`${SUDO}supervisorctl restart x11vnc`, { timeout: 10000 })
526
+ }
527
+
528
+ console.log('[VNC] VNC password synced successfully')
529
+ return true
530
+ } catch (error) {
531
+ console.error(`[VNC] Failed to sync VNC password: ${error.message}`)
532
+ return false
533
+ }
534
+ }
535
+
536
+ // Start server
537
+ async function start() {
538
+ try {
539
+ // Listen on all interfaces
540
+ await fastify.listen({ port: config.AGENT_PORT, host: '0.0.0.0' })
541
+
542
+ console.log(`[Server] Minion agent listening on port ${config.AGENT_PORT}`)
543
+
544
+ if (isHqConfigured()) {
545
+ console.log(`[Server] HQ URL: ${config.HQ_URL}`)
546
+
547
+ // Start heartbeat
548
+ heartbeat.start()
549
+
550
+ // Sync VNC password from HQ (non-blocking, run in background)
551
+ syncVncPassword().catch(err => {
552
+ console.error('[VNC] Background sync failed:', err.message)
553
+ })
554
+ } else {
555
+ console.log('[Server] Running in standalone mode (no HQ connection)')
556
+ }
557
+ } catch (err) {
558
+ fastify.log.error(err)
559
+ process.exit(1)
560
+ }
561
+ }
562
+
563
+ start()