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.
@@ -0,0 +1,205 @@
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
+ .action(async (name: string, options: { readonly force?: boolean }) => {
21
+ await forkModelAction(name, options);
22
+ });
23
+
24
+ fork
25
+ .command("template <idNameOrPath>")
26
+ .description("Fork a template and its referenced models to ~/.miravo/registry/local/")
27
+ .option("--force", "Overwrite if already exists in local")
28
+ .option("--models-only", "Only fork referenced models, skip the template itself")
29
+ .action(
30
+ async (
31
+ idNameOrPath: string,
32
+ options: { readonly force?: boolean; readonly modelsOnly?: boolean },
33
+ ) => {
34
+ await forkTemplateAction(idNameOrPath, options);
35
+ },
36
+ );
37
+ }
38
+
39
+ // ---------------------------------------------------------------------------
40
+ // Fork model
41
+ // ---------------------------------------------------------------------------
42
+
43
+ async function forkModelAction(name: string, options: { readonly force?: boolean }): Promise<void> {
44
+ try {
45
+ const catalog = createCatalog();
46
+ const resolvedPath = await catalog.resolveModel(name);
47
+ const localModelsDir = join(catalog.getLocalRegistryDir(), "models");
48
+ await mkdir(localModelsDir, { recursive: true });
49
+
50
+ const targetPath = join(localModelsDir, basename(resolvedPath));
51
+ const copied = await atomicCopyFile({
52
+ sourcePath: resolvedPath,
53
+ targetPath,
54
+ label: `model "${name}"`,
55
+ force: options.force ?? false,
56
+ });
57
+
58
+ writeOutput();
59
+ if (copied) {
60
+ writeOutput(` ${colors.bold("Forked to local:")}`);
61
+ writeOutput(` models/${basename(resolvedPath)}`);
62
+ }
63
+ writeOutput();
64
+ } catch (error) {
65
+ handleCommandError(error);
66
+ }
67
+ }
68
+
69
+ // ---------------------------------------------------------------------------
70
+ // Fork template
71
+ // ---------------------------------------------------------------------------
72
+
73
+ async function forkTemplateAction(
74
+ idNameOrPath: string,
75
+ options: { readonly force?: boolean; readonly modelsOnly?: boolean },
76
+ ): Promise<void> {
77
+ try {
78
+ const catalog = createCatalog();
79
+ const resolvedPath = await resolveTemplateIdentifier(idNameOrPath, catalog);
80
+
81
+ const localDir = catalog.getLocalRegistryDir();
82
+ const localModelsDir = join(localDir, "models");
83
+ const localTemplatesDir = join(localDir, "templates");
84
+ await mkdir(localModelsDir, { recursive: true });
85
+ await mkdir(localTemplatesDir, { recursive: true });
86
+
87
+ const forked: string[] = [];
88
+ const skipped: string[] = [];
89
+
90
+ // Fork the template itself (unless --models-only)
91
+ if (!options.modelsOnly) {
92
+ const targetPath = join(localTemplatesDir, basename(resolvedPath));
93
+ const copied = await atomicCopyFile({
94
+ sourcePath: resolvedPath,
95
+ targetPath,
96
+ label: `template "${idNameOrPath}"`,
97
+ force: options.force ?? false,
98
+ });
99
+ if (copied) {
100
+ forked.push(`templates/${basename(resolvedPath)}`);
101
+ } else {
102
+ skipped.push(`templates/${basename(resolvedPath)}`);
103
+ }
104
+ }
105
+
106
+ // Fork referenced models
107
+ const template = await loadTemplate(resolvedPath);
108
+ const inlineModels = new Set((template.models ?? []).map((m) => m.model.name));
109
+ const referencedModels = collectReferencedModels(template);
110
+ const searchPaths = catalog.getTemplateValidationSearchPaths(resolvedPath);
111
+
112
+ for (const modelName of referencedModels) {
113
+ if (inlineModels.has(modelName)) continue;
114
+
115
+ let sourceModelPath: string;
116
+ try {
117
+ sourceModelPath = await resolveModelPath(modelName, searchPaths);
118
+ } catch {
119
+ configLog.warn(`Skipped model "${modelName}": not found.`);
120
+ continue;
121
+ }
122
+
123
+ const targetModelPath = join(localModelsDir, basename(sourceModelPath));
124
+ const copied = await atomicCopyFile({
125
+ sourcePath: sourceModelPath,
126
+ targetPath: targetModelPath,
127
+ label: `model "${modelName}"`,
128
+ force: options.force ?? false,
129
+ });
130
+ if (copied) {
131
+ forked.push(`models/${basename(sourceModelPath)}`);
132
+ } else {
133
+ skipped.push(`models/${basename(sourceModelPath)}`);
134
+ }
135
+ }
136
+
137
+ writeOutput();
138
+ if (forked.length > 0) {
139
+ writeOutput(` ${colors.bold("Forked to local:")}`);
140
+ for (const f of forked) {
141
+ writeOutput(` ${f}`);
142
+ }
143
+ }
144
+ if (skipped.length > 0) {
145
+ for (const s of skipped) {
146
+ writeOutput(` ${s} ${colors.dim("(skipped — already in local)")}`);
147
+ }
148
+ }
149
+ if (forked.length === 0 && skipped.length === 0) {
150
+ writeOutput(" Nothing to fork.");
151
+ }
152
+ writeOutput();
153
+ } catch (error) {
154
+ handleCommandError(error);
155
+ }
156
+ }
157
+
158
+ // ---------------------------------------------------------------------------
159
+ // Helpers
160
+ // ---------------------------------------------------------------------------
161
+
162
+ /**
163
+ * Copy a file using stage-to-temp-and-rename for atomic writes.
164
+ * Returns true if the file was copied, false if skipped (already exists and no --force).
165
+ */
166
+ async function atomicCopyFile(options: {
167
+ readonly sourcePath: string;
168
+ readonly targetPath: string;
169
+ readonly label: string;
170
+ readonly force: boolean;
171
+ }): Promise<boolean> {
172
+ const { sourcePath, targetPath, label, force } = options;
173
+
174
+ if (resolve(sourcePath) === resolve(targetPath)) {
175
+ configLog.warn(`Skipped ${label}: source and destination are the same.`);
176
+ return false;
177
+ }
178
+
179
+ const targetExists = await Bun.file(targetPath).exists();
180
+ if (targetExists && !force) {
181
+ configLog.info(`Skipped ${label}: already exists in local. Use --force to overwrite.`);
182
+ return false;
183
+ }
184
+
185
+ const contents = await Bun.file(sourcePath).text();
186
+ const tmpPath = join(dirname(targetPath), `.${basename(targetPath)}.tmp`);
187
+
188
+ try {
189
+ await Bun.write(tmpPath, contents);
190
+ await rename(tmpPath, targetPath);
191
+ } catch {
192
+ // Clean up temp file on failure
193
+ try {
194
+ await unlink(tmpPath);
195
+ } catch {
196
+ // Ignore cleanup errors
197
+ }
198
+ throw new MiravoError(
199
+ `Failed to write ${label} to "${targetPath}".`,
200
+ `Ensure "${dirname(targetPath)}" is writable by the current user.`,
201
+ );
202
+ }
203
+
204
+ return true;
205
+ }
@@ -13,7 +13,7 @@ describe("miravo inject", () => {
13
13
  const optionNames = injectCmd?.options.map((o) => o.long);
14
14
  expect(optionNames).toContain("--clear");
15
15
  expect(optionNames).toContain("--name");
16
- expect(optionNames).toContain("--template");
16
+ expect(optionNames).toContain("--admin-url");
17
17
  });
18
18
 
19
19
  test("accepts instanceId and fault as positional arguments", async () => {
@@ -29,26 +29,24 @@ describe("miravo inject", () => {
29
29
  expect(args?.[1]?.required).toBe(false);
30
30
  });
31
31
 
32
- test("sets exit code 1 when connecting to non-existent broker", async () => {
32
+ test("sets exit code 1 with invalid admin URL", async () => {
33
33
  const { program } = await findCliCommand("inject");
34
34
  program.exitOverride();
35
35
 
36
36
  try {
37
- // Use a port that's very unlikely to have a broker running
38
37
  await program.parseAsync([
39
38
  "node",
40
39
  "miravo",
41
40
  "inject",
42
41
  "pump-001",
43
42
  "cavitation",
44
- "--template",
45
- "/nonexistent.miravo.yaml",
43
+ "--admin-url",
44
+ "not-a-url",
46
45
  ]);
47
46
  } catch {
48
- // Expected: either exitOverride or connection error
47
+ // Expected
49
48
  }
50
49
 
51
- // Should fail — either the template doesn't exist or the broker isn't running
52
50
  expect(process.exitCode).toBe(1);
53
51
  });
54
52
 
@@ -73,8 +71,7 @@ describe("miravo inject", () => {
73
71
  // Expected
74
72
  }
75
73
 
76
- // Should not succeed without a terminal in interactive mode
77
- // The exact exit behavior depends on broker connection attempt
74
+ expect(process.exitCode).toBe(1);
78
75
  } finally {
79
76
  // Restore
80
77
  Object.defineProperty(process.stdout, "isTTY", { value: origStdoutTTY, configurable: true });
@@ -1,22 +1,21 @@
1
1
  import { confirm, intro, isCancel, outro, select } from "@clack/prompts";
2
- import type { CommandResult, EngineCommand } from "@miravo/shared";
3
- import { CONTROL_CMD_TIMEOUT_MS, EngineStateSnapshotSchema } from "@miravo/shared";
2
+ import type { EngineCommand } from "@miravo/shared";
3
+ import { CONTROL_CMD_TIMEOUT_MS, MiravoError } from "@miravo/shared";
4
4
  import type { Command } from "commander";
5
- import { connectToBroker, disconnectClient } from "../lib/mqtt-client.ts";
5
+ import { executeAdminCommand, getAdminState, resolveAdminUrl } from "../lib/admin-client.ts";
6
6
  import { parseDurationMs } from "../lib/parse.ts";
7
- import { resolveTargetSimulation, type SimulationTopics } from "../lib/resolve-simulation.ts";
8
7
  import { faultLog, handleCommandError } from "../logger.ts";
9
8
 
10
9
  interface InjectOptions {
11
10
  readonly clear?: boolean;
12
11
  readonly name?: string;
13
- readonly template?: string;
12
+ readonly adminUrl?: string;
14
13
  readonly timeout?: number;
15
14
  }
16
15
 
17
16
  /**
18
17
  * Register the `miravo inject` command with the root Commander program.
19
- * Triggers or clears a fault on a running twin instance via MQTT control plane.
18
+ * Triggers or clears a fault on a running twin instance via the admin HTTP server.
20
19
  */
21
20
  export function registerInjectCommand(program: Command): void {
22
21
  program
@@ -24,13 +23,10 @@ export function registerInjectCommand(program: Command): void {
24
23
  .description("Inject or clear a fault on a running twin instance")
25
24
  .option("--clear", "Clear the fault instead of triggering it")
26
25
  .option("--name <name>", "Target simulation name")
27
- .option(
28
- "--template <path>",
29
- "Path to .miravo.yaml for broker connection info (env: MIRAVO_TEMPLATE)",
30
- )
26
+ .option("--admin-url <url>", "Miravo admin server URL (env: MIRAVO_ADMIN_URL)")
31
27
  .option(
32
28
  "--timeout <ms>",
33
- "Connection/command timeout in milliseconds (default: 5000)",
29
+ "Admin request timeout in milliseconds (default: 5000)",
34
30
  parseDurationMs,
35
31
  )
36
32
  .action(
@@ -57,65 +53,44 @@ async function injectAction(
57
53
  }
58
54
 
59
55
  try {
60
- const templateOption = options.template ?? Bun.env["MIRAVO_TEMPLATE"];
61
56
  const timeoutMs = options.timeout ?? CONTROL_CMD_TIMEOUT_MS;
62
- const { client, brokerInfo } = await connectToBroker(templateOption, timeoutMs);
63
-
64
- try {
65
- const target = await resolveTargetSimulation(client, {
66
- timeoutMs,
67
- brokerUrl: brokerInfo.url,
68
- ...(options.name !== undefined ? { name: options.name } : {}),
69
- });
70
-
71
- let targetInstance = instanceId;
72
- let targetFault = fault;
57
+ const adminUrl = resolveAdminUrl(options.adminUrl);
58
+ const stateResult = await getTargetState(adminUrl, timeoutMs, options.name);
73
59
 
74
- // Interactive mode: pick instance and fault via prompts
75
- if (!targetInstance || !targetFault) {
76
- if (!process.stdout.isTTY || !process.stdin.isTTY) {
77
- faultLog.error(
78
- "Interactive mode requires a terminal. Usage: miravo inject <instance> <fault>",
79
- );
80
- process.exitCode = 1;
81
- return;
82
- }
60
+ let targetInstance = instanceId;
61
+ let targetFault = fault;
83
62
 
84
- const stateResult = await waitForState(
85
- client,
86
- brokerInfo.url,
87
- timeoutMs,
88
- target.topics,
89
- target.simulationName,
63
+ // Interactive mode: pick instance and fault via prompts
64
+ if (!targetInstance || !targetFault) {
65
+ if (!process.stdout.isTTY || !process.stdin.isTTY) {
66
+ faultLog.error(
67
+ "Interactive mode requires a terminal. Usage: miravo inject <instance> <fault>",
90
68
  );
91
- if (!stateResult) return;
69
+ process.exitCode = 1;
70
+ return;
71
+ }
92
72
 
93
- const picked = await pickInstanceAndFault(stateResult, options.clear ?? false);
94
- if (!picked) return;
73
+ const picked = await pickInstanceAndFault(stateResult, options.clear ?? false);
74
+ if (!picked) return;
95
75
 
96
- targetInstance = picked.instanceId;
97
- targetFault = picked.fault;
98
- }
76
+ targetInstance = picked.instanceId;
77
+ targetFault = picked.fault;
78
+ }
99
79
 
100
- // Build command
101
- const command: EngineCommand = options.clear
102
- ? { type: "clearFault", instanceId: targetInstance, fault: targetFault }
103
- : { type: "triggerFault", instanceId: targetInstance, fault: targetFault };
80
+ const command: EngineCommand = options.clear
81
+ ? { type: "clearFault", instanceId: targetInstance, fault: targetFault }
82
+ : { type: "triggerFault", instanceId: targetInstance, fault: targetFault };
104
83
 
105
- // Send and wait for result
106
- const result = await sendCommand(client, command, timeoutMs, target.topics);
84
+ const result = await executeAdminCommand(adminUrl, command, timeoutMs);
107
85
 
108
- if (result.success) {
109
- faultLog.success(result.message);
110
- } else {
111
- faultLog.error(result.message);
112
- if (result.error) {
113
- faultLog.error(`Fix: ${result.error}`);
114
- }
115
- process.exitCode = 1;
86
+ if (result.success) {
87
+ faultLog.success(result.message);
88
+ } else {
89
+ faultLog.error(result.message);
90
+ if (result.error) {
91
+ faultLog.error(`Fix: ${result.error}`);
116
92
  }
117
- } finally {
118
- disconnectClient(client);
93
+ process.exitCode = 1;
119
94
  }
120
95
  } catch (error) {
121
96
  handleCommandError(error, faultLog);
@@ -123,7 +98,7 @@ async function injectAction(
123
98
  }
124
99
 
125
100
  // ---------------------------------------------------------------------------
126
- // Wait for state snapshot from control plane
101
+ // Read state snapshot from admin server
127
102
  // ---------------------------------------------------------------------------
128
103
 
129
104
  interface StateSnapshot {
@@ -139,47 +114,19 @@ interface StateSnapshot {
139
114
  }>;
140
115
  }
141
116
 
142
- function waitForState(
143
- client: import("mqtt").MqttClient,
144
- brokerUrl: string,
117
+ async function getTargetState(
118
+ adminUrl: string,
145
119
  timeoutMs: number,
146
- topics: SimulationTopics,
147
- simulationName: string,
148
- ): Promise<StateSnapshot | null> {
149
- return new Promise((resolve) => {
150
- const cleanup = (): void => {
151
- clearTimeout(timer);
152
- client.off("message", handler);
153
- client.unsubscribe(topics.state);
154
- };
155
-
156
- const timer = setTimeout(() => {
157
- cleanup();
158
- faultLog.error(
159
- `No running simulation named "${simulationName}" found on ${brokerUrl}. Start one with 'miravo start --name ${simulationName}'.`,
160
- );
161
- process.exitCode = 1;
162
- resolve(null);
163
- }, timeoutMs);
164
-
165
- const handler = (topic: string, message: Buffer): void => {
166
- if (topic !== topics.state) return;
167
- try {
168
- const parsed = EngineStateSnapshotSchema.safeParse(JSON.parse(message.toString()));
169
- if (!parsed.success) {
170
- // Skip malformed payloads — wait for a valid one or timeout.
171
- return;
172
- }
173
- cleanup();
174
- resolve(parsed.data as StateSnapshot);
175
- } catch {
176
- // JSON parse error — skip and wait for valid data.
177
- }
178
- };
179
-
180
- client.subscribe(topics.state, { qos: 0 });
181
- client.on("message", handler);
182
- });
120
+ expectedName?: string,
121
+ ): Promise<StateSnapshot> {
122
+ const state = await getAdminState(adminUrl, timeoutMs);
123
+ if (expectedName && state.simulationName !== expectedName) {
124
+ throw new MiravoError(
125
+ `Connected to simulation "${state.simulationName}" instead of "${expectedName}".`,
126
+ "Pass --admin-url for the correct Miravo process, or omit --name to target the connected simulation.",
127
+ );
128
+ }
129
+ return state as StateSnapshot;
183
130
  }
184
131
 
185
132
  // ---------------------------------------------------------------------------
@@ -255,60 +202,3 @@ async function pickInstanceAndFault(
255
202
 
256
203
  return { instanceId: instanceChoice as string, fault: faultChoice as string };
257
204
  }
258
-
259
- // ---------------------------------------------------------------------------
260
- // Send command and wait for result
261
- // ---------------------------------------------------------------------------
262
-
263
- function sendCommand(
264
- client: import("mqtt").MqttClient,
265
- command: EngineCommand,
266
- timeoutMs: number,
267
- topics: SimulationTopics,
268
- ): Promise<CommandResult> {
269
- const commandWithCorrelation: EngineCommand = {
270
- ...command,
271
- correlationId: crypto.randomUUID(),
272
- };
273
-
274
- return new Promise((resolve) => {
275
- const cleanup = (): void => {
276
- clearTimeout(timer);
277
- client.off("message", handler);
278
- client.unsubscribe(topics.cmdResult);
279
- };
280
-
281
- const timer = setTimeout(() => {
282
- cleanup();
283
- resolve({
284
- success: false,
285
- command: command.type,
286
- correlationId: commandWithCorrelation.correlationId,
287
- message: "Command timed out.",
288
- error: `No response within ${timeoutMs}ms. The simulation may not have a control plane enabled.`,
289
- });
290
- }, timeoutMs);
291
-
292
- const handler = (topic: string, message: Buffer): void => {
293
- if (topic !== topics.cmdResult) return;
294
- try {
295
- const result = JSON.parse(message.toString()) as CommandResult;
296
- if (result.correlationId !== commandWithCorrelation.correlationId) return;
297
- cleanup();
298
- resolve(result);
299
- } catch {
300
- cleanup();
301
- resolve({
302
- success: false,
303
- command: command.type,
304
- correlationId: commandWithCorrelation.correlationId,
305
- message: "Received invalid response from simulation.",
306
- });
307
- }
308
- };
309
-
310
- client.subscribe(topics.cmdResult, { qos: 0 });
311
- client.on("message", handler);
312
- client.publish(topics.cmd, JSON.stringify(commandWithCorrelation), { qos: 0 });
313
- });
314
- }
@@ -1,14 +1,14 @@
1
1
  import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
2
  import { resolve } from "node:path";
3
3
  import { loadTwinModel } from "@miravo/core";
4
- import type { GeneratorConfig } from "@miravo/shared";
4
+ import { type GeneratorConfig, TwinModelSchema } from "@miravo/shared";
5
5
  import { stripAnsi } from "consola/utils";
6
6
  import { configureLogger } from "../logger.ts";
7
7
  import { formatGeneratorSummary, formatModelInspect } from "./models.ts";
8
8
 
9
9
  configureLogger({ silent: true });
10
10
 
11
- const modelsDir = resolve(import.meta.dir, "..", "..", "..", "..", "models");
11
+ const modelsDir = resolve(import.meta.dir, "..", "..", "..", "content", "models");
12
12
 
13
13
  beforeEach(() => {
14
14
  process.exitCode = 0;
@@ -54,6 +54,16 @@ describe("formatGeneratorSummary", () => {
54
54
  },
55
55
  "health-dependent (2 stages)",
56
56
  ],
57
+ [
58
+ "gate",
59
+ {
60
+ type: "gate",
61
+ enabled_member: "run_command",
62
+ input: { member: "speed_command" },
63
+ disabled_value: 0,
64
+ },
65
+ "gate (enabled: run_command)",
66
+ ],
57
67
  ];
58
68
 
59
69
  test.each(cases)("%s → %s", (_label, config, expected) => {
@@ -78,20 +88,34 @@ describe("formatModelInspect", () => {
78
88
  expect(output).toContain("centrifugal-pump");
79
89
  expect(output).toContain("Parameters:");
80
90
  expect(output).toContain("rated_rpm");
81
- expect(output).toContain("Sensors (10):");
91
+ expect(output).toContain("Members (29):");
92
+ expect(output).toContain("Methods (5):");
93
+ expect(output).toContain("availability_state");
82
94
  expect(output).toContain("Lifecycle: healthy");
83
95
  expect(output).toContain("\u2192");
84
96
  expect(output).toContain("Faults:");
85
97
  expect(output).toContain("Source: built-in");
86
98
  });
87
99
 
88
- test("model without lifecycle shows 'none'", async () => {
89
- const model = await loadTwinModel(resolve(modelsDir, "storage-tank.twin.yaml"));
90
- const raw = formatModelInspect(model, resolve(modelsDir, "storage-tank.twin.yaml"), "built-in");
100
+ test("model without lifecycle shows 'none'", () => {
101
+ const model = TwinModelSchema.parse({
102
+ schema_version: "2",
103
+ model: { name: "test-vessel", description: "Minimal vessel" },
104
+ members: {
105
+ level: {
106
+ kind: "variable",
107
+ data_type: "Double",
108
+ access: "read",
109
+ unit: "%",
110
+ generator: { type: "noise", center: 65, stddev: 1.5 },
111
+ },
112
+ },
113
+ });
114
+ const raw = formatModelInspect(model, "/tmp/test-vessel.twin.yaml", "test");
91
115
  const output = stripAnsi(raw);
92
116
 
93
- expect(output).toContain("storage-tank");
94
- expect(output).toContain("Parameters:");
117
+ expect(output).toContain("test-vessel");
118
+ expect(output).toContain("Methods: none");
95
119
  expect(output).toContain("Lifecycle: none");
96
120
  });
97
121
  });