@thinger-io/cli-core 0.2.1 → 0.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.
package/lib/dashboard.js CHANGED
@@ -5,13 +5,15 @@ import path from 'node:path';
5
5
  import api from './api.js';
6
6
  import { TIMEOUTS } from './constants.js';
7
7
  import { readConfig, serverURL } from './config.js';
8
+ import { checkSdk } from './dashboard-sdk.js';
8
9
  import { apiError, inputError } from './errors.js';
10
+ import { identity } from './identity.js';
9
11
  import { v1, v2, v3 } from './paths.js';
10
12
 
11
13
  /**
12
14
  * Url-source dashboards: an HTML page (plus its scripts, styles, images)
13
15
  * kept in a file storage and rendered by the console in an iframe, where it
14
- * talks to the server through the dashboard SDK (`/sdk/0.1/thinger-dashboard.js`).
16
+ * talks to the server through the dashboard SDK (`@thinger-io/dashboard-sdk`, in its build).
15
17
  *
16
18
  * The storage must be public: the iframe cannot send an Authorization
17
19
  * header when it loads the page's files. Only the code is exposed; every API
@@ -200,7 +202,7 @@ async function remove(storage, files, user) {
200
202
  */
201
203
 
202
204
  /** @param {Destination} d */
203
- function checkDestination(d) {
205
+ export function checkDestination(d) {
204
206
  if (!d || typeof d !== 'object') throw inputError('each destination must be an object');
205
207
  if (!d.dashboard === !(d.type || d.id)) {
206
208
  throw inputError('a destination is either {dashboard} or {type, id}');
@@ -334,7 +336,25 @@ export function previewTokenId(storage, project) {
334
336
  * @returns {Promise<{ token: string, accessToken: string, expire: number }>}
335
337
  */
336
338
  export async function previewToken(storage, { project, hours = PREVIEW_HOURS, user } = {}) {
337
- const token = previewTokenId(storage, project);
339
+ return replaceToken(previewTokenId(storage, project), {
340
+ name: `Preview of ${storage}${project ? ` (${project})` : ''}`,
341
+ allow: PREVIEW_ALLOW,
342
+ hours,
343
+ project,
344
+ user,
345
+ });
346
+ }
347
+
348
+ /**
349
+ * Create an access token valid for `hours`, replacing the one with the same
350
+ * id: the expiry is signed into the token, so it is renewed by deleting it
351
+ * and creating it again. Inside a project (?project=) it only works there.
352
+ *
353
+ * @param {string} token Token id.
354
+ * @param {{ name: string, allow: object, hours: number, project?: string, user?: string | null }} opts
355
+ * @returns {Promise<{ token: string, accessToken: string, expire: number }>}
356
+ */
357
+ export async function replaceToken(token, { name, allow, hours, project, user }) {
338
358
  const params = project ? { project } : undefined;
339
359
  try {
340
360
  await api.delete(`${v1(user)}/tokens/${token}`, { params });
@@ -346,13 +366,7 @@ export async function previewToken(storage, { project, hours = PREVIEW_HOURS, us
346
366
  try {
347
367
  res = await api.post(
348
368
  `${v1(user)}/tokens`,
349
- {
350
- token,
351
- name: `Preview of ${storage}${project ? ` (${project})` : ''}`,
352
- enabled: true,
353
- allow: PREVIEW_ALLOW,
354
- expire,
355
- },
369
+ { token, name, enabled: true, allow, expire },
356
370
  { params },
357
371
  );
358
372
  } catch (e) {
@@ -375,26 +389,16 @@ export async function previewToken(storage, { project, hours = PREVIEW_HOURS, us
375
389
  export function previewUrl(url, token, context = {}) {
376
390
  const params = new URLSearchParams({ thinger_token: token });
377
391
  for (const [key, id] of Object.entries(context)) if (id) params.set(`thinger_${key}`, id);
378
- const [base, route] = url.split('#');
392
+ const hash = url.indexOf('#');
393
+ const base = hash < 0 ? url : url.slice(0, hash);
394
+ const route = hash < 0 ? '' : url.slice(hash + 1);
395
+ // The route goes first in the fragment: it must not add parameters of its own.
396
+ if (/[&#]/.test(route)) throw inputError(`a route has no "&" or "#": ${route}`);
379
397
  return `${base}#${route || '/'}&${params}`;
380
398
  }
381
399
 
382
- /** Sizes the layout check opens an app at: website embeds and a phone. */
383
- export const LAYOUT_SIZES = '1150x758,950x678,380x598';
384
-
385
- /**
386
- * The server's layout harness (/sdk/preview.html) around a preview link:
387
- * it shows the app at several sizes and lists layout issues.
388
- *
389
- * @param {string} base Server url.
390
- * @param {string} url Standalone preview link.
391
- */
392
- export function layoutUrl(base, url) {
393
- return `${base}/sdk/preview.html#src=${encodeURIComponent(url)}&sizes=${LAYOUT_SIZES}`;
394
- }
395
-
396
400
  /** The SDK context key each destination type fills. */
397
- const CONTEXT_KEYS = {
401
+ export const CONTEXT_KEYS = {
398
402
  device: 'device',
399
403
  product: 'product',
400
404
  asset_type: 'type',
@@ -466,7 +470,8 @@ async function point(d, url, user) {
466
470
  * point?: boolean,
467
471
  * bump?: boolean,
468
472
  * user?: string | null,
469
- * }} opts
473
+ * dir?: string,
474
+ * }} opts `dir`: the folder the files come from, to check the SDK it was built with.
470
475
  */
471
476
  export async function publishApp({
472
477
  storage,
@@ -477,6 +482,7 @@ export async function publishApp({
477
482
  point: repoint = true,
478
483
  bump = false,
479
484
  user,
485
+ dir,
480
486
  }) {
481
487
  if (!Array.isArray(to)) throw inputError('to must be a list of destinations');
482
488
  to.forEach(checkDestination);
@@ -589,7 +595,6 @@ export async function publishApp({
589
595
  previews.push({
590
596
  to: d.dashboard ? `dashboard ${d.dashboard}` : d.type ? `${d.type} ${d.id}` : 'app',
591
597
  url,
592
- layout: layoutUrl(base, url),
593
598
  expire,
594
599
  });
595
600
  }
@@ -601,5 +606,143 @@ export async function publishApp({
601
606
  deleted,
602
607
  pointed,
603
608
  previews,
609
+ sdk: files ? await checkSdk({ dir, files }) : [],
604
610
  };
605
611
  }
612
+
613
+ /**
614
+ * A destination written as `kind:id` on the command line: `dashboard:sales`,
615
+ * `device:gw1`, `product:meter`, `asset_type:pump`, `asset_group:plant`,
616
+ * `project:demo`.
617
+ *
618
+ * @param {string} value
619
+ * @returns {Destination}
620
+ */
621
+ export function parseDestination(value) {
622
+ const m = /^([a-z_]+):(.+)$/.exec(String(value));
623
+ const kinds = ['dashboard', ...DASHBOARD_TARGETS];
624
+ if (!m || !kinds.includes(m[1])) {
625
+ throw inputError(
626
+ `a destination is <${kinds.join('|')}>:<id>, e.g. device:gw1; got "${value}"`,
627
+ );
628
+ }
629
+ const d = m[1] === 'dashboard' ? { dashboard: m[2] } : { type: m[1], id: m[2] };
630
+ checkDestination(d);
631
+ return d;
632
+ }
633
+
634
+ /** The SDK context a destination gives its app (empty for collection dashboards). */
635
+ export function destinationContext(d) {
636
+ return d?.type ? { [CONTEXT_KEYS[d.type]]: d.id } : {};
637
+ }
638
+
639
+ /** File, in an app's folder, describing how it is published and developed. */
640
+ export function manifestName() {
641
+ return `${identity().name}.json`;
642
+ }
643
+
644
+ /**
645
+ * The `dashboard` section of an app's manifest (`<cli>.json`), or null when
646
+ * there is none. Paths in it are relative to the manifest.
647
+ *
648
+ * {
649
+ * "dashboard": {
650
+ * "dir": "dist/app/browser", // build output to publish
651
+ * "storage": "app_greenhouse",
652
+ * "prune": true,
653
+ * "to": [{ "type": "device", "id": "gh_01", "route": "/" }],
654
+ * "dev": { "url": "http://localhost:4200/", // the app's dev server
655
+ * "context": { "device": "gh_01" } } // default: the first destination's
656
+ * }
657
+ * }
658
+ *
659
+ * @param {string} [file] Manifest path (default: <cli>.json in the working folder).
660
+ * @returns {{ file: string, dir?: string, storage?: string, prune?: boolean, entry?: string,
661
+ * to?: Destination[], dev?: { url?: string, context?: Record<string, string>,
662
+ * route?: string, hours?: number } } | null}
663
+ */
664
+ export function readManifest(file) {
665
+ const full = path.resolve(file || manifestName());
666
+ if (!fs.existsSync(full)) {
667
+ if (file) throw inputError(`No such file: ${file}`);
668
+ return null;
669
+ }
670
+ let json;
671
+ try {
672
+ json = JSON.parse(fs.readFileSync(full, 'utf8'));
673
+ } catch (e) {
674
+ throw inputError(`${full} is not valid JSON: ${e.message}`);
675
+ }
676
+ const section = json?.dashboard;
677
+ if (!section || typeof section !== 'object') {
678
+ if (file) throw inputError(`${full} has no "dashboard" section`);
679
+ return null;
680
+ }
681
+ if (section.to !== undefined) {
682
+ if (!Array.isArray(section.to)) throw inputError(`${full}: dashboard.to must be a list`);
683
+ section.to.forEach(checkDestination);
684
+ }
685
+ const base = path.dirname(full);
686
+ const dir = section.dir ? path.resolve(base, String(section.dir)) : undefined;
687
+ // A manifest may come with someone else's repository: what it publishes
688
+ // stays inside its folder (a folder outside it is passed on the command line).
689
+ if (dir) {
690
+ const rel = path.relative(base, dir);
691
+ if (rel.startsWith('..') || path.isAbsolute(rel)) {
692
+ throw inputError(`${full}: dashboard.dir must be inside its folder`);
693
+ }
694
+ }
695
+ const types = { storage: 'string', prune: 'boolean', entry: 'string', dev: 'object' };
696
+ for (const [key, type] of Object.entries(types)) {
697
+ if (section[key] !== undefined && typeof section[key] !== type) {
698
+ throw inputError(`${full}: dashboard.${key} must be a ${type}`);
699
+ }
700
+ }
701
+ const dev = section.dev || {};
702
+ const devTypes = { url: 'string', context: 'object', route: 'string', hours: 'number' };
703
+ for (const [key, type] of Object.entries(devTypes)) {
704
+ if (dev[key] !== undefined && (typeof dev[key] !== type || dev[key] === null)) {
705
+ throw inputError(`${full}: dashboard.dev.${key} must be a ${type}`);
706
+ }
707
+ }
708
+ return { ...section, file: full, dir };
709
+ }
710
+
711
+ /**
712
+ * Human summary of a publishApp result, as the CLI prints it and the MCP
713
+ * tool answers it.
714
+ *
715
+ * @param {Awaited<ReturnType<typeof publishApp>>} result
716
+ * @param {{ point?: boolean }} [opts]
717
+ */
718
+ export function publishSummary(result, { point: repoint = true } = {}) {
719
+ const lines = [];
720
+ if (result.uploaded.length) {
721
+ lines.push(
722
+ `Uploaded ${result.uploaded.length} file(s) to ${result.storageCreated ? 'new public' : 'public'} storage ${result.storage}` +
723
+ (result.deleted.length
724
+ ? `; deleted ${result.deleted.length} no longer in the app.`
725
+ : '.'),
726
+ );
727
+ }
728
+ for (const p of result.pointed) lines.push(`${p.to} → ${p.url}\n open: ${p.open}`);
729
+ if (!result.pointed.length && repoint !== false) {
730
+ lines.push(
731
+ 'No dashboard was pointed: those already showing this storage load the new files the next time they open.',
732
+ );
733
+ }
734
+ if (result.previews.length) {
735
+ const until = new Date(result.previews[0].expire)
736
+ .toISOString()
737
+ .slice(0, 16)
738
+ .replace('T', ' ');
739
+ lines.push(
740
+ `Preview links (standalone pages for a browser, read-only token valid until ${until} UTC; servers that ignore token expiry keep it until the next preview replaces it; the previous preview links of this storage stop working):`,
741
+ );
742
+ for (const p of result.previews) {
743
+ lines.push(`${p.to} → ${p.url}`);
744
+ }
745
+ }
746
+ for (const note of result.sdk || []) lines.push(`SDK: ${note}`);
747
+ return lines.join('\n');
748
+ }
package/lib/docs.js CHANGED
@@ -1,33 +1,67 @@
1
1
  // @ts-check
2
2
  import fs from 'node:fs';
3
- import api from './api.js';
4
- import { apiError, inputError } from './errors.js';
3
+ import path from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { SDK_PACKAGE, cliSdkPackageDir } from './dashboard-sdk.js';
6
+ import { inputError } from './errors.js';
5
7
 
6
8
  /**
7
- * Documentation the server publishes for agents and app developers: a
8
- * guide to the platform's concepts, the dashboard SDK reference, its
9
- * TypeScript declarations… listed in the server's own index
10
- * (`/sdk/docs.json`), so a new guide reaches the model by being added
11
- * there, without a new CLI release. Always read from the server the profile
12
- * points at, so it matches the version it runs.
9
+ * Documentation for agents and app developers, all local: the platform's
10
+ * guides that cli-core ships (concepts, product code, server
11
+ * administration), the dashboard SDK's reference and types from its npm
12
+ * package, and the guides a CLI adds for its own platform. None depends on
13
+ * the server: where a feature needs a given server version the text says
14
+ * "since Thinger X", and the server tells its version at
15
+ * `/v1/server/version`.
13
16
  *
14
17
  * @typedef {object} Doc
15
18
  * @property {string} id
16
19
  * @property {string} title
17
20
  * @property {string} [description]
18
- * @property {string} [path] Path on the same server (`/sdk/…`), for the server's documents.
19
- * @property {string} [file] Local file, for the documents a CLI ships.
21
+ * @property {string} [file] Local file.
22
+ * @property {string[]} [files] Local files read as one document (the SDK's type declarations).
20
23
  * @property {string} [format] markdown, typescript, json…
21
24
  */
22
25
 
26
+ const DOCS_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'docs');
27
+
28
+ /** The platform's guides, shipped with cli-core. */
29
+ /** @type {Doc[]} */
30
+ export const PLATFORM_DOCS = [
31
+ {
32
+ id: 'concepts',
33
+ title: 'Thinger concepts',
34
+ description:
35
+ 'How projects, devices (incl. Virtual), products, buckets (reading, importing history), asset types and groups, properties, alarms (rule syntax), endpoints, storages, tokens, dashboards and events relate, and which data source to use for each need.',
36
+ file: path.join(DOCS_DIR, 'thinger-concepts.md'),
37
+ format: 'markdown',
38
+ },
39
+ {
40
+ id: 'product-code',
41
+ title: 'Product code',
42
+ description:
43
+ "JavaScript run by a product's profile: functions that compute resource values (simulated devices), payload transforms, device_id_resolver; runtime, signatures, placeholders, state between calls, logging and limits.",
44
+ file: path.join(DOCS_DIR, 'thinger-product-code.md'),
45
+ format: 'markdown',
46
+ },
47
+ {
48
+ id: 'server-admin',
49
+ title: 'Server administration',
50
+ description:
51
+ 'For admins: host settings and restarts, custom domains with their brand (DNS, certificates, logos, email), user accounts, OAuth clients, identity providers and proxies, with their side effects and risks.',
52
+ file: path.join(DOCS_DIR, 'thinger-server-admin.md'),
53
+ format: 'markdown',
54
+ },
55
+ ];
56
+
23
57
  /** @type {Doc[]} */
24
58
  let cliDocs = [];
25
59
 
26
60
  /**
27
- * Documents the CLI itself ships (its own platform's guides), listed next
28
- * to the server's. They travel with the CLI because they describe what the
29
- * CLI implements on top of the server. Ids must not clash with the server's:
30
- * the CLI's win.
61
+ * Documents the CLI itself ships (its own platform's guides), listed first.
62
+ * They travel with the CLI because they describe what the CLI implements on
63
+ * top of the server. On an id clash with cli-core's documents, the CLI's
64
+ * win.
31
65
  *
32
66
  * @param {Array<{ id: string, title: string, description?: string, format?: string, file: string | URL }>} docs
33
67
  */
@@ -39,113 +73,74 @@ export function defineCliDocs(docs = []) {
39
73
  }
40
74
  cliDocs = docs.map((d) => ({
41
75
  ...d,
42
- file: d.file instanceof URL ? d.file.pathname : String(d.file),
76
+ file: d.file instanceof URL ? fileURLToPath(d.file) : String(d.file),
43
77
  format: d.format || 'markdown',
44
78
  }));
45
79
  }
46
80
 
47
- const INDEX = '/sdk/docs.json';
48
-
49
- /** Documents servers published before the index existed: looked for when there is no index. */
50
- /** @type {Doc[]} */
51
- export const FALLBACK_DOCS = [
52
- {
53
- id: 'concepts',
54
- title: 'Thinger concepts',
55
- description: 'How the platform pieces relate, and which data source to use for each need.',
56
- path: '/sdk/thinger-concepts.md',
57
- format: 'markdown',
58
- },
59
- {
60
- id: 'dashboard-sdk',
61
- title: 'Dashboard SDK reference',
62
- description: 'Build url dashboards and web apps that run inside the console.',
63
- path: '/sdk/thinger-dashboard.md',
64
- format: 'markdown',
65
- },
66
- {
67
- id: 'dashboard-sdk-types',
68
- title: 'Dashboard SDK TypeScript declarations',
69
- description: 'Type declarations for the global Thinger object.',
70
- path: '/sdk/thinger-dashboard.d.ts',
71
- format: 'typescript',
72
- },
73
- ];
74
-
75
- /** A path on the profile's own server: never another host, never a parent folder. */
76
- const LOCAL_PATH = /^\/(?!\/)[^?#\\]*$/;
77
-
78
- export function validDoc(d) {
79
- return (
80
- d &&
81
- typeof d.id === 'string' &&
82
- /^[a-z0-9][a-z0-9._-]*$/i.test(d.id) &&
83
- typeof d.path === 'string' &&
84
- LOCAL_PATH.test(d.path) &&
85
- !d.path.split('/').includes('..')
86
- );
87
- }
88
-
89
- /** Text of a file on the server, whatever type it is labelled with (dev servers say video/mp2t for .ts). */
90
- async function readText(path) {
91
- const res = await api.get(path, { transformResponse: [(x) => x], responseType: 'text' });
92
- return String(res.data ?? '');
93
- }
94
-
95
81
  /**
96
- * The documents the server publishes, from its index; the known ones when
97
- * it has no index.
82
+ * The dashboard SDK's documents, from its npm package: the project's (the
83
+ * version the app uses), else the CLI's own.
98
84
  *
99
- * @returns {Promise<Doc[]>}
85
+ * @returns {Doc[]}
100
86
  */
101
- async function listServerDocs() {
102
- let raw;
103
- try {
104
- raw = await readText(INDEX);
105
- } catch (e) {
106
- if (e.response?.status === 404) return publishedFallbacks();
107
- throw apiError(e);
108
- }
109
- let docs;
87
+ function sdkDocs() {
88
+ const dir = cliSdkPackageDir();
89
+ if (!dir) return [];
90
+ let version;
110
91
  try {
111
- docs = JSON.parse(raw).docs;
92
+ version = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8')).version;
112
93
  } catch {
113
- throw inputError(`The server's documentation index (${INDEX}) is not valid JSON`);
94
+ return [];
114
95
  }
115
- return Array.isArray(docs)
116
- ? docs.filter(validDoc).map((d) => ({ ...d, title: d.title || d.id }))
96
+ /** @type {Doc[]} */
97
+ const docs = [];
98
+ const readme = path.join(dir, 'README.md');
99
+ if (fs.existsSync(readme)) {
100
+ docs.push({
101
+ id: 'dashboard-sdk',
102
+ title: `Dashboard SDK reference (${SDK_PACKAGE} ${version})`,
103
+ description:
104
+ 'Build dashboard apps (React, Angular…) on the SDK package: installing it, the console and standalone modes, routing, context and every call.',
105
+ file: readme,
106
+ format: 'markdown',
107
+ });
108
+ }
109
+ const dist = path.join(dir, 'dist');
110
+ const declarations = fs.existsSync(dist)
111
+ ? fs.readdirSync(dist).filter((f) => f.endsWith('.d.ts'))
117
112
  : [];
113
+ if (declarations.includes('index.d.ts')) {
114
+ const first = ['index.d.ts', 'types.d.ts'];
115
+ const order = [
116
+ ...first.filter((f) => declarations.includes(f)),
117
+ ...declarations.filter((f) => !first.includes(f)).sort(),
118
+ ];
119
+ docs.push({
120
+ id: 'dashboard-sdk-types',
121
+ title: `Dashboard SDK TypeScript declarations (${SDK_PACKAGE} ${version})`,
122
+ description:
123
+ 'Type declarations of the SDK (index.d.ts, then the data types and each module); they come with the package.',
124
+ files: order.map((f) => path.join(dist, f)),
125
+ format: 'typescript',
126
+ });
127
+ }
128
+ return docs;
118
129
  }
119
130
 
120
131
  /**
121
- * Every document available: the CLI's own, then the server's. When the
122
- * server cannot be read, the CLI's alone (and the error if there are none).
132
+ * Every document available: the CLI's own, the platform's, then the
133
+ * dashboard SDK's (from its package). Ids are unique: the first wins.
123
134
  *
124
135
  * @returns {Promise<Doc[]>}
125
136
  */
126
137
  export async function listDocs() {
127
- let server;
128
- try {
129
- server = await listServerDocs();
130
- } catch (e) {
131
- if (!cliDocs.length) throw e;
132
- server = [];
138
+ /** @type {Doc[]} */
139
+ const docs = [];
140
+ for (const d of [...cliDocs, ...PLATFORM_DOCS, ...sdkDocs()]) {
141
+ if (!docs.some((x) => x.id === d.id)) docs.push(d);
133
142
  }
134
- const own = new Set(cliDocs.map((d) => d.id));
135
- return [...cliDocs, ...server.filter((d) => !own.has(d.id))];
136
- }
137
-
138
- /** Without an index, the known documents this server actually serves. */
139
- async function publishedFallbacks() {
140
- const served = await Promise.all(
141
- FALLBACK_DOCS.map((d) =>
142
- readText(d.path).then(
143
- () => true,
144
- () => false,
145
- ),
146
- ),
147
- );
148
- return FALLBACK_DOCS.filter((_, i) => served[i]);
143
+ return docs;
149
144
  }
150
145
 
151
146
  /**
@@ -162,15 +157,20 @@ export async function readDoc(id) {
162
157
  `Unknown document "${id}". Available: ${docs.map((d) => d.id).join(', ') || 'none'}.`,
163
158
  );
164
159
  }
165
- if (doc.file) return { doc, text: fs.readFileSync(doc.file, 'utf8') };
166
- try {
167
- return { doc, text: await readText(doc.path) };
168
- } catch (e) {
169
- if (e.response?.status === 404) {
170
- throw inputError(`This server does not publish "${doc.title}" (${doc.path}).`);
171
- }
172
- throw apiError(e);
160
+ if (doc.files) {
161
+ const text = doc.files
162
+ .map(
163
+ (f) =>
164
+ `// ${path.basename(f)}\n` +
165
+ fs
166
+ .readFileSync(f, 'utf8')
167
+ .replace(/^\/\/# sourceMappingURL=.*$/gm, '')
168
+ .trim(),
169
+ )
170
+ .join('\n\n');
171
+ return { doc, text };
173
172
  }
173
+ return { doc, text: fs.readFileSync(/** @type {string} */ (doc.file), 'utf8') };
174
174
  }
175
175
 
176
176
  /** MIME type for a document format. */
package/lib/identity.js CHANGED
@@ -31,6 +31,8 @@
31
31
  * @property {string | null} defaultServer Server suggested by the login prompt.
32
32
  * @property {{ serverName: string, toolPrefix: string }} mcp
33
33
  * MCP server name and the prefix every tool name gets (`<prefix>_devices`).
34
+ * @property {string} [npmPackage] npm package of the CLI, run with npx by the CI workflow
35
+ * `dashboard init --ci` writes (default: the package of its entry point).
34
36
  * @property {ProductTemplate} [productTemplate]
35
37
  * Defaults for products created through the CLI or MCP, for platforms
36
38
  * whose products need a given shape (a type, a bucket every device feeds).
@@ -67,7 +67,7 @@ import { inputError } from '../errors.js';
67
67
  * query has these params are kept that long per profile (slow, remote catalogs).
68
68
  * @property {Record<string, Action>} [actions] Operations that are not CRUD, run with
69
69
  * `resource_call`: verify a domain, restart a host, test an alarm rule…
70
- * @property {boolean} [sdk] `resource_describe` adds the dashboard SDK reference, read from the server.
70
+ * @property {boolean} [sdk] `resource_describe` adds the dashboard SDK reference (from its package, see docs.js).
71
71
  */
72
72
 
73
73
  const PROPERTY_NOTES =
@@ -173,7 +173,7 @@ export const CATALOG = {
173
173
  path: 'dashboards',
174
174
  id: 'dashboard',
175
175
  sdk: true,
176
- notes: 'Build dashboards as HTML apps that use the dashboard SDK (source "url", the page at `url`): {tool:dashboard_publish} uploads the app and points the dashboard at it. Widget layouts (source "widgets") are legacy, made in the console editor: read them, do not create new ones. The SDK reference is under `sdk`; TypeScript apps get its declarations with {tool:doc_read} id "dashboard-sdk-types". A per-device, product, asset type, asset group or project dashboard is the "dashboard" property of that resource instead ({tool:dashboard_publish} with `target`).',
176
+ notes: 'Build dashboards as HTML apps that use the dashboard SDK (source "url", the page at `url`): {tool:dashboard_publish} uploads the app and points the dashboard at it. Start it with {tool:dashboard_init} (a TypeScript project on the npm package @thinger-io/dashboard-sdk), develop it on its own dev server with {tool:dashboard_dev} (a link that opens the local app against this server, in the context you pick), and publish it once it works. Widget layouts (source "widgets") are legacy, made in the console editor: read them, do not create new ones. The SDK reference is under `sdk`; its types come with the package (node_modules/@thinger-io/dashboard-sdk). A per-device, product, asset type, asset group or project dashboard is the "dashboard" property of that resource instead ({tool:dashboard_publish} with `to: [{type, id}]`).',
177
177
  ops: {
178
178
  list: 'Dashboard_List',
179
179
  create: 'Dashboard_Create',
@@ -339,6 +339,16 @@ export const CATALOG = {
339
339
  delete: 'Alarm_DeleteInstance',
340
340
  projects: 'Alarm_UpdateInstanceProjects',
341
341
  },
342
+ actions: {
343
+ stats: {
344
+ op: 'Alarm_ReadInstanceStats',
345
+ method: 'get',
346
+ path: 'stats',
347
+ on: 'collection',
348
+ description:
349
+ 'How many instances there are in each state: the health of the account at a glance, without listing them.',
350
+ },
351
+ },
342
352
  notes: 'States: 0 none, 1 active, 2 acknowledged, 3 latched, 4 shelved, 5 cleared; severities: 0 none, 1 low, 2 medium, 3 high, 4 critical. Acknowledge with update {state: 2, annotation?}; shelve with {state: 4, reactivation: {type: "timespan", timespan: {value, magnitude: "minute"|"hour"|"day"…}}}; clear with {state: 5} (a cleared alarm cannot be reactivated). Prefer clearing to deleting: delete removes the history. List filters: state and severity (arrays allowed), name, sort, order, and exact values of nested fields with dots: {"alarm.rule": "<rule id>"}, {"origin.id": "<device>"}, or both (servers before this filter ignore it and list every instance: check the records). `search` matches the instance id and the rule\'s name, not the rule id. A record: {instance, name, description, state, severity, alarm: {rule: <rule id>, group, type}, origin: {source: "device", id: <device>, name}, asset_type, asset_group, activation: {initiated, completed, detections}, normalization: {…}, evaluation: {last_values: {<source>: {<field>: value}}, last_completed}, notification: {last_type, total_completed, total_failed}, project}; times in unix ms.',
343
353
  },
344
354
  claim: {
@@ -7,7 +7,7 @@ import { tools as pluginTools } from './tools-plugins.js';
7
7
  import { tools as productTools } from './tools-products.js';
8
8
  import { resourceTools } from './tools-resource.js';
9
9
  import { tools as storageTools } from './tools-storage.js';
10
- import { FALLBACK_DOCS, docMimeType, listDocs, readDoc } from '../docs.js';
10
+ import { docMimeType, listDocs, readDoc } from '../docs.js';
11
11
  import { identity } from '../identity.js';
12
12
  import { expandToolRefs, toolName } from './naming.js';
13
13
 
@@ -15,6 +15,10 @@ import { expandToolRefs, toolName } from './naming.js';
15
15
  * @typedef {object} ToolOptions
16
16
  * @property {Record<string, import('./resource-hooks.js').ResourceHooks>} [hooks]
17
17
  * The CLI's own hooks on catalog types (see resource-hooks.js).
18
+ * @property {Record<string, import('./tools-resource.js').LocalType>} [types]
19
+ * Resource types the CLI adds, served by the same `resource_*` tools.
20
+ * @property {Record<string, Record<string, import('./tools-resource.js').LocalAction>>} [actions]
21
+ * Actions the CLI adds to resource types (the server's or its own).
18
22
  */
19
23
 
20
24
  /**
@@ -107,14 +111,12 @@ function expandSchema(node, known, where) {
107
111
  }
108
112
 
109
113
  /**
110
- * MCP resources: the documents the server of the default profile lists in
111
- * its documentation index (see docs.js), each as `<prefix>://<id>`. Listed
112
- * on every request, so a guide the server adds shows up without a CLI
113
- * release; the known ones when the server cannot be reached.
114
+ * MCP resources: the documents of docs.js (the platform's guides, the
115
+ * dashboard SDK's and the CLI's own), each as `<prefix>://<id>`.
114
116
  */
115
117
  export async function listResources() {
116
118
  const prefix = identity().mcp.toolPrefix;
117
- const docs = await listDocs().catch(() => FALLBACK_DOCS);
119
+ const docs = await listDocs();
118
120
  return docs.map((d) => ({
119
121
  uri: `${prefix}://${d.id}`,
120
122
  name: d.id,
package/lib/mcp/server.js CHANGED
@@ -25,13 +25,16 @@ import { defineCliDocs } from '../docs.js';
25
25
  * tools?: Array<object>,
26
26
  * instructions?: string,
27
27
  * resourceHooks?: Record<string, import('./resource-hooks.js').ResourceHooks>,
28
+ * resourceTypes?: Record<string, import('./tools-resource.js').LocalType>,
29
+ * resourceActions?: Record<string, Record<string, import('./tools-resource.js').LocalAction>>,
28
30
  * docs?: Parameters<typeof import('../docs.js').defineCliDocs>[0],
29
31
  * }} [opts]
30
32
  * `version` is echoed in the `initialize` response's `serverInfo.version`;
31
33
  * callers pass their `package.json#version` so the two never drift.
32
34
  * `tools` are CLI-specific tools added to the generic set.
33
35
  * `instructions` is the server-level guidance sent to the client.
34
- * `resourceHooks` are the CLI's hooks on catalog types.
36
+ * `resourceHooks` are the CLI's hooks on catalog types; `resourceTypes`
37
+ * and `resourceActions` the types and actions it adds to the catalog.
35
38
  * `docs` are the guides the CLI ships (see docs.js).
36
39
  */
37
40
  export async function startMCPServer({
@@ -39,11 +42,17 @@ export async function startMCPServer({
39
42
  tools: extra = [],
40
43
  instructions,
41
44
  resourceHooks,
45
+ resourceTypes,
46
+ resourceActions,
42
47
  docs,
43
48
  } = {}) {
44
49
  const { mcp } = identity();
45
50
  defineCliDocs(docs);
46
- const tools = buildTools(extra, { hooks: resourceHooks });
51
+ const tools = buildTools(extra, {
52
+ hooks: resourceHooks,
53
+ types: resourceTypes,
54
+ actions: resourceActions,
55
+ });
47
56
 
48
57
  const server = new Server(
49
58
  {