@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
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// L1 · domain —— 安装/更新作业的**持久化**(0.5.83)
|
|
2
|
+
//
|
|
3
|
+
// 今天排障最大的障碍:installJobs 是**内存表**,宿主一重启就清空 ——
|
|
4
|
+
// 用户点了更新、作业卡住或失败,重启后我**查不到它到底干了什么**(本次连"版本号有没有传下去"都只能靠猜)。
|
|
5
|
+
// 与「待重启徽章」同一个病根(那个已用 pending-restart.json 修好)。
|
|
6
|
+
//
|
|
7
|
+
// 本模块把**已结束**的作业(done / failed)追加到 <DSH_HOME>/plugin-console/install-jobs.json:
|
|
8
|
+
// · 只保留最近 N 条(默认 50),文件不会无限增长;
|
|
9
|
+
// · 只记**诊断需要的字段**,不复制整个 job(避免把大对象/敏感信息落盘);
|
|
10
|
+
// · 任何异常都吞掉并降级为"没有持久记录"(绝不因为写日志失败影响安装本身)。
|
|
11
|
+
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'
|
|
12
|
+
import { dirname, join } from 'node:path'
|
|
13
|
+
import { dshHome } from '../infra/paths.js'
|
|
14
|
+
|
|
15
|
+
const INSTALL_JOBS_FILE = 'install-jobs.json'
|
|
16
|
+
const KEEP = 50
|
|
17
|
+
|
|
18
|
+
function installJobsPath(home = undefined) {
|
|
19
|
+
return join(home ?? dshHome(), 'plugin-console', INSTALL_JOBS_FILE)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** 只挑诊断需要的字段(多余的一律不落盘)。 */
|
|
23
|
+
function pickJobFields(job) {
|
|
24
|
+
if (job === null || typeof job !== 'object') return null
|
|
25
|
+
const id = typeof job.id === 'string' ? job.id : (typeof job.jobId === 'string' ? job.jobId : null)
|
|
26
|
+
if (id === null) return null
|
|
27
|
+
return {
|
|
28
|
+
jobId: id,
|
|
29
|
+
packageName: typeof job.packageName === 'string' ? job.packageName : null,
|
|
30
|
+
repo: typeof job.repo === 'string' ? job.repo : null,
|
|
31
|
+
status: typeof job.status === 'string' ? job.status : null,
|
|
32
|
+
stage: typeof job.stage === 'string' ? job.stage : null,
|
|
33
|
+
version: typeof job.version === 'string' ? job.version : null,
|
|
34
|
+
// ★ 0.5.87:目标版本(上一行)与**真正落盘的版本**是两件事 —— 真机 2026-10-18 就是
|
|
35
|
+
// "version=0.2.1-rc.2、盘上还是 0.1.6-alpha.1"却记成 done,事后没人能从这条记录里看出没落地。
|
|
36
|
+
installedVersion: typeof job.installedVersion === 'string' ? job.installedVersion : null,
|
|
37
|
+
error: typeof job.error === 'string' ? job.error.slice(0, 500) : null,
|
|
38
|
+
source: typeof job.source === 'string' ? job.source : null,
|
|
39
|
+
startedAt: Number.isFinite(Number(job.startedAt)) ? Number(job.startedAt) : null,
|
|
40
|
+
finishedAt: Number.isFinite(Number(job.finishedAt)) ? Number(job.finishedAt) : Date.now(),
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function readInstallJobs({ home = undefined } = {}) {
|
|
45
|
+
try {
|
|
46
|
+
const raw = JSON.parse(readFileSync(installJobsPath(home), 'utf8'))
|
|
47
|
+
const list = raw !== null && typeof raw === 'object' && Array.isArray(raw.items) ? raw.items : []
|
|
48
|
+
return list.filter((x) => x !== null && typeof x === 'object' && typeof x.jobId === 'string')
|
|
49
|
+
} catch { return [] }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* 记录一条**已结束**的作业;进行中的不写(否则每次轮询都写盘)。
|
|
54
|
+
* @returns {{ saved: boolean, reason?: string }}
|
|
55
|
+
*/
|
|
56
|
+
function recordInstallJob(job, { home = undefined } = {}) {
|
|
57
|
+
const picked = pickJobFields(job)
|
|
58
|
+
if (picked === null) return { saved: false, reason: 'job 形状不可识别' }
|
|
59
|
+
if (picked.status !== 'done' && picked.status !== 'failed') return { saved: false, reason: '作业尚未结束' }
|
|
60
|
+
try {
|
|
61
|
+
const prev = readInstallJobs({ home }).filter((x) => x.jobId !== picked.jobId)
|
|
62
|
+
const items = [...prev, picked].slice(-KEEP)
|
|
63
|
+
const file = installJobsPath(home)
|
|
64
|
+
mkdirSync(dirname(file), { recursive: true })
|
|
65
|
+
writeFileSync(file, `${JSON.stringify({ items }, null, 2)}\n`, 'utf8')
|
|
66
|
+
return { saved: true }
|
|
67
|
+
} catch (error) {
|
|
68
|
+
return { saved: false, reason: error instanceof Error ? error.message : String(error) }
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export { INSTALL_JOBS_FILE, installJobsPath, readInstallJobs, recordInstallJob }
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// L1 · domain —— install-version.js(「更新到的到底是哪一版」的**唯一判据**;2026-10-18 新增,纯加法)
|
|
2
|
+
//
|
|
3
|
+
// 真机缺陷(2026-10-18,用户实测,父任务已确认现场):
|
|
4
|
+
// 点「更新并适配」`@deepseek-ai/dsh-experimental-auto-review`(0.1.6-alpha.1 → 0.2.1-rc.2),
|
|
5
|
+
// 请求体显式带 `version: "0.2.1-rc.2"`,作业跑完**报 done、version=0.2.1-rc.2、error=null**,
|
|
6
|
+
// 而磁盘与清单**都还是 0.1.6-alpha.1**。
|
|
7
|
+
// 根因不是"版本号没传下去",而是**没人核对最终落地的是不是目标版本**:
|
|
8
|
+
// 该包在 registry 上的 `dist-tags.latest` 恰好就是旧版 0.1.6-alpha.1(rc 版从不打 latest),
|
|
9
|
+
// 于是"按最新版下载"的通道(curl)把**旧版**铺回 node_modules,凡是"按名报成功"的判据都看不出异常。
|
|
10
|
+
//
|
|
11
|
+
// 本模块只做一件此前没人做的事:**把请求里点名的版本与磁盘上真实的版本比一比**。
|
|
12
|
+
// 为什么不塞进 install-verify.js:那个模块的契约是"只提示、绝不改成功判定"(见它的顶部硬规矩①),
|
|
13
|
+
// 而这里的结论**必须能否决一次成功**——两者语义相反,放一个文件里迟早会被下一个人混用。
|
|
14
|
+
//
|
|
15
|
+
// 三条硬规矩:
|
|
16
|
+
// ① 只读:只读 node_modules/<包名>/package.json 的 version 字段,绝不写盘、绝不改动清单;
|
|
17
|
+
// ② 只对"点名了版本"的请求生效(`job.version` 为空 = 照旧按 latest,判据恒 null,行为一字不变);
|
|
18
|
+
// ③ 绝不猜:读不到版本时 actual 记 null,**不**据此报"不是目标版本"以外的任何结论(见 versionMissText)。
|
|
19
|
+
import { readInstalledVersion } from './selfupdate.js'
|
|
20
|
+
|
|
21
|
+
/** 这次作业**点名**要装的那个版本(没有点名 / 不是点名的那个包 ⇒ null)。
|
|
22
|
+
*
|
|
23
|
+
* 为什么必须逐字比包名:`job.version` 是"目标包的目标版本",而候选循环里的 `name` 可能是自动展开出的
|
|
24
|
+
* 任意子包(聚合仓库)。给子包套用父候选的版本号=给一个根本没发布这个版本号的包下精确规格,
|
|
25
|
+
* 会把"本来能装"变成"404 装不上"(与 domain/install-channels.js 顶部 ① 条同一条理由)。
|
|
26
|
+
* 版本号只对**用户点名的那一件**成立 —— 传进来的 name 必须与 job.packageName 逐字相等。 */
|
|
27
|
+
function targetVersionOf(job, name) {
|
|
28
|
+
const version = typeof job?.version === 'string' ? job.version.trim() : ''
|
|
29
|
+
if (version === '' || typeof name !== 'string' || name === '') return null
|
|
30
|
+
return name === job.packageName ? version : null
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* 磁盘上真实的版本 vs 目标版本(**只读**,永不抛)。
|
|
35
|
+
* 返回 null 表示"无需/无法判定":
|
|
36
|
+
* · 没点名版本(按 latest 装的请求)—— 既有流程一字不动;
|
|
37
|
+
* · 磁盘上就是目标版本 —— 通道这次真的把版本装对了。
|
|
38
|
+
* 返回 `{ kind:'version-miss', packageName, expected, actual, channel }` 表示**没装上目标版本**。
|
|
39
|
+
* `channel` 由调用方填(哪个通道声称装上了),只用于如实告知,不参与判定。
|
|
40
|
+
*/
|
|
41
|
+
function versionMissOf(profileDir, name, job, channel = null) {
|
|
42
|
+
const expected = targetVersionOf(job, name)
|
|
43
|
+
if (expected === null) return null
|
|
44
|
+
const actual = readInstalledVersion(profileDir, name)
|
|
45
|
+
if (actual === expected) return null
|
|
46
|
+
return {
|
|
47
|
+
kind: 'version-miss',
|
|
48
|
+
packageName: name,
|
|
49
|
+
expected,
|
|
50
|
+
actual: typeof actual === 'string' && actual !== '' ? actual : null,
|
|
51
|
+
channel: typeof channel === 'string' && channel !== '' ? channel : null,
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** 一句话如实说清"哪一版没装上、盘上现在是什么"(作业 error / 通道备注共用一份文案)。 */
|
|
56
|
+
function versionMissText(miss) {
|
|
57
|
+
if (miss === null || typeof miss !== 'object') return null
|
|
58
|
+
const actual = miss.actual === null || miss.actual === undefined ? '(磁盘上读不到版本)' : String(miss.actual)
|
|
59
|
+
const via = miss.channel === null || miss.channel === undefined ? '' : `${miss.channel} 通道`
|
|
60
|
+
return `目标版本 ${miss.expected} 没有装上:${via === '' ? '本次' : via}落地的是 ${actual}`
|
|
61
|
+
+ '(多半是镜像的 dist-tags.latest 还指着旧版,按"最新版"下载就会拿到它)。'
|
|
62
|
+
+ '已保留磁盘上现有的这一版、清单未改动;可换源后重试,或稍等镜像同步。'
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export { targetVersionOf, versionMissOf, versionMissText }
|
|
@@ -16,6 +16,8 @@ import { downloadReleaseArtifact, releaseInstallTarget, selectReleaseInstall } f
|
|
|
16
16
|
import { verifyTarballDigest } from './install-verify.js'
|
|
17
17
|
import { presetJobView } from './preset-install.js'
|
|
18
18
|
import { readBundlePatchRefNames } from './bundle-refs.js'
|
|
19
|
+
// 盒子验证(装前静态校验)2026-10-18 搬进 install-box.js(本文件贴着 600 行硬顶);名字与语义一字未变。
|
|
20
|
+
import { parseBundlePatchRefs, verifyPackageBox } from './install-box.js'
|
|
19
21
|
|
|
20
22
|
/** bundle 包判定:声明 dsh.bundle 的包一律按官方 `dsh plugin add` 行为追加为
|
|
21
23
|
* profile bundle 层(其 cordis.patch.yml 的插入行在下次启动时组合进树)。
|
|
@@ -198,11 +200,19 @@ async function curlManualInstall(profileDir, packageName, registries, signal = n
|
|
|
198
200
|
* 修法:补上第三个出口 —— 两条通道都 settle(无论成败)即刻收工;同时把定时器清掉,
|
|
199
201
|
* 否则每次竞速都会留下一个 120 秒的挂起定时器,拖住进程退出。
|
|
200
202
|
* 第 4 个参数是可选注入(单测用:把两条通道换成桩,才能离线断言"都失败 → 立刻收工"的时延语义;
|
|
201
|
-
* capMs 也只是给单测缩短兜底时长,生产一律用默认 120 秒)。
|
|
202
|
-
|
|
203
|
+
* capMs 也只是给单测缩短兜底时长,生产一律用默认 120 秒)。
|
|
204
|
+
*
|
|
205
|
+
* ★ 0.5.87 改错(真机 2026-10-18):第 1 个规格参数**同时喂给两条腿**是错的 —— pnpm 要的是
|
|
206
|
+
* `name@version`,而 curl 要的是**裸包名 + 目标版本**(`pkg@1.2.3` 对 curl 是非法包名,必然 404)。
|
|
207
|
+
* 第 5 个参数是可选扩展 `{ packageName, exactVersion }`:给了就按它跑 curl 腿(没给则与旧行为逐字相同:
|
|
208
|
+
* 用 `name` 当包名、不传版本 ⇒ 取 dist-tags.latest)。旧调用点与旧单测一个都不用改。 */
|
|
209
|
+
async function raceInstallChannels(profileDir, name, registries, impls = {}, opts = {}) {
|
|
203
210
|
const runPnpm = typeof impls.pnpmInstall === 'function' ? impls.pnpmInstall : pnpmInstall
|
|
204
211
|
const runCurl = typeof impls.curlManualInstall === 'function' ? impls.curlManualInstall : curlManualInstall
|
|
205
212
|
const capMs = Number.isFinite(impls.capMs) && impls.capMs > 0 ? impls.capMs : 120000
|
|
213
|
+
// curl 腿的包名/版本(见上方 ★):只认非空字符串,其余一律回落旧形态
|
|
214
|
+
const curlName = typeof opts.packageName === 'string' && opts.packageName !== '' ? opts.packageName : name
|
|
215
|
+
const curlVersion = typeof opts.exactVersion === 'string' && opts.exactVersion !== '' ? opts.exactVersion : null
|
|
206
216
|
const controller = new AbortController()
|
|
207
217
|
const signal = controller.signal
|
|
208
218
|
const pnpmTask = (async () => {
|
|
@@ -219,7 +229,7 @@ async function raceInstallChannels(profileDir, name, registries, impls = {}) {
|
|
|
219
229
|
throw lastError ?? new Error('pnpm 通道失败')
|
|
220
230
|
})()
|
|
221
231
|
const curlTask = (async () => {
|
|
222
|
-
const info = await runCurl(profileDir,
|
|
232
|
+
const info = await runCurl(profileDir, curlName, registries, signal, curlVersion)
|
|
223
233
|
return { channel: 'curl', info }
|
|
224
234
|
})()
|
|
225
235
|
const waitSuccess = (promise) => promise.then((value) => ({ value }), () => new Promise(() => {}))
|
|
@@ -236,73 +246,6 @@ async function raceInstallChannels(profileDir, name, registries, impls = {}) {
|
|
|
236
246
|
}
|
|
237
247
|
}
|
|
238
248
|
|
|
239
|
-
/**
|
|
240
|
-
* 盒子实验验证:解压后的包目录先通过静态校验再允许覆盖正式位置。
|
|
241
|
-
* - package.json 必须可解析且 name 与安装目标一致
|
|
242
|
-
* - main / exports 入口文件必须真实存在(防"装上了但加载即崩")
|
|
243
|
-
* - bundle patch(cordis.patch.yml)引用的包必须已就位(防聚合包引用缺失崩溃)
|
|
244
|
-
* 失败抛错 → 调用方保留旧版本(安装不中断服务)。
|
|
245
|
-
*/
|
|
246
|
-
function verifyPackageBox(pkgPath, profileDir, packageName, refCheckRoot = null) {
|
|
247
|
-
const note = []
|
|
248
|
-
const pkgRaw = readFileSync(join(pkgPath, 'package.json'), 'utf8')
|
|
249
|
-
const pkg = JSON.parse(pkgRaw)
|
|
250
|
-
if (pkg.name !== packageName) {
|
|
251
|
-
throw new Error(`盒子验证失败:包名不符(tarball 内为 ${pkg.name},期望 ${packageName}),已保留旧版本`)
|
|
252
|
-
}
|
|
253
|
-
// 入口存在性:main 字段 / exports.'.'(字符串或对象 default)指向的文件必须存在
|
|
254
|
-
let entry = typeof pkg.main === 'string' ? pkg.main : null
|
|
255
|
-
if (entry === null && pkg.exports && typeof pkg.exports === 'object') {
|
|
256
|
-
const dot = pkg.exports['.'] ?? pkg.exports['./package.json'] === undefined ? pkg.exports['.'] : null
|
|
257
|
-
if (typeof dot === 'string') entry = dot
|
|
258
|
-
else if (dot && typeof dot === 'object') entry = typeof dot.default === 'string' ? dot.default : null
|
|
259
|
-
}
|
|
260
|
-
if (entry !== null) {
|
|
261
|
-
const entryPath = join(pkgPath, ...entry.split('/'))
|
|
262
|
-
if (!existsSync(entryPath)) {
|
|
263
|
-
throw new Error(`盒子验证失败:入口文件缺失(${entry}),已保留旧版本`)
|
|
264
|
-
}
|
|
265
|
-
}
|
|
266
|
-
// bundle patch 引用预检:引用的包必须已存在于目标 node_modules(防聚合包半更新崩溃)。
|
|
267
|
-
// refCheckRoot 指定检查根(宿主插件在根层 node_modules,与 profile 层不同)。
|
|
268
|
-
const checkRoot = refCheckRoot ?? join(profileDir, 'node_modules')
|
|
269
|
-
try {
|
|
270
|
-
const patchRel = pkg.dsh?.bundle?.patch
|
|
271
|
-
if (typeof patchRel === 'string') {
|
|
272
|
-
const bundlePatch = join(pkgPath, patchRel)
|
|
273
|
-
if (existsSync(bundlePatch)) {
|
|
274
|
-
const refs = parseBundlePatchRefs(readFileSync(bundlePatch, 'utf8'))
|
|
275
|
-
const missingRefs = refs.filter((r) => !existsSync(join(checkRoot, r.name)))
|
|
276
|
-
if (missingRefs.length > 0) {
|
|
277
|
-
note.push(`bundle 引用缺失:${missingRefs.map((r) => r.name).join('、')}(安装后由聚合完整性检查补齐/禁用)`)
|
|
278
|
-
}
|
|
279
|
-
if (refs.length > 0) note.push(`bundle 引用 ${refs.length} 个已就位`)
|
|
280
|
-
}
|
|
281
|
-
}
|
|
282
|
-
} catch (error) {
|
|
283
|
-
if (error instanceof Error && error.message.startsWith('盒子验证失败')) throw error
|
|
284
|
-
}
|
|
285
|
-
return { note: note.length > 0 ? note.join(';') : null }
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
/** 解析 bundle patch(cordis.patch.yml)的 insert 引用列表(id + 包名)。 */
|
|
289
|
-
function parseBundlePatchRefs(text) {
|
|
290
|
-
const refs = []
|
|
291
|
-
const lines = text.split(/\r?\n/u)
|
|
292
|
-
let inInsert = false
|
|
293
|
-
for (let i = 0; i < lines.length; i += 1) {
|
|
294
|
-
const line = lines[i]
|
|
295
|
-
if (/^- insert:\s*$/u.test(line)) { inInsert = true; continue }
|
|
296
|
-
if (/^- /u.test(line) && !/^ {4}- /u.test(line)) inInsert = false
|
|
297
|
-
if (!inInsert) continue
|
|
298
|
-
const idM = line.match(/^ {4}- id: ([A-Za-z0-9_.-]+)\s*$/u)
|
|
299
|
-
if (!idM) continue
|
|
300
|
-
const nameM = (lines[i + 1] ?? '').match(/^ {6}name: ['"]([^'"]+)['"]\s*$/u)
|
|
301
|
-
if (nameM) refs.push({ id: idM[1], name: nameM[1] })
|
|
302
|
-
}
|
|
303
|
-
return refs
|
|
304
|
-
}
|
|
305
|
-
|
|
306
249
|
/**
|
|
307
250
|
* 捆绑依赖补装(事故教训:dsh-web-ui-all 更新后 17 个捆绑依赖缺失 → 服务加载崩溃):
|
|
308
251
|
* curl/GitHub 通道只解压主包,这里逐个补装直接依赖(pnpm → curl 依次尝试)。
|
|
@@ -513,7 +456,11 @@ function installJobView(job) {
|
|
|
513
456
|
entryId: job.entryId ?? null,
|
|
514
457
|
bundle: job.bundle ?? false,
|
|
515
458
|
ai: job.ai ?? false,
|
|
516
|
-
aiNote: job.aiNote ?? null,
|
|
459
|
+
aiNote: job.aiNote ?? null, version: job.version ?? null, // ★ 0.5.82:目标版本一并投影
|
|
460
|
+
// ★ 0.5.87(真机 2026-10-18):上面那个是**请求点名的目标版本**,不等于装上了什么 ——
|
|
461
|
+
// 本次真正落到磁盘的版本 + 版本闸的结论(`versionMiss` 非空 ⇒ 目标版本没装上,作业必为 failed)。
|
|
462
|
+
// 面板拿到这两项才分得清"更新到 0.2.1-rc.2"与"报了个目标版本号、盘上还是旧版"。
|
|
463
|
+
installedVersion: job.installedVersion ?? null, versionMiss: job.versionMiss ?? null,
|
|
517
464
|
subpackages: job.subpackages ?? null,
|
|
518
465
|
source: job.source ?? 'github',
|
|
519
466
|
curlNote: job.curlNote ?? null,
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
// · 任何单个包出问题都**不抛**,如实降级成该行的一个字段(一份清单不能因为一行坏掉就整体消失)。
|
|
20
20
|
//
|
|
21
21
|
// 分层:L1 domain —— 不认识 cordis ctx;home / profileDir / patchPath 全部由调用方解开后传入。
|
|
22
|
-
import { readFileSync } from 'node:fs'
|
|
22
|
+
import { readFileSync, readdirSync, statSync } from 'node:fs'
|
|
23
23
|
import { join } from 'node:path'
|
|
24
24
|
import { canonicalSourceSpec, sourceSpecTarget } from './dep-source.js'
|
|
25
25
|
import { planLinkSelfSufficiency, listSelfSufficiencyRecords } from './link-self-sufficiency.js'
|
|
@@ -29,6 +29,34 @@ const readJsonSafe = (file) => {
|
|
|
29
29
|
try { return JSON.parse(readFileSync(file, 'utf8')) } catch { return null }
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
/** 该目录里不参与"改动时间"的条目(`node_modules` 上万文件、`.git` 每次操作都在动,两者都不代表"我在改这个插件")。 */
|
|
33
|
+
const MTIME_IGNORED_ENTRIES = new Set(['node_modules', '.git'])
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* 目标目录的**改动时间**(ISO;拿不到就是 null,绝不编造)。
|
|
37
|
+
*
|
|
38
|
+
* 为什么不是只取目录本身:**Windows 上改文件内容不会更新目录的 mtime**(只有增删条目才会),
|
|
39
|
+
* 只取目录会让人看到"我明明刚改过、时间还是上周" —— 而这个字段的用途恰恰是「这是不是我正在改的那个」。
|
|
40
|
+
* 为什么不做递归:`node_modules` 动辄上万文件,一次递归会把 `/links` 拖慢一个数量级;
|
|
41
|
+
* 浅扫(目录 + 它**直接下一层**,跳过 node_modules/.git)已经覆盖 package.json / 入口文件 /
|
|
42
|
+
* `src` 这类常见改动点的 mtime,成本是一次 readdir + N 次 stat。
|
|
43
|
+
* 拿不到(目录被删/无权限)一律 null,由面板渲染成「—」。
|
|
44
|
+
*/
|
|
45
|
+
function targetMtimeOf(dir) {
|
|
46
|
+
let newest = 0
|
|
47
|
+
try { newest = statSync(dir).mtimeMs } catch { return null }
|
|
48
|
+
let entries = []
|
|
49
|
+
try { entries = readdirSync(dir, { withFileTypes: true }) } catch { return new Date(newest).toISOString() }
|
|
50
|
+
for (const entry of entries) {
|
|
51
|
+
if (MTIME_IGNORED_ENTRIES.has(entry.name)) continue
|
|
52
|
+
try {
|
|
53
|
+
const when = statSync(join(dir, entry.name)).mtimeMs
|
|
54
|
+
if (when > newest) newest = when
|
|
55
|
+
} catch {}
|
|
56
|
+
}
|
|
57
|
+
return new Date(newest).toISOString()
|
|
58
|
+
}
|
|
59
|
+
|
|
32
60
|
/** 路径型来源(`link:` / `file:`)—— 只有这一族是"开发式安装",才有目标目录可体检。 */
|
|
33
61
|
const PATH_SPEC_RE = /^(?:link:|file:)/u
|
|
34
62
|
|
|
@@ -78,6 +106,10 @@ function buildLinkInventory({ profileDir, patchPath = null, entries = null, home
|
|
|
78
106
|
spec,
|
|
79
107
|
target: null,
|
|
80
108
|
targetExists: false,
|
|
109
|
+
// ★ 0.5.87 加法:目标目录的最后修改时间(ISO 字符串;拿不到就是 null,由面板渲染成「—」)。
|
|
110
|
+
// 为什么需要:开发者常常同时有 plugin-src 下的好几个副本,「这是不是我正在改的那个」
|
|
111
|
+
// 只能靠时间判断。**绝不编造**:stat 失败一律 null,不写 0、不写当前时间。
|
|
112
|
+
targetMtime: null,
|
|
81
113
|
version: null,
|
|
82
114
|
ready: null,
|
|
83
115
|
missingDeps: [],
|
|
@@ -104,8 +136,13 @@ function buildLinkInventory({ profileDir, patchPath = null, entries = null, home
|
|
|
104
136
|
item.foreignPeers = plan.foreignPeers ?? []
|
|
105
137
|
item.note = plan.note ?? plan.reason ?? null
|
|
106
138
|
if (item.target !== null && item.targetExists) {
|
|
107
|
-
|
|
108
|
-
|
|
139
|
+
// 版本与修改时间各自独立 try:读 package.json 坏掉不该连"文件是什么时候动的"也一并丢掉,
|
|
140
|
+
// 反之 stat 失败也不该吞掉版本号。
|
|
141
|
+
try {
|
|
142
|
+
const pkg = readJsonSafe(join(item.target, 'package.json'))
|
|
143
|
+
item.version = typeof pkg?.version === 'string' ? pkg.version : null
|
|
144
|
+
} catch {}
|
|
145
|
+
try { item.targetMtime = targetMtimeOf(item.target) } catch { item.targetMtime = null }
|
|
109
146
|
}
|
|
110
147
|
} catch (error) {
|
|
111
148
|
item.error = error instanceof Error ? error.message : String(error)
|
|
@@ -139,4 +176,4 @@ function isLinkTargetOfProfile(profileDir, candidate) {
|
|
|
139
176
|
return false
|
|
140
177
|
}
|
|
141
178
|
|
|
142
|
-
export { PATH_SPEC_RE, pathDepsOf, buildLinkInventory, isLinkTargetOfProfile }
|
|
179
|
+
export { PATH_SPEC_RE, MTIME_IGNORED_ENTRIES, targetMtimeOf, pathDepsOf, buildLinkInventory, isLinkTargetOfProfile }
|
|
@@ -327,6 +327,72 @@ async function ensureLinkSelfSufficiency({ profileDir, packageName, spec = null,
|
|
|
327
327
|
}
|
|
328
328
|
}
|
|
329
329
|
|
|
330
|
+
/**
|
|
331
|
+
* **定点**补齐:只做用户点的那一件事(0.5.87 加法,给面板的 [补齐依赖] / [造垫片] 两个按钮)。
|
|
332
|
+
*
|
|
333
|
+
* 与 `ensureLinkSelfSufficiency` 的关系:那个是**安装主路径**上的"缺什么补什么"(全做,别改它);
|
|
334
|
+
* 本函数是"点哪个按钮做哪件事"的定点版。两条出路本来就不是一回事 ——
|
|
335
|
+
* · `deps` = 在链接目标里跑 pnpm install(补 dependencies);
|
|
336
|
+
* · `shims` = 只建框架 peer 垫片(绝不跑 pnpm、绝不联网);
|
|
337
|
+
* · `all` = 两件都做(等价于主路径的顺序:先依赖、后垫片,因为 pnpm install 会修剪 node_modules)。
|
|
338
|
+
* 为什么必须分开:共用一份实现会让 [造垫片] 顺手跑一次 pnpm install —— 那是**用户没要求的写盘**;
|
|
339
|
+
* 反之 [补齐依赖] 顺手建垫片则会让"只补依赖"变成两种副作用。
|
|
340
|
+
*
|
|
341
|
+
* 判据与写盘全部复用同族函数(planLinkSelfSufficiency / installTargetDependencies /
|
|
342
|
+
* createPeerShims / writeSelfSufficiencyRecord / composeSelfSufficiencyNote),**不新写**任何判据。
|
|
343
|
+
* 任何情况都**不抛**;`ok` = 这次尝试本身没出意外,`ready` 才是"现在自足了吗"。
|
|
344
|
+
*/
|
|
345
|
+
async function repairLinkTarget({ profileDir, packageName, kind = 'all', spec = null, home = null, deps = {} } = {}) {
|
|
346
|
+
const homeDir = typeof home === 'string' && home !== '' ? home : dshHome()
|
|
347
|
+
const wanted = kind === 'deps' ? 'deps' : kind === 'shims' ? 'shims' : 'all'
|
|
348
|
+
const empty = { ok: true, kind: wanted, applies: false, ready: true, changed: false, installed: null, created: [], skipped: [], note: null, plan: null }
|
|
349
|
+
try {
|
|
350
|
+
const plan = planLinkSelfSufficiency({ profileDir, packageName, spec, home: homeDir })
|
|
351
|
+
if (plan.applies !== true) return { ...empty, note: plan.reason, plan }
|
|
352
|
+
if (plan.targetExists !== true) return { ...empty, applies: true, ready: false, note: plan.note, plan }
|
|
353
|
+
// 幂等 + 不谎报:该做的那件事**本来就没有对象**时,如实说"没有可补的",一个字节都不写。
|
|
354
|
+
if (wanted === 'deps' && plan.missingDeps.length === 0) {
|
|
355
|
+
return { ...empty, applies: true, ready: plan.ready === true, note: `${packageName}:没有缺的 dependencies(依赖已齐,未改动任何东西)`, plan }
|
|
356
|
+
}
|
|
357
|
+
if (wanted === 'shims' && plan.shimmablePeers.length === 0) {
|
|
358
|
+
const why = plan.missingPeers.length === 0
|
|
359
|
+
? '没有缺的框架 peer(未改动任何东西)'
|
|
360
|
+
: `缺的 peer 都不在框架根里、造不出垫片(${plan.unavailablePeers.join(', ')})—— 未改动任何东西`
|
|
361
|
+
return { ...empty, applies: true, ready: plan.ready === true, note: `${packageName}:${why}`, plan }
|
|
362
|
+
}
|
|
363
|
+
if (wanted === 'all' && plan.ready === true) {
|
|
364
|
+
return { ...empty, applies: true, ready: true, note: `${packageName}:链接目标已自足(未改动任何东西)`, plan }
|
|
365
|
+
}
|
|
366
|
+
let installed = null
|
|
367
|
+
if (wanted !== 'shims' && plan.missingDeps.length > 0) installed = await installTargetDependencies({ target: plan.target, deps })
|
|
368
|
+
let created = []
|
|
369
|
+
let skipped = []
|
|
370
|
+
if (wanted !== 'deps') {
|
|
371
|
+
// 顺序与安装主路径一致:先依赖、后垫片(pnpm install 会把先建好的垫片修剪掉)
|
|
372
|
+
const afterDeps = planLinkSelfSufficiency({ profileDir, packageName, spec: plan.spec, home: homeDir })
|
|
373
|
+
const shimmed = createPeerShims({ target: plan.target, peers: afterDeps.shimmablePeers, home: homeDir, deps })
|
|
374
|
+
created = shimmed.created
|
|
375
|
+
skipped = shimmed.skipped
|
|
376
|
+
if (created.length > 0) writeSelfSufficiencyRecord(homeDir, packageName, created)
|
|
377
|
+
}
|
|
378
|
+
const finalPlan = planLinkSelfSufficiency({ profileDir, packageName, spec: plan.spec, home: homeDir })
|
|
379
|
+
return {
|
|
380
|
+
ok: true,
|
|
381
|
+
kind: wanted,
|
|
382
|
+
applies: true,
|
|
383
|
+
ready: finalPlan.ready === true,
|
|
384
|
+
changed: created.length > 0 || installed?.ok === true,
|
|
385
|
+
installed,
|
|
386
|
+
created,
|
|
387
|
+
skipped,
|
|
388
|
+
note: composeSelfSufficiencyNote({ packageName, installed, created, skipped, finalPlan }),
|
|
389
|
+
plan: finalPlan,
|
|
390
|
+
}
|
|
391
|
+
} catch (error) {
|
|
392
|
+
return { ...empty, ok: false, applies: true, ready: false, note: `${packageName}:补齐时出错(${String(error?.message ?? error).slice(0, 300)})`, plan: null }
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
330
396
|
/** 列出全部自足性记录(报告/体检用;读不到就是空数组)。 */
|
|
331
397
|
function listSelfSufficiencyRecords({ home = null } = {}) {
|
|
332
398
|
const homeDir = typeof home === 'string' && home !== '' ? home : dshHome()
|
|
@@ -408,6 +474,7 @@ export {
|
|
|
408
474
|
selfSufficiencyRecordPath,
|
|
409
475
|
planLinkSelfSufficiency,
|
|
410
476
|
ensureLinkSelfSufficiency,
|
|
477
|
+
repairLinkTarget,
|
|
411
478
|
removeLinkSelfSufficiency,
|
|
412
479
|
listSelfSufficiencyRecords,
|
|
413
480
|
installTargetDependencies,
|