@thinger-io/cli-core 0.1.0 → 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.
@@ -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
+ }