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.
- 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 +11 -10
- 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,288 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* uniweb validate - Check your content against the data schemas your
|
|
3
|
+
* foundation declares.
|
|
4
|
+
*
|
|
5
|
+
* For each section that consumes file-based data, this resolves the schema the
|
|
6
|
+
* foundation bound to that input (via `meta.js` `data:`) and checks the data
|
|
7
|
+
* items against it. It answers "does my data match what I promised?" — distinct
|
|
8
|
+
* from `doctor`, which checks your project against framework conventions.
|
|
9
|
+
*
|
|
10
|
+
* It warns by default; `--strict` turns findings into a non-zero exit for CI.
|
|
11
|
+
* The live render path stays tolerant — this gate runs before a site is live,
|
|
12
|
+
* by choice. Dynamic (remote) inputs and entity references can't be resolved
|
|
13
|
+
* without a running backend, so they're reported as deferred, never silently
|
|
14
|
+
* skipped.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
18
|
+
import { join, resolve, basename } from 'node:path'
|
|
19
|
+
import yaml from 'js-yaml'
|
|
20
|
+
import { validateDataInputs } from '@uniweb/build'
|
|
21
|
+
import { discoverFoundations, discoverSites } from '../utils/discover.js'
|
|
22
|
+
import { findWorkspaceRoot } from '../utils/workspace.js'
|
|
23
|
+
|
|
24
|
+
const colors = {
|
|
25
|
+
reset: '\x1b[0m',
|
|
26
|
+
bright: '\x1b[1m',
|
|
27
|
+
dim: '\x1b[2m',
|
|
28
|
+
red: '\x1b[31m',
|
|
29
|
+
green: '\x1b[32m',
|
|
30
|
+
yellow: '\x1b[33m',
|
|
31
|
+
blue: '\x1b[36m',
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const log = console.log
|
|
35
|
+
const success = (msg) => log(`${colors.green}✓${colors.reset} ${msg}`)
|
|
36
|
+
const warn = (msg) => log(`${colors.yellow}⚠${colors.reset} ${msg}`)
|
|
37
|
+
const error = (msg) => console.error(`${colors.red}✗${colors.reset} ${msg}`)
|
|
38
|
+
const info = (msg) => log(`${colors.blue}→${colors.reset} ${msg}`)
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Read a flag that takes a value: `--site foo` or `--site=foo`.
|
|
42
|
+
*/
|
|
43
|
+
function flagValue(args, name) {
|
|
44
|
+
const eq = args.find((a) => a.startsWith(`${name}=`))
|
|
45
|
+
if (eq) return eq.slice(name.length + 1)
|
|
46
|
+
const idx = args.indexOf(name)
|
|
47
|
+
if (idx !== -1 && args[idx + 1] && !args[idx + 1].startsWith('--')) return args[idx + 1]
|
|
48
|
+
return null
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function loadSiteYml(dir) {
|
|
52
|
+
for (const f of ['site.yml', 'site.yaml']) {
|
|
53
|
+
const p = join(dir, f)
|
|
54
|
+
if (existsSync(p)) {
|
|
55
|
+
try {
|
|
56
|
+
return yaml.load(readFileSync(p, 'utf8'))
|
|
57
|
+
} catch {
|
|
58
|
+
return null
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return null
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Validate one site against its local foundation.
|
|
67
|
+
*
|
|
68
|
+
* @returns {Promise<Object>} { site, foundation, status, report?, reason? }
|
|
69
|
+
* status: 'checked' | 'skipped'
|
|
70
|
+
*/
|
|
71
|
+
async function validateSite(site, foundations, workspaceDir) {
|
|
72
|
+
const sitePath = join(workspaceDir, site.path)
|
|
73
|
+
const siteYml = loadSiteYml(sitePath)
|
|
74
|
+
const foundationName = siteYml?.foundation
|
|
75
|
+
|
|
76
|
+
// No local foundation to check against → out of static scope (the schemas
|
|
77
|
+
// live in the foundation; a registry-ref / URL foundation isn't on disk).
|
|
78
|
+
if (!foundationName) {
|
|
79
|
+
return { site: site.name, status: 'skipped', reason: 'no foundation declared in site.yml (runtime-loaded?)' }
|
|
80
|
+
}
|
|
81
|
+
const match = foundations.find((f) => f.name === foundationName || basename(f.path) === foundationName)
|
|
82
|
+
if (!match) {
|
|
83
|
+
return {
|
|
84
|
+
site: site.name,
|
|
85
|
+
status: 'skipped',
|
|
86
|
+
reason: `foundation "${foundationName}" is not a local workspace foundation — its schemas aren't on disk to check against`,
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const foundationPath = join(workspaceDir, match.path)
|
|
91
|
+
const report = await validateDataInputs({ siteRoot: sitePath, foundationPath })
|
|
92
|
+
return { site: site.name, foundation: match.name, status: 'checked', report }
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Print one site's findings in human form. Groups by the (file, schema) pair
|
|
97
|
+
* so a collection feeding many sections lists its findings once, with the
|
|
98
|
+
* sections that use it — every link of the chain (route › section › key › file
|
|
99
|
+
* › item › field) is present, de-duped.
|
|
100
|
+
*/
|
|
101
|
+
function printSiteHuman(result) {
|
|
102
|
+
log('')
|
|
103
|
+
if (result.status === 'skipped') {
|
|
104
|
+
warn(`${colors.bright}${result.site}${colors.reset} — skipped: ${result.reason}`)
|
|
105
|
+
return
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const { report, foundation } = result
|
|
109
|
+
const { violations, deferred, setupErrors, summary } = report
|
|
110
|
+
const header = `${colors.bright}${result.site}${colors.reset} ${colors.dim}(foundation: ${foundation})${colors.reset}`
|
|
111
|
+
|
|
112
|
+
if (violations.length === 0 && setupErrors.length === 0) {
|
|
113
|
+
success(`${header} — ${summary.records} record(s) / ${summary.schemas} schema(s) conform`)
|
|
114
|
+
} else {
|
|
115
|
+
info(header)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Group violations by file+schema pair.
|
|
119
|
+
const groups = new Map()
|
|
120
|
+
for (const v of violations) {
|
|
121
|
+
const key = `${v.file} ${v.schema}`
|
|
122
|
+
if (!groups.has(key)) groups.set(key, { file: v.file, schema: v.schema, users: v.users, findings: [] })
|
|
123
|
+
groups.get(key).findings.push(v)
|
|
124
|
+
}
|
|
125
|
+
for (const g of groups.values()) {
|
|
126
|
+
log(` ${colors.red}✗${colors.reset} ${g.file} ${colors.dim}· schema ${g.schema}${colors.reset}`)
|
|
127
|
+
for (const u of dedupeUsers(g.users)) {
|
|
128
|
+
log(` ${colors.dim}used by${colors.reset} ${u.route} › ${u.section} › data.${u.key}`)
|
|
129
|
+
}
|
|
130
|
+
for (const f of g.findings) {
|
|
131
|
+
log(` ${colors.yellow}•${colors.reset} item ${colors.bright}"${f.item}"${colors.reset} › ${f.field} — ${f.message}`)
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
for (const e of setupErrors) {
|
|
136
|
+
log(` ${colors.red}✗${colors.reset} ${e.file} — ${e.message}`)
|
|
137
|
+
for (const u of dedupeUsers(e.users)) {
|
|
138
|
+
log(` ${colors.dim}used by${colors.reset} ${u.route} › ${u.section} › data.${u.key}`)
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (deferred.length > 0) {
|
|
143
|
+
log(` ${colors.dim}↪ deferred (not statically checkable):${colors.reset}`)
|
|
144
|
+
for (const d of deferred) {
|
|
145
|
+
const extra = d.url ? ` ${colors.dim}(${d.url})${colors.reset}` : d.ref ? ` ${colors.dim}(${d.ref})${colors.reset}` : ''
|
|
146
|
+
log(` ${colors.dim}•${colors.reset} ${d.route} › ${d.section} › data.${d.key} — ${d.reason}${extra}`)
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
log(
|
|
151
|
+
` ${colors.dim}${summary.records} record(s) · ${summary.schemas} schema(s) · ` +
|
|
152
|
+
`${summary.violations} violation(s) · ${summary.deferred} deferred${colors.reset}`
|
|
153
|
+
)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function dedupeUsers(users) {
|
|
157
|
+
const seen = new Set()
|
|
158
|
+
const out = []
|
|
159
|
+
for (const u of users || []) {
|
|
160
|
+
const k = `${u.route} ${u.section} ${u.key}`
|
|
161
|
+
if (seen.has(k)) continue
|
|
162
|
+
seen.add(k)
|
|
163
|
+
out.push(u)
|
|
164
|
+
}
|
|
165
|
+
return out
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export async function validate(args = []) {
|
|
169
|
+
const asJson = args.includes('--json')
|
|
170
|
+
const strict = args.includes('--strict')
|
|
171
|
+
const siteFilter = flagValue(args, '--site')
|
|
172
|
+
const positional = args.find((a, i) => !a.startsWith('--') && args[i - 1] !== '--site')
|
|
173
|
+
|
|
174
|
+
const target = positional ? resolve(process.cwd(), positional) : process.cwd()
|
|
175
|
+
const workspaceDir = findWorkspaceRoot(target)
|
|
176
|
+
|
|
177
|
+
if (!workspaceDir) {
|
|
178
|
+
if (asJson) log(JSON.stringify({ ok: false, error: 'not in a Uniweb workspace' }, null, 2))
|
|
179
|
+
else error('Not in a Uniweb workspace. Run this from a project root or a site directory.')
|
|
180
|
+
return { exitCode: 2 }
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const [sites, foundations] = await Promise.all([discoverSites(workspaceDir), discoverFoundations(workspaceDir)])
|
|
184
|
+
|
|
185
|
+
// Select which sites to check: --site filter, an explicitly targeted site
|
|
186
|
+
// directory, or all sites in the workspace.
|
|
187
|
+
let selected = sites
|
|
188
|
+
if (siteFilter) {
|
|
189
|
+
selected = sites.filter((s) => s.name === siteFilter || basename(s.path) === siteFilter)
|
|
190
|
+
if (selected.length === 0) {
|
|
191
|
+
const names = sites.map((s) => s.name).join(', ') || '(none)'
|
|
192
|
+
if (asJson) log(JSON.stringify({ ok: false, error: `site "${siteFilter}" not found`, sites: sites.map((s) => s.name) }, null, 2))
|
|
193
|
+
else error(`Site "${siteFilter}" not found. Available: ${names}`)
|
|
194
|
+
return { exitCode: 2 }
|
|
195
|
+
}
|
|
196
|
+
} else if (target !== workspaceDir) {
|
|
197
|
+
const match = sites.find((s) => join(workspaceDir, s.path) === target)
|
|
198
|
+
if (match) selected = [match]
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (selected.length === 0) {
|
|
202
|
+
if (asJson) log(JSON.stringify({ ok: true, sites: [], note: 'no sites found' }, null, 2))
|
|
203
|
+
else warn('No sites found in this workspace.')
|
|
204
|
+
return { exitCode: 0 }
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// The data pipeline (collectSiteContent / processCollections) prints progress
|
|
208
|
+
// via console.log. Route that to stderr while the engine runs so stdout stays
|
|
209
|
+
// clean — pure JSON for `--json`, just the report otherwise. `log` captured
|
|
210
|
+
// the original stdout writer at module load, so our own output is unaffected.
|
|
211
|
+
const results = []
|
|
212
|
+
const origConsoleLog = console.log
|
|
213
|
+
console.log = (...a) => process.stderr.write(a.join(' ') + '\n')
|
|
214
|
+
try {
|
|
215
|
+
for (const site of selected) {
|
|
216
|
+
try {
|
|
217
|
+
results.push(await validateSite(site, foundations, workspaceDir))
|
|
218
|
+
} catch (err) {
|
|
219
|
+
results.push({ site: site.name, status: 'error', reason: err.message })
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
} finally {
|
|
223
|
+
console.log = origConsoleLog
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const totalViolations = results.reduce((n, r) => n + (r.report?.violations.length || 0), 0)
|
|
227
|
+
const totalSetupErrors = results.reduce((n, r) => n + (r.report?.setupErrors.length || 0), 0)
|
|
228
|
+
const hadError = results.some((r) => r.status === 'error')
|
|
229
|
+
|
|
230
|
+
if (asJson) {
|
|
231
|
+
const payload = {
|
|
232
|
+
ok: totalViolations === 0 && !hadError,
|
|
233
|
+
strict,
|
|
234
|
+
sites: results.map((r) => ({
|
|
235
|
+
site: r.site,
|
|
236
|
+
foundation: r.foundation || null,
|
|
237
|
+
status: r.status,
|
|
238
|
+
reason: r.reason || null,
|
|
239
|
+
...(r.report || {}),
|
|
240
|
+
})),
|
|
241
|
+
summary: {
|
|
242
|
+
sites: results.length,
|
|
243
|
+
violations: totalViolations,
|
|
244
|
+
setupErrors: totalSetupErrors,
|
|
245
|
+
deferred: results.reduce((n, r) => n + (r.report?.deferred.length || 0), 0),
|
|
246
|
+
},
|
|
247
|
+
}
|
|
248
|
+
log(JSON.stringify(payload, null, 2))
|
|
249
|
+
} else {
|
|
250
|
+
log('')
|
|
251
|
+
log(`${colors.blue}${colors.bright}Uniweb Validate${colors.reset}`)
|
|
252
|
+
log(`${colors.dim}Checking content against the data schemas your foundation declares…${colors.reset}`)
|
|
253
|
+
for (const r of results) {
|
|
254
|
+
if (r.status === 'error') {
|
|
255
|
+
log('')
|
|
256
|
+
error(`${colors.bright}${r.site}${colors.reset} — could not check: ${r.reason}`)
|
|
257
|
+
} else {
|
|
258
|
+
printSiteHuman(r)
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
log('')
|
|
263
|
+
log('─'.repeat(50))
|
|
264
|
+
if (totalViolations === 0 && !hadError) {
|
|
265
|
+
log('')
|
|
266
|
+
success(`${colors.bright}All data conforms.${colors.reset}`)
|
|
267
|
+
if (!strict && totalSetupErrors === 0) {
|
|
268
|
+
// nothing more to say
|
|
269
|
+
}
|
|
270
|
+
log('')
|
|
271
|
+
} else {
|
|
272
|
+
log('')
|
|
273
|
+
if (totalViolations > 0) {
|
|
274
|
+
const mode = strict ? `${colors.red}error${colors.reset}` : `${colors.yellow}warning${colors.reset}`
|
|
275
|
+
log(`${totalViolations} violation(s) — reported as ${mode}${strict ? '' : ` ${colors.dim}(pass --strict to fail CI)${colors.reset}`}`)
|
|
276
|
+
}
|
|
277
|
+
if (hadError) log(`${colors.red}Some sites could not be checked.${colors.reset}`)
|
|
278
|
+
log('')
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// Exit semantics: 2 = couldn't run; 1 = violations under --strict; 0 = clean
|
|
283
|
+
// or warn-only (the live path stays tolerant, so findings don't fail by
|
|
284
|
+
// default). Setup/read failures are surfaced but don't fail the build.
|
|
285
|
+
if (hadError) return { exitCode: 2 }
|
|
286
|
+
if (totalViolations > 0 && strict) return { exitCode: 1 }
|
|
287
|
+
return { exitCode: 0 }
|
|
288
|
+
}
|
package/src/framework-index.json
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 1,
|
|
3
|
-
"generatedAt": "2026-
|
|
3
|
+
"generatedAt": "2026-06-03T20:15:20.971Z",
|
|
4
4
|
"packages": {
|
|
5
5
|
"@uniweb/build": {
|
|
6
|
-
"version": "0.14.
|
|
6
|
+
"version": "0.14.7",
|
|
7
7
|
"path": "framework/build",
|
|
8
8
|
"deps": [
|
|
9
9
|
"@uniweb/content-reader",
|
|
10
|
+
"@uniweb/content-writer",
|
|
10
11
|
"@uniweb/core",
|
|
11
12
|
"@uniweb/runtime",
|
|
12
13
|
"@uniweb/schemas",
|
|
@@ -14,17 +15,17 @@
|
|
|
14
15
|
]
|
|
15
16
|
},
|
|
16
17
|
"@uniweb/content-reader": {
|
|
17
|
-
"version": "1.1.
|
|
18
|
+
"version": "1.1.12",
|
|
18
19
|
"path": "framework/content-reader",
|
|
19
20
|
"deps": []
|
|
20
21
|
},
|
|
21
22
|
"@uniweb/content-writer": {
|
|
22
|
-
"version": "0.2.
|
|
23
|
+
"version": "0.2.5",
|
|
23
24
|
"path": "framework/content-writer",
|
|
24
25
|
"deps": []
|
|
25
26
|
},
|
|
26
27
|
"@uniweb/core": {
|
|
27
|
-
"version": "0.7.
|
|
28
|
+
"version": "0.7.12",
|
|
28
29
|
"path": "framework/core",
|
|
29
30
|
"deps": [
|
|
30
31
|
"@uniweb/semantic-parser",
|
|
@@ -42,7 +43,7 @@
|
|
|
42
43
|
"deps": []
|
|
43
44
|
},
|
|
44
45
|
"@uniweb/kit": {
|
|
45
|
-
"version": "0.9.
|
|
46
|
+
"version": "0.9.14",
|
|
46
47
|
"path": "framework/kit",
|
|
47
48
|
"deps": [
|
|
48
49
|
"@uniweb/core"
|
|
@@ -54,12 +55,12 @@
|
|
|
54
55
|
"deps": []
|
|
55
56
|
},
|
|
56
57
|
"@uniweb/press": {
|
|
57
|
-
"version": "0.4.
|
|
58
|
+
"version": "0.4.7",
|
|
58
59
|
"path": "framework/press",
|
|
59
60
|
"deps": []
|
|
60
61
|
},
|
|
61
62
|
"@uniweb/runtime": {
|
|
62
|
-
"version": "0.8.
|
|
63
|
+
"version": "0.8.15",
|
|
63
64
|
"path": "framework/runtime",
|
|
64
65
|
"deps": [
|
|
65
66
|
"@uniweb/core",
|
|
@@ -82,7 +83,7 @@
|
|
|
82
83
|
"deps": []
|
|
83
84
|
},
|
|
84
85
|
"@uniweb/templates": {
|
|
85
|
-
"version": "0.7.
|
|
86
|
+
"version": "0.7.39",
|
|
86
87
|
"path": "framework/templates",
|
|
87
88
|
"deps": []
|
|
88
89
|
},
|
|
@@ -92,7 +93,7 @@
|
|
|
92
93
|
"deps": []
|
|
93
94
|
},
|
|
94
95
|
"@uniweb/unipress": {
|
|
95
|
-
"version": "0.4.
|
|
96
|
+
"version": "0.4.11",
|
|
96
97
|
"path": "framework/unipress",
|
|
97
98
|
"deps": [
|
|
98
99
|
"@uniweb/build",
|
package/src/index.js
CHANGED
|
@@ -37,6 +37,7 @@ import { login } from './commands/login.js'
|
|
|
37
37
|
import { invite } from './commands/invite.js'
|
|
38
38
|
import { handoff } from './commands/handoff.js'
|
|
39
39
|
import { update } from './commands/update.js'
|
|
40
|
+
import { clone } from './commands/clone.js'
|
|
40
41
|
import { template } from './commands/template.js'
|
|
41
42
|
import {
|
|
42
43
|
resolveTemplate,
|
|
@@ -133,7 +134,7 @@ function getCliVersion() {
|
|
|
133
134
|
// install that's the whole point; delegating to the project-local copy
|
|
134
135
|
// would align the project to the version it already has, i.e. a no-op.
|
|
135
136
|
const STANDALONE_COMMANDS = new Set([
|
|
136
|
-
'create', '--help', '-h', '--version', '-v', 'login', 'update',
|
|
137
|
+
'create', 'clone', '--help', '-h', '--version', '-v', 'login', 'update',
|
|
137
138
|
])
|
|
138
139
|
|
|
139
140
|
/**
|
|
@@ -599,6 +600,44 @@ async function main() {
|
|
|
599
600
|
process.exit(result?.errors > 0 ? 1 : 0)
|
|
600
601
|
}
|
|
601
602
|
|
|
603
|
+
// Handle validate command (dynamic import — depends on @uniweb/build)
|
|
604
|
+
if (command === 'validate') {
|
|
605
|
+
const { validate } = await importProjectCommand('./commands/validate.js')
|
|
606
|
+
const result = await validate(args.slice(1))
|
|
607
|
+
process.exit(result?.exitCode ?? 0)
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
// Handle register command (dynamic import — depends on @uniweb/build)
|
|
611
|
+
if (command === 'register') {
|
|
612
|
+
const { register } = await importProjectCommand('./commands/register.js')
|
|
613
|
+
const result = await register(args.slice(1))
|
|
614
|
+
process.exit(result?.exitCode ?? 0)
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
// Handle push command (dynamic import — depends on @uniweb/build)
|
|
618
|
+
if (command === 'push') {
|
|
619
|
+
const { push } = await importProjectCommand('./commands/push.js')
|
|
620
|
+
const result = await push(args.slice(1))
|
|
621
|
+
process.exit(result?.exitCode ?? 0)
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
// Handle pull command (dynamic import — depends on @uniweb/build)
|
|
625
|
+
if (command === 'pull') {
|
|
626
|
+
const { pull } = await importProjectCommand('./commands/pull.js')
|
|
627
|
+
const result = await pull(args.slice(1))
|
|
628
|
+
process.exit(result?.exitCode ?? 0)
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
// Handle clone command (global — bootstraps a new project from a backend site;
|
|
632
|
+
// STANDALONE, so a global `uniweb clone` runs here instead of delegating to a
|
|
633
|
+
// project-local CLI that doesn't exist yet. clone.js avoids any static
|
|
634
|
+
// @uniweb/build import, then delegates the projection to the project-local
|
|
635
|
+
// `uniweb pull` after install.)
|
|
636
|
+
if (command === 'clone') {
|
|
637
|
+
const result = await clone(args.slice(1))
|
|
638
|
+
process.exit(result?.exitCode ?? 0)
|
|
639
|
+
}
|
|
640
|
+
|
|
602
641
|
// Handle update command
|
|
603
642
|
if (command === 'update') {
|
|
604
643
|
await update(args.slice(1))
|
|
@@ -646,9 +685,32 @@ async function main() {
|
|
|
646
685
|
return
|
|
647
686
|
}
|
|
648
687
|
|
|
649
|
-
// Handle
|
|
688
|
+
// Handle content command (dynamic import — depends on @uniweb/build/uwx)
|
|
689
|
+
if (command === 'content') {
|
|
690
|
+
const { content } = await importProjectCommand('./commands/content.js')
|
|
691
|
+
await content(args.slice(1))
|
|
692
|
+
return
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
// Handle login command. Default targets the NEW backend (username/password);
|
|
696
|
+
// `--legacy` runs the old browser/social flow (still used by publish/deploy
|
|
697
|
+
// internally via ensureAuth, so it stays reachable).
|
|
650
698
|
if (command === 'login') {
|
|
651
|
-
|
|
699
|
+
const loginArgs = args.slice(1)
|
|
700
|
+
if (loginArgs.includes('--legacy')) {
|
|
701
|
+
await login(loginArgs.filter((a) => a !== '--legacy'))
|
|
702
|
+
} else {
|
|
703
|
+
const { getRegistryApiBaseUrl } = await import('./utils/config.js')
|
|
704
|
+
const { runRegistryLogin } = await import('./utils/registry-auth.js')
|
|
705
|
+
await runRegistryLogin({ apiBase: getRegistryApiBaseUrl(), args: loginArgs })
|
|
706
|
+
}
|
|
707
|
+
return
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
// Handle org command (new-backend orgs/units — publish-scope management)
|
|
711
|
+
if (command === 'org') {
|
|
712
|
+
const { org } = await import('./commands/org.js')
|
|
713
|
+
await org(args.slice(1))
|
|
652
714
|
return
|
|
653
715
|
}
|
|
654
716
|
|
|
@@ -1169,6 +1231,59 @@ ${colors.bright}Options:${colors.reset}
|
|
|
1169
1231
|
--non-interactive Fail with usage info instead of prompting
|
|
1170
1232
|
|
|
1171
1233
|
Exit code is 1 if errors are found (warnings only → exit 0).
|
|
1234
|
+
`,
|
|
1235
|
+
validate: `
|
|
1236
|
+
${colors.cyan}${colors.bright}uniweb validate${colors.reset} ${colors.dim}— Check your content against your foundation's data schemas${colors.reset}
|
|
1237
|
+
|
|
1238
|
+
${colors.bright}Usage:${colors.reset}
|
|
1239
|
+
uniweb validate [path] [options]
|
|
1240
|
+
|
|
1241
|
+
Checks each section's file-based data inputs against the schema your
|
|
1242
|
+
foundation declared for that input (meta.js \`data:\`). Answers "does my
|
|
1243
|
+
data match what I promised?" — distinct from \`doctor\`, which checks your
|
|
1244
|
+
project against framework conventions.
|
|
1245
|
+
|
|
1246
|
+
Warns by default; the live render path stays tolerant, so this is a
|
|
1247
|
+
pre-live / CI gate. Dynamic (\`url:\`) inputs and entity references can't be
|
|
1248
|
+
resolved without a running backend, so they're reported as deferred,
|
|
1249
|
+
never silently skipped.
|
|
1250
|
+
|
|
1251
|
+
${colors.bright}Options:${colors.reset}
|
|
1252
|
+
--strict Treat findings as errors (non-zero exit for CI)
|
|
1253
|
+
--json Machine-readable output (for CI annotations)
|
|
1254
|
+
--site <name> Check one site in a multi-site workspace
|
|
1255
|
+
|
|
1256
|
+
Exit codes: 0 clean (or warn-only), 1 violations under --strict, 2 setup error.
|
|
1257
|
+
`,
|
|
1258
|
+
register: `
|
|
1259
|
+
${colors.cyan}${colors.bright}uniweb register${colors.reset} ${colors.dim}— Register a foundation + its data schemas with the backend registry${colors.reset}
|
|
1260
|
+
|
|
1261
|
+
${colors.bright}Usage:${colors.reset}
|
|
1262
|
+
uniweb register [options]
|
|
1263
|
+
|
|
1264
|
+
Builds one \`.uwx\` document and submits it to the registry over HTTP. Run
|
|
1265
|
+
\`uniweb login\` first (or pass \`--token\`). Distinct from \`uniweb publish\` (legacy
|
|
1266
|
+
hosting platform).
|
|
1267
|
+
|
|
1268
|
+
Auto-detects what you run it in:
|
|
1269
|
+
• a foundation the foundation + the data schemas it defines/renders
|
|
1270
|
+
• a schemas-only pkg just its data schemas, no foundation — e.g. @uniweb/schemas,
|
|
1271
|
+
any @org/schemas package, or a bare schemas/*.yml folder
|
|
1272
|
+
|
|
1273
|
+
Schema scopes (set the org with --scope, or package.json uniweb.scope):
|
|
1274
|
+
@/name your own schema, scoped to the publish org (@/x -> @org/x)
|
|
1275
|
+
@std/name a shared standard schema (from @uniweb/schemas)
|
|
1276
|
+
@org/name another org's published schema, referenced by name
|
|
1277
|
+
|
|
1278
|
+
${colors.bright}Options:${colors.reset}
|
|
1279
|
+
--scope @org Publish under @org (resolves @/x -> @org/x); default: package.json uniweb.scope
|
|
1280
|
+
--dry-run Print the .uwx; submit nothing
|
|
1281
|
+
-o, --output <f> Write the .uwx to a file; submit nothing
|
|
1282
|
+
--registry <url> Submit endpoint (default: \$UNIWEB_REGISTER_URL or a local URL)
|
|
1283
|
+
--token <bearer> Submit with this bearer; skips \`uniweb login\` (or set UNIWEB_TOKEN)
|
|
1284
|
+
--non-interactive Fail with usage info instead of prompting
|
|
1285
|
+
|
|
1286
|
+
Run from a foundation, a schemas-only package, or a workspace with a single foundation.
|
|
1172
1287
|
`,
|
|
1173
1288
|
rename: `
|
|
1174
1289
|
${colors.cyan}${colors.bright}uniweb rename${colors.reset} ${colors.dim}— Rename a workspace package${colors.reset}
|
|
@@ -1343,6 +1458,7 @@ ${colors.bright}Usage:${colors.reset}
|
|
|
1343
1458
|
|
|
1344
1459
|
${colors.bright}Commands:${colors.reset}
|
|
1345
1460
|
create [name] Create a new project
|
|
1461
|
+
clone <site-uuid> Clone a backend site into a local file project
|
|
1346
1462
|
add <type> [name] Add a foundation, site, or extension to a project
|
|
1347
1463
|
rename <type> Rename a foundation, site, or extension across the workspace
|
|
1348
1464
|
dev Start a dev server for a site
|
|
@@ -1350,11 +1466,15 @@ ${colors.bright}Commands:${colors.reset}
|
|
|
1350
1466
|
deploy Deploy a site to Uniweb hosting
|
|
1351
1467
|
export Export a self-contained site for third-party hosting
|
|
1352
1468
|
publish Publish a foundation to the Uniweb registry
|
|
1469
|
+
register Register a foundation + its data schemas with the backend registry
|
|
1470
|
+
push Push a site's content to the backend
|
|
1471
|
+
pull Pull a site's content from the backend
|
|
1353
1472
|
invite <email> Create a foundation invite for a client
|
|
1354
1473
|
handoff <email> Hand off a site to a client
|
|
1355
1474
|
inspect <path> Inspect parsed content shape of a markdown file or folder
|
|
1356
1475
|
docs Generate component documentation
|
|
1357
1476
|
doctor Diagnose project configuration issues
|
|
1477
|
+
validate Check your content against your foundation's data schemas
|
|
1358
1478
|
update Align workspace deps + AGENTS.md to the running CLI
|
|
1359
1479
|
i18n <cmd> Internationalization (extract, sync, status)
|
|
1360
1480
|
template publish Publish a site as a cloud template
|
|
@@ -166,6 +166,35 @@ async function processFile(sourcePath, targetPath, data, options = {}) {
|
|
|
166
166
|
}
|
|
167
167
|
}
|
|
168
168
|
|
|
169
|
+
/**
|
|
170
|
+
* Rename a leading single underscore to a dot: `_gitignore` → `.gitignore`,
|
|
171
|
+
* `_vscode` → `.vscode`.
|
|
172
|
+
*
|
|
173
|
+
* Dotfiles live in the template source under a `_`-prefixed name for two
|
|
174
|
+
* reasons: (1) so they aren't swept up by the template repo's own git ignore
|
|
175
|
+
* rules, and (2) — critically — so they survive `npm publish`. npm strips any
|
|
176
|
+
* file literally named `.gitignore` from the package tarball (it's treated as
|
|
177
|
+
* an ignore-source, not shippable content), so a template that needs to ship a
|
|
178
|
+
* `.gitignore` MUST store it as `_gitignore` and rename it at scaffold time.
|
|
179
|
+
* `.gitkeep` and other dotfiles ship fine; `.gitignore` is the trap.
|
|
180
|
+
*
|
|
181
|
+
* `__name` (double underscore) is left untouched, matching the prior
|
|
182
|
+
* directory-only behavior.
|
|
183
|
+
*/
|
|
184
|
+
function dotfileRename(name) {
|
|
185
|
+
return name.startsWith('_') && !name.startsWith('__') ? `.${name.slice(1)}` : name
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Compute a template file's scaffolded output name: strip the `.hbs`
|
|
190
|
+
* Handlebars suffix, then apply {@link dotfileRename}. So `_gitignore` →
|
|
191
|
+
* `.gitignore` and `_env.local.hbs` → `.env.local`.
|
|
192
|
+
*/
|
|
193
|
+
function templateOutputName(sourceName) {
|
|
194
|
+
const base = sourceName.endsWith('.hbs') ? sourceName.slice(0, -4) : sourceName
|
|
195
|
+
return dotfileRename(base)
|
|
196
|
+
}
|
|
197
|
+
|
|
169
198
|
/**
|
|
170
199
|
* Copy a directory structure recursively, processing templates
|
|
171
200
|
*
|
|
@@ -187,12 +216,8 @@ export async function copyTemplateDirectory(sourcePath, targetPath, data, option
|
|
|
187
216
|
|
|
188
217
|
if (entry.isDirectory()) {
|
|
189
218
|
const sourceFullPath = path.join(sourcePath, sourceName)
|
|
190
|
-
// Rename _prefix directories to .prefix (e.g., _vscode → .vscode)
|
|
191
|
-
|
|
192
|
-
const targetName = sourceName.startsWith('_') && !sourceName.startsWith('__')
|
|
193
|
-
? `.${sourceName.slice(1)}`
|
|
194
|
-
: sourceName
|
|
195
|
-
const targetFullPath = path.join(targetPath, targetName)
|
|
219
|
+
// Rename _prefix directories to .prefix (e.g., _vscode → .vscode).
|
|
220
|
+
const targetFullPath = path.join(targetPath, dotfileRename(sourceName))
|
|
196
221
|
|
|
197
222
|
await copyTemplateDirectory(sourceFullPath, targetFullPath, data, { onWarning, onProgress, skip })
|
|
198
223
|
} else {
|
|
@@ -201,17 +226,14 @@ export async function copyTemplateDirectory(sourcePath, targetPath, data, option
|
|
|
201
226
|
continue
|
|
202
227
|
}
|
|
203
228
|
|
|
204
|
-
//
|
|
205
|
-
|
|
206
|
-
|
|
229
|
+
// Output name: strip `.hbs`, then rename a leading `_` to `.`
|
|
230
|
+
// (e.g. `_gitignore` → `.gitignore`). Used for both the skip check and
|
|
231
|
+
// the written filename so they can't drift.
|
|
232
|
+
const targetName = templateOutputName(sourceName)
|
|
233
|
+
if (skip?.includes(targetName)) {
|
|
207
234
|
continue
|
|
208
235
|
}
|
|
209
236
|
|
|
210
|
-
// Remove .hbs extension for target filename
|
|
211
|
-
const targetName = sourceName.endsWith('.hbs')
|
|
212
|
-
? sourceName.slice(0, -4)
|
|
213
|
-
: sourceName
|
|
214
|
-
|
|
215
237
|
const sourceFullPath = path.join(sourcePath, sourceName)
|
|
216
238
|
const targetFullPath = path.join(targetPath, targetName)
|
|
217
239
|
|
|
@@ -228,7 +250,9 @@ export async function copyTemplateDirectory(sourcePath, targetPath, data, option
|
|
|
228
250
|
* Enumerate the output paths a template directory would write, without
|
|
229
251
|
* touching disk. Mirrors `copyTemplateDirectory`'s naming rules:
|
|
230
252
|
* - `.hbs` extension is stripped
|
|
231
|
-
* - `
|
|
253
|
+
* - a leading `_` is renamed to `.` for both files and directories
|
|
254
|
+
* (e.g. `_gitignore` → `.gitignore`, `_vscode` → `.vscode`); `__name`
|
|
255
|
+
* is preserved
|
|
232
256
|
* - `template.json` is excluded
|
|
233
257
|
* - `skip` filenames are excluded by their post-rename name
|
|
234
258
|
*
|
|
@@ -253,9 +277,7 @@ async function enumerateInto(sourcePath, relPath, outputs, skip) {
|
|
|
253
277
|
for (const entry of entries) {
|
|
254
278
|
const sourceName = entry.name
|
|
255
279
|
if (entry.isDirectory()) {
|
|
256
|
-
const targetName =
|
|
257
|
-
? `.${sourceName.slice(1)}`
|
|
258
|
-
: sourceName
|
|
280
|
+
const targetName = dotfileRename(sourceName)
|
|
259
281
|
await enumerateInto(
|
|
260
282
|
path.join(sourcePath, sourceName),
|
|
261
283
|
relPath ? path.join(relPath, targetName) : targetName,
|
|
@@ -264,7 +286,7 @@ async function enumerateInto(sourcePath, relPath, outputs, skip) {
|
|
|
264
286
|
)
|
|
265
287
|
} else {
|
|
266
288
|
if (sourceName === 'template.json') continue
|
|
267
|
-
const outputName =
|
|
289
|
+
const outputName = templateOutputName(sourceName)
|
|
268
290
|
if (skip.includes(outputName)) continue
|
|
269
291
|
outputs.push(relPath ? path.join(relPath, outputName) : outputName)
|
|
270
292
|
}
|
package/src/utils/config.js
CHANGED
|
@@ -83,6 +83,27 @@ export function getRegistryUrl() {
|
|
|
83
83
|
|| PRODUCTION_REGISTRY_URL
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
+
/**
|
|
87
|
+
* Get the new registry backend's API base origin — DISTINCT from the
|
|
88
|
+
* legacy PHP getBackendUrl(). `register` POSTs to {origin}/dev/registry/register
|
|
89
|
+
* and the new-backend `login` to {origin}/dev/auth/login.
|
|
90
|
+
*
|
|
91
|
+
* Priority: UNIWEB_REGISTER_URL's origin (the env users already set for
|
|
92
|
+
* register) > ~/.uniweb/config.json registryApiUrl > local default.
|
|
93
|
+
* @returns {string}
|
|
94
|
+
*/
|
|
95
|
+
export function getRegistryApiBaseUrl() {
|
|
96
|
+
const fromEnv = process.env.UNIWEB_REGISTER_URL
|
|
97
|
+
if (fromEnv) {
|
|
98
|
+
try { return new URL(fromEnv).origin } catch { /* fall through */ }
|
|
99
|
+
}
|
|
100
|
+
const fromCfg = readCliConfig().registryApiUrl
|
|
101
|
+
if (fromCfg) {
|
|
102
|
+
try { return new URL(fromCfg).origin } catch { return fromCfg }
|
|
103
|
+
}
|
|
104
|
+
return 'http://localhost:8080'
|
|
105
|
+
}
|
|
106
|
+
|
|
86
107
|
/**
|
|
87
108
|
* Read workspace package globs.
|
|
88
109
|
* Tries pnpm-workspace.yaml first, falls back to package.json workspaces.
|