miravo 0.2.3 → 0.4.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 +14 -150
- package/console-dist/assets/fira-code-cyrillic-ext-wght-normal-DhYMMuQd.woff2 +0 -0
- package/console-dist/assets/fira-code-cyrillic-wght-normal-Y3u8pIsh.woff2 +0 -0
- package/console-dist/assets/fira-code-greek-ext-wght-normal-wWus70Ix.woff2 +0 -0
- package/console-dist/assets/fira-code-greek-wght-normal-B2SviObF.woff2 +0 -0
- package/console-dist/assets/fira-code-latin-ext-wght-normal-Dvfvaomy.woff2 +0 -0
- package/console-dist/assets/fira-code-latin-wght-normal-CHoedHDv.woff2 +0 -0
- package/console-dist/assets/fira-code-symbols2-wght-normal-CE6EOz_n.woff2 +0 -0
- package/console-dist/assets/index-C3XMYq26.css +2 -0
- package/console-dist/assets/index-hUJSpKtB.js +119 -0
- package/console-dist/assets/karla-latin-ext-wght-normal-Dq0rrjgs.woff2 +0 -0
- package/console-dist/assets/karla-latin-wght-normal-C3-ma4ov.woff2 +0 -0
- package/console-dist/assets/schibsted-grotesk-latin-ext-wght-normal-hsMS0n0O.woff2 +0 -0
- package/console-dist/assets/schibsted-grotesk-latin-wght-normal-Bb8VGrTG.woff2 +0 -0
- package/console-dist/favicon.svg +37 -0
- package/console-dist/index.html +14 -0
- package/console-dist/miravo-logo.png +0 -0
- package/package.json +12 -9
- package/src/__tests__/integration/executable-methods-runner.ts +53 -45
- package/src/__tests__/integration/executable-methods.integration.ts +648 -80
- package/src/commands/adapter.ts +76 -0
- package/src/commands/cli-test-setup.ts +1 -1
- package/src/commands/fork.ts +14 -0
- package/src/commands/inject.test.ts +0 -24
- package/src/commands/inject.ts +16 -8
- package/src/commands/load.ts +167 -0
- package/src/commands/logs.ts +175 -0
- package/src/commands/models.test.ts +23 -2
- package/src/commands/models.ts +20 -0
- package/src/commands/pause.ts +67 -0
- package/src/commands/ps.ts +116 -0
- package/src/commands/remove.ts +38 -0
- package/src/commands/reset.ts +44 -0
- package/src/commands/rm.ts +97 -0
- package/src/commands/spawn.ts +45 -0
- package/src/commands/speed.ts +40 -0
- package/src/commands/start.test.ts +93 -0
- package/src/commands/start.ts +1043 -193
- package/src/commands/status.test.ts +0 -24
- package/src/commands/status.ts +12 -6
- package/src/commands/stop.test.ts +84 -0
- package/src/commands/stop.ts +158 -0
- package/src/commands/templates.test.ts +1 -14
- package/src/commands/templates.ts +13 -0
- package/src/index.ts +49 -7
- package/src/lib/admin-client.test.ts +35 -0
- package/src/lib/admin-client.ts +13 -1
- package/src/lib/admin-server.test.ts +339 -53
- package/src/lib/admin-server.ts +413 -62
- package/src/lib/daemon-registry.test.ts +429 -0
- package/src/lib/daemon-registry.ts +345 -0
- package/src/lib/mqtt-client.ts +1 -1
- package/src/lib/parse.ts +18 -0
- package/src/lib/resolve-simulation.ts +1 -1
- package/src/lib/shutdown.test.ts +16 -4
- package/src/lib/shutdown.ts +3 -3
- package/src/lib/template-category.test.ts +1 -0
- package/src/lib/template-utils.ts +33 -2
- package/src/lib/ws-hub.ts +255 -0
- package/src/logger.test.ts +19 -19
- package/src/logger.ts +34 -6
|
@@ -5,30 +5,6 @@ beforeEach(resetExitCode);
|
|
|
5
5
|
afterEach(resetExitCode);
|
|
6
6
|
|
|
7
7
|
describe("miravo status", () => {
|
|
8
|
-
test("command is registered with correct name and options", async () => {
|
|
9
|
-
const { cmd: statusCmd } = await findCliCommand("status");
|
|
10
|
-
expect(statusCmd).toBeDefined();
|
|
11
|
-
expect(statusCmd?.description()).toBe("Show status of a running simulation");
|
|
12
|
-
|
|
13
|
-
const optionNames = statusCmd?.options.map((o) => o.long);
|
|
14
|
-
expect(optionNames).toContain("--name");
|
|
15
|
-
expect(optionNames).toContain("--discover");
|
|
16
|
-
expect(optionNames).toContain("--watch");
|
|
17
|
-
expect(optionNames).toContain("--instance");
|
|
18
|
-
expect(optionNames).toContain("--json");
|
|
19
|
-
expect(optionNames).toContain("--admin-url");
|
|
20
|
-
expect(optionNames).toContain("--template");
|
|
21
|
-
});
|
|
22
|
-
|
|
23
|
-
test("--watch has short alias -w", async () => {
|
|
24
|
-
const { cmd: statusCmd } = await findCliCommand("status");
|
|
25
|
-
expect(statusCmd).toBeDefined();
|
|
26
|
-
|
|
27
|
-
const watchOpt = statusCmd?.options.find((o) => o.long === "--watch");
|
|
28
|
-
expect(watchOpt).toBeDefined();
|
|
29
|
-
expect(watchOpt?.short).toBe("-w");
|
|
30
|
-
});
|
|
31
|
-
|
|
32
8
|
test("sets exit code 1 with invalid admin URL", async () => {
|
|
33
9
|
const { program } = await findCliCommand("status");
|
|
34
10
|
program.exitOverride();
|
package/src/commands/status.ts
CHANGED
|
@@ -8,7 +8,7 @@ import type { Command } from "commander";
|
|
|
8
8
|
import { colors } from "consola/utils";
|
|
9
9
|
import { getAdminSnapshot, resolveAdminUrl } from "../lib/admin-client.ts";
|
|
10
10
|
import { connectToBroker, disconnectClient } from "../lib/mqtt-client.ts";
|
|
11
|
-
import {
|
|
11
|
+
import { parseTimeoutSeconds } from "../lib/parse.ts";
|
|
12
12
|
import { discoverRunningSimulations } from "../lib/resolve-simulation.ts";
|
|
13
13
|
import { engineLog, handleCommandError, isPlain, writeOutput } from "../logger.ts";
|
|
14
14
|
import {
|
|
@@ -39,7 +39,7 @@ interface StatusOptions {
|
|
|
39
39
|
export function registerStatusCommand(program: Command): void {
|
|
40
40
|
program
|
|
41
41
|
.command("status")
|
|
42
|
-
.description("Show status of a running simulation")
|
|
42
|
+
.description("Show live status, metrics, and instances of a running simulation")
|
|
43
43
|
.option("--name <name>", "Target simulation name")
|
|
44
44
|
.option("--discover", "Discover running simulations and exit")
|
|
45
45
|
.option("-w, --watch", "Poll the admin server and re-render on updates")
|
|
@@ -50,10 +50,16 @@ export function registerStatusCommand(program: Command): void {
|
|
|
50
50
|
"--template <path>",
|
|
51
51
|
"Path to .miravo.yaml for MQTT discovery connection info (env: MIRAVO_TEMPLATE)",
|
|
52
52
|
)
|
|
53
|
-
.option(
|
|
54
|
-
|
|
55
|
-
"
|
|
56
|
-
|
|
53
|
+
.option("--timeout <seconds>", "Timeout in seconds (default: 5)", parseTimeoutSeconds)
|
|
54
|
+
.addHelpText(
|
|
55
|
+
"after",
|
|
56
|
+
`
|
|
57
|
+
Examples:
|
|
58
|
+
$ miravo status Show simulation overview
|
|
59
|
+
$ miravo status -w Watch mode (live updates)
|
|
60
|
+
$ miravo status --instance pump-001 Detail view for one instance
|
|
61
|
+
$ miravo status --json Machine-readable JSON output
|
|
62
|
+
$ miravo status --discover Find running simulations via MQTT`,
|
|
57
63
|
)
|
|
58
64
|
.action(async (options: StatusOptions) => {
|
|
59
65
|
await statusAction(options);
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import type { DaemonRegistryEntry } from "../lib/daemon-registry.ts";
|
|
6
|
+
import { readEntry, writeEntry } from "../lib/daemon-registry.ts";
|
|
7
|
+
import { resetExitCode, runCliCommand } from "./cli-test-setup.ts";
|
|
8
|
+
|
|
9
|
+
let testHome: string;
|
|
10
|
+
const originalFetch = globalThis.fetch;
|
|
11
|
+
const originalKill = process.kill;
|
|
12
|
+
const forwardKill = originalKill as (pid: number, signal?: number | NodeJS.Signals) => void;
|
|
13
|
+
|
|
14
|
+
function makeEntry(overrides: Partial<DaemonRegistryEntry> = {}): DaemonRegistryEntry {
|
|
15
|
+
return {
|
|
16
|
+
name: "test-sim",
|
|
17
|
+
pid: 42_424,
|
|
18
|
+
adminPort: 8080,
|
|
19
|
+
adminHost: "127.0.0.1",
|
|
20
|
+
adapters: {},
|
|
21
|
+
templatePath: "/tmp/test.miravo.yaml",
|
|
22
|
+
startedAt: "2026-03-18T10:00:00Z",
|
|
23
|
+
seed: 42,
|
|
24
|
+
runId: "run-123",
|
|
25
|
+
...overrides,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
beforeEach(resetExitCode);
|
|
30
|
+
|
|
31
|
+
afterEach(async () => {
|
|
32
|
+
globalThis.fetch = originalFetch;
|
|
33
|
+
process.kill = originalKill;
|
|
34
|
+
delete Bun.env["MIRAVO_HOME"];
|
|
35
|
+
resetExitCode();
|
|
36
|
+
if (testHome) {
|
|
37
|
+
await rm(testHome, { recursive: true, force: true });
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe("miravo stop", () => {
|
|
42
|
+
test("force-kills a verified daemon after /shutdown makes /readyz disappear", async () => {
|
|
43
|
+
testHome = await mkdtemp(join(tmpdir(), "miravo-stop-test-"));
|
|
44
|
+
Bun.env["MIRAVO_HOME"] = testHome;
|
|
45
|
+
|
|
46
|
+
let alive = true;
|
|
47
|
+
process.kill = ((pid: number, signal?: number | NodeJS.Signals) => {
|
|
48
|
+
if (pid !== 42_424) {
|
|
49
|
+
return forwardKill(pid, signal);
|
|
50
|
+
}
|
|
51
|
+
if (signal === 0 || signal === undefined) {
|
|
52
|
+
if (!alive) {
|
|
53
|
+
const error = new Error("ESRCH") as NodeJS.ErrnoException;
|
|
54
|
+
error.code = "ESRCH";
|
|
55
|
+
throw error;
|
|
56
|
+
}
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
if (signal === "SIGKILL") {
|
|
60
|
+
alive = false;
|
|
61
|
+
}
|
|
62
|
+
}) as typeof process.kill;
|
|
63
|
+
|
|
64
|
+
globalThis.fetch = (async (input, init) => {
|
|
65
|
+
const url = String(input);
|
|
66
|
+
if (url.endsWith("/readyz")) {
|
|
67
|
+
return Response.json({ status: "paused", runId: "run-123" }, { status: 503 });
|
|
68
|
+
}
|
|
69
|
+
if (url.endsWith("/shutdown")) {
|
|
70
|
+
expect(init?.method).toBe("POST");
|
|
71
|
+
return Response.json({ status: "shutting_down" }, { status: 200 });
|
|
72
|
+
}
|
|
73
|
+
throw new Error(`Unexpected fetch: ${url}`);
|
|
74
|
+
}) as typeof fetch;
|
|
75
|
+
|
|
76
|
+
await writeEntry(makeEntry());
|
|
77
|
+
|
|
78
|
+
await runCliCommand(["stop", "test-sim", "--timeout", "0.001"]);
|
|
79
|
+
|
|
80
|
+
expect(alive).toBe(false);
|
|
81
|
+
expect(await readEntry("test-sim")).toBeNull();
|
|
82
|
+
expect(process.exitCode).toBe(0);
|
|
83
|
+
});
|
|
84
|
+
});
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import type { Command } from "commander";
|
|
2
|
+
import { isPidAlive, probeEntryIdentity, readEntry, removeEntry } from "../lib/daemon-registry.ts";
|
|
3
|
+
import { parseTimeoutSeconds } from "../lib/parse.ts";
|
|
4
|
+
import { handleCommandError, writeOutput } from "../logger.ts";
|
|
5
|
+
|
|
6
|
+
interface StopOptions {
|
|
7
|
+
readonly json?: boolean;
|
|
8
|
+
readonly timeout?: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function registerStopCommand(program: Command): void {
|
|
12
|
+
program
|
|
13
|
+
.command("stop <name>")
|
|
14
|
+
.description("Stop a running background simulation")
|
|
15
|
+
.option("--json", "Machine-readable JSON output")
|
|
16
|
+
.option("--timeout <seconds>", "Shutdown timeout in seconds (default: 10)", parseTimeoutSeconds)
|
|
17
|
+
.addHelpText(
|
|
18
|
+
"after",
|
|
19
|
+
`
|
|
20
|
+
Examples:
|
|
21
|
+
$ miravo stop my-sim Graceful shutdown
|
|
22
|
+
$ miravo stop my-sim --timeout 30 Wait up to 30s before force kill`,
|
|
23
|
+
)
|
|
24
|
+
.action(async (name: string, options: StopOptions) => {
|
|
25
|
+
try {
|
|
26
|
+
await stopAction(name, options);
|
|
27
|
+
} catch (error) {
|
|
28
|
+
handleCommandError(error);
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function jsonOut(obj: Record<string, unknown>): void {
|
|
34
|
+
console.log(JSON.stringify(obj, null, 2));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function stopAction(name: string, options: StopOptions): Promise<void> {
|
|
38
|
+
const entry = await readEntry(name);
|
|
39
|
+
if (!entry) {
|
|
40
|
+
const msg = `Simulation "${name}" not found. Use 'miravo ps' to list running simulations.`;
|
|
41
|
+
if (options.json) {
|
|
42
|
+
jsonOut({ success: false, name, message: msg });
|
|
43
|
+
} else {
|
|
44
|
+
writeOutput(msg);
|
|
45
|
+
}
|
|
46
|
+
process.exitCode = 1;
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Verify identity before taking any action — guards against PID reuse
|
|
51
|
+
const identity = await probeEntryIdentity(entry);
|
|
52
|
+
if (identity.status === "dead" || identity.status === "mismatch") {
|
|
53
|
+
await removeEntry(name);
|
|
54
|
+
const msg = `Simulation "${name}" was not running (stale registry entry removed).`;
|
|
55
|
+
if (options.json) {
|
|
56
|
+
jsonOut({ success: true, name, message: msg });
|
|
57
|
+
} else {
|
|
58
|
+
writeOutput(msg);
|
|
59
|
+
}
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
if (identity.status === "unreachable") {
|
|
63
|
+
const msg = `Simulation "${name}" could not be safely verified because its admin server is unreachable while PID ${entry.pid} is still alive.`;
|
|
64
|
+
if (options.json) {
|
|
65
|
+
jsonOut({
|
|
66
|
+
success: false,
|
|
67
|
+
name,
|
|
68
|
+
message: msg,
|
|
69
|
+
error: "Retry later, or inspect the process manually before removing the registry entry.",
|
|
70
|
+
});
|
|
71
|
+
} else {
|
|
72
|
+
writeOutput(
|
|
73
|
+
`${msg} Retry later, or inspect the process manually before removing the registry entry.`,
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
process.exitCode = 1;
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Try graceful shutdown via admin server (identity-safe — admin server belongs to this process)
|
|
81
|
+
let shutdownSent = false;
|
|
82
|
+
let canForceKill = true;
|
|
83
|
+
try {
|
|
84
|
+
const adminUrl = `http://${entry.adminHost}:${entry.adminPort}/shutdown`;
|
|
85
|
+
const headers: Record<string, string> = { "content-type": "application/json" };
|
|
86
|
+
const adminToken = Bun.env["MIRAVO_ADMIN_TOKEN"];
|
|
87
|
+
if (adminToken) {
|
|
88
|
+
headers["authorization"] = `Bearer ${adminToken}`;
|
|
89
|
+
}
|
|
90
|
+
const response = await fetch(adminUrl, {
|
|
91
|
+
method: "POST",
|
|
92
|
+
headers,
|
|
93
|
+
body: JSON.stringify({ reason: "miravo stop" }),
|
|
94
|
+
signal: AbortSignal.timeout(2_000),
|
|
95
|
+
});
|
|
96
|
+
shutdownSent = response.ok;
|
|
97
|
+
} catch {
|
|
98
|
+
// Admin server unreachable — fall back to SIGTERM only if we can re-verify identity
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (!shutdownSent) {
|
|
102
|
+
try {
|
|
103
|
+
process.kill(entry.pid, "SIGTERM");
|
|
104
|
+
} catch {
|
|
105
|
+
// Process may have already exited
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Poll for process exit
|
|
110
|
+
const timeoutMs = options.timeout ?? 10_000;
|
|
111
|
+
const deadline = Date.now() + timeoutMs;
|
|
112
|
+
|
|
113
|
+
while (isPidAlive(entry.pid) && Date.now() < deadline) {
|
|
114
|
+
await Bun.sleep(500);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// After /shutdown succeeds the admin server may exit before engine.stop() finishes,
|
|
118
|
+
// so keep the verified identity from the successful request for the SIGKILL fallback.
|
|
119
|
+
if (shutdownSent) {
|
|
120
|
+
canForceKill = true;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Force kill only if still alive and we already verified identity earlier.
|
|
124
|
+
if (isPidAlive(entry.pid) && canForceKill) {
|
|
125
|
+
try {
|
|
126
|
+
process.kill(entry.pid, "SIGKILL");
|
|
127
|
+
} catch {
|
|
128
|
+
// Ignore — process may have just exited
|
|
129
|
+
}
|
|
130
|
+
await Bun.sleep(200);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (isPidAlive(entry.pid)) {
|
|
134
|
+
const msg = `Simulation "${name}" did not exit within the timeout.`;
|
|
135
|
+
if (options.json) {
|
|
136
|
+
jsonOut({
|
|
137
|
+
success: false,
|
|
138
|
+
name,
|
|
139
|
+
message: msg,
|
|
140
|
+
error: "Inspect the process manually and retry with a longer --timeout if needed.",
|
|
141
|
+
});
|
|
142
|
+
} else {
|
|
143
|
+
writeOutput(
|
|
144
|
+
`${msg} Inspect the process manually and retry with a longer --timeout if needed.`,
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
process.exitCode = 1;
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
await removeEntry(name);
|
|
152
|
+
const msg = `Simulation "${name}" stopped.`;
|
|
153
|
+
if (options.json) {
|
|
154
|
+
jsonOut({ success: true, name, message: msg });
|
|
155
|
+
} else {
|
|
156
|
+
writeOutput(msg);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
@@ -7,7 +7,7 @@ import { configureLogger } from "../logger.ts";
|
|
|
7
7
|
import { runCliCommand } from "./cli-test-setup.ts";
|
|
8
8
|
import { formatTemplateInspect } from "./templates.ts";
|
|
9
9
|
|
|
10
|
-
configureLogger({
|
|
10
|
+
configureLogger({ quiet: true });
|
|
11
11
|
|
|
12
12
|
const templatesDir = resolve(import.meta.dir, "..", "..", "..", "content", "templates");
|
|
13
13
|
const { cleanupTmpDir } = createTmpFileUtils(resolve(import.meta.dir, ".tmp-test-templates"));
|
|
@@ -41,19 +41,6 @@ describe("formatTemplateInspect", () => {
|
|
|
41
41
|
});
|
|
42
42
|
|
|
43
43
|
describe("miravo templates", () => {
|
|
44
|
-
test("command is registered with list and inspect subcommands", async () => {
|
|
45
|
-
const { createProgram } = await import("../index.ts");
|
|
46
|
-
const program = createProgram();
|
|
47
|
-
|
|
48
|
-
const templatesCmd = program.commands.find((c) => c.name() === "templates");
|
|
49
|
-
expect(templatesCmd).toBeDefined();
|
|
50
|
-
expect(templatesCmd?.description()).toBe("List and inspect Miravo templates");
|
|
51
|
-
|
|
52
|
-
const subcommandNames = templatesCmd?.commands.map((c) => c.name()) ?? [];
|
|
53
|
-
expect(subcommandNames).toContain("list");
|
|
54
|
-
expect(subcommandNames).toContain("inspect");
|
|
55
|
-
});
|
|
56
|
-
|
|
57
44
|
test("templates inspect nonexistent template sets exit code 1", async () => {
|
|
58
45
|
await runCliCommand(["templates", "inspect", "nonexistent-template"]);
|
|
59
46
|
expect(process.exitCode).toBe(1);
|
|
@@ -20,6 +20,12 @@ export function registerTemplatesCommand(program: Command): void {
|
|
|
20
20
|
.command("list")
|
|
21
21
|
.description("Discover and list available templates")
|
|
22
22
|
.option("--dir <path>", "Additional directory to search for templates")
|
|
23
|
+
.addHelpText(
|
|
24
|
+
"after",
|
|
25
|
+
`
|
|
26
|
+
Examples:
|
|
27
|
+
$ miravo templates list List all discoverable templates`,
|
|
28
|
+
)
|
|
23
29
|
.action(async (options: { readonly dir?: string }) => {
|
|
24
30
|
await templatesListAction(options);
|
|
25
31
|
});
|
|
@@ -28,6 +34,13 @@ export function registerTemplatesCommand(program: Command): void {
|
|
|
28
34
|
.command("inspect <idNameOrPath>")
|
|
29
35
|
.description("Show detailed information about a template by id, name, or path")
|
|
30
36
|
.option("--raw", "Dump the raw YAML file to stdout")
|
|
37
|
+
.addHelpText(
|
|
38
|
+
"after",
|
|
39
|
+
`
|
|
40
|
+
Examples:
|
|
41
|
+
$ miravo templates inspect water-treatment Show template details
|
|
42
|
+
$ miravo templates inspect water-treatment --raw Dump raw YAML`,
|
|
43
|
+
)
|
|
31
44
|
.action(
|
|
32
45
|
async (
|
|
33
46
|
idNameOrPath: string,
|
package/src/index.ts
CHANGED
|
@@ -1,13 +1,24 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
import { MIRAVO_VERSION } from "@miravo/shared";
|
|
3
3
|
import { Command, InvalidOptionArgumentError } from "commander";
|
|
4
|
+
import { registerAdapterCommand } from "./commands/adapter.ts";
|
|
4
5
|
import { registerForkCommand } from "./commands/fork.ts";
|
|
5
6
|
import { registerInjectCommand } from "./commands/inject.ts";
|
|
7
|
+
import { registerLoadCommand } from "./commands/load.ts";
|
|
8
|
+
import { registerLogsCommand } from "./commands/logs.ts";
|
|
6
9
|
import { registerModelsCommand } from "./commands/models.ts";
|
|
10
|
+
import { registerPauseCommand, registerResumeCommand } from "./commands/pause.ts";
|
|
11
|
+
import { registerPsCommand } from "./commands/ps.ts";
|
|
12
|
+
import { registerRemoveCommand } from "./commands/remove.ts";
|
|
13
|
+
import { registerResetCommand } from "./commands/reset.ts";
|
|
14
|
+
import { registerRmCommand } from "./commands/rm.ts";
|
|
15
|
+
import { registerSpawnCommand } from "./commands/spawn.ts";
|
|
16
|
+
import { registerSpeedCommand } from "./commands/speed.ts";
|
|
7
17
|
import { registerStartCommand } from "./commands/start.ts";
|
|
8
18
|
import { registerStatusCommand } from "./commands/status.ts";
|
|
19
|
+
import { registerStopCommand } from "./commands/stop.ts";
|
|
9
20
|
import { registerTemplatesCommand } from "./commands/templates.ts";
|
|
10
|
-
import { configureLogger, parseLogFormat, parseLogLevel } from "./logger.ts";
|
|
21
|
+
import { configureLogger, parseLogFormat, parseLogLevel, writeOutput } from "./logger.ts";
|
|
11
22
|
|
|
12
23
|
/**
|
|
13
24
|
* Create and configure the root Commander.js program.
|
|
@@ -20,8 +31,10 @@ export function createProgram(): Command {
|
|
|
20
31
|
.name("miravo")
|
|
21
32
|
.description("Industrial environment platform for realistic software-defined plants")
|
|
22
33
|
.version(MIRAVO_VERSION, "-V, --version", "Print version")
|
|
34
|
+
.helpOption("-h, --help", "Show help for command")
|
|
35
|
+
.addHelpCommand("help [command]", "Show help for command")
|
|
23
36
|
.option("-v, --verbose", "Shorthand for --log-level debug")
|
|
24
|
-
.option("--
|
|
37
|
+
.option("-q, --quiet", "Suppress all output except errors")
|
|
25
38
|
.option("--plain", "Use stable plain-text output (no ANSI styling)")
|
|
26
39
|
.option("--log-level <level>", "Log level: debug | info | warn | error", (value: string) => {
|
|
27
40
|
try {
|
|
@@ -44,36 +57,65 @@ export function createProgram(): Command {
|
|
|
44
57
|
},
|
|
45
58
|
"pretty",
|
|
46
59
|
)
|
|
60
|
+
.addHelpText("afterAll", "\nDocs: https://github.com/amine-amaach/miravo")
|
|
61
|
+
.action(() => {
|
|
62
|
+
writeOutput(`miravo v${MIRAVO_VERSION} — Industrial environment platform
|
|
63
|
+
|
|
64
|
+
Quick start:
|
|
65
|
+
$ miravo start Start with auto-detected template
|
|
66
|
+
$ miravo status View simulation dashboard
|
|
67
|
+
$ miravo ps List background simulations
|
|
68
|
+
|
|
69
|
+
Run miravo --help for all commands.
|
|
70
|
+
Docs: https://github.com/amine-amaach/miravo`);
|
|
71
|
+
})
|
|
47
72
|
.hook("preAction", (_thisCommand, actionCommand) => {
|
|
48
73
|
const rootOpts = program.opts<{
|
|
49
74
|
verbose?: boolean;
|
|
50
|
-
|
|
75
|
+
quiet?: boolean;
|
|
51
76
|
plain?: boolean;
|
|
52
77
|
logLevel?: "debug" | "info" | "warn" | "error";
|
|
53
78
|
logFormat?: "pretty" | "json";
|
|
54
79
|
}>();
|
|
55
80
|
const cmdOpts = actionCommand.opts<{
|
|
56
81
|
verbose?: boolean;
|
|
57
|
-
|
|
82
|
+
quiet?: boolean;
|
|
58
83
|
plain?: boolean;
|
|
59
84
|
logLevel?: "debug" | "info" | "warn" | "error";
|
|
60
85
|
logFormat?: "pretty" | "json";
|
|
61
86
|
}>();
|
|
62
87
|
configureLogger({
|
|
63
88
|
verbose: rootOpts.verbose ?? cmdOpts.verbose,
|
|
64
|
-
|
|
89
|
+
quiet: rootOpts.quiet ?? cmdOpts.quiet,
|
|
65
90
|
plain: rootOpts.plain ?? cmdOpts.plain,
|
|
66
91
|
logLevel: rootOpts.logLevel ?? cmdOpts.logLevel,
|
|
67
92
|
logFormat: rootOpts.logFormat ?? cmdOpts.logFormat,
|
|
68
93
|
});
|
|
69
94
|
});
|
|
70
95
|
|
|
96
|
+
// Simulation lifecycle
|
|
71
97
|
registerStartCommand(program);
|
|
98
|
+
registerStatusCommand(program);
|
|
99
|
+
registerPsCommand(program);
|
|
100
|
+
registerStopCommand(program);
|
|
101
|
+
registerLogsCommand(program);
|
|
102
|
+
registerRmCommand(program);
|
|
103
|
+
|
|
104
|
+
// Runtime control
|
|
105
|
+
registerSpawnCommand(program);
|
|
106
|
+
registerRemoveCommand(program);
|
|
107
|
+
registerPauseCommand(program);
|
|
108
|
+
registerResumeCommand(program);
|
|
109
|
+
registerSpeedCommand(program);
|
|
110
|
+
registerLoadCommand(program);
|
|
111
|
+
registerResetCommand(program);
|
|
112
|
+
registerInjectCommand(program);
|
|
113
|
+
registerAdapterCommand(program);
|
|
114
|
+
|
|
115
|
+
// Content discovery
|
|
72
116
|
registerModelsCommand(program);
|
|
73
117
|
registerTemplatesCommand(program);
|
|
74
118
|
registerForkCommand(program);
|
|
75
|
-
registerInjectCommand(program);
|
|
76
|
-
registerStatusCommand(program);
|
|
77
119
|
|
|
78
120
|
return program;
|
|
79
121
|
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import type { EngineCommand } from "@miravo/shared";
|
|
3
|
+
import { executeAdminCommand } from "./admin-client.ts";
|
|
4
|
+
|
|
5
|
+
const originalFetch = globalThis.fetch;
|
|
6
|
+
|
|
7
|
+
afterEach(() => {
|
|
8
|
+
globalThis.fetch = originalFetch;
|
|
9
|
+
delete Bun.env["MIRAVO_ADMIN_TOKEN"];
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
describe("admin-client", () => {
|
|
13
|
+
test("forwards MIRAVO_ADMIN_TOKEN on command requests", async () => {
|
|
14
|
+
Bun.env["MIRAVO_ADMIN_TOKEN"] = "secret-token";
|
|
15
|
+
|
|
16
|
+
globalThis.fetch = (async (_input, init) => {
|
|
17
|
+
expect(init?.headers).toEqual({
|
|
18
|
+
"content-type": "application/json",
|
|
19
|
+
authorization: "Bearer secret-token",
|
|
20
|
+
});
|
|
21
|
+
return Response.json({
|
|
22
|
+
success: true,
|
|
23
|
+
command: "pause",
|
|
24
|
+
correlationId: "req-1",
|
|
25
|
+
message: "paused",
|
|
26
|
+
});
|
|
27
|
+
}) as typeof fetch;
|
|
28
|
+
|
|
29
|
+
const command: EngineCommand = { type: "pause", correlationId: "req-1" };
|
|
30
|
+
const result = await executeAdminCommand("http://127.0.0.1:8080", command, 1_000);
|
|
31
|
+
|
|
32
|
+
expect(result.success).toBe(true);
|
|
33
|
+
expect(result.command).toBe("pause");
|
|
34
|
+
});
|
|
35
|
+
});
|
package/src/lib/admin-client.ts
CHANGED
|
@@ -87,6 +87,18 @@ function parseAdminUrl(input: string): string {
|
|
|
87
87
|
return url.toString().replace(/\/$/, "");
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
+
function buildRequestHeaders(body?: string): Record<string, string> | undefined {
|
|
91
|
+
const headers: Record<string, string> = {};
|
|
92
|
+
if (body) {
|
|
93
|
+
headers["content-type"] = "application/json";
|
|
94
|
+
}
|
|
95
|
+
const adminToken = Bun.env["MIRAVO_ADMIN_TOKEN"];
|
|
96
|
+
if (adminToken && adminToken.trim().length > 0) {
|
|
97
|
+
headers["authorization"] = `Bearer ${adminToken}`;
|
|
98
|
+
}
|
|
99
|
+
return Object.keys(headers).length > 0 ? headers : undefined;
|
|
100
|
+
}
|
|
101
|
+
|
|
90
102
|
async function requestJson<T>(options: RequestJsonOptions<T>): Promise<T> {
|
|
91
103
|
const requestUrl = new URL(options.path, `${options.baseUrl}/`).toString();
|
|
92
104
|
const controller = new AbortController();
|
|
@@ -97,7 +109,7 @@ async function requestJson<T>(options: RequestJsonOptions<T>): Promise<T> {
|
|
|
97
109
|
try {
|
|
98
110
|
response = await fetch(requestUrl, {
|
|
99
111
|
method: options.method ?? "GET",
|
|
100
|
-
headers: options.body
|
|
112
|
+
headers: buildRequestHeaders(options.body),
|
|
101
113
|
body: options.body,
|
|
102
114
|
signal: controller.signal,
|
|
103
115
|
});
|