@geekbeer/minion 3.5.6 → 3.5.30

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@geekbeer/minion",
3
- "version": "3.5.6",
3
+ "version": "3.5.30",
4
4
  "description": "AI Agent runtime for Minion - manages status and skill deployment on VPS",
5
5
  "main": "linux/server.js",
6
6
  "bin": {
@@ -10,6 +10,7 @@
10
10
  "hq-win": "./win/bin/hq-win.js"
11
11
  },
12
12
  "files": [
13
+ "postinstall.js",
13
14
  "core/",
14
15
  "linux/",
15
16
  "win/",
@@ -22,7 +23,8 @@
22
23
  ],
23
24
  "scripts": {
24
25
  "start": "node linux/server.js",
25
- "start:win": "node win/server.js"
26
+ "start:win": "node win/server.js",
27
+ "postinstall": "node postinstall.js"
26
28
  },
27
29
  "dependencies": {
28
30
  "better-sqlite3": "^11.0.0",
package/postinstall.js ADDED
@@ -0,0 +1,18 @@
1
+ #!/usr/bin/env node
2
+ // postinstall.js — Re-apply file ACLs on Windows after npm install -g.
3
+ // On Linux this is a no-op (permissions are not affected by reinstall).
4
+
5
+ if (process.platform !== 'win32') process.exit(0);
6
+
7
+ const { spawnSync } = require('child_process');
8
+ const path = require('path');
9
+
10
+ const ps1 = path.join(__dirname, 'win', 'postinstall.ps1');
11
+ const result = spawnSync('powershell.exe', [
12
+ '-ExecutionPolicy', 'Bypass',
13
+ '-NoProfile',
14
+ '-NoLogo',
15
+ '-File', ps1,
16
+ ], { stdio: 'inherit', timeout: 30_000 });
17
+
18
+ process.exit(result.status || 0);
@@ -35,6 +35,20 @@ function getNssmPath() {
35
35
  return vendorNssm
36
36
  }
37
37
 
38
+ /**
39
+ * Derive the npm global prefix from the nssm.exe path.
40
+ * nssm is bundled at: <npm-prefix>/node_modules/@geekbeer/minion/win/vendor/nssm.exe
41
+ * So the prefix is the ancestor directory containing node_modules.
42
+ * @param {string} nssmPath - Absolute path to nssm.exe
43
+ * @returns {string|null} npm global prefix, or null if not derivable
44
+ */
45
+ function getNpmPrefix(nssmPath) {
46
+ const parts = nssmPath.split(path.sep)
47
+ const nmIndex = parts.lastIndexOf('node_modules')
48
+ if (nmIndex > 0) return parts.slice(0, nmIndex).join(path.sep)
49
+ return null
50
+ }
51
+
38
52
  /**
39
53
  * Detect Windows process manager. Always returns 'nssm'.
40
54
  * @returns {'nssm'}
@@ -56,7 +70,7 @@ function buildGracefulStopBlock(agentPort, apiToken) {
56
70
  return [
57
71
  ` try {`,
58
72
  ` Invoke-RestMethod -Uri 'http://localhost:${agentPort}/api/shutdown' -Method POST ` +
59
- `-ContentType 'application/json' -Headers @{ Authorization = 'Bearer ${apiToken}' } -TimeoutSec 5 | Out-Null`,
73
+ `-Body '{}' -ContentType 'application/json' -Headers @{ Authorization = 'Bearer ${apiToken}' } -TimeoutSec 5 | Out-Null`,
60
74
  ` Log 'Graceful shutdown requested, waiting for offline heartbeat...'`,
61
75
  ` Start-Sleep -Seconds 4`,
62
76
  ` } catch {`,
@@ -71,6 +85,11 @@ function buildGracefulStopBlock(agentPort, apiToken) {
71
85
  * 2. Stop service via NSSM
72
86
  * 3. Run npm install -g
73
87
  * 4. Start service via NSSM
88
+ * 5. Remove the temporary updater service (self-cleanup)
89
+ *
90
+ * This script is registered as a temporary NSSM service ("minion-update")
91
+ * so it runs independently of the minion-agent service and survives
92
+ * the agent's stop/restart cycle.
74
93
  *
75
94
  * @param {string} npmInstallCmd - The npm install command to run
76
95
  * @param {string} nssmPath - Absolute path to nssm.exe
@@ -81,34 +100,63 @@ function buildGracefulStopBlock(agentPort, apiToken) {
81
100
  */
82
101
  function buildUpdateScript(npmInstallCmd, nssmPath, scriptName = 'update-agent', agentPort = 8080, apiToken = '') {
83
102
  const dataDir = path.join(os.homedir(), '.minion')
103
+ const homeDir = os.homedir()
104
+ const npmPrefix = getNpmPrefix(nssmPath)
84
105
  const scriptPath = path.join(dataDir, `${scriptName}.ps1`)
85
- const logPath = path.join(dataDir, `${scriptName}.log`)
86
- const nssm = nssmPath.replace(/\\/g, '\\\\')
106
+ const logDir = path.join(dataDir, 'logs')
107
+ const logPath = path.join(logDir, `${scriptName}.log`)
108
+
109
+ // Append --prefix to npm command so it installs to the correct global directory
110
+ const fullNpmCmd = npmPrefix
111
+ ? `${npmInstallCmd} --prefix "${npmPrefix}"`
112
+ : npmInstallCmd
87
113
 
88
114
  const gracefulStop = buildGracefulStopBlock(agentPort, apiToken)
89
115
 
90
116
  const ps1 = [
117
+ `# Override LocalSystem's default profile so npm uses the correct paths`,
118
+ `$env:USERPROFILE = '${homeDir}'`,
119
+ `$env:HOME = '${homeDir}'`,
91
120
  `$ErrorActionPreference = 'Stop'`,
92
- `$logFile = '${logPath.replace(/\\/g, '\\\\')}'`,
121
+ `# Use nssm.exe from data dir (copied there to avoid EBUSY on package files)`,
122
+ `$nssm = '${path.join(dataDir, 'nssm.exe')}'`,
123
+ `$logFile = '${logPath}'`,
93
124
  `function Log($msg) { "$(Get-Date -Format o) $msg" | Out-File -Append $logFile }`,
94
125
  `Log 'Update started'`,
95
126
  `try {`,
96
127
  ` Log 'Requesting graceful shutdown...'`,
97
128
  gracefulStop,
98
129
  ` Log 'Stopping service via NSSM...'`,
99
- ` & '${nssm}' stop minion-agent`,
130
+ ` & $nssm stop minion-agent`,
100
131
  ` Start-Sleep -Seconds 3`,
132
+ ` # Retry npm install up to 5 times (Windows may hold file locks briefly after service stop)`,
101
133
  ` Log 'Installing package...'`,
102
- ` $out = & cmd /c "${npmInstallCmd} 2>&1"`,
103
- ` Log "npm output: $out"`,
104
- ` if ($LASTEXITCODE -ne 0) { throw "npm install failed (exit code $LASTEXITCODE)" }`,
134
+ ` $installed = $false`,
135
+ ` for ($attempt = 1; $attempt -le 5; $attempt++) {`,
136
+ ` $out = & cmd /c "${fullNpmCmd} 2>&1"`,
137
+ ` if ($LASTEXITCODE -eq 0) {`,
138
+ ` Log "npm output: $out"`,
139
+ ` $installed = $true`,
140
+ ` break`,
141
+ ` }`,
142
+ ` Log "npm install attempt $attempt failed (exit $LASTEXITCODE): $out"`,
143
+ ` if ($attempt -lt 5) {`,
144
+ ` Log "Retrying in 10 seconds..."`,
145
+ ` Start-Sleep -Seconds 10`,
146
+ ` }`,
147
+ ` }`,
148
+ ` if (-not $installed) { throw "npm install failed after 5 attempts" }`,
105
149
  ` Log 'Starting service...'`,
106
- ` & '${nssm}' start minion-agent`,
150
+ ` & $nssm start minion-agent`,
107
151
  ` Log 'Update completed successfully'`,
108
152
  `} catch {`,
109
153
  ` Log "Update failed: $_"`,
110
154
  ` Log 'Attempting to start service anyway...'`,
111
- ` & '${nssm}' start minion-agent`,
155
+ ` & $nssm start minion-agent`,
156
+ `} finally {`,
157
+ ` Log 'Cleaning up updater service...'`,
158
+ ` & $nssm stop minion-update confirm 2>$null`,
159
+ ` & $nssm remove minion-update confirm 2>$null`,
112
160
  `}`,
113
161
  ].join('\n')
114
162
 
@@ -122,35 +170,47 @@ function buildUpdateScript(npmInstallCmd, nssmPath, scriptName = 'update-agent',
122
170
  * Generate a temporary PowerShell restart script:
123
171
  * 1. Graceful shutdown via HTTP API (offline heartbeat)
124
172
  * 2. Restart service via NSSM
173
+ * 3. Remove the temporary updater service (self-cleanup)
174
+ *
175
+ * Registered as a temporary NSSM service ("minion-update") so the restart
176
+ * process is independent of the minion-agent service.
125
177
  *
126
178
  * @param {string} nssmPath - Absolute path to nssm.exe
127
179
  * @param {number} agentPort - The agent's HTTP port
128
180
  * @param {string} apiToken - The agent's API token
129
181
  * @returns {string} - Path to the generated restart script (.ps1)
130
182
  */
131
- function buildRestartScript(nssmPath, agentPort, apiToken) {
183
+ function buildRestartScript(_nssmPath, agentPort, apiToken) {
132
184
  const dataDir = path.join(os.homedir(), '.minion')
185
+ const homeDir = os.homedir()
133
186
  const scriptPath = path.join(dataDir, 'restart-agent.ps1')
134
- const logPath = path.join(dataDir, 'restart-agent.log')
135
- const nssm = nssmPath.replace(/\\/g, '\\\\')
187
+ const logDir = path.join(dataDir, 'logs')
188
+ const logPath = path.join(logDir, 'restart-agent.log')
136
189
 
137
190
  const gracefulStop = buildGracefulStopBlock(agentPort, apiToken)
138
191
 
139
192
  const ps1 = [
193
+ `$env:USERPROFILE = '${homeDir}'`,
194
+ `$env:HOME = '${homeDir}'`,
140
195
  `$ErrorActionPreference = 'Stop'`,
141
- `$logFile = '${logPath.replace(/\\/g, '\\\\')}'`,
196
+ `$nssm = '${path.join(dataDir, 'nssm.exe')}'`,
197
+ `$logFile = '${logPath}'`,
142
198
  `function Log($msg) { "$(Get-Date -Format o) $msg" | Out-File -Append $logFile }`,
143
199
  `Log 'Restart started'`,
144
200
  `try {`,
145
201
  ` Log 'Requesting graceful shutdown...'`,
146
202
  gracefulStop,
147
203
  ` Log 'Restarting service via NSSM...'`,
148
- ` & '${nssm}' restart minion-agent`,
204
+ ` & $nssm restart minion-agent`,
149
205
  ` Log 'Restart completed successfully'`,
150
206
  `} catch {`,
151
207
  ` Log "Restart failed: $_"`,
152
208
  ` Log 'Attempting to start service...'`,
153
- ` & '${nssm}' start minion-agent`,
209
+ ` & $nssm start minion-agent`,
210
+ `} finally {`,
211
+ ` Log 'Cleaning up updater service...'`,
212
+ ` & $nssm stop minion-update confirm 2>$null`,
213
+ ` & $nssm remove minion-update confirm 2>$null`,
154
214
  `}`,
155
215
  ].join('\n')
156
216
 
@@ -162,9 +222,15 @@ function buildRestartScript(nssmPath, agentPort, apiToken) {
162
222
 
163
223
  /**
164
224
  * Build allowed commands for NSSM-based service management.
225
+ *
226
+ * Deferred commands (update/restart) use a temporary NSSM service
227
+ * ("minion-update") to run PowerShell scripts independently of the
228
+ * minion-agent service. This allows the script to stop, update, and
229
+ * restart minion-agent without being killed in the process.
230
+ *
165
231
  * @param {string} _procMgr - Ignored (always 'nssm')
166
232
  * @param {{ AGENT_PORT?: number, API_TOKEN?: string }} [agentConfig] - Agent config
167
- * @returns {Record<string, { description: string; command?: string; spawnArgs?: [string, string[]]; deferred?: boolean }>}
233
+ * @returns {Record<string, { description: string; command?: string; nssmService?: { scriptPath: string }; deferred?: boolean }>}
168
234
  */
169
235
  function buildAllowedCommands(_procMgr, agentConfig = {}) {
170
236
  const commands = {}
@@ -174,28 +240,29 @@ function buildAllowedCommands(_procMgr, agentConfig = {}) {
174
240
 
175
241
  commands['restart-agent'] = {
176
242
  description: 'Restart the minion agent service',
177
- spawnArgs: ['powershell', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command',
178
- `& '${buildRestartScript(nssmPath, agentPort, apiToken)}'`]],
243
+ nssmService: { scriptPath: buildRestartScript(nssmPath, agentPort, apiToken) },
179
244
  deferred: true,
180
245
  }
181
246
 
182
247
  commands['update-agent'] = {
183
248
  description: 'Update @geekbeer/minion to latest version and restart',
184
- spawnArgs: ['powershell', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command',
185
- `& '${buildUpdateScript(
249
+ nssmService: {
250
+ scriptPath: buildUpdateScript(
186
251
  'npm install -g @geekbeer/minion@latest',
187
252
  nssmPath, 'update-agent', agentPort, apiToken,
188
- )}'`]],
253
+ ),
254
+ },
189
255
  deferred: true,
190
256
  }
191
257
 
192
258
  commands['update-agent-dev'] = {
193
259
  description: 'Update @geekbeer/minion from Verdaccio (dev) and restart',
194
- spawnArgs: ['powershell', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command',
195
- `& '${buildUpdateScript(
260
+ nssmService: {
261
+ scriptPath: buildUpdateScript(
196
262
  'npm install -g @geekbeer/minion@latest --registry http://verdaccio:4873',
197
263
  nssmPath, 'update-agent-dev', agentPort, apiToken,
198
- )}'`]],
264
+ ),
265
+ },
199
266
  deferred: true,
200
267
  }
201
268
 
@@ -360,7 +360,13 @@ function Stop-MinionService {
360
360
  Write-Host "minion-agent: not installed" -ForegroundColor Red
361
361
  return
362
362
  }
363
- # Graceful shutdown via HTTP API first (sends offline heartbeat to HQ)
363
+ if ($state -eq 'STOPPED') {
364
+ Write-Host "minion-agent service is already stopped"
365
+ return
366
+ }
367
+ # Trigger graceful shutdown (sends offline heartbeat to HQ) but do NOT wait
368
+ # for the process to exit — otherwise NSSM sees the exit as a crash and
369
+ # restarts the process before sc.exe stop can run.
364
370
  $token = ''
365
371
  if (Test-Path $EnvFile) {
366
372
  $envVars = Read-EnvFile $EnvFile
@@ -369,13 +375,18 @@ function Stop-MinionService {
369
375
  try {
370
376
  $headers = @{}
371
377
  if ($token) { $headers['Authorization'] = "Bearer $token" }
372
- Invoke-RestMethod -Uri "$AgentUrl/api/shutdown" -Method POST -ContentType 'application/json' -Headers $headers -TimeoutSec 5 | Out-Null
373
- Write-Host "Graceful shutdown requested, waiting..."
374
- Start-Sleep -Seconds 4
378
+ Invoke-RestMethod -Uri "$AgentUrl/api/shutdown" -Method POST -ContentType 'application/json' -Headers $headers -TimeoutSec 3 | Out-Null
375
379
  } catch {
376
- Write-Host "Graceful shutdown skipped (agent may not be running)"
380
+ # Agent may not be responding — sc.exe stop will handle it
377
381
  }
382
+ # Immediately stop via NSSM (prevents auto-restart, sends kill if still alive)
378
383
  sc.exe stop minion-agent 2>&1 | Out-Null
384
+ # Wait for service to fully stop (up to 10 seconds)
385
+ for ($i = 0; $i -lt 10; $i++) {
386
+ $s = Get-ServiceState 'minion-agent'
387
+ if ($s -eq 'STOPPED') { break }
388
+ Start-Sleep -Seconds 1
389
+ }
379
390
  Write-Host "minion-agent service stopped"
380
391
  }
381
392
 
@@ -1326,11 +1337,19 @@ function Invoke-Configure {
1326
1337
  # ============================================================
1327
1338
 
1328
1339
  function Show-Status {
1329
- $state = Get-ServiceState 'minion-agent'
1330
- if ($state) {
1331
- Write-Host "minion-agent: $state"
1332
- } else {
1333
- Write-Host "minion-agent: not installed"
1340
+ try {
1341
+ $response = Invoke-RestMethod -Uri "$AgentUrl/api/status" -TimeoutSec 5 -ErrorAction Stop
1342
+ # Pretty-print the JSON response
1343
+ $response | ConvertTo-Json -Depth 5 | Write-Host
1344
+ }
1345
+ catch {
1346
+ # API unreachable — fall back to service state
1347
+ $state = Get-ServiceState 'minion-agent'
1348
+ if ($state) {
1349
+ Write-Host "minion-agent: $state (API unreachable)"
1350
+ } else {
1351
+ Write-Host "minion-agent: not installed"
1352
+ }
1334
1353
  }
1335
1354
  }
1336
1355
 
@@ -0,0 +1,82 @@
1
+ # postinstall.ps1 — Re-apply file ACLs after npm install -g replaces the package directory.
2
+ # Called automatically from postinstall.js on Windows.
3
+ # Requires Administrator privileges (npm install -g typically runs as admin).
4
+ # Exit silently if not admin or if setup has never been run (no .target-user-profile).
5
+
6
+ $ErrorActionPreference = 'SilentlyContinue'
7
+
8
+ # --- Locate setup metadata ---------------------------------------------------
9
+ # During setup, .minion/.target-user-profile is written to the target user's
10
+ # home directory. We need to find it by scanning user profiles.
11
+
12
+ function Find-TargetUserProfile {
13
+ $profiles = Get-CimInstance Win32_UserProfile -ErrorAction SilentlyContinue |
14
+ Where-Object { -not $_.Special -and $_.LocalPath -and $_.LocalPath -notmatch '\\(systemprofile|LocalService|NetworkService)$' }
15
+
16
+ foreach ($p in $profiles) {
17
+ $candidate = Join-Path $p.LocalPath '.minion\.target-user-profile'
18
+ if (Test-Path $candidate) {
19
+ $saved = ([System.IO.File]::ReadAllText($candidate)).Trim()
20
+ if ($saved -and (Test-Path $saved)) { return $saved }
21
+ }
22
+ }
23
+ return $null
24
+ }
25
+
26
+ # --- Main ---------------------------------------------------------------------
27
+
28
+ # Must be admin (npm install -g runs as admin)
29
+ $isAdmin = ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole(
30
+ [Security.Principal.WindowsBuiltInRole]::Administrator)
31
+ if (-not $isAdmin) { exit 0 }
32
+
33
+ $TargetUserProfile = Find-TargetUserProfile
34
+ if (-not $TargetUserProfile) { exit 0 } # setup never ran — nothing to fix
35
+
36
+ $targetUserName = Split-Path $TargetUserProfile -Leaf
37
+
38
+ # Determine npm global bin directory (where minion-cli-win.cmd lives)
39
+ $adminNpmBin = Split-Path (Get-Command minion-cli-win -ErrorAction SilentlyContinue).Source -ErrorAction SilentlyContinue
40
+ if (-not $adminNpmBin) {
41
+ $adminNpmBin = & npm config get prefix 2>$null
42
+ }
43
+ if (-not $adminNpmBin) { exit 0 }
44
+
45
+ # Skip if the target user's own npm prefix (no ACL fix needed)
46
+ if ($adminNpmBin -eq (Join-Path $TargetUserProfile 'AppData\Roaming\npm')) { exit 0 }
47
+
48
+ # 1. Grant ReadAndExecute on bin links (minion-cli-win.cmd, hq-win.cmd, etc.)
49
+ $binFiles = @()
50
+ $binFiles += Get-ChildItem -Path $adminNpmBin -Filter '*minion*' -ErrorAction SilentlyContinue
51
+ $binFiles += Get-ChildItem -Path $adminNpmBin -Filter '*hq-win*' -ErrorAction SilentlyContinue
52
+ foreach ($f in $binFiles) {
53
+ icacls $f.FullName /grant "${targetUserName}:(RX)" /Q 2>$null | Out-Null
54
+ }
55
+
56
+ # 2. Grant ReadAndExecute on the @geekbeer/minion package directory (recursive)
57
+ $minionPkgDir = Join-Path (Join-Path $adminNpmBin 'node_modules') '@geekbeer\minion'
58
+ if (Test-Path $minionPkgDir) {
59
+ icacls $minionPkgDir /grant "${targetUserName}:(OI)(CI)RX" /T /Q 2>$null | Out-Null
60
+ }
61
+
62
+ # 3. Grant traverse access on ancestor directories (idempotent)
63
+ $traverseDirs = @(
64
+ $adminNpmBin,
65
+ (Join-Path $adminNpmBin 'node_modules'),
66
+ (Join-Path $adminNpmBin 'node_modules\@geekbeer')
67
+ )
68
+ $walkDir = $adminNpmBin
69
+ while ($walkDir) {
70
+ $parent = Split-Path $walkDir -Parent
71
+ if (-not $parent -or $parent -eq $walkDir) { break }
72
+ $traverseDirs += $parent
73
+ $walkDir = $parent
74
+ if ($parent.Length -le 3) { break }
75
+ }
76
+ foreach ($dir in ($traverseDirs | Select-Object -Unique)) {
77
+ if (Test-Path $dir) {
78
+ icacls $dir /grant "${targetUserName}:(RX)" /Q 2>$null | Out-Null
79
+ }
80
+ }
81
+
82
+ Write-Host "[@geekbeer/minion] postinstall: file permissions restored for user '$targetUserName'"
@@ -4,7 +4,7 @@
4
4
  * Same API as routes/commands.js but uses win/process-manager.js
5
5
  */
6
6
 
7
- const { exec, spawn } = require('child_process')
7
+ const { exec, execSync } = require('child_process')
8
8
  const { promisify } = require('util')
9
9
  const fs = require('fs')
10
10
  const path = require('path')
@@ -13,11 +13,11 @@ const execAsync = promisify(exec)
13
13
 
14
14
  const { verifyToken } = require('../../core/lib/auth')
15
15
  const { config } = require('../../core/config')
16
- const { detectProcessManager, buildAllowedCommands } = require('../lib/process-manager')
16
+ const { detectProcessManager, buildAllowedCommands, getNssmPath } = require('../lib/process-manager')
17
17
 
18
- const SPAWN_LOG = path.join(os.homedir(), '.minion', 'spawn-debug.log')
19
- function spawnLog(msg) {
20
- try { fs.appendFileSync(SPAWN_LOG, `${new Date().toISOString()} ${msg}\n`) } catch {}
18
+ const DEFERRED_LOG = path.join(os.homedir(), '.minion', 'logs', 'deferred-debug.log')
19
+ function deferredLog(msg) {
20
+ try { fs.appendFileSync(DEFERRED_LOG, `${new Date().toISOString()} ${msg}\n`) } catch {}
21
21
  }
22
22
 
23
23
  const PROC_MGR = detectProcessManager()
@@ -64,29 +64,45 @@ async function commandRoutes(fastify) {
64
64
  if (allowedCommand.deferred) {
65
65
  console.log(`[Command] Scheduling deferred command: ${command}`)
66
66
  setTimeout(() => {
67
- if (allowedCommand.spawnArgs) {
68
- const [cmd, args] = allowedCommand.spawnArgs
69
- spawnLog(`[${command}] spawn: ${cmd} ${JSON.stringify(args)}`)
67
+ if (allowedCommand.nssmService) {
68
+ // Register and start a temporary NSSM service to run the script
69
+ // independently of the minion-agent service process tree.
70
+ const { scriptPath } = allowedCommand.nssmService
71
+ const nssmPath = getNssmPath()
72
+ const svcName = 'minion-update'
73
+ deferredLog(`[${command}] registering NSSM service: ${svcName}`)
70
74
  try {
71
- const stderrPath = path.join(os.homedir(), '.minion', `${command}-stderr.log`)
72
- const stderrFd = fs.openSync(stderrPath, 'w')
73
- const child = spawn(cmd, args, {
74
- detached: true,
75
- stdio: ['ignore', 'ignore', stderrFd],
76
- })
77
- child.on('error', (err) => {
78
- spawnLog(`[${command}] child error: ${err.message}`)
79
- console.error(`[Command] Spawn child error: ${command} - ${err.message}`)
80
- })
81
- child.on('exit', (code, signal) => {
82
- spawnLog(`[${command}] child exit: code=${code} signal=${signal}`)
83
- })
84
- child.unref()
85
- spawnLog(`[${command}] spawned pid=${child.pid}`)
86
- console.log(`[Command] Deferred command spawned: ${command} (pid: ${child.pid})`)
75
+ // Copy nssm.exe to data dir so the service wrapper binary is
76
+ // outside the npm package directory. This prevents EBUSY when
77
+ // npm tries to replace the package during update.
78
+ const dataDirNssm = path.join(os.homedir(), '.minion', 'nssm.exe')
79
+ fs.copyFileSync(nssmPath, dataDirNssm)
80
+ const svcNssm = dataDirNssm
81
+
82
+ // Clean up any leftover service from a previous run
83
+ try { execSync(`"${svcNssm}" stop ${svcName} confirm`, { stdio: 'ignore', timeout: 10000 }) } catch {}
84
+ try { execSync(`"${svcNssm}" remove ${svcName} confirm`, { stdio: 'ignore', timeout: 10000 }) } catch {}
85
+
86
+ const powershellPath = 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe'
87
+ const args = `-NoProfile -ExecutionPolicy Bypass -File "${scriptPath}"`
88
+
89
+ execSync(`"${svcNssm}" install ${svcName} "${powershellPath}" ${args}`, { timeout: 10000 })
90
+ execSync(`"${svcNssm}" set ${svcName} AppDirectory "${path.dirname(scriptPath)}"`, { stdio: 'ignore', timeout: 5000 })
91
+ execSync(`"${svcNssm}" set ${svcName} Start SERVICE_DEMAND_START`, { stdio: 'ignore', timeout: 5000 })
92
+ execSync(`"${svcNssm}" set ${svcName} AppExit Default Exit`, { stdio: 'ignore', timeout: 5000 })
93
+
94
+ // Redirect service stdout/stderr to log files
95
+ const svcLogDir = path.join(os.homedir(), '.minion', 'logs')
96
+ execSync(`"${svcNssm}" set ${svcName} AppStdout "${path.join(svcLogDir, `${command}-svc-stdout.log`)}"`, { stdio: 'ignore', timeout: 5000 })
97
+ execSync(`"${svcNssm}" set ${svcName} AppStderr "${path.join(svcLogDir, `${command}-svc-stderr.log`)}"`, { stdio: 'ignore', timeout: 5000 })
98
+
99
+ // Start the service (runs asynchronously, independent of minion-agent)
100
+ execSync(`"${svcNssm}" start ${svcName}`, { timeout: 10000 })
101
+ deferredLog(`[${command}] NSSM service started: ${svcName}`)
102
+ console.log(`[Command] Deferred command started via NSSM service: ${command}`)
87
103
  } catch (err) {
88
- spawnLog(`[${command}] spawn threw: ${err.message}`)
89
- console.error(`[Command] Deferred spawn failed: ${command} - ${err.message}`)
104
+ deferredLog(`[${command}] NSSM service failed: ${err.message}`)
105
+ console.error(`[Command] Deferred NSSM service failed: ${command} - ${err.message}`)
90
106
  }
91
107
  } else {
92
108
  exec(allowedCommand.command, { timeout: 60000, shell: true }, (err) => {