dsh-update-center 0.8.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,598 @@
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
+ async function performNpmUpdate(spec, runner, reporter) {
114
+ const beforeVersion = readPackageVersion(spec.profileDir, spec.packageName)
115
+ reporter.progress({
116
+ stage: 'update',
117
+ beforeVersion,
118
+ message: `正在更新 ${spec.packageName}`,
119
+ })
120
+ const networkEnv = spec.proxy
121
+ ? { HTTP_PROXY: spec.proxy, HTTPS_PROXY: spec.proxy, http_proxy: spec.proxy, https_proxy: spec.proxy }
122
+ : undefined
123
+ const result = runner('pnpm', ['update', spec.packageName, '--latest'], spec.profileDir, 600_000, networkEnv)
124
+ if (!result.ok) throw new JobError('update', `pnpm update 失败\n${commandFailure(result)}`)
125
+ reporter.step('pnpm update 执行完成')
126
+ reporter.progress({ stage: 'verify', message: '正在核对实际安装版本' })
127
+ const installed = readPackageVersion(spec.profileDir, spec.packageName)
128
+ const verification = verifyNpmUpdate({
129
+ before: beforeVersion,
130
+ expected: spec.expectedVersion,
131
+ installed,
132
+ })
133
+ if (!verification.ok) throw new JobError('verify', verification.message)
134
+ reporter.step(verification.message)
135
+ reporter.progress({ stage: 'reconcile', message: '正在同步 profile bundles' })
136
+ const profileManifest = readPackage(spec.profileDir)
137
+ const declares = bundleDeclared(spec.profileDir, spec.packageName)
138
+ const reconciliation = reconcileBundles(profileManifest, spec.packageName, declares)
139
+ if (reconciliation.changed) {
140
+ profileManifest.dsh = {
141
+ ...profileManifest.dsh,
142
+ profile: { ...profileManifest.dsh?.profile, bundles: reconciliation.bundles },
143
+ }
144
+ writeProfileManifestAtomic(spec.profileDir, profileManifest)
145
+ reporter.step(`bundles 已同步:${declares ? '已加入' : '已移出'} ${spec.packageName}`)
146
+ } else {
147
+ reporter.step('bundles 无需变更')
148
+ }
149
+ reporter.finish({
150
+ status: 'completed',
151
+ stage: 'completed',
152
+ message: `${spec.packageName} 更新完成`,
153
+ afterVersion: installed,
154
+ changed: verification.changed,
155
+ restartRequired: true,
156
+ finishedAt: new Date().toISOString(),
157
+ })
158
+ }
159
+
160
+ async function runNpmJob(spec, state, runner) {
161
+ await performNpmUpdate(spec, runner, singleReporter(spec, state))
162
+ }
163
+
164
+ function readPackage(baseDir) {
165
+ try {
166
+ return JSON.parse(readFileSync(join(baseDir, 'package.json'), 'utf8'))
167
+ } catch {
168
+ return null
169
+ }
170
+ }
171
+
172
+ /**
173
+ * 安装在 profile 里的包是否声明了 dsh.bundle(与官方 `dsh plugin` 的判定一致:
174
+ * dsh.bundle.patch 存在即为 bundle 层)。
175
+ */
176
+ export function bundleDeclared(profileDir, packageName) {
177
+ const pkg = readPackage(join(profileDir, 'node_modules', ...packageName.split('/')))
178
+ return pkg?.dsh?.bundle?.patch !== undefined
179
+ }
180
+
181
+ /**
182
+ * 按官方 `dsh plugin` 的对账规则同步 dsh.profile.bundles:
183
+ * 依赖解析到声明 dsh.bundle 的包 → 加入层列表(按依赖顺序追加);
184
+ * 依赖不再声明 → 移出层列表。模板 bundle(非依赖)永不触碰。
185
+ */
186
+ export function reconcileBundles(manifest, packageName, declaresBundle) {
187
+ const isDependency = packageName in (manifest?.dependencies ?? {})
188
+ const bundles = [...((manifest?.dsh?.profile?.bundles) ?? [])]
189
+ if (!isDependency) return { changed: false, bundles }
190
+ const listed = bundles.includes(packageName)
191
+ if (declaresBundle && !listed) {
192
+ bundles.push(packageName)
193
+ return { changed: true, bundles }
194
+ }
195
+ if (!declaresBundle && listed) {
196
+ bundles.splice(bundles.indexOf(packageName), 1)
197
+ return { changed: true, bundles }
198
+ }
199
+ return { changed: false, bundles }
200
+ }
201
+
202
+ function writeProfileManifestAtomic(profileDir, manifest) {
203
+ const file = join(profileDir, 'package.json')
204
+ const temporary = `${file}.${process.pid}.tmp`
205
+ writeFileSync(temporary, JSON.stringify(manifest, null, 2) + '\n', 'utf8')
206
+ renameSync(temporary, file)
207
+ }
208
+
209
+ /** 卸载路径的 bundles 对账:在层列表中直接移除(调用方已校验它曾是 profile 依赖)。 */
210
+ export function removeFromBundles(manifest, packageName) {
211
+ const bundles = [...((manifest?.dsh?.profile?.bundles) ?? [])]
212
+ const index = bundles.indexOf(packageName)
213
+ if (index < 0) return { changed: false, bundles }
214
+ bundles.splice(index, 1)
215
+ return { changed: true, bundles }
216
+ }
217
+
218
+ /** 从 pnpm 输出中提取被跳过构建脚本的包名(去掉版本后缀与列表符号)。 */
219
+ export function parseIgnoredBuilds(text) {
220
+ const lines = String(text ?? '').split(/\r?\n/)
221
+ const index = lines.findIndex((line) => line.includes('Ignored build scripts'))
222
+ if (index < 0) return []
223
+ const names = []
224
+ for (const line of lines.slice(index + 1)) {
225
+ if (!line.trim()) break
226
+ const match = /^\s*[-*]\s*(.+?)\s*$/.exec(line)
227
+ if (!match) break
228
+ names.push(match[1].replace(/@[^@\s]+$/, '').replace(/[,;]+$/, ''))
229
+ }
230
+ return [...new Set(names)]
231
+ }
232
+
233
+ /** 识别 pnpm≥10 显式拒绝执行构建脚本的报错,返回包名(放行 allowBuilds 后重试可解决)。 */
234
+ export function parseBuildsRefused(text) {
235
+ const match = /"([^"]+)@[^"]*"\s+(?:needs to execute|wants to run) build scripts/.exec(String(text ?? ''))
236
+ return match ? match[1] : null
237
+ }
238
+
239
+ /**
240
+ * 安装后的防假成功校验:包可解析、且(声明 dsh 清单 或 main 入口文件存在)。
241
+ * prepare 构建被 pnpm 跳过时产物缺失,会被这里拦下并触发回滚。
242
+ */
243
+ export function installedPackageValid(profileDir, packageName) {
244
+ const dir = join(profileDir, 'node_modules', ...packageName.split('/'))
245
+ const pkg = readPackage(dir)
246
+ if (!pkg?.name) return false
247
+ if (pkg.dsh !== undefined) return true
248
+ if (typeof pkg.main === 'string') return existsSync(resolve(dir, pkg.main))
249
+ return true
250
+ }
251
+
252
+ function buildScriptHint(result) {
253
+ const refused = parseBuildsRefused(`${result.out}\n${result.err}`)
254
+ if (refused) {
255
+ return `\n提示:pnpm 拒绝执行 ${refused} 的构建脚本。在 profile 的 pnpm-workspace.yaml 中按 pnpm 输出的确切键名放行(allowBuilds/onlyBuiltDependencies)后重试。`
256
+ }
257
+ const ignored = parseIgnoredBuilds(`${result.out}\n${result.err}`)
258
+ if (ignored.length) {
259
+ return `\n提示:以下包的构建脚本被 pnpm 跳过,缺少构建产物时插件可能无法加载:${ignored.join(', ')}。在 profile 的 pnpm-workspace.yaml 放行后重新安装。`
260
+ }
261
+ return ''
262
+ }
263
+
264
+ function applyBundleReconciliations(profileDir, names) {
265
+ const manifest = readPackage(profileDir)
266
+ if (!manifest) return []
267
+ const changed = []
268
+ let bundles = manifest.dsh?.profile?.bundles ? [...manifest.dsh.profile.bundles] : []
269
+ for (const name of names) {
270
+ if (bundleDeclared(profileDir, name)) {
271
+ if (!bundles.includes(name)) {
272
+ bundles.push(name)
273
+ changed.push(name)
274
+ }
275
+ }
276
+ }
277
+ if (changed.length) {
278
+ manifest.dsh = { ...manifest.dsh, profile: { ...manifest.dsh?.profile, bundles } }
279
+ writeProfileManifestAtomic(profileDir, manifest)
280
+ }
281
+ return changed
282
+ }
283
+
284
+ async function runInstallJob(spec, state, runner) {
285
+ const networkEnv = spec.proxy
286
+ ? { HTTP_PROXY: spec.proxy, HTTPS_PROXY: spec.proxy, http_proxy: spec.proxy, https_proxy: spec.proxy }
287
+ : undefined
288
+ const before = readPackage(spec.profileDir)
289
+ const beforeDeps = new Set(Object.keys(before?.dependencies ?? {}))
290
+ updateState(spec, state, { stage: 'install', message: `正在安装 ${spec.target || spec.installSpec}` })
291
+ const result = runner('pnpm', ['add', spec.installSpec], spec.profileDir, 600_000, networkEnv)
292
+ if (!result.ok) {
293
+ throw new JobError('install', `pnpm add 失败\n${commandFailure(result)}${buildScriptHint(result)}`)
294
+ }
295
+ const hint = buildScriptHint(result)
296
+ if (hint) state.steps.push(`pnpm 提示(构建脚本):${hint.trim()}`)
297
+ const after = readPackage(spec.profileDir)
298
+ const added = Object.keys(after?.dependencies ?? {}).filter((name) => !beforeDeps.has(name))
299
+ if (!added.length) {
300
+ throw new JobError('verify', 'pnpm add 成功但依赖没有任何变化(可能已安装,或版本被 minimumReleaseAge 限制)。')
301
+ }
302
+ state.steps.push(`已安装依赖:${added.join(', ')}`)
303
+
304
+ const broken = added.filter((name) => !installedPackageValid(spec.profileDir, name))
305
+ if (broken.length) {
306
+ updateState(spec, state, { stage: 'rollback', message: '安装校验失败,正在回滚' })
307
+ for (const name of broken) {
308
+ runner('pnpm', ['remove', name], spec.profileDir, 300_000, networkEnv)
309
+ }
310
+ throw new JobError('verify', `以下包安装后缺少可运行入口(可能是构建脚本被跳过),已自动回滚:${broken.join(', ')}${hint || buildScriptHint(result)}`)
311
+ }
312
+
313
+ updateState(spec, state, { stage: 'reconcile', message: '正在同步 profile bundles' })
314
+ const activated = applyBundleReconciliations(spec.profileDir, added)
315
+ if (activated.length) state.steps.push(`已加入 bundles 层:${activated.join(', ')}`)
316
+ else state.steps.push('新包未声明 dsh.bundle,按普通依赖安装')
317
+
318
+ updateState(spec, state, {
319
+ status: 'completed',
320
+ stage: 'completed',
321
+ message: `${spec.target || added.join(', ')} 安装完成,重启 dsh web 后生效`,
322
+ installed: added,
323
+ restartRequired: true,
324
+ finishedAt: new Date().toISOString(),
325
+ })
326
+ }
327
+
328
+ async function runRemoveJob(spec, state, runner) {
329
+ const before = readPackage(spec.profileDir)
330
+ if (!(spec.packageName in (before?.dependencies ?? {}))) {
331
+ throw new JobError('preflight', `${spec.packageName} 不在 profile 依赖中,无法卸载。`)
332
+ }
333
+ updateState(spec, state, { stage: 'remove', message: `正在卸载 ${spec.packageName}` })
334
+ const result = runner('pnpm', ['remove', spec.packageName], spec.profileDir, 300_000)
335
+ if (!result.ok) {
336
+ throw new JobError('remove', `pnpm remove 失败\n${commandFailure(result)}`)
337
+ }
338
+ state.steps.push('pnpm remove 完成')
339
+ const manifest = readPackage(spec.profileDir)
340
+ const reconciliation = removeFromBundles(manifest, spec.packageName)
341
+ if (reconciliation.changed) {
342
+ manifest.dsh = {
343
+ ...manifest.dsh,
344
+ profile: { ...manifest.dsh?.profile, bundles: reconciliation.bundles },
345
+ }
346
+ writeProfileManifestAtomic(spec.profileDir, manifest)
347
+ state.steps.push(`已从 bundles 层移除:${spec.packageName}`)
348
+ }
349
+ updateState(spec, state, {
350
+ status: 'completed',
351
+ stage: 'completed',
352
+ message: `${spec.packageName} 卸载完成,重启 dsh web 后生效`,
353
+ restartRequired: true,
354
+ finishedAt: new Date().toISOString(),
355
+ })
356
+ }
357
+
358
+ function requireCommand(result, stage, label) {
359
+ if (!result.ok) throw new JobError(stage, `${label}失败\n${commandFailure(result)}`)
360
+ return result.out.trim()
361
+ }
362
+
363
+ function packageManagerFor(dir, packageJson) {
364
+ if (typeof packageJson?.packageManager === 'string' && packageJson.packageManager.startsWith('pnpm@')) return 'pnpm'
365
+ if (existsSync(join(dir, 'pnpm-lock.yaml'))) return 'pnpm'
366
+ return 'npm'
367
+ }
368
+
369
+ async function performLinkUpdate(spec, runner, reporter) {
370
+ const dir = spec.dir
371
+ const networkEnv = spec.proxy
372
+ ? { HTTP_PROXY: spec.proxy, HTTPS_PROXY: spec.proxy, http_proxy: spec.proxy, https_proxy: spec.proxy }
373
+ : undefined
374
+ const status = requireCommand(runner('git', ['status', '--porcelain'], dir, 10_000), 'preflight', '读取 Git 状态')
375
+ if (status) throw new JobError('preflight', `检测到未提交改动,为避免覆盖本地工作,已停止更新:\n${status}`)
376
+ const before = requireCommand(runner('git', ['rev-parse', 'HEAD'], dir, 10_000), 'preflight', '读取当前提交')
377
+ reporter.progress({ stage: 'fetch', beforeCommit: before, message: `正在获取 ${spec.packageName} 的远端更新` })
378
+ requireCommand(runner('git', ['fetch', '--all', '--prune'], dir, 120_000, networkEnv), 'fetch', 'git fetch ')
379
+
380
+ const branch = requireCommand(runner('git', ['rev-parse', '--abbrev-ref', 'HEAD'], dir, 10_000), 'preflight', '读取当前分支')
381
+ if (branch === 'HEAD') {
382
+ const defaultRef = requireCommand(
383
+ runner('git', ['symbolic-ref', '--quiet', '--short', 'refs/remotes/origin/HEAD'], dir, 10_000),
384
+ 'preflight',
385
+ '确认远端默认分支',
386
+ )
387
+ const shortName = defaultRef.includes('/') ? defaultRef.slice(defaultRef.indexOf('/') + 1) : defaultRef
388
+ requireCommand(runner('git', ['checkout', '-B', shortName, defaultRef], dir, 60_000), 'checkout', '切换默认分支')
389
+ }
390
+
391
+ reporter.progress({ stage: 'pull', message: `正在拉取 ${spec.packageName}` })
392
+ requireCommand(runner('git', ['pull', '--ff-only'], dir, 300_000, networkEnv), 'pull', 'git pull ')
393
+ const after = requireCommand(runner('git', ['rev-parse', 'HEAD'], dir, 10_000), 'verify', '读取更新后提交')
394
+ const changed = before !== after
395
+ const changedOutput = changed
396
+ ? requireCommand(runner('git', ['diff', '--name-only', `${before}..${after}`], dir, 10_000), 'verify', '读取变更文件')
397
+ : ''
398
+ const changedPaths = changedOutput.split('\n').map((file) => file.trim()).filter(Boolean)
399
+ const artifacts = analyzeGitArtifacts(changedPaths)
400
+ reporter.step(changed ? `Git 已更新:${before.slice(0, 10)} → ${after.slice(0, 10)}` : 'Git 已是最新')
401
+
402
+ if (artifacts.buildRequired) {
403
+ const packageJson = readPackage(dir)
404
+ if (typeof packageJson?.scripts?.build !== 'string') {
405
+ throw new JobError('build', '源码已更新,但上游没有提交 lib/dist 产物,也没有提供 build 脚本。')
406
+ }
407
+ const manager = packageManagerFor(dir, packageJson)
408
+ reporter.progress({ stage: 'build', message: `正在重新构建 ${spec.packageName}` })
409
+ const buildEnv = { ...networkEnv, DSH_CHECKOUT: spec.repoDir ?? process.env.DSH_CHECKOUT ?? '' }
410
+ requireCommand(runner(manager, ['run', 'build'], dir, 900_000, buildEnv), 'build', `${manager} run build `)
411
+ reporter.step(`${manager} run build 完成`)
412
+ }
413
+
414
+ if (spec.kind === 'preset') {
415
+ const presetSource = join(dir, 'preset')
416
+ if (existsSync(presetSource)) {
417
+ for (const file of ['agent.cordis.yml', 'preset.yml', 'router-bootstrap.mjs', 'router-core.mjs']) {
418
+ const source = join(presetSource, file)
419
+ if (existsSync(source)) copyFileSync(source, join(dir, file))
420
+ }
421
+ reporter.step('preset 运行副本已同步')
422
+ }
423
+ }
424
+
425
+ const packageJson = readPackage(dir)
426
+ if (typeof packageJson?.main === 'string' && !existsSync(join(dir, packageJson.main))) {
427
+ throw new JobError('verify', `运行入口不存在:${packageJson.main}`)
428
+ }
429
+ reporter.finish({
430
+ status: 'completed',
431
+ stage: 'completed',
432
+ message: changed ? `${spec.packageName} 更新完成` : `${spec.packageName} 已是最新`,
433
+ afterCommit: after,
434
+ changed,
435
+ changedPaths,
436
+ restartRequired: changed,
437
+ finishedAt: new Date().toISOString(),
438
+ })
439
+ }
440
+
441
+ async function runLinkJob(spec, state, runner) {
442
+ await performLinkUpdate(spec, runner, singleReporter(spec, state))
443
+ }
444
+
445
+ /** 批量更新:串行执行子任务,单个失败不中断其余,最后汇总成败。 */
446
+ async function runBatchJob(spec, state, runner) {
447
+ const jobs = Array.isArray(spec.jobs) ? spec.jobs : []
448
+ if (!jobs.length) throw new JobError('preflight', '批量任务为空')
449
+ const results = []
450
+ let succeeded = 0
451
+ let restartRequired = false
452
+ for (let i = 0; i < jobs.length; i += 1) {
453
+ const sub = jobs[i]
454
+ const label = String(sub.target ?? sub.packageName ?? `#${i + 1}`)
455
+ const reporter = {
456
+ step: (text) => { state.steps.push(`[${label}] ${text}`) },
457
+ progress: (patch) => updateState(spec, state, {
458
+ stage: 'batch',
459
+ message: `(${i + 1}/${jobs.length}) ${String(patch.message ?? label)}`,
460
+ }),
461
+ finish: (patch) => {
462
+ if (patch.restartRequired) restartRequired = true
463
+ state.steps.push(`[${label}] ${String(patch.message ?? '完成')}`)
464
+ },
465
+ }
466
+ try {
467
+ const subSpec = { ...spec, ...sub }
468
+ if (sub.action === 'npm') await performNpmUpdate(subSpec, runner, reporter)
469
+ else if (sub.action === 'link') await performLinkUpdate(subSpec, runner, reporter)
470
+ else throw new JobError('batch', `unsupported sub action: ${String(sub.action)}`)
471
+ succeeded += 1
472
+ results.push({ target: label, ok: true })
473
+ } catch (error) {
474
+ const message = error instanceof Error ? error.message : String(error)
475
+ results.push({ target: label, ok: false, error: message })
476
+ state.steps.push(`[${label}] 失败:${message}`)
477
+ }
478
+ }
479
+ const failed = results.filter((entry) => !entry.ok)
480
+ updateState(spec, state, {
481
+ status: succeeded === 0 ? 'failed' : 'completed',
482
+ stage: 'batch-done',
483
+ message: failed.length
484
+ ? `批量更新:${succeeded} 成功 / ${failed.length} 失败`
485
+ : `批量更新完成:${succeeded} 项`,
486
+ error: failed.length ? failed.map((entry) => `${entry.target}: ${entry.error}`).join('\n') : undefined,
487
+ results,
488
+ restartRequired: restartRequired && succeeded > 0,
489
+ finishedAt: new Date().toISOString(),
490
+ })
491
+ }
492
+
493
+ async function runDshJob(spec, state, runner) {
494
+ const dir = spec.repoDir
495
+ const networkEnv = spec.proxy
496
+ ? { HTTP_PROXY: spec.proxy, HTTPS_PROXY: spec.proxy, http_proxy: spec.proxy, https_proxy: spec.proxy }
497
+ : undefined
498
+ const status = requireCommand(runner('git', ['status', '--porcelain'], dir, 10_000), 'preflight', '读取 dsh Git 状态')
499
+ if (status) throw new JobError('preflight', `dsh 存在未提交改动,为避免覆盖本地工作,已停止更新:\n${status}`)
500
+ const currentBranch = requireCommand(runner('git', ['rev-parse', '--abbrev-ref', 'HEAD'], dir, 10_000), 'preflight', '读取 dsh 分支')
501
+ if (currentBranch !== spec.branch) {
502
+ throw new JobError('preflight', `当前分支为 ${currentBranch},配置更新分支为 ${spec.branch}`)
503
+ }
504
+ const before = requireCommand(runner('git', ['rev-parse', 'HEAD'], dir, 10_000), 'preflight', '读取 dsh 当前提交')
505
+ const beforeVersion = readPackage(dir)?.version ?? null
506
+ updateState(spec, state, {
507
+ stage: 'pull',
508
+ beforeCommit: before,
509
+ beforeVersion,
510
+ message: '正在拉取 dsh 更新',
511
+ })
512
+ requireCommand(
513
+ runner('git', ['pull', '--ff-only', spec.remote, spec.branch], dir, 300_000, networkEnv),
514
+ 'pull',
515
+ 'git pull ',
516
+ )
517
+ const after = requireCommand(runner('git', ['rev-parse', 'HEAD'], dir, 10_000), 'verify', '读取 dsh 更新后提交')
518
+ const changed = before !== after
519
+ state.steps.push(changed ? `Git 已更新:${before.slice(0, 10)} → ${after.slice(0, 10)}` : 'Git 已是最新')
520
+
521
+ if (spec.full) {
522
+ updateState(spec, state, { stage: 'install', message: '正在安装 dsh 依赖' })
523
+ requireCommand(runner('pnpm', ['install'], dir, 600_000, networkEnv), 'install', 'pnpm install ')
524
+ state.steps.push('pnpm install 完成')
525
+ updateState(spec, state, { stage: 'build', message: '正在重新构建 dsh' })
526
+ requireCommand(runner('pnpm', ['run', 'build'], dir, 900_000, networkEnv), 'build', 'pnpm run build ')
527
+ state.steps.push('pnpm run build 完成')
528
+ }
529
+
530
+ const afterVersion = readPackage(dir)?.version ?? null
531
+ updateState(spec, state, {
532
+ status: 'completed',
533
+ stage: 'completed',
534
+ message: changed || spec.full ? 'dsh 更新完成' : 'dsh 已是最新',
535
+ afterCommit: after,
536
+ afterVersion,
537
+ changed,
538
+ restartRequired: changed || spec.full,
539
+ finishedAt: new Date().toISOString(),
540
+ })
541
+ }
542
+
543
+ export async function runWorker(specPath, dependencies = {}) {
544
+ const spec = JSON.parse(readFileSync(specPath, 'utf8'))
545
+ const runner = dependencies.runCommand ?? runCommand
546
+ const startedAt = new Date().toISOString()
547
+ const base = {
548
+ id: spec.id,
549
+ action: spec.action,
550
+ target: spec.target ?? '',
551
+ status: 'running',
552
+ stage: 'starting',
553
+ steps: [],
554
+ startedAt,
555
+ restartRequired: false,
556
+ workerPid: process.pid,
557
+ }
558
+ writeJobState(spec.statePath, spec.latestPath, base)
559
+ try {
560
+ if (spec.action === 'npm') {
561
+ await runNpmJob(spec, base, runner)
562
+ return
563
+ }
564
+ if (spec.action === 'link') {
565
+ await runLinkJob(spec, base, runner)
566
+ return
567
+ }
568
+ if (spec.action === 'dsh') {
569
+ await runDshJob(spec, base, runner)
570
+ return
571
+ }
572
+ if (spec.action === 'batch') {
573
+ await runBatchJob(spec, base, runner)
574
+ return
575
+ }
576
+ if (spec.action === 'install') {
577
+ await runInstallJob(spec, base, runner)
578
+ return
579
+ }
580
+ if (spec.action === 'remove') {
581
+ await runRemoveJob(spec, base, runner)
582
+ return
583
+ }
584
+ throw new JobError('failed', `unsupported update action: ${String(spec.action)}`)
585
+ } catch (error) {
586
+ writeJobState(spec.statePath, spec.latestPath, {
587
+ ...base,
588
+ status: 'failed',
589
+ stage: error instanceof JobError ? error.stage : 'failed',
590
+ error: error instanceof Error ? error.message : String(error),
591
+ finishedAt: new Date().toISOString(),
592
+ })
593
+ }
594
+ }
595
+
596
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
597
+ await runWorker(process.argv[2])
598
+ }