@thinremote/thinr-cli 1.3.0 → 1.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +33 -0
- package/lib/alarms.js +11 -40
- package/lib/auth.js +4 -3
- package/lib/bucket.js +3 -8
- package/lib/command-stream.js +2 -1
- package/lib/config.js +9 -0
- package/lib/console.js +9 -7
- package/lib/dashboard/event-stream.js +11 -4
- package/lib/device-api.js +6 -4
- package/lib/devices.js +4 -10
- package/lib/mcp/tools-properties.js +3 -3
- package/lib/mcp/tools-resources.js +4 -2
- package/lib/mcp/tools-scripts.js +5 -5
- package/lib/monitoring.js +4 -10
- package/lib/paths.js +32 -0
- package/lib/playbook/actions.js +35 -17
- package/lib/product.js +1 -9
- package/lib/profile.js +1 -9
- package/lib/property.js +17 -27
- package/lib/resource.js +26 -29
- package/lib/status.js +11 -21
- package/lib/storage.js +1 -12
- package/lib/tokens.js +1 -9
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,38 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [1.3.2] - 2026-09-24
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
|
|
7
|
+
- Device properties and device status now honour the `user` argument.
|
|
8
|
+
`thinr_property_get`, `thinr_property_set` and the playbook `property`
|
|
9
|
+
actions accepted it but addressed the active profile's own account, the
|
|
10
|
+
same gap fixed for resources in 1.3.1.
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
|
|
14
|
+
- Account-scoped API paths are built in one place (`lib/paths.js`): the
|
|
15
|
+
`/v{1,2,3}/users/{account}` prefixes and the device prefix, all resolving
|
|
16
|
+
the account through a single `resolveUser`. Five modules carried private
|
|
17
|
+
copies of the same three lines and four more interpolated the account by
|
|
18
|
+
hand. A test fails the build if any module starts doing that again, which
|
|
19
|
+
is what keeps the account from being silently dropped: omitting it
|
|
20
|
+
produces a URL that works against your own account and only breaks when
|
|
21
|
+
acting on behalf of another.
|
|
22
|
+
- Not-found errors from properties and status name the account they looked
|
|
23
|
+
in, matching the resource helpers.
|
|
24
|
+
|
|
25
|
+
## [1.3.1] - 2026-09-24
|
|
26
|
+
|
|
27
|
+
### Fixed
|
|
28
|
+
|
|
29
|
+
- Device resources now honour the `user` argument, so an admin acting on
|
|
30
|
+
another account reaches that account's devices. `thinr_resource_list`,
|
|
31
|
+
`thinr_resource_call` and the `thinr_script_*` tools accepted the
|
|
32
|
+
argument but addressed the active profile's own account, which answered
|
|
33
|
+
"Device not found"; playbook `resource` and script actions had the same
|
|
34
|
+
gap. Not-found errors now name the account they looked in.
|
|
35
|
+
|
|
3
36
|
## [1.3.0] - 2026-09-13
|
|
4
37
|
|
|
5
38
|
### Added
|
package/lib/alarms.js
CHANGED
|
@@ -1,10 +1,6 @@
|
|
|
1
1
|
import api from './api.js';
|
|
2
2
|
import { apiError } from './errors.js';
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
function resolveUser(user) {
|
|
6
|
-
return user || requireConfig().username;
|
|
7
|
-
}
|
|
3
|
+
import { v1 } from './paths.js';
|
|
8
4
|
|
|
9
5
|
// Backend enums (see backend/src/thinger/features/alarms/alarm_rule.hpp).
|
|
10
6
|
// Exported so the dashboard can render labels/colors without re-deriving
|
|
@@ -114,7 +110,6 @@ export async function getAlarmInstances({
|
|
|
114
110
|
sort,
|
|
115
111
|
order,
|
|
116
112
|
} = {}) {
|
|
117
|
-
const apiUser = resolveUser(user);
|
|
118
113
|
const params = new URLSearchParams();
|
|
119
114
|
params.set('count', String(count));
|
|
120
115
|
params.set('index', String(index));
|
|
@@ -124,9 +119,7 @@ export async function getAlarmInstances({
|
|
|
124
119
|
appendMulti(params, 'state', state);
|
|
125
120
|
appendMulti(params, 'severity', severity);
|
|
126
121
|
try {
|
|
127
|
-
const res = await api.get(
|
|
128
|
-
`/v1/users/${apiUser}/alarms/instances?${params.toString()}`,
|
|
129
|
-
);
|
|
122
|
+
const res = await api.get(`${v1(user)}/alarms/instances?${params.toString()}`);
|
|
130
123
|
return Array.isArray(res.data) ? res.data : [];
|
|
131
124
|
} catch (e) {
|
|
132
125
|
throw apiError(e);
|
|
@@ -137,9 +130,8 @@ export async function getAlarmInstances({
|
|
|
137
130
|
// server seeds zeros for every known state so consumers can render a
|
|
138
131
|
// stable layout without null-checks.
|
|
139
132
|
export async function getAlarmInstanceStats({ user = null } = {}) {
|
|
140
|
-
const apiUser = resolveUser(user);
|
|
141
133
|
try {
|
|
142
|
-
const res = await api.get(
|
|
134
|
+
const res = await api.get(`${v1(user)}/alarms/instances/stats`);
|
|
143
135
|
return res.data || {};
|
|
144
136
|
} catch (e) {
|
|
145
137
|
throw apiError(e);
|
|
@@ -147,11 +139,8 @@ export async function getAlarmInstanceStats({ user = null } = {}) {
|
|
|
147
139
|
}
|
|
148
140
|
|
|
149
141
|
export async function getAlarmInstance(instance, { user = null } = {}) {
|
|
150
|
-
const apiUser = resolveUser(user);
|
|
151
142
|
try {
|
|
152
|
-
const res = await api.get(
|
|
153
|
-
`/v1/users/${apiUser}/alarms/instances/${encodeURIComponent(instance)}`,
|
|
154
|
-
);
|
|
143
|
+
const res = await api.get(`${v1(user)}/alarms/instances/${encodeURIComponent(instance)}`);
|
|
155
144
|
return res.data;
|
|
156
145
|
} catch (e) {
|
|
157
146
|
throw apiError(e, { notFound: `Alarm instance not found: ${instance}` });
|
|
@@ -162,10 +151,9 @@ export async function getAlarmInstance(instance, { user = null } = {}) {
|
|
|
162
151
|
// (see UPDATE_SCHEMA in alarms_instances.cpp): name, description, state,
|
|
163
152
|
// annotation, reactivation. Anything else is rejected.
|
|
164
153
|
export async function updateAlarmInstance(instance, body, { user = null } = {}) {
|
|
165
|
-
const apiUser = resolveUser(user);
|
|
166
154
|
try {
|
|
167
155
|
const res = await api.put(
|
|
168
|
-
|
|
156
|
+
`${v1(user)}/alarms/instances/${encodeURIComponent(instance)}`,
|
|
169
157
|
body,
|
|
170
158
|
);
|
|
171
159
|
return res.data;
|
|
@@ -175,11 +163,8 @@ export async function updateAlarmInstance(instance, body, { user = null } = {})
|
|
|
175
163
|
}
|
|
176
164
|
|
|
177
165
|
export async function deleteAlarmInstance(instance, { user = null } = {}) {
|
|
178
|
-
const apiUser = resolveUser(user);
|
|
179
166
|
try {
|
|
180
|
-
await api.delete(
|
|
181
|
-
`/v1/users/${apiUser}/alarms/instances/${encodeURIComponent(instance)}`,
|
|
182
|
-
);
|
|
167
|
+
await api.delete(`${v1(user)}/alarms/instances/${encodeURIComponent(instance)}`);
|
|
183
168
|
return true;
|
|
184
169
|
} catch (e) {
|
|
185
170
|
throw apiError(e, { notFound: `Alarm instance not found: ${instance}` });
|
|
@@ -187,14 +172,11 @@ export async function deleteAlarmInstance(instance, { user = null } = {}) {
|
|
|
187
172
|
}
|
|
188
173
|
|
|
189
174
|
export async function getAlarmRules({ user = null, count = 200, index = 0 } = {}) {
|
|
190
|
-
const apiUser = resolveUser(user);
|
|
191
175
|
const params = new URLSearchParams();
|
|
192
176
|
params.set('count', String(count));
|
|
193
177
|
params.set('index', String(index));
|
|
194
178
|
try {
|
|
195
|
-
const res = await api.get(
|
|
196
|
-
`/v1/users/${apiUser}/alarms/rules?${params.toString()}`,
|
|
197
|
-
);
|
|
179
|
+
const res = await api.get(`${v1(user)}/alarms/rules?${params.toString()}`);
|
|
198
180
|
return Array.isArray(res.data) ? res.data : [];
|
|
199
181
|
} catch (e) {
|
|
200
182
|
throw apiError(e);
|
|
@@ -202,11 +184,8 @@ export async function getAlarmRules({ user = null, count = 200, index = 0 } = {}
|
|
|
202
184
|
}
|
|
203
185
|
|
|
204
186
|
export async function getAlarmRule(rule, { user = null } = {}) {
|
|
205
|
-
const apiUser = resolveUser(user);
|
|
206
187
|
try {
|
|
207
|
-
const res = await api.get(
|
|
208
|
-
`/v1/users/${apiUser}/alarms/rules/${encodeURIComponent(rule)}`,
|
|
209
|
-
);
|
|
188
|
+
const res = await api.get(`${v1(user)}/alarms/rules/${encodeURIComponent(rule)}`);
|
|
210
189
|
return res.data;
|
|
211
190
|
} catch (e) {
|
|
212
191
|
throw apiError(e, { notFound: `Alarm rule not found: ${rule}` });
|
|
@@ -217,9 +196,8 @@ export async function getAlarmRule(rule, { user = null } = {}) {
|
|
|
217
196
|
// `name`, `enabled` and an optional `config` object — see CREATE_SCHEMA
|
|
218
197
|
// in alarms_rules.cpp for the full shape.
|
|
219
198
|
export async function createAlarmRule(body, { user = null } = {}) {
|
|
220
|
-
const apiUser = resolveUser(user);
|
|
221
199
|
try {
|
|
222
|
-
const res = await api.post(
|
|
200
|
+
const res = await api.post(`${v1(user)}/alarms/rules`, body);
|
|
223
201
|
return res.data;
|
|
224
202
|
} catch (e) {
|
|
225
203
|
throw apiError(e);
|
|
@@ -227,12 +205,8 @@ export async function createAlarmRule(body, { user = null } = {}) {
|
|
|
227
205
|
}
|
|
228
206
|
|
|
229
207
|
export async function updateAlarmRule(rule, body, { user = null } = {}) {
|
|
230
|
-
const apiUser = resolveUser(user);
|
|
231
208
|
try {
|
|
232
|
-
const res = await api.put(
|
|
233
|
-
`/v1/users/${apiUser}/alarms/rules/${encodeURIComponent(rule)}`,
|
|
234
|
-
body,
|
|
235
|
-
);
|
|
209
|
+
const res = await api.put(`${v1(user)}/alarms/rules/${encodeURIComponent(rule)}`, body);
|
|
236
210
|
return res.data;
|
|
237
211
|
} catch (e) {
|
|
238
212
|
throw apiError(e, { notFound: `Alarm rule not found: ${rule}` });
|
|
@@ -240,11 +214,8 @@ export async function updateAlarmRule(rule, body, { user = null } = {}) {
|
|
|
240
214
|
}
|
|
241
215
|
|
|
242
216
|
export async function deleteAlarmRule(rule, { user = null } = {}) {
|
|
243
|
-
const apiUser = resolveUser(user);
|
|
244
217
|
try {
|
|
245
|
-
await api.delete(
|
|
246
|
-
`/v1/users/${apiUser}/alarms/rules/${encodeURIComponent(rule)}`,
|
|
247
|
-
);
|
|
218
|
+
await api.delete(`${v1(user)}/alarms/rules/${encodeURIComponent(rule)}`);
|
|
248
219
|
return true;
|
|
249
220
|
} catch (e) {
|
|
250
221
|
throw apiError(e, { notFound: `Alarm rule not found: ${rule}` });
|
package/lib/auth.js
CHANGED
|
@@ -7,6 +7,7 @@ import { label, success, warning, error as errorStyle, info } from './format.js'
|
|
|
7
7
|
|
|
8
8
|
import api, { setBaseURL } from './api.js';
|
|
9
9
|
import { OAUTH, TIMEOUTS } from './constants.js';
|
|
10
|
+
import { v1 } from './paths.js';
|
|
10
11
|
|
|
11
12
|
const OAUTH_CLIENT_ID = OAUTH.CLIENT_ID;
|
|
12
13
|
const OAUTH_DEVICE_CODE_TIMEOUT_MS = TIMEOUTS.OAUTH_DEVICE_CODE_MS;
|
|
@@ -89,14 +90,14 @@ async function authenticateWithUserPass() {
|
|
|
89
90
|
// Replace any existing token with a fresh permanent one
|
|
90
91
|
spinner.text = 'Preparing user token...';
|
|
91
92
|
try {
|
|
92
|
-
await api.delete(
|
|
93
|
+
await api.delete(`${v1(username)}/tokens/ThinRemote`);
|
|
93
94
|
} catch (error) {
|
|
94
95
|
if (error.response && error.response.status !== 404) throw error;
|
|
95
96
|
}
|
|
96
97
|
|
|
97
98
|
spinner.text = 'Creating new user token...';
|
|
98
99
|
const createTokenResponse = await api.post(
|
|
99
|
-
|
|
100
|
+
`${v1(username)}/tokens`,
|
|
100
101
|
{
|
|
101
102
|
enabled: true,
|
|
102
103
|
allow: { '*': { '*': '*' } },
|
|
@@ -234,7 +235,7 @@ export async function deleteToken() {
|
|
|
234
235
|
const spinner = ora('Deleting user token...').start();
|
|
235
236
|
|
|
236
237
|
try {
|
|
237
|
-
await api.delete(
|
|
238
|
+
await api.delete(`${v1(config.username)}/tokens/ThinRemote`);
|
|
238
239
|
spinner.succeed('Token deleted successfully');
|
|
239
240
|
} catch (error) {
|
|
240
241
|
if (error.response && error.response.status === 404) {
|
package/lib/bucket.js
CHANGED
|
@@ -1,10 +1,6 @@
|
|
|
1
1
|
import api from './api.js';
|
|
2
2
|
import { apiError } from './errors.js';
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
function resolveUser(user) {
|
|
6
|
-
return user || requireConfig().username;
|
|
7
|
-
}
|
|
3
|
+
import { v2, v3 } from './paths.js';
|
|
8
4
|
|
|
9
5
|
/**
|
|
10
6
|
* Query an arbitrary time-series bucket declared in a product profile.
|
|
@@ -60,7 +56,6 @@ export async function getBucketData({
|
|
|
60
56
|
group_by,
|
|
61
57
|
} = {}) {
|
|
62
58
|
if (!bucket) throw new Error('bucket is required');
|
|
63
|
-
const apiUser = resolveUser(user);
|
|
64
59
|
const params = { items, sort };
|
|
65
60
|
if (minutes) {
|
|
66
61
|
params.min_ts = Date.now() - minutes * 60 * 1000;
|
|
@@ -75,8 +70,8 @@ export async function getBucketData({
|
|
|
75
70
|
if (fields) params.fields = fields;
|
|
76
71
|
|
|
77
72
|
const url = device
|
|
78
|
-
?
|
|
79
|
-
:
|
|
73
|
+
? `${v3(user)}/devices/${device}/buckets/${bucket}/data`
|
|
74
|
+
: `${v2(user)}/buckets/${bucket}/data`;
|
|
80
75
|
|
|
81
76
|
try {
|
|
82
77
|
const res = await api.get(url, { params });
|
package/lib/command-stream.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import WebSocket from 'ws';
|
|
3
3
|
import { randomUUID } from 'crypto';
|
|
4
4
|
import { TIMEOUTS } from './constants.js';
|
|
5
|
+
import { device } from './paths.js';
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* One-shot execution of a shell command on a device over the IOTMP
|
|
@@ -79,7 +80,7 @@ export class CommandStream {
|
|
|
79
80
|
const { server, apiUser, deviceId, cmd, timeout } = this.opts;
|
|
80
81
|
const params = new URLSearchParams({ cmd });
|
|
81
82
|
if (timeout && timeout > 0) params.set('timeout', String(timeout));
|
|
82
|
-
return `wss://${server}
|
|
83
|
+
return `wss://${server}${device(apiUser, deviceId)}/resources/${this.resource}?${params}`;
|
|
83
84
|
}
|
|
84
85
|
|
|
85
86
|
_openSocket() {
|
package/lib/config.js
CHANGED
|
@@ -164,6 +164,15 @@ export function readConfig() {
|
|
|
164
164
|
* via classifyError. Replaces the same boilerplate that used to live
|
|
165
165
|
* at the top of every helper in lib/.
|
|
166
166
|
*/
|
|
167
|
+
/**
|
|
168
|
+
* The account an API call is addressed to: an explicit one when acting on
|
|
169
|
+
* behalf of another account, the active profile's own otherwise. Single
|
|
170
|
+
* source of truth — every path in lib/paths.js goes through it.
|
|
171
|
+
*/
|
|
172
|
+
export function resolveUser(user) {
|
|
173
|
+
return user || requireConfig().username;
|
|
174
|
+
}
|
|
175
|
+
|
|
167
176
|
export function requireConfig() {
|
|
168
177
|
const config = readConfig();
|
|
169
178
|
if (!config.token || !config.server || !config.username) {
|
package/lib/console.js
CHANGED
|
@@ -3,6 +3,7 @@ import { requireConfig } from './config.js';
|
|
|
3
3
|
import { ensureFreshToken } from './auth.js';
|
|
4
4
|
import api from './api.js';
|
|
5
5
|
import { warning, error as errorStyle } from './format.js';
|
|
6
|
+
import { device } from './paths.js';
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* Creates a WebSocket connection to a device terminal. Returns a
|
|
@@ -14,9 +15,10 @@ import { warning, error as errorStyle } from './format.js';
|
|
|
14
15
|
* never calls process.exit.
|
|
15
16
|
*
|
|
16
17
|
* @param {string} deviceId - The ID of the device to connect to
|
|
18
|
+
* @param {string} [user] - Account that owns the device (defaults to the active profile)
|
|
17
19
|
* @returns {Promise<number>} Exit code (0 on clean close)
|
|
18
20
|
*/
|
|
19
|
-
export async function connectToDeviceConsole(deviceId) {
|
|
21
|
+
export async function connectToDeviceConsole(deviceId, user) {
|
|
20
22
|
const config = requireConfig();
|
|
21
23
|
|
|
22
24
|
// WS handshakes lack the refresh-on-401 retry that api.js gives
|
|
@@ -25,7 +27,7 @@ export async function connectToDeviceConsole(deviceId) {
|
|
|
25
27
|
const token = (await ensureFreshToken()) || config.token;
|
|
26
28
|
|
|
27
29
|
const sessionId = createUUID();
|
|
28
|
-
const wsUrl = `wss://${config.server}
|
|
30
|
+
const wsUrl = `wss://${config.server}${device(user, deviceId)}/resources/$terminal/${sessionId}?raw=1`;
|
|
29
31
|
const ws = new WebSocket(wsUrl, {
|
|
30
32
|
headers: { Authorization: `Bearer ${token}` },
|
|
31
33
|
});
|
|
@@ -46,7 +48,7 @@ export async function connectToDeviceConsole(deviceId) {
|
|
|
46
48
|
return new Promise((resolve, reject) => {
|
|
47
49
|
ws.on('open', () => {
|
|
48
50
|
updateTerminalSize(
|
|
49
|
-
|
|
51
|
+
user,
|
|
50
52
|
deviceId,
|
|
51
53
|
sessionId,
|
|
52
54
|
process.stdout.columns,
|
|
@@ -55,7 +57,7 @@ export async function connectToDeviceConsole(deviceId) {
|
|
|
55
57
|
|
|
56
58
|
process.stdout.on('resize', () => {
|
|
57
59
|
updateTerminalSize(
|
|
58
|
-
|
|
60
|
+
user,
|
|
59
61
|
deviceId,
|
|
60
62
|
sessionId,
|
|
61
63
|
process.stdout.columns,
|
|
@@ -130,15 +132,15 @@ export async function connectToDeviceConsole(deviceId) {
|
|
|
130
132
|
|
|
131
133
|
/**
|
|
132
134
|
* Update terminal size on remote device
|
|
133
|
-
* @param {
|
|
135
|
+
* @param {string|undefined} user - Account that owns the device
|
|
134
136
|
* @param {string} deviceId - Device ID
|
|
135
137
|
* @param {string} sessionId - Session ID
|
|
136
138
|
* @param {number} cols - Number of columns
|
|
137
139
|
* @param {number} rows - Number of rows
|
|
138
140
|
*/
|
|
139
|
-
async function updateTerminalSize(
|
|
141
|
+
async function updateTerminalSize(user, deviceId, sessionId, cols, rows) {
|
|
140
142
|
try {
|
|
141
|
-
const url =
|
|
143
|
+
const url = `${device(user, deviceId)}/resources/$terminal/${sessionId}/params`;
|
|
142
144
|
|
|
143
145
|
await api.post(url, {
|
|
144
146
|
size: {
|
|
@@ -3,6 +3,7 @@ import WebSocket from 'ws';
|
|
|
3
3
|
import { readConfig } from '../config.js';
|
|
4
4
|
import { refreshToken } from '../auth.js';
|
|
5
5
|
import { debugCount, debugLog } from '../debug-log.js';
|
|
6
|
+
import { v2 } from '../paths.js';
|
|
6
7
|
|
|
7
8
|
// Single shared websocket to /v2/users/{user}/events. Both fleet
|
|
8
9
|
// monitoring and product metrics live on the same endpoint with the
|
|
@@ -99,14 +100,18 @@ class EventStream {
|
|
|
99
100
|
// background; await it here so the next attempt uses the fresh
|
|
100
101
|
// token instead of racing the HTTP interceptor's refresh.
|
|
101
102
|
if (this._refreshing) {
|
|
102
|
-
try {
|
|
103
|
+
try {
|
|
104
|
+
await this._refreshing;
|
|
105
|
+
} catch {
|
|
106
|
+
/* fall through; readConfig may still have a usable token */
|
|
107
|
+
}
|
|
103
108
|
}
|
|
104
109
|
if (this.disposed) return;
|
|
105
110
|
const config = readConfig();
|
|
106
111
|
if (!config?.server || !config?.token || !config?.username) return;
|
|
107
|
-
const url = `wss://${config.server}
|
|
112
|
+
const url = `wss://${config.server}${v2(config.username)}/events?authorization=${config.token}`;
|
|
108
113
|
debugLog('ws:events', 'connecting', {
|
|
109
|
-
endpoint: `wss://${config.server}
|
|
114
|
+
endpoint: `wss://${config.server}${v2(config.username)}/events`,
|
|
110
115
|
pending_subs: this.subs.size,
|
|
111
116
|
});
|
|
112
117
|
const ws = new WebSocket(url);
|
|
@@ -132,7 +137,9 @@ class EventStream {
|
|
|
132
137
|
// fully resubscribed socket — they can rely on new events landing.
|
|
133
138
|
if (isReconnect) {
|
|
134
139
|
for (const h of this.reconnectHandlers) {
|
|
135
|
-
try {
|
|
140
|
+
try {
|
|
141
|
+
h();
|
|
142
|
+
} catch (err) {
|
|
136
143
|
debugLog('ws:events', 'reconnect-handler-error', {
|
|
137
144
|
error: err?.message || String(err),
|
|
138
145
|
});
|
package/lib/device-api.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import api from './api.js';
|
|
2
|
-
import { requireConfig } from './config.js';
|
|
2
|
+
import { requireConfig, resolveUser } from './config.js';
|
|
3
3
|
import { BYTES, TIMEOUTS } from './constants.js';
|
|
4
4
|
import { CommandStream } from './command-stream.js';
|
|
5
5
|
import { ensureFreshToken, refreshToken } from './auth.js';
|
|
6
6
|
import { Readable } from 'node:stream';
|
|
7
|
+
import { device } from './paths.js';
|
|
7
8
|
|
|
8
9
|
// Directory endpoints on the agent expect a trailing slash so it can
|
|
9
10
|
// distinguish "list this directory" from "read a file with this name".
|
|
@@ -16,9 +17,10 @@ const withLeadingSlash = (p) => (p.startsWith('/') ? p : `/${p}`);
|
|
|
16
17
|
|
|
17
18
|
export function createDeviceAPI(deviceId, options = {}) {
|
|
18
19
|
const config = requireConfig();
|
|
19
|
-
const apiUser = options.user
|
|
20
|
-
const
|
|
21
|
-
const
|
|
20
|
+
const apiUser = resolveUser(options.user);
|
|
21
|
+
const base = device(apiUser, deviceId);
|
|
22
|
+
const resourcePath = `${base}/resources`;
|
|
23
|
+
const filesPath = `${base}/files`;
|
|
22
24
|
|
|
23
25
|
async function callResource(resource, payload = null, opts = {}) {
|
|
24
26
|
const url = `${resourcePath}/${resource}`;
|
package/lib/devices.js
CHANGED
|
@@ -1,10 +1,8 @@
|
|
|
1
|
-
import { requireConfig } from './config.js';
|
|
2
1
|
import api from './api.js';
|
|
3
2
|
import { apiError } from './errors.js';
|
|
3
|
+
import { v1 } from './paths.js';
|
|
4
4
|
|
|
5
5
|
export async function getDevices(filter, user = null) {
|
|
6
|
-
const config = requireConfig();
|
|
7
|
-
const apiUser = user || config.username;
|
|
8
6
|
let devices = [];
|
|
9
7
|
|
|
10
8
|
let queryParametersString = '';
|
|
@@ -14,7 +12,7 @@ export async function getDevices(filter, user = null) {
|
|
|
14
12
|
|
|
15
13
|
try {
|
|
16
14
|
const count = 50;
|
|
17
|
-
const url =
|
|
15
|
+
const url = `${v1(user)}/devices?count=${count}&${queryParametersString}`;
|
|
18
16
|
let index = 0;
|
|
19
17
|
let res_length = 0;
|
|
20
18
|
|
|
@@ -37,10 +35,8 @@ export async function getDevices(filter, user = null) {
|
|
|
37
35
|
// connection state…). For device-side data — system_info, monitoring —
|
|
38
36
|
// hit the agent resources directly instead.
|
|
39
37
|
export async function getDevice(deviceId, user = null) {
|
|
40
|
-
const config = requireConfig();
|
|
41
|
-
const apiUser = user || config.username;
|
|
42
38
|
try {
|
|
43
|
-
const response = await api.get(
|
|
39
|
+
const response = await api.get(`${v1(user)}/devices/${deviceId}`);
|
|
44
40
|
return response.data || {};
|
|
45
41
|
} catch (error) {
|
|
46
42
|
throw apiError(error, { notFound: `Device not found: ${deviceId}` });
|
|
@@ -50,10 +46,8 @@ export async function getDevice(deviceId, user = null) {
|
|
|
50
46
|
// Idempotent: returns true if the device record was removed, false if it
|
|
51
47
|
// was already absent. Other failures (auth, network) propagate as apiError.
|
|
52
48
|
export async function deleteDevice(deviceId, user = null) {
|
|
53
|
-
const config = requireConfig();
|
|
54
|
-
const apiUser = user || config.username;
|
|
55
49
|
try {
|
|
56
|
-
await api.delete(
|
|
50
|
+
await api.delete(`${v1(user)}/devices/${deviceId}`);
|
|
57
51
|
return true;
|
|
58
52
|
} catch (error) {
|
|
59
53
|
if (error.response?.status === 404) return false;
|
|
@@ -7,13 +7,13 @@ async function toolPropertyGet(args) {
|
|
|
7
7
|
if (!device)
|
|
8
8
|
throw inputError('No device specified. Use thinr_devices to list available devices.');
|
|
9
9
|
if (args.property) {
|
|
10
|
-
const value = await getDeviceProperty(device, args.property);
|
|
10
|
+
const value = await getDeviceProperty(device, args.property, args.user);
|
|
11
11
|
return {
|
|
12
12
|
content: [{ type: 'text', text: JSON.stringify(value, null, 2) }],
|
|
13
13
|
isError: false,
|
|
14
14
|
};
|
|
15
15
|
}
|
|
16
|
-
const list = await getDeviceProperties(device);
|
|
16
|
+
const list = await getDeviceProperties(device, args.user);
|
|
17
17
|
const names = Array.isArray(list) ? list.map((p) => p.property).filter(Boolean) : [];
|
|
18
18
|
return {
|
|
19
19
|
content: [{ type: 'text', text: names.length ? names.join('\n') : '(no properties)' }],
|
|
@@ -27,7 +27,7 @@ async function toolPropertySet(args) {
|
|
|
27
27
|
throw inputError('No device specified. Use thinr_devices to list available devices.');
|
|
28
28
|
if (!args.property) throw inputError('property is required');
|
|
29
29
|
if (args.value === undefined) throw inputError('value is required');
|
|
30
|
-
const saved = await setDeviceProperty(device, args.property, args.value);
|
|
30
|
+
const saved = await setDeviceProperty(device, args.property, args.value, args.user);
|
|
31
31
|
return {
|
|
32
32
|
content: [
|
|
33
33
|
{
|
|
@@ -6,7 +6,7 @@ async function toolResourceList(args) {
|
|
|
6
6
|
const device = args.device;
|
|
7
7
|
if (!device)
|
|
8
8
|
throw inputError('No device specified. Use thinr_devices to list available devices.');
|
|
9
|
-
const entries = await listDeviceResourcesWithSchemas(device);
|
|
9
|
+
const entries = await listDeviceResourcesWithSchemas(device, { user: args.user });
|
|
10
10
|
const fnLabel = (f) =>
|
|
11
11
|
({ 1: 'no params', 2: 'input', 3: 'output', 4: 'input/output' })[f] || 'unknown';
|
|
12
12
|
const lines = entries.map((e) => {
|
|
@@ -26,7 +26,9 @@ async function toolResourceCall(args) {
|
|
|
26
26
|
if (!device)
|
|
27
27
|
throw inputError('No device specified. Use thinr_devices to list available devices.');
|
|
28
28
|
if (!args.resource) throw inputError('resource is required');
|
|
29
|
-
const result = await callDeviceResource(device, args.resource, args.inputs
|
|
29
|
+
const result = await callDeviceResource(device, args.resource, args.inputs, {
|
|
30
|
+
user: args.user,
|
|
31
|
+
});
|
|
30
32
|
return {
|
|
31
33
|
content: [
|
|
32
34
|
{
|
package/lib/mcp/tools-scripts.js
CHANGED
|
@@ -7,7 +7,7 @@ async function toolScriptList(args) {
|
|
|
7
7
|
const device = args.device;
|
|
8
8
|
if (!device)
|
|
9
9
|
throw inputError('No device specified. Use thinr_devices to list available devices.');
|
|
10
|
-
const info = await readDeviceResource(device, '$scripts/info');
|
|
10
|
+
const info = await readDeviceResource(device, '$scripts/info', { user: args.user });
|
|
11
11
|
const scripts = Array.isArray(info?.scripts) ? info.scripts : [];
|
|
12
12
|
const header = `Scripts directory: ${info?.path || '(unknown)'}\n${scripts.length} script(s) registered:`;
|
|
13
13
|
const entries = scripts.map((s) => {
|
|
@@ -33,7 +33,7 @@ async function toolScriptWrite(args) {
|
|
|
33
33
|
|
|
34
34
|
const { api: deviceApi } = getAPI(device, args.user);
|
|
35
35
|
|
|
36
|
-
const info = await readDeviceResource(device, '$scripts/info');
|
|
36
|
+
const info = await readDeviceResource(device, '$scripts/info', { user: args.user });
|
|
37
37
|
const baseDir = info?.path;
|
|
38
38
|
if (!baseDir)
|
|
39
39
|
throw new Error('Agent did not return a scripts directory (is $scripts/info supported?)');
|
|
@@ -52,7 +52,7 @@ async function toolScriptWrite(args) {
|
|
|
52
52
|
throw new Error(`chmod +x failed: ${chmod.stderr || `exit ${chmod.retcode}`}`);
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
-
const reloaded = await callDeviceResource(device, '$scripts/reload', {});
|
|
55
|
+
const reloaded = await callDeviceResource(device, '$scripts/reload', {}, { user: args.user });
|
|
56
56
|
// The agent registers resources by the file's stem, so "battery.sh"
|
|
57
57
|
// becomes scripts/battery. Match the same way to echo back the schema.
|
|
58
58
|
const registeredName = args.name.replace(/\.[^./]+$/, '');
|
|
@@ -82,7 +82,7 @@ async function toolScriptDelete(args) {
|
|
|
82
82
|
if (!args.name) throw inputError('name is required');
|
|
83
83
|
|
|
84
84
|
const { api: deviceApi } = getAPI(device, args.user);
|
|
85
|
-
const info = await readDeviceResource(device, '$scripts/info');
|
|
85
|
+
const info = await readDeviceResource(device, '$scripts/info', { user: args.user });
|
|
86
86
|
const baseDir = info?.path;
|
|
87
87
|
if (!baseDir)
|
|
88
88
|
throw new Error('Agent did not return a scripts directory (is $scripts/info supported?)');
|
|
@@ -93,7 +93,7 @@ async function toolScriptDelete(args) {
|
|
|
93
93
|
const scriptPath = `${baseDir}/${args.name}`;
|
|
94
94
|
|
|
95
95
|
await deviceApi.delete(scriptPath, false);
|
|
96
|
-
const reloaded = await callDeviceResource(device, '$scripts/reload', {});
|
|
96
|
+
const reloaded = await callDeviceResource(device, '$scripts/reload', {}, { user: args.user });
|
|
97
97
|
const remaining = Array.isArray(reloaded?.scripts) ? reloaded.scripts.length : 0;
|
|
98
98
|
return {
|
|
99
99
|
content: [
|
package/lib/monitoring.js
CHANGED
|
@@ -1,10 +1,6 @@
|
|
|
1
1
|
import api from './api.js';
|
|
2
2
|
import { apiError } from './errors.js';
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
function resolveUser(user) {
|
|
6
|
-
return user || requireConfig().username;
|
|
7
|
-
}
|
|
3
|
+
import { v2, v3 } from './paths.js';
|
|
8
4
|
|
|
9
5
|
/**
|
|
10
6
|
* Query the monitoring bucket. When `device` is provided, scopes to
|
|
@@ -55,7 +51,6 @@ export async function getMonitoringData({
|
|
|
55
51
|
fields,
|
|
56
52
|
group_by,
|
|
57
53
|
} = {}) {
|
|
58
|
-
const apiUser = resolveUser(user);
|
|
59
54
|
const params = { items, sort };
|
|
60
55
|
if (minutes) {
|
|
61
56
|
params.min_ts = Date.now() - minutes * 60 * 1000;
|
|
@@ -80,13 +75,12 @@ export async function getMonitoringData({
|
|
|
80
75
|
.split(',')
|
|
81
76
|
.map((s) => s.trim())
|
|
82
77
|
.includes('device');
|
|
83
|
-
params.fields =
|
|
84
|
-
!device && !effectiveGroupBy && !hasDevice ? `device,${fields}` : fields;
|
|
78
|
+
params.fields = !device && !effectiveGroupBy && !hasDevice ? `device,${fields}` : fields;
|
|
85
79
|
}
|
|
86
80
|
|
|
87
81
|
const url = device
|
|
88
|
-
?
|
|
89
|
-
:
|
|
82
|
+
? `${v3(user)}/devices/${device}/buckets/monitoring/data`
|
|
83
|
+
: `${v2(user)}/buckets/monitoring/data`;
|
|
90
84
|
|
|
91
85
|
try {
|
|
92
86
|
const res = await api.get(url, { params });
|
package/lib/paths.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import { resolveUser } from './config.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Account-scoped base paths, one per API version.
|
|
6
|
+
*
|
|
7
|
+
* Every endpoint the CLI talks to hangs off `/v{N}/users/{account}`, and
|
|
8
|
+
* the account is the part that is easy to get wrong: leaving it out still
|
|
9
|
+
* produces a valid URL, one that works against your own account and fails
|
|
10
|
+
* only when acting on behalf of another — with a "not found" that points
|
|
11
|
+
* nowhere near the real problem. Building the prefix in one place keeps
|
|
12
|
+
* the account in the signature, where it is visible, and `tests/paths.test.js`
|
|
13
|
+
* fails the build if a module starts hand-rolling these again.
|
|
14
|
+
*
|
|
15
|
+
* Pass `user` to address another account; omit it for the active profile.
|
|
16
|
+
*/
|
|
17
|
+
export function v1(user) {
|
|
18
|
+
return `/v1/users/${resolveUser(user)}`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function v2(user) {
|
|
22
|
+
return `/v2/users/${resolveUser(user)}`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function v3(user) {
|
|
26
|
+
return `/v3/users/${resolveUser(user)}`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** `/v3/users/{account}/devices/{deviceId}` — the most common prefix. */
|
|
30
|
+
export function device(user, deviceId) {
|
|
31
|
+
return `${v3(user)}/devices/${deviceId}`;
|
|
32
|
+
}
|
package/lib/playbook/actions.js
CHANGED
|
@@ -30,7 +30,10 @@ const MAX_CAPTURED_STREAM_BYTES = 32 * 1024;
|
|
|
30
30
|
|
|
31
31
|
function capStream(s) {
|
|
32
32
|
if (typeof s !== 'string' || s.length <= MAX_CAPTURED_STREAM_BYTES) return s;
|
|
33
|
-
return
|
|
33
|
+
return (
|
|
34
|
+
s.slice(0, MAX_CAPTURED_STREAM_BYTES) +
|
|
35
|
+
`\n…[truncated, ${s.length - MAX_CAPTURED_STREAM_BYTES} more bytes]`
|
|
36
|
+
);
|
|
34
37
|
}
|
|
35
38
|
|
|
36
39
|
/** @type {Record<string, (ctx: any, params: any) => Promise<string | { summary: string, stdout?: string, stderr?: string, exitCode?: number }>>} */
|
|
@@ -123,16 +126,21 @@ export const HANDLERS = {
|
|
|
123
126
|
},
|
|
124
127
|
|
|
125
128
|
async property_set(ctx, params) {
|
|
126
|
-
await setDeviceProperty(ctx.deviceId, params.property, params.value);
|
|
129
|
+
await setDeviceProperty(ctx.deviceId, params.property, params.value, ctx.user);
|
|
127
130
|
return `set ${params.property}`;
|
|
128
131
|
},
|
|
129
132
|
|
|
130
133
|
async resource(ctx, params) {
|
|
131
|
-
const result = await callDeviceResource(
|
|
134
|
+
const result = await callDeviceResource(
|
|
135
|
+
ctx.deviceId,
|
|
136
|
+
params.resource,
|
|
137
|
+
params.inputs || null,
|
|
138
|
+
{
|
|
139
|
+
user: ctx.user,
|
|
140
|
+
},
|
|
141
|
+
);
|
|
132
142
|
const summary =
|
|
133
|
-
typeof result === 'string'
|
|
134
|
-
? result.slice(0, 40)
|
|
135
|
-
: JSON.stringify(result).slice(0, 40);
|
|
143
|
+
typeof result === 'string' ? result.slice(0, 40) : JSON.stringify(result).slice(0, 40);
|
|
136
144
|
return `resource ${params.resource} → ${summary}`;
|
|
137
145
|
},
|
|
138
146
|
|
|
@@ -158,7 +166,7 @@ export const HANDLERS = {
|
|
|
158
166
|
if (!/^[A-Za-z0-9._-]+$/.test(params.name)) {
|
|
159
167
|
throw new Error(`invalid script name "${params.name}"`);
|
|
160
168
|
}
|
|
161
|
-
const info = await readDeviceResource(ctx.deviceId, '$scripts/info');
|
|
169
|
+
const info = await readDeviceResource(ctx.deviceId, '$scripts/info', { user: ctx.user });
|
|
162
170
|
const baseDir = info?.path;
|
|
163
171
|
if (!baseDir) throw new Error('agent did not return a scripts directory');
|
|
164
172
|
const path = `${baseDir}/${params.name}`;
|
|
@@ -168,7 +176,7 @@ export const HANDLERS = {
|
|
|
168
176
|
TIMEOUTS.SCRIPT_CHMOD_SECONDS,
|
|
169
177
|
);
|
|
170
178
|
if (chmod.retcode !== 0) throw new Error(`chmod failed: ${chmod.stderr || chmod.retcode}`);
|
|
171
|
-
await callDeviceResource(ctx.deviceId, '$scripts/reload', {});
|
|
179
|
+
await callDeviceResource(ctx.deviceId, '$scripts/reload', {}, { user: ctx.user });
|
|
172
180
|
return `installed ${params.name}`;
|
|
173
181
|
},
|
|
174
182
|
|
|
@@ -176,11 +184,11 @@ export const HANDLERS = {
|
|
|
176
184
|
if (!/^[A-Za-z0-9._-]+$/.test(params.name)) {
|
|
177
185
|
throw new Error(`invalid script name "${params.name}"`);
|
|
178
186
|
}
|
|
179
|
-
const info = await readDeviceResource(ctx.deviceId, '$scripts/info');
|
|
187
|
+
const info = await readDeviceResource(ctx.deviceId, '$scripts/info', { user: ctx.user });
|
|
180
188
|
const baseDir = info?.path;
|
|
181
189
|
if (!baseDir) throw new Error('agent did not return a scripts directory');
|
|
182
190
|
await ctx.api.delete(`${baseDir}/${params.name}`, false);
|
|
183
|
-
await callDeviceResource(ctx.deviceId, '$scripts/reload', {});
|
|
191
|
+
await callDeviceResource(ctx.deviceId, '$scripts/reload', {}, { user: ctx.user });
|
|
184
192
|
return `removed ${params.name}`;
|
|
185
193
|
},
|
|
186
194
|
};
|
|
@@ -221,10 +229,16 @@ export const CHECKERS = {
|
|
|
221
229
|
try {
|
|
222
230
|
const info = await ctx.api.info(params.source);
|
|
223
231
|
const size = info?.size ?? '?';
|
|
224
|
-
return {
|
|
232
|
+
return {
|
|
233
|
+
status: 'changed',
|
|
234
|
+
summary: `would pull ${params.source} (${size} bytes) → ${params.destination}`,
|
|
235
|
+
};
|
|
225
236
|
} catch (err) {
|
|
226
237
|
if (err?.response?.status === 404) {
|
|
227
|
-
return {
|
|
238
|
+
return {
|
|
239
|
+
status: 'changed',
|
|
240
|
+
summary: `source ${params.source} not found on device`,
|
|
241
|
+
};
|
|
228
242
|
}
|
|
229
243
|
throw err;
|
|
230
244
|
}
|
|
@@ -236,7 +250,7 @@ export const CHECKERS = {
|
|
|
236
250
|
}
|
|
237
251
|
const content = resolveWriteContent(params, ctx.baseDir);
|
|
238
252
|
const localHash = sha256Hex(content);
|
|
239
|
-
const info = await readDeviceResource(ctx.deviceId, '$scripts/info');
|
|
253
|
+
const info = await readDeviceResource(ctx.deviceId, '$scripts/info', { user: ctx.user });
|
|
240
254
|
const baseDir = info?.path;
|
|
241
255
|
if (!baseDir) throw new Error('agent did not return a scripts directory');
|
|
242
256
|
const remotePath = `${baseDir}/${params.name}`;
|
|
@@ -261,7 +275,7 @@ export const CHECKERS = {
|
|
|
261
275
|
async property_set(ctx, params) {
|
|
262
276
|
let current;
|
|
263
277
|
try {
|
|
264
|
-
current = await getDeviceProperty(ctx.deviceId, params.property);
|
|
278
|
+
current = await getDeviceProperty(ctx.deviceId, params.property, ctx.user);
|
|
265
279
|
} catch (err) {
|
|
266
280
|
if (err?.code === 'not_found') {
|
|
267
281
|
return { status: 'changed', summary: `create property ${params.property}` };
|
|
@@ -342,7 +356,8 @@ export const CHECKERS = {
|
|
|
342
356
|
const upToDate = result?.status === 'up_to_date';
|
|
343
357
|
return {
|
|
344
358
|
status: upToDate ? 'unchanged' : 'changed',
|
|
345
|
-
summary:
|
|
359
|
+
summary:
|
|
360
|
+
`update ${params.op}: ${result?.status || 'unknown'}` +
|
|
346
361
|
(result?.latest ? ` (latest=${result.latest})` : ''),
|
|
347
362
|
};
|
|
348
363
|
},
|
|
@@ -351,7 +366,7 @@ export const CHECKERS = {
|
|
|
351
366
|
if (!/^[A-Za-z0-9._-]+$/.test(params.name)) {
|
|
352
367
|
throw new Error(`invalid script name "${params.name}"`);
|
|
353
368
|
}
|
|
354
|
-
const info = await readDeviceResource(ctx.deviceId, '$scripts/info');
|
|
369
|
+
const info = await readDeviceResource(ctx.deviceId, '$scripts/info', { user: ctx.user });
|
|
355
370
|
const baseDir = info?.path;
|
|
356
371
|
if (!baseDir) throw new Error('agent did not return a scripts directory');
|
|
357
372
|
try {
|
|
@@ -396,7 +411,10 @@ async function hashCheck(api, path, contentBuffer) {
|
|
|
396
411
|
return { status: 'changed', summary: `create ${path} (${contentBuffer.length} bytes)` };
|
|
397
412
|
}
|
|
398
413
|
if (err?.response?.status === 400) {
|
|
399
|
-
return {
|
|
414
|
+
return {
|
|
415
|
+
status: 'changed',
|
|
416
|
+
summary: `${path} is not a regular file — would overwrite`,
|
|
417
|
+
};
|
|
400
418
|
}
|
|
401
419
|
if (err?.response?.status === 405 || err?.response?.status === 501) {
|
|
402
420
|
return { status: 'unknown', summary: 'agent lacks $fs/hash — cannot predict' };
|
package/lib/product.js
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import api from './api.js';
|
|
2
2
|
import { apiError, inputError } from './errors.js';
|
|
3
|
-
import { requireConfig } from './config.js';
|
|
4
3
|
import {
|
|
5
4
|
storageExists,
|
|
6
5
|
createStorage,
|
|
@@ -12,6 +11,7 @@ import {
|
|
|
12
11
|
deleteStorageFile,
|
|
13
12
|
} from './storage.js';
|
|
14
13
|
import { parsePlaybook } from './playbook/loader.js';
|
|
14
|
+
import { v1 } from './paths.js';
|
|
15
15
|
|
|
16
16
|
/**
|
|
17
17
|
* Helpers for ThinRemote products and the product-script workflow.
|
|
@@ -28,14 +28,6 @@ import { parsePlaybook } from './playbook/loader.js';
|
|
|
28
28
|
* sequence.
|
|
29
29
|
*/
|
|
30
30
|
|
|
31
|
-
function resolveUser(user) {
|
|
32
|
-
return user || requireConfig().username;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
function v1(user) {
|
|
36
|
-
return `/v1/users/${resolveUser(user)}`;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
31
|
// ─── Low-level: products ─────────────────────────────────────────────
|
|
40
32
|
|
|
41
33
|
export async function getProducts(user) {
|
package/lib/profile.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
import api from './api.js';
|
|
3
3
|
import { apiError, inputError } from './errors.js';
|
|
4
|
-
import {
|
|
4
|
+
import { v1 } from './paths.js';
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
7
|
* Helpers for product *profile* configuration.
|
|
@@ -68,14 +68,6 @@ import { requireConfig } from './config.js';
|
|
|
68
68
|
* device code change.
|
|
69
69
|
*/
|
|
70
70
|
|
|
71
|
-
function resolveUser(user) {
|
|
72
|
-
return user || requireConfig().username;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
function v1(user) {
|
|
76
|
-
return `/v1/users/${resolveUser(user)}`;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
71
|
// ─── Listener config validators ──────────────────────────────────────
|
|
80
72
|
//
|
|
81
73
|
// Each `data.source` shape (for buckets/properties) and each request
|
package/lib/property.js
CHANGED
|
@@ -1,28 +1,23 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { resolveUser } from './config.js';
|
|
2
|
+
import { device } from './paths.js';
|
|
2
3
|
import api from './api.js';
|
|
3
4
|
import { apiError } from './errors.js';
|
|
4
5
|
import { info, success } from './format.js';
|
|
5
6
|
|
|
6
|
-
export async function getDeviceProperties(deviceId) {
|
|
7
|
-
const config = requireConfig();
|
|
8
|
-
|
|
7
|
+
export async function getDeviceProperties(deviceId, user) {
|
|
9
8
|
try {
|
|
10
|
-
const response = await api.get(
|
|
11
|
-
`/v3/users/${config.username}/devices/${deviceId}/properties`,
|
|
12
|
-
);
|
|
9
|
+
const response = await api.get(`${device(user, deviceId)}/properties`);
|
|
13
10
|
return response.data;
|
|
14
11
|
} catch (error) {
|
|
15
|
-
throw apiError(error, {
|
|
12
|
+
throw apiError(error, {
|
|
13
|
+
notFound: `Device not found: ${deviceId} (account ${resolveUser(user)})`,
|
|
14
|
+
});
|
|
16
15
|
}
|
|
17
16
|
}
|
|
18
17
|
|
|
19
|
-
export async function getDeviceProperty(deviceId, propertyId) {
|
|
20
|
-
const config = requireConfig();
|
|
21
|
-
|
|
18
|
+
export async function getDeviceProperty(deviceId, propertyId, user) {
|
|
22
19
|
try {
|
|
23
|
-
const response = await api.get(
|
|
24
|
-
`/v3/users/${config.username}/devices/${deviceId}/properties/${propertyId}`,
|
|
25
|
-
);
|
|
20
|
+
const response = await api.get(`${device(user, deviceId)}/properties/${propertyId}`);
|
|
26
21
|
return response.data.value;
|
|
27
22
|
} catch (error) {
|
|
28
23
|
throw apiError(error, {
|
|
@@ -33,17 +28,16 @@ export async function getDeviceProperty(deviceId, propertyId) {
|
|
|
33
28
|
|
|
34
29
|
// PUT so the operation is idempotent — missing properties are created,
|
|
35
30
|
// existing ones are overwritten wholesale.
|
|
36
|
-
export async function setDeviceProperty(deviceId, propertyId, value) {
|
|
37
|
-
const config = requireConfig();
|
|
38
|
-
|
|
31
|
+
export async function setDeviceProperty(deviceId, propertyId, value, user) {
|
|
39
32
|
try {
|
|
40
|
-
const response = await api.put(
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
);
|
|
33
|
+
const response = await api.put(`${device(user, deviceId)}/properties/${propertyId}`, {
|
|
34
|
+
value,
|
|
35
|
+
});
|
|
44
36
|
return response.data;
|
|
45
37
|
} catch (error) {
|
|
46
|
-
throw apiError(error, {
|
|
38
|
+
throw apiError(error, {
|
|
39
|
+
notFound: `Device not found: ${deviceId} (account ${resolveUser(user)})`,
|
|
40
|
+
});
|
|
47
41
|
}
|
|
48
42
|
}
|
|
49
43
|
|
|
@@ -59,11 +53,7 @@ export function formatDeviceProperties(deviceId, properties) {
|
|
|
59
53
|
|
|
60
54
|
export function formatDeviceProperty(deviceId, propertyId, propertyValue) {
|
|
61
55
|
let formattedValue =
|
|
62
|
-
'\nValue of property ' +
|
|
63
|
-
info(propertyId) +
|
|
64
|
-
' for device ' +
|
|
65
|
-
info(deviceId) +
|
|
66
|
-
':\n';
|
|
56
|
+
'\nValue of property ' + info(propertyId) + ' for device ' + info(deviceId) + ':\n';
|
|
67
57
|
const flattenedValue = flatten(propertyValue);
|
|
68
58
|
for (const key in flattenedValue) {
|
|
69
59
|
formattedValue += `${key}: ${success(flattenedValue[key])}\n`;
|
package/lib/resource.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { resolveUser } from './config.js';
|
|
2
|
+
import { device } from './paths.js';
|
|
2
3
|
import api from './api.js';
|
|
3
4
|
import { apiError } from './errors.js';
|
|
4
5
|
import { info, warning, hint } from './format.js';
|
|
@@ -6,20 +7,20 @@ import { info, warning, hint } from './format.js';
|
|
|
6
7
|
// Drops internal resources whose keys start with `$`; those belong to
|
|
7
8
|
// the agent's own framework (cmd, fs, scripts…) and shouldn't surface
|
|
8
9
|
// through the user-facing resource list.
|
|
9
|
-
export async function getDeviceResources(deviceId) {
|
|
10
|
-
const
|
|
10
|
+
export async function getDeviceResources(deviceId, options = {}) {
|
|
11
|
+
const user = resolveUser(options.user);
|
|
11
12
|
|
|
12
13
|
try {
|
|
13
14
|
// `/resources/api` merges firmware resources with API resources
|
|
14
15
|
// inherited from the product profile, which `/resources` alone omits.
|
|
15
|
-
const response = await api.get(
|
|
16
|
-
`/v3/users/${config.username}/devices/${deviceId}/resources/api`,
|
|
17
|
-
);
|
|
16
|
+
const response = await api.get(`${device(user, deviceId)}/resources/api`);
|
|
18
17
|
return Object.fromEntries(
|
|
19
18
|
Object.entries(response.data).filter(([key]) => !key.startsWith('$')),
|
|
20
19
|
);
|
|
21
20
|
} catch (error) {
|
|
22
|
-
throw apiError(error, {
|
|
21
|
+
throw apiError(error, {
|
|
22
|
+
notFound: `Device not found: ${deviceId} (account ${user})`,
|
|
23
|
+
});
|
|
23
24
|
}
|
|
24
25
|
}
|
|
25
26
|
|
|
@@ -28,16 +29,14 @@ export async function getDeviceResources(deviceId) {
|
|
|
28
29
|
* output-only (fn=3) or fn=4 resources when you only want the latest
|
|
29
30
|
* sample, not to trigger the input side.
|
|
30
31
|
*/
|
|
31
|
-
export async function readDeviceResource(deviceId, resourceId) {
|
|
32
|
-
const
|
|
32
|
+
export async function readDeviceResource(deviceId, resourceId, options = {}) {
|
|
33
|
+
const user = resolveUser(options.user);
|
|
33
34
|
try {
|
|
34
|
-
const response = await api.get(
|
|
35
|
-
`/v3/users/${config.username}/devices/${deviceId}/resources/${resourceId}`,
|
|
36
|
-
);
|
|
35
|
+
const response = await api.get(`${device(user, deviceId)}/resources/${resourceId}`);
|
|
37
36
|
return response.data;
|
|
38
37
|
} catch (error) {
|
|
39
38
|
throw apiError(error, {
|
|
40
|
-
notFound: `Resource not found: ${resourceId} on device ${deviceId}`,
|
|
39
|
+
notFound: `Resource not found: ${resourceId} on device ${deviceId} (account ${user})`,
|
|
41
40
|
});
|
|
42
41
|
}
|
|
43
42
|
}
|
|
@@ -51,32 +50,30 @@ export async function readDeviceResource(deviceId, resourceId) {
|
|
|
51
50
|
* return `in`; fn=1 may return an empty object. Not every agent/firmware
|
|
52
51
|
* exposes /api — callers should tolerate 404/501.
|
|
53
52
|
*/
|
|
54
|
-
export async function getDeviceResourceApi(deviceId, resourceId) {
|
|
55
|
-
const
|
|
53
|
+
export async function getDeviceResourceApi(deviceId, resourceId, options = {}) {
|
|
54
|
+
const user = resolveUser(options.user);
|
|
56
55
|
try {
|
|
57
|
-
const response = await api.get(
|
|
58
|
-
`/v3/users/${config.username}/devices/${deviceId}/resources/${resourceId}/api`,
|
|
59
|
-
);
|
|
56
|
+
const response = await api.get(`${device(user, deviceId)}/resources/${resourceId}/api`);
|
|
60
57
|
return response.data;
|
|
61
58
|
} catch (error) {
|
|
62
59
|
throw apiError(error, {
|
|
63
|
-
notFound: `Resource not found: ${resourceId} on device ${deviceId}`,
|
|
60
|
+
notFound: `Resource not found: ${resourceId} on device ${deviceId} (account ${user})`,
|
|
64
61
|
});
|
|
65
62
|
}
|
|
66
63
|
}
|
|
67
64
|
|
|
68
|
-
export async function executeDeviceResource(deviceId, resourceId, inputs) {
|
|
69
|
-
const
|
|
65
|
+
export async function executeDeviceResource(deviceId, resourceId, inputs, options = {}) {
|
|
66
|
+
const user = resolveUser(options.user);
|
|
70
67
|
|
|
71
68
|
try {
|
|
72
69
|
const response = await api.post(
|
|
73
|
-
|
|
70
|
+
`${device(user, deviceId)}/resources/${resourceId}`,
|
|
74
71
|
inputs,
|
|
75
72
|
);
|
|
76
73
|
return response.data;
|
|
77
74
|
} catch (error) {
|
|
78
75
|
throw apiError(error, {
|
|
79
|
-
notFound: `Resource not found: ${resourceId} on device ${deviceId}`,
|
|
76
|
+
notFound: `Resource not found: ${resourceId} on device ${deviceId} (account ${user})`,
|
|
80
77
|
});
|
|
81
78
|
}
|
|
82
79
|
}
|
|
@@ -87,14 +84,14 @@ export async function executeDeviceResource(deviceId, resourceId, inputs) {
|
|
|
87
84
|
* the output side of fn=3/4 resources without triggering the input
|
|
88
85
|
* handler. Otherwise POST with the payload.
|
|
89
86
|
*/
|
|
90
|
-
export async function callDeviceResource(deviceId, resourceId, inputs) {
|
|
87
|
+
export async function callDeviceResource(deviceId, resourceId, inputs, options = {}) {
|
|
91
88
|
const hasInputs =
|
|
92
89
|
inputs !== undefined &&
|
|
93
90
|
inputs !== null &&
|
|
94
91
|
!(typeof inputs === 'object' && !Array.isArray(inputs) && Object.keys(inputs).length === 0);
|
|
95
92
|
return hasInputs
|
|
96
|
-
? executeDeviceResource(deviceId, resourceId, inputs)
|
|
97
|
-
: readDeviceResource(deviceId, resourceId);
|
|
93
|
+
? executeDeviceResource(deviceId, resourceId, inputs, options)
|
|
94
|
+
: readDeviceResource(deviceId, resourceId, options);
|
|
98
95
|
}
|
|
99
96
|
|
|
100
97
|
/**
|
|
@@ -102,14 +99,14 @@ export async function callDeviceResource(deviceId, resourceId, inputs) {
|
|
|
102
99
|
* `/api` schema. Returns an array with `{name, fn, in?, out?}` so both
|
|
103
100
|
* the CLI and the MCP can render the same information.
|
|
104
101
|
*/
|
|
105
|
-
export async function listDeviceResourcesWithSchemas(deviceId) {
|
|
106
|
-
const resources = await getDeviceResources(deviceId);
|
|
102
|
+
export async function listDeviceResourcesWithSchemas(deviceId, options = {}) {
|
|
103
|
+
const resources = await getDeviceResources(deviceId, options);
|
|
107
104
|
const names = Object.keys(resources);
|
|
108
105
|
const entries = await Promise.all(
|
|
109
106
|
names.map(async (name) => {
|
|
110
107
|
const fn = resources[name]?.fn;
|
|
111
108
|
try {
|
|
112
|
-
const schema = await getDeviceResourceApi(deviceId, name);
|
|
109
|
+
const schema = await getDeviceResourceApi(deviceId, name, options);
|
|
113
110
|
return { name, fn, ...schema };
|
|
114
111
|
} catch {
|
|
115
112
|
return { name, fn };
|
package/lib/status.js
CHANGED
|
@@ -1,29 +1,23 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { resolveUser } from './config.js';
|
|
2
|
+
import { v1 } from './paths.js';
|
|
2
3
|
import api from './api.js';
|
|
3
4
|
import { apiError } from './errors.js';
|
|
4
|
-
import {
|
|
5
|
-
formatUptime,
|
|
6
|
-
colorPct,
|
|
7
|
-
formatBytes,
|
|
8
|
-
label,
|
|
9
|
-
muted,
|
|
10
|
-
success,
|
|
11
|
-
error,
|
|
12
|
-
} from './format.js';
|
|
5
|
+
import { formatUptime, colorPct, formatBytes, label, muted, success, error } from './format.js';
|
|
13
6
|
|
|
14
7
|
/**
|
|
15
8
|
* Get device status
|
|
16
9
|
* @param {string} deviceId - Device ID to check
|
|
10
|
+
* @param {string} [user] - Account that owns the device (defaults to the active profile)
|
|
17
11
|
* @returns {Promise<Object>} Status information
|
|
18
12
|
*/
|
|
19
|
-
export async function getDeviceStatus(deviceId) {
|
|
20
|
-
const config = requireConfig();
|
|
21
|
-
|
|
13
|
+
export async function getDeviceStatus(deviceId, user) {
|
|
22
14
|
try {
|
|
23
|
-
const response = await api.get(
|
|
15
|
+
const response = await api.get(`${v1(user)}/devices/${deviceId}/stats`);
|
|
24
16
|
return response.data;
|
|
25
17
|
} catch (error) {
|
|
26
|
-
throw apiError(error, {
|
|
18
|
+
throw apiError(error, {
|
|
19
|
+
notFound: `Device not found: ${deviceId} (account ${resolveUser(user)})`,
|
|
20
|
+
});
|
|
27
21
|
}
|
|
28
22
|
}
|
|
29
23
|
|
|
@@ -80,17 +74,13 @@ export function formatDeviceStatus(deviceId, status, monitoring = null) {
|
|
|
80
74
|
const root = monitoring.disk.root;
|
|
81
75
|
const detail =
|
|
82
76
|
root.available != null && root.total != null
|
|
83
|
-
? muted(
|
|
84
|
-
` (${formatBytes(root.available)} free of ${formatBytes(root.total)})`,
|
|
85
|
-
)
|
|
77
|
+
? muted(` (${formatBytes(root.available)} free of ${formatBytes(root.total)})`)
|
|
86
78
|
: '';
|
|
87
79
|
lines.push(` Disk: ${colorPct(root.usage)}${detail}`);
|
|
88
80
|
}
|
|
89
81
|
if (monitoring.load) {
|
|
90
82
|
const l = monitoring.load;
|
|
91
|
-
const parts = [l['1m'], l['5m'], l['15m']].map((v) =>
|
|
92
|
-
v != null ? v.toFixed(2) : '—',
|
|
93
|
-
);
|
|
83
|
+
const parts = [l['1m'], l['5m'], l['15m']].map((v) => (v != null ? v.toFixed(2) : '—'));
|
|
94
84
|
lines.push(` Load: ${parts.join(' · ')}`);
|
|
95
85
|
}
|
|
96
86
|
if (monitoring.uptime != null) {
|
package/lib/storage.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import api from './api.js';
|
|
2
2
|
import { apiError } from './errors.js';
|
|
3
|
-
import {
|
|
3
|
+
import { v1, v2 } from './paths.js';
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* Helpers for ThinRemote file storages — independent buckets that the
|
|
@@ -13,17 +13,6 @@ import { requireConfig } from './config.js';
|
|
|
13
13
|
* so orchestration code can stay branch-light.
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
-
function resolveUser(user) {
|
|
17
|
-
return user || requireConfig().username;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
function v1(user) {
|
|
21
|
-
return `/v1/users/${resolveUser(user)}`;
|
|
22
|
-
}
|
|
23
|
-
function v2(user) {
|
|
24
|
-
return `/v2/users/${resolveUser(user)}`;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
16
|
export async function getStorage(storageId, user) {
|
|
28
17
|
try {
|
|
29
18
|
const res = await api.get(`${v1(user)}/storages/${storageId}`);
|
package/lib/tokens.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
import api from './api.js';
|
|
3
3
|
import { apiError, inputError } from './errors.js';
|
|
4
|
-
import {
|
|
4
|
+
import { v1 } from './paths.js';
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
7
|
* Helpers for ThinRemote access tokens — both user-level (broad scope,
|
|
@@ -60,14 +60,6 @@ import { requireConfig } from './config.js';
|
|
|
60
60
|
* it.
|
|
61
61
|
*/
|
|
62
62
|
|
|
63
|
-
function resolveUser(user) {
|
|
64
|
-
return user || requireConfig().username;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
function v1(user) {
|
|
68
|
-
return `/v1/users/${resolveUser(user)}`;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
63
|
// ─── Duration helpers ───────────────────────────────────────────────
|
|
72
64
|
|
|
73
65
|
const DURATION_RE = /^(\d+)([smhdwy])$/;
|