@geekbeer/minion 2.73.0 → 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,10 +1,9 @@
1
1
  /**
2
- * Windows Process Manager
2
+ * Windows Process Manager (NSSM-based)
3
3
  *
4
- * Detects process management mode and builds allowed commands.
5
- * Supports: NSSM (legacy), sc.exe (legacy), user-process (default).
6
- * User-process mode uses PID file + start-agent.ps1 for management
7
- * and does not require administrator privileges.
4
+ * Uses NSSM (Non-Sucking Service Manager) for Windows Service management.
5
+ * Requires administrator privileges for service control.
6
+ * NSSM binary is bundled at win/vendor/nssm.exe.
8
7
  */
9
8
 
10
9
  const { execSync } = require('child_process')
@@ -13,53 +12,41 @@ const path = require('path')
13
12
  const os = require('os')
14
13
 
15
14
  /**
16
- * Detect Windows process manager.
17
- * Prefers user-process mode (no admin). Falls back to NSSM/sc for legacy installs.
18
- * @returns {'user-process' | 'nssm' | 'sc'}
15
+ * Get the absolute path to nssm.exe.
16
+ * Checks bundled vendor location first, then PATH.
17
+ * @returns {string} Path to nssm.exe
19
18
  */
20
- function detectProcessManager() {
21
- // Check for user-process mode (PID file or start-agent.ps1 exists)
22
- const dataDir = path.join(os.homedir(), '.minion')
23
- const startScript = path.join(dataDir, 'start-agent.ps1')
24
- if (fs.existsSync(startScript)) {
25
- return 'user-process'
26
- }
19
+ function getNssmPath() {
20
+ // 1. Bundled in npm package: win/vendor/nssm.exe
21
+ const vendorNssm = path.join(__dirname, '..', 'vendor', 'nssm.exe')
22
+ if (fs.existsSync(vendorNssm)) return vendorNssm
27
23
 
28
- // Legacy: check NSSM
24
+ // 2. Installed to data dir (legacy or manual)
25
+ const dataDirNssm = path.join(os.homedir(), '.minion', 'nssm.exe')
26
+ if (fs.existsSync(dataDirNssm)) return dataDirNssm
27
+
28
+ // 3. On PATH
29
29
  try {
30
30
  execSync('nssm version', { stdio: 'ignore', timeout: 5000 })
31
31
  return 'nssm'
32
- } catch { /* not installed */ }
32
+ } catch { /* not on PATH */ }
33
33
 
34
- // Legacy: check sc.exe service
35
- try {
36
- execSync('sc query minion-agent', { stdio: 'ignore', timeout: 5000 })
37
- return 'sc'
38
- } catch { /* not available */ }
39
-
40
- return 'user-process'
34
+ // Fallback: return vendor path (will fail with clear error if missing)
35
+ return vendorNssm
41
36
  }
42
37
 
43
38
  /**
44
- * Generate a temporary PowerShell script that:
45
- * 1. Stops the running agent process (releasing file locks)
46
- * 2. Runs npm install -g
47
- * 3. Restarts the agent
48
- *
49
- * This is necessary because node-pty's conpty.node DLL is locked by the
50
- * running process, causing EBUSY errors if npm tries to overwrite it in-place.
51
- *
52
- * @param {string} npmInstallCmd - The npm install command to run
53
- * @param {string} stopCmd - Command/script block to stop the agent
54
- * @param {string} startCmd - Command/script block to start the agent
55
- * @returns {string} - Path to the generated update script (.ps1)
39
+ * Detect Windows process manager. Always returns 'nssm'.
40
+ * @returns {'nssm'}
56
41
  */
42
+ function detectProcessManager() {
43
+ return 'nssm'
44
+ }
45
+
57
46
  /**
58
47
  * Generate a PowerShell snippet that requests graceful shutdown via the agent's
59
- * HTTP API, then falls back to force-kill. This mirrors Linux's SIGTERM behavior:
60
- * the agent runs shutdown() → sends offline heartbeat exits cleanly.
61
- *
62
- * Windows has no SIGTERM for console processes, so we use HTTP instead.
48
+ * HTTP API, then falls back to NSSM stop. This ensures the agent sends an
49
+ * offline heartbeat to HQ before stopping.
63
50
  *
64
51
  * @param {number} agentPort - The agent's HTTP port (default 8080)
65
52
  * @param {string} apiToken - The agent's API token for authentication
@@ -73,60 +60,55 @@ function buildGracefulStopBlock(agentPort, apiToken) {
73
60
  ` Log 'Graceful shutdown requested, waiting for offline heartbeat...'`,
74
61
  ` Start-Sleep -Seconds 4`,
75
62
  ` } catch {`,
76
- ` Log "Graceful shutdown API failed: $_ (will force kill)"`,
63
+ ` Log "Graceful shutdown API failed: $_ (will use nssm stop)"`,
77
64
  ` }`,
78
65
  ].join('\n')
79
66
  }
80
67
 
81
68
  /**
82
- * Apply self-PID exclusion to a stopCmd so that the detached script
83
- * does not kill itself when enumerating child processes.
84
- */
85
- function makeSafeStopCmd(stopCmd) {
86
- return stopCmd.replace(
87
- /ForEach-Object\s*\{\s*Stop-Process/,
88
- 'Where-Object { $_.ProcessId -ne $PID } | ForEach-Object { Stop-Process'
89
- )
90
- }
91
-
92
- /**
93
- * Generate a temporary PowerShell restart script that:
94
- * 1. Requests graceful shutdown via HTTP API (sends offline heartbeat)
95
- * 2. Force-kills remaining processes (fallback)
96
- * 3. Restarts the agent
69
+ * Generate a temporary PowerShell script for updating the agent:
70
+ * 1. Graceful shutdown via HTTP API (offline heartbeat)
71
+ * 2. Stop service via NSSM
72
+ * 3. Run npm install -g
73
+ * 4. Start service via NSSM
97
74
  *
98
- * @param {string} stopCmd - Command/script block to force-stop the agent
99
- * @param {string} startCmd - Command/script block to start the agent
75
+ * @param {string} npmInstallCmd - The npm install command to run
76
+ * @param {string} nssmPath - Absolute path to nssm.exe
77
+ * @param {string} scriptName - Base name for the .ps1 and .log files
100
78
  * @param {number} agentPort - The agent's HTTP port
101
79
  * @param {string} apiToken - The agent's API token
102
- * @returns {string} - Path to the generated restart script (.ps1)
80
+ * @returns {string} - Path to the generated update script (.ps1)
103
81
  */
104
- function buildRestartScript(stopCmd, startCmd, agentPort, apiToken) {
82
+ function buildUpdateScript(npmInstallCmd, nssmPath, scriptName = 'update-agent', agentPort = 8080, apiToken = '') {
105
83
  const dataDir = path.join(os.homedir(), '.minion')
106
- const scriptPath = path.join(dataDir, 'restart-agent.ps1')
107
- const logPath = path.join(dataDir, 'restart-agent.log')
84
+ const scriptPath = path.join(dataDir, `${scriptName}.ps1`)
85
+ const logPath = path.join(dataDir, `${scriptName}.log`)
86
+ const nssm = nssmPath.replace(/\\/g, '\\\\')
108
87
 
109
- const safeStopCmd = makeSafeStopCmd(stopCmd)
110
88
  const gracefulStop = buildGracefulStopBlock(agentPort, apiToken)
111
89
 
112
90
  const ps1 = [
113
91
  `$ErrorActionPreference = 'Stop'`,
114
92
  `$logFile = '${logPath.replace(/\\/g, '\\\\')}'`,
115
93
  `function Log($msg) { "$(Get-Date -Format o) $msg" | Out-File -Append $logFile }`,
116
- `Log 'Restart started'`,
94
+ `Log 'Update started'`,
117
95
  `try {`,
118
96
  ` Log 'Requesting graceful shutdown...'`,
119
97
  gracefulStop,
120
- ` Log 'Force stopping remaining processes...'`,
121
- ` ${safeStopCmd}`,
122
- ` Start-Sleep -Seconds 2`,
123
- ` Log 'Starting agent...'`,
124
- ` ${startCmd}`,
125
- ` Log 'Restart completed successfully'`,
98
+ ` Log 'Stopping service via NSSM...'`,
99
+ ` & '${nssm}' stop minion-agent`,
100
+ ` Start-Sleep -Seconds 3`,
101
+ ` 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)" }`,
105
+ ` Log 'Starting service...'`,
106
+ ` & '${nssm}' start minion-agent`,
107
+ ` Log 'Update completed successfully'`,
126
108
  `} catch {`,
127
- ` Log "Restart failed: $_"`,
128
- ` Log 'Attempting to start agent anyway...'`,
129
- ` ${startCmd}`,
109
+ ` Log "Update failed: $_"`,
110
+ ` Log 'Attempting to start service anyway...'`,
111
+ ` & '${nssm}' start minion-agent`,
130
112
  `}`,
131
113
  ].join('\n')
132
114
 
@@ -137,187 +119,99 @@ function buildRestartScript(stopCmd, startCmd, agentPort, apiToken) {
137
119
  }
138
120
 
139
121
  /**
140
- * Generate a temporary PowerShell script that:
141
- * 1. Requests graceful shutdown via HTTP API (sends offline heartbeat)
142
- * 2. Force-kills remaining processes (releases DLL file locks)
143
- * 3. Runs npm install -g
144
- * 4. Restarts the agent
145
- *
146
- * The force-kill after graceful shutdown is necessary because node-pty's
147
- * conpty.node DLL is locked by the running process, causing EBUSY errors
148
- * if npm tries to overwrite it in-place.
122
+ * Generate a temporary PowerShell restart script:
123
+ * 1. Graceful shutdown via HTTP API (offline heartbeat)
124
+ * 2. Restart service via NSSM
149
125
  *
150
- * @param {string} npmInstallCmd - The npm install command to run
151
- * @param {string} stopCmd - Command/script block to stop the agent
152
- * @param {string} startCmd - Command/script block to start the agent
153
- * @param {string} scriptName - Base name for the .ps1 and .log files
126
+ * @param {string} nssmPath - Absolute path to nssm.exe
154
127
  * @param {number} agentPort - The agent's HTTP port
155
128
  * @param {string} apiToken - The agent's API token
156
- * @returns {string} - Path to the generated update script (.ps1)
129
+ * @returns {string} - Path to the generated restart script (.ps1)
157
130
  */
158
- function buildUpdateScript(npmInstallCmd, stopCmd, startCmd, scriptName = 'update-agent', agentPort = 8080, apiToken = '') {
131
+ function buildRestartScript(nssmPath, agentPort, apiToken) {
159
132
  const dataDir = path.join(os.homedir(), '.minion')
160
- const scriptPath = path.join(dataDir, `${scriptName}.ps1`)
161
- const logPath = path.join(dataDir, `${scriptName}.log`)
133
+ const scriptPath = path.join(dataDir, 'restart-agent.ps1')
134
+ const logPath = path.join(dataDir, 'restart-agent.log')
135
+ const nssm = nssmPath.replace(/\\/g, '\\\\')
162
136
 
163
- const safeStopCmd = makeSafeStopCmd(stopCmd)
164
137
  const gracefulStop = buildGracefulStopBlock(agentPort, apiToken)
165
138
 
166
139
  const ps1 = [
167
140
  `$ErrorActionPreference = 'Stop'`,
168
141
  `$logFile = '${logPath.replace(/\\/g, '\\\\')}'`,
169
142
  `function Log($msg) { "$(Get-Date -Format o) $msg" | Out-File -Append $logFile }`,
170
- `Log 'Update started'`,
143
+ `Log 'Restart started'`,
171
144
  `try {`,
172
145
  ` Log 'Requesting graceful shutdown...'`,
173
146
  gracefulStop,
174
- ` Log 'Force stopping remaining processes...'`,
175
- ` ${safeStopCmd}`,
176
- ` Start-Sleep -Seconds 3`,
177
- ` Log 'Installing package...'`,
178
- ` $out = & cmd /c "${npmInstallCmd} 2>&1"`,
179
- ` Log "npm output: $out"`,
180
- ` if ($LASTEXITCODE -ne 0) { throw "npm install failed (exit code $LASTEXITCODE)" }`,
181
- ` Log 'Starting agent...'`,
182
- ` ${startCmd}`,
183
- ` Log 'Update completed successfully'`,
147
+ ` Log 'Restarting service via NSSM...'`,
148
+ ` & '${nssm}' restart minion-agent`,
149
+ ` Log 'Restart completed successfully'`,
184
150
  `} catch {`,
185
- ` Log "Update failed: $_"`,
186
- ` Log 'Attempting to restart agent anyway...'`,
187
- ` ${startCmd}`,
151
+ ` Log "Restart failed: $_"`,
152
+ ` Log 'Attempting to start service...'`,
153
+ ` & '${nssm}' start minion-agent`,
188
154
  `}`,
189
155
  ].join('\n')
190
156
 
191
- // Write the script to disk
192
157
  try { fs.mkdirSync(dataDir, { recursive: true }) } catch { /* exists */ }
193
158
  fs.writeFileSync(scriptPath, ps1, 'utf-8')
194
159
 
195
- // Return script path — caller uses spawn() with detached:true to launch it,
196
- // avoiding cmd.exe quoting issues and -WindowStyle Hidden hangs in non-interactive sessions.
197
160
  return scriptPath
198
161
  }
199
162
 
200
163
  /**
201
- * Build allowed commands for the detected process manager.
202
- * @param {string} procMgr - Process manager type
203
- * @param {{ AGENT_PORT?: number, API_TOKEN?: string }} [agentConfig] - Agent config for graceful shutdown
204
- * @returns {Record<string, { description: string; command: string; deferred?: boolean }>}
164
+ * Build allowed commands for NSSM-based service management.
165
+ * @param {string} _procMgr - Ignored (always 'nssm')
166
+ * @param {{ AGENT_PORT?: number, API_TOKEN?: string }} [agentConfig] - Agent config
167
+ * @returns {Record<string, { description: string; command?: string; spawnArgs?: [string, string[]]; deferred?: boolean }>}
205
168
  */
206
- function buildAllowedCommands(procMgr, agentConfig = {}) {
169
+ function buildAllowedCommands(_procMgr, agentConfig = {}) {
207
170
  const commands = {}
208
171
  const agentPort = agentConfig.AGENT_PORT || 8080
209
172
  const apiToken = agentConfig.API_TOKEN || ''
173
+ const nssmPath = getNssmPath()
210
174
 
211
- if (procMgr === 'user-process') {
212
- const dataDir = path.join(os.homedir(), '.minion')
213
- const pidFile = path.join(dataDir, 'minion-agent.pid')
214
- const startScript = path.join(dataDir, 'start-agent.ps1')
215
-
216
- const stopBlock = `$agentPid = Get-Content '${pidFile}' -ErrorAction SilentlyContinue; if ($agentPid) { Get-CimInstance Win32_Process -Filter ('ParentProcessId = ' + $agentPid) -ErrorAction SilentlyContinue | ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }; Stop-Process -Id $agentPid -Force -ErrorAction SilentlyContinue }`
217
- const startBlock = `Start-Process powershell -ArgumentList ('-ExecutionPolicy Bypass -WindowStyle Hidden -File \"' + '${startScript}' + '\"') -WindowStyle Hidden`
175
+ commands['restart-agent'] = {
176
+ description: 'Restart the minion agent service',
177
+ spawnArgs: ['powershell', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command',
178
+ `& '${buildRestartScript(nssmPath, agentPort, apiToken)}'`]],
179
+ deferred: true,
180
+ }
218
181
 
219
- commands['restart-agent'] = {
220
- description: 'Restart the minion agent process',
221
- spawnArgs: ['powershell', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command',
222
- `& '${buildRestartScript(stopBlock, startBlock, agentPort, apiToken)}'`]],
223
- deferred: true,
224
- }
225
- commands['update-agent'] = {
226
- description: 'Update @geekbeer/minion to latest version and restart',
227
- spawnArgs: ['powershell', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command',
228
- `& '${buildUpdateScript(
229
- 'npm install -g @geekbeer/minion@latest',
230
- stopBlock, startBlock, 'update-agent', agentPort, apiToken,
231
- )}'`]],
232
- deferred: true,
233
- }
234
- commands['update-agent-dev'] = {
235
- description: 'Update @geekbeer/minion from Verdaccio (dev) and restart',
236
- spawnArgs: ['powershell', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command',
237
- `& '${buildUpdateScript(
238
- 'npm install -g @geekbeer/minion@latest --registry http://verdaccio:4873',
239
- stopBlock, startBlock, 'update-agent-dev', agentPort, apiToken,
240
- )}'`]],
241
- deferred: true,
242
- }
243
- commands['status-services'] = {
244
- description: 'Show agent process info',
245
- command: `powershell -Command "$pidFile = '${pidFile}'; if (Test-Path $pidFile) { $agentPid = (Get-Content $pidFile -Raw).Trim(); $proc = Get-Process -Id $agentPid -ErrorAction SilentlyContinue; if ($proc) { Write-Host 'minion-agent: running (PID:' $agentPid ')' } else { Write-Host 'minion-agent: not running (stale PID)' } } else { Write-Host 'minion-agent: not running' }"`,
246
- }
247
- } else if (procMgr === 'nssm') {
248
- // NSSM handles graceful stop via its own service control, so no HTTP API needed
249
- commands['restart-agent'] = {
250
- description: 'Restart the minion agent service',
251
- command: 'nssm restart minion-agent',
252
- deferred: true,
253
- }
254
- commands['update-agent'] = {
255
- description: 'Update @geekbeer/minion to latest version and restart',
256
- spawnArgs: ['powershell', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', buildUpdateScript(
257
- 'npm install -g @geekbeer/minion@latest',
258
- 'nssm stop minion-agent',
259
- 'nssm start minion-agent',
260
- 'update-agent',
261
- agentPort,
262
- apiToken,
263
- )]],
264
- deferred: true,
265
- }
266
- commands['update-agent-dev'] = {
267
- description: 'Update @geekbeer/minion from Verdaccio (dev) and restart',
268
- spawnArgs: ['powershell', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', buildUpdateScript(
269
- 'npm install -g @geekbeer/minion@latest --registry http://verdaccio:4873',
270
- 'nssm stop minion-agent',
271
- 'nssm start minion-agent',
272
- 'update-agent-dev',
273
- agentPort,
274
- apiToken,
275
- )]],
276
- deferred: true,
277
- }
278
- commands['restart-display'] = {
279
- description: 'Restart TightVNC and websockify services',
280
- command: 'nssm restart minion-websockify',
281
- }
282
- commands['status-services'] = {
283
- description: 'Check status of all services',
284
- command: 'nssm status minion-agent & nssm status minion-websockify',
285
- }
286
- } else if (procMgr === 'sc') {
287
- // sc.exe handles graceful stop via service control, so no HTTP API needed
288
- commands['restart-agent'] = {
289
- description: 'Restart the minion agent service',
290
- command: 'net stop minion-agent & net start minion-agent',
291
- deferred: true,
292
- }
293
- commands['update-agent'] = {
294
- description: 'Update @geekbeer/minion to latest version and restart',
295
- spawnArgs: ['powershell', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', buildUpdateScript(
182
+ commands['update-agent'] = {
183
+ description: 'Update @geekbeer/minion to latest version and restart',
184
+ spawnArgs: ['powershell', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command',
185
+ `& '${buildUpdateScript(
296
186
  'npm install -g @geekbeer/minion@latest',
297
- 'net stop minion-agent',
298
- 'net start minion-agent',
299
- 'update-agent',
300
- agentPort,
301
- apiToken,
302
- )]],
303
- deferred: true,
304
- }
305
- commands['update-agent-dev'] = {
306
- description: 'Update @geekbeer/minion from Verdaccio (dev) and restart',
307
- spawnArgs: ['powershell', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', buildUpdateScript(
187
+ nssmPath, 'update-agent', agentPort, apiToken,
188
+ )}'`]],
189
+ deferred: true,
190
+ }
191
+
192
+ commands['update-agent-dev'] = {
193
+ description: 'Update @geekbeer/minion from Verdaccio (dev) and restart',
194
+ spawnArgs: ['powershell', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command',
195
+ `& '${buildUpdateScript(
308
196
  'npm install -g @geekbeer/minion@latest --registry http://verdaccio:4873',
309
- 'net stop minion-agent',
310
- 'net start minion-agent',
311
- 'update-agent-dev',
312
- agentPort,
313
- apiToken,
314
- )]],
315
- deferred: true,
316
- }
317
- commands['status-services'] = {
318
- description: 'Check status of minion agent service',
319
- command: 'sc query minion-agent',
320
- }
197
+ nssmPath, 'update-agent-dev', agentPort, apiToken,
198
+ )}'`]],
199
+ deferred: true,
200
+ }
201
+
202
+ commands['restart-display'] = {
203
+ description: 'Restart VNC and websockify services',
204
+ command: `"${nssmPath}" restart minion-vnc & "${nssmPath}" restart minion-websockify`,
205
+ }
206
+
207
+ commands['restart-tunnel'] = {
208
+ description: 'Restart Cloudflare tunnel service',
209
+ command: `"${nssmPath}" restart minion-cloudflared`,
210
+ }
211
+
212
+ commands['status-services'] = {
213
+ description: 'Check status of all minion services',
214
+ command: `"${nssmPath}" status minion-agent & "${nssmPath}" status minion-vnc & "${nssmPath}" status minion-websockify & "${nssmPath}" status minion-cloudflared`,
321
215
  }
322
216
 
323
217
  return commands
@@ -326,4 +220,5 @@ function buildAllowedCommands(procMgr, agentConfig = {}) {
326
220
  module.exports = {
327
221
  detectProcessManager,
328
222
  buildAllowedCommands,
223
+ getNssmPath,
329
224
  }