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

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 +63 -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
@@ -1,198 +1,167 @@
1
- import { configExists } from "../lib/config.js";
2
- import chalk from "chalk";
3
- import ora from "ora";
4
- import { formatDeviceProperty, getDeviceProperty } from "../lib/property.js";
5
- import { executeDeviceResource } from "../lib/resource.js";
6
- import { filterActiveDevices, getDevices } from "../lib/device.js";
1
+ import chalk from 'chalk';
2
+ import { InvalidArgumentError } from 'commander';
3
+ import { configExists } from '../lib/config.js';
4
+ import { formatDeviceProperty, getDeviceProperty } from '../lib/property.js';
5
+ import { callDeviceResource } from '../lib/resource.js';
6
+ import { filterActiveDevices, getDevices } from '../lib/devices.js';
7
+ import {
8
+ setJsonMode,
9
+ isJsonMode,
10
+ printOk,
11
+ printErr,
12
+ createSpinner,
13
+ classifyError,
14
+ } from '../lib/output.js';
15
+
16
+ function ensureConfigured() {
17
+ if (!configExists()) {
18
+ printErr('Not configured. Run thinr without parameters to set up.', {
19
+ code: 'not_configured',
20
+ });
21
+ }
22
+ }
7
23
 
8
- /**
9
- * Register the product command with all product related functionality.
10
- * @param {Command} program - Commander program instance
11
- */
12
- export function productCommand(program) {
24
+ function applyJsonFlag(opts) {
25
+ if (opts.json) setJsonMode(true);
26
+ }
13
27
 
14
- const product = program.command('product').description('Product commands');
28
+ function getGlobalUser(cmd) {
29
+ let root = cmd;
30
+ while (root.parent) root = root.parent;
31
+ return root.opts().user || null;
32
+ }
15
33
 
16
- product
17
- .argument('<productId>', 'ID of the product to manage')
18
- .description('Device commands for a specific product')
19
- .allowUnknownOption(true)
20
- .allowExcessArguments(true)
21
- .action( async (productId, options, command) => {
34
+ function collectInput(value, previous = {}) {
35
+ const idx = value.indexOf('=');
36
+ if (idx === -1) throw new InvalidArgumentError('must be key=value');
37
+ return { ...previous, [value.slice(0, idx)]: value.slice(idx + 1) };
38
+ }
22
39
 
23
- // Get additional arguments manually
24
- const args = command.args.slice(1); // Skip productId
25
- const action = args[0];
26
- const target = args[1] && !args[1].startsWith('-') ? args[1] : null;
40
+ async function fetchProductDevices(productId, group, user) {
41
+ const filter = { productId };
42
+ if (group) filter.asset_group = group;
43
+ return getDevices(filter, user);
44
+ }
27
45
 
28
- // Check if configured
29
- if (!configExists()) {
30
- console.error(chalk.red('Error: Not configured. Run thinr without parameters to set up.'));
31
- process.exit(1);
32
- }
46
+ /**
47
+ * `thinr product <subcommand> <productId> …` — fan-out commands across
48
+ * every device that belongs to a product.
49
+ */
50
+ export function productCommand(program) {
51
+ const product = program
52
+ .command('product')
53
+ .description('Product commands (subcommand-first: thinr product <action> <productId>)');
33
54
 
34
- // If no action provided, show help
35
- if (!action) {
36
- printHelp(productId);
55
+ product
56
+ .command('property <productId> <propertyId>')
57
+ .description('Read a property on every device of the product')
58
+ .option('-j, --json', 'Output as JSON')
59
+ .option('-f, --field <field>', 'Extract a sub-field from each property (dot path)')
60
+ .option('-a, --all', 'Include offline devices (default: only active)')
61
+ .option('-g, --group <group>', 'Filter devices by asset group')
62
+ .action(async (productId, propertyId, opts, cmd) => {
63
+ applyJsonFlag(opts);
64
+ ensureConfigured();
65
+ const user = getGlobalUser(cmd);
66
+ const spinner = createSpinner(`Retrieving devices for product ${productId}...`).start();
67
+ let devices;
68
+ try {
69
+ devices = await fetchProductDevices(productId, opts.group, user);
70
+ spinner.succeed(`Devices for product ${productId} retrieved successfully`);
71
+ } catch (error) {
72
+ spinner.fail(`Failed to retrieve devices for product ${productId}`);
73
+ const { message, code } = classifyError(error);
74
+ printErr(message, { code });
37
75
  return;
38
76
  }
39
-
40
- // Parse options manually from process.argv
41
- const parsedOptions = {
42
- json: false,
43
- inputs: {},
44
- field: "",
45
- allDevices: false,
46
- group: null // Group option for filtering devices
47
- };
48
-
49
- const argv = process.argv;
50
- for (let i = 0; i < argv.length; i++) {
51
- if (argv[i] === '-j' || argv[i] === '--json') {
52
- parsedOptions.json = true;
53
- } else if (argv[i] === '-i' || argv[i] === '--input') {
54
- let inputPair = argv[i + 1].split('=');
55
- parsedOptions.inputs[inputPair[0]] = inputPair[1];
56
- } else if (argv[i] === '-f' || argv[i] === '--field') {
57
- parsedOptions.field = argv[i + 1];
58
- } else if (argv[i] === '-h' || argv[i] === '--help') {
59
- printHelp(productId);
60
- return;
61
- } else if (argv[i] === '-a' || argv[i] === '--all') {
62
- parsedOptions.allDevices = true;
63
- } else if (argv[i] === '-g' || argv[i] === '--group') {
64
- parsedOptions.group = argv[i + 1];
77
+ if (!opts.all) devices = filterActiveDevices(devices);
78
+
79
+ const results = [];
80
+ for (const device of devices) {
81
+ try {
82
+ const property = await getDeviceProperty(device.device, propertyId);
83
+ const value = opts.field
84
+ ? opts.field.split('.').reduce((obj, key) => obj && obj[key], property)
85
+ : property;
86
+ results.push({ device: device.device, ok: true, data: value });
87
+ if (!isJsonMode()) {
88
+ console.log(
89
+ 'Device',
90
+ chalk.blue(device.device),
91
+ 'property',
92
+ chalk.blue(propertyId),
93
+ );
94
+ if (opts.field) console.log(value);
95
+ else console.log(formatDeviceProperty(device.device, propertyId, property));
96
+ }
97
+ } catch (error) {
98
+ const { message, code } = classifyError(error);
99
+ results.push({ device: device.device, ok: false, error: { message, code } });
100
+ if (!isJsonMode()) {
101
+ console.error(
102
+ chalk.red(
103
+ `Error retrieving property ${propertyId} for device ${device.device}: ${message}`,
104
+ ),
105
+ );
106
+ }
65
107
  }
66
-
67
108
  }
109
+ if (isJsonMode()) printOk({ product: productId, property: propertyId, results });
110
+ });
68
111
 
69
- let devices = [];
70
- const spinner = ora(`Retrieving devices for product ${productId}...`).start();
112
+ product
113
+ .command('resource <productId> <resource>')
114
+ .description('Call a resource on every active device of the product')
115
+ .option('-j, --json', 'Output as JSON')
116
+ .option('-f, --field <field>', 'Extract a sub-field from each result (dot path)')
117
+ .option('-g, --group <group>', 'Filter devices by asset group')
118
+ .option('-i, --input <key=value>', 'Resource input (repeatable)', collectInput, {})
119
+ .action(async (productId, resource, opts, cmd) => {
120
+ applyJsonFlag(opts);
121
+ ensureConfigured();
122
+ const user = getGlobalUser(cmd);
123
+ const spinner = createSpinner(`Retrieving devices for product ${productId}...`).start();
124
+ let devices;
71
125
  try {
72
- let filter = {
73
- productId: productId,
74
- };
75
- if (parsedOptions.group) {
76
- filter.asset_group = parsedOptions.group;
77
- }
78
- devices = await getDevices(filter);
126
+ devices = await fetchProductDevices(productId, opts.group, user);
79
127
  spinner.succeed(`Devices for product ${productId} retrieved successfully`);
80
- } catch (e) {
128
+ } catch (error) {
81
129
  spinner.fail(`Failed to retrieve devices for product ${productId}`);
82
- process.exit(1);
130
+ const { message, code } = classifyError(error);
131
+ printErr(message, { code });
132
+ return;
83
133
  }
84
-
85
- switch (action.toLowerCase()) {
86
-
87
- case 'property': {
88
-
89
- //const spinner = ora(`Retrieving property ${target} for devices belonging to the product ${productId}...`).start();
90
-
91
- if ( ! parsedOptions.allDevices ) {
92
- devices = filterActiveDevices(devices);
134
+ devices = filterActiveDevices(devices);
135
+
136
+ const results = [];
137
+ for (const device of devices) {
138
+ try {
139
+ const result = await callDeviceResource(device.device, resource, opts.input);
140
+ const value = opts.field
141
+ ? opts.field.split('.').reduce((obj, key) => obj && obj[key], result)
142
+ : result;
143
+ results.push({ device: device.device, ok: true, data: value });
144
+ if (!isJsonMode()) {
145
+ console.log(
146
+ 'Device',
147
+ chalk.blue(device.device),
148
+ 'resource',
149
+ chalk.blue(resource),
150
+ );
151
+ console.log(value);
93
152
  }
94
-
95
- try {
96
- for (const device of devices) {
97
- try {
98
- const property = await getDeviceProperty(device.device, target);
99
- console.log('Device', chalk.blue(device.device), 'property', chalk.blue(target));
100
-
101
- if (parsedOptions.json) {
102
- if ( parsedOptions.field ) {
103
- console.log(parsedOptions.field.split('.').reduce((obj, key) => obj && obj[key], property));
104
- } else {
105
- console.log(JSON.stringify(property, null, 2));
106
- }
107
- } else {
108
- console.log(formatDeviceProperty(device.device, target, property));
109
- }
110
- } catch (e) {
111
- console.error(chalk.red(`Error retrieving property ${target} for device ${device.device}: ${e.message}`));
112
- }
113
- }
114
- //spinner.succeed(`Property ${target} for devices belonging to product ${productId} found`);
115
- } catch (error) {
116
- spinner.fail(`Property retrieval failed`);
117
- console.error(chalk.red(`Error: ${error.message}`));
118
- process.exit(1);
119
- }
120
- break;
121
- }
122
-
123
- case 'resource': {
124
-
125
- //const spinner = ora(`Executing resource ${target} for devices belonging to the product ${productId}...`).start();
126
-
127
- // Obviously, we can only execute resources on active devices
128
- devices = filterActiveDevices(devices);
129
-
130
- try {
131
- for (const device of devices) {
132
- try {
133
- const result = await executeDeviceResource(device.device, target, parsedOptions.inputs);
134
- console.log('Device', chalk.blue(device.device), 'resource', chalk.blue(target));
135
- if ( parsedOptions.field ) {
136
- console.log(parsedOptions.field.split('.').reduce((obj, key) => obj && obj[key], result));
137
- } else {
138
- console.log(result);
139
- }
140
- } catch (e) {
141
- console.error(chalk.red(`Error retrieving property ${target} for device ${device.device}: ${e.message}`));
142
- }
143
- }
144
- //spinner.succeed(`Resource ${target} executed successfully for devices belonging to product ${productId}`);
145
-
146
-
147
- } catch (error) {
148
- spinner.fail(`Resource execution failed`);
149
- console.error(chalk.red(`Error: ${error.message}`));
150
- process.exit(1);
153
+ } catch (error) {
154
+ const { message, code } = classifyError(error);
155
+ results.push({ device: device.device, ok: false, error: { message, code } });
156
+ if (!isJsonMode()) {
157
+ console.error(
158
+ chalk.red(
159
+ `Error executing resource ${resource} for device ${device.device}: ${message}`,
160
+ ),
161
+ );
151
162
  }
152
-
153
- break;
154
-
155
163
  }
156
-
157
- default:
158
- console.error(chalk.red(`Error: Unknown action '${action}'. Available actions: property, resource.\n`));
159
- printHelp(productId);
160
- process.exit(1);
161
-
162
164
  }
165
+ if (isJsonMode()) printOk({ product: productId, resource, results });
163
166
  });
164
167
  }
165
-
166
- function printHelp(productId) {
167
- console.log(`Usage: thinr device product ${productId} <command> [options]
168
-
169
- Available commands for device product "${productId}":
170
-
171
- property [propertyId] Get a specific property of all devices belonging to the product
172
- Example: thinr device product ${productId} property uptime
173
-
174
- resource [resource] Execute a specific resource of all devices belonging to the product
175
- Example: thinr device product ${productId} resource reboot
176
-
177
- Arguments:
178
-
179
- propertyId ID of the property to retrieve (for property command)
180
-
181
- resource Name of the resource to execute (for resource command)
182
-
183
-
184
- Options (for all product subcommands):
185
- -g, --group <group> Group of devices to filter (e.g., -g group1)
186
-
187
- Options (for property command):
188
- -j, --json Output as JSON
189
- -f, --field <field> Field to extract from the property (e.g., -f data.value)
190
- -a, --all Include non active devices (default: false)
191
-
192
- Options (for resource command):
193
- -j, --json Output as JSON
194
- -f, --field <field> Field to extract from the property (e.g., -f data.value)
195
-
196
- Use "thinr product device ${productId} <command> --help" for more details on each command.`);
197
-
198
- }
@@ -0,0 +1,105 @@
1
+ import chalk from 'chalk';
2
+ import {
3
+ listProfiles,
4
+ getActiveProfile,
5
+ getProfile,
6
+ setDefaultProfile,
7
+ deleteProfile,
8
+ } from '../lib/config.js';
9
+ import { setJsonMode, isJsonMode, printOk, printErr } from '../lib/output.js';
10
+
11
+ function applyJsonFlag(opts) {
12
+ if (opts.json) setJsonMode(true);
13
+ }
14
+
15
+ export function profileCommand(program) {
16
+ const profile = program.command('profile').description('Manage configuration profiles');
17
+
18
+ profile
19
+ .command('list')
20
+ .description('List configured profiles')
21
+ .option('-j, --json', 'Output as JSON')
22
+ .action((opts) => {
23
+ applyJsonFlag(opts);
24
+ const names = listProfiles();
25
+ const active = getActiveProfile();
26
+ if (isJsonMode()) {
27
+ const entries = names.map((name) => {
28
+ const data = getProfile(name) || {};
29
+ return {
30
+ name,
31
+ active: name === active,
32
+ username: data.username || null,
33
+ server: data.server || null,
34
+ };
35
+ });
36
+ printOk({ active, profiles: entries });
37
+ return;
38
+ }
39
+ if (!names.length) {
40
+ console.log(chalk.gray('No profiles configured. Run thinr to set one up.'));
41
+ return;
42
+ }
43
+ for (const name of names) {
44
+ const data = getProfile(name) || {};
45
+ const marker = name === active ? chalk.green('* ') : ' ';
46
+ const detail = chalk.gray(`${data.username || ''}@${data.server || name}`);
47
+ console.log(`${marker}${chalk.bold(name)} ${detail}`);
48
+ }
49
+ });
50
+
51
+ profile
52
+ .command('current')
53
+ .description('Print the active profile')
54
+ .option('-j, --json', 'Output as JSON')
55
+ .action((opts) => {
56
+ applyJsonFlag(opts);
57
+ const name = getActiveProfile();
58
+ if (isJsonMode()) {
59
+ printOk({ active: name });
60
+ return;
61
+ }
62
+ if (!name) {
63
+ console.log(chalk.gray('No active profile.'));
64
+ return;
65
+ }
66
+ console.log(name);
67
+ });
68
+
69
+ profile
70
+ .command('use <name>')
71
+ .description('Set the default profile')
72
+ .option('-j, --json', 'Output as JSON')
73
+ .action((name, opts) => {
74
+ applyJsonFlag(opts);
75
+ try {
76
+ setDefaultProfile(name);
77
+ } catch (error) {
78
+ printErr(error.message, { code: 'not_found' });
79
+ return;
80
+ }
81
+ if (isJsonMode()) {
82
+ printOk({ active: name });
83
+ return;
84
+ }
85
+ console.log(chalk.green(`Default profile set to ${name}`));
86
+ });
87
+
88
+ profile
89
+ .command('delete <name>')
90
+ .description('Remove a profile')
91
+ .option('-j, --json', 'Output as JSON')
92
+ .action((name, opts) => {
93
+ applyJsonFlag(opts);
94
+ const ok = deleteProfile(name);
95
+ if (!ok) {
96
+ printErr(`Profile not found: ${name}`, { code: 'not_found' });
97
+ return;
98
+ }
99
+ if (isJsonMode()) {
100
+ printOk({ removed: name });
101
+ return;
102
+ }
103
+ console.log(chalk.green(`Removed profile ${name}`));
104
+ });
105
+ }
package/lib/api.js CHANGED
@@ -1,10 +1,15 @@
1
1
  import axios from 'axios';
2
- import { refreshToken, getAccessToken } from './auth.js'; // Adjust the import based on your project structure
2
+ import { refreshToken, getAccessToken } from './auth.js';
3
+ import { readConfig } from './config.js';
3
4
  import http from 'http';
4
5
  import https from 'https';
5
6
 
6
7
  const httpAgent = new http.Agent({ keepAlive: true });
8
+ // Accept self-signed certificates when talking to local dev servers
9
+ // (localhost / 127.0.0.1). Any other host still enforces full TLS
10
+ // validation. Toggleable via THINR_INSECURE=1 for other local setups.
7
11
  const httpsAgent = new https.Agent({ keepAlive: true });
12
+ const insecureAgent = new https.Agent({ keepAlive: true, rejectUnauthorized: false });
8
13
 
9
14
  const api = axios.create({
10
15
  // You can leave baseURL undefined initially
@@ -17,44 +22,62 @@ export function setBaseURL(url) {
17
22
  api.defaults.baseURL = url;
18
23
  }
19
24
 
20
- api.interceptors.request.use(request => {
25
+ api.interceptors.request.use(
26
+ (request) => {
27
+ const token = getAccessToken();
28
+ if (token) {
29
+ request.headers['Authorization'] = `Bearer ${token}`;
30
+ }
21
31
 
22
- const token = getAccessToken();
23
- if (token) {
24
- request.headers['Authorization'] = `Bearer ${token}`;
25
- }
26
- return request;
27
- }, error => {
28
- return Promise.reject(error);
29
- });
32
+ // Allow self-signed TLS against localhost / 127.0.0.1 or when the user
33
+ // opts in explicitly. Anything else keeps default certificate validation.
34
+ try {
35
+ const base = request.baseURL || api.defaults.baseURL || '';
36
+ const url = new URL(request.url || '', base || 'https://placeholder/');
37
+ const isLocal = url.hostname === 'localhost' || url.hostname === '127.0.0.1';
38
+ if (url.protocol === 'https:' && (isLocal || process.env.THINR_INSECURE === '1')) {
39
+ request.httpsAgent = insecureAgent;
40
+ }
41
+ } catch {}
42
+
43
+ return request;
44
+ },
45
+ (error) => {
46
+ return Promise.reject(error);
47
+ },
48
+ );
30
49
 
31
50
  api.interceptors.response.use(
32
- response => response,
33
- async error => {
51
+ (response) => response,
52
+ async (error) => {
34
53
  const originalRequest = error.config;
35
54
 
36
- // Only handle 401 once
37
- if (error.response?.status === 401 && !originalRequest._retry) {
38
- originalRequest._retry = true;
55
+ // Only intervene on 401s; everything else surfaces unchanged.
56
+ if (error.response?.status !== 401) return Promise.reject(error);
39
57
 
40
- try {
41
- const newToken = await refreshToken();
58
+ // Don't recurse: refresh requests themselves and already-retried
59
+ // requests must not trigger another refresh.
60
+ if (originalRequest?._isRefresh || originalRequest?._retry) {
61
+ return Promise.reject(error);
62
+ }
42
63
 
43
- // Set new token on headers
44
- api.defaults.headers.common['Authorization'] = `Bearer ${newToken}`;
45
- originalRequest.headers['Authorization'] = `Bearer ${newToken}`;
64
+ // Tokens issued via the username/password flow have no
65
+ // refresh_token. Skip the refresh attempt and let the 401 reach
66
+ // the caller — they can re-run `thinr` to re-authenticate.
67
+ if (!readConfig().refresh_token) return Promise.reject(error);
46
68
 
47
- // return the retried request
48
- return api(originalRequest);
49
- } catch (refreshError) {
50
- // Token refresh failed, reject to trigger outer catch
51
- return Promise.reject(refreshError);
52
- }
53
- }
69
+ originalRequest._retry = true;
54
70
 
55
- // Any other errors
56
- return Promise.reject(error);
57
- }
71
+ try {
72
+ const newToken = await refreshToken();
73
+ // Patch only this request; subsequent requests pick up the new
74
+ // token from config via the request interceptor.
75
+ originalRequest.headers['Authorization'] = `Bearer ${newToken}`;
76
+ return api(originalRequest);
77
+ } catch (refreshError) {
78
+ return Promise.reject(refreshError);
79
+ }
80
+ },
58
81
  );
59
82
 
60
- export default api;
83
+ export default api;