@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.
@@ -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 (`/sdk/0.1/thinger-dashboard.js`).
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
- const token = previewTokenId(storage, project);
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 [base, route] = url.split('#');
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
- * }} opts
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
+ }