@m6d/cortex-cli 0.13.0 → 0.15.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 +94 -26
- package/package.json +1 -1
- package/src/cli.ts +2 -0
- package/src/commands/apply.ts +171 -44
- package/src/commands/management.ts +41 -22
- package/src/commands/new.ts +48 -25
- package/src/commands/setup.ts +611 -0
- package/src/contracts/datasets/dataset-sql.ts +22 -5
- package/src/contracts/datasets/datasets.ts +139 -16
- package/src/contracts/egress-policy/scrub.ts +11 -8
- package/src/contracts/json/index.ts +21 -0
- package/src/contracts/manifest/index.ts +223 -55
- package/src/contracts/server/index.ts +109 -9
- package/src/contracts/server/reserved-names.fixtures.json +1 -3
- package/src/contracts/wire/index.ts +42 -3
- package/src/management/client.ts +14 -0
- package/src/management/profiles.ts +6 -4
- package/src/scaffold/files.ts +183 -144
- package/src/scaffold/ports.ts +5 -6
- package/src/scaffold/templates/cspell.json.txt +41 -0
- package/src/scaffold/templates/dockerignore.txt +3 -4
- package/src/scaffold/templates/gitignore.txt +1 -2
- package/src/scaffold/templates/setup.ts.txt +18 -26
- package/src/ui/prompts.ts +32 -10
|
@@ -25,11 +25,22 @@ export const datasetColumnRefSchema = z.string().regex(DATASET_COLUMN_REF_PATTER
|
|
|
25
25
|
export const DATASET_COLUMN_TYPES = ["string", "number", "boolean", "date"] as const;
|
|
26
26
|
export type DatasetColumnType = (typeof DATASET_COLUMN_TYPES)[number];
|
|
27
27
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
28
|
+
/**
|
|
29
|
+
* One column. `values`, on a string column, lists every value it can hold:
|
|
30
|
+
* the agent reads them, and a query comparing the column to anything else is
|
|
31
|
+
* refused before it runs.
|
|
32
|
+
*/
|
|
33
|
+
export const datasetColumnSchema = z
|
|
34
|
+
.object({
|
|
35
|
+
name: datasetIdentifierSchema,
|
|
36
|
+
type: z.enum(DATASET_COLUMN_TYPES),
|
|
37
|
+
description: z.string().max(200).default(""),
|
|
38
|
+
values: z.array(z.string().min(1).max(200)).min(1).max(100).optional(),
|
|
39
|
+
})
|
|
40
|
+
.refine((column) => column.values === undefined || column.type === "string", {
|
|
41
|
+
message: "only a string column lists its values",
|
|
42
|
+
path: ["values"],
|
|
43
|
+
});
|
|
33
44
|
export type DatasetColumn = z.infer<typeof datasetColumnSchema>;
|
|
34
45
|
|
|
35
46
|
/**
|
|
@@ -396,10 +407,59 @@ export function resolveDatasetColumn(catalog: DatasetCatalog, from: string, ref:
|
|
|
396
407
|
dataset = target;
|
|
397
408
|
}
|
|
398
409
|
const column = dataset.columns.find((candidate) => candidate.name === columnName);
|
|
399
|
-
if (!column) return missing(
|
|
410
|
+
if (!column) return missing(unknownColumnError(dataset, columnName));
|
|
400
411
|
return { ok: true as const, column, path: segments.join(".") };
|
|
401
412
|
}
|
|
402
413
|
|
|
414
|
+
/**
|
|
415
|
+
* Why `dataset` has no column `name`, with the columns it has and up to
|
|
416
|
+
* three closest ones: names containing it or contained in it (ignoring
|
|
417
|
+
* case, and only for names longer than two characters), else names two
|
|
418
|
+
* edits or fewer away. A query that guessed `name` reads `departments has
|
|
419
|
+
* no column "name". Closest: nameAr, nameEn. Columns: id, nameAr, ...`
|
|
420
|
+
*/
|
|
421
|
+
export function unknownColumnError(dataset: DatasetDefinition, name: string) {
|
|
422
|
+
const columns = dataset.columns.map((column) => column.name);
|
|
423
|
+
const wanted = name.toLowerCase();
|
|
424
|
+
const containing = columns.filter((column) => {
|
|
425
|
+
const lower = column.toLowerCase();
|
|
426
|
+
return (
|
|
427
|
+
(wanted.length > 2 && lower.includes(wanted)) ||
|
|
428
|
+
(lower.length > 2 && wanted.includes(lower))
|
|
429
|
+
);
|
|
430
|
+
});
|
|
431
|
+
const closest = (
|
|
432
|
+
containing.length > 0
|
|
433
|
+
? containing
|
|
434
|
+
: // Lengths more than two apart are more than two edits apart, so a
|
|
435
|
+
// name of any length costs only its own columns' comparisons.
|
|
436
|
+
columns.filter(
|
|
437
|
+
(column) =>
|
|
438
|
+
Math.abs(column.length - wanted.length) <= 2 &&
|
|
439
|
+
editDistance(column.toLowerCase(), wanted) <= 2,
|
|
440
|
+
)
|
|
441
|
+
).slice(0, 3);
|
|
442
|
+
const hint = closest.length > 0 ? ` Closest: ${closest.join(", ")}.` : "";
|
|
443
|
+
return `${dataset.name} has no column "${name}".${hint} Columns: ${columns.join(", ")}.`;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/** Levenshtein distance: the fewest single-character edits from `a` to `b`. */
|
|
447
|
+
function editDistance(a: string, b: string) {
|
|
448
|
+
let previous = Array.from({ length: b.length + 1 }, (_, index) => index);
|
|
449
|
+
for (let i = 1; i <= a.length; i++) {
|
|
450
|
+
const current = [i];
|
|
451
|
+
for (let j = 1; j <= b.length; j++) {
|
|
452
|
+
current[j] = Math.min(
|
|
453
|
+
previous[j]! + 1,
|
|
454
|
+
current[j - 1]! + 1,
|
|
455
|
+
previous[j - 1]! + (a[i - 1] === b[j - 1] ? 0 : 1),
|
|
456
|
+
);
|
|
457
|
+
}
|
|
458
|
+
previous = current;
|
|
459
|
+
}
|
|
460
|
+
return previous[b.length]!;
|
|
461
|
+
}
|
|
462
|
+
|
|
403
463
|
function refError(kind: string, ref: string, reason: string) {
|
|
404
464
|
return `${kind} "${ref}": ${reason}`;
|
|
405
465
|
}
|
|
@@ -654,6 +714,19 @@ export function validateDatasetQuery(catalog: DatasetCatalog, query: DatasetQuer
|
|
|
654
714
|
} satisfies QueryCheck;
|
|
655
715
|
|
|
656
716
|
for (const filter of query.filter) checkFilter(check, filter);
|
|
717
|
+
const row = checkAnsweredRow(check, catalog, query);
|
|
718
|
+
checkFinishedRow(check, query, row);
|
|
719
|
+
checkExpansion(check, query, catalog.get(query.from)?.columns.length ?? 0);
|
|
720
|
+
return issues;
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
/**
|
|
724
|
+
* The row a query answers, checked as it is built: on a grouped query its
|
|
725
|
+
* keys and aggregate aliases (never one name for both, and no select), on a
|
|
726
|
+
* plain one its select aliases; each with the names that are a column as it
|
|
727
|
+
* is, which keep what the column says.
|
|
728
|
+
*/
|
|
729
|
+
function checkAnsweredRow(check: QueryCheck, catalog: DatasetCatalog, query: DatasetQuery) {
|
|
657
730
|
const groupKeys = checkGroupBy(check, query.groupBy);
|
|
658
731
|
const aliases = checkAggregates(check, query.aggregate);
|
|
659
732
|
for (const alias of aliases.keys()) {
|
|
@@ -671,12 +744,17 @@ export function validateDatasetQuery(catalog: DatasetCatalog, query: DatasetQuer
|
|
|
671
744
|
);
|
|
672
745
|
}
|
|
673
746
|
const selectAliases = checkSelect(check, query.select ?? []);
|
|
674
|
-
|
|
675
|
-
? {
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
747
|
+
return grouped
|
|
748
|
+
? {
|
|
749
|
+
names: new Map([...groupKeys, ...aliases]),
|
|
750
|
+
kind: "grouped" as const,
|
|
751
|
+
columns: passedColumns(catalog, query, query.groupBy),
|
|
752
|
+
}
|
|
753
|
+
: {
|
|
754
|
+
names: selectAliases,
|
|
755
|
+
kind: "plain" as const,
|
|
756
|
+
columns: passedColumns(catalog, query, query.select ?? []),
|
|
757
|
+
};
|
|
680
758
|
}
|
|
681
759
|
|
|
682
760
|
/** How large any one expression a host translates may grow once every alias it names is written out in place. */
|
|
@@ -856,7 +934,7 @@ function filterCost(filter: DatasetFilter, expanded: ReadonlyMap<string, number>
|
|
|
856
934
|
function checkFinishedRow(
|
|
857
935
|
check: QueryCheck,
|
|
858
936
|
query: DatasetQuery,
|
|
859
|
-
row: { names: Map<string, DatasetColumnType
|
|
937
|
+
row: RowNames & { names: Map<string, DatasetColumnType> },
|
|
860
938
|
) {
|
|
861
939
|
const rowCheck = rowScope(check, row);
|
|
862
940
|
// On a plain row a selected column is a name too; a window or calculation may not take it.
|
|
@@ -881,7 +959,7 @@ function rowScope(check: QueryCheck, row: RowNames) {
|
|
|
881
959
|
issue: (message: string) => check.issue(message),
|
|
882
960
|
columnOf(kind, ref) {
|
|
883
961
|
const type = row.names.get(ref);
|
|
884
|
-
if (type) return { name: ref, type, description: "" };
|
|
962
|
+
if (type) return row.columns.get(ref) ?? { name: ref, type, description: "" };
|
|
885
963
|
if (row.kind === "plain") return check.columnOf(kind, ref);
|
|
886
964
|
check.issue(
|
|
887
965
|
refError(
|
|
@@ -1203,10 +1281,31 @@ function checkFilter(check: QueryCheck, filter: DatasetFilter, kind = "filter")
|
|
|
1203
1281
|
? columnValueIssue(check, kind, type, filter.op, filter.value.col)
|
|
1204
1282
|
: (filterValueIssue(filter) ??
|
|
1205
1283
|
filterValueTypeIssue(type, filter) ??
|
|
1206
|
-
truncatedDateIssue(filter)
|
|
1284
|
+
truncatedDateIssue(filter) ??
|
|
1285
|
+
listedValueIssue(check, kind, filter));
|
|
1207
1286
|
if (issue) check.issue(refError(kind, shown, issue));
|
|
1208
1287
|
}
|
|
1209
1288
|
|
|
1289
|
+
const LISTED_VALUE_OPS = new Set<DatasetFilterOp>(["=", "!=", "in", "notIn"]);
|
|
1290
|
+
|
|
1291
|
+
/**
|
|
1292
|
+
* A column that lists its values is only compared with one of them: any
|
|
1293
|
+
* other literal would match no row, or every row, and the host would answer
|
|
1294
|
+
* as if the data said so.
|
|
1295
|
+
*/
|
|
1296
|
+
function listedValueIssue(check: QueryCheck, kind: string, filter: DatasetCondition) {
|
|
1297
|
+
if (typeof filter.col !== "string" || !LISTED_VALUE_OPS.has(filter.op)) return undefined;
|
|
1298
|
+
if (filter.value === undefined || isColumnValue(filter.value)) return undefined;
|
|
1299
|
+
const listed = check.columnOf(kind, filter.col)?.values;
|
|
1300
|
+
if (!listed) return undefined;
|
|
1301
|
+
const values = Array.isArray(filter.value) ? filter.value : [filter.value];
|
|
1302
|
+
// A number or a boolean is compared as the text the host turns it into.
|
|
1303
|
+
const odd = values.find((value) => !listed.includes(String(value)));
|
|
1304
|
+
return odd === undefined
|
|
1305
|
+
? undefined
|
|
1306
|
+
: `${JSON.stringify(odd)} is not one of its values: ${listed.join(", ")}`;
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1210
1309
|
/** A truncated date is a whole day, so a literal against it is one too ("2024-03-01"); a time or a bare month would never match. */
|
|
1211
1310
|
function truncatedDateIssue(filter: DatasetCondition) {
|
|
1212
1311
|
const truncated =
|
|
@@ -1314,7 +1413,31 @@ function aggregateType(
|
|
|
1314
1413
|
}
|
|
1315
1414
|
|
|
1316
1415
|
/** What sort and rank may name: on a grouped query a group key or an alias; on a plain one a select alias or any reachable column. */
|
|
1317
|
-
type RowNames = {
|
|
1416
|
+
type RowNames = {
|
|
1417
|
+
names: ReadonlyMap<string, DatasetColumnType>;
|
|
1418
|
+
kind: "grouped" | "plain";
|
|
1419
|
+
/** The names that are a column as it is (group keys, select entries), so what the column says (its values) still holds. */
|
|
1420
|
+
columns: ReadonlyMap<string, DatasetColumn>;
|
|
1421
|
+
};
|
|
1422
|
+
|
|
1423
|
+
/**
|
|
1424
|
+
* The row names that are a column as it is, each with the column: a bare
|
|
1425
|
+
* column (`status`), or one under an alias (`{ expr: "status", as: "state" }`).
|
|
1426
|
+
*/
|
|
1427
|
+
function passedColumns(
|
|
1428
|
+
catalog: DatasetCatalog,
|
|
1429
|
+
query: DatasetQuery,
|
|
1430
|
+
entries: ReadonlyArray<string | { expr: DatasetOperand; as: string }>,
|
|
1431
|
+
) {
|
|
1432
|
+
return new Map(
|
|
1433
|
+
entries.flatMap((entry) => {
|
|
1434
|
+
const [name, ref] = typeof entry === "string" ? [entry, entry] : [entry.as, entry.expr];
|
|
1435
|
+
if (typeof ref !== "string") return [];
|
|
1436
|
+
const resolved = resolveDatasetColumn(catalog, query.from, ref);
|
|
1437
|
+
return resolved.ok ? [[name, resolved.column] as const] : [];
|
|
1438
|
+
}),
|
|
1439
|
+
);
|
|
1440
|
+
}
|
|
1318
1441
|
|
|
1319
1442
|
function checkSort(check: QueryCheck, sort: DatasetQuery["sort"], kind: string, row: RowNames) {
|
|
1320
1443
|
for (const { col } of sort) checkRowName(check, kind, col, row);
|
|
@@ -7,19 +7,22 @@ const REDACTION_MARKER = "[REDACTED]";
|
|
|
7
7
|
* itself and, after a leading scheme word (`Bearer xyz`, `Basic abc`), the
|
|
8
8
|
* bare secret the scheme merely labels; each of those raw as in a header,
|
|
9
9
|
* percent-encoded as in a path slot (`encodeURIComponent`) or a query value
|
|
10
|
-
* (`URLSearchParams`, which spells a space as `+`),
|
|
11
|
-
* a request body
|
|
10
|
+
* (`URLSearchParams`, which spells a space as `+`), JSON-escaped as inside
|
|
11
|
+
* a request body, and JSON-escaped then percent-encoded as a form field sent
|
|
12
|
+
* as JSON text is. The scrubber replaces longer forms first.
|
|
12
13
|
*/
|
|
13
14
|
export function credentialForms(value: string) {
|
|
14
15
|
const bare = /^[A-Za-z][\w.-]* +(\S.*)$/.exec(value)?.[1];
|
|
16
|
+
const percentEncoded = (form: string) => [
|
|
17
|
+
encodeURIComponent(form),
|
|
18
|
+
new URLSearchParams({ v: form }).toString().slice(2),
|
|
19
|
+
];
|
|
15
20
|
return [
|
|
16
21
|
...new Set(
|
|
17
|
-
(bare ? [value, bare] : [value]).flatMap((form) =>
|
|
18
|
-
form,
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
JSON.stringify(form).slice(1, -1),
|
|
22
|
-
]),
|
|
22
|
+
(bare ? [value, bare] : [value]).flatMap((form) => {
|
|
23
|
+
const json = JSON.stringify(form).slice(1, -1);
|
|
24
|
+
return [form, ...percentEncoded(form), json, ...percentEncoded(json)];
|
|
25
|
+
}),
|
|
23
26
|
),
|
|
24
27
|
];
|
|
25
28
|
}
|
|
@@ -24,3 +24,24 @@ export function escapePointerToken(token: string) {
|
|
|
24
24
|
export function unescapePointerToken(token: string) {
|
|
25
25
|
return token.replaceAll("~1", "/").replaceAll("~0", "~");
|
|
26
26
|
}
|
|
27
|
+
|
|
28
|
+
/** An RFC 6901 array index: digits with no leading zero. */
|
|
29
|
+
export const ARRAY_INDEX = /^(0|[1-9]\d*)$/;
|
|
30
|
+
|
|
31
|
+
/** RFC 6901 lookup; `undefined` when the pointer names nothing (an inherited key, or an array index not written canonically, included). */
|
|
32
|
+
export function readJsonPointer(value: unknown, pointer: string) {
|
|
33
|
+
if (pointer === "") return value;
|
|
34
|
+
let current: unknown = value;
|
|
35
|
+
for (const raw of pointer.slice(1).split("/")) {
|
|
36
|
+
const key = unescapePointerToken(raw);
|
|
37
|
+
if (Array.isArray(current)) {
|
|
38
|
+
if (!ARRAY_INDEX.test(key)) return undefined;
|
|
39
|
+
current = current[Number(key)];
|
|
40
|
+
} else if (isRecord(current) && Object.hasOwn(current, key)) {
|
|
41
|
+
current = current[key];
|
|
42
|
+
} else {
|
|
43
|
+
return undefined;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return current;
|
|
47
|
+
}
|