@hiyve/cli 1.0.20 → 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.20",
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];
@@ -341,32 +366,46 @@ async function fetchRoomToken(): Promise<string> {
341
366
  return data.roomToken;
342
367
  }
343
368
 
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' });
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
+ });
347
384
  const data = await res.json().catch(() => ({}));
348
385
  if (!res.ok) throw new Error(data.message || 'Failed to generate cloud token');
349
- return data.cloudToken;
386
+ return { cloudToken: data.cloudToken as string, environment: data.environment as string };
350
387
  }
351
388
 
352
389
  export default function App() {
353
390
  const [roomToken, setRoomToken] = useState<string | null>(null);
354
- 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('');
355
394
 
356
- const handleJoin = useCallback(async (name: string) => {
357
- setDisplayName(name);
395
+ const handleJoin = useCallback(async (email: string) => {
396
+ setUserId(email);
358
397
  setRoomToken(await fetchRoomToken());
359
398
  }, []);
360
399
 
361
400
  const handleLeave = useCallback(() => setRoomToken(null), []);
362
401
 
363
402
  if (!roomToken) {
364
- return <PrebuiltLobby onJoin={handleJoin} />;
403
+ return <PrebuiltLobby onJoin={handleJoin} labels={{ namePlaceholder: 'you@example.com' }} />;
365
404
  }
366
405
 
367
406
  return (
368
- <HiyveRoom roomToken={roomToken} region={REGION} generateToken={fetchCloudToken} userId={displayName} intelligence>
369
- <PrebuiltRoom userId={displayName} onLeave={handleLeave} />
407
+ <HiyveRoom roomToken={roomToken} region={REGION} generateToken={fetchCloudToken} userId={userId} intelligence>
408
+ <PrebuiltRoom userId={userId} onLeave={handleLeave} />
370
409
  </HiyveRoom>
371
410
  );
372
411
  }
@@ -145,7 +145,9 @@ describe('generated client', () => {
145
145
  expect(app).toContain("fetch('/api/generate-room-token'");
146
146
  expect(app).toContain('return data.roomToken');
147
147
  expect(app).toContain("fetch('/api/generate-cloud-token'");
148
- 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');
149
151
  expect(app).not.toContain('/api/token');
150
152
  });
151
153
 
@@ -155,3 +157,64 @@ describe('generated client', () => {
155
157
  expect(ts.compilerOptions.types).toEqual(expect.arrayContaining(['vite/client', 'node']));
156
158
  });
157
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
+ });