@m6d/cortex-cli 0.1.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 +175 -0
- package/bin/cortex.js +52 -0
- package/package.json +41 -0
- package/src/cc/client.ts +295 -0
- package/src/cc/flags.ts +135 -0
- package/src/cc/output.ts +77 -0
- package/src/cc/profiles.ts +105 -0
- package/src/cli.ts +56 -0
- package/src/commands/cc.ts +504 -0
- package/src/commands/graph.ts +229 -0
- package/src/commands/new.ts +333 -0
- package/src/commands/swagger.ts +261 -0
- package/src/config/load.ts +122 -0
- package/src/config/validate.ts +46 -0
- package/src/contracts/charts/chart-spec.ts +201 -0
- package/src/contracts/charts/index.ts +7 -0
- package/src/contracts/client-tools/index.ts +82 -0
- package/src/contracts/egress/index.ts +161 -0
- package/src/contracts/egress/scrub.ts +59 -0
- package/src/contracts/graph/clients/embed.ts +45 -0
- package/src/contracts/graph/clients/neo4j.ts +85 -0
- package/src/contracts/graph/helpers.ts +57 -0
- package/src/contracts/graph/index.ts +30 -0
- package/src/contracts/graph/schema.ts +65 -0
- package/src/contracts/graph/types.ts +125 -0
- package/src/contracts/markdown/index.ts +39 -0
- package/src/contracts/rich-text/index.ts +220 -0
- package/src/contracts/runtime/api.ts +328 -0
- package/src/contracts/runtime/auth.ts +41 -0
- package/src/contracts/runtime/dataset-sql-guide.ts +67 -0
- package/src/contracts/runtime/dataset-sql.ts +2522 -0
- package/src/contracts/runtime/datasets.fixtures.json +2849 -0
- package/src/contracts/runtime/datasets.ts +1391 -0
- package/src/contracts/runtime/grammar.ts +161 -0
- package/src/contracts/runtime/index.ts +14 -0
- package/src/contracts/runtime/signature.ts +237 -0
- package/src/contracts/runtime/sync.ts +172 -0
- package/src/contracts/runtime/triggers.ts +224 -0
- package/src/contracts/wire/index.ts +264 -0
- package/src/graph/expand-domains.ts +288 -0
- package/src/graph/generate-cypher.ts +201 -0
- package/src/graph/seed.ts +207 -0
- package/src/graph/validate.ts +78 -0
- package/src/scaffold/features.ts +190 -0
- package/src/scaffold/files.ts +411 -0
- package/src/scaffold/ports.ts +86 -0
- package/src/scaffold/templates/domains-README.md.txt +16 -0
- package/src/scaffold/templates/eslint.config.mjs.txt +20 -0
- package/src/scaffold/templates/fallowrc.jsonc.txt +42 -0
- package/src/scaffold/templates/gitignore.txt +19 -0
- package/src/scaffold/templates/index.ts.txt +18 -0
- package/src/scaffold/templates/prettier.config.mjs.txt +5 -0
- package/src/scaffold/templates/tsconfig.json.txt +25 -0
- package/src/swagger/extract-endpoints.ts +266 -0
- package/src/swagger/generated-block.ts +74 -0
- package/src/swagger/openapi-schema.ts +161 -0
- package/src/swagger/parse-response.ts +88 -0
- package/src/swagger/types.ts +25 -0
- package/src/ui/prompts.ts +125 -0
- package/src/ui/report.ts +107 -0
- package/tsconfig.json +46 -0
|
@@ -0,0 +1,504 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
|
|
3
|
+
import { type Command, Option } from "@commander-js/extra-typings";
|
|
4
|
+
import { isTlsOrLoopback } from "@cortex/contracts/egress";
|
|
5
|
+
|
|
6
|
+
import { callManagement, inputSchemaOf, isRecord, loadOpenApi, ManagementError } from "@/cc/client";
|
|
7
|
+
import type { OpenApiDocument, OpenApiOperation } from "@/cc/client";
|
|
8
|
+
import { flagsFor, parseAnswer, toOption } from "@/cc/flags";
|
|
9
|
+
import type { Flag } from "@/cc/flags";
|
|
10
|
+
import { print } from "@/cc/output";
|
|
11
|
+
import {
|
|
12
|
+
ProfileFileError,
|
|
13
|
+
profilesPath,
|
|
14
|
+
removeProfile,
|
|
15
|
+
resolveProfile,
|
|
16
|
+
saveProfile,
|
|
17
|
+
} from "@/cc/profiles";
|
|
18
|
+
import * as prompts from "@/ui/prompts";
|
|
19
|
+
import * as report from "@/ui/report";
|
|
20
|
+
|
|
21
|
+
const PROFILE_COMMANDS = new Set(["login", "logout", "status"]);
|
|
22
|
+
const MAX_PAGES = 1000;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* `cortex cc <router> <procedure> [id] [flags]`: the Control Center's
|
|
26
|
+
* management API as commands. The tree is built from the CC's own OpenAPI
|
|
27
|
+
* document on each run, so it always matches the deployment it points at
|
|
28
|
+
* and a new procedure needs no CLI work. Profiles hold the URL and key.
|
|
29
|
+
*/
|
|
30
|
+
export function registerCc(program: Command, argv: string[]) {
|
|
31
|
+
const cc = program
|
|
32
|
+
.command("cc")
|
|
33
|
+
.helpGroup("Control Center:")
|
|
34
|
+
.summary("Manage a Control Center without its console")
|
|
35
|
+
.description(
|
|
36
|
+
"Manage a Control Center without its console.\n\nCommands come from the CC's management API: `cortex cc <router> <procedure>`.\nLog in once with a management key; profiles live in ~/.cortex/cc.json.",
|
|
37
|
+
)
|
|
38
|
+
.option("-p, --profile <name>", "Which saved Control Center to talk to")
|
|
39
|
+
.addOption(
|
|
40
|
+
new Option("-o, --output <format>", "Output format")
|
|
41
|
+
.choices(["table", "json"])
|
|
42
|
+
.default("table"),
|
|
43
|
+
)
|
|
44
|
+
.helpCommand(false);
|
|
45
|
+
|
|
46
|
+
cc.command("login")
|
|
47
|
+
.description("Save a Control Center URL and management key as a profile")
|
|
48
|
+
.option("--url <url>")
|
|
49
|
+
.option("--name <name>", "Profile name", "default")
|
|
50
|
+
.action(async function (options) {
|
|
51
|
+
await login(options);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
cc.command("logout")
|
|
55
|
+
.description("Forget a profile")
|
|
56
|
+
.option("--name <name>", "Profile name", "default")
|
|
57
|
+
.action(function (options) {
|
|
58
|
+
report.header("cortex cc logout");
|
|
59
|
+
report.footer(
|
|
60
|
+
withProfileFile(() => removeProfile(options.name))
|
|
61
|
+
? `removed ${options.name}`
|
|
62
|
+
: `no profile ${options.name}`,
|
|
63
|
+
);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
cc.command("status")
|
|
67
|
+
.description("Which Control Center commands run against, and with which key")
|
|
68
|
+
.action(async function () {
|
|
69
|
+
const profile = requireProfile(cc.opts().profile);
|
|
70
|
+
report.header("cortex cc status");
|
|
71
|
+
report.input("Profile", profile.name);
|
|
72
|
+
report.input("URL", profile.url);
|
|
73
|
+
const me = await whoami(profile);
|
|
74
|
+
report.footer(me ? `authenticated with ${me}` : "key rejected");
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// The resource tree needs the document, which needs a profile, so it is
|
|
78
|
+
// built only when argv actually names a resource command: the first
|
|
79
|
+
// operand after `cc`. Commander parses the group's own options here, so
|
|
80
|
+
// every spelling it accepts (`-p x`, `-px`, `--profile=x`) names the same
|
|
81
|
+
// profile the commands then run against. Root options may precede `cc`.
|
|
82
|
+
const at = argv.findIndex((word) => !word.startsWith("-"));
|
|
83
|
+
if (at === -1 || argv[at] !== "cc") return;
|
|
84
|
+
const { operands } = cc.parseOptions(argv.slice(at + 1));
|
|
85
|
+
const first = operands[0];
|
|
86
|
+
if (first === undefined || PROFILE_COMMANDS.has(first)) return;
|
|
87
|
+
return buildResourceCommands(cc, requireProfile(cc.opts().profile));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The key is never a flag (argv is visible to other processes and lands in
|
|
92
|
+
* shell history): it is prompted for masked, or read from `CORTEX_CC_KEY`
|
|
93
|
+
* when there is no terminal to ask.
|
|
94
|
+
*/
|
|
95
|
+
async function login(options: { url?: string; name: string }) {
|
|
96
|
+
const url = requireTls(
|
|
97
|
+
(
|
|
98
|
+
options.url ?? (await prompts.askValue("Control Center URL", "https://cc.example.com"))
|
|
99
|
+
).replace(/\/+$/, ""),
|
|
100
|
+
);
|
|
101
|
+
const key = process.stdin.isTTY
|
|
102
|
+
? await prompts.askSecret("Management key")
|
|
103
|
+
: (process.env["CORTEX_CC_KEY"] ??
|
|
104
|
+
report.fail({
|
|
105
|
+
what: "No terminal to ask for the management key",
|
|
106
|
+
blocks: ["Set CORTEX_CC_KEY in the environment and run login again."],
|
|
107
|
+
}));
|
|
108
|
+
const me = await whoami({ url, key });
|
|
109
|
+
if (!me) {
|
|
110
|
+
report.fail({
|
|
111
|
+
what: "The Control Center rejected that key",
|
|
112
|
+
blocks: [
|
|
113
|
+
"Mint one on your account page, or set BOOTSTRAP_MANAGEMENT_KEY on a headless deployment.",
|
|
114
|
+
],
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
withProfileFile(() => saveProfile(options.name, { url, key }));
|
|
118
|
+
report.header("cortex cc login");
|
|
119
|
+
report.input("Profile", options.name);
|
|
120
|
+
report.input("URL", url);
|
|
121
|
+
report.input("Key", me);
|
|
122
|
+
report.footer(`saved to ${profilesPath()}`);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* The key the Control Center recognized the bearer as, or null when it does
|
|
127
|
+
* not authenticate. A Control Center that cannot be reached, or answers with
|
|
128
|
+
* anything but 200/401, is the run's outcome, not a CLI bug.
|
|
129
|
+
*/
|
|
130
|
+
async function whoami(profile: { url: string; key: string }) {
|
|
131
|
+
try {
|
|
132
|
+
const me = (await callManagement(profile, "get", "/management-keys/mine", undefined)) as {
|
|
133
|
+
name: string;
|
|
134
|
+
fingerprint: string;
|
|
135
|
+
};
|
|
136
|
+
return `key "${me.name}" (${me.fingerprint})`;
|
|
137
|
+
} catch (error) {
|
|
138
|
+
if (error instanceof ManagementError && error.status === 401) return null;
|
|
139
|
+
if (error instanceof ManagementError) {
|
|
140
|
+
report.fail({
|
|
141
|
+
what: `${profile.url} answered ${error.status} ${error.code}: ${error.message}`,
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
report.fail({
|
|
145
|
+
what: `Could not reach ${profile.url}`,
|
|
146
|
+
error,
|
|
147
|
+
blocks: ["Check the URL and that the Control Center is up."],
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Runs a profile-file operation; a file this CLI cannot read is an input error, not a crash. */
|
|
153
|
+
function withProfileFile<T>(operation: () => T) {
|
|
154
|
+
try {
|
|
155
|
+
return operation();
|
|
156
|
+
} catch (error) {
|
|
157
|
+
if (error instanceof ProfileFileError) {
|
|
158
|
+
report.fail({
|
|
159
|
+
what: error.message,
|
|
160
|
+
blocks: ["Fix or delete the file, then log in again:", " cortex cc login"],
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
throw error;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** The key rides on every call, so the URL it goes to must be https (or loopback). */
|
|
168
|
+
function requireTls(url: string) {
|
|
169
|
+
if (isTlsOrLoopback(url)) return url;
|
|
170
|
+
return report.fail({
|
|
171
|
+
what: `${url} is not https`,
|
|
172
|
+
blocks: [
|
|
173
|
+
"The management key would travel in the clear. Use an https URL (http is allowed on localhost only).",
|
|
174
|
+
],
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function requireProfile(name: string | undefined) {
|
|
179
|
+
const profile = withProfileFile(() => resolveProfile(name));
|
|
180
|
+
if (!profile) {
|
|
181
|
+
report.fail({
|
|
182
|
+
what: "No Control Center to talk to",
|
|
183
|
+
blocks: [
|
|
184
|
+
"Log in once:",
|
|
185
|
+
" cortex cc login",
|
|
186
|
+
"or set CORTEX_CC_URL and CORTEX_CC_KEY in the environment.",
|
|
187
|
+
],
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
requireTls(profile.url);
|
|
191
|
+
return profile;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
type Resolved = NonNullable<ReturnType<typeof resolveProfile>>;
|
|
195
|
+
|
|
196
|
+
type CcGroup = Command<[], { profile?: string; output: "table" | "json" }>;
|
|
197
|
+
|
|
198
|
+
type Operation = { path: string; method: "get" | "post"; operation: OpenApiOperation };
|
|
199
|
+
|
|
200
|
+
async function buildResourceCommands(cc: CcGroup, profile: Resolved) {
|
|
201
|
+
const loaded = await loadOpenApi(profile.url).catch((error: unknown) =>
|
|
202
|
+
report.fail({
|
|
203
|
+
what: `Could not read the management API at ${profile.url}`,
|
|
204
|
+
error,
|
|
205
|
+
blocks: [
|
|
206
|
+
"The command list comes from /api/management/openapi.json; nothing is cached yet.",
|
|
207
|
+
],
|
|
208
|
+
}),
|
|
209
|
+
);
|
|
210
|
+
if (loaded.from === "stale")
|
|
211
|
+
report.aside(`Using cached commands; ${profile.url} is unreachable.`);
|
|
212
|
+
const { document } = loaded;
|
|
213
|
+
|
|
214
|
+
// Every operation by procedure path, so a write can find the operation
|
|
215
|
+
// its `x-publish-procedure` names and invoke it by that operation's own
|
|
216
|
+
// method and path.
|
|
217
|
+
const byProcedure = new Map(
|
|
218
|
+
[...groupByRouter(document).values()].flat().map((o) => [o.operation.operationId, o]),
|
|
219
|
+
);
|
|
220
|
+
for (const [router, operations] of groupByRouter(document)) {
|
|
221
|
+
const group = cc.command(router).description(`${router} procedures`).helpCommand(false);
|
|
222
|
+
for (const operation of operations) {
|
|
223
|
+
registerOperation({ cc, group, router, operation, byProcedure, document, profile });
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Operations by their router tag, the first path segment when untagged. */
|
|
229
|
+
function groupByRouter(document: OpenApiDocument) {
|
|
230
|
+
const byRouter = new Map<string, Operation[]>();
|
|
231
|
+
for (const [path, methods] of Object.entries(document.paths)) {
|
|
232
|
+
for (const [method, operation] of Object.entries(methods)) {
|
|
233
|
+
const router = operation.tags?.[0] ?? path.split("/")[1] ?? "";
|
|
234
|
+
byRouter.set(router, [
|
|
235
|
+
...(byRouter.get(router) ?? []),
|
|
236
|
+
{ path, method: method as "get" | "post", operation },
|
|
237
|
+
]);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
return byRouter;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* One procedure as one command: a positional for `id`, a typed flag per
|
|
245
|
+
* field, `--json` for a whole body, `--all` on paginated reads, `--publish`
|
|
246
|
+
* on writes to a publishable router.
|
|
247
|
+
*/
|
|
248
|
+
function registerOperation(scope: {
|
|
249
|
+
cc: CcGroup;
|
|
250
|
+
group: Command;
|
|
251
|
+
router: string;
|
|
252
|
+
operation: Operation;
|
|
253
|
+
byProcedure: Map<string, Operation>;
|
|
254
|
+
document: OpenApiDocument;
|
|
255
|
+
profile: Resolved;
|
|
256
|
+
}) {
|
|
257
|
+
const { cc, group, router, byProcedure, document, profile } = scope;
|
|
258
|
+
const { path, method, operation } = scope.operation;
|
|
259
|
+
const name = operation.operationId.split(".")[1] ?? path;
|
|
260
|
+
const input = inputSchemaOf(operation);
|
|
261
|
+
const flags = input ? flagsFor(input, document) : [];
|
|
262
|
+
const paginated = method === "get" && flags.some((flag) => flag.name === "pageIndex");
|
|
263
|
+
// `--publish` rides only on writes the document says can be published
|
|
264
|
+
// (`x-publish-procedure`); a create that leaves the publication content
|
|
265
|
+
// blank carries none, since publishing it would only fail. Fields the
|
|
266
|
+
// document marks live (`x-live-fields`) are reported as such after the
|
|
267
|
+
// write instead of as part of the draft.
|
|
268
|
+
const publishWith = operation["x-publish-procedure"];
|
|
269
|
+
const publisher = publishWith === undefined ? undefined : byProcedure.get(publishWith);
|
|
270
|
+
const canPublish = publisher !== undefined;
|
|
271
|
+
const liveFields = operation["x-live-fields"] ?? [];
|
|
272
|
+
|
|
273
|
+
const command = declareCommand(group, name, operation.summary ?? "", flags, {
|
|
274
|
+
paginated,
|
|
275
|
+
canPublish,
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
command.action(async function (...args) {
|
|
279
|
+
const options = command.opts() as Record<string, unknown> & {
|
|
280
|
+
json?: string;
|
|
281
|
+
all?: boolean;
|
|
282
|
+
publish?: boolean;
|
|
283
|
+
};
|
|
284
|
+
// Commander passes the options object first when the command declares
|
|
285
|
+
// no argument, so only a string is the `[id]` positional.
|
|
286
|
+
const positional = typeof args[0] === "string" ? args[0] : undefined;
|
|
287
|
+
const { json, all, publish, ...given } = options;
|
|
288
|
+
const body = await collectInput(flags, given, json, positional, name);
|
|
289
|
+
const format = cc.opts().output;
|
|
290
|
+
// `-o json` promises stdout holds only the result; prose moves to stderr.
|
|
291
|
+
const note = format === "json" ? report.aside : report.note;
|
|
292
|
+
if (format === "table") {
|
|
293
|
+
report.header(`cortex cc ${router} ${name}`);
|
|
294
|
+
report.input("Control Center", profile.url);
|
|
295
|
+
}
|
|
296
|
+
const result = await callOrFail(profile, () =>
|
|
297
|
+
all
|
|
298
|
+
? walkPages(profile, method, path, body)
|
|
299
|
+
: callManagement(profile, method, path, body),
|
|
300
|
+
);
|
|
301
|
+
print(result, format);
|
|
302
|
+
if (publisher) {
|
|
303
|
+
await publishAfterWrite(profile, publisher, result, publish === true, {
|
|
304
|
+
note,
|
|
305
|
+
liveFields: liveFields.filter(
|
|
306
|
+
(field) => isRecord(body) && body[field] !== undefined,
|
|
307
|
+
),
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** The command's arguments and options, from what its input schema admits. */
|
|
314
|
+
function declareCommand(
|
|
315
|
+
group: Command,
|
|
316
|
+
name: string,
|
|
317
|
+
summary: string,
|
|
318
|
+
flags: Flag[],
|
|
319
|
+
shape: { paginated: boolean; canPublish: boolean },
|
|
320
|
+
) {
|
|
321
|
+
const command = group.command(name).description(summary);
|
|
322
|
+
const idFlag = flags.find((flag) => flag.name === "id");
|
|
323
|
+
if (idFlag) command.argument(idFlag.required ? "<id>" : "[id]");
|
|
324
|
+
// A sensitive field never becomes a flag: argv is visible to every
|
|
325
|
+
// process on the machine and lands in shell history. It is prompted for
|
|
326
|
+
// masked on a terminal, or supplied through `--json -` from stdin.
|
|
327
|
+
for (const flag of flags) {
|
|
328
|
+
if (flag.name !== "id" && !flag.sensitive) command.addOption(toOption(flag));
|
|
329
|
+
}
|
|
330
|
+
if (flags.length > 0) {
|
|
331
|
+
command.option("--json <file>", "Read the whole input from a JSON file, or - for stdin");
|
|
332
|
+
}
|
|
333
|
+
if (shape.paginated) command.option("--all", "Walk every page");
|
|
334
|
+
if (shape.canPublish) command.option("--publish", "Publish the entity right after saving");
|
|
335
|
+
return command;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* A write to a publishable router is a draft: publish it now, or say how to.
|
|
340
|
+
* Fields the server reads live are already in effect and are named as such,
|
|
341
|
+
* since "draft" would understate what just happened.
|
|
342
|
+
*/
|
|
343
|
+
async function publishAfterWrite(
|
|
344
|
+
profile: Resolved,
|
|
345
|
+
publisher: Operation,
|
|
346
|
+
result: unknown,
|
|
347
|
+
publish: boolean,
|
|
348
|
+
options: { note: (text: string) => void; liveFields: string[] },
|
|
349
|
+
) {
|
|
350
|
+
const id = (result as { id?: string } | null)?.id;
|
|
351
|
+
if (!id) return;
|
|
352
|
+
const command = `cortex cc ${publisher.operation.operationId.replace(".", " ")} ${id}`;
|
|
353
|
+
const live = options.liveFields.length > 0;
|
|
354
|
+
if (live) {
|
|
355
|
+
options.note(
|
|
356
|
+
`${options.liveFields.join(", ")} ${options.liveFields.length === 1 ? "is" : "are"} live: bound on the server's next config revalidation, no publish needed.`,
|
|
357
|
+
);
|
|
358
|
+
}
|
|
359
|
+
if (publish) {
|
|
360
|
+
await callOrFail(profile, () =>
|
|
361
|
+
callManagement(profile, publisher.method, publisher.path, { id }),
|
|
362
|
+
);
|
|
363
|
+
options.note(`Published ${id}.`);
|
|
364
|
+
} else {
|
|
365
|
+
options.note(
|
|
366
|
+
`${live ? "The rest is" : "Saved as"} a draft. Add --publish, or run: ${command}`,
|
|
367
|
+
);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* A management refusal, or a Control Center that cannot be reached, is the
|
|
373
|
+
* run's outcome, in the error grammar; anything else is a bug.
|
|
374
|
+
*/
|
|
375
|
+
async function callOrFail<T>(profile: Resolved, call: () => Promise<T>) {
|
|
376
|
+
try {
|
|
377
|
+
return await call();
|
|
378
|
+
} catch (error) {
|
|
379
|
+
if (error instanceof ManagementError) {
|
|
380
|
+
report.fail({ what: `${error.status} ${error.code}: ${error.message}` });
|
|
381
|
+
}
|
|
382
|
+
if (error instanceof TypeError) {
|
|
383
|
+
report.fail({
|
|
384
|
+
what: `Could not reach ${profile.url}`,
|
|
385
|
+
error,
|
|
386
|
+
blocks: ["Check the URL and that the Control Center is up."],
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
throw error;
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* The body: `--json` first, flags layered over it, the positional as `id`.
|
|
395
|
+
* A required field still missing is asked for on a TTY and named as the
|
|
396
|
+
* flag to add otherwise.
|
|
397
|
+
*/
|
|
398
|
+
async function collectInput(
|
|
399
|
+
flags: Flag[],
|
|
400
|
+
given: Record<string, unknown>,
|
|
401
|
+
jsonSource: string | undefined,
|
|
402
|
+
positional: string | undefined,
|
|
403
|
+
verb: string,
|
|
404
|
+
) {
|
|
405
|
+
const body = new Map(Object.entries(jsonSource ? readJsonBody(jsonSource) : {}));
|
|
406
|
+
for (const flag of flags) {
|
|
407
|
+
if (given[flag.name] !== undefined) body.set(flag.name, given[flag.name]);
|
|
408
|
+
}
|
|
409
|
+
if (positional !== undefined) body.set("id", positional);
|
|
410
|
+
|
|
411
|
+
const missing = flags.filter((flag) => flag.required && body.get(flag.name) === undefined);
|
|
412
|
+
if (missing.length > 0 && !process.stdin.isTTY) {
|
|
413
|
+
const named = (flag: Flag) =>
|
|
414
|
+
flag.sensitive ? `${flag.name} (via --json -)` : `--${flag.name}`;
|
|
415
|
+
report.fail({
|
|
416
|
+
what: `${verb} needs ${named(missing[0]!)}`,
|
|
417
|
+
blocks: [
|
|
418
|
+
`Required: ${missing.map(named).join(" ")}`,
|
|
419
|
+
...(missing.some((flag) => flag.sensitive)
|
|
420
|
+
? ["Secrets are not flags. Pipe them in a JSON body: ... --json -"]
|
|
421
|
+
: []),
|
|
422
|
+
],
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
for (const flag of missing) body.set(flag.name, await askUntilValid(flag));
|
|
426
|
+
return Object.fromEntries(body);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/** Asks for one field until the answer parses the way the flag would. */
|
|
430
|
+
async function askUntilValid(flag: Flag) {
|
|
431
|
+
const hint = flag.choices?.join(" | ") ?? flag.description ?? flag.kind;
|
|
432
|
+
for (;;) {
|
|
433
|
+
const answer = flag.sensitive
|
|
434
|
+
? await prompts.askSecret(flag.name)
|
|
435
|
+
: await prompts.askValue(flag.name, hint);
|
|
436
|
+
const value = parseAnswer(flag, answer);
|
|
437
|
+
if (value !== undefined) return value;
|
|
438
|
+
report.note(`${flag.name} must be ${flag.choices ? `one of ${hint}` : `a ${flag.kind}`}.`);
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/** JSON the user supplied; a parse failure is their input, not a crash. */
|
|
443
|
+
function parseJson(text: string, what: string) {
|
|
444
|
+
try {
|
|
445
|
+
return JSON.parse(text) as unknown;
|
|
446
|
+
} catch (error) {
|
|
447
|
+
return report.fail({ what: `${what} is not valid JSON`, error });
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/** The `--json` body: an object, since flags and the positional layer over it. */
|
|
452
|
+
function readJsonBody(source: string) {
|
|
453
|
+
if (source === "-" && process.stdin.isTTY) {
|
|
454
|
+
report.fail({
|
|
455
|
+
what: "--json - reads the body from stdin, but nothing is piped in",
|
|
456
|
+
blocks: ["Pipe a JSON document:", " cat body.json | cortex cc ... --json -"],
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
const label = source === "-" ? "stdin" : source;
|
|
460
|
+
let text: string;
|
|
461
|
+
try {
|
|
462
|
+
text = readFileSync(source === "-" ? 0 : source, "utf8");
|
|
463
|
+
} catch (error) {
|
|
464
|
+
return report.fail({ what: `Could not read ${label}`, error });
|
|
465
|
+
}
|
|
466
|
+
const parsed = parseJson(text, label);
|
|
467
|
+
if (!isRecord(parsed)) {
|
|
468
|
+
report.fail({
|
|
469
|
+
what: `${label} must hold a JSON object`,
|
|
470
|
+
blocks: [
|
|
471
|
+
"Flags and the positional id are layered over it, so it cannot be a bare value.",
|
|
472
|
+
],
|
|
473
|
+
});
|
|
474
|
+
}
|
|
475
|
+
return parsed;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
async function walkPages(profile: Resolved, method: "get" | "post", path: string, body: unknown) {
|
|
479
|
+
const given = isRecord(body) ? body : {};
|
|
480
|
+
const pageSize = typeof given["pageSize"] === "number" ? given["pageSize"] : 100;
|
|
481
|
+
const items = new Array<unknown>();
|
|
482
|
+
let count = 0;
|
|
483
|
+
let filteredCount = 0;
|
|
484
|
+
// Bounded: a server that reports more rows than it serves must not spin,
|
|
485
|
+
// and a listing the bound cannot cover is an error, never a short result.
|
|
486
|
+
for (let pageIndex = 0; pageIndex < MAX_PAGES; pageIndex += 1) {
|
|
487
|
+
const page = (await callManagement(profile, method, path, {
|
|
488
|
+
...given,
|
|
489
|
+
pageIndex,
|
|
490
|
+
pageSize,
|
|
491
|
+
})) as { items?: unknown[]; count?: number; filteredCount?: number };
|
|
492
|
+
if (!Array.isArray(page.items)) return page;
|
|
493
|
+
items.push(...page.items);
|
|
494
|
+
count = page.count ?? items.length;
|
|
495
|
+
filteredCount = page.filteredCount ?? items.length;
|
|
496
|
+
if (page.items.length < pageSize || items.length >= filteredCount) {
|
|
497
|
+
return { items, count, filteredCount };
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
return report.fail({
|
|
501
|
+
what: `--all stopped after ${MAX_PAGES} pages with ${items.length} of ${filteredCount} rows`,
|
|
502
|
+
blocks: ["Narrow the listing with a filter, or raise --pageSize."],
|
|
503
|
+
});
|
|
504
|
+
}
|