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.
- package/README.md +29 -0
- package/package.json +7 -7
- package/partials/agents.md +41 -11
- package/partials/config-reference.hbs +1 -2
- package/src/commands/add.js +1 -87
- package/src/commands/build.js +2 -2
- package/src/commands/clone.js +337 -0
- package/src/commands/content.js +199 -0
- package/src/commands/deploy.js +27 -6
- package/src/commands/docs.js +2 -3
- package/src/commands/org.js +66 -0
- package/src/commands/pull.js +238 -0
- package/src/commands/push.js +400 -0
- package/src/commands/register.js +274 -0
- package/src/commands/validate.js +288 -0
- package/src/framework-index.json +11 -10
- package/src/index.js +123 -3
- package/src/templates/processor.js +41 -19
- package/src/utils/config.js +21 -0
- package/src/utils/placement.js +100 -0
- package/src/utils/registry-auth.js +380 -0
- package/src/utils/registry-orgs.js +179 -0
- package/src/utils/scaffold.js +14 -3
- package/src/utils/site-content-refs.js +21 -0
- package/templates/foundation/_gitignore +5 -0
- package/templates/site/_gitignore +5 -0
- package/templates/site/package.json.hbs +2 -2
- package/templates/workspace/_gitignore +33 -0
|
@@ -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
|
+
}
|