dsh-computers 0.0.0-stage → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1993 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * dsh-computers-agent — the machine-side half of dsh-computers.
4
+ *
5
+ * What it does (the whole trust story, so this file can be audited):
6
+ * 1. `enroll` sends the one-time join key to the dsh-web server and stores the
7
+ * long-lived token it gets back in a 0600 JSON file under ~/.dsh-computers.
8
+ * 2. `run` keeps one outbound SSE stream open (GET .../agent/events) carrying
9
+ * `exec` / `cancel` / `ping` / `config` events, executes the commands the
10
+ * server sends with the local shell, and POSTs the result back.
11
+ * 3. `service` installs a user-level launchd / systemd unit so `run` starts at
12
+ * login and is restarted if it ever exits.
13
+ *
14
+ * It only makes outbound connections. It never listens on a port, never opens a
15
+ * reverse proxy, and never prints the token. There are no dependencies, no
16
+ * dynamic imports and no child processes other than the commands the server
17
+ * asks for, the service manager, and (on Windows) taskkill.
18
+ *
19
+ * Protocol: see PROTOCOL.md in the dsh-computers repository (v1).
20
+ *
21
+ * Node.js >= 22, self-contained ESM. This exact file is served at
22
+ * /api/dsh-computers/agent.mjs and downloaded by the install one-liner, so it
23
+ * must work from any install directory and must not import siblings.
24
+ */
25
+
26
+ import { execFile, spawn } from 'node:child_process'
27
+ import fs from 'node:fs'
28
+ import fsp from 'node:fs/promises'
29
+ import os from 'node:os'
30
+ import path from 'node:path'
31
+ import { fileURLToPath, pathToFileURL } from 'node:url'
32
+ import { parseArgs } from 'node:util'
33
+
34
+ // Keep in sync with package.json. The server compares this for display only.
35
+ export const AGENT_VERSION = '1.1.0'
36
+
37
+ const DEFAULT_EXEC_TIMEOUT_MS = 120_000
38
+ const MAX_EXEC_TIMEOUT_MS = 24 * 60 * 60 * 1000
39
+ const MAX_OUTPUT_BYTES = 1024 * 1024
40
+ const TRUNCATION_MARKER = '\n[output truncated]'
41
+ const LOG_ROTATE_BYTES = 5 * 1024 * 1024
42
+ const MIN_BACKOFF_MS = 1000
43
+ const MAX_BACKOFF_MS = 30_000
44
+ const HEARTBEAT_LOG_INTERVAL_MS = 60_000
45
+ const TRANSIENT_SERVER_WARN_INTERVAL_MS = 60_000
46
+ const DEFAULT_HEARTBEAT_MS = 20_000
47
+
48
+ // Service identifier. Overridable so tests (and power users with two installs)
49
+ // can use an isolated launchd/systemd job. Not part of the protocol.
50
+ const SERVICE_LABEL = (process.env.DSH_COMPUTERS_SERVICE_LABEL || 'com.dsh.computers.agent').trim()
51
+ const SERVICE_ACTIONS = ['install', 'uninstall', 'start', 'stop', 'restart', 'status']
52
+
53
+ const SELF_PATH = path.resolve(fileURLToPath(import.meta.url))
54
+
55
+ // ---------------------------------------------------------------------------
56
+ // Small utilities
57
+ // ---------------------------------------------------------------------------
58
+
59
+ class CliError extends Error {
60
+ constructor(message, exitCode = 1) {
61
+ super(message)
62
+ this.name = 'CliError'
63
+ this.exitCode = exitCode
64
+ }
65
+ }
66
+
67
+ function describeError(error) {
68
+ if (!error) return 'unknown error'
69
+ if (typeof error === 'string') return error
70
+ if (error.name === 'AbortError' || error.name === 'TimeoutError') return '请求被中止或超时'
71
+ const cause = error.cause
72
+ const causeText = cause ? (cause.code && cause.message ? `${cause.code} ${cause.message}` : String(cause.message || cause)) : ''
73
+ const text = error.message ? String(error.message) : String(error)
74
+ return causeText && !text.includes(causeText) ? `${text} (${causeText})` : text
75
+ }
76
+
77
+ function parseJsonSafe(text) {
78
+ try {
79
+ return JSON.parse(text)
80
+ } catch {
81
+ return undefined
82
+ }
83
+ }
84
+
85
+ function abbreviate(text, max = 160) {
86
+ const oneLine = String(text ?? '').replace(/\s+/g, ' ')
87
+ return oneLine.length > max ? `${oneLine.slice(0, max)}…` : oneLine
88
+ }
89
+
90
+ function sleep(ms, signal) {
91
+ return new Promise((resolve) => {
92
+ if (signal?.aborted) return resolve()
93
+ const timer = setTimeout(done, ms)
94
+ function done() {
95
+ clearTimeout(timer)
96
+ signal?.removeEventListener('abort', done)
97
+ resolve()
98
+ }
99
+ signal?.addEventListener('abort', done, { once: true })
100
+ })
101
+ }
102
+
103
+ function platformLabel(platform = process.platform) {
104
+ if (platform === 'darwin') return 'macOS'
105
+ if (platform === 'linux') return 'Linux'
106
+ if (platform === 'win32') return 'Windows'
107
+ return 'Unknown'
108
+ }
109
+
110
+ function machineInfo(name) {
111
+ const hostname = os.hostname()
112
+ let cpu = 'unknown'
113
+ try {
114
+ cpu = os.cpus()?.[0]?.model?.trim() || 'unknown'
115
+ } catch {
116
+ // os.cpus() is allowed to fail on some sandboxes; a placeholder is fine.
117
+ }
118
+ return {
119
+ hostname,
120
+ name: name && name.trim() ? name.trim() : hostname,
121
+ os: process.platform,
122
+ arch: process.arch,
123
+ platform: platformLabel(),
124
+ cpuModel: cpu,
125
+ memoryBytes: os.totalmem(),
126
+ nodeVersion: process.version,
127
+ }
128
+ }
129
+
130
+ // ---------------------------------------------------------------------------
131
+ // Paths and config (${DSH_COMPUTERS_HOME:-$HOME/.dsh-computers})
132
+ // ---------------------------------------------------------------------------
133
+
134
+ function agentHome() {
135
+ const override = process.env.DSH_COMPUTERS_HOME
136
+ if (override && override.trim()) return path.resolve(override.trim())
137
+ return path.join(os.homedir(), '.dsh-computers')
138
+ }
139
+
140
+ function configFile() {
141
+ return path.join(agentHome(), 'config.json')
142
+ }
143
+
144
+ function logFile() {
145
+ return path.join(agentHome(), 'agent.log')
146
+ }
147
+
148
+ function launchdPlistPath() {
149
+ return path.join(os.homedir(), 'Library', 'LaunchAgents', `${SERVICE_LABEL}.plist`)
150
+ }
151
+
152
+ function systemdUnitPath() {
153
+ return path.join(os.homedir(), '.config', 'systemd', 'user', `${SERVICE_LABEL}.service`)
154
+ }
155
+
156
+ function nohupPidFile() {
157
+ return path.join(agentHome(), 'agent.pid')
158
+ }
159
+
160
+ function ensureAgentHome() {
161
+ const home = agentHome()
162
+ try {
163
+ fs.mkdirSync(home, { recursive: true, mode: 0o700 })
164
+ } catch {
165
+ // reported later when a write actually fails
166
+ }
167
+ try {
168
+ fs.chmodSync(home, 0o700)
169
+ } catch {
170
+ // best effort: FAT/exFAT and Windows do not support POSIX modes
171
+ }
172
+ return home
173
+ }
174
+
175
+ async function ensureDir(dir) {
176
+ await fsp.mkdir(dir, { recursive: true, mode: 0o700 })
177
+ }
178
+
179
+ /**
180
+ * Write `data` to `file` through a temp file + rename, with an explicit mode.
181
+ * Rename is atomic on the same filesystem, so a crash never leaves a partial
182
+ * config.json that would lose the enrollment.
183
+ */
184
+ async function atomicWriteFile(file, data, mode = 0o600) {
185
+ await ensureDir(path.dirname(file))
186
+ const tmp = path.join(path.dirname(file), `.${path.basename(file)}.tmp-${process.pid}-${Date.now()}`)
187
+ let handle
188
+ try {
189
+ handle = await fsp.open(tmp, 'w', mode)
190
+ await handle.writeFile(data, 'utf8')
191
+ try {
192
+ await handle.sync()
193
+ } catch {
194
+ // fsync is not supported everywhere (and not required for atomicity)
195
+ }
196
+ await handle.close()
197
+ handle = undefined
198
+ await fsp.rename(tmp, file)
199
+ try {
200
+ await fsp.chmod(file, mode)
201
+ } catch {
202
+ // best effort only
203
+ }
204
+ } catch (error) {
205
+ if (handle) {
206
+ try {
207
+ await handle.close()
208
+ } catch {
209
+ // ignore
210
+ }
211
+ }
212
+ try {
213
+ await fsp.rm(tmp, { force: true })
214
+ } catch {
215
+ // ignore
216
+ }
217
+ throw error
218
+ }
219
+ }
220
+
221
+ async function saveConfig(config) {
222
+ await atomicWriteFile(configFile(), `${JSON.stringify(config, null, 2)}\n`, 0o600)
223
+ }
224
+
225
+ async function loadConfig() {
226
+ const file = configFile()
227
+ let raw
228
+ let info
229
+ try {
230
+ info = await fsp.stat(file)
231
+ raw = await fsp.readFile(file, 'utf8')
232
+ } catch (error) {
233
+ if (error?.code === 'ENOENT') {
234
+ throw new CliError(`尚未注册:找不到配置文件 ${file}\n请先运行 enroll --server <url> --key <key>(或使用服务器给出的安装命令)。`)
235
+ }
236
+ throw new CliError(`无法读取配置文件 ${file}:${describeError(error)}`)
237
+ }
238
+ if (process.platform !== 'win32' && (info.mode & 0o077) !== 0) {
239
+ process.stderr.write(`警告:${file} 的权限过宽,已修正为 0600\n`)
240
+ try {
241
+ await fsp.chmod(file, 0o600)
242
+ } catch {
243
+ // keep going; the token is still only used locally
244
+ }
245
+ }
246
+ const parsed = parseJsonSafe(raw)
247
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
248
+ throw new CliError(`配置文件 ${file} 不是合法 JSON,请重新 enroll。`)
249
+ }
250
+ if (parsed.version !== 1) {
251
+ throw new CliError(`配置文件 ${file} 的版本不受支持(${String(parsed.version)}),请重新 enroll。`)
252
+ }
253
+ for (const field of ['serverUrl', 'computerId', 'token', 'name']) {
254
+ if (typeof parsed[field] !== 'string' || parsed[field].length === 0) {
255
+ throw new CliError(`配置文件 ${file} 缺少字段 ${field},请重新 enroll。`)
256
+ }
257
+ }
258
+ return {
259
+ version: 1,
260
+ serverUrl: normalizeServerUrl(parsed.serverUrl),
261
+ computerId: parsed.computerId,
262
+ token: parsed.token,
263
+ name: parsed.name,
264
+ enrolledAt: typeof parsed.enrolledAt === 'string' ? parsed.enrolledAt : '',
265
+ }
266
+ }
267
+
268
+ function redactToken(token) {
269
+ if (typeof token !== 'string' || token.length === 0) return '(无)'
270
+ return `dshc_…${token.slice(-6)}`
271
+ }
272
+
273
+ // ---------------------------------------------------------------------------
274
+ // Logging (agent.log, append, 5 MiB rotation keeping one .1)
275
+ // ---------------------------------------------------------------------------
276
+
277
+ function appendLog(message) {
278
+ const file = logFile()
279
+ try {
280
+ ensureAgentHome()
281
+ let size = 0
282
+ try {
283
+ size = fs.statSync(file).size
284
+ } catch {
285
+ size = 0
286
+ }
287
+ if (size > LOG_ROTATE_BYTES) {
288
+ try {
289
+ fs.rmSync(`${file}.1`, { force: true })
290
+ } catch {
291
+ // ignore
292
+ }
293
+ try {
294
+ fs.renameSync(file, `${file}.1`)
295
+ } catch {
296
+ // a concurrent writer may have moved it; not fatal
297
+ }
298
+ }
299
+ fs.appendFileSync(file, `${new Date().toISOString()} ${message}\n`, { mode: 0o600 })
300
+ } catch {
301
+ // Logging must never take the agent down.
302
+ }
303
+ }
304
+
305
+ /**
306
+ * Default logger for embedders that did not pass `log` (e.g. a test or a host
307
+ * that forgot it): stay quiet on info, surface warnings/errors on the console.
308
+ */
309
+ function defaultRuntimeLog(level, message) {
310
+ if (level === 'warn') console.warn(`dsh-computers-agent: ${message}`)
311
+ else if (level === 'error') console.error(`dsh-computers-agent: ${message}`)
312
+ }
313
+
314
+ /**
315
+ * CLI `run` sink: always append to agent.log, and also echo to stdout unless
316
+ * launchd is already capturing stdout into the same log file.
317
+ */
318
+ function createCliLogSink() {
319
+ return (level, message) => {
320
+ appendLog(level === 'info' ? message : `[${level}] ${message}`)
321
+ if (process.env.DSH_COMPUTERS_LOG_ONLY !== '1') {
322
+ process.stdout.write(`[${new Date().toISOString()}] ${message}\n`)
323
+ }
324
+ }
325
+ }
326
+
327
+ const RUNTIME_LOG_LEVELS = new Set(['info', 'warn', 'error'])
328
+
329
+ /** Call the runtime's logger; a broken host logger must never break the loop. */
330
+ function runtimeLog(runtime, level, message) {
331
+ try {
332
+ runtime.log(RUNTIME_LOG_LEVELS.has(level) ? level : 'info', message)
333
+ } catch {
334
+ // ignore
335
+ }
336
+ }
337
+
338
+ // ---------------------------------------------------------------------------
339
+ // HTTP / SSE client (global fetch, no dependencies)
340
+ // ---------------------------------------------------------------------------
341
+
342
+ function trimTrailingSlash(value) {
343
+ return String(value).replace(/\/+$/, '')
344
+ }
345
+
346
+ function apiBase(serverUrl) {
347
+ return `${trimTrailingSlash(serverUrl)}/api/dsh-computers`
348
+ }
349
+
350
+ function enrollUrl(serverUrl) {
351
+ return `${apiBase(serverUrl)}/agent/enroll`
352
+ }
353
+
354
+ function eventsUrl(serverUrl) {
355
+ return `${apiBase(serverUrl)}/agent/events`
356
+ }
357
+
358
+ function resultsUrl(serverUrl) {
359
+ return `${apiBase(serverUrl)}/agent/results`
360
+ }
361
+
362
+ function heartbeatUrl(serverUrl) {
363
+ return `${apiBase(serverUrl)}/agent/heartbeat`
364
+ }
365
+
366
+ function normalizeServerUrl(value) {
367
+ if (typeof value !== 'string' || !value.trim()) {
368
+ throw new CliError('缺少 --server <url>(dsh-web 的地址,例如 http://127.0.0.1:3000)')
369
+ }
370
+ let url
371
+ try {
372
+ url = new URL(value.trim())
373
+ } catch {
374
+ throw new CliError(`--server 不是合法 URL:${value}`)
375
+ }
376
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') {
377
+ throw new CliError('--server 必须是 http:// 或 https:// 地址')
378
+ }
379
+ url.search = ''
380
+ url.hash = ''
381
+ return trimTrailingSlash(url.toString())
382
+ }
383
+
384
+ function requestHeaders(token, extra = {}) {
385
+ const headers = {
386
+ 'user-agent': `dsh-computers-agent/${AGENT_VERSION}`,
387
+ ...extra,
388
+ }
389
+ if (token) headers.authorization = `Bearer ${token}`
390
+ return headers
391
+ }
392
+
393
+ /**
394
+ * One JSON request. Returns { status, ok, json, text }. Throws only on
395
+ * transport-level failures (DNS, refused connection, timeout, abort).
396
+ */
397
+ async function requestJson(url, { method = 'GET', token, body, timeoutMs = 30_000, signal } = {}) {
398
+ const controller = new AbortController()
399
+ const onOuterAbort = () => controller.abort(signal.reason)
400
+ if (signal) {
401
+ if (signal.aborted) controller.abort(signal.reason)
402
+ else signal.addEventListener('abort', onOuterAbort, { once: true })
403
+ }
404
+ const timer = setTimeout(() => controller.abort(new Error(`请求超时(${timeoutMs}ms)`)), timeoutMs)
405
+ timer.unref?.()
406
+ try {
407
+ const response = await fetch(url, {
408
+ method,
409
+ headers: requestHeaders(token, body === undefined ? {} : { 'content-type': 'application/json; charset=utf-8' }),
410
+ body: body === undefined ? undefined : JSON.stringify(body),
411
+ signal: controller.signal,
412
+ })
413
+ const text = await response.text()
414
+ return { status: response.status, ok: response.ok, json: parseJsonSafe(text), text }
415
+ } finally {
416
+ clearTimeout(timer)
417
+ signal?.removeEventListener('abort', onOuterAbort)
418
+ }
419
+ }
420
+
421
+ /**
422
+ * Decide whether an HTTP 401 is OUR rejection of the stored token, or a
423
+ * transient server-side condition.
424
+ *
425
+ * The host plugin answers a bad bearer with JSON `{"error":"invalid-token",...}`
426
+ * (PROTOCOL §3). Anything else on 401 — bare text, a different JSON error, an
427
+ * empty body — is NOT proof that the token is dead: while the plugin is being
428
+ * disabled/enabled or upgraded, the SSE route momentarily does not exist and
429
+ * the request falls through to the generic `/api` gateway, which answers a bare
430
+ * 401/`unauthorized`. Treating that as fatal would break every connected
431
+ * machine until the user re-ran the installer, so it must be retried instead.
432
+ *
433
+ * The same rule applies to 403/404/5xx: any non-2xx during connect is a
434
+ * server-side condition, never a reason to drop the enrollment.
435
+ */
436
+ function isInvalidTokenRejection(status, bodyText) {
437
+ if (status !== 401) return false
438
+ const parsed = parseJsonSafe(bodyText)
439
+ return Boolean(parsed && typeof parsed === 'object' && parsed.error === 'invalid-token')
440
+ }
441
+
442
+ /** Read an error response body without ever throwing (bodies are tiny). */
443
+ async function readErrorBody(response) {
444
+ try {
445
+ return (await response.text()).trim()
446
+ } catch {
447
+ return ''
448
+ }
449
+ }
450
+
451
+ function sseHeaders(token) {
452
+ return requestHeaders(token, {
453
+ accept: 'text/event-stream',
454
+ 'cache-control': 'no-cache',
455
+ })
456
+ }
457
+
458
+ /** Parse one SSE block (`event:` / `data:` lines) into { event, data }. */
459
+ function parseSseBlock(block) {
460
+ let name = ''
461
+ const dataLines = []
462
+ for (const line of block.split('\n')) {
463
+ if (!line || line.startsWith(':')) continue
464
+ const colon = line.indexOf(':')
465
+ const field = colon === -1 ? line : line.slice(0, colon)
466
+ let value = colon === -1 ? '' : line.slice(colon + 1)
467
+ if (value.startsWith(' ')) value = value.slice(1)
468
+ if (field === 'event') name = value
469
+ else if (field === 'data') dataLines.push(value)
470
+ // `id:` and `retry:` are accepted and ignored; the agent owns its backoff.
471
+ }
472
+ if (name === '' && dataLines.length === 0) return null
473
+ return { event: name || 'message', data: dataLines.join('\n') }
474
+ }
475
+
476
+ /** Async generator over the events of a text/event-stream body. */
477
+ async function* sseEvents(stream, signal) {
478
+ const reader = stream.getReader()
479
+ const decoder = new TextDecoder()
480
+ let buffer = ''
481
+ try {
482
+ while (true) {
483
+ if (signal?.aborted) return
484
+ const { value, done } = await reader.read()
485
+ if (done) break
486
+ buffer += decoder.decode(value, { stream: true })
487
+ buffer = buffer.replace(/\r\n/g, '\n')
488
+ let boundary
489
+ while ((boundary = buffer.indexOf('\n\n')) !== -1) {
490
+ const block = buffer.slice(0, boundary)
491
+ buffer = buffer.slice(boundary + 2)
492
+ const event = parseSseBlock(block)
493
+ if (event) yield event
494
+ }
495
+ }
496
+ } finally {
497
+ try {
498
+ await reader.cancel()
499
+ } catch {
500
+ // already errored/closed
501
+ }
502
+ }
503
+ }
504
+
505
+ /** Open the event stream for ~timeoutMs and wait for `hello`. */
506
+ async function probeEventStream(config, timeoutMs = 5000) {
507
+ const controller = new AbortController()
508
+ const timer = setTimeout(() => controller.abort(), timeoutMs)
509
+ timer.unref?.()
510
+ try {
511
+ const response = await fetch(eventsUrl(config.serverUrl), {
512
+ headers: sseHeaders(config.token),
513
+ signal: controller.signal,
514
+ })
515
+ if (response.status === 401) {
516
+ const body = await readErrorBody(response)
517
+ if (isInvalidTokenRejection(response.status, body)) {
518
+ return { ok: false, unauthorized: true, message: 'HTTP 401 invalid-token' }
519
+ }
520
+ return { ok: false, message: `HTTP 401${body ? ` ${abbreviate(body, 80)}` : ''}(服务器可能尚未就绪)` }
521
+ }
522
+ if (!response.ok) {
523
+ const body = await readErrorBody(response)
524
+ return { ok: false, message: `HTTP ${response.status}${body ? ` ${abbreviate(body, 80)}` : ''}` }
525
+ }
526
+ if (!response.body) return { ok: false, message: '响应没有 body' }
527
+ for await (const event of sseEvents(response.body, controller.signal)) {
528
+ if (event.event === 'hello') {
529
+ return { ok: true, hello: parseJsonSafe(event.data) || {} }
530
+ }
531
+ }
532
+ return { ok: false, message: '服务器没有发送 hello 事件' }
533
+ } catch (error) {
534
+ if (controller.signal.aborted) return { ok: false, message: `等待 hello 超时(${timeoutMs}ms)` }
535
+ return { ok: false, message: describeError(error) }
536
+ } finally {
537
+ clearTimeout(timer)
538
+ controller.abort()
539
+ }
540
+ }
541
+
542
+ // ---------------------------------------------------------------------------
543
+ // Command execution
544
+ // ---------------------------------------------------------------------------
545
+
546
+ /**
547
+ * Build the host-native shell invocation.
548
+ * POSIX: /bin/sh -lc <command> (login shell so the user's PATH is available).
549
+ * Windows: let Node build `cmd.exe /d /s /c <command>` via the `shell` option;
550
+ * passing the command as an argv element would double-apply CreateProcess
551
+ * quoting and break metacharacters.
552
+ */
553
+ function shellSpawnSpec(command) {
554
+ if (process.platform === 'win32') {
555
+ return { command, args: [], detached: false, shell: process.env.ComSpec || 'cmd.exe' }
556
+ }
557
+ return { command: '/bin/sh', args: ['-lc', command], detached: true, shell: false }
558
+ }
559
+
560
+ function resolveCwd(cwd) {
561
+ const home = os.homedir()
562
+ if (typeof cwd !== 'string' || cwd.trim() === '') return home
563
+ let value = cwd.trim()
564
+ if (value === '~') value = home
565
+ else if (value.startsWith('~/') || value.startsWith('~\\')) value = path.join(home, value.slice(2))
566
+ return path.resolve(value)
567
+ }
568
+
569
+ /** Bounded byte capture for one output stream; never grows past `limit`. */
570
+ function createCapture(limit = MAX_OUTPUT_BYTES) {
571
+ const chunks = []
572
+ let size = 0
573
+ let overflow = false
574
+ return {
575
+ push(chunk) {
576
+ if (overflow) return
577
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
578
+ const room = limit - size
579
+ if (buffer.length > room) {
580
+ if (room > 0) {
581
+ chunks.push(buffer.subarray(0, room))
582
+ size = limit
583
+ }
584
+ overflow = true
585
+ return
586
+ }
587
+ chunks.push(buffer)
588
+ size += buffer.length
589
+ },
590
+ isTruncated: () => overflow,
591
+ text: () => Buffer.concat(chunks, size).toString('utf8'),
592
+ }
593
+ }
594
+
595
+ function normalizeTimeout(value) {
596
+ const number = Number(value)
597
+ if (!Number.isFinite(number) || number <= 0) return DEFAULT_EXEC_TIMEOUT_MS
598
+ return Math.min(Math.max(1, Math.floor(number)), MAX_EXEC_TIMEOUT_MS)
599
+ }
600
+
601
+ /**
602
+ * Run one command. Resolves with exactly the §3.3 result shape (minus
603
+ * requestId):
604
+ * { ok, exitCode, signal, stdout, stderr, durationMs, truncated, timedOut, error }
605
+ *
606
+ * The child is spawned detached on POSIX so the whole process group can be
607
+ * SIGKILLed on timeout/cancel; otherwise `sh -lc 'cmd | pipeline'` would leave
608
+ * grandchildren running and the timeout would not actually stop the work.
609
+ */
610
+ function executeCommand({ command, cwd, timeoutMs }) {
611
+ const startedAt = Date.now()
612
+ const limit = normalizeTimeout(timeoutMs)
613
+ const stdout = createCapture()
614
+ const stderr = createCapture()
615
+ let child
616
+ let finished = false
617
+ let timedOut = false
618
+ let cancelled = false
619
+ let timeoutTimer
620
+ let killTimer
621
+ let resolvePromise
622
+ const promise = new Promise((resolve) => {
623
+ resolvePromise = resolve
624
+ })
625
+
626
+ const settle = (exitCode, signalCode, errorMessage) => {
627
+ if (finished) return
628
+ finished = true
629
+ clearTimeout(timeoutTimer)
630
+ clearTimeout(killTimer)
631
+ const truncated = stdout.isTruncated() || stderr.isTruncated()
632
+ resolvePromise({
633
+ ok: exitCode === 0 && !timedOut && !cancelled,
634
+ exitCode: typeof exitCode === 'number' ? exitCode : null,
635
+ signal: signalCode ?? null,
636
+ stdout: stdout.text() + (stdout.isTruncated() ? TRUNCATION_MARKER : ''),
637
+ stderr: stderr.text() + (stderr.isTruncated() ? TRUNCATION_MARKER : ''),
638
+ durationMs: Date.now() - startedAt,
639
+ truncated,
640
+ timedOut,
641
+ error: errorMessage ?? (cancelled ? 'cancelled' : null),
642
+ })
643
+ }
644
+
645
+ const killGroup = () => {
646
+ if (finished) return
647
+ if (!child || child.pid === undefined) return
648
+ if (process.platform === 'win32') {
649
+ try {
650
+ spawn('taskkill', ['/pid', String(child.pid), '/t', '/f'], { stdio: 'ignore', windowsHide: true }).unref()
651
+ } catch {
652
+ // best effort
653
+ }
654
+ } else {
655
+ try {
656
+ // Negative pid = the child's process group (it is a group leader).
657
+ process.kill(-child.pid, 'SIGKILL')
658
+ } catch {
659
+ try {
660
+ child.kill('SIGKILL')
661
+ } catch {
662
+ // already gone
663
+ }
664
+ }
665
+ }
666
+ // SIGKILL cannot be ignored, but guard against a wedged close event so the
667
+ // result is always reported.
668
+ killTimer = setTimeout(() => settle(null, 'SIGKILL', cancelled ? 'cancelled' : '命令在终止后仍未退出'), 5000)
669
+ killTimer.unref?.()
670
+ }
671
+
672
+ try {
673
+ const spec = shellSpawnSpec(String(command ?? ''))
674
+ child = spawn(spec.command, spec.args, {
675
+ cwd: resolveCwd(cwd),
676
+ env: process.env,
677
+ detached: spec.detached,
678
+ stdio: ['ignore', 'pipe', 'pipe'],
679
+ shell: spec.shell,
680
+ windowsHide: true,
681
+ })
682
+ } catch (error) {
683
+ settle(null, null, `无法启动命令:${describeError(error)}`)
684
+ return { promise, cancel() {} }
685
+ }
686
+
687
+ child.stdout?.on('data', (chunk) => stdout.push(chunk))
688
+ child.stderr?.on('data', (chunk) => stderr.push(chunk))
689
+ child.on('error', (error) => settle(null, null, `命令进程错误:${describeError(error)}`))
690
+ child.on('close', (code, signalCode) => settle(code, signalCode, null))
691
+ timeoutTimer = setTimeout(() => {
692
+ timedOut = true
693
+ killGroup()
694
+ }, limit)
695
+
696
+ return {
697
+ promise,
698
+ cancel() {
699
+ if (finished) return
700
+ cancelled = true
701
+ killGroup()
702
+ },
703
+ }
704
+ }
705
+
706
+ // ---------------------------------------------------------------------------
707
+ // results / heartbeat client (one runtime instance per startRuntime() call)
708
+ // ---------------------------------------------------------------------------
709
+
710
+ async function postResult(runtime, payload) {
711
+ const config = runtime.config
712
+ const url = resultsUrl(config.serverUrl)
713
+ for (let attempt = 1; attempt <= 3; attempt++) {
714
+ try {
715
+ const response = await requestJson(url, { method: 'POST', token: config.token, body: payload, timeoutMs: 30_000 })
716
+ if (response.status === 200) return true
717
+ if (response.status === 404) {
718
+ runtimeLog(runtime, 'info', '结果被服务器忽略(该请求已结算或已超时)')
719
+ return false
720
+ }
721
+ if (response.status === 401) {
722
+ if (isInvalidTokenRejection(response.status, response.text)) {
723
+ runtimeLog(runtime, 'error', '结果上报被拒绝(invalid-token):token 已失效,需要重新 enroll')
724
+ return false
725
+ }
726
+ // Bare/foreign 401 (server reloading): retry like any transient failure.
727
+ throw new Error(`HTTP 401${response.text ? ` ${abbreviate(response.text, 80)}` : ''}`)
728
+ }
729
+ throw new Error(`HTTP ${response.status}${response.text ? ` ${abbreviate(response.text, 100)}` : ''}`)
730
+ } catch (error) {
731
+ if (attempt === 3) {
732
+ runtimeLog(runtime, 'error', `结果上报失败(${payload.requestId}):${describeError(error)}`)
733
+ return false
734
+ }
735
+ await sleep(1000 * attempt)
736
+ }
737
+ }
738
+ return false
739
+ }
740
+
741
+ async function sendHeartbeat(runtime) {
742
+ const config = runtime.config
743
+ const state = runtime.state
744
+ try {
745
+ const response = await requestJson(heartbeatUrl(config.serverUrl), {
746
+ method: 'POST',
747
+ token: config.token,
748
+ body: {
749
+ meta: {
750
+ cpuModel: machineInfo('').cpuModel,
751
+ memoryBytes: os.totalmem(),
752
+ agentVersion: AGENT_VERSION,
753
+ },
754
+ },
755
+ timeoutMs: 15_000,
756
+ })
757
+ if (response.status === 401) {
758
+ if (isInvalidTokenRejection(response.status, response.text)) {
759
+ runtimeLog(runtime, 'error', '心跳被拒绝(invalid-token):token 已失效,请重新 enroll')
760
+ } else {
761
+ logHeartbeatError(runtime, `HTTP 401${response.text ? ` ${abbreviate(response.text, 60)}` : ''}(服务器可能正在重启)`)
762
+ }
763
+ return
764
+ }
765
+ if (!response.ok) {
766
+ logHeartbeatError(runtime, `HTTP ${response.status}`)
767
+ return
768
+ }
769
+ const name = response.json && typeof response.json.name === 'string' ? response.json.name : ''
770
+ if (name && name !== state.name) {
771
+ state.name = name
772
+ config.name = name
773
+ emitRuntimeState(runtime)
774
+ runtimeLog(runtime, 'info', `服务器把电脑名称更新为 ${name}`)
775
+ }
776
+ } catch (error) {
777
+ logHeartbeatError(runtime, describeError(error))
778
+ }
779
+ }
780
+
781
+ function logHeartbeatError(runtime, message) {
782
+ const now = Date.now()
783
+ if (now - runtime.state.lastHeartbeatErrorAt < HEARTBEAT_LOG_INTERVAL_MS) return
784
+ runtime.state.lastHeartbeatErrorAt = now
785
+ runtimeLog(runtime, 'warn', `心跳失败:${message}`)
786
+ }
787
+
788
+ // ---------------------------------------------------------------------------
789
+ // SSE event handlers
790
+ // ---------------------------------------------------------------------------
791
+
792
+ function handleExec(runtime, rawData) {
793
+ const request = parseJsonSafe(rawData)
794
+ if (!request || typeof request.requestId !== 'string' || typeof request.command !== 'string') {
795
+ runtimeLog(runtime, 'warn', '忽略一条字段不完整的 exec 事件')
796
+ return
797
+ }
798
+ if (runtime.stopping || !runtime.state.running) {
799
+ runtimeLog(runtime, 'warn', `运行时已停止,忽略请求 ${request.requestId}`)
800
+ return
801
+ }
802
+ if (runtime.jobs.has(request.requestId)) {
803
+ runtimeLog(runtime, 'warn', `忽略重复的请求 ${request.requestId}`)
804
+ return
805
+ }
806
+ const job = executeCommand({ command: request.command, cwd: request.cwd ?? null, timeoutMs: request.timeoutMs })
807
+ runtime.jobs.set(request.requestId, job)
808
+ runtimeLog(runtime, 'info', `▶ ${request.requestId} ${abbreviate(request.command)}`)
809
+ void job.promise
810
+ .then(async (result) => {
811
+ runtimeLog(
812
+ runtime,
813
+ 'info',
814
+ `◀ ${request.requestId} exit=${result.exitCode ?? 'null'} ${result.durationMs}ms` +
815
+ `${result.timedOut ? ' timedOut' : ''}${result.truncated ? ' truncated' : ''}`,
816
+ )
817
+ if (runtime.stopping) {
818
+ runtimeLog(runtime, 'info', `运行时已停止,不再上报结果 ${request.requestId}`)
819
+ return
820
+ }
821
+ await postResult(runtime, { requestId: request.requestId, ...result })
822
+ })
823
+ .catch((error) => {
824
+ runtimeLog(runtime, 'error', `✗ ${request.requestId} 执行失败:${describeError(error)}`)
825
+ })
826
+ .finally(() => {
827
+ runtime.jobs.delete(request.requestId)
828
+ })
829
+ }
830
+
831
+ function handleCancel(runtime, rawData) {
832
+ const request = parseJsonSafe(rawData)
833
+ if (!request || typeof request.requestId !== 'string') return
834
+ const job = runtime.jobs.get(request.requestId)
835
+ if (!job) {
836
+ runtimeLog(runtime, 'info', `取消了一个未知请求:${request.requestId}`)
837
+ return
838
+ }
839
+ runtimeLog(runtime, 'info', `取消 ${request.requestId}`)
840
+ job.cancel()
841
+ }
842
+
843
+ function applyConfigEvent(runtime, rawData) {
844
+ const value = parseJsonSafe(rawData)
845
+ if (!value) return
846
+ if (typeof value.name === 'string' && value.name && value.name !== runtime.state.name) {
847
+ runtime.state.name = value.name
848
+ runtime.config.name = value.name
849
+ emitRuntimeState(runtime)
850
+ runtimeLog(runtime, 'info', `服务器把电脑名称更新为 ${value.name}`)
851
+ }
852
+ if (value.execEnabled === false) {
853
+ runtimeLog(runtime, 'warn', '服务器已关闭远程执行(execEnabled=false),仍保持连接')
854
+ }
855
+ }
856
+
857
+ // ---------------------------------------------------------------------------
858
+ // Embeddable runtime (PROTOCOL §10.4) — owns SSE, exec, results, heartbeats
859
+ // ---------------------------------------------------------------------------
860
+
861
+ function requireOptionString(value, field) {
862
+ if (typeof value !== 'string' || value.trim() === '') {
863
+ throw new TypeError(`startRuntime: options.${field} must be a non-empty string`)
864
+ }
865
+ return value.trim()
866
+ }
867
+
868
+ function normalizeRuntimeHeartbeat(value) {
869
+ const number = Number(value)
870
+ if (!Number.isFinite(number) || number <= 0) return DEFAULT_HEARTBEAT_MS
871
+ return Math.min(Math.max(1000, Math.floor(number)), 300_000)
872
+ }
873
+
874
+ function createRuntime(options) {
875
+ if (!options || typeof options !== 'object' || Array.isArray(options)) {
876
+ throw new TypeError('startRuntime(options) requires an options object')
877
+ }
878
+ let serverUrl
879
+ try {
880
+ serverUrl = normalizeServerUrl(requireOptionString(options.serverUrl, 'serverUrl'))
881
+ } catch (error) {
882
+ if (error instanceof TypeError) throw error
883
+ throw new TypeError(`startRuntime: options.serverUrl is not a valid http(s) URL (${describeError(error)})`)
884
+ }
885
+ const config = {
886
+ serverUrl,
887
+ token: requireOptionString(options.token, 'token'),
888
+ computerId: requireOptionString(options.computerId, 'computerId'),
889
+ name: typeof options.name === 'string' ? options.name.trim() : '',
890
+ }
891
+ return {
892
+ config,
893
+ log: typeof options.log === 'function' ? options.log : defaultRuntimeLog,
894
+ onState: typeof options.onState === 'function' ? options.onState : null,
895
+ jobs: new Map(),
896
+ controller: new AbortController(),
897
+ stopping: false,
898
+ stoppedLogged: false,
899
+ emittingState: false,
900
+ lastStateKey: '',
901
+ loopPromise: null,
902
+ state: {
903
+ running: true,
904
+ connected: false,
905
+ heartbeatMs: normalizeRuntimeHeartbeat(options.heartbeatMs),
906
+ name: config.name,
907
+ since: null,
908
+ lastConnectedAt: null,
909
+ lastError: null,
910
+ attempts: 0,
911
+ exitCode: null,
912
+ lastPingLogAt: 0,
913
+ lastHeartbeatErrorAt: 0,
914
+ },
915
+ }
916
+ }
917
+
918
+ /**
919
+ * Start an embedded runtime (PROTOCOL §10.4).
920
+ *
921
+ * @param {{
922
+ * serverUrl: string,
923
+ * token: string,
924
+ * computerId: string,
925
+ * name?: string,
926
+ * heartbeatMs?: number,
927
+ * log?: (level: 'info'|'warn'|'error', message: string) => void,
928
+ * onState?: (state: object) => void,
929
+ * }} options
930
+ * @returns {{ stop: () => Promise<void>, status: () => object }}
931
+ *
932
+ * `status()` is synchronous and returns
933
+ * `{ running, connected, serverUrl, computerId, name, heartbeatMs, since,
934
+ * lastConnectedAt, lastError, attempts, exitCode }` —
935
+ * the host manager only needs `connected` / `since` / `lastError` from it.
936
+ *
937
+ * A fatal `{"error":"invalid-token"}` 401 does NOT throw: the loop ends, sets
938
+ * `lastError: 'invalid-token'`, `exitCode: 1`, and reports through `onState()`.
939
+ */
940
+ export function startRuntime(options) {
941
+ const runtime = createRuntime(options)
942
+ runtime.loopPromise = runRuntimeLoop(runtime)
943
+ .catch((error) => {
944
+ runtime.state.lastError = describeError(error)
945
+ runtimeLog(runtime, 'error', `运行时错误:${describeError(error)}`)
946
+ return 1
947
+ })
948
+ .then((exitCode) => {
949
+ runtime.state.exitCode = exitCode
950
+ runtime.state.running = false
951
+ runtime.state.connected = false
952
+ runtime.state.since = null
953
+ emitRuntimeState(runtime)
954
+ return exitCode
955
+ })
956
+ return {
957
+ stop: () => stopRuntime(runtime),
958
+ status: () => runtimeStatus(runtime),
959
+ }
960
+ }
961
+
962
+ function runtimeStatus(runtime) {
963
+ const state = runtime.state
964
+ return {
965
+ running: state.running,
966
+ connected: state.connected,
967
+ serverUrl: runtime.config.serverUrl,
968
+ computerId: runtime.config.computerId,
969
+ name: state.name || runtime.config.name,
970
+ heartbeatMs: state.heartbeatMs,
971
+ since: state.since,
972
+ lastConnectedAt: state.lastConnectedAt,
973
+ lastError: state.lastError,
974
+ attempts: state.attempts,
975
+ exitCode: state.exitCode,
976
+ }
977
+ }
978
+
979
+ /** Report a state snapshot; identical snapshots are skipped (one per transition). */
980
+ function emitRuntimeState(runtime) {
981
+ if (!runtime.onState || runtime.emittingState) return
982
+ const status = runtimeStatus(runtime)
983
+ const key = [status.running, status.connected, status.since, status.lastError, status.name, status.exitCode].join('|')
984
+ if (key === runtime.lastStateKey) return
985
+ runtime.lastStateKey = key
986
+ runtime.emittingState = true
987
+ try {
988
+ runtime.onState(status)
989
+ } catch {
990
+ // A broken host callback must not break the loop.
991
+ } finally {
992
+ runtime.emittingState = false
993
+ }
994
+ }
995
+
996
+ /** SIGKILL + reap every command still running for this runtime instance. */
997
+ async function cancelAndReapJobs(runtime) {
998
+ const jobs = [...runtime.jobs.values()]
999
+ for (const job of jobs) {
1000
+ try {
1001
+ job.cancel()
1002
+ } catch {
1003
+ // ignore
1004
+ }
1005
+ }
1006
+ if (jobs.length > 0) await Promise.allSettled(jobs.map((job) => job.promise))
1007
+ }
1008
+
1009
+ async function stopRuntime(runtime) {
1010
+ if (runtime.emittingState) {
1011
+ // A host may call stop() from inside onState(); awaiting the loop from
1012
+ // within that callback would deadlock, so defer by a tick.
1013
+ return new Promise((resolve) => {
1014
+ setTimeout(() => {
1015
+ void stopRuntime(runtime).then(resolve)
1016
+ }, 0)
1017
+ })
1018
+ }
1019
+ if (!runtime.stopping) {
1020
+ runtime.stopping = true
1021
+ runtimeLog(runtime, 'info', '正在停止运行时…')
1022
+ runtime.controller.abort()
1023
+ }
1024
+ await cancelAndReapJobs(runtime)
1025
+ if (runtime.loopPromise) await runtime.loopPromise.catch(() => {})
1026
+ // Reap jobs that a last in-flight event may have started before the flag.
1027
+ await cancelAndReapJobs(runtime)
1028
+ runtime.state.running = false
1029
+ runtime.state.connected = false
1030
+ runtime.state.since = null
1031
+ if (runtime.state.exitCode === null) runtime.state.exitCode = 0
1032
+ emitRuntimeState(runtime)
1033
+ if (!runtime.stoppedLogged) {
1034
+ runtime.stoppedLogged = true
1035
+ runtimeLog(runtime, 'info', '运行时已停止')
1036
+ }
1037
+ }
1038
+
1039
+ function jitteredDelay(base) {
1040
+ const factor = 0.75 + Math.random() * 0.5
1041
+ return Math.max(MIN_BACKOFF_MS, Math.min(MAX_BACKOFF_MS, Math.round(base * factor)))
1042
+ }
1043
+
1044
+ /**
1045
+ * The reconnect loop. Never throws for a fatal token rejection: it returns the
1046
+ * exit code (1 for invalid-token), and the caller (`startRuntime`) reports it.
1047
+ */
1048
+ async function runRuntimeLoop(runtime) {
1049
+ const config = runtime.config
1050
+ runtimeLog(runtime, 'info', `dsh-computers-agent ${AGENT_VERSION} 启动 · ${config.serverUrl} · computer=${config.computerId}`)
1051
+
1052
+ let backoff = MIN_BACKOFF_MS
1053
+ let lastTransientWarnAt = 0
1054
+ while (!runtime.controller.signal.aborted) {
1055
+ const attemptStartedAt = Date.now()
1056
+ runtime.state.attempts += 1
1057
+ let outcome
1058
+ try {
1059
+ outcome = await runOneSession(runtime, runtime.controller.signal)
1060
+ } catch (error) {
1061
+ if (runtime.controller.signal.aborted) break
1062
+ outcome = { kind: 'closed', reason: describeError(error), gotHello: false }
1063
+ }
1064
+
1065
+ if (outcome.kind === 'unauthorized') {
1066
+ // The server explicitly answered `{"error":"invalid-token"}` for our
1067
+ // bearer: retrying cannot fix it. Do not throw (PROTOCOL §10.4 — the
1068
+ // embedded host decides what to do); return the exit code for the CLI.
1069
+ runtime.state.connected = false
1070
+ runtime.state.since = null
1071
+ runtime.state.lastError = 'invalid-token'
1072
+ emitRuntimeState(runtime)
1073
+ runtimeLog(runtime, 'error', '✗ 服务器拒绝了已保存的凭据(HTTP 401 invalid-token)。token 已失效,请重新运行 enroll 完成注册。')
1074
+ return 1
1075
+ }
1076
+ if (runtime.controller.signal.aborted) break
1077
+
1078
+ runtime.state.connected = false
1079
+ runtime.state.since = null
1080
+ runtime.state.lastError = outcome.reason || 'unknown'
1081
+ emitRuntimeState(runtime)
1082
+
1083
+ const healthy = outcome.gotHello && Date.now() - attemptStartedAt > 60_000
1084
+ if (healthy) backoff = MIN_BACKOFF_MS
1085
+ const delay = jitteredDelay(backoff)
1086
+ if (outcome.transientServer) {
1087
+ // Plugin reload / server restart / generic /api fence: the token is not
1088
+ // the problem. Warn at most once a minute (not once per attempt) and keep
1089
+ // retrying forever.
1090
+ const now = Date.now()
1091
+ if (now - lastTransientWarnAt >= TRANSIENT_SERVER_WARN_INTERVAL_MS) {
1092
+ lastTransientWarnAt = now
1093
+ runtimeLog(
1094
+ runtime,
1095
+ 'warn',
1096
+ `⚠ 服务器暂时不可用(${outcome.reason || '未知原因'});保存的 token 未被拒绝,将继续重试(约 ${Math.round(delay / 1000)} 秒后)`,
1097
+ )
1098
+ }
1099
+ } else {
1100
+ runtimeLog(runtime, 'warn', `连接断开(${outcome.reason || '未知原因'}),${Math.round(delay / 1000)} 秒后重连`)
1101
+ }
1102
+ await sleep(delay, runtime.controller.signal)
1103
+ backoff = Math.min(backoff * 2, MAX_BACKOFF_MS)
1104
+ }
1105
+ runtimeLog(runtime, 'info', '已退出')
1106
+ return 0
1107
+ }
1108
+
1109
+ /**
1110
+ * One connection attempt. Returns:
1111
+ * { kind: 'unauthorized' } -> only for our JSON `invalid-token` 401
1112
+ * { kind: 'closed', reason, gotHello, transientServer? }
1113
+ * Throws only on transport errors (also retried).
1114
+ */
1115
+ async function runOneSession(runtime, signal) {
1116
+ const config = runtime.config
1117
+ const response = await fetch(eventsUrl(config.serverUrl), { headers: sseHeaders(config.token), signal })
1118
+ if (response.status === 401) {
1119
+ const body = await readErrorBody(response)
1120
+ if (isInvalidTokenRejection(response.status, body)) return { kind: 'unauthorized' }
1121
+ // A bare/foreign 401 is the plugin-reload / gateway-fence case: the token is
1122
+ // probably still valid, so keep retrying with the normal backoff.
1123
+ return {
1124
+ kind: 'closed',
1125
+ reason: `HTTP 401${body ? ` ${abbreviate(body, 80)}` : ''}(token 可能仍然有效)`,
1126
+ gotHello: false,
1127
+ transientServer: true,
1128
+ }
1129
+ }
1130
+ if (!response.ok) {
1131
+ const detail = await readErrorBody(response)
1132
+ return {
1133
+ kind: 'closed',
1134
+ reason: `HTTP ${response.status}${detail ? ` ${abbreviate(detail, 80)}` : ''}`,
1135
+ gotHello: false,
1136
+ transientServer: true,
1137
+ }
1138
+ }
1139
+ if (!response.body) return { kind: 'closed', reason: '响应没有 body', gotHello: false }
1140
+
1141
+ const state = runtime.state
1142
+ let gotHello = false
1143
+ let heartbeatTimer
1144
+
1145
+ const startHeartbeatTimer = () => {
1146
+ if (heartbeatTimer) return
1147
+ const intervalMs = Math.max(2000, Math.min(state.heartbeatMs || DEFAULT_HEARTBEAT_MS, 300_000))
1148
+ heartbeatTimer = setInterval(() => {
1149
+ void sendHeartbeat(runtime)
1150
+ }, intervalMs)
1151
+ heartbeatTimer.unref?.()
1152
+ }
1153
+
1154
+ try {
1155
+ for await (const event of sseEvents(response.body, signal)) {
1156
+ switch (event.event) {
1157
+ case 'hello': {
1158
+ const hello = parseJsonSafe(event.data) || {}
1159
+ gotHello = true
1160
+ if (typeof hello.name === 'string' && hello.name) state.name = hello.name
1161
+ if (typeof hello.heartbeatMs === 'number' && hello.heartbeatMs > 0) state.heartbeatMs = hello.heartbeatMs
1162
+ state.connected = true
1163
+ state.since = new Date().toISOString()
1164
+ state.lastConnectedAt = state.since
1165
+ state.lastError = null
1166
+ runtimeLog(
1167
+ runtime,
1168
+ 'info',
1169
+ `已连接 ${config.serverUrl} · computer=${config.computerId}` +
1170
+ `${state.name ? ` · name=${state.name}` : ''} · heartbeat=${state.heartbeatMs}ms`,
1171
+ )
1172
+ emitRuntimeState(runtime)
1173
+ startHeartbeatTimer()
1174
+ break
1175
+ }
1176
+ case 'exec':
1177
+ handleExec(runtime, event.data)
1178
+ break
1179
+ case 'cancel':
1180
+ handleCancel(runtime, event.data)
1181
+ break
1182
+ case 'ping': {
1183
+ const now = Date.now()
1184
+ if (now - state.lastPingLogAt >= HEARTBEAT_LOG_INTERVAL_MS) {
1185
+ state.lastPingLogAt = now
1186
+ runtimeLog(runtime, 'info', `心跳正常 · computer=${config.computerId}`)
1187
+ }
1188
+ break
1189
+ }
1190
+ case 'config':
1191
+ applyConfigEvent(runtime, event.data)
1192
+ break
1193
+ default:
1194
+ // Unknown event types are ignored for forward compatibility.
1195
+ break
1196
+ }
1197
+ }
1198
+ return { kind: 'closed', reason: '服务器关闭了事件流', gotHello }
1199
+ } finally {
1200
+ if (heartbeatTimer) clearInterval(heartbeatTimer)
1201
+ }
1202
+ }
1203
+
1204
+ // ---------------------------------------------------------------------------
1205
+ // CLI `run` — a thin wrapper around startRuntime() for foreground / launchd use
1206
+ // ---------------------------------------------------------------------------
1207
+
1208
+ async function cmdRun(argv) {
1209
+ const { values } = parseCliArgs(argv, { help: { type: 'boolean', short: 'h' } })
1210
+ if (values.help) {
1211
+ console.log(RUN_USAGE)
1212
+ return 0
1213
+ }
1214
+ const config = await loadConfig()
1215
+ ensureAgentHome()
1216
+
1217
+ const sink = createCliLogSink()
1218
+ let savedName = config.name
1219
+ let resolveDone
1220
+ const done = new Promise((resolve) => {
1221
+ resolveDone = resolve
1222
+ })
1223
+
1224
+ const runtime = startRuntime({
1225
+ serverUrl: config.serverUrl,
1226
+ token: config.token,
1227
+ computerId: config.computerId,
1228
+ name: config.name,
1229
+ heartbeatMs: DEFAULT_HEARTBEAT_MS,
1230
+ log: sink,
1231
+ onState: (state) => {
1232
+ // Keep the CLI config file in sync with server-driven renames; the
1233
+ // embedded runtime never writes ~/.dsh-computers.
1234
+ if (state.name && state.name !== savedName) {
1235
+ savedName = state.name
1236
+ config.name = state.name
1237
+ void saveConfig(config).catch(() => {})
1238
+ }
1239
+ if (!state.running) resolveDone(state)
1240
+ },
1241
+ })
1242
+
1243
+ let stopping = false
1244
+ const shutdown = (name) => {
1245
+ if (stopping) return
1246
+ stopping = true
1247
+ sink('info', `收到 ${name},正在退出`)
1248
+ void runtime.stop()
1249
+ }
1250
+ const onSigint = () => shutdown('SIGINT')
1251
+ const onSigterm = () => shutdown('SIGTERM')
1252
+ process.on('SIGINT', onSigint)
1253
+ process.on('SIGTERM', onSigterm)
1254
+
1255
+ try {
1256
+ await done
1257
+ } finally {
1258
+ process.off('SIGINT', onSigint)
1259
+ process.off('SIGTERM', onSigterm)
1260
+ // Idempotent: the fatal path already finished the loop, a signal path is
1261
+ // already stopping; this guarantees children are reaped either way.
1262
+ await runtime.stop().catch(() => {})
1263
+ }
1264
+ return runtime.status().exitCode ?? 0
1265
+ }
1266
+
1267
+ // ---------------------------------------------------------------------------
1268
+ // enroll
1269
+ // ---------------------------------------------------------------------------
1270
+
1271
+ async function cmdEnroll(argv) {
1272
+ let values
1273
+ try {
1274
+ ;({ values } = parseArgs({
1275
+ args: argv,
1276
+ options: {
1277
+ server: { type: 'string' },
1278
+ key: { type: 'string' },
1279
+ name: { type: 'string' },
1280
+ replace: { type: 'boolean' },
1281
+ help: { type: 'boolean', short: 'h' },
1282
+ },
1283
+ strict: true,
1284
+ allowPositionals: false,
1285
+ }))
1286
+ } catch (error) {
1287
+ throw new CliError(`参数错误:${describeError(error)}\n\n${ENROLL_USAGE}`)
1288
+ }
1289
+ if (values.help) {
1290
+ console.log(ENROLL_USAGE)
1291
+ return 0
1292
+ }
1293
+
1294
+ const serverUrl = normalizeServerUrl(values.server)
1295
+ const key = typeof values.key === 'string' ? values.key.trim() : ''
1296
+ if (!key) throw new CliError(`缺少 --key <key>(服务器生成的加入密钥)\n\n${ENROLL_USAGE}`)
1297
+ const requestedName = typeof values.name === 'string' ? values.name.trim() : ''
1298
+
1299
+ if (fs.existsSync(configFile())) {
1300
+ console.error(`提示:${configFile()} 已存在,本次注册会覆盖旧的凭据。`)
1301
+ }
1302
+
1303
+ const body = { key, agentVersion: AGENT_VERSION, machine: machineInfo(requestedName) }
1304
+ if (values.replace) body.replace = true
1305
+
1306
+ let response
1307
+ try {
1308
+ response = await requestJson(enrollUrl(serverUrl), { method: 'POST', body, timeoutMs: 30_000 })
1309
+ } catch (error) {
1310
+ throw new CliError(`无法连接 ${serverUrl}:${describeError(error)}`)
1311
+ }
1312
+
1313
+ if (response.status !== 200) {
1314
+ const code = (response.json && response.json.error) || `http-${response.status}`
1315
+ const message = (response.json && response.json.message) || (response.text || '').trim()
1316
+ throw new CliError(`注册失败(${code})${message ? `:${message}` : ''}`)
1317
+ }
1318
+
1319
+ const data = response.json
1320
+ if (
1321
+ !data ||
1322
+ typeof data.computerId !== 'string' ||
1323
+ typeof data.token !== 'string' ||
1324
+ typeof data.name !== 'string'
1325
+ ) {
1326
+ throw new CliError('服务器返回的注册响应缺少 computerId / token / name,请升级服务器端的 dsh-computers 插件。')
1327
+ }
1328
+
1329
+ const config = {
1330
+ version: 1,
1331
+ serverUrl: normalizeServerUrl(typeof data.serverUrl === 'string' && data.serverUrl ? data.serverUrl : serverUrl),
1332
+ computerId: data.computerId,
1333
+ token: data.token,
1334
+ name: data.name,
1335
+ enrolledAt: new Date().toISOString(),
1336
+ }
1337
+ await saveConfig(config)
1338
+ console.log(`✓ 凭据已保存到 ${configFile()}(仅本机可读,权限 0600)`)
1339
+
1340
+ // Prove the round trip once. Failure to receive hello is not fatal: the
1341
+ // enrollment itself succeeded and `run` retries forever.
1342
+ const probe = await probeEventStream(config, 5000)
1343
+ if (probe.unauthorized) {
1344
+ throw new CliError('服务器拒绝了这个 token(HTTP 401),请重新生成加入密钥。')
1345
+ }
1346
+ if (probe.ok) {
1347
+ const name = (probe.hello && probe.hello.name) || config.name
1348
+ console.log(`✓ 已连接 ${config.serverUrl},这台电脑现在是 ${name}`)
1349
+ } else {
1350
+ console.log(`… 凭据已保存,但实时连接探测未成功(${probe.message});run 会自动重试。`)
1351
+ }
1352
+
1353
+ console.log(`下一步:node "${SELF_PATH}" service install && node "${SELF_PATH}" service start`)
1354
+ console.log(` 或者前台运行:node "${SELF_PATH}" run`)
1355
+ return 0
1356
+ }
1357
+
1358
+ // ---------------------------------------------------------------------------
1359
+ // service (launchd on macOS, systemd --user on Linux, manual note on Windows)
1360
+ // ---------------------------------------------------------------------------
1361
+
1362
+ /** Promise wrapper around execFile that never rejects; returns { code, stdout, stderr }. */
1363
+ function runCommandFile(file, args, { timeout = 20_000 } = {}) {
1364
+ return new Promise((resolve) => {
1365
+ execFile(file, args, { timeout, encoding: 'utf8', windowsHide: true }, (error, stdout, stderr) => {
1366
+ if (error) {
1367
+ const code = typeof error.code === 'number' ? error.code : 1
1368
+ const message = String(stderr || error.message || '').trim()
1369
+ resolve({ code, stdout: String(stdout || ''), stderr: message })
1370
+ return
1371
+ }
1372
+ resolve({ code: 0, stdout: String(stdout || ''), stderr: String(stderr || '').trim() })
1373
+ })
1374
+ })
1375
+ }
1376
+
1377
+ function xmlEscape(value) {
1378
+ const text = String(value)
1379
+ if (text.includes('\0')) throw new CliError('服务配置项包含非法空字符')
1380
+ return text
1381
+ .replaceAll('&', '&amp;')
1382
+ .replaceAll('<', '&lt;')
1383
+ .replaceAll('>', '&gt;')
1384
+ .replaceAll('"', '&quot;')
1385
+ .replaceAll("'", '&apos;')
1386
+ }
1387
+
1388
+ function launchdContext() {
1389
+ const uid = typeof process.getuid === 'function' ? process.getuid() : 0
1390
+ return {
1391
+ label: SERVICE_LABEL,
1392
+ domain: `gui/${uid}`,
1393
+ target: `gui/${uid}/${SERVICE_LABEL}`,
1394
+ plistPath: launchdPlistPath(),
1395
+ }
1396
+ }
1397
+
1398
+ function renderLaunchdPlist(context) {
1399
+ const environment = {
1400
+ PATH: process.env.PATH || '/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin',
1401
+ // Tell `run` that launchd already redirects stdout/stderr into agent.log,
1402
+ // so it should only append its own structured lines (no duplicates).
1403
+ DSH_COMPUTERS_LOG_ONLY: '1',
1404
+ }
1405
+ if (process.env.DSH_COMPUTERS_HOME) environment.DSH_COMPUTERS_HOME = path.resolve(process.env.DSH_COMPUTERS_HOME)
1406
+ if (process.env.DSH_COMPUTERS_SERVICE_LABEL) environment.DSH_COMPUTERS_SERVICE_LABEL = SERVICE_LABEL
1407
+ const environmentXml = Object.entries(environment)
1408
+ .map(([name, value]) => ` <key>${xmlEscape(name)}</key>\n <string>${xmlEscape(value)}</string>`)
1409
+ .join('\n')
1410
+ return `<?xml version="1.0" encoding="UTF-8"?>
1411
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
1412
+ <plist version="1.0">
1413
+ <dict>
1414
+ <key>Label</key>
1415
+ <string>${xmlEscape(context.label)}</string>
1416
+ <key>ProgramArguments</key>
1417
+ <array>
1418
+ <string>${xmlEscape(process.execPath)}</string>
1419
+ <string>${xmlEscape(SELF_PATH)}</string>
1420
+ <string>run</string>
1421
+ </array>
1422
+ <key>EnvironmentVariables</key>
1423
+ <dict>
1424
+ ${environmentXml}
1425
+ </dict>
1426
+ <key>WorkingDirectory</key>
1427
+ <string>${xmlEscape(agentHome())}</string>
1428
+ <key>RunAtLoad</key>
1429
+ <true/>
1430
+ <key>KeepAlive</key>
1431
+ <true/>
1432
+ <key>ThrottleInterval</key>
1433
+ <integer>10</integer>
1434
+ <key>StandardOutPath</key>
1435
+ <string>${xmlEscape(logFile())}</string>
1436
+ <key>StandardErrorPath</key>
1437
+ <string>${xmlEscape(logFile())}</string>
1438
+ </dict>
1439
+ </plist>
1440
+ `
1441
+ }
1442
+
1443
+ async function launchdIsLoaded(context) {
1444
+ const result = await runCommandFile('/bin/launchctl', ['print', context.target])
1445
+ return result.code === 0
1446
+ }
1447
+
1448
+ async function launchdStatus(context) {
1449
+ const installed = fs.existsSync(context.plistPath)
1450
+ if (!installed) return { installed: false, loaded: false, pid: null }
1451
+ const loaded = await launchdIsLoaded(context)
1452
+ let pid = null
1453
+ if (loaded) {
1454
+ const result = await runCommandFile('/bin/launchctl', ['print', context.target])
1455
+ const match = result.stdout.match(/\bpid\s*=\s*(\d+)/)
1456
+ if (match) pid = Number(match[1])
1457
+ }
1458
+ return { installed, loaded, pid }
1459
+ }
1460
+
1461
+ async function launchdWaitLoaded(context, expected) {
1462
+ for (let attempt = 0; attempt < 25; attempt++) {
1463
+ if ((await launchdIsLoaded(context)) === expected) return true
1464
+ await sleep(200)
1465
+ }
1466
+ return (await launchdIsLoaded(context)) === expected
1467
+ }
1468
+
1469
+ async function launchdInstall(context) {
1470
+ ensureAgentHome()
1471
+ await ensureDir(path.dirname(context.plistPath))
1472
+ await atomicWriteFile(context.plistPath, renderLaunchdPlist(context), 0o600)
1473
+ const lint = await runCommandFile('/usr/bin/plutil', ['-lint', context.plistPath])
1474
+ console.log(`✓ launchd 配置已写入 ${context.plistPath}`)
1475
+ if (lint.code !== 0) {
1476
+ console.error(`警告:plutil 校验失败:${lint.stderr || lint.stdout}`)
1477
+ }
1478
+ if (!fs.existsSync(configFile())) {
1479
+ console.error('警告:还没有完成 enroll,服务启动后会反复重试直到注册成功。')
1480
+ }
1481
+ console.log(`下一步:node "${SELF_PATH}" service start`)
1482
+ return 0
1483
+ }
1484
+
1485
+ async function launchdStart(context) {
1486
+ if (!fs.existsSync(context.plistPath)) {
1487
+ console.error(`✗ 服务尚未安装(找不到 ${context.plistPath}),请先运行 service install。`)
1488
+ return 1
1489
+ }
1490
+ if (await launchdIsLoaded(context)) {
1491
+ console.log(`✓ 服务已在运行(${context.label})`)
1492
+ return 0
1493
+ }
1494
+ const bootstrap = await runCommandFile('/bin/launchctl', ['bootstrap', context.domain, context.plistPath])
1495
+ if (bootstrap.code !== 0) {
1496
+ // Legacy loader for older macOS releases / restricted sessions.
1497
+ const legacy = await runCommandFile('/bin/launchctl', ['load', '-w', context.plistPath])
1498
+ if (legacy.code !== 0 && !(await launchdIsLoaded(context))) {
1499
+ console.error(`✗ launchctl bootstrap/load 失败:${bootstrap.stderr || legacy.stderr || '未知错误'}`)
1500
+ return 1
1501
+ }
1502
+ }
1503
+ if (await launchdWaitLoaded(context, true)) {
1504
+ console.log(`✓ 服务已启动(${context.label})`)
1505
+ return 0
1506
+ }
1507
+ console.error('✗ 服务已提交但未出现在 launchctl 中,请查看日志:' + logFile())
1508
+ return 1
1509
+ }
1510
+
1511
+ async function launchdStop(context) {
1512
+ if (!(await launchdIsLoaded(context))) {
1513
+ console.log(`✓ 服务未在运行(${context.label})`)
1514
+ return 0
1515
+ }
1516
+ // bootout is asynchronous: keep polling until launchctl agrees it is gone.
1517
+ const bootout = await runCommandFile('/bin/launchctl', ['bootout', context.target])
1518
+ if (bootout.code !== 0 && fs.existsSync(context.plistPath)) {
1519
+ await runCommandFile('/bin/launchctl', ['unload', '-w', context.plistPath])
1520
+ }
1521
+ if (await launchdWaitLoaded(context, false)) {
1522
+ console.log(`✓ 服务已停止(${context.label})`)
1523
+ return 0
1524
+ }
1525
+ console.error('✗ 服务仍在运行,请检查 launchctl print ' + context.target)
1526
+ return 1
1527
+ }
1528
+
1529
+ async function launchdUninstall(context) {
1530
+ await launchdStop(context) // tolerant: reports "未在运行" when nothing is loaded
1531
+ try {
1532
+ await fsp.rm(context.plistPath, { force: true })
1533
+ } catch (error) {
1534
+ console.error(`✗ 无法删除 ${context.plistPath}:${describeError(error)}`)
1535
+ return 1
1536
+ }
1537
+ console.log('✓ launchd 服务已卸载(凭据与配置仍保留,可重新 enroll)')
1538
+ return 0
1539
+ }
1540
+
1541
+ async function launchdCommand(action) {
1542
+ const context = launchdContext()
1543
+ switch (action) {
1544
+ case 'install':
1545
+ return launchdInstall(context)
1546
+ case 'uninstall':
1547
+ return launchdUninstall(context)
1548
+ case 'start':
1549
+ return launchdStart(context)
1550
+ case 'stop':
1551
+ return launchdStop(context)
1552
+ case 'restart':
1553
+ await launchdStop(context)
1554
+ return launchdStart(context)
1555
+ case 'status': {
1556
+ const status = await launchdStatus(context)
1557
+ const label = !status.installed ? '未安装' : status.loaded ? `运行中${status.pid ? ` (pid ${status.pid})` : ''}` : '已安装,未运行'
1558
+ console.log(`launchd: ${label}`)
1559
+ console.log(`plist: ${context.plistPath}`)
1560
+ return 0
1561
+ }
1562
+ default:
1563
+ console.error(`未知的 service 子命令:${action}`)
1564
+ return 1
1565
+ }
1566
+ }
1567
+
1568
+ // --- Linux: systemd --user, with a nohup fallback -------------------------
1569
+
1570
+ function systemdContext() {
1571
+ return {
1572
+ unitName: `${SERVICE_LABEL}.service`,
1573
+ unitPath: systemdUnitPath(),
1574
+ }
1575
+ }
1576
+
1577
+ function systemdQuote(value) {
1578
+ const text = String(value)
1579
+ return /[\s"\\]/.test(text) ? `"${text.replaceAll('\\', '\\\\').replaceAll('"', '\\"')}"` : text
1580
+ }
1581
+
1582
+ function renderSystemdUnit(context) {
1583
+ const environmentPath = process.env.PATH || '/usr/local/bin:/usr/bin:/bin:/usr/local/sbin:/usr/sbin:/sbin'
1584
+ const environmentHome = process.env.DSH_COMPUTERS_HOME
1585
+ ? `Environment=${systemdQuote(`DSH_COMPUTERS_HOME=${path.resolve(process.env.DSH_COMPUTERS_HOME)}`)}\n`
1586
+ : ''
1587
+ const environmentLabel = process.env.DSH_COMPUTERS_SERVICE_LABEL
1588
+ ? `Environment=${systemdQuote(`DSH_COMPUTERS_SERVICE_LABEL=${SERVICE_LABEL}`)}\n`
1589
+ : ''
1590
+ return `[Unit]
1591
+ Description=dsh-computers agent (run shell commands for dsh-web)
1592
+ After=network-online.target
1593
+
1594
+ [Service]
1595
+ Type=simple
1596
+ ExecStart=${systemdQuote(process.execPath)} ${systemdQuote(SELF_PATH)} run
1597
+ WorkingDirectory=${systemdQuote(agentHome())}
1598
+ Environment=${systemdQuote(`PATH=${environmentPath}`)}
1599
+ ${environmentHome}${environmentLabel}Restart=always
1600
+ RestartSec=5
1601
+ StandardOutput=append:${systemdQuote(logFile())}
1602
+ StandardError=append:${systemdQuote(logFile())}
1603
+
1604
+ [Install]
1605
+ WantedBy=default.target
1606
+ `
1607
+ }
1608
+
1609
+ async function systemctlAvailable() {
1610
+ const result = await runCommandFile('systemctl', ['--user', '--version'], { timeout: 5000 })
1611
+ return result.code === 0
1612
+ }
1613
+
1614
+ async function systemdInstall(context) {
1615
+ ensureAgentHome()
1616
+ await ensureDir(path.dirname(context.unitPath))
1617
+ await atomicWriteFile(context.unitPath, renderSystemdUnit(context), 0o644)
1618
+ console.log(`✓ systemd 用户单元已写入 ${context.unitPath}`)
1619
+ if (await systemctlAvailable()) {
1620
+ const reload = await runCommandFile('systemctl', ['--user', 'daemon-reload'])
1621
+ if (reload.code !== 0) console.error(`警告:daemon-reload 失败:${reload.stderr || reload.stdout}`)
1622
+ } else {
1623
+ console.error('警告:找不到可用的 systemctl --user,service start 会退化为 nohup 后台启动。')
1624
+ }
1625
+ if (!fs.existsSync(configFile())) {
1626
+ console.error('警告:还没有完成 enroll,服务启动后会反复重试直到注册成功。')
1627
+ }
1628
+ console.log(`下一步:node "${SELF_PATH}" service start`)
1629
+ return 0
1630
+ }
1631
+
1632
+ async function systemdStart(context) {
1633
+ if (!fs.existsSync(context.unitPath)) {
1634
+ console.error(`✗ 服务尚未安装(找不到 ${context.unitPath}),请先运行 service install。`)
1635
+ return 1
1636
+ }
1637
+ if (await systemctlAvailable()) {
1638
+ const start = await runCommandFile('systemctl', ['--user', 'enable', '--now', context.unitName])
1639
+ if (start.code !== 0) {
1640
+ console.error(`✗ systemctl --user enable --now 失败:${start.stderr || start.stdout}`)
1641
+ return 1
1642
+ }
1643
+ console.log(`✓ 服务已启动(${context.unitName})`)
1644
+ return 0
1645
+ }
1646
+ return nohupStart()
1647
+ }
1648
+
1649
+ async function systemdStop(context) {
1650
+ if (await systemctlAvailable()) {
1651
+ // disable --now exits non-zero when the unit is not loaded; that is fine.
1652
+ await runCommandFile('systemctl', ['--user', 'disable', '--now', context.unitName])
1653
+ console.log(`✓ 服务已停止(${context.unitName})`)
1654
+ return 0
1655
+ }
1656
+ return nohupStop()
1657
+ }
1658
+
1659
+ async function systemdUninstall(context) {
1660
+ if (await systemctlAvailable()) {
1661
+ await runCommandFile('systemctl', ['--user', 'disable', '--now', context.unitName])
1662
+ }
1663
+ // Also clean up a previous nohup fallback, without duplicating output.
1664
+ await nohupStop({ quiet: true }).catch(() => {})
1665
+ try {
1666
+ await fsp.rm(context.unitPath, { force: true })
1667
+ } catch (error) {
1668
+ console.error(`✗ 无法删除 ${context.unitPath}:${describeError(error)}`)
1669
+ return 1
1670
+ }
1671
+ if (await systemctlAvailable()) await runCommandFile('systemctl', ['--user', 'daemon-reload'])
1672
+ console.log('✓ systemd 服务已卸载(凭据与配置仍保留,可重新 enroll)')
1673
+ return 0
1674
+ }
1675
+
1676
+ async function systemdStatus(context) {
1677
+ const installed = fs.existsSync(context.unitPath)
1678
+ let state = '未安装'
1679
+ if (installed) {
1680
+ if (await systemctlAvailable()) {
1681
+ const active = await runCommandFile('systemctl', ['--user', 'is-active', context.unitName])
1682
+ state = active.code === 0 ? '运行中' : '已安装,未运行'
1683
+ } else {
1684
+ state = '已安装(无 systemctl --user,使用 nohup 回退)'
1685
+ }
1686
+ }
1687
+ console.log(`systemd: ${state}`)
1688
+ console.log(`unit: ${context.unitPath}`)
1689
+ return 0
1690
+ }
1691
+
1692
+ // --- nohup fallback (Linux without systemd --user) ------------------------
1693
+
1694
+ function readPidFile() {
1695
+ try {
1696
+ const pid = Number(fs.readFileSync(nohupPidFile(), 'utf8').trim())
1697
+ return Number.isInteger(pid) && pid > 0 ? pid : null
1698
+ } catch {
1699
+ return null
1700
+ }
1701
+ }
1702
+
1703
+ function processAlive(pid) {
1704
+ try {
1705
+ process.kill(pid, 0)
1706
+ return true
1707
+ } catch {
1708
+ return false
1709
+ }
1710
+ }
1711
+
1712
+ async function nohupStart() {
1713
+ const existing = readPidFile()
1714
+ if (existing && processAlive(existing)) {
1715
+ console.log(`✓ 已在后台运行(pid ${existing})`)
1716
+ return 0
1717
+ }
1718
+ ensureAgentHome()
1719
+ const out = fs.openSync(logFile(), 'a', 0o600)
1720
+ try {
1721
+ const child = spawn(process.execPath, [SELF_PATH, 'run'], {
1722
+ detached: true,
1723
+ stdio: ['ignore', out, out],
1724
+ cwd: agentHome(),
1725
+ env: process.env,
1726
+ })
1727
+ child.unref()
1728
+ fs.writeFileSync(nohupPidFile(), `${child.pid}\n`, { mode: 0o600 })
1729
+ console.log(`✓ 已在后台启动(nohup 回退,pid ${child.pid})`)
1730
+ } finally {
1731
+ try {
1732
+ fs.closeSync(out)
1733
+ } catch {
1734
+ // ignore
1735
+ }
1736
+ }
1737
+ return 0
1738
+ }
1739
+
1740
+ async function nohupStop({ quiet = false } = {}) {
1741
+ const pid = readPidFile()
1742
+ if (!pid || !processAlive(pid)) {
1743
+ try {
1744
+ await fsp.rm(nohupPidFile(), { force: true })
1745
+ } catch {
1746
+ // ignore
1747
+ }
1748
+ if (!quiet) console.log('✓ 服务未在运行')
1749
+ return 0
1750
+ }
1751
+ try {
1752
+ process.kill(pid, 'SIGTERM')
1753
+ } catch {
1754
+ // already gone
1755
+ }
1756
+ for (let attempt = 0; attempt < 25 && processAlive(pid); attempt++) await sleep(200)
1757
+ if (processAlive(pid)) {
1758
+ try {
1759
+ process.kill(pid, 'SIGKILL')
1760
+ } catch {
1761
+ // ignore
1762
+ }
1763
+ }
1764
+ try {
1765
+ await fsp.rm(nohupPidFile(), { force: true })
1766
+ } catch {
1767
+ // ignore
1768
+ }
1769
+ if (!quiet) console.log('✓ 服务已停止')
1770
+ return 0
1771
+ }
1772
+
1773
+ async function systemdCommand(action) {
1774
+ const context = systemdContext()
1775
+ switch (action) {
1776
+ case 'install':
1777
+ return systemdInstall(context)
1778
+ case 'uninstall':
1779
+ return systemdUninstall(context)
1780
+ case 'start':
1781
+ return systemdStart(context)
1782
+ case 'stop':
1783
+ return systemdStop(context)
1784
+ case 'restart':
1785
+ await systemdStop(context)
1786
+ return systemdStart(context)
1787
+ case 'status':
1788
+ return systemdStatus(context)
1789
+ default:
1790
+ console.error(`未知的 service 子命令:${action}`)
1791
+ return 1
1792
+ }
1793
+ }
1794
+
1795
+ function windowsServiceNote() {
1796
+ console.log('Windows 自动安装服务尚未实现(v1),请手动运行:')
1797
+ console.log(` node "${SELF_PATH}" run`)
1798
+ console.log('开机自启:任务计划程序 → 创建任务 → 触发器“登录时” → 操作“启动程序”')
1799
+ console.log(` 程序:"${process.execPath}"`)
1800
+ console.log(` 参数:"${SELF_PATH}" run`)
1801
+ console.log(`配置与日志位于 ${agentHome()}`)
1802
+ return 0
1803
+ }
1804
+
1805
+ async function cmdService(argv) {
1806
+ const action = String(argv[0] ?? '').toLowerCase()
1807
+ if (!SERVICE_ACTIONS.includes(action)) {
1808
+ console.error(`用法:dsh-computers-agent service <${SERVICE_ACTIONS.join('|')}>`)
1809
+ return 1
1810
+ }
1811
+ if (process.platform === 'darwin') return launchdCommand(action)
1812
+ if (process.platform === 'linux') return systemdCommand(action)
1813
+ return windowsServiceNote()
1814
+ }
1815
+
1816
+ // ---------------------------------------------------------------------------
1817
+ // status
1818
+ // ---------------------------------------------------------------------------
1819
+
1820
+ async function serviceSummary() {
1821
+ try {
1822
+ if (process.platform === 'darwin') {
1823
+ const context = launchdContext()
1824
+ const status = await launchdStatus(context)
1825
+ if (!status.installed) return '未安装'
1826
+ if (!status.loaded) return '已安装,未运行'
1827
+ return `运行中${status.pid ? ` (pid ${status.pid})` : ''}`
1828
+ }
1829
+ if (process.platform === 'linux') {
1830
+ const context = systemdContext()
1831
+ if (!fs.existsSync(context.unitPath)) return '未安装'
1832
+ if (await systemctlAvailable()) {
1833
+ const active = await runCommandFile('systemctl', ['--user', 'is-active', context.unitName])
1834
+ return active.code === 0 ? '运行中' : '已安装,未运行'
1835
+ }
1836
+ return '已安装(无 systemctl --user)'
1837
+ }
1838
+ return 'Windows 请手动运行(v1)'
1839
+ } catch (error) {
1840
+ return `未知(${describeError(error)})`
1841
+ }
1842
+ }
1843
+
1844
+ async function cmdStatus(argv) {
1845
+ const { values } = parseCliArgs(argv, { help: { type: 'boolean', short: 'h' } })
1846
+ if (values.help) {
1847
+ console.log(STATUS_USAGE)
1848
+ return 0
1849
+ }
1850
+ console.log(`dsh-computers-agent ${AGENT_VERSION}`)
1851
+ let config
1852
+ try {
1853
+ config = await loadConfig()
1854
+ } catch (error) {
1855
+ console.log('注册状态:未注册')
1856
+ console.log(`配置:${configFile()}(不存在或不可用)`)
1857
+ console.log(`提示:${describeError(error)}`)
1858
+ return 1
1859
+ }
1860
+ console.log('注册状态:已注册')
1861
+ console.log(`服务器:${config.serverUrl}`)
1862
+ console.log(`电脑:${config.name}(${config.computerId})`)
1863
+ console.log(`注册时间:${config.enrolledAt || '未知'}`)
1864
+ console.log(`Token:${redactToken(config.token)}(已脱敏)`)
1865
+ console.log(`配置:${configFile()}`)
1866
+ console.log(`日志:${logFile()}`)
1867
+ console.log(`服务:${await serviceSummary()}`)
1868
+ return 0
1869
+ }
1870
+
1871
+ // ---------------------------------------------------------------------------
1872
+ // CLI plumbing
1873
+ // ---------------------------------------------------------------------------
1874
+
1875
+ const ENROLL_USAGE = `用法:dsh-computers-agent enroll --server <url> --key <key> [--name <name>] [--replace]
1876
+
1877
+ --server <url> dsh-web 的地址,例如 http://127.0.0.1:3000
1878
+ --key <key> 服务器生成的加入密钥(dshc_…),只能使用一次
1879
+ --name <name> 显示在服务器电脑列表里的名称,默认使用主机名
1880
+ --replace 让服务器用这台电脑的新凭据替换同名记录`
1881
+
1882
+ const RUN_USAGE = `用法:dsh-computers-agent run
1883
+
1884
+ 连接服务器并等待执行请求(前台运行,Ctrl-C 退出)。
1885
+ 需要先完成 enroll;凭据保存在 ${configFile()}。`
1886
+
1887
+ const STATUS_USAGE = `用法:dsh-computers-agent status
1888
+
1889
+ 显示注册信息、脱敏后的 token 和服务状态。退出码 0 表示已注册,1 表示未注册。`
1890
+
1891
+ const HELP_TEXT = `dsh-computers-agent ${AGENT_VERSION}
1892
+ 把本机接入 dsh-web,让服务器上的 agent 通过 computer_exec 在这台电脑执行命令。
1893
+
1894
+ 用法 / Usage:
1895
+ dsh-computers-agent enroll --server <url> --key <key> [--name <name>] [--replace]
1896
+ 用服务器给出的加入密钥注册这台电脑,并把凭据保存到本机。
1897
+ dsh-computers-agent run
1898
+ 前台运行:保持 SSE 连接并执行服务器发来的命令(Ctrl-C 退出)。
1899
+ dsh-computers-agent service install|uninstall|start|stop|restart|status
1900
+ 安装 / 卸载 / 启动 / 停止登录自启服务(macOS launchd、Linux systemd --user)。
1901
+ dsh-computers-agent status
1902
+ 显示注册信息、脱敏后的 token 与服务状态。
1903
+ dsh-computers-agent --version
1904
+ dsh-computers-agent --help
1905
+
1906
+ 环境变量:
1907
+ DSH_COMPUTERS_HOME 配置与日志目录,默认 ~/.dsh-computers
1908
+ DSH_COMPUTERS_SERVICE_LABEL 覆盖 launchd/systemd 服务标识(默认 ${SERVICE_LABEL})
1909
+
1910
+ 凭据与安全:
1911
+ · 配置文件 ${configFile()} 权限 0600,原子写入。
1912
+ · token 只保存在本机,不会写入日志;status 只显示末 6 位。
1913
+ · 本程序只发起对外连接(enroll / SSE / 结果上报 / 心跳),不监听任何端口。
1914
+ · 命令以当前用户身份在 /bin/sh -lc 中执行,因此拥有与终端相同的权限。`
1915
+
1916
+ function parseCliArgs(args, options) {
1917
+ try {
1918
+ return parseArgs({ args, options, strict: true, allowPositionals: false })
1919
+ } catch (error) {
1920
+ throw new CliError(`参数错误:${describeError(error)}\n\n${HELP_TEXT}`)
1921
+ }
1922
+ }
1923
+
1924
+ async function main(argv) {
1925
+ if (process.versions?.node && Number(process.versions.node.split('.')[0]) < 22) {
1926
+ console.error(`✗ 需要 Node.js >= 22,当前是 ${process.version}。请升级 Node.js 后重试。`)
1927
+ return 1
1928
+ }
1929
+
1930
+ const [command, ...rest] = argv
1931
+ switch (command) {
1932
+ case undefined:
1933
+ case 'help':
1934
+ case '--help':
1935
+ case '-h':
1936
+ console.log(HELP_TEXT)
1937
+ return 0
1938
+ case 'version':
1939
+ case '--version':
1940
+ case '-v':
1941
+ console.log(AGENT_VERSION)
1942
+ return 0
1943
+ case 'enroll':
1944
+ return cmdEnroll(rest)
1945
+ case 'run':
1946
+ return cmdRun(rest)
1947
+ case 'service':
1948
+ return cmdService(rest)
1949
+ case 'status':
1950
+ return cmdStatus(rest)
1951
+ default:
1952
+ throw new CliError(`未知命令:${command}\n\n${HELP_TEXT}`)
1953
+ }
1954
+ }
1955
+
1956
+ /**
1957
+ * True only when this file is the process entry point (`node agent.mjs …`),
1958
+ * false when it is imported (e.g. `import { startRuntime } from …` by the host
1959
+ * plugin) or evaluated with `node -e`. Importing the module must never run CLI
1960
+ * code or touch the filesystem.
1961
+ *
1962
+ * `process.argv[1]` is resolved through pathToFileURL so relative paths work;
1963
+ * a realpath comparison keeps symlinked installs working, and comparing the
1964
+ * URL keeps `--preserve-symlinks-main` working.
1965
+ */
1966
+ function isProcessEntryPoint() {
1967
+ const entry = process.argv[1]
1968
+ if (!entry) return false // `node -e`, REPL, `import()`
1969
+ try {
1970
+ if (pathToFileURL(path.resolve(entry)).href === import.meta.url) return true
1971
+ return fs.realpathSync(path.resolve(entry)) === fs.realpathSync(fileURLToPath(import.meta.url))
1972
+ } catch {
1973
+ return false
1974
+ }
1975
+ }
1976
+
1977
+ if (isProcessEntryPoint()) {
1978
+ main(process.argv.slice(2))
1979
+ .then((code) => {
1980
+ process.exitCode = code
1981
+ // If an idle HttpClient keeps the event loop alive, do not hang forever.
1982
+ const forced = setTimeout(() => process.exit(code), 5000)
1983
+ forced.unref?.()
1984
+ })
1985
+ .catch((error) => {
1986
+ const message = error instanceof CliError ? error.message : `✗ ${describeError(error)}`
1987
+ process.stderr.write(`${message}\n`)
1988
+ if (!(error instanceof CliError)) appendLog(`fatal: ${describeError(error)}${error?.stack ? `\n${error.stack}` : ''}`)
1989
+ process.exitCode = error instanceof CliError ? error.exitCode : 1
1990
+ const forced = setTimeout(() => process.exit(process.exitCode ?? 1), 5000)
1991
+ forced.unref?.()
1992
+ })
1993
+ }