@noob-stupid/dsh-plugin-console 0.5.40 → 0.5.41
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 +2 -4
- package/README.zh.md +2 -3
- package/lib/server/domain/install-finalize.js +45 -0
- package/lib/server/domain/install-job.js +50 -39
- package/lib/server/domain/install.js +8 -3
- package/lib/server/domain/manifest.js +69 -12
- package/lib/server/domain/persist.js +37 -4
- package/lib/server/domain/plugin-actions.js +67 -4
- package/lib/server/domain/selfupdate.js +9 -2
- package/lib/server/domain/source-spec.js +212 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -303,9 +303,7 @@ Environment reminder: an accelerator/proxy that rewrites hosts makes the direct
|
|
|
303
303
|
|
|
304
304
|
#### Development installs (`link:`)
|
|
305
305
|
|
|
306
|
-
|
|
307
|
-
their **real source spec** to the package manager (`pnpm add <spec>`) — dependencies and peers are
|
|
308
|
-
guaranteed by pnpm, on the same channel as the official `dsh plugin add`.
|
|
306
|
+
Ordinary installs go through the package manager (`pnpm add`); that is the baseline, not a feature here.
|
|
309
307
|
|
|
310
308
|
`link:` is the separate, **explicit** route: mount a **local directory / plugin you are developing**
|
|
311
309
|
directly into the profile.
|
|
@@ -313,7 +311,7 @@ directly into the profile.
|
|
|
313
311
|
- **Source edits take effect immediately**: `node_modules/<name>` in the profile is a **link** to your
|
|
314
312
|
local directory, not a copy — edit the source, refresh the page. No reinstall, no repackaging.
|
|
315
313
|
- **Who it is for**: plugin authors, or anyone who wants to edit a plugin's source. If you just want
|
|
316
|
-
to *use* a plugin,
|
|
314
|
+
to *use* a plugin, an ordinary install is simpler.
|
|
317
315
|
- **What it pins**: the local artifact is written into the profile manifest `dependencies`
|
|
318
316
|
(`link:<absolute path>`) and gets a matching entry in `pnpm-lock.yaml` — so lockfile health checks
|
|
319
317
|
and rebuilds never treat it as an orphan, and it **survives restarts and instance updates**.
|
package/README.zh.md
CHANGED
|
@@ -238,14 +238,13 @@ git clone https://github.com/Noob-stupid/dsh-plugin-gating-hub /tmp/dsh-plugin-c
|
|
|
238
238
|
|
|
239
239
|
#### 开发式安装(`link:`)
|
|
240
240
|
|
|
241
|
-
|
|
242
|
-
(`pnpm add <spec>`)——依赖与 peer 由 pnpm 保证,与官方 `dsh plugin add` 同一条通道。
|
|
241
|
+
普通安装走包管理器(`pnpm add`)—— 那是基线,不是这里的卖点。
|
|
243
242
|
|
|
244
243
|
`link:` 是**显式**的另一条路:把**本地目录 / 正在开发的插件**直接挂进 profile。
|
|
245
244
|
|
|
246
245
|
- **改源码即时生效**:profile 里的 `node_modules/<包名>` 是一条指向本地目录的**链接**,不是副本 ——
|
|
247
246
|
改完源码刷新页面即可,无需重装、无需重新打包。
|
|
248
|
-
- **适合谁**:插件作者,或想直接改某个插件源码的人。只想"装个插件来用"
|
|
247
|
+
- **适合谁**:插件作者,或想直接改某个插件源码的人。只想"装个插件来用"的话,普通安装更省心。
|
|
249
248
|
- **它钉住什么**:本地那份物会被写进 profile 清单的 `dependencies`(`link:<绝对路径>`),并在
|
|
250
249
|
`pnpm-lock.yaml` 里留下对应条目 —— 所以「依赖锁体检 / 重建」不会把它当多余项清掉,
|
|
251
250
|
**重启与实例更新后依然在**。
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// L1 · domain —— install-finalize.js(安装收口结果 → 作业字段的**唯一映射**;2026-10-06 从 install-job.js 搬出)
|
|
2
|
+
//
|
|
3
|
+
// 为什么单独成模块:install-job.js 贴着 600 行架构棘轮(tests/test-architecture-guard.mjs),而这块
|
|
4
|
+
// 本来就是自洽的一块 —— 收口(domain/persist.js)返回什么,作业(`installJobView` 下发给面板)就怎么显示,
|
|
5
|
+
// 中间**不许**再各判一套。搬移只搬移未改逻辑;0.5.41 只多一条:**真装失败 ⇒ 一键真装动作优先**
|
|
6
|
+
// (link 回落让插件能加载,但用户要的那一步"依赖交给 pnpm"没成,必须能一键补上;三处齐备时也要下发)。
|
|
7
|
+
//
|
|
8
|
+
// `deps` 是**离线测试缝**(与 marketProbes/sourceDeps 同一风格):正控/负控必须能确定性地让 lock 那一步
|
|
9
|
+
// 失败("故意让 lock 写入失败 ⇒ 必须报未持久化而不是成功"),生产调用方不传时形状与默认值一个字都不变。
|
|
10
|
+
|
|
11
|
+
import { ensurePersisted } from './persist.js'
|
|
12
|
+
import { sourcePersistOptions } from './source-spec.js'
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* 收口 + 落到作业字段。返回 `ensurePersisted` 的完整结果(调用方按需再读)。
|
|
16
|
+
* 只写这些字段(与旧代码逐字一致):`job.depNote` / `job.declared` / `job.entryId` / `job.persisted` /
|
|
17
|
+
* `job.persist` / `job.persistNote` / `job.suggestedAction`。
|
|
18
|
+
*/
|
|
19
|
+
async function finalizeInstallPersistence({
|
|
20
|
+
job, profileDir, patchPath, packageName, mode, taken = null, entries = null, registries = [],
|
|
21
|
+
source = null, deps = {},
|
|
22
|
+
}) {
|
|
23
|
+
const persist = await ensurePersisted({
|
|
24
|
+
profileDir, patchPath, packageName, mode, taken, entries, registries, deps,
|
|
25
|
+
...sourcePersistOptions(source),
|
|
26
|
+
})
|
|
27
|
+
const persistNotes = [persist.declared?.depNote, persist.declared?.lockNote, persist.lock?.lockNote, persist.lock?.depNote, ...persist.notes]
|
|
28
|
+
.filter((n) => typeof n === 'string' && n !== '')
|
|
29
|
+
if (persistNotes.length > 0) job.depNote = job.depNote === undefined ? persistNotes.join(';') : `${job.depNote};${persistNotes.join(';')}`
|
|
30
|
+
if (persist.declared?.changed === true) job.declared = persist.declared.spec
|
|
31
|
+
// entryId 的语义**与旧行为一致**:只有"用户补丁里的 insert 行"才算本作业注册的行(bundle 层提供的行
|
|
32
|
+
// 与运行时已有行都不算,旧代码在这两条路上也不设 entryId)——撤销安装的归属判定依赖这一点。
|
|
33
|
+
job.entryId = persist.after.parts.mount.via === 'patch-row' ? (persist.rowId ?? null) : null
|
|
34
|
+
job.persisted = persist.persisted
|
|
35
|
+
job.persist = { missing: persist.missing, actual: persist.after.actual, note: persist.note }
|
|
36
|
+
if (persist.persisted !== true) job.persistNote = persist.note
|
|
37
|
+
const persistSuggested = persist.suggested ?? null
|
|
38
|
+
if (persistSuggested !== null
|
|
39
|
+
&& (persistSuggested.kind === 'real-install' || job.suggestedAction === undefined || job.suggestedAction === null)) {
|
|
40
|
+
job.suggestedAction = persistSuggested
|
|
41
|
+
}
|
|
42
|
+
return persist
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export { finalizeInstallPersistence }
|
|
@@ -18,7 +18,7 @@ import { tryPresetSourceChannel } from './preset-source.js'
|
|
|
18
18
|
import { settlePresetOutcome } from './preset-in-package.js'
|
|
19
19
|
import { alreadyInstalledTarget, verifyInstalledEntry } from './install-verify.js'
|
|
20
20
|
import { applyNpmNameHint, expandSubpackageCandidates, fetchRepoPackage, fetchRepoPackageEx, fetchSubpackageNames, npmNameHintForRepo, packageProbeErrorText, subpackageCandidates } from './market.js'
|
|
21
|
-
import {
|
|
21
|
+
import { finalizeInstallPersistence } from './install-finalize.js'
|
|
22
22
|
import { readPatchState, syncNameFromNote } from './patch.js'
|
|
23
23
|
import { listEntries } from './runtime.js'
|
|
24
24
|
import { detectSkillRepo, runSkillInstallJob } from './skills.js'
|
|
@@ -75,6 +75,10 @@ async function tryCandidateChannels({ job, ch, name, profileDir, registries, rep
|
|
|
75
75
|
const patchPath = join(profileDir, 'cordis.patch.yml')
|
|
76
76
|
let installedName = null
|
|
77
77
|
let lastError = null
|
|
78
|
+
// 0.5.41 接线:这次成功装上的**来源**(`{ spec, origin, kind }`)—— 取样通道(curl tarball /
|
|
79
|
+
// GitHub release 资产)知道真实下载地址,收口那一步据此走 `pnpm add <spec>` 真装(依赖交给 pnpm)。
|
|
80
|
+
// registry 通道不填(既有流程一字不改);git 通道也不填(它本来就是 pnpm 装的,清单里已是 pnpm 写的规格)。
|
|
81
|
+
let source = null
|
|
78
82
|
// 加法优化:包已在 node_modules 且名字匹配时,不再重复安装/触发 EPERM,直接进入启用流程
|
|
79
83
|
// (2026-09-27 搬进 install-verify.js#alreadyInstalledTarget:本文件贴着 600 行架构硬顶)
|
|
80
84
|
const already = alreadyInstalledTarget(profileDir, name, job)
|
|
@@ -100,6 +104,9 @@ async function tryCandidateChannels({ job, ch, name, profileDir, registries, rep
|
|
|
100
104
|
}
|
|
101
105
|
if (racedInfo.boxNote) job.curlNote += `(盒子验证:${racedInfo.boxNote})`
|
|
102
106
|
job.integrity = racedInfo.integrity ?? null // ④ 下载物摘要校验结果(加法;见 install-verify.js)
|
|
107
|
+
// 0.5.41:curl tarball 是**取样通道**(物是解压铺进去的,不是包管理器装的)——
|
|
108
|
+
// 真实来源规格(registry tarball URL)带回收口。registry 可解析时收口仍写版本号(不倒退)。
|
|
109
|
+
source = { spec: racedInfo.sourceSpec ?? null, origin: 'sampled', kind: 'registry-tarball' }
|
|
103
110
|
}
|
|
104
111
|
}
|
|
105
112
|
} catch (raceError) {
|
|
@@ -126,6 +133,8 @@ async function tryCandidateChannels({ job, ch, name, profileDir, registries, rep
|
|
|
126
133
|
job.curlNote = `已通过 curl 通道安装 v${info.version}${stillMissing.length > 0 ? `(依赖仍未补齐:${stillMissing.join('、')},网络恢复后建议重新安装)` : '(捆绑依赖已补齐)'}`
|
|
127
134
|
if (info.boxNote) job.curlNote += `(盒子验证:${info.boxNote})`
|
|
128
135
|
job.integrity = info.integrity ?? null // ④ 下载物摘要校验结果(加法;见 install-verify.js)
|
|
136
|
+
// 0.5.41:同上(取样通道 ⇒ 带真实 tarball URL 回去真装)
|
|
137
|
+
source = { spec: info.sourceSpec ?? null, origin: 'sampled', kind: 'registry-tarball' }
|
|
129
138
|
} catch (curlError) {
|
|
130
139
|
lastError = curlError
|
|
131
140
|
}
|
|
@@ -144,6 +153,9 @@ async function tryCandidateChannels({ job, ch, name, profileDir, registries, rep
|
|
|
144
153
|
const from = typeof info.sourceNote === 'string' && info.sourceNote !== '' ? `(来源:${info.sourceNote})` : ''
|
|
145
154
|
job.curlNote = `已通过 GitHub release 通道安装 v${info.version ?? '?'}${from}${stillMissing.length > 0 ? `(依赖仍未补齐:${stillMissing.join('、')},网络恢复后建议重新安装)` : '(捆绑依赖已补齐)'}`
|
|
146
155
|
if (info.boxNote) job.curlNote += `(盒子验证:${info.boxNote})`
|
|
156
|
+
// 0.5.41 接线(**本轮的核心**):GitHub release 资产(tarball URL)是取样通道 —— 把真实下载
|
|
157
|
+
// 地址带回给收口,由 `pnpm add <url>` 真装(依赖与 peer 交给包管理器,与官方 dsh plugin add 同效)。
|
|
158
|
+
source = { spec: info.sourceSpec ?? null, origin: 'sampled', kind: 'github-release' }
|
|
147
159
|
} catch (ghError) {
|
|
148
160
|
lastError = ghError
|
|
149
161
|
}
|
|
@@ -186,7 +198,7 @@ async function tryCandidateChannels({ job, ch, name, profileDir, registries, rep
|
|
|
186
198
|
installedName = retry.installedName ?? installedName
|
|
187
199
|
if (retry.error != null) lastError = retry.error
|
|
188
200
|
}
|
|
189
|
-
if (installedName !== null) return { installedName, lastError }
|
|
201
|
+
if (installedName !== null) return { installedName, lastError, source }
|
|
190
202
|
// Windows 原子替换失败(陈旧目录 / _tmp_ 残留)是 EPERM 类错误的根因:
|
|
191
203
|
// 清理后用主源重试一次(把 AI 人工修复经验自动化,减少 AI 兜底触发)
|
|
192
204
|
if (/EPERM|EACCES|rename/i.test(String(lastError?.message ?? ''))) {
|
|
@@ -356,6 +368,8 @@ async function runInstallJob(job, ports, deps = {}) {
|
|
|
356
368
|
for (const id of [...patch.inserts, ...patch.disables, ...patch.forced]) taken.add(id)
|
|
357
369
|
let installedName = null
|
|
358
370
|
let lastError = null
|
|
371
|
+
// 0.5.41:成功后由哪个**来源**装上的(`{ spec, origin, kind } | null`)—— 一路传到收口那一步
|
|
372
|
+
let installedSource = null
|
|
359
373
|
let expanded = false
|
|
360
374
|
// 可配置软件源:主→备依次尝试(默认 npmmirror → npmjs,可增删自定义/内网源)
|
|
361
375
|
const registries = orderedRegistries(readSources())
|
|
@@ -401,7 +415,7 @@ async function runInstallJob(job, ports, deps = {}) {
|
|
|
401
415
|
})
|
|
402
416
|
installedName = attempt.installedName
|
|
403
417
|
lastError = attempt.lastError
|
|
404
|
-
if (installedName !== null) break
|
|
418
|
+
if (installedName !== null) { installedSource = attempt.source ?? null; break }
|
|
405
419
|
}
|
|
406
420
|
// 预设收口(D-③ 源码装配成功 / D-⑥ 落地物里带预设):能按预设收口就直接 return —— 见 preset-in-package.js
|
|
407
421
|
// (这两条路都**没有 node_modules 插件**,写补丁行/声明依赖会让补丁行指向无入口模块 → 启动崩溃)。
|
|
@@ -430,7 +444,13 @@ async function runInstallJob(job, ports, deps = {}) {
|
|
|
430
444
|
// 不能让用户面对"面板说失败、磁盘上却留了半个包和 _tmp_ 残留"的糊涂账。
|
|
431
445
|
const leftovers = cleanupAttemptedCandidates(profileDir, candidates)
|
|
432
446
|
job.leftovers = leftovers
|
|
447
|
+
// ★ 失败路径 B 的文案(0.5.41 加法):**只有如实失败 + 原因**。取样/下载本身失败时磁盘上
|
|
448
|
+
// 什么都没有 ⇒ 没有任何东西可挂 ⇒ 面板**绝不**出现"回落成功 / 已安装 / 已启用"之类说法。
|
|
449
|
+
// 这里把最后一次的真实失败原因接在既有文案之后(不改写、不掩盖),并保留既有诊断分类(可重试动作
|
|
450
|
+
// 由面板的「重试」按钮与 diagnosis.hint 承担)。
|
|
451
|
+
const reason = typeof lastError?.message === 'string' && lastError.message !== '' ? lastError.message : null
|
|
433
452
|
job.error = aiConsentFailureText(decision, leftovers, aiConsentTimeoutMs)
|
|
453
|
+
+ (reason === null ? '' : `;最后一次失败原因:${reason}`)
|
|
434
454
|
}
|
|
435
455
|
return
|
|
436
456
|
}
|
|
@@ -469,24 +489,28 @@ async function runInstallJob(job, ports, deps = {}) {
|
|
|
469
489
|
} catch {}
|
|
470
490
|
// lock 对账(2026-09-21,用户报告的自更新缺陷同源):主包 + 本次被补装/对齐的聚合子包一起核对,
|
|
471
491
|
// 一次 pnpm add 把漂移的包全写进 lock;对不上就留 lockNote,让面板如实告知(不假装成功)。
|
|
492
|
+
// ★ 0.5.41:本次装上的包**有真实来源规格**时(取样通道 = curl/release 资产),这里的对账要**让开**:
|
|
493
|
+
// 它只会按"registry 查无此包"的老判据写成 link:(还顺手复制进 plugin-src、留一句"已按 link: 形式记录"),
|
|
494
|
+
// 而紧接着的收口会用真实来源规格(`pnpm add <spec>` 真装)把它声明对 —— 两处不再打架。
|
|
472
495
|
try {
|
|
473
496
|
// 同一个离线测试缝(见下方 `deps.persistDeps` 注释):安装路径上"动 lock"的两处都归它,
|
|
474
497
|
// 否则负控里这一处会先偷偷把 lock 写齐,收口那一步就再也测不出"未持久化"。
|
|
475
498
|
const reconcile = typeof deps.persistDeps?.reconcile === 'function' ? deps.persistDeps.reconcile : reconcileLockfile
|
|
476
|
-
const
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
499
|
+
const lockPackages = [installedName, ...syncedNames]
|
|
500
|
+
.filter((name) => !(installedSource !== null && typeof installedSource.spec === 'string' && installedSource.spec !== '' && name === installedName))
|
|
501
|
+
.map((name) => ({ name }))
|
|
502
|
+
if (lockPackages.length > 0) {
|
|
503
|
+
const lock = await reconcile({ profileDir, packages: lockPackages, registries })
|
|
504
|
+
job.lockUpdated = lock.lockUpdated
|
|
505
|
+
job.lockVersion = lock.lockVersion
|
|
506
|
+
job.lockMethod = lock.method
|
|
507
|
+
job.lockPackages = lock.packages
|
|
508
|
+
if (lock.lockNote !== null) job.lockNote = lock.lockNote
|
|
509
|
+
// 非常规来源(link:)写回说明:必须让用户看见(缺陷②最阴的地方就是"装完完全看不出问题")
|
|
510
|
+
if (lock.depNote !== null) job.depNote = lock.depNote
|
|
511
|
+
// 写回没生效时的**结构化**建议动作(加法;老客户端忽略该字段,向后兼容)
|
|
512
|
+
if (Array.isArray(lock.suggestedActions) && lock.suggestedActions.length > 0) job.suggestedAction = lock.suggestedActions[0]
|
|
513
|
+
}
|
|
490
514
|
} catch {}
|
|
491
515
|
job.stage = 'configuring'
|
|
492
516
|
// 2026-09-06 事故(i18n 更新变重复行):@linxin666/dsh-i18n 声明了 dsh.bundle.patch,
|
|
@@ -495,11 +519,11 @@ async function runInstallJob(job, ports, deps = {}) {
|
|
|
495
519
|
const alreadyServed = listEntries(ports).some((e) => String(e.moduleName).startsWith(installedName)
|
|
496
520
|
|| String(e.moduleName) === installedName
|
|
497
521
|
|| packageNameOf(e.moduleName) === installedName)
|
|
498
|
-
// 安装收口(0.5.38
|
|
499
|
-
// ③ 挂载行(cordis.patch.yml 的 insert 行 / dsh.profile.bundles 的 bundle 层)。
|
|
500
|
-
//
|
|
501
|
-
//
|
|
502
|
-
//
|
|
522
|
+
// 安装收口(0.5.38 判据 / 0.5.41 接线):**一次动作内**把该插件钉住 —— ① 清单 spec
|
|
523
|
+
// ② pnpm-lock.yaml 条目 ③ 挂载行(cordis.patch.yml 的 insert 行 / dsh.profile.bundles 的 bundle 层)。
|
|
524
|
+
// 判据与写入的唯一承担者是 domain/persist.js;结果 → 作业字段的唯一映射在 domain/install-finalize.js
|
|
525
|
+
// (本轮把"本次装上的**真实来源规格**"一并带进去:取样通道 ⇒ `pnpm add <spec>` 真装;真装失败 ⇒
|
|
526
|
+
// 回落 link: 并如实报「已装上(link 方式)· 真装失败:… · 可一键真装」)。
|
|
503
527
|
let mode = 'insert'
|
|
504
528
|
if (await detectBundleOnly(profileDir, installedName)) {
|
|
505
529
|
mode = 'bundle'
|
|
@@ -526,23 +550,10 @@ async function runInstallJob(job, ports, deps = {}) {
|
|
|
526
550
|
mode = 'served'
|
|
527
551
|
job.note = `${installedName} 已由已安装条目提供,仅更新包本身,不重复注册`
|
|
528
552
|
}
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
const persistNotes = [persist.declared?.depNote, persist.declared?.lockNote, persist.lock?.lockNote, persist.lock?.depNote, ...persist.notes]
|
|
534
|
-
.filter((n) => typeof n === 'string' && n !== '')
|
|
535
|
-
if (persistNotes.length > 0) job.depNote = job.depNote === undefined ? persistNotes.join(';') : `${job.depNote};${persistNotes.join(';')}`
|
|
536
|
-
if (persist.declared?.changed === true) job.declared = persist.declared.spec
|
|
537
|
-
// entryId 的语义**与旧行为一致**:只有"用户补丁里的 insert 行"才算本作业注册的行(bundle 层提供的行
|
|
538
|
-
// 与运行时已有行都不算,旧代码在这两条路上也不设 entryId)——撤销安装的归属判定依赖这一点。
|
|
539
|
-
job.entryId = persist.after.parts.mount.via === 'patch-row' ? (persist.rowId ?? null) : null
|
|
540
|
-
job.persisted = persist.persisted
|
|
541
|
-
job.persist = { missing: persist.missing, actual: persist.after.actual, note: persist.note }
|
|
542
|
-
if (persist.persisted !== true) {
|
|
543
|
-
job.persistNote = persist.note
|
|
544
|
-
if (persist.suggested !== null && persist.suggested !== undefined && (job.suggestedAction === undefined || job.suggestedAction === null)) job.suggestedAction = persist.suggested
|
|
545
|
-
}
|
|
553
|
+
await finalizeInstallPersistence({
|
|
554
|
+
job, profileDir, patchPath, packageName: installedName, mode, taken, entries: listEntries(ports), registries,
|
|
555
|
+
source: installedSource, deps: deps.persistDeps ?? {},
|
|
556
|
+
})
|
|
546
557
|
job.status = 'done'
|
|
547
558
|
} catch (error) {
|
|
548
559
|
job.status = 'failed'
|
|
@@ -181,7 +181,7 @@ async function curlManualInstall(profileDir, packageName, registries, signal = n
|
|
|
181
181
|
try {
|
|
182
182
|
writeFileSync(join(target, '.dsh-installed-at'), String(Date.now()), 'utf8')
|
|
183
183
|
} catch {}
|
|
184
|
-
return { version, missingDeps, boxNote: box.note, integrity }
|
|
184
|
+
return { version, missingDeps, boxNote: box.note, integrity, sourceSpec: tarball }
|
|
185
185
|
} finally {
|
|
186
186
|
rmSync(tmp, { recursive: true, force: true })
|
|
187
187
|
}
|
|
@@ -360,10 +360,14 @@ async function githubReleaseInstall(profileDir, repo, packageName, options = {})
|
|
|
360
360
|
const tgz = join(tmp, 'pkg.tgz')
|
|
361
361
|
let version = null
|
|
362
362
|
let sourceNote = null
|
|
363
|
+
// 0.5.41 接线:把**真实下载地址**带回给调用方(收口那一步据此走 `pnpm add <url>` 真装:
|
|
364
|
+
// 依赖与 peer 交给包管理器,而不是只挂一个目录 —— 见 domain/manifest.js#declareProfileDependency)。
|
|
365
|
+
let downloadUrl = null
|
|
363
366
|
if (plan.ok) {
|
|
364
367
|
const url = plan.asset?.browser_download_url
|
|
365
368
|
if (typeof url !== 'string' || url === '') throw new Error(`GitHub 通道:asset ${plan.file} 没有下载地址(接口未返回 browser_download_url)`)
|
|
366
369
|
await downloadReleaseArtifact(url, tgz, { bin })
|
|
370
|
+
downloadUrl = url
|
|
367
371
|
version = plan.version ?? null
|
|
368
372
|
sourceNote = `${plan.repo} 的 release ${plan.release?.tag_name ?? '(无 tag)'} 的资产 ${plan.file}`
|
|
369
373
|
} else if (plan.sourceFallback !== null) {
|
|
@@ -371,7 +375,8 @@ async function githubReleaseInstall(profileDir, repo, packageName, options = {})
|
|
|
371
375
|
const tag = plan.sourceFallback.tag
|
|
372
376
|
// codeload 官方源码 tarball(已验证本机可用 200)。GitHub 黑洞期由上层通道兜底
|
|
373
377
|
// (pnpm/git 通道),此处失败即报错保留旧版本。
|
|
374
|
-
|
|
378
|
+
downloadUrl = `https://codeload.github.com/${srcRepo}/tar.gz/refs/tags/${encodeURIComponent(tag)}`
|
|
379
|
+
await downloadReleaseArtifact(downloadUrl, tgz, { bin })
|
|
375
380
|
version = String(tag).replace(/^v/iu, '')
|
|
376
381
|
sourceNote = `${srcRepo} 的 release ${tag} 源码 tarball(该仓库没有任何与包名匹配的资产)`
|
|
377
382
|
} else {
|
|
@@ -409,7 +414,7 @@ async function githubReleaseInstall(profileDir, repo, packageName, options = {})
|
|
|
409
414
|
try {
|
|
410
415
|
writeFileSync(join(target, '.dsh-installed-at'), String(Date.now()), 'utf8')
|
|
411
416
|
} catch {}
|
|
412
|
-
return { version, missingDeps, boxNote: box.note, source: 'github', sourceNote }
|
|
417
|
+
return { version, missingDeps, boxNote: box.note, source: 'github', sourceNote, sourceSpec: downloadUrl }
|
|
413
418
|
} finally {
|
|
414
419
|
rmSync(tmp, { recursive: true, force: true })
|
|
415
420
|
}
|
|
@@ -12,8 +12,9 @@
|
|
|
12
12
|
|
|
13
13
|
import { readFile, writeFile } from 'node:fs/promises'
|
|
14
14
|
import { join } from 'node:path'
|
|
15
|
-
import { linkSpecFor, materializePackageForLink, probeFailureKind, probeRegistryPackage, sameSourceSpec, suggestedPinAction } from './dep-source.js'
|
|
15
|
+
import { canonicalSourceSpec, linkSpecFor, materializePackageForLink, probeFailureKind, probeRegistryPackage, sameSourceSpec, suggestedPinAction } from './dep-source.js'
|
|
16
16
|
import { ensureLinkSelfSufficiency, removeLinkSelfSufficiency } from './link-self-sufficiency.js'
|
|
17
|
+
import { composeSourceInstallNote, forgetSourceSpec, isLinkSourceSpec, isRealInstallSpec, recordSourceSpec, suggestedRealInstallAction } from './source-spec.js'
|
|
17
18
|
import { queuedWrite } from '../infra/fsx.js'
|
|
18
19
|
import { runPnpmAdd } from '../infra/exec.js'
|
|
19
20
|
import { resolvePackageJson } from '../infra/paths.js'
|
|
@@ -183,21 +184,58 @@ async function planDependencySpec({ profileDir, packageName, version, registries
|
|
|
183
184
|
* lockSynced: boolean, lockNote: string|null, depNote: string|null, suggested: object|null }}
|
|
184
185
|
*/
|
|
185
186
|
async function declareProfileDependency(profileDir, packageName, version = null, options = {}) {
|
|
186
|
-
const { syncLock = true, registries = [], probe = null, home = undefined, profile = null, sourceSpec = null, installSource = null } = options
|
|
187
|
+
const { syncLock = true, registries = [], probe = null, home = undefined, profile = null, sourceSpec = null, installSource = null, sourceOrigin = null, sourceKind = null } = options
|
|
187
188
|
const empty = { changed: false, version: null, spec: null, form: null, reason: null, lockSynced: false, lockNote: null, depNote: null, suggested: null, sourceInstall: null, selfSufficiency: null }
|
|
188
189
|
const realSpec = typeof sourceSpec === 'string' && sourceSpec.trim() !== '' ? sourceSpec.trim() : null
|
|
190
|
+
const homeOpt = home === undefined ? {} : { home }
|
|
191
|
+
// `link:` 规格 = **显式开发式安装**(本地目录的正常路径):**不**交给 pnpm 真装,
|
|
192
|
+
// 直接走既有 link: 路径 + 自足补齐(判据 C:本地目录不涉及下载,link: 就是它的正常形态)。
|
|
193
|
+
const linkSource = realSpec !== null && isLinkSourceSpec(realSpec)
|
|
194
|
+
const installSpec = realSpec !== null && linkSource !== true && isRealInstallSpec(realSpec) ? realSpec : null
|
|
189
195
|
// ★ 来源型默认【真装】(2026-10-06 加法):给了真实来源规格、而磁盘上还没有这个包时,
|
|
190
196
|
// **先真装**(pnpm add <spec>,依赖/peer 交给包管理器);装失败也**不拒绝** ——
|
|
191
197
|
// 如实记 sourceInstall 后继续走既有判定(退回 link: 并由自足性补齐)。
|
|
192
198
|
let sourceInstall = null
|
|
193
|
-
if (
|
|
194
|
-
sourceInstall = await installSourceDependency({ profileDir, spec:
|
|
199
|
+
if (installSpec !== null && await installedVersionOf(profileDir, packageName) === null) {
|
|
200
|
+
sourceInstall = await installSourceDependency({ profileDir, spec: installSpec, registries, install: installSource })
|
|
201
|
+
recordSourceSpec({ packageName, spec: installSpec, kind: sourceKind, install: sourceInstall, ...homeOpt })
|
|
195
202
|
}
|
|
196
203
|
const resolved = typeof version === 'string' && version !== '' ? version : await installedVersionOf(profileDir, packageName)
|
|
197
|
-
if (resolved === null)
|
|
204
|
+
if (resolved === null) {
|
|
205
|
+
// ★ 失败路径 B(取样/下载本身失败 ⇒ 物不在盘上):只有如实失败 + 原因,**绝不**出现
|
|
206
|
+
// "已装上 / 回落成功 / 已安装"之类说法(判据:回落只对"物已在盘上"成立)。
|
|
207
|
+
const reason = sourceInstall !== null && sourceInstall.ok !== true
|
|
208
|
+
? `真装失败:${sourceInstall.error};磁盘上没有任何副本 —— 无可挂载(清单未改动)`
|
|
209
|
+
: '读不到已安装版本'
|
|
210
|
+
return { ...empty, sourceInstall, reason, depNote: composeSourceInstallNote({ packageName, install: sourceInstall }) ?? reason }
|
|
211
|
+
}
|
|
198
212
|
// 探测/物化放在写锁**之外**(网络探测最长 8 秒,占着清单写锁会堵住其它写)
|
|
199
|
-
|
|
200
|
-
|
|
213
|
+
let plan = linkSource
|
|
214
|
+
? {
|
|
215
|
+
form: 'link',
|
|
216
|
+
spec: canonicalSourceSpec(realSpec),
|
|
217
|
+
note: `${packageName}:按**显式开发式安装**记录(${realSpec})—— 不经 npm registry 解析,依赖与框架 peer 由 link 自足性补齐`,
|
|
218
|
+
suggestion: null,
|
|
219
|
+
}
|
|
220
|
+
: await planDependencySpec({ profileDir, packageName, version: resolved, registries, probe, home, profile, sourceSpec: installSpec })
|
|
221
|
+
// ★ 本轮接线(2026-10-06):盘上那份是**取样通道**铺的(curl / GitHub release 资产 / 源码装配),
|
|
222
|
+
// 而不是包管理器装的 —— 而 registry 又查无此包(plan 判成来源型)⇒ **补一次真装**,
|
|
223
|
+
// 把依赖交给 pnpm(与官方 dsh plugin add 同一条通道)。
|
|
224
|
+
// 真装失败 ⇒ 回落既有 `link:` 路径:**回落只对"物已在盘上"成立**,这里正是(resolved 非空)。
|
|
225
|
+
if (installSpec !== null && sourceInstall === null && sourceOrigin === 'sampled' && plan.form === 'source') {
|
|
226
|
+
sourceInstall = await installSourceDependency({ profileDir, spec: installSpec, registries, install: installSource })
|
|
227
|
+
recordSourceSpec({ packageName, spec: installSpec, kind: sourceKind, install: sourceInstall, ...homeOpt })
|
|
228
|
+
if (sourceInstall.ok !== true) {
|
|
229
|
+
plan = await planDependencySpec({ profileDir, packageName, version: resolved, registries, probe, home, profile, sourceSpec: null })
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
const installNote = composeSourceInstallNote({ packageName, install: sourceInstall, plan })
|
|
233
|
+
if (plan.spec === null) {
|
|
234
|
+
return {
|
|
235
|
+
...empty, version: resolved, form: plan.form, reason: plan.note, sourceInstall,
|
|
236
|
+
depNote: joinNotes(installNote, plan.note), suggested: plan.suggestion,
|
|
237
|
+
}
|
|
238
|
+
}
|
|
201
239
|
const result = await queuedWrite(async () => {
|
|
202
240
|
const manifest = await readManifest(profileDir)
|
|
203
241
|
const deps = { ...(manifest.dependencies ?? {}) }
|
|
@@ -215,17 +253,33 @@ async function declareProfileDependency(profileDir, packageName, version = null,
|
|
|
215
253
|
const selfSufficiency = plan.form === 'link'
|
|
216
254
|
? await ensureLinkSelfSufficiency({ profileDir, packageName, spec: plan.spec, home })
|
|
217
255
|
: null
|
|
218
|
-
|
|
256
|
+
// 真装失败 ⇒ 结构化「一键真装」动作优先(此时"钉住/自足"类动作都不是用户想要的那一步);
|
|
257
|
+
// 它的 payload 里**没有** spec —— 规格由服务端从记录里读(见 domain/source-spec.js)。
|
|
258
|
+
const installAction = sourceInstall !== null && sourceInstall.ok !== true
|
|
259
|
+
? suggestedRealInstallAction({ packageName, spec: sourceInstall.spec, profile, profileDir, reason: sourceInstall.error })
|
|
260
|
+
: null
|
|
261
|
+
const out = {
|
|
262
|
+
...empty, changed: result.changed, version: resolved, spec: plan.spec, form: plan.form,
|
|
263
|
+
depNote: joinNotes(installNote, plan.note),
|
|
264
|
+
// 优先级:真装失败的「一键真装」> 自足性动作 > 计划里的建议(与 syncLock:false 的旧行为逐字一致)
|
|
265
|
+
suggested: installAction ?? (selfSufficiency === null ? null : selfSufficiency.action) ?? plan.suggestion,
|
|
266
|
+
sourceInstall, selfSufficiency,
|
|
267
|
+
}
|
|
219
268
|
if (syncLock !== true) return out
|
|
220
269
|
// **写完清单必须把 lock 拉齐**(2026-09-24 CI 实测):只写清单不写 lock → 清单/lock 不一致 →
|
|
221
270
|
// 之后任何 pnpm 操作都会硬失败(ERR_PNPM_CANNOT_REMOVE_MISSING_DEPS)。
|
|
222
271
|
// 放在写队列**之外**执行(对账本身会跑 pnpm,不该占着清单写锁);失败只记 note,不让安装失败。
|
|
223
|
-
const synced = await syncLockFor(profileDir, [packageName], {
|
|
272
|
+
const synced = await syncLockFor(profileDir, [packageName], {
|
|
273
|
+
registries, home, profile,
|
|
274
|
+
// 0.5.41:刚真装出来的 **tarball URL** 是来源事实 ⇒ 告诉对账"别把它规整回 link:"
|
|
275
|
+
// (否则收口最后一步会把自己刚写下的真实来源规格改掉,用户看到的是"真装了但清单里是 link:")。
|
|
276
|
+
keepUrlSpecs: plan.form === 'source' && /^https?:/u.test(String(plan.spec ?? '')) ? [packageName] : [],
|
|
277
|
+
})
|
|
224
278
|
return {
|
|
225
279
|
...out,
|
|
226
280
|
...synced,
|
|
227
|
-
depNote: joinNotes(plan.note, selfSufficiency === null ? null : selfSufficiency.note, synced.depNote),
|
|
228
|
-
suggested: synced.suggested ?? (selfSufficiency === null ? null : selfSufficiency.action) ?? plan.suggestion,
|
|
281
|
+
depNote: joinNotes(installNote, plan.note, selfSufficiency === null ? null : selfSufficiency.note, synced.depNote),
|
|
282
|
+
suggested: installAction ?? synced.suggested ?? (selfSufficiency === null ? null : selfSufficiency.action) ?? plan.suggestion,
|
|
229
283
|
}
|
|
230
284
|
}
|
|
231
285
|
|
|
@@ -258,6 +312,7 @@ async function syncLockFor(profileDir, names, options = {}) {
|
|
|
258
312
|
profileDir,
|
|
259
313
|
packages: names.map((name) => ({ name })),
|
|
260
314
|
registries: Array.isArray(options.registries) ? options.registries : [],
|
|
315
|
+
...(Array.isArray(options.keepUrlSpecs) && options.keepUrlSpecs.length > 0 ? { keepUrlSpecs: options.keepUrlSpecs } : {}),
|
|
261
316
|
...(options.home === undefined ? {} : { home: options.home }),
|
|
262
317
|
})
|
|
263
318
|
return {
|
|
@@ -339,7 +394,9 @@ async function undeclareProfileDependency(profileDir, packageName, options = {})
|
|
|
339
394
|
// 2026-10-06 加法:link 自足性造的垫片是我们加的,卸载时只清**我们造的**那些
|
|
340
395
|
// (记录里逐个核对"现在仍是指向当初目标的链接",被人改过的一律不碰)。清不掉只如实报。
|
|
341
396
|
const shims = removeLinkSelfSufficiency({ packageName, home: options.home ?? null })
|
|
342
|
-
|
|
397
|
+
// 2026-10-06 接线:来源规格记录同样"卸载即撤销" —— 否则下一次安装会拿旧规格去真装(来源早已变了)。
|
|
398
|
+
const sourceRecord = forgetSourceSpec({ packageName, home: options.home ?? null })
|
|
399
|
+
return { ...result, shimsRemoved: shims.removed, shimsKept: shims.kept, selfSufficiencyNote: shims.note, sourceSpecForgotten: sourceRecord.removed || sourceRecord.emptied }
|
|
343
400
|
}
|
|
344
401
|
|
|
345
402
|
/**
|
|
@@ -290,10 +290,16 @@ function suggestedPersistAction({ packageName, profile = null, profileDir = null
|
|
|
290
290
|
* · ② 缺/形态不一致 → `reconcileLockfile`(既有对账通道,argv/写回形态同安装路径);
|
|
291
291
|
* · ③ 缺 → `mode==='bundle'` 时补 `dsh.profile.bundles`;否则追加一条 insert 行(entry id 由
|
|
292
292
|
* `deriveEntryId` 按既有规则产出)。`mode==='served'` 时**绝不写行**(已有行提供它,再写就是重复装配)。
|
|
293
|
+
*
|
|
294
|
+
* 2026-10-06 接线(0.5.41):`sourceSpec` / `sourceOrigin` 由**安装通道**一路传进来 ——
|
|
295
|
+
* 取样通道(curl / GitHub release 资产 / 源码装配)装的包,真实来源规格在这里被交给
|
|
296
|
+
* `declareProfileDependency({sourceSpec, sourceOrigin:'sampled'})` ⇒ `pnpm add <spec>` 真装,
|
|
297
|
+
* 依赖与 peer 由包管理器保证;真装失败则回落既有 `link:` 路径并**如实**报
|
|
298
|
+
* 「已装上(link 方式)· 真装失败:… · 可一键真装」(回落只对"物已在盘上"成立)。
|
|
293
299
|
* 返回 `{ persisted, before, after, missing, declared, lock, notes, suggested, note, rowId }`;
|
|
294
300
|
* 任何一步失败都**如实**留在 notes 里,绝不影响收口的返回值语义(persisted 只认最终读回结果)。
|
|
295
301
|
*/
|
|
296
|
-
async function ensurePersisted({ profileDir, patchPath, packageName, mode = 'insert', taken = null, entries = null, registries = [], deps = {} } = {}) {
|
|
302
|
+
async function ensurePersisted({ profileDir, patchPath, packageName, mode = 'insert', taken = null, entries = null, registries = [], deps = {}, sourceSpec = null, sourceOrigin = null, sourceKind = null } = {}) {
|
|
297
303
|
const pin = typeof deps.pin === 'function' ? deps.pin : pinProfileDependency
|
|
298
304
|
const declare = typeof deps.declare === 'function' ? deps.declare : declareProfileDependency
|
|
299
305
|
const setSpec = typeof deps.setSpec === 'function' ? deps.setSpec : setProfileDependency
|
|
@@ -322,7 +328,17 @@ async function ensurePersisted({ profileDir, patchPath, packageName, mode = 'ins
|
|
|
322
328
|
declared = { changed: wrote.changed === true, spec: wrote.after ?? disk.spec, form: 'link', lockSynced: null, lockNote: null, depNote: null, suggested: null }
|
|
323
329
|
notes.push(`清单已按磁盘真实值写成 ${wrote.after ?? disk.spec}(该链接指向用户自己的位置,未做物化复制)`)
|
|
324
330
|
} else if (disk.form === 'version') {
|
|
325
|
-
|
|
331
|
+
// 来源型(安装通道知道真实来源规格):把规格一并交给写入层 —— 由它决定"真装 / link:"
|
|
332
|
+
// (0.5.41 接线;没传规格时形状与默认值一个字都不变)。
|
|
333
|
+
// `deps.installSource` / `deps.syncLock` 是**离线测试缝**(与 deps.pin / deps.declare 同一风格):
|
|
334
|
+
// 正控/负控要能确定性地让"包管理器那一步"成功或失败、并让 lock 那一步不跑真 pnpm,
|
|
335
|
+
// 生产调用方不传时用的就是真实 `pnpm add` 与真实 lock 对账。
|
|
336
|
+
declared = await declare(profileDir, packageName, disk.version, {
|
|
337
|
+
registries,
|
|
338
|
+
...(typeof sourceSpec === 'string' && sourceSpec !== '' ? { sourceSpec, sourceOrigin, sourceKind } : {}),
|
|
339
|
+
...(typeof deps.installSource === 'function' ? { installSource: deps.installSource } : {}),
|
|
340
|
+
...(deps.syncLock === false ? { syncLock: false } : {}),
|
|
341
|
+
})
|
|
326
342
|
if (declared.changed !== true) notes.push(`清单里已有这个包(实际是 ${declared.spec ?? disk.version})`)
|
|
327
343
|
if (declared.depNote !== null && declared.depNote !== undefined) notes.push(declared.depNote)
|
|
328
344
|
} else {
|
|
@@ -336,7 +352,15 @@ async function ensurePersisted({ profileDir, patchPath, packageName, mode = 'ins
|
|
|
336
352
|
const mid = persistReport({ profileDir, patchPath, packageName, entries })
|
|
337
353
|
if (mid.parts.lock.ok !== true) {
|
|
338
354
|
try {
|
|
339
|
-
|
|
355
|
+
// 0.5.41:本次带来的是 **tarball URL** 来源规格(刚真装过)⇒ 不许对账把它规整回 link:
|
|
356
|
+
// (判据与 manifest.js#declareProfileDependency 的同一套:只有这一段来源型接线会点名 keepUrlSpecs)。
|
|
357
|
+
const keepUrlSpecs = (typeof sourceSpec === 'string' && /^https?:/u.test(sourceSpec.trim()))
|
|
358
|
+
|| (declared !== null && declared !== undefined && declared.form === 'source' && /^https?:/u.test(String(declared.spec ?? '')))
|
|
359
|
+
? [packageName] : []
|
|
360
|
+
lock = await reconcile({
|
|
361
|
+
profileDir, packages: [{ name: packageName }], registries,
|
|
362
|
+
...(keepUrlSpecs.length > 0 ? { keepUrlSpecs } : {}),
|
|
363
|
+
})
|
|
340
364
|
if (lock.lockNote !== null && lock.lockNote !== undefined) notes.push(lock.lockNote)
|
|
341
365
|
else if (lock.depNote !== null && lock.depNote !== undefined) notes.push(lock.depNote)
|
|
342
366
|
} catch (error) {
|
|
@@ -377,7 +401,15 @@ async function ensurePersisted({ profileDir, patchPath, packageName, mode = 'ins
|
|
|
377
401
|
after.note = after.persisted ? null : persistNote(after)
|
|
378
402
|
lockVerdict = { verified: lock.lockVerified, reason }
|
|
379
403
|
}
|
|
380
|
-
|
|
404
|
+
// 0.5.41 接线:真装失败时写入层会下发「一键真装」动作 —— 它优先于"钉住"(此时用户要的是真装那一步);
|
|
405
|
+
// 只有三处都没就位时仍按老规矩给「一键钉住」(收口更根本)。老客户端忽略该字段,向后兼容。
|
|
406
|
+
const declaredAction = declared !== null && declared !== undefined && declared.suggested !== null && declared.suggested !== undefined
|
|
407
|
+
? declared.suggested
|
|
408
|
+
: null
|
|
409
|
+
const suggested = after.persisted
|
|
410
|
+
? declaredAction
|
|
411
|
+
: suggestedPersistAction({ packageName, profileDir, missing: after.missing, reason: after.note })
|
|
412
|
+
const sourceInstall = declared !== null && declared !== undefined && declared.sourceInstall !== undefined ? declared.sourceInstall : null
|
|
381
413
|
return {
|
|
382
414
|
persisted: after.persisted,
|
|
383
415
|
before,
|
|
@@ -392,6 +424,7 @@ async function ensurePersisted({ profileDir, patchPath, packageName, mode = 'ins
|
|
|
392
424
|
notes,
|
|
393
425
|
suggested,
|
|
394
426
|
note: after.note,
|
|
427
|
+
sourceInstall,
|
|
395
428
|
}
|
|
396
429
|
}
|
|
397
430
|
|
|
@@ -37,18 +37,21 @@
|
|
|
37
37
|
import { existsSync, readFileSync } from 'node:fs'
|
|
38
38
|
import { join } from 'node:path'
|
|
39
39
|
import { applyAllowBuilds, pendingApprovedBuilds, readWorkspaceApprovals } from './allow-builds.js'
|
|
40
|
-
import { NPM_NAME_RE, suggestedAllowBuildsAction } from './dep-source.js'
|
|
40
|
+
import { NPM_NAME_RE, sameSourceSpec, suggestedAllowBuildsAction } from './dep-source.js'
|
|
41
41
|
import { classifyInstallFailure, ignoredBuildsFrom } from './install-diagnose.js'
|
|
42
42
|
import { runLockfileRepair } from './lockfile-health.js'
|
|
43
43
|
import { pinProfileDependency, specOf } from './manifest.js'
|
|
44
44
|
import { ensurePersisted } from './persist.js'
|
|
45
45
|
import { lockVersion } from './selfupdate.js'
|
|
46
46
|
import { overwritePreset } from './preset-source.js'
|
|
47
|
+
import { isRealInstallSpec, readSourceSpec, recordSourceSpec } from './source-spec.js'
|
|
47
48
|
import { buildPnpmEnv, pnpmAddArgs, runPnpmWithFallback, unknownPnpmOption } from '../infra/exec.js'
|
|
48
49
|
|
|
49
50
|
const DEFAULT_REGISTRY = 'https://registry.npmmirror.com'
|
|
50
|
-
/** 动作白名单(客户端只能给这些 kind;执行分支与 argv 全在服务端)。
|
|
51
|
-
|
|
51
|
+
/** 动作白名单(客户端只能给这些 kind;执行分支与 argv 全在服务端)。
|
|
52
|
+
* 0.5.41 登记(加法,不改既有动作):`real-install` —— 来源型安装的"出路":真装失败(回落 link:)后
|
|
53
|
+
* 一键把**记录里的真实来源规格**再交给包管理器(`pnpm add <spec>`)。规格只从服务端记录读。 */
|
|
54
|
+
const ACTION_KINDS = ['pin-dependency', 'reconcile-lock', 'allow-builds', 'overwrite-preset', 'persist-plugin', 'real-install']
|
|
52
55
|
/** 一旦出现这些键就整条拒绝:本接口不存在"传命令"的位置。 */
|
|
53
56
|
const COMMAND_KEYS = ['command', 'cmd', 'argv', 'args', 'exec', 'execute', 'shell', 'script', 'run', 'spawn', 'bin', 'shellCommand']
|
|
54
57
|
const OUTPUT_LIMIT = 4000
|
|
@@ -73,7 +76,7 @@ function parseActionRequest(body) {
|
|
|
73
76
|
return { ok: false, status: 400, error: `未知动作 ${action}(只允许 ${ACTION_KINDS.join(' / ')})` }
|
|
74
77
|
}
|
|
75
78
|
const packageName = typeof raw.packageName === 'string' ? raw.packageName.trim() : ''
|
|
76
|
-
if (action === 'pin-dependency' || action === 'persist-plugin') {
|
|
79
|
+
if (action === 'pin-dependency' || action === 'persist-plugin' || action === 'real-install') {
|
|
77
80
|
if (packageName === '') return { ok: false, status: 400, error: `${action} 需要 packageName` }
|
|
78
81
|
if (packageName.length > 214 || !NPM_NAME_RE.test(packageName)) return { ok: false, status: 400, error: 'packageName 不是合法的 npm 包名' }
|
|
79
82
|
}
|
|
@@ -307,6 +310,66 @@ async function runSuggestedAction({ body, profileDir, registries = [], deps = {}
|
|
|
307
310
|
lock: { entry: null, synced: view?.action === 'repaired' || view?.action === 'noop' },
|
|
308
311
|
}
|
|
309
312
|
}
|
|
313
|
+
if (parsed.action === 'real-install') {
|
|
314
|
+
// ★ 0.5.41 加法(来源型"一键真装"):把**服务端记录里的真实来源规格**再交给包管理器装一次
|
|
315
|
+
// (`pnpm add <spec>`)—— 依赖与 peer 由 pnpm 保证,与官方 `dsh plugin add` 同一条通道。
|
|
316
|
+
// 为什么规格必须从记录读:动作接口只接受 `{ action, packageName, profile }`,客户端**没有位置**
|
|
317
|
+
// 传 spec 字符串(否则等于开了"任意来源/任意命令"的口子);记录由安装时写入(domain/source-spec.js)。
|
|
318
|
+
if (!isProfilePackage(profileDir, parsed.packageName)) {
|
|
319
|
+
return { ...base, ok: false, status: 400, error: `${parsed.packageName} 不在本 profile 的清单(dependencies / dsh.profile.bundles)里,拒绝改动(先安装)` }
|
|
320
|
+
}
|
|
321
|
+
const record = readSourceSpec({ packageName: parsed.packageName, home: deps.home ?? null })
|
|
322
|
+
if (record === null || isRealInstallSpec(record.spec) !== true) {
|
|
323
|
+
return {
|
|
324
|
+
...base, ok: false, status: 400,
|
|
325
|
+
error: `没有 ${parsed.packageName} 的来源规格记录(或记录的规格不能真装):本动作只重放**这个实例装过的来源型包**的真实规格(tarball URL / git 族 / file:),registry 版本号与 link: 不在其列`,
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
const before = await specOf(profileDir, parsed.packageName)
|
|
329
|
+
const run = await runAddWithReleaseAgeRetry({ profileDir, spec: record.spec, registry, runAdd, timeoutMs: ACTION_ADD_TIMEOUT_MS })
|
|
330
|
+
const after = await specOf(profileDir, parsed.packageName)
|
|
331
|
+
const entry = lockVersion(profileDir, parsed.packageName)
|
|
332
|
+
const failure = run.ok === true ? null : classifyInstallFailure(`${run.stderr}\n${String(run.error?.message ?? '')}`)
|
|
333
|
+
const ignored = failure?.kind === 'ignored-builds'
|
|
334
|
+
const ignoredBuilds = ignored === true ? ignoredBuildsFrom(run.stderr) : []
|
|
335
|
+
// 真装结果记回记录(面板/报告据此看到"最近一次真装成没成")——写失败只影响记录,绝不影响本次结论。
|
|
336
|
+
recordSourceSpec({
|
|
337
|
+
packageName: parsed.packageName,
|
|
338
|
+
spec: record.spec,
|
|
339
|
+
kind: record.kind ?? null,
|
|
340
|
+
install: { ok: run.ok === true, error: run.ok === true ? null : String(run.error?.message ?? run.stderr ?? '').slice(0, 600) },
|
|
341
|
+
home: deps.home ?? null,
|
|
342
|
+
})
|
|
343
|
+
// 与 pin 分支同一口径(A-② 语义):清单里的 spec 已经是那条真实来源规格 ⇒ 目标状态已达成;
|
|
344
|
+
// ERR_PNPM_IGNORED_BUILDS 这类报错一个脚本都不执行、也不影响加载,不算"没装上"。
|
|
345
|
+
const reached = sameSourceSpec(profileDir, after, record.spec)
|
|
346
|
+
const ok = run.ok === true || (ignored === true && reached)
|
|
347
|
+
const notes = []
|
|
348
|
+
if (run.relaxedReleaseAge) notes.push('本机 pnpm 的 24h 供应链闸(minimumReleaseAge)拦下了这次真装,已**仅对这一条命令**追加 --config.minimumReleaseAge=0 重试一次(有安全代价,未写进任何配置文件)。')
|
|
349
|
+
if (ignored === true) notes.push(IGNORED_BUILDS_REACHED_NOTE)
|
|
350
|
+
if (run.ok !== true && reached) notes.push(`pnpm 那一步没跑完(exitCode=${run.exitCode}),但清单里的 spec 已经是这条真实来源规格(${after})—— 目标状态已达成,原始输出见 stderr。`)
|
|
351
|
+
return {
|
|
352
|
+
...base,
|
|
353
|
+
kind: ignored === true ? 'ignored-builds' : 'real-install',
|
|
354
|
+
ignoredBuilds,
|
|
355
|
+
nextAction: ignored === true ? suggestedAllowBuildsAction({ packages: ignoredBuilds, profile: parsed.profile, reason: 'pnpm 因构建脚本未获批准而报错' }) : null,
|
|
356
|
+
ok,
|
|
357
|
+
partial: ok !== true && reached,
|
|
358
|
+
exitCode: run.exitCode,
|
|
359
|
+
command: `pnpm ${pnpmAddArgs(record.spec, registry).join(' ')}`,
|
|
360
|
+
stdout: run.stdout,
|
|
361
|
+
stderr: run.stderr,
|
|
362
|
+
relaxedReleaseAge: run.relaxedReleaseAge,
|
|
363
|
+
spec: record.spec,
|
|
364
|
+
manifest: { before, after, changed: before !== after, planned: record.spec, pinned: reached },
|
|
365
|
+
lock: { entry: entry ?? null, synced: typeof entry === 'string' && entry !== '' },
|
|
366
|
+
notes,
|
|
367
|
+
failureKind: failure?.kind ?? null,
|
|
368
|
+
reason: ok === true
|
|
369
|
+
? `${parsed.packageName} 已按真实来源规格真装:${record.spec}(依赖与 peer 由包管理器保证)`
|
|
370
|
+
: `真装没跑成(exitCode=${run.exitCode}):${record.spec} —— 清单当前是 ${after ?? '(无该条目)'},原始输出见 stderr`,
|
|
371
|
+
}
|
|
372
|
+
}
|
|
310
373
|
// pin-dependency
|
|
311
374
|
if (!isProfilePackage(profileDir, parsed.packageName)) {
|
|
312
375
|
return { ...base, ok: false, status: 400, error: `${parsed.packageName} 不在本 profile 的清单(dependencies / dsh.profile.bundles)里,拒绝改动` }
|
|
@@ -331,7 +331,7 @@ async function settleImporterEntries({ profileDir, items = [], deps = {}, regist
|
|
|
331
331
|
* 查无此包(404)→ 物化到 `<DSH_HOME>/plugin-src/<包名>` 并写 `link:<绝对路径>`,同时回 depNote;
|
|
332
332
|
* 连物化都做不到 → **跳过对齐且不碰 package.json**,只记 note。
|
|
333
333
|
*/
|
|
334
|
-
async function reconcileLockfile({ profileDir, packageName = null, packages = null, registries = [], pnpmAdd = pnpmInstall, execOpts = {}, fetchJson = fetchJsonUrl, home = null, deps = {} } = {}) {
|
|
334
|
+
async function reconcileLockfile({ profileDir, packageName = null, packages = null, registries = [], pnpmAdd = pnpmInstall, execOpts = {}, fetchJson = fetchJsonUrl, home = null, deps = {}, keepUrlSpecs = [] } = {}) {
|
|
335
335
|
const targets = Array.isArray(packages)
|
|
336
336
|
? packages.filter((p) => p && typeof p.name === 'string' && p.name !== '')
|
|
337
337
|
: (packageName === null ? [] : [{ name: packageName, version: null }])
|
|
@@ -375,7 +375,14 @@ async function reconcileLockfile({ profileDir, packageName = null, packages = nu
|
|
|
375
375
|
}
|
|
376
376
|
return r.installed === r.lock || sourcePinned(r.lock)
|
|
377
377
|
}
|
|
378
|
-
|
|
378
|
+
// 0.5.41 加法:**刚按真实来源规格真装过**的包(tarball URL)**不算漂移** —— 那个 URL 是用户可见的
|
|
379
|
+
// 来源事实,lock 里的解析记录也正是 pnpm 自己在那次 `pnpm add <url>` 里写下的(带 integrity)。
|
|
380
|
+
// 这条口子只对调用方**点名**的包生效(调用方 = 刚刚真装它的那条路,见 manifest.js / persist.js),
|
|
381
|
+
// 其余任何 URL 形态照旧走下面的"URL → link:"规整(那条保护是给"pnpm 静默改写/来源不确定"的历史状态)。
|
|
382
|
+
const keepUrls = new Set((Array.isArray(keepUrlSpecs) ? keepUrlSpecs : []).filter((n) => typeof n === 'string' && n !== ''))
|
|
383
|
+
const driftedOf = (rows) => rows
|
|
384
|
+
.filter((r) => !(keepUrls.has(r.name) && typeof r.spec === 'string' && /^https?:/u.test(r.spec.trim())))
|
|
385
|
+
.filter((r) => r.installed === null || !aligned(r) || misrecorded(r) || urlSpec(r))
|
|
379
386
|
|
|
380
387
|
const depNotes = []
|
|
381
388
|
// 物化 + link: 计划(registry 查无此包时唯一安全的写回形式)。
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
// L1 · domain —— source-spec.js(**来源型安装的"真实来源规格"记录** + 一键真装动作;2026-10-06 接线)
|
|
2
|
+
//
|
|
3
|
+
// 为什么需要它(本轮的接线缺口):0.5.40 已经把能力做进了写入层 —— `declareProfileDependency({sourceSpec})`
|
|
4
|
+
// 会把**真实来源规格**写回清单,并由 `installSourceDependency` 走 `pnpm add <spec>` 真装(依赖与 peer
|
|
5
|
+
// 交给包管理器,与官方 `dsh plugin add` 同一条通道)。但**生产路径上没有一个调用方传过 sourceSpec**:
|
|
6
|
+
// curl / GitHub release 这些"取样通道"把包铺进 `node_modules` 之后,收口那一步只知道"磁盘上有个真实目录",
|
|
7
|
+
// 于是按老的 registry 404 判据写成 `link:`(复制到 plugin-src + 链接)——依赖还是没人管。
|
|
8
|
+
// 本轮把"这个包是从哪个来源装来的"从通道一路传到收口,并在两处留下可核对的东西:
|
|
9
|
+
// ① **记录**(本模块):`<DSH_HOME>/plugin-console/source-specs/<包名>.json` —— 记下真实来源规格与
|
|
10
|
+
// 最近一次真装结果。用途是让「一键真装」动作能**服务端自己**取到规格:动作接口只接受
|
|
11
|
+
// `{ action, packageName, profile }`,客户端**没有位置**传 spec 字符串(否则等于开了任意命令的口子)。
|
|
12
|
+
// ② **文案与动作**(本模块是唯一承担者):真装成功 / 真装失败的两种如实说法 + 结构化「一键真装」动作。
|
|
13
|
+
//
|
|
14
|
+
// 失败路径的语义(写死在这里,别处不许再解释一遍 —— 用户点名要求):
|
|
15
|
+
// A) 取样/下载**成功**、包管理器安装**失败** ⇒ 磁盘上确实有那份物化副本 ⇒ 允许回落既有 `link:`
|
|
16
|
+
// 路径(插件至少能加载)⇒ 文案必须含「已装上(link 方式)」+「真装失败:<原因>」+「可一键真装」。
|
|
17
|
+
// B) 取样/下载**本身失败** ⇒ 磁盘上什么都没有 ⇒ **没有任何东西可挂** ⇒ 只能如实失败
|
|
18
|
+
// (原因 + 重试);**严禁**出现"回落成功 / 已安装 / 已启用"之类说法。
|
|
19
|
+
// C) 本地目录:不涉及下载 ⇒ `link:` 是它的正常路径(+ 既有自足补齐)。
|
|
20
|
+
// 判据一句话:**回落只对"物已在盘上"成立;物不在盘上就只有如实失败**。
|
|
21
|
+
//
|
|
22
|
+
// 分层:L1 domain —— 不认识 cordis ctx;home 可注入(隔离测试用私有 DSH_HOME),一切失败都不抛。
|
|
23
|
+
|
|
24
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'
|
|
25
|
+
import { dirname, join } from 'node:path'
|
|
26
|
+
import { dshHome } from '../infra/paths.js'
|
|
27
|
+
import { canonicalSourceSpec, profileNameOf } from './dep-source.js'
|
|
28
|
+
|
|
29
|
+
/** 记录目录(与 link-self-sufficiency 记录同族,都在 `<DSH_HOME>/plugin-console/` 下)。 */
|
|
30
|
+
const SOURCE_SPEC_DIR = 'source-specs'
|
|
31
|
+
|
|
32
|
+
/** `link:` = 显式开发式安装(本地目录的正常路径),**不**交给 pnpm 真装(见 C)。 */
|
|
33
|
+
const LINK_SPEC_RE = /^link:/u
|
|
34
|
+
|
|
35
|
+
/** 可以交给 `pnpm add <spec>` 的**来源型**规格(tarball URL / git 族 / file: / 别名协议)。
|
|
36
|
+
* 刻意**不含**版本号、版本范围、dist-tag:那些是 registry 路径,写回形态由既有判据决定(不倒退)。 */
|
|
37
|
+
const REAL_INSTALLABLE_RE = /^(?:https?:|git\+|git:|github:|gitlab:|bitbucket:|file:|workspace:|portal:|npm:|jsr:)/u
|
|
38
|
+
|
|
39
|
+
/** 记录目录 / 记录文件(一个包一份,便于一键真装与卸载清理精确定位)。 */
|
|
40
|
+
function sourceSpecRecordDir(home = null) {
|
|
41
|
+
return join(typeof home === 'string' && home !== '' ? home : dshHome(), 'plugin-console', SOURCE_SPEC_DIR)
|
|
42
|
+
}
|
|
43
|
+
function recordFileNameOf(packageName) {
|
|
44
|
+
return `${String(packageName).replace(/[^A-Za-z0-9._-]+/gu, '_')}.json`
|
|
45
|
+
}
|
|
46
|
+
function sourceSpecRecordPath(home, packageName) {
|
|
47
|
+
return join(sourceSpecRecordDir(home), recordFileNameOf(packageName))
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** 读 JSON(读不到/坏文件都返回 null,绝不抛)。 */
|
|
51
|
+
function readJsonSafe(file) {
|
|
52
|
+
try {
|
|
53
|
+
return JSON.parse(readFileSync(file, 'utf8'))
|
|
54
|
+
} catch {
|
|
55
|
+
return null
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** 这个规格该不该交给包管理器**真装**(`link:` 不算:那是显式开发式安装)。 */
|
|
60
|
+
function isRealInstallSpec(spec) {
|
|
61
|
+
const canonical = canonicalSourceSpec(spec)
|
|
62
|
+
if (canonical === null || canonical === '') return false
|
|
63
|
+
if (LINK_SPEC_RE.test(canonical)) return false
|
|
64
|
+
return REAL_INSTALLABLE_RE.test(canonical)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** 这个规格是不是 `link:`(显式开发式安装 / 本地目录的正常路径)。 */
|
|
68
|
+
function isLinkSourceSpec(spec) {
|
|
69
|
+
const canonical = canonicalSourceSpec(spec)
|
|
70
|
+
return canonical !== null && LINK_SPEC_RE.test(canonical)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* 安装通道带回的「来源」(`{ spec, origin, kind }`)→ `ensurePersisted` 的入参。
|
|
75
|
+
* 空/没有规格 ⇒ **空对象**(调用方的形状与默认值与接线前逐字一致);有规格 ⇒ 三个字段一次给全,
|
|
76
|
+
* 收口才好判"这份物是取样通道铺的(sampled)还是包管理器装的(pnpm)"。
|
|
77
|
+
*/
|
|
78
|
+
function sourcePersistOptions(source) {
|
|
79
|
+
if (source === null || source === undefined || typeof source !== 'object') return {}
|
|
80
|
+
const spec = typeof source.spec === 'string' && source.spec !== '' ? source.spec : null
|
|
81
|
+
if (spec === null) return {}
|
|
82
|
+
return { sourceSpec: spec, sourceOrigin: source.origin ?? null, sourceKind: source.kind ?? null }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** 读某包的来源规格记录(没有/坏文件返回 null)。 */
|
|
86
|
+
function readSourceSpec({ packageName, home = null } = {}) {
|
|
87
|
+
if (typeof packageName !== 'string' || packageName === '') return null
|
|
88
|
+
const record = readJsonSafe(sourceSpecRecordPath(home, packageName))
|
|
89
|
+
if (record === null || typeof record !== 'object') return null
|
|
90
|
+
if (typeof record.spec !== 'string' || record.spec === '') return null
|
|
91
|
+
return record
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** 列出全部记录(体检/报告用)。 */
|
|
95
|
+
function listSourceSpecs({ home = null } = {}) {
|
|
96
|
+
const dir = sourceSpecRecordDir(home)
|
|
97
|
+
try {
|
|
98
|
+
return readdirSync(dir).filter((f) => f.endsWith('.json')).map((f) => ({ file: join(dir, f), record: readJsonSafe(join(dir, f)) }))
|
|
99
|
+
} catch {
|
|
100
|
+
return []
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* 记下某包的真实来源规格(安装时写;**幂等**:规格与最近一次真装结论都没变 ⇒ 一个字节都不写)。
|
|
106
|
+
* `install` 传 `installSourceDependency` 的返回值(`{ ok, spec, error }`)时,最近一次结论一并记下。
|
|
107
|
+
* 返回 `{ ok, changed, file, record }`;任何失败都只如实返回,**绝不抛**(安装主路径不许被它带崩)。
|
|
108
|
+
*/
|
|
109
|
+
function recordSourceSpec({ packageName, spec, kind = null, install = null, home = null } = {}) {
|
|
110
|
+
const empty = { ok: false, changed: false, file: null, record: null }
|
|
111
|
+
if (typeof packageName !== 'string' || packageName === '') return empty
|
|
112
|
+
const canonical = canonicalSourceSpec(spec)
|
|
113
|
+
if (canonical === null || canonical === '') return empty
|
|
114
|
+
const file = sourceSpecRecordPath(home, packageName)
|
|
115
|
+
const installSummary = install === null || install === undefined
|
|
116
|
+
? null
|
|
117
|
+
: { ok: install.ok === true, error: typeof install.error === 'string' && install.error !== '' ? install.error.slice(0, 600) : null }
|
|
118
|
+
try {
|
|
119
|
+
const previous = readJsonSafe(file)
|
|
120
|
+
const sameSpec = typeof previous?.spec === 'string' && canonicalSourceSpec(previous.spec) === canonical
|
|
121
|
+
const sameInstall = installSummary === null
|
|
122
|
+
? true
|
|
123
|
+
: previous?.lastInstall?.ok === installSummary.ok && (previous?.lastInstall?.error ?? null) === installSummary.error
|
|
124
|
+
if (sameSpec && sameInstall) return { ok: true, changed: false, file, record: previous }
|
|
125
|
+
const record = {
|
|
126
|
+
packageName,
|
|
127
|
+
spec: canonical,
|
|
128
|
+
kind: typeof kind === 'string' && kind !== '' ? kind : (previous?.kind ?? null),
|
|
129
|
+
recordedAt: new Date().toISOString(),
|
|
130
|
+
...(installSummary === null ? {} : { lastInstall: { ...installSummary, at: new Date().toISOString() } }),
|
|
131
|
+
}
|
|
132
|
+
mkdirSync(dirname(file), { recursive: true })
|
|
133
|
+
writeFileSync(file, `${JSON.stringify(record, null, 2)}\n`, 'utf8')
|
|
134
|
+
return { ok: true, changed: true, file, record }
|
|
135
|
+
} catch (error) {
|
|
136
|
+
return { ...empty, file, reason: String(error?.message ?? error).slice(0, 300) }
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* 忘掉某包的来源规格记录(**卸载即撤销**,与 `undeclareProfileDependency` 同一时机调用)。
|
|
142
|
+
* 删不掉时**退而求其次清空内容**(Windows 上 `<DSH_HOME>/plugin-console/` 下删除会静默落空,
|
|
143
|
+
* 与 junction / `*_tmp_*` 同族)—— 绝不留下指向已卸载包的陈旧规格。
|
|
144
|
+
*/
|
|
145
|
+
function forgetSourceSpec({ packageName, home = null } = {}) {
|
|
146
|
+
if (typeof packageName !== 'string' || packageName === '') return { removed: false, emptied: false, file: null }
|
|
147
|
+
const file = sourceSpecRecordPath(home, packageName)
|
|
148
|
+
if (!existsSync(file)) return { removed: false, emptied: false, file }
|
|
149
|
+
try { rmSync(file, { force: true }) } catch {}
|
|
150
|
+
if (!existsSync(file)) return { removed: true, emptied: false, file }
|
|
151
|
+
let emptied = false
|
|
152
|
+
try {
|
|
153
|
+
writeFileSync(file, `${JSON.stringify({ packageName, forgotten: true, spec: null, note: '记录已作废;文件本身删除未生效,已清空以免留下陈旧规格' }, null, 2)}\n`, 'utf8')
|
|
154
|
+
emptied = readJsonSafe(file)?.spec === null
|
|
155
|
+
} catch { emptied = false }
|
|
156
|
+
return { removed: false, emptied, file }
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* 结构化「一键真装」动作(与 `suggestedPinAction` 同一套安全语义):
|
|
161
|
+
* `command` 只供展示/复制;执行侧只认 `payload.action`,**规格由服务端从记录里读**,
|
|
162
|
+
* payload 里没有 spec / 命令字符串的位置。
|
|
163
|
+
*/
|
|
164
|
+
function suggestedRealInstallAction({ packageName, spec = null, profile = null, profileDir = null, reason = null } = {}) {
|
|
165
|
+
const name = profile ?? profileNameOf(profileDir)
|
|
166
|
+
const shown = canonicalSourceSpec(spec) ?? '<来源规格>'
|
|
167
|
+
return {
|
|
168
|
+
kind: 'real-install',
|
|
169
|
+
label: '一键真装(交给包管理器装依赖)',
|
|
170
|
+
command: `pnpm add ${shown} --registry https://registry.npmmirror.com`,
|
|
171
|
+
payload: { action: 'real-install', packageName, profile: name },
|
|
172
|
+
reason,
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* 真装结果 → 如实说法(**唯一承担者**)。三种形态各自把该说的话说完,绝不覆盖、绝不美化:
|
|
178
|
+
* · 成功:说清"按真实来源规格真装、依赖与 peer 由包管理器保证";
|
|
179
|
+
* · 失败 + 已回落 `link:`:说清「已装上(link 方式)· 真装失败:<原因> · 可一键真装」;
|
|
180
|
+
* · 失败 + 盘上什么都没有:说清"无可挂载(未改动清单)"——**不出现**任何"已装上"字样。
|
|
181
|
+
*/
|
|
182
|
+
function composeSourceInstallNote({ packageName, install, plan = null } = {}) {
|
|
183
|
+
if (install === null || install === undefined || typeof install.ok !== 'boolean') return null
|
|
184
|
+
const spec = typeof install.spec === 'string' && install.spec !== '' ? install.spec : null
|
|
185
|
+
if (install.ok === true) {
|
|
186
|
+
return `${packageName}:已按真实来源规格真装(pnpm add ${spec ?? '(规格缺失)'})—— 依赖与 peer 由包管理器保证(与官方 dsh plugin add 同一条通道)`
|
|
187
|
+
}
|
|
188
|
+
const error = typeof install.error === 'string' && install.error !== '' ? install.error : '(原因未记录)'
|
|
189
|
+
if (plan?.form === 'link' && typeof plan.spec === 'string' && plan.spec !== '') {
|
|
190
|
+
return `${packageName}:已装上(link 方式)· 真装失败:${error} · 已按 link: 记录(${plan.spec}),缺失依赖由 link 自足性尽力补齐 · 可一键真装`
|
|
191
|
+
}
|
|
192
|
+
if (plan !== null && typeof plan?.spec === 'string' && plan.spec !== '') {
|
|
193
|
+
return `${packageName}:真装失败:${error};已按现有形态记录(${plan.spec})· 可一键真装`
|
|
194
|
+
}
|
|
195
|
+
return `${packageName}:真装失败:${error};磁盘上没有任何副本 —— 无可挂载(未改动清单)`
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export {
|
|
199
|
+
SOURCE_SPEC_DIR,
|
|
200
|
+
REAL_INSTALLABLE_RE,
|
|
201
|
+
sourceSpecRecordDir,
|
|
202
|
+
sourceSpecRecordPath,
|
|
203
|
+
isRealInstallSpec,
|
|
204
|
+
isLinkSourceSpec,
|
|
205
|
+
sourcePersistOptions,
|
|
206
|
+
readSourceSpec,
|
|
207
|
+
listSourceSpecs,
|
|
208
|
+
recordSourceSpec,
|
|
209
|
+
forgetSourceSpec,
|
|
210
|
+
suggestedRealInstallAction,
|
|
211
|
+
composeSourceInstallNote,
|
|
212
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@noob-stupid/dsh-plugin-console",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.41",
|
|
4
4
|
"description": "DSH 框架升级安全与插件升级门控:一键升级、失败自动回滚、升级后回滚上版、旧插件不适配自动禁用;内置多源插件市场为发现层,插件源全部可自定义,可指向公司内网私有源 / 私有索引 / 本地 Git 仓库,纯内网离线可用 | Framework upgrade safety & plugin version gating for DSH, with a customizable multi-source plugin market: point every source at internal mirrors or a local file:// repo for intranet-only, offline installs.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|