@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.
- package/CHANGELOG.md +113 -0
- package/README.md +167 -120
- package/bin/thinr.js +3 -58
- package/commands/_shared.js +1 -0
- package/commands/{device/update.js → agent/device.js} +10 -26
- package/commands/agent/fleet.js +254 -0
- package/commands/agent/index.js +79 -0
- package/commands/device/console.js +1 -1
- package/commands/device/exec.js +1 -1
- package/commands/device/fs.js +6 -6
- package/commands/device/index.js +4 -6
- package/commands/device/logs.js +1 -1
- package/commands/device/proxy.js +15 -6
- package/commands/device/status.js +1 -1
- package/commands/device/transfer.js +2 -2
- package/commands/product/exec.js +1 -1
- package/commands/product/fs.js +3 -3
- package/commands/product/index.js +5 -4
- package/commands/product/logs.js +35 -36
- package/commands/product/metric.js +24 -14
- package/commands/product/playbook.js +10 -5
- package/commands/product/property.js +4 -14
- package/commands/product/push.js +1 -1
- package/commands/product/resource.js +56 -56
- package/commands/product/status.js +1 -1
- package/docs/guides/thinremote-metrics-logs.md +21 -22
- package/docs/guides/thinremote-playbooks.md +15 -11
- package/docs/guides/thinremote-scripts.md +11 -10
- package/docs/guides/thinremote.md +53 -42
- package/lib/agent-releases.js +1 -1
- package/lib/cli.js +172 -0
- package/lib/constants.js +1 -1
- package/lib/guides.js +1 -1
- package/lib/identity.js +8 -6
- package/lib/mcp/{tools-exec.js → device-exec.js} +84 -55
- package/lib/mcp/helpers.js +2 -2
- package/lib/mcp/resource-hooks.js +104 -6
- package/lib/mcp/resource-types.js +114 -227
- package/lib/playbook/actions.js +1 -1
- package/lib/playbook/schema.js +1 -1
- package/lib/product/log-presets.js +1 -1
- package/lib/product.js +26 -0
- package/package.json +2 -2
- package/commands/fleet/index.js +0 -17
- package/commands/fleet/upgrade.js +0 -275
- 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
|
-
|
|
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:
|
|
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:
|
|
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
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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
|
-
|
|
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
|
+
};
|
package/lib/mcp/helpers.js
CHANGED
|
@@ -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 {
|
|
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 ${
|
|
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 {
|
|
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: ${
|
|
9
|
-
files:
|
|
10
|
-
scripts: `type "device_script"
|
|
11
|
-
commands: `${
|
|
12
|
-
agent_update: `${
|
|
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
|
};
|