@triscope/cli 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,189 @@
1
+ // `triscope new-gltf <file.glb>` — generate a wired triscope Element from a
2
+ // glTF/GLB asset. Reads the asset's bounds + animation names WITHOUT a renderer
3
+ // or a heavy dep (GLB is a binary container whose first chunk is the glTF JSON),
4
+ // then emits a ready-to-edit Element that GLTFLoads the model, auto-fits cameras
5
+ // to the bounds, and plays the first animation clip.
6
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
7
+ import { basename, dirname, extname, join } from 'node:path';
8
+
9
+ const GLB_MAGIC = 0x46546c67; // 'glTF'
10
+ const JSON_CHUNK = 0x4e4f534a; // 'JSON'
11
+
12
+ /** Extract { animations, bounds, meshCount } from a .glb or .gltf buffer. */
13
+ export function readGltfMeta(buf) {
14
+ let json;
15
+ if (buf.length >= 20 && buf.readUInt32LE(0) === GLB_MAGIC) {
16
+ const chunkLen = buf.readUInt32LE(12);
17
+ const chunkType = buf.readUInt32LE(16);
18
+ if (chunkType !== JSON_CHUNK) throw new Error('first GLB chunk is not JSON');
19
+ json = JSON.parse(buf.subarray(20, 20 + chunkLen).toString('utf8'));
20
+ } else {
21
+ json = JSON.parse(buf.toString('utf8'));
22
+ }
23
+ const animations = (json.animations ?? []).map((a, i) => a.name || `clip${i}`);
24
+ const accessors = json.accessors ?? [];
25
+ const min = [Number.POSITIVE_INFINITY, Number.POSITIVE_INFINITY, Number.POSITIVE_INFINITY];
26
+ const max = [Number.NEGATIVE_INFINITY, Number.NEGATIVE_INFINITY, Number.NEGATIVE_INFINITY];
27
+ let found = false;
28
+ for (const mesh of json.meshes ?? []) {
29
+ for (const prim of mesh.primitives ?? []) {
30
+ const ai = prim.attributes?.POSITION;
31
+ if (ai == null) continue;
32
+ const acc = accessors[ai];
33
+ if (Array.isArray(acc?.min) && Array.isArray(acc?.max) && acc.min.length === 3) {
34
+ found = true;
35
+ for (let k = 0; k < 3; k++) {
36
+ if (acc.min[k] < min[k]) min[k] = acc.min[k];
37
+ if (acc.max[k] > max[k]) max[k] = acc.max[k];
38
+ }
39
+ }
40
+ }
41
+ }
42
+ return { animations, bounds: found ? { min, max } : null, meshCount: (json.meshes ?? []).length };
43
+ }
44
+
45
+ function camerasFor(bounds) {
46
+ // Fitted presets around the asset's bounds (or a unit box fallback).
47
+ const b = bounds ?? { min: [-1, -1, -1], max: [1, 1, 1] };
48
+ const c = [0, 1, 2].map((k) => (b.min[k] + b.max[k]) / 2);
49
+ const size = Math.max(...[0, 1, 2].map((k) => b.max[k] - b.min[k]), 1);
50
+ const d = size * 1.8;
51
+ const t = `[${c[0].toFixed(2)}, ${c[1].toFixed(2)}, ${c[2].toFixed(2)}]`;
52
+ return ` cameras: {
53
+ front: { position: [${c[0].toFixed(2)}, ${c[1].toFixed(2)}, ${(c[2] + d).toFixed(2)}], target: ${t}, fit: true },
54
+ side: { position: [${(c[0] + d).toFixed(2)}, ${c[1].toFixed(2)}, ${c[2].toFixed(2)}], target: ${t}, fit: true },
55
+ top: { position: [${c[0].toFixed(2)}, ${(c[1] + d).toFixed(2)}, ${c[2].toFixed(2)}], target: ${t}, fit: true },
56
+ 'three-quarter': { position: [${(c[0] + d * 0.7).toFixed(2)}, ${(c[1] + d * 0.5).toFixed(2)}, ${(c[2] + d * 0.7).toFixed(2)}], target: ${t}, fit: true },
57
+ },`;
58
+ }
59
+
60
+ export function gltfElementSource({ name, assetPath, meta }) {
61
+ const bounds = meta.bounds
62
+ ? ` bounds: { min: [${meta.bounds.min.join(', ')}], max: [${meta.bounds.max.join(', ')}] },`
63
+ : ' // bounds: set me — the asset declared no POSITION accessor min/max';
64
+ const hasAnim = meta.animations.length > 0;
65
+ return `// Generated by \`triscope new-gltf\`. Edit freely.
66
+ import type { Element } from '@triscope/core';
67
+ import * as THREE from 'three/webgpu';
68
+ import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
69
+
70
+ // Served from /public (vite serves it at the site root).
71
+ const ASSET_URL = ${JSON.stringify(assetPath)};
72
+
73
+ interface ${cap(name)}Data {
74
+ root: THREE.Group;
75
+ mixer: THREE.AnimationMixer | null;
76
+ scale: number;
77
+ clips: THREE.AnimationClip[];
78
+ }
79
+
80
+ export const ${name}: Element = {
81
+ name: '${name}',
82
+ ${bounds}
83
+
84
+ mount: ({ parent }) => {
85
+ const root = new THREE.Group();
86
+ parent.add(root);
87
+ const u: ${cap(name)}Data = { root, mixer: null, scale: 1, clips: [] };
88
+
89
+ new GLTFLoader().load(
90
+ ASSET_URL,
91
+ (gltf) => {
92
+ root.add(gltf.scene);
93
+ u.clips = gltf.animations ?? [];
94
+ ${hasAnim ? `if (u.clips.length) { u.mixer = new THREE.AnimationMixer(gltf.scene); u.mixer.clipAction(u.clips[0]).play(); }` : '// no animations in this asset'}
95
+ },
96
+ undefined,
97
+ (err) => console.error('GLTF load failed:', err),
98
+ );
99
+
100
+ // Advance the animation mixer each frame (CPU-integrated → use capture_motion mode:'real').
101
+ let last = performance.now();
102
+ const tick = () => {
103
+ const now = performance.now();
104
+ if (u.mixer) u.mixer.update((now - last) / 1000);
105
+ last = now;
106
+ requestAnimationFrame(tick);
107
+ };
108
+ requestAnimationFrame(tick);
109
+
110
+ return {
111
+ root,
112
+ userData: u as unknown as Record<string, unknown>,
113
+ dispose: () => {
114
+ parent.remove(root);
115
+ root.traverse((o) => {
116
+ const m = o as THREE.Mesh;
117
+ m.geometry?.dispose?.();
118
+ const mat = m.material as THREE.Material | THREE.Material[] | undefined;
119
+ if (Array.isArray(mat)) mat.forEach((x) => x.dispose?.());
120
+ else mat?.dispose?.();
121
+ });
122
+ },
123
+ };
124
+ },
125
+
126
+ ${camerasFor(meta.bounds)}
127
+
128
+ knobs: {
129
+ scale: { type: 'number', min: 0.1, max: 5, step: 0.1, default: 1, label: 'scale' },${
130
+ hasAnim
131
+ ? `\n // clips: ${JSON.stringify(meta.animations)} — wire a picker if you want`
132
+ : ''
133
+ }
134
+ },
135
+
136
+ onKnob: (handle, key, value) => {
137
+ const u = handle.userData as unknown as ${cap(name)}Data;
138
+ if (key === 'scale') u.root.scale.setScalar(Number(value));
139
+ },
140
+
141
+ telemetry: (handle) => {
142
+ const u = handle.userData as unknown as ${cap(name)}Data;
143
+ return { scale: u.scale, hasModel: u.root.children.length > 0, clips: u.clips.length };
144
+ },
145
+ };
146
+ `;
147
+ }
148
+
149
+ function cap(s) {
150
+ return s.replace(/[^A-Za-z0-9]/g, '_').replace(/^./, (c) => c.toUpperCase());
151
+ }
152
+
153
+ export async function runGltfScaffold({ file, name: nameOverride, cwd = process.cwd() }) {
154
+ if (!file) {
155
+ console.error('Usage: triscope new-gltf <file.glb> [--name <n>]');
156
+ process.exit(2);
157
+ }
158
+ if (!existsSync(file)) {
159
+ console.error(`asset not found: ${file}`);
160
+ process.exit(2);
161
+ }
162
+ const meta = readGltfMeta(readFileSync(file));
163
+ // Whitelist the extension — never let a crafted filename's "extension" flow
164
+ // into the copied filename or the generated source.
165
+ const rawExt = extname(file).toLowerCase();
166
+ const ext = rawExt === '.gltf' ? '.gltf' : '.glb';
167
+ const name = (nameOverride ?? basename(file, extname(file))).replace(/[^A-Za-z0-9_-]/g, '-');
168
+
169
+ // Copy the asset into /public so vite serves it at /<name><ext>.
170
+ const publicDir = join(cwd, 'public');
171
+ mkdirSync(publicDir, { recursive: true });
172
+ const assetFile = `${name}${ext}`;
173
+ copyFileSync(file, join(publicDir, assetFile));
174
+ const assetPath = `/${assetFile}`;
175
+
176
+ const elPath = join(cwd, 'src', 'elements', `${name}.ts`);
177
+ mkdirSync(dirname(elPath), { recursive: true });
178
+ writeFileSync(elPath, gltfElementSource({ name, assetPath, meta }));
179
+
180
+ console.log(`generated element "${name}" from ${file}`);
181
+ console.log(` + ${elPath}`);
182
+ console.log(` + ${join(publicDir, assetFile)}`);
183
+ console.log(` bounds: ${meta.bounds ? JSON.stringify(meta.bounds) : '(none — set manually)'}`);
184
+ console.log(` animations: ${meta.animations.length ? meta.animations.join(', ') : '(none)'}`);
185
+ console.log(
186
+ ` next: triscope import is for packages; add a lab — copy labs/cube.html → labs/${name}.html`,
187
+ );
188
+ return { name, elPath, meta };
189
+ }
@@ -0,0 +1,193 @@
1
+ // `triscope import <pkg|github>` — install a published element package and wire
2
+ // it into the current project (lab page + vite input + package.json#triscope.labs).
3
+ // The naming convention is `triscope-element-<name>`; the package default-exports
4
+ // an Element. The pure helpers are unit-tested; runImport spawns `npm install`.
5
+ import { spawnSync } from 'node:child_process';
6
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
7
+ import { dirname, join, resolve } from 'node:path';
8
+
9
+ const GH_SHORTHAND = /^[\w-]+\/[\w.-]+(#[\w./-]+)?$/;
10
+ // Strict allowlists for what we'll hand to `npm install`. No leading dash (flag
11
+ // smuggling), no shell metacharacters, no glob `*`.
12
+ const NPM_SPEC = /^(@[\w.-]+\/)?[\w.-]+(@[\w.^~-]+)?$/;
13
+ const GH_SPEC = /^github:[\w.-]+\/[\w.-]+(#[\w./-]+)?$/;
14
+
15
+ /** Reject anything that isn't a plain npm/github install spec (no flags, no shell chars). */
16
+ export function assertSafeInstallSpec(spec) {
17
+ if (
18
+ typeof spec !== 'string' ||
19
+ spec.startsWith('-') ||
20
+ !(NPM_SPEC.test(spec) || GH_SPEC.test(spec))
21
+ ) {
22
+ throw new Error(
23
+ `refusing unsafe install spec ${JSON.stringify(spec)} — expected a plain package name, name@version, or github:user/repo[#ref]`,
24
+ );
25
+ }
26
+ }
27
+
28
+ export function parseElementSource(source, nameOverride) {
29
+ let kind = 'npm';
30
+ let installSpec = source;
31
+ let pkgName = source;
32
+ if (source.startsWith('github:') || GH_SHORTHAND.test(source)) {
33
+ kind = 'github';
34
+ installSpec = source.startsWith('github:') ? source : `github:${source}`;
35
+ pkgName = installSpec.replace(/^github:/, '').split('#')[0];
36
+ } else {
37
+ // strip a trailing version range (pkg@1.2.3) but keep an @scope
38
+ pkgName = source.replace(/(?<!^)@[\^~]?\d[\w.-]*$/, '');
39
+ }
40
+ const base = pkgName.split('/').pop();
41
+ const name = nameOverride ?? base.replace(/^triscope-element-/, '');
42
+ return { kind, installSpec, pkgName, name };
43
+ }
44
+
45
+ export function labEntry(name, pkgName) {
46
+ return `import { runLab } from '@triscope/core';
47
+ import element from ${JSON.stringify(pkgName)};
48
+
49
+ const canvas = document.getElementById('canvas');
50
+ runLab({
51
+ element,
52
+ canvas,
53
+ hud: document.getElementById('hud'),
54
+ labelContainer: document.getElementById('app'),
55
+ editorContainer: document.getElementById('lab-controls'),
56
+ bootOverlay: document.getElementById('boot'),
57
+ }).catch((e) => {
58
+ const b = document.getElementById('boot');
59
+ if (b) b.textContent = 'Init failed: ' + (e?.message ?? e);
60
+ console.error(e);
61
+ });
62
+ `;
63
+ }
64
+
65
+ export function labHtml(name) {
66
+ return `<!doctype html>
67
+ <html lang="en">
68
+ <head>
69
+ <meta charset="UTF-8" />
70
+ <title>${name} lab</title>
71
+ <style>
72
+ html, body { margin: 0; padding: 0; overflow: hidden; background: #000; color: #cfd6db; font-family: ui-monospace, monospace; }
73
+ #app { position: fixed; inset: 0; }
74
+ canvas { display: block; width: 100%; height: 100%; }
75
+ .triscope-label { position: absolute; background: rgba(0,0,0,.55); color: #cfd6db; padding: 4px 8px; font-size: 11px; pointer-events: none; border-radius: 3px; z-index: 5; }
76
+ #hud { position: fixed; bottom: 8px; left: 8px; z-index: 10; font-size: 11px; padding: 4px 8px; background: rgba(0,0,0,.55); border-radius: 3px; }
77
+ #boot { position: fixed; inset: 0; display: flex; align-items: center; justify-content: center; background: #0a1a20; z-index: 50; }
78
+ #lab-controls { position: fixed; right: 8px; bottom: 8px; z-index: 20; width: min(320px, calc(100vw - 24px)); padding: 10px 12px; background: rgba(5,12,16,.78); border: 1px solid rgba(210,230,240,.16); border-radius: 6px; box-sizing: border-box; }
79
+ .triscope-editor__row { display: grid; grid-template-columns: 110px 1fr 56px; align-items: center; gap: 6px; font-size: 11px; margin: 4px 0; }
80
+ .triscope-editor__row input { width: 100%; accent-color: #8fc7d9; }
81
+ .triscope-editor__row output { text-align: right; color: #f0f5f7; }
82
+ </style>
83
+ </head>
84
+ <body>
85
+ <div id="boot">Initialising Triscope · WebGPU...</div>
86
+ <div id="app"><canvas id="canvas"></canvas></div>
87
+ <div id="hud">- fps · WebGPU</div>
88
+ <div id="lab-controls"></div>
89
+ <script type="module" src="/src/labs/${name}.ts"></script>
90
+ </body>
91
+ </html>
92
+ `;
93
+ }
94
+
95
+ export function injectLabInput(viteText, name) {
96
+ if (viteText.includes(`labs/${name}.html`)) return viteText;
97
+ const line = ` ${name}: 'labs/${name}.html',`;
98
+ return viteText.replace(/(\n[ \t]*index: 'index\.html',)/, `$1\n${line}`);
99
+ }
100
+
101
+ /**
102
+ * Idempotently add `triscopeTelemetryPlugin()` to an existing vite.config's
103
+ * `plugins: [...]` (the one piece `triscope adopt` can't get for free). Returns
104
+ * { text, injected, already?, manual? }: if there's no `plugins:` array we don't
105
+ * touch the file and hand back a `manual` patch string instead of guessing.
106
+ */
107
+ export function injectTelemetryPlugin(viteText) {
108
+ if (/triscopeTelemetryPlugin/.test(viteText)) {
109
+ return { text: viteText, injected: false, already: true };
110
+ }
111
+ const importLine = "import { triscopeTelemetryPlugin } from '@triscope/core/vite';\n";
112
+ const manual = {
113
+ text: viteText,
114
+ injected: false,
115
+ manual: `${importLine}…and add triscopeTelemetryPlugin() to your Vite \`plugins: []\` array.`,
116
+ };
117
+ // Find the FIRST `plugins: [` on a non-comment line (so we don't inject into a
118
+ // commented-out array, which would be a silent no-op). If none, hand back a
119
+ // manual patch instead of guessing/corrupting.
120
+ const m = /^(?![ \t]*(?:\/\/|\*)).*?\bplugins\s*:\s*\[/m.exec(viteText);
121
+ if (!m) return manual;
122
+ const at = m.index + m[0].length; // just past the opening `[`
123
+ const withPlugin = `${viteText.slice(0, at)}triscopeTelemetryPlugin(), ${viteText.slice(at)}`;
124
+ // Add the import after the first existing import (or prepend).
125
+ const text = /^[ \t]*import\s/m.test(withPlugin)
126
+ ? withPlugin.replace(/^([ \t]*import\s.*\n)/m, `$1${importLine}`)
127
+ : importLine + withPlugin;
128
+ return { text, injected: true };
129
+ }
130
+
131
+ export function wireProjectLabs(cwd, name) {
132
+ const p = join(cwd, 'package.json');
133
+ if (!existsSync(p)) return;
134
+ const pkg = JSON.parse(readFileSync(p, 'utf8'));
135
+ pkg.triscope ??= {};
136
+ pkg.triscope.labs ??= {};
137
+ pkg.triscope.labs[name] = `/labs/${name}.html`;
138
+ writeFileSync(p, `${JSON.stringify(pkg, null, 2)}\n`);
139
+ }
140
+
141
+ export async function runImport({
142
+ source,
143
+ name: nameOverride,
144
+ install = true,
145
+ cwd = process.cwd(),
146
+ }) {
147
+ if (!source) {
148
+ console.error('Usage: triscope import <pkg|github:user/repo> [--name <n>] [--no-install]');
149
+ process.exit(2);
150
+ }
151
+ const { installSpec, pkgName, name } = parseElementSource(source, nameOverride);
152
+ // Always validate — installSpec/pkgName also flow into the generated lab
153
+ // import, so a malicious value must be rejected even with --no-install.
154
+ assertSafeInstallSpec(installSpec);
155
+
156
+ if (install) {
157
+ // Harden against argv flag smuggling / shell injection: pass `--` so npm
158
+ // can't read it as a flag, and spawn npm WITHOUT a shell (explicit npm.cmd
159
+ // on Windows) so there's no shell to inject into.
160
+ console.log(`installing ${installSpec}…`);
161
+ const npmBin = process.platform === 'win32' ? 'npm.cmd' : 'npm';
162
+ const r = spawnSync(npmBin, ['install', '--', installSpec], { cwd, stdio: 'inherit' });
163
+ if (r.status !== 0) {
164
+ console.error(`npm install ${installSpec} failed (${r.status}).`);
165
+ process.exit(1);
166
+ }
167
+ }
168
+
169
+ const created = [];
170
+ const htmlPath = join(cwd, 'labs', `${name}.html`);
171
+ mkdirSync(dirname(htmlPath), { recursive: true });
172
+ writeFileSync(htmlPath, labHtml(name));
173
+ created.push(htmlPath);
174
+
175
+ const entryPath = join(cwd, 'src', 'labs', `${name}.ts`);
176
+ mkdirSync(dirname(entryPath), { recursive: true });
177
+ writeFileSync(entryPath, labEntry(name, pkgName));
178
+ created.push(entryPath);
179
+
180
+ const vitePath = ['vite.config.js', 'vite.config.ts', 'vite.config.mjs']
181
+ .map((f) => join(cwd, f))
182
+ .find((f) => existsSync(f));
183
+ if (vitePath) {
184
+ writeFileSync(vitePath, injectLabInput(readFileSync(vitePath, 'utf8'), name));
185
+ created.push(`${vitePath} (lab input)`);
186
+ }
187
+ wireProjectLabs(cwd, name);
188
+
189
+ console.log(`imported "${name}" from ${pkgName}.`);
190
+ for (const c of created) console.log(` + ${c}`);
191
+ console.log(` open /labs/${name}.html (or: triscope smoke ${name})`);
192
+ return { name, pkgName, created };
193
+ }
package/src/init.mjs ADDED
@@ -0,0 +1,78 @@
1
+ // `triscope init <dir>` — thin wrapper around create-triscope so users
2
+ // don't have to remember `npm init triscope` vs `npx create-triscope`.
3
+ import { spawn } from 'node:child_process';
4
+ import { existsSync, readdirSync, statSync } from 'node:fs';
5
+ import { dirname, join, resolve } from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
7
+ import { resolveQuickMode, runQuickSetup } from './wizard.mjs';
8
+
9
+ const HERE = dirname(fileURLToPath(import.meta.url));
10
+ // In a hoisted monorepo install, create-triscope sits next to cli inside
11
+ // the same node_modules root. In a published install, it lives at
12
+ // `<consumer>/node_modules/create-triscope`. We try both.
13
+ export function locateScaffolderBin() {
14
+ const candidates = [
15
+ // monorepo / workspace layout (cli/src/init.mjs → ../../create-triscope)
16
+ resolve(HERE, '../../create-triscope/bin/create.mjs'),
17
+ // hoisted into the consumer's node_modules
18
+ resolve(process.cwd(), 'node_modules/create-triscope/bin/create.mjs'),
19
+ ];
20
+ for (const p of candidates) {
21
+ if (existsSync(p)) return p;
22
+ }
23
+ return null;
24
+ }
25
+
26
+ export async function runInit({ dir, install, quick, yes, preset }) {
27
+ if (!dir) {
28
+ console.error('Usage: triscope init <project-dir> [--install]');
29
+ process.exit(2);
30
+ }
31
+ const target = resolve(process.cwd(), dir);
32
+ // create-triscope itself rejects non-empty dirs; we warn early so the
33
+ // user sees the failure before we spawn the scaffolder. Only the
34
+ // readdirSync call can plausibly fail (permission errors) — handle
35
+ // *that* narrowly instead of wrapping process.exit, which earlier
36
+ // wrapping was silently swallowing.
37
+ if (existsSync(target) && statSync(target).isDirectory()) {
38
+ let entries;
39
+ try {
40
+ entries = readdirSync(target);
41
+ } catch {
42
+ entries = null;
43
+ }
44
+ if (entries && entries.length > 0) {
45
+ console.error(`refusing: ${target} exists and is not empty`);
46
+ process.exit(2);
47
+ }
48
+ }
49
+
50
+ const presetArgs = preset ? ['--preset', preset] : [];
51
+ const bin = locateScaffolderBin();
52
+ if (bin) {
53
+ await spawnAndWait(process.execPath, [bin, dir, ...presetArgs]);
54
+ } else {
55
+ // Fall back to `npm init triscope` so users without the workspace can
56
+ // still bootstrap (npm will fetch create-triscope from the registry).
57
+ await spawnAndWait('npm', ['init', 'triscope', dir, ...presetArgs]);
58
+ }
59
+
60
+ // --quick runs the post-scaffold setup wizard (interactive on a TTY, safe
61
+ // non-interactive defaults with --yes, and a never-hang "skip" otherwise).
62
+ const mode = resolveQuickMode({ quick: !!quick, yes: !!yes, isTTY: !!process.stdin.isTTY });
63
+ if (mode !== 'off') {
64
+ await runQuickSetup({ target, mode });
65
+ } else if (install) {
66
+ console.log('');
67
+ console.log(`running \`npm install\` in ${dir}…`);
68
+ await spawnAndWait('npm', ['install'], { cwd: target });
69
+ }
70
+ }
71
+
72
+ function spawnAndWait(cmd, args, opts = {}) {
73
+ return new Promise((res, rej) => {
74
+ const p = spawn(cmd, args, { stdio: 'inherit', ...opts });
75
+ p.on('exit', (code) => (code === 0 ? res() : rej(new Error(`${cmd} exited ${code}`))));
76
+ p.on('error', rej);
77
+ });
78
+ }
package/src/list.mjs ADDED
@@ -0,0 +1,21 @@
1
+ // `triscope list` — print the live element manifest from the running dev server.
2
+ export async function runList({ url = 'http://localhost:5173' } = {}) {
3
+ const endpoint = `${url.replace(/\/$/, '')}/__manifest`;
4
+ let res;
5
+ try {
6
+ res = await fetch(endpoint);
7
+ } catch (err) {
8
+ console.error(`Could not reach ${endpoint}. Is \`triscope dev\` running?`);
9
+ process.exit(1);
10
+ }
11
+ if (!res.ok) {
12
+ console.error(`${endpoint} returned ${res.status}`);
13
+ process.exit(1);
14
+ }
15
+ const manifest = await res.json();
16
+ if (manifest == null) {
17
+ console.error('Dev server is up but no manifest has been posted yet. Load a lab page first.');
18
+ process.exit(2);
19
+ }
20
+ console.log(JSON.stringify(manifest, null, 2));
21
+ }