@gmickel/gno 1.12.4 → 1.14.0

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.
Files changed (61) hide show
  1. package/README.md +126 -59
  2. package/assets/skill/SKILL.md +8 -1
  3. package/assets/skill/cli-reference.md +18 -7
  4. package/assets/skill/mcp-reference.md +22 -3
  5. package/package.json +3 -1
  6. package/src/app/constants.ts +43 -10
  7. package/src/app/index-name.ts +127 -0
  8. package/src/cli/commands/doctor-activation.ts +151 -0
  9. package/src/cli/commands/doctor.ts +41 -16
  10. package/src/cli/commands/get.ts +18 -0
  11. package/src/cli/commands/mcp/atomic-config-write.ts +118 -0
  12. package/src/cli/commands/mcp/config-discovery.ts +42 -0
  13. package/src/cli/commands/mcp/config-editors.ts +432 -0
  14. package/src/cli/commands/mcp/config.ts +63 -160
  15. package/src/cli/commands/mcp/install.ts +75 -37
  16. package/src/cli/commands/mcp/paths.ts +141 -136
  17. package/src/cli/commands/mcp/server-entry.ts +66 -0
  18. package/src/cli/commands/mcp/status.ts +189 -57
  19. package/src/cli/commands/mcp/target-display.ts +30 -0
  20. package/src/cli/commands/mcp/uninstall.ts +29 -31
  21. package/src/cli/commands/mcp/yaml-config-editor.ts +257 -0
  22. package/src/cli/commands/mcp/yaml-layout-scanner.ts +447 -0
  23. package/src/cli/commands/multi-get.ts +31 -6
  24. package/src/cli/commands/status.ts +107 -11
  25. package/src/cli/program.ts +66 -20
  26. package/src/core/activation-connector-health.ts +19 -0
  27. package/src/core/activation-probe-plan.ts +321 -0
  28. package/src/core/activation-probe.ts +138 -0
  29. package/src/core/activation-receipt-store.ts +39 -0
  30. package/src/core/activation-status.ts +513 -0
  31. package/src/core/activation-verifier.ts +416 -0
  32. package/src/core/connector-environment.ts +68 -0
  33. package/src/core/connector-policy.ts +233 -0
  34. package/src/core/connector-verification-target.ts +150 -0
  35. package/src/core/connector-verifier.ts +497 -0
  36. package/src/core/indexed-reference.ts +33 -8
  37. package/src/core/runtime-entrypoint.ts +24 -0
  38. package/src/mcp/activation-verification-mode.ts +4 -0
  39. package/src/mcp/server.ts +9 -2
  40. package/src/sdk/client.ts +7 -0
  41. package/src/sdk/types.ts +1 -0
  42. package/src/serve/activation-health.ts +91 -0
  43. package/src/serve/background-runtime.ts +11 -1
  44. package/src/serve/connectors.ts +164 -19
  45. package/src/serve/public/components/BootstrapStatus.tsx +94 -1
  46. package/src/serve/public/components/FirstRunWizard.tsx +13 -51
  47. package/src/serve/public/components/HealthCenter.tsx +8 -2
  48. package/src/serve/public/globals.built.css +1 -1
  49. package/src/serve/public/pages/Connectors.tsx +216 -55
  50. package/src/serve/public/pages/Dashboard.tsx +1 -0
  51. package/src/serve/routes/api.ts +152 -8
  52. package/src/serve/server.ts +44 -9
  53. package/src/serve/status-model.ts +4 -0
  54. package/src/serve/status.ts +79 -35
  55. package/src/store/activation-receipts.ts +390 -0
  56. package/src/store/index.ts +8 -0
  57. package/src/store/migrations/012-activation-receipts.ts +38 -0
  58. package/src/store/migrations/013-fts-sync-marker.ts +39 -0
  59. package/src/store/migrations/index.ts +4 -0
  60. package/src/store/sqlite/adapter.ts +313 -53
  61. package/src/store/types.ts +118 -0
@@ -0,0 +1,257 @@
1
+ /** Byte-preserving YAML editor for the LibreChat MCP server entry. */
2
+
3
+ import type { ParsedServerEntry } from "./config-editors.js";
4
+
5
+ import { CliError } from "../../errors.js";
6
+ import {
7
+ scanYamlTarget,
8
+ type YamlMapSpan,
9
+ type YamlPairSpan,
10
+ } from "./yaml-layout-scanner.js";
11
+
12
+ const yamlNewline = (content: string): "\r\n" | "\n" =>
13
+ content.includes("\r\n") ? "\r\n" : "\n";
14
+
15
+ const parseYamlObject = (
16
+ content: string,
17
+ configPath: string
18
+ ): Record<string, unknown> => {
19
+ let parsed: unknown;
20
+ try {
21
+ parsed = Bun.YAML.parse(content);
22
+ } catch {
23
+ throw new CliError("RUNTIME", `Malformed YAML in ${configPath}.`);
24
+ }
25
+ if (parsed === null || parsed === undefined) return {};
26
+ if (typeof parsed !== "object" || Array.isArray(parsed)) {
27
+ throw new CliError("RUNTIME", `YAML root in ${configPath} must be a map.`);
28
+ }
29
+ return parsed as Record<string, unknown>;
30
+ };
31
+
32
+ const semanticEntry = (
33
+ content: string,
34
+ configPath: string,
35
+ serversKey: string
36
+ ): ParsedServerEntry => {
37
+ const root = parseYamlObject(content, configPath);
38
+ const servers = root[serversKey];
39
+ if (servers === undefined) return { exists: false };
40
+ if (
41
+ servers === null ||
42
+ typeof servers !== "object" ||
43
+ Array.isArray(servers)
44
+ ) {
45
+ throw new CliError(
46
+ "RUNTIME",
47
+ `${serversKey} in ${configPath} must be a map.`
48
+ );
49
+ }
50
+ if (!Object.hasOwn(servers, "gno")) return { exists: false };
51
+ return { exists: true, entry: (servers as Record<string, unknown>).gno };
52
+ };
53
+
54
+ const serializeEntry = (entry: unknown): string => {
55
+ const serialized = JSON.stringify(entry);
56
+ if (serialized === undefined) {
57
+ throw new CliError("RUNTIME", "Cannot serialize the GNO MCP YAML entry.");
58
+ }
59
+ return serialized;
60
+ };
61
+
62
+ const validateEdit = (
63
+ content: string,
64
+ configPath: string,
65
+ serversKey: string,
66
+ expected: unknown
67
+ ): string => {
68
+ const layout = scanYamlTarget(content, configPath, serversKey);
69
+ const actual = semanticEntry(content, configPath, serversKey);
70
+ if (expected === undefined) {
71
+ if (layout.gnoPair || actual.exists) {
72
+ throw new CliError(
73
+ "RUNTIME",
74
+ `Cannot safely remove ${serversKey}.gno from ${configPath}.`
75
+ );
76
+ }
77
+ } else if (
78
+ !layout.gnoPair ||
79
+ !actual.exists ||
80
+ JSON.stringify(actual.entry) !== JSON.stringify(expected)
81
+ ) {
82
+ throw new CliError(
83
+ "RUNTIME",
84
+ `Cannot safely update ${serversKey}.gno in ${configPath}.`
85
+ );
86
+ }
87
+ return content;
88
+ };
89
+
90
+ const flowInsert = (
91
+ content: string,
92
+ map: YamlMapSpan,
93
+ entry: unknown
94
+ ): string => {
95
+ const inner = content.slice(map.start + 1, map.end - 1);
96
+ // Keep the original inner prefix byte-for-byte so removing this first item
97
+ // restores the pre-install map, including its exact leading whitespace.
98
+ const separator = inner.trim() ? "," : "";
99
+ return `${content.slice(0, map.start + 1)}gno: ${serializeEntry(entry)}${separator}${content.slice(map.start + 1)}`;
100
+ };
101
+
102
+ const flowRemove = (
103
+ content: string,
104
+ map: YamlMapSpan,
105
+ pair: YamlPairSpan
106
+ ): string => {
107
+ if (map.items.length === 1) {
108
+ return `${content.slice(0, pair.start)}${content.slice(pair.end)}`;
109
+ }
110
+ if (pair.commaAfter !== undefined) {
111
+ const end = pair.commaAfter + 1;
112
+ return `${content.slice(0, pair.start)}${content.slice(end)}`;
113
+ }
114
+ if (pair.commaBefore !== undefined) {
115
+ return `${content.slice(0, pair.commaBefore)}${content.slice(pair.end)}`;
116
+ }
117
+ throw new CliError("RUNTIME", "Cannot safely remove flow YAML entry.");
118
+ };
119
+
120
+ const blockPairText = (
121
+ indent: string,
122
+ entry: unknown,
123
+ newline: string
124
+ ): string => `${indent}gno: ${serializeEntry(entry)}${newline}`;
125
+
126
+ const blockPairEnd = (
127
+ content: string,
128
+ pair: YamlPairSpan,
129
+ indent: number
130
+ ): number => {
131
+ const lineBreak = content.indexOf("\n", pair.start);
132
+ const lineEnd = lineBreak === -1 ? content.length : lineBreak + 1;
133
+ const firstLineValue = content.slice(pair.valueStart, lineEnd).trim();
134
+ if (
135
+ firstLineValue &&
136
+ !firstLineValue.startsWith("#") &&
137
+ !/^[>|]/u.test(firstLineValue)
138
+ ) {
139
+ return lineEnd;
140
+ }
141
+
142
+ let lastOwnedEnd = lineEnd;
143
+ let cursor = lineEnd;
144
+ while (cursor < pair.end) {
145
+ const nextBreak = content.indexOf("\n", cursor);
146
+ const fullEnd =
147
+ nextBreak === -1 ? pair.end : Math.min(pair.end, nextBreak + 1);
148
+ const line = content
149
+ .slice(cursor, nextBreak === -1 ? pair.end : nextBreak)
150
+ .replace(/\r$/u, "");
151
+ const trimmed = line.trim();
152
+ const childIndent = /^ */u.exec(line)?.[0].length ?? 0;
153
+ if (trimmed && childIndent > indent) {
154
+ lastOwnedEnd = fullEnd;
155
+ }
156
+ cursor = fullEnd;
157
+ }
158
+ return lastOwnedEnd;
159
+ };
160
+
161
+ export function getYamlServerEntry(
162
+ content: string,
163
+ configPath: string,
164
+ serversKey: string
165
+ ): ParsedServerEntry {
166
+ const layout = scanYamlTarget(content, configPath, serversKey);
167
+ const entry = semanticEntry(content, configPath, serversKey);
168
+ if (entry.exists !== Boolean(layout.gnoPair)) {
169
+ throw new CliError("RUNTIME", `Ambiguous YAML in ${configPath}.`);
170
+ }
171
+ return entry;
172
+ }
173
+
174
+ export function setYamlServerEntry(
175
+ content: string,
176
+ configPath: string,
177
+ serversKey: string,
178
+ entry: unknown
179
+ ): string {
180
+ parseYamlObject(content, configPath);
181
+ const target = scanYamlTarget(content, configPath, serversKey);
182
+ const newline = yamlNewline(content);
183
+ let updated: string;
184
+ if (!target.servers || !target.serversPair) {
185
+ if (target.root.kind === "flow") {
186
+ throw new CliError(
187
+ "RUNTIME",
188
+ `Cannot safely add ${serversKey} to a flow-style YAML root in ${configPath}.`
189
+ );
190
+ }
191
+ // With no root pair, prepend before comment-only content. That keeps the
192
+ // generated block independently removable even when the original file had
193
+ // no final newline.
194
+ const insertion = target.root.items[0]?.start ?? 0;
195
+ const prefix =
196
+ insertion > 0 && content[insertion - 1] !== "\n" ? newline : "";
197
+ updated = `${content.slice(0, insertion)}${prefix}${serversKey}:${newline} gno: ${serializeEntry(entry)}${newline}${content.slice(insertion)}`;
198
+ } else if (target.servers.kind === "flow") {
199
+ if (target.gnoPair) {
200
+ updated = `${content.slice(0, target.gnoPair.start)}gno: ${serializeEntry(entry)}${content.slice(target.gnoPair.end)}`;
201
+ } else {
202
+ updated = flowInsert(content, target.servers, entry);
203
+ }
204
+ } else {
205
+ const pairText = blockPairText(target.servers.indent, entry, newline);
206
+ if (target.gnoPair) {
207
+ const end = blockPairEnd(
208
+ content,
209
+ target.gnoPair,
210
+ target.servers.indent.length
211
+ );
212
+ updated = `${content.slice(0, target.gnoPair.start)}${pairText}${content.slice(end)}`;
213
+ } else {
214
+ // Insert before an existing child so a no-final-newline file needs no
215
+ // synthetic delimiter and uninstall can restore it byte-for-byte.
216
+ const insertion = target.servers.items[0]?.start ?? target.servers.end;
217
+ const prefix =
218
+ insertion > 0 && content[insertion - 1] !== "\n" ? newline : "";
219
+ updated = `${content.slice(0, insertion)}${prefix}${pairText}${content.slice(insertion)}`;
220
+ }
221
+ }
222
+ return validateEdit(updated, configPath, serversKey, entry);
223
+ }
224
+
225
+ export function removeYamlServerEntry(
226
+ content: string,
227
+ configPath: string,
228
+ serversKey: string
229
+ ): { content: string; removed: boolean } {
230
+ parseYamlObject(content, configPath);
231
+ const target = scanYamlTarget(content, configPath, serversKey);
232
+ if (!target.servers || !target.serversPair || !target.gnoPair) {
233
+ return { content, removed: false };
234
+ }
235
+ let updated: string;
236
+ if (target.servers.kind === "flow") {
237
+ updated = flowRemove(content, target.servers, target.gnoPair);
238
+ } else if (target.servers.items.length === 1) {
239
+ const end = blockPairEnd(
240
+ content,
241
+ target.gnoPair,
242
+ target.servers.indent.length
243
+ );
244
+ updated = `${content.slice(0, target.serversPair.start)}${content.slice(end)}`;
245
+ } else {
246
+ const end = blockPairEnd(
247
+ content,
248
+ target.gnoPair,
249
+ target.servers.indent.length
250
+ );
251
+ updated = `${content.slice(0, target.gnoPair.start)}${content.slice(end)}`;
252
+ }
253
+ return {
254
+ content: validateEdit(updated, configPath, serversKey, undefined),
255
+ removed: true,
256
+ };
257
+ }
@@ -0,0 +1,447 @@
1
+ /** Narrow, source-preserving YAML layout scanner for LibreChat's MCP map. */
2
+
3
+ import { CliError } from "../../errors.js";
4
+
5
+ export interface YamlPairSpan {
6
+ key: string;
7
+ start: number;
8
+ end: number;
9
+ valueStart: number;
10
+ valueEnd: number;
11
+ commaBefore?: number;
12
+ commaAfter?: number;
13
+ }
14
+
15
+ export interface YamlMapSpan {
16
+ kind: "block" | "flow";
17
+ start: number;
18
+ end: number;
19
+ indent: string;
20
+ items: YamlPairSpan[];
21
+ }
22
+
23
+ export interface YamlTargetLayout {
24
+ root: YamlMapSpan;
25
+ serversPair?: YamlPairSpan;
26
+ servers?: YamlMapSpan;
27
+ gnoPair?: YamlPairSpan;
28
+ }
29
+
30
+ interface LineSpan {
31
+ start: number;
32
+ end: number;
33
+ fullEnd: number;
34
+ text: string;
35
+ }
36
+
37
+ const countIndent = (text: string): number => {
38
+ const match = /^ */u.exec(text);
39
+ return match?.[0].length ?? 0;
40
+ };
41
+
42
+ const linesOf = (content: string): LineSpan[] => {
43
+ const lines: LineSpan[] = [];
44
+ let start = 0;
45
+ while (start < content.length) {
46
+ const newline = content.indexOf("\n", start);
47
+ const fullEnd = newline === -1 ? content.length : newline + 1;
48
+ const rawEnd = newline === -1 ? content.length : newline;
49
+ const end =
50
+ rawEnd > start && content[rawEnd - 1] === "\r" ? rawEnd - 1 : rawEnd;
51
+ lines.push({ start, end, fullEnd, text: content.slice(start, end) });
52
+ start = fullEnd;
53
+ }
54
+ return lines;
55
+ };
56
+
57
+ const decodeKey = (raw: string, configPath: string): string => {
58
+ const key = raw.trim();
59
+ if (!key || /^[?!&*]/u.test(key) || key.startsWith("!!")) {
60
+ throw new CliError(
61
+ "RUNTIME",
62
+ `Cannot safely edit complex YAML keys in ${configPath}.`
63
+ );
64
+ }
65
+ if (key.startsWith('"')) {
66
+ try {
67
+ const decoded = JSON.parse(key) as unknown;
68
+ if (typeof decoded === "string") {
69
+ return decoded;
70
+ }
71
+ } catch {
72
+ // Fall through to the bounded error below.
73
+ }
74
+ throw new CliError("RUNTIME", `Malformed YAML key in ${configPath}.`);
75
+ }
76
+ if (key.startsWith("'")) {
77
+ if (!key.endsWith("'")) {
78
+ throw new CliError("RUNTIME", `Malformed YAML key in ${configPath}.`);
79
+ }
80
+ return key.slice(1, -1).replaceAll("''", "'");
81
+ }
82
+ if (/[[\]{}#,]|:\s/u.test(key)) {
83
+ throw new CliError(
84
+ "RUNTIME",
85
+ `Cannot safely edit YAML key in ${configPath}.`
86
+ );
87
+ }
88
+ return key;
89
+ };
90
+
91
+ const findColon = (text: string): number => {
92
+ let quote: "'" | '"' | null = null;
93
+ for (let index = 0; index < text.length; index += 1) {
94
+ const char = text[index];
95
+ if (quote === '"') {
96
+ if (char === "\\") {
97
+ index += 1;
98
+ } else if (char === '"') {
99
+ quote = null;
100
+ }
101
+ continue;
102
+ }
103
+ if (quote === "'") {
104
+ if (char === "'" && text[index + 1] === "'") {
105
+ index += 1;
106
+ } else if (char === "'") {
107
+ quote = null;
108
+ }
109
+ continue;
110
+ }
111
+ if (char === '"' || char === "'") {
112
+ quote = char;
113
+ } else if (char === ":") {
114
+ return index;
115
+ }
116
+ }
117
+ return -1;
118
+ };
119
+
120
+ const linePair = (
121
+ line: LineSpan,
122
+ indent: number,
123
+ configPath: string
124
+ ): Omit<YamlPairSpan, "end" | "valueEnd"> | null => {
125
+ if (countIndent(line.text) !== indent) {
126
+ return null;
127
+ }
128
+ const body = line.text.slice(indent);
129
+ if (!body.trim() || body.trimStart().startsWith("#")) {
130
+ return null;
131
+ }
132
+ if (body.startsWith("?")) {
133
+ throw new CliError(
134
+ "RUNTIME",
135
+ `Cannot safely edit explicit YAML keys in ${configPath}.`
136
+ );
137
+ }
138
+ const colon = findColon(body);
139
+ if (colon === -1) {
140
+ throw new CliError("RUNTIME", `Malformed YAML in ${configPath}.`);
141
+ }
142
+ const key = decodeKey(body.slice(0, colon), configPath);
143
+ const valueOffset = line.start + indent + colon + 1;
144
+ const valueStart =
145
+ valueOffset +
146
+ (/^ */u.exec(line.text.slice(valueOffset - line.start))?.[0].length ?? 0);
147
+ return { key, start: line.start, valueStart };
148
+ };
149
+
150
+ const blockMap = (
151
+ content: string,
152
+ start: number,
153
+ end: number,
154
+ indent: number,
155
+ configPath: string
156
+ ): YamlMapSpan => {
157
+ const candidates = linesOf(content).filter(
158
+ (line) => line.start >= start && line.start < end
159
+ );
160
+ const partials: Array<Omit<YamlPairSpan, "end" | "valueEnd">> = [];
161
+ for (const line of candidates) {
162
+ const pair = linePair(line, indent, configPath);
163
+ if (pair) {
164
+ partials.push(pair);
165
+ }
166
+ }
167
+ const items = partials.map((pair, index) => {
168
+ const pairEnd = partials[index + 1]?.start ?? end;
169
+ return { ...pair, end: pairEnd, valueEnd: pairEnd };
170
+ });
171
+ return { kind: "block", start, end, indent: " ".repeat(indent), items };
172
+ };
173
+
174
+ const matchingBrace = (
175
+ content: string,
176
+ open: number,
177
+ configPath: string
178
+ ): number => {
179
+ let quote: "'" | '"' | null = null;
180
+ let comment = false;
181
+ const stack: string[] = [];
182
+ for (let index = open; index < content.length; index += 1) {
183
+ const char = content[index];
184
+ if (comment) {
185
+ if (char === "\n") {
186
+ comment = false;
187
+ }
188
+ continue;
189
+ }
190
+ if (quote === '"') {
191
+ if (char === "\\") {
192
+ index += 1;
193
+ } else if (char === '"') {
194
+ quote = null;
195
+ }
196
+ continue;
197
+ }
198
+ if (quote === "'") {
199
+ if (char === "'" && content[index + 1] === "'") {
200
+ index += 1;
201
+ } else if (char === "'") {
202
+ quote = null;
203
+ }
204
+ continue;
205
+ }
206
+ if (char === '"' || char === "'") {
207
+ quote = char;
208
+ } else if (char === "#") {
209
+ comment = true;
210
+ } else if (char === "{" || char === "[") {
211
+ stack.push(char);
212
+ } else if (char === "}" || char === "]") {
213
+ const expected = char === "}" ? "{" : "[";
214
+ if (stack.pop() !== expected) {
215
+ break;
216
+ }
217
+ if (stack.length === 0) {
218
+ return index;
219
+ }
220
+ }
221
+ }
222
+ throw new CliError("RUNTIME", `Malformed flow YAML in ${configPath}.`);
223
+ };
224
+
225
+ interface FlowSegment {
226
+ start: number;
227
+ end: number;
228
+ commaBefore?: number;
229
+ commaAfter?: number;
230
+ }
231
+
232
+ const flowSegments = (
233
+ content: string,
234
+ start: number,
235
+ end: number
236
+ ): FlowSegment[] => {
237
+ const commas: number[] = [];
238
+ let quote: "'" | '"' | null = null;
239
+ let comment = false;
240
+ let depth = 0;
241
+ for (let index = start; index < end; index += 1) {
242
+ const char = content[index];
243
+ if (comment) {
244
+ if (char === "\n") comment = false;
245
+ continue;
246
+ }
247
+ if (quote === '"') {
248
+ if (char === "\\") index += 1;
249
+ else if (char === '"') quote = null;
250
+ continue;
251
+ }
252
+ if (quote === "'") {
253
+ if (char === "'" && content[index + 1] === "'") index += 1;
254
+ else if (char === "'") quote = null;
255
+ continue;
256
+ }
257
+ if (char === '"' || char === "'") quote = char;
258
+ else if (char === "#") comment = true;
259
+ else if (char === "{" || char === "[") depth += 1;
260
+ else if (char === "}" || char === "]") depth -= 1;
261
+ else if (char === "," && depth === 0) commas.push(index);
262
+ }
263
+ const boundaries = [start - 1, ...commas, end];
264
+ const segments: FlowSegment[] = [];
265
+ for (let index = 0; index < boundaries.length - 1; index += 1) {
266
+ const rawStart = (boundaries[index] as number) + 1;
267
+ const rawEnd = boundaries[index + 1] as number;
268
+ if (!content.slice(rawStart, rawEnd).trim()) continue;
269
+ segments.push({
270
+ start: rawStart,
271
+ end: rawEnd,
272
+ commaBefore: index > 0 ? boundaries[index] : undefined,
273
+ commaAfter:
274
+ index < boundaries.length - 2 ? boundaries[index + 1] : undefined,
275
+ });
276
+ }
277
+ return segments;
278
+ };
279
+
280
+ const flowMap = (
281
+ content: string,
282
+ open: number,
283
+ configPath: string
284
+ ): YamlMapSpan => {
285
+ const close = matchingBrace(content, open, configPath);
286
+ const items = flowSegments(content, open + 1, close).map((segment) => {
287
+ const raw = content.slice(segment.start, segment.end);
288
+ const colon = findColon(raw);
289
+ if (colon === -1) {
290
+ throw new CliError("RUNTIME", `Malformed flow YAML in ${configPath}.`);
291
+ }
292
+ const leading = /^\s*/u.exec(raw)?.[0].length ?? 0;
293
+ const trailing = /\s*$/u.exec(raw)?.[0].length ?? 0;
294
+ const start = segment.start + leading;
295
+ const end = segment.end - trailing;
296
+ const key = decodeKey(raw.slice(leading, colon), configPath);
297
+ const rawAfterColon = raw.slice(colon + 1);
298
+ const valueStart =
299
+ segment.start +
300
+ colon +
301
+ 1 +
302
+ (/^\s*/u.exec(rawAfterColon)?.[0].length ?? 0);
303
+ return {
304
+ key,
305
+ start,
306
+ end,
307
+ valueStart,
308
+ valueEnd: end,
309
+ commaBefore: segment.commaBefore,
310
+ commaAfter: segment.commaAfter,
311
+ };
312
+ });
313
+ return { kind: "flow", start: open, end: close + 1, indent: "", items };
314
+ };
315
+
316
+ const uniquePair = (
317
+ map: YamlMapSpan,
318
+ key: string,
319
+ configPath: string
320
+ ): YamlPairSpan | undefined => {
321
+ const matches = map.items.filter((item) => item.key === key);
322
+ if (matches.length > 1) {
323
+ throw new CliError(
324
+ "RUNTIME",
325
+ `Malformed YAML in ${configPath}: duplicate ${key} key.`
326
+ );
327
+ }
328
+ return matches[0];
329
+ };
330
+
331
+ const valueToken = (content: string, pair: YamlPairSpan): string =>
332
+ content
333
+ .slice(
334
+ pair.valueStart,
335
+ Math.min(
336
+ pair.valueEnd,
337
+ content.indexOf("\n", pair.valueStart) === -1
338
+ ? content.length
339
+ : content.indexOf("\n", pair.valueStart)
340
+ )
341
+ )
342
+ .trim();
343
+
344
+ const childBlockMap = (
345
+ content: string,
346
+ pair: YamlPairSpan,
347
+ parentIndent: number,
348
+ configPath: string
349
+ ): YamlMapSpan => {
350
+ const lines = linesOf(content).filter(
351
+ (line) => line.start >= pair.valueStart && line.start < pair.end
352
+ );
353
+ const firstChild = lines.find((line) => {
354
+ const trimmed = line.text.trim();
355
+ return (
356
+ trimmed &&
357
+ !trimmed.startsWith("#") &&
358
+ countIndent(line.text) > parentIndent
359
+ );
360
+ });
361
+ const indent = firstChild ? countIndent(firstChild.text) : parentIndent + 2;
362
+ return blockMap(content, pair.valueStart, pair.end, indent, configPath);
363
+ };
364
+
365
+ export function scanYamlTarget(
366
+ content: string,
367
+ configPath: string,
368
+ serversKey: string
369
+ ): YamlTargetLayout {
370
+ if (/^(?:---|\.\.\.)(?:\s*(?:#.*)?)?$/mu.test(content)) {
371
+ throw new CliError(
372
+ "RUNTIME",
373
+ `Multi-document YAML is unsupported in ${configPath}.`
374
+ );
375
+ }
376
+ const first = /[^\s#]/u.exec(content);
377
+ if (first?.[0] === "{") {
378
+ const root = flowMap(content, first.index, configPath);
379
+ if (root.items.some((item) => item.key === "<<")) {
380
+ throw new CliError(
381
+ "RUNTIME",
382
+ `Cannot safely edit a merged YAML root in ${configPath}.`
383
+ );
384
+ }
385
+ const serversPair = uniquePair(root, serversKey, configPath);
386
+ if (!serversPair) return { root };
387
+ const token = valueToken(content, serversPair);
388
+ if (!token.startsWith("{")) {
389
+ throw new CliError(
390
+ "RUNTIME",
391
+ `${serversKey} in ${configPath} must be a map.`
392
+ );
393
+ }
394
+ const servers = flowMap(content, serversPair.valueStart, configPath);
395
+ return {
396
+ root,
397
+ serversPair,
398
+ servers,
399
+ gnoPair: uniquePair(servers, "gno", configPath),
400
+ };
401
+ }
402
+
403
+ const root = blockMap(content, 0, content.length, 0, configPath);
404
+ if (root.items.some((item) => item.key === "<<")) {
405
+ throw new CliError(
406
+ "RUNTIME",
407
+ `Cannot safely edit a merged YAML root in ${configPath}.`
408
+ );
409
+ }
410
+ const serversPair = uniquePair(root, serversKey, configPath);
411
+ if (!serversPair) return { root };
412
+ const token = valueToken(content, serversPair);
413
+ if (/^(?:[&*!]|!!)/u.test(token)) {
414
+ throw new CliError(
415
+ "RUNTIME",
416
+ `Cannot safely edit aliased or tagged ${serversKey} in ${configPath}.`
417
+ );
418
+ }
419
+ let servers: YamlMapSpan;
420
+ if (token.startsWith("{")) {
421
+ servers = flowMap(content, serversPair.valueStart, configPath);
422
+ } else if (
423
+ !token ||
424
+ token.startsWith("#") ||
425
+ token.startsWith("\r") ||
426
+ token.startsWith("\n")
427
+ ) {
428
+ servers = childBlockMap(content, serversPair, 0, configPath);
429
+ } else {
430
+ throw new CliError(
431
+ "RUNTIME",
432
+ `${serversKey} in ${configPath} must be a map.`
433
+ );
434
+ }
435
+ if (servers.items.some((item) => item.key === "<<")) {
436
+ throw new CliError(
437
+ "RUNTIME",
438
+ `Cannot safely edit merged ${serversKey} in ${configPath}.`
439
+ );
440
+ }
441
+ return {
442
+ root,
443
+ serversPair,
444
+ servers,
445
+ gnoPair: uniquePair(servers, "gno", configPath),
446
+ };
447
+ }