@noob-stupid/dsh-plugin-console 0.5.37 → 0.5.38

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 [] }
@@ -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
 
@@ -240,6 +240,27 @@ async function pinProfileDependency(profileDir, packageName, options = {}) {
240
240
  return { ok: after === spec, spec, before, after, changed, dir }
241
241
  }
242
242
 
243
+ /**
244
+ * 把清单里的该包 spec **原样写成给定值**(0.5.38 加法,供 domain/persist.js 的持久化收口用)。
245
+ * 场景:`node_modules/<包名>` 是指向**用户自己位置**的链接(开发目录 / 别的盘),而清单里没有条目。
246
+ * 此时"按磁盘真实值写 spec"就是写这条链接本身 —— 走 `pinProfileDependency` 会把它**物化复制**到
247
+ * plugin-src 并把 spec 换成一个新拷贝,等于悄悄改掉用户的来源(那是另一件事,不该由收口代做)。
248
+ * 只碰 `dependencies[packageName]` 这一个键,走同一把写队列;返回 `{ before, after, changed }`。
249
+ */
250
+ async function setProfileDependency(profileDir, packageName, spec) {
251
+ const before = await specOf(profileDir, packageName)
252
+ const changed = await queuedWrite(async () => {
253
+ const manifest = await readManifest(profileDir)
254
+ const deps = { ...(manifest.dependencies ?? {}) }
255
+ if (deps[packageName] === spec) return false
256
+ deps[packageName] = spec
257
+ manifest.dependencies = deps
258
+ await writeManifest(profileDir, manifest)
259
+ return true
260
+ })
261
+ return { before, after: await specOf(profileDir, packageName), changed }
262
+ }
263
+
243
264
  /** 卸载即撤销声明。 */
244
265
  async function undeclareProfileDependency(profileDir, packageName) {
245
266
  return queuedWrite(async () => {
@@ -305,5 +326,5 @@ async function applyDependencyBackfill(profileDir, options = {}) {
305
326
  export {
306
327
  DEFAULT_BUNDLES, addBundleToManifest, applyDependencyBackfill, declareProfileDependency,
307
328
  installedVersionOf, planDependencyBackfill, planDependencySpec, pinProfileDependency, removeBundleFromManifest,
308
- specOf, syncLockFor, undeclareProfileDependency,
329
+ setProfileDependency, specOf, syncLockFor, undeclareProfileDependency,
309
330
  }
@@ -153,7 +153,13 @@ function sanitizePatchText(text) {
153
153
  .replace(/\s+$/u, '') + '\n'
154
154
  }
155
155
 
156
- /** 解析用户补丁中 insert 块的 id → moduleName(name 字段)。 */
156
+ /** 解析用户补丁中 insert 块的 id → moduleName(name 字段)。
157
+ * ★ 2026-10-04 改错(真机发现,0.5.38):`name:` 的值**加了引号才算**是错的 —— YAML 里
158
+ * `name: dsh-whale-widget` 与 `name: 'dsh-whale-widget'` 完全等价,而本机真装的
159
+ * `dsh-whale-widget`(插件市场里的真实第三方插件)写的正是**不带引号**的那种。
160
+ * 旧判据只认带引号的值 ⇒ 那一行在解析结果里**根本不存在**:补丁自愈看不见它、
161
+ * 装后校验看不见它、撤销安装也看不见它("行明明在文件里,程序却说没有")。
162
+ * 现在两种形态都认(末尾的行内注释也剥掉),解析结果只多不少。 */
157
163
  function parseInsertNames(text) {
158
164
  const map = new Map()
159
165
  const lines = text.split(/\r?\n/u)
@@ -166,8 +172,13 @@ function parseInsertNames(text) {
166
172
  const idMatch = line.match(/^\s+- id: ([A-Za-z0-9_.-]+)/u)
167
173
  if (idMatch) { curId = idMatch[1]; continue }
168
174
  if (curId !== null) {
169
- const nameMatch = line.match(/^\s+name: ['"]([^'"]+)['"]/u)
170
- if (nameMatch) { map.set(curId, nameMatch[1]); curId = null }
175
+ const nameMatch = line.match(/^\s+name:\s*(.+?)\s*$/u)
176
+ if (nameMatch) {
177
+ const raw = nameMatch[1].replace(/\s+#.*$/u, '').trim()
178
+ const value = /^(['"]).*\1$/u.test(raw) ? raw.slice(1, -1) : raw
179
+ if (value !== '') map.set(curId, value)
180
+ curId = null
181
+ }
171
182
  }
172
183
  }
173
184
  }
@@ -0,0 +1,384 @@
1
+ // L1 · domain —— persist.js(「下载即持久」的**唯一判据** + **唯一收口动作**;0.5.38 加法)
2
+ //
3
+ // 用户诉求原话(2026-10-04):「能不能插件下载下来就不用 lock、重启也不会消失」。
4
+ // 现场(desktop profile 的 dshmarket):包确实下载了、node_modules 里有、bundle 行也挂着,
5
+ // 但 `dependencies` 里没有条目 ⇒ 之后任何一次 pnpm 操作都把它当多余包清掉,
6
+ // 而面板当时报的是「已安装并启用」——**装了没钉住**被报成了成功。
7
+ //
8
+ // 定案(本模块是唯一承担者,别处不许再各判一套):
9
+ // 「安装完成」= 三处齐备,缺一不可:
10
+ // ① `<profile>/package.json` 的 `dependencies` 里有该包,且 spec 与**磁盘真实形态**一致
11
+ // (`node_modules/<包名>` 是指向 plugin-src 的链接 → `link:`;真实目录 → 版本号;
12
+ // `file:`/`git+`/URL 来源 → 原样保留,绝不改写成版本号);
13
+ // ② `<profile>/pnpm-lock.yaml` 里有该包的条目,且与 ① 的 spec 同一形态
14
+ // (link: 依赖必须也钉在 link:,版本号必须逐字相等);
15
+ // ③ 重启后真的会被**挂载**:用户补丁里有它的 insert 行,或它已在 `dsh.profile.bundles` 里
16
+ // 且它自己声明了 `dsh.bundle.patch`(bundle 层会装入它自己的行),或运行时已有行提供它。
17
+ // 三者任一不成立 ⇒ `persisted:false` + `missing` 明细 + 结构化「一键钉住」动作 —— 面板**不许**报成功。
18
+ //
19
+ // 为什么判据必须集中在这一处(两处判据 = 下次框架/包管理器一变只有一处被修好):
20
+ // · 读文件的三个入口全部复用既有唯一真源:`profileSpec` / `readInstalledVersion` / `lockVersion`
21
+ // (domain/selfupdate.js)、`linkSpecIsIntact`(domain/dep-source.js)、`parseInsertNames`
22
+ // (domain/patch.js)——本模块**不新写任何 lock / 清单 / 补丁解析器**。
23
+ // · 写入同样复用既有写入器:`pinProfileDependency`(link: 形态)/ `declareProfileDependency`
24
+ // (registry 探测决定形态)/ `reconcileLockfile`(lock 对账)/ `appendInsert`(补丁行)/
25
+ // `addBundleToManifest`(bundle 层)——本模块**不新造第二套写入**。
26
+ //
27
+ // 非 registry 来源的既有规矩(照旧,绝不违反):`link:` / `file:` / `tgz` / git / URL 来源
28
+ // **不经** registry 解析 —— 因此本模块的判据里**没有**任何 "registry 404" 判定:
29
+ // ① 只比"清单写成什么形态 vs 磁盘是什么形态",② 只比"lock 里的条目 vs ① 的 spec 形态"。
30
+ //
31
+ // 分层:L1 domain —— 不认识 cordis ctx;IO 与四个写入器都可注入(便于离线单测跑正控/负控)。
32
+ import { existsSync, lstatSync, readFileSync, realpathSync } from 'node:fs'
33
+ import { join } from 'node:path'
34
+ import { PLUGIN_SRC_DIR, linkSpecFor, profileNameOf, linkSpecIsIntact } from './dep-source.js'
35
+ import { isUnder } from '../infra/framework-root.js'
36
+ import { addBundleToManifest, declareProfileDependency, pinProfileDependency, setProfileDependency } from './manifest.js'
37
+ import { appendInsert, parseInsertNames } from './patch.js'
38
+ import { deriveEntryId } from './runtime.js'
39
+ import { lockVersion, profileSpec, readInstalledVersion, reconcileLockfile } from './selfupdate.js'
40
+ import { dshHome, packageNameOf } from '../infra/paths.js'
41
+
42
+ /** 三处缺口的**稳定名字**(面板文案与测试都按它们断言,不许改写)。 */
43
+ const PERSIST_PARTS = ['dependency', 'lock', 'mount']
44
+
45
+ /** 精确版本号形态(`1.2.3` / `0.0.1-rc1`):只有它才要求与磁盘版本逐字相等。 */
46
+ const EXACT_VERSION_RE = /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/u
47
+ /** 来源型 spec 前缀(协议)——这些形态**绝不**改写成版本号(改写等于丢来源)。 */
48
+ const SOURCE_SPEC_RE = /^(?:link:|file:|git\+|git:|github:|https?:|workspace:|portal:)/u
49
+
50
+ /** 读 profile 清单(读不到返回 null;绝不因为读不到就抛)。 */
51
+ function readManifestSync(profileDir) {
52
+ try {
53
+ return JSON.parse(readFileSync(join(String(profileDir), 'package.json'), 'utf8'))
54
+ } catch {
55
+ return null
56
+ }
57
+ }
58
+
59
+ /** `dsh.profile.bundles`(读不到就空数组)。 */
60
+ function readBundlesSync(profileDir) {
61
+ const manifest = readManifestSync(profileDir)
62
+ const bundles = manifest?.dsh?.profile?.bundles
63
+ return Array.isArray(bundles) ? bundles.filter((x) => typeof x === 'string' && x !== '') : []
64
+ }
65
+
66
+ /**
67
+ * 磁盘**真实形态**(不猜):`node_modules/<包名>` 是指向别处的链接 → `link:<真实目标>`;
68
+ * 真实目录 → 版本号;都没有 → missing。
69
+ * Windows 的 junction 在 lstat 里同样是 symbolic link;`realpathSync` 拿到**规范化后的目标**
70
+ * (与 `linkSpecIsIntact` 的判据同一把尺子,因此"清单写的绝对路径"不必与磁盘逐字相同也算数)。
71
+ */
72
+ function diskFormOf(profileDir, packageName) {
73
+ const dir = join(String(profileDir), 'node_modules', ...String(packageName).split('/'))
74
+ const version = readInstalledVersion(profileDir, packageName)
75
+ if (!existsSync(join(dir, 'package.json'))) return { form: 'missing', spec: null, version, target: null }
76
+ try {
77
+ if (lstatSync(dir).isSymbolicLink()) {
78
+ const target = realpathSync(dir)
79
+ return { form: 'link', spec: linkSpecFor(target), version, target }
80
+ }
81
+ } catch {}
82
+ return { form: 'version', spec: version, version, target: null }
83
+ }
84
+
85
+ /** ① 依赖声明:清单里的 spec 与磁盘真实形态是否一致。 */
86
+ function dependencyPart({ profileDir, packageName }) {
87
+ const spec = profileSpec(profileDir, packageName)
88
+ const disk = diskFormOf(profileDir, packageName)
89
+ const out = { ok: false, spec, form: null, disk: { form: disk.form, version: disk.version }, reason: null }
90
+ if (spec === null) {
91
+ out.reason = 'package.json 的 dependencies 里没有这个条目(pnpm 会把它当多余包清掉)'
92
+ return out
93
+ }
94
+ const trimmed = spec.trim()
95
+ if (trimmed.startsWith('link:')) {
96
+ out.form = 'link'
97
+ if (disk.form !== 'link') {
98
+ out.reason = `清单是 ${trimmed},但 node_modules/${packageName} 不是指向它的链接(磁盘上实际是 ${disk.form === 'missing' ? '不存在' : '真实目录'})`
99
+ return out
100
+ }
101
+ if (!linkSpecIsIntact(profileDir, packageName, trimmed)) {
102
+ out.reason = `清单是 ${trimmed},但链接当前不成立(目标不存在或指向别处)`
103
+ return out
104
+ }
105
+ out.ok = true
106
+ return out
107
+ }
108
+ if (SOURCE_SPEC_RE.test(trimmed)) {
109
+ // file: / git+ / URL / workspace: …:来源即事实,本地无法逐字校验 ⇒ 有条目即算就位(绝不改写成版本号)
110
+ out.form = 'source'
111
+ out.ok = true
112
+ return out
113
+ }
114
+ if (EXACT_VERSION_RE.test(trimmed)) {
115
+ out.form = 'version'
116
+ if (disk.version === null) {
117
+ out.reason = '清单写的是版本号,但 node_modules 里读不到该包的版本(没装或被还原)'
118
+ return out
119
+ }
120
+ if (disk.version !== trimmed) {
121
+ out.reason = `清单写 ${trimmed},磁盘上是 ${disk.version} —— 两处对不上`
122
+ return out
123
+ }
124
+ out.ok = true
125
+ return out
126
+ }
127
+ // 版本范围(^1.2.3)或 dist-tag(latest/next):registry 来源但非精确值 —— 有条目且装上了即算就位
128
+ out.form = 'range'
129
+ if (disk.version === null) {
130
+ out.reason = `清单写 ${trimmed},但 node_modules 里读不到该包(没装或被还原)`
131
+ return out
132
+ }
133
+ out.ok = true
134
+ return out
135
+ }
136
+
137
+ /** ② lock 条目:与 ① 的 spec 同一形态(link: ↔ link:;精确版本 ↔ 逐字相等)。 */
138
+ function lockPart({ profileDir, packageName, spec }) {
139
+ const entry = lockVersion(profileDir, packageName)
140
+ const out = { ok: false, entry: entry ?? null, reason: null }
141
+ if (entry === null || entry === undefined) {
142
+ out.reason = 'pnpm-lock.yaml 里没有这个包的条目(任何一次 pnpm 操作都可能把它还原/清掉)'
143
+ return out
144
+ }
145
+ const value = String(entry).trim()
146
+ const s = typeof spec === 'string' ? spec.trim() : ''
147
+ if (s.startsWith('link:')) {
148
+ if (value.startsWith('link:')) {
149
+ out.ok = true
150
+ return out
151
+ }
152
+ out.reason = `清单把该包钉在 ${s},而 lock 里是 ${value}(对账后会被 pnpm 还原)`
153
+ return out
154
+ }
155
+ if (EXACT_VERSION_RE.test(s)) {
156
+ // lock 的 importer `version:` 对**带 peer 的**包会写成 `0.2.0-rc.2(@scope/peer@x)`(真机
157
+ // `@deepseek-ai/dsh-experimental-schedule-bundle` 就是这个形态)—— 前导版本号相等即算同版,
158
+ // 后缀是 pnpm 的 peer 解析记录,不是"另一个版本"。
159
+ const core = value.replace(/\(.*$/u, '').replace(/_[^_]*$/u, '').trim()
160
+ if (core === s || value === s) {
161
+ out.ok = true
162
+ return out
163
+ }
164
+ out.reason = `清单写 ${s},lock 里是 ${value} —— 两处对不上`
165
+ return out
166
+ }
167
+ // 范围 / dist-tag / 来源型:lock 有条目即算就位(形态由 pnpm 自己解析,本地不猜)
168
+ out.ok = true
169
+ return out
170
+ }
171
+
172
+ /** ③ 挂载行:用户补丁 insert 行 / bundle 层 / 运行时已有行,三者任一成立即算会挂载。 */
173
+ function mountPart({ profileDir, patchPath, packageName, patchText = null, bundles = null, entries = null }) {
174
+ const out = { ok: false, via: 'none', rowId: null, bundlePatch: null, reason: null }
175
+ // 运行时已有行提供它(例如聚合包里的行):最直接的证据,先看
176
+ if (Array.isArray(entries)) {
177
+ for (const entry of entries) {
178
+ const moduleName = String(entry?.moduleName ?? '')
179
+ if (moduleName === packageName || packageNameOf(moduleName) === packageName) {
180
+ return { ...out, ok: true, via: 'live-entry', rowId: entry.rowId ?? entry.entryId ?? null }
181
+ }
182
+ }
183
+ }
184
+ // 用户补丁里的 insert 行
185
+ let text = patchText
186
+ if (typeof text !== 'string') {
187
+ try {
188
+ text = existsSync(patchPath) ? readFileSync(patchPath, 'utf8') : ''
189
+ } catch {
190
+ text = ''
191
+ }
192
+ }
193
+ for (const [id, name] of parseInsertNames(text)) {
194
+ if (String(name).trim() === packageName) return { ...out, ok: true, via: 'patch-row', rowId: id }
195
+ }
196
+ // bundle 层:只有"它自己声明了 dsh.bundle.patch"时框架才会装入它自己的行
197
+ const list = Array.isArray(bundles) ? bundles : readBundlesSync(profileDir)
198
+ if (list.includes(packageName)) {
199
+ const pkgPath = join(String(profileDir), 'node_modules', ...String(packageName).split('/'), 'package.json')
200
+ let rel = null
201
+ try {
202
+ rel = JSON.parse(readFileSync(pkgPath, 'utf8'))?.dsh?.bundle?.patch ?? null
203
+ } catch {
204
+ rel = null
205
+ }
206
+ if (typeof rel !== 'string' || rel === '') {
207
+ out.reason = `已在 dsh.profile.bundles 里,但该包没有声明 dsh.bundle.patch —— bundle 层不会装入任何行,需要一条 insert 行`
208
+ return out
209
+ }
210
+ const file = join(String(profileDir), 'node_modules', ...String(packageName).split('/'), rel)
211
+ if (!existsSync(file)) {
212
+ out.bundlePatch = file
213
+ out.reason = `dsh.profile.bundles 里有它,但它声明的 bundle 补丁文件不存在(${file})`
214
+ return out
215
+ }
216
+ let rows = new Map()
217
+ try {
218
+ rows = parseInsertNames(readFileSync(file, 'utf8'))
219
+ } catch {
220
+ rows = new Map()
221
+ }
222
+ if (rows.size === 0) {
223
+ out.bundlePatch = file
224
+ out.reason = `bundle 补丁里读不到任何 insert 行(${rel})—— 没有任何东西会被挂载`
225
+ return out
226
+ }
227
+ return { ...out, ok: true, via: 'bundle', rowId: [...rows.keys()][0], bundlePatch: file }
228
+ }
229
+ out.reason = '用户补丁里没有它的 insert 行,dsh.profile.bundles 里也没有它 —— 重启后不会被挂载'
230
+ return out
231
+ }
232
+
233
+ /**
234
+ * 持久化判据(**只读**,永不抛):返回
235
+ * `{ persisted, packageName, missing, parts:{dependency,lock,mount}, note }`。
236
+ * 三个阶段各自读盘(不信任调用方传进来的 job 字段),missing 的顺序恒为 PERSIST_PARTS 的顺序。
237
+ */
238
+ function persistReport({ profileDir, patchPath, packageName, entries = null, patchText = null, bundles = null }) {
239
+ const dep = dependencyPart({ profileDir, packageName })
240
+ const lock = lockPart({ profileDir, packageName, spec: dep.spec })
241
+ const mount = mountPart({ profileDir, patchPath, packageName, patchText, bundles, entries })
242
+ const parts = { dependency: dep, lock, mount }
243
+ const missing = PERSIST_PARTS.filter((key) => parts[key].ok !== true)
244
+ const report = {
245
+ persisted: missing.length === 0,
246
+ packageName,
247
+ missing,
248
+ parts,
249
+ actual: {
250
+ spec: dep.spec,
251
+ lockEntry: lock.entry,
252
+ mountVia: mount.via,
253
+ version: dep.disk.version,
254
+ inBundles: (Array.isArray(bundles) ? bundles : readBundlesSync(profileDir)).includes(packageName),
255
+ },
256
+ }
257
+ report.note = report.persisted ? null : persistNote(report)
258
+ return report
259
+ }
260
+
261
+ /** 缺口 → 人话(**如实**:说清实际是什么、缺的是哪一处、后果是什么)。 */
262
+ function persistNote(report) {
263
+ const labels = { dependency: '① 清单 dependencies', lock: '② pnpm-lock.yaml', mount: '③ 挂载行(cordis.patch.yml / bundles)' }
264
+ const rows = report.missing.map((key) => `${labels[key]}:${report.parts[key].reason ?? '未就位'}`)
265
+ return `未持久化(重启或任何一次 pnpm 操作后可能消失)—— ${rows.join(';')}`
266
+ }
267
+
268
+ /** 结构化「一键钉住」动作(面板据此渲染执行框;命令只供展示/复制,执行侧只认 payload.action)。 */
269
+ function suggestedPersistAction({ packageName, profile = null, profileDir = null, missing = [], reason = null } = {}) {
270
+ const name = profile ?? profileNameOf(profileDir)
271
+ const list = Array.isArray(missing) ? missing.filter((m) => typeof m === 'string' && m !== '') : []
272
+ return {
273
+ kind: 'persist-plugin',
274
+ label: '一键钉住(清单 + lock + 挂载行)',
275
+ command: `dsh-plugin-console: persist-plugin ${packageName}`,
276
+ payload: { action: 'persist-plugin', packageName, profile: name },
277
+ missing: list,
278
+ reason,
279
+ }
280
+ }
281
+
282
+ /**
283
+ * 收口动作(**唯一的写入入口**):把 ①②③ 里缺的部分补齐,然后**读回磁盘**再判一次。
284
+ * · ① 缺/形态不一致 → 按磁盘真实形态写:链接 → `pinProfileDependency`(link: 并物化到 plugin-src);
285
+ * 真实目录 → `declareProfileDependency`(自带 registry 可解析性判定:非 registry 来源写 link:);
286
+ * · ② 缺/形态不一致 → `reconcileLockfile`(既有对账通道,argv/写回形态同安装路径);
287
+ * · ③ 缺 → `mode==='bundle'` 时补 `dsh.profile.bundles`;否则追加一条 insert 行(entry id 由
288
+ * `deriveEntryId` 按既有规则产出)。`mode==='served'` 时**绝不写行**(已有行提供它,再写就是重复装配)。
289
+ * 返回 `{ persisted, before, after, missing, declared, lock, notes, suggested, note, rowId }`;
290
+ * 任何一步失败都**如实**留在 notes 里,绝不影响收口的返回值语义(persisted 只认最终读回结果)。
291
+ */
292
+ async function ensurePersisted({ profileDir, patchPath, packageName, mode = 'insert', taken = null, entries = null, registries = [], deps = {} } = {}) {
293
+ const pin = typeof deps.pin === 'function' ? deps.pin : pinProfileDependency
294
+ const declare = typeof deps.declare === 'function' ? deps.declare : declareProfileDependency
295
+ const setSpec = typeof deps.setSpec === 'function' ? deps.setSpec : setProfileDependency
296
+ const reconcile = typeof deps.reconcile === 'function' ? deps.reconcile : reconcileLockfile
297
+ const insert = typeof deps.insert === 'function' ? deps.insert : appendInsert
298
+ const addBundle = typeof deps.addBundle === 'function' ? deps.addBundle : addBundleToManifest
299
+ const notes = []
300
+ const before = persistReport({ profileDir, patchPath, packageName, entries })
301
+ let declared = null
302
+ let lock = null
303
+ let rowId = before.parts.mount.rowId
304
+ // ① 依赖声明(按磁盘真实形态写,绝不猜)
305
+ if (before.parts.dependency.ok !== true) {
306
+ const disk = diskFormOf(profileDir, packageName)
307
+ try {
308
+ if (disk.form === 'link' && isUnder(disk.target, join(dshHome(), PLUGIN_SRC_DIR))) {
309
+ // 目标已经在 plugin-src 里 → 走既有「钉住」(幂等:realpath 相同则不会重复物化)
310
+ const pinned = await pin(profileDir, packageName, {})
311
+ declared = { changed: pinned.changed === true, spec: pinned.after ?? null, form: 'link', lockSynced: null, lockNote: null, depNote: null, suggested: null }
312
+ notes.push(pinned.changed === true
313
+ ? `清单已按磁盘真实形态钉成 ${pinned.after}(node_modules 里就是指到这个目录的链接)`
314
+ : `清单里已是 ${pinned.after ?? '(无条目)'}(无需改动)`)
315
+ } else if (disk.form === 'link') {
316
+ // 目标是用户自己的位置(开发目录 / 别的盘)→ **原样写这条链接**,绝不复制成 plugin-src 的新拷贝
317
+ const wrote = await setSpec(profileDir, packageName, disk.spec)
318
+ declared = { changed: wrote.changed === true, spec: wrote.after ?? disk.spec, form: 'link', lockSynced: null, lockNote: null, depNote: null, suggested: null }
319
+ notes.push(`清单已按磁盘真实值写成 ${wrote.after ?? disk.spec}(该链接指向用户自己的位置,未做物化复制)`)
320
+ } else if (disk.form === 'version') {
321
+ declared = await declare(profileDir, packageName, disk.version, { registries })
322
+ if (declared.changed !== true) notes.push(`清单里已有这个包(实际是 ${declared.spec ?? disk.version})`)
323
+ if (declared.depNote !== null && declared.depNote !== undefined) notes.push(declared.depNote)
324
+ } else {
325
+ notes.push(`node_modules 里读不到 ${packageName} —— 无法判断该写成什么形态,已跳过清单写回(绝不凭空猜一个 spec)`)
326
+ }
327
+ } catch (error) {
328
+ notes.push(`清单写回失败:${error instanceof Error ? error.message : String(error)}`)
329
+ }
330
+ }
331
+ // ② lock 对账
332
+ const mid = persistReport({ profileDir, patchPath, packageName, entries })
333
+ if (mid.parts.lock.ok !== true) {
334
+ try {
335
+ lock = await reconcile({ profileDir, packages: [{ name: packageName }], registries })
336
+ if (lock.lockNote !== null && lock.lockNote !== undefined) notes.push(lock.lockNote)
337
+ else if (lock.depNote !== null && lock.depNote !== undefined) notes.push(lock.depNote)
338
+ } catch (error) {
339
+ notes.push(`lock 对账失败:${error instanceof Error ? error.message : String(error)}`)
340
+ }
341
+ } else if (declared !== null && declared.lockSynced === false && declared.lockNote !== null) {
342
+ notes.push(declared.lockNote)
343
+ }
344
+ // ③ 挂载行
345
+ const pre = persistReport({ profileDir, patchPath, packageName, entries })
346
+ let bundled = null
347
+ if (pre.parts.mount.ok !== true && mode !== 'served') {
348
+ try {
349
+ if (mode === 'bundle') {
350
+ bundled = await addBundle(profileDir, packageName)
351
+ } else {
352
+ const id = deriveEntryId(packageName, taken instanceof Set ? taken : new Set())
353
+ const row = await insert(patchPath, id, packageName)
354
+ rowId = id
355
+ if (row && row.changed === false && row.reason !== undefined) {
356
+ notes.push(`未新写补丁行(原因:${row.reason})—— 由既有声明承担挂载`)
357
+ }
358
+ }
359
+ } catch (error) {
360
+ notes.push(`挂载行写入失败:${error instanceof Error ? error.message : String(error)}`)
361
+ }
362
+ }
363
+ const after = persistReport({ profileDir, patchPath, packageName, entries })
364
+ const suggested = after.persisted ? null : suggestedPersistAction({ packageName, profileDir, missing: after.missing, reason: after.note })
365
+ return {
366
+ persisted: after.persisted,
367
+ before,
368
+ after,
369
+ missing: after.missing,
370
+ parts: after.parts,
371
+ declared,
372
+ lock,
373
+ bundled,
374
+ rowId: after.parts.mount.rowId ?? rowId ?? null,
375
+ notes,
376
+ suggested,
377
+ note: after.note,
378
+ }
379
+ }
380
+
381
+ export {
382
+ PERSIST_PARTS, EXACT_VERSION_RE, SOURCE_SPEC_RE, readManifestSync, readBundlesSync, diskFormOf,
383
+ dependencyPart, lockPart, mountPart, persistReport, persistNote, suggestedPersistAction, ensurePersisted,
384
+ }
@@ -41,13 +41,14 @@ import { NPM_NAME_RE, suggestedAllowBuildsAction } from './dep-source.js'
41
41
  import { classifyInstallFailure, ignoredBuildsFrom } from './install-diagnose.js'
42
42
  import { runLockfileRepair } from './lockfile-health.js'
43
43
  import { pinProfileDependency, specOf } from './manifest.js'
44
+ import { ensurePersisted } from './persist.js'
44
45
  import { lockVersion } from './selfupdate.js'
45
46
  import { overwritePreset } from './preset-source.js'
46
47
  import { buildPnpmEnv, pnpmAddArgs, runPnpmWithFallback, unknownPnpmOption } from '../infra/exec.js'
47
48
 
48
49
  const DEFAULT_REGISTRY = 'https://registry.npmmirror.com'
49
50
  /** 动作白名单(客户端只能给这些 kind;执行分支与 argv 全在服务端)。 */
50
- const ACTION_KINDS = ['pin-dependency', 'reconcile-lock', 'allow-builds', 'overwrite-preset']
51
+ const ACTION_KINDS = ['pin-dependency', 'reconcile-lock', 'allow-builds', 'overwrite-preset', 'persist-plugin']
51
52
  /** 一旦出现这些键就整条拒绝:本接口不存在"传命令"的位置。 */
52
53
  const COMMAND_KEYS = ['command', 'cmd', 'argv', 'args', 'exec', 'execute', 'shell', 'script', 'run', 'spawn', 'bin', 'shellCommand']
53
54
  const OUTPUT_LIMIT = 4000
@@ -72,8 +73,8 @@ function parseActionRequest(body) {
72
73
  return { ok: false, status: 400, error: `未知动作 ${action}(只允许 ${ACTION_KINDS.join(' / ')})` }
73
74
  }
74
75
  const packageName = typeof raw.packageName === 'string' ? raw.packageName.trim() : ''
75
- if (action === 'pin-dependency') {
76
- if (packageName === '') return { ok: false, status: 400, error: 'pin-dependency 需要 packageName' }
76
+ if (action === 'pin-dependency' || action === 'persist-plugin') {
77
+ if (packageName === '') return { ok: false, status: 400, error: `${action} 需要 packageName` }
77
78
  if (packageName.length > 214 || !NPM_NAME_RE.test(packageName)) return { ok: false, status: 400, error: 'packageName 不是合法的 npm 包名' }
78
79
  }
79
80
  // allow-builds 的 packageName 可省略(缺省 = 放行当前被忽略的全部);给了就必须是合法 npm 名。
@@ -252,6 +253,44 @@ async function runSuggestedAction({ body, profileDir, registries = [], deps = {}
252
253
  detail: applied,
253
254
  }
254
255
  }
256
+ if (parsed.action === 'persist-plugin') {
257
+ // 0.5.38 加法(用户诉求:「插件下载下来就不用 lock、重启也不会消失」):把三处**一次补齐** ——
258
+ // ① 清单 spec(按磁盘真实形态:链接 → link:;真实目录 → 版本号;非 registry 来源照旧不猜)
259
+ // ② pnpm-lock.yaml 的条目(走既有对账通道)
260
+ // ③ 挂载行(已在 dsh.profile.bundles 的补 dsh.profile.bundles;否则追加 insert 行)
261
+ // 与 pin-dependency 的边界:pin 只动"来源形态"这一件事(历史行为一个字不改),本动作是**收口**,
262
+ // 且只在"确实缺"时才写;两者共用同一批写入器(patch.js / manifest.js / selfupdate.js),没有第二套。
263
+ if (!isProfilePackage(profileDir, parsed.packageName)) {
264
+ return { ...base, ok: false, status: 400, error: `${parsed.packageName} 不在本 profile 的清单(dependencies / dsh.profile.bundles)里,拒绝改动(先安装)` }
265
+ }
266
+ if (!isInstalled(profileDir, parsed.packageName)) {
267
+ return { ...base, ok: false, status: 400, error: `${parsed.packageName} 没有装在当前 profile 的 node_modules 里,无法钉住(先安装)` }
268
+ }
269
+ const ensure = typeof deps.ensurePersisted === 'function' ? deps.ensurePersisted : ensurePersisted
270
+ let fixed = null
271
+ try {
272
+ fixed = await ensure({ profileDir, patchPath: join(profileDir, 'cordis.patch.yml'), packageName: parsed.packageName, mode: 'insert', registries })
273
+ } catch (error) {
274
+ return { ...base, ok: false, status: 200, error: `持久化收口失败:${error instanceof Error ? error.message : String(error)}` }
275
+ }
276
+ const after = fixed?.after ?? null
277
+ return {
278
+ ...base,
279
+ ok: fixed?.persisted === true,
280
+ partial: fixed?.persisted !== true && (after?.parts?.dependency?.ok === true || after?.parts?.lock?.ok === true),
281
+ exitCode: fixed?.persisted === true ? 0 : 1,
282
+ command: `dsh-plugin-console: persist-plugin ${parsed.packageName}`,
283
+ manifest: { before: fixed?.before?.actual?.spec ?? null, after: after?.actual?.spec ?? null, changed: fixed?.declared?.changed === true, pinned: after?.parts?.dependency?.form === 'link' },
284
+ lock: { entry: after?.actual?.lockEntry ?? null, synced: after?.parts?.lock?.ok === true },
285
+ patch: { rowId: fixed?.rowId ?? null, via: after?.actual?.mountVia ?? null },
286
+ notes: Array.isArray(fixed?.notes) ? fixed.notes : [],
287
+ reason: fixed?.persisted === true
288
+ ? `${parsed.packageName} 已持久化:清单(${after?.actual?.spec ?? '?'})+ lock(${after?.actual?.lockEntry ?? '?'})+ 挂载(${after?.actual?.mountVia ?? '?'})`
289
+ : (fixed?.note ?? '仍有一处没就位'),
290
+ missing: Array.isArray(fixed?.missing) ? fixed.missing : [],
291
+ detail: { before: fixed?.before ?? null, after },
292
+ }
293
+ }
255
294
  if (parsed.action === 'reconcile-lock') {
256
295
  const view = await repair({ profileDir, registries: Array.isArray(registries) && registries.length > 0 ? registries : [registry] })
257
296
  const done = view?.action === 'repaired' || view?.action === 'noop'
@@ -72,8 +72,16 @@ function lockVersion(profileDir, name) {
72
72
  return null
73
73
  }
74
74
  for (let i = 0; i < lines.length; i += 1) {
75
- const bare = lines[i].trim().replace(/^'|':?$/gu, '').replace(/'$/u, '')
76
- if (bare === name || lines[i].trim() === `'${name}':`) {
75
+ const trimmed = lines[i].trim()
76
+ // 键形态三种都认(0.5.38 改错):`name`、`'name':`(pnpm 对**含 `/`/`@`** 的包名加引号)、
77
+ // `name:`(pnpm 对**未加 scope** 的包名不加引号)。旧判据漏了第三种 ⇒ 未加 scope 的 `link:`
78
+ // 依赖在 importers 里明明有条目却读不出来(真机 desktop profile 的 `dshmarket` 就是:
79
+ // lock 的 35-37 行写着 `dshmarket: / specifier: link:… / version: link:../../plugin-src/dshmarket`,
80
+ // 而 `lockVersion()` 返回 null)——后果是面板永远报"没写进 pnpm-lock.yaml"、pin 永远 partial、
81
+ // 每次对账都白跑一次 `pnpm add`。真值一律从下面这一处读,别在调用方再补一套。
82
+ const bare = trimmed.replace(/^'|':?$/gu, '').replace(/'$/u, '')
83
+ const bareKey = trimmed.replace(/'$/u, '').replace(/:$/u, '')
84
+ if (bare === name || bareKey === name || trimmed === `'${name}':`) {
77
85
  for (let k = i + 1; k < Math.min(i + 8, lines.length); k += 1) {
78
86
  const m = /^\s*version:\s*(\S+)\s*$/u.exec(lines[k])
79
87
  if (m) return m[1]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@noob-stupid/dsh-plugin-console",
3
- "version": "0.5.37",
3
+ "version": "0.5.38",
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",