@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,262 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import open from 'open';
|
|
4
|
+
import {
|
|
5
|
+
collectFiles,
|
|
6
|
+
destinationContext,
|
|
7
|
+
manifestName,
|
|
8
|
+
parseDestination,
|
|
9
|
+
publishApp,
|
|
10
|
+
publishSummary,
|
|
11
|
+
readManifest,
|
|
12
|
+
} from '../dashboard.js';
|
|
13
|
+
import { DEV_HOURS, devLink } from '../dashboard-dev.js';
|
|
14
|
+
import { DEFAULT_TEMPLATE, initApp } from '../dashboard-init.js';
|
|
15
|
+
import { inputError } from '../errors.js';
|
|
16
|
+
import { accent, hint, warning } from '../format.js';
|
|
17
|
+
import { identity } from '../identity.js';
|
|
18
|
+
import { isJsonMode, printOk } from '../output.js';
|
|
19
|
+
import { applyJsonFlag, ensureConfigured, getGlobalUser, runCommand } from './_shared.js';
|
|
20
|
+
|
|
21
|
+
const collect = (value, previous = []) => [...previous, value];
|
|
22
|
+
|
|
23
|
+
/** Destinations of the command line, each with the shared --entry / --route / --name. */
|
|
24
|
+
function flagDestinations(opts) {
|
|
25
|
+
return (opts.to || []).map((value) => ({
|
|
26
|
+
...parseDestination(value),
|
|
27
|
+
...(opts.entry ? { entry: opts.entry } : {}),
|
|
28
|
+
...(opts.route ? { route: opts.route } : {}),
|
|
29
|
+
...(opts.name ? { name: opts.name } : {}),
|
|
30
|
+
}));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** `<cli> dashboard dev|publish` — url-source dashboard apps. */
|
|
34
|
+
export function registerDashboardCommands(program) {
|
|
35
|
+
const cli = identity().name;
|
|
36
|
+
const dashboard = program
|
|
37
|
+
.command('dashboard')
|
|
38
|
+
.helpGroup('Operations:')
|
|
39
|
+
.description(
|
|
40
|
+
'Dashboard apps (HTML + the dashboard SDK): develop them on their dev server and publish them',
|
|
41
|
+
)
|
|
42
|
+
.addHelpText(
|
|
43
|
+
'after',
|
|
44
|
+
`
|
|
45
|
+
Both commands read defaults from ${manifestName()} in the working folder (or --config):
|
|
46
|
+
{ "dashboard": { "dir": "dist/greenhouse/browser", "storage": "app_greenhouse", "prune": true,
|
|
47
|
+
"to": [{ "type": "device", "id": "gh_01" }],
|
|
48
|
+
"dev": { "url": "http://localhost:4200/", "context": { "device": "gh_01" } } } }
|
|
49
|
+
|
|
50
|
+
In CI, ${identity().envPrefix}_TOKEN and ${identity().envPrefix}_SERVER replace the saved profile (unless --profile or ${identity().envPrefix}_PROFILE names one).`,
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
dashboard
|
|
54
|
+
.command('init <dir>')
|
|
55
|
+
.description(
|
|
56
|
+
'Create a dashboard app (TypeScript, on @thinger-io/dashboard-sdk) from a template of the SDK',
|
|
57
|
+
)
|
|
58
|
+
.option(
|
|
59
|
+
'-t, --template <name>',
|
|
60
|
+
`Template of the SDK package: react, angular… (default ${DEFAULT_TEMPLATE})`,
|
|
61
|
+
)
|
|
62
|
+
.option(
|
|
63
|
+
'--to <destination>',
|
|
64
|
+
'Where it publishes: device:<id>, product:<id>, dashboard:<id>…',
|
|
65
|
+
)
|
|
66
|
+
.option('-s, --storage <id>', 'Storage it publishes to (default app_<folder>_<random>)')
|
|
67
|
+
.option(
|
|
68
|
+
'--from <package>',
|
|
69
|
+
'SDK package to take the template from: a version or tag on npm, or a folder or .tgz',
|
|
70
|
+
)
|
|
71
|
+
.option('--ci', 'Add a GitHub workflow that publishes it on every push to main')
|
|
72
|
+
.option('--install', 'Run npm install')
|
|
73
|
+
.option('-j, --json', 'Output as JSON')
|
|
74
|
+
.action(async (dir, opts) => {
|
|
75
|
+
applyJsonFlag(opts);
|
|
76
|
+
await runCommand({
|
|
77
|
+
start: 'Creating the app...',
|
|
78
|
+
fn: () =>
|
|
79
|
+
initApp({
|
|
80
|
+
dir,
|
|
81
|
+
template: opts.template,
|
|
82
|
+
to: opts.to ? parseDestination(opts.to) : undefined,
|
|
83
|
+
storage: opts.storage,
|
|
84
|
+
from: opts.from,
|
|
85
|
+
ci: !!opts.ci,
|
|
86
|
+
install: !!opts.install,
|
|
87
|
+
}),
|
|
88
|
+
success: (r) =>
|
|
89
|
+
`Created ${r.name} (${r.template}, ${r.sdk ? `SDK ${r.sdk}` : 'SDK'})`,
|
|
90
|
+
failure: 'Failed to create the app',
|
|
91
|
+
onSuccess: (r) => {
|
|
92
|
+
if (isJsonMode()) {
|
|
93
|
+
printOk(r);
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
console.log(
|
|
97
|
+
hint(
|
|
98
|
+
` ${r.dir}: ${r.template} app on ${r.dependency.startsWith('file:') ? r.dependency : `@thinger-io/dashboard-sdk ${r.dependency}`}, ${manifestName()} (publishes ${r.build} to ${r.storage}) and AGENTS.md.`,
|
|
99
|
+
),
|
|
100
|
+
);
|
|
101
|
+
console.log(`\n cd ${dir}`);
|
|
102
|
+
if (!r.installed) console.log(' npm install');
|
|
103
|
+
console.log(` ${r.devCommand}`);
|
|
104
|
+
console.log(
|
|
105
|
+
` ${cli} dashboard dev${Object.keys(r.context).length ? '' : ' --device <id>'} ${hint('# link to the running app against the server')}`,
|
|
106
|
+
);
|
|
107
|
+
console.log(` npm run build && ${cli} dashboard publish`);
|
|
108
|
+
},
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
dashboard
|
|
113
|
+
.command('dev [url]')
|
|
114
|
+
.description(
|
|
115
|
+
'Link that opens the app on its own dev server (ng serve, Vite…) against the server, with a temporary token',
|
|
116
|
+
)
|
|
117
|
+
.option(
|
|
118
|
+
'--to <destination>',
|
|
119
|
+
'Context of a destination: device:<id>, product:<id>, dashboard:<id>…',
|
|
120
|
+
)
|
|
121
|
+
.option('--device <id>', 'Device in the SDK context')
|
|
122
|
+
.option('--product <id>', 'Product in the SDK context')
|
|
123
|
+
.option('--asset-type <id>', 'Asset type in the SDK context')
|
|
124
|
+
.option('--asset-group <id>', 'Asset group in the SDK context')
|
|
125
|
+
.option('--project <id>', 'Project the app runs in')
|
|
126
|
+
.option('--route <route>', 'Initial route, e.g. /devices')
|
|
127
|
+
.option('--hours <hours>', `Hours the token lasts (default ${DEV_HOURS})`, Number)
|
|
128
|
+
.option('--open', 'Open the link in the browser')
|
|
129
|
+
.option(
|
|
130
|
+
'--allow-remote',
|
|
131
|
+
'Allow an app url that is not on this machine (the link carries a full-access token)',
|
|
132
|
+
)
|
|
133
|
+
.option('--config <file>', `Manifest (default ${manifestName()})`)
|
|
134
|
+
.option('-j, --json', 'Output as JSON')
|
|
135
|
+
.addHelpText(
|
|
136
|
+
'after',
|
|
137
|
+
`
|
|
138
|
+
Start the app's dev server first (the command warns when nothing answers at the url).
|
|
139
|
+
The app bundles @thinger-io/dashboard-sdk, which takes the server, a temporary token and the
|
|
140
|
+
context from the link: its dev server needs no proxy. Each run replaces the previous link's
|
|
141
|
+
token (one per project).`,
|
|
142
|
+
)
|
|
143
|
+
.action(async (url, opts, cmd) => {
|
|
144
|
+
applyJsonFlag(opts);
|
|
145
|
+
ensureConfigured();
|
|
146
|
+
const user = getGlobalUser(cmd);
|
|
147
|
+
await runCommand({
|
|
148
|
+
start: 'Creating the dev link...',
|
|
149
|
+
fn: async () => {
|
|
150
|
+
const manifest = readManifest(opts.config);
|
|
151
|
+
const dev = manifest?.dev || {};
|
|
152
|
+
const app = url || dev.url;
|
|
153
|
+
if (!app) {
|
|
154
|
+
throw inputError(
|
|
155
|
+
`pass the app's dev server url (e.g. ${cli} dashboard dev http://localhost:4200/ --device gw1), or set dev.url in ${manifestName()}`,
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
const explicit = {
|
|
159
|
+
device: opts.device,
|
|
160
|
+
product: opts.product,
|
|
161
|
+
type: opts.assetType,
|
|
162
|
+
group: opts.assetGroup,
|
|
163
|
+
project: opts.project,
|
|
164
|
+
};
|
|
165
|
+
const to = opts.to ? parseDestination(opts.to) : manifest?.to?.[0];
|
|
166
|
+
const context = Object.values(explicit).some(Boolean)
|
|
167
|
+
? explicit
|
|
168
|
+
: !opts.to && dev.context
|
|
169
|
+
? dev.context
|
|
170
|
+
: destinationContext(to);
|
|
171
|
+
return devLink({
|
|
172
|
+
url: app,
|
|
173
|
+
context,
|
|
174
|
+
route: opts.route ?? dev.route ?? to?.route,
|
|
175
|
+
hours: opts.hours ?? dev.hours,
|
|
176
|
+
user,
|
|
177
|
+
dir: manifest ? path.dirname(manifest.file) : process.cwd(),
|
|
178
|
+
allowRemote: !!opts.allowRemote,
|
|
179
|
+
});
|
|
180
|
+
},
|
|
181
|
+
success: () => 'Dev link ready',
|
|
182
|
+
failure: 'Failed to create the dev link',
|
|
183
|
+
onSuccess: async (dev) => {
|
|
184
|
+
if (isJsonMode()) printOk(dev);
|
|
185
|
+
else {
|
|
186
|
+
const ctx = Object.entries(dev.context).map(([k, v]) => `${k} ${v}`);
|
|
187
|
+
const until = new Date(dev.expire).toTimeString().slice(0, 5);
|
|
188
|
+
console.log(` ${accent(dev.link)}`);
|
|
189
|
+
console.log(
|
|
190
|
+
hint(
|
|
191
|
+
` ${dev.app} → ${dev.server} as ${dev.user}${ctx.length ? `, ${ctx.join(', ')}` : ''}. Temporary token ${dev.token} until ${until}: writes are real.`,
|
|
192
|
+
),
|
|
193
|
+
);
|
|
194
|
+
for (const note of dev.notes) console.log(warning(` ${note}`));
|
|
195
|
+
}
|
|
196
|
+
if (opts.open) await open(dev.link).catch(() => {});
|
|
197
|
+
},
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
dashboard
|
|
202
|
+
.command('publish [dir]')
|
|
203
|
+
.description('Upload an app to a public storage and point dashboards at it')
|
|
204
|
+
.option('-s, --storage <id>', 'Storage of the app (created public if missing)')
|
|
205
|
+
.option(
|
|
206
|
+
'--to <destination>',
|
|
207
|
+
'Dashboard to point (repeatable): dashboard:<id>, device:<id>, product:<id>, asset_type:<id>, asset_group:<id>, project:<id>',
|
|
208
|
+
collect,
|
|
209
|
+
)
|
|
210
|
+
.option('--entry <page>', 'Page the destinations open (default index.html)')
|
|
211
|
+
.option('--route <route>', 'Initial route of the destinations')
|
|
212
|
+
.option('--name <name>', 'Name of a collection dashboard')
|
|
213
|
+
.option('--prune', 'Delete files of the storage that are not in this upload')
|
|
214
|
+
.option('--no-prune', "Keep the storage's other files (overrides the manifest)")
|
|
215
|
+
.option('--bump', 'Make consoles that have the app open reload it at once')
|
|
216
|
+
.option(
|
|
217
|
+
'--preview [hours]',
|
|
218
|
+
'Also print standalone preview links (read-only, 1 h by default)',
|
|
219
|
+
)
|
|
220
|
+
.option('--no-point', 'Upload without pointing the destinations (preview links only)')
|
|
221
|
+
.option('--config <file>', `Manifest (default ${manifestName()})`)
|
|
222
|
+
.option('-j, --json', 'Output as JSON')
|
|
223
|
+
.action(async (dir, opts, cmd) => {
|
|
224
|
+
applyJsonFlag(opts);
|
|
225
|
+
ensureConfigured();
|
|
226
|
+
const user = getGlobalUser(cmd);
|
|
227
|
+
await runCommand({
|
|
228
|
+
start: 'Publishing...',
|
|
229
|
+
fn: async () => {
|
|
230
|
+
const manifest = readManifest(opts.config);
|
|
231
|
+
const folder = dir || manifest?.dir;
|
|
232
|
+
const to = opts.to ? flagDestinations(opts) : manifest?.to || [];
|
|
233
|
+
const prune = opts.prune ?? manifest?.prune;
|
|
234
|
+
const preview =
|
|
235
|
+
opts.preview === undefined
|
|
236
|
+
? false
|
|
237
|
+
: opts.preview === true
|
|
238
|
+
? true
|
|
239
|
+
: Number(opts.preview);
|
|
240
|
+
return publishApp({
|
|
241
|
+
storage: opts.storage || manifest?.storage,
|
|
242
|
+
files: folder ? collectFiles(folder) : undefined,
|
|
243
|
+
prune: !!prune,
|
|
244
|
+
to,
|
|
245
|
+
preview,
|
|
246
|
+
point: opts.point,
|
|
247
|
+
bump: !!opts.bump,
|
|
248
|
+
user,
|
|
249
|
+
dir: folder,
|
|
250
|
+
});
|
|
251
|
+
},
|
|
252
|
+
success: (r) => `Published ${r.storage}`,
|
|
253
|
+
failure: 'Failed to publish',
|
|
254
|
+
onSuccess: (result) => {
|
|
255
|
+
if (isJsonMode()) printOk(result);
|
|
256
|
+
else console.log(publishSummary(result, { point: opts.point }));
|
|
257
|
+
},
|
|
258
|
+
});
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
return dashboard;
|
|
262
|
+
}
|
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
|
+
}
|