@thinger-io/cli-core 0.3.0 → 0.4.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.
- package/CHANGELOG.md +77 -0
- package/README.md +19 -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 +4 -2
- 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 +186 -0
- package/lib/dashboard.js +171 -28
- package/lib/docs.js +110 -110
- package/lib/identity.js +2 -0
- package/lib/mcp/catalog.js +2 -2
- package/lib/mcp/registry.js +4 -6
- package/lib/mcp/tools-dashboards.js +169 -30
- package/lib/mcp/tools-misc.js +1 -1
- package/lib/mcp/tools-resource.js +2 -2
- package/lib/tgz.js +66 -0
- package/package.json +3 -1
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',
|
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
|
|
|
@@ -111,14 +111,12 @@ function expandSchema(node, known, where) {
|
|
|
111
111
|
}
|
|
112
112
|
|
|
113
113
|
/**
|
|
114
|
-
* MCP resources: the documents
|
|
115
|
-
*
|
|
116
|
-
* on every request, so a guide the server adds shows up without a CLI
|
|
117
|
-
* 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>`.
|
|
118
116
|
*/
|
|
119
117
|
export async function listResources() {
|
|
120
118
|
const prefix = identity().mcp.toolPrefix;
|
|
121
|
-
const docs = await listDocs()
|
|
119
|
+
const docs = await listDocs();
|
|
122
120
|
return docs.map((d) => ({
|
|
123
121
|
uri: `${prefix}://${d.id}`,
|
|
124
122
|
name: d.id,
|
|
@@ -1,6 +1,74 @@
|
|
|
1
1
|
// @ts-check
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
DASHBOARD_TARGETS,
|
|
4
|
+
checkDestination,
|
|
5
|
+
collectFiles,
|
|
6
|
+
destinationContext,
|
|
7
|
+
manifestName,
|
|
8
|
+
publishApp,
|
|
9
|
+
publishSummary,
|
|
10
|
+
} from '../dashboard.js';
|
|
11
|
+
import { devLink } from '../dashboard-dev.js';
|
|
12
|
+
import { DEFAULT_TEMPLATE, initApp } from '../dashboard-init.js';
|
|
3
13
|
import { inputError } from '../errors.js';
|
|
14
|
+
import { toolName } from './naming.js';
|
|
15
|
+
|
|
16
|
+
async function toolInit(args) {
|
|
17
|
+
if (args.to) checkDestination(args.to);
|
|
18
|
+
const r = await initApp({
|
|
19
|
+
dir: args.dir,
|
|
20
|
+
template: args.template,
|
|
21
|
+
to: args.to,
|
|
22
|
+
storage: args.storage,
|
|
23
|
+
from: args.from,
|
|
24
|
+
ci: args.ci,
|
|
25
|
+
install: args.install,
|
|
26
|
+
});
|
|
27
|
+
return {
|
|
28
|
+
content: [
|
|
29
|
+
{
|
|
30
|
+
type: 'text',
|
|
31
|
+
text: [
|
|
32
|
+
`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}.`,
|
|
34
|
+
].join('\n'),
|
|
35
|
+
},
|
|
36
|
+
],
|
|
37
|
+
isError: false,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function toolDev(args) {
|
|
42
|
+
if (!args.url) throw inputError("pass url: the app's dev server, e.g. http://localhost:4200/");
|
|
43
|
+
if (args.to && args.context) throw inputError('pass to or context, not both');
|
|
44
|
+
if (args.to) checkDestination(args.to);
|
|
45
|
+
const dev = await devLink({
|
|
46
|
+
url: args.url,
|
|
47
|
+
context: args.to ? destinationContext(args.to) : args.context,
|
|
48
|
+
route: args.route ?? args.to?.route,
|
|
49
|
+
hours: args.hours,
|
|
50
|
+
user: args.user,
|
|
51
|
+
dir: args.dir || process.cwd(),
|
|
52
|
+
allowRemote: args.allow_remote === true,
|
|
53
|
+
});
|
|
54
|
+
const ctx = Object.entries(dev.context).map(([k, v]) => `${k} ${v}`);
|
|
55
|
+
const until = new Date(dev.expire).toISOString().slice(0, 16).replace('T', ' ');
|
|
56
|
+
return {
|
|
57
|
+
content: [
|
|
58
|
+
{
|
|
59
|
+
type: 'text',
|
|
60
|
+
text: [
|
|
61
|
+
`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.`,
|
|
63
|
+
...dev.notes.map((n) => `Check: ${n}`),
|
|
64
|
+
"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')}.`,
|
|
66
|
+
].join('\n'),
|
|
67
|
+
},
|
|
68
|
+
],
|
|
69
|
+
isError: false,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
4
72
|
|
|
5
73
|
async function toolPublish(args) {
|
|
6
74
|
if (args.dir && args.files) throw inputError('pass dir or files, not both');
|
|
@@ -21,45 +89,116 @@ async function toolPublish(args) {
|
|
|
21
89
|
point: args.point,
|
|
22
90
|
bump: args.bump,
|
|
23
91
|
user: args.user,
|
|
92
|
+
dir: args.dir,
|
|
24
93
|
});
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
(result.deleted.length
|
|
30
|
-
? `; deleted ${result.deleted.length} no longer in the app.`
|
|
31
|
-
: '.'),
|
|
32
|
-
);
|
|
33
|
-
}
|
|
34
|
-
for (const p of result.pointed) lines.push(`${p.to} → ${p.url}\n open: ${p.open}`);
|
|
35
|
-
if (!result.pointed.length && args.point !== false) {
|
|
36
|
-
lines.push(
|
|
37
|
-
'No dashboard was pointed: those already showing this storage load the new files the next time they open.',
|
|
38
|
-
);
|
|
39
|
-
}
|
|
40
|
-
if (result.previews.length) {
|
|
41
|
-
const until = new Date(result.previews[0].expire)
|
|
42
|
-
.toISOString()
|
|
43
|
-
.slice(0, 16)
|
|
44
|
-
.replace('T', ' ');
|
|
45
|
-
lines.push(
|
|
46
|
-
`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):`,
|
|
47
|
-
);
|
|
48
|
-
for (const p of result.previews) {
|
|
49
|
-
lines.push(`${p.to} → ${p.url}\n layout check (website and phone sizes): ${p.layout}`);
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
return { content: [{ type: 'text', text: lines.join('\n') }], isError: false };
|
|
94
|
+
return {
|
|
95
|
+
content: [{ type: 'text', text: publishSummary(result, { point: args.point }) }],
|
|
96
|
+
isError: false,
|
|
97
|
+
};
|
|
53
98
|
}
|
|
54
99
|
|
|
55
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: {
|
|
105
|
+
type: 'object',
|
|
106
|
+
properties: {
|
|
107
|
+
dir: {
|
|
108
|
+
type: 'string',
|
|
109
|
+
description:
|
|
110
|
+
'New (or empty) folder for the app. Absolute, or relative to the folder this MCP server runs in.',
|
|
111
|
+
},
|
|
112
|
+
template: {
|
|
113
|
+
type: 'string',
|
|
114
|
+
description: `Template of the SDK package (default ${DEFAULT_TEMPLATE}).`,
|
|
115
|
+
},
|
|
116
|
+
to: {
|
|
117
|
+
type: 'object',
|
|
118
|
+
description:
|
|
119
|
+
'Where it publishes, as in dashboard_publish: {type, id}, or {dashboard}. Its context is the one dashboard_dev uses.',
|
|
120
|
+
properties: {
|
|
121
|
+
dashboard: { type: 'string' },
|
|
122
|
+
type: { type: 'string', enum: DASHBOARD_TARGETS },
|
|
123
|
+
id: { type: 'string' },
|
|
124
|
+
},
|
|
125
|
+
},
|
|
126
|
+
storage: {
|
|
127
|
+
type: 'string',
|
|
128
|
+
description: 'Storage it publishes to (default app_<folder>_<random>).',
|
|
129
|
+
},
|
|
130
|
+
from: {
|
|
131
|
+
type: 'string',
|
|
132
|
+
description:
|
|
133
|
+
'SDK package to take the template from: a version or tag on npm (default the latest release), or a local folder or .tgz of it.',
|
|
134
|
+
},
|
|
135
|
+
ci: {
|
|
136
|
+
type: 'boolean',
|
|
137
|
+
description: 'Add a GitHub workflow that publishes it on push to main.',
|
|
138
|
+
},
|
|
139
|
+
install: { type: 'boolean', description: 'Run npm install.' },
|
|
140
|
+
},
|
|
141
|
+
required: ['dir'],
|
|
142
|
+
},
|
|
143
|
+
handler: toolInit,
|
|
144
|
+
},
|
|
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.
|
|
148
|
+
|
|
149
|
+
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: {
|
|
151
|
+
type: 'object',
|
|
152
|
+
properties: {
|
|
153
|
+
url: {
|
|
154
|
+
type: 'string',
|
|
155
|
+
description:
|
|
156
|
+
'The app on its dev server, e.g. http://localhost:4200/ (a #route in it is kept).',
|
|
157
|
+
},
|
|
158
|
+
to: {
|
|
159
|
+
type: 'object',
|
|
160
|
+
description:
|
|
161
|
+
'Destination whose context the app gets, as in dashboard_publish: {type, id}, or {dashboard} for a collection dashboard (no context).',
|
|
162
|
+
properties: {
|
|
163
|
+
dashboard: { type: 'string' },
|
|
164
|
+
type: { type: 'string', enum: DASHBOARD_TARGETS },
|
|
165
|
+
id: { type: 'string' },
|
|
166
|
+
route: { type: 'string' },
|
|
167
|
+
},
|
|
168
|
+
},
|
|
169
|
+
context: {
|
|
170
|
+
type: 'object',
|
|
171
|
+
description:
|
|
172
|
+
'Or the SDK context itself: device, product, type, group, project ids.',
|
|
173
|
+
},
|
|
174
|
+
route: { type: 'string', description: 'Initial route, e.g. "/devices".' },
|
|
175
|
+
dir: {
|
|
176
|
+
type: 'string',
|
|
177
|
+
description:
|
|
178
|
+
"The app's project folder, to check its SDK against this server (default: the folder this MCP server runs in).",
|
|
179
|
+
},
|
|
180
|
+
hours: {
|
|
181
|
+
type: 'number',
|
|
182
|
+
description: 'Hours the temporary token lasts (default 8, up to 168).',
|
|
183
|
+
},
|
|
184
|
+
allow_remote: {
|
|
185
|
+
type: 'boolean',
|
|
186
|
+
description:
|
|
187
|
+
'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
|
+
},
|
|
189
|
+
user: { type: 'string', description: 'Account to act on (admin only).' },
|
|
190
|
+
},
|
|
191
|
+
required: ['url'],
|
|
192
|
+
},
|
|
193
|
+
handler: toolDev,
|
|
194
|
+
},
|
|
56
195
|
{
|
|
57
196
|
name: 'dashboard_publish',
|
|
58
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.
|
|
59
198
|
|
|
60
199
|
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.
|
|
61
200
|
|
|
62
|
-
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:
|
|
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).`,
|
|
63
202
|
inputSchema: {
|
|
64
203
|
type: 'object',
|
|
65
204
|
properties: {
|
package/lib/mcp/tools-misc.js
CHANGED
|
@@ -30,7 +30,7 @@ export const tools = [
|
|
|
30
30
|
{
|
|
31
31
|
name: 'doc_read',
|
|
32
32
|
description:
|
|
33
|
-
"Read the guides for agents and app developers:
|
|
33
|
+
"Read the 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. Without `id`, lists them. Read the relevant guide before building something new.",
|
|
34
34
|
inputSchema: {
|
|
35
35
|
type: 'object',
|
|
36
36
|
properties: { id: { type: 'string', description: 'Document id from the list.' } },
|
|
@@ -548,8 +548,8 @@ export function resourceTools({ hooks: extraHooks, types: local = {}, actions: e
|
|
|
548
548
|
|
|
549
549
|
async function toolDescribe(args) {
|
|
550
550
|
if (!args.type) {
|
|
551
|
-
// The overview call: the types, plus the
|
|
552
|
-
// concepts
|
|
551
|
+
// The overview call: the types, plus the guide to the
|
|
552
|
+
// platform's concepts.
|
|
553
553
|
const concepts = await readDoc('concepts').then(
|
|
554
554
|
(d) => d.text,
|
|
555
555
|
() => '',
|
package/lib/tgz.js
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import zlib from 'node:zlib';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Extract an npm package tarball (.tgz) into a folder, without a `tar`
|
|
8
|
+
* binary: only regular files and folders, every path kept inside `dest`.
|
|
9
|
+
* npm packs ustar archives, with pax headers for long paths.
|
|
10
|
+
*
|
|
11
|
+
* @param {Buffer} tgz
|
|
12
|
+
* @param {string} dest Folder to extract into (created).
|
|
13
|
+
* @param {{ strip?: number }} [opts] Leading path segments to drop (1 for npm's `package/`).
|
|
14
|
+
*/
|
|
15
|
+
export function extractTgz(tgz, dest, { strip = 0 } = {}) {
|
|
16
|
+
let tar;
|
|
17
|
+
try {
|
|
18
|
+
tar = zlib.gunzipSync(tgz);
|
|
19
|
+
} catch {
|
|
20
|
+
throw new Error('not a .tgz archive');
|
|
21
|
+
}
|
|
22
|
+
const root = path.resolve(dest);
|
|
23
|
+
fs.mkdirSync(root, { recursive: true });
|
|
24
|
+
const field = (buf, start, length) =>
|
|
25
|
+
buf.toString('utf8', start, start + length).replace(/\0.*$/s, '');
|
|
26
|
+
let offset = 0;
|
|
27
|
+
/** @type {string | null} */
|
|
28
|
+
let longName = null;
|
|
29
|
+
while (offset + 512 <= tar.length) {
|
|
30
|
+
const header = tar.subarray(offset, offset + 512);
|
|
31
|
+
if (header.every((b) => b === 0)) break;
|
|
32
|
+
const size = parseInt(field(header, 124, 12).trim() || '0', 8);
|
|
33
|
+
if (!Number.isFinite(size) || size < 0) throw new Error('corrupt tar header');
|
|
34
|
+
const type = String.fromCharCode(header[156] || 48);
|
|
35
|
+
const prefix = field(header, 345, 155);
|
|
36
|
+
const name =
|
|
37
|
+
longName ?? (prefix ? `${prefix}/${field(header, 0, 100)}` : field(header, 0, 100));
|
|
38
|
+
longName = null;
|
|
39
|
+
const body = tar.subarray(offset + 512, offset + 512 + size);
|
|
40
|
+
offset += 512 + Math.ceil(size / 512) * 512;
|
|
41
|
+
|
|
42
|
+
if (type === 'x') {
|
|
43
|
+
// pax extended header: "<len> path=<value>\n" records for the next entry
|
|
44
|
+
const match = /(?:^|\n)\d+ path=([^\n]*)\n/.exec(body.toString('utf8'));
|
|
45
|
+
if (match) longName = match[1];
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
if (type === 'L') {
|
|
49
|
+
longName = body.toString('utf8').replace(/\0.*$/s, '');
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
if (type !== '0' && type !== '5') continue; // links, devices… are skipped
|
|
53
|
+
const parts = name.split('/').filter(Boolean).slice(strip);
|
|
54
|
+
if (!parts.length) continue;
|
|
55
|
+
if (parts.some((p) => p === '..' || p.includes('\\') || p.includes(':'))) {
|
|
56
|
+
throw new Error(`unsafe path in archive: ${name}`);
|
|
57
|
+
}
|
|
58
|
+
const target = path.join(root, ...parts);
|
|
59
|
+
if (!target.startsWith(root + path.sep)) throw new Error(`unsafe path in archive: ${name}`);
|
|
60
|
+
if (type === '5') fs.mkdirSync(target, { recursive: true });
|
|
61
|
+
else {
|
|
62
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
63
|
+
fs.writeFileSync(target, body);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|