@gmickel/gno 1.12.3 → 1.13.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.
- package/README.md +57 -30
- package/assets/skill/SKILL.md +6 -1
- package/assets/skill/cli-reference.md +16 -6
- package/assets/skill/mcp-reference.md +22 -3
- package/package.json +2 -1
- package/src/app/constants.ts +43 -10
- package/src/app/index-name.ts +127 -0
- package/src/cli/commands/doctor-activation.ts +151 -0
- package/src/cli/commands/doctor.ts +41 -16
- package/src/cli/commands/get.ts +18 -0
- package/src/cli/commands/mcp/atomic-config-write.ts +118 -0
- package/src/cli/commands/mcp/config-discovery.ts +42 -0
- package/src/cli/commands/mcp/config-editors.ts +432 -0
- package/src/cli/commands/mcp/config.ts +63 -160
- package/src/cli/commands/mcp/install.ts +75 -37
- package/src/cli/commands/mcp/paths.ts +141 -136
- package/src/cli/commands/mcp/server-entry.ts +66 -0
- package/src/cli/commands/mcp/status.ts +189 -57
- package/src/cli/commands/mcp/target-display.ts +30 -0
- package/src/cli/commands/mcp/uninstall.ts +29 -31
- package/src/cli/commands/mcp/yaml-config-editor.ts +257 -0
- package/src/cli/commands/mcp/yaml-layout-scanner.ts +447 -0
- package/src/cli/commands/multi-get.ts +31 -6
- package/src/cli/commands/status.ts +107 -11
- package/src/cli/program.ts +66 -20
- package/src/core/activation-connector-health.ts +19 -0
- package/src/core/activation-probe-plan.ts +321 -0
- package/src/core/activation-probe.ts +138 -0
- package/src/core/activation-receipt-store.ts +39 -0
- package/src/core/activation-status.ts +513 -0
- package/src/core/activation-verifier.ts +416 -0
- package/src/core/connector-environment.ts +68 -0
- package/src/core/connector-policy.ts +233 -0
- package/src/core/connector-verification-target.ts +150 -0
- package/src/core/connector-verifier.ts +497 -0
- package/src/core/context-resolver.ts +285 -0
- package/src/core/indexed-reference.ts +33 -8
- package/src/core/runtime-entrypoint.ts +24 -0
- package/src/mcp/activation-verification-mode.ts +4 -0
- package/src/mcp/server.ts +9 -2
- package/src/mcp/tools/index.ts +3 -3
- package/src/pipeline/answer-prompt.ts +80 -0
- package/src/pipeline/answer.ts +12 -26
- package/src/pipeline/hybrid.ts +2 -0
- package/src/pipeline/result-context.ts +51 -0
- package/src/pipeline/search.ts +5 -1
- package/src/pipeline/vsearch.ts +2 -0
- package/src/sdk/client.ts +7 -0
- package/src/sdk/types.ts +1 -0
- package/src/serve/activation-health.ts +91 -0
- package/src/serve/background-runtime.ts +11 -1
- package/src/serve/connectors.ts +164 -19
- package/src/serve/public/components/BootstrapStatus.tsx +94 -1
- package/src/serve/public/components/FirstRunWizard.tsx +13 -51
- package/src/serve/public/components/HealthCenter.tsx +8 -2
- package/src/serve/public/globals.built.css +1 -1
- package/src/serve/public/pages/Connectors.tsx +216 -55
- package/src/serve/public/pages/Dashboard.tsx +1 -0
- package/src/serve/routes/api.ts +152 -8
- package/src/serve/server.ts +44 -9
- package/src/serve/status-model.ts +4 -0
- package/src/serve/status.ts +79 -35
- package/src/store/activation-receipts.ts +390 -0
- package/src/store/index.ts +8 -0
- package/src/store/migrations/012-activation-receipts.ts +38 -0
- package/src/store/migrations/013-fts-sync-marker.ts +39 -0
- package/src/store/migrations/index.ts +4 -0
- package/src/store/sqlite/adapter.ts +320 -53
- package/src/store/types.ts +124 -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
|
+
}
|