@noob-stupid/dsh-plugin-console 0.5.100 → 0.5.102

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.
@@ -16,6 +16,12 @@ import { linkSpecFor, linkSpecIsIntact, materializePackageForLink, probeRegistry
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'
22
+ // 自更新兜底(2026-11-03 加法,见 self-update-guard.js 顶部事故记录):
23
+ // begin/note/settle 是同一套判据与同一份 manualCommand;finishSelfUpdate 是本文件收尾段**原样搬出**的(腾行数用)。
24
+ import { beginSelfUpdateGuard, finishSelfUpdate, noteGuardStep, settleSelfUpdateGuard } from './self-update-guard.js'
19
25
  import { fetchJsonUrl } from '../infra/http.js'
20
26
  import { runPnpmWithFallback } from '../infra/exec.js'
21
27
 
@@ -110,13 +116,34 @@ function selfUpdateCommand(latest, profileName = '<你的profile>') {
110
116
  * 把控制台升到 `latest` 并**确保写进 lock**。
111
117
  * 依赖以参数注入(便于单测替换):`runPnpm`(默认 runPnpmWithFallback)、`pnpmAdd`(默认 pnpmInstall)、
112
118
  * `curlManualInstall`(手铺文件兜底,由路由传入)。
119
+ *
120
+ * ★ 2026-11-04 加法(唯一的改动,见 domain/install-lock.js 顶部现场):整段操作过**同一个包互斥闸** ——
121
+ * 这条路和插件卡片的「更新」(`/install` → domain/install-channels.js)动的是**同一个**
122
+ * `node_modules/@noob-stupid/dsh-plugin-console`,真机事故就是两边同时装这个包。
123
+ * 拿不到锁 ⇒ 如实返回"正在安装中"(ok=false + reason),**不装、不碰目录**(结构同 finishSelfUpdate)。
113
124
  */
114
- async function selfUpdateToLatest({ profileDir, latest, registries, curlManualInstall, runPnpm = runPnpmWithFallback, pnpmAdd = pnpmInstall, profileName = null, execOpts = {} }) {
125
+ async function selfUpdateToLatest(args = {}) {
126
+ return await withInstallLockOr(
127
+ { profileDir: args?.profileDir, packageName: CONSOLE_PACKAGE },
128
+ async () => await selfUpdateToLatestLocked(args),
129
+ (error) => ({
130
+ method: null, spec: profileSpec(args?.profileDir ?? '', CONSOLE_PACKAGE), installedVersion: readInstalledVersion(args?.profileDir ?? '', CONSOLE_PACKAGE),
131
+ lockVersion: lockVersion(args?.profileDir ?? '', CONSOLE_PACKAGE), lockUpdated: false, lockNote: null,
132
+ command: selfUpdateCommand(args?.latest, args?.profileName ?? '<你的profile>'), note: error.message, errors: [error.message],
133
+ ok: false, reason: error.message, selfUpdate: null,
134
+ }),
135
+ )
136
+ }
137
+
138
+ /** 上面那个互斥外壳里的正文(原来的函数体,逐字未改)。 */
139
+ async function selfUpdateToLatestLocked({ profileDir, latest, registries, curlManualInstall, runPnpm = runPnpmWithFallback, pnpmAdd = pnpmInstall, profileName = null, execOpts = {}, selfUpdateDeps = {} }) {
115
140
  const command = selfUpdateCommand(latest, profileName ?? '<你的profile>')
116
141
  const spec = profileSpec(profileDir, CONSOLE_PACKAGE)
117
142
  const errors = []
118
143
  let method = null
119
144
  const label = (e) => (e instanceof Error ? e.message : String(e))
145
+ // 自更新兜底(规矩:只对本插件生效 / 更新前就有完整副本 / 真的装在 node_modules 里,三条闸都在 begin 里)
146
+ const guard = beginSelfUpdateGuard({ packageName: CONSOLE_PACKAGE, profileDir, targetVersion: latest, deps: selfUpdateDeps })
120
147
 
121
148
  // ① spec 是版本范围 → pnpm update(spec 不变,lock 提到范围内最新)
122
149
  if (isRegistryRange(spec)) {
@@ -126,6 +153,7 @@ async function selfUpdateToLatest({ profileDir, latest, registries, curlManualIn
126
153
  } catch (error) {
127
154
  errors.push(`pnpm update 失败:${label(error)}`)
128
155
  }
156
+ noteGuardStep(guard, 'pnpm-update') // 这一步结束就校验一次(只记录,自愈留到收尾统一做)
129
157
  }
130
158
  // ② 还没到 latest(超出 range / git·file 来源)→ pnpm add <pkg>@<latest>(spec 与 lock 一起改写)
131
159
  if (readInstalledVersion(profileDir, CONSOLE_PACKAGE) !== latest) {
@@ -135,6 +163,7 @@ async function selfUpdateToLatest({ profileDir, latest, registries, curlManualIn
135
163
  } catch (error) {
136
164
  errors.push(`pnpm add 失败:${label(error)}`)
137
165
  }
166
+ noteGuardStep(guard, 'pnpm-add')
138
167
  }
139
168
 
140
169
  let installed = readInstalledVersion(profileDir, CONSOLE_PACKAGE)
@@ -153,40 +182,15 @@ async function selfUpdateToLatest({ profileDir, latest, registries, curlManualIn
153
182
  } catch (error) {
154
183
  errors.push(`手动铺文件失败:${label(error)}`)
155
184
  }
185
+ noteGuardStep(guard, 'manual-copy')
156
186
  if (!lockUpdated) {
157
187
  lockNote = `此更新未写入 pnpm-lock.yaml(lock 里仍是 ${lock ?? '未知'}):之后任何 pnpm 操作(开关插件、dsh plugin add/remove)都会把它还原成 lock 里的版本。要真正落地请执行:${command}`
158
188
  }
159
189
  }
160
190
 
161
- const sourceSwitch = spec !== null && !isRegistryRange(spec) ? `安装来源已从 ${spec} 变为 registry 版本 ${latest}` : null
162
-
163
- // ④ 清单 spec 也要跟上(2026-09-24 实测踩到:lock 写了、spec 还钉着 0.3.57,
164
- // 于是下一次 pnpm 操作会把控制台「降级」回 0.3.57 —— 包管理器通道 ①② 通常会顺手改写 spec,
165
- // 但手铺兜底不会,所以这里统一收口:装到哪版就把 spec 写成哪版(来源型 spec 除外,绝不丢来源)。
166
- let specUpdated = null
167
- if (installed === latest && (spec === null || isRegistryRange(spec))) {
168
- try {
169
- const manifestPath = join(profileDir, 'package.json')
170
- const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'))
171
- const deps = { ...(manifest.dependencies ?? {}) }
172
- if (deps[CONSOLE_PACKAGE] !== latest) {
173
- deps[CONSOLE_PACKAGE] = latest
174
- manifest.dependencies = deps
175
- writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8')
176
- specUpdated = latest
177
- }
178
- } catch (error) {
179
- errors.push(`清单 spec 同步失败:${label(error)}`)
180
- }
181
- } else if (installed === latest && spec !== null && !isRegistryRange(spec)) {
182
- errors.push(`清单 spec 是来源型(${spec}),按约定保留不改写`)
183
- }
184
-
185
- const noteParts = [lockUpdated ? '已写入 pnpm-lock.yaml,重启服务后生效' : '已铺入文件但未写入 pnpm-lock.yaml:重启后可用,但会被 pnpm 还原']
186
- if (sourceSwitch !== null) noteParts.push(sourceSwitch)
187
- if (specUpdated !== null) noteParts.push(`清单 spec 已同步为 ${specUpdated}(防 pnpm 降级)`)
188
-
189
- return { method, spec: specUpdated ?? spec, installedVersion: installed, lockVersion: lock, lockUpdated, lockNote, command, note: noteParts.join(';'), errors }
191
+ // 收尾:三步都走完才统一校验(全部走完仍不过才自愈),然后交给**原样搬出**的收尾段组装返回结构
192
+ const selfUpdate = settleSelfUpdateGuard(guard, { targetVersion: latest })
193
+ return finishSelfUpdate({ profileDir, packageName: CONSOLE_PACKAGE, latest, spec, installed, method, command, errors, lockVersion: lock, lockUpdated, lockNote, registryRange: isRegistryRange, selfUpdate })
190
194
  }
191
195
 
192
196
  /**
@@ -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
  }
@@ -15,6 +15,8 @@ import { markPendingAdopted } from '../domain/quarantine.js'
15
15
  import { pendingRestartJobs, packageDirIn, needsPnpmRemove, revokePendingInstall } from '../domain/revoke.js'
16
16
  import { deriveEntryId, isProtectedModule, listEntries } from '../domain/runtime.js'
17
17
  import { selfUpdateToLatest } from '../domain/selfupdate.js'
18
+ // 自更新响应视图(2026-11-03 搬出:这个文件贴着 600 行棘轮;字段与取值逻辑一字未改,只多 selfUpdate/reason)
19
+ import { selfUpdateView } from '../domain/self-update-guard.js'
18
20
  import { orderedRegistries, readSources } from '../domain/sources.js'
19
21
  import { fetchJsonUrl } from '../infra/http.js'
20
22
  import { sendError, sendJson } from '../infra/httpd.js'
@@ -573,22 +575,9 @@ async function routeSelfUpdate(req, res, rc) {
573
575
  // 包管理器优先(见 domain/selfupdate.js 顶部注释):旧实现只把文件铺进 node_modules、不写
574
576
  // pnpm-lock.yaml → "看起来升级成功、下一次 pnpm 操作就被还原"(用户 2026-09-20 实测报告)。
575
577
  const result = await selfUpdateToLatest({ profileDir, latest, registries, curlManualInstall })
576
- sendJson(res, 200, {
577
- ok: true,
578
- current: selfVersion,
579
- latest,
580
- updated: true,
581
- version: result.installedVersion ?? latest,
582
- method: result.method,
583
- spec: result.spec,
584
- installedVersion: result.installedVersion,
585
- lockVersion: result.lockVersion,
586
- lockUpdated: result.lockUpdated,
587
- lockNote: result.lockNote,
588
- command: result.command,
589
- note: result.note,
590
- errors: result.errors,
591
- })
578
+ // 响应视图统一在 domain/self-update-guard.js#selfUpdateView(字段与取值原样搬出,只多 selfUpdate/reason;
579
+ // 顺便给这个文件腾出 600 行棘轮的余量)。失败时 ok 与 updated 同时为 false —— 绝不把"没落地"报成更新成功。
580
+ sendJson(res, 200, selfUpdateView({ result, current: selfVersion, latest }))
592
581
  } catch (error) {
593
582
  sendError(res, 500, `Hub 自动更新失败:${error instanceof Error ? error.message : String(error)}`)
594
583
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@noob-stupid/dsh-plugin-console",
3
- "version": "0.5.100",
3
+ "version": "0.5.102",
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",