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,199 @@
1
+ /**
2
+ * Content Command — `uniweb content export`
3
+ *
4
+ * Packages a site project (or a built foundation's schema) as a `.uwx`
5
+ * entity package — the Uniweb exchange format — for import into Uniweb.
6
+ * No JS/HTML: pure content / declarative data.
7
+ *
8
+ * uniweb content export Package the site in the cwd
9
+ * uniweb content export <dir> Package a specific site/foundation
10
+ * uniweb content export -o team.uwx Choose the output filename
11
+ * uniweb content export --no-sidecar Mint fresh ids (submit-once;
12
+ * default is the syncable round trip,
13
+ * persisting ids in .uniweb/uwx-ids.json)
14
+ * uniweb content export --dry-run Print a summary; write nothing
15
+ * uniweb content export --source-locale fr Wrap single-language fields under
16
+ * this locale (default: en)
17
+ *
18
+ * A site dir is detected by `site.yml`; a foundation by a built
19
+ * `dist/meta/schema.json` (run `uniweb build` first for a foundation).
20
+ */
21
+
22
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs'
23
+ import { join, basename, resolve } from 'node:path'
24
+
25
+ const c = {
26
+ reset: '\x1b[0m',
27
+ dim: '\x1b[2m',
28
+ cyan: '\x1b[36m',
29
+ green: '\x1b[32m',
30
+ red: '\x1b[31m',
31
+ yellow: '\x1b[33m',
32
+ }
33
+ const say = {
34
+ ok: (m) => console.log(`${c.green}✓${c.reset} ${m}`),
35
+ info: (m) => console.log(`${c.cyan}→${c.reset} ${m}`),
36
+ err: (m) => console.error(`${c.red}✗${c.reset} ${m}`),
37
+ warn: (m) => console.log(`${c.yellow}!${c.reset} ${m}`),
38
+ dim: (m) => console.log(` ${c.dim}${m}${c.reset}`),
39
+ }
40
+
41
+ function usage() {
42
+ console.log(`
43
+ ${c.cyan}uniweb content export${c.reset} [dir] [options]
44
+
45
+ Package a site project (or built foundation schema) as a .uwx entity
46
+ package for import into Uniweb.
47
+
48
+ ${c.dim}-o, --output <file>${c.reset} Output path (default: <name>.uwx)
49
+ ${c.dim}--no-sidecar${c.reset} Mint fresh ids (submit-once) instead of
50
+ the syncable round trip
51
+ ${c.dim}--dry-run${c.reset} Print a summary; write nothing
52
+ ${c.dim}--source-locale <code>${c.reset} Locale for single-language fields (default: en)
53
+ `)
54
+ }
55
+
56
+ export async function content(args = []) {
57
+ const sub = args[0]
58
+ if (!sub || sub === '--help' || sub === '-h') {
59
+ usage()
60
+ return
61
+ }
62
+ if (sub === 'export') {
63
+ await contentExport(args.slice(1))
64
+ return
65
+ }
66
+ say.err(`Unknown subcommand: content ${sub}`)
67
+ usage()
68
+ process.exit(1)
69
+ }
70
+
71
+ async function contentExport(args) {
72
+ const { readFlagValue } = await import('../utils/args.js')
73
+ const dryRun = args.includes('--dry-run')
74
+ const noSidecar = args.includes('--no-sidecar')
75
+ const sourceLocale = readFlagValue(args, '--source-locale') || undefined
76
+ let output = readFlagValue(args, '-o')
77
+ if (!output || output === true) output = readFlagValue(args, '--output')
78
+ const dir = resolve(
79
+ args.find((a) => !a.startsWith('-') && a !== output) || process.cwd()
80
+ )
81
+
82
+ const isSite = existsSync(join(dir, 'site.yml'))
83
+ const schemaPath = join(dir, 'dist', 'meta', 'schema.json')
84
+ const isFoundation = !isSite && existsSync(schemaPath)
85
+
86
+ if (!isSite && !isFoundation) {
87
+ say.err(`No site.yml or built dist/meta/schema.json found in ${dir}`)
88
+ say.dim('Point at a site directory, or `uniweb build` a foundation first.')
89
+ process.exit(1)
90
+ }
91
+
92
+ const uwx = await import('@uniweb/build/uwx')
93
+ // --dry-run must have zero side effects: mint ids (no sidecar file).
94
+ const useSidecar = !dryRun && !noSidecar
95
+
96
+ let buf
97
+ let defaultName
98
+ try {
99
+ if (isSite) {
100
+ say.info(`Packaging site → @uniweb/site-content (.uwx)…`)
101
+ // Nested $-document on the sync lane. The sidecar is read-only here: it
102
+ // supplies $uuids a prior sync recorded (the backend mints, never this
103
+ // export), so a fresh project exports a uuid-less document.
104
+ buf = await uwx.emitSiteSyncPackage(dir, {
105
+ sidecar: useSidecar, // read <dir>/.uniweb/uwx-ids.json if present
106
+ sourceLocale,
107
+ })
108
+ defaultName = basename(dir)
109
+ } else {
110
+ const schema = JSON.parse(readFileSync(schemaPath, 'utf8'))
111
+ say.info(`Packaging foundation schema → @uniweb/foundation-schema (.uwx)…`)
112
+ buf = uwx.emitFoundationSchemaPackage(schema, {
113
+ sidecar: useSidecar ? join(dir, uwx.SIDECAR_RELPATH) : undefined,
114
+ foundationDir: dir,
115
+ sourceLocale,
116
+ })
117
+ defaultName = (schema?._self?.name || basename(dir)).replace(
118
+ /[^a-z0-9._-]+/gi,
119
+ '-'
120
+ )
121
+ }
122
+ } catch (err) {
123
+ say.err(err.message)
124
+ process.exit(1)
125
+ }
126
+
127
+ // Structural summary (not an import simulation). The entity file is located
128
+ // via the manifest index, which works for both lanes: the register lane
129
+ // (foundation schema, flat `items[]`) and the sync lane (site, nested
130
+ // `$`-document).
131
+ const files = uwx.readZip(buf)
132
+ const manifest = JSON.parse(files.get('manifest.json').toString('utf8'))
133
+ const entityFile = manifest.entries?.[0]?.file
134
+ const entity = JSON.parse(files.get(entityFile).toString('utf8'))
135
+ const counts = {}
136
+ if (Array.isArray(entity.items)) {
137
+ // Flat register lane: one item per section occurrence.
138
+ for (const it of entity.items) {
139
+ counts[it.section] = (counts[it.section] || 0) + 1
140
+ }
141
+ } else {
142
+ // Nested sync lane: count records per top-level section, recursing into
143
+ // self-nested `$children` and inline `page_sections`.
144
+ const countPages = (pages) => {
145
+ let p = 0
146
+ let s = 0
147
+ for (const page of pages || []) {
148
+ p++
149
+ s += (page.page_sections || []).length
150
+ const sub = countPages(page.$children)
151
+ p += sub.p
152
+ s += sub.s
153
+ }
154
+ return { p, s }
155
+ }
156
+ if (entity.info) counts.info = 1
157
+ const pg = countPages(entity.pages)
158
+ if (pg.p) counts.pages = pg.p
159
+ if (pg.s) counts.page_sections = pg.s
160
+ if (entity.layout_sections?.length) counts.layout_sections = entity.layout_sections.length
161
+ if (entity.extensions?.length) counts.extensions = entity.extensions.length
162
+ if (entity.collections?.length) counts.collections = entity.collections.length
163
+ }
164
+
165
+ console.log('')
166
+ say.dim(`subtype ${manifest.subtype} (format ${manifest.format})`)
167
+ say.dim(
168
+ `type ${manifest.models_required[0].name_at_export} ${manifest.models_required[0].uuid}`
169
+ )
170
+ say.dim(`entity ${entity.uuid || entity.$uuid || entity.$id}`)
171
+ say.dim(
172
+ `items ${Object.entries(counts)
173
+ .map(([s, n]) => `${s}:${n}`)
174
+ .join(' ')}`
175
+ )
176
+ say.dim(`package_sha256 ${manifest.package_sha256.slice(0, 16)}…`)
177
+ say.dim(`size ${(buf.length / 1024).toFixed(1)} KiB`)
178
+ console.log('')
179
+
180
+ if (dryRun) {
181
+ say.ok('Dry run — nothing written.')
182
+ return
183
+ }
184
+
185
+ const outPath = resolve(output || `${defaultName}.uwx`)
186
+ writeFileSync(outPath, buf)
187
+ say.ok(`Wrote ${c.cyan}${outPath}${c.reset}`)
188
+ // Only the register lane (foundation schema) mints + persists ids locally. The
189
+ // site sync lane reads the sidecar read-only — the backend mints on sync — so
190
+ // there is nothing to persist here for a site.
191
+ if (useSidecar && isFoundation) {
192
+ say.dim(
193
+ `ids persisted in ${uwx.SIDECAR_RELPATH} — commit it so re-exports update, not duplicate.`
194
+ )
195
+ }
196
+ console.log('')
197
+ say.warn('v0 scope: media bytes, collection records, and @-nested section')
198
+ say.dim('hierarchy are not yet carried (documented).')
199
+ }
@@ -11,7 +11,7 @@
11
11
  * - Static-host adapters (`s3-cloudfront`, `cloudflare-pages`,
12
12
  * `github-pages`, `generic-static`, …): build dist/ in bundle-mode
13
13
  * and hand it to a host adapter for upload + invalidation. No login,
14
- * no edge. See kb/framework/plans/static-host-deploy-adapters.md.
14
+ * no edge.
15
15
  *
16
16
  * For static-host artifacts WITHOUT upload, see `uniweb export`.
17
17
  *
@@ -50,8 +50,6 @@
50
50
  * UNIWEB_SKIP_BILLING=1 Admin-only: bypass billing gate
51
51
  * UNIWEB_FORCE_REVIEW=1 Force the browser review flow
52
52
  * UNIWEB_ALLOW_DIRTY_FOUNDATION=1 Don't treat a dirty workspace as stale
53
- *
54
- * See kb/platform/plans/cli-site-deploy-decisions.md for the full design.
55
53
  */
56
54
 
57
55
  import { createServer } from 'node:http'
@@ -453,7 +451,7 @@ export async function deploy(args = []) {
453
451
  //
454
452
  // The default flow (`uniweb`) requires a `foundation:` declaration;
455
453
  // static-host deploys don't, so this branch comes BEFORE the foundation
456
- // check. See kb/framework/plans/static-host-deploy-adapters.md.
454
+ // check.
457
455
  const targetFromFlag = readFlagValue(args, '--target')
458
456
  let hostFromFlag = readFlagValue(args, '--host')
459
457
  const noSave = args.includes('--no-save')
@@ -907,6 +905,11 @@ export async function deploy(args = []) {
907
905
  say.dim(`Data files : ${Object.keys(dataFiles).length} (collection JSON)`)
908
906
  }
909
907
 
908
+ const searchFiles = await collectSearchFiles(distDir)
909
+ if (Object.keys(searchFiles).length > 0) {
910
+ say.dim(`Search indexes : ${Object.keys(searchFiles).length} (_search/ JSON)`)
911
+ }
912
+
910
913
  // Asset pipeline — upload dist/assets/* + favicon + fonts + content-scan
911
914
  // hits (public/, data file refs) to S3, then rewrite each locale's
912
915
  // siteContent + each parsed data file so the runtime resolves CDN URLs at
@@ -948,6 +951,7 @@ export async function deploy(args = []) {
948
951
  // publish writes each to ${sitePrefix}/data/<key>; worker serve allows
949
952
  // /data/* paths from R2 alongside _pages/*.
950
953
  ...(Object.keys(dataFiles).length > 0 ? { dataFiles } : {}),
954
+ ...(Object.keys(searchFiles).length > 0 ? { searchFiles } : {}),
951
955
  // Same shape as Editor publish — one entry per language. Single-locale
952
956
  // sites end up with `{ [defaultLanguage]: siteContent }`; multi-locale
953
957
  // sites carry per-locale translated content emitted by buildLocalizedContent.
@@ -1039,8 +1043,6 @@ export async function deploy(args = []) {
1039
1043
  // registered in @uniweb/build/hosts. Always runs `uniweb build` (bundle
1040
1044
  // mode + prerender) first, then hands dist/ to the adapter's deploy hook
1041
1045
  // for upload + invalidation.
1042
- //
1043
- // See kb/framework/plans/static-host-deploy-adapters.md.
1044
1046
 
1045
1047
  async function deployStaticHost(siteDir, hostName, resolved, { dryRun, autoSave, hostOverridden }) {
1046
1048
  let getAdapter
@@ -1324,6 +1326,25 @@ async function collectDataFiles(distDir) {
1324
1326
  return files
1325
1327
  }
1326
1328
 
1329
+ // Collect search index files from dist/_search/ recursively.
1330
+ // Returns `{ '<locale>/<name>.json': '<utf8-content>' }` so the worker can
1331
+ // write each to `${sitePrefix}/_search/<key>` in R2, gated by searchEnabled.
1332
+ // Empty object when the build emitted no search indexes.
1333
+ async function collectSearchFiles(distDir) {
1334
+ const searchDir = join(distDir, '_search')
1335
+ if (!existsSync(searchDir)) return {}
1336
+ const files = {}
1337
+ const entries = await readdir(searchDir, { withFileTypes: true, recursive: true })
1338
+ for (const entry of entries) {
1339
+ if (!entry.isFile()) continue
1340
+ if (!entry.name.endsWith('.json')) continue
1341
+ const fullPath = join(entry.parentPath || entry.path, entry.name)
1342
+ const relPath = relative(searchDir, fullPath)
1343
+ files[relPath] = await readFile(fullPath, 'utf8')
1344
+ }
1345
+ return files
1346
+ }
1347
+
1327
1348
  // Optional per-language labels from site.yml's object form. Returns null when
1328
1349
  // site.yml uses the plain-string form (no labels declared) — server falls back
1329
1350
  // to its own defaults in that case.
@@ -119,8 +119,7 @@ ${colors.bright}Child Page Ordering:${colors.reset}
119
119
 
120
120
  ${colors.bright}Navigation Visibility:${colors.reset}
121
121
  ${colors.cyan}hidden${colors.reset} Hide from all navigation (page still exists)
122
- ${colors.cyan}hideInHeader${colors.reset} Hide from header nav only
123
- ${colors.cyan}hideInFooter${colors.reset} Hide from footer nav only
122
+ ${colors.cyan}hideIn${colors.reset} Hide from named nav areas, e.g. [header] or [footer, sidebar]
124
123
 
125
124
  ${colors.bright}Layout Options:${colors.reset}
126
125
  ${colors.cyan}layout${colors.reset} Layout name or object with name, hide, params
@@ -148,7 +147,7 @@ ${colors.bright}Example:${colors.reset}
148
147
  ${colors.dim}title: About Us
149
148
  description: Learn about our company
150
149
  order: 2
151
- hideInFooter: true
150
+ hideIn: [footer]
152
151
  layout:
153
152
  hide: [right]
154
153
  seo:
@@ -0,0 +1,66 @@
1
+ /**
2
+ * uniweb org — manage publish orgs on the new backend.
3
+ *
4
+ * uniweb org list List the orgs you're a member of.
5
+ * uniweb org create <handle> Create an org; you become a member.
6
+ *
7
+ * Auth: the new-backend session (`uniweb login`) / UNIWEB_TOKEN. Distinct from
8
+ * the legacy platform (publish/deploy); this talks to the registry backend.
9
+ */
10
+
11
+ import { getRegistryApiBaseUrl } from '../utils/config.js'
12
+ import { ensureRegistryAuth } from '../utils/registry-auth.js'
13
+ import { listOrgs, createOrg, validateHandle, bareHandle } from '../utils/registry-orgs.js'
14
+
15
+ const colors = { reset: '\x1b[0m', bright: '\x1b[1m', dim: '\x1b[2m', red: '\x1b[31m', green: '\x1b[32m' }
16
+ const error = (m) => console.error(`${colors.red}✗${colors.reset} ${m}`)
17
+ const success = (m) => console.log(`${colors.green}✓${colors.reset} ${m}`)
18
+
19
+ export async function org(args = []) {
20
+ const sub = args[0]
21
+ const apiBase = getRegistryApiBaseUrl()
22
+
23
+ if (sub === 'list') {
24
+ const token = await ensureRegistryAuth({ apiBase, command: 'Listing orgs', args })
25
+ const orgs = await listOrgs({ apiBase, token })
26
+ if (!orgs.length) {
27
+ console.log('You have no orgs yet. Create one: uniweb org create <handle>')
28
+ return { exitCode: 0 }
29
+ }
30
+ console.log('Your orgs:')
31
+ for (const u of orgs) {
32
+ console.log(` ${colors.bright}@${u.handle}${colors.reset}${u.is_primary ? `${colors.dim} (primary)${colors.reset}` : ''}`)
33
+ }
34
+ return { exitCode: 0 }
35
+ }
36
+
37
+ if (sub === 'create') {
38
+ const handle = bareHandle(args[1])
39
+ if (!handle) {
40
+ error('Usage: uniweb org create <handle>')
41
+ return { exitCode: 2 }
42
+ }
43
+ const invalid = validateHandle(handle)
44
+ if (invalid) {
45
+ error(invalid)
46
+ return { exitCode: 2 }
47
+ }
48
+ const token = await ensureRegistryAuth({ apiBase, command: 'Creating an org', args })
49
+ try {
50
+ const org = await createOrg({ apiBase, token, handle })
51
+ success(`Created ${colors.bright}@${org.handle}${colors.reset} — you're a member${org.is_primary ? ' (primary)' : ''}.`)
52
+ console.log(`${colors.dim}Publish under it: uniweb register --scope @${org.handle}${colors.reset}`)
53
+ return { exitCode: 0 }
54
+ } catch (err) {
55
+ error(err.message)
56
+ return { exitCode: 1 }
57
+ }
58
+ }
59
+
60
+ console.log('uniweb org <command>')
61
+ console.log(' list List orgs you belong to')
62
+ console.log(' create <handle> Create an org (you become a member)')
63
+ return { exitCode: sub ? 2 : 0 }
64
+ }
65
+
66
+ export default org
@@ -0,0 +1,238 @@
1
+ /**
2
+ * uniweb pull — bring the backend's copy of a site back to canonical files.
3
+ *
4
+ * The read-side mirror of `uniweb push`. The project holds exactly one identity —
5
+ * `site.yml::$uuid` (the site-content entity) — and BOTH pull lanes are keyed by it
6
+ * (the backend owns the site's `@uniweb/folder` and resolves it from the site-content
7
+ * uuid, so the framework never holds a folder uuid). It GETs the two lanes and projects
8
+ * the returned documents back to files via the framework's projection layer
9
+ * (`@uniweb/build/uwx`):
10
+ *
11
+ * - content lane → `siteContentDocumentToProject` (site.yml/theme.yml/head.html,
12
+ * pages/**, layout/**), and
13
+ * - folder lane → `collectionsToProject` (the folder + record files).
14
+ *
15
+ * Pull is git-pull-like: it reconciles the working tree to the backend, DELETING
16
+ * pages/sections that no longer exist there (toggle off with `--no-delete`). The
17
+ * deletion is guarded so an empty/partial payload never wipes the tree.
18
+ *
19
+ * `uniweb login && uniweb pull`. Run from a site, or a workspace with one site.
20
+ *
21
+ * Usage:
22
+ * uniweb pull GET both lanes, project to files, prune orphans
23
+ * uniweb pull --no-collections Pull pages only; skip the folder (collections) lane
24
+ * uniweb pull --no-delete Project, but keep files with no backend item
25
+ * uniweb pull --dry-run Report what it would GET; write nothing
26
+ * uniweb pull --registry <url> Override the backend origin
27
+ * uniweb pull --token <bearer> Read with this bearer; skips `uniweb login`
28
+ *
29
+ * Endpoints: <origin>/dev/site/content/pull/{uuid} + /dev/site/folder/pull/{uuid},
30
+ * both keyed by `site.yml::$uuid`. Origin from
31
+ * --registry > UNIWEB_REGISTER_URL > the local default.
32
+ * Auth: --token > UNIWEB_TOKEN > `uniweb login` session.
33
+ *
34
+ * A project that never pushed has no `$uuid` to pull by — pull is a no-op with a
35
+ * clear message. NOTE: the backend pull routes have not been exercised live; the
36
+ * response-envelope extraction (extractDocument / splitCollectionsPull) is
37
+ * deliberately tolerant and is the single point to adjust at the first live run.
38
+ */
39
+
40
+ import { readFileSync } from 'node:fs'
41
+ import { join } from 'node:path'
42
+ import yaml from 'js-yaml'
43
+ import {
44
+ siteContentDocumentToProject,
45
+ collectionsToProject,
46
+ resolveCollectionsConfig,
47
+ } from '@uniweb/build/uwx'
48
+ import { makeModelResolver } from './push.js'
49
+ import { ensureRegistryAuth } from '../utils/registry-auth.js'
50
+ import { resolveSiteDir as defaultResolveSiteDir } from './deploy.js'
51
+
52
+ const DEFAULT_BACKEND_ORIGIN = 'http://localhost:8080'
53
+ const FOLDER_MODEL = '@uniweb/folder'
54
+
55
+ const colors = { reset: '\x1b[0m', bright: '\x1b[1m', dim: '\x1b[2m', red: '\x1b[31m', green: '\x1b[32m', blue: '\x1b[36m' }
56
+ const log = console.log
57
+ const success = (m) => log(`${colors.green}✓${colors.reset} ${m}`)
58
+ const error = (m) => console.error(`${colors.red}✗${colors.reset} ${m}`)
59
+ const info = (m) => log(`${colors.blue}→${colors.reset} ${m}`)
60
+ const note = (m) => log(` ${colors.dim}${m}${colors.reset}`)
61
+
62
+ function flagValue(args, name) {
63
+ const eq = args.find((a) => a.startsWith(`${name}=`))
64
+ if (eq) return eq.slice(name.length + 1)
65
+ const i = args.indexOf(name)
66
+ if (i !== -1 && args[i + 1] && !args[i + 1].startsWith('-')) return args[i + 1]
67
+ return null
68
+ }
69
+
70
+ // Read a top-level `$uuid:` scalar from a YAML file, or null.
71
+ function readYamlUuid(filePath) {
72
+ try {
73
+ const obj = yaml.load(readFileSync(filePath, 'utf8'))
74
+ return typeof obj?.$uuid === 'string' ? obj.$uuid : null
75
+ } catch {
76
+ return null
77
+ }
78
+ }
79
+
80
+ // Extract a single entity `$`-document from a pull response. Tolerant of a raw
81
+ // document, or a `{ document }` / `{ entity }` envelope. (Adjust at live e2e.)
82
+ export function extractDocument(payload) {
83
+ if (!payload || typeof payload !== 'object') return null
84
+ if (payload.$model || payload.$id || payload.info) return payload
85
+ return payload.document || payload.entity || null
86
+ }
87
+
88
+ // Split a collections pull (the folder + the entities it references) into the
89
+ // folder document and the record documents. Tolerant of an array, an
90
+ // `{ entities }` / `{ documents }` list, or an explicit `{ folder, records }`.
91
+ export function splitCollectionsPull(payload) {
92
+ if (payload?.folder) return { folderDoc: payload.folder, recordDocs: payload.records || [] }
93
+ const list = Array.isArray(payload)
94
+ ? payload
95
+ : Array.isArray(payload?.entities)
96
+ ? payload.entities
97
+ : Array.isArray(payload?.documents)
98
+ ? payload.documents
99
+ : null
100
+ if (!list) return { folderDoc: null, recordDocs: [] }
101
+ const docs = list.map(extractDocument).filter(Boolean)
102
+ return {
103
+ folderDoc: docs.find((d) => d.$model === FOLDER_MODEL) || null,
104
+ recordDocs: docs.filter((d) => d.$model !== FOLDER_MODEL),
105
+ }
106
+ }
107
+
108
+ /**
109
+ * @param {string[]} args
110
+ * @param {object} [deps] - injectable seams for testing: `fetch` (default global
111
+ * fetch), `resolveSiteDir`, `getToken` (skip auth).
112
+ */
113
+ export async function pull(args = [], deps = {}) {
114
+ const fetchImpl = deps.fetch || globalThis.fetch
115
+ const resolveSiteDir = deps.resolveSiteDir || defaultResolveSiteDir
116
+
117
+ const dryRun = args.includes('--dry-run')
118
+ const tokenFlag = flagValue(args, '--token')
119
+ const prune = !(args.includes('--no-delete') || args.includes('--no-prune')) // git-like by default
120
+ 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
+ }
130
+
131
+ const siteDir = await resolveSiteDir(args, 'pull')
132
+ // One identity per site: `site.yml::$uuid`. Both lanes (content + folder) are keyed
133
+ // by it — the backend resolves the site's `@uniweb/folder` from this uuid.
134
+ const siteContentUuid = readYamlUuid(join(siteDir, 'site.yml'))
135
+
136
+ if (!siteContentUuid) {
137
+ info('Nothing to pull — this project has no $uuid yet. Run `uniweb push` first.')
138
+ return { exitCode: 0 }
139
+ }
140
+
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
+ 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}`)
155
+ return { exitCode: 0 }
156
+ }
157
+
158
+ // GET a pull lane and parse it as JSON. 404 → null (deleted / no access); any
159
+ // 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} …`)
163
+ let res
164
+ try {
165
+ res = await fetchImpl(url, { headers: { Authorization: `Bearer ${await getToken()}` } })
166
+ } catch (err) {
167
+ error(`Could not reach the backend at ${url}: ${err.message}`)
168
+ note('Set the origin with --registry <url> or UNIWEB_REGISTER_URL.')
169
+ return null
170
+ }
171
+ if (res.status === 404) {
172
+ note(`${label}: not found (404) — it was deleted, or you lack access.`)
173
+ return null
174
+ }
175
+ if (!res.ok) {
176
+ error(`${label} pull failed: HTTP ${res.status} ${res.statusText}`)
177
+ if (res.status === 401 || res.status === 403) note("Credentials weren't accepted — supply a bearer with --token <bearer>.")
178
+ return null
179
+ }
180
+ try {
181
+ return await res.json()
182
+ } catch (err) {
183
+ error(`Could not parse the ${label} response as JSON: ${err.message}`)
184
+ return null
185
+ }
186
+ }
187
+
188
+ let pages = 0
189
+ let sections = 0
190
+ let records = 0
191
+ let deleted = 0
192
+
193
+ // Lane 1 — content → config + pages/** + layout/**.
194
+ const siteDoc = extractDocument(
195
+ await getJson(`/dev/site/content/pull/${encodeURIComponent(siteContentUuid)}`, 'content')
196
+ )
197
+ if (siteDoc) {
198
+ const report = siteContentDocumentToProject({ document: siteDoc, siteRoot: siteDir, prune })
199
+ pages += report.pages.length
200
+ sections += report.sections.length
201
+ deleted += report.deleted.length
202
+ }
203
+
204
+ // Lane 2 — folder → the folder + record files, keyed by the SAME site-content uuid
205
+ // (the backend resolves the site's `@uniweb/folder` from it; the framework never
206
+ // holds a folder uuid). Models are resolved by name (async) up front, so
207
+ // collectionsToProject keeps its synchronous contract.
208
+ if (!noCollections) {
209
+ const payload = await getJson(`/dev/site/folder/pull/${encodeURIComponent(siteContentUuid)}`, 'collections')
210
+ if (payload) {
211
+ const { folderDoc, recordDocs } = splitCollectionsPull(payload)
212
+ const resolveModel = makeModelResolver({ apiBase, getToken, fetchImpl })
213
+ const declByModel = new Map()
214
+ for (const model of [...new Set(recordDocs.map((d) => d.$model).filter(Boolean))]) {
215
+ try {
216
+ declByModel.set(model, await resolveModel(model))
217
+ } catch (err) {
218
+ note(`! could not resolve model ${model}: ${err.message}`)
219
+ }
220
+ }
221
+ const collectionsConfig = await resolveCollectionsConfig(siteDir).catch(() => null)
222
+ const report = collectionsToProject({
223
+ folderDoc,
224
+ recordDocs,
225
+ siteRoot: siteDir,
226
+ opts: { resolveDeclaration: (name) => declByModel.get(name) || null, collectionsConfig },
227
+ })
228
+ records += report.placed.length + report.updated.length
229
+ for (const s of report.skipped) note(`↷ ${s.slug ?? s.uuid ?? '(record)'}: ${s.reason}`)
230
+ for (const w of report.warnings) note(`! ${w}`)
231
+ }
232
+ }
233
+
234
+ success(
235
+ `Pulled — ${pages} page(s), ${sections} section(s), ${records} record(s)` + (deleted ? `, ${deleted} deleted` : '')
236
+ )
237
+ return { exitCode: 0 }
238
+ }