@thinremote/thinr-cli 1.3.0 → 1.3.1

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,16 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.3.1] - 2026-09-24
4
+
5
+ ### Fixed
6
+
7
+ - Device resources now honour the `user` argument, so an admin acting on
8
+ another account reaches that account's devices. `thinr_resource_list`,
9
+ `thinr_resource_call` and the `thinr_script_*` tools accepted the
10
+ argument but addressed the active profile's own account, which answered
11
+ "Device not found"; playbook `resource` and script actions had the same
12
+ gap. Not-found errors now name the account they looked in.
13
+
3
14
  ## [1.3.0] - 2026-09-13
4
15
 
5
16
  ### Added
@@ -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
  {
@@ -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: [
@@ -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 s.slice(0, MAX_CAPTURED_STREAM_BYTES) + `\n…[truncated, ${s.length - MAX_CAPTURED_STREAM_BYTES} more bytes]`;
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 }>>} */
@@ -128,11 +131,16 @@ export const HANDLERS = {
128
131
  },
129
132
 
130
133
  async resource(ctx, params) {
131
- const result = await callDeviceResource(ctx.deviceId, params.resource, params.inputs || null);
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 { status: 'changed', summary: `would pull ${params.source} (${size} bytes) → ${params.destination}` };
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 { status: 'changed', summary: `source ${params.source} not found on device` };
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}`;
@@ -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: `update ${params.op}: ${result?.status || 'unknown'}` +
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 { status: 'changed', summary: `${path} is not a regular file — would overwrite` };
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/resource.js CHANGED
@@ -3,23 +3,33 @@ import api from './api.js';
3
3
  import { apiError } from './errors.js';
4
4
  import { info, warning, hint } from './format.js';
5
5
 
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
+
6
16
  // Drops internal resources whose keys start with `$`; those belong to
7
17
  // the agent's own framework (cmd, fs, scripts…) and shouldn't surface
8
18
  // through the user-facing resource list.
9
- export async function getDeviceResources(deviceId) {
10
- const config = requireConfig();
19
+ export async function getDeviceResources(deviceId, options = {}) {
20
+ const user = apiUser(options);
11
21
 
12
22
  try {
13
23
  // `/resources/api` merges firmware resources with API resources
14
24
  // 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
- );
25
+ const response = await api.get(`/v3/users/${user}/devices/${deviceId}/resources/api`);
18
26
  return Object.fromEntries(
19
27
  Object.entries(response.data).filter(([key]) => !key.startsWith('$')),
20
28
  );
21
29
  } catch (error) {
22
- throw apiError(error, { notFound: `Device not found: ${deviceId}` });
30
+ throw apiError(error, {
31
+ notFound: `Device not found: ${deviceId} (account ${user})`,
32
+ });
23
33
  }
24
34
  }
25
35
 
@@ -28,16 +38,16 @@ export async function getDeviceResources(deviceId) {
28
38
  * output-only (fn=3) or fn=4 resources when you only want the latest
29
39
  * sample, not to trigger the input side.
30
40
  */
31
- export async function readDeviceResource(deviceId, resourceId) {
32
- const config = requireConfig();
41
+ export async function readDeviceResource(deviceId, resourceId, options = {}) {
42
+ const user = apiUser(options);
33
43
  try {
34
44
  const response = await api.get(
35
- `/v3/users/${config.username}/devices/${deviceId}/resources/${resourceId}`,
45
+ `/v3/users/${user}/devices/${deviceId}/resources/${resourceId}`,
36
46
  );
37
47
  return response.data;
38
48
  } catch (error) {
39
49
  throw apiError(error, {
40
- notFound: `Resource not found: ${resourceId} on device ${deviceId}`,
50
+ notFound: `Resource not found: ${resourceId} on device ${deviceId} (account ${user})`,
41
51
  });
42
52
  }
43
53
  }
@@ -51,32 +61,32 @@ export async function readDeviceResource(deviceId, resourceId) {
51
61
  * return `in`; fn=1 may return an empty object. Not every agent/firmware
52
62
  * exposes /api — callers should tolerate 404/501.
53
63
  */
54
- export async function getDeviceResourceApi(deviceId, resourceId) {
55
- const config = requireConfig();
64
+ export async function getDeviceResourceApi(deviceId, resourceId, options = {}) {
65
+ const user = apiUser(options);
56
66
  try {
57
67
  const response = await api.get(
58
- `/v3/users/${config.username}/devices/${deviceId}/resources/${resourceId}/api`,
68
+ `/v3/users/${user}/devices/${deviceId}/resources/${resourceId}/api`,
59
69
  );
60
70
  return response.data;
61
71
  } catch (error) {
62
72
  throw apiError(error, {
63
- notFound: `Resource not found: ${resourceId} on device ${deviceId}`,
73
+ notFound: `Resource not found: ${resourceId} on device ${deviceId} (account ${user})`,
64
74
  });
65
75
  }
66
76
  }
67
77
 
68
- export async function executeDeviceResource(deviceId, resourceId, inputs) {
69
- const config = requireConfig();
78
+ export async function executeDeviceResource(deviceId, resourceId, inputs, options = {}) {
79
+ const user = apiUser(options);
70
80
 
71
81
  try {
72
82
  const response = await api.post(
73
- `/v3/users/${config.username}/devices/${deviceId}/resources/${resourceId}`,
83
+ `/v3/users/${user}/devices/${deviceId}/resources/${resourceId}`,
74
84
  inputs,
75
85
  );
76
86
  return response.data;
77
87
  } catch (error) {
78
88
  throw apiError(error, {
79
- notFound: `Resource not found: ${resourceId} on device ${deviceId}`,
89
+ notFound: `Resource not found: ${resourceId} on device ${deviceId} (account ${user})`,
80
90
  });
81
91
  }
82
92
  }
@@ -87,14 +97,14 @@ export async function executeDeviceResource(deviceId, resourceId, inputs) {
87
97
  * the output side of fn=3/4 resources without triggering the input
88
98
  * handler. Otherwise POST with the payload.
89
99
  */
90
- export async function callDeviceResource(deviceId, resourceId, inputs) {
100
+ export async function callDeviceResource(deviceId, resourceId, inputs, options = {}) {
91
101
  const hasInputs =
92
102
  inputs !== undefined &&
93
103
  inputs !== null &&
94
104
  !(typeof inputs === 'object' && !Array.isArray(inputs) && Object.keys(inputs).length === 0);
95
105
  return hasInputs
96
- ? executeDeviceResource(deviceId, resourceId, inputs)
97
- : readDeviceResource(deviceId, resourceId);
106
+ ? executeDeviceResource(deviceId, resourceId, inputs, options)
107
+ : readDeviceResource(deviceId, resourceId, options);
98
108
  }
99
109
 
100
110
  /**
@@ -102,14 +112,14 @@ export async function callDeviceResource(deviceId, resourceId, inputs) {
102
112
  * `/api` schema. Returns an array with `{name, fn, in?, out?}` so both
103
113
  * the CLI and the MCP can render the same information.
104
114
  */
105
- export async function listDeviceResourcesWithSchemas(deviceId) {
106
- const resources = await getDeviceResources(deviceId);
115
+ export async function listDeviceResourcesWithSchemas(deviceId, options = {}) {
116
+ const resources = await getDeviceResources(deviceId, options);
107
117
  const names = Object.keys(resources);
108
118
  const entries = await Promise.all(
109
119
  names.map(async (name) => {
110
120
  const fn = resources[name]?.fn;
111
121
  try {
112
- const schema = await getDeviceResourceApi(deviceId, name);
122
+ const schema = await getDeviceResourceApi(deviceId, name, options);
113
123
  return { name, fn, ...schema };
114
124
  } catch {
115
125
  return { name, fn };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thinremote/thinr-cli",
3
- "version": "1.3.0",
3
+ "version": "1.3.1",
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": {