dsh-plugin-pack 0.0.0-stage → 1.0.2

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,672 @@
1
+ /**
2
+ * @file dsh `cordis.patch.yml` 的文本块级处理。
3
+ *
4
+ * 设计前提:**不解析 YAML**。只做两件事:
5
+ * 1. 按「列 0 的 `- `」切顶层条目;
6
+ * 2. 按缩进切 `- insert:` 的子条目。
7
+ * 其余内容(注释、`!!js` 表达式、多行 config、缩进风格)一律逐字保留,
8
+ * 因为用户手写的注释和表达式比结构化往返更重要。
9
+ *
10
+ * 行尾:只识别 LF(`\n`)与 CRLF(`\r\n`),不处理孤立 CR。
11
+ * 顶层条目的 `raw` 含其上方紧邻的注释块(中间无空行),不含结尾多余空行。
12
+ */
13
+
14
+ /** @typedef {{ start: number, end: number, content: string, eol: string, raw: string }} Line */
15
+
16
+ /**
17
+ * 顶层条目。
18
+ * @typedef {object} TopEntry
19
+ * @property {string} raw 条目原文(含上方紧邻注释块,不含结尾多余空行)
20
+ * @property {number} start 在原文中的起始字符偏移
21
+ * @property {number} end 在原文中的结束字符偏移(不含)
22
+ * @property {string|null} id 条目自身 id;insert 块为 null
23
+ * @property {'row'|'insert'|'other'} kind 条目类型
24
+ * @property {string[]} names 条目内出现的所有 `name:` 值(已去引号)
25
+ * @property {boolean|null} disabled 仅 row 且显式 `disabled: true/false` 时有值,否则 null
26
+ */
27
+
28
+ /**
29
+ * insert 子条目。
30
+ * @typedef {object} InsertChild
31
+ * @property {string} raw 子条目原文(含上方紧邻注释块,不含结尾多余空行)
32
+ * @property {string|null} id 子条目 id
33
+ * @property {string[]} names 子条目内出现的所有 `name:` 值(已去引号)
34
+ * @property {boolean|null} disabled 显式 `disabled: true/false` 时有值,否则 null
35
+ */
36
+
37
+ /** 行首键:可选缩进 + 可选 `- ` + key + `:` + 剩余。 */
38
+ const KEY_RE = /^([ \t]*)(?:-[ \t]+)?([A-Za-z0-9_$@][A-Za-z0-9_$@.-]*)[ \t]*:(.*)$/;
39
+
40
+ /** 行首列表项:缩进 + `- ` + key + `:` + 剩余。 */
41
+ const HEAD_RE = /^([ \t]*)-([ \t]+)([A-Za-z0-9_$@][A-Za-z0-9_$@.-]*)[ \t]*:(.*)$/;
42
+
43
+ /** 可裸写的 plain scalar 字符集(首字符不能是 `@`、`-`、`#` 等指示符)。 */
44
+ const PLAIN_RE = /^[A-Za-z0-9_][A-Za-z0-9_.\-/@]*$/;
45
+
46
+ /** YAML 1.1/1.2 里会被解释成布尔或 null 的裸值。 */
47
+ const RESERVED_RE = /^(?:true|false|yes|no|on|off|null|~|y|n)$/i;
48
+
49
+ /** 数字形(含下划线、指数),裸写会被解释成数字。 */
50
+ const NUMBER_RE = /^[+-]?\d[\d_]*(?:\.\d*)?(?:[eE][+-]?\d+)?$/;
51
+
52
+ /** 日期/时间形,裸写会被解释成 timestamp。 */
53
+ const DATE_RE = /^\d{4}-\d{2}-\d{2}(?:[Tt ].*)?$/;
54
+
55
+ /**
56
+ * 把文本切成带字符偏移的行。
57
+ * @param {string} text 任意文本
58
+ * @returns {Line[]} 行对象数组;空串返回空数组
59
+ */
60
+ function scanLines(text) {
61
+ /** @type {Line[]} */
62
+ const lines = [];
63
+ let start = 0;
64
+ while (start < text.length) {
65
+ const nl = text.indexOf('\n', start);
66
+ if (nl === -1) {
67
+ const raw = text.slice(start);
68
+ lines.push({ start, end: text.length, content: raw, eol: '', raw });
69
+ break;
70
+ }
71
+ const raw = text.slice(start, nl + 1);
72
+ let content = raw.slice(0, -1);
73
+ let eol = '\n';
74
+ if (content.endsWith('\r')) {
75
+ content = content.slice(0, -1);
76
+ eol = '\r\n';
77
+ }
78
+ lines.push({ start, end: nl + 1, content, eol, raw });
79
+ start = nl + 1;
80
+ }
81
+ return lines;
82
+ }
83
+
84
+ /**
85
+ * 是否空白行(只含空格 / 制表符)。
86
+ * @param {string} content 行内容(不含行尾)
87
+ * @returns {boolean}
88
+ */
89
+ function isBlank(content) {
90
+ return content.trim() === '';
91
+ }
92
+
93
+ /**
94
+ * 是否注释行(`#` 前只允许空白)。
95
+ * @param {string} content 行内容(不含行尾)
96
+ * @returns {boolean}
97
+ */
98
+ function isComment(content) {
99
+ return /^[ \t]*#/.test(content);
100
+ }
101
+
102
+ /**
103
+ * 取一行的缩进宽度。
104
+ * @param {string} content 行内容(不含行尾)
105
+ * @returns {number}
106
+ */
107
+ function indentOf(content) {
108
+ const m = /^[ \t]*/.exec(content);
109
+ return m ? m[0].length : 0;
110
+ }
111
+
112
+ /**
113
+ * 探测换行风格:第一处 `\n` 前是 `\r` 则为 CRLF。
114
+ * @param {string} text 任意文本
115
+ * @returns {'\n'|'\r\n'}
116
+ */
117
+ function detectEol(text) {
118
+ const i = typeof text === 'string' ? text.indexOf('\n') : -1;
119
+ if (i > 0 && text[i - 1] === '\r') return '\r\n';
120
+ return '\n';
121
+ }
122
+
123
+ /**
124
+ * 把文本里的所有换行统一成指定风格。
125
+ * @param {string} text 任意文本
126
+ * @param {'\n'|'\r\n'} eol 目标换行
127
+ * @returns {string}
128
+ */
129
+ function normalizeEol(text, eol) {
130
+ return text.replace(/\r\n|\n|\r/g, eol);
131
+ }
132
+
133
+ /**
134
+ * 从「冒号后面的原文」取出标量值,并返回剩余部分(通常是行尾注释)。
135
+ * 只做一层引号 / 注释处理,不解析复杂结构。
136
+ * @param {string} text 冒号后的原文
137
+ * @returns {{ value: string, rest: string }} 去引号的值与剩余原文
138
+ */
139
+ function takeScalar(text) {
140
+ const s = String(text ?? '').trim();
141
+ if (s.startsWith("'")) {
142
+ let out = '';
143
+ for (let i = 1; i < s.length; i += 1) {
144
+ if (s[i] === "'") {
145
+ if (s[i + 1] === "'") {
146
+ out += "'";
147
+ i += 1;
148
+ continue;
149
+ }
150
+ return { value: out, rest: s.slice(i + 1) };
151
+ }
152
+ out += s[i];
153
+ }
154
+ return { value: out, rest: '' };
155
+ }
156
+ if (s.startsWith('"')) {
157
+ let out = '';
158
+ for (let i = 1; i < s.length; i += 1) {
159
+ const ch = s[i];
160
+ if (ch === '\\' && i + 1 < s.length) {
161
+ const nx = s[i + 1];
162
+ out += nx === 'n' ? '\n' : nx === 't' ? '\t' : nx === 'r' ? '\r' : nx;
163
+ i += 1;
164
+ continue;
165
+ }
166
+ if (ch === '"') return { value: out, rest: s.slice(i + 1) };
167
+ out += ch;
168
+ }
169
+ return { value: out, rest: '' };
170
+ }
171
+ const hash = s.search(/[ \t]#/);
172
+ if (hash === -1) return { value: s, rest: '' };
173
+ return { value: s.slice(0, hash).trim(), rest: s.slice(hash) };
174
+ }
175
+
176
+ /**
177
+ * 把裸值按 YAML 规则判成布尔;不是布尔返回 null。
178
+ * @param {string} text 冒号后的原文
179
+ * @returns {boolean|null}
180
+ */
181
+ function boolOf(text) {
182
+ const v = takeScalar(text).value.toLowerCase();
183
+ if (v === 'true') return true;
184
+ if (v === 'false') return false;
185
+ return null;
186
+ }
187
+
188
+ /**
189
+ * 判断条目类型。
190
+ * @param {string} firstContent 条目的 `- ` 行内容(不含行尾)
191
+ * @returns {'row'|'insert'|'other'}
192
+ */
193
+ function kindOf(firstContent) {
194
+ if (/^-[ \t]+insert[ \t]*:/.test(firstContent)) return 'insert';
195
+ if (/^-[ \t]+id[ \t]*:/.test(firstContent)) return 'row';
196
+ return 'other';
197
+ }
198
+
199
+ /**
200
+ * 读取一个条目 / 子条目的元信息。
201
+ * @param {Line[]} lines 条目所在的行数组
202
+ * @param {number} from 条目 `- ` 行的下标(不含上方注释)
203
+ * @param {number} to 条目结束行下标(不含)
204
+ * @returns {{ id: string|null, names: string[], disabled: boolean|null, baseIndent: number, ownIndent: number }}
205
+ */
206
+ function analyzeEntry(lines, from, to) {
207
+ const head = HEAD_RE.exec(lines[from].content);
208
+ const baseIndent = head ? head[1].length : 0;
209
+ const ownIndent = head ? baseIndent + 1 + head[2].length : baseIndent + 2;
210
+ /** @type {string[]} */
211
+ const names = [];
212
+ /** @type {string|null} */
213
+ let id = null;
214
+ /** @type {boolean|null} */
215
+ let disabled = null;
216
+
217
+ if (head) {
218
+ if (head[3] === 'name') names.push(takeScalar(head[4]).value);
219
+ if (head[3] === 'id') id = takeScalar(head[4]).value;
220
+ if (head[3] === 'disabled') disabled = boolOf(head[4]);
221
+ }
222
+
223
+ for (let i = from + 1; i < to; i += 1) {
224
+ const content = lines[i].content;
225
+ if (isBlank(content) || isComment(content)) continue;
226
+ const m = KEY_RE.exec(content);
227
+ if (!m) continue;
228
+ if (m[2] === 'name') {
229
+ names.push(takeScalar(m[3]).value);
230
+ continue;
231
+ }
232
+ if (m[1].length !== ownIndent) continue;
233
+ if (m[2] === 'id' && id === null) id = takeScalar(m[3]).value;
234
+ else if (m[2] === 'disabled') disabled = boolOf(m[3]);
235
+ }
236
+
237
+ return { id, names, disabled, baseIndent, ownIndent };
238
+ }
239
+
240
+ /**
241
+ * 去掉结尾的空行(保留最后一个非空行自己的行尾)。
242
+ * @param {string} text 待处理文本
243
+ * @returns {string} 去掉结尾空行后的文本
244
+ */
245
+ function trimBlankLines(text) {
246
+ const lines = scanLines(text);
247
+ let a = 0;
248
+ let b = lines.length - 1;
249
+ while (a <= b && isBlank(lines[a].content)) a += 1;
250
+ while (b >= a && isBlank(lines[b].content)) b -= 1;
251
+ if (a > b) return '';
252
+ const slice = text.slice(lines[a].start, lines[b].end);
253
+ return slice.endsWith('\r\n') ? slice.slice(0, -2) : slice.endsWith('\n') ? slice.slice(0, -1) : slice;
254
+ }
255
+
256
+ /**
257
+ * 按列 0 的 `- ` 切顶层条目。
258
+ *
259
+ * 规则:
260
+ * - 缩进行、空行不产生条目;
261
+ * - 条目上方紧邻的注释块(中间没有空行)归入该条目的 `raw`;
262
+ * - 条目 `raw` 不含结尾多余空行;
263
+ * - 空文件、纯注释文件返回 `[]`。
264
+ *
265
+ * @param {string} text 整个 `cordis.patch.yml` 文本
266
+ * @returns {TopEntry[]} 顶层条目数组(按出现顺序)
267
+ * @throws {TypeError} 入参不是字符串时抛出
268
+ */
269
+ export function splitTopLevel(text) {
270
+ if (typeof text !== 'string') {
271
+ throw new TypeError('splitTopLevel 需要字符串输入');
272
+ }
273
+ if (text.length === 0) return [];
274
+
275
+ const lines = scanLines(text);
276
+ /** @type {number[]} */
277
+ const starts = [];
278
+ for (let i = 0; i < lines.length; i += 1) {
279
+ if (lines[i].content.startsWith('- ')) starts.push(i);
280
+ }
281
+ if (starts.length === 0) return [];
282
+
283
+ // 每个条目真正的起点:向上吃掉紧邻的注释行(遇到空行或非注释即停)。
284
+ const blocks = starts.map((s) => {
285
+ let b = s;
286
+ while (b > 0 && isComment(lines[b - 1].content)) b -= 1;
287
+ return b;
288
+ });
289
+
290
+ /** @type {TopEntry[]} */
291
+ const entries = [];
292
+ for (let k = 0; k < starts.length; k += 1) {
293
+ const fromLine = blocks[k];
294
+ const startLine = starts[k];
295
+ const stopLine = k + 1 < starts.length ? blocks[k + 1] : lines.length;
296
+
297
+ let lastLine = stopLine - 1;
298
+ while (lastLine > startLine && isBlank(lines[lastLine].content)) lastLine -= 1;
299
+
300
+ const start = lines[fromLine].start;
301
+ const end = lines[lastLine].end;
302
+ const kind = kindOf(lines[startLine].content);
303
+ const meta = analyzeEntry(lines, startLine, stopLine);
304
+
305
+ entries.push({
306
+ raw: text.slice(start, end),
307
+ start,
308
+ end,
309
+ id: kind === 'insert' ? null : meta.id,
310
+ kind,
311
+ names: meta.names,
312
+ disabled: kind === 'row' ? meta.disabled : null,
313
+ });
314
+ }
315
+ return entries;
316
+ }
317
+
318
+ /**
319
+ * 从 insert 块的 `raw` 里拆出子条目(缩进 4 的 `- `)。
320
+ *
321
+ * 子条目上方紧邻的注释块(缩进 >= 4,中间没有空行)归入该子条目。
322
+ *
323
+ * @param {string} raw insert 块原文(`splitTopLevel` 得到的 `raw`)
324
+ * @returns {InsertChild[]} 子条目数组
325
+ * @throws {TypeError} 入参不是字符串时抛出
326
+ */
327
+ export function splitInsertChildren(raw) {
328
+ if (typeof raw !== 'string') {
329
+ throw new TypeError('splitInsertChildren 需要字符串输入');
330
+ }
331
+ if (raw.length === 0) return [];
332
+
333
+ const lines = scanLines(raw);
334
+ /** @type {number[]} */
335
+ const starts = [];
336
+ for (let i = 0; i < lines.length; i += 1) {
337
+ if (/^ {4}- /.test(lines[i].content)) starts.push(i);
338
+ }
339
+ if (starts.length === 0) return [];
340
+
341
+ const blocks = starts.map((s) => {
342
+ let b = s;
343
+ while (b > 0 && isComment(lines[b - 1].content) && indentOf(lines[b - 1].content) >= 4) {
344
+ b -= 1;
345
+ }
346
+ return b;
347
+ });
348
+
349
+ /** @type {InsertChild[]} */
350
+ const children = [];
351
+ for (let k = 0; k < starts.length; k += 1) {
352
+ const startLine = starts[k];
353
+ const stopLine = k + 1 < starts.length ? blocks[k + 1] : lines.length;
354
+ let lastLine = stopLine - 1;
355
+ while (lastLine > startLine && isBlank(lines[lastLine].content)) lastLine -= 1;
356
+
357
+ const meta = analyzeEntry(lines, startLine, stopLine);
358
+ children.push({
359
+ raw: raw.slice(lines[blocks[k]].start, lines[lastLine].end),
360
+ id: meta.id,
361
+ names: meta.names,
362
+ disabled: meta.disabled,
363
+ });
364
+ }
365
+ return children;
366
+ }
367
+
368
+ /**
369
+ * 把名字里的反斜杠统一成正斜杠,去掉结尾分隔符,便于跨平台比较。
370
+ * @param {string} value 原始路径
371
+ * @returns {string} 归一化后的路径(未做大小写处理)
372
+ */
373
+ function normalizePath(value) {
374
+ return String(value ?? '')
375
+ .trim()
376
+ .replace(/\\/g, '/')
377
+ .replace(/\/{2,}/g, '/')
378
+ .replace(/\/+$/, '');
379
+ }
380
+
381
+ /**
382
+ * 判断某个顶层条目是否属于给定插件。
383
+ *
384
+ * 匹配规则(任一命中即可):
385
+ * 1. `entry.id === plugin.name`;
386
+ * 2. `entry.names` 里有一项等于 `plugin.name`;
387
+ * 3. `entry.names` 里有一项落在 `plugin.dirs` 的某个目录下(按 `/` 或 `\` 分段)。
388
+ *
389
+ * 路径比较忽略大小写(Windows 语义),id / name 的相等比较区分大小写。
390
+ *
391
+ * @param {TopEntry} entry `splitTopLevel` 的条目
392
+ * @param {{ name: string, dirs?: string[] }} plugin 插件描述
393
+ * @returns {boolean} 是否匹配
394
+ */
395
+ export function entryMatchesPlugin(entry, plugin) {
396
+ if (!entry || !plugin || typeof plugin.name !== 'string') return false;
397
+ const name = plugin.name.trim();
398
+ if (name === '') return false;
399
+
400
+ if (entry.id !== null && entry.id !== undefined && entry.id === name) return true;
401
+
402
+ const names = Array.isArray(entry.names) ? entry.names : [];
403
+ if (names.includes(name)) return true;
404
+
405
+ const dirs = Array.isArray(plugin.dirs) ? plugin.dirs : [];
406
+ const targets = names.map(normalizePath).filter((n) => n !== '');
407
+ if (targets.length === 0) return false;
408
+ for (const dir of dirs) {
409
+ const base = normalizePath(dir).toLowerCase();
410
+ if (base === '') continue;
411
+ for (const target of targets) {
412
+ const p = target.toLowerCase();
413
+ if (p === base || p.startsWith(`${base}/`)) return true;
414
+ }
415
+ }
416
+ return false;
417
+ }
418
+
419
+ /**
420
+ * 收集文件里所有已存在的 id(含 insert 子条目的 id)。
421
+ * @param {string} text 整个 `cordis.patch.yml` 文本
422
+ * @returns {Set<string>} id 集合(不含 null / 空串)
423
+ */
424
+ export function existingIds(text) {
425
+ /** @type {Set<string>} */
426
+ const ids = new Set();
427
+ for (const entry of splitTopLevel(text)) {
428
+ if (entry.id) ids.add(entry.id);
429
+ if (entry.kind === 'insert') {
430
+ for (const child of splitInsertChildren(entry.raw)) {
431
+ if (child.id) ids.add(child.id);
432
+ }
433
+ }
434
+ }
435
+ return ids;
436
+ }
437
+
438
+ /**
439
+ * 给多行文本统一加缩进,空行(含纯空白行)不加。
440
+ * @param {string} raw 多行文本
441
+ * @param {number} spaces 缩进空格数
442
+ * @returns {string} 加好缩进的文本
443
+ * @throws {TypeError} 入参类型不对时抛出
444
+ */
445
+ export function indentBlock(raw, spaces) {
446
+ if (typeof raw !== 'string') {
447
+ throw new TypeError('indentBlock 需要字符串输入');
448
+ }
449
+ const n = Number(spaces);
450
+ if (!Number.isFinite(n) || n < 0) {
451
+ throw new TypeError('indentBlock 的 spaces 必须是非负数字');
452
+ }
453
+ const pad = ' '.repeat(Math.floor(n));
454
+ if (pad === '') return raw;
455
+ return raw.replace(/^([ \t]*)(?=\S)/gm, (match) => pad + match);
456
+ }
457
+
458
+ /**
459
+ * 判断字符串能否作为 YAML plain scalar 裸写。
460
+ * @param {string} text 待判断的值
461
+ * @returns {boolean}
462
+ */
463
+ function isPlainSafe(text) {
464
+ if (text.length === 0) return false;
465
+ if (!PLAIN_RE.test(text)) return false;
466
+ if (RESERVED_RE.test(text)) return false;
467
+ if (NUMBER_RE.test(text)) return false;
468
+ if (DATE_RE.test(text)) return false;
469
+ return true;
470
+ }
471
+
472
+ /**
473
+ * YAML 标量安全化:需要引号时用单引号包裹,内部单引号双写转义。
474
+ * @param {unknown} value 任意值(null / undefined 视为空串)
475
+ * @returns {string} 可直接写进 YAML 的标量
476
+ */
477
+ export function yamlScalar(value) {
478
+ const text = value === null || value === undefined ? '' : String(value);
479
+ if (isPlainSafe(text)) return text;
480
+ return `'${text.replace(/'/g, "''")}'`;
481
+ }
482
+
483
+ /**
484
+ * 按原文的引号风格格式化一个新值。
485
+ * @param {string} originalText 冒号后的原值文本
486
+ * @param {string} value 新值
487
+ * @returns {string} 格式化后的标量
488
+ */
489
+ function formatScalarLike(originalText, value) {
490
+ const v = String(originalText ?? '').trim();
491
+ if (v.startsWith("'")) return `'${value.replace(/'/g, "''")}'`;
492
+ if (v.startsWith('"')) return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
493
+ return yamlScalar(value);
494
+ }
495
+
496
+ /**
497
+ * 从一条 row 原文里取 id;取不到返回 null。
498
+ *
499
+ * 只看前两个非空非注释行:
500
+ * - 首行是 `- id:`(缩进 0~4:顶层 row 与 insert 子条目两种形态)→ 取该值;
501
+ * - 否则次行是缩进 2 的 `id:`(row 首行是 `- name:` 之类时)→ 取该值;
502
+ * - insert 整块(首行 `- insert:`,次行是缩进 4 的子条目)→ null。
503
+ * 更深缩进(config 列表项,如 llm models)不会命中。
504
+ *
505
+ * @param {string} raw row 原文(顶层 row 或 insert 子条目)
506
+ * @returns {string|null} id 值(已去引号)
507
+ */
508
+ export function rowId(raw) {
509
+ if (typeof raw !== 'string' || raw.length === 0) return null;
510
+ let seen = 0;
511
+ for (const line of scanLines(raw)) {
512
+ if (isBlank(line.content) || isComment(line.content)) continue;
513
+ if (seen === 0) {
514
+ const dashed = /^ {0,4}-[ \t]+id[ \t]*:(.*)$/.exec(line.content);
515
+ if (dashed) return takeScalar(dashed[1]).value;
516
+ } else if (seen === 1) {
517
+ const own = /^ {2}id[ \t]*:(.*)$/.exec(line.content);
518
+ if (own) return takeScalar(own[1]).value;
519
+ return null;
520
+ }
521
+ seen += 1;
522
+ }
523
+ return null;
524
+ }
525
+
526
+ /**
527
+ * 把一条 row 里的顶层 `name:` 值替换成 newName,保持引号风格。
528
+ *
529
+ * 顶层指列 0 的 `- name:` 或缩进 2 的 `name:`;更深层的 `name:` 不动。
530
+ * 找不到时在 row 末尾补一行 ` name: ...`。
531
+ *
532
+ * @param {string} raw row 原文
533
+ * @param {string} newName 新名字
534
+ * @returns {string} 替换后的新文本
535
+ * @throws {TypeError} 入参不是字符串时抛出
536
+ */
537
+ export function replaceRowName(raw, newName) {
538
+ if (typeof raw !== 'string') {
539
+ throw new TypeError('replaceRowName 需要字符串输入');
540
+ }
541
+ if (raw.length === 0) {
542
+ throw new Error('replaceRowName 收到空文本,无法定位 name');
543
+ }
544
+ const name = String(newName ?? '');
545
+
546
+ const lines = scanLines(raw);
547
+ for (let i = 0; i < lines.length; i += 1) {
548
+ const content = lines[i].content;
549
+ if (isComment(content)) continue;
550
+ const m = /^([ \t]*(?:-[ \t]+)?name[ \t]*:)(.*)$/.exec(content);
551
+ if (!m) continue;
552
+ const prefix = /^[ \t]*/.exec(m[1])[0];
553
+ const dashed = /^[ \t]*-[ \t]+/.test(m[1]);
554
+ if (dashed && prefix.length !== 0) continue;
555
+ if (!dashed && prefix.length !== 2) continue;
556
+
557
+ const parsed = takeScalar(m[2]);
558
+ const replacement = `${m[1]} ${formatScalarLike(m[2], name)}${parsed.rest}`;
559
+ const at = lines[i].start;
560
+ return raw.slice(0, at) + replacement + raw.slice(at + content.length);
561
+ }
562
+
563
+ const eol = detectEol(raw);
564
+ const tail = raw.endsWith('\n') ? '' : eol;
565
+ return `${raw}${tail} name: ${yamlScalar(name)}`;
566
+ }
567
+
568
+ /**
569
+ * 把一条 row 的 config 整段替换。
570
+ *
571
+ * `configBody` 是不含 `config:` 行的 YAML 片段,缩进由函数处理(相对 `config:` 再缩进 2)。
572
+ * config 块的范围是 `config:` 行之后所有缩进更深的连续非空行。
573
+ * 找不到 `config:` 行时在 row 末尾追加。
574
+ *
575
+ * @param {string} raw row 原文
576
+ * @param {string} configBody 新的 config 内容(可为空串,空则写成 `config: {}`)
577
+ * @returns {string} 替换后的新文本
578
+ * @throws {TypeError} 入参不是字符串时抛出
579
+ */
580
+ export function replaceRowConfig(raw, configBody) {
581
+ if (typeof raw !== 'string') {
582
+ throw new TypeError('replaceRowConfig 需要字符串输入');
583
+ }
584
+ const eol = detectEol(raw);
585
+ const body = trimBlankLines(normalizeEol(String(configBody ?? ''), eol));
586
+ const lines = scanLines(raw);
587
+
588
+ for (let i = 0; i < lines.length; i += 1) {
589
+ const content = lines[i].content;
590
+ if (isComment(content)) continue;
591
+ const m = /^([ \t]*)(-[ \t]+)?config[ \t]*:(.*)$/.exec(content);
592
+ if (!m) continue;
593
+ const indent = m[1].length;
594
+ const dashed = Boolean(m[2]);
595
+ if (dashed && indent !== 0) continue;
596
+ if (!dashed && indent !== 2) continue;
597
+
598
+ let last = i;
599
+ for (let j = i + 1; j < lines.length; j += 1) {
600
+ const next = lines[j].content;
601
+ if (isBlank(next)) break;
602
+ if (indentOf(next) <= indent) break;
603
+ last = j;
604
+ }
605
+
606
+ const pad = ' '.repeat(indent);
607
+ const head =
608
+ body === ''
609
+ ? `${pad}config: {}`
610
+ : `${pad}config:${eol}${indentBlock(body, indent + 2)}`;
611
+ const trailingEol = lines[last].eol;
612
+ const startOffset = lines[i].start;
613
+ const endOffset = lines[last].end;
614
+ return raw.slice(0, startOffset) + head + trailingEol + raw.slice(endOffset);
615
+ }
616
+
617
+ const tail = raw.endsWith('\n') ? '' : eol;
618
+ if (body === '') return `${raw}${tail} config: {}`;
619
+ return `${raw}${tail} config:${eol}${indentBlock(body, 4)}`;
620
+ }
621
+
622
+ /**
623
+ * 把若干条 patch 行包成一个新的顶层 `- insert:` 块追加到 text 末尾。
624
+ *
625
+ * 输出结构:
626
+ * ```
627
+ * 原文(保证以单个换行结尾)
628
+ * <空行,原文末尾没有空行时>
629
+ * # >>> dsh-plugin-pack <comment>
630
+ * - insert:
631
+ * <每条 row 的所有行,统一缩进 4 空格>
632
+ * ```
633
+ * 换行风格沿用原文(CRLF 输入产出全 CRLF)。rows 为空时不追加任何内容。
634
+ *
635
+ * @param {string} text 原文(可为空串)
636
+ * @param {string[]} rows 每条是完整行原文,形如 `- id: foo` 开头的顶层行
637
+ * @param {string} [comment] 注释里跟在 `# >>> dsh-plugin-pack` 后面的说明
638
+ * @returns {{ text: string, added: number }} 新文本与追加的条目数
639
+ * @throws {TypeError} 入参类型不对时抛出
640
+ */
641
+ export function appendInsertBlock(text, rows, comment = '') {
642
+ if (typeof text !== 'string') {
643
+ throw new TypeError('appendInsertBlock 的 text 必须是字符串');
644
+ }
645
+ if (!Array.isArray(rows)) {
646
+ throw new TypeError('appendInsertBlock 的 rows 必须是字符串数组');
647
+ }
648
+ for (const row of rows) {
649
+ if (typeof row !== 'string') {
650
+ throw new TypeError('appendInsertBlock 的 rows 每一项都必须是字符串');
651
+ }
652
+ }
653
+ const list = rows.filter((row) => trimBlankLines(row) !== '');
654
+ if (list.length === 0) return { text, added: 0 };
655
+
656
+ const eol = detectEol(text);
657
+ const label = String(comment ?? '').replace(/\s*\r?\n\s*/g, ' ').trim();
658
+ const head = label === '' ? '# >>> dsh-plugin-pack' : `# >>> dsh-plugin-pack ${label}`;
659
+
660
+ let base = text;
661
+ if (base.length > 0) {
662
+ if (!base.endsWith('\n')) base += eol;
663
+ if (!/(?:\r?\n)[ \t]*(?:\r?\n)$/.test(base)) base += eol;
664
+ }
665
+
666
+ const body = list
667
+ .map((row) => indentBlock(normalizeEol(trimBlankLines(row), eol), 4))
668
+ .join(eol);
669
+
670
+ const block = `${head}${eol}- insert:${eol}${body}${eol}`;
671
+ return { text: base + block, added: list.length };
672
+ }