@thinremote/thinr-cli 1.3.4 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +86 -0
- package/README.md +117 -123
- package/bin/thinr.js +43 -105
- package/commands/_shared.js +10 -90
- package/commands/device/_shared.js +2 -2
- package/commands/device/exec.js +2 -2
- package/commands/device/fs.js +2 -2
- package/commands/device/index.js +5 -19
- package/commands/device/logs.js +4 -4
- package/commands/device/status.js +1 -1
- package/commands/device/transfer.js +2 -2
- package/commands/device/update.js +8 -2
- package/commands/fleet/upgrade.js +3 -3
- package/commands/playbook/run.js +2 -2
- package/commands/playbook/validate.js +2 -2
- package/commands/product/_shared.js +1 -1
- package/commands/product/exec.js +2 -2
- package/commands/product/fs.js +2 -2
- package/commands/product/index.js +4 -11
- package/commands/product/logs.js +2 -2
- package/commands/product/metric.js +2 -2
- package/commands/product/playbook.js +5 -5
- package/commands/product/property.js +4 -4
- package/commands/product/push.js +2 -2
- package/commands/product/resource.js +4 -4
- package/commands/product/status.js +3 -3
- package/dist/dashboard.js +12 -12
- package/docs/guides/thinremote-metrics-logs.md +59 -0
- package/docs/guides/thinremote-playbooks.md +133 -0
- package/docs/guides/thinremote-scripts.md +115 -0
- package/docs/guides/thinremote.md +116 -0
- package/lib/command-stream.js +3 -2
- package/lib/console.js +6 -6
- package/lib/constants.js +1 -19
- package/lib/dashboard/event-stream.js +5 -5
- package/lib/device-api.js +4 -4
- package/lib/guides.js +41 -0
- package/lib/identity.js +75 -0
- package/lib/local-files.js +54 -0
- package/lib/mcp/{tools-install.js → agent-install.js} +12 -61
- package/lib/mcp/helpers.js +5 -25
- package/lib/mcp/playbook-ops.js +393 -0
- package/lib/mcp/registry.js +9 -43
- package/lib/mcp/resource-hooks.js +53 -0
- package/lib/mcp/resource-types.js +857 -0
- package/lib/mcp/tools-exec.js +217 -177
- package/lib/monitoring.js +3 -3
- package/lib/playbook/actions.js +30 -18
- package/lib/playbook/expression.js +1 -4
- package/lib/playbook/fleet.js +4 -1
- package/lib/playbook/loader.js +16 -8
- package/lib/playbook/runner.js +40 -8
- package/lib/playbook/schema.js +108 -34
- package/lib/product/log-presets.js +1 -1
- package/lib/product/logs.js +2 -2
- package/lib/product-orchestrator.js +14 -9
- package/lib/product.js +29 -227
- package/lib/proxy.js +4 -4
- package/lib/status.js +13 -5
- package/package.json +4 -2
- package/commands/device/delete.js +0 -53
- package/commands/device/list.js +0 -55
- package/commands/device/property.js +0 -52
- package/commands/device/resource.js +0 -52
- package/commands/logout.js +0 -47
- package/commands/product/list.js +0 -86
- package/commands/product/props.js +0 -125
- package/commands/profile.js +0 -173
- package/lib/alarms.js +0 -223
- package/lib/api.js +0 -89
- package/lib/auth.js +0 -428
- package/lib/bucket.js +0 -82
- package/lib/concurrency.js +0 -52
- package/lib/config.js +0 -230
- package/lib/devices.js +0 -60
- package/lib/errors.js +0 -65
- package/lib/format.js +0 -114
- package/lib/mcp/server.js +0 -67
- package/lib/mcp/tools-alarms.js +0 -507
- package/lib/mcp/tools-buckets.js +0 -99
- package/lib/mcp/tools-devices.js +0 -235
- package/lib/mcp/tools-filesystem.js +0 -281
- package/lib/mcp/tools-logs.js +0 -225
- package/lib/mcp/tools-playbook.js +0 -210
- package/lib/mcp/tools-products.js +0 -1557
- package/lib/mcp/tools-profile.js +0 -635
- package/lib/mcp/tools-profiles.js +0 -30
- package/lib/mcp/tools-properties.js +0 -79
- package/lib/mcp/tools-resources.js +0 -78
- package/lib/mcp/tools-scripts.js +0 -212
- package/lib/mcp/tools-tokens.js +0 -369
- package/lib/output.js +0 -114
- package/lib/paths.js +0 -32
- package/lib/profile.js +0 -657
- package/lib/property.js +0 -75
- package/lib/resource.js +0 -151
- package/lib/storage.js +0 -117
- package/lib/tokens.js +0 -366
package/lib/mcp/tools-exec.js
CHANGED
|
@@ -1,17 +1,21 @@
|
|
|
1
1
|
// @ts-check
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
2
|
+
import { filterActiveDevices, getDevices } from '@thinger-io/cli-core/devices';
|
|
3
|
+
import { getProduct, getProducts } from '@thinger-io/cli-core/product';
|
|
4
|
+
import { runPool } from '@thinger-io/cli-core/concurrency';
|
|
5
|
+
import { inputError } from '@thinger-io/cli-core/errors';
|
|
6
|
+
import { TIMEOUTS } from '../constants.js';
|
|
7
|
+
import { createDeviceAPI } from '../device-api.js';
|
|
8
|
+
import { runProductFanOut } from '../product-orchestrator.js';
|
|
5
9
|
import { getAPI } from './helpers.js';
|
|
6
10
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
11
|
+
/** Products thinr made or adopted: the ones whose devices run the agent. */
|
|
12
|
+
export const THINREMOTE_PRODUCT_TYPE = 'thinremote';
|
|
13
|
+
|
|
14
|
+
const positive = (n, fallback) => (Number.isFinite(n) && n > 0 ? Math.floor(n) : fallback);
|
|
15
|
+
|
|
16
|
+
/** One device: the output as the shell printed it, then the exit code. */
|
|
17
|
+
async function execOne(device, args, timeout) {
|
|
18
|
+
const { api } = getAPI(device, args.user);
|
|
15
19
|
let output = '';
|
|
16
20
|
const { exitCode, timedOut } = await api.execStream(args.command, {
|
|
17
21
|
timeout,
|
|
@@ -32,202 +36,238 @@ async function toolExec(args) {
|
|
|
32
36
|
};
|
|
33
37
|
}
|
|
34
38
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
const
|
|
42
|
-
return
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
39
|
+
/**
|
|
40
|
+
* The products of the account made for ThinRemote. The product list does
|
|
41
|
+
* not carry `config`, so each one is read.
|
|
42
|
+
*/
|
|
43
|
+
async function thinremoteProducts(user) {
|
|
44
|
+
const ids = (await getProducts(user)).map((p) => p.product);
|
|
45
|
+
const read = await runPool(ids, 8, (id) => getProduct(id, user));
|
|
46
|
+
return new Set(
|
|
47
|
+
read
|
|
48
|
+
.map((r, i) =>
|
|
49
|
+
r?.ok && r.value?.config?.type === THINREMOTE_PRODUCT_TYPE ? ids[i] : null,
|
|
50
|
+
)
|
|
51
|
+
.filter(Boolean),
|
|
52
|
+
);
|
|
46
53
|
}
|
|
47
54
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
55
|
+
/**
|
|
56
|
+
* The devices a fleet call reaches: a list, a product (optionally one of
|
|
57
|
+
* its groups), a group, or every device of the ThinRemote products.
|
|
58
|
+
* Offline ones are left out unless asked for, and named when they were
|
|
59
|
+
* asked for by id.
|
|
60
|
+
*
|
|
61
|
+
* @returns {Promise<{ devices: any[], offline: string[] }>}
|
|
62
|
+
*/
|
|
63
|
+
async function targetsOf(args) {
|
|
64
|
+
let devices;
|
|
65
|
+
let named = false;
|
|
66
|
+
if (Array.isArray(args.devices) && args.devices.length) {
|
|
67
|
+
named = true;
|
|
68
|
+
const wanted = new Set(args.devices.map(String));
|
|
69
|
+
const known = await getDevices({}, args.user);
|
|
70
|
+
devices = known.filter((d) => wanted.has(d.device));
|
|
71
|
+
const missing = [...wanted].filter((id) => !devices.some((d) => d.device === id));
|
|
72
|
+
if (missing.length) throw inputError(`Unknown devices: ${missing.join(', ')}`);
|
|
73
|
+
} else if (args.product || args.group) {
|
|
74
|
+
const filter = {};
|
|
75
|
+
if (args.product) filter.product = args.product;
|
|
76
|
+
if (args.group) filter.asset_group = args.group;
|
|
77
|
+
devices = await getDevices(filter, args.user);
|
|
78
|
+
} else {
|
|
79
|
+
const ids = await thinremoteProducts(args.user);
|
|
80
|
+
devices = (await getDevices({}, args.user)).filter((d) => ids.has(d.product));
|
|
81
|
+
}
|
|
82
|
+
if (args.include_offline) return { devices, offline: [] };
|
|
83
|
+
const active = filterActiveDevices(devices);
|
|
84
|
+
const offline = named ? devices.filter((d) => !active.includes(d)).map((d) => d.device) : [];
|
|
85
|
+
return { devices: active, offline };
|
|
86
|
+
}
|
|
51
87
|
|
|
52
|
-
|
|
53
|
-
if (args.
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
)
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
`Disk: ${mon.disk.root.usage?.toFixed(1)}% (available: ${(mon.disk.root.available / BYTES.GB).toFixed(1)}GB / total: ${(mon.disk.root.total / BYTES.GB).toFixed(0)}GB)`,
|
|
69
|
-
);
|
|
70
|
-
if (mon.network)
|
|
71
|
-
parts.push(
|
|
72
|
-
`Network: rx=${(mon.network.rx_rate / BYTES.KB).toFixed(1)}KB/s tx=${(mon.network.tx_rate / BYTES.KB).toFixed(1)}KB/s`,
|
|
73
|
-
);
|
|
74
|
-
if (mon.load)
|
|
75
|
-
parts.push(
|
|
76
|
-
`Load: ${mon.load['1m']?.toFixed(2)} / ${mon.load['5m']?.toFixed(2)} / ${mon.load['15m']?.toFixed(2)}`,
|
|
77
|
-
);
|
|
78
|
-
if (mon.processes) parts.push(`Processes: ${mon.processes.total}`);
|
|
79
|
-
if (mon.uptime)
|
|
80
|
-
parts.push(
|
|
81
|
-
`Uptime: ${Math.floor(mon.uptime / 86400)}d ${Math.floor((mon.uptime % 86400) / 3600)}h ${Math.floor((mon.uptime % 3600) / 60)}m`,
|
|
82
|
-
);
|
|
83
|
-
if (mon.agent) parts.push(`Agent: ${mon.agent.version}`);
|
|
84
|
-
return { content: [{ type: 'text', text: parts.join('\n') }], isError: false };
|
|
88
|
+
async function toolExec(args) {
|
|
89
|
+
if (typeof args.command !== 'string' || !args.command.trim()) {
|
|
90
|
+
throw inputError('command is required');
|
|
91
|
+
}
|
|
92
|
+
const listed = Array.isArray(args.devices) && args.devices.length > 0;
|
|
93
|
+
const fleet = listed || args.product || args.group;
|
|
94
|
+
const kinds = [args.device, listed, args.product || args.group, args.all].filter(Boolean);
|
|
95
|
+
if (kinds.length > 1) {
|
|
96
|
+
throw inputError(
|
|
97
|
+
'give one kind of target: `device`, `devices`, `product` (and `group`) or `group`, or `all`',
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
if (!args.device && !fleet && !args.all) {
|
|
101
|
+
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',
|
|
103
|
+
);
|
|
85
104
|
}
|
|
105
|
+
// Default guards against hung commands locking the session; callers
|
|
106
|
+
// override for known-long operations. Knob lives in lib/constants.js.
|
|
107
|
+
const timeout = positive(args.timeout, TIMEOUTS.DEFAULT_EXEC_SECONDS);
|
|
108
|
+
if (args.device && !args.dry_run) return execOne(args.device, args, timeout);
|
|
86
109
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
110
|
+
const { devices, offline } = args.device
|
|
111
|
+
? { devices: [{ device: args.device }], offline: [] }
|
|
112
|
+
: await targetsOf(args);
|
|
113
|
+
const skipped = offline.length
|
|
114
|
+
? `\nNot connected, left out (include_offline to try them): ${offline.join(', ')}.`
|
|
115
|
+
: '';
|
|
116
|
+
if (args.dry_run) {
|
|
117
|
+
const ids = devices.map((d) => d.device);
|
|
118
|
+
return {
|
|
119
|
+
content: [
|
|
120
|
+
{
|
|
121
|
+
type: 'text',
|
|
122
|
+
text: `Would run \`${args.command}\` on ${ids.length} device(s)${ids.length ? `: ${ids.join(', ')}` : ''}.${skipped}`,
|
|
123
|
+
},
|
|
124
|
+
],
|
|
125
|
+
isError: false,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
if (devices.length === 0) {
|
|
129
|
+
return {
|
|
130
|
+
content: [{ type: 'text', text: `No connected devices match the target.${skipped}` }],
|
|
131
|
+
isError: offline.length > 0,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const concurrency = positive(args.concurrency, 10);
|
|
136
|
+
const { entries, durationMs } = await runProductFanOut({
|
|
137
|
+
devices,
|
|
138
|
+
user: args.user,
|
|
139
|
+
concurrency,
|
|
140
|
+
failFast: !!args.fail_fast,
|
|
141
|
+
worker: async (device) => {
|
|
142
|
+
const perStart = Date.now();
|
|
143
|
+
let stdout = '';
|
|
144
|
+
let stderr = '';
|
|
145
|
+
try {
|
|
146
|
+
const api = createDeviceAPI(device.device, { user: args.user || undefined });
|
|
147
|
+
const { exitCode, timedOut } = await api.execStream(args.command, {
|
|
148
|
+
timeout,
|
|
149
|
+
onStdout: (s) => {
|
|
150
|
+
stdout += s;
|
|
151
|
+
},
|
|
152
|
+
onStderr: (s) => {
|
|
153
|
+
stderr += s;
|
|
154
|
+
},
|
|
155
|
+
});
|
|
156
|
+
return {
|
|
157
|
+
device: device.device,
|
|
158
|
+
ok: !timedOut && exitCode === 0,
|
|
159
|
+
exitCode: exitCode ?? null,
|
|
160
|
+
timedOut: !!timedOut,
|
|
161
|
+
durationMs: Date.now() - perStart,
|
|
162
|
+
stdout,
|
|
163
|
+
stderr,
|
|
164
|
+
};
|
|
165
|
+
} catch (err) {
|
|
166
|
+
return {
|
|
167
|
+
device: device.device,
|
|
168
|
+
ok: false,
|
|
169
|
+
exitCode: null,
|
|
170
|
+
timedOut: false,
|
|
171
|
+
durationMs: Date.now() - perStart,
|
|
172
|
+
stdout,
|
|
173
|
+
stderr,
|
|
174
|
+
error: err instanceof Error ? err.message : String(err),
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
},
|
|
178
|
+
skipped: (device, firstFailure) => ({
|
|
179
|
+
device: device.device,
|
|
180
|
+
ok: false,
|
|
181
|
+
exitCode: null,
|
|
182
|
+
timedOut: false,
|
|
183
|
+
durationMs: 0,
|
|
184
|
+
stdout: '',
|
|
185
|
+
stderr: '',
|
|
186
|
+
error: `skipped (fail-fast after ${firstFailure})`,
|
|
187
|
+
}),
|
|
188
|
+
isFailure: (entry) => !entry.ok,
|
|
99
189
|
});
|
|
100
190
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
// Single-window aggregated rows (group_by without `agg`) come
|
|
106
|
-
// back without `ts`; skip it rather than emitting "Invalid Date".
|
|
107
|
-
const parts = [];
|
|
108
|
-
if (d.ts) parts.push(new Date(d.ts).toISOString());
|
|
109
|
-
if (d.device) parts.push(`device=${d.device}`);
|
|
110
|
-
if (d.cpu) {
|
|
111
|
-
const cpuParts = [];
|
|
112
|
-
if (d.cpu.usage !== undefined) cpuParts.push(`${d.cpu.usage.toFixed(1)}%`);
|
|
113
|
-
if (d.cpu.temperature !== undefined)
|
|
114
|
-
cpuParts.push(`${d.cpu.temperature.toFixed(1)}°C`);
|
|
115
|
-
if (cpuParts.length) parts.push(`cpu=${cpuParts.join(' ')}`);
|
|
116
|
-
}
|
|
117
|
-
if (d.memory) parts.push(`mem=${d.memory.usage?.toFixed(1)}%`);
|
|
118
|
-
if (d.disk?.root) parts.push(`disk=${d.disk.root.usage?.toFixed(1)}%`);
|
|
119
|
-
if (d.network)
|
|
120
|
-
parts.push(
|
|
121
|
-
`net_rx=${(d.network.rx_rate / BYTES.KB)?.toFixed(1)}KB/s tx=${(d.network.tx_rate / BYTES.KB)?.toFixed(1)}KB/s`,
|
|
122
|
-
);
|
|
123
|
-
if (d.load) parts.push(`load=${d.load['1m']?.toFixed(2)}`);
|
|
124
|
-
if (d.processes) parts.push(`procs=${d.processes.total}`);
|
|
125
|
-
return parts.join(' | ');
|
|
126
|
-
})
|
|
127
|
-
.join('\n');
|
|
128
|
-
} else {
|
|
129
|
-
output = JSON.stringify(data, null, 2);
|
|
130
|
-
}
|
|
191
|
+
const okCount = entries.filter((e) => e.ok).length;
|
|
192
|
+
const timedOutCount = entries.filter((e) => e.timedOut).length;
|
|
193
|
+
const errorCount = entries.filter((e) => e.error).length;
|
|
194
|
+
const failCount = entries.length - okCount - timedOutCount - errorCount;
|
|
131
195
|
|
|
196
|
+
const lines = [
|
|
197
|
+
`Ran \`${args.command}\` on ${entries.length} device(s) (concurrency=${concurrency}, timeout=${timeout}s).\n`,
|
|
198
|
+
];
|
|
199
|
+
for (const e of entries) {
|
|
200
|
+
const status = e.error
|
|
201
|
+
? `ERROR (${e.error})`
|
|
202
|
+
: e.timedOut
|
|
203
|
+
? `TIMEOUT after ${timeout}s`
|
|
204
|
+
: e.ok
|
|
205
|
+
? `OK exit=0`
|
|
206
|
+
: `FAIL exit=${e.exitCode}`;
|
|
207
|
+
lines.push(`--- ${e.device} [${status}] ${e.durationMs}ms ---`);
|
|
208
|
+
if (e.stdout) lines.push(e.stdout.trimEnd());
|
|
209
|
+
if (e.stderr) lines.push(`stderr: ${e.stderr.trimEnd()}`);
|
|
210
|
+
}
|
|
211
|
+
lines.push(
|
|
212
|
+
`\nSummary: ${okCount} ok, ${failCount} failed, ${timedOutCount} timed out, ${errorCount} errored — ${durationMs}ms total${skipped}`,
|
|
213
|
+
);
|
|
132
214
|
return {
|
|
133
|
-
content: [{ type: 'text', text:
|
|
134
|
-
isError:
|
|
215
|
+
content: [{ type: 'text', text: lines.join('\n') }],
|
|
216
|
+
isError: failCount + timedOutCount + errorCount + offline.length > 0,
|
|
135
217
|
};
|
|
136
218
|
}
|
|
137
219
|
|
|
138
220
|
export const tools = [
|
|
139
221
|
{
|
|
140
|
-
name: '
|
|
141
|
-
description:
|
|
142
|
-
|
|
143
|
-
type: 'object',
|
|
144
|
-
properties: {
|
|
145
|
-
command: { type: 'string', description: 'Shell command to execute' },
|
|
146
|
-
device_id: { type: 'string', description: 'Device ID (from thinr_devices)' },
|
|
147
|
-
timeout: { type: 'number', description: 'Timeout in seconds (default: 30)' },
|
|
148
|
-
user: { type: 'string', description: 'API user (for admin impersonation)' },
|
|
149
|
-
},
|
|
150
|
-
required: ['command', 'device_id'],
|
|
151
|
-
},
|
|
152
|
-
handler: toolExec,
|
|
153
|
-
},
|
|
154
|
-
{
|
|
155
|
-
name: 'thinr_update',
|
|
156
|
-
description: `Check for agent updates or apply an update on a remote device.`,
|
|
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").',
|
|
157
225
|
inputSchema: {
|
|
158
226
|
type: 'object',
|
|
159
227
|
properties: {
|
|
160
|
-
|
|
161
|
-
|
|
228
|
+
command: { type: 'string', description: 'Shell command to run.' },
|
|
229
|
+
device: {
|
|
162
230
|
type: 'string',
|
|
163
|
-
description: '
|
|
231
|
+
description: 'One device (from {tool:resource_list} type "device").',
|
|
164
232
|
},
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
},
|
|
170
|
-
handler: toolUpdate,
|
|
171
|
-
},
|
|
172
|
-
{
|
|
173
|
-
name: 'thinr_monitoring',
|
|
174
|
-
description: `Get monitoring data (CPU, memory, disk, network, temperature, load).
|
|
175
|
-
|
|
176
|
-
Modes:
|
|
177
|
-
- realtime=true + device: live sample read straight from the agent.
|
|
178
|
-
- device only: historical time series for that device. Use \`agg\`+\`agg_type\` for time-windowed aggregates (e.g. avg CPU every 5m over the last hour).
|
|
179
|
-
- no device: historical fleet view — returns ONE ROW PER DEVICE. Without \`agg_type\` each row is the latest sample for that device ("current fleet state"). With \`agg_type\` each row is the reducer applied over the time window per device (e.g. \`agg_type=max\`+\`minutes=60\` ⇒ peak per device in the last hour, ideal for ranking "which device had the most X"). Sort the returned array client-side on the metric to get the top N.`,
|
|
180
|
-
inputSchema: {
|
|
181
|
-
type: 'object',
|
|
182
|
-
properties: {
|
|
183
|
-
realtime: {
|
|
184
|
-
type: 'boolean',
|
|
185
|
-
description:
|
|
186
|
-
'If true, get live data directly from the device. If false/omitted, query historical data from the monitoring bucket.',
|
|
233
|
+
devices: {
|
|
234
|
+
type: 'array',
|
|
235
|
+
items: { type: 'string' },
|
|
236
|
+
description: 'Several devices by id.',
|
|
187
237
|
},
|
|
188
|
-
|
|
238
|
+
product: { type: 'string', description: 'Every device of this product.' },
|
|
239
|
+
group: {
|
|
189
240
|
type: 'string',
|
|
190
|
-
description:
|
|
191
|
-
'Device ID. Required for realtime. Omit for a one-row-per-device fleet view.',
|
|
241
|
+
description: 'Asset group: alone, or narrowing `product`.',
|
|
192
242
|
},
|
|
193
|
-
|
|
194
|
-
type: '
|
|
195
|
-
description:
|
|
196
|
-
'Number of data points to return (default: 10). In multi-device mode set ≥ number of devices (e.g. 200) since each device contributes one row.',
|
|
243
|
+
all: {
|
|
244
|
+
type: 'boolean',
|
|
245
|
+
description: 'Every device of the ThinRemote products of the account.',
|
|
197
246
|
},
|
|
198
|
-
|
|
199
|
-
type: '
|
|
200
|
-
description: '
|
|
247
|
+
include_offline: {
|
|
248
|
+
type: 'boolean',
|
|
249
|
+
description: 'Also try devices that are not connected (default false).',
|
|
201
250
|
},
|
|
202
|
-
|
|
203
|
-
max_ts: {
|
|
251
|
+
timeout: {
|
|
204
252
|
type: 'number',
|
|
205
|
-
description: '
|
|
253
|
+
description: 'Seconds per device before giving up (default 30).',
|
|
206
254
|
},
|
|
207
|
-
|
|
208
|
-
type: '
|
|
209
|
-
description:
|
|
210
|
-
'Sort order by timestamp: "asc" or "desc" (default: "desc"). Does NOT sort by metric — do that client-side on the result.',
|
|
211
|
-
},
|
|
212
|
-
agg: {
|
|
213
|
-
type: 'string',
|
|
214
|
-
description:
|
|
215
|
-
'Time-window size for windowed aggregation: "5m", "10m", "1h", "6h". Only meaningful with a specific device. For a per-device single value across the whole range, omit `agg` and pass `agg_type` alone.',
|
|
255
|
+
concurrency: {
|
|
256
|
+
type: 'number',
|
|
257
|
+
description: 'Devices at a time, on several (default 10).',
|
|
216
258
|
},
|
|
217
|
-
|
|
218
|
-
type: '
|
|
219
|
-
description:
|
|
220
|
-
'Reducer applied to each group/window. Supported: "mean", "max", "min", "sum", "count", "first", "last", "median", "spread" (max-min), "stddev". Most common for monitoring: max, mean, min.',
|
|
259
|
+
fail_fast: {
|
|
260
|
+
type: 'boolean',
|
|
261
|
+
description: 'On several: start no more devices after one fails.',
|
|
221
262
|
},
|
|
222
|
-
|
|
223
|
-
type: '
|
|
224
|
-
description:
|
|
225
|
-
'Comma-separated fields to return (e.g., "cpu.usage,memory.usage,disk.root.usage"). Narrower queries are cheaper and produce tidier output.',
|
|
263
|
+
dry_run: {
|
|
264
|
+
type: 'boolean',
|
|
265
|
+
description: 'Only list the devices it would run on.',
|
|
226
266
|
},
|
|
227
|
-
user: { type: 'string', description: '
|
|
267
|
+
user: { type: 'string', description: 'Account to act on (admin only).' },
|
|
228
268
|
},
|
|
229
|
-
required: [],
|
|
269
|
+
required: ['command'],
|
|
230
270
|
},
|
|
231
|
-
handler:
|
|
271
|
+
handler: toolExec,
|
|
232
272
|
},
|
|
233
273
|
];
|
package/lib/monitoring.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import api from '
|
|
2
|
-
import { apiError } from '
|
|
3
|
-
import { v2, v3 } from '
|
|
1
|
+
import api from '@thinger-io/cli-core/api';
|
|
2
|
+
import { apiError } from '@thinger-io/cli-core/errors';
|
|
3
|
+
import { v2, v3 } from '@thinger-io/cli-core/paths';
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* Query the monitoring bucket. When `device` is provided, scopes to
|
package/lib/playbook/actions.js
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
import { readFileSync } from 'fs';
|
|
3
|
+
import { localFileToRead, localFileToWrite } from '../local-files.js';
|
|
3
4
|
import { writeFile as writeLocalFile } from 'fs/promises';
|
|
4
5
|
import { resolve as resolvePath } from 'path';
|
|
5
6
|
import { createHash } from 'crypto';
|
|
6
7
|
import { createDeviceAPI } from '../device-api.js';
|
|
7
|
-
import { setDeviceProperty, getDeviceProperty } from '
|
|
8
|
-
import { callDeviceResource, readDeviceResource } from '
|
|
8
|
+
import { setDeviceProperty, getDeviceProperty } from '@thinger-io/cli-core/property';
|
|
9
|
+
import { callDeviceResource, readDeviceResource } from '@thinger-io/cli-core/resource';
|
|
9
10
|
import { TIMEOUTS } from '../constants.js';
|
|
10
11
|
|
|
11
12
|
/**
|
|
@@ -18,12 +19,22 @@ import { TIMEOUTS } from '../constants.js';
|
|
|
18
19
|
* in runner.js — handlers only care about their one job.
|
|
19
20
|
*/
|
|
20
21
|
|
|
21
|
-
|
|
22
|
-
|
|
22
|
+
/**
|
|
23
|
+
* A local file a step names. Run from the MCP server (`ctx.localRoot`), it
|
|
24
|
+
* must stay inside that folder (see lib/local-files.js); from the CLI,
|
|
25
|
+
* relative paths resolve against the playbook's folder.
|
|
26
|
+
*/
|
|
27
|
+
function localPath(ctx, relPath, { write = false } = {}) {
|
|
28
|
+
if (ctx.localRoot) {
|
|
29
|
+
return write
|
|
30
|
+
? localFileToWrite(relPath, ctx.localRoot, { overwrite: true })
|
|
31
|
+
: localFileToRead(relPath, ctx.localRoot);
|
|
32
|
+
}
|
|
33
|
+
return resolveLocalPath(relPath, ctx.baseDir);
|
|
23
34
|
}
|
|
24
35
|
|
|
25
|
-
function
|
|
26
|
-
return
|
|
36
|
+
function resolveLocalPath(relPath, baseDir) {
|
|
37
|
+
return baseDir ? resolvePath(baseDir, relPath) : resolvePath(relPath);
|
|
27
38
|
}
|
|
28
39
|
|
|
29
40
|
const MAX_CAPTURED_STREAM_BYTES = 32 * 1024;
|
|
@@ -97,20 +108,20 @@ export const HANDLERS = {
|
|
|
97
108
|
},
|
|
98
109
|
|
|
99
110
|
async push(ctx, params) {
|
|
100
|
-
const local =
|
|
111
|
+
const local = localPath(ctx, params.source);
|
|
101
112
|
const content = readFileSync(local);
|
|
102
113
|
await ctx.api.writeFile(params.destination, content);
|
|
103
114
|
return `pushed ${content.byteLength} bytes`;
|
|
104
115
|
},
|
|
105
116
|
|
|
106
117
|
async pull(ctx, params) {
|
|
107
|
-
const local =
|
|
118
|
+
const local = localPath(ctx, params.destination, { write: true });
|
|
108
119
|
const buf = await ctx.api.readFile(params.source);
|
|
109
120
|
await writeLocalFile(local, buf);
|
|
110
121
|
return `pulled ${buf.byteLength} bytes`;
|
|
111
122
|
},
|
|
112
123
|
|
|
113
|
-
async
|
|
124
|
+
async delete(ctx, params) {
|
|
114
125
|
await ctx.api.delete(params.path, params.recursive !== false);
|
|
115
126
|
return `removed ${params.path}`;
|
|
116
127
|
},
|
|
@@ -120,7 +131,7 @@ export const HANDLERS = {
|
|
|
120
131
|
return `created ${params.path}`;
|
|
121
132
|
},
|
|
122
133
|
|
|
123
|
-
async
|
|
134
|
+
async move(ctx, params) {
|
|
124
135
|
await ctx.api.move(params.source, params.destination, !!params.overwrite);
|
|
125
136
|
return `moved ${params.source} → ${params.destination}`;
|
|
126
137
|
},
|
|
@@ -160,7 +171,7 @@ export const HANDLERS = {
|
|
|
160
171
|
let content;
|
|
161
172
|
if (typeof params.content === 'string') content = params.content;
|
|
162
173
|
else if (typeof params.content_file === 'string')
|
|
163
|
-
content =
|
|
174
|
+
content = readFileSync(localPath(ctx, params.content_file), 'utf8');
|
|
164
175
|
else throw new Error('`content` or `content_file` is required');
|
|
165
176
|
|
|
166
177
|
if (!/^[A-Za-z0-9._-]+$/.test(params.name)) {
|
|
@@ -220,7 +231,7 @@ export const CHECKERS = {
|
|
|
220
231
|
},
|
|
221
232
|
|
|
222
233
|
async push(ctx, params) {
|
|
223
|
-
const local =
|
|
234
|
+
const local = localPath(ctx, params.source);
|
|
224
235
|
const content = readFileSync(local);
|
|
225
236
|
return hashCheck(ctx.api, params.destination, content);
|
|
226
237
|
},
|
|
@@ -248,7 +259,7 @@ export const CHECKERS = {
|
|
|
248
259
|
if (!/^[A-Za-z0-9._-]+$/.test(params.name)) {
|
|
249
260
|
throw new Error(`invalid script name "${params.name}"`);
|
|
250
261
|
}
|
|
251
|
-
const content = resolveWriteContent(params, ctx
|
|
262
|
+
const content = resolveWriteContent(params, ctx);
|
|
252
263
|
const localHash = sha256Hex(content);
|
|
253
264
|
const info = await readDeviceResource(ctx.deviceId, '$scripts/info', { user: ctx.user });
|
|
254
265
|
const baseDir = info?.path;
|
|
@@ -288,7 +299,7 @@ export const CHECKERS = {
|
|
|
288
299
|
return { status: 'changed', summary: `property ${params.property} would change` };
|
|
289
300
|
},
|
|
290
301
|
|
|
291
|
-
async
|
|
302
|
+
async delete(ctx, params) {
|
|
292
303
|
try {
|
|
293
304
|
await ctx.api.info(params.path);
|
|
294
305
|
} catch (err) {
|
|
@@ -318,7 +329,7 @@ export const CHECKERS = {
|
|
|
318
329
|
}
|
|
319
330
|
},
|
|
320
331
|
|
|
321
|
-
async
|
|
332
|
+
async move(ctx, params) {
|
|
322
333
|
try {
|
|
323
334
|
await ctx.api.info(params.source);
|
|
324
335
|
} catch (err) {
|
|
@@ -386,12 +397,12 @@ export const CHECKERS = {
|
|
|
386
397
|
* logic the handler uses — so the check path hashes exactly the same
|
|
387
398
|
* bytes that would be uploaded.
|
|
388
399
|
*/
|
|
389
|
-
function resolveWriteContent(params,
|
|
400
|
+
function resolveWriteContent(params, ctx) {
|
|
390
401
|
if (typeof params.content === 'string') {
|
|
391
402
|
return Buffer.from(params.content, 'utf8');
|
|
392
403
|
}
|
|
393
404
|
if (typeof params.content_file === 'string') {
|
|
394
|
-
return
|
|
405
|
+
return readFileSync(localPath(ctx, params.content_file));
|
|
395
406
|
}
|
|
396
407
|
throw new Error('`content` or `content_file` is required');
|
|
397
408
|
}
|
|
@@ -455,11 +466,12 @@ function stableStringify(value) {
|
|
|
455
466
|
/**
|
|
456
467
|
* Build a per-device execution context that handlers share.
|
|
457
468
|
*/
|
|
458
|
-
export function createActionContext({ deviceId, user, baseDir }) {
|
|
469
|
+
export function createActionContext({ deviceId, user, baseDir, localRoot }) {
|
|
459
470
|
return {
|
|
460
471
|
deviceId,
|
|
461
472
|
user,
|
|
462
473
|
baseDir,
|
|
474
|
+
localRoot,
|
|
463
475
|
api: createDeviceAPI(deviceId, { user: user || undefined }),
|
|
464
476
|
};
|
|
465
477
|
}
|
|
@@ -104,10 +104,7 @@ function tokenize(src) {
|
|
|
104
104
|
}
|
|
105
105
|
if ((c >= '0' && c <= '9') || (c === '-' && src[i + 1] >= '0' && src[i + 1] <= '9')) {
|
|
106
106
|
let j = i + 1;
|
|
107
|
-
while (
|
|
108
|
-
j < src.length &&
|
|
109
|
-
((src[j] >= '0' && src[j] <= '9') || src[j] === '.')
|
|
110
|
-
) {
|
|
107
|
+
while (j < src.length && ((src[j] >= '0' && src[j] <= '9') || src[j] === '.')) {
|
|
111
108
|
j++;
|
|
112
109
|
}
|
|
113
110
|
const n = Number(src.slice(i, j));
|
package/lib/playbook/fleet.js
CHANGED
|
@@ -31,6 +31,7 @@ export const DEFAULT_FAILURE_THRESHOLD = 10;
|
|
|
31
31
|
* overrides?: Record<string, unknown>,
|
|
32
32
|
* checkMode?: boolean,
|
|
33
33
|
* baseDir?: string,
|
|
34
|
+
* localRoot?: string,
|
|
34
35
|
* signal?: AbortSignal,
|
|
35
36
|
* onBatchStart?: (info: { index: number, size: number, firstIndex: number, deviceIds: string[] }) => void,
|
|
36
37
|
* onBatchEnd?: (info: { index: number, size: number, firstIndex: number, deviceIds: string[], results: any[], cumulative: { attempted: number, succeeded: number, failed: number, failureRate: number } }) => void,
|
|
@@ -88,6 +89,7 @@ export async function runFleetPlaybook(pb, devices, opts = {}) {
|
|
|
88
89
|
failFast: false,
|
|
89
90
|
checkMode: !!opts.checkMode,
|
|
90
91
|
baseDir: opts.baseDir,
|
|
92
|
+
localRoot: opts.localRoot,
|
|
91
93
|
overrides: opts.overrides,
|
|
92
94
|
});
|
|
93
95
|
|
|
@@ -139,7 +141,8 @@ export async function runFleetPlaybook(pb, devices, opts = {}) {
|
|
|
139
141
|
}
|
|
140
142
|
|
|
141
143
|
if (aborted) {
|
|
142
|
-
const skipReason =
|
|
144
|
+
const skipReason =
|
|
145
|
+
reason === 'cancelled' ? 'cancelled' : 'aborted (failure threshold reached)';
|
|
143
146
|
for (let i = 0; i < devices.length; i++) {
|
|
144
147
|
if (results[i] === undefined) {
|
|
145
148
|
results[i] = {
|