@noob-stupid/dsh-plugin-console 0.5.19 → 0.5.21

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, typeof done?.exitCode === "number" ? "exitCode=" + done.exitCode : null, 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 }
@@ -214,6 +214,14 @@ async function runInstallJob(job, ports, deps = {}) {
214
214
  }
215
215
  const jobBudgetMs = Number.isFinite(deps.jobBudgetMs) && deps.jobBudgetMs > 0 ? deps.jobBudgetMs : 8 * 60 * 1000
216
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
+ }
217
225
  try {
218
226
  job.stage = 'preparing'
219
227
  // 2026-09-06 事故(室友机器):对 deepseek-ai/deepseek-harness(框架本体仓库)点安装会按 bundle 规则
@@ -479,6 +487,8 @@ async function runInstallJob(job, ports, deps = {}) {
479
487
  if (lock.lockNote !== null) job.lockNote = lock.lockNote
480
488
  // 非常规来源(link:)写回说明:必须让用户看见(缺陷②最阴的地方就是"装完完全看不出问题")
481
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]
482
492
  } catch {}
483
493
  job.stage = 'configuring'
484
494
  // 2026-09-06 事故(i18n 更新变重复行):@linxin666/dsh-i18n 声明了 dsh.bundle.patch,
@@ -507,30 +517,23 @@ async function runInstallJob(job, ports, deps = {}) {
507
517
  } else {
508
518
  job.bundleNote = (job.bundleNote ? job.bundleNote + ';' : '') + `${installedName} 已由已安装聚合包提供,仅更新包本身,不再注册重复行`
509
519
  }
510
- // 安装即声明(2026-09-24):装到哪版就把它写进 profile 清单的 dependencies,
511
- // **并立刻同步 pnpm-lock.yaml**(declareProfileDependency 内部已含对账)——只写清单不写 lock
512
- // 会让之后任何 pnpm 操作硬失败(ERR_PNPM_CANNOT_REMOVE_MISSING_DEPS)。
513
- // 对账失败只记 note,不让已经装好的包变成"安装失败"。
514
- const declaredBundle = await declareProfileDependency(profileDir, installedName)
515
- if (declaredBundle.changed) job.declared = declaredBundle.version
516
- 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 }))
517
524
  job.status = 'done'
518
525
  return
519
526
  }
520
527
  if (alreadyServed) {
521
528
  job.note = `${installedName} 已由已安装条目提供,仅更新包本身,不重复注册`
522
- const declaredServed = await declareProfileDependency(profileDir, installedName)
523
- if (declaredServed.changed) job.declared = declaredServed.version
524
- if (declaredServed.lockSynced === false && declaredServed.lockNote !== null) job.lockNote = declaredServed.lockNote
529
+ applyDeclared(await declareProfileDependency(profileDir, installedName, null, { registries }))
525
530
  job.status = 'done'
526
531
  return
527
532
  }
528
533
  const entryId = deriveEntryId(installedName, taken)
529
534
  await appendInsert(patchPath, entryId, installedName)
530
535
  job.entryId = entryId
531
- const declared = await declareProfileDependency(profileDir, installedName)
532
- if (declared.changed) job.declared = declared.version
533
- if (declared.lockSynced === false && declared.lockNote !== null) job.lockNote = declared.lockNote
536
+ applyDeclared(await declareProfileDependency(profileDir, installedName, null, { registries }))
534
537
  job.status = 'done'
535
538
  } catch (error) {
536
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'
@@ -34,6 +34,19 @@ const PROBE_TIMEOUT_MS = 8000
34
34
  const REPAIR_TIMEOUT_MS = 180000
35
35
  const DEP_SECTIONS = ['dependencies', 'devDependencies', 'optionalDependencies']
36
36
 
37
+ /**
38
+ * spec 是不是**非 registry 来源**(`link:` / `file:` / `workspace:` / `portal:` / URL / git / 别名)。
39
+ * 2026-09-27 改错(与 0.5.20 的写回规则配套):这类依赖**不经 registry 解析**,所以"registry 上 404"
40
+ * 对它们毫无信息量 —— 体检不能拿它判问题,重建更不能被它拦下。真实现场:desktop profile 的两个
41
+ * `@dsh-external/*` 按新规则钉成 `link:` 之后,体检仍按包名去 registry 探,报 `fetch-404` 并把重建
42
+ * **永久**挡在门外(`repair.applicable=false`),而 lock 其实完全一致。
43
+ * 与 `specSatisfiedBy` 共用同一份判据(唯一真源,避免两处各写一条正则)。
44
+ */
45
+ function isSourceSpec(spec) {
46
+ return typeof spec === 'string'
47
+ && /^(?:link|file|workspace|portal|npm|git\+|git:|github:|gitlab:|bitbucket:|https?:)/iu.test(spec.trim())
48
+ }
49
+
37
50
  /** 重建 lock 的 argv(纯函数,唯一产出点):只重写 lock,不碰 node_modules;
38
51
  * `--no-frozen-lockfile` 是必需的(我们的 pnpm env 带 CI=true,pnpm 在 CI 下默认 frozen-lockfile,
39
52
  * 不加它就只会得到 `ERR_PNPM_OUTDATED_LOCKFILE` —— 正是要修的那个症状)。
@@ -111,7 +124,7 @@ function parseLockImporters(lockText) {
111
124
  * · 非 registry 来源(link:/file:/git+/http…)→ 交给 specifier 相等判定,这里恒 true。 */
112
125
  function specSatisfiedBy(spec, version) {
113
126
  if (typeof spec !== 'string' || spec === '') return true
114
- if (/^(?:link|file|workspace|portal|npm|git\+|git:|github:|https?:)/iu.test(spec)) return true
127
+ if (isSourceSpec(spec)) return true
115
128
  if (typeof version !== 'string' || version === '') return false
116
129
  if (/^[\^~]?\d/u.test(spec) || /^(?:>=|>|=|<=|<)\s*\d/u.test(spec)) return semverRangeMatch(version, spec)
117
130
  return version === spec
@@ -151,7 +164,9 @@ function diffLockfile({ manifestText, lockText, installed = {} } = {}) {
151
164
  state = 'version-mismatch'
152
165
  }
153
166
  const onDisk = installed[dep.name] ?? null
154
- if (onDisk !== null && locked.version !== null && onDisk !== locked.version) {
167
+ // 来源型 spec(link:/file:/URL/git)没有"版本号相等"可比:按盘上版本号与 lock 里的
168
+ // `link:../../…` 比,必然"漂移"(假阳性)。两个方向都判来源(spec 或 lock 解析任一是来源)。
169
+ if (onDisk !== null && locked.version !== null && !isSourceSpec(dep.spec) && !isSourceSpec(locked.version) && onDisk !== locked.version) {
155
170
  drift.push({ name: dep.name, installed: onDisk, lockVersion: locked.version })
156
171
  }
157
172
  deps.push({ name: dep.name, spec: dep.spec, section: dep.section, lockSpecifier: locked.specifier, lockVersion: locked.version, state })
@@ -183,11 +198,12 @@ function freshReleases(name, meta, { now = Date.now(), hours = RELEASE_AGE_HOURS
183
198
  return ageHours >= 0 && ageHours < hours ? [{ name, version: latest, publishedAt: time[latest], ageHours: Math.round(ageHours * 10) / 10 }] : []
184
199
  }
185
200
 
186
- /** registry 探测结论 → 分类(纯函数):能从 tried 文本里认出 404 就点名 404,否则算"不可达"。 */
201
+ /** registry 探测结论 → 分类(纯函数):能从 tried 文本里认出 404 就点名 404,否则算"不可达"。
202
+ * 2026-09-27:判据搬进 domain/dep-source.js#probeFailureKind(写回决策与体检共用同一份正则——两处
203
+ * 各写一份的结果是"一处改了另一处不知道",而这两处对 404 的判定必须永远一致)。本函数保留为
204
+ * 兼容导出,行为逐字不变(tests/test-lockfile-health.mjs ⑥ 仍按原名断言)。 */
187
205
  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'
206
+ return probeFailureKind(probe)
191
207
  }
192
208
 
193
209
  /** manifest 的 spec 里能确定"就是这一个版本"时取出来(用于探测"这个版本在不在 registry 上")。 */
@@ -249,8 +265,10 @@ async function runLockfileCheck({
249
265
  const diff = diffLockfile({ manifestText: manifestText ?? '', lockText, installed })
250
266
  const manifest = readManifestDeps(manifestText ?? '')
251
267
 
252
- // ① 依赖能不能在 registry 上解析(404 要点名;不可达与 404 分开说)
253
- const probes = await Promise.all((manifest.ok ? manifest.deps : []).map(async (dep) => {
268
+ // ① 依赖能不能在 registry 上解析(404 要点名;不可达与 404 分开说)。
269
+ // **来源型 spec 跳过探测**(2026-09-27 改错):link:/file:/URL/git 本来就不经 registry 解析。
270
+ const sourceSpecDeps = (manifest.ok ? manifest.deps : []).filter((dep) => isSourceSpec(dep.spec)).map((dep) => dep.name)
271
+ const probes = await Promise.all((manifest.ok ? manifest.deps : []).filter((dep) => !isSourceSpec(dep.spec)).map(async (dep) => {
254
272
  const result = await probe(dep.name, probeList, {
255
273
  version: exactVersionOf(dep.spec),
256
274
  timeoutMs: PROBE_TIMEOUT_MS,
@@ -315,6 +333,8 @@ async function runLockfileCheck({
315
333
  lockfile: { present: diff.lockfilePresent, parsed: diff.lockfileParsed, entries: diff.deps.filter((d) => d.lockVersion !== null).length },
316
334
  problems,
317
335
  packages404: name404,
336
+ // 非 registry 来源的依赖(link:/file:/URL/git):明确列出来,说明"为什么没探测它们"
337
+ sourceLinked: sourceSpecDeps,
318
338
  outdated: {
319
339
  lockfileMissing: !diff.lockfilePresent,
320
340
  missing: diff.missing,
@@ -421,6 +441,7 @@ export {
421
441
  diffLockfile,
422
442
  exactVersionOf,
423
443
  freshReleases,
444
+ isSourceSpec,
424
445
  parseLockImporters,
425
446
  problemLines,
426
447
  probeVerdict,
@@ -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
  }
@@ -0,0 +1,208 @@
1
+ // L1 · domain —— plugin-actions.js(**结构化建议动作的执行器**:白名单动作 + 服务端自拼 argv)
2
+ //
3
+ // 为什么单开一个模块(2026-09-27 加法 + 改错,用户明确要求「可执行的钉住/修复动作框」):
4
+ // · 面板过去只给一句文案("要钉住请执行:dsh plugin … add …"),用户得自己开终端抄命令,
5
+ // 抄错一个字符就是另一种事故;而**让面板自由执行命令**又是绝不能开的口子。
6
+ // · 所以接口只接受**结构化的动作类型 + 目标**(`{ action, packageName, version, profile }`),
7
+ // 命令行的每一个参数都由本模块的 ACTION_SPECS 自己拼 —— 客户端**没有任何位置**能传命令字符串。
8
+ // 出现 command/cmd/argv/args/exec/shell/script/run/spawn/bin 任一字段即 400(配套测试钉死)。
9
+ // · `profile` 字段**只作回显**:服务端一律按当前实例的 profile 目录施工(绝不按请求体挑目录)。
10
+ //
11
+ // 动作表(唯一真源,新增动作必须同时进这张表与测试):
12
+ // · pin-dependency:把清单里的该包钉成 `link:<DSH_HOME>/plugin-src/<包名>`,再跑一次
13
+ // `pnpm add <link:…>` 把 lock / node_modules 对齐(argv 由 infra/exec.js#pnpmAddArgs 产出);
14
+ // · reconcile-lock:只重写 pnpm-lock.yaml,复用 lockfile-health 的 repair(argv 由 repairArgsFor
15
+ // 唯一产出,**不带**任何绕过供应链闸的开关)。
16
+ //
17
+ // 供应链闸的一次性放宽(写清代价,绝不静默):pnpm 11 的 `minimumReleaseAge` 默认 24h,
18
+ // 本控制台自己刚发的版本 (<24h) 会让**任何** lockfile 校验失败 —— 连"把本地 link: 记进 lock"都做不成。
19
+ // 处理:第一次**原样跑**(不带任何放宽);只有当失败原因**确定**是 ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION
20
+ // 时,才为**这一条命令**追加 `--config.minimumReleaseAge=0` 重试一次,并在结果里显式回报
21
+ // `relaxedReleaseAge: true`(面板原样展示)。绝不写进任何配置文件、绝不用它绕过错配的包名/版本。
22
+ //
23
+ // 分层:L1 domain —— 不认识 cordis ctx,IO/执行器一律可注入(便于离线单测)。
24
+ import { existsSync, readFileSync } from 'node:fs'
25
+ import { join } from 'node:path'
26
+ import { NPM_NAME_RE } from './dep-source.js'
27
+ import { runLockfileRepair } from './lockfile-health.js'
28
+ import { pinProfileDependency, specOf } from './manifest.js'
29
+ import { lockVersion } from './selfupdate.js'
30
+ import { buildPnpmEnv, pnpmAddArgs, runPnpmWithFallback } from '../infra/exec.js'
31
+
32
+ const DEFAULT_REGISTRY = 'https://registry.npmmirror.com'
33
+ /** 动作白名单(客户端只能给这些 kind;执行分支与 argv 全在服务端)。 */
34
+ const ACTION_KINDS = ['pin-dependency', 'reconcile-lock']
35
+ /** 一旦出现这些键就整条拒绝:本接口不存在"传命令"的位置。 */
36
+ const COMMAND_KEYS = ['command', 'cmd', 'argv', 'args', 'exec', 'execute', 'shell', 'script', 'run', 'spawn', 'bin', 'shellCommand']
37
+ const OUTPUT_LIMIT = 4000
38
+
39
+ /** 校验并翻译成结构化请求。返回 { ok, error, status, action, packageName, version, profile }。 */
40
+ function parseActionRequest(body) {
41
+ const raw = body !== null && typeof body === 'object' ? body : {}
42
+ for (const key of COMMAND_KEYS) {
43
+ if (key in raw) {
44
+ return { ok: false, status: 400, error: `不接受客户端传来的命令字符串(字段 ${key} 非法):本接口只认结构化动作,命令行由服务端自己拼` }
45
+ }
46
+ }
47
+ const action = typeof raw.action === 'string' ? raw.action.trim() : ''
48
+ if (action === '') return { ok: false, status: 400, error: '缺少 action(只允许 pin-dependency / reconcile-lock)' }
49
+ if (!ACTION_KINDS.includes(action)) {
50
+ return { ok: false, status: 400, error: `未知动作 ${action}(只允许 ${ACTION_KINDS.join(' / ')})` }
51
+ }
52
+ const packageName = typeof raw.packageName === 'string' ? raw.packageName.trim() : ''
53
+ if (action === 'pin-dependency') {
54
+ if (packageName === '') return { ok: false, status: 400, error: 'pin-dependency 需要 packageName' }
55
+ if (packageName.length > 214 || !NPM_NAME_RE.test(packageName)) return { ok: false, status: 400, error: 'packageName 不是合法的 npm 包名' }
56
+ }
57
+ const version = typeof raw.version === 'string' && raw.version.length <= 120 ? raw.version : null
58
+ const profile = typeof raw.profile === 'string' && raw.profile.length <= 64 ? raw.profile : null
59
+ return { ok: true, error: null, status: 200, action, packageName: packageName === '' ? null : packageName, version, profile }
60
+ }
61
+
62
+ /** 该包是否属于当前 profile(已声明在 dependencies / devDependencies / optionalDependencies 或
63
+ * `dsh.profile.bundles` 里)。**安全边界**:动作只对该 profile 自己认得的包生效,
64
+ * 请求体说了不算(包名由客户端给,但"这个包属不属于这个 profile"必须服务端自己读盘判定)。 */
65
+ function isProfilePackage(profileDir, packageName) {
66
+ try {
67
+ const manifest = JSON.parse(readFileSync(join(profileDir, 'package.json'), 'utf8'))
68
+ for (const section of ['dependencies', 'devDependencies', 'optionalDependencies']) {
69
+ if (manifest?.[section] !== null && typeof manifest?.[section] === 'object' && packageName in manifest[section]) return true
70
+ }
71
+ const bundles = manifest?.dsh?.profile?.bundles
72
+ return Array.isArray(bundles) && bundles.includes(packageName)
73
+ } catch {
74
+ return false
75
+ }
76
+ }
77
+
78
+ /** 目标包是否已落在该 profile 的 node_modules 里(pin 的物理前提)。 */
79
+ function isInstalled(profileDir, packageName) {
80
+ return existsSync(join(profileDir, 'node_modules', ...String(packageName).split('/'), 'package.json'))
81
+ }
82
+
83
+ function tail(text, limit = OUTPUT_LIMIT) {
84
+ const s = typeof text === 'string' ? text : String(text ?? '')
85
+ return s.length <= limit ? s : s.slice(-limit)
86
+ }
87
+
88
+ /** 供应链闸判据(只认 pnpm 自己的错误码/文案;认不出就绝不放宽)。 */
89
+ function isReleaseAgeBlock(message) {
90
+ return /ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION|minimumReleaseAge/iu.test(String(message ?? ''))
91
+ }
92
+
93
+ /** 跑一次 pnpm(原样),必要时**只对这一条命令**放宽供应链闸并如实标记。
94
+ * 返回 { ok, exitCode, stdout, stderr, relaxedReleaseAge, error }。 */
95
+ async function runAddWithReleaseAgeRetry({ profileDir, spec, registry, runAdd, timeoutMs = 180000 }) {
96
+ const argv = pnpmAddArgs(spec, registry)
97
+ const execOpts = { cwd: profileDir, timeout: timeoutMs, windowsHide: true, maxBuffer: 4 * 1024 * 1024, env: buildPnpmEnv(registry) }
98
+ const attempt = async (args) => {
99
+ try {
100
+ const out = await runAdd(args, { execOpts })
101
+ return { ok: true, exitCode: 0, stdout: tail(out?.stdout), stderr: tail(out?.stderr), error: null }
102
+ } catch (error) {
103
+ return {
104
+ ok: false,
105
+ exitCode: Number.isInteger(error?.code) ? error.code : 1,
106
+ stdout: tail(error?.stdout),
107
+ stderr: tail(error?.stderr ?? error?.message ?? String(error)),
108
+ error: error instanceof Error ? error : new Error(String(error)),
109
+ }
110
+ }
111
+ }
112
+ const first = await attempt(argv)
113
+ // 放宽条件**必须同时**成立:第一次真的失败了,且失败原因确定是供应链闸(文案/错误码认得出)
114
+ const blocked = !first.ok && (isReleaseAgeBlock(first.stderr) || isReleaseAgeBlock(String(first.error?.message ?? '')))
115
+ if (!blocked) return { ...first, relaxedReleaseAge: false }
116
+ const retried = await attempt([...argv, '--config.minimumReleaseAge=0'])
117
+ return { ...retried, relaxedReleaseAge: true }
118
+ }
119
+
120
+ /**
121
+ * 执行一个结构化建议动作(**唯一入口**,路由只做鉴权/取 profileDir)。
122
+ * 返回统一形状:{ ok, status, action, packageName, exitCode, command, stdout, stderr, relaxedReleaseAge,
123
+ * manifest: { before, after, changed, pinned }, lock: { entry, synced }, notes, reason, hint, detail }
124
+ * `ok` = 目标状态**真的**达成(pin:清单是 link: **且** lock 里也有该 link 条目);只达成一半时
125
+ * `partial: true` 并如实说明哪一半没成 —— 绝不把"写了一半"报成成功。
126
+ */
127
+ async function runSuggestedAction({ body, profileDir, registries = [], deps = {} } = {}) {
128
+ const parsed = parseActionRequest(body)
129
+ if (parsed.ok !== true) return { ok: false, status: parsed.status, action: null, error: parsed.error }
130
+ const registry = (Array.isArray(registries) ? registries : []).find((r) => typeof r === 'string' && r.trim() !== '') ?? DEFAULT_REGISTRY
131
+ const runAdd = typeof deps.runAdd === 'function' ? deps.runAdd : runPnpmWithFallback
132
+ const pin = typeof deps.pin === 'function' ? deps.pin : pinProfileDependency
133
+ const repair = typeof deps.repair === 'function' ? deps.repair : runLockfileRepair
134
+ const base = {
135
+ ok: false, status: 200, action: parsed.action, packageName: parsed.packageName, profile: parsed.profile,
136
+ registry, exitCode: null, command: null, stdout: '', stderr: '', relaxedReleaseAge: false,
137
+ manifest: null, lock: null, notes: [], reason: null, hint: null, detail: null,
138
+ }
139
+ if (parsed.action === 'reconcile-lock') {
140
+ const view = await repair({ profileDir, registries: Array.isArray(registries) && registries.length > 0 ? registries : [registry] })
141
+ const done = view?.action === 'repaired' || view?.action === 'noop'
142
+ return {
143
+ ...base,
144
+ ok: done,
145
+ exitCode: view?.action === 'failed' ? 1 : 0,
146
+ command: view?.command ?? null,
147
+ stderr: tail(view?.stderrTail),
148
+ notes: [view?.reason].filter((n) => typeof n === 'string'),
149
+ reason: view?.reason ?? null,
150
+ hint: view?.hint ?? null,
151
+ detail: view ?? null,
152
+ lock: { entry: null, synced: view?.action === 'repaired' || view?.action === 'noop' },
153
+ }
154
+ }
155
+ // pin-dependency
156
+ if (!isProfilePackage(profileDir, parsed.packageName)) {
157
+ return { ...base, ok: false, status: 400, error: `${parsed.packageName} 不在本 profile 的清单(dependencies / dsh.profile.bundles)里,拒绝改动` }
158
+ }
159
+ if (!isInstalled(profileDir, parsed.packageName)) {
160
+ return { ...base, ok: false, status: 400, error: `${parsed.packageName} 没有装在当前 profile 的 node_modules 里,无法钉住(先安装)` }
161
+ }
162
+ const before = await specOf(profileDir, parsed.packageName)
163
+ let pinned = null
164
+ try {
165
+ pinned = await pin(profileDir, parsed.packageName, {})
166
+ } catch (error) {
167
+ return { ...base, ok: false, status: 200, manifest: { before, after: before, changed: false, pinned: false }, reason: error instanceof Error ? error.message : String(error) }
168
+ }
169
+ const argv = pnpmAddArgs(pinned.spec, registry)
170
+ const run = await runAddWithReleaseAgeRetry({ profileDir, spec: pinned.spec, registry, runAdd, timeoutMs: deps.timeoutMs })
171
+ const after = await specOf(profileDir, parsed.packageName)
172
+ const entry = lockVersion(profileDir, parsed.packageName)
173
+ const lockSynced = typeof entry === 'string' && entry.startsWith('link:')
174
+ // "钉住了"的判据:清单里该包**是 link: 形式**(不要求与计划的绝对路径逐字相同 —— pnpm 自己也可能把它
175
+ // 规范化成相对路径,那同样是"不经 registry 解析"的来源型 spec,判据见 domain/dep-source.js#linkSpecIsIntact)
176
+ const pinnedOk = typeof after === 'string' && after.startsWith('link:')
177
+ const manifests = { before, after, changed: before !== after, planned: pinned.spec, pinned: pinnedOk }
178
+ const notes = []
179
+ if (run.relaxedReleaseAge) notes.push('本机 pnpm 的 24h 供应链闸(minimumReleaseAge)拦下了这次 lock 校验,已**仅对这一条命令**追加 --config.minimumReleaseAge=0 重试一次(有安全代价,未写进任何配置文件)。')
180
+ // 三种"pnpm 那一步没跑成"的形态都如实记一句(2026-09-27 真机桌面端实例实测:corepack 在该进程里
181
+ // 解析不到 → pnpm 那一步失败,而清单/lock 其实已达成目标;只留 stderr 会让用户自己猜发生了什么)
182
+ if (!run.ok) {
183
+ notes.push(manifests.pinned
184
+ ? (lockSynced
185
+ ? `pnpm 那一步没跑完(exitCode=${run.exitCode},原始输出见 stderr):清单与 lock 已经是 link:,目标状态已达成。`
186
+ : `清单已钉住(下次 pnpm 操作不会再 404),但 lock 没能对齐(exitCode=${run.exitCode}):见 stderr。`)
187
+ : `清单与 lock 都没改成功(exitCode=${run.exitCode}):见 stderr(原始输出已原样回显)。`)
188
+ }
189
+ return {
190
+ ...base,
191
+ // 只有"清单真的是 link: **且** lock 里也有这条 link"才算成功;只成一半时 partial=true 并如实说明
192
+ ok: manifests.pinned && lockSynced,
193
+ partial: manifests.pinned && !lockSynced,
194
+ exitCode: run.exitCode,
195
+ command: `pnpm ${argv.join(' ')}`,
196
+ stdout: run.stdout,
197
+ stderr: run.stderr,
198
+ relaxedReleaseAge: run.relaxedReleaseAge,
199
+ manifest: manifests,
200
+ lock: { entry: entry ?? null, synced: lockSynced },
201
+ notes,
202
+ reason: manifests.pinned
203
+ ? (lockSynced ? `${parsed.packageName} 已钉住:清单与 pnpm-lock.yaml 都指向 link:${pinned.dir}` : `${parsed.packageName} 的清单已钉住,lock 未对齐`)
204
+ : `${parsed.packageName} 的清单写回未生效(实际是 ${after ?? '(无该条目)'})`,
205
+ }
206
+ }
207
+
208
+ export { ACTION_KINDS, COMMAND_KEYS, DEFAULT_REGISTRY, OUTPUT_LIMIT, isProfilePackage, isReleaseAgeBlock, parseActionRequest, runAddWithReleaseAgeRetry, runSuggestedAction }
@@ -12,7 +12,7 @@
12
12
  // ③ 回读核实 readInstalledVersion / lockVersion;都不匹配才回落手铺文件,且**必须**带 lockNote 警告。
13
13
  import { existsSync, readFileSync, writeFileSync } from 'node:fs'
14
14
  import { join } from 'node:path'
15
- import { linkSpecFor, linkSpecIsIntact, materializePackageForLink, probeRegistryPackage } from './dep-source.js'
15
+ import { linkSpecFor, linkSpecIsIntact, materializePackageForLink, probeRegistryPackage, suggestedPinAction } from './dep-source.js'
16
16
  import { pnpmInstall } from './install.js'
17
17
  import { fetchJsonUrl } from '../infra/http.js'
18
18
  import { runPnpmWithFallback } from '../infra/exec.js'
@@ -239,36 +239,46 @@ async function reconcileLockfile({ profileDir, packageName = null, packages = nu
239
239
  }))
240
240
  const sourcePinned = (v) => typeof v === 'string' && SOURCE_PINNED_RE.test(v.trim())
241
241
  const urlPinned = (v) => typeof v === 'string' && /^https?:/u.test(v.trim())
242
- /** 缺陷②指纹:manifest 声明裸版本号、lock 却把该包解析到一个 URL —— pnpm 静默改写留下的状态。 */
243
- const misrecorded = (r) => typeof r.spec === 'string' && EXACT_VERSION_RE.test(r.spec.trim()) && urlPinned(r.lock)
242
+ /** 缺陷②指纹:manifest 声明裸版本号、lock 却把该包解析到一个 URL —— pnpm 静默改写留下的状态。
243
+ * 2026-09-27 扩展(真机证据,desktop profile):**不止 URL** —— `dependencies` 写 `0.0.1-rc1`
244
+ * 而 lock 的 importer 把同一个包钉在 `link:…`,同样是"两处对不上"的静默遗留态(清单里那个
245
+ * 裸版本号在 registry 上根本不存在,lock 一重建就 ERR_PNPM_FETCH_404)。来源一律算指纹。 */
246
+ const misrecorded = (r) => typeof r.spec === 'string' && EXACT_VERSION_RE.test(r.spec.trim()) && sourcePinned(r.lock)
244
247
  /** manifest 里仍是 tarball URL:虽然 lock 能解析,但 pnpm 10 重写 lock 会丢 integrity(实测),
245
248
  * 必须**主动**规整成 link: 形式 —— 否则「删 lock / 清 node_modules / 换机」就装不回来。 */
246
249
  const urlSpec = (r) => typeof r.spec === 'string' && /^https?:/u.test(r.spec.trim())
247
250
  /** 来源钉住的包没有「版本号相等」可比:link 依赖要看链接是否**真的**还在(release 通道更新会把
248
251
  * node_modules/<包名> 换成真实目录,此时必须重新物化 + 重放 link:,否则下次 pnpm 操作会还原版本);
249
- * 其余来源(git+/file:/URL)只要有解析且包装着即视为对齐。 */
252
+ * 其余来源(git+/file:/URL)只要有解析且包装着即视为对齐。
253
+ * ★ 2026-09-27 改错:link: 依赖还必须**在 lock 里有那条 link 条目**才算对齐。旧判据只看"链接还在",
254
+ * 于是「清单是 link:、node_modules 是链接、但 lock 里根本没有这个依赖」也被判成对齐 → `lockUpdated`
255
+ * 报 true(谎报"已写进 lock"),而 lock 一重建就丢依赖。真机复现路径:先手动把 node_modules 换成指向
256
+ * plugin-src 的链接(或上一次对账刚建好链接),再走一次声明 —— pnpm add 根本不会被调用。 */
250
257
  const aligned = (r) => {
251
258
  if (r.installed === null) return false
252
- if (typeof r.spec === 'string' && r.spec.startsWith('link:')) return linkSpecIsIntact(profileDir, r.name, r.spec)
259
+ if (typeof r.spec === 'string' && r.spec.startsWith('link:')) {
260
+ return linkSpecIsIntact(profileDir, r.name, r.spec)
261
+ && typeof r.lock === 'string' && r.lock.trim().startsWith('link:')
262
+ }
253
263
  return r.installed === r.lock || sourcePinned(r.lock)
254
264
  }
255
265
  const driftedOf = (rows) => rows.filter((r) => r.installed === null || !aligned(r) || misrecorded(r) || urlSpec(r))
256
266
 
257
267
  const depNotes = []
258
- // 物化 + link: 计划(registry 查无此包时唯一安全的写回形式)
268
+ // 物化 + link: 计划(registry 查无此包时唯一安全的写回形式)。
269
+ // ★ 2026-09-27 改错:这里**只产出"打算写什么"和"为什么"**,不再抢先断言"已按 link: 形式记录依赖"——
270
+ // 旧版在 pnpm add 之前就把这句写进 depNote,于是"写回失败/被后一步覆盖"时面板仍然报"已钉住",
271
+ // 而磁盘上留着的是裸版本号(真机 desktop profile 的实际状态)。最终文案一律在写回之后**读回真实值**再生成。
259
272
  const linkPlan = (row, why) => {
260
273
  const dir = materializePackageForLink(profileDir, row.name, home === null ? {} : { home })
261
274
  if (dir === null) {
262
275
  return {
263
276
  spec: null,
277
+ reason: why,
264
278
  note: `${row.name}:${why},但 node_modules 里找不到可物化的已装副本 —— 已跳过 lock 对齐(**未改动 package.json**,避免留下指向不存在 npm 版本的裸版本号)`,
265
279
  }
266
280
  }
267
- const link = linkSpecFor(dir)
268
- return {
269
- spec: link,
270
- note: `${row.name}:${why},已按 link: 形式记录依赖(${link})—— 不经 npm registry 解析、不经 tarball 完整性校验,pnpm 重建 lock 也能装上`,
271
- }
281
+ return { spec: linkSpecFor(dir), reason: why, note: null }
272
282
  }
273
283
  /** 决定单个漂移包该以什么 spec 写回。spec=null 表示跳过(不碰 manifest)。 */
274
284
  const planWriteback = async (row) => {
@@ -281,19 +291,25 @@ async function reconcileLockfile({ profileDir, packageName = null, packages = nu
281
291
  // link: 规格:链接可能已被 release/curl 通道的"先删再铺"打断 → 先把新副本刷进 plugin-src 再重放
282
292
  if (spec.trim().startsWith('link:')) {
283
293
  const dir = materializePackageForLink(profileDir, name, home === null ? {} : { home })
284
- return { spec: dir === null ? spec.trim() : linkSpecFor(dir), note: null }
294
+ return { spec: dir === null ? spec.trim() : linkSpecFor(dir), reason: null, note: null }
285
295
  }
286
- return { spec: spec.trim(), note: null } // git+ / file: / 别名 原样重放(幂等,来源不变)
296
+ return { spec: spec.trim(), reason: null, note: null } // git+ / file: / 别名 原样重放(幂等,来源不变)
297
+ }
298
+ // ①′ 清单写裸版本号、lock 却把该包钉在**来源**上(URL/link/git):pnpm 静默改写的遗留态。
299
+ // 按 lock 的来源重放(来源绝不降级成版本号),而不是"registry 能解析就换回版本号"——
300
+ // 后者等于把用户/前一步刻意钉住的来源丢掉,且 lock 重建时会指向 registry 上并不存在的版本。
301
+ if (misrecorded(row) && typeof row.lock === 'string' && row.lock.trim().startsWith('link:')) {
302
+ return linkPlan(row, `pnpm-lock.yaml 里把该包钉在 link:(${row.lock.trim()})而清单里是版本号 ${spec.trim()} —— 两处对不上,按来源重放`)
287
303
  }
288
304
  // ② dist-tag(latest/next…):registry 来源,但**保留标签**(钉成版本号会悄悄失去升级语义)
289
305
  if (typeof spec === 'string' && DIST_TAG_RE.test(spec.trim())) {
290
306
  const probeTag = await probeRegistryPackage(name, registries, { fetchJson })
291
- if (probeTag.resolvable) return { spec: `${name}@${spec.trim()}`, note: null }
307
+ if (probeTag.resolvable) return { spec: `${name}@${spec.trim()}`, reason: null, note: null }
292
308
  return linkPlan(row, 'npm registry 查无此包(该包只存在于 GitHub release)')
293
309
  }
294
310
  // ③ 版本号或没有条目 → 先探 registry:**这个版本**可解析才允许写版本号
295
311
  const probe = await probeRegistryPackage(name, registries, { version: installed, fetchJson })
296
- if (probe.resolvable && probe.hasVersion) return { spec: `${name}@${installed}`, note: null }
312
+ if (probe.resolvable && probe.hasVersion) return { spec: `${name}@${installed}`, reason: null, note: null }
297
313
  // ④ registry 查无此包(或查无此版本)→ 只存在于 GitHub release → 物化 + link:
298
314
  return linkPlan(row, probe.resolvable ? `registry 上没有 ${installed} 这个版本` : 'npm registry 查无此包(该包只存在于 GitHub release)')
299
315
  }
@@ -302,13 +318,14 @@ async function reconcileLockfile({ profileDir, packageName = null, packages = nu
302
318
  let drifted = driftedOf(rows)
303
319
  const errors = []
304
320
  let method = null
321
+ const planned = []
305
322
  if (drifted.length > 0) {
306
323
  // 只对"能读到实际版本"的包做对齐;读不到的(目录都没有)无法对账,留给 lockNote
307
324
  const specs = []
308
325
  for (const row of drifted) {
309
326
  if (row.installed === null) continue
310
327
  const plan = await planWriteback(row)
311
- if (plan.note !== null) depNotes.push(plan.note)
328
+ planned.push({ name: row.name, installed: row.installed, spec: plan.spec, reason: plan.reason ?? null, note: plan.note ?? null })
312
329
  if (plan.spec !== null) specs.push(plan.spec)
313
330
  }
314
331
  if (specs.length > 0) {
@@ -322,6 +339,25 @@ async function reconcileLockfile({ profileDir, packageName = null, packages = nu
322
339
  rows = snap()
323
340
  drifted = driftedOf(rows)
324
341
  }
342
+ // ★ 文案一律按**实际写入形态**生成(读回清单再描述)——"计划写 link:"与"清单里真是 link:"
343
+ // 是两件事:pnpm add 失败、或后续某一步又把 spec 覆盖成版本号时,绝不能报"已钉住"。
344
+ const suggestedActions = []
345
+ for (const item of planned) {
346
+ const actual = profileSpec(profileDir, item.name)
347
+ if (item.spec === null) {
348
+ if (item.note !== null) depNotes.push(item.note)
349
+ continue
350
+ }
351
+ if (actual === item.spec) {
352
+ if (item.spec.startsWith('link:') && item.reason !== null) {
353
+ depNotes.push(`${item.name}:${item.reason},已按 link: 形式记录依赖(${actual})—— 不经 npm registry 解析、不经 tarball 完整性校验,pnpm 重建 lock 也能装上`)
354
+ }
355
+ continue
356
+ }
357
+ const why = `${item.name}:${item.reason ?? '该包在 npm registry 上解析不到'};计划写入 ${item.spec},但清单里实际是 ${actual ?? '(没有这个条目)'} —— 写回**未生效**${errors.length > 0 ? `(${errors.join(';')})` : ''}`
358
+ depNotes.push(`${why}。请执行「钉住」动作或手动执行:${command(item.name, item.installed)}`)
359
+ suggestedActions.push(suggestedPinAction({ packageName: item.name, version: item.installed, profileDir, reason: why }))
360
+ }
325
361
  const lockUpdated = drifted.length === 0
326
362
  return {
327
363
  method,
@@ -333,8 +369,11 @@ async function reconcileLockfile({ profileDir, packageName = null, packages = nu
333
369
  lockNote: lockUpdated
334
370
  ? null
335
371
  : `${drifted.length} 个包没写进 pnpm-lock.yaml(${drifted.map((r) => `${r.name}:装了 ${r.installed ?? '未知'}/lock 里是 ${r.lock ?? '未记录'}`).join(';')}):之后任何 pnpm 操作(开关插件、dsh plugin add/remove)都可能把它们还原。要钉住请执行:${command(drifted[0].name, drifted[0].installed)}`,
336
- // 非常规来源(link:)写回时的**用户可见**说明:绝不静默(缺陷②的隐蔽性正是"装完看不出问题")
372
+ // 非常规来源(link:)写回时的**用户可见**说明:绝不静默(缺陷②的隐蔽性正是"装完看不出问题")。
373
+ // 文案由上面的"读回真实值"步骤产出,因此永远不会出现"面板说已钉住、磁盘上仍是版本号"。
337
374
  depNote: depNotes.length > 0 ? depNotes.join(';') : null,
375
+ // 结构化建议动作(2026-09-27 加法):面板可一键「执行」(服务端只认白名单动作,argv 自己拼)
376
+ suggestedActions,
338
377
  command: drifted.length > 0 ? command(drifted[0].name, drifted[0].installed) : command(rows[0]?.name ?? '', rows[0]?.installed ?? null),
339
378
  errors,
340
379
  }
@@ -233,6 +233,17 @@ function orderedRegistries(sources) {
233
233
  return [...list.filter((r) => r.primary), ...list.filter((r) => !r.primary)].map((r) => r.url)
234
234
  }
235
235
 
236
+ /** 主源优先的 registry 列表(2026-09-27 加法):所有"要探测/解析 registry"的通道共用一个口径
237
+ * (用户可在「源管理」里改配置;读不到配置时退回镜像源,绝不返回空数组——空数组会让探测静默退化)。 */
238
+ function preferredRegistries() {
239
+ try {
240
+ const list = orderedRegistries(readSources())
241
+ return Array.isArray(list) && list.length > 0 ? list : ['https://registry.npmmirror.com']
242
+ } catch {
243
+ return ['https://registry.npmmirror.com']
244
+ }
245
+ }
246
+
236
247
  function createGiteeOAuthState() {
237
248
  const state = globalThis.crypto?.randomUUID?.() ?? `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`
238
249
  const now = Date.now()
@@ -346,4 +357,4 @@ const GITEE_AUTH_URL = 'https://gitee.com/oauth/authorize'
346
357
 
347
358
  const GITEE_TOKEN_URL = 'https://gitee.com/oauth/token'
348
359
  const DEFAULT_SEARCH = 'dsh-plugin'
349
- export { readSources, writeSources, maskSources, readSourceSecrets, writeSourceSecrets, isAllowedSourceUrl, isAllowedGitSourceUrl, readGiteeConfig, giteeStatusView, orderedRegistries, createGiteeOAuthState, consumeGiteeOAuthState, DEFAULT_SOURCES, GITEE_OAUTH_STATES, gitCloneUrls, gitCloneCandidates, GITEE_AUTH_URL, GITEE_TOKEN_URL, DEFAULT_SEARCH }
360
+ export { readSources, writeSources, maskSources, readSourceSecrets, writeSourceSecrets, isAllowedSourceUrl, isAllowedGitSourceUrl, readGiteeConfig, giteeStatusView, orderedRegistries, preferredRegistries, createGiteeOAuthState, consumeGiteeOAuthState, DEFAULT_SOURCES, GITEE_OAUTH_STATES, gitCloneUrls, gitCloneCandidates, GITEE_AUTH_URL, GITEE_TOKEN_URL, DEFAULT_SEARCH }
@@ -0,0 +1,32 @@
1
+ // L2 · routes —— 结构化建议动作(POST /plugin-console/run-suggested,2026-09-27 加法)
2
+ //
3
+ // 为什么要有它:安装结果卡片会下发 `job.suggestedAction = { kind, label, command, payload }`
4
+ // (见 domain/install-job.js 与 domain/dep-source.js#suggestedPinAction),用户要求能**直接点执行**,
5
+ // 而不是自己抄一条命令去终端跑。
6
+ //
7
+ // 安全语义(写死,配套测试 tests/test-plugin-actions.mjs 与 tests/test-route-inventory.mjs):
8
+ // · 请求体只认 `{ action, packageName, version, profile }`;`command`/`argv`/`shell`… 任一出现即 400,
9
+ // 命令行**全部**由 domain/plugin-actions.js 的白名单动作自己拼;
10
+ // · `profile` 只作回显 —— profileDir 一律取当前实例(profileDirOf)的目录,**绝不**按请求体挑目录;
11
+ // · 动作只对"本 profile 自己清单里的包"生效(服务端读盘判定,不信客户端)。
12
+ import { runSuggestedAction } from '../domain/plugin-actions.js'
13
+ import { preferredRegistries } from '../domain/sources.js'
14
+ import { sendError, sendJson } from '../infra/httpd.js'
15
+ import { profileDirOf } from '../infra/paths.js'
16
+
17
+ async function routeRunSuggested(req, res, rc) {
18
+ const profileDir = profileDirOf(rc.ctx)
19
+ if (typeof profileDir !== 'string' || profileDir === '') {
20
+ sendError(res, 400, '无法定位 profile 目录(读不到 cordis.yml 的 include 条目)')
21
+ return
22
+ }
23
+ const result = await runSuggestedAction({ body: rc.body, profileDir, registries: preferredRegistries() })
24
+ if (result.ok === false && result.status === 400) {
25
+ sendError(res, 400, result.error)
26
+ return
27
+ }
28
+ const { status, error, ...view } = result
29
+ sendJson(res, 200, { ok: result.ok === true, ...view, ...(typeof error === 'string' ? { error } : {}) })
30
+ }
31
+
32
+ export { routeRunSuggested }
@@ -15,6 +15,7 @@ import { dirname } from 'node:path'
15
15
  import { compareFingerprint, computeFingerprint, readCompatMode, readFingerprint, readRollbackRecord, stampFingerprint, writeCompatMode } from '../domain/compat-state.js'
16
16
  import { currentFrameworkVersion } from '../domain/framework.js'
17
17
  import { applyDependencyBackfill, planDependencyBackfill } from '../domain/manifest.js'
18
+ import { preferredRegistries } from '../domain/sources.js'
18
19
  import { reconcileLockfile } from '../domain/selfupdate.js'
19
20
  import { sendError, sendJson } from '../infra/httpd.js'
20
21
  import { baseDirOf, profileDirOf, resolvePackageJson } from '../infra/paths.js'
@@ -93,16 +94,26 @@ async function routeDeclareInstalled(req, res, rc) {
93
94
  const profileDir = profileDirOf(rc.ctx)
94
95
  try {
95
96
  if (mode === 'apply') {
96
- const result = await applyDependencyBackfill(profileDir)
97
+ // 2026-09-27:补声明同样按 registry 可解析性决定写回形态(非 registry 包写 link:),
98
+ // 且必须传**用户配置的** registry 列表 —— 否则探测只用默认源,自定义源/备源形同虚设。
99
+ const registries = preferredRegistries()
100
+ const result = await applyDependencyBackfill(profileDir, { registries })
97
101
  // 关键:只写清单不写 lock → 之后任何 pnpm 操作都会因清单/lock 不一致失败(2026-09-24 CI 实测)
98
102
  let lockNote = null
99
- try { await reconcileLockfile({ profileDir }) } catch (error) { lockNote = `lock 对账失败:${error instanceof Error ? error.message : String(error)}` }
103
+ let suggestedAction = Array.isArray(result.suggestedActions) && result.suggestedActions.length > 0 ? result.suggestedActions[0] : null
104
+ try {
105
+ const lock = await reconcileLockfile({ profileDir, registries })
106
+ if (lock.lockNote !== null) lockNote = lock.lockNote
107
+ if (Array.isArray(lock.suggestedActions) && lock.suggestedActions.length > 0) suggestedAction = lock.suggestedActions[0]
108
+ } catch (error) { lockNote = `lock 对账失败:${error instanceof Error ? error.message : String(error)}` }
100
109
  sendJson(res, 200, {
101
110
  ok: true,
102
111
  mode,
103
112
  declared: result.declared,
104
113
  skipped: result.skipped,
105
114
  lockNote,
115
+ depNote: result.depNote ?? null,
116
+ suggestedAction,
106
117
  note: result.declared.length === 0
107
118
  ? '没有需要补声明的插件'
108
119
  : `已补声明 ${result.declared.length} 项 —— 它们现在会出现在官方/第三方插件页的「已安装」里,也不会再被 pnpm 还原`,
@@ -13,6 +13,7 @@ import { DEFAULT_SEARCH } from '../domain/sources.js'
13
13
  import { runSuiteInstallJob } from '../domain/suite.js'
14
14
  import { readBody, sendError } from '../infra/httpd.js'
15
15
  import { routeAiConsent, routeAiEmpowerCancel, routeAiEmpowerList, routeAiEmpowerPlan, routeAiEmpowerRun, routeAiEmpowerStatus } from './ai.js'
16
+ import { routeRunSuggested } from './actions.js'
16
17
  import { routeComponentAutostart, routeComponentStart, routeComponentStatus, routeComponentStop, routeComponents, routeRepoClone, routeRepoLandConfig, routeRepoList, routeRepoOpen, routeRepoRemove } from './components.js'
17
18
  import { routeCompatMode, routeCompatStamp, routeCompatStatusGet, routeDeclareInstalled } from './compat.js'
18
19
  import { routeFrameworkUpgrade } from './framework-upgrade.js'
@@ -132,6 +133,8 @@ const ROUTES = [
132
133
  // 依赖锁体检与重建(2026-09-27 加法):check 纯只读;repair 必须由用户显式调用
133
134
  { methods: ['POST'], path: `${ROUTE_PREFIX}/lockfile-check`, handler: routeLockfileCheck },
134
135
  { methods: ['POST'], path: `${ROUTE_PREFIX}/lockfile-repair`, handler: routeLockfileRepair },
136
+ // 结构化建议动作(2026-09-27 加法):只认白名单动作类型,argv 由服务端自拼(绝不接受命令字符串)
137
+ { methods: ['POST'], path: `${ROUTE_PREFIX}/run-suggested`, handler: routeRunSuggested },
135
138
 
136
139
  ]
137
140
 
@@ -114,7 +114,14 @@ async function routeInstallStatus(req, res, rc) {
114
114
  sendError(res, 404, '没有这个安装任务')
115
115
  return
116
116
  }
117
- sendJson(res, 200, { ok: true, ...installJobView(job) })
117
+ sendJson(res, 200, {
118
+ ok: true,
119
+ ...installJobView(job),
120
+ // 结构化建议动作(2026-09-27 加法,向后兼容):老客户端忽略该字段;
121
+ // 新客户端在安装结果卡片里渲染「执行 / 复制」小框(POST /plugin-console/run-suggested)。
122
+ // 放这里而不是 install.js#installJobView:那个模块已贴着 600 行架构棘轮(见 test-architecture-guard.mjs)。
123
+ suggestedAction: job.suggestedAction ?? null,
124
+ })
118
125
  return
119
126
  }
120
127
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@noob-stupid/dsh-plugin-console",
3
- "version": "0.5.19",
3
+ "version": "0.5.21",
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",