@noob-stupid/dsh-plugin-console 0.5.25 → 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 +52 -2
- package/lib/server/domain/bundle-refs.js +41 -0
- package/lib/server/domain/install-job.js +8 -4
- package/lib/server/domain/install.js +9 -25
- 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 +133 -99
- package/lib/server/domain/preset-yaml.js +165 -0
- package/lib/server/domain/repo-size.js +22 -3
- 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: "已切到托管模式:框架升级与回滚仍由本控制台负责",
|
|
@@ -663,6 +669,11 @@ window.__ModuleLoader__.load({
|
|
|
663
669
|
actionAllowOk: "Approval written",
|
|
664
670
|
actionAllowFailed: "Approval failed (file untouched)",
|
|
665
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)",
|
|
666
677
|
safetyMode: "Operating mode",
|
|
667
678
|
safetyModeManaged: "Managed: this console owns framework upgrade and rollback (default, unchanged behaviour)",
|
|
668
679
|
safetyModeManagedOn: "Switched to managed: framework upgrade and rollback stay with this console",
|
|
@@ -5240,17 +5251,22 @@ setMessage(t("failed") + ":" + (data?.reason ?? "未知"));
|
|
|
5240
5251
|
// 只在**执行结果**说明"pnpm 因构建脚本未获批准而报错"时才出现(服务端下发 state.nextAction);
|
|
5241
5252
|
// 平时一个像素都不多占。
|
|
5242
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 });
|
|
5243
5257
|
const action = job === null || job === undefined ? null : (job.suggestedAction ?? null);
|
|
5244
5258
|
if (action === null || typeof action.command !== "string" || state.closed === true) return null;
|
|
5245
5259
|
const running = state.phase === "running";
|
|
5246
5260
|
const label = action.kind === "pin-dependency" ? t("actionPinLabel")
|
|
5247
5261
|
: action.kind === "reconcile-lock" ? t("actionReconcileLabel")
|
|
5248
5262
|
: action.kind === "allow-builds" ? t("actionAllowLabel")
|
|
5249
|
-
:
|
|
5263
|
+
: action.kind === "overwrite-preset" ? t("actionOverwritePresetLabel")
|
|
5264
|
+
: (typeof action.label === "string" && action.label !== "" ? action.label : String(action.kind ?? ""));
|
|
5250
5265
|
const allowAction = state.nextAction !== null && state.nextAction !== undefined && typeof state.nextAction === "object"
|
|
5251
5266
|
? state.nextAction
|
|
5252
5267
|
: (action.kind === "allow-builds" ? action : null);
|
|
5253
5268
|
const allowRunning = allow.phase === "running";
|
|
5269
|
+
const overwriteRunning = overwrite.phase === "running";
|
|
5254
5270
|
const showAllow = allowAction !== null && typeof allowAction.command === "string" && allow.phase !== "done";
|
|
5255
5271
|
const run = () => {
|
|
5256
5272
|
if (running || action.payload === null || typeof action.payload !== "object") return;
|
|
@@ -5301,6 +5317,27 @@ setMessage(t("failed") + ":" + (data?.reason ?? "未知"));
|
|
|
5301
5317
|
(error) => setAllow({ phase: "error", text: (error !== null && typeof error.message === "string" && error.message !== "" ? error.message : t("actionAllowFailed")), level: "error", title: null }),
|
|
5302
5318
|
);
|
|
5303
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
|
+
};
|
|
5304
5341
|
return el("div", { className: styles.detail, "data-suggested-action": String(action.kind ?? "") },
|
|
5305
5342
|
el("div", { className: styles.rowTop },
|
|
5306
5343
|
el("span", { className: styles.phase }, t("actionBoxTitle")),
|
|
@@ -5321,6 +5358,12 @@ setMessage(t("failed") + ":" + (data?.reason ?? "未知"));
|
|
|
5321
5358
|
showAllow
|
|
5322
5359
|
? el("button", { type: "button", className: styles.toggle, "data-allow-builds": "true", title: t("actionAllowLong"), disabled: allowRunning, onClick: runAllow }, allowRunning ? t("actionRunning") : t("actionAllowLabel"))
|
|
5323
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,
|
|
5324
5367
|
el("span", {
|
|
5325
5368
|
className: styles.message,
|
|
5326
5369
|
"data-warn": state.level === "warn" ? "true" : undefined,
|
|
@@ -5333,7 +5376,14 @@ setMessage(t("failed") + ":" + (data?.reason ?? "未知"));
|
|
|
5333
5376
|
"data-warn": allow.level === "warn" ? "true" : undefined,
|
|
5334
5377
|
"data-error": allow.level === "error" ? "true" : undefined,
|
|
5335
5378
|
title: allow.title === null ? undefined : allow.title,
|
|
5336
|
-
}, 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)));
|
|
5337
5387
|
}
|
|
5338
5388
|
|
|
5339
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 }
|
|
@@ -10,7 +10,8 @@ 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
17
|
import { gatePrivateRoot } from './repo-size.js'
|
|
@@ -339,11 +340,14 @@ async function runInstallJob(job, ports, deps = {}) {
|
|
|
339
340
|
const rootPkg = await probes.fetchRepoPackage(job.repo, 'main')
|
|
340
341
|
if (rootPkg !== null && rootPkg.private === true) {
|
|
341
342
|
subpackageMode = true
|
|
342
|
-
//
|
|
343
|
-
await gatePrivateRoot(job, noteChannel, sourceDeps)
|
|
343
|
+
await gatePrivateRoot(job, noteChannel, sourceDeps) // 批次 D-①:按仓库体积决定源码通道
|
|
344
344
|
const subs = await probes.subpackageCandidates(job.repo, 'main', readGithubAuth().token)
|
|
345
345
|
if (subs.length > 0) {
|
|
346
|
-
|
|
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))]
|
|
347
351
|
job.subpackages = subs
|
|
348
352
|
}
|
|
349
353
|
}
|
|
@@ -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,8 +525,11 @@ function installJobView(job) {
|
|
|
544
525
|
skillDir: job.skillDir ?? null,
|
|
545
526
|
skillNote: job.skillNote ?? null,
|
|
546
527
|
suiteReport: job.suiteReport ?? null,
|
|
547
|
-
// 预设装配结果(2026-09-27 批次 D-③
|
|
548
|
-
|
|
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),
|
|
549
533
|
hint: job.hint ?? null,
|
|
550
534
|
// 子包级/套装级进度(2026-09-20 真装实测缺口:11 个子包的聚合仓库跑了 19 分钟,面板只显示
|
|
551
535
|
// "安装中",用户不知道在装第几个、还剩几个)。子包通道来自 install-job.js 候选循环维护的
|
|
@@ -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'
|
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
// L1 · domain —— preset-install.js(预设**装配**:只补不覆盖 / 显式覆盖 / 校验 / 备份 / 读回核实)
|
|
2
|
+
//
|
|
3
|
+
// 为什么单开一个模块(0.5.26 改错 F2 + F3 + F4,独立对抗式复核抓到):
|
|
4
|
+
// · **F2**:`assemblePreset` 旧语义是"已存在同名预设 → 整目录备份 + 逐文件**合并覆盖**"。
|
|
5
|
+
// 真机一次装配改写了 3 个在用预设、**11 个文件**(router-standard 4 个,含 `agent.cordis.yml`)。
|
|
6
|
+
// 它有备份、有逐条报告(不静默),但**与上游 `install.ps1` 的语义相反** ——
|
|
7
|
+
// 上游是「预设已存在 → 请先手动删除」= **跳过**。默认写别人的文件就是错的方向。
|
|
8
|
+
// 新语义:**默认只补缺失文件,绝不覆盖已存在的同名文件**;覆盖必须由用户**显式**点
|
|
9
|
+
// 「覆盖该预设」(白名单动作 `overwrite-preset`)才发生,且改前整目录备份 + 写后读回核实。
|
|
10
|
+
// · **F3**:装配前必须校验 `agent.cordis.yml` 可解析(见 preset-yaml.js)——
|
|
11
|
+
// 旧代码把非法 YAML 覆盖到原本可用的文件上(`overwritten=["agent.cordis.yml"]`),
|
|
12
|
+
// 下次新建会话挂载这个预设就报错。校验不通过 → **跳过该文件并如实报告,绝不写**。
|
|
13
|
+
// · **F4**:同一毫秒的两次装配会生成**同一个**备份路径(`<name>.bak-<Date.now()>`),
|
|
14
|
+
// 第二次把第一次的备份覆盖掉 → 用户以为有两个回滚点,其实只剩一个。
|
|
15
|
+
// 新语义:备份路径加唯一后缀(`-2`、`-3`… 直到不冲突),两次装配的备份**各自完整**。
|
|
16
|
+
//
|
|
17
|
+
// 与 preset-source.js 的分工:那边负责"把源码取下来"(稀疏 clone / git 定位 / 网络),
|
|
18
|
+
// 这边只负责"往 `.agent-presets` 里写"(判据 / 校验 / 备份 / 核实)—— 写盘路径全项目只有这一处。
|
|
19
|
+
//
|
|
20
|
+
// 分层:L1 domain —— 不认识 cordis ctx;时间戳与 IO 可注入(便于离线断言)。
|
|
21
|
+
|
|
22
|
+
import { createHash } from 'node:crypto'
|
|
23
|
+
import { chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs'
|
|
24
|
+
import { basename, dirname, join, relative, sep } from 'node:path'
|
|
25
|
+
import { dshHome } from '../infra/paths.js'
|
|
26
|
+
import { validateAgentConfig } from './preset-yaml.js'
|
|
27
|
+
|
|
28
|
+
/** 预设的判别文件(框架发现预设看的就是这个:`<presetsRoot>/<name>/agent.cordis.yml`)。 */
|
|
29
|
+
const PRESET_MARKER_FILES = ['agent.cordis.yml', 'agent.cordis.yaml']
|
|
30
|
+
/** 预设清单(可选,面板文案用)。 */
|
|
31
|
+
const PRESET_MANIFEST_FILE = 'preset.yml'
|
|
32
|
+
/** 记录"每个已装配预设的源码出处"的文件(`overwrite-preset` 动作据此重新取源码)。
|
|
33
|
+
* 为什么不放 `<presetsRoot>` 里:那里是**框架**读的目录,多一个非预设文件会污染预设列表。 */
|
|
34
|
+
const PRESET_SOURCE_RECORD_LIMIT = 80
|
|
35
|
+
|
|
36
|
+
function presetSourcesFile(home = undefined) {
|
|
37
|
+
return join(home ?? dshHome(), 'plugin-console', 'preset-sources.json')
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function sha256Of(text) {
|
|
41
|
+
return createHash('sha256').update(typeof text === 'string' ? text : String(text ?? ''), 'utf8').digest('hex')
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** 目录树里的文件清单(相对路径 + 字节数)——只读,best-effort,绝不抛。 */
|
|
45
|
+
function listTreeFiles(root, { maxDepth = 8 } = {}) {
|
|
46
|
+
const out = []
|
|
47
|
+
const walk = (dir, depth) => {
|
|
48
|
+
if (depth > maxDepth) return
|
|
49
|
+
let entries = []
|
|
50
|
+
try { entries = readdirSync(dir, { withFileTypes: true }) } catch { return }
|
|
51
|
+
for (const e of entries) {
|
|
52
|
+
const child = join(dir, e.name)
|
|
53
|
+
try {
|
|
54
|
+
if (e.isDirectory()) walk(child, depth + 1)
|
|
55
|
+
else if (e.isFile()) out.push({ rel: relative(root, child).split(sep).join('/'), bytes: statSync(child).size })
|
|
56
|
+
} catch {}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
walk(String(root), 0)
|
|
60
|
+
return out
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** 备份路径:`<dest>.bak-<timeValue>`,已存在就自增后缀直到不冲突(**F4**)。
|
|
64
|
+
* 旧实现直接 `${dest}.bak-${now()}` —— 同一毫秒的两次装配拿到同一个路径,
|
|
65
|
+
* 第二次 `copyTree` 把第一次的备份**覆盖**掉:用户以为有两个回滚点,其实只剩一个。 */
|
|
66
|
+
function uniqueBackupPath(dest, timeValue) {
|
|
67
|
+
const base = `${dest}.bak-${timeValue}`
|
|
68
|
+
if (!existsSync(base)) return base
|
|
69
|
+
for (let n = 2; n < 1000; n += 1) {
|
|
70
|
+
const candidate = `${base}-${n}`
|
|
71
|
+
if (!existsSync(candidate)) return candidate
|
|
72
|
+
}
|
|
73
|
+
return `${base}-${Date.now()}`
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** 读回核实(**写后必做**):每个本该落盘的文件都真的在盘上、且字节与源一致。
|
|
77
|
+
* 返回 { ok, checked, mismatched[] };读不到/不一致都算不通过(绝不"写没写成"靠假设)。 */
|
|
78
|
+
function verifyWrittenFiles(dest, srcDir, rels) {
|
|
79
|
+
const mismatched = []
|
|
80
|
+
for (const rel of rels) {
|
|
81
|
+
const target = join(dest, ...String(rel).split('/'))
|
|
82
|
+
const source = join(srcDir, ...String(rel).split('/'))
|
|
83
|
+
try {
|
|
84
|
+
if (!existsSync(target)) { mismatched.push(`${rel}(写后不存在)`); continue }
|
|
85
|
+
if (!readFileSync(target).equals(readFileSync(source))) mismatched.push(`${rel}(字节与源不一致)`)
|
|
86
|
+
} catch (error) {
|
|
87
|
+
mismatched.push(`${rel}(读回失败:${error instanceof Error ? error.message : String(error)})`)
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return { ok: mismatched.length === 0, checked: rels.length, mismatched }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* 装配一个预设到 `<presetsRoot>/<name>`(**唯一实现**)。
|
|
95
|
+
*
|
|
96
|
+
* 两种模式(`overwrite` 必须由**用户显式动作**打开,安装路径永不传 true):
|
|
97
|
+
* · 默认(`overwrite !== true`):**只补缺失文件**。已存在且内容不同的同名文件一律**不写**,
|
|
98
|
+
* 逐个记进 `conflicts` 并在 note 里点名,提示"如需覆盖,请点「覆盖该预设」"。
|
|
99
|
+
* 这个模式**不建备份**:一个字节都不改,建备份只会让用户以为目录被改过。
|
|
100
|
+
* · `overwrite: true`:先整目录备份(唯一路径)→ 再按文件合并(新增 + 覆盖同名)→ 写后读回核实。
|
|
101
|
+
*
|
|
102
|
+
* F3 校验(两种模式都做):源里的 `agent.cordis.yml/.yaml` 必须通过结构校验;
|
|
103
|
+
* 不通过 → **这个文件不写**,进 `skippedInvalid` 并如实报告(其它文件照常)。
|
|
104
|
+
*
|
|
105
|
+
* 返回 { ok, name, dest, backup, mode, added, skipped, overwritten, identical, conflicts,
|
|
106
|
+
* skippedInvalid, verified, verifiedConfig, hashes, bytes, files, note, error }。
|
|
107
|
+
*/
|
|
108
|
+
function assemblePreset(srcDir, name, { presetsRoot = null, now = Date.now, overwrite = false, verify = true } = {}) {
|
|
109
|
+
const root = presetsRoot ?? join(dshHome(), '.agent-presets')
|
|
110
|
+
const presetName = String(name ?? basename(srcDir)).trim()
|
|
111
|
+
if (presetName === '' || presetName === '.' || /[\\/]/u.test(presetName)) {
|
|
112
|
+
return { ok: false, name: presetName, dest: null, error: `预设目录名不合法:${JSON.stringify(name)}` }
|
|
113
|
+
}
|
|
114
|
+
const dest = join(root, presetName)
|
|
115
|
+
const srcFiles = listTreeFiles(srcDir)
|
|
116
|
+
if (srcFiles.length === 0) return { ok: false, name: presetName, dest, error: `源目录里没有任何文件:${srcDir}` }
|
|
117
|
+
const bytes = srcFiles.reduce((sum, f) => sum + f.bytes, 0)
|
|
118
|
+
const existed = existsSync(dest)
|
|
119
|
+
|
|
120
|
+
// ── F3 校验(**写盘之前**):预设的判别文件必须结构合法,否则绝不写 ──────────────────────
|
|
121
|
+
const skippedInvalid = []
|
|
122
|
+
const invalidConfig = []
|
|
123
|
+
for (const file of srcFiles) {
|
|
124
|
+
if (!PRESET_MARKER_FILES.includes(file.rel)) continue
|
|
125
|
+
let text = null
|
|
126
|
+
try { text = readFileSync(join(srcDir, ...file.rel.split('/')), 'utf8') } catch { text = null }
|
|
127
|
+
if (text === null) { invalidConfig.push({ rel: file.rel, problems: ['读不到内容'] }); continue }
|
|
128
|
+
const verdict = validateAgentConfig(text)
|
|
129
|
+
if (verdict.ok !== true) invalidConfig.push({ rel: file.rel, problems: verdict.problems })
|
|
130
|
+
}
|
|
131
|
+
if (invalidConfig.length > 0) {
|
|
132
|
+
for (const bad of invalidConfig) skippedInvalid.push({ rel: bad.rel, problems: bad.problems })
|
|
133
|
+
}
|
|
134
|
+
/** 预设的判别文件被拦下 = 这个预设**装不成**(框架发现预设看的就是它)→ 整个预设拒绝,
|
|
135
|
+
* 不做半成品(只铺 preset.yml/脚本、没有配置)—— 那种目录会让用户以为装上了,实际挂载不了。
|
|
136
|
+
* 这是**加法**:旧代码会把非法 YAML 直接覆盖上去,比"不装"更糟。 */
|
|
137
|
+
const markerBlocked = skippedInvalid.length > 0
|
|
138
|
+
if (markerBlocked) {
|
|
139
|
+
return {
|
|
140
|
+
ok: false, name: presetName, dest, existed, mode: overwrite === true ? 'overwrite' : 'add-only',
|
|
141
|
+
error: `预设配置未通过结构校验,拒绝装配(绝不写坏现有预设):${skippedInvalid.map((s) => `${s.rel}(${s.problems[0]})`).join(';')}`,
|
|
142
|
+
added: [], skipped: [], overwritten: [], identical: [], conflicts: [], skippedInvalid,
|
|
143
|
+
verified: null, verifiedConfig: null, hashes: [], bytes, files: srcFiles.map((f) => f.rel),
|
|
144
|
+
note: `预设 ${presetName} **未装配**:${skippedInvalid.length} 个预设配置文件未通过结构校验(${skippedInvalid.map((s) => s.rel).join('、')}),`
|
|
145
|
+
+ '已跳过未写入 —— 避免把原本可用的预设写坏;现有文件一个字节都没动',
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ── 备份(**只在真要覆盖时**):改前整目录复制;备份失败就不合并(绝不在没备份的情况下覆盖)──
|
|
150
|
+
let backup = null
|
|
151
|
+
if (existed && overwrite === true) {
|
|
152
|
+
backup = uniqueBackupPath(dest, now())
|
|
153
|
+
try {
|
|
154
|
+
copyTreeStrict(dest, backup)
|
|
155
|
+
} catch (error) {
|
|
156
|
+
return {
|
|
157
|
+
ok: false, name: presetName, dest, existed, mode: 'overwrite', backup: null,
|
|
158
|
+
error: `已存在同名预设且备份失败(绝不在没备份的情况下覆盖):${error instanceof Error ? error.message : String(error)}`,
|
|
159
|
+
conflicts: srcFiles.map((f) => f.rel), added: [], skipped: [], overwritten: [], identical: [], skippedInvalid, verified: null, bytes,
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
mkdirSync(root, { recursive: true })
|
|
165
|
+
const added = []
|
|
166
|
+
const skipped = []
|
|
167
|
+
const overwritten = []
|
|
168
|
+
const identical = []
|
|
169
|
+
const written = []
|
|
170
|
+
for (const file of srcFiles) {
|
|
171
|
+
const from = join(srcDir, ...file.rel.split('/'))
|
|
172
|
+
const target = join(dest, ...file.rel.split('/'))
|
|
173
|
+
const had = existsSync(target)
|
|
174
|
+
if (had) {
|
|
175
|
+
let same = false
|
|
176
|
+
try { same = readFileSync(target).equals(readFileSync(from)) } catch { same = false }
|
|
177
|
+
if (same) { identical.push(file.rel); continue }
|
|
178
|
+
// ★ F2 的核心:默认**不覆盖**用户在用同名预设里的任何文件
|
|
179
|
+
if (overwrite !== true) { skipped.push(file.rel); continue }
|
|
180
|
+
}
|
|
181
|
+
try {
|
|
182
|
+
mkdirSync(dirname(target), { recursive: true })
|
|
183
|
+
writeFileSync(target, readFileSync(from))
|
|
184
|
+
written.push(file.rel)
|
|
185
|
+
} catch (error) {
|
|
186
|
+
return {
|
|
187
|
+
ok: false, name: presetName, dest, existed, mode: overwrite === true ? 'overwrite' : 'add-only', backup,
|
|
188
|
+
error: `合并 ${file.rel} 失败:${error instanceof Error ? error.message : String(error)}`,
|
|
189
|
+
added, skipped, overwritten, identical, conflicts: skipped, skippedInvalid, verified: null, bytes,
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
if (had) overwritten.push(file.rel)
|
|
193
|
+
else added.push(file.rel)
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// ── 写后读回核实(只在真的写了东西时)────────────────────────────────────────────────
|
|
197
|
+
const verified = verify === true && written.length > 0 ? verifyWrittenFiles(dest, srcDir, written) : null
|
|
198
|
+
const destConfigValid = PRESET_MARKER_FILES.some((f) => existsSync(join(dest, f)))
|
|
199
|
+
? PRESET_MARKER_FILES.map((f) => (existsSync(join(dest, f)) ? validateAgentConfig(readFileSync(join(dest, f), 'utf8')) : null)).find((v) => v !== null) ?? { ok: true }
|
|
200
|
+
: { ok: false, problems: ['目标目录里没有 agent.cordis.yml/.yaml'] }
|
|
201
|
+
const mode = overwrite === true ? 'overwrite' : 'add-only'
|
|
202
|
+
|
|
203
|
+
const noteParts = []
|
|
204
|
+
if (existed !== true) {
|
|
205
|
+
noteParts.push(`预设 ${presetName} 已装配到 ${dest}(新增 ${added.length} 个文件)`)
|
|
206
|
+
} else if (mode === 'overwrite') {
|
|
207
|
+
// 逐条点名被覆盖的文件(旧实现有这句,改错时不能丢:用户要知道**具体哪几个文件**被动过)
|
|
208
|
+
noteParts.push(`预设 ${presetName}:已按你的显式要求**覆盖**(原目录整份备份到 ${backup};本次覆盖 ${overwritten.length} 个、新增 ${added.length} 个、内容一致 ${identical.length} 个)`
|
|
209
|
+
+ (overwritten.length > 0 ? `(被覆盖的:${overwritten.slice(0, 6).join('、')}${overwritten.length > 6 ? ' 等' : ''})` : ''))
|
|
210
|
+
} else {
|
|
211
|
+
noteParts.push(`预设 ${presetName}:**默认只补缺失文件、不覆盖**(本次新增 ${added.length} 个)`)
|
|
212
|
+
if (skipped.length > 0) {
|
|
213
|
+
noteParts.push(`已存在同名预设,其中 ${skipped.length} 个同名文件内容与你手上的不同,**已原样保留未动**:${skipped.slice(0, 6).join('、')}`
|
|
214
|
+
+ `${skipped.length > 6 ? ' 等' : ''} —— 如需用仓库里的版本覆盖,请点「覆盖该预设」(会先把原目录整份备份)`)
|
|
215
|
+
}
|
|
216
|
+
if (identical.length > 0) noteParts.push(`另有 ${identical.length} 个文件与你手上的内容一致,未改动`)
|
|
217
|
+
}
|
|
218
|
+
if (skippedInvalid.length > 0) {
|
|
219
|
+
noteParts.push(`${skippedInvalid.length} 个预设配置文件**未通过结构校验、已跳过未写入**:`
|
|
220
|
+
+ skippedInvalid.map((s) => `${s.rel}(${s.problems[0]})`).join('、'))
|
|
221
|
+
}
|
|
222
|
+
if (skipped.length > 0) {
|
|
223
|
+
noteParts.push(`${skipped.length} 个同名文件因默认"只补不覆盖"而保留了你手上的版本`)
|
|
224
|
+
}
|
|
225
|
+
if (verified !== null && verified.ok !== true) {
|
|
226
|
+
noteParts.push(`⚠️ 写后读回核实不通过:${verified.mismatched.join('、')}`)
|
|
227
|
+
}
|
|
228
|
+
const result = {
|
|
229
|
+
ok: true,
|
|
230
|
+
name: presetName,
|
|
231
|
+
dest,
|
|
232
|
+
backup,
|
|
233
|
+
existed,
|
|
234
|
+
mode,
|
|
235
|
+
added,
|
|
236
|
+
skipped,
|
|
237
|
+
overwritten,
|
|
238
|
+
identical,
|
|
239
|
+
conflicts: skipped,
|
|
240
|
+
skippedInvalid,
|
|
241
|
+
verified,
|
|
242
|
+
verifiedConfig: destConfigValid,
|
|
243
|
+
hashes: written.map((rel) => ({ rel, sha256: sha256Of(readFileSync(join(dest, ...rel.split('/')))) })),
|
|
244
|
+
bytes,
|
|
245
|
+
files: srcFiles.map((f) => f.rel),
|
|
246
|
+
note: noteParts.join(';'),
|
|
247
|
+
}
|
|
248
|
+
// ok 的判据:真写成的文件都通过了读回核实 + 落盘后的配置结构合法。
|
|
249
|
+
// 两点必须说清,否则"改错"会变成"动不动报失败":
|
|
250
|
+
// · 一个文件都没写(内容全一致)**不是失败** —— 那是幂等重装的正常结局(verified 为 null);
|
|
251
|
+
// · 默认模式下"跳过同名冲突文件"**不是失败** —— 那正是"只补不覆盖"的设计目的,
|
|
252
|
+
// 但要靠 note 里逐条点名 + `skipped` 字段让面板看得见(绝不静默)。
|
|
253
|
+
const writtenOk = written.length === 0 || (verified !== null && verified.ok === true)
|
|
254
|
+
result.ok = writtenOk && destConfigValid.ok === true
|
|
255
|
+
if (!result.ok) {
|
|
256
|
+
result.error = writtenOk !== true
|
|
257
|
+
? `写后读回核实不通过:${(verified?.mismatched ?? []).join('、')}`
|
|
258
|
+
: `落盘后的预设配置未通过结构校验:${(destConfigValid.problems ?? []).join(';')}`
|
|
259
|
+
}
|
|
260
|
+
return result
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** 逐文件复制目录树(不用 fs.cpSync:见 infra/fsx.js#copyTree 的本环境 EIO 说明)。
|
|
264
|
+
* 与 fsx.copyTree 的差别:这里**先清掉**同名的旧备份残留(绝不会发生,uniqueBackupPath 已保证),
|
|
265
|
+
* 并在复制后清掉只读位,保证备份可删可读。 */
|
|
266
|
+
function copyTreeStrict(src, dest) {
|
|
267
|
+
mkdirSync(dest, { recursive: true })
|
|
268
|
+
for (const entry of readdirSync(src, { withFileTypes: true })) {
|
|
269
|
+
const from = join(src, entry.name)
|
|
270
|
+
const to = join(dest, entry.name)
|
|
271
|
+
if (entry.isDirectory()) { copyTreeStrict(from, to); continue }
|
|
272
|
+
if (!entry.isFile()) continue
|
|
273
|
+
copyFileSync(from, to)
|
|
274
|
+
try { chmodSync(to, 0o666) } catch {}
|
|
275
|
+
}
|
|
276
|
+
return dest
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// ── 源码出处记录(`overwrite-preset` 动作据此重新取源码)──────────────────────────────
|
|
280
|
+
|
|
281
|
+
/** 读全部记录(读不到/坏文件 → 空对象,绝不抛)。 */
|
|
282
|
+
function readPresetSources(home = undefined) {
|
|
283
|
+
try {
|
|
284
|
+
const parsed = JSON.parse(readFileSync(presetSourcesFile(home), 'utf8'))
|
|
285
|
+
return parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {}
|
|
286
|
+
} catch {
|
|
287
|
+
return {}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** 记下"这个预设是从哪个仓库的哪个子目录装配来的"(唯一写盘点,由装配路径调用)。
|
|
292
|
+
* 只记**可重新定位源码**的最小信息:repo / candidateName / subdir / sparse。 */
|
|
293
|
+
function recordPresetSource(entries, { home = undefined, now = Date.now } = {}) {
|
|
294
|
+
const list = Array.isArray(entries) ? entries.filter((e) => e !== null && typeof e === 'object' && typeof e.name === 'string' && e.name !== '') : []
|
|
295
|
+
if (list.length === 0) return { ok: false, written: 0 }
|
|
296
|
+
const all = readPresetSources(home)
|
|
297
|
+
const at = new Date(typeof now === 'function' ? now() : now).toISOString()
|
|
298
|
+
for (const e of list) {
|
|
299
|
+
all[e.name] = {
|
|
300
|
+
repo: typeof e.repo === 'string' ? e.repo : null,
|
|
301
|
+
candidateName: typeof e.candidateName === 'string' ? e.candidateName : null,
|
|
302
|
+
subdir: typeof e.subdir === 'string' ? e.subdir : null,
|
|
303
|
+
sparse: e.sparse === true,
|
|
304
|
+
dest: typeof e.dest === 'string' ? e.dest : null,
|
|
305
|
+
at,
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
// 只保留最近 N 条(防无限增长):对象键顺序 = 插入顺序,从最老的开始丢
|
|
309
|
+
const names = Object.keys(all)
|
|
310
|
+
for (const name of names.slice(0, Math.max(0, names.length - PRESET_SOURCE_RECORD_LIMIT))) delete all[name]
|
|
311
|
+
try {
|
|
312
|
+
mkdirSync(dirname(presetSourcesFile(home)), { recursive: true })
|
|
313
|
+
writeFileSync(presetSourcesFile(home), JSON.stringify(all, null, 2), 'utf8')
|
|
314
|
+
return { ok: true, written: list.length, file: presetSourcesFile(home) }
|
|
315
|
+
} catch (error) {
|
|
316
|
+
return { ok: false, written: 0, error: error instanceof Error ? error.message : String(error) }
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** 取一个预设记下的源码出处(没有 → null)。 */
|
|
321
|
+
function presetSourceOf(name, home = undefined) {
|
|
322
|
+
const all = readPresetSources(home)
|
|
323
|
+
const hit = all[String(name ?? '').trim()]
|
|
324
|
+
return hit !== null && typeof hit === 'object' ? hit : null
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** 面板的「覆盖该预设」显式动作(**结构化**,与 pin-dependency / allow-builds 同一套安全语义):
|
|
328
|
+
* `command` 只供展示/复制;执行侧只认 `payload.action` 白名单类型,`presetName` 由服务端自己校验
|
|
329
|
+
* (必须真的是 `<DSH_HOME>/.agent-presets/<name>` 下已存在的目录,客户端说了不算)。 */
|
|
330
|
+
function suggestedOverwritePresetAction({ presetName, repo = null, reason = null } = {}) {
|
|
331
|
+
const name = typeof presetName === 'string' ? presetName.trim() : ''
|
|
332
|
+
return {
|
|
333
|
+
kind: 'overwrite-preset',
|
|
334
|
+
label: '覆盖该预设(先用仓库版本覆盖,自动整目录备份)',
|
|
335
|
+
command: `dsh-plugin-console: overwrite-preset ${name}`,
|
|
336
|
+
payload: { action: 'overwrite-preset', presetName: name, repo: typeof repo === 'string' && repo !== '' ? repo : null },
|
|
337
|
+
reason,
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/** 面板视图里的**预设字段簇**(0.5.26 加法):`installJobView` 只做一行展开。
|
|
342
|
+
* 为什么放这里:`domain/install.js` 贴着 600 行架构硬顶(test-architecture-guard.mjs),
|
|
343
|
+
* 而"预设这条通道要下发什么"本来就属于本模块 —— 字段与它的产出者放在一起,才不会漂移。 */
|
|
344
|
+
function presetJobView(job) {
|
|
345
|
+
const j = job ?? {}
|
|
346
|
+
return {
|
|
347
|
+
presetNote: j.presetNote ?? null,
|
|
348
|
+
presetInstalled: j.presetInstalled ?? null,
|
|
349
|
+
presetSource: j.presetSource ?? null,
|
|
350
|
+
// 默认只补不覆盖:出现同名冲突时下发的**显式**「覆盖该预设」动作(老客户端忽略该字段,向后兼容)
|
|
351
|
+
suggestedAction: j.suggestedAction ?? null,
|
|
352
|
+
presetOverwriteCandidates: j.presetOverwriteCandidates ?? null,
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
export {
|
|
357
|
+
PRESET_MARKER_FILES, PRESET_MANIFEST_FILE, PRESET_SOURCE_RECORD_LIMIT,
|
|
358
|
+
assemblePreset, copyTreeStrict, listTreeFiles, presetJobView, presetSourceOf, presetSourcesFile,
|
|
359
|
+
readPresetSources, recordPresetSource, sha256Of, suggestedOverwritePresetAction, uniqueBackupPath, verifyWrittenFiles,
|
|
360
|
+
}
|
|
@@ -7,29 +7,30 @@
|
|
|
7
7
|
// 于是第三件连一次机会都没有;而"添加并启用"这条路上,按包名的通道(registry/curl/release)
|
|
8
8
|
// 对它必然是 404 —— 缺的就是"从仓库里把那一个目录取下来,按预设装配"这条能力。
|
|
9
9
|
//
|
|
10
|
-
//
|
|
10
|
+
// 本模块提供三件事:
|
|
11
11
|
// ① findPresetDirs / isPresetDir —— 预设目录识别(从 suite.js **原样搬来**,suite.js 改为 re-export)
|
|
12
|
-
// ②
|
|
13
|
-
// 绝不静默覆盖**:先整目录备份,再逐文件合并;同名不同内容的逐条报告)
|
|
14
|
-
// ③ fetchPresetSource / tryPresetSourceChannel —— 稀疏取源码(`--filter=blob:none --sparse`
|
|
12
|
+
// ② fetchPresetSource / tryPresetSourceChannel —— 稀疏取源码(`--filter=blob:none --sparse`
|
|
15
13
|
// + `git sparse-checkout set <subdir>`;不支持时降级普通 clone)并装配
|
|
14
|
+
// ③ 装配本身(assemblePreset / 校验 / 备份 / 读回核实)**已搬到 domain/preset-install.js**
|
|
15
|
+
// (0.5.26 改错 F2+F3+F4:默认改为"只补不覆盖"、写前校验、备份路径唯一)。
|
|
16
|
+
// 本模块只做 re-export,既有调用方与测试一个字都不用改 —— 写盘逻辑全项目只有一份。
|
|
16
17
|
//
|
|
17
18
|
// 进程管理一律复用 repoland.js 的 runGitArgs / gitCloneRepo(kill-tree + wait-for-exit + `.tryN` +
|
|
18
19
|
// `.trash-*` 降级)—— 本模块**不新造任何进程管理**。
|
|
19
20
|
|
|
20
|
-
import { existsSync, readFileSync, readdirSync, mkdirSync
|
|
21
|
-
import { basename,
|
|
21
|
+
import { existsSync, readFileSync, readdirSync, mkdirSync } from 'node:fs'
|
|
22
|
+
import { basename, join } from 'node:path'
|
|
22
23
|
import { tmpdir } from 'node:os'
|
|
23
24
|
import { gitCloneRepo, runGitArgs } from './repoland.js'
|
|
24
25
|
import { noteChannel } from './git-channel.js'
|
|
25
26
|
import { evaluateSourceChannel } from './repo-size.js'
|
|
26
|
-
import {
|
|
27
|
+
import { disposeDir } from '../infra/fsx.js'
|
|
27
28
|
import { dshHome } from '../infra/paths.js'
|
|
29
|
+
import {
|
|
30
|
+
PRESET_MARKER_FILES, PRESET_MANIFEST_FILE, assemblePreset, listTreeFiles,
|
|
31
|
+
presetSourceOf, recordPresetSource, suggestedOverwritePresetAction,
|
|
32
|
+
} from './preset-install.js'
|
|
28
33
|
|
|
29
|
-
/** 预设的判别文件(框架发现预设看的就是这个:`~/.dsh/.agent-presets/<name>/agent.cordis.yml`)。 */
|
|
30
|
-
const PRESET_MARKER_FILES = ['agent.cordis.yml', 'agent.cordis.yaml']
|
|
31
|
-
/** 预设清单(可选,面板文案用)。 */
|
|
32
|
-
const PRESET_MANIFEST_FILE = 'preset.yml'
|
|
33
34
|
/** git 只读命令(ls-tree / show / sparse-checkout)的超时:都是元数据或单个 blob。 */
|
|
34
35
|
const GIT_READ_TIMEOUT_MS = 60000
|
|
35
36
|
|
|
@@ -84,88 +85,6 @@ function readPresetManifest(dir) {
|
|
|
84
85
|
}
|
|
85
86
|
}
|
|
86
87
|
|
|
87
|
-
/** 目录树里的文件清单(相对路径 + 字节数)——只读,best-effort,绝不抛。 */
|
|
88
|
-
function listTreeFiles(root, { maxDepth = 8 } = {}) {
|
|
89
|
-
const out = []
|
|
90
|
-
const walk = (dir, depth) => {
|
|
91
|
-
if (depth > maxDepth) return
|
|
92
|
-
let entries = []
|
|
93
|
-
try { entries = readdirSync(dir, { withFileTypes: true }) } catch { return }
|
|
94
|
-
for (const e of entries) {
|
|
95
|
-
const child = join(dir, e.name)
|
|
96
|
-
try {
|
|
97
|
-
if (e.isDirectory()) walk(child, depth + 1)
|
|
98
|
-
else if (e.isFile()) out.push({ rel: relative(root, child).split(sep).join('/'), bytes: statSync(child).size })
|
|
99
|
-
} catch {}
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
walk(String(root), 0)
|
|
103
|
-
return out
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
/** 装配一个预设到 `<presetsRoot>/<name>`(**唯一实现**)。
|
|
107
|
-
* 已存在同名预设时**绝不静默覆盖**:
|
|
108
|
-
* ① 先把原目录整份备份成 `<name>.bak-<ts>`(备份失败就**不合并**,如实报冲突);
|
|
109
|
-
* ② 再逐文件合并:源里有、目标里没有的算「新增」;两边都有且内容不同的算「覆盖」并逐条列出
|
|
110
|
-
* (内容相同的算「一致」,只在统计里体现,不刷屏)。
|
|
111
|
-
* 这是**加法**:旧代码(suite.js 里那三行)是 `rmSync(dest)` + `copyTree` —— 用户手上的同名预设
|
|
112
|
-
* 会被无声抹掉(真机 `.agent-presets` 下就有 router-standard / router-spec / router-react 三个在用)。
|
|
113
|
-
* 返回 { ok, name, dest, backup, added, overwritten, identical, conflicts, bytes, note }。 */
|
|
114
|
-
function assemblePreset(srcDir, name, { presetsRoot = null, now = Date.now } = {}) {
|
|
115
|
-
const root = presetsRoot ?? join(dshHome(), '.agent-presets')
|
|
116
|
-
const presetName = String(name ?? basename(srcDir)).trim()
|
|
117
|
-
if (presetName === '' || presetName === '.' || /[\\/]/u.test(presetName)) {
|
|
118
|
-
return { ok: false, name: presetName, dest: null, error: `预设目录名不合法:${JSON.stringify(name)}` }
|
|
119
|
-
}
|
|
120
|
-
const dest = join(root, presetName)
|
|
121
|
-
const srcFiles = listTreeFiles(srcDir)
|
|
122
|
-
if (srcFiles.length === 0) return { ok: false, name: presetName, dest, error: `源目录里没有任何文件:${srcDir}` }
|
|
123
|
-
const bytes = srcFiles.reduce((sum, f) => sum + f.bytes, 0)
|
|
124
|
-
let backup = null
|
|
125
|
-
const existed = existsSync(dest)
|
|
126
|
-
if (existed) {
|
|
127
|
-
backup = `${dest}.bak-${now()}`
|
|
128
|
-
try {
|
|
129
|
-
copyTree(dest, backup)
|
|
130
|
-
} catch (error) {
|
|
131
|
-
return {
|
|
132
|
-
ok: false,
|
|
133
|
-
name: presetName,
|
|
134
|
-
dest,
|
|
135
|
-
error: `已存在同名预设且备份失败(绝不在没备份的情况下覆盖):${error instanceof Error ? error.message : String(error)}`,
|
|
136
|
-
conflicts: srcFiles.map((f) => f.rel),
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
|
-
mkdirSync(root, { recursive: true })
|
|
141
|
-
const added = []
|
|
142
|
-
const overwritten = []
|
|
143
|
-
const identical = []
|
|
144
|
-
for (const file of srcFiles) {
|
|
145
|
-
const from = join(srcDir, ...file.rel.split('/'))
|
|
146
|
-
const target = join(dest, ...file.rel.split('/'))
|
|
147
|
-
const had = existsSync(target)
|
|
148
|
-
let same = false
|
|
149
|
-
if (had) {
|
|
150
|
-
try { same = readFileSync(target).equals(readFileSync(from)) } catch { same = false }
|
|
151
|
-
}
|
|
152
|
-
if (same) { identical.push(file.rel); continue }
|
|
153
|
-
try {
|
|
154
|
-
mkdirSync(dirname(target), { recursive: true })
|
|
155
|
-
writeFileSync(target, readFileSync(from))
|
|
156
|
-
} catch (error) {
|
|
157
|
-
return { ok: false, name: presetName, dest, backup, error: `合并 ${file.rel} 失败:${error instanceof Error ? error.message : String(error)}` }
|
|
158
|
-
}
|
|
159
|
-
if (had) overwritten.push(file.rel)
|
|
160
|
-
else added.push(file.rel)
|
|
161
|
-
}
|
|
162
|
-
const note = existed
|
|
163
|
-
? `预设 ${presetName}:已存在同名预设,原目录已备份到 ${backup};本次合并新增 ${added.length} 个、覆盖 ${overwritten.length} 个同名文件、内容一致 ${identical.length} 个`
|
|
164
|
-
+ (overwritten.length > 0 ? `(被覆盖的:${overwritten.slice(0, 6).join('、')}${overwritten.length > 6 ? ' 等' : ''})` : '')
|
|
165
|
-
: `预设 ${presetName} 已装配到 ${dest}(新增 ${added.length} 个文件)`
|
|
166
|
-
return { ok: true, name: presetName, dest, backup, added, overwritten, identical, conflicts: overwritten, bytes, files: srcFiles.map((f) => f.rel), note }
|
|
167
|
-
}
|
|
168
|
-
|
|
169
88
|
/** 跑一条 git 命令并取回 stdout(ls-tree/show 要的就是输出)。仍然走 repoland 的 runGitArgs:
|
|
170
89
|
* 那边已经把停滞判据 + kill-tree + wait-for-exit 做完了,这里只转发(`stdout` 由 runGitArgs 采集)。 */
|
|
171
90
|
function gitCapture(argv, { cwd, timeout, runGit = null } = {}) {
|
|
@@ -299,30 +218,64 @@ async function tryPresetSourceChannel({ job, name, source = 'github', limit = 4,
|
|
|
299
218
|
const skipped = Math.max(0, presets.length - limit)
|
|
300
219
|
for (const p of presets.slice(0, limit)) {
|
|
301
220
|
// eslint-disable-next-line no-await-in-loop
|
|
221
|
+
// overwrite **永不**在这里为真:安装路径必须走"只补不覆盖"的默认语义。
|
|
222
|
+
// 覆盖只能由用户显式点「覆盖该预设」→ 白名单动作 overwrite-preset 触发(见 plugin-actions.js)。
|
|
302
223
|
const report = assemblePreset(p, basename(p), { presetsRoot, ...(deps.now === undefined ? {} : { now: deps.now }) })
|
|
303
224
|
reports.push({ ...report, manifest: readPresetManifest(p), sparse: fetched.sparse === true, subdir: fetched.subdir })
|
|
304
225
|
}
|
|
305
226
|
const failed = reports.filter((r) => r.ok !== true)
|
|
306
227
|
if (job !== null && job !== undefined) {
|
|
228
|
+
// 面板可读的结构化结果。0.5.26 加法:mode / skipped / skippedInvalid / verified ——
|
|
229
|
+
// 「默认只补不覆盖」必须**在数据里看得见**(跳过了哪几个同名文件、为什么),不能只在 note 里叙述。
|
|
307
230
|
job.presetInstalled = reports.map((r) => ({
|
|
308
231
|
name: r.name, dest: r.dest, ok: r.ok === true, added: r.added?.length ?? 0, overwritten: r.overwritten?.length ?? 0,
|
|
309
232
|
identical: r.identical?.length ?? 0, backup: r.backup ?? null, bytes: r.bytes ?? 0, error: r.error ?? null,
|
|
310
233
|
marker: r.ok === true ? 'agent.cordis.yml' : null, subdir: r.subdir ?? null, sparse: r.sparse === true,
|
|
234
|
+
mode: r.mode ?? null, existed: r.existed === true,
|
|
235
|
+
skipped: Array.isArray(r.skipped) ? r.skipped : [],
|
|
236
|
+
skippedInvalid: Array.isArray(r.skippedInvalid) ? r.skippedInvalid : [],
|
|
237
|
+
verified: r.verified === null || r.verified === undefined ? null : r.verified.ok === true,
|
|
311
238
|
}))
|
|
312
239
|
job.presetSource = { repo, subdir: fetched.subdir, sparse: fetched.sparse === true, downgraded: fetched.downgraded === true, root: fetched.root }
|
|
240
|
+
// 源码出处落盘:`overwrite-preset` 动作据此**重新取源码**(临时 clone 目录在 finally 里已清理,
|
|
241
|
+
// 所以"仅靠内存里的路径"是做不到的)。只记 repo / 候选名 / 子目录,不记任何路径或凭据。
|
|
242
|
+
try {
|
|
243
|
+
recordPresetSource(reports.filter((r) => r.ok === true).map((r) => ({
|
|
244
|
+
name: r.name, repo, candidateName, subdir: fetched.subdir, sparse: fetched.sparse === true, dest: r.dest,
|
|
245
|
+
})), { home: deps.home, now: deps.now })
|
|
246
|
+
} catch {}
|
|
247
|
+
// 同名冲突 / 校验跳过 → 给面板一个**显式**的「覆盖该预设」按钮(结构化动作,见 plugin-actions.js)。
|
|
248
|
+
// 只在真的有冲突时出现;用户不点,一个字节都不会被覆盖。
|
|
249
|
+
const conflicted = reports.filter((r) => r.ok === true && ((r.skipped?.length ?? 0) > 0 || (r.skippedInvalid?.length ?? 0) > 0))
|
|
250
|
+
if (conflicted.length > 0 && job.suggestedAction === undefined) {
|
|
251
|
+
const first = conflicted[0]
|
|
252
|
+
job.suggestedAction = suggestedOverwritePresetAction({
|
|
253
|
+
presetName: first.name,
|
|
254
|
+
repo,
|
|
255
|
+
reason: `预设 ${first.name} 已存在同名文件(${(first.skipped ?? []).join('、') || '配置未通过校验'}),本次**按默认语义保留了你手上的版本**;如需用仓库版本覆盖,点这个按钮(会先把原目录整份备份)`,
|
|
256
|
+
})
|
|
257
|
+
job.presetOverwriteCandidates = conflicted.map((r) => r.name)
|
|
258
|
+
}
|
|
313
259
|
}
|
|
314
260
|
if (failed.length > 0 && failed.length === reports.length) {
|
|
315
261
|
return { handled: true, installed: false, error: new Error(`预设装配失败:${failed[0].error}`), presets: reports }
|
|
316
262
|
}
|
|
317
263
|
const okNames = reports.filter((r) => r.ok === true).map((r) => r.name)
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
const
|
|
264
|
+
const skippedFiles = reports.reduce((sum, r) => sum + (r.skipped?.length ?? 0), 0)
|
|
265
|
+
const invalidFiles = reports.reduce((sum, r) => sum + (r.skippedInvalid?.length ?? 0), 0)
|
|
266
|
+
const backedUp = reports.filter((r) => r.ok === true && typeof r.backup === 'string' && r.backup !== '')
|
|
321
267
|
const note = `预设已装配:${okNames.join('、')}(落盘 ${presetsRoot};**新建会话时选择**)`
|
|
322
268
|
+ (fetched.sparse === true ? ';源码用稀疏取源码(--filter=blob:none --sparse + sparse-checkout set),只取该子包目录' : '')
|
|
323
269
|
+ (skipped > 0 ? `;该仓库还有 ${skipped} 个预设本次未装配(单次上限 ${limit} 个)` : '')
|
|
324
|
-
|
|
325
|
-
|
|
270
|
+
// 默认语义必须在面板上说清(用户要先知道"你的文件没被动过",才谈得上要不要覆盖)
|
|
271
|
+
+ (skippedFiles > 0
|
|
272
|
+
? `;**预设装配默认只补不覆盖**:${skippedFiles} 个同名文件与你手上的内容不同,已原样保留未动 —— 如需覆盖,请点「覆盖该预设」(会先把原目录整份备份)`
|
|
273
|
+
: '')
|
|
274
|
+
+ (invalidFiles > 0
|
|
275
|
+
? `;${invalidFiles} 个预设配置文件未通过结构校验(非法 YAML),**已跳过未写入**(避免把原本可用的预设写坏)`
|
|
276
|
+
: '')
|
|
277
|
+
+ (backedUp.length > 0
|
|
278
|
+
? `;已存在同名预设 ${backedUp.map((r) => r.name).join('、')}:原目录已备份(${backedUp.map((r) => r.backup).join('、')}),本次覆盖 ${backedUp.reduce((s, r) => s + r.overwritten.length, 0)} 个、内容一致 ${backedUp.reduce((s, r) => s + r.identical.length, 0)} 个`
|
|
326
279
|
: '')
|
|
327
280
|
+ (failed.length > 0 ? `;${failed.length} 个预设装配失败:${failed.map((r) => `${r.name}(${r.error})`).join('、')}` : '')
|
|
328
281
|
return { handled: true, installed: true, name: candidateName, presets: reports, note }
|
|
@@ -332,8 +285,89 @@ async function tryPresetSourceChannel({ job, name, source = 'github', limit = 4,
|
|
|
332
285
|
}
|
|
333
286
|
}
|
|
334
287
|
|
|
288
|
+
/** 「覆盖该预设」的**执行体**(0.5.26 加法,修 F2;只由显式动作 `overwrite-preset` 调用)。
|
|
289
|
+
*
|
|
290
|
+
* 为什么需要它:默认语义改成"只补不覆盖"之后,用户必须有**一条明确的出路**才能拿到仓库版本。
|
|
291
|
+
* 这条出路不能靠"临时 clone 目录"——那个目录在 tryPresetSourceChannel 的 finally 里已经清理了,
|
|
292
|
+
* 所以要**按记录重新取一次源码**(记录见 preset-install.js#recordPresetSource),再走
|
|
293
|
+
* `assemblePreset(..., { overwrite: true })`(先整目录备份 → 覆盖 → 写后读回核实)。
|
|
294
|
+
*
|
|
295
|
+
* 安全边界(与 allow-builds / pin-dependency 同一套,配套断言钉死):
|
|
296
|
+
* · 只认**已存在**于 `<presetsRoot>/<name>` 的目录,名字由服务端校验(客户端说了不算);
|
|
297
|
+
* · 只有**记过源码出处**的预设才能覆盖(没记录 = 不是本控制台装的 → 让它走正常安装流程);
|
|
298
|
+
* · 源码通道体积门禁照旧(巨仓不放行);取源码走既有 gitCloneRepo(不新造进程管理);
|
|
299
|
+
* · 本函数**不执行任何客户端传来的字符串**,也不接受任何命令参数。
|
|
300
|
+
*
|
|
301
|
+
* 返回 { ok, status, name, backup, overwritten, added, identical, verified, note, error, reason }。 */
|
|
302
|
+
async function overwritePreset({ presetName, deps = {} } = {}) {
|
|
303
|
+
const raw = String(presetName ?? '').trim()
|
|
304
|
+
// 目录名判据与 assemblePreset 完全一致(含"不得含路径分隔符"),这里先做一次**便宜**的拒绝
|
|
305
|
+
if (raw === '' || raw === '.' || raw === '..' || /[\\/]/u.test(raw)) {
|
|
306
|
+
return { ok: false, status: 400, name: raw, error: `预设名不合法:${JSON.stringify(presetName)}` }
|
|
307
|
+
}
|
|
308
|
+
const presetsRoot = deps.presetsRoot ?? join(dshHome(), '.agent-presets')
|
|
309
|
+
const dest = join(presetsRoot, raw)
|
|
310
|
+
if (!existsSync(dest)) {
|
|
311
|
+
return { ok: false, status: 400, name: raw, error: `预设 ${raw} 不存在(${dest}),没有可覆盖的对象 —— 本动作只改已存在的预设` }
|
|
312
|
+
}
|
|
313
|
+
const home = deps.home
|
|
314
|
+
const explicitRepo = typeof deps.repo === 'string' && deps.repo.trim() !== '' ? deps.repo.trim() : null
|
|
315
|
+
const record = presetSourceOf(raw, home)
|
|
316
|
+
const repo = explicitRepo ?? (typeof record?.repo === 'string' && record.repo !== '' ? record.repo : null)
|
|
317
|
+
if (repo === null) {
|
|
318
|
+
return {
|
|
319
|
+
ok: false, status: 400, name: raw,
|
|
320
|
+
error: `没有 ${raw} 的源码出处记录(它可能不是本控制台装配的)。请用市场卡片重新安装这个预设,安装后即可覆盖。`,
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
const candidateName = (typeof record?.candidateName === 'string' && record.candidateName !== '') ? record.candidateName : `dsh-${raw}`
|
|
324
|
+
const source = typeof deps.source === 'string' && deps.source !== '' ? deps.source : 'github'
|
|
325
|
+
const gate = await evaluateSourceChannel(repo, deps.sizeDeps ?? {})
|
|
326
|
+
if (gate.allow !== true) {
|
|
327
|
+
return { ok: false, status: 400, name: raw, repo, error: `覆盖需要重新取源码,但源码通道不可用:${gate.note}` }
|
|
328
|
+
}
|
|
329
|
+
const tmpRoot = deps.tmpRoot ?? join(tmpdir(), `dsh-preset-overwrite-${raw}-${Date.now()}`)
|
|
330
|
+
const destClone = join(tmpRoot, 'repo')
|
|
331
|
+
try { mkdirSync(tmpRoot, { recursive: true }) } catch {}
|
|
332
|
+
try {
|
|
333
|
+
const fetched = await fetchPresetSource({ repo, candidateName, dest: destClone, source, timeout: deps.cloneTimeoutMs, deps: deps.fetchDeps ?? {} })
|
|
334
|
+
if (fetched.ok !== true) {
|
|
335
|
+
return { ok: false, status: 200, name: raw, repo, error: `重新取源码失败:${fetched.error}` }
|
|
336
|
+
}
|
|
337
|
+
const subDir = join(fetched.root, ...String(fetched.subdir).split('/'))
|
|
338
|
+
const found = findPresetDirs(subDir, 2)
|
|
339
|
+
if (found.length === 0 && isPresetDir(subDir)) found.push(subDir)
|
|
340
|
+
// 优先取**同名**的那个预设目录(一个子包里可能有多个预设)
|
|
341
|
+
const pick = found.find((p) => basename(p) === raw) ?? found[0] ?? null
|
|
342
|
+
if (pick === null) {
|
|
343
|
+
return { ok: false, status: 200, name: raw, repo, error: `仓库 ${repo} 的 ${fetched.subdir} 里没有找到预设目录` }
|
|
344
|
+
}
|
|
345
|
+
// 覆盖的永远是**用户点名的那一个**名字(不是源目录的 basename):用户点的是哪个就改哪个
|
|
346
|
+
const report = assemblePreset(pick, raw, { presetsRoot, overwrite: true, ...(deps.now === undefined ? {} : { now: deps.now }) })
|
|
347
|
+
if (report.ok !== true) {
|
|
348
|
+
return { ok: false, status: 200, name: raw, repo, backup: report.backup ?? null, error: report.error ?? '覆盖失败(文件未改动)', report }
|
|
349
|
+
}
|
|
350
|
+
// 覆盖后刷新源码出处记录(时间戳更新;repo/子目录可能与旧记录不同)
|
|
351
|
+
try {
|
|
352
|
+
recordPresetSource([{ name: raw, repo, candidateName, subdir: fetched.subdir, sparse: fetched.sparse === true, dest: report.dest }], { home, now: deps.now })
|
|
353
|
+
} catch {}
|
|
354
|
+
return {
|
|
355
|
+
ok: true, status: 200, name: raw, repo, dest: report.dest,
|
|
356
|
+
backup: report.backup, added: report.added, overwritten: report.overwritten, identical: report.identical,
|
|
357
|
+
skippedInvalid: report.skippedInvalid, verified: report.verified, bytes: report.bytes, hashes: report.hashes,
|
|
358
|
+
mode: 'overwrite', note: report.note, error: null,
|
|
359
|
+
reason: `已用仓库 ${repo} 的版本覆盖预设 ${raw}(覆盖 ${report.overwritten.length} 个文件`
|
|
360
|
+
+ `${report.overwritten.length > 0 ? `:${report.overwritten.slice(0, 6).join('、')}${report.overwritten.length > 6 ? ' 等' : ''}` : ''}`
|
|
361
|
+
+ `、新增 ${report.added.length} 个;原目录已备份到 ${report.backup})`,
|
|
362
|
+
}
|
|
363
|
+
} finally {
|
|
364
|
+
try { disposeDir(tmpRoot) } catch {}
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
335
368
|
export {
|
|
336
369
|
PRESET_MARKER_FILES, PRESET_MANIFEST_FILE, GIT_READ_TIMEOUT_MS,
|
|
337
370
|
findPresetDirs, isPresetDir, looksLikePresetPackageName, readPresetManifest, listTreeFiles,
|
|
338
371
|
assemblePreset, locateSubpackageViaGit, fetchPresetSource, gitCapture, tryPresetSourceChannel,
|
|
339
|
-
|
|
372
|
+
overwritePreset,
|
|
373
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
// L1 · domain —— preset-yaml.js(预设配置 agent.cordis.yml 的**结构校验**;0.5.26 加法,修 F3)
|
|
2
|
+
//
|
|
3
|
+
// 为什么要有它(真机缺陷 F3,2026-09-27 独立对抗式复核抓到):
|
|
4
|
+
// 预设装配会把源仓库里的 `agent.cordis.yml` 覆盖到用户 `~/.dsh/.agent-presets/<预设>/` 下。
|
|
5
|
+
// 一旦取到的内容**不是可用的配置**(空文件、被截断、带 tab 缩进、引号没闭合…),
|
|
6
|
+
// 覆盖就把**原本可用**的预设写坏了 —— 复核报告里的现场就是 `overwritten=["agent.cordis.yml"]`,
|
|
7
|
+
// 下次新建会话挂载这个预设时报错,而用户完全不知道是自己点的那次「安装」干的。
|
|
8
|
+
// 修法(改错):**写盘之前**先做结构校验;不通过就**跳过这一个文件并如实报告**,绝不写坏。
|
|
9
|
+
//
|
|
10
|
+
// ⚠️ 判据宽严的分寸(为什么不用"顶层必须是 mapping"这类严格判据):
|
|
11
|
+
// 框架的 agent 预设是**顶层 services 列表**(`- id: …` / `name: …` / `config:`),
|
|
12
|
+
// 里面还合法地使用 `!!js` 自定义标签、`>-` 折叠块、`|-` 字面块、注释与空行。
|
|
13
|
+
// 严格的通用 YAML 校验器(或"顶层必须是 mapping")会把**真文件**判成非法 → 于是"修 F3"变成
|
|
14
|
+
// "所有预设都装不上",那是把缺陷换了个方向。所以这里只钉**能确证损坏**的形态(全部可离线判定):
|
|
15
|
+
// ① 空内容 / 全是注释与空行 —— 没有任何配置行,框架挂载它等于空预设;
|
|
16
|
+
// ② 行首缩进里出现 **tab** —— YAML 明确禁止用 tab 做缩进(框架的解析器必然报错);
|
|
17
|
+
// ③ 明显**未闭合的引号**(引号从头到尾用反斜杠转义、真正的解析器能容忍跨行,这里只抓最常见的截断);
|
|
18
|
+
// ④ 缩进**跳级**(父级还没打开一个映射/序列就多缩进)—— 截断与坏编辑的典型形状。
|
|
19
|
+
// `!!js` / 块标量 / 注释 / CRLF 一律按合法处理(见 passesStructure 的分支注释)。
|
|
20
|
+
//
|
|
21
|
+
// 分层:L1 domain —— 纯函数,无 IO、无 ctx、不抛(解析失败是正常结局,调用方按"不通过"处理)。
|
|
22
|
+
|
|
23
|
+
/** 缩进宽度(空格数;tab 会在这里被单独判掉,不参与计数)。 */
|
|
24
|
+
function indentOf(line) {
|
|
25
|
+
const m = String(line).match(/^[ \t]*/u)
|
|
26
|
+
return m === null ? 0 : m[0].length
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** 这一行的缩进里有没有 tab(YAML 禁止;框架解析器会直接报错)。 */
|
|
30
|
+
function hasTabIndent(line) {
|
|
31
|
+
return /^[ \t]*\t/u.test(String(line))
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** 去掉行尾注释后的"有效内容"(只处理**不在引号里**的 `#`;`#` 前必须有空白或行首)。
|
|
35
|
+
* 例:`name: '@deepseek-ai/dsh-persona' # 注释` → `name: '@deepseek-ai/dsh-persona'`。 */
|
|
36
|
+
function stripTrailingComment(line) {
|
|
37
|
+
const text = String(line)
|
|
38
|
+
let quote = null
|
|
39
|
+
for (let i = 0; i < text.length; i += 1) {
|
|
40
|
+
const ch = text[i]
|
|
41
|
+
if (quote !== null) {
|
|
42
|
+
if (ch === '\\') { i += 1; continue }
|
|
43
|
+
if (ch === quote) quote = null
|
|
44
|
+
continue
|
|
45
|
+
}
|
|
46
|
+
if (ch === "'" || ch === '"') { quote = ch; continue }
|
|
47
|
+
if (ch === '#' && (i === 0 || /\s/u.test(text[i - 1]))) return text.slice(0, i)
|
|
48
|
+
}
|
|
49
|
+
return text
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** 值里的引号是否闭合(只判**单个标量内**的成对性;跨行的引号在 YAML 里合法,故不跨行判)。 */
|
|
53
|
+
function quotesBalanced(value) {
|
|
54
|
+
const text = String(value)
|
|
55
|
+
let quote = null
|
|
56
|
+
for (let i = 0; i < text.length; i += 1) {
|
|
57
|
+
const ch = text[i]
|
|
58
|
+
if (quote === null) {
|
|
59
|
+
if (ch === "'" || ch === '"') quote = ch
|
|
60
|
+
continue
|
|
61
|
+
}
|
|
62
|
+
if (quote === '"' && ch === '\\') { i += 1; continue }
|
|
63
|
+
if (ch === quote) {
|
|
64
|
+
// YAML 的单引号转义是 `''`(两个单引号),要一起吃掉
|
|
65
|
+
if (quote === "'" && text[i + 1] === "'") { i += 1; continue }
|
|
66
|
+
quote = null
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return quote === null
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** 块标量指示符(`|`、`>`、`|-`、`>+2`、`|2-`…)。返回 null 表示不是块标量。
|
|
73
|
+
* 只有**整个值**就是指示符时才认(`> something` 是普通标量,不是块头),否则会把普通行误判成块。 */
|
|
74
|
+
function blockScalarIndicator(value) {
|
|
75
|
+
const text = String(value).trim()
|
|
76
|
+
const m = text.match(/^([|>])([+-]?\d*|\d*[+-]?)$/u)
|
|
77
|
+
return m === null ? null : m[0]
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* 结构校验(**纯函数**,永不抛)。返回 `{ ok, problems: string[] }`。
|
|
82
|
+
* 只判"能确证损坏"的形态(见本文件顶部 ①~④),宁松勿误杀 —— 误杀的代价是预设装不上。
|
|
83
|
+
*/
|
|
84
|
+
function validateAgentConfig(text) {
|
|
85
|
+
const source = typeof text === 'string' ? text : String(text ?? '')
|
|
86
|
+
const problems = []
|
|
87
|
+
const lines = source.split(/\r?\n/u)
|
|
88
|
+
const significant = []
|
|
89
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
90
|
+
const raw = lines[i]
|
|
91
|
+
if (raw.trim() === '') continue
|
|
92
|
+
if (/^\s*#/u.test(raw)) continue
|
|
93
|
+
significant.push({ line: i + 1, raw, indent: indentOf(raw), at: i })
|
|
94
|
+
}
|
|
95
|
+
// ① 空内容 / 全是注释与空行
|
|
96
|
+
if (significant.length === 0) {
|
|
97
|
+
problems.push('没有任何配置行(空文件,或只有注释与空行)—— 框架挂载它等于空预设')
|
|
98
|
+
return { ok: false, problems }
|
|
99
|
+
}
|
|
100
|
+
/** 块标量(`>-` / `|` 等)的内容由框架按字面读,**不是** YAML 结构 —— 必须整段跳过,
|
|
101
|
+
* 否则 persona 那种多行散文会被逐行当成"非法条目"(误杀三个真机预设,2026-09-27 实测)。
|
|
102
|
+
* 结束判据:出现缩进 **小于等于** 块首行缩进的非空行(或文件结束)。 */
|
|
103
|
+
const blockScalarEnd = (startIdx, parentIndent) => {
|
|
104
|
+
let end = startIdx
|
|
105
|
+
for (let i = startIdx + 1; i < lines.length; i += 1) {
|
|
106
|
+
const line = lines[i]
|
|
107
|
+
if (line.trim() === '') { end = i; continue }
|
|
108
|
+
if (indentOf(line) > parentIndent) { end = i; continue }
|
|
109
|
+
break
|
|
110
|
+
}
|
|
111
|
+
return end
|
|
112
|
+
}
|
|
113
|
+
const stack = [] // 已打开的父级:{ indent, opensNested }
|
|
114
|
+
let skipThrough = -1
|
|
115
|
+
for (const item of significant) {
|
|
116
|
+
if (item.at <= skipThrough) continue
|
|
117
|
+
// ② tab 缩进
|
|
118
|
+
if (hasTabIndent(item.raw)) {
|
|
119
|
+
problems.push(`第 ${item.line} 行的缩进里有 tab(YAML 禁止用 tab 缩进)`)
|
|
120
|
+
continue
|
|
121
|
+
}
|
|
122
|
+
const content = stripTrailingComment(item.raw).replace(/\s+$/u, '')
|
|
123
|
+
if (content.trim() === '') continue
|
|
124
|
+
const body = item.raw.slice(item.indent)
|
|
125
|
+
// ④ 缩进跳级:比当前父级深、但父级没有"还能放子项"的位置
|
|
126
|
+
while (stack.length > 0 && item.indent < stack[stack.length - 1].indent) stack.pop()
|
|
127
|
+
const parent = stack.length > 0 ? stack[stack.length - 1] : null
|
|
128
|
+
if (parent !== null && item.indent > parent.indent && parent.opensNested !== true) {
|
|
129
|
+
problems.push(`第 ${item.line} 行缩进 ${item.indent} 空格,但上一层的 \`${parent.label}\` 不接受子项(缩进跳级/文件被截断)`)
|
|
130
|
+
}
|
|
131
|
+
if (body.startsWith('- ') || body === '-') {
|
|
132
|
+
stack.push({ indent: item.indent, opensNested: true, label: body.slice(0, 40) })
|
|
133
|
+
continue
|
|
134
|
+
}
|
|
135
|
+
const kv = body.match(/^("[^"]*"|'[^']*'|[^\s:#][^:]*?)\s*:\s*(.*)$/u)
|
|
136
|
+
if (kv === null) {
|
|
137
|
+
problems.push(`第 ${item.line} 行不是合法的 YAML 条目(既不是 \`- \` 序列项,也不是 \`键: 值\`):${body.slice(0, 60)}`)
|
|
138
|
+
continue
|
|
139
|
+
}
|
|
140
|
+
const value = kv[2].trim()
|
|
141
|
+
// ③ 未闭合的引号(只在单行内判;`#` 已剥掉,行尾也不会再有注释)
|
|
142
|
+
if (!quotesBalanced(value)) {
|
|
143
|
+
problems.push(`第 ${item.line} 行的值里有未闭合的引号:${value.slice(0, 60)}`)
|
|
144
|
+
}
|
|
145
|
+
const isBlock = blockScalarIndicator(value) !== null
|
|
146
|
+
if (isBlock) skipThrough = blockScalarEnd(item.at, item.indent)
|
|
147
|
+
stack.push({ indent: item.indent, opensNested: value === '' || isBlock, label: `${kv[1].trim()}:` })
|
|
148
|
+
}
|
|
149
|
+
return { ok: problems.length === 0, problems }
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** 校验一个**目录里**的 agent 配置文件(写盘前的调用点用这个)。
|
|
153
|
+
* 返回 `{ ok, file, problems }`;目录里没有 agent.cordis.yml/.yaml 时 ok=true / file=null(不是预设,不归这里管)。 */
|
|
154
|
+
function validateAgentConfigDir(dir, { readFile = null } = {}) {
|
|
155
|
+
const read = typeof readFile === 'function' ? readFile : null
|
|
156
|
+
for (const name of ['agent.cordis.yml', 'agent.cordis.yaml']) {
|
|
157
|
+
const text = read === null ? null : read(name)
|
|
158
|
+
if (text === null || text === undefined) continue
|
|
159
|
+
const result = validateAgentConfig(text)
|
|
160
|
+
return { ...result, file: name }
|
|
161
|
+
}
|
|
162
|
+
return { ok: true, file: null, problems: [] }
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export { validateAgentConfig, validateAgentConfigDir, stripTrailingComment, quotesBalanced, blockScalarIndicator, hasTabIndent, indentOf }
|
|
@@ -37,17 +37,36 @@ function formatRepoSize(sizeKb) {
|
|
|
37
37
|
* · state='known' 且 sizeKb > 阈值 → **仍禁**,note 必须含体积、原因与出路(仓库落地 / 本地镜像)
|
|
38
38
|
* · state='unknown'(探测失败/超时/404)→ **保守但不沉默**:保持禁令 + note 说明"尺寸未知,已保守跳过"
|
|
39
39
|
* 返回的 note 一律非空 —— 静默跳过正是这次事故的另一半(用户什么都看不到)。 */
|
|
40
|
-
function sourceChannelGate(
|
|
40
|
+
function sourceChannelGate(options = {}, thresholdMb = GIT_MAX_REPO_MB) {
|
|
41
|
+
// ⚠️ 这里**故意不用解构默认值**(旧代码是 `{ state = 'unknown', sizeKb = null } = {}`):
|
|
42
|
+
// 解构的 `= null` 会在"键存在但值是 undefined"时也填上 null,于是**原始值被抹掉**——
|
|
43
|
+
// 而本次改错要判的恰恰是"读到的到底是什么",note 里必须分得出 null / undefined / 空串 / false。
|
|
44
|
+
const raw = options !== null && typeof options === 'object' ? options : {}
|
|
45
|
+
const provided = Object.prototype.hasOwnProperty.call(raw, 'sizeKb')
|
|
46
|
+
const state = raw.state === undefined ? 'unknown' : raw.state
|
|
47
|
+
const rawSize = provided ? raw.sizeKb : null
|
|
48
|
+
const sizeKb = rawSize
|
|
41
49
|
const limit = Number.isFinite(Number(thresholdMb)) && Number(thresholdMb) >= 1 ? Number(thresholdMb) : GIT_MAX_REPO_MB
|
|
42
50
|
const kb = Number(sizeKb)
|
|
43
|
-
|
|
51
|
+
// 判据必须落在**原始值**上:旧代码写的是 `Number(sizeKb)` 之后再判有限性,于是
|
|
52
|
+
// `sizeKb: null` → `Number(null) === 0` → 有限且 ≥ 0 → **被当成 0 MB 放行**(`undefined` 同理 → NaN→
|
|
53
|
+
// 有限性为假,那条反倒是禁的;真正漏的是 null / '' / false / [] 这些"能转成 0"的值)。
|
|
54
|
+
// 0 KB 只有在**真的是数字 0**时才是合法尺寸(空仓库;见边界断言),"读不到尺寸"绝不能伪装成 0。
|
|
55
|
+
const numericSize = typeof rawSize === 'number' && Number.isFinite(rawSize) && rawSize >= 0
|
|
56
|
+
const known = state === 'known' && numericSize
|
|
44
57
|
if (!known) {
|
|
58
|
+
// state='known' 但尺寸不是有限数 = 上游给了自相矛盾的结论(探测层不会这样产出)。
|
|
59
|
+
// 这时必须**如实说清**并保守跳过;旧文案会把它说成"探测失败/超时/404",与真实原因不符。
|
|
60
|
+
const contradiction = state === 'known'
|
|
61
|
+
const shown = provided ? String(rawSize) : 'undefined(未提供)'
|
|
45
62
|
return {
|
|
46
63
|
allow: false,
|
|
47
64
|
state: 'unknown',
|
|
48
65
|
sizeKb: null,
|
|
49
66
|
limitMb: limit,
|
|
50
|
-
note:
|
|
67
|
+
note: contradiction
|
|
68
|
+
? `仓库体积读到的不是有效数字(state=known 但 sizeKb=${shown}):已**保守跳过**源码通道(git / archive / 稀疏取源码)—— 尺寸读不出来时绝不能当成 0 MB 放行。请重试;或改用「仓库落地」把仓库克隆到本地目录后安装。`
|
|
69
|
+
: '仓库体积未知(GitHub 元数据探测失败/超时/404):已**保守跳过**源码通道(git / archive / 稀疏取源码),避免白拉一个可能几百 MB 的仓库。请重试;或改用「仓库落地」把仓库克隆到本地目录后安装。',
|
|
51
70
|
}
|
|
52
71
|
}
|
|
53
72
|
if (kb <= limit * 1024) {
|
package/lib/server/infra/fsx.js
CHANGED
|
@@ -119,12 +119,34 @@ function waitGone(dir, timeoutMs) {
|
|
|
119
119
|
})
|
|
120
120
|
}
|
|
121
121
|
|
|
122
|
+
/** 外部删除兜底该用哪条命令(**判据唯一真源**,异步/同步两个兜底共用)。
|
|
123
|
+
*
|
|
124
|
+
* 真机缺陷(0.5.25 及以前,2026-09-27 定位):`clean-residuals` 清的陈旧
|
|
125
|
+
* `fw-quarantine.json.applied-*` 是**文件**,而 shell 兜底一律用 `rmdir /s /q <path>` ——
|
|
126
|
+
* `rmdir` 在 Windows 上只能删**目录**,对文件返回 `exit=267 The directory name is invalid`,
|
|
127
|
+
* 于是"`rmSync` 一次瞬时失败"被兜底放大成**用户可见的「有 1 项没能删除」误报**(文件其实好好的)。
|
|
128
|
+
* 判据:文件 → `del /f /q`;目录 → `rmdir /s /q`;已经不在了 → 视为已达成(**不是失败**)。
|
|
129
|
+
* `lstatSync` 而不是 `statSync`:悬空符号链接本身也要能被删掉。
|
|
130
|
+
* 读不出类型(竞态/权限)→ 走目录形态:目录树是这套兜底原本唯一服务的对象,保持旧行为不倒退。
|
|
131
|
+
* 注意:`del` / `rmdir` 都是 cmd.exe 的**内建命令**,只能经 `cmd.exe /c` 调用,没有对应的可执行文件。 */
|
|
132
|
+
function shellDeleteCommand(dir) {
|
|
133
|
+
if (!existsSync(dir)) return { kind: 'gone', argv: null, method: 'already-gone' }
|
|
134
|
+
let isDirectory = true
|
|
135
|
+
try { isDirectory = lstatSync(dir).isDirectory() } catch { isDirectory = true }
|
|
136
|
+
return isDirectory
|
|
137
|
+
? { kind: 'dir', argv: ['rmdir', '/s', '/q', dir], method: 'rmdir' }
|
|
138
|
+
: { kind: 'file', argv: ['del', '/f', '/q', dir], method: 'del' }
|
|
139
|
+
}
|
|
140
|
+
|
|
122
141
|
/** 外部删除兜底:实测本机 PowerShell/.NET 能删掉 Node `rmSync` 静默删不掉的树。 */
|
|
123
142
|
async function removeViaShell(dir, timeoutMs = 120000) {
|
|
124
143
|
try {
|
|
125
144
|
if (process.platform === 'win32') {
|
|
126
|
-
|
|
127
|
-
return { ok: true, method:
|
|
145
|
+
const target = shellDeleteCommand(dir)
|
|
146
|
+
if (target.kind === 'gone') return { ok: true, method: target.method }
|
|
147
|
+
await execFileAsync('cmd.exe', ['/c', ...target.argv], { windowsHide: true, timeout: timeoutMs })
|
|
148
|
+
// `del` 对"文件已被别处删掉"会返回 0 又不报错,这里不再多判;调用方仍会核实目标是否消失
|
|
149
|
+
return { ok: true, method: target.method }
|
|
128
150
|
}
|
|
129
151
|
await execFileAsync('rm', ['-rf', '--', dir], { timeout: timeoutMs })
|
|
130
152
|
return { ok: true, method: 'rm' }
|
|
@@ -179,12 +201,16 @@ function sleepSync(ms) {
|
|
|
179
201
|
} catch {}
|
|
180
202
|
}
|
|
181
203
|
|
|
182
|
-
/** 外部删除兜底(同步版):实测本机 `cmd /c rmdir /s /q` 能删掉 Node `rmSync` 静默删不掉的树。
|
|
204
|
+
/** 外部删除兜底(同步版):实测本机 `cmd /c rmdir /s /q` 能删掉 Node `rmSync` 静默删不掉的树。
|
|
205
|
+
* 目标类型判据与异步版**同一份**(shellDeleteCommand):文件用 `del /f /q`、目录用 `rmdir /s /q` ——
|
|
206
|
+
* 旧实现对文件用 `rmdir` 只会拿到 exit=267,把"其实删得掉"的文件报成删不掉(见 shellDeleteCommand 注释)。 */
|
|
183
207
|
function removeViaShellSync(dir, timeoutMs = 120000) {
|
|
184
208
|
try {
|
|
185
209
|
if (process.platform === 'win32') {
|
|
186
|
-
|
|
187
|
-
return { ok: true, method:
|
|
210
|
+
const target = shellDeleteCommand(dir)
|
|
211
|
+
if (target.kind === 'gone') return { ok: true, method: target.method }
|
|
212
|
+
execFileSync('cmd.exe', ['/c', ...target.argv], { windowsHide: true, timeout: timeoutMs, stdio: 'ignore' })
|
|
213
|
+
return { ok: true, method: target.method }
|
|
188
214
|
}
|
|
189
215
|
execFileSync('rm', ['-rf', '--', dir], { timeout: timeoutMs, stdio: 'ignore' })
|
|
190
216
|
return { ok: true, method: 'rm' }
|
|
@@ -395,4 +421,4 @@ function startTrashCleanup(opts = {}) {
|
|
|
395
421
|
}
|
|
396
422
|
}
|
|
397
423
|
|
|
398
|
-
export { copyTree, queuedWrite, cleanupStalePackageDir, removeDirVerified, removeDirVerifiedAsync, removeDirVerifiedWithRetry, removeViaShellSync, sleepSync, clearReadonly, waitGone, removeViaShell, writeQueue, isLockFailure, trashPathFor, disposeDir, disposeNote, trashScanRoots, findTrashDirs, cleanupTrashDirs, startTrashCleanup, TRASH_PREFIX, TRASH_RE, TRASH_CLEAN_LIMIT, TRASH_CLEAN_ITEM_MS }
|
|
424
|
+
export { copyTree, queuedWrite, cleanupStalePackageDir, removeDirVerified, removeDirVerifiedAsync, removeDirVerifiedWithRetry, removeViaShellSync, sleepSync, clearReadonly, waitGone, removeViaShell, shellDeleteCommand, writeQueue, isLockFailure, trashPathFor, disposeDir, disposeNote, trashScanRoots, findTrashDirs, cleanupTrashDirs, startTrashCleanup, TRASH_PREFIX, TRASH_RE, TRASH_CLEAN_LIMIT, TRASH_CLEAN_ITEM_MS }
|
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.26",
|
|
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",
|