@hiyve/cli 1.0.19 → 1.1.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hiyve/cli",
3
- "version": "1.0.19",
3
+ "version": "1.1.0",
4
4
  "description": "Hiyve SDK CLI - Configure npm for private @hiyve packages",
5
5
  "type": "module",
6
6
  "bin": {
@@ -8,6 +8,8 @@ import { writeFileSync, mkdirSync, existsSync } from 'node:fs';
8
8
  import { join, resolve } from 'node:path';
9
9
  import chalk from 'chalk';
10
10
  import ora from 'ora';
11
+ import { fetchLatestVersions } from '../utils/registryApi.js';
12
+ import { getCurrentConfig } from '../utils/npmrc.js';
11
13
  import prompts from 'prompts';
12
14
 
13
15
  const TEMPLATES = {
@@ -135,6 +137,20 @@ export async function init(projectName, options) {
135
137
  process.exit(1);
136
138
  }
137
139
 
140
+ const resolving = ora('Resolving @hiyve package versions...').start();
141
+ const hiyveVersions = await fetchLatestVersions(
142
+ template.packages,
143
+ getCurrentConfig()?.apiKey,
144
+ );
145
+ const unresolved = template.packages.filter((p) => !hiyveVersions[p]);
146
+ if (unresolved.length === 0) {
147
+ resolving.succeed(`Pinned ${template.packages.length} @hiyve packages`);
148
+ } else {
149
+ resolving.warn(
150
+ `${unresolved.length} package(s) left on "latest" — run ${chalk.cyan('hiyve login')} first to pin exact versions`,
151
+ );
152
+ }
153
+
138
154
  const spinner = ora(`Creating ${chalk.cyan(projectName)} with ${template.name} template...`).start();
139
155
 
140
156
  try {
@@ -144,7 +160,7 @@ export async function init(projectName, options) {
144
160
  mkdirSync(join(projectDir, 'server'), { recursive: true });
145
161
 
146
162
  // Generate files
147
- writeFile(projectDir, 'package.json', generatePackageJson(projectName, template));
163
+ writeFile(projectDir, 'package.json', generatePackageJson(projectName, template, hiyveVersions));
148
164
  writeFile(projectDir, 'tsconfig.json', generateTsConfig());
149
165
  writeFile(projectDir, 'vite.config.ts', generateViteConfig());
150
166
  writeFile(projectDir, '.env.example', generateEnvExample(template));
@@ -190,10 +206,19 @@ function usesPrebuiltRoom(template) {
190
206
  return template.features.intelligence && template.features.collaboration;
191
207
  }
192
208
 
193
- export function generatePackageJson(name, template) {
209
+ /**
210
+ * @param {string} name Project name.
211
+ * @param {{packages: string[]}} template
212
+ * @param {Record<string, string>} [hiyveVersions] name → version resolved from
213
+ * the registry. Anything missing falls back to `latest`.
214
+ */
215
+ export function generatePackageJson(name, template, hiyveVersions = {}) {
194
216
  const deps = {};
195
217
  for (const pkg of template.packages) {
196
- deps[pkg] = 'latest';
218
+ // Caret, not `latest`: it locks the major, which is what keeps the set
219
+ // mutually consistent, while still picking up patches.
220
+ const version = hiyveVersions[pkg];
221
+ deps[pkg] = version ? `^${version}` : 'latest';
197
222
  }
198
223
  for (const k of ['react', 'react-dom', '@mui/material', '@mui/icons-material', '@emotion/react', '@emotion/styled', 'express', 'cors', 'dotenv']) {
199
224
  deps[k] = PINS[k];
@@ -279,7 +304,9 @@ CLIENT_SECRET=sk_live_your_client_secret_here
279
304
  # passed to HiyveProvider / HiyveRoom in src/ (${DEFAULT_REGION} in both).
280
305
  SERVER_REGION=${DEFAULT_REGION}
281
306
  SERVER_REGION_URL=.rtc.muziemedia.com
282
- ENVIRONMENT=development
307
+ # production and development resolve to the SAME cloud (there is currently no
308
+ # separate development stack); development only adds a boot warning. Leave as-is.
309
+ ENVIRONMENT=production
283
310
  `;
284
311
 
285
312
  if (template.features.intelligence) {
@@ -339,32 +366,46 @@ async function fetchRoomToken(): Promise<string> {
339
366
  return data.roomToken;
340
367
  }
341
368
 
342
- /** Fetch a cloud token for AI features from the same server. */
343
- async function fetchCloudToken(): Promise<string> {
344
- const res = await fetch('/api/generate-cloud-token', { method: 'POST' });
369
+ /**
370
+ * Fetch a cloud token for AI features from the same server.
371
+ *
372
+ * HiyveRoom calls this with the current \`userId\`, which MUST be the email
373
+ * address of a user that exists in your organization's identity system: the
374
+ * cloud-token endpoint looks the user up and answers 404 for anyone it does
375
+ * not know. Sign users in with @hiyve/react-identity, or add them at
376
+ * console.hiyve.dev, before turning AI features on.
377
+ */
378
+ async function fetchCloudToken({ userId }: { userId: string }) {
379
+ const res = await fetch('/api/generate-cloud-token', {
380
+ method: 'POST',
381
+ headers: { 'Content-Type': 'application/json' },
382
+ body: JSON.stringify({ userId }),
383
+ });
345
384
  const data = await res.json().catch(() => ({}));
346
385
  if (!res.ok) throw new Error(data.message || 'Failed to generate cloud token');
347
- return data.cloudToken;
386
+ return { cloudToken: data.cloudToken as string, environment: data.environment as string };
348
387
  }
349
388
 
350
389
  export default function App() {
351
390
  const [roomToken, setRoomToken] = useState<string | null>(null);
352
- const [displayName, setDisplayName] = useState('');
391
+ // The lobby collects an email because the cloud-token endpoint identifies
392
+ // users by email. Without AI features any display name would do.
393
+ const [userId, setUserId] = useState('');
353
394
 
354
- const handleJoin = useCallback(async (name: string) => {
355
- setDisplayName(name);
395
+ const handleJoin = useCallback(async (email: string) => {
396
+ setUserId(email);
356
397
  setRoomToken(await fetchRoomToken());
357
398
  }, []);
358
399
 
359
400
  const handleLeave = useCallback(() => setRoomToken(null), []);
360
401
 
361
402
  if (!roomToken) {
362
- return <PrebuiltLobby onJoin={handleJoin} />;
403
+ return <PrebuiltLobby onJoin={handleJoin} labels={{ namePlaceholder: 'you@example.com' }} />;
363
404
  }
364
405
 
365
406
  return (
366
- <HiyveRoom roomToken={roomToken} region={REGION} generateToken={fetchCloudToken} userId={displayName} intelligence>
367
- <PrebuiltRoom userId={displayName} onLeave={handleLeave} />
407
+ <HiyveRoom roomToken={roomToken} region={REGION} generateToken={fetchCloudToken} userId={userId} intelligence>
408
+ <PrebuiltRoom userId={userId} onLeave={handleLeave} />
368
409
  </HiyveRoom>
369
410
  );
370
411
  }
@@ -104,6 +104,12 @@ describe('generated .env.example', () => {
104
104
  expect(env).not.toContain('signal.hiyve.dev');
105
105
  expect(env).toContain('console.hiyve.dev');
106
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
+ });
107
113
  });
108
114
 
109
115
  describe('region pin', () => {
@@ -139,7 +145,9 @@ describe('generated client', () => {
139
145
  expect(app).toContain("fetch('/api/generate-room-token'");
140
146
  expect(app).toContain('return data.roomToken');
141
147
  expect(app).toContain("fetch('/api/generate-cloud-token'");
142
- expect(app).toContain('return data.cloudToken');
148
+ // The cloud route returns { cloudToken, environment }; the template reads
149
+ // both so CloudProvider can detect the environment that issued the token.
150
+ expect(app).toContain('data.cloudToken');
143
151
  expect(app).not.toContain('/api/token');
144
152
  });
145
153
 
@@ -149,3 +157,64 @@ describe('generated client', () => {
149
157
  expect(ts.compilerOptions.types).toEqual(expect.arrayContaining(['vite/client', 'node']));
150
158
  });
151
159
  });
160
+
161
+
162
+ describe('@hiyve dependency pinning', () => {
163
+ it('pins the resolved version with a caret, never "latest"', () => {
164
+ const pkg = JSON.parse(
165
+ generatePackageJson('demo', TEMPLATES.full, {
166
+ '@hiyve/react-room': '40.0.0',
167
+ '@hiyve/react-intelligence': '24.0.0',
168
+ '@hiyve/react-semantic-relay': '19.0.0',
169
+ '@hiyve/admin': '2.3.1',
170
+ }),
171
+ );
172
+
173
+ expect(pkg.dependencies['@hiyve/react-room']).toBe('^40.0.0');
174
+ expect(pkg.dependencies['@hiyve/admin']).toBe('^2.3.1');
175
+ expect(Object.values(pkg.dependencies)).not.toContain('latest');
176
+ });
177
+
178
+ it('falls back to "latest" only for what the registry did not resolve', () => {
179
+ const pkg = JSON.parse(
180
+ generatePackageJson('demo', TEMPLATES.full, { '@hiyve/react-room': '40.0.0' }),
181
+ );
182
+
183
+ expect(pkg.dependencies['@hiyve/react-room']).toBe('^40.0.0');
184
+ expect(pkg.dependencies['@hiyve/admin']).toBe('latest');
185
+ });
186
+
187
+ it('still scaffolds with no resolved versions at all (offline)', () => {
188
+ const pkg = JSON.parse(generatePackageJson('demo', TEMPLATES.full));
189
+ for (const name of TEMPLATES.full.packages) {
190
+ expect(pkg.dependencies[name]).toBe('latest');
191
+ }
192
+ });
193
+ });
194
+
195
+ describe('full template cloud token', () => {
196
+ const app = generateApp(TEMPLATES.full);
197
+
198
+ it('sends userId in the request body — the endpoint 400s without it', () => {
199
+ expect(app).toContain("body: JSON.stringify({ userId })");
200
+ expect(app).toContain("'Content-Type': 'application/json'");
201
+ // The shipped bug: a bare POST with no body at all.
202
+ expect(app).not.toMatch(/generate-cloud-token',\s*\{\s*method:\s*'POST'\s*\}/);
203
+ });
204
+
205
+ it('takes userId as a parameter, because HiyveRoom supplies it', () => {
206
+ expect(app).toContain('async function fetchCloudToken({ userId }: { userId: string })');
207
+ });
208
+
209
+ it('passes an email as userId, not the lobby display name', () => {
210
+ // The cloud-token endpoint resolves users by email; a display name 404s.
211
+ expect(app).toContain('userId={userId}');
212
+ expect(app).not.toContain('userId={displayName}');
213
+ expect(app).toContain("namePlaceholder: 'you@example.com'");
214
+ });
215
+
216
+ it('returns the environment so the client can detect which cloud issued it', () => {
217
+ expect(app).toContain('cloudToken: data.cloudToken');
218
+ expect(app).toContain('environment: data.environment');
219
+ });
220
+ });
@@ -16,7 +16,12 @@
16
16
  * GET /-/whoami 200 `{username}` for a valid key — but
17
17
  * ALSO 200 `{}` for a bad key or none, so
18
18
  * it cannot be the validity check.
19
- * GET /-/v1/search?text=@hiyve 200, the live catalogue; unauthenticated.
19
+ * GET /-/v1/search?text=@hiyve 200 either way, but the catalogue is only
20
+ * populated WITH a key: unauthenticated it
21
+ * answers 200 `{objects:[],total:0}`, not a
22
+ * 401. Re-verified 2026-09-04. Treat an empty
23
+ * result as "not authenticated", never as
24
+ * "the registry is empty".
20
25
  */
21
26
 
22
27
  import { getApiUrl } from '../config.js';
@@ -142,4 +147,43 @@ export async function fetchPackageCatalogue(apiKey) {
142
147
  return { ok: true, ...catalogueFromSearch(searchResult) };
143
148
  }
144
149
 
145
- export default { maskApiKey, verifyApiKey, catalogueFromSearch, fetchPackageCatalogue };
150
+ /**
151
+ * Resolve the current version of each named @hiyve package.
152
+ *
153
+ * `init` pins what it scaffolds with. Left on `latest`, a generated project
154
+ * resolves a different set of @hiyve packages every day, and because `latest`
155
+ * resolves each one independently their peer ranges can land mutually
156
+ * unsatisfiable — this registry ships several majors a week. A developer who
157
+ * scaffolds today and reads the docs next week is then running code the docs
158
+ * do not describe.
159
+ *
160
+ * @param {string[]} names Package names to resolve.
161
+ * @param {string} [apiKey] Optional; the catalogue endpoint is unauthenticated.
162
+ * @returns {Promise<Record<string, string>>} name → version. Empty when the
163
+ * registry cannot be reached, so callers can fall back to `latest` rather
164
+ * than fail the scaffold offline.
165
+ */
166
+ export async function fetchLatestVersions(names, apiKey) {
167
+ try {
168
+ const catalogue = await fetchPackageCatalogue(apiKey);
169
+ if (!catalogue.ok) return {};
170
+
171
+ const wanted = new Set(names);
172
+ const versions = {};
173
+ for (const { name, version } of [...catalogue.sdk, ...catalogue.components]) {
174
+ if (wanted.has(name) && version) versions[name] = version;
175
+ }
176
+ return versions;
177
+ } catch {
178
+ // Offline, DNS failure, timeout — scaffolding must still work.
179
+ return {};
180
+ }
181
+ }
182
+
183
+ export default {
184
+ maskApiKey,
185
+ verifyApiKey,
186
+ catalogueFromSearch,
187
+ fetchPackageCatalogue,
188
+ fetchLatestVersions,
189
+ };
@@ -14,6 +14,7 @@ import {
14
14
  verifyApiKey,
15
15
  catalogueFromSearch,
16
16
  fetchPackageCatalogue,
17
+ fetchLatestVersions,
17
18
  } from './registryApi.js';
18
19
 
19
20
  const REGISTRY = 'https://registry.muziemedia.com/';
@@ -167,3 +168,53 @@ describe('fetchPackageCatalogue', () => {
167
168
  expect(result).toEqual({ ok: false, status: 500, error: 'Registry returned HTTP 500' });
168
169
  });
169
170
  });
171
+
172
+
173
+ describe('fetchLatestVersions', () => {
174
+ const searchBody = (pkgs) => ({
175
+ objects: pkgs.map(([name, version]) => ({ package: { name, version } })),
176
+ });
177
+
178
+ it('resolves only the packages asked for', async () => {
179
+ fetchMock.mockResolvedValue(
180
+ jsonResponse(200, searchBody([
181
+ ['@hiyve/react', '18.0.2'],
182
+ ['@hiyve/react-room', '40.0.0'],
183
+ ['@hiyve/react-clips', '34.0.0'],
184
+ ])),
185
+ );
186
+
187
+ const versions = await fetchLatestVersions(['@hiyve/react', '@hiyve/react-room'], KEY);
188
+
189
+ expect(versions).toEqual({ '@hiyve/react': '18.0.2', '@hiyve/react-room': '40.0.0' });
190
+ expect(versions['@hiyve/react-clips']).toBeUndefined();
191
+ });
192
+
193
+ it('sends the key — an unauthenticated search answers 200 with an EMPTY catalogue', async () => {
194
+ fetchMock.mockResolvedValue(jsonResponse(200, searchBody([])));
195
+
196
+ const versions = await fetchLatestVersions(['@hiyve/react'], KEY);
197
+
198
+ expect(fetchMock.mock.calls[0][1].headers.Authorization).toBe(`Bearer ${KEY}`);
199
+ // Empty means "not authenticated", and the caller must fall back to
200
+ // `latest` rather than pin nothing at all.
201
+ expect(versions).toEqual({});
202
+ });
203
+
204
+ it('falls back to an empty map when the registry errors', async () => {
205
+ fetchMock.mockResolvedValue(jsonResponse(500, {}));
206
+ await expect(fetchLatestVersions(['@hiyve/react'], KEY)).resolves.toEqual({});
207
+ });
208
+
209
+ it('falls back to an empty map offline instead of throwing', async () => {
210
+ fetchMock.mockRejectedValue(new Error('getaddrinfo ENOTFOUND'));
211
+ await expect(fetchLatestVersions(['@hiyve/react'], KEY)).resolves.toEqual({});
212
+ });
213
+
214
+ it('skips a package the registry lists without a version', async () => {
215
+ fetchMock.mockResolvedValue(
216
+ jsonResponse(200, { objects: [{ package: { name: '@hiyve/react' } }] }),
217
+ );
218
+ await expect(fetchLatestVersions(['@hiyve/react'], KEY)).resolves.toEqual({});
219
+ });
220
+ });