dsh-update-center 0.8.2 → 0.9.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/README.md +4 -0
- package/data/extra-plugins.json +3 -3
- package/data/registry-snapshot.json +523 -373
- package/lib/client.js +12 -3
- package/lib/client.js.map +1 -1
- package/lib/index.js +149 -19
- package/lib/index.js.map +1 -1
- package/package.json +1 -1
- package/plugins.json +523 -373
- package/scripts/restart-dsh.mjs +19 -2
- package/scripts/update-worker.mjs +765 -598
|
@@ -1,598 +1,765 @@
|
|
|
1
|
-
import { spawnSync } from 'node:child_process'
|
|
2
|
-
import { copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
|
|
3
|
-
import { dirname, join, resolve } from 'node:path'
|
|
4
|
-
import { pathToFileURL } from 'node:url'
|
|
5
|
-
|
|
6
|
-
export function verifyNpmUpdate({ before, expected, installed }) {
|
|
7
|
-
if (!installed || installed !== expected) {
|
|
8
|
-
return {
|
|
9
|
-
ok: false,
|
|
10
|
-
changed: installed !== before,
|
|
11
|
-
message: `安装版本核验失败:更新前 ${before || '未知'},期望 ${expected || '未知'},实际 ${installed || '未安装'}`,
|
|
12
|
-
}
|
|
13
|
-
}
|
|
14
|
-
return {
|
|
15
|
-
ok: true,
|
|
16
|
-
changed: installed !== before,
|
|
17
|
-
message: `已安装 ${installed}`,
|
|
18
|
-
}
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
export function analyzeGitArtifacts(changedPaths) {
|
|
22
|
-
const normalized = changedPaths.map((file) => String(file).replaceAll('\\', '/'))
|
|
23
|
-
const sourceChanged = normalized.some((file) => file.startsWith('src/'))
|
|
24
|
-
const artifactsChanged = normalized.some((file) => file.startsWith('lib/') || file.startsWith('dist/'))
|
|
25
|
-
return {
|
|
26
|
-
sourceChanged,
|
|
27
|
-
artifactsChanged,
|
|
28
|
-
buildRequired: sourceChanged && !artifactsChanged,
|
|
29
|
-
}
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
export function writeJobState(statePath, latestPath, state) {
|
|
33
|
-
const payload = JSON.stringify({ ...state, updatedAt: new Date().toISOString() }, null, 2) + '\n'
|
|
34
|
-
for (const file of [statePath, latestPath]) {
|
|
35
|
-
mkdirSync(dirname(file), { recursive: true })
|
|
36
|
-
const temporary = `${file}.${process.pid}.tmp`
|
|
37
|
-
writeFileSync(temporary, payload, 'utf8')
|
|
38
|
-
renameSync(temporary, file)
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
function readPackageVersion(baseDir, packageName) {
|
|
43
|
-
try {
|
|
44
|
-
const packageFile = join(baseDir, 'node_modules', ...packageName.split('/'), 'package.json')
|
|
45
|
-
const parsed = JSON.parse(readFileSync(packageFile, 'utf8'))
|
|
46
|
-
return typeof parsed.version === 'string' ? parsed.version : null
|
|
47
|
-
} catch {
|
|
48
|
-
return null
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
function commandFailure(result) {
|
|
53
|
-
return (result.err?.trim() || result.out?.trim() || `进程退出码 ${String(result.code)}`).slice(0, 1200)
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
export function runCommand(command, args, cwd, timeoutMs = 600_000, extraEnv) {
|
|
57
|
-
const isWindows = process.platform === 'win32'
|
|
58
|
-
const candidates = isWindows && !/\.(exe|cmd|bat)$/i.test(command)
|
|
59
|
-
? [command, `${command}.cmd`]
|
|
60
|
-
: [command]
|
|
61
|
-
for (const candidate of candidates) {
|
|
62
|
-
const isBatch = isWindows && candidate.endsWith('.cmd')
|
|
63
|
-
const executable = isBatch ? 'cmd.exe' : candidate
|
|
64
|
-
const commandArgs = isBatch
|
|
65
|
-
? ['/d', '/s', '/c', [candidate, ...args].map(quoteCmdArgument).join(' ')]
|
|
66
|
-
: args
|
|
67
|
-
const result = spawnSync(executable, commandArgs, {
|
|
68
|
-
cwd,
|
|
69
|
-
encoding: 'utf8',
|
|
70
|
-
timeout: timeoutMs,
|
|
71
|
-
env: { ...process.env, ...extraEnv },
|
|
72
|
-
windowsHide: true,
|
|
73
|
-
})
|
|
74
|
-
const code = result.error?.code
|
|
75
|
-
if (result.error && (code === 'ENOENT' || code === 'EINVAL')) continue
|
|
76
|
-
return {
|
|
77
|
-
ok: result.status === 0 && !result.error,
|
|
78
|
-
code: result.status,
|
|
79
|
-
out: String(result.stdout ?? ''),
|
|
80
|
-
err: [String(result.stderr ?? ''), result.error?.message ?? ''].filter(Boolean).join('\n'),
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
return { ok: false, code: null, out: '', err: `command not found: ${command}` }
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
function quoteCmdArgument(value) {
|
|
87
|
-
const text = String(value)
|
|
88
|
-
if (/^[A-Za-z0-9_@%+=:,./\\-]+$/.test(text)) return text
|
|
89
|
-
return `"${text.replaceAll('"', '""')}"`
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
class JobError extends Error {
|
|
93
|
-
constructor(stage, message) {
|
|
94
|
-
super(message)
|
|
95
|
-
this.stage = stage
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
function updateState(spec, state, patch) {
|
|
100
|
-
Object.assign(state, patch)
|
|
101
|
-
writeJobState(spec.statePath, spec.latestPath, state)
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
/** 单任务模式:步骤与进度直接写入本 job 的状态。 */
|
|
105
|
-
function singleReporter(spec, state) {
|
|
106
|
-
return {
|
|
107
|
-
step: (text) => { state.steps.push(text) },
|
|
108
|
-
progress: (patch) => updateState(spec, state, patch),
|
|
109
|
-
finish: (patch) => updateState(spec, state, patch),
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
})
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
reporter.
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
message:
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
export function
|
|
235
|
-
const
|
|
236
|
-
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
return
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
if (
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
}
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
}
|
|
338
|
-
state.
|
|
339
|
-
const
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
}
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
}
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
const
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
const
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
}
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
const
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
const
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
}
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
}
|
|
1
|
+
import { spawnSync } from 'node:child_process'
|
|
2
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from 'node:fs'
|
|
3
|
+
import { dirname, join, relative, resolve } from 'node:path'
|
|
4
|
+
import { pathToFileURL } from 'node:url'
|
|
5
|
+
|
|
6
|
+
export function verifyNpmUpdate({ before, expected, installed }) {
|
|
7
|
+
if (!installed || installed !== expected) {
|
|
8
|
+
return {
|
|
9
|
+
ok: false,
|
|
10
|
+
changed: installed !== before,
|
|
11
|
+
message: `安装版本核验失败:更新前 ${before || '未知'},期望 ${expected || '未知'},实际 ${installed || '未安装'}`,
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
return {
|
|
15
|
+
ok: true,
|
|
16
|
+
changed: installed !== before,
|
|
17
|
+
message: `已安装 ${installed}`,
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function analyzeGitArtifacts(changedPaths) {
|
|
22
|
+
const normalized = changedPaths.map((file) => String(file).replaceAll('\\', '/'))
|
|
23
|
+
const sourceChanged = normalized.some((file) => file.startsWith('src/'))
|
|
24
|
+
const artifactsChanged = normalized.some((file) => file.startsWith('lib/') || file.startsWith('dist/'))
|
|
25
|
+
return {
|
|
26
|
+
sourceChanged,
|
|
27
|
+
artifactsChanged,
|
|
28
|
+
buildRequired: sourceChanged && !artifactsChanged,
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function writeJobState(statePath, latestPath, state) {
|
|
33
|
+
const payload = JSON.stringify({ ...state, updatedAt: new Date().toISOString() }, null, 2) + '\n'
|
|
34
|
+
for (const file of [statePath, latestPath]) {
|
|
35
|
+
mkdirSync(dirname(file), { recursive: true })
|
|
36
|
+
const temporary = `${file}.${process.pid}.tmp`
|
|
37
|
+
writeFileSync(temporary, payload, 'utf8')
|
|
38
|
+
renameSync(temporary, file)
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function readPackageVersion(baseDir, packageName) {
|
|
43
|
+
try {
|
|
44
|
+
const packageFile = join(baseDir, 'node_modules', ...packageName.split('/'), 'package.json')
|
|
45
|
+
const parsed = JSON.parse(readFileSync(packageFile, 'utf8'))
|
|
46
|
+
return typeof parsed.version === 'string' ? parsed.version : null
|
|
47
|
+
} catch {
|
|
48
|
+
return null
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function commandFailure(result) {
|
|
53
|
+
return (result.err?.trim() || result.out?.trim() || `进程退出码 ${String(result.code)}`).slice(0, 1200)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function runCommand(command, args, cwd, timeoutMs = 600_000, extraEnv) {
|
|
57
|
+
const isWindows = process.platform === 'win32'
|
|
58
|
+
const candidates = isWindows && !/\.(exe|cmd|bat)$/i.test(command)
|
|
59
|
+
? [command, `${command}.cmd`]
|
|
60
|
+
: [command]
|
|
61
|
+
for (const candidate of candidates) {
|
|
62
|
+
const isBatch = isWindows && candidate.endsWith('.cmd')
|
|
63
|
+
const executable = isBatch ? 'cmd.exe' : candidate
|
|
64
|
+
const commandArgs = isBatch
|
|
65
|
+
? ['/d', '/s', '/c', [candidate, ...args].map(quoteCmdArgument).join(' ')]
|
|
66
|
+
: args
|
|
67
|
+
const result = spawnSync(executable, commandArgs, {
|
|
68
|
+
cwd,
|
|
69
|
+
encoding: 'utf8',
|
|
70
|
+
timeout: timeoutMs,
|
|
71
|
+
env: { ...process.env, ...extraEnv },
|
|
72
|
+
windowsHide: true,
|
|
73
|
+
})
|
|
74
|
+
const code = result.error?.code
|
|
75
|
+
if (result.error && (code === 'ENOENT' || code === 'EINVAL')) continue
|
|
76
|
+
return {
|
|
77
|
+
ok: result.status === 0 && !result.error,
|
|
78
|
+
code: result.status,
|
|
79
|
+
out: String(result.stdout ?? ''),
|
|
80
|
+
err: [String(result.stderr ?? ''), result.error?.message ?? ''].filter(Boolean).join('\n'),
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return { ok: false, code: null, out: '', err: `command not found: ${command}` }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function quoteCmdArgument(value) {
|
|
87
|
+
const text = String(value)
|
|
88
|
+
if (/^[A-Za-z0-9_@%+=:,./\\-]+$/.test(text)) return text
|
|
89
|
+
return `"${text.replaceAll('"', '""')}"`
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
class JobError extends Error {
|
|
93
|
+
constructor(stage, message) {
|
|
94
|
+
super(message)
|
|
95
|
+
this.stage = stage
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function updateState(spec, state, patch) {
|
|
100
|
+
Object.assign(state, patch)
|
|
101
|
+
writeJobState(spec.statePath, spec.latestPath, state)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** 单任务模式:步骤与进度直接写入本 job 的状态。 */
|
|
105
|
+
function singleReporter(spec, state) {
|
|
106
|
+
return {
|
|
107
|
+
step: (text) => { state.steps.push(text) },
|
|
108
|
+
progress: (patch) => updateState(spec, state, patch),
|
|
109
|
+
finish: (patch) => updateState(spec, state, patch),
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** 查询 npm registry 某包当前 latest(更新竞态宽限用;失败返回 null)。 */
|
|
114
|
+
export function queryNpmLatest(spec, runner, networkEnv) {
|
|
115
|
+
const r = runner('npm', ['view', spec.packageName, 'version', '--json'], spec.profileDir, 30_000, networkEnv)
|
|
116
|
+
if (!r.ok) return null
|
|
117
|
+
try {
|
|
118
|
+
const v = JSON.parse(String(r.out).trim())
|
|
119
|
+
return typeof v === 'string' ? v : null
|
|
120
|
+
} catch {
|
|
121
|
+
return null
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** 把包回滚到更新前版本:已恢复原样返回 null(无需或已回滚),失败返回原因。
|
|
126
|
+
* 目标是"更新失败 = 保持更新前状态",不把半更新态留给用户。 */
|
|
127
|
+
function rollbackNpmUpdate(spec, runner, reporter, beforeVersion, networkEnv) {
|
|
128
|
+
if (!beforeVersion) return '更新前版本未知,无法自动回滚,请检查当前安装状态'
|
|
129
|
+
const current = readPackageVersion(spec.profileDir, spec.packageName)
|
|
130
|
+
if (!current || current === beforeVersion) return null
|
|
131
|
+
reporter.progress({ stage: 'rollback', message: `正在回滚 ${spec.packageName} 到 ${beforeVersion}` })
|
|
132
|
+
const rb = runner('pnpm', ['add', `${spec.packageName}@${beforeVersion}`], spec.profileDir, 600_000, networkEnv)
|
|
133
|
+
const restored = readPackageVersion(spec.profileDir, spec.packageName)
|
|
134
|
+
if (rb.ok && restored === beforeVersion) {
|
|
135
|
+
reporter.step(`已回滚到 ${beforeVersion},保持更新前状态`)
|
|
136
|
+
return null
|
|
137
|
+
}
|
|
138
|
+
return `自动回滚失败(当前 ${restored || '未安装'},期望 ${beforeVersion}):${commandFailure(rb)}`
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** 更新失败时先尝试回滚再抛错:回滚失败的原因并入任务错误,便于用户接手。 */
|
|
142
|
+
function throwAfterRollback(stage, baseMessage, spec, runner, reporter, beforeVersion, networkEnv) {
|
|
143
|
+
const rollbackError = rollbackNpmUpdate(spec, runner, reporter, beforeVersion, networkEnv)
|
|
144
|
+
throw new JobError(stage, rollbackError ? `${baseMessage}\n${rollbackError}` : baseMessage)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function performNpmUpdate(spec, runner, reporter) {
|
|
148
|
+
const beforeVersion = readPackageVersion(spec.profileDir, spec.packageName)
|
|
149
|
+
reporter.progress({
|
|
150
|
+
stage: 'update',
|
|
151
|
+
beforeVersion,
|
|
152
|
+
message: `正在更新 ${spec.packageName}`,
|
|
153
|
+
})
|
|
154
|
+
const networkEnv = spec.proxy
|
|
155
|
+
? { HTTP_PROXY: spec.proxy, HTTPS_PROXY: spec.proxy, http_proxy: spec.proxy, https_proxy: spec.proxy }
|
|
156
|
+
: undefined
|
|
157
|
+
const result = runner('pnpm', ['update', spec.packageName, '--latest'], spec.profileDir, 600_000, networkEnv)
|
|
158
|
+
if (!result.ok) {
|
|
159
|
+
throwAfterRollback('update', `pnpm update 失败\n${commandFailure(result)}`, spec, runner, reporter, beforeVersion, networkEnv)
|
|
160
|
+
}
|
|
161
|
+
reporter.step('pnpm update 执行完成')
|
|
162
|
+
reporter.progress({ stage: 'verify', message: '正在核对实际安装版本' })
|
|
163
|
+
const installed = readPackageVersion(spec.profileDir, spec.packageName)
|
|
164
|
+
const verification = verifyNpmUpdate({
|
|
165
|
+
before: beforeVersion,
|
|
166
|
+
expected: spec.expectedVersion,
|
|
167
|
+
installed,
|
|
168
|
+
})
|
|
169
|
+
let effectiveVersion = installed
|
|
170
|
+
if (!verification.ok) {
|
|
171
|
+
// 竞态宽限:检查更新与执行之间上游可能又发了新版,pnpm --latest 会装上
|
|
172
|
+
// 比任务期望更新的一版。装上的若就是此刻的 npm latest,按实际版本放行;
|
|
173
|
+
// 否则才视为失败并回滚。
|
|
174
|
+
const freshLatest = queryNpmLatest(spec, runner, networkEnv)
|
|
175
|
+
if (installed && freshLatest && installed === freshLatest) {
|
|
176
|
+
reporter.step(`更新期间上游发布了 ${freshLatest},已按实际安装版本通过校验`)
|
|
177
|
+
effectiveVersion = freshLatest
|
|
178
|
+
} else {
|
|
179
|
+
throwAfterRollback('verify', verification.message, spec, runner, reporter, beforeVersion, networkEnv)
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
reporter.step(effectiveVersion === installed && verification.ok ? verification.message : `已安装 ${effectiveVersion}`)
|
|
183
|
+
reporter.progress({ stage: 'reconcile', message: '正在同步 profile bundles' })
|
|
184
|
+
const profileManifest = readPackage(spec.profileDir)
|
|
185
|
+
const declares = bundleDeclared(spec.profileDir, spec.packageName)
|
|
186
|
+
const reconciliation = reconcileBundles(profileManifest, spec.packageName, declares)
|
|
187
|
+
if (reconciliation.changed) {
|
|
188
|
+
profileManifest.dsh = {
|
|
189
|
+
...profileManifest.dsh,
|
|
190
|
+
profile: { ...profileManifest.dsh?.profile, bundles: reconciliation.bundles },
|
|
191
|
+
}
|
|
192
|
+
writeProfileManifestAtomic(spec.profileDir, profileManifest)
|
|
193
|
+
reporter.step(`bundles 已同步:${declares ? '已加入' : '已移出'} ${spec.packageName}`)
|
|
194
|
+
} else {
|
|
195
|
+
reporter.step('bundles 无需变更')
|
|
196
|
+
}
|
|
197
|
+
reporter.finish({
|
|
198
|
+
status: 'completed',
|
|
199
|
+
stage: 'completed',
|
|
200
|
+
message: `${spec.packageName} 更新完成`,
|
|
201
|
+
afterVersion: effectiveVersion,
|
|
202
|
+
changed: verification.changed,
|
|
203
|
+
restartRequired: true,
|
|
204
|
+
finishedAt: new Date().toISOString(),
|
|
205
|
+
})
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
async function runNpmJob(spec, state, runner) {
|
|
209
|
+
await performNpmUpdate(spec, runner, singleReporter(spec, state))
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function readPackage(baseDir) {
|
|
213
|
+
try {
|
|
214
|
+
return JSON.parse(readFileSync(join(baseDir, 'package.json'), 'utf8'))
|
|
215
|
+
} catch {
|
|
216
|
+
return null
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* 安装在 profile 里的包是否声明了 dsh.bundle(与官方 `dsh plugin` 的判定一致:
|
|
222
|
+
* dsh.bundle.patch 存在即为 bundle 层)。
|
|
223
|
+
*/
|
|
224
|
+
export function bundleDeclared(profileDir, packageName) {
|
|
225
|
+
const pkg = readPackage(join(profileDir, 'node_modules', ...packageName.split('/')))
|
|
226
|
+
return pkg?.dsh?.bundle?.patch !== undefined
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* 按官方 `dsh plugin` 的对账规则同步 dsh.profile.bundles:
|
|
231
|
+
* 依赖解析到声明 dsh.bundle 的包 → 加入层列表(按依赖顺序追加);
|
|
232
|
+
* 依赖不再声明 → 移出层列表。模板 bundle(非依赖)永不触碰。
|
|
233
|
+
*/
|
|
234
|
+
export function reconcileBundles(manifest, packageName, declaresBundle) {
|
|
235
|
+
const isDependency = packageName in (manifest?.dependencies ?? {})
|
|
236
|
+
const bundles = [...((manifest?.dsh?.profile?.bundles) ?? [])]
|
|
237
|
+
if (!isDependency) return { changed: false, bundles }
|
|
238
|
+
const listed = bundles.includes(packageName)
|
|
239
|
+
if (declaresBundle && !listed) {
|
|
240
|
+
bundles.push(packageName)
|
|
241
|
+
return { changed: true, bundles }
|
|
242
|
+
}
|
|
243
|
+
if (!declaresBundle && listed) {
|
|
244
|
+
bundles.splice(bundles.indexOf(packageName), 1)
|
|
245
|
+
return { changed: true, bundles }
|
|
246
|
+
}
|
|
247
|
+
return { changed: false, bundles }
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function writeProfileManifestAtomic(profileDir, manifest) {
|
|
251
|
+
const file = join(profileDir, 'package.json')
|
|
252
|
+
const temporary = `${file}.${process.pid}.tmp`
|
|
253
|
+
writeFileSync(temporary, JSON.stringify(manifest, null, 2) + '\n', 'utf8')
|
|
254
|
+
renameSync(temporary, file)
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** 卸载路径的 bundles 对账:在层列表中直接移除(调用方已校验它曾是 profile 依赖)。 */
|
|
258
|
+
export function removeFromBundles(manifest, packageName) {
|
|
259
|
+
const bundles = [...((manifest?.dsh?.profile?.bundles) ?? [])]
|
|
260
|
+
const index = bundles.indexOf(packageName)
|
|
261
|
+
if (index < 0) return { changed: false, bundles }
|
|
262
|
+
bundles.splice(index, 1)
|
|
263
|
+
return { changed: true, bundles }
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** 从 pnpm 输出中提取被跳过构建脚本的包名(去掉版本后缀与列表符号)。 */
|
|
267
|
+
export function parseIgnoredBuilds(text) {
|
|
268
|
+
const lines = String(text ?? '').split(/\r?\n/)
|
|
269
|
+
const index = lines.findIndex((line) => line.includes('Ignored build scripts'))
|
|
270
|
+
if (index < 0) return []
|
|
271
|
+
const names = []
|
|
272
|
+
for (const line of lines.slice(index + 1)) {
|
|
273
|
+
if (!line.trim()) break
|
|
274
|
+
const match = /^\s*[-*]\s*(.+?)\s*$/.exec(line)
|
|
275
|
+
if (!match) break
|
|
276
|
+
names.push(match[1].replace(/@[^@\s]+$/, '').replace(/[,;]+$/, ''))
|
|
277
|
+
}
|
|
278
|
+
return [...new Set(names)]
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** 识别 pnpm≥10 显式拒绝执行构建脚本的报错,返回包名(放行 allowBuilds 后重试可解决)。 */
|
|
282
|
+
export function parseBuildsRefused(text) {
|
|
283
|
+
const match = /"([^"]+)@[^"]*"\s+(?:needs to execute|wants to run) build scripts/.exec(String(text ?? ''))
|
|
284
|
+
return match ? match[1] : null
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* 安装后的防假成功校验:包可解析、且(声明 dsh 清单 或 main 入口文件存在)。
|
|
289
|
+
* prepare 构建被 pnpm 跳过时产物缺失,会被这里拦下并触发回滚。
|
|
290
|
+
*/
|
|
291
|
+
export function installedPackageValid(profileDir, packageName) {
|
|
292
|
+
const dir = join(profileDir, 'node_modules', ...packageName.split('/'))
|
|
293
|
+
const pkg = readPackage(dir)
|
|
294
|
+
if (!pkg?.name) return false
|
|
295
|
+
if (pkg.dsh !== undefined) return true
|
|
296
|
+
if (typeof pkg.main === 'string') return existsSync(resolve(dir, pkg.main))
|
|
297
|
+
return true
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function buildScriptHint(result) {
|
|
301
|
+
const refused = parseBuildsRefused(`${result.out}\n${result.err}`)
|
|
302
|
+
if (refused) {
|
|
303
|
+
return `\n提示:pnpm 拒绝执行 ${refused} 的构建脚本。在 profile 的 pnpm-workspace.yaml 中按 pnpm 输出的确切键名放行(allowBuilds/onlyBuiltDependencies)后重试。`
|
|
304
|
+
}
|
|
305
|
+
const ignored = parseIgnoredBuilds(`${result.out}\n${result.err}`)
|
|
306
|
+
if (ignored.length) {
|
|
307
|
+
return `\n提示:以下包的构建脚本被 pnpm 跳过,缺少构建产物时插件可能无法加载:${ignored.join(', ')}。在 profile 的 pnpm-workspace.yaml 放行后重新安装。`
|
|
308
|
+
}
|
|
309
|
+
return ''
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function applyBundleReconciliations(profileDir, names) {
|
|
313
|
+
const manifest = readPackage(profileDir)
|
|
314
|
+
if (!manifest) return []
|
|
315
|
+
const changed = []
|
|
316
|
+
let bundles = manifest.dsh?.profile?.bundles ? [...manifest.dsh.profile.bundles] : []
|
|
317
|
+
for (const name of names) {
|
|
318
|
+
if (bundleDeclared(profileDir, name)) {
|
|
319
|
+
if (!bundles.includes(name)) {
|
|
320
|
+
bundles.push(name)
|
|
321
|
+
changed.push(name)
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
if (changed.length) {
|
|
326
|
+
manifest.dsh = { ...manifest.dsh, profile: { ...manifest.dsh?.profile, bundles } }
|
|
327
|
+
writeProfileManifestAtomic(profileDir, manifest)
|
|
328
|
+
}
|
|
329
|
+
return changed
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
async function runInstallJob(spec, state, runner) {
|
|
333
|
+
const networkEnv = spec.proxy
|
|
334
|
+
? { HTTP_PROXY: spec.proxy, HTTPS_PROXY: spec.proxy, http_proxy: spec.proxy, https_proxy: spec.proxy }
|
|
335
|
+
: undefined
|
|
336
|
+
const before = readPackage(spec.profileDir)
|
|
337
|
+
const beforeDeps = new Set(Object.keys(before?.dependencies ?? {}))
|
|
338
|
+
updateState(spec, state, { stage: 'install', message: `正在安装 ${spec.target || spec.installSpec}` })
|
|
339
|
+
const result = runner('pnpm', ['add', spec.installSpec], spec.profileDir, 600_000, networkEnv)
|
|
340
|
+
if (!result.ok) {
|
|
341
|
+
throw new JobError('install', `pnpm add 失败\n${commandFailure(result)}${buildScriptHint(result)}`)
|
|
342
|
+
}
|
|
343
|
+
const hint = buildScriptHint(result)
|
|
344
|
+
if (hint) state.steps.push(`pnpm 提示(构建脚本):${hint.trim()}`)
|
|
345
|
+
const after = readPackage(spec.profileDir)
|
|
346
|
+
const added = Object.keys(after?.dependencies ?? {}).filter((name) => !beforeDeps.has(name))
|
|
347
|
+
if (!added.length) {
|
|
348
|
+
throw new JobError('verify', 'pnpm add 成功但依赖没有任何变化(可能已安装,或版本被 minimumReleaseAge 限制)。')
|
|
349
|
+
}
|
|
350
|
+
state.steps.push(`已安装依赖:${added.join(', ')}`)
|
|
351
|
+
|
|
352
|
+
const broken = added.filter((name) => !installedPackageValid(spec.profileDir, name))
|
|
353
|
+
if (broken.length) {
|
|
354
|
+
updateState(spec, state, { stage: 'rollback', message: '安装校验失败,正在回滚' })
|
|
355
|
+
for (const name of broken) {
|
|
356
|
+
runner('pnpm', ['remove', name], spec.profileDir, 300_000, networkEnv)
|
|
357
|
+
}
|
|
358
|
+
throw new JobError('verify', `以下包安装后缺少可运行入口(可能是构建脚本被跳过),已自动回滚:${broken.join(', ')}${hint || buildScriptHint(result)}`)
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
updateState(spec, state, { stage: 'reconcile', message: '正在同步 profile bundles' })
|
|
362
|
+
const activated = applyBundleReconciliations(spec.profileDir, added)
|
|
363
|
+
if (activated.length) state.steps.push(`已加入 bundles 层:${activated.join(', ')}`)
|
|
364
|
+
else state.steps.push('新包未声明 dsh.bundle,按普通依赖安装')
|
|
365
|
+
|
|
366
|
+
updateState(spec, state, {
|
|
367
|
+
status: 'completed',
|
|
368
|
+
stage: 'completed',
|
|
369
|
+
message: `${spec.target || added.join(', ')} 安装完成,重启 dsh web 后生效`,
|
|
370
|
+
installed: added,
|
|
371
|
+
restartRequired: true,
|
|
372
|
+
finishedAt: new Date().toISOString(),
|
|
373
|
+
})
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
async function runRemoveJob(spec, state, runner) {
|
|
377
|
+
const before = readPackage(spec.profileDir)
|
|
378
|
+
if (!(spec.packageName in (before?.dependencies ?? {}))) {
|
|
379
|
+
throw new JobError('preflight', `${spec.packageName} 不在 profile 依赖中,无法卸载。`)
|
|
380
|
+
}
|
|
381
|
+
updateState(spec, state, { stage: 'remove', message: `正在卸载 ${spec.packageName}` })
|
|
382
|
+
const result = runner('pnpm', ['remove', spec.packageName], spec.profileDir, 300_000)
|
|
383
|
+
if (!result.ok) {
|
|
384
|
+
throw new JobError('remove', `pnpm remove 失败\n${commandFailure(result)}`)
|
|
385
|
+
}
|
|
386
|
+
state.steps.push('pnpm remove 完成')
|
|
387
|
+
const manifest = readPackage(spec.profileDir)
|
|
388
|
+
const reconciliation = removeFromBundles(manifest, spec.packageName)
|
|
389
|
+
if (reconciliation.changed) {
|
|
390
|
+
manifest.dsh = {
|
|
391
|
+
...manifest.dsh,
|
|
392
|
+
profile: { ...manifest.dsh?.profile, bundles: reconciliation.bundles },
|
|
393
|
+
}
|
|
394
|
+
writeProfileManifestAtomic(spec.profileDir, manifest)
|
|
395
|
+
state.steps.push(`已从 bundles 层移除:${spec.packageName}`)
|
|
396
|
+
}
|
|
397
|
+
updateState(spec, state, {
|
|
398
|
+
status: 'completed',
|
|
399
|
+
stage: 'completed',
|
|
400
|
+
message: `${spec.packageName} 卸载完成,重启 dsh web 后生效`,
|
|
401
|
+
restartRequired: true,
|
|
402
|
+
finishedAt: new Date().toISOString(),
|
|
403
|
+
})
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function requireCommand(result, stage, label) {
|
|
407
|
+
if (!result.ok) throw new JobError(stage, `${label}失败\n${commandFailure(result)}`)
|
|
408
|
+
return result.out.trim()
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function packageManagerFor(dir, packageJson) {
|
|
412
|
+
if (typeof packageJson?.packageManager === 'string' && packageJson.packageManager.startsWith('pnpm@')) return 'pnpm'
|
|
413
|
+
if (existsSync(join(dir, 'pnpm-lock.yaml'))) return 'pnpm'
|
|
414
|
+
return 'npm'
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
async function performLinkUpdate(spec, runner, reporter) {
|
|
418
|
+
const dir = spec.dir
|
|
419
|
+
const networkEnv = spec.proxy
|
|
420
|
+
? { HTTP_PROXY: spec.proxy, HTTPS_PROXY: spec.proxy, http_proxy: spec.proxy, https_proxy: spec.proxy }
|
|
421
|
+
: undefined
|
|
422
|
+
const status = requireCommand(runner('git', ['status', '--porcelain'], dir, 10_000), 'preflight', '读取 Git 状态')
|
|
423
|
+
if (status) throw new JobError('preflight', `检测到未提交改动,为避免覆盖本地工作,已停止更新:\n${status}`)
|
|
424
|
+
const before = requireCommand(runner('git', ['rev-parse', 'HEAD'], dir, 10_000), 'preflight', '读取当前提交')
|
|
425
|
+
reporter.progress({ stage: 'fetch', beforeCommit: before, message: `正在获取 ${spec.packageName} 的远端更新` })
|
|
426
|
+
requireCommand(runner('git', ['fetch', '--all', '--prune'], dir, 120_000, networkEnv), 'fetch', 'git fetch ')
|
|
427
|
+
|
|
428
|
+
const branch = requireCommand(runner('git', ['rev-parse', '--abbrev-ref', 'HEAD'], dir, 10_000), 'preflight', '读取当前分支')
|
|
429
|
+
if (branch === 'HEAD') {
|
|
430
|
+
const defaultRef = requireCommand(
|
|
431
|
+
runner('git', ['symbolic-ref', '--quiet', '--short', 'refs/remotes/origin/HEAD'], dir, 10_000),
|
|
432
|
+
'preflight',
|
|
433
|
+
'确认远端默认分支',
|
|
434
|
+
)
|
|
435
|
+
const shortName = defaultRef.includes('/') ? defaultRef.slice(defaultRef.indexOf('/') + 1) : defaultRef
|
|
436
|
+
requireCommand(runner('git', ['checkout', '-B', shortName, defaultRef], dir, 60_000), 'checkout', '切换默认分支')
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
reporter.progress({ stage: 'pull', message: `正在拉取 ${spec.packageName}` })
|
|
440
|
+
requireCommand(runner('git', ['pull', '--ff-only'], dir, 300_000, networkEnv), 'pull', 'git pull ')
|
|
441
|
+
const after = requireCommand(runner('git', ['rev-parse', 'HEAD'], dir, 10_000), 'verify', '读取更新后提交')
|
|
442
|
+
const changed = before !== after
|
|
443
|
+
const changedOutput = changed
|
|
444
|
+
? requireCommand(runner('git', ['diff', '--name-only', `${before}..${after}`], dir, 10_000), 'verify', '读取变更文件')
|
|
445
|
+
: ''
|
|
446
|
+
const changedPaths = changedOutput.split('\n').map((file) => file.trim()).filter(Boolean)
|
|
447
|
+
const artifacts = analyzeGitArtifacts(changedPaths)
|
|
448
|
+
reporter.step(changed ? `Git 已更新:${before.slice(0, 10)} → ${after.slice(0, 10)}` : 'Git 已是最新')
|
|
449
|
+
|
|
450
|
+
if (artifacts.buildRequired) {
|
|
451
|
+
const packageJson = readPackage(dir)
|
|
452
|
+
if (typeof packageJson?.scripts?.build !== 'string') {
|
|
453
|
+
throw new JobError('build', '源码已更新,但上游没有提交 lib/dist 产物,也没有提供 build 脚本。')
|
|
454
|
+
}
|
|
455
|
+
const manager = packageManagerFor(dir, packageJson)
|
|
456
|
+
reporter.progress({ stage: 'build', message: `正在重新构建 ${spec.packageName}` })
|
|
457
|
+
const buildEnv = { ...networkEnv, DSH_CHECKOUT: spec.repoDir ?? process.env.DSH_CHECKOUT ?? '' }
|
|
458
|
+
requireCommand(runner(manager, ['run', 'build'], dir, 900_000, buildEnv), 'build', `${manager} run build `)
|
|
459
|
+
reporter.step(`${manager} run build 完成`)
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
if (spec.kind === 'preset') {
|
|
463
|
+
const presetSource = join(dir, 'preset')
|
|
464
|
+
if (existsSync(presetSource)) {
|
|
465
|
+
for (const file of ['agent.cordis.yml', 'preset.yml', 'router-bootstrap.mjs', 'router-core.mjs']) {
|
|
466
|
+
const source = join(presetSource, file)
|
|
467
|
+
if (existsSync(source)) copyFileSync(source, join(dir, file))
|
|
468
|
+
}
|
|
469
|
+
reporter.step('preset 运行副本已同步')
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
const packageJson = readPackage(dir)
|
|
474
|
+
if (typeof packageJson?.main === 'string' && !existsSync(join(dir, packageJson.main))) {
|
|
475
|
+
throw new JobError('verify', `运行入口不存在:${packageJson.main}`)
|
|
476
|
+
}
|
|
477
|
+
reporter.finish({
|
|
478
|
+
status: 'completed',
|
|
479
|
+
stage: 'completed',
|
|
480
|
+
message: changed ? `${spec.packageName} 更新完成` : `${spec.packageName} 已是最新`,
|
|
481
|
+
afterCommit: after,
|
|
482
|
+
changed,
|
|
483
|
+
changedPaths,
|
|
484
|
+
restartRequired: changed,
|
|
485
|
+
finishedAt: new Date().toISOString(),
|
|
486
|
+
})
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
async function runLinkJob(spec, state, runner) {
|
|
490
|
+
await performLinkUpdate(spec, runner, singleReporter(spec, state))
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/** 批量更新:串行执行子任务,单个失败不中断其余,最后汇总成败。 */
|
|
494
|
+
async function runBatchJob(spec, state, runner) {
|
|
495
|
+
const jobs = Array.isArray(spec.jobs) ? spec.jobs : []
|
|
496
|
+
if (!jobs.length) throw new JobError('preflight', '批量任务为空')
|
|
497
|
+
const results = []
|
|
498
|
+
let succeeded = 0
|
|
499
|
+
let restartRequired = false
|
|
500
|
+
for (let i = 0; i < jobs.length; i += 1) {
|
|
501
|
+
const sub = jobs[i]
|
|
502
|
+
const label = String(sub.target ?? sub.packageName ?? `#${i + 1}`)
|
|
503
|
+
const reporter = {
|
|
504
|
+
step: (text) => { state.steps.push(`[${label}] ${text}`) },
|
|
505
|
+
progress: (patch) => updateState(spec, state, {
|
|
506
|
+
stage: 'batch',
|
|
507
|
+
message: `(${i + 1}/${jobs.length}) ${String(patch.message ?? label)}`,
|
|
508
|
+
}),
|
|
509
|
+
finish: (patch) => {
|
|
510
|
+
if (patch.restartRequired) restartRequired = true
|
|
511
|
+
state.steps.push(`[${label}] ${String(patch.message ?? '完成')}`)
|
|
512
|
+
},
|
|
513
|
+
}
|
|
514
|
+
try {
|
|
515
|
+
const subSpec = { ...spec, ...sub }
|
|
516
|
+
if (sub.action === 'npm') await performNpmUpdate(subSpec, runner, reporter)
|
|
517
|
+
else if (sub.action === 'link') await performLinkUpdate(subSpec, runner, reporter)
|
|
518
|
+
else throw new JobError('batch', `unsupported sub action: ${String(sub.action)}`)
|
|
519
|
+
succeeded += 1
|
|
520
|
+
results.push({ target: label, ok: true })
|
|
521
|
+
} catch (error) {
|
|
522
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
523
|
+
results.push({ target: label, ok: false, error: message })
|
|
524
|
+
state.steps.push(`[${label}] 失败:${message}`)
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
const failed = results.filter((entry) => !entry.ok)
|
|
528
|
+
updateState(spec, state, {
|
|
529
|
+
status: succeeded === 0 ? 'failed' : 'completed',
|
|
530
|
+
stage: 'batch-done',
|
|
531
|
+
message: failed.length
|
|
532
|
+
? `批量更新:${succeeded} 成功 / ${failed.length} 失败`
|
|
533
|
+
: `批量更新完成:${succeeded} 项`,
|
|
534
|
+
error: failed.length ? failed.map((entry) => `${entry.target}: ${entry.error}`).join('\n') : undefined,
|
|
535
|
+
results,
|
|
536
|
+
restartRequired: restartRequired && succeeded > 0,
|
|
537
|
+
finishedAt: new Date().toISOString(),
|
|
538
|
+
})
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/** tsdown/pnpm workspace glob 覆盖的包目录(packages/<scope>/<name>、vendor/<name>、apps/cli)。 */
|
|
542
|
+
export function enumerateWorkspaceDirs(repoDir) {
|
|
543
|
+
const out = []
|
|
544
|
+
const packagesDir = join(repoDir, 'packages')
|
|
545
|
+
try {
|
|
546
|
+
for (const scope of readdirSync(packagesDir, { withFileTypes: true })) {
|
|
547
|
+
if (!scope.isDirectory()) continue
|
|
548
|
+
const scopeDir = join(packagesDir, scope.name)
|
|
549
|
+
for (const pkg of readdirSync(scopeDir, { withFileTypes: true })) {
|
|
550
|
+
if (pkg.isDirectory()) out.push(join(scopeDir, pkg.name))
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
} catch { /* 无 packages 目录则跳过 */ }
|
|
554
|
+
try {
|
|
555
|
+
for (const dirent of readdirSync(join(repoDir, 'vendor'), { withFileTypes: true })) {
|
|
556
|
+
if (dirent.isDirectory()) out.push(join(repoDir, 'vendor', dirent.name))
|
|
557
|
+
}
|
|
558
|
+
} catch { /* 无 vendor 目录则跳过 */ }
|
|
559
|
+
out.push(join(repoDir, 'apps', 'cli'))
|
|
560
|
+
return out
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
/** 空壳目录里允许存在的构建产物名;出现其余内容即视为可能含本地工作,跳过不删。 */
|
|
564
|
+
const RESIDUAL_ENTRY_WHITELIST = new Set(['lib', 'dist', 'node_modules', '.turbo'])
|
|
565
|
+
|
|
566
|
+
/**
|
|
567
|
+
* 找出上游删包残留的空壳目录:git pull 不会清除 gitignore 的 lib/node_modules,
|
|
568
|
+
* 会被 workspace glob 卷进构建图(曾致 tsdown [MISSING_EXPORT] 构建失败)。
|
|
569
|
+
* 只报告同时满足三个条件的目录:无 package.json、git 不跟踪其中任何文件、
|
|
570
|
+
* 目录内容全部是白名单内的构建产物。git 查询失败时宁可保守(不报告)。
|
|
571
|
+
*/
|
|
572
|
+
export function findStaleResidualDirs(repoDir, runner) {
|
|
573
|
+
const stale = []
|
|
574
|
+
for (const dir of enumerateWorkspaceDirs(repoDir)) {
|
|
575
|
+
if (existsSync(join(dir, 'package.json'))) continue
|
|
576
|
+
const rel = relative(repoDir, dir).replaceAll('\\', '/')
|
|
577
|
+
const tracked = runner('git', ['ls-files', '--', rel], repoDir, 10_000)
|
|
578
|
+
if (!tracked.ok || tracked.out.trim()) continue
|
|
579
|
+
try {
|
|
580
|
+
const entries = readdirSync(dir)
|
|
581
|
+
if (!entries.length || !entries.every((entry) => RESIDUAL_ENTRY_WHITELIST.has(entry))) continue
|
|
582
|
+
stale.push(dir)
|
|
583
|
+
} catch { /* 目录不可读则跳过 */ }
|
|
584
|
+
}
|
|
585
|
+
return stale
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
function cleanupStaleResidualDirs(repoDir, state, runner) {
|
|
589
|
+
let staleDirs
|
|
590
|
+
try {
|
|
591
|
+
staleDirs = findStaleResidualDirs(repoDir, runner)
|
|
592
|
+
} catch (error) {
|
|
593
|
+
state.steps.push(`残留目录扫描失败(不影响本次更新):${error instanceof Error ? error.message : String(error)}`)
|
|
594
|
+
return
|
|
595
|
+
}
|
|
596
|
+
for (const dir of staleDirs) {
|
|
597
|
+
try {
|
|
598
|
+
rmSync(dir, { recursive: true, force: true })
|
|
599
|
+
state.steps.push(`已清理上游删包残留目录:${relative(repoDir, dir).replaceAll('\\', '/')}`)
|
|
600
|
+
} catch { /* 删除失败不阻断更新;build 会以原始错误暴露问题 */ }
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
async function runDshJob(spec, state, runner) {
|
|
605
|
+
const dir = spec.repoDir
|
|
606
|
+
const networkEnv = spec.proxy
|
|
607
|
+
? { HTTP_PROXY: spec.proxy, HTTPS_PROXY: spec.proxy, http_proxy: spec.proxy, https_proxy: spec.proxy }
|
|
608
|
+
: undefined
|
|
609
|
+
const status = requireCommand(runner('git', ['status', '--porcelain'], dir, 10_000), 'preflight', '读取 dsh Git 状态')
|
|
610
|
+
if (status) throw new JobError('preflight', `dsh 存在未提交改动,为避免覆盖本地工作,已停止更新:\n${status}`)
|
|
611
|
+
const currentBranch = requireCommand(runner('git', ['rev-parse', '--abbrev-ref', 'HEAD'], dir, 10_000), 'preflight', '读取 dsh 分支')
|
|
612
|
+
if (currentBranch !== spec.branch) {
|
|
613
|
+
throw new JobError('preflight', `当前分支为 ${currentBranch},配置更新分支为 ${spec.branch}`)
|
|
614
|
+
}
|
|
615
|
+
const before = requireCommand(runner('git', ['rev-parse', 'HEAD'], dir, 10_000), 'preflight', '读取 dsh 当前提交')
|
|
616
|
+
const beforeVersion = readPackage(dir)?.version ?? null
|
|
617
|
+
updateState(spec, state, {
|
|
618
|
+
stage: 'pull',
|
|
619
|
+
beforeCommit: before,
|
|
620
|
+
beforeVersion,
|
|
621
|
+
message: '正在拉取 dsh 更新',
|
|
622
|
+
})
|
|
623
|
+
requireCommand(
|
|
624
|
+
runner('git', ['pull', '--ff-only', spec.remote, spec.branch], dir, 300_000, networkEnv),
|
|
625
|
+
'pull',
|
|
626
|
+
'git pull ',
|
|
627
|
+
)
|
|
628
|
+
const after = requireCommand(runner('git', ['rev-parse', 'HEAD'], dir, 10_000), 'verify', '读取 dsh 更新后提交')
|
|
629
|
+
const changed = before !== after
|
|
630
|
+
state.steps.push(changed ? `Git 已更新:${before.slice(0, 10)} → ${after.slice(0, 10)}` : 'Git 已是最新')
|
|
631
|
+
|
|
632
|
+
// 上游删包残留清理:pull 后、install/build 前执行。失败不阻断更新。
|
|
633
|
+
cleanupStaleResidualDirs(dir, state, runner)
|
|
634
|
+
|
|
635
|
+
if (spec.full) {
|
|
636
|
+
updateState(spec, state, { stage: 'install', message: '正在安装 dsh 依赖' })
|
|
637
|
+
requireCommand(runner('pnpm', ['install'], dir, 600_000, networkEnv), 'install', 'pnpm install ')
|
|
638
|
+
state.steps.push('pnpm install 完成')
|
|
639
|
+
updateState(spec, state, { stage: 'build', message: '正在重新构建 dsh' })
|
|
640
|
+
requireCommand(runner('pnpm', ['run', 'build'], dir, 900_000, networkEnv), 'build', 'pnpm run build ')
|
|
641
|
+
state.steps.push('pnpm run build 完成')
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
const afterVersion = readPackage(dir)?.version ?? null
|
|
645
|
+
updateState(spec, state, {
|
|
646
|
+
status: 'completed',
|
|
647
|
+
stage: 'completed',
|
|
648
|
+
message: changed || spec.full ? 'dsh 更新完成' : 'dsh 已是最新',
|
|
649
|
+
afterCommit: after,
|
|
650
|
+
afterVersion,
|
|
651
|
+
changed,
|
|
652
|
+
restartRequired: changed || spec.full,
|
|
653
|
+
finishedAt: new Date().toISOString(),
|
|
654
|
+
})
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
/** dsh 本体回退:reset --hard 到任务记录的"更新前提交"并完整重建。
|
|
658
|
+
* 源码回退了,lib/node_modules 等产物必须跟着重建,否则新旧混跑。 */
|
|
659
|
+
async function runDshRollbackJob(spec, state, runner) {
|
|
660
|
+
const dir = spec.repoDir
|
|
661
|
+
const networkEnv = spec.proxy
|
|
662
|
+
? { HTTP_PROXY: spec.proxy, HTTPS_PROXY: spec.proxy, http_proxy: spec.proxy, https_proxy: spec.proxy }
|
|
663
|
+
: undefined
|
|
664
|
+
const commit = String(spec.commit ?? '')
|
|
665
|
+
if (!/^[0-9a-f]{4,40}$/i.test(commit)) {
|
|
666
|
+
throw new JobError('preflight', `回退目标提交号非法: ${commit}`)
|
|
667
|
+
}
|
|
668
|
+
const status = requireCommand(runner('git', ['status', '--porcelain'], dir, 10_000), 'preflight', '读取 dsh Git 状态')
|
|
669
|
+
if (status) throw new JobError('preflight', `dsh 存在未提交改动,为避免覆盖本地工作,已停止回退:\n${status}`)
|
|
670
|
+
const currentBranch = requireCommand(runner('git', ['rev-parse', '--abbrev-ref', 'HEAD'], dir, 10_000), 'preflight', '读取 dsh 分支')
|
|
671
|
+
if (spec.branch && currentBranch !== spec.branch) {
|
|
672
|
+
throw new JobError('preflight', `当前分支为 ${currentBranch},配置分支为 ${spec.branch}`)
|
|
673
|
+
}
|
|
674
|
+
const before = requireCommand(runner('git', ['rev-parse', 'HEAD'], dir, 10_000), 'preflight', '读取 dsh 当前提交')
|
|
675
|
+
if (before === commit) throw new JobError('preflight', 'dsh 已处于回退目标提交,无需回退')
|
|
676
|
+
requireCommand(runner('git', ['cat-file', '-e', `${commit}^{commit}`], dir, 10_000), 'preflight', '校验回退目标提交')
|
|
677
|
+
updateState(spec, state, {
|
|
678
|
+
stage: 'reset',
|
|
679
|
+
beforeCommit: before,
|
|
680
|
+
targetCommit: commit,
|
|
681
|
+
message: `正在回退 dsh 到 ${commit.slice(0, 10)}`,
|
|
682
|
+
})
|
|
683
|
+
requireCommand(runner('git', ['reset', '--hard', commit], dir, 60_000), 'reset', 'git reset ')
|
|
684
|
+
state.steps.push(`已回退:${before.slice(0, 10)} → ${commit.slice(0, 10)}(被回退的提交仍可用 git reflog 找回)`)
|
|
685
|
+
// 回退后的源码同样可能有上游删包残留,与正向更新共用清理逻辑
|
|
686
|
+
cleanupStaleResidualDirs(dir, state, runner)
|
|
687
|
+
updateState(spec, state, { stage: 'install', message: '正在安装 dsh 依赖' })
|
|
688
|
+
requireCommand(runner('pnpm', ['install'], dir, 600_000, networkEnv), 'install', 'pnpm install ')
|
|
689
|
+
state.steps.push('pnpm install 完成')
|
|
690
|
+
updateState(spec, state, { stage: 'build', message: '正在重新构建 dsh' })
|
|
691
|
+
requireCommand(runner('pnpm', ['run', 'build'], dir, 900_000, networkEnv), 'build', 'pnpm run build ')
|
|
692
|
+
state.steps.push('pnpm run build 完成')
|
|
693
|
+
const afterVersion = readPackage(dir)?.version ?? null
|
|
694
|
+
updateState(spec, state, {
|
|
695
|
+
status: 'completed',
|
|
696
|
+
stage: 'completed',
|
|
697
|
+
message: `dsh 已回退到 ${commit.slice(0, 10)},请重启 dsh web`,
|
|
698
|
+
afterCommit: commit,
|
|
699
|
+
afterVersion,
|
|
700
|
+
changed: true,
|
|
701
|
+
restartRequired: true,
|
|
702
|
+
finishedAt: new Date().toISOString(),
|
|
703
|
+
})
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
export async function runWorker(specPath, dependencies = {}) {
|
|
707
|
+
const spec = JSON.parse(readFileSync(specPath, 'utf8'))
|
|
708
|
+
const runner = dependencies.runCommand ?? runCommand
|
|
709
|
+
const startedAt = new Date().toISOString()
|
|
710
|
+
const base = {
|
|
711
|
+
id: spec.id,
|
|
712
|
+
action: spec.action,
|
|
713
|
+
target: spec.target ?? '',
|
|
714
|
+
status: 'running',
|
|
715
|
+
stage: 'starting',
|
|
716
|
+
steps: [],
|
|
717
|
+
startedAt,
|
|
718
|
+
restartRequired: false,
|
|
719
|
+
workerPid: process.pid,
|
|
720
|
+
}
|
|
721
|
+
writeJobState(spec.statePath, spec.latestPath, base)
|
|
722
|
+
try {
|
|
723
|
+
if (spec.action === 'npm') {
|
|
724
|
+
await runNpmJob(spec, base, runner)
|
|
725
|
+
return
|
|
726
|
+
}
|
|
727
|
+
if (spec.action === 'link') {
|
|
728
|
+
await runLinkJob(spec, base, runner)
|
|
729
|
+
return
|
|
730
|
+
}
|
|
731
|
+
if (spec.action === 'dsh') {
|
|
732
|
+
await runDshJob(spec, base, runner)
|
|
733
|
+
return
|
|
734
|
+
}
|
|
735
|
+
if (spec.action === 'rollback-dsh') {
|
|
736
|
+
await runDshRollbackJob(spec, base, runner)
|
|
737
|
+
return
|
|
738
|
+
}
|
|
739
|
+
if (spec.action === 'batch') {
|
|
740
|
+
await runBatchJob(spec, base, runner)
|
|
741
|
+
return
|
|
742
|
+
}
|
|
743
|
+
if (spec.action === 'install') {
|
|
744
|
+
await runInstallJob(spec, base, runner)
|
|
745
|
+
return
|
|
746
|
+
}
|
|
747
|
+
if (spec.action === 'remove') {
|
|
748
|
+
await runRemoveJob(spec, base, runner)
|
|
749
|
+
return
|
|
750
|
+
}
|
|
751
|
+
throw new JobError('failed', `unsupported update action: ${String(spec.action)}`)
|
|
752
|
+
} catch (error) {
|
|
753
|
+
writeJobState(spec.statePath, spec.latestPath, {
|
|
754
|
+
...base,
|
|
755
|
+
status: 'failed',
|
|
756
|
+
stage: error instanceof JobError ? error.stage : 'failed',
|
|
757
|
+
error: error instanceof Error ? error.message : String(error),
|
|
758
|
+
finishedAt: new Date().toISOString(),
|
|
759
|
+
})
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
764
|
+
await runWorker(process.argv[2])
|
|
765
|
+
}
|