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