uniweb 0.12.20 → 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 +30 -1
- 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/doctor.js +24 -5
- package/src/commands/org.js +66 -0
- package/src/commands/publish.js +4 -3
- package/src/commands/pull.js +238 -0
- package/src/commands/push.js +400 -0
- package/src/commands/register.js +274 -0
- package/src/commands/rename.js +10 -5
- package/src/commands/update.js +211 -245
- package/src/commands/validate.js +288 -0
- package/src/framework-index.json +11 -10
- package/src/index.js +155 -26
- package/src/templates/processor.js +41 -19
- package/src/utils/config.js +30 -2
- package/src/utils/dep-survey.js +99 -0
- package/src/utils/json-file.js +68 -0
- package/src/utils/placement.js +100 -0
- package/src/utils/pm.js +29 -0
- package/src/utils/registry-auth.js +380 -0
- package/src/utils/registry-orgs.js +179 -0
- package/src/utils/scaffold.js +18 -5
- package/src/utils/site-content-refs.js +21 -0
- package/src/utils/update-check.js +4 -2
- package/src/versions.js +11 -4
- 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
package/src/commands/doctor.js
CHANGED
|
@@ -10,6 +10,8 @@ import { loadDeployYml } from '@uniweb/build/site'
|
|
|
10
10
|
import { listAdapters } from '@uniweb/build/hosts'
|
|
11
11
|
import { getCliVersion } from '../versions.js'
|
|
12
12
|
import { readAgentsVersion } from '../utils/agents-stamp.js'
|
|
13
|
+
import { writeJsonPreservingStyle } from '../utils/json-file.js'
|
|
14
|
+
import { surveyWorkspaceDeps } from '../utils/dep-survey.js'
|
|
13
15
|
import { discoverFoundations, discoverSites } from '../utils/discover.js'
|
|
14
16
|
import { findWorkspaceRoot } from '../utils/workspace.js'
|
|
15
17
|
|
|
@@ -204,9 +206,10 @@ export async function doctor(args = []) {
|
|
|
204
206
|
const union = Array.from(new Set([...ymlPackages, ...pkgWorkspaces])).sort()
|
|
205
207
|
writeFileSync(ymlPath, yaml.dump({ packages: union }, { flowLevel: -1, quotingType: '"' }))
|
|
206
208
|
const rootPkgPath = join(workspaceDir, 'package.json')
|
|
207
|
-
const
|
|
209
|
+
const rootPkgSrc = readFileSync(rootPkgPath, 'utf-8')
|
|
210
|
+
const rootPkg = JSON.parse(rootPkgSrc)
|
|
208
211
|
rootPkg.workspaces = union
|
|
209
|
-
|
|
212
|
+
writeJsonPreservingStyle(rootPkgPath, rootPkg, rootPkgSrc)
|
|
210
213
|
issue.fixed = true
|
|
211
214
|
fixed(`wrote union [${union.join(', ')}] to both manifests`)
|
|
212
215
|
} else {
|
|
@@ -364,7 +367,7 @@ export async function doctor(args = []) {
|
|
|
364
367
|
const sitePkgPath = join(sitePath, 'package.json')
|
|
365
368
|
const updatedPkg = { ...sitePkg }
|
|
366
369
|
updatedPkg.dependencies = { ...(updatedPkg.dependencies || {}), [foundationName]: expectedPath }
|
|
367
|
-
|
|
370
|
+
writeJsonPreservingStyle(sitePkgPath, updatedPkg)
|
|
368
371
|
issue.fixed = true
|
|
369
372
|
fixed(`added "${foundationName}": "${expectedPath}" to ${relative(workspaceDir, sitePkgPath)}`)
|
|
370
373
|
} else {
|
|
@@ -391,7 +394,7 @@ export async function doctor(args = []) {
|
|
|
391
394
|
const sitePkgPath = join(sitePath, 'package.json')
|
|
392
395
|
const updatedPkg = { ...sitePkg }
|
|
393
396
|
updatedPkg.dependencies = { ...(updatedPkg.dependencies || {}), [foundationName]: expectedPath }
|
|
394
|
-
|
|
397
|
+
writeJsonPreservingStyle(sitePkgPath, updatedPkg)
|
|
395
398
|
issue.fixed = true
|
|
396
399
|
fixed(`updated "${foundationName}" to "${expectedPath}" in ${relative(workspaceDir, sitePkgPath)}`)
|
|
397
400
|
} else {
|
|
@@ -672,11 +675,27 @@ export async function doctor(args = []) {
|
|
|
672
675
|
}
|
|
673
676
|
}
|
|
674
677
|
|
|
678
|
+
const cliVersion = getCliVersion()
|
|
679
|
+
|
|
680
|
+
// Check @uniweb/* dep alignment with the running CLI
|
|
681
|
+
log('')
|
|
682
|
+
const depSurvey = await surveyWorkspaceDeps(workspaceDir)
|
|
683
|
+
const behindDeps = depSurvey.rows.filter(r => r.status === 'behind')
|
|
684
|
+
if (behindDeps.length > 0) {
|
|
685
|
+
const names = [...new Set(behindDeps.map(r => r.name))].sort()
|
|
686
|
+
warn(`${behindDeps.length} workspace dep declaration${behindDeps.length === 1 ? '' : 's'} lag the CLI (v${cliVersion}): ${names.join(', ')}`)
|
|
687
|
+
info(`Run: uniweb update`)
|
|
688
|
+
issues.push({ type: 'warn', message: `${behindDeps.length} @uniweb/* dep declaration(s) behind CLI v${cliVersion}` })
|
|
689
|
+
} else if (depSurvey.anyAhead) {
|
|
690
|
+
success(`@uniweb/* deps are aligned or ahead of the CLI (v${cliVersion})`)
|
|
691
|
+
} else {
|
|
692
|
+
success(`@uniweb/* deps are aligned with the CLI (v${cliVersion})`)
|
|
693
|
+
}
|
|
694
|
+
|
|
675
695
|
// Check AGENTS.md freshness
|
|
676
696
|
log('')
|
|
677
697
|
const agentsPath = join(workspaceDir, 'AGENTS.md')
|
|
678
698
|
const agentsVersion = readAgentsVersion(agentsPath)
|
|
679
|
-
const cliVersion = getCliVersion()
|
|
680
699
|
|
|
681
700
|
if (!existsSync(agentsPath)) {
|
|
682
701
|
warn('AGENTS.md not found')
|
|
@@ -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
|
package/src/commands/publish.js
CHANGED
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
*/
|
|
35
35
|
|
|
36
36
|
import { existsSync } from 'node:fs'
|
|
37
|
-
import { readFile,
|
|
37
|
+
import { readFile, mkdir } from 'node:fs/promises'
|
|
38
38
|
import { resolve, join } from 'node:path'
|
|
39
39
|
import { execSync } from 'node:child_process'
|
|
40
40
|
|
|
@@ -42,6 +42,7 @@ import { resolveFoundationSrcPath, classifyPackage } from '@uniweb/build'
|
|
|
42
42
|
import { createLocalRegistry, RemoteRegistry } from '../utils/registry.js'
|
|
43
43
|
import { ensureAuth, readAuth, writeAuth, decodeJwtPayload } from '../utils/auth.js'
|
|
44
44
|
import { getRegistryUrl, getBackendUrl } from '../utils/config.js'
|
|
45
|
+
import { writeJsonPreservingStyleAsync } from '../utils/json-file.js'
|
|
45
46
|
import { findWorkspaceRoot, findFoundations, findSites, promptSelect } from '../utils/workspace.js'
|
|
46
47
|
import { isNonInteractive, getCliPrefix } from '../utils/interactive.js'
|
|
47
48
|
|
|
@@ -681,7 +682,7 @@ export async function publish(args = []) {
|
|
|
681
682
|
if (writeBackId) {
|
|
682
683
|
pkg.uniweb = pkg.uniweb || {}
|
|
683
684
|
pkg.uniweb.id = foundationName
|
|
684
|
-
await
|
|
685
|
+
await writeJsonPreservingStyleAsync(pkgPath, pkg)
|
|
685
686
|
info(`Wrote ${colors.cyan}uniweb.id: "${foundationName}"${colors.reset} to ${colors.dim}package.json${colors.reset}`)
|
|
686
687
|
}
|
|
687
688
|
|
|
@@ -851,7 +852,7 @@ export async function publish(args = []) {
|
|
|
851
852
|
if (writeBackId) {
|
|
852
853
|
pkg.uniweb = pkg.uniweb || {}
|
|
853
854
|
pkg.uniweb.id = foundationName
|
|
854
|
-
await
|
|
855
|
+
await writeJsonPreservingStyleAsync(pkgPath, pkg)
|
|
855
856
|
info(`Wrote ${colors.cyan}uniweb.id: "${foundationName}"${colors.reset} to ${colors.dim}package.json${colors.reset}`)
|
|
856
857
|
}
|
|
857
858
|
console.log('')
|
|
@@ -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
|
+
}
|