@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/CHANGELOG.md +87 -0
- package/README.md +31 -6
- package/docs/thinger-concepts.md +389 -0
- package/docs/thinger-product-code.md +159 -0
- package/docs/thinger-server-admin.md +165 -0
- package/lib/cli.js +13 -3
- package/lib/commands/auth.js +20 -1
- package/lib/commands/dashboard.js +262 -0
- package/lib/config.js +42 -0
- package/lib/dashboard-dev.js +180 -0
- package/lib/dashboard-init.js +417 -0
- package/lib/dashboard-sdk.js +184 -0
- package/lib/dashboard.js +171 -28
- package/lib/docs.js +110 -110
- package/lib/identity.js +2 -0
- package/lib/mcp/catalog.js +12 -2
- package/lib/mcp/registry.js +8 -6
- package/lib/mcp/server.js +11 -2
- package/lib/mcp/tools-dashboards.js +169 -30
- package/lib/mcp/tools-misc.js +1 -1
- package/lib/mcp/tools-resource.js +387 -50
- package/lib/tgz.js +66 -0
- package/package.json +3 -1
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 (
|
|
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
|
-
|
|
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
|
|
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
|
-
*
|
|
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
|
|
4
|
-
import {
|
|
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
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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} [
|
|
19
|
-
* @property {string} [
|
|
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
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
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
|
|
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
|
|
97
|
-
*
|
|
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 {
|
|
85
|
+
* @returns {Doc[]}
|
|
100
86
|
*/
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
-
|
|
92
|
+
version = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8')).version;
|
|
112
93
|
} catch {
|
|
113
|
-
|
|
94
|
+
return [];
|
|
114
95
|
}
|
|
115
|
-
|
|
116
|
-
|
|
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,
|
|
122
|
-
*
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
|
|
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.
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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).
|
package/lib/mcp/catalog.js
CHANGED
|
@@ -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
|
|
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`;
|
|
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: {
|
package/lib/mcp/registry.js
CHANGED
|
@@ -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 {
|
|
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
|
|
111
|
-
*
|
|
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()
|
|
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, {
|
|
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
|
{
|