@m6d/cortex-cli 0.17.0 → 0.19.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 +33 -0
- package/package.json +1 -1
- package/src/commands/apply.ts +28 -13
- package/src/commands/management.ts +17 -15
- package/src/commands/setup.ts +72 -60
- package/src/contracts/datasets/dataset-sql.ts +65 -28
- package/src/contracts/management/index.ts +169 -0
- package/src/contracts/management/procedures.generated.ts +5891 -0
- package/src/contracts/manifest/index.ts +199 -33
- package/src/contracts/manifest-file/index.ts +58 -6
- package/src/management/client.ts +17 -77
- package/src/scaffold/files.ts +2 -2
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
import { isRecord } from "../json/index";
|
|
4
|
+
import { managementProcedureKinds } from "./procedures.generated";
|
|
5
|
+
import type { ManagementProcedures } from "./procedures.generated";
|
|
6
|
+
|
|
7
|
+
/*
|
|
8
|
+
* Cortex's management API as a caller reaches it: one REST operation per
|
|
9
|
+
* console procedure under `/api/management`, a query as GET with its input
|
|
10
|
+
* on the query string, a mutation as POST with its input as the JSON body,
|
|
11
|
+
* and a management key as the bearer. The console routes a procedure by
|
|
12
|
+
* `managementPath`; the CLI and the TypeScript SDK call through
|
|
13
|
+
* `callManagement`, and M6d.Cortex spells the same rules in C#.
|
|
14
|
+
* `management.fixtures.json` holds every caller to the same requests and
|
|
15
|
+
* refusals. `createManagementClient` types each call from
|
|
16
|
+
* `procedures.generated.ts`, which the console prints from its procedures.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** Where a console serves the management API. */
|
|
20
|
+
export const MANAGEMENT_BASE_PATH = "/api/management";
|
|
21
|
+
|
|
22
|
+
/** A procedure's REST path: its tRPC path, kebab-cased (`managementKeys.mine` is `/management-keys/mine`). */
|
|
23
|
+
export function managementPath(procedure: string) {
|
|
24
|
+
const kebab = procedure.replace(/[A-Z]/g, (upper) => `-${upper.toLowerCase()}`);
|
|
25
|
+
return `/${kebab.replaceAll(".", "/")}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function isScalar(value: unknown): value is string | number | boolean | null {
|
|
29
|
+
return value === null || ["string", "number", "boolean"].includes(typeof value);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* A query's input on the query string, one parameter per field: a scalar as
|
|
34
|
+
* itself, a non-empty array of scalars as a repeated parameter, anything
|
|
35
|
+
* else as JSON, as the document declares each field; "" for none.
|
|
36
|
+
*/
|
|
37
|
+
function managementQuery(input: Record<string, unknown>) {
|
|
38
|
+
const params = new URLSearchParams();
|
|
39
|
+
for (const [name, value] of Object.entries(input)) {
|
|
40
|
+
if (value === undefined) continue;
|
|
41
|
+
if (isScalar(value)) params.set(name, String(value));
|
|
42
|
+
else if (Array.isArray(value) && value.length > 0 && value.every(isScalar)) {
|
|
43
|
+
for (const item of value) params.append(name, String(item));
|
|
44
|
+
} else params.set(name, JSON.stringify(value));
|
|
45
|
+
}
|
|
46
|
+
const encoded = params.toString();
|
|
47
|
+
return encoded === "" ? "" : `?${encoded}`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The body a refusal answers with, each field kept only when it has its documented shape. */
|
|
51
|
+
const refusalBodySchema = z.object({
|
|
52
|
+
code: z.string().optional().catch(undefined),
|
|
53
|
+
message: z.string().optional().catch(undefined),
|
|
54
|
+
refusal: z.string().optional().catch(undefined),
|
|
55
|
+
cause: z.record(z.string(), z.unknown()).optional().catch(undefined),
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Cortex refused a management call: `code` is the tRPC code (`FORBIDDEN`),
|
|
60
|
+
* the message the refusal's literal (`read_only`), `detail` what the refusal
|
|
61
|
+
* names when it names something (`{ error, fields }`), and `refusal` the
|
|
62
|
+
* sentence Cortex words it in, when it has one. A body that is no refusal
|
|
63
|
+
* (a proxy's error page) reads as code `error` with the status text.
|
|
64
|
+
*/
|
|
65
|
+
export class ManagementClientError extends Error {
|
|
66
|
+
public constructor(
|
|
67
|
+
public readonly status: number,
|
|
68
|
+
public readonly code: string,
|
|
69
|
+
message: string,
|
|
70
|
+
public readonly detail?: Record<string, unknown>,
|
|
71
|
+
public readonly refusal?: string,
|
|
72
|
+
) {
|
|
73
|
+
super(message);
|
|
74
|
+
this.name = "ManagementClientError";
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Where management calls go: a console's origin and a management key (`cortex_mgmt_…`); a test hands in its own `fetch`. */
|
|
79
|
+
export type ManagementTarget = {
|
|
80
|
+
url: string;
|
|
81
|
+
key: string;
|
|
82
|
+
fetch?: (url: string, init: RequestInit) => Promise<Response>;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* One management call by REST route, answering the procedure's output as
|
|
87
|
+
* parsed JSON. A refusal throws `ManagementClientError`; a Cortex that cannot
|
|
88
|
+
* be reached, or a success body that cannot be read, rejects as `fetch` and
|
|
89
|
+
* `Response.json` do.
|
|
90
|
+
*/
|
|
91
|
+
export async function callManagement(
|
|
92
|
+
target: ManagementTarget,
|
|
93
|
+
request: { method: "get" | "post"; path: string; input?: unknown; signal?: AbortSignal },
|
|
94
|
+
) {
|
|
95
|
+
const { method, path, input, signal } = request;
|
|
96
|
+
const query = method === "get" && isRecord(input) ? managementQuery(input) : "";
|
|
97
|
+
const url = `${target.url.replace(/\/+$/, "")}${MANAGEMENT_BASE_PATH}${path}${query}`;
|
|
98
|
+
const response = await (target.fetch ?? globalThis.fetch)(url, {
|
|
99
|
+
method: method.toUpperCase(),
|
|
100
|
+
headers: {
|
|
101
|
+
Authorization: `Bearer ${target.key}`,
|
|
102
|
+
...(method === "post" ? { "Content-Type": "application/json" } : {}),
|
|
103
|
+
},
|
|
104
|
+
body: method === "post" && input !== undefined ? JSON.stringify(input) : undefined,
|
|
105
|
+
signal,
|
|
106
|
+
});
|
|
107
|
+
// A success that cannot be read is the caller's failure, never a null result.
|
|
108
|
+
if (response.ok) {
|
|
109
|
+
const output: unknown = await response.json();
|
|
110
|
+
return output;
|
|
111
|
+
}
|
|
112
|
+
const body: unknown = await response.json().catch(() => null);
|
|
113
|
+
const refused = refusalBodySchema.safeParse(body).data ?? {};
|
|
114
|
+
throw new ManagementClientError(
|
|
115
|
+
response.status,
|
|
116
|
+
refused.code ?? "error",
|
|
117
|
+
refused.message ?? response.statusText,
|
|
118
|
+
refused.cause,
|
|
119
|
+
refused.refusal,
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
type ManagementProcedure = keyof ManagementProcedures;
|
|
124
|
+
|
|
125
|
+
/** What procedure `P` takes, as it is sent. */
|
|
126
|
+
export type ManagementInput<P extends ManagementProcedure> = ManagementProcedures[P]["input"];
|
|
127
|
+
|
|
128
|
+
/** What procedure `P` answers, as JSON carries it: a date is its ISO string. */
|
|
129
|
+
export type ManagementOutput<P extends ManagementProcedure> = ManagementProcedures[P]["output"];
|
|
130
|
+
|
|
131
|
+
type CallOptions = { signal?: AbortSignal };
|
|
132
|
+
|
|
133
|
+
/** Procedure `P` as a call; its input may be left out when it takes none, or nothing it requires. */
|
|
134
|
+
type Call<P extends ManagementProcedure> =
|
|
135
|
+
Partial<ManagementInput<P>> extends ManagementInput<P>
|
|
136
|
+
? (input?: ManagementInput<P>, options?: CallOptions) => Promise<ManagementOutput<P>>
|
|
137
|
+
: (input: ManagementInput<P>, options?: CallOptions) => Promise<ManagementOutput<P>>;
|
|
138
|
+
|
|
139
|
+
/** Every procedure as `client.<router>.<procedure>`, as the API documents it. */
|
|
140
|
+
export type ManagementClient = {
|
|
141
|
+
[R in ManagementProcedure extends `${infer Router}.${string}` ? Router : never]: {
|
|
142
|
+
[P in ManagementProcedure as P extends `${R}.${infer Name}` ? Name : never]: Call<P>;
|
|
143
|
+
};
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* A typed management client: `client.agents.saveSettings({ id, greeting })`
|
|
148
|
+
* sends the procedure's request, a GET or a POST as its kind is, and answers
|
|
149
|
+
* its output. A refusal throws `ManagementClientError`.
|
|
150
|
+
*/
|
|
151
|
+
export function createManagementClient(target: ManagementTarget) {
|
|
152
|
+
const client: Record<
|
|
153
|
+
string,
|
|
154
|
+
Record<string, (input?: unknown, options?: CallOptions) => Promise<unknown>>
|
|
155
|
+
> = {};
|
|
156
|
+
for (const [procedure, kind] of Object.entries(managementProcedureKinds)) {
|
|
157
|
+
const [router = "", name = ""] = procedure.split(".");
|
|
158
|
+
(client[router] ??= {})[name] = (input, options) =>
|
|
159
|
+
callManagement(target, {
|
|
160
|
+
method: kind === "query" ? "get" : "post",
|
|
161
|
+
path: managementPath(procedure),
|
|
162
|
+
input,
|
|
163
|
+
signal: options?.signal,
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
// The table of kinds and the procedure map are printed from one model, so
|
|
167
|
+
// each call built here is the one its type names.
|
|
168
|
+
return client as ManagementClient;
|
|
169
|
+
}
|