@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.
@@ -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 --hq-url https://... --minion-id <UUID> --api-token <TOKEN>
5
- # minion-cli-win setup --setup-tunnel
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 CLI version from package.json
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
- # 1) websockify.exe on PATH
77
- if (Get-Command websockify -ErrorAction SilentlyContinue) {
78
- return @((Get-Command websockify).Source)
79
- }
80
- # 2) Look in Python Scripts directories
81
- if (Test-CommandExists 'python') {
82
- $scriptsDir = & python -c "import sysconfig; print(sysconfig.get_path('scripts'))" 2>$null
83
- if ($scriptsDir) {
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
- $npmRoot = & npm root -g 2>$null
113
- return Join-Path $npmRoot '@geekbeer\minion\win\server.js'
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 Start-MinionProcess {
162
- # Check if already running via PID file
163
- if (Test-Path $PidFile) {
164
- $existingPid = (Get-Content $PidFile -Raw).Trim()
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
- # Launch start-agent.ps1 as a hidden background process
179
- Start-Process powershell -ArgumentList "-ExecutionPolicy Bypass -WindowStyle Hidden -File `"$StartAgentScript`"" `
180
- -WindowStyle Hidden
181
- Start-Sleep -Seconds 2
288
+ # ============================================================
289
+ # Service SDDL helpers (grant control to non-admin user)
290
+ # ============================================================
182
291
 
183
- if (Test-Path $PidFile) {
184
- $newPid = (Get-Content $PidFile -Raw).Trim()
185
- Write-Host "minion-agent started (PID: $newPid)"
186
- } else {
187
- Write-Host "minion-agent starting... (check logs at $LogDir)"
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
- function Stop-MinionProcess {
192
- if (-not (Test-Path $PidFile)) {
193
- Write-Host "minion-agent is not running (no PID file)"
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
- $agentPid = (Get-Content $PidFile -Raw).Trim()
197
- $proc = Get-Process -Id $agentPid -ErrorAction SilentlyContinue
198
- if ($proc) {
199
- # Graceful shutdown via HTTP API (sends offline heartbeat to HQ)
200
- # This mirrors Linux's SIGTERM → shutdown() flow.
201
- $token = ''
202
- if (Test-Path $EnvFile) {
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
- # Force kill remaining processes (node, websockify, cloudflared)
217
- Get-CimInstance Win32_Process -Filter "ParentProcessId = $agentPid" -ErrorAction SilentlyContinue |
218
- ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }
219
- Stop-Process -Id $agentPid -Force -ErrorAction SilentlyContinue
220
- Write-Host "minion-agent stopped (PID: $agentPid)"
221
- } else {
222
- Write-Host "minion-agent was not running (stale PID file)"
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
- Remove-Item $PidFile -Force -ErrorAction SilentlyContinue
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-MinionProcess {
228
- Stop-MinionProcess
384
+ function Restart-MinionService {
385
+ Stop-MinionService
229
386
  Start-Sleep -Seconds 2
230
- Start-MinionProcess
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 " - Register auto-start via Startup folder"
265
- Write-Host " - Overwrite Claude Code settings (permissions, rules, skills)"
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
- # Load existing .env for redeploy scenario
278
- $envValues = @{}
279
- if (-not $HqUrl -and -not $MinionId -and -not $ApiToken -and (Test-Path $EnvFile)) {
280
- Write-Host "Reading existing .env values (redeploy mode)..."
281
- $envValues = Read-EnvFile $EnvFile
282
- if (-not $HqUrl -and $envValues['HQ_URL']) { $HqUrl = $envValues['HQ_URL'] }
283
- if (-not $MinionId -and $envValues['MINION_ID']) { $MinionId = $envValues['MINION_ID'] }
284
- if (-not $ApiToken -and $envValues['API_TOKEN']) { $ApiToken = $envValues['API_TOKEN'] }
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 (no admin required)"
291
- Write-Host "User: $env:USERNAME ($env:USERPROFILE)"
464
+ Write-Host "Platform: Windows (Administrator mode)"
465
+ Write-Host "User: $env:USERNAME (target: $TargetUserProfile)"
292
466
  Write-Host "Data: $DataDir"
293
- if ($HqUrl) {
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 config directory and .env
407
- Write-Step 4 $totalSteps "Creating config directory and .env..."
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
- $envValues = @{
411
- 'AGENT_PORT' = '8080'
412
- 'MINION_USER' = $env:USERNAME
413
- 'REFLECTION_TIME' = '03:00'
414
- }
415
- if ($HqUrl) { $envValues['HQ_URL'] = $HqUrl }
416
- if ($ApiToken) { $envValues['API_TOKEN'] = $ApiToken }
417
- if ($MinionId) { $envValues['MINION_ID'] = $MinionId }
418
- Write-EnvFile $EnvFile $envValues
419
- Write-Detail "$EnvFile generated"
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 5: Install node-pty (required for Windows terminal management)
422
- Write-Step 5 $totalSteps "Installing terminal support (node-pty)..."
423
- $npmRoot = & npm root -g 2>$null
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 6: Generate start-agent.ps1 and register auto-start
461
- Write-Step 6 $totalSteps "Registering auto-start..."
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
- # Generate start-agent.ps1 (watchdog script)
473
- $startAgentContent = @"
474
- # Auto-generated by minion-cli-win setup
475
- # Starts minion agent with watchdog (auto-restart on crash)
476
-
477
- `$DataDir = '$DataDir'
478
- `$EnvFile = Join-Path `$DataDir '.env'
479
- `$PidFile = Join-Path `$DataDir 'minion-agent.pid'
480
- `$LogDir = Join-Path `$DataDir 'logs'
481
- `$ServerJs = '$serverJs'
482
-
483
- # Load .env
484
- if (Test-Path `$EnvFile) {
485
- Get-Content `$EnvFile | ForEach-Object {
486
- `$line = `$_.Trim()
487
- if (`$line -and -not `$line.StartsWith('#') -and `$line.Contains('=')) {
488
- `$idx = `$line.IndexOf('=')
489
- `$key = `$line.Substring(0, `$idx).Trim()
490
- `$value = `$line.Substring(`$idx + 1).Trim()
491
- [Environment]::SetEnvironmentVariable(`$key, `$value, 'Process')
492
- }
493
- }
494
- }
495
-
496
- New-Item -Path `$LogDir -ItemType Directory -Force | Out-Null
497
-
498
- # Startup log for debugging VNC/websockify/cloudflared launch
499
- `$StartupLog = Join-Path `$LogDir 'startup.log'
500
- function Write-StartupLog {
501
- param([string]`$Message)
502
- `$ts = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
503
- Add-Content -Path `$StartupLog -Value "[`$ts] `$Message"
504
- }
505
- Write-StartupLog "=== start-agent.ps1 starting (PID: `$PID) ==="
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 " Downloading TightVNC portable..."
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
- New-Item -Path $vncPortableDir -ItemType Directory -Force | Out-Null
673
- # Extract MSI without admin (administrative install = extract only)
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 $vncPortablePath) {
678
- Write-Detail "TightVNC portable extracted to $vncPortableDir"
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 extraction failed. Check $vncPortableDir"
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 for no-auth localhost-only mode via registry
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 ((Test-Path $vncSystemPath) -or (Test-Path $vncPortablePath)) {
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
- # Step 9: Setup websockify (WebSocket proxy for VNC)
715
- Write-Step 9 $totalSteps "Setting up websockify..."
716
- if (Get-WebsockifyCommand) {
717
- Write-Detail "websockify already installed"
718
- }
719
- else {
720
- # Ensure Python is installed (Microsoft Store stub doesn't include pip)
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
- # Microsoft Store stub returns just "Python" with no version number
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
- # Deploy bundled skills
831
- Write-Host ""
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
- # Deploy bundled rules
844
- $claudeRulesDir = Join-Path $env:USERPROFILE '.claude\rules'
845
- $bundledRulesDir = Join-Path $minionPkgDir 'rules'
846
- if (Test-Path $bundledRulesDir) {
847
- New-Item -Path $claudeRulesDir -ItemType Directory -Force | Out-Null
848
- $coreRule = Join-Path $bundledRulesDir 'core.md'
849
- if (Test-Path $coreRule) {
850
- Copy-Item $coreRule -Destination (Join-Path $claudeRulesDir 'core.md') -Force
851
- Write-Detail "Deployed rules: core.md"
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
- # Start (or restart) agent so updated .env takes effect
856
- Write-Step $totalSteps $totalSteps "Starting agent..."
857
- Restart-MinionProcess
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
- # Health check
860
- if (Invoke-HealthCheck) {
861
- Write-Detail "Health check passed"
862
- }
863
- else {
864
- Write-Warn "Health check failed after 5 attempts"
865
- Write-Host " Check logs at: $LogDir"
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
- # Firewall notice
869
- Write-Host ""
870
- Write-Host "NOTE: Firewall rules are NOT configured automatically (requires admin)." -ForegroundColor Yellow
871
- Write-Host " If you need LAN access without Cloudflare Tunnel, ask an administrator to run:" -ForegroundColor Yellow
872
- Write-Host " New-NetFirewallRule -DisplayName 'Minion Agent' -Direction Inbound -Protocol TCP -LocalPort 8080 -Action Allow" -ForegroundColor Gray
873
- Write-Host " New-NetFirewallRule -DisplayName 'Minion Terminal' -Direction Inbound -Protocol TCP -LocalPort 7681 -Action Allow" -ForegroundColor Gray
874
- Write-Host " New-NetFirewallRule -DisplayName 'Minion VNC' -Direction Inbound -Protocol TCP -LocalPort 6080 -Action Allow" -ForegroundColor Gray
875
- Write-Host " Or use Cloudflare Tunnel (--setup-tunnel) to avoid firewall configuration." -ForegroundColor Yellow
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
- # Notify HQ
878
- if ($HqUrl -and $ApiToken) {
879
- Write-Host ""
880
- Write-Host "Notifying HQ of setup completion..."
881
- try {
882
- $lanIp = Get-LanIPAddress
883
- $headers = @{
884
- 'Authorization' = "Bearer $ApiToken"
885
- 'Content-Type' = 'application/json'
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 "Useful commands:"
913
- Write-Host " minion-cli-win status # Agent status"
914
- Write-Host " minion-cli-win health # Health check"
915
- Write-Host " minion-cli-win daemons # Daemon status"
916
- Write-Host " minion-cli-win restart # Restart agent"
917
- Write-Host " minion-cli-win stop # Stop agent"
918
- Write-Host " Get-Content $(Join-Path $LogDir 'service-stdout.log') -Tail 50 # View logs"
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 "This will remove the minion agent and all related configuration."
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 = 5
947
-
948
- # Step 1: Stop agent and child processes
949
- Write-Step 1 $totalSteps "Stopping agent and related processes..."
950
- Stop-MinionProcess
951
-
952
- # Stop VNC server
953
- $vncProc = Get-Process -Name tvnserver -ErrorAction SilentlyContinue
954
- if ($vncProc) {
955
- Stop-Process -Name tvnserver -Force -ErrorAction SilentlyContinue
956
- Write-Detail "TightVNC server stopped"
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
- # Stop websockify
960
- $wsProc = Get-Process -Name websockify -ErrorAction SilentlyContinue
961
- if ($wsProc) {
962
- Stop-Process -Name websockify -Force -ErrorAction SilentlyContinue
963
- Write-Detail "websockify stopped"
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
- # Stop cloudflared
967
- $cfProc = Get-Process -Name cloudflared -ErrorAction SilentlyContinue
968
- if ($cfProc) {
969
- Stop-Process -Name cloudflared -Force -ErrorAction SilentlyContinue
970
- Write-Detail "cloudflared stopped"
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
- Write-Detail "All processes stopped"
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 $shortcutPath"
1041
+ Write-Detail "Removed legacy Startup shortcut"
982
1042
  }
983
1043
  else {
984
- Write-Detail "Startup shortcut not found, skipping"
1044
+ Write-Detail "No legacy Startup shortcut found"
985
1045
  }
986
1046
 
987
- # Step 3: Remove data directory (or keep .env)
988
- Write-Step 3 $totalSteps "Removing data directory..."
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 4: Remove deployed skills and rules
1015
- Write-Step 4 $totalSteps "Removing deployed skills and rules..."
1016
- $claudeSkillsDir = Join-Path $env:USERPROFILE '.claude\skills'
1017
- $claudeRulesDir = Join-Path $env:USERPROFILE '.claude\rules'
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
- # Remove bundled skills (only skills that ship with the package)
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 5: Remove Cloudflare Tunnel configuration
1043
- Write-Step 5 $totalSteps "Removing Cloudflare Tunnel configuration..."
1044
- $cfConfigDir = Join-Path $env:USERPROFILE '.cloudflared'
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
- # Reconfigure
1140
+ # Configure (HQ connection — no admin required)
1069
1141
  # ============================================================
1070
1142
 
1071
- function Invoke-Reconfigure {
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
- if (-not (Test-Path $EnvFile)) {
1077
- Write-Error "$EnvFile not found. Run 'setup' first."
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 Reconfigure" -ForegroundColor Cyan
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: Update .env
1089
- Write-Step 1 4 "Updating .env credentials..."
1090
- $existing = Read-EnvFile $EnvFile
1091
- $existing['HQ_URL'] = $HqUrl
1092
- $existing['API_TOKEN'] = $ApiToken
1093
- $existing['MINION_ID'] = $MinionId
1094
- Write-EnvFile $EnvFile $existing
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: Restart agent process
1098
- Write-Step 2 4 "Restarting minion-agent..."
1099
- Restart-MinionProcess
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
- # Step 3: Health check
1102
- Write-Step 3 4 "Verifying agent health..."
1293
+ # Health check
1294
+ Write-Step $totalSteps $totalSteps "Health check..."
1103
1295
  if (Invoke-HealthCheck) {
1104
- Write-Detail "Agent is healthy"
1296
+ Write-Detail "Health check passed"
1105
1297
  }
1106
1298
  else {
1107
- Write-Warn "Agent health check failed after 5 attempts"
1299
+ Write-Warn "Health check failed"
1300
+ Write-Host " Check logs at: $LogDir"
1108
1301
  }
1109
1302
 
1110
- # Step 4: Notify HQ
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
- $bodyHash['ip_address'] = $lanIp
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
- if ($lanIp) {
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 "Skipped (heartbeat will notify HQ within 30s)"
1315
+ Write-Detail "HQ notification skipped"
1135
1316
  }
1136
1317
 
1137
1318
  Write-Host ""
1138
1319
  Write-Host "=========================================" -ForegroundColor Green
1139
- Write-Host " Reconfigure Complete!" -ForegroundColor Green
1320
+ Write-Host " Configure Complete!" -ForegroundColor Green
1140
1321
  Write-Host "=========================================" -ForegroundColor Green
1141
- Write-Host "The minion should appear online in HQ shortly."
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
- # Main command dispatch
1333
+ # Status / Health / Daemons / Diagnose
1146
1334
  # ============================================================
1147
1335
 
1148
- switch ($Command) {
1149
- 'setup' {
1150
- Invoke-Setup
1151
- }
1152
- 'uninstall' {
1153
- Invoke-Uninstall
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
- 'status' {
1168
- try {
1169
- $response = Invoke-RestMethod -Uri "$AgentUrl/api/status" -TimeoutSec 5
1170
- $response | ConvertTo-Json -Depth 5
1171
- }
1172
- catch {
1173
- Write-Error "Failed to get status. Is the agent running?"
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
- 'health' {
1177
- try {
1178
- $response = Invoke-RestMethod -Uri "$AgentUrl/api/health" -TimeoutSec 5
1179
- $response | ConvertTo-Json -Depth 5
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
- 'daemons' {
1186
- try {
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
- 'diagnose' {
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
- $ver = if ($result.version) { $result.version } else { '?' }
1207
- $plat = if ($result.platform) { $result.platform } else { '?' }
1208
- Write-Host "=== Minion Diagnostics (v$ver, $plat) ==="
1209
- Write-Host ""
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
- $checkNames = @('agent', 'hq', 'tunnel', 'vnc', 'terminal', 'llm', 'env')
1212
- foreach ($check in $checkNames) {
1213
- $c = $result.checks.$check
1214
- $label = $check.ToUpper().PadRight(10)
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
- Write-Host ""
1224
- if ($result.summary -eq 'ALL OK') {
1225
- Write-Host $result.summary -ForegroundColor Green
1226
- }
1227
- else {
1228
- Write-Host $result.summary -ForegroundColor Yellow
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
- 'version' {
1232
- Write-Host "@geekbeer/minion v$CliVersion (Windows)"
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 (@geekbeer/minion) v$CliVersion (Windows)" -ForegroundColor Cyan
1446
+ Write-Host "Minion Agent CLI for Windows v$CliVersion"
1236
1447
  Write-Host ""
1237
- Write-Host "Usage (no admin required):"
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 "Setup options:"
1250
- Write-Host " --hq-url <URL> HQ server URL (optional, omit for standalone mode)"
1251
- Write-Host " --minion-id <UUID> Minion ID (optional)"
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 "Reconfigure options:"
1256
- Write-Host " --hq-url <URL> HQ server URL (required)"
1257
- Write-Host " --minion-id <UUID> Minion ID (required)"
1258
- Write-Host " --api-token <TOKEN> API token (required)"
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 "Uninstall options:"
1261
- Write-Host " --keep-data Keep .env file (preserve credentials)"
1262
- Write-Host ""
1263
- Write-Host "Data directory: $DataDir"
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 "Environment:"
1266
- Write-Host " MINION_AGENT_URL Agent URL (default: http://localhost:8080)"
1472
+ Write-Host "Uninstall options:"
1473
+ Write-Host " --keep-data Preserve .env file"
1267
1474
  }
1268
1475
  }