@thinremote/thinr-cli 1.3.1 → 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 CHANGED
@@ -1,5 +1,27 @@
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
+
3
25
  ## [1.3.1] - 2026-09-24
4
26
 
5
27
  ### Fixed
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 { requireConfig } from './config.js';
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(`/v1/users/${apiUser}/alarms/instances/stats`);
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
- `/v1/users/${apiUser}/alarms/instances/${encodeURIComponent(instance)}`,
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(`/v1/users/${apiUser}/alarms/rules`, body);
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(`/v1/users/${username}/tokens/ThinRemote`);
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
- `/v1/users/${username}/tokens`,
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(`/v1/users/${config.username}/tokens/ThinRemote`);
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 { requireConfig } from './config.js';
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
- ? `/v3/users/${apiUser}/devices/${device}/buckets/${bucket}/data`
79
- : `/v2/users/${apiUser}/buckets/${bucket}/data`;
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 });
@@ -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}/v3/users/${apiUser}/devices/${deviceId}/resources/${this.resource}?${params}`;
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}/v3/users/${config.username}/devices/${deviceId}/resources/$terminal/${sessionId}?raw=1`;
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
- config,
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
- config,
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 {Object} config - Configuration object
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(config, deviceId, sessionId, cols, rows) {
141
+ async function updateTerminalSize(user, deviceId, sessionId, cols, rows) {
140
142
  try {
141
- const url = `/v3/users/${config.username}/devices/${deviceId}/resources/$terminal/${sessionId}/params`;
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 { await this._refreshing; } catch { /* fall through; readConfig may still have a usable token */ }
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}/v2/users/${config.username}/events?authorization=${config.token}`;
112
+ const url = `wss://${config.server}${v2(config.username)}/events?authorization=${config.token}`;
108
113
  debugLog('ws:events', 'connecting', {
109
- endpoint: `wss://${config.server}/v2/users/${config.username}/events`,
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 { h(); } catch (err) {
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 || config.username;
20
- const resourcePath = `/v3/users/${apiUser}/devices/${deviceId}/resources`;
21
- const filesPath = `/v3/users/${apiUser}/devices/${deviceId}/files`;
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 = `/v1/users/${apiUser}/devices?count=${count}&${queryParametersString}`;
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(`/v1/users/${apiUser}/devices/${deviceId}`);
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(`/v1/users/${apiUser}/devices/${deviceId}`);
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
  {
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 { requireConfig } from './config.js';
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
- ? `/v3/users/${apiUser}/devices/${device}/buckets/monitoring/data`
89
- : `/v2/users/${apiUser}/buckets/monitoring/data`;
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
+ }
@@ -126,7 +126,7 @@ export const HANDLERS = {
126
126
  },
127
127
 
128
128
  async property_set(ctx, params) {
129
- await setDeviceProperty(ctx.deviceId, params.property, params.value);
129
+ await setDeviceProperty(ctx.deviceId, params.property, params.value, ctx.user);
130
130
  return `set ${params.property}`;
131
131
  },
132
132
 
@@ -275,7 +275,7 @@ export const CHECKERS = {
275
275
  async property_set(ctx, params) {
276
276
  let current;
277
277
  try {
278
- current = await getDeviceProperty(ctx.deviceId, params.property);
278
+ current = await getDeviceProperty(ctx.deviceId, params.property, ctx.user);
279
279
  } catch (err) {
280
280
  if (err?.code === 'not_found') {
281
281
  return { status: 'changed', summary: `create property ${params.property}` };
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 { requireConfig } from './config.js';
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 { requireConfig } from './config.js';
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, { notFound: `Device not found: ${deviceId}` });
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
- `/v3/users/${config.username}/devices/${deviceId}/properties/${propertyId}`,
42
- { value },
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, { notFound: `Device not found: ${deviceId}` });
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,28 +1,19 @@
1
- import { requireConfig } from './config.js';
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';
5
6
 
6
- /**
7
- * Account the request is addressed to: `options.user` for admin access to
8
- * another account's devices, the active profile's own user otherwise.
9
- * Matches `createDeviceAPI`, so both halves of a device interaction (files
10
- * and resources) resolve the same way.
11
- */
12
- function apiUser(options = {}) {
13
- return options.user || requireConfig().username;
14
- }
15
-
16
7
  // Drops internal resources whose keys start with `$`; those belong to
17
8
  // the agent's own framework (cmd, fs, scripts…) and shouldn't surface
18
9
  // through the user-facing resource list.
19
10
  export async function getDeviceResources(deviceId, options = {}) {
20
- const user = apiUser(options);
11
+ const user = resolveUser(options.user);
21
12
 
22
13
  try {
23
14
  // `/resources/api` merges firmware resources with API resources
24
15
  // inherited from the product profile, which `/resources` alone omits.
25
- const response = await api.get(`/v3/users/${user}/devices/${deviceId}/resources/api`);
16
+ const response = await api.get(`${device(user, deviceId)}/resources/api`);
26
17
  return Object.fromEntries(
27
18
  Object.entries(response.data).filter(([key]) => !key.startsWith('$')),
28
19
  );
@@ -39,11 +30,9 @@ export async function getDeviceResources(deviceId, options = {}) {
39
30
  * sample, not to trigger the input side.
40
31
  */
41
32
  export async function readDeviceResource(deviceId, resourceId, options = {}) {
42
- const user = apiUser(options);
33
+ const user = resolveUser(options.user);
43
34
  try {
44
- const response = await api.get(
45
- `/v3/users/${user}/devices/${deviceId}/resources/${resourceId}`,
46
- );
35
+ const response = await api.get(`${device(user, deviceId)}/resources/${resourceId}`);
47
36
  return response.data;
48
37
  } catch (error) {
49
38
  throw apiError(error, {
@@ -62,11 +51,9 @@ export async function readDeviceResource(deviceId, resourceId, options = {}) {
62
51
  * exposes /api — callers should tolerate 404/501.
63
52
  */
64
53
  export async function getDeviceResourceApi(deviceId, resourceId, options = {}) {
65
- const user = apiUser(options);
54
+ const user = resolveUser(options.user);
66
55
  try {
67
- const response = await api.get(
68
- `/v3/users/${user}/devices/${deviceId}/resources/${resourceId}/api`,
69
- );
56
+ const response = await api.get(`${device(user, deviceId)}/resources/${resourceId}/api`);
70
57
  return response.data;
71
58
  } catch (error) {
72
59
  throw apiError(error, {
@@ -76,11 +63,11 @@ export async function getDeviceResourceApi(deviceId, resourceId, options = {}) {
76
63
  }
77
64
 
78
65
  export async function executeDeviceResource(deviceId, resourceId, inputs, options = {}) {
79
- const user = apiUser(options);
66
+ const user = resolveUser(options.user);
80
67
 
81
68
  try {
82
69
  const response = await api.post(
83
- `/v3/users/${user}/devices/${deviceId}/resources/${resourceId}`,
70
+ `${device(user, deviceId)}/resources/${resourceId}`,
84
71
  inputs,
85
72
  );
86
73
  return response.data;
package/lib/status.js CHANGED
@@ -1,29 +1,23 @@
1
- import { requireConfig } from './config.js';
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(`/v1/users/${config.username}/devices/${deviceId}/stats`);
15
+ const response = await api.get(`${v1(user)}/devices/${deviceId}/stats`);
24
16
  return response.data;
25
17
  } catch (error) {
26
- throw apiError(error, { notFound: `Device not found: ${deviceId}` });
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 { requireConfig } from './config.js';
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 { requireConfig } from './config.js';
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])$/;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thinremote/thinr-cli",
3
- "version": "1.3.1",
3
+ "version": "1.3.2",
4
4
  "description": "CLI for ThinRemote — remote management for IoT devices, with an MCP server for AI clients",
5
5
  "main": "bin/thinr.js",
6
6
  "bin": {