railcode 0.1.13 → 0.1.16
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 +20 -6
- package/dist/index.js +3217 -173
- package/dist/llm.js +20 -0
- package/dist/manifest.js +695 -0
- package/dist/query.js +94 -0
- package/package.json +2 -2
- package/static/react-template-assets/bigquery.png +0 -0
- package/static/react-template-assets/connectors/clerk.png +0 -0
- package/static/react-template-assets/connectors/mixpanel.png +0 -0
- package/static/react-template-assets/connectors/posthog.svg +1 -0
- package/static/react-template-assets/connectors/resend.svg +1 -0
- package/static/react-template-assets/connectors/stripe.png +0 -0
- package/static/react-template-assets/llm/anthropic.png +0 -0
- package/static/react-template-assets/llm/bedrock.png +0 -0
- package/static/react-template-assets/llm/gemini.png +0 -0
- package/static/react-template-assets/llm/openai.svg +1 -0
- package/static/react-template-assets/postgres.svg +1 -0
- package/static/sdk.js +29 -10
package/dist/manifest.js
ADDED
|
@@ -0,0 +1,695 @@
|
|
|
1
|
+
// Pure helpers for the app manifest (`manifest.yaml`) — local strict parsing so
|
|
2
|
+
// `railcode manifest validate` and `railcode deploy` fail fast with named
|
|
3
|
+
// errors before any upload. The SERVER parse is authoritative (PyYAML +
|
|
4
|
+
// org-resource existence checks); this parser is dependency-free and pinned to
|
|
5
|
+
// the server's accept/reject decisions for the manifest grammar — a shallow
|
|
6
|
+
// mapping with string lists and one nested map:
|
|
7
|
+
//
|
|
8
|
+
// run_as: app # or "user"
|
|
9
|
+
// saved_queries:
|
|
10
|
+
// - my_orders
|
|
11
|
+
// connectors:
|
|
12
|
+
// stripe:
|
|
13
|
+
// - GET /v1/balance
|
|
14
|
+
// - GET /v1/customers/*
|
|
15
|
+
// llm: true
|
|
16
|
+
// adhoc_sql:
|
|
17
|
+
// - warehouse
|
|
18
|
+
//
|
|
19
|
+
// Also supported, matching PyYAML: flow lists (`saved_queries: [a, b]`), a flow
|
|
20
|
+
// map for connectors (`connectors: {stripe: [GET /v1/balance]}`), a whole-
|
|
21
|
+
// document flow mapping (`{run_as: app, llm: true}`), quotes with YAML escape
|
|
22
|
+
// decoding (`''` in single quotes, C-style `\n`/`\uXXXX`/… in double quotes),
|
|
23
|
+
// comments, blank lines, a leading `---` document-start marker, a trailing
|
|
24
|
+
// `...` document-end marker, and duplicate mapping keys (the last value wins —
|
|
25
|
+
// though a superseded value must still be well-formed YAML, as on the server).
|
|
26
|
+
// Bare scalars that YAML resolves to non-strings (`123`, `true`, `null`, …) are
|
|
27
|
+
// rejected where the manifest wants a name, exactly like the server. YAML
|
|
28
|
+
// anchors/aliases are the one known divergence: the server expands them, this
|
|
29
|
+
// parser rejects them with a named error. The shared battery in
|
|
30
|
+
// test/manifest-fixtures.json (also run by
|
|
31
|
+
// backend/tests/test_manifest_parse_fixtures.py) keeps the two parsers pinned.
|
|
32
|
+
export const APP_MANIFEST_NAME = "manifest.yaml";
|
|
33
|
+
const ALLOWED_KEYS = ["run_as", "saved_queries", "connectors", "llm", "adhoc_sql"];
|
|
34
|
+
const HTTP_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"];
|
|
35
|
+
const SAVED_QUERY_NAME = /^[a-z0-9_]{1,80}$/;
|
|
36
|
+
// PyYAML's YAML 1.1 plain-scalar resolver: a bare (unquoted) scalar matching one
|
|
37
|
+
// of these resolves to a non-string (bool/null/int/float/timestamp), and the
|
|
38
|
+
// server rejects a non-string wherever the manifest wants a name. The quirks are
|
|
39
|
+
// deliberate — e.g. "1e3" IS a string, because PyYAML's float exponent requires
|
|
40
|
+
// an explicit sign.
|
|
41
|
+
const YAML_TRUE = /^(?:yes|Yes|YES|true|True|TRUE|on|On|ON)$/;
|
|
42
|
+
const YAML_FALSE = /^(?:no|No|NO|false|False|FALSE|off|Off|OFF)$/;
|
|
43
|
+
const YAML_NON_STRING = [
|
|
44
|
+
YAML_TRUE,
|
|
45
|
+
YAML_FALSE,
|
|
46
|
+
/^(?:~|null|Null|NULL)$/,
|
|
47
|
+
/^[-+]?(?:0b[0-1_]+|0[0-7_]+|(?:0|[1-9][0-9_]*)|0x[0-9a-fA-F_]+|[1-9][0-9_]*(?::[0-5]?[0-9])+)$/,
|
|
48
|
+
/^(?:[-+]?[0-9][0-9_]*\.[0-9_]*(?:[eE][-+][0-9]+)?|\.[0-9_]+(?:[eE][-+][0-9]+)?|[-+]?[0-9][0-9_]*(?::[0-5]?[0-9])+\.[0-9_]*|[-+]?\.(?:inf|Inf|INF)|\.(?:nan|NaN|NAN))$/,
|
|
49
|
+
/^\d{4}-\d\d-\d\d$|^\d{4}-\d\d?-\d\d?(?:[Tt]|[ \t]+)\d\d?:\d\d:\d\d(?:\.\d*)?(?:[ \t]*(?:Z|[-+]\d\d?(?::\d\d)?))?$/,
|
|
50
|
+
];
|
|
51
|
+
function isYamlNonString(value) {
|
|
52
|
+
return YAML_NON_STRING.some((re) => re.test(value));
|
|
53
|
+
}
|
|
54
|
+
export class ManifestParseError extends Error {
|
|
55
|
+
line;
|
|
56
|
+
constructor(message, line) {
|
|
57
|
+
super(line > 0 ? `${message} (line ${line})` : message);
|
|
58
|
+
this.line = line;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
function significantLines(source) {
|
|
62
|
+
const out = [];
|
|
63
|
+
source.split(/\r?\n/).forEach((raw, i) => {
|
|
64
|
+
const withoutComment = stripComment(raw);
|
|
65
|
+
if (!withoutComment.trim())
|
|
66
|
+
return;
|
|
67
|
+
const indent = withoutComment.length - withoutComment.trimStart().length;
|
|
68
|
+
if (withoutComment.slice(0, indent).includes("\t")) {
|
|
69
|
+
throw new ManifestParseError("Use spaces for indentation, not tabs", i + 1);
|
|
70
|
+
}
|
|
71
|
+
out.push({ indent, text: withoutComment.trim(), num: i + 1 });
|
|
72
|
+
});
|
|
73
|
+
return out;
|
|
74
|
+
}
|
|
75
|
+
function stripComment(line) {
|
|
76
|
+
// A "#" starts a comment at line start or after whitespace — good enough for
|
|
77
|
+
// this grammar (no scalar we accept legitimately contains " #").
|
|
78
|
+
let inSingle = false;
|
|
79
|
+
let inDouble = false;
|
|
80
|
+
for (let i = 0; i < line.length; i += 1) {
|
|
81
|
+
const ch = line[i];
|
|
82
|
+
if (ch === "\\" && inDouble)
|
|
83
|
+
i += 1; // \" does not close a double-quoted scalar
|
|
84
|
+
else if (ch === "'" && !inDouble)
|
|
85
|
+
inSingle = !inSingle;
|
|
86
|
+
else if (ch === '"' && !inSingle)
|
|
87
|
+
inDouble = !inDouble;
|
|
88
|
+
else if (ch === "#" && !inSingle && !inDouble && (i === 0 || /\s/.test(line[i - 1]))) {
|
|
89
|
+
return line.slice(0, i);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return line;
|
|
93
|
+
}
|
|
94
|
+
// PyYAML's double-quote escape table (scanner.ESCAPE_REPLACEMENTS) — an escape
|
|
95
|
+
// outside this set (plus \x/\u/\U) is a YAML error, as on the server.
|
|
96
|
+
const DOUBLE_QUOTE_ESCAPES = {
|
|
97
|
+
"0": "\0",
|
|
98
|
+
a: "\x07",
|
|
99
|
+
b: "\b",
|
|
100
|
+
t: "\t",
|
|
101
|
+
"\t": "\t",
|
|
102
|
+
n: "\n",
|
|
103
|
+
v: "\v",
|
|
104
|
+
f: "\f",
|
|
105
|
+
r: "\r",
|
|
106
|
+
e: "\x1b",
|
|
107
|
+
" ": " ",
|
|
108
|
+
'"': '"',
|
|
109
|
+
"/": "/",
|
|
110
|
+
"\\": "\\",
|
|
111
|
+
N: "\u0085",
|
|
112
|
+
_: "\u00a0",
|
|
113
|
+
L: "\u2028",
|
|
114
|
+
P: "\u2029",
|
|
115
|
+
};
|
|
116
|
+
function unquote(value, num) {
|
|
117
|
+
const v = value.trim();
|
|
118
|
+
if (v.startsWith("'"))
|
|
119
|
+
return decodeSingleQuoted(v, num);
|
|
120
|
+
if (v.startsWith('"'))
|
|
121
|
+
return decodeDoubleQuoted(v, num);
|
|
122
|
+
return v;
|
|
123
|
+
}
|
|
124
|
+
// YAML single-quoted scalar: the ONLY escape is '' → a literal '; a backslash
|
|
125
|
+
// is a plain character.
|
|
126
|
+
function decodeSingleQuoted(v, num) {
|
|
127
|
+
let out = "";
|
|
128
|
+
let i = 1;
|
|
129
|
+
while (i < v.length) {
|
|
130
|
+
const ch = v[i];
|
|
131
|
+
if (ch === "'") {
|
|
132
|
+
if (v[i + 1] === "'") {
|
|
133
|
+
out += "'";
|
|
134
|
+
i += 2;
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
if (i !== v.length - 1) {
|
|
138
|
+
throw new ManifestParseError(`Unexpected content after closing quote in "${v}"`, num);
|
|
139
|
+
}
|
|
140
|
+
return out;
|
|
141
|
+
}
|
|
142
|
+
out += ch;
|
|
143
|
+
i += 1;
|
|
144
|
+
}
|
|
145
|
+
throw new ManifestParseError(`Unterminated quote in "${v}"`, num);
|
|
146
|
+
}
|
|
147
|
+
// YAML double-quoted scalar: C-style escapes, decoded the way PyYAML does.
|
|
148
|
+
function decodeDoubleQuoted(v, num) {
|
|
149
|
+
let out = "";
|
|
150
|
+
let i = 1;
|
|
151
|
+
while (i < v.length) {
|
|
152
|
+
const ch = v[i];
|
|
153
|
+
if (ch === '"') {
|
|
154
|
+
if (i !== v.length - 1) {
|
|
155
|
+
throw new ManifestParseError(`Unexpected content after closing quote in "${v}"`, num);
|
|
156
|
+
}
|
|
157
|
+
return out;
|
|
158
|
+
}
|
|
159
|
+
if (ch === "\\") {
|
|
160
|
+
const esc = v[i + 1];
|
|
161
|
+
if (esc === undefined)
|
|
162
|
+
break; // dangling backslash → unterminated
|
|
163
|
+
if (esc === "x" || esc === "u" || esc === "U") {
|
|
164
|
+
const len = esc === "x" ? 2 : esc === "u" ? 4 : 8;
|
|
165
|
+
const hex = v.slice(i + 2, i + 2 + len);
|
|
166
|
+
if (hex.length !== len || !/^[0-9a-fA-F]+$/.test(hex)) {
|
|
167
|
+
throw new ManifestParseError(`Invalid \\${esc} escape in "${v}"`, num);
|
|
168
|
+
}
|
|
169
|
+
out += String.fromCodePoint(parseInt(hex, 16));
|
|
170
|
+
i += 2 + len;
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
const mapped = DOUBLE_QUOTE_ESCAPES[esc];
|
|
174
|
+
if (mapped === undefined) {
|
|
175
|
+
throw new ManifestParseError(`Unknown escape "\\${esc}" in "${v}"`, num);
|
|
176
|
+
}
|
|
177
|
+
out += mapped;
|
|
178
|
+
i += 2;
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
out += ch;
|
|
182
|
+
i += 1;
|
|
183
|
+
}
|
|
184
|
+
throw new ManifestParseError(`Unterminated quote in "${v}"`, num);
|
|
185
|
+
}
|
|
186
|
+
function scalar(raw, num) {
|
|
187
|
+
const v = raw.trim();
|
|
188
|
+
if (v.startsWith("&") || v.startsWith("*")) {
|
|
189
|
+
throw new ManifestParseError(`YAML anchors/aliases are not supported here ("${v}") — write the value out literally`, num);
|
|
190
|
+
}
|
|
191
|
+
return { value: unquote(v, num), quoted: v.startsWith('"') || v.startsWith("'") };
|
|
192
|
+
}
|
|
193
|
+
// Split a flow body on top-level commas — commas inside [], {} or quotes don't
|
|
194
|
+
// separate entries. A trailing comma is allowed (YAML flow grammar); an empty
|
|
195
|
+
// entry anywhere else is a YAML error, as on the server.
|
|
196
|
+
function splitFlowEntries(inner, num, what) {
|
|
197
|
+
const parts = [];
|
|
198
|
+
let depth = 0;
|
|
199
|
+
let quote = null;
|
|
200
|
+
let current = "";
|
|
201
|
+
for (let i = 0; i < inner.length; i += 1) {
|
|
202
|
+
const ch = inner[i];
|
|
203
|
+
if (quote !== null) {
|
|
204
|
+
if (ch === "\\" && quote === '"') {
|
|
205
|
+
current += ch + (inner[i + 1] ?? "");
|
|
206
|
+
i += 1;
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
if (ch === quote)
|
|
210
|
+
quote = null;
|
|
211
|
+
}
|
|
212
|
+
else if (ch === '"' || ch === "'") {
|
|
213
|
+
quote = ch;
|
|
214
|
+
}
|
|
215
|
+
else if (ch === "[" || ch === "{") {
|
|
216
|
+
depth += 1;
|
|
217
|
+
}
|
|
218
|
+
else if (ch === "]" || ch === "}") {
|
|
219
|
+
depth -= 1;
|
|
220
|
+
if (depth < 0) {
|
|
221
|
+
throw new ManifestParseError(`Unbalanced "${ch}" in ${what}`, num);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
else if (ch === "," && depth === 0) {
|
|
225
|
+
parts.push(current);
|
|
226
|
+
current = "";
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
current += ch;
|
|
230
|
+
}
|
|
231
|
+
parts.push(current);
|
|
232
|
+
if (parts.length > 1 && parts[parts.length - 1].trim() === "")
|
|
233
|
+
parts.pop();
|
|
234
|
+
for (const part of parts) {
|
|
235
|
+
if (!part.trim())
|
|
236
|
+
throw new ManifestParseError(`Empty entry in ${what}`, num);
|
|
237
|
+
}
|
|
238
|
+
return parts.map((p) => p.trim());
|
|
239
|
+
}
|
|
240
|
+
// A flow collection ("[...]"/"{...}") must balance its delimiters, terminate
|
|
241
|
+
// its quotes, and consume the whole value — trailing content after the final
|
|
242
|
+
// closing delimiter is a YAML error. This is the well-formedness the server's
|
|
243
|
+
// loader enforces even on values a later duplicate key supersedes.
|
|
244
|
+
function checkFlowBalanced(value, num) {
|
|
245
|
+
const closers = [];
|
|
246
|
+
let quote = null;
|
|
247
|
+
for (let i = 0; i < value.length; i += 1) {
|
|
248
|
+
const ch = value[i];
|
|
249
|
+
if (quote !== null) {
|
|
250
|
+
if (ch === "\\" && quote === '"')
|
|
251
|
+
i += 1;
|
|
252
|
+
else if (ch === quote)
|
|
253
|
+
quote = null;
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
if (ch === '"' || ch === "'")
|
|
257
|
+
quote = ch;
|
|
258
|
+
else if (ch === "[")
|
|
259
|
+
closers.push("]");
|
|
260
|
+
else if (ch === "{")
|
|
261
|
+
closers.push("}");
|
|
262
|
+
else if (ch === "]" || ch === "}") {
|
|
263
|
+
if (closers.pop() !== ch) {
|
|
264
|
+
throw new ManifestParseError(`Unbalanced "${ch}" in flow collection`, num);
|
|
265
|
+
}
|
|
266
|
+
if (closers.length === 0) {
|
|
267
|
+
const rest = value.slice(i + 1).trim();
|
|
268
|
+
if (rest) {
|
|
269
|
+
throw new ManifestParseError(`Unexpected content after flow collection: "${rest}"`, num);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
if (quote !== null) {
|
|
275
|
+
throw new ManifestParseError(`Unterminated quote in "${value}"`, num);
|
|
276
|
+
}
|
|
277
|
+
if (closers.length > 0) {
|
|
278
|
+
throw new ManifestParseError(`Unterminated flow collection in "${value}" (missing "${closers[closers.length - 1]}")`, num);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
// Syntactic (not semantic) validation of one collected value: a flow
|
|
282
|
+
// collection must balance, a quoted scalar must terminate. Applied to EVERY
|
|
283
|
+
// entry — including ones a later duplicate key supersedes — because the
|
|
284
|
+
// server's yaml.safe_load parses the whole document before last-wins applies.
|
|
285
|
+
function checkValueSyntax(value, num) {
|
|
286
|
+
if (!value)
|
|
287
|
+
return;
|
|
288
|
+
if (value.startsWith("[") || value.startsWith("{"))
|
|
289
|
+
checkFlowBalanced(value, num);
|
|
290
|
+
else if (value.startsWith('"') || value.startsWith("'"))
|
|
291
|
+
unquote(value, num);
|
|
292
|
+
}
|
|
293
|
+
function checkEntrySyntax(entry) {
|
|
294
|
+
checkValueSyntax(entry.inline, entry.keyLine.num);
|
|
295
|
+
for (const child of entry.children) {
|
|
296
|
+
let v = child.text;
|
|
297
|
+
if (v.startsWith("- ")) {
|
|
298
|
+
v = v.slice(2).trim();
|
|
299
|
+
}
|
|
300
|
+
else {
|
|
301
|
+
const m = v.match(CONNECTOR_BLOCK_ENTRY);
|
|
302
|
+
if (m)
|
|
303
|
+
v = (m[2] ?? "").trim();
|
|
304
|
+
}
|
|
305
|
+
checkValueSyntax(v, child.num);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
function parseFlowList(value, num) {
|
|
309
|
+
const inner = value.slice(1, -1).trim();
|
|
310
|
+
if (!inner)
|
|
311
|
+
return [];
|
|
312
|
+
return splitFlowEntries(inner, num, "flow list").map((part) => scalar(part, num));
|
|
313
|
+
}
|
|
314
|
+
// One string-list entry (saved_queries / adhoc_sql): must be a YAML *string* —
|
|
315
|
+
// a bare number/bool/null is a different type and the server rejects it.
|
|
316
|
+
function listString(s, key, num) {
|
|
317
|
+
if (!s.quoted && isYamlNonString(s.value)) {
|
|
318
|
+
throw new ManifestParseError(`"${s.value}" under "${key}" is not a YAML string — quote it ("${s.value}") if you mean the name`, num);
|
|
319
|
+
}
|
|
320
|
+
const value = s.value.trim();
|
|
321
|
+
if (!value)
|
|
322
|
+
throw new ManifestParseError(`Empty list item under "${key}"`, num);
|
|
323
|
+
return value;
|
|
324
|
+
}
|
|
325
|
+
function parseBool(s, num) {
|
|
326
|
+
// A quoted scalar is a STRING even if it spells a boolean — as on the server.
|
|
327
|
+
if (!s.quoted) {
|
|
328
|
+
if (YAML_TRUE.test(s.value))
|
|
329
|
+
return true;
|
|
330
|
+
if (YAML_FALSE.test(s.value))
|
|
331
|
+
return false;
|
|
332
|
+
}
|
|
333
|
+
throw new ManifestParseError(`"llm" must be true or false (got "${s.value}")`, num);
|
|
334
|
+
}
|
|
335
|
+
// Strip the optional document markers: one leading "---" and one trailing "..."
|
|
336
|
+
// are no-ops. Anything more is a multi-document stream, which the server
|
|
337
|
+
// (yaml.safe_load) refuses too — including a trailing "---", which starts an
|
|
338
|
+
// empty second document.
|
|
339
|
+
function documentLines(source) {
|
|
340
|
+
let lines = significantLines(source);
|
|
341
|
+
if (lines.length > 0 && lines[0].indent === 0 && lines[0].text === "---") {
|
|
342
|
+
lines = lines.slice(1);
|
|
343
|
+
}
|
|
344
|
+
const last = lines[lines.length - 1];
|
|
345
|
+
if (last !== undefined && last.indent === 0 && last.text === "...") {
|
|
346
|
+
lines = lines.slice(0, -1);
|
|
347
|
+
}
|
|
348
|
+
for (const line of lines) {
|
|
349
|
+
if (line.indent === 0 && (line.text === "---" || line.text === "...")) {
|
|
350
|
+
throw new ManifestParseError("manifest.yaml must be a single YAML document (unexpected document marker)", line.num);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
return lines;
|
|
354
|
+
}
|
|
355
|
+
// A block-context key: the colon must be followed by whitespace or end the
|
|
356
|
+
// line ("llm:true" is a plain SCALAR in block YAML, not a mapping). In flow
|
|
357
|
+
// context the colon may also sit directly before a flow opener — PyYAML
|
|
358
|
+
// accepts "{stripe:[GET /x]}" but reads "{llm:true}" as the key "llm:true".
|
|
359
|
+
const BLOCK_KEY = /^([A-Za-z_][A-Za-z0-9_]*)\s*:(?:\s+(.*))?$/;
|
|
360
|
+
const FLOW_KEY = /^([A-Za-z_][A-Za-z0-9_]*)\s*:(?=$|[\s[{])\s*(.*)$/;
|
|
361
|
+
export function parseManifestYaml(source) {
|
|
362
|
+
const lines = documentLines(source);
|
|
363
|
+
// Pass 1: split the top level into key entries. The document is either a
|
|
364
|
+
// block mapping or one flow mapping ("{...}", possibly spanning lines) —
|
|
365
|
+
// both are dicts to the server. A duplicate key is legal YAML: the last
|
|
366
|
+
// value wins (as on the server), and a superseded value gets syntactic
|
|
367
|
+
// checks only — the server never validates it semantically either.
|
|
368
|
+
const first = lines[0];
|
|
369
|
+
let entries;
|
|
370
|
+
if (first !== undefined && first.indent === 0 && first.text.startsWith("{")) {
|
|
371
|
+
entries = collectRootFlowEntries(lines);
|
|
372
|
+
}
|
|
373
|
+
else if (first !== undefined && first.indent === 0 && first.text.startsWith("[")) {
|
|
374
|
+
throw new ManifestParseError("manifest.yaml must be a mapping of keys — see the manifest docs", first.num);
|
|
375
|
+
}
|
|
376
|
+
else {
|
|
377
|
+
entries = collectBlockEntries(lines);
|
|
378
|
+
}
|
|
379
|
+
// Pass 2: parse each (winning) entry.
|
|
380
|
+
const doc = {
|
|
381
|
+
run_as: "user",
|
|
382
|
+
saved_queries: [],
|
|
383
|
+
connectors: {},
|
|
384
|
+
llm: false,
|
|
385
|
+
adhoc_sql: [],
|
|
386
|
+
};
|
|
387
|
+
for (const [key, entry] of entries) {
|
|
388
|
+
if (key === "run_as") {
|
|
389
|
+
const value = inlineScalar(entry, key).value;
|
|
390
|
+
if (value !== "app" && value !== "user") {
|
|
391
|
+
throw new ManifestParseError(`"run_as" must be "app" or "user" (got "${value}")`, entry.keyLine.num);
|
|
392
|
+
}
|
|
393
|
+
doc.run_as = value;
|
|
394
|
+
}
|
|
395
|
+
else if (key === "llm") {
|
|
396
|
+
doc.llm = parseBool(inlineScalar(entry, key), entry.keyLine.num);
|
|
397
|
+
}
|
|
398
|
+
else if (key === "connectors") {
|
|
399
|
+
doc.connectors = parseConnectors(entry);
|
|
400
|
+
}
|
|
401
|
+
else {
|
|
402
|
+
// saved_queries / adhoc_sql: a list of names.
|
|
403
|
+
const items = parseStringList(entry, key);
|
|
404
|
+
if (key === "saved_queries")
|
|
405
|
+
doc.saved_queries = items;
|
|
406
|
+
else
|
|
407
|
+
doc.adhoc_sql = items;
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
validateDoc(doc, lines);
|
|
411
|
+
return doc;
|
|
412
|
+
}
|
|
413
|
+
function collectBlockEntries(lines) {
|
|
414
|
+
const entries = new Map();
|
|
415
|
+
let i = 0;
|
|
416
|
+
while (i < lines.length) {
|
|
417
|
+
const line = lines[i];
|
|
418
|
+
if (line.indent !== 0) {
|
|
419
|
+
throw new ManifestParseError(`Unexpected indentation before "${line.text}"`, line.num);
|
|
420
|
+
}
|
|
421
|
+
const match = line.text.match(BLOCK_KEY);
|
|
422
|
+
if (!match) {
|
|
423
|
+
throw new ManifestParseError(`Expected "key: value" or "key:" at "${line.text}"`, line.num);
|
|
424
|
+
}
|
|
425
|
+
const key = match[1];
|
|
426
|
+
if (!ALLOWED_KEYS.includes(key)) {
|
|
427
|
+
throw new ManifestParseError(`Unknown manifest key "${key}" — allowed keys: ${ALLOWED_KEYS.join(", ")}`, line.num);
|
|
428
|
+
}
|
|
429
|
+
i += 1;
|
|
430
|
+
const children = [];
|
|
431
|
+
while (i < lines.length && lines[i].indent > 0) {
|
|
432
|
+
children.push(lines[i]);
|
|
433
|
+
i += 1;
|
|
434
|
+
}
|
|
435
|
+
const entry = { keyLine: line, inline: (match[2] ?? "").trim(), children };
|
|
436
|
+
checkEntrySyntax(entry);
|
|
437
|
+
entries.set(key, entry);
|
|
438
|
+
}
|
|
439
|
+
return entries;
|
|
440
|
+
}
|
|
441
|
+
// The whole document is one flow mapping, e.g. `{run_as: app, llm: true}` —
|
|
442
|
+
// PyYAML reads it as a dict, so the server accepts it. Flow collections may
|
|
443
|
+
// span lines, so the lines are joined before scanning.
|
|
444
|
+
function collectRootFlowEntries(lines) {
|
|
445
|
+
const keyLine = lines[0];
|
|
446
|
+
const joined = lines.map((l) => l.text).join(" ");
|
|
447
|
+
checkFlowBalanced(joined, keyLine.num); // also rejects content after the "}"
|
|
448
|
+
const entries = new Map();
|
|
449
|
+
const inner = joined.slice(1, joined.lastIndexOf("}")).trim();
|
|
450
|
+
if (!inner)
|
|
451
|
+
return entries;
|
|
452
|
+
for (const part of splitFlowEntries(inner, keyLine.num, "the manifest flow mapping")) {
|
|
453
|
+
// A part that isn't "key: value" is a lone scalar key with a null value
|
|
454
|
+
// (e.g. "{run_as}" or the no-space "{llm:true}") — name it like the server.
|
|
455
|
+
const match = part.match(FLOW_KEY);
|
|
456
|
+
const key = match ? match[1] : part;
|
|
457
|
+
if (!ALLOWED_KEYS.includes(key)) {
|
|
458
|
+
throw new ManifestParseError(`Unknown manifest key "${key}" — allowed keys: ${ALLOWED_KEYS.join(", ")}`, keyLine.num);
|
|
459
|
+
}
|
|
460
|
+
entries.set(key, { keyLine, inline: match ? match[2].trim() : "", children: [] });
|
|
461
|
+
}
|
|
462
|
+
return entries;
|
|
463
|
+
}
|
|
464
|
+
function requireNoChildren(entry, key) {
|
|
465
|
+
const extra = entry.children[0];
|
|
466
|
+
if (extra !== undefined) {
|
|
467
|
+
throw new ManifestParseError(`Unexpected indented block under "${key}"`, extra.num);
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
function inlineScalar(entry, key) {
|
|
471
|
+
requireNoChildren(entry, key);
|
|
472
|
+
return scalar(entry.inline, entry.keyLine.num);
|
|
473
|
+
}
|
|
474
|
+
function parseStringList(entry, key) {
|
|
475
|
+
const { keyLine, inline, children } = entry;
|
|
476
|
+
if (inline) {
|
|
477
|
+
requireNoChildren(entry, key);
|
|
478
|
+
if (inline.startsWith("[") && inline.endsWith("]")) {
|
|
479
|
+
return parseFlowList(inline, keyLine.num).map((s) => listString(s, key, keyLine.num));
|
|
480
|
+
}
|
|
481
|
+
throw new ManifestParseError(`"${key}" must be a list of names — e.g.\n${key}:\n - some_name`, keyLine.num);
|
|
482
|
+
}
|
|
483
|
+
if (children.length === 0) {
|
|
484
|
+
throw new ManifestParseError(`"${key}" must list at least one name`, keyLine.num);
|
|
485
|
+
}
|
|
486
|
+
const itemIndent = children[0].indent;
|
|
487
|
+
return children.map((item) => {
|
|
488
|
+
if (item.indent !== itemIndent) {
|
|
489
|
+
throw new ManifestParseError(`Inconsistent indentation in the "${key}" list — every "- " item must line up`, item.num);
|
|
490
|
+
}
|
|
491
|
+
if (!item.text.startsWith("- ") && item.text !== "-") {
|
|
492
|
+
throw new ManifestParseError(`Expected a "- name" list item under "${key}" (got "${item.text}")`, item.num);
|
|
493
|
+
}
|
|
494
|
+
return listString(scalar(item.text.slice(1), item.num), key, item.num);
|
|
495
|
+
});
|
|
496
|
+
}
|
|
497
|
+
// A connector name entry. In block context the colon must be followed by
|
|
498
|
+
// whitespace or end the line; in flow context PyYAML also allows the colon to
|
|
499
|
+
// sit directly before a flow opener ("{stripe:[GET /x]}").
|
|
500
|
+
const CONNECTOR_BLOCK_ENTRY = /^("[^"]*"|'[^']*'|[^"':\s][^":]*?)\s*:(?:\s+(.*))?$/;
|
|
501
|
+
const CONNECTOR_FLOW_ENTRY = /^("[^"]*"|'[^']*'|[^"':\s][^":]*?)\s*:(?=$|[\s[{])\s*(.*)$/;
|
|
502
|
+
function connectorName(raw, num) {
|
|
503
|
+
const s = scalar(raw, num);
|
|
504
|
+
const name = s.value.trim();
|
|
505
|
+
if (!name)
|
|
506
|
+
throw new ManifestParseError("Connector names must be non-empty", num);
|
|
507
|
+
if (!s.quoted && isYamlNonString(name)) {
|
|
508
|
+
throw new ManifestParseError(`Connector names under "connectors" must be strings (got "${name}")`, num);
|
|
509
|
+
}
|
|
510
|
+
return name;
|
|
511
|
+
}
|
|
512
|
+
function parseConnectors(entry) {
|
|
513
|
+
const { keyLine, inline, children } = entry;
|
|
514
|
+
if (inline) {
|
|
515
|
+
requireNoChildren(entry, "connectors");
|
|
516
|
+
if (inline.startsWith("{") && inline.endsWith("}")) {
|
|
517
|
+
return parseConnectorsFlowMap(inline, keyLine.num);
|
|
518
|
+
}
|
|
519
|
+
throw new ManifestParseError('"connectors" takes an indented block (or a {flow map}): connector names, each with a list of endpoints', keyLine.num);
|
|
520
|
+
}
|
|
521
|
+
if (children.length === 0) {
|
|
522
|
+
throw new ManifestParseError('"connectors" must map at least one connector to its endpoints', keyLine.num);
|
|
523
|
+
}
|
|
524
|
+
const connectors = {};
|
|
525
|
+
const nameIndent = children[0].indent;
|
|
526
|
+
let i = 0;
|
|
527
|
+
while (i < children.length) {
|
|
528
|
+
const nameLine = children[i];
|
|
529
|
+
if (nameLine.indent !== nameIndent) {
|
|
530
|
+
throw new ManifestParseError('Inconsistent indentation under "connectors" — every connector name must line up', nameLine.num);
|
|
531
|
+
}
|
|
532
|
+
const match = nameLine.text.match(CONNECTOR_BLOCK_ENTRY);
|
|
533
|
+
if (!match) {
|
|
534
|
+
throw new ManifestParseError(`Expected a connector name (e.g. "stripe:") under "connectors" (got "${nameLine.text}")`, nameLine.num);
|
|
535
|
+
}
|
|
536
|
+
const name = connectorName(match[1], nameLine.num);
|
|
537
|
+
const inlineValue = (match[2] ?? "").trim();
|
|
538
|
+
i += 1;
|
|
539
|
+
let endpoints = [];
|
|
540
|
+
if (inlineValue) {
|
|
541
|
+
const extra = children[i];
|
|
542
|
+
if (extra !== undefined && extra.indent > nameIndent) {
|
|
543
|
+
throw new ManifestParseError(`Unexpected indented block under "${name}" after an inline value`, extra.num);
|
|
544
|
+
}
|
|
545
|
+
if (!(inlineValue.startsWith("[") && inlineValue.endsWith("]"))) {
|
|
546
|
+
throw new ManifestParseError(`"${name}" must list its endpoints as "- <METHOD> </path>" lines or a [flow list]`, nameLine.num);
|
|
547
|
+
}
|
|
548
|
+
endpoints = parseFlowList(inlineValue, nameLine.num);
|
|
549
|
+
}
|
|
550
|
+
else {
|
|
551
|
+
const first = children[i];
|
|
552
|
+
const endpointIndent = first !== undefined && first.indent > nameIndent ? first.indent : 0;
|
|
553
|
+
while (i < children.length && children[i].indent > nameIndent) {
|
|
554
|
+
const item = children[i];
|
|
555
|
+
if (item.indent !== endpointIndent) {
|
|
556
|
+
throw new ManifestParseError(`Inconsistent indentation in the endpoint list under "${name}"`, item.num);
|
|
557
|
+
}
|
|
558
|
+
if (!item.text.startsWith("- ")) {
|
|
559
|
+
throw new ManifestParseError(`Expected a "- <METHOD> </path>" endpoint under "${name}" (got "${item.text}")`, item.num);
|
|
560
|
+
}
|
|
561
|
+
endpoints.push(scalar(item.text.slice(1), item.num));
|
|
562
|
+
i += 1;
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
if (endpoints.length === 0) {
|
|
566
|
+
throw new ManifestParseError(`"${name}" must list at least one "<METHOD> </path>" endpoint`, nameLine.num);
|
|
567
|
+
}
|
|
568
|
+
// A repeated connector name is legal YAML — the last mapping wins.
|
|
569
|
+
connectors[name] = endpoints.map((e) => normalizeEndpoint(name, e.value, nameLine.num));
|
|
570
|
+
}
|
|
571
|
+
return connectors;
|
|
572
|
+
}
|
|
573
|
+
function parseConnectorsFlowMap(value, num) {
|
|
574
|
+
const connectors = {};
|
|
575
|
+
const inner = value.slice(1, -1).trim();
|
|
576
|
+
if (!inner)
|
|
577
|
+
return connectors; // {} is a valid empty mapping, as on the server
|
|
578
|
+
for (const part of splitFlowEntries(inner, num, '"connectors" flow map')) {
|
|
579
|
+
const match = part.match(CONNECTOR_FLOW_ENTRY);
|
|
580
|
+
if (!match) {
|
|
581
|
+
throw new ManifestParseError(`Expected "connector: [endpoints]" in the "connectors" flow map (got "${part}")`, num);
|
|
582
|
+
}
|
|
583
|
+
const name = connectorName(match[1], num);
|
|
584
|
+
const endpointsValue = match[2].trim();
|
|
585
|
+
if (!(endpointsValue.startsWith("[") && endpointsValue.endsWith("]"))) {
|
|
586
|
+
throw new ManifestParseError(`"${name}" must list its endpoints as a [flow list] in the "connectors" flow map`, num);
|
|
587
|
+
}
|
|
588
|
+
const endpoints = parseFlowList(endpointsValue, num);
|
|
589
|
+
if (endpoints.length === 0) {
|
|
590
|
+
throw new ManifestParseError(`"${name}" must list at least one "<METHOD> </path>" endpoint`, num);
|
|
591
|
+
}
|
|
592
|
+
connectors[name] = endpoints.map((e) => normalizeEndpoint(name, e.value, num));
|
|
593
|
+
}
|
|
594
|
+
return connectors;
|
|
595
|
+
}
|
|
596
|
+
function normalizeEndpoint(connector, entry, num) {
|
|
597
|
+
const parts = entry.split(/\s+/);
|
|
598
|
+
if (parts.length !== 2) {
|
|
599
|
+
throw new ManifestParseError(`"${entry}" under "${connector}" is not a valid endpoint — use "<METHOD> </path>", e.g. "GET /v1/balance"`, num);
|
|
600
|
+
}
|
|
601
|
+
const method = parts[0].toUpperCase();
|
|
602
|
+
const path = parts[1];
|
|
603
|
+
if (!HTTP_METHODS.includes(method)) {
|
|
604
|
+
throw new ManifestParseError(`"${parts[0]}" is not an HTTP method — use one of: ${HTTP_METHODS.join(", ")}`, num);
|
|
605
|
+
}
|
|
606
|
+
if (!path.startsWith("/")) {
|
|
607
|
+
throw new ManifestParseError(`"${path}" must start with "/" — e.g. "GET /v1/balance"`, num);
|
|
608
|
+
}
|
|
609
|
+
if (path.includes("*") && (!path.endsWith("/*") || path.slice(0, -1).includes("*"))) {
|
|
610
|
+
throw new ManifestParseError(`"${path}" has an unsupported wildcard — only a trailing "/*" segment is allowed (no mid-path globs)`, num);
|
|
611
|
+
}
|
|
612
|
+
const segments = path.replace(/\*$/, "").split("/");
|
|
613
|
+
if (segments.some((seg) => seg === "." || seg === "..")) {
|
|
614
|
+
throw new ManifestParseError(`"${path}" must not contain "." or ".." path segments`, num);
|
|
615
|
+
}
|
|
616
|
+
return `${method} ${path}`;
|
|
617
|
+
}
|
|
618
|
+
function validateDoc(doc, lines) {
|
|
619
|
+
const lineOf = (token) => lines.find((l) => l.text.includes(token))?.num ?? 0;
|
|
620
|
+
for (const name of doc.saved_queries) {
|
|
621
|
+
if (!SAVED_QUERY_NAME.test(name)) {
|
|
622
|
+
throw new ManifestParseError(`"${name}" is not a valid saved-query name — 1-80 lowercase letters, digits or underscores`, lineOf(name));
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
doc.saved_queries = [...new Set(doc.saved_queries)].sort();
|
|
626
|
+
doc.adhoc_sql = [...new Set(doc.adhoc_sql)].sort();
|
|
627
|
+
for (const name of Object.keys(doc.connectors)) {
|
|
628
|
+
doc.connectors[name] = [...new Set(doc.connectors[name])].sort();
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
export function documentOperations(doc) {
|
|
632
|
+
const ops = [];
|
|
633
|
+
for (const name of doc.saved_queries) {
|
|
634
|
+
ops.push({ type: "saved_query", id: name, label: `saved query "${name}"` });
|
|
635
|
+
}
|
|
636
|
+
for (const [connector, endpoints] of Object.entries(doc.connectors)) {
|
|
637
|
+
for (const endpoint of endpoints) {
|
|
638
|
+
ops.push({
|
|
639
|
+
type: "sc_endpoint",
|
|
640
|
+
id: `${connector}:${endpoint}`,
|
|
641
|
+
label: `"${endpoint}" on "${connector}"`,
|
|
642
|
+
});
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
if (doc.llm)
|
|
646
|
+
ops.push({ type: "llm", id: "*", label: "LLM access" });
|
|
647
|
+
for (const name of doc.adhoc_sql) {
|
|
648
|
+
ops.push({ type: "connector", id: name, label: `ad-hoc SQL on "${name}"` });
|
|
649
|
+
}
|
|
650
|
+
return ops;
|
|
651
|
+
}
|
|
652
|
+
export function renderManifestSummary(doc) {
|
|
653
|
+
const lines = [];
|
|
654
|
+
lines.push(doc.run_as === "app"
|
|
655
|
+
? "run_as: app — runs with its ratified authority (diffs need ratification)"
|
|
656
|
+
: "run_as: user — runs as each caller; no borrowed authority, nothing to ratify");
|
|
657
|
+
const ops = documentOperations(doc);
|
|
658
|
+
if (ops.length === 0) {
|
|
659
|
+
lines.push("operations: none declared");
|
|
660
|
+
}
|
|
661
|
+
else {
|
|
662
|
+
lines.push(`operations (${ops.length}):`);
|
|
663
|
+
for (const op of ops)
|
|
664
|
+
lines.push(` - ${op.label}`);
|
|
665
|
+
}
|
|
666
|
+
if (doc.adhoc_sql.length > 0) {
|
|
667
|
+
lines.push("warning: adhoc_sql grants ad-hoc SQL authority — scarce by design; prefer saved queries");
|
|
668
|
+
}
|
|
669
|
+
return lines.join("\n");
|
|
670
|
+
}
|
|
671
|
+
export function renderManifestDeployOutcome(m) {
|
|
672
|
+
const lines = [];
|
|
673
|
+
for (const warning of m.warnings)
|
|
674
|
+
lines.push(`warning: ${warning}`);
|
|
675
|
+
switch (m.action) {
|
|
676
|
+
case "none":
|
|
677
|
+
break; // no manifest, nothing to say
|
|
678
|
+
case "unchanged":
|
|
679
|
+
lines.push("Manifest unchanged.");
|
|
680
|
+
break;
|
|
681
|
+
case "ratified":
|
|
682
|
+
lines.push(m.added.length > 0
|
|
683
|
+
? `Manifest ratified (you hold: ${m.added.join(", ")}).`
|
|
684
|
+
: "Manifest ratified.");
|
|
685
|
+
if (m.removed.length > 0)
|
|
686
|
+
lines.push(`Removed: ${m.removed.join(", ")}.`);
|
|
687
|
+
break;
|
|
688
|
+
case "removed":
|
|
689
|
+
lines.push("Manifest removed — the app is back to pass-through (runs as each caller).");
|
|
690
|
+
if (m.removed.length > 0)
|
|
691
|
+
lines.push(`Shed: ${m.removed.join(", ")}.`);
|
|
692
|
+
break;
|
|
693
|
+
}
|
|
694
|
+
return lines;
|
|
695
|
+
}
|