@thinremote/thinr-cli 1.0.2 → 1.1.0-alpha.2

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 (46) hide show
  1. package/CHANGELOG.md +90 -0
  2. package/README.md +362 -87
  3. package/bin/thinr.js +51 -11
  4. package/commands/device/_shared.js +48 -0
  5. package/commands/device/console.js +15 -0
  6. package/commands/device/exec.js +76 -0
  7. package/commands/device/index.js +31 -0
  8. package/commands/device/list.js +43 -0
  9. package/commands/device/property.js +50 -0
  10. package/commands/device/proxy.js +42 -0
  11. package/commands/device/resource.js +51 -0
  12. package/commands/device/status.js +26 -0
  13. package/commands/device/update.js +53 -0
  14. package/commands/logout.js +12 -5
  15. package/commands/product.js +146 -177
  16. package/commands/profile.js +105 -0
  17. package/lib/api.js +54 -31
  18. package/lib/auth.js +225 -309
  19. package/lib/config.js +176 -45
  20. package/lib/console.js +87 -108
  21. package/lib/device-api.js +257 -0
  22. package/lib/devices.js +69 -0
  23. package/lib/errors.js +53 -0
  24. package/lib/mcp/helpers.js +34 -0
  25. package/lib/mcp/registry.js +32 -0
  26. package/lib/mcp/server.js +67 -0
  27. package/lib/mcp/tools-devices.js +194 -0
  28. package/lib/mcp/tools-exec.js +213 -0
  29. package/lib/mcp/tools-filesystem.js +163 -0
  30. package/lib/mcp/tools-products.js +232 -0
  31. package/lib/mcp/tools-profiles.js +30 -0
  32. package/lib/mcp/tools-properties.js +79 -0
  33. package/lib/mcp/tools-resources.js +76 -0
  34. package/lib/mcp/tools-scripts.js +172 -0
  35. package/lib/monitoring.js +72 -0
  36. package/lib/output.js +114 -0
  37. package/lib/product.js +373 -0
  38. package/lib/property.js +41 -57
  39. package/lib/proxy.js +58 -90
  40. package/lib/resource.js +106 -72
  41. package/lib/status.js +7 -33
  42. package/lib/storage.js +128 -0
  43. package/package.json +68 -37
  44. package/.github/workflows/publish.yml +0 -79
  45. package/commands/device.js +0 -268
  46. package/lib/device.js +0 -76
@@ -0,0 +1,257 @@
1
+ import api from './api.js';
2
+ import { requireConfig } from './config.js';
3
+ import WebSocket from 'ws';
4
+ import { randomUUID } from 'crypto';
5
+
6
+ /**
7
+ * Create a device API client for filesystem and command operations
8
+ * @param {string} deviceId - The device ID
9
+ * @param {Object} [options] - Options
10
+ * @param {string} [options.user] - Override API user (admin impersonation)
11
+ * @returns {Object} API client with fs and exec methods
12
+ */
13
+ export function createDeviceAPI(deviceId, options = {}) {
14
+ const config = requireConfig();
15
+ const apiUser = options.user || config.username;
16
+ const resourcePath = `/v3/users/${apiUser}/devices/${deviceId}/resources`;
17
+ const filesPath = `/v3/users/${apiUser}/devices/${deviceId}/files`;
18
+
19
+ /**
20
+ * Call a device resource (for cmd, etc.)
21
+ */
22
+ async function callResource(resource, payload = null, opts = {}) {
23
+ const url = `${resourcePath}/${resource}`;
24
+ const response = await api.post(url, payload, {
25
+ responseType: opts.responseType || 'json',
26
+ params: opts.params || {},
27
+ headers: opts.headers || {},
28
+ timeout: opts.timeout || 30000,
29
+ });
30
+ return response;
31
+ }
32
+
33
+ return {
34
+ /**
35
+ * List directory contents
36
+ * GET /v3/users/{user}/devices/{device}/files/{path}/
37
+ */
38
+ async listDir(dirPath, includeHidden = false) {
39
+ const normalized = dirPath.endsWith('/') ? dirPath : dirPath + '/';
40
+ const params = {};
41
+ if (includeHidden) params.hidden = true;
42
+ const response = await api.get(`${filesPath}${normalized}`, { params });
43
+ return response.data;
44
+ },
45
+
46
+ /**
47
+ * Read file contents
48
+ * GET /v3/users/{user}/devices/{device}/files/{path}
49
+ */
50
+ async readFile(filePath) {
51
+ const response = await api.get(`${filesPath}${filePath}`, {
52
+ responseType: 'arraybuffer',
53
+ });
54
+ return Buffer.from(response.data);
55
+ },
56
+
57
+ /**
58
+ * Write file contents
59
+ * PUT /v3/users/{user}/devices/{device}/files/{path}
60
+ */
61
+ async writeFile(filePath, content, overwrite = true) {
62
+ const params = {};
63
+ if (!overwrite) params.overwrite = false;
64
+ const response = await api.put(`${filesPath}${filePath}`, content, {
65
+ headers: { 'Content-Type': 'application/octet-stream' },
66
+ params,
67
+ });
68
+ return response.data;
69
+ },
70
+
71
+ /**
72
+ * Get file/directory info
73
+ * GET /v3/users/{user}/devices/{device}/files/{path}?info=true
74
+ */
75
+ async info(filePath) {
76
+ const response = await api.get(`${filesPath}${filePath}`, {
77
+ params: { info: true },
78
+ });
79
+ return response.data;
80
+ },
81
+
82
+ /**
83
+ * Delete file or directory
84
+ * DELETE /v3/users/{user}/devices/{device}/files/{path}
85
+ */
86
+ async delete(filePath, recursive = true) {
87
+ const params = {};
88
+ if (recursive) params.recursive = true;
89
+ const response = await api.delete(`${filesPath}${filePath}`, { params });
90
+ return response.data;
91
+ },
92
+
93
+ /**
94
+ * Move/rename file or directory
95
+ * PATCH /v3/users/{user}/devices/{device}/files/{sourcePath}
96
+ */
97
+ async move(source, destination, overwrite = false) {
98
+ const response = await api.patch(`${filesPath}${source}`, {
99
+ path: destination,
100
+ overwrite,
101
+ });
102
+ return response.data;
103
+ },
104
+
105
+ /**
106
+ * Create directory
107
+ * PUT /v3/users/{user}/devices/{device}/files/{path}/
108
+ */
109
+ async mkdir(dirPath) {
110
+ const normalized = dirPath.endsWith('/') ? dirPath : dirPath + '/';
111
+ const response = await api.put(`${filesPath}${normalized}`, null, {
112
+ headers: { 'Content-Length': '0' },
113
+ });
114
+ return response.data;
115
+ },
116
+
117
+ /**
118
+ * Read a device resource property (GET)
119
+ * GET /v3/users/{user}/devices/{device}/resources/{resource}
120
+ */
121
+ async getResource(resource) {
122
+ const url = `${resourcePath}/${resource}`;
123
+ const response = await api.get(url, { timeout: 15000 });
124
+ return response.data;
125
+ },
126
+
127
+ /**
128
+ * Call a device resource action (POST)
129
+ * POST /v3/users/{user}/devices/{device}/resources/{resource}
130
+ */
131
+ async callResource(resource, payload = null, { timeout = 30000 } = {}) {
132
+ const url = `${resourcePath}/${resource}`;
133
+ const response = await api.post(url, payload, { timeout });
134
+ return response.data;
135
+ },
136
+
137
+ /**
138
+ * Execute a command on the device
139
+ * POST /v3/users/{user}/devices/{device}/resources/cmd
140
+ */
141
+ async exec(cmd, timeout = 30) {
142
+ const response = await callResource(
143
+ 'cmd',
144
+ {
145
+ cmd,
146
+ timeout,
147
+ mode: 'api',
148
+ },
149
+ { timeout: (timeout + 5) * 1000 },
150
+ );
151
+ return response.data;
152
+ },
153
+
154
+ /**
155
+ * Execute a command on the device using the WebSocket stream. Incoming
156
+ * frames are dispatched to onStdout/onStderr as they arrive. Resolves
157
+ * with { exitCode, timedOut } when the remote command completes or the
158
+ * connection closes. Returns a `cancel()` function via the optional
159
+ * onCancel hook so callers (e.g. SIGINT handlers) can tear it down.
160
+ *
161
+ * Preferred path for the CLI: the server detects WS close and stops
162
+ * the device-side stream automatically.
163
+ */
164
+ execStream(cmd, { timeout = 0, onStdout, onStderr, onCancel, stdin } = {}) {
165
+ const sessionId = randomUUID();
166
+ const resource = `$cmd/${sessionId}`;
167
+ const params = new URLSearchParams({ cmd });
168
+ if (timeout > 0) params.set('timeout', String(timeout));
169
+ const isLocal = config.server === 'localhost' || config.server === '127.0.0.1';
170
+ const insecure = isLocal || process.env.THINR_INSECURE === '1';
171
+ const wsUrl = `wss://${config.server}/v3/users/${apiUser}/devices/${deviceId}/resources/${resource}?${params}`;
172
+ const ws = new WebSocket(wsUrl, {
173
+ headers: { Authorization: `Bearer ${config.token}` },
174
+ rejectUnauthorized: !insecure,
175
+ });
176
+
177
+ let exitCode = null;
178
+ let timedOut = false;
179
+ let cancelled = false;
180
+
181
+ const cancel = () => {
182
+ cancelled = true;
183
+ if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) {
184
+ ws.close();
185
+ }
186
+ };
187
+ onCancel?.(cancel);
188
+
189
+ const decodeBytes = (field) => {
190
+ if (typeof field === 'string') return field;
191
+ if (field && Array.isArray(field.bytes)) {
192
+ return Buffer.from(field.bytes).toString('utf8');
193
+ }
194
+ return '';
195
+ };
196
+
197
+ return new Promise((resolve, reject) => {
198
+ let resolved = false;
199
+ const done = () => {
200
+ if (resolved) return;
201
+ resolved = true;
202
+ resolve({ exitCode, timedOut, cancelled });
203
+ // Tear down the socket without blocking on the server's
204
+ // close handshake.
205
+ if (
206
+ ws.readyState === WebSocket.OPEN ||
207
+ ws.readyState === WebSocket.CONNECTING
208
+ ) {
209
+ ws.terminate();
210
+ }
211
+ };
212
+
213
+ ws.on('open', () => {
214
+ // If the caller supplied a stdin stream, forward each
215
+ // chunk to the device as a stream frame. Payload is sent
216
+ // as a UTF-8 string under the standard {resource, payload}
217
+ // envelope that the server's stream handler expects.
218
+ if (stdin && typeof stdin.on === 'function') {
219
+ stdin.on('data', (chunk) => {
220
+ if (ws.readyState !== WebSocket.OPEN) return;
221
+ const text = Buffer.isBuffer(chunk)
222
+ ? chunk.toString('utf8')
223
+ : String(chunk);
224
+ ws.send(JSON.stringify({ resource, payload: text }));
225
+ });
226
+ }
227
+ });
228
+ ws.on('error', (err) => {
229
+ if (cancelled) return;
230
+ reject(err);
231
+ });
232
+ ws.on('message', (raw) => {
233
+ let evt;
234
+ try {
235
+ evt = JSON.parse(raw.toString('utf8'));
236
+ } catch {
237
+ return;
238
+ }
239
+ if (evt.signal && evt.signal !== 'data') return;
240
+ const p = evt.payload;
241
+ if (!p || typeof p !== 'object') return;
242
+ if (p.out !== undefined) onStdout?.(decodeBytes(p.out));
243
+ else if (p.err !== undefined) onStderr?.(decodeBytes(p.err));
244
+ else if (typeof p.exit === 'number') {
245
+ exitCode = p.exit;
246
+ if (p.timeout === true) timedOut = true;
247
+ // We have the full result — resolve immediately and
248
+ // tear down the WS without waiting for the server's
249
+ // stop cascade, which can add several seconds.
250
+ done();
251
+ }
252
+ });
253
+ ws.on('close', () => done());
254
+ });
255
+ },
256
+ };
257
+ }
package/lib/devices.js ADDED
@@ -0,0 +1,69 @@
1
+ import { requireConfig } from './config.js';
2
+ import api from './api.js';
3
+ import { apiError } from './errors.js';
4
+
5
+ /**
6
+ * Get devices belonging to a product
7
+ * @param {Object} filter - Filters to query devices
8
+ * @return {Promise<Array>} List of devices
9
+ */
10
+ export async function getDevices(filter, user = null) {
11
+ const config = requireConfig();
12
+ const apiUser = user || config.username;
13
+ let devices = [];
14
+
15
+ let queryParametersString = '';
16
+ for (const [key, value] of Object.entries(filter)) {
17
+ if (value) queryParametersString += key + '=' + value + '&';
18
+ }
19
+
20
+ try {
21
+ const count = 50;
22
+ const url = `/v1/users/${apiUser}/devices?count=${count}&${queryParametersString}`;
23
+ let index = 0;
24
+ let res_length = 0;
25
+
26
+ do {
27
+ const response = await api.get(`${url}index=${index}`);
28
+ res_length = response.data.length;
29
+ index += res_length;
30
+ devices = devices.concat(response.data);
31
+ } while (res_length === count);
32
+
33
+ return devices;
34
+ } catch (error) {
35
+ const product = filter && filter.productId;
36
+ const notFound = product ? `Product not found: ${product}` : 'Devices not found';
37
+ throw apiError(error, { notFound });
38
+ }
39
+ }
40
+
41
+ /**
42
+ * Fetch a single device record (metadata only — assigned product,
43
+ * description, asset group, connection state, …). Use this when you
44
+ * need the side-channel info that doesn't come from the device's own
45
+ * agent (system_info, monitoring, …) but from the server's record.
46
+ *
47
+ * @param {string} deviceId
48
+ * @param {string} [user] Admin impersonation; defaults to the active profile.
49
+ * @returns {Promise<Object>} Raw API record.
50
+ */
51
+ export async function getDevice(deviceId, user = null) {
52
+ const config = requireConfig();
53
+ const apiUser = user || config.username;
54
+ try {
55
+ const response = await api.get(`/v1/users/${apiUser}/devices/${deviceId}`);
56
+ return response.data || {};
57
+ } catch (error) {
58
+ throw apiError(error, { notFound: `Device not found: ${deviceId}` });
59
+ }
60
+ }
61
+
62
+ /**
63
+ * Filter active devices from a list of devices
64
+ * @param {Array} devices - List of devices to filter
65
+ * @return {Array} List of active devices
66
+ */
67
+ export function filterActiveDevices(devices) {
68
+ return devices.filter((device) => device.connection && device.connection.active === true);
69
+ }
package/lib/errors.js ADDED
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Normalize an axios / fetch-style error into a single Error with a stable
3
+ * `.code` and `.status`. Replaces the repeated if-response/if-request
4
+ * blocks in status.js / property.js / resource.js / device.js.
5
+ *
6
+ * Pass `messages.notFound` to override the 404 message with something
7
+ * that names the missing entity (e.g. "Device not found: abc").
8
+ */
9
+ export function apiError(error, messages = {}) {
10
+ if (error && error.response) {
11
+ const status = error.response.status;
12
+ if (status === 404) {
13
+ const err = new Error(messages.notFound || 'Not found');
14
+ err.code = 'not_found';
15
+ err.status = 404;
16
+ return err;
17
+ }
18
+ if (status === 401 || status === 403) {
19
+ const err = new Error(
20
+ messages.unauthorized ||
21
+ 'Unauthorized. Your token may have expired. Please reconfigure.',
22
+ );
23
+ err.code = 'unauthorized';
24
+ err.status = status;
25
+ return err;
26
+ }
27
+ const err = new Error(`Server error: ${status} ${error.response.statusText || ''}`.trim());
28
+ err.code = 'server_error';
29
+ err.status = status;
30
+ return err;
31
+ }
32
+ if (error && error.request) {
33
+ const err = new Error('No response from server. Please check your connection.');
34
+ err.code = 'network_error';
35
+ return err;
36
+ }
37
+ const err = new Error(error?.message || String(error));
38
+ err.code = 'error';
39
+ return err;
40
+ }
41
+
42
+ /**
43
+ * Pre-tagged error for invalid / missing user input (CLI option,
44
+ * MCP tool argument, …). Picked up by `classifyError` so it surfaces
45
+ * as `code: 'input_error'` in both the CLI JSON envelope and the
46
+ * MCP error metadata, matching the same code the CLI's `printErr`
47
+ * uses for argument validation failures.
48
+ */
49
+ export function inputError(message) {
50
+ const err = new Error(message);
51
+ err.code = 'input_error';
52
+ return err;
53
+ }
@@ -0,0 +1,34 @@
1
+ // @ts-check
2
+ import { createDeviceAPI } from '../device-api.js';
3
+ import { getActiveProfile, getProfile, setActiveProfile } from '../config.js';
4
+ import { setBaseURL } from '../api.js';
5
+ import { inputError } from '../errors.js';
6
+
7
+ /** Build a device-scoped API client. Tools must always pass `device`
8
+ * explicitly — the server has no per-session default. */
9
+ export function getAPI(device, user) {
10
+ if (!device)
11
+ throw inputError('device is required. Use thinr_devices to list available devices.');
12
+ return { api: createDeviceAPI(device, { user: user || undefined }), device };
13
+ }
14
+
15
+ /** Run `fn` with the active profile temporarily switched to `profile`
16
+ * (when supplied) so callers can target any configured environment per
17
+ * tool call without persisting the change. */
18
+ export async function withProfile(profile, fn) {
19
+ if (!profile) return fn();
20
+ const previous = getActiveProfile();
21
+ const target = getProfile(profile);
22
+ if (!target) {
23
+ throw inputError(`Unknown profile: ${profile}. Use thinr_profiles to list available ones.`);
24
+ }
25
+ setActiveProfile(profile);
26
+ if (target.server) setBaseURL(`https://${target.server}`);
27
+ try {
28
+ return await fn();
29
+ } finally {
30
+ setActiveProfile(previous);
31
+ const restore = previous ? getProfile(previous) : null;
32
+ if (restore?.server) setBaseURL(`https://${restore.server}`);
33
+ }
34
+ }
@@ -0,0 +1,32 @@
1
+ // @ts-check
2
+ import { tools as devicesTools } from './tools-devices.js';
3
+ import { tools as filesystemTools } from './tools-filesystem.js';
4
+ import { tools as execTools } from './tools-exec.js';
5
+ import { tools as resourcesTools } from './tools-resources.js';
6
+ import { tools as propertiesTools } from './tools-properties.js';
7
+ import { tools as scriptsTools } from './tools-scripts.js';
8
+ import { tools as productsTools } from './tools-products.js';
9
+ import { tools as profilesTools } from './tools-profiles.js';
10
+
11
+ export const tools = [
12
+ ...devicesTools,
13
+ ...filesystemTools,
14
+ ...execTools,
15
+ ...resourcesTools,
16
+ ...propertiesTools,
17
+ ...scriptsTools,
18
+ ...productsTools,
19
+ ...profilesTools,
20
+ ];
21
+
22
+ // Inject the optional `profile` parameter on every tool so callers can
23
+ // target any configured environment per-call without changing the saved
24
+ // default. `thinr_profiles` is the lookup itself, so it's exempt.
25
+ for (const t of tools) {
26
+ if (t.name === 'thinr_profiles') continue;
27
+ t.inputSchema.properties = t.inputSchema.properties || {};
28
+ t.inputSchema.properties.profile = {
29
+ type: 'string',
30
+ description: 'CLI profile to use for this call (see thinr_profiles). Omit for the default.',
31
+ };
32
+ }
@@ -0,0 +1,67 @@
1
+ // @ts-check
2
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
3
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
4
+ import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
5
+ import { readConfig } from '../config.js';
6
+ import { setBaseURL } from '../api.js';
7
+ import { classifyError } from '../output.js';
8
+ import { tools } from './registry.js';
9
+ import { withProfile } from './helpers.js';
10
+
11
+ /**
12
+ * Start a generic MCP server for ThinRemote.
13
+ * All tools accept device (when relevant) plus optional user/profile parameters.
14
+ *
15
+ * @param {{ version?: string }} [opts] `version` is echoed back in the
16
+ * MCP `initialize` response's `serverInfo.version`. Caller is expected
17
+ * to pass the CLI's `package.json#version` so the two never drift.
18
+ */
19
+ export async function startMCPServer({ version = 'unknown' } = {}) {
20
+ const config = readConfig();
21
+ if (config.server) {
22
+ setBaseURL(`https://${config.server}`);
23
+ }
24
+
25
+ const server = new Server(
26
+ {
27
+ name: 'thinr',
28
+ version,
29
+ },
30
+ {
31
+ capabilities: { tools: {} },
32
+ },
33
+ );
34
+
35
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({
36
+ tools: tools.map(({ handler: _handler, ...t }) => t),
37
+ }));
38
+
39
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
40
+ const name = request.params.name;
41
+ const args = request.params.arguments || {};
42
+ const tool = tools.find((t) => t.name === name);
43
+ if (!tool) {
44
+ return {
45
+ content: [{ type: 'text', text: `Unknown tool: ${name}` }],
46
+ isError: true,
47
+ };
48
+ }
49
+ try {
50
+ return await withProfile(args.profile, () => tool.handler(args));
51
+ } catch (error) {
52
+ // Surface the same {message, code} pair the CLI emits in JSON
53
+ // mode so MCP clients can pattern-match on `[code]` instead of
54
+ // doing fuzzy matches on the human message.
55
+ const { message, code } = classifyError(error);
56
+ return {
57
+ content: [{ type: 'text', text: `Error [${code}]: ${message}` }],
58
+ isError: true,
59
+ _meta: { code, message },
60
+ };
61
+ }
62
+ });
63
+
64
+ const transport = new StdioServerTransport();
65
+ await server.connect(transport);
66
+ console.error('[thinr-mcp] Server started');
67
+ }