@noob-stupid/dsh-plugin-console 0.5.26 → 0.5.28
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 +8 -4
- package/lib/server/domain/ai.js +4 -1
- package/lib/server/domain/format-scan.js +4 -1
- package/lib/server/domain/install-job.js +7 -7
- package/lib/server/domain/preset-declare.js +541 -0
- package/lib/server/domain/preset-in-package.js +159 -0
- package/lib/server/domain/preset-install.js +92 -4
- package/lib/server/domain/preset-source.js +27 -18
- package/lib/server/domain/suite.js +21 -3
- package/lib/server/routes/components.js +3 -1
- package/lib/server/routes/framework-preflight.js +12 -3
- package/package.json +1 -1
package/lib/client.js
CHANGED
|
@@ -309,7 +309,11 @@ window.__ModuleLoader__.load({
|
|
|
309
309
|
diagIntegrityNone: "来源无摘要,未校验",
|
|
310
310
|
diagIntegrityMismatch: "摘要不匹配(已安装,未拦截)",
|
|
311
311
|
diagIntegritySkip: "摘要未校验(读取失败,不影响安装)",
|
|
312
|
-
|
|
312
|
+
// 0.5.28 改错:框架 0.1.7-rc.x 起预设改为**声明行**(不再扫 .agent-presets 目录),
|
|
313
|
+
// "新建会话时选择"对没写成声明行的预设是错的。可见性由服务端下发的 presetNote 说清
|
|
314
|
+
// (「已声明为预设行 … 重启实例后在新会话可选」/「文件已就位,但…需要声明行」),
|
|
315
|
+
// 这里只留一句中性提示,不再替服务端断言"选了就能用"。
|
|
316
|
+
presetDot: "。预设:见上方说明(需要声明行才会出现在选择器里)",
|
|
313
317
|
presetInstalledMsg: "预设已装配",
|
|
314
318
|
diagChannelNotes: "通道说明",
|
|
315
319
|
direct: "(浏览器直连 GitHub;若你的浏览器打不开 GitHub,将自动回退到服务端通道)",
|
|
@@ -593,7 +597,7 @@ window.__ModuleLoader__.load({
|
|
|
593
597
|
frameworkUpgradeMore: "展开全部可选版本({n})",
|
|
594
598
|
// 升级前契约预检(2026-09-24 事故:v4 消息格式变更让运行期生产方全部失效)
|
|
595
599
|
fwPreflightBtn: "升级预检",
|
|
596
|
-
fwPreflightTitle: "升级前契约预检:抓目标版本的会话格式契约(迁移边),扫描 agent-presets 与已装插件里的旧形状消息来源;可一键适配并自动备份(.bak-preflight-*)",
|
|
600
|
+
fwPreflightTitle: "升级前契约预检:抓目标版本的会话格式契约(迁移边),扫描 .agent-presets 与已装插件里的旧形状消息来源;可一键适配并自动备份(.bak-preflight-*)",
|
|
597
601
|
fwPreflightRunning: "预检中…",
|
|
598
602
|
fwPreflightClean: "预检通过:本地生产方没有会触发格式校验的旧写法",
|
|
599
603
|
fwPreflightFound: "预检发现 {n} 处必须适配(另有 {m} 处提示)",
|
|
@@ -848,7 +852,7 @@ window.__ModuleLoader__.load({
|
|
|
848
852
|
diagIntegrityNone: "No digest from source; not verified",
|
|
849
853
|
diagIntegrityMismatch: "Digest mismatch (installed, not blocked)",
|
|
850
854
|
diagIntegritySkip: "Digest not verified (read failed; install unaffected)",
|
|
851
|
-
presetDot: ". Preset:
|
|
855
|
+
presetDot: ". Preset: see the note above (a declaration row is required for it to appear)",
|
|
852
856
|
presetInstalledMsg: "Presets installed",
|
|
853
857
|
diagChannelNotes: "Channel notes",
|
|
854
858
|
direct: "(The browser calls GitHub directly; the server channel is used as fallback.)",
|
|
@@ -1132,7 +1136,7 @@ window.__ModuleLoader__.load({
|
|
|
1132
1136
|
frameworkUpgradeMore: "Show all available versions ({n})",
|
|
1133
1137
|
// Pre-upgrade contract preflight (2026-09-24 incident: v4 message-format change broke every runtime producer)
|
|
1134
1138
|
fwPreflightBtn: "Pre-upgrade preflight",
|
|
1135
|
-
fwPreflightTitle: "Pre-upgrade contract preflight: fetch the target session-format contract (migration edges) and scan agent-presets + installed plugins for legacy message sources; one click adapts with .bak-preflight-* backups",
|
|
1139
|
+
fwPreflightTitle: "Pre-upgrade contract preflight: fetch the target session-format contract (migration edges) and scan .agent-presets + installed plugins for legacy message sources; one click adapts with .bak-preflight-* backups",
|
|
1136
1140
|
fwPreflightRunning: "Preflighting…",
|
|
1137
1141
|
fwPreflightClean: "Preflight passed: no legacy producer shape that would fail format validation",
|
|
1138
1142
|
fwPreflightFound: "Preflight found {n} blockers to adapt ({m} advisories)",
|
package/lib/server/domain/ai.js
CHANGED
|
@@ -209,7 +209,10 @@ function aiEmpowerPresetFor(source) {
|
|
|
209
209
|
const common = [
|
|
210
210
|
'## 本机环境事实(务必遵守)',
|
|
211
211
|
'- 网络:pypi.org 不通、清华镜像 403;pip 必须用 `--index-url https://mirrors.aliyun.com/pypi/simple/`。npm registry.npmjs.org 可能黑洞,pnpm 用 `--registry https://registry.npmmirror.com`。huggingface.co 被墙,模型文件一律用 `https://hf-mirror.com/<同一路径>`(执行器会自动把 huggingface.co 换成 hf-mirror.com,计划里也可直接写 hf-mirror)。',
|
|
212
|
-
|
|
212
|
+
// 0.5.28 改错:这条原本把它写成"本机用户名含中文(花火)"的**个案**,读起来像只在
|
|
213
|
+
// 那台机器上成立。真正的规则是通用的:只要**写盘路径**里出现非 ASCII 字符,各语言的
|
|
214
|
+
// Rust/原生向量库就可能报 UnicodeDecodeError —— 与用户名具体是不是中文无关。
|
|
215
|
+
'- 用户名/路径含非 ASCII 字符时:任何写盘路径若含非 ASCII 字符,各语言的 Rust/原生向量库会报 UnicodeDecodeError——服务数据目录必须纯 ASCII(如 `D:/OpenVikingData`)。',
|
|
213
216
|
'- 凭据复用:DSH 的 DeepSeek 密钥在 `${home}/.dsh/.credentials.yaml`(YAML 行 `DEEPSEEK_API_KEY: sk-...`);模型选择在 `${home}/.dsh/settings.yaml` 的 `agent-default-model`(provider deepseek-official)。对外 OpenAI 兼容接口:api_base `https://api.deepseek.com`,模型名 `deepseek-v4-flash-vision-exp`。',
|
|
214
217
|
'- 步骤解释用中文;每个动作一个步骤;步骤数尽量少;不得包含任何破坏性命令;服务类组件必须配 start-service + wait-health。',
|
|
215
218
|
]
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
// L1 · domain —— format-scan.js:会话格式契约预检(Step 2 · 本地生产方扫描 + 补丁)
|
|
2
2
|
//
|
|
3
3
|
// 扫描面(这次事故的三类生产方,正是它们让「一发消息就炸」):
|
|
4
|
-
// ① ~/.dsh
|
|
4
|
+
// ① ~/.dsh/.agent-presets/** —— 用户自己的 agent 预设(10 个文件 11 处)
|
|
5
|
+
// (★ 2026-09-27 改错:这里旧注释写成 `~/.dsh/agent-presets/**`,**少一个点**。
|
|
6
|
+
// 框架的目录是 `.agent-presets`(旧 discovery 的 USER_PRESET_DIR),routes/framework-preflight.js
|
|
7
|
+
// 的扫描面也跟着错了同一个字 —— 于是预检**从未**扫到预设,升级前"0 blocker"是假绿。)
|
|
5
8
|
// ② profile 内已装插件包里的运行时代码 —— agent.inject / createUserMessage 的构造点(3 处)
|
|
6
9
|
// ③ 插件里引用了目标版本已移除的框架 API(报告项,不自动改)
|
|
7
10
|
//
|
|
@@ -16,6 +16,7 @@ import { classifyInstallFailure, retryPnpmOnce } from './install-diagnose.js'
|
|
|
16
16
|
import { jobLog } from './jobs.js'
|
|
17
17
|
import { gatePrivateRoot } from './repo-size.js'
|
|
18
18
|
import { tryPresetSourceChannel } from './preset-source.js'
|
|
19
|
+
import { settlePresetOutcome } from './preset-in-package.js'
|
|
19
20
|
import { alreadyInstalledTarget, verifyInstalledEntry } from './install-verify.js'
|
|
20
21
|
import { applyNpmNameHint, expandSubpackageCandidates, fetchRepoPackage, fetchRepoPackageEx, fetchSubpackageNames, npmNameHintForRepo, packageProbeErrorText, subpackageCandidates } from './market.js'
|
|
21
22
|
import { declareProfileDependency } from './manifest.js'
|
|
@@ -71,6 +72,8 @@ function channelImpls(ports) {
|
|
|
71
72
|
* 属无意义尝试)**并保留 !expanded**(同一作业里对同一个 job.repo 反复 clone 纯属浪费时间,
|
|
72
73
|
* 级联顺序也不该被破坏)。 */
|
|
73
74
|
async function tryCandidateChannels({ job, ch, name, profileDir, registries, repoChannelAllowed, budget, baseUrl = null, expanded = false, deadline = null, expand = null, sourceDeps = {} }) {
|
|
75
|
+
// 声明行的目标 profile(0.5.28):框架 0.1.7-rc.x 起预设不再靠目录发现,装配后必须往这个 profile 的补丁写 `preset-<id>` 行,否则用户看不见(见 preset-declare.js 顶部)。
|
|
76
|
+
const patchPath = join(profileDir, 'cordis.patch.yml')
|
|
74
77
|
let installedName = null
|
|
75
78
|
let lastError = null
|
|
76
79
|
// 加法优化:包已在 node_modules 且名字匹配时,不再重复安装/触发 EPERM,直接进入启用流程
|
|
@@ -218,7 +221,7 @@ async function runInstallJob(job, ports, deps = {}) {
|
|
|
218
221
|
probes.expandSubpackages = (opts = {}) => expandSubpackageCandidates({ ...opts, deps: { fetchSubpackageNames: probes.fetchSubpackageNames } })
|
|
219
222
|
}
|
|
220
223
|
const jobBudgetMs = Number.isFinite(deps.jobBudgetMs) && deps.jobBudgetMs > 0 ? deps.jobBudgetMs : 8 * 60 * 1000
|
|
221
|
-
// 源码通道(体积门禁 + 稀疏取源码 + 预设装配)的注入缝:与 marketProbes
|
|
224
|
+
// 源码通道(体积门禁 + 稀疏取源码 + 预设装配)的注入缝:与 marketProbes 同一风格,生产调用方不传。
|
|
222
225
|
const sourceDeps = deps.sourceDeps ?? {}
|
|
223
226
|
const aiConsentTimeoutMs = Number.isFinite(deps.aiConsentTimeoutMs) && deps.aiConsentTimeoutMs > 0 ? deps.aiConsentTimeoutMs : AI_CONSENT_TIMEOUT_MS
|
|
224
227
|
/** 「安装即声明」的收尾(三个调用点共用一个出口):清单写回结果 → job 上的可见字段。
|
|
@@ -409,10 +412,9 @@ async function runInstallJob(job, ports, deps = {}) {
|
|
|
409
412
|
lastError = attempt.lastError
|
|
410
413
|
if (installedName !== null) break
|
|
411
414
|
}
|
|
412
|
-
//
|
|
413
|
-
//
|
|
414
|
-
|
|
415
|
-
if (job.presetDone === true) { job.status = 'done'; job.stage = 'done'; job.candidateDone = true; job.packageName = job.candidateName ?? job.packageName; return }
|
|
415
|
+
// 预设收口(D-③ 源码装配成功 / D-⑥ 落地物里带预设):能按预设收口就直接 return —— 见 preset-in-package.js
|
|
416
|
+
// (这两条路都**没有 node_modules 插件**,写补丁行/声明依赖会让补丁行指向无入口模块 → 启动崩溃)。
|
|
417
|
+
if (await settlePresetOutcome({ job, installedName, profileDir, repo: job.repo, deps: sourceDeps }) === true) return
|
|
416
418
|
if (installedName === null) {
|
|
417
419
|
if (job.diagnosis == null) job.diagnosis = classifyInstallFailure(lastError?.message) // 分类提示兜底(加法)
|
|
418
420
|
// 本地 AI 兜底会调用模型 API、产生费用:挂起等待用户明确同意后再执行
|
|
@@ -544,7 +546,6 @@ async function runInstallJob(job, ports, deps = {}) {
|
|
|
544
546
|
job.finishedAt = Date.now()
|
|
545
547
|
}
|
|
546
548
|
}
|
|
547
|
-
|
|
548
549
|
/** 用 corepack pnpm 移除包(与安装同一管理器与凭据抑制环境)。
|
|
549
550
|
* 注意:pnpm remove 不支持 --registry 选项,去掉避免静默失败。 */
|
|
550
551
|
async function pnpmRemove(profileDir, packageName) {
|
|
@@ -562,7 +563,6 @@ async function pnpmRemove(profileDir, packageName) {
|
|
|
562
563
|
// 与 pnpmInstall 同一套跨平台定位(Linux 的 corepack 不在 <node bin>/node_modules 下)
|
|
563
564
|
await runPnpmWithFallback(args, { execOpts: opts })
|
|
564
565
|
}
|
|
565
|
-
|
|
566
566
|
/** 读取用户额外 bundle(非官方模板)的补丁插入行 id 与包名,用于"额外插件"判定。 */
|
|
567
567
|
async function readExtraBundleRows(profileDir) {
|
|
568
568
|
const rows = new Set()
|
|
@@ -0,0 +1,541 @@
|
|
|
1
|
+
// L1 · domain —— preset-declare.js(预设**声明行**:把 `.agent-presets/<id>/` 的内容写成 profile 补丁里的一行)
|
|
2
|
+
//
|
|
3
|
+
// 为什么必须有它(**框架 0.1.7-rc.x 的机制迁移**,2026-09-27 查实):
|
|
4
|
+
// · **0.1.5 及更早**:`@deepseek-ai/dsh-agent-presets` 扫描 `$DSH_HOME/.agent-presets/` 目录发现预设
|
|
5
|
+
// (常量 `USER_PRESET_DIR = '.agent-presets'`,逐项校验 `PRESET_ID = /^[a-z0-9][a-z0-9-]*$/`)。
|
|
6
|
+
// · **0.1.7-rc.2 起**:该包**不在依赖图里、也没被挂载**;`dsh-agent-preset-registry` 的 `definitions`
|
|
7
|
+
// 是**内存 Map,没有任何 fs 调用** —— 目录发现**彻底没了**。
|
|
8
|
+
// · 现在的载体是 profile `cordis.patch.yml` 里的**声明行**:
|
|
9
|
+
// - insert:
|
|
10
|
+
// - id: preset-<x>
|
|
11
|
+
// name: '@deepseek-ai/dsh-agent-preset'
|
|
12
|
+
// config: { id, name?, description?, order?, plugins }
|
|
13
|
+
// schema 见 `@deepseek-ai/dsh-agent-preset@0.1.7-rc.2/lib/index.js:13-25`(id/plugins 必填,name/description/order 可选)。
|
|
14
|
+
// **只有落了文件、没有声明行的预设,用户在界面上根本看不到**(桌面端「自定义」分组为空就是这个原因);
|
|
15
|
+
// 更糟的是老会话 resume 会 `RemoteError: Unknown agent preset: <id>`。
|
|
16
|
+
//
|
|
17
|
+
// 本模块只做一件事:**幂等地**把声明行写进目标 profile 的补丁文件(先备份 → 写 → 读回核实)。
|
|
18
|
+
// · 行 id 固定 `preset-<id>`(与官方内置 `preset-standard` / `preset-minimal` … 同一命名法);
|
|
19
|
+
// · 同 id 行已存在 → **原地更新**,绝不重复插入(重复会让注册表 `Duplicate agent preset` 直接抛);
|
|
20
|
+
// · 块内容与磁盘预设逐字节一致 → **一个字节都不写、不建备份**(真正的幂等);
|
|
21
|
+
// · 写不成 → 如实返回 `ok:false + reason`,调用方**不许**再宣称"新建会话时选择"。
|
|
22
|
+
//
|
|
23
|
+
// ⚠️ **相对路径基准变了**(最容易踩的一脚):声明行在 **profile 补丁**里,所以行内 `./x.mjs`
|
|
24
|
+
// 的相对基准是 **profile 目录**而不是预设目录。故 composition 里所有"确实存在于预设目录"的
|
|
25
|
+
// `./x.mjs` 一律改写成 `file:///` 绝对 URL(`pathToFileURL` 会做百分号编码,中文用户名也安全)。
|
|
26
|
+
// 预设目录里**不存在**的同名文件**原样保留** —— 那种相对引用在 profile 下本来就不通,
|
|
27
|
+
// 我们没有依据替它猜一个绝对路径,硬改只会把指向别处的引用改坏(如实保留,不猜)。
|
|
28
|
+
//
|
|
29
|
+
// 分层:L1 domain —— 不认识 cordis ctx;时间戳 / 文件 IO / URL 转换均可注入(便于离线断言)。
|
|
30
|
+
// 行数预算:本模块与 preset-install.js 都必须留在 600 行架构硬顶之内(test-architecture-guard.mjs)。
|
|
31
|
+
|
|
32
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
33
|
+
import { basename, dirname, join, resolve } from 'node:path'
|
|
34
|
+
import { pathToFileURL } from 'node:url'
|
|
35
|
+
import { queuedWrite } from '../infra/fsx.js'
|
|
36
|
+
import { validateAgentConfig } from './preset-yaml.js'
|
|
37
|
+
|
|
38
|
+
/** 声明行指向的模块名(框架侧预设插件,0.1.7-rc.2 起才有)。 */
|
|
39
|
+
const AGENT_PRESET_MODULE = '@deepseek-ai/dsh-agent-preset'
|
|
40
|
+
/** 行 id 前缀:官方内置行是 `preset-standard` / `preset-minimal` / `preset-ptc` / `preset-cordis`,照抄同一命名法。 */
|
|
41
|
+
const PRESET_ROW_PREFIX = 'preset-'
|
|
42
|
+
/** composition 的嵌套缩进:`plugins:` 在 `config:` 下 = 8 空格,其内容再缩进 2 = **10 空格**
|
|
43
|
+
* (与真机既有 `preset-router-*` 行逐字节同形)。 */
|
|
44
|
+
const CONFIG_KEY_INDENT = 8
|
|
45
|
+
const COMPOSITION_INDENT = CONFIG_KEY_INDENT + 2
|
|
46
|
+
|
|
47
|
+
/** 预设 id 判据(与旧 discovery 的 `PRESET_ID = /^[a-z0-9][a-z0-9-]*$/` 同源,另容忍下划线/点以便通用)。
|
|
48
|
+
* 为什么必须校验:id 会进 YAML 的 `id:` 与注册表的 Map 键,形状可疑的值宁可拒绝也不写进用户补丁。 */
|
|
49
|
+
function isValidPresetId(id) {
|
|
50
|
+
return typeof id === 'string' && /^[a-z0-9][a-z0-9._-]*$/u.test(id)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** YAML 单引号标量(`'` 用 `''` 转义;换行折成空格——单行值最不容易写坏用户补丁)。 */
|
|
54
|
+
function quoteScalar(value) {
|
|
55
|
+
const text = String(value).replace(/\r?\n/gu, ' ').trim()
|
|
56
|
+
return `'${text.replace(/'/gu, "''")}'`
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** 归一 composition 文本:CRLF → LF、去尾部空行、正好一个结尾换行。 */
|
|
60
|
+
function normalizeComposition(text) {
|
|
61
|
+
return `${String(text).replace(/\r\n/gu, '\n').replace(/\s+$/u, '')}\n`
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** 读预设清单(`preset.yml` 的 name/description/order;读不到返回 null,不抛)。
|
|
65
|
+
* **本模块是它的唯一定义处**:preset-source.js 从这里 re-export —— 这样依赖方向是
|
|
66
|
+
* `preset-source → preset-declare`(单向),不会出现"声明模块反过来依赖取源码模块"的循环。 */
|
|
67
|
+
function readPresetManifest(dir) {
|
|
68
|
+
try {
|
|
69
|
+
const text = readFileSync(join(dir, 'preset.yml'), 'utf8')
|
|
70
|
+
const pick = (key) => {
|
|
71
|
+
const m = text.match(new RegExp(`^\\s*${key}\\s*:\\s*(.+)$`, 'mu'))
|
|
72
|
+
return m === null ? null : m[1].trim().replace(/^["']|["']$/gu, '')
|
|
73
|
+
}
|
|
74
|
+
return { name: pick('name'), description: pick('description'), order: pick('order') }
|
|
75
|
+
} catch {
|
|
76
|
+
return null
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** 读预设目录里的 composition(`agent.cordis.yml` 优先,回退 `.yaml`)。读不到返回 null。 */
|
|
81
|
+
function readPresetComposition(dir) {
|
|
82
|
+
for (const name of ['agent.cordis.yml', 'agent.cordis.yaml']) {
|
|
83
|
+
const file = join(dir, name)
|
|
84
|
+
try {
|
|
85
|
+
if (existsSync(file)) return { file, text: readFileSync(file, 'utf8') }
|
|
86
|
+
} catch {}
|
|
87
|
+
}
|
|
88
|
+
return null
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* 读预设展示元数据(`preset.yml` 的 name/description/order),带**合理缺省**:
|
|
93
|
+
* · name 缺省 = 目录名(面板至少有个能认的名字;绝不发 `name: ''` 这种空标量);
|
|
94
|
+
* · description 缺省 = 不提这个键(可选字段,比编一句话诚实);
|
|
95
|
+
* · order 缺省 = 1000(排在内置预设之后,不抢它们的位置)。
|
|
96
|
+
* `preset.yml` 缺 name 时**也**回退目录名(readPresetManifest 会返回 null 值的 name)。
|
|
97
|
+
*/
|
|
98
|
+
function presetDisplayMeta(dir, id) {
|
|
99
|
+
const raw = (() => {
|
|
100
|
+
try { return readPresetManifest(dir) } catch { return null }
|
|
101
|
+
})() ?? {}
|
|
102
|
+
const name = typeof raw.name === 'string' && raw.name.trim() !== '' ? raw.name.trim() : String(id)
|
|
103
|
+
const description = typeof raw.description === 'string' && raw.description.trim() !== '' ? raw.description.trim() : null
|
|
104
|
+
const orderNum = Number(raw.order)
|
|
105
|
+
const order = Number.isFinite(orderNum) ? orderNum : 1000
|
|
106
|
+
return { name, description, order }
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* composition 里的相对文件引用 → `file:///` 绝对 URL(**只改真存在于预设目录的那些**)。
|
|
111
|
+
* 形态判据:`name:` 的值是 `./x` 或 `../x`(`.mjs`/`.js`/`.cjs`/`.json`/任意相对文件)。
|
|
112
|
+
* 返回 `{ text, rewritten[] }`:rewritten 逐条记下 `<相对> → <file: URL>`,供调用方如实上报。
|
|
113
|
+
*/
|
|
114
|
+
function absolutizeComposition(text, dir, { toFileUrl = pathToFileURL, exists = existsSync } = {}) {
|
|
115
|
+
const rewritten = []
|
|
116
|
+
const out = String(text).replace(/^([ \t]*name:[ \t]*)(\.\.?\/[^\s#]+)[ \t]*$/gmu, (all, head, spec) => {
|
|
117
|
+
let target = null
|
|
118
|
+
try { target = resolve(dir, spec) } catch { return all }
|
|
119
|
+
let present = false
|
|
120
|
+
try { present = exists(target) === true } catch { present = false }
|
|
121
|
+
if (!present) return all // 不在预设目录里 → 原样保留(我们没有依据替它猜路径,不猜)
|
|
122
|
+
let url = null
|
|
123
|
+
try { url = toFileUrl(target).href } catch { return all }
|
|
124
|
+
rewritten.push({ from: spec, to: url })
|
|
125
|
+
return `${head}${url}`
|
|
126
|
+
})
|
|
127
|
+
return { text: out, rewritten }
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** 把 composition 整段缩进成 `plugins:` 的嵌套列表(空行保持空行,不留尾随空格)。 */
|
|
131
|
+
function indentComposition(text, spaces = COMPOSITION_INDENT) {
|
|
132
|
+
const pad = ' '.repeat(spaces)
|
|
133
|
+
return String(text)
|
|
134
|
+
.replace(/\r\n/gu, '\n')
|
|
135
|
+
.split('\n')
|
|
136
|
+
.map((line) => (line.trim() === '' ? '' : `${pad}${line}`))
|
|
137
|
+
.join('\n')
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** 渲染一行完整的预设声明(`- insert:` 顶级项 + `- id:` 子项 + `config:`)。 */
|
|
141
|
+
function renderPresetRow(config, compositionText) {
|
|
142
|
+
const lines = [
|
|
143
|
+
'- insert:',
|
|
144
|
+
` - id: ${PRESET_ROW_PREFIX}${config.id}`,
|
|
145
|
+
` name: '${AGENT_PRESET_MODULE}'`,
|
|
146
|
+
' config:',
|
|
147
|
+
` id: ${config.id}`,
|
|
148
|
+
]
|
|
149
|
+
if (typeof config.name === 'string' && config.name !== '') lines.push(` name: ${quoteScalar(config.name)}`)
|
|
150
|
+
if (typeof config.description === 'string' && config.description !== '') lines.push(` description: ${quoteScalar(config.description)}`)
|
|
151
|
+
if (config.order !== null && config.order !== undefined) lines.push(` order: ${config.order}`)
|
|
152
|
+
lines.push(' plugins:')
|
|
153
|
+
lines.push(indentComposition(compositionText).replace(/\s+$/u, ''))
|
|
154
|
+
return lines.join('\n')
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** 声明成功后那句核心文案(**成功/未改动两条路共用**,避免一边说"重启后可选"、另一边忘了说)。 */
|
|
158
|
+
function presetDeclarationSentence(rowId, patchPath, { unchanged = false } = {}) {
|
|
159
|
+
const tail = unchanged ? '(此前已声明、内容一致,本次未改动任何字节)' : ''
|
|
160
|
+
return `已声明为预设行 ${rowId}(写入 ${patchPath})${tail};**重启实例后**在新会话可选`
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* 找一个 `- insert:` 块的**结束**下标(独占)—— 这是本模块最容易写错的一处,故单独成函数。
|
|
165
|
+
*
|
|
166
|
+
* 为什么不能只判"下一个列 0 的 `^- `":那样在**声明行是文件最后一块**时(我们新写的行正是如此),
|
|
167
|
+
* 块的边界会一路吃到文件末尾,于是行级的 ` name: '…'` 被当成 `config.plugins` 的内容 ——
|
|
168
|
+
* 读回核实会报"plugins 与 agent.cordis.yml 不一致",而文件其实完全正确(自测真抓到过这个假红)。
|
|
169
|
+
*
|
|
170
|
+
* 判据(与真机 `dev-*.yml` 的缩进惯例一致:insert 子项 4 空格、行级键 6 空格、嵌套 6+):
|
|
171
|
+
* · 列 0 的 `^- ` → 新的顶级条目,块到此为止;
|
|
172
|
+
* · 缩进 **≤ 2** 的非空行 → 也是新的顶级条目(写成 `- id: x` 或 `-id` 都算);
|
|
173
|
+
* · 缩进 ≥ 4 → 仍是本块内容(`- id:` / `name:` / `config:` / composition…)。
|
|
174
|
+
* 返回下标 **不含**块尾的空行(尾随空行属于块与块之间的分隔,不该被搬来搬去)。
|
|
175
|
+
*/
|
|
176
|
+
function findInsertBlockEnd(lines, start) {
|
|
177
|
+
let end = lines.length
|
|
178
|
+
let lastContent = start
|
|
179
|
+
for (let j = start + 1; j < lines.length; j += 1) {
|
|
180
|
+
const line = lines[j]
|
|
181
|
+
if (line.trim() === '') continue
|
|
182
|
+
const indent = (line.match(/^ */u) ?? [''])[0].length
|
|
183
|
+
if (/^- /u.test(line) || indent <= 2) { end = j; break }
|
|
184
|
+
lastContent = j
|
|
185
|
+
}
|
|
186
|
+
if (end === lines.length) return lastContent + 1
|
|
187
|
+
return end
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* 从整个补丁文件文本里找出**所有** `preset-*` 声明行(含它们的块边界)。
|
|
192
|
+
* 返回 `[{ id, start, end, text }]`(end 为**独占**下标,指向块后的第一个顶级行或文件末)。
|
|
193
|
+
*/
|
|
194
|
+
function scanPresetRows(text) {
|
|
195
|
+
const lines = String(text ?? '').split(/\r?\n/u)
|
|
196
|
+
const rows = []
|
|
197
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
198
|
+
if (!/^- insert:\s*$/u.test(lines[i])) continue
|
|
199
|
+
const end = findInsertBlockEnd(lines, i)
|
|
200
|
+
let id = null
|
|
201
|
+
for (let j = i + 1; j < end; j += 1) {
|
|
202
|
+
const m = lines[j].match(new RegExp(`^ {4}- id: (${PRESET_ROW_PREFIX}[A-Za-z0-9._-]+)\\s*$`, 'u'))
|
|
203
|
+
if (m !== null) { id = m[1]; break }
|
|
204
|
+
}
|
|
205
|
+
if (id === null) { i = Math.max(i, end - 1); continue }
|
|
206
|
+
rows.push({ id, start: i, end, text: lines.slice(i, end).join('\n').replace(/\s+$/u, '') })
|
|
207
|
+
i = Math.max(i, end - 1)
|
|
208
|
+
}
|
|
209
|
+
return rows
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** 把一个区间(行下标,独占 end)从行数组里删掉。 */
|
|
213
|
+
function spliceOut(lines, start, end) {
|
|
214
|
+
return lines.slice(0, start).concat(lines.slice(end))
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** 收拾块间空行:不留连续空行,不留文件头/尾空行;结尾正好一个换行。 */
|
|
218
|
+
function tidyBlankLines(lines) {
|
|
219
|
+
const out = []
|
|
220
|
+
for (const line of lines) {
|
|
221
|
+
if (line.trim() === '' && out.length > 0 && out[out.length - 1].trim() === '') continue
|
|
222
|
+
out.push(line)
|
|
223
|
+
}
|
|
224
|
+
while (out.length > 0 && out[0].trim() === '') out.shift()
|
|
225
|
+
while (out.length > 0 && out[out.length - 1].trim() === '') out.pop()
|
|
226
|
+
return `${out.join('\n')}\n`
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* 把声明行**幂等**地写进补丁文本(纯文本变换,不碰盘;写盘与核实见 declarePresetRow)。
|
|
231
|
+
* 返回 `{ text, changed, created, removed }`:
|
|
232
|
+
* · 同 id 已有行 → 用新块替换(`created:false`);`removed` 记下被清掉的重复行 id;
|
|
233
|
+
* · 没有 → 追加到末尾(`created:true`);
|
|
234
|
+
* · 新块与旧块逐字节一致 → `changed:false`,`text` 与输入一致(**真正的幂等**)。
|
|
235
|
+
* 老的 `[]` 顶层占位符在这里就被去掉(issue #7 事故:`[]` 后面再跟条目是非法 YAML,启动直接崩)。
|
|
236
|
+
*/
|
|
237
|
+
function upsertPresetRow(patchText, rowText, rowId) {
|
|
238
|
+
const original = String(patchText ?? '')
|
|
239
|
+
const withoutPlaceholder = original
|
|
240
|
+
.split(/\r?\n/u)
|
|
241
|
+
.filter((line) => !/^\s*\[\s*\]\s*$/u.test(line))
|
|
242
|
+
.join('\n')
|
|
243
|
+
const lines = withoutPlaceholder.split(/\r?\n/u)
|
|
244
|
+
const rows = scanPresetRows(lines.join('\n'))
|
|
245
|
+
const same = rows.filter((r) => r.id === rowId)
|
|
246
|
+
// 块尾补一个空行 = 与下一个顶级条目之间留出分隔(补丁文件的可读性;YAML 里空行无意义)。
|
|
247
|
+
// 旧实现不带它,于是"原地替换"会把原来那句 `- id: …` 贴到 composition 的最后一行上(自测抓到的形状退化)。
|
|
248
|
+
const block = [...rowText.split('\n'), '']
|
|
249
|
+
if (same.length > 0) {
|
|
250
|
+
if (same.length === 1 && same[0].text === rowText) {
|
|
251
|
+
const tidy = tidyBlankLines(lines)
|
|
252
|
+
return { text: tidy, changed: tidy !== original, created: false, removed: [] }
|
|
253
|
+
}
|
|
254
|
+
// 从后往前替换/删除,下标才不会失效
|
|
255
|
+
const removed = []
|
|
256
|
+
const ordered = [...same].sort((a, b) => b.start - a.start)
|
|
257
|
+
let next = lines
|
|
258
|
+
for (const [index, row] of ordered.entries()) {
|
|
259
|
+
if (index === 0) {
|
|
260
|
+
next = next.slice(0, row.start).concat(block, next.slice(row.end))
|
|
261
|
+
} else {
|
|
262
|
+
removed.push(row.id)
|
|
263
|
+
next = spliceOut(next, row.start, row.end)
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
return { text: tidyBlankLines(next), changed: true, created: false, removed }
|
|
267
|
+
}
|
|
268
|
+
const inserted = [...lines]
|
|
269
|
+
while (inserted.length > 0 && inserted[inserted.length - 1].trim() === '') inserted.pop()
|
|
270
|
+
inserted.push(...block)
|
|
271
|
+
return { text: tidyBlankLines(inserted), changed: true, created: true, removed: [] }
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// ── 读回核实(**写后必做**):YAML 关键字段比对,逐条给出证据 ────────────────────────────
|
|
275
|
+
|
|
276
|
+
/** 从补丁文本里抽出某个 preset 行的 `config:` 段落,转成「键 → 值」的浅表(只认 config 的直接子键)。
|
|
277
|
+
* 值一律做"去掉包裹引号 + 折行拼接"的归一,用来做**关键字段比对**(不是通用 YAML 解析器)。 */
|
|
278
|
+
function readPresetRowConfig(patchText, rowId) {
|
|
279
|
+
const lines = String(patchText ?? '').split(/\r?\n/u)
|
|
280
|
+
const rows = scanPresetRows(lines.join('\n'))
|
|
281
|
+
const hit = rows.find((r) => r.id === rowId)
|
|
282
|
+
if (hit === undefined) return null
|
|
283
|
+
const block = lines.slice(hit.start, hit.end)
|
|
284
|
+
let cfgAt = -1
|
|
285
|
+
for (let i = 0; i < block.length; i += 1) {
|
|
286
|
+
if (new RegExp(`^ {6}config:\\s*$`, 'u').test(block[i])) { cfgAt = i; break }
|
|
287
|
+
}
|
|
288
|
+
if (cfgAt === -1) return null
|
|
289
|
+
const fields = {}
|
|
290
|
+
const plugins = []
|
|
291
|
+
let inPlugins = false
|
|
292
|
+
for (let i = cfgAt + 1; i < block.length; i += 1) {
|
|
293
|
+
const line = block[i]
|
|
294
|
+
if (line.trim() === '' || /^\s*#/u.test(line)) continue
|
|
295
|
+
const indent = (line.match(/^ */u) ?? [''])[0].length
|
|
296
|
+
if (indent < CONFIG_KEY_INDENT) break // 回到行级键(`- id:` / `name:`)
|
|
297
|
+
if (indent === CONFIG_KEY_INDENT) {
|
|
298
|
+
// ⚠️ `^ {8}`(= 至少 8 空格,后面直接是键名)而**不是**"正好 8 空格"的写法 —— 后者在 config 是
|
|
299
|
+
// 文档**最后一块**时会把每一行都当成 `plugins` 的内容,config 字段全部读成 undefined,
|
|
300
|
+
// 于是"写成功了却报核实不通过"(自测真抓到过)。
|
|
301
|
+
const kv = line.match(new RegExp(`^ {${CONFIG_KEY_INDENT}}([A-Za-z_][\\w-]*):\\s*(.*)$`, 'u'))
|
|
302
|
+
if (kv === null) continue
|
|
303
|
+
inPlugins = kv[1] === 'plugins'
|
|
304
|
+
if (!inPlugins) fields[kv[1]] = kv[2].trim()
|
|
305
|
+
continue
|
|
306
|
+
}
|
|
307
|
+
if (inPlugins) plugins.push(line.slice(COMPOSITION_INDENT))
|
|
308
|
+
}
|
|
309
|
+
return { fields, plugins }
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** 把「键 → 原始标量」归一成可比较的值(去引号、`''`→`'`、数字字面量转数字)。 */
|
|
313
|
+
function normalizeScalar(raw) {
|
|
314
|
+
if (raw === undefined || raw === null) return null
|
|
315
|
+
const text = String(raw).trim()
|
|
316
|
+
if (text === '') return ''
|
|
317
|
+
if (text.startsWith("'") && text.endsWith("'") && text.length >= 2) return text.slice(1, -1).replace(/''/gu, "'")
|
|
318
|
+
if (text.startsWith('"') && text.endsWith('"') && text.length >= 2) return text.slice(1, -1)
|
|
319
|
+
const num = Number(text)
|
|
320
|
+
return Number.isFinite(num) && text !== '' ? num : text
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* 写后读回核实:① 行在;② 模块名对;③ `config.id` 对;④ name/description/order 与期望一致;
|
|
325
|
+
* ⑤ `plugins` 与磁盘 `agent.cordis.yml` 逐行一致(已把相对引用改写成 file: URL 之后);
|
|
326
|
+
* ⑥ 没有残留的相对 `./` 引用(除非源目录里本来就没有那个文件)。
|
|
327
|
+
* 返回 `{ ok, problems[], checked }` —— 任一条不通过都算不通过(绝不"写没写成"靠假设)。
|
|
328
|
+
*/
|
|
329
|
+
function verifyDeclaredRow(patchText, { rowId, config, compositionText }) {
|
|
330
|
+
const problems = []
|
|
331
|
+
const block = scanPresetRows(patchText).find((r) => r.id === rowId)
|
|
332
|
+
if (block === undefined) {
|
|
333
|
+
return { ok: false, problems: [`读回时找不到声明行 ${rowId}`], checked: 0 }
|
|
334
|
+
}
|
|
335
|
+
if (!block.text.includes(`name: '${AGENT_PRESET_MODULE}'`)) problems.push(`声明行的 name 不是 ${AGENT_PRESET_MODULE}`)
|
|
336
|
+
const parsed = readPresetRowConfig(patchText, rowId)
|
|
337
|
+
if (parsed === null) problems.push('声明行里读不到 config 段')
|
|
338
|
+
else {
|
|
339
|
+
const gotId = normalizeScalar(parsed.fields.id)
|
|
340
|
+
if (gotId !== config.id) problems.push(`config.id 读回是 ${JSON.stringify(gotId)},期望 ${JSON.stringify(config.id)}`)
|
|
341
|
+
if (typeof config.name === 'string' && normalizeScalar(parsed.fields.name) !== config.name) {
|
|
342
|
+
problems.push(`config.name 读回是 ${JSON.stringify(normalizeScalar(parsed.fields.name))},期望 ${JSON.stringify(config.name)}`)
|
|
343
|
+
}
|
|
344
|
+
if (typeof config.description === 'string' && normalizeScalar(parsed.fields.description) !== config.description) {
|
|
345
|
+
problems.push('config.description 与源 preset.yml 不一致')
|
|
346
|
+
}
|
|
347
|
+
if (config.order !== null && config.order !== undefined && normalizeScalar(parsed.fields.order) !== config.order) {
|
|
348
|
+
problems.push(`config.order 读回是 ${JSON.stringify(normalizeScalar(parsed.fields.order))},期望 ${JSON.stringify(config.order)}`)
|
|
349
|
+
}
|
|
350
|
+
const want = normalizeComposition(compositionText).replace(/\s+$/u, '').split('\n')
|
|
351
|
+
// 注释/空行不做逐字比对:YAML 里它们不是数据(config 顶层注释会被解析器丢掉,缩进更深的
|
|
352
|
+
// 注释会作为块标量内容留下)—— 逐字比对会把"文件完全正确"判成不一致(自测真抓到过这个假红)。
|
|
353
|
+
// 真正要钉的是**配置行**逐行一致,那才是框架读进去的东西。
|
|
354
|
+
const significant = (list) => list.filter((line) => line.trim() !== '' && !/^\s*#/u.test(line))
|
|
355
|
+
if (significant(parsed.plugins).join('\n') !== significant(want).join('\n')) {
|
|
356
|
+
const at = significant(want).findIndex((line, i) => significant(parsed.plugins)[i] !== line)
|
|
357
|
+
problems.push(`config.plugins 与 agent.cordis.yml 不一致(第 ${at + 1} 行起)`)
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
const residual = block.text.split('\n').filter((line) => /^\s+name:\s*\.\.?\//u.test(line))
|
|
361
|
+
if (residual.length > 0) problems.push(`声明行里仍有 ${residual.length} 处相对引用未改写:${residual[0].trim()}`)
|
|
362
|
+
return { ok: problems.length === 0, problems, checked: 6 }
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// ── 落盘入口 ────────────────────────────────────────────────────────────────────────
|
|
366
|
+
|
|
367
|
+
/** 组装一行预设声明的**全部输入**(纯函数式,不写盘):composition / config / rowText。 */
|
|
368
|
+
function buildPresetDeclaration(dir, id, opts = {}) {
|
|
369
|
+
const presetId = String(id ?? '').trim()
|
|
370
|
+
if (!isValidPresetId(presetId)) return { ok: false, reason: 'invalid-preset-id', detail: `预设 id 不合法:${JSON.stringify(id)}` }
|
|
371
|
+
if (typeof dir !== 'string' || dir === '' || !existsSync(dir)) {
|
|
372
|
+
return { ok: false, reason: 'preset-dir-missing', detail: `预设目录不存在:${dir}` }
|
|
373
|
+
}
|
|
374
|
+
const composition = readPresetComposition(dir)
|
|
375
|
+
if (composition === null) return { ok: false, reason: 'no-composition', detail: `预设目录里没有 agent.cordis.yml/.yaml:${dir}` }
|
|
376
|
+
const verdict = validateAgentConfig(composition.text)
|
|
377
|
+
if (verdict.ok !== true) {
|
|
378
|
+
return { ok: false, reason: 'invalid-composition', detail: `agent.cordis.yml 未通过结构校验:${verdict.problems[0] ?? ''}` }
|
|
379
|
+
}
|
|
380
|
+
const absolutized = absolutizeComposition(composition.text, dir, opts)
|
|
381
|
+
const compositionText = normalizeComposition(absolutized.text)
|
|
382
|
+
const meta = presetDisplayMeta(dir, presetId)
|
|
383
|
+
const config = { id: presetId, name: meta.name, description: meta.description, order: meta.order }
|
|
384
|
+
const rowId = `${PRESET_ROW_PREFIX}${presetId}`
|
|
385
|
+
return {
|
|
386
|
+
ok: true, id: presetId, rowId, dir, config, compositionText,
|
|
387
|
+
compositionFile: composition.file, rewritten: absolutized.rewritten,
|
|
388
|
+
rowText: renderPresetRow(config, compositionText),
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/** 补丁文件路径:显式 `patchPath` 优先,否则 `profileDir/cordis.patch.yml`;两者都没有 → null。 */
|
|
393
|
+
function resolvePatchPath({ patchPath = null, profileDir = null } = {}) {
|
|
394
|
+
if (typeof patchPath === 'string' && patchPath !== '') return patchPath
|
|
395
|
+
if (typeof profileDir === 'string' && profileDir !== '') return join(profileDir, 'cordis.patch.yml')
|
|
396
|
+
return null
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/** 备份名:`<补丁>.bak-preset-<时间戳>`(与既有 `.bak-<ts>` 家族同形状,可被同一套清理识别)。
|
|
400
|
+
* 已存在就自增后缀 —— 同一毫秒的两次声明绝不互相覆盖(F4 的同族教训)。 */
|
|
401
|
+
function presetRowBackupPath(patchPath, timeValue, exists = existsSync) {
|
|
402
|
+
const base = `${patchPath}.bak-preset-${timeValue}`
|
|
403
|
+
if (!exists(base)) return base
|
|
404
|
+
for (let n = 2; n < 1000; n += 1) {
|
|
405
|
+
const candidate = `${base}-${n}`
|
|
406
|
+
if (!exists(candidate)) return candidate
|
|
407
|
+
}
|
|
408
|
+
return `${base}-${Date.now()}`
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* **装配预设后写声明行**(唯一写盘点)。返回结构化结果,字段全部如实:
|
|
413
|
+
* `{ ok, status: 'created'|'updated'|'unchanged'|'skipped'|'failed', reason?, detail?, rowId, patchPath,
|
|
414
|
+
* backup, changed, verified: {ok, problems, checked} | null, rewritten[], note }`
|
|
415
|
+
*
|
|
416
|
+
* 五种结局(`reason` 只在非 created/updated/unchanged 时出现):
|
|
417
|
+
* · `created` — 新写了 `preset-<id>` 行(先备份原有补丁);
|
|
418
|
+
* · `updated` — 已有同 id 行,原地换成新内容(先备份原有补丁);
|
|
419
|
+
* · `unchanged` — 已有同 id 行且**逐字节一致**:一个字节都没写、不建备份(真正的幂等);
|
|
420
|
+
* · `skipped` — `no-patch-path`:调用方没给 profile(离线单测/无 profile 场景),**明确不宣称已声明**;
|
|
421
|
+
* · `failed` — 预设目录/id 不合法、YAML 校验不过、写盘失败、读回核实不通过(**绝不谎报**)。
|
|
422
|
+
*/
|
|
423
|
+
function declarePresetRow(dir, id, opts = {}) {
|
|
424
|
+
const {
|
|
425
|
+
patchPath = null, profileDir = null, now = Date.now,
|
|
426
|
+
backup = true, verify = true, toFileUrl = pathToFileURL, exists = existsSync,
|
|
427
|
+
} = opts
|
|
428
|
+
const built = buildPresetDeclaration(dir, id, { toFileUrl, exists })
|
|
429
|
+
if (built.ok !== true) {
|
|
430
|
+
return { ok: false, status: 'failed', reason: built.reason, detail: built.detail, rowId: null, patchPath: null, backup: null, changed: false, verified: null, rewritten: [], note: built.detail }
|
|
431
|
+
}
|
|
432
|
+
const target = resolvePatchPath({ patchPath, profileDir })
|
|
433
|
+
if (target === null) {
|
|
434
|
+
return {
|
|
435
|
+
ok: false, status: 'skipped', reason: 'no-patch-path', rowId: built.rowId, patchPath: null, backup: null,
|
|
436
|
+
changed: false, verified: null, rewritten: built.rewritten, config: built.config,
|
|
437
|
+
note: `未写入声明行:本次调用没有目标 profile(既没有 patchPath 也没有 profileDir)—— `
|
|
438
|
+
+ `**文件已就位,但当前框架版本(0.1.7-rc.x 起)需要声明行才能显示**;请在目标 profile 的 cordis.patch.yml 里补一行 preset-${built.id}`,
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
// 补丁文件所在目录不存在 = 调用方给的 profile 是错的:宁可 skipped 也不凭空造一个 profile 目录
|
|
442
|
+
try {
|
|
443
|
+
if (!exists(dirname(target))) {
|
|
444
|
+
return {
|
|
445
|
+
ok: false, status: 'failed', reason: 'no-profile-dir', rowId: built.rowId, patchPath: target, backup: null,
|
|
446
|
+
changed: false, verified: null, rewritten: built.rewritten, config: built.config,
|
|
447
|
+
detail: `目标 profile 目录不存在:${dirname(target)}`,
|
|
448
|
+
note: `未写入声明行:目标 profile 目录不存在(${dirname(target)})—— **文件已就位,但当前框架版本需要声明行才能显示**`,
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
} catch {}
|
|
452
|
+
return queuedWrite(() => {
|
|
453
|
+
let before = ''
|
|
454
|
+
try { if (exists(target)) before = readFileSync(target, 'utf8') } catch { before = '' }
|
|
455
|
+
const outcome = upsertPresetRow(before, built.rowText, built.rowId)
|
|
456
|
+
if (outcome.changed !== true) {
|
|
457
|
+
return {
|
|
458
|
+
ok: true, status: 'unchanged', rowId: built.rowId, patchPath: target, backup: null, changed: false,
|
|
459
|
+
verified: verify === true ? verifyDeclaredRow(before, built) : null, rewritten: built.rewritten, config: built.config,
|
|
460
|
+
removed: outcome.removed,
|
|
461
|
+
// 与 created/updated 用**同一句**核心文案:幂等重装时用户同样要看懂"它在、重启后可选",
|
|
462
|
+
// 只多一句"本次未改动任何字节"(诚实交代用户的文件没被动过)。
|
|
463
|
+
note: presetDeclarationSentence(built.rowId, target, { unchanged: true }),
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
let backupPath = null
|
|
467
|
+
if (before.trim() !== '' && backup === true) {
|
|
468
|
+
backupPath = presetRowBackupPath(target, typeof now === 'function' ? now() : now, exists)
|
|
469
|
+
try {
|
|
470
|
+
copyFileSync(target, backupPath)
|
|
471
|
+
} catch (error) {
|
|
472
|
+
return {
|
|
473
|
+
ok: false, status: 'failed', reason: 'backup-failed', rowId: built.rowId, patchPath: target, backup: null,
|
|
474
|
+
changed: false, verified: null, rewritten: built.rewritten, config: built.config,
|
|
475
|
+
detail: `备份失败(绝不在没备份的情况下改用户补丁):${error instanceof Error ? error.message : String(error)}`,
|
|
476
|
+
note: '未写入声明行:改前备份失败,已放弃写入(补丁文件一个字节都没动)',
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
try {
|
|
481
|
+
mkdirSync(dirname(target), { recursive: true })
|
|
482
|
+
writeFileSync(target, outcome.text, 'utf8')
|
|
483
|
+
} catch (error) {
|
|
484
|
+
return {
|
|
485
|
+
ok: false, status: 'failed', reason: 'write-failed', rowId: built.rowId, patchPath: target, backup: backupPath,
|
|
486
|
+
changed: false, verified: null, rewritten: built.rewritten, config: built.config,
|
|
487
|
+
detail: `写入失败:${error instanceof Error ? error.message : String(error)}`,
|
|
488
|
+
note: `写入声明行失败(${error instanceof Error ? error.message : String(error)})—— `
|
|
489
|
+
+ '**文件已就位,但当前框架版本(0.1.7-rc.x 起)需要声明行才能显示**',
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
let readBack = null
|
|
493
|
+
try { readBack = readFileSync(target, 'utf8') } catch { readBack = null }
|
|
494
|
+
if (readBack === null) {
|
|
495
|
+
return {
|
|
496
|
+
ok: false, status: 'failed', reason: 'readback-failed', rowId: built.rowId, patchPath: target, backup: backupPath,
|
|
497
|
+
changed: false, verified: null, rewritten: built.rewritten, config: built.config,
|
|
498
|
+
detail: '写后读回失败(读不到文件)', note: '写入声明行后读回失败:无法确认是否写成,请人工检查该补丁文件',
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
const checked = verify === true ? verifyDeclaredRow(readBack, built) : null
|
|
502
|
+
const ok = checked === null || checked.ok === true
|
|
503
|
+
const extra = outcome.removed.length > 0 ? `;顺带清掉 ${outcome.removed.length} 行重复的同 id 声明` : ''
|
|
504
|
+
return {
|
|
505
|
+
ok, status: ok ? (outcome.created ? 'created' : 'updated') : 'failed',
|
|
506
|
+
reason: ok ? null : 'verify-failed',
|
|
507
|
+
detail: ok ? null : `读回核实不通过:${checked.problems.join(';')}`,
|
|
508
|
+
rowId: built.rowId, patchPath: target, backup: backupPath, changed: true,
|
|
509
|
+
verified: checked, rewritten: built.rewritten, config: built.config, removed: outcome.removed,
|
|
510
|
+
note: ok
|
|
511
|
+
? presetDeclarationSentence(built.rowId, target)
|
|
512
|
+
+ (built.rewritten.length > 0 ? `;composition 里 ${built.rewritten.length} 处相对文件已改写为 file: URL(基准是 profile 目录,不是预设目录)` : '')
|
|
513
|
+
+ extra
|
|
514
|
+
: `声明行写入后读回核实不通过:${checked.problems.join(';')}(已备份到 ${backupPath},请人工检查)`,
|
|
515
|
+
}
|
|
516
|
+
})
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/** 面板/日志用的一句话(**按实际落盘与声明结果生成**,绝不无脑说"新建会话时选择"):
|
|
520
|
+
* · 声明成功 → 「已声明为预设行 preset-x(写入 …);**重启实例后**在新会话可选」;
|
|
521
|
+
* · 只落了文件 → 明确说「文件已就位,但当前框架版本需要声明行才能显示」并给出出路。 */
|
|
522
|
+
function presetDeclarationNote(result) {
|
|
523
|
+
const r = result ?? {}
|
|
524
|
+
if (r.ok === true && (r.status === 'created' || r.status === 'updated' || r.status === 'unchanged')) {
|
|
525
|
+
return presetDeclarationSentence(r.rowId, r.patchPath, { unchanged: r.status === 'unchanged' })
|
|
526
|
+
}
|
|
527
|
+
const why = r.detail ?? r.reason ?? '未知原因'
|
|
528
|
+
// ⚠️ 失败分支**不能**说"重启实例后即可选":那时还没写成,重启也不会出现它。
|
|
529
|
+
// ("重启实例后可选"是**声明成功**那一路的语义,只出现在 presetDeclarationSentence 里。)
|
|
530
|
+
return `**文件已就位,但当前框架版本(0.1.7-rc.x 起预设改为声明行)需要声明行才能在界面上显示** —— `
|
|
531
|
+
+ `本次声明未写成(${why})。出路:在目标 profile 的 cordis.patch.yml 里补一行 `
|
|
532
|
+
+ `\`- insert:\` / \`- id: preset-<预设 id>\` / \`name: '${AGENT_PRESET_MODULE}'\`(config 取该预设的 agent.cordis.yml 作为 plugins),补上后即可选用`
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
export {
|
|
536
|
+
AGENT_PRESET_MODULE, COMPOSITION_INDENT, CONFIG_KEY_INDENT, PRESET_ROW_PREFIX,
|
|
537
|
+
absolutizeComposition, buildPresetDeclaration, declarePresetRow, findInsertBlockEnd,
|
|
538
|
+
indentComposition, isValidPresetId, normalizeComposition, presetDeclarationNote, presetDisplayMeta,
|
|
539
|
+
presetRowBackupPath, quoteScalar, readPresetComposition, readPresetManifest, readPresetRowConfig,
|
|
540
|
+
renderPresetRow, resolvePatchPath, scanPresetRows, tidyBlankLines, upsertPresetRow, verifyDeclaredRow,
|
|
541
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
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, declarationClauseForReports, 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
|
+
// 声明行(框架 0.1.7-rc.x:预设改为 profile 补丁里的 `preset-<id>` 声明行,不再扫目录)。
|
|
65
|
+
// 这条通道天然知道 profileDir,所以能直接把它当目标写出声明行;离线单测可用 deps.patchPath 覆盖。
|
|
66
|
+
const patchPath = typeof deps.patchPath === 'string' && deps.patchPath !== '' ? deps.patchPath : null
|
|
67
|
+
noteChannel(job, `装出来的包 ${packageName} 里带着预设(${presets.map((p) => basename(p)).join('、')}):按预设装配,不注册为插件(它没有插件入口)`)
|
|
68
|
+
const limit = Number.isFinite(deps.limit) ? deps.limit : 8
|
|
69
|
+
const reports = []
|
|
70
|
+
for (const p of presets.slice(0, limit)) {
|
|
71
|
+
// eslint-disable-next-line no-await-in-loop
|
|
72
|
+
const report = await assemblePreset(p, basename(p), {
|
|
73
|
+
presetsRoot,
|
|
74
|
+
profileDir,
|
|
75
|
+
...(patchPath === null ? {} : { patchPath }),
|
|
76
|
+
overwrite: deps.overwrite === true, // 默认**只补不覆盖**(与 0.5.26 的语义一致)
|
|
77
|
+
...(deps.now === undefined ? {} : { now: deps.now }),
|
|
78
|
+
})
|
|
79
|
+
reports.push({ ...report, srcDir: p })
|
|
80
|
+
}
|
|
81
|
+
const okReports = reports.filter((r) => r.ok === true)
|
|
82
|
+
const failed = reports.filter((r) => r.ok !== true)
|
|
83
|
+
// 同名冲突(默认只补不覆盖时的正常结局):某个预设的现有文件被跳过
|
|
84
|
+
const conflicted = okReports.filter((r) => (r.skipped?.length ?? 0) > 0).map((r) => r.name)
|
|
85
|
+
const summary = reports.map((r) => ({
|
|
86
|
+
name: r.name, dest: r.dest ?? null, ok: r.ok === true, error: r.error ?? null,
|
|
87
|
+
added: r.added?.length ?? 0, skipped: r.skipped?.length ?? 0, overwritten: r.overwritten?.length ?? 0,
|
|
88
|
+
identical: r.identical?.length ?? 0, backup: r.backup ?? null, bytes: r.bytes ?? 0,
|
|
89
|
+
marker: r.ok === true ? 'agent.cordis.yml' : null, carriedBy: packageName,
|
|
90
|
+
// 声明行结果(0.5.28 加法):结构字段下发,面板不必去解析 note 才知道"到底能不能选"
|
|
91
|
+
declaration: r.declaration === null || r.declaration === undefined ? null : {
|
|
92
|
+
ok: r.declaration.ok === true, status: r.declaration.status ?? null, rowId: r.declaration.rowId ?? null,
|
|
93
|
+
patchPath: r.declaration.patchPath ?? null, reason: r.declaration.reason ?? null, detail: r.declaration.detail ?? null,
|
|
94
|
+
},
|
|
95
|
+
}))
|
|
96
|
+
if (job !== null && job !== undefined) {
|
|
97
|
+
job.presetInstalled = summary
|
|
98
|
+
job.presetSource = { repo, packageName, mode: 'in-package', root: packageDir, presets: reports.map((r) => r.name) }
|
|
99
|
+
// 同名冲突(只补不覆盖时的正常结局)→ 面板给**显式**的「覆盖该预设」动作(复用既有动作构造器)
|
|
100
|
+
if (conflicted.length > 0) {
|
|
101
|
+
job.presetOverwriteCandidates = conflicted
|
|
102
|
+
job.suggestedAction = suggestedOverwritePresetAction({
|
|
103
|
+
presetName: conflicted[0],
|
|
104
|
+
repo,
|
|
105
|
+
reason: `已存在同名预设 ${conflicted.join('、')}:默认只补不覆盖(现有文件一个字节都没动)。要用仓库版本覆盖请点这个动作`,
|
|
106
|
+
})
|
|
107
|
+
}
|
|
108
|
+
job.presetNote = `预设已装配:${okReports.map((r) => r.name).join('、') || '(无)'}`
|
|
109
|
+
+ `(落盘 ${presetsRoot})`
|
|
110
|
+
+ declarationClauseForReports(okReports)
|
|
111
|
+
+ `;来源:包 ${packageName} 自带的预设目录(该包没有插件入口,已按预设装配、未注册为插件)`
|
|
112
|
+
+ (conflicted.length > 0 ? `;同名预设 ${conflicted.join('、')} 默认只补不覆盖(缺的文件已补、现有文件未改动),如需覆盖请点「覆盖该预设」` : '')
|
|
113
|
+
+ (failed.length > 0 ? `;${failed.length} 个预设未装配:${failed.map((r) => `${r.name}(${r.error})`).join('、')}` : '')
|
|
114
|
+
job.presetDone = okReports.length > 0
|
|
115
|
+
if (okReports.length > 0) {
|
|
116
|
+
job.status = 'done'
|
|
117
|
+
job.stage = 'done'
|
|
118
|
+
job.candidateDone = true
|
|
119
|
+
job.packageName = packageName
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
// 记下来源(面板的「覆盖该预设」要按记录重新取源码):repo 为空时记 null,动作侧会如实告知"没有源码出处"
|
|
123
|
+
try {
|
|
124
|
+
recordPresetSource(
|
|
125
|
+
okReports.map((r) => ({ name: r.name, repo, candidateName: packageName, subdir: null, sparse: false, dest: r.dest })),
|
|
126
|
+
deps.now === undefined ? {} : { now: deps.now },
|
|
127
|
+
)
|
|
128
|
+
} catch {}
|
|
129
|
+
if (okReports.length === 0) {
|
|
130
|
+
return { handled: true, installed: false, presets: summary, error: new Error(`包 ${packageName} 自带预设,但装配全部失败:${failed.map((r) => `${r.name}(${r.error})`).join('、')}`) }
|
|
131
|
+
}
|
|
132
|
+
return {
|
|
133
|
+
handled: true,
|
|
134
|
+
installed: true,
|
|
135
|
+
presets: summary,
|
|
136
|
+
conflicted,
|
|
137
|
+
note: job?.presetNote ?? null,
|
|
138
|
+
error: failed.length > 0 ? new Error(`${failed.length} 个预设未装配:${failed.map((r) => `${r.name}(${r.error})`).join('、')}`) : null,
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** 装成功后(或预设源码通道成功后)的**唯一收口**(install-job 只调这一行,它贴着 600 行架构硬顶):
|
|
143
|
+
* · `job.presetDone`(源码通道已装配好)→ 把 job 收口并返回 true;
|
|
144
|
+
* · 落地物里带预设 → 走 installPresetsCarriedByPackage 装配,成功则收口并返回 true;
|
|
145
|
+
* · 其余 → 返回 false(调用方照旧按普通插件收口,行为一个字不变)。 */
|
|
146
|
+
async function settlePresetOutcome({ job, installedName, profileDir, repo = null, deps = {} } = {}) {
|
|
147
|
+
if (job !== null && job !== undefined && job.presetDone === true) {
|
|
148
|
+
job.status = 'done'
|
|
149
|
+
job.stage = 'done'
|
|
150
|
+
job.candidateDone = true
|
|
151
|
+
job.packageName = job.candidateName ?? job.packageName
|
|
152
|
+
return true
|
|
153
|
+
}
|
|
154
|
+
if (typeof installedName !== 'string' || installedName === '') return false
|
|
155
|
+
const carried = await installPresetsCarriedByPackage({ job, name: installedName, profileDir, repo, deps })
|
|
156
|
+
return carried.installed === true
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export { findCarriedPresets, installedPackageDir, installPresetsCarriedByPackage, settlePresetOutcome }
|
|
@@ -24,6 +24,7 @@ import { chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, readdirSy
|
|
|
24
24
|
import { basename, dirname, join, relative, sep } from 'node:path'
|
|
25
25
|
import { dshHome } from '../infra/paths.js'
|
|
26
26
|
import { validateAgentConfig } from './preset-yaml.js'
|
|
27
|
+
import { declarePresetRow, presetDeclarationNote } from './preset-declare.js'
|
|
27
28
|
|
|
28
29
|
/** 预设的判别文件(框架发现预设看的就是这个:`<presetsRoot>/<name>/agent.cordis.yml`)。 */
|
|
29
30
|
const PRESET_MARKER_FILES = ['agent.cordis.yml', 'agent.cordis.yaml']
|
|
@@ -90,6 +91,78 @@ function verifyWrittenFiles(dest, srcDir, rels) {
|
|
|
90
91
|
return { ok: mismatched.length === 0, checked: rels.length, mismatched }
|
|
91
92
|
}
|
|
92
93
|
|
|
94
|
+
/**
|
|
95
|
+
* 装配后要不要写**声明行**(框架 0.1.7-rc.x 的机制迁移,见 preset-declare.js 顶部):
|
|
96
|
+
* 0.1.5 及更早靠"扫 `$DSH_HOME/.agent-presets/` 目录"发现预设;rc.2 起目录发现被彻底移除,
|
|
97
|
+
* 注册表只认 profile `cordis.patch.yml` 里的 `preset-<id>` 声明行。所以"只把文件铺上去"在
|
|
98
|
+
* 新框架上等于**用户看不见这个预设**(桌面端「自定义」为空就是这么来的),老会话 resume 还会
|
|
99
|
+
* `RemoteError: Unknown agent preset`。
|
|
100
|
+
*
|
|
101
|
+
* 语义边界(默认**不动**用户补丁):
|
|
102
|
+
* · 只在显式给了 `patchPath`/`profileDir` 时才写 —— 离线单测、无 profile 场景不凭空造文件;
|
|
103
|
+
* · 写前备份、写后读回核实(preset-declare.js 内部做全);
|
|
104
|
+
* · 写不成 → **如实返回失败原因**,调用方据此生成文案,**绝不**再说"新建会话时选择"。
|
|
105
|
+
* 返回声明结果对象,或 null(没给目标 profile / 调用方显式关闭)。**异步**:写盘走 fsx 的 queuedWrite
|
|
106
|
+
* 串行队列(与补丁文件的其它写者互斥),所以调用方必须 await —— 忘了 await 会拿到一个 Promise,
|
|
107
|
+
* `declaration.ok` 恒为 undefined,于是"明明写成了却报没写成"(自测真抓到过这个形状)。
|
|
108
|
+
*/
|
|
109
|
+
async function runPresetDeclaration(srcDir, name, { patchPath = null, profileDir = null, declare = null, now = Date.now } = {}) {
|
|
110
|
+
if (typeof declare === 'function') {
|
|
111
|
+
try { return await declare(srcDir, name) } catch (error) {
|
|
112
|
+
return { ok: false, status: 'failed', reason: 'declare-threw', detail: error instanceof Error ? error.message : String(error), rowId: null, patchPath: null, backup: null, changed: false, verified: null, rewritten: [] }
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
if (patchPath === null && profileDir === null) return null
|
|
116
|
+
try {
|
|
117
|
+
return await declarePresetRow(srcDir, name, { patchPath, profileDir, now })
|
|
118
|
+
} catch (error) {
|
|
119
|
+
return {
|
|
120
|
+
ok: false, status: 'failed', reason: 'declare-threw', rowId: null, patchPath: patchPath ?? null, backup: null,
|
|
121
|
+
changed: false, verified: null, rewritten: [],
|
|
122
|
+
detail: `写声明行时异常:${error instanceof Error ? error.message : String(error)}`,
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** 面板文案里"这个预设到底能不能被看见"的那一句(**按实际结果生成**,绝不无脑说"新建会话时选择")。
|
|
128
|
+
* `null`(调用方没给目标 profile → 根本没尝试声明)也必须**明说**:文件已就位、但当前框架版本
|
|
129
|
+
* 需要声明行才显示,并给出出路 —— 这正是旧文案"新建会话时选择"撒谎的那一格。 */
|
|
130
|
+
function declarationClause(declaration) {
|
|
131
|
+
if (declaration === null || declaration === undefined) {
|
|
132
|
+
return ';**文件已就位,但当前框架版本(0.1.7-rc.x 起预设改为声明行)需要声明行才能在界面上显示** —— '
|
|
133
|
+
+ '本次没有目标 profile 可写,请在目标 profile 的 cordis.patch.yml 里补 `preset-<预设 id>` 声明行'
|
|
134
|
+
}
|
|
135
|
+
if (declaration.ok === true) return `;${presetDeclarationNote(declaration)}`
|
|
136
|
+
return `;⚠️ ${presetDeclarationNote(declaration)}`
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** 多个预设装配报告 → 一句话(**成功逐条点名、失败如实报因**,两类都在,绝不只报好消息)。
|
|
140
|
+
* 为什么不是"有一个成功就整体乐观":一次装配可能有 3 个预设,2 个声明成功、1 个写失败 ——
|
|
141
|
+
* 用户必须知道**哪个**看不见,否则他会对着"已装配"这三个字找不到那一个预设。 */
|
|
142
|
+
function declarationClauseForReports(reports) {
|
|
143
|
+
const list = (Array.isArray(reports) ? reports : []).filter((r) => r !== null && typeof r === 'object')
|
|
144
|
+
const decls = list.map((r) => r.declaration).filter((d) => d !== null && d !== undefined)
|
|
145
|
+
if (decls.length === 0) {
|
|
146
|
+
return ';**文件已就位,但当前框架版本(0.1.7-rc.x 起预设改为声明行)需要声明行才能在界面上显示** —— '
|
|
147
|
+
+ '本次没有目标 profile 可写,请在目标 profile 的 cordis.patch.yml 里补 `preset-<预设 id>` 声明行'
|
|
148
|
+
}
|
|
149
|
+
const okDecls = decls.filter((d) => d.ok === true)
|
|
150
|
+
const badDecls = decls.filter((d) => d.ok !== true)
|
|
151
|
+
const parts = []
|
|
152
|
+
if (okDecls.length > 0) {
|
|
153
|
+
const rows = okDecls.map((d) => d.rowId).filter((r) => typeof r === 'string' && r !== '')
|
|
154
|
+
const unchanged = okDecls.filter((d) => d.changed === false).length
|
|
155
|
+
parts.push(`已声明为预设行 ${rows.join('、')}`
|
|
156
|
+
+ `${unchanged > 0 ? `(其中 ${unchanged} 个此前已声明、内容一致未改动)` : ''}`
|
|
157
|
+
+ `${typeof okDecls[0].patchPath === 'string' ? `(写入 ${okDecls[0].patchPath})` : ''};**重启实例后**在新会话可选`)
|
|
158
|
+
}
|
|
159
|
+
if (badDecls.length > 0) {
|
|
160
|
+
parts.push(`${badDecls.length} 个预设**未写成声明行**(${badDecls.map((d) => d.detail ?? d.reason ?? '未知原因').join(';')})`
|
|
161
|
+
+ '—— 文件已就位,但当前框架版本需要声明行才能显示')
|
|
162
|
+
}
|
|
163
|
+
return `;${parts.join(';')}`
|
|
164
|
+
}
|
|
165
|
+
|
|
93
166
|
/**
|
|
94
167
|
* 装配一个预设到 `<presetsRoot>/<name>`(**唯一实现**)。
|
|
95
168
|
*
|
|
@@ -103,9 +176,11 @@ function verifyWrittenFiles(dest, srcDir, rels) {
|
|
|
103
176
|
* 不通过 → **这个文件不写**,进 `skippedInvalid` 并如实报告(其它文件照常)。
|
|
104
177
|
*
|
|
105
178
|
* 返回 { ok, name, dest, backup, mode, added, skipped, overwritten, identical, conflicts,
|
|
106
|
-
* skippedInvalid, verified, verifiedConfig, hashes, bytes, files, note, error }。
|
|
179
|
+
* skippedInvalid, verified, verifiedConfig, hashes, bytes, files, declaration, note, error }。
|
|
180
|
+
* `declaration` = 声明行结果(见 preset-declare.js);**没给目标 profile 时为 null**(那时 note 会
|
|
181
|
+
* 明说"文件已就位,但当前框架版本需要声明行才能显示",绝不宣称"新建会话时选择")。
|
|
107
182
|
*/
|
|
108
|
-
function assemblePreset(srcDir, name, { presetsRoot = null, now = Date.now, overwrite = false, verify = true } = {}) {
|
|
183
|
+
async function assemblePreset(srcDir, name, { presetsRoot = null, now = Date.now, overwrite = false, verify = true, patchPath = null, profileDir = null, declare = null } = {}) {
|
|
109
184
|
const root = presetsRoot ?? join(dshHome(), '.agent-presets')
|
|
110
185
|
const presetName = String(name ?? basename(srcDir)).trim()
|
|
111
186
|
if (presetName === '' || presetName === '.' || /[\\/]/u.test(presetName)) {
|
|
@@ -200,6 +275,13 @@ function assemblePreset(srcDir, name, { presetsRoot = null, now = Date.now, over
|
|
|
200
275
|
: { ok: false, problems: ['目标目录里没有 agent.cordis.yml/.yaml'] }
|
|
201
276
|
const mode = overwrite === true ? 'overwrite' : 'add-only'
|
|
202
277
|
|
|
278
|
+
// ── 声明行(**框架 0.1.7-rc.x 的机制迁移**的落点)──────────────────────────────────────
|
|
279
|
+
// 判据:磁盘上的预设配置合法(`destConfigValid.ok`)才声明 —— 声明一个挂不起来的预设会让
|
|
280
|
+
// 用户在新会话里选中它然后炸掉,比"不声明"更糟。写盘/备份/读回核实全在 preset-declare.js。
|
|
281
|
+
const declaration = destConfigValid.ok === true
|
|
282
|
+
? await runPresetDeclaration(srcDir, presetName, { patchPath, profileDir, declare, now })
|
|
283
|
+
: null
|
|
284
|
+
|
|
203
285
|
const noteParts = []
|
|
204
286
|
if (existed !== true) {
|
|
205
287
|
noteParts.push(`预设 ${presetName} 已装配到 ${dest}(新增 ${added.length} 个文件)`)
|
|
@@ -215,6 +297,10 @@ function assemblePreset(srcDir, name, { presetsRoot = null, now = Date.now, over
|
|
|
215
297
|
}
|
|
216
298
|
if (identical.length > 0) noteParts.push(`另有 ${identical.length} 个文件与你手上的内容一致,未改动`)
|
|
217
299
|
}
|
|
300
|
+
// 「这个预设到底能不能被看见」必须**按实际结果**说(旧文案无条件说"新建会话时选择",
|
|
301
|
+
// 在 rc.2 上是一句谎话:没有声明行的预设根本不会出现在选择器里)
|
|
302
|
+
const declNote = declarationClause(declaration)
|
|
303
|
+
if (declNote !== '') noteParts.push(declNote.replace(/^;/u, ''))
|
|
218
304
|
if (skippedInvalid.length > 0) {
|
|
219
305
|
noteParts.push(`${skippedInvalid.length} 个预设配置文件**未通过结构校验、已跳过未写入**:`
|
|
220
306
|
+ skippedInvalid.map((s) => `${s.rel}(${s.problems[0]})`).join('、'))
|
|
@@ -240,6 +326,7 @@ function assemblePreset(srcDir, name, { presetsRoot = null, now = Date.now, over
|
|
|
240
326
|
skippedInvalid,
|
|
241
327
|
verified,
|
|
242
328
|
verifiedConfig: destConfigValid,
|
|
329
|
+
declaration,
|
|
243
330
|
hashes: written.map((rel) => ({ rel, sha256: sha256Of(readFileSync(join(dest, ...rel.split('/')))) })),
|
|
244
331
|
bytes,
|
|
245
332
|
files: srcFiles.map((f) => f.rel),
|
|
@@ -355,6 +442,7 @@ function presetJobView(job) {
|
|
|
355
442
|
|
|
356
443
|
export {
|
|
357
444
|
PRESET_MARKER_FILES, PRESET_MANIFEST_FILE, PRESET_SOURCE_RECORD_LIMIT,
|
|
358
|
-
assemblePreset, copyTreeStrict,
|
|
359
|
-
|
|
445
|
+
assemblePreset, copyTreeStrict, declarationClause, declarationClauseForReports, listTreeFiles,
|
|
446
|
+
presetJobView, presetSourceOf, presetSourcesFile, readPresetSources, recordPresetSource,
|
|
447
|
+
runPresetDeclaration, sha256Of, suggestedOverwritePresetAction, uniqueBackupPath, verifyWrittenFiles,
|
|
360
448
|
}
|
|
@@ -23,11 +23,12 @@ import { basename, join } from 'node:path'
|
|
|
23
23
|
import { tmpdir } from 'node:os'
|
|
24
24
|
import { gitCloneRepo, runGitArgs } from './repoland.js'
|
|
25
25
|
import { noteChannel } from './git-channel.js'
|
|
26
|
+
import { readPresetManifest } from './preset-declare.js'
|
|
26
27
|
import { evaluateSourceChannel } from './repo-size.js'
|
|
27
28
|
import { disposeDir } from '../infra/fsx.js'
|
|
28
29
|
import { dshHome } from '../infra/paths.js'
|
|
29
30
|
import {
|
|
30
|
-
PRESET_MARKER_FILES, PRESET_MANIFEST_FILE, assemblePreset, listTreeFiles,
|
|
31
|
+
PRESET_MARKER_FILES, PRESET_MANIFEST_FILE, assemblePreset, declarationClauseForReports, listTreeFiles,
|
|
31
32
|
presetSourceOf, recordPresetSource, suggestedOverwritePresetAction,
|
|
32
33
|
} from './preset-install.js'
|
|
33
34
|
|
|
@@ -71,19 +72,10 @@ function looksLikePresetPackageName(name) {
|
|
|
71
72
|
return /(?:^|[-_/])(?:router|preset)(?:[-_/]|$)/iu.test(String(name ?? '').trim())
|
|
72
73
|
}
|
|
73
74
|
|
|
74
|
-
/** 读预设清单(preset.yml 的 name/description/order;读不到返回 null,不抛)。
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
const pick = (key) => {
|
|
79
|
-
const m = text.match(new RegExp(`^\\s*${key}\\s*:\\s*(.+)$`, 'mu'))
|
|
80
|
-
return m === null ? null : m[1].trim().replace(/^["']|["']$/gu, '')
|
|
81
|
-
}
|
|
82
|
-
return { name: pick('name'), description: pick('description'), order: pick('order') }
|
|
83
|
-
} catch {
|
|
84
|
-
return null
|
|
85
|
-
}
|
|
86
|
-
}
|
|
75
|
+
/** 读预设清单(preset.yml 的 name/description/order;读不到返回 null,不抛)。
|
|
76
|
+
* 实现已搬到 preset-declare.js(那里也要用它,且依赖方向必须单向 preset-source → preset-declare)——
|
|
77
|
+
* 这里 re-export,既有调用方与测试一个字都不用改。 */
|
|
78
|
+
export { readPresetManifest } from './preset-declare.js'
|
|
87
79
|
|
|
88
80
|
/** 跑一条 git 命令并取回 stdout(ls-tree/show 要的就是输出)。仍然走 repoland 的 runGitArgs:
|
|
89
81
|
* 那边已经把停滞判据 + kill-tree + wait-for-exit 做完了,这里只转发(`stdout` 由 runGitArgs 采集)。 */
|
|
@@ -214,13 +206,19 @@ async function tryPresetSourceChannel({ job, name, source = 'github', limit = 4,
|
|
|
214
206
|
return { handled: true, installed: false, error: new Error(`预设型子包 ${candidateName}:${why}`), presets: [] }
|
|
215
207
|
}
|
|
216
208
|
const presetsRoot = deps.presetsRoot ?? join(dshHome(), '.agent-presets')
|
|
209
|
+
// 目标 profile 的补丁文件(可选):给了就**同时写声明行** —— 框架 0.1.7-rc.x 起预设不再靠目录
|
|
210
|
+
// 发现,只落文件不写声明行 = 用户看不见(见 preset-declare.js 顶部)。安装路径由 install-job.js
|
|
211
|
+
// 传 findPatchPath(ports),线上一律非空;离线单测不传 → 不写、且 note 如实说明。
|
|
212
|
+
const patchPath = typeof deps.patchPath === 'string' && deps.patchPath !== '' ? deps.patchPath : null
|
|
217
213
|
const reports = []
|
|
218
214
|
const skipped = Math.max(0, presets.length - limit)
|
|
219
215
|
for (const p of presets.slice(0, limit)) {
|
|
220
216
|
// eslint-disable-next-line no-await-in-loop
|
|
221
217
|
// overwrite **永不**在这里为真:安装路径必须走"只补不覆盖"的默认语义。
|
|
222
218
|
// 覆盖只能由用户显式点「覆盖该预设」→ 白名单动作 overwrite-preset 触发(见 plugin-actions.js)。
|
|
223
|
-
const report = assemblePreset(p, basename(p), {
|
|
219
|
+
const report = await assemblePreset(p, basename(p), {
|
|
220
|
+
presetsRoot, patchPath, ...(deps.now === undefined ? {} : { now: deps.now }),
|
|
221
|
+
})
|
|
224
222
|
reports.push({ ...report, manifest: readPresetManifest(p), sparse: fetched.sparse === true, subdir: fetched.subdir })
|
|
225
223
|
}
|
|
226
224
|
const failed = reports.filter((r) => r.ok !== true)
|
|
@@ -235,6 +233,13 @@ async function tryPresetSourceChannel({ job, name, source = 'github', limit = 4,
|
|
|
235
233
|
skipped: Array.isArray(r.skipped) ? r.skipped : [],
|
|
236
234
|
skippedInvalid: Array.isArray(r.skippedInvalid) ? r.skippedInvalid : [],
|
|
237
235
|
verified: r.verified === null || r.verified === undefined ? null : r.verified.ok === true,
|
|
236
|
+
// 声明行结果(0.5.28 加法):面板据此显示"已声明为 preset-x(重启实例后可选)",
|
|
237
|
+
// 或如实显示"文件已就位但没写成声明行" —— 结构字段 + note 两处都下发,绝不只在一处叙述。
|
|
238
|
+
declaration: r.declaration === null || r.declaration === undefined ? null : {
|
|
239
|
+
ok: r.declaration.ok === true, status: r.declaration.status ?? null, rowId: r.declaration.rowId ?? null,
|
|
240
|
+
patchPath: r.declaration.patchPath ?? null, backup: r.declaration.backup ?? null,
|
|
241
|
+
reason: r.declaration.reason ?? null, detail: r.declaration.detail ?? null,
|
|
242
|
+
},
|
|
238
243
|
}))
|
|
239
244
|
job.presetSource = { repo, subdir: fetched.subdir, sparse: fetched.sparse === true, downgraded: fetched.downgraded === true, root: fetched.root }
|
|
240
245
|
// 源码出处落盘:`overwrite-preset` 动作据此**重新取源码**(临时 clone 目录在 finally 里已清理,
|
|
@@ -264,7 +269,8 @@ async function tryPresetSourceChannel({ job, name, source = 'github', limit = 4,
|
|
|
264
269
|
const skippedFiles = reports.reduce((sum, r) => sum + (r.skipped?.length ?? 0), 0)
|
|
265
270
|
const invalidFiles = reports.reduce((sum, r) => sum + (r.skippedInvalid?.length ?? 0), 0)
|
|
266
271
|
const backedUp = reports.filter((r) => r.ok === true && typeof r.backup === 'string' && r.backup !== '')
|
|
267
|
-
const note = `预设已装配:${okNames.join('、')}(落盘 ${presetsRoot}
|
|
272
|
+
const note = `预设已装配:${okNames.join('、')}(落盘 ${presetsRoot})`
|
|
273
|
+
+ declarationClauseForReports(reports)
|
|
268
274
|
+ (fetched.sparse === true ? ';源码用稀疏取源码(--filter=blob:none --sparse + sparse-checkout set),只取该子包目录' : '')
|
|
269
275
|
+ (skipped > 0 ? `;该仓库还有 ${skipped} 个预设本次未装配(单次上限 ${limit} 个)` : '')
|
|
270
276
|
// 默认语义必须在面板上说清(用户要先知道"你的文件没被动过",才谈得上要不要覆盖)
|
|
@@ -343,7 +349,10 @@ async function overwritePreset({ presetName, deps = {} } = {}) {
|
|
|
343
349
|
return { ok: false, status: 200, name: raw, repo, error: `仓库 ${repo} 的 ${fetched.subdir} 里没有找到预设目录` }
|
|
344
350
|
}
|
|
345
351
|
// 覆盖的永远是**用户点名的那一个**名字(不是源目录的 basename):用户点的是哪个就改哪个
|
|
346
|
-
const
|
|
352
|
+
const patchPath = typeof deps.patchPath === 'string' && deps.patchPath !== '' ? deps.patchPath : null
|
|
353
|
+
const report = await assemblePreset(pick, raw, {
|
|
354
|
+
presetsRoot, overwrite: true, patchPath, ...(deps.now === undefined ? {} : { now: deps.now }),
|
|
355
|
+
})
|
|
347
356
|
if (report.ok !== true) {
|
|
348
357
|
return { ok: false, status: 200, name: raw, repo, backup: report.backup ?? null, error: report.error ?? '覆盖失败(文件未改动)', report }
|
|
349
358
|
}
|
|
@@ -367,7 +376,7 @@ async function overwritePreset({ presetName, deps = {} } = {}) {
|
|
|
367
376
|
|
|
368
377
|
export {
|
|
369
378
|
PRESET_MARKER_FILES, PRESET_MANIFEST_FILE, GIT_READ_TIMEOUT_MS,
|
|
370
|
-
findPresetDirs, isPresetDir, looksLikePresetPackageName,
|
|
379
|
+
findPresetDirs, isPresetDir, looksLikePresetPackageName, listTreeFiles,
|
|
371
380
|
assemblePreset, locateSubpackageViaGit, fetchPresetSource, gitCapture, tryPresetSourceChannel,
|
|
372
381
|
overwritePreset,
|
|
373
382
|
}
|
|
@@ -282,14 +282,20 @@ async function runSuiteInstallJob(job, ports, deps = {}) {
|
|
|
282
282
|
// 用户手上的同名预设;现在改为"先整目录备份、再逐文件合并",并把备份路径与覆盖清单写进报告。
|
|
283
283
|
const presets = findPresetDirs(subDir, 2)
|
|
284
284
|
for (const p of presets) {
|
|
285
|
-
|
|
285
|
+
// 声明行同源(0.5.28 加法):目标 profile 就是本次套装的 profileDir —— 框架 0.1.7-rc.x 起
|
|
286
|
+
// 预设必须作为声明行出现在 cordis.patch.yml 里,只铺文件用户看不见(见 preset-declare.js 顶部)。
|
|
287
|
+
const assembled = await assemblePreset(p, basename(p), { profileDir })
|
|
286
288
|
report.push({
|
|
287
289
|
component: sub.name,
|
|
288
290
|
type: 'preset',
|
|
289
291
|
ok: assembled.ok === true,
|
|
290
|
-
|
|
292
|
+
// 文案按**实际声明结果**生成(旧文案无条件说"新建会话时选择",在 rc.2 上是一句谎话)
|
|
293
|
+
note: assembled.ok === true
|
|
294
|
+
? assembled.note
|
|
295
|
+
: `预设装配失败:${assembled.error}`,
|
|
291
296
|
presetPath: assembled.dest ?? null,
|
|
292
297
|
backup: assembled.backup ?? null,
|
|
298
|
+
declaration: assembled.declaration ?? null,
|
|
293
299
|
})
|
|
294
300
|
if (assembled.ok === true) handled = true
|
|
295
301
|
}
|
|
@@ -320,7 +326,19 @@ async function runSuiteInstallJob(job, ports, deps = {}) {
|
|
|
320
326
|
const okCount = report.filter((r) => r.ok).length
|
|
321
327
|
const failCount = report.filter((r) => !r.ok).length
|
|
322
328
|
const bundleCount = report.filter((r) => r.type === 'bundle' && r.ok).length
|
|
323
|
-
|
|
329
|
+
// 预设的可见性按**实际声明结果**说(0.5.28 改错):框架 0.1.7-rc.x 起预设不再靠目录发现,
|
|
330
|
+
// 只有落进 profile 补丁的 `preset-<id>` 声明行才会出现在选择器里 —— 旧文案"预设需新建会话时选择"
|
|
331
|
+
// 对新框架是错的(没有声明行时新建多少次会话都选不到)。有声明失败的条目就点名。
|
|
332
|
+
const presetRows = report.filter((r) => r.type === 'preset')
|
|
333
|
+
const presetDeclared = presetRows.filter((r) => r.declaration?.ok === true).length
|
|
334
|
+
const presetUndeclared = presetRows.filter((r) => r.ok === true && r.declaration?.ok !== true).length
|
|
335
|
+
const presetFailed = presetRows.filter((r) => !r.ok).length
|
|
336
|
+
job.suiteNote = `套装安装完成:${okCount} 个组件成功${failCount > 0 ? `,${failCount} 个失败(详见报告)` : ''}${bundleCount > 0 ? '。bundle 组件需重启服务生效' : ''}`
|
|
337
|
+
+ (presetDeclared > 0 ? `;${presetDeclared} 个预设已声明为声明行(重启实例后在新会话可选)` : '')
|
|
338
|
+
+ (presetUndeclared > 0
|
|
339
|
+
? `;⚠️ ${presetUndeclared} 个预设**文件已就位,但当前框架版本(0.1.7-rc.x 起预设改为声明行)需要声明行才能在界面上显示**`
|
|
340
|
+
: '')
|
|
341
|
+
+ (presetFailed > 0 ? `;${presetFailed} 个预设装配失败(详见报告)` : '')
|
|
324
342
|
} catch (error) {
|
|
325
343
|
job.status = 'failed'
|
|
326
344
|
job.error = error instanceof Error ? error.message : String(error)
|
|
@@ -109,7 +109,9 @@ async function routeRepoLandConfig(req, res, rc) {
|
|
|
109
109
|
const body = rc.body
|
|
110
110
|
const dir = typeof body.dir === 'string' ? body.dir.trim() : ''
|
|
111
111
|
if (dir === '' || !/^[A-Za-z]:[\\/]/.test(dir) && !/^\\\\/.test(dir)) {
|
|
112
|
-
|
|
112
|
+
// 0.5.28 改错:示例原本写成本机真实目录 `D:\dsh\repos`(把开发者的目录当成了通用示例)。
|
|
113
|
+
// 换成中性的任意绝对路径示例,并说明"任意绝对路径"——判据是形状(盘符/UNC),不是具体目录。
|
|
114
|
+
sendError(res, 400, '保存路径不合法:请提供绝对路径(任意绝对路径均可,如 C:\\repos 或 \\\\server\\share\\repos)')
|
|
113
115
|
return
|
|
114
116
|
}
|
|
115
117
|
try {
|
|
@@ -23,9 +23,18 @@ const REGISTRY = 'https://registry.npmmirror.com'
|
|
|
23
23
|
|
|
24
24
|
/** 预检的两个扫描面:用户预设目录 + profile 内每个已装插件包。
|
|
25
25
|
* 框架自带包(解析到 profile 之外,即 npx 缓存里的 @deepseek-ai/*)标成 kind='framework':
|
|
26
|
-
* 仍然扫(能暴露框架自身的契约不一致),但**只报告不改**——改它等于污染框架安装树。
|
|
26
|
+
* 仍然扫(能暴露框架自身的契约不一致),但**只报告不改**——改它等于污染框架安装树。
|
|
27
|
+
*
|
|
28
|
+
* ★ 2026-09-27 改错(真 bug):用户预设目录写成了 `join(dshHome(), 'agent-presets')` —— **少一个点**。
|
|
29
|
+
* 框架的目录是 `<DSH_HOME>/.agent-presets`(旧 discovery 的 `USER_PRESET_DIR = '.agent-presets'`),
|
|
30
|
+
* 所以这一路**一直指向一个不存在的目录**:静默扫到 0 个文件,预设里的 V3 生产方从来没被预检看见
|
|
31
|
+
* (升级前"0 blocker"的结论因此是**假绿**)。现在由 `userPresetsRoot()` 单点推导,配断言钉死。 */
|
|
32
|
+
function userPresetsRoot(home = undefined) {
|
|
33
|
+
return join(home ?? dshHome(), '.agent-presets')
|
|
34
|
+
}
|
|
35
|
+
|
|
27
36
|
function preflightRoots(ports, profileDir) {
|
|
28
|
-
const roots = [{ root:
|
|
37
|
+
const roots = [{ root: userPresetsRoot(), kind: 'preset' }]
|
|
29
38
|
// 控制台自身排除在外:它是工具、不是会话消息生产方,而它的报错文案/文档里本身就含
|
|
30
39
|
// `kind: 'plugin'`、`.volatile(` 这类字样 —— 发版门槛自扫时正是这些字串报出了无意义条目。
|
|
31
40
|
let selfName = null
|
|
@@ -161,4 +170,4 @@ async function routeFrameworkPreflightPatch(req, res, rc) {
|
|
|
161
170
|
})
|
|
162
171
|
}
|
|
163
172
|
|
|
164
|
-
export { routeFrameworkPreflight, routeFrameworkPreflightPatch, preflightRoots, resolveTargetVersion, resolveCurrentVersion }
|
|
173
|
+
export { routeFrameworkPreflight, routeFrameworkPreflightPatch, preflightRoots, resolveTargetVersion, resolveCurrentVersion, userPresetsRoot }
|
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.28",
|
|
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",
|