@noob-stupid/dsh-plugin-console 0.5.18 → 0.5.20

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
@@ -110,6 +110,18 @@ window.__ModuleLoader__.load({
110
110
  safetyLockNoRebuild: "当前不需要重建",
111
111
  safetyLockCheckBtn: "体检",
112
112
  safetyLockRepairBtn: "重建 lock(显式)",
113
+ // 2026-09-27 加法:安装结果旁的「建议动作」执行框(短句进面板,长解释进悬浮 title)
114
+ actionBoxTitle: "建议动作",
115
+ actionPinLabel: "钉住该依赖(按 link: 记录)",
116
+ actionReconcileLabel: "重建 pnpm-lock.yaml",
117
+ actionRun: "执行",
118
+ actionCopy: "复制",
119
+ actionCopied: "已复制命令",
120
+ actionRunning: "执行中…",
121
+ actionOk: "执行成功",
122
+ actionPartial: "部分成功(清单已改,lock 未对齐)",
123
+ actionFailed: "执行失败",
124
+ actionLongTitle: "控制台按白名单动作在自己的进程里执行(只认 pin-dependency / reconcile-lock 这类内置动作,绝不接受自定义命令字符串);命令仅供展示与复制。",
113
125
  safetyMode: "运行模式",
114
126
  safetyModeManaged: "托管:框架升级与回滚由本控制台负责(默认,行为与之前一致)",
115
127
  safetyModeManagedOn: "已切到托管模式:框架升级与回滚仍由本控制台负责",
@@ -624,6 +636,17 @@ window.__ModuleLoader__.load({
624
636
  safetyLockNoRebuild: "No rebuild needed right now",
625
637
  safetyLockCheckBtn: "Check",
626
638
  safetyLockRepairBtn: "Rebuild lock (explicit)",
639
+ actionBoxTitle: "Suggested action",
640
+ actionPinLabel: "Pin this dependency (record as link:)",
641
+ actionReconcileLabel: "Rebuild pnpm-lock.yaml",
642
+ actionRun: "Run",
643
+ actionCopy: "Copy",
644
+ actionCopied: "Command copied",
645
+ actionRunning: "Running…",
646
+ actionOk: "Done",
647
+ actionPartial: "Partly done (manifest updated, lock not aligned)",
648
+ actionFailed: "Failed",
649
+ actionLongTitle: "The console runs a whitelisted action inside its own process (only built-in kinds such as pin-dependency / reconcile-lock; a custom command string is never accepted). The command itself is display/copy only.",
627
650
  safetyMode: "Operating mode",
628
651
  safetyModeManaged: "Managed: this console owns framework upgrade and rollback (default, unchanged behaviour)",
629
652
  safetyModeManagedOn: "Switched to managed: framework upgrade and rollback stay with this console",
@@ -1166,6 +1189,27 @@ window.__ModuleLoader__.load({
1166
1189
  }
1167
1190
  return data;
1168
1191
  }
1192
+ /** 执行「建议动作」:与 call() 的关键差别 ——**服务端把"动作失败"也当结果返回**
1193
+ * (`{ ok:false, partial:true, exitCode, reason, stderr }`),而 call() 的统一约定会把
1194
+ * `data.ok === false` 抛成一句没信息量的 "HTTP 200"(真机体验:失败原因看不见)。
1195
+ * 所以这里直接读响应体,只把"协议级失败(HTTP != 2xx / 网络异常)"当错误抛。 */
1196
+ async function callAction(payload) {
1197
+ const response = await fetch("/plugin-console/run-suggested", {
1198
+ method: "POST",
1199
+ headers: { "content-type": "application/json" },
1200
+ body: JSON.stringify(payload),
1201
+ });
1202
+ let data = null;
1203
+ try { data = await response.json(); } catch {}
1204
+ if (!response.ok) {
1205
+ throw new Error(data !== null && typeof data.error === "string" && data.error !== "" ? data.error : "HTTP " + response.status);
1206
+ }
1207
+ return data ?? {};
1208
+ }
1209
+ /** 复制到剪贴板:抽成函数便于离线渲染测试断言;失败**绝不抛**(无权限/老浏览器时只是没复制上)。 */
1210
+ function copyToClipboard(text) {
1211
+ try { void navigator.clipboard.writeText(String(text)); return true } catch { return false }
1212
+ }
1169
1213
  /** 重启服务(按钮 / 工具项 / 自更新后共用):服务端在「本实例由桌面端外壳托管」等场景会**拒绝**
1170
1214
  * 并回结构化短句 —— 必须把它显示出来,否则按钮看起来是「点了没反应」(旧实现把错误吞掉了)。 */
1171
1215
  function restartServiceViaServer(setMessage, options) {
@@ -2835,6 +2879,10 @@ window.__ModuleLoader__.load({
2835
2879
  }
2836
2880
  if (data.bundle) {
2837
2881
  setRestartHint(true);
2882
+ } else if (data.suggestedAction !== null && data.suggestedAction !== undefined) {
2883
+ // 有建议动作时不自动刷新页面(2026-09-27 加法):旧行为 2.5 秒后 reload,
2884
+ // 用户根本来不及点「执行」;这里改成给一个"请刷新"的可见提示。
2885
+ setReloadHint(true);
2838
2886
  } else {
2839
2887
  // 安装成功:自动刷新页面,让插件立即出现在列表里(可见的成功反馈)
2840
2888
  setReloadHint(true);
@@ -4752,6 +4800,9 @@ setMessage(t("failed") + ":" + (data?.reason ?? "未知"));
4752
4800
  // 安装诊断卡(2026-09-26 加法):只有**装成功但装后校验 / 摘要校验有问题**的作业才出现
4753
4801
  // (无问题时 diagCardEl 返回 null,不占版面)。数据只用本地 jobs 状态,不新增任何接口字段。
4754
4802
  Object.values(jobs).filter((job) => job.status === "done").map((job) => diagCardEl(job)),
4803
+ // 建议动作执行框(2026-09-27 加法):只在安装结果里带了 suggestedAction 时出现(否则返回 null,不占版面)
4804
+ Object.values(jobs).filter((job) => job.status === "done" && job.suggestedAction !== null && job.suggestedAction !== undefined)
4805
+ .map((job) => el(SuggestedActionBox, { key: "act-" + job.jobId, job: job, t: t })),
4755
4806
  Object.entries(suiteReports).map(([jobId, report]) =>
4756
4807
  el("div", { key: "suite-" + jobId, className: styles.detail },
4757
4808
  el("p", { className: styles.status }, t("suiteComponentType") + ":"),
@@ -5138,6 +5189,68 @@ setMessage(t("failed") + ":" + (data?.reason ?? "未知"));
5138
5189
  // 它没有开放任何子插槽;官方 settings.plugins.tab 的 owner props 也是空的(没有切兄弟 tab 的 API)。
5139
5190
  // 所以做法是「就地抽屉」,而不是往别人的 DOM 里注入(注入会在对方更新时碎掉)。
5140
5191
  // 原 tab(id=console)与悬浮面板一行未动。
5192
+ /** 「建议动作」执行框(2026-09-27 加法,用户明确要求):安装结果旁的一个**小**框 ——
5193
+ * 一句短说明 + 等宽只读命令 + 「执行」「复制」+ 执行中状态 + 结果/错误回显 + 可关闭。
5194
+ * 边界(与服务端同一条):面板只发**结构化动作**(`suggestedAction.payload`),
5195
+ * 命令字符串仅用于展示/复制;服务端只认白名单动作类型(见 domain/plugin-actions.js)。
5196
+ * 只用既有 styles.*(detail / rowTop / phase / name / message / note / toggle / trashBtn),
5197
+ * 不新增徽章、不新增独立面板 —— 长解释一律进悬浮 title。 */
5198
+ function SuggestedActionBox({ job, t }) {
5199
+ const [state, setState] = react.useState({ phase: "idle", text: null, level: null, title: null, closed: false });
5200
+ const action = job === null || job === undefined ? null : (job.suggestedAction ?? null);
5201
+ if (action === null || typeof action.command !== "string" || state.closed === true) return null;
5202
+ const running = state.phase === "running";
5203
+ const label = action.kind === "pin-dependency" ? t("actionPinLabel")
5204
+ : action.kind === "reconcile-lock" ? t("actionReconcileLabel")
5205
+ : (typeof action.label === "string" && action.label !== "" ? action.label : String(action.kind ?? ""));
5206
+ const run = () => {
5207
+ if (running || action.payload === null || typeof action.payload !== "object") return;
5208
+ setState({ phase: "running", text: t("actionRunning"), level: null, title: null, closed: false });
5209
+ callAction(action.payload).then(
5210
+ (done) => {
5211
+ const verdict = done?.ok === true ? t("actionOk") : done?.partial === true ? t("actionPartial") : t("actionFailed");
5212
+ const parts = [verdict];
5213
+ if (typeof done?.reason === "string" && done.reason !== "") parts.push(done.reason);
5214
+ const raw = typeof done?.stderr === "string" && done.stderr.trim() !== "" ? done.stderr.trim() : "";
5215
+ if (raw !== "") parts.push(raw.split("\n").slice(-1)[0]);
5216
+ setState({
5217
+ phase: done?.ok === true ? "done" : "error",
5218
+ text: parts.join(" · "),
5219
+ level: done?.ok === true ? null : "warn",
5220
+ title: [done?.command, done?.stdout, raw].filter((s) => typeof s === "string" && s !== "").join("\n") || null,
5221
+ closed: false,
5222
+ });
5223
+ },
5224
+ (error) => setState({ phase: "error", text: (error !== null && typeof error.message === "string" && error.message !== "" ? error.message : t("actionFailed")), level: "error", title: null, closed: false }),
5225
+ );
5226
+ };
5227
+ const copy = () => {
5228
+ if (copyToClipboard(action.command)) setState({ phase: state.phase, text: t("actionCopied"), level: null, title: null, closed: false });
5229
+ };
5230
+ return el("div", { className: styles.detail, "data-suggested-action": String(action.kind ?? "") },
5231
+ el("div", { className: styles.rowTop },
5232
+ el("span", { className: styles.phase }, t("actionBoxTitle")),
5233
+ el("strong", { className: styles.name, title: t("actionLongTitle") }, label),
5234
+ el("button", {
5235
+ type: "button", className: styles.trashBtn, title: t("closeModal"), "aria-label": t("closeModal"),
5236
+ onClick: () => setState({ phase: "idle", text: null, level: null, title: null, closed: true }),
5237
+ }, "✕")),
5238
+ el("code", {
5239
+ className: styles.note,
5240
+ title: t("actionLongTitle"),
5241
+ style: { fontFamily: "ui-monospace,SFMono-Regular,Menlo,monospace", fontSize: "12px", wordBreak: "break-all", userSelect: "text" },
5242
+ }, action.command),
5243
+ el("div", { className: styles.rowTop },
5244
+ el("button", { type: "button", className: styles.toggle, disabled: running, onClick: run }, running ? t("actionRunning") : t("actionRun")),
5245
+ el("button", { type: "button", className: styles.toggle, onClick: copy }, t("actionCopy")),
5246
+ el("span", {
5247
+ className: styles.message,
5248
+ "data-warn": state.level === "warn" ? "true" : undefined,
5249
+ "data-error": state.level === "error" ? "true" : undefined,
5250
+ title: state.title === null ? undefined : state.title,
5251
+ }, state.text ?? "")));
5252
+ }
5253
+
5141
5254
  function PluginSafetyTab({ t }) {
5142
5255
  const [open, setOpen] = react.useState(false);
5143
5256
  const [status, setStatus] = react.useState(null);
@@ -5349,6 +5462,9 @@ setMessage(t("failed") + ":" + (data?.reason ?? "未知"));
5349
5462
  exports.NS = NS;
5350
5463
  exports.apply = apply;
5351
5464
  exports.inject = inject;
5465
+ // 仅测试用导出(tests/test-plugin-actions.mjs 的离线渲染断言:用假 React 真渲染这个组件,
5466
+ // 断言"有/无 suggestedAction、执行中、成功、失败、中英切换"五种情形的 DOM 结构)。
5467
+ exports.SuggestedActionBox = SuggestedActionBox;
5352
5468
  return module.exports;
5353
5469
  }
5354
5470
  });
@@ -20,7 +20,7 @@
20
20
  // 不经 tarball 完整性校验,lock 删掉重建、node_modules 清空重装都稳定通过。
21
21
 
22
22
  import { existsSync, rmSync, mkdirSync, realpathSync } from 'node:fs'
23
- import { dirname, join } from 'node:path'
23
+ import { basename, dirname, join } from 'node:path'
24
24
  import { copyTree } from '../infra/fsx.js'
25
25
  import { fetchJsonUrl } from '../infra/http.js'
26
26
  import { dshHome } from '../infra/paths.js'
@@ -28,6 +28,16 @@ import { dshHome } from '../infra/paths.js'
28
28
  /** 物化目录的根(与用户 issue 里手工规避用的 `/root/.dsh/plugin-src/...` 同一位置)。 */
29
29
  const PLUGIN_SRC_DIR = 'plugin-src'
30
30
 
31
+ /** profile 目录名(`…/profiles/web` → `web`):只用于**回显**给用户看的动作载荷,
32
+ * 服务端**绝不**按它挑目录(永远按当前实例的 profile 目录施工)。 */
33
+ function profileNameOf(profileDir) {
34
+ try {
35
+ return typeof profileDir === 'string' && profileDir !== '' ? basename(profileDir) : null
36
+ } catch {
37
+ return null
38
+ }
39
+ }
40
+
31
41
  /**
32
42
  * registry 能否解析该包的指定版本(按顺序多源尝试,任一源可解析即通过)。
33
43
  * 404 / 网络失败都归为「不可解析」——调用方据此决定**绝不写裸版本号**。
@@ -123,4 +133,51 @@ function linkSpecIsIntact(profileDir, packageName, spec) {
123
133
  }
124
134
  }
125
135
 
126
- export { PLUGIN_SRC_DIR, probeRegistryPackage, materializePackageForLink, linkSpecFor, linkSpecIsIntact }
136
+ /**
137
+ * 探测失败的性质(纯函数,**唯一真源**):能从 `tried` 文本里认出 404 就是「registry 查无此包」,
138
+ * 否则算「网络/镜像不可达」——两者对写回决策的意义完全不同(前者必须走 link:,后者只是本次没探到)。
139
+ * lockfile-health.js 的 probeVerdict 与 manifest.js 的写回判据都调它,避免两处正则各写一份。
140
+ */
141
+ function probeFailureKind(probe) {
142
+ const tried = Array.isArray(probe?.tried) ? probe.tried.join(' | ') : ''
143
+ if (/HTTP 404|Not Found|E404|is not in the npm registry/iu.test(tried)) return 'fetch-404'
144
+ return 'network-timeout'
145
+ }
146
+
147
+ /** 包的 npm 名字法判据(与 routes/install.js 的入参校验同一形状,供动作载荷校验复用)。 */
148
+ const NPM_NAME_RE = /^(?:@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/u
149
+
150
+ /**
151
+ * 给用户的**结构化建议动作**(2026-09-27 加法,用户明确要求):
152
+ * 面板不再只显示一句"请执行 xxx",而是拿到一个可执行的结构体 —— `{ kind, label, command, payload }`。
153
+ * 安全语义(写死,配套测试见 tests/test-plugin-actions.mjs):
154
+ * · `command` **只供展示/复制**,服务端执行时**只认 payload.action 这个白名单类型**,argv 自己拼;
155
+ * · payload 里只有动作类型 + 包名 + 版本 + profile 名(回显用),**没有任何命令字符串**位置。
156
+ */
157
+ function suggestedPinAction({ packageName, version = null, profileDir = null, profile = null, home = undefined, reason = null } = {}) {
158
+ const dir = join(home ?? dshHome(), PLUGIN_SRC_DIR, ...String(packageName).split('/'))
159
+ const spec = linkSpecFor(dir)
160
+ const name = profile ?? profileNameOf(profileDir)
161
+ return {
162
+ kind: 'pin-dependency',
163
+ label: '钉住该依赖(按 link: 记录)',
164
+ // 展示用命令:与执行器同一份 argv 形状(服务端执行时自己拼,绝不采用客户端回传的字符串)
165
+ command: `pnpm add ${spec} --registry https://registry.npmmirror.com`,
166
+ payload: { action: 'pin-dependency', packageName, version, profile: name },
167
+ reason,
168
+ }
169
+ }
170
+
171
+ /** `reconcile-lock` 的建议动作(只重写 pnpm-lock.yaml,argv 由 lockfile-health.repairArgsFor 唯一产出)。 */
172
+ function suggestedReconcileAction({ profileDir = null, profile = null, reason = null } = {}) {
173
+ const name = profile ?? profileNameOf(profileDir)
174
+ return {
175
+ kind: 'reconcile-lock',
176
+ label: '重建 pnpm-lock.yaml(只写 lock)',
177
+ command: 'pnpm install --lockfile-only --no-frozen-lockfile --registry https://registry.npmmirror.com',
178
+ payload: { action: 'reconcile-lock', profile: name },
179
+ reason,
180
+ }
181
+ }
182
+
183
+ export { PLUGIN_SRC_DIR, NPM_NAME_RE, probeRegistryPackage, probeFailureKind, materializePackageForLink, linkSpecFor, linkSpecIsIntact, profileNameOf, suggestedPinAction, suggestedReconcileAction }
@@ -21,10 +21,9 @@ import { deriveEntryId, listEntries } from './runtime.js'
21
21
  import { detectSkillRepo, runSkillInstallJob } from './skills.js'
22
22
  import { orderedRegistries, readSources } from './sources.js'
23
23
  import { reconcileLockfile } from './selfupdate.js'
24
- import { probeGitmodules, resolveInstallKind, runSuiteInstallJob } from './suite.js'
24
+ import { fetchRepoMeta, runSuiteInstallJob, shouldRunSuiteInstall } from './suite.js'
25
25
  import { buildPnpmEnv, execFileAsync, runPnpmWithFallback } from '../infra/exec.js'
26
26
  import { cleanupStalePackageDir, startTrashCleanup, trashScanRoots } from '../infra/fsx.js'
27
- import { GITHUB_API, META_BUDGET_MS, curlJson, githubJson } from '../infra/http.js'
28
27
  import { findPatchPath, packageNameOf, resolvePackageJson } from '../infra/paths.js'
29
28
 
30
29
  /** release 通道的候选预算(issue #3):release 通道现在按包名反查发布仓库,**每个候选**至少一次
@@ -215,6 +214,14 @@ async function runInstallJob(job, ports, deps = {}) {
215
214
  }
216
215
  const jobBudgetMs = Number.isFinite(deps.jobBudgetMs) && deps.jobBudgetMs > 0 ? deps.jobBudgetMs : 8 * 60 * 1000
217
216
  const aiConsentTimeoutMs = Number.isFinite(deps.aiConsentTimeoutMs) && deps.aiConsentTimeoutMs > 0 ? deps.aiConsentTimeoutMs : AI_CONSENT_TIMEOUT_MS
217
+ /** 「安装即声明」的收尾(三个调用点共用一个出口):清单写回结果 → job 上的可见字段。
218
+ * depNote / suggestedAction 也在这里落地:面板必须能看见"清单里**实际**写成了什么",以及"没写成时点哪个按钮"。 */
219
+ const applyDeclared = (declared) => {
220
+ if (declared.changed) job.declared = declared.version
221
+ if (declared.depNote !== null) job.depNote = declared.depNote
222
+ if (declared.suggested !== null) job.suggestedAction = declared.suggested
223
+ if (declared.lockSynced === false && declared.lockNote !== null) job.lockNote = declared.lockNote
224
+ }
218
225
  try {
219
226
  job.stage = 'preparing'
220
227
  // 2026-09-06 事故(室友机器):对 deepseek-ai/deepseek-harness(框架本体仓库)点安装会按 bundle 规则
@@ -242,10 +249,11 @@ async function runInstallJob(job, ports, deps = {}) {
242
249
  }
243
250
  }
244
251
  if (candidates.length === 0) {
245
- // 套装兜底:submodule 聚合仓库(根 .gitmodules 内容校验通过)→ 自动转套装安装,不依赖前端标记。
246
- // 判据是**内容**(resolveInstallKind),不是"探测非 null":后者会把代理/CDN 对不存在文件回的
247
- // 2xx 空 body、垃圾页当成套装(2026-09-19 用户反馈的「未找到 .gitmodules」事故根因)。
248
- if (resolveInstallKind(job.kind, await probeGitmodules(job.repo)) === 'suite') {
252
+ // 套装判定(0.5.19 改错,唯一入口在 suite.js 的 shouldRunSuiteInstall):**先看根包是否已发布**,
253
+ // 再按 .gitmodules 的**内容**判是否套装(不是"探测非 null":代理/CDN 对不存在的文件会回 2xx 空 body、
254
+ // 垃圾页,2026-09-19「未找到 .gitmodules」事故的口径不变)。根包/子包任一已发布到 registry 时优先
255
+ // 插件通道 —— 真机 dsh-web:429 MB 套装克隆 vs 5.97 MB 的子包 @linxin666/dsh-web-all。
256
+ if (await shouldRunSuiteInstall(job, probes) === true) {
249
257
  job.kind = 'suite'
250
258
  const suiteResult = await runSuiteInstallJob(job, ports)
251
259
  // 套装装配出的普通插件是 copyTree 铺进去的(不在 lock 里)→ 同样对账,避免之后被 pnpm 还原/清理
@@ -263,20 +271,15 @@ async function runInstallJob(job, ports, deps = {}) {
263
271
  // clone 后才发现没有 .gitmodules(探测假阳性 / 仓库已重构)→ 回落普通安装,不给用户一个失败
264
272
  if (suiteResult?.notASuite !== true) return
265
273
  job.kind = 'plugin'
266
- job.suiteNote = '探测到的 .gitmodules 与仓库实际内容不符(不是 submodule 套装仓库),已自动回落普通插件安装'
274
+ job.suiteNote = suiteResult?.reason ?? '探测到的 .gitmodules 与仓库实际内容不符(不是 submodule 套装仓库),已自动回落普通插件安装'
267
275
  }
268
- // 兜底:宿主端自行拉取仓库元数据(githubJson 与 curl 竞速 + 8s 超时降级)。8s 而非旧值 3s:
269
- // IPv6 无路由时单条通道就要 5.4s,3s 预算必输 → branch 恒为 main(2026-09-20 另一位用户实测)。
270
- const meta = await Promise.race([
271
- Promise.any([
272
- githubJson(`${GITHUB_API}/repos/${job.repo}`),
273
- curlJson(`${GITHUB_API}/repos/${job.repo}`, 12000, {}, { ipv4: true }),
274
- ]),
275
- new Promise((resolve) => setTimeout(() => resolve(null), META_BUDGET_MS)),
276
- ]).catch(() => null)
276
+ // 仓库元数据(默认分支)探测搬进 suite.js 的 fetchRepoMeta(githubJson 与 curl 竞速 + 8s 降级,
277
+ // 黑洞期不卡 40s;8s 而非 3s 的理由见那里的注释)。套装判定时已经探过一次 → 这里复用缓存,不重复联网。
278
+ const meta = job.repoMeta !== undefined ? job.repoMeta : await fetchRepoMeta(job.repo)
277
279
  const branch = meta?.default_branch ?? 'main'
278
280
  job.defaultBranch = branch // 批次 B-⑥:后续读子包一律以它打头(默认分支是 dev 的仓库不再读不到子包)
279
- const { pkg, reason } = await probes.fetchRepoPackageEx(job.repo, branch)
281
+ const rootProbe = job.rootPkgProbe !== undefined ? job.rootPkgProbe : await probes.fetchRepoPackageEx(job.repo, branch)
282
+ const { pkg, reason } = rootProbe
280
283
  if (pkg === null) {
281
284
  // 无 package.json:先探测是否技能仓库(含 SKILL.md)→ 自动转技能安装;
282
285
  // 否则标记 hint=repo-land,前端给出「仓库落地」一键入口(克隆到本地目录)。
@@ -306,7 +309,7 @@ async function runInstallJob(job, ports, deps = {}) {
306
309
  // private: true,但 main/dev 各有 22 个子包目录。失败原因是当时市场索引源全挂、网络受限,
307
310
  // subpackageCandidates() 读不到列表 —— 旧代码只有"有没有子包"一个出口,把**没读到**
308
311
  // 报成了**不存在**,直接把用户带偏。教训:探测失败必须与确定性结论分开表达。
309
- let subs = await probes.subpackageCandidates(job.repo, branch)
312
+ let subs = job.subpackageProbe !== undefined ? job.subpackageProbe : await probes.subpackageCandidates(job.repo, branch)
310
313
  if (subs.length === 0) {
311
314
  // 读不到时先换一条分支重试:meta 探测失败时 branch 恒为 main,而默认分支为 dev 的仓库
312
315
  // (本例 dsh-web 就是 dev 为默认分支)main 上的子包布局可能不同/为空;
@@ -484,6 +487,8 @@ async function runInstallJob(job, ports, deps = {}) {
484
487
  if (lock.lockNote !== null) job.lockNote = lock.lockNote
485
488
  // 非常规来源(link:)写回说明:必须让用户看见(缺陷②最阴的地方就是"装完完全看不出问题")
486
489
  if (lock.depNote !== null) job.depNote = lock.depNote
490
+ // 写回没生效时的**结构化**建议动作(加法;老客户端忽略该字段,向后兼容)
491
+ if (Array.isArray(lock.suggestedActions) && lock.suggestedActions.length > 0) job.suggestedAction = lock.suggestedActions[0]
487
492
  } catch {}
488
493
  job.stage = 'configuring'
489
494
  // 2026-09-06 事故(i18n 更新变重复行):@linxin666/dsh-i18n 声明了 dsh.bundle.patch,
@@ -512,30 +517,23 @@ async function runInstallJob(job, ports, deps = {}) {
512
517
  } else {
513
518
  job.bundleNote = (job.bundleNote ? job.bundleNote + ';' : '') + `${installedName} 已由已安装聚合包提供,仅更新包本身,不再注册重复行`
514
519
  }
515
- // 安装即声明(2026-09-24):装到哪版就把它写进 profile 清单的 dependencies,
516
- // **并立刻同步 pnpm-lock.yaml**(declareProfileDependency 内部已含对账)——只写清单不写 lock
517
- // 会让之后任何 pnpm 操作硬失败(ERR_PNPM_CANNOT_REMOVE_MISSING_DEPS)。
518
- // 对账失败只记 note,不让已经装好的包变成"安装失败"。
519
- const declaredBundle = await declareProfileDependency(profileDir, installedName)
520
- if (declaredBundle.changed) job.declared = declaredBundle.version
521
- if (declaredBundle.lockSynced === false && declaredBundle.lockNote !== null) job.lockNote = declaredBundle.lockNote
520
+ // 安装即声明(2026-09-24):装到哪版就写进 profile 清单并立刻同步 lock —— 只写清单不写 lock 会让
521
+ // 之后任何 pnpm 操作硬失败(ERR_PNPM_CANNOT_REMOVE_MISSING_DEPS)。2026-09-27:写回**形态**由
522
+ // registry 可解析性决定(非 registry 包写 link:,见 manifest.js#planDependencySpec),故必须传 registries。
523
+ applyDeclared(await declareProfileDependency(profileDir, installedName, null, { registries }))
522
524
  job.status = 'done'
523
525
  return
524
526
  }
525
527
  if (alreadyServed) {
526
528
  job.note = `${installedName} 已由已安装条目提供,仅更新包本身,不重复注册`
527
- const declaredServed = await declareProfileDependency(profileDir, installedName)
528
- if (declaredServed.changed) job.declared = declaredServed.version
529
- if (declaredServed.lockSynced === false && declaredServed.lockNote !== null) job.lockNote = declaredServed.lockNote
529
+ applyDeclared(await declareProfileDependency(profileDir, installedName, null, { registries }))
530
530
  job.status = 'done'
531
531
  return
532
532
  }
533
533
  const entryId = deriveEntryId(installedName, taken)
534
534
  await appendInsert(patchPath, entryId, installedName)
535
535
  job.entryId = entryId
536
- const declared = await declareProfileDependency(profileDir, installedName)
537
- if (declared.changed) job.declared = declared.version
538
- if (declared.lockSynced === false && declared.lockNote !== null) job.lockNote = declared.lockNote
536
+ applyDeclared(await declareProfileDependency(profileDir, installedName, null, { registries }))
539
537
  job.status = 'done'
540
538
  } catch (error) {
541
539
  job.status = 'failed'
@@ -20,7 +20,7 @@
20
20
  // 分层:本模块属于 L1 domain —— 不认识 cordis ctx,IO 一律以参数注入(便于离线单测)。
21
21
  import { existsSync, readFileSync } from 'node:fs'
22
22
  import { join } from 'node:path'
23
- import { probeRegistryPackage } from './dep-source.js'
23
+ import { probeFailureKind, probeRegistryPackage } from './dep-source.js'
24
24
  import { classifyInstallFailure, hintForKind } from './install-diagnose.js'
25
25
  import { buildPnpmEnv, runPnpmWithFallback } from '../infra/exec.js'
26
26
  import { semverRangeMatch } from '../infra/semver.js'
@@ -183,11 +183,12 @@ function freshReleases(name, meta, { now = Date.now(), hours = RELEASE_AGE_HOURS
183
183
  return ageHours >= 0 && ageHours < hours ? [{ name, version: latest, publishedAt: time[latest], ageHours: Math.round(ageHours * 10) / 10 }] : []
184
184
  }
185
185
 
186
- /** registry 探测结论 → 分类(纯函数):能从 tried 文本里认出 404 就点名 404,否则算"不可达"。 */
186
+ /** registry 探测结论 → 分类(纯函数):能从 tried 文本里认出 404 就点名 404,否则算"不可达"。
187
+ * 2026-09-27:判据搬进 domain/dep-source.js#probeFailureKind(写回决策与体检共用同一份正则——两处
188
+ * 各写一份的结果是"一处改了另一处不知道",而这两处对 404 的判定必须永远一致)。本函数保留为
189
+ * 兼容导出,行为逐字不变(tests/test-lockfile-health.mjs ⑥ 仍按原名断言)。 */
187
190
  function probeVerdict(probe) {
188
- const tried = Array.isArray(probe?.tried) ? probe.tried.join(' | ') : ''
189
- if (/HTTP 404|Not Found|E404|is not in the npm registry/iu.test(tried)) return 'fetch-404'
190
- return 'network-timeout'
191
+ return probeFailureKind(probe)
191
192
  }
192
193
 
193
194
  /** manifest 的 spec 里能确定"就是这一个版本"时取出来(用于探测"这个版本在不在 registry 上")。 */
@@ -12,6 +12,7 @@
12
12
 
13
13
  import { readFile, writeFile } from 'node:fs/promises'
14
14
  import { join } from 'node:path'
15
+ import { linkSpecFor, materializePackageForLink, probeFailureKind, probeRegistryPackage, suggestedPinAction } from './dep-source.js'
15
16
  import { queuedWrite } from '../infra/fsx.js'
16
17
  import { resolvePackageJson } from '../infra/paths.js'
17
18
  import { reconcileLockfile } from './selfupdate.js'
@@ -82,6 +83,73 @@ async function removeBundleFromManifest(profileDir, bundlePkg) {
82
83
  })
83
84
  }
84
85
 
86
+ /** 读 profile 清单里某包的 spec(读不到返回 null)。 */
87
+ async function specOf(profileDir, packageName) {
88
+ try {
89
+ const manifest = await readManifest(profileDir)
90
+ const spec = manifest?.dependencies?.[packageName]
91
+ return typeof spec === 'string' && spec !== '' ? spec : null
92
+ } catch {
93
+ return null
94
+ }
95
+ }
96
+
97
+ /**
98
+ * **写回形态判定**(2026-09-27 改错,这是"清单里留下裸版本号 → 下次 pnpm 操作 404"的根修)。
99
+ *
100
+ * 修的是什么:`declareProfileDependency` 过去无条件把**磁盘上的版本号**写进 `dependencies`
101
+ * (注释写着"装到哪版就写哪版")——对 release 通道 / curl 手铺 / git 装配来的包,npm registry 上
102
+ * **根本没有这个包**。面板当时显示安装成功,直到任何一次 pnpm 操作按清单解析才炸 `ERR_PNPM_FETCH_404`;
103
+ * 真机证据:desktop profile 里 `@dsh-external/dsh-graded-mode: 0.0.1-rc1`(npmmirror 与 npmjs 双双 404)。
104
+ *
105
+ * 判据(与 lockfile 对账的 planWriteback 同一套语义,绝不两处各判一套):
106
+ * · registry 上**查得到这个包的这个版本** → 写版本号(原行为,回归不变);
107
+ * · 查无此包 / 查无此版本(404) → 物化到 `<DSH_HOME>/plugin-src/<包名>` 并写 `link:<绝对路径>`;
108
+ * · 本次没探到(网络/镜像不可达) → 不敢替用户猜:仍按旧行为写版本号,但**如实记 note**
109
+ * 并给出结构化「钉住」建议动作(面板可一键执行),绝不写成"已钉住"。
110
+ * 返回 { form, spec, note, suggestion };`spec` 为 null 表示"不要改清单"。
111
+ */
112
+ async function planDependencySpec({ profileDir, packageName, version, registries = [], probe = null, home = undefined, profile = null }) {
113
+ const doProbe = typeof probe === 'function' ? probe : probeRegistryPackage
114
+ const linkable = () => materializePackageForLink(profileDir, packageName, home === undefined ? {} : { home })
115
+ const asLink = (why) => {
116
+ const dir = linkable()
117
+ if (dir === null) {
118
+ return {
119
+ form: 'unknown',
120
+ spec: null,
121
+ note: `${packageName}:${why},但 node_modules 里找不到可物化的已装副本 —— 已跳过写回(未改动清单,避免留下指向不存在 npm 版本的裸版本号)`,
122
+ suggestion: null,
123
+ }
124
+ }
125
+ const link = linkSpecFor(dir)
126
+ return {
127
+ form: 'link',
128
+ spec: link,
129
+ note: `${packageName}:${why},清单已按 link: 形式记录(${link})—— 不经 npm registry 解析、不经 tarball 完整性校验,pnpm 重建 lock 也能装上`,
130
+ suggestion: null,
131
+ }
132
+ }
133
+ let result = null
134
+ try {
135
+ result = await doProbe(packageName, registries, { version })
136
+ } catch (error) {
137
+ result = { resolvable: false, hasVersion: false, tried: [error instanceof Error ? error.message : String(error)] }
138
+ }
139
+ if (result?.resolvable === true && result.hasVersion !== false) return { form: 'version', spec: version, note: null, suggestion: null }
140
+ if (probeFailureKind(result) === 'fetch-404') {
141
+ return asLink(result?.resolvable === true
142
+ ? `registry 上没有 ${version} 这个版本(该包只在 GitHub release / 本地有)`
143
+ : 'npm registry 查无此包(该包只存在于 GitHub release 或本地)')
144
+ }
145
+ return {
146
+ form: 'unknown',
147
+ spec: version,
148
+ note: `${packageName}:本次没能确认 registry 可解析性(网络/镜像不可达),先按版本号 ${version} 写入;若该包确实不在 npm 上,请执行下面的「钉住」动作改成 link:(否则下次 pnpm 操作会 404)`,
149
+ suggestion: suggestedPinAction({ packageName, version, profileDir, profile }),
150
+ }
151
+ }
152
+
85
153
  /**
86
154
  * 安装即声明:把包写进 profile 清单的 `dependencies`。
87
155
  *
@@ -89,38 +157,89 @@ async function removeBundleFromManifest(profileDir, bundlePkg) {
89
157
  * · 官方「设置 → 插件」的**已安装**分区只显示清单里声明过的包 —— 我们手铺的包在那里看不见;
90
158
  * · 任何一次 pnpm 操作都会按清单 + lock 重装,未声明的包会被**还原/清掉**;
91
159
  * · 连我们自己的「清理残余」也按「有没有被声明」判孤儿 —— 判据病根也在这里。
92
- * @returns {{ changed: boolean, version: string|null, reason: string|null, lockSynced: boolean, lockNote: string|null }}
160
+ *
161
+ * 2026-09-27 改错(见 planDependencySpec 注释):写进去的形态**按 registry 可解析性决定**,
162
+ * 并且写完**总是**做一次 lock 对账(对齐时零成本:reconcileLockfile 只在漂移时才跑 pnpm)。
163
+ * @returns {{ changed: boolean, version: string|null, spec: string|null, form: string|null, reason: string|null,
164
+ * lockSynced: boolean, lockNote: string|null, depNote: string|null, suggested: object|null }}
93
165
  */
94
- async function declareProfileDependency(profileDir, packageName, version = null, { syncLock = true } = {}) {
166
+ async function declareProfileDependency(profileDir, packageName, version = null, options = {}) {
167
+ const { syncLock = true, registries = [], probe = null, home = undefined, profile = null } = options
168
+ const empty = { changed: false, version: null, spec: null, form: null, reason: null, lockSynced: false, lockNote: null, depNote: null, suggested: null }
169
+ const resolved = typeof version === 'string' && version !== '' ? version : await installedVersionOf(profileDir, packageName)
170
+ if (resolved === null) return { ...empty, reason: '读不到已安装版本' }
171
+ // 探测/物化放在写锁**之外**(网络探测最长 8 秒,占着清单写锁会堵住其它写)
172
+ const plan = await planDependencySpec({ profileDir, packageName, version: resolved, registries, probe, home, profile })
173
+ if (plan.spec === null) return { ...empty, version: resolved, form: plan.form, reason: plan.note, depNote: plan.note, suggested: plan.suggestion }
95
174
  const result = await queuedWrite(async () => {
96
175
  const manifest = await readManifest(profileDir)
97
- const resolved = typeof version === 'string' && version !== '' ? version : await installedVersionOf(profileDir, packageName)
98
- if (resolved === null) return { changed: false, version: null, reason: '读不到已安装版本', needsLockSync: false }
99
176
  const deps = { ...(manifest.dependencies ?? {}) }
100
- if (deps[packageName] === resolved) return { changed: false, version: resolved, reason: null, needsLockSync: false }
101
- deps[packageName] = resolved
177
+ if (deps[packageName] === plan.spec) return { changed: false }
178
+ deps[packageName] = plan.spec
102
179
  manifest.dependencies = deps
103
180
  await writeManifest(profileDir, manifest)
104
- return { changed: true, version: resolved, reason: null, needsLockSync: true }
181
+ return { changed: true }
105
182
  })
106
- if (result.needsLockSync !== true || syncLock !== true) return { ...result, lockSynced: false, lockNote: null }
183
+ const out = { ...empty, changed: result.changed, version: resolved, spec: plan.spec, form: plan.form, depNote: plan.note, suggested: plan.suggestion }
184
+ if (syncLock !== true) return out
107
185
  // **写完清单必须把 lock 拉齐**(2026-09-24 CI 实测):只写清单不写 lock → 清单/lock 不一致 →
108
186
  // 之后任何 pnpm 操作都会硬失败(ERR_PNPM_CANNOT_REMOVE_MISSING_DEPS)。
109
187
  // 放在写队列**之外**执行(对账本身会跑 pnpm,不该占着清单写锁);失败只记 note,不让安装失败。
110
- return { ...(await syncLockFor(profileDir, [packageName])), changed: true, version: result.version, reason: null }
188
+ const synced = await syncLockFor(profileDir, [packageName], { registries, home, profile })
189
+ return { ...out, ...synced, depNote: joinNotes(plan.note, synced.depNote), suggested: synced.suggested ?? plan.suggestion }
190
+ }
191
+
192
+ /** 多段说明拼接(空段自动跳过):文案只做拼接,不覆盖 —— 覆盖就是"谎报"的来源。 */
193
+ function joinNotes(...notes) {
194
+ const list = notes.filter((note) => typeof note === 'string' && note.trim() !== '')
195
+ return list.length === 0 ? null : list.join(';')
111
196
  }
112
197
 
113
198
  /** 声明之后同步 lock:用仓库既有的对账通道;失败如实回报,不假装成功。 */
114
- async function syncLockFor(profileDir, names) {
199
+ async function syncLockFor(profileDir, names, options = {}) {
115
200
  try {
116
- const { reconcileLockfile } = await import('./selfupdate.js')
117
- await reconcileLockfile({ profileDir, packages: names.map((name) => ({ name })) })
118
- return { lockSynced: true, lockNote: null }
201
+ const lock = await reconcileLockfile({
202
+ profileDir,
203
+ packages: names.map((name) => ({ name })),
204
+ registries: Array.isArray(options.registries) ? options.registries : [],
205
+ ...(options.home === undefined ? {} : { home: options.home }),
206
+ })
207
+ return {
208
+ lockSynced: lock.lockUpdated === true,
209
+ lockNote: lock.lockNote,
210
+ depNote: lock.depNote,
211
+ suggested: Array.isArray(lock.suggestedActions) && lock.suggestedActions.length > 0 ? lock.suggestedActions[0] : null,
212
+ }
119
213
  } catch (error) {
120
- return { lockSynced: false, lockNote: `lock 对账失败:${error instanceof Error ? error.message : String(error)}` }
214
+ return { lockSynced: false, lockNote: `lock 对账失败:${error instanceof Error ? error.message : String(error)}`, depNote: null, suggested: null }
121
215
  }
122
216
  }
123
217
 
218
+ /**
219
+ * 「钉住」动作的核心(POST /plugin-console/run-suggested 的 pin-dependency 分支):
220
+ * 把这个包在清单里钉成 `link:<DSH_HOME>/plugin-src/<包名>`(需要时先把已装副本物化过去)。
221
+ * 只碰清单,不跑 pnpm(lock/node_modules 的对齐由动作执行器随后显式跑一次 pnpm add 完成)。
222
+ * 返回 { ok, spec, before, after, changed, dir };物化不出来时**抛错**(绝不写指向不存在目录的 link:)。
223
+ */
224
+ async function pinProfileDependency(profileDir, packageName, options = {}) {
225
+ const { home = undefined } = options
226
+ const dir = materializePackageForLink(profileDir, packageName, home === undefined ? {} : { home })
227
+ if (dir === null) throw new Error(`${packageName}:node_modules 里没有可物化的已装副本,无法钉住(已中止,未改动清单)`)
228
+ const spec = linkSpecFor(dir)
229
+ const before = await specOf(profileDir, packageName)
230
+ const changed = await queuedWrite(async () => {
231
+ const manifest = await readManifest(profileDir)
232
+ const deps = { ...(manifest.dependencies ?? {}) }
233
+ if (deps[packageName] === spec) return false
234
+ deps[packageName] = spec
235
+ manifest.dependencies = deps
236
+ await writeManifest(profileDir, manifest)
237
+ return true
238
+ })
239
+ const after = await specOf(profileDir, packageName)
240
+ return { ok: after === spec, spec, before, after, changed, dir }
241
+ }
242
+
124
243
  /** 卸载即撤销声明。 */
125
244
  async function undeclareProfileDependency(profileDir, packageName) {
126
245
  return queuedWrite(async () => {
@@ -165,19 +284,26 @@ async function planDependencyBackfill(profileDir) {
165
284
  return { candidates, skipped }
166
285
  }
167
286
 
168
- /** 执行补声明:逐条写入清单(每条独立走写队列,一条失败不影响其它)。 */
169
- async function applyDependencyBackfill(profileDir) {
287
+ /** 执行补声明:逐条写入清单(每条独立走写队列,一条失败不影响其它)。
288
+ * 2026-09-27:补声明同样按 registry 可解析性决定形态(补声明也曾把 release 专属包写成裸版本号 →
289
+ * 下次 pnpm 操作 404)。`options` 直接透传给 declareProfileDependency(registries/probe 注入)。 */
290
+ async function applyDependencyBackfill(profileDir, options = {}) {
170
291
  const { candidates, skipped } = await planDependencyBackfill(profileDir)
171
292
  const declared = []
293
+ const notes = []
294
+ const suggested = []
172
295
  for (const item of candidates) {
173
- const result = await declareProfileDependency(profileDir, item.name, item.version)
174
- if (result.changed) declared.push({ name: item.name, version: result.version })
296
+ const result = await declareProfileDependency(profileDir, item.name, item.version, options)
297
+ if (result.changed) declared.push({ name: item.name, version: result.version, spec: result.spec, form: result.form })
175
298
  else skipped.push({ name: item.name, reason: result.reason ?? '无需改动' })
299
+ if (result.depNote !== null) notes.push(result.depNote)
300
+ if (result.suggested !== null) suggested.push(result.suggested)
176
301
  }
177
- return { declared, skipped }
302
+ return { declared, skipped, depNote: notes.length === 0 ? null : notes.join(';'), suggestedActions: suggested }
178
303
  }
179
304
 
180
305
  export {
181
306
  DEFAULT_BUNDLES, addBundleToManifest, applyDependencyBackfill, declareProfileDependency,
182
- installedVersionOf, planDependencyBackfill, removeBundleFromManifest, syncLockFor, undeclareProfileDependency,
307
+ installedVersionOf, planDependencyBackfill, planDependencySpec, pinProfileDependency, removeBundleFromManifest,
308
+ specOf, syncLockFor, undeclareProfileDependency,
183
309
  }