@thinremote/thinr-cli 1.3.4 → 1.4.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 (98) hide show
  1. package/CHANGELOG.md +86 -0
  2. package/README.md +117 -123
  3. package/bin/thinr.js +43 -105
  4. package/commands/_shared.js +10 -90
  5. package/commands/device/_shared.js +2 -2
  6. package/commands/device/exec.js +2 -2
  7. package/commands/device/fs.js +2 -2
  8. package/commands/device/index.js +5 -19
  9. package/commands/device/logs.js +4 -4
  10. package/commands/device/status.js +1 -1
  11. package/commands/device/transfer.js +2 -2
  12. package/commands/device/update.js +8 -2
  13. package/commands/fleet/upgrade.js +3 -3
  14. package/commands/playbook/run.js +2 -2
  15. package/commands/playbook/validate.js +2 -2
  16. package/commands/product/_shared.js +1 -1
  17. package/commands/product/exec.js +2 -2
  18. package/commands/product/fs.js +2 -2
  19. package/commands/product/index.js +4 -11
  20. package/commands/product/logs.js +2 -2
  21. package/commands/product/metric.js +2 -2
  22. package/commands/product/playbook.js +5 -5
  23. package/commands/product/property.js +4 -4
  24. package/commands/product/push.js +2 -2
  25. package/commands/product/resource.js +4 -4
  26. package/commands/product/status.js +3 -3
  27. package/dist/dashboard.js +12 -12
  28. package/docs/guides/thinremote-metrics-logs.md +59 -0
  29. package/docs/guides/thinremote-playbooks.md +133 -0
  30. package/docs/guides/thinremote-scripts.md +115 -0
  31. package/docs/guides/thinremote.md +116 -0
  32. package/lib/command-stream.js +3 -2
  33. package/lib/console.js +6 -6
  34. package/lib/constants.js +1 -19
  35. package/lib/dashboard/event-stream.js +5 -5
  36. package/lib/device-api.js +4 -4
  37. package/lib/guides.js +41 -0
  38. package/lib/identity.js +75 -0
  39. package/lib/local-files.js +54 -0
  40. package/lib/mcp/{tools-install.js → agent-install.js} +12 -61
  41. package/lib/mcp/helpers.js +5 -25
  42. package/lib/mcp/playbook-ops.js +393 -0
  43. package/lib/mcp/registry.js +9 -43
  44. package/lib/mcp/resource-hooks.js +53 -0
  45. package/lib/mcp/resource-types.js +857 -0
  46. package/lib/mcp/tools-exec.js +217 -177
  47. package/lib/monitoring.js +3 -3
  48. package/lib/playbook/actions.js +30 -18
  49. package/lib/playbook/expression.js +1 -4
  50. package/lib/playbook/fleet.js +4 -1
  51. package/lib/playbook/loader.js +16 -8
  52. package/lib/playbook/runner.js +40 -8
  53. package/lib/playbook/schema.js +108 -34
  54. package/lib/product/log-presets.js +1 -1
  55. package/lib/product/logs.js +2 -2
  56. package/lib/product-orchestrator.js +14 -9
  57. package/lib/product.js +29 -227
  58. package/lib/proxy.js +4 -4
  59. package/lib/status.js +13 -5
  60. package/package.json +4 -2
  61. package/commands/device/delete.js +0 -53
  62. package/commands/device/list.js +0 -55
  63. package/commands/device/property.js +0 -52
  64. package/commands/device/resource.js +0 -52
  65. package/commands/logout.js +0 -47
  66. package/commands/product/list.js +0 -86
  67. package/commands/product/props.js +0 -125
  68. package/commands/profile.js +0 -173
  69. package/lib/alarms.js +0 -223
  70. package/lib/api.js +0 -89
  71. package/lib/auth.js +0 -428
  72. package/lib/bucket.js +0 -82
  73. package/lib/concurrency.js +0 -52
  74. package/lib/config.js +0 -230
  75. package/lib/devices.js +0 -60
  76. package/lib/errors.js +0 -65
  77. package/lib/format.js +0 -114
  78. package/lib/mcp/server.js +0 -67
  79. package/lib/mcp/tools-alarms.js +0 -507
  80. package/lib/mcp/tools-buckets.js +0 -99
  81. package/lib/mcp/tools-devices.js +0 -235
  82. package/lib/mcp/tools-filesystem.js +0 -281
  83. package/lib/mcp/tools-logs.js +0 -225
  84. package/lib/mcp/tools-playbook.js +0 -210
  85. package/lib/mcp/tools-products.js +0 -1557
  86. package/lib/mcp/tools-profile.js +0 -635
  87. package/lib/mcp/tools-profiles.js +0 -30
  88. package/lib/mcp/tools-properties.js +0 -79
  89. package/lib/mcp/tools-resources.js +0 -78
  90. package/lib/mcp/tools-scripts.js +0 -212
  91. package/lib/mcp/tools-tokens.js +0 -369
  92. package/lib/output.js +0 -114
  93. package/lib/paths.js +0 -32
  94. package/lib/profile.js +0 -657
  95. package/lib/property.js +0 -75
  96. package/lib/resource.js +0 -151
  97. package/lib/storage.js +0 -117
  98. package/lib/tokens.js +0 -366
package/lib/profile.js DELETED
@@ -1,657 +0,0 @@
1
- // @ts-check
2
- import api from './api.js';
3
- import { apiError, inputError } from './errors.js';
4
- import { v1 } from './paths.js';
5
-
6
- /**
7
- * Helpers for product *profile* configuration.
8
- *
9
- * Concept primer
10
- * ──────────────
11
- * A product `profile` is the server-side wiring of a product. It bundles
12
- * the four declarative subsystems the platform consumes when a device of
13
- * that product emits or receives data:
14
- *
15
- * - `profile.api.<name>` — REST API resources callable on every
16
- * device of the product (or invoked by
17
- * a script). Pick a `target` and the
18
- * platform proxies the request there.
19
- * - `profile.buckets.<name>` — auto-managed time-series buckets fed
20
- * from a declared source (`resource`,
21
- * `resource_stream`, `product_stream`,
22
- * `topic`, `event`).
23
- * - `profile.properties.<name>` — auto-managed device properties fed
24
- * from the same family of sources, with
25
- * optional JSON-merge semantics via
26
- * `data.patch`.
27
- * - `profile.code` — script-storage link (handled
28
- * elsewhere; not exposed here).
29
- *
30
- * REST endpoints these helpers wrap:
31
- * - GET /v1/users/{user}/products/{p}/profile/api
32
- * - PUT /v1/users/{user}/products/{p}/profile/api/{name}
33
- * - GET /v1/users/{user}/products/{p}/profile/api/{name}
34
- * - DELETE /v1/users/{user}/products/{p}/profile/api/{name}
35
- * - …same shape for /buckets and /properties.
36
- *
37
- * IMPORTANT: do NOT confuse `profile/properties/<name>` with
38
- * `/products/{p}/properties/{name}`. The former is a *handler* that fans
39
- * incoming source data into device properties; the latter (handled by
40
- * `getProductProperty`/`setProductProperty` in `product.js`) is a JSON
41
- * value attached to the product itself. Same word, completely different
42
- * concept — the helpers in this file consistently use the
43
- * `getProductProfileProperty` naming to keep callers honest.
44
- *
45
- * Canonical end-to-end use case
46
- * ─────────────────────────────
47
- * Fan-out a `backup_complete` stream resource → `backups` bucket
48
- * (history) and `backups` device property (last-known):
49
- *
50
- * 1. setProductApiResource(p, 'backup_complete', user, {
51
- * enabled: true,
52
- * request: { data: { target: 'resource_stream', resource_stream: 'backup_complete', payload_type: 'source_payload', payload: '' } },
53
- * response: { data: { payload_type: 'none' } },
54
- * })
55
- * 2. setProductBucket(p, 'backups', user, {
56
- * enabled: true, name: 'Backups', backend: 'mongodb',
57
- * retention: { period: 1, unit: 'years' },
58
- * data: { source: 'resource_stream', resource_stream: 'backup_complete', payload_type: 'source_payload', payload: '{{payload}}' },
59
- * })
60
- * 3. setProductProfileProperty(p, 'backups', user, {
61
- * enabled: true, name: 'Backups',
62
- * data: { source: 'resource_stream', resource_stream: 'backup_complete', payload_type: 'source_payload', payload: '{{payload}}' },
63
- * })
64
- *
65
- * After this, every device of the product can call its own `backup_complete`
66
- * stream resource. The platform fans the payload out to the bucket (history)
67
- * and to the device property `backups` (last-known) automatically — no
68
- * device code change.
69
- */
70
-
71
- // ─── Listener config validators ──────────────────────────────────────
72
- //
73
- // Each `data.source` shape (for buckets/properties) and each request
74
- // `target` (for API resources) demands a matching key in the data block.
75
- // We enforce these client-side so the caller gets a clear error long
76
- // before the server returns a generic 400.
77
-
78
- /** Sources accepted by `profile.buckets[*].data.source` and `profile.properties[*].data.source`. */
79
- export const PROFILE_DATA_SOURCES = ['resource', 'resource_stream', 'product_stream', 'topic', 'event'];
80
-
81
- /** Targets accepted by `profile.api[*].request.data.target`. */
82
- export const PROFILE_API_TARGETS = [
83
- 'resource',
84
- 'resource_stream',
85
- 'product_stream',
86
- 'property',
87
- 'function',
88
- 'endpoint',
89
- 'topic',
90
- ];
91
-
92
- /** Payload types recognised by the platform on both request and response sides. */
93
- export const PROFILE_PAYLOAD_TYPES = [
94
- 'template_payload',
95
- 'source_payload',
96
- 'source_event',
97
- 'none',
98
- ];
99
-
100
- /** Required key in `source_args` for each `data.source` value. */
101
- const SOURCE_KEY = {
102
- resource: 'resource',
103
- resource_stream: 'resource_stream',
104
- product_stream: 'product_stream',
105
- topic: 'topic',
106
- event: 'event',
107
- };
108
-
109
- /** Required key in `target_args` for each `request.data.target` value. */
110
- const TARGET_KEY = {
111
- resource: 'resource',
112
- resource_stream: 'resource_stream',
113
- property: 'property',
114
- function: 'function',
115
- endpoint: 'endpoint',
116
- topic: 'topic',
117
- // product_stream needs BOTH `product` and `product_stream` — handled inline.
118
- product_stream: 'product_stream',
119
- };
120
-
121
- function assertValidSource(source) {
122
- if (!PROFILE_DATA_SOURCES.includes(source)) {
123
- throw inputError(
124
- `Invalid source "${source}". Allowed: ${PROFILE_DATA_SOURCES.join(', ')}.`,
125
- );
126
- }
127
- }
128
-
129
- function assertValidTarget(target) {
130
- if (!PROFILE_API_TARGETS.includes(target)) {
131
- throw inputError(
132
- `Invalid target "${target}". Allowed: ${PROFILE_API_TARGETS.join(', ')}.`,
133
- );
134
- }
135
- }
136
-
137
- function assertValidPayloadType(value, label = 'payload_type') {
138
- if (value === undefined || value === null) return;
139
- if (!PROFILE_PAYLOAD_TYPES.includes(value)) {
140
- throw inputError(
141
- `Invalid ${label} "${value}". Allowed: ${PROFILE_PAYLOAD_TYPES.join(', ')}.`,
142
- );
143
- }
144
- }
145
-
146
- /**
147
- * Build the `data` block for a bucket or property listener from the
148
- * caller's source + source_args + payload bits. Returns a plain object
149
- * the server will accept verbatim.
150
- *
151
- * @param {{
152
- * source: string,
153
- * source_args?: Record<string, unknown>,
154
- * payload_type?: string,
155
- * payload?: string,
156
- * payload_function?: string,
157
- * patch?: boolean,
158
- * }} opts
159
- */
160
- export function buildListenerDataBlock(opts) {
161
- const source = opts.source;
162
- assertValidSource(source);
163
- const args = opts.source_args || {};
164
-
165
- const data = { source };
166
-
167
- const requiredKey = SOURCE_KEY[source];
168
- if (requiredKey) {
169
- const value = args[requiredKey];
170
- if (typeof value !== 'string' || !value) {
171
- throw inputError(
172
- `source "${source}" requires source_args.${requiredKey} to be a non-empty string`,
173
- );
174
- }
175
- data[requiredKey] = value;
176
- }
177
-
178
- if (source === 'resource') {
179
- // `resource` source supports an optional polling cadence. When
180
- // omitted, the server defaults to event mode (interval = 0).
181
- const update = args.update;
182
- if (update !== undefined) {
183
- if (update !== 'interval' && update !== 'event') {
184
- throw inputError(
185
- `source_args.update must be "interval" or "event" (got "${update}")`,
186
- );
187
- }
188
- data.update = update;
189
- }
190
- if (args.interval !== undefined) {
191
- const n = Number(args.interval);
192
- if (!Number.isFinite(n) || n < 0) {
193
- throw inputError('source_args.interval must be a non-negative number');
194
- }
195
- data.interval = n;
196
- }
197
- if (args.magnitude !== undefined) {
198
- data.magnitude = String(args.magnitude);
199
- }
200
- }
201
-
202
- if (source === 'product_stream') {
203
- // The bucket/property listener listens on the *current* product's
204
- // stream by default. A foreign-product listener is unusual — the
205
- // server checks `data.product` only when present. Pass it through.
206
- if (args.product !== undefined) {
207
- if (typeof args.product !== 'string' || !args.product) {
208
- throw inputError('source_args.product must be a non-empty string');
209
- }
210
- data.product = args.product;
211
- }
212
- }
213
-
214
- if (source === 'event' && args.filter !== undefined) {
215
- if (typeof args.filter !== 'object' || Array.isArray(args.filter)) {
216
- throw inputError('source_args.filter must be an object');
217
- }
218
- data.filter = args.filter;
219
- }
220
-
221
- // Payload shape — common to every listener kind.
222
- assertValidPayloadType(opts.payload_type);
223
- if (opts.payload_type !== undefined) data.payload_type = opts.payload_type;
224
- if (opts.payload !== undefined) {
225
- if (typeof opts.payload !== 'string') {
226
- throw inputError('payload must be a string (template or "{{payload}}")');
227
- }
228
- data.payload = opts.payload;
229
- }
230
- if (opts.payload_function !== undefined) {
231
- if (typeof opts.payload_function !== 'string') {
232
- throw inputError('payload_function must be a string');
233
- }
234
- data.payload_function = opts.payload_function;
235
- }
236
-
237
- // Property handler flag — only meaningful for property listeners but
238
- // accepted on the base helper for symmetry; the bucket handler simply
239
- // ignores it.
240
- if (opts.patch !== undefined) {
241
- data.patch = !!opts.patch;
242
- }
243
-
244
- return data;
245
- }
246
-
247
- /**
248
- * Build the `request.data` block for a profile API resource.
249
- *
250
- * @param {{
251
- * target: string,
252
- * target_args?: Record<string, unknown>,
253
- * resource_name?: string,
254
- * payload_type?: string,
255
- * payload?: string,
256
- * payload_function?: string,
257
- * }} opts
258
- */
259
- export function buildApiRequestData(opts) {
260
- const target = opts.target;
261
- assertValidTarget(target);
262
- const args = opts.target_args || {};
263
- const data = { target };
264
-
265
- if (target === 'product_stream') {
266
- if (typeof args.product !== 'string' || !args.product) {
267
- throw inputError(
268
- 'target "product_stream" requires target_args.product (the destination product id)',
269
- );
270
- }
271
- if (typeof args.product_stream !== 'string' || !args.product_stream) {
272
- throw inputError(
273
- 'target "product_stream" requires target_args.product_stream (the destination stream name)',
274
- );
275
- }
276
- data.product = args.product;
277
- data.product_stream = args.product_stream;
278
- } else if (target === 'resource_stream') {
279
- // resource_stream defaults to the API resource name itself, so the
280
- // caller can omit target_args entirely for a self-named stream.
281
- const stream = args.resource_stream || opts.resource_name;
282
- if (!stream) {
283
- throw inputError(
284
- 'target "resource_stream" needs target_args.resource_stream (defaults to the API resource name when omitted)',
285
- );
286
- }
287
- data.resource_stream = stream;
288
- } else {
289
- const requiredKey = TARGET_KEY[target];
290
- if (requiredKey) {
291
- const value = args[requiredKey];
292
- if (typeof value !== 'string' || !value) {
293
- throw inputError(
294
- `target "${target}" requires target_args.${requiredKey} to be a non-empty string`,
295
- );
296
- }
297
- data[requiredKey] = value;
298
- }
299
- }
300
-
301
- assertValidPayloadType(opts.payload_type);
302
- // template_payload is the platform default. The frontend encodes it
303
- // as an empty string when round-tripping, but we accept the explicit
304
- // value here too and pass it through unchanged.
305
- if (opts.payload_type !== undefined) data.payload_type = opts.payload_type;
306
- if (opts.payload !== undefined) {
307
- if (typeof opts.payload !== 'string') {
308
- throw inputError('payload must be a string');
309
- }
310
- data.payload = opts.payload;
311
- }
312
- if (opts.payload_function !== undefined) {
313
- if (typeof opts.payload_function !== 'string') {
314
- throw inputError('payload_function must be a string');
315
- }
316
- data.payload_function = opts.payload_function;
317
- }
318
-
319
- return data;
320
- }
321
-
322
- /**
323
- * Build the `response.data` block for a profile API resource. `response`
324
- * is optional on the wire; this helper produces a sensible default of
325
- * `{ payload_type: 'none' }` when the caller wants a fire-and-forget
326
- * resource, which matches the stream-style shape used by the
327
- * `backup_complete` example.
328
- *
329
- * @param {{
330
- * payload_type?: string,
331
- * source?: string,
332
- * payload?: string,
333
- * payload_function?: string,
334
- * } | undefined | null} response
335
- */
336
- export function buildApiResponseData(response) {
337
- if (!response) return { payload_type: 'none' };
338
- const data = {};
339
- assertValidPayloadType(response.payload_type, 'response.payload_type');
340
- if (response.payload_type !== undefined) data.payload_type = response.payload_type;
341
- if (response.source !== undefined) {
342
- if (typeof response.source !== 'string') {
343
- throw inputError('response.source must be a string');
344
- }
345
- data.source = response.source;
346
- }
347
- if (response.payload !== undefined) {
348
- if (typeof response.payload !== 'string') {
349
- throw inputError('response.payload must be a string');
350
- }
351
- data.payload = response.payload;
352
- }
353
- if (response.payload_function !== undefined) {
354
- if (typeof response.payload_function !== 'string') {
355
- throw inputError('response.payload_function must be a string');
356
- }
357
- data.payload_function = response.payload_function;
358
- }
359
- if (Object.keys(data).length === 0) {
360
- return { payload_type: 'none' };
361
- }
362
- return data;
363
- }
364
-
365
- // ─── Low-level: profile/api/<name> ───────────────────────────────────
366
-
367
- /** Returns the `{ resourceName: definition }` map; `{}` when none. */
368
- export async function getProductApiResources(productId, user) {
369
- try {
370
- const res = await api.get(`${v1(user)}/products/${productId}/profile/api`);
371
- return res.data || {};
372
- } catch (e) {
373
- if (e.response?.status === 404) return {};
374
- throw apiError(e);
375
- }
376
- }
377
-
378
- export async function getProductApiResource(productId, name, user) {
379
- try {
380
- const res = await api.get(
381
- `${v1(user)}/products/${productId}/profile/api/${name}`,
382
- );
383
- return res.data || {};
384
- } catch (e) {
385
- throw apiError(e, {
386
- notFound: `API resource not found: ${name} on product ${productId}`,
387
- });
388
- }
389
- }
390
-
391
- /**
392
- * Upsert a profile API resource by passing the full `{request, response, ...}`
393
- * document straight through. Use `setProfileApiResource` (below) for the
394
- * higher-level builder that takes target/target_args/payload* fields and
395
- * assembles the document for you.
396
- */
397
- export async function setProductApiResource(productId, name, user, payload) {
398
- try {
399
- await api.put(
400
- `${v1(user)}/products/${productId}/profile/api/${name}`,
401
- payload,
402
- );
403
- } catch (e) {
404
- throw apiError(e);
405
- }
406
- }
407
-
408
- export async function deleteProductApiResource(productId, name, user) {
409
- try {
410
- await api.delete(
411
- `${v1(user)}/products/${productId}/profile/api/${name}`,
412
- );
413
- return true;
414
- } catch (e) {
415
- if (e.response?.status === 404) return false;
416
- throw apiError(e);
417
- }
418
- }
419
-
420
- /**
421
- * Upsert a profile API resource using the structured target/payload shape.
422
- * Lets callers configure any of the platform's dispatch targets without
423
- * hand-rolling the JSON. Required fields per target are validated client-
424
- * side: missing `target_args.resource` for `target: "resource"` raises
425
- * `inputError` instead of waiting for a 400 from the server.
426
- *
427
- * @param {string} productId
428
- * @param {string} name API resource name (becomes `profile.api.<name>`).
429
- * @param {string|null|undefined} user
430
- * @param {{
431
- * target: string,
432
- * target_args?: Record<string, unknown>,
433
- * payload_type?: string,
434
- * payload?: string,
435
- * payload_function?: string,
436
- * response?: {
437
- * payload_type?: string,
438
- * source?: string,
439
- * payload?: string,
440
- * payload_function?: string,
441
- * } | null,
442
- * enabled?: boolean,
443
- * }} opts
444
- */
445
- export async function setProfileApiResource(productId, name, user, opts) {
446
- if (!productId) throw inputError('productId is required');
447
- if (!name) throw inputError('name is required');
448
- const requestData = buildApiRequestData({ ...opts, resource_name: name });
449
- const responseData = buildApiResponseData(opts.response);
450
- const payload = {
451
- enabled: opts.enabled !== false,
452
- request: { data: requestData },
453
- response: { data: responseData },
454
- };
455
- await setProductApiResource(productId, name, user, payload);
456
- return payload;
457
- }
458
-
459
- // ─── Low-level: profile/buckets/<name> ───────────────────────────────
460
-
461
- export async function getProductBuckets(productId, user) {
462
- try {
463
- const res = await api.get(`${v1(user)}/products/${productId}/profile/buckets`);
464
- return res.data || {};
465
- } catch (e) {
466
- if (e.response?.status === 404) return {};
467
- throw apiError(e);
468
- }
469
- }
470
-
471
- export async function getProductBucket(productId, name, user) {
472
- try {
473
- const res = await api.get(
474
- `${v1(user)}/products/${productId}/profile/buckets/${name}`,
475
- );
476
- return res.data || {};
477
- } catch (e) {
478
- throw apiError(e, {
479
- notFound: `Profile bucket not found: ${name} on product ${productId}`,
480
- });
481
- }
482
- }
483
-
484
- export async function setProductBucket(productId, name, user, payload) {
485
- try {
486
- await api.put(
487
- `${v1(user)}/products/${productId}/profile/buckets/${name}`,
488
- payload,
489
- );
490
- } catch (e) {
491
- throw apiError(e);
492
- }
493
- }
494
-
495
- export async function deleteProductBucket(productId, name, user) {
496
- try {
497
- await api.delete(
498
- `${v1(user)}/products/${productId}/profile/buckets/${name}`,
499
- );
500
- return true;
501
- } catch (e) {
502
- if (e.response?.status === 404) return false;
503
- throw apiError(e);
504
- }
505
- }
506
-
507
- const RETENTION_UNITS = ['minutes', 'hours', 'days', 'months', 'years'];
508
-
509
- function buildRetention(retention) {
510
- if (retention === undefined || retention === null) return undefined;
511
- if (typeof retention !== 'object' || Array.isArray(retention)) {
512
- throw inputError('retention must be an object { period, unit }');
513
- }
514
- const period = Number(retention.period);
515
- if (!Number.isFinite(period) || period <= 0) {
516
- throw inputError('retention.period must be a positive number');
517
- }
518
- const unit = retention.unit;
519
- if (!RETENTION_UNITS.includes(unit)) {
520
- throw inputError(
521
- `retention.unit must be one of: ${RETENTION_UNITS.join(', ')}`,
522
- );
523
- }
524
- return { period, unit };
525
- }
526
-
527
- /**
528
- * Upsert a profile bucket using the structured source/payload shape. The
529
- * server creates / migrates the underlying time-series bucket lazily on
530
- * the first event delivery (see `product_bucket_handler::initialize_bucket`).
531
- *
532
- * @param {string} productId
533
- * @param {string} name
534
- * @param {string|null|undefined} user
535
- * @param {{
536
- * source: string,
537
- * source_args?: Record<string, unknown>,
538
- * payload_type?: string,
539
- * payload?: string,
540
- * payload_function?: string,
541
- * name_label?: string,
542
- * description?: string,
543
- * enabled?: boolean,
544
- * backend?: string,
545
- * retention?: { period: number, unit: string },
546
- * tags?: string[],
547
- * }} opts
548
- */
549
- export async function setProfileBucket(productId, name, user, opts) {
550
- if (!productId) throw inputError('productId is required');
551
- if (!name) throw inputError('name is required');
552
-
553
- const data = buildListenerDataBlock(opts);
554
- const payload = {
555
- enabled: opts.enabled !== false,
556
- name: opts.name_label || name,
557
- description: opts.description || `Bucket for ${productId} product`,
558
- backend: opts.backend || 'mongodb',
559
- data,
560
- tags: Array.isArray(opts.tags) ? opts.tags : [],
561
- };
562
- const retention = buildRetention(opts.retention);
563
- if (retention) payload.retention = retention;
564
-
565
- await setProductBucket(productId, name, user, payload);
566
- return payload;
567
- }
568
-
569
- // ─── Low-level: profile/properties/<name> ────────────────────────────
570
- //
571
- // NOTE: `getProductProfileProperty` ≠ `getProductProperty`. The former
572
- // lives under `profile/properties/<name>` and configures a *handler* that
573
- // fans listener output into device properties. The latter (in product.js)
574
- // reads a JSON value attached to the product itself. See file header.
575
-
576
- export async function getProductProfileProperties(productId, user) {
577
- try {
578
- const res = await api.get(`${v1(user)}/products/${productId}/profile/properties`);
579
- return res.data || {};
580
- } catch (e) {
581
- if (e.response?.status === 404) return {};
582
- throw apiError(e);
583
- }
584
- }
585
-
586
- export async function getProductProfileProperty(productId, name, user) {
587
- try {
588
- const res = await api.get(
589
- `${v1(user)}/products/${productId}/profile/properties/${name}`,
590
- );
591
- return res.data || {};
592
- } catch (e) {
593
- throw apiError(e, {
594
- notFound: `Profile property not found: ${name} on product ${productId}`,
595
- });
596
- }
597
- }
598
-
599
- export async function setProductProfileProperty(productId, name, user, payload) {
600
- try {
601
- await api.put(
602
- `${v1(user)}/products/${productId}/profile/properties/${name}`,
603
- payload,
604
- );
605
- } catch (e) {
606
- throw apiError(e);
607
- }
608
- }
609
-
610
- export async function deleteProductProfileProperty(productId, name, user) {
611
- try {
612
- await api.delete(
613
- `${v1(user)}/products/${productId}/profile/properties/${name}`,
614
- );
615
- return true;
616
- } catch (e) {
617
- if (e.response?.status === 404) return false;
618
- throw apiError(e);
619
- }
620
- }
621
-
622
- /**
623
- * Upsert a profile property handler using the structured source/payload
624
- * shape. When `patch: true` is passed, the platform applies the resulting
625
- * value as a JSON merge-patch on top of the existing device property
626
- * instead of replacing it wholesale (see
627
- * `product_property_handler::set_config` for the `data.patch` flag).
628
- *
629
- * @param {string} productId
630
- * @param {string} name
631
- * @param {string|null|undefined} user
632
- * @param {{
633
- * source: string,
634
- * source_args?: Record<string, unknown>,
635
- * payload_type?: string,
636
- * payload?: string,
637
- * payload_function?: string,
638
- * name_label?: string,
639
- * description?: string,
640
- * enabled?: boolean,
641
- * patch?: boolean,
642
- * }} opts
643
- */
644
- export async function setProfileProperty(productId, name, user, opts) {
645
- if (!productId) throw inputError('productId is required');
646
- if (!name) throw inputError('name is required');
647
-
648
- const data = buildListenerDataBlock(opts);
649
- const payload = {
650
- enabled: opts.enabled !== false,
651
- name: opts.name_label || name,
652
- description: opts.description || `Property handler for ${productId} product`,
653
- data,
654
- };
655
- await setProductProfileProperty(productId, name, user, payload);
656
- return payload;
657
- }