uniweb 0.12.21 → 0.12.22
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 +9 -8
- 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,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
|
+
}
|
package/src/commands/deploy.js
CHANGED
|
@@ -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.
|
|
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.
|
|
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.
|
package/src/commands/docs.js
CHANGED
|
@@ -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}
|
|
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
|
-
|
|
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
|
+
}
|