@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 +22 -0
- package/api.js +76 -0
- package/config.js +48 -0
- package/heartbeat.js +91 -0
- package/minion-cli.sh +445 -0
- package/package.json +36 -0
- package/server.js +563 -0
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()
|