@noob-stupid/dsh-plugin-console 0.5.37 → 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.
package/lib/client.js CHANGED
@@ -113,6 +113,7 @@ window.__ModuleLoader__.load({
113
113
  // 2026-09-27 加法:安装结果旁的「建议动作」执行框(短句进面板,长解释进悬浮 title)
114
114
  actionBoxTitle: "建议动作",
115
115
  actionPinLabel: "钉住该依赖(按 link: 记录)",
116
+ actionPersistLabel: "一键钉住(清单 + lock + 挂载行)",
116
117
  actionReconcileLabel: "重建 pnpm-lock.yaml",
117
118
  actionRun: "执行",
118
119
  actionCopy: "复制",
@@ -279,11 +280,16 @@ window.__ModuleLoader__.load({
279
280
  subpackagesEmpty: "未发现可安装的子包。",
280
281
  recentFailures: "最近安装失败",
281
282
  installed: "已安装并启用",
283
+ // 0.5.38 加法:三处没齐备(清单 / lock / 挂载行)时**绝不**说"已安装并启用"——
284
+ // 那种状态下任何一次 pnpm 操作或重启都会把它清掉(真机 dshmarket 现场)。
285
+ installedNotPersisted: "⚠ 已装上但未持久化(重启/重装会消失)",
282
286
  installNote: "HMR 正在生效;若插件带界面,请刷新页面",
283
287
  bundleNote: "该包是 bundle 层插件(如皮肤合集),重启 DSH 服务后生效",
284
288
  // ── 安装诊断短句(2026-09-26 加法):面板只放短句;解释性长文留在服务端字段里,
285
289
  // 前端只把它挂到 title 悬浮提示上,不铺进面板(用户明确要求)──
286
290
  diagCardTitle: "安装诊断",
291
+ // 0.5.38 加法:持久化判据(服务端 domain/persist.js)——三处任一没就位就出这张卡
292
+ diagNotPersisted: "未持久化:清单 / lock / 挂载行有缺口",
287
293
  // 分隔符也进字典:英文界面用半角标点,避免中文全角标点混进英文句子
288
294
  diagColon: ":",
289
295
  diagSemi: ";",
@@ -674,6 +680,7 @@ window.__ModuleLoader__.load({
674
680
  safetyLockRepairBtn: "Rebuild lock (explicit)",
675
681
  actionBoxTitle: "Suggested action",
676
682
  actionPinLabel: "Pin this dependency (record as link:)",
683
+ actionPersistLabel: "Pin it in one click (manifest + lock + mount row)",
677
684
  actionReconcileLabel: "Rebuild pnpm-lock.yaml",
678
685
  actionRun: "Run",
679
686
  actionCopy: "Copy",
@@ -838,11 +845,13 @@ window.__ModuleLoader__.load({
838
845
  subpackagesEmpty: "No installable subpackages found.",
839
846
  recentFailures: "Recent install failures",
840
847
  installed: "Installed and enabled",
848
+ installedNotPersisted: "⚠ Installed but NOT persisted (it disappears on restart/reinstall)",
841
849
  installNote: "HMR is applying; refresh the page if the plugin ships UI",
842
850
  bundleNote: "This package is a bundle-layer plugin (e.g. skin packs); restart the DSH service to activate it",
843
851
  // ── install-diagnosis one-liners (2026-09-26, additive): the panel shows short sentences only;
844
852
  // the long server-side explanations go to the title tooltip, never into the panel body ──
845
853
  diagCardTitle: "Install diagnosis",
854
+ diagNotPersisted: "Not persisted: a gap remains in manifest / lock / mount row",
846
855
  // separators live in the dictionary too: English uses half-width punctuation
847
856
  diagColon: ": ",
848
857
  diagSemi: "; ",
@@ -3282,6 +3291,12 @@ window.__ModuleLoader__.load({
3282
3291
  // 「预设:新建会话时选择」(点击安装的当下没有新会话,必须先说清楚下一步)。
3283
3292
  setMessage(t("presetInstalledMsg") + ":" + data.presetNote + t("presetDot") + diagMessage(data));
3284
3293
  setReloadHint(true);
3294
+ } else if (data.persisted === false) {
3295
+ // 0.5.38 加法(用户诉求「下载下来就不用 lock、重启也不会消失」):
3296
+ // 三处没齐备时**绝不报"已安装并启用"** —— 说清它还缺什么、后果是什么,
3297
+ // 并把服务端下发的「一键钉住」执行框带出来(下面的 suggestedAction 分支)。
3298
+ setMessage(t("installedNotPersisted") + ":" + data.packageName + (typeof data.persistNote === "string" && data.persistNote !== "" ? "。" + data.persistNote : "") + diagMessage(data));
3299
+ setReloadHint(true);
3285
3300
  } else {
3286
3301
  setMessage(t("installed") + ":" + data.packageName + "(" + (data.entryId ?? "") + ")。" + (data.curlNote ?? (data.bundle ? t("bundleNote") : t("installNote"))) + (data.bundleNote ? "。" + data.bundleNote : "") + (data.lockNote ? "。⚠️ " + data.lockNote : "") + (data.depNote ? "。" + data.depNote : "") + diagMessage(data));
3287
3302
  }
@@ -3293,6 +3308,9 @@ window.__ModuleLoader__.load({
3293
3308
  }
3294
3309
  if (data.bundle) {
3295
3310
  setRestartHint(true);
3311
+ } else if (data.persisted === false) {
3312
+ // 未持久化:不自动刷新(卡片上的「一键钉住」还没点,刷新会把结果冲掉)
3313
+ setReloadHint(true);
3296
3314
  } else if ((data.suggestedAction !== null && data.suggestedAction !== undefined)
3297
3315
  || (typeof data.presetNote === "string" && data.presetNote !== "")) {
3298
3316
  // 有建议动作时不自动刷新页面(2026-09-27 加法):旧行为 2.5 秒后 reload,
@@ -3428,6 +3446,17 @@ window.__ModuleLoader__.load({
3428
3446
  title: channelNotes.join("\n"),
3429
3447
  });
3430
3448
  }
3449
+ // ⑤ 持久化判据(0.5.38 加法,服务端 domain/persist.js:{ missing, actual, note }):
3450
+ // 三处(清单 spec / pnpm-lock.yaml 条目 / 挂载行)任一没就位 → error 级诊断行,
3451
+ // 完整缺口清单挂 title。**装成功但没钉住**的作业从此不会只显示"已安装并启用"。
3452
+ if (view.status === "done" && view.persisted === false) {
3453
+ lines.push({
3454
+ key: "persist",
3455
+ text: t("diagNotPersisted") + (Array.isArray(view.persist?.missing) && view.persist.missing.length > 0 ? t("diagColon") + view.persist.missing.join(" / ") : ""),
3456
+ level: "error",
3457
+ title: typeof view.persistNote === "string" && view.persistNote !== "" ? view.persistNote : (typeof view.persist?.note === "string" ? view.persist.note : null),
3458
+ });
3459
+ }
3431
3460
  return lines;
3432
3461
  };
3433
3462
  /** 诊断短句 → 消息栏后缀(空串 = 没问题):只在既有文案**末尾追加**,一个字都不替换。 */
@@ -5731,6 +5760,7 @@ window.__ModuleLoader__.load({
5731
5760
  if (action === null || typeof action.command !== "string" || state.closed === true) return null;
5732
5761
  const running = state.phase === "running";
5733
5762
  const label = action.kind === "pin-dependency" ? t("actionPinLabel")
5763
+ : action.kind === "persist-plugin" ? t("actionPersistLabel")
5734
5764
  : action.kind === "reconcile-lock" ? t("actionReconcileLabel")
5735
5765
  : action.kind === "allow-builds" ? t("actionAllowLabel")
5736
5766
  : action.kind === "overwrite-preset" ? t("actionOverwritePresetLabel")
@@ -31,8 +31,15 @@ function readBundlePatchRefNames(profileDir, pkgName) {
31
31
  if (/^- insert:\s*$/u.test(line)) { inInsert = true; continue }
32
32
  if (/^- /u.test(line) && !/^ {4}- /u.test(line)) inInsert = false
33
33
  if (!inInsert) continue
34
- const nameM = (lines[i + 1] ?? '').match(/^ {6}name: ['"]([^'"]+)['"]\s*$/u)
35
- if (nameM) names.push(nameM[1])
34
+ // ★ 2026-10-04 改错(0.5.38):`name:` 的值**加不加引号都是合法 YAML** —— 真机 dsh-whale-widget
35
+ // 写的是不带引号的 `name: dsh-whale-widget`,旧判据只认带引号 ⇒ 引用清单为空 ⇒ 注册前的
36
+ // "引用包是否都可解析"这道防崩闸门对这类包**形同不存在**。现在两种形态都认。
37
+ const nameM = (lines[i + 1] ?? '').match(/^ {6}name:\s*(.+?)\s*$/u)
38
+ if (nameM) {
39
+ const raw = nameM[1].replace(/\s+#.*$/u, '').trim()
40
+ const value = /^(['"]).*\1$/u.test(raw) ? raw.slice(1, -1) : raw
41
+ if (value !== '') names.push(value)
42
+ }
36
43
  }
37
44
  return names
38
45
  } catch { return [] }
@@ -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 }
@@ -1,11 +1,13 @@
1
- // L1 · domain —— install-cleanup.js(安装失败清场 + 授权被拒/超时后的失败文案)
1
+ // L1 · domain —— install-cleanup.js(安装失败清场 + 授权被拒/超时后的失败文案 + pnpm remove)
2
2
  // 2026-09-27 从 install-job.js 原样搬出(install-job.js 触到 600 行硬顶,见 test-architecture-guard.mjs)。
3
3
  // 只搬移、未改逻辑;install-job.js 继续 re-export 这两个名字,调用点与测试的 import 面一个字都没变。
4
+ // 2026-10-04:`pnpmRemove` 同样**原样搬入**(0.5.38 要给安装收口腾行数;卸载路径与清场同族)。
4
5
 
5
6
  import { existsSync, readdirSync } from 'node:fs'
6
7
  import { basename, dirname, join } from 'node:path'
7
8
  import { AI_CONSENT_TIMEOUT_MS } from './install.js'
8
9
  import { disposeDir, disposeNote } from '../infra/fsx.js'
10
+ import { buildPnpmEnv, runPnpmWithFallback } from '../infra/exec.js'
9
11
 
10
12
  /** 安装失败清场:把本次尝试过的候选包目录与 pnpm `_tmp_` 半成品一起清掉,并**如实汇报**清了什么、什么没清掉
11
13
  * (2026-09-20 真装演练:聚合仓库跑 19 分钟后失败,node_modules 留着 `…_tmp_56272_2` 半成品,面板只说"安装失败")。
@@ -59,4 +61,23 @@ function aiConsentFailureText(decision, leftovers, timeoutMs = AI_CONSENT_TIMEOU
59
61
  return `${base}${cleanedNote}${trashedNote}${failedNote}`
60
62
  }
61
63
 
62
- export { cleanupAttemptedCandidates, aiConsentFailureText }
64
+ /** 用 corepack pnpm 移除包(与安装同一管理器与凭据抑制环境)。
65
+ * 注意:pnpm remove 不支持 --registry 选项,去掉避免静默失败。
66
+ * 2026-10-04 从 install-job.js **原样搬入**(那边要给安装收口腾行数;install-job.js 继续 re-export)。 */
67
+ async function pnpmRemove(profileDir, packageName) {
68
+ const args = ['remove', packageName]
69
+ const opts = {
70
+ cwd: profileDir,
71
+ timeout: 120000,
72
+ windowsHide: true,
73
+ maxBuffer: 4 * 1024 * 1024,
74
+ // 2026-09-26:env 统一走 buildPnpmEnv(在既有 COREPACK_NPM_REGISTRY / 禁交互之上追加
75
+ // npm_config_fetch_timeout / npm_config_fetch_retries / CI=true)。**不加** --fetch-timeout 类
76
+ // CLI 选项:实测 `pnpm remove --fetch-timeout=…` 会 `[ERROR] Unknown options` 直接失败。
77
+ env: buildPnpmEnv('https://registry.npmmirror.com'),
78
+ }
79
+ // 与 pnpmInstall 同一套跨平台定位(Linux 的 corepack 不在 <node bin>/node_modules 下)
80
+ await runPnpmWithFallback(args, { execOpts: opts })
81
+ }
82
+
83
+ export { cleanupAttemptedCandidates, aiConsentFailureText, pnpmRemove }
@@ -2,13 +2,12 @@
2
2
  // 分层 Step 8c-2 从 lib/index.js 搬出;形参由 ctx 收窄为 ports(调用方注入的窄接口),只搬移未改逻辑。
3
3
  // 分层分组:L1 · domain(边界由 tests/test-architecture-guard.mjs 断言)
4
4
 
5
- import { readFileSync, existsSync } from 'node:fs'
6
5
  import { readFile } from 'node:fs/promises'
7
6
  import { dirname, join, resolve } from 'node:path'
8
7
  import { aiRepair } from './ai-run.js'
9
8
  import { maybeAutoAdaptCompat } from './compat.js'
10
9
  import { getReposDir } from './repoland.js'
11
- import { aiConsentFailureText, cleanupAttemptedCandidates } from './install-cleanup.js'
10
+ import { aiConsentFailureText, cleanupAttemptedCandidates, pnpmRemove } from './install-cleanup.js'
12
11
  import { GIT_CHANNEL_BUDGET_MS, GIT_MIN_ATTEMPT_MS, GIT_SPEC_TIMEOUT_MS, gitChannelBudgetMs, noteChannel, formatBudgetMs, tryGitChannel } from './git-channel.js'
13
12
  import { DEFAULT_BUNDLES, AI_CONSENT_TIMEOUT_MS, PNPM_INSTALL_TIMEOUT_MS, addBundleToManifest, backfillMissingDeps, curlManualInstall, detectBundleOnly, ensureBundlePatchIntegrity, githubReleaseInstall, pnpmInstall, raceInstallChannels, readGithubAuth, syncAggregateSubpackageVersions } from './install.js'
14
13
  import { readBundlePatchRefNames } from './bundle-refs.js' // 0.5.26 搬家:install.js 贴着 600 行硬顶
@@ -19,14 +18,14 @@ import { tryPresetSourceChannel } from './preset-source.js'
19
18
  import { settlePresetOutcome } from './preset-in-package.js'
20
19
  import { alreadyInstalledTarget, verifyInstalledEntry } from './install-verify.js'
21
20
  import { applyNpmNameHint, expandSubpackageCandidates, fetchRepoPackage, fetchRepoPackageEx, fetchSubpackageNames, npmNameHintForRepo, packageProbeErrorText, subpackageCandidates } from './market.js'
22
- import { declareProfileDependency } from './manifest.js'
23
- import { appendInsert, readPatchState, syncNameFromNote } from './patch.js'
24
- import { deriveEntryId, listEntries } from './runtime.js'
21
+ import { ensurePersisted } from './persist.js'
22
+ import { readPatchState, syncNameFromNote } from './patch.js'
23
+ import { listEntries } from './runtime.js'
25
24
  import { detectSkillRepo, runSkillInstallJob } from './skills.js'
26
25
  import { orderedRegistries, readSources } from './sources.js'
27
26
  import { reconcileLockfile } from './selfupdate.js'
28
27
  import { fetchRepoMeta, runSuiteInstallJob, shouldRunSuiteInstall } from './suite.js'
29
- import { buildPnpmEnv, execFileAsync, runPnpmWithFallback } from '../infra/exec.js'
28
+ import { execFileAsync } from '../infra/exec.js'
30
29
  import { cleanupStalePackageDir, startTrashCleanup, trashScanRoots } from '../infra/fsx.js'
31
30
  import { findPatchPath, packageNameOf, resolvePackageJson } from '../infra/paths.js'
32
31
 
@@ -224,14 +223,6 @@ async function runInstallJob(job, ports, deps = {}) {
224
223
  // 源码通道(体积门禁 + 稀疏取源码 + 预设装配)的注入缝:与 marketProbes 同一风格,生产调用方不传。
225
224
  const sourceDeps = deps.sourceDeps ?? {}
226
225
  const aiConsentTimeoutMs = Number.isFinite(deps.aiConsentTimeoutMs) && deps.aiConsentTimeoutMs > 0 ? deps.aiConsentTimeoutMs : AI_CONSENT_TIMEOUT_MS
227
- /** 「安装即声明」的收尾(三个调用点共用一个出口):清单写回结果 → job 上的可见字段。
228
- * depNote / suggestedAction 也在这里落地:面板必须能看见"清单里**实际**写成了什么",以及"没写成时点哪个按钮"。 */
229
- const applyDeclared = (declared) => {
230
- if (declared.changed) job.declared = declared.version
231
- if (declared.depNote !== null) job.depNote = declared.depNote
232
- if (declared.suggested !== null) job.suggestedAction = declared.suggested
233
- if (declared.lockSynced === false && declared.lockNote !== null) job.lockNote = declared.lockNote
234
- }
235
226
  try {
236
227
  job.stage = 'preparing'
237
228
  // 2026-09-06 事故(室友机器):对 deepseek-ai/deepseek-harness(框架本体仓库)点安装会按 bundle 规则
@@ -479,7 +470,10 @@ async function runInstallJob(job, ports, deps = {}) {
479
470
  // lock 对账(2026-09-21,用户报告的自更新缺陷同源):主包 + 本次被补装/对齐的聚合子包一起核对,
480
471
  // 一次 pnpm add 把漂移的包全写进 lock;对不上就留 lockNote,让面板如实告知(不假装成功)。
481
472
  try {
482
- const lock = await reconcileLockfile({
473
+ // 同一个离线测试缝(见下方 `deps.persistDeps` 注释):安装路径上"动 lock"的两处都归它,
474
+ // 否则负控里这一处会先偷偷把 lock 写齐,收口那一步就再也测不出"未持久化"。
475
+ const reconcile = typeof deps.persistDeps?.reconcile === 'function' ? deps.persistDeps.reconcile : reconcileLockfile
476
+ const lock = await reconcile({
483
477
  profileDir,
484
478
  packages: [installedName, ...syncedNames].map((name) => ({ name })),
485
479
  registries,
@@ -501,7 +495,14 @@ async function runInstallJob(job, ports, deps = {}) {
501
495
  const alreadyServed = listEntries(ports).some((e) => String(e.moduleName).startsWith(installedName)
502
496
  || String(e.moduleName) === installedName
503
497
  || packageNameOf(e.moduleName) === installedName)
498
+ // 安装收口(0.5.38):**一次动作内**把该插件钉住 —— ① 清单 spec ② pnpm-lock.yaml 条目
499
+ // ③ 挂载行(cordis.patch.yml 的 insert 行 / dsh.profile.bundles 的 bundle 层)。
500
+ // 三处齐备才算 `persisted`;缺任何一处都**如实**报「未持久化」+ 一键钉住动作 —— 绝不报成功
501
+ // (用户真机现场:dshmarket 装了、bundle 行挂着,清单里却没有条目 ⇒ 重启/任何 pnpm 操作即消失)。
502
+ // 判据与写入的唯一承担者是 domain/persist.js(别处不许再各判一套)。
503
+ let mode = 'insert'
504
504
  if (await detectBundleOnly(profileDir, installedName)) {
505
+ mode = 'bundle'
505
506
  // 官方 dsh plugin add 行为:声明 dsh.bundle 的包追加为 profile bundle 层,
506
507
  // 其 cordis.patch.yml 在下次启动时参与组合(含皮肤包与 web-ui-settings 这类有入口的包)
507
508
  if (!alreadyServed) {
@@ -521,23 +522,27 @@ async function runInstallJob(job, ports, deps = {}) {
521
522
  } else {
522
523
  job.bundleNote = (job.bundleNote ? job.bundleNote + ';' : '') + `${installedName} 已由已安装聚合包提供,仅更新包本身,不再注册重复行`
523
524
  }
524
- // 安装即声明(2026-09-24):装到哪版就写进 profile 清单并立刻同步 lock —— 只写清单不写 lock 会让
525
- // 之后任何 pnpm 操作硬失败(ERR_PNPM_CANNOT_REMOVE_MISSING_DEPS)。2026-09-27:写回**形态**由
526
- // registry 可解析性决定(非 registry 包写 link:,见 manifest.js#planDependencySpec),故必须传 registries。
527
- applyDeclared(await declareProfileDependency(profileDir, installedName, null, { registries }))
528
- job.status = 'done'
529
- return
530
- }
531
- if (alreadyServed) {
525
+ } else if (alreadyServed) {
526
+ mode = 'served'
532
527
  job.note = `${installedName} 已由已安装条目提供,仅更新包本身,不重复注册`
533
- applyDeclared(await declareProfileDependency(profileDir, installedName, null, { registries }))
534
- job.status = 'done'
535
- return
536
528
  }
537
- const entryId = deriveEntryId(installedName, taken)
538
- await appendInsert(patchPath, entryId, installedName)
539
- job.entryId = entryId
540
- applyDeclared(await declareProfileDependency(profileDir, installedName, null, { registries }))
529
+ // `deps.persistDeps` 是**离线测试缝**(与 marketProbes/sourceDeps 同一风格):正控/负控必须能
530
+ // 确定性地让 lock 那一步失败("故意让 lock 写入失败 ⇒ 必须报未持久化而不是成功"),
531
+ // 生产调用方不传第三个参数时形状与默认值一个字都不变。
532
+ const persist = await ensurePersisted({ profileDir, patchPath, packageName: installedName, mode, taken, entries: listEntries(ports), registries, deps: deps.persistDeps ?? {} })
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
+ }
541
546
  job.status = 'done'
542
547
  } catch (error) {
543
548
  job.status = 'failed'
@@ -546,23 +551,6 @@ async function runInstallJob(job, ports, deps = {}) {
546
551
  job.finishedAt = Date.now()
547
552
  }
548
553
  }
549
- /** 用 corepack pnpm 移除包(与安装同一管理器与凭据抑制环境)。
550
- * 注意:pnpm remove 不支持 --registry 选项,去掉避免静默失败。 */
551
- async function pnpmRemove(profileDir, packageName) {
552
- const args = ['remove', packageName]
553
- const opts = {
554
- cwd: profileDir,
555
- timeout: 120000,
556
- windowsHide: true,
557
- maxBuffer: 4 * 1024 * 1024,
558
- // 2026-09-26:env 统一走 buildPnpmEnv(在既有 COREPACK_NPM_REGISTRY / 禁交互之上追加
559
- // npm_config_fetch_timeout / npm_config_fetch_retries / CI=true)。**不加** --fetch-timeout 类
560
- // CLI 选项:实测 `pnpm remove --fetch-timeout=…` 会 `[ERROR] Unknown options` 直接失败。
561
- env: buildPnpmEnv('https://registry.npmmirror.com'),
562
- }
563
- // 与 pnpmInstall 同一套跨平台定位(Linux 的 corepack 不在 <node bin>/node_modules 下)
564
- await runPnpmWithFallback(args, { execOpts: opts })
565
- }
566
554
  /** 读取用户额外 bundle(非官方模板)的补丁插入行 id 与包名,用于"额外插件"判定。 */
567
555
  async function readExtraBundleRows(profileDir) {
568
556
  const rows = new Set()
@@ -546,6 +546,9 @@ function installJobView(job) {
546
546
  diagnosis: job.diagnosis ?? null,
547
547
  // 装后校验与下载物摘要(2026-09-26 加法,见 install-verify.js):**只提示**,不改变既有成功判定
548
548
  entryCheck: job.entryCheck ?? null, integrity: job.integrity ?? null,
549
+ // 持久化判据(0.5.38 加法,见 domain/persist.js):`persisted:false` ⇒ 面板必须报「未持久化」
550
+ // (重启/任何一次 pnpm 操作后可能消失)并给出「一键钉住」动作 —— 绝不报"安装成功"。
551
+ persisted: job.persisted ?? null, persistNote: job.persistNote ?? null, persist: job.persist ?? null,
549
552
  }
550
553
  }
551
554