@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
@@ -85,21 +85,25 @@ async function targetsOf(args) {
85
85
  return { devices: active, offline };
86
86
  }
87
87
 
88
- async function toolExec(args) {
88
+ /**
89
+ * @param {Record<string, any>} args The body, with `device` (ids.device) and `user`.
90
+ * @param {import('@thinger-io/cli-core/mcp/tools-resource').LocalContext} [ctx]
91
+ */
92
+ async function toolExec(args, ctx) {
89
93
  if (typeof args.command !== 'string' || !args.command.trim()) {
90
- throw inputError('command is required');
94
+ throw inputError('body.command is required');
91
95
  }
92
96
  const listed = Array.isArray(args.devices) && args.devices.length > 0;
93
97
  const fleet = listed || args.product || args.group;
94
98
  const kinds = [args.device, listed, args.product || args.group, args.all].filter(Boolean);
95
99
  if (kinds.length > 1) {
96
100
  throw inputError(
97
- 'give one kind of target: `device`, `devices`, `product` (and `group`) or `group`, or `all`',
101
+ 'give one kind of target: ids.device, or in the body `devices`, `product` (and `group`) or `group`, or `all`',
98
102
  );
99
103
  }
100
104
  if (!args.device && !fleet && !args.all) {
101
105
  throw inputError(
102
- 'give the target: `device` for one device; `devices`, `product` or `group` for several; `all: true` for every device of the ThinRemote products',
106
+ 'give the target: ids.device for one device; body `devices`, `product` or `group` for several; body `all: true` for every device of the ThinRemote products',
103
107
  );
104
108
  }
105
109
  // Default guards against hung commands locking the session; callers
@@ -131,6 +135,13 @@ async function toolExec(args) {
131
135
  isError: offline.length > 0,
132
136
  };
133
137
  }
138
+ // On several devices it asks first: the plan names every target.
139
+ if (!args.device && ctx?.plan && !ctx.confirmed) {
140
+ const ids = devices.map((d) => d.device);
141
+ return ctx.plan(
142
+ `It would run \`${args.command}\` on ${ids.length} device(s): ${ids.join(', ')}.${skipped}`,
143
+ );
144
+ }
134
145
 
135
146
  const concurrency = positive(args.concurrency, 10);
136
147
  const { entries, durationMs } = await runProductFanOut({
@@ -217,57 +228,75 @@ async function toolExec(args) {
217
228
  };
218
229
  }
219
230
 
220
- export const tools = [
221
- {
222
- name: 'device_exec',
223
- description:
224
- 'Run a shell command on devices with the ThinRemote agent: on one (`device`: its output and exit code), or on several in parallel (`devices`, a `product` and optionally its `group`, or `all: true` for every device of the ThinRemote products: output, exit code and duration per device plus a summary). Offline devices are skipped unless `include_offline`. `dry_run` says which devices it would reach without running anything. On several devices it acts at once: confirm with the user first; for repeatable, checked changes use a playbook (type "product_playbook").',
225
- inputSchema: {
226
- type: 'object',
227
- properties: {
228
- command: { type: 'string', description: 'Shell command to run.' },
229
- device: {
230
- type: 'string',
231
- description: 'One device (from {tool:resource_list} type "device").',
232
- },
233
- devices: {
234
- type: 'array',
235
- items: { type: 'string' },
236
- description: 'Several devices by id.',
237
- },
238
- product: { type: 'string', description: 'Every device of this product.' },
239
- group: {
240
- type: 'string',
241
- description: 'Asset group: alone, or narrowing `product`.',
242
- },
243
- all: {
244
- type: 'boolean',
245
- description: 'Every device of the ThinRemote products of the account.',
246
- },
247
- include_offline: {
248
- type: 'boolean',
249
- description: 'Also try devices that are not connected (default false).',
250
- },
251
- timeout: {
252
- type: 'number',
253
- description: 'Seconds per device before giving up (default 30).',
254
- },
255
- concurrency: {
256
- type: 'number',
257
- description: 'Devices at a time, on several (default 10).',
258
- },
259
- fail_fast: {
260
- type: 'boolean',
261
- description: 'On several: start no more devices after one fails.',
262
- },
263
- dry_run: {
264
- type: 'boolean',
265
- description: 'Only list the devices it would run on.',
266
- },
267
- user: { type: 'string', description: 'Account to act on (admin only).' },
231
+ /**
232
+ * The shell command, as an action of the server's `device` type: on the
233
+ * device of ids.device, or on several (body devices, product, group, all).
234
+ * On several it answers its targets until confirmed.
235
+ *
236
+ * @type {import('@thinger-io/cli-core/mcp/tools-resource').LocalAction}
237
+ */
238
+ export const execAction = {
239
+ description:
240
+ 'Run a shell command on devices with the ThinRemote agent: on one (ids.device: its output and exit code), or on several in parallel (no ids; body `devices`, a `product` and optionally its `group`, or `all: true` for every device of the ThinRemote products: output, exit code and duration per device plus a summary). Offline devices are skipped unless `include_offline`. `dry_run` says which devices it would reach without running anything. For repeatable, checked changes use a playbook (type "product_playbook").',
241
+ on: 'either',
242
+ bodyRequired: true,
243
+ confirm: true,
244
+ body: {
245
+ type: 'object',
246
+ additionalProperties: false,
247
+ required: ['command'],
248
+ properties: {
249
+ command: { type: 'string', description: 'Shell command to run.' },
250
+ devices: {
251
+ type: 'array',
252
+ items: { type: 'string' },
253
+ description: 'Several devices by id.',
254
+ },
255
+ product: { type: 'string', description: 'Every device of this product.' },
256
+ group: { type: 'string', description: 'Asset group: alone, or narrowing `product`.' },
257
+ all: {
258
+ type: 'boolean',
259
+ description: 'Every device of the ThinRemote products of the account.',
260
+ },
261
+ include_offline: {
262
+ type: 'boolean',
263
+ description: 'Also try devices that are not connected (default false).',
264
+ },
265
+ timeout: {
266
+ type: 'number',
267
+ description: 'Seconds per device before giving up (default 30).',
268
268
  },
269
- required: ['command'],
269
+ concurrency: {
270
+ type: 'number',
271
+ description: 'Devices at a time, on several (default 10).',
272
+ },
273
+ fail_fast: {
274
+ type: 'boolean',
275
+ description: 'On several: start no more devices after one fails.',
276
+ },
277
+ dry_run: { type: 'boolean', description: 'Only list the devices it would run on.' },
270
278
  },
271
- handler: toolExec,
272
279
  },
273
- ];
280
+ examples: [
281
+ {
282
+ tool: 'resource_call',
283
+ arguments: {
284
+ type: 'device',
285
+ action: 'exec',
286
+ ids: { device: 'gw_1' },
287
+ body: { command: 'uptime' },
288
+ },
289
+ does: 'Run uptime on one device.',
290
+ },
291
+ {
292
+ tool: 'resource_call',
293
+ arguments: {
294
+ type: 'device',
295
+ action: 'exec',
296
+ body: { command: 'df -h /', product: 'gateways', dry_run: true },
297
+ },
298
+ does: 'The connected devices of the product gateways it would run on.',
299
+ },
300
+ ],
301
+ run: (body, ctx) => toolExec({ ...body, device: ctx.id, user: ctx.user }, ctx),
302
+ };
@@ -1,14 +1,14 @@
1
1
  // @ts-check
2
2
  import { createDeviceAPI } from '../device-api.js';
3
3
  import { inputError } from '@thinger-io/cli-core/errors';
4
- import { toolName } from '@thinger-io/cli-core/mcp/naming';
4
+ import { opRef } from '@thinger-io/cli-core/mcp/naming';
5
5
 
6
6
  /** Build a device-scoped API client. Tools must always pass `device`
7
7
  * explicitly — the server has no per-session default. */
8
8
  export function getAPI(device, user) {
9
9
  if (!device)
10
10
  throw inputError(
11
- `device is required. Use ${toolName('resource_list')} (type "device") to list available devices.`,
11
+ `device is required. Use ${opRef('device', 'list')} to list available devices.`,
12
12
  );
13
13
  return { api: createDeviceAPI(device, { user: user || undefined }), device };
14
14
  }
@@ -1,17 +1,84 @@
1
1
  // @ts-check
2
2
  import { deleteStorage } from '@thinger-io/cli-core/storage';
3
- import { toolName } from '@thinger-io/cli-core/mcp/naming';
3
+ import { inputError } from '@thinger-io/cli-core/errors';
4
+ import { opRef } from '@thinger-io/cli-core/mcp/naming';
5
+ import { DASHBOARD_METRICS_PROPERTY, validateDashboardMetrics } from '../product.js';
6
+ import {
7
+ FALLBACK_LOGS_COMMAND,
8
+ FALLBACK_LOGS_SOURCE_NAME,
9
+ LOGS_PROPERTY,
10
+ listPresets,
11
+ validateLogsConfig,
12
+ } from '../product/logs.js';
4
13
  import { getAPI } from './helpers.js';
5
14
 
6
15
  /** Where the rest of what an agent offers is, keyed by what it is. */
7
16
  const agentMap = (device) => ({
8
- monitoring: `live: ${toolName('device_resource_call')} resource "monitoring"; history: ${toolName('bucket_data_read')} bucket "monitoring" device "${device}" (fields cpu.usage, memory.usage, disk.root.usage, network.rx_rate, load.1m…), kept only when the device's product has a monitoring bucket (ThinRemote products do): empty otherwise`,
9
- files: `${toolName('resource_list')} / _get / _create / _update / _delete type "device_fs" parent "${device}"`,
10
- scripts: `type "device_script" parent "${device}"`,
11
- commands: `${toolName('device_exec')} device "${device}"`,
12
- agent_update: `${toolName('resource_call')} type "device" action "agent_update" id "${device}"`,
17
+ monitoring: `live: ${opRef('device_resource', 'call')} ids {"device": "${device}", "resource": "monitoring"}; history: ${opRef('bucket_data', 'list')} ids {"bucket": "monitoring"} filter {"device": "${device}"} (fields cpu.usage, memory.usage, disk.root.usage, network.rx_rate, load.1m…), kept only when the device's product has a monitoring bucket (ThinRemote products do): empty otherwise`,
18
+ files: `type "device_fs" ids {"device": "${device}", "path": <absolute path>}`,
19
+ scripts: `type "device_script" ids {"device": "${device}", "script": <file name>}`,
20
+ commands: `${opRef('device', 'exec')} ids {"device": "${device}"} body {"command": …}`,
21
+ agent_update: `${opRef('device', 'agent_update')} ids {"device": "${device}"}`,
13
22
  });
14
23
 
24
+ /** One metric of thinr's terminal dashboard, an element of `dashboard_metrics`. */
25
+ const METRIC_SCHEMA = {
26
+ type: 'object',
27
+ required: ['name', 'resource'],
28
+ properties: {
29
+ name: { type: 'string', description: 'Metric id (letters, digits, _ and -), unique.' },
30
+ label: { type: 'string', description: 'Shown in the panel.' },
31
+ resource: {
32
+ type: 'string',
33
+ description: 'Product API resource asked to each device (usually a product script).',
34
+ },
35
+ field: {
36
+ type: 'string',
37
+ description: 'Dot path to the value in the answer; omit for scalars.',
38
+ },
39
+ aggregation: {
40
+ type: 'string',
41
+ enum: ['sum', 'avg', 'max', 'min', 'count', 'top', 'none', 'distribution'],
42
+ description:
43
+ 'How the devices\' values combine; "distribution" counts devices per value (versions, kernels).',
44
+ },
45
+ visualization: {
46
+ type: 'string',
47
+ enum: ['kpi', 'bar', 'sparkline', 'list'],
48
+ description: 'kpi (default), sparkline (trend), list (pairs with distribution).',
49
+ },
50
+ interval: {
51
+ type: 'number',
52
+ description: 'Seconds between refreshes; omit to only relay what devices push.',
53
+ },
54
+ unit: { type: 'string', description: 'Suffix shown with the value ("%").' },
55
+ },
56
+ };
57
+
58
+ /** The product properties thinr reads, checked whole before they are written. */
59
+ const CHECKED = {
60
+ [LOGS_PROPERTY]: validateLogsConfig,
61
+ [DASHBOARD_METRICS_PROPERTY]: validateDashboardMetrics,
62
+ };
63
+
64
+ /**
65
+ * The value of `logs` or `dashboard_metrics`, checked (and normalized) before
66
+ * it reaches the server: thinr reads them, and a bad one breaks its
67
+ * terminal dashboard for the whole product.
68
+ */
69
+ function checkedProperty(body, ctx) {
70
+ if (!body || typeof body !== 'object' || Array.isArray(body)) return body;
71
+ const name = ctx.id ?? body.property;
72
+ const check = Object.hasOwn(CHECKED, name) ? CHECKED[name] : undefined;
73
+ if (!check || body.value === undefined) return body;
74
+ if (ctx.merge) {
75
+ throw inputError(
76
+ `${name} is written whole: update it without merge (read it, change it, send the whole value)`,
77
+ );
78
+ }
79
+ return { ...body, value: check(body.value) };
80
+ }
81
+
15
82
  /**
16
83
  * What ThinRemote adds to the generic `resource_*` tools of cli-core, which
17
84
  * run these after their own hooks:
@@ -24,6 +91,10 @@ const agentMap = (device) => ({
24
91
  * model that starts from the device finds it.
25
92
  * - Deleting a product also deletes its file storage, which holds the
26
93
  * product's scripts and only ThinRemote products have.
94
+ * - The product properties thinr's terminal dashboard reads (`logs`, its
95
+ * log sources; `dashboard_metrics`, its fleet metrics) are checked before
96
+ * they are written, and resource_describe of product_property shows their
97
+ * shape.
27
98
  *
28
99
  * @type {Record<string, import('@thinger-io/cli-core/mcp/resource-hooks').ResourceHooks>}
29
100
  */
@@ -50,4 +121,31 @@ export const resourceHooks = {
50
121
  }
51
122
  },
52
123
  },
124
+ product_property: {
125
+ beforeCreate: checkedProperty,
126
+ beforeUpdate: checkedProperty,
127
+ describe: () => ({
128
+ [LOGS_PROPERTY]: {
129
+ about: "Log sources of thinr's terminal dashboard: named shell commands the agent streams when someone opens a device's logs. Written whole; checked before it is saved.",
130
+ shape: {
131
+ sources: [
132
+ {
133
+ name: 'letters, digits, _ and - (up to 32), unique',
134
+ command: '"journalctl -f", "docker logs -f app", "tail -F <file>"',
135
+ preset: 'optional: a named line format (presets below)',
136
+ pattern:
137
+ 'optional, not with preset: a regex with named groups time, level and msg',
138
+ },
139
+ ],
140
+ default: 'optional: the name of the source shown first',
141
+ },
142
+ fallback: `Without the property a product shows one "${FALLBACK_LOGS_SOURCE_NAME}" source: ${FALLBACK_LOGS_COMMAND}.`,
143
+ presets: Object.fromEntries(listPresets().map((p) => [p.name, p.description])),
144
+ },
145
+ [DASHBOARD_METRICS_PROPERTY]: {
146
+ about: "Fleet metrics of thinr's terminal dashboard (not a console dashboard): an array of metrics, each a value asked to every device and combined across the fleet. Written whole; checked before it is saved.",
147
+ element: METRIC_SCHEMA,
148
+ },
149
+ }),
150
+ },
53
151
  };