@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
package/lib/proxy.js CHANGED
@@ -1,8 +1,9 @@
1
1
  import chalk from 'chalk';
2
2
  import open, { apps } from 'open';
3
3
  import ora from 'ora';
4
- import { configExists, readConfig } from './config.js';
4
+ import { readConfig, requireConfig } from './config.js';
5
5
  import api from './api.js';
6
+ import { apiError } from './errors.js';
6
7
 
7
8
  /**
8
9
  * Parse target argument into address and port
@@ -75,49 +76,43 @@ function parseTarget(target, defaultPort) {
75
76
  return {
76
77
  address: address || 'localhost',
77
78
  port: port,
78
- isSecure
79
+ isSecure,
79
80
  };
80
81
  }
81
82
 
82
83
  /**
83
- * Create proxy action handler
84
+ * Open a proxy and keep it running until the user hits Ctrl+C.
85
+ * Resolves with `0` on a clean SIGINT shutdown, rejects with the
86
+ * underlying error if the proxy can't be created. Never calls
87
+ * process.exit — the wrapping CLI command decides what to do with
88
+ * the resolved code or the thrown error.
89
+ *
90
+ * @returns {Promise<number>} Exit code (0 on clean shutdown)
84
91
  */
85
92
  export async function handleProxyAction(deviceId, protocol, target, options) {
86
- // Check if configured
87
- if (!configExists()) {
88
- console.error(chalk.red('Error: Not configured. Run thinr without parameters to set up.'));
89
- process.exit(1);
90
- }
91
-
92
- // Determine default port based on protocol
93
- const defaultPort = protocol === 'http' ? 80 : (protocol === 'tls' ? 443 : 22);
93
+ const defaultPort = protocol === 'http' ? 80 : protocol === 'tls' ? 443 : 22;
94
94
  const { address, port, isSecure } = parseTarget(target, defaultPort);
95
95
 
96
- // Determine proxy configuration based on protocol
97
- let proxyConfig = {
96
+ const proxyConfig = {
98
97
  targetAddress: address,
99
98
  targetPort: port,
100
99
  serverPort: options.port ? parseInt(options.port) : null,
101
100
  targetSecure: false,
102
101
  serverSecure: false,
103
- web: false
102
+ web: false,
104
103
  };
105
104
 
106
105
  switch (protocol) {
107
106
  case 'tcp':
108
- // TCP proxy without TLS
109
107
  proxyConfig.serverSecure = false;
110
108
  break;
111
109
  case 'tls':
112
- // TLS proxy
113
110
  proxyConfig.targetSecure = true;
114
111
  proxyConfig.serverSecure = true;
115
112
  break;
116
113
  case 'http':
117
- // HTTP proxy
118
114
  proxyConfig.web = true;
119
- proxyConfig.serverSecure = true; // Default to HTTPS for web interface
120
- // Use the isSecure flag from parseTarget
115
+ proxyConfig.serverSecure = true;
121
116
  proxyConfig.targetSecure = isSecure || port === 443;
122
117
  break;
123
118
  }
@@ -125,54 +120,44 @@ export async function handleProxyAction(deviceId, protocol, target, options) {
125
120
  const proxyType = proxyConfig.web ? 'HTTP proxy' : `${protocol.toUpperCase()} proxy`;
126
121
  const spinner = ora(`Creating ${proxyType} to ${deviceId} (${address}:${port})...`).start();
127
122
  let proxyId = null;
123
+ let proxy;
128
124
 
129
125
  try {
130
- // Create proxy
131
- const proxy = await createProxy(deviceId, proxyConfig);
126
+ proxy = await createProxy(deviceId, proxyConfig);
132
127
  proxyId = proxy.proxyId;
128
+ } catch (error) {
129
+ spinner.fail(`Failed to create ${proxyType}`);
130
+ if (proxyId) await deleteProxy(proxyId);
131
+ throw error;
132
+ }
133
133
 
134
- if (proxyConfig.web) {
135
- // For HTTP proxy, show target as URL format
136
- const targetProtocol = isSecure ? 'https' : 'http';
137
- const targetUrl = `${targetProtocol}://${address}${port === 80 || port === 443 ? '' : `:${port}`}`;
138
-
139
- spinner.succeed(`HTTP proxy running at ${chalk.blue(proxy.url)} → ${deviceId} -> ${chalk.cyan(targetUrl)}`);
140
-
141
- // Open browser if not disabled
142
- if (options.openBrowser !== false) {
143
- await openBrowser(proxy.url);
144
- }
145
- } else {
146
- // For TCP/TLS proxy, show address:port format
147
- const secureInfo = isSecure ? ' [TLS]' : '';
148
- spinner.succeed(`${protocol.toUpperCase()} proxy running on ${chalk.blue(proxy.serverHost + ':' + proxy.serverPort)} → ${deviceId} (${address}:${port})${secureInfo}`);
134
+ if (proxyConfig.web) {
135
+ const targetProtocol = isSecure ? 'https' : 'http';
136
+ const targetUrl = `${targetProtocol}://${address}${port === 80 || port === 443 ? '' : `:${port}`}`;
137
+ spinner.succeed(
138
+ `HTTP proxy running at ${chalk.blue(proxy.url)} → ${deviceId} -> ${chalk.cyan(targetUrl)}`,
139
+ );
140
+ if (options.openBrowser !== false) {
141
+ await openBrowser(proxy.url);
149
142
  }
143
+ } else {
144
+ const secureInfo = isSecure ? ' [TLS]' : '';
145
+ spinner.succeed(
146
+ `${protocol.toUpperCase()} proxy running on ${chalk.blue(proxy.serverHost + ':' + proxy.serverPort)} → ${deviceId} (${address}:${port})${secureInfo}`,
147
+ );
148
+ }
150
149
 
151
- console.log(chalk.gray(`Press Ctrl+C to stop`));
150
+ console.log(chalk.gray(`Press Ctrl+C to stop`));
151
+ process.stdin.resume();
152
152
 
153
- // Handle process termination
153
+ // Resolve when the user hits Ctrl+C — the CLI command then exits cleanly.
154
+ return new Promise((resolve) => {
154
155
  process.on('SIGINT', async () => {
155
156
  console.log(chalk.yellow('\nStopping proxy...'));
156
- if (proxyId) {
157
- await deleteProxy(proxyId);
158
- }
159
- process.exit(0);
157
+ if (proxyId) await deleteProxy(proxyId);
158
+ resolve(0);
160
159
  });
161
-
162
- // Keep the process running
163
- process.stdin.resume();
164
-
165
- } catch (error) {
166
- spinner.fail(`Failed to create ${proxyType}`);
167
- console.error(chalk.red(`Error: ${error.message}`));
168
-
169
- // Clean up if needed
170
- if (proxyId) {
171
- await deleteProxy(proxyId);
172
- }
173
-
174
- process.exit(1);
175
- }
160
+ });
176
161
  }
177
162
 
178
163
  /**
@@ -192,27 +177,22 @@ function getRandomPort(min = 50000, max = 51000) {
192
177
  * @returns {Promise<Object>} Proxy information
193
178
  */
194
179
  async function createProxy(deviceId, options) {
195
- // Get configuration
196
- const config = readConfig();
197
-
198
- if (!config.token || !config.server || !config.username) {
199
- throw new Error('Not configured. Run thinr without parameters to set up.');
200
- }
180
+ const config = requireConfig();
201
181
 
202
182
  const protocol = options.web ? 'http_iotmp' : 'tcp_iotmp';
203
183
  const targetAddress = options.targetAddress || 'localhost';
204
184
  const targetPort = options.targetPort || (options.web ? 80 : 22);
205
185
  const serverPort = options.serverPort || getRandomPort();
206
- const serverSecure = options.serverSecure || (!!options.web);
186
+ const serverSecure = options.serverSecure || !!options.web;
207
187
  const targetSecure = options.targetSecure || false;
208
188
 
209
189
  // Create a unique proxy ID based on device, protocol and a timestamp
210
190
  const timestamp = new Date().getTime().toString(36);
211
- const proxyId = `${protocol}_${deviceId.slice(0,12)}_${timestamp}`;
191
+ const proxyId = `${protocol}_${deviceId.slice(0, 12)}_${timestamp}`;
212
192
 
213
193
  try {
214
194
  // Create proxy
215
- const response = await api.post(
195
+ await api.post(
216
196
  `/v1/proxies`,
217
197
  {
218
198
  enabled: true,
@@ -223,23 +203,23 @@ async function createProxy(deviceId, options) {
223
203
  device: deviceId,
224
204
  address: targetAddress,
225
205
  port: targetPort,
226
- secure: targetSecure
206
+ secure: targetSecure,
227
207
  },
228
208
  protocol: protocol,
229
209
  source: {
230
210
  port: serverPort,
231
- secure: serverSecure
232
- }
211
+ secure: serverSecure,
212
+ },
233
213
  },
234
214
  proxy: proxyId,
235
215
  name: options.web ? `Web access for ${deviceId}` : `TCP proxy for ${deviceId}`,
236
- description: `${options.web ? 'Web interface' : 'TCP proxy'} for ${deviceId}:${targetPort} created by ThingR CLI`
216
+ description: `${options.web ? 'Web interface' : 'TCP proxy'} for ${deviceId}:${targetPort} created by Thinr CLI`,
237
217
  },
238
218
  {
239
219
  headers: {
240
- 'Content-Type': 'application/json'
241
- }
242
- }
220
+ 'Content-Type': 'application/json',
221
+ },
222
+ },
243
223
  );
244
224
 
245
225
  // Determine the URL based on protocol and SSL setting
@@ -255,20 +235,10 @@ async function createProxy(deviceId, options) {
255
235
  targetPort: targetPort,
256
236
  protocol: protocol,
257
237
  serverSecure: serverSecure,
258
- targetSecure: targetSecure
238
+ targetSecure: targetSecure,
259
239
  };
260
240
  } catch (error) {
261
- if (error.response) {
262
- if (error.response.status === 401) {
263
- throw new Error('Unauthorized. Your token may have expired. Please reconfigure.');
264
- } else {
265
- throw new Error(`Server error: ${error.response.status} ${error.response.statusText}`);
266
- }
267
- } else if (error.request) {
268
- throw new Error('No response from server. Please check your connection.');
269
- } else {
270
- throw new Error(`Error: ${error.message}`);
271
- }
241
+ throw apiError(error, { notFound: `Device not found: ${deviceId}` });
272
242
  }
273
243
  }
274
244
 
@@ -285,9 +255,7 @@ async function deleteProxy(proxyId) {
285
255
  }
286
256
 
287
257
  try {
288
- await api.delete(
289
- `/v1/proxies/${proxyId}`,
290
- );
258
+ await api.delete(`/v1/proxies/${proxyId}`);
291
259
 
292
260
  return true;
293
261
  } catch (error) {
@@ -302,10 +270,10 @@ async function deleteProxy(proxyId) {
302
270
  */
303
271
  async function openBrowser(url) {
304
272
  try {
305
- await open(url, {app: [{name: apps.browser}, 'firefox-developer-edition']});
273
+ await open(url, { app: [{ name: apps.browser }, 'firefox-developer-edition'] });
306
274
  return true;
307
275
  } catch (error) {
308
276
  console.error(chalk.red(`Error opening browser: ${error.message}`));
309
277
  return false;
310
278
  }
311
- }
279
+ }
package/lib/resource.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import chalk from 'chalk';
2
- import { readConfig } from './config.js';
2
+ import { requireConfig } from './config.js';
3
3
  import api from './api.js';
4
+ import { apiError } from './errors.js';
4
5
 
5
6
  /**
6
7
  * Get device resource value
@@ -8,116 +9,149 @@ import api from './api.js';
8
9
  * @return {Promise<Object>} Resource value
9
10
  */
10
11
  export async function getDeviceResources(deviceId) {
11
- // Get configuration
12
- const config = readConfig();
13
-
14
- if (!config.token || !config.server || !config.username) {
15
- throw new Error('Not configured. Run thinr without parameters to set up.');
16
- }
12
+ const config = requireConfig();
17
13
 
18
14
  try {
19
- // Fetch resource value
20
15
  const response = await api.get(
21
16
  `/v3/users/${config.username}/devices/${deviceId}/resources`,
22
17
  );
23
-
24
- // Remove hidden resources for which the keys starting with '_'
25
- const filtered = Object.fromEntries(
26
- Object.entries(response.data).filter(([key]) => !key.startsWith('$'))
18
+ // Drop internal resources whose keys start with '$'
19
+ return Object.fromEntries(
20
+ Object.entries(response.data).filter(([key]) => !key.startsWith('$')),
27
21
  );
22
+ } catch (error) {
23
+ throw apiError(error, { notFound: `Device not found: ${deviceId}` });
24
+ }
25
+ }
28
26
 
29
- return filtered;
27
+ /**
28
+ * Read the current value of a resource via GET. Appropriate for
29
+ * output-only (fn=3) or fn=4 resources when you only want the latest
30
+ * sample, not to trigger the input side.
31
+ */
32
+ export async function readDeviceResource(deviceId, resourceId) {
33
+ const config = requireConfig();
34
+ try {
35
+ const response = await api.get(
36
+ `/v3/users/${config.username}/devices/${deviceId}/resources/${resourceId}`,
37
+ );
38
+ return response.data;
30
39
  } catch (error) {
31
- if (error.response) {
32
- // The request was made and the server responded with a status code
33
- if (error.response.status === 404) {
34
- throw new Error(`Device not found: ${deviceId}`);
35
- } else if (error.response.status === 401) {
36
- throw new Error('Unauthorized. Your token may have expired. Please reconfigure.');
37
- } else {
38
- throw new Error(`Server error: ${error.response.status} ${error.response.statusText}`);
39
- }
40
- } else if (error.request) {
41
- // The request was made but no response was received
42
- throw new Error('No response from server. Please check your connection.');
43
- } else {
44
- // Something happened in setting up the request that triggered an Error
45
- throw new Error(`Error: ${error.message}`);
46
- }
40
+ throw apiError(error, {
41
+ notFound: `Resource not found: ${resourceId} on device ${deviceId}`,
42
+ });
47
43
  }
44
+ }
48
45
 
46
+ /**
47
+ * Introspect a single resource. Returns the `{in, out}` schema the
48
+ * agent advertises for that resource, e.g. for `cmd`:
49
+ * { in: { cmd: "", mode: "api", timeout: 30 }, out: { stdout: "", stderr: "", retcode: 0 } }
50
+ *
51
+ * Read-only resources (fn=3) only return `out`; input-only (fn=2) only
52
+ * return `in`; fn=1 may return an empty object. Not every agent/firmware
53
+ * exposes /api — callers should tolerate 404/501.
54
+ */
55
+ export async function getDeviceResourceApi(deviceId, resourceId) {
56
+ const config = requireConfig();
57
+ try {
58
+ const response = await api.get(
59
+ `/v3/users/${config.username}/devices/${deviceId}/resources/${resourceId}/api`,
60
+ );
61
+ return response.data;
62
+ } catch (error) {
63
+ throw apiError(error, {
64
+ notFound: `Resource not found: ${resourceId} on device ${deviceId}`,
65
+ });
66
+ }
49
67
  }
50
68
 
51
69
  /**
52
70
  * Execute a resource on a device
53
71
  */
54
72
  export async function executeDeviceResource(deviceId, resourceId, inputs) {
55
- // Get configuration
56
- const config = readConfig();
57
-
58
- if (!config.token || !config.server || !config.username) {
59
- throw new Error('Not configured. Run thinr without parameters to set up.');
60
- }
73
+ const config = requireConfig();
61
74
 
62
75
  try {
63
- // Execute resource
64
76
  const response = await api.post(
65
77
  `/v3/users/${config.username}/devices/${deviceId}/resources/${resourceId}`,
66
78
  inputs,
67
79
  );
68
-
69
80
  return response.data;
70
81
  } catch (error) {
71
- if (error.response) {
72
- // The request was made and the server responded with a status code
73
- if (error.response.status === 404) {
74
- throw new Error(`Resource not found: ${resourceId} on device ${deviceId}`);
75
- } else if (error.response.status === 401) {
76
- throw new Error('Unauthorized. Your token may have expired. Please reconfigure.');
77
- } else {
78
- throw new Error(`Server error: ${error.response.status} ${error.response.statusText}`);
79
- }
80
- } else if (error.request) {
81
- // The request was made but no response was received
82
- throw new Error('No response from server. Please check your connection.');
83
- } else {
84
- // Something happened in setting up the request that triggered an Error
85
- throw new Error(`Error: ${error.message}`);
86
- }
82
+ throw apiError(error, {
83
+ notFound: `Resource not found: ${resourceId} on device ${deviceId}`,
84
+ });
87
85
  }
88
86
  }
89
87
 
90
88
  /**
91
- * Format device resources for display
92
- * @param {string} deviceId - Device ID
93
- * @param {Object} resources - Resources data from API
94
- * @return {string} Formatted resources
89
+ * Decide GET vs POST based on whether the caller passed real inputs.
90
+ * No inputs (undefined, null, or an empty object) → GET, which reads
91
+ * the output side of fn=3/4 resources without triggering the input
92
+ * handler. Otherwise POST with the payload.
93
+ */
94
+ export async function callDeviceResource(deviceId, resourceId, inputs) {
95
+ const hasInputs =
96
+ inputs !== undefined &&
97
+ inputs !== null &&
98
+ !(typeof inputs === 'object' && !Array.isArray(inputs) && Object.keys(inputs).length === 0);
99
+ return hasInputs
100
+ ? executeDeviceResource(deviceId, resourceId, inputs)
101
+ : readDeviceResource(deviceId, resourceId);
102
+ }
103
+
104
+ /**
105
+ * Fetch the resource list and, in parallel, introspect each resource's
106
+ * `/api` schema. Returns an array with `{name, fn, in?, out?}` so both
107
+ * the CLI and the MCP can render the same information.
95
108
  */
96
- export function formatDeviceResources(deviceId, resources) {
97
- let formattedResources = `\nResources for device ${chalk.cyan(deviceId)}:\n`;
109
+ export async function listDeviceResourcesWithSchemas(deviceId) {
110
+ const resources = await getDeviceResources(deviceId);
111
+ const names = Object.keys(resources);
112
+ const entries = await Promise.all(
113
+ names.map(async (name) => {
114
+ const fn = resources[name]?.fn;
115
+ try {
116
+ const schema = await getDeviceResourceApi(deviceId, name);
117
+ return { name, fn, ...schema };
118
+ } catch {
119
+ return { name, fn };
120
+ }
121
+ }),
122
+ );
123
+ return entries;
124
+ }
98
125
 
99
- //
100
- if (resources && Object.keys(resources).length > 0) {
101
- Object.keys(resources).forEach(key => {
102
- formattedResources += `- ${chalk.green(key)}: ${chalk.blue(getResourceType(resources[key]))}\n`;
103
- });
104
- } else {
105
- formattedResources += chalk.yellow('No resources found for this device.\n');
126
+ /**
127
+ * Format the resource entries produced by listDeviceResourcesWithSchemas
128
+ * (`{name, fn, in?, out?}`). Shared between CLI (with chalk) and any
129
+ * other human-facing surface.
130
+ */
131
+ export function formatDeviceResourcesWithSchemas(deviceId, entries) {
132
+ let out = `\nResources for device ${chalk.cyan(deviceId)}:\n`;
133
+ if (!entries || entries.length === 0) {
134
+ return out + chalk.yellow('No resources found for this device.\n');
106
135
  }
107
-
108
- return formattedResources;
136
+ for (const e of entries) {
137
+ const kind = getResourceType({ fn: e.fn });
138
+ out += `\n- ${chalk.green(e.name)} ${chalk.gray('[' + kind + ']')}\n`;
139
+ if (e.in !== undefined) out += ` in: ${JSON.stringify(e.in)}\n`;
140
+ if (e.out !== undefined) out += ` out: ${JSON.stringify(e.out)}\n`;
141
+ }
142
+ return out;
109
143
  }
110
144
 
111
145
  function getResourceType(value) {
112
146
  switch (value.fn) {
113
147
  case 1:
114
- return "no parameters";
148
+ return 'no parameters';
115
149
  case 2:
116
- return "input";
150
+ return 'input';
117
151
  case 3:
118
- return "output";
152
+ return 'output';
119
153
  case 4:
120
- return "input/output";
154
+ return 'input/output';
121
155
  default:
122
156
  return 'unknown';
123
157
  }
package/lib/status.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import chalk from 'chalk';
2
- import { readConfig } from './config.js';
2
+ import { requireConfig } from './config.js';
3
3
  import api from './api.js';
4
+ import { apiError } from './errors.js';
4
5
 
5
6
  /**
6
7
  * Get device status
@@ -8,38 +9,13 @@ import api from './api.js';
8
9
  * @returns {Promise<Object>} Status information
9
10
  */
10
11
  export async function getDeviceStatus(deviceId) {
11
- // Get configuration
12
- const config = readConfig();
13
-
14
- if (!config.token || !config.server || !config.username) {
15
- throw new Error('Not configured. Run thinr without parameters to set up.');
16
- }
12
+ const config = requireConfig();
17
13
 
18
14
  try {
19
- // Fetch device status
20
- const response = await api.get(
21
- `/v1/users/${config.username}/devices/${deviceId}/stats`,
22
- );
23
-
15
+ const response = await api.get(`/v1/users/${config.username}/devices/${deviceId}/stats`);
24
16
  return response.data;
25
17
  } catch (error) {
26
- if (error.response) {
27
- // The request was made and the server responded with a status code
28
- // that falls out of the range of 2xx
29
- if (error.response.status === 404) {
30
- throw new Error(`Device not found: ${deviceId}`);
31
- } else if (error.response.status === 401) {
32
- throw new Error('Unauthorized. Your token may have expired. Please reconfigure.');
33
- } else {
34
- throw new Error(`Server error: ${error.response.status} ${error.response.statusText}`);
35
- }
36
- } else if (error.request) {
37
- // The request was made but no response was received
38
- throw new Error('No response from server. Please check your connection.');
39
- } else {
40
- // Something happened in setting up the request that triggered an Error
41
- throw new Error(`Error: ${error.message}`);
42
- }
18
+ throw apiError(error, { notFound: `Device not found: ${deviceId}` });
43
19
  }
44
20
  }
45
21
 
@@ -50,9 +26,7 @@ export async function getDeviceStatus(deviceId) {
50
26
  * @returns {string} Formatted status
51
27
  */
52
28
  export function formatDeviceStatus(deviceId, status) {
53
- const connectedStatus = status.connected ?
54
- chalk.green('● Online') :
55
- chalk.red('○ Offline');
29
+ const connectedStatus = status.connected ? chalk.green('● Online') : chalk.red('○ Offline');
56
30
 
57
31
  // Format connected time
58
32
  let connectedTime = '';
@@ -93,4 +67,4 @@ Data Transfer:
93
67
  `;
94
68
 
95
69
  return output;
96
- }
70
+ }