@noob-stupid/dsh-plugin-console 0.5.92 → 0.5.94
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/README.zh.md +1 -1
- package/lib/client.js +1097 -70
- package/lib/server/domain/plugin-annotations.js +569 -0
- package/lib/server/domain/update-batch.js +181 -0
- package/lib/server/routes/annotations.js +82 -0
- package/lib/server/routes/framework.js +137 -120
- package/lib/server/routes/index.js +12 -0
- package/lib/server/routes/update-batch.js +97 -0
- package/package.json +1 -1
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
// L1 · domain —— update-batch.js(已安装插件「一键检测全部更新」的批量执行器;issue #22 ①)
|
|
2
|
+
// 分层分组:L1 · domain(tests/test-architecture-guard.mjs 断言边界;不认识 cordis ctx、不联网、不碰盘)
|
|
3
|
+
//
|
|
4
|
+
// ── 它解决什么 ────────────────────────────────────────────────────────────────
|
|
5
|
+
// issue #22 ①:`familyCheckUpdate` 只覆盖全家桶内部,单独装的第三方插件要逐个点卡片上的「检测更新」。
|
|
6
|
+
// 批量检测 = 把 N 个包名丢给同一个探针(probe)跑一遍。风险只有一个,而且是**已经发生过的那一个**:
|
|
7
|
+
//
|
|
8
|
+
// ── 并发上限为什么必须在这里、而且是"进程一份" ─────────────────────────────────
|
|
9
|
+
// 0.5.84 的真机事故(domain/enrich-throttle.js 顶部有完整记录):市场富化当时按"每条卡片各发自己的
|
|
10
|
+
// 请求"来写,worker 12 × 每条 9 通道 ≈ 100 个连接同时出去,网络被占满,安装作业(pnpm / clone)跟着卡死。
|
|
11
|
+
// 结论:限流器**不能每个调用点各写一份** —— 那样上限会随调用点数量相乘而失效。
|
|
12
|
+
// 这里因此分两层:
|
|
13
|
+
// · `createBatchUpdater()` 造出来的是**进程级一份**实例(路由模块加载时造一次),并发上限与缓存在
|
|
14
|
+
// 所有调用点之间共享 → 用户连点两次「一键检测」也不会变成两份并发;
|
|
15
|
+
// · 探针本身随请求变(它要用本次请求的 ctx),所以以 `run(names, { probe })` 的形式**每次传入**,
|
|
16
|
+
// 而不是在工厂里绑死 —— 即"闸门是共享的,被执行的活是每次的"。
|
|
17
|
+
// 上限取 4:与 enrich-throttle 的 ENRICH_CONCURRENCY 同一个实测结论(4 条就够快,更多只会打满网络)。
|
|
18
|
+
//
|
|
19
|
+
// ── 判据(版本比较)不在这里 ──────────────────────────────────────────────────
|
|
20
|
+
// 本模块**不做任何版本比较**:谁新谁旧由探针(probe)回答,而探针是 `routes/framework.js#probePackageUpdate`
|
|
21
|
+
// —— 单卡「检测更新」用的**同一份**代码,末尾走 `dropStaleUpdateCandidates` + `pickUpdateTarget`,
|
|
22
|
+
// 也就是 `infra/semver.js` 里 `<<<update-pick>>>` 那块唯一判据(tests/test-update-version-pick.mjs 钉死)。
|
|
23
|
+
// 本模块只把结论归成三态(batchStatusOf),不参与"哪个版本更高"的判断。
|
|
24
|
+
//
|
|
25
|
+
// ── 三态语义(面板据此标注卡片;不猜)──────────────────────────────────────────
|
|
26
|
+
// · 'update' :探针给出了严格更高的目标版本(target 非空)→ 卡片可以出现「更新」按钮;
|
|
27
|
+
// · 'current' :探针查到了候选,但没有比已装更高的(target 为空、错误为空)→ 「已最新」;
|
|
28
|
+
// · 'unknown' :探针**一个候选都没拿到**(npm/GitHub 都没答上来)→ 「未检测到」。绝不把它说成"已最新":
|
|
29
|
+
// 没查到 ≠ 没有新版。
|
|
30
|
+
//
|
|
31
|
+
// ── 缓存(60s TTL)为什么值得有 ────────────────────────────────────────────────
|
|
32
|
+
// 批量入口一次要问几十个包;用户点完一次再点一次(或换筛选后回来点)没有理由再打一遍网络。
|
|
33
|
+
// TTL 内直接复用上轮结论,响应里 `cached: true` 如实标注这是复用而不是新查的。
|
|
34
|
+
// `force: true` 越过缓存(面板将来若要"强制重查"就是这个开关)。
|
|
35
|
+
import { makeLimiter } from './enrich-throttle.js'
|
|
36
|
+
|
|
37
|
+
/** 并发上限(与域内富化闸同一实测结论:4 条够快,再多只会把网络打满)。 */
|
|
38
|
+
const BATCH_UPDATE_CONCURRENCY = 4
|
|
39
|
+
/** 结论缓存时长:同一批包在这段时间内重复点「一键检测」不再打网络。 */
|
|
40
|
+
const BATCH_UPDATE_TTL_MS = 60 * 1000
|
|
41
|
+
/** 单次批量最多接受多少个包名(防"把整个 registry 拉一遍"这种自伤用法)。 */
|
|
42
|
+
const BATCH_UPDATE_MAX = 200
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* 把一次探针结论归成三态(纯函数;**不比较版本**,版本高低由探针/唯一判据给出)。
|
|
46
|
+
* @param {{target?: string|null, latest?: string|null, next?: string|null, beta?: string|null, error?: string|null}|null|undefined} result
|
|
47
|
+
* @returns {'update'|'current'|'unknown'}
|
|
48
|
+
*/
|
|
49
|
+
function batchStatusOf(result) {
|
|
50
|
+
const src = result !== null && typeof result === 'object' ? result : {}
|
|
51
|
+
const target = typeof src.target === 'string' ? src.target.trim() : ''
|
|
52
|
+
if (target !== '') return 'update'
|
|
53
|
+
const hasCandidate = [src.latest, src.next, src.beta].some((v) => typeof v === 'string' && v.trim() !== '')
|
|
54
|
+
if (hasCandidate) return 'current'
|
|
55
|
+
// 一个候选都没有:查得到结论(error 为空)才算"已最新";查不到就是"未检测到"
|
|
56
|
+
return typeof src.error === 'string' && src.error !== '' ? 'unknown' : 'current'
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* 造一个批量更新检测器。**路由模块加载时造一次、进程内共享**(并发上限与缓存都是共享的)。
|
|
61
|
+
*
|
|
62
|
+
* @param {object} [opts]
|
|
63
|
+
* @param {number} [opts.concurrency] 并发上限(默认 BATCH_UPDATE_CONCURRENCY=4)
|
|
64
|
+
* @param {number} [opts.ttlMs] 结论缓存时长(默认 60s)
|
|
65
|
+
* @param {number} [opts.max] 单次最多处理的包名数(默认 200)
|
|
66
|
+
* @param {() => number} [opts.now] 时钟注入缝(单测判缓存过期用)
|
|
67
|
+
*/
|
|
68
|
+
function createBatchUpdater({ concurrency = BATCH_UPDATE_CONCURRENCY, ttlMs = BATCH_UPDATE_TTL_MS, max = BATCH_UPDATE_MAX, now = Date.now } = {}) {
|
|
69
|
+
const limit = Number.isFinite(concurrency) && concurrency > 0 ? Math.floor(concurrency) : BATCH_UPDATE_CONCURRENCY
|
|
70
|
+
const ttl = Number.isFinite(ttlMs) && ttlMs >= 0 ? ttlMs : BATCH_UPDATE_TTL_MS
|
|
71
|
+
const cap = Number.isFinite(max) && max > 0 ? Math.floor(max) : BATCH_UPDATE_MAX
|
|
72
|
+
// ★ 进程一份闸门(不是每次 run 一份):连点两次也共用同一个上限
|
|
73
|
+
const limiter = makeLimiter(limit)
|
|
74
|
+
/** packageName → { at, result }(结论缓存;失败也缓存,避免黑洞期反复打同一个包) */
|
|
75
|
+
const cache = new Map()
|
|
76
|
+
const counters = { runs: 0, probed: 0, cacheHits: 0, failures: 0 }
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* 跑一次批量检测。
|
|
80
|
+
* @param {string[]} packageNames 包名(调用方已校验;这里仍会去重/截断,但**不**放宽校验)
|
|
81
|
+
* @param {object} io
|
|
82
|
+
* @param {(packageName: string) => Promise<object>} io.probe 探针(单包结论)
|
|
83
|
+
* @param {(packageName: string) => (string|null)} [io.installedOf] 已装版本(只作展示,不参与判据)
|
|
84
|
+
* @param {boolean} [io.force] 越过缓存
|
|
85
|
+
* @returns {Promise<{ items: Array<object>, checked: number, updatable: number, unknown: number, stats: object }>}
|
|
86
|
+
*/
|
|
87
|
+
async function run(packageNames, io = {}) {
|
|
88
|
+
const probe = typeof io.probe === 'function' ? io.probe : null
|
|
89
|
+
const installedOf = typeof io.installedOf === 'function' ? io.installedOf : null
|
|
90
|
+
const force = io.force === true
|
|
91
|
+
const list = []
|
|
92
|
+
const seen = new Set()
|
|
93
|
+
for (const raw of Array.isArray(packageNames) ? packageNames : []) {
|
|
94
|
+
const name = typeof raw === 'string' ? raw.trim() : ''
|
|
95
|
+
if (name === '' || seen.has(name)) continue
|
|
96
|
+
seen.add(name)
|
|
97
|
+
list.push(name)
|
|
98
|
+
if (list.length >= cap) break
|
|
99
|
+
}
|
|
100
|
+
counters.runs += 1
|
|
101
|
+
if (probe === null) {
|
|
102
|
+
// 没有探针就是接线错了 —— 如实报,不假装"都没有更新"
|
|
103
|
+
return { items: [], checked: 0, updatable: 0, unknown: 0, error: '没有提供探针(probe),拒绝把"查不到"说成"已最新"', stats: statsOf() }
|
|
104
|
+
}
|
|
105
|
+
// ① 缓存命中判定(同步做,保持结果顺序与请求顺序一致)
|
|
106
|
+
const slots = list.map((packageName) => {
|
|
107
|
+
const hit = force ? undefined : cache.get(packageName)
|
|
108
|
+
const fresh = hit !== undefined && now() - hit.at < ttl
|
|
109
|
+
if (fresh) counters.cacheHits += 1
|
|
110
|
+
return { packageName, cached: fresh ? hit : null, fromCache: fresh }
|
|
111
|
+
})
|
|
112
|
+
// ② 未命中的并发查(**全部**经同一个 limiter;队列 FIFO,绝不丢弃)
|
|
113
|
+
await Promise.all(slots.map(async (slot) => {
|
|
114
|
+
if (slot.cached !== null) return
|
|
115
|
+
let result
|
|
116
|
+
try {
|
|
117
|
+
counters.probed += 1
|
|
118
|
+
result = await limiter.run(() => probe(slot.packageName))
|
|
119
|
+
if (result === null || typeof result !== 'object') {
|
|
120
|
+
result = { latest: null, next: null, beta: null, target: null, error: '探针没有返回结论', source: 'npm', depsOutdated: [], migrate: null }
|
|
121
|
+
counters.failures += 1
|
|
122
|
+
}
|
|
123
|
+
} catch (error) {
|
|
124
|
+
// 单包失败**只影响自己**:批量里一个包炸掉不许拖垮其余(如实带上原因)
|
|
125
|
+
counters.failures += 1
|
|
126
|
+
result = { latest: null, next: null, beta: null, target: null, error: error instanceof Error ? error.message : String(error), source: 'npm', depsOutdated: [], migrate: null }
|
|
127
|
+
}
|
|
128
|
+
cache.set(slot.packageName, { at: now(), result })
|
|
129
|
+
slot.cached = { at: now(), result }
|
|
130
|
+
}))
|
|
131
|
+
// ③ 组装(顺序 = 请求顺序;每项都带 status 与 cached,前端无需自己推)
|
|
132
|
+
const items = slots.map((slot) => {
|
|
133
|
+
const result = slot.cached !== null && typeof slot.cached === 'object' ? slot.cached.result : {}
|
|
134
|
+
return {
|
|
135
|
+
packageName: slot.packageName,
|
|
136
|
+
installedVersion: installedOf === null ? null : installedOf(slot.packageName),
|
|
137
|
+
latest: result.latest ?? null,
|
|
138
|
+
next: result.next ?? null,
|
|
139
|
+
beta: result.beta ?? null,
|
|
140
|
+
target: result.target ?? null,
|
|
141
|
+
source: result.source ?? 'npm',
|
|
142
|
+
error: result.error ?? null,
|
|
143
|
+
depsOutdated: Array.isArray(result.depsOutdated) ? result.depsOutdated : [],
|
|
144
|
+
migrate: result.migrate ?? null,
|
|
145
|
+
status: batchStatusOf(result),
|
|
146
|
+
// 如实标注这一项是"本轮新查的"还是"复用 TTL 内的缓存"
|
|
147
|
+
cached: slot.fromCache === true,
|
|
148
|
+
}
|
|
149
|
+
})
|
|
150
|
+
return {
|
|
151
|
+
items,
|
|
152
|
+
checked: items.length,
|
|
153
|
+
updatable: items.filter((i) => i.status === 'update').length,
|
|
154
|
+
unknown: items.filter((i) => i.status === 'unknown').length,
|
|
155
|
+
stats: statsOf(),
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function statsOf() {
|
|
160
|
+
const limiterStats = limiter.stats()
|
|
161
|
+
// 注意:limiter.stats() 也有一个 `max`(= 并发上限)。这里把它摊平改名,避免与本模块的
|
|
162
|
+
// `maxPackages`(单次最多多少个包)撞名 —— 两个"上限"含义完全不同,撞名会读错诊断。
|
|
163
|
+
return {
|
|
164
|
+
concurrencyLimit: limit,
|
|
165
|
+
ttlMs: ttl,
|
|
166
|
+
maxPackages: cap,
|
|
167
|
+
cacheSize: cache.size,
|
|
168
|
+
cacheHits: counters.cacheHits,
|
|
169
|
+
probed: counters.probed,
|
|
170
|
+
runs: counters.runs,
|
|
171
|
+
failures: counters.failures,
|
|
172
|
+
active: limiterStats.active,
|
|
173
|
+
queued: limiterStats.queued,
|
|
174
|
+
peak: limiterStats.peak,
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return { run, stats: statsOf, clearCache: () => cache.clear() }
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export { BATCH_UPDATE_CONCURRENCY, BATCH_UPDATE_TTL_MS, BATCH_UPDATE_MAX, batchStatusOf, createBatchUpdater }
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// L2 · routes —— /annotations(用户备注 / 用户徽章 / 用户分组;issue #22 ②③)
|
|
2
|
+
//
|
|
3
|
+
// 两条路由,同一个路径(与既有的 `/sources` 同一手法:GET 只读住守卫前,POST 吃 body 住守卫后):
|
|
4
|
+
// · GET /plugin-console/annotations → 读整份注解(**纯只读**,永不抛;坏文件降级 + 带原因)
|
|
5
|
+
// · POST /plugin-console/annotations → 一次写动作(action 见 domain/plugin-annotations.js)
|
|
6
|
+
//
|
|
7
|
+
// 三条边界:
|
|
8
|
+
// ① **与插件列表完全解耦**:注解读/写失败只影响这两条路由,绝不进 /state 的路径
|
|
9
|
+
// (插件列表照常渲染;"注解坏了不该让我看不见插件"——用户明确要求)。
|
|
10
|
+
// ② 写动作全部走 domain 的四步(备份 → 原子写 → 读回核实 → 回滚),路由只做**入参映射 + 状态码**:
|
|
11
|
+
// 校验 400、坏文件/重名 409、分组不存在 404、其余 200。绝不在这里自己写盘。
|
|
12
|
+
// ③ 成功时回**整份最新状态**(plugins + groups + stats),面板不必再请求一次就能重绘
|
|
13
|
+
// (少一次往返,也避免"写成功但界面还是旧的"这种自相矛盾)。
|
|
14
|
+
//
|
|
15
|
+
// 路由注册:`lib/server/routes/index.js`(GET 在 ROUTES_EARLY,POST 在 ROUTES);
|
|
16
|
+
// 路由清单:`tests/test-route-inventory.mjs`。
|
|
17
|
+
import { annotationsSummary, annotationsPath, applyAnnotation, readAnnotations } from '../domain/plugin-annotations.js'
|
|
18
|
+
import { sendError, sendJson } from '../infra/httpd.js'
|
|
19
|
+
|
|
20
|
+
/** 一份可直接给面板渲染的注解视图(读不到也永远有完整形状)。 */
|
|
21
|
+
function annotationsView(read) {
|
|
22
|
+
const data = read.data
|
|
23
|
+
return {
|
|
24
|
+
ok: read.ok === true,
|
|
25
|
+
file: read.file,
|
|
26
|
+
exists: read.exists === true,
|
|
27
|
+
emptyFile: read.emptyFile === true,
|
|
28
|
+
version: data.version ?? 1,
|
|
29
|
+
updatedAt: data.updatedAt ?? 0,
|
|
30
|
+
plugins: data.plugins,
|
|
31
|
+
groups: data.groups,
|
|
32
|
+
stats: annotationsSummary(data),
|
|
33
|
+
// 读的时候做过规范化(用户手工改过文件):如实把"我改了哪些"说出来,不静默
|
|
34
|
+
repaired: Array.isArray(read.repaired) ? read.repaired : [],
|
|
35
|
+
error: read.error ?? null,
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function routeAnnotationsGet(req, res, rc) {
|
|
40
|
+
void rc
|
|
41
|
+
const read = readAnnotations()
|
|
42
|
+
// 只读接口永远 200:坏文件也要让面板拿得到"空结构 + 原因",否则面板只能显示一句泛泛的失败
|
|
43
|
+
sendJson(res, 200, annotationsView(read))
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function routeAnnotationsPost(req, res, rc) {
|
|
47
|
+
const body = rc.body ?? {}
|
|
48
|
+
const action = typeof body.action === 'string' ? body.action : ''
|
|
49
|
+
if (action === '') {
|
|
50
|
+
sendError(res, 400, '缺少 action(可用:note / badges / group-create / group-rename / group-delete / group-restore / group-assign / group-collapse)')
|
|
51
|
+
return
|
|
52
|
+
}
|
|
53
|
+
// input 原样透传:所有校验都在 domain(**一处判据**),路由不重复判断、也不放宽
|
|
54
|
+
const result = await applyAnnotation({ ...body, action })
|
|
55
|
+
const after = readAnnotations()
|
|
56
|
+
const view = annotationsView({ ...after, data: result.data ?? after.data })
|
|
57
|
+
const payload = {
|
|
58
|
+
ok: result.ok === true,
|
|
59
|
+
action,
|
|
60
|
+
changed: result.changed === true,
|
|
61
|
+
error: result.error ?? null,
|
|
62
|
+
backup: result.backup ?? null,
|
|
63
|
+
file: result.file ?? annotationsPath(),
|
|
64
|
+
key: result.meta?.key ?? null,
|
|
65
|
+
group: result.meta?.group ?? null,
|
|
66
|
+
deleted: result.meta?.deleted ?? null,
|
|
67
|
+
index: result.meta?.index ?? null,
|
|
68
|
+
movedFrom: result.meta?.movedFrom ?? null,
|
|
69
|
+
removedFrom: result.meta?.removedFrom ?? null,
|
|
70
|
+
plugins: view.plugins,
|
|
71
|
+
groups: view.groups,
|
|
72
|
+
stats: view.stats,
|
|
73
|
+
}
|
|
74
|
+
if (result.ok === true) {
|
|
75
|
+
sendJson(res, 200, payload)
|
|
76
|
+
return
|
|
77
|
+
}
|
|
78
|
+
const status = Number.isFinite(result.status) ? result.status : 400
|
|
79
|
+
sendJson(res, status, { ...payload, ok: false })
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export { routeAnnotationsGet, routeAnnotationsPost, annotationsView }
|
|
@@ -235,136 +235,153 @@ async function routeCompatGate(req, res, rc) {
|
|
|
235
235
|
return
|
|
236
236
|
}
|
|
237
237
|
|
|
238
|
-
|
|
238
|
+
/**
|
|
239
|
+
* 单个已装插件的更新检测 —— **唯一实现**(issue #22 ①:一键批量检测必须与单卡检测同结论)。
|
|
240
|
+
* 批量入口若另写一份"问 registry / 回落 GitHub release / 净化陈旧候选",就是第二套检测逻辑 ——
|
|
241
|
+
* 本仓已为"两套判据分叉"付过代价(0.5.37 反向更新)。所以只留一份:`/check-update`(单卡)与
|
|
242
|
+
* `/check-updates`(批量)都调本函数;判据仍是 `infra/semver.js` 的 `<<<update-pick>>>` 块。
|
|
243
|
+
* 本函数**不是路由**:不写 res、不校验入参(调用方各自校验),只回结论。
|
|
244
|
+
* options.fetchJson = 出网注入缝(默认 infra/http.js 的 fetchJsonUrl ⇒ 生产行为零变化;批量离线测试靠它)。
|
|
245
|
+
*/
|
|
246
|
+
async function probePackageUpdate(packageName, rc, options = {}) {
|
|
239
247
|
const currentFrameworkVersion = rc.deps.currentFrameworkVersion
|
|
240
248
|
const ctx = rc.ctx
|
|
241
|
-
const
|
|
242
|
-
|
|
243
|
-
const
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
249
|
+
const fetchJson = typeof options.fetchJson === 'function' ? options.fetchJson : fetchJsonUrl
|
|
250
|
+
// ★ 2026-10-25 改错(抽函数时发现):profileDir 必须在**函数作用域**里。
|
|
251
|
+
// 旧代码只在 registry 那个 `try {}` 块内声明 `const profileDir = dirname(patchPath)`,
|
|
252
|
+
// 而下面的 migrate(换名检测)块在 try **之外**引用它 ⇒ 每次调用必抛
|
|
253
|
+
// `ReferenceError: profileDir is not defined`,被那个块自己的 `catch {}` 吞掉
|
|
254
|
+
// ⇒ `migrate` 恒为 null(面板上"项目已改名/迁移发布 → 去装新包"这条提示从未生效过)。
|
|
255
|
+
// tests/test-update-batch.mjs ⑤ 用正控钉住:声明了 dsh.migrate.to 的包必须真的回 migrate。
|
|
256
|
+
const profileDir = dirname(findPatchPath(ctx))
|
|
257
|
+
let latest = null
|
|
258
|
+
let next = null
|
|
259
|
+
let beta = null
|
|
260
|
+
let depsOutdated = []
|
|
261
|
+
let error = null
|
|
262
|
+
let source = 'npm'
|
|
263
|
+
// 检测已安装插件是否有新版本:curl registry 元数据取 dist-tags.latest(node 网络黑洞时 curl 可用)。
|
|
264
|
+
// 聚合包(有 dependencies)额外对比子包版本:声明版本 vs 本地 node_modules 实际版本,
|
|
265
|
+
// 返回 depsOutdated 提示"更新本包需同步子包",避免半更新混搭导致启动冲突。
|
|
266
|
+
try {
|
|
267
|
+
const encoded = packageName.startsWith('@')
|
|
268
|
+
? `@${encodeURIComponent(packageName.slice(1).split('/')[0])}%2f${encodeURIComponent(packageName.split('/').slice(1).join('/'))}`
|
|
269
|
+
: encodeURIComponent(packageName)
|
|
270
|
+
const data = await fetchJson(`https://registry.npmmirror.com/${encoded}`)
|
|
271
|
+
latest = data?.['dist-tags']?.latest ?? null
|
|
272
|
+
next = data?.['dist-tags']?.next ?? null
|
|
273
|
+
beta = data?.['dist-tags']?.beta ?? null
|
|
274
|
+
if (latest === null) throw new Error(`registry 无 dist-tags.latest(${packageName})`)
|
|
275
|
+
// 子包配套检查:最新版声明依赖 vs 本地实际版本
|
|
276
|
+
const declared = latest ? data?.versions?.[latest]?.dependencies ?? {} : {}
|
|
277
|
+
const keys = typeof declared === 'object' ? Object.keys(declared) : []
|
|
278
|
+
for (const dep of keys) {
|
|
279
|
+
const required = String(declared[dep] ?? '').replace(/^[\^~>=< ]+/u, '')
|
|
280
|
+
if (!required) continue
|
|
281
|
+
let current = null
|
|
282
|
+
try {
|
|
283
|
+
const pkgPath = join(profileDir, 'node_modules', dep, 'package.json')
|
|
284
|
+
if (existsSync(pkgPath)) {
|
|
285
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'))
|
|
286
|
+
current = typeof pkg.version === 'string' ? pkg.version : null
|
|
287
|
+
}
|
|
288
|
+
} catch {}
|
|
289
|
+
if (current !== null && current !== required) {
|
|
290
|
+
depsOutdated.push({ name: dep, current, required })
|
|
291
|
+
}
|
|
252
292
|
}
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
let
|
|
293
|
+
} catch (err) {
|
|
294
|
+
// GitHub 发布回退(自身/未发布到 npm 的插件):npm registry 404/无 latest 时,
|
|
295
|
+
// 从已装包 package.json 的 repository 字段反查 GitHub 最新版本。
|
|
296
|
+
// 通道顺序:GitHub API(带 token)→ jsDelivr 版本 API(GitHub 黑洞期可用,已验证 200)。
|
|
297
|
+
// 覆盖 dsh-plugin-console(本面板)这类"源码在 GitHub、npm 上不存在"的宿主插件。
|
|
298
|
+
let fallbackError = err instanceof Error ? err.message : String(err)
|
|
259
299
|
try {
|
|
260
|
-
const
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
// 子包配套检查:最新版声明依赖 vs 本地实际版本
|
|
269
|
-
const patchPath = findPatchPath(ctx)
|
|
270
|
-
const profileDir = dirname(patchPath)
|
|
271
|
-
const declared = latest ? data?.versions?.[latest]?.dependencies ?? {} : {}
|
|
272
|
-
const keys = typeof declared === 'object' ? Object.keys(declared) : []
|
|
273
|
-
for (const dep of keys) {
|
|
274
|
-
const required = String(declared[dep] ?? '').replace(/^[\^~>=< ]+/u, '')
|
|
275
|
-
if (!required) continue
|
|
276
|
-
let current = null
|
|
300
|
+
const meta = entryPkgMeta(packageName, ctx.baseUrl ?? 'file:///', profileDirOf(ctx))
|
|
301
|
+
const repo = typeof meta?.repository === 'string'
|
|
302
|
+
? meta.repository.replace(/^git\+/u, '').replace(/\.git$/u, '')
|
|
303
|
+
: (meta?.repository && typeof meta.repository === 'object' ? meta.repository.url : null)
|
|
304
|
+
const m = typeof repo === 'string' ? repo.match(/github\.com[/:]([^/]+\/[^/]+?)(?:\.git)?$/u) : null
|
|
305
|
+
if (m) {
|
|
306
|
+
let tag = null
|
|
307
|
+
// 通道 1:GitHub API(匿名可读,限流 60/h;token 时 5000/h)
|
|
277
308
|
try {
|
|
278
|
-
const
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
309
|
+
const auth = readGithubAuth()
|
|
310
|
+
const headers = { 'User-Agent': 'dsh-plugin-console' }
|
|
311
|
+
if (auth.token) headers.Authorization = `token ${auth.token}`
|
|
312
|
+
const release = await fetchJson(`https://api.github.com/repos/${m[1]}/releases/latest`, 12000, headers)
|
|
313
|
+
if (typeof release?.tag_name === 'string') tag = release.tag_name
|
|
283
314
|
} catch {}
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
}
|
|
287
|
-
}
|
|
288
|
-
} catch (err) {
|
|
289
|
-
// GitHub 发布回退(自身/未发布到 npm 的插件):npm registry 404/无 latest 时,
|
|
290
|
-
// 从已装包 package.json 的 repository 字段反查 GitHub 最新版本。
|
|
291
|
-
// 通道顺序:GitHub API(带 token)→ jsDelivr 版本 API(GitHub 黑洞期可用,已验证 200)。
|
|
292
|
-
// 覆盖 dsh-plugin-console(本面板)这类"源码在 GitHub、npm 上不存在"的宿主插件。
|
|
293
|
-
let fallbackError = err instanceof Error ? err.message : String(err)
|
|
294
|
-
try {
|
|
295
|
-
const meta = entryPkgMeta(packageName, ctx.baseUrl ?? 'file:///', profileDirOf(ctx))
|
|
296
|
-
const repo = typeof meta?.repository === 'string'
|
|
297
|
-
? meta.repository.replace(/^git\+/u, '').replace(/\.git$/u, '')
|
|
298
|
-
: (meta?.repository && typeof meta.repository === 'object' ? meta.repository.url : null)
|
|
299
|
-
const m = typeof repo === 'string' ? repo.match(/github\.com[/:]([^/]+\/[^/]+?)(?:\.git)?$/u) : null
|
|
300
|
-
if (m) {
|
|
301
|
-
let tag = null
|
|
302
|
-
// 通道 1:GitHub API(匿名可读,限流 60/h;token 时 5000/h)
|
|
315
|
+
// 通道 2:jsDelivr 版本列表(GitHub 直连黑洞时可用;取最高版本号)
|
|
316
|
+
if (tag === null) {
|
|
303
317
|
try {
|
|
304
|
-
const
|
|
305
|
-
const
|
|
306
|
-
|
|
307
|
-
const
|
|
308
|
-
|
|
318
|
+
const data = await fetchJson(`https://data.jsdelivr.com/v1/packages/gh/${m[1]}`, 12000)
|
|
319
|
+
const versions = Array.isArray(data?.versions) ? data.versions.map((v) => String(v.version ?? '')) : []
|
|
320
|
+
// 按语义版本号排序取最高(v 前缀剥离后比较)
|
|
321
|
+
const parsed = versions
|
|
322
|
+
.map((v) => ({ raw: v, ver: v.replace(/^v/iu, '') }))
|
|
323
|
+
.filter((x) => /^\d+\.\d+\.\d+/u.test(x.ver))
|
|
324
|
+
.sort((a, b) => {
|
|
325
|
+
const pa = a.ver.split(/[.-]/u).map((n) => (Number.isFinite(Number(n)) ? Number(n) : n))
|
|
326
|
+
const pb = b.ver.split(/[.-]/u).map((n) => (Number.isFinite(Number(n)) ? Number(n) : n))
|
|
327
|
+
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
|
328
|
+
const x = pa[i] ?? -1; const y = pb[i] ?? -1
|
|
329
|
+
if (x !== y) return typeof x === 'number' && typeof y === 'number' ? x - y : String(x) < String(y) ? -1 : 1
|
|
330
|
+
}
|
|
331
|
+
return 0
|
|
332
|
+
})
|
|
333
|
+
if (parsed.length > 0) tag = parsed[parsed.length - 1].raw
|
|
309
334
|
} catch {}
|
|
310
|
-
// 通道 2:jsDelivr 版本列表(GitHub 直连黑洞时可用;取最高版本号)
|
|
311
|
-
if (tag === null) {
|
|
312
|
-
try {
|
|
313
|
-
const data = await fetchJsonUrl(`https://data.jsdelivr.com/v1/packages/gh/${m[1]}`, 12000)
|
|
314
|
-
const versions = Array.isArray(data?.versions) ? data.versions.map((v) => String(v.version ?? '')) : []
|
|
315
|
-
// 按语义版本号排序取最高(v 前缀剥离后比较)
|
|
316
|
-
const parsed = versions
|
|
317
|
-
.map((v) => ({ raw: v, ver: v.replace(/^v/iu, '') }))
|
|
318
|
-
.filter((x) => /^\d+\.\d+\.\d+/u.test(x.ver))
|
|
319
|
-
.sort((a, b) => {
|
|
320
|
-
const pa = a.ver.split(/[.-]/u).map((n) => (Number.isFinite(Number(n)) ? Number(n) : n))
|
|
321
|
-
const pb = b.ver.split(/[.-]/u).map((n) => (Number.isFinite(Number(n)) ? Number(n) : n))
|
|
322
|
-
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
|
323
|
-
const x = pa[i] ?? -1; const y = pb[i] ?? -1
|
|
324
|
-
if (x !== y) return typeof x === 'number' && typeof y === 'number' ? x - y : String(x) < String(y) ? -1 : 1
|
|
325
|
-
}
|
|
326
|
-
return 0
|
|
327
|
-
})
|
|
328
|
-
if (parsed.length > 0) tag = parsed[parsed.length - 1].raw
|
|
329
|
-
} catch {}
|
|
330
|
-
}
|
|
331
|
-
if (tag !== null) {
|
|
332
|
-
latest = tag.replace(/^v/iu, '')
|
|
333
|
-
next = null
|
|
334
|
-
source = 'github'
|
|
335
|
-
error = null
|
|
336
|
-
fallbackError = null
|
|
337
|
-
}
|
|
338
335
|
}
|
|
339
|
-
if (
|
|
340
|
-
|
|
341
|
-
|
|
336
|
+
if (tag !== null) {
|
|
337
|
+
latest = tag.replace(/^v/iu, '')
|
|
338
|
+
next = null
|
|
339
|
+
source = 'github'
|
|
340
|
+
error = null
|
|
341
|
+
fallbackError = null
|
|
342
|
+
}
|
|
342
343
|
}
|
|
344
|
+
if (fallbackError !== null && latest === null) error = `npm 与 GitHub 均未检测到版本(${fallbackError})`
|
|
345
|
+
} catch (fbErr) {
|
|
346
|
+
error = `npm registry 查询失败且 GitHub 回退不可用(${fallbackError};${fbErr instanceof Error ? fbErr.message : String(fbErr)})`
|
|
343
347
|
}
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
}
|
|
364
|
-
|
|
365
|
-
}
|
|
366
|
-
|
|
367
|
-
|
|
348
|
+
}
|
|
349
|
+
// migrate 换名检测(2026-09-04 缺陷修复):本地包声明 dsh.migrate.to 时查目标包最新版/引擎声明,
|
|
350
|
+
// 识别「项目已改名/迁移发布」的更新(如 @linxin666/dsh-web-ui-all → @linxin666/dsh-web-all 0.3.14)
|
|
351
|
+
let migrate = null
|
|
352
|
+
try {
|
|
353
|
+
const localPath = resolvePackageJson(packageName, profileDir)
|
|
354
|
+
if (localPath !== null) {
|
|
355
|
+
const localPkg = JSON.parse(readFileSync(localPath, 'utf8'))
|
|
356
|
+
const to = typeof localPkg.dsh?.migrate?.to === 'string' ? localPkg.dsh.migrate.to : null
|
|
357
|
+
if (to !== null && to !== '' && to !== packageName) {
|
|
358
|
+
const encTo = to.startsWith('@')
|
|
359
|
+
? `@${encodeURIComponent(to.slice(1).split('/')[0])}%2f${encodeURIComponent(to.split('/').slice(1).join('/'))}`
|
|
360
|
+
: encodeURIComponent(to)
|
|
361
|
+
const meta = await fetchJson(`https://registry.npmmirror.com/${encTo}`)
|
|
362
|
+
const toLatest = meta?.['dist-tags']?.latest ?? meta?.['dist-tags']?.next ?? null
|
|
363
|
+
const toPkg = toLatest !== null ? (meta?.versions?.[toLatest] ?? null) : null
|
|
364
|
+
const engine = toPkg?.dsh?.engines?.dsh ?? toPkg?.engines?.dsh ?? null
|
|
365
|
+
const fwVer = currentFrameworkVersion(ctx)
|
|
366
|
+
const compatible = fwVer !== null && (engine === null || semverRangeMatchLoose(fwVer, engine))
|
|
367
|
+
migrate = { to, latest: toLatest, engine, compatible }
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
} catch {}
|
|
371
|
+
// 兜底(0.5.37):低于已装版本的候选不是"新版"(真机:镜像 latest=0.1.1-rc.1 < 已装 0.2.0-rc.2)
|
|
372
|
+
;({ latest, next, beta } = dropStaleUpdateCandidates({ latest, next, beta }, packageName, ctx.baseUrl ?? 'file:///', profileDirOf(ctx)))
|
|
373
|
+
return { latest, next, beta, depsOutdated, error, source, migrate }
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
async function routeCheckUpdate(req, res, rc) {
|
|
377
|
+
const body = rc.body
|
|
378
|
+
const packageName = packageNameOf(typeof body.packageName === 'string' ? body.packageName.trim() : '')
|
|
379
|
+
if (!packageName) {
|
|
380
|
+
sendError(res, 400, 'packageName 不能为空')
|
|
381
|
+
return
|
|
382
|
+
}
|
|
383
|
+
// 检测走**唯一实现** probePackageUpdate(与 /check-updates 批量入口同一份,见其函数注释)
|
|
384
|
+
const { latest, next, beta, depsOutdated, error, source, migrate } = await probePackageUpdate(packageName, rc, { fetchJson: rc.deps.fetchJson })
|
|
368
385
|
sendJson(res, 200, { ok: true, packageName, latest, next, beta, target: pickUpdateTarget({ latest, next, beta }), depsOutdated, error, source, migrate })
|
|
369
386
|
return
|
|
370
387
|
}
|
|
@@ -573,4 +590,4 @@ async function routeFrameworkStatusClear(req, res, rc) {
|
|
|
573
590
|
}
|
|
574
591
|
sendJson(res, 200, { ok: true, removed, failed, status: 'idle', message: null })
|
|
575
592
|
}
|
|
576
|
-
export { refuseWhenShellHosted, refuseWhenPlatformUnsupported, routeFrameworkStatusClear, routeFrameworkUpgradeStatusGet, routeFrameworkRelaunch, routeFrameworkCheck, routeCompatGate, routeCheckUpdate, routeFrameworkRollback, routeRestart }
|
|
593
|
+
export { refuseWhenShellHosted, refuseWhenPlatformUnsupported, routeFrameworkStatusClear, routeFrameworkUpgradeStatusGet, routeFrameworkRelaunch, routeFrameworkCheck, routeCompatGate, routeCheckUpdate, routeFrameworkRollback, routeRestart, probePackageUpdate }
|
|
@@ -18,6 +18,8 @@ import { routeRunSuggested } from './actions.js'
|
|
|
18
18
|
import { routeComponentAutostart, routeComponentStart, routeComponentStatus, routeComponentStop, routeComponents, routeRepoClone, routeRepoLandConfig, routeRepoList, routeRepoOpen, routeRepoRemove } from './components.js'
|
|
19
19
|
import { routeCompatAck, routeCompatMode, routeCompatStamp, routeCompatStatusGet, routeDeclareInstalled } from './compat.js'
|
|
20
20
|
import { routeSafeBoot } from './safe-boot.js'
|
|
21
|
+
// issue #22 ②③ 加法:用户备注 / 用户徽章 / 用户自建分组(独立文件,与插件列表完全解耦)
|
|
22
|
+
import { routeAnnotationsGet, routeAnnotationsPost } from './annotations.js'
|
|
21
23
|
import { routePatchAuditGet } from './patch-audit.js'
|
|
22
24
|
import { routePresetAuditGet } from './preset-audit.js'
|
|
23
25
|
// 0.5.84 加法(用户第 7 项):<DSH_HOME>/plugin-console 的磁盘体检(只读)与**确认后**清理
|
|
@@ -25,6 +27,8 @@ import { routeDiskAudit, routeDiskCleanup } from './disk.js'
|
|
|
25
27
|
import { routeFrameworkUpgrade } from './framework-upgrade.js'
|
|
26
28
|
import { routeFrameworkPreflight, routeFrameworkPreflightPatch } from './framework-preflight.js'
|
|
27
29
|
import { routeCheckUpdate, routeCompatGate, routeFrameworkCheck, routeFrameworkRelaunch, routeFrameworkRollback, routeFrameworkStatusClear, routeFrameworkUpgradeStatusGet, routeRestart } from './framework.js'
|
|
30
|
+
// issue #22 ① 加法:已安装插件「一键检测全部更新」(判据复用 routeCheckUpdate 那条唯一实现)
|
|
31
|
+
import { routeCheckUpdates } from './update-batch.js'
|
|
28
32
|
import { routeGithubLogin, routeGithubOpenLogin } from './github-login.js'
|
|
29
33
|
import { routeInstall, routeInstallStatus } from './install.js'
|
|
30
34
|
import { routeLockfileCheck, routeLockfileRepair } from './lockfile.js'
|
|
@@ -66,6 +70,8 @@ const ROUTES_EARLY = [
|
|
|
66
70
|
// 0.5.84 加法(用户第 7 项):磁盘体检**只读** ⇒ 必须住在 405 守卫之前的表里(声明 GET 就要走得到)。
|
|
67
71
|
{ methods: ['GET'], path: `${ROUTE_PREFIX}/disk-audit`, handler: routeDiskAudit },
|
|
68
72
|
{ methods: ['POST'], path: `${ROUTE_PREFIX}/framework-relaunch`, handler: routeFrameworkRelaunch },
|
|
73
|
+
// issue #22 ②③ 加法:注解**只读**(用户备注/徽章/分组)。纯只读 ⇒ 必须住在 405 守卫之前。
|
|
74
|
+
{ methods: ['GET'], path: `${ROUTE_PREFIX}/annotations`, handler: routeAnnotationsGet },
|
|
69
75
|
|
|
70
76
|
]
|
|
71
77
|
|
|
@@ -122,6 +128,9 @@ const ROUTES = [
|
|
|
122
128
|
{ methods: ['POST'], path: `${ROUTE_PREFIX}/install-status`, handler: routeInstallStatus }, { methods: ['POST'], path: `${ROUTE_PREFIX}/framework-check`, handler: routeFrameworkCheck },
|
|
123
129
|
{ methods: ['POST'], path: `${ROUTE_PREFIX}/compat-gate`, handler: routeCompatGate },
|
|
124
130
|
{ methods: ['POST'], path: `${ROUTE_PREFIX}/check-update`, handler: routeCheckUpdate },
|
|
131
|
+
// issue #22 ① 加法:批量检测(已安装列表的「一键检测全部更新」)。
|
|
132
|
+
// ★ 与 /check-update 共用同一个探针(probePackageUpdate),判据不分叉;并发上限在 domain/update-batch.js。
|
|
133
|
+
{ methods: ['POST'], path: `${ROUTE_PREFIX}/check-updates`, handler: routeCheckUpdates },
|
|
125
134
|
{ methods: ['POST'], path: `${ROUTE_PREFIX}/framework-upgrade`, handler: routeFrameworkUpgrade },
|
|
126
135
|
{ methods: ['POST'], path: `${ROUTE_PREFIX}/framework-preflight`, handler: routeFrameworkPreflight },
|
|
127
136
|
{ methods: ['POST'], path: `${ROUTE_PREFIX}/framework-preflight-patch`, handler: routeFrameworkPreflightPatch },
|
|
@@ -140,6 +149,9 @@ const ROUTES = [
|
|
|
140
149
|
// 安全启动材料恢复入口(2026-09-29 加法 D3-①,0.5.32):白名单动作 list / restore-last-good /
|
|
141
150
|
// disable-suspects / mark-good;只认动作名,绝不接受命令串;服务起不来时走 scripts/safe-boot.mjs
|
|
142
151
|
{ methods: ['POST'], path: `${ROUTE_PREFIX}/safe-boot`, handler: routeSafeBoot },
|
|
152
|
+
// issue #22 ②③ 加法:注解**写**动作(action = note / badges / group-*)。
|
|
153
|
+
// 写盘纪律全在 domain/plugin-annotations.js(备份 → 原子写 → 读回核实 → 回滚),路由只映射状态码。
|
|
154
|
+
{ methods: ['POST'], path: `${ROUTE_PREFIX}/annotations`, handler: routeAnnotationsPost },
|
|
143
155
|
{ methods: ['POST'], path: `${ROUTE_PREFIX}/framework-rollback`, handler: routeFrameworkRollback },
|
|
144
156
|
{ methods: ['POST'], path: `${ROUTE_PREFIX}/restart`, handler: routeRestart },
|
|
145
157
|
{ methods: ['POST'], path: `${ROUTE_PREFIX}/toggle`, handler: routeToggle },
|