uniweb 0.12.21 → 0.12.23

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.
@@ -0,0 +1,400 @@
1
+ /**
2
+ * uniweb push — push a site to the backend over its two directional lanes:
3
+ * - content lane → `@uniweb/site-content` (the static half: pages, sections,
4
+ * layout, theme, foundation ref, extensions, collection decls);
5
+ * - folder lane → one `@uniweb/folder` + the collection-record entities it
6
+ * references (the dynamic half; the `$ref` closure rides together).
7
+ *
8
+ * Each entity is an entity-content document (`$id` + `$model` + sections). The site
9
+ * holds exactly one identity: `site.yml::$uuid` (the site-content entity). A first
10
+ * push has none — it CREATEs the site (`POST /dev/site/content`, uuid-less), the
11
+ * backend mints + adopts it and returns the new uuid, which `push` records into
12
+ * `site.yml`. Later pushes UPDATE by that uuid (`POST /dev/site/content/push/{uuid}`).
13
+ * The folder lane is keyed by the SAME site-content uuid (`POST
14
+ * /dev/site/folder/push/{uuid}`) — the backend owns the site's `@uniweb/folder`, so
15
+ * the framework never holds a folder uuid. Records still round-trip their own `$uuid`
16
+ * (back-filled into their source files). site-content is pushed wholesale (no per-item
17
+ * uuids on the wire). Push-only, last-push-wins (`collision=force`) in v1.
18
+ *
19
+ * Order: content first (CREATE or UPDATE — the site must exist before its folder),
20
+ * then the folder, keyed by the site's uuid. On a brand-new site the backend creates
21
+ * the folder on its first folder push for that uuid.
22
+ *
23
+ * `uniweb login && uniweb push`. Run from a site, or a workspace with one site.
24
+ *
25
+ * Usage:
26
+ * uniweb push Build, push both lanes, back-fill $uuid
27
+ * uniweb push --as-org @org Act as @org (membership-gated)
28
+ * uniweb push --dry-run Report what would be pushed; submit nothing
29
+ * uniweb push -o out.uwx Write the .uwx file(s) per lane; submit nothing
30
+ * uniweb push --registry <url> Override the backend origin
31
+ * uniweb push --token <bearer> Submit with this bearer; skips `uniweb login`
32
+ * uniweb push --foundation <dir> Use this local foundation for the Model schema
33
+ * uniweb push --all Send every record (bypass the changed-only cache)
34
+ *
35
+ * Endpoints: <origin>/dev/site/content (create), /dev/site/content/push/{uuid}
36
+ * (update), /dev/site/folder/push/{uuid}. origin from
37
+ * --registry > UNIWEB_REGISTER_URL > the local default.
38
+ * Auth: --token > UNIWEB_TOKEN > `uniweb login` session.
39
+ */
40
+
41
+ import { writeFileSync, readFileSync, mkdirSync } from 'node:fs'
42
+ import { resolve, join, dirname } from 'node:path'
43
+ import {
44
+ emitSyncPackages,
45
+ backfillEntityUuids,
46
+ writeSiteEntityUuid,
47
+ } from '@uniweb/build/uwx'
48
+ import { ensureRegistryAuth } from '../utils/registry-auth.js'
49
+ import { resolveSiteDir } from './deploy.js'
50
+
51
+ // Same backend host as `uniweb register`; only the path differs (the restore
52
+ // lane). Overridable via --registry / UNIWEB_REGISTER_URL (a URL; origin taken).
53
+ const DEFAULT_BACKEND_ORIGIN = 'http://localhost:8080'
54
+
55
+ const colors = {
56
+ reset: '\x1b[0m', bright: '\x1b[1m', dim: '\x1b[2m',
57
+ red: '\x1b[31m', green: '\x1b[32m', yellow: '\x1b[33m', blue: '\x1b[36m',
58
+ }
59
+ const log = console.log
60
+ const success = (m) => log(`${colors.green}✓${colors.reset} ${m}`)
61
+ const error = (m) => console.error(`${colors.red}✗${colors.reset} ${m}`)
62
+ const info = (m) => log(`${colors.blue}→${colors.reset} ${m}`)
63
+ const note = (m) => log(` ${colors.dim}${m}${colors.reset}`)
64
+
65
+ function flagValue(args, name) {
66
+ const eq = args.find((a) => a.startsWith(`${name}=`))
67
+ if (eq) return eq.slice(name.length + 1)
68
+ const i = args.indexOf(name)
69
+ if (i !== -1 && args[i + 1] && !args[i + 1].startsWith('-')) return args[i + 1]
70
+ return null
71
+ }
72
+
73
+ // Pull the finalized entities out of the restore response. The backend returns
74
+ // `{ report: { finalized: [ { index, uuid, changed, document }, … ] } }` — each
75
+ // entry carries its position in the SUBMITTED sequence (`index`, the correlation
76
+ // key — `$id` is not echoed), the minted entity `uuid`, a `changed` flag, and the
77
+ // full `document` (verbatim stored content with every `$uuid` filled in). A couple
78
+ // of shapes are tolerated; only entries with a valid `index` + `uuid` are usable.
79
+ function extractFinalized(payload) {
80
+ const list = Array.isArray(payload?.report?.finalized)
81
+ ? payload.report.finalized
82
+ : Array.isArray(payload?.finalized)
83
+ ? payload.finalized
84
+ : Array.isArray(payload)
85
+ ? payload
86
+ : null
87
+ if (!list) return null
88
+ return list
89
+ .map((d) => ({
90
+ index: d?.index,
91
+ uuid: d?.uuid ?? d?.document?.$uuid ?? null,
92
+ changed: d?.changed,
93
+ document: d?.document ?? null,
94
+ }))
95
+ .filter((e) => Number.isInteger(e.index) && e.uuid)
96
+ }
97
+
98
+ // Pull the minted site-content uuid out of a CREATE (`POST /dev/site/content`)
99
+ // response. The exact shape is an open backend item — tolerant of a bare
100
+ // `{ siteContentUuid }` / `{ $uuid }` / `{ uuid }`, or the same `report.finalized[]`
101
+ // envelope the update/folder lanes return (the site entity is submitted alone, so its
102
+ // minted uuid is the first finalized entry). Returns null if none is present. (Single
103
+ // adjust-point to pin at the first live CREATE.)
104
+ export function extractMintedSiteUuid(payload) {
105
+ if (typeof payload?.siteContentUuid === 'string') return payload.siteContentUuid
106
+ if (typeof payload?.$uuid === 'string') return payload.$uuid
107
+ if (typeof payload?.uuid === 'string') return payload.uuid
108
+ const finalized = extractFinalized(payload)
109
+ if (finalized && finalized.length) {
110
+ const site = finalized.find((f) => f.index === 0) || finalized[0]
111
+ return site?.uuid ?? null
112
+ }
113
+ return null
114
+ }
115
+
116
+ // One-line summary from the authoritative per-entity `changed` flag (`false` = a
117
+ // true no-op). Falls back silently when the backend omits it.
118
+ function changedSummary(finalized) {
119
+ const changed = finalized.filter((f) => f.changed === true).length
120
+ const unchanged = finalized.filter((f) => f.changed === false).length
121
+ const parts = []
122
+ if (changed) parts.push(`${changed} changed`)
123
+ if (unchanged) parts.push(`${unchanged} unchanged`)
124
+ return parts.length ? parts.join(', ') : null
125
+ }
126
+
127
+ // Build the data-schema-read path for a registry name. `@scope/name` →
128
+ // /dev/registry/data-schemas/{scope}/{name}; a bare name →
129
+ // /dev/registry/data-schemas/{name}. The `@` sigil is not part of the path
130
+ // segment. (Path segment encoding to confirm at live e2e.)
131
+ export function modelPathFor(modelName) {
132
+ const m = /^@([^/]+)\/(.+)$/.exec(modelName)
133
+ if (m) return `/dev/registry/data-schemas/${encodeURIComponent(m[1])}/${encodeURIComponent(m[2])}`
134
+ return `/dev/registry/data-schemas/${encodeURIComponent(modelName)}`
135
+ }
136
+
137
+ // Resolve a Model NOT defined by the local foundation by fetching its declaration
138
+ // (the `@uniweb/data-schema` form) from the backend's Model-read route. Cached per
139
+ // run; HTTP 404 → null (the emitter then says "register it first"). The bearer is
140
+ // acquired lazily via getToken, so a fully-local sync never authenticates.
141
+ export function makeModelResolver({ apiBase, getToken, fetchImpl = fetch }) {
142
+ const cache = new Map()
143
+ return async (modelName) => {
144
+ if (cache.has(modelName)) return cache.get(modelName)
145
+ const url = `${apiBase}${modelPathFor(modelName)}`
146
+ const token = await getToken()
147
+ let res
148
+ try {
149
+ res = await fetchImpl(url, { headers: { Authorization: `Bearer ${token}` } })
150
+ } catch (err) {
151
+ throw new Error(`could not reach the Model-read endpoint ${url}: ${err.message}`)
152
+ }
153
+ if (res.status === 404) {
154
+ cache.set(modelName, null)
155
+ return null
156
+ }
157
+ if (!res.ok) {
158
+ throw new Error(`Model-read ${url} failed: HTTP ${res.status} ${res.statusText}`)
159
+ }
160
+ let decl
161
+ try {
162
+ decl = await res.json()
163
+ } catch (err) {
164
+ throw new Error(`Model-read ${url} returned non-JSON: ${err.message}`)
165
+ }
166
+ cache.set(modelName, decl)
167
+ return decl
168
+ }
169
+ }
170
+
171
+ // "Send only changed" cache: content hashes from the last successful sync, keyed
172
+ // `<model> <id>`. Gitignored, per-clone, deletable (a deleted cache just means one
173
+ // full re-sync, which the backend then no-ops). NOT identity — the minted `$uuid`
174
+ // lives in the source files; this is a pure wire-efficiency cache.
175
+ function syncCachePath(siteDir) {
176
+ return join(siteDir, '.uniweb', 'sync-cache.json')
177
+ }
178
+ function readSyncCache(siteDir) {
179
+ try {
180
+ const obj = JSON.parse(readFileSync(syncCachePath(siteDir), 'utf8'))
181
+ return obj && typeof obj.hashes === 'object' && obj.hashes ? obj.hashes : {}
182
+ } catch {
183
+ return {} // missing / unreadable → treat everything as changed
184
+ }
185
+ }
186
+ function writeSyncCache(siteDir, hashes) {
187
+ const p = syncCachePath(siteDir)
188
+ mkdirSync(dirname(p), { recursive: true })
189
+ writeFileSync(p, JSON.stringify({ version: 1, hashes }, null, 2) + '\n')
190
+ }
191
+
192
+ export async function push(args = []) {
193
+ const dryRun = args.includes('--dry-run')
194
+ const output = flagValue(args, '-o') || flagValue(args, '--output')
195
+ const tokenFlag = flagValue(args, '--token')
196
+ const asOrg = flagValue(args, '--as-org')
197
+ const foundationDir = flagValue(args, '--foundation')
198
+ const sendAll = args.includes('--all') // bypass the send-only-changed cache
199
+ const registryFlag =
200
+ flagValue(args, '--registry') || process.env.UNIWEB_REGISTER_URL || DEFAULT_BACKEND_ORIGIN
201
+
202
+ let apiBase
203
+ try {
204
+ apiBase = new URL(registryFlag).origin
205
+ } catch {
206
+ error(`Invalid --registry / UNIWEB_REGISTER_URL: ${registryFlag}`)
207
+ return { exitCode: 2 }
208
+ }
209
+
210
+ const siteDir = await resolveSiteDir(args, 'push')
211
+
212
+ // Lazy bearer — acquired once, on first need (a non-local Model fetch during the
213
+ // build, or the submit). A fully-local sync never triggers it, so --dry-run / -o
214
+ // stay offline when every Model is defined by the local foundation.
215
+ let cachedToken = null
216
+ const getToken = async () => {
217
+ if (cachedToken) return cachedToken
218
+ cachedToken =
219
+ tokenFlag || process.env.UNIWEB_TOKEN || (await ensureRegistryAuth({ apiBase, command: 'Syncing', args }))
220
+ return cachedToken
221
+ }
222
+
223
+ // Build BOTH directional packages (the producer side). Each carries its own
224
+ // `index` — the per-entity source-file map for back-fill, correlated by submission
225
+ // position. Non-local Models are fetched from the registry on demand. `priorHashes`
226
+ // (the .uniweb push-cache) drives "send only changed" across both lanes; --all bypasses.
227
+ const priorHashes = readSyncCache(siteDir)
228
+ let pkg
229
+ try {
230
+ pkg = await emitSyncPackages(siteDir, {
231
+ ...(foundationDir ? { foundationDir } : {}),
232
+ resolveModel: makeModelResolver({ apiBase, getToken }),
233
+ priorHashes,
234
+ sendAll,
235
+ })
236
+ } catch (err) {
237
+ error(`Could not build the sync package: ${err.message}`)
238
+ return { exitCode: 2 }
239
+ }
240
+ const { siteContent, collections, siteContentUuid, warnings, hashes, skipped } = pkg
241
+ log('')
242
+ for (const w of warnings) note(`! ${w}`)
243
+
244
+ const totalEntities = (siteContent?.entityCount || 0) + (collections?.entityCount || 0)
245
+
246
+ // Nothing changed since the last push — the backend is already up to date.
247
+ if (totalEntities === 0) {
248
+ success(`Nothing to push — ${skipped} entit${skipped === 1 ? 'y' : 'ies'} unchanged since the last push.`)
249
+ return { exitCode: 0 }
250
+ }
251
+ if (siteContent) info(`${colors.bright}site-content${colors.reset} → ${siteContent.models.join(', ')}`)
252
+ if (collections) {
253
+ const n = collections.entityCount
254
+ info(`${colors.bright}collections${colors.reset} (${n} entit${n === 1 ? 'y' : 'ies'}) → ${collections.models.join(', ')}`)
255
+ }
256
+ if (skipped) note(`${skipped} unchanged, skipped`)
257
+
258
+ // Preview paths — no submit, no auth. Two lanes → up to two files / two routes.
259
+ if (output) {
260
+ const base = output.replace(/\.uwx$/, '')
261
+ if (siteContent) writeFileSync(resolve(`${base}.site-content.uwx`), siteContent.buffer)
262
+ if (collections) writeFileSync(resolve(`${base}.collections.uwx`), collections.buffer)
263
+ const lanes = [siteContent && 'site-content', collections && 'collections'].filter(Boolean)
264
+ success(`Wrote ${lanes.join(' + ')} .uwx — not submitted`)
265
+ return { exitCode: 0 }
266
+ }
267
+ if (dryRun) {
268
+ if (siteContent) {
269
+ const route = siteContentUuid ? `/dev/site/content/push/${siteContentUuid}` : '/dev/site/content'
270
+ const verb = siteContentUuid ? 'update' : 'create'
271
+ info(`Dry run — would ${verb} content at ${colors.dim}${apiBase}${route}${colors.reset}`)
272
+ }
273
+ if (collections) {
274
+ const key = siteContentUuid || '{new-site-uuid}'
275
+ info(`Dry run — would push the folder to ${colors.dim}${apiBase}/dev/site/folder/push/${key}${colors.reset}`)
276
+ }
277
+ return { exitCode: 0 }
278
+ }
279
+
280
+ const token = await getToken()
281
+ const wrote = []
282
+ let finalizedTotal = 0
283
+
284
+ // POST one lane's .uwx and parse the JSON response. Returns the parsed payload, or
285
+ // null on any transport/HTTP/parse failure (already reported). `collision=force`
286
+ // (last-push-wins) rides on every lane; `--as-org` rides as `as_org`. (Both the
287
+ // scope-param name — `as_org` vs the backend's `as_unit` — and whether `collision`
288
+ // stays required now that create/update are split are open backend items; preserved
289
+ // as-is until pinned at the first live run.)
290
+ const postLane = async (label, path, buffer) => {
291
+ const params = new URLSearchParams({ collision: 'force' })
292
+ if (asOrg) params.set('as_org', asOrg)
293
+ const url = `${apiBase}${path}?${params.toString()}`
294
+ info(`Pushing ${label} to ${colors.dim}${url}${colors.reset} …`)
295
+ let res
296
+ try {
297
+ res = await fetch(url, {
298
+ method: 'POST',
299
+ headers: { 'Content-Type': 'application/zip', Authorization: `Bearer ${token}` },
300
+ body: buffer,
301
+ })
302
+ } catch (err) {
303
+ error(`Could not reach the backend at ${url}: ${err.message}`)
304
+ note('Set the origin with --registry <url> or UNIWEB_REGISTER_URL.')
305
+ return null
306
+ }
307
+ if (!res.ok) {
308
+ error(`${label} push rejected: HTTP ${res.status} ${res.statusText}`)
309
+ if (res.status === 401 || res.status === 403) {
310
+ note("Credentials weren't accepted — supply a bearer with --token <bearer> (or UNIWEB_TOKEN).")
311
+ }
312
+ const body = await res.text().catch(() => '')
313
+ if (body) note(body.slice(0, 800))
314
+ return null
315
+ }
316
+ try {
317
+ return await res.json()
318
+ } catch (err) {
319
+ error(`Could not parse the ${label} response as JSON: ${err.message}`)
320
+ return null
321
+ }
322
+ }
323
+
324
+ // POST a lane that round-trips entity uuids (content UPDATE + the folder): parse the
325
+ // finalized list (for record back-fill + the changed summary). Returns the finalized
326
+ // array, or null on failure (already reported).
327
+ const pushLane = async (label, path, buffer) => {
328
+ const payload = await postLane(label, path, buffer)
329
+ if (payload === null) return null
330
+ const finalized = extractFinalized(payload)
331
+ if (!finalized) {
332
+ error(`The ${label} response carried no recognizable finalized list (expected report.finalized[] with index + uuid).`)
333
+ note(JSON.stringify(payload).slice(0, 800))
334
+ return null
335
+ }
336
+ const summary = changedSummary(finalized)
337
+ if (summary) note(`${label}: ${summary}`)
338
+ return finalized
339
+ }
340
+
341
+ // Lane 1 — site-content (the site is born here; it must exist before its folder). A
342
+ // known site uuid → UPDATE by uuid; none → CREATE (the backend mints + adopts the
343
+ // site and returns its uuid, which we record into site.yml). `boundSiteUuid` carries
344
+ // the minted/known uuid forward to key the folder push.
345
+ let boundSiteUuid = siteContentUuid
346
+ if (siteContent) {
347
+ if (siteContentUuid) {
348
+ const finalized = await pushLane(
349
+ 'site-content',
350
+ `/dev/site/content/push/${encodeURIComponent(siteContentUuid)}`,
351
+ siteContent.buffer
352
+ )
353
+ if (!finalized) return { exitCode: 1 }
354
+ finalizedTotal += finalized.length
355
+ } else {
356
+ const payload = await postLane('site-content', '/dev/site/content', siteContent.buffer)
357
+ if (payload === null) return { exitCode: 1 }
358
+ const minted = extractMintedSiteUuid(payload)
359
+ if (!minted) {
360
+ error('The create response carried no minted site-content uuid — cannot record the site identity or push its folder.')
361
+ note(JSON.stringify(payload).slice(0, 800))
362
+ return { exitCode: 1 }
363
+ }
364
+ writeSiteEntityUuid(siteDir, minted)
365
+ boundSiteUuid = minted
366
+ wrote.push('recorded site $uuid in site.yml')
367
+ finalizedTotal += extractFinalized(payload)?.length ?? 1
368
+ }
369
+ }
370
+
371
+ // Lane 2 — collections (the @uniweb/folder + the records it references), keyed by the
372
+ // site-content uuid. On a brand-new site the backend creates the folder on this first
373
+ // push. Records round-trip their own $uuid (back-filled into source files); the folder
374
+ // itself has no uuid (the backend owns it).
375
+ if (collections) {
376
+ if (!boundSiteUuid) {
377
+ error('Cannot push collections — the site has no uuid yet. Push the site-content lane first.')
378
+ return { exitCode: 1 }
379
+ }
380
+ const finalized = await pushLane(
381
+ 'collections',
382
+ `/dev/site/folder/push/${encodeURIComponent(boundSiteUuid)}`,
383
+ collections.buffer
384
+ )
385
+ if (!finalized) return { exitCode: 1 }
386
+ const bf = backfillEntityUuids({ index: collections.index, finalized })
387
+ for (const w of bf.warnings) note(`! ${w}`)
388
+ for (const d of bf.deferred) note(`↷ ${d.id ?? `#${d.index}`}: ${d.reason}`)
389
+ if (bf.updated.length) wrote.push(`wrote ${bf.updated.length} record file(s)`)
390
+ finalizedTotal += finalized.length
391
+ }
392
+
393
+ // Persist the full content-hash map so the next push skips unchanged entities.
394
+ writeSyncCache(siteDir, hashes)
395
+ success(
396
+ `Pushed ${finalizedTotal} entit${finalizedTotal === 1 ? 'y' : 'ies'}` +
397
+ (wrote.length ? ` — ${wrote.join(', ')}` : '')
398
+ )
399
+ return { exitCode: 0 }
400
+ }
@@ -0,0 +1,274 @@
1
+ /**
2
+ * uniweb register — submit a foundation (and the data schemas it renders), or a
3
+ * standalone schemas package (the data schemas alone, no foundation), to the
4
+ * registry as one names-only `.uwx` document (uwx-format.md §5).
5
+ *
6
+ * `uniweb login && uniweb register`. Distinct from `uniweb publish` (which
7
+ * targets the legacy unicloud / uniweb-edge platform) — `register` talks to the
8
+ * registry over HTTP at a configurable endpoint.
9
+ *
10
+ * Run from a foundation, or from a schemas-only package — a package that exports
11
+ * schemas (e.g. `@uniweb/schemas`, any `@org/schemas`) or a bare `schemas/*.yml`
12
+ * folder. The schemas-only package is auto-detected and submits its data schemas
13
+ * standalone (foundation-less); same flags, `--scope` names them.
14
+ *
15
+ * Usage:
16
+ * uniweb register Build the .uwx and submit it
17
+ * uniweb register --scope @org Publish under @org (resolves @/x -> @org/x).
18
+ * Default: the package's package.json "uniweb.scope".
19
+ * uniweb register --dry-run Print the .uwx; submit nothing
20
+ * uniweb register -o foundation.uwx Write the .uwx to a file; submit nothing
21
+ * uniweb register --registry <url> Override the submit endpoint
22
+ * uniweb register --token <bearer> Submit with this bearer; skips `uniweb login`
23
+ *
24
+ * Endpoint resolution: --registry <url> > UNIWEB_REGISTER_URL > the local default.
25
+ * Auth (submit only): --token <bearer> > UNIWEB_TOKEN > `uniweb login` session.
26
+ */
27
+
28
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs'
29
+ import { resolve, join } from 'node:path'
30
+ import { buildRegistryPackage, buildSchemaOnlyPackage } from '@uniweb/build/uwx'
31
+ import { classifyPackage, isSchemasPackage, collectStandaloneSchemas } from '@uniweb/build'
32
+ import { ensureRegistryAuth, readRegistryAuth } from '../utils/registry-auth.js'
33
+ import { deriveScope } from '../utils/registry-orgs.js'
34
+ import { writeJsonPreservingStyleAsync } from '../utils/json-file.js'
35
+ import { findWorkspaceRoot, findFoundations, promptSelect } from '../utils/workspace.js'
36
+ import { isNonInteractive, getCliPrefix } from '../utils/interactive.js'
37
+
38
+ // The backend route is `/dev/registry/register`; the host defaults to a local
39
+ // server and is overridable via --registry / UNIWEB_REGISTER_URL (full URL).
40
+ const DEFAULT_REGISTER_URL = 'http://localhost:8080/dev/registry/register'
41
+
42
+ const colors = {
43
+ reset: '\x1b[0m', bright: '\x1b[1m', dim: '\x1b[2m',
44
+ red: '\x1b[31m', green: '\x1b[32m', yellow: '\x1b[33m', blue: '\x1b[36m',
45
+ }
46
+ const log = console.log
47
+ const success = (m) => log(`${colors.green}✓${colors.reset} ${m}`)
48
+ const error = (m) => console.error(`${colors.red}✗${colors.reset} ${m}`)
49
+ const info = (m) => log(`${colors.blue}→${colors.reset} ${m}`)
50
+
51
+ function flagValue(args, name) {
52
+ const eq = args.find((a) => a.startsWith(`${name}=`))
53
+ if (eq) return eq.slice(name.length + 1)
54
+ const i = args.indexOf(name)
55
+ if (i !== -1 && args[i + 1] && !args[i + 1].startsWith('-')) return args[i + 1]
56
+ return null
57
+ }
58
+
59
+ // The uniweb CLI version, for the `.uwx` exporter envelope. Safe fallback if the
60
+ // package.json isn't reachable.
61
+ function cliVersion() {
62
+ try {
63
+ return JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8')).version
64
+ } catch {
65
+ return '0.0.0'
66
+ }
67
+ }
68
+
69
+ // The foundation's recorded publish org, from its package.json `uniweb.scope`
70
+ // (`{ "uniweb": { "scope": "@acme" } }`) — the default when `--scope` is absent.
71
+ function readPkgScope(foundationDir) {
72
+ try {
73
+ const pkg = JSON.parse(readFileSync(join(foundationDir, 'package.json'), 'utf8'))
74
+ return pkg?.uniweb?.scope || null
75
+ } catch {
76
+ return null
77
+ }
78
+ }
79
+
80
+ // Record the chosen publish scope in the foundation's package.json so it travels
81
+ // with the foundation (read back by readPkgScope on the next register). Preserves
82
+ // the file's existing JSON style.
83
+ async function writePkgScope(foundationDir, scope) {
84
+ const path = join(foundationDir, 'package.json')
85
+ const pkg = JSON.parse(readFileSync(path, 'utf8'))
86
+ pkg.uniweb = { ...(pkg.uniweb || {}), scope }
87
+ await writeJsonPreservingStyleAsync(path, pkg)
88
+ }
89
+
90
+ // The package's name from its package.json, for display. The standalone schemas
91
+ // register has no foundation `_self` to name, so it labels by package name.
92
+ function readPkgName(dir) {
93
+ try {
94
+ return JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8'))?.name || null
95
+ } catch {
96
+ return null
97
+ }
98
+ }
99
+
100
+ /**
101
+ * Resolve which foundation to register: the cwd if it's a foundation, else the
102
+ * single foundation in the workspace, else prompt (or error in non-interactive).
103
+ * Mirrors `uniweb publish`.
104
+ */
105
+ async function resolveFoundationDir(args) {
106
+ const cwd = process.cwd()
107
+ if (classifyPackage(cwd) === 'foundation') return cwd
108
+
109
+ const workspaceRoot = findWorkspaceRoot(cwd)
110
+ if (workspaceRoot) {
111
+ const foundations = await findFoundations(workspaceRoot)
112
+ if (foundations.length === 1) return resolve(workspaceRoot, foundations[0])
113
+ if (foundations.length > 1) {
114
+ if (isNonInteractive(args)) {
115
+ error('Multiple foundations found. Run register from the one you mean.')
116
+ for (const f of foundations) log(` ${colors.cyan || ''}cd ${f} && ${getCliPrefix()} register${colors.reset}`)
117
+ process.exit(1)
118
+ }
119
+ const choice = await promptSelect('Which foundation?', foundations)
120
+ if (!choice) { log('\nRegister cancelled.'); process.exit(0) }
121
+ return resolve(workspaceRoot, choice)
122
+ }
123
+ }
124
+
125
+ error('No foundation found. Run register from a foundation directory or a workspace that has one.')
126
+ process.exit(1)
127
+ }
128
+
129
+ export async function register(args = []) {
130
+ const dryRun = args.includes('--dry-run')
131
+ const output = flagValue(args, '-o') || flagValue(args, '--output')
132
+ const scopeFlag = flagValue(args, '--scope')
133
+ const tokenFlag = flagValue(args, '--token')
134
+ const registryUrl = flagValue(args, '--registry') || process.env.UNIWEB_REGISTER_URL || DEFAULT_REGISTER_URL
135
+
136
+ // Target: a schemas-only package (standalone data-schema register) or a
137
+ // foundation (foundation + the schemas it renders). A schemas package is only
138
+ // detected when the cwd isn't a foundation, so the foundation path — including
139
+ // its workspace-scan + prompt (resolveFoundationDir) — is unchanged.
140
+ const standalone = isSchemasPackage(process.cwd())
141
+ const targetDir = standalone ? process.cwd() : await resolveFoundationDir(args)
142
+
143
+ // Scope: --scope flag, else package.json `uniweb.scope`, else (real submit
144
+ // only) derived from login membership in the bootstrap below.
145
+ const pkgScope = readPkgScope(targetDir)
146
+ let scope = scopeFlag || pkgScope
147
+ let scopeSource = scopeFlag ? '--scope' : pkgScope ? 'package.json uniweb.scope' : null
148
+ const isPreview = !!output || dryRun
149
+ const apiBase = new URL(registryUrl).origin
150
+ let token = null
151
+
152
+ // Each path supplies a different schema source: the standalone path discovers
153
+ // the package's own schemas; the foundation path reads its built schema.json.
154
+ let schema = null
155
+ let schemas = null
156
+ if (standalone) {
157
+ try {
158
+ schemas = await collectStandaloneSchemas(targetDir)
159
+ } catch (err) {
160
+ error(`Could not read the schemas package: ${err.message}`)
161
+ return { exitCode: 2 }
162
+ }
163
+ if (!schemas || Object.keys(schemas).length === 0) {
164
+ error('No data schemas found in this package.')
165
+ log(` ${colors.dim}Expected a package that exports schemas (getSchema / schemas), or a schemas/ directory of *.yml files.${colors.reset}`)
166
+ return { exitCode: 2 }
167
+ }
168
+ } else {
169
+ const schemaPath = join(targetDir, 'dist', 'meta', 'schema.json')
170
+ if (!existsSync(schemaPath)) {
171
+ error('No built schema found (dist/meta/schema.json).')
172
+ log(` Build the foundation first: ${colors.bright}uniweb build${colors.reset}`)
173
+ return { exitCode: 2 }
174
+ }
175
+ try {
176
+ schema = JSON.parse(readFileSync(schemaPath, 'utf8'))
177
+ } catch (err) {
178
+ error(`Could not read ${schemaPath}: ${err.message}`)
179
+ return { exitCode: 2 }
180
+ }
181
+ }
182
+
183
+ // No scope for a real submit → derive it from login membership (list → 1 use /
184
+ // 0 create / N pick), persist to package.json, and reuse the session token.
185
+ if (!scope && !isPreview) {
186
+ token = tokenFlag || (await ensureRegistryAuth({ apiBase, command: 'Registering', args }))
187
+ const sess = await readRegistryAuth()
188
+ const derived = await deriveScope({ apiBase, token, accountHandle: sess?.handle || null, args })
189
+ if (!derived) return { exitCode: 0 }
190
+ scope = `@${derived}`
191
+ scopeSource = 'login'
192
+ try {
193
+ await writePkgScope(targetDir, scope)
194
+ info(`Saved ${colors.bright}${scope}${colors.reset} as this ${standalone ? 'package' : 'foundation'}'s publish scope (package.json).`)
195
+ } catch {
196
+ log(` ${colors.dim}(Could not save the scope to package.json — pass --scope ${scope} next time.)${colors.reset}`)
197
+ }
198
+ }
199
+
200
+ const exporter = { tool: 'uniweb', version: cliVersion(), instance: 'build' }
201
+ let doc
202
+ try {
203
+ doc = standalone
204
+ ? buildSchemaOnlyPackage({ schemas, scope, exporter })
205
+ : buildRegistryPackage({ schema, foundationDir: targetDir, scope, exporter })
206
+ } catch (err) {
207
+ error(`Could not assemble the .uwx: ${err.message}`)
208
+ return { exitCode: 2 }
209
+ }
210
+ const json = JSON.stringify(doc, null, 2)
211
+
212
+ const defined = doc.entities.filter((e) => e.model === '@uniweb/data-schema').map((e) => e.name)
213
+ log('')
214
+ if (standalone) {
215
+ info(`${colors.bright}${readPkgName(targetDir) || 'schemas'}${colors.reset} ${colors.dim}(schemas-only — no foundation)${colors.reset}`)
216
+ } else {
217
+ info(`${colors.bright}${schema._self.name}@${schema._self.version}${colors.reset}`)
218
+ }
219
+ log(` ${colors.dim}data schemas ${standalone ? 'registered' : 'defined'}: ${defined.length ? defined.join(', ') : '(none)'}${colors.reset}`)
220
+ if (scope) log(` ${colors.dim}scope: ${scope} (${scopeSource})${colors.reset}`)
221
+
222
+ // Preview paths — no submit, no auth needed.
223
+ if (output) {
224
+ writeFileSync(resolve(output), json)
225
+ success(`Wrote ${output} (${doc.entities.length} entities) — not submitted`)
226
+ return { exitCode: 0 }
227
+ }
228
+ if (dryRun) {
229
+ log('')
230
+ log(json)
231
+ log('')
232
+ info(`Dry run — would submit to ${registryUrl}`)
233
+ return { exitCode: 0 }
234
+ }
235
+
236
+ // Submit requires a concrete scope — the registry rejects @/… fail-closed.
237
+ if (!scope) {
238
+ error('No publish scope — set "uniweb.scope" in package.json, or pass --scope @org.')
239
+ log(` ${colors.dim}Without a scope, names stay @/… and the registry rejects them.${colors.reset}`)
240
+ return { exitCode: 2 }
241
+ }
242
+ // Submit auth: reuse the token from the scope bootstrap if it ran, else
243
+ // --token › UNIWEB_TOKEN › stored session › login (ensureRegistryAuth).
244
+ token = token || tokenFlag || (await ensureRegistryAuth({ apiBase, command: 'Registering', args }))
245
+ info(`Submitting to ${colors.dim}${registryUrl}${colors.reset} …`)
246
+ let res
247
+ try {
248
+ res = await fetch(registryUrl, {
249
+ method: 'POST',
250
+ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
251
+ body: json,
252
+ })
253
+ } catch (err) {
254
+ error(`Could not reach the registry at ${registryUrl}: ${err.message}`)
255
+ log(` ${colors.dim}Set the endpoint with --registry <url> or UNIWEB_REGISTER_URL.${colors.reset}`)
256
+ return { exitCode: 2 }
257
+ }
258
+ if (!res.ok) {
259
+ error(`Registry rejected the submission: HTTP ${res.status} ${res.statusText}`)
260
+ if (res.status === 401 || res.status === 403) {
261
+ log(` ${colors.dim}The registry didn't accept your credentials — it may use different ones than \`uniweb login\`.${colors.reset}`)
262
+ log(` ${colors.dim}Supply a registry bearer with --token <bearer> (or UNIWEB_TOKEN); an existing one may be wrong or expired.${colors.reset}`)
263
+ }
264
+ const body = await res.text().catch(() => '')
265
+ if (body) log(` ${colors.dim}${body.slice(0, 500)}${colors.reset}`)
266
+ return { exitCode: 1 }
267
+ }
268
+ success(
269
+ standalone
270
+ ? `Registered ${defined.length} data schema(s)${scope ? ` under ${scope}` : ''}`
271
+ : `Registered ${schema._self.name}@${schema._self.version}${defined.length ? ` + ${defined.length} data schema(s)` : ''}`
272
+ )
273
+ return { exitCode: 0 }
274
+ }