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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "uniweb",
3
- "version": "0.12.26",
3
+ "version": "0.12.27",
4
4
  "description": "Create structured Vite + React sites with content/code separation",
5
5
  "type": "module",
6
6
  "bin": {
@@ -42,13 +42,13 @@
42
42
  "prompts": "^2.4.2",
43
43
  "tar": "^7.0.0",
44
44
  "@uniweb/core": "0.7.12",
45
- "@uniweb/runtime": "0.8.16",
46
- "@uniweb/kit": "0.9.15"
45
+ "@uniweb/kit": "0.9.16",
46
+ "@uniweb/runtime": "0.8.16"
47
47
  },
48
48
  "peerDependencies": {
49
- "@uniweb/semantic-parser": "1.1.17",
50
- "@uniweb/build": "0.14.10",
51
- "@uniweb/content-reader": "1.1.12"
49
+ "@uniweb/build": "0.14.11",
50
+ "@uniweb/content-reader": "1.1.12",
51
+ "@uniweb/semantic-parser": "1.1.17"
52
52
  },
53
53
  "peerDependenciesMeta": {
54
54
  "@uniweb/build": {
@@ -794,6 +794,7 @@ id: about # Stable identity (for page: links, survives moves)
794
794
  order: 2 # Navigation sort position
795
795
  pages: [team, history, ...] # Child page order (... = rest). Without ... = strict (hides unlisted)
796
796
  redirect: academic # Redirect to child page (relative or absolute path, or URL)
797
+ slug: { fr: a-propos } # Localized URL segment per language (multilingual sites)
797
798
  ```
798
799
 
799
800
  **site.yml:**
@@ -805,6 +806,8 @@ pages: [home, about] # Strict: only listed pages in nav
805
806
 
806
807
  **Route mapping:** Folder structure maps 1:1 to routes. Every folder keeps its natural route — `pages:` controls **order only**, not which child "becomes" the parent. The only exception is the site root: `index:` (or first in `pages:`) in site.yml sets the homepage at `/`.
807
808
 
809
+ **Localized URLs:** On a multilingual site (`languages:` in site.yml), a page's `slug: { <lang>: <segment> }` gives it a native URL segment per language (`/about` → `/fr/a-propos`); the folder name stays the canonical route. Nested folders compose automatically, and localized URLs flow through navigation, the language switcher, and the sitemap.
810
+
808
811
  **Content-less containers:** Folders with `page.yml` but no markdown are structural groups. They appear in `getPageHierarchy()` with `hasContent: false` and their own title/label. When visited directly, the runtime auto-redirects to the first descendant with content. This supports hierarchical navigation (courses → modules → lessons) at any depth.
809
812
 
810
813
  ### Lists as Navigation Menus
@@ -0,0 +1,339 @@
1
+ /**
2
+ * BackendClient — the single front door to the Uniweb backend.
3
+ *
4
+ * Every CLI verb that talks to the backend (register, push, pull, clone, org,
5
+ * login — and, as the consolidation proceeds, publish/deploy/invite/handoff)
6
+ * goes through one instance of this client instead of hand-rolling
7
+ * `fetch(… Authorization: Bearer …)` against a per-command default origin. It
8
+ * owns the three things that were previously scattered across a dozen files:
9
+ *
10
+ * 1. ORIGIN — where the backend is. resolveBackendOrigin(): an explicit
11
+ * flag (`--backend` / `--registry`) > UNIWEB_REGISTER_URL >
12
+ * the local default. Any full URL is reduced to its origin.
13
+ * 2. AUTH — the opaque session bearer (utils/registry-auth.js). Resolved
14
+ * LAZILY on the first authed call, so dry-runs and fully-local
15
+ * work never trigger a login.
16
+ * 3. REQUESTS — one request() helper (auth header, query params, body
17
+ * content-type) plus a typed method per backend operation.
18
+ *
19
+ * The legacy platform paths (the old per-verb URL resolvers, the separate
20
+ * token-based auth, and the remote/local registry classes) are deliberately
21
+ * NOT represented here — consolidating every verb onto this client is what
22
+ * retires them.
23
+ *
24
+ * During Phase 1 the typed methods delegate to the existing, well-tested
25
+ * helpers (registry-auth.js, registry-orgs.js, code-upload.js); those modules
26
+ * move under backend/ as the consolidation lands, leaving this client as their
27
+ * single home.
28
+ */
29
+
30
+ import { getRegistryApiBaseUrl } from '../utils/config.js'
31
+ import { ensureRegistryAuth, fetchMe } from '../utils/registry-auth.js'
32
+ import { fetchOrgs as fetchOrgsImpl, createOrg as createOrgImpl } from '../utils/registry-orgs.js'
33
+ import { uploadFoundationCode } from '../utils/code-upload.js'
34
+ import { uploadSiteAssets } from '../utils/asset-upload.js'
35
+ import { uploadRuntime } from '../utils/runtime-upload.js'
36
+
37
+ /**
38
+ * Resolve the backend origin: an explicit flag value wins; otherwise defer to
39
+ * the shared origin resolver (env override > saved config > local default). A
40
+ * full URL is reduced to its origin, so callers may pass a whole endpoint URL.
41
+ *
42
+ * One resolver, one place to revisit when we settle the single-origin-input +
43
+ * discovery-handshake decision — kept delegating for now so this stays a pure,
44
+ * behavior-preserving consolidation.
45
+ *
46
+ * @param {string} [flag] - the raw value of --backend / --registry, if supplied
47
+ * @returns {string} a bare origin with no trailing slash
48
+ */
49
+ export function resolveBackendOrigin(flag) {
50
+ if (flag) {
51
+ try { return new URL(flag).origin } catch { /* not a URL — fall through */ }
52
+ }
53
+ return getRegistryApiBaseUrl()
54
+ }
55
+
56
+ /**
57
+ * The fallback capability doc when `GET /dev/config` is absent or unreachable
58
+ * (an older backend, or no backend at all). Keeps the client non-breaking: the
59
+ * bases mirror a self-serve dev backend, `assetBase` falls back to the historical
60
+ * production CDN host so published-site asset resolution is unchanged, and
61
+ * `runtime.installed` is empty so runtime resolution requires an explicit pin.
62
+ */
63
+ export const DISCOVERY_DEFAULTS = {
64
+ gatewayBase: '/gateway',
65
+ assetBase: 'https://assets.uniweb.app/',
66
+ auth: { loginPath: '/dev/auth/login', required: true },
67
+ delivery: { deploy: true, publish: true, broker: 'self-serve' },
68
+ assets: { supported: false },
69
+ runtime: { installed: [] },
70
+ }
71
+
72
+ export class BackendClient {
73
+ /**
74
+ * @param {object} [opts]
75
+ * @param {string} [opts.origin] - explicit origin (wins over originFlag/env)
76
+ * @param {string} [opts.originFlag] - raw --backend/--registry value to resolve
77
+ * @param {string} [opts.token] - explicit bearer (wins over env + stored session)
78
+ * @param {() => Promise<string>} [opts.getToken] - injected bearer resolver (tests, or
79
+ * callers with their own auth); used when no explicit token/env is present
80
+ * @param {string[]} [opts.args] - argv slice (checked for --non-interactive in auth)
81
+ * @param {string} [opts.command] - label for the login prompt ('Pushing', 'Registering', …)
82
+ * @param {typeof fetch} [opts.fetchImpl] - injectable fetch (tests)
83
+ */
84
+ constructor({ origin, originFlag, token, getToken, args = [], command = 'This command', fetchImpl } = {}) {
85
+ this.origin = (origin || resolveBackendOrigin(originFlag)).replace(/\/+$/, '')
86
+ this._token = token || process.env.UNIWEB_TOKEN || null
87
+ this._getToken = getToken || null
88
+ this._args = args
89
+ this._command = command
90
+ this._fetch = fetchImpl || ((url, init) => globalThis.fetch(url, init))
91
+ this._discovery = null
92
+ }
93
+
94
+ /**
95
+ * The session bearer, resolved lazily and memoized. Order (matching the
96
+ * standalone verbs): explicit --token / UNIWEB_TOKEN (constructor) > stored
97
+ * session > interactive login (ensureRegistryAuth).
98
+ * @returns {Promise<string>}
99
+ */
100
+ async token() {
101
+ if (this._token) return this._token
102
+ if (this._getToken) {
103
+ this._token = await this._getToken()
104
+ return this._token
105
+ }
106
+ this._token = await ensureRegistryAuth({ apiBase: this.origin, command: this._command, args: this._args })
107
+ return this._token
108
+ }
109
+
110
+ /**
111
+ * Low-level request against the backend. Adds the bearer (unless auth:false),
112
+ * applies query params, and infers a content-type from the body when unset
113
+ * (string → application/json, Buffer/Uint8Array → application/zip). Returns
114
+ * the raw Response so callers branch on status themselves (409 resume,
115
+ * 404 → null, 401/403 messaging, …).
116
+ *
117
+ * @param {string} path - leading-slash path, e.g. '/dev/site/content'
118
+ * @param {object} [opts]
119
+ * @param {string} [opts.method='GET']
120
+ * @param {*} [opts.body]
121
+ * @param {Record<string,string>} [opts.headers]
122
+ * @param {Record<string,string|number|undefined>} [opts.query]
123
+ * @param {boolean} [opts.auth=true]
124
+ * @returns {Promise<Response>}
125
+ */
126
+ async request(path, { method = 'GET', body, headers = {}, query, auth = true } = {}) {
127
+ const url = new URL(path, this.origin)
128
+ if (query) {
129
+ for (const [k, v] of Object.entries(query)) {
130
+ if (v != null) url.searchParams.set(k, String(v))
131
+ }
132
+ }
133
+ const h = { ...headers }
134
+ if (auth) h.Authorization = `Bearer ${await this.token()}`
135
+ if (body != null && h['Content-Type'] == null) {
136
+ if (typeof body === 'string') h['Content-Type'] = 'application/json'
137
+ else if (body instanceof Uint8Array || Buffer.isBuffer(body)) h['Content-Type'] = 'application/zip'
138
+ }
139
+ return this._fetch(url.href, { method, headers: h, body })
140
+ }
141
+
142
+ // ── Discovery ─────────────────────────────────────────────────────────────────
143
+
144
+ /**
145
+ * GET /dev/config — the anonymous capability/handshake document. The one route
146
+ * that answers before login (`auth: false`). Lazy + cached for the client's
147
+ * lifetime; a missing route or any transport/parse error falls back to
148
+ * DISCOVERY_DEFAULTS (non-breaking — an older backend still works). Lets the
149
+ * CLI hardcode nothing about a backend but its origin and discover the rest:
150
+ * `gatewayBase`/`assetBase` (serve/asset roots — relative ⇒ relative-to-origin),
151
+ * `auth`, `delivery` (deploy/publish? broker), `assets` (lane built yet?),
152
+ * `runtime.installed` (the default-runtime source replacing the old /runtime/latest).
153
+ * @returns {Promise<object>}
154
+ */
155
+ async discover() {
156
+ if (this._discovery) return this._discovery
157
+ try {
158
+ const res = await this.request('/dev/config', { auth: false })
159
+ this._discovery = res.ok ? await res.json() : { ...DISCOVERY_DEFAULTS }
160
+ } catch {
161
+ this._discovery = { ...DISCOVERY_DEFAULTS }
162
+ }
163
+ return this._discovery
164
+ }
165
+
166
+ // ── Identity ────────────────────────────────────────────────────────────────
167
+
168
+ /** GET /dev/auth/me → the account object ({ uuid, username, handle }) or null. */
169
+ async whoami() {
170
+ return fetchMe({ apiBase: this.origin, token: await this.token() })
171
+ }
172
+
173
+ // ── Registry: foundations + data schemas ──────────────────────────────────────
174
+
175
+ /**
176
+ * POST /dev/registry/register — submit a names-only .uwx document (a
177
+ * foundation schema + the data schemas it renders, or a standalone schemas
178
+ * package). Returns the raw Response (register branches on a 409 "already
179
+ * registered" to resume code delivery).
180
+ * @param {string} uwxJson - the serialized .uwx (a JSON string)
181
+ * @returns {Promise<Response>}
182
+ */
183
+ async register(uwxJson) {
184
+ return this.request('/dev/registry/register', { method: 'POST', body: uwxJson })
185
+ }
186
+
187
+ /**
188
+ * Deliver a built foundation's dist/ code (plan → PUT-per-file → verify).
189
+ * Thin pass-through to utils/code-upload.js with this client's origin + token.
190
+ * @param {object} opts - { name, version, distDir, files?, onProgress? }
191
+ */
192
+ async uploadFoundationCode(opts) {
193
+ return uploadFoundationCode({ apiBase: this.origin, token: await this.token(), ...opts })
194
+ }
195
+
196
+ /**
197
+ * GET /dev/registry/data-schemas/{scope}/{name} — a Model declaration, or
198
+ * null on 404 (the caller then says "register it first"). Accepts `@scope/name`
199
+ * and bare `name`.
200
+ * @param {string} modelName
201
+ * @returns {Promise<object|null>}
202
+ */
203
+ async readDataSchema(modelName) {
204
+ const res = await this.request(dataSchemaPath(modelName))
205
+ if (res.status === 404) return null
206
+ if (!res.ok) throw new Error(`Model-read ${modelName} failed: HTTP ${res.status} ${res.statusText}`)
207
+ return res.json()
208
+ }
209
+
210
+ // ── Orgs ──────────────────────────────────────────────────────────────────────
211
+
212
+ /** GET /dev/orgs → { account_handle, personal_org_exists, orgs[] }. */
213
+ async fetchOrgs() {
214
+ return fetchOrgsImpl({ apiBase: this.origin, token: await this.token() })
215
+ }
216
+
217
+ /** POST /dev/orgs { handle } → { handle, uuid, is_primary }. Throws with the server's detail on 409/422. */
218
+ async createOrg(handle) {
219
+ return createOrgImpl({ apiBase: this.origin, token: await this.token(), handle })
220
+ }
221
+
222
+ // ── Site sync (push / pull) ─────────────────────────────────────────────────────
223
+
224
+ /** POST /dev/site/content — CREATE a site from its content lane (.uwx zip). */
225
+ async createSiteContent(buffer, { asOrg } = {}) {
226
+ return this.request('/dev/site/content', { method: 'POST', body: buffer, query: pushQuery(asOrg) })
227
+ }
228
+
229
+ /** POST /dev/site/content/push/{uuid} — UPDATE the content lane by site uuid (.uwx zip). */
230
+ async updateSiteContent(uuid, buffer, { asOrg } = {}) {
231
+ return this.request(`/dev/site/content/push/${encodeURIComponent(uuid)}`, {
232
+ method: 'POST', body: buffer, query: pushQuery(asOrg),
233
+ })
234
+ }
235
+
236
+ /** POST /dev/site/folder/push/{uuid} — push the folder lane, keyed by the site uuid (.uwx zip). */
237
+ async pushFolder(uuid, buffer, { asOrg } = {}) {
238
+ return this.request(`/dev/site/folder/push/${encodeURIComponent(uuid)}`, {
239
+ method: 'POST', body: buffer, query: pushQuery(asOrg),
240
+ })
241
+ }
242
+
243
+ /** GET /dev/site/content/pull/{uuid} — the content lane document. */
244
+ async pullSiteContent(uuid) {
245
+ return this.request(`/dev/site/content/pull/${encodeURIComponent(uuid)}`)
246
+ }
247
+
248
+ /** GET /dev/site/folder/pull/{uuid} — the folder lane (folder + record documents). */
249
+ async pullFolder(uuid) {
250
+ return this.request(`/dev/site/folder/pull/${encodeURIComponent(uuid)}`)
251
+ }
252
+
253
+ // ── Delivery: deploy + site publish ─────────────────────────────────────────────
254
+
255
+ /**
256
+ * POST /dev/deploy — dumb, file-built delivery. Body is the deploy payload (the
257
+ * runtime-init JSON `build-site-data.js` produces — foundation, runtimeVersion,
258
+ * theme, languages, locales, optional dataFiles/searchFiles) plus an optional
259
+ * `site_uuid`. First deploy of a never-synced site omits it → the backend mints
260
+ * a uuid and returns it for write-back to deploy.yml; later deploys resend it so
261
+ * `/gateway/site/{uuid}/` stays stable. Returns the raw Response so the caller
262
+ * messages its own errors and reads `{ site_uuid, url, locales }` on 200.
263
+ * @param {object} payload - the deploy payload (universal currency)
264
+ * @param {object} [opts]
265
+ * @param {string} [opts.siteUuid] - a previously-minted delivery uuid
266
+ * @returns {Promise<Response>}
267
+ */
268
+ async deploy(payload, { siteUuid } = {}) {
269
+ const body = siteUuid ? { ...payload, site_uuid: siteUuid } : payload
270
+ return this.request('/dev/deploy', { method: 'POST', body: JSON.stringify(body) })
271
+ }
272
+
273
+ /**
274
+ * POST /dev/site/publish/{uuid} — CMS-publish a synced site (make its CURRENT
275
+ * backend state live; it does NOT push local files). `{uuid}` is the site-content
276
+ * uuid (`site.yml::$uuid`); a never-synced site 404s (sync first, or use deploy).
277
+ * The CLI knows the runtime from its build; the body carries it snake-cased per
278
+ * the route contract. Returns the raw Response ({ deploy_uuid, url,
279
+ * published_folder_uuid, status } on 200).
280
+ * @param {string} uuid - the site-content uuid
281
+ * @param {object} opts
282
+ * @param {string} opts.runtimeVersion
283
+ * @param {string[]} [opts.languages]
284
+ * @returns {Promise<Response>}
285
+ */
286
+ async publishSite(uuid, { runtimeVersion, languages } = {}) {
287
+ // Runtime rides as a query param (?runtime=<version>) per the shipped /dev
288
+ // route (D3, "request-carried"), NOT the body. Languages, when present, go in
289
+ // the body; absent → no body (the route only requires the runtime).
290
+ return this.request(`/dev/site/publish/${encodeURIComponent(uuid)}`, {
291
+ method: 'POST',
292
+ query: { runtime: runtimeVersion },
293
+ ...(languages ? { body: JSON.stringify({ languages }) } : {}),
294
+ })
295
+ }
296
+
297
+ /**
298
+ * POST /dev/site/unpublish/{uuid} — drop the published-folder gate so /gateway
299
+ * stops serving the site's dynamic content. Returns the raw Response ({ was_published }).
300
+ * @param {string} uuid - the site-content uuid
301
+ * @returns {Promise<Response>}
302
+ */
303
+ async unpublishSite(uuid) {
304
+ return this.request(`/dev/site/unpublish/${encodeURIComponent(uuid)}`, { method: 'POST' })
305
+ }
306
+
307
+ /**
308
+ * Deliver a site's processed assets (plan → PUT-per-file) to the backend's
309
+ * content-addressed store. Thin pass-through to utils/asset-upload.js with this
310
+ * client's origin + token; returns the `localUrl → { id, ext }` rewrite map the
311
+ * deploy step turns into durable `{assetBase}dist/{id}/base.{ext}` serve URLs.
312
+ * @param {object} opts - { distDir, files?, onProgress? }
313
+ */
314
+ async uploadSiteAssets(opts) {
315
+ return uploadSiteAssets({ apiBase: this.origin, token: await this.token(), ...opts })
316
+ }
317
+
318
+ /**
319
+ * Upload a built `@uniweb/runtime` to the runtime registry (plan → PUT-per-file),
320
+ * served at `/gateway/runtime/{version}`. @std-gated on the backend. Thin
321
+ * pass-through to utils/runtime-upload.js with this client's origin + token.
322
+ * @param {object} opts - { version, distDir, files?, onProgress? }
323
+ */
324
+ async uploadRuntime(opts) {
325
+ return uploadRuntime({ apiBase: this.origin, token: await this.token(), ...opts })
326
+ }
327
+ }
328
+
329
+ /** `@scope/name` → /dev/registry/data-schemas/{scope}/{name}; a bare name → …/{name}. */
330
+ export function dataSchemaPath(modelName) {
331
+ const m = /^@([^/]+)\/(.+)$/.exec(modelName)
332
+ if (m) return `/dev/registry/data-schemas/${encodeURIComponent(m[1])}/${encodeURIComponent(m[2])}`
333
+ return `/dev/registry/data-schemas/${encodeURIComponent(modelName)}`
334
+ }
335
+
336
+ /** The shared push query: last-push-wins, plus an optional acting-org. */
337
+ function pushQuery(asOrg) {
338
+ return { collision: 'force', ...(asOrg ? { as_org: asOrg } : {}) }
339
+ }
@@ -12,8 +12,8 @@
12
12
  * `npx uniweb clone`, same reason utils/workspace.js loads the classifier lazily).
13
13
  * So clone does the minimum itself and delegates the heavy lifting:
14
14
  *
15
- * 1. plain `fetch` GET <origin>/dev/site/content/pull/<uuid>read the `foundation`
16
- * ref out of that one document (no `@uniweb/build` needed for a GET);
15
+ * 1. read the site-content document via the backend client pull the `foundation`
16
+ * ref out of that one document (no `@uniweb/build` needed for a read);
17
17
  * 2. scaffold the HARNESS — a full Vite site package whose foundation is
18
18
  * REFERENCED (runtime-loaded), no local foundation sibling (scaffoldSite with
19
19
  * foundationRef and no foundationPath) + AGENTS.md + deps pinned to this CLI's
@@ -39,7 +39,7 @@
39
39
  * uniweb clone <uuid> --project docs Co-located docs/site
40
40
  * uniweb clone <uuid> --no-collections Pull pages only; skip collection records
41
41
  *
42
- * Endpoints: <origin>/dev/site/content/pull/<uuid>. Origin from
42
+ * Backend: via BackendClient (the site-content pull lane). Origin from
43
43
  * --registry > UNIWEB_REGISTER_URL > the local default (internal dev overrides;
44
44
  * not the user-facing path — `uniweb login` determines the origin).
45
45
  * Auth: --token > UNIWEB_TOKEN > `uniweb login` session.
@@ -53,12 +53,10 @@ import { resolvePlacement, SITE_KIND } from '../utils/placement.js'
53
53
  import { findWorkspaceRoot } from '../utils/workspace.js'
54
54
  import { addWorkspaceGlob } from '../utils/config.js'
55
55
  import { detectWorkspacePm, installCmd } from '../utils/pm.js'
56
- import { ensureRegistryAuth } from '../utils/registry-auth.js'
56
+ import { BackendClient } from '../backend/client.js'
57
57
  import { isNonInteractive, getCliPrefix } from '../utils/interactive.js'
58
58
  import { extractFoundationRef } from '../utils/site-content-refs.js'
59
59
 
60
- const DEFAULT_BACKEND_ORIGIN = 'http://localhost:8080'
61
-
62
60
  const colors = {
63
61
  reset: '\x1b[0m', bright: '\x1b[1m', dim: '\x1b[2m',
64
62
  red: '\x1b[31m', green: '\x1b[32m', yellow: '\x1b[33m', blue: '\x1b[36m', cyan: '\x1b[36m',
@@ -150,8 +148,6 @@ function pullExecArgv(pm, extra) {
150
148
  * runPull(siteDir, pm, extraArgs).
151
149
  */
152
150
  export async function clone(args = [], deps = {}) {
153
- const fetchImpl = deps.fetch || globalThis.fetch
154
-
155
151
  const positionals = args.filter((a) => !a.startsWith('-'))
156
152
  const siteUuid = positionals[0]
157
153
  const target = positionals[1] || null // [name|.]
@@ -167,31 +163,21 @@ export async function clone(args = [], deps = {}) {
167
163
  const pathFlag = flagValue(args, '--path')
168
164
  const projectFlag = flagValue(args, '--project')
169
165
  const tokenFlag = flagValue(args, '--token')
170
- const registryFlag = flagValue(args, '--registry') || process.env.UNIWEB_REGISTER_URL || DEFAULT_BACKEND_ORIGIN
171
-
172
- let apiBase
173
- try {
174
- apiBase = new URL(registryFlag).origin
175
- } catch {
176
- error(`Invalid --registry / UNIWEB_REGISTER_URL: ${registryFlag}`)
177
- return { exitCode: 2 }
178
- }
179
-
180
- let cachedToken = null
181
- const getToken =
182
- deps.getToken ||
183
- (async () => {
184
- if (cachedToken) return cachedToken
185
- cachedToken = tokenFlag || process.env.UNIWEB_TOKEN || (await ensureRegistryAuth({ apiBase, command: 'Cloning', args }))
186
- return cachedToken
187
- })
166
+ const explicitRegistry = flagValue(args, '--registry')
167
+ const client = new BackendClient({
168
+ originFlag: explicitRegistry,
169
+ token: tokenFlag,
170
+ getToken: deps.getToken,
171
+ fetchImpl: deps.fetch,
172
+ args,
173
+ command: 'Cloning',
174
+ })
188
175
 
189
- // 1. GET the site-content document (plain fetch — no @uniweb/build).
190
- const url = `${apiBase}/dev/site/content/pull/${encodeURIComponent(siteUuid)}`
191
- info(`Reading site ${colors.bright}${siteUuid}${colors.reset} from ${colors.dim}${url}${colors.reset} …`)
176
+ // 1. GET the site-content document (no @uniweb/build needed for a read).
177
+ info(`Reading site ${colors.bright}${siteUuid}${colors.reset} from ${colors.dim}${client.origin}${colors.reset} …`)
192
178
  let payload
193
179
  try {
194
- const res = await fetchImpl(url, { headers: { Authorization: `Bearer ${await getToken()}` } })
180
+ const res = await client.pullSiteContent(siteUuid)
195
181
  if (res.status === 404) {
196
182
  error(`Site not found (404) — check the uuid, or you lack access.`)
197
183
  return { exitCode: 1 }
@@ -203,7 +189,7 @@ export async function clone(args = [], deps = {}) {
203
189
  }
204
190
  payload = await res.json()
205
191
  } catch (err) {
206
- error(`Could not reach the backend at ${url}: ${err.message}`)
192
+ error(`Could not reach the backend at ${client.origin}: ${err.message}`)
207
193
  return { exitCode: 1 }
208
194
  }
209
195
 
@@ -309,7 +295,7 @@ export async function clone(args = [], deps = {}) {
309
295
  }
310
296
 
311
297
  const pullExtra = []
312
- if (flagValue(args, '--registry')) pullExtra.push('--registry', registryFlag)
298
+ if (explicitRegistry) pullExtra.push('--registry', explicitRegistry)
313
299
  if (tokenFlag) pullExtra.push('--token', tokenFlag)
314
300
  if (noCollections) pullExtra.push('--no-collections')
315
301