@thinger-io/cli-core 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +67 -0
- package/README.md +19 -6
- package/docs/thinger-concepts.md +389 -0
- package/docs/thinger-product-code.md +159 -0
- package/docs/thinger-server-admin.md +165 -0
- package/lib/cli.js +4 -2
- package/lib/commands/auth.js +20 -1
- package/lib/commands/dashboard.js +262 -0
- package/lib/config.js +42 -0
- package/lib/dashboard-dev.js +180 -0
- package/lib/dashboard-init.js +417 -0
- package/lib/dashboard-sdk.js +184 -0
- package/lib/dashboard.js +171 -28
- package/lib/docs.js +110 -110
- package/lib/identity.js +2 -0
- package/lib/mcp/catalog.js +2 -2
- package/lib/mcp/registry.js +4 -6
- package/lib/mcp/tools-dashboards.js +169 -30
- package/lib/mcp/tools-misc.js +1 -1
- package/lib/mcp/tools-resource.js +2 -2
- package/lib/tgz.js +66 -0
- package/package.json +3 -1
package/lib/config.js
CHANGED
|
@@ -131,6 +131,8 @@ export function getActiveProfile() {
|
|
|
131
131
|
if (scoped) return scoped;
|
|
132
132
|
if (activeProfileOverride) return activeProfileOverride;
|
|
133
133
|
if (env('PROFILE')) return env('PROFILE');
|
|
134
|
+
// A profile given by the environment has no name: readConfig reads it.
|
|
135
|
+
if (envProfile()) return null;
|
|
134
136
|
const store = readStore();
|
|
135
137
|
if (store.default && store.profiles[store.default]) return store.default;
|
|
136
138
|
// Fall back to the only profile if there's exactly one.
|
|
@@ -224,16 +226,48 @@ export function deleteProfile(name) {
|
|
|
224
226
|
* Check if any profile is configured.
|
|
225
227
|
*/
|
|
226
228
|
export function configExists() {
|
|
229
|
+
if (envProfile()) return true;
|
|
227
230
|
const store = readStore();
|
|
228
231
|
if (!Object.keys(store.profiles).length) return false;
|
|
229
232
|
return getActiveProfile() != null;
|
|
230
233
|
}
|
|
231
234
|
|
|
235
|
+
/**
|
|
236
|
+
* A profile given by the environment, for CI and other machines without a
|
|
237
|
+
* login: <PREFIX>_TOKEN (an access token) and <PREFIX>_SERVER (a host or a
|
|
238
|
+
* url; never the identity's default server, which would receive a token
|
|
239
|
+
* meant for another one). The user comes from the
|
|
240
|
+
* token. It applies unless a profile is named (--profile, <PREFIX>_PROFILE,
|
|
241
|
+
* a tool call's `profile`), which keeps reading the saved ones.
|
|
242
|
+
*
|
|
243
|
+
* @returns {{ server: string | undefined, token: string, username: string, env: true } | null}
|
|
244
|
+
*/
|
|
245
|
+
function envProfile() {
|
|
246
|
+
const token = env('TOKEN');
|
|
247
|
+
if (!token || scopedProfile.getStore() || activeProfileOverride || env('PROFILE')) return null;
|
|
248
|
+
// Without a server it is still the profile in use: requireConfig says what is missing.
|
|
249
|
+
const server = env('SERVER') || undefined;
|
|
250
|
+
let username = null;
|
|
251
|
+
try {
|
|
252
|
+
username = JSON.parse(Buffer.from(token.split('.')[1], 'base64url').toString()).usr;
|
|
253
|
+
} catch {
|
|
254
|
+
// Not a JWT: requireConfig reports the missing user.
|
|
255
|
+
}
|
|
256
|
+
return {
|
|
257
|
+
server,
|
|
258
|
+
token,
|
|
259
|
+
username,
|
|
260
|
+
env: true,
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
|
|
232
264
|
/**
|
|
233
265
|
* Read the active profile's data.
|
|
234
266
|
* @returns {Object} Active profile fields (server, username, token, ...) or {}
|
|
235
267
|
*/
|
|
236
268
|
export function readConfig() {
|
|
269
|
+
const fromEnv = envProfile();
|
|
270
|
+
if (fromEnv) return fromEnv;
|
|
237
271
|
const store = readStore();
|
|
238
272
|
const name = getActiveProfile();
|
|
239
273
|
if (!name) return {};
|
|
@@ -258,6 +292,14 @@ export function resolveUser(user) {
|
|
|
258
292
|
|
|
259
293
|
export function requireConfig() {
|
|
260
294
|
const config = readConfig();
|
|
295
|
+
if (config.env && !config.server) {
|
|
296
|
+
const { envPrefix } = identity();
|
|
297
|
+
const err = new Error(
|
|
298
|
+
`${envPrefix}_TOKEN is set without ${envPrefix}_SERVER: set both, or unset ${envPrefix}_TOKEN to use the saved profiles.`,
|
|
299
|
+
);
|
|
300
|
+
err.code = 'not_configured';
|
|
301
|
+
throw err;
|
|
302
|
+
}
|
|
261
303
|
if (!config.token || !config.server || !config.username) {
|
|
262
304
|
const err = new Error(`Not configured. ${loginHint()}`);
|
|
263
305
|
err.code = 'not_configured';
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { readConfig, serverURL } from './config.js';
|
|
4
|
+
import { previewUrl, replaceToken } from './dashboard.js';
|
|
5
|
+
import { checkSdk } from './dashboard-sdk.js';
|
|
6
|
+
import { inputError } from './errors.js';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Local development of url-source dashboards: the app runs on its own dev
|
|
10
|
+
* server (ng serve, Vite…) and is opened directly, as a standalone page,
|
|
11
|
+
* with a link whose fragment carries the server (`thinger_api`), a
|
|
12
|
+
* temporary access token and the SDK context. The SDK calls the server
|
|
13
|
+
* itself (it answers CORS from any origin), so the app gets real data with
|
|
14
|
+
* its dev server's reload and the browser's own tools. The app bundles the
|
|
15
|
+
* SDK (an npm dependency), so its dev server needs no proxy.
|
|
16
|
+
*
|
|
17
|
+
* Unlike in the console, the page is not in a sandboxed iframe: what only
|
|
18
|
+
* fails there (localStorage, cookies, a <base> tag) does not fail here.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const CONTEXT_FIELDS = ['device', 'product', 'type', 'group', 'project'];
|
|
22
|
+
const ID_RE = /^[a-zA-Z0-9_-]{1,32}$/;
|
|
23
|
+
export const DEV_HOURS = 8;
|
|
24
|
+
const CHECK_TIMEOUT_MS = 3000;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The SDK context of a dev link, checked.
|
|
28
|
+
*
|
|
29
|
+
* @param {Record<string, string | undefined>} [context]
|
|
30
|
+
* @returns {Record<string, string>}
|
|
31
|
+
*/
|
|
32
|
+
export function checkContext(context = {}) {
|
|
33
|
+
/** @type {Record<string, string>} */
|
|
34
|
+
const out = {};
|
|
35
|
+
for (const [key, id] of Object.entries(context)) {
|
|
36
|
+
if (id === undefined || id === null || id === '') continue;
|
|
37
|
+
if (!CONTEXT_FIELDS.includes(key)) {
|
|
38
|
+
throw inputError(`context keys are ${CONTEXT_FIELDS.join(', ')}; got "${key}"`);
|
|
39
|
+
}
|
|
40
|
+
if (typeof id !== 'string' || !ID_RE.test(id)) {
|
|
41
|
+
throw inputError(`invalid ${key} id: "${id}"`);
|
|
42
|
+
}
|
|
43
|
+
out[key] = id;
|
|
44
|
+
}
|
|
45
|
+
return out;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Token id of dev links: one per project, so a new link replaces the
|
|
50
|
+
* previous one instead of piling up tokens.
|
|
51
|
+
*
|
|
52
|
+
* @param {string} [project]
|
|
53
|
+
*/
|
|
54
|
+
export function devTokenId(project) {
|
|
55
|
+
if (!project) return 'dashboard_dev';
|
|
56
|
+
const readable = `dashboard_dev_${project}`;
|
|
57
|
+
if (readable.length <= 32) return readable;
|
|
58
|
+
return `dashboard_dev_${createHash('sha1').update(project).digest('hex').slice(0, 18)}`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** A host of this machine: localhost, *.localhost, 127.0.0.0/8, [::1]. */
|
|
62
|
+
export function isLoopback(hostname) {
|
|
63
|
+
const host = hostname.replace(/^\[|\]$/g, '').toLowerCase();
|
|
64
|
+
return (
|
|
65
|
+
host === 'localhost' ||
|
|
66
|
+
host.endsWith('.localhost') ||
|
|
67
|
+
host === '::1' ||
|
|
68
|
+
/^127(\.\d{1,3}){3}$/.test(host)
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The app's url, checked: http(s), no credentials in it, and on this
|
|
74
|
+
* machine unless `allowRemote`: the link carries a full-access token, and
|
|
75
|
+
* the url may come from a manifest of someone else's repository.
|
|
76
|
+
*
|
|
77
|
+
* @param {string} url
|
|
78
|
+
* @param {{ allowRemote?: boolean }} [opts]
|
|
79
|
+
*/
|
|
80
|
+
export function checkAppUrl(url, { allowRemote = false } = {}) {
|
|
81
|
+
let parsed;
|
|
82
|
+
try {
|
|
83
|
+
parsed = new URL(String(url));
|
|
84
|
+
} catch {
|
|
85
|
+
throw inputError(`pass the url of the app's dev server, e.g. http://localhost:4200/`);
|
|
86
|
+
}
|
|
87
|
+
if (!/^https?:$/.test(parsed.protocol) || parsed.username || parsed.password) {
|
|
88
|
+
throw inputError(`the app's url must be http(s) without credentials: ${url}`);
|
|
89
|
+
}
|
|
90
|
+
if (!allowRemote && !isLoopback(parsed.hostname)) {
|
|
91
|
+
throw inputError(
|
|
92
|
+
`${parsed.host} is not this machine: the link would give it a full-access token. Pass allow remote (--allow-remote) only for a dev server you trust`,
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
return parsed;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** GET with a short timeout: status and text, or the reason it failed. */
|
|
99
|
+
async function probe(url) {
|
|
100
|
+
try {
|
|
101
|
+
const res = await fetch(url, { signal: AbortSignal.timeout(CHECK_TIMEOUT_MS) });
|
|
102
|
+
return { status: res.status, text: await res.text() };
|
|
103
|
+
} catch (e) {
|
|
104
|
+
return { error: e?.cause?.code || e?.name || String(e) };
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Notes on the app as its dev server serves it: whether it answers, and its
|
|
110
|
+
* page for the SDK check. Never an error: the link works once the dev
|
|
111
|
+
* server runs.
|
|
112
|
+
*
|
|
113
|
+
* @param {URL} app
|
|
114
|
+
* @returns {Promise<{ notes: string[], html?: string }>}
|
|
115
|
+
*/
|
|
116
|
+
export async function checkApp(app) {
|
|
117
|
+
const page = await probe(`${app.origin}${app.pathname}${app.search}`);
|
|
118
|
+
if (page.error || page.status >= 400) {
|
|
119
|
+
return {
|
|
120
|
+
notes: [
|
|
121
|
+
`Nothing serves the app at ${app.origin}${app.pathname} (${page.error || `HTTP ${page.status}`}): start its dev server, then open the link.`,
|
|
122
|
+
],
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
return { notes: [], html: page.text };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* A link that opens the app on its dev server against the profile's server,
|
|
130
|
+
* with a temporary token (full access to the account, or to the project
|
|
131
|
+
* with a project context) valid for `hours`. A new link replaces the
|
|
132
|
+
* previous one's token.
|
|
133
|
+
*
|
|
134
|
+
* @param {{ url: string, context?: Record<string, string | undefined>, route?: string,
|
|
135
|
+
* hours?: number, user?: string | null, check?: boolean, dir?: string, allowRemote?: boolean }} opts
|
|
136
|
+
* `dir`: the app's project folder, to check the SDK it is built with.
|
|
137
|
+
*/
|
|
138
|
+
export async function devLink({
|
|
139
|
+
url,
|
|
140
|
+
context: raw,
|
|
141
|
+
route,
|
|
142
|
+
hours = DEV_HOURS,
|
|
143
|
+
user,
|
|
144
|
+
check = true,
|
|
145
|
+
dir,
|
|
146
|
+
allowRemote = false,
|
|
147
|
+
}) {
|
|
148
|
+
const app = checkAppUrl(url, { allowRemote });
|
|
149
|
+
const context = checkContext(raw);
|
|
150
|
+
if (!(hours > 0 && hours <= 24 * 7)) throw inputError('hours is between 0 and 168');
|
|
151
|
+
const config = readConfig();
|
|
152
|
+
const server = serverURL(config.server);
|
|
153
|
+
if (!server || !config.token) throw inputError('no server configured: sign in first');
|
|
154
|
+
|
|
155
|
+
let notes = [];
|
|
156
|
+
if (check) {
|
|
157
|
+
const { notes: served, html } = await checkApp(app);
|
|
158
|
+
const files = html === undefined ? {} : { 'index.html': html };
|
|
159
|
+
notes = [...served, ...(await checkSdk({ dir, files }))];
|
|
160
|
+
}
|
|
161
|
+
const { token, accessToken, expire } = await replaceToken(devTokenId(context.project), {
|
|
162
|
+
name: 'Dashboard dev (temporary)',
|
|
163
|
+
allow: { '*': { '*': '*' } },
|
|
164
|
+
hours,
|
|
165
|
+
project: context.project,
|
|
166
|
+
user,
|
|
167
|
+
});
|
|
168
|
+
const hash = route ?? app.hash.replace(/^#/, '');
|
|
169
|
+
const base = `${app.origin}${app.pathname}${app.search}${hash ? `#${hash}` : ''}`;
|
|
170
|
+
return {
|
|
171
|
+
link: previewUrl(base, accessToken, { api: server, ...context }),
|
|
172
|
+
app: `${app.origin}${app.pathname}`,
|
|
173
|
+
server,
|
|
174
|
+
user: user || config.username,
|
|
175
|
+
context,
|
|
176
|
+
token,
|
|
177
|
+
expire,
|
|
178
|
+
notes,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
@@ -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
|
+
}
|