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.
@@ -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
- 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
- }
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
+ }