@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
|
@@ -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 {
|
|
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
|
|
43
|
-
*
|
|
44
|
-
*
|
|
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:
|
|
55
|
-
*
|
|
56
|
-
*
|
|
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 (
|
|
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: '
|
|
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 ${
|
|
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, ${
|
|
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
|
|
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
|
|
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 ${
|
|
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: '
|
|
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 (
|
|
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: '
|
|
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: ${
|
|
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: '
|
|
403
|
+
id: 'script',
|
|
404
|
+
bodyId: 'name',
|
|
381
405
|
schema: {
|
|
382
406
|
type: 'object',
|
|
383
407
|
properties: {
|
|
384
|
-
name: { type: 'string', description: 'File name (
|
|
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: {
|
|
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: '
|
|
485
|
+
id: 'playbook',
|
|
486
|
+
bodyId: 'name',
|
|
646
487
|
schema: {
|
|
647
488
|
type: 'object',
|
|
648
489
|
properties: {
|
|
649
|
-
name: {
|
|
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
|
|
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 (
|
|
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 (
|
|
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
|
|
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
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
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
|
|
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(
|
|
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
|
|
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: {
|
package/lib/playbook/actions.js
CHANGED
|
@@ -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
|
|
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 });
|
package/lib/playbook/schema.js
CHANGED
|
@@ -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 `
|
|
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 `
|
|
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.
|
|
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.
|
|
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",
|
package/commands/fleet/index.js
DELETED
|
@@ -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
|
-
}
|