uniweb 0.12.26 → 0.12.27

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.
@@ -1,466 +0,0 @@
1
- /**
2
- * Local Foundation Registry
3
- *
4
- * Manages published foundations in .unicloud/registry/. The on-disk
5
- * shape mirrors uniweb-edge's registry index (versions as an array of
6
- * { version, ... } objects, plus top-level namespace and latest) so
7
- * `--local` exercises the same data shape that ships in production.
8
- *
9
- * Layout:
10
- * .unicloud/
11
- * registry/
12
- * index.json # see "Index format" below
13
- * packages/
14
- * name/
15
- * 1.0.0/
16
- * foundation.js
17
- * schema.json
18
- * assets/...
19
- *
20
- * Index format:
21
- * {
22
- * "@ns/name": {
23
- * namespace: "ns",
24
- * versions: [
25
- * { version: "1.0.0", publishedAt, publishedBy, ... },
26
- * ...
27
- * ],
28
- * latest: "1.0.0"
29
- * }
30
- * }
31
- *
32
- * Legacy entries (versions as an object keyed by version) are migrated
33
- * to this shape on read. The next write persists the new shape.
34
- */
35
-
36
- import { existsSync } from 'node:fs'
37
- import { readFile, writeFile, readdir, mkdir, cp } from 'node:fs/promises'
38
- import { join, dirname, relative } from 'node:path'
39
-
40
- import { findWorkspaceRoot } from './workspace.js'
41
-
42
- /**
43
- * Get the .unicloud/registry/ directory path.
44
- * Looks for workspace root first; falls back to cwd.
45
- * @param {string} [startDir]
46
- * @returns {string}
47
- */
48
- export function getRegistryDir(startDir = process.cwd()) {
49
- const root = findWorkspaceRoot(startDir)
50
- const base = root || startDir
51
- return join(base, '.unicloud', 'registry')
52
- }
53
-
54
- /**
55
- * Sanitize a package name for filesystem use.
56
- * '@org/pkg' → 'org/pkg'
57
- * @param {string} name
58
- * @returns {string}
59
- */
60
- function sanitizeName(name) {
61
- // Strip leading @ for directory structure: @org/name → org/name
62
- return name.startsWith('@') ? name.slice(1) : name
63
- }
64
-
65
- /**
66
- * Parse the namespace out of a scoped package name. '@org/pkg' → 'org';
67
- * unscoped → ''.
68
- * @param {string} name
69
- * @returns {string}
70
- */
71
- function parseNamespace(name) {
72
- const m = /^@([a-z0-9_-]+)\//.exec(name)
73
- return m ? m[1] : ''
74
- }
75
-
76
- /**
77
- * Migrate a legacy index entry (versions as object, no namespace/latest)
78
- * to the current shape (versions as array, namespace + latest at top).
79
- * Mutates and returns the entry.
80
- */
81
- function normalizeEntry(name, entry) {
82
- if (!entry) return entry
83
- if (entry.versions && !Array.isArray(entry.versions) && typeof entry.versions === 'object') {
84
- entry.versions = Object.entries(entry.versions).map(([version, data]) => ({
85
- version,
86
- ...data,
87
- }))
88
- }
89
- if (!Array.isArray(entry.versions)) entry.versions = []
90
- if (!entry.namespace) entry.namespace = parseNamespace(name)
91
- if (!entry.latest && entry.versions.length > 0) {
92
- entry.latest = entry.versions[entry.versions.length - 1].version
93
- }
94
- return entry
95
- }
96
-
97
- /**
98
- * Local registry — stores published foundations in .unicloud/registry/
99
- */
100
- export class LocalRegistry {
101
- constructor(startDir) {
102
- this.registryDir = getRegistryDir(startDir)
103
- this.indexPath = join(this.registryDir, 'index.json')
104
- this.packagesDir = join(this.registryDir, 'packages')
105
- }
106
-
107
- async _readIndex() {
108
- if (!existsSync(this.indexPath)) return {}
109
- const raw = JSON.parse(await readFile(this.indexPath, 'utf8'))
110
- for (const name of Object.keys(raw)) {
111
- normalizeEntry(name, raw[name])
112
- }
113
- return raw
114
- }
115
-
116
- async _writeIndex(index) {
117
- await mkdir(this.registryDir, { recursive: true })
118
- await writeFile(this.indexPath, JSON.stringify(index, null, 2))
119
- }
120
-
121
- /**
122
- * Check if a specific version exists.
123
- * @param {string} name
124
- * @param {string} version
125
- * @returns {Promise<boolean>}
126
- */
127
- async exists(name, version) {
128
- const index = await this._readIndex()
129
- const versions = index[name]?.versions
130
- if (!Array.isArray(versions)) return false
131
- return versions.some(v => v.version === version)
132
- }
133
-
134
- /**
135
- * Get all published versions for a package as an array of
136
- * `{ version, publishedAt, ... }` entries (matches uniweb-edge).
137
- * @param {string} name
138
- * @returns {Promise<Array>}
139
- */
140
- async getVersions(name) {
141
- const index = await this._readIndex()
142
- return index[name]?.versions || []
143
- }
144
-
145
- /**
146
- * Get the full version entry for `name@version`, or null if absent.
147
- * Used by `publish` (pre-flight duplicate check) and `deploy`
148
- * (staleness check via git provenance comparison).
149
- *
150
- * @param {string} name
151
- * @param {string} version
152
- * @returns {Promise<Object|null>}
153
- */
154
- async getVersionEntry(name, version) {
155
- const versions = await this.getVersions(name)
156
- return versions.find(v => v.version === version) || null
157
- }
158
-
159
- /**
160
- * Publish a foundation to the local registry.
161
- * Copies the dist directory and updates the index.
162
- * @param {string} name
163
- * @param {string} version
164
- * @param {string} distDir - Path to the foundation's dist/ directory
165
- * @param {Object} [metadata] - Additional metadata (publishedBy, etc.)
166
- */
167
- async publish(name, version, distDir, metadata = {}) {
168
- const safeName = sanitizeName(name)
169
- const destDir = join(this.packagesDir, safeName, version)
170
-
171
- await mkdir(destDir, { recursive: true })
172
- await cp(distDir, destDir, { recursive: true })
173
-
174
- const index = await this._readIndex()
175
- if (!index[name]) {
176
- index[name] = {
177
- namespace: parseNamespace(name),
178
- versions: [],
179
- latest: null,
180
- }
181
- }
182
-
183
- const versionEntry = {
184
- version,
185
- publishedAt: new Date().toISOString(),
186
- ...metadata,
187
- }
188
- const existingIdx = index[name].versions.findIndex(v => v.version === version)
189
- if (existingIdx >= 0) {
190
- index[name].versions[existingIdx] = versionEntry
191
- } else {
192
- index[name].versions.push(versionEntry)
193
- }
194
- index[name].latest = version
195
-
196
- await this._writeIndex(index)
197
- }
198
-
199
- /**
200
- * Get the filesystem path for a published package version.
201
- * @param {string} name
202
- * @param {string} version
203
- * @returns {string}
204
- */
205
- getPackagePath(name, version) {
206
- return join(this.packagesDir, sanitizeName(name), version)
207
- }
208
- }
209
-
210
- /**
211
- * Create a local registry instance.
212
- * @param {string} [startDir]
213
- * @returns {LocalRegistry}
214
- */
215
- export function createLocalRegistry(startDir) {
216
- return new LocalRegistry(startDir)
217
- }
218
-
219
- /**
220
- * Remote registry — publishes foundations to a cloud server via HTTP.
221
- */
222
- export class RemoteRegistry {
223
- /**
224
- * @param {string} apiUrl - Registry server URL (e.g. "http://localhost:4001")
225
- * @param {string} [token] - Bearer token for authentication
226
- */
227
- constructor(apiUrl, token) {
228
- this.apiUrl = apiUrl.replace(/\/$/, '')
229
- this.token = token
230
- }
231
-
232
- /**
233
- * Fetch the registry index from the server.
234
- * @returns {Promise<Object>}
235
- */
236
- async _fetchIndex() {
237
- const res = await fetch(`${this.apiUrl}/`)
238
- if (!res.ok) throw new Error(`Registry request failed: ${res.status}`)
239
- return res.json()
240
- }
241
-
242
- /**
243
- * Check if a specific version exists on the remote.
244
- * @param {string} name
245
- * @param {string} version
246
- * @returns {Promise<boolean>}
247
- */
248
- async exists(name, version) {
249
- try {
250
- const index = await this._fetchIndex()
251
- const versions = index[name]?.versions
252
- if (!Array.isArray(versions)) return false
253
- return versions.some(v => v.version === version)
254
- } catch {
255
- return false
256
- }
257
- }
258
-
259
- /**
260
- * Get all published versions for a package as an array of
261
- * `{ version, publishedAt, ... }` entries.
262
- * @param {string} name
263
- * @returns {Promise<Array>}
264
- */
265
- async getVersions(name) {
266
- const index = await this._fetchIndex()
267
- return index[name]?.versions || []
268
- }
269
-
270
- /**
271
- * Get the full version entry for `name@version`, or null if absent.
272
- * Used by `publish` (pre-flight duplicate check) and `deploy`
273
- * (staleness check via git provenance comparison).
274
- *
275
- * @param {string} name
276
- * @param {string} version
277
- * @returns {Promise<Object|null>}
278
- */
279
- async getVersionEntry(name, version) {
280
- try {
281
- const versions = await this.getVersions(name)
282
- return versions.find(v => v.version === version) || null
283
- } catch {
284
- return null
285
- }
286
- }
287
-
288
- /**
289
- * Publish a foundation to the remote registry.
290
- * Reads files from distDir, encodes as base64, and POSTs to the server.
291
- *
292
- * @param {string} name
293
- * @param {string} version
294
- * @param {string} distDir - Path to the foundation's dist/ directory
295
- * @param {Object} [metadata] - Additional metadata
296
- * @returns {Promise<{ name: string, version: string, filesCount: number }>}
297
- */
298
- async publish(name, version, distDir, metadata = {}) {
299
- // Walk distDir recursively and encode files as base64
300
- const files = {}
301
- const entries = await readdir(distDir, { withFileTypes: true, recursive: true })
302
-
303
- for (const entry of entries) {
304
- if (!entry.isFile()) continue
305
- const fullPath = join(entry.parentPath || entry.path, entry.name)
306
- const relPath = relative(distDir, fullPath)
307
- const content = await readFile(fullPath)
308
- files[relPath] = content.toString('base64')
309
- }
310
-
311
- const { editAccess, ...restMetadata } = metadata
312
- const payload = { name, version, files, metadata: restMetadata }
313
- if (editAccess) {
314
- payload.editAccess = editAccess
315
- }
316
-
317
- const headers = { 'Content-Type': 'application/json' }
318
- if (this.token) {
319
- headers['Authorization'] = `Bearer ${this.token}`
320
- }
321
-
322
- const res = await fetch(`${this.apiUrl}/foundations`, {
323
- method: 'POST',
324
- headers,
325
- body: JSON.stringify(payload),
326
- })
327
-
328
- const body = await res.json()
329
-
330
- if (!res.ok) {
331
- if (res.status === 409) {
332
- throw Object.assign(new Error(body.error || `${name}@${version} already exists`), { code: 'CONFLICT' })
333
- }
334
- if (res.status === 401) {
335
- throw Object.assign(new Error(body.error || 'Unauthorized'), { code: 'UNAUTHORIZED' })
336
- }
337
- throw new Error(body.error || `Server error (${res.status})`)
338
- }
339
-
340
- return body
341
- }
342
-
343
- /**
344
- * Common fetch helper with auth headers.
345
- * @param {string} url
346
- * @param {Object} [options]
347
- * @returns {Promise<Response>}
348
- */
349
- _authHeaders() {
350
- const headers = { 'Content-Type': 'application/json' }
351
- if (this.token) {
352
- headers['Authorization'] = `Bearer ${this.token}`
353
- }
354
- return headers
355
- }
356
-
357
- /**
358
- * Create a foundation invite.
359
- * @param {string} foundationName
360
- * @param {Object} payload - { email, majorVersion, maxUses?, expiresInDays? }
361
- * @returns {Promise<Object>}
362
- */
363
- async createInvite(foundationName, payload) {
364
- const res = await fetch(`${this.apiUrl}/api/foundations/${encodeURIComponent(foundationName)}/invites`, {
365
- method: 'POST',
366
- headers: this._authHeaders(),
367
- body: JSON.stringify(payload),
368
- })
369
- const body = await res.json()
370
- if (!res.ok) {
371
- throw Object.assign(new Error(body.error || `Server error (${res.status})`), { statusCode: res.status })
372
- }
373
- return body
374
- }
375
-
376
- /**
377
- * List invites for a foundation.
378
- * @param {string} foundationName
379
- * @returns {Promise<Array>}
380
- */
381
- async listInvites(foundationName) {
382
- const res = await fetch(`${this.apiUrl}/api/foundations/${encodeURIComponent(foundationName)}/invites`, {
383
- headers: this._authHeaders(),
384
- })
385
- const body = await res.json()
386
- if (!res.ok) {
387
- throw Object.assign(new Error(body.error || `Server error (${res.status})`), { statusCode: res.status })
388
- }
389
- return body.invites || []
390
- }
391
-
392
- /**
393
- * Revoke a foundation invite.
394
- * @param {string} foundationName
395
- * @param {string} inviteId
396
- * @returns {Promise<Object>}
397
- */
398
- async revokeInvite(foundationName, inviteId) {
399
- const res = await fetch(`${this.apiUrl}/api/foundations/${encodeURIComponent(foundationName)}/invites/${inviteId}`, {
400
- method: 'DELETE',
401
- headers: this._authHeaders(),
402
- })
403
- const body = await res.json()
404
- if (!res.ok) {
405
- throw Object.assign(new Error(body.error || `Server error (${res.status})`), { statusCode: res.status })
406
- }
407
- return body
408
- }
409
-
410
- /**
411
- * Resend a foundation invite.
412
- * @param {string} foundationName
413
- * @param {string} inviteId
414
- * @returns {Promise<Object>}
415
- */
416
- async resendInvite(foundationName, inviteId) {
417
- const res = await fetch(`${this.apiUrl}/api/foundations/${encodeURIComponent(foundationName)}/invites/${inviteId}/resend`, {
418
- method: 'POST',
419
- headers: this._authHeaders(),
420
- })
421
- const body = await res.json()
422
- if (!res.ok) {
423
- throw Object.assign(new Error(body.error || `Server error (${res.status})`), { statusCode: res.status })
424
- }
425
- return body
426
- }
427
-
428
- /**
429
- * Create a site record on Unicloud.
430
- * @param {string} siteId
431
- * @param {Object} options
432
- * @param {Object} options.foundation - { name }
433
- * @returns {Promise<Object>}
434
- */
435
- async createSite(siteId, { foundation }) {
436
- const res = await fetch(`${this.apiUrl}/api/sites`, {
437
- method: 'POST',
438
- headers: this._authHeaders(),
439
- body: JSON.stringify({ siteId, foundation }),
440
- })
441
- const body = await res.json()
442
- if (!res.ok) {
443
- throw Object.assign(new Error(body.error || `Server error (${res.status})`), { statusCode: res.status })
444
- }
445
- return body
446
- }
447
-
448
- /**
449
- * Transfer site ownership.
450
- * @param {string} siteId
451
- * @param {string} newOwner - Email of the new owner
452
- * @returns {Promise<Object>}
453
- */
454
- async transferSiteOwnership(siteId, newOwner) {
455
- const res = await fetch(`${this.apiUrl}/api/sites/${siteId}/owner`, {
456
- method: 'PATCH',
457
- headers: this._authHeaders(),
458
- body: JSON.stringify({ newOwner }),
459
- })
460
- const body = await res.json()
461
- if (!res.ok) {
462
- throw Object.assign(new Error(body.error || `Server error (${res.status})`), { statusCode: res.status })
463
- }
464
- return body
465
- }
466
- }