@thinger-io/cli-core 0.4.2 → 0.5.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.
Files changed (46) hide show
  1. package/CHANGELOG.md +207 -0
  2. package/README.md +82 -31
  3. package/lib/auth.js +25 -1
  4. package/lib/bucket-export.js +91 -0
  5. package/lib/cli.js +106 -50
  6. package/lib/commands/_shared.js +89 -1
  7. package/lib/commands/api.js +90 -0
  8. package/lib/commands/arguments.js +62 -0
  9. package/lib/commands/bucket.js +52 -44
  10. package/lib/commands/dashboard.js +5 -2
  11. package/lib/commands/data.js +539 -0
  12. package/lib/commands/device.js +128 -241
  13. package/lib/commands/grammar.js +279 -0
  14. package/lib/commands/product.js +104 -214
  15. package/lib/commands/profile.js +1 -0
  16. package/lib/commands/resources.js +975 -0
  17. package/lib/dashboard-init.js +4 -3
  18. package/lib/dashboard.js +10 -2
  19. package/lib/mcp/catalog.js +302 -64
  20. package/lib/mcp/core-types.js +36 -0
  21. package/lib/mcp/helpers.js +23 -2
  22. package/lib/mcp/naming.js +20 -0
  23. package/lib/mcp/registry.js +18 -67
  24. package/lib/mcp/resource-hooks.js +19 -5
  25. package/lib/mcp/server.js +43 -11
  26. package/lib/mcp/tools-resource.js +1234 -330
  27. package/lib/mcp/types/bucket-data.js +151 -0
  28. package/lib/mcp/{tools-dashboards.js → types/dashboard-apps.js} +99 -37
  29. package/lib/mcp/types/device-resource.js +117 -0
  30. package/lib/mcp/types/doc.js +35 -0
  31. package/lib/mcp/types/plugin-call.js +75 -0
  32. package/lib/mcp/types/product-api.js +44 -0
  33. package/lib/mcp/types/profile.js +37 -0
  34. package/lib/mcp/types/storage-file.js +170 -0
  35. package/lib/openapi.js +18 -0
  36. package/lib/product-apply.js +121 -0
  37. package/lib/raw-api.js +111 -0
  38. package/lib/storage.js +41 -13
  39. package/lib/tokens.js +16 -2
  40. package/package.json +1 -1
  41. package/lib/mcp/tools-buckets.js +0 -130
  42. package/lib/mcp/tools-devices.js +0 -134
  43. package/lib/mcp/tools-misc.js +0 -48
  44. package/lib/mcp/tools-plugins.js +0 -63
  45. package/lib/mcp/tools-products.js +0 -52
  46. package/lib/mcp/tools-storage.js +0 -173
@@ -0,0 +1,151 @@
1
+ // @ts-check
2
+ import api from '../../api.js';
3
+ import { TIMEOUTS } from '../../constants.js';
4
+ import { getBucketData } from '../../bucket.js';
5
+ import { apiError, inputError } from '../../errors.js';
6
+ import { v1 } from '../../paths.js';
7
+
8
+ /**
9
+ * The time series of a bucket, as a type of the resource tools: `list`
10
+ * reads it with the server's own query (not paged by the tool), `create`
11
+ * writes samples.
12
+ *
13
+ * @type {import('../tools-resource.js').LocalType}
14
+ */
15
+ export const bucketData = {
16
+ description: 'The time series of a bucket: list reads it, create writes samples.',
17
+ parent: 'bucket',
18
+ paged: false,
19
+ ops: { list: ['Bucket_ReadData', 'Device_ReadBucketData'], create: 'Bucket_WriteData' },
20
+ filter: {
21
+ type: 'object',
22
+ additionalProperties: false,
23
+ properties: {
24
+ device: {
25
+ type: 'string',
26
+ description:
27
+ 'Device id, to read only its records: the bucket needs the `device` tag (product buckets have it).',
28
+ },
29
+ items: {
30
+ type: 'number',
31
+ description: 'Samples to return (default 10, at most 1000; `count` sets it too).',
32
+ },
33
+ minutes: { type: 'number', description: 'Last N minutes (instead of min_ts/max_ts).' },
34
+ min_ts: { type: 'number', description: 'From (unix ms).' },
35
+ max_ts: { type: 'number', description: 'To (unix ms, 0 = now).' },
36
+ sort: { type: 'string', enum: ['desc', 'asc'], description: 'By time (default desc).' },
37
+ agg: { type: 'string', description: 'Aggregation window: "5m", "1h", "1d"…' },
38
+ agg_type: {
39
+ type: 'string',
40
+ enum: [
41
+ 'mean',
42
+ 'max',
43
+ 'min',
44
+ 'sum',
45
+ 'count',
46
+ 'first',
47
+ 'last',
48
+ 'mode',
49
+ 'median',
50
+ 'spread',
51
+ 'stddev',
52
+ ],
53
+ description: 'Reducer of each aggregation window.',
54
+ },
55
+ tz: {
56
+ type: 'string',
57
+ description:
58
+ 'IANA time zone for the aggregation windows, e.g. "Europe/Madrid": "1d" then cuts at local midnight (default UTC).',
59
+ },
60
+ fields: {
61
+ type: ['string', 'array'],
62
+ items: { type: 'string' },
63
+ description:
64
+ 'Fields to return: comma-separated or an array. Without fields, an aggregation covers numeric fields only (those of the latest record in the range); name string fields explicitly to read them with last, first, mode or count.',
65
+ },
66
+ group_by: { type: 'string', description: 'Group key, e.g. "device".' },
67
+ },
68
+ },
69
+ schema: {
70
+ type: ['object', 'array'],
71
+ description:
72
+ 'One sample ({"temperature": 21.5}), stored now or at its `ts` (unix ms), or an array of samples each with its numeric `ts` (MongoDB and InfluxDB buckets).',
73
+ },
74
+ notes: 'Without filter.device a list reads the whole bucket: a bucket declared in a product profile holds every device of the product interleaved, and filter {"group_by": "device"} gives one row per device when samples carry that tag. With filter.device, the series the bucket keeps for that device. Writes need a bucket whose data source accepts API writes; servers limit how often a bucket can be written (429 when exceeded): for long histories, import a CSV ({op:bucket.import}).',
75
+ examples: [
76
+ {
77
+ tool: 'resource_list',
78
+ arguments: {
79
+ type: 'bucket_data',
80
+ ids: { bucket: 'temps' },
81
+ filter: {
82
+ min_ts: 1793314800000,
83
+ max_ts: 1793401199999,
84
+ agg: '1h',
85
+ agg_type: 'mean',
86
+ tz: 'Europe/Madrid',
87
+ fields: ['temperature'],
88
+ items: 1000,
89
+ sort: 'asc',
90
+ },
91
+ },
92
+ does: "Hourly mean of temperature over one local day: min_ts and max_ts are that day's start and end in Europe/Madrid, as unix ms.",
93
+ },
94
+ {
95
+ tool: 'resource_list',
96
+ arguments: {
97
+ type: 'bucket_data',
98
+ ids: { bucket: 'temps' },
99
+ filter: { device: 'boiler', items: 10 },
100
+ },
101
+ does: 'The last 10 samples of one device in a product bucket.',
102
+ },
103
+ {
104
+ tool: 'resource_create',
105
+ arguments: {
106
+ type: 'bucket_data',
107
+ ids: { bucket: 'temps' },
108
+ body: [
109
+ { ts: 1793314800000, temperature: 21.5 },
110
+ { ts: 1793318400000, temperature: 22 },
111
+ ],
112
+ },
113
+ does: 'Write two samples, each at its own time.',
114
+ },
115
+ ],
116
+ list(ctx) {
117
+ const filter = ctx.filter || {};
118
+ return getBucketData({
119
+ bucket: String(ctx.parent),
120
+ ...filter,
121
+ items: filter.items ?? ctx.count ?? 10,
122
+ sort: filter.sort ?? 'desc',
123
+ fields: Array.isArray(filter.fields) ? filter.fields.join(',') : filter.fields,
124
+ user: ctx.user,
125
+ });
126
+ },
127
+ async create(body, ctx) {
128
+ if (!body || typeof body !== 'object') {
129
+ throw inputError('body must be a JSON object (one sample) or an array of them');
130
+ }
131
+ // One sample's ts goes in the query, where the server reads it.
132
+ let data = body;
133
+ let params;
134
+ if (!Array.isArray(body) && body.ts !== undefined) {
135
+ const { ts, ...rest } = body;
136
+ data = rest;
137
+ params = { ts };
138
+ }
139
+ const samples = Array.isArray(body) ? body.length : 1;
140
+ try {
141
+ await api.post(
142
+ `${v1(ctx.user)}/buckets/${encodeURIComponent(String(ctx.parent))}/data`,
143
+ data,
144
+ { params, timeout: TIMEOUTS.API_LONG_MS },
145
+ );
146
+ } catch (e) {
147
+ throw apiError(e, { notFound: `Bucket not found: ${ctx.parent}` });
148
+ }
149
+ return { bucket: ctx.parent, samples };
150
+ },
151
+ };
@@ -5,15 +5,24 @@ import {
5
5
  collectFiles,
6
6
  destinationContext,
7
7
  manifestName,
8
+ defaultStorage,
9
+ listFiles,
8
10
  publishApp,
9
11
  publishSummary,
10
- } from '../dashboard.js';
11
- import { devLink } from '../dashboard-dev.js';
12
- import { DEFAULT_TEMPLATE, initApp } from '../dashboard-init.js';
13
- import { inputError } from '../errors.js';
14
- import { toolName } from './naming.js';
12
+ } from '../../dashboard.js';
13
+ import { devLink } from '../../dashboard-dev.js';
14
+ import { DEFAULT_TEMPLATE, initApp } from '../../dashboard-init.js';
15
+ import { inputError } from '../../errors.js';
16
+ import { opRef } from '../naming.js';
17
+
18
+ /**
19
+ * Dashboard apps on the dashboard SDK: actions of the catalog type
20
+ * dashboard that run on this machine (start a project, link its dev
21
+ * server to the server) or upload its build (publish).
22
+ */
15
23
 
16
24
  async function toolInit(args) {
25
+ args = args ?? {};
17
26
  if (args.to) checkDestination(args.to);
18
27
  const r = await initApp({
19
28
  dir: args.dir,
@@ -30,7 +39,7 @@ async function toolInit(args) {
30
39
  type: 'text',
31
40
  text: [
32
41
  `Created ${r.dir}: ${r.template} app on @thinger-io/dashboard-sdk ${r.dependency}, with ${manifestName()} (publishes ${r.build} to storage ${r.storage}${args.to ? '' : '; add destinations under dashboard.to'}) and AGENTS.md (how to develop it and the rules of the console's frame: read it).`,
33
- `Next: ${r.installed ? '' : 'npm install, then '}${r.devCommand} in the background (it serves ${r.devUrl}), ${toolName('dashboard_dev')} with url ${r.devUrl}${Object.keys(r.context).length ? '' : ' and a context'} and dir ${r.dir}, open the link with browser tools; when it works, npm run build and ${toolName('dashboard_publish')} with dir ${r.dir}/${r.build}.`,
42
+ `Next: ${r.installed ? '' : 'npm install, then '}${r.devCommand} in the background (it serves ${r.devUrl}), ${opRef('dashboard', 'dev')} with url ${r.devUrl}${Object.keys(r.context).length ? '' : ' and a context'} and dir ${r.dir}, open the link with browser tools; when it works, npm run build and ${opRef('dashboard', 'publish')} with dir ${r.dir}/${r.build}.`,
34
43
  ].join('\n'),
35
44
  },
36
45
  ],
@@ -38,7 +47,8 @@ async function toolInit(args) {
38
47
  };
39
48
  }
40
49
 
41
- async function toolDev(args) {
50
+ async function toolDev(args, ctx) {
51
+ args = args ?? {};
42
52
  if (!args.url) throw inputError("pass url: the app's dev server, e.g. http://localhost:4200/");
43
53
  if (args.to && args.context) throw inputError('pass to or context, not both');
44
54
  if (args.to) checkDestination(args.to);
@@ -47,11 +57,11 @@ async function toolDev(args) {
47
57
  context: args.to ? destinationContext(args.to) : args.context,
48
58
  route: args.route ?? args.to?.route,
49
59
  hours: args.hours,
50
- user: args.user,
60
+ user: ctx.user,
51
61
  dir: args.dir || process.cwd(),
52
62
  allowRemote: args.allow_remote === true,
53
63
  });
54
- const ctx = Object.entries(dev.context).map(([k, v]) => `${k} ${v}`);
64
+ const shown = Object.entries(dev.context).map(([k, v]) => `${k} ${v}`);
55
65
  const until = new Date(dev.expire).toISOString().slice(0, 16).replace('T', ' ');
56
66
  return {
57
67
  content: [
@@ -59,10 +69,11 @@ async function toolDev(args) {
59
69
  type: 'text',
60
70
  text: [
61
71
  `Open this link in a browser: ${dev.link}`,
62
- `It opens ${dev.app} as a standalone page against ${dev.server} as ${dev.user}${ctx.length ? `, context ${ctx.join(', ')}` : ''}, with a temporary token (${dev.token}, full access, until ${until} UTC; a new link replaces it). Calls reach the real server: writes are real.`,
72
+ `It opens ${dev.app} as a standalone page against ${dev.server} as ${dev.user}${shown.length ? `, context ${shown.join(', ')}` : ''}
73
+ , with a temporary token (${dev.token}, full access, until ${until} UTC; a new link replaces it). Calls reach the real server: writes are real.`,
63
74
  ...dev.notes.map((n) => `Check: ${n}`),
64
75
  "The app's dev server reloads it on every change; its errors are in the browser console. Unlike in the console's sandboxed iframe, localStorage, cookies and a <base> tag work here, so do not rely on them.",
65
- `When it works, build it and publish the build once with ${toolName('dashboard_publish')}.`,
76
+ `When it works, build it and publish the build once with ${opRef('dashboard', 'publish')}.`,
66
77
  ].join('\n'),
67
78
  },
68
79
  ],
@@ -70,7 +81,8 @@ async function toolDev(args) {
70
81
  };
71
82
  }
72
83
 
73
- async function toolPublish(args) {
84
+ async function toolPublish(args, ctx) {
85
+ args = args ?? {};
74
86
  if (args.dir && args.files) throw inputError('pass dir or files, not both');
75
87
  if (args.files) {
76
88
  if (typeof args.files !== 'object' || Array.isArray(args.files)) {
@@ -80,15 +92,31 @@ async function toolPublish(args) {
80
92
  if (typeof content !== 'string') throw inputError(`files["${file}"] must be a string`);
81
93
  }
82
94
  }
95
+ const files = args.dir ? collectFiles(args.dir) : args.files;
96
+ // Pruning deletes files of the storage: say which before doing it.
97
+ const to = Array.isArray(args.to) ? args.to : [];
98
+ const storage =
99
+ args.storage ||
100
+ (to.length === 1 && (to[0].dashboard || (to[0].type && to[0].id))
101
+ ? defaultStorage(to[0].dashboard, to[0].dashboard ? undefined : to[0])
102
+ : undefined);
103
+ if (args.prune && files && storage && !ctx.confirmed) {
104
+ const gone = (await listFiles(storage, ctx.user)).filter((f) => !(f in files));
105
+ const shown =
106
+ gone.slice(0, 50).join(', ') + (gone.length > 50 ? ` (+${gone.length - 50})` : '');
107
+ return ctx.plan(
108
+ `It would upload ${Object.keys(files).length} file(s) to storage ${storage} and delete the ${gone.length} file(s) of it that are not in this upload${gone.length ? `: ${shown}` : ''}.`,
109
+ );
110
+ }
83
111
  const result = await publishApp({
84
112
  storage: args.storage,
85
- files: args.dir ? collectFiles(args.dir) : args.files,
113
+ files,
86
114
  prune: args.prune,
87
115
  to: args.to,
88
116
  preview: args.preview,
89
117
  point: args.point,
90
118
  bump: args.bump,
91
- user: args.user,
119
+ user: ctx.user,
92
120
  dir: args.dir,
93
121
  });
94
122
  return {
@@ -97,12 +125,15 @@ async function toolPublish(args) {
97
125
  };
98
126
  }
99
127
 
100
- export const tools = [
101
- {
102
- name: 'dashboard_init',
103
- description: `Create a dashboard app: a TypeScript project on @thinger-io/dashboard-sdk from one of the templates the SDK package ships (react by default, angular…), with its manifest for {tool:dashboard_dev} and {tool:dashboard_publish} and an AGENTS.md with the rules of the console's frame. Start every new dashboard app here instead of writing one from scratch.`,
104
- inputSchema: {
128
+ /** @type {Record<string, import('../tools-resource.js').LocalAction>} */
129
+ export const dashboardActions = {
130
+ init: {
131
+ on: 'collection',
132
+ local: true,
133
+ description: `Create a dashboard app: a TypeScript project on @thinger-io/dashboard-sdk from one of the templates the SDK package ships (react by default, angular…), with its manifest for its dev and publish actions and an AGENTS.md with the rules of the console's frame. Start every new dashboard app here instead of writing one from scratch.`,
134
+ body: {
105
135
  type: 'object',
136
+ additionalProperties: false,
106
137
  properties: {
107
138
  dir: {
108
139
  type: 'string',
@@ -116,7 +147,7 @@ export const tools = [
116
147
  to: {
117
148
  type: 'object',
118
149
  description:
119
- 'Where it publishes, as in dashboard_publish: {type, id}, or {dashboard}. Its context is the one dashboard_dev uses.',
150
+ 'Where it publishes, as in publish: {type, id}, or {dashboard}. Its context is the one dev uses.',
120
151
  properties: {
121
152
  dashboard: { type: 'string' },
122
153
  type: { type: 'string', enum: DASHBOARD_TARGETS },
@@ -140,15 +171,28 @@ export const tools = [
140
171
  },
141
172
  required: ['dir'],
142
173
  },
143
- handler: toolInit,
174
+ examples: [
175
+ {
176
+ tool: 'resource_call',
177
+ arguments: {
178
+ type: 'dashboard',
179
+ action: 'init',
180
+ body: { dir: 'plant-dashboard', to: { type: 'project', id: 'plant_a' } },
181
+ },
182
+ does: 'Start a React dashboard app that publishes as the dashboard of project plant_a.',
183
+ },
184
+ ],
185
+ run: (body) => toolInit(body),
144
186
  },
145
- {
146
- name: 'dashboard_dev',
147
- description: `Develop a dashboard app on its own dev server (ng serve, npm run dev…) against this server, then publish it once with {tool:dashboard_publish}, instead of publishing to try every change.
187
+ dev: {
188
+ on: 'collection',
189
+ local: true,
190
+ description: `Develop a dashboard app on its own dev server (ng serve, npm run dev…) against this server, then publish it once with its publish action, instead of publishing to try every change.
148
191
 
149
192
  Returns a link to the app's local url that opens it as a standalone page: the SDK reads the server, a temporary token and the context from the link and calls the server directly, so the app gets real data, its dev server reloads it on every change and its errors are in the browser console. Start the app's dev server first (the answer says when nothing answers at url). Open the link with the browser tools to see the app and read its console. Each call replaces the previous link's token.`,
150
- inputSchema: {
193
+ body: {
151
194
  type: 'object',
195
+ additionalProperties: false,
152
196
  properties: {
153
197
  url: {
154
198
  type: 'string',
@@ -158,7 +202,7 @@ Returns a link to the app's local url that opens it as a standalone page: the SD
158
202
  to: {
159
203
  type: 'object',
160
204
  description:
161
- 'Destination whose context the app gets, as in dashboard_publish: {type, id}, or {dashboard} for a collection dashboard (no context).',
205
+ 'Destination whose context the app gets, as in publish: {type, id}, or {dashboard} for a collection dashboard (no context).',
162
206
  properties: {
163
207
  dashboard: { type: 'string' },
164
208
  type: { type: 'string', enum: DASHBOARD_TARGETS },
@@ -186,21 +230,24 @@ Returns a link to the app's local url that opens it as a standalone page: the SD
186
230
  description:
187
231
  'Allow a url that is not on this machine. The link carries a full-access token: only for a dev server the user named and trusts.',
188
232
  },
189
- user: { type: 'string', description: 'Account to act on (admin only).' },
190
233
  },
191
234
  required: ['url'],
192
235
  },
193
- handler: toolDev,
236
+ run: (body, ctx) => toolDev(body, ctx),
194
237
  },
195
- {
196
- name: 'dashboard_publish',
197
- description: `Publish an HTML app to a public storage and point dashboards at it. Upload (dir or files) and point (to) are both optional: upload a new build, point more dashboards at an app already uploaded, or both. Read the dashboard SDK reference with {tool:resource_describe} type "dashboard" first.
238
+ publish: {
239
+ on: 'collection',
240
+ local: true,
241
+ confirm: true,
242
+ op: ['Storage_UpdateFilesFiles(?<file>.*$)', 'Dashboard_Create', 'Dashboard_Update'],
243
+ description: `Publish an HTML app to a public storage and point dashboards at it. Upload (dir or files) and point (to) are both optional: upload a new build, point more dashboards at an app already uploaded, or both. Read the dashboard SDK reference first ({op:dashboard.describe}).
198
244
 
199
245
  Destinations (to): {dashboard} for the dashboards collection (created if missing, empty context), or {type, id} to set the "dashboard" property of a device, product, asset_type, asset_group or project, whose id the page gets in Thinger.context. Each may open its own entry page and an initial hash route.
200
246
 
201
- One app can serve many dashboards: name its storage (e.g. app_<project>) and upload the build output folder with prune: true (hashed file names change between builds). Dashboards point at the storage folder (…/files/, served as the storage's index) or at another entry page by name, without version queries: files are served with no-cache and an ETag, so viewers always get the latest upload. Apps in the iframe: start them with {tool:dashboard_init} (a TypeScript project on @thinger-io/dashboard-sdk with these rules set up and an AGENTS.md); hash routing, relative asset URLs and no <base> tag, render after Thinger.ready(), and no localStorage or cookies (opaque origin: keep state in properties through the SDK). Publish the build output folder; the answer warns when the app's SDK needs a newer server than this one, is not the latest on npm, or a page loads the deprecated script build (thinger-dashboard.js).`,
202
- inputSchema: {
247
+ One app can serve many dashboards: name its storage (e.g. app_<project>) and upload the build output folder with prune: true (hashed file names change between builds). Dashboards point at the storage folder (…/files/, served as the storage's index) or at another entry page by name, without version queries: files are served with no-cache and an ETag, so viewers always get the latest upload. Apps in the iframe: start them with the init action (a TypeScript project on @thinger-io/dashboard-sdk with these rules set up and an AGENTS.md); hash routing, relative asset URLs and no <base> tag, render after Thinger.ready(), and no localStorage or cookies (opaque origin: keep state in properties through the SDK). Publish the build output folder; the answer warns when the app's SDK needs a newer server than this one, is not the latest on npm, or a page loads the deprecated script build (thinger-dashboard.js).`,
248
+ body: {
203
249
  type: 'object',
250
+ additionalProperties: false,
204
251
  properties: {
205
252
  storage: {
206
253
  type: 'string',
@@ -218,7 +265,8 @@ One app can serve many dashboards: name its storage (e.g. app_<project>) and upl
218
265
  },
219
266
  prune: {
220
267
  type: 'boolean',
221
- description: 'Delete files of the storage that are not in this upload.',
268
+ description:
269
+ 'Delete files of the storage that are not in this upload (asks first: see confirm).',
222
270
  },
223
271
  to: {
224
272
  type: 'array',
@@ -253,10 +301,24 @@ One app can serve many dashboards: name its storage (e.g. app_<project>) and upl
253
301
  description:
254
302
  'false: only build preview links for `to`, without pointing those dashboards again.',
255
303
  },
256
- user: { type: 'string', description: 'Account to act on (admin only).' },
257
304
  },
258
305
  required: [],
259
306
  },
260
- handler: toolPublish,
307
+ examples: [
308
+ {
309
+ tool: 'resource_call',
310
+ arguments: {
311
+ type: 'dashboard',
312
+ action: 'publish',
313
+ body: {
314
+ dir: './dist',
315
+ storage: 'app_plant',
316
+ to: [{ dashboard: 'plant_overview' }],
317
+ },
318
+ },
319
+ does: 'Upload the build in ./dist to storage app_plant and point dashboard plant_overview at it.',
320
+ },
321
+ ],
322
+ run: (body, ctx) => toolPublish(body, ctx),
261
323
  },
262
- ];
324
+ };
@@ -0,0 +1,117 @@
1
+ // @ts-check
2
+ import api from '../../api.js';
3
+ import { apiError } from '../../errors.js';
4
+ import { v1 } from '../../paths.js';
5
+ import { callDeviceResource, listDeviceResourcesWithSchemas } from '../../resource.js';
6
+ import { textResult } from '../helpers.js';
7
+
8
+ /** What a resource takes and gives, by its `fn`. */
9
+ const KIND = { 1: 'no params', 2: 'input', 3: 'output', 4: 'input/output' };
10
+
11
+ /**
12
+ * The resources of a connected device (its firmware's API plus the API
13
+ * resources its product adds), as a type of the resource tools: list
14
+ * them, and run one with the `call` action. There is no get: reading a
15
+ * resource without input runs it.
16
+ *
17
+ * @type {import('../tools-resource.js').LocalType}
18
+ */
19
+ export const deviceResource = {
20
+ description:
21
+ "Resources of a connected device: its firmware's API (a sensor to read, an output to switch) plus the API resources its product adds. Not server resources. The device must be online.",
22
+ parent: 'device',
23
+ id: 'resource',
24
+ recordId: 'name',
25
+
26
+ ops: { list: 'Device_AccessResources' },
27
+ notes: 'list gives each resource with its kind and its input and output shapes. Run one with the call action: with a body it runs with that input; without, it reads its output (a resource without input runs anyway).',
28
+ async list(ctx) {
29
+ const entries = await listDeviceResourcesWithSchemas(String(ctx.parent), {
30
+ user: ctx.user,
31
+ });
32
+ return entries.map((e) => ({ ...e, kind: KIND[e.fn] ?? 'unknown' }));
33
+ },
34
+ actions: {
35
+ call: {
36
+ description:
37
+ "Run a resource of a connected device with `body` as its input, and return its output; without a body it reads the output (a resource without input runs). Its input and output shapes are in the list of the device's resources.",
38
+ op: ['Device_AccessInputResources', 'Device_AccessOutputResources'],
39
+ examples: [
40
+ {
41
+ tool: 'resource_call',
42
+ arguments: {
43
+ type: 'device_resource',
44
+ action: 'call',
45
+ ids: { device: 'boiler', resource: 'led' },
46
+ body: true,
47
+ },
48
+ does: 'Switch the resource led on (its input is a boolean).',
49
+ },
50
+ {
51
+ tool: 'resource_call',
52
+ arguments: {
53
+ type: 'device_resource',
54
+ action: 'call',
55
+ ids: { device: 'boiler', resource: 'temperature' },
56
+ },
57
+ does: 'Read the current output of the resource temperature.',
58
+ },
59
+ ],
60
+ async run(body, ctx) {
61
+ const data = await callDeviceResource(String(ctx.parent), String(ctx.id), body, {
62
+ user: ctx.user,
63
+ });
64
+ return textResult(
65
+ data === undefined || data === null || data === ''
66
+ ? '(no output)'
67
+ : typeof data === 'string'
68
+ ? data
69
+ : JSON.stringify(data, null, 2),
70
+ data ?? null,
71
+ );
72
+ },
73
+ },
74
+ },
75
+ };
76
+
77
+ /**
78
+ * Usage of every device of the account, and their positions: an action on
79
+ * the collection of devices.
80
+ *
81
+ * @type {import('../tools-resource.js').LocalAction}
82
+ */
83
+ export const overview = {
84
+ description:
85
+ 'Usage of all the devices of the account over the last 30 days, per day; with body {locations: true}, also the last known position of each connected device.',
86
+ on: 'collection',
87
+ readOnly: true,
88
+ op: ['Device_ReadDevicesStatistics', 'User_ListDeviceLocations'],
89
+ body: {
90
+ type: 'object',
91
+ additionalProperties: false,
92
+ properties: {
93
+ locations: {
94
+ type: 'boolean',
95
+ description: 'Also the last known position of each connected device.',
96
+ },
97
+ },
98
+ },
99
+ examples: [
100
+ {
101
+ tool: 'resource_call',
102
+ arguments: { type: 'device', action: 'overview', body: { locations: true } },
103
+ does: 'Usage of every device, and where the connected ones are.',
104
+ },
105
+ ],
106
+ async run(body, ctx) {
107
+ try {
108
+ const [stats, locations] = await Promise.all([
109
+ api.get(`${v1(ctx.user)}/info/devices/stats`),
110
+ body?.locations ? api.get(`${v1(ctx.user)}/info/devices/locations`) : null,
111
+ ]);
112
+ return locations ? { stats: stats.data, locations: locations.data } : stats.data;
113
+ } catch (e) {
114
+ throw apiError(e);
115
+ }
116
+ },
117
+ };
@@ -0,0 +1,35 @@
1
+ // @ts-check
2
+ import { listDocs, readDoc } from '../../docs.js';
3
+
4
+ /**
5
+ * The guides for agents and app developers (docs.js) as a type of the
6
+ * resource tools: list them, read one whole.
7
+ *
8
+ * @type {import('../tools-resource.js').LocalType}
9
+ */
10
+ export const doc = {
11
+ description:
12
+ "Guides for agents and app developers: the platform's concepts, product code, server administration, the dashboard SDK reference and types (the version the project in this folder uses), and this CLI's own.",
13
+ id: 'doc',
14
+ user: false,
15
+ local: true,
16
+ notes: 'Read the relevant guide before building something new: list gives the ids.',
17
+ examples: [
18
+ {
19
+ tool: 'resource_get',
20
+ arguments: { type: 'doc', ids: { doc: 'concepts' } },
21
+ does: "Read the guide to the platform's concepts.",
22
+ },
23
+ ],
24
+ async list() {
25
+ return (await listDocs()).map((d) => ({
26
+ doc: d.id,
27
+ title: d.title,
28
+ ...(d.description ? { description: d.description } : {}),
29
+ format: d.format,
30
+ }));
31
+ },
32
+ async get(ctx) {
33
+ return (await readDoc(String(ctx.id))).text;
34
+ },
35
+ };
@@ -0,0 +1,75 @@
1
+ // @ts-check
2
+ import api from '../../api.js';
3
+ import { TIMEOUTS } from '../../constants.js';
4
+ import { apiError, inputError } from '../../errors.js';
5
+ import { pluginApi } from '../../paths.js';
6
+ import { textResult } from '../helpers.js';
7
+
8
+ const METHODS = ['get', 'post', 'put', 'patch', 'delete'];
9
+
10
+ /**
11
+ * The API an installed plugin serves, at its own paths (no catalog): an
12
+ * action of the catalog type plugin. Its body is an envelope (path,
13
+ * method, body) and `query` goes to the plugin as is.
14
+ *
15
+ * @type {import('../tools-resource.js').LocalAction}
16
+ */
17
+ export const pluginCall = {
18
+ description:
19
+ "Call the API an installed plugin serves: body {path, method, body} and the call's `query` go to the plugin as they are (what it offers depends on the plugin; see its documentation or try GET on its root).",
20
+ query: true,
21
+ body: {
22
+ type: 'object',
23
+ additionalProperties: false,
24
+ properties: {
25
+ path: { type: 'string', description: 'Path inside the plugin, e.g. "/api/status".' },
26
+ method: { type: 'string', enum: METHODS, description: 'Default get.' },
27
+ body: { description: 'JSON body for post, put and patch.' },
28
+ },
29
+ },
30
+ examples: [
31
+ {
32
+ tool: 'resource_call',
33
+ arguments: {
34
+ type: 'plugin',
35
+ action: 'call',
36
+ ids: { plugin: 'node-red' },
37
+ body: { path: '/api/status', method: 'get' },
38
+ },
39
+ does: "GET the plugin's /api/status.",
40
+ },
41
+ ],
42
+ async run(body, ctx) {
43
+ const b = body ?? {};
44
+ const method = String(b.method || 'get').toLowerCase();
45
+ if (!METHODS.includes(method))
46
+ throw inputError(`body.method must be one of ${METHODS.join(', ')}`);
47
+ const path = b.path ? `/${String(b.path).replace(/^\/+/, '')}` : '';
48
+ // %2e%2e is resolved like .. by the URL parser.
49
+ const dots = (seg) => /^(\.|%2e){1,2}$/i.test(seg);
50
+ if (path.split('/').some(dots))
51
+ throw inputError('body.path must not contain "." or ".." segments');
52
+ try {
53
+ const res = await api.request({
54
+ method,
55
+ url: `${pluginApi(ctx.user, String(ctx.id))}${path}`,
56
+ data: method === 'get' || method === 'delete' ? undefined : (b.body ?? {}),
57
+ params: ctx.query,
58
+ paramsSerializer: { indexes: null },
59
+ transformResponse: [(x) => x],
60
+ responseType: 'text',
61
+ timeout: TIMEOUTS.API_LONG_MS,
62
+ });
63
+ const raw = String(res.data ?? '');
64
+ let text = raw;
65
+ let data = raw;
66
+ try {
67
+ data = JSON.parse(raw);
68
+ text = JSON.stringify(data, null, 2);
69
+ } catch {}
70
+ return textResult(text || `${res.status} (no content)`, data === '' ? null : data);
71
+ } catch (e) {
72
+ throw apiError(e, { notFound: `Not found: plugin ${ctx.id}${path}` });
73
+ }
74
+ },
75
+ };