@noob-stupid/dsh-plugin-console 0.5.25 → 0.5.27

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js CHANGED
@@ -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
- : (typeof action.label === "string" && action.label !== "" ? action.label : String(action.kind ?? ""));
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,11 +10,13 @@ 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, readBundlePatchRefNames, readGithubAuth, syncAggregateSubpackageVersions } from './install.js'
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'
17
18
  import { tryPresetSourceChannel } from './preset-source.js'
19
+ import { settlePresetOutcome } from './preset-in-package.js'
18
20
  import { alreadyInstalledTarget, verifyInstalledEntry } from './install-verify.js'
19
21
  import { applyNpmNameHint, expandSubpackageCandidates, fetchRepoPackage, fetchRepoPackageEx, fetchSubpackageNames, npmNameHintForRepo, packageProbeErrorText, subpackageCandidates } from './market.js'
20
22
  import { declareProfileDependency } from './manifest.js'
@@ -339,11 +341,14 @@ async function runInstallJob(job, ports, deps = {}) {
339
341
  const rootPkg = await probes.fetchRepoPackage(job.repo, 'main')
340
342
  if (rootPkg !== null && rootPkg.private === true) {
341
343
  subpackageMode = true
342
- // 上游分支同一道判据(批次 D-①):按仓库体积决定源码通道能不能走
343
- await gatePrivateRoot(job, noteChannel, sourceDeps)
344
+ await gatePrivateRoot(job, noteChannel, sourceDeps) // 批次 D-①:按仓库体积决定源码通道
344
345
  const subs = await probes.subpackageCandidates(job.repo, 'main', readGithubAuth().token)
345
346
  if (subs.length > 0) {
346
- candidates = [...subs.filter((name) => !candidates.includes(name)), ...candidates]
347
+ // ★ D-⑤(真机验证发现的**第二个根因**):用户**明确点的那一件**必须排最前 —— 卡片子包列表每件的
348
+ // 按钮发 `{repo, packageName}`;旧代码把自动展开的子包统统排在它前面,第一件装成(或`已装在本地`
349
+ // 命中)就 break:**点第三件装的却是第一件**。只有"没给包名"时才该聚合包优先(那条路不经过这里)。
350
+ const requested = candidates.filter((n) => typeof n === 'string' && n !== '')
351
+ candidates = [...requested, ...subs.filter((name) => !requested.includes(name))]
347
352
  job.subpackages = subs
348
353
  }
349
354
  }
@@ -405,10 +410,9 @@ async function runInstallJob(job, ports, deps = {}) {
405
410
  lastError = attempt.lastError
406
411
  if (installedName !== null) break
407
412
  }
408
- // 预设型子包走的是"取源码 → 装配到 ~/.dsh/.agent-presets":**没有 node_modules 包、也不该写补丁行/
409
- // 声明依赖**(否则补丁行指向不存在的模块 → 启动崩溃,2026-09-06 事故同族)。candidateDone/packageName
410
- // 与普通成功安装对齐,面板显示的才是真正装上的那一件。
411
- if (job.presetDone === true) { job.status = 'done'; job.stage = 'done'; job.candidateDone = true; job.packageName = job.candidateName ?? job.packageName; return }
413
+ // 预设收口(D-③ 源码装配成功 / D-⑥ 落地物里带预设):能按预设收口就直接 return —— 见 preset-in-package.js
414
+ // (这两条路都**没有 node_modules 插件**,写补丁行/声明依赖会让补丁行指向无入口模块 → 启动崩溃)。
415
+ if (await settlePresetOutcome({ job, installedName, profileDir, repo: job.repo, deps: sourceDeps }) === true) return
412
416
  if (installedName === null) {
413
417
  if (job.diagnosis == null) job.diagnosis = classifyInstallFailure(lastError?.message) // 分类提示兜底(加法)
414
418
  // 本地 AI 兜底会调用模型 API、产生费用:挂起等待用户明确同意后再执行
@@ -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
- suiteNote: job.suiteNote ?? null, presetNote: job.presetNote ?? null, presetInstalled: job.presetInstalled ?? null, presetSource: job.presetSource ?? null, repoSize: job.repoSize ?? null,
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,148 @@
1
+ // L1 · domain —— preset-in-package.js(批次 D-⑥:**预设随包分发** —— 装出来的包体里带 agent.cordis.yml 时按预设装配)
2
+ //
3
+ // 真机事故(2026-09-27,官方桌面端实例、0.5.25 实测,卡片"子包"列表第三件的等价调用):
4
+ // POST /install {repo:'yjh051108/dsh-routing-suite', packageName:'dsh-router-standard'}
5
+ // → release 通道按包名反查到**它自己的仓库** yjh051108/dsh-router-standard 的 release v0.3.0 资产
6
+ // (dsh-router-standard-0.3.0.tgz)→ 装成 node_modules/dsh-router-standard@0.3.0。
7
+ // 而那个包体里**没有 main/exports、也没有 dsh.bundle**,只有 `preset/`(三个预设目录)+ docs/。
8
+ // 于是出现两个后果(都在用户的验收点上):
9
+ // ① 预设**一个字节都没进** `~/.dsh/.agent-presets` → 用户依然用不上(预设才是他要的东西);
10
+ // ② 却照样 `appendInsert` 了一行 `- insert: {id: dsh-router-standard}` → 注册一个**加载不了的模块行**
11
+ // (2026-09-06「装 dsh-desktop 后服务崩」事故同族:补丁行指向的模块没有入口)。
12
+ // 根因是判据只按**仓库**(有没有 .gitmodules / 是不是预设型子包名)分类,没按**落地物**分类:
13
+ // 预设可以随包分发(registry tarball / release 资产 / git 规格三条路都会发生),
14
+ // 而"包里带 agent.cordis.yml"是**装完之后一定能看到**的事实。
15
+ //
16
+ // 本模块补上缺的那一步:**任何通道装成功之后、写补丁行/声明依赖之前**,先看落地物里有没有预设;
17
+ // 有 → 走 preset-install.js#assemblePreset(**复用**既有装配:只补不覆盖 / 校验 / 备份 / 读回核实),
18
+ // 并按 `presetDone` 收口 —— **绝不写补丁行、绝不声明依赖**(预设不是 npm 插件)。
19
+ //
20
+ // 与 preset-source.js#tryPresetSourceChannel 的分工:那条通道是"按包名的通道都失败后,从仓库源码取";
21
+ // 本模块是"包已经装下来了,落地物里带预设"。两条路共用同一份装配实现与同一份"预设目录"判据。
22
+
23
+ import { existsSync } from 'node:fs'
24
+ import { basename, join } from 'node:path'
25
+ import { findPresetDirs, isPresetDir } from './preset-source.js'
26
+ import { assemblePreset, recordPresetSource, suggestedOverwritePresetAction } from './preset-install.js'
27
+ import { noteChannel } from './git-channel.js'
28
+ import { dshHome } from '../infra/paths.js'
29
+
30
+ /** 扫一个已装包的目录树找预设(深度 ≤2,与套装路径 / 候选源码路径**同一份判据**)。
31
+ * 返回预设目录绝对路径数组(有序、去重);包里没有预设时返回空数组。 */
32
+ function findCarriedPresets(packageDir, maxDepth = 2) {
33
+ if (typeof packageDir !== 'string' || packageDir === '' || !existsSync(packageDir)) return []
34
+ const found = findPresetDirs(packageDir, maxDepth)
35
+ // 兜底:包根自己就是预设(有的作者把 agent.cordis.yml 直接放在包根)
36
+ if (found.length === 0 && isPresetDir(packageDir)) found.push(packageDir)
37
+ return [...new Set(found)]
38
+ }
39
+
40
+ /** 已装包的目录(支持 scoped 包名)。找不到返回 null。 */
41
+ function installedPackageDir(profileDir, name) {
42
+ const dir = join(profileDir, 'node_modules', ...String(name ?? '').split('/'))
43
+ return existsSync(dir) ? dir : null
44
+ }
45
+
46
+ /**
47
+ * 「落地物里带预设」的唯一入口(install-job 在装成功后、appendInsert/declareProfileDependency 之前调用)。
48
+ * 返回 { handled, installed, presets, note, error }:
49
+ * · handled=false → 这个包里没有预设,调用方照旧按普通插件收口(行为一个字不变);
50
+ * · installed=true → 已按预设装配并**由本函数把 job 收口**(status/stage/presetDone/presetNote…),
51
+ * 调用方必须直接 return,**不要**再写补丁行/声明依赖。
52
+ * 副作用与既有预设通道一致:`job.presetInstalled / job.presetSource / job.presetNote / channelNotes`,
53
+ * 并调用 recordPresetSource 记下来源(这样面板上的「覆盖该预设」能按记录重新取源码)。
54
+ */
55
+ async function installPresetsCarriedByPackage({ job, name, profileDir, repo = null, deps = {} } = {}) {
56
+ const packageName = String(name ?? '').trim()
57
+ if (packageName === '' || profileDir === null || profileDir === undefined) return { handled: false, installed: false, error: null }
58
+ const packageDir = deps.packageDir ?? installedPackageDir(profileDir, packageName)
59
+ if (packageDir === null) return { handled: false, installed: false, error: null }
60
+ const presets = findCarriedPresets(packageDir, Number.isFinite(deps.maxDepth) ? deps.maxDepth : 2)
61
+ if (presets.length === 0) return { handled: false, installed: false, error: null }
62
+
63
+ const presetsRoot = deps.presetsRoot ?? join(dshHome(), '.agent-presets')
64
+ noteChannel(job, `装出来的包 ${packageName} 里带着预设(${presets.map((p) => basename(p)).join('、')}):按预设装配,不注册为插件(它没有插件入口)`)
65
+ const limit = Number.isFinite(deps.limit) ? deps.limit : 8
66
+ const reports = []
67
+ for (const p of presets.slice(0, limit)) {
68
+ // eslint-disable-next-line no-await-in-loop
69
+ const report = assemblePreset(p, basename(p), {
70
+ presetsRoot,
71
+ overwrite: deps.overwrite === true, // 默认**只补不覆盖**(与 0.5.26 的语义一致)
72
+ ...(deps.now === undefined ? {} : { now: deps.now }),
73
+ })
74
+ reports.push({ ...report, srcDir: p })
75
+ }
76
+ const okReports = reports.filter((r) => r.ok === true)
77
+ const failed = reports.filter((r) => r.ok !== true)
78
+ // 同名冲突(默认只补不覆盖时的正常结局):某个预设的现有文件被跳过
79
+ const conflicted = okReports.filter((r) => (r.skipped?.length ?? 0) > 0).map((r) => r.name)
80
+ const summary = reports.map((r) => ({
81
+ name: r.name, dest: r.dest ?? null, ok: r.ok === true, error: r.error ?? null,
82
+ added: r.added?.length ?? 0, skipped: r.skipped?.length ?? 0, overwritten: r.overwritten?.length ?? 0,
83
+ identical: r.identical?.length ?? 0, backup: r.backup ?? null, bytes: r.bytes ?? 0,
84
+ marker: r.ok === true ? 'agent.cordis.yml' : null, carriedBy: packageName,
85
+ }))
86
+ if (job !== null && job !== undefined) {
87
+ job.presetInstalled = summary
88
+ job.presetSource = { repo, packageName, mode: 'in-package', root: packageDir, presets: reports.map((r) => r.name) }
89
+ // 同名冲突(只补不覆盖时的正常结局)→ 面板给**显式**的「覆盖该预设」动作(复用既有动作构造器)
90
+ if (conflicted.length > 0) {
91
+ job.presetOverwriteCandidates = conflicted
92
+ job.suggestedAction = suggestedOverwritePresetAction({
93
+ presetName: conflicted[0],
94
+ repo,
95
+ reason: `已存在同名预设 ${conflicted.join('、')}:默认只补不覆盖(现有文件一个字节都没动)。要用仓库版本覆盖请点这个动作`,
96
+ })
97
+ }
98
+ job.presetNote = `预设已装配:${okReports.map((r) => r.name).join('、') || '(无)'}`
99
+ + `(落盘 ${presetsRoot};**新建会话时选择**)`
100
+ + `;来源:包 ${packageName} 自带的预设目录(该包没有插件入口,已按预设装配、未注册为插件)`
101
+ + (conflicted.length > 0 ? `;同名预设 ${conflicted.join('、')} 默认只补不覆盖(缺的文件已补、现有文件未改动),如需覆盖请点「覆盖该预设」` : '')
102
+ + (failed.length > 0 ? `;${failed.length} 个预设未装配:${failed.map((r) => `${r.name}(${r.error})`).join('、')}` : '')
103
+ job.presetDone = okReports.length > 0
104
+ if (okReports.length > 0) {
105
+ job.status = 'done'
106
+ job.stage = 'done'
107
+ job.candidateDone = true
108
+ job.packageName = packageName
109
+ }
110
+ }
111
+ // 记下来源(面板的「覆盖该预设」要按记录重新取源码):repo 为空时记 null,动作侧会如实告知"没有源码出处"
112
+ try {
113
+ recordPresetSource(
114
+ okReports.map((r) => ({ name: r.name, repo, candidateName: packageName, subdir: null, sparse: false, dest: r.dest })),
115
+ deps.now === undefined ? {} : { now: deps.now },
116
+ )
117
+ } catch {}
118
+ if (okReports.length === 0) {
119
+ return { handled: true, installed: false, presets: summary, error: new Error(`包 ${packageName} 自带预设,但装配全部失败:${failed.map((r) => `${r.name}(${r.error})`).join('、')}`) }
120
+ }
121
+ return {
122
+ handled: true,
123
+ installed: true,
124
+ presets: summary,
125
+ conflicted,
126
+ note: job?.presetNote ?? null,
127
+ error: failed.length > 0 ? new Error(`${failed.length} 个预设未装配:${failed.map((r) => `${r.name}(${r.error})`).join('、')}`) : null,
128
+ }
129
+ }
130
+
131
+ /** 装成功后(或预设源码通道成功后)的**唯一收口**(install-job 只调这一行,它贴着 600 行架构硬顶):
132
+ * · `job.presetDone`(源码通道已装配好)→ 把 job 收口并返回 true;
133
+ * · 落地物里带预设 → 走 installPresetsCarriedByPackage 装配,成功则收口并返回 true;
134
+ * · 其余 → 返回 false(调用方照旧按普通插件收口,行为一个字不变)。 */
135
+ async function settlePresetOutcome({ job, installedName, profileDir, repo = null, deps = {} } = {}) {
136
+ if (job !== null && job !== undefined && job.presetDone === true) {
137
+ job.status = 'done'
138
+ job.stage = 'done'
139
+ job.candidateDone = true
140
+ job.packageName = job.candidateName ?? job.packageName
141
+ return true
142
+ }
143
+ if (typeof installedName !== 'string' || installedName === '') return false
144
+ const carried = await installPresetsCarriedByPackage({ job, name: installedName, profileDir, repo, deps })
145
+ return carried.installed === true
146
+ }
147
+
148
+ export { findCarriedPresets, installedPackageDir, installPresetsCarriedByPackage, settlePresetOutcome }