@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.
- package/CHANGELOG.md +63 -1
- package/README.md +235 -30
- package/bin/thinr.js +43 -25
- package/commands/_shared.js +154 -0
- package/commands/dashboard.js +17 -0
- package/commands/device/_shared.js +17 -34
- package/commands/device/console.js +1 -0
- package/commands/device/delete.js +53 -0
- package/commands/device/exec.js +7 -6
- package/commands/device/fs.js +193 -0
- package/commands/device/index.js +9 -0
- package/commands/device/list.js +35 -23
- package/commands/device/logs.js +178 -0
- package/commands/device/property.js +30 -28
- package/commands/device/proxy.js +3 -0
- package/commands/device/resource.js +30 -29
- package/commands/device/status.js +35 -14
- package/commands/device/transfer.js +236 -0
- package/commands/device/update.js +15 -10
- package/commands/fleet/index.js +17 -0
- package/commands/fleet/upgrade.js +275 -0
- package/commands/logout.js +10 -11
- package/commands/playbook/_shared.js +12 -0
- package/commands/playbook/index.js +18 -0
- package/commands/playbook/run.js +302 -0
- package/commands/playbook/validate.js +38 -0
- package/commands/product/_shared.js +8 -0
- package/commands/product/exec.js +243 -0
- package/commands/product/fs.js +218 -0
- package/commands/product/index.js +35 -0
- package/commands/product/list.js +86 -0
- package/commands/product/logs.js +289 -0
- package/commands/product/metric.js +124 -0
- package/commands/product/playbook.js +911 -0
- package/commands/product/property.js +76 -0
- package/commands/product/props.js +125 -0
- package/commands/product/push.js +184 -0
- package/commands/product/resource.js +76 -0
- package/commands/product/status.js +213 -0
- package/commands/profile.js +76 -8
- package/dist/dashboard.js +5644 -0
- package/lib/agent-releases.js +59 -0
- package/lib/agent-versions.js +73 -0
- package/lib/alarms.js +252 -0
- package/lib/api.js +8 -2
- package/lib/auth.js +68 -20
- package/lib/bucket.js +87 -0
- package/lib/command-stream.js +213 -0
- package/lib/config.js +5 -3
- package/lib/console.js +19 -5
- package/lib/constants.js +83 -0
- package/lib/dashboard/event-stream.js +239 -0
- package/lib/dashboard/metric-aggregate.js +64 -0
- package/lib/dashboard/metric-filter.js +39 -0
- package/lib/debug-log.js +93 -0
- package/lib/device-api.js +131 -148
- package/lib/devices.js +18 -21
- package/lib/errors.js +12 -0
- package/lib/fleet-upgrade.js +106 -0
- package/lib/format.js +114 -0
- package/lib/mcp/registry.js +14 -0
- package/lib/mcp/tools-alarms.js +510 -0
- package/lib/mcp/tools-buckets.js +103 -0
- package/lib/mcp/tools-devices.js +33 -3
- package/lib/mcp/tools-exec.js +30 -17
- package/lib/mcp/tools-filesystem.js +102 -6
- package/lib/mcp/tools-install.js +153 -0
- package/lib/mcp/tools-logs.js +225 -0
- package/lib/mcp/tools-playbook.js +211 -0
- package/lib/mcp/tools-products.js +1226 -105
- package/lib/mcp/tools-profile.js +634 -0
- package/lib/mcp/tools-tokens.js +371 -0
- package/lib/monitoring.js +26 -1
- package/lib/playbook/actions.js +447 -0
- package/lib/playbook/expression.js +302 -0
- package/lib/playbook/fleet.js +175 -0
- package/lib/playbook/loader.js +247 -0
- package/lib/playbook/runner.js +300 -0
- package/lib/playbook/schema.js +209 -0
- package/lib/playbook/vars.js +209 -0
- package/lib/product/log-presets.js +196 -0
- package/lib/product/logs.js +485 -0
- package/lib/product-orchestrator.js +124 -0
- package/lib/product.js +523 -20
- package/lib/profile.js +665 -0
- package/lib/property.js +7 -40
- package/lib/proxy.js +9 -8
- package/lib/resource.js +10 -14
- package/lib/status.js +78 -37
- package/lib/tokens.js +374 -0
- package/package.json +14 -3
- 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
|
+
}
|
package/lib/debug-log.js
ADDED
|
@@ -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
|
|
4
|
-
import {
|
|
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 ||
|
|
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
|
|
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
|
|
65
|
-
|
|
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
|
-
*
|
|
84
|
-
*
|
|
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
|
|
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:
|
|
165
|
+
const response = await api.get(url, { timeout: TIMEOUTS.DEVICE_RESOURCE_GET_MS });
|
|
124
166
|
return response.data;
|
|
125
167
|
},
|
|
126
168
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
-
|
|
139
|
-
|
|
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:
|
|
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
|
|
158
|
-
* connection closes
|
|
159
|
-
*
|
|
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
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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.
|
|
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
|
-
|
|
43
|
-
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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) {
|