@thinremote/thinr-cli 1.3.4 → 1.4.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 (98) hide show
  1. package/CHANGELOG.md +86 -0
  2. package/README.md +117 -123
  3. package/bin/thinr.js +43 -105
  4. package/commands/_shared.js +10 -90
  5. package/commands/device/_shared.js +2 -2
  6. package/commands/device/exec.js +2 -2
  7. package/commands/device/fs.js +2 -2
  8. package/commands/device/index.js +5 -19
  9. package/commands/device/logs.js +4 -4
  10. package/commands/device/status.js +1 -1
  11. package/commands/device/transfer.js +2 -2
  12. package/commands/device/update.js +8 -2
  13. package/commands/fleet/upgrade.js +3 -3
  14. package/commands/playbook/run.js +2 -2
  15. package/commands/playbook/validate.js +2 -2
  16. package/commands/product/_shared.js +1 -1
  17. package/commands/product/exec.js +2 -2
  18. package/commands/product/fs.js +2 -2
  19. package/commands/product/index.js +4 -11
  20. package/commands/product/logs.js +2 -2
  21. package/commands/product/metric.js +2 -2
  22. package/commands/product/playbook.js +5 -5
  23. package/commands/product/property.js +4 -4
  24. package/commands/product/push.js +2 -2
  25. package/commands/product/resource.js +4 -4
  26. package/commands/product/status.js +3 -3
  27. package/dist/dashboard.js +12 -12
  28. package/docs/guides/thinremote-metrics-logs.md +59 -0
  29. package/docs/guides/thinremote-playbooks.md +133 -0
  30. package/docs/guides/thinremote-scripts.md +115 -0
  31. package/docs/guides/thinremote.md +116 -0
  32. package/lib/command-stream.js +3 -2
  33. package/lib/console.js +6 -6
  34. package/lib/constants.js +1 -19
  35. package/lib/dashboard/event-stream.js +5 -5
  36. package/lib/device-api.js +4 -4
  37. package/lib/guides.js +41 -0
  38. package/lib/identity.js +75 -0
  39. package/lib/local-files.js +54 -0
  40. package/lib/mcp/{tools-install.js → agent-install.js} +12 -61
  41. package/lib/mcp/helpers.js +5 -25
  42. package/lib/mcp/playbook-ops.js +393 -0
  43. package/lib/mcp/registry.js +9 -43
  44. package/lib/mcp/resource-hooks.js +53 -0
  45. package/lib/mcp/resource-types.js +857 -0
  46. package/lib/mcp/tools-exec.js +217 -177
  47. package/lib/monitoring.js +3 -3
  48. package/lib/playbook/actions.js +30 -18
  49. package/lib/playbook/expression.js +1 -4
  50. package/lib/playbook/fleet.js +4 -1
  51. package/lib/playbook/loader.js +16 -8
  52. package/lib/playbook/runner.js +40 -8
  53. package/lib/playbook/schema.js +108 -34
  54. package/lib/product/log-presets.js +1 -1
  55. package/lib/product/logs.js +2 -2
  56. package/lib/product-orchestrator.js +14 -9
  57. package/lib/product.js +29 -227
  58. package/lib/proxy.js +4 -4
  59. package/lib/status.js +13 -5
  60. package/package.json +4 -2
  61. package/commands/device/delete.js +0 -53
  62. package/commands/device/list.js +0 -55
  63. package/commands/device/property.js +0 -52
  64. package/commands/device/resource.js +0 -52
  65. package/commands/logout.js +0 -47
  66. package/commands/product/list.js +0 -86
  67. package/commands/product/props.js +0 -125
  68. package/commands/profile.js +0 -173
  69. package/lib/alarms.js +0 -223
  70. package/lib/api.js +0 -89
  71. package/lib/auth.js +0 -428
  72. package/lib/bucket.js +0 -82
  73. package/lib/concurrency.js +0 -52
  74. package/lib/config.js +0 -230
  75. package/lib/devices.js +0 -60
  76. package/lib/errors.js +0 -65
  77. package/lib/format.js +0 -114
  78. package/lib/mcp/server.js +0 -67
  79. package/lib/mcp/tools-alarms.js +0 -507
  80. package/lib/mcp/tools-buckets.js +0 -99
  81. package/lib/mcp/tools-devices.js +0 -235
  82. package/lib/mcp/tools-filesystem.js +0 -281
  83. package/lib/mcp/tools-logs.js +0 -225
  84. package/lib/mcp/tools-playbook.js +0 -210
  85. package/lib/mcp/tools-products.js +0 -1557
  86. package/lib/mcp/tools-profile.js +0 -635
  87. package/lib/mcp/tools-profiles.js +0 -30
  88. package/lib/mcp/tools-properties.js +0 -79
  89. package/lib/mcp/tools-resources.js +0 -78
  90. package/lib/mcp/tools-scripts.js +0 -212
  91. package/lib/mcp/tools-tokens.js +0 -369
  92. package/lib/output.js +0 -114
  93. package/lib/paths.js +0 -32
  94. package/lib/profile.js +0 -657
  95. package/lib/property.js +0 -75
  96. package/lib/resource.js +0 -151
  97. package/lib/storage.js +0 -117
  98. package/lib/tokens.js +0 -366
@@ -21,10 +21,9 @@ export function parsePlaybook(source, { sourcePath } = {}) {
21
21
  try {
22
22
  raw = YAML.parse(source);
23
23
  } catch (err) {
24
- throw new Error(
25
- `Invalid YAML${sourcePath ? ` in ${sourcePath}` : ''}: ${err.message}`,
26
- { cause: err },
27
- );
24
+ throw new Error(`Invalid YAML${sourcePath ? ` in ${sourcePath}` : ''}: ${err.message}`, {
25
+ cause: err,
26
+ });
28
27
  }
29
28
 
30
29
  const errors = [];
@@ -98,7 +97,9 @@ function validateVars(v, errors) {
98
97
  const variables = [];
99
98
  for (const [name, raw] of Object.entries(v)) {
100
99
  if (!IDENT_RE.test(name)) {
101
- errors.push(`\`vars.${name}\` is not a valid identifier (letters, digits, underscore; no leading digit).`);
100
+ errors.push(
101
+ `\`vars.${name}\` is not a valid identifier (letters, digits, underscore; no leading digit).`,
102
+ );
102
103
  continue;
103
104
  }
104
105
  const def = normaliseVarDef(name, raw, errors);
@@ -132,7 +133,9 @@ function normaliseVarDef(name, raw, errors) {
132
133
 
133
134
  for (const key of Object.keys(raw)) {
134
135
  if (!VAR_META_KEY_SET.has(key)) {
135
- errors.push(`\`vars.${name}\`: unknown metadata key "${key}" (expected: ${VAR_META_KEYS.join(', ')}).`);
136
+ errors.push(
137
+ `\`vars.${name}\`: unknown metadata key "${key}" (expected: ${VAR_META_KEYS.join(', ')}).`,
138
+ );
136
139
  return null;
137
140
  }
138
141
  }
@@ -230,8 +233,13 @@ function validateSteps(s, errors) {
230
233
  errors.push(`${where}: \`when\` must be a string expression.`);
231
234
  return;
232
235
  }
233
- if (register !== undefined && (typeof register !== 'string' || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(register))) {
234
- errors.push(`${where}: \`register\` must be a valid identifier (letters, digits, underscore; no leading digit).`);
236
+ if (
237
+ register !== undefined &&
238
+ (typeof register !== 'string' || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(register))
239
+ ) {
240
+ errors.push(
241
+ `${where}: \`register\` must be a valid identifier (letters, digits, underscore; no leading digit).`,
242
+ );
235
243
  return;
236
244
  }
237
245
  out.push({
@@ -1,6 +1,6 @@
1
1
  // @ts-check
2
- import { filterActiveDevices, getDevices } from '../devices.js';
3
- import { runPool } from '../concurrency.js';
2
+ import { filterActiveDevices, getDevices } from '@thinger-io/cli-core/devices';
3
+ import { runPool } from '@thinger-io/cli-core/concurrency';
4
4
  import { HANDLERS, CHECKERS, createActionContext } from './actions.js';
5
5
 
6
6
  /** Actions whose effect isn't derivable from observable state, so
@@ -37,7 +37,9 @@ export async function resolveTargets(pb, opts = {}) {
37
37
  // id isn't in the user's fleet (unlikely).
38
38
  const all = await getDevices({}, user);
39
39
  const byId = Object.fromEntries(all.map((d) => [d.device, d]));
40
- return target.devices.map((id) => byId[id] || { device: id, connection: { active: false } });
40
+ return target.devices.map(
41
+ (id) => byId[id] || { device: id, connection: { active: false } },
42
+ );
41
43
  }
42
44
  if (!target.product) {
43
45
  throw new Error('playbook target has no product/devices after overrides');
@@ -98,6 +100,7 @@ export function buildDryRunPlan(pb, opts) {
98
100
  * failFast?: boolean,
99
101
  * continueOnError?: boolean,
100
102
  * baseDir?: string,
103
+ * localRoot?: string,
101
104
  * checkMode?: boolean,
102
105
  * overrides?: Record<string, unknown>,
103
106
  * onStepStart?: (args: { deviceId: string, stepIndex: number, step: any }) => void,
@@ -117,7 +120,7 @@ export async function runPlaybook(pb, devices, opts = {}) {
117
120
 
118
121
  const results = await runPool(devices, concurrency, async (device) => {
119
122
  const deviceId = device.device;
120
- const ctx = createActionContext({ deviceId, user, baseDir });
123
+ const ctx = createActionContext({ deviceId, user, baseDir, localRoot: opts.localRoot });
121
124
  /** @type {Record<string, unknown>} */
122
125
  const scope = buildScope(pb, opts, { device: deviceId });
123
126
  const steps = [];
@@ -125,7 +128,14 @@ export async function runPlaybook(pb, devices, opts = {}) {
125
128
 
126
129
  for (let i = 0; i < pb.steps.length; i++) {
127
130
  if (aborted) {
128
- steps.push({ index: i, name: pb.steps[i].name, ok: false, skipped: true, summary: 'skipped (fail-fast)', durationMs: 0 });
131
+ steps.push({
132
+ index: i,
133
+ name: pb.steps[i].name,
134
+ ok: false,
135
+ skipped: true,
136
+ summary: 'skipped (fail-fast)',
137
+ durationMs: 0,
138
+ });
129
139
  continue;
130
140
  }
131
141
  const step = pb.steps[i];
@@ -149,7 +159,15 @@ export async function runPlaybook(pb, devices, opts = {}) {
149
159
  error: msg,
150
160
  durationMs,
151
161
  });
152
- opts.onStepEnd?.({ deviceId, stepIndex: i, step, ok: false, summary: msg, error: msg, durationMs });
162
+ opts.onStepEnd?.({
163
+ deviceId,
164
+ stepIndex: i,
165
+ step,
166
+ ok: false,
167
+ summary: msg,
168
+ error: msg,
169
+ durationMs,
170
+ });
153
171
  deviceOk = false;
154
172
  if (failFast) aborted = true;
155
173
  break;
@@ -163,7 +181,14 @@ export async function runPlaybook(pb, devices, opts = {}) {
163
181
  summary: `skipped (when: ${step.when})`,
164
182
  durationMs: 0,
165
183
  });
166
- opts.onStepEnd?.({ deviceId, stepIndex: i, step, ok: true, summary: 'skipped', durationMs: 0 });
184
+ opts.onStepEnd?.({
185
+ deviceId,
186
+ stepIndex: i,
187
+ step,
188
+ ok: true,
189
+ summary: 'skipped',
190
+ durationMs: 0,
191
+ });
167
192
  continue;
168
193
  }
169
194
  }
@@ -245,7 +270,14 @@ export async function runPlaybook(pb, devices, opts = {}) {
245
270
  const stdout = err && typeof err === 'object' ? err.stdout : undefined;
246
271
  const stderr = err && typeof err === 'object' ? err.stderr : undefined;
247
272
  const exitCode = err && typeof err === 'object' ? err.exitCode : undefined;
248
- const entry = { index: i, name: step.name, ok: false, summary: msg, error: msg, durationMs };
273
+ const entry = {
274
+ index: i,
275
+ name: step.name,
276
+ ok: false,
277
+ summary: msg,
278
+ error: msg,
279
+ durationMs,
280
+ };
249
281
  if (stdout !== undefined) entry.stdout = stdout;
250
282
  if (stderr !== undefined) entry.stderr = stderr;
251
283
  if (exitCode !== undefined) entry.exitCode = exitCode;
@@ -20,8 +20,17 @@ export const ACTIONS = [
20
20
  name: 'exec',
21
21
  description: 'Run a shell command on the device and fail the step on non-zero exit.',
22
22
  params: [
23
- { name: 'command', type: 'string', required: true, description: 'Shell command to run.' },
24
- { name: 'timeout', type: 'number', description: 'Per-device timeout in seconds (default: 30).' },
23
+ {
24
+ name: 'command',
25
+ type: 'string',
26
+ required: true,
27
+ description: 'Shell command to run.',
28
+ },
29
+ {
30
+ name: 'timeout',
31
+ type: 'number',
32
+ description: 'Per-device timeout in seconds (default: 30).',
33
+ },
25
34
  ],
26
35
  summary: (p) => `exec: ${p.command}`,
27
36
  },
@@ -35,10 +44,21 @@ export const ACTIONS = [
35
44
  },
36
45
  {
37
46
  name: 'write',
38
- description: 'Write inline content to a file on the device. Use `push` to upload a local file instead.',
47
+ description:
48
+ 'Write inline content to a file on the device. Use `push` to upload a local file instead.',
39
49
  params: [
40
- { name: 'path', type: 'string', required: true, description: 'Absolute path on the device.' },
41
- { name: 'content', type: 'string', required: true, description: 'Inline content to write.' },
50
+ {
51
+ name: 'path',
52
+ type: 'string',
53
+ required: true,
54
+ description: 'Absolute path on the device.',
55
+ },
56
+ {
57
+ name: 'content',
58
+ type: 'string',
59
+ required: true,
60
+ description: 'Inline content to write.',
61
+ },
42
62
  ],
43
63
  summary: (p) => `write ${p.path}`,
44
64
  },
@@ -46,8 +66,19 @@ export const ACTIONS = [
46
66
  name: 'push',
47
67
  description: 'Upload a local file to the device.',
48
68
  params: [
49
- { name: 'source', type: 'string', required: true, description: 'Local file path (relative paths resolve against the playbook directory).' },
50
- { name: 'destination', type: 'string', required: true, description: 'Absolute path on the device.' },
69
+ {
70
+ name: 'source',
71
+ type: 'string',
72
+ required: true,
73
+ description:
74
+ 'Local file path (relative paths resolve against the playbook directory).',
75
+ },
76
+ {
77
+ name: 'destination',
78
+ type: 'string',
79
+ required: true,
80
+ description: 'Absolute path on the device.',
81
+ },
51
82
  ],
52
83
  summary: (p) => `push ${p.source} → ${p.destination}`,
53
84
  },
@@ -55,44 +86,79 @@ export const ACTIONS = [
55
86
  name: 'pull',
56
87
  description: 'Download a remote file from the device to the local disk.',
57
88
  params: [
58
- { name: 'source', type: 'string', required: true, description: 'Absolute path on the device.' },
59
- { name: 'destination', type: 'string', required: true, description: 'Local file path (relative paths resolve against the playbook directory).' },
89
+ {
90
+ name: 'source',
91
+ type: 'string',
92
+ required: true,
93
+ description: 'Absolute path on the device.',
94
+ },
95
+ {
96
+ name: 'destination',
97
+ type: 'string',
98
+ required: true,
99
+ description:
100
+ 'Local file path (relative paths resolve against the playbook directory).',
101
+ },
60
102
  ],
61
103
  summary: (p) => `pull ${p.source} → ${p.destination}`,
62
104
  },
63
105
  {
64
- name: 'rm',
106
+ name: 'delete',
65
107
  description: 'Remove a file or directory on the device.',
66
108
  params: [
67
- { name: 'path', type: 'string', required: true, description: 'Absolute path on the device.' },
68
- { name: 'recursive', type: 'boolean', description: 'Recurse into directories (default: true).' },
109
+ {
110
+ name: 'path',
111
+ type: 'string',
112
+ required: true,
113
+ description: 'Absolute path on the device.',
114
+ },
115
+ {
116
+ name: 'recursive',
117
+ type: 'boolean',
118
+ description: 'Recurse into directories (default: true).',
119
+ },
69
120
  ],
70
- summary: (p) => `rm ${p.path}`,
121
+ summary: (p) => `delete ${p.path}`,
71
122
  },
72
123
  {
73
124
  name: 'mkdir',
74
125
  description: 'Create a directory on the device.',
75
126
  params: [
76
- { name: 'path', type: 'string', required: true, description: 'Absolute directory path on the device.' },
127
+ {
128
+ name: 'path',
129
+ type: 'string',
130
+ required: true,
131
+ description: 'Absolute directory path on the device.',
132
+ },
77
133
  ],
78
134
  summary: (p) => `mkdir ${p.path}`,
79
135
  },
80
136
  {
81
- name: 'mv',
82
- description: 'Move or rename a path on the device. Handy for atomic deploys (write to tmp, mv into place).',
137
+ name: 'move',
138
+ description:
139
+ 'Move or rename a path on the device. Handy for atomic deploys (write to tmp, move into place).',
83
140
  params: [
84
141
  { name: 'source', type: 'string', required: true, description: 'Path to move from.' },
85
142
  { name: 'destination', type: 'string', required: true, description: 'Target path.' },
86
- { name: 'overwrite', type: 'boolean', description: 'Overwrite destination if it exists (default: false).' },
143
+ {
144
+ name: 'overwrite',
145
+ type: 'boolean',
146
+ description: 'Overwrite destination if it exists (default: false).',
147
+ },
87
148
  ],
88
- summary: (p) => `mv ${p.source} → ${p.destination}`,
149
+ summary: (p) => `move ${p.source} → ${p.destination}`,
89
150
  },
90
151
  {
91
152
  name: 'property_set',
92
153
  description: 'Create or overwrite a device property.',
93
154
  params: [
94
155
  { name: 'property', type: 'string', required: true, description: 'Property name.' },
95
- { name: 'value', type: 'object', required: true, description: 'Any JSON-serialisable value.' },
156
+ {
157
+ name: 'value',
158
+ type: 'object',
159
+ required: true,
160
+ description: 'Any JSON-serialisable value.',
161
+ },
96
162
  ],
97
163
  summary: (p) => `property_set ${p.property}`,
98
164
  },
@@ -101,7 +167,11 @@ export const ACTIONS = [
101
167
  description: 'Invoke a device resource. Inputs are forwarded as the resource payload.',
102
168
  params: [
103
169
  { name: 'resource', type: 'string', required: true, description: 'Resource name.' },
104
- { name: 'inputs', type: 'object', description: 'Resource inputs (omit for read-only resources).' },
170
+ {
171
+ name: 'inputs',
172
+ type: 'object',
173
+ description: 'Resource inputs (omit for read-only resources).',
174
+ },
105
175
  ],
106
176
  summary: (p) => `resource ${p.resource}`,
107
177
  },
@@ -118,18 +188,25 @@ export const ACTIONS = [
118
188
  name: 'script_install',
119
189
  description: 'Install or replace a custom script on the device. Triggers a scripts reload.',
120
190
  params: [
121
- { name: 'name', type: 'string', required: true, description: 'Script filename (e.g. "battery.sh").' },
191
+ {
192
+ name: 'name',
193
+ type: 'string',
194
+ required: true,
195
+ description: 'Script filename (e.g. "battery.sh").',
196
+ },
122
197
  { name: 'content', type: 'string', description: 'Inline script source.' },
123
- { name: 'content_file', type: 'string', description: 'Local file with the script source.' },
198
+ {
199
+ name: 'content_file',
200
+ type: 'string',
201
+ description: 'Local file with the script source.',
202
+ },
124
203
  ],
125
204
  summary: (p) => `script_install ${p.name}`,
126
205
  },
127
206
  {
128
207
  name: 'script_delete',
129
208
  description: 'Remove a custom script from the device.',
130
- params: [
131
- { name: 'name', type: 'string', required: true, description: 'Script filename.' },
132
- ],
209
+ params: [{ name: 'name', type: 'string', required: true, description: 'Script filename.' }],
133
210
  summary: (p) => `script_delete ${p.name}`,
134
211
  },
135
212
  ];
@@ -164,7 +241,8 @@ export function playbookSchema() {
164
241
  description:
165
242
  'Extended form for a single `vars` entry. Use it when a variable needs a description, an explicit type for override validation, or must be marked non-overridable or required.',
166
243
  fields: {
167
- default: 'Default value. Optional when `required: true`. Must match the declared `type`.',
244
+ default:
245
+ 'Default value. Optional when `required: true`. Must match the declared `type`.',
168
246
  description: 'Optional human-readable text shown in the dashboard form.',
169
247
  type: 'Expected value type for overrides: "string", "number", "boolean", or "object".',
170
248
  overridable:
@@ -183,17 +261,13 @@ export function playbookSchema() {
183
261
  action: 'Action name — one of the actions below.',
184
262
  pause_after: 'Optional seconds to wait after this step completes.',
185
263
  when: 'Optional boolean expression. When present and falsy the step is skipped. See `when_expression` below for supported syntax.',
186
- register: 'Optional identifier. When set, the step outcome is exposed under that name to later `when` expressions and string interpolation as `{ ok, result | error, duration_ms }`.',
264
+ register:
265
+ 'Optional identifier. When set, the step outcome is exposed under that name to later `when` expressions and string interpolation as `{ ok, result | error, duration_ms }`.',
187
266
  '...': 'Additional fields are the action parameters (see `actions` below).',
188
267
  },
189
268
  when_expression: {
190
- syntax:
191
- 'Minimal boolean mini-language: `==`, `!=`, `>`, `<`, `>=`, `<=`, `and`, `or`, `not`, parentheses; literals for strings (single or double quoted), numbers, `true`, `false`, `null`; dotted identifier paths resolve against the current scope.',
192
- examples: [
193
- 'env == "prod"',
194
- 'check.ok and agent.version != "1.6.0"',
195
- 'not dry_run',
196
- ],
269
+ syntax: 'Minimal boolean mini-language: `==`, `!=`, `>`, `<`, `>=`, `<=`, `and`, `or`, `not`, parentheses; literals for strings (single or double quoted), numbers, `true`, `false`, `null`; dotted identifier paths resolve against the current scope.',
270
+ examples: ['env == "prod"', 'check.ok and agent.version != "1.6.0"', 'not dry_run'],
197
271
  },
198
272
  actions: ACTIONS.map((a) => ({
199
273
  name: a.name,
@@ -115,7 +115,7 @@ export const LEVEL_SEVERITY = /** @type {Record<string, number>} */ ({
115
115
 
116
116
  /**
117
117
  * Return all presets as a sorted list. Used by `thinr product logs presets`
118
- * and by the MCP `thinr_product_logs_presets` tool.
118
+ * and by `thinr_resource_describe` type `product_log_source` (MCP).
119
119
  *
120
120
  * @returns {LogPreset[]}
121
121
  */
@@ -1,10 +1,10 @@
1
1
  // @ts-check
2
- import { inputError } from '../errors.js';
2
+ import { inputError } from '@thinger-io/cli-core/errors';
3
3
  import {
4
4
  getProductProperty,
5
5
  setProductProperty,
6
6
  deleteProductProperty,
7
- } from '../product.js';
7
+ } from '@thinger-io/cli-core/product';
8
8
  import { LEVEL_SEVERITY, PRESET_NAMES, getPreset, listPresets } from './log-presets.js';
9
9
 
10
10
  export { LEVEL_SEVERITY, getPreset, listPresets };
@@ -1,11 +1,11 @@
1
1
  // @ts-check
2
- import { getDevices, filterActiveDevices } from './devices.js';
3
- import { runPool } from './concurrency.js';
2
+ import { getDevices, filterActiveDevices } from '@thinger-io/cli-core/devices';
3
+ import { runPool } from '@thinger-io/cli-core/concurrency';
4
4
 
5
5
  /**
6
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.
7
+ * asset group and active connection), or take the `devices` given, then
8
+ * run `worker(device)` over them with bounded concurrency.
9
9
  *
10
10
  * The worker is responsible for its own error handling and always
11
11
  * returns a fully-populated entry. Any escaping throw is caught and
@@ -26,7 +26,8 @@ import { runPool } from './concurrency.js';
26
26
  *
27
27
  * @template E
28
28
  * @param {{
29
- * product: string,
29
+ * product?: string,
30
+ * devices?: any[],
30
31
  * group?: string,
31
32
  * includeOffline?: boolean,
32
33
  * user?: string | null,
@@ -49,6 +50,7 @@ import { runPool } from './concurrency.js';
49
50
  export async function runProductFanOut(opts) {
50
51
  const {
51
52
  product,
53
+ devices: given,
52
54
  group,
53
55
  includeOffline = false,
54
56
  user,
@@ -63,10 +65,13 @@ export async function runProductFanOut(opts) {
63
65
  onItemFinish,
64
66
  } = opts;
65
67
 
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);
68
+ let devices = given;
69
+ if (!devices) {
70
+ const filter = { product };
71
+ if (group) filter.asset_group = group;
72
+ devices = await getDevices(filter, user || undefined);
73
+ if (!includeOffline) devices = filterActiveDevices(devices);
74
+ }
70
75
 
71
76
  onDevicesResolved?.(devices);
72
77