miravo 0.1.4 → 0.2.1
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 +38 -32
- package/package.json +11 -5
- package/src/__tests__/integration/executable-methods-runner.ts +253 -0
- package/src/__tests__/integration/executable-methods.integration.ts +1688 -0
- package/src/commands/fork.ts +205 -0
- package/src/commands/inject.test.ts +6 -9
- package/src/commands/inject.ts +48 -158
- package/src/commands/models.test.ts +32 -8
- package/src/commands/models.ts +71 -45
- package/src/commands/start.test.ts +32 -63
- package/src/commands/start.ts +333 -193
- package/src/commands/status.test.ts +3 -22
- package/src/commands/status.ts +114 -279
- package/src/commands/templates.test.ts +4 -50
- package/src/commands/templates.ts +12 -191
- package/src/index.ts +42 -8
- package/src/lib/admin-client.ts +214 -0
- package/src/lib/admin-server.test.ts +276 -0
- package/src/lib/admin-server.ts +307 -0
- package/src/lib/shutdown.test.ts +221 -93
- package/src/lib/shutdown.ts +478 -98
- package/src/lib/template-category.test.ts +14 -7
- package/src/lib/template-category.ts +6 -1
- package/src/lib/template-locator.ts +5 -8
- package/src/lib/template-utils.ts +14 -0
- package/src/logger.test.ts +2 -2
- package/src/logger.ts +68 -15
- package/src/output.ts +41 -1
- package/src/lib/health-server.test.ts +0 -161
- package/src/lib/health-server.ts +0 -154
|
@@ -1,25 +1,15 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
type DiscoveredTemplateWithSource,
|
|
5
|
-
discoverTemplates,
|
|
6
|
-
loadTemplate,
|
|
7
|
-
resolveModelPath,
|
|
8
|
-
} from "@miravo/core";
|
|
9
|
-
import {
|
|
10
|
-
type DiscoveredTemplate,
|
|
11
|
-
MIRAVO_CONTENT_DIR_ENV,
|
|
12
|
-
MiravoError,
|
|
13
|
-
type MiravoTemplate,
|
|
14
|
-
TEMPLATE_FILE_EXTENSION,
|
|
15
|
-
} from "@miravo/shared";
|
|
1
|
+
import { basename, relative, resolve } from "node:path";
|
|
2
|
+
import { type DiscoveredTemplateWithSource, discoverTemplates, loadTemplate } from "@miravo/core";
|
|
3
|
+
import { type DiscoveredTemplate, MiravoError, TEMPLATE_FILE_EXTENSION } from "@miravo/shared";
|
|
16
4
|
import type { Command } from "commander";
|
|
17
5
|
import { colors } from "consola/utils";
|
|
18
6
|
import {
|
|
19
7
|
groupTemplatesByCategory,
|
|
8
|
+
isStressBenchmarkTemplate,
|
|
20
9
|
sortTemplatesByCategoryAndName,
|
|
21
10
|
} from "../lib/template-category.ts";
|
|
22
11
|
import { createCatalog, resolveTemplateIdentifier } from "../lib/template-locator.ts";
|
|
12
|
+
import { collectReferencedModels } from "../lib/template-utils.ts";
|
|
23
13
|
import { configLog, handleCommandError, writeOutput } from "../logger.ts";
|
|
24
14
|
import { formatKeyValue, formatTable } from "../output.ts";
|
|
25
15
|
|
|
@@ -30,11 +20,7 @@ export function registerTemplatesCommand(program: Command): void {
|
|
|
30
20
|
.command("list")
|
|
31
21
|
.description("Discover and list available templates")
|
|
32
22
|
.option("--dir <path>", "Additional directory to search for templates")
|
|
33
|
-
.
|
|
34
|
-
"--content-dir <path>",
|
|
35
|
-
"User content root with /templates and /models (env: MIRAVO_CONTENT_DIR)",
|
|
36
|
-
)
|
|
37
|
-
.action(async (options: { readonly dir?: string; readonly contentDir?: string }) => {
|
|
23
|
+
.action(async (options: { readonly dir?: string }) => {
|
|
38
24
|
await templatesListAction(options);
|
|
39
25
|
});
|
|
40
26
|
|
|
@@ -42,18 +28,11 @@ export function registerTemplatesCommand(program: Command): void {
|
|
|
42
28
|
.command("inspect <idNameOrPath>")
|
|
43
29
|
.description("Show detailed information about a template by id, name, or path")
|
|
44
30
|
.option("--raw", "Dump the raw YAML file to stdout")
|
|
45
|
-
.option("--copy", "Copy template + referenced built-in models to user content directory")
|
|
46
|
-
.option(
|
|
47
|
-
"--content-dir <path>",
|
|
48
|
-
"User content root with /templates and /models (env: MIRAVO_CONTENT_DIR)",
|
|
49
|
-
)
|
|
50
31
|
.action(
|
|
51
32
|
async (
|
|
52
33
|
idNameOrPath: string,
|
|
53
34
|
options: {
|
|
54
35
|
readonly raw?: boolean;
|
|
55
|
-
readonly copy?: boolean;
|
|
56
|
-
readonly contentDir?: string;
|
|
57
36
|
},
|
|
58
37
|
) => {
|
|
59
38
|
await templatesInspectAction(idNameOrPath, options);
|
|
@@ -63,20 +42,19 @@ export function registerTemplatesCommand(program: Command): void {
|
|
|
63
42
|
|
|
64
43
|
interface TemplateListOptions {
|
|
65
44
|
readonly dir?: string;
|
|
66
|
-
readonly contentDir?: string;
|
|
67
45
|
}
|
|
68
46
|
|
|
69
47
|
interface TemplateInspectOptions {
|
|
70
48
|
readonly raw?: boolean;
|
|
71
|
-
readonly copy?: boolean;
|
|
72
|
-
readonly contentDir?: string;
|
|
73
49
|
}
|
|
74
50
|
|
|
75
51
|
async function templatesListAction(options: TemplateListOptions): Promise<void> {
|
|
76
52
|
try {
|
|
77
|
-
const catalog = createCatalog(
|
|
53
|
+
const catalog = createCatalog();
|
|
78
54
|
const all = await catalog.listTemplates();
|
|
79
|
-
const builtinTemplates = all.filter(
|
|
55
|
+
const builtinTemplates = all.filter(
|
|
56
|
+
(t) => t.source.kind === "builtin" && !isStressBenchmarkTemplate(t.id),
|
|
57
|
+
);
|
|
80
58
|
const localTemplates: DiscoveredTemplateWithSource[] = all.filter(
|
|
81
59
|
(t) => t.source.kind !== "builtin",
|
|
82
60
|
);
|
|
@@ -164,17 +142,10 @@ async function templatesInspectAction(
|
|
|
164
142
|
options: TemplateInspectOptions,
|
|
165
143
|
): Promise<void> {
|
|
166
144
|
try {
|
|
167
|
-
const catalog = createCatalog(
|
|
145
|
+
const catalog = createCatalog();
|
|
168
146
|
const resolvedPath = await resolveTemplateIdentifier(idNameOrPath, catalog);
|
|
169
147
|
const sourceLabel = catalog.getSourceLabel(resolvedPath);
|
|
170
148
|
|
|
171
|
-
if (options.raw && options.copy) {
|
|
172
|
-
throw new MiravoError(
|
|
173
|
-
"Cannot combine --raw with --copy.",
|
|
174
|
-
"Run either 'miravo templates inspect <template> --raw' or 'miravo templates inspect <template> --copy'.",
|
|
175
|
-
);
|
|
176
|
-
}
|
|
177
|
-
|
|
178
149
|
if (options.raw) {
|
|
179
150
|
const rawText = await Bun.file(resolvedPath).text();
|
|
180
151
|
process.stdout.write(rawText);
|
|
@@ -183,163 +154,13 @@ async function templatesInspectAction(
|
|
|
183
154
|
|
|
184
155
|
const template = await loadTemplate(resolvedPath);
|
|
185
156
|
writeOutput(formatTemplateInspect(template, resolvedPath, sourceLabel));
|
|
186
|
-
if (options.copy) {
|
|
187
|
-
const contentDir = options.contentDir ?? Bun.env[MIRAVO_CONTENT_DIR_ENV];
|
|
188
|
-
const targetRootDir = contentDir ? resolve(contentDir) : process.cwd();
|
|
189
|
-
await copyTemplateBundle({
|
|
190
|
-
sourceTemplatePath: resolvedPath,
|
|
191
|
-
template,
|
|
192
|
-
contentRootDir: targetRootDir,
|
|
193
|
-
builtinModelsDir: catalog.getBuiltinModelsDir(),
|
|
194
|
-
});
|
|
195
|
-
}
|
|
196
157
|
} catch (error) {
|
|
197
158
|
handleCommandError(error);
|
|
198
159
|
}
|
|
199
160
|
}
|
|
200
161
|
|
|
201
|
-
interface CopyTemplateBundleOptions {
|
|
202
|
-
readonly sourceTemplatePath: string;
|
|
203
|
-
readonly template: MiravoTemplate;
|
|
204
|
-
readonly contentRootDir: string;
|
|
205
|
-
readonly builtinModelsDir: string;
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
interface CopyStats {
|
|
209
|
-
created: number;
|
|
210
|
-
skipped: number;
|
|
211
|
-
warnings: number;
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
async function copyTemplateBundle(options: CopyTemplateBundleOptions): Promise<void> {
|
|
215
|
-
const { sourceTemplatePath, template, contentRootDir, builtinModelsDir } = options;
|
|
216
|
-
const templatesDir = join(contentRootDir, "templates");
|
|
217
|
-
const modelsDir = join(contentRootDir, "models");
|
|
218
|
-
try {
|
|
219
|
-
await mkdir(templatesDir, { recursive: true });
|
|
220
|
-
await mkdir(modelsDir, { recursive: true });
|
|
221
|
-
} catch (error) {
|
|
222
|
-
throw toContentWriteError(error, contentRootDir);
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
const stats: CopyStats = { created: 0, skipped: 0, warnings: 0 };
|
|
226
|
-
|
|
227
|
-
const targetTemplatePath = resolve(templatesDir, basename(sourceTemplatePath));
|
|
228
|
-
await copyFileWithPolicy({
|
|
229
|
-
sourcePath: sourceTemplatePath,
|
|
230
|
-
targetPath: targetTemplatePath,
|
|
231
|
-
kind: "template",
|
|
232
|
-
stats,
|
|
233
|
-
contentRootDir,
|
|
234
|
-
});
|
|
235
|
-
|
|
236
|
-
const inlineModels = new Set((template.models ?? []).map((model) => model.name));
|
|
237
|
-
const referencedModels = collectReferencedModels(template);
|
|
238
|
-
|
|
239
|
-
for (const modelName of referencedModels) {
|
|
240
|
-
if (inlineModels.has(modelName)) {
|
|
241
|
-
continue;
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
let sourceModelPath: string;
|
|
245
|
-
try {
|
|
246
|
-
sourceModelPath = await resolveModelPath(modelName, [builtinModelsDir]);
|
|
247
|
-
} catch {
|
|
248
|
-
configLog.warn(`Skipped model "${modelName}": built-in model not found.`);
|
|
249
|
-
stats.warnings++;
|
|
250
|
-
continue;
|
|
251
|
-
}
|
|
252
|
-
if (!sourceModelPath.startsWith(builtinModelsDir)) {
|
|
253
|
-
configLog.warn(`Skipped model "${modelName}": only built-in referenced models are copied.`);
|
|
254
|
-
stats.warnings++;
|
|
255
|
-
continue;
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
const targetModelPath = resolve(modelsDir, basename(sourceModelPath));
|
|
259
|
-
await copyFileWithPolicy({
|
|
260
|
-
sourcePath: sourceModelPath,
|
|
261
|
-
targetPath: targetModelPath,
|
|
262
|
-
kind: `model "${modelName}"`,
|
|
263
|
-
stats,
|
|
264
|
-
contentRootDir,
|
|
265
|
-
});
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
writeOutput();
|
|
269
|
-
writeOutput(` ${colors.bold("Copy Summary:")}`);
|
|
270
|
-
writeOutput(` root: ${colors.cyan(contentRootDir)}`);
|
|
271
|
-
writeOutput(
|
|
272
|
-
` created: ${stats.created} · skipped: ${stats.skipped} · warnings: ${stats.warnings}`,
|
|
273
|
-
);
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
async function copyFileWithPolicy(options: {
|
|
277
|
-
readonly sourcePath: string;
|
|
278
|
-
readonly targetPath: string;
|
|
279
|
-
readonly kind: string;
|
|
280
|
-
readonly stats: CopyStats;
|
|
281
|
-
readonly contentRootDir: string;
|
|
282
|
-
}): Promise<void> {
|
|
283
|
-
const { sourcePath, targetPath, kind, stats, contentRootDir } = options;
|
|
284
|
-
if (resolve(sourcePath) === resolve(targetPath)) {
|
|
285
|
-
configLog.warn(`Skipped ${kind}: source and destination are the same (${targetPath}).`);
|
|
286
|
-
stats.skipped++;
|
|
287
|
-
stats.warnings++;
|
|
288
|
-
return;
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
const targetExists = await Bun.file(targetPath).exists();
|
|
292
|
-
if (targetExists) {
|
|
293
|
-
configLog.warn(`Skipped ${kind}: already exists in content directory (${targetPath}).`);
|
|
294
|
-
stats.skipped++;
|
|
295
|
-
return;
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
const contents = await Bun.file(sourcePath).text();
|
|
299
|
-
try {
|
|
300
|
-
await Bun.write(targetPath, contents);
|
|
301
|
-
} catch (error) {
|
|
302
|
-
throw toContentWriteError(error, contentRootDir);
|
|
303
|
-
}
|
|
304
|
-
stats.created++;
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
function toContentWriteError(error: unknown, contentRootDir: string): MiravoError {
|
|
308
|
-
if (isPermissionError(error)) {
|
|
309
|
-
return new MiravoError(
|
|
310
|
-
`Cannot write to content directory "${contentRootDir}".`,
|
|
311
|
-
`Ensure "${contentRootDir}" is writable by the current user/container (for example: chmod/chown the mount, or run compose with UID/GID mapping).`,
|
|
312
|
-
);
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
316
|
-
return new MiravoError(
|
|
317
|
-
`Failed to copy content into "${contentRootDir}": ${message}`,
|
|
318
|
-
"Ensure the destination path exists and is writable, then retry with --copy.",
|
|
319
|
-
);
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
function isPermissionError(error: unknown): boolean {
|
|
323
|
-
if (!error || typeof error !== "object") return false;
|
|
324
|
-
if (!("code" in error)) return false;
|
|
325
|
-
const code = (error as { code?: unknown }).code;
|
|
326
|
-
return code === "EACCES" || code === "EPERM";
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
function collectReferencedModels(template: MiravoTemplate): Set<string> {
|
|
330
|
-
const referencedModels = new Set<string>();
|
|
331
|
-
for (const area of template.areas) {
|
|
332
|
-
for (const line of area.lines) {
|
|
333
|
-
for (const twin of line.twins) {
|
|
334
|
-
referencedModels.add(twin.model);
|
|
335
|
-
}
|
|
336
|
-
}
|
|
337
|
-
}
|
|
338
|
-
return referencedModels;
|
|
339
|
-
}
|
|
340
|
-
|
|
341
162
|
export function formatTemplateInspect(
|
|
342
|
-
template: MiravoTemplate,
|
|
163
|
+
template: import("@miravo/shared").MiravoTemplate,
|
|
343
164
|
sourcePath: string,
|
|
344
165
|
sourceLabel: string,
|
|
345
166
|
): string {
|
package/src/index.ts
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
import { MIRAVO_VERSION } from "@miravo/shared";
|
|
3
3
|
import { Command, InvalidOptionArgumentError } from "commander";
|
|
4
|
+
import { registerForkCommand } from "./commands/fork.ts";
|
|
4
5
|
import { registerInjectCommand } from "./commands/inject.ts";
|
|
5
6
|
import { registerModelsCommand } from "./commands/models.ts";
|
|
6
7
|
import { registerStartCommand } from "./commands/start.ts";
|
|
7
8
|
import { registerStatusCommand } from "./commands/status.ts";
|
|
8
9
|
import { registerTemplatesCommand } from "./commands/templates.ts";
|
|
9
|
-
import { configureLogger, parseLogFormat } from "./logger.ts";
|
|
10
|
+
import { configureLogger, parseLogFormat, parseLogLevel } from "./logger.ts";
|
|
10
11
|
|
|
11
12
|
/**
|
|
12
13
|
* Create and configure the root Commander.js program.
|
|
@@ -17,11 +18,19 @@ export function createProgram(): Command {
|
|
|
17
18
|
|
|
18
19
|
program
|
|
19
20
|
.name("miravo")
|
|
20
|
-
.description("
|
|
21
|
+
.description("Industrial environment platform for realistic software-defined plants")
|
|
21
22
|
.version(MIRAVO_VERSION, "-V, --version", "Print version")
|
|
22
|
-
.option("-v, --verbose", "
|
|
23
|
+
.option("-v, --verbose", "Shorthand for --log-level debug")
|
|
23
24
|
.option("--silent", "Suppress all output except errors")
|
|
24
25
|
.option("--plain", "Use stable plain-text output (no ANSI styling)")
|
|
26
|
+
.option("--log-level <level>", "Log level: debug | info | warn | error", (value: string) => {
|
|
27
|
+
try {
|
|
28
|
+
return parseLogLevel(value);
|
|
29
|
+
} catch (error) {
|
|
30
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
31
|
+
throw new InvalidOptionArgumentError(message);
|
|
32
|
+
}
|
|
33
|
+
})
|
|
25
34
|
.option(
|
|
26
35
|
"--log-format <format>",
|
|
27
36
|
"Log format: pretty | json",
|
|
@@ -40,18 +49,21 @@ export function createProgram(): Command {
|
|
|
40
49
|
verbose?: boolean;
|
|
41
50
|
silent?: boolean;
|
|
42
51
|
plain?: boolean;
|
|
52
|
+
logLevel?: "debug" | "info" | "warn" | "error";
|
|
43
53
|
logFormat?: "pretty" | "json";
|
|
44
54
|
}>();
|
|
45
55
|
const cmdOpts = actionCommand.opts<{
|
|
46
56
|
verbose?: boolean;
|
|
47
57
|
silent?: boolean;
|
|
48
58
|
plain?: boolean;
|
|
59
|
+
logLevel?: "debug" | "info" | "warn" | "error";
|
|
49
60
|
logFormat?: "pretty" | "json";
|
|
50
61
|
}>();
|
|
51
62
|
configureLogger({
|
|
52
63
|
verbose: rootOpts.verbose ?? cmdOpts.verbose,
|
|
53
64
|
silent: rootOpts.silent ?? cmdOpts.silent,
|
|
54
65
|
plain: rootOpts.plain ?? cmdOpts.plain,
|
|
66
|
+
logLevel: rootOpts.logLevel ?? cmdOpts.logLevel,
|
|
55
67
|
logFormat: rootOpts.logFormat ?? cmdOpts.logFormat,
|
|
56
68
|
});
|
|
57
69
|
});
|
|
@@ -59,24 +71,46 @@ export function createProgram(): Command {
|
|
|
59
71
|
registerStartCommand(program);
|
|
60
72
|
registerModelsCommand(program);
|
|
61
73
|
registerTemplatesCommand(program);
|
|
74
|
+
registerForkCommand(program);
|
|
62
75
|
registerInjectCommand(program);
|
|
63
76
|
registerStatusCommand(program);
|
|
64
|
-
// TODO(cli): Add `miravo completion <shell>` to generate shell completion scripts.
|
|
65
77
|
|
|
66
78
|
return program;
|
|
67
79
|
}
|
|
68
80
|
|
|
69
81
|
// Only run when executed directly, not when imported in tests
|
|
70
82
|
if (import.meta.path === Bun.main) {
|
|
71
|
-
//
|
|
83
|
+
// In compiled binaries, extract embedded content to a temp directory so
|
|
84
|
+
// ContentCatalog's filesystem-based discovery can find built-in models/templates.
|
|
72
85
|
// The manifest only exists during `bun run build:standalone`; the catch is a no-op in dev.
|
|
73
86
|
try {
|
|
74
87
|
// @ts-expect-error — file is generated at build time and does not exist in source tree
|
|
75
|
-
await import("./_embedded-manifest.ts");
|
|
88
|
+
const manifest = await import("./_embedded-manifest.ts");
|
|
89
|
+
const { modelPaths, templatePaths } = manifest as {
|
|
90
|
+
modelPaths: string[];
|
|
91
|
+
templatePaths: string[];
|
|
92
|
+
};
|
|
93
|
+
const { basename, join } = await import("node:path");
|
|
94
|
+
const { tmpdir } = await import("node:os");
|
|
95
|
+
const { mkdirSync } = await import("node:fs");
|
|
96
|
+
const { registerEmbeddedContentDir } = await import("@miravo/core");
|
|
97
|
+
|
|
98
|
+
const contentDir = join(tmpdir(), "miravo-embedded");
|
|
99
|
+
const modelsDir = join(contentDir, "models");
|
|
100
|
+
const templatesDir = join(contentDir, "templates");
|
|
101
|
+
mkdirSync(modelsDir, { recursive: true });
|
|
102
|
+
mkdirSync(templatesDir, { recursive: true });
|
|
103
|
+
|
|
104
|
+
for (const p of modelPaths) {
|
|
105
|
+
await Bun.write(join(modelsDir, basename(p)), Bun.file(p));
|
|
106
|
+
}
|
|
107
|
+
for (const p of templatePaths) {
|
|
108
|
+
await Bun.write(join(templatesDir, basename(p)), Bun.file(p));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
registerEmbeddedContentDir(contentDir);
|
|
76
112
|
} catch {
|
|
77
113
|
// Not a compiled binary or manifest not present — expected in dev mode
|
|
78
114
|
}
|
|
79
|
-
const { prepareEmbeddedContent } = await import("@miravo/core");
|
|
80
|
-
await prepareEmbeddedContent();
|
|
81
115
|
createProgram().parse();
|
|
82
116
|
}
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
CommandResult,
|
|
3
|
+
EngineCommand,
|
|
4
|
+
EngineMetricsSnapshot,
|
|
5
|
+
EngineStateSnapshot,
|
|
6
|
+
} from "@miravo/shared";
|
|
7
|
+
import {
|
|
8
|
+
CommandResultSchema,
|
|
9
|
+
EngineMetricsSnapshotSchema,
|
|
10
|
+
EngineStateSnapshotSchema,
|
|
11
|
+
MiravoError,
|
|
12
|
+
} from "@miravo/shared";
|
|
13
|
+
|
|
14
|
+
const DEFAULT_ADMIN_HOST = "127.0.0.1";
|
|
15
|
+
const DEFAULT_ADMIN_PORT = 8080;
|
|
16
|
+
|
|
17
|
+
interface SafeParseResult<T> {
|
|
18
|
+
readonly success: boolean;
|
|
19
|
+
readonly data?: T;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface SafeParseSchema<T> {
|
|
23
|
+
safeParse(value: unknown): SafeParseResult<T>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface RequestJsonOptions<T> {
|
|
27
|
+
readonly baseUrl: string;
|
|
28
|
+
readonly path: string;
|
|
29
|
+
readonly timeoutMs: number;
|
|
30
|
+
readonly schema: SafeParseSchema<T>;
|
|
31
|
+
readonly method?: "GET" | "POST";
|
|
32
|
+
readonly body?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function normalizeAdminHost(host: string): string {
|
|
36
|
+
return host === "0.0.0.0" || host === "::" ? DEFAULT_ADMIN_HOST : host;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function resolveAdminUrl(adminUrl?: string): string {
|
|
40
|
+
const explicitUrl = adminUrl ?? Bun.env["MIRAVO_ADMIN_URL"];
|
|
41
|
+
if (explicitUrl && explicitUrl.trim().length > 0) {
|
|
42
|
+
return parseAdminUrl(explicitUrl);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const hostValue = Bun.env["MIRAVO_ADMIN_HOST"] ?? DEFAULT_ADMIN_HOST;
|
|
46
|
+
if (hostValue.trim().length === 0) {
|
|
47
|
+
throw new MiravoError(
|
|
48
|
+
"Invalid MIRAVO_ADMIN_HOST environment variable: value is empty.",
|
|
49
|
+
"Set MIRAVO_ADMIN_HOST to a valid host such as 127.0.0.1 or 0.0.0.0.",
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
const host = normalizeAdminHost(hostValue);
|
|
53
|
+
const portValue = Bun.env["MIRAVO_ADMIN_PORT"];
|
|
54
|
+
if (!portValue) {
|
|
55
|
+
return `http://${host}:${DEFAULT_ADMIN_PORT}`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const parsedPort = Number(portValue);
|
|
59
|
+
if (!Number.isInteger(parsedPort) || parsedPort < 1 || parsedPort > 65535) {
|
|
60
|
+
throw new MiravoError(
|
|
61
|
+
`Invalid MIRAVO_ADMIN_PORT environment variable: "${portValue}".`,
|
|
62
|
+
"Set MIRAVO_ADMIN_PORT to a valid port number between 1 and 65535.",
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return `http://${host}:${parsedPort}`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function parseAdminUrl(input: string): string {
|
|
70
|
+
let url: URL;
|
|
71
|
+
try {
|
|
72
|
+
url = new URL(input);
|
|
73
|
+
} catch {
|
|
74
|
+
throw new MiravoError(
|
|
75
|
+
`Invalid admin URL: "${input}".`,
|
|
76
|
+
"Pass --admin-url with a full HTTP URL such as http://127.0.0.1:8080.",
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
81
|
+
throw new MiravoError(
|
|
82
|
+
`Invalid admin URL protocol: "${url.protocol}".`,
|
|
83
|
+
"Pass an HTTP or HTTPS URL such as http://127.0.0.1:8080.",
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return url.toString().replace(/\/$/, "");
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function requestJson<T>(options: RequestJsonOptions<T>): Promise<T> {
|
|
91
|
+
const requestUrl = new URL(options.path, `${options.baseUrl}/`).toString();
|
|
92
|
+
const controller = new AbortController();
|
|
93
|
+
const timer = setTimeout(() => controller.abort(), options.timeoutMs);
|
|
94
|
+
|
|
95
|
+
try {
|
|
96
|
+
let response: Response;
|
|
97
|
+
try {
|
|
98
|
+
response = await fetch(requestUrl, {
|
|
99
|
+
method: options.method ?? "GET",
|
|
100
|
+
headers: options.body ? { "content-type": "application/json" } : undefined,
|
|
101
|
+
body: options.body,
|
|
102
|
+
signal: controller.signal,
|
|
103
|
+
});
|
|
104
|
+
} catch (error) {
|
|
105
|
+
if (error instanceof DOMException && error.name === "AbortError") {
|
|
106
|
+
throw new MiravoError(
|
|
107
|
+
`Admin request to ${requestUrl} timed out after ${options.timeoutMs}ms.`,
|
|
108
|
+
"Check that Miravo is running and the admin URL is reachable.",
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
113
|
+
throw new MiravoError(
|
|
114
|
+
`Admin request to ${requestUrl} failed: ${message}`,
|
|
115
|
+
"Check that Miravo is running and the admin URL is correct.",
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const text = await response.text();
|
|
120
|
+
let parsedJson: unknown;
|
|
121
|
+
if (text.length > 0) {
|
|
122
|
+
try {
|
|
123
|
+
parsedJson = JSON.parse(text);
|
|
124
|
+
} catch {
|
|
125
|
+
throw new MiravoError(
|
|
126
|
+
`Admin server returned invalid JSON from ${requestUrl}.`,
|
|
127
|
+
"Check the Miravo admin server logs and retry.",
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (!response.ok) {
|
|
133
|
+
const details =
|
|
134
|
+
typeof parsedJson === "object" &&
|
|
135
|
+
parsedJson !== null &&
|
|
136
|
+
"message" in parsedJson &&
|
|
137
|
+
typeof (parsedJson as Record<string, unknown>)["message"] === "string"
|
|
138
|
+
? String((parsedJson as Record<string, unknown>)["message"])
|
|
139
|
+
: `HTTP ${response.status}`;
|
|
140
|
+
const fix =
|
|
141
|
+
typeof parsedJson === "object" &&
|
|
142
|
+
parsedJson !== null &&
|
|
143
|
+
"error" in parsedJson &&
|
|
144
|
+
typeof (parsedJson as Record<string, unknown>)["error"] === "string"
|
|
145
|
+
? String((parsedJson as Record<string, unknown>)["error"])
|
|
146
|
+
: "Check that Miravo is running and the admin request is valid.";
|
|
147
|
+
throw new MiravoError(`Admin request to ${requestUrl} failed: ${details}`, fix);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const validation = options.schema.safeParse(parsedJson);
|
|
151
|
+
if (!validation.success || validation.data === undefined) {
|
|
152
|
+
throw new MiravoError(
|
|
153
|
+
`Admin server returned an unexpected payload from ${requestUrl}.`,
|
|
154
|
+
"Check that the Miravo CLI and server are running the same version.",
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return validation.data;
|
|
159
|
+
} finally {
|
|
160
|
+
clearTimeout(timer);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async function getAdminMetrics(
|
|
165
|
+
adminUrl: string,
|
|
166
|
+
timeoutMs: number,
|
|
167
|
+
): Promise<EngineMetricsSnapshot> {
|
|
168
|
+
return requestJson({
|
|
169
|
+
baseUrl: adminUrl,
|
|
170
|
+
path: "/metrics",
|
|
171
|
+
timeoutMs,
|
|
172
|
+
schema: EngineMetricsSnapshotSchema,
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async function getAdminState(adminUrl: string, timeoutMs: number): Promise<EngineStateSnapshot> {
|
|
177
|
+
return requestJson({
|
|
178
|
+
baseUrl: adminUrl,
|
|
179
|
+
path: "/state",
|
|
180
|
+
timeoutMs,
|
|
181
|
+
schema: EngineStateSnapshotSchema,
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async function getAdminSnapshot(
|
|
186
|
+
adminUrl: string,
|
|
187
|
+
timeoutMs: number,
|
|
188
|
+
): Promise<{
|
|
189
|
+
readonly metrics: EngineMetricsSnapshot;
|
|
190
|
+
readonly state: EngineStateSnapshot;
|
|
191
|
+
}> {
|
|
192
|
+
const [metrics, state] = await Promise.all([
|
|
193
|
+
getAdminMetrics(adminUrl, timeoutMs),
|
|
194
|
+
getAdminState(adminUrl, timeoutMs),
|
|
195
|
+
]);
|
|
196
|
+
return { metrics, state };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
async function executeAdminCommand(
|
|
200
|
+
adminUrl: string,
|
|
201
|
+
command: EngineCommand,
|
|
202
|
+
timeoutMs: number,
|
|
203
|
+
): Promise<CommandResult> {
|
|
204
|
+
return requestJson({
|
|
205
|
+
baseUrl: adminUrl,
|
|
206
|
+
path: "/commands",
|
|
207
|
+
timeoutMs,
|
|
208
|
+
schema: CommandResultSchema,
|
|
209
|
+
method: "POST",
|
|
210
|
+
body: JSON.stringify(command),
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export { executeAdminCommand, getAdminMetrics, getAdminSnapshot, getAdminState, resolveAdminUrl };
|