miravo 0.1.4 → 1.0.5

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 CHANGED
@@ -87,24 +87,29 @@ miravo start --content-dir ./my-content
87
87
 
88
88
  Start the simulation engine with an embedded MQTT broker.
89
89
 
90
- | Option | Description |
91
- | ----------------------------- | ----------------------------------------------------------------------------- |
92
- | `--template <id\|name\|path>` | Template to run (env: `MIRAVO_TEMPLATE`) |
93
- | `--content-dir <path>` | User content root with `/templates` and `/models` (env: `MIRAVO_CONTENT_DIR`) |
94
- | `--models-dir <path>` | Additional `.twin.yaml` model directory |
95
- | `--speed <multiplier>` | Time multiplier (default: `1`) |
96
- | `--seed <number>` | Deterministic RNG seed |
97
- | `--tick <ms>` | Tick interval in ms (default: `1000`) |
98
- | `--port <number>` | MQTT port (default: `1883`, env: `MIRAVO_MQTT_PORT`) |
99
- | `--mqtt-host <host>` | MQTT bind host (default: `127.0.0.1`, env: `MIRAVO_MQTT_HOST`) |
100
- | `--mqtt-mode <mode>` | `embedded` or `external` (env: `MIRAVO_MQTT_MODE`) |
101
- | `--mqtt-url <url>` | External broker URL (env: `MIRAVO_MQTT_URL`) |
102
- | `--mqtt-username <user>` | External broker username (env: `MIRAVO_MQTT_USERNAME`) |
103
- | `--mqtt-password <pass>` | External broker password (env: `MIRAVO_MQTT_PASSWORD`) |
104
- | `--health-host <host>` | Health server host (default: `0.0.0.0`, env: `MIRAVO_HEALTH_HOST`) |
105
- | `--health-port <number>` | Health server port (default: `8080`, env: `MIRAVO_HEALTH_PORT`) |
106
- | `--dry-run` | Validate and print summary without starting |
107
- | `--summary-interval <s>` | Seconds between periodic summaries (default: `5`) |
90
+ | Option | Description |
91
+ | -------------------------------- | ----------------------------------------------------------------------------- |
92
+ | `-t, --template <id\|name\|path>`| Template to run (env: `MIRAVO_TEMPLATE`) |
93
+ | `-n, --name <name>` | Simulation name for control topics (env: `MIRAVO_NAME`) |
94
+ | `--content-dir <path>` | User content root with `/templates` and `/models` (env: `MIRAVO_CONTENT_DIR`) |
95
+ | `--models-dir <path>` | Additional `.twin.yaml` model directory |
96
+ | `-s, --speed <multiplier>` | Time multiplier (default: `1`) |
97
+ | `--seed <number>` | Deterministic RNG seed |
98
+ | `--tick <ms>` | Tick interval in ms (default: `1000`) |
99
+ | `-p, --mqtt-port <number>` | MQTT port (default: `1883`, env: `MIRAVO_MQTT_PORT`) |
100
+ | `--mqtt-host <host>` | MQTT bind host (default: `127.0.0.1`, env: `MIRAVO_MQTT_HOST`) |
101
+ | `--mqtt-mode <mode>` | `embedded` or `external` (env: `MIRAVO_MQTT_MODE`) |
102
+ | `--mqtt-url <url>` | External broker URL (env: `MIRAVO_MQTT_URL`) |
103
+ | `--mqtt-username <user>` | External broker username (env: `MIRAVO_MQTT_USERNAME`) |
104
+ | `--mqtt-password <pass>` | External broker password (env: `MIRAVO_MQTT_PASSWORD`) |
105
+ | `--no-mqtt` | Disable MQTT broker |
106
+ | `--opcua` | Enable OPC UA server |
107
+ | `--opcua-port <number>` | OPC UA port (default: `4840`, env: `MIRAVO_OPCUA_PORT`) |
108
+ | `--opcua-host <host>` | OPC UA bind host (default: `0.0.0.0`, env: `MIRAVO_OPCUA_HOST`) |
109
+ | `--health-host <host>` | Health server host (default: `0.0.0.0`, env: `MIRAVO_HEALTH_HOST`) |
110
+ | `--health-port <number>` | Health server port (default: `8080`, env: `MIRAVO_HEALTH_PORT`) |
111
+ | `-d, --dry-run` | Validate and print summary without starting |
112
+ | `--summary-interval <s>` | Seconds between periodic summaries (default: `5`) |
108
113
 
109
114
  ### `miravo templates list`
110
115
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "miravo",
3
- "version": "0.1.4",
3
+ "version": "1.0.5",
4
4
  "type": "module",
5
5
  "description": "Digital twin engine for Industrial IoT simulation",
6
6
  "license": "Apache-2.0",
@@ -31,9 +31,10 @@
31
31
  ],
32
32
  "dependencies": {
33
33
  "@clack/prompts": "^1.1.0",
34
- "@miravo/core": "0.1.4",
35
- "@miravo/mqtt": "0.1.4",
36
- "@miravo/shared": "0.1.4",
34
+ "@miravo/core": "1.0.5",
35
+ "@miravo/mqtt": "1.0.5",
36
+ "@miravo/opcua": "1.0.5",
37
+ "@miravo/shared": "1.0.5",
37
38
  "commander": "^13.0.0",
38
39
  "consola": "^3.4.0",
39
40
  "mqtt": "^5.15.0"
@@ -12,11 +12,13 @@ import {
12
12
  validateFullTemplate,
13
13
  } from "@miravo/core";
14
14
  import { MQTTAdapter } from "@miravo/mqtt";
15
+ import { OPCUAAdapter } from "@miravo/opcua";
15
16
  import type { DigitalTwinModel, DiscoveredTemplate, MiravoTemplate } from "@miravo/shared";
16
17
  import {
17
18
  ConfigNotFoundError,
18
19
  ConfigValidationError,
19
20
  DEFAULT_MQTT_PORT,
21
+ DEFAULT_OPCUA_PORT,
20
22
  DEFAULT_TICK_INTERVAL_MS,
21
23
  DEFAULT_TIME_MULTIPLIER,
22
24
  MIRAVO_VERSION,
@@ -24,7 +26,7 @@ import {
24
26
  SimulationNameSchema,
25
27
  } from "@miravo/shared";
26
28
  import type { Command } from "commander";
27
- import { box, colors } from "consola/utils";
29
+ import { box, colors, stripAnsi } from "consola/utils";
28
30
  import { startHealthServer } from "../lib/health-server.ts";
29
31
  import {
30
32
  parseDurationMs,
@@ -53,6 +55,7 @@ import {
53
55
  isPlain,
54
56
  lifecycleLog,
55
57
  mqttLog,
58
+ opcuaLog,
56
59
  setRuntimeLogContext,
57
60
  writeOutput,
58
61
  } from "../logger.ts";
@@ -66,12 +69,16 @@ interface StartOptions {
66
69
  readonly speed?: number;
67
70
  readonly seed?: number;
68
71
  readonly tick?: number;
69
- readonly port?: number;
72
+ readonly mqttPort?: number;
70
73
  readonly mqttHost?: string;
71
74
  readonly mqttMode?: string;
72
75
  readonly mqttUrl?: string;
73
76
  readonly mqttUsername?: string;
74
77
  readonly mqttPassword?: string;
78
+ readonly mqtt?: boolean;
79
+ readonly opcuaPort?: number;
80
+ readonly opcuaHost?: string;
81
+ readonly opcua?: boolean;
75
82
  readonly healthHost?: string;
76
83
  readonly healthPort?: number;
77
84
  readonly dryRun?: boolean;
@@ -99,9 +106,9 @@ export function registerStartCommand(program: Command): void {
99
106
  program
100
107
  .command("start")
101
108
  .description("Start the digital twin simulation")
102
- .option("--name <name>", "Simulation name for control topics (env: MIRAVO_NAME)")
109
+ .option("-n, --name <name>", "Simulation name for control topics (env: MIRAVO_NAME)")
103
110
  .option(
104
- "--template <id|name|path>",
111
+ "-t, --template <id|name|path>",
105
112
  "Template identifier, display name, or .miravo.yaml path (env: MIRAVO_TEMPLATE)",
106
113
  )
107
114
  .option("--models-dir <path>", "Additional directory to search for .twin.yaml models")
@@ -109,26 +116,27 @@ export function registerStartCommand(program: Command): void {
109
116
  "--content-dir <path>",
110
117
  "User content root with /templates and /models (env: MIRAVO_CONTENT_DIR)",
111
118
  )
112
- .option("--speed <multiplier>", "Time multiplier override (default: 1)", parsePositiveFloat)
119
+ .option("-s, --speed <multiplier>", "Time multiplier (default: 1)", parsePositiveFloat)
113
120
  .option("--seed <number>", "RNG seed for deterministic simulation", parseNonNegativeInt)
121
+ .option("--tick <ms>", "Tick interval in milliseconds (default: 1000)", parseDurationMs)
114
122
  .option(
115
- "--tick <ms>",
116
- "Tick interval override in milliseconds (default: 1000)",
117
- parseDurationMs,
118
- )
119
- .option(
120
- "--port <number>",
121
- "MQTT broker port override (default: 1883, env: MIRAVO_MQTT_PORT)",
123
+ "-p, --mqtt-port <number>",
124
+ "MQTT broker port (default: 1883, env: MIRAVO_MQTT_PORT)",
122
125
  parsePort,
123
126
  )
124
- .option(
125
- "--mqtt-host <host>",
126
- "Embedded MQTT bind host override (default: 127.0.0.1, env: MIRAVO_MQTT_HOST)",
127
- )
128
- .option("--mqtt-mode <mode>", "MQTT mode override: embedded | external (env: MIRAVO_MQTT_MODE)")
127
+ .option("--mqtt-host <host>", "MQTT bind host (default: 127.0.0.1, env: MIRAVO_MQTT_HOST)")
128
+ .option("--mqtt-mode <mode>", "MQTT mode: embedded | external (env: MIRAVO_MQTT_MODE)")
129
129
  .option("--mqtt-url <url>", "External MQTT broker URL (env: MIRAVO_MQTT_URL)")
130
130
  .option("--mqtt-username <username>", "External MQTT username (env: MIRAVO_MQTT_USERNAME)")
131
131
  .option("--mqtt-password <password>", "External MQTT password (env: MIRAVO_MQTT_PASSWORD)")
132
+ .option("--no-mqtt", "Disable MQTT broker")
133
+ .option("--opcua", "Enable OPC UA server")
134
+ .option(
135
+ "--opcua-port <number>",
136
+ "OPC UA server port (default: 4840, env: MIRAVO_OPCUA_PORT)",
137
+ parsePort,
138
+ )
139
+ .option("--opcua-host <host>", "OPC UA server bind host (env: MIRAVO_OPCUA_HOST)")
132
140
  .option(
133
141
  "--health-host <host>",
134
142
  "Health server host (default: 0.0.0.0, env: MIRAVO_HEALTH_HOST)",
@@ -138,7 +146,7 @@ export function registerStartCommand(program: Command): void {
138
146
  "Health server port (default: 8080, env: MIRAVO_HEALTH_PORT)",
139
147
  parsePort,
140
148
  )
141
- .option("--dry-run", "Validate config and print summary without starting")
149
+ .option("-d, --dry-run", "Validate config and print summary without starting")
142
150
  .option(
143
151
  "--summary-interval <seconds>",
144
152
  "Seconds between periodic summary lines (default: 5)",
@@ -155,50 +163,115 @@ export function registerStartCommand(program: Command): void {
155
163
 
156
164
  async function startAction(options: StartOptions): Promise<void> {
157
165
  let runtimeLogContextSet = false;
166
+ /** Adapter stop functions accumulated during setup. Drained on partial startup failure. */
167
+ const pendingAdapterStops: Array<() => Promise<void>> = [];
158
168
  try {
159
169
  const catalog = createCatalog(options.contentDir);
160
170
 
161
171
  // Resolve env-var fallbacks for common options
162
172
  const templateOption = options.template ?? Bun.env["MIRAVO_TEMPLATE"];
163
173
  const simulationNameEnv = Bun.env["MIRAVO_NAME"];
164
- const portEnv = Bun.env["MIRAVO_MQTT_PORT"];
165
- const mqttHost = options.mqttHost ?? Bun.env["MIRAVO_MQTT_HOST"];
166
- const mqttMode = parseMqttModeOption(options.mqttMode ?? Bun.env["MIRAVO_MQTT_MODE"]);
167
- const mqttUrl = options.mqttUrl ?? Bun.env["MIRAVO_MQTT_URL"];
168
- const mqttUsername = options.mqttUsername ?? Bun.env["MIRAVO_MQTT_USERNAME"];
169
- const mqttPassword = options.mqttPassword ?? Bun.env["MIRAVO_MQTT_PASSWORD"];
170
- const healthHost = options.healthHost ?? Bun.env["MIRAVO_HEALTH_HOST"] ?? "0.0.0.0";
171
- const healthPortEnv = Bun.env["MIRAVO_HEALTH_PORT"];
172
- const mqttOverrides: MqttRunOverrides = {
173
- ...(mqttMode !== undefined ? { mode: mqttMode } : {}),
174
- ...(mqttHost !== undefined && mqttHost.length > 0 ? { host: mqttHost } : {}),
175
- ...(mqttUrl !== undefined && mqttUrl.length > 0 ? { url: mqttUrl } : {}),
176
- ...(mqttUsername !== undefined && mqttUsername.length > 0 ? { username: mqttUsername } : {}),
177
- ...(mqttPassword !== undefined && mqttPassword.length > 0 ? { password: mqttPassword } : {}),
178
- };
179
- let portOption = options.port;
180
- if (portOption === undefined && portEnv) {
181
- try {
182
- portOption = parsePort(portEnv);
183
- } catch {
184
- throw new ConfigValidationError({
185
- message: `Invalid MIRAVO_MQTT_PORT environment variable: "${portEnv}".`,
186
- fix: "Set MIRAVO_MQTT_PORT to a valid port number between 1 and 65535 (for example, 1883).",
187
- });
174
+ // Protocol selection: CLI flags decide which protocols run
175
+ const mqttEnabled = options.mqtt !== false; // --no-mqtt disables
176
+ const opcuaEnabled = options.opcua === true; // --opcua enables
177
+
178
+ // Warn on ignored protocol-specific flags
179
+ if (!mqttEnabled) {
180
+ if (options.mqttPort !== undefined) {
181
+ configLog.warn("--mqtt-port ignored because MQTT is disabled.");
182
+ }
183
+ if (options.mqttHost !== undefined) {
184
+ configLog.warn("--mqtt-host ignored because MQTT is disabled.");
185
+ }
186
+ if (options.mqttMode !== undefined) {
187
+ configLog.warn("--mqtt-mode ignored because MQTT is disabled.");
188
188
  }
189
189
  }
190
+ if (!opcuaEnabled) {
191
+ if (options.opcuaPort !== undefined) {
192
+ configLog.warn(
193
+ "--opcua-port ignored because OPC UA is not enabled. Use --opcua to enable it.",
194
+ );
195
+ }
196
+ if (options.opcuaHost !== undefined) {
197
+ configLog.warn(
198
+ "--opcua-host ignored because OPC UA is not enabled. Use --opcua to enable it.",
199
+ );
200
+ }
201
+ }
202
+
203
+ const healthHost = options.healthHost ?? Bun.env["MIRAVO_HEALTH_HOST"] ?? "0.0.0.0";
204
+ const healthPortEnv = Bun.env["MIRAVO_HEALTH_PORT"];
190
205
  if (healthHost.trim().length === 0) {
191
206
  throw new ConfigValidationError({
192
207
  message: "Invalid health server host: value is empty.",
193
208
  fix: "Set --health-host / MIRAVO_HEALTH_HOST to a valid host (for example, 0.0.0.0).",
194
209
  });
195
210
  }
196
- if (mqttHost !== undefined && mqttHost.trim().length === 0) {
197
- throw new ConfigValidationError({
198
- message: "Invalid MQTT bind host: value is empty.",
199
- fix: "Set --mqtt-host / MIRAVO_MQTT_HOST to a valid host (for example, 127.0.0.1 or 0.0.0.0).",
200
- });
211
+
212
+ // MQTT env overrides — only parsed when MQTT is enabled
213
+ let portOption = options.mqttPort;
214
+ let mqttOverrides: MqttRunOverrides = {};
215
+ if (mqttEnabled) {
216
+ const portEnv = Bun.env["MIRAVO_MQTT_PORT"];
217
+ const mqttHost = options.mqttHost ?? Bun.env["MIRAVO_MQTT_HOST"];
218
+ const mqttMode = parseMqttModeOption(options.mqttMode ?? Bun.env["MIRAVO_MQTT_MODE"]);
219
+ const mqttUrl = options.mqttUrl ?? Bun.env["MIRAVO_MQTT_URL"];
220
+ const mqttUsername = options.mqttUsername ?? Bun.env["MIRAVO_MQTT_USERNAME"];
221
+ const mqttPassword = options.mqttPassword ?? Bun.env["MIRAVO_MQTT_PASSWORD"];
222
+ mqttOverrides = {
223
+ ...(mqttMode !== undefined ? { mode: mqttMode } : {}),
224
+ ...(mqttHost !== undefined && mqttHost.length > 0 ? { host: mqttHost } : {}),
225
+ ...(mqttUrl !== undefined && mqttUrl.length > 0 ? { url: mqttUrl } : {}),
226
+ ...(mqttUsername !== undefined && mqttUsername.length > 0
227
+ ? { username: mqttUsername }
228
+ : {}),
229
+ ...(mqttPassword !== undefined && mqttPassword.length > 0
230
+ ? { password: mqttPassword }
231
+ : {}),
232
+ };
233
+ if (portOption === undefined && portEnv) {
234
+ try {
235
+ portOption = parsePort(portEnv);
236
+ } catch {
237
+ throw new ConfigValidationError({
238
+ message: `Invalid MIRAVO_MQTT_PORT environment variable: "${portEnv}".`,
239
+ fix: "Set MIRAVO_MQTT_PORT to a valid port number between 1 and 65535 (for example, 1883).",
240
+ });
241
+ }
242
+ }
243
+ if (mqttHost !== undefined && mqttHost.trim().length === 0) {
244
+ throw new ConfigValidationError({
245
+ message: "Invalid MQTT bind host: value is empty.",
246
+ fix: "Set --mqtt-host / MIRAVO_MQTT_HOST to a valid host (for example, 127.0.0.1 or 0.0.0.0).",
247
+ });
248
+ }
201
249
  }
250
+
251
+ // OPC UA env overrides — only parsed when OPC UA is enabled
252
+ let opcuaPort: number | undefined = options.opcuaPort;
253
+ let opcuaHost: string | undefined;
254
+ if (opcuaEnabled) {
255
+ const opcuaPortEnv = Bun.env["MIRAVO_OPCUA_PORT"];
256
+ if (opcuaPort === undefined && opcuaPortEnv) {
257
+ try {
258
+ opcuaPort = parsePort(opcuaPortEnv);
259
+ } catch {
260
+ throw new ConfigValidationError({
261
+ message: `Invalid MIRAVO_OPCUA_PORT environment variable: "${opcuaPortEnv}".`,
262
+ fix: "Set MIRAVO_OPCUA_PORT to a valid port number between 1 and 65535 (for example, 4840).",
263
+ });
264
+ }
265
+ }
266
+ opcuaHost = options.opcuaHost ?? Bun.env["MIRAVO_OPCUA_HOST"] ?? undefined;
267
+ if (opcuaHost !== undefined && opcuaHost.trim().length === 0) {
268
+ throw new ConfigValidationError({
269
+ message: "Invalid OPC UA bind host: value is empty.",
270
+ fix: "Set --opcua-host / MIRAVO_OPCUA_HOST to a valid host (for example, 0.0.0.0).",
271
+ });
272
+ }
273
+ }
274
+
202
275
  let healthPort = options.healthPort;
203
276
  if (healthPort === undefined && healthPortEnv) {
204
277
  try {
@@ -251,15 +324,18 @@ async function startAction(options: StartOptions): Promise<void> {
251
324
  simulationName,
252
325
  );
253
326
 
254
- // Step 5: Start MQTT adapter
255
- const mqttStartResult = await startMQTTAdapter(
256
- engine,
257
- template,
258
- portOption,
259
- mqttOverrides,
260
- simulationName,
261
- );
327
+ // Step 5: Start MQTT adapter (unless --no-mqtt)
328
+ const mqttStartResult = mqttEnabled
329
+ ? await startMQTTAdapter(engine, template, portOption, mqttOverrides, simulationName)
330
+ : null;
262
331
  const adapter = mqttStartResult?.adapter ?? null;
332
+ if (adapter) pendingAdapterStops.push(() => adapter.stop());
333
+
334
+ // Step 5a: Start OPC UA adapter (only when --opcua)
335
+ const opcuaResult = opcuaEnabled
336
+ ? await startOPCUAAdapter(engine, template, opcuaPort, opcuaHost, simulationName)
337
+ : null;
338
+ if (opcuaResult) pendingAdapterStops.push(() => opcuaResult.adapter.stop());
263
339
 
264
340
  // Step 5b: Wire control plane
265
341
  let controlPlane: ControlPlane | null = null;
@@ -291,6 +367,8 @@ async function startAction(options: StartOptions): Promise<void> {
291
367
  port: healthPort,
292
368
  mqttEnabled: adapter !== null,
293
369
  isMqttConnected: () => (adapter ? adapter.getMetrics().connected : false),
370
+ opcuaEnabled: opcuaResult !== null,
371
+ isOpcuaConnected: () => (opcuaResult ? opcuaResult.adapter.getMetrics().connected : false),
294
372
  getLastTickProgressAt: () => lastTickProgressAt,
295
373
  });
296
374
  controlLog.info(`Health server active on ${healthServer.endpoint}`);
@@ -310,6 +388,7 @@ async function startAction(options: StartOptions): Promise<void> {
310
388
  seed,
311
389
  seedExplicit,
312
390
  mqttStartResult?.endpoint ?? null,
391
+ opcuaResult?.endpoint ?? null,
313
392
  simulationName,
314
393
  runId,
315
394
  healthServer.endpoint,
@@ -351,8 +430,12 @@ async function startAction(options: StartOptions): Promise<void> {
351
430
  runtimeLogContextSet = false;
352
431
  throw error;
353
432
  }
433
+ // Engine running — shutdown handlers now own adapter lifecycle
434
+ pendingAdapterStops.length = 0;
354
435
  engineLog.success("Simulation started.");
355
436
  } catch (error) {
437
+ // Stop adapters started before engine/shutdown handlers took ownership
438
+ await Promise.allSettled(pendingAdapterStops.map((fn) => fn()));
356
439
  if (runtimeLogContextSet) {
357
440
  clearRuntimeLogContext();
358
441
  runtimeLogContextSet = false;
@@ -757,12 +840,59 @@ async function startMQTTAdapter(
757
840
  const message = error instanceof Error ? error.message : String(error);
758
841
  const isPortConflict = message.includes("EADDRINUSE") || message.includes("in use");
759
842
  const fix = isPortConflict
760
- ? `Port ${port} is in use. Use '--port ${port + 1}' or set protocols.mqtt.port in template.`
843
+ ? `Port ${port} is in use. Use '--mqtt-port ${port + 1}' or set protocols.mqtt.port in template.`
761
844
  : "Check the MQTT configuration in your template file.";
762
845
  throw new MiravoError(`MQTT adapter failed to start: ${message}`, fix);
763
846
  }
764
847
  }
765
848
 
849
+ // ---------------------------------------------------------------------------
850
+ // Step 5a: OPC UA adapter
851
+ // ---------------------------------------------------------------------------
852
+
853
+ async function startOPCUAAdapter(
854
+ engine: Engine,
855
+ template: MiravoTemplate,
856
+ portOverride: number | undefined,
857
+ hostOverride: string | undefined,
858
+ simulationName: string,
859
+ ): Promise<{ adapter: OPCUAAdapter; endpoint: string } | null> {
860
+ const opcuaConfig = template.protocols?.opcua;
861
+
862
+ const port = portOverride ?? opcuaConfig?.port ?? DEFAULT_OPCUA_PORT;
863
+ const host = hostOverride ?? opcuaConfig?.host ?? "0.0.0.0";
864
+
865
+ opcuaLog.debug(`Starting OPC UA adapter on opc.tcp://${host}:${port}`);
866
+
867
+ const opcuaAdapter = new OPCUAAdapter(engine.bus);
868
+
869
+ try {
870
+ const resolvedConfig = {
871
+ name: "opcua" as const,
872
+ enabled: true,
873
+ port,
874
+ host,
875
+ security: opcuaConfig?.security ?? "none",
876
+ simulationName,
877
+ ...(opcuaConfig?.buildInfo ? { buildInfo: opcuaConfig.buildInfo } : {}),
878
+ };
879
+
880
+ await opcuaAdapter.start(resolvedConfig);
881
+ engine.registerAdapter(opcuaAdapter);
882
+
883
+ const endpoint = `opc.tcp://${host === "0.0.0.0" ? "localhost" : host}:${port}`;
884
+ opcuaLog.ready(`OPC UA server: ${colors.cyan(endpoint)}`);
885
+ return { adapter: opcuaAdapter, endpoint };
886
+ } catch (error) {
887
+ const message = error instanceof Error ? error.message : String(error);
888
+ const isPortConflict = message.includes("EADDRINUSE") || message.includes("in use");
889
+ const fix = isPortConflict
890
+ ? `Port ${port} is in use. Use '--opcua-port ${port + 1}'.`
891
+ : "Check the OPC UA port/network settings and retry.";
892
+ throw new MiravoError(`OPC UA adapter failed to start: ${message}`, fix);
893
+ }
894
+ }
895
+
766
896
  // ---------------------------------------------------------------------------
767
897
  // Step 6: Startup banner
768
898
  // ---------------------------------------------------------------------------
@@ -773,32 +903,55 @@ function printStartupBanner(
773
903
  seed: number,
774
904
  seedExplicit: boolean,
775
905
  mqttEndpoint: string | null,
906
+ opcuaEndpoint: string | null,
776
907
  simulationName: string,
777
908
  runId: string,
778
909
  healthEndpoint: string,
779
910
  ): void {
780
911
  const state = engine.getState();
781
- const modelSummary =
782
- state.models
783
- .map((m) => `${colors.bold(m.name)} ${colors.dim(`(${m.sensorCount} sensors)`)}`)
784
- .join(", ") || colors.dim("(none)");
912
+
913
+ // Build model summary: one model per line for readability
914
+ const modelLines = state.models.map(
915
+ (m) => `${colors.bold(m.name)} ${colors.dim(`(${m.sensorCount} sensors)`)}`,
916
+ );
917
+ const modelCount = `${colors.bold(String(state.models.length))} ${colors.dim("models")}`;
785
918
 
786
919
  const seedValue = seedExplicit
787
920
  ? colors.bold(String(seed))
788
921
  : `${colors.bold(String(seed))} ${colors.gray("(random — add seed to config to reproduce)")}`;
789
922
 
790
- const content = formatKeyValue([
923
+ const mqttValue = mqttEndpoint ? colors.cyan(mqttEndpoint) : colors.dim("disabled");
924
+ const opcuaValue = opcuaEndpoint ? colors.cyan(opcuaEndpoint) : colors.dim("disabled");
925
+
926
+ const allEntries: ReadonlyArray<readonly [string, string]> = [
791
927
  [colors.dim("Simulation"), colors.bold(simulationName)],
792
928
  [colors.dim("Run ID"), colors.bold(runId)],
793
929
  [colors.dim("Template"), templatePath],
794
- [colors.dim("Models"), modelSummary],
930
+ [colors.dim("Models"), modelCount],
795
931
  [colors.dim("Instances"), colors.bold(String(state.instances.length))],
796
- [colors.dim("MQTT"), mqttEndpoint ? colors.cyan(mqttEndpoint) : colors.dim("disabled")],
932
+ [colors.dim("MQTT"), mqttValue],
933
+ [colors.dim("OPC UA"), opcuaValue],
797
934
  [colors.dim("Health"), colors.cyan(`${healthEndpoint}/healthz`)],
798
935
  [colors.dim("Tick interval"), colors.bold(`${state.config.tickIntervalMs}ms`)],
799
936
  [colors.dim("Time multiplier"), colors.bold(`${state.config.timeMultiplier}x`)],
800
937
  [colors.dim("Seed"), seedValue],
801
- ]);
938
+ ];
939
+
940
+ const kvBlock = formatKeyValue(allEntries);
941
+
942
+ // Compute the value column offset used by formatKeyValue so model detail
943
+ // lines align with the value of the "Models" row.
944
+ const maxKeyWidth = Math.max(...allEntries.map(([key]) => stripAnsi(key).length));
945
+ const valueIndent = 4 + maxKeyWidth + 3; // " " + key + ":" + padding(2) + " "
946
+
947
+ // Inject per-model detail lines right after the "Models" row
948
+ const kvLines = kvBlock.split("\n");
949
+ const modelsIdx = kvLines.findIndex((line) => stripAnsi(line).includes("Models:"));
950
+ if (modelsIdx !== -1 && modelLines.length > 0) {
951
+ const detailLines = modelLines.map((line) => `${"".padEnd(valueIndent)}${line}`);
952
+ kvLines.splice(modelsIdx + 1, 0, ...detailLines);
953
+ }
954
+ const content = kvLines.join("\n");
802
955
 
803
956
  const header = `${colors.cyan(colors.bold("Miravo"))} ${colors.dim(`v${MIRAVO_VERSION}`)}`;
804
957
 
@@ -856,7 +1009,7 @@ function subscribeToEvents(engine: Engine, errorContextCounts: Map<string, numbe
856
1009
  };
857
1010
 
858
1011
  const onFaultCleared = (event: { instanceId: string; fault: string }): void => {
859
- faultLog.success(`${event.instanceId} ${event.fault} cleared`);
1012
+ faultLog.debug(`${event.instanceId} ${event.fault} cleared`);
860
1013
  };
861
1014
 
862
1015
  const onError = (event: { context: string; error: Error }): void => {
@@ -918,14 +1071,25 @@ function startPeriodicSummary(
918
1071
 
919
1072
  const sep = colors.dim(" · ");
920
1073
 
921
- let mqttStatus: string;
1074
+ const protocolParts: string[] = [];
1075
+
922
1076
  if (mqttAdapter) {
923
1077
  const errCount = mqttAdapter.metrics.errorsCount;
924
1078
  const errStr =
925
1079
  errCount === 0 ? colors.green(formatCount(errCount)) : colors.yellow(formatCount(errCount));
926
- mqttStatus = `MQTT: ${colors.bold(formatCount(mqttAdapter.metrics.messagesPublished))} published${sep}${errStr} errors`;
927
- } else {
928
- mqttStatus = colors.dim("MQTT: not connected");
1080
+ protocolParts.push(
1081
+ `MQTT: ${colors.bold(formatCount(mqttAdapter.metrics.messagesPublished))} published${sep}${errStr} errors`,
1082
+ );
1083
+ }
1084
+
1085
+ const opcuaAdapter = metrics.adapters.find((a) => a.name === "opcua");
1086
+ if (opcuaAdapter) {
1087
+ const errCount = opcuaAdapter.metrics.errorsCount;
1088
+ const errStr =
1089
+ errCount === 0 ? colors.green(formatCount(errCount)) : colors.yellow(formatCount(errCount));
1090
+ protocolParts.push(
1091
+ `OPC UA: ${colors.bold(formatCount(opcuaAdapter.metrics.messagesPublished))} updates${sep}${errStr} errors`,
1092
+ );
929
1093
  }
930
1094
 
931
1095
  const tps =
@@ -933,8 +1097,10 @@ function startPeriodicSummary(
933
1097
  ? `${colors.bold(core.actualTicksPerSecond.toFixed(1))} tps`
934
1098
  : "";
935
1099
 
1100
+ const protocolSuffix = protocolParts.length > 0 ? `${sep}${protocolParts.join(sep)}` : "";
1101
+
936
1102
  engineLog.info(
937
- `tick #${colors.bold(formatCount(core.tickCount))}${sep}${colors.bold(formatCount(core.instanceCount))} instances${sep}${colors.bold(formatCount(core.unsEntryCount))} UNS topics${sep}sim: ${colors.bold(core.simulationTimeSeconds.toFixed(1))}s${sep}${tps}${tps ? sep : ""}${mqttStatus}`,
1103
+ `tick #${colors.bold(formatCount(core.tickCount))}${sep}${colors.bold(formatCount(core.instanceCount))} instances${sep}${colors.bold(formatCount(core.unsEntryCount))} UNS topics${sep}sim: ${colors.bold(core.simulationTimeSeconds.toFixed(1))}s${sep}${tps}${tps ? sep : ""}${protocolSuffix}`,
938
1104
  );
939
1105
  }, intervalSeconds * 1_000);
940
1106
  }
@@ -523,6 +523,26 @@ function renderOverview(metrics: MetricsSnapshot, state: StateSnapshot): void {
523
523
  );
524
524
  }
525
525
 
526
+ // OPC UA
527
+ const opcuaAdapter = metrics.adapters.find((a) => a.name === "opcua");
528
+ if (opcuaAdapter) {
529
+ const connStr = opcuaAdapter.metrics.connected
530
+ ? colors.green("connected")
531
+ : colors.red("disconnected");
532
+ const errStr =
533
+ opcuaAdapter.metrics.errorsCount > 0
534
+ ? colors.yellow(String(opcuaAdapter.metrics.errorsCount))
535
+ : colors.green("0");
536
+ writeOutput(
537
+ formatKeyValue([
538
+ [
539
+ colors.dim("OPC UA"),
540
+ `${connStr}${sep}${formatCount(opcuaAdapter.metrics.messagesPublished)} updates${sep}${errStr} errors`,
541
+ ],
542
+ ]),
543
+ );
544
+ }
545
+
526
546
  // Errors
527
547
  if (
528
548
  core.engineErrorsCount > 0 ||
@@ -65,6 +65,8 @@ interface StartServerOptions {
65
65
  readonly tickIntervalMs?: number;
66
66
  readonly mqttEnabled: boolean;
67
67
  readonly isMqttConnected: () => boolean;
68
+ readonly opcuaEnabled?: boolean;
69
+ readonly isOpcuaConnected?: () => boolean;
68
70
  }
69
71
 
70
72
  function startTestServer(options: StartServerOptions, handles: Array<{ stop: () => void }>) {
@@ -75,6 +77,8 @@ function startTestServer(options: StartServerOptions, handles: Array<{ stop: ()
75
77
  port: 0,
76
78
  mqttEnabled: options.mqttEnabled,
77
79
  isMqttConnected: options.isMqttConnected,
80
+ opcuaEnabled: options.opcuaEnabled ?? false,
81
+ isOpcuaConnected: options.isOpcuaConnected ?? (() => false),
78
82
  getLastTickProgressAt: () => mock.getLastTickProgressAt(),
79
83
  });
80
84
  handles.push(handle);
@@ -133,6 +137,40 @@ describe("health server", () => {
133
137
  expect(ready.status).toBe(200);
134
138
  });
135
139
 
140
+ test("/readyz returns 503 when opcua is enabled but disconnected", async () => {
141
+ const { baseUrl } = startTestServer(
142
+ {
143
+ state: "running",
144
+ mqttEnabled: false,
145
+ isMqttConnected: () => false,
146
+ opcuaEnabled: true,
147
+ isOpcuaConnected: () => false,
148
+ },
149
+ handles,
150
+ );
151
+
152
+ const ready = await fetch(`${baseUrl}/readyz`);
153
+ expect(ready.status).toBe(503);
154
+ const body = (await ready.json()) as { reason?: string };
155
+ expect(body.reason).toBe("opcua disconnected");
156
+ });
157
+
158
+ test("/readyz ignores opcua when opcua is disabled", async () => {
159
+ const { baseUrl } = startTestServer(
160
+ {
161
+ state: "running",
162
+ mqttEnabled: false,
163
+ isMqttConnected: () => false,
164
+ opcuaEnabled: false,
165
+ isOpcuaConnected: () => false,
166
+ },
167
+ handles,
168
+ );
169
+
170
+ const ready = await fetch(`${baseUrl}/readyz`);
171
+ expect(ready.status).toBe(200);
172
+ });
173
+
136
174
  test("/healthz returns 503 when state is stopped", async () => {
137
175
  const { baseUrl } = startTestServer(
138
176
  { state: "stopped", mqttEnabled: false, isMqttConnected: () => false },
@@ -11,6 +11,8 @@ interface HealthServerOptions {
11
11
  readonly port: number;
12
12
  readonly mqttEnabled: boolean;
13
13
  readonly isMqttConnected: () => boolean;
14
+ readonly opcuaEnabled: boolean;
15
+ readonly isOpcuaConnected: () => boolean;
14
16
  readonly getLastTickProgressAt: () => number;
15
17
  }
16
18
 
@@ -30,6 +32,8 @@ interface HealthResponseBody {
30
32
  readonly tickCount: number;
31
33
  readonly mqttEnabled: boolean;
32
34
  readonly mqttConnected: boolean | null;
35
+ readonly opcuaEnabled: boolean;
36
+ readonly opcuaConnected: boolean | null;
33
37
  readonly stalled: boolean;
34
38
  readonly reason?: string;
35
39
  }
@@ -62,6 +66,8 @@ function readinessStatus(input: {
62
66
  readonly stalled: boolean;
63
67
  readonly mqttEnabled: boolean;
64
68
  readonly mqttConnected: boolean;
69
+ readonly opcuaEnabled: boolean;
70
+ readonly opcuaConnected: boolean;
65
71
  }): HealthServerStatus {
66
72
  if (input.core.state !== "running") {
67
73
  return { status: "fail", reason: `engine state is "${input.core.state}"` };
@@ -72,6 +78,9 @@ function readinessStatus(input: {
72
78
  if (input.mqttEnabled && !input.mqttConnected) {
73
79
  return { status: "fail", reason: "mqtt disconnected" };
74
80
  }
81
+ if (input.opcuaEnabled && !input.opcuaConnected) {
82
+ return { status: "fail", reason: "opcua disconnected" };
83
+ }
75
84
  return { status: "ok" };
76
85
  }
77
86
 
@@ -79,10 +88,19 @@ function readinessStatus(input: {
79
88
  * Start a lightweight HTTP health server for container probes.
80
89
  *
81
90
  * - `/healthz`: liveness (process/tick loop health)
82
- * - `/readyz`: readiness (engine running + MQTT connected when enabled)
91
+ * - `/readyz`: readiness (engine running + adapters connected when enabled)
83
92
  */
84
93
  function startHealthServer(options: HealthServerOptions): HealthServerHandle {
85
- const { engine, host, port, mqttEnabled, isMqttConnected, getLastTickProgressAt } = options;
94
+ const {
95
+ engine,
96
+ host,
97
+ port,
98
+ mqttEnabled,
99
+ isMqttConnected,
100
+ opcuaEnabled,
101
+ isOpcuaConnected,
102
+ getLastTickProgressAt,
103
+ } = options;
86
104
 
87
105
  const server = Bun.serve({
88
106
  hostname: host,
@@ -101,11 +119,14 @@ function startHealthServer(options: HealthServerOptions): HealthServerHandle {
101
119
  now - lastTickProgressAt > stallThresholdMs;
102
120
 
103
121
  const mqttConnected = mqttEnabled ? isMqttConnected() : false;
122
+ const opcuaConnected = opcuaEnabled ? isOpcuaConnected() : false;
104
123
  const bodyBase = {
105
124
  simulationState: metrics.core.state,
106
125
  tickCount: metrics.core.tickCount,
107
126
  mqttEnabled,
108
127
  mqttConnected: mqttEnabled ? mqttConnected : null,
128
+ opcuaEnabled,
129
+ opcuaConnected: opcuaEnabled ? opcuaConnected : null,
109
130
  stalled,
110
131
  } as const;
111
132
 
@@ -127,6 +148,8 @@ function startHealthServer(options: HealthServerOptions): HealthServerHandle {
127
148
  stalled,
128
149
  mqttEnabled,
129
150
  mqttConnected,
151
+ opcuaEnabled,
152
+ opcuaConnected,
130
153
  });
131
154
  return toJsonResponse(
132
155
  {
@@ -119,37 +119,86 @@ function printShutdownSummary(
119
119
 
120
120
  const { core, fleet, adapters } = metrics;
121
121
  const mqttAdapter = adapters.find((a) => a.name === "mqtt");
122
+ const opcuaAdapter = adapters.find((a) => a.name === "opcua");
122
123
 
123
124
  const sep = colors.dim(" \u00B7 ");
124
125
  const lines: string[] = [];
125
126
 
127
+ // --- Core stats ---
126
128
  lines.push(
127
- `${colors.bold(formatDuration(core.uptimeMs))}${sep}${colors.bold(formatCount(core.tickCount))} ticks${sep}${colors.bold(formatCount(core.instanceCount))} instances${sep}${colors.bold(formatCount(core.unsEntryCount))} UNS topics`,
129
+ `${colors.bold(formatDuration(core.uptimeMs))}${sep}${colors.bold(formatCount(core.tickCount))} ticks${sep}${colors.bold(String(core.actualTicksPerSecond.toFixed(1)))} ticks/s`,
130
+ );
131
+ lines.push(
132
+ `${colors.bold(formatCount(core.instanceCount))} instances${sep}${colors.bold(formatCount(core.unsEntryCount))} UNS topics`,
128
133
  );
129
134
 
135
+ // --- Protocol adapters ---
130
136
  if (mqttAdapter) {
131
- const errCount = mqttAdapter.metrics.errorsCount;
137
+ const m = mqttAdapter.metrics;
138
+ const errStr =
139
+ m.errorsCount === 0
140
+ ? colors.green(formatCount(m.errorsCount))
141
+ : colors.yellow(formatCount(m.errorsCount));
142
+ lines.push(
143
+ `MQTT: ${colors.bold(formatCount(m.messagesPublished))} published${sep}${errStr} errors`,
144
+ );
145
+ }
146
+ if (opcuaAdapter) {
147
+ const m = opcuaAdapter.metrics;
132
148
  const errStr =
133
- errCount === 0 ? colors.green(formatCount(errCount)) : colors.yellow(formatCount(errCount));
149
+ m.errorsCount === 0
150
+ ? colors.green(formatCount(m.errorsCount))
151
+ : colors.yellow(formatCount(m.errorsCount));
134
152
  lines.push(
135
- `MQTT: ${colors.bold(formatCount(mqttAdapter.metrics.messagesPublished))} published${sep}${errStr} errors`,
153
+ `OPC UA: ${colors.bold(formatCount(m.messagesPublished))} updates${sep}${errStr} errors`,
136
154
  );
137
155
  }
138
156
 
157
+ // --- Fleet health ---
158
+ const healthParts: string[] = [];
139
159
  if (fleet.activeFaultCount > 0) {
140
- lines.push(`Active faults at shutdown: ${colors.yellow(String(fleet.activeFaultCount))}`);
160
+ healthParts.push(`${colors.yellow(String(fleet.activeFaultCount))} active faults`);
161
+ }
162
+ if (core.quarantinedInstanceCount > 0) {
163
+ healthParts.push(
164
+ `${colors.yellow(String(core.quarantinedInstanceCount))} quarantined instances`,
165
+ );
166
+ }
167
+ if (core.tickOverrunsCount > 0) {
168
+ healthParts.push(`${colors.yellow(formatCount(core.tickOverrunsCount))} tick overruns`);
169
+ }
170
+ if (core.circuitBreakerTripsCount > 0) {
171
+ healthParts.push(
172
+ `${colors.yellow(formatCount(core.circuitBreakerTripsCount))} circuit breaker trips`,
173
+ );
174
+ }
175
+ if (healthParts.length > 0) {
176
+ lines.push(healthParts.join(sep));
177
+ }
178
+
179
+ // --- Lifecycle breakdown ---
180
+ const lifecycleEntries = Object.entries(fleet.lifecycleBreakdown).filter(
181
+ ([, count]) => count > 0,
182
+ );
183
+ if (lifecycleEntries.length > 0) {
184
+ const parts = lifecycleEntries.map(
185
+ ([stage, count]) => `${colors.bold(String(count))} ${stage}`,
186
+ );
187
+ lines.push(`Lifecycle: ${parts.join(sep)}`);
141
188
  }
142
189
 
190
+ // --- Seed ---
143
191
  const seedLine = seedExplicit
144
192
  ? `Seed: ${colors.bold(String(seed))}`
145
193
  : `Seed: ${colors.bold(String(seed))} ${colors.gray("(add seed to config to reproduce)")}`;
146
194
  lines.push(seedLine);
147
195
 
196
+ // --- Top error contexts ---
148
197
  if (errorContextCounts && errorContextCounts.size > 0) {
149
198
  const topContexts = [...errorContextCounts.entries()].sort((a, b) => b[1] - a[1]).slice(0, 5);
150
199
  lines.push("Top error contexts:");
151
200
  for (const [context, count] of topContexts) {
152
- lines.push(` ${context} ${colors.dim("·")} ${colors.bold(formatCount(count))}`);
201
+ lines.push(` ${context} ${colors.dim("\u00B7")} ${colors.bold(formatCount(count))}`);
153
202
  }
154
203
  }
155
204
 
@@ -43,13 +43,13 @@ async function captureConsoleLogs<T>(
43
43
  describe("configureLogger", () => {
44
44
  test("--silent trumps --verbose", () => {
45
45
  configureLogger({ verbose: true, silent: true });
46
- expect(logger.level).toBe(1);
46
+ expect(logger.level).toBe(0);
47
47
  expect(isSilent()).toBe(true);
48
48
  });
49
49
 
50
50
  test("--silent sets output suppressed", () => {
51
51
  configureLogger({ silent: true });
52
- expect(logger.level).toBe(1);
52
+ expect(logger.level).toBe(0);
53
53
  expect(isSilent()).toBe(true);
54
54
  });
55
55
 
package/src/logger.ts CHANGED
@@ -12,6 +12,7 @@ export type LogFormat = "pretty" | "json";
12
12
  const TAG_COLORS: Readonly<Record<string, (s: string) => string>> = {
13
13
  engine: colors.cyan,
14
14
  mqtt: colors.green,
15
+ opcua: colors.green,
15
16
  config: colors.yellow,
16
17
  lifecycle: colors.magenta,
17
18
  fault: colors.red,
@@ -43,6 +44,7 @@ interface ReporterLogObject {
43
44
  const fancyReporters = [...logger.options.reporters];
44
45
  const prettyReporter = {
45
46
  log(logObj: ReporterLogObject, ctx: unknown) {
47
+ if (logObj.level === 1 && !_verbose) return;
46
48
  if (logObj.args.length > 0 && typeof logObj.args[0] === "string") {
47
49
  logObj.args[0] = ` ${logObj.args[0]}`;
48
50
  }
@@ -81,12 +83,13 @@ function levelToString(level: number): "trace" | "debug" | "info" | "warn" | "er
81
83
  if (level === 4) return "debug";
82
84
  if (level === 3) return "info";
83
85
  if (level === 2) return "warn";
84
- if (level === 1) return "error";
86
+ if (level === 1) return "warn";
85
87
  return "fatal";
86
88
  }
87
89
 
88
90
  const jsonReporter = {
89
91
  log(logObj: ReporterLogObject) {
92
+ if (logObj.level === 1 && !_verbose) return;
90
93
  const errorArg = logObj.args.find((arg) => arg instanceof Error);
91
94
  const metadataArg = logObj.args.find(
92
95
  (arg) => typeof arg === "object" && arg !== null && !(arg instanceof Error),
@@ -137,6 +140,9 @@ export const lifecycleLog: ConsolaInstance = logger.withTag("lifecycle");
137
140
  /** Fault trigger and clear events. */
138
141
  export const faultLog: ConsolaInstance = logger.withTag("fault");
139
142
 
143
+ /** OPC UA adapter connection and server events. */
144
+ export const opcuaLog: ConsolaInstance = logger.withTag("opcua");
145
+
140
146
  /** Control plane status and command routing events. */
141
147
  export const controlLog: ConsolaInstance = logger.withTag("control");
142
148
 
@@ -145,6 +151,7 @@ const allLoggers: ConsolaInstance[] = [
145
151
  logger,
146
152
  engineLog,
147
153
  mqttLog,
154
+ opcuaLog,
148
155
  configLog,
149
156
  lifecycleLog,
150
157
  faultLog,
@@ -168,6 +175,7 @@ export interface LoggerOptions {
168
175
 
169
176
  let _outputSuppressed = false;
170
177
  let _plainOutput = false;
178
+ let _verbose = false;
171
179
  let _locked = false;
172
180
  let _logFormat: LogFormat = "pretty";
173
181
  const runtimeLogContext: { simulationName: string | undefined; runId: string | undefined } = {
@@ -197,7 +205,7 @@ export function clearRuntimeLogContext(): void {
197
205
  * Set the global log level based on CLI flags.
198
206
  * --silent trumps --verbose when both are provided.
199
207
  *
200
- * Levels: 1 = errors/fatals only, 3 = info (default), 4 = debug.
208
+ * Levels: 0 = errors/fatals only, 3 = info (default), 4 = debug.
201
209
  */
202
210
  export function configureLogger(opts: LoggerOptions): void {
203
211
  if (_locked) return;
@@ -205,9 +213,11 @@ export function configureLogger(opts: LoggerOptions): void {
205
213
  applyLogReporter(_logFormat);
206
214
  _plainOutput = opts.plain ?? false;
207
215
 
216
+ _verbose = opts.verbose ?? false;
217
+
208
218
  let level: number;
209
219
  if (opts.silent) {
210
- level = 1;
220
+ level = 0;
211
221
  _outputSuppressed = true;
212
222
  } else if (opts.verbose) {
213
223
  level = 4;