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.
- package/LICENSE +29 -0
- package/README.md +93 -0
- package/cordis.patch.yml +4 -0
- package/data/extra-plugins.json +112 -0
- package/data/registry-snapshot.json +7636 -0
- package/lib/client.js +606 -0
- package/lib/client.js.map +1 -0
- package/lib/github-topic.mjs +80 -0
- package/lib/index.js +1048 -0
- package/lib/index.js.map +1 -0
- package/lib/npm-mapping.mjs +43 -0
- package/lib/run-command.mjs +58 -0
- package/lib/types/client/index.d.ts +12 -0
- package/lib/types/index.d.ts +43 -0
- package/package.json +76 -0
- package/plugins.json +7636 -0
- package/scripts/build.mjs +101 -0
- package/scripts/build.sh +4 -0
- package/scripts/refresh-snapshot.mjs +93 -0
- package/scripts/update-worker.mjs +598 -0
|
@@ -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
|
+
}
|