@hunan36/dsh-ssh-helper 0.0.0-stage → 0.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.
package/host/update.js ADDED
@@ -0,0 +1,992 @@
1
+ /**
2
+ * Approval-gated remote update and rollback service.
3
+ *
4
+ * `plan` performs only read-only preflight work, caches a short-lived plan bound
5
+ * to one agent, and returns it. `apply` re-validates the plan, backs up the
6
+ * current directory, runs update/restart/verify commands, prunes old backups and
7
+ * records history; any failure stops the sequence, keeps the backup and never
8
+ * auto-rolls back. `rollback` restores one archived backup.
9
+ *
10
+ * The service itself does not know about the approval pipeline: `host/gate.js`
11
+ * is the only place that turns a valid plan/history entry into an approval ask.
12
+ *
13
+ * @module host/update
14
+ */
15
+
16
+ import { randomUUID } from 'node:crypto'
17
+ import path from 'node:path'
18
+ import { buildInspectCommand, parseInspectOutput } from './discover.js'
19
+ import { backupLayout, remotePathWarnings, safeSegment, shQuote, validateRemotePath } from './shell.js'
20
+
21
+ /** Cached plan lifetime (15 minutes). */
22
+ const PLAN_TTL_MS = 15 * 60 * 1000
23
+ /** Maximum cached plans. */
24
+ const PLAN_MAX = 50
25
+ /** Maximum pinned rollback targets. */
26
+ const ROLLBACK_MAX = 50
27
+ /** Default preflight timeout (60 s). */
28
+ const DEFAULT_PREFLIGHT_TIMEOUT_MS = 60_000
29
+ /** Minimum accepted timeout (1 s). */
30
+ const MIN_TIMEOUT_MS = 1_000
31
+ /** Maximum accepted timeout (10 min). */
32
+ const MAX_TIMEOUT_MS = 600_000
33
+ /** Captured output tail length. */
34
+ const TAIL_MAX_LENGTH = 8192
35
+ /** Command text length kept in history / approval reasons. */
36
+ const COMMAND_MAX_LENGTH = 8192
37
+ /** Extra disk headroom required for a backup (10 MiB). */
38
+ const BACKUP_HEADROOM_KB = 10 * 1024
39
+
40
+ /**
41
+ * Build one tagged error.
42
+ * @param {string} code - machine-readable code.
43
+ * @param {string} message - Chinese message.
44
+ * @param {unknown} [cause] - underlying failure.
45
+ * @returns {Error & { code: string }} tagged error.
46
+ */
47
+ function taggedError(code, message, cause) {
48
+ const error = new Error(message)
49
+ error.code = code
50
+ if (cause !== undefined) error.cause = cause
51
+ return error
52
+ }
53
+
54
+ /** @returns {Error} invalid-input error. */
55
+ function invalid(message) {
56
+ return taggedError('invalid-input', message)
57
+ }
58
+
59
+ /**
60
+ * Clamp one integer option.
61
+ * @param {unknown} value - raw value.
62
+ * @param {number} fallback - default.
63
+ * @param {number} min - lower bound.
64
+ * @param {number} max - upper bound.
65
+ * @returns {number} clamped integer.
66
+ */
67
+ function clampInt(value, fallback, min, max) {
68
+ if (value === undefined || value === null || value === '') return fallback
69
+ const numeric = Number(value)
70
+ if (!Number.isFinite(numeric)) return fallback
71
+ return Math.min(max, Math.max(min, Math.round(numeric)))
72
+ }
73
+
74
+ /**
75
+ * Convert milliseconds to ISO text.
76
+ * @param {number} ms - epoch milliseconds.
77
+ * @returns {string} ISO timestamp.
78
+ */
79
+ function iso(ms) {
80
+ return new Date(ms).toISOString()
81
+ }
82
+
83
+ /**
84
+ * Format a compact UTC stamp such as `20261106T131500Z`.
85
+ * @param {number} ms - epoch milliseconds.
86
+ * @returns {string} compact stamp.
87
+ */
88
+ function compactStamp(ms) {
89
+ return new Date(ms).toISOString().replace(/[-:]/g, '').replace(/\.\d{3}Z$/, 'Z')
90
+ }
91
+
92
+ /**
93
+ * Keep the last `max` characters of one output string.
94
+ * @param {unknown} value - raw output.
95
+ * @param {number} max - maximum length.
96
+ * @returns {string} tail.
97
+ */
98
+ function tail(value, max = TAIL_MAX_LENGTH) {
99
+ if (typeof value !== 'string') return ''
100
+ return value.length <= max ? value : value.slice(value.length - max)
101
+ }
102
+
103
+ /**
104
+ * Truncate one command for display.
105
+ * @param {unknown} value - raw command.
106
+ * @param {number} max - maximum length.
107
+ * @returns {string} command text.
108
+ */
109
+ function cut(value, max = COMMAND_MAX_LENGTH) {
110
+ if (typeof value !== 'string') return ''
111
+ return value.length <= max ? value : `${value.slice(0, max)}…`
112
+ }
113
+
114
+ /**
115
+ * Whether `candidate` is the directory itself or lies below it.
116
+ * @param {string} parent - parent directory.
117
+ * @param {string} candidate - child path.
118
+ * @returns {boolean} containment.
119
+ */
120
+ function isInside(parent, candidate) {
121
+ if (typeof parent !== 'string' || typeof candidate !== 'string') return false
122
+ return candidate === parent || candidate.startsWith(`${parent}/`)
123
+ }
124
+
125
+ /**
126
+ * Extract one SSH error message safely.
127
+ * @param {unknown} error - thrown value.
128
+ * @returns {string} message.
129
+ */
130
+ function errorMessage(error) {
131
+ if (error && typeof error.message === 'string' && error.message !== '') return error.message
132
+ return '未知错误'
133
+ }
134
+
135
+ /**
136
+ * Parse the `###FREE_KB` section appended to the preflight command.
137
+ * @param {string} text - command output.
138
+ * @returns {number} available kilobytes (0 when unknown).
139
+ */
140
+ function parseFreeKB(text) {
141
+ const match = /###FREE_KB\r?\n([^\r\n]*)/.exec(String(text ?? ''))
142
+ const value = match === null ? Number.NaN : Number.parseInt(match[1].trim(), 10)
143
+ return Number.isFinite(value) && value >= 0 ? value : 0
144
+ }
145
+
146
+ /**
147
+ * Run the read-only preflight for one app directory.
148
+ * @param {object} account - full account record.
149
+ * @param {string} appDir - validated absolute path.
150
+ * @param {number} timeoutMs - command timeout.
151
+ * @returns {Promise<object>} preflight facts.
152
+ */
153
+ async function runPreflight(ssh, account, appDir, timeoutMs) {
154
+ const command = [
155
+ buildInspectCommand(appDir),
156
+ "printf '\\n###FREE_KB\\n'",
157
+ `__dir=${shQuote(appDir)}`,
158
+ 'while [ ! -d "$__dir" ] && [ "$__dir" != "/" ]; do __dir=$(dirname "$__dir"); done',
159
+ 'df -Pk "$__dir" 2>/dev/null | awk \'NR==2 {print $4}\'',
160
+ ].join('\n')
161
+ const result = await ssh.exec(account, command, { timeoutMs })
162
+ const parsed = parseInspectOutput(result.stdout)
163
+ return {
164
+ exists: parsed.exists,
165
+ writable: parsed.writable,
166
+ physicalPath: parsed.physicalPath,
167
+ sizeKB: parsed.sizeKB,
168
+ freeKB: parseFreeKB(result.stdout),
169
+ markers: parsed.markers,
170
+ git: parsed.git,
171
+ serviceUnits: parsed.serviceUnits,
172
+ }
173
+ }
174
+
175
+ /**
176
+ * Collect blockers for one plan/apply attempt.
177
+ * @param {{ appDir: string, preflight: object, updateCommand: string, backup: { enabled: boolean, dir: string } }} input - facts.
178
+ * @returns {string[]} blockers.
179
+ */
180
+ function collectBlockers({ appDir, preflight, updateCommand, backup }) {
181
+ const blockers = []
182
+ if (!preflight.exists) blockers.push('The app directory does not exist or is not a directory.')
183
+ if (!preflight.writable) blockers.push('The app directory is not writable by the SSH user.')
184
+ if (typeof updateCommand !== 'string' || updateCommand.trim() === '') blockers.push('The update command is empty.')
185
+ if (backup.enabled) {
186
+ if (isInside(appDir, backup.dir)) blockers.push('The backup directory is inside the app directory.')
187
+ const required = Math.ceil(preflight.sizeKB * 1.2) + BACKUP_HEADROOM_KB
188
+ if (!(preflight.freeKB >= required)) {
189
+ blockers.push(`Not enough free disk space for a backup: need about ${required} KB, have ${preflight.freeKB} KB.`)
190
+ }
191
+ }
192
+ return blockers
193
+ }
194
+
195
+ /**
196
+ * Collect non-blocking warnings for one plan.
197
+ * @param {{ resource: object, appDir: string, preflight: object, backup: { enabled: boolean, dir: string } }} input - facts.
198
+ * @returns {string[]} warnings.
199
+ */
200
+ function collectWarnings({ resource, appDir, preflight, backup }) {
201
+ const warnings = []
202
+ if (preflight.physicalPath !== '' && preflight.physicalPath !== appDir) {
203
+ warnings.push(`The app directory is a symlink to ${preflight.physicalPath}.`)
204
+ }
205
+ const discovered = resource && typeof resource.discovered === 'object' && resource.discovered !== null ? resource.discovered : undefined
206
+ if (discovered) {
207
+ if (Array.isArray(discovered.markers)) {
208
+ const before = discovered.markers.slice().sort().join('\n')
209
+ const nowMarkers = Array.isArray(preflight.markers) ? preflight.markers.slice().sort().join('\n') : ''
210
+ if (before !== nowMarkers) warnings.push('Project markers changed since the last discovery.')
211
+ }
212
+ const gitKnown = typeof discovered.gitRemote === 'string' || typeof discovered.gitBranch === 'string'
213
+ if (gitKnown) {
214
+ const beforeGit = `${discovered.gitRemote ?? ''}\n${discovered.gitBranch ?? ''}`
215
+ const nowGit = preflight.git ? `${preflight.git.remote}\n${preflight.git.branch}` : '\n'
216
+ if (beforeGit !== nowGit) warnings.push('Git remote/branch changed since the last discovery.')
217
+ }
218
+ }
219
+ if (!backup.enabled) warnings.push('Backup is disabled for this resource.')
220
+ for (const warning of remotePathWarnings(appDir)) {
221
+ if (!warnings.includes(warning)) warnings.push(warning)
222
+ }
223
+ if (backup.enabled && backup.dir !== '') {
224
+ for (const warning of remotePathWarnings(backup.dir)) {
225
+ const text = `Backup: ${warning}`
226
+ if (!warnings.includes(text)) warnings.push(text)
227
+ }
228
+ }
229
+ return warnings
230
+ }
231
+
232
+ /**
233
+ * Build the human summary for a plan.
234
+ * @param {{ resourceName: string, serverName: string, appDir: string, updateCommand: string, backup: object }} plan - plan fields.
235
+ * @returns {{ en: string, zh: string }} summary.
236
+ */
237
+ function buildSummary({ resourceName, serverName, appDir, updateCommand, backup }) {
238
+ const command = cut(updateCommand, 300)
239
+ const backupEn = backup.enabled ? `Back up to ${backup.file} first. ` : 'Backup is disabled. '
240
+ const backupZh = backup.enabled ? `先备份到 ${backup.file}。` : '未启用备份。'
241
+ return {
242
+ en: `Update "${resourceName}" on ${serverName} in ${appDir}. ${backupEn}Then run: ${command}`,
243
+ zh: `在 ${serverName} 上更新 ${resourceName}(${appDir})。${backupZh}随后执行:${command}`,
244
+ }
245
+ }
246
+
247
+ /**
248
+ * Create the update service.
249
+ * @param {{ ssh: object, resources: object, history: object, now?: () => number }} options - services.
250
+ * @returns {{
251
+ * plan: Function,
252
+ * peekPlan: Function,
253
+ * apply: Function,
254
+ * rollback: Function,
255
+ * resolveRollbackTarget: Function,
256
+ * }} update service.
257
+ */
258
+ export function createUpdateService({ ssh, resources, history, now = () => Date.now() } = {}) {
259
+ if (!ssh || typeof ssh.exec !== 'function') throw new TypeError('createUpdateService: ssh client is required')
260
+ if (!resources || typeof resources.get !== 'function' || typeof resources.find !== 'function') {
261
+ throw new TypeError('createUpdateService: resources store is required')
262
+ }
263
+ if (!history || typeof history.append !== 'function' || typeof history.list !== 'function') {
264
+ throw new TypeError('createUpdateService: history store is required')
265
+ }
266
+
267
+ /** @type {Map<string, object>} cached plans, insertion ordered. */
268
+ const plans = new Map()
269
+ /** @type {Map<string, object>} pinned rollback targets, insertion ordered. */
270
+ const rollbacks = new Map()
271
+ /** @type {Set<string>} servers with an apply/rollback in flight. */
272
+ const busyServers = new Set()
273
+
274
+ /**
275
+ * Normalize the agent id used to bind plans.
276
+ * @param {unknown} agentId - raw id.
277
+ * @returns {string} normalized id.
278
+ */
279
+ function agentKey(agentId) {
280
+ return typeof agentId === 'string' ? agentId : ''
281
+ }
282
+
283
+ /** Drop expired plans and enforce the cache cap. */
284
+ function prunePlans() {
285
+ const at = now()
286
+ for (const [id, plan] of plans) {
287
+ const expires = Date.parse(plan.expiresAt)
288
+ if (!Number.isFinite(expires) || expires <= at) plans.delete(id)
289
+ }
290
+ while (plans.size > PLAN_MAX) {
291
+ const oldest = plans.keys().next().value
292
+ if (oldest === undefined) break
293
+ plans.delete(oldest)
294
+ }
295
+ }
296
+
297
+ /**
298
+ * Clone a plan for a caller that must not mutate the cache.
299
+ * @param {object} plan - cached plan.
300
+ * @returns {object} clone.
301
+ */
302
+ function clonePlan(plan) {
303
+ return JSON.parse(JSON.stringify(plan))
304
+ }
305
+
306
+ /**
307
+ * Resolve the latest usable backup history entry for one resource.
308
+ * @param {string | object} resourceQuery - resource id/name query or public view.
309
+ * @param {string} [historyId] - explicit history entry.
310
+ * @returns {object | undefined} rollback target.
311
+ */
312
+ function resolveRollbackTarget(resourceQuery, historyId) {
313
+ const resource =
314
+ typeof resourceQuery === 'string'
315
+ ? resources.find(resourceQuery)
316
+ : resourceQuery && typeof resourceQuery === 'object' && typeof resourceQuery.id === 'string'
317
+ ? (resources.get(resourceQuery.id) ?? resourceQuery)
318
+ : undefined
319
+ if (!resource || typeof resource.appDir !== 'string' || resource.appDir === '') return undefined
320
+
321
+ let layout
322
+ try {
323
+ layout = backupLayout({ appDir: resource.appDir, backupDir: resource.backupDir, name: resource.name, timestamp: now() })
324
+ } catch {
325
+ return undefined
326
+ }
327
+
328
+ /** @type {object | undefined} */
329
+ let entry
330
+ if (typeof historyId === 'string' && historyId !== '') {
331
+ const candidate = history.get(historyId)
332
+ if (
333
+ candidate &&
334
+ candidate.resourceId === resource.id &&
335
+ candidate.action === 'apply' &&
336
+ typeof candidate.backupPath === 'string' &&
337
+ candidate.backupPath !== '' &&
338
+ candidate.backupPath.startsWith(`${layout.dir}/`)
339
+ ) {
340
+ entry = candidate
341
+ }
342
+ } else {
343
+ entry = history
344
+ .list({ resourceId: resource.id, limit: 200 })
345
+ .find(
346
+ (candidate) =>
347
+ candidate.action === 'apply' &&
348
+ typeof candidate.backupPath === 'string' &&
349
+ candidate.backupPath !== '' &&
350
+ candidate.backupPath.startsWith(`${layout.dir}/`),
351
+ )
352
+ }
353
+ if (!entry || typeof entry.backupPath !== 'string' || entry.backupPath === '') return undefined
354
+ return {
355
+ resource,
356
+ entry,
357
+ backupPath: entry.backupPath,
358
+ backupDir: layout.dir,
359
+ parent: layout.parent,
360
+ }
361
+ }
362
+
363
+ /** Drop expired rollback pins and enforce the cache cap. */
364
+ function pruneRollbacks() {
365
+ const at = now()
366
+ for (const [id, ticket] of rollbacks) {
367
+ if (!Number.isFinite(ticket.expiresAt) || ticket.expiresAt <= at) rollbacks.delete(id)
368
+ }
369
+ while (rollbacks.size > ROLLBACK_MAX) {
370
+ const oldest = rollbacks.keys().next().value
371
+ if (oldest === undefined) break
372
+ rollbacks.delete(oldest)
373
+ }
374
+ }
375
+
376
+ /**
377
+ * Pin one resolved rollback target so the body restores exactly what was approved.
378
+ *
379
+ * Approval is a two-phase operation: the gate resolves a target to build the
380
+ * prompt, the operator approves, and only then does the tool body restore.
381
+ * Re-resolving in the body would let any apply/upload landing in between
382
+ * change which archive gets extracted, so the resolved entry is pinned here
383
+ * and consumed verbatim by {@link rollback}.
384
+ *
385
+ * The pin is keyed by agent and resource because the harness gate cannot
386
+ * rewrite tool arguments between the approval prompt and the tool body.
387
+ *
388
+ * @param {object} target - value from {@link resolveRollbackTarget}.
389
+ * @param {string | undefined} agentId - agent that must consume the pin.
390
+ * @returns {string} pin key.
391
+ */
392
+ function pinRollback(target, agentId) {
393
+ pruneRollbacks()
394
+ const key = `${agentKey(agentId)}\u0000${target.resource.id}`
395
+ rollbacks.set(key, {
396
+ key,
397
+ resourceId: target.resource.id,
398
+ serverId: target.resource.serverId,
399
+ appDir: target.resource.appDir,
400
+ entryId: target.entry && typeof target.entry.id === 'string' ? target.entry.id : '',
401
+ backupPath: target.backupPath,
402
+ parent: target.parent,
403
+ expiresAt: now() + PLAN_TTL_MS,
404
+ })
405
+ return key
406
+ }
407
+
408
+ /**
409
+ * Read the pin for one agent and resource without consuming it.
410
+ * @param {string | undefined} agentId - calling agent id.
411
+ * @param {string} resourceId - resource id.
412
+ * @returns {object | undefined} pinned target.
413
+ */
414
+ function peekRollback(agentId, resourceId) {
415
+ pruneRollbacks()
416
+ if (typeof resourceId !== 'string' || resourceId === '') return undefined
417
+ return rollbacks.get(`${agentKey(agentId)}\u0000${resourceId}`)
418
+ }
419
+
420
+ /**
421
+ * Drop one pin.
422
+ * @param {string | undefined} agentId - calling agent id.
423
+ * @param {string} resourceId - resource id.
424
+ */
425
+ function clearRollback(agentId, resourceId) {
426
+ rollbacks.delete(`${agentKey(agentId)}\u0000${resourceId}`)
427
+ }
428
+
429
+ /**
430
+ * Build a plan without touching the remote host beyond read-only preflight.
431
+ * @param {object} account - full account record.
432
+ * @param {object} resource - resource public view.
433
+ * @param {{ agentId?: string, timeoutMs?: number }} [options] - plan options.
434
+ * @returns {Promise<object>} cached plan.
435
+ */
436
+ async function plan(account, resource, { agentId, timeoutMs } = {}) {
437
+ if (!account || typeof account !== 'object') throw invalid('缺少服务器账户')
438
+ if (!resource || typeof resource !== 'object') throw invalid('缺少资源')
439
+ if (typeof resource.id !== 'string' || resource.id === '') throw invalid('资源 id 无效')
440
+ if (typeof resource.serverId !== 'string' || resource.serverId === '') throw invalid('资源缺少服务器')
441
+ const appDir = validateRemotePath(resource.appDir)
442
+ const preflightTimeout = clampInt(timeoutMs, DEFAULT_PREFLIGHT_TIMEOUT_MS, MIN_TIMEOUT_MS, MAX_TIMEOUT_MS)
443
+ const layout = backupLayout({ appDir, backupDir: resource.backupDir, name: resource.name, timestamp: now() })
444
+ const preflight = await runPreflight(ssh, account, appDir, preflightTimeout)
445
+ const updateCommand = typeof resource.updateCommand === 'string' ? resource.updateCommand.trim() : ''
446
+ const backup = { enabled: resource.backupEnabled !== false, dir: layout.dir, file: layout.file }
447
+ const blockers = collectBlockers({ appDir, preflight, updateCommand, backup })
448
+ const warnings = collectWarnings({ resource, appDir, preflight, backup })
449
+
450
+ const restartCommand = typeof resource.restartCommand === 'string' ? resource.restartCommand.trim() : ''
451
+ const verifyCommand = typeof resource.verifyCommand === 'string' ? resource.verifyCommand.trim() : ''
452
+ const backupKeep = clampInt(resource.backupKeep, 5, 1, 50)
453
+ const backupParent = path.posix.dirname(appDir)
454
+ const backupBase = path.posix.basename(appDir)
455
+
456
+ const steps = [{ id: 'preflight', title: 'Preflight checks', detail: `Inspect ${appDir} (read-only).` }]
457
+ if (backup.enabled) {
458
+ steps.push({
459
+ id: 'backup',
460
+ title: 'Back up the current directory',
461
+ detail: cut(`mkdir -p ${layout.dir} && tar -C ${backupParent} -czf ${layout.file} ./${backupBase}`, 500),
462
+ })
463
+ }
464
+ steps.push({ id: 'update', title: 'Run the update command', detail: cut(`cd ${appDir} && bash -lc ${shQuote(updateCommand)}`, 500) })
465
+ if (restartCommand !== '') {
466
+ steps.push({ id: 'restart', title: 'Restart the service', detail: cut(`cd ${appDir} && bash -lc ${shQuote(restartCommand)}`, 500) })
467
+ }
468
+ if (verifyCommand !== '') {
469
+ steps.push({ id: 'verify', title: 'Verify the deployment', detail: cut(`cd ${appDir} && bash -lc ${shQuote(verifyCommand)}`, 500) })
470
+ }
471
+ if (backup.enabled) {
472
+ steps.push({ id: 'prune', title: 'Prune old backups', detail: `Keep the newest ${backupKeep} backup archives in ${layout.dir}.` })
473
+ }
474
+
475
+ const createdAt = now()
476
+ const planId = randomUUID()
477
+ const serverName =
478
+ typeof resource.serverName === 'string' && resource.serverName !== ''
479
+ ? resource.serverName
480
+ : typeof account.name === 'string'
481
+ ? account.name
482
+ : ''
483
+ const result = {
484
+ id: planId,
485
+ agentId: agentKey(agentId),
486
+ resourceId: resource.id,
487
+ resourceName: typeof resource.name === 'string' ? resource.name : '',
488
+ serverId: resource.serverId,
489
+ serverName,
490
+ appDir,
491
+ updateCommand,
492
+ backup,
493
+ preflight,
494
+ steps,
495
+ warnings,
496
+ blockers,
497
+ createdAt: iso(createdAt),
498
+ expiresAt: iso(createdAt + PLAN_TTL_MS),
499
+ summary: buildSummary({
500
+ resourceName: typeof resource.name === 'string' ? resource.name : '',
501
+ serverName,
502
+ appDir,
503
+ updateCommand,
504
+ backup,
505
+ }),
506
+ }
507
+ if (restartCommand !== '') result.restartCommand = restartCommand
508
+ if (verifyCommand !== '') result.verifyCommand = verifyCommand
509
+
510
+ plans.set(planId, result)
511
+ prunePlans()
512
+ return result
513
+ }
514
+
515
+ /**
516
+ * Read one cached plan without consuming it.
517
+ * @param {string} planId - plan id.
518
+ * @param {string | undefined} agentId - calling agent id.
519
+ * @returns {object | undefined} plan copy.
520
+ */
521
+ function peekPlan(planId, agentId) {
522
+ prunePlans()
523
+ if (typeof planId !== 'string' || planId === '') return undefined
524
+ const plan = plans.get(planId)
525
+ if (plan === undefined) return undefined
526
+ if (plan.agentId !== agentKey(agentId)) return undefined
527
+ if (Date.parse(plan.expiresAt) <= now()) {
528
+ plans.delete(planId)
529
+ return undefined
530
+ }
531
+ return clonePlan(plan)
532
+ }
533
+
534
+ /**
535
+ * Execute one remote command as a history step.
536
+ * @param {object} account - full account record.
537
+ * @param {{ id: string, title: string, command: string }} definition - step definition.
538
+ * @param {number} timeoutMs - command timeout.
539
+ * @returns {Promise<object>} history step.
540
+ */
541
+ async function runCommandStep(account, definition, timeoutMs) {
542
+ const startedAt = now()
543
+ try {
544
+ const result = await ssh.exec(account, definition.command, { timeoutMs })
545
+ const stdoutTail = tail(result.stdout)
546
+ const stderrTail = tail(result.stderr)
547
+ return {
548
+ id: definition.id,
549
+ title: definition.title,
550
+ command: cut(definition.command),
551
+ status: result.exitCode === 0 ? 'success' : 'failed',
552
+ exitCode: Number.isInteger(result.exitCode) ? result.exitCode : null,
553
+ durationMs: typeof result.durationMs === 'number' && Number.isFinite(result.durationMs) ? Math.max(0, result.durationMs) : Math.max(0, now() - startedAt),
554
+ stdoutTail,
555
+ stderrTail,
556
+ truncated:
557
+ result.truncated === true ||
558
+ (typeof result.stdout === 'string' && result.stdout.length > TAIL_MAX_LENGTH) ||
559
+ (typeof result.stderr === 'string' && result.stderr.length > TAIL_MAX_LENGTH),
560
+ }
561
+ } catch (error) {
562
+ return {
563
+ id: definition.id,
564
+ title: definition.title,
565
+ command: cut(definition.command),
566
+ status: 'failed',
567
+ exitCode: null,
568
+ durationMs: Math.max(0, now() - startedAt),
569
+ stdoutTail: '',
570
+ stderrTail: errorMessage(error),
571
+ truncated: false,
572
+ }
573
+ }
574
+ }
575
+
576
+ /**
577
+ * Run one backup/update/restart/verify/prune sequence.
578
+ * @param {object} account - full account record.
579
+ * @param {object} plan - cached plan.
580
+ * @param {object} resource - current resource view.
581
+ * @returns {Promise<object>} stored history entry.
582
+ */
583
+ async function executeApply(account, plan, resource) {
584
+ const startedAt = now()
585
+ const steps = []
586
+ let backupPath = ''
587
+ const timeoutMs = clampInt(resource.timeoutSeconds, 600, 30, 3600) * 1000
588
+ const serverName =
589
+ typeof resource.serverName === 'string' && resource.serverName !== ''
590
+ ? resource.serverName
591
+ : typeof account.name === 'string'
592
+ ? account.name
593
+ : ''
594
+ /** @type {string} */
595
+ let message = ''
596
+
597
+ /**
598
+ * Persist the attempt and return its history entry.
599
+ * @param {string} status - final status.
600
+ * @param {string} [note] - final message.
601
+ * @returns {Promise<object>} stored entry.
602
+ */
603
+ const finish = async (status, note) => {
604
+ if (typeof note === 'string' && note !== '') message = note
605
+ const finishedAt = now()
606
+ const entry = await history.append({
607
+ action: 'apply',
608
+ resourceId: plan.resourceId,
609
+ resourceName: plan.resourceName,
610
+ serverId: plan.serverId,
611
+ serverName,
612
+ appDir: plan.appDir,
613
+ backupPath,
614
+ status,
615
+ startedAt: iso(startedAt),
616
+ finishedAt: iso(finishedAt),
617
+ durationMs: Math.max(0, finishedAt - startedAt),
618
+ steps,
619
+ ...(message === '' ? {} : { message }),
620
+ })
621
+ await resources.markUpdated(plan.resourceId, { at: entry.finishedAt, status, backupPath })
622
+ return entry
623
+ }
624
+
625
+ try {
626
+ let preflight
627
+ const preflightStarted = now()
628
+ try {
629
+ preflight = await runPreflight(ssh, account, plan.appDir, Math.min(timeoutMs, DEFAULT_PREFLIGHT_TIMEOUT_MS))
630
+ } catch (error) {
631
+ steps.push({
632
+ id: 'preflight',
633
+ title: 'Preflight checks',
634
+ status: 'failed',
635
+ exitCode: null,
636
+ durationMs: Math.max(0, now() - preflightStarted),
637
+ stdoutTail: '',
638
+ stderrTail: errorMessage(error),
639
+ truncated: false,
640
+ })
641
+ return await finish('failed', '预检失败,未执行任何远端变更。')
642
+ }
643
+
644
+ const blockers = collectBlockers({
645
+ appDir: plan.appDir,
646
+ preflight,
647
+ updateCommand: plan.updateCommand,
648
+ backup: plan.backup,
649
+ })
650
+ if (blockers.length > 0) {
651
+ steps.push({
652
+ id: 'preflight',
653
+ title: 'Preflight checks',
654
+ status: 'failed',
655
+ exitCode: null,
656
+ durationMs: Math.max(0, now() - preflightStarted),
657
+ stdoutTail: '',
658
+ stderrTail: blockers.join('\n'),
659
+ truncated: false,
660
+ })
661
+ return await finish('blocked', '预检发现阻塞项,未执行任何远端变更。')
662
+ }
663
+ steps.push({
664
+ id: 'preflight',
665
+ title: 'Preflight checks',
666
+ status: 'success',
667
+ exitCode: null,
668
+ durationMs: Math.max(0, now() - preflightStarted),
669
+ stdoutTail: '',
670
+ stderrTail: '',
671
+ truncated: false,
672
+ })
673
+
674
+ const parent = path.posix.dirname(plan.appDir)
675
+ const baseName = path.posix.basename(plan.appDir)
676
+
677
+ if (plan.backup.enabled) {
678
+ backupPath = plan.backup.file
679
+ const command = [
680
+ `mkdir -p ${shQuote(plan.backup.dir)}`,
681
+ `tar -C ${shQuote(parent)} -czf ${shQuote(plan.backup.file)} ${shQuote(`./${baseName}`)}`,
682
+ `test -s ${shQuote(plan.backup.file)}`,
683
+ ].join(' && ')
684
+ const step = await runCommandStep(account, { id: 'backup', title: 'Back up the current directory', command }, timeoutMs)
685
+ steps.push(step)
686
+ if (step.status !== 'success') return await finish('failed', '备份失败,更新未执行;备份文件(如有)已保留。')
687
+ }
688
+
689
+ const updateStep = await runCommandStep(
690
+ account,
691
+ {
692
+ id: 'update',
693
+ title: 'Run the update command',
694
+ command: `cd ${shQuote(plan.appDir)} && bash -lc ${shQuote(plan.updateCommand)}`,
695
+ },
696
+ timeoutMs,
697
+ )
698
+ steps.push(updateStep)
699
+ if (updateStep.status !== 'success') return await finish('failed', '更新命令执行失败;备份已保留,且不会自动回滚。')
700
+
701
+ if (typeof plan.restartCommand === 'string' && plan.restartCommand !== '') {
702
+ const restartStep = await runCommandStep(
703
+ account,
704
+ {
705
+ id: 'restart',
706
+ title: 'Restart the service',
707
+ command: `cd ${shQuote(plan.appDir)} && bash -lc ${shQuote(plan.restartCommand)}`,
708
+ },
709
+ timeoutMs,
710
+ )
711
+ steps.push(restartStep)
712
+ if (restartStep.status !== 'success') return await finish('partial', '更新已执行,但重启命令失败。')
713
+ }
714
+
715
+ if (typeof plan.verifyCommand === 'string' && plan.verifyCommand !== '') {
716
+ const verifyStep = await runCommandStep(
717
+ account,
718
+ {
719
+ id: 'verify',
720
+ title: 'Verify the deployment',
721
+ command: `cd ${shQuote(plan.appDir)} && bash -lc ${shQuote(plan.verifyCommand)}`,
722
+ },
723
+ timeoutMs,
724
+ )
725
+ steps.push(verifyStep)
726
+ if (verifyStep.status !== 'success') return await finish('partial', '更新已执行,但校验命令失败。')
727
+ }
728
+
729
+ if (plan.backup.enabled) {
730
+ const keep = clampInt(resource.backupKeep, 5, 1, 50)
731
+ const segment = safeSegment(plan.resourceName)
732
+ const pattern = `${shQuote(`${plan.backup.dir}/${segment}-`)}*.tar.gz`
733
+ const command = `ls -1 ${pattern} 2>/dev/null | sort -r | tail -n +${keep + 1} | while IFS= read -r __f; do rm -f "$__f"; done`
734
+ const pruneStep = await runCommandStep(account, { id: 'prune', title: 'Prune old backups', command }, timeoutMs)
735
+ steps.push(pruneStep)
736
+ if (pruneStep.status !== 'success') return await finish('partial', '更新完成,但清理旧备份失败。')
737
+ }
738
+
739
+ return await finish('success', '更新完成。')
740
+ } catch (error) {
741
+ steps.push({
742
+ id: 'internal',
743
+ title: 'Unexpected failure',
744
+ status: 'failed',
745
+ exitCode: null,
746
+ durationMs: 0,
747
+ stdoutTail: '',
748
+ stderrTail: errorMessage(error),
749
+ truncated: false,
750
+ })
751
+ return await finish('failed', '更新流程异常终止。')
752
+ }
753
+ }
754
+
755
+ /**
756
+ * Execute one rollback attempt.
757
+ * @param {object} account - full account record.
758
+ * @param {object} target - rollback target from `resolveRollbackTarget`.
759
+ * @returns {Promise<object>} stored history entry.
760
+ */
761
+ async function executeRollback(account, target) {
762
+ const { resource, entry } = target
763
+ const startedAt = now()
764
+ const steps = []
765
+ const timeoutMs = clampInt(resource.timeoutSeconds, 600, 30, 3600) * 1000
766
+ const serverName =
767
+ typeof resource.serverName === 'string' && resource.serverName !== ''
768
+ ? resource.serverName
769
+ : typeof account.name === 'string'
770
+ ? account.name
771
+ : ''
772
+ const stamp = compactStamp(now())
773
+ const previousDir = `${resource.appDir}.ssh-helper-before-rollback-${stamp}`
774
+ /** @type {string} */
775
+ let message = ''
776
+
777
+ /**
778
+ * Persist the rollback attempt.
779
+ * @param {string} status - final status.
780
+ * @param {string} [note] - final message.
781
+ * @returns {Promise<object>} stored entry.
782
+ */
783
+ const finish = async (status, note) => {
784
+ if (typeof note === 'string' && note !== '') message = note
785
+ const finishedAt = now()
786
+ const stored = await history.append({
787
+ action: 'rollback',
788
+ resourceId: resource.id,
789
+ resourceName: resource.name,
790
+ serverId: resource.serverId,
791
+ serverName,
792
+ appDir: resource.appDir,
793
+ backupPath: target.backupPath,
794
+ status,
795
+ startedAt: iso(startedAt),
796
+ finishedAt: iso(finishedAt),
797
+ durationMs: Math.max(0, finishedAt - startedAt),
798
+ steps,
799
+ ...(message === '' ? {} : { message }),
800
+ })
801
+ return stored
802
+ }
803
+
804
+ try {
805
+ const backupStep = await runCommandStep(
806
+ account,
807
+ { id: 'backup-check', title: 'Verify the backup archive', command: `test -s ${shQuote(target.backupPath)}` },
808
+ timeoutMs,
809
+ )
810
+ steps.push(backupStep)
811
+ if (backupStep.status !== 'success') return await finish('failed', '备份文件不存在或为空,回滚未开始。')
812
+
813
+ const moveStep = await runCommandStep(
814
+ account,
815
+ { id: 'move-aside', title: 'Move the current directory aside', command: `mv ${shQuote(resource.appDir)} ${shQuote(previousDir)}` },
816
+ timeoutMs,
817
+ )
818
+ steps.push(moveStep)
819
+ if (moveStep.status !== 'success') {
820
+ const restoreStep = await runCommandStep(
821
+ account,
822
+ {
823
+ id: 'restore',
824
+ title: 'Restore the previous directory',
825
+ command: `if [ -d ${shQuote(previousDir)} ] && [ ! -e ${shQuote(resource.appDir)} ]; then mv ${shQuote(previousDir)} ${shQuote(resource.appDir)}; fi`,
826
+ },
827
+ timeoutMs,
828
+ )
829
+ steps.push(restoreStep)
830
+ return await finish('failed', '无法移动当前应用目录,已尝试恢复原目录。')
831
+ }
832
+
833
+ const extractStep = await runCommandStep(
834
+ account,
835
+ {
836
+ id: 'extract',
837
+ title: 'Extract the backup archive',
838
+ command: `tar -C ${shQuote(target.parent)} -xzf ${shQuote(target.backupPath)} && test -d ${shQuote(resource.appDir)}`,
839
+ },
840
+ timeoutMs,
841
+ )
842
+ steps.push(extractStep)
843
+ if (extractStep.status !== 'success') {
844
+ const restoreStep = await runCommandStep(
845
+ account,
846
+ {
847
+ id: 'restore',
848
+ title: 'Restore the previous directory',
849
+ command: `rm -rf ${shQuote(resource.appDir)} 2>/dev/null; mv ${shQuote(previousDir)} ${shQuote(resource.appDir)}`,
850
+ },
851
+ timeoutMs,
852
+ )
853
+ steps.push(restoreStep)
854
+ return await finish('failed', '解压备份失败,已尝试恢复原目录;备份文件保留。')
855
+ }
856
+
857
+ if (typeof resource.restartCommand === 'string' && resource.restartCommand !== '') {
858
+ const restartStep = await runCommandStep(
859
+ account,
860
+ {
861
+ id: 'restart',
862
+ title: 'Restart the service',
863
+ command: `cd ${shQuote(resource.appDir)} && bash -lc ${shQuote(resource.restartCommand)}`,
864
+ },
865
+ timeoutMs,
866
+ )
867
+ steps.push(restartStep)
868
+ if (restartStep.status !== 'success') return await finish('partial', '目录已回滚,但重启命令失败。')
869
+ }
870
+
871
+ if (typeof resource.verifyCommand === 'string' && resource.verifyCommand !== '') {
872
+ const verifyStep = await runCommandStep(
873
+ account,
874
+ {
875
+ id: 'verify',
876
+ title: 'Verify the deployment',
877
+ command: `cd ${shQuote(resource.appDir)} && bash -lc ${shQuote(resource.verifyCommand)}`,
878
+ },
879
+ timeoutMs,
880
+ )
881
+ steps.push(verifyStep)
882
+ if (verifyStep.status !== 'success') return await finish('partial', '目录已回滚,但校验命令失败。')
883
+ }
884
+
885
+ return await finish('success', `回滚完成;原目录已移动到 ${previousDir}。`)
886
+ } catch (error) {
887
+ steps.push({
888
+ id: 'internal',
889
+ title: 'Unexpected failure',
890
+ status: 'failed',
891
+ exitCode: null,
892
+ durationMs: 0,
893
+ stdoutTail: '',
894
+ stderrTail: errorMessage(error),
895
+ truncated: false,
896
+ })
897
+ return await finish('failed', '回滚流程异常终止。')
898
+ }
899
+ }
900
+
901
+ /**
902
+ * Execute a cached plan.
903
+ * @param {object} account - full account record.
904
+ * @param {string} planId - plan id from `plan`.
905
+ * @param {string | undefined} agentId - calling agent id.
906
+ * @returns {Promise<object>} stored history entry.
907
+ */
908
+ async function apply(account, planId, agentId) {
909
+ prunePlans()
910
+ const cached = typeof planId === 'string' ? plans.get(planId) : undefined
911
+ if (cached === undefined) throw taggedError('plan-not-found', '更新计划不存在,请重新生成计划')
912
+ if (cached.agentId !== agentKey(agentId)) throw taggedError('plan-not-found', '更新计划不存在,请重新生成计划')
913
+ if (Date.parse(cached.expiresAt) <= now()) {
914
+ plans.delete(planId)
915
+ throw taggedError('plan-expired', '更新计划已过期,请重新生成计划')
916
+ }
917
+
918
+ const resource = resources.get(cached.resourceId)
919
+ if (!resource) throw invalid('资源不存在,请刷新资源目录')
920
+ if (resource.appDir !== cached.appDir || resource.serverId !== cached.serverId) {
921
+ throw invalid('资源的目标目录或服务器已变更,请重新生成计划')
922
+ }
923
+ if (account && typeof account.id === 'string' && account.id !== cached.serverId) {
924
+ throw invalid('账户与更新计划不匹配,请重新生成计划')
925
+ }
926
+ if (busyServers.has(cached.serverId)) throw taggedError('busy', '该服务器已有更新或回滚正在执行')
927
+
928
+ busyServers.add(cached.serverId)
929
+ try {
930
+ return await executeApply(account, cached, resource)
931
+ } finally {
932
+ busyServers.delete(cached.serverId)
933
+ plans.delete(planId)
934
+ }
935
+ }
936
+
937
+ /**
938
+ * Restore one backup archive.
939
+ *
940
+ * When an approval pin exists for this agent and resource, it decides which
941
+ * archive is restored, so the body can never drift from the prompt the
942
+ * operator approved. Without a pin the latest usable backup is resolved here,
943
+ * which is only safe for callers that do not pass through an approval prompt.
944
+ *
945
+ * @param {object} account - full account record.
946
+ * @param {string | object} resourceQuery - resource id/name query or view.
947
+ * @param {{ agentId?: string, historyId?: string }} [options] - rollback options.
948
+ * @returns {Promise<object>} stored history entry.
949
+ */
950
+ async function rollback(account, resourceQuery, { agentId, historyId } = {}) {
951
+ const resource =
952
+ typeof resourceQuery === 'string'
953
+ ? resources.find(resourceQuery)
954
+ : resourceQuery && typeof resourceQuery === 'object' && typeof resourceQuery.id === 'string'
955
+ ? (resources.get(resourceQuery.id) ?? resourceQuery)
956
+ : undefined
957
+ if (!resource) throw invalid('资源不存在,请刷新资源目录')
958
+
959
+ const pin = peekRollback(agentId, resource.id)
960
+ let target
961
+ if (pin) {
962
+ // The pin is only valid while the resource still points at the approved
963
+ // directory and the archive is still the one that was recorded.
964
+ if (resource.appDir !== pin.appDir || resource.serverId !== pin.serverId) {
965
+ clearRollback(agentId, resource.id)
966
+ throw invalid('资源的目标目录或服务器已变更,请重新发起回滚')
967
+ }
968
+ const entry = history.get(pin.entryId)
969
+ if (!entry || entry.backupPath !== pin.backupPath) {
970
+ clearRollback(agentId, resource.id)
971
+ throw invalid('备份记录已变更,请重新发起回滚')
972
+ }
973
+ target = { resource, entry, backupPath: pin.backupPath, parent: pin.parent, backupDir: path.posix.dirname(pin.backupPath) }
974
+ } else {
975
+ target = resolveRollbackTarget(resource, historyId)
976
+ if (!target) throw taggedError('no-backup', '没有可用的备份记录,无法回滚')
977
+ }
978
+ if (account && typeof account.id === 'string' && account.id !== target.resource.serverId) {
979
+ throw invalid('账户与资源不匹配')
980
+ }
981
+ if (busyServers.has(target.resource.serverId)) throw taggedError('busy', '该服务器已有更新或回滚正在执行')
982
+ busyServers.add(target.resource.serverId)
983
+ try {
984
+ return await executeRollback(account, target)
985
+ } finally {
986
+ busyServers.delete(target.resource.serverId)
987
+ clearRollback(agentId, resource.id)
988
+ }
989
+ }
990
+
991
+ return { plan, peekPlan, apply, rollback, resolveRollbackTarget, pinRollback, peekRollback }
992
+ }