@thinger-io/cli-core 0.1.1 → 0.2.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 +93 -0
- package/README.md +37 -5
- package/lib/api.js +3 -0
- package/lib/auth.js +29 -9
- package/lib/bucket.js +3 -0
- package/lib/cli.js +15 -2
- package/lib/config.js +55 -5
- package/lib/constants.js +8 -0
- package/lib/dashboard.js +605 -0
- package/lib/docs.js +182 -0
- package/lib/errors.js +29 -4
- package/lib/mcp/catalog.js +1235 -0
- package/lib/mcp/helpers.js +1 -1
- package/lib/mcp/registry.js +72 -30
- package/lib/mcp/resource-hooks.js +145 -0
- package/lib/mcp/server.js +46 -5
- package/lib/mcp/tools-buckets.js +79 -80
- package/lib/mcp/tools-dashboards.js +123 -0
- package/lib/mcp/tools-devices.js +88 -234
- package/lib/mcp/tools-misc.js +48 -0
- package/lib/mcp/tools-plugins.js +63 -0
- package/lib/mcp/tools-products.js +32 -385
- package/lib/mcp/tools-resource.js +760 -0
- package/lib/mcp/tools-storage.js +173 -0
- package/lib/openapi.js +169 -0
- package/lib/paths.js +8 -0
- package/lib/resource.js +2 -0
- package/lib/storage.js +2 -1
- package/openapi/swagger.json +1 -0
- package/package.json +2 -1
- package/lib/mcp/tools-alarms.js +0 -507
- package/lib/mcp/tools-profile.js +0 -635
- package/lib/mcp/tools-profiles.js +0 -30
- package/lib/mcp/tools-properties.js +0 -84
- package/lib/mcp/tools-resources.js +0 -83
- package/lib/mcp/tools-tokens.js +0 -370
package/lib/dashboard.js
ADDED
|
@@ -0,0 +1,605 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import api from './api.js';
|
|
6
|
+
import { TIMEOUTS } from './constants.js';
|
|
7
|
+
import { readConfig, serverURL } from './config.js';
|
|
8
|
+
import { apiError, inputError } from './errors.js';
|
|
9
|
+
import { v1, v2, v3 } from './paths.js';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Url-source dashboards: an HTML page (plus its scripts, styles, images)
|
|
13
|
+
* kept in a file storage and rendered by the console in an iframe, where it
|
|
14
|
+
* talks to the server through the dashboard SDK (`/sdk/0.1/thinger-dashboard.js`).
|
|
15
|
+
*
|
|
16
|
+
* The storage must be public: the iframe cannot send an Authorization
|
|
17
|
+
* header when it loads the page's files. Only the code is exposed; every API
|
|
18
|
+
* call the page makes carries the viewer's token.
|
|
19
|
+
*
|
|
20
|
+
* A dashboard of the dashboards collection gets an empty context. A
|
|
21
|
+
* per-device (or product, asset type, asset group, project) dashboard is
|
|
22
|
+
* the `dashboard` property of that resource, holding the same
|
|
23
|
+
* `{ source: "url", url }` object: the SDK then gets that id in its context.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/** Resources that can hold a `dashboard` property, and where it lives. */
|
|
27
|
+
const TARGETS = {
|
|
28
|
+
device: { path: 'devices', properties: v3, console: 'devices' },
|
|
29
|
+
product: { path: 'products', properties: v1, console: 'products' },
|
|
30
|
+
asset_type: { path: 'types', properties: v1, console: 'types' },
|
|
31
|
+
asset_group: { path: 'groups', properties: v1, console: 'groups' },
|
|
32
|
+
project: { path: 'projects', properties: v1, console: 'projects' },
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/** The resource a target names, and its `dashboard` property (device properties are v3). */
|
|
36
|
+
const targetPath = (type, user, id) => `${v1(user)}/${TARGETS[type].path}/${id}`;
|
|
37
|
+
const propertyPath = (type, user, id) =>
|
|
38
|
+
`${TARGETS[type].properties(user)}/${TARGETS[type].path}/${id}/properties/dashboard`;
|
|
39
|
+
|
|
40
|
+
export const DASHBOARD_TARGETS = Object.keys(TARGETS);
|
|
41
|
+
|
|
42
|
+
const ID_RE = /^[a-zA-Z0-9_-]{1,32}$/;
|
|
43
|
+
/** Folders never uploaded from a local app folder. */
|
|
44
|
+
const SKIPPED = new Set(['node_modules', '.git']);
|
|
45
|
+
const MAX_BYTES = 20 * 1024 * 1024;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Files of a local folder, as { "relative/path": Buffer }. Hidden entries
|
|
49
|
+
* and the `skip` folders (node_modules, .git) are left out.
|
|
50
|
+
*
|
|
51
|
+
* @param {string} dir
|
|
52
|
+
* @param {{ skip?: Set<string>, maxBytes?: number, tooLarge?: string }} [options]
|
|
53
|
+
* `maxBytes` caps the folder (20 MB by default, an app's build output);
|
|
54
|
+
* `tooLarge` is the advice added to the error past it.
|
|
55
|
+
* @returns {Record<string, Buffer>}
|
|
56
|
+
*/
|
|
57
|
+
export function collectFiles(
|
|
58
|
+
dir,
|
|
59
|
+
{ skip = SKIPPED, maxBytes = MAX_BYTES, tooLarge = 'publish a build output folder' } = {},
|
|
60
|
+
) {
|
|
61
|
+
const root = path.resolve(dir);
|
|
62
|
+
if (!fs.existsSync(root) || !fs.statSync(root).isDirectory()) {
|
|
63
|
+
throw inputError(`Not a folder: ${dir}`);
|
|
64
|
+
}
|
|
65
|
+
/** @type {Record<string, Buffer>} */
|
|
66
|
+
const files = {};
|
|
67
|
+
let total = 0;
|
|
68
|
+
const walk = (current) => {
|
|
69
|
+
for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
|
|
70
|
+
if (entry.name.startsWith('.') || skip.has(entry.name)) continue;
|
|
71
|
+
const full = path.join(current, entry.name);
|
|
72
|
+
if (entry.isDirectory()) walk(full);
|
|
73
|
+
else if (entry.isFile()) {
|
|
74
|
+
const content = fs.readFileSync(full);
|
|
75
|
+
total += content.length;
|
|
76
|
+
if (total > maxBytes) {
|
|
77
|
+
throw inputError(
|
|
78
|
+
`${dir} holds more than ${maxBytes / 1024 / 1024} MB: ${tooLarge}`,
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
files[path.relative(root, full).split(path.sep).join('/')] = content;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
walk(root);
|
|
86
|
+
return files;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Storage a dashboard's files go to unless the caller names one:
|
|
91
|
+
* `dashboard_<id>`, `dashboard_<type>_<id>` for per-resource dashboards, or
|
|
92
|
+
* a stable hash of the same when that does not fit the 32 characters a
|
|
93
|
+
* storage id allows. Always the same for the same dashboard, so
|
|
94
|
+
* republishing overwrites its files instead of scattering copies.
|
|
95
|
+
*
|
|
96
|
+
* @param {string | undefined} dashboard
|
|
97
|
+
* @param {{ type: string, id: string }} [target]
|
|
98
|
+
*/
|
|
99
|
+
export function defaultStorage(dashboard, target) {
|
|
100
|
+
const readable = dashboard ? `dashboard_${dashboard}` : `dashboard_${target.type}_${target.id}`;
|
|
101
|
+
if (readable.length <= 32) return readable;
|
|
102
|
+
const key = dashboard ? `dashboard:${dashboard}` : `${target.type}:${target.id}`;
|
|
103
|
+
return `dashboard_${createHash('sha1').update(key).digest('hex').slice(0, 22)}`;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* The app's storage, created public if missing (in `projects`, when given).
|
|
108
|
+
*
|
|
109
|
+
* @returns {Promise<{ created: boolean, index?: string }>}
|
|
110
|
+
*/
|
|
111
|
+
async function ensurePublicStorage(storage, label, user, projects = []) {
|
|
112
|
+
try {
|
|
113
|
+
const res = await api.get(`${v1(user)}/storages/${storage}`);
|
|
114
|
+
if (res.data?.public !== true) {
|
|
115
|
+
throw inputError(
|
|
116
|
+
`Storage "${storage}" is private, and the dashboard iframe can only load files from a public one. Pass another storage (it is created public), or make this one public first.`,
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
return { created: false, index: res.data.index || undefined };
|
|
120
|
+
} catch (e) {
|
|
121
|
+
if (e.response?.status !== 404) throw e.code === 'input_error' ? e : apiError(e);
|
|
122
|
+
}
|
|
123
|
+
try {
|
|
124
|
+
await api.post(`${v1(user)}/storages`, {
|
|
125
|
+
storage,
|
|
126
|
+
name: label,
|
|
127
|
+
description: 'Files of a url-source dashboard',
|
|
128
|
+
public: true,
|
|
129
|
+
});
|
|
130
|
+
if (projects.length) await api.put(`${v1(user)}/storages/${storage}/projects`, projects);
|
|
131
|
+
return { created: true };
|
|
132
|
+
} catch (e) {
|
|
133
|
+
throw apiError(e);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Account-relative path of a file in a storage, each segment escaped. */
|
|
138
|
+
const storageFile = (user, storage, file, version = 'v1') =>
|
|
139
|
+
`${version === 'v2' ? v2(user) : v1(user)}/storages/${storage}/files/${file
|
|
140
|
+
.split('/')
|
|
141
|
+
.map(encodeURIComponent)
|
|
142
|
+
.join('/')}`;
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Write files into a storage, overwriting what is there.
|
|
146
|
+
*
|
|
147
|
+
* @param {string} storage
|
|
148
|
+
* @param {Record<string, Buffer | string>} files Paths inside the storage → content.
|
|
149
|
+
* @param {string | null} [user]
|
|
150
|
+
*/
|
|
151
|
+
export async function uploadFiles(storage, files, user) {
|
|
152
|
+
for (const [file, content] of Object.entries(files)) {
|
|
153
|
+
try {
|
|
154
|
+
await api.put(storageFile(user, storage, file, 'v2'), content, {
|
|
155
|
+
params: { overwrite: true },
|
|
156
|
+
headers: { 'Content-Type': 'application/octet-stream' },
|
|
157
|
+
timeout: TIMEOUTS.API_LONG_MS,
|
|
158
|
+
maxBodyLength: Infinity,
|
|
159
|
+
});
|
|
160
|
+
} catch (e) {
|
|
161
|
+
throw apiError(e, { notFound: `Storage not found: ${storage}` });
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Paths of every file in a storage (empty when it has none yet). */
|
|
167
|
+
async function listFiles(storage, user) {
|
|
168
|
+
try {
|
|
169
|
+
const res = await api.get(`${v1(user)}/storages/${storage}/files`);
|
|
170
|
+
return (Array.isArray(res.data) ? res.data : [])
|
|
171
|
+
.filter((f) => f.type !== 'directory')
|
|
172
|
+
.map((f) => String(f.path ?? f.name).replace(/^\/+/, ''));
|
|
173
|
+
} catch (e) {
|
|
174
|
+
// The server creates a storage's folder with its first file.
|
|
175
|
+
if (e.response?.status === 404) return [];
|
|
176
|
+
throw apiError(e);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async function remove(storage, files, user) {
|
|
181
|
+
for (const file of files) {
|
|
182
|
+
try {
|
|
183
|
+
await api.delete(storageFile(user, storage, file, 'v2'));
|
|
184
|
+
} catch (e) {
|
|
185
|
+
if (e.response?.status !== 404) throw apiError(e);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* @typedef {object} Destination Where an app is shown: a dashboard of the
|
|
192
|
+
* collection, or the `dashboard` property of a resource (type + id).
|
|
193
|
+
* @property {string} [dashboard]
|
|
194
|
+
* @property {string} [type]
|
|
195
|
+
* @property {string} [id]
|
|
196
|
+
* @property {string} [entry] Page in the storage (default index.html).
|
|
197
|
+
* @property {string} [route] Initial route, after `#` (hash routing).
|
|
198
|
+
* @property {string} [name] Name of a collection dashboard.
|
|
199
|
+
* @property {string} [description]
|
|
200
|
+
*/
|
|
201
|
+
|
|
202
|
+
/** @param {Destination} d */
|
|
203
|
+
function checkDestination(d) {
|
|
204
|
+
if (!d || typeof d !== 'object') throw inputError('each destination must be an object');
|
|
205
|
+
if (!d.dashboard === !(d.type || d.id)) {
|
|
206
|
+
throw inputError('a destination is either {dashboard} or {type, id}');
|
|
207
|
+
}
|
|
208
|
+
if (d.type && !TARGETS[d.type]) {
|
|
209
|
+
throw inputError(`type must be one of ${DASHBOARD_TARGETS.join(', ')}`);
|
|
210
|
+
}
|
|
211
|
+
const id = d.dashboard || d.id;
|
|
212
|
+
if (!id || !ID_RE.test(id)) throw inputError(`invalid id: "${id ?? ''}"`);
|
|
213
|
+
if (
|
|
214
|
+
d.entry !== undefined &&
|
|
215
|
+
(typeof d.entry !== 'string' || !d.entry || d.entry.startsWith('/'))
|
|
216
|
+
) {
|
|
217
|
+
throw inputError('entry is a path inside the storage, e.g. "index.html"');
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Where a destination points: the storage folder when its entry is the
|
|
223
|
+
* storage's index (`…/files/`, the trailing slash makes the server serve the
|
|
224
|
+
* index), its file otherwise, plus its initial route. Storage files are
|
|
225
|
+
* served with no-cache and an ETag, so viewers get each upload without a
|
|
226
|
+
* version query; `version` adds one (?v=) to make open consoles reload.
|
|
227
|
+
*
|
|
228
|
+
* @param {string | null | undefined} user
|
|
229
|
+
* @param {string} storage
|
|
230
|
+
* @param {{ entry?: string, route?: string }} destination
|
|
231
|
+
* @param {{ index?: string, version?: number | string }} [opts]
|
|
232
|
+
*/
|
|
233
|
+
export function entryUrl(user, storage, { entry = 'index.html', route }, { index, version } = {}) {
|
|
234
|
+
const hash = route ? `#${String(route).replace(/^#/, '')}` : '';
|
|
235
|
+
const query = version !== undefined ? `?v=${version}` : '';
|
|
236
|
+
return `${storageFile(user, storage, entry === index ? '' : entry)}${query}${hash}`;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* What a preview token may do: read everything the SDK reads, nothing else.
|
|
241
|
+
* Explicit names, not Read*: on devices that would also read their files
|
|
242
|
+
* and web services. Device and product resources are left out, since the
|
|
243
|
+
* same action reads and calls them.
|
|
244
|
+
*/
|
|
245
|
+
export const PREVIEW_ALLOW = {
|
|
246
|
+
Device: {
|
|
247
|
+
'*': [
|
|
248
|
+
'ListDevices',
|
|
249
|
+
'ReadDeviceConfig',
|
|
250
|
+
'ReadDeviceStatus',
|
|
251
|
+
'ReadDeviceStatistics',
|
|
252
|
+
'ListDeviceProperties',
|
|
253
|
+
'ReadDeviceProperty',
|
|
254
|
+
'ListDeviceBuckets',
|
|
255
|
+
'ReadDeviceBucket',
|
|
256
|
+
'ListDeviceBucketTags',
|
|
257
|
+
'ReadDeviceBucketTag',
|
|
258
|
+
],
|
|
259
|
+
},
|
|
260
|
+
Bucket: {
|
|
261
|
+
'*': ['ListBuckets', 'ReadBucketConfig', 'ReadBucket', 'ListBucketTags', 'ReadBucketTag'],
|
|
262
|
+
},
|
|
263
|
+
Product: {
|
|
264
|
+
'*': [
|
|
265
|
+
'ListProducts',
|
|
266
|
+
'ReadProductConfig',
|
|
267
|
+
'ListProductProperties',
|
|
268
|
+
'ReadProductProperty',
|
|
269
|
+
'ReadProductProfile',
|
|
270
|
+
],
|
|
271
|
+
},
|
|
272
|
+
Type: {
|
|
273
|
+
'*': [
|
|
274
|
+
'ListTypes',
|
|
275
|
+
'ReadTypeConfig',
|
|
276
|
+
'ListTypeProperties',
|
|
277
|
+
'ReadTypeProperty',
|
|
278
|
+
'ReadAssetsLocations',
|
|
279
|
+
],
|
|
280
|
+
},
|
|
281
|
+
Group: {
|
|
282
|
+
'*': [
|
|
283
|
+
'ListGroups',
|
|
284
|
+
'ReadGroupConfig',
|
|
285
|
+
'ListGroupProperties',
|
|
286
|
+
'ReadGroupProperty',
|
|
287
|
+
'ReadAssetsLocations',
|
|
288
|
+
],
|
|
289
|
+
},
|
|
290
|
+
Asset: { '*': ['ReadAssetsLocations'] },
|
|
291
|
+
Project: {
|
|
292
|
+
'*': ['ListProjects', 'ReadProjectConfig', 'ListProjectProperties', 'ReadProjectProperty'],
|
|
293
|
+
},
|
|
294
|
+
Property: { '*': ['ListProperties', 'ReadProperty'] },
|
|
295
|
+
Alarm: {
|
|
296
|
+
'*': [
|
|
297
|
+
'ListAlarmRules',
|
|
298
|
+
'ReadAlarmRuleConfig',
|
|
299
|
+
'ListAlarmInstances',
|
|
300
|
+
'ReadAlarmInstanceConfig',
|
|
301
|
+
'ReadAlarmInstanceStats',
|
|
302
|
+
],
|
|
303
|
+
},
|
|
304
|
+
Storage: { '*': ['ListStorages', 'ReadStorageConfig', 'ListStorageFiles', 'ReadStorageFiles'] },
|
|
305
|
+
User: {
|
|
306
|
+
'*': ['SubscribeUserEvents', 'ReadUserProfile', 'ReadUserTimezone', 'ListDeviceLocations'],
|
|
307
|
+
},
|
|
308
|
+
};
|
|
309
|
+
|
|
310
|
+
export const PREVIEW_HOURS = 1;
|
|
311
|
+
export const PREVIEW_MAX_HOURS = 24 * 30;
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Token id of an app's previews: one per storage and project, so publishing
|
|
315
|
+
* again replaces it instead of piling up tokens.
|
|
316
|
+
*
|
|
317
|
+
* @param {string} storage
|
|
318
|
+
* @param {string} [project]
|
|
319
|
+
*/
|
|
320
|
+
export function previewTokenId(storage, project) {
|
|
321
|
+
const readable = project ? `preview_${storage}_${project}` : `preview_${storage}`;
|
|
322
|
+
if (readable.length <= 32) return readable;
|
|
323
|
+
const key = `${storage}:${project || ''}`;
|
|
324
|
+
return `preview_${createHash('sha1').update(key).digest('hex').slice(0, 24)}`;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* A read-only access token for previews, valid for `hours` (default 1). The
|
|
329
|
+
* expiry is signed into the token, so it is renewed by deleting it and
|
|
330
|
+
* creating it again. Inside a project (?project=) it only works there.
|
|
331
|
+
*
|
|
332
|
+
* @param {string} storage
|
|
333
|
+
* @param {{ project?: string, hours?: number, user?: string | null }} [opts]
|
|
334
|
+
* @returns {Promise<{ token: string, accessToken: string, expire: number }>}
|
|
335
|
+
*/
|
|
336
|
+
export async function previewToken(storage, { project, hours = PREVIEW_HOURS, user } = {}) {
|
|
337
|
+
const token = previewTokenId(storage, project);
|
|
338
|
+
const params = project ? { project } : undefined;
|
|
339
|
+
try {
|
|
340
|
+
await api.delete(`${v1(user)}/tokens/${token}`, { params });
|
|
341
|
+
} catch (e) {
|
|
342
|
+
if (e.response?.status !== 404) throw apiError(e);
|
|
343
|
+
}
|
|
344
|
+
const expire = Date.now() + hours * 3600 * 1000;
|
|
345
|
+
let res;
|
|
346
|
+
try {
|
|
347
|
+
res = await api.post(
|
|
348
|
+
`${v1(user)}/tokens`,
|
|
349
|
+
{
|
|
350
|
+
token,
|
|
351
|
+
name: `Preview of ${storage}${project ? ` (${project})` : ''}`,
|
|
352
|
+
enabled: true,
|
|
353
|
+
allow: PREVIEW_ALLOW,
|
|
354
|
+
expire,
|
|
355
|
+
},
|
|
356
|
+
{ params },
|
|
357
|
+
);
|
|
358
|
+
} catch (e) {
|
|
359
|
+
throw apiError(e);
|
|
360
|
+
}
|
|
361
|
+
const accessToken = res.data?.access_token;
|
|
362
|
+
if (!accessToken) throw new Error(`the server returned no access token for ${token}`);
|
|
363
|
+
return { token, accessToken, expire };
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* The entry url opened as a standalone page: the SDK takes the session from
|
|
368
|
+
* the fragment (thinger_* params after the route) and strips it before the
|
|
369
|
+
* app starts, so it never reaches the server.
|
|
370
|
+
*
|
|
371
|
+
* @param {string} url Absolute entry url, with its optional #route.
|
|
372
|
+
* @param {string} token Access token.
|
|
373
|
+
* @param {Record<string, string | undefined>} [context] SDK context: project, device, product, type, group.
|
|
374
|
+
*/
|
|
375
|
+
export function previewUrl(url, token, context = {}) {
|
|
376
|
+
const params = new URLSearchParams({ thinger_token: token });
|
|
377
|
+
for (const [key, id] of Object.entries(context)) if (id) params.set(`thinger_${key}`, id);
|
|
378
|
+
const [base, route] = url.split('#');
|
|
379
|
+
return `${base}#${route || '/'}&${params}`;
|
|
380
|
+
}
|
|
381
|
+
|
|
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
|
+
/** The SDK context key each destination type fills. */
|
|
397
|
+
const CONTEXT_KEYS = {
|
|
398
|
+
device: 'device',
|
|
399
|
+
product: 'product',
|
|
400
|
+
asset_type: 'type',
|
|
401
|
+
asset_group: 'group',
|
|
402
|
+
project: 'project',
|
|
403
|
+
};
|
|
404
|
+
|
|
405
|
+
/** Point one destination at `url`; returns where to open it in the console. */
|
|
406
|
+
async function point(d, url, user) {
|
|
407
|
+
const base = serverURL(readConfig().server) || '';
|
|
408
|
+
if (d.dashboard) {
|
|
409
|
+
const body = {
|
|
410
|
+
source: 'url',
|
|
411
|
+
url,
|
|
412
|
+
...(d.name ? { name: d.name } : {}),
|
|
413
|
+
...(d.description !== undefined ? { description: d.description } : {}),
|
|
414
|
+
};
|
|
415
|
+
try {
|
|
416
|
+
await api.put(`${v1(user)}/dashboards/${d.dashboard}`, body);
|
|
417
|
+
} catch (e) {
|
|
418
|
+
if (e.response?.status !== 404) throw apiError(e);
|
|
419
|
+
try {
|
|
420
|
+
await api.post(`${v1(user)}/dashboards`, {
|
|
421
|
+
dashboard: d.dashboard,
|
|
422
|
+
name: d.name || d.dashboard,
|
|
423
|
+
...body,
|
|
424
|
+
});
|
|
425
|
+
} catch (e2) {
|
|
426
|
+
throw apiError(e2);
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
return `${base}/console/dashboards/${d.dashboard}`;
|
|
430
|
+
}
|
|
431
|
+
const property = propertyPath(d.type, user, d.id);
|
|
432
|
+
const value = { source: 'url', url };
|
|
433
|
+
try {
|
|
434
|
+
await api.put(property, { value });
|
|
435
|
+
} catch (e) {
|
|
436
|
+
if (e.response?.status !== 404) throw apiError(e);
|
|
437
|
+
// No dashboard property yet: create it.
|
|
438
|
+
try {
|
|
439
|
+
await api.post(property.replace(/\/dashboard$/, ''), { property: 'dashboard', value });
|
|
440
|
+
} catch (e2) {
|
|
441
|
+
throw apiError(e2, { notFound: `${d.type} not found: ${d.id}` });
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
return `${base}/console/${TARGETS[d.type].console}/${d.id}/dashboard`;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* Publish an HTML app to a public storage and/or point dashboards at it.
|
|
449
|
+
*
|
|
450
|
+
* Both halves are optional: upload a new build without touching the
|
|
451
|
+
* dashboards, point more dashboards at an app already uploaded, or both at
|
|
452
|
+
* once. One storage can hold an app shared by many dashboards, each opening
|
|
453
|
+
* its own entry page or initial route. Storage files are served with
|
|
454
|
+
* no-cache and an ETag, so viewers get the new upload on their next load;
|
|
455
|
+
* `bump` adds ?v=<publish time> to the urls so open consoles reload at once.
|
|
456
|
+
*
|
|
457
|
+
* With `preview`, each destination also gets a standalone link carrying a
|
|
458
|
+
* read-only token that expires in an hour (see previewToken).
|
|
459
|
+
*
|
|
460
|
+
* @param {{
|
|
461
|
+
* storage?: string,
|
|
462
|
+
* files?: Record<string, Buffer | string>,
|
|
463
|
+
* prune?: boolean,
|
|
464
|
+
* to?: Destination[],
|
|
465
|
+
* preview?: boolean | number,
|
|
466
|
+
* point?: boolean,
|
|
467
|
+
* bump?: boolean,
|
|
468
|
+
* user?: string | null,
|
|
469
|
+
* }} opts
|
|
470
|
+
*/
|
|
471
|
+
export async function publishApp({
|
|
472
|
+
storage,
|
|
473
|
+
files,
|
|
474
|
+
prune = false,
|
|
475
|
+
to = [],
|
|
476
|
+
preview = false,
|
|
477
|
+
point: repoint = true,
|
|
478
|
+
bump = false,
|
|
479
|
+
user,
|
|
480
|
+
}) {
|
|
481
|
+
if (!Array.isArray(to)) throw inputError('to must be a list of destinations');
|
|
482
|
+
to.forEach(checkDestination);
|
|
483
|
+
const names = files ? Object.keys(files) : [];
|
|
484
|
+
if (files && !names.length) throw inputError('no files to publish');
|
|
485
|
+
if (!files && !to.length && !preview)
|
|
486
|
+
throw inputError('nothing to do: pass files to upload, to to point, or both');
|
|
487
|
+
const hours = preview === true ? PREVIEW_HOURS : Number(preview || 0);
|
|
488
|
+
if (preview !== false && !(hours > 0 && hours <= PREVIEW_MAX_HOURS)) {
|
|
489
|
+
throw inputError(`preview is true or a number of hours, up to ${PREVIEW_MAX_HOURS}`);
|
|
490
|
+
}
|
|
491
|
+
if (prune && !files) throw inputError('prune needs the files of the new version');
|
|
492
|
+
|
|
493
|
+
if (!storage && to.length !== 1) {
|
|
494
|
+
throw inputError('pass storage: it names the app shared by these destinations');
|
|
495
|
+
}
|
|
496
|
+
const storageId =
|
|
497
|
+
storage ||
|
|
498
|
+
defaultStorage(
|
|
499
|
+
to[0].dashboard,
|
|
500
|
+
to[0].dashboard ? undefined : { type: to[0].type, id: to[0].id },
|
|
501
|
+
);
|
|
502
|
+
if (!ID_RE.test(storageId)) throw inputError(`invalid storage id: "${storageId}"`);
|
|
503
|
+
|
|
504
|
+
// Check the resources before creating or filling any storage for them,
|
|
505
|
+
// and gather their projects: a storage created for them joins those.
|
|
506
|
+
const owner = user || readConfig().username;
|
|
507
|
+
const projects = new Set();
|
|
508
|
+
for (const d of to.filter((x) => x.type)) {
|
|
509
|
+
let record;
|
|
510
|
+
try {
|
|
511
|
+
record = (await api.get(targetPath(d.type, user, d.id))).data;
|
|
512
|
+
} catch (e) {
|
|
513
|
+
throw apiError(e, { notFound: `${d.type} not found: ${d.id}` });
|
|
514
|
+
}
|
|
515
|
+
if (d.type === 'project') projects.add(`${owner}@${d.id}`);
|
|
516
|
+
for (const p of Array.isArray(record?.project) ? record.project : []) projects.add(p);
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
let storageInfo;
|
|
520
|
+
let deleted = [];
|
|
521
|
+
if (files) {
|
|
522
|
+
const label = to.length === 1 && to[0].name ? to[0].name : `App ${storageId}`;
|
|
523
|
+
storageInfo = await ensurePublicStorage(storageId, label, user, [...projects]);
|
|
524
|
+
await uploadFiles(
|
|
525
|
+
storageId,
|
|
526
|
+
Object.fromEntries(Object.entries(files).map(([k, v]) => [k, Buffer.from(v)])),
|
|
527
|
+
user,
|
|
528
|
+
);
|
|
529
|
+
if (prune) {
|
|
530
|
+
const keep = new Set(names);
|
|
531
|
+
deleted = (await listFiles(storageId, user)).filter((f) => !keep.has(f));
|
|
532
|
+
await remove(storageId, deleted, user);
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
// Every entry must be in the storage before anything points at it.
|
|
537
|
+
const available = new Set(storageInfo?.created ? [] : await listFiles(storageId, user));
|
|
538
|
+
for (const name of names) available.add(name);
|
|
539
|
+
for (const d of to) {
|
|
540
|
+
const entry = d.entry || 'index.html';
|
|
541
|
+
if (!available.has(entry)) {
|
|
542
|
+
throw inputError(`storage ${storageId} has no ${entry}: upload the app first`);
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
storageInfo ??= await ensurePublicStorage(storageId, `App ${storageId}`, user, [...projects]);
|
|
546
|
+
|
|
547
|
+
// The storage's index, served at its folder url: kept while it is in the
|
|
548
|
+
// storage, else index.html or the first destination's entry.
|
|
549
|
+
let index = storageInfo.index;
|
|
550
|
+
if (!index || !available.has(index)) {
|
|
551
|
+
const fallback = available.has('index.html') ? 'index.html' : to[0]?.entry;
|
|
552
|
+
if (fallback && fallback !== index) {
|
|
553
|
+
try {
|
|
554
|
+
await api.put(`${v1(user)}/storages/${storageId}`, { index: fallback });
|
|
555
|
+
} catch (e) {
|
|
556
|
+
throw apiError(e);
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
index = fallback;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
const urlOpts = { index, version: bump ? Date.now() : undefined };
|
|
563
|
+
const pointed = [];
|
|
564
|
+
for (const d of repoint ? to : []) {
|
|
565
|
+
const url = entryUrl(user, storageId, d, urlOpts);
|
|
566
|
+
pointed.push({
|
|
567
|
+
to: d.dashboard ? `dashboard ${d.dashboard}` : `${d.type} ${d.id}`,
|
|
568
|
+
url,
|
|
569
|
+
open: await point(d, url, user),
|
|
570
|
+
});
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
// One token per project the previews run in (none for account dashboards).
|
|
574
|
+
const previews = [];
|
|
575
|
+
if (preview) {
|
|
576
|
+
const base = serverURL(readConfig().server) || '';
|
|
577
|
+
const tokens = new Map();
|
|
578
|
+
for (const d of to.length ? to : [{}]) {
|
|
579
|
+
const project = d.type === 'project' ? d.id : undefined;
|
|
580
|
+
if (!tokens.has(project)) {
|
|
581
|
+
tokens.set(project, await previewToken(storageId, { project, hours, user }));
|
|
582
|
+
}
|
|
583
|
+
const { accessToken, expire } = tokens.get(project);
|
|
584
|
+
const url = previewUrl(
|
|
585
|
+
base + entryUrl(user, storageId, d, urlOpts),
|
|
586
|
+
accessToken,
|
|
587
|
+
d.type ? { [CONTEXT_KEYS[d.type]]: d.id } : {},
|
|
588
|
+
);
|
|
589
|
+
previews.push({
|
|
590
|
+
to: d.dashboard ? `dashboard ${d.dashboard}` : d.type ? `${d.type} ${d.id}` : 'app',
|
|
591
|
+
url,
|
|
592
|
+
layout: layoutUrl(base, url),
|
|
593
|
+
expire,
|
|
594
|
+
});
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
return {
|
|
598
|
+
storage: storageId,
|
|
599
|
+
storageCreated: storageInfo.created,
|
|
600
|
+
uploaded: names,
|
|
601
|
+
deleted,
|
|
602
|
+
pointed,
|
|
603
|
+
previews,
|
|
604
|
+
};
|
|
605
|
+
}
|