@thinremote/thinr-cli 1.1.0-alpha.2 → 1.2.0-alpha.1
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/CHANGELOG.md +35 -0
- package/README.md +38 -0
- package/commands/product.js +240 -0
- package/lib/concurrency.js +52 -0
- package/lib/mcp/tools-products.js +212 -0
- package/package.json +2 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,31 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [1.2.0] - 2026-04-16
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- `thinr product exec <productId> <command...>` — run a shell command
|
|
8
|
+
in parallel on every active device of a product, with bounded
|
|
9
|
+
concurrency (`-c, --concurrency`, default 10), per-device timeout
|
|
10
|
+
(`--timeout`, default 30 s), `--fail-fast`, asset-group filter
|
|
11
|
+
(`-g, --group`) and an `-a, --all` switch to include offline
|
|
12
|
+
devices. Live progress shows a single spinner with done/total
|
|
13
|
+
counters; the final render uses a `cli-table3` table per device
|
|
14
|
+
plus a compact `Output:` section and a one-line totals bar.
|
|
15
|
+
- `thinr_product_exec` MCP tool — the same fan-out as the CLI
|
|
16
|
+
subcommand, returning a consolidated text report so an AI agent
|
|
17
|
+
can roll out a change across a whole fleet in a single call
|
|
18
|
+
instead of looping `thinr_exec` per device.
|
|
19
|
+
- `lib/concurrency.js#runPool` — tiny helper used by both entry
|
|
20
|
+
points; keeps at most N workers in flight and returns a parallel
|
|
21
|
+
results array. `runPool` plus an internal try/catch in each worker
|
|
22
|
+
keeps fleet-wide errors localised per device.
|
|
23
|
+
|
|
24
|
+
### Changed
|
|
25
|
+
|
|
26
|
+
- `cli-table3` added as a direct dependency to render the
|
|
27
|
+
product-exec summary.
|
|
28
|
+
|
|
3
29
|
## [1.1.0] - 2026-04-16
|
|
4
30
|
|
|
5
31
|
Major release: MCP server for AI clients, subcommand-first CLI, JSON
|
|
@@ -70,6 +96,11 @@ consistent error taxonomy across both interfaces.
|
|
|
70
96
|
- Bad `-i key=value` values surface as a clean Commander
|
|
71
97
|
`InvalidArgumentError` instead of a stack trace.
|
|
72
98
|
- Node.js engine requirement bumped to **`>=18`**; `.nvmrc` pinned to 20.
|
|
99
|
+
- The MCP server's `initialize` response now reports the CLI's
|
|
100
|
+
`package.json` version in `serverInfo.version` (was hardcoded).
|
|
101
|
+
- Runtime dependencies refreshed, including majors: `@inquirer/prompts`
|
|
102
|
+
7 → 8, `conf` 14 → 15, `open` 10 → 11, `ora` 8 → 9. All npm audit
|
|
103
|
+
advisories cleared (0 vulnerabilities on install).
|
|
73
104
|
|
|
74
105
|
### Removed
|
|
75
106
|
|
|
@@ -88,6 +119,9 @@ consistent error taxonomy across both interfaces.
|
|
|
88
119
|
`npm run typecheck`.
|
|
89
120
|
- Unit tests with `node --test` (zero deps): MCP registry invariants +
|
|
90
121
|
error helpers. Script: `npm test`.
|
|
122
|
+
- Publish pipeline migrated from a long-lived `NPM_TOKEN` secret to npm
|
|
123
|
+
Trusted Publishing (OIDC), with `--provenance` attestations on every
|
|
124
|
+
publish. GitHub Actions bumped to `checkout@v5` / `setup-node@v5`.
|
|
91
125
|
|
|
92
126
|
## [1.0.2] - 2025-07-21
|
|
93
127
|
|
|
@@ -120,6 +154,7 @@ consistent error taxonomy across both interfaces.
|
|
|
120
154
|
- Property listing and retrieval
|
|
121
155
|
- Device resource listing and execution
|
|
122
156
|
|
|
157
|
+
[1.2.0]: https://github.com/Thin-Remote/thinr-cli/compare/1.1.0...1.2.0
|
|
123
158
|
[1.1.0]: https://github.com/Thin-Remote/thinr-cli/compare/1.0.2...1.1.0
|
|
124
159
|
[1.0.2]: https://github.com/Thin-Remote/thinr-cli/compare/1.0.1...1.0.2
|
|
125
160
|
[1.0.1]: https://github.com/Thin-Remote/thinr-cli/compare/1.0.0...1.0.1
|
package/README.md
CHANGED
|
@@ -299,6 +299,44 @@ Options:
|
|
|
299
299
|
Offline devices are skipped for `resource` — the server would reject
|
|
300
300
|
the call anyway.
|
|
301
301
|
|
|
302
|
+
#### `exec`
|
|
303
|
+
|
|
304
|
+
Run a shell command in parallel across every active device of the
|
|
305
|
+
product and report a per-device status table plus each device's
|
|
306
|
+
output:
|
|
307
|
+
|
|
308
|
+
```bash
|
|
309
|
+
thinr product exec <productId> <command...> [options]
|
|
310
|
+
```
|
|
311
|
+
|
|
312
|
+
Options:
|
|
313
|
+
|
|
314
|
+
- `-j, --json`: Output as JSON. Emits a single envelope with a
|
|
315
|
+
`summary` block and a `results[]` array carrying stdout, stderr,
|
|
316
|
+
exit code, `timedOut` flag and per-device duration.
|
|
317
|
+
- `-c, --concurrency <n>`: Max parallel executions (default: 10)
|
|
318
|
+
- `--timeout <seconds>`: Per-device timeout (default: 30)
|
|
319
|
+
- `--fail-fast`: Stop dequeueing new devices as soon as one fails
|
|
320
|
+
- `-g, --group <group>`: Filter devices by asset group
|
|
321
|
+
- `-a, --all`: Include offline devices (default: only active)
|
|
322
|
+
|
|
323
|
+
Examples:
|
|
324
|
+
|
|
325
|
+
```bash
|
|
326
|
+
# hostname on every active device
|
|
327
|
+
thinr product exec my-product hostname
|
|
328
|
+
|
|
329
|
+
# apt upgrade across the fleet with 5 parallel slots and a long timeout
|
|
330
|
+
thinr product exec my-product "apt-get -y upgrade" -c 5 --timeout 600
|
|
331
|
+
|
|
332
|
+
# target only a subset of the product
|
|
333
|
+
thinr product exec my-product "systemctl restart agent" -g staging
|
|
334
|
+
```
|
|
335
|
+
|
|
336
|
+
Also exposed as an MCP tool (`thinr_product_exec`) with the same
|
|
337
|
+
parameters, so agents can drive a fleet operation with a single call
|
|
338
|
+
instead of looping `thinr_exec` per device.
|
|
339
|
+
|
|
302
340
|
### `thinr profile`
|
|
303
341
|
|
|
304
342
|
Manage the profile store (see [Profiles and multi-account
|
package/commands/product.js
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import chalk from 'chalk';
|
|
2
|
+
import Table from 'cli-table3';
|
|
2
3
|
import { InvalidArgumentError } from 'commander';
|
|
3
4
|
import { configExists } from '../lib/config.js';
|
|
4
5
|
import { formatDeviceProperty, getDeviceProperty } from '../lib/property.js';
|
|
5
6
|
import { callDeviceResource } from '../lib/resource.js';
|
|
6
7
|
import { filterActiveDevices, getDevices } from '../lib/devices.js';
|
|
8
|
+
import { createDeviceAPI } from '../lib/device-api.js';
|
|
9
|
+
import { runPool } from '../lib/concurrency.js';
|
|
7
10
|
import {
|
|
8
11
|
setJsonMode,
|
|
9
12
|
isJsonMode,
|
|
@@ -37,6 +40,16 @@ function collectInput(value, previous = {}) {
|
|
|
37
40
|
return { ...previous, [value.slice(0, idx)]: value.slice(idx + 1) };
|
|
38
41
|
}
|
|
39
42
|
|
|
43
|
+
function parsePositiveInt(label) {
|
|
44
|
+
return (value) => {
|
|
45
|
+
const n = Number(value);
|
|
46
|
+
if (!Number.isInteger(n) || n <= 0) {
|
|
47
|
+
throw new InvalidArgumentError(`${label} must be a positive integer`);
|
|
48
|
+
}
|
|
49
|
+
return n;
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
40
53
|
async function fetchProductDevices(productId, group, user) {
|
|
41
54
|
const filter = { productId };
|
|
42
55
|
if (group) filter.asset_group = group;
|
|
@@ -164,4 +177,231 @@ export function productCommand(program) {
|
|
|
164
177
|
}
|
|
165
178
|
if (isJsonMode()) printOk({ product: productId, resource, results });
|
|
166
179
|
});
|
|
180
|
+
|
|
181
|
+
product
|
|
182
|
+
.command('exec <productId> <command...>')
|
|
183
|
+
.description(
|
|
184
|
+
'Execute a shell command in parallel on every active device of the product',
|
|
185
|
+
)
|
|
186
|
+
.option('-j, --json', 'Output as JSON')
|
|
187
|
+
.option('-g, --group <group>', 'Filter devices by asset group')
|
|
188
|
+
.option(
|
|
189
|
+
'-c, --concurrency <n>',
|
|
190
|
+
'Max parallel executions (default: 10)',
|
|
191
|
+
parsePositiveInt('concurrency'),
|
|
192
|
+
10,
|
|
193
|
+
)
|
|
194
|
+
.option(
|
|
195
|
+
'--timeout <seconds>',
|
|
196
|
+
'Per-device timeout in seconds (default: 30)',
|
|
197
|
+
parsePositiveInt('timeout'),
|
|
198
|
+
30,
|
|
199
|
+
)
|
|
200
|
+
.option('--fail-fast', 'Stop dequeueing new devices as soon as one fails')
|
|
201
|
+
.option('-a, --all', 'Include offline devices (by default, only active)')
|
|
202
|
+
.action(async (productId, commandParts, opts, cmd) => {
|
|
203
|
+
applyJsonFlag(opts);
|
|
204
|
+
ensureConfigured();
|
|
205
|
+
const user = getGlobalUser(cmd);
|
|
206
|
+
const commandStr = commandParts.join(' ');
|
|
207
|
+
|
|
208
|
+
const spinner = createSpinner(`Retrieving devices for product ${productId}...`).start();
|
|
209
|
+
let devices;
|
|
210
|
+
try {
|
|
211
|
+
devices = await fetchProductDevices(productId, opts.group, user);
|
|
212
|
+
spinner.succeed(`Found ${devices.length} device(s) in product ${productId}`);
|
|
213
|
+
} catch (error) {
|
|
214
|
+
spinner.fail(`Failed to retrieve devices for product ${productId}`);
|
|
215
|
+
const { message, code } = classifyError(error);
|
|
216
|
+
printErr(message, { code });
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
if (!opts.all) devices = filterActiveDevices(devices);
|
|
220
|
+
|
|
221
|
+
if (devices.length === 0) {
|
|
222
|
+
if (isJsonMode()) {
|
|
223
|
+
printOk({
|
|
224
|
+
product: productId,
|
|
225
|
+
command: commandStr,
|
|
226
|
+
summary: { total: 0, ok: 0, failed: 0, durationMs: 0 },
|
|
227
|
+
results: [],
|
|
228
|
+
});
|
|
229
|
+
} else {
|
|
230
|
+
console.log(chalk.yellow('No devices to run on.'));
|
|
231
|
+
}
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Live progress (human mode only): a single spinner that
|
|
236
|
+
// updates with done/in-flight counters as workers finish.
|
|
237
|
+
// Spinner is silenced in JSON mode by createSpinner itself.
|
|
238
|
+
const runSpinner = createSpinner('').start();
|
|
239
|
+
let doneCount = 0;
|
|
240
|
+
let inFlight = 0;
|
|
241
|
+
const updateSpinnerText = () => {
|
|
242
|
+
runSpinner.text =
|
|
243
|
+
`Running \`${commandStr}\` on ${devices.length} device(s) — ` +
|
|
244
|
+
`${doneCount}/${devices.length} done · ${inFlight} in flight` +
|
|
245
|
+
(opts.failFast ? ' · fail-fast' : '');
|
|
246
|
+
};
|
|
247
|
+
updateSpinnerText();
|
|
248
|
+
|
|
249
|
+
const startTs = Date.now();
|
|
250
|
+
const poolResults = await runPool(
|
|
251
|
+
devices,
|
|
252
|
+
opts.concurrency,
|
|
253
|
+
async (device) => {
|
|
254
|
+
inFlight++;
|
|
255
|
+
updateSpinnerText();
|
|
256
|
+
const api = createDeviceAPI(device.device, { user });
|
|
257
|
+
const perDeviceStart = Date.now();
|
|
258
|
+
let stdout = '';
|
|
259
|
+
let stderr = '';
|
|
260
|
+
const { exitCode, timedOut } = await api.execStream(commandStr, {
|
|
261
|
+
timeout: opts.timeout,
|
|
262
|
+
onStdout: (s) => {
|
|
263
|
+
stdout += s;
|
|
264
|
+
},
|
|
265
|
+
onStderr: (s) => {
|
|
266
|
+
stderr += s;
|
|
267
|
+
},
|
|
268
|
+
});
|
|
269
|
+
const durationMs = Date.now() - perDeviceStart;
|
|
270
|
+
const entry = {
|
|
271
|
+
device: device.device,
|
|
272
|
+
ok: !timedOut && exitCode === 0,
|
|
273
|
+
exitCode: exitCode ?? null,
|
|
274
|
+
timedOut: !!timedOut,
|
|
275
|
+
durationMs,
|
|
276
|
+
stdout,
|
|
277
|
+
stderr,
|
|
278
|
+
};
|
|
279
|
+
inFlight--;
|
|
280
|
+
doneCount++;
|
|
281
|
+
updateSpinnerText();
|
|
282
|
+
return entry;
|
|
283
|
+
},
|
|
284
|
+
{ failFast: !!opts.failFast },
|
|
285
|
+
).catch((err) => {
|
|
286
|
+
// fail-fast re-throws the first error; the partial results
|
|
287
|
+
// are still in `results` as it was populated before the throw.
|
|
288
|
+
// We fall through below and normalize with what we have.
|
|
289
|
+
return err;
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
// poolResults is either the array (success path) or the rethrown
|
|
293
|
+
// error from fail-fast. When it's an error, the pool still filled
|
|
294
|
+
// `results[i]` for what finished; but we lost that handle. Easy
|
|
295
|
+
// workaround: when fail-fast throws, re-run with failFast=false on
|
|
296
|
+
// nothing — or simpler, just catch inside runPool and not throw.
|
|
297
|
+
// For now, if it's an Error instance, surface it as a top-level
|
|
298
|
+
// failure and abort.
|
|
299
|
+
// Stop the live spinner now that the pool has settled, regardless
|
|
300
|
+
// of outcome. The final summary below replaces it.
|
|
301
|
+
runSpinner.stop();
|
|
302
|
+
|
|
303
|
+
if (poolResults instanceof Error) {
|
|
304
|
+
const { message, code } = classifyError(poolResults);
|
|
305
|
+
if (isJsonMode()) {
|
|
306
|
+
printErr(message, { code });
|
|
307
|
+
} else {
|
|
308
|
+
console.error(chalk.red(`fail-fast: ${message}`));
|
|
309
|
+
}
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const entries = poolResults.map((r, i) => {
|
|
314
|
+
if (r?.ok) return r.value;
|
|
315
|
+
const { message, code } = classifyError(r?.error);
|
|
316
|
+
return {
|
|
317
|
+
device: devices[i].device,
|
|
318
|
+
ok: false,
|
|
319
|
+
exitCode: null,
|
|
320
|
+
timedOut: false,
|
|
321
|
+
durationMs: 0,
|
|
322
|
+
stdout: '',
|
|
323
|
+
stderr: '',
|
|
324
|
+
error: { message, code },
|
|
325
|
+
};
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
const totalDurationMs = Date.now() - startTs;
|
|
329
|
+
const okCount = entries.filter((e) => e.ok).length;
|
|
330
|
+
const timedOutCount = entries.filter((e) => e.timedOut).length;
|
|
331
|
+
const errorCount = entries.filter((e) => e.error).length;
|
|
332
|
+
const failCount = entries.length - okCount - timedOutCount - errorCount;
|
|
333
|
+
|
|
334
|
+
if (isJsonMode()) {
|
|
335
|
+
printOk({
|
|
336
|
+
product: productId,
|
|
337
|
+
command: commandStr,
|
|
338
|
+
summary: {
|
|
339
|
+
total: entries.length,
|
|
340
|
+
ok: okCount,
|
|
341
|
+
failed: failCount + timedOutCount + errorCount,
|
|
342
|
+
timedOut: timedOutCount,
|
|
343
|
+
errored: errorCount,
|
|
344
|
+
durationMs: totalDurationMs,
|
|
345
|
+
},
|
|
346
|
+
results: entries,
|
|
347
|
+
});
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const statusBadge = (e) => {
|
|
352
|
+
if (e.error) return chalk.red('error');
|
|
353
|
+
if (e.timedOut) return chalk.yellow('timeout');
|
|
354
|
+
if (e.ok) return chalk.green('ok');
|
|
355
|
+
return chalk.red('fail');
|
|
356
|
+
};
|
|
357
|
+
const detail = (e) => {
|
|
358
|
+
if (e.error) return e.error.message;
|
|
359
|
+
if (e.timedOut) return `after ${opts.timeout}s`;
|
|
360
|
+
return `exit=${e.exitCode ?? 'null'}`;
|
|
361
|
+
};
|
|
362
|
+
const table = new Table({
|
|
363
|
+
head: ['Device', 'Status', 'Detail', 'Duration'].map((h) => chalk.bold(h)),
|
|
364
|
+
style: { head: [], border: ['gray'] },
|
|
365
|
+
colAligns: ['left', 'left', 'left', 'right'],
|
|
366
|
+
});
|
|
367
|
+
for (const e of entries) {
|
|
368
|
+
table.push([
|
|
369
|
+
e.device,
|
|
370
|
+
statusBadge(e),
|
|
371
|
+
detail(e),
|
|
372
|
+
`${e.durationMs}ms`,
|
|
373
|
+
]);
|
|
374
|
+
}
|
|
375
|
+
console.log(table.toString());
|
|
376
|
+
|
|
377
|
+
// Output section: only devices that produced stdout/stderr.
|
|
378
|
+
// Single-line outputs render inline; multi-line ones indent
|
|
379
|
+
// on their own block so grepping and copying still work.
|
|
380
|
+
const withOutput = entries.filter(
|
|
381
|
+
(e) => (e.stdout && e.stdout.trim()) || (e.stderr && e.stderr.trim()),
|
|
382
|
+
);
|
|
383
|
+
if (withOutput.length) {
|
|
384
|
+
console.log(chalk.bold('\nOutput:'));
|
|
385
|
+
for (const e of withOutput) {
|
|
386
|
+
const body = [e.stdout, e.stderr]
|
|
387
|
+
.map((s) => (s ? s.replace(/\s+$/, '') : ''))
|
|
388
|
+
.filter(Boolean)
|
|
389
|
+
.join('\n');
|
|
390
|
+
const lines = body.split('\n');
|
|
391
|
+
if (lines.length === 1) {
|
|
392
|
+
console.log(` ${chalk.dim(e.device + ':')} ${lines[0]}`);
|
|
393
|
+
} else {
|
|
394
|
+
console.log(` ${chalk.dim(e.device + ':')}`);
|
|
395
|
+
for (const line of lines) console.log(` ${line}`);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
console.log(
|
|
401
|
+
'\n' +
|
|
402
|
+
chalk.gray(
|
|
403
|
+
`${entries.length} total — ${okCount} ok, ${failCount} fail, ${timedOutCount} timeout, ${errorCount} error · ${totalDurationMs}ms`,
|
|
404
|
+
),
|
|
405
|
+
);
|
|
406
|
+
});
|
|
167
407
|
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Run `worker(item, index)` over `items` with at most `limit` invocations
|
|
5
|
+
* in flight at any time. Returns an array parallel to `items` describing
|
|
6
|
+
* the outcome of each task as `{ ok: true, value }` or `{ ok: false, error }`.
|
|
7
|
+
*
|
|
8
|
+
* By default, a rejection from one worker does not stop the others — the
|
|
9
|
+
* caller inspects the results array. Set `failFast: true` to stop dequeueing
|
|
10
|
+
* new work as soon as any worker rejects; in-flight tasks still finish and
|
|
11
|
+
* their results are included. The first rejection is rethrown after the
|
|
12
|
+
* in-flight tasks settle.
|
|
13
|
+
*
|
|
14
|
+
* @template T, R
|
|
15
|
+
* @param {T[]} items
|
|
16
|
+
* @param {number} limit
|
|
17
|
+
* @param {(item: T, index: number) => Promise<R>} worker
|
|
18
|
+
* @param {{ failFast?: boolean }} [opts]
|
|
19
|
+
* @returns {Promise<Array<{ ok: true, value: R } | { ok: false, error: unknown }>>}
|
|
20
|
+
*/
|
|
21
|
+
export async function runPool(items, limit, worker, { failFast = false } = {}) {
|
|
22
|
+
const results = new Array(items.length);
|
|
23
|
+
let nextIndex = 0;
|
|
24
|
+
let aborted = false;
|
|
25
|
+
/** @type {unknown} */
|
|
26
|
+
let abortError = null;
|
|
27
|
+
|
|
28
|
+
const effectiveLimit = Math.max(1, Math.min(limit, items.length));
|
|
29
|
+
|
|
30
|
+
async function runner() {
|
|
31
|
+
while (!aborted) {
|
|
32
|
+
const i = nextIndex++;
|
|
33
|
+
if (i >= items.length) return;
|
|
34
|
+
try {
|
|
35
|
+
results[i] = { ok: true, value: await worker(items[i], i) };
|
|
36
|
+
} catch (error) {
|
|
37
|
+
results[i] = { ok: false, error };
|
|
38
|
+
if (failFast && !aborted) {
|
|
39
|
+
aborted = true;
|
|
40
|
+
abortError = error;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const runners = [];
|
|
47
|
+
for (let i = 0; i < effectiveLimit; i++) runners.push(runner());
|
|
48
|
+
await Promise.all(runners);
|
|
49
|
+
|
|
50
|
+
if (aborted && abortError) throw abortError;
|
|
51
|
+
return results;
|
|
52
|
+
}
|
|
@@ -8,6 +8,9 @@ import {
|
|
|
8
8
|
readProductScript,
|
|
9
9
|
getProductApi,
|
|
10
10
|
} from '../product.js';
|
|
11
|
+
import { getDevices, filterActiveDevices } from '../devices.js';
|
|
12
|
+
import { createDeviceAPI } from '../device-api.js';
|
|
13
|
+
import { runPool } from '../concurrency.js';
|
|
11
14
|
import { inputError } from '../errors.js';
|
|
12
15
|
|
|
13
16
|
async function toolProducts(args) {
|
|
@@ -110,6 +113,170 @@ async function toolProductScriptDelete(args) {
|
|
|
110
113
|
};
|
|
111
114
|
}
|
|
112
115
|
|
|
116
|
+
/**
|
|
117
|
+
* @typedef {{
|
|
118
|
+
* device: string,
|
|
119
|
+
* ok: boolean,
|
|
120
|
+
* exitCode: number | null,
|
|
121
|
+
* timedOut: boolean,
|
|
122
|
+
* durationMs: number,
|
|
123
|
+
* stdout: string,
|
|
124
|
+
* stderr: string,
|
|
125
|
+
* error?: string,
|
|
126
|
+
* }} ExecEntry
|
|
127
|
+
*/
|
|
128
|
+
|
|
129
|
+
async function toolProductExec(args) {
|
|
130
|
+
if (!args.product) throw inputError('product is required');
|
|
131
|
+
if (!args.command) throw inputError('command is required');
|
|
132
|
+
const concurrency =
|
|
133
|
+
Number.isFinite(args.concurrency) && args.concurrency > 0
|
|
134
|
+
? Math.floor(args.concurrency)
|
|
135
|
+
: 10;
|
|
136
|
+
const timeout =
|
|
137
|
+
Number.isFinite(args.timeout) && args.timeout > 0 ? Math.floor(args.timeout) : 30;
|
|
138
|
+
const failFast = !!args.fail_fast;
|
|
139
|
+
const includeOffline = !!args.all;
|
|
140
|
+
|
|
141
|
+
const filter = { productId: args.product };
|
|
142
|
+
if (args.group) filter.asset_group = args.group;
|
|
143
|
+
let devices = await getDevices(filter, args.user);
|
|
144
|
+
if (!includeOffline) devices = filterActiveDevices(devices);
|
|
145
|
+
|
|
146
|
+
if (devices.length === 0) {
|
|
147
|
+
const suffix = args.group ? `, group="${args.group}"` : '';
|
|
148
|
+
return {
|
|
149
|
+
content: [
|
|
150
|
+
{
|
|
151
|
+
type: 'text',
|
|
152
|
+
text: `No devices to run on (product="${args.product}"${suffix}).`,
|
|
153
|
+
},
|
|
154
|
+
],
|
|
155
|
+
isError: false,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const startTs = Date.now();
|
|
160
|
+
let aborted = false;
|
|
161
|
+
/** @type {string | null} */
|
|
162
|
+
let firstFailureDevice = null;
|
|
163
|
+
|
|
164
|
+
// Worker never throws: it returns a populated `ExecEntry` in every
|
|
165
|
+
// branch (success, non-zero exit, timeout, connection error, or
|
|
166
|
+
// "skipped" once fail-fast has triggered). That keeps the post-pool
|
|
167
|
+
// mapping trivial and sidesteps the `{ ok: true } | { ok: false }`
|
|
168
|
+
// union narrowing limitation with JSDoc types.
|
|
169
|
+
const poolResults = await runPool(devices, concurrency, async (device) => {
|
|
170
|
+
if (failFast && aborted) {
|
|
171
|
+
/** @type {ExecEntry} */
|
|
172
|
+
const skipped = {
|
|
173
|
+
device: device.device,
|
|
174
|
+
ok: false,
|
|
175
|
+
exitCode: null,
|
|
176
|
+
timedOut: false,
|
|
177
|
+
durationMs: 0,
|
|
178
|
+
stdout: '',
|
|
179
|
+
stderr: '',
|
|
180
|
+
error: `skipped (fail-fast after ${firstFailureDevice})`,
|
|
181
|
+
};
|
|
182
|
+
return skipped;
|
|
183
|
+
}
|
|
184
|
+
const perStart = Date.now();
|
|
185
|
+
let stdout = '';
|
|
186
|
+
let stderr = '';
|
|
187
|
+
/** @type {ExecEntry} */
|
|
188
|
+
let entry;
|
|
189
|
+
try {
|
|
190
|
+
const api = createDeviceAPI(device.device, { user: args.user || undefined });
|
|
191
|
+
const { exitCode, timedOut } = await api.execStream(args.command, {
|
|
192
|
+
timeout,
|
|
193
|
+
onStdout: (s) => {
|
|
194
|
+
stdout += s;
|
|
195
|
+
},
|
|
196
|
+
onStderr: (s) => {
|
|
197
|
+
stderr += s;
|
|
198
|
+
},
|
|
199
|
+
});
|
|
200
|
+
entry = {
|
|
201
|
+
device: device.device,
|
|
202
|
+
ok: !timedOut && exitCode === 0,
|
|
203
|
+
exitCode: exitCode ?? null,
|
|
204
|
+
timedOut: !!timedOut,
|
|
205
|
+
durationMs: Date.now() - perStart,
|
|
206
|
+
stdout,
|
|
207
|
+
stderr,
|
|
208
|
+
};
|
|
209
|
+
} catch (err) {
|
|
210
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
211
|
+
entry = {
|
|
212
|
+
device: device.device,
|
|
213
|
+
ok: false,
|
|
214
|
+
exitCode: null,
|
|
215
|
+
timedOut: false,
|
|
216
|
+
durationMs: Date.now() - perStart,
|
|
217
|
+
stdout,
|
|
218
|
+
stderr,
|
|
219
|
+
error: msg,
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
if (failFast && !entry.ok && !aborted) {
|
|
223
|
+
aborted = true;
|
|
224
|
+
firstFailureDevice = entry.device;
|
|
225
|
+
}
|
|
226
|
+
return entry;
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
/** @type {ExecEntry[]} */
|
|
230
|
+
const entries = poolResults.map((r, i) => {
|
|
231
|
+
if (r && r.ok) return r.value;
|
|
232
|
+
// Defensive fallback — the worker above never throws, so this
|
|
233
|
+
// branch shouldn't trigger in practice.
|
|
234
|
+
return {
|
|
235
|
+
device: devices[i].device,
|
|
236
|
+
ok: false,
|
|
237
|
+
exitCode: null,
|
|
238
|
+
timedOut: false,
|
|
239
|
+
durationMs: 0,
|
|
240
|
+
stdout: '',
|
|
241
|
+
stderr: '',
|
|
242
|
+
error: 'unknown error',
|
|
243
|
+
};
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
const totalMs = Date.now() - startTs;
|
|
247
|
+
const okCount = entries.filter((e) => e.ok).length;
|
|
248
|
+
const timedOutCount = entries.filter((e) => e.timedOut).length;
|
|
249
|
+
const errorCount = entries.filter((e) => e.error).length;
|
|
250
|
+
const failCount = entries.length - okCount - timedOutCount - errorCount;
|
|
251
|
+
|
|
252
|
+
const lines = [];
|
|
253
|
+
lines.push(
|
|
254
|
+
`Ran \`${args.command}\` on ${entries.length} device(s) of product "${args.product}" ` +
|
|
255
|
+
`(concurrency=${concurrency}, timeout=${timeout}s).\n`,
|
|
256
|
+
);
|
|
257
|
+
for (const e of entries) {
|
|
258
|
+
const status = e.error
|
|
259
|
+
? `ERROR (${e.error})`
|
|
260
|
+
: e.timedOut
|
|
261
|
+
? `TIMEOUT after ${timeout}s`
|
|
262
|
+
: e.ok
|
|
263
|
+
? `OK exit=0`
|
|
264
|
+
: `FAIL exit=${e.exitCode}`;
|
|
265
|
+
lines.push(`--- ${e.device} [${status}] ${e.durationMs}ms ---`);
|
|
266
|
+
if (e.stdout) lines.push(e.stdout.trimEnd());
|
|
267
|
+
if (e.stderr) lines.push(`stderr: ${e.stderr.trimEnd()}`);
|
|
268
|
+
}
|
|
269
|
+
lines.push(
|
|
270
|
+
`\nSummary: ${okCount} ok, ${failCount} failed, ${timedOutCount} timed out, ${errorCount} errored — ${totalMs}ms total`,
|
|
271
|
+
);
|
|
272
|
+
|
|
273
|
+
const anyFailure = failCount + timedOutCount + errorCount > 0;
|
|
274
|
+
return {
|
|
275
|
+
content: [{ type: 'text', text: lines.join('\n') }],
|
|
276
|
+
isError: anyFailure,
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
|
|
113
280
|
export const tools = [
|
|
114
281
|
{
|
|
115
282
|
name: 'thinr_products',
|
|
@@ -211,6 +378,51 @@ The file extension (.sh, .py, .js, …) is preserved in the storage for editor h
|
|
|
211
378
|
},
|
|
212
379
|
handler: toolProductScriptWrite,
|
|
213
380
|
},
|
|
381
|
+
{
|
|
382
|
+
name: 'thinr_product_exec',
|
|
383
|
+
description: `Execute a shell command in parallel on every active device of a product, and return stdout, stderr, exit code and duration per device plus a consolidated summary.
|
|
384
|
+
|
|
385
|
+
Prefer this over iterating \`thinr_exec\` device-by-device when you need to roll out or inspect something across a fleet. Offline devices are skipped by default; pass \`all: true\` to attempt them anyway (they will typically fail fast with a connection error). \`concurrency\` bounds how many devices run at once (default 10), \`timeout\` is per-device in seconds (default 30), and \`fail_fast\` stops dequeueing new devices as soon as one returns a non-zero exit / times out — in-flight work still completes.`,
|
|
386
|
+
inputSchema: {
|
|
387
|
+
type: 'object',
|
|
388
|
+
properties: {
|
|
389
|
+
product: {
|
|
390
|
+
type: 'string',
|
|
391
|
+
description: 'Product ID (discover with thinr_products).',
|
|
392
|
+
},
|
|
393
|
+
command: {
|
|
394
|
+
type: 'string',
|
|
395
|
+
description: 'Shell command to run on each device.',
|
|
396
|
+
},
|
|
397
|
+
group: {
|
|
398
|
+
type: 'string',
|
|
399
|
+
description:
|
|
400
|
+
'Optional asset group filter — restricts execution to devices in this group.',
|
|
401
|
+
},
|
|
402
|
+
concurrency: {
|
|
403
|
+
type: 'number',
|
|
404
|
+
description: 'Max parallel executions (default: 10).',
|
|
405
|
+
},
|
|
406
|
+
timeout: {
|
|
407
|
+
type: 'number',
|
|
408
|
+
description: 'Per-device timeout in seconds (default: 30).',
|
|
409
|
+
},
|
|
410
|
+
fail_fast: {
|
|
411
|
+
type: 'boolean',
|
|
412
|
+
description:
|
|
413
|
+
'Stop dequeueing new devices as soon as one fails (default: false).',
|
|
414
|
+
},
|
|
415
|
+
all: {
|
|
416
|
+
type: 'boolean',
|
|
417
|
+
description:
|
|
418
|
+
'Include offline devices (default: false — only active devices are targeted).',
|
|
419
|
+
},
|
|
420
|
+
user: { type: 'string', description: 'API user (admin impersonation).' },
|
|
421
|
+
},
|
|
422
|
+
required: ['product', 'command'],
|
|
423
|
+
},
|
|
424
|
+
handler: toolProductExec,
|
|
425
|
+
},
|
|
214
426
|
{
|
|
215
427
|
name: 'thinr_product_script_delete',
|
|
216
428
|
description: `Remove a product script: deletes the file under scripts/ in the product storage and the associated API resource.`,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@thinremote/thinr-cli",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0-alpha.1",
|
|
4
4
|
"description": "CLI for ThinRemote — remote management for IoT devices, with an MCP server for AI clients",
|
|
5
5
|
"main": "bin/thinr.js",
|
|
6
6
|
"bin": {
|
|
@@ -46,6 +46,7 @@
|
|
|
46
46
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
47
47
|
"axios": "^1.10.0",
|
|
48
48
|
"chalk": "^5.4.1",
|
|
49
|
+
"cli-table3": "^0.6.5",
|
|
49
50
|
"commander": "^14.0.0",
|
|
50
51
|
"conf": "^15.1.0",
|
|
51
52
|
"figlet": "^1.8.1",
|