@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 +30 -0
- package/lib/server/domain/bundle-refs.js +9 -2
- package/lib/server/domain/install-cleanup.js +23 -2
- package/lib/server/domain/install-job.js +35 -47
- package/lib/server/domain/install.js +3 -0
- package/lib/server/domain/manifest.js +22 -1
- package/lib/server/domain/patch.js +14 -3
- package/lib/server/domain/persist.js +384 -0
- package/lib/server/domain/plugin-actions.js +42 -3
- package/lib/server/domain/selfupdate.js +10 -2
- package/package.json +1 -1
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
|
-
|
|
35
|
-
|
|
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
|
-
|
|
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 {
|
|
23
|
-
import {
|
|
24
|
-
import {
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
525
|
-
|
|
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
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
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
|
|
170
|
-
if (nameMatch) {
|
|
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:
|
|
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
|
|
76
|
-
|
|
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.
|
|
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",
|