@noob-stupid/dsh-plugin-console 0.5.77 → 0.5.87
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/lib/client.js +490 -20
- package/lib/server/domain/dev-roots.js +12 -0
- package/lib/server/domain/disk-audit.js +330 -0
- package/lib/server/domain/enrich-throttle.js +242 -0
- package/lib/server/domain/install-box.js +77 -0
- package/lib/server/domain/install-channels.js +242 -0
- package/lib/server/domain/install-diagnose.js +9 -4
- package/lib/server/domain/install-finalize.js +3 -1
- package/lib/server/domain/install-job.js +49 -175
- package/lib/server/domain/install-jobs-store.js +72 -0
- package/lib/server/domain/install-version.js +65 -0
- package/lib/server/domain/install.js +18 -71
- package/lib/server/domain/link-inventory.js +41 -4
- package/lib/server/domain/link-self-sufficiency.js +67 -0
- package/lib/server/domain/lock-autofix.js +310 -0
- package/lib/server/domain/manifest.js +17 -3
- package/lib/server/domain/market.js +13 -7
- package/lib/server/domain/persist.js +128 -3
- package/lib/server/domain/restart-launcher.js +110 -0
- package/lib/server/domain/restart.js +84 -10
- package/lib/server/domain/selfupdate.js +6 -1
- package/lib/server/domain/skills.js +9 -5
- package/lib/server/domain/unpin-dev-dir.js +149 -0
- package/lib/server/infra/egress.js +34 -1
- package/lib/server/routes/disk.js +59 -0
- package/lib/server/routes/framework.js +63 -2
- package/lib/server/routes/index.js +19 -3
- package/lib/server/routes/install.js +6 -0
- package/lib/server/routes/link-actions.js +120 -0
- package/lib/server/routes/state.js +23 -1
- package/package.json +1 -1
|
@@ -42,6 +42,59 @@ import { dshHome, packageNameOf } from '../infra/paths.js'
|
|
|
42
42
|
/** 三处缺口的**稳定名字**(面板文案与测试都按它们断言,不许改写)。 */
|
|
43
43
|
const PERSIST_PARTS = ['dependency', 'lock', 'mount']
|
|
44
44
|
|
|
45
|
+
// ── 0.5.84 判据降噪:把「不一致」分成两类(**只分类,不改判据强度**)─────────────────
|
|
46
|
+
// 用户反馈:面板上到处是红字「未持久化」,但绝大多数并不影响使用 —— 看一眼就想关掉,
|
|
47
|
+
// 真正要紧的那条也跟着被无视了。所以这里给每个缺口定一个**机器可读的原因码**
|
|
48
|
+
// (`parts.*.code`),再按「这个不一致会不会让插件在重启后消失」分成两档:
|
|
49
|
+
//
|
|
50
|
+
// critical(会丢插件,必须红)—— 只有这两种:
|
|
51
|
+
// ① 清单里**没有**这个条目(或清单写的版本在磁盘上根本不存在):
|
|
52
|
+
// pnpm 下一次任何操作都会把这个"多余包"清掉 → 插件真的没了。
|
|
53
|
+
// ② 挂载行**不满足**:包在盘上,但重启后没有任何行会加载它 → 用户视角"插件消失了"。
|
|
54
|
+
// 注意这一条判的是"重启后会不会被挂上",与"包是不是已经下载"无关。
|
|
55
|
+
//
|
|
56
|
+
// warn(只是口径不一致,重启后照常加载,降级成可折叠提示)—— 其余全部:
|
|
57
|
+
// · lock 条目缺失 / 形态不符 / 指向的目标不同:**清单才是事实来源**,pnpm 会按清单重算 lock,
|
|
58
|
+
// 插件本身不会被清掉(最坏情况是 lock 被重写成同样的结论)。
|
|
59
|
+
// · 清单声明 link: 而磁盘是真实目录、或精确版本与磁盘版本不一致:包就在那里,
|
|
60
|
+
// pnpm 会把它改写成清单说的形态/版本 —— 换的是那一份副本,不是"没有"。
|
|
61
|
+
//
|
|
62
|
+
// 为什么要在**服务端**分(而不是让面板各自判):判据分两处必然分叉(本仓已多次踩过),
|
|
63
|
+
// 而"会不会丢"这件事需要同时看清单、lock 与挂载行的真实值 —— 只有这里同时握着这三份事实。
|
|
64
|
+
/** 会丢插件(必须红)的原因码。表里没有的码一律按 warn 处理(保守方向:宁可少报红)。 */
|
|
65
|
+
const PERSIST_CRITICAL_CODES = new Set([
|
|
66
|
+
'dep-missing-entry', // 清单 dependencies 里没有它 → pnpm 会当多余包清掉
|
|
67
|
+
'dep-link-broken', // 清单钉的 link 目标不存在 → 这份链接是断的,pnpm 只会把它当坏的
|
|
68
|
+
'dep-version-missing', // 清单写了精确版本,但盘上根本没有这个包
|
|
69
|
+
'dep-range-missing', // 清单写了范围/dist-tag,但盘上没有这个包
|
|
70
|
+
])
|
|
71
|
+
/** 只是为了让人看得懂原因码说的是什么(面板不读它,测试与日志用它)。 */
|
|
72
|
+
const PERSIST_CODE_TEXT = {
|
|
73
|
+
'dep-missing-entry': '清单 dependencies 里没有条目',
|
|
74
|
+
'dep-link-broken': '清单钉的 link 目标不成立',
|
|
75
|
+
'dep-version-missing': '盘上没有这个版本',
|
|
76
|
+
'dep-range-missing': '盘上没有这个包',
|
|
77
|
+
'dep-link-not-a-link': '清单是 link: 但盘上是真实目录',
|
|
78
|
+
'dep-version-drift': '清单版本与磁盘版本不一致',
|
|
79
|
+
'lock-missing-entry': 'lock 里没有条目',
|
|
80
|
+
'lock-link-target-drift': 'lock 的 link 目标与清单不同',
|
|
81
|
+
'lock-form-drift': 'lock 的形态与清单不同',
|
|
82
|
+
'lock-version-drift': 'lock 的版本与清单不同',
|
|
83
|
+
'lock-unverified': 'lock 写入后未被只读校验确认',
|
|
84
|
+
'mount-bundle-no-patch': '在 bundles 里但没声明 bundle 补丁',
|
|
85
|
+
'mount-bundle-patch-missing': '声明的 bundle 补丁文件不存在',
|
|
86
|
+
'mount-bundle-patch-empty': 'bundle 补丁里没有任何 insert 行',
|
|
87
|
+
'mount-no-row': '补丁/bundles/运行时都没有它的行',
|
|
88
|
+
}
|
|
89
|
+
/** `mount-*` 一律 critical(挂不上就是"重启后看不见");这里显式列出,避免靠前缀猜。 */
|
|
90
|
+
const PERSIST_CRITICAL_MOUNT = new Set(['mount-bundle-no-patch', 'mount-bundle-patch-missing', 'mount-bundle-patch-empty', 'mount-no-row'])
|
|
91
|
+
|
|
92
|
+
/** 某个原因码算不算"会丢插件"。 */
|
|
93
|
+
function isPersistCriticalCode(code) {
|
|
94
|
+
return PERSIST_CRITICAL_CODES.has(code) || PERSIST_CRITICAL_MOUNT.has(code)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
|
|
45
98
|
/** 精确版本号形态(`1.2.3` / `0.0.1-rc1`):只有它才要求与磁盘版本逐字相等。 */
|
|
46
99
|
const EXACT_VERSION_RE = /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/u
|
|
47
100
|
/** 来源型 spec 前缀(协议)——这些形态**绝不**改写成版本号(改写等于丢来源)。 */
|
|
@@ -86,8 +139,9 @@ function diskFormOf(profileDir, packageName) {
|
|
|
86
139
|
function dependencyPart({ profileDir, packageName }) {
|
|
87
140
|
const spec = profileSpec(profileDir, packageName)
|
|
88
141
|
const disk = diskFormOf(profileDir, packageName)
|
|
89
|
-
const out = { ok: false, spec, form: null, disk: { form: disk.form, version: disk.version }, reason: null }
|
|
142
|
+
const out = { ok: false, spec, form: null, disk: { form: disk.form, version: disk.version }, reason: null, code: null }
|
|
90
143
|
if (spec === null) {
|
|
144
|
+
out.code = 'dep-missing-entry'
|
|
91
145
|
out.reason = 'package.json 的 dependencies 里没有这个条目(pnpm 会把它当多余包清掉)'
|
|
92
146
|
return out
|
|
93
147
|
}
|
|
@@ -95,10 +149,12 @@ function dependencyPart({ profileDir, packageName }) {
|
|
|
95
149
|
if (trimmed.startsWith('link:')) {
|
|
96
150
|
out.form = 'link'
|
|
97
151
|
if (disk.form !== 'link') {
|
|
152
|
+
out.code = 'dep-link-not-a-link'
|
|
98
153
|
out.reason = `清单是 ${trimmed},但 node_modules/${packageName} 不是指向它的链接(磁盘上实际是 ${disk.form === 'missing' ? '不存在' : '真实目录'})`
|
|
99
154
|
return out
|
|
100
155
|
}
|
|
101
156
|
if (!linkSpecIsIntact(profileDir, packageName, trimmed)) {
|
|
157
|
+
out.code = 'dep-link-broken'
|
|
102
158
|
out.reason = `清单是 ${trimmed},但链接当前不成立(目标不存在或指向别处)`
|
|
103
159
|
return out
|
|
104
160
|
}
|
|
@@ -114,10 +170,12 @@ function dependencyPart({ profileDir, packageName }) {
|
|
|
114
170
|
if (EXACT_VERSION_RE.test(trimmed)) {
|
|
115
171
|
out.form = 'version'
|
|
116
172
|
if (disk.version === null) {
|
|
173
|
+
out.code = 'dep-version-missing'
|
|
117
174
|
out.reason = '清单写的是版本号,但 node_modules 里读不到该包的版本(没装或被还原)'
|
|
118
175
|
return out
|
|
119
176
|
}
|
|
120
177
|
if (disk.version !== trimmed) {
|
|
178
|
+
out.code = 'dep-version-drift'
|
|
121
179
|
out.reason = `清单写 ${trimmed},磁盘上是 ${disk.version} —— 两处对不上`
|
|
122
180
|
return out
|
|
123
181
|
}
|
|
@@ -127,6 +185,7 @@ function dependencyPart({ profileDir, packageName }) {
|
|
|
127
185
|
// 版本范围(^1.2.3)或 dist-tag(latest/next):registry 来源但非精确值 —— 有条目且装上了即算就位
|
|
128
186
|
out.form = 'range'
|
|
129
187
|
if (disk.version === null) {
|
|
188
|
+
out.code = 'dep-range-missing'
|
|
130
189
|
out.reason = `清单写 ${trimmed},但 node_modules 里读不到该包(没装或被还原)`
|
|
131
190
|
return out
|
|
132
191
|
}
|
|
@@ -137,8 +196,9 @@ function dependencyPart({ profileDir, packageName }) {
|
|
|
137
196
|
/** ② lock 条目:与 ① 的 spec 同一形态(link: ↔ link:;精确版本 ↔ 逐字相等)。 */
|
|
138
197
|
function lockPart({ profileDir, packageName, spec }) {
|
|
139
198
|
const entry = lockVersion(profileDir, packageName)
|
|
140
|
-
const out = { ok: false, entry: entry ?? null, reason: null }
|
|
199
|
+
const out = { ok: false, entry: entry ?? null, reason: null, code: null }
|
|
141
200
|
if (entry === null || entry === undefined) {
|
|
201
|
+
out.code = 'lock-missing-entry'
|
|
142
202
|
out.reason = 'pnpm-lock.yaml 里没有这个包的条目(任何一次 pnpm 操作都可能把它还原/清掉)'
|
|
143
203
|
return out
|
|
144
204
|
}
|
|
@@ -151,6 +211,7 @@ function lockPart({ profileDir, packageName, spec }) {
|
|
|
151
211
|
out.ok = true
|
|
152
212
|
return out
|
|
153
213
|
}
|
|
214
|
+
out.code = value.startsWith('link:') ? 'lock-link-target-drift' : 'lock-form-drift'
|
|
154
215
|
out.reason = value.startsWith('link:')
|
|
155
216
|
? `清单把该包钉在 ${s},而 lock 里是 ${value}(**指向的目标不同**,对账后会被 pnpm 还原)`
|
|
156
217
|
: `清单把该包钉在 ${s},而 lock 里是 ${value}(对账后会被 pnpm 还原)`
|
|
@@ -165,6 +226,7 @@ function lockPart({ profileDir, packageName, spec }) {
|
|
|
165
226
|
out.ok = true
|
|
166
227
|
return out
|
|
167
228
|
}
|
|
229
|
+
out.code = 'lock-version-drift'
|
|
168
230
|
out.reason = `清单写 ${s},lock 里是 ${value} —— 两处对不上`
|
|
169
231
|
return out
|
|
170
232
|
}
|
|
@@ -175,7 +237,7 @@ function lockPart({ profileDir, packageName, spec }) {
|
|
|
175
237
|
|
|
176
238
|
/** ③ 挂载行:用户补丁 insert 行 / bundle 层 / 运行时已有行,三者任一成立即算会挂载。 */
|
|
177
239
|
function mountPart({ profileDir, patchPath, packageName, patchText = null, bundles = null, entries = null }) {
|
|
178
|
-
const out = { ok: false, via: 'none', rowId: null, bundlePatch: null, reason: null }
|
|
240
|
+
const out = { ok: false, via: 'none', rowId: null, bundlePatch: null, reason: null, code: null }
|
|
179
241
|
// 运行时已有行提供它(例如聚合包里的行):最直接的证据,先看
|
|
180
242
|
if (Array.isArray(entries)) {
|
|
181
243
|
for (const entry of entries) {
|
|
@@ -208,12 +270,14 @@ function mountPart({ profileDir, patchPath, packageName, patchText = null, bundl
|
|
|
208
270
|
rel = null
|
|
209
271
|
}
|
|
210
272
|
if (typeof rel !== 'string' || rel === '') {
|
|
273
|
+
out.code = 'mount-bundle-no-patch'
|
|
211
274
|
out.reason = `已在 dsh.profile.bundles 里,但该包没有声明 dsh.bundle.patch —— bundle 层不会装入任何行,需要一条 insert 行`
|
|
212
275
|
return out
|
|
213
276
|
}
|
|
214
277
|
const file = join(String(profileDir), 'node_modules', ...String(packageName).split('/'), rel)
|
|
215
278
|
if (!existsSync(file)) {
|
|
216
279
|
out.bundlePatch = file
|
|
280
|
+
out.code = 'mount-bundle-patch-missing'
|
|
217
281
|
out.reason = `dsh.profile.bundles 里有它,但它声明的 bundle 补丁文件不存在(${file})`
|
|
218
282
|
return out
|
|
219
283
|
}
|
|
@@ -225,15 +289,64 @@ function mountPart({ profileDir, patchPath, packageName, patchText = null, bundl
|
|
|
225
289
|
}
|
|
226
290
|
if (rows.size === 0) {
|
|
227
291
|
out.bundlePatch = file
|
|
292
|
+
out.code = 'mount-bundle-patch-empty'
|
|
228
293
|
out.reason = `bundle 补丁里读不到任何 insert 行(${rel})—— 没有任何东西会被挂载`
|
|
229
294
|
return out
|
|
230
295
|
}
|
|
231
296
|
return { ...out, ok: true, via: 'bundle', rowId: [...rows.keys()][0], bundlePatch: file }
|
|
232
297
|
}
|
|
298
|
+
out.code = 'mount-no-row'
|
|
233
299
|
out.reason = '用户补丁里没有它的 insert 行,dsh.profile.bundles 里也没有它 —— 重启后不会被挂载'
|
|
234
300
|
return out
|
|
235
301
|
}
|
|
236
302
|
|
|
303
|
+
/**
|
|
304
|
+
* 0.5.84 判据降噪的**唯一判据**:这次不一致会不会让插件在重启后消失?
|
|
305
|
+
* 输入可以是一份 `persistReport()` 的结果(只看 `parts` / `missing`),也可以是裸的 `parts`。
|
|
306
|
+
* 返回 `{ level, losesAfterRestart, critical, downgraded, codes, note }`:
|
|
307
|
+
* · `level: 'ok'` 三处齐备(不会多报红)
|
|
308
|
+
* · `level: 'critical'` 至少一处会丢插件(清单里没有 / 挂不上)→ 面板必须报红
|
|
309
|
+
* · `level: 'warn'` 只有口径不一致(lock 形态/目标、清单形态与磁盘不同)→ 降级成提示
|
|
310
|
+
* `note` 是**降级后**那句人话(critical 时与旧 persistNote 同一句,warn 时换软一点的措辞);
|
|
311
|
+
* 旧的 `report.note` / `persisted` / `suggestedPersistAction` **一个字都没动**(安装闸门语义不变)。
|
|
312
|
+
*/
|
|
313
|
+
function persistSeverity(input) {
|
|
314
|
+
const parts = input !== null && typeof input === 'object' && input.parts !== undefined && input.parts !== null
|
|
315
|
+
? input.parts
|
|
316
|
+
: (input !== null && typeof input === 'object' ? input : {})
|
|
317
|
+
const partsOf = {}
|
|
318
|
+
for (const key of PERSIST_PARTS) {
|
|
319
|
+
const part = parts[key]
|
|
320
|
+
partsOf[key] = part === undefined || part === null ? null : part
|
|
321
|
+
}
|
|
322
|
+
const openParts = PERSIST_PARTS.filter((key) => partsOf[key] !== null && partsOf[key].ok !== true)
|
|
323
|
+
const codes = {}
|
|
324
|
+
const critical = []
|
|
325
|
+
const downgraded = []
|
|
326
|
+
for (const key of openParts) {
|
|
327
|
+
const code = typeof partsOf[key].code === 'string' && partsOf[key].code !== '' ? partsOf[key].code : `${key}-unknown`
|
|
328
|
+
codes[key] = code
|
|
329
|
+
// 表里没有的码一律按 warn(保守方向:宁可少报红,也不拿红字打扰人)
|
|
330
|
+
if (isPersistCriticalCode(code)) critical.push(key)
|
|
331
|
+
else downgraded.push(key)
|
|
332
|
+
}
|
|
333
|
+
const losesAfterRestart = critical.length > 0
|
|
334
|
+
const level = openParts.length === 0 ? 'ok' : (losesAfterRestart ? 'critical' : 'warn')
|
|
335
|
+
const labels = { dependency: '① 清单 dependencies', lock: '② pnpm-lock.yaml', mount: '③ 挂载行(cordis.patch.yml / bundles)' }
|
|
336
|
+
let note = null
|
|
337
|
+
if (level === 'critical') {
|
|
338
|
+
const rows = critical.map((key) => `${labels[key]}:${(partsOf[key] && partsOf[key].reason) ?? PERSIST_CODE_TEXT[codes[key]] ?? '未就位'}`)
|
|
339
|
+
note = `未持久化(重启或任何一次 pnpm 操作后可能消失)—— ${rows.join(';')}`
|
|
340
|
+
if (downgraded.length > 0) {
|
|
341
|
+
note += `。另有 ${downgraded.length} 处口径不一致(不影响重启后加载):${downgraded.map((key) => labels[key] + ':' + ((partsOf[key] && partsOf[key].reason) ?? '不一致')).join(';')}`
|
|
342
|
+
}
|
|
343
|
+
} else if (level === 'warn') {
|
|
344
|
+
const rows = downgraded.map((key) => `${labels[key]}:${(partsOf[key] && partsOf[key].reason) ?? '不一致'}`)
|
|
345
|
+
note = `口径不一致(**不影响重启后加载**,只是清单 / lock 没对齐,下次 pnpm 操作会自行收敛)—— ${rows.join(';')}`
|
|
346
|
+
}
|
|
347
|
+
return { level, losesAfterRestart, critical, downgraded, codes, note }
|
|
348
|
+
}
|
|
349
|
+
|
|
237
350
|
/**
|
|
238
351
|
* 持久化判据(**只读**,永不抛):返回
|
|
239
352
|
* `{ persisted, packageName, missing, parts:{dependency,lock,mount}, note }`。
|
|
@@ -259,6 +372,9 @@ function persistReport({ profileDir, patchPath, packageName, entries = null, pat
|
|
|
259
372
|
},
|
|
260
373
|
}
|
|
261
374
|
report.note = report.persisted ? null : persistNote(report)
|
|
375
|
+
// 0.5.84 加法:同一份事实多一档"要不要报红"的结论(判据见 persistSeverity 顶部)。
|
|
376
|
+
// 旧的 persisted / note 保持原样 —— 这里只多一个字段,老调用点(与安装闸门)一个字都不用改。
|
|
377
|
+
report.severity = persistSeverity(report)
|
|
262
378
|
return report
|
|
263
379
|
}
|
|
264
380
|
|
|
@@ -338,6 +454,11 @@ async function ensurePersisted({ profileDir, patchPath, packageName, mode = 'ins
|
|
|
338
454
|
...(typeof sourceSpec === 'string' && sourceSpec !== '' ? { sourceSpec, sourceOrigin, sourceKind } : {}),
|
|
339
455
|
...(typeof deps.installSource === 'function' ? { installSource: deps.installSource } : {}),
|
|
340
456
|
...(deps.syncLock === false ? { syncLock: false } : {}),
|
|
457
|
+
// ★ 0.5.87 加法(用户红线:正常下载安装/更新绝不能产出 link:/file:):`registry-tarball` 是
|
|
458
|
+
// curl 通道从 registry 元数据里拿到 dist.tarball 并**校验摘要后**铺进去的 —— 这份物**证明**
|
|
459
|
+
// 该版本在 registry 上存在。于是探测说"查无此包/此版本"只能算探测失败,不能据此物化成 link:
|
|
460
|
+
// (详见 manifest.js#planDependencySpec 的 registryProven 分支)。其它来源一律不置位。
|
|
461
|
+
...(sourceKind === 'registry-tarball' ? { registryProven: true } : {}),
|
|
341
462
|
})
|
|
342
463
|
if (declared.changed !== true) notes.push(`清单里已有这个包(实际是 ${declared.spec ?? disk.version})`)
|
|
343
464
|
if (declared.depNote !== null && declared.depNote !== undefined) notes.push(declared.depNote)
|
|
@@ -399,6 +520,8 @@ async function ensurePersisted({ profileDir, patchPath, packageName, mode = 'ins
|
|
|
399
520
|
after.missing = PERSIST_PARTS.filter((key) => after.parts[key].ok !== true)
|
|
400
521
|
after.persisted = after.missing.length === 0
|
|
401
522
|
after.note = after.persisted ? null : persistNote(after)
|
|
523
|
+
// 0.5.84:这一档判据也改了 parts(lock → ok:false),降噪结论必须跟着重算,否则会拿旧的
|
|
524
|
+
after.severity = persistSeverity(after)
|
|
402
525
|
lockVerdict = { verified: lock.lockVerified, reason }
|
|
403
526
|
}
|
|
404
527
|
// 0.5.41 接线:真装失败时写入层会下发「一键真装」动作 —— 它优先于"钉住"(此时用户要的是真装那一步);
|
|
@@ -431,4 +554,6 @@ async function ensurePersisted({ profileDir, patchPath, packageName, mode = 'ins
|
|
|
431
554
|
export {
|
|
432
555
|
PERSIST_PARTS, EXACT_VERSION_RE, SOURCE_SPEC_RE, readManifestSync, readBundlesSync, diskFormOf,
|
|
433
556
|
dependencyPart, lockPart, mountPart, persistReport, persistNote, suggestedPersistAction, ensurePersisted,
|
|
557
|
+
// 0.5.84 判据降噪:原因码表 + 唯一判据 + 判定函数(面板与测试都只认这一份)
|
|
558
|
+
PERSIST_CRITICAL_CODES, PERSIST_CRITICAL_MOUNT, PERSIST_CODE_TEXT, isPersistCriticalCode, persistSeverity,
|
|
434
559
|
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// L1 · domain —— restart-launcher.js(独立实例的**detached 重拉**:只起新进程,绝不动老进程;0.5.84 加法)
|
|
2
|
+
// 分层分组:L1 · domain(不认识 cordis ctx;进程原语经 infra/egress.js 的 spawnDetached 出口)
|
|
3
|
+
//
|
|
4
|
+
// ── 为什么需要它(用户 2026-10 的第 5 项)────────────────────────────────────────
|
|
5
|
+
// 「重启服务」过去在全仓是**一律拒绝**的:`restartPathDecision` 硬编码 spawns:false / kill:false。
|
|
6
|
+
// 那条红线来自 2026-10-10 的真机事故 —— 但事故的成因是**桌面端托管**时自杀 + 与宿主争端口
|
|
7
|
+
// (详见 domain/restart.js 顶部取证)。**独立 `dsh web` 实例**没有那个问题:它自己就是那个进程,
|
|
8
|
+
// 端口也归它;重启它只需要"起一个新进程 + 自己退出"。
|
|
9
|
+
// ⇒ 所以本模块只服务非 shell 托管的那一半,且只做两件事:
|
|
10
|
+
// ① 起一个 **detached** 的新实例(`node <bin.js> web [--port N]`);
|
|
11
|
+
// ② 老进程**自己优雅退出**(不 kill 自己、不建计划任务、不自删)—— 端口随之释放。
|
|
12
|
+
//
|
|
13
|
+
// ── 端口竞态(必须处理,否则新实例必然 EADDRINUSE)────────────────────────────────
|
|
14
|
+
// 父进程收到请求时端口还占着,而它要等响应发完才退出。所以新进程不能直接起 ——
|
|
15
|
+
// 由本模块起一个**极小的中间启动器**(`node -e <script>`,无临时文件):
|
|
16
|
+
// 轮询 127.0.0.1:port,直到连接被拒绝(= 端口空了)再 spawn 真实例;
|
|
17
|
+
// 最多等 30s,等不到就自己退出(绝不无限挂着一个轮询进程)。
|
|
18
|
+
// 计划通过 `DSH_RELAUNCH_PLAN` 环境变量传给启动器(不走 argv —— `node -e` 下的 argv 位置不直观)。
|
|
19
|
+
//
|
|
20
|
+
// ── 红线(一条不减)────────────────────────────────────────────────────────────────
|
|
21
|
+
// · 不 kill 任何进程(包括自己):`kill:false`、`scheduledTask:false`、`selfDelete:false`;
|
|
22
|
+
// · 只在 `host.hosted !== true` 时才会走到这里(判据在 domain/restart.js,唯一承担者);
|
|
23
|
+
// · 进程原语只经 infra/egress.js 的 `spawnDetached` 出口 ⇒ 单测注入替身后"一次真进程都没起"
|
|
24
|
+
// 是**可计数证明**的(tests/test-standalone-relaunch.mjs 就此断言)。
|
|
25
|
+
import { spawnDetached } from '../infra/egress.js'
|
|
26
|
+
|
|
27
|
+
/** 启动器最多等端口多久(毫秒)。 **有上限**:等不到就自己退出,不留悬挂轮询进程。 */
|
|
28
|
+
const PORT_WAIT_DEADLINE_MS = 30000
|
|
29
|
+
/** 轮询间隔(毫秒)。 */
|
|
30
|
+
const PORT_POLL_MS = 250
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* 启动器脚本(**纯文本**,单测可断言它只做"等端口 → 起新进程")。
|
|
34
|
+
* 读 `DSH_RELAUNCH_PLAN`(JSON: { bin, args, cwd, port })。
|
|
35
|
+
*/
|
|
36
|
+
function launcherScript() {
|
|
37
|
+
return [
|
|
38
|
+
"const net = require('node:net')",
|
|
39
|
+
"const { spawn } = require('node:child_process')",
|
|
40
|
+
"const plan = JSON.parse(process.env.DSH_RELAUNCH_PLAN || '{}')",
|
|
41
|
+
'const port = Number(plan.port) || 0',
|
|
42
|
+
'const deadline = Date.now() + Number(plan.deadlineMs) || 0',
|
|
43
|
+
'const poll = Number(plan.pollMs) || 250',
|
|
44
|
+
'const freeProbe = () => new Promise((resolve) => {',
|
|
45
|
+
" const socket = net.connect({ host: '127.0.0.1', port: port })",
|
|
46
|
+
' const settle = (free) => { try { socket.destroy() } catch { } ; resolve(free) }',
|
|
47
|
+
' socket.setTimeout(1000, () => settle(false))',
|
|
48
|
+
' socket.once(' + "'error'" + ', () => settle(true))',
|
|
49
|
+
' socket.once(' + "'connect'" + ', () => settle(false))',
|
|
50
|
+
'})',
|
|
51
|
+
'const start = () => {',
|
|
52
|
+
' const child = spawn(process.execPath, [plan.bin].concat(plan.args || []), { detached: true, stdio: ' + "'ignore'" + ', windowsHide: true, cwd: plan.cwd || undefined })',
|
|
53
|
+
' child.unref()',
|
|
54
|
+
' process.exit(0)',
|
|
55
|
+
'}',
|
|
56
|
+
'const tick = () => {',
|
|
57
|
+
' if (Number.isFinite(deadline) && deadline > 0 && Date.now() > deadline) process.exit(1)',
|
|
58
|
+
' freeProbe().then((free) => { if (free) start(); else setTimeout(tick, poll) })',
|
|
59
|
+
'}',
|
|
60
|
+
'tick()',
|
|
61
|
+
].join('\n')
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* 规划一次 detached 重拉(**纯函数**):argv 由调用方给(bin 路径由 routes 层按既有 `resolveDshBin` 解析)。
|
|
66
|
+
* @returns {{ ok: boolean, reason: string|null, plan: object|null }}
|
|
67
|
+
*/
|
|
68
|
+
function planRelaunch({ binPath, args = [], port = null, cwd = null } = {}) {
|
|
69
|
+
const bin = typeof binPath === 'string' ? binPath.trim() : ''
|
|
70
|
+
if (bin === '') return { ok: false, reason: '找不到 DSH 的入口(bin.js),无法自动重启 —— 请手动重启这个 dsh web 进程', plan: null }
|
|
71
|
+
const argv = (Array.isArray(args) ? args : []).filter((a) => typeof a === 'string')
|
|
72
|
+
const numericPort = Number.isFinite(port) && port > 0 ? Math.floor(port) : null
|
|
73
|
+
return {
|
|
74
|
+
ok: true,
|
|
75
|
+
reason: null,
|
|
76
|
+
plan: {
|
|
77
|
+
bin,
|
|
78
|
+
args: argv,
|
|
79
|
+
cwd: typeof cwd === 'string' && cwd !== '' ? cwd : null,
|
|
80
|
+
port: numericPort,
|
|
81
|
+
deadlineMs: PORT_WAIT_DEADLINE_MS,
|
|
82
|
+
pollMs: PORT_POLL_MS,
|
|
83
|
+
},
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* 真的起那个启动器(**detached + unref**,父进程退出后继续活)。
|
|
89
|
+
* `deps.spawn` 只为单测注入(生产走 infra/egress.js 的 spawnDetached 出口)。
|
|
90
|
+
* @returns {Promise<{ started: boolean, pid: number|null, error: string|null }>}
|
|
91
|
+
*/
|
|
92
|
+
async function relaunchDetached(plan, { spawn = spawnDetached, env = process.env } = {}) {
|
|
93
|
+
if (plan === null || typeof plan !== 'object' || typeof plan.bin !== 'string' || plan.bin === '') {
|
|
94
|
+
return { started: false, pid: null, error: '重拉计划无效(缺少 bin 路径),未起任何进程' }
|
|
95
|
+
}
|
|
96
|
+
if (typeof spawn !== 'function') return { started: false, pid: null, error: '进程出口不可用(spawnDetached 未接线)' }
|
|
97
|
+
const payload = JSON.stringify({ bin: plan.bin, args: plan.args ?? [], cwd: plan.cwd ?? null, port: plan.port ?? null, deadlineMs: plan.deadlineMs ?? PORT_WAIT_DEADLINE_MS, pollMs: plan.pollMs ?? PORT_POLL_MS })
|
|
98
|
+
try {
|
|
99
|
+
const out = await spawn(process.execPath, ['-e', launcherScript()], {
|
|
100
|
+
env: { ...env, DSH_RELAUNCH_PLAN: payload },
|
|
101
|
+
cwd: plan.cwd ?? undefined,
|
|
102
|
+
detached: true,
|
|
103
|
+
})
|
|
104
|
+
return { started: true, pid: out !== null && typeof out?.pid === 'number' ? out.pid : null, error: null }
|
|
105
|
+
} catch (error) {
|
|
106
|
+
return { started: false, pid: null, error: error instanceof Error ? error.message : String(error) }
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export { PORT_POLL_MS, PORT_WAIT_DEADLINE_MS, launcherScript, planRelaunch, relaunchDetached }
|
|
@@ -61,8 +61,14 @@ const STANDALONE_RESTART_NOTICE = 'DSH 没有可由插件调用的官方重启
|
|
|
61
61
|
/** `/framework-relaunch`(原「手动拉起服务」悬浮按钮):同一判据,另加"这条路已下线"的说明。 */
|
|
62
62
|
const MANUAL_LAUNCH_REMOVED_NOTE = '「手动拉起服务」已按官方路径下线(控制台不再 spawn 新的 dsh 实例):'
|
|
63
63
|
|
|
64
|
-
/** 两条官方判定通道(稳定字符串,前端与测试按它判因)。
|
|
65
|
-
|
|
64
|
+
/** 两条官方判定通道(稳定字符串,前端与测试按它判因)。
|
|
65
|
+
* 0.5.84 加法:新增 `standaloneRelaunch` —— **只有独立实例**(非 shell 托管)才走这条真重启;
|
|
66
|
+
* 桌面端托管仍然走 desktop-client(拒绝 + 说明),那条一个字没动。 */
|
|
67
|
+
const RESTART_CHANNEL = { desktopClient: 'desktop-client', standaloneRelaunch: 'standalone-relaunch', unavailable: 'unavailable' }
|
|
68
|
+
|
|
69
|
+
/** 独立实例真的重启成功后的文案(**先起新进程、老进程自己优雅退出**)。 */
|
|
70
|
+
const STANDALONE_RELAUNCH_NOTICE = '正在重启:已 detached 拉起一个新的 dsh web 进程,本进程即将优雅退出(不 kill 任何进程、不建计划任务);'
|
|
71
|
+
+ '新实例会等端口释放后启动,稍等几秒刷新页面即可。'
|
|
66
72
|
|
|
67
73
|
/** 面向用户的出路(结构化下发,前端可原样展示;桌面端与独立实例各自一条真出路)。 */
|
|
68
74
|
const GUIDES = {
|
|
@@ -71,6 +77,11 @@ const GUIDES = {
|
|
|
71
77
|
'已弹出「应用无法启动或已意外停止」时:直接点弹窗里的「重启」(官方恢复对话框)。',
|
|
72
78
|
'只是想让插件改动生效:按 DSH 官方语义"下次启动生效"(restart-required),刷新页面即可看到已生效的部分。',
|
|
73
79
|
],
|
|
80
|
+
'standalone-relaunch': [
|
|
81
|
+
'新实例会在老进程退出、端口释放后自动启动(启动器最多等 30 秒)。',
|
|
82
|
+
'若几秒后页面仍然连不上:回到启动它的终端看输出,必要时手动运行 dsh web。',
|
|
83
|
+
'控制台绝不会 kill 进程、绝不建计划任务:失败时如实告知,不制造幽灵实例。',
|
|
84
|
+
],
|
|
74
85
|
unavailable: [
|
|
75
86
|
'独立 dsh web:在启动它的终端里 Ctrl+C,然后重新运行 dsh web。',
|
|
76
87
|
'只改了客户端插件:DSH 官方语义是"下次启动生效"(restart-required),直接刷新页面。',
|
|
@@ -85,17 +96,80 @@ const GUIDES = {
|
|
|
85
96
|
* @param {boolean} p.hosted 是否由桌面端外壳托管(调用方用 domain/framework.js#detectHostShape 判定后传入;
|
|
86
97
|
* 本函数不重复判定,保证与 shellHostedRefusal 同一份事实)
|
|
87
98
|
* @param {'restart'|'relaunch'} [p.intent] 控制台里的哪颗按钮(只影响文案前缀,判定完全相同)
|
|
88
|
-
* @
|
|
99
|
+
* @param {object|null} [p.relaunch] **独立实例**的重拉计划(domain/restart-launcher.js#planRelaunch 的产物)。
|
|
100
|
+
* 给了且 ok ⇒ 走 standalone-relaunch(真起一个 detached 新进程);
|
|
101
|
+
* 没给 / 不 ok ⇒ 回落 unavailable(如实告知手动重启)。
|
|
102
|
+
* ⚠️ 桌面端托管时本参数**被忽略**:那条路永远不 spawn(用户红线)。
|
|
103
|
+
* @returns {{ ok: boolean, official: boolean, channel: string, spawns: boolean, kill: false,
|
|
89
104
|
* status: number, code: string, message: string, details: object }}
|
|
90
105
|
*/
|
|
91
|
-
function restartPathDecision({ hosted, intent = 'restart' } = {}) {
|
|
106
|
+
function restartPathDecision({ hosted, intent = 'restart', relaunch = null } = {}) {
|
|
92
107
|
const shellHosted = hosted === true
|
|
93
|
-
|
|
94
|
-
|
|
108
|
+
// ★ 桌面端托管:判据与文案与 2026-10-10 那次改错**逐字一致**(不接受任何 relaunch 计划)
|
|
109
|
+
if (shellHosted) {
|
|
110
|
+
const channel = RESTART_CHANNEL.desktopClient
|
|
111
|
+
const message = intent === 'relaunch' ? MANUAL_LAUNCH_REMOVED_NOTE + DESKTOP_CLIENT_RESTART_NOTICE : DESKTOP_CLIENT_RESTART_NOTICE
|
|
112
|
+
return {
|
|
113
|
+
ok: false,
|
|
114
|
+
official: true,
|
|
115
|
+
channel,
|
|
116
|
+
spawns: false,
|
|
117
|
+
kill: false,
|
|
118
|
+
status: 409,
|
|
119
|
+
code: `official-restart-${channel}`,
|
|
120
|
+
message,
|
|
121
|
+
details: {
|
|
122
|
+
code: `official-restart-${channel}`,
|
|
123
|
+
official: true,
|
|
124
|
+
channel,
|
|
125
|
+
// 机器可读的红线判据:桌面端托管时这三项永远是 false(测试的负控就钉在这里)
|
|
126
|
+
spawns: false,
|
|
127
|
+
kill: false,
|
|
128
|
+
scheduledTask: false,
|
|
129
|
+
selfDelete: false,
|
|
130
|
+
manualLaunch: intent === 'relaunch' ? 'removed' : 'never',
|
|
131
|
+
guide: GUIDES[channel],
|
|
132
|
+
},
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
// ★ 独立实例 + 有可用重拉计划 ⇒ **真重启**:起一个新进程,老进程自己优雅退出。
|
|
136
|
+
// 仍然不 kill / 不建计划任务 / 不自删 —— 用户那次事故的根因是"自杀 + 争端口",
|
|
137
|
+
// 而独立实例既不需要自杀(它自己退出就行),也不存在争端口(启动器等端口空了才起新实例)。
|
|
138
|
+
const plan = relaunch !== null && typeof relaunch === 'object' && relaunch.ok === true ? relaunch.plan ?? null : null
|
|
139
|
+
if (plan !== null && typeof plan === 'object' && typeof plan.bin === 'string' && plan.bin !== '') {
|
|
140
|
+
const channel = RESTART_CHANNEL.standaloneRelaunch
|
|
141
|
+
return {
|
|
142
|
+
ok: true,
|
|
143
|
+
official: false,
|
|
144
|
+
channel,
|
|
145
|
+
spawns: true,
|
|
146
|
+
kill: false,
|
|
147
|
+
status: 200,
|
|
148
|
+
code: channel,
|
|
149
|
+
message: STANDALONE_RELAUNCH_NOTICE,
|
|
150
|
+
details: {
|
|
151
|
+
code: channel,
|
|
152
|
+
official: false,
|
|
153
|
+
channel,
|
|
154
|
+
spawns: true,
|
|
155
|
+
kill: false,
|
|
156
|
+
scheduledTask: false,
|
|
157
|
+
selfDelete: false,
|
|
158
|
+
detached: true,
|
|
159
|
+
gracefulExit: true,
|
|
160
|
+
manualLaunch: intent === 'relaunch' ? 'removed' : 'never',
|
|
161
|
+
plan: { bin: plan.bin, args: Array.isArray(plan.args) ? plan.args : [], port: plan.port ?? null, cwd: plan.cwd ?? null },
|
|
162
|
+
guide: GUIDES[channel],
|
|
163
|
+
},
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
// ★ 独立实例但拿不到重拉计划(例如找不到 bin.js)⇒ 与旧行为一致:如实告知手动重启
|
|
167
|
+
const channel = RESTART_CHANNEL.unavailable
|
|
168
|
+
const base = typeof relaunch?.reason === 'string' && relaunch.reason !== '' ? `${relaunch.reason}。${STANDALONE_RESTART_NOTICE}` : STANDALONE_RESTART_NOTICE
|
|
95
169
|
const message = intent === 'relaunch' ? MANUAL_LAUNCH_REMOVED_NOTE + base : base
|
|
96
170
|
return {
|
|
97
171
|
ok: false,
|
|
98
|
-
official:
|
|
172
|
+
official: false,
|
|
99
173
|
channel,
|
|
100
174
|
spawns: false,
|
|
101
175
|
kill: false,
|
|
@@ -104,9 +178,9 @@ function restartPathDecision({ hosted, intent = 'restart' } = {}) {
|
|
|
104
178
|
message,
|
|
105
179
|
details: {
|
|
106
180
|
code: `official-restart-${channel}`,
|
|
107
|
-
official:
|
|
181
|
+
official: false,
|
|
108
182
|
channel,
|
|
109
|
-
//
|
|
183
|
+
// 机器可读的红线判据:这三项永远是 false
|
|
110
184
|
spawns: false,
|
|
111
185
|
kill: false,
|
|
112
186
|
scheduledTask: false,
|
|
@@ -117,4 +191,4 @@ function restartPathDecision({ hosted, intent = 'restart' } = {}) {
|
|
|
117
191
|
}
|
|
118
192
|
}
|
|
119
193
|
|
|
120
|
-
export { DESKTOP_CLIENT_RESTART_NOTICE, MANUAL_LAUNCH_REMOVED_NOTE, RESTART_CHANNEL, STANDALONE_RESTART_NOTICE, restartPathDecision }
|
|
194
|
+
export { DESKTOP_CLIENT_RESTART_NOTICE, MANUAL_LAUNCH_REMOVED_NOTE, RESTART_CHANNEL, STANDALONE_RELAUNCH_NOTICE, STANDALONE_RESTART_NOTICE, restartPathDecision }
|
|
@@ -511,7 +511,12 @@ async function reconcileLockfile({ profileDir, packageName = null, packages = nu
|
|
|
511
511
|
suggestedActions.push(suggestedPinAction({ packageName: item.name, version: item.installed, profileDir, reason: why }))
|
|
512
512
|
continue
|
|
513
513
|
}
|
|
514
|
-
|
|
514
|
+
// ★ 0.5.87 改错(真机 E2E 2026-10-18 复现):这条路的计划是 **`name@版本`**(给 pnpm add 用的
|
|
515
|
+
// 完整规格),而清单 `dependencies` 里存的是**版本号那一段**。旧代码拿整串去比 ⇒ 明明已经写对
|
|
516
|
+
// (清单 = lock = 最新版)也一律报「写回**未生效**,请执行「钉住」动作」,还顺手挂上一个 pin 动作。
|
|
517
|
+
// 表现:更新成功、`persisted: true`,面板却同时说"没写进去"。这里只比**要写进清单的那一段**。
|
|
518
|
+
const wantedInManifest = item.spec.startsWith(`${item.name}@`) ? item.spec.slice(item.name.length + 1) : item.spec
|
|
519
|
+
if (actual === wantedInManifest) {
|
|
515
520
|
if (item.spec.startsWith('link:') && item.reason !== null) {
|
|
516
521
|
depNotes.push(`${item.name}:${item.reason},已按源码安装(本地物化)记录依赖(${actual})—— 不经 npm registry 解析、不经 tarball 完整性校验,pnpm 重建 lock 也能装上`)
|
|
517
522
|
}
|
|
@@ -13,17 +13,21 @@ import { dshHome } from '../infra/paths.js'
|
|
|
13
13
|
/** 探测仓库是否为技能仓库:根目录或第一层子目录存在 SKILL.md。
|
|
14
14
|
* 返回 { hasSkill, skillDir }(skillDir 为相对仓库根的目录,'' 表示根)。
|
|
15
15
|
* raw 双通道竞速,3 秒封顶,失败静默。 */
|
|
16
|
-
async function detectSkillRepo(repo, branch = 'main') {
|
|
16
|
+
async function detectSkillRepo(repo, branch = 'main', deps = {}) {
|
|
17
|
+
// deps 只为**富化路径**(domain/market.js)注入限流出口而加;生产的老调用点都不传,
|
|
18
|
+
// 于是回落值与改动前逐字相同(curlText / curlJson)。
|
|
19
|
+
const fetchText = typeof deps.curlText === 'function' ? deps.curlText : curlText
|
|
20
|
+
const fetchJson = typeof deps.curlJson === 'function' ? deps.curlJson : curlJson
|
|
17
21
|
const branchEnc = encodeURIComponent(branch)
|
|
18
22
|
try {
|
|
19
23
|
const root = await Promise.any([
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
24
|
+
fetchText(`${GITHUB_RAW}/${repo}/${branchEnc}/SKILL.md`, 3000),
|
|
25
|
+
fetchText(`https://ghproxy.net/${GITHUB_RAW}/${repo}/${branchEnc}/SKILL.md`, 3000),
|
|
26
|
+
fetchText(`https://cdn.jsdelivr.net/gh/${repo}@${branchEnc}/SKILL.md`, 3000),
|
|
23
27
|
]).catch(() => null)
|
|
24
28
|
if (root !== null) return { hasSkill: true, skillDir: '' }
|
|
25
29
|
// 根没有时再查一层子目录(常用布局:skills/<name>/SKILL.md、<name>/SKILL.md)
|
|
26
|
-
const tree = await
|
|
30
|
+
const tree = await fetchJson(`https://api.github.com/repos/${repo}/git/trees/${branchEnc}?recursive=1`, 5000).catch(() => null)
|
|
27
31
|
const skillPaths = (tree?.tree ?? [])
|
|
28
32
|
.filter((n) => n.type === 'blob' && /(?:^|\/)SKILL\.md$/u.test(n.path))
|
|
29
33
|
.map((n) => n.path)
|