@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.
Files changed (98) hide show
  1. package/CHANGELOG.md +86 -0
  2. package/README.md +117 -123
  3. package/bin/thinr.js +43 -105
  4. package/commands/_shared.js +10 -90
  5. package/commands/device/_shared.js +2 -2
  6. package/commands/device/exec.js +2 -2
  7. package/commands/device/fs.js +2 -2
  8. package/commands/device/index.js +5 -19
  9. package/commands/device/logs.js +4 -4
  10. package/commands/device/status.js +1 -1
  11. package/commands/device/transfer.js +2 -2
  12. package/commands/device/update.js +8 -2
  13. package/commands/fleet/upgrade.js +3 -3
  14. package/commands/playbook/run.js +2 -2
  15. package/commands/playbook/validate.js +2 -2
  16. package/commands/product/_shared.js +1 -1
  17. package/commands/product/exec.js +2 -2
  18. package/commands/product/fs.js +2 -2
  19. package/commands/product/index.js +4 -11
  20. package/commands/product/logs.js +2 -2
  21. package/commands/product/metric.js +2 -2
  22. package/commands/product/playbook.js +5 -5
  23. package/commands/product/property.js +4 -4
  24. package/commands/product/push.js +2 -2
  25. package/commands/product/resource.js +4 -4
  26. package/commands/product/status.js +3 -3
  27. package/dist/dashboard.js +12 -12
  28. package/docs/guides/thinremote-metrics-logs.md +59 -0
  29. package/docs/guides/thinremote-playbooks.md +133 -0
  30. package/docs/guides/thinremote-scripts.md +115 -0
  31. package/docs/guides/thinremote.md +116 -0
  32. package/lib/command-stream.js +3 -2
  33. package/lib/console.js +6 -6
  34. package/lib/constants.js +1 -19
  35. package/lib/dashboard/event-stream.js +5 -5
  36. package/lib/device-api.js +4 -4
  37. package/lib/guides.js +41 -0
  38. package/lib/identity.js +75 -0
  39. package/lib/local-files.js +54 -0
  40. package/lib/mcp/{tools-install.js → agent-install.js} +12 -61
  41. package/lib/mcp/helpers.js +5 -25
  42. package/lib/mcp/playbook-ops.js +393 -0
  43. package/lib/mcp/registry.js +9 -43
  44. package/lib/mcp/resource-hooks.js +53 -0
  45. package/lib/mcp/resource-types.js +857 -0
  46. package/lib/mcp/tools-exec.js +217 -177
  47. package/lib/monitoring.js +3 -3
  48. package/lib/playbook/actions.js +30 -18
  49. package/lib/playbook/expression.js +1 -4
  50. package/lib/playbook/fleet.js +4 -1
  51. package/lib/playbook/loader.js +16 -8
  52. package/lib/playbook/runner.js +40 -8
  53. package/lib/playbook/schema.js +108 -34
  54. package/lib/product/log-presets.js +1 -1
  55. package/lib/product/logs.js +2 -2
  56. package/lib/product-orchestrator.js +14 -9
  57. package/lib/product.js +29 -227
  58. package/lib/proxy.js +4 -4
  59. package/lib/status.js +13 -5
  60. package/package.json +4 -2
  61. package/commands/device/delete.js +0 -53
  62. package/commands/device/list.js +0 -55
  63. package/commands/device/property.js +0 -52
  64. package/commands/device/resource.js +0 -52
  65. package/commands/logout.js +0 -47
  66. package/commands/product/list.js +0 -86
  67. package/commands/product/props.js +0 -125
  68. package/commands/profile.js +0 -173
  69. package/lib/alarms.js +0 -223
  70. package/lib/api.js +0 -89
  71. package/lib/auth.js +0 -428
  72. package/lib/bucket.js +0 -82
  73. package/lib/concurrency.js +0 -52
  74. package/lib/config.js +0 -230
  75. package/lib/devices.js +0 -60
  76. package/lib/errors.js +0 -65
  77. package/lib/format.js +0 -114
  78. package/lib/mcp/server.js +0 -67
  79. package/lib/mcp/tools-alarms.js +0 -507
  80. package/lib/mcp/tools-buckets.js +0 -99
  81. package/lib/mcp/tools-devices.js +0 -235
  82. package/lib/mcp/tools-filesystem.js +0 -281
  83. package/lib/mcp/tools-logs.js +0 -225
  84. package/lib/mcp/tools-playbook.js +0 -210
  85. package/lib/mcp/tools-products.js +0 -1557
  86. package/lib/mcp/tools-profile.js +0 -635
  87. package/lib/mcp/tools-profiles.js +0 -30
  88. package/lib/mcp/tools-properties.js +0 -79
  89. package/lib/mcp/tools-resources.js +0 -78
  90. package/lib/mcp/tools-scripts.js +0 -212
  91. package/lib/mcp/tools-tokens.js +0 -369
  92. package/lib/output.js +0 -114
  93. package/lib/paths.js +0 -32
  94. package/lib/profile.js +0 -657
  95. package/lib/property.js +0 -75
  96. package/lib/resource.js +0 -151
  97. package/lib/storage.js +0 -117
  98. package/lib/tokens.js +0 -366
@@ -1,17 +1,21 @@
1
1
  // @ts-check
2
- import { getMonitoringData } from '../monitoring.js';
3
- import { inputError } from '../errors.js';
4
- import { BYTES, TIMEOUTS } from '../constants.js';
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
- async function toolExec(args) {
8
- const { api } = getAPI(args.device_id, args.user);
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;
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
- async function toolUpdate(args) {
36
- const { api: deviceApi } = getAPI(args.device_id, args.user);
37
- const payload = {
38
- action: args.action,
39
- channel: args.channel || 'latest',
40
- };
41
- const result = await deviceApi.callResource('update', payload);
42
- return {
43
- content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
44
- isError: false,
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
- async function toolMonitoring(args) {
49
- const device = args.device_id;
50
- const user = args.user;
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
- // Real-time: read the device's `monitoring` resource directly.
53
- if (args.realtime) {
54
- if (!device) throw inputError('Device required for real-time monitoring');
55
- const { api: deviceApi } = getAPI(device, args.user);
56
- const mon = await deviceApi.getResource('monitoring');
57
- const parts = [`Real-time monitoring for ${device}:`];
58
- if (mon.cpu)
59
- parts.push(
60
- `CPU: ${mon.cpu.usage?.toFixed(1)}% (${mon.cpu.cores} cores, ${mon.cpu.temperature?.toFixed(1)}°C)`,
61
- );
62
- if (mon.memory)
63
- parts.push(
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)`,
65
- );
66
- if (mon.disk?.root)
67
- parts.push(
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
- // Historical: query the monitoring bucket.
88
- const data = await getMonitoringData({
89
- device,
90
- user,
91
- items: args.items || 10,
92
- sort: args.sort || 'desc',
93
- minutes: args.minutes,
94
- min_ts: args.min_ts,
95
- max_ts: args.max_ts,
96
- agg: args.agg,
97
- agg_type: args.agg_type,
98
- fields: args.fields,
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
- let output;
102
- if (Array.isArray(data) && data.length > 0) {
103
- output = data
104
- .map((d) => {
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: output || 'No data' }],
134
- isError: false,
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: 'thinr_exec',
141
- description: `Execute a shell command on a remote device. Returns stdout, stderr, and exit code.`,
142
- inputSchema: {
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
- device_id: { type: 'string', description: 'Device ID' },
161
- action: {
228
+ command: { type: 'string', description: 'Shell command to run.' },
229
+ device: {
162
230
  type: 'string',
163
- description: '"check" to check for updates, "apply" to install the update',
231
+ description: 'One device (from {tool:resource_list} type "device").',
164
232
  },
165
- channel: { type: 'string', description: 'Update channel (default: "latest")' },
166
- user: { type: 'string', description: 'API user' },
167
- },
168
- required: ['action', 'device_id'],
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
- device_id: {
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
- items: {
194
- type: 'number',
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
- minutes: {
199
- type: 'number',
200
- description: 'Get data from the last N minutes. Alternative to min_ts/max_ts.',
247
+ include_offline: {
248
+ type: 'boolean',
249
+ description: 'Also try devices that are not connected (default false).',
201
250
  },
202
- min_ts: { type: 'number', description: 'Minimum timestamp in milliseconds' },
203
- max_ts: {
251
+ timeout: {
204
252
  type: 'number',
205
- description: 'Maximum timestamp in milliseconds (0 = now)',
253
+ description: 'Seconds per device before giving up (default 30).',
206
254
  },
207
- sort: {
208
- type: 'string',
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
- agg_type: {
218
- type: 'string',
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
- fields: {
223
- type: 'string',
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: 'API user' },
267
+ user: { type: 'string', description: 'Account to act on (admin only).' },
228
268
  },
229
- required: [],
269
+ required: ['command'],
230
270
  },
231
- handler: toolMonitoring,
271
+ handler: toolExec,
232
272
  },
233
273
  ];
package/lib/monitoring.js CHANGED
@@ -1,6 +1,6 @@
1
- import api from './api.js';
2
- import { apiError } from './errors.js';
3
- import { v2, v3 } from './paths.js';
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
@@ -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 '../property.js';
8
- import { callDeviceResource, readDeviceResource } from '../resource.js';
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
- function resolveLocalPath(relPath, baseDir) {
22
- return baseDir ? resolvePath(baseDir, relPath) : resolvePath(relPath);
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 resolveLocalFile(relPath, baseDir) {
26
- return readFileSync(resolveLocalPath(relPath, baseDir), 'utf8');
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 = resolveLocalPath(params.source, ctx.baseDir);
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 = resolveLocalPath(params.destination, ctx.baseDir);
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 rm(ctx, params) {
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 mv(ctx, params) {
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 = resolveLocalFile(params.content_file, ctx.baseDir);
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 = resolveLocalPath(params.source, ctx.baseDir);
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.baseDir);
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 rm(ctx, params) {
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 mv(ctx, params) {
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, baseDir) {
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 Buffer.from(resolveLocalFile(params.content_file, baseDir), 'utf8');
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));
@@ -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 = reason === 'cancelled' ? 'cancelled' : 'aborted (failure threshold reached)';
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] = {