@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.
@@ -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
+ }