@thinremote/thinr-cli 1.3.5 → 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 +77 -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 -282
  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
package/lib/auth.js DELETED
@@ -1,428 +0,0 @@
1
- import { Buffer } from 'node:buffer';
2
- import { input, select } from '@inquirer/prompts';
3
- import ora from 'ora';
4
- import open, { apps } from 'open';
5
- import { writeConfig, readConfig, mergeConfig } from './config.js';
6
- import { label, success, warning, error as errorStyle, info } from './format.js';
7
-
8
- import api, { setBaseURL } from './api.js';
9
- import { OAUTH, TIMEOUTS } from './constants.js';
10
- import { v1 } from './paths.js';
11
-
12
- const OAUTH_CLIENT_ID = OAUTH.CLIENT_ID;
13
- const OAUTH_DEVICE_CODE_TIMEOUT_MS = TIMEOUTS.OAUTH_DEVICE_CODE_MS;
14
-
15
- /**
16
- * Authenticate user
17
- * @returns {Promise<void>}
18
- */
19
- export async function authenticate() {
20
- // When re-authenticating an existing profile, default the server prompt
21
- // to the one already stored so `thinr login` is a couple of keystrokes.
22
- const existing = readConfig();
23
-
24
- // Ask for authentication method
25
- const method = await select({
26
- message: 'How would you like to authenticate?',
27
- choices: [
28
- { name: 'OAuth', value: 'oauth' },
29
- { name: 'Token', value: 'token' },
30
- { name: 'Username and password', value: 'userpass' },
31
- ],
32
- });
33
-
34
- // Ask for server URL
35
- const server = await input({
36
- message: 'Enter server URL:',
37
- default: existing?.server || 'console.thinr.io',
38
- validate: (input) => {
39
- return input ? true : 'Server URL is required';
40
- },
41
- });
42
-
43
- setBaseURL(`https://${server}`);
44
-
45
- writeConfig({
46
- server,
47
- });
48
-
49
- if (method === 'userpass') {
50
- // Authenticate with username and password
51
- await authenticateWithUserPass();
52
- } else if (method === 'oauth') {
53
- await authenticateWithOAuth();
54
- } else {
55
- // Authenticate with token
56
- await authenticateWithToken();
57
- }
58
- }
59
-
60
- /**
61
- * Authenticate with username and password using OAuth2 password flow
62
- * @returns {Promise<void>}
63
- */
64
- async function authenticateWithUserPass() {
65
- // Ask for credentials
66
- const username = await input({
67
- message: 'Enter username:',
68
- validate: (input) => (input ? true : 'Username is required'),
69
- });
70
- const password = await input({
71
- message: 'Enter password:',
72
- type: 'password',
73
- mask: '*',
74
- validate: (input) => (input ? true : 'Password is required'),
75
- });
76
-
77
- const spinner = ora('Authenticating...').start();
78
-
79
- try {
80
- // OAuth2 password grant
81
- const formData = new URLSearchParams();
82
- formData.append('grant_type', 'password');
83
- formData.append('username', username);
84
- formData.append('password', password);
85
-
86
- await api.post(`/oauth/token`, formData, {
87
- headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
88
- });
89
-
90
- // Replace any existing token with a fresh permanent one
91
- spinner.text = 'Preparing user token...';
92
- try {
93
- await api.delete(`${v1(username)}/tokens/ThinRemote`);
94
- } catch (error) {
95
- if (error.response && error.response.status !== 404) throw error;
96
- }
97
-
98
- spinner.text = 'Creating new user token...';
99
- const createTokenResponse = await api.post(
100
- `${v1(username)}/tokens`,
101
- {
102
- enabled: true,
103
- allow: { '*': { '*': '*' } },
104
- deny: {},
105
- token: 'ThinRemote',
106
- name: 'ThinRemote',
107
- description: '',
108
- },
109
- { headers: { 'Content-Type': 'application/json' } },
110
- );
111
- const permanentToken = createTokenResponse.data.access_token;
112
-
113
- // server was already persisted by the outer authenticate(); only
114
- // username + token need to be merged here.
115
- mergeConfig({ username, token: permanentToken });
116
- spinner.succeed('Authentication successful');
117
- } catch (error) {
118
- spinner.fail('Authentication failed');
119
- if (error.response && error.response.status === 401) {
120
- throw new Error('Invalid username or password', { cause: error });
121
- }
122
- if (error.response && error.response.status === 404) {
123
- throw new Error('Authentication endpoint not found. Please check the server URL.', {
124
- cause: error,
125
- });
126
- }
127
- throw error;
128
- }
129
- }
130
-
131
- /**
132
- * Exchange the saved refresh_token for a fresh access_token. Throws a
133
- * tagged `code: 'unauthorized'` Error when no refresh_token is on
134
- * record (e.g. logins made with username/password don't get one) or
135
- * when the OAuth server rejects the refresh — both cases mean the
136
- * caller has to re-authenticate.
137
- *
138
- * Concurrent callers share a single in-flight refresh: refresh tokens
139
- * are rotated on use, so issuing two refreshes in parallel makes the
140
- * second one fail against an already-consumed token. The shared
141
- * promise lets every awaiter see the same result and lets the next
142
- * caller (after completion) start a new refresh against the freshly
143
- * persisted token.
144
- *
145
- * @returns {Promise<string>} The new access token.
146
- */
147
- let inFlightRefresh = null;
148
-
149
- export async function refreshToken() {
150
- if (inFlightRefresh) return inFlightRefresh;
151
- inFlightRefresh = doRefresh().finally(() => {
152
- inFlightRefresh = null;
153
- });
154
- return inFlightRefresh;
155
- }
156
-
157
- async function doRefresh() {
158
- const config = readConfig();
159
- if (!config.refresh_token) {
160
- const err = new Error('Session expired. Run `thinr login` to sign in again.');
161
- err.code = 'unauthorized';
162
- throw err;
163
- }
164
-
165
- const formData = new URLSearchParams();
166
- formData.append('grant_type', 'refresh_token');
167
- formData.append('client_id', OAUTH_CLIENT_ID);
168
- formData.append('refresh_token', config.refresh_token);
169
-
170
- let response;
171
- try {
172
- // `_isRefresh` flags this request so the response interceptor
173
- // skips its own refresh-on-401 logic — otherwise a refresh that
174
- // returned 401 would recurse back here forever.
175
- response = await api.post('/oauth/token', formData, {
176
- headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
177
- _isRefresh: true,
178
- });
179
- } catch (_error) {
180
- const err = new Error('Session expired. Run `thinr login` to sign in again.');
181
- err.code = 'unauthorized';
182
- throw err;
183
- }
184
-
185
- mergeConfig({
186
- token: response.data.access_token,
187
- refresh_token: response.data.refresh_token,
188
- });
189
- return response.data.access_token;
190
- }
191
-
192
- /**
193
- * Milliseconds-since-epoch expiry of a JWT access token, or null when
194
- * the token is not a decodable JWT (permanent API tokens have no exp).
195
- */
196
- function jwtExpiryMs(token) {
197
- try {
198
- const payload = JSON.parse(Buffer.from(token.split('.')[1], 'base64').toString());
199
- return typeof payload.exp === 'number' ? payload.exp * 1000 : null;
200
- } catch {
201
- return null;
202
- }
203
- }
204
-
205
- /**
206
- * Return a currently-valid access token, refreshing first when the
207
- * stored one is an expired (or about-to-expire) JWT. HTTP requests
208
- * recover from expiry through the api.js response interceptor, but
209
- * WebSocket handshakes have no retry layer: callers opening a WS use
210
- * this to start from a valid token. Tokens without a decodable `exp`
211
- * and profiles without a refresh_token pass through untouched.
212
- */
213
- export async function ensureFreshToken() {
214
- const config = readConfig();
215
- if (!config?.token || !config.refresh_token) return config?.token ?? null;
216
- const expiresAt = jwtExpiryMs(config.token);
217
- if (expiresAt === null || expiresAt - Date.now() > TIMEOUTS.TOKEN_EXPIRY_SLACK_MS) {
218
- return config.token;
219
- }
220
- return refreshToken();
221
- }
222
-
223
- /**
224
- * Delete user token from server
225
- * @returns {Promise<void>}
226
- */
227
- export async function deleteToken() {
228
- try {
229
- const config = readConfig();
230
- if (!config || !config.username || !config.server || !config.token) {
231
- console.log(warning('No configuration found'));
232
- return;
233
- }
234
-
235
- const spinner = ora('Deleting user token...').start();
236
-
237
- try {
238
- await api.delete(`${v1(config.username)}/tokens/ThinRemote`);
239
- spinner.succeed('Token deleted successfully');
240
- } catch (error) {
241
- if (error.response && error.response.status === 404) {
242
- spinner.succeed('Token was already deleted');
243
- } else {
244
- spinner.fail('Failed to delete token');
245
- console.error(errorStyle(`Error: ${error.message}`));
246
- }
247
- }
248
- } catch (error) {
249
- console.error(errorStyle(`Error: ${error.message}`));
250
- }
251
- }
252
-
253
- /**
254
- * Authenticate with token
255
- * @returns {Promise<void>}
256
- */
257
- async function authenticateWithToken() {
258
- const username = await input({
259
- message: 'Enter username:',
260
- validate: (input) => (input ? true : 'Username is required'),
261
- });
262
- const token = await input({
263
- message: 'Enter token:',
264
- type: 'password',
265
- mask: '*',
266
- validate: (input) => (input ? true : 'Token is required'),
267
- });
268
-
269
- const spinner = ora('Validating token...').start();
270
- try {
271
- // The api interceptor attaches Authorization from the saved
272
- // config; at this point we haven't persisted the token yet, so
273
- // the header has to be passed explicitly for the probe request.
274
- await api.get(`/v1/proxies`, {
275
- headers: { Authorization: `Bearer ${token}` },
276
- });
277
- } catch (error) {
278
- spinner.fail('Token validation failed');
279
- if (error.response && error.response.status === 401) {
280
- throw new Error('Invalid token', { cause: error });
281
- }
282
- throw error;
283
- }
284
- mergeConfig({ username, token });
285
- spinner.succeed('Token validation successful');
286
- }
287
-
288
- /**
289
- * Authenticate with OAuth token
290
- * @returns {Promise<void>}
291
- */
292
- async function authenticateWithOAuth() {
293
- const client_id = OAUTH_CLIENT_ID;
294
- let oauth_tokens = {};
295
-
296
- const spinner = ora('Requesting OAuth User Code...').start();
297
- let data;
298
- try {
299
- ({ data } = await api.post(
300
- `/oauth/device/authorize`,
301
- { client_id },
302
- { headers: { 'content-type': 'application/x-www-form-urlencoded' } },
303
- ));
304
- spinner.succeed('OAuth User Code received');
305
- } catch (error) {
306
- spinner.fail('OAuth device-code request failed');
307
- throw error;
308
- }
309
-
310
- console.log('\nYour one time device code is: ' + label(data.user_code));
311
- console.log(
312
- info('Press ' + label('ENTER')) +
313
- ' to open your browser or submit your device code here: ' +
314
- data.verification_uri +
315
- '\n',
316
- );
317
-
318
- const device_code = data.device_code;
319
- let pollDeviceIntervalId;
320
- let interval = 5000;
321
- let pollError = null; // captured by polling, surfaced after the await
322
- const controller = new AbortController();
323
-
324
- function setPollingInterval(ms) {
325
- if (pollDeviceIntervalId) clearInterval(pollDeviceIntervalId);
326
- pollDeviceIntervalId = setInterval(pollDevice, ms);
327
- }
328
-
329
- async function pollDevice() {
330
- try {
331
- const response = await api.post(
332
- `/oauth/token`,
333
- {
334
- grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
335
- client_id,
336
- device_code,
337
- },
338
- { headers: { 'content-type': 'application/x-www-form-urlencoded' } },
339
- );
340
- if (response.status === 200) {
341
- oauth_tokens = response.data;
342
- controller.abort('validated');
343
- }
344
- } catch (e) {
345
- if (e.response && e.response.status === 400) {
346
- const err = e.response.data && e.response.data.error;
347
- if (err === 'authorization_pending') return;
348
- if (err === 'slow_down') {
349
- interval += 1000;
350
- setPollingInterval(interval);
351
- return;
352
- }
353
- if (err === 'access_denied') {
354
- pollError = new Error('User denied the authorization request');
355
- controller.abort('error');
356
- return;
357
- }
358
- if (err === 'expired_token') {
359
- pollError = new Error('Device code expired');
360
- controller.abort('error');
361
- return;
362
- }
363
- pollError = new Error(`OAuth error: ${err}`);
364
- controller.abort('error');
365
- }
366
- }
367
- }
368
-
369
- setPollingInterval(interval);
370
-
371
- // 10-minute hard ceiling on the user finishing in the browser
372
- const timeoutId = setTimeout(() => {
373
- clearInterval(pollDeviceIntervalId);
374
- controller.abort('timeout');
375
- }, OAUTH_DEVICE_CODE_TIMEOUT_MS);
376
-
377
- try {
378
- await input(
379
- {
380
- message: 'Press ENTER to continue or wait for authentication...',
381
- waitUserInput: true,
382
- validate: () => {
383
- open(data.verification_uri_complete, {
384
- app: [{ name: apps.browser }, 'firefox-developer-edition'],
385
- });
386
- return '';
387
- },
388
- },
389
- {
390
- signal: controller.signal,
391
- },
392
- ).catch((error) => {
393
- if (error.cause === 'timeout') {
394
- console.log(warning('Polling aborted due to timeout'));
395
- } else if (error.cause === 'validated') {
396
- console.log(success('Device authenticated successfully'));
397
- }
398
- // 'error' cause falls through; we surface pollError below.
399
- });
400
- } finally {
401
- clearInterval(pollDeviceIntervalId);
402
- clearTimeout(timeoutId);
403
- }
404
-
405
- if (pollError) throw pollError;
406
- if (!oauth_tokens.access_token) throw new Error('OAuth flow ended without a token');
407
-
408
- const token = oauth_tokens.access_token;
409
- const refresh_token = oauth_tokens.refresh_token;
410
- const decodedJWT = JSON.parse(Buffer.from(token.split('.')[1], 'base64').toString());
411
- const username = decodedJWT.usr;
412
-
413
- mergeConfig({ username, token, refresh_token });
414
- console.log(success('OAuth token validation successful\n'));
415
- }
416
-
417
- /**
418
- * Get access token from configuration
419
- * @returns {string|axios.CancelToken|*|null}
420
- */
421
- export function getAccessToken() {
422
- const config = readConfig();
423
- if (!config || !config.token) {
424
- console.log(warning('No token found in configuration'));
425
- return null;
426
- }
427
- return config.token;
428
- }
package/lib/bucket.js DELETED
@@ -1,82 +0,0 @@
1
- import api from './api.js';
2
- import { apiError } from './errors.js';
3
- import { v2, v3 } from './paths.js';
4
-
5
- /**
6
- * Query an arbitrary time-series bucket declared in a product profile.
7
- * When `device` is provided, scopes to that device's bucket; otherwise
8
- * queries the user-level bucket across every device of the account.
9
- *
10
- * Buckets are the mechanism products use to persist structured samples
11
- * over time — the platform ships a `monitoring` bucket by default, and
12
- * users can declare custom ones for their own metrics.
13
- *
14
- * Time range:
15
- * - `minutes`: shortcut for "last N minutes" (sets min_ts to
16
- * `Date.now() - minutes * 60_000`, max_ts to 0).
17
- * - `min_ts` / `max_ts`: explicit unix-millis range when `minutes`
18
- * is not supplied; either may be omitted for an open bound.
19
- *
20
- * Aggregation: `agg` is the time-window size (e.g. "5m"), `agg_type`
21
- * the reducer (e.g. "mean"), and `fields` a comma-separated allowlist
22
- * to trim the response down.
23
- *
24
- * `group_by` is passed through verbatim — no default. Custom buckets
25
- * may not even expose a `device` tag, so the caller decides whether
26
- * to group (e.g. `group_by=device` for a fleet view) or read raw
27
- * interleaved samples.
28
- *
29
- * @param {{
30
- * bucket: string,
31
- * device?: string | null,
32
- * user?: string | null,
33
- * items?: number,
34
- * sort?: string,
35
- * minutes?: number,
36
- * min_ts?: number,
37
- * max_ts?: number,
38
- * agg?: string,
39
- * agg_type?: string,
40
- * fields?: string,
41
- * group_by?: string | null,
42
- * }} opts
43
- */
44
- export async function getBucketData({
45
- bucket,
46
- device = null,
47
- user = null,
48
- items = 10,
49
- sort = 'desc',
50
- minutes,
51
- min_ts,
52
- max_ts,
53
- agg,
54
- agg_type,
55
- fields,
56
- group_by,
57
- } = {}) {
58
- if (!bucket) throw new Error('bucket is required');
59
- const params = { items, sort };
60
- if (minutes) {
61
- params.min_ts = Date.now() - minutes * 60 * 1000;
62
- params.max_ts = 0;
63
- } else {
64
- if (min_ts) params.min_ts = min_ts;
65
- if (max_ts !== undefined) params.max_ts = max_ts;
66
- }
67
- if (agg) params.agg = agg;
68
- if (agg_type) params.agg_type = agg_type;
69
- if (group_by) params.group_by = group_by;
70
- if (fields) params.fields = fields;
71
-
72
- const url = device
73
- ? `${v3(user)}/devices/${device}/buckets/${bucket}/data`
74
- : `${v2(user)}/buckets/${bucket}/data`;
75
-
76
- try {
77
- const res = await api.get(url, { params });
78
- return res.data;
79
- } catch (e) {
80
- throw apiError(e);
81
- }
82
- }
@@ -1,52 +0,0 @@
1
- // @ts-check
2
-
3
- /**
4
- * Run `worker(item, index)` over `items` with at most `limit` invocations
5
- * in flight at any time. Returns an array parallel to `items` describing
6
- * the outcome of each task as `{ ok: true, value }` or `{ ok: false, error }`.
7
- *
8
- * By default, a rejection from one worker does not stop the others — the
9
- * caller inspects the results array. Set `failFast: true` to stop dequeueing
10
- * new work as soon as any worker rejects; in-flight tasks still finish and
11
- * their results are included. The first rejection is rethrown after the
12
- * in-flight tasks settle.
13
- *
14
- * @template T, R
15
- * @param {T[]} items
16
- * @param {number} limit
17
- * @param {(item: T, index: number) => Promise<R>} worker
18
- * @param {{ failFast?: boolean }} [opts]
19
- * @returns {Promise<Array<{ ok: true, value: R } | { ok: false, error: unknown }>>}
20
- */
21
- export async function runPool(items, limit, worker, { failFast = false } = {}) {
22
- const results = new Array(items.length);
23
- let nextIndex = 0;
24
- let aborted = false;
25
- /** @type {unknown} */
26
- let abortError = null;
27
-
28
- const effectiveLimit = Math.max(1, Math.min(limit, items.length));
29
-
30
- async function runner() {
31
- while (!aborted) {
32
- const i = nextIndex++;
33
- if (i >= items.length) return;
34
- try {
35
- results[i] = { ok: true, value: await worker(items[i], i) };
36
- } catch (error) {
37
- results[i] = { ok: false, error };
38
- if (failFast && !aborted) {
39
- aborted = true;
40
- abortError = error;
41
- }
42
- }
43
- }
44
- }
45
-
46
- const runners = [];
47
- for (let i = 0; i < effectiveLimit; i++) runners.push(runner());
48
- await Promise.all(runners);
49
-
50
- if (aborted && abortError) throw abortError;
51
- return results;
52
- }