@thinremote/thinr-cli 1.2.0-alpha.1 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/CHANGELOG.md +63 -1
  2. package/README.md +235 -30
  3. package/bin/thinr.js +43 -25
  4. package/commands/_shared.js +154 -0
  5. package/commands/dashboard.js +17 -0
  6. package/commands/device/_shared.js +17 -34
  7. package/commands/device/console.js +1 -0
  8. package/commands/device/delete.js +53 -0
  9. package/commands/device/exec.js +7 -6
  10. package/commands/device/fs.js +193 -0
  11. package/commands/device/index.js +9 -0
  12. package/commands/device/list.js +35 -23
  13. package/commands/device/logs.js +178 -0
  14. package/commands/device/property.js +30 -28
  15. package/commands/device/proxy.js +3 -0
  16. package/commands/device/resource.js +30 -29
  17. package/commands/device/status.js +35 -14
  18. package/commands/device/transfer.js +236 -0
  19. package/commands/device/update.js +15 -10
  20. package/commands/fleet/index.js +17 -0
  21. package/commands/fleet/upgrade.js +275 -0
  22. package/commands/logout.js +10 -11
  23. package/commands/playbook/_shared.js +12 -0
  24. package/commands/playbook/index.js +18 -0
  25. package/commands/playbook/run.js +302 -0
  26. package/commands/playbook/validate.js +38 -0
  27. package/commands/product/_shared.js +8 -0
  28. package/commands/product/exec.js +243 -0
  29. package/commands/product/fs.js +218 -0
  30. package/commands/product/index.js +35 -0
  31. package/commands/product/list.js +86 -0
  32. package/commands/product/logs.js +289 -0
  33. package/commands/product/metric.js +124 -0
  34. package/commands/product/playbook.js +911 -0
  35. package/commands/product/property.js +76 -0
  36. package/commands/product/props.js +125 -0
  37. package/commands/product/push.js +184 -0
  38. package/commands/product/resource.js +76 -0
  39. package/commands/product/status.js +213 -0
  40. package/commands/profile.js +76 -8
  41. package/dist/dashboard.js +5644 -0
  42. package/lib/agent-releases.js +59 -0
  43. package/lib/agent-versions.js +73 -0
  44. package/lib/alarms.js +252 -0
  45. package/lib/api.js +8 -2
  46. package/lib/auth.js +68 -20
  47. package/lib/bucket.js +87 -0
  48. package/lib/command-stream.js +213 -0
  49. package/lib/config.js +5 -3
  50. package/lib/console.js +19 -5
  51. package/lib/constants.js +83 -0
  52. package/lib/dashboard/event-stream.js +239 -0
  53. package/lib/dashboard/metric-aggregate.js +64 -0
  54. package/lib/dashboard/metric-filter.js +39 -0
  55. package/lib/debug-log.js +93 -0
  56. package/lib/device-api.js +131 -148
  57. package/lib/devices.js +18 -21
  58. package/lib/errors.js +12 -0
  59. package/lib/fleet-upgrade.js +106 -0
  60. package/lib/format.js +114 -0
  61. package/lib/mcp/registry.js +14 -0
  62. package/lib/mcp/tools-alarms.js +510 -0
  63. package/lib/mcp/tools-buckets.js +103 -0
  64. package/lib/mcp/tools-devices.js +33 -3
  65. package/lib/mcp/tools-exec.js +30 -17
  66. package/lib/mcp/tools-filesystem.js +102 -6
  67. package/lib/mcp/tools-install.js +153 -0
  68. package/lib/mcp/tools-logs.js +225 -0
  69. package/lib/mcp/tools-playbook.js +211 -0
  70. package/lib/mcp/tools-products.js +1226 -105
  71. package/lib/mcp/tools-profile.js +634 -0
  72. package/lib/mcp/tools-tokens.js +371 -0
  73. package/lib/monitoring.js +26 -1
  74. package/lib/playbook/actions.js +447 -0
  75. package/lib/playbook/expression.js +302 -0
  76. package/lib/playbook/fleet.js +175 -0
  77. package/lib/playbook/loader.js +247 -0
  78. package/lib/playbook/runner.js +300 -0
  79. package/lib/playbook/schema.js +209 -0
  80. package/lib/playbook/vars.js +209 -0
  81. package/lib/product/log-presets.js +196 -0
  82. package/lib/product/logs.js +485 -0
  83. package/lib/product-orchestrator.js +124 -0
  84. package/lib/product.js +523 -20
  85. package/lib/profile.js +665 -0
  86. package/lib/property.js +7 -40
  87. package/lib/proxy.js +9 -8
  88. package/lib/resource.js +10 -14
  89. package/lib/status.js +78 -37
  90. package/lib/tokens.js +374 -0
  91. package/package.json +14 -3
  92. package/commands/product.js +0 -407
@@ -0,0 +1,371 @@
1
+ // @ts-check
2
+ import {
3
+ createDeviceToken,
4
+ createToken,
5
+ deleteDeviceToken,
6
+ deleteToken,
7
+ getToken,
8
+ listDeviceTokens,
9
+ listTokens,
10
+ updateToken,
11
+ } from '../tokens.js';
12
+ import { inputError } from '../errors.js';
13
+
14
+ // ─── User-level tokens ──────────────────────────────────────────────
15
+
16
+ async function toolTokenList(args) {
17
+ const tokens = await listTokens(args.user);
18
+ if (tokens.length === 0) {
19
+ return {
20
+ content: [{ type: 'text', text: 'No access tokens configured.' }],
21
+ isError: false,
22
+ };
23
+ }
24
+ const lines = tokens.map((t) => {
25
+ const enabled = t.enabled === false ? '✗' : '✓';
26
+ const name = t.name || t.token;
27
+ return ` ${enabled} ${t.token} (${name})`;
28
+ });
29
+ return {
30
+ content: [
31
+ {
32
+ type: 'text',
33
+ text: `${tokens.length} token(s):\n${lines.join('\n')}\n\nNote: the access_token JWT is NOT returned by list — use thinr_token_get to read it.`,
34
+ },
35
+ ],
36
+ isError: false,
37
+ };
38
+ }
39
+
40
+ async function toolTokenGet(args) {
41
+ if (!args.token_id) throw inputError('token_id is required');
42
+ const doc = await getToken(args.token_id, args.user);
43
+ return {
44
+ content: [{ type: 'text', text: JSON.stringify(doc, null, 2) }],
45
+ isError: false,
46
+ };
47
+ }
48
+
49
+ async function toolTokenCreate(args) {
50
+ if (!args.token_id) throw inputError('token_id is required');
51
+ if (!args.name) throw inputError('name is required');
52
+ if (!args.allow) throw inputError('allow is required (permission tree)');
53
+
54
+ const doc = await createToken(
55
+ {
56
+ token: args.token_id,
57
+ name: args.name,
58
+ allow: args.allow,
59
+ deny: args.deny,
60
+ description: args.description,
61
+ expire: args.expire,
62
+ enabled: args.enabled,
63
+ },
64
+ args.user,
65
+ );
66
+ return {
67
+ content: [
68
+ {
69
+ type: 'text',
70
+ text: `Created token "${args.token_id}". Full document (including access_token JWT):\n${JSON.stringify(doc, null, 2)}\n\nIMPORTANT: persist the JWT now — it will not be visible in thinr_token_list responses; only thinr_token_get returns it.`,
71
+ },
72
+ ],
73
+ isError: false,
74
+ };
75
+ }
76
+
77
+ async function toolTokenUpdate(args) {
78
+ if (!args.token_id) throw inputError('token_id is required');
79
+ const patch = {};
80
+ if (args.name !== undefined) patch.name = args.name;
81
+ if (args.description !== undefined) patch.description = args.description;
82
+ if (args.enabled !== undefined) patch.enabled = !!args.enabled;
83
+ if (args.expire !== undefined) patch.expire = args.expire;
84
+ if (args.allow !== undefined) patch.allow = args.allow;
85
+ if (args.deny !== undefined) patch.deny = args.deny;
86
+
87
+ const doc = await updateToken(args.token_id, patch, args.user);
88
+ return {
89
+ content: [
90
+ {
91
+ type: 'text',
92
+ text: `Updated token "${args.token_id}".\n${JSON.stringify(doc, null, 2)}`,
93
+ },
94
+ ],
95
+ isError: false,
96
+ };
97
+ }
98
+
99
+ async function toolTokenDelete(args) {
100
+ if (!args.token_id) throw inputError('token_id is required');
101
+ const removed = await deleteToken(args.token_id, args.user);
102
+ return {
103
+ content: [
104
+ {
105
+ type: 'text',
106
+ text: removed
107
+ ? `Deleted token "${args.token_id}".`
108
+ : `Token "${args.token_id}" was not configured.`,
109
+ },
110
+ ],
111
+ isError: false,
112
+ };
113
+ }
114
+
115
+ // ─── Device-level tokens ────────────────────────────────────────────
116
+
117
+ async function toolDeviceTokenList(args) {
118
+ if (!args.device) throw inputError('device is required');
119
+ const tokens = await listDeviceTokens(args.device, args.user);
120
+ if (tokens.length === 0) {
121
+ return {
122
+ content: [
123
+ { type: 'text', text: `No device tokens on device "${args.device}".` },
124
+ ],
125
+ isError: false,
126
+ };
127
+ }
128
+ return {
129
+ content: [
130
+ {
131
+ type: 'text',
132
+ text: `${tokens.length} device token(s) on "${args.device}":\n${JSON.stringify(tokens, null, 2)}`,
133
+ },
134
+ ],
135
+ isError: false,
136
+ };
137
+ }
138
+
139
+ async function toolDeviceTokenCreate(args) {
140
+ if (!args.device) throw inputError('device is required');
141
+ if (!args.token_name) throw inputError('token_name is required');
142
+
143
+ const doc = await createDeviceToken(
144
+ args.device,
145
+ {
146
+ token_name: args.token_name,
147
+ token_resources: args.token_resources,
148
+ token_expiration: args.token_expiration,
149
+ },
150
+ args.user,
151
+ );
152
+ return {
153
+ content: [
154
+ {
155
+ type: 'text',
156
+ text: `Created device token "${args.token_name}" on "${args.device}".\n${JSON.stringify(doc, null, 2)}`,
157
+ },
158
+ ],
159
+ isError: false,
160
+ };
161
+ }
162
+
163
+ async function toolDeviceTokenDelete(args) {
164
+ if (!args.device) throw inputError('device is required');
165
+ if (!args.token_id) throw inputError('token_id is required');
166
+ const removed = await deleteDeviceToken(args.device, args.token_id, args.user);
167
+ return {
168
+ content: [
169
+ {
170
+ type: 'text',
171
+ text: removed
172
+ ? `Deleted device token "${args.token_id}" from "${args.device}".`
173
+ : `Device token "${args.token_id}" was not configured on "${args.device}".`,
174
+ },
175
+ ],
176
+ isError: false,
177
+ };
178
+ }
179
+
180
+ // ─── Tool registration ───────────────────────────────────────────────
181
+
182
+ const PERMISSION_GRAMMAR_NOTE = `Permission grammar (allow / deny):
183
+ { "<ResourceType>": { "<id>"|"*": ["<Action>"|"*"] | "*" } }
184
+
185
+ Resource types are platform categories (Device, Bucket, Product, Endpoint, Property, Token, User, …) plus "*" for any. The id level is a literal id or "*". Leaves are arrays of action strings or the wildcard "*".
186
+
187
+ Limit: the grammar does NOT scope by product. Only by resource type + id. To restrict a token to one product's devices, enumerate device ids explicitly. See \`tokens.cpp:validate_token_permissions\` and \`tokens.hpp:PERMISSION_DEFINITION\` in the backend.
188
+
189
+ Examples (verbatim from the server's CREATE_SCHEMA_EXAMPLE):
190
+ - Admin Access:
191
+ { token: "admin_access", name: "Admin Access", enabled: true, allow: { "*": { "*": "*" } } }
192
+ Every resource, any name, any action.
193
+ - Shared Access:
194
+ { token: "shared_access", name: "Shared Access", enabled: true,
195
+ allow: { "Bucket": { "mqtt_bucket": ["ReadBucket"] }, "Device": { "*": ["AccessDeviceResources"] } } }
196
+ Reads from one bucket plus access to any device's resources.`;
197
+
198
+ const EXPIRY_NOTE = `Expiry handling: pass either a unix-seconds number or a relative duration string ("30d", "12h", "1y", "90d"). Strings are converted client-side to absolute unix seconds before the request.`;
199
+
200
+ export const tools = [
201
+ {
202
+ name: 'thinr_token_list',
203
+ description: `List the user-level access tokens configured on the account. The response omits the JWT (\`access_token\` is excluded by the server's LIST_PROJECTION). Use \`thinr_token_get\` to read a specific token's JWT.`,
204
+ inputSchema: {
205
+ type: 'object',
206
+ properties: {
207
+ user: { type: 'string', description: 'API user.' },
208
+ },
209
+ required: [],
210
+ },
211
+ handler: toolTokenList,
212
+ },
213
+ {
214
+ name: 'thinr_token_get',
215
+ description: `Read a single user-level access token, including the JWT (\`access_token\`). The JWT is shown only by this endpoint and on creation — list responses strip it.`,
216
+ inputSchema: {
217
+ type: 'object',
218
+ properties: {
219
+ token_id: {
220
+ type: 'string',
221
+ description: 'Token id (matches /^[a-zA-Z0-9_]{1,50}$/).',
222
+ },
223
+ user: { type: 'string', description: 'API user.' },
224
+ },
225
+ required: ['token_id'],
226
+ },
227
+ handler: toolTokenGet,
228
+ },
229
+ {
230
+ name: 'thinr_token_create',
231
+ description: `Create a user-level access token and issue a JWT scoped by an \`allow\` (and optional \`deny\`) permission tree.
232
+
233
+ Use this to mint narrow-scope tokens you can hand off to scripts, integrations, or third parties so they can call the platform API on your behalf without your full credentials. The JWT is returned in the response — capture it immediately, it will not be visible from the list endpoint afterwards (only \`thinr_token_get\` shows it).
234
+
235
+ ${PERMISSION_GRAMMAR_NOTE}
236
+
237
+ ${EXPIRY_NOTE}
238
+
239
+ Example: a backup script that needs to invoke device resources for 90 days.
240
+ { token_id: "backup_notifier", name: "Backup Notifier", allow: { "Device": { "*": ["AccessDeviceResources"] } }, expire: "90d" }`,
241
+ inputSchema: {
242
+ type: 'object',
243
+ properties: {
244
+ token_id: {
245
+ type: 'string',
246
+ description:
247
+ 'Stable token id (letters, digits, underscore, ≤ 50 chars). Used as the URL key for read/update/delete.',
248
+ },
249
+ name: { type: 'string', description: 'Display name.' },
250
+ description: { type: 'string', description: 'Free-form description.' },
251
+ allow: {
252
+ type: 'object',
253
+ description:
254
+ 'Permission tree. See the description for the grammar; example: { "Device": { "*": ["AccessDeviceResources"] } }.',
255
+ additionalProperties: true,
256
+ },
257
+ deny: {
258
+ type: 'object',
259
+ description: 'Optional deny tree (same grammar as allow).',
260
+ additionalProperties: true,
261
+ },
262
+ expire: {
263
+ description:
264
+ 'Expiry as unix seconds (number) or relative duration ("30d", "12h", "1y"). Omit for no expiry.',
265
+ },
266
+ enabled: {
267
+ type: 'boolean',
268
+ description: 'Default: true. Set to false to mint a token in disabled state.',
269
+ },
270
+ user: { type: 'string', description: 'API user.' },
271
+ },
272
+ required: ['token_id', 'name', 'allow'],
273
+ },
274
+ handler: toolTokenCreate,
275
+ },
276
+ {
277
+ name: 'thinr_token_update',
278
+ description: `Patch an existing token. Accepted fields per UPDATE_SCHEMA: name, description, enabled, expire, allow, deny. Pass at least one. The JWT itself cannot be rotated through this endpoint — delete and recreate to rotate.
279
+
280
+ ${EXPIRY_NOTE}`,
281
+ inputSchema: {
282
+ type: 'object',
283
+ properties: {
284
+ token_id: { type: 'string', description: 'Token id to patch.' },
285
+ name: { type: 'string' },
286
+ description: { type: 'string' },
287
+ enabled: { type: 'boolean' },
288
+ expire: {
289
+ description:
290
+ 'Unix seconds (number) or relative duration ("30d", "12h"). Omit to leave unchanged.',
291
+ },
292
+ allow: { type: 'object', additionalProperties: true },
293
+ deny: { type: 'object', additionalProperties: true },
294
+ user: { type: 'string', description: 'API user.' },
295
+ },
296
+ required: ['token_id'],
297
+ },
298
+ handler: toolTokenUpdate,
299
+ },
300
+ {
301
+ name: 'thinr_token_delete',
302
+ description: `Delete a user-level token. Idempotent — reports cleanly when the token was already absent.`,
303
+ inputSchema: {
304
+ type: 'object',
305
+ properties: {
306
+ token_id: { type: 'string', description: 'Token id to remove.' },
307
+ user: { type: 'string', description: 'API user.' },
308
+ },
309
+ required: ['token_id'],
310
+ },
311
+ handler: toolTokenDelete,
312
+ },
313
+
314
+ // ── Device tokens ──
315
+ {
316
+ name: 'thinr_device_token_list',
317
+ description: `List the device-scoped tokens issued for a single device. Device tokens are a different kind from user-level tokens (\`thinr_token_*\`): they're issued via \`/devices/{id}/tokens\`, scoped optionally to a subset of the device's resources, and have a simpler \`{ token_name, token_resources?, token_expiration? }\` shape.`,
318
+ inputSchema: {
319
+ type: 'object',
320
+ properties: {
321
+ device: { type: 'string', description: 'Device ID.' },
322
+ user: { type: 'string', description: 'API user.' },
323
+ },
324
+ required: ['device'],
325
+ },
326
+ handler: toolDeviceTokenList,
327
+ },
328
+ {
329
+ name: 'thinr_device_token_create',
330
+ description: `Issue a device-scoped token. Pass \`token_resources\` to restrict the token to a subset of the device's resources, or omit it for "all resources of this device". Pass \`token_expiration\` (unix seconds or relative like "30d") for an expiry, or omit for none.
331
+
332
+ ${EXPIRY_NOTE}
333
+
334
+ Example: a token that lets a remote script call only the \`monitoring\` and \`reboot\` resources of "device-007" for 24 hours.
335
+ { device: "device-007", token_name: "ops-bot", token_resources: ["monitoring", "reboot"], token_expiration: "24h" }`,
336
+ inputSchema: {
337
+ type: 'object',
338
+ properties: {
339
+ device: { type: 'string', description: 'Device ID.' },
340
+ token_name: { type: 'string', description: 'Display name for the new token.' },
341
+ token_resources: {
342
+ type: 'array',
343
+ items: { type: 'string' },
344
+ description:
345
+ 'Resource names the token may access. Omit for ALL resources of the device.',
346
+ },
347
+ token_expiration: {
348
+ description:
349
+ 'Unix seconds (number) or relative duration ("30d", "24h"). Omit for no expiry.',
350
+ },
351
+ user: { type: 'string', description: 'API user.' },
352
+ },
353
+ required: ['device', 'token_name'],
354
+ },
355
+ handler: toolDeviceTokenCreate,
356
+ },
357
+ {
358
+ name: 'thinr_device_token_delete',
359
+ description: `Delete a device-scoped token. Idempotent.`,
360
+ inputSchema: {
361
+ type: 'object',
362
+ properties: {
363
+ device: { type: 'string', description: 'Device ID.' },
364
+ token_id: { type: 'string', description: 'Token id to remove.' },
365
+ user: { type: 'string', description: 'API user.' },
366
+ },
367
+ required: ['device', 'token_id'],
368
+ },
369
+ handler: toolDeviceTokenDelete,
370
+ },
371
+ ];
package/lib/monitoring.js CHANGED
@@ -21,6 +21,13 @@ function resolveUser(user) {
21
21
  * reducer (e.g. "avg"), and `fields` a comma-separated allowlist to
22
22
  * trim the response down.
23
23
  *
24
+ * Multi-device queries (no `device`) default to `group_by=device`, so
25
+ * the server returns one row per device instead of a stream of raw
26
+ * samples interleaved across the fleet. Without `agg_type`, that row is
27
+ * the latest sample per device; with `agg_type` (max/mean/min/...), the
28
+ * reducer is applied across the time window per device. Pass
29
+ * `group_by=''` to opt out and get raw interleaved samples.
30
+ *
24
31
  * @param {{
25
32
  * device?: string | null,
26
33
  * user?: string | null,
@@ -32,6 +39,7 @@ function resolveUser(user) {
32
39
  * agg?: string,
33
40
  * agg_type?: string,
34
41
  * fields?: string,
42
+ * group_by?: string | null,
35
43
  * }} [opts]
36
44
  */
37
45
  export async function getMonitoringData({
@@ -45,6 +53,7 @@ export async function getMonitoringData({
45
53
  agg,
46
54
  agg_type,
47
55
  fields,
56
+ group_by,
48
57
  } = {}) {
49
58
  const apiUser = resolveUser(user);
50
59
  const params = { items, sort };
@@ -57,7 +66,23 @@ export async function getMonitoringData({
57
66
  }
58
67
  if (agg) params.agg = agg;
59
68
  if (agg_type) params.agg_type = agg_type;
60
- if (fields) params.fields = fields;
69
+ // Default multi-device queries to one row per device. An explicit empty
70
+ // string opts out (raw interleaved samples); any other explicit value
71
+ // is passed through verbatim.
72
+ const effectiveGroupBy = group_by === undefined ? (device ? null : 'device') : group_by;
73
+ if (effectiveGroupBy) params.group_by = effectiveGroupBy;
74
+ if (fields) {
75
+ // Raw multi-device queries (no group_by) need `device` in the field
76
+ // list to attribute each sample — otherwise rows come back anonymous.
77
+ // With group_by the server already projects the group key in the
78
+ // output, and injecting `device` here would confuse the field filter.
79
+ const hasDevice = fields
80
+ .split(',')
81
+ .map((s) => s.trim())
82
+ .includes('device');
83
+ params.fields =
84
+ !device && !effectiveGroupBy && !hasDevice ? `device,${fields}` : fields;
85
+ }
61
86
 
62
87
  const url = device
63
88
  ? `/v3/users/${apiUser}/devices/${device}/buckets/monitoring/data`