@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
package/lib/tokens.js ADDED
@@ -0,0 +1,374 @@
1
+ // @ts-check
2
+ import api from './api.js';
3
+ import { apiError, inputError } from './errors.js';
4
+ import { requireConfig } from './config.js';
5
+
6
+ /**
7
+ * Helpers for ThinRemote access tokens — both user-level (broad scope,
8
+ * `allow`/`deny` resource trees) and device-level (narrow, scoped to
9
+ * one device's resources).
10
+ *
11
+ * REST endpoints wrapped here:
12
+ *
13
+ * User-level
14
+ * ──────────
15
+ * GET /v1/users/{user}/tokens → list (no JWT)
16
+ * POST /v1/users/{user}/tokens → create (returns full doc + JWT)
17
+ * GET /v1/users/{user}/tokens/{tokenId} → read full doc + JWT
18
+ * PUT /v1/users/{user}/tokens/{tokenId} → patch
19
+ * DELETE /v1/users/{user}/tokens/{tokenId} → delete
20
+ *
21
+ * Device-level
22
+ * ────────────
23
+ * GET /v1/users/{user}/devices/{d}/tokens → list
24
+ * POST /v1/users/{user}/devices/{d}/tokens → create
25
+ * DELETE /v1/users/{user}/devices/{d}/tokens/{tokenId} → delete
26
+ *
27
+ * Naming
28
+ * ──────
29
+ * The user-level CREATE_SCHEMA accepts `token` (the id), `name`, `enabled`,
30
+ * `allow`, `deny`, `expire`, `description`. The device-level POST accepts
31
+ * a different field set: `token_name`, `token_resources`, `token_expiration`.
32
+ * See `tokens.cpp` and `devices.cpp` in the backend for the canonical
33
+ * shape. Helpers here keep the two distinct so callers don't get them
34
+ * confused.
35
+ *
36
+ * Permission grammar (user-level only)
37
+ * ────────────────────────────────────
38
+ * allow / deny: {
39
+ * "<ResourceType>": { "<id|*>": ["<Action>" | "*"] | "*" }
40
+ * }
41
+ *
42
+ * Resource types are the platform's resource categories (`Device`,
43
+ * `Bucket`, `Product`, `Endpoint`, `Property`, `Token`, `User`, …) plus
44
+ * `*` (any). The id level can be a literal id or `*` for any. The leaf is
45
+ * either an array of action strings (e.g. `["AccessDeviceResources"]`) or
46
+ * the wildcard string `"*"`.
47
+ *
48
+ * The grammar does NOT scope by *product*; only by resource type + id.
49
+ * To restrict a token to a single product's devices you would have to
50
+ * enumerate the device ids explicitly, or rely on a product-shaped naming
51
+ * convention — see `tokens.cpp:validate_token_permissions` and
52
+ * `tokens.hpp:PERMISSION_DEFINITION`. The MCP descriptions explicitly
53
+ * call this out so callers don't bake in the wrong assumption.
54
+ *
55
+ * JWT visibility
56
+ * ──────────────
57
+ * The list projection on the server omits `access_token`. The full JWT is
58
+ * returned only by the per-token GET (and once on creation). Treat the
59
+ * value as one-shot-readable: persist or hand it off as soon as you fetch
60
+ * it.
61
+ */
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
+ // ─── Duration helpers ───────────────────────────────────────────────
72
+
73
+ const DURATION_RE = /^(\d+)([smhdwy])$/;
74
+ const DURATION_MULT = {
75
+ s: 1,
76
+ m: 60,
77
+ h: 60 * 60,
78
+ d: 24 * 60 * 60,
79
+ w: 7 * 24 * 60 * 60,
80
+ y: 365 * 24 * 60 * 60,
81
+ };
82
+
83
+ /**
84
+ * Convert a relative duration string (e.g. "30d", "12h", "365d") or an
85
+ * absolute unix-seconds timestamp into a unix-seconds expiry. Returns
86
+ * `null` for null/undefined input so callers can pass through "no
87
+ * expiry" without branching.
88
+ */
89
+ export function resolveExpiry(value) {
90
+ if (value === undefined || value === null || value === '') return null;
91
+ if (typeof value === 'number') {
92
+ if (!Number.isFinite(value) || value < 0) {
93
+ throw inputError('expiry must be a non-negative number of seconds');
94
+ }
95
+ return Math.floor(value);
96
+ }
97
+ if (typeof value !== 'string') {
98
+ throw inputError('expiry must be a number, a duration string ("30d") or null');
99
+ }
100
+ const trimmed = value.trim();
101
+ if (/^\d+$/.test(trimmed)) {
102
+ return Number.parseInt(trimmed, 10);
103
+ }
104
+ const m = DURATION_RE.exec(trimmed);
105
+ if (!m) {
106
+ throw inputError(
107
+ `expiry "${value}" not understood. Use a unix-seconds number or a relative duration like "30d", "12h", "1y".`,
108
+ );
109
+ }
110
+ const n = Number.parseInt(m[1], 10);
111
+ const unit = m[2];
112
+ if (n <= 0) throw inputError('expiry duration must be positive');
113
+ return Math.floor(Date.now() / 1000) + n * DURATION_MULT[unit];
114
+ }
115
+
116
+ // ─── User-level token validators ────────────────────────────────────
117
+
118
+ export const TOKEN_ID_RE = /^[a-zA-Z0-9_]{1,50}$/;
119
+
120
+ function assertValidTokenId(id) {
121
+ if (typeof id !== 'string' || !TOKEN_ID_RE.test(id)) {
122
+ throw inputError(
123
+ 'token_id must match /^[a-zA-Z0-9_]{1,50}$/ (letters, digits, underscore, ≤ 50 chars)',
124
+ );
125
+ }
126
+ }
127
+
128
+ /**
129
+ * Client-side mirror of the server-side `validate_token_permissions`
130
+ * check. Catches grammar mistakes before the request leaves the box so
131
+ * callers get a precise error pointing at the offending node.
132
+ */
133
+ export function validatePermissionTree(node, label = 'allow') {
134
+ if (node === undefined || node === null) return;
135
+ if (typeof node !== 'object' || Array.isArray(node)) {
136
+ throw inputError(`${label} must be an object keyed by ResourceType`);
137
+ }
138
+ for (const [type, byId] of Object.entries(node)) {
139
+ if (!/^[a-zA-Z0-9_-]{1,32}$|^\*$/.test(type)) {
140
+ throw inputError(
141
+ `${label} contains invalid resource type "${type}" (allowed: A-Z, a-z, 0-9, underscore, dash; up to 32 chars; or "*")`,
142
+ );
143
+ }
144
+ if (typeof byId !== 'object' || Array.isArray(byId) || byId === null) {
145
+ throw inputError(
146
+ `${label}.${type} must be an object keyed by resource id (or "*")`,
147
+ );
148
+ }
149
+ for (const [id, actions] of Object.entries(byId)) {
150
+ if (!/^[a-zA-Z0-9_]{1,32}(?:@[a-zA-Z0-9_]{1,32})?$|^\*$/.test(id)) {
151
+ throw inputError(
152
+ `${label}.${type}.${id} is not a valid resource id (letters, digits, underscore, optional @suffix, ≤ 32 chars; or "*")`,
153
+ );
154
+ }
155
+ if (typeof actions === 'string') {
156
+ if (!/^[a-zA-Z0-9_*]{1,64}$/.test(actions) && actions !== '*') {
157
+ throw inputError(
158
+ `${label}.${type}.${id} action "${actions}" is not a valid action name`,
159
+ );
160
+ }
161
+ } else if (Array.isArray(actions)) {
162
+ for (const action of actions) {
163
+ if (typeof action === 'string') {
164
+ if (!/^[a-zA-Z0-9_*]{1,64}$/.test(action) && action !== '*') {
165
+ throw inputError(
166
+ `${label}.${type}.${id}: action "${action}" is not a valid action name`,
167
+ );
168
+ }
169
+ } else if (typeof action === 'object' && action !== null) {
170
+ // nested object form — minimal shape check.
171
+ for (const k of Object.keys(action)) {
172
+ if (!/^[a-zA-Z0-9_-]{1,32}$/.test(k)) {
173
+ throw inputError(
174
+ `${label}.${type}.${id}: nested action key "${k}" is invalid`,
175
+ );
176
+ }
177
+ }
178
+ } else {
179
+ throw inputError(
180
+ `${label}.${type}.${id}: actions must be strings or nested objects`,
181
+ );
182
+ }
183
+ }
184
+ } else {
185
+ throw inputError(
186
+ `${label}.${type}.${id} must be an array of action strings or the wildcard "*"`,
187
+ );
188
+ }
189
+ }
190
+ }
191
+ }
192
+
193
+ // ─── User-level tokens ──────────────────────────────────────────────
194
+
195
+ export async function listTokens(user) {
196
+ try {
197
+ const res = await api.get(`${v1(user)}/tokens`);
198
+ return Array.isArray(res.data) ? res.data : [];
199
+ } catch (e) {
200
+ throw apiError(e);
201
+ }
202
+ }
203
+
204
+ export async function getToken(tokenId, user) {
205
+ assertValidTokenId(tokenId);
206
+ try {
207
+ const res = await api.get(`${v1(user)}/tokens/${tokenId}`);
208
+ return res.data || {};
209
+ } catch (e) {
210
+ throw apiError(e, { notFound: `Token not found: ${tokenId}` });
211
+ }
212
+ }
213
+
214
+ /**
215
+ * Create a user-level token. Returns the persisted document, including
216
+ * the freshly-issued JWT under `access_token` (only ever returned on
217
+ * create and on per-token GET — list responses strip it).
218
+ *
219
+ * @param {{
220
+ * token: string,
221
+ * name: string,
222
+ * allow: Record<string, Record<string, string[] | string>>,
223
+ * deny?: Record<string, Record<string, string[] | string>>,
224
+ * description?: string,
225
+ * expire?: number | string | null,
226
+ * enabled?: boolean,
227
+ * }} body
228
+ */
229
+ export async function createToken(body, user) {
230
+ if (!body || typeof body !== 'object') throw inputError('token body is required');
231
+ assertValidTokenId(body.token);
232
+ if (!body.name || typeof body.name !== 'string') {
233
+ throw inputError('token name is required');
234
+ }
235
+ if (!body.allow || typeof body.allow !== 'object') {
236
+ throw inputError(
237
+ 'token allow is required (top-level: ResourceType strings; second-level: ids/"*"; leaves: arrays of action strings or "*")',
238
+ );
239
+ }
240
+ validatePermissionTree(body.allow, 'allow');
241
+ validatePermissionTree(body.deny, 'deny');
242
+
243
+ const payload = {
244
+ token: body.token,
245
+ name: body.name,
246
+ enabled: body.enabled === undefined ? true : !!body.enabled,
247
+ allow: body.allow,
248
+ };
249
+ if (body.deny) payload.deny = body.deny;
250
+ if (body.description !== undefined) payload.description = String(body.description);
251
+ const expire = resolveExpiry(body.expire);
252
+ if (expire !== null && expire !== undefined) payload.expire = expire;
253
+
254
+ try {
255
+ const res = await api.post(`${v1(user)}/tokens`, payload);
256
+ return res.data;
257
+ } catch (e) {
258
+ throw apiError(e);
259
+ }
260
+ }
261
+
262
+ /**
263
+ * Patch a token. Only the fields accepted by the server's UPDATE_SCHEMA
264
+ * (name, description, expire, enabled, allow, deny) are propagated.
265
+ */
266
+ export async function updateToken(tokenId, patch, user) {
267
+ assertValidTokenId(tokenId);
268
+ if (!patch || typeof patch !== 'object') throw inputError('patch is required');
269
+
270
+ const body = {};
271
+ if (patch.name !== undefined) body.name = String(patch.name);
272
+ if (patch.description !== undefined) body.description = String(patch.description);
273
+ if (patch.enabled !== undefined) body.enabled = !!patch.enabled;
274
+ if (patch.expire !== undefined) {
275
+ const expire = resolveExpiry(patch.expire);
276
+ if (expire !== null && expire !== undefined) body.expire = expire;
277
+ }
278
+ if (patch.allow !== undefined) {
279
+ validatePermissionTree(patch.allow, 'allow');
280
+ body.allow = patch.allow;
281
+ }
282
+ if (patch.deny !== undefined) {
283
+ validatePermissionTree(patch.deny, 'deny');
284
+ body.deny = patch.deny;
285
+ }
286
+
287
+ if (Object.keys(body).length === 0) {
288
+ throw inputError(
289
+ 'patch must set at least one of: name, description, enabled, expire, allow, deny',
290
+ );
291
+ }
292
+
293
+ try {
294
+ const res = await api.put(`${v1(user)}/tokens/${tokenId}`, body);
295
+ return res.data;
296
+ } catch (e) {
297
+ throw apiError(e, { notFound: `Token not found: ${tokenId}` });
298
+ }
299
+ }
300
+
301
+ export async function deleteToken(tokenId, user) {
302
+ assertValidTokenId(tokenId);
303
+ try {
304
+ await api.delete(`${v1(user)}/tokens/${tokenId}`);
305
+ return true;
306
+ } catch (e) {
307
+ if (e.response?.status === 404) return false;
308
+ throw apiError(e);
309
+ }
310
+ }
311
+
312
+ // ─── Device-level tokens ────────────────────────────────────────────
313
+
314
+ export async function listDeviceTokens(deviceId, user) {
315
+ if (!deviceId) throw inputError('device is required');
316
+ try {
317
+ const res = await api.get(`${v1(user)}/devices/${deviceId}/tokens`);
318
+ return Array.isArray(res.data) ? res.data : [];
319
+ } catch (e) {
320
+ throw apiError(e, { notFound: `Device not found: ${deviceId}` });
321
+ }
322
+ }
323
+
324
+ /**
325
+ * Create a device-scoped token. Server-side payload shape (different from
326
+ * user-level tokens):
327
+ *
328
+ * { token_name, token_resources?, token_expiration? }
329
+ *
330
+ * `token_resources` omitted ⇒ all resources of the device.
331
+ * `token_expiration` omitted ⇒ no expiry.
332
+ */
333
+ export async function createDeviceToken(deviceId, body, user) {
334
+ if (!deviceId) throw inputError('device is required');
335
+ if (!body || typeof body !== 'object') throw inputError('token body is required');
336
+ if (!body.token_name || typeof body.token_name !== 'string') {
337
+ throw inputError('token_name is required');
338
+ }
339
+ const payload = { token_name: body.token_name };
340
+ if (body.token_resources !== undefined) {
341
+ if (!Array.isArray(body.token_resources)) {
342
+ throw inputError('token_resources must be an array of resource names');
343
+ }
344
+ for (const r of body.token_resources) {
345
+ if (typeof r !== 'string' || !r) {
346
+ throw inputError('token_resources entries must be non-empty strings');
347
+ }
348
+ }
349
+ payload.token_resources = body.token_resources;
350
+ }
351
+ const expiration = resolveExpiry(body.token_expiration);
352
+ if (expiration !== null && expiration !== undefined) {
353
+ payload.token_expiration = expiration;
354
+ }
355
+
356
+ try {
357
+ const res = await api.post(`${v1(user)}/devices/${deviceId}/tokens`, payload);
358
+ return res.data;
359
+ } catch (e) {
360
+ throw apiError(e, { notFound: `Device not found: ${deviceId}` });
361
+ }
362
+ }
363
+
364
+ export async function deleteDeviceToken(deviceId, tokenId, user) {
365
+ if (!deviceId) throw inputError('device is required');
366
+ if (!tokenId) throw inputError('token_id is required');
367
+ try {
368
+ await api.delete(`${v1(user)}/devices/${deviceId}/tokens/${tokenId}`);
369
+ return true;
370
+ } catch (e) {
371
+ if (e.response?.status === 404) return false;
372
+ throw apiError(e);
373
+ }
374
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thinremote/thinr-cli",
3
- "version": "1.2.0-alpha.1",
3
+ "version": "1.3.0",
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": {
@@ -10,6 +10,7 @@
10
10
  "bin",
11
11
  "commands",
12
12
  "lib",
13
+ "dist",
13
14
  "CHANGELOG.md",
14
15
  "LICENSE",
15
16
  "README.md"
@@ -27,7 +28,10 @@
27
28
  "format": "prettier --write .",
28
29
  "format:check": "prettier --check .",
29
30
  "typecheck": "tsc",
30
- "test": "node --test 'tests/**/*.test.js'"
31
+ "test": "node --test 'tests/**/*.test.js'",
32
+ "build:dashboard": "node scripts/build-dashboard.mjs",
33
+ "build:dashboard:watch": "node scripts/build-dashboard.mjs --watch",
34
+ "prepare": "node scripts/build-dashboard.mjs"
31
35
  },
32
36
  "keywords": [
33
37
  "cli",
@@ -42,18 +46,24 @@
42
46
  "author": "ThinRemote",
43
47
  "license": "MIT",
44
48
  "dependencies": {
49
+ "@inkjs/ui": "^2.0.0",
45
50
  "@inquirer/prompts": "^8.4.1",
46
51
  "@modelcontextprotocol/sdk": "^1.29.0",
47
52
  "axios": "^1.10.0",
48
53
  "chalk": "^5.4.1",
54
+ "cli-progress": "^3.12.0",
49
55
  "cli-table3": "^0.6.5",
50
56
  "commander": "^14.0.0",
51
57
  "conf": "^15.1.0",
52
58
  "figlet": "^1.8.1",
59
+ "ink": "^7.0.1",
60
+ "ink-gradient": "^4.0.0",
53
61
  "jsonwebtoken": "^9.0.2",
54
62
  "open": "^11.0.0",
55
63
  "ora": "^9.3.0",
56
- "ws": "^8.18.2"
64
+ "react": "^19.2.5",
65
+ "ws": "^8.18.2",
66
+ "yaml": "^2.8.3"
57
67
  },
58
68
  "type": "module",
59
69
  "engines": {
@@ -62,6 +72,7 @@
62
72
  "devDependencies": {
63
73
  "@eslint/js": "^10.0.1",
64
74
  "@types/node": "^25.6.0",
75
+ "esbuild": "^0.28.0",
65
76
  "eslint": "^10.2.0",
66
77
  "eslint-config-prettier": "^10.1.8",
67
78
  "globals": "^17.5.0",