@thinremote/thinr-cli 1.2.0-alpha.1 → 1.3.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 (92) hide show
  1. package/CHANGELOG.md +63 -1
  2. package/README.md +235 -30
  3. package/bin/thinr.js +43 -25
  4. package/commands/_shared.js +154 -0
  5. package/commands/dashboard.js +17 -0
  6. package/commands/device/_shared.js +17 -34
  7. package/commands/device/console.js +1 -0
  8. package/commands/device/delete.js +53 -0
  9. package/commands/device/exec.js +7 -6
  10. package/commands/device/fs.js +193 -0
  11. package/commands/device/index.js +9 -0
  12. package/commands/device/list.js +35 -23
  13. package/commands/device/logs.js +178 -0
  14. package/commands/device/property.js +30 -28
  15. package/commands/device/proxy.js +3 -0
  16. package/commands/device/resource.js +30 -29
  17. package/commands/device/status.js +35 -14
  18. package/commands/device/transfer.js +236 -0
  19. package/commands/device/update.js +15 -10
  20. package/commands/fleet/index.js +17 -0
  21. package/commands/fleet/upgrade.js +275 -0
  22. package/commands/logout.js +10 -11
  23. package/commands/playbook/_shared.js +12 -0
  24. package/commands/playbook/index.js +18 -0
  25. package/commands/playbook/run.js +302 -0
  26. package/commands/playbook/validate.js +38 -0
  27. package/commands/product/_shared.js +8 -0
  28. package/commands/product/exec.js +243 -0
  29. package/commands/product/fs.js +218 -0
  30. package/commands/product/index.js +35 -0
  31. package/commands/product/list.js +86 -0
  32. package/commands/product/logs.js +289 -0
  33. package/commands/product/metric.js +124 -0
  34. package/commands/product/playbook.js +911 -0
  35. package/commands/product/property.js +76 -0
  36. package/commands/product/props.js +125 -0
  37. package/commands/product/push.js +184 -0
  38. package/commands/product/resource.js +76 -0
  39. package/commands/product/status.js +213 -0
  40. package/commands/profile.js +76 -8
  41. package/dist/dashboard.js +5644 -0
  42. package/lib/agent-releases.js +59 -0
  43. package/lib/agent-versions.js +73 -0
  44. package/lib/alarms.js +252 -0
  45. package/lib/api.js +8 -2
  46. package/lib/auth.js +68 -20
  47. package/lib/bucket.js +87 -0
  48. package/lib/command-stream.js +213 -0
  49. package/lib/config.js +5 -3
  50. package/lib/console.js +19 -5
  51. package/lib/constants.js +83 -0
  52. package/lib/dashboard/event-stream.js +239 -0
  53. package/lib/dashboard/metric-aggregate.js +64 -0
  54. package/lib/dashboard/metric-filter.js +39 -0
  55. package/lib/debug-log.js +93 -0
  56. package/lib/device-api.js +131 -148
  57. package/lib/devices.js +18 -21
  58. package/lib/errors.js +12 -0
  59. package/lib/fleet-upgrade.js +106 -0
  60. package/lib/format.js +114 -0
  61. package/lib/mcp/registry.js +14 -0
  62. package/lib/mcp/tools-alarms.js +510 -0
  63. package/lib/mcp/tools-buckets.js +103 -0
  64. package/lib/mcp/tools-devices.js +33 -3
  65. package/lib/mcp/tools-exec.js +30 -17
  66. package/lib/mcp/tools-filesystem.js +102 -6
  67. package/lib/mcp/tools-install.js +153 -0
  68. package/lib/mcp/tools-logs.js +225 -0
  69. package/lib/mcp/tools-playbook.js +211 -0
  70. package/lib/mcp/tools-products.js +1226 -105
  71. package/lib/mcp/tools-profile.js +634 -0
  72. package/lib/mcp/tools-tokens.js +371 -0
  73. package/lib/monitoring.js +26 -1
  74. package/lib/playbook/actions.js +447 -0
  75. package/lib/playbook/expression.js +302 -0
  76. package/lib/playbook/fleet.js +175 -0
  77. package/lib/playbook/loader.js +247 -0
  78. package/lib/playbook/runner.js +300 -0
  79. package/lib/playbook/schema.js +209 -0
  80. package/lib/playbook/vars.js +209 -0
  81. package/lib/product/log-presets.js +196 -0
  82. package/lib/product/logs.js +485 -0
  83. package/lib/product-orchestrator.js +124 -0
  84. package/lib/product.js +523 -20
  85. package/lib/profile.js +665 -0
  86. package/lib/property.js +7 -40
  87. package/lib/proxy.js +9 -8
  88. package/lib/resource.js +10 -14
  89. package/lib/status.js +78 -37
  90. package/lib/tokens.js +374 -0
  91. package/package.json +14 -3
  92. package/commands/product.js +0 -407
@@ -0,0 +1,485 @@
1
+ // @ts-check
2
+ import { inputError } from '../errors.js';
3
+ import {
4
+ getProductProperty,
5
+ setProductProperty,
6
+ deleteProductProperty,
7
+ } from '../product.js';
8
+ import { LEVEL_SEVERITY, PRESET_NAMES, getPreset, listPresets } from './log-presets.js';
9
+
10
+ export { LEVEL_SEVERITY, getPreset, listPresets };
11
+
12
+ /**
13
+ * Schema, validator and helpers for the per-product `logs` property.
14
+ *
15
+ * A product's `logs` value declares the named log streams the dashboard,
16
+ * CLI and MCP can offer for any device bound to that product. Each
17
+ * source is a `{ name, command }` pair: the agent already knows how to
18
+ * exec-stream an arbitrary shell command, so a "log source" is just a
19
+ * label plus the command the client should send. No agent change, no
20
+ * new IOTMP kind — the command is an opaque string that the client
21
+ * stores and forwards.
22
+ *
23
+ * Fallback: when a product has no `logs` property, consumers should
24
+ * behave as before — surface a single synthetic source named `system`
25
+ * whose command is `FALLBACK_LOGS_COMMAND`, mirroring the journalctl
26
+ * stream the dashboard has used historically.
27
+ *
28
+ * @typedef {{
29
+ * name: string,
30
+ * command: string,
31
+ * pattern?: string,
32
+ * preset?: string,
33
+ * }} LogSource
34
+ * @typedef {{ sources: LogSource[], default?: string }} LogsConfig
35
+ */
36
+
37
+ /** Property id under which the config lives on the product. */
38
+ export const LOGS_PROPERTY = 'logs';
39
+
40
+ /** Source name format: slug-ish, ≤ 32 chars, must have at least one char. */
41
+ export const LOG_SOURCE_NAME_RE = /^[A-Za-z0-9_-]{1,32}$/;
42
+
43
+ /** Maximum number of sources a single config may declare. */
44
+ export const MAX_LOG_SOURCES = 32;
45
+
46
+ /** Command used by the synthetic fallback when no `logs` config exists. */
47
+ export const FALLBACK_LOGS_COMMAND = 'journalctl --no-pager --output=short -f';
48
+
49
+ /** Fallback source name for products without a configured `logs` property. */
50
+ export const FALLBACK_LOGS_SOURCE_NAME = 'system';
51
+
52
+ const SOURCE_KEYS = new Set(['name', 'command', 'pattern', 'preset']);
53
+ const ROOT_KEYS = new Set(['sources', 'default']);
54
+
55
+ /**
56
+ * Compile a pattern string into a RegExp, throwing `inputError` when the
57
+ * regex is malformed. We never execute the regex against adversarial
58
+ * input — it's operator-supplied — but we want to reject obvious
59
+ * mistakes (unbalanced parens, bad escapes) at config time so the
60
+ * dashboard never has to handle them at render time.
61
+ *
62
+ * @param {string} pattern
63
+ * @returns {RegExp}
64
+ */
65
+ export function compileLogPattern(pattern) {
66
+ try {
67
+ return new RegExp(pattern);
68
+ } catch (e) {
69
+ throw inputError(
70
+ `pattern is not a valid regular expression: ${e?.message || String(e)}`,
71
+ );
72
+ }
73
+ }
74
+
75
+ /**
76
+ * Validate and normalize a `logs` property value.
77
+ *
78
+ * Accepts an unknown value, returns a clean copy stripped of unknown
79
+ * keys. Throws `inputError` on the first problem so callers can rely
80
+ * on the returned shape being safe to persist or hand off to the UI.
81
+ *
82
+ * @param {unknown} value
83
+ * @returns {LogsConfig}
84
+ */
85
+ export function validateLogsConfig(value) {
86
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
87
+ throw inputError('logs must be an object with a `sources` array');
88
+ }
89
+ const obj = /** @type {Record<string, unknown>} */ (value);
90
+
91
+ for (const key of Object.keys(obj)) {
92
+ if (!ROOT_KEYS.has(key)) {
93
+ throw inputError(`logs: unknown key "${key}" (allowed: sources, default)`);
94
+ }
95
+ }
96
+
97
+ const rawSources = obj.sources;
98
+ if (!Array.isArray(rawSources)) {
99
+ throw inputError('logs.sources is required and must be an array');
100
+ }
101
+ if (rawSources.length === 0) {
102
+ throw inputError('logs.sources must contain at least one source');
103
+ }
104
+ if (rawSources.length > MAX_LOG_SOURCES) {
105
+ throw inputError(`logs.sources accepts at most ${MAX_LOG_SOURCES} entries`);
106
+ }
107
+
108
+ const sources = [];
109
+ const seen = new Set();
110
+ rawSources.forEach((raw, i) => {
111
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
112
+ throw inputError(`logs.sources[${i}] must be an object with name and command`);
113
+ }
114
+ const entry = /** @type {Record<string, unknown>} */ (raw);
115
+ for (const key of Object.keys(entry)) {
116
+ if (!SOURCE_KEYS.has(key)) {
117
+ throw inputError(
118
+ `logs.sources[${i}]: unknown key "${key}" (allowed: name, command, pattern, preset)`,
119
+ );
120
+ }
121
+ }
122
+ const { name, command, pattern, preset } = entry;
123
+ if (typeof name !== 'string' || !LOG_SOURCE_NAME_RE.test(name)) {
124
+ throw inputError(
125
+ `logs.sources[${i}].name must be a slug (letters, digits, underscore, dash; 1-32 chars)`,
126
+ );
127
+ }
128
+ if (seen.has(name)) {
129
+ throw inputError(`logs.sources[${i}].name "${name}" is duplicated`);
130
+ }
131
+ if (typeof command !== 'string' || command.trim() === '') {
132
+ throw inputError(`logs.sources[${i}].command must be a non-empty string`);
133
+ }
134
+ if (pattern !== undefined && preset !== undefined) {
135
+ throw inputError(
136
+ `logs.sources[${i}]: "pattern" and "preset" are mutually exclusive`,
137
+ );
138
+ }
139
+ /** @type {LogSource} */
140
+ const out = { name, command };
141
+ if (pattern !== undefined) {
142
+ if (typeof pattern !== 'string' || pattern === '') {
143
+ throw inputError(
144
+ `logs.sources[${i}].pattern must be a non-empty regex string`,
145
+ );
146
+ }
147
+ try {
148
+ new RegExp(pattern);
149
+ } catch (e) {
150
+ throw inputError(
151
+ `logs.sources[${i}].pattern is not a valid regular expression: ${e?.message || String(e)}`,
152
+ );
153
+ }
154
+ out.pattern = pattern;
155
+ }
156
+ if (preset !== undefined) {
157
+ if (typeof preset !== 'string' || preset === '') {
158
+ throw inputError(
159
+ `logs.sources[${i}].preset must be a non-empty preset name`,
160
+ );
161
+ }
162
+ if (!getPreset(preset)) {
163
+ throw inputError(
164
+ `logs.sources[${i}].preset "${preset}" is unknown (available: ${PRESET_NAMES.join(', ')})`,
165
+ );
166
+ }
167
+ out.preset = preset;
168
+ }
169
+ seen.add(name);
170
+ sources.push(out);
171
+ });
172
+
173
+ let def;
174
+ if (obj.default !== undefined && obj.default !== null) {
175
+ if (typeof obj.default !== 'string') {
176
+ throw inputError('logs.default must be a string matching a source name');
177
+ }
178
+ if (!seen.has(obj.default)) {
179
+ throw inputError(
180
+ `logs.default "${obj.default}" does not match any source name`,
181
+ );
182
+ }
183
+ def = obj.default;
184
+ }
185
+
186
+ /** @type {LogsConfig} */
187
+ const out = { sources };
188
+ if (def !== undefined) out.default = def;
189
+ return out;
190
+ }
191
+
192
+ /**
193
+ * Resolve the active source from a (validated) config. Returns the
194
+ * entry whose name matches `default`, falling back to the first
195
+ * source. Useful for "what should I show first?" decisions in the UI.
196
+ *
197
+ * @param {LogsConfig} config
198
+ * @returns {LogSource}
199
+ */
200
+ export function resolveDefaultLogSource(config) {
201
+ if (config.default) {
202
+ const match = config.sources.find((s) => s.name === config.default);
203
+ if (match) return match;
204
+ }
205
+ return config.sources[0];
206
+ }
207
+
208
+ /**
209
+ * Synthetic config returned by `getProductLogs` when the product has
210
+ * no `logs` property set. Keeps consumers compatible with how the
211
+ * dashboard streamed logs before this feature existed.
212
+ *
213
+ * @returns {LogsConfig}
214
+ */
215
+ export function fallbackLogsConfig() {
216
+ return {
217
+ sources: [{ name: FALLBACK_LOGS_SOURCE_NAME, command: FALLBACK_LOGS_COMMAND }],
218
+ default: FALLBACK_LOGS_SOURCE_NAME,
219
+ };
220
+ }
221
+
222
+ /**
223
+ * Read a product's `logs` config, validating the stored value before
224
+ * returning it. When the property is missing, returns the synthetic
225
+ * fallback so callers can render a `system` source unconditionally.
226
+ *
227
+ * The returned object always carries a `__fallback` flag so the UI can
228
+ * tell "operator configured this" from "we made one up". The flag is
229
+ * non-enumerable to keep it out of JSON round-trips.
230
+ *
231
+ * @param {string} productId
232
+ * @param {string} [user]
233
+ * @returns {Promise<LogsConfig & { __fallback?: boolean }>}
234
+ */
235
+ export async function getProductLogs(productId, user) {
236
+ let raw;
237
+ try {
238
+ raw = await getProductProperty(productId, LOGS_PROPERTY, user);
239
+ } catch (e) {
240
+ if (/Property not found/.test(e?.message || '')) {
241
+ return markFallback(fallbackLogsConfig());
242
+ }
243
+ throw e;
244
+ }
245
+ if (raw === undefined || raw === null) {
246
+ return markFallback(fallbackLogsConfig());
247
+ }
248
+ return validateLogsConfig(raw);
249
+ }
250
+
251
+ function markFallback(config) {
252
+ Object.defineProperty(config, '__fallback', {
253
+ value: true,
254
+ enumerable: false,
255
+ writable: false,
256
+ });
257
+ return config;
258
+ }
259
+
260
+ /**
261
+ * Validate and persist a product's `logs` config. Returns the
262
+ * normalized value that was written.
263
+ *
264
+ * @param {string} productId
265
+ * @param {unknown} value
266
+ * @param {string} [user]
267
+ * @returns {Promise<LogsConfig>}
268
+ */
269
+ export async function setProductLogs(productId, value, user) {
270
+ const normalized = validateLogsConfig(value);
271
+ await setProductProperty(productId, LOGS_PROPERTY, normalized, user);
272
+ return normalized;
273
+ }
274
+
275
+ /**
276
+ * Remove the `logs` property from a product. Returns true when the
277
+ * property existed, false when it was already absent.
278
+ *
279
+ * @param {string} productId
280
+ * @param {string} [user]
281
+ * @returns {Promise<boolean>}
282
+ */
283
+ export async function deleteProductLogs(productId, user) {
284
+ return deleteProductProperty(productId, LOGS_PROPERTY, user);
285
+ }
286
+
287
+ /**
288
+ * Read the raw stored config without applying the synthetic fallback.
289
+ * Returns `null` when the property is missing — callers that need the
290
+ * fallback should reach for `getProductLogs` instead.
291
+ *
292
+ * @param {string} productId
293
+ * @param {string} [user]
294
+ * @returns {Promise<LogsConfig | null>}
295
+ */
296
+ async function readStoredLogs(productId, user) {
297
+ let raw;
298
+ try {
299
+ raw = await getProductProperty(productId, LOGS_PROPERTY, user);
300
+ } catch (e) {
301
+ if (/Property not found/.test(e?.message || '')) return null;
302
+ throw e;
303
+ }
304
+ if (raw === undefined || raw === null) return null;
305
+ return validateLogsConfig(raw);
306
+ }
307
+
308
+ /**
309
+ * Add or replace a source on a product's `logs` config. When the
310
+ * product has no `logs` property yet, the call creates it from scratch
311
+ * with this single source. Pass `makeDefault` to mark the new source
312
+ * as the active default; otherwise the previous default is preserved
313
+ * (or, on a fresh config, no default is set so the first-source rule
314
+ * applies).
315
+ *
316
+ * `pattern` and `preset` are mutually exclusive. Passing either as an
317
+ * empty string clears the corresponding field on an existing source
318
+ * (handy for `--pattern ""` to revert a source to raw rendering).
319
+ *
320
+ * @param {string} productId
321
+ * @param {{
322
+ * name?: string,
323
+ * command?: string,
324
+ * makeDefault?: boolean,
325
+ * pattern?: string,
326
+ * preset?: string,
327
+ * }} [source]
328
+ * @param {string} [user]
329
+ * @returns {Promise<{ config: LogsConfig, action: 'added' | 'updated' }>}
330
+ */
331
+ export async function addLogSource(productId, source = {}, user) {
332
+ const { name, command, makeDefault, pattern, preset } = source;
333
+ if (typeof name !== 'string' || !LOG_SOURCE_NAME_RE.test(name)) {
334
+ throw inputError(
335
+ 'name must be a slug (letters, digits, underscore, dash; 1-32 chars)',
336
+ );
337
+ }
338
+ if (typeof command !== 'string' || command.trim() === '') {
339
+ throw inputError('command must be a non-empty string');
340
+ }
341
+ const hasPattern = typeof pattern === 'string' && pattern !== '';
342
+ const hasPreset = typeof preset === 'string' && preset !== '';
343
+ if (hasPattern && hasPreset) {
344
+ throw inputError('pattern and preset are mutually exclusive');
345
+ }
346
+ if (hasPreset && !getPreset(preset)) {
347
+ throw inputError(
348
+ `preset "${preset}" is unknown (available: ${PRESET_NAMES.join(', ')})`,
349
+ );
350
+ }
351
+ if (hasPattern) compileLogPattern(pattern);
352
+
353
+ const stored = (await readStoredLogs(productId, user)) || { sources: [] };
354
+ const nextSources = [...stored.sources];
355
+ const idx = nextSources.findIndex((s) => s.name === name);
356
+ const action = idx >= 0 ? 'updated' : 'added';
357
+ /** @type {LogSource} */
358
+ const entry = { name, command };
359
+ if (hasPattern) entry.pattern = pattern;
360
+ else if (hasPreset) entry.preset = preset;
361
+ if (idx >= 0) nextSources[idx] = entry;
362
+ else nextSources.push(entry);
363
+
364
+ /** @type {LogsConfig} */
365
+ const next = { sources: nextSources };
366
+ if (makeDefault) next.default = name;
367
+ else if (stored.default) next.default = stored.default;
368
+
369
+ const normalized = validateLogsConfig(next);
370
+ await setProductProperty(productId, LOGS_PROPERTY, normalized, user);
371
+ return { config: normalized, action };
372
+ }
373
+
374
+ /**
375
+ * Resolve the regex string a source uses to parse its lines. Returns the
376
+ * literal `pattern` when set, the preset's pattern when `preset` is
377
+ * set, or `null` when the source declares neither (raw rendering).
378
+ *
379
+ * @param {LogSource} source
380
+ * @returns {string | null}
381
+ */
382
+ export function resolveSourcePattern(source) {
383
+ if (!source) return null;
384
+ if (typeof source.pattern === 'string' && source.pattern !== '') {
385
+ return source.pattern;
386
+ }
387
+ if (typeof source.preset === 'string' && source.preset !== '') {
388
+ const p = getPreset(source.preset);
389
+ if (p) return p.pattern;
390
+ }
391
+ return null;
392
+ }
393
+
394
+ /**
395
+ * Remove a source by name from a product's `logs` config. When the
396
+ * removed source was the default, the default is dropped (downstream
397
+ * `resolveDefaultLogSource` will pick the first remaining source).
398
+ * Removing the last source deletes the property entirely so callers
399
+ * fall back to the synthetic `system` config.
400
+ *
401
+ * @param {string} productId
402
+ * @param {string} name
403
+ * @param {string} [user]
404
+ * @returns {Promise<{ removed: boolean, config: LogsConfig | null }>}
405
+ */
406
+ export async function removeLogSource(productId, name, user) {
407
+ if (typeof name !== 'string' || !name) {
408
+ throw inputError('name is required');
409
+ }
410
+ const stored = await readStoredLogs(productId, user);
411
+ if (!stored) return { removed: false, config: null };
412
+
413
+ const nextSources = stored.sources.filter((s) => s.name !== name);
414
+ if (nextSources.length === stored.sources.length) {
415
+ return { removed: false, config: stored };
416
+ }
417
+ if (nextSources.length === 0) {
418
+ await deleteProductProperty(productId, LOGS_PROPERTY, user);
419
+ return { removed: true, config: null };
420
+ }
421
+
422
+ /** @type {LogsConfig} */
423
+ const next = { sources: nextSources };
424
+ if (stored.default && stored.default !== name) next.default = stored.default;
425
+
426
+ const normalized = validateLogsConfig(next);
427
+ await setProductProperty(productId, LOGS_PROPERTY, normalized, user);
428
+ return { removed: true, config: normalized };
429
+ }
430
+
431
+ /**
432
+ * Set the active default source on a product's `logs` config. The
433
+ * named source must already exist; passing `null` clears the default
434
+ * so the first-source rule applies.
435
+ *
436
+ * @param {string} productId
437
+ * @param {string | null} name
438
+ * @param {string} [user]
439
+ * @returns {Promise<LogsConfig>}
440
+ */
441
+ export async function setDefaultLogSource(productId, name, user) {
442
+ const clear = name === null || name === undefined;
443
+ if (!clear && typeof name !== 'string') {
444
+ throw inputError('default must be a string matching a source name');
445
+ }
446
+ const stored = await readStoredLogs(productId, user);
447
+ if (!stored) {
448
+ throw inputError(
449
+ `Product "${productId}" has no logs sources configured. Add one first.`,
450
+ );
451
+ }
452
+ /** @type {LogsConfig} */
453
+ const next = { sources: stored.sources };
454
+ if (!clear) {
455
+ if (!stored.sources.some((s) => s.name === name)) {
456
+ throw inputError(
457
+ `Source "${name}" is not configured on this product (have: ${stored.sources.map((s) => s.name).join(', ')})`,
458
+ );
459
+ }
460
+ next.default = name;
461
+ }
462
+ const normalized = validateLogsConfig(next);
463
+ await setProductProperty(productId, LOGS_PROPERTY, normalized, user);
464
+ return normalized;
465
+ }
466
+
467
+ /**
468
+ * Look up a single source by name on a product's `logs` config. When
469
+ * the property is missing, the synthetic fallback is consulted so
470
+ * `getLogSource(p, "system")` always resolves on products that have
471
+ * not been explicitly configured. Returns `null` when no source
472
+ * matches.
473
+ *
474
+ * @param {string} productId
475
+ * @param {string} name
476
+ * @param {string} [user]
477
+ * @returns {Promise<LogSource | null>}
478
+ */
479
+ export async function getLogSource(productId, name, user) {
480
+ if (typeof name !== 'string' || !name) {
481
+ throw inputError('name is required');
482
+ }
483
+ const cfg = await getProductLogs(productId, user);
484
+ return cfg.sources.find((s) => s.name === name) || null;
485
+ }
@@ -0,0 +1,124 @@
1
+ // @ts-check
2
+ import { getDevices, filterActiveDevices } from './devices.js';
3
+ import { runPool } from './concurrency.js';
4
+
5
+ /**
6
+ * Resolve the target devices of a product (optionally filtered by
7
+ * asset group and active connection), then run `worker(device)` over
8
+ * them with bounded concurrency.
9
+ *
10
+ * The worker is responsible for its own error handling and always
11
+ * returns a fully-populated entry. Any escaping throw is caught and
12
+ * converted via `onInternalError(device, err)` into a normalized
13
+ * failure entry, so the returned `entries` array always has one
14
+ * element per target device in the same order.
15
+ *
16
+ * Fail-fast is "soft": after the first failure (as judged by
17
+ * `isFailure(entry)`) remaining devices receive a `skipped(device,
18
+ * firstFailureDevice)` entry instead of running the worker. That way
19
+ * the per-device report still covers the whole fleet — the caller can
20
+ * show "skipped" in a table column instead of vanishing rows.
21
+ *
22
+ * The shape of `entry` is fully caller-defined: exec returns
23
+ * `{stdout, stderr, exitCode, …}`, push returns `{bytes, …}`,
24
+ * filesystem ops return `{durationMs, …}`. The orchestrator only
25
+ * touches the fields it gets told about via callbacks.
26
+ *
27
+ * @template E
28
+ * @param {{
29
+ * product: string,
30
+ * group?: string,
31
+ * includeOffline?: boolean,
32
+ * user?: string | null,
33
+ * concurrency?: number,
34
+ * failFast?: boolean,
35
+ * worker: (device: any) => Promise<E>,
36
+ * skipped: (device: any, firstFailureDevice: string) => E,
37
+ * isFailure: (entry: E) => boolean,
38
+ * onInternalError?: (device: any, error: unknown) => E,
39
+ * onDevicesResolved?: (devices: any[]) => void,
40
+ * onItemStart?: (device: any) => void,
41
+ * onItemFinish?: (device: any, entry: E) => void,
42
+ * }} opts
43
+ * @returns {Promise<{
44
+ * devices: any[],
45
+ * entries: E[],
46
+ * durationMs: number,
47
+ * }>}
48
+ */
49
+ export async function runProductFanOut(opts) {
50
+ const {
51
+ product,
52
+ group,
53
+ includeOffline = false,
54
+ user,
55
+ concurrency = 10,
56
+ failFast = false,
57
+ worker,
58
+ skipped,
59
+ isFailure,
60
+ onInternalError,
61
+ onDevicesResolved,
62
+ onItemStart,
63
+ onItemFinish,
64
+ } = opts;
65
+
66
+ const filter = { product };
67
+ if (group) filter.asset_group = group;
68
+ let devices = await getDevices(filter, user || undefined);
69
+ if (!includeOffline) devices = filterActiveDevices(devices);
70
+
71
+ onDevicesResolved?.(devices);
72
+
73
+ if (devices.length === 0) {
74
+ return { devices, entries: [], durationMs: 0 };
75
+ }
76
+
77
+ const startTs = Date.now();
78
+ let aborted = false;
79
+ /** @type {string | null} */
80
+ let firstFailureDevice = null;
81
+
82
+ const poolResults = await runPool(devices, concurrency, async (device) => {
83
+ if (failFast && aborted && firstFailureDevice !== null) {
84
+ return skipped(device, firstFailureDevice);
85
+ }
86
+ onItemStart?.(device);
87
+ /** @type {E} */
88
+ let entry;
89
+ try {
90
+ entry = await worker(device);
91
+ } catch (err) {
92
+ // Workers should own their errors; this branch is the last
93
+ // line of defence for an uncaught throw (OOM, unexpected
94
+ // library bug…). Callers can customize the fallback shape.
95
+ entry =
96
+ onInternalError?.(device, err) ??
97
+ /** @type {E} */ (
98
+ /** @type {unknown} */ ({
99
+ device: device.device ?? String(device),
100
+ ok: false,
101
+ error: err instanceof Error ? err.message : String(err),
102
+ })
103
+ );
104
+ }
105
+ onItemFinish?.(device, entry);
106
+ if (failFast && !aborted && isFailure(entry)) {
107
+ aborted = true;
108
+ firstFailureDevice = device.device ?? String(device);
109
+ }
110
+ return entry;
111
+ });
112
+
113
+ /** @type {E[]} */
114
+ const entries = poolResults.map((r, i) => {
115
+ if (r?.ok) return r.value;
116
+ // Shouldn't happen — the inner try/catch already normalized
117
+ // every error into an entry. If it does, fall back to the
118
+ // caller's skipped() with a synthetic reason so the array
119
+ // shape stays consistent.
120
+ return skipped(devices[i], 'internal pool error');
121
+ });
122
+
123
+ return { devices, entries, durationMs: Date.now() - startTs };
124
+ }