@geekbeer/minion 2.70.2 → 3.4.7
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/db.js +297 -0
- package/core/lib/thread-watcher.js +3 -6
- package/core/routes/daily-logs.js +17 -11
- package/core/routes/memory.js +16 -10
- package/core/stores/chat-store.js +138 -76
- package/core/stores/daily-log-store.js +84 -105
- package/core/stores/execution-store.js +59 -84
- package/core/stores/memory-store.js +84 -177
- package/core/stores/routine-store.js +55 -72
- package/core/stores/workflow-store.js +52 -69
- package/docs/api-reference.md +6 -2
- package/linux/minion-cli.sh +203 -254
- package/linux/routes/chat.js +54 -7
- package/linux/server.js +3 -1
- package/package.json +3 -2
- package/roles/engineer.md +12 -0
- package/roles/pm.md +16 -0
- package/rules/core.md +66 -0
- package/win/lib/process-manager.js +115 -220
- package/win/minion-cli.ps1 +882 -675
- package/win/routes/chat.js +54 -7
- package/win/server.js +2 -0
- package/win/vendor/README.md +13 -0
- package/win/vendor/nssm.exe +0 -0
package/win/minion-cli.ps1
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
#Requires -Version 5.1
|
|
2
2
|
# Minion Agent CLI for Windows (@geekbeer/minion)
|
|
3
|
+
# Administrator required only for: setup, uninstall
|
|
4
|
+
# All other commands (start/stop/restart/status/etc.) work as regular user after setup.
|
|
5
|
+
#
|
|
3
6
|
# Usage:
|
|
4
|
-
# minion-cli-win setup
|
|
5
|
-
# minion-cli-win
|
|
6
|
-
# minion-cli-win uninstall [--keep-data]
|
|
7
|
+
# minion-cli-win setup # Install software & register services (admin required)
|
|
8
|
+
# minion-cli-win configure --hq-url https://... --minion-id <UUID> --api-token <TOKEN>
|
|
9
|
+
# minion-cli-win uninstall [--keep-data] # Remove agent (admin required)
|
|
7
10
|
# minion-cli-win start | stop | restart | status | health | daemons | diagnose | version | help
|
|
8
11
|
|
|
9
12
|
# Parse arguments manually to avoid issues with npm wrapper passing $args as array
|
|
@@ -18,7 +21,7 @@ $i = 0
|
|
|
18
21
|
while ($i -lt $args.Count) {
|
|
19
22
|
$arg = [string]$args[$i]
|
|
20
23
|
switch -Regex ($arg) {
|
|
21
|
-
'^(setup|reconfigure|uninstall|start|stop|restart|status|health|daemons|diagnose|version|help)$' { $Command = $arg }
|
|
24
|
+
'^(setup|configure|reconfigure|uninstall|start|stop|restart|status|health|daemons|diagnose|version|help)$' { $Command = $arg }
|
|
22
25
|
'^(-v|--version)$' { $Command = 'version' }
|
|
23
26
|
'^--hq-url$' { $i++; if ($i -lt $args.Count) { $HqUrl = [string]$args[$i] } }
|
|
24
27
|
'^--minion-id$' { $i++; if ($i -lt $args.Count) { $MinionId = [string]$args[$i] } }
|
|
@@ -32,16 +35,42 @@ while ($i -lt $args.Count) {
|
|
|
32
35
|
|
|
33
36
|
$ErrorActionPreference = 'Stop'
|
|
34
37
|
|
|
38
|
+
# Load System.Web for password generation (used in setup)
|
|
39
|
+
Add-Type -AssemblyName System.Web -ErrorAction SilentlyContinue
|
|
40
|
+
|
|
41
|
+
# ============================================================
|
|
42
|
+
# Require Administrator for service management commands
|
|
43
|
+
# ============================================================
|
|
44
|
+
$adminRequired = @('setup', 'uninstall')
|
|
45
|
+
if ($Command -in $adminRequired) {
|
|
46
|
+
$currentPrincipal = New-Object Security.Principal.WindowsPrincipal([Security.Principal.WindowsIdentity]::GetCurrent())
|
|
47
|
+
if (-not $currentPrincipal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
|
|
48
|
+
Write-Host "ERROR: '$Command' requires Administrator privileges." -ForegroundColor Red
|
|
49
|
+
Write-Host " Right-click PowerShell and select 'Run as administrator'." -ForegroundColor Yellow
|
|
50
|
+
exit 1
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
35
54
|
# Constants
|
|
36
55
|
$DataDir = Join-Path $env:USERPROFILE '.minion'
|
|
37
56
|
$EnvFile = Join-Path $DataDir '.env'
|
|
38
|
-
$PidFile = Join-Path $DataDir 'minion-agent.pid'
|
|
39
57
|
$LogDir = Join-Path $DataDir 'logs'
|
|
40
|
-
$StartAgentScript = Join-Path $DataDir 'start-agent.ps1'
|
|
41
58
|
$AgentUrl = if ($env:MINION_AGENT_URL) { $env:MINION_AGENT_URL } else { 'http://localhost:8080' }
|
|
42
59
|
|
|
43
|
-
# Resolve
|
|
60
|
+
# Resolve NSSM path: bundled in npm package > data dir > PATH
|
|
44
61
|
$CliDir = Split-Path -Parent $PSScriptRoot
|
|
62
|
+
$NssmPath = $null
|
|
63
|
+
$vendorNssm = Join-Path $CliDir 'win\vendor\nssm.exe'
|
|
64
|
+
if (Test-Path $vendorNssm) { $NssmPath = $vendorNssm }
|
|
65
|
+
elseif (Test-Path (Join-Path $DataDir 'nssm.exe')) { $NssmPath = Join-Path $DataDir 'nssm.exe' }
|
|
66
|
+
elseif (Get-Command nssm -ErrorAction SilentlyContinue) { $NssmPath = (Get-Command nssm).Source }
|
|
67
|
+
else {
|
|
68
|
+
# Fallback: try alternate vendor path (when run from package root)
|
|
69
|
+
$altVendor = Join-Path $PSScriptRoot 'vendor\nssm.exe'
|
|
70
|
+
if (Test-Path $altVendor) { $NssmPath = $altVendor }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
# Resolve CLI version from package.json
|
|
45
74
|
$CliVersion = try {
|
|
46
75
|
(Get-Content (Join-Path $CliDir 'package.json') -Raw | ConvertFrom-Json).version
|
|
47
76
|
}
|
|
@@ -66,6 +95,22 @@ function Write-Warn {
|
|
|
66
95
|
Write-Host " WARNING: $Message" -ForegroundColor Yellow
|
|
67
96
|
}
|
|
68
97
|
|
|
98
|
+
function Invoke-Nssm {
|
|
99
|
+
# Wrapper to call nssm.exe without $ErrorActionPreference = 'Stop' killing the script.
|
|
100
|
+
# NSSM writes informational output (version banner, status messages) to stderr,
|
|
101
|
+
# which PowerShell converts to ErrorRecords. Under $ErrorActionPreference = 'Stop'
|
|
102
|
+
# these become terminating errors even with 2>$null redirection.
|
|
103
|
+
$prevPref = $ErrorActionPreference
|
|
104
|
+
$ErrorActionPreference = 'Continue'
|
|
105
|
+
try {
|
|
106
|
+
$output = & $NssmPath @args 2>&1
|
|
107
|
+
# Filter out ErrorRecord objects (stderr), return stdout as strings
|
|
108
|
+
$output | Where-Object { $_ -isnot [System.Management.Automation.ErrorRecord] } | ForEach-Object { "$_" }
|
|
109
|
+
} finally {
|
|
110
|
+
$ErrorActionPreference = $prevPref
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
69
114
|
function Test-CommandExists {
|
|
70
115
|
param([string]$Name)
|
|
71
116
|
$null -ne (Get-Command $Name -ErrorAction SilentlyContinue)
|
|
@@ -73,24 +118,32 @@ function Test-CommandExists {
|
|
|
73
118
|
|
|
74
119
|
function Get-WebsockifyCommand {
|
|
75
120
|
# Returns @(executable, args-prefix) for launching websockify.
|
|
76
|
-
#
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
$wsExe = Join-Path $scriptsDir 'websockify.exe'
|
|
85
|
-
if (Test-Path $wsExe) { return @($wsExe) }
|
|
121
|
+
# Python writes informational output to stderr, which causes terminating errors
|
|
122
|
+
# under $ErrorActionPreference = 'Stop'. Temporarily switch to 'Continue'.
|
|
123
|
+
$prevPref = $ErrorActionPreference
|
|
124
|
+
$ErrorActionPreference = 'Continue'
|
|
125
|
+
try {
|
|
126
|
+
# 1) websockify.exe on PATH
|
|
127
|
+
if (Get-Command websockify -ErrorAction SilentlyContinue) {
|
|
128
|
+
return @((Get-Command websockify).Source)
|
|
86
129
|
}
|
|
130
|
+
# 2) Look in Python Scripts directories
|
|
131
|
+
if (Test-CommandExists 'python') {
|
|
132
|
+
$scriptsDir = & python -c "import sysconfig; print(sysconfig.get_path('scripts'))" 2>$null
|
|
133
|
+
if ($scriptsDir) {
|
|
134
|
+
$wsExe = Join-Path $scriptsDir 'websockify.exe'
|
|
135
|
+
if (Test-Path $wsExe) { return @($wsExe) }
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
# 3) Fallback: python -m websockify
|
|
139
|
+
if (Test-CommandExists 'python') {
|
|
140
|
+
$check = & python -c "import websockify" 2>&1
|
|
141
|
+
if ($LASTEXITCODE -eq 0) { return @('python', '-m', 'websockify') }
|
|
142
|
+
}
|
|
143
|
+
return $null
|
|
144
|
+
} finally {
|
|
145
|
+
$ErrorActionPreference = $prevPref
|
|
87
146
|
}
|
|
88
|
-
# 3) Fallback: python -m websockify
|
|
89
|
-
if (Test-CommandExists 'python') {
|
|
90
|
-
$check = & python -c "import websockify" 2>&1
|
|
91
|
-
if ($LASTEXITCODE -eq 0) { return @('python', '-m', 'websockify') }
|
|
92
|
-
}
|
|
93
|
-
return $null
|
|
94
147
|
}
|
|
95
148
|
|
|
96
149
|
function Get-LanIPAddress {
|
|
@@ -109,8 +162,74 @@ function Get-LanIPAddress {
|
|
|
109
162
|
}
|
|
110
163
|
|
|
111
164
|
function Get-MinionServerJs {
|
|
112
|
-
$
|
|
113
|
-
|
|
165
|
+
# Use $CliDir (script's package root) instead of npm root -g,
|
|
166
|
+
# which may point to a different user's npm global when run as admin.
|
|
167
|
+
return Join-Path $CliDir 'win\server.js'
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function Resolve-TargetUserProfile {
|
|
171
|
+
# Resolve the target user's profile directory.
|
|
172
|
+
# In setup: interactively ask if the current admin account differs from the intended user.
|
|
173
|
+
# In configure/uninstall: read from saved .target-user-profile file.
|
|
174
|
+
param([switch]$Interactive)
|
|
175
|
+
|
|
176
|
+
if ($Interactive) {
|
|
177
|
+
# Detect all local user profiles (excluding system accounts)
|
|
178
|
+
$profiles = @()
|
|
179
|
+
Get-CimInstance Win32_UserProfile -ErrorAction SilentlyContinue |
|
|
180
|
+
Where-Object { -not $_.Special -and $_.LocalPath -and $_.LocalPath -notmatch '\\(systemprofile|LocalService|NetworkService)$' } |
|
|
181
|
+
ForEach-Object {
|
|
182
|
+
$profilePath = $_.LocalPath
|
|
183
|
+
$sid = $_.SID
|
|
184
|
+
try {
|
|
185
|
+
$objSID = New-Object System.Security.Principal.SecurityIdentifier($sid)
|
|
186
|
+
$objUser = $objSID.Translate([System.Security.Principal.NTAccount])
|
|
187
|
+
$userName = $objUser.Value -replace '^.*\\'
|
|
188
|
+
}
|
|
189
|
+
catch { $userName = Split-Path $profilePath -Leaf }
|
|
190
|
+
$profiles += @{ Name = $userName; Path = $profilePath }
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
# If only one profile, use it directly
|
|
194
|
+
if ($profiles.Count -le 1) {
|
|
195
|
+
return $env:USERPROFILE
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
# Check if current user matches a non-admin typical user
|
|
199
|
+
$currentUser = $env:USERNAME
|
|
200
|
+
Write-Host " Detected user profiles on this machine:" -ForegroundColor Yellow
|
|
201
|
+
Write-Host ""
|
|
202
|
+
for ($idx = 0; $idx -lt $profiles.Count; $idx++) {
|
|
203
|
+
$marker = if ($profiles[$idx].Name -eq $currentUser) { " (current)" } else { "" }
|
|
204
|
+
Write-Host " [$($idx + 1)] $($profiles[$idx].Name) - $($profiles[$idx].Path)$marker"
|
|
205
|
+
}
|
|
206
|
+
Write-Host ""
|
|
207
|
+
Write-Host " Which user should own the minion data (.minion, .claude directories)?" -ForegroundColor Yellow
|
|
208
|
+
Write-Host " If you are running this from a different admin account, select the" -ForegroundColor Yellow
|
|
209
|
+
Write-Host " regular user account that normally uses this machine." -ForegroundColor Yellow
|
|
210
|
+
Write-Host ""
|
|
211
|
+
|
|
212
|
+
while ($true) {
|
|
213
|
+
$selection = Read-Host " Enter number [1-$($profiles.Count)] (default: 1)"
|
|
214
|
+
if ([string]::IsNullOrWhiteSpace($selection)) { $selection = '1' }
|
|
215
|
+
$selIdx = 0
|
|
216
|
+
if ([int]::TryParse($selection, [ref]$selIdx) -and $selIdx -ge 1 -and $selIdx -le $profiles.Count) {
|
|
217
|
+
$chosen = $profiles[$selIdx - 1]
|
|
218
|
+
Write-Host ""
|
|
219
|
+
Write-Detail "Target user: $($chosen.Name) ($($chosen.Path))"
|
|
220
|
+
return $chosen.Path
|
|
221
|
+
}
|
|
222
|
+
Write-Host " Invalid selection. Please enter a number between 1 and $($profiles.Count)." -ForegroundColor Red
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
# Non-interactive: read from saved file
|
|
227
|
+
$savedFile = Join-Path $DataDir '.target-user-profile'
|
|
228
|
+
if (Test-Path $savedFile) {
|
|
229
|
+
$saved = ([System.IO.File]::ReadAllText($savedFile)).Trim()
|
|
230
|
+
if ($saved -and (Test-Path $saved)) { return $saved }
|
|
231
|
+
}
|
|
232
|
+
return $env:USERPROFILE
|
|
114
233
|
}
|
|
115
234
|
|
|
116
235
|
function Read-EnvFile {
|
|
@@ -158,76 +277,114 @@ function Invoke-HealthCheck {
|
|
|
158
277
|
return $false
|
|
159
278
|
}
|
|
160
279
|
|
|
161
|
-
function
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
$proc = Get-Process -Id $existingPid -ErrorAction SilentlyContinue
|
|
166
|
-
if ($proc) {
|
|
167
|
-
Write-Host "minion-agent is already running (PID: $existingPid)"
|
|
168
|
-
return
|
|
169
|
-
}
|
|
170
|
-
Remove-Item $PidFile -Force
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
if (-not (Test-Path $StartAgentScript)) {
|
|
174
|
-
Write-Error "start-agent.ps1 not found. Run 'minion-cli-win setup' first."
|
|
280
|
+
function Assert-NssmAvailable {
|
|
281
|
+
if (-not $NssmPath -or -not (Test-Path $NssmPath)) {
|
|
282
|
+
Write-Error "NSSM not found. Expected at: $vendorNssm"
|
|
283
|
+
Write-Host " Reinstall the package: npm install -g @geekbeer/minion" -ForegroundColor Yellow
|
|
175
284
|
exit 1
|
|
176
285
|
}
|
|
286
|
+
}
|
|
177
287
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
Start-Sleep -Seconds 2
|
|
288
|
+
# ============================================================
|
|
289
|
+
# Service SDDL helpers (grant control to non-admin user)
|
|
290
|
+
# ============================================================
|
|
182
291
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
292
|
+
function Get-SetupUserSid {
|
|
293
|
+
# Returns SID of the user who invoked setup (for SDDL grant).
|
|
294
|
+
# In admin shell, $env:USERNAME is the admin account.
|
|
295
|
+
# We store the setup user's SID in .minion/.setup-user-sid during setup.
|
|
296
|
+
$sidFile = Join-Path $DataDir '.setup-user-sid'
|
|
297
|
+
if (Test-Path $sidFile) {
|
|
298
|
+
return ([System.IO.File]::ReadAllText($sidFile)).Trim()
|
|
188
299
|
}
|
|
300
|
+
# Fallback: current user
|
|
301
|
+
return ([System.Security.Principal.WindowsIdentity]::GetCurrent().User).Value
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function Grant-ServiceControlToUser {
|
|
305
|
+
param([string]$ServiceName, [string]$UserSid)
|
|
306
|
+
# Grant Start/Stop/Pause/QueryStatus/QueryConfig/Interrogate/UserDefined rights
|
|
307
|
+
# RPWPDTLOCRRC = RP (read property) WP (write property) DT (delete tree) LO (list object)
|
|
308
|
+
# CR (control rights) RC (read control)
|
|
309
|
+
# Simpler: use standard rights for service control
|
|
310
|
+
# GA=Generic All is too broad. Use specific rights:
|
|
311
|
+
# CC=ServiceQueryConfig, LC=ServiceQueryStatus, SW=ServiceEnumerateDependents,
|
|
312
|
+
# RP=ServiceStart, WP=ServiceStop, DT=ServicePauseContinue, LO=ServiceInterrogate,
|
|
313
|
+
# CR=ServiceUserDefinedControl, RC=ReadControl
|
|
314
|
+
$ace = "(A;;CCLCSWRPWPDTLOCRRC;;;$UserSid)"
|
|
315
|
+
try {
|
|
316
|
+
$sdShowOutput = sc.exe sdshow $ServiceName 2>&1
|
|
317
|
+
$currentSddl = ($sdShowOutput | Where-Object { $_ -match '^D:' }) -as [string]
|
|
318
|
+
if (-not $currentSddl) { return }
|
|
319
|
+
# Insert ACE before S: (SACL) section if present, otherwise append
|
|
320
|
+
if ($currentSddl -match '(S:.*)$') {
|
|
321
|
+
$newSddl = $currentSddl -replace '(S:.*)', "$ace`$1"
|
|
322
|
+
} else {
|
|
323
|
+
$newSddl = $currentSddl + $ace
|
|
324
|
+
}
|
|
325
|
+
sc.exe sdset $ServiceName $newSddl 2>&1 | Out-Null
|
|
326
|
+
} catch { }
|
|
189
327
|
}
|
|
190
328
|
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
329
|
+
# ============================================================
|
|
330
|
+
# Service management (sc.exe-based, no admin required after setup)
|
|
331
|
+
# ============================================================
|
|
332
|
+
|
|
333
|
+
function Get-ServiceState {
|
|
334
|
+
param([string]$ServiceName)
|
|
335
|
+
# Query service state via sc.exe (works without admin if SDDL grants query rights)
|
|
336
|
+
$output = sc.exe query $ServiceName 2>&1
|
|
337
|
+
if ($output -match 'RUNNING') { return 'RUNNING' }
|
|
338
|
+
if ($output -match 'STOPPED') { return 'STOPPED' }
|
|
339
|
+
if ($output -match 'PAUSED') { return 'PAUSED' }
|
|
340
|
+
if ($output -match 'PENDING') { return 'PENDING' }
|
|
341
|
+
if ($output -match '1060') { return $null } # service not installed
|
|
342
|
+
return 'UNKNOWN'
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function Start-MinionService {
|
|
346
|
+
$state = Get-ServiceState 'minion-agent'
|
|
347
|
+
if (-not $state) {
|
|
348
|
+
Write-Host "minion-agent: not installed (run 'setup' as administrator first)" -ForegroundColor Red
|
|
194
349
|
return
|
|
195
350
|
}
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
$envVars = Read-EnvFile $EnvFile
|
|
204
|
-
if ($envVars['API_TOKEN']) { $token = $envVars['API_TOKEN'] }
|
|
205
|
-
}
|
|
206
|
-
try {
|
|
207
|
-
$headers = @{}
|
|
208
|
-
if ($token) { $headers['Authorization'] = "Bearer $token" }
|
|
209
|
-
Invoke-RestMethod -Uri "$AgentUrl/api/shutdown" -Method POST -ContentType 'application/json' -Headers $headers -TimeoutSec 5 | Out-Null
|
|
210
|
-
Write-Host "Graceful shutdown requested, waiting..."
|
|
211
|
-
Start-Sleep -Seconds 4
|
|
212
|
-
} catch {
|
|
213
|
-
Write-Host "Graceful shutdown failed, force stopping..."
|
|
214
|
-
}
|
|
351
|
+
if ($state -eq 'RUNNING') {
|
|
352
|
+
Write-Host "minion-agent service is already running"
|
|
353
|
+
return
|
|
354
|
+
}
|
|
355
|
+
sc.exe start minion-agent 2>&1 | Out-Null
|
|
356
|
+
Write-Host "minion-agent service started"
|
|
357
|
+
}
|
|
215
358
|
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
}
|
|
222
|
-
|
|
359
|
+
function Stop-MinionService {
|
|
360
|
+
$state = Get-ServiceState 'minion-agent'
|
|
361
|
+
if (-not $state) {
|
|
362
|
+
Write-Host "minion-agent: not installed" -ForegroundColor Red
|
|
363
|
+
return
|
|
364
|
+
}
|
|
365
|
+
# Graceful shutdown via HTTP API first (sends offline heartbeat to HQ)
|
|
366
|
+
$token = ''
|
|
367
|
+
if (Test-Path $EnvFile) {
|
|
368
|
+
$envVars = Read-EnvFile $EnvFile
|
|
369
|
+
if ($envVars['API_TOKEN']) { $token = $envVars['API_TOKEN'] }
|
|
223
370
|
}
|
|
224
|
-
|
|
371
|
+
try {
|
|
372
|
+
$headers = @{}
|
|
373
|
+
if ($token) { $headers['Authorization'] = "Bearer $token" }
|
|
374
|
+
Invoke-RestMethod -Uri "$AgentUrl/api/shutdown" -Method POST -ContentType 'application/json' -Headers $headers -TimeoutSec 5 | Out-Null
|
|
375
|
+
Write-Host "Graceful shutdown requested, waiting..."
|
|
376
|
+
Start-Sleep -Seconds 4
|
|
377
|
+
} catch {
|
|
378
|
+
Write-Host "Graceful shutdown skipped (agent may not be running)"
|
|
379
|
+
}
|
|
380
|
+
sc.exe stop minion-agent 2>&1 | Out-Null
|
|
381
|
+
Write-Host "minion-agent service stopped"
|
|
225
382
|
}
|
|
226
383
|
|
|
227
|
-
function Restart-
|
|
228
|
-
Stop-
|
|
384
|
+
function Restart-MinionService {
|
|
385
|
+
Stop-MinionService
|
|
229
386
|
Start-Sleep -Seconds 2
|
|
230
|
-
Start-
|
|
387
|
+
Start-MinionService
|
|
231
388
|
}
|
|
232
389
|
|
|
233
390
|
# ============================================================
|
|
@@ -236,7 +393,6 @@ function Restart-MinionProcess {
|
|
|
236
393
|
|
|
237
394
|
function Invoke-Setup {
|
|
238
395
|
$totalSteps = 10
|
|
239
|
-
if ($SetupTunnel) { $totalSteps = 11 }
|
|
240
396
|
|
|
241
397
|
# Minionization warning
|
|
242
398
|
Write-Host ""
|
|
@@ -245,27 +401,14 @@ function Invoke-Setup {
|
|
|
245
401
|
Write-Host "=========================================" -ForegroundColor Red
|
|
246
402
|
Write-Host ""
|
|
247
403
|
|
|
248
|
-
# Check if current user is in Administrators group
|
|
249
|
-
$adminGroupMembers = net localgroup Administrators 2>$null
|
|
250
|
-
if ($adminGroupMembers -match [regex]::Escape($env:USERNAME)) {
|
|
251
|
-
Write-Host " SECURITY WARNING: User '$env:USERNAME' has Administrator privileges." -ForegroundColor Yellow
|
|
252
|
-
Write-Host " We recommend setting up the minion under a Standard user account." -ForegroundColor Yellow
|
|
253
|
-
Write-Host " If this machine is compromised, an Administrator account gives" -ForegroundColor Yellow
|
|
254
|
-
Write-Host " the attacker full control over the system." -ForegroundColor Yellow
|
|
255
|
-
Write-Host ""
|
|
256
|
-
Write-Host " To create a Standard user:" -ForegroundColor Gray
|
|
257
|
-
Write-Host " net user minion <password> /add" -ForegroundColor Gray
|
|
258
|
-
Write-Host " Then log in as that user and run setup again." -ForegroundColor Gray
|
|
259
|
-
Write-Host ""
|
|
260
|
-
}
|
|
261
|
-
|
|
262
404
|
Write-Host " This setup will:" -ForegroundColor Yellow
|
|
263
|
-
Write-Host " - Install and configure software (Node.js, Claude Code)"
|
|
264
|
-
Write-Host " -
|
|
265
|
-
Write-Host " -
|
|
405
|
+
Write-Host " - Install and configure software (Node.js, Claude Code, VNC)"
|
|
406
|
+
Write-Host " - Create dedicated 'minion' service account"
|
|
407
|
+
Write-Host " - Register Windows Services via NSSM"
|
|
408
|
+
Write-Host " - Configure firewall rules"
|
|
266
409
|
Write-Host ""
|
|
410
|
+
Write-Host " After setup, run 'minion-cli-win configure' to connect to HQ." -ForegroundColor Yellow
|
|
267
411
|
Write-Host " This machine should be DEDICATED to the minion agent." -ForegroundColor Yellow
|
|
268
|
-
Write-Host " Do not use it as your daily workstation after setup." -ForegroundColor Yellow
|
|
269
412
|
Write-Host ""
|
|
270
413
|
$confirm = Read-Host " Type 'yes' to continue"
|
|
271
414
|
if ($confirm -ne 'yes') {
|
|
@@ -274,29 +417,54 @@ function Invoke-Setup {
|
|
|
274
417
|
}
|
|
275
418
|
Write-Host ""
|
|
276
419
|
|
|
277
|
-
#
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
420
|
+
# Ask which user profile to target (handles admin account != regular user case)
|
|
421
|
+
Write-Host "=========================================" -ForegroundColor Cyan
|
|
422
|
+
Write-Host " Target User Selection" -ForegroundColor Cyan
|
|
423
|
+
Write-Host "=========================================" -ForegroundColor Cyan
|
|
424
|
+
Write-Host ""
|
|
425
|
+
$TargetUserProfile = Resolve-TargetUserProfile -Interactive
|
|
426
|
+
# Recalculate paths based on target user
|
|
427
|
+
$DataDir = Join-Path $TargetUserProfile '.minion'
|
|
428
|
+
$EnvFile = Join-Path $DataDir '.env'
|
|
429
|
+
$LogDir = Join-Path $DataDir 'logs'
|
|
430
|
+
Write-Host ""
|
|
431
|
+
|
|
432
|
+
# Save setup user's SID for SDDL grants (so non-admin can control services later)
|
|
433
|
+
$setupUserSid = ([System.Security.Principal.WindowsIdentity]::GetCurrent().User).Value
|
|
434
|
+
New-Item -Path $DataDir -ItemType Directory -Force | Out-Null
|
|
435
|
+
[System.IO.File]::WriteAllText((Join-Path $DataDir '.setup-user-sid'), $setupUserSid)
|
|
436
|
+
# Save target user profile so configure/uninstall can find it
|
|
437
|
+
[System.IO.File]::WriteAllText((Join-Path $DataDir '.target-user-profile'), $TargetUserProfile)
|
|
438
|
+
|
|
439
|
+
# Migrate from old user-process mode if detected
|
|
440
|
+
$oldStartScript = Join-Path $DataDir 'start-agent.ps1'
|
|
441
|
+
$oldPidFile = Join-Path $DataDir 'minion-agent.pid'
|
|
442
|
+
if (Test-Path $oldStartScript) {
|
|
443
|
+
Write-Host "Migrating from user-process mode..." -ForegroundColor Yellow
|
|
444
|
+
if (Test-Path $oldPidFile) {
|
|
445
|
+
$oldPid = (Get-Content $oldPidFile -Raw).Trim()
|
|
446
|
+
$proc = Get-Process -Id $oldPid -ErrorAction SilentlyContinue
|
|
447
|
+
if ($proc) {
|
|
448
|
+
Get-CimInstance Win32_Process -Filter "ParentProcessId = $oldPid" -ErrorAction SilentlyContinue |
|
|
449
|
+
ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }
|
|
450
|
+
Stop-Process -Id $oldPid -Force -ErrorAction SilentlyContinue
|
|
451
|
+
}
|
|
452
|
+
Remove-Item $oldPidFile -Force -ErrorAction SilentlyContinue
|
|
453
|
+
}
|
|
454
|
+
Remove-Item $oldStartScript -Force
|
|
455
|
+
$oldShortcut = Join-Path ([Environment]::GetFolderPath('Startup')) 'MinionAgent.lnk'
|
|
456
|
+
Remove-Item $oldShortcut -Force -ErrorAction SilentlyContinue
|
|
457
|
+
Write-Detail "Migrated from user-process mode (old artifacts removed)"
|
|
458
|
+
Write-Host ""
|
|
285
459
|
}
|
|
286
460
|
|
|
287
461
|
Write-Host "=========================================" -ForegroundColor Cyan
|
|
288
462
|
Write-Host " @geekbeer/minion Windows Setup" -ForegroundColor Cyan
|
|
289
463
|
Write-Host "=========================================" -ForegroundColor Cyan
|
|
290
|
-
Write-Host "Platform: Windows (
|
|
291
|
-
Write-Host "User: $env:USERNAME ($
|
|
464
|
+
Write-Host "Platform: Windows (Administrator mode)"
|
|
465
|
+
Write-Host "User: $env:USERNAME (target: $TargetUserProfile)"
|
|
292
466
|
Write-Host "Data: $DataDir"
|
|
293
|
-
|
|
294
|
-
Write-Host "Mode: Connected to HQ ($HqUrl)"
|
|
295
|
-
}
|
|
296
|
-
else {
|
|
297
|
-
Write-Host "Mode: Standalone (no HQ connection)"
|
|
298
|
-
}
|
|
299
|
-
if ($SetupTunnel) { Write-Host "Tunnel: Enabled" }
|
|
467
|
+
Write-Host "Service Manager: NSSM"
|
|
300
468
|
Write-Host ""
|
|
301
469
|
|
|
302
470
|
# Step 1: Check/Install Node.js
|
|
@@ -403,25 +571,99 @@ function Invoke-Setup {
|
|
|
403
571
|
Write-Host " Please run 'claude' in a terminal to complete the authentication process." -ForegroundColor Yellow
|
|
404
572
|
Write-Host ""
|
|
405
573
|
|
|
406
|
-
# Step 4: Create
|
|
407
|
-
Write-Step 4 $totalSteps "Creating
|
|
574
|
+
# Step 4: Create dedicated 'minion' service account
|
|
575
|
+
Write-Step 4 $totalSteps "Creating dedicated service account..."
|
|
576
|
+
$MinionSvcUser = 'minion'
|
|
577
|
+
$MinionSvcUserFull = ".\$MinionSvcUser"
|
|
578
|
+
$minionUserExists = [bool](Get-LocalUser -Name $MinionSvcUser -ErrorAction SilentlyContinue)
|
|
579
|
+
if ($minionUserExists) {
|
|
580
|
+
Write-Detail "Service account '$MinionSvcUser' already exists"
|
|
581
|
+
} else {
|
|
582
|
+
# Generate a random password (service account — not used interactively)
|
|
583
|
+
$svcPassword = [System.Web.Security.Membership]::GeneratePassword(24, 4)
|
|
584
|
+
$securePassword = ConvertTo-SecureString $svcPassword -AsPlainText -Force
|
|
585
|
+
New-LocalUser -Name $MinionSvcUser -Password $securePassword -Description 'Minion Agent Service Account' -PasswordNeverExpires -UserMayNotChangePassword -AccountNeverExpires | Out-Null
|
|
586
|
+
# Deny interactive/remote logon (service-only account)
|
|
587
|
+
& net localgroup "Users" $MinionSvcUser /delete 2>$null
|
|
588
|
+
Write-Detail "Service account '$MinionSvcUser' created (non-interactive)"
|
|
589
|
+
}
|
|
590
|
+
# Store password for NSSM ObjectName configuration
|
|
591
|
+
if (-not $minionUserExists) {
|
|
592
|
+
# Save password to a protected file for NSSM service registration
|
|
593
|
+
$svcPasswordFile = Join-Path $DataDir '.svc-password'
|
|
594
|
+
New-Item -Path (Split-Path $svcPasswordFile) -ItemType Directory -Force | Out-Null
|
|
595
|
+
[System.IO.File]::WriteAllText($svcPasswordFile, $svcPassword)
|
|
596
|
+
# Restrict file access to current user only
|
|
597
|
+
$acl = Get-Acl $svcPasswordFile
|
|
598
|
+
$acl.SetAccessRuleProtection($true, $false)
|
|
599
|
+
$adminRule = New-Object System.Security.AccessControl.FileSystemAccessRule(
|
|
600
|
+
[System.Security.Principal.WindowsIdentity]::GetCurrent().Name, 'FullControl', 'Allow')
|
|
601
|
+
$acl.AddAccessRule($adminRule)
|
|
602
|
+
Set-Acl $svcPasswordFile $acl
|
|
603
|
+
Write-Detail "Service account credentials stored"
|
|
604
|
+
} else {
|
|
605
|
+
$svcPasswordFile = Join-Path $DataDir '.svc-password'
|
|
606
|
+
if (Test-Path $svcPasswordFile) {
|
|
607
|
+
$svcPassword = [System.IO.File]::ReadAllText($svcPasswordFile).Trim()
|
|
608
|
+
} else {
|
|
609
|
+
# Re-generate password for existing account (reset)
|
|
610
|
+
$svcPassword = [System.Web.Security.Membership]::GeneratePassword(24, 4)
|
|
611
|
+
$securePassword = ConvertTo-SecureString $svcPassword -AsPlainText -Force
|
|
612
|
+
Set-LocalUser -Name $MinionSvcUser -Password $securePassword
|
|
613
|
+
New-Item -Path (Split-Path $svcPasswordFile) -ItemType Directory -Force | Out-Null
|
|
614
|
+
[System.IO.File]::WriteAllText($svcPasswordFile, $svcPassword)
|
|
615
|
+
$acl = Get-Acl $svcPasswordFile
|
|
616
|
+
$acl.SetAccessRuleProtection($true, $false)
|
|
617
|
+
$adminRule = New-Object System.Security.AccessControl.FileSystemAccessRule(
|
|
618
|
+
[System.Security.Principal.WindowsIdentity]::GetCurrent().Name, 'FullControl', 'Allow')
|
|
619
|
+
$acl.AddAccessRule($adminRule)
|
|
620
|
+
Set-Acl $svcPasswordFile $acl
|
|
621
|
+
Write-Detail "Service account password reset and stored"
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
# Grant 'Log on as a service' right to the minion user
|
|
625
|
+
$tempCfg = Join-Path $env:TEMP 'minion-secedit.cfg'
|
|
626
|
+
$tempDb = Join-Path $env:TEMP 'minion-secedit.sdb'
|
|
627
|
+
& secedit /export /cfg $tempCfg /areas USER_RIGHTS 2>$null
|
|
628
|
+
$cfgContent = Get-Content $tempCfg -Raw
|
|
629
|
+
if ($cfgContent -match 'SeServiceLogonRight\s*=\s*(.*)') {
|
|
630
|
+
$existing = $Matches[1]
|
|
631
|
+
if ($existing -notmatch $MinionSvcUser) {
|
|
632
|
+
$cfgContent = $cfgContent -replace "(SeServiceLogonRight\s*=\s*)(.*)", "`$1`$2,$MinionSvcUser"
|
|
633
|
+
[System.IO.File]::WriteAllText($tempCfg, $cfgContent)
|
|
634
|
+
& secedit /configure /db $tempDb /cfg $tempCfg /areas USER_RIGHTS 2>$null
|
|
635
|
+
Write-Detail "Granted 'Log on as a service' right to '$MinionSvcUser'"
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
Remove-Item $tempCfg, $tempDb -Force -ErrorAction SilentlyContinue
|
|
639
|
+
|
|
640
|
+
# Step 5: Create config directory and default .env
|
|
641
|
+
Write-Step 5 $totalSteps "Creating config directory..."
|
|
408
642
|
New-Item -Path $DataDir -ItemType Directory -Force | Out-Null
|
|
409
643
|
New-Item -Path $LogDir -ItemType Directory -Force | Out-Null
|
|
410
|
-
$
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
644
|
+
if (-not (Test-Path $EnvFile)) {
|
|
645
|
+
$envValues = @{
|
|
646
|
+
'AGENT_PORT' = '8080'
|
|
647
|
+
'MINION_USER' = (Split-Path $TargetUserProfile -Leaf)
|
|
648
|
+
'REFLECTION_TIME' = '03:00'
|
|
649
|
+
}
|
|
650
|
+
Write-EnvFile $EnvFile $envValues
|
|
651
|
+
Write-Detail "$EnvFile created (run 'configure' to set HQ credentials)"
|
|
652
|
+
} else {
|
|
653
|
+
Write-Detail "$EnvFile already exists, preserving"
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
# Grant minion service account read/write access to data directory
|
|
657
|
+
$minionAcl = Get-Acl $DataDir
|
|
658
|
+
$minionRule = New-Object System.Security.AccessControl.FileSystemAccessRule(
|
|
659
|
+
$MinionSvcUser, 'Modify', 'ContainerInherit,ObjectInherit', 'None', 'Allow')
|
|
660
|
+
$minionAcl.AddAccessRule($minionRule)
|
|
661
|
+
Set-Acl $DataDir $minionAcl
|
|
662
|
+
Write-Detail "Granted '$MinionSvcUser' access to $DataDir"
|
|
420
663
|
|
|
421
|
-
# Step
|
|
422
|
-
Write-Step
|
|
423
|
-
$
|
|
424
|
-
$minionPkgDir = Join-Path $npmRoot '@geekbeer\minion'
|
|
664
|
+
# Step 6: Install node-pty (required for Windows terminal management)
|
|
665
|
+
Write-Step 6 $totalSteps "Installing terminal support (node-pty)..."
|
|
666
|
+
$minionPkgDir = $CliDir
|
|
425
667
|
if (Test-Path $minionPkgDir) {
|
|
426
668
|
Push-Location $minionPkgDir
|
|
427
669
|
$ptyInstalled = $false
|
|
@@ -457,227 +699,96 @@ function Invoke-Setup {
|
|
|
457
699
|
Write-Host " Please run: npm install -g @geekbeer/minion"
|
|
458
700
|
}
|
|
459
701
|
|
|
460
|
-
# Step
|
|
461
|
-
Write-Step
|
|
702
|
+
# Step 7: Verify NSSM
|
|
703
|
+
Write-Step 7 $totalSteps "Verifying NSSM..."
|
|
704
|
+
Assert-NssmAvailable
|
|
705
|
+
$nssmVersion = Invoke-Nssm version
|
|
706
|
+
Write-Detail "NSSM available: $NssmPath ($nssmVersion)"
|
|
707
|
+
|
|
708
|
+
# Step 8: Register Windows Services via NSSM
|
|
709
|
+
Write-Step 8 $totalSteps "Registering Windows Services..."
|
|
710
|
+
|
|
462
711
|
$serverJs = Join-Path $minionPkgDir 'win\server.js'
|
|
463
|
-
if (-not (Test-Path $serverJs)) {
|
|
464
|
-
$serverJs = Join-Path $minionPkgDir 'win' 'server.js'
|
|
465
|
-
}
|
|
466
712
|
if (-not (Test-Path $serverJs)) {
|
|
467
713
|
Write-Error "server.js not found at $serverJs"
|
|
468
714
|
Write-Host " Please run: npm install -g @geekbeer/minion"
|
|
469
715
|
exit 1
|
|
470
716
|
}
|
|
717
|
+
$nodePath = (Get-Command node).Source
|
|
471
718
|
|
|
472
|
-
#
|
|
473
|
-
$
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
Write-
|
|
506
|
-
Write-StartupLog "DataDir: `$DataDir"
|
|
507
|
-
Write-StartupLog "ServerJs: `$ServerJs"
|
|
508
|
-
|
|
509
|
-
# Write PID of this watchdog process
|
|
510
|
-
Set-Content -Path `$PidFile -Value `$PID
|
|
511
|
-
|
|
512
|
-
# Helper: find websockify executable
|
|
513
|
-
function Get-WebsockifyCommand {
|
|
514
|
-
if (Get-Command websockify -ErrorAction SilentlyContinue) {
|
|
515
|
-
`$found = (Get-Command websockify).Source
|
|
516
|
-
Write-StartupLog "websockify found on PATH: `$found"
|
|
517
|
-
return @(`$found)
|
|
518
|
-
}
|
|
519
|
-
Write-StartupLog "websockify not on PATH, checking Python..."
|
|
520
|
-
if (Get-Command python -ErrorAction SilentlyContinue) {
|
|
521
|
-
`$pyPath = (Get-Command python).Source
|
|
522
|
-
Write-StartupLog "Python found: `$pyPath"
|
|
523
|
-
`$scriptsDir = & python -c "import sysconfig; print(sysconfig.get_path('scripts'))" 2>`$null
|
|
524
|
-
Write-StartupLog "Python Scripts dir: `$scriptsDir"
|
|
525
|
-
if (`$scriptsDir) {
|
|
526
|
-
`$wsExe = Join-Path `$scriptsDir 'websockify.exe'
|
|
527
|
-
Write-StartupLog "Checking `$wsExe : exists=$(Test-Path `$wsExe)"
|
|
528
|
-
if (Test-Path `$wsExe) { return @(`$wsExe) }
|
|
529
|
-
}
|
|
530
|
-
`$check = & python -c "import websockify" 2>&1
|
|
531
|
-
Write-StartupLog "python -c 'import websockify' exit code: `$LASTEXITCODE"
|
|
532
|
-
if (`$LASTEXITCODE -eq 0) { return @('python', '-m', 'websockify') }
|
|
533
|
-
} else {
|
|
534
|
-
Write-StartupLog "Python not found"
|
|
535
|
-
}
|
|
536
|
-
Write-StartupLog "websockify not found by any method"
|
|
537
|
-
return `$null
|
|
538
|
-
}
|
|
539
|
-
|
|
540
|
-
# Start TightVNC + websockify if available
|
|
541
|
-
`$vncExe = `$null
|
|
542
|
-
if (Test-Path 'C:\Program Files\TightVNC\tvnserver.exe') {
|
|
543
|
-
`$vncExe = 'C:\Program Files\TightVNC\tvnserver.exe'
|
|
544
|
-
} elseif (Test-Path (Join-Path `$DataDir 'tightvnc\PFiles\TightVNC\tvnserver.exe')) {
|
|
545
|
-
`$vncExe = Join-Path `$DataDir 'tightvnc\PFiles\TightVNC\tvnserver.exe'
|
|
546
|
-
}
|
|
547
|
-
Write-StartupLog "VNC exe: `$vncExe"
|
|
548
|
-
if (`$vncExe) {
|
|
549
|
-
# Start VNC server in application mode (no admin, no service registration)
|
|
550
|
-
`$vncProc = Get-Process -Name tvnserver -ErrorAction SilentlyContinue
|
|
551
|
-
if (-not `$vncProc) {
|
|
552
|
-
Write-StartupLog "Starting TightVNC server..."
|
|
553
|
-
Start-Process -FilePath `$vncExe -ArgumentList '-run' -WindowStyle Hidden
|
|
554
|
-
# Wait for VNC server to bind port 5900 before starting websockify
|
|
555
|
-
Start-Sleep -Seconds 3
|
|
556
|
-
} else {
|
|
557
|
-
Write-StartupLog "TightVNC already running (PID: `$(`$vncProc.Id))"
|
|
558
|
-
}
|
|
559
|
-
# Reload registry config (ensures no-auth settings are applied)
|
|
560
|
-
& `$vncExe -controlapp -reload 2>`$null
|
|
561
|
-
[array]`$wsCmd = Get-WebsockifyCommand
|
|
562
|
-
Write-StartupLog "websockify command result (count=`$(`$wsCmd.Count)): `$(`$wsCmd -join ' ')"
|
|
563
|
-
if (`$wsCmd) {
|
|
564
|
-
`$wsProc = Get-Process -Name websockify -ErrorAction SilentlyContinue
|
|
565
|
-
if (-not `$wsProc) {
|
|
566
|
-
Write-StartupLog "Starting websockify (count=`$(`$wsCmd.Count))..."
|
|
567
|
-
if (`$wsCmd.Count -eq 1) {
|
|
568
|
-
Start-Process -FilePath `$wsCmd[0] -ArgumentList '6080', 'localhost:5900' -WindowStyle Hidden
|
|
569
|
-
Write-StartupLog "websockify started: `$(`$wsCmd[0]) 6080 localhost:5900"
|
|
570
|
-
} else {
|
|
571
|
-
# python -m websockify 6080 localhost:5900
|
|
572
|
-
`$wsArgs = (`$wsCmd[1..(`$wsCmd.Count-1)] + @('6080', 'localhost:5900')) -join ' '
|
|
573
|
-
Start-Process -FilePath `$wsCmd[0] -ArgumentList `$wsArgs -WindowStyle Hidden
|
|
574
|
-
Write-StartupLog "websockify started: `$(`$wsCmd[0]) `$wsArgs"
|
|
575
|
-
}
|
|
576
|
-
} else {
|
|
577
|
-
Write-StartupLog "websockify already running (PID: `$(`$wsProc.Id))"
|
|
578
|
-
}
|
|
579
|
-
} else {
|
|
580
|
-
Write-StartupLog "ERROR: websockify command not found, skipping"
|
|
581
|
-
}
|
|
582
|
-
} else {
|
|
583
|
-
Write-StartupLog "VNC server not found, skipping VNC+websockify setup"
|
|
584
|
-
}
|
|
585
|
-
|
|
586
|
-
# Start cloudflared tunnel if configured
|
|
587
|
-
`$cfConfig = Join-Path `$env:USERPROFILE '.cloudflared\config.yml'
|
|
588
|
-
if (Test-Path `$cfConfig) {
|
|
589
|
-
`$cfExe = `$null
|
|
590
|
-
if (Get-Command cloudflared -ErrorAction SilentlyContinue) {
|
|
591
|
-
`$cfExe = (Get-Command cloudflared).Source
|
|
592
|
-
} elseif (Test-Path (Join-Path `$DataDir 'cloudflared.exe')) {
|
|
593
|
-
`$cfExe = Join-Path `$DataDir 'cloudflared.exe'
|
|
594
|
-
}
|
|
595
|
-
if (`$cfExe) {
|
|
596
|
-
Start-Process -FilePath `$cfExe -ArgumentList 'tunnel', 'run' -WindowStyle Hidden
|
|
597
|
-
Write-StartupLog "cloudflared started: `$cfExe"
|
|
598
|
-
}
|
|
599
|
-
}
|
|
600
|
-
|
|
601
|
-
Write-StartupLog "=== Startup sequence complete, entering watchdog loop ==="
|
|
602
|
-
|
|
603
|
-
# Watchdog loop: restart node if it crashes
|
|
604
|
-
`$nodePath = (Get-Command node).Source
|
|
605
|
-
while (`$true) {
|
|
606
|
-
`$stdoutLog = Join-Path `$LogDir 'service-stdout.log'
|
|
607
|
-
`$stderrLog = Join-Path `$LogDir 'service-stderr.log'
|
|
608
|
-
`$proc = Start-Process -FilePath `$nodePath -ArgumentList `$ServerJs ``
|
|
609
|
-
-WorkingDirectory `$DataDir ``
|
|
610
|
-
-RedirectStandardOutput `$stdoutLog ``
|
|
611
|
-
-RedirectStandardError `$stderrLog ``
|
|
612
|
-
-WindowStyle Hidden -PassThru
|
|
613
|
-
`$proc.WaitForExit()
|
|
614
|
-
if (`$proc.ExitCode -eq 0) { break }
|
|
615
|
-
Start-Sleep -Seconds 5
|
|
616
|
-
}
|
|
617
|
-
|
|
618
|
-
# Cleanup PID file on normal exit
|
|
619
|
-
Remove-Item `$PidFile -Force -ErrorAction SilentlyContinue
|
|
620
|
-
"@
|
|
621
|
-
Set-Content -Path $StartAgentScript -Value $startAgentContent -Encoding UTF8
|
|
622
|
-
Write-Detail "Generated $StartAgentScript"
|
|
623
|
-
|
|
624
|
-
# Create shortcut in Startup folder
|
|
625
|
-
$startupDir = [Environment]::GetFolderPath('Startup')
|
|
626
|
-
$shortcutPath = Join-Path $startupDir 'MinionAgent.lnk'
|
|
627
|
-
$wsShell = New-Object -ComObject WScript.Shell
|
|
628
|
-
$shortcut = $wsShell.CreateShortcut($shortcutPath)
|
|
629
|
-
$shortcut.TargetPath = 'powershell.exe'
|
|
630
|
-
$shortcut.Arguments = "-ExecutionPolicy Bypass -WindowStyle Hidden -File `"$StartAgentScript`""
|
|
631
|
-
$shortcut.WorkingDirectory = $DataDir
|
|
632
|
-
$shortcut.WindowStyle = 7 # Minimized
|
|
633
|
-
$shortcut.Description = 'Minion Agent auto-start'
|
|
634
|
-
$shortcut.Save()
|
|
635
|
-
Write-Detail "Startup shortcut created: $shortcutPath"
|
|
636
|
-
|
|
637
|
-
# Step 7: Disable screensaver, lock screen, and sleep
|
|
638
|
-
Write-Step 7 $totalSteps "Disabling screensaver, lock screen, and sleep..."
|
|
639
|
-
# Screensaver off
|
|
640
|
-
Set-ItemProperty -Path 'HKCU:\Control Panel\Desktop' -Name ScreenSaveActive -Value '0'
|
|
641
|
-
Set-ItemProperty -Path 'HKCU:\Control Panel\Desktop' -Name ScreenSaveTimeOut -Value '0'
|
|
642
|
-
Set-ItemProperty -Path 'HKCU:\Control Panel\Desktop' -Name SCRNSAVE.EXE -Value ''
|
|
643
|
-
Write-Detail "Screensaver disabled"
|
|
644
|
-
# Lock screen off (user-level: disable lock on resume)
|
|
645
|
-
Set-ItemProperty -Path 'HKCU:\Control Panel\Desktop' -Name ScreenSaverIsSecure -Value '0'
|
|
646
|
-
Write-Detail "Lock on resume disabled"
|
|
647
|
-
# Power settings: never sleep, never turn off display
|
|
648
|
-
& powercfg -change -standby-timeout-ac 0 2>$null
|
|
649
|
-
& powercfg -change -standby-timeout-dc 0 2>$null
|
|
650
|
-
& powercfg -change -monitor-timeout-ac 0 2>$null
|
|
651
|
-
& powercfg -change -monitor-timeout-dc 0 2>$null
|
|
652
|
-
Write-Detail "Sleep and monitor timeout disabled"
|
|
653
|
-
|
|
654
|
-
# Step 8: Install TightVNC Server
|
|
655
|
-
Write-Step 8 $totalSteps "Setting up TightVNC Server..."
|
|
719
|
+
# Build environment string for NSSM (static vars only — .env is read by config.js at startup)
|
|
720
|
+
$envLines = @(
|
|
721
|
+
"USERPROFILE=$TargetUserProfile",
|
|
722
|
+
"HOME=$TargetUserProfile"
|
|
723
|
+
)
|
|
724
|
+
if ($env:CLAUDE_CODE_GIT_BASH_PATH) {
|
|
725
|
+
$envLines += "CLAUDE_CODE_GIT_BASH_PATH=$($env:CLAUDE_CODE_GIT_BASH_PATH)"
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
# Remove existing service if present (idempotent re-setup)
|
|
729
|
+
Invoke-Nssm stop minion-agent
|
|
730
|
+
Invoke-Nssm remove minion-agent confirm
|
|
731
|
+
|
|
732
|
+
# Install minion-agent service
|
|
733
|
+
Invoke-Nssm install minion-agent $nodePath $serverJs
|
|
734
|
+
Invoke-Nssm set minion-agent AppDirectory $DataDir
|
|
735
|
+
Invoke-Nssm set minion-agent AppEnvironmentExtra ($envLines -join "`n")
|
|
736
|
+
Invoke-Nssm set minion-agent AppStdout (Join-Path $LogDir 'service-stdout.log')
|
|
737
|
+
Invoke-Nssm set minion-agent AppStderr (Join-Path $LogDir 'service-stderr.log')
|
|
738
|
+
Invoke-Nssm set minion-agent AppStdoutCreationDisposition 4
|
|
739
|
+
Invoke-Nssm set minion-agent AppStderrCreationDisposition 4
|
|
740
|
+
Invoke-Nssm set minion-agent AppRotateFiles 1
|
|
741
|
+
Invoke-Nssm set minion-agent AppRotateBytes 10485760
|
|
742
|
+
Invoke-Nssm set minion-agent AppRestartDelay 5000
|
|
743
|
+
Invoke-Nssm set minion-agent Start SERVICE_AUTO_START
|
|
744
|
+
Invoke-Nssm set minion-agent DisplayName "Minion Agent"
|
|
745
|
+
Invoke-Nssm set minion-agent Description "GeekBeer Minion AI Agent Service"
|
|
746
|
+
# Run as dedicated minion service account (not LocalSystem)
|
|
747
|
+
Invoke-Nssm set minion-agent ObjectName $MinionSvcUserFull $svcPassword
|
|
748
|
+
Grant-ServiceControlToUser 'minion-agent' $setupUserSid
|
|
749
|
+
Write-Detail "minion-agent service registered (runs as '$MinionSvcUser')"
|
|
750
|
+
|
|
751
|
+
# Step 9: Install and configure TightVNC (runs as LocalSystem for desktop capture)
|
|
752
|
+
Write-Step 9 $totalSteps "Setting up TightVNC Server..."
|
|
656
753
|
$vncSystemPath = 'C:\Program Files\TightVNC\tvnserver.exe'
|
|
657
754
|
$vncPortableDir = Join-Path $DataDir 'tightvnc'
|
|
658
755
|
$vncPortablePath = Join-Path $vncPortableDir 'PFiles\TightVNC\tvnserver.exe'
|
|
756
|
+
$vncExePath = $null
|
|
659
757
|
|
|
660
758
|
if (Test-Path $vncSystemPath) {
|
|
661
759
|
Write-Detail "TightVNC Server already installed (system)"
|
|
760
|
+
$vncExePath = $vncSystemPath
|
|
662
761
|
}
|
|
663
762
|
elseif (Test-Path $vncPortablePath) {
|
|
664
763
|
Write-Detail "TightVNC Server already installed (portable)"
|
|
764
|
+
$vncExePath = $vncPortablePath
|
|
665
765
|
}
|
|
666
766
|
else {
|
|
667
|
-
Write-Host "
|
|
767
|
+
Write-Host " Installing TightVNC via MSI..."
|
|
668
768
|
try {
|
|
669
769
|
$msiUrl = 'https://www.tightvnc.com/download/2.8.85/tightvnc-2.8.85-gpl-setup-64bit.msi'
|
|
670
770
|
$msiPath = Join-Path $env:TEMP 'tightvnc-setup.msi'
|
|
671
771
|
Invoke-WebRequest -Uri $msiUrl -OutFile $msiPath -UseBasicParsing
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
# Creates PFiles\TightVNC\ subdirectory structure
|
|
675
|
-
Start-Process msiexec -ArgumentList "/a `"$msiPath`" /qn TARGETDIR=`"$vncPortableDir`"" -Wait -NoNewWindow
|
|
772
|
+
# With admin, do a proper quiet install
|
|
773
|
+
Start-Process msiexec -ArgumentList "/i `"$msiPath`" /qn /norestart ADDLOCAL=Server SET_ALLOWLOOPBACK=1 SET_LOOPBACKONLY=1 SET_USEVNCAUTHENTICATION=0 SET_USECONTROLAUTHENTICATION=0 VALUE_OF_RFBPORT=5900" -Wait -NoNewWindow
|
|
676
774
|
Remove-Item $msiPath -Force -ErrorAction SilentlyContinue
|
|
677
|
-
if (Test-Path $
|
|
678
|
-
Write-Detail "TightVNC
|
|
775
|
+
if (Test-Path $vncSystemPath) {
|
|
776
|
+
Write-Detail "TightVNC installed to $vncSystemPath"
|
|
777
|
+
$vncExePath = $vncSystemPath
|
|
778
|
+
# Stop the TightVNC service if the MSI auto-started it (we manage via NSSM)
|
|
779
|
+
Stop-Service TightVNC -ErrorAction SilentlyContinue
|
|
780
|
+
Set-Service TightVNC -StartupType Disabled -ErrorAction SilentlyContinue
|
|
679
781
|
} else {
|
|
680
|
-
Write-Warn "TightVNC
|
|
782
|
+
Write-Warn "TightVNC MSI install failed, trying portable extraction..."
|
|
783
|
+
# Fallback to portable extraction
|
|
784
|
+
New-Item -Path $vncPortableDir -ItemType Directory -Force | Out-Null
|
|
785
|
+
Start-Process msiexec -ArgumentList "/a `"$msiPath`" /qn TARGETDIR=`"$vncPortableDir`"" -Wait -NoNewWindow
|
|
786
|
+
if (Test-Path $vncPortablePath) {
|
|
787
|
+
Write-Detail "TightVNC portable extracted to $vncPortableDir"
|
|
788
|
+
$vncExePath = $vncPortablePath
|
|
789
|
+
} else {
|
|
790
|
+
Write-Warn "TightVNC extraction also failed."
|
|
791
|
+
}
|
|
681
792
|
}
|
|
682
793
|
}
|
|
683
794
|
catch {
|
|
@@ -686,13 +797,9 @@ Remove-Item `$PidFile -Force -ErrorAction SilentlyContinue
|
|
|
686
797
|
}
|
|
687
798
|
}
|
|
688
799
|
|
|
689
|
-
# Configure TightVNC
|
|
690
|
-
# - LoopbackOnly=1: only accept connections from localhost (security)
|
|
691
|
-
# - AllowLoopback=1: enable loopback connections
|
|
692
|
-
# - UseVncAuthentication=0: no password prompt (websockify connects via localhost,
|
|
693
|
-
# HQ WebSocket proxy provides its own Supabase session authentication)
|
|
800
|
+
# Configure TightVNC registry (localhost-only, no VNC auth)
|
|
694
801
|
$vncRegPath = 'HKCU:\Software\TightVNC\Server'
|
|
695
|
-
if (
|
|
802
|
+
if ($vncExePath) {
|
|
696
803
|
if (-not (Test-Path $vncRegPath)) {
|
|
697
804
|
New-Item -Path $vncRegPath -Force | Out-Null
|
|
698
805
|
}
|
|
@@ -701,27 +808,29 @@ Remove-Item `$PidFile -Force -ErrorAction SilentlyContinue
|
|
|
701
808
|
Set-ItemProperty -Path $vncRegPath -Name 'UseVncAuthentication' -Value 0 -Type DWord
|
|
702
809
|
Set-ItemProperty -Path $vncRegPath -Name 'UseControlAuthentication' -Value 0 -Type DWord
|
|
703
810
|
Set-ItemProperty -Path $vncRegPath -Name 'RfbPort' -Value 5900 -Type DWord
|
|
704
|
-
# Reload config if tvnserver is already running (re-setup scenario)
|
|
705
|
-
$vncProc = Get-Process -Name tvnserver -ErrorAction SilentlyContinue
|
|
706
|
-
if ($vncProc) {
|
|
707
|
-
$vncExePath = if (Test-Path $vncSystemPath) { $vncSystemPath } else { $vncPortablePath }
|
|
708
|
-
& $vncExePath -controlapp -reload 2>$null
|
|
709
|
-
Write-Detail "TightVNC config reloaded"
|
|
710
|
-
}
|
|
711
|
-
Write-Detail "TightVNC configured: localhost-only, no VNC password (auth via HQ proxy)"
|
|
712
|
-
}
|
|
713
811
|
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
812
|
+
# Register VNC as NSSM service (application mode, not TightVNC's own service)
|
|
813
|
+
Invoke-Nssm stop minion-vnc
|
|
814
|
+
Invoke-Nssm remove minion-vnc confirm
|
|
815
|
+
Invoke-Nssm install minion-vnc $vncExePath '-run'
|
|
816
|
+
Invoke-Nssm set minion-vnc Start SERVICE_AUTO_START
|
|
817
|
+
Invoke-Nssm set minion-vnc DisplayName "Minion VNC Server"
|
|
818
|
+
Invoke-Nssm set minion-vnc Description "TightVNC for Minion remote desktop"
|
|
819
|
+
Invoke-Nssm set minion-vnc AppRestartDelay 3000
|
|
820
|
+
Grant-ServiceControlToUser 'minion-vnc' $setupUserSid
|
|
821
|
+
Write-Detail "minion-vnc service registered"
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
# Step 10: Setup websockify (runs as LocalSystem, paired with VNC)
|
|
825
|
+
Write-Step 10 $totalSteps "Setting up websockify..."
|
|
826
|
+
[array]$wsCmd = Get-WebsockifyCommand
|
|
827
|
+
if (-not $wsCmd) {
|
|
828
|
+
# Ensure Python is installed
|
|
721
829
|
$pythonUsable = $false
|
|
722
830
|
if (Test-CommandExists 'python') {
|
|
831
|
+
$prevPref = $ErrorActionPreference; $ErrorActionPreference = 'Continue'
|
|
723
832
|
$pyVer = & python --version 2>&1
|
|
724
|
-
|
|
833
|
+
$ErrorActionPreference = $prevPref
|
|
725
834
|
if ($pyVer -match '\d+\.\d+') { $pythonUsable = $true }
|
|
726
835
|
}
|
|
727
836
|
if (-not $pythonUsable) {
|
|
@@ -740,8 +849,6 @@ Remove-Item `$PidFile -Force -ErrorAction SilentlyContinue
|
|
|
740
849
|
|
|
741
850
|
Write-Host " Installing websockify via pip..."
|
|
742
851
|
try {
|
|
743
|
-
# Use cmd /c to prevent pip's stderr (progress bars, warnings) from
|
|
744
|
-
# becoming RemoteException errors in PowerShell remoting sessions.
|
|
745
852
|
if (Test-CommandExists 'pip') {
|
|
746
853
|
$pipResult = cmd /c "pip install websockify 2>&1"
|
|
747
854
|
if ($LASTEXITCODE -eq 0) { Write-Detail "websockify installed" }
|
|
@@ -760,148 +867,88 @@ Remove-Item `$PidFile -Force -ErrorAction SilentlyContinue
|
|
|
760
867
|
else {
|
|
761
868
|
Write-Warn "pip not found. Install Python first, then: pip install websockify"
|
|
762
869
|
}
|
|
763
|
-
# Refresh PATH so websockify.exe in Python Scripts dir is discoverable
|
|
764
870
|
$env:PATH = [System.Environment]::GetEnvironmentVariable('PATH', 'Machine') + ';' + [System.Environment]::GetEnvironmentVariable('PATH', 'User')
|
|
765
871
|
}
|
|
766
872
|
catch {
|
|
767
873
|
Write-Warn "Failed to install websockify: $_"
|
|
768
874
|
}
|
|
769
|
-
}
|
|
770
|
-
|
|
771
|
-
# Step 10 (optional): Cloudflare Tunnel
|
|
772
|
-
if ($SetupTunnel) {
|
|
773
|
-
$currentStep = $totalSteps - 1
|
|
774
|
-
Write-Step $currentStep $totalSteps "Setting up Cloudflare Tunnel..."
|
|
775
|
-
|
|
776
|
-
if (-not $HqUrl -or -not $ApiToken) {
|
|
777
|
-
Write-Error "--setup-tunnel requires --hq-url and --api-token"
|
|
778
|
-
exit 1
|
|
779
|
-
}
|
|
780
|
-
|
|
781
|
-
# Install cloudflared
|
|
782
|
-
if (-not (Test-CommandExists 'cloudflared')) {
|
|
783
|
-
Write-Host " Installing cloudflared..."
|
|
784
|
-
if (Test-CommandExists 'winget') {
|
|
785
|
-
& winget install --id Cloudflare.cloudflared --accept-package-agreements --accept-source-agreements
|
|
786
|
-
$env:PATH = [System.Environment]::GetEnvironmentVariable('PATH', 'Machine') + ';' + [System.Environment]::GetEnvironmentVariable('PATH', 'User')
|
|
787
|
-
}
|
|
788
|
-
elseif (Test-CommandExists 'choco') {
|
|
789
|
-
& choco install cloudflared -y
|
|
790
|
-
}
|
|
791
|
-
else {
|
|
792
|
-
Write-Host " Downloading cloudflared..."
|
|
793
|
-
$cfUrl = 'https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-windows-amd64.exe'
|
|
794
|
-
$cfPath = Join-Path $DataDir 'cloudflared.exe'
|
|
795
|
-
Invoke-WebRequest -Uri $cfUrl -OutFile $cfPath
|
|
796
|
-
Write-Detail "cloudflared downloaded to $cfPath"
|
|
797
|
-
}
|
|
798
|
-
}
|
|
799
|
-
else {
|
|
800
|
-
Write-Detail "cloudflared already installed"
|
|
801
|
-
}
|
|
802
|
-
|
|
803
|
-
# Fetch tunnel config from HQ
|
|
804
|
-
Write-Host " Fetching tunnel configuration from HQ..."
|
|
805
|
-
try {
|
|
806
|
-
$headers = @{ 'Authorization' = "Bearer $ApiToken" }
|
|
807
|
-
$tunnelData = Invoke-RestMethod -Uri "$HqUrl/api/minion/tunnel-credentials?platform=windows" -Headers $headers
|
|
808
|
-
|
|
809
|
-
if ($tunnelData.tunnel_id) {
|
|
810
|
-
$cfConfigDir = Join-Path $env:USERPROFILE '.cloudflared'
|
|
811
|
-
New-Item -Path $cfConfigDir -ItemType Directory -Force | Out-Null
|
|
812
|
-
|
|
813
|
-
# Save credentials (BOM-free UTF-8, required by cloudflared)
|
|
814
|
-
[System.IO.File]::WriteAllText((Join-Path $cfConfigDir "$($tunnelData.tunnel_id).json"), $tunnelData.credentials_json, [System.Text.UTF8Encoding]::new($false))
|
|
815
|
-
Write-Detail "Tunnel credentials saved"
|
|
816
|
-
|
|
817
|
-
# Save config (BOM-free UTF-8)
|
|
818
|
-
[System.IO.File]::WriteAllText((Join-Path $cfConfigDir 'config.yml'), $tunnelData.config_yml, [System.Text.UTF8Encoding]::new($false))
|
|
819
|
-
Write-Detail "Tunnel config saved (will run as user process on start)"
|
|
820
|
-
}
|
|
821
|
-
else {
|
|
822
|
-
Write-Warn "Tunnel not configured for this minion"
|
|
823
|
-
}
|
|
824
|
-
}
|
|
825
|
-
catch {
|
|
826
|
-
Write-Warn "Failed to fetch tunnel credentials: $_"
|
|
827
|
-
}
|
|
828
|
-
}
|
|
829
875
|
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
Write-Host "Deploying bundled assets..."
|
|
833
|
-
$claudeSkillsDir = Join-Path $env:USERPROFILE '.claude\skills'
|
|
834
|
-
$bundledSkillsDir = Join-Path $minionPkgDir 'skills'
|
|
835
|
-
if (Test-Path $bundledSkillsDir) {
|
|
836
|
-
New-Item -Path $claudeSkillsDir -ItemType Directory -Force | Out-Null
|
|
837
|
-
Get-ChildItem -Path $bundledSkillsDir -Directory | ForEach-Object {
|
|
838
|
-
Copy-Item $_.FullName -Destination (Join-Path $claudeSkillsDir $_.Name) -Recurse -Force
|
|
839
|
-
Write-Detail "Deployed skill: $($_.Name)"
|
|
840
|
-
}
|
|
876
|
+
# Re-check after install
|
|
877
|
+
[array]$wsCmd = Get-WebsockifyCommand
|
|
841
878
|
}
|
|
842
879
|
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
880
|
+
if ($wsCmd -and $vncExePath) {
|
|
881
|
+
# Register websockify as NSSM service
|
|
882
|
+
Invoke-Nssm stop minion-websockify
|
|
883
|
+
Invoke-Nssm remove minion-websockify confirm
|
|
884
|
+
if ($wsCmd.Count -eq 1) {
|
|
885
|
+
Invoke-Nssm install minion-websockify $wsCmd[0] '6080 localhost:5900'
|
|
886
|
+
} else {
|
|
887
|
+
# python -m websockify 6080 localhost:5900
|
|
888
|
+
$wsArgs = ($wsCmd[1..($wsCmd.Count-1)] + @('6080', 'localhost:5900')) -join ' '
|
|
889
|
+
Invoke-Nssm install minion-websockify $wsCmd[0] $wsArgs
|
|
852
890
|
}
|
|
891
|
+
Invoke-Nssm set minion-websockify DependOnService minion-vnc
|
|
892
|
+
Invoke-Nssm set minion-websockify Start SERVICE_AUTO_START
|
|
893
|
+
Invoke-Nssm set minion-websockify DisplayName "Minion Websockify"
|
|
894
|
+
Invoke-Nssm set minion-websockify Description "WebSocket proxy for VNC (6080 -> 5900)"
|
|
895
|
+
Invoke-Nssm set minion-websockify AppRestartDelay 3000
|
|
896
|
+
Grant-ServiceControlToUser 'minion-websockify' $setupUserSid
|
|
897
|
+
Write-Detail "minion-websockify service registered (depends on minion-vnc)"
|
|
898
|
+
} else {
|
|
899
|
+
Write-Warn "websockify not available, VNC WebSocket proxy will not be registered"
|
|
853
900
|
}
|
|
854
901
|
|
|
855
|
-
#
|
|
856
|
-
Write-Step
|
|
857
|
-
|
|
902
|
+
# Step 11: Disable screensaver, lock screen, and sleep
|
|
903
|
+
Write-Step 11 $totalSteps "Disabling screensaver, lock screen, and sleep..."
|
|
904
|
+
Set-ItemProperty -Path 'HKCU:\Control Panel\Desktop' -Name ScreenSaveActive -Value '0'
|
|
905
|
+
Set-ItemProperty -Path 'HKCU:\Control Panel\Desktop' -Name ScreenSaveTimeOut -Value '0'
|
|
906
|
+
Set-ItemProperty -Path 'HKCU:\Control Panel\Desktop' -Name SCRNSAVE.EXE -Value ''
|
|
907
|
+
Write-Detail "Screensaver disabled"
|
|
908
|
+
Set-ItemProperty -Path 'HKCU:\Control Panel\Desktop' -Name ScreenSaverIsSecure -Value '0'
|
|
909
|
+
Write-Detail "Lock on resume disabled"
|
|
910
|
+
& powercfg -change -standby-timeout-ac 0 2>$null
|
|
911
|
+
& powercfg -change -standby-timeout-dc 0 2>$null
|
|
912
|
+
& powercfg -change -monitor-timeout-ac 0 2>$null
|
|
913
|
+
& powercfg -change -monitor-timeout-dc 0 2>$null
|
|
914
|
+
Write-Detail "Sleep and monitor timeout disabled"
|
|
858
915
|
|
|
859
|
-
#
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
916
|
+
# Configure firewall rules
|
|
917
|
+
Write-Step 10 $totalSteps "Configuring firewall rules..."
|
|
918
|
+
$fwRules = @(
|
|
919
|
+
@{ Name = 'Minion Agent'; Port = 8080 },
|
|
920
|
+
@{ Name = 'Minion Terminal'; Port = 7681 },
|
|
921
|
+
@{ Name = 'Minion VNC'; Port = 6080 }
|
|
922
|
+
)
|
|
923
|
+
foreach ($rule in $fwRules) {
|
|
924
|
+
$existing = Get-NetFirewallRule -DisplayName $rule.Name -ErrorAction SilentlyContinue
|
|
925
|
+
if (-not $existing) {
|
|
926
|
+
New-NetFirewallRule -DisplayName $rule.Name -Direction Inbound -Protocol TCP -LocalPort $rule.Port -Action Allow -Profile Any | Out-Null
|
|
927
|
+
Write-Detail "Firewall rule added: $($rule.Name) (TCP $($rule.Port))"
|
|
928
|
+
} else {
|
|
929
|
+
Write-Detail "Firewall rule exists: $($rule.Name)"
|
|
930
|
+
}
|
|
866
931
|
}
|
|
867
932
|
|
|
868
|
-
#
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
933
|
+
# Grant minion service account access to .claude directory (skills, rules, settings)
|
|
934
|
+
$claudeDir = Join-Path $TargetUserProfile '.claude'
|
|
935
|
+
New-Item -Path $claudeDir -ItemType Directory -Force | Out-Null
|
|
936
|
+
$claudeAcl = Get-Acl $claudeDir
|
|
937
|
+
$claudeRule = New-Object System.Security.AccessControl.FileSystemAccessRule(
|
|
938
|
+
$MinionSvcUser, 'Modify', 'ContainerInherit,ObjectInherit', 'None', 'Allow')
|
|
939
|
+
$claudeAcl.AddAccessRule($claudeRule)
|
|
940
|
+
Set-Acl $claudeDir $claudeAcl
|
|
941
|
+
Write-Detail "Granted '$MinionSvcUser' access to $claudeDir"
|
|
876
942
|
|
|
877
|
-
#
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
$
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
}
|
|
887
|
-
$bodyHash = @{}
|
|
888
|
-
if ($lanIp) {
|
|
889
|
-
$bodyHash['ip_address'] = $lanIp
|
|
890
|
-
$bodyHash['internal_ip_address'] = $lanIp
|
|
891
|
-
} else {
|
|
892
|
-
$bodyHash['internal_ip_address'] = [System.Net.Dns]::GetHostName()
|
|
893
|
-
}
|
|
894
|
-
$body = $bodyHash | ConvertTo-Json
|
|
895
|
-
Invoke-RestMethod -Uri "$HqUrl/api/minion/setup-complete" -Method Post -Headers $headers -Body $body -ErrorAction Stop | Out-Null
|
|
896
|
-
if ($lanIp) {
|
|
897
|
-
Write-Detail "HQ notified successfully (LAN IP: $lanIp)"
|
|
898
|
-
} else {
|
|
899
|
-
Write-Detail "HQ notified successfully (LAN IP detection failed, hostname sent)"
|
|
900
|
-
}
|
|
901
|
-
}
|
|
902
|
-
catch {
|
|
903
|
-
Write-Detail "HQ notification skipped (HQ may not be reachable)"
|
|
904
|
-
}
|
|
943
|
+
# Grant minion service account access to npm global directory (for package access)
|
|
944
|
+
$npmGlobalDir = Split-Path $CliDir
|
|
945
|
+
if ($npmGlobalDir -and (Test-Path $npmGlobalDir)) {
|
|
946
|
+
$npmAcl = Get-Acl $npmGlobalDir
|
|
947
|
+
$npmRule = New-Object System.Security.AccessControl.FileSystemAccessRule(
|
|
948
|
+
$MinionSvcUser, 'ReadAndExecute', 'ContainerInherit,ObjectInherit', 'None', 'Allow')
|
|
949
|
+
$npmAcl.AddAccessRule($npmRule)
|
|
950
|
+
Set-Acl $npmGlobalDir $npmAcl
|
|
951
|
+
Write-Detail "Granted '$MinionSvcUser' read access to npm global"
|
|
905
952
|
}
|
|
906
953
|
|
|
907
954
|
Write-Host ""
|
|
@@ -909,13 +956,17 @@ Remove-Item `$PidFile -Force -ErrorAction SilentlyContinue
|
|
|
909
956
|
Write-Host " Setup Complete!" -ForegroundColor Green
|
|
910
957
|
Write-Host "=========================================" -ForegroundColor Green
|
|
911
958
|
Write-Host ""
|
|
912
|
-
Write-Host "
|
|
913
|
-
Write-Host " minion-
|
|
914
|
-
Write-Host " minion-
|
|
915
|
-
Write-Host " minion-
|
|
916
|
-
Write-Host "
|
|
917
|
-
Write-Host "
|
|
918
|
-
Write-Host "
|
|
959
|
+
Write-Host "Services registered (not yet started):"
|
|
960
|
+
Write-Host " minion-agent - AI Agent (port 8080)"
|
|
961
|
+
Write-Host " minion-vnc - TightVNC Server (port 5900)"
|
|
962
|
+
Write-Host " minion-websockify - WebSocket proxy (port 6080)"
|
|
963
|
+
Write-Host ""
|
|
964
|
+
Write-Host "Next step: Connect to HQ (run as regular user):" -ForegroundColor Yellow
|
|
965
|
+
Write-Host " minion-cli-win configure ``"
|
|
966
|
+
Write-Host " --hq-url <HQ_URL> ``"
|
|
967
|
+
Write-Host " --minion-id <MINION_ID> ``"
|
|
968
|
+
Write-Host " --api-token <API_TOKEN>"
|
|
969
|
+
Write-Host ""
|
|
919
970
|
}
|
|
920
971
|
|
|
921
972
|
# ============================================================
|
|
@@ -923,12 +974,19 @@ Remove-Item `$PidFile -Force -ErrorAction SilentlyContinue
|
|
|
923
974
|
# ============================================================
|
|
924
975
|
|
|
925
976
|
function Invoke-Uninstall {
|
|
977
|
+
# Resolve target user profile from saved setup config
|
|
978
|
+
$TargetUserProfile = Resolve-TargetUserProfile
|
|
979
|
+
$DataDir = Join-Path $TargetUserProfile '.minion'
|
|
980
|
+
$EnvFile = Join-Path $DataDir '.env'
|
|
981
|
+
$LogDir = Join-Path $DataDir 'logs'
|
|
982
|
+
|
|
926
983
|
Write-Host ""
|
|
927
984
|
Write-Host "=========================================" -ForegroundColor Red
|
|
928
985
|
Write-Host " @geekbeer/minion Uninstall" -ForegroundColor Red
|
|
929
986
|
Write-Host "=========================================" -ForegroundColor Red
|
|
930
987
|
Write-Host ""
|
|
931
|
-
Write-Host "
|
|
988
|
+
Write-Host "Target user: $TargetUserProfile"
|
|
989
|
+
Write-Host "This will remove the minion agent services and all related configuration."
|
|
932
990
|
if ($KeepData) {
|
|
933
991
|
Write-Host " --keep-data: $EnvFile will be preserved."
|
|
934
992
|
}
|
|
@@ -943,51 +1001,52 @@ function Invoke-Uninstall {
|
|
|
943
1001
|
}
|
|
944
1002
|
Write-Host ""
|
|
945
1003
|
|
|
946
|
-
$totalSteps =
|
|
947
|
-
|
|
948
|
-
# Step 1: Stop
|
|
949
|
-
Write-Step 1 $totalSteps "Stopping
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
1004
|
+
$totalSteps = 7
|
|
1005
|
+
|
|
1006
|
+
# Step 1: Stop and remove all NSSM services
|
|
1007
|
+
Write-Step 1 $totalSteps "Stopping and removing services..."
|
|
1008
|
+
if ($NssmPath -and (Test-Path $NssmPath)) {
|
|
1009
|
+
foreach ($svc in @('minion-cloudflared', 'minion-websockify', 'minion-vnc', 'minion-agent')) {
|
|
1010
|
+
$status = Invoke-Nssm status $svc
|
|
1011
|
+
if ($status) {
|
|
1012
|
+
Invoke-Nssm stop $svc
|
|
1013
|
+
Invoke-Nssm remove $svc confirm
|
|
1014
|
+
Write-Detail "Removed service: $svc"
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
957
1017
|
}
|
|
958
1018
|
|
|
959
|
-
#
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
}
|
|
1019
|
+
# Also stop legacy processes
|
|
1020
|
+
Stop-Process -Name tvnserver -Force -ErrorAction SilentlyContinue
|
|
1021
|
+
Stop-Process -Name websockify -Force -ErrorAction SilentlyContinue
|
|
1022
|
+
Stop-Process -Name cloudflared -Force -ErrorAction SilentlyContinue
|
|
1023
|
+
Write-Detail "All services and processes stopped"
|
|
965
1024
|
|
|
966
|
-
#
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
1025
|
+
# Step 2: Remove firewall rules
|
|
1026
|
+
Write-Step 2 $totalSteps "Removing firewall rules..."
|
|
1027
|
+
foreach ($ruleName in @('Minion Agent', 'Minion VNC')) {
|
|
1028
|
+
$existing = Get-NetFirewallRule -DisplayName $ruleName -ErrorAction SilentlyContinue
|
|
1029
|
+
if ($existing) {
|
|
1030
|
+
Remove-NetFirewallRule -DisplayName $ruleName
|
|
1031
|
+
Write-Detail "Removed firewall rule: $ruleName"
|
|
1032
|
+
}
|
|
971
1033
|
}
|
|
972
1034
|
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
# Step 2: Remove startup shortcut
|
|
976
|
-
Write-Step 2 $totalSteps "Removing auto-start registration..."
|
|
1035
|
+
# Step 3: Remove Startup folder shortcut (legacy migration)
|
|
1036
|
+
Write-Step 3 $totalSteps "Removing auto-start registration..."
|
|
977
1037
|
$startupDir = [Environment]::GetFolderPath('Startup')
|
|
978
1038
|
$shortcutPath = Join-Path $startupDir 'MinionAgent.lnk'
|
|
979
1039
|
if (Test-Path $shortcutPath) {
|
|
980
1040
|
Remove-Item $shortcutPath -Force
|
|
981
|
-
Write-Detail "Removed
|
|
1041
|
+
Write-Detail "Removed legacy Startup shortcut"
|
|
982
1042
|
}
|
|
983
1043
|
else {
|
|
984
|
-
Write-Detail "Startup shortcut
|
|
1044
|
+
Write-Detail "No legacy Startup shortcut found"
|
|
985
1045
|
}
|
|
986
1046
|
|
|
987
|
-
# Step
|
|
988
|
-
Write-Step
|
|
1047
|
+
# Step 4: Remove data directory (or keep .env)
|
|
1048
|
+
Write-Step 4 $totalSteps "Removing data directory..."
|
|
989
1049
|
if ($KeepData) {
|
|
990
|
-
# Remove everything except .env
|
|
991
1050
|
if (Test-Path $DataDir) {
|
|
992
1051
|
Get-ChildItem -Path $DataDir -Recurse -File | Where-Object { $_.FullName -ne $EnvFile } | Remove-Item -Force -ErrorAction SilentlyContinue
|
|
993
1052
|
Get-ChildItem -Path $DataDir -Recurse -Directory | Sort-Object { $_.FullName.Length } -Descending | ForEach-Object {
|
|
@@ -1011,14 +1070,12 @@ function Invoke-Uninstall {
|
|
|
1011
1070
|
}
|
|
1012
1071
|
}
|
|
1013
1072
|
|
|
1014
|
-
# Step
|
|
1015
|
-
Write-Step
|
|
1016
|
-
$claudeSkillsDir = Join-Path $
|
|
1017
|
-
$claudeRulesDir = Join-Path $
|
|
1073
|
+
# Step 5: Remove deployed skills and rules
|
|
1074
|
+
Write-Step 5 $totalSteps "Removing deployed skills and rules..."
|
|
1075
|
+
$claudeSkillsDir = Join-Path $TargetUserProfile '.claude\skills'
|
|
1076
|
+
$claudeRulesDir = Join-Path $TargetUserProfile '.claude\rules'
|
|
1018
1077
|
|
|
1019
|
-
|
|
1020
|
-
$npmRoot = & npm root -g 2>$null
|
|
1021
|
-
$bundledSkillsDir = Join-Path $npmRoot '@geekbeer\minion\skills'
|
|
1078
|
+
$bundledSkillsDir = Join-Path $CliDir 'skills'
|
|
1022
1079
|
if ((Test-Path $bundledSkillsDir) -and (Test-Path $claudeSkillsDir)) {
|
|
1023
1080
|
Get-ChildItem -Path $bundledSkillsDir -Directory | ForEach-Object {
|
|
1024
1081
|
$targetSkill = Join-Path $claudeSkillsDir $_.Name
|
|
@@ -1032,16 +1089,31 @@ function Invoke-Uninstall {
|
|
|
1032
1089
|
Write-Detail "No bundled skills to remove"
|
|
1033
1090
|
}
|
|
1034
1091
|
|
|
1035
|
-
# Remove deployed rules
|
|
1036
1092
|
$coreRule = Join-Path $claudeRulesDir 'core.md'
|
|
1037
1093
|
if (Test-Path $coreRule) {
|
|
1038
1094
|
Remove-Item $coreRule -Force
|
|
1039
1095
|
Write-Detail "Removed rules: core.md"
|
|
1040
1096
|
}
|
|
1041
1097
|
|
|
1042
|
-
# Step
|
|
1043
|
-
Write-Step
|
|
1044
|
-
$
|
|
1098
|
+
# Step 6: Remove minion service account
|
|
1099
|
+
Write-Step 6 $totalSteps "Removing service account..."
|
|
1100
|
+
$MinionSvcUser = 'minion'
|
|
1101
|
+
if (Get-LocalUser -Name $MinionSvcUser -ErrorAction SilentlyContinue) {
|
|
1102
|
+
Remove-LocalUser -Name $MinionSvcUser
|
|
1103
|
+
Write-Detail "Removed local user '$MinionSvcUser'"
|
|
1104
|
+
} else {
|
|
1105
|
+
Write-Detail "Service account '$MinionSvcUser' not found, skipping"
|
|
1106
|
+
}
|
|
1107
|
+
# Remove stored service password
|
|
1108
|
+
$svcPasswordFile = Join-Path $DataDir '.svc-password'
|
|
1109
|
+
if (Test-Path $svcPasswordFile) {
|
|
1110
|
+
Remove-Item $svcPasswordFile -Force
|
|
1111
|
+
Write-Detail "Removed service credentials file"
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
# Step 7: Remove Cloudflare Tunnel configuration
|
|
1115
|
+
Write-Step 7 $totalSteps "Removing Cloudflare Tunnel configuration..."
|
|
1116
|
+
$cfConfigDir = Join-Path $TargetUserProfile '.cloudflared'
|
|
1045
1117
|
if (Test-Path $cfConfigDir) {
|
|
1046
1118
|
Remove-Item $cfConfigDir -Recurse -Force
|
|
1047
1119
|
Write-Detail "Removed $cfConfigDir"
|
|
@@ -1065,204 +1137,339 @@ function Invoke-Uninstall {
|
|
|
1065
1137
|
}
|
|
1066
1138
|
|
|
1067
1139
|
# ============================================================
|
|
1068
|
-
#
|
|
1140
|
+
# Configure (HQ connection — no admin required)
|
|
1069
1141
|
# ============================================================
|
|
1070
1142
|
|
|
1071
|
-
function Invoke-
|
|
1143
|
+
function Invoke-Configure {
|
|
1072
1144
|
if (-not $HqUrl -or -not $MinionId -or -not $ApiToken) {
|
|
1073
1145
|
Write-Error "All three parameters are required: --hq-url, --minion-id, --api-token"
|
|
1074
1146
|
exit 1
|
|
1075
1147
|
}
|
|
1076
|
-
|
|
1077
|
-
|
|
1148
|
+
|
|
1149
|
+
# Resolve target user profile from saved setup config
|
|
1150
|
+
$TargetUserProfile = Resolve-TargetUserProfile
|
|
1151
|
+
$DataDir = Join-Path $TargetUserProfile '.minion'
|
|
1152
|
+
$EnvFile = Join-Path $DataDir '.env'
|
|
1153
|
+
$LogDir = Join-Path $DataDir 'logs'
|
|
1154
|
+
|
|
1155
|
+
if (-not (Test-Path $DataDir)) {
|
|
1156
|
+
Write-Error "Data directory not found. Run 'setup' as administrator first."
|
|
1078
1157
|
exit 1
|
|
1079
1158
|
}
|
|
1080
1159
|
|
|
1160
|
+
$totalSteps = 5
|
|
1161
|
+
if ($SetupTunnel) { $totalSteps = 6 }
|
|
1162
|
+
|
|
1081
1163
|
Write-Host "=========================================" -ForegroundColor Cyan
|
|
1082
|
-
Write-Host " @geekbeer/minion
|
|
1164
|
+
Write-Host " @geekbeer/minion Configure" -ForegroundColor Cyan
|
|
1083
1165
|
Write-Host "=========================================" -ForegroundColor Cyan
|
|
1084
1166
|
Write-Host "HQ: $HqUrl"
|
|
1085
1167
|
Write-Host "Minion ID: $MinionId"
|
|
1168
|
+
if ($SetupTunnel) { Write-Host "Tunnel: Enabled" }
|
|
1086
1169
|
Write-Host ""
|
|
1087
1170
|
|
|
1088
|
-
# Step 1:
|
|
1089
|
-
Write-Step 1
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1171
|
+
# Step 1: Write/update .env (single source of truth for config)
|
|
1172
|
+
Write-Step 1 $totalSteps "Writing .env credentials..."
|
|
1173
|
+
if (Test-Path $EnvFile) {
|
|
1174
|
+
$envValues = Read-EnvFile $EnvFile
|
|
1175
|
+
} else {
|
|
1176
|
+
$envValues = @{
|
|
1177
|
+
'AGENT_PORT' = '8080'
|
|
1178
|
+
'MINION_USER' = (Split-Path $TargetUserProfile -Leaf)
|
|
1179
|
+
'REFLECTION_TIME' = '03:00'
|
|
1180
|
+
}
|
|
1181
|
+
}
|
|
1182
|
+
$envValues['HQ_URL'] = $HqUrl
|
|
1183
|
+
$envValues['API_TOKEN'] = $ApiToken
|
|
1184
|
+
$envValues['MINION_ID'] = $MinionId
|
|
1185
|
+
Write-EnvFile $EnvFile $envValues
|
|
1095
1186
|
Write-Detail "$EnvFile updated"
|
|
1096
1187
|
|
|
1097
|
-
# Step 2:
|
|
1098
|
-
Write-Step 2
|
|
1099
|
-
|
|
1188
|
+
# Step 2: Deploy bundled skills and rules
|
|
1189
|
+
Write-Step 2 $totalSteps "Deploying bundled assets..."
|
|
1190
|
+
$minionPkgDir = $CliDir
|
|
1191
|
+
if (Test-Path $minionPkgDir) {
|
|
1192
|
+
$claudeSkillsDir = Join-Path $TargetUserProfile '.claude\skills'
|
|
1193
|
+
$bundledSkillsDir = Join-Path $minionPkgDir 'skills'
|
|
1194
|
+
if (Test-Path $bundledSkillsDir) {
|
|
1195
|
+
New-Item -Path $claudeSkillsDir -ItemType Directory -Force | Out-Null
|
|
1196
|
+
Get-ChildItem -Path $bundledSkillsDir -Directory | ForEach-Object {
|
|
1197
|
+
Copy-Item $_.FullName -Destination (Join-Path $claudeSkillsDir $_.Name) -Recurse -Force
|
|
1198
|
+
Write-Detail "Deployed skill: $($_.Name)"
|
|
1199
|
+
}
|
|
1200
|
+
}
|
|
1201
|
+
$claudeRulesDir = Join-Path $TargetUserProfile '.claude\rules'
|
|
1202
|
+
$bundledRulesDir = Join-Path $minionPkgDir 'rules'
|
|
1203
|
+
if (Test-Path $bundledRulesDir) {
|
|
1204
|
+
New-Item -Path $claudeRulesDir -ItemType Directory -Force | Out-Null
|
|
1205
|
+
$coreRule = Join-Path $bundledRulesDir 'core.md'
|
|
1206
|
+
if (Test-Path $coreRule) {
|
|
1207
|
+
Copy-Item $coreRule -Destination (Join-Path $claudeRulesDir 'core.md') -Force
|
|
1208
|
+
Write-Detail "Deployed rules: core.md"
|
|
1209
|
+
}
|
|
1210
|
+
}
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
# Step 3: Cloudflare Tunnel (optional)
|
|
1214
|
+
if ($SetupTunnel) {
|
|
1215
|
+
Write-Step 3 $totalSteps "Setting up Cloudflare Tunnel..."
|
|
1216
|
+
|
|
1217
|
+
# Install cloudflared if needed
|
|
1218
|
+
if (-not (Test-CommandExists 'cloudflared')) {
|
|
1219
|
+
Write-Host " Installing cloudflared..."
|
|
1220
|
+
if (Test-CommandExists 'winget') {
|
|
1221
|
+
& winget install --id Cloudflare.cloudflared --accept-package-agreements --accept-source-agreements
|
|
1222
|
+
$env:PATH = [System.Environment]::GetEnvironmentVariable('PATH', 'Machine') + ';' + [System.Environment]::GetEnvironmentVariable('PATH', 'User')
|
|
1223
|
+
}
|
|
1224
|
+
elseif (Test-CommandExists 'choco') {
|
|
1225
|
+
& choco install cloudflared -y
|
|
1226
|
+
}
|
|
1227
|
+
else {
|
|
1228
|
+
Write-Host " Downloading cloudflared..."
|
|
1229
|
+
$cfUrl = 'https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-windows-amd64.exe'
|
|
1230
|
+
$cfPath = Join-Path $DataDir 'cloudflared.exe'
|
|
1231
|
+
Invoke-WebRequest -Uri $cfUrl -OutFile $cfPath
|
|
1232
|
+
Write-Detail "cloudflared downloaded to $cfPath"
|
|
1233
|
+
}
|
|
1234
|
+
}
|
|
1235
|
+
else {
|
|
1236
|
+
Write-Detail "cloudflared already installed"
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
# Fetch tunnel config from HQ
|
|
1240
|
+
Write-Host " Fetching tunnel configuration from HQ..."
|
|
1241
|
+
try {
|
|
1242
|
+
$headers = @{ 'Authorization' = "Bearer $ApiToken" }
|
|
1243
|
+
$tunnelData = Invoke-RestMethod -Uri "$HqUrl/api/minion/tunnel-credentials?platform=windows" -Headers $headers
|
|
1244
|
+
|
|
1245
|
+
if ($tunnelData.tunnel_id) {
|
|
1246
|
+
$cfConfigDir = Join-Path $TargetUserProfile '.cloudflared'
|
|
1247
|
+
New-Item -Path $cfConfigDir -ItemType Directory -Force | Out-Null
|
|
1248
|
+
[System.IO.File]::WriteAllText((Join-Path $cfConfigDir "$($tunnelData.tunnel_id).json"), $tunnelData.credentials_json, [System.Text.UTF8Encoding]::new($false))
|
|
1249
|
+
Write-Detail "Tunnel credentials saved"
|
|
1250
|
+
[System.IO.File]::WriteAllText((Join-Path $cfConfigDir 'config.yml'), $tunnelData.config_yml, [System.Text.UTF8Encoding]::new($false))
|
|
1251
|
+
Write-Detail "Tunnel config saved"
|
|
1252
|
+
|
|
1253
|
+
# Register cloudflared as NSSM service (requires admin — will be skipped if non-admin)
|
|
1254
|
+
$cfExe = $null
|
|
1255
|
+
if (Get-Command cloudflared -ErrorAction SilentlyContinue) {
|
|
1256
|
+
$cfExe = (Get-Command cloudflared).Source
|
|
1257
|
+
} elseif (Test-Path (Join-Path $DataDir 'cloudflared.exe')) {
|
|
1258
|
+
$cfExe = Join-Path $DataDir 'cloudflared.exe'
|
|
1259
|
+
}
|
|
1260
|
+
if ($cfExe) {
|
|
1261
|
+
# Check if cloudflared service already registered
|
|
1262
|
+
$cfState = Get-ServiceState 'minion-cloudflared'
|
|
1263
|
+
if (-not $cfState) {
|
|
1264
|
+
Write-Warn "minion-cloudflared service not registered. Run 'setup' as admin to register tunnel service."
|
|
1265
|
+
} else {
|
|
1266
|
+
sc.exe start minion-cloudflared 2>&1 | Out-Null
|
|
1267
|
+
Write-Detail "minion-cloudflared started"
|
|
1268
|
+
}
|
|
1269
|
+
}
|
|
1270
|
+
}
|
|
1271
|
+
else {
|
|
1272
|
+
Write-Warn "Tunnel not configured for this minion"
|
|
1273
|
+
}
|
|
1274
|
+
}
|
|
1275
|
+
catch {
|
|
1276
|
+
Write-Warn "Failed to fetch tunnel credentials: $_"
|
|
1277
|
+
}
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
# Start services (uses sc.exe — works without admin via SDDL)
|
|
1281
|
+
$startStep = if ($SetupTunnel) { $totalSteps - 2 } else { $totalSteps - 2 }
|
|
1282
|
+
Write-Step ($totalSteps - 1) $totalSteps "Starting services..."
|
|
1283
|
+
# Start VNC/websockify if registered
|
|
1284
|
+
foreach ($svc in @('minion-vnc', 'minion-websockify')) {
|
|
1285
|
+
$svcState = Get-ServiceState $svc
|
|
1286
|
+
if ($svcState -and $svcState -ne 'RUNNING') {
|
|
1287
|
+
sc.exe start $svc 2>&1 | Out-Null
|
|
1288
|
+
Write-Detail "$svc started"
|
|
1289
|
+
}
|
|
1290
|
+
}
|
|
1291
|
+
Start-MinionService
|
|
1100
1292
|
|
|
1101
|
-
#
|
|
1102
|
-
Write-Step
|
|
1293
|
+
# Health check
|
|
1294
|
+
Write-Step $totalSteps $totalSteps "Health check..."
|
|
1103
1295
|
if (Invoke-HealthCheck) {
|
|
1104
|
-
Write-Detail "
|
|
1296
|
+
Write-Detail "Health check passed"
|
|
1105
1297
|
}
|
|
1106
1298
|
else {
|
|
1107
|
-
Write-Warn "
|
|
1299
|
+
Write-Warn "Health check failed"
|
|
1300
|
+
Write-Host " Check logs at: $LogDir"
|
|
1108
1301
|
}
|
|
1109
1302
|
|
|
1110
|
-
#
|
|
1111
|
-
Write-Step 4 4 "Notifying HQ..."
|
|
1303
|
+
# Notify HQ
|
|
1112
1304
|
try {
|
|
1113
1305
|
$lanIp = Get-LanIPAddress
|
|
1114
|
-
$headers = @{
|
|
1115
|
-
'Authorization' = "Bearer $ApiToken"
|
|
1116
|
-
'Content-Type' = 'application/json'
|
|
1117
|
-
}
|
|
1306
|
+
$headers = @{ 'Authorization' = "Bearer $ApiToken"; 'Content-Type' = 'application/json' }
|
|
1118
1307
|
$bodyHash = @{}
|
|
1119
|
-
if ($lanIp) {
|
|
1120
|
-
|
|
1121
|
-
$bodyHash['internal_ip_address'] = $lanIp
|
|
1122
|
-
} else {
|
|
1123
|
-
$bodyHash['internal_ip_address'] = [System.Net.Dns]::GetHostName()
|
|
1124
|
-
}
|
|
1308
|
+
if ($lanIp) { $bodyHash['ip_address'] = $lanIp; $bodyHash['internal_ip_address'] = $lanIp }
|
|
1309
|
+
else { $bodyHash['internal_ip_address'] = [System.Net.Dns]::GetHostName() }
|
|
1125
1310
|
$body = $bodyHash | ConvertTo-Json
|
|
1126
1311
|
Invoke-RestMethod -Uri "$HqUrl/api/minion/setup-complete" -Method Post -Headers $headers -Body $body -ErrorAction Stop | Out-Null
|
|
1127
|
-
|
|
1128
|
-
Write-Detail "HQ notified successfully (LAN IP: $lanIp)"
|
|
1129
|
-
} else {
|
|
1130
|
-
Write-Detail "HQ notified successfully (LAN IP detection failed, hostname sent)"
|
|
1131
|
-
}
|
|
1312
|
+
Write-Detail "HQ notified"
|
|
1132
1313
|
}
|
|
1133
1314
|
catch {
|
|
1134
|
-
Write-Detail "
|
|
1315
|
+
Write-Detail "HQ notification skipped"
|
|
1135
1316
|
}
|
|
1136
1317
|
|
|
1137
1318
|
Write-Host ""
|
|
1138
1319
|
Write-Host "=========================================" -ForegroundColor Green
|
|
1139
|
-
Write-Host "
|
|
1320
|
+
Write-Host " Configure Complete!" -ForegroundColor Green
|
|
1140
1321
|
Write-Host "=========================================" -ForegroundColor Green
|
|
1141
|
-
Write-Host "
|
|
1322
|
+
Write-Host ""
|
|
1323
|
+
Write-Host "Useful commands:"
|
|
1324
|
+
Write-Host " minion-cli-win status # Service status"
|
|
1325
|
+
Write-Host " minion-cli-win health # Health check"
|
|
1326
|
+
Write-Host " minion-cli-win daemons # All daemon status"
|
|
1327
|
+
Write-Host " minion-cli-win restart # Restart agent"
|
|
1328
|
+
Write-Host " minion-cli-win stop # Stop agent"
|
|
1329
|
+
Write-Host " Get-Content $(Join-Path $LogDir 'service-stdout.log') -Tail 50 # View logs"
|
|
1142
1330
|
}
|
|
1143
1331
|
|
|
1144
1332
|
# ============================================================
|
|
1145
|
-
#
|
|
1333
|
+
# Status / Health / Daemons / Diagnose
|
|
1146
1334
|
# ============================================================
|
|
1147
1335
|
|
|
1148
|
-
|
|
1149
|
-
'
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
}
|
|
1155
|
-
'reconfigure' {
|
|
1156
|
-
Invoke-Reconfigure
|
|
1157
|
-
}
|
|
1158
|
-
'start' {
|
|
1159
|
-
Start-MinionProcess
|
|
1160
|
-
}
|
|
1161
|
-
'stop' {
|
|
1162
|
-
Stop-MinionProcess
|
|
1163
|
-
}
|
|
1164
|
-
'restart' {
|
|
1165
|
-
Restart-MinionProcess
|
|
1336
|
+
function Show-Status {
|
|
1337
|
+
$state = Get-ServiceState 'minion-agent'
|
|
1338
|
+
if ($state) {
|
|
1339
|
+
Write-Host "minion-agent: $state"
|
|
1340
|
+
} else {
|
|
1341
|
+
Write-Host "minion-agent: not installed"
|
|
1166
1342
|
}
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
Write-
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
function Show-Daemons {
|
|
1346
|
+
foreach ($svc in @('minion-agent', 'minion-vnc', 'minion-websockify', 'minion-cloudflared')) {
|
|
1347
|
+
$state = Get-ServiceState $svc
|
|
1348
|
+
if ($state) {
|
|
1349
|
+
Write-Host "${svc}: $state"
|
|
1350
|
+
} else {
|
|
1351
|
+
Write-Host "${svc}: not installed"
|
|
1174
1352
|
}
|
|
1175
1353
|
}
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
catch {
|
|
1182
|
-
Write-Error "Health check failed. Is the agent running?"
|
|
1183
|
-
}
|
|
1354
|
+
}
|
|
1355
|
+
|
|
1356
|
+
function Show-Health {
|
|
1357
|
+
if (Invoke-HealthCheck -Retries 1 -DelaySeconds 0) {
|
|
1358
|
+
Write-Host "Agent is healthy" -ForegroundColor Green
|
|
1184
1359
|
}
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
$response = Invoke-RestMethod -Uri "$AgentUrl/api/daemons/status" -TimeoutSec 5
|
|
1188
|
-
$response | ConvertTo-Json -Depth 5
|
|
1189
|
-
}
|
|
1190
|
-
catch {
|
|
1191
|
-
Write-Error "Failed to get daemon status. Is the agent running?"
|
|
1192
|
-
}
|
|
1360
|
+
else {
|
|
1361
|
+
Write-Host "Agent is not responding" -ForegroundColor Red
|
|
1193
1362
|
}
|
|
1194
|
-
|
|
1195
|
-
Write-Host "Running diagnostics..."
|
|
1196
|
-
Write-Host ""
|
|
1197
|
-
try {
|
|
1198
|
-
$result = Invoke-RestMethod -Uri "$AgentUrl/api/diagnose" -TimeoutSec 15
|
|
1199
|
-
}
|
|
1200
|
-
catch {
|
|
1201
|
-
Write-Host "FAIL: Cannot reach minion agent at $AgentUrl" -ForegroundColor Red
|
|
1202
|
-
Write-Host " Is the agent running? Try: minion-cli-win start"
|
|
1203
|
-
exit 1
|
|
1204
|
-
}
|
|
1363
|
+
}
|
|
1205
1364
|
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1365
|
+
function Show-Diagnose {
|
|
1366
|
+
# Resolve target user profile for accurate diagnostics
|
|
1367
|
+
$TargetUserProfile = Resolve-TargetUserProfile
|
|
1368
|
+
$DataDir = Join-Path $TargetUserProfile '.minion'
|
|
1369
|
+
$EnvFile = Join-Path $DataDir '.env'
|
|
1370
|
+
$LogDir = Join-Path $DataDir 'logs'
|
|
1210
1371
|
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
if ($c.ok) {
|
|
1216
|
-
Write-Host " [PASS] $label $($c.details)" -ForegroundColor Green
|
|
1217
|
-
}
|
|
1218
|
-
else {
|
|
1219
|
-
Write-Host " [FAIL] $label $($c.details)" -ForegroundColor Red
|
|
1220
|
-
}
|
|
1221
|
-
}
|
|
1372
|
+
Write-Host "=========================================" -ForegroundColor Cyan
|
|
1373
|
+
Write-Host " Minion Diagnostics" -ForegroundColor Cyan
|
|
1374
|
+
Write-Host "=========================================" -ForegroundColor Cyan
|
|
1375
|
+
Write-Host ""
|
|
1222
1376
|
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1377
|
+
Write-Host "System:" -ForegroundColor Yellow
|
|
1378
|
+
Write-Host " OS: $([System.Environment]::OSVersion.VersionString)"
|
|
1379
|
+
Write-Host " PowerShell: $($PSVersionTable.PSVersion)"
|
|
1380
|
+
$isAdmin = ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
|
|
1381
|
+
Write-Host " Administrator: $isAdmin"
|
|
1382
|
+
Write-Host " Target user: $TargetUserProfile"
|
|
1383
|
+
Write-Host ""
|
|
1384
|
+
|
|
1385
|
+
Write-Host "Service Account:" -ForegroundColor Yellow
|
|
1386
|
+
$MinionSvcUser = 'minion'
|
|
1387
|
+
$svcUser = Get-LocalUser -Name $MinionSvcUser -ErrorAction SilentlyContinue
|
|
1388
|
+
if ($svcUser) {
|
|
1389
|
+
Write-Host " User: $MinionSvcUser (Enabled: $($svcUser.Enabled))"
|
|
1390
|
+
} else {
|
|
1391
|
+
Write-Host " User: NOT FOUND (services run as LocalSystem)" -ForegroundColor Yellow
|
|
1230
1392
|
}
|
|
1231
|
-
|
|
1232
|
-
|
|
1393
|
+
Write-Host ""
|
|
1394
|
+
|
|
1395
|
+
Write-Host "NSSM:" -ForegroundColor Yellow
|
|
1396
|
+
if ($NssmPath -and (Test-Path $NssmPath)) {
|
|
1397
|
+
Write-Host " Path: $NssmPath"
|
|
1398
|
+
$nssmVer = Invoke-Nssm version
|
|
1399
|
+
Write-Host " Version: $nssmVer"
|
|
1400
|
+
} else {
|
|
1401
|
+
Write-Host " NOT FOUND"
|
|
1233
1402
|
}
|
|
1403
|
+
Write-Host ""
|
|
1404
|
+
|
|
1405
|
+
Write-Host "Services:" -ForegroundColor Yellow
|
|
1406
|
+
Show-Daemons
|
|
1407
|
+
Write-Host ""
|
|
1408
|
+
|
|
1409
|
+
Write-Host "Software:" -ForegroundColor Yellow
|
|
1410
|
+
if (Test-CommandExists 'node') { Write-Host " Node.js: $(& node --version 2>$null)" }
|
|
1411
|
+
else { Write-Host " Node.js: NOT FOUND" }
|
|
1412
|
+
if (Test-CommandExists 'claude') { Write-Host " Claude Code: $(& claude --version 2>$null)" }
|
|
1413
|
+
else { Write-Host " Claude Code: NOT FOUND" }
|
|
1414
|
+
if (Test-CommandExists 'git') { Write-Host " Git: $(& git --version 2>$null)" }
|
|
1415
|
+
else { Write-Host " Git: NOT FOUND" }
|
|
1416
|
+
Write-Host ""
|
|
1417
|
+
|
|
1418
|
+
Write-Host "Config:" -ForegroundColor Yellow
|
|
1419
|
+
Write-Host " Data dir: $DataDir (exists: $(Test-Path $DataDir))"
|
|
1420
|
+
Write-Host " .env: $(Test-Path $EnvFile)"
|
|
1421
|
+
Write-Host " Log dir: $LogDir (exists: $(Test-Path $LogDir))"
|
|
1422
|
+
Write-Host ""
|
|
1423
|
+
|
|
1424
|
+
Write-Host "Health:" -ForegroundColor Yellow
|
|
1425
|
+
Show-Health
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1428
|
+
# ============================================================
|
|
1429
|
+
# Main dispatch
|
|
1430
|
+
# ============================================================
|
|
1431
|
+
|
|
1432
|
+
switch ($Command) {
|
|
1433
|
+
'setup' { Invoke-Setup }
|
|
1434
|
+
'configure' { Invoke-Configure }
|
|
1435
|
+
'reconfigure' { Invoke-Configure } # alias for backwards compatibility
|
|
1436
|
+
'uninstall' { Invoke-Uninstall }
|
|
1437
|
+
'start' { Start-MinionService }
|
|
1438
|
+
'stop' { Stop-MinionService }
|
|
1439
|
+
'restart' { Restart-MinionService }
|
|
1440
|
+
'status' { Show-Status }
|
|
1441
|
+
'health' { Show-Health }
|
|
1442
|
+
'daemons' { Show-Daemons }
|
|
1443
|
+
'diagnose' { Show-Diagnose }
|
|
1444
|
+
'version' { Write-Host "minion-cli-win v$CliVersion" }
|
|
1234
1445
|
'help' {
|
|
1235
|
-
Write-Host "Minion Agent CLI
|
|
1446
|
+
Write-Host "Minion Agent CLI for Windows v$CliVersion"
|
|
1236
1447
|
Write-Host ""
|
|
1237
|
-
Write-Host "Usage
|
|
1238
|
-
Write-Host " minion-cli-win setup [options] # Set up agent (auto-start on login)"
|
|
1239
|
-
Write-Host " minion-cli-win reconfigure [options] # Re-register with new HQ credentials"
|
|
1240
|
-
Write-Host " minion-cli-win uninstall [options] # Remove agent and configuration"
|
|
1241
|
-
Write-Host " minion-cli-win start # Start agent process"
|
|
1242
|
-
Write-Host " minion-cli-win stop # Stop agent process"
|
|
1243
|
-
Write-Host " minion-cli-win restart # Restart agent process"
|
|
1244
|
-
Write-Host " minion-cli-win status # Get current status"
|
|
1245
|
-
Write-Host " minion-cli-win health # Health check"
|
|
1246
|
-
Write-Host " minion-cli-win diagnose # Run full service diagnostics"
|
|
1247
|
-
Write-Host " minion-cli-win version # Show version"
|
|
1448
|
+
Write-Host "Usage: minion-cli-win <command> [options]"
|
|
1248
1449
|
Write-Host ""
|
|
1249
|
-
Write-Host "
|
|
1250
|
-
Write-Host "
|
|
1251
|
-
Write-Host "
|
|
1252
|
-
Write-Host " --api-token <TOKEN> API token (optional)"
|
|
1253
|
-
Write-Host " --setup-tunnel Set up cloudflared tunnel (requires --hq-url and --api-token)"
|
|
1450
|
+
Write-Host "Commands (require Administrator — first-time only):"
|
|
1451
|
+
Write-Host " setup Install software, register services, configure firewall"
|
|
1452
|
+
Write-Host " uninstall Remove minion agent and services"
|
|
1254
1453
|
Write-Host ""
|
|
1255
|
-
Write-Host "
|
|
1256
|
-
Write-Host "
|
|
1257
|
-
Write-Host "
|
|
1258
|
-
Write-Host "
|
|
1454
|
+
Write-Host "Commands (no admin required):"
|
|
1455
|
+
Write-Host " configure Connect to HQ, deploy skills, start services"
|
|
1456
|
+
Write-Host " start Start the minion-agent service"
|
|
1457
|
+
Write-Host " stop Stop the minion-agent service"
|
|
1458
|
+
Write-Host " restart Restart the minion-agent service"
|
|
1459
|
+
Write-Host " status Show agent service status"
|
|
1460
|
+
Write-Host " health Check agent health endpoint"
|
|
1461
|
+
Write-Host " daemons Show all daemon service status"
|
|
1462
|
+
Write-Host " diagnose Run diagnostics"
|
|
1463
|
+
Write-Host " version Show version"
|
|
1464
|
+
Write-Host " help Show this help"
|
|
1259
1465
|
Write-Host ""
|
|
1260
|
-
Write-Host "
|
|
1261
|
-
Write-Host " --
|
|
1262
|
-
Write-Host ""
|
|
1263
|
-
Write-Host "
|
|
1466
|
+
Write-Host "Configure options:"
|
|
1467
|
+
Write-Host " --hq-url <URL> HQ server URL"
|
|
1468
|
+
Write-Host " --minion-id <UUID> Minion ID"
|
|
1469
|
+
Write-Host " --api-token <TOKEN> API token"
|
|
1470
|
+
Write-Host " --setup-tunnel Enable Cloudflare Tunnel"
|
|
1264
1471
|
Write-Host ""
|
|
1265
|
-
Write-Host "
|
|
1266
|
-
Write-Host "
|
|
1472
|
+
Write-Host "Uninstall options:"
|
|
1473
|
+
Write-Host " --keep-data Preserve .env file"
|
|
1267
1474
|
}
|
|
1268
1475
|
}
|