@noob-stupid/dsh-plugin-console 0.5.24 → 0.5.26
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 +79 -3
- package/lib/server/domain/bundle-refs.js +41 -0
- package/lib/server/domain/git-channel.js +33 -2
- package/lib/server/domain/install-job.js +37 -35
- package/lib/server/domain/install-verify.js +18 -1
- package/lib/server/domain/install.js +10 -26
- package/lib/server/domain/market.js +18 -1
- package/lib/server/domain/plugin-actions.js +48 -2
- package/lib/server/domain/preset-install.js +360 -0
- package/lib/server/domain/preset-source.js +373 -0
- package/lib/server/domain/preset-yaml.js +165 -0
- package/lib/server/domain/repo-size.js +155 -0
- package/lib/server/domain/repoland.js +86 -22
- package/lib/server/domain/suite.js +14 -28
- package/lib/server/infra/fsx.js +32 -6
- package/package.json +1 -1
package/lib/client.js
CHANGED
|
@@ -129,6 +129,12 @@ window.__ModuleLoader__.load({
|
|
|
129
129
|
actionAllowOk: "已写入放行项",
|
|
130
130
|
actionAllowFailed: "放行失败(文件未改动)",
|
|
131
131
|
actionAllowBackup: "备份",
|
|
132
|
+
// 2026-09-27 加法(0.5.26 修 F2):预设装配**默认只补不覆盖**;出现同名冲突时多出这一个按钮。
|
|
133
|
+
// 短句进面板,长解释(含"会先整目录备份")一律进悬浮 title。
|
|
134
|
+
actionOverwritePresetLabel: "覆盖该预设",
|
|
135
|
+
actionOverwritePresetLong: "只有你点它才会覆盖:先按记录的源码出处重新取一次仓库里的预设,把原目录**整份备份**成 <预设名>.bak-<时间戳>,再逐文件覆盖,写完读回核实(字节不一致会如实报告)。默认装配只补缺失文件、绝不覆盖你在用的同名预设。",
|
|
136
|
+
actionOverwritePresetOk: "已覆盖(原目录已备份)",
|
|
137
|
+
actionOverwritePresetFailed: "覆盖失败(文件未改动)",
|
|
132
138
|
safetyMode: "运行模式",
|
|
133
139
|
safetyModeManaged: "托管:框架升级与回滚由本控制台负责(默认,行为与之前一致)",
|
|
134
140
|
safetyModeManagedOn: "已切到托管模式:框架升级与回滚仍由本控制台负责",
|
|
@@ -303,6 +309,9 @@ window.__ModuleLoader__.load({
|
|
|
303
309
|
diagIntegrityNone: "来源无摘要,未校验",
|
|
304
310
|
diagIntegrityMismatch: "摘要不匹配(已安装,未拦截)",
|
|
305
311
|
diagIntegritySkip: "摘要未校验(读取失败,不影响安装)",
|
|
312
|
+
presetDot: "。预设:新建会话时选择",
|
|
313
|
+
presetInstalledMsg: "预设已装配",
|
|
314
|
+
diagChannelNotes: "通道说明",
|
|
306
315
|
direct: "(浏览器直连 GitHub;若你的浏览器打不开 GitHub,将自动回退到服务端通道)",
|
|
307
316
|
details: "详情",
|
|
308
317
|
expandDesc: "展开",
|
|
@@ -660,6 +669,11 @@ window.__ModuleLoader__.load({
|
|
|
660
669
|
actionAllowOk: "Approval written",
|
|
661
670
|
actionAllowFailed: "Approval failed (file untouched)",
|
|
662
671
|
actionAllowBackup: "Backup",
|
|
672
|
+
// 2026-09-27 additive (0.5.26 F2): preset assembly is add-only by default; a same-name conflict shows this button.
|
|
673
|
+
actionOverwritePresetLabel: "Overwrite this preset",
|
|
674
|
+
actionOverwritePresetLong: "Nothing is overwritten until you click this: it re-fetches the preset from its recorded source repo, copies your whole current directory to <preset>.bak-<timestamp> first, then overwrites file by file and reads every file back to verify (a byte mismatch is reported, never hidden). By default assembly only adds missing files and never touches your same-name presets.",
|
|
675
|
+
actionOverwritePresetOk: "Overwritten (original backed up)",
|
|
676
|
+
actionOverwritePresetFailed: "Overwrite failed (files untouched)",
|
|
663
677
|
safetyMode: "Operating mode",
|
|
664
678
|
safetyModeManaged: "Managed: this console owns framework upgrade and rollback (default, unchanged behaviour)",
|
|
665
679
|
safetyModeManagedOn: "Switched to managed: framework upgrade and rollback stay with this console",
|
|
@@ -834,6 +848,9 @@ window.__ModuleLoader__.load({
|
|
|
834
848
|
diagIntegrityNone: "No digest from source; not verified",
|
|
835
849
|
diagIntegrityMismatch: "Digest mismatch (installed, not blocked)",
|
|
836
850
|
diagIntegritySkip: "Digest not verified (read failed; install unaffected)",
|
|
851
|
+
presetDot: ". Preset: pick it when starting a new session",
|
|
852
|
+
presetInstalledMsg: "Presets installed",
|
|
853
|
+
diagChannelNotes: "Channel notes",
|
|
837
854
|
direct: "(The browser calls GitHub directly; the server channel is used as fallback.)",
|
|
838
855
|
details: "Details",
|
|
839
856
|
expandDesc: "Expand",
|
|
@@ -2881,6 +2898,11 @@ window.__ModuleLoader__.load({
|
|
|
2881
2898
|
}
|
|
2882
2899
|
} else if (data.ai === true) {
|
|
2883
2900
|
setMessage(t("installed") + ":" + data.packageName + "。" + (data.aiNote ?? t("aiRepaired")) + diagMessage(data));
|
|
2901
|
+
} else if (typeof data.presetNote === "string" && data.presetNote !== "") {
|
|
2902
|
+
// 预设装配成功(2026-09-27 批次 D-③):落盘路径由服务端如实下发,这里补一句
|
|
2903
|
+
// 「预设:新建会话时选择」(点击安装的当下没有新会话,必须先说清楚下一步)。
|
|
2904
|
+
setMessage(t("presetInstalledMsg") + ":" + data.presetNote + t("presetDot") + diagMessage(data));
|
|
2905
|
+
setReloadHint(true);
|
|
2884
2906
|
} else {
|
|
2885
2907
|
setMessage(t("installed") + ":" + data.packageName + "(" + (data.entryId ?? "") + ")。" + (data.curlNote ?? (data.bundle ? t("bundleNote") : t("installNote"))) + (data.bundleNote ? "。" + data.bundleNote : "") + (data.lockNote ? "。⚠️ " + data.lockNote : "") + (data.depNote ? "。" + data.depNote : "") + diagMessage(data));
|
|
2886
2908
|
}
|
|
@@ -2892,9 +2914,12 @@ window.__ModuleLoader__.load({
|
|
|
2892
2914
|
}
|
|
2893
2915
|
if (data.bundle) {
|
|
2894
2916
|
setRestartHint(true);
|
|
2895
|
-
} else if (data.suggestedAction !== null && data.suggestedAction !== undefined)
|
|
2917
|
+
} else if ((data.suggestedAction !== null && data.suggestedAction !== undefined)
|
|
2918
|
+
|| (typeof data.presetNote === "string" && data.presetNote !== "")) {
|
|
2896
2919
|
// 有建议动作时不自动刷新页面(2026-09-27 加法):旧行为 2.5 秒后 reload,
|
|
2897
2920
|
// 用户根本来不及点「执行」;这里改成给一个"请刷新"的可见提示。
|
|
2921
|
+
// 预设装配同理:只是往 ~/.dsh/.agent-presets 落盘,刷新页面没有意义,
|
|
2922
|
+
// 还会把「落盘路径 + 新建会话时选择」这句提示冲掉。
|
|
2898
2923
|
setReloadHint(true);
|
|
2899
2924
|
} else {
|
|
2900
2925
|
// 安装成功:自动刷新页面,让插件立即出现在列表里(可见的成功反馈)
|
|
@@ -3012,6 +3037,18 @@ window.__ModuleLoader__.load({
|
|
|
3012
3037
|
title: typeof diagnosis.hint === "string" ? diagnosis.hint : null,
|
|
3013
3038
|
});
|
|
3014
3039
|
}
|
|
3040
|
+
// ④ 通道备注(2026-09-27 批次 D-④):服务端一直在下发"为什么没试某个通道"(体积门禁、
|
|
3041
|
+
// 稀疏降级、release/git 预算…),但面板从未展示 → 用户只看到笼统的"失败"。
|
|
3042
|
+
// 失败时逐条显示(最多 6 条),成功时不显示(成功路径已有 presetNote/curlNote 等具体文案)。
|
|
3043
|
+
const channelNotes = Array.isArray(view.channelNotes) ? view.channelNotes.filter((n) => typeof n === "string" && n !== "") : [];
|
|
3044
|
+
if (view.status === "failed" && channelNotes.length > 0) {
|
|
3045
|
+
lines.push({
|
|
3046
|
+
key: "channelNotes",
|
|
3047
|
+
text: t("diagChannelNotes") + t("diagColon") + channelNotes.slice(0, 6).join(t("diagSemi")),
|
|
3048
|
+
level: "warn",
|
|
3049
|
+
title: channelNotes.join("\n"),
|
|
3050
|
+
});
|
|
3051
|
+
}
|
|
3015
3052
|
return lines;
|
|
3016
3053
|
};
|
|
3017
3054
|
/** 诊断短句 → 消息栏后缀(空串 = 没问题):只在既有文案**末尾追加**,一个字都不替换。 */
|
|
@@ -5214,17 +5251,22 @@ setMessage(t("failed") + ":" + (data?.reason ?? "未知"));
|
|
|
5214
5251
|
// 只在**执行结果**说明"pnpm 因构建脚本未获批准而报错"时才出现(服务端下发 state.nextAction);
|
|
5215
5252
|
// 平时一个像素都不多占。
|
|
5216
5253
|
const [allow, setAllow] = react.useState({ phase: "idle", text: null, level: null, title: null });
|
|
5254
|
+
// 2026-09-27 加法(0.5.26 修 F2):第三个显式动作的状态(「覆盖该预设」)。
|
|
5255
|
+
// 只在服务端下发的 suggestedAction.kind === "overwrite-preset" 时出现(默认装配只补不覆盖)。
|
|
5256
|
+
const [overwrite, setOverwrite] = react.useState({ phase: "idle", text: null, level: null, title: null });
|
|
5217
5257
|
const action = job === null || job === undefined ? null : (job.suggestedAction ?? null);
|
|
5218
5258
|
if (action === null || typeof action.command !== "string" || state.closed === true) return null;
|
|
5219
5259
|
const running = state.phase === "running";
|
|
5220
5260
|
const label = action.kind === "pin-dependency" ? t("actionPinLabel")
|
|
5221
5261
|
: action.kind === "reconcile-lock" ? t("actionReconcileLabel")
|
|
5222
5262
|
: action.kind === "allow-builds" ? t("actionAllowLabel")
|
|
5223
|
-
:
|
|
5263
|
+
: action.kind === "overwrite-preset" ? t("actionOverwritePresetLabel")
|
|
5264
|
+
: (typeof action.label === "string" && action.label !== "" ? action.label : String(action.kind ?? ""));
|
|
5224
5265
|
const allowAction = state.nextAction !== null && state.nextAction !== undefined && typeof state.nextAction === "object"
|
|
5225
5266
|
? state.nextAction
|
|
5226
5267
|
: (action.kind === "allow-builds" ? action : null);
|
|
5227
5268
|
const allowRunning = allow.phase === "running";
|
|
5269
|
+
const overwriteRunning = overwrite.phase === "running";
|
|
5228
5270
|
const showAllow = allowAction !== null && typeof allowAction.command === "string" && allow.phase !== "done";
|
|
5229
5271
|
const run = () => {
|
|
5230
5272
|
if (running || action.payload === null || typeof action.payload !== "object") return;
|
|
@@ -5275,6 +5317,27 @@ setMessage(t("failed") + ":" + (data?.reason ?? "未知"));
|
|
|
5275
5317
|
(error) => setAllow({ phase: "error", text: (error !== null && typeof error.message === "string" && error.message !== "" ? error.message : t("actionAllowFailed")), level: "error", title: null }),
|
|
5276
5318
|
);
|
|
5277
5319
|
};
|
|
5320
|
+
// 「覆盖该预设」:同样是**结构化动作**(只发 action.payload),服务端自己重新取源码 + 备份 + 覆盖 + 读回核实;
|
|
5321
|
+
// 面板绝不拼命令、也绝不碰 .agent-presets。
|
|
5322
|
+
const runOverwrite = () => {
|
|
5323
|
+
if (overwriteRunning || action.payload === null || typeof action.payload !== "object") return;
|
|
5324
|
+
setOverwrite({ phase: "running", text: t("actionRunning"), level: null, title: null });
|
|
5325
|
+
callAction(action.payload).then(
|
|
5326
|
+
(done) => {
|
|
5327
|
+
const verdict = done?.ok === true ? t("actionOverwritePresetOk") : t("actionOverwritePresetFailed");
|
|
5328
|
+
const parts = [verdict, typeof done?.reason === "string" ? done.reason : null, Array.isArray(done?.notes) && done.notes.length > 0 ? done.notes[0] : null].filter((s) => typeof s === "string" && s !== "");
|
|
5329
|
+
setOverwrite({
|
|
5330
|
+
phase: done?.ok === true ? "done" : "error",
|
|
5331
|
+
text: parts.join(" · "),
|
|
5332
|
+
level: done?.ok === true ? null : "warn",
|
|
5333
|
+
title: [done?.file,
|
|
5334
|
+
Array.isArray(done?.overwritten) && done.overwritten.length > 0 ? t("actionOverwritePresetLabel") + ":" + done.overwritten.join("、") : null,
|
|
5335
|
+
typeof done?.backup === "string" ? t("actionAllowBackup") + ":" + done.backup : null].filter((s) => typeof s === "string" && s !== "").join("\n") || null,
|
|
5336
|
+
});
|
|
5337
|
+
},
|
|
5338
|
+
(error) => setOverwrite({ phase: "error", text: (error !== null && typeof error.message === "string" && error.message !== "" ? error.message : t("actionOverwritePresetFailed")), level: "error", title: null }),
|
|
5339
|
+
);
|
|
5340
|
+
};
|
|
5278
5341
|
return el("div", { className: styles.detail, "data-suggested-action": String(action.kind ?? "") },
|
|
5279
5342
|
el("div", { className: styles.rowTop },
|
|
5280
5343
|
el("span", { className: styles.phase }, t("actionBoxTitle")),
|
|
@@ -5295,6 +5358,12 @@ setMessage(t("failed") + ":" + (data?.reason ?? "未知"));
|
|
|
5295
5358
|
showAllow
|
|
5296
5359
|
? el("button", { type: "button", className: styles.toggle, "data-allow-builds": "true", title: t("actionAllowLong"), disabled: allowRunning, onClick: runAllow }, allowRunning ? t("actionRunning") : t("actionAllowLabel"))
|
|
5297
5360
|
: null,
|
|
5361
|
+
// ★ 0.5.26 加法(修 F2):预设装配**默认只补不覆盖**,出现同名冲突时多出这一个按钮。
|
|
5362
|
+
// 与上面同一个模式:只发结构化 payload(服务端自己重新取源码 + 备份 + 覆盖 + 读回核实),
|
|
5363
|
+
// 面板绝不拼命令、也绝不写 .agent-presets。
|
|
5364
|
+
action.kind === "overwrite-preset" && overwrite.phase !== "done"
|
|
5365
|
+
? el("button", { type: "button", className: styles.toggle, "data-overwrite-preset": String(action.payload?.presetName ?? ""), title: t("actionOverwritePresetLong"), disabled: overwriteRunning, onClick: runOverwrite }, overwriteRunning ? t("actionRunning") : t("actionOverwritePresetLabel"))
|
|
5366
|
+
: null,
|
|
5298
5367
|
el("span", {
|
|
5299
5368
|
className: styles.message,
|
|
5300
5369
|
"data-warn": state.level === "warn" ? "true" : undefined,
|
|
@@ -5307,7 +5376,14 @@ setMessage(t("failed") + ":" + (data?.reason ?? "未知"));
|
|
|
5307
5376
|
"data-warn": allow.level === "warn" ? "true" : undefined,
|
|
5308
5377
|
"data-error": allow.level === "error" ? "true" : undefined,
|
|
5309
5378
|
title: allow.title === null ? undefined : allow.title,
|
|
5310
|
-
}, allow.text))
|
|
5379
|
+
}, allow.text)),
|
|
5380
|
+
overwrite.text === null ? null : el("div", { className: styles.rowTop },
|
|
5381
|
+
el("span", {
|
|
5382
|
+
className: styles.message,
|
|
5383
|
+
"data-warn": overwrite.level === "warn" ? "true" : undefined,
|
|
5384
|
+
"data-error": overwrite.level === "error" ? "true" : undefined,
|
|
5385
|
+
title: overwrite.title === null ? undefined : overwrite.title,
|
|
5386
|
+
}, overwrite.text)));
|
|
5311
5387
|
}
|
|
5312
5388
|
|
|
5313
5389
|
function PluginSafetyTab({ t }) {
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// L1 · domain —— bundle-refs.js(读**已装聚合包**的 bundle patch 引用清单)
|
|
2
|
+
//
|
|
3
|
+
// 为什么单开一个模块(0.5.26):install.js 贴着 600 行架构硬顶(test-architecture-guard.mjs),
|
|
4
|
+
// 而"从 node_modules/<pkg>/cordis.patch.yml 里读出 insert 行引用了哪些包名"是一块自洽的只读逻辑,
|
|
5
|
+
// 装前校验(install-job 的注册前防线)与装后完整性保障都要用。抽出**只搬移未改逻辑**。
|
|
6
|
+
//
|
|
7
|
+
// 背景事故(2026-09-06):装 dsh-desktop 类插件时其 bundle patch 引用了 `@deepseek-ai/dsh-root`
|
|
8
|
+
// 等框架级行,那些包不在 profile node_modules → 注册后整服务启动崩溃。
|
|
9
|
+
// 注册前先按本模块读出的引用逐个 `resolvePackageJson` 核实,缺失即拒绝注册。
|
|
10
|
+
|
|
11
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
12
|
+
import { join } from 'node:path'
|
|
13
|
+
|
|
14
|
+
/** 读取聚合包 `cordis.patch.yml` 的 insert 行 name 列表(注册前校验用)。
|
|
15
|
+
* 解析形状与 install.js#ensureBundlePatchIntegrity 同构:`- insert:` 块下的
|
|
16
|
+
* `- id: xxx` + 下一行 ` name: '包名'`。读不到(没装 / 没声明 dsh.bundle.patch / 文件缺失)
|
|
17
|
+
* 一律返回 `[]` —— 调用方据此判定"没有可核实的引用",而不是把它当成错误。 */
|
|
18
|
+
function readBundlePatchRefNames(profileDir, pkgName) {
|
|
19
|
+
try {
|
|
20
|
+
const pkgJsonPath = join(profileDir, 'node_modules', pkgName, 'package.json')
|
|
21
|
+
if (!existsSync(pkgJsonPath)) return []
|
|
22
|
+
const pkg = JSON.parse(readFileSync(pkgJsonPath, 'utf8'))
|
|
23
|
+
const patchRel = pkg.dsh?.bundle?.patch
|
|
24
|
+
if (typeof patchRel !== 'string') return []
|
|
25
|
+
const text = readFileSync(join(profileDir, 'node_modules', pkgName, patchRel), 'utf8')
|
|
26
|
+
const names = []
|
|
27
|
+
const lines = text.split(/\r?\n/u)
|
|
28
|
+
let inInsert = false
|
|
29
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
30
|
+
const line = lines[i]
|
|
31
|
+
if (/^- insert:\s*$/u.test(line)) { inInsert = true; continue }
|
|
32
|
+
if (/^- /u.test(line) && !/^ {4}- /u.test(line)) inInsert = false
|
|
33
|
+
if (!inInsert) continue
|
|
34
|
+
const nameM = (lines[i + 1] ?? '').match(/^ {6}name: ['"]([^'"]+)['"]\s*$/u)
|
|
35
|
+
if (nameM) names.push(nameM[1])
|
|
36
|
+
}
|
|
37
|
+
return names
|
|
38
|
+
} catch { return [] }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export { readBundlePatchRefNames }
|
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
// 而"git 通道怎么用预算"本来就是一块自洽的策略,独立成模块后 install-job 只留一行调用。
|
|
4
4
|
// 抽出只搬移 + 收窄形参,**语义一字未改**(原实现见 commit 8deb112 之前的 tryCandidateChannels 尾段)。
|
|
5
5
|
|
|
6
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
7
|
+
import { join } from 'node:path'
|
|
6
8
|
import { gitCloneUrls } from './sources.js'
|
|
7
9
|
|
|
8
10
|
/** git 通道的**独立预算**(2026-09-27 加法):默认 120 秒,可用环境变量
|
|
@@ -38,6 +40,19 @@ function formatBudgetMs(ms) {
|
|
|
38
40
|
return n >= 10000 ? `${Math.round(n / 1000)} 秒` : `${Math.round(n)} 毫秒`
|
|
39
41
|
}
|
|
40
42
|
|
|
43
|
+
/** 读回 node_modules 里那个包**真实的** package.json.name(读不到返回 null)。
|
|
44
|
+
* 用途见 tryGitChannel 里的核实:git 规格装出来的是**仓库根包**,名字未必等于候选包名。 */
|
|
45
|
+
function installedPackageName(profileDir, name) {
|
|
46
|
+
try {
|
|
47
|
+
const p = join(profileDir, 'node_modules', name, 'package.json')
|
|
48
|
+
if (!existsSync(p)) return null
|
|
49
|
+
const pkg = JSON.parse(readFileSync(p, 'utf8'))
|
|
50
|
+
return typeof pkg?.name === 'string' && pkg.name !== '' ? pkg.name : null
|
|
51
|
+
} catch {
|
|
52
|
+
return null
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
41
56
|
/** git 通道(守卫③):只对**请求里那个仓库**试 git 规格,且只在"同一作业第一次"试。
|
|
42
57
|
* 返回 { installedName, lastError }(与 tryCandidateChannels 的返回同形,便于原地替换)。
|
|
43
58
|
* 预算规则(2026-09-27):
|
|
@@ -45,7 +60,7 @@ function formatBudgetMs(ms) {
|
|
|
45
60
|
* · 单个规格的 pnpm 超时 = min(60 秒, 剩余);
|
|
46
61
|
* · 预算耗尽就停手,并在 job 上留一条面板可见的原因(不静默跳过)。
|
|
47
62
|
* 依赖注入:ch(通道实现)与 budget 由调用方传入 —— 与 install-job 的注入缝同一套语义。 */
|
|
48
|
-
async function tryGitChannel({ job, ch, name, profileDir, repoChannelAllowed, expanded = false, budget = {}, deadline = null }) {
|
|
63
|
+
async function tryGitChannel({ job, ch, name, profileDir, repoChannelAllowed, expanded = false, budget = {}, deadline = null, verifyName = null }) {
|
|
49
64
|
let installedName = null
|
|
50
65
|
let lastError = null
|
|
51
66
|
// gitChannelBlocked:作业层面已判定"这个仓库不该走 git"(根包未发布 / 首选候选来自市场索引)。
|
|
@@ -76,6 +91,22 @@ async function tryGitChannel({ job, ch, name, profileDir, repoChannelAllowed, ex
|
|
|
76
91
|
try {
|
|
77
92
|
// eslint-disable-next-line no-await-in-loop
|
|
78
93
|
await ch.pnpmInstall(profileDir, spec, undefined, Math.min(GIT_SPEC_TIMEOUT_MS, remain))
|
|
94
|
+
// 装完**读回真实包名核实**(2026-09-27 批次 D-① 一并改掉的缺陷):git 规格装的是**仓库根包**,
|
|
95
|
+
// 候选是子包时名字根本不同(真机 dsh-routing-suite:根包 private、名字
|
|
96
|
+
// @dsh-external/dsh-super-injector,而候选是 dsh-router-standard)。旧代码无条件把 installedName
|
|
97
|
+
// 记成候选名 → 后面 appendInsert 写一条指向不存在模块的补丁行 → 下次启动服务直接崩。
|
|
98
|
+
// 读不到(null)时按"无法核实"放行(不误杀;桩实现/别名安装都可能读不到),但要留下备注。
|
|
99
|
+
const realName = typeof verifyName === 'function' ? verifyName(profileDir, name) : installedPackageName(profileDir, name)
|
|
100
|
+
if (realName === null) {
|
|
101
|
+
noteChannel(job, `git 规格装完后读不到 node_modules/${name}/package.json,无法核实包名(按成功处理,装后校验会再查)`)
|
|
102
|
+
installedName = name
|
|
103
|
+
break
|
|
104
|
+
}
|
|
105
|
+
if (realName !== name) {
|
|
106
|
+
lastError = new Error(`git 规格装成的是仓库根包 ${realName},不是候选 ${name}(git 只能装根包;子包请走 registry/release/源码装配通道)`)
|
|
107
|
+
noteChannel(job, lastError.message)
|
|
108
|
+
continue
|
|
109
|
+
}
|
|
79
110
|
installedName = name
|
|
80
111
|
break
|
|
81
112
|
} catch (gitError) {
|
|
@@ -88,4 +119,4 @@ async function tryGitChannel({ job, ch, name, profileDir, repoChannelAllowed, ex
|
|
|
88
119
|
return { installedName, lastError }
|
|
89
120
|
}
|
|
90
121
|
|
|
91
|
-
export { GIT_CHANNEL_BUDGET_MS, GIT_SPEC_TIMEOUT_MS, GIT_MIN_ATTEMPT_MS, gitChannelBudgetMs, noteChannel, formatBudgetMs, tryGitChannel }
|
|
122
|
+
export { GIT_CHANNEL_BUDGET_MS, GIT_SPEC_TIMEOUT_MS, GIT_MIN_ATTEMPT_MS, gitChannelBudgetMs, noteChannel, formatBudgetMs, installedPackageName, tryGitChannel }
|
|
@@ -10,11 +10,14 @@ import { maybeAutoAdaptCompat } from './compat.js'
|
|
|
10
10
|
import { getReposDir } from './repoland.js'
|
|
11
11
|
import { aiConsentFailureText, cleanupAttemptedCandidates } from './install-cleanup.js'
|
|
12
12
|
import { GIT_CHANNEL_BUDGET_MS, GIT_MIN_ATTEMPT_MS, GIT_SPEC_TIMEOUT_MS, gitChannelBudgetMs, noteChannel, formatBudgetMs, tryGitChannel } from './git-channel.js'
|
|
13
|
-
import { DEFAULT_BUNDLES, AI_CONSENT_TIMEOUT_MS, PNPM_INSTALL_TIMEOUT_MS, addBundleToManifest, backfillMissingDeps, curlManualInstall, detectBundleOnly, ensureBundlePatchIntegrity, githubReleaseInstall, pnpmInstall, raceInstallChannels,
|
|
13
|
+
import { DEFAULT_BUNDLES, AI_CONSENT_TIMEOUT_MS, PNPM_INSTALL_TIMEOUT_MS, addBundleToManifest, backfillMissingDeps, curlManualInstall, detectBundleOnly, ensureBundlePatchIntegrity, githubReleaseInstall, pnpmInstall, raceInstallChannels, readGithubAuth, syncAggregateSubpackageVersions } from './install.js'
|
|
14
|
+
import { readBundlePatchRefNames } from './bundle-refs.js' // 0.5.26 搬家:install.js 贴着 600 行硬顶
|
|
14
15
|
import { classifyInstallFailure, retryPnpmOnce } from './install-diagnose.js'
|
|
15
16
|
import { jobLog } from './jobs.js'
|
|
16
|
-
import {
|
|
17
|
-
import {
|
|
17
|
+
import { gatePrivateRoot } from './repo-size.js'
|
|
18
|
+
import { tryPresetSourceChannel } from './preset-source.js'
|
|
19
|
+
import { alreadyInstalledTarget, verifyInstalledEntry } from './install-verify.js'
|
|
20
|
+
import { applyNpmNameHint, expandSubpackageCandidates, fetchRepoPackage, fetchRepoPackageEx, fetchSubpackageNames, npmNameHintForRepo, packageProbeErrorText, subpackageCandidates } from './market.js'
|
|
18
21
|
import { declareProfileDependency } from './manifest.js'
|
|
19
22
|
import { appendInsert, readPatchState, syncNameFromNote } from './patch.js'
|
|
20
23
|
import { deriveEntryId, listEntries } from './runtime.js'
|
|
@@ -67,19 +70,15 @@ function channelImpls(ports) {
|
|
|
67
70
|
* ③ git 通道:**保留 repoChannelAllowed**(它只 clone `job.repo`,候选是子包时 clone 根仓库装不出子包,
|
|
68
71
|
* 属无意义尝试)**并保留 !expanded**(同一作业里对同一个 job.repo 反复 clone 纯属浪费时间,
|
|
69
72
|
* 级联顺序也不该被破坏)。 */
|
|
70
|
-
async function tryCandidateChannels({ job, ch, name, profileDir, registries, repoChannelAllowed, budget, baseUrl = null, expanded = false, deadline = null, expand = null }) {
|
|
73
|
+
async function tryCandidateChannels({ job, ch, name, profileDir, registries, repoChannelAllowed, budget, baseUrl = null, expanded = false, deadline = null, expand = null, sourceDeps = {} }) {
|
|
71
74
|
let installedName = null
|
|
72
75
|
let lastError = null
|
|
73
76
|
// 加法优化:包已在 node_modules 且名字匹配时,不再重复安装/触发 EPERM,直接进入启用流程
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
job.curlNote = `已检测到本地已安装 ${name}@${existingPkg.version ?? '?'},跳过重复下载`
|
|
80
|
-
return { installedName: name, lastError: null }
|
|
81
|
-
}
|
|
82
|
-
} catch {}
|
|
77
|
+
// (2026-09-27 搬进 install-verify.js#alreadyInstalledTarget:本文件贴着 600 行架构硬顶)
|
|
78
|
+
const already = alreadyInstalledTarget(profileDir, name, job)
|
|
79
|
+
if (already !== null) {
|
|
80
|
+
job.curlNote = already
|
|
81
|
+
return { installedName: name, lastError: null }
|
|
83
82
|
}
|
|
84
83
|
// 通道 0:并行竞速(pnpm 与 curl 同时启动,先成功者生效;失败方 abort,不影响后续串行通道)——守卫①
|
|
85
84
|
{
|
|
@@ -165,6 +164,12 @@ async function tryCandidateChannels({ job, ch, name, profileDir, registries, rep
|
|
|
165
164
|
noteChannel(job, `自动展开仓库子包失败(继续走后续通道):${error instanceof Error ? error.message : String(error)}`)
|
|
166
165
|
}
|
|
167
166
|
}
|
|
167
|
+
if (installedName === null) {
|
|
168
|
+
// 通道 n+1c(批次 D-③,2026-09-27 加法):**预设型子包的源码装配** —— 真机 dsh-routing-suite 的第三件
|
|
169
|
+
// dsh-router-standard 只存在于仓库源码里(npm 双 404、release 无资产),按包名的通道全试完才轮到它。
|
|
170
|
+
const preset = await tryPresetSourceChannel({ job, name, source: job.source, deps: sourceDeps })
|
|
171
|
+
if (preset.installed === true) { job.presetDone = true; job.presetNote = preset.note; installedName = name } else if (preset.error !== null && preset.error !== undefined) lastError = preset.error
|
|
172
|
+
}
|
|
168
173
|
if (installedName === null) {
|
|
169
174
|
// 通道 n+2:git 通道(GitHub 走加速代理+直连;Gitee 走对应平台)——守卫③
|
|
170
175
|
// 2026-09-27:实现搬进 domain/git-channel.js(独立预算 + 不得超过作业剩余预算;
|
|
@@ -213,6 +218,8 @@ async function runInstallJob(job, ports, deps = {}) {
|
|
|
213
218
|
probes.expandSubpackages = (opts = {}) => expandSubpackageCandidates({ ...opts, deps: { fetchSubpackageNames: probes.fetchSubpackageNames } })
|
|
214
219
|
}
|
|
215
220
|
const jobBudgetMs = Number.isFinite(deps.jobBudgetMs) && deps.jobBudgetMs > 0 ? deps.jobBudgetMs : 8 * 60 * 1000
|
|
221
|
+
// 源码通道(体积门禁 + 稀疏取源码 + 预设装配)的注入缝:与 marketProbes 同一风格,生产调用方不传
|
|
222
|
+
const sourceDeps = deps.sourceDeps ?? {}
|
|
216
223
|
const aiConsentTimeoutMs = Number.isFinite(deps.aiConsentTimeoutMs) && deps.aiConsentTimeoutMs > 0 ? deps.aiConsentTimeoutMs : AI_CONSENT_TIMEOUT_MS
|
|
217
224
|
/** 「安装即声明」的收尾(三个调用点共用一个出口):清单写回结果 → job 上的可见字段。
|
|
218
225
|
* depNote / suggestedAction 也在这里落地:面板必须能看见"清单里**实际**写成了什么",以及"没写成时点哪个按钮"。 */
|
|
@@ -237,16 +244,8 @@ async function runInstallJob(job, ports, deps = {}) {
|
|
|
237
244
|
// ★ 市场索引的首选候选(批次 C-⑪):索引带 npmName 时直接按包名走 registry,连"读根 package.json →
|
|
238
245
|
// 展开子包"这一轮都省掉(本机 api.github.com 不可达时那条路根本走不通)。老索引无该字段 → 行为不变。
|
|
239
246
|
if (candidates.length === 0 && job.repo) {
|
|
240
|
-
const
|
|
241
|
-
if (
|
|
242
|
-
candidates = [npmHint]
|
|
243
|
-
job.npmNameHint = npmHint
|
|
244
|
-
job.packageName = npmHint
|
|
245
|
-
// git 通道只 clone 整个仓库(真机 dsh-web 429 MB / ghproxy 0 B/s),而且装不出这个子包 → 禁掉
|
|
246
|
-
job.gitChannelBlocked = true
|
|
247
|
-
noteChannel(job, `市场索引给出首选候选:${npmHint}(按包名直装,省掉"探测根包 + 展开子包")`)
|
|
248
|
-
noteChannel(job, '已跳过 git 克隆通道:git 只 clone 整个仓库、装不出这个包(真机 dsh-web = 429 MB)')
|
|
249
|
-
}
|
|
247
|
+
const hinted = applyNpmNameHint(job, probes.npmNameHint(job.repo), noteChannel)
|
|
248
|
+
if (hinted !== null) candidates = hinted
|
|
250
249
|
}
|
|
251
250
|
if (candidates.length === 0) {
|
|
252
251
|
// 套装判定(0.5.19 改错,唯一入口在 suite.js 的 shouldRunSuiteInstall):**先看根包是否已发布**,
|
|
@@ -299,12 +298,10 @@ async function runInstallJob(job, ports, deps = {}) {
|
|
|
299
298
|
if (pkg.private === true) {
|
|
300
299
|
// 私有 monorepo 根:自动列出子包作为候选(把人工修复经验自动化),聚合包优先
|
|
301
300
|
subpackageMode = true
|
|
302
|
-
// 批次
|
|
303
|
-
//
|
|
304
|
-
//
|
|
305
|
-
job
|
|
306
|
-
job.gitChannelBlocked = true
|
|
307
|
-
noteChannel(job, '根包未发布到 npm(private: true),已跳过 git 克隆通道(避免白拉整个仓库)')
|
|
301
|
+
// 批次 D-①(2026-09-27 **改错**):根包 private = 没发布到 npm → registry 通道必然 404。
|
|
302
|
+
// 源码通道能不能走改由**仓库体积**判定(批次 A-③ 的一刀切把 dsh-routing-suite 的 preset
|
|
303
|
+
// 子包一起禁掉了,而它只有 1334 KB);note 由 gatePrivateRoot 写入(巨仓/尺寸未知都写清原因与出路)。
|
|
304
|
+
await gatePrivateRoot(job, noteChannel, sourceDeps)
|
|
308
305
|
// 2026-09-20 事故(用户点装 zhu1090093659/dsh-web,报"未发现子包"):该仓库根包确实
|
|
309
306
|
// private: true,但 main/dev 各有 22 个子包目录。失败原因是当时市场索引源全挂、网络受限,
|
|
310
307
|
// subpackageCandidates() 读不到列表 —— 旧代码只有"有没有子包"一个出口,把**没读到**
|
|
@@ -343,13 +340,14 @@ async function runInstallJob(job, ports, deps = {}) {
|
|
|
343
340
|
const rootPkg = await probes.fetchRepoPackage(job.repo, 'main')
|
|
344
341
|
if (rootPkg !== null && rootPkg.private === true) {
|
|
345
342
|
subpackageMode = true
|
|
346
|
-
|
|
347
|
-
job.privateRoot = true
|
|
348
|
-
job.gitChannelBlocked = true
|
|
349
|
-
noteChannel(job, '根包未发布到 npm(private: true),已跳过 git 克隆通道(避免白拉整个仓库)')
|
|
343
|
+
await gatePrivateRoot(job, noteChannel, sourceDeps) // 批次 D-①:按仓库体积决定源码通道
|
|
350
344
|
const subs = await probes.subpackageCandidates(job.repo, 'main', readGithubAuth().token)
|
|
351
345
|
if (subs.length > 0) {
|
|
352
|
-
|
|
346
|
+
// ★ D-⑤(真机验证发现的**第二个根因**):用户**明确点的那一件**必须排最前 —— 卡片子包列表每件的
|
|
347
|
+
// 按钮发 `{repo, packageName}`;旧代码把自动展开的子包统统排在它前面,第一件装成(或`已装在本地`
|
|
348
|
+
// 命中)就 break:**点第三件装的却是第一件**。只有"没给包名"时才该聚合包优先(那条路不经过这里)。
|
|
349
|
+
const requested = candidates.filter((n) => typeof n === 'string' && n !== '')
|
|
350
|
+
candidates = [...requested, ...subs.filter((name) => !requested.includes(name))]
|
|
353
351
|
job.subpackages = subs
|
|
354
352
|
}
|
|
355
353
|
}
|
|
@@ -404,13 +402,17 @@ async function runInstallJob(job, ports, deps = {}) {
|
|
|
404
402
|
}
|
|
405
403
|
const attempt = await tryCandidateChannels({
|
|
406
404
|
job, ch, name, profileDir, registries, repoChannelAllowed, budget,
|
|
407
|
-
baseUrl: ports.baseUrl ?? null, expanded, deadline,
|
|
405
|
+
baseUrl: ports.baseUrl ?? null, expanded, deadline, sourceDeps,
|
|
408
406
|
expand: (expanded || job.npmNameHint !== undefined) ? null : expandSubpackages,
|
|
409
407
|
})
|
|
410
408
|
installedName = attempt.installedName
|
|
411
409
|
lastError = attempt.lastError
|
|
412
410
|
if (installedName !== null) break
|
|
413
411
|
}
|
|
412
|
+
// 预设型子包走的是"取源码 → 装配到 ~/.dsh/.agent-presets":**没有 node_modules 包、也不该写补丁行/
|
|
413
|
+
// 声明依赖**(否则补丁行指向不存在的模块 → 启动崩溃,2026-09-06 事故同族)。candidateDone/packageName
|
|
414
|
+
// 与普通成功安装对齐,面板显示的才是真正装上的那一件。
|
|
415
|
+
if (job.presetDone === true) { job.status = 'done'; job.stage = 'done'; job.candidateDone = true; job.packageName = job.candidateName ?? job.packageName; return }
|
|
414
416
|
if (installedName === null) {
|
|
415
417
|
if (job.diagnosis == null) job.diagnosis = classifyInstallFailure(lastError?.message) // 分类提示兜底(加法)
|
|
416
418
|
// 本地 AI 兜底会调用模型 API、产生费用:挂起等待用户明确同意后再执行
|
|
@@ -218,4 +218,21 @@ function verifyTarballDigest(tarballPath, dist = null) {
|
|
|
218
218
|
}
|
|
219
219
|
}
|
|
220
220
|
|
|
221
|
-
|
|
221
|
+
/** 「目标包已经在 node_modules 里、且名字就是它」→ 返回面板要显示的短句;否则返回 null。
|
|
222
|
+
* 2026-09-27 从 install-job.js **原样搬出**(那边贴着 600 行架构硬顶):这套判断本来就是"装前只读校验",
|
|
223
|
+
* 与 verifyInstalledEntry 同族,只是发生在装之前。
|
|
224
|
+
* 语义一个字没改:只有 package.json 能解析、name 完全相等、且不是"更新"请求时才算命中
|
|
225
|
+
* (命中就不再重复下载,直接进入启用流程 —— 也避免再触发一次 Windows 上的 EPERM 原子替换)。 */
|
|
226
|
+
function alreadyInstalledTarget(profileDir, name, job = null) {
|
|
227
|
+
const target = join(profileDir, 'node_modules', name, 'package.json')
|
|
228
|
+
if (!existsSync(target)) return null
|
|
229
|
+
try {
|
|
230
|
+
const pkg = JSON.parse(readFileSync(target, 'utf8'))
|
|
231
|
+
if (pkg && pkg.name === name && job?.update !== true) {
|
|
232
|
+
return `已检测到本地已安装 ${name}@${pkg.version ?? '?'},跳过重复下载`
|
|
233
|
+
}
|
|
234
|
+
} catch {}
|
|
235
|
+
return null
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export { verifyInstalledEntry, verifyTarballDigest, entryFieldOf, alreadyInstalledTarget, MIRROR_HINT }
|
|
@@ -14,6 +14,8 @@ import { fetchJsonUrl } from '../infra/http.js'
|
|
|
14
14
|
import { dshHome, resolvePackageJson } from '../infra/paths.js'
|
|
15
15
|
import { downloadReleaseArtifact, releaseInstallTarget, selectReleaseInstall } from './release-source.js'
|
|
16
16
|
import { verifyTarballDigest } from './install-verify.js'
|
|
17
|
+
import { presetJobView } from './preset-install.js'
|
|
18
|
+
import { readBundlePatchRefNames } from './bundle-refs.js'
|
|
17
19
|
|
|
18
20
|
/** bundle 包判定:声明 dsh.bundle 的包一律按官方 `dsh plugin add` 行为追加为
|
|
19
21
|
* profile bundle 层(其 cordis.patch.yml 的插入行在下次启动时组合进树)。
|
|
@@ -418,30 +420,9 @@ async function githubReleaseInstall(profileDir, repo, packageName, options = {})
|
|
|
418
420
|
* (cordis.patch.yml)引用的包未安装 → 服务加载崩溃。
|
|
419
421
|
* 读已装包的 bundle patch → 检查每个 insert 引用的包是否存在 → 缺失补装(pnpm/curl)→
|
|
420
422
|
* 仍缺则在用户 patch 层自动禁用该行(防崩兜底)+ 返回报告供面板提示。
|
|
423
|
+
* 注:`readBundlePatchRefNames`(读 insert 引用清单)0.5.26 搬进 domain/bundle-refs.js ——
|
|
424
|
+
* 本文件贴着 600 行架构硬顶,而那块是自洽的只读逻辑;这里 re-export 保持接口不变。
|
|
421
425
|
*/
|
|
422
|
-
/** 读取聚合包 cordis.patch.yml 的 insert 行 name 列表(注册前校验用)。 */
|
|
423
|
-
function readBundlePatchRefNames(profileDir, pkgName) {
|
|
424
|
-
try {
|
|
425
|
-
const pkgJsonPath = join(profileDir, 'node_modules', pkgName, 'package.json')
|
|
426
|
-
if (!existsSync(pkgJsonPath)) return []
|
|
427
|
-
const pkg = JSON.parse(readFileSync(pkgJsonPath, 'utf8'))
|
|
428
|
-
const patchRel = pkg.dsh?.bundle?.patch
|
|
429
|
-
if (typeof patchRel !== 'string') return []
|
|
430
|
-
const text = readFileSync(join(profileDir, 'node_modules', pkgName, patchRel), 'utf8')
|
|
431
|
-
const names = []
|
|
432
|
-
const lines = text.split(/\r?\n/u)
|
|
433
|
-
let inInsert = false
|
|
434
|
-
for (let i = 0; i < lines.length; i += 1) {
|
|
435
|
-
const line = lines[i]
|
|
436
|
-
if (/^- insert:\s*$/u.test(line)) { inInsert = true; continue }
|
|
437
|
-
if (/^- /u.test(line) && !/^ {4}- /u.test(line)) inInsert = false
|
|
438
|
-
if (!inInsert) continue
|
|
439
|
-
const nameM = (lines[i + 1] ?? '').match(/^ {6}name: ['"]([^'"]+)['"]\s*$/u)
|
|
440
|
-
if (nameM) names.push(nameM[1])
|
|
441
|
-
}
|
|
442
|
-
return names
|
|
443
|
-
} catch { return [] }
|
|
444
|
-
}
|
|
445
426
|
|
|
446
427
|
async function ensureBundlePatchIntegrity(profileDir, pkgName, userPatchPath, transientAllow = []) {
|
|
447
428
|
const report = { checked: 0, installed: [], disabled: [], missing: [], pending: [] }
|
|
@@ -544,7 +525,11 @@ function installJobView(job) {
|
|
|
544
525
|
skillDir: job.skillDir ?? null,
|
|
545
526
|
skillNote: job.skillNote ?? null,
|
|
546
527
|
suiteReport: job.suiteReport ?? null,
|
|
547
|
-
|
|
528
|
+
// 预设装配结果(2026-09-27 批次 D-③ 加法;0.5.26 起"预设那几项"整簇搬进
|
|
529
|
+
// preset-install.js#presetJobView —— 本文件贴着 600 行架构硬顶,且"预设通道要下发什么"
|
|
530
|
+
// 本来就该跟它的产出者放在一起)。套装备注与仓库体积仍是本文件的老字段,照旧在这里下发。
|
|
531
|
+
suiteNote: job.suiteNote ?? null, repoSize: job.repoSize ?? null,
|
|
532
|
+
...presetJobView(job),
|
|
548
533
|
hint: job.hint ?? null,
|
|
549
534
|
// 子包级/套装级进度(2026-09-20 真装实测缺口:11 个子包的聚合仓库跑了 19 分钟,面板只显示
|
|
550
535
|
// "安装中",用户不知道在装第几个、还剩几个)。子包通道来自 install-job.js 候选循环维护的
|
|
@@ -564,8 +549,7 @@ function installJobView(job) {
|
|
|
564
549
|
}
|
|
565
550
|
}
|
|
566
551
|
|
|
567
|
-
/**
|
|
568
|
-
* (与 #3 的 release 源解析同族);本模块不再重复导出它(全仓库无导入点,导出面由 lib/index.js 决定)。 */
|
|
552
|
+
/** 「从 GitHub Release 下载预构建 tgz 装配」已搬到 domain/release-source.js(与 #3 的 release 源解析同族)。 */
|
|
569
553
|
|
|
570
554
|
async function syncAggregateSubpackageVersions(profileDir, packageName, registries) {
|
|
571
555
|
const pkgPath = join(profileDir, 'node_modules', packageName, 'package.json')
|
|
@@ -428,6 +428,23 @@ function npmNameHintForRepo(repo, deps = {}) {
|
|
|
428
428
|
}
|
|
429
429
|
}
|
|
430
430
|
|
|
431
|
+
/** 市场索引首选候选落地(批次 C-⑪,2026-09-27;批次 D-③ 从 install-job 搬进 market.js,
|
|
432
|
+
* 那边贴着 600 行架构硬顶)。索引带 npmName 时直接按包名走 registry,连"读根 package.json →
|
|
433
|
+
* 展开子包"这一轮都省掉(本机 api.github.com 不可达时那条路根本走不通);老索引无该字段 → null。
|
|
434
|
+
* 副作用与搬移前**一字不差**:写 job.npmNameHint / job.packageName / job.gitChannelBlocked + 两条备注。 */
|
|
435
|
+
function applyNpmNameHint(job, hint, note = null) {
|
|
436
|
+
if (typeof hint !== 'string' || hint === '') return null
|
|
437
|
+
job.npmNameHint = hint
|
|
438
|
+
job.packageName = hint
|
|
439
|
+
// git 通道只 clone 整个仓库(真机 dsh-web 429 MB / ghproxy 0 B/s),而且装不出这个子包 → 禁掉
|
|
440
|
+
job.gitChannelBlocked = true
|
|
441
|
+
if (typeof note === 'function') {
|
|
442
|
+
note(job, `市场索引给出首选候选:${hint}(按包名直装,省掉"探测根包 + 展开子包")`)
|
|
443
|
+
note(job, '已跳过 git 克隆通道:git 只 clone 整个仓库、装不出这个包(真机 dsh-web = 429 MB)')
|
|
444
|
+
}
|
|
445
|
+
return [hint]
|
|
446
|
+
}
|
|
447
|
+
|
|
431
448
|
/** 懒惰展开(批次 A-③ 的实现;2026-09-27 从 install-job.js 搬进 market.js —— 那边触到 600 行硬顶):
|
|
432
449
|
* 读仓库子包列表 → **聚合包优先**(名字以 all 结尾/-all- 形式)→ 去掉已有候选 → 限量返回。
|
|
433
450
|
* 分支由调用方给(install-job 传 meta.default_branch),fetchSubpackageNames 自己会回退 main/master。 */
|
|
@@ -472,4 +489,4 @@ async function subpackageCandidates(repo, branch, auth) {
|
|
|
472
489
|
|
|
473
490
|
const ENRICH_CACHE_TTL = 24 * 60 * 60 * 1000
|
|
474
491
|
|
|
475
|
-
export { readEnrichCache, writeEnrichCache, enrichItemOne, enrichItems, normalizePlatformItems, githubRepoInfo, fetchRepoPackage, fetchRepoPackageEx, packageProbeErrorText, searchNpmPackages, searchSubpackageItems, parseRepoFromUrl, hasDirectNameHit, summarizeReadme, readPluginDetails, fetchSubpackageNames, fetchSubpackageNamesOnBranch, subpackageBranchOrder, subpackageCandidates, expandSubpackageCandidates, npmNameHintForRepo, ENRICH_CACHE_TTL }
|
|
492
|
+
export { readEnrichCache, writeEnrichCache, enrichItemOne, enrichItems, normalizePlatformItems, githubRepoInfo, fetchRepoPackage, fetchRepoPackageEx, packageProbeErrorText, searchNpmPackages, searchSubpackageItems, parseRepoFromUrl, hasDirectNameHit, summarizeReadme, readPluginDetails, fetchSubpackageNames, fetchSubpackageNamesOnBranch, subpackageBranchOrder, subpackageCandidates, expandSubpackageCandidates, npmNameHintForRepo, applyNpmNameHint, ENRICH_CACHE_TTL }
|
|
@@ -17,6 +17,10 @@
|
|
|
17
17
|
// **补上具体包名**的构建放行项(domain/allow-builds.js:改前备份 + 写后读回核实 + 只动那几行)。
|
|
18
18
|
// 它**不跑任何命令**,更不下载/执行脚本 —— 只影响"pnpm 下次安装是否还会因未批准而报错"。
|
|
19
19
|
// 只有用户自己点「允许这些构建脚本」才会走到这里(绝不自动放行)。
|
|
20
|
+
// · overwrite-preset(0.5.26 加法,修 F2 的"出路"):预设装配**默认只补不覆盖**,
|
|
21
|
+
// 用户在面板上点「覆盖该预设」才走到这里 —— 按记录的源码出处**重新取一次源码**,
|
|
22
|
+
// 先整目录备份、再覆盖、写后读回核实(domain/preset-source.js#overwritePreset)。
|
|
23
|
+
// 它同样**不接受任何命令字符串**,也不跑 pnpm:只取源码 + 写 `.agent-presets/<name>`。
|
|
20
24
|
//
|
|
21
25
|
// 等待预算(2026-09-27 真机实测后的改错):放行之后**第一次**安装会真的执行那些脚本
|
|
22
26
|
// (cloudflared 下二进制、cpu-features/ssh2 走 node-gyp 原生编译;本机实测 18m59.6s 才结束,exitCode=0)。
|
|
@@ -38,11 +42,12 @@ import { classifyInstallFailure, ignoredBuildsFrom } from './install-diagnose.js
|
|
|
38
42
|
import { runLockfileRepair } from './lockfile-health.js'
|
|
39
43
|
import { pinProfileDependency, specOf } from './manifest.js'
|
|
40
44
|
import { lockVersion } from './selfupdate.js'
|
|
45
|
+
import { overwritePreset } from './preset-source.js'
|
|
41
46
|
import { buildPnpmEnv, pnpmAddArgs, runPnpmWithFallback, unknownPnpmOption } from '../infra/exec.js'
|
|
42
47
|
|
|
43
48
|
const DEFAULT_REGISTRY = 'https://registry.npmmirror.com'
|
|
44
49
|
/** 动作白名单(客户端只能给这些 kind;执行分支与 argv 全在服务端)。 */
|
|
45
|
-
const ACTION_KINDS = ['pin-dependency', 'reconcile-lock', 'allow-builds']
|
|
50
|
+
const ACTION_KINDS = ['pin-dependency', 'reconcile-lock', 'allow-builds', 'overwrite-preset']
|
|
46
51
|
/** 一旦出现这些键就整条拒绝:本接口不存在"传命令"的位置。 */
|
|
47
52
|
const COMMAND_KEYS = ['command', 'cmd', 'argv', 'args', 'exec', 'execute', 'shell', 'script', 'run', 'spawn', 'bin', 'shellCommand']
|
|
48
53
|
const OUTPUT_LIMIT = 4000
|
|
@@ -75,9 +80,18 @@ function parseActionRequest(body) {
|
|
|
75
80
|
if (action === 'allow-builds' && packageName !== '' && (packageName.length > 214 || !NPM_NAME_RE.test(packageName))) {
|
|
76
81
|
return { ok: false, status: 400, error: 'packageName 不是合法的 npm 包名' }
|
|
77
82
|
}
|
|
83
|
+
// overwrite-preset 用 presetName(**预设目录名**,不是 npm 包名):必须是非空、无路径分隔符的短名字。
|
|
84
|
+
// 这里只做**形状**校验;"这个预设到底存不存在 / 是不是本控制台装的"由执行体自己读盘判定(客户端说了不算)。
|
|
85
|
+
const rawPreset = typeof raw.presetName === 'string' ? raw.presetName.trim() : ''
|
|
86
|
+
if (action === 'overwrite-preset') {
|
|
87
|
+
if (rawPreset === '') return { ok: false, status: 400, error: 'overwrite-preset 需要 presetName' }
|
|
88
|
+
if (rawPreset.length > 120 || rawPreset === '.' || rawPreset === '..' || /[\\/]/u.test(rawPreset) || /[\u0000-\u001f]/u.test(rawPreset)) {
|
|
89
|
+
return { ok: false, status: 400, error: 'presetName 不是合法的预设目录名(不得含路径分隔符或控制字符)' }
|
|
90
|
+
}
|
|
91
|
+
}
|
|
78
92
|
const version = typeof raw.version === 'string' && raw.version.length <= 120 ? raw.version : null
|
|
79
93
|
const profile = typeof raw.profile === 'string' && raw.profile.length <= 64 ? raw.profile : null
|
|
80
|
-
return { ok: true, error: null, status: 200, action, packageName: packageName === '' ? null : packageName, version, profile }
|
|
94
|
+
return { ok: true, error: null, status: 200, action, packageName: packageName === '' ? null : packageName, version, profile, presetName: rawPreset === '' ? null : rawPreset }
|
|
81
95
|
}
|
|
82
96
|
|
|
83
97
|
/** 该包是否属于当前 profile(已声明在 dependencies / devDependencies / optionalDependencies 或
|
|
@@ -206,6 +220,38 @@ async function runSuggestedAction({ body, profileDir, registries = [], deps = {}
|
|
|
206
220
|
detail: applied,
|
|
207
221
|
}
|
|
208
222
|
}
|
|
223
|
+
if (parsed.action === 'overwrite-preset') {
|
|
224
|
+
// ★ 0.5.26 加法(修 F2 的"出路"那一半):预设装配**默认只补不覆盖**,覆盖必须由用户显式点这个动作。
|
|
225
|
+
// 执行体在 domain/preset-source.js#overwritePreset:按记录**重新取一次源码** → 先整目录备份 →
|
|
226
|
+
// 覆盖 → 写后读回核实。本动作**不认识任何命令字符串**(COMMAND_KEYS 已在最前面拦掉),
|
|
227
|
+
// 也不跑 pnpm —— 只做"取源码 + 写 `.agent-presets/<name>`"这一件事。
|
|
228
|
+
const overwrite = typeof deps.overwritePreset === 'function' ? deps.overwritePreset : overwritePreset
|
|
229
|
+
let applied = null
|
|
230
|
+
try {
|
|
231
|
+
applied = await overwrite({ presetName: parsed.presetName, deps: deps.overwriteDeps ?? {} })
|
|
232
|
+
} catch (error) {
|
|
233
|
+
return { ...base, ok: false, status: 200, error: `覆盖预设失败:${error instanceof Error ? error.message : String(error)}` }
|
|
234
|
+
}
|
|
235
|
+
return {
|
|
236
|
+
...base,
|
|
237
|
+
ok: applied?.ok === true,
|
|
238
|
+
status: applied?.status ?? 200,
|
|
239
|
+
exitCode: applied?.ok === true ? 0 : 1,
|
|
240
|
+
// 展示/复制的命令 = 本控制台自己的等价入口(**本动作不执行任何命令行**;服务端只取源码 + 写文件)
|
|
241
|
+
command: `dsh-plugin-console: overwrite-preset ${parsed.presetName}`,
|
|
242
|
+
presetName: parsed.presetName,
|
|
243
|
+
changed: (applied?.overwritten?.length ?? 0) + (applied?.added?.length ?? 0) > 0,
|
|
244
|
+
added: Array.isArray(applied?.added) ? applied.added : [],
|
|
245
|
+
overwritten: Array.isArray(applied?.overwritten) ? applied.overwritten : [],
|
|
246
|
+
backup: applied?.backup ?? null,
|
|
247
|
+
verified: applied?.verified ?? null,
|
|
248
|
+
file: applied?.dest ?? null,
|
|
249
|
+
notes: [applied?.note].filter((n) => typeof n === 'string' && n !== ''),
|
|
250
|
+
reason: applied?.ok === true ? applied.reason : (applied?.error ?? '覆盖失败(文件未改动)'),
|
|
251
|
+
error: applied?.ok === true ? undefined : (applied?.error ?? null),
|
|
252
|
+
detail: applied,
|
|
253
|
+
}
|
|
254
|
+
}
|
|
209
255
|
if (parsed.action === 'reconcile-lock') {
|
|
210
256
|
const view = await repair({ profileDir, registries: Array.isArray(registries) && registries.length > 0 ? registries : [registry] })
|
|
211
257
|
const done = view?.action === 'repaired' || view?.action === 'noop'
|