@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 +1 -1
- package/src/commands/init.js +52 -13
- package/src/commands/init.test.js +64 -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];
|
|
@@ -341,32 +366,46 @@ async function fetchRoomToken(): Promise<string> {
|
|
|
341
366
|
return data.roomToken;
|
|
342
367
|
}
|
|
343
368
|
|
|
344
|
-
/**
|
|
345
|
-
|
|
346
|
-
|
|
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
|
-
|
|
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 (
|
|
357
|
-
|
|
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={
|
|
369
|
-
<PrebuiltRoom userId={
|
|
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
|
-
|
|
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
|
+
});
|
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
|
+
});
|