@hiyve/cli 1.0.18 → 1.0.20

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hiyve/cli",
3
- "version": "1.0.18",
3
+ "version": "1.0.20",
4
4
  "description": "Hiyve SDK CLI - Configure npm for private @hiyve packages",
5
5
  "type": "module",
6
6
  "bin": {
@@ -14,7 +14,10 @@ const TEMPLATES = {
14
14
  basic: {
15
15
  name: 'Basic',
16
16
  description: 'Video conferencing with controls and participant list',
17
- packages: ['@hiyve/react', '@hiyve/react-ui', '@hiyve/core', '@hiyve/rtc-client', '@hiyve/utilities'],
17
+ packages: [
18
+ '@hiyve/react', '@hiyve/react-ui', '@hiyve/core', '@hiyve/rtc-client', '@hiyve/utilities',
19
+ '@hiyve/admin',
20
+ ],
18
21
  features: { intelligence: false, collaboration: false, capture: false },
19
22
  },
20
23
  ai: {
@@ -23,21 +26,65 @@ const TEMPLATES = {
23
26
  packages: [
24
27
  '@hiyve/react', '@hiyve/react-ui', '@hiyve/react-intelligence', '@hiyve/react-capture',
25
28
  '@hiyve/core', '@hiyve/cloud', '@hiyve/rtc-client', '@hiyve/utilities',
29
+ '@hiyve/admin',
26
30
  ],
27
31
  features: { intelligence: true, collaboration: false, capture: true },
28
32
  },
29
33
  full: {
30
34
  name: 'Full Suite',
31
35
  description: 'Video + AI + chat, polls, Q&A, file sharing, notes',
36
+ // react-room's REQUIRED peers are all here: react-intelligence and
37
+ // react-semantic-relay are imported statically, not optionally.
32
38
  packages: [
33
- '@hiyve/react', '@hiyve/react-ui', '@hiyve/react-intelligence', '@hiyve/react-capture',
34
- '@hiyve/react-collaboration', '@hiyve/react-notes', '@hiyve/react-room',
39
+ '@hiyve/react', '@hiyve/react-ui', '@hiyve/react-intelligence', '@hiyve/react-semantic-relay',
40
+ '@hiyve/react-capture', '@hiyve/react-collaboration', '@hiyve/react-notes', '@hiyve/react-room',
35
41
  '@hiyve/core', '@hiyve/cloud', '@hiyve/rtc-client', '@hiyve/utilities',
42
+ '@hiyve/admin',
36
43
  ],
37
44
  features: { intelligence: true, collaboration: true, capture: true },
38
45
  },
39
46
  };
40
47
 
48
+ /**
49
+ * The signaling region every generated project pins. It appears in BOTH
50
+ * `.env.example` (SERVER_REGION — what the token server mints for) and the
51
+ * client (`region` on HiyveProvider / HiyveRoom). They must agree: a token
52
+ * presented to a stack that did not sign it is rejected with a generic
53
+ * "Internal server error".
54
+ */
55
+ export const DEFAULT_REGION = 'us-east-2';
56
+
57
+ /** Port the generated token server listens on; vite proxies /api to it. */
58
+ const SERVER_PORT = 3001;
59
+
60
+ /**
61
+ * Pinned versions. Taken from what `npm create vite@latest -- --template
62
+ * react-ts` emits (2026-09-01) plus the peer ranges the published @hiyve
63
+ * packages declare: React 18 or 19, MUI ^9. Bumping MUI below 9 or React
64
+ * below 18 makes `npm install` fail with ERESOLVE against the registry.
65
+ */
66
+ export const PINS = {
67
+ react: '^19.2.8',
68
+ 'react-dom': '^19.2.8',
69
+ '@mui/material': '^9.4.0',
70
+ '@mui/icons-material': '^9.4.0',
71
+ '@emotion/react': '^11.14.0',
72
+ '@emotion/styled': '^11.14.1',
73
+ express: '^5.2.1',
74
+ cors: '^2.8.6',
75
+ dotenv: '^17.4.2',
76
+ '@types/react': '^19.2.18',
77
+ '@types/react-dom': '^19.2.4',
78
+ '@types/express': '^5.0.6',
79
+ '@types/cors': '^2.8.19',
80
+ '@types/node': '^24.13.3',
81
+ '@vitejs/plugin-react': '^6.1.0',
82
+ concurrently: '^10.0.5',
83
+ tsx: '^4.23.13',
84
+ typescript: '~6.0.2',
85
+ vite: '^8.2.2',
86
+ };
87
+
41
88
  export async function init(projectName, options) {
42
89
  console.log('');
43
90
  console.log(chalk.cyan(' Hiyve Project Scaffolding'));
@@ -102,8 +149,11 @@ export async function init(projectName, options) {
102
149
  writeFile(projectDir, 'vite.config.ts', generateViteConfig());
103
150
  writeFile(projectDir, '.env.example', generateEnvExample(template));
104
151
  writeFile(projectDir, '.gitignore', generateGitignore());
105
- writeFile(projectDir, 'src/main.tsx', generateMain());
152
+ writeFile(projectDir, 'src/main.tsx', generateMain(template));
106
153
  writeFile(projectDir, 'src/App.tsx', generateApp(template));
154
+ if (!usesPrebuiltRoom(template)) {
155
+ writeFile(projectDir, 'src/VideoRoom.tsx', generateVideoRoom());
156
+ }
107
157
  writeFile(projectDir, 'index.html', generateIndexHtml(projectName));
108
158
  writeFile(projectDir, 'server/index.ts', generateServer(template));
109
159
 
@@ -113,7 +163,7 @@ export async function init(projectName, options) {
113
163
  console.log(chalk.gray(' Next steps:'));
114
164
  console.log('');
115
165
  console.log(` ${chalk.cyan('cd')} ${projectName}`);
116
- console.log(` ${chalk.cyan('cp')} .env.example .env ${chalk.gray('# Add your API key')}`);
166
+ console.log(` ${chalk.cyan('cp')} .env.example .env ${chalk.gray('# Add your API key + client secret (console.hiyve.dev)')}`);
117
167
  console.log(` ${chalk.cyan('npm install')}`);
118
168
  console.log(` ${chalk.cyan('npm run dev')}`);
119
169
  console.log('');
@@ -136,17 +186,23 @@ function writeFile(dir, filePath, content) {
136
186
  writeFileSync(fullPath, content, 'utf-8');
137
187
  }
138
188
 
139
- function generatePackageJson(name, template) {
189
+ function usesPrebuiltRoom(template) {
190
+ return template.features.intelligence && template.features.collaboration;
191
+ }
192
+
193
+ export function generatePackageJson(name, template) {
140
194
  const deps = {};
141
195
  for (const pkg of template.packages) {
142
196
  deps[pkg] = 'latest';
143
197
  }
144
- deps['react'] = '^18.2.0';
145
- deps['react-dom'] = '^18.2.0';
146
- deps['@mui/material'] = '^5.15.0';
147
- deps['@mui/icons-material'] = '^5.15.0';
148
- deps['@emotion/react'] = '^11.11.0';
149
- deps['@emotion/styled'] = '^11.11.0';
198
+ for (const k of ['react', 'react-dom', '@mui/material', '@mui/icons-material', '@emotion/react', '@emotion/styled', 'express', 'cors', 'dotenv']) {
199
+ deps[k] = PINS[k];
200
+ }
201
+
202
+ const devDependencies = {};
203
+ for (const k of ['@types/react', '@types/react-dom', '@types/express', '@types/cors', '@types/node', '@vitejs/plugin-react', 'concurrently', 'tsx', 'typescript', 'vite']) {
204
+ devDependencies[k] = PINS[k];
205
+ }
150
206
 
151
207
  const pkg = {
152
208
  name,
@@ -154,39 +210,32 @@ function generatePackageJson(name, template) {
154
210
  private: true,
155
211
  type: 'module',
156
212
  scripts: {
157
- dev: 'concurrently "vite" "tsx watch server/index.ts"',
213
+ dev: 'concurrently "tsx watch server/index.ts" "vite"',
158
214
  build: 'vite build',
159
215
  preview: 'vite preview',
160
- 'server': 'tsx server/index.ts',
216
+ server: 'tsx server/index.ts',
161
217
  },
162
218
  dependencies: deps,
163
- devDependencies: {
164
- '@types/react': '^18.2.0',
165
- '@types/react-dom': '^18.2.0',
166
- '@vitejs/plugin-react': '^4.2.0',
167
- 'concurrently': '^8.2.0',
168
- 'dotenv': '^16.3.0',
169
- 'express': '^4.18.0',
170
- 'tsx': '^4.7.0',
171
- 'typescript': '^5.3.0',
172
- 'vite': '^5.0.0',
173
- },
219
+ devDependencies,
174
220
  };
175
221
 
176
222
  return JSON.stringify(pkg, null, 2) + '\n';
177
223
  }
178
224
 
179
- function generateTsConfig() {
225
+ export function generateTsConfig() {
226
+ // Mirrors the react-ts template `npm create vite@latest` emits, with one
227
+ // tsconfig covering both src/ (browser) and server/ (tsx).
180
228
  const config = {
181
229
  compilerOptions: {
182
- target: 'ES2020',
183
- useDefineForClassFields: true,
184
- lib: ['ES2020', 'DOM', 'DOM.Iterable'],
230
+ target: 'ES2023',
231
+ lib: ['ES2023', 'DOM', 'DOM.Iterable'],
185
232
  module: 'ESNext',
186
- skipLibCheck: true,
187
233
  moduleResolution: 'bundler',
234
+ types: ['vite/client', 'node'],
235
+ skipLibCheck: true,
188
236
  allowImportingTsExtensions: true,
189
- resolveJsonModule: true,
237
+ verbatimModuleSyntax: true,
238
+ moduleDetection: 'force',
190
239
  isolatedModules: true,
191
240
  noEmit: true,
192
241
  jsx: 'react-jsx',
@@ -195,46 +244,57 @@ function generateTsConfig() {
195
244
  noUnusedParameters: true,
196
245
  noFallthroughCasesInSwitch: true,
197
246
  },
198
- include: ['src'],
247
+ include: ['src', 'server', 'vite.config.ts'],
199
248
  };
200
249
  return JSON.stringify(config, null, 2) + '\n';
201
250
  }
202
251
 
203
- function generateViteConfig() {
252
+ export function generateViteConfig() {
204
253
  return `import { defineConfig } from 'vite';
205
254
  import react from '@vitejs/plugin-react';
206
255
 
207
256
  export default defineConfig({
208
257
  plugins: [react()],
209
258
  server: {
210
- port: 3000,
211
259
  proxy: {
212
- '/api': 'http://localhost:4000',
260
+ // The token server (server/index.ts). @hiyve/react fetches
261
+ // /api/generate-room-token and /api/generate-cloud-token from here.
262
+ '/api': 'http://localhost:${SERVER_PORT}',
213
263
  },
214
264
  },
215
265
  });
216
266
  `;
217
267
  }
218
268
 
219
- function generateEnvExample(template) {
220
- let env = `# Hiyve API Key (from cloud.hiyve.io)
221
- HIYVE_API_KEY=pk_live_your_api_key_here
269
+ export function generateEnvExample(template) {
270
+ let env = `# Both keys come from https://console.hiyve.dev (API Keys)
271
+
272
+ # API Key (required — identifies your app)
273
+ APIKEY=pk_live_your_api_key_here
274
+
275
+ # Client Secret (required for video conferencing — mints room tokens; server-side only)
276
+ CLIENT_SECRET=sk_live_your_client_secret_here
222
277
 
223
- # Room configuration
224
- HIYVE_SIGNALING_URL=https://signal.hiyve.dev
278
+ # Signaling region the token server mints for. MUST match the \`region\`
279
+ # passed to HiyveProvider / HiyveRoom in src/ (${DEFAULT_REGION} in both).
280
+ SERVER_REGION=${DEFAULT_REGION}
281
+ SERVER_REGION_URL=.rtc.muziemedia.com
282
+ # production and development resolve to the SAME cloud (there is currently no
283
+ # separate development stack); development only adds a boot warning. Leave as-is.
284
+ ENVIRONMENT=production
225
285
  `;
226
286
 
227
287
  if (template.features.intelligence) {
228
288
  env += `
229
- # Cloud API (for AI features)
230
- HIYVE_CLOUD_URL=https://cloud.hiyve.io
289
+ # Cloud API for AI features. Defaults to https://cloud.hiyve.io — leave unset.
290
+ # HIYVE_CLOUD_API_URL=https://cloud.hiyve.io
231
291
  `;
232
292
  }
233
293
 
234
294
  return env;
235
295
  }
236
296
 
237
- function generateGitignore() {
297
+ export function generateGitignore() {
238
298
  return `node_modules
239
299
  dist
240
300
  .env
@@ -242,53 +302,70 @@ dist
242
302
  `;
243
303
  }
244
304
 
245
- function generateMain() {
246
- return `import React from 'react';
247
- import ReactDOM from 'react-dom/client';
305
+ export function generateMain(template) {
306
+ if (usesPrebuiltRoom(template)) {
307
+ // HiyveRoom (in App.tsx) provides the HiyveProvider, theme and region.
308
+ return `import ReactDOM from 'react-dom/client';
248
309
  import App from './App';
249
310
 
311
+ ReactDOM.createRoot(document.getElementById('root')!).render(<App />);
312
+ `;
313
+ }
314
+
315
+ return `import ReactDOM from 'react-dom/client';
316
+ import { HiyveProvider } from '@hiyve/react';
317
+ import App from './App';
318
+
319
+ // region MUST match SERVER_REGION in .env — see .env.example.
250
320
  ReactDOM.createRoot(document.getElementById('root')!).render(
251
- <React.StrictMode>
321
+ <HiyveProvider region="${DEFAULT_REGION}">
252
322
  <App />
253
- </React.StrictMode>,
323
+ </HiyveProvider>,
254
324
  );
255
325
  `;
256
326
  }
257
327
 
258
- function generateApp(template) {
259
- if (template.features.intelligence && template.features.collaboration) {
260
- // Full template use @hiyve/react-room PrebuiltRoom
261
- return `import React, { useState, useCallback } from 'react';
328
+ export function generateApp(template) {
329
+ if (usesPrebuiltRoom(template)) {
330
+ return `import { useState, useCallback } from 'react';
262
331
  import { HiyveRoom, PrebuiltRoom, PrebuiltLobby } from '@hiyve/react-room';
263
332
 
333
+ // Must match SERVER_REGION in .env — see .env.example.
334
+ const REGION = '${DEFAULT_REGION}';
335
+
336
+ /** Fetch a room token from server/index.ts (mountHiyveRoutes). */
337
+ async function fetchRoomToken(): Promise<string> {
338
+ const res = await fetch('/api/generate-room-token', { method: 'POST' });
339
+ const data = await res.json().catch(() => ({}));
340
+ if (!res.ok) throw new Error(data.message || 'Failed to generate room token');
341
+ return data.roomToken;
342
+ }
343
+
344
+ /** Fetch a cloud token for AI features from the same server. */
345
+ async function fetchCloudToken(): Promise<string> {
346
+ const res = await fetch('/api/generate-cloud-token', { method: 'POST' });
347
+ const data = await res.json().catch(() => ({}));
348
+ if (!res.ok) throw new Error(data.message || 'Failed to generate cloud token');
349
+ return data.cloudToken;
350
+ }
351
+
264
352
  export default function App() {
265
353
  const [roomToken, setRoomToken] = useState<string | null>(null);
266
- const [cloudToken, setCloudToken] = useState<string | null>(null);
267
354
  const [displayName, setDisplayName] = useState('');
268
355
 
269
356
  const handleJoin = useCallback(async (name: string) => {
270
357
  setDisplayName(name);
271
- const res = await fetch('/api/token', {
272
- method: 'POST',
273
- headers: { 'Content-Type': 'application/json' },
274
- body: JSON.stringify({ roomName: 'my-room', userId: name, displayName: name }),
275
- });
276
- const data = await res.json();
277
- setRoomToken(data.roomToken);
278
- if (data.cloudToken) setCloudToken(data.cloudToken);
358
+ setRoomToken(await fetchRoomToken());
279
359
  }, []);
280
360
 
281
- const handleLeave = useCallback(() => {
282
- setRoomToken(null);
283
- setCloudToken(null);
284
- }, []);
361
+ const handleLeave = useCallback(() => setRoomToken(null), []);
285
362
 
286
363
  if (!roomToken) {
287
364
  return <PrebuiltLobby onJoin={handleJoin} />;
288
365
  }
289
366
 
290
367
  return (
291
- <HiyveRoom roomToken={roomToken} cloudToken={cloudToken} intelligence>
368
+ <HiyveRoom roomToken={roomToken} region={REGION} generateToken={fetchCloudToken} userId={displayName} intelligence>
292
369
  <PrebuiltRoom userId={displayName} onLeave={handleLeave} />
293
370
  </HiyveRoom>
294
371
  );
@@ -296,67 +373,62 @@ export default function App() {
296
373
  `;
297
374
  }
298
375
 
299
- // Basic / AI template compose directly
300
- const imports = [`import { HiyveProvider, useRoom, useConnection } from '@hiyve/react';`];
301
- imports.push(`import { VideoGrid, ControlBar } from '@hiyve/react-ui';`);
302
-
303
- let providerOpen = ' <HiyveProvider generateRoomToken={generateRoomToken}>';
304
- let providerClose = ' </HiyveProvider>';
305
- let extraComponents = '';
306
-
307
- if (template.features.intelligence) {
308
- extraComponents += `\n {/* AI features available via @hiyve/react-intelligence hooks */}`;
376
+ // Basic / AI — the getting-started guide's App, verbatim. HiyveProvider
377
+ // (src/main.tsx) fetches the room token itself; JoinForm drives the flow.
378
+ const aiNote = template.features.intelligence
379
+ ? `\n// AI: add hooks from @hiyve/react-intelligence inside VideoRoom (HiyveProvider\n// already fetches the cloud token from /api/generate-cloud-token).\n`
380
+ : '';
381
+ return `import { useRoomFlow } from '@hiyve/react';
382
+ import { JoinForm, ConnectingScreen } from '@hiyve/react-ui';
383
+ import VideoRoom from './VideoRoom';
384
+ ${aiNote}
385
+ function App() {
386
+ const { screen } = useRoomFlow();
387
+
388
+ switch (screen) {
389
+ case 'connecting':
390
+ return <ConnectingScreen />;
391
+ case 'in-room':
392
+ return <VideoRoom />;
393
+ default:
394
+ return <JoinForm autoConnect devicePreviewMode="inline" />;
309
395
  }
310
-
311
- return `import React, { useState, useCallback } from 'react';
312
- ${imports.join('\n')}
313
-
314
- function Room({ onLeave }: { onLeave: () => void }) {
315
- return (
316
- <div style={{ display: 'flex', flexDirection: 'column', height: '100vh', background: '#121212' }}>
317
- <div style={{ flex: 1, overflow: 'hidden' }}>
318
- <VideoGrid localVideoElementId="local-video" />
319
- </div>${extraComponents}
320
- <ControlBar onLeave={onLeave} />
321
- </div>
322
- );
323
396
  }
324
397
 
325
- export default function App() {
326
- const [roomToken, setRoomToken] = useState<string | null>(null);
327
-
328
- const handleJoin = useCallback(async () => {
329
- const res = await fetch('/api/token', {
330
- method: 'POST',
331
- headers: { 'Content-Type': 'application/json' },
332
- body: JSON.stringify({ roomName: 'my-room', userId: 'user-' + Date.now() }),
333
- });
334
- const data = await res.json();
335
- setRoomToken(data.roomToken);
336
- }, []);
398
+ export default App;
399
+ `;
400
+ }
337
401
 
338
- const generateRoomToken = useCallback(() => Promise.resolve(roomToken!), [roomToken]);
402
+ export function generateVideoRoom() {
403
+ return `import { useState } from 'react';
404
+ import { useRoom, useConnection } from '@hiyve/react';
405
+ import { VideoGrid, ControlBar } from '@hiyve/react-ui';
339
406
 
340
- if (!roomToken) {
341
- return (
342
- <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100vh', background: '#121212' }}>
343
- <button onClick={handleJoin} style={{ padding: '12px 32px', fontSize: 18, borderRadius: 8, border: 'none', background: '#6c63ff', color: '#fff', cursor: 'pointer' }}>
344
- Join Room
345
- </button>
346
- </div>
347
- );
348
- }
407
+ function VideoRoom() {
408
+ const { room } = useRoom();
409
+ const { leaveRoom } = useConnection();
410
+ const [layout, setLayout] = useState('grid');
349
411
 
350
412
  return (
351
- ${providerOpen}
352
- <Room onLeave={() => setRoomToken(null)} />
353
- ${providerClose}
413
+ <div style={{ height: '100vh', display: 'flex', flexDirection: 'column' }}>
414
+ {room?.name && <h2 style={{ margin: '8px 16px' }}>{room.name}</h2>}
415
+ <VideoGrid localVideoElementId="local-video" layout={layout} showNames showLocalFlip />
416
+ <ControlBar
417
+ onLeave={leaveRoom}
418
+ showScreenShare
419
+ showLayoutSelector
420
+ layout={layout}
421
+ onLayoutChange={setLayout}
422
+ />
423
+ </div>
354
424
  );
355
425
  }
426
+
427
+ export default VideoRoom;
356
428
  `;
357
429
  }
358
430
 
359
- function generateIndexHtml(name) {
431
+ export function generateIndexHtml(name) {
360
432
  return `<!DOCTYPE html>
361
433
  <html lang="en">
362
434
  <head>
@@ -372,71 +444,25 @@ function generateIndexHtml(name) {
372
444
  `;
373
445
  }
374
446
 
375
- function generateServer(template) {
376
- const hasCloud = template.features.intelligence;
377
-
378
- return `import express from 'express';
379
- import 'dotenv/config';
447
+ export function generateServer() {
448
+ // The getting-started guide's server. mountHiyveRoutes exposes
449
+ // /generate-room-token, /generate-cloud-token, /create-join-token and
450
+ // /health under /api; loadHiyveConfig reads APIKEY, CLIENT_SECRET and
451
+ // SERVER_REGION from .env. Nothing here talks to signaling directly.
452
+ return `import 'dotenv/config';
453
+ import express from 'express';
454
+ import cors from 'cors';
455
+ import { mountHiyveRoutes, loadHiyveConfig } from '@hiyve/admin';
380
456
 
381
457
  const app = express();
458
+ app.use(cors({ origin: ['http://localhost:5173'] }));
382
459
  app.use(express.json());
383
460
 
384
- const API_KEY = process.env.HIYVE_API_KEY;
385
- const SIGNALING_URL = process.env.HIYVE_SIGNALING_URL || 'https://signal.hiyve.dev';
386
- ${hasCloud ? `const CLOUD_URL = process.env.HIYVE_CLOUD_URL || 'https://cloud.hiyve.io';\n` : ''}
387
- /**
388
- * Generate a room token for the client.
389
- * In production, add your own authentication before issuing tokens.
390
- */
391
- app.post('/api/token', async (req, res) => {
392
- const { roomName, userId, displayName } = req.body;
393
-
394
- try {
395
- const response = await fetch(\`\${SIGNALING_URL}/api/rooms/token\`, {
396
- method: 'POST',
397
- headers: {
398
- 'Content-Type': 'application/json',
399
- 'Authorization': \`Bearer \${API_KEY}\`,
400
- },
401
- body: JSON.stringify({ roomName, userId, displayName: displayName || userId }),
402
- });
403
-
404
- if (!response.ok) {
405
- throw new Error(\`Token request failed: \${response.status}\`);
406
- }
407
-
408
- const data = await response.json();
409
- ${hasCloud ? `
410
- // Also generate a cloud token for AI features
411
- let cloudToken: string | undefined;
412
- try {
413
- const cloudRes = await fetch(\`\${CLOUD_URL}/auth/tokens/cloud\`, {
414
- method: 'POST',
415
- headers: {
416
- 'Content-Type': 'application/json',
417
- 'x-api-key': API_KEY!,
418
- },
419
- body: JSON.stringify({ userId }),
420
- });
421
- if (cloudRes.ok) {
422
- const cloudData = await cloudRes.json();
423
- cloudToken = cloudData.token;
424
- }
425
- } catch {
426
- // Cloud token optional — continue without it
427
- }
428
-
429
- res.json({ roomToken: data.token, cloudToken });
430
- ` : ` res.json({ roomToken: data.token });
431
- `} } catch (err: any) {
432
- console.error('Token error:', err.message);
433
- res.status(500).json({ error: 'Failed to generate token' });
434
- }
435
- });
461
+ const apiRouter = express.Router();
462
+ mountHiyveRoutes(apiRouter, loadHiyveConfig());
463
+ app.use('/api', apiRouter);
436
464
 
437
- const PORT = process.env.PORT || 4000;
438
- app.listen(PORT, () => {
439
- console.log(\`Server running on http://localhost:\${PORT}\`);
440
- });
465
+ const PORT = Number(process.env.PORT) || ${SERVER_PORT};
466
+ app.listen(PORT, () => console.log(\`Server running on http://localhost:\${PORT}\`));
441
467
  `;
442
468
  }
@@ -0,0 +1,157 @@
1
+ /**
2
+ * The scaffolder must produce a project that installs and runs against the
3
+ * PUBLISHED packages and matches the getting-started guide. It drifted
4
+ * badly once (2026-09-01): MUI ^5 against a ^9 peer (ERESOLVE on install), a
5
+ * hand-rolled token route posting to a host with no DNS record, no region
6
+ * pin, and env var names the SDK does not read. These tests check the
7
+ * generated output against the real peer ranges in this workspace so the
8
+ * next drift fails here, not on a developer's first `npm install`.
9
+ */
10
+ import { describe, it, expect } from 'vitest';
11
+ import { readFileSync } from 'node:fs';
12
+ import { fileURLToPath } from 'node:url';
13
+ import { dirname, join } from 'node:path';
14
+ import {
15
+ PINS,
16
+ DEFAULT_REGION,
17
+ generatePackageJson,
18
+ generateEnvExample,
19
+ generateMain,
20
+ generateApp,
21
+ generateVideoRoom,
22
+ generateServer,
23
+ generateViteConfig,
24
+ generateTsConfig,
25
+ } from './init.js';
26
+
27
+ const here = dirname(fileURLToPath(import.meta.url));
28
+ const workspacePkg = (name) =>
29
+ JSON.parse(readFileSync(join(here, '../../../..', name, 'package.json'), 'utf8'));
30
+
31
+ const TEMPLATES = {
32
+ basic: { packages: ['@hiyve/react', '@hiyve/react-ui', '@hiyve/admin'], features: { intelligence: false, collaboration: false } },
33
+ ai: { packages: ['@hiyve/react', '@hiyve/react-ui', '@hiyve/react-intelligence', '@hiyve/admin'], features: { intelligence: true, collaboration: false } },
34
+ full: { packages: ['@hiyve/react-room', '@hiyve/react-intelligence', '@hiyve/react-semantic-relay', '@hiyve/admin'], features: { intelligence: true, collaboration: true } },
35
+ };
36
+
37
+ /** Major of a ^x.y.z / ~x.y.z pin. */
38
+ const majorOf = (range) => Number.parseInt(range.replace(/^[\^~]/, ''), 10);
39
+ /** Majors a caret-only peer range admits, e.g. "^18.0.0 || ^19.0.0" -> [18, 19]. */
40
+ const peerMajors = (peer) => peer.split('||').map((r) => majorOf(r.trim()));
41
+ /** Does a caret pin fall inside a caret-only peer range? */
42
+ const rangeFitsPeer = (pin, peer) => peerMajors(peer).includes(majorOf(pin));
43
+
44
+ describe('generated package.json', () => {
45
+ const pkg = JSON.parse(generatePackageJson('demo', TEMPLATES.basic));
46
+
47
+ it('pins MUI inside the range @hiyve/react-ui and @hiyve/react-room actually require', () => {
48
+ for (const p of ['react-ui', 'react-room']) {
49
+ const peer = workspacePkg(`packages/${p}`).peerDependencies['@mui/material'];
50
+ expect(rangeFitsPeer(pkg.dependencies['@mui/material'], peer), `${p} wants ${peer}`).toBe(true);
51
+ }
52
+ expect(pkg.dependencies['@mui/icons-material']).toBe(pkg.dependencies['@mui/material']);
53
+ });
54
+
55
+ it('pins React inside the range @hiyve/react requires', () => {
56
+ const peer = workspacePkg('packages/react').peerDependencies.react;
57
+ expect(rangeFitsPeer(pkg.dependencies.react, peer)).toBe(true);
58
+ expect(pkg.dependencies['react-dom']).toBe(pkg.dependencies.react);
59
+ });
60
+
61
+ it('never pins MUI 5 or React 18 — the drift that broke install', () => {
62
+ expect(majorOf(pkg.dependencies['@mui/material'])).toBeGreaterThanOrEqual(9);
63
+ expect(majorOf(pkg.dependencies.react)).toBeGreaterThanOrEqual(19);
64
+ });
65
+
66
+ it('includes @hiyve/admin — the token server is the SDK, not hand-rolled', () => {
67
+ expect(pkg.dependencies['@hiyve/admin']).toBeDefined();
68
+ expect(pkg.dependencies.express).toBeDefined();
69
+ expect(pkg.dependencies.cors).toBeDefined();
70
+ });
71
+
72
+ it('every dev dependency the tsconfig relies on is present', () => {
73
+ for (const k of ['@types/node', 'typescript', 'vite', '@vitejs/plugin-react', 'tsx', 'concurrently']) {
74
+ expect(pkg.devDependencies[k], k).toBe(PINS[k]);
75
+ }
76
+ });
77
+ });
78
+
79
+ describe('generated server', () => {
80
+ const server = generateServer();
81
+
82
+ it('mounts the SDK routes instead of posting to a signaling host by hand', () => {
83
+ expect(server).toContain("from '@hiyve/admin'");
84
+ expect(server).toContain('mountHiyveRoutes(apiRouter, loadHiyveConfig())');
85
+ expect(server).toContain("app.use('/api', apiRouter)");
86
+ expect(server).not.toContain('signal.hiyve.dev');
87
+ expect(server).not.toContain('/api/rooms/token');
88
+ expect(server).not.toContain('HIYVE_API_KEY');
89
+ });
90
+
91
+ it('listens where vite proxies /api to', () => {
92
+ const port = server.match(/\|\| (\d+);/)[1];
93
+ expect(generateViteConfig()).toContain(`http://localhost:${port}`);
94
+ });
95
+ });
96
+
97
+ describe('generated .env.example', () => {
98
+ it('uses the variable names loadHiyveConfig reads, and the region the client pins', () => {
99
+ const env = generateEnvExample(TEMPLATES.basic);
100
+ expect(env).toMatch(/^APIKEY=pk_/m);
101
+ expect(env).toMatch(/^CLIENT_SECRET=sk_/m);
102
+ expect(env).toContain(`SERVER_REGION=${DEFAULT_REGION}`);
103
+ expect(env).not.toContain('HIYVE_API_KEY');
104
+ expect(env).not.toContain('signal.hiyve.dev');
105
+ expect(env).toContain('console.hiyve.dev');
106
+ });
107
+
108
+ it('never emits ENVIRONMENT=development — same cloud as production plus a boot warning', () => {
109
+ const env = generateEnvExample(TEMPLATES.basic);
110
+ expect(env).toMatch(/^ENVIRONMENT=production$/m);
111
+ expect(env).not.toMatch(/^ENVIRONMENT=development/m);
112
+ });
113
+ });
114
+
115
+ describe('region pin', () => {
116
+ it('basic/ai: HiyveProvider in main.tsx carries the region', () => {
117
+ expect(generateMain(TEMPLATES.basic)).toContain(`<HiyveProvider region="${DEFAULT_REGION}">`);
118
+ expect(generateMain(TEMPLATES.ai)).toContain(`<HiyveProvider region="${DEFAULT_REGION}">`);
119
+ });
120
+
121
+ it('full: HiyveRoom carries the region and main.tsx does not double-wrap', () => {
122
+ expect(generateApp(TEMPLATES.full)).toContain(`region={REGION}`);
123
+ expect(generateApp(TEMPLATES.full)).toContain(`const REGION = '${DEFAULT_REGION}'`);
124
+ expect(generateMain(TEMPLATES.full)).not.toContain('HiyveProvider');
125
+ });
126
+
127
+ it('client and server agree on the region', () => {
128
+ const env = generateEnvExample(TEMPLATES.full);
129
+ const serverRegion = env.match(/^SERVER_REGION=(\S+)/m)[1];
130
+ expect(serverRegion).toBe(DEFAULT_REGION);
131
+ });
132
+ });
133
+
134
+ describe('generated client', () => {
135
+ it('basic/ai is the guide verbatim: useRoomFlow + JoinForm, SDK fetches the token', () => {
136
+ const app = generateApp(TEMPLATES.basic);
137
+ expect(app).toContain("import { useRoomFlow } from '@hiyve/react'");
138
+ expect(app).toContain('<JoinForm autoConnect devicePreviewMode="inline" />');
139
+ expect(app).not.toContain('fetch(');
140
+ expect(generateVideoRoom()).toContain('<VideoGrid localVideoElementId="local-video"');
141
+ });
142
+
143
+ it('full fetches tokens from the SDK routes and reads the fields they return', () => {
144
+ const app = generateApp(TEMPLATES.full);
145
+ expect(app).toContain("fetch('/api/generate-room-token'");
146
+ expect(app).toContain('return data.roomToken');
147
+ expect(app).toContain("fetch('/api/generate-cloud-token'");
148
+ expect(app).toContain('return data.cloudToken');
149
+ expect(app).not.toContain('/api/token');
150
+ });
151
+
152
+ it('tsconfig covers both the browser and the server sources', () => {
153
+ const ts = JSON.parse(generateTsConfig());
154
+ expect(ts.include).toEqual(expect.arrayContaining(['src', 'server']));
155
+ expect(ts.compilerOptions.types).toEqual(expect.arrayContaining(['vite/client', 'node']));
156
+ });
157
+ });