@noob-stupid/dsh-plugin-console 0.5.38 → 0.5.39
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.
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
// 不经 tarball 完整性校验,lock 删掉重建、node_modules 清空重装都稳定通过。
|
|
21
21
|
|
|
22
22
|
import { existsSync, rmSync, mkdirSync, realpathSync } from 'node:fs'
|
|
23
|
-
import { basename, dirname, join } from 'node:path'
|
|
23
|
+
import { basename, dirname, isAbsolute, join, resolve } from 'node:path'
|
|
24
24
|
import { copyTree } from '../infra/fsx.js'
|
|
25
25
|
import { fetchJsonUrl } from '../infra/http.js'
|
|
26
26
|
import { dshHome } from '../infra/paths.js'
|
|
@@ -113,6 +113,65 @@ function linkSpecFor(dir) {
|
|
|
113
113
|
return `link:${String(dir).replace(/\\/gu, '/')}`
|
|
114
114
|
}
|
|
115
115
|
|
|
116
|
+
/**
|
|
117
|
+
* 依赖规格的**唯一规范形**(0.5.39 加法):`link:` / `file:` 这类**路径型**来源统一成 pnpm 实际写出的
|
|
118
|
+
* 形态 —— **正斜杠**(是绝对还是相对保持调用方给的选择:pnpm 的 lock 里由它自己决定,见
|
|
119
|
+
* lock-importer.js#lockVersionText)。其它 spec(版本号/范围/dist-tag/URL/git)只做 trim。
|
|
120
|
+
*
|
|
121
|
+
* 为什么必须归一(真机证据,2026-10-04):desktop profile 的 `@noob-stupid/dsh-connection-card-host`
|
|
122
|
+
* 清单里是 `link:D:\dsh-link\dsh-connection-card-host`(**反斜杠**),而所有写回路径都是逐字比较
|
|
123
|
+
* (`pinProfileDependency` / `setProfileDependency` / `declareProfileDependency` 的 `deps[name] === spec`)
|
|
124
|
+
* ⇒ 任何一次「钉住」都会把它改成正斜杠 —— 用户没要求的漂移,而且幂等性彻底失效(每次都"有变化")。
|
|
125
|
+
*/
|
|
126
|
+
function canonicalSourceSpec(spec) {
|
|
127
|
+
if (typeof spec !== 'string') return null
|
|
128
|
+
const s = spec.trim()
|
|
129
|
+
const m = /^(link:|file:)([\s\S]*)$/u.exec(s)
|
|
130
|
+
if (m === null) return s
|
|
131
|
+
let target = m[2].replace(/\\/gu, '/').replace(/\/{2,}/gu, '/')
|
|
132
|
+
// 去掉尾部多余的斜杠(但别把盘根 `C:/` 或 POSIX 根 `/` 也削掉)
|
|
133
|
+
if (target.length > 1 && /\/$/u.test(target) && !/:\/$/u.test(target)) target = target.replace(/\/+$/u, '')
|
|
134
|
+
return `${m[1]}${target}`
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** 路径型 spec 的目标目录(相对写法按 profile 目录解析);非路径型返回 null。 */
|
|
138
|
+
function sourceSpecTarget(profileDir, spec) {
|
|
139
|
+
const canonical = canonicalSourceSpec(spec)
|
|
140
|
+
if (canonical === null) return null
|
|
141
|
+
const m = /^(link:|file:)([\s\S]*)$/u.exec(canonical)
|
|
142
|
+
if (m === null || m[2] === '') return null
|
|
143
|
+
const raw = m[2]
|
|
144
|
+
try {
|
|
145
|
+
return isAbsolute(raw) ? raw : resolve(String(profileDir ?? ''), raw)
|
|
146
|
+
} catch {
|
|
147
|
+
return null
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* 两种写法是否**指向同一个东西**(0.5.39 加法,Fix 2 的判据):等价即视为**完好、零写盘**。
|
|
153
|
+
* · 先比规范形(`link:C:/x` ↔ `link:C:\x` ↔ `link:C:/x/` 全部等价);
|
|
154
|
+
* · 路径型再比 realpath(相对↔绝对、Windows 大小写差异、符号链接指向同一目录都算等价)。
|
|
155
|
+
* 只用它做"要不要写"的判断;真正写盘时一律写**规范形**(canonicalSourceSpec)。
|
|
156
|
+
*/
|
|
157
|
+
function sameSourceSpec(profileDir, a, b) {
|
|
158
|
+
const na = canonicalSourceSpec(a)
|
|
159
|
+
const nb = canonicalSourceSpec(b)
|
|
160
|
+
if (na === null || nb === null) return false
|
|
161
|
+
if (na === nb) return true
|
|
162
|
+
if (!/^(?:link:|file:)/u.test(na) || !/^(?:link:|file:)/u.test(nb)) return false
|
|
163
|
+
const pa = sourceSpecTarget(profileDir, na)
|
|
164
|
+
const pb = sourceSpecTarget(profileDir, nb)
|
|
165
|
+
if (pa === null || pb === null) return false
|
|
166
|
+
try {
|
|
167
|
+
const ra = realpathSync(pa)
|
|
168
|
+
const rb = realpathSync(pb)
|
|
169
|
+
return process.platform === 'win32' ? ra.toLowerCase() === rb.toLowerCase() : ra === rb
|
|
170
|
+
} catch {
|
|
171
|
+
return false
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
116
175
|
/**
|
|
117
176
|
* manifest 里的 `link:` 规格当前是否**真的**还生效(`node_modules/<包名>` 就是指向它的那个链接)。
|
|
118
177
|
* 为什么必须查:release / curl 通道更新包时是「先 rmSync 再 copyTree」,会把 `node_modules/<包名>`
|
|
@@ -198,4 +257,4 @@ function suggestedReconcileAction({ profileDir = null, profile = null, reason =
|
|
|
198
257
|
}
|
|
199
258
|
}
|
|
200
259
|
|
|
201
|
-
export { PLUGIN_SRC_DIR, NPM_NAME_RE, probeRegistryPackage, probeFailureKind, materializePackageForLink, linkSpecFor, linkSpecIsIntact, profileNameOf, suggestedAllowBuildsAction, suggestedPinAction, suggestedReconcileAction }
|
|
260
|
+
export { PLUGIN_SRC_DIR, NPM_NAME_RE, probeRegistryPackage, probeFailureKind, materializePackageForLink, linkSpecFor, linkSpecIsIntact, profileNameOf, suggestedAllowBuildsAction, suggestedPinAction, suggestedReconcileAction, canonicalSourceSpec, sameSourceSpec, sourceSpecTarget }
|
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
// L1 · domain —— lock-importer.js(pnpm-lock.yaml importer 条目的**定点写入** + **只读**校验;0.5.39 加法)
|
|
2
|
+
//
|
|
3
|
+
// 为什么存在(用户 2026-10-04 定案,两处真缺陷):
|
|
4
|
+
// ① `reconcileLockfile` 过去对 `link:` 漂移一律跑一次**完整** `pnpm add`(拉包、动 node_modules、
|
|
5
|
+
// 重写整份 lock)—— 而 lock 里 link 依赖的持久化**只需要 importer 里那两行**
|
|
6
|
+
// (pnpm 对 link 依赖不写 packages/snapshots —— 本机 pnpm 11.21.0 实测)。所以非 registry 来源的
|
|
7
|
+
// lock 持久化 = 本模块的定点写入(**唯一实现**;persist.js 走的 reconcile 通路就是这一条),
|
|
8
|
+
// `pnpm add` **不再**充当持久化手段。
|
|
9
|
+
// ② 写出后必须用 **只读** 的 pnpm 校验确认 lock 仍可解析:`install --lockfile-only --dry-run
|
|
10
|
+
// --frozen-lockfile`(实测:成功与失败两种结局下 lock 都逐字节不变);失败或无法确认 ⇒ 调用方
|
|
11
|
+
// 报「未持久化」+ 一键钉住,**绝不报成功**。
|
|
12
|
+
//
|
|
13
|
+
// ★ 现场事实(2026-10-04 实测,pnpm 11.21.0,本机 desktop profile 的副本):
|
|
14
|
+
// `pnpm install --lockfile-only`(**不加** `--dry-run`)会把**无关条目**的 peer 后缀剥掉 ——
|
|
15
|
+
// 实测把 `@deepseek-ai/dsh-experimental-schedule-bundle` / `dsh-schedule` 的
|
|
16
|
+
// `(@deepseek-ai/dsh-brand@0.1.7-rc.2)` 去掉。⇒ 它**不是**"定点补一条"的无损工具,因此:
|
|
17
|
+
// · 本模块的写入是**纯文本外科手术**(只动目标条目的那几行,其余字节逐字保留,见 planLockImporterEntry);
|
|
18
|
+
// · 校验只用 `--dry-run`(并且每次校验后核对 lock 字节,一旦发现被动过就**立即还原**并报未确认)。
|
|
19
|
+
//
|
|
20
|
+
// 分层:L1 domain —— 不认识 cordis ctx;IO 与 pnpm 执行都可注入(便于离线单测跑正控/负控)。
|
|
21
|
+
import { readFileSync, writeFileSync } from 'node:fs'
|
|
22
|
+
import { isAbsolute, join, relative, resolve } from 'node:path'
|
|
23
|
+
import { buildPnpmEnv, runPnpmWithFallback } from '../infra/exec.js'
|
|
24
|
+
import { queuedWrite } from '../infra/fsx.js'
|
|
25
|
+
|
|
26
|
+
/** importers 段里 pnpm 认的三个依赖分区(与 lockfile-health.js#DEP_SECTIONS 同一形状)。 */
|
|
27
|
+
const IMPORTER_SECTIONS = ['dependencies', 'devDependencies', 'optionalDependencies']
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* 能由 importer 条目**完整**表达的来源(唯一白名单):`link:`。
|
|
31
|
+
* 为什么其余的不在这里(本机 pnpm 11.21.0 实测):`file:` / `git+` / tarball URL 依赖除了 importer 那两行,
|
|
32
|
+
* pnpm 还要写 `packages:` / `snapshots:` 里的**解析记录**(file: 目录 = `resolution: {directory: …}`,
|
|
33
|
+
* file: tgz 与 URL = `integrity`),只留 importer 时 `pnpm install --lockfile-only` 会把 packages 段
|
|
34
|
+
* 重新补出来 ⇒ 本地无法凭空确定,**绝不伪造**(也不退回 `pnpm add`;由调用方如实报未持久化 + 一键钉住)。
|
|
35
|
+
*/
|
|
36
|
+
const IMPORTER_ONLY_RE = /^link:/u
|
|
37
|
+
|
|
38
|
+
/** 该校验/写入用的 lock 文件名(唯一常量,别处不要再拼字面量)。 */
|
|
39
|
+
const LOCKFILE_NAME = 'pnpm-lock.yaml'
|
|
40
|
+
|
|
41
|
+
/** 只读校验的超时(`--dry-run --lockfile-only` 实测 0.5~1.5s;给足余量,失败即如实报)。 */
|
|
42
|
+
const VERIFY_TIMEOUT_MS = 60000
|
|
43
|
+
|
|
44
|
+
/** 该来源是否走「只写 lock importer 条目」的定点写入(唯一判据;调用方不许再各判一套)。 */
|
|
45
|
+
function canWriteImporterEntry(spec) {
|
|
46
|
+
return typeof spec === 'string' && IMPORTER_ONLY_RE.test(spec.trim())
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* lock 里 importer 键行的判据(**唯一真源**):`name:` / `'name':` / `"name":` 三种形态都认。
|
|
51
|
+
* 与 selfupdate.js#lockVersion 的键判据**逐字同一份**(真机证据:pnpm 对含 `/`、`@` 的包名加引号,
|
|
52
|
+
* 对未加 scope 的裸名不加 —— 0.5.38 修过一次"裸键读不出来",两处各写一份必然再次分叉)。
|
|
53
|
+
*/
|
|
54
|
+
function lockEntryKeyMatches(line, name) {
|
|
55
|
+
const trimmed = String(line).trim()
|
|
56
|
+
const bare = trimmed.replace(/^'|':?$/gu, '').replace(/'$/u, '')
|
|
57
|
+
const bareKey = trimmed.replace(/'$/u, '').replace(/:$/u, '')
|
|
58
|
+
const unquoted = trimmed.replace(/^"|":?$/gu, '').replace(/"$/u, '')
|
|
59
|
+
return trimmed === `'${name}':` || trimmed === `"${name}":` || bare === name || bareKey === name || unquoted === name
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** YAML 明文标量安全吗(危险形态才加引号)。普通路径/URL/`link:…` 一律**明文** —— pnpm 自己就是
|
|
63
|
+
* 这么写的(真机 lock 证据:`specifier: link:C:/Users/花火/.dsh/plugin-src/…` 未加引号;
|
|
64
|
+
* `@` 与 `:` 出现在**中间**是合法明文,只有出现在开头/结尾或后跟空格才是危险形态)。 */
|
|
65
|
+
function yamlScalar(value) {
|
|
66
|
+
const text = String(value)
|
|
67
|
+
const needsQuote = text === ''
|
|
68
|
+
|| /[\n\r\t]/u.test(text)
|
|
69
|
+
|| /^\s|\s$/u.test(text)
|
|
70
|
+
|| /:\s|:$/u.test(text)
|
|
71
|
+
|| /#/u.test(text)
|
|
72
|
+
|| /^[!&*?|>%@`"'[\]{},-]/u.test(text)
|
|
73
|
+
return needsQuote ? `'${text.replace(/'/gu, "''")}'` : text
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* `link:` 规格在 lock 里的 `version:` 形态(**pnpm 实际写出的形态**,真机 + 本机实测双重证据):
|
|
78
|
+
* · 同盘 → 相对 profile 目录的路径(`link:../../plugin-src/x`);
|
|
79
|
+
* · 跨盘(`path.relative` 只能给出绝对路径)→ 绝对路径(`link:D:/dsh-link/x`);
|
|
80
|
+
* · 一律**正斜杠**(清单里的反斜杠目标也归一成正斜杠 —— 真机 desktop profile 的
|
|
81
|
+
* `link:D:\dsh-link\…` 在 lock 里就是 `link:D:/dsh-link/…`)。
|
|
82
|
+
* 非 link: 规格返回 null(不在本模块的白名单里)。
|
|
83
|
+
*/
|
|
84
|
+
function lockVersionText(profileDir, spec) {
|
|
85
|
+
if (!canWriteImporterEntry(spec)) return null
|
|
86
|
+
const raw = spec.trim().slice('link:'.length).replace(/\\/gu, '/')
|
|
87
|
+
if (raw === '') return null
|
|
88
|
+
const abs = isAbsolute(raw) ? raw : resolve(String(profileDir), raw)
|
|
89
|
+
let rel = ''
|
|
90
|
+
try {
|
|
91
|
+
rel = relative(String(profileDir), abs)
|
|
92
|
+
} catch {
|
|
93
|
+
rel = ''
|
|
94
|
+
}
|
|
95
|
+
const text = rel !== '' && !isAbsolute(rel) ? rel : abs
|
|
96
|
+
return `link:${String(text).replace(/\\/gu, '/')}`
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* 纯函数:把 lock 文本改成"该包在 importer 里有正确条目"的文本(**只动它自己那几行**)。
|
|
101
|
+
* 返回 `{ ok, changed, text, reason }`;`changed:false` 表示磁盘上的形态已经就是目标形态(零写盘)。
|
|
102
|
+
* 形态不认识的 lock(没有 importers 段 / 缩进不是 pnpm 的形状)→ `ok:false` + 原因,**绝不猜着改**。
|
|
103
|
+
*/
|
|
104
|
+
function planLockImporterEntry(lockText, { name, specifier, version, section = IMPORTER_SECTIONS[0] } = {}) {
|
|
105
|
+
const text = typeof lockText === 'string' ? lockText : String(lockText ?? '')
|
|
106
|
+
const eol = /\r\n/u.test(text) ? '\r\n' : '\n'
|
|
107
|
+
const trailing = /\r?\n$/u.test(text)
|
|
108
|
+
const lines = text.split(/\r?\n/u)
|
|
109
|
+
if (trailing && lines.length > 0 && lines[lines.length - 1] === '') lines.pop()
|
|
110
|
+
const rebuild = (next) => next.join(eol) + (trailing ? eol : '')
|
|
111
|
+
const fail = (reason) => ({ ok: false, changed: false, text, reason })
|
|
112
|
+
|
|
113
|
+
const start = lines.findIndex((l) => /^importers:\s*$/u.test(l))
|
|
114
|
+
if (start === -1) return fail('lock 里没有 importers 段(形态不认识),已跳过定点写入')
|
|
115
|
+
// 根 importer(profile 是单项目工程 → 键是 `.`;找不到就退回第一个 importer)
|
|
116
|
+
let importerAt = -1
|
|
117
|
+
let importerEnd = lines.length
|
|
118
|
+
for (let i = start + 1; i < lines.length; i += 1) {
|
|
119
|
+
if (/^[A-Za-z]/u.test(lines[i])) {
|
|
120
|
+
if (importerAt !== -1) importerEnd = i
|
|
121
|
+
break
|
|
122
|
+
}
|
|
123
|
+
const key = /^\s{2}(\S.*?):\s*$/u.exec(lines[i])
|
|
124
|
+
if (key !== null) {
|
|
125
|
+
if (importerAt === -1) importerAt = i
|
|
126
|
+
if (key[1] === '.') {
|
|
127
|
+
importerAt = i
|
|
128
|
+
// 该 importer 的结束位置:下一个缩进 ≤2 的非空行
|
|
129
|
+
for (let k = i + 1; k < lines.length; k += 1) {
|
|
130
|
+
if (lines[k].trim() !== '' && !/^\s{3,}/u.test(lines[k])) {
|
|
131
|
+
importerEnd = k
|
|
132
|
+
break
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
break
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
if (importerAt === -1) return fail('lock 里读不到 importer 键(形态不认识),已跳过定点写入')
|
|
140
|
+
|
|
141
|
+
const wanted = [yamlScalar(`specifier: ${specifier}`), yamlScalar(`version: ${version}`)]
|
|
142
|
+
// 扫该 importer:既找该包现有条目(就地改),也记录目标分区的插入点
|
|
143
|
+
let foundAt = -1
|
|
144
|
+
let foundSection = null
|
|
145
|
+
let foundBlockEnd = -1
|
|
146
|
+
let target = IMPORTER_SECTIONS.includes(section) ? section : IMPORTER_SECTIONS[0]
|
|
147
|
+
let currentSection = null
|
|
148
|
+
let insertAt = -1
|
|
149
|
+
for (let i = importerAt + 1; i < importerEnd; i += 1) {
|
|
150
|
+
const line = lines[i]
|
|
151
|
+
if (line.trim() === '') continue
|
|
152
|
+
const sec = /^\s{4}([A-Za-z]+):\s*$/u.exec(line)
|
|
153
|
+
if (sec !== null) {
|
|
154
|
+
currentSection = sec[1]
|
|
155
|
+
if (currentSection === target) {
|
|
156
|
+
// 目标分区的插入点:本分区最后一个条目之后(遇到缩进 ≤4 的非空行即本分区结束)
|
|
157
|
+
let last = i
|
|
158
|
+
for (let k = i + 1; k < importerEnd; k += 1) {
|
|
159
|
+
if (lines[k].trim() !== '' && !/^\s{5,}/u.test(lines[k])) break
|
|
160
|
+
last = k
|
|
161
|
+
}
|
|
162
|
+
insertAt = last + 1
|
|
163
|
+
}
|
|
164
|
+
continue
|
|
165
|
+
}
|
|
166
|
+
if (foundAt === -1 && /^\s{6}\S/u.test(line) && lockEntryKeyMatches(line, name)) {
|
|
167
|
+
foundAt = i
|
|
168
|
+
foundSection = currentSection
|
|
169
|
+
// 该条目的块范围(键行 + 缩进更深的字段行)
|
|
170
|
+
foundBlockEnd = i + 1
|
|
171
|
+
for (let k = i + 1; k < importerEnd; k += 1) {
|
|
172
|
+
if (lines[k].trim() !== '' && !/^\s{7,}/u.test(lines[k])) break
|
|
173
|
+
foundBlockEnd = k + 1
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (foundAt !== -1) {
|
|
179
|
+
// 就地改:只替换/补齐 specifier 与 version 两行,其它字段与所有无关行逐字保留
|
|
180
|
+
const keyIndent = /^\s*/u.exec(lines[foundAt])[0]
|
|
181
|
+
const fieldIndent = `${keyIndent} `
|
|
182
|
+
const block = lines.slice(foundAt + 1, foundBlockEnd)
|
|
183
|
+
const next = [...block]
|
|
184
|
+
let sawSpecifier = false
|
|
185
|
+
let sawVersion = false
|
|
186
|
+
for (let k = 0; k < next.length; k += 1) {
|
|
187
|
+
const field = /^\s*(specifier|version):/u.exec(next[k])
|
|
188
|
+
if (field === null) continue
|
|
189
|
+
if (field[1] === 'specifier') {
|
|
190
|
+
sawSpecifier = true
|
|
191
|
+
next[k] = `${fieldIndent}specifier: ${yamlScalar(specifier)}`
|
|
192
|
+
} else {
|
|
193
|
+
sawVersion = true
|
|
194
|
+
next[k] = `${fieldIndent}version: ${yamlScalar(version)}`
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
if (!sawSpecifier) next.unshift(`${fieldIndent}specifier: ${yamlScalar(specifier)}`)
|
|
198
|
+
if (!sawVersion) next.push(`${fieldIndent}version: ${yamlScalar(version)}`)
|
|
199
|
+
if (next.join(eol) === block.join(eol)) {
|
|
200
|
+
return { ok: true, changed: false, text, reason: null, section: foundSection ?? target, version, specifier }
|
|
201
|
+
}
|
|
202
|
+
return {
|
|
203
|
+
ok: true,
|
|
204
|
+
changed: true,
|
|
205
|
+
text: rebuild([...lines.slice(0, foundAt + 1), ...next, ...lines.slice(foundBlockEnd)]),
|
|
206
|
+
reason: null,
|
|
207
|
+
section: foundSection ?? target,
|
|
208
|
+
version,
|
|
209
|
+
specifier,
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// 没有该条目 → 在目标分区末尾插入一条(分区不存在时先补分区头)
|
|
214
|
+
const keyText = /[/@]/u.test(name) ? `'${name}':` : `${name}:`
|
|
215
|
+
const block = [` ${keyText}`, ` specifier: ${yamlScalar(specifier)}`, ` version: ${yamlScalar(version)}`]
|
|
216
|
+
if (insertAt === -1) {
|
|
217
|
+
// 分区不存在:插到该 importer 现有分区之后(没有分区就紧跟 importer 键行)
|
|
218
|
+
let at = importerAt + 1
|
|
219
|
+
for (let i = importerAt + 1; i < importerEnd; i += 1) {
|
|
220
|
+
if (lines[i].trim() === '') continue
|
|
221
|
+
at = i + 1
|
|
222
|
+
}
|
|
223
|
+
return {
|
|
224
|
+
ok: true,
|
|
225
|
+
changed: true,
|
|
226
|
+
text: rebuild([...lines.slice(0, at), ` ${target}:`, ...block, ...lines.slice(at)]),
|
|
227
|
+
reason: null,
|
|
228
|
+
section: target,
|
|
229
|
+
version,
|
|
230
|
+
specifier,
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
return {
|
|
234
|
+
ok: true,
|
|
235
|
+
changed: true,
|
|
236
|
+
text: rebuild([...lines.slice(0, insertAt), ...block, ...lines.slice(insertAt)]),
|
|
237
|
+
reason: null,
|
|
238
|
+
section: target,
|
|
239
|
+
version,
|
|
240
|
+
specifier,
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* 写入(**唯一写入口**):读 → 纯函数改造 → 写 → **读回核实**(既有规矩:写完必须读回,绝不凭返回值
|
|
246
|
+
* 报成功)。走 fsx 的写队列(与清单/补丁写入同一把锁,避免并发读改写互相覆盖)。
|
|
247
|
+
* 返回 `{ changed, unchanged, specifier, version, section, reason }`;任何一步不成 ⇒ `changed:false` + 原因。
|
|
248
|
+
*/
|
|
249
|
+
async function writeLockImporterEntry(profileDir, { name, spec, section = IMPORTER_SECTIONS[0], deps = {} } = {}) {
|
|
250
|
+
const readText = typeof deps.readText === 'function' ? deps.readText : (p) => readFileSync(p, 'utf8')
|
|
251
|
+
const writeText = typeof deps.writeText === 'function' ? deps.writeText : (p, text) => writeFileSync(p, text, 'utf8')
|
|
252
|
+
const file = join(String(profileDir), LOCKFILE_NAME)
|
|
253
|
+
const specifier = typeof spec === 'string' ? spec.trim() : ''
|
|
254
|
+
const version = lockVersionText(profileDir, specifier)
|
|
255
|
+
if (version === null) {
|
|
256
|
+
return { changed: false, unchanged: false, specifier, version: null, section, reason: '该来源的 lock 记录不止 importer 一行,本地无法确定 ⇒ 未写入(不用 pnpm add 当持久化手段)' }
|
|
257
|
+
}
|
|
258
|
+
return queuedWrite(async () => {
|
|
259
|
+
let text = null
|
|
260
|
+
try {
|
|
261
|
+
text = readText(file)
|
|
262
|
+
} catch (error) {
|
|
263
|
+
return { changed: false, unchanged: false, specifier, version, section, reason: `读不到 ${LOCKFILE_NAME}(${error instanceof Error ? error.message : String(error)}),未写入` }
|
|
264
|
+
}
|
|
265
|
+
const plan = planLockImporterEntry(text, { name, specifier, version, section })
|
|
266
|
+
if (plan.ok !== true) return { changed: false, unchanged: false, specifier, version, section, reason: plan.reason }
|
|
267
|
+
if (plan.changed !== true) return { changed: false, unchanged: true, specifier: plan.specifier, version: plan.version, section: plan.section, reason: null }
|
|
268
|
+
try {
|
|
269
|
+
writeText(file, plan.text)
|
|
270
|
+
} catch (error) {
|
|
271
|
+
return { changed: false, unchanged: false, specifier, version, section, reason: `写盘失败:${error instanceof Error ? error.message : String(error)}` }
|
|
272
|
+
}
|
|
273
|
+
let after = null
|
|
274
|
+
try {
|
|
275
|
+
after = readText(file)
|
|
276
|
+
} catch (error) {
|
|
277
|
+
return { changed: false, unchanged: false, specifier, version, section, reason: `写盘后读回失败:${error instanceof Error ? error.message : String(error)}` }
|
|
278
|
+
}
|
|
279
|
+
if (after !== plan.text) return { changed: false, unchanged: false, specifier, version, section, reason: '写盘后读回与预期不一致(未能核实),按未写入处理' }
|
|
280
|
+
return { changed: true, unchanged: false, specifier: plan.specifier, version: plan.version, section: plan.section, reason: null }
|
|
281
|
+
})
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* 从 lock 自己的 `settings:` 段取出 pnpm 配置,供**只读**校验按 lock 的形态复算
|
|
286
|
+
* (不镜像它时 pnpm 直接 `ERR_PNPM_LOCKFILE_CONFIG_MISMATCH`:本机实测 `autoInstallPeers`)。
|
|
287
|
+
* 只认单行标量;对象/数组等复杂设置一律不传(不猜)。
|
|
288
|
+
*/
|
|
289
|
+
function lockfileSettingsFlags(lockText) {
|
|
290
|
+
const lines = String(lockText ?? '').split(/\r?\n/u)
|
|
291
|
+
const start = lines.findIndex((l) => /^settings:\s*$/u.test(l))
|
|
292
|
+
if (start === -1) return []
|
|
293
|
+
const flags = []
|
|
294
|
+
for (let i = start + 1; i < lines.length; i += 1) {
|
|
295
|
+
if (/^[A-Za-z]/u.test(lines[i])) break
|
|
296
|
+
const m = /^\s{2}([A-Za-z][A-Za-z0-9]*):\s*(.+?)\s*$/u.exec(lines[i])
|
|
297
|
+
if (m === null) continue
|
|
298
|
+
const value = m[2].replace(/^'|'$/gu, '')
|
|
299
|
+
if (!/^[A-Za-z0-9._-]+$/u.test(value)) continue
|
|
300
|
+
flags.push(`--config.${m[1]}=${value}`)
|
|
301
|
+
}
|
|
302
|
+
return flags
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** 只读校验的 argv(**唯一产出点**,纯函数,便于单测断言"没有任何写盘开关")。 */
|
|
306
|
+
function verifyArgsFor({ registry = null, settings = [], relaxReleaseAge = false } = {}) {
|
|
307
|
+
const args = ['install', '--lockfile-only', '--dry-run', '--frozen-lockfile']
|
|
308
|
+
if (typeof registry === 'string' && registry.trim() !== '') args.push('--registry', registry.trim())
|
|
309
|
+
args.push(...settings)
|
|
310
|
+
// ★ 供应链年龄闸(pnpm 的 minimumReleaseAge,默认 24h)会在**解析之前**卡住整份 lock:
|
|
311
|
+
// 本机实测(刚发布的 @noob-stupid/dsh-plugin-console@0.5.37 在 24h 窗口内)不加它时任何
|
|
312
|
+
// `--lockfile-only` 调用都只报 ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION,看不出 lock 到底能不能解析。
|
|
313
|
+
// 它与"lock 可解析"是两件事,所以**只读重试**时才放宽;`--dry-run` + `--lockfile-only` 下不装任何东西
|
|
314
|
+
// (实测 lock 逐字节不变)。**写盘路径永不出现这个开关**(修复 argv 由 lockfile-health#repairArgsFor
|
|
315
|
+
// 唯一产出,那一处一个字没动)。
|
|
316
|
+
if (relaxReleaseAge) args.push('--config.minimumReleaseAge=0')
|
|
317
|
+
return args
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** 错误文本尾部(面板要能看到 pnpm 的原始输出,不允许只报"失败了")。 */
|
|
321
|
+
function tailOf(error) {
|
|
322
|
+
const message = String(error?.message ?? error ?? '')
|
|
323
|
+
return message.split(/\r?\n/u).filter((l) => l.trim() !== '').slice(-3).join(' ⏎ ').slice(0, 400)
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* **只读**校验:这条 lock 现在还能被 pnpm 解析吗(`install --lockfile-only --dry-run --frozen-lockfile`)。
|
|
328
|
+
* 返回 `{ verified, via, reason, attempts, changed }`:
|
|
329
|
+
* · `verified:true` —— pnpm 退出码 0,且 lock 字节**逐字节未变**;
|
|
330
|
+
* · `verified:false` —— pnpm 明确报错(lock 形态/配置有问题);
|
|
331
|
+
* · `verified:null` —— pnpm 根本跑不起来(执行方式不可用)⇒ "无法确认",调用方按未持久化处理。
|
|
332
|
+
* `changed:true` 表示校验过程**动过** lock(理论上不可能,`--dry-run` 的硬前提)——此时立即还原原字节
|
|
333
|
+
* 并把结论降为未确认:宁可报"没确认",也绝不留下被校验命令改过的 lock。
|
|
334
|
+
*/
|
|
335
|
+
async function verifyLockReadOnly({ profileDir, registry = null, run = runPnpmWithFallback, deps = {}, execOpts = {} } = {}) {
|
|
336
|
+
const file = join(String(profileDir), LOCKFILE_NAME)
|
|
337
|
+
let before = null
|
|
338
|
+
try {
|
|
339
|
+
before = readFileSync(file, 'utf8')
|
|
340
|
+
} catch (error) {
|
|
341
|
+
return { verified: false, via: null, changed: false, attempts: [], reason: `读不到 ${LOCKFILE_NAME}(${error instanceof Error ? error.message : String(error)}),无法校验` }
|
|
342
|
+
}
|
|
343
|
+
const settings = lockfileSettingsFlags(before)
|
|
344
|
+
const attempts = []
|
|
345
|
+
const once = async (relax) => {
|
|
346
|
+
const args = verifyArgsFor({ registry, settings, relaxReleaseAge: relax })
|
|
347
|
+
try {
|
|
348
|
+
await run(args, { execOpts: { cwd: profileDir, timeout: VERIFY_TIMEOUT_MS, windowsHide: true, maxBuffer: 4 * 1024 * 1024, env: buildPnpmEnv(registry), ...execOpts } })
|
|
349
|
+
attempts.push({ args, ok: true, tail: null })
|
|
350
|
+
return { ok: true, tail: null }
|
|
351
|
+
} catch (error) {
|
|
352
|
+
const tail = tailOf(error)
|
|
353
|
+
attempts.push({ args, ok: false, tail })
|
|
354
|
+
return { ok: false, tail, error }
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
const guard = () => {
|
|
358
|
+
// 校验必须是无损的:核对字节,被动过就还原(并如实标注 changed)
|
|
359
|
+
let now = null
|
|
360
|
+
try {
|
|
361
|
+
now = readFileSync(file, 'utf8')
|
|
362
|
+
} catch {
|
|
363
|
+
return { changed: true, restored: false }
|
|
364
|
+
}
|
|
365
|
+
if (now === before) return { changed: false, restored: false }
|
|
366
|
+
try {
|
|
367
|
+
writeFileSync(file, before, 'utf8')
|
|
368
|
+
return { changed: true, restored: true }
|
|
369
|
+
} catch {
|
|
370
|
+
return { changed: true, restored: false }
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
let outcome = await once(false)
|
|
374
|
+
if (outcome.ok !== true && /MINIMUM_RELEASE_AGE|minimumReleaseAge/iu.test(String(outcome.tail))) outcome = await once(true)
|
|
375
|
+
const bytes = guard()
|
|
376
|
+
const via = attempts.filter((a) => a.ok).length > 0 ? 'pnpm install --lockfile-only --dry-run --frozen-lockfile' : null
|
|
377
|
+
if (bytes.changed) {
|
|
378
|
+
return {
|
|
379
|
+
verified: null,
|
|
380
|
+
via,
|
|
381
|
+
changed: true,
|
|
382
|
+
restored: bytes.restored,
|
|
383
|
+
attempts,
|
|
384
|
+
reason: `只读校验**改动了** ${LOCKFILE_NAME}(违反只读前提)——已${bytes.restored ? '还原' : '尝试还原失败'},按未确认处理`,
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
if (outcome.ok === true) return { verified: true, via, changed: false, attempts, reason: null }
|
|
388
|
+
const unavailable = /ENOENT|Cannot find module|已尝试:/u.test(String(outcome.tail)) && !/ERR_PNPM_/u.test(String(outcome.tail))
|
|
389
|
+
return {
|
|
390
|
+
verified: unavailable ? null : false,
|
|
391
|
+
via,
|
|
392
|
+
changed: false,
|
|
393
|
+
attempts,
|
|
394
|
+
reason: `${unavailable ? 'pnpm 执行方式不可用(无法确认)' : 'pnpm 只读校验未通过'}:${outcome.tail ?? '(无输出)'}`,
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
export {
|
|
399
|
+
IMPORTER_SECTIONS,
|
|
400
|
+
IMPORTER_ONLY_RE,
|
|
401
|
+
LOCKFILE_NAME,
|
|
402
|
+
VERIFY_TIMEOUT_MS,
|
|
403
|
+
canWriteImporterEntry,
|
|
404
|
+
lockEntryKeyMatches,
|
|
405
|
+
lockVersionText,
|
|
406
|
+
lockfileSettingsFlags,
|
|
407
|
+
planLockImporterEntry,
|
|
408
|
+
verifyArgsFor,
|
|
409
|
+
verifyLockReadOnly,
|
|
410
|
+
writeLockImporterEntry,
|
|
411
|
+
}
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
|
|
13
13
|
import { readFile, writeFile } from 'node:fs/promises'
|
|
14
14
|
import { join } from 'node:path'
|
|
15
|
-
import { linkSpecFor, materializePackageForLink, probeFailureKind, probeRegistryPackage, suggestedPinAction } from './dep-source.js'
|
|
15
|
+
import { linkSpecFor, materializePackageForLink, probeFailureKind, probeRegistryPackage, sameSourceSpec, suggestedPinAction } from './dep-source.js'
|
|
16
16
|
import { queuedWrite } from '../infra/fsx.js'
|
|
17
17
|
import { resolvePackageJson } from '../infra/paths.js'
|
|
18
18
|
import { reconcileLockfile } from './selfupdate.js'
|
|
@@ -174,7 +174,9 @@ async function declareProfileDependency(profileDir, packageName, version = null,
|
|
|
174
174
|
const result = await queuedWrite(async () => {
|
|
175
175
|
const manifest = await readManifest(profileDir)
|
|
176
176
|
const deps = { ...(manifest.dependencies ?? {}) }
|
|
177
|
-
|
|
177
|
+
// 0.5.39 改错(Fix 2):逐字比较会把「同一目标的另一种写法」当成漂移 —— 真机 desktop profile 的
|
|
178
|
+
// `link:D:\dsh-link\…`(反斜杠)会被这条路径无谓改写成正斜杠(用户没要求的漂移)。等价 ⇒ 零写盘。
|
|
179
|
+
if (sameSourceSpec(profileDir, deps[packageName], plan.spec)) return { changed: false }
|
|
178
180
|
deps[packageName] = plan.spec
|
|
179
181
|
manifest.dependencies = deps
|
|
180
182
|
await writeManifest(profileDir, manifest)
|
|
@@ -230,14 +232,16 @@ async function pinProfileDependency(profileDir, packageName, options = {}) {
|
|
|
230
232
|
const changed = await queuedWrite(async () => {
|
|
231
233
|
const manifest = await readManifest(profileDir)
|
|
232
234
|
const deps = { ...(manifest.dependencies ?? {}) }
|
|
233
|
-
|
|
235
|
+
// 0.5.39 改错(Fix 2):等价写法(正/反斜杠、相对↔绝对指向同一目录)**不再改写清单** ——
|
|
236
|
+
// 幂等零写盘;只有真漂移(目标不同/确实缺失)才写,且写的是 linkSpecFor 的**规范形**。
|
|
237
|
+
if (sameSourceSpec(profileDir, deps[packageName], spec)) return false
|
|
234
238
|
deps[packageName] = spec
|
|
235
239
|
manifest.dependencies = deps
|
|
236
240
|
await writeManifest(profileDir, manifest)
|
|
237
241
|
return true
|
|
238
242
|
})
|
|
239
243
|
const after = await specOf(profileDir, packageName)
|
|
240
|
-
return { ok: after
|
|
244
|
+
return { ok: sameSourceSpec(profileDir, after, spec), spec, before, after, changed, dir }
|
|
241
245
|
}
|
|
242
246
|
|
|
243
247
|
/**
|
|
@@ -252,7 +256,8 @@ async function setProfileDependency(profileDir, packageName, spec) {
|
|
|
252
256
|
const changed = await queuedWrite(async () => {
|
|
253
257
|
const manifest = await readManifest(profileDir)
|
|
254
258
|
const deps = { ...(manifest.dependencies ?? {}) }
|
|
255
|
-
|
|
259
|
+
// 0.5.39 改错(Fix 2):判等用同一把尺子(canonicalSourceSpec + realpath)—— 等价即零写盘
|
|
260
|
+
if (sameSourceSpec(profileDir, deps[packageName], spec)) return false
|
|
256
261
|
deps[packageName] = spec
|
|
257
262
|
manifest.dependencies = deps
|
|
258
263
|
await writeManifest(profileDir, manifest)
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
// 分层:L1 domain —— 不认识 cordis ctx;IO 与四个写入器都可注入(便于离线单测跑正控/负控)。
|
|
32
32
|
import { existsSync, lstatSync, readFileSync, realpathSync } from 'node:fs'
|
|
33
33
|
import { join } from 'node:path'
|
|
34
|
-
import { PLUGIN_SRC_DIR, linkSpecFor, profileNameOf, linkSpecIsIntact } from './dep-source.js'
|
|
34
|
+
import { PLUGIN_SRC_DIR, linkSpecFor, profileNameOf, linkSpecIsIntact, sameSourceSpec } from './dep-source.js'
|
|
35
35
|
import { isUnder } from '../infra/framework-root.js'
|
|
36
36
|
import { addBundleToManifest, declareProfileDependency, pinProfileDependency, setProfileDependency } from './manifest.js'
|
|
37
37
|
import { appendInsert, parseInsertNames } from './patch.js'
|
|
@@ -145,11 +145,15 @@ function lockPart({ profileDir, packageName, spec }) {
|
|
|
145
145
|
const value = String(entry).trim()
|
|
146
146
|
const s = typeof spec === 'string' ? spec.trim() : ''
|
|
147
147
|
if (s.startsWith('link:')) {
|
|
148
|
-
|
|
148
|
+
// 0.5.39 改错(Fix 2):link 条目还必须**指向清单里的同一个目标** —— 只判"以 link: 开头"会把
|
|
149
|
+
// 「清单指 A、lock 指 B」当成就位,而 pnpm 下一次操作会按 lock 把链接还原到 B(真实漂移)。
|
|
150
|
+
if (value.startsWith('link:') && sameSourceSpec(profileDir, value, s)) {
|
|
149
151
|
out.ok = true
|
|
150
152
|
return out
|
|
151
153
|
}
|
|
152
|
-
out.reason =
|
|
154
|
+
out.reason = value.startsWith('link:')
|
|
155
|
+
? `清单把该包钉在 ${s},而 lock 里是 ${value}(**指向的目标不同**,对账后会被 pnpm 还原)`
|
|
156
|
+
: `清单把该包钉在 ${s},而 lock 里是 ${value}(对账后会被 pnpm 还原)`
|
|
153
157
|
return out
|
|
154
158
|
}
|
|
155
159
|
if (EXACT_VERSION_RE.test(s)) {
|
|
@@ -361,6 +365,18 @@ async function ensurePersisted({ profileDir, patchPath, packageName, mode = 'ins
|
|
|
361
365
|
}
|
|
362
366
|
}
|
|
363
367
|
const after = persistReport({ profileDir, patchPath, packageName, entries })
|
|
368
|
+
// 0.5.39 加法(修 1 的收尾):reconcile 写出 lock importer 条目后会用 pnpm 的**只读**用法确认 lock
|
|
369
|
+
// 仍可解析;「没通过 / 无法确认」时**不能**因为磁盘上有那两行就报成功 —— 按未持久化处理。
|
|
370
|
+
// 判据与文案都来自同一条通路(reconcile 的 lockVerified/lockNote),本模块只把结论并进来,不新判一套。
|
|
371
|
+
let lockVerdict = null
|
|
372
|
+
if (lock !== null && typeof lock === 'object' && (lock.lockVerified === false || lock.lockVerified === null) && lock.lockUpdated !== true) {
|
|
373
|
+
const reason = lock.lockNote ?? 'lock 已写入,但 pnpm 的只读校验未能确认它可解析'
|
|
374
|
+
after.parts.lock = { ...after.parts.lock, ok: false, reason }
|
|
375
|
+
after.missing = PERSIST_PARTS.filter((key) => after.parts[key].ok !== true)
|
|
376
|
+
after.persisted = after.missing.length === 0
|
|
377
|
+
after.note = after.persisted ? null : persistNote(after)
|
|
378
|
+
lockVerdict = { verified: lock.lockVerified, reason }
|
|
379
|
+
}
|
|
364
380
|
const suggested = after.persisted ? null : suggestedPersistAction({ packageName, profileDir, missing: after.missing, reason: after.note })
|
|
365
381
|
return {
|
|
366
382
|
persisted: after.persisted,
|
|
@@ -370,6 +386,7 @@ async function ensurePersisted({ profileDir, patchPath, packageName, mode = 'ins
|
|
|
370
386
|
parts: after.parts,
|
|
371
387
|
declared,
|
|
372
388
|
lock,
|
|
389
|
+
lockVerdict,
|
|
373
390
|
bundled,
|
|
374
391
|
rowId: after.parts.mount.rowId ?? rowId ?? null,
|
|
375
392
|
notes,
|
|
@@ -12,7 +12,9 @@
|
|
|
12
12
|
// ③ 回读核实 readInstalledVersion / lockVersion;都不匹配才回落手铺文件,且**必须**带 lockNote 警告。
|
|
13
13
|
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
|
|
14
14
|
import { join } from 'node:path'
|
|
15
|
-
import { linkSpecFor, linkSpecIsIntact, materializePackageForLink, probeRegistryPackage, suggestedPinAction } from './dep-source.js'
|
|
15
|
+
import { linkSpecFor, linkSpecIsIntact, materializePackageForLink, probeRegistryPackage, sameSourceSpec, suggestedPinAction } from './dep-source.js'
|
|
16
|
+
import { canWriteImporterEntry, lockEntryKeyMatches, verifyLockReadOnly, writeLockImporterEntry } from './lock-importer.js'
|
|
17
|
+
import { readManifestDeps } from './lockfile-health.js'
|
|
16
18
|
import { pnpmInstall } from './install.js'
|
|
17
19
|
import { fetchJsonUrl } from '../infra/http.js'
|
|
18
20
|
import { runPnpmWithFallback } from '../infra/exec.js'
|
|
@@ -72,16 +74,15 @@ function lockVersion(profileDir, name) {
|
|
|
72
74
|
return null
|
|
73
75
|
}
|
|
74
76
|
for (let i = 0; i < lines.length; i += 1) {
|
|
75
|
-
const trimmed = lines[i].trim()
|
|
76
77
|
// 键形态三种都认(0.5.38 改错):`name`、`'name':`(pnpm 对**含 `/`/`@`** 的包名加引号)、
|
|
77
78
|
// `name:`(pnpm 对**未加 scope** 的包名不加引号)。旧判据漏了第三种 ⇒ 未加 scope 的 `link:`
|
|
78
79
|
// 依赖在 importers 里明明有条目却读不出来(真机 desktop profile 的 `dshmarket` 就是:
|
|
79
80
|
// lock 的 35-37 行写着 `dshmarket: / specifier: link:… / version: link:../../plugin-src/dshmarket`,
|
|
80
81
|
// 而 `lockVersion()` 返回 null)——后果是面板永远报"没写进 pnpm-lock.yaml"、pin 永远 partial、
|
|
81
82
|
// 每次对账都白跑一次 `pnpm add`。真值一律从下面这一处读,别在调用方再补一套。
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
if (
|
|
83
|
+
// 0.5.39:键判据搬进 lock-importer.js#lockEntryKeyMatches —— 定点**写**入也要认同一批键形态,
|
|
84
|
+
// 两处各写一份必然再次分叉(写进去的键、读的时候认不出,就是 0.5.38 那个现场)。
|
|
85
|
+
if (lockEntryKeyMatches(lines[i], name)) {
|
|
85
86
|
for (let k = i + 1; k < Math.min(i + 8, lines.length); k += 1) {
|
|
86
87
|
const m = /^\s*version:\s*(\S+)\s*$/u.exec(lines[k])
|
|
87
88
|
if (m) return m[1]
|
|
@@ -210,14 +211,113 @@ const EXACT_VERSION_RE = /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/u
|
|
|
210
211
|
*/
|
|
211
212
|
const DIST_TAG_RE = /^[A-Za-z][A-Za-z0-9._-]*$/u
|
|
212
213
|
|
|
214
|
+
/**
|
|
215
|
+
* manifest spec 的写入器(0.5.39 加法):**复用 manifest.js 那一个既有写入器**
|
|
216
|
+
* `setProfileDependency`(只改 `dependencies[包名]` 一个键、走同一把写队列),本模块**不新造第二套**。
|
|
217
|
+
* 为什么用动态 import:manifest.js 顶部 import 了本模块的 `reconcileLockfile`(syncLockFor 用),
|
|
218
|
+
* 静态互相 import 会成环;这里只在**真的需要改清单**时才按需取一次(同进程内 import 有缓存,零成本)。
|
|
219
|
+
* 单测可用 `deps.setSpec` 直接替换(离线夹具不需要真写清单时)。
|
|
220
|
+
*/
|
|
221
|
+
async function defaultSetSpec(profileDir, packageName, spec) {
|
|
222
|
+
// 注意:这里**不能**用解构(`const { setProfileDependency } = await import(…)`)—— 架构守卫的自由变量
|
|
223
|
+
// 扫描认不出解构声明(`const {` 形态),会把导入名误判成"没声明/没 import"的自由变量。
|
|
224
|
+
const manifestModule = await import('./manifest.js')
|
|
225
|
+
return manifestModule.setProfileDependency(profileDir, packageName, spec)
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** 该包在清单里属于哪个依赖分区(复用 lockfile-health.js 的既有解析器,不新写一份)。 */
|
|
229
|
+
function manifestSectionOf(profileDir, name) {
|
|
230
|
+
try {
|
|
231
|
+
const { deps } = readManifestDeps(readFileSync(join(String(profileDir), 'package.json'), 'utf8'))
|
|
232
|
+
return deps.find((d) => d.name === name)?.section ?? 'dependencies'
|
|
233
|
+
} catch {
|
|
234
|
+
return 'dependencies'
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* 非 registry 来源的 lock 持久化收口(0.5.39 加法,用户点名的修 1):
|
|
240
|
+
* · `items`(**能由 importer 条目完整表达**的来源,目前只有 `link:`)→ **只写 lock importer 条目**
|
|
241
|
+
* (lock-importer.js 是唯一实现;persist.js 走的 reconcile 通路就是这一条),清单形态与目标不等价时
|
|
242
|
+
* 先用既有 `setProfileDependency` 写规范形;写完跑**一次** pnpm **只读**校验确认 lock 仍可解析;
|
|
243
|
+
* · `skipped`(file:/workspace:/portal:/git+/URL…)→ lock 记录不止 importer 一行(pnpm 还要写
|
|
244
|
+
* packages/snapshots 的解析记录),本地无法确定 ⇒ **既不伪造条目、也不用 `pnpm add` 当持久化手段**,
|
|
245
|
+
* 由调用方如实报「未持久化」+ 一键钉住。
|
|
246
|
+
* 返回 `{ writes, verified, verifyNote, errors, manifestWrites, changed }`;`verified` 为 `true|false|null`
|
|
247
|
+
* (null = pnpm 执行方式不可用 ⇒ 无法确认)。
|
|
248
|
+
*/
|
|
249
|
+
async function settleImporterEntries({ profileDir, items = [], deps = {}, registries = [] } = {}) {
|
|
250
|
+
const setSpec = typeof deps.setSpec === 'function' ? deps.setSpec : defaultSetSpec
|
|
251
|
+
const writeImporter = typeof deps.writeImporter === 'function' ? deps.writeImporter : writeLockImporterEntry
|
|
252
|
+
const verify = typeof deps.verifyLock === 'function' ? deps.verifyLock : verifyLockReadOnly
|
|
253
|
+
const writes = []
|
|
254
|
+
const errors = []
|
|
255
|
+
const manifestWrites = []
|
|
256
|
+
for (const item of items) {
|
|
257
|
+
// ① 清单里必须有这个来源的声明:lock 条目不能单独存在(清单没有 = pnpm 会把它当多余条目清掉)
|
|
258
|
+
const before = profileSpec(profileDir, item.name)
|
|
259
|
+
if (typeof before !== 'string' || before.trim() === '') {
|
|
260
|
+
writes.push({ name: item.name, changed: false, skipped: true, reason: '清单 dependencies 里没有这个依赖的声明(lock 条目不能单独存在)—— 未写 lock,请先走「声明」通路' })
|
|
261
|
+
continue
|
|
262
|
+
}
|
|
263
|
+
// ② 形态与目标**不等价**才算真漂移 → 写**规范形**;等价(正/反斜杠、相对↔绝对同一目标)⇒ 零写盘
|
|
264
|
+
if (!sameSourceSpec(profileDir, before, item.spec)) {
|
|
265
|
+
try {
|
|
266
|
+
const wrote = await setSpec(profileDir, item.name, item.spec)
|
|
267
|
+
manifestWrites.push({ name: item.name, changed: wrote?.changed === true, before, after: wrote?.after ?? null })
|
|
268
|
+
} catch (error) {
|
|
269
|
+
const reason = `清单写回失败:${error instanceof Error ? error.message : String(error)}`
|
|
270
|
+
errors.push(reason)
|
|
271
|
+
writes.push({ name: item.name, changed: false, skipped: true, reason })
|
|
272
|
+
continue
|
|
273
|
+
}
|
|
274
|
+
const actual = profileSpec(profileDir, item.name)
|
|
275
|
+
if (!sameSourceSpec(profileDir, actual, item.spec)) {
|
|
276
|
+
const reason = `清单写回未生效(现在仍是 ${actual ?? '(没有这个条目)'})—— 未写 lock`
|
|
277
|
+
errors.push(`${item.name}:${reason}`)
|
|
278
|
+
writes.push({ name: item.name, changed: false, skipped: true, reason })
|
|
279
|
+
continue
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
// ③ 定点写 lock importer 条目(读改写 + 读回核实都在 lock-importer.js 里)
|
|
283
|
+
let written = null
|
|
284
|
+
try {
|
|
285
|
+
written = await writeImporter(profileDir, { name: item.name, spec: item.spec, section: manifestSectionOf(profileDir, item.name), deps })
|
|
286
|
+
} catch (error) {
|
|
287
|
+
written = { changed: false, reason: `lock 写入失败:${error instanceof Error ? error.message : String(error)}` }
|
|
288
|
+
}
|
|
289
|
+
if (written?.changed !== true && written?.unchanged !== true) errors.push(`${item.name}:${written?.reason ?? 'lock 写入未成功'}`)
|
|
290
|
+
writes.push({ name: item.name, ...written, reason: written?.reason ?? null })
|
|
291
|
+
}
|
|
292
|
+
let verified
|
|
293
|
+
let verifyNote = null
|
|
294
|
+
if (writes.some((w) => w.changed === true)) {
|
|
295
|
+
// 写出后**必须**用 pnpm 的只读用法确认 lock 仍可解析;失败或无法确认 ⇒ 调用方报「未持久化」
|
|
296
|
+
let verdict = null
|
|
297
|
+
try {
|
|
298
|
+
verdict = await verify({ profileDir, registry: registries?.[0] ?? null })
|
|
299
|
+
} catch (error) {
|
|
300
|
+
verdict = { verified: null, reason: `只读校验执行失败:${error instanceof Error ? error.message : String(error)}` }
|
|
301
|
+
}
|
|
302
|
+
verified = verdict?.verified ?? null
|
|
303
|
+
verifyNote = verdict?.reason ?? null
|
|
304
|
+
if (verified !== true) errors.push(`pnpm 只读校验${verified === null ? '无法确认' : '未通过'}:${verifyNote ?? '原因未知'}`)
|
|
305
|
+
}
|
|
306
|
+
return { writes, manifestWrites, verified, verifyNote, errors, changed: writes.some((w) => w.changed === true) }
|
|
307
|
+
}
|
|
308
|
+
|
|
213
309
|
/**
|
|
214
310
|
* 通用 lock 对账(**任何**安装通道装完都该调用,支持一次对账多个包):
|
|
215
311
|
* 走非 pnpm 通道(并行 curl / curl tarball / GitHub Release / git clone 装配 / 套装装配)装的包,
|
|
216
312
|
* node_modules 里的版本与 `pnpm-lock.yaml` 记的版本可能不一致——之后任何 pnpm 操作(开关插件改
|
|
217
313
|
* `dsh.profile.bundles`、`dsh plugin add/remove`)都可能按 lock 把它**还原**甚至当外来物处理。这里:
|
|
218
314
|
* ① 全部一致 → 直接返回(不跑 pnpm,零成本);
|
|
219
|
-
* ② 有漂移 →
|
|
220
|
-
*
|
|
315
|
+
* ② 有漂移 → registry 形态(版本号/dist-tag):**一次** `pnpm add <spec1> <spec2> …` 把它们精确写进
|
|
316
|
+
* lock(**行为一个字未改**);非 registry 来源里的 `link:`:**只写 lock importer 条目**
|
|
317
|
+
* (domain/lock-importer.js,唯一实现),再用 pnpm 的**只读**用法确认 lock 可解析 ——
|
|
318
|
+
* `pnpm add` **不再**充当非 registry 来源的持久化手段;
|
|
319
|
+
* ③ 仍对不上(含只读校验没通过/无法确认)→ 返回 lockNote(逐包列出)与可复制命令,由调用方如实展示,
|
|
320
|
+
* 绝不假装成功。
|
|
221
321
|
* `packages` 形如 [{ name, version }];只给 `packageName` 时等价于单包对账(向后兼容)。
|
|
222
322
|
*
|
|
223
323
|
* ★ 缺陷②修复(0.4.0-beta.16 / 0.3.63):写回 spec 前必须确认来源,**绝不能把 release 来源的包
|
|
@@ -231,7 +331,7 @@ const DIST_TAG_RE = /^[A-Za-z][A-Za-z0-9._-]*$/u
|
|
|
231
331
|
* 查无此包(404)→ 物化到 `<DSH_HOME>/plugin-src/<包名>` 并写 `link:<绝对路径>`,同时回 depNote;
|
|
232
332
|
* 连物化都做不到 → **跳过对齐且不碰 package.json**,只记 note。
|
|
233
333
|
*/
|
|
234
|
-
async function reconcileLockfile({ profileDir, packageName = null, packages = null, registries = [], pnpmAdd = pnpmInstall, execOpts = {}, fetchJson = fetchJsonUrl, home = null } = {}) {
|
|
334
|
+
async function reconcileLockfile({ profileDir, packageName = null, packages = null, registries = [], pnpmAdd = pnpmInstall, execOpts = {}, fetchJson = fetchJsonUrl, home = null, deps = {} } = {}) {
|
|
235
335
|
const targets = Array.isArray(packages)
|
|
236
336
|
? packages.filter((p) => p && typeof p.name === 'string' && p.name !== '')
|
|
237
337
|
: (packageName === null ? [] : [{ name: packageName, version: null }])
|
|
@@ -265,8 +365,13 @@ async function reconcileLockfile({ profileDir, packageName = null, packages = nu
|
|
|
265
365
|
const aligned = (r) => {
|
|
266
366
|
if (r.installed === null) return false
|
|
267
367
|
if (typeof r.spec === 'string' && r.spec.startsWith('link:')) {
|
|
368
|
+
// 0.5.39 改错(Fix 2):lock 里那条 link 条目还必须**指向同一个目标**才算对齐。
|
|
369
|
+
// 旧判据只要求它以 `link:` 开头 ⇒ 「清单指 A、lock 指 B」被当成对齐(真实漂移:两处目标不同),
|
|
370
|
+
// 而 pnpm 下一次操作会按 lock 把链接还原到 B。等价判据与清单写入共用同一把尺子
|
|
371
|
+
// (dep-source.js#sameSourceSpec:正/反斜杠、相对↔绝对都算同一目标)。
|
|
268
372
|
return linkSpecIsIntact(profileDir, r.name, r.spec)
|
|
269
373
|
&& typeof r.lock === 'string' && r.lock.trim().startsWith('link:')
|
|
374
|
+
&& sameSourceSpec(profileDir, r.lock, r.spec)
|
|
270
375
|
}
|
|
271
376
|
return r.installed === r.lock || sourcePinned(r.lock)
|
|
272
377
|
}
|
|
@@ -296,8 +401,11 @@ async function reconcileLockfile({ profileDir, packageName = null, packages = nu
|
|
|
296
401
|
if (/^https?:/u.test(spec.trim()) || misrecorded(row)) {
|
|
297
402
|
return linkPlan(row, '该包原先以 tarball URL 记录(pnpm 10 重写 lock 会丢 integrity,实测 ERR_PNPM_MISSING_TARBALL_INTEGRITY)')
|
|
298
403
|
}
|
|
299
|
-
// link:
|
|
404
|
+
// link: 规格:链接**还在**(node_modules 里就是指向清单那个目标的链接)→ 一个字节都不用物化,
|
|
405
|
+
// 按清单形态重放即可(0.5.39:等价即零漂移,别为了写一条 lock 去复制整棵包);
|
|
406
|
+
// 链接已被 release/curl 通道的"先删再铺"打断 → 先把新副本刷进 plugin-src 再重放(既有能力,不变)
|
|
300
407
|
if (spec.trim().startsWith('link:')) {
|
|
408
|
+
if (linkSpecIsIntact(profileDir, name, spec)) return { spec: spec.trim(), reason: null, note: null }
|
|
301
409
|
const dir = materializePackageForLink(profileDir, name, home === null ? {} : { home })
|
|
302
410
|
return { spec: dir === null ? spec.trim() : linkSpecFor(dir), reason: null, note: null }
|
|
303
411
|
}
|
|
@@ -327,14 +435,29 @@ async function reconcileLockfile({ profileDir, packageName = null, packages = nu
|
|
|
327
435
|
const errors = []
|
|
328
436
|
let method = null
|
|
329
437
|
const planned = []
|
|
438
|
+
let importerSettled = null
|
|
330
439
|
if (drifted.length > 0) {
|
|
331
440
|
// 只对"能读到实际版本"的包做对齐;读不到的(目录都没有)无法对账,留给 lockNote
|
|
332
|
-
const specs = []
|
|
441
|
+
const specs = [] // registry 形态(版本号 / dist-tag)→ **照旧**走既有 pnpm add 通道
|
|
442
|
+
const importerItems = [] // 非 registry 来源且**能由 importer 条目完整表达**(link:)→ 定点写 lock
|
|
443
|
+
const notWritable = [] // 其余非 registry 来源(file:/git+/URL/…)→ 本地不伪造、也不用 pnpm add
|
|
333
444
|
for (const row of drifted) {
|
|
334
445
|
if (row.installed === null) continue
|
|
335
446
|
const plan = await planWriteback(row)
|
|
336
|
-
|
|
337
|
-
if (plan.spec !== null
|
|
447
|
+
const item = { name: row.name, installed: row.installed, spec: plan.spec, reason: plan.reason ?? null, note: plan.note ?? null }
|
|
448
|
+
if (plan.spec !== null && canWriteImporterEntry(plan.spec)) {
|
|
449
|
+
item.channel = 'importer'
|
|
450
|
+
importerItems.push(item)
|
|
451
|
+
} else if (plan.spec !== null && SOURCE_PINNED_RE.test(plan.spec.trim())) {
|
|
452
|
+
// 修 1(0.5.39):非 registry 来源**绝不**再用 `pnpm add` 当持久化手段;本地又无法确定它的
|
|
453
|
+
// lock 记录(上面 IMPORTER_ONLY_RE 的注释说了为什么)⇒ 一个字节都不写,交给下面的文案如实报。
|
|
454
|
+
item.channel = 'importer-skipped'
|
|
455
|
+
notWritable.push(item)
|
|
456
|
+
} else {
|
|
457
|
+
item.channel = 'pnpm'
|
|
458
|
+
if (plan.spec !== null) specs.push(plan.spec)
|
|
459
|
+
}
|
|
460
|
+
planned.push(item)
|
|
338
461
|
}
|
|
339
462
|
if (specs.length > 0) {
|
|
340
463
|
try {
|
|
@@ -344,18 +467,43 @@ async function reconcileLockfile({ profileDir, packageName = null, packages = nu
|
|
|
344
467
|
errors.push(`pnpm add 对齐失败:${error instanceof Error ? error.message : String(error)}`)
|
|
345
468
|
}
|
|
346
469
|
}
|
|
470
|
+
if (importerItems.length > 0) {
|
|
471
|
+
importerSettled = await settleImporterEntries({ profileDir, items: importerItems, deps, registries })
|
|
472
|
+
errors.push(...importerSettled.errors)
|
|
473
|
+
if (importerSettled.changed === true) method = method === null ? 'lock-importer' : `${method}+lock-importer`
|
|
474
|
+
}
|
|
347
475
|
rows = snap()
|
|
348
476
|
drifted = driftedOf(rows)
|
|
349
477
|
}
|
|
350
478
|
// ★ 文案一律按**实际写入形态**生成(读回清单再描述)——"计划写 link:"与"清单里真是 link:"
|
|
351
479
|
// 是两件事:pnpm add 失败、或后续某一步又把 spec 覆盖成版本号时,绝不能报"已钉住"。
|
|
352
480
|
const suggestedActions = []
|
|
481
|
+
const writeOf = new Map((importerSettled?.writes ?? []).map((w) => [w.name, w]))
|
|
353
482
|
for (const item of planned) {
|
|
354
483
|
const actual = profileSpec(profileDir, item.name)
|
|
355
484
|
if (item.spec === null) {
|
|
356
485
|
if (item.note !== null) depNotes.push(item.note)
|
|
357
486
|
continue
|
|
358
487
|
}
|
|
488
|
+
if (item.channel === 'importer') {
|
|
489
|
+
const wrote = writeOf.get(item.name) ?? null
|
|
490
|
+
if (wrote !== null && (wrote.changed === true || wrote.unchanged === true)) {
|
|
491
|
+
if (item.reason !== null) {
|
|
492
|
+
depNotes.push(`${item.name}:${item.reason},已按 link: 形式记录依赖(${actual})—— lock 的 importer 条目已**定点写入**(${wrote.version ?? ''}),不经 npm registry 解析、不经 tarball 完整性校验`)
|
|
493
|
+
}
|
|
494
|
+
continue
|
|
495
|
+
}
|
|
496
|
+
const why = `${item.name}:${item.reason !== null ? `${item.reason};` : ''}${wrote?.reason ?? 'lock importer 条目未能写入'}(清单现在是 ${actual ?? '(没有这个条目)'})—— lock 条目**未写入**`
|
|
497
|
+
depNotes.push(`${why}。请执行「钉住」动作或手动执行:${command(item.name, item.installed)}`)
|
|
498
|
+
suggestedActions.push(suggestedPinAction({ packageName: item.name, version: item.installed, profileDir, reason: why }))
|
|
499
|
+
continue
|
|
500
|
+
}
|
|
501
|
+
if (item.channel === 'importer-skipped') {
|
|
502
|
+
const why = `${item.name}:来源是 ${item.spec}(非 registry)—— lock 里的记录不止 importer 一行(pnpm 还要写 packages/snapshots 的解析记录),本地无法确定 ⇒ **未写入 lock**,也没用 pnpm add 顶替`
|
|
503
|
+
depNotes.push(`${why}。要钉住请执行「钉住」动作或手动执行:${command(item.name, item.installed)}`)
|
|
504
|
+
suggestedActions.push(suggestedPinAction({ packageName: item.name, version: item.installed, profileDir, reason: why }))
|
|
505
|
+
continue
|
|
506
|
+
}
|
|
359
507
|
if (actual === item.spec) {
|
|
360
508
|
if (item.spec.startsWith('link:') && item.reason !== null) {
|
|
361
509
|
depNotes.push(`${item.name}:${item.reason},已按 link: 形式记录依赖(${actual})—— 不经 npm registry 解析、不经 tarball 完整性校验,pnpm 重建 lock 也能装上`)
|
|
@@ -366,7 +514,39 @@ async function reconcileLockfile({ profileDir, packageName = null, packages = nu
|
|
|
366
514
|
depNotes.push(`${why}。请执行「钉住」动作或手动执行:${command(item.name, item.installed)}`)
|
|
367
515
|
suggestedActions.push(suggestedPinAction({ packageName: item.name, version: item.installed, profileDir, reason: why }))
|
|
368
516
|
}
|
|
369
|
-
|
|
517
|
+
// 只读校验没通过/无法确认 ⇒ **不算已持久化**(修 1 的硬要求:不许报成功),把缺口与出路如实写进 lockNote
|
|
518
|
+
const lockVerified = importerSettled === null ? undefined : importerSettled.verified
|
|
519
|
+
const verifyBlocked = lockVerified === false || lockVerified === null
|
|
520
|
+
const verifyNames = (importerSettled?.writes ?? []).filter((w) => w.changed === true).map((w) => w.name)
|
|
521
|
+
const verifyNote = verifyBlocked
|
|
522
|
+
? `${verifyNames.join('、')} 的 lock importer 条目已按来源写入,但 pnpm 的**只读**校验${lockVerified === null ? '无法确认' : '未通过'}(${importerSettled?.verifyNote ?? '原因未知'})—— 按**未持久化**处理,绝不报成功`
|
|
523
|
+
: null
|
|
524
|
+
if (verifyBlocked && verifyNames.length > 0 && !suggestedActions.some((a) => a?.payload?.packageName === verifyNames[0])) {
|
|
525
|
+
const target = planned.find((p) => p.name === verifyNames[0]) ?? { name: verifyNames[0], installed: readInstalledVersion(profileDir, verifyNames[0]) }
|
|
526
|
+
suggestedActions.push(suggestedPinAction({ packageName: target.name, version: target.installed ?? null, profileDir, reason: verifyNote }))
|
|
527
|
+
}
|
|
528
|
+
// 只写 lock 成功、但 node_modules 里的链接还没就位(release/curl/URL 通道「先删再铺」把链接换成了真实目录):
|
|
529
|
+
// 这条**不是**"没写进 lock",文案必须分开说;同时要给「缺口明细 + 一键钉住」。
|
|
530
|
+
const writtenNames = new Set((importerSettled?.writes ?? []).filter((w) => w.changed === true || w.unchanged === true).map((w) => w.name))
|
|
531
|
+
const linkPending = drifted.filter((r) => writtenNames.has(r.name) && typeof r.lock === 'string' && r.lock.trim().startsWith('link:'))
|
|
532
|
+
for (const row of linkPending) {
|
|
533
|
+
if (suggestedActions.some((a) => a?.payload?.packageName === row.name)) continue
|
|
534
|
+
const reason = `${row.name}:lock 条目已按来源写入(${row.lock}),但 node_modules 里的链接尚未就位 —— 点「钉住」可立即用 pnpm 把链接建起来(否则要等下一次 pnpm 操作)`
|
|
535
|
+
depNotes.push(reason)
|
|
536
|
+
suggestedActions.push(suggestedPinAction({ packageName: row.name, version: row.installed, profileDir, reason }))
|
|
537
|
+
}
|
|
538
|
+
const lockUpdated = drifted.length === 0 && !verifyBlocked
|
|
539
|
+
const notWritten = drifted.filter((r) => !linkPending.includes(r))
|
|
540
|
+
const driftedText = drifted.length === 0
|
|
541
|
+
? null
|
|
542
|
+
: [
|
|
543
|
+
linkPending.length > 0
|
|
544
|
+
? `${linkPending.map((r) => `${r.name}:lock 条目已按来源写入(${r.lock}),但 node_modules 里的链接尚未就位 —— 下一次 pnpm 操作会按这条 lock 重建链接`).join(';')}`
|
|
545
|
+
: null,
|
|
546
|
+
notWritten.length > 0
|
|
547
|
+
? `${notWritten.length} 个包没写进 pnpm-lock.yaml(${notWritten.map((r) => `${r.name}:装了 ${r.installed ?? '未知'}/lock 里是 ${r.lock ?? '未记录'}`).join(';')}):之后任何 pnpm 操作(开关插件、dsh plugin add/remove)都可能把它们还原。要钉住请执行:${command(notWritten[0].name, notWritten[0].installed)}`
|
|
548
|
+
: null,
|
|
549
|
+
].filter((x) => x !== null).join(';')
|
|
370
550
|
return {
|
|
371
551
|
method,
|
|
372
552
|
packages: rows.map((r) => ({ name: r.name, installedVersion: r.installed, lockVersion: r.lock, spec: r.spec, aligned: r.installed !== null && (r.installed === r.lock || sourcePinned(r.lock)) })),
|
|
@@ -374,9 +554,19 @@ async function reconcileLockfile({ profileDir, packageName = null, packages = nu
|
|
|
374
554
|
installedVersion: rows.length === 1 ? rows[0].installed : null,
|
|
375
555
|
lockVersion: rows.length === 1 ? rows[0].lock : null,
|
|
376
556
|
lockUpdated,
|
|
557
|
+
// 只读校验的结论如实下发(true=确认可解析 / false=未通过 / null=无法确认;没写过条目时是 undefined)
|
|
558
|
+
lockVerified,
|
|
559
|
+
lockVerifyNote: importerSettled?.verifyNote ?? null,
|
|
560
|
+
lockImporterWrites: (importerSettled?.writes ?? []).map((w) => ({ name: w.name, changed: w.changed === true, version: w.version ?? null, reason: w.reason ?? null })),
|
|
377
561
|
lockNote: lockUpdated
|
|
378
562
|
? null
|
|
379
|
-
:
|
|
563
|
+
: [
|
|
564
|
+
verifyNote,
|
|
565
|
+
// 定点写入失败的逐条原因也要进 lockNote(缺口明细:面板只看 lockNote 时也说得清)
|
|
566
|
+
(importerSettled?.writes ?? []).filter((w) => w.changed !== true && w.unchanged !== true && typeof w.reason === 'string' && w.reason !== '')
|
|
567
|
+
.map((w) => `${w.name}:${w.reason}`).join(';') || null,
|
|
568
|
+
drifted.length === 0 ? null : `${driftedText}`,
|
|
569
|
+
].filter((x) => x !== null).join(';'),
|
|
380
570
|
// 非常规来源(link:)写回时的**用户可见**说明:绝不静默(缺陷②的隐蔽性正是"装完看不出问题")。
|
|
381
571
|
// 文案由上面的"读回真实值"步骤产出,因此永远不会出现"面板说已钉住、磁盘上仍是版本号"。
|
|
382
572
|
depNote: depNotes.length > 0 ? depNotes.join(';') : null,
|
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.39",
|
|
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",
|