@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
@@ -1,407 +0,0 @@
1
- import chalk from 'chalk';
2
- import Table from 'cli-table3';
3
- import { InvalidArgumentError } from 'commander';
4
- import { configExists } from '../lib/config.js';
5
- import { formatDeviceProperty, getDeviceProperty } from '../lib/property.js';
6
- import { callDeviceResource } from '../lib/resource.js';
7
- import { filterActiveDevices, getDevices } from '../lib/devices.js';
8
- import { createDeviceAPI } from '../lib/device-api.js';
9
- import { runPool } from '../lib/concurrency.js';
10
- import {
11
- setJsonMode,
12
- isJsonMode,
13
- printOk,
14
- printErr,
15
- createSpinner,
16
- classifyError,
17
- } from '../lib/output.js';
18
-
19
- function ensureConfigured() {
20
- if (!configExists()) {
21
- printErr('Not configured. Run thinr without parameters to set up.', {
22
- code: 'not_configured',
23
- });
24
- }
25
- }
26
-
27
- function applyJsonFlag(opts) {
28
- if (opts.json) setJsonMode(true);
29
- }
30
-
31
- function getGlobalUser(cmd) {
32
- let root = cmd;
33
- while (root.parent) root = root.parent;
34
- return root.opts().user || null;
35
- }
36
-
37
- function collectInput(value, previous = {}) {
38
- const idx = value.indexOf('=');
39
- if (idx === -1) throw new InvalidArgumentError('must be key=value');
40
- return { ...previous, [value.slice(0, idx)]: value.slice(idx + 1) };
41
- }
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
-
53
- async function fetchProductDevices(productId, group, user) {
54
- const filter = { productId };
55
- if (group) filter.asset_group = group;
56
- return getDevices(filter, user);
57
- }
58
-
59
- /**
60
- * `thinr product <subcommand> <productId> …` — fan-out commands across
61
- * every device that belongs to a product.
62
- */
63
- export function productCommand(program) {
64
- const product = program
65
- .command('product')
66
- .description('Product commands (subcommand-first: thinr product <action> <productId>)');
67
-
68
- product
69
- .command('property <productId> <propertyId>')
70
- .description('Read a property on every device of the product')
71
- .option('-j, --json', 'Output as JSON')
72
- .option('-f, --field <field>', 'Extract a sub-field from each property (dot path)')
73
- .option('-a, --all', 'Include offline devices (default: only active)')
74
- .option('-g, --group <group>', 'Filter devices by asset group')
75
- .action(async (productId, propertyId, opts, cmd) => {
76
- applyJsonFlag(opts);
77
- ensureConfigured();
78
- const user = getGlobalUser(cmd);
79
- const spinner = createSpinner(`Retrieving devices for product ${productId}...`).start();
80
- let devices;
81
- try {
82
- devices = await fetchProductDevices(productId, opts.group, user);
83
- spinner.succeed(`Devices for product ${productId} retrieved successfully`);
84
- } catch (error) {
85
- spinner.fail(`Failed to retrieve devices for product ${productId}`);
86
- const { message, code } = classifyError(error);
87
- printErr(message, { code });
88
- return;
89
- }
90
- if (!opts.all) devices = filterActiveDevices(devices);
91
-
92
- const results = [];
93
- for (const device of devices) {
94
- try {
95
- const property = await getDeviceProperty(device.device, propertyId);
96
- const value = opts.field
97
- ? opts.field.split('.').reduce((obj, key) => obj && obj[key], property)
98
- : property;
99
- results.push({ device: device.device, ok: true, data: value });
100
- if (!isJsonMode()) {
101
- console.log(
102
- 'Device',
103
- chalk.blue(device.device),
104
- 'property',
105
- chalk.blue(propertyId),
106
- );
107
- if (opts.field) console.log(value);
108
- else console.log(formatDeviceProperty(device.device, propertyId, property));
109
- }
110
- } catch (error) {
111
- const { message, code } = classifyError(error);
112
- results.push({ device: device.device, ok: false, error: { message, code } });
113
- if (!isJsonMode()) {
114
- console.error(
115
- chalk.red(
116
- `Error retrieving property ${propertyId} for device ${device.device}: ${message}`,
117
- ),
118
- );
119
- }
120
- }
121
- }
122
- if (isJsonMode()) printOk({ product: productId, property: propertyId, results });
123
- });
124
-
125
- product
126
- .command('resource <productId> <resource>')
127
- .description('Call a resource on every active device of the product')
128
- .option('-j, --json', 'Output as JSON')
129
- .option('-f, --field <field>', 'Extract a sub-field from each result (dot path)')
130
- .option('-g, --group <group>', 'Filter devices by asset group')
131
- .option('-i, --input <key=value>', 'Resource input (repeatable)', collectInput, {})
132
- .action(async (productId, resource, opts, cmd) => {
133
- applyJsonFlag(opts);
134
- ensureConfigured();
135
- const user = getGlobalUser(cmd);
136
- const spinner = createSpinner(`Retrieving devices for product ${productId}...`).start();
137
- let devices;
138
- try {
139
- devices = await fetchProductDevices(productId, opts.group, user);
140
- spinner.succeed(`Devices for product ${productId} retrieved successfully`);
141
- } catch (error) {
142
- spinner.fail(`Failed to retrieve devices for product ${productId}`);
143
- const { message, code } = classifyError(error);
144
- printErr(message, { code });
145
- return;
146
- }
147
- devices = filterActiveDevices(devices);
148
-
149
- const results = [];
150
- for (const device of devices) {
151
- try {
152
- const result = await callDeviceResource(device.device, resource, opts.input);
153
- const value = opts.field
154
- ? opts.field.split('.').reduce((obj, key) => obj && obj[key], result)
155
- : result;
156
- results.push({ device: device.device, ok: true, data: value });
157
- if (!isJsonMode()) {
158
- console.log(
159
- 'Device',
160
- chalk.blue(device.device),
161
- 'resource',
162
- chalk.blue(resource),
163
- );
164
- console.log(value);
165
- }
166
- } catch (error) {
167
- const { message, code } = classifyError(error);
168
- results.push({ device: device.device, ok: false, error: { message, code } });
169
- if (!isJsonMode()) {
170
- console.error(
171
- chalk.red(
172
- `Error executing resource ${resource} for device ${device.device}: ${message}`,
173
- ),
174
- );
175
- }
176
- }
177
- }
178
- if (isJsonMode()) printOk({ product: productId, resource, results });
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
- });
407
- }