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.
@@ -26,8 +26,8 @@
26
26
  * uniweb pull --registry <url> Override the backend origin
27
27
  * uniweb pull --token <bearer> Read with this bearer; skips `uniweb login`
28
28
  *
29
- * Endpoints: <origin>/dev/site/content/pull/{uuid} + /dev/site/folder/pull/{uuid},
30
- * both keyed by `site.yml::$uuid`. Origin from
29
+ * Backend: via BackendClient (the content + folder pull lanes), both keyed by
30
+ * `site.yml::$uuid`. Origin from
31
31
  * --registry > UNIWEB_REGISTER_URL > the local default.
32
32
  * Auth: --token > UNIWEB_TOKEN > `uniweb login` session.
33
33
  *
@@ -46,10 +46,9 @@ import {
46
46
  resolveCollectionsConfig,
47
47
  } from '@uniweb/build/uwx'
48
48
  import { makeModelResolver } from './push.js'
49
- import { ensureRegistryAuth } from '../utils/registry-auth.js'
49
+ import { BackendClient } from '../backend/client.js'
50
50
  import { resolveSiteDir as defaultResolveSiteDir } from './deploy.js'
51
51
 
52
- const DEFAULT_BACKEND_ORIGIN = 'http://localhost:8080'
53
52
  const FOLDER_MODEL = '@uniweb/folder'
54
53
 
55
54
  const colors = { reset: '\x1b[0m', bright: '\x1b[1m', dim: '\x1b[2m', red: '\x1b[31m', green: '\x1b[32m', blue: '\x1b[36m' }
@@ -111,22 +110,20 @@ export function splitCollectionsPull(payload) {
111
110
  * fetch), `resolveSiteDir`, `getToken` (skip auth).
112
111
  */
113
112
  export async function pull(args = [], deps = {}) {
114
- const fetchImpl = deps.fetch || globalThis.fetch
115
113
  const resolveSiteDir = deps.resolveSiteDir || defaultResolveSiteDir
116
114
 
117
115
  const dryRun = args.includes('--dry-run')
118
116
  const tokenFlag = flagValue(args, '--token')
119
117
  const prune = !(args.includes('--no-delete') || args.includes('--no-prune')) // git-like by default
120
118
  const noCollections = args.includes('--no-collections') || args.includes('--content-only')
121
- const registryFlag = flagValue(args, '--registry') || process.env.UNIWEB_REGISTER_URL || DEFAULT_BACKEND_ORIGIN
122
-
123
- let apiBase
124
- try {
125
- apiBase = new URL(registryFlag).origin
126
- } catch {
127
- error(`Invalid --registry / UNIWEB_REGISTER_URL: ${registryFlag}`)
128
- return { exitCode: 2 }
129
- }
119
+ const client = new BackendClient({
120
+ originFlag: flagValue(args, '--registry'),
121
+ token: tokenFlag,
122
+ getToken: deps.getToken,
123
+ fetchImpl: deps.fetch,
124
+ args,
125
+ command: 'Pulling',
126
+ })
130
127
 
131
128
  const siteDir = await resolveSiteDir(args, 'pull')
132
129
  // One identity per site: `site.yml::$uuid`. Both lanes (content + folder) are keyed
@@ -138,33 +135,22 @@ export async function pull(args = [], deps = {}) {
138
135
  return { exitCode: 0 }
139
136
  }
140
137
 
141
- // Lazy bearer — acquired on first GET (a dry run stays offline). Tests inject
142
- // deps.getToken to skip auth entirely.
143
- let cachedToken = null
144
- const getToken =
145
- deps.getToken ||
146
- (async () => {
147
- if (cachedToken) return cachedToken
148
- cachedToken = tokenFlag || process.env.UNIWEB_TOKEN || (await ensureRegistryAuth({ apiBase, command: 'Pulling', args }))
149
- return cachedToken
150
- })
151
-
152
138
  if (dryRun) {
153
- info(`Dry run — would GET ${colors.dim}${apiBase}/dev/site/content/pull/${siteContentUuid}${colors.reset}`)
154
- if (!noCollections) info(`Dry run — would GET ${colors.dim}${apiBase}/dev/site/folder/pull/${siteContentUuid}${colors.reset}`)
139
+ info(`Dry run — would pull content from ${colors.dim}${client.origin}${colors.reset}`)
140
+ if (!noCollections) info(`Dry run — would also pull collections`)
155
141
  return { exitCode: 0 }
156
142
  }
157
143
 
158
- // GET a pull lane and parse it as JSON. 404 null (deleted / no access); any
144
+ // GET a pull lane via the client and parse it as JSON. `doRequest` is a thunk
145
+ // returning the client's Response promise. 404 → null (deleted / no access); any
159
146
  // failure is reported and returns null (the lane is skipped, not fatal).
160
- const getJson = async (path, label) => {
161
- const url = `${apiBase}${path}`
162
- info(`Pulling ${colors.bright}${label}${colors.reset} from ${colors.dim}${url}${colors.reset} …`)
147
+ const getJson = async (label, doRequest) => {
148
+ info(`Pulling ${colors.bright}${label}${colors.reset} from ${colors.dim}${client.origin}${colors.reset} …`)
163
149
  let res
164
150
  try {
165
- res = await fetchImpl(url, { headers: { Authorization: `Bearer ${await getToken()}` } })
151
+ res = await doRequest()
166
152
  } catch (err) {
167
- error(`Could not reach the backend at ${url}: ${err.message}`)
153
+ error(`Could not reach the backend at ${client.origin}: ${err.message}`)
168
154
  note('Set the origin with --registry <url> or UNIWEB_REGISTER_URL.')
169
155
  return null
170
156
  }
@@ -192,7 +178,7 @@ export async function pull(args = [], deps = {}) {
192
178
 
193
179
  // Lane 1 — content → config + pages/** + layout/**.
194
180
  const siteDoc = extractDocument(
195
- await getJson(`/dev/site/content/pull/${encodeURIComponent(siteContentUuid)}`, 'content')
181
+ await getJson('content', () => client.pullSiteContent(siteContentUuid))
196
182
  )
197
183
  if (siteDoc) {
198
184
  const report = siteContentDocumentToProject({ document: siteDoc, siteRoot: siteDir, prune })
@@ -206,10 +192,10 @@ export async function pull(args = [], deps = {}) {
206
192
  // holds a folder uuid). Models are resolved by name (async) up front, so
207
193
  // collectionsToProject keeps its synchronous contract.
208
194
  if (!noCollections) {
209
- const payload = await getJson(`/dev/site/folder/pull/${encodeURIComponent(siteContentUuid)}`, 'collections')
195
+ const payload = await getJson('collections', () => client.pullFolder(siteContentUuid))
210
196
  if (payload) {
211
197
  const { folderDoc, recordDocs } = splitCollectionsPull(payload)
212
- const resolveModel = makeModelResolver({ apiBase, getToken, fetchImpl })
198
+ const resolveModel = makeModelResolver({ client })
213
199
  const declByModel = new Map()
214
200
  for (const model of [...new Set(recordDocs.map((d) => d.$model).filter(Boolean))]) {
215
201
  try {
@@ -7,12 +7,11 @@
7
7
  *
8
8
  * Each entity is an entity-content document (`$id` + `$model` + sections). The site
9
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`
10
+ * push has none — it CREATEs the site (uuid-less), the backend mints + adopts it
11
+ * and returns the new uuid, which `push` records into `site.yml`. Later pushes
12
+ * UPDATE by that uuid. The folder lane is keyed by the SAME site-content uuid
13
+ * the backend owns the site's `@uniweb/folder`, so the framework never holds a
14
+ * folder uuid. Records still round-trip their own `$uuid`
16
15
  * (back-filled into their source files). site-content is pushed wholesale (no per-item
17
16
  * uuids on the wire). Push-only, last-push-wins (`collision=force`) in v1.
18
17
  *
@@ -32,8 +31,7 @@
32
31
  * uniweb push --foundation <dir> Use this local foundation for the Model schema
33
32
  * uniweb push --all Send every record (bypass the changed-only cache)
34
33
  *
35
- * Endpoints: <origin>/dev/site/content (create), /dev/site/content/push/{uuid}
36
- * (update), /dev/site/folder/push/{uuid}. origin from
34
+ * Backend: via BackendClient (the content + folder sync lanes). Origin from
37
35
  * --registry > UNIWEB_REGISTER_URL > the local default.
38
36
  * Auth: --token > UNIWEB_TOKEN > `uniweb login` session.
39
37
  */
@@ -45,13 +43,9 @@ import {
45
43
  backfillEntityUuids,
46
44
  writeSiteEntityUuid,
47
45
  } from '@uniweb/build/uwx'
48
- import { ensureRegistryAuth } from '../utils/registry-auth.js'
46
+ import { BackendClient } from '../backend/client.js'
49
47
  import { resolveSiteDir } from './deploy.js'
50
48
 
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
49
  const colors = {
56
50
  reset: '\x1b[0m', bright: '\x1b[1m', dim: '\x1b[2m',
57
51
  red: '\x1b[31m', green: '\x1b[32m', yellow: '\x1b[33m', blue: '\x1b[36m',
@@ -95,7 +89,7 @@ function extractFinalized(payload) {
95
89
  .filter((e) => Number.isInteger(e.index) && e.uuid)
96
90
  }
97
91
 
98
- // Pull the minted site-content uuid out of a CREATE (`POST /dev/site/content`)
92
+ // Pull the minted site-content uuid out of a CREATE
99
93
  // response. The exact shape is an open backend item — tolerant of a bare
100
94
  // `{ siteContentUuid }` / `{ $uuid }` / `{ uuid }`, or the same `report.finalized[]`
101
95
  // envelope the update/folder lanes return (the site entity is submitted alone, so its
@@ -124,45 +118,15 @@ function changedSummary(finalized) {
124
118
  return parts.length ? parts.join(', ') : null
125
119
  }
126
120
 
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 }) {
121
+ // Resolve a Model NOT defined by the local foundation by reading its declaration
122
+ // (the `@uniweb/data-schema` form) from the backend via the client. Cached per run;
123
+ // HTTP 404 null (the emitter then says "register it first"). The bearer is acquired
124
+ // lazily by the client, so a fully-local sync never authenticates.
125
+ export function makeModelResolver({ client }) {
142
126
  const cache = new Map()
143
127
  return async (modelName) => {
144
128
  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
- }
129
+ const decl = await client.readDataSchema(modelName)
166
130
  cache.set(modelName, decl)
167
131
  return decl
168
132
  }
@@ -196,30 +160,18 @@ export async function push(args = []) {
196
160
  const asOrg = flagValue(args, '--as-org')
197
161
  const foundationDir = flagValue(args, '--foundation')
198
162
  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
- }
163
+ // One front door. The bearer is resolved lazily on first need (a non-local Model
164
+ // read during the build, or the submit), so a fully-local sync — and --dry-run / -o
165
+ // — never authenticate when every Model is defined by the local foundation.
166
+ const client = new BackendClient({
167
+ originFlag: flagValue(args, '--registry'),
168
+ token: tokenFlag,
169
+ args,
170
+ command: 'Syncing',
171
+ })
209
172
 
210
173
  const siteDir = await resolveSiteDir(args, 'push')
211
174
 
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
175
  // Build BOTH directional packages (the producer side). Each carries its own
224
176
  // `index` — the per-entity source-file map for back-fill, correlated by submission
225
177
  // position. Non-local Models are fetched from the registry on demand. `priorHashes`
@@ -229,7 +181,7 @@ export async function push(args = []) {
229
181
  try {
230
182
  pkg = await emitSyncPackages(siteDir, {
231
183
  ...(foundationDir ? { foundationDir } : {}),
232
- resolveModel: makeModelResolver({ apiBase, getToken }),
184
+ resolveModel: makeModelResolver({ client }),
233
185
  priorHashes,
234
186
  sendAll,
235
187
  })
@@ -266,41 +218,30 @@ export async function push(args = []) {
266
218
  }
267
219
  if (dryRun) {
268
220
  if (siteContent) {
269
- const route = siteContentUuid ? `/dev/site/content/push/${siteContentUuid}` : '/dev/site/content'
270
221
  const verb = siteContentUuid ? 'update' : 'create'
271
- info(`Dry run — would ${verb} content at ${colors.dim}${apiBase}${route}${colors.reset}`)
222
+ info(`Dry run — would ${verb} content at ${colors.dim}${client.origin}${colors.reset}`)
272
223
  }
273
224
  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}`)
225
+ info(`Dry run would push the folder at ${colors.dim}${client.origin}${colors.reset}`)
276
226
  }
277
227
  return { exitCode: 0 }
278
228
  }
279
229
 
280
- const token = await getToken()
281
230
  const wrote = []
282
231
  let finalizedTotal = 0
283
232
 
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} …`)
233
+ // POST one lane via the client and parse the JSON response. `doRequest` is a thunk
234
+ // returning the client's Response promise (so the "Pushing …" line prints before the
235
+ // request fires). The client carries `collision=force` (last-push-wins) + the optional
236
+ // `--as-org`. Returns the parsed payload, or null on any transport/HTTP/parse failure
237
+ // (already reported).
238
+ const postLane = async (label, doRequest) => {
239
+ info(`Pushing ${label} to ${colors.dim}${client.origin}${colors.reset} …`)
295
240
  let res
296
241
  try {
297
- res = await fetch(url, {
298
- method: 'POST',
299
- headers: { 'Content-Type': 'application/zip', Authorization: `Bearer ${token}` },
300
- body: buffer,
301
- })
242
+ res = await doRequest()
302
243
  } catch (err) {
303
- error(`Could not reach the backend at ${url}: ${err.message}`)
244
+ error(`Could not reach the backend at ${client.origin}: ${err.message}`)
304
245
  note('Set the origin with --registry <url> or UNIWEB_REGISTER_URL.')
305
246
  return null
306
247
  }
@@ -324,8 +265,8 @@ export async function push(args = []) {
324
265
  // POST a lane that round-trips entity uuids (content UPDATE + the folder): parse the
325
266
  // finalized list (for record back-fill + the changed summary). Returns the finalized
326
267
  // array, or null on failure (already reported).
327
- const pushLane = async (label, path, buffer) => {
328
- const payload = await postLane(label, path, buffer)
268
+ const pushLane = async (label, doRequest) => {
269
+ const payload = await postLane(label, doRequest)
329
270
  if (payload === null) return null
330
271
  const finalized = extractFinalized(payload)
331
272
  if (!finalized) {
@@ -347,13 +288,15 @@ export async function push(args = []) {
347
288
  if (siteContentUuid) {
348
289
  const finalized = await pushLane(
349
290
  'site-content',
350
- `/dev/site/content/push/${encodeURIComponent(siteContentUuid)}`,
351
- siteContent.buffer
291
+ () => client.updateSiteContent(siteContentUuid, siteContent.buffer, { asOrg })
352
292
  )
353
293
  if (!finalized) return { exitCode: 1 }
354
294
  finalizedTotal += finalized.length
355
295
  } else {
356
- const payload = await postLane('site-content', '/dev/site/content', siteContent.buffer)
296
+ const payload = await postLane(
297
+ 'site-content',
298
+ () => client.createSiteContent(siteContent.buffer, { asOrg })
299
+ )
357
300
  if (payload === null) return { exitCode: 1 }
358
301
  const minted = extractMintedSiteUuid(payload)
359
302
  if (!minted) {
@@ -379,8 +322,7 @@ export async function push(args = []) {
379
322
  }
380
323
  const finalized = await pushLane(
381
324
  'collections',
382
- `/dev/site/folder/push/${encodeURIComponent(boundSiteUuid)}`,
383
- collections.buffer
325
+ () => client.pushFolder(boundSiteUuid, collections.buffer, { asOrg })
384
326
  )
385
327
  if (!finalized) return { exitCode: 1 }
386
328
  const bf = backfillEntityUuids({ index: collections.index, finalized })