@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 +1 -1
- package/src/commands/init.js +55 -14
- package/src/commands/init.test.js +70 -1
- package/src/utils/registryApi.js +46 -2
- package/src/utils/registryApi.test.js +51 -0
package/package.json
CHANGED
package/src/commands/init.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
343
|
-
|
|
344
|
-
|
|
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
|
-
|
|
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 (
|
|
355
|
-
|
|
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={
|
|
367
|
-
<PrebuiltRoom userId={
|
|
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
|
-
|
|
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
|
+
});
|
package/src/utils/registryApi.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
+
});
|