@dnalec/dsh-auto-approve 0.2.0 → 0.2.1
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/CHANGELOG.md +64 -0
- package/README.md +21 -12
- package/README.zh.md +21 -10
- package/client.js +300 -54
- package/locales.mjs +40 -4
- package/package.json +3 -2
- package/src/index.mjs +99 -16
- package/src/preset-patch.mjs +331 -76
- package/src/rules.mjs +214 -70
- package/src/util.mjs +43 -2
package/src/preset-patch.mjs
CHANGED
|
@@ -1,21 +1,252 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* 把 auto-approve 预设写入 profile 的 cordis.patch.yml。
|
|
3
3
|
* 权限表冻结,不能运行时扩展 presets。改 sandbox 后必须重启并重新选预设。
|
|
4
|
-
*
|
|
4
|
+
*
|
|
5
|
+
* 这个文件里所有「是否已存在 auto-approve」的判断都必须用**缩进键锚定**,
|
|
6
|
+
* 不能用 `text.includes('auto-approve:')`:后者会被注释、description、块标量
|
|
7
|
+
* 里的同名子串骗到,结果是预设永远不装且 UI 不报警。
|
|
8
|
+
* 空 patch(`[]`、`[] # 注释`、`---` + `[]`、注释 + `[]`、只有注释)必须整段替换成块,
|
|
9
|
+
* 绝不能拼成 `[]\n- id: permission`(YAML 两个根节点,profile 直接起不来)。
|
|
5
10
|
*/
|
|
6
11
|
import { readFileSync, writeFileSync } from 'node:fs'
|
|
12
|
+
import { dirname, join } from 'node:path'
|
|
7
13
|
import { autoApprovePresetYaml, FULL_PERMISSION_BLOCK, normalizePresetSandbox } from './rules.mjs'
|
|
8
14
|
|
|
15
|
+
/** auto-approve 预设键:必须缩进(`presets:` 下的子键),排除注释和行内文本。 */
|
|
16
|
+
const AUTO_APPROVE_KEY = /^([ \t]+)auto-approve:[ \t]*\r?$/
|
|
17
|
+
/** permission 行:顶层 `- id: permission`,或 `insert:` 下的缩进形式;允许行尾注释。 */
|
|
18
|
+
const PERMISSION_ROW = /^([ \t]*)- id:[ \t]*permission(?:[ \t]+#.*)?[ \t]*\r?$/
|
|
19
|
+
/** 块内 sandbox 行:保留前缀缩进与行尾,只换值。 */
|
|
20
|
+
const SANDBOX_LINE = /^([ \t]+sandbox:[ \t]*)(\S+?)([ \t]*\r?)$/
|
|
21
|
+
/** 空数组字面量行,允许行尾注释(`[] # empty`)。 */
|
|
22
|
+
const EMPTY_ARRAY_LINE = /^[ \t]*\[\][ \t]*(?:#.*)?\r?$/
|
|
23
|
+
/** YAML 文档标记行:结构行,不算「有内容」。 */
|
|
24
|
+
const DOC_MARKER_LINE = /^[ \t]*(?:---|\.\.\.)[ \t]*(?:#.*)?\r?$/
|
|
25
|
+
/**
|
|
26
|
+
* 文档标记(**只认列 0**:块标量里的同名行是缩进的,属于内容,不能动)。
|
|
27
|
+
* `...` 结束文档:在它后面追加条目会变成两个文档,DSH 的 parsePatchList 直接抛错。
|
|
28
|
+
* 写盘前一律去掉——列 0 的 `---` / `...` 只可能是文档标记。
|
|
29
|
+
*/
|
|
30
|
+
const DOC_END_LINE = /^\.\.\.[ \t]*(?:#.*)?\r?$/
|
|
31
|
+
const DOC_START_LINE = /^---[ \t]*(?:#.*)?\r?$/
|
|
32
|
+
/** 块标量头(`key: |`、`key: >-`、`- |2`):它后面缩进行是字面文本,不是 YAML 键。 */
|
|
33
|
+
const BLOCK_SCALAR_HEADER = /(?:^|:)[ \t]*(?:-[ \t]+)?[|>](?:[+-]?\d*|\d*[+-]?)?[ \t]*(?:#.*)?\r?$/
|
|
34
|
+
|
|
35
|
+
function indentOf(line) {
|
|
36
|
+
return (String(line || '').match(/^[ \t]*/) || [''])[0].length
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** 去掉列 0 的文档标记,保证写出的是**单文档** patch(多文档会被 DSH 拒绝)。 */
|
|
40
|
+
export function stripDocumentMarkers(text) {
|
|
41
|
+
return String(text || '')
|
|
42
|
+
.split('\n')
|
|
43
|
+
.filter((line) => !DOC_END_LINE.test(line) && !DOC_START_LINE.test(line))
|
|
44
|
+
.join('\n')
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** 上一个非空行(用于识别块标量头)。 */
|
|
48
|
+
function previousContentLine(lines, index) {
|
|
49
|
+
for (let i = index - 1; i >= 0; i--) {
|
|
50
|
+
if (String(lines[i]).trim() !== '') return lines[i]
|
|
51
|
+
}
|
|
52
|
+
return ''
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** 找所有 permission 行(顶层或 insert 里的缩进形式)。 */
|
|
56
|
+
function findPermissionRows(lines) {
|
|
57
|
+
const out = []
|
|
58
|
+
for (let i = 0; i < lines.length; i++) {
|
|
59
|
+
const m = PERMISSION_ROW.exec(lines[i])
|
|
60
|
+
if (m) out.push({ line: i, indent: m[1].length })
|
|
61
|
+
}
|
|
62
|
+
return out
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** 父行缩进块的最后一个子行下标(没有子行时返回父行本身)。 */
|
|
66
|
+
function blockEndIndex(lines, parentLine, parentIndent) {
|
|
67
|
+
let last = parentLine
|
|
68
|
+
for (let i = parentLine + 1; i < lines.length; i++) {
|
|
69
|
+
const line = lines[i]
|
|
70
|
+
if (line.trim() === '') continue
|
|
71
|
+
if (indentOf(line) <= parentIndent) break
|
|
72
|
+
last = i
|
|
73
|
+
}
|
|
74
|
+
return last
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* 行内(flow 风格)`config:` 行:` config: {…}` / ` config: !!js …`。
|
|
79
|
+
* 这种形态没法安全做文本插入,只能明确报错,不能猜。
|
|
80
|
+
*/
|
|
81
|
+
function hasInlineConfigLine(lines, row) {
|
|
82
|
+
for (let i = row.line + 1; i < lines.length; i++) {
|
|
83
|
+
const line = lines[i]
|
|
84
|
+
if (line.trim() === '' || line.trim().startsWith('#')) continue
|
|
85
|
+
const indent = indentOf(line)
|
|
86
|
+
if (indent <= row.indent) return false
|
|
87
|
+
if (indent === row.indent + 2 && /^[ \t]*config:[ \t]*\S/.test(line)) return true
|
|
88
|
+
}
|
|
89
|
+
return false
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* 在一行的缩进块里找直接子键(缩进必须大于父行)。
|
|
94
|
+
* 比写死 `^ {4}presets:` 稳:`insert:` 形式与别的缩进都不会错位。
|
|
95
|
+
*/
|
|
96
|
+
function findBlockChild(lines, parentLine, parentIndent, key) {
|
|
97
|
+
const re = new RegExp('^[ \\t]*' + key + ':[ \\t]*\\r?$')
|
|
98
|
+
for (let i = parentLine + 1; i < lines.length; i++) {
|
|
99
|
+
const line = lines[i]
|
|
100
|
+
if (line.trim() === '') continue
|
|
101
|
+
const indent = indentOf(line)
|
|
102
|
+
if (indent <= parentIndent) return null
|
|
103
|
+
if (re.test(line)) return { line: i, indent }
|
|
104
|
+
}
|
|
105
|
+
return null
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* 从 patch 文本里抽出 `permission` 行 `config.presets` 的直接子键。
|
|
110
|
+
* 纯文本扫描(插件不引 YAML 依赖);找不到 permission/presets 时返回 null。
|
|
111
|
+
* 出厂 base 的 patch 与 profile 的 patch 都可以用。
|
|
112
|
+
* @param {string} text - patch 文件全文。
|
|
113
|
+
* @returns {string[] | null}
|
|
114
|
+
*/
|
|
115
|
+
export function extractPresetKeysFromPatchText(text) {
|
|
116
|
+
const lines = String(text || '').split('\n')
|
|
117
|
+
for (const row of findPermissionRows(lines)) {
|
|
118
|
+
const presets = findBlockChild(lines, row.line, row.indent, 'presets')
|
|
119
|
+
if (!presets) continue
|
|
120
|
+
const keys = []
|
|
121
|
+
for (let i = presets.line + 1; i < lines.length; i++) {
|
|
122
|
+
const line = lines[i]
|
|
123
|
+
if (line.trim() === '' || line.trim().startsWith('#')) continue
|
|
124
|
+
const indent = indentOf(line)
|
|
125
|
+
if (indent <= presets.indent) break
|
|
126
|
+
if (indent !== presets.indent + 2) continue
|
|
127
|
+
const m = /^[ \t]*([A-Za-z0-9._-]+):[ \t]*\r?$/.exec(line)
|
|
128
|
+
if (m) keys.push(m[1])
|
|
129
|
+
}
|
|
130
|
+
return keys
|
|
131
|
+
}
|
|
132
|
+
return null
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* 读 DSH 出厂 `@deepseek-ai/dsh-base` 的 permission 预设键。
|
|
137
|
+
* 找不到(打包版、profile 布局不同)时 ok:false —— 调用方必须保持沉默,不要据此报警。
|
|
138
|
+
* @param {string} profileDir - profile 目录(`dirname(profilePatch)`)。
|
|
139
|
+
* @returns {{ ok: boolean, keys: string[], path?: string }}
|
|
140
|
+
*/
|
|
141
|
+
export function readBasePresetKeys(profileDir) {
|
|
142
|
+
const dir = String(profileDir || '')
|
|
143
|
+
if (!dir) return { ok: false, keys: [] }
|
|
144
|
+
for (const root of [join(dir, 'node_modules'), join(dirname(dir), 'node_modules')]) {
|
|
145
|
+
const pkgDir = join(root, '@deepseek-ai', 'dsh-base')
|
|
146
|
+
try {
|
|
147
|
+
const manifest = JSON.parse(readFileSync(join(pkgDir, 'package.json'), 'utf8'))
|
|
148
|
+
const rel = manifest && manifest.dsh && manifest.dsh.bundle && manifest.dsh.bundle.patch
|
|
149
|
+
if (typeof rel !== 'string' || !rel) continue
|
|
150
|
+
const patchPath = join(pkgDir, rel)
|
|
151
|
+
const keys = extractPresetKeysFromPatchText(readFileSync(patchPath, 'utf8'))
|
|
152
|
+
if (keys && keys.length) return { ok: true, keys, path: patchPath }
|
|
153
|
+
} catch { /* 试下一个候选 */ }
|
|
154
|
+
}
|
|
155
|
+
return { ok: false, keys: [] }
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* 出厂预设表与 profile 里那份的差异。
|
|
160
|
+
* 插件写入的 `permission` 行会**整块替换** base 的 config(patch 语义:按 id 覆盖时
|
|
161
|
+
* config 是整体赋值,不做深合并),所以 DSH 新增的预设不会自动出现。
|
|
162
|
+
* 这里只做检测,供日志与设置页提示;不自动改写用户文件。
|
|
163
|
+
* @param {string[]} baseKeys - 出厂键。
|
|
164
|
+
* @param {string[]} ourKeys - profile 里的键。
|
|
165
|
+
* @returns {{ missing: string[], extra: string[] }}
|
|
166
|
+
*/
|
|
167
|
+
export function presetDrift(baseKeys, ourKeys) {
|
|
168
|
+
const base = Array.isArray(baseKeys) ? baseKeys : []
|
|
169
|
+
const ours = Array.isArray(ourKeys) ? ourKeys : []
|
|
170
|
+
return {
|
|
171
|
+
missing: base.filter((key) => !ours.includes(key)),
|
|
172
|
+
extra: ours.filter((key) => !base.includes(key)),
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* 找 auto-approve 预设键的行号与缩进。
|
|
178
|
+
* **只在 permission 行自己的 presets 块里找**:别的插件 presets 里的同名键不算数
|
|
179
|
+
* (误判的代价是「以为已配置」,预设永远不装且 UI 不报警)。
|
|
180
|
+
* 同时排除注释、行内文本,以及块标量(`description: |`)里的同名文本行。
|
|
181
|
+
* @param {string} text - patch 文件全文。
|
|
182
|
+
* @returns {{ line: number, indent: number } | null}
|
|
183
|
+
*/
|
|
184
|
+
export function findAutoApproveKey(text) {
|
|
185
|
+
const lines = String(text || '').split('\n')
|
|
186
|
+
for (const row of findPermissionRows(lines)) {
|
|
187
|
+
const presets = findBlockChild(lines, row.line, row.indent, 'presets')
|
|
188
|
+
if (!presets) continue
|
|
189
|
+
for (let i = presets.line + 1; i < lines.length; i++) {
|
|
190
|
+
const line = lines[i]
|
|
191
|
+
if (line.trim() === '') continue
|
|
192
|
+
const indent = indentOf(line)
|
|
193
|
+
if (indent <= presets.indent) break
|
|
194
|
+
const m = AUTO_APPROVE_KEY.exec(line)
|
|
195
|
+
if (!m) continue
|
|
196
|
+
if (BLOCK_SCALAR_HEADER.test(previousContentLine(lines, i))) continue
|
|
197
|
+
return { line: i, indent: m[1].length }
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
return null
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** patch 里是否真的有 auto-approve 预设(不是注释里提过一句)。 */
|
|
204
|
+
export function hasAutoApprovePreset(text) {
|
|
205
|
+
return findAutoApproveKey(text) !== null
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* patch 是否等价于空数组:没有顶层条目。
|
|
210
|
+
* 只认注释、空行、`---`/`...` 与 `[]`(可带行尾注释)——
|
|
211
|
+
* DSH 生成 profile 时写的模板就是「注释 + `[]`」。
|
|
212
|
+
*/
|
|
213
|
+
export function isPatchArrayEmpty(text) {
|
|
214
|
+
for (const line of String(text || '').split('\n')) {
|
|
215
|
+
const trimmed = line.trim()
|
|
216
|
+
if (!trimmed || trimmed.startsWith('#')) continue
|
|
217
|
+
if (DOC_MARKER_LINE.test(line) || EMPTY_ARRAY_LINE.test(line)) continue
|
|
218
|
+
return false
|
|
219
|
+
}
|
|
220
|
+
return true
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* 把块并入 patch 文本。空数组时**替换**那个 `[]`(保留注释),否则追加。
|
|
225
|
+
* 两种分支都会先去掉列 0 的文档标记:`...` 结束文档,在它后面追加会产出
|
|
226
|
+
* 多文档 YAML,DSH 的 parsePatchList 直接抛错(profile 起不来)。
|
|
227
|
+
* 任何情况下都不会产出「`[]` 后面还有条目」或「两个文档」的非法 YAML。
|
|
228
|
+
*/
|
|
229
|
+
export function composePatchText(text, block) {
|
|
230
|
+
const src = stripDocumentMarkers(text)
|
|
231
|
+
const tail = String(block || '').replace(/^\n/, '').replace(/\s*$/, '')
|
|
232
|
+
if (isPatchArrayEmpty(src)) {
|
|
233
|
+
const head = src.replace(/^[ \t]*\[\][ \t]*(?:#.*)?\r?\n?/m, '').replace(/\s*$/, '')
|
|
234
|
+
return (head ? head + '\n' : '') + tail + '\n'
|
|
235
|
+
}
|
|
236
|
+
return src.replace(/\s*$/, '') + '\n' + tail + '\n'
|
|
237
|
+
}
|
|
238
|
+
|
|
9
239
|
export function getSetupState(patchPath) {
|
|
10
240
|
try {
|
|
11
241
|
const text = readFileSync(patchPath, 'utf8')
|
|
12
242
|
return {
|
|
13
|
-
configured: text
|
|
243
|
+
configured: hasAutoApprovePreset(text),
|
|
14
244
|
patchPath,
|
|
15
245
|
sandbox: readAutoApproveSandboxFromText(text),
|
|
246
|
+
presets: extractPresetKeysFromPatchText(text) || [],
|
|
16
247
|
}
|
|
17
248
|
} catch (e) {
|
|
18
|
-
return { configured: false, patchPath, sandbox: '', error: String((e && e.message) || e) }
|
|
249
|
+
return { configured: false, patchPath, sandbox: '', presets: [], error: String((e && e.message) || e) }
|
|
19
250
|
}
|
|
20
251
|
}
|
|
21
252
|
|
|
@@ -27,100 +258,108 @@ export function readAutoApproveSandbox(patchPath) {
|
|
|
27
258
|
}
|
|
28
259
|
}
|
|
29
260
|
|
|
261
|
+
/** 读 auto-approve 块里的 sandbox 原值(不规范化,UI 要看到真实围栏)。 */
|
|
30
262
|
export function readAutoApproveSandboxFromText(text) {
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
}
|
|
41
|
-
if (!inAuto) continue
|
|
42
|
-
const indent = (line.match(/^[ \t]*/) || [''])[0].length
|
|
43
|
-
if (line.trim() !== '' && indent <= autoIndent) {
|
|
44
|
-
inAuto = false
|
|
45
|
-
continue
|
|
46
|
-
}
|
|
47
|
-
const sandbox = line.match(/^[ \t]+sandbox:\s*(workspace-write|read-only)\s*$/)
|
|
48
|
-
if (sandbox) return sandbox[1]
|
|
263
|
+
const src = String(text || '')
|
|
264
|
+
const key = findAutoApproveKey(src)
|
|
265
|
+
if (!key) return ''
|
|
266
|
+
const lines = src.split('\n')
|
|
267
|
+
for (let i = key.line + 1; i < lines.length; i++) {
|
|
268
|
+
const line = lines[i]
|
|
269
|
+
if (line.trim() !== '' && indentOf(line) <= key.indent) break
|
|
270
|
+
const m = SANDBOX_LINE.exec(line)
|
|
271
|
+
if (m) return m[2]
|
|
49
272
|
}
|
|
50
273
|
return ''
|
|
51
274
|
}
|
|
52
275
|
|
|
276
|
+
/**
|
|
277
|
+
* 只改 auto-approve 块里的 sandbox 值。找不到块或块里没有 sandbox 行时
|
|
278
|
+
* `found: false`,由调用方报错,绝不假装成功(否则 UI 说 read-only、围栏还是全权限)。
|
|
279
|
+
* @returns {{ text: string, changed: boolean, found: boolean }}
|
|
280
|
+
*/
|
|
53
281
|
export function replaceAutoApproveSandbox(text, sandbox) {
|
|
282
|
+
const src = String(text || '')
|
|
54
283
|
const mode = normalizePresetSandbox(sandbox)
|
|
55
|
-
const
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
let
|
|
59
|
-
for (let i = 0; i < lines.length; i++) {
|
|
284
|
+
const key = findAutoApproveKey(src)
|
|
285
|
+
if (!key) return { text: src, changed: false, found: false }
|
|
286
|
+
const lines = src.split('\n')
|
|
287
|
+
for (let i = key.line + 1; i < lines.length; i++) {
|
|
60
288
|
const line = lines[i]
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
}
|
|
67
|
-
if (!inAuto) continue
|
|
68
|
-
const indent = (line.match(/^[ \t]*/) || [''])[0].length
|
|
69
|
-
if (line.trim() !== '' && indent <= autoIndent) {
|
|
70
|
-
inAuto = false
|
|
71
|
-
continue
|
|
72
|
-
}
|
|
73
|
-
if (/^[ \t]+sandbox:\s*(workspace-write|read-only)\s*$/.test(line)) {
|
|
74
|
-
const next = line.replace(/workspace-write|read-only/, mode)
|
|
75
|
-
if (next !== line) {
|
|
76
|
-
lines[i] = next
|
|
77
|
-
changed = true
|
|
78
|
-
}
|
|
79
|
-
inAuto = false
|
|
80
|
-
}
|
|
289
|
+
if (line.trim() !== '' && indentOf(line) <= key.indent) break
|
|
290
|
+
const m = SANDBOX_LINE.exec(line)
|
|
291
|
+
if (!m) continue
|
|
292
|
+
if (m[2] === mode) return { text: src, changed: false, found: true }
|
|
293
|
+
lines[i] = m[1] + mode + m[3]
|
|
294
|
+
return { text: lines.join('\n'), changed: true, found: true }
|
|
81
295
|
}
|
|
82
|
-
return { text:
|
|
296
|
+
return { text: src, changed: false, found: false }
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* 出厂预设表按 delta 重新缩进(从 FULL_PERMISSION_BLOCK 里原样取出,避免维护两份表)。
|
|
301
|
+
* @param {string} sandbox - auto-approve 的沙箱模式。
|
|
302
|
+
* @param {number} delta - 目标 presets 缩进相对出厂 4 空格的偏移。
|
|
303
|
+
*/
|
|
304
|
+
function shippedPresetsBlock(sandbox, delta) {
|
|
305
|
+
const lines = FULL_PERMISSION_BLOCK.split('\n')
|
|
306
|
+
const start = lines.findIndex((line) => /^ {4}presets:[ \t]*$/.test(line))
|
|
307
|
+
/* v8 ignore next -- FULL_PERMISSION_BLOCK 是常量,必然含 presets 键 */
|
|
308
|
+
if (start === -1) return ''
|
|
309
|
+
const pad = ' '.repeat(Math.max(0, delta))
|
|
310
|
+
const body = lines.slice(start).map((line) => (line.trim() === '' ? line : pad + line)).join('\n')
|
|
311
|
+
return body.replace(/\s*$/, '') + '\n' + autoApprovePresetYaml(sandbox, 6 + delta)
|
|
83
312
|
}
|
|
84
313
|
|
|
85
314
|
export function ensureAutoApprovePreset(patchPath, sandbox = 'workspace-write') {
|
|
86
|
-
const yaml = autoApprovePresetYaml(sandbox)
|
|
87
315
|
try {
|
|
88
316
|
const text = readFileSync(patchPath, 'utf8')
|
|
89
|
-
if (text
|
|
317
|
+
if (hasAutoApprovePreset(text)) return { ok: true, status: 'already', needRestart: false }
|
|
90
318
|
|
|
91
319
|
const lines = text.split('\n')
|
|
92
|
-
|
|
93
|
-
for (let i = 0; i < lines.length; i++) {
|
|
94
|
-
if (/^- id:\s*permission\s*$/.test(lines[i])) { permIdx = i; break }
|
|
95
|
-
}
|
|
320
|
+
const rows = findPermissionRows(lines)
|
|
96
321
|
|
|
97
|
-
if (
|
|
98
|
-
const
|
|
99
|
-
|
|
100
|
-
const next = (trimmed === '' || trimmed === '[]')
|
|
101
|
-
? block
|
|
102
|
-
: (text.replace(/\s*$/, '') + '\n' + block)
|
|
103
|
-
writeFileSync(patchPath, next.endsWith('\n') ? next : next + '\n', 'utf8')
|
|
322
|
+
if (rows.length === 0) {
|
|
323
|
+
const block = FULL_PERMISSION_BLOCK + autoApprovePresetYaml(sandbox)
|
|
324
|
+
writeFileSync(patchPath, composePatchText(text, block), 'utf8')
|
|
104
325
|
return { ok: true, status: 'added-entry', needRestart: true }
|
|
105
326
|
}
|
|
106
327
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
if (presetsIdx === -1) {
|
|
113
|
-
return { ok: false, status: 'no-presets-key', needRestart: false, code: 'err.noPresetsKey' }
|
|
328
|
+
// 同一个 id 可以出现多条 patch(后写的 config 生效),取第一条带 presets 的。
|
|
329
|
+
let presets = null
|
|
330
|
+
for (const row of rows) {
|
|
331
|
+
presets = findBlockChild(lines, row.line, row.indent, 'presets')
|
|
332
|
+
if (presets) break
|
|
114
333
|
}
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
334
|
+
|
|
335
|
+
if (!presets) {
|
|
336
|
+
// 行里没有 presets:如果它有块状 `config:`(单独一行、无行内内容),
|
|
337
|
+
// 把整个出厂表插进那个 config,保留用户已有的其它键(例如 defaultPreset)。
|
|
338
|
+
for (const row of rows) {
|
|
339
|
+
const config = findBlockChild(lines, row.line, row.indent, 'config')
|
|
340
|
+
if (!config) continue
|
|
341
|
+
const insertAt = blockEndIndex(lines, config.line, config.indent)
|
|
342
|
+
const block = shippedPresetsBlock(sandbox, config.indent + 2 - 4).replace(/\s*$/, '')
|
|
343
|
+
if (!block) break
|
|
344
|
+
lines.splice(insertAt + 1, 0, block)
|
|
345
|
+
const next = lines.join('\n')
|
|
346
|
+
writeFileSync(patchPath, next.endsWith('\n') ? next : next + '\n', 'utf8')
|
|
347
|
+
return { ok: true, status: 'added-presets-key', needRestart: true }
|
|
348
|
+
}
|
|
349
|
+
// 一条 permission 行都没有可插入的块状 config:要么它只有 name/inject(追加整块没问题,
|
|
350
|
+
// 因为它本来就没提供 config),要么是行内 flow config(文本插入不安全 → 明确报错)。
|
|
351
|
+
if (rows.some((row) => hasInlineConfigLine(lines, row))) {
|
|
352
|
+
return { ok: false, status: 'no-presets-key', needRestart: false, code: 'err.noPresetsKey' }
|
|
353
|
+
}
|
|
354
|
+
const block = FULL_PERMISSION_BLOCK + autoApprovePresetYaml(sandbox)
|
|
355
|
+
writeFileSync(patchPath, composePatchText(text, block), 'utf8')
|
|
356
|
+
return { ok: true, status: 'added-entry', needRestart: true }
|
|
121
357
|
}
|
|
122
|
-
|
|
123
|
-
|
|
358
|
+
// 插到 presets 块最后一个子键之后:块结束于第一条缩进 <= presets 的非空行。
|
|
359
|
+
const insertAt = blockEndIndex(lines, presets.line, presets.indent)
|
|
360
|
+
lines.splice(insertAt + 1, 0, autoApprovePresetYaml(sandbox, presets.indent + 2).replace(/\n$/, ''))
|
|
361
|
+
const next = lines.join('\n')
|
|
362
|
+
writeFileSync(patchPath, next.endsWith('\n') ? next : next + '\n', 'utf8')
|
|
124
363
|
return { ok: true, status: 'added-preset', needRestart: true }
|
|
125
364
|
} catch (e) {
|
|
126
365
|
return { ok: false, status: 'error', needRestart: false, code: 'err.preset', details: { error: String((e && e.message) || e) } }
|
|
@@ -135,8 +374,24 @@ export function setAutoApproveSandbox(patchPath, sandbox) {
|
|
|
135
374
|
try {
|
|
136
375
|
const text = readFileSync(patchPath, 'utf8')
|
|
137
376
|
const replaced = replaceAutoApproveSandbox(text, mode)
|
|
377
|
+
if (!replaced.found) {
|
|
378
|
+
// 预设存在但块里没有 sandbox 行:必须报错,不能返回 ok 让 UI 以为写成功。
|
|
379
|
+
return {
|
|
380
|
+
ok: false,
|
|
381
|
+
status: 'no-sandbox-line',
|
|
382
|
+
needRestart: false,
|
|
383
|
+
sandbox: mode,
|
|
384
|
+
code: 'err.presetSandboxMissing',
|
|
385
|
+
details: { path: String(patchPath || '') },
|
|
386
|
+
}
|
|
387
|
+
}
|
|
138
388
|
if (!replaced.changed) {
|
|
139
|
-
return {
|
|
389
|
+
return {
|
|
390
|
+
ok: true,
|
|
391
|
+
status: ensured.status === 'already' ? 'unchanged' : ensured.status,
|
|
392
|
+
needRestart: Boolean(ensured.needRestart),
|
|
393
|
+
sandbox: mode,
|
|
394
|
+
}
|
|
140
395
|
}
|
|
141
396
|
writeFileSync(patchPath, replaced.text, 'utf8')
|
|
142
397
|
return { ok: true, status: 'updated', needRestart: true, sandbox: mode }
|