miravo 0.5.0 → 0.6.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.
Files changed (69) hide show
  1. package/LICENSE +199 -0
  2. package/README.md +7 -70
  3. package/bin/miravo.js +84 -0
  4. package/package.json +20 -36
  5. package/console-dist/assets/fira-code-cyrillic-ext-wght-normal-DhYMMuQd.woff2 +0 -0
  6. package/console-dist/assets/fira-code-cyrillic-wght-normal-Y3u8pIsh.woff2 +0 -0
  7. package/console-dist/assets/fira-code-greek-ext-wght-normal-wWus70Ix.woff2 +0 -0
  8. package/console-dist/assets/fira-code-greek-wght-normal-B2SviObF.woff2 +0 -0
  9. package/console-dist/assets/fira-code-latin-ext-wght-normal-Dvfvaomy.woff2 +0 -0
  10. package/console-dist/assets/fira-code-latin-wght-normal-CHoedHDv.woff2 +0 -0
  11. package/console-dist/assets/fira-code-symbols2-wght-normal-CE6EOz_n.woff2 +0 -0
  12. package/console-dist/assets/index-C1oOHU1J.js +0 -119
  13. package/console-dist/assets/index-C3XMYq26.css +0 -2
  14. package/console-dist/assets/karla-latin-ext-wght-normal-Dq0rrjgs.woff2 +0 -0
  15. package/console-dist/assets/karla-latin-wght-normal-C3-ma4ov.woff2 +0 -0
  16. package/console-dist/assets/schibsted-grotesk-latin-ext-wght-normal-hsMS0n0O.woff2 +0 -0
  17. package/console-dist/assets/schibsted-grotesk-latin-wght-normal-Bb8VGrTG.woff2 +0 -0
  18. package/console-dist/favicon.svg +0 -37
  19. package/console-dist/index.html +0 -14
  20. package/console-dist/miravo-logo.png +0 -0
  21. package/src/__tests__/integration/executable-methods-runner.ts +0 -261
  22. package/src/__tests__/integration/executable-methods.integration.ts +0 -2256
  23. package/src/commands/adapter.ts +0 -76
  24. package/src/commands/cli-test-setup.ts +0 -91
  25. package/src/commands/fork.ts +0 -219
  26. package/src/commands/inject.test.ts +0 -57
  27. package/src/commands/inject.ts +0 -212
  28. package/src/commands/load.ts +0 -167
  29. package/src/commands/logs.ts +0 -175
  30. package/src/commands/models.test.ts +0 -162
  31. package/src/commands/models.ts +0 -338
  32. package/src/commands/pause.ts +0 -67
  33. package/src/commands/ps.ts +0 -116
  34. package/src/commands/remove.ts +0 -38
  35. package/src/commands/reset.ts +0 -44
  36. package/src/commands/rm.ts +0 -97
  37. package/src/commands/spawn.ts +0 -45
  38. package/src/commands/speed.ts +0 -40
  39. package/src/commands/start.test.ts +0 -481
  40. package/src/commands/start.ts +0 -2253
  41. package/src/commands/status.test.ts +0 -20
  42. package/src/commands/status.ts +0 -470
  43. package/src/commands/stop.test.ts +0 -84
  44. package/src/commands/stop.ts +0 -158
  45. package/src/commands/templates.test.ts +0 -58
  46. package/src/commands/templates.ts +0 -279
  47. package/src/index.ts +0 -158
  48. package/src/lib/admin-client.test.ts +0 -35
  49. package/src/lib/admin-client.ts +0 -226
  50. package/src/lib/admin-server.test.ts +0 -562
  51. package/src/lib/admin-server.ts +0 -658
  52. package/src/lib/daemon-registry.test.ts +0 -429
  53. package/src/lib/daemon-registry.ts +0 -345
  54. package/src/lib/mqtt-client.ts +0 -116
  55. package/src/lib/parse.test.ts +0 -123
  56. package/src/lib/parse.ts +0 -87
  57. package/src/lib/resolve-simulation.test.ts +0 -118
  58. package/src/lib/resolve-simulation.ts +0 -186
  59. package/src/lib/shutdown.test.ts +0 -380
  60. package/src/lib/shutdown.ts +0 -561
  61. package/src/lib/template-category.test.ts +0 -71
  62. package/src/lib/template-category.ts +0 -72
  63. package/src/lib/template-locator.ts +0 -49
  64. package/src/lib/template-utils.ts +0 -45
  65. package/src/lib/ws-hub.ts +0 -255
  66. package/src/logger.test.ts +0 -172
  67. package/src/logger.ts +0 -394
  68. package/src/output.test.ts +0 -64
  69. package/src/output.ts +0 -157
@@ -1,76 +0,0 @@
1
- import type { EngineCommand } from "@miravo/shared";
2
- import { CONTROL_CMD_TIMEOUT_MS } from "@miravo/shared";
3
- import type { Command } from "commander";
4
- import { executeAdminCommand, resolveAdminUrl } from "../lib/admin-client.ts";
5
- import { parsePositiveInt, parseTimeoutSeconds } from "../lib/parse.ts";
6
- import { handleCommandError, handleCommandResult } from "../logger.ts";
7
-
8
- interface AdapterOptions {
9
- readonly port?: number;
10
- readonly host?: string;
11
- readonly json?: boolean;
12
- readonly adminUrl?: string;
13
- readonly timeout?: number;
14
- }
15
-
16
- export function registerAdapterCommand(program: Command): void {
17
- const adapter = program.command("adapter").description("Manage protocol adapters (MQTT, OPC UA)");
18
-
19
- adapter
20
- .command("enable <name>")
21
- .description("Enable a protocol adapter (mqtt, opcua)")
22
- .option("-p, --port <number>", "Override adapter port", parsePositiveInt)
23
- .option("--host <host>", "Override adapter bind host")
24
- .option("--json", "Machine-readable JSON output")
25
- .option("--admin-url <url>", "Miravo admin server URL (env: MIRAVO_ADMIN_URL)")
26
- .option("--timeout <seconds>", "Timeout in seconds (default: 5)", parseTimeoutSeconds)
27
- .addHelpText(
28
- "after",
29
- `
30
- Examples:
31
- $ miravo adapter enable mqtt Enable MQTT adapter
32
- $ miravo adapter enable opcua -p 4841 Enable OPC UA on custom port`,
33
- )
34
- .action(async (name: string, options: AdapterOptions) => {
35
- try {
36
- const adminUrl = resolveAdminUrl(options.adminUrl);
37
- const timeoutMs = options.timeout ?? CONTROL_CMD_TIMEOUT_MS;
38
- const config: Record<string, unknown> = {};
39
- if (options.port !== undefined) config["port"] = options.port;
40
- if (options.host !== undefined) config["host"] = options.host;
41
- const command: EngineCommand = {
42
- type: "enableAdapter",
43
- adapter: name,
44
- ...(Object.keys(config).length > 0 ? { config } : {}),
45
- };
46
- const result = await executeAdminCommand(adminUrl, command, timeoutMs);
47
- handleCommandResult(result, options.json);
48
- } catch (error) {
49
- handleCommandError(error);
50
- }
51
- });
52
-
53
- adapter
54
- .command("disable <name>")
55
- .description("Disable a running protocol adapter (mqtt, opcua)")
56
- .option("--json", "Machine-readable JSON output")
57
- .option("--admin-url <url>", "Miravo admin server URL (env: MIRAVO_ADMIN_URL)")
58
- .option("--timeout <seconds>", "Timeout in seconds (default: 5)", parseTimeoutSeconds)
59
- .addHelpText(
60
- "after",
61
- `
62
- Examples:
63
- $ miravo adapter disable mqtt Disable MQTT adapter`,
64
- )
65
- .action(async (name: string, options: AdapterOptions) => {
66
- try {
67
- const adminUrl = resolveAdminUrl(options.adminUrl);
68
- const timeoutMs = options.timeout ?? CONTROL_CMD_TIMEOUT_MS;
69
- const command: EngineCommand = { type: "disableAdapter", adapter: name };
70
- const result = await executeAdminCommand(adminUrl, command, timeoutMs);
71
- handleCommandResult(result, options.json);
72
- } catch (error) {
73
- handleCommandError(error);
74
- }
75
- });
76
- }
@@ -1,91 +0,0 @@
1
- /**
2
- * Shared CLI test utilities: suppresses logs and provides command lookup.
3
- *
4
- * Every CLI test file should import this module. It:
5
- * 1. Mutes all consola loggers at import time (level -999 + no-op reporter)
6
- * 2. Re-mutes before every test (guards against tests that unlock/reconfigure)
7
- *
8
- * Note: process.exitCode must be reset in each test file's own beforeEach/afterEach.
9
- */
10
- import { beforeEach } from "bun:test";
11
- import {
12
- configLog,
13
- configureLogger,
14
- controlLog,
15
- engineLog,
16
- faultLog,
17
- lifecycleLog,
18
- lockLogger,
19
- logger,
20
- mqttLog,
21
- unlockLogger,
22
- } from "../logger.ts";
23
-
24
- const noopReporter = { log() {} };
25
- const allLoggers = [logger, engineLog, mqttLog, configLog, lifecycleLog, faultLog, controlLog];
26
-
27
- export function muteAllLoggers(): void {
28
- unlockLogger();
29
- configureLogger({ quiet: true });
30
- for (const l of allLoggers) {
31
- l.level = -999;
32
- l.setReporters([noopReporter]);
33
- }
34
- lockLogger();
35
- }
36
-
37
- muteAllLoggers();
38
-
39
- beforeEach(() => {
40
- muteAllLoggers();
41
- });
42
-
43
- /** Resets process.exitCode — pass to beforeEach/afterEach in test files. */
44
- export const resetExitCode = () => {
45
- process.exitCode = 0;
46
- };
47
-
48
- /** Creates a program and finds a command by name. */
49
- export async function findCliCommand(name: string) {
50
- const { createProgram } = await import("../index.ts");
51
- const program = createProgram();
52
- const cmd = program.commands.find((c) => c.name() === name);
53
- return { program, cmd };
54
- }
55
-
56
- /**
57
- * Creates a CLI program with exitOverride, runs parseAsync with the given args,
58
- * and swallows any Commander exitOverride errors.
59
- */
60
- export async function runCliCommand(args: string[]): Promise<void> {
61
- const { createProgram } = await import("../index.ts");
62
- const program = createProgram();
63
- program.exitOverride();
64
-
65
- try {
66
- await program.parseAsync(["node", "miravo", ...args]);
67
- } catch {
68
- // Commander may throw on exitOverride
69
- }
70
- }
71
-
72
- /**
73
- * Saves an env var, sets it to a new value, runs the callback, then restores it.
74
- */
75
- export async function withEnvVar<T>(
76
- key: string,
77
- value: string,
78
- fn: () => T | Promise<T>,
79
- ): Promise<T> {
80
- const prev = Bun.env[key];
81
- Bun.env[key] = value;
82
- try {
83
- return await fn();
84
- } finally {
85
- if (prev === undefined) {
86
- delete Bun.env[key];
87
- } else {
88
- Bun.env[key] = prev;
89
- }
90
- }
91
- }
@@ -1,219 +0,0 @@
1
- import { mkdir, rename, unlink } from "node:fs/promises";
2
- import { basename, dirname, join, resolve } from "node:path";
3
- import { loadTemplate, resolveModelPath } from "@miravo/core";
4
- import { MiravoError } from "@miravo/shared";
5
- import type { Command } from "commander";
6
- import { colors } from "consola/utils";
7
- import { createCatalog, resolveTemplateIdentifier } from "../lib/template-locator.ts";
8
- import { collectReferencedModels } from "../lib/template-utils.ts";
9
- import { configLog, handleCommandError, writeOutput } from "../logger.ts";
10
-
11
- export function registerForkCommand(program: Command): void {
12
- const fork = program
13
- .command("fork")
14
- .description("Copy a model or template to your local registry for customization");
15
-
16
- fork
17
- .command("model <name>")
18
- .description("Fork a model to ~/.miravo/registry/local/models/")
19
- .option("--force", "Overwrite if already exists in local")
20
- .addHelpText(
21
- "after",
22
- `
23
- Examples:
24
- $ miravo fork model centrifugal-pump Fork a model for customization
25
- $ miravo fork model centrifugal-pump --force Overwrite existing fork`,
26
- )
27
- .action(async (name: string, options: { readonly force?: boolean }) => {
28
- await forkModelAction(name, options);
29
- });
30
-
31
- fork
32
- .command("template <idNameOrPath>")
33
- .description("Fork a template and its referenced models to ~/.miravo/registry/local/")
34
- .option("--force", "Overwrite if already exists in local")
35
- .option("--models-only", "Only fork referenced models, skip the template itself")
36
- .addHelpText(
37
- "after",
38
- `
39
- Examples:
40
- $ miravo fork template water-treatment Fork template + referenced models
41
- $ miravo fork template water-treatment --models-only Fork only models`,
42
- )
43
- .action(
44
- async (
45
- idNameOrPath: string,
46
- options: { readonly force?: boolean; readonly modelsOnly?: boolean },
47
- ) => {
48
- await forkTemplateAction(idNameOrPath, options);
49
- },
50
- );
51
- }
52
-
53
- // ---------------------------------------------------------------------------
54
- // Fork model
55
- // ---------------------------------------------------------------------------
56
-
57
- async function forkModelAction(name: string, options: { readonly force?: boolean }): Promise<void> {
58
- try {
59
- const catalog = createCatalog();
60
- const resolvedPath = await catalog.resolveModel(name);
61
- const localModelsDir = join(catalog.getLocalRegistryDir(), "models");
62
- await mkdir(localModelsDir, { recursive: true });
63
-
64
- const targetPath = join(localModelsDir, basename(resolvedPath));
65
- const copied = await atomicCopyFile({
66
- sourcePath: resolvedPath,
67
- targetPath,
68
- label: `model "${name}"`,
69
- force: options.force ?? false,
70
- });
71
-
72
- writeOutput();
73
- if (copied) {
74
- writeOutput(` ${colors.bold("Forked to local:")}`);
75
- writeOutput(` models/${basename(resolvedPath)}`);
76
- }
77
- writeOutput();
78
- } catch (error) {
79
- handleCommandError(error);
80
- }
81
- }
82
-
83
- // ---------------------------------------------------------------------------
84
- // Fork template
85
- // ---------------------------------------------------------------------------
86
-
87
- async function forkTemplateAction(
88
- idNameOrPath: string,
89
- options: { readonly force?: boolean; readonly modelsOnly?: boolean },
90
- ): Promise<void> {
91
- try {
92
- const catalog = createCatalog();
93
- const resolvedPath = await resolveTemplateIdentifier(idNameOrPath, catalog);
94
-
95
- const localDir = catalog.getLocalRegistryDir();
96
- const localModelsDir = join(localDir, "models");
97
- const localTemplatesDir = join(localDir, "templates");
98
- await mkdir(localModelsDir, { recursive: true });
99
- await mkdir(localTemplatesDir, { recursive: true });
100
-
101
- const forked: string[] = [];
102
- const skipped: string[] = [];
103
-
104
- // Fork the template itself (unless --models-only)
105
- if (!options.modelsOnly) {
106
- const targetPath = join(localTemplatesDir, basename(resolvedPath));
107
- const copied = await atomicCopyFile({
108
- sourcePath: resolvedPath,
109
- targetPath,
110
- label: `template "${idNameOrPath}"`,
111
- force: options.force ?? false,
112
- });
113
- if (copied) {
114
- forked.push(`templates/${basename(resolvedPath)}`);
115
- } else {
116
- skipped.push(`templates/${basename(resolvedPath)}`);
117
- }
118
- }
119
-
120
- // Fork referenced models
121
- const template = await loadTemplate(resolvedPath);
122
- const inlineModels = new Set((template.models ?? []).map((m) => m.model.name));
123
- const referencedModels = collectReferencedModels(template);
124
- const searchPaths = catalog.getTemplateValidationSearchPaths(resolvedPath);
125
-
126
- for (const modelName of referencedModels) {
127
- if (inlineModels.has(modelName)) continue;
128
-
129
- let sourceModelPath: string;
130
- try {
131
- sourceModelPath = await resolveModelPath(modelName, searchPaths);
132
- } catch {
133
- configLog.warn(`Skipped model "${modelName}": not found.`);
134
- continue;
135
- }
136
-
137
- const targetModelPath = join(localModelsDir, basename(sourceModelPath));
138
- const copied = await atomicCopyFile({
139
- sourcePath: sourceModelPath,
140
- targetPath: targetModelPath,
141
- label: `model "${modelName}"`,
142
- force: options.force ?? false,
143
- });
144
- if (copied) {
145
- forked.push(`models/${basename(sourceModelPath)}`);
146
- } else {
147
- skipped.push(`models/${basename(sourceModelPath)}`);
148
- }
149
- }
150
-
151
- writeOutput();
152
- if (forked.length > 0) {
153
- writeOutput(` ${colors.bold("Forked to local:")}`);
154
- for (const f of forked) {
155
- writeOutput(` ${f}`);
156
- }
157
- }
158
- if (skipped.length > 0) {
159
- for (const s of skipped) {
160
- writeOutput(` ${s} ${colors.dim("(skipped — already in local)")}`);
161
- }
162
- }
163
- if (forked.length === 0 && skipped.length === 0) {
164
- writeOutput(" Nothing to fork.");
165
- }
166
- writeOutput();
167
- } catch (error) {
168
- handleCommandError(error);
169
- }
170
- }
171
-
172
- // ---------------------------------------------------------------------------
173
- // Helpers
174
- // ---------------------------------------------------------------------------
175
-
176
- /**
177
- * Copy a file using stage-to-temp-and-rename for atomic writes.
178
- * Returns true if the file was copied, false if skipped (already exists and no --force).
179
- */
180
- async function atomicCopyFile(options: {
181
- readonly sourcePath: string;
182
- readonly targetPath: string;
183
- readonly label: string;
184
- readonly force: boolean;
185
- }): Promise<boolean> {
186
- const { sourcePath, targetPath, label, force } = options;
187
-
188
- if (resolve(sourcePath) === resolve(targetPath)) {
189
- configLog.warn(`Skipped ${label}: source and destination are the same.`);
190
- return false;
191
- }
192
-
193
- const targetExists = await Bun.file(targetPath).exists();
194
- if (targetExists && !force) {
195
- configLog.info(`Skipped ${label}: already exists in local. Use --force to overwrite.`);
196
- return false;
197
- }
198
-
199
- const contents = await Bun.file(sourcePath).text();
200
- const tmpPath = join(dirname(targetPath), `.${basename(targetPath)}.tmp`);
201
-
202
- try {
203
- await Bun.write(tmpPath, contents);
204
- await rename(tmpPath, targetPath);
205
- } catch {
206
- // Clean up temp file on failure
207
- try {
208
- await unlink(tmpPath);
209
- } catch {
210
- // Ignore cleanup errors
211
- }
212
- throw new MiravoError(
213
- `Failed to write ${label} to "${targetPath}".`,
214
- `Ensure "${dirname(targetPath)}" is writable by the current user.`,
215
- );
216
- }
217
-
218
- return true;
219
- }
@@ -1,57 +0,0 @@
1
- import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
- import { findCliCommand, resetExitCode } from "./cli-test-setup.ts";
3
-
4
- beforeEach(resetExitCode);
5
- afterEach(resetExitCode);
6
-
7
- describe("miravo inject", () => {
8
- test("sets exit code 1 with invalid admin URL", async () => {
9
- const { program } = await findCliCommand("inject");
10
- program.exitOverride();
11
-
12
- try {
13
- await program.parseAsync([
14
- "node",
15
- "miravo",
16
- "inject",
17
- "pump-001",
18
- "cavitation",
19
- "--admin-url",
20
- "not-a-url",
21
- ]);
22
- } catch {
23
- // Expected
24
- }
25
-
26
- expect(process.exitCode).toBe(1);
27
- });
28
-
29
- test("requires terminal for interactive mode", async () => {
30
- // Save originals
31
- const origStdoutTTY = process.stdout.isTTY;
32
- const origStdinTTY = process.stdin.isTTY;
33
-
34
- try {
35
- // Simulate non-TTY
36
- Object.defineProperty(process.stdout, "isTTY", { value: false, configurable: true });
37
- Object.defineProperty(process.stdin, "isTTY", { value: false, configurable: true });
38
-
39
- // Import inject action indirectly via program
40
- const { program } = await findCliCommand("inject");
41
- program.exitOverride();
42
-
43
- try {
44
- // No positional args → interactive mode needed → should fail in non-TTY
45
- await program.parseAsync(["node", "miravo", "inject"]);
46
- } catch {
47
- // Expected
48
- }
49
-
50
- expect(process.exitCode).toBe(1);
51
- } finally {
52
- // Restore
53
- Object.defineProperty(process.stdout, "isTTY", { value: origStdoutTTY, configurable: true });
54
- Object.defineProperty(process.stdin, "isTTY", { value: origStdinTTY, configurable: true });
55
- }
56
- });
57
- });
@@ -1,212 +0,0 @@
1
- import { confirm, intro, isCancel, outro, select } from "@clack/prompts";
2
- import type { EngineCommand } from "@miravo/shared";
3
- import { CONTROL_CMD_TIMEOUT_MS, MiravoError } from "@miravo/shared";
4
- import type { Command } from "commander";
5
- import { executeAdminCommand, getAdminState, resolveAdminUrl } from "../lib/admin-client.ts";
6
- import { parseTimeoutSeconds } from "../lib/parse.ts";
7
- import { faultLog, handleCommandError } from "../logger.ts";
8
-
9
- interface InjectOptions {
10
- readonly clear?: boolean;
11
- readonly json?: boolean;
12
- readonly name?: string;
13
- readonly adminUrl?: string;
14
- readonly timeout?: number;
15
- }
16
-
17
- /**
18
- * Register the `miravo inject` command with the root Commander program.
19
- * Triggers or clears a fault on a running twin instance via the admin HTTP server.
20
- */
21
- export function registerInjectCommand(program: Command): void {
22
- program
23
- .command("inject [instanceId] [fault]")
24
- .description("Trigger or clear a fault on a running instance")
25
- .option("--clear", "Clear the fault instead of triggering it")
26
- .option("--json", "Machine-readable JSON output")
27
- .option("--name <name>", "Target simulation name")
28
- .option("--admin-url <url>", "Miravo admin server URL (env: MIRAVO_ADMIN_URL)")
29
- .option("--timeout <seconds>", "Timeout in seconds (default: 5)", parseTimeoutSeconds)
30
- .addHelpText(
31
- "after",
32
- `
33
- Examples:
34
- $ miravo inject Interactive mode (pick instance + fault)
35
- $ miravo inject pump-001 cavitation Trigger fault directly
36
- $ miravo inject pump-001 cavitation --clear Clear an active fault`,
37
- )
38
- .action(
39
- async (instanceId: string | undefined, fault: string | undefined, options: InjectOptions) => {
40
- await injectAction(instanceId, fault, options);
41
- },
42
- );
43
- }
44
-
45
- // ---------------------------------------------------------------------------
46
- // Action
47
- // ---------------------------------------------------------------------------
48
-
49
- async function injectAction(
50
- instanceId: string | undefined,
51
- fault: string | undefined,
52
- options: InjectOptions,
53
- ): Promise<void> {
54
- // Fail fast before broker connect when interactive prompts would be required.
55
- if ((!instanceId || !fault) && (!process.stdout.isTTY || !process.stdin.isTTY)) {
56
- faultLog.error("Interactive mode requires a terminal. Usage: miravo inject <instance> <fault>");
57
- process.exitCode = 1;
58
- return;
59
- }
60
-
61
- try {
62
- const timeoutMs = options.timeout ?? CONTROL_CMD_TIMEOUT_MS;
63
- const adminUrl = resolveAdminUrl(options.adminUrl);
64
- const stateResult = await getTargetState(adminUrl, timeoutMs, options.name);
65
-
66
- let targetInstance = instanceId;
67
- let targetFault = fault;
68
-
69
- // Interactive mode: pick instance and fault via prompts
70
- if (!targetInstance || !targetFault) {
71
- if (!process.stdout.isTTY || !process.stdin.isTTY) {
72
- faultLog.error(
73
- "Interactive mode requires a terminal. Usage: miravo inject <instance> <fault>",
74
- );
75
- process.exitCode = 1;
76
- return;
77
- }
78
-
79
- const picked = await pickInstanceAndFault(stateResult, options.clear ?? false);
80
- if (!picked) return;
81
-
82
- targetInstance = picked.instanceId;
83
- targetFault = picked.fault;
84
- }
85
-
86
- const command: EngineCommand = options.clear
87
- ? { type: "clearFault", instanceId: targetInstance, fault: targetFault }
88
- : { type: "triggerFault", instanceId: targetInstance, fault: targetFault };
89
-
90
- const result = await executeAdminCommand(adminUrl, command, timeoutMs);
91
-
92
- if (options.json) {
93
- console.log(JSON.stringify(result, null, 2));
94
- } else if (result.success) {
95
- faultLog.success(result.message);
96
- } else {
97
- faultLog.error(result.message);
98
- if (result.error) {
99
- faultLog.error(`Fix: ${result.error}`);
100
- }
101
- }
102
- if (!result.success) process.exitCode = 1;
103
- } catch (error) {
104
- handleCommandError(error, faultLog);
105
- }
106
- }
107
-
108
- // ---------------------------------------------------------------------------
109
- // Read state snapshot from admin server
110
- // ---------------------------------------------------------------------------
111
-
112
- interface StateSnapshot {
113
- readonly models: ReadonlyArray<{
114
- readonly name: string;
115
- readonly faultNames: readonly string[];
116
- }>;
117
- readonly instances: ReadonlyArray<{
118
- readonly id: string;
119
- readonly model: string;
120
- readonly lifecycleStage: string;
121
- readonly activeFaults: readonly string[];
122
- }>;
123
- }
124
-
125
- async function getTargetState(
126
- adminUrl: string,
127
- timeoutMs: number,
128
- expectedName?: string,
129
- ): Promise<StateSnapshot> {
130
- const state = await getAdminState(adminUrl, timeoutMs);
131
- if (expectedName && state.simulationName !== expectedName) {
132
- throw new MiravoError(
133
- `Connected to simulation "${state.simulationName}" instead of "${expectedName}".`,
134
- "Pass --admin-url for the correct Miravo process, or omit --name to target the connected simulation.",
135
- );
136
- }
137
- return state as StateSnapshot;
138
- }
139
-
140
- // ---------------------------------------------------------------------------
141
- // Interactive: pick instance and fault
142
- // ---------------------------------------------------------------------------
143
-
144
- async function pickInstanceAndFault(
145
- state: StateSnapshot,
146
- clearing: boolean,
147
- ): Promise<{ instanceId: string; fault: string } | null> {
148
- if (state.instances.length === 0) {
149
- faultLog.error("No instances running. Spawn instances first with 'miravo start'.");
150
- process.exitCode = 1;
151
- return null;
152
- }
153
-
154
- intro(clearing ? "Clear a fault" : "Inject a fault");
155
-
156
- // Pick instance
157
- const instanceChoice = await select({
158
- message: "Which instance?",
159
- options: state.instances.map((inst) => ({
160
- value: inst.id,
161
- label: inst.id,
162
- hint: `${inst.model} · ${inst.lifecycleStage}${inst.activeFaults.length > 0 ? ` · faults: ${inst.activeFaults.join(", ")}` : ""}`,
163
- })),
164
- });
165
-
166
- if (isCancel(instanceChoice)) {
167
- outro("Cancelled.");
168
- return null;
169
- }
170
-
171
- const selectedInstance = state.instances.find((i) => i.id === instanceChoice);
172
- if (!selectedInstance) return null;
173
-
174
- // Get available faults for this instance's model
175
- const model = state.models.find((m) => m.name === selectedInstance.model);
176
- const availableFaults = clearing ? selectedInstance.activeFaults : (model?.faultNames ?? []);
177
-
178
- if (availableFaults.length === 0) {
179
- const reason = clearing
180
- ? `No active faults on ${selectedInstance.id}.`
181
- : `Model "${selectedInstance.model}" has no defined faults.`;
182
- outro(reason);
183
- return null;
184
- }
185
-
186
- // Pick fault
187
- const faultChoice = await select({
188
- message: clearing ? "Which fault to clear?" : "Which fault to trigger?",
189
- options: availableFaults.map((f) => ({
190
- value: f,
191
- label: f,
192
- })),
193
- });
194
-
195
- if (isCancel(faultChoice)) {
196
- outro("Cancelled.");
197
- return null;
198
- }
199
-
200
- // Confirm
201
- const action = clearing ? "Clear" : "Trigger";
202
- const confirmed = await confirm({
203
- message: `${action} ${faultChoice as string} on ${instanceChoice as string}?`,
204
- });
205
-
206
- if (isCancel(confirmed) || !confirmed) {
207
- outro("Cancelled.");
208
- return null;
209
- }
210
-
211
- return { instanceId: instanceChoice as string, fault: faultChoice as string };
212
- }