@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 +116 -0
- package/lib/server/domain/dep-source.js +59 -2
- package/lib/server/domain/install-job.js +16 -13
- package/lib/server/domain/lockfile-health.js +30 -9
- package/lib/server/domain/manifest.js +146 -20
- package/lib/server/domain/plugin-actions.js +208 -0
- package/lib/server/domain/selfupdate.js +56 -17
- package/lib/server/domain/sources.js +12 -1
- package/lib/server/routes/actions.js +32 -0
- package/lib/server/routes/compat.js +13 -2
- package/lib/server/routes/index.js +3 -0
- package/lib/server/routes/install.js +8 -1
- package/package.json +1 -1
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
|
-
|
|
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
|
|
511
|
-
//
|
|
512
|
-
//
|
|
513
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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,
|
|
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] ===
|
|
101
|
-
deps[packageName] =
|
|
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
|
|
181
|
+
return { changed: true }
|
|
105
182
|
})
|
|
106
|
-
|
|
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
|
-
|
|
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
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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:'))
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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, {
|
|
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.
|
|
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",
|