@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
package/lib/auth.js CHANGED
@@ -7,7 +7,13 @@ import { writeConfig, readConfig, mergeConfig } from './config.js';
7
7
 
8
8
  import api, { setBaseURL } from './api.js';
9
9
 
10
+ // OAuth device-flow client identifier registered for this CLI on the
11
+ // ThinRemote OAuth provider. Stable across versions.
12
+ const OAUTH_CLIENT_ID = '3b40164d28730e416cbd';
10
13
 
14
+ // Hard ceiling on how long to keep polling for the user to finish the
15
+ // device-code flow in the browser before we give up (10 minutes).
16
+ const OAUTH_DEVICE_CODE_TIMEOUT_MS = 10 * 60 * 1000;
11
17
 
12
18
  /**
13
19
  * Authenticate user
@@ -21,7 +27,7 @@ export async function authenticate() {
21
27
  { name: 'OAuth', value: 'oauth' },
22
28
  { name: 'Token', value: 'token' },
23
29
  { name: 'Username and password', value: 'userpass' },
24
- ]
30
+ ],
25
31
  });
26
32
 
27
33
  // Ask for server URL
@@ -30,7 +36,7 @@ export async function authenticate() {
30
36
  default: 'console.thinr.io',
31
37
  validate: (input) => {
32
38
  return input ? true : 'Server URL is required';
33
- }
39
+ },
34
40
  });
35
41
 
36
42
  setBaseURL(`https://${server}`);
@@ -48,7 +54,6 @@ export async function authenticate() {
48
54
  // Authenticate with token
49
55
  await authenticateWithToken();
50
56
  }
51
-
52
57
  }
53
58
 
54
59
  /**
@@ -56,150 +61,114 @@ export async function authenticate() {
56
61
  * @returns {Promise<void>}
57
62
  */
58
63
  async function authenticateWithUserPass() {
64
+ // Ask for credentials
65
+ const username = await input({
66
+ message: 'Enter username:',
67
+ validate: (input) => (input ? true : 'Username is required'),
68
+ });
69
+ const password = await input({
70
+ message: 'Enter password:',
71
+ type: 'password',
72
+ mask: '*',
73
+ validate: (input) => (input ? true : 'Password is required'),
74
+ });
75
+
76
+ const spinner = ora('Authenticating...').start();
77
+
59
78
  try {
60
- // Ask for credentials
61
- const username = await input({
62
- message: 'Enter username:',
63
- validate: (input) => {
64
- return input ? true : 'Username is required';
65
- }
66
- });
67
- const password = await input({
68
- message: 'Enter password:',
69
- type: 'password',
70
- mask: '*',
71
- validate: (input) => {
72
- return input ? true : 'Password is required';
73
- }
79
+ // OAuth2 password grant
80
+ const formData = new URLSearchParams();
81
+ formData.append('grant_type', 'password');
82
+ formData.append('username', username);
83
+ formData.append('password', password);
84
+
85
+ await api.post(`/oauth/token`, formData, {
86
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
74
87
  });
75
88
 
76
- // Show spinner
77
- const spinner = ora('Authenticating...').start();
78
-
89
+ // Replace any existing token with a fresh permanent one
90
+ spinner.text = 'Preparing user token...';
79
91
  try {
80
- // Prepare OAuth2 password grant form data
81
- const formData = new URLSearchParams();
82
- formData.append('grant_type', 'password');
83
- formData.append('username', username);
84
- formData.append('password', password);
85
-
86
- // Authenticate with server using OAuth2 password flow
87
- const response = await api.post(
88
- `/oauth/token`,
89
- formData,
90
- {
91
- headers: {
92
- 'Content-Type': 'application/x-www-form-urlencoded'
93
- }
94
- }
95
- );
96
-
97
- // Update spinner message
98
- spinner.text = 'Preparing user token...';
99
-
100
- // Delete existing token if it exists
101
- try {
102
- await api.delete(
103
- `/v1/users/${username}/tokens/ThinRemote`,
104
- );
105
- } catch (error) {
106
- // Ignore 404 errors (token doesn't exist)
107
- if (error.response && error.response.status !== 404) {
108
- throw error;
109
- }
110
- }
111
-
112
- // Create new token
113
- spinner.text = 'Creating new user token...';
114
- const createTokenResponse = await api.post(
115
- `/v1/users/${username}/tokens`,
116
- {
117
- "enabled": true,
118
- "allow": {"*": {"*": "*"}},
119
- "deny": {},
120
- "token": "ThinRemote",
121
- "name": "ThinRemote",
122
- "description": ""
123
- },
124
- {
125
- headers: {
126
- 'Content-Type': 'application/json'
127
- }
128
- }
129
- );
130
- const permanentToken = createTokenResponse.data.access_token;
131
-
132
- // Save configuration with permanent token
133
- writeConfig({
134
- username,
135
- server,
136
- token: permanentToken
137
- });
138
-
139
- spinner.succeed('Authentication successful');
92
+ await api.delete(`/v1/users/${username}/tokens/ThinRemote`);
140
93
  } catch (error) {
141
- spinner.fail('Authentication failed');
142
-
143
- if (error.response && error.response.status === 401) {
144
- console.error(chalk.red('Invalid username or password'));
145
- } else if (error.response && error.response.status === 404) {
146
- console.error(chalk.red('Authentication endpoint not found. Please check the server URL.'));
147
- } else {
148
- console.error(chalk.red(`Error: ${error.message}`));
149
- }
150
-
151
- process.exit(1);
94
+ if (error.response && error.response.status !== 404) throw error;
152
95
  }
96
+
97
+ spinner.text = 'Creating new user token...';
98
+ const createTokenResponse = await api.post(
99
+ `/v1/users/${username}/tokens`,
100
+ {
101
+ enabled: true,
102
+ allow: { '*': { '*': '*' } },
103
+ deny: {},
104
+ token: 'ThinRemote',
105
+ name: 'ThinRemote',
106
+ description: '',
107
+ },
108
+ { headers: { 'Content-Type': 'application/json' } },
109
+ );
110
+ const permanentToken = createTokenResponse.data.access_token;
111
+
112
+ // server was already persisted by the outer authenticate(); only
113
+ // username + token need to be merged here.
114
+ mergeConfig({ username, token: permanentToken });
115
+ spinner.succeed('Authentication successful');
153
116
  } catch (error) {
154
- console.error(chalk.red(`Error: ${error.message}`));
155
- process.exit(1);
117
+ spinner.fail('Authentication failed');
118
+ if (error.response && error.response.status === 401) {
119
+ throw new Error('Invalid username or password', { cause: error });
120
+ }
121
+ if (error.response && error.response.status === 404) {
122
+ throw new Error('Authentication endpoint not found. Please check the server URL.', {
123
+ cause: error,
124
+ });
125
+ }
126
+ throw error;
156
127
  }
157
128
  }
158
129
 
130
+ /**
131
+ * Exchange the saved refresh_token for a fresh access_token. Throws a
132
+ * tagged `code: 'unauthorized'` Error when no refresh_token is on
133
+ * record (e.g. logins made with username/password don't get one) or
134
+ * when the OAuth server rejects the refresh — both cases mean the
135
+ * caller has to re-authenticate.
136
+ *
137
+ * @returns {Promise<string>} The new access token.
138
+ */
159
139
  export async function refreshToken() {
160
- try {
161
- const config = readConfig();
162
- if (!config || !config.username || !config.server || !config.refresh_token) {
163
- console.log(chalk.yellow('No configuration found'));
164
- return;
165
- }
166
-
167
- //const spinner = ora('Refreshing user token...').start();
168
-
169
- try {
170
- // Prepare OAuth2 refresh token form data
171
- const formData = new URLSearchParams();
172
- formData.append('grant_type', 'refresh_token');
173
- formData.append('client_id', '3b40164d28730e416cbd');
174
- formData.append('refresh_token', config.refresh_token);
175
-
176
- // Refresh token using OAuth2 refresh flow
177
- const response = await api.post(
178
- `/oauth/token`,
179
- formData,
180
- {
181
- headers: {
182
- 'Content-Type': 'application/x-www-form-urlencoded'
183
- }
184
- }
185
- );
186
-
187
- // Save new token in configuration
188
- mergeConfig({
189
- token: response.data.access_token,
190
- refresh_token: response.data.refresh_token
191
- });
140
+ const config = readConfig();
141
+ if (!config.refresh_token) {
142
+ const err = new Error('No refresh token available; re-authenticate with `thinr`.');
143
+ err.code = 'unauthorized';
144
+ throw err;
145
+ }
192
146
 
193
- //spinner.succeed('Token refreshed successfully');
194
- return response.data.access_token;
147
+ const formData = new URLSearchParams();
148
+ formData.append('grant_type', 'refresh_token');
149
+ formData.append('client_id', OAUTH_CLIENT_ID);
150
+ formData.append('refresh_token', config.refresh_token);
195
151
 
196
- } catch (error) {
197
- //spinner.fail('Failed to refresh token');
198
- console.error(chalk.red(`Error: ${error.message}`));
199
- }
200
- } catch (error) {
201
- console.error(chalk.red(`Error: ${error.message}`));
152
+ let response;
153
+ try {
154
+ // `_isRefresh` flags this request so the response interceptor
155
+ // skips its own refresh-on-401 logic — otherwise a refresh that
156
+ // returned 401 would recurse back here forever.
157
+ response = await api.post('/oauth/token', formData, {
158
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
159
+ _isRefresh: true,
160
+ });
161
+ } catch (_error) {
162
+ const err = new Error('Token refresh failed; re-authenticate with `thinr`.');
163
+ err.code = 'unauthorized';
164
+ throw err;
202
165
  }
166
+
167
+ mergeConfig({
168
+ token: response.data.access_token,
169
+ refresh_token: response.data.refresh_token,
170
+ });
171
+ return response.data.access_token;
203
172
  }
204
173
 
205
174
  /**
@@ -217,9 +186,7 @@ export async function deleteToken() {
217
186
  const spinner = ora('Deleting user token...').start();
218
187
 
219
188
  try {
220
- await api.delete(
221
- `/v1/users/${config.username}/tokens/ThinRemote`,
222
- );
189
+ await api.delete(`/v1/users/${config.username}/tokens/ThinRemote`);
223
190
  spinner.succeed('Token deleted successfully');
224
191
  } catch (error) {
225
192
  if (error.response && error.response.status === 404) {
@@ -239,54 +206,34 @@ export async function deleteToken() {
239
206
  * @returns {Promise<void>}
240
207
  */
241
208
  async function authenticateWithToken() {
209
+ const username = await input({
210
+ message: 'Enter username:',
211
+ validate: (input) => (input ? true : 'Username is required'),
212
+ });
213
+ const token = await input({
214
+ message: 'Enter token:',
215
+ type: 'password',
216
+ mask: '*',
217
+ validate: (input) => (input ? true : 'Token is required'),
218
+ });
219
+
220
+ const spinner = ora('Validating token...').start();
242
221
  try {
243
- // Ask for username and token
244
- const username = await input({
245
- message: 'Enter username:',
246
- validate: (input) => {
247
- return input ? true : 'Username is required';
248
- }
249
- });
250
- const token = await input({
251
- message: 'Enter token:',
252
- type: 'password',
253
- mask: '*',
254
- validate: (input) => {
255
- return input ? true : 'Token is required';
256
- }
222
+ // The api interceptor attaches Authorization from the saved
223
+ // config; at this point we haven't persisted the token yet, so
224
+ // the header has to be passed explicitly for the probe request.
225
+ await api.get(`/v1/proxies`, {
226
+ headers: { Authorization: `Bearer ${token}` },
257
227
  });
258
-
259
- // Show spinner
260
- const spinner = ora('Validating token...').start();
261
-
262
- try {
263
- // Validate token by making a request to the proxies API
264
- await api.get(
265
- `/v1/proxies`,
266
- );
267
-
268
- // Save configuration
269
- mergeConfig({
270
- username,
271
- token
272
- });
273
-
274
- spinner.succeed('Token validation successful');
275
- } catch (error) {
276
- spinner.fail('Token validation failed');
277
-
278
- if (error.response && error.response.status === 401) {
279
- console.error(chalk.red('Invalid token'));
280
- } else {
281
- console.error(chalk.red(`Error: ${error.message}`));
282
- }
283
-
284
- process.exit(1);
285
- }
286
228
  } catch (error) {
287
- console.error(chalk.red(`Error: ${error.message}`));
288
- process.exit(1);
229
+ spinner.fail('Token validation failed');
230
+ if (error.response && error.response.status === 401) {
231
+ throw new Error('Invalid token', { cause: error });
232
+ }
233
+ throw error;
289
234
  }
235
+ mergeConfig({ username, token });
236
+ spinner.succeed('Token validation successful');
290
237
  }
291
238
 
292
239
  /**
@@ -294,159 +241,128 @@ async function authenticateWithToken() {
294
241
  * @returns {Promise<void>}
295
242
  */
296
243
  async function authenticateWithOAuth() {
297
-
298
- const client_id = '3b40164d28730e416cbd';
299
- let device_code = '';
244
+ const client_id = OAUTH_CLIENT_ID;
300
245
  let oauth_tokens = {};
301
246
 
247
+ const spinner = ora('Requesting OAuth User Code...').start();
248
+ let data;
302
249
  try {
250
+ ({ data } = await api.post(
251
+ `/oauth/device/authorize`,
252
+ { client_id },
253
+ { headers: { 'content-type': 'application/x-www-form-urlencoded' } },
254
+ ));
255
+ spinner.succeed('OAuth User Code received');
256
+ } catch (error) {
257
+ spinner.fail('OAuth device-code request failed');
258
+ throw error;
259
+ }
303
260
 
304
- // Show spinner
305
- const spinner = ora('Requesting OAuth User Code...').start();
261
+ console.log('\nYour one time device code is: ' + chalk.bold(data.user_code));
262
+ console.log(
263
+ chalk.blue('Press ' + chalk.bold('ENTER')) +
264
+ ' to open your browser or submit your device code here: ' +
265
+ data.verification_uri +
266
+ '\n',
267
+ );
268
+
269
+ const device_code = data.device_code;
270
+ let pollDeviceIntervalId;
271
+ let interval = 5000;
272
+ let pollError = null; // captured by polling, surfaced after the await
273
+ const controller = new AbortController();
274
+
275
+ function setPollingInterval(ms) {
276
+ if (pollDeviceIntervalId) clearInterval(pollDeviceIntervalId);
277
+ pollDeviceIntervalId = setInterval(pollDevice, ms);
278
+ }
306
279
 
280
+ async function pollDevice() {
307
281
  try {
308
- // Validate OAuth token by making a request to the proxies API
309
- const { data } = await api.post(
310
- `/oauth/device/authorize`,
282
+ const response = await api.post(
283
+ `/oauth/token`,
311
284
  {
312
- client_id: client_id
285
+ grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
286
+ client_id,
287
+ device_code,
313
288
  },
314
- {
315
- headers: {
316
- 'content-type': 'application/x-www-form-urlencoded'
317
- }
318
- }
289
+ { headers: { 'content-type': 'application/x-www-form-urlencoded' } },
319
290
  );
320
-
321
- spinner.succeed('OAuth User Code received');
322
-
323
- console.log('\nYour one time device code is: ' + chalk.bold(data.user_code));
324
-
325
- console.log(chalk.blue('Press ' + chalk.bold('ENTER')) + ' to open your browser or submit your device code here: ' + data.verification_uri + '\n');
326
-
327
- device_code = data.device_code;
328
-
329
- let validated = false;
330
- let pollDeviceIntervalId;
331
- let interval = 5000; // Default polling interval
332
- const controller = new AbortController();
333
-
334
- function setPollingInterval(interval) {
335
- if (pollDeviceIntervalId) {
336
- clearInterval(pollDeviceIntervalId);
337
- }
338
- pollDeviceIntervalId = setInterval(pollDevice, interval);
291
+ if (response.status === 200) {
292
+ oauth_tokens = response.data;
293
+ controller.abort('validated');
339
294
  }
340
-
341
- async function pollDevice () {
342
- api.post(
343
- `/oauth/token`,
344
- {
345
- grant_type: "urn:ietf:params:oauth:grant-type:device_code",
346
- client_id: client_id,
347
- device_code: device_code
348
- },
349
- {
350
- headers: {
351
- 'content-type': 'application/x-www-form-urlencoded'
352
- }
353
- }
354
- )
355
- .then((response) => {
356
- if (response.status === 200) {
357
- console.log(response);
358
- validated = true;
359
- oauth_tokens = response.data;
360
- controller.abort("validated");
361
- }
362
- }).catch((e) => {
363
- if (e.status === 400) {
364
- // Check for specific OAuth error messages
365
- if ( e.response.data.error === 'authorization_pending' ) {
366
- //console.debug("Authorization pending, continuing to poll...");
367
- } else if ( e.response.data.error === 'slow_down' ) {
368
- //console.debug("Received slow_down, increasing polling interval");
369
- // Increase polling interval to avoid hitting rate limits
370
- clearInterval(pollDeviceIntervalId);
371
- interval = 5000+1000;
372
- setPollingInterval(interval); // Increase to 10 seconds
373
- } else if ( e.response.data.error === 'access_denied' ) {
374
- console.error(chalk.red('User denied the authorization request'));
375
- process.exit(1);
376
- } else if ( e.response.data.error === 'expired_token' ) {
377
- console.error(chalk.red('Device code expired'));
378
- process.exit(1);
379
- } else {
380
- console.error(chalk.red(`OAuth error: ${e.response.data.error}`));
381
- }
382
- }
383
- });
384
-
295
+ } catch (e) {
296
+ if (e.response && e.response.status === 400) {
297
+ const err = e.response.data && e.response.data.error;
298
+ if (err === 'authorization_pending') return;
299
+ if (err === 'slow_down') {
300
+ interval += 1000;
301
+ setPollingInterval(interval);
302
+ return;
303
+ }
304
+ if (err === 'access_denied') {
305
+ pollError = new Error('User denied the authorization request');
306
+ controller.abort('error');
307
+ return;
308
+ }
309
+ if (err === 'expired_token') {
310
+ pollError = new Error('Device code expired');
311
+ controller.abort('error');
312
+ return;
313
+ }
314
+ pollError = new Error(`OAuth error: ${err}`);
315
+ controller.abort('error');
385
316
  }
317
+ }
318
+ }
386
319
 
387
- // Poll every 5 seconds
388
- setPollingInterval(interval);
389
- //pollDeviceIntervalId = setInterval(pollDevice, 5000, client_id, data.device_code);
390
-
391
- // Timeout after 10 minutes
392
- setTimeout(() => {
393
- clearInterval(pollDeviceIntervalId)
394
- controller.abort("timeout");
395
- }, 600000);
320
+ setPollingInterval(interval);
396
321
 
322
+ // 10-minute hard ceiling on the user finishing in the browser
323
+ const timeoutId = setTimeout(() => {
324
+ clearInterval(pollDeviceIntervalId);
325
+ controller.abort('timeout');
326
+ }, OAUTH_DEVICE_CODE_TIMEOUT_MS);
397
327
 
398
- // Wait for validation
399
- await input({
328
+ try {
329
+ await input(
330
+ {
400
331
  message: 'Press ENTER to continue or wait for authentication...',
401
332
  waitUserInput: true,
402
- validate: function (input) {
403
- open(data.verification_uri_complete, {app: [{name: apps.browser}, 'firefox-developer-edition']});
333
+ validate: () => {
334
+ open(data.verification_uri_complete, {
335
+ app: [{ name: apps.browser }, 'firefox-developer-edition'],
336
+ });
404
337
  return '';
405
338
  },
406
339
  },
407
340
  {
408
- signal: controller.signal
409
- }).catch((error) => {
410
- if (error.cause === 'timeout') {
411
- console.log(chalk.yellow('Polling aborted due to timeout'));
412
- // Polling was aborted
413
- } else if (error.cause === 'validated') {
414
- console.log(chalk.green('Device authenticated successfully'));
415
- }
416
- });
417
-
418
- clearInterval(pollDeviceIntervalId);
419
-
420
- const token = oauth_tokens.access_token;
421
- const refresh_token = oauth_tokens.refresh_token;
422
- const decodedJWT = JSON.parse(Buffer.from(oauth_tokens.access_token.split('.')[1], 'base64').toString());
423
- const username = decodedJWT.usr;
424
-
425
- // Save configuration
426
- mergeConfig({
427
- username,
428
- token,
429
- refresh_token,
430
- });
431
-
432
- spinner.succeed('OAuth token validation successful\n');
433
- } catch (error) {
434
- spinner.fail('OAuth token validation failed');
435
-
436
- if (error.response && error.response.status === 401) {
437
- console.error(chalk.red('Invalid OAuth token'));
438
- } else {
439
- console.error(chalk.red(`Error: ${error.message}`));
341
+ signal: controller.signal,
342
+ },
343
+ ).catch((error) => {
344
+ if (error.cause === 'timeout') {
345
+ console.log(chalk.yellow('Polling aborted due to timeout'));
346
+ } else if (error.cause === 'validated') {
347
+ console.log(chalk.green('Device authenticated successfully'));
440
348
  }
349
+ // 'error' cause falls through; we surface pollError below.
350
+ });
351
+ } finally {
352
+ clearInterval(pollDeviceIntervalId);
353
+ clearTimeout(timeoutId);
354
+ }
441
355
 
442
- process.exit(1);
443
- }
356
+ if (pollError) throw pollError;
357
+ if (!oauth_tokens.access_token) throw new Error('OAuth flow ended without a token');
444
358
 
445
- } catch (error) {
446
- console.error(chalk.red(`Error: ${error.message}`));
447
- process.exit(1);
448
- }
359
+ const token = oauth_tokens.access_token;
360
+ const refresh_token = oauth_tokens.refresh_token;
361
+ const decodedJWT = JSON.parse(Buffer.from(token.split('.')[1], 'base64').toString());
362
+ const username = decodedJWT.usr;
449
363
 
364
+ mergeConfig({ username, token, refresh_token });
365
+ console.log(chalk.green('OAuth token validation successful\n'));
450
366
  }
451
367
 
452
368
  /**