@thinremote/thinr-cli 1.2.0-alpha.1 → 1.3.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 +63 -1
- package/README.md +235 -30
- package/bin/thinr.js +43 -25
- package/commands/_shared.js +154 -0
- package/commands/dashboard.js +17 -0
- package/commands/device/_shared.js +17 -34
- package/commands/device/console.js +1 -0
- package/commands/device/delete.js +53 -0
- package/commands/device/exec.js +7 -6
- package/commands/device/fs.js +193 -0
- package/commands/device/index.js +9 -0
- package/commands/device/list.js +35 -23
- package/commands/device/logs.js +178 -0
- package/commands/device/property.js +30 -28
- package/commands/device/proxy.js +3 -0
- package/commands/device/resource.js +30 -29
- package/commands/device/status.js +35 -14
- package/commands/device/transfer.js +236 -0
- package/commands/device/update.js +15 -10
- package/commands/fleet/index.js +17 -0
- package/commands/fleet/upgrade.js +275 -0
- package/commands/logout.js +10 -11
- package/commands/playbook/_shared.js +12 -0
- package/commands/playbook/index.js +18 -0
- package/commands/playbook/run.js +302 -0
- package/commands/playbook/validate.js +38 -0
- package/commands/product/_shared.js +8 -0
- package/commands/product/exec.js +243 -0
- package/commands/product/fs.js +218 -0
- package/commands/product/index.js +35 -0
- package/commands/product/list.js +86 -0
- package/commands/product/logs.js +289 -0
- package/commands/product/metric.js +124 -0
- package/commands/product/playbook.js +911 -0
- package/commands/product/property.js +76 -0
- package/commands/product/props.js +125 -0
- package/commands/product/push.js +184 -0
- package/commands/product/resource.js +76 -0
- package/commands/product/status.js +213 -0
- package/commands/profile.js +76 -8
- package/dist/dashboard.js +5644 -0
- package/lib/agent-releases.js +59 -0
- package/lib/agent-versions.js +73 -0
- package/lib/alarms.js +252 -0
- package/lib/api.js +8 -2
- package/lib/auth.js +68 -20
- package/lib/bucket.js +87 -0
- package/lib/command-stream.js +213 -0
- package/lib/config.js +5 -3
- package/lib/console.js +19 -5
- package/lib/constants.js +83 -0
- package/lib/dashboard/event-stream.js +239 -0
- package/lib/dashboard/metric-aggregate.js +64 -0
- package/lib/dashboard/metric-filter.js +39 -0
- package/lib/debug-log.js +93 -0
- package/lib/device-api.js +131 -148
- package/lib/devices.js +18 -21
- package/lib/errors.js +12 -0
- package/lib/fleet-upgrade.js +106 -0
- package/lib/format.js +114 -0
- package/lib/mcp/registry.js +14 -0
- package/lib/mcp/tools-alarms.js +510 -0
- package/lib/mcp/tools-buckets.js +103 -0
- package/lib/mcp/tools-devices.js +33 -3
- package/lib/mcp/tools-exec.js +30 -17
- package/lib/mcp/tools-filesystem.js +102 -6
- package/lib/mcp/tools-install.js +153 -0
- package/lib/mcp/tools-logs.js +225 -0
- package/lib/mcp/tools-playbook.js +211 -0
- package/lib/mcp/tools-products.js +1226 -105
- package/lib/mcp/tools-profile.js +634 -0
- package/lib/mcp/tools-tokens.js +371 -0
- package/lib/monitoring.js +26 -1
- package/lib/playbook/actions.js +447 -0
- package/lib/playbook/expression.js +302 -0
- package/lib/playbook/fleet.js +175 -0
- package/lib/playbook/loader.js +247 -0
- package/lib/playbook/runner.js +300 -0
- package/lib/playbook/schema.js +209 -0
- package/lib/playbook/vars.js +209 -0
- package/lib/product/log-presets.js +196 -0
- package/lib/product/logs.js +485 -0
- package/lib/product-orchestrator.js +124 -0
- package/lib/product.js +523 -20
- package/lib/profile.js +665 -0
- package/lib/property.js +7 -40
- package/lib/proxy.js +9 -8
- package/lib/resource.js +10 -14
- package/lib/status.js +78 -37
- package/lib/tokens.js +374 -0
- package/package.json +14 -3
- package/commands/product.js +0 -407
package/lib/mcp/tools-exec.js
CHANGED
|
@@ -1,14 +1,17 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
import { getMonitoringData } from '../monitoring.js';
|
|
3
3
|
import { inputError } from '../errors.js';
|
|
4
|
+
import { BYTES, TIMEOUTS } from '../constants.js';
|
|
4
5
|
import { getAPI } from './helpers.js';
|
|
5
6
|
|
|
6
7
|
async function toolExec(args) {
|
|
7
8
|
const { api } = getAPI(args.device, args.user);
|
|
8
|
-
// Default
|
|
9
|
-
//
|
|
10
|
-
|
|
11
|
-
|
|
9
|
+
// Default guards against hung commands locking the session; callers
|
|
10
|
+
// override for known-long operations. Knob lives in lib/constants.js.
|
|
11
|
+
const timeout =
|
|
12
|
+
Number.isFinite(args.timeout) && args.timeout > 0
|
|
13
|
+
? args.timeout
|
|
14
|
+
: TIMEOUTS.DEFAULT_EXEC_SECONDS;
|
|
12
15
|
let output = '';
|
|
13
16
|
const { exitCode, timedOut } = await api.execStream(args.command, {
|
|
14
17
|
timeout,
|
|
@@ -58,15 +61,15 @@ async function toolMonitoring(args) {
|
|
|
58
61
|
);
|
|
59
62
|
if (mon.memory)
|
|
60
63
|
parts.push(
|
|
61
|
-
`Memory: ${mon.memory.usage?.toFixed(1)}% (available: ${(mon.memory.available /
|
|
64
|
+
`Memory: ${mon.memory.usage?.toFixed(1)}% (available: ${(mon.memory.available / BYTES.GB).toFixed(1)}GB / total: ${(mon.memory.total / BYTES.GB).toFixed(1)}GB)`,
|
|
62
65
|
);
|
|
63
66
|
if (mon.disk?.root)
|
|
64
67
|
parts.push(
|
|
65
|
-
`Disk: ${mon.disk.root.usage?.toFixed(1)}% (available: ${(mon.disk.root.available /
|
|
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)`,
|
|
66
69
|
);
|
|
67
70
|
if (mon.network)
|
|
68
71
|
parts.push(
|
|
69
|
-
`Network: rx=${(mon.network.rx_rate /
|
|
72
|
+
`Network: rx=${(mon.network.rx_rate / BYTES.KB).toFixed(1)}KB/s tx=${(mon.network.tx_rate / BYTES.KB).toFixed(1)}KB/s`,
|
|
70
73
|
);
|
|
71
74
|
if (mon.load)
|
|
72
75
|
parts.push(
|
|
@@ -99,8 +102,10 @@ async function toolMonitoring(args) {
|
|
|
99
102
|
if (Array.isArray(data) && data.length > 0) {
|
|
100
103
|
output = data
|
|
101
104
|
.map((d) => {
|
|
102
|
-
|
|
103
|
-
|
|
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());
|
|
104
109
|
if (d.device) parts.push(`device=${d.device}`);
|
|
105
110
|
if (d.cpu) {
|
|
106
111
|
const cpuParts = [];
|
|
@@ -113,7 +118,7 @@ async function toolMonitoring(args) {
|
|
|
113
118
|
if (d.disk?.root) parts.push(`disk=${d.disk.root.usage?.toFixed(1)}%`);
|
|
114
119
|
if (d.network)
|
|
115
120
|
parts.push(
|
|
116
|
-
`net_rx=${(d.network.rx_rate /
|
|
121
|
+
`net_rx=${(d.network.rx_rate / BYTES.KB)?.toFixed(1)}KB/s tx=${(d.network.tx_rate / BYTES.KB)?.toFixed(1)}KB/s`,
|
|
117
122
|
);
|
|
118
123
|
if (d.load) parts.push(`load=${d.load['1m']?.toFixed(2)}`);
|
|
119
124
|
if (d.processes) parts.push(`procs=${d.processes.total}`);
|
|
@@ -166,7 +171,12 @@ export const tools = [
|
|
|
166
171
|
},
|
|
167
172
|
{
|
|
168
173
|
name: 'thinr_monitoring',
|
|
169
|
-
description: `Get monitoring data (CPU, memory, disk, network, temperature, load)
|
|
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.`,
|
|
170
180
|
inputSchema: {
|
|
171
181
|
type: 'object',
|
|
172
182
|
properties: {
|
|
@@ -178,11 +188,11 @@ export const tools = [
|
|
|
178
188
|
device: {
|
|
179
189
|
type: 'string',
|
|
180
190
|
description:
|
|
181
|
-
'Device ID. Required for realtime. Omit for
|
|
191
|
+
'Device ID. Required for realtime. Omit for a one-row-per-device fleet view.',
|
|
182
192
|
},
|
|
183
193
|
items: {
|
|
184
194
|
type: 'number',
|
|
185
|
-
description: 'Number of data points to return (default: 10)',
|
|
195
|
+
description: '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.',
|
|
186
196
|
},
|
|
187
197
|
minutes: {
|
|
188
198
|
type: 'number',
|
|
@@ -195,14 +205,17 @@ export const tools = [
|
|
|
195
205
|
},
|
|
196
206
|
sort: {
|
|
197
207
|
type: 'string',
|
|
198
|
-
description: 'Sort order: "asc" or "desc" (default: "desc")',
|
|
208
|
+
description: 'Sort order by timestamp: "asc" or "desc" (default: "desc"). Does NOT sort by metric — do that client-side on the result.',
|
|
209
|
+
},
|
|
210
|
+
agg: { type: 'string', description: '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.' },
|
|
211
|
+
agg_type: {
|
|
212
|
+
type: 'string',
|
|
213
|
+
description: '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.',
|
|
199
214
|
},
|
|
200
|
-
agg: { type: 'string', description: 'Aggregation period: "5m", "10m", "1h", "6h"' },
|
|
201
|
-
agg_type: { type: 'string', description: 'Aggregation type: "mean", "min", "max"' },
|
|
202
215
|
fields: {
|
|
203
216
|
type: 'string',
|
|
204
217
|
description:
|
|
205
|
-
'Comma-separated fields to return (e.g., "cpu.usage,memory.usage,disk.root.usage")',
|
|
218
|
+
'Comma-separated fields to return (e.g., "cpu.usage,memory.usage,disk.root.usage"). Narrower queries are cheaper and produce tidier output.',
|
|
206
219
|
},
|
|
207
220
|
user: { type: 'string', description: 'API user' },
|
|
208
221
|
},
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
// @ts-check
|
|
2
|
+
import { readFile as readLocalFile, writeFile as writeLocalFile } from 'fs/promises';
|
|
3
|
+
import { resolve as resolvePath, basename } from 'path';
|
|
2
4
|
import { getAPI } from './helpers.js';
|
|
5
|
+
import { inputError } from '../errors.js';
|
|
3
6
|
|
|
4
7
|
async function toolRead(args) {
|
|
5
8
|
const { api } = getAPI(args.device, args.user);
|
|
@@ -43,7 +46,7 @@ async function toolMkdir(args) {
|
|
|
43
46
|
};
|
|
44
47
|
}
|
|
45
48
|
|
|
46
|
-
async function
|
|
49
|
+
async function toolRm(args) {
|
|
47
50
|
const { api } = getAPI(args.device, args.user);
|
|
48
51
|
await api.delete(args.path, args.recursive !== false);
|
|
49
52
|
return {
|
|
@@ -52,7 +55,7 @@ async function toolDelete(args) {
|
|
|
52
55
|
};
|
|
53
56
|
}
|
|
54
57
|
|
|
55
|
-
async function
|
|
58
|
+
async function toolMv(args) {
|
|
56
59
|
const { api } = getAPI(args.device, args.user);
|
|
57
60
|
await api.move(args.source, args.destination, args.overwrite || false);
|
|
58
61
|
return {
|
|
@@ -61,6 +64,55 @@ async function toolMove(args) {
|
|
|
61
64
|
};
|
|
62
65
|
}
|
|
63
66
|
|
|
67
|
+
async function toolPush(args) {
|
|
68
|
+
if (typeof args.source !== 'string' || !args.source) {
|
|
69
|
+
throw inputError('`source` (local file path) is required');
|
|
70
|
+
}
|
|
71
|
+
if (typeof args.destination !== 'string' || !args.destination) {
|
|
72
|
+
throw inputError('`destination` (remote path on the device) is required');
|
|
73
|
+
}
|
|
74
|
+
const localPath = resolvePath(args.source);
|
|
75
|
+
const remotePath = args.destination.endsWith('/')
|
|
76
|
+
? args.destination + basename(localPath)
|
|
77
|
+
: args.destination;
|
|
78
|
+
const { api } = getAPI(args.device, args.user);
|
|
79
|
+
const content = await readLocalFile(localPath);
|
|
80
|
+
await api.writeFile(remotePath, content);
|
|
81
|
+
return {
|
|
82
|
+
content: [
|
|
83
|
+
{
|
|
84
|
+
type: 'text',
|
|
85
|
+
text: `Pushed ${content.byteLength} bytes: ${localPath} → ${remotePath}`,
|
|
86
|
+
},
|
|
87
|
+
],
|
|
88
|
+
isError: false,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function toolPull(args) {
|
|
93
|
+
if (typeof args.source !== 'string' || !args.source) {
|
|
94
|
+
throw inputError('`source` (remote path on the device) is required');
|
|
95
|
+
}
|
|
96
|
+
if (typeof args.destination !== 'string' || !args.destination) {
|
|
97
|
+
throw inputError('`destination` (local file path) is required');
|
|
98
|
+
}
|
|
99
|
+
const localPath = args.destination.endsWith('/')
|
|
100
|
+
? resolvePath(args.destination, basename(args.source))
|
|
101
|
+
: resolvePath(args.destination);
|
|
102
|
+
const { api } = getAPI(args.device, args.user);
|
|
103
|
+
const buf = await api.readFile(args.source);
|
|
104
|
+
await writeLocalFile(localPath, buf);
|
|
105
|
+
return {
|
|
106
|
+
content: [
|
|
107
|
+
{
|
|
108
|
+
type: 'text',
|
|
109
|
+
text: `Pulled ${buf.byteLength} bytes: ${args.source} → ${localPath}`,
|
|
110
|
+
},
|
|
111
|
+
],
|
|
112
|
+
isError: false,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
64
116
|
export const tools = [
|
|
65
117
|
{
|
|
66
118
|
name: 'thinr_read',
|
|
@@ -124,7 +176,7 @@ export const tools = [
|
|
|
124
176
|
handler: toolMkdir,
|
|
125
177
|
},
|
|
126
178
|
{
|
|
127
|
-
name: '
|
|
179
|
+
name: 'thinr_rm',
|
|
128
180
|
description: `Delete a file or directory on a remote device. Paths are absolute on the device filesystem (e.g., "/etc/hosts", "/var/log/syslog"). Subject to the OS user the agent runs as; check whoami (or thinr_device_info) before assuming write access to system paths like /etc.`,
|
|
129
181
|
inputSchema: {
|
|
130
182
|
type: 'object',
|
|
@@ -139,10 +191,10 @@ export const tools = [
|
|
|
139
191
|
},
|
|
140
192
|
required: ['path', 'device'],
|
|
141
193
|
},
|
|
142
|
-
handler:
|
|
194
|
+
handler: toolRm,
|
|
143
195
|
},
|
|
144
196
|
{
|
|
145
|
-
name: '
|
|
197
|
+
name: 'thinr_mv',
|
|
146
198
|
description: `Move or rename a file or directory on a remote device. Paths are absolute on the device filesystem (e.g., "/etc/hosts", "/var/log/syslog"). Subject to the OS user the agent runs as; check whoami (or thinr_device_info) before assuming write access to system paths like /etc.`,
|
|
147
199
|
inputSchema: {
|
|
148
200
|
type: 'object',
|
|
@@ -158,6 +210,50 @@ export const tools = [
|
|
|
158
210
|
},
|
|
159
211
|
required: ['source', 'destination', 'device'],
|
|
160
212
|
},
|
|
161
|
-
handler:
|
|
213
|
+
handler: toolMv,
|
|
214
|
+
},
|
|
215
|
+
{
|
|
216
|
+
name: 'thinr_push',
|
|
217
|
+
description: `Upload a local file to a remote device. \`source\` is a path on the machine running the MCP server; \`destination\` is an absolute path on the device (trailing "/" keeps the source basename). Use this for binary or large payloads that don't fit as inline strings in thinr_write.`,
|
|
218
|
+
inputSchema: {
|
|
219
|
+
type: 'object',
|
|
220
|
+
properties: {
|
|
221
|
+
source: {
|
|
222
|
+
type: 'string',
|
|
223
|
+
description: 'Local file path (absolute or relative to the MCP server CWD).',
|
|
224
|
+
},
|
|
225
|
+
destination: {
|
|
226
|
+
type: 'string',
|
|
227
|
+
description:
|
|
228
|
+
'Absolute path on the device. Trailing "/" keeps the source basename.',
|
|
229
|
+
},
|
|
230
|
+
device: { type: 'string', description: 'Device ID' },
|
|
231
|
+
user: { type: 'string', description: 'API user' },
|
|
232
|
+
},
|
|
233
|
+
required: ['source', 'destination', 'device'],
|
|
234
|
+
},
|
|
235
|
+
handler: toolPush,
|
|
236
|
+
},
|
|
237
|
+
{
|
|
238
|
+
name: 'thinr_pull',
|
|
239
|
+
description: `Download a remote file from a device to the local disk of the machine running the MCP server. \`source\` is an absolute path on the device; \`destination\` is a local file path (trailing "/" keeps the source basename).`,
|
|
240
|
+
inputSchema: {
|
|
241
|
+
type: 'object',
|
|
242
|
+
properties: {
|
|
243
|
+
source: {
|
|
244
|
+
type: 'string',
|
|
245
|
+
description: 'Absolute file path on the device.',
|
|
246
|
+
},
|
|
247
|
+
destination: {
|
|
248
|
+
type: 'string',
|
|
249
|
+
description:
|
|
250
|
+
'Local file path (absolute or relative to the MCP server CWD). Trailing "/" keeps the source basename.',
|
|
251
|
+
},
|
|
252
|
+
device: { type: 'string', description: 'Device ID' },
|
|
253
|
+
user: { type: 'string', description: 'API user' },
|
|
254
|
+
},
|
|
255
|
+
required: ['source', 'destination', 'device'],
|
|
256
|
+
},
|
|
257
|
+
handler: toolPull,
|
|
162
258
|
},
|
|
163
259
|
];
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import { requireConfig } from '../config.js';
|
|
3
|
+
import { inputError } from '../errors.js';
|
|
4
|
+
|
|
5
|
+
const CHANNEL_SCRIPT = {
|
|
6
|
+
latest: 'install.sh',
|
|
7
|
+
main: 'install-main.sh',
|
|
8
|
+
develop: 'install-develop.sh',
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
function shQuote(value) {
|
|
12
|
+
return `'${String(value).replace(/'/g, `'\\''`)}'`;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function decodeJwtClaims(token) {
|
|
16
|
+
const parts = String(token).split('.');
|
|
17
|
+
if (parts.length !== 3) return null;
|
|
18
|
+
try {
|
|
19
|
+
const pad = parts[1].length % 4 === 0 ? '' : '='.repeat(4 - (parts[1].length % 4));
|
|
20
|
+
const normalized = parts[1].replace(/-/g, '+').replace(/_/g, '/') + pad;
|
|
21
|
+
return JSON.parse(Buffer.from(normalized, 'base64').toString('utf8'));
|
|
22
|
+
} catch {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function toolAgentInstallCommand(args) {
|
|
28
|
+
// requireConfig throws `not_configured` if there's no active profile
|
|
29
|
+
// with a token — classifyError in server.js surfaces that cleanly.
|
|
30
|
+
const config = requireConfig();
|
|
31
|
+
|
|
32
|
+
const channel = args.channel || 'latest';
|
|
33
|
+
const script = CHANNEL_SCRIPT[channel];
|
|
34
|
+
if (!script) {
|
|
35
|
+
throw inputError(
|
|
36
|
+
`Unknown channel "${channel}". Valid channels: ${Object.keys(CHANNEL_SCRIPT).join(', ')}.`,
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const scheme = args.http ? 'http' : 'https';
|
|
41
|
+
const url = `${scheme}://get.thinremote.io/${script}`;
|
|
42
|
+
|
|
43
|
+
const agentArgs = ['install', '--token', shQuote(config.token)];
|
|
44
|
+
if (args.device) agentArgs.push('--device', shQuote(args.device));
|
|
45
|
+
if (args.product) agentArgs.push('--product', shQuote(args.product));
|
|
46
|
+
if (args.overwrite) agentArgs.push('--overwrite');
|
|
47
|
+
if (args.no_start) agentArgs.push('--no-start');
|
|
48
|
+
if (args.no_verify_ssl) agentArgs.push('--no-verify-ssl');
|
|
49
|
+
|
|
50
|
+
const runner = args.sudo ? 'sudo sh' : 'sh';
|
|
51
|
+
const command = `curl -fsSL ${url} | ${runner} -s -- ${agentArgs.join(' ')}`;
|
|
52
|
+
|
|
53
|
+
// The agent's `install --token` flow decodes the JWT and requires
|
|
54
|
+
// both `svr` and `usr` claims. Permanent user tokens minted via
|
|
55
|
+
// /v1/users/{user}/tokens are missing `svr` on some server versions,
|
|
56
|
+
// which makes auto-provisioning fail with "Invalid token". Warn up
|
|
57
|
+
// front so the caller can switch to an OAuth-issued token or ask
|
|
58
|
+
// the platform for a proper provisioning token.
|
|
59
|
+
const claims = decodeJwtClaims(config.token);
|
|
60
|
+
const warnings = [];
|
|
61
|
+
if (!claims) {
|
|
62
|
+
warnings.push(
|
|
63
|
+
'The active profile token is not a decodable JWT. Auto-provisioning expects a JWT with `svr` and `usr` claims.',
|
|
64
|
+
);
|
|
65
|
+
} else {
|
|
66
|
+
if (!claims.svr) {
|
|
67
|
+
warnings.push(
|
|
68
|
+
`The profile token is missing the \`svr\` claim. The agent will reject it with "Invalid token: missing 'svr' or 'usr' claim". Re-authenticate this profile via OAuth (\`thinr\`) or generate a provisioning token from the ${config.server} console.`,
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
if (!claims.usr) {
|
|
72
|
+
warnings.push('The profile token is missing the `usr` claim.');
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const sections = [
|
|
77
|
+
`Server: ${config.server}`,
|
|
78
|
+
`User: ${config.username}`,
|
|
79
|
+
`Channel: ${channel}`,
|
|
80
|
+
'',
|
|
81
|
+
'Run on the target host (directly or via `ssh host "<command>"`):',
|
|
82
|
+
'',
|
|
83
|
+
command,
|
|
84
|
+
'',
|
|
85
|
+
'Notes:',
|
|
86
|
+
'- The command embeds the active profile\'s API token — treat it as a credential.',
|
|
87
|
+
'- Installing the system service needs root. Pass `sudo: true` if the target host is not already root.',
|
|
88
|
+
'- The agent reads the target server from the JWT, so `--host` is not needed.',
|
|
89
|
+
];
|
|
90
|
+
|
|
91
|
+
if (warnings.length) {
|
|
92
|
+
sections.push('', 'Warnings:', ...warnings.map((w) => `- ${w}`));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return {
|
|
96
|
+
content: [{ type: 'text', text: sections.join('\n') }],
|
|
97
|
+
isError: false,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export const tools = [
|
|
102
|
+
{
|
|
103
|
+
name: 'thinr_agent_install_command',
|
|
104
|
+
description:
|
|
105
|
+
"Generate a one-liner shell command that installs thinr-agent on a remote host using the active profile's token for auto-provisioning. Meant to be run on the target machine (directly or piped through `ssh`). The token is embedded verbatim, so only share the output through trusted channels.",
|
|
106
|
+
inputSchema: {
|
|
107
|
+
type: 'object',
|
|
108
|
+
properties: {
|
|
109
|
+
device: {
|
|
110
|
+
type: 'string',
|
|
111
|
+
description:
|
|
112
|
+
'Custom device identifier. Omit to let the agent default to the target host\'s hostname.',
|
|
113
|
+
},
|
|
114
|
+
product: {
|
|
115
|
+
type: 'string',
|
|
116
|
+
description:
|
|
117
|
+
'Product to associate the device with. Omit to auto-detect or create the default product.',
|
|
118
|
+
},
|
|
119
|
+
overwrite: {
|
|
120
|
+
type: 'boolean',
|
|
121
|
+
description:
|
|
122
|
+
'Auto-overwrite an existing device with the same id on the server.',
|
|
123
|
+
},
|
|
124
|
+
no_start: {
|
|
125
|
+
type: 'boolean',
|
|
126
|
+
description: 'Install the service but do not start it.',
|
|
127
|
+
},
|
|
128
|
+
no_verify_ssl: {
|
|
129
|
+
type: 'boolean',
|
|
130
|
+
description:
|
|
131
|
+
'Disable TLS certificate verification (for self-signed servers).',
|
|
132
|
+
},
|
|
133
|
+
channel: {
|
|
134
|
+
type: 'string',
|
|
135
|
+
enum: ['latest', 'main', 'develop'],
|
|
136
|
+
description: 'Release channel. Defaults to `latest` (stable).',
|
|
137
|
+
},
|
|
138
|
+
sudo: {
|
|
139
|
+
type: 'boolean',
|
|
140
|
+
description:
|
|
141
|
+
'Pipe into `sudo sh` instead of `sh`. Needed on Linux hosts when running as a non-root user, since installing the systemd service writes under /etc.',
|
|
142
|
+
},
|
|
143
|
+
http: {
|
|
144
|
+
type: 'boolean',
|
|
145
|
+
description:
|
|
146
|
+
'Use http://get.thinremote.io instead of https (for devices without working HTTPS).',
|
|
147
|
+
},
|
|
148
|
+
},
|
|
149
|
+
required: [],
|
|
150
|
+
},
|
|
151
|
+
handler: toolAgentInstallCommand,
|
|
152
|
+
},
|
|
153
|
+
];
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import {
|
|
3
|
+
addLogSource,
|
|
4
|
+
getProductLogs,
|
|
5
|
+
listPresets,
|
|
6
|
+
removeLogSource,
|
|
7
|
+
setDefaultLogSource,
|
|
8
|
+
} from '../product/logs.js';
|
|
9
|
+
import { inputError } from '../errors.js';
|
|
10
|
+
|
|
11
|
+
function truncate(value, max = 80) {
|
|
12
|
+
if (typeof value !== 'string') return '';
|
|
13
|
+
return value.length > max ? `${value.slice(0, max - 1)}…` : value;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async function toolProductLogsList(args) {
|
|
17
|
+
if (!args.product) throw inputError('product is required');
|
|
18
|
+
const cfg = await getProductLogs(args.product, args.user);
|
|
19
|
+
const fallback = !!(/** @type {any} */ (cfg).__fallback);
|
|
20
|
+
const def = cfg.default || cfg.sources[0]?.name || null;
|
|
21
|
+
const lines = cfg.sources.map((s) => {
|
|
22
|
+
const tag = s.name === def ? '★' : ' ';
|
|
23
|
+
return ` ${tag} ${s.name} → ${truncate(s.command)}`;
|
|
24
|
+
});
|
|
25
|
+
const header = fallback
|
|
26
|
+
? `Product "${args.product}" has no logs property — showing the synthetic fallback (${cfg.sources.length} source):`
|
|
27
|
+
: `${cfg.sources.length} log source(s) on "${args.product}" (default: ${def}):`;
|
|
28
|
+
return {
|
|
29
|
+
content: [{ type: 'text', text: [header, ...lines].join('\n') }],
|
|
30
|
+
isError: false,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function toolProductLogsAdd(args) {
|
|
35
|
+
if (!args.product) throw inputError('product is required');
|
|
36
|
+
if (!args.name) throw inputError('name is required');
|
|
37
|
+
if (typeof args.command !== 'string' || args.command.trim() === '') {
|
|
38
|
+
throw inputError('command is required (non-empty string)');
|
|
39
|
+
}
|
|
40
|
+
const { config, action } = await addLogSource(
|
|
41
|
+
args.product,
|
|
42
|
+
{
|
|
43
|
+
name: args.name,
|
|
44
|
+
command: args.command,
|
|
45
|
+
makeDefault: !!args.default,
|
|
46
|
+
pattern: args.pattern,
|
|
47
|
+
preset: args.preset,
|
|
48
|
+
},
|
|
49
|
+
args.user,
|
|
50
|
+
);
|
|
51
|
+
const def = config.default || config.sources[0]?.name || null;
|
|
52
|
+
const lines = [
|
|
53
|
+
`${action === 'added' ? 'Added' : 'Updated'} source "${args.name}" on product "${args.product}"${
|
|
54
|
+
args.default ? ' (default)' : ''
|
|
55
|
+
}.`,
|
|
56
|
+
`Command: ${truncate(args.command, 120)}`,
|
|
57
|
+
];
|
|
58
|
+
if (args.preset) lines.push(`Preset: ${args.preset}`);
|
|
59
|
+
if (args.pattern) lines.push(`Pattern: ${truncate(args.pattern, 120)}`);
|
|
60
|
+
lines.push(`Sources: ${config.sources.length}${def ? `, default: ${def}` : ''}`);
|
|
61
|
+
return {
|
|
62
|
+
content: [{ type: 'text', text: lines.join('\n') }],
|
|
63
|
+
isError: false,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function toolProductLogsPresets() {
|
|
68
|
+
const presets = listPresets();
|
|
69
|
+
const lines = [
|
|
70
|
+
`${presets.length} log line preset(s) shipped with the CLI:`,
|
|
71
|
+
...presets.map((p) => ` · ${p.name} — ${p.description}`),
|
|
72
|
+
];
|
|
73
|
+
return {
|
|
74
|
+
content: [{ type: 'text', text: lines.join('\n') }],
|
|
75
|
+
isError: false,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function toolProductLogsRemove(args) {
|
|
80
|
+
if (!args.product) throw inputError('product is required');
|
|
81
|
+
if (!args.name) throw inputError('name is required');
|
|
82
|
+
const { removed, config } = await removeLogSource(args.product, args.name, args.user);
|
|
83
|
+
if (!removed) {
|
|
84
|
+
return {
|
|
85
|
+
content: [
|
|
86
|
+
{
|
|
87
|
+
type: 'text',
|
|
88
|
+
text: `Source "${args.name}" was not configured on product "${args.product}".`,
|
|
89
|
+
},
|
|
90
|
+
],
|
|
91
|
+
isError: false,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
if (config === null) {
|
|
95
|
+
return {
|
|
96
|
+
content: [
|
|
97
|
+
{
|
|
98
|
+
type: 'text',
|
|
99
|
+
text: `Removed last source "${args.name}" from product "${args.product}". Logs property cleared — falls back to the system journal.`,
|
|
100
|
+
},
|
|
101
|
+
],
|
|
102
|
+
isError: false,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
const def = config.default || config.sources[0]?.name || null;
|
|
106
|
+
return {
|
|
107
|
+
content: [
|
|
108
|
+
{
|
|
109
|
+
type: 'text',
|
|
110
|
+
text: `Removed source "${args.name}" from product "${args.product}". ${config.sources.length} source(s) remain${def ? `, default: ${def}` : ''}.`,
|
|
111
|
+
},
|
|
112
|
+
],
|
|
113
|
+
isError: false,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function toolProductLogsSetDefault(args) {
|
|
118
|
+
if (!args.product) throw inputError('product is required');
|
|
119
|
+
if (!args.name) throw inputError('name is required');
|
|
120
|
+
const config = await setDefaultLogSource(args.product, args.name, args.user);
|
|
121
|
+
return {
|
|
122
|
+
content: [
|
|
123
|
+
{
|
|
124
|
+
type: 'text',
|
|
125
|
+
text: `Default log source set to "${config.default}" on product "${args.product}".`,
|
|
126
|
+
},
|
|
127
|
+
],
|
|
128
|
+
isError: false,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export const tools = [
|
|
133
|
+
{
|
|
134
|
+
name: 'thinr_product_logs_list',
|
|
135
|
+
description: `List the log sources configured on a product. Each source pairs a slug-ish \`name\` (visible to operators) with a \`command\` the agent will exec-stream when that source is selected. The default source — used when callers do not pick explicitly — is highlighted with a ★. When the product has no \`logs\` property yet, returns a synthetic fallback with a single \`system\` source running journalctl, matching the dashboard's pre-feature behaviour. Pair with thinr_product_logs_add to populate, thinr_product_logs_set_default to pick which one comes up first, or thinr_product_logs_remove to drop entries.`,
|
|
136
|
+
inputSchema: {
|
|
137
|
+
type: 'object',
|
|
138
|
+
properties: {
|
|
139
|
+
product: { type: 'string', description: 'Product ID.' },
|
|
140
|
+
user: { type: 'string', description: 'API user' },
|
|
141
|
+
},
|
|
142
|
+
required: ['product'],
|
|
143
|
+
},
|
|
144
|
+
handler: toolProductLogsList,
|
|
145
|
+
},
|
|
146
|
+
{
|
|
147
|
+
name: 'thinr_product_logs_add',
|
|
148
|
+
description: `Upsert a log source on a product: adds a new entry if \`name\` is unused, replaces the command in place when it already exists. The command is an opaque shell string the agent will exec-stream as-is when the dashboard or CLI selects this source — there is no protocol indirection, no special log "kind". Use it to expose journalctl unit tails, container log streams (\`docker logs -f thinger\`), application log files (\`tail -F /var/log/app.log\`), or any other follow-style command relevant to the device fleet.
|
|
149
|
+
|
|
150
|
+
Set \`default: true\` to mark this source as the active default; the previous default is preserved otherwise (or, on a fresh product, no default is recorded so consumers fall back to the first source). Names are slug-ish (letters, digits, underscore, dash; ≤ 32 chars). Up to 32 sources per product. The product does not need to exist on the agent — the configuration lives entirely server-side.
|
|
151
|
+
|
|
152
|
+
Optionally attach a \`pattern\` (regex with named groups \`time\`, \`level\`, \`msg\`) or a \`preset\` (named pattern shipped with the CLI; see thinr_product_logs_presets) to enable structured rendering in the dashboard. When the pattern captures a \`level\` group, the operator can filter the panel by severity (info+/warn+/error+). \`pattern\` and \`preset\` are mutually exclusive; omit both to keep the source rendered raw.`,
|
|
153
|
+
inputSchema: {
|
|
154
|
+
type: 'object',
|
|
155
|
+
properties: {
|
|
156
|
+
product: { type: 'string', description: 'Product ID.' },
|
|
157
|
+
name: {
|
|
158
|
+
type: 'string',
|
|
159
|
+
description:
|
|
160
|
+
'Stable identifier for the source (letters, digits, underscore, dash; ≤ 32 chars). Reusing the name updates the existing entry.',
|
|
161
|
+
},
|
|
162
|
+
command: {
|
|
163
|
+
type: 'string',
|
|
164
|
+
description:
|
|
165
|
+
'Shell command the agent should exec-stream when this source is selected (e.g. "journalctl --no-pager --output=short -f", "docker logs -f thinger", "tail -F /var/log/nginx/access.log"). Stored verbatim.',
|
|
166
|
+
},
|
|
167
|
+
default: {
|
|
168
|
+
type: 'boolean',
|
|
169
|
+
description:
|
|
170
|
+
'Mark this source as the active default (default: false). When false the previous default is preserved.',
|
|
171
|
+
},
|
|
172
|
+
pattern: {
|
|
173
|
+
type: 'string',
|
|
174
|
+
description:
|
|
175
|
+
'Custom regex with named groups (time, level, msg) for structured rendering in the dashboard. Mutually exclusive with `preset`. Validated to compile at config time.',
|
|
176
|
+
},
|
|
177
|
+
preset: {
|
|
178
|
+
type: 'string',
|
|
179
|
+
description:
|
|
180
|
+
'Named preset for structured rendering (e.g. "spdlog", "journalctl", "nginx-error"). Use thinr_product_logs_presets to list available names. Mutually exclusive with `pattern`.',
|
|
181
|
+
},
|
|
182
|
+
user: { type: 'string', description: 'API user' },
|
|
183
|
+
},
|
|
184
|
+
required: ['product', 'name', 'command'],
|
|
185
|
+
},
|
|
186
|
+
handler: toolProductLogsAdd,
|
|
187
|
+
},
|
|
188
|
+
{
|
|
189
|
+
name: 'thinr_product_logs_remove',
|
|
190
|
+
description: `Remove a log source by name from a product. When the removed entry was the default, the default is dropped (consumers will fall back to the first remaining source). When the last source is removed, the entire \`logs\` property is deleted so the product reverts to the synthetic fallback. Idempotent — calling it on a missing source reports success without erroring.`,
|
|
191
|
+
inputSchema: {
|
|
192
|
+
type: 'object',
|
|
193
|
+
properties: {
|
|
194
|
+
product: { type: 'string', description: 'Product ID.' },
|
|
195
|
+
name: { type: 'string', description: 'Source name previously registered.' },
|
|
196
|
+
user: { type: 'string', description: 'API user' },
|
|
197
|
+
},
|
|
198
|
+
required: ['product', 'name'],
|
|
199
|
+
},
|
|
200
|
+
handler: toolProductLogsRemove,
|
|
201
|
+
},
|
|
202
|
+
{
|
|
203
|
+
name: 'thinr_product_logs_presets',
|
|
204
|
+
description: `List the named log line patterns shipped with the CLI. Each preset bundles a regex with named groups (\`time\`, \`level\`, \`msg\`) tuned for a common log format (journalctl short, spdlog, nginx access/error). Use the preset name with thinr_product_logs_add via the \`preset\` argument to enable structured rendering and level-based filtering on a source without typing the regex by hand.`,
|
|
205
|
+
inputSchema: {
|
|
206
|
+
type: 'object',
|
|
207
|
+
properties: {},
|
|
208
|
+
},
|
|
209
|
+
handler: toolProductLogsPresets,
|
|
210
|
+
},
|
|
211
|
+
{
|
|
212
|
+
name: 'thinr_product_logs_set_default',
|
|
213
|
+
description: `Set the active default log source on a product. The named source must already exist (use thinr_product_logs_add first). The default is what the dashboard surfaces first when an operator opens a device, and what \`thinr device logs <id>\` resolves to when no \`--source\` flag is passed. To clear the default, remove the matching source with thinr_product_logs_remove.`,
|
|
214
|
+
inputSchema: {
|
|
215
|
+
type: 'object',
|
|
216
|
+
properties: {
|
|
217
|
+
product: { type: 'string', description: 'Product ID.' },
|
|
218
|
+
name: { type: 'string', description: 'Source name to mark as default.' },
|
|
219
|
+
user: { type: 'string', description: 'API user' },
|
|
220
|
+
},
|
|
221
|
+
required: ['product', 'name'],
|
|
222
|
+
},
|
|
223
|
+
handler: toolProductLogsSetDefault,
|
|
224
|
+
},
|
|
225
|
+
];
|