miravo 0.4.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 -8
  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 +137 -16
  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 +104 -34
  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 +39 -14
  158. package/src/output.ts +3 -0
  159. package/console-dist/assets/index-C3XMYq26.css +0 -2
  160. package/console-dist/assets/index-hUJSpKtB.js +0 -119
  161. package/src/commands/start.ts +0 -2023
@@ -0,0 +1,216 @@
1
+ /**
2
+ * CLI command: miravo apply le
3
+ */
4
+
5
+ import type { ProtocolName } from "@miravo/shared";
6
+ import { ProtocolNameSchema } from "@miravo/shared";
7
+ import type { Command } from "commander";
8
+ import { buildApplyPlan, executePlan } from "../lib/le/le-apply.ts";
9
+ import { resolveLeAuth, resolveSourceHostname } from "../lib/le/le-auth.ts";
10
+ import {
11
+ extractTemplateId,
12
+ parseScopeOption,
13
+ writePlanOutput,
14
+ writeStatusMessage,
15
+ } from "../lib/le/le-cli-helpers.ts";
16
+ import { LEClient } from "../lib/le/le-client.ts";
17
+ import { LEDeviceHub } from "../lib/le/le-devicehub.ts";
18
+ import { LEDigitalTwins } from "../lib/le/le-digital-twins.ts";
19
+ import type { ApplyScope } from "../lib/le/le-plan.ts";
20
+ import { renderPlanHuman } from "../lib/le/le-plan.ts";
21
+ import { buildDesiredStateFromTemplate } from "../lib/le/le-static-planner.ts";
22
+ import { createCatalog, resolveTemplateIdentifier } from "../lib/template-locator.ts";
23
+ import { handleCommandError, writeOutput } from "../logger.ts";
24
+
25
+ const DEFAULT_SCOPE: ApplyScope[] = ["devices", "models", "instances"];
26
+
27
+ function parseIfUnmanagedPolicy(raw: string | undefined): {
28
+ readonly policy?: "fail" | "skip" | undefined;
29
+ readonly error?: string | undefined;
30
+ } {
31
+ const normalized = raw?.trim();
32
+ if (!normalized) return { policy: "fail" };
33
+ if (normalized === "fail" || normalized === "skip") return { policy: normalized };
34
+ return { error: `invalid --if-unmanaged value "${raw}". Valid values: fail, skip.` };
35
+ }
36
+
37
+ interface ApplyLeOptions {
38
+ readonly environment?: string;
39
+ readonly leHostname?: string;
40
+ readonly leApiToken?: string;
41
+ readonly template?: string;
42
+ readonly protocol?: string;
43
+ readonly scope?: string;
44
+ readonly area?: string;
45
+ readonly line?: string;
46
+ readonly instance?: string;
47
+ readonly sourceHostname?: string;
48
+ readonly ifUnmanaged?: string;
49
+ readonly dryRun?: boolean;
50
+ readonly json?: boolean;
51
+ }
52
+
53
+ export function registerApplyCommand(program: Command): void {
54
+ const applyCmd = program
55
+ .command("apply")
56
+ .description("Apply Miravo resources to external platforms");
57
+
58
+ applyCmd
59
+ .command("le")
60
+ .description("Apply a Miravo template to Litmus Edge (create/update managed resources)")
61
+ .option(
62
+ "--environment <label>",
63
+ "Named LE environment from ~/.miravo/platforms/environments.json",
64
+ )
65
+ .option("--le-hostname <host>", "Litmus Edge hostname or IP (env: LITMUS_EDGE_HOSTNAME)")
66
+ .option("--le-api-token <token>", "Litmus Edge API token (env: LITMUS_EDGE_API_TOKEN)")
67
+ .requiredOption("--template <path>", "Template name or file path")
68
+ .option("--protocol <name>", "Protocol: mqtt, opcua, or modbus (default: opcua)")
69
+ .option("--scope <scope>", `Scope: comma-separated (default: ${DEFAULT_SCOPE.join(",")})`)
70
+ .option("--area <name>", "Filter by area name")
71
+ .option("--line <name>", "Filter by line name")
72
+ .option("--instance <id>", "Filter by instance ID")
73
+ .option(
74
+ "--source-hostname <host>",
75
+ "Hostname LE uses to reach Miravo (env: MIRAVO_SOURCE_HOSTNAME)",
76
+ )
77
+ .option("--if-unmanaged <policy>", "Policy for unmanaged collisions: fail or skip", "fail")
78
+ .option("--dry-run", "Print plan without applying", false)
79
+ .option("--json", "Output plan as JSON", false)
80
+ .action(async (options: ApplyLeOptions) => {
81
+ try {
82
+ const jsonOutput = options.json ?? false;
83
+ const writeStatus = (msg: string): void => writeStatusMessage(jsonOutput, msg, writeOutput);
84
+
85
+ const leConfig = await resolveLeAuth({
86
+ environment: options.environment,
87
+ leHostname: options.leHostname,
88
+ leApiToken: options.leApiToken,
89
+ });
90
+
91
+ const scopeResult = parseScopeOption(options.scope, DEFAULT_SCOPE);
92
+ if (!scopeResult.scope) {
93
+ writeStatus(`Error: ${scopeResult.error}`);
94
+ process.exitCode = 1;
95
+ return;
96
+ }
97
+ const scope = scopeResult.scope;
98
+
99
+ const unmanagedResult = parseIfUnmanagedPolicy(options.ifUnmanaged);
100
+ if (!unmanagedResult.policy) {
101
+ writeStatus(`Error: ${unmanagedResult.error}`);
102
+ process.exitCode = 1;
103
+ return;
104
+ }
105
+
106
+ const protocolParse =
107
+ options.protocol !== undefined
108
+ ? ProtocolNameSchema.safeParse(options.protocol)
109
+ : undefined;
110
+ if (protocolParse && !protocolParse.success) {
111
+ writeStatus(
112
+ `Error: invalid --protocol value "${options.protocol}". Valid values: mqtt, opcua, modbus.`,
113
+ );
114
+ process.exitCode = 1;
115
+ return;
116
+ }
117
+ const protocol: ProtocolName | undefined = protocolParse?.data;
118
+
119
+ const client = new LEClient(leConfig);
120
+ const hub = new LEDeviceHub(client);
121
+ const dt = new LEDigitalTwins(client);
122
+
123
+ writeStatus("Checking LE connectivity...");
124
+ await client.checkAuth();
125
+
126
+ const sourceHostname = await resolveSourceHostname({
127
+ environment: options.environment,
128
+ sourceHostname: options.sourceHostname,
129
+ });
130
+
131
+ const catalog = createCatalog();
132
+ const templatePath = await resolveTemplateIdentifier(options.template as string, catalog);
133
+
134
+ let defaultProtocolNotified = false;
135
+ const staticPlan = await buildDesiredStateFromTemplate({
136
+ templatePath,
137
+ catalog,
138
+ sourceHostname,
139
+ protocol,
140
+ filters: { area: options.area, line: options.line, instance: options.instance },
141
+ onDefaultProtocol: (p) => {
142
+ if (!defaultProtocolNotified) {
143
+ writeStatus(
144
+ `No --protocol specified and template has no protocol config; defaulting to ${p}.`,
145
+ );
146
+ defaultProtocolNotified = true;
147
+ }
148
+ },
149
+ });
150
+
151
+ const desired = staticPlan.desired;
152
+
153
+ if (
154
+ desired.devices.length === 0 &&
155
+ desired.models.length === 0 &&
156
+ desired.instances.length === 0
157
+ ) {
158
+ writeStatus("Warning: empty desired state for the chosen protocol/filters.");
159
+ }
160
+
161
+ const applyOpts = {
162
+ templateId: extractTemplateId(templatePath),
163
+ targetHost: leConfig.hostname,
164
+ sourceHostname,
165
+ scope,
166
+ filters: { area: options.area, line: options.line, instance: options.instance },
167
+ ifUnmanaged: unmanagedResult.policy,
168
+ dryRun: options.dryRun ?? false,
169
+ };
170
+
171
+ const { plan, snapshot } = await buildApplyPlan(hub, dt, desired, applyOpts);
172
+
173
+ const blockers = plan.entries.filter((e) => e.action === "blocked");
174
+ if (blockers.length > 0 && !options.dryRun) {
175
+ writePlanOutput(plan, jsonOutput, writeOutput);
176
+ writeStatus(`${blockers.length} blocker(s) found. Fix and retry.`);
177
+ process.exitCode = 1;
178
+ return;
179
+ }
180
+
181
+ if (options.dryRun) {
182
+ writePlanOutput(plan, jsonOutput, writeOutput);
183
+ return;
184
+ }
185
+
186
+ if (jsonOutput) {
187
+ writePlanOutput(plan, true, writeOutput);
188
+ } else {
189
+ writeOutput(renderPlanHuman(plan));
190
+ writeOutput("");
191
+ }
192
+
193
+ const result = await executePlan(
194
+ hub,
195
+ dt,
196
+ plan,
197
+ desired,
198
+ {
199
+ ...applyOpts,
200
+ onProgress: (msg) => writeStatus(msg),
201
+ },
202
+ snapshot,
203
+ );
204
+
205
+ if (!jsonOutput) writeOutput("");
206
+ if (result.failed > 0) {
207
+ writeStatus(`Done with errors. ${result.applied} applied, ${result.failed} failed.`);
208
+ process.exitCode = 1;
209
+ } else {
210
+ writeStatus(`Done. ${result.applied} resources applied.`);
211
+ }
212
+ } catch (error) {
213
+ handleCommandError(error);
214
+ }
215
+ });
216
+ }
@@ -0,0 +1,34 @@
1
+ import { afterEach, beforeEach, expect, test } from "bun:test";
2
+ import { resetExitCode, runCliCommand } from "./cli-test-setup.ts";
3
+
4
+ beforeEach(resetExitCode);
5
+ afterEach(resetExitCode);
6
+
7
+ test("cleanup le rejects invalid --scope values", async () => {
8
+ await runCliCommand([
9
+ "cleanup",
10
+ "le",
11
+ "--all-managed",
12
+ "--le-hostname",
13
+ "le.example.local",
14
+ "--le-api-token",
15
+ "token",
16
+ "--scope",
17
+ "foo,bar",
18
+ ]);
19
+
20
+ expect(process.exitCode).toBe(1);
21
+ });
22
+
23
+ test("cleanup le requires --template or --all-managed", async () => {
24
+ await runCliCommand([
25
+ "cleanup",
26
+ "le",
27
+ "--le-hostname",
28
+ "le.example.local",
29
+ "--le-api-token",
30
+ "token",
31
+ ]);
32
+
33
+ expect(process.exitCode).toBe(1);
34
+ });
@@ -0,0 +1,201 @@
1
+ /**
2
+ * CLI command: miravo cleanup le
3
+ */
4
+
5
+ import type { Command } from "commander";
6
+ import { resolveLeAuth } from "../lib/le/le-auth.ts";
7
+ import { buildCleanupPlan, executeCleanup } from "../lib/le/le-cleanup.ts";
8
+ import {
9
+ extractTemplateId,
10
+ parseScopeOption,
11
+ writePlanOutput,
12
+ writeStatusMessage,
13
+ } from "../lib/le/le-cli-helpers.ts";
14
+ import { LEClient } from "../lib/le/le-client.ts";
15
+ import { LEDeviceHub } from "../lib/le/le-devicehub.ts";
16
+ import { LEDigitalTwins } from "../lib/le/le-digital-twins.ts";
17
+ import type { ApplyScope } from "../lib/le/le-plan.ts";
18
+ import { createCatalog, resolveTemplateIdentifier } from "../lib/template-locator.ts";
19
+ import { handleCommandError, writeOutput } from "../logger.ts";
20
+
21
+ const DEFAULT_CLEANUP_SCOPE: ApplyScope[] = ["instances", "models", "devices"];
22
+
23
+ interface CleanupLeOptions {
24
+ readonly environment?: string;
25
+ readonly leHostname?: string;
26
+ readonly leApiToken?: string;
27
+ readonly template?: string;
28
+ readonly scope?: string;
29
+ readonly area?: string;
30
+ readonly line?: string;
31
+ readonly instance?: string;
32
+ readonly allManaged?: boolean;
33
+ readonly dryRun?: boolean;
34
+ readonly json?: boolean;
35
+ readonly yes?: boolean;
36
+ }
37
+
38
+ export function registerCleanupCommand(program: Command): void {
39
+ const cleanupCmd = program
40
+ .command("cleanup")
41
+ .description("Clean up Miravo-managed resources from external platforms");
42
+
43
+ cleanupCmd
44
+ .command("le")
45
+ .description("Remove Miravo-managed resources from Litmus Edge")
46
+ .option(
47
+ "--environment <label>",
48
+ "Named LE environment from ~/.miravo/platforms/environments.json",
49
+ )
50
+ .option("--le-hostname <host>", "Litmus Edge hostname or IP (env: LITMUS_EDGE_HOSTNAME)")
51
+ .option("--le-api-token <token>", "Litmus Edge API token (env: LITMUS_EDGE_API_TOKEN)")
52
+ .option("--template <path>", "Template name or file path (for scoped cleanup)")
53
+ .option(
54
+ "--scope <scope>",
55
+ `Scope: comma-separated (default: ${DEFAULT_CLEANUP_SCOPE.join(",")})`,
56
+ )
57
+ .option("--area <name>", "Filter by area name")
58
+ .option("--line <name>", "Filter by line name")
59
+ .option("--instance <id>", "Filter by instance ID")
60
+ .option(
61
+ "--all-managed",
62
+ "Remove ALL Miravo-managed resources (ignores template filtering)",
63
+ false,
64
+ )
65
+ .option("--dry-run", "Print plan without deleting", false)
66
+ .option("--json", "Output plan as JSON", false)
67
+ .option("--yes", "Skip confirmation prompts", false)
68
+ .action(async (options: CleanupLeOptions) => {
69
+ try {
70
+ const jsonOutput = options.json ?? false;
71
+ const writeStatus = (msg: string): void => writeStatusMessage(jsonOutput, msg, writeOutput);
72
+
73
+ const leConfig = await resolveLeAuth({
74
+ environment: options.environment,
75
+ leHostname: options.leHostname,
76
+ leApiToken: options.leApiToken,
77
+ });
78
+
79
+ const scopeResult = parseScopeOption(options.scope, DEFAULT_CLEANUP_SCOPE);
80
+ if (!scopeResult.scope) {
81
+ writeStatus(`Error: ${scopeResult.error}`);
82
+ process.exitCode = 1;
83
+ return;
84
+ }
85
+ const scope = scopeResult.scope;
86
+
87
+ let templateId = "all-managed";
88
+ if (!options.allManaged) {
89
+ if (!options.template) {
90
+ writeStatus("Error: provide --template or use --all-managed to clean all.");
91
+ process.exitCode = 1;
92
+ return;
93
+ }
94
+ const catalog = createCatalog();
95
+ const templatePath = await resolveTemplateIdentifier(options.template, catalog);
96
+ templateId = extractTemplateId(templatePath);
97
+ }
98
+
99
+ const client = new LEClient(leConfig);
100
+ const hub = new LEDeviceHub(client);
101
+ const dt = new LEDigitalTwins(client);
102
+
103
+ writeStatus("Checking LE connectivity...");
104
+ await client.checkAuth();
105
+
106
+ const cleanupOpts = {
107
+ templateId,
108
+ targetHost: leConfig.hostname,
109
+ scope,
110
+ filters: { area: options.area, line: options.line, instance: options.instance },
111
+ allManaged: options.allManaged ?? false,
112
+ dryRun: options.dryRun ?? false,
113
+ };
114
+
115
+ const maxAttempts = 3;
116
+ let attempt = 0;
117
+ let totalDeleted = 0;
118
+
119
+ while (attempt < maxAttempts) {
120
+ attempt++;
121
+ try {
122
+ const planResult = await buildCleanupPlan(hub, dt, cleanupOpts);
123
+
124
+ if (planResult.plan.entries.length === 0) {
125
+ if (attempt === 1) {
126
+ if (jsonOutput) {
127
+ writePlanOutput(planResult.plan, true, writeOutput);
128
+ } else {
129
+ writeOutput("No Miravo-managed resources found to clean up.");
130
+ }
131
+ } else {
132
+ if (!jsonOutput) writeOutput("");
133
+ writeStatus(`Done. ${totalDeleted} resources deleted.`);
134
+ }
135
+ return;
136
+ }
137
+
138
+ if (options.dryRun) {
139
+ writePlanOutput(planResult.plan, jsonOutput, writeOutput);
140
+ return;
141
+ }
142
+
143
+ if (attempt === 1) {
144
+ if (jsonOutput) {
145
+ writePlanOutput(planResult.plan, true, writeOutput);
146
+ } else {
147
+ writeOutput(
148
+ `Found ${planResult.plan.entries.length} managed resources to delete:\n`,
149
+ );
150
+ for (const entry of planResult.plan.entries) {
151
+ writeOutput(` DELETE ${entry.resourceType} ${entry.name}`);
152
+ }
153
+ writeOutput("");
154
+ }
155
+
156
+ if (!options.yes) {
157
+ writeStatus("Use --yes to confirm deletion, or --dry-run to preview.");
158
+ process.exitCode = 1;
159
+ return;
160
+ }
161
+ }
162
+
163
+ const result = await executeCleanup(hub, dt, planResult, {
164
+ ...cleanupOpts,
165
+ onProgress: (msg) => writeStatus(msg),
166
+ });
167
+
168
+ totalDeleted += result.deleted;
169
+
170
+ if (result.failed > 0 && attempt < maxAttempts) {
171
+ writeStatus(
172
+ `Attempt ${attempt} finished with ${result.failed} errors, retrying in 3s...`,
173
+ );
174
+ await Bun.sleep(3000);
175
+ continue;
176
+ }
177
+
178
+ if (!jsonOutput) writeOutput("");
179
+ if (result.failed > 0) {
180
+ writeStatus(`Done with errors. ${totalDeleted} deleted, ${result.failed} failed.`);
181
+ process.exitCode = 1;
182
+ } else {
183
+ writeStatus(`Done. ${totalDeleted} resources deleted.`);
184
+ }
185
+ return;
186
+ } catch (err) {
187
+ if (attempt < maxAttempts && !options.dryRun && options.yes) {
188
+ writeStatus(
189
+ `Attempt ${attempt} failed: ${err instanceof Error ? err.message : String(err)}, retrying in 3s...`,
190
+ );
191
+ await Bun.sleep(3000);
192
+ continue;
193
+ }
194
+ throw err;
195
+ }
196
+ }
197
+ } catch (error) {
198
+ handleCommandError(error);
199
+ }
200
+ });
201
+ }
@@ -0,0 +1,51 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { findCliCommand, resetExitCode } from "./cli-test-setup.ts";
3
+
4
+ describe("miravo export le-tags", () => {
5
+ test("export command is registered", async () => {
6
+ resetExitCode();
7
+ const { cmd } = await findCliCommand("export");
8
+ expect(cmd).toBeDefined();
9
+ });
10
+
11
+ test("le-tags subcommand is registered", async () => {
12
+ resetExitCode();
13
+ const { cmd } = await findCliCommand("export");
14
+ const leTags = cmd?.commands.find((c) => c.name() === "le-tags");
15
+ expect(leTags).toBeDefined();
16
+ expect(leTags?.description()).toContain("Litmus Edge");
17
+ });
18
+
19
+ test("le-tags has required --device option", async () => {
20
+ resetExitCode();
21
+ const { cmd } = await findCliCommand("export");
22
+ const leTags = cmd?.commands.find((c) => c.name() === "le-tags");
23
+ const deviceOpt = leTags?.options.find((o) => o.long === "--device");
24
+ expect(deviceOpt).toBeDefined();
25
+ expect(deviceOpt?.required).toBe(true);
26
+ });
27
+
28
+ test("le-tags has --instance option", async () => {
29
+ resetExitCode();
30
+ const { cmd } = await findCliCommand("export");
31
+ const leTags = cmd?.commands.find((c) => c.name() === "le-tags");
32
+ const instanceOpt = leTags?.options.find((o) => o.long === "--instance");
33
+ expect(instanceOpt).toBeDefined();
34
+ });
35
+
36
+ test("le-tags has --output option", async () => {
37
+ resetExitCode();
38
+ const { cmd } = await findCliCommand("export");
39
+ const leTags = cmd?.commands.find((c) => c.name() === "le-tags");
40
+ const outputOpt = leTags?.options.find((o) => o.long === "--output");
41
+ expect(outputOpt).toBeDefined();
42
+ });
43
+
44
+ test("le-tags has --protocol option", async () => {
45
+ resetExitCode();
46
+ const { cmd } = await findCliCommand("export");
47
+ const leTags = cmd?.commands.find((c) => c.name() === "le-tags");
48
+ const protocolOpt = leTags?.options.find((o) => o.long === "--protocol");
49
+ expect(protocolOpt).toBeDefined();
50
+ });
51
+ });