@thinger-io/cli-core 0.3.0 → 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 +67 -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 +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 +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
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import { createRequire } from 'node:module';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import api from './api.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The dashboard SDK an app is built with (`@thinger-io/dashboard-sdk`,
|
|
9
|
+
* bundled into the app), against the server it is published to and the
|
|
10
|
+
* latest release on npm. The package declares the oldest server it works
|
|
11
|
+
* with and, per function, any newer one it needs (`compat.json`); the
|
|
12
|
+
* server tells its version at `/v1/server/version`. Everything here is
|
|
13
|
+
* advice: what cannot be read gives no note.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export const SDK_PACKAGE = '@thinger-io/dashboard-sdk';
|
|
17
|
+
const REGISTRY = 'https://registry.npmjs.org';
|
|
18
|
+
const TIMEOUT_MS = 3000;
|
|
19
|
+
/** Oldest SDK the console gives a token to (per-load frame key, PROTOCOL.md "Frame key"). */
|
|
20
|
+
export const CONSOLE_MIN_SDK = '0.3.0';
|
|
21
|
+
const SCRIPT_BUILD = /<script[^>]+src=["'][^"']*thinger-dashboard(?:\.min)?\.js["']/i;
|
|
22
|
+
|
|
23
|
+
/** Numeric order of dotted versions (0.9 < 0.10); suffixes are ignored. */
|
|
24
|
+
export function compareVersions(a, b) {
|
|
25
|
+
const x = numeric(a).split('.').map(Number);
|
|
26
|
+
const y = numeric(b).split('.').map(Number);
|
|
27
|
+
for (let i = 0; i < Math.max(x.length, y.length); i++) {
|
|
28
|
+
const d = (x[i] || 0) - (y[i] || 0);
|
|
29
|
+
if (d) return d;
|
|
30
|
+
}
|
|
31
|
+
return 0;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** "7.3.4-152-g308cf54a-develop" → "7.3.4". */
|
|
35
|
+
export function numeric(version) {
|
|
36
|
+
return /^\d+(?:\.\d+)*/.exec(String(version ?? ''))?.[0] || '0';
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Folder of the SDK package installed for an app: searched in node_modules
|
|
41
|
+
* from `dir` up (a build folder sits inside its project).
|
|
42
|
+
*
|
|
43
|
+
* @param {string} dir
|
|
44
|
+
* @returns {string | null}
|
|
45
|
+
*/
|
|
46
|
+
export function sdkPackageDir(dir) {
|
|
47
|
+
let current = path.resolve(dir);
|
|
48
|
+
for (;;) {
|
|
49
|
+
const pkgDir = path.join(current, 'node_modules', ...SDK_PACKAGE.split('/'));
|
|
50
|
+
if (fs.existsSync(path.join(pkgDir, 'package.json'))) return pkgDir;
|
|
51
|
+
const parent = path.dirname(current);
|
|
52
|
+
if (parent === current) return null;
|
|
53
|
+
current = parent;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Folder of the SDK package for the CLI's own use (its documentation):
|
|
59
|
+
* the project's in the working folder, else the one the CLI installs.
|
|
60
|
+
*
|
|
61
|
+
* @returns {string | null}
|
|
62
|
+
*/
|
|
63
|
+
export function cliSdkPackageDir() {
|
|
64
|
+
const project = sdkPackageDir(process.cwd());
|
|
65
|
+
if (project) return project;
|
|
66
|
+
try {
|
|
67
|
+
return path.dirname(createRequire(import.meta.url).resolve(`${SDK_PACKAGE}/package.json`));
|
|
68
|
+
} catch {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The SDK package installed for an app: searched in node_modules from
|
|
75
|
+
* `dir` up (a build folder sits inside its project).
|
|
76
|
+
*
|
|
77
|
+
* @param {string} dir
|
|
78
|
+
* @returns {{ version: string, compat: { baseServer?: string, functions?: Record<string, string> } } | null}
|
|
79
|
+
*/
|
|
80
|
+
export function installedSdk(dir) {
|
|
81
|
+
const pkgDir = sdkPackageDir(dir);
|
|
82
|
+
if (!pkgDir) return null;
|
|
83
|
+
try {
|
|
84
|
+
const { version } = JSON.parse(fs.readFileSync(path.join(pkgDir, 'package.json'), 'utf8'));
|
|
85
|
+
let compat = {};
|
|
86
|
+
try {
|
|
87
|
+
compat = JSON.parse(fs.readFileSync(path.join(pkgDir, 'dist', 'compat.json'), 'utf8'));
|
|
88
|
+
} catch {
|
|
89
|
+
// older packages have no manifest
|
|
90
|
+
}
|
|
91
|
+
return typeof version === 'string' ? { version, compat } : null;
|
|
92
|
+
} catch {
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** The server's version, or null when it cannot be read. */
|
|
98
|
+
async function serverVersion() {
|
|
99
|
+
try {
|
|
100
|
+
const res = await api.get('/v1/server/version', { timeout: TIMEOUT_MS });
|
|
101
|
+
return typeof res.data?.version === 'string' ? res.data.version : null;
|
|
102
|
+
} catch {
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** The latest SDK on npm, or null (not published, offline…). */
|
|
108
|
+
async function latestSdk() {
|
|
109
|
+
try {
|
|
110
|
+
const res = await fetch(`${REGISTRY}/${SDK_PACKAGE}/latest`, {
|
|
111
|
+
signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
112
|
+
});
|
|
113
|
+
if (!res.ok) return null;
|
|
114
|
+
const { version } = await res.json();
|
|
115
|
+
return typeof version === 'string' ? version : null;
|
|
116
|
+
} catch {
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Notes for an installed SDK against a server version and the latest
|
|
123
|
+
* release.
|
|
124
|
+
*
|
|
125
|
+
* @param {{ version: string, compat: { baseServer?: string, functions?: Record<string, string> } }} sdk
|
|
126
|
+
* @param {string | null} server The server's version.
|
|
127
|
+
* @param {string | null} latest The latest SDK on npm.
|
|
128
|
+
* @returns {string[]}
|
|
129
|
+
*/
|
|
130
|
+
export function sdkNotes(sdk, server, latest) {
|
|
131
|
+
const notes = [];
|
|
132
|
+
if (compareVersions(sdk.version, CONSOLE_MIN_SDK) < 0) {
|
|
133
|
+
notes.push(
|
|
134
|
+
`The app uses ${SDK_PACKAGE} ${sdk.version}: inside the console it gets no token (the console needs SDK ${CONSOLE_MIN_SDK} or later). Update it: npm install ${SDK_PACKAGE}@^${CONSOLE_MIN_SDK}.`,
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
const { baseServer, functions = {} } = sdk.compat || {};
|
|
138
|
+
if (server && baseServer && compareVersions(server, baseServer) < 0) {
|
|
139
|
+
notes.push(
|
|
140
|
+
`The app's dashboard SDK ${sdk.version} needs Thinger ${baseServer} or later; this server runs ${numeric(server)}: the app will not work on it.`,
|
|
141
|
+
);
|
|
142
|
+
} else if (server) {
|
|
143
|
+
const missing = Object.entries(functions)
|
|
144
|
+
.filter(([, min]) => compareVersions(server, min) < 0)
|
|
145
|
+
.map(([name, min]) => `${name} (${min})`);
|
|
146
|
+
if (missing.length) {
|
|
147
|
+
notes.push(
|
|
148
|
+
`On this server (Thinger ${numeric(server)}) these SDK calls fail because they need a newer one: ${missing.join(', ')}.`,
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
if (latest && compareVersions(sdk.version, latest) < 0) {
|
|
153
|
+
notes.push(
|
|
154
|
+
`The app uses ${SDK_PACKAGE} ${sdk.version}; the latest is ${latest}. Its CHANGELOG.md says what changes and how to migrate: npm install ${SDK_PACKAGE}@^${latest}.`,
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
return notes;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Notes on the SDK of an app: the package found from `dir` (its project or
|
|
162
|
+
* build folder) against the profile's server and npm, and pages loading
|
|
163
|
+
* the deprecated script build instead.
|
|
164
|
+
*
|
|
165
|
+
* @param {{ dir?: string, files?: Record<string, Buffer | string> }} app
|
|
166
|
+
* @returns {Promise<string[]>}
|
|
167
|
+
*/
|
|
168
|
+
export async function checkSdk({ dir, files = {} }) {
|
|
169
|
+
const notes = [];
|
|
170
|
+
const scripts = Object.entries(files).filter(
|
|
171
|
+
([name, content]) => /\.html?$/i.test(name) && SCRIPT_BUILD.test(String(content)),
|
|
172
|
+
);
|
|
173
|
+
if (scripts.length) {
|
|
174
|
+
notes.push(
|
|
175
|
+
`${scripts.map(([name]) => name).join(', ')} ${scripts.length === 1 ? 'loads' : 'load'} the SDK's script build (thinger-dashboard.js), which is deprecated: install ${SDK_PACKAGE} and import { Thinger } from it, so the SDK goes in the build.`,
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
const sdk = dir ? installedSdk(dir) : null;
|
|
179
|
+
if (sdk) {
|
|
180
|
+
const [server, latest] = await Promise.all([serverVersion(), latestSdk()]);
|
|
181
|
+
notes.push(...sdkNotes(sdk, server, latest));
|
|
182
|
+
}
|
|
183
|
+
return notes;
|
|
184
|
+
}
|
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
|
+
}
|