@thinger-io/cli-core 0.2.1 → 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 +87 -0
- package/README.md +31 -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 +13 -3
- 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 +12 -2
- package/lib/mcp/registry.js +8 -6
- package/lib/mcp/server.js +11 -2
- package/lib/mcp/tools-dashboards.js +169 -30
- package/lib/mcp/tools-misc.js +1 -1
- package/lib/mcp/tools-resource.js +387 -50
- package/lib/tgz.js +66 -0
- package/package.json +3 -1
|
@@ -0,0 +1,417 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import { exec } from 'node:child_process';
|
|
3
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import { createRequire } from 'node:module';
|
|
6
|
+
import os from 'node:os';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import { promisify } from 'node:util';
|
|
9
|
+
import { checkDestination, destinationContext, manifestName } from './dashboard.js';
|
|
10
|
+
import { SDK_PACKAGE } from './dashboard-sdk.js';
|
|
11
|
+
import { inputError } from './errors.js';
|
|
12
|
+
import { identity } from './identity.js';
|
|
13
|
+
import { toolName } from './mcp/naming.js';
|
|
14
|
+
import { extractTgz } from './tgz.js';
|
|
15
|
+
|
|
16
|
+
const run = promisify(exec);
|
|
17
|
+
const REGISTRY = 'https://registry.npmjs.org';
|
|
18
|
+
const FETCH_TIMEOUT_MS = 30000;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* `dashboard init`: a new dashboard app from one of the templates the SDK
|
|
22
|
+
* package ships (`templates/<name>` in @thinger-io/dashboard-sdk), ready to
|
|
23
|
+
* develop with `dashboard dev` and publish with `dashboard publish`: the
|
|
24
|
+
* project, its manifest (`<cli>.json`) and an AGENTS.md with the rules an
|
|
25
|
+
* app in the console's frame follows.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/** Template the init uses unless told otherwise. */
|
|
29
|
+
export const DEFAULT_TEMPLATE = 'react';
|
|
30
|
+
|
|
31
|
+
/** Folders of a template never copied (left there by trying it in place). */
|
|
32
|
+
const NOT_COPIED = new Set(['node_modules', 'dist', '.angular']);
|
|
33
|
+
|
|
34
|
+
/** The SDK package the CLI installs (its own dependency), or null. */
|
|
35
|
+
function ownSdk() {
|
|
36
|
+
try {
|
|
37
|
+
return path.dirname(createRequire(import.meta.url).resolve(`${SDK_PACKAGE}/package.json`));
|
|
38
|
+
} catch {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** A version or dist-tag of the SDK on npm, as `--from` names it ("0.3.0", "next", "<pkg>@0.3.0"). */
|
|
44
|
+
export function sdkSpec(from) {
|
|
45
|
+
const tag = from.startsWith(`${SDK_PACKAGE}@`) ? from.slice(SDK_PACKAGE.length + 1) : from;
|
|
46
|
+
return /^[0-9A-Za-z][0-9A-Za-z.+-]{0,63}$/.test(tag) ? tag : null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Download a version (or dist-tag) of the SDK package from npm, checking
|
|
51
|
+
* its integrity.
|
|
52
|
+
*
|
|
53
|
+
* @param {string} tag
|
|
54
|
+
* @returns {Promise<Buffer>}
|
|
55
|
+
*/
|
|
56
|
+
async function downloadSdk(tag) {
|
|
57
|
+
const signal = () => AbortSignal.timeout(FETCH_TIMEOUT_MS);
|
|
58
|
+
const meta = await fetch(`${REGISTRY}/${SDK_PACKAGE}/${encodeURIComponent(tag)}`, {
|
|
59
|
+
signal: signal(),
|
|
60
|
+
});
|
|
61
|
+
if (meta.status === 404) throw inputError(`${SDK_PACKAGE} has no version or tag "${tag}"`);
|
|
62
|
+
if (!meta.ok) throw new Error(`npm answered ${meta.status}`);
|
|
63
|
+
const { dist } = await meta.json();
|
|
64
|
+
if (typeof dist?.tarball !== 'string' || !dist.tarball.startsWith(`${REGISTRY}/`)) {
|
|
65
|
+
throw new Error('npm gave no tarball');
|
|
66
|
+
}
|
|
67
|
+
const res = await fetch(dist.tarball, { signal: signal() });
|
|
68
|
+
if (!res.ok) throw new Error(`npm answered ${res.status} for the tarball`);
|
|
69
|
+
const tgz = Buffer.from(await res.arrayBuffer());
|
|
70
|
+
const sri = /^sha512-(.+)$/.exec(dist.integrity || '');
|
|
71
|
+
if (sri && createHash('sha512').update(tgz).digest('base64') !== sri[1]) {
|
|
72
|
+
throw new Error('the tarball does not match its integrity');
|
|
73
|
+
}
|
|
74
|
+
return tgz;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The SDK package to take the templates from: by default its latest
|
|
79
|
+
* release on npm (or the CLI's own copy when npm cannot be reached);
|
|
80
|
+
* `from` names a local package folder, a .tgz, or a version or tag on npm.
|
|
81
|
+
*
|
|
82
|
+
* @param {string} [from]
|
|
83
|
+
* @returns {Promise<{ root: string, version: string, dependency: string, notes: string[],
|
|
84
|
+
* cleanup: () => void }>}
|
|
85
|
+
*/
|
|
86
|
+
async function sdkPackage(from) {
|
|
87
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'dashboard-init-'));
|
|
88
|
+
const cleanup = () => fs.rmSync(tmp, { recursive: true, force: true });
|
|
89
|
+
const notes = [];
|
|
90
|
+
try {
|
|
91
|
+
const local = from && fs.existsSync(from) ? path.resolve(from) : null;
|
|
92
|
+
let root = local && fs.statSync(local).isDirectory() ? local : null;
|
|
93
|
+
/** @type {Buffer | null} */
|
|
94
|
+
let tgz = root || !local ? null : fs.readFileSync(local);
|
|
95
|
+
if (!root && !tgz) {
|
|
96
|
+
const tag = from ? sdkSpec(from) : 'latest';
|
|
97
|
+
if (!tag) {
|
|
98
|
+
throw inputError(
|
|
99
|
+
`--from is a folder or .tgz of ${SDK_PACKAGE}, or one of its versions or tags; got "${from}"`,
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
try {
|
|
103
|
+
tgz = await downloadSdk(tag);
|
|
104
|
+
} catch (e) {
|
|
105
|
+
// Offline: the copy the CLI installs, unless another one was asked for.
|
|
106
|
+
root = from || e.code === 'input_error' ? null : ownSdk();
|
|
107
|
+
if (!root) {
|
|
108
|
+
if (e.code === 'input_error') throw e;
|
|
109
|
+
throw inputError(
|
|
110
|
+
`could not get ${SDK_PACKAGE}@${tag} from npm (${e.message}); pass --from with a local copy of the package`,
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
notes.push(
|
|
114
|
+
`npm could not be reached (${e.message}): the template comes from the SDK the CLI installs.`,
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
if (tgz) {
|
|
119
|
+
try {
|
|
120
|
+
extractTgz(tgz, tmp, { strip: 1 });
|
|
121
|
+
} catch (e) {
|
|
122
|
+
throw inputError(`${from || SDK_PACKAGE}: ${e.message}`);
|
|
123
|
+
}
|
|
124
|
+
root = tmp;
|
|
125
|
+
}
|
|
126
|
+
const pkgFile = path.join(/** @type {string} */ (root), 'package.json');
|
|
127
|
+
const pkg = fs.existsSync(pkgFile) ? JSON.parse(fs.readFileSync(pkgFile, 'utf8')) : {};
|
|
128
|
+
if (pkg.name !== SDK_PACKAGE) {
|
|
129
|
+
throw inputError(`${from || root} is not the ${SDK_PACKAGE} package`);
|
|
130
|
+
}
|
|
131
|
+
// A local copy is installed from where it is; one from npm by version.
|
|
132
|
+
const dependency = local ? `file:${local}` : `^${pkg.version}`;
|
|
133
|
+
return {
|
|
134
|
+
root: /** @type {string} */ (root),
|
|
135
|
+
version: pkg.version,
|
|
136
|
+
dependency,
|
|
137
|
+
notes,
|
|
138
|
+
cleanup,
|
|
139
|
+
};
|
|
140
|
+
} catch (e) {
|
|
141
|
+
cleanup();
|
|
142
|
+
throw e;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** An npm package name from a folder name. */
|
|
147
|
+
export function packageName(dir) {
|
|
148
|
+
const name = path
|
|
149
|
+
.basename(path.resolve(dir))
|
|
150
|
+
.toLowerCase()
|
|
151
|
+
.replace(/[^a-z0-9._-]+/g, '-')
|
|
152
|
+
.replace(/^[._-]+|[-]+$/g, '');
|
|
153
|
+
return name || 'dashboard-app';
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* The storage an app publishes to by default: app_<name>_<random>, within 32
|
|
158
|
+
* characters, so two apps in folders of the same name never share (and
|
|
159
|
+
* prune) one storage.
|
|
160
|
+
*/
|
|
161
|
+
export function appStorage(name, suffix = randomBytes(2).toString('hex')) {
|
|
162
|
+
return `${`app_${name.replace(/[^a-zA-Z0-9_-]/g, '_')}`.slice(0, 27)}_${suffix}`;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function copyDir(from, to) {
|
|
166
|
+
fs.mkdirSync(to, { recursive: true });
|
|
167
|
+
for (const entry of fs.readdirSync(from, { withFileTypes: true })) {
|
|
168
|
+
const src = path.join(from, entry.name);
|
|
169
|
+
const dest = path.join(to, entry.name === '_gitignore' ? '.gitignore' : entry.name);
|
|
170
|
+
if (entry.isDirectory()) {
|
|
171
|
+
if (!NOT_COPIED.has(entry.name)) copyDir(src, dest);
|
|
172
|
+
} else if (entry.isFile()) fs.copyFileSync(src, dest);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function editJson(file, edit) {
|
|
177
|
+
const json = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
178
|
+
edit(json);
|
|
179
|
+
fs.writeFileSync(file, JSON.stringify(json, null, 4) + '\n');
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Guide for the agents that work on the app. */
|
|
183
|
+
function agentsGuide({ name, template, buildDir, devCommand }) {
|
|
184
|
+
const cli = identity().name;
|
|
185
|
+
return `# ${name}
|
|
186
|
+
|
|
187
|
+
A Thinger dashboard app (${template} template) built on \`${SDK_PACKAGE}\`. The Thinger
|
|
188
|
+
console shows it in a sandboxed iframe, loading the build from a file storage; it also runs on
|
|
189
|
+
its own as a standalone page.
|
|
190
|
+
|
|
191
|
+
## Develop
|
|
192
|
+
|
|
193
|
+
1. \`npm install\`, then \`${devCommand}\` (the app's dev server).
|
|
194
|
+
2. \`${cli} dashboard dev\` gives a link that opens the local app against the Thinger server, with
|
|
195
|
+
a temporary token and the context in ${manifestName()} (\`dev\`). Open it in a browser: the
|
|
196
|
+
app gets real data, reloads on every change, and its errors are in the browser console.
|
|
197
|
+
Agents: the MCP tool \`${toolName('dashboard_dev')}\` gives the same link; open it with browser
|
|
198
|
+
tools.
|
|
199
|
+
3. \`npm run build\`, then \`${cli} dashboard publish\`: it uploads \`${buildDir}\` and points the
|
|
200
|
+
destinations in ${manifestName()} at it.
|
|
201
|
+
|
|
202
|
+
## Reviewing the layout
|
|
203
|
+
|
|
204
|
+
Dashboards show at website sizes (around 1150 × 758 and 950 × 678) and on phones (around
|
|
205
|
+
380 × 600). Open the dev link and resize the window; below about 500 px, where a desktop window
|
|
206
|
+
does not shrink, use Playwright with a fixed viewport, or load the dev link in an iframe of that
|
|
207
|
+
size from a page of the app's own dev server (same origin, so you can look inside it). Give the
|
|
208
|
+
iframe the whole dev link, with its \`#…&thinger_token=…\` part, never \`location.href\`: the SDK
|
|
209
|
+
removes the token from the address bar when it starts, and a frame does not take the session
|
|
210
|
+
from the tab, so it would have none. The frame posts \`thinger:dashboard-ready\` to the page once
|
|
211
|
+
its data is in. The package README has more under "Reviewing the layout".
|
|
212
|
+
|
|
213
|
+
## Rules (they only fail once published, inside the console)
|
|
214
|
+
|
|
215
|
+
- Hash routing only: the storage serves real files, not app routes.
|
|
216
|
+
- Relative asset URLs and no \`<base>\` tag.
|
|
217
|
+
- Render after \`Thinger.ready()\`: it brings the user, the token and the context
|
|
218
|
+
(\`Thinger.context.device\`…).
|
|
219
|
+
- No localStorage or cookies (the frame has an opaque origin): keep settings in properties
|
|
220
|
+
through the SDK.
|
|
221
|
+
- Reach the server only through the SDK (\`Thinger.*\`), never with a token of your own.
|
|
222
|
+
- Reload the app with \`Thinger.reload()\`, never \`location.reload()\`: inside the console a
|
|
223
|
+
plain reload loses the frame's key and the app gets no token.
|
|
224
|
+
|
|
225
|
+
## Reference
|
|
226
|
+
|
|
227
|
+
\`node_modules/${SDK_PACKAGE}/README.md\` documents every call of the installed version, and its
|
|
228
|
+
types come with the package; \`CHANGELOG.md\` there says what changes between versions.
|
|
229
|
+
`;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** npm package of the running CLI, to run it with npx in CI. */
|
|
233
|
+
function cliPackage() {
|
|
234
|
+
if (identity().npmPackage) return identity().npmPackage;
|
|
235
|
+
try {
|
|
236
|
+
let dir = path.dirname(fs.realpathSync(process.argv[1]));
|
|
237
|
+
for (;;) {
|
|
238
|
+
const file = path.join(dir, 'package.json');
|
|
239
|
+
if (fs.existsSync(file)) {
|
|
240
|
+
const { name } = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
241
|
+
if (name) return name;
|
|
242
|
+
}
|
|
243
|
+
const parent = path.dirname(dir);
|
|
244
|
+
if (parent === dir) break;
|
|
245
|
+
dir = parent;
|
|
246
|
+
}
|
|
247
|
+
} catch {
|
|
248
|
+
// fall back to the binary name
|
|
249
|
+
}
|
|
250
|
+
return identity().name;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** Workflow that publishes the app on every push to main. */
|
|
254
|
+
function ciWorkflow() {
|
|
255
|
+
const { envPrefix } = identity();
|
|
256
|
+
return `name: Publish dashboard
|
|
257
|
+
on:
|
|
258
|
+
push:
|
|
259
|
+
branches: [main]
|
|
260
|
+
jobs:
|
|
261
|
+
publish:
|
|
262
|
+
runs-on: ubuntu-latest
|
|
263
|
+
steps:
|
|
264
|
+
- uses: actions/checkout@v4
|
|
265
|
+
- uses: actions/setup-node@v4
|
|
266
|
+
with:
|
|
267
|
+
node-version: 22
|
|
268
|
+
- run: npm ci && npm run build
|
|
269
|
+
- run: npx -y ${cliPackage()} dashboard publish
|
|
270
|
+
env:
|
|
271
|
+
${envPrefix}_SERVER: \${{ vars.${envPrefix}_SERVER }}
|
|
272
|
+
${envPrefix}_TOKEN: \${{ secrets.${envPrefix}_TOKEN }}
|
|
273
|
+
`;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Create a dashboard app in `dir` (new or empty).
|
|
278
|
+
*
|
|
279
|
+
* @param {{ dir: string, template?: string, to?: import('./dashboard.js').Destination,
|
|
280
|
+
* storage?: string, from?: string, ci?: boolean, install?: boolean }} opts
|
|
281
|
+
*/
|
|
282
|
+
export async function initApp({
|
|
283
|
+
dir,
|
|
284
|
+
template = DEFAULT_TEMPLATE,
|
|
285
|
+
to,
|
|
286
|
+
storage,
|
|
287
|
+
from,
|
|
288
|
+
ci = false,
|
|
289
|
+
install = false,
|
|
290
|
+
}) {
|
|
291
|
+
if (!dir) throw inputError('pass the folder of the new app');
|
|
292
|
+
const target = path.resolve(dir);
|
|
293
|
+
const existed = fs.existsSync(target);
|
|
294
|
+
if (existed && !fs.statSync(target).isDirectory()) throw inputError(`${dir} is a file`);
|
|
295
|
+
if (existed && fs.readdirSync(target).some((f) => f !== '.git')) {
|
|
296
|
+
throw inputError(`${dir} is not empty: pass a new folder`);
|
|
297
|
+
}
|
|
298
|
+
if (to) checkDestination(to);
|
|
299
|
+
|
|
300
|
+
const pkg = await sdkPackage(from);
|
|
301
|
+
try {
|
|
302
|
+
return await createApp({ target, dir, template, to, storage, ci, install, pkg });
|
|
303
|
+
} catch (e) {
|
|
304
|
+
// Leave no half-made app behind (a .git that was there stays).
|
|
305
|
+
if (existed) {
|
|
306
|
+
for (const f of fs.readdirSync(target)) {
|
|
307
|
+
if (f !== '.git') fs.rmSync(path.join(target, f), { recursive: true, force: true });
|
|
308
|
+
}
|
|
309
|
+
} else fs.rmSync(target, { recursive: true, force: true });
|
|
310
|
+
throw e;
|
|
311
|
+
} finally {
|
|
312
|
+
pkg.cleanup();
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** The steps of initApp once the package is at hand. */
|
|
317
|
+
async function createApp({ target, dir, template, to, storage, ci, install, pkg }) {
|
|
318
|
+
const notes = [...pkg.notes];
|
|
319
|
+
{
|
|
320
|
+
const templates = path.join(pkg.root, 'templates');
|
|
321
|
+
const available = fs.existsSync(templates)
|
|
322
|
+
? fs
|
|
323
|
+
.readdirSync(templates, { withFileTypes: true })
|
|
324
|
+
.filter((e) => e.isDirectory())
|
|
325
|
+
.map((e) => e.name)
|
|
326
|
+
: [];
|
|
327
|
+
if (!available.includes(template)) {
|
|
328
|
+
throw inputError(
|
|
329
|
+
`${SDK_PACKAGE} ${pkg.version} has no template "${template}"; it has ${available.join(', ') || 'none'}`,
|
|
330
|
+
);
|
|
331
|
+
}
|
|
332
|
+
copyDir(path.join(templates, template), target);
|
|
333
|
+
}
|
|
334
|
+
if (!fs.existsSync(path.join(target, 'package.json'))) {
|
|
335
|
+
throw inputError(`template "${template}" has no package.json`);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const name = packageName(target);
|
|
339
|
+
let original;
|
|
340
|
+
editJson(path.join(target, 'package.json'), (json) => {
|
|
341
|
+
original = json.name;
|
|
342
|
+
json.name = name;
|
|
343
|
+
for (const deps of [json.dependencies, json.devDependencies]) {
|
|
344
|
+
if (deps?.[SDK_PACKAGE]) deps[SDK_PACKAGE] = pkg.dependency;
|
|
345
|
+
}
|
|
346
|
+
});
|
|
347
|
+
const scripts = JSON.parse(fs.readFileSync(path.join(target, 'package.json'), 'utf8')).scripts;
|
|
348
|
+
|
|
349
|
+
// Angular names its build folder after the project: dist/<project>/browser.
|
|
350
|
+
const angular = fs.existsSync(path.join(target, 'angular.json'));
|
|
351
|
+
if (angular && original) {
|
|
352
|
+
for (const file of ['angular.json', 'README.md']) {
|
|
353
|
+
const full = path.join(target, file);
|
|
354
|
+
if (fs.existsSync(full)) {
|
|
355
|
+
fs.writeFileSync(full, fs.readFileSync(full, 'utf8').split(original).join(name));
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
const buildDir = angular ? `dist/${name}/browser` : 'dist';
|
|
360
|
+
const devCommand = scripts?.dev ? 'npm run dev' : 'npm start';
|
|
361
|
+
const devUrl = angular ? 'http://localhost:4200/' : 'http://localhost:5173/';
|
|
362
|
+
|
|
363
|
+
const manifest = {
|
|
364
|
+
dashboard: {
|
|
365
|
+
dir: buildDir,
|
|
366
|
+
storage: storage || appStorage(name),
|
|
367
|
+
prune: true,
|
|
368
|
+
...(to ? { to: [to] } : {}),
|
|
369
|
+
dev: { url: devUrl },
|
|
370
|
+
},
|
|
371
|
+
};
|
|
372
|
+
fs.writeFileSync(path.join(target, manifestName()), JSON.stringify(manifest, null, 4) + '\n');
|
|
373
|
+
fs.writeFileSync(
|
|
374
|
+
path.join(target, 'AGENTS.md'),
|
|
375
|
+
agentsGuide({ name, template, buildDir, devCommand }),
|
|
376
|
+
);
|
|
377
|
+
fs.writeFileSync(path.join(target, 'CLAUDE.md'), '@AGENTS.md\n');
|
|
378
|
+
if (ci && pkg.dependency.startsWith('file:')) {
|
|
379
|
+
notes.push(
|
|
380
|
+
`The SDK dependency points at a local copy (${pkg.dependency}): the workflow's npm ci fails until it names a published version.`,
|
|
381
|
+
);
|
|
382
|
+
}
|
|
383
|
+
if (ci) {
|
|
384
|
+
const workflows = path.join(target, '.github', 'workflows');
|
|
385
|
+
fs.mkdirSync(workflows, { recursive: true });
|
|
386
|
+
fs.writeFileSync(path.join(workflows, 'dashboard.yml'), ciWorkflow());
|
|
387
|
+
}
|
|
388
|
+
if (install) {
|
|
389
|
+
try {
|
|
390
|
+
// a fixed command line, run in the app's folder
|
|
391
|
+
await run('npm install --no-fund --no-audit', { cwd: target });
|
|
392
|
+
} catch (e) {
|
|
393
|
+
throw inputError(
|
|
394
|
+
`created ${dir}, but npm install failed: ${
|
|
395
|
+
String(e.stderr || e.message)
|
|
396
|
+
.trim()
|
|
397
|
+
.split('\n')
|
|
398
|
+
.slice(-1)[0]
|
|
399
|
+
}`,
|
|
400
|
+
);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
return {
|
|
404
|
+
dir: target,
|
|
405
|
+
name,
|
|
406
|
+
template,
|
|
407
|
+
sdk: pkg.version,
|
|
408
|
+
dependency: pkg.dependency,
|
|
409
|
+
build: buildDir,
|
|
410
|
+
devCommand,
|
|
411
|
+
devUrl,
|
|
412
|
+
storage: manifest.dashboard.storage,
|
|
413
|
+
context: destinationContext(to),
|
|
414
|
+
installed: install,
|
|
415
|
+
notes,
|
|
416
|
+
};
|
|
417
|
+
}
|
|
@@ -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
|
+
}
|