@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.
@@ -0,0 +1,165 @@
1
+ // L1 · domain —— preset-yaml.js(预设配置 agent.cordis.yml 的**结构校验**;0.5.26 加法,修 F3)
2
+ //
3
+ // 为什么要有它(真机缺陷 F3,2026-09-27 独立对抗式复核抓到):
4
+ // 预设装配会把源仓库里的 `agent.cordis.yml` 覆盖到用户 `~/.dsh/.agent-presets/<预设>/` 下。
5
+ // 一旦取到的内容**不是可用的配置**(空文件、被截断、带 tab 缩进、引号没闭合…),
6
+ // 覆盖就把**原本可用**的预设写坏了 —— 复核报告里的现场就是 `overwritten=["agent.cordis.yml"]`,
7
+ // 下次新建会话挂载这个预设时报错,而用户完全不知道是自己点的那次「安装」干的。
8
+ // 修法(改错):**写盘之前**先做结构校验;不通过就**跳过这一个文件并如实报告**,绝不写坏。
9
+ //
10
+ // ⚠️ 判据宽严的分寸(为什么不用"顶层必须是 mapping"这类严格判据):
11
+ // 框架的 agent 预设是**顶层 services 列表**(`- id: …` / `name: …` / `config:`),
12
+ // 里面还合法地使用 `!!js` 自定义标签、`>-` 折叠块、`|-` 字面块、注释与空行。
13
+ // 严格的通用 YAML 校验器(或"顶层必须是 mapping")会把**真文件**判成非法 → 于是"修 F3"变成
14
+ // "所有预设都装不上",那是把缺陷换了个方向。所以这里只钉**能确证损坏**的形态(全部可离线判定):
15
+ // ① 空内容 / 全是注释与空行 —— 没有任何配置行,框架挂载它等于空预设;
16
+ // ② 行首缩进里出现 **tab** —— YAML 明确禁止用 tab 做缩进(框架的解析器必然报错);
17
+ // ③ 明显**未闭合的引号**(引号从头到尾用反斜杠转义、真正的解析器能容忍跨行,这里只抓最常见的截断);
18
+ // ④ 缩进**跳级**(父级还没打开一个映射/序列就多缩进)—— 截断与坏编辑的典型形状。
19
+ // `!!js` / 块标量 / 注释 / CRLF 一律按合法处理(见 passesStructure 的分支注释)。
20
+ //
21
+ // 分层:L1 domain —— 纯函数,无 IO、无 ctx、不抛(解析失败是正常结局,调用方按"不通过"处理)。
22
+
23
+ /** 缩进宽度(空格数;tab 会在这里被单独判掉,不参与计数)。 */
24
+ function indentOf(line) {
25
+ const m = String(line).match(/^[ \t]*/u)
26
+ return m === null ? 0 : m[0].length
27
+ }
28
+
29
+ /** 这一行的缩进里有没有 tab(YAML 禁止;框架解析器会直接报错)。 */
30
+ function hasTabIndent(line) {
31
+ return /^[ \t]*\t/u.test(String(line))
32
+ }
33
+
34
+ /** 去掉行尾注释后的"有效内容"(只处理**不在引号里**的 `#`;`#` 前必须有空白或行首)。
35
+ * 例:`name: '@deepseek-ai/dsh-persona' # 注释` → `name: '@deepseek-ai/dsh-persona'`。 */
36
+ function stripTrailingComment(line) {
37
+ const text = String(line)
38
+ let quote = null
39
+ for (let i = 0; i < text.length; i += 1) {
40
+ const ch = text[i]
41
+ if (quote !== null) {
42
+ if (ch === '\\') { i += 1; continue }
43
+ if (ch === quote) quote = null
44
+ continue
45
+ }
46
+ if (ch === "'" || ch === '"') { quote = ch; continue }
47
+ if (ch === '#' && (i === 0 || /\s/u.test(text[i - 1]))) return text.slice(0, i)
48
+ }
49
+ return text
50
+ }
51
+
52
+ /** 值里的引号是否闭合(只判**单个标量内**的成对性;跨行的引号在 YAML 里合法,故不跨行判)。 */
53
+ function quotesBalanced(value) {
54
+ const text = String(value)
55
+ let quote = null
56
+ for (let i = 0; i < text.length; i += 1) {
57
+ const ch = text[i]
58
+ if (quote === null) {
59
+ if (ch === "'" || ch === '"') quote = ch
60
+ continue
61
+ }
62
+ if (quote === '"' && ch === '\\') { i += 1; continue }
63
+ if (ch === quote) {
64
+ // YAML 的单引号转义是 `''`(两个单引号),要一起吃掉
65
+ if (quote === "'" && text[i + 1] === "'") { i += 1; continue }
66
+ quote = null
67
+ }
68
+ }
69
+ return quote === null
70
+ }
71
+
72
+ /** 块标量指示符(`|`、`>`、`|-`、`>+2`、`|2-`…)。返回 null 表示不是块标量。
73
+ * 只有**整个值**就是指示符时才认(`> something` 是普通标量,不是块头),否则会把普通行误判成块。 */
74
+ function blockScalarIndicator(value) {
75
+ const text = String(value).trim()
76
+ const m = text.match(/^([|>])([+-]?\d*|\d*[+-]?)$/u)
77
+ return m === null ? null : m[0]
78
+ }
79
+
80
+ /**
81
+ * 结构校验(**纯函数**,永不抛)。返回 `{ ok, problems: string[] }`。
82
+ * 只判"能确证损坏"的形态(见本文件顶部 ①~④),宁松勿误杀 —— 误杀的代价是预设装不上。
83
+ */
84
+ function validateAgentConfig(text) {
85
+ const source = typeof text === 'string' ? text : String(text ?? '')
86
+ const problems = []
87
+ const lines = source.split(/\r?\n/u)
88
+ const significant = []
89
+ for (let i = 0; i < lines.length; i += 1) {
90
+ const raw = lines[i]
91
+ if (raw.trim() === '') continue
92
+ if (/^\s*#/u.test(raw)) continue
93
+ significant.push({ line: i + 1, raw, indent: indentOf(raw), at: i })
94
+ }
95
+ // ① 空内容 / 全是注释与空行
96
+ if (significant.length === 0) {
97
+ problems.push('没有任何配置行(空文件,或只有注释与空行)—— 框架挂载它等于空预设')
98
+ return { ok: false, problems }
99
+ }
100
+ /** 块标量(`>-` / `|` 等)的内容由框架按字面读,**不是** YAML 结构 —— 必须整段跳过,
101
+ * 否则 persona 那种多行散文会被逐行当成"非法条目"(误杀三个真机预设,2026-09-27 实测)。
102
+ * 结束判据:出现缩进 **小于等于** 块首行缩进的非空行(或文件结束)。 */
103
+ const blockScalarEnd = (startIdx, parentIndent) => {
104
+ let end = startIdx
105
+ for (let i = startIdx + 1; i < lines.length; i += 1) {
106
+ const line = lines[i]
107
+ if (line.trim() === '') { end = i; continue }
108
+ if (indentOf(line) > parentIndent) { end = i; continue }
109
+ break
110
+ }
111
+ return end
112
+ }
113
+ const stack = [] // 已打开的父级:{ indent, opensNested }
114
+ let skipThrough = -1
115
+ for (const item of significant) {
116
+ if (item.at <= skipThrough) continue
117
+ // ② tab 缩进
118
+ if (hasTabIndent(item.raw)) {
119
+ problems.push(`第 ${item.line} 行的缩进里有 tab(YAML 禁止用 tab 缩进)`)
120
+ continue
121
+ }
122
+ const content = stripTrailingComment(item.raw).replace(/\s+$/u, '')
123
+ if (content.trim() === '') continue
124
+ const body = item.raw.slice(item.indent)
125
+ // ④ 缩进跳级:比当前父级深、但父级没有"还能放子项"的位置
126
+ while (stack.length > 0 && item.indent < stack[stack.length - 1].indent) stack.pop()
127
+ const parent = stack.length > 0 ? stack[stack.length - 1] : null
128
+ if (parent !== null && item.indent > parent.indent && parent.opensNested !== true) {
129
+ problems.push(`第 ${item.line} 行缩进 ${item.indent} 空格,但上一层的 \`${parent.label}\` 不接受子项(缩进跳级/文件被截断)`)
130
+ }
131
+ if (body.startsWith('- ') || body === '-') {
132
+ stack.push({ indent: item.indent, opensNested: true, label: body.slice(0, 40) })
133
+ continue
134
+ }
135
+ const kv = body.match(/^("[^"]*"|'[^']*'|[^\s:#][^:]*?)\s*:\s*(.*)$/u)
136
+ if (kv === null) {
137
+ problems.push(`第 ${item.line} 行不是合法的 YAML 条目(既不是 \`- \` 序列项,也不是 \`键: 值\`):${body.slice(0, 60)}`)
138
+ continue
139
+ }
140
+ const value = kv[2].trim()
141
+ // ③ 未闭合的引号(只在单行内判;`#` 已剥掉,行尾也不会再有注释)
142
+ if (!quotesBalanced(value)) {
143
+ problems.push(`第 ${item.line} 行的值里有未闭合的引号:${value.slice(0, 60)}`)
144
+ }
145
+ const isBlock = blockScalarIndicator(value) !== null
146
+ if (isBlock) skipThrough = blockScalarEnd(item.at, item.indent)
147
+ stack.push({ indent: item.indent, opensNested: value === '' || isBlock, label: `${kv[1].trim()}:` })
148
+ }
149
+ return { ok: problems.length === 0, problems }
150
+ }
151
+
152
+ /** 校验一个**目录里**的 agent 配置文件(写盘前的调用点用这个)。
153
+ * 返回 `{ ok, file, problems }`;目录里没有 agent.cordis.yml/.yaml 时 ok=true / file=null(不是预设,不归这里管)。 */
154
+ function validateAgentConfigDir(dir, { readFile = null } = {}) {
155
+ const read = typeof readFile === 'function' ? readFile : null
156
+ for (const name of ['agent.cordis.yml', 'agent.cordis.yaml']) {
157
+ const text = read === null ? null : read(name)
158
+ if (text === null || text === undefined) continue
159
+ const result = validateAgentConfig(text)
160
+ return { ...result, file: name }
161
+ }
162
+ return { ok: true, file: null, problems: [] }
163
+ }
164
+
165
+ export { validateAgentConfig, validateAgentConfigDir, stripTrailingComment, quotesBalanced, blockScalarIndicator, hasTabIndent, indentOf }
@@ -37,17 +37,36 @@ function formatRepoSize(sizeKb) {
37
37
  * · state='known' 且 sizeKb > 阈值 → **仍禁**,note 必须含体积、原因与出路(仓库落地 / 本地镜像)
38
38
  * · state='unknown'(探测失败/超时/404)→ **保守但不沉默**:保持禁令 + note 说明"尺寸未知,已保守跳过"
39
39
  * 返回的 note 一律非空 —— 静默跳过正是这次事故的另一半(用户什么都看不到)。 */
40
- function sourceChannelGate({ state = 'unknown', sizeKb = null } = {}, thresholdMb = GIT_MAX_REPO_MB) {
40
+ function sourceChannelGate(options = {}, thresholdMb = GIT_MAX_REPO_MB) {
41
+ // ⚠️ 这里**故意不用解构默认值**(旧代码是 `{ state = 'unknown', sizeKb = null } = {}`):
42
+ // 解构的 `= null` 会在"键存在但值是 undefined"时也填上 null,于是**原始值被抹掉**——
43
+ // 而本次改错要判的恰恰是"读到的到底是什么",note 里必须分得出 null / undefined / 空串 / false。
44
+ const raw = options !== null && typeof options === 'object' ? options : {}
45
+ const provided = Object.prototype.hasOwnProperty.call(raw, 'sizeKb')
46
+ const state = raw.state === undefined ? 'unknown' : raw.state
47
+ const rawSize = provided ? raw.sizeKb : null
48
+ const sizeKb = rawSize
41
49
  const limit = Number.isFinite(Number(thresholdMb)) && Number(thresholdMb) >= 1 ? Number(thresholdMb) : GIT_MAX_REPO_MB
42
50
  const kb = Number(sizeKb)
43
- const known = state === 'known' && Number.isFinite(kb) && kb >= 0
51
+ // 判据必须落在**原始值**上:旧代码写的是 `Number(sizeKb)` 之后再判有限性,于是
52
+ // `sizeKb: null` → `Number(null) === 0` → 有限且 ≥ 0 → **被当成 0 MB 放行**(`undefined` 同理 → NaN→
53
+ // 有限性为假,那条反倒是禁的;真正漏的是 null / '' / false / [] 这些"能转成 0"的值)。
54
+ // 0 KB 只有在**真的是数字 0**时才是合法尺寸(空仓库;见边界断言),"读不到尺寸"绝不能伪装成 0。
55
+ const numericSize = typeof rawSize === 'number' && Number.isFinite(rawSize) && rawSize >= 0
56
+ const known = state === 'known' && numericSize
44
57
  if (!known) {
58
+ // state='known' 但尺寸不是有限数 = 上游给了自相矛盾的结论(探测层不会这样产出)。
59
+ // 这时必须**如实说清**并保守跳过;旧文案会把它说成"探测失败/超时/404",与真实原因不符。
60
+ const contradiction = state === 'known'
61
+ const shown = provided ? String(rawSize) : 'undefined(未提供)'
45
62
  return {
46
63
  allow: false,
47
64
  state: 'unknown',
48
65
  sizeKb: null,
49
66
  limitMb: limit,
50
- note: '仓库体积未知(GitHub 元数据探测失败/超时/404):已**保守跳过**源码通道(git / archive / 稀疏取源码),避免白拉一个可能几百 MB 的仓库。请重试;或改用「仓库落地」把仓库克隆到本地目录后安装。',
67
+ note: contradiction
68
+ ? `仓库体积读到的不是有效数字(state=known 但 sizeKb=${shown}):已**保守跳过**源码通道(git / archive / 稀疏取源码)—— 尺寸读不出来时绝不能当成 0 MB 放行。请重试;或改用「仓库落地」把仓库克隆到本地目录后安装。`
69
+ : '仓库体积未知(GitHub 元数据探测失败/超时/404):已**保守跳过**源码通道(git / archive / 稀疏取源码),避免白拉一个可能几百 MB 的仓库。请重试;或改用「仓库落地」把仓库克隆到本地目录后安装。',
51
70
  }
52
71
  }
53
72
  if (kb <= limit * 1024) {
@@ -119,12 +119,34 @@ function waitGone(dir, timeoutMs) {
119
119
  })
120
120
  }
121
121
 
122
+ /** 外部删除兜底该用哪条命令(**判据唯一真源**,异步/同步两个兜底共用)。
123
+ *
124
+ * 真机缺陷(0.5.25 及以前,2026-09-27 定位):`clean-residuals` 清的陈旧
125
+ * `fw-quarantine.json.applied-*` 是**文件**,而 shell 兜底一律用 `rmdir /s /q <path>` ——
126
+ * `rmdir` 在 Windows 上只能删**目录**,对文件返回 `exit=267 The directory name is invalid`,
127
+ * 于是"`rmSync` 一次瞬时失败"被兜底放大成**用户可见的「有 1 项没能删除」误报**(文件其实好好的)。
128
+ * 判据:文件 → `del /f /q`;目录 → `rmdir /s /q`;已经不在了 → 视为已达成(**不是失败**)。
129
+ * `lstatSync` 而不是 `statSync`:悬空符号链接本身也要能被删掉。
130
+ * 读不出类型(竞态/权限)→ 走目录形态:目录树是这套兜底原本唯一服务的对象,保持旧行为不倒退。
131
+ * 注意:`del` / `rmdir` 都是 cmd.exe 的**内建命令**,只能经 `cmd.exe /c` 调用,没有对应的可执行文件。 */
132
+ function shellDeleteCommand(dir) {
133
+ if (!existsSync(dir)) return { kind: 'gone', argv: null, method: 'already-gone' }
134
+ let isDirectory = true
135
+ try { isDirectory = lstatSync(dir).isDirectory() } catch { isDirectory = true }
136
+ return isDirectory
137
+ ? { kind: 'dir', argv: ['rmdir', '/s', '/q', dir], method: 'rmdir' }
138
+ : { kind: 'file', argv: ['del', '/f', '/q', dir], method: 'del' }
139
+ }
140
+
122
141
  /** 外部删除兜底:实测本机 PowerShell/.NET 能删掉 Node `rmSync` 静默删不掉的树。 */
123
142
  async function removeViaShell(dir, timeoutMs = 120000) {
124
143
  try {
125
144
  if (process.platform === 'win32') {
126
- await execFileAsync('cmd.exe', ['/c', 'rmdir', '/s', '/q', dir], { windowsHide: true, timeout: timeoutMs })
127
- return { ok: true, method: 'rmdir' }
145
+ const target = shellDeleteCommand(dir)
146
+ if (target.kind === 'gone') return { ok: true, method: target.method }
147
+ await execFileAsync('cmd.exe', ['/c', ...target.argv], { windowsHide: true, timeout: timeoutMs })
148
+ // `del` 对"文件已被别处删掉"会返回 0 又不报错,这里不再多判;调用方仍会核实目标是否消失
149
+ return { ok: true, method: target.method }
128
150
  }
129
151
  await execFileAsync('rm', ['-rf', '--', dir], { timeout: timeoutMs })
130
152
  return { ok: true, method: 'rm' }
@@ -179,12 +201,16 @@ function sleepSync(ms) {
179
201
  } catch {}
180
202
  }
181
203
 
182
- /** 外部删除兜底(同步版):实测本机 `cmd /c rmdir /s /q` 能删掉 Node `rmSync` 静默删不掉的树。 */
204
+ /** 外部删除兜底(同步版):实测本机 `cmd /c rmdir /s /q` 能删掉 Node `rmSync` 静默删不掉的树。
205
+ * 目标类型判据与异步版**同一份**(shellDeleteCommand):文件用 `del /f /q`、目录用 `rmdir /s /q` ——
206
+ * 旧实现对文件用 `rmdir` 只会拿到 exit=267,把"其实删得掉"的文件报成删不掉(见 shellDeleteCommand 注释)。 */
183
207
  function removeViaShellSync(dir, timeoutMs = 120000) {
184
208
  try {
185
209
  if (process.platform === 'win32') {
186
- execFileSync('cmd.exe', ['/c', 'rmdir', '/s', '/q', dir], { windowsHide: true, timeout: timeoutMs, stdio: 'ignore' })
187
- return { ok: true, method: 'rmdir' }
210
+ const target = shellDeleteCommand(dir)
211
+ if (target.kind === 'gone') return { ok: true, method: target.method }
212
+ execFileSync('cmd.exe', ['/c', ...target.argv], { windowsHide: true, timeout: timeoutMs, stdio: 'ignore' })
213
+ return { ok: true, method: target.method }
188
214
  }
189
215
  execFileSync('rm', ['-rf', '--', dir], { timeout: timeoutMs, stdio: 'ignore' })
190
216
  return { ok: true, method: 'rm' }
@@ -395,4 +421,4 @@ function startTrashCleanup(opts = {}) {
395
421
  }
396
422
  }
397
423
 
398
- export { copyTree, queuedWrite, cleanupStalePackageDir, removeDirVerified, removeDirVerifiedAsync, removeDirVerifiedWithRetry, removeViaShellSync, sleepSync, clearReadonly, waitGone, removeViaShell, writeQueue, isLockFailure, trashPathFor, disposeDir, disposeNote, trashScanRoots, findTrashDirs, cleanupTrashDirs, startTrashCleanup, TRASH_PREFIX, TRASH_RE, TRASH_CLEAN_LIMIT, TRASH_CLEAN_ITEM_MS }
424
+ export { copyTree, queuedWrite, cleanupStalePackageDir, removeDirVerified, removeDirVerifiedAsync, removeDirVerifiedWithRetry, removeViaShellSync, sleepSync, clearReadonly, waitGone, removeViaShell, shellDeleteCommand, writeQueue, isLockFailure, trashPathFor, disposeDir, disposeNote, trashScanRoots, findTrashDirs, cleanupTrashDirs, startTrashCleanup, TRASH_PREFIX, TRASH_RE, TRASH_CLEAN_LIMIT, TRASH_CLEAN_ITEM_MS }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@noob-stupid/dsh-plugin-console",
3
- "version": "0.5.25",
3
+ "version": "0.5.27",
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",