@thinremote/thinr-cli 1.5.0 → 1.6.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.
Files changed (46) hide show
  1. package/CHANGELOG.md +113 -0
  2. package/README.md +167 -120
  3. package/bin/thinr.js +3 -58
  4. package/commands/_shared.js +1 -0
  5. package/commands/{device/update.js → agent/device.js} +10 -26
  6. package/commands/agent/fleet.js +254 -0
  7. package/commands/agent/index.js +79 -0
  8. package/commands/device/console.js +1 -1
  9. package/commands/device/exec.js +1 -1
  10. package/commands/device/fs.js +6 -6
  11. package/commands/device/index.js +4 -6
  12. package/commands/device/logs.js +1 -1
  13. package/commands/device/proxy.js +15 -6
  14. package/commands/device/status.js +1 -1
  15. package/commands/device/transfer.js +2 -2
  16. package/commands/product/exec.js +1 -1
  17. package/commands/product/fs.js +3 -3
  18. package/commands/product/index.js +5 -4
  19. package/commands/product/logs.js +35 -36
  20. package/commands/product/metric.js +24 -14
  21. package/commands/product/playbook.js +10 -5
  22. package/commands/product/property.js +4 -14
  23. package/commands/product/push.js +1 -1
  24. package/commands/product/resource.js +56 -56
  25. package/commands/product/status.js +1 -1
  26. package/docs/guides/thinremote-metrics-logs.md +21 -22
  27. package/docs/guides/thinremote-playbooks.md +15 -11
  28. package/docs/guides/thinremote-scripts.md +11 -10
  29. package/docs/guides/thinremote.md +53 -42
  30. package/lib/agent-releases.js +1 -1
  31. package/lib/cli.js +172 -0
  32. package/lib/constants.js +1 -1
  33. package/lib/guides.js +1 -1
  34. package/lib/identity.js +8 -6
  35. package/lib/mcp/{tools-exec.js → device-exec.js} +84 -55
  36. package/lib/mcp/helpers.js +2 -2
  37. package/lib/mcp/resource-hooks.js +104 -6
  38. package/lib/mcp/resource-types.js +114 -227
  39. package/lib/playbook/actions.js +1 -1
  40. package/lib/playbook/schema.js +1 -1
  41. package/lib/product/log-presets.js +1 -1
  42. package/lib/product.js +26 -0
  43. package/package.json +2 -2
  44. package/commands/fleet/index.js +0 -17
  45. package/commands/fleet/upgrade.js +0 -275
  46. package/lib/mcp/registry.js +0 -12
@@ -8,25 +8,22 @@ import {
8
8
  } from '@thinger-io/cli-core/resource';
9
9
  import { getDevices } from '@thinger-io/cli-core/devices';
10
10
  import { inputError } from '@thinger-io/cli-core/errors';
11
- import { toolName } from '@thinger-io/cli-core/mcp/naming';
11
+ import { opRef } from '@thinger-io/cli-core/mcp/naming';
12
12
  import {
13
13
  deleteProductPlaybook,
14
14
  findProductPlaybook,
15
15
  installProductScript,
16
- listDashboardMetrics,
17
16
  listProductPlaybooks,
18
17
  listProductScripts,
19
18
  readProductPlaybook,
20
19
  readProductScript,
21
- removeDashboardMetric,
22
20
  removeProductScript,
23
- upsertDashboardMetric,
24
21
  uploadProductPlaybook,
25
22
  } from '../product.js';
26
- import { addLogSource, getProductLogs, listPresets, removeLogSource } from '../product/logs.js';
27
23
  import { localFileToRead, localFileToWrite } from '../local-files.js';
28
24
  import { getAPI } from './helpers.js';
29
25
  import { agentInstallCommand } from './agent-install.js';
26
+ import { execAction } from './device-exec.js';
30
27
  import {
31
28
  DEFAULT_FAILURE_THRESHOLD,
32
29
  playbookSchema,
@@ -39,9 +36,10 @@ import {
39
36
  /**
40
37
  * ThinRemote's own resources, served by the generic `resource_*` tools of
41
38
  * cli-core next to the server's: a device's file system and scripts, and
42
- * a product's scripts, dashboard metrics, log sources and playbooks. Plus
43
- * two actions on the server's `device` type: updating the agent and the
44
- * command that installs it.
39
+ * a product's scripts and playbooks. Plus three actions on the server's
40
+ * `device` type: a shell command, updating the agent and the command that
41
+ * installs it. (The product's dashboard metrics and log sources are its
42
+ * product properties `dashboard_metrics` and `logs`: resource-hooks.js.)
45
43
  *
46
44
  * Create fails when the record is already there and update when it is
47
45
  * not, as on the server; a file of `device_fs` is written either way.
@@ -51,9 +49,9 @@ import {
51
49
  /** @typedef {import('@thinger-io/cli-core/mcp/tools-resource').LocalAction} LocalAction */
52
50
 
53
51
  /**
54
- * One write at a time per product and kind: metrics, log sources and the
55
- * playbook index are each one product property, read, changed and written
56
- * back, and a client often sends several creates at once.
52
+ * One write at a time per product and kind: the scripts and the playbook
53
+ * index are each read, changed and written back, and a client often sends
54
+ * several creates at once.
57
55
  * @type {Map<string, Promise<unknown>>}
58
56
  */
59
57
  const queues = new Map();
@@ -93,7 +91,7 @@ const deviceFs = {
93
91
  schema: {
94
92
  type: 'object',
95
93
  properties: {
96
- path: { type: 'string', description: 'Absolute path on the device (the id).' },
94
+ path: { type: 'string', description: 'Absolute path on the device (ids.path).' },
97
95
  content: { type: 'string', description: 'Text to write (create, update).' },
98
96
  type: {
99
97
  type: 'string',
@@ -102,7 +100,31 @@ const deviceFs = {
102
100
  },
103
101
  },
104
102
  },
105
- notes: 'Paths are absolute on the device and act as the agent\'s OS user (root for a system install; check before writing to /etc). resource_list takes filter {path: "<folder>", include_hidden?: true} (default "/", which means the agent\'s working directory, normally the root). resource_get reads a text file; binary files are copied with the pull action. resource_create writes a file (body {content}) or creates a folder (body {type: "directory"}); resource_update writes a file whether it exists or not; resource_delete deletes a file or a folder with all it holds, never "/" or a folder right under it (use device_exec for that). push and pull read and write files of the machine running this MCP server only inside its local folder (THINR_MCP_LOCAL_DIR, or where the server started), and pull replaces a file only with body.overwrite.',
103
+ notes: 'ids.path is absolute on the device; the tools act as the agent\'s OS user (root for a system install; check before writing to /etc). resource_list takes filter {path: "<folder>", include_hidden?: true} (default "/", which means the agent\'s working directory, normally the root). resource_get reads a text file; binary files are copied with the pull action. resource_create writes a file (body {content}) or creates a folder (body {type: "directory"}); resource_update writes a file whether it exists or not; resource_delete deletes a file or a folder with all it holds, never "/" or a folder right under it (use {op:device.exec} for that). push and pull read and write files of the machine running this MCP server only inside its local folder (THINR_MCP_LOCAL_DIR, or where the server started), and pull replaces a file only with body.overwrite.',
104
+ filter: {
105
+ type: 'object',
106
+ additionalProperties: false,
107
+ properties: {
108
+ path: { type: 'string', description: 'Folder to list (default "/").' },
109
+ include_hidden: { type: 'boolean', description: 'Also dot files.' },
110
+ },
111
+ },
112
+ examples: [
113
+ {
114
+ tool: 'resource_list',
115
+ arguments: { type: 'device_fs', ids: { device: 'gw_1' }, filter: { path: '/etc' } },
116
+ does: 'List /etc on the device gw_1.',
117
+ },
118
+ {
119
+ tool: 'resource_create',
120
+ arguments: {
121
+ type: 'device_fs',
122
+ ids: { device: 'gw_1', path: '/tmp/app.conf' },
123
+ body: { content: 'port=8080\n' },
124
+ },
125
+ does: 'Write a text file on the device.',
126
+ },
127
+ ],
106
128
  async list(ctx) {
107
129
  const { api } = getAPI(ctx.parent, ctx.user);
108
130
  const asked = ctx.filter?.path || '/';
@@ -125,7 +147,7 @@ const deviceFs = {
125
147
  const { api } = getAPI(ctx.parent, ctx.user);
126
148
  const buf = await api.readFile(ctx.id);
127
149
  if (isBinary(buf)) {
128
- return `Binary file of ${buf.byteLength} bytes: copy it to this machine with ${toolName('resource_call')} action "pull".`;
150
+ return `Binary file of ${buf.byteLength} bytes: copy it to this machine with ${opRef('device_fs', 'pull')}.`;
129
151
  }
130
152
  return buf.toString('utf8');
131
153
  },
@@ -152,7 +174,7 @@ const deviceFs = {
152
174
  const path = posix.normalize(String(ctx.id));
153
175
  if (!path.startsWith('/') || path.split('/').filter(Boolean).length < 2) {
154
176
  throw inputError(
155
- `Not deleting ${ctx.id}: only absolute paths at least two levels deep (/var/tmp/x). For more, ${toolName('device_exec')}.`,
177
+ `Not deleting ${ctx.id}: only absolute paths at least two levels deep (/var/tmp/x). For more, ${opRef('device', 'exec')}.`,
156
178
  );
157
179
  }
158
180
  const { api } = getAPI(ctx.parent, ctx.user);
@@ -161,7 +183,7 @@ const deviceFs = {
161
183
  actions: {
162
184
  move: {
163
185
  description:
164
- 'Move or rename the path `id` to body.destination; body.overwrite replaces what is there. Write to a temporary file and move it into place for a change that is never seen half done.',
186
+ 'Move or rename the path ids.path to body.destination; body.overwrite replaces what is there. Write to a temporary file and move it into place for a change that is never seen half done.',
165
187
  body: {
166
188
  type: 'object',
167
189
  properties: { destination: { type: 'string' }, overwrite: { type: 'boolean' } },
@@ -201,7 +223,7 @@ const deviceFs = {
201
223
  },
202
224
  pull: {
203
225
  description:
204
- 'Copy the file `id` of the device to the machine running this MCP server (body.destination, inside its local folder: see notes; ending in "/" keeps the name). An existing file is replaced only with body.overwrite.',
226
+ 'Copy the file ids.path of the device to the machine running this MCP server (body.destination, inside its local folder: see notes; ending in "/" keeps the name). An existing file is replaced only with body.overwrite.',
205
227
  body: {
206
228
  type: 'object',
207
229
  properties: { destination: { type: 'string' }, overwrite: { type: 'boolean' } },
@@ -284,7 +306,7 @@ async function installDeviceScript(body, ctx, { replace }) {
284
306
  );
285
307
  const found = (reloaded?.scripts || []).find((s) => s.name === resource);
286
308
  return [
287
- `Script at ${path}; resource "${resource}": call it with ${toolName('device_resource_call')}.`,
309
+ `Script at ${path}; resource "${resource}": call it with ${opRef('device_resource', 'call')} ids {"device": "${ctx.parent}", "resource": "${resource}"}.`,
288
310
  found?.describe
289
311
  ? `Schema: ${JSON.stringify(found.describe)}`
290
312
  : 'Schema: none (the script does not answer --describe): any input, raw output.',
@@ -296,13 +318,14 @@ const deviceScript = {
296
318
  description:
297
319
  'Scripts installed on a device with the agent: each becomes a resource of the device (battery.sh → "battery").',
298
320
  parent: 'device',
299
- id: 'name',
321
+ id: 'script',
322
+ bodyId: 'name',
300
323
  schema: {
301
324
  type: 'object',
302
325
  properties: {
303
326
  name: {
304
327
  type: 'string',
305
- description: 'File name (the id); without extension it names the resource.',
328
+ description: 'File name (ids.script); without extension it names the resource.',
306
329
  },
307
330
  content: { type: 'string', description: 'Script source, shebang included.' },
308
331
  force: {
@@ -312,7 +335,7 @@ const deviceScript = {
312
335
  },
313
336
  },
314
337
  },
315
- notes: 'The id is the file name with its extension ("uptime.sh"), in create, update and delete alike; the resource it becomes is named without it ("uptime"). Scripts run on whatever OS the device has: check it (resource_get of the device, agent.system_info) before relying on /proc or GNU tools. A script runs with the agent\'s privileges (root for a system install): installing one grants permanent remote execution of that code. Contract (--describe, JSON on stdin and stdout): {tool:doc_read} id "thinremote-scripts". For every device of a product use type "product_script" instead.',
338
+ notes: 'ids.script is the file name with its extension ("uptime.sh"), in create, update and delete alike; the resource it becomes is named without it ("uptime"). Scripts run on whatever OS the device has: check it (resource_get of the device, agent.system_info) before relying on /proc or GNU tools. A script runs with the agent\'s privileges (root for a system install): installing one grants permanent remote execution of that code. Contract (--describe, JSON on stdin and stdout): {op:doc.get} ids {"doc": "thinremote-scripts"}. For every device of a product use type "product_script" instead.',
316
339
  async list(ctx) {
317
340
  const { scripts } = await scriptsInfo(ctx.parent, ctx.user);
318
341
  return scripts.map((s) => ({
@@ -368,7 +391,7 @@ async function writeProductScript(body, ctx, { replace }) {
368
391
  });
369
392
  return [
370
393
  ...steps.map((s) => `- ${s}`),
371
- `Call it on any device of the product: ${toolName('device_resource_call')} resource "${stem}".`,
394
+ `Call it on any device of the product: ${opRef('device_resource', 'call')} ids {"device": <device>, "resource": "${stem}"}.`,
372
395
  ].join('\n');
373
396
  }
374
397
 
@@ -377,11 +400,12 @@ const productScript = {
377
400
  description:
378
401
  'Scripts of a product: kept once in its storage, they run on any of its devices as a product API resource named after the file (backup.sh → "backup").',
379
402
  parent: 'product',
380
- id: 'name',
403
+ id: 'script',
404
+ bodyId: 'name',
381
405
  schema: {
382
406
  type: 'object',
383
407
  properties: {
384
- name: { type: 'string', description: 'File name (the id), e.g. "backup.sh".' },
408
+ name: { type: 'string', description: 'File name (ids.script), e.g. "backup.sh".' },
385
409
  content: { type: 'string', description: 'Script source.' },
386
410
  input: {
387
411
  type: 'object',
@@ -395,7 +419,7 @@ const productScript = {
395
419
  },
396
420
  },
397
421
  },
398
- notes: 'Writing one sets up all it needs (storage, wrapper, product code, API resource); the product must exist. Records listed with registered: false are files without their API resource. Contract and examples: {tool:doc_read} id "thinremote-scripts".',
422
+ notes: 'Writing one sets up all it needs (storage, wrapper, product code, API resource); the product must exist. Records listed with registered: false are files without their API resource. Contract and examples: {op:doc.get} ids {"doc": "thinremote-scripts"}.',
399
423
  list: (ctx) => listProductScripts(ctx.parent, ctx.user),
400
424
  async get(ctx) {
401
425
  return { name: ctx.id, content: await readProductScript(ctx.parent, ctx.id, ctx.user) };
@@ -412,190 +436,6 @@ const productScript = {
412
436
  },
413
437
  };
414
438
 
415
- // ─── product_metric ─────────────────────────────────────────────────
416
-
417
- const METRIC_FIELDS = [
418
- 'label',
419
- 'resource',
420
- 'field',
421
- 'aggregation',
422
- 'visualization',
423
- 'interval',
424
- 'unit',
425
- ];
426
-
427
- async function writeMetric(body, ctx, { replace }) {
428
- const list = await listDashboardMetrics(ctx.parent, ctx.user);
429
- const current = list.find((m) => m.name === ctx.id);
430
- if (current && !replace) throw exists('metric', ctx.id, `product ${ctx.parent}`);
431
- if (!current && replace) throw missing('metric', ctx.id, `product ${ctx.parent}`);
432
- const metric = { ...(current || {}), name: ctx.id };
433
- for (const k of METRIC_FIELDS) if (body[k] !== undefined) metric[k] = body[k];
434
- const { count } = await upsertDashboardMetric(ctx.parent, metric, ctx.user);
435
- return `The product has ${count} metric(s).`;
436
- }
437
-
438
- /** @type {LocalType} */
439
- const productMetric = {
440
- description:
441
- "Metrics of thinr's terminal dashboard for a product (not a console dashboard): a value asked to every device and combined across the fleet.",
442
- parent: 'product',
443
- id: 'name',
444
- schema: {
445
- type: 'object',
446
- properties: {
447
- name: { type: 'string', description: 'Metric id.' },
448
- label: { type: 'string', description: 'Shown in the panel.' },
449
- resource: {
450
- type: 'string',
451
- description:
452
- 'Product API resource asked to each device (usually a product script). Required.',
453
- },
454
- field: {
455
- type: 'string',
456
- description: 'Dot path to the value in the answer; omit for scalars.',
457
- },
458
- aggregation: {
459
- type: 'string',
460
- enum: ['sum', 'avg', 'max', 'min', 'count', 'top', 'none', 'distribution'],
461
- description:
462
- 'How the devices\' values combine; "distribution" counts devices per value (versions, kernels).',
463
- },
464
- visualization: {
465
- type: 'string',
466
- enum: ['kpi', 'bar', 'sparkline', 'list'],
467
- description: 'kpi (default), sparkline (trend), list (pairs with distribution).',
468
- },
469
- interval: {
470
- type: 'number',
471
- description: 'Seconds between refreshes; omit to only relay what devices push.',
472
- },
473
- unit: { type: 'string', description: 'Suffix shown with the value ("%").' },
474
- },
475
- required: ['resource'],
476
- },
477
- notes: 'Kept in the product property "dashboard_metrics": change them here, not through the property. Recipe: {tool:doc_read} id "thinremote-metrics-logs".',
478
- list: (ctx) => listDashboardMetrics(ctx.parent, ctx.user),
479
- async get(ctx) {
480
- const metric = (await listDashboardMetrics(ctx.parent, ctx.user)).find(
481
- (m) => m.name === ctx.id,
482
- );
483
- if (!metric) throw missing('metric', ctx.id, `product ${ctx.parent}`);
484
- return metric;
485
- },
486
- create: (body, ctx) =>
487
- oneAtATime(ctx, 'metric', () => writeMetric(body, ctx, { replace: false })),
488
- update: (body, ctx) =>
489
- oneAtATime(ctx, 'metric', () => writeMetric(body, ctx, { replace: true })),
490
- async delete(ctx) {
491
- const { action } = await oneAtATime(ctx, 'metric', () =>
492
- removeDashboardMetric(ctx.parent, ctx.id, ctx.user),
493
- );
494
- if (action !== 'removed') throw missing('metric', ctx.id, `product ${ctx.parent}`);
495
- },
496
- };
497
-
498
- // ─── product_log_source ─────────────────────────────────────────────
499
-
500
- async function logSources(product, user) {
501
- const cfg = await getProductLogs(product, user);
502
- const fallback = !!(/** @type {any} */ (cfg).__fallback);
503
- const def = cfg.default || cfg.sources[0]?.name || null;
504
- return {
505
- fallback,
506
- sources: cfg.sources.map((s) => ({
507
- ...s,
508
- default: s.name === def,
509
- ...(fallback ? { fallback: true } : {}),
510
- })),
511
- };
512
- }
513
-
514
- async function writeLogSource(body, ctx, { replace }) {
515
- const { sources, fallback } = await logSources(ctx.parent, ctx.user);
516
- const current = fallback ? undefined : sources.find((s) => s.name === ctx.id);
517
- if (current && !replace) throw exists('log source', ctx.id, `product ${ctx.parent}`);
518
- if (!current && replace) throw missing('log source', ctx.id, `product ${ctx.parent}`);
519
- const command = body.command ?? current?.command;
520
- if (typeof command !== 'string' || !command.trim()) {
521
- throw inputError('body.command is required');
522
- }
523
- const { config } = await addLogSource(
524
- ctx.parent,
525
- {
526
- name: ctx.id,
527
- command,
528
- makeDefault: !!body.default,
529
- // Giving one of them (even null) replaces both: they exclude each other.
530
- ...('pattern' in body || 'preset' in body
531
- ? { pattern: body.pattern ?? undefined, preset: body.preset ?? undefined }
532
- : { pattern: current?.pattern, preset: current?.preset }),
533
- },
534
- ctx.user,
535
- );
536
- const def = config.default || config.sources[0]?.name;
537
- return `The product has ${config.sources.length} log source(s); default: ${def}.`;
538
- }
539
-
540
- /** @type {LocalType} */
541
- const productLogSource = {
542
- description:
543
- "Log sources of a product: named shell commands the agent streams when someone opens a device's logs in thinr.",
544
- parent: 'product',
545
- id: 'name',
546
- schema: {
547
- type: 'object',
548
- properties: {
549
- name: {
550
- type: 'string',
551
- description: 'Source id (letters, digits, _ and -; up to 32).',
552
- },
553
- command: {
554
- type: 'string',
555
- description:
556
- 'What the agent runs and streams: "journalctl -f", "docker logs -f app", "tail -F <file>".',
557
- },
558
- default: {
559
- type: 'boolean',
560
- description: 'Shown first (and by `thinr device logs` without --source).',
561
- },
562
- preset: {
563
- type: 'string',
564
- description: 'Named line format for structured rendering (see presets below).',
565
- },
566
- pattern: {
567
- type: 'string',
568
- description: 'Or a regex with named groups time, level and msg (not with preset).',
569
- },
570
- },
571
- required: ['command'],
572
- },
573
- notes: 'Without any source a product shows one `system` journal source, listed with fallback: true; removing the last one brings it back. Details: {tool:doc_read} id "thinremote-metrics-logs".',
574
- describe: () => ({
575
- presets: Object.fromEntries(listPresets().map((p) => [p.name, p.description])),
576
- }),
577
- list: async (ctx) => (await logSources(ctx.parent, ctx.user)).sources,
578
- async get(ctx) {
579
- const source = (await logSources(ctx.parent, ctx.user)).sources.find(
580
- (s) => s.name === ctx.id,
581
- );
582
- if (!source) throw missing('log source', ctx.id, `product ${ctx.parent}`);
583
- return source;
584
- },
585
- create: (body, ctx) =>
586
- oneAtATime(ctx, 'logs', () => writeLogSource(body, ctx, { replace: false })),
587
- update: (body, ctx) =>
588
- oneAtATime(ctx, 'logs', () => writeLogSource(body, ctx, { replace: true })),
589
- async delete(ctx) {
590
- const { removed, config } = await oneAtATime(ctx, 'logs', () =>
591
- removeLogSource(ctx.parent, ctx.id, ctx.user),
592
- );
593
- if (!removed) throw missing('log source', ctx.id, `product ${ctx.parent}`);
594
- if (config === null)
595
- return 'It was the last one: the product falls back to the system journal.';
596
- },
597
- };
598
-
599
439
  // ─── product_playbook ───────────────────────────────────────────────
600
440
 
601
441
  /** What resource_describe shows next to the format: fields at the top level. */
@@ -642,11 +482,15 @@ const productPlaybook = {
642
482
  description:
643
483
  'Playbooks of a product: YAML steps the agent runs on each device (write a file, run a command, install a script…), validated, tried and rolled out in batches.',
644
484
  parent: 'product',
645
- id: 'name',
485
+ id: 'playbook',
486
+ bodyId: 'name',
646
487
  schema: {
647
488
  type: 'object',
648
489
  properties: {
649
- name: { type: 'string', description: 'Playbook id (letters, digits, _ and -).' },
490
+ name: {
491
+ type: 'string',
492
+ description: 'Playbook id (ids.playbook; letters, digits, _ and -).',
493
+ },
650
494
  content: {
651
495
  type: 'string',
652
496
  description: 'The YAML, following `playbook_format` below.',
@@ -654,7 +498,19 @@ const productPlaybook = {
654
498
  description: { type: 'string', description: "Defaults to the YAML's own description." },
655
499
  },
656
500
  },
657
- notes: 'Flow: write the YAML following playbook_format, check it with the validate action, save it with resource_create (validated again: nothing is saved if invalid), try it with the run action on one device (dry_run, then check, then for real), then roll it out with the rollout action. The run and validate actions also take an unsaved playbook: no id, the YAML in body.content. Rollouts change real machines: confirm with the user and start with small batches. Guide: {tool:doc_read} id "thinremote-playbooks".',
501
+ notes: 'Flow: write the YAML following playbook_format, check it with the validate action, save it with resource_create (validated again: nothing is saved if invalid), try it with the run action on one device (dry_run, then check, then for real), then roll it out with the rollout action. The run and validate actions also take an unsaved playbook: no ids.playbook, the YAML in body.content. Rollouts and runs on several devices change real machines: they answer their plan first, run with confirm: true once the user agrees; start with small batches. Guide: {op:doc.get} ids {"doc": "thinremote-playbooks"}.',
502
+ examples: [
503
+ {
504
+ tool: 'resource_call',
505
+ arguments: {
506
+ type: 'product_playbook',
507
+ action: 'run',
508
+ ids: { product: 'gateways', playbook: 'app-config' },
509
+ body: { device: 'gw_1', check: true },
510
+ },
511
+ does: 'Contact one device read-only and say what each step of the saved playbook would change.',
512
+ },
513
+ ],
658
514
  describe: () => {
659
515
  // The schema calls the top-level fields "document": said plainly,
660
516
  // with an example, so nobody nests them under a `document:` key.
@@ -683,7 +539,7 @@ const productPlaybook = {
683
539
  actions: {
684
540
  validate: {
685
541
  description:
686
- 'Check a playbook without running or saving it: a summary, or every issue found. The saved one (`id`) or the YAML in body.content.',
542
+ 'Check a playbook without running or saving it: a summary, or every issue found. The saved one (ids.playbook) or the YAML in body.content.',
687
543
  on: 'either',
688
544
  body: { type: 'object', properties: { content: { type: 'string' } } },
689
545
  async run(body, ctx) {
@@ -693,8 +549,9 @@ const productPlaybook = {
693
549
  },
694
550
  run: {
695
551
  description:
696
- 'Run a playbook now, on the target given: body.device (one device: the pre-flight before a rollout), body.devices (of this product), body.group, or body.all: true for every device of the product at once (no batches or failure threshold: for a fleet, prefer rollout). The playbook is the saved one (`id`) or the YAML in body.content. body.dry_run gives the plan without contacting devices; body.check contacts them read-only and says what each step would change. body.vars overrides its variables (checked against what it declares).',
552
+ 'Run a playbook now, on the target given: body.device (one device: the pre-flight before a rollout), body.devices (of this product), body.group, or body.all: true for every device of the product at once (no batches or failure threshold: for a fleet, prefer rollout; on several devices it answers its targets until confirmed). The playbook is the saved one (ids.playbook) or the YAML in body.content. body.dry_run gives the plan without contacting devices; body.check contacts them read-only and says what each step would change. body.vars overrides its variables (checked against what it declares).',
697
553
  on: 'either',
554
+ confirm: true,
698
555
  body: {
699
556
  type: 'object',
700
557
  properties: {
@@ -729,6 +586,14 @@ const productPlaybook = {
729
586
  throw inputError(`Not devices of ${ctx.parent}: ${foreign.join(', ')}`);
730
587
  }
731
588
  }
589
+ if (!b.device && !b.dry_run && !b.check && !ctx.confirmed && ctx.plan) {
590
+ const which = listed
591
+ ? `the devices ${b.devices.join(', ')}`
592
+ : await groupDevices(ctx, b.group);
593
+ return ctx.plan(
594
+ `It would run the playbook ${ctx.id ? `"${ctx.id}"` : 'given in body.content'} now on ${which}, all at once.`,
595
+ );
596
+ }
732
597
  if (ctx.id && b.device && !b.content) {
733
598
  return toolProductPlaybookRun({
734
599
  product: ctx.parent,
@@ -757,7 +622,8 @@ const productPlaybook = {
757
622
  },
758
623
  },
759
624
  rollout: {
760
- description: `Roll the saved playbook \`id\` out across the product's devices in batches of body.batch_size (default 5), stopping when the failure rate reaches body.failure_threshold (percent, default ${DEFAULT_FAILURE_THRESHOLD}). Narrow it with body.group or body.filter; offline devices are skipped unless body.include_offline. body.dry_run lists plan and devices; body.check says what would change. Writes a report to playbooks/runs/ in the product storage. Changes real machines: confirm with the user and try it with run on one device first.`,
625
+ description: `Roll the saved playbook ids.playbook out across the product's devices in batches of body.batch_size (default 5), stopping when the failure rate reaches body.failure_threshold (percent, default ${DEFAULT_FAILURE_THRESHOLD}). Narrow it with body.group or body.filter; offline devices are skipped unless body.include_offline. body.dry_run lists plan and devices; body.check says what would change. Writes a report to playbooks/runs/ in the product storage. Changes real machines: it answers its plan (devices, batches) until confirmed; try it with run on one device first.`,
626
+ confirm: true,
761
627
  body: {
762
628
  type: 'object',
763
629
  properties: {
@@ -771,24 +637,46 @@ const productPlaybook = {
771
637
  check: { type: 'boolean' },
772
638
  },
773
639
  },
774
- run(body, ctx) {
640
+ async run(body, ctx) {
775
641
  const b = body && typeof body === 'object' ? body : {};
776
- return toolProductPlaybookRollout({
777
- ...b,
778
- product: ctx.parent,
779
- name: ctx.id,
780
- user: ctx.user,
781
- });
642
+ const args = { ...b, product: ctx.parent, name: ctx.id, user: ctx.user };
643
+ if (!b.dry_run && !b.check && !ctx.confirmed && ctx.plan) {
644
+ return ctx.plan(
645
+ rolloutPlan(await toolProductPlaybookRollout({ ...args, dry_run: true })),
646
+ );
647
+ }
648
+ return toolProductPlaybookRollout(args);
782
649
  },
783
650
  },
784
651
  },
785
652
  };
786
653
 
787
- /** The YAML an action works on: body.content, or the saved playbook `id`. */
654
+ /** The devices of a product (or of one of its groups) a run reaches, in words. */
655
+ async function groupDevices(ctx, group) {
656
+ const filter = { product: ctx.parent, ...(group ? { asset_group: group } : {}) };
657
+ const ids = (await getDevices(filter, ctx.user)).map((d) => d.device);
658
+ const where = group ? `group "${group}" of product "${ctx.parent}"` : `product "${ctx.parent}"`;
659
+ return `every device of ${where} (${ids.length}${ids.length ? `: ${ids.join(', ')}` : ''})`;
660
+ }
661
+
662
+ /** What a rollout would do, from its dry run. */
663
+ function rolloutPlan(dry) {
664
+ const text = dry?.content?.[0]?.text ?? '';
665
+ try {
666
+ const p = JSON.parse(text);
667
+ return `It would roll out the playbook "${p.name}" on ${p.device_count} device(s) of product "${p.product}" in batches of ${p.batch_size}, stopping at ${p.failure_threshold}% failures: ${(p.devices || []).join(', ')}. Steps: ${(p.steps || []).length}.`;
668
+ } catch {
669
+ return `It would roll out the playbook:\n${text}`;
670
+ }
671
+ }
672
+
673
+ /** The YAML an action works on: body.content, or the saved playbook ids.playbook. */
788
674
  async function playbookSource(body, ctx) {
789
675
  if (typeof body?.content === 'string' && body.content.trim()) return body.content;
790
676
  if (!ctx.id)
791
- throw inputError('give the playbook: `id` (saved on the product) or body.content (YAML)');
677
+ throw inputError(
678
+ 'give the playbook: ids.playbook (saved on the product) or body.content (YAML)',
679
+ );
792
680
  const entry = await findProductPlaybook(ctx.parent, ctx.id, ctx.user);
793
681
  if (!entry) {
794
682
  const names = (await listProductPlaybooks(ctx.parent, ctx.user)).map((e) => e.name);
@@ -806,17 +694,16 @@ export const resourceTypes = {
806
694
  device_fs: deviceFs,
807
695
  device_script: deviceScript,
808
696
  product_script: productScript,
809
- product_metric: productMetric,
810
- product_log_source: productLogSource,
811
697
  product_playbook: productPlaybook,
812
698
  };
813
699
 
814
700
  /** @type {Record<string, Record<string, LocalAction>>} */
815
701
  export const resourceActions = {
816
702
  device: {
703
+ exec: execAction,
817
704
  agent_update: {
818
705
  description:
819
- 'Check for a new version of the ThinRemote agent of device `id` (body {action: "check"}) or install it (body {action: "apply", channel?: "latest" | "main" | "develop"}).',
706
+ 'Check for a new version of the ThinRemote agent of the device ids.device (body {action: "check"}) or install it (body {action: "apply", channel?: "latest" | "main" | "develop"}).',
820
707
  body: {
821
708
  type: 'object',
822
709
  properties: {
@@ -160,7 +160,7 @@ export const HANDLERS = {
160
160
  if (op !== 'check' && op !== 'apply') throw new Error('`op` must be "check" or "apply"');
161
161
  const payload = { action: op, channel: params.channel || 'latest' };
162
162
  // Agent updates can take a while. Give `apply` the same 5-min
163
- // window the CLI's `thinr device update apply` uses.
163
+ // window the CLI's `thinr agent update` uses.
164
164
  const timeout =
165
165
  op === 'apply' ? TIMEOUTS.DEVICE_UPDATE_APPLY_MS : TIMEOUTS.DEVICE_RESOURCE_CALL_MS;
166
166
  const result = await ctx.api.callResource('update', payload, { timeout });
@@ -216,7 +216,7 @@ export const ACTION_BY_NAME = Object.fromEntries(ACTIONS.map((a) => [a.name, a])
216
216
 
217
217
  /**
218
218
  * JSON-shaped summary of the accepted playbook format. Consumed by the
219
- * MCP `thinr_playbook_schema` tool so an AI agent can author playbooks
219
+ * MCP's resource_describe of type `product_playbook` so an agent can author playbooks
220
220
  * that validate on the first try.
221
221
  */
222
222
  export function playbookSchema() {
@@ -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 `thinr_resource_describe` type `product_log_source` (MCP).
118
+ * and by `thinr_resource_describe` type `product_property` (MCP).
119
119
  *
120
120
  * @returns {LogPreset[]}
121
121
  */
package/lib/product.js CHANGED
@@ -374,6 +374,32 @@ function validateMetric(m) {
374
374
  }
375
375
  }
376
376
 
377
+ /**
378
+ * The whole value of the `dashboard_metrics` property, as written through
379
+ * the resource tools: an array of metrics with unique names.
380
+ *
381
+ * @param {unknown} value
382
+ * @returns {any[]}
383
+ */
384
+ export function validateDashboardMetrics(value) {
385
+ if (!Array.isArray(value)) {
386
+ throw inputError('dashboard_metrics must be an array of metrics');
387
+ }
388
+ const seen = new Set();
389
+ value.forEach((m, i) => {
390
+ try {
391
+ validateMetric(m);
392
+ } catch (e) {
393
+ throw inputError(`dashboard_metrics[${i}]: ${e.message}`);
394
+ }
395
+ if (seen.has(m.name)) {
396
+ throw inputError(`dashboard_metrics[${i}].name "${m.name}" is duplicated`);
397
+ }
398
+ seen.add(m.name);
399
+ });
400
+ return value;
401
+ }
402
+
377
403
  async function loadMetricList(productId, user) {
378
404
  try {
379
405
  const value = await getProductProperty(productId, DASHBOARD_METRICS_PROPERTY, user);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thinremote/thinr-cli",
3
- "version": "1.5.0",
3
+ "version": "1.6.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": {
@@ -50,7 +50,7 @@
50
50
  "@inkjs/ui": "^2.0.0",
51
51
  "@inquirer/prompts": "^8.4.1",
52
52
  "@modelcontextprotocol/sdk": "^1.29.0",
53
- "@thinger-io/cli-core": "^0.4.0",
53
+ "@thinger-io/cli-core": "^0.5.1",
54
54
  "axios": "^1.10.0",
55
55
  "chalk": "^5.4.1",
56
56
  "cli-progress": "^3.12.0",
@@ -1,17 +0,0 @@
1
- // @ts-check
2
- import { registerUpgradeCommand } from './upgrade.js';
3
-
4
- /**
5
- * `thinr fleet <subcommand>` — operations that treat the whole device
6
- * fleet as the unit of work, not a single device. Today we only expose
7
- * `upgrade`; future fan-out commands (status summary, health probe,
8
- * rollback, etc.) would live alongside it.
9
- */
10
- export function fleetCommand(program) {
11
- const fleet = program
12
- .command('fleet')
13
- .helpGroup('Operations:')
14
- .description('Fleet-wide commands (run once, touch many devices)');
15
-
16
- registerUpgradeCommand(fleet);
17
- }