@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/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',
@@ -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
 
@@ -111,14 +111,12 @@ function expandSchema(node, known, where) {
111
111
  }
112
112
 
113
113
  /**
114
- * MCP resources: the documents the server of the default profile lists in
115
- * its documentation index (see docs.js), each as `<prefix>://<id>`. Listed
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().catch(() => FALLBACK_DOCS);
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 { DASHBOARD_TARGETS, collectFiles, publishApp } from '../dashboard.js';
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
- const lines = [];
26
- if (result.uploaded.length) {
27
- lines.push(
28
- `Uploaded ${result.uploaded.length} file(s) to ${result.storageCreated ? 'new public' : 'public'} storage ${result.storage}` +
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: no <base> tag (relative asset paths resolve next to the entry page), hash routing (Angular: withHashLocation()), wait for Thinger.ready() before rendering (Angular: provideAppInitializer(() => Thinger.ready())), <script src="/sdk/0.1/thinger-dashboard.js"> in index.html (the SDK version the app was written for; /sdk/thinger-dashboard.js is always the latest), and no localStorage or cookies (opaque origin: keep state in properties through the SDK).`,
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: {
@@ -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: those this CLI ships and those the server publishes (the platform's concepts, how-tos, the dashboard SDK reference…). Without `id`, lists them. Read the relevant guide before building something new.",
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 server's guide to its
552
- // concepts when it publishes one (older servers do not).
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
+ }