@thinremote/thinr-cli 1.2.0-alpha.1 → 1.2.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 +45 -1
- package/README.md +192 -16
- package/bin/thinr.js +23 -24
- 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 +30 -17
- package/lib/bucket.js +87 -0
- package/lib/command-stream.js +199 -0
- package/lib/config.js +5 -3
- package/lib/console.js +12 -4
- package/lib/constants.js +76 -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 +109 -147
- package/lib/devices.js +18 -21
- 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
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,39 @@
|
|
|
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 { Readable } from 'node:stream';
|
|
6
|
+
|
|
7
|
+
// Directory endpoints on the agent expect a trailing slash so it can
|
|
8
|
+
// distinguish "list this directory" from "read a file with this name".
|
|
9
|
+
const withTrailingSlash = (p) => (p.endsWith('/') ? p : p + '/');
|
|
10
|
+
|
|
11
|
+
// The $fs/hash resource is mounted under the resource path, which is
|
|
12
|
+
// already an absolute URL path; the caller's relative `foo/bar` would
|
|
13
|
+
// otherwise produce `…/resources/$fs/hashfoo/bar`. Prepend the slash.
|
|
14
|
+
const withLeadingSlash = (p) => (p.startsWith('/') ? p : `/${p}`);
|
|
5
15
|
|
|
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
16
|
export function createDeviceAPI(deviceId, options = {}) {
|
|
14
17
|
const config = requireConfig();
|
|
15
18
|
const apiUser = options.user || config.username;
|
|
16
19
|
const resourcePath = `/v3/users/${apiUser}/devices/${deviceId}/resources`;
|
|
17
20
|
const filesPath = `/v3/users/${apiUser}/devices/${deviceId}/files`;
|
|
18
21
|
|
|
19
|
-
/**
|
|
20
|
-
* Call a device resource (for cmd, etc.)
|
|
21
|
-
*/
|
|
22
22
|
async function callResource(resource, payload = null, opts = {}) {
|
|
23
23
|
const url = `${resourcePath}/${resource}`;
|
|
24
24
|
const response = await api.post(url, payload, {
|
|
25
25
|
responseType: opts.responseType || 'json',
|
|
26
26
|
params: opts.params || {},
|
|
27
27
|
headers: opts.headers || {},
|
|
28
|
-
timeout: opts.timeout ||
|
|
28
|
+
timeout: opts.timeout || TIMEOUTS.DEVICE_RESOURCE_CALL_MS,
|
|
29
29
|
});
|
|
30
30
|
return response;
|
|
31
31
|
}
|
|
32
32
|
|
|
33
33
|
return {
|
|
34
|
-
|
|
35
|
-
* List directory contents
|
|
36
|
-
* GET /v3/users/{user}/devices/{device}/files/{path}/
|
|
37
|
-
*/
|
|
34
|
+
// GET /v3/users/{user}/devices/{device}/files/{path}/
|
|
38
35
|
async listDir(dirPath, includeHidden = false) {
|
|
39
|
-
const normalized = dirPath
|
|
36
|
+
const normalized = withTrailingSlash(dirPath);
|
|
40
37
|
const params = {};
|
|
41
38
|
if (includeHidden) params.hidden = true;
|
|
42
39
|
const response = await api.get(`${filesPath}${normalized}`, { params });
|
|
@@ -44,34 +41,73 @@ export function createDeviceAPI(deviceId, options = {}) {
|
|
|
44
41
|
},
|
|
45
42
|
|
|
46
43
|
/**
|
|
47
|
-
* Read file contents
|
|
44
|
+
* Read file contents. Pass `opts.onProgress` to observe the
|
|
45
|
+
* download byte-by-byte (axios ProgressEvent: `{ loaded, total,
|
|
46
|
+
* progress, rate, ... }`). File transfers run with no HTTP
|
|
47
|
+
* timeout by default because a large pull can easily outlast
|
|
48
|
+
* the default 10s axios timeout.
|
|
49
|
+
*
|
|
48
50
|
* GET /v3/users/{user}/devices/{device}/files/{path}
|
|
49
51
|
*/
|
|
50
|
-
async readFile(filePath) {
|
|
52
|
+
async readFile(filePath, opts = {}) {
|
|
51
53
|
const response = await api.get(`${filesPath}${filePath}`, {
|
|
52
54
|
responseType: 'arraybuffer',
|
|
55
|
+
timeout: opts.timeout ?? 0,
|
|
56
|
+
onDownloadProgress: opts.onProgress,
|
|
53
57
|
});
|
|
54
58
|
return Buffer.from(response.data);
|
|
55
59
|
},
|
|
56
60
|
|
|
57
61
|
/**
|
|
58
|
-
* Write file contents
|
|
62
|
+
* Write file contents. Pass `opts.onProgress` to observe the
|
|
63
|
+
* upload byte-by-byte (axios ProgressEvent: `{ loaded, total,
|
|
64
|
+
* progress, rate, ... }`). Buffers are wrapped in a chunked
|
|
65
|
+
* Readable so the progress callback fires per chunk — axios
|
|
66
|
+
* otherwise flushes the whole body in one socket write and
|
|
67
|
+
* progress ends up binary (0% → 100%). Timeout defaults to
|
|
68
|
+
* unbounded; a 10s default kills any transfer bigger than
|
|
69
|
+
* a handful of megabytes.
|
|
70
|
+
*
|
|
59
71
|
* PUT /v3/users/{user}/devices/{device}/files/{path}
|
|
60
72
|
*/
|
|
61
|
-
async writeFile(filePath, content, overwrite = true) {
|
|
73
|
+
async writeFile(filePath, content, overwrite = true, opts = {}) {
|
|
62
74
|
const params = {};
|
|
63
75
|
if (!overwrite) params.overwrite = false;
|
|
64
|
-
const
|
|
65
|
-
|
|
76
|
+
const isBuffer = Buffer.isBuffer(content);
|
|
77
|
+
// Streams: the caller gets granular `onUploadProgress` events
|
|
78
|
+
// per socket write. Buffers: axios flushes the whole body in
|
|
79
|
+
// a single write, so the callback only fires at 0% and 100%
|
|
80
|
+
// — callers that care about progress should pass a stream.
|
|
81
|
+
const body = isBuffer
|
|
82
|
+
? Readable.from(content, { highWaterMark: BYTES.STREAM_CHUNK })
|
|
83
|
+
: content;
|
|
84
|
+
const headers = { 'Content-Type': 'application/octet-stream' };
|
|
85
|
+
if (isBuffer) {
|
|
86
|
+
headers['Content-Length'] = String(content.byteLength);
|
|
87
|
+
} else if (opts.contentLength != null) {
|
|
88
|
+
headers['Content-Length'] = String(opts.contentLength);
|
|
89
|
+
}
|
|
90
|
+
const response = await api.put(`${filesPath}${filePath}`, body, {
|
|
91
|
+
headers,
|
|
66
92
|
params,
|
|
93
|
+
timeout: opts.timeout ?? 0,
|
|
94
|
+
maxBodyLength: Infinity,
|
|
95
|
+
maxContentLength: Infinity,
|
|
96
|
+
// `follow-redirects` (axios' default HTTP client)
|
|
97
|
+
// buffers the request body in memory to be able to
|
|
98
|
+
// replay it on a 3xx. That defeats the upstream
|
|
99
|
+
// backpressure that the CLI's progress meter relies
|
|
100
|
+
// on — bytes get "sent" to RAM instantly, then the
|
|
101
|
+
// real socket drain happens out-of-band. The
|
|
102
|
+
// /v3/…/files/ endpoint doesn't redirect, so opting
|
|
103
|
+
// out is free and restores true streaming.
|
|
104
|
+
maxRedirects: 0,
|
|
105
|
+
onUploadProgress: opts.onProgress,
|
|
67
106
|
});
|
|
68
107
|
return response.data;
|
|
69
108
|
},
|
|
70
109
|
|
|
71
|
-
|
|
72
|
-
* Get file/directory info
|
|
73
|
-
* GET /v3/users/{user}/devices/{device}/files/{path}?info=true
|
|
74
|
-
*/
|
|
110
|
+
// GET /v3/users/{user}/devices/{device}/files/{path}?info=true
|
|
75
111
|
async info(filePath) {
|
|
76
112
|
const response = await api.get(`${filesPath}${filePath}`, {
|
|
77
113
|
params: { info: true },
|
|
@@ -80,9 +116,23 @@ export function createDeviceAPI(deviceId, options = {}) {
|
|
|
80
116
|
},
|
|
81
117
|
|
|
82
118
|
/**
|
|
83
|
-
*
|
|
84
|
-
*
|
|
119
|
+
* Hash the remote file with the given algorithm.
|
|
120
|
+
* GET /v3/users/{user}/devices/{device}/resources/$fs/hash/{path}
|
|
121
|
+
*
|
|
122
|
+
* Returns `{ path, algorithm, hash, size, mtime }`. Throws axios
|
|
123
|
+
* errors (404 for missing file, 400 for non-regular path, 403
|
|
124
|
+
* when the path is outside the agent's allowed base).
|
|
85
125
|
*/
|
|
126
|
+
async hashFile(filePath, algorithm = 'sha256') {
|
|
127
|
+
const normalized = withLeadingSlash(filePath);
|
|
128
|
+
const response = await api.get(`${resourcePath}/$fs/hash${normalized}`, {
|
|
129
|
+
params: algorithm && algorithm !== 'sha256' ? { algorithm } : {},
|
|
130
|
+
timeout: TIMEOUTS.DEVICE_RESOURCE_CALL_MS,
|
|
131
|
+
});
|
|
132
|
+
return response.data;
|
|
133
|
+
},
|
|
134
|
+
|
|
135
|
+
// DELETE /v3/users/{user}/devices/{device}/files/{path}
|
|
86
136
|
async delete(filePath, recursive = true) {
|
|
87
137
|
const params = {};
|
|
88
138
|
if (recursive) params.recursive = true;
|
|
@@ -90,10 +140,7 @@ export function createDeviceAPI(deviceId, options = {}) {
|
|
|
90
140
|
return response.data;
|
|
91
141
|
},
|
|
92
142
|
|
|
93
|
-
|
|
94
|
-
* Move/rename file or directory
|
|
95
|
-
* PATCH /v3/users/{user}/devices/{device}/files/{sourcePath}
|
|
96
|
-
*/
|
|
143
|
+
// PATCH /v3/users/{user}/devices/{device}/files/{sourcePath}
|
|
97
144
|
async move(source, destination, overwrite = false) {
|
|
98
145
|
const response = await api.patch(`${filesPath}${source}`, {
|
|
99
146
|
path: destination,
|
|
@@ -102,43 +149,32 @@ export function createDeviceAPI(deviceId, options = {}) {
|
|
|
102
149
|
return response.data;
|
|
103
150
|
},
|
|
104
151
|
|
|
105
|
-
|
|
106
|
-
* Create directory
|
|
107
|
-
* PUT /v3/users/{user}/devices/{device}/files/{path}/
|
|
108
|
-
*/
|
|
152
|
+
// PUT /v3/users/{user}/devices/{device}/files/{path}/
|
|
109
153
|
async mkdir(dirPath) {
|
|
110
|
-
const normalized = dirPath
|
|
154
|
+
const normalized = withTrailingSlash(dirPath);
|
|
111
155
|
const response = await api.put(`${filesPath}${normalized}`, null, {
|
|
112
156
|
headers: { 'Content-Length': '0' },
|
|
113
157
|
});
|
|
114
158
|
return response.data;
|
|
115
159
|
},
|
|
116
160
|
|
|
117
|
-
|
|
118
|
-
* Read a device resource property (GET)
|
|
119
|
-
* GET /v3/users/{user}/devices/{device}/resources/{resource}
|
|
120
|
-
*/
|
|
161
|
+
// GET /v3/users/{user}/devices/{device}/resources/{resource}
|
|
121
162
|
async getResource(resource) {
|
|
122
163
|
const url = `${resourcePath}/${resource}`;
|
|
123
|
-
const response = await api.get(url, { timeout:
|
|
164
|
+
const response = await api.get(url, { timeout: TIMEOUTS.DEVICE_RESOURCE_GET_MS });
|
|
124
165
|
return response.data;
|
|
125
166
|
},
|
|
126
167
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
* POST /v3/users/{user}/devices/{device}/resources/{resource}
|
|
130
|
-
*/
|
|
131
|
-
async callResource(resource, payload = null, { timeout = 30000 } = {}) {
|
|
168
|
+
// POST /v3/users/{user}/devices/{device}/resources/{resource}
|
|
169
|
+
async callResource(resource, payload = null, { timeout = TIMEOUTS.DEVICE_RESOURCE_CALL_MS } = {}) {
|
|
132
170
|
const url = `${resourcePath}/${resource}`;
|
|
133
171
|
const response = await api.post(url, payload, { timeout });
|
|
134
172
|
return response.data;
|
|
135
173
|
},
|
|
136
174
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
*/
|
|
141
|
-
async exec(cmd, timeout = 30) {
|
|
175
|
+
// Legacy one-shot exec via POST /resources/cmd. Prefer execStream
|
|
176
|
+
// for anything interactive or longer than a few seconds.
|
|
177
|
+
async exec(cmd, timeout = TIMEOUTS.DEFAULT_EXEC_SECONDS) {
|
|
142
178
|
const response = await callResource(
|
|
143
179
|
'cmd',
|
|
144
180
|
{
|
|
@@ -146,7 +182,7 @@ export function createDeviceAPI(deviceId, options = {}) {
|
|
|
146
182
|
timeout,
|
|
147
183
|
mode: 'api',
|
|
148
184
|
},
|
|
149
|
-
{ timeout:
|
|
185
|
+
{ timeout: timeout * 1000 + TIMEOUTS.EXEC_EXTRA_GRACE_MS },
|
|
150
186
|
);
|
|
151
187
|
return response.data;
|
|
152
188
|
},
|
|
@@ -154,104 +190,30 @@ export function createDeviceAPI(deviceId, options = {}) {
|
|
|
154
190
|
/**
|
|
155
191
|
* Execute a command on the device using the WebSocket stream. Incoming
|
|
156
192
|
* frames are dispatched to onStdout/onStderr as they arrive. Resolves
|
|
157
|
-
* with { exitCode, timedOut } when the remote command
|
|
158
|
-
* connection closes
|
|
159
|
-
*
|
|
193
|
+
* with { exitCode, timedOut, cancelled } when the remote command
|
|
194
|
+
* completes, the connection closes, the safety timer fires, or the
|
|
195
|
+
* caller invokes the `cancel()` function exposed via onCancel.
|
|
160
196
|
*
|
|
161
197
|
* Preferred path for the CLI: the server detects WS close and stops
|
|
162
|
-
* the device-side stream automatically.
|
|
198
|
+
* the device-side stream automatically. Framing, timeout and
|
|
199
|
+
* cancellation live in `CommandStream` (lib/command-stream.js).
|
|
163
200
|
*/
|
|
164
201
|
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));
|
|
169
202
|
const isLocal = config.server === 'localhost' || config.server === '127.0.0.1';
|
|
170
203
|
const insecure = isLocal || process.env.THINR_INSECURE === '1';
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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
|
-
});
|
|
204
|
+
return new CommandStream({
|
|
205
|
+
server: config.server,
|
|
206
|
+
token: config.token,
|
|
207
|
+
apiUser,
|
|
208
|
+
deviceId,
|
|
209
|
+
insecure,
|
|
210
|
+
cmd,
|
|
211
|
+
timeout,
|
|
212
|
+
onStdout,
|
|
213
|
+
onStderr,
|
|
214
|
+
onCancel,
|
|
215
|
+
stdin,
|
|
216
|
+
}).run();
|
|
255
217
|
},
|
|
256
218
|
};
|
|
257
219
|
}
|
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
|
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { createDeviceAPI } from './device-api.js';
|
|
2
|
+
import { TIMEOUTS } from './constants.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Roll a fleet-wide agent upgrade. Strategy:
|
|
6
|
+
* 1. If `canary` is set and there's more than one device, upgrade one
|
|
7
|
+
* device in isolation first. A canary failure always aborts — the
|
|
8
|
+
* point of the canary is to catch arch / channel / connectivity
|
|
9
|
+
* mistakes before we touch the rest.
|
|
10
|
+
* 2. The remainder is upgraded in parallel batches of `batchSize`. If
|
|
11
|
+
* `abortOnFailure` is set, any failure inside a batch stops the run
|
|
12
|
+
* after that batch completes (we never leave requests dangling).
|
|
13
|
+
* 3. Results come back as { ok, failed, aborted } plus the per-device
|
|
14
|
+
* records. Progress is reported through `onProgress` so the UI can
|
|
15
|
+
* show `updating N/M…` live.
|
|
16
|
+
*
|
|
17
|
+
* We intentionally treat any HTTP rejection as a failure and any resolved
|
|
18
|
+
* call as success. The agent's `update` resource doesn't expose a stable
|
|
19
|
+
* machine-readable status across every "nothing to do" case, so finer
|
|
20
|
+
* classification would be fragile here — a resolved call means the agent
|
|
21
|
+
* accepted the request; whether it then swapped binaries is visible in
|
|
22
|
+
* the next sample's version field.
|
|
23
|
+
*/
|
|
24
|
+
export async function runFleetUpgrade({
|
|
25
|
+
deviceIds,
|
|
26
|
+
channel = 'latest',
|
|
27
|
+
user,
|
|
28
|
+
canary = true,
|
|
29
|
+
abortOnFailure = true,
|
|
30
|
+
batchSize = 5,
|
|
31
|
+
onProgress,
|
|
32
|
+
onDeviceResult,
|
|
33
|
+
signal,
|
|
34
|
+
}) {
|
|
35
|
+
const queue = [...deviceIds];
|
|
36
|
+
const total = queue.length;
|
|
37
|
+
const results = [];
|
|
38
|
+
let done = 0;
|
|
39
|
+
|
|
40
|
+
const emitProgress = (phase) => {
|
|
41
|
+
onProgress?.({ done, total, phase });
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
async function upgradeOne(id) {
|
|
45
|
+
if (signal?.aborted) {
|
|
46
|
+
const r = { deviceId: id, ok: false, error: 'cancelled' };
|
|
47
|
+
results.push(r);
|
|
48
|
+
onDeviceResult?.(r);
|
|
49
|
+
return r;
|
|
50
|
+
}
|
|
51
|
+
let r;
|
|
52
|
+
try {
|
|
53
|
+
const api = createDeviceAPI(id, user ? { user } : {});
|
|
54
|
+
const result = await api.callResource(
|
|
55
|
+
'update',
|
|
56
|
+
{ action: 'apply', channel },
|
|
57
|
+
{ timeout: TIMEOUTS.DEVICE_UPDATE_APPLY_MS },
|
|
58
|
+
);
|
|
59
|
+
r = { deviceId: id, ok: true, result };
|
|
60
|
+
} catch (e) {
|
|
61
|
+
r = {
|
|
62
|
+
deviceId: id,
|
|
63
|
+
ok: false,
|
|
64
|
+
error: e?.message || String(e),
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
results.push(r);
|
|
68
|
+
done += 1;
|
|
69
|
+
onDeviceResult?.(r);
|
|
70
|
+
emitProgress('running');
|
|
71
|
+
return r;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
emitProgress('running');
|
|
75
|
+
|
|
76
|
+
// Canary first — a single device, synchronously. Treat failure as
|
|
77
|
+
// terminal regardless of `abortOnFailure`: if the canary fails we have
|
|
78
|
+
// no signal that a wider rollout would succeed, and blindly firing at
|
|
79
|
+
// 95 more devices would only amplify whatever went wrong.
|
|
80
|
+
if (canary && queue.length > 1) {
|
|
81
|
+
const canaryId = queue.shift();
|
|
82
|
+
const r = await upgradeOne(canaryId);
|
|
83
|
+
if (!r.ok) {
|
|
84
|
+
return { results, aborted: true, reason: 'canary-failed' };
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
while (queue.length > 0) {
|
|
89
|
+
if (signal?.aborted) {
|
|
90
|
+
return { results, aborted: true, reason: 'cancelled' };
|
|
91
|
+
}
|
|
92
|
+
const batch = queue.splice(0, batchSize);
|
|
93
|
+
const batchResults = await Promise.all(batch.map(upgradeOne));
|
|
94
|
+
if (abortOnFailure && batchResults.some((r) => !r.ok)) {
|
|
95
|
+
return { results, aborted: true, reason: 'batch-failed' };
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return { results, aborted: false };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function summarize(results) {
|
|
103
|
+
const ok = results.filter((r) => r.ok).length;
|
|
104
|
+
const failed = results.length - ok;
|
|
105
|
+
return { ok, failed, total: results.length };
|
|
106
|
+
}
|