@noob-stupid/dsh-plugin-console 0.5.101 → 0.5.103

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.
@@ -10,100 +10,30 @@
10
10
  // 现在的顺序:① spec 是版本范围 → `pnpm update <pkg>`(spec 不变、lock 提到范围内最新)
11
11
  // ② 仍不是 latest(超出范围 / spec 是 git·file 来源)→ `pnpm add <pkg>@<latest>`(spec 与 lock 同步改写)
12
12
  // ③ 回读核实 readInstalledVersion / lockVersion;都不匹配才回落手铺文件,且**必须**带 lockNote 警告。
13
- import { existsSync, readFileSync, writeFileSync } from 'node:fs'
13
+ import { readFileSync } from 'node:fs'
14
14
  import { join } from 'node:path'
15
15
  import { linkSpecFor, linkSpecIsIntact, materializePackageForLink, probeRegistryPackage, sameSourceSpec, suggestedPinAction } from './dep-source.js'
16
16
  import { canWriteImporterEntry, lockEntryKeyMatches, verifyLockReadOnly, writeLockImporterEntry } from './lock-importer.js'
17
17
  import { readManifestDeps } from './lockfile-health.js'
18
18
  import { pnpmInstall } from './install.js'
19
+ // 同一个 profile + 包名 的安装互斥(2026-11-04 加法,见 domain/install-lock.js 顶部现场):
20
+ // 「插件卡片的更新」与「市场页那颗自更新 pill」动的是**同一个** node_modules/<控制台> 目录 —— 必须串行。
21
+ import { withInstallLockOr } from './install-lock.js'
19
22
  // 自更新兜底(2026-11-03 加法,见 self-update-guard.js 顶部事故记录):
20
23
  // begin/note/settle 是同一套判据与同一份 manualCommand;finishSelfUpdate 是本文件收尾段**原样搬出**的(腾行数用)。
21
24
  import { beginSelfUpdateGuard, finishSelfUpdate, noteGuardStep, settleSelfUpdateGuard } from './self-update-guard.js'
25
+ // 只读读数(spec 判定 / 已装版本 / lock 里钉住的版本)2026-11-04 搬进 selfupdate-read.js:
26
+ // 本文件贴着 600 行架构棘轮,而本轮要给"pnpm 卡住/失败后立刻手铺 + 补 lock"腾出空间。
27
+ // 名字与语义一个字未改;下面的 export 语句照旧把它们**原样 re-export**(外部调用方与测试不用改)。
28
+ import { isRegistryRange, lockVersion, profileSpec, readInstalledVersion } from './selfupdate-read.js'
29
+ // 手铺兜底(2026-11-04 加法,见 selfupdate-copy.js 顶部现场):pnpm 之前先把 tarball 备好 →
30
+ // pnpm 超时/失败就**只覆盖文件、不清空目录**地铺进去 → 再把 lock 的版本/完整性补上。
31
+ import { disposeSelfUpdateTarball, overlaySelfUpdateTarball, patchSelfUpdateLock, prepareSelfUpdateTarball } from './selfupdate-copy.js'
22
32
  import { fetchJsonUrl } from '../infra/http.js'
23
33
  import { runPnpmWithFallback } from '../infra/exec.js'
24
34
 
25
35
  const CONSOLE_PACKAGE = '@noob-stupid/dsh-plugin-console'
26
36
 
27
- /** spec 是否是 registry 版本范围(`^1.2.3` / `~1.2.3` / `1.2.3` / `>=1` / `=1`)。
28
- * 只有这种才能用 `pnpm update` 在**不改写 spec**的前提下把 lock 提到范围内最新;
29
- * git / file / link / workspace 来源没有"范围"可言,只能走 `pnpm add <pkg>@<版本>`。 */
30
- function isRegistryRange(spec) {
31
- if (typeof spec !== 'string') return false
32
- const s = spec.trim()
33
- if (/^[\^~]?\d/u.test(s)) return true
34
- return /^(>=|>|=)\s*\d/u.test(s)
35
- }
36
-
37
- /** profile 里该包的依赖声明(spec)。读不到返回 null。 */
38
- function profileSpec(profileDir, name) {
39
- try {
40
- const pkg = JSON.parse(readFileSync(join(profileDir, 'package.json'), 'utf8'))
41
- const spec = pkg?.dependencies?.[name]
42
- return typeof spec === 'string' ? spec : null
43
- } catch {
44
- return null
45
- }
46
- }
47
-
48
- /** 已安装版本(直接读 node_modules 里那份 package.json)。 */
49
- function readInstalledVersion(profileDir, name) {
50
- try {
51
- const pkg = JSON.parse(readFileSync(join(profileDir, 'node_modules', ...name.split('/'), 'package.json'), 'utf8'))
52
- return typeof pkg?.version === 'string' ? pkg.version : null
53
- } catch {
54
- return null
55
- }
56
- }
57
-
58
- /**
59
- * 从 pnpm-lock.yaml 读出该包被**钉住**的版本。解析策略(lock 格式五花八门,只认两种确定性位置,避免子串误命中):
60
- * ① importers 段:行内容恰为 `'<name>':`(可带缩进),随后几行内的 `version: X`;
61
- * ② packages 段:行首(可带缩进/引号)`<name>@<版本或 git+...>:` 的键名。
62
- * 读不到返回 null(调用方据此判定"没能核实",不谎报成功)。
63
- *
64
- * ★ 2026-09-22 修复(缺陷②连带):旧版 ① 在遇到 `specifier: …` 行时**直接 break**,而 importers 段
65
- * 恒为 `specifier:` 在前、`version:` 在后 → ① 从来没生效过,全靠 ② 兜底;而 ② 的正则用 `[^':\s]+`
66
- * 取值,遇到 `name@https://…tgz` / `link:…` 会在第一个冒号处截断(读出 `http`)或读不到(link 依赖
67
- * 在 lock 里没有 packages 条目 → 返回 null)。后果:来源钉住的包永远被判为「漂移」→ 每次安装都白跑
68
- * 一次 pnpm add,并给用户一条假的「没写进 lock」警告。现在 ① 跳过 specifier 行、② 取到行尾。
69
- */
70
- function lockVersion(profileDir, name) {
71
- const file = join(profileDir, 'pnpm-lock.yaml')
72
- if (!existsSync(file)) return null
73
- let lines = []
74
- try {
75
- lines = readFileSync(file, 'utf8').split(/\r?\n/u)
76
- } catch {
77
- return null
78
- }
79
- for (let i = 0; i < lines.length; i += 1) {
80
- // 键形态三种都认(0.5.38 改错):`name`、`'name':`(pnpm 对**含 `/`/`@`** 的包名加引号)、
81
- // `name:`(pnpm 对**未加 scope** 的包名不加引号)。旧判据漏了第三种 ⇒ 未加 scope 的 `link:`
82
- // 依赖在 importers 里明明有条目却读不出来(真机 desktop profile 的 `dshmarket` 就是:
83
- // lock 的 35-37 行写着 `dshmarket: / specifier: link:… / version: link:../../plugin-src/dshmarket`,
84
- // 而 `lockVersion()` 返回 null)——后果是面板永远报"没写进 pnpm-lock.yaml"、pin 永远 partial、
85
- // 每次对账都白跑一次 `pnpm add`。真值一律从下面这一处读,别在调用方再补一套。
86
- // 0.5.39:键判据搬进 lock-importer.js#lockEntryKeyMatches —— 定点**写**入也要认同一批键形态,
87
- // 两处各写一份必然再次分叉(写进去的键、读的时候认不出,就是 0.5.38 那个现场)。
88
- if (lockEntryKeyMatches(lines[i], name)) {
89
- for (let k = i + 1; k < Math.min(i + 8, lines.length); k += 1) {
90
- const m = /^\s*version:\s*(\S+)\s*$/u.exec(lines[k])
91
- if (m) return m[1]
92
- // 只在新包的键行(`'<name>':` 形态)处停止;`specifier: X` 有值,不是键行,必须继续往下看
93
- if (/^\s*'?[^\s:]+'?:\s*$/u.test(lines[k])) break
94
- }
95
- }
96
- }
97
- const escaped = name.replace(/[/\\^$*+?.()|[\]{}]/gu, '\\$&')
98
- // 取到行尾再剥尾部的引号/冒号:`'name@https://…tgz':` / `name@git+https://…#sha:` / `name@1.2.3:`
99
- const keyRe = new RegExp(`^\\s*'?${escaped}@(.+?)'?:\\s*$`, 'u')
100
- for (const line of lines) {
101
- const m = keyRe.exec(line)
102
- if (m) return m[1]
103
- }
104
- return null
105
- }
106
-
107
37
  /** 给用户的兜底建议命令(照用户报告里那条:让 lock 也变成新版本,而不是只改文件)。 */
108
38
  function selfUpdateCommand(latest, profileName = '<你的profile>') {
109
39
  return `dsh plugin --profile ${profileName} add ${CONSOLE_PACKAGE}@${latest}`
@@ -113,8 +43,37 @@ function selfUpdateCommand(latest, profileName = '<你的profile>') {
113
43
  * 把控制台升到 `latest` 并**确保写进 lock**。
114
44
  * 依赖以参数注入(便于单测替换):`runPnpm`(默认 runPnpmWithFallback)、`pnpmAdd`(默认 pnpmInstall)、
115
45
  * `curlManualInstall`(手铺文件兜底,由路由传入)。
46
+ *
47
+ * ★ 2026-11-04 加法(唯一的改动,见 domain/install-lock.js 顶部现场):整段操作过**同一个包互斥闸** ——
48
+ * 这条路和插件卡片的「更新」(`/install` → domain/install-channels.js)动的是**同一个**
49
+ * `node_modules/@noob-stupid/dsh-plugin-console`,真机事故就是两边同时装这个包。
50
+ * 拿不到锁 ⇒ 如实返回"正在安装中"(ok=false + reason),**不装、不碰目录**(结构同 finishSelfUpdate)。
51
+ */
52
+ async function selfUpdateToLatest(args = {}) {
53
+ return await withInstallLockOr(
54
+ { profileDir: args?.profileDir, packageName: CONSOLE_PACKAGE },
55
+ async () => await selfUpdateToLatestLocked(args),
56
+ (error) => ({
57
+ method: null, spec: profileSpec(args?.profileDir ?? '', CONSOLE_PACKAGE), installedVersion: readInstalledVersion(args?.profileDir ?? '', CONSOLE_PACKAGE),
58
+ lockVersion: lockVersion(args?.profileDir ?? '', CONSOLE_PACKAGE), lockUpdated: false, lockNote: null,
59
+ command: selfUpdateCommand(args?.latest, args?.profileName ?? '<你的profile>'), note: error.message, errors: [error.message],
60
+ ok: false, reason: error.message, selfUpdate: null,
61
+ }),
62
+ )
63
+ }
64
+
65
+ /**
66
+ * 上面那个互斥外壳里的正文。
67
+ *
68
+ * ★ 2026-11-04 加法(**三步的触发条件与顺序一个字未改**,只多了"预先备 tarball"和"手铺后补 lock"):
69
+ * 现场:pnpm 一启动就清空目标目录,在 profile 里重算整棵依赖树要几分钟 → 撞 90s/180s 超时被杀 →
70
+ * 目录停在"空了"的状态,手铺那一步(第③步)压根还没轮到;即使轮到,既有 curl 通道也是"先删再铺"。
71
+ * 现在:⓪ 先用既有 curl/元数据链路把目标版本 tarball 备到手(失败不影响后续)→ ① ② 照旧 →
72
+ * ③ 需要手铺时**先用备好的 tarball 只覆盖文件(绝不清空目录)**,不行才回落到既有 curl 通道 →
73
+ * ③c 手铺成功但 lock 没跟上 ⇒ 立刻补 lock(版本 + 完整性)并复核;补不上就如实记 lockNote。
74
+ * `copyDeps` 只是单测注入口(默认全是既有实现),路由侧一个字都不用改。
116
75
  */
117
- async function selfUpdateToLatest({ profileDir, latest, registries, curlManualInstall, runPnpm = runPnpmWithFallback, pnpmAdd = pnpmInstall, profileName = null, execOpts = {}, selfUpdateDeps = {} }) {
76
+ async function selfUpdateToLatestLocked({ profileDir, latest, registries, curlManualInstall, runPnpm = runPnpmWithFallback, pnpmAdd = pnpmInstall, profileName = null, execOpts = {}, selfUpdateDeps = {}, copyDeps = {} }) {
118
77
  const command = selfUpdateCommand(latest, profileName ?? '<你的profile>')
119
78
  const spec = profileSpec(profileDir, CONSOLE_PACKAGE)
120
79
  const errors = []
@@ -123,6 +82,9 @@ async function selfUpdateToLatest({ profileDir, latest, registries, curlManualIn
123
82
  // 自更新兜底(规矩:只对本插件生效 / 更新前就有完整副本 / 真的装在 node_modules 里,三条闸都在 begin 里)
124
83
  const guard = beginSelfUpdateGuard({ packageName: CONSOLE_PACKAGE, profileDir, targetVersion: latest, deps: selfUpdateDeps })
125
84
 
85
+ // ⓪ 走 pnpm **之前**先把目标版本的 tarball 备到手(失败只记原因,绝不阻断后续:三步顺序不变)
86
+ const prepared = await prepareSelfUpdateTarball({ packageName: CONSOLE_PACKAGE, version: latest, registries, deps: copyDeps })
87
+
126
88
  // ① spec 是版本范围 → pnpm update(spec 不变,lock 提到范围内最新)
127
89
  if (isRegistryRange(spec)) {
128
90
  try {
@@ -148,27 +110,65 @@ async function selfUpdateToLatest({ profileDir, latest, registries, curlManualIn
148
110
  let lock = lockVersion(profileDir, CONSOLE_PACKAGE)
149
111
  let lockUpdated = installed === latest && lock === latest
150
112
  let lockNote = null
113
+ let copy = null
114
+ let lockPatch = null
151
115
 
152
- // ③ 包管理器都没成 → 手铺文件兜底(**不写 lock**),并且必须明确警告,不能让人以为升级成功
116
+ // ③ 包管理器都没成 → 手铺文件兜底(触发条件与 0.5.102 一致:`!lockUpdated`)
153
117
  if (!lockUpdated) {
154
- try {
155
- const info = await curlManualInstall(profileDir, CONSOLE_PACKAGE, registries, null, latest)
156
- method = method === null ? 'manual-copy' : `${method}+manual-copy`
157
- installed = info?.version ?? readInstalledVersion(profileDir, CONSOLE_PACKAGE)
118
+ // ③a 先用**已备好的 tarball 只覆盖文件、不清空目录**(pnpm 卡住/失败时那条"立刻手铺"的腿)
119
+ const overlay = await overlaySelfUpdateTarball({ prepared, profileDir, packageName: CONSOLE_PACKAGE, expectedVersion: latest, deps: copyDeps })
120
+ if (overlay.ok === true) {
121
+ method = method === null ? 'manual-overlay' : `${method}+manual-overlay`
122
+ installed = overlay.version ?? readInstalledVersion(profileDir, CONSOLE_PACKAGE)
123
+ copy = { channel: 'overlay', files: overlay.files, preserved: overlay.preserved, version: overlay.version ?? null, boxNote: overlay.boxNote ?? null, noClear: true, reason: null }
124
+ } else {
125
+ // ③b 没 tarball / 覆盖失败 → **既有** curl 通道(先删再铺),行为与 0.5.102 逐字一致
126
+ try {
127
+ const info = await curlManualInstall(profileDir, CONSOLE_PACKAGE, registries, null, latest)
128
+ method = method === null ? 'manual-copy' : `${method}+manual-copy`
129
+ installed = info?.version ?? readInstalledVersion(profileDir, CONSOLE_PACKAGE)
130
+ copy = { channel: 'curl', files: null, preserved: null, version: info?.version ?? null, boxNote: info?.boxNote ?? null, noClear: false, reason: overlay.reason ?? null }
131
+ } catch (error) {
132
+ errors.push(`手动铺文件失败:${label(error)}`)
133
+ copy = { channel: null, files: null, preserved: null, version: null, boxNote: null, noClear: false, reason: overlay.reason ?? null }
134
+ }
135
+ }
136
+ lock = lockVersion(profileDir, CONSOLE_PACKAGE)
137
+ lockUpdated = installed === latest && lock === latest
138
+ // ③c 手铺成功(版本对了)但 lock 还钉在旧版本 ⇒ **必须补 lock**,否则下一次 pnpm 操作就把它还原
139
+ if (installed === latest && !lockUpdated) {
140
+ lockPatch = await patchSelfUpdateLock({
141
+ profileDir, packageName: CONSOLE_PACKAGE, targetVersion: latest, spec,
142
+ integrity: prepared.ok === true ? prepared.integrity : null,
143
+ section: manifestSectionOf(profileDir, CONSOLE_PACKAGE), deps: copyDeps,
144
+ })
158
145
  lock = lockVersion(profileDir, CONSOLE_PACKAGE)
159
146
  lockUpdated = installed === latest && lock === latest
160
- } catch (error) {
161
- errors.push(`手动铺文件失败:${label(error)}`)
147
+ if (lockPatch.reason !== null) errors.push(`补 pnpm-lock.yaml 未成功:${lockPatch.reason}`)
162
148
  }
163
149
  noteGuardStep(guard, 'manual-copy')
164
150
  if (!lockUpdated) {
165
151
  lockNote = `此更新未写入 pnpm-lock.yaml(lock 里仍是 ${lock ?? '未知'}):之后任何 pnpm 操作(开关插件、dsh plugin add/remove)都会把它还原成 lock 里的版本。要真正落地请执行:${command}`
152
+ if (lockPatch !== null && lockPatch.reason !== null) lockNote = `${lockNote}(补 lock 未成功:${lockPatch.reason})`
166
153
  }
167
154
  }
168
155
 
156
+ // 临时工作目录(预下载的 tarball / 解包目录)收尾即删;删不掉要如实记下来(本机 %TEMP% 下 rmSync 会静默落空)
157
+ const cleanup = disposeSelfUpdateTarball(prepared)
158
+
169
159
  // 收尾:三步都走完才统一校验(全部走完仍不过才自愈),然后交给**原样搬出**的收尾段组装返回结构
170
160
  const selfUpdate = settleSelfUpdateGuard(guard, { targetVersion: latest })
171
- return finishSelfUpdate({ profileDir, packageName: CONSOLE_PACKAGE, latest, spec, installed, method, command, errors, lockVersion: lock, lockUpdated, lockNote, registryRange: isRegistryRange, selfUpdate })
161
+ const tarball = {
162
+ ok: prepared.ok === true,
163
+ version: prepared.version ?? latest,
164
+ integrity: prepared.integrity ?? null,
165
+ tarball: prepared.tarball ?? null,
166
+ registry: prepared.registry ?? null,
167
+ reason: prepared.reason ?? null,
168
+ ms: prepared.ms ?? 0,
169
+ cleanup: { removed: cleanup.removed === true, note: cleanup.note ?? null },
170
+ }
171
+ return finishSelfUpdate({ profileDir, packageName: CONSOLE_PACKAGE, latest, spec, installed, method, command, errors, lockVersion: lock, lockUpdated, lockNote, registryRange: isRegistryRange, selfUpdate, copy, tarball, lockPatch })
172
172
  }
173
173
 
174
174
  /**
@@ -32,6 +32,7 @@ import { PLUGIN_SRC_DIR, linkSpecFor, sameSourceSpec } from './dep-source.js'
32
32
  import { noteChannel } from './git-channel.js'
33
33
  import { addBundleToManifest, detectBundleOnly } from './install.js'
34
34
  import { finalizeInstallPersistence } from './install-finalize.js'
35
+ import { INSTALL_LOCK_TIMEOUT, withInstallLock } from './install-lock.js'
35
36
  import { alreadyInstalledTarget, verifyInstalledEntry } from './install-verify.js'
36
37
  import { ensureLinkSelfSufficiency } from './link-self-sufficiency.js'
37
38
  import { setProfileDependency } from './manifest.js'
@@ -313,9 +314,15 @@ async function runSubdirInstallJobInner(job, ports, deps = {}) {
313
314
  job.subdirSource = { dir, from: acquired.dir, reused: acquired.reused === true, placed: placed.dir, version: recheck.version }
314
315
  // ⑥ 本地目录来源 = 既有安装通道:`pnpm add link:<目录>` 建起 node_modules 里的那条链接
315
316
  // (link: 是本地目录的正常形态,见 source-spec.js 判据 C;依赖由下一步的自足性补齐)
317
+ // ★ 2026-11-04:过**同一个包互斥闸**(domain/install-lock.js)—— 它动的也是 node_modules/<包名>
316
318
  try {
317
- await channels.pnpmInstall(profileDir, spec, undefined)
319
+ await withInstallLock({ profileDir, packageName, job }, async () => await channels.pnpmInstall(profileDir, spec, undefined))
318
320
  } catch (error) {
321
+ // 排队等不到(INSTALL_LOCK_TIMEOUT)与 pnpm 真失败都是"没装上",但文案必须分开(不把排队说成安装失败)
322
+ if (error?.code === INSTALL_LOCK_TIMEOUT) {
323
+ job.installLock = { ...(job.installLock ?? {}), timeout: true, reason: error.message }
324
+ return failSubdirInstall(job, error.message)
325
+ }
319
326
  return failSubdirInstall(job, `从子目录 ${dir}/ 安装失败(本地目录来源 ${spec}):${error instanceof Error ? error.message : String(error)}。`
320
327
  + `源码已取到本地(${placed.dir}),可重试;重试会复用已取到的源码。`)
321
328
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@noob-stupid/dsh-plugin-console",
3
- "version": "0.5.101",
3
+ "version": "0.5.103",
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",