@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/config.js CHANGED
@@ -3,14 +3,39 @@ import path from 'path';
3
3
  import os from 'os';
4
4
 
5
5
  /**
6
- * Get the config file path
7
- * @returns {string} Config file path
6
+ * Profile-based configuration store.
7
+ *
8
+ * On disk the config file looks like:
9
+ * {
10
+ * "default": "<profile-name>",
11
+ * "profiles": {
12
+ * "<profile-name>": { server, username, token, refresh_token, ... }
13
+ * }
14
+ * }
15
+ *
16
+ * Profile names default to the server hostname so login against a new
17
+ * server automatically creates an isolated profile.
18
+ *
19
+ * Legacy single-profile files (top-level server/username/token) are
20
+ * detected on read and migrated to the new layout transparently. The
21
+ * migration uses the legacy `server` field as the profile name.
22
+ *
23
+ * The "active" profile is the one all helpers operate on. It is picked
24
+ * (in order): explicit override via setActiveProfile(), the THINR_PROFILE
25
+ * environment variable, the file's `default` field.
8
26
  */
9
- export function getConfigPath() {
27
+
28
+ let activeProfileOverride = null;
29
+
30
+ /**
31
+ * Resolve the on-disk config path, creating the parent directory on
32
+ * first access. Internal — every consumer goes through readStore /
33
+ * writeStore so they never have to know the location.
34
+ */
35
+ function getConfigPath() {
10
36
  const homeDir = os.homedir();
11
37
  const configDir = path.join(homeDir, '.config', 'thinr-cli');
12
38
 
13
- // Create config directory if it doesn't exist
14
39
  if (!fs.existsSync(configDir)) {
15
40
  fs.mkdirSync(configDir, { recursive: true });
16
41
  }
@@ -18,71 +43,177 @@ export function getConfigPath() {
18
43
  return path.join(configDir, 'config.json');
19
44
  }
20
45
 
46
+ function emptyStore() {
47
+ return { default: null, profiles: {} };
48
+ }
49
+
50
+ function readStore() {
51
+ const configPath = getConfigPath();
52
+ if (!fs.existsSync(configPath)) return emptyStore();
53
+ let raw;
54
+ try {
55
+ raw = JSON.parse(fs.readFileSync(configPath, 'utf8'));
56
+ } catch {
57
+ return emptyStore();
58
+ }
59
+
60
+ // Legacy format: top-level {server, username, token, ...}. Migrate to
61
+ // a profile keyed by the server hostname.
62
+ if (raw && typeof raw === 'object' && !raw.profiles && raw.server) {
63
+ const name = String(raw.server);
64
+ return { default: name, profiles: { [name]: { ...raw } } };
65
+ }
66
+
67
+ if (!raw || typeof raw !== 'object' || !raw.profiles) return emptyStore();
68
+ return raw;
69
+ }
70
+
71
+ function writeStore(store) {
72
+ const configPath = getConfigPath();
73
+ fs.writeFileSync(configPath, JSON.stringify(store, null, 2), 'utf8');
74
+ }
75
+
21
76
  /**
22
- * Check if configuration exists
23
- * @returns {boolean} True if configuration exists
77
+ * Override the active profile for the current process. Pass null to fall
78
+ * back to env var / file default. Used by the CLI entry point when the
79
+ * user passes --profile.
24
80
  */
25
- export function configExists() {
26
- const configPath = getConfigPath();
27
- return fs.existsSync(configPath);
81
+ export function setActiveProfile(name) {
82
+ activeProfileOverride = name || null;
28
83
  }
29
84
 
30
85
  /**
31
- * Read configuration
32
- * @returns {Object} Configuration object
86
+ * Resolve the active profile name, or null if no profile exists.
33
87
  */
34
- export function readConfig() {
35
- const configPath = getConfigPath();
88
+ export function getActiveProfile() {
89
+ if (activeProfileOverride) return activeProfileOverride;
90
+ if (process.env.THINR_PROFILE) return process.env.THINR_PROFILE;
91
+ const store = readStore();
92
+ if (store.default && store.profiles[store.default]) return store.default;
93
+ // Fall back to the only profile if there's exactly one.
94
+ const names = Object.keys(store.profiles);
95
+ return names.length === 1 ? names[0] : null;
96
+ }
97
+
98
+ /**
99
+ * List all profile names.
100
+ */
101
+ export function listProfiles() {
102
+ return Object.keys(readStore().profiles);
103
+ }
104
+
105
+ /**
106
+ * Get a specific profile by name (or undefined if missing).
107
+ */
108
+ export function getProfile(name) {
109
+ const store = readStore();
110
+ return store.profiles[name];
111
+ }
36
112
 
37
- if (!fs.existsSync(configPath)) {
38
- return {};
113
+ /**
114
+ * Set the persisted default profile.
115
+ */
116
+ export function setDefaultProfile(name) {
117
+ const store = readStore();
118
+ if (!store.profiles[name]) {
119
+ throw new Error(`Profile not found: ${name}`);
39
120
  }
121
+ store.default = name;
122
+ writeStore(store);
123
+ }
40
124
 
41
- try {
42
- const configData = fs.readFileSync(configPath, 'utf8');
43
- return JSON.parse(configData);
44
- } catch (error) {
45
- // If there's an error reading the config, return an empty object
46
- return {};
125
+ /**
126
+ * Remove a profile by name. Returns true if removed.
127
+ */
128
+ export function deleteProfile(name) {
129
+ const store = readStore();
130
+ if (!store.profiles[name]) return false;
131
+ delete store.profiles[name];
132
+ if (store.default === name) {
133
+ const remaining = Object.keys(store.profiles);
134
+ store.default = remaining[0] || null;
47
135
  }
136
+ writeStore(store);
137
+ return true;
48
138
  }
49
139
 
50
140
  /**
51
- * Write configuration
52
- * @param {Object} config - Configuration object
141
+ * Check if any profile is configured.
53
142
  */
54
- export function writeConfig(config) {
55
- const configPath = getConfigPath();
56
- const configData = JSON.stringify(config, null, 2);
143
+ export function configExists() {
144
+ const store = readStore();
145
+ if (!Object.keys(store.profiles).length) return false;
146
+ return getActiveProfile() != null;
147
+ }
57
148
 
58
- fs.writeFileSync(configPath, configData, 'utf8');
149
+ /**
150
+ * Read the active profile's data.
151
+ * @returns {Object} Active profile fields (server, username, token, ...) or {}
152
+ */
153
+ export function readConfig() {
154
+ const store = readStore();
155
+ const name = getActiveProfile();
156
+ if (!name) return {};
157
+ return store.profiles[name] || {};
59
158
  }
60
159
 
61
160
  /**
62
- * Merge new configuration properties with existing one
63
- * @param {Object} newConfig - New configuration properties
161
+ * Read the active profile and assert that it has the fields every API
162
+ * call needs (server, username, token). Throws a tagged error so the
163
+ * MCP and CLI surfaces both classify the failure as `not_configured`
164
+ * via classifyError. Replaces the same boilerplate that used to live
165
+ * at the top of every helper in lib/.
166
+ */
167
+ export function requireConfig() {
168
+ const config = readConfig();
169
+ if (!config.token || !config.server || !config.username) {
170
+ const err = new Error('Not configured. Run thinr without parameters to set up.');
171
+ err.code = 'not_configured';
172
+ throw err;
173
+ }
174
+ return config;
175
+ }
176
+
177
+ /**
178
+ * Write a fresh profile from the supplied data. The profile name is
179
+ * derived from the supplied `server`, or from the active profile name
180
+ * if no server is provided. Becomes the default if there isn't one yet.
181
+ */
182
+ export function writeConfig(config) {
183
+ const store = readStore();
184
+ const name = (config && config.server) || getActiveProfile();
185
+ if (!name) {
186
+ throw new Error('Cannot write config without a server / profile name');
187
+ }
188
+ store.profiles[name] = { ...config };
189
+ if (!store.default) store.default = name;
190
+ writeStore(store);
191
+ }
192
+
193
+ /**
194
+ * Merge fields into the active profile.
64
195
  */
65
196
  export function mergeConfig(newConfig) {
66
- const existingConfig = readConfig();
67
- const mergedConfig = {...existingConfig, ...newConfig};
68
- writeConfig(mergedConfig);
197
+ const store = readStore();
198
+ const name = getActiveProfile();
199
+ if (!name) {
200
+ // No active profile yet: fall back to writeConfig semantics.
201
+ return writeConfig(newConfig);
202
+ }
203
+ store.profiles[name] = { ...store.profiles[name], ...newConfig };
204
+ writeStore(store);
69
205
  }
70
206
 
71
207
  /**
72
- * Delete configuration
73
- * @returns {boolean} True if deleted successfully
208
+ * Delete the entire config (all profiles).
74
209
  */
75
210
  export function deleteConfig() {
76
211
  const configPath = getConfigPath();
77
-
78
- if (fs.existsSync(configPath)) {
79
- try {
80
- fs.unlinkSync(configPath);
81
- return true;
82
- } catch (error) {
83
- return false;
84
- }
212
+ if (!fs.existsSync(configPath)) return true;
213
+ try {
214
+ fs.unlinkSync(configPath);
215
+ return true;
216
+ } catch {
217
+ return false;
85
218
  }
86
-
87
- return true; // Already doesn't exist
88
- }
219
+ }
package/lib/console.js CHANGED
@@ -1,134 +1,116 @@
1
1
  import WebSocket from 'ws';
2
2
  import chalk from 'chalk';
3
- import { readConfig } from './config.js';
3
+ import { requireConfig } from './config.js';
4
4
  import api from './api.js';
5
5
 
6
6
  /**
7
- * Creates a WebSocket connection to a device terminal
7
+ * Creates a WebSocket connection to a device terminal. Returns a
8
+ * promise that resolves with `0` when the remote side closes
9
+ * cleanly, and rejects with a tagged Error when the connection
10
+ * fails (404 → not_found, 401/403 → unauthorized, anything else →
11
+ * error). The CLI command wrapping this call decides what to do
12
+ * with the resolved code or the thrown error — the helper itself
13
+ * never calls process.exit.
14
+ *
8
15
  * @param {string} deviceId - The ID of the device to connect to
16
+ * @returns {Promise<number>} Exit code (0 on clean close)
9
17
  */
10
18
  export async function connectToDeviceConsole(deviceId) {
11
- // Get configuration
12
- const config = readConfig();
19
+ const config = requireConfig();
13
20
 
14
- if (!config.token || !config.server || !config.username) {
15
- console.error(chalk.red('Error: Not configured. Run thinr without parameters to set up.'));
16
- process.exit(1);
17
- }
18
-
19
- // Create a unique session ID
20
21
  const sessionId = createUUID();
21
-
22
- // Create WebSocket URL
23
22
  const wsUrl = `wss://${config.server}/v3/users/${config.username}/devices/${deviceId}/resources/$terminal/${sessionId}?raw=1`;
24
-
25
- //console.log(chalk.blue(`Connecting to ${deviceId}...`));
26
-
27
- // Create WebSocket connection
28
23
  const ws = new WebSocket(wsUrl, {
29
- headers: {
30
- 'Authorization': `Bearer ${config.token}`
31
- }
24
+ headers: { Authorization: `Bearer ${config.token}` },
32
25
  });
33
26
 
34
- // Flag to track if terminal settings have been restored
35
27
  let terminalRestored = false;
36
-
37
- // Setup raw mode for process.stdin
38
28
  process.stdin.setRawMode(true);
39
29
  process.stdin.resume();
40
30
  process.stdin.setEncoding('utf8');
41
31
 
42
- // Function to restore terminal settings
43
32
  const restoreTerminal = () => {
44
33
  if (!terminalRestored) {
45
- // Restore terminal settings
46
34
  process.stdin.setRawMode(false);
47
35
  process.stdin.pause();
48
36
  terminalRestored = true;
49
37
  }
50
38
  };
51
39
 
52
- // Handle WebSocket connection
53
- ws.on('open', () => {
54
- //console.log(chalk.green(`Connected to ${deviceId}`));
55
-
56
- // Set initial terminal size
57
- updateTerminalSize(config, deviceId, sessionId, process.stdout.columns, process.stdout.rows);
58
-
59
- // Handle window resize
60
- process.stdout.on('resize', () => {
61
- updateTerminalSize(config, deviceId, sessionId, process.stdout.columns, process.stdout.rows);
40
+ return new Promise((resolve, reject) => {
41
+ ws.on('open', () => {
42
+ updateTerminalSize(
43
+ config,
44
+ deviceId,
45
+ sessionId,
46
+ process.stdout.columns,
47
+ process.stdout.rows,
48
+ );
49
+
50
+ process.stdout.on('resize', () => {
51
+ updateTerminalSize(
52
+ config,
53
+ deviceId,
54
+ sessionId,
55
+ process.stdout.columns,
56
+ process.stdout.rows,
57
+ );
58
+ });
59
+
60
+ process.stdin.on('data', (data) => {
61
+ // Ctrl+D (EOT) to exit gracefully
62
+ if (data.toString() === '\u0004') {
63
+ ws.close();
64
+ return;
65
+ }
66
+ // Forward everything else (including Ctrl+C) to the device
67
+ ws.send(data);
68
+ });
69
+
70
+ ws.on('message', (data) => {
71
+ if (!(data instanceof Buffer)) data = Buffer.from(data);
72
+ process.stdout.write(data);
73
+ });
62
74
  });
63
75
 
64
- // Forward stdin to WebSocket
65
- process.stdin.on('data', (data) => {
66
- // Check for Ctrl+D (EOT character, ASCII 4) to exit gracefully
67
- if (data.toString() === '\u0004') {
68
- ws.close();
69
- return;
70
- }
71
-
72
- // Forward any other input (including Ctrl+C) to the remote device
73
- ws.send(data);
76
+ ws.on('close', () => {
77
+ console.log(chalk.yellow('\nConnection closed'));
78
+ restoreTerminal();
79
+ // Small delay so any final output flushes before the caller exits.
80
+ setTimeout(() => resolve(0), 100);
74
81
  });
75
82
 
76
- // Forward WebSocket data to stdout
77
- ws.on('message', (data) => {
78
- // Ensure we're writing Buffer data to avoid encoding issues
79
- if (!(data instanceof Buffer)) {
80
- data = Buffer.from(data);
83
+ ws.on('error', (error) => {
84
+ restoreTerminal();
85
+ const m = /(\d{3})/.exec(error.message || '');
86
+ const status = m ? parseInt(m[1], 10) : 0;
87
+ let msg, code;
88
+ if (status === 404) {
89
+ msg = `Device "${deviceId}" not found or offline.`;
90
+ code = 'not_found';
91
+ } else if (status === 401 || status === 403) {
92
+ msg = `Not authorized to access "${deviceId}". Check your token or user.`;
93
+ code = 'unauthorized';
94
+ } else {
95
+ msg = `Connection error: ${error.message}`;
96
+ code = 'error';
81
97
  }
82
-
83
- process.stdout.write(data);
98
+ const err = new Error(msg);
99
+ err.code = code;
100
+ reject(err);
84
101
  });
85
- });
86
102
 
87
- // Handle WebSocket close
88
- ws.on('close', () => {
89
- console.log(chalk.yellow('\nConnection closed'));
90
- restoreTerminal();
91
-
92
- // Small delay before exiting to flush any remaining output
93
- setTimeout(() => {
94
- process.exit(0);
95
- }, 100);
96
- });
97
-
98
- // Handle WebSocket errors
99
- ws.on('error', (error) => {
100
- console.error(chalk.red(`\nWebSocket error: ${error.message}`));
101
- restoreTerminal();
102
- process.exit(1);
103
- });
104
-
105
- // Handle SIGINT (Ctrl+C) at process level
106
- process.on('SIGINT', () => {
107
- // Try to send Ctrl+C to the remote terminal
108
- if (ws.readyState === WebSocket.OPEN) {
109
- ws.send('\u0003');
110
- }
111
-
112
- // Don't exit our process - let the remote terminal handle it
113
- return false;
114
- });
115
-
116
- // Ensure cleanup on other exit signals
117
- process.on('SIGTERM', () => {
118
- restoreTerminal();
119
- if (ws.readyState === WebSocket.OPEN) {
120
- ws.close();
121
- }
122
- });
103
+ // Forward Ctrl+C to the remote terminal instead of killing ourselves.
104
+ process.on('SIGINT', () => {
105
+ if (ws.readyState === WebSocket.OPEN) ws.send('\u0003');
106
+ return false;
107
+ });
123
108
 
124
- // Handle uncaught exceptions gracefully
125
- process.on('uncaughtException', (error) => {
126
- console.error(chalk.red(`\nUnexpected error: ${error.message}`));
127
- restoreTerminal();
128
- if (ws.readyState === WebSocket.OPEN) {
129
- ws.close();
130
- }
131
- process.exit(1);
109
+ // Cleanup on shutdown signals (no exit — the WS close will resolve us).
110
+ process.on('SIGTERM', () => {
111
+ restoreTerminal();
112
+ if (ws.readyState === WebSocket.OPEN) ws.close();
113
+ });
132
114
  });
133
115
  }
134
116
 
@@ -144,15 +126,12 @@ async function updateTerminalSize(config, deviceId, sessionId, cols, rows) {
144
126
  try {
145
127
  const url = `/v3/users/${config.username}/devices/${deviceId}/resources/$terminal/${sessionId}/params`;
146
128
 
147
- await api.post(
148
- url,
149
- {
150
- size: {
151
- cols: cols,
152
- rows: rows
153
- }
129
+ await api.post(url, {
130
+ size: {
131
+ cols: cols,
132
+ rows: rows,
154
133
  },
155
- );
134
+ });
156
135
  } catch (error) {
157
136
  console.error(chalk.red(`Error updating terminal size: ${error.message}`));
158
137
  }
@@ -164,9 +143,9 @@ async function updateTerminalSize(config, deviceId, sessionId, cols, rows) {
164
143
  */
165
144
  function createUUID() {
166
145
  let dt = new Date().getTime();
167
- return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
168
- const r = (dt + Math.random() * 16) % 16 | 0;
146
+ return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
147
+ const r = ((dt + Math.random() * 16) % 16) | 0;
169
148
  dt = Math.floor(dt / 16);
170
- return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16);
149
+ return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16);
171
150
  });
172
- }
151
+ }