@thinremote/thinr-cli 1.2.0-alpha.1 → 1.3.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 (92) hide show
  1. package/CHANGELOG.md +63 -1
  2. package/README.md +235 -30
  3. package/bin/thinr.js +43 -25
  4. package/commands/_shared.js +154 -0
  5. package/commands/dashboard.js +17 -0
  6. package/commands/device/_shared.js +17 -34
  7. package/commands/device/console.js +1 -0
  8. package/commands/device/delete.js +53 -0
  9. package/commands/device/exec.js +7 -6
  10. package/commands/device/fs.js +193 -0
  11. package/commands/device/index.js +9 -0
  12. package/commands/device/list.js +35 -23
  13. package/commands/device/logs.js +178 -0
  14. package/commands/device/property.js +30 -28
  15. package/commands/device/proxy.js +3 -0
  16. package/commands/device/resource.js +30 -29
  17. package/commands/device/status.js +35 -14
  18. package/commands/device/transfer.js +236 -0
  19. package/commands/device/update.js +15 -10
  20. package/commands/fleet/index.js +17 -0
  21. package/commands/fleet/upgrade.js +275 -0
  22. package/commands/logout.js +10 -11
  23. package/commands/playbook/_shared.js +12 -0
  24. package/commands/playbook/index.js +18 -0
  25. package/commands/playbook/run.js +302 -0
  26. package/commands/playbook/validate.js +38 -0
  27. package/commands/product/_shared.js +8 -0
  28. package/commands/product/exec.js +243 -0
  29. package/commands/product/fs.js +218 -0
  30. package/commands/product/index.js +35 -0
  31. package/commands/product/list.js +86 -0
  32. package/commands/product/logs.js +289 -0
  33. package/commands/product/metric.js +124 -0
  34. package/commands/product/playbook.js +911 -0
  35. package/commands/product/property.js +76 -0
  36. package/commands/product/props.js +125 -0
  37. package/commands/product/push.js +184 -0
  38. package/commands/product/resource.js +76 -0
  39. package/commands/product/status.js +213 -0
  40. package/commands/profile.js +76 -8
  41. package/dist/dashboard.js +5644 -0
  42. package/lib/agent-releases.js +59 -0
  43. package/lib/agent-versions.js +73 -0
  44. package/lib/alarms.js +252 -0
  45. package/lib/api.js +8 -2
  46. package/lib/auth.js +68 -20
  47. package/lib/bucket.js +87 -0
  48. package/lib/command-stream.js +213 -0
  49. package/lib/config.js +5 -3
  50. package/lib/console.js +19 -5
  51. package/lib/constants.js +83 -0
  52. package/lib/dashboard/event-stream.js +239 -0
  53. package/lib/dashboard/metric-aggregate.js +64 -0
  54. package/lib/dashboard/metric-filter.js +39 -0
  55. package/lib/debug-log.js +93 -0
  56. package/lib/device-api.js +131 -148
  57. package/lib/devices.js +18 -21
  58. package/lib/errors.js +12 -0
  59. package/lib/fleet-upgrade.js +106 -0
  60. package/lib/format.js +114 -0
  61. package/lib/mcp/registry.js +14 -0
  62. package/lib/mcp/tools-alarms.js +510 -0
  63. package/lib/mcp/tools-buckets.js +103 -0
  64. package/lib/mcp/tools-devices.js +33 -3
  65. package/lib/mcp/tools-exec.js +30 -17
  66. package/lib/mcp/tools-filesystem.js +102 -6
  67. package/lib/mcp/tools-install.js +153 -0
  68. package/lib/mcp/tools-logs.js +225 -0
  69. package/lib/mcp/tools-playbook.js +211 -0
  70. package/lib/mcp/tools-products.js +1226 -105
  71. package/lib/mcp/tools-profile.js +634 -0
  72. package/lib/mcp/tools-tokens.js +371 -0
  73. package/lib/monitoring.js +26 -1
  74. package/lib/playbook/actions.js +447 -0
  75. package/lib/playbook/expression.js +302 -0
  76. package/lib/playbook/fleet.js +175 -0
  77. package/lib/playbook/loader.js +247 -0
  78. package/lib/playbook/runner.js +300 -0
  79. package/lib/playbook/schema.js +209 -0
  80. package/lib/playbook/vars.js +209 -0
  81. package/lib/product/log-presets.js +196 -0
  82. package/lib/product/logs.js +485 -0
  83. package/lib/product-orchestrator.js +124 -0
  84. package/lib/product.js +523 -20
  85. package/lib/profile.js +665 -0
  86. package/lib/property.js +7 -40
  87. package/lib/proxy.js +9 -8
  88. package/lib/resource.js +10 -14
  89. package/lib/status.js +78 -37
  90. package/lib/tokens.js +374 -0
  91. package/package.json +14 -3
  92. package/commands/product.js +0 -407
@@ -0,0 +1,39 @@
1
+ // @ts-check
2
+
3
+ // Pure helper for the "click a bucket → filter devices" interaction.
4
+ //
5
+ // `valuesByDevice` mirrors the per-device latest sample map maintained
6
+ // inside `useProductMetrics`: keyed by metricKey (`<product>:<name>`),
7
+ // the value is a map of deviceId → latest value reported for that
8
+ // metric. We compare reported values to the bucket key after coercing
9
+ // both sides to string, matching the behaviour of the `distribution`
10
+ // aggregator (which also bins by `String(v)`).
11
+
12
+ /**
13
+ * @typedef {{ device: string, [k: string]: any }} DeviceLike
14
+ * @typedef {{ metricKey: string, bucket: string, [k: string]: any } | null | undefined} MetricFilter
15
+ */
16
+
17
+ /**
18
+ * Filter a fleet view down to the devices whose latest sample for the
19
+ * filter's metric equals the chosen bucket. Devices missing a sample for
20
+ * that metric (offline or never-reported) drop out — they belong to the
21
+ * implicit "no data" bucket, which the panel doesn't surface.
22
+ *
23
+ * @template {DeviceLike} D
24
+ * @param {readonly D[]} devices
25
+ * @param {Record<string, Record<string, any>> | null | undefined} valuesByDevice
26
+ * @param {MetricFilter} metricFilter
27
+ * @returns {D[]}
28
+ */
29
+ export function filterDevicesByMetric(devices, valuesByDevice, metricFilter) {
30
+ if (!metricFilter || metricFilter.bucket == null) return [...devices];
31
+ const map = valuesByDevice?.[metricFilter.metricKey];
32
+ if (!map) return [];
33
+ const target = String(metricFilter.bucket);
34
+ return devices.filter((d) => {
35
+ const v = map[d.device];
36
+ if (v == null) return false;
37
+ return String(v) === target;
38
+ });
39
+ }
@@ -0,0 +1,93 @@
1
+ // @ts-check
2
+ import { createWriteStream, mkdirSync } from 'fs';
3
+ import { homedir } from 'os';
4
+ import { join } from 'path';
5
+
6
+ // Opt-in JSONL logger for diagnosing dashboard backend load. Activates only
7
+ // when `THINR_DEBUG` is set to a truthy string. Writes to
8
+ // `~/.thinr/dashboard-debug.log` so it never collides with the Ink alt-screen.
9
+ //
10
+ // API:
11
+ // debugLog(category, msg, data?) — one line per event
12
+ // debugCount(key, n = 1) — bump a counter; flushed every 5s in a
13
+ // `summary` line and then reset, so the
14
+ // log shows event rates without one row
15
+ // per high-frequency frame.
16
+ // isDebugEnabled()
17
+ //
18
+ // Each line is JSON:
19
+ // { t: <iso>, c: <category>, m: <msg>, d?: <data> }
20
+ // { t: <iso>, c: 'summary', d: { '<key>': <count>, ... } }
21
+
22
+ const SUMMARY_MS = 5_000;
23
+
24
+ let stream = null;
25
+ let enabled = null;
26
+ let counters = new Map();
27
+ let summaryTimer = null;
28
+
29
+ function init() {
30
+ if (enabled !== null) return;
31
+ enabled = !!process.env.THINR_DEBUG;
32
+ if (!enabled) return;
33
+ const dir = join(homedir(), '.thinr');
34
+ try {
35
+ mkdirSync(dir, { recursive: true });
36
+ } catch {
37
+ // best-effort; if creation fails the createWriteStream below will
38
+ // also fail and we silently disable.
39
+ }
40
+ const path = join(dir, 'dashboard-debug.log');
41
+ try {
42
+ stream = createWriteStream(path, { flags: 'w' });
43
+ } catch {
44
+ enabled = false;
45
+ return;
46
+ }
47
+ write({
48
+ t: new Date().toISOString(),
49
+ c: 'init',
50
+ m: 'log open',
51
+ d: { pid: process.pid, mode: process.env.THINR_DEBUG },
52
+ });
53
+ summaryTimer = setInterval(flushSummary, SUMMARY_MS);
54
+ summaryTimer.unref?.();
55
+ }
56
+
57
+ function write(obj) {
58
+ if (!stream) return;
59
+ try {
60
+ stream.write(JSON.stringify(obj) + '\n');
61
+ } catch {
62
+ // ignore — better to drop a line than crash the dashboard.
63
+ }
64
+ }
65
+
66
+ function flushSummary() {
67
+ if (!enabled || counters.size === 0) return;
68
+ const d = Object.fromEntries(counters);
69
+ counters = new Map();
70
+ write({ t: new Date().toISOString(), c: 'summary', d });
71
+ }
72
+
73
+ export function debugLog(category, msg, data) {
74
+ init();
75
+ if (!enabled) return;
76
+ write({
77
+ t: new Date().toISOString(),
78
+ c: category,
79
+ m: msg,
80
+ ...(data !== undefined ? { d: data } : {}),
81
+ });
82
+ }
83
+
84
+ export function debugCount(key, n = 1) {
85
+ init();
86
+ if (!enabled) return;
87
+ counters.set(key, (counters.get(key) || 0) + n);
88
+ }
89
+
90
+ export function isDebugEnabled() {
91
+ init();
92
+ return !!enabled;
93
+ }
package/lib/device-api.js CHANGED
@@ -1,42 +1,40 @@
1
1
  import api from './api.js';
2
2
  import { requireConfig } from './config.js';
3
- import WebSocket from 'ws';
4
- import { randomUUID } from 'crypto';
3
+ import { BYTES, TIMEOUTS } from './constants.js';
4
+ import { CommandStream } from './command-stream.js';
5
+ import { ensureFreshToken, refreshToken } from './auth.js';
6
+ import { Readable } from 'node:stream';
7
+
8
+ // Directory endpoints on the agent expect a trailing slash so it can
9
+ // distinguish "list this directory" from "read a file with this name".
10
+ const withTrailingSlash = (p) => (p.endsWith('/') ? p : p + '/');
11
+
12
+ // The $fs/hash resource is mounted under the resource path, which is
13
+ // already an absolute URL path; the caller's relative `foo/bar` would
14
+ // otherwise produce `…/resources/$fs/hashfoo/bar`. Prepend the slash.
15
+ const withLeadingSlash = (p) => (p.startsWith('/') ? p : `/${p}`);
5
16
 
6
- /**
7
- * Create a device API client for filesystem and command operations
8
- * @param {string} deviceId - The device ID
9
- * @param {Object} [options] - Options
10
- * @param {string} [options.user] - Override API user (admin impersonation)
11
- * @returns {Object} API client with fs and exec methods
12
- */
13
17
  export function createDeviceAPI(deviceId, options = {}) {
14
18
  const config = requireConfig();
15
19
  const apiUser = options.user || config.username;
16
20
  const resourcePath = `/v3/users/${apiUser}/devices/${deviceId}/resources`;
17
21
  const filesPath = `/v3/users/${apiUser}/devices/${deviceId}/files`;
18
22
 
19
- /**
20
- * Call a device resource (for cmd, etc.)
21
- */
22
23
  async function callResource(resource, payload = null, opts = {}) {
23
24
  const url = `${resourcePath}/${resource}`;
24
25
  const response = await api.post(url, payload, {
25
26
  responseType: opts.responseType || 'json',
26
27
  params: opts.params || {},
27
28
  headers: opts.headers || {},
28
- timeout: opts.timeout || 30000,
29
+ timeout: opts.timeout || TIMEOUTS.DEVICE_RESOURCE_CALL_MS,
29
30
  });
30
31
  return response;
31
32
  }
32
33
 
33
34
  return {
34
- /**
35
- * List directory contents
36
- * GET /v3/users/{user}/devices/{device}/files/{path}/
37
- */
35
+ // GET /v3/users/{user}/devices/{device}/files/{path}/
38
36
  async listDir(dirPath, includeHidden = false) {
39
- const normalized = dirPath.endsWith('/') ? dirPath : dirPath + '/';
37
+ const normalized = withTrailingSlash(dirPath);
40
38
  const params = {};
41
39
  if (includeHidden) params.hidden = true;
42
40
  const response = await api.get(`${filesPath}${normalized}`, { params });
@@ -44,34 +42,73 @@ export function createDeviceAPI(deviceId, options = {}) {
44
42
  },
45
43
 
46
44
  /**
47
- * Read file contents
45
+ * Read file contents. Pass `opts.onProgress` to observe the
46
+ * download byte-by-byte (axios ProgressEvent: `{ loaded, total,
47
+ * progress, rate, ... }`). File transfers run with no HTTP
48
+ * timeout by default because a large pull can easily outlast
49
+ * the default 10s axios timeout.
50
+ *
48
51
  * GET /v3/users/{user}/devices/{device}/files/{path}
49
52
  */
50
- async readFile(filePath) {
53
+ async readFile(filePath, opts = {}) {
51
54
  const response = await api.get(`${filesPath}${filePath}`, {
52
55
  responseType: 'arraybuffer',
56
+ timeout: opts.timeout ?? 0,
57
+ onDownloadProgress: opts.onProgress,
53
58
  });
54
59
  return Buffer.from(response.data);
55
60
  },
56
61
 
57
62
  /**
58
- * Write file contents
63
+ * Write file contents. Pass `opts.onProgress` to observe the
64
+ * upload byte-by-byte (axios ProgressEvent: `{ loaded, total,
65
+ * progress, rate, ... }`). Buffers are wrapped in a chunked
66
+ * Readable so the progress callback fires per chunk — axios
67
+ * otherwise flushes the whole body in one socket write and
68
+ * progress ends up binary (0% → 100%). Timeout defaults to
69
+ * unbounded; a 10s default kills any transfer bigger than
70
+ * a handful of megabytes.
71
+ *
59
72
  * PUT /v3/users/{user}/devices/{device}/files/{path}
60
73
  */
61
- async writeFile(filePath, content, overwrite = true) {
74
+ async writeFile(filePath, content, overwrite = true, opts = {}) {
62
75
  const params = {};
63
76
  if (!overwrite) params.overwrite = false;
64
- const response = await api.put(`${filesPath}${filePath}`, content, {
65
- headers: { 'Content-Type': 'application/octet-stream' },
77
+ const isBuffer = Buffer.isBuffer(content);
78
+ // Streams: the caller gets granular `onUploadProgress` events
79
+ // per socket write. Buffers: axios flushes the whole body in
80
+ // a single write, so the callback only fires at 0% and 100%
81
+ // — callers that care about progress should pass a stream.
82
+ const body = isBuffer
83
+ ? Readable.from(content, { highWaterMark: BYTES.STREAM_CHUNK })
84
+ : content;
85
+ const headers = { 'Content-Type': 'application/octet-stream' };
86
+ if (isBuffer) {
87
+ headers['Content-Length'] = String(content.byteLength);
88
+ } else if (opts.contentLength != null) {
89
+ headers['Content-Length'] = String(opts.contentLength);
90
+ }
91
+ const response = await api.put(`${filesPath}${filePath}`, body, {
92
+ headers,
66
93
  params,
94
+ timeout: opts.timeout ?? 0,
95
+ maxBodyLength: Infinity,
96
+ maxContentLength: Infinity,
97
+ // `follow-redirects` (axios' default HTTP client)
98
+ // buffers the request body in memory to be able to
99
+ // replay it on a 3xx. That defeats the upstream
100
+ // backpressure that the CLI's progress meter relies
101
+ // on — bytes get "sent" to RAM instantly, then the
102
+ // real socket drain happens out-of-band. The
103
+ // /v3/…/files/ endpoint doesn't redirect, so opting
104
+ // out is free and restores true streaming.
105
+ maxRedirects: 0,
106
+ onUploadProgress: opts.onProgress,
67
107
  });
68
108
  return response.data;
69
109
  },
70
110
 
71
- /**
72
- * Get file/directory info
73
- * GET /v3/users/{user}/devices/{device}/files/{path}?info=true
74
- */
111
+ // GET /v3/users/{user}/devices/{device}/files/{path}?info=true
75
112
  async info(filePath) {
76
113
  const response = await api.get(`${filesPath}${filePath}`, {
77
114
  params: { info: true },
@@ -80,9 +117,23 @@ export function createDeviceAPI(deviceId, options = {}) {
80
117
  },
81
118
 
82
119
  /**
83
- * Delete file or directory
84
- * DELETE /v3/users/{user}/devices/{device}/files/{path}
120
+ * Hash the remote file with the given algorithm.
121
+ * GET /v3/users/{user}/devices/{device}/resources/$fs/hash/{path}
122
+ *
123
+ * Returns `{ path, algorithm, hash, size, mtime }`. Throws axios
124
+ * errors (404 for missing file, 400 for non-regular path, 403
125
+ * when the path is outside the agent's allowed base).
85
126
  */
127
+ async hashFile(filePath, algorithm = 'sha256') {
128
+ const normalized = withLeadingSlash(filePath);
129
+ const response = await api.get(`${resourcePath}/$fs/hash${normalized}`, {
130
+ params: algorithm && algorithm !== 'sha256' ? { algorithm } : {},
131
+ timeout: TIMEOUTS.DEVICE_RESOURCE_CALL_MS,
132
+ });
133
+ return response.data;
134
+ },
135
+
136
+ // DELETE /v3/users/{user}/devices/{device}/files/{path}
86
137
  async delete(filePath, recursive = true) {
87
138
  const params = {};
88
139
  if (recursive) params.recursive = true;
@@ -90,10 +141,7 @@ export function createDeviceAPI(deviceId, options = {}) {
90
141
  return response.data;
91
142
  },
92
143
 
93
- /**
94
- * Move/rename file or directory
95
- * PATCH /v3/users/{user}/devices/{device}/files/{sourcePath}
96
- */
144
+ // PATCH /v3/users/{user}/devices/{device}/files/{sourcePath}
97
145
  async move(source, destination, overwrite = false) {
98
146
  const response = await api.patch(`${filesPath}${source}`, {
99
147
  path: destination,
@@ -102,43 +150,36 @@ export function createDeviceAPI(deviceId, options = {}) {
102
150
  return response.data;
103
151
  },
104
152
 
105
- /**
106
- * Create directory
107
- * PUT /v3/users/{user}/devices/{device}/files/{path}/
108
- */
153
+ // PUT /v3/users/{user}/devices/{device}/files/{path}/
109
154
  async mkdir(dirPath) {
110
- const normalized = dirPath.endsWith('/') ? dirPath : dirPath + '/';
155
+ const normalized = withTrailingSlash(dirPath);
111
156
  const response = await api.put(`${filesPath}${normalized}`, null, {
112
157
  headers: { 'Content-Length': '0' },
113
158
  });
114
159
  return response.data;
115
160
  },
116
161
 
117
- /**
118
- * Read a device resource property (GET)
119
- * GET /v3/users/{user}/devices/{device}/resources/{resource}
120
- */
162
+ // GET /v3/users/{user}/devices/{device}/resources/{resource}
121
163
  async getResource(resource) {
122
164
  const url = `${resourcePath}/${resource}`;
123
- const response = await api.get(url, { timeout: 15000 });
165
+ const response = await api.get(url, { timeout: TIMEOUTS.DEVICE_RESOURCE_GET_MS });
124
166
  return response.data;
125
167
  },
126
168
 
127
- /**
128
- * Call a device resource action (POST)
129
- * POST /v3/users/{user}/devices/{device}/resources/{resource}
130
- */
131
- async callResource(resource, payload = null, { timeout = 30000 } = {}) {
169
+ // POST /v3/users/{user}/devices/{device}/resources/{resource}
170
+ async callResource(
171
+ resource,
172
+ payload = null,
173
+ { timeout = TIMEOUTS.DEVICE_RESOURCE_CALL_MS } = {},
174
+ ) {
132
175
  const url = `${resourcePath}/${resource}`;
133
176
  const response = await api.post(url, payload, { timeout });
134
177
  return response.data;
135
178
  },
136
179
 
137
- /**
138
- * Execute a command on the device
139
- * POST /v3/users/{user}/devices/{device}/resources/cmd
140
- */
141
- async exec(cmd, timeout = 30) {
180
+ // Legacy one-shot exec via POST /resources/cmd. Prefer execStream
181
+ // for anything interactive or longer than a few seconds.
182
+ async exec(cmd, timeout = TIMEOUTS.DEFAULT_EXEC_SECONDS) {
142
183
  const response = await callResource(
143
184
  'cmd',
144
185
  {
@@ -146,7 +187,7 @@ export function createDeviceAPI(deviceId, options = {}) {
146
187
  timeout,
147
188
  mode: 'api',
148
189
  },
149
- { timeout: (timeout + 5) * 1000 },
190
+ { timeout: timeout * 1000 + TIMEOUTS.EXEC_EXTRA_GRACE_MS },
150
191
  );
151
192
  return response.data;
152
193
  },
@@ -154,104 +195,46 @@ export function createDeviceAPI(deviceId, options = {}) {
154
195
  /**
155
196
  * Execute a command on the device using the WebSocket stream. Incoming
156
197
  * frames are dispatched to onStdout/onStderr as they arrive. Resolves
157
- * with { exitCode, timedOut } when the remote command completes or the
158
- * connection closes. Returns a `cancel()` function via the optional
159
- * onCancel hook so callers (e.g. SIGINT handlers) can tear it down.
198
+ * with { exitCode, timedOut, cancelled } when the remote command
199
+ * completes, the connection closes, the safety timer fires, or the
200
+ * caller invokes the `cancel()` function exposed via onCancel.
160
201
  *
161
202
  * Preferred path for the CLI: the server detects WS close and stops
162
- * the device-side stream automatically.
203
+ * the device-side stream automatically. Framing, timeout and
204
+ * cancellation live in `CommandStream` (lib/command-stream.js).
205
+ *
206
+ * Auth: the WS handshake has no refresh-on-401 interceptor like
207
+ * HTTP calls do, so the token is refreshed up front when its JWT
208
+ * is already expired, and a handshake 401 gets one explicit
209
+ * refresh-and-retry.
163
210
  */
164
- execStream(cmd, { timeout = 0, onStdout, onStderr, onCancel, stdin } = {}) {
165
- const sessionId = randomUUID();
166
- const resource = `$cmd/${sessionId}`;
167
- const params = new URLSearchParams({ cmd });
168
- if (timeout > 0) params.set('timeout', String(timeout));
211
+ async execStream(cmd, { timeout = 0, onStdout, onStderr, onCancel, stdin } = {}) {
169
212
  const isLocal = config.server === 'localhost' || config.server === '127.0.0.1';
170
213
  const insecure = isLocal || process.env.THINR_INSECURE === '1';
171
- const wsUrl = `wss://${config.server}/v3/users/${apiUser}/devices/${deviceId}/resources/${resource}?${params}`;
172
- const ws = new WebSocket(wsUrl, {
173
- headers: { Authorization: `Bearer ${config.token}` },
174
- rejectUnauthorized: !insecure,
175
- });
176
-
177
- let exitCode = null;
178
- let timedOut = false;
179
- let cancelled = false;
180
-
181
- const cancel = () => {
182
- cancelled = true;
183
- if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) {
184
- ws.close();
185
- }
186
- };
187
- onCancel?.(cancel);
188
-
189
- const decodeBytes = (field) => {
190
- if (typeof field === 'string') return field;
191
- if (field && Array.isArray(field.bytes)) {
192
- return Buffer.from(field.bytes).toString('utf8');
193
- }
194
- return '';
195
- };
196
-
197
- return new Promise((resolve, reject) => {
198
- let resolved = false;
199
- const done = () => {
200
- if (resolved) return;
201
- resolved = true;
202
- resolve({ exitCode, timedOut, cancelled });
203
- // Tear down the socket without blocking on the server's
204
- // close handshake.
205
- if (
206
- ws.readyState === WebSocket.OPEN ||
207
- ws.readyState === WebSocket.CONNECTING
208
- ) {
209
- ws.terminate();
210
- }
211
- };
212
-
213
- ws.on('open', () => {
214
- // If the caller supplied a stdin stream, forward each
215
- // chunk to the device as a stream frame. Payload is sent
216
- // as a UTF-8 string under the standard {resource, payload}
217
- // envelope that the server's stream handler expects.
218
- if (stdin && typeof stdin.on === 'function') {
219
- stdin.on('data', (chunk) => {
220
- if (ws.readyState !== WebSocket.OPEN) return;
221
- const text = Buffer.isBuffer(chunk)
222
- ? chunk.toString('utf8')
223
- : String(chunk);
224
- ws.send(JSON.stringify({ resource, payload: text }));
225
- });
226
- }
227
- });
228
- ws.on('error', (err) => {
229
- if (cancelled) return;
230
- reject(err);
231
- });
232
- ws.on('message', (raw) => {
233
- let evt;
234
- try {
235
- evt = JSON.parse(raw.toString('utf8'));
236
- } catch {
237
- return;
238
- }
239
- if (evt.signal && evt.signal !== 'data') return;
240
- const p = evt.payload;
241
- if (!p || typeof p !== 'object') return;
242
- if (p.out !== undefined) onStdout?.(decodeBytes(p.out));
243
- else if (p.err !== undefined) onStderr?.(decodeBytes(p.err));
244
- else if (typeof p.exit === 'number') {
245
- exitCode = p.exit;
246
- if (p.timeout === true) timedOut = true;
247
- // We have the full result — resolve immediately and
248
- // tear down the WS without waiting for the server's
249
- // stop cascade, which can add several seconds.
250
- done();
251
- }
252
- });
253
- ws.on('close', () => done());
254
- });
214
+ const run = (token) =>
215
+ new CommandStream({
216
+ server: config.server,
217
+ token,
218
+ apiUser,
219
+ deviceId,
220
+ insecure,
221
+ cmd,
222
+ timeout,
223
+ onStdout,
224
+ onStderr,
225
+ onCancel,
226
+ stdin,
227
+ }).run();
228
+ try {
229
+ return await run((await ensureFreshToken()) || config.token);
230
+ } catch (error) {
231
+ // A token that decodes as still-valid can be rejected
232
+ // anyway (revoked, clock skew): mirror the api.js
233
+ // interceptor and retry the handshake once after an
234
+ // explicit refresh.
235
+ if (error?.statusCode !== 401) throw error;
236
+ return run(await refreshToken());
237
+ }
255
238
  },
256
239
  };
257
240
  }
package/lib/devices.js CHANGED
@@ -2,11 +2,6 @@ import { requireConfig } from './config.js';
2
2
  import api from './api.js';
3
3
  import { apiError } from './errors.js';
4
4
 
5
- /**
6
- * Get devices belonging to a product
7
- * @param {Object} filter - Filters to query devices
8
- * @return {Promise<Array>} List of devices
9
- */
10
5
  export async function getDevices(filter, user = null) {
11
6
  const config = requireConfig();
12
7
  const apiUser = user || config.username;
@@ -32,22 +27,15 @@ export async function getDevices(filter, user = null) {
32
27
 
33
28
  return devices;
34
29
  } catch (error) {
35
- const product = filter && filter.productId;
30
+ const product = filter && filter.product;
36
31
  const notFound = product ? `Product not found: ${product}` : 'Devices not found';
37
32
  throw apiError(error, { notFound });
38
33
  }
39
34
  }
40
35
 
41
- /**
42
- * Fetch a single device record (metadata only — assigned product,
43
- * description, asset group, connection state, …). Use this when you
44
- * need the side-channel info that doesn't come from the device's own
45
- * agent (system_info, monitoring, …) but from the server's record.
46
- *
47
- * @param {string} deviceId
48
- * @param {string} [user] Admin impersonation; defaults to the active profile.
49
- * @returns {Promise<Object>} Raw API record.
50
- */
36
+ // Server-side metadata (assigned product, description, asset group,
37
+ // connection state…). For device-side data — system_info, monitoring —
38
+ // hit the agent resources directly instead.
51
39
  export async function getDevice(deviceId, user = null) {
52
40
  const config = requireConfig();
53
41
  const apiUser = user || config.username;
@@ -59,11 +47,20 @@ export async function getDevice(deviceId, user = null) {
59
47
  }
60
48
  }
61
49
 
62
- /**
63
- * Filter active devices from a list of devices
64
- * @param {Array} devices - List of devices to filter
65
- * @return {Array} List of active devices
66
- */
50
+ // Idempotent: returns true if the device record was removed, false if it
51
+ // was already absent. Other failures (auth, network) propagate as apiError.
52
+ export async function deleteDevice(deviceId, user = null) {
53
+ const config = requireConfig();
54
+ const apiUser = user || config.username;
55
+ try {
56
+ await api.delete(`/v1/users/${apiUser}/devices/${deviceId}`);
57
+ return true;
58
+ } catch (error) {
59
+ if (error.response?.status === 404) return false;
60
+ throw apiError(error);
61
+ }
62
+ }
63
+
67
64
  export function filterActiveDevices(devices) {
68
65
  return devices.filter((device) => device.connection && device.connection.active === true);
69
66
  }
package/lib/errors.js CHANGED
@@ -7,6 +7,18 @@
7
7
  * that names the missing entity (e.g. "Device not found: abc").
8
8
  */
9
9
  export function apiError(error, messages = {}) {
10
+ // Errors normalized upstream (the tagged 'unauthorized' from a failed
11
+ // token refresh, inputError, ...) carry a stable string code and no
12
+ // axios envelope: pass them through so the code survives.
13
+ if (
14
+ error &&
15
+ typeof error.code === 'string' &&
16
+ !error.isAxiosError &&
17
+ !error.response &&
18
+ !error.request
19
+ ) {
20
+ return error;
21
+ }
10
22
  if (error && error.response) {
11
23
  const status = error.response.status;
12
24
  if (status === 404) {