miravo 0.5.0 → 0.6.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.
Files changed (161) hide show
  1. package/README.md +1 -1
  2. package/console-dist/assets/activity-DsWeRq4h.js +1 -0
  3. package/console-dist/assets/api-4AqKvsm9.js +1 -0
  4. package/console-dist/assets/arrow-right-Ea_UhTNi.js +1 -0
  5. package/console-dist/assets/arrow-up-down-DFSjzzl2.js +1 -0
  6. package/console-dist/assets/blueprint-card-classes-DBFCDy0M.js +1 -0
  7. package/console-dist/assets/box-C1WaoOMF.js +1 -0
  8. package/console-dist/assets/card-BjeP32PN.js +1 -0
  9. package/console-dist/assets/catalog-BJzyzbLR.js +1 -0
  10. package/console-dist/assets/catalog-DKjEm-c4.js +1 -0
  11. package/console-dist/assets/catalog-template-icons-BrUeHaCY.js +1 -0
  12. package/console-dist/assets/checkbox-DZlJXTE4.js +1 -0
  13. package/console-dist/assets/chunk-OE4NN4TA-CEjpfhS6.js +3 -0
  14. package/console-dist/assets/combobox-BGnuCYxO.js +1 -0
  15. package/console-dist/assets/control-CPhvwx9o.js +1 -0
  16. package/console-dist/assets/control-Cg2up9NA.js +1 -0
  17. package/console-dist/assets/copy-button-WkbNkMcd.js +1 -0
  18. package/console-dist/assets/createLucideIcon-Bwxw859K.js +1 -0
  19. package/console-dist/assets/deploy-tab-JeWDwPoZ.js +1 -0
  20. package/console-dist/assets/dist-BhbDML0a.js +1 -0
  21. package/console-dist/assets/dist-Cy8XTNFT.js +2 -0
  22. package/console-dist/assets/download-DQ6NH16U.js +1 -0
  23. package/console-dist/assets/engine-store-CrLnz692.js +1 -0
  24. package/console-dist/assets/events-CXm29Ok4.js +1 -0
  25. package/console-dist/assets/events-D01IxhnP.js +1 -0
  26. package/console-dist/assets/fleet-CfxbKXt9.js +1 -0
  27. package/console-dist/assets/fleet-lotMGVqe.js +1 -0
  28. package/console-dist/assets/format-kXTonpDJ.js +1 -0
  29. package/console-dist/assets/gauge-Ci8zNZlk.js +1 -0
  30. package/console-dist/assets/get-error-message-3Q5Q-2G5.js +1 -0
  31. package/console-dist/assets/index-CVPKSDvJ.js +12 -0
  32. package/console-dist/assets/index-DJkgDeun.css +2 -0
  33. package/console-dist/assets/input-BWEQFptx.js +1 -0
  34. package/console-dist/assets/instance-detail-C5IVwuh3.js +1 -0
  35. package/console-dist/assets/label-uephX5ZG.js +1 -0
  36. package/console-dist/assets/layers-CUsLQ1fq.js +1 -0
  37. package/console-dist/assets/login-C3RJli0m.js +126 -0
  38. package/console-dist/assets/models-B_UioWQ5.js +1 -0
  39. package/console-dist/assets/models-_IPlPiZ7.js +1 -0
  40. package/console-dist/assets/not-found-B8gAm08S.js +1 -0
  41. package/console-dist/assets/platform-targets-R3T-4eJ7.js +1 -0
  42. package/console-dist/assets/platforms-BWh0BQ3Y.js +1 -0
  43. package/console-dist/assets/platforms-DStNbtVc.js +1 -0
  44. package/console-dist/assets/proxy-CsnYqnPp.js +9 -0
  45. package/console-dist/assets/query-keys-BvtE7BMs.js +1 -0
  46. package/console-dist/assets/radio-CoHegOv5.js +1 -0
  47. package/console-dist/assets/rolldown-runtime-COnpUsM8.js +1 -0
  48. package/console-dist/assets/select-CeO4vP0G.js +1 -0
  49. package/console-dist/assets/select-display-items-CIzY8953.js +1 -0
  50. package/console-dist/assets/server-D9Kfn8fQ.js +1 -0
  51. package/console-dist/assets/shallow-6lGjQhtJ.js +1 -0
  52. package/console-dist/assets/tags-CiXOIHtY.js +4 -0
  53. package/console-dist/assets/tags-FQCXIxrP.js +1 -0
  54. package/console-dist/assets/tooltip-HGLF366Q.js +1 -0
  55. package/console-dist/assets/transition-nav-link-Df4Rk6vV.js +1 -0
  56. package/console-dist/assets/transition-types-CPlGtM8w.js +1 -0
  57. package/console-dist/assets/triangle-alert-adNk_sea.js +1 -0
  58. package/console-dist/assets/use-platform-stream-DGbK1k2L.js +2 -0
  59. package/console-dist/assets/use-transition-navigate-CCpcWXB0.js +2 -0
  60. package/console-dist/assets/validation-Blvud9_O.js +1 -0
  61. package/console-dist/assets/vendor-charts-CAZdaPlq.js +60 -0
  62. package/console-dist/assets/vendor-table-wd8JuMmT.js +4 -0
  63. package/console-dist/assets/vendor-ui-ByahnaY0.js +1 -0
  64. package/console-dist/index.html +70 -2
  65. package/package.json +9 -9
  66. package/src/__tests__/integration/executable-methods-runner.ts +7 -6
  67. package/src/__tests__/integration/executable-methods.integration.ts +214 -122
  68. package/src/__tests__/integration/opcua-concurrent-clients.integration.ts +314 -0
  69. package/src/commands/adapter.ts +180 -7
  70. package/src/commands/apply.test.ts +62 -0
  71. package/src/commands/apply.ts +216 -0
  72. package/src/commands/cleanup.test.ts +34 -0
  73. package/src/commands/cleanup.ts +201 -0
  74. package/src/commands/export.test.ts +51 -0
  75. package/src/commands/export.ts +404 -0
  76. package/src/commands/fork.ts +2 -2
  77. package/src/commands/inject.ts +2 -2
  78. package/src/commands/load.ts +1 -1
  79. package/src/commands/logs.ts +13 -15
  80. package/src/commands/models.test.ts +1 -1
  81. package/src/commands/models.ts +1 -1
  82. package/src/commands/pause.ts +3 -3
  83. package/src/commands/platforms.test.ts +186 -0
  84. package/src/commands/platforms.ts +371 -0
  85. package/src/commands/remove.ts +1 -1
  86. package/src/commands/reset.ts +2 -2
  87. package/src/commands/spawn.ts +2 -2
  88. package/src/commands/speed.ts +2 -2
  89. package/src/commands/start/adapter-config.ts +157 -0
  90. package/src/commands/start/catalog-le-dt.test.ts +50 -0
  91. package/src/commands/start/catalog-le-dt.ts +187 -0
  92. package/src/commands/start/catalog-model-schema.ts +89 -0
  93. package/src/commands/start/catalog-template-detail.ts +54 -0
  94. package/src/commands/start/daemon.ts +255 -0
  95. package/src/commands/start/display.ts +150 -0
  96. package/src/commands/start/engine-setup.ts +247 -0
  97. package/src/commands/start/events.ts +126 -0
  98. package/src/commands/start/index.ts +897 -0
  99. package/src/commands/start/protocol-plans.ts +239 -0
  100. package/src/commands/start/template-loader.ts +197 -0
  101. package/src/commands/start/template-resolver.ts +185 -0
  102. package/src/commands/start/types.ts +73 -0
  103. package/src/commands/start.test.ts +20 -21
  104. package/src/commands/status.ts +1 -1
  105. package/src/commands/stop.ts +19 -8
  106. package/src/commands/templates.test.ts +1 -1
  107. package/src/commands/templates.ts +1 -3
  108. package/src/index.ts +11 -3
  109. package/src/lib/admin-auth.ts +157 -0
  110. package/src/lib/admin-client.test.ts +31 -15
  111. package/src/lib/admin-client.ts +106 -6
  112. package/src/lib/admin-platform-routes.ts +1017 -0
  113. package/src/lib/admin-platform-stream.test.ts +434 -0
  114. package/src/lib/admin-platform-stream.ts +788 -0
  115. package/src/lib/admin-responses.ts +211 -0
  116. package/src/lib/admin-server.test.ts +274 -55
  117. package/src/lib/admin-server.ts +387 -248
  118. package/src/lib/auth-store.ts +87 -0
  119. package/src/lib/daemon-registry.ts +12 -14
  120. package/src/lib/le/le-apply.test.ts +591 -0
  121. package/src/lib/le/le-apply.ts +832 -0
  122. package/src/lib/le/le-auth.ts +89 -0
  123. package/src/lib/le/le-cleanup.test.ts +168 -0
  124. package/src/lib/le/le-cleanup.ts +399 -0
  125. package/src/lib/le/le-cli-helpers.ts +77 -0
  126. package/src/lib/le/le-client.ts +184 -0
  127. package/src/lib/le/le-concurrency.test.ts +157 -0
  128. package/src/lib/le/le-concurrency.ts +78 -0
  129. package/src/lib/le/le-connection-test.ts +42 -0
  130. package/src/lib/le/le-devicehub-properties.test.ts +53 -0
  131. package/src/lib/le/le-devicehub-properties.ts +33 -0
  132. package/src/lib/le/le-devicehub.ts +213 -0
  133. package/src/lib/le/le-digital-twins.ts +285 -0
  134. package/src/lib/le/le-drivers.ts +23 -0
  135. package/src/lib/le/le-endpoint.ts +40 -0
  136. package/src/lib/le/le-environments.test.ts +73 -0
  137. package/src/lib/le/le-environments.ts +156 -0
  138. package/src/lib/le/le-markers.ts +87 -0
  139. package/src/lib/le/le-plan.test.ts +26 -0
  140. package/src/lib/le/le-plan.ts +109 -0
  141. package/src/lib/le/le-platform-operations.test.ts +198 -0
  142. package/src/lib/le/le-platform-operations.ts +340 -0
  143. package/src/lib/le/le-static-model-schema.ts +78 -0
  144. package/src/lib/le/le-static-planner.test.ts +255 -0
  145. package/src/lib/le/le-static-planner.ts +442 -0
  146. package/src/lib/le/le-template-exporter.test.ts +527 -0
  147. package/src/lib/le/le-template-exporter.ts +459 -0
  148. package/src/lib/le/le-value-types.ts +23 -0
  149. package/src/lib/mqtt-client.ts +1 -2
  150. package/src/lib/resolve-simulation.ts +1 -6
  151. package/src/lib/session-store.ts +110 -0
  152. package/src/lib/shutdown.test.ts +184 -2
  153. package/src/lib/shutdown.ts +96 -36
  154. package/src/lib/template-category.ts +3 -3
  155. package/src/lib/template-locator.ts +8 -2
  156. package/src/lib/ws-hub.ts +72 -46
  157. package/src/logger.ts +18 -10
  158. package/src/output.ts +3 -0
  159. package/console-dist/assets/index-C1oOHU1J.js +0 -119
  160. package/console-dist/assets/index-C3XMYq26.css +0 -2
  161. package/src/commands/start.ts +0 -2253
@@ -27,7 +27,6 @@ const BUILTIN_TEMPLATE_PATHS = [
27
27
  resolve(stressTemplatesDir, "stress-10k.miravo.yaml"),
28
28
  resolve(stressTemplatesDir, "stress-50k.miravo.yaml"),
29
29
  resolve(stressTemplatesDir, "stress-100k.miravo.yaml"),
30
- resolve(stressTemplatesDir, "stress-200k.miravo.yaml"),
31
30
  ];
32
31
 
33
32
  beforeEach(resetExitCode);
@@ -157,7 +156,7 @@ function createTemplateWithNamespace(namespace?: MiravoTemplate["namespace"]): M
157
156
 
158
157
  describe("resolveSimulationName", () => {
159
158
  test("priority: --name > MIRAVO_NAME > namespace", async () => {
160
- const { resolveSimulationName } = await import("./start.ts");
159
+ const { resolveSimulationName } = await import("./start/index.ts");
161
160
  const template = createTemplateWithNamespace({ enterprise: "acme", site: "site-a" });
162
161
 
163
162
  expect(
@@ -172,20 +171,20 @@ describe("resolveSimulationName", () => {
172
171
  });
173
172
 
174
173
  test("uses flat namespace prefix when ISA-95 site is not present", async () => {
175
- const { resolveSimulationName } = await import("./start.ts");
174
+ const { resolveSimulationName } = await import("./start/index.ts");
176
175
  const template = createTemplateWithNamespace({ prefix: "flat-prefix" });
177
176
  expect(resolveSimulationName({ template })).toBe("flat-prefix");
178
177
  });
179
178
 
180
179
  test("throws when template has no namespace and no explicit name", async () => {
181
- const { resolveSimulationName } = await import("./start.ts");
180
+ const { resolveSimulationName } = await import("./start/index.ts");
182
181
  const template = createTemplateWithNamespace();
183
182
 
184
183
  expect(() => resolveSimulationName({ template })).toThrow("Template has no namespace.");
185
184
  });
186
185
 
187
186
  test("rejects invalid names with special characters", async () => {
188
- const { resolveSimulationName } = await import("./start.ts");
187
+ const { resolveSimulationName } = await import("./start/index.ts");
189
188
  const template = createTemplateWithNamespace({ prefix: "valid" });
190
189
 
191
190
  expect(() => resolveSimulationName({ template, cliName: "UPPER-CASE" })).toThrow(
@@ -202,7 +201,7 @@ describe("resolveSimulationName", () => {
202
201
  });
203
202
 
204
203
  test("accepts single-character name", async () => {
205
- const { resolveSimulationName } = await import("./start.ts");
204
+ const { resolveSimulationName } = await import("./start/index.ts");
206
205
  const template = createTemplateWithNamespace({ prefix: "x" });
207
206
  expect(resolveSimulationName({ template })).toBe("x");
208
207
  });
@@ -210,7 +209,7 @@ describe("resolveSimulationName", () => {
210
209
 
211
210
  describe("resolveProtocolBootPlans", () => {
212
211
  test("preserves restored adapter state when no explicit protocol overrides are provided", async () => {
213
- const { resolveProtocolBootPlans } = await import("./start.ts");
212
+ const { resolveProtocolBootPlans } = await import("./start/index.ts");
214
213
 
215
214
  expect(
216
215
  resolveProtocolBootPlans({
@@ -226,7 +225,7 @@ describe("resolveProtocolBootPlans", () => {
226
225
  });
227
226
 
228
227
  test("applies boot defaults when no persisted snapshot was restored", async () => {
229
- const { resolveProtocolBootPlans } = await import("./start.ts");
228
+ const { resolveProtocolBootPlans } = await import("./start/index.ts");
230
229
 
231
230
  expect(
232
231
  resolveProtocolBootPlans({
@@ -242,7 +241,7 @@ describe("resolveProtocolBootPlans", () => {
242
241
  });
243
242
 
244
243
  test("treats explicit MQTT disable as authoritative over persisted state", async () => {
245
- const { resolveProtocolBootPlans } = await import("./start.ts");
244
+ const { resolveProtocolBootPlans } = await import("./start/index.ts");
246
245
 
247
246
  expect(
248
247
  resolveProtocolBootPlans({
@@ -254,7 +253,7 @@ describe("resolveProtocolBootPlans", () => {
254
253
  });
255
254
 
256
255
  test("treats explicit CLI MQTT config overrides as authoritative over persisted config", async () => {
257
- const { resolveProtocolBootPlans } = await import("./start.ts");
256
+ const { resolveProtocolBootPlans } = await import("./start/index.ts");
258
257
 
259
258
  expect(
260
259
  resolveProtocolBootPlans({
@@ -266,7 +265,7 @@ describe("resolveProtocolBootPlans", () => {
266
265
  });
267
266
 
268
267
  test("does not treat env fallback config as authoritative over persisted MQTT state", async () => {
269
- const { resolveProtocolBootPlans } = await import("./start.ts");
268
+ const { resolveProtocolBootPlans } = await import("./start/index.ts");
270
269
 
271
270
  expect(
272
271
  resolveProtocolBootPlans({
@@ -278,7 +277,7 @@ describe("resolveProtocolBootPlans", () => {
278
277
  });
279
278
 
280
279
  test("treats explicit OPC UA enable as authoritative over persisted state", async () => {
281
- const { resolveProtocolBootPlans } = await import("./start.ts");
280
+ const { resolveProtocolBootPlans } = await import("./start/index.ts");
282
281
 
283
282
  expect(
284
283
  resolveProtocolBootPlans({
@@ -290,7 +289,7 @@ describe("resolveProtocolBootPlans", () => {
290
289
  });
291
290
 
292
291
  test("does not treat ignored OPC UA config flags as persisted-state overrides", async () => {
293
- const { resolveProtocolBootPlans } = await import("./start.ts");
292
+ const { resolveProtocolBootPlans } = await import("./start/index.ts");
294
293
 
295
294
  expect(
296
295
  resolveProtocolBootPlans({
@@ -304,7 +303,7 @@ describe("resolveProtocolBootPlans", () => {
304
303
 
305
304
  describe("resolveDetachAdapterConfigs", () => {
306
305
  test("uses template adapter configs when no CLI/env overrides are provided", async () => {
307
- const { resolveDetachAdapterConfigs } = await import("./start.ts");
306
+ const { resolveDetachAdapterConfigs } = await import("./start/index.ts");
308
307
 
309
308
  const template = createTemplateWithNamespace({ prefix: "factory" });
310
309
  const withProtocols: MiravoTemplate = {
@@ -330,14 +329,14 @@ describe("resolveDetachAdapterConfigs", () => {
330
329
  modbus: { enabled: true, portOverride: undefined, hostOverride: undefined },
331
330
  }),
332
331
  ).toEqual({
333
- mqtt: { port: 1889, host: "127.0.0.1", embedded: true },
332
+ mqtt: { port: 1889, host: "0.0.0.0", embedded: true },
334
333
  opcua: { port: 4851, host: "127.0.0.1" },
335
334
  modbus: { port: 1602, host: "127.0.0.1" },
336
335
  });
337
336
  });
338
337
 
339
338
  test("CLI/env overrides take precedence over template values", async () => {
340
- const { resolveDetachAdapterConfigs } = await import("./start.ts");
339
+ const { resolveDetachAdapterConfigs } = await import("./start/index.ts");
341
340
 
342
341
  const template = createTemplateWithNamespace({ prefix: "factory" });
343
342
  const withProtocols: MiravoTemplate = {
@@ -374,7 +373,7 @@ describe("resolveDetachAdapterConfigs", () => {
374
373
  });
375
374
 
376
375
  test("returns disabled placeholders when adapters are disabled", async () => {
377
- const { resolveDetachAdapterConfigs } = await import("./start.ts");
376
+ const { resolveDetachAdapterConfigs } = await import("./start/index.ts");
378
377
  const template = createTemplateWithNamespace({ prefix: "factory" });
379
378
 
380
379
  expect(
@@ -398,7 +397,7 @@ describe("resolveDetachAdapterConfigs", () => {
398
397
  });
399
398
 
400
399
  test("marks external MQTT mode as non-embedded for detach conflict checks", async () => {
401
- const { resolveDetachAdapterConfigs } = await import("./start.ts");
400
+ const { resolveDetachAdapterConfigs } = await import("./start/index.ts");
402
401
  const template = createTemplateWithNamespace({ prefix: "factory" });
403
402
 
404
403
  expect(
@@ -424,7 +423,7 @@ describe("resolveDetachAdapterConfigs", () => {
424
423
 
425
424
  describe("error context aggregation", () => {
426
425
  test("normalizeErrorContext redacts high-cardinality fragments", async () => {
427
- const { normalizeErrorContext } = await import("./start.ts");
426
+ const { normalizeErrorContext } = await import("./start/index.ts");
428
427
 
429
428
  expect(normalizeErrorContext('generator="rpm" instance="pump-001" tick=42')).toBe(
430
429
  'generator="rpm" instance="*" tick=*',
@@ -435,7 +434,7 @@ describe("error context aggregation", () => {
435
434
  });
436
435
 
437
436
  test("incrementErrorContextCount caps unique keys and accumulates in overflow bucket", async () => {
438
- const { incrementErrorContextCount } = await import("./start.ts");
437
+ const { incrementErrorContextCount } = await import("./start/index.ts");
439
438
  const counts = new Map<string, number>();
440
439
 
441
440
  for (let i = 0; i < 256; i++) {
@@ -453,7 +452,7 @@ describe("error context aggregation", () => {
453
452
 
454
453
  describe("template picker choices", () => {
455
454
  test("buildBuiltinTemplateChoices renders tree labels directly in select options", async () => {
456
- const { buildBuiltinTemplateChoices } = await import("./start.ts");
455
+ const { buildBuiltinTemplateChoices } = await import("./start/index.ts");
457
456
  const choices = buildBuiltinTemplateChoices([
458
457
  discoveredTemplate("stress-50k", "Stress Benchmark 50K"),
459
458
  discoveredTemplate("water-treatment", "Water Treatment Plant"),
@@ -45,7 +45,7 @@ export function registerStatusCommand(program: Command): void {
45
45
  .option("-w, --watch", "Poll the admin server and re-render on updates")
46
46
  .option("--instance <id>", "Show detailed status for one instance")
47
47
  .option("--json", "Machine-readable JSON output")
48
- .option("--admin-url <url>", "Miravo admin server URL (env: MIRAVO_ADMIN_URL)")
48
+ .option("--admin-url <url>", "Admin API URL (env: MIRAVO_ADMIN_URL)")
49
49
  .option(
50
50
  "--template <path>",
51
51
  "Path to .miravo.yaml for MQTT discovery connection info (env: MIRAVO_TEMPLATE)",
@@ -81,15 +81,26 @@ async function stopAction(name: string, options: StopOptions): Promise<void> {
81
81
  let shutdownSent = false;
82
82
  let canForceKill = true;
83
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, {
84
+ const adminBase = `http://${entry.adminHost}:${entry.adminPort}`;
85
+ // Authenticate with session-based login
86
+ // Resolve password: env > default.
87
+ // If credential file exists, we can't reverse the hash — use "admin" as default.
88
+ // The server will verify against the stored hash. If the user changed their password,
89
+ // they need to set MIRAVO_ADMIN_PASSWORD env var for CLI access.
90
+ const envPassword = Bun.env["MIRAVO_ADMIN_PASSWORD"];
91
+ const password = envPassword && envPassword.trim().length > 0 ? envPassword : "admin";
92
+ const loginResponse = await fetch(`${adminBase}/auth/login`, {
93
+ method: "POST",
94
+ headers: { "content-type": "application/json" },
95
+ body: JSON.stringify({ username: "admin", password }),
96
+ signal: AbortSignal.timeout(2_000),
97
+ });
98
+ const setCookie = loginResponse.ok ? loginResponse.headers.get("set-cookie") : null;
99
+ const cookie = setCookie ? (setCookie.split(";")[0] ?? "") : "";
100
+
101
+ const response = await fetch(`${adminBase}/shutdown`, {
91
102
  method: "POST",
92
- headers,
103
+ headers: { "content-type": "application/json", ...(cookie ? { cookie } : {}) },
93
104
  body: JSON.stringify({ reason: "miravo stop" }),
94
105
  signal: AbortSignal.timeout(2_000),
95
106
  });
@@ -36,7 +36,7 @@ describe("formatTemplateInspect", () => {
36
36
  expect(output).toContain("Namespace:");
37
37
  expect(output).toContain("Twin assignments:");
38
38
  expect(output).toContain("Areas:");
39
- expect(output).toContain("production");
39
+ expect(output).toContain("machining");
40
40
  });
41
41
  });
42
42
 
@@ -19,7 +19,7 @@ export function registerTemplatesCommand(program: Command): void {
19
19
  templates
20
20
  .command("list")
21
21
  .description("Discover and list available templates")
22
- .option("--dir <path>", "Additional directory to search for templates")
22
+ .option("--dir <path>", "Additional directory to search for .miravo.yaml templates")
23
23
  .addHelpText(
24
24
  "after",
25
25
  `
@@ -275,5 +275,3 @@ export function formatTemplateInspect(
275
275
 
276
276
  return lines.join("\n");
277
277
  }
278
-
279
- export type { DiscoveredTemplate };
package/src/index.ts CHANGED
@@ -2,19 +2,23 @@
2
2
  import { MIRAVO_VERSION } from "@miravo/shared";
3
3
  import { Command, InvalidOptionArgumentError } from "commander";
4
4
  import { registerAdapterCommand } from "./commands/adapter.ts";
5
+ import { registerApplyCommand } from "./commands/apply.ts";
6
+ import { registerCleanupCommand } from "./commands/cleanup.ts";
7
+ import { registerExportCommand } from "./commands/export.ts";
5
8
  import { registerForkCommand } from "./commands/fork.ts";
6
9
  import { registerInjectCommand } from "./commands/inject.ts";
7
10
  import { registerLoadCommand } from "./commands/load.ts";
8
11
  import { registerLogsCommand } from "./commands/logs.ts";
9
12
  import { registerModelsCommand } from "./commands/models.ts";
10
13
  import { registerPauseCommand, registerResumeCommand } from "./commands/pause.ts";
14
+ import { registerEnvironmentsCommand } from "./commands/platforms.ts";
11
15
  import { registerPsCommand } from "./commands/ps.ts";
12
16
  import { registerRemoveCommand } from "./commands/remove.ts";
13
17
  import { registerResetCommand } from "./commands/reset.ts";
14
18
  import { registerRmCommand } from "./commands/rm.ts";
15
19
  import { registerSpawnCommand } from "./commands/spawn.ts";
16
20
  import { registerSpeedCommand } from "./commands/speed.ts";
17
- import { registerStartCommand } from "./commands/start.ts";
21
+ import { registerStartCommand } from "./commands/start/index.ts";
18
22
  import { registerStatusCommand } from "./commands/status.ts";
19
23
  import { registerStopCommand } from "./commands/stop.ts";
20
24
  import { registerTemplatesCommand } from "./commands/templates.ts";
@@ -57,7 +61,7 @@ export function createProgram(): Command {
57
61
  },
58
62
  "pretty",
59
63
  )
60
- .addHelpText("afterAll", "\nDocs: https://github.com/amine-amaach/miravo")
64
+ .addHelpText("afterAll", "\nDocs: https://miravo.dev/docs")
61
65
  .action(() => {
62
66
  writeOutput(`miravo v${MIRAVO_VERSION} — Industrial environment platform
63
67
 
@@ -67,7 +71,7 @@ Quick start:
67
71
  $ miravo ps List background simulations
68
72
 
69
73
  Run miravo --help for all commands.
70
- Docs: https://github.com/amine-amaach/miravo`);
74
+ Docs: https://miravo.dev/docs`);
71
75
  })
72
76
  .hook("preAction", (_thisCommand, actionCommand) => {
73
77
  const rootOpts = program.opts<{
@@ -111,6 +115,10 @@ Docs: https://github.com/amine-amaach/miravo`);
111
115
  registerResetCommand(program);
112
116
  registerInjectCommand(program);
113
117
  registerAdapterCommand(program);
118
+ registerExportCommand(program);
119
+ registerApplyCommand(program);
120
+ registerCleanupCommand(program);
121
+ registerEnvironmentsCommand(program);
114
122
 
115
123
  // Content discovery
116
124
  registerModelsCommand(program);
@@ -0,0 +1,157 @@
1
+ /**
2
+ * Auth route handlers for the Miravo admin server.
3
+ *
4
+ * Each exported function handles a single auth endpoint and returns a Response.
5
+ */
6
+
7
+ import { ChangePasswordRequestSchema, LoginRequestSchema } from "@miravo/shared";
8
+ import { checkContentType, jsonResponse } from "./admin-responses.ts";
9
+ import type { SessionStore } from "./session-store.ts";
10
+ import { buildClearCookie, buildSessionCookie, extractSessionId } from "./session-store.ts";
11
+
12
+ /** Minimal auth configuration needed by auth handlers. */
13
+ interface AuthConfig {
14
+ readonly verifyPassword: (password: string) => Promise<boolean>;
15
+ readonly isDefaultPassword: boolean;
16
+ readonly sessionStore: SessionStore;
17
+ readonly changePassword: (oldPassword: string, newPassword: string) => Promise<void>;
18
+ readonly anonymousMode?: boolean;
19
+ }
20
+
21
+ /**
22
+ * POST /auth/login
23
+ * Validates credentials and sets a session cookie on success.
24
+ */
25
+ export async function handleLogin(request: Request, auth: AuthConfig): Promise<Response> {
26
+ if (auth.anonymousMode) {
27
+ const session = auth.sessionStore.create("anonymous");
28
+ return new Response(JSON.stringify({ ok: true, changePassword: false }), {
29
+ status: 200,
30
+ headers: {
31
+ "content-type": "application/json; charset=utf-8",
32
+ "set-cookie": buildSessionCookie(session.id),
33
+ "cache-control": "no-store",
34
+ },
35
+ });
36
+ }
37
+
38
+ const ctBlock = checkContentType(request);
39
+ if (ctBlock) return ctBlock;
40
+
41
+ let payload: unknown;
42
+ try {
43
+ payload = await request.json();
44
+ } catch {
45
+ return jsonResponse({ message: "Invalid JSON.", error: "Send valid JSON." }, 400);
46
+ }
47
+
48
+ const validation = LoginRequestSchema.safeParse(payload);
49
+ if (!validation.success) {
50
+ return jsonResponse(
51
+ { message: "Invalid login request.", error: "Provide username and password." },
52
+ 400,
53
+ );
54
+ }
55
+ if (validation.data.username !== "admin") {
56
+ return jsonResponse(
57
+ { message: "Invalid credentials.", error: "Check username and password." },
58
+ 401,
59
+ );
60
+ }
61
+ const passwordValid = await auth.verifyPassword(validation.data.password);
62
+ if (!passwordValid) {
63
+ return jsonResponse(
64
+ { message: "Invalid credentials.", error: "Check username and password." },
65
+ 401,
66
+ );
67
+ }
68
+
69
+ const session = auth.sessionStore.create("admin");
70
+ return new Response(JSON.stringify({ ok: true, changePassword: auth.isDefaultPassword }), {
71
+ status: 200,
72
+ headers: {
73
+ "content-type": "application/json; charset=utf-8",
74
+ "set-cookie": buildSessionCookie(session.id),
75
+ "cache-control": "no-store",
76
+ },
77
+ });
78
+ }
79
+
80
+ /**
81
+ * POST /auth/logout
82
+ * Destroys the current session and clears the session cookie.
83
+ */
84
+ export function handleLogout(request: Request, auth: AuthConfig): Response {
85
+ const sessionId = extractSessionId(request);
86
+ if (sessionId) auth.sessionStore.destroy(sessionId);
87
+ return new Response(JSON.stringify({ ok: true }), {
88
+ status: 200,
89
+ headers: {
90
+ "content-type": "application/json; charset=utf-8",
91
+ "set-cookie": buildClearCookie(),
92
+ "cache-control": "no-store",
93
+ },
94
+ });
95
+ }
96
+
97
+ /**
98
+ * PUT /auth/password
99
+ * Changes the admin password. Requires an active session.
100
+ * The caller is responsible for verifying the session before calling this handler.
101
+ */
102
+ export async function handlePasswordChange(request: Request, auth: AuthConfig): Promise<Response> {
103
+ const ctBlock = checkContentType(request);
104
+ if (ctBlock) return ctBlock;
105
+
106
+ let payload: unknown;
107
+ try {
108
+ payload = await request.json();
109
+ } catch {
110
+ return jsonResponse({ message: "Invalid JSON.", error: "Send valid JSON." }, 400);
111
+ }
112
+
113
+ const validation = ChangePasswordRequestSchema.safeParse(payload);
114
+ if (!validation.success) {
115
+ return jsonResponse(
116
+ {
117
+ message: "Invalid request.",
118
+ error: "Provide oldPassword and newPassword (min 6 chars).",
119
+ },
120
+ 400,
121
+ );
122
+ }
123
+
124
+ try {
125
+ await auth.changePassword(validation.data.oldPassword, validation.data.newPassword);
126
+ } catch (err) {
127
+ const msg = err instanceof Error ? err.message : "Password change failed";
128
+ return jsonResponse({ message: msg, error: "Check old password." }, 400);
129
+ }
130
+
131
+ // Invalidate all existing sessions, then issue a fresh one for the caller
132
+ auth.sessionStore.clear();
133
+ const session = auth.sessionStore.create("admin");
134
+ return new Response(JSON.stringify({ ok: true }), {
135
+ status: 200,
136
+ headers: {
137
+ "content-type": "application/json",
138
+ "set-cookie": buildSessionCookie(session.id),
139
+ },
140
+ });
141
+ }
142
+
143
+ /**
144
+ * GET /auth/status
145
+ * Returns authentication state for the current session cookie.
146
+ */
147
+ export function handleAuthStatus(request: Request, auth: AuthConfig): Response {
148
+ if (auth.anonymousMode) {
149
+ return jsonResponse({ authenticated: true, username: "anonymous" }, 200);
150
+ }
151
+ const sessionId = extractSessionId(request);
152
+ const session = sessionId ? auth.sessionStore.validate(sessionId) : null;
153
+ if (session) {
154
+ return jsonResponse({ authenticated: true, username: session.username }, 200);
155
+ }
156
+ return jsonResponse({ authenticated: false }, 200);
157
+ }
@@ -6,29 +6,45 @@ const originalFetch = globalThis.fetch;
6
6
 
7
7
  afterEach(() => {
8
8
  globalThis.fetch = originalFetch;
9
- delete Bun.env["MIRAVO_ADMIN_TOKEN"];
9
+ delete Bun.env["MIRAVO_ADMIN_PASSWORD"];
10
10
  });
11
11
 
12
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
- });
13
+ test("authenticates via session login and sends cookie on command requests", async () => {
14
+ let loginCalled = false;
15
+ let commandCookie = "";
16
+
17
+ globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => {
18
+ const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
19
+
20
+ // Login request
21
+ if (url.endsWith("/auth/login")) {
22
+ loginCalled = true;
23
+ return new Response(JSON.stringify({ ok: true, changePassword: false }), {
24
+ status: 200,
25
+ headers: { "set-cookie": "miravo_session=test-session-id; HttpOnly; Path=/" },
26
+ });
27
+ }
28
+
29
+ // Command request — verify session cookie is sent
30
+ if (url.endsWith("/commands")) {
31
+ commandCookie = (init?.headers as Record<string, string>)?.["cookie"] ?? "";
32
+ return Response.json({
33
+ success: true,
34
+ command: "pause",
35
+ correlationId: "req-1",
36
+ message: "paused",
37
+ });
38
+ }
39
+
40
+ return Response.json({ message: "Not found" }, { status: 404 });
27
41
  }) as typeof fetch;
28
42
 
29
43
  const command: EngineCommand = { type: "pause", correlationId: "req-1" };
30
44
  const result = await executeAdminCommand("http://127.0.0.1:8080", command, 1_000);
31
45
 
46
+ expect(loginCalled).toBe(true);
47
+ expect(commandCookie).toBe("miravo_session=test-session-id");
32
48
  expect(result.success).toBe(true);
33
49
  expect(result.command).toBe("pause");
34
50
  });
@@ -10,6 +10,7 @@ import {
10
10
  EngineStateSnapshotSchema,
11
11
  MiravoError,
12
12
  } from "@miravo/shared";
13
+ import { loadCredential } from "./auth-store.ts";
13
14
 
14
15
  const DEFAULT_ADMIN_HOST = "127.0.0.1";
15
16
  const DEFAULT_ADMIN_PORT = 8080;
@@ -87,19 +88,67 @@ function parseAdminUrl(input: string): string {
87
88
  return url.toString().replace(/\/$/, "");
88
89
  }
89
90
 
90
- function buildRequestHeaders(body?: string): Record<string, string> | undefined {
91
+ // ---------------------------------------------------------------------------
92
+ // Session-based auth for CLI client
93
+ // ---------------------------------------------------------------------------
94
+
95
+ let sessionCookie: string | null = null;
96
+
97
+ async function loginToAdmin(adminUrl: string): Promise<void> {
98
+ // Resolve password: env > file > default
99
+ const envPassword = Bun.env["MIRAVO_ADMIN_PASSWORD"];
100
+ let password = "admin";
101
+ if (envPassword && envPassword.trim().length > 0) {
102
+ password = envPassword;
103
+ } else {
104
+ const credential = await loadCredential();
105
+ // If credential file exists, we can't reverse the hash — use "admin" as default.
106
+ // The server will verify against the stored hash. If the user changed their password,
107
+ // they need to set MIRAVO_ADMIN_PASSWORD env var for CLI access.
108
+ if (!credential) {
109
+ password = "admin";
110
+ }
111
+ }
112
+
113
+ const response = await fetch(`${adminUrl}/auth/login`, {
114
+ method: "POST",
115
+ headers: { "content-type": "application/json" },
116
+ body: JSON.stringify({ username: "admin", password }),
117
+ signal: AbortSignal.timeout(10_000),
118
+ });
119
+
120
+ if (!response.ok) {
121
+ throw new MiravoError(
122
+ "CLI authentication failed.",
123
+ "Set MIRAVO_ADMIN_PASSWORD or log in with default credentials (admin/admin).",
124
+ );
125
+ }
126
+
127
+ const setCookie = response.headers.get("set-cookie");
128
+ if (setCookie) {
129
+ // Extract just the cookie value (before first ;)
130
+ sessionCookie = setCookie.split(";")[0] ?? null;
131
+ }
132
+ }
133
+
134
+ async function ensureAuthenticated(adminUrl: string): Promise<void> {
135
+ if (sessionCookie) return;
136
+ await loginToAdmin(adminUrl);
137
+ }
138
+
139
+ function buildRequestHeaders(body?: string): Record<string, string> {
91
140
  const headers: Record<string, string> = {};
92
141
  if (body) {
93
142
  headers["content-type"] = "application/json";
94
143
  }
95
- const adminToken = Bun.env["MIRAVO_ADMIN_TOKEN"];
96
- if (adminToken && adminToken.trim().length > 0) {
97
- headers["authorization"] = `Bearer ${adminToken}`;
144
+ if (sessionCookie) {
145
+ headers["cookie"] = sessionCookie;
98
146
  }
99
- return Object.keys(headers).length > 0 ? headers : undefined;
147
+ return headers;
100
148
  }
101
149
 
102
150
  async function requestJson<T>(options: RequestJsonOptions<T>): Promise<T> {
151
+ await ensureAuthenticated(options.baseUrl);
103
152
  const requestUrl = new URL(options.path, `${options.baseUrl}/`).toString();
104
153
  const controller = new AbortController();
105
154
  const timer = setTimeout(() => controller.abort(), options.timeoutMs);
@@ -128,6 +177,18 @@ async function requestJson<T>(options: RequestJsonOptions<T>): Promise<T> {
128
177
  );
129
178
  }
130
179
 
180
+ // Re-authenticate on session expiry and retry once
181
+ if (response.status === 401) {
182
+ sessionCookie = null;
183
+ await loginToAdmin(options.baseUrl);
184
+ response = await fetch(requestUrl, {
185
+ method: options.method ?? "GET",
186
+ headers: buildRequestHeaders(options.body),
187
+ body: options.body,
188
+ signal: controller.signal,
189
+ });
190
+ }
191
+
131
192
  const text = await response.text();
132
193
  let parsedJson: unknown;
133
194
  if (text.length > 0) {
@@ -223,4 +284,43 @@ async function executeAdminCommand(
223
284
  });
224
285
  }
225
286
 
226
- export { executeAdminCommand, getAdminMetrics, getAdminSnapshot, getAdminState, resolveAdminUrl };
287
+ /** Schema that accepts any JSON object — used for adapter introspection (protocol-specific shape). */
288
+ const AnyObjectSchema = {
289
+ safeParse(value: unknown): SafeParseResult<Record<string, unknown>> {
290
+ if (typeof value === "object" && value !== null && !Array.isArray(value)) {
291
+ return { success: true, data: value as Record<string, unknown> };
292
+ }
293
+ return { success: false };
294
+ },
295
+ };
296
+
297
+ async function getManifest(adminUrl: string, timeoutMs: number): Promise<Record<string, unknown>> {
298
+ return requestJson({
299
+ baseUrl: adminUrl,
300
+ path: "/manifest",
301
+ timeoutMs,
302
+ schema: AnyObjectSchema,
303
+ });
304
+ }
305
+
306
+ async function getLeDtJson(
307
+ adminUrl: string,
308
+ modelName: string,
309
+ timeoutMs: number,
310
+ ): Promise<Record<string, unknown>> {
311
+ return requestJson({
312
+ baseUrl: adminUrl,
313
+ path: `/catalog/models/${encodeURIComponent(modelName)}/le-dt.json`,
314
+ timeoutMs,
315
+ schema: AnyObjectSchema,
316
+ });
317
+ }
318
+
319
+ export {
320
+ executeAdminCommand,
321
+ getAdminSnapshot,
322
+ getAdminState,
323
+ getLeDtJson,
324
+ getManifest,
325
+ resolveAdminUrl,
326
+ };