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.
@@ -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
+ }
@@ -1,12 +1,13 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "generatedAt": "2026-05-13T05:21:17.590Z",
3
+ "generatedAt": "2026-06-03T19:50:40.168Z",
4
4
  "packages": {
5
5
  "@uniweb/build": {
6
- "version": "0.14.5",
6
+ "version": "0.14.6",
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.11",
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.4",
23
+ "version": "0.2.5",
23
24
  "path": "framework/content-writer",
24
25
  "deps": []
25
26
  },
26
27
  "@uniweb/core": {
27
- "version": "0.7.11",
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.13",
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.6",
58
+ "version": "0.4.7",
58
59
  "path": "framework/press",
59
60
  "deps": []
60
61
  },
61
62
  "@uniweb/runtime": {
62
- "version": "0.8.14",
63
+ "version": "0.8.15",
63
64
  "path": "framework/runtime",
64
65
  "deps": [
65
66
  "@uniweb/core",
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 login command
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
- await login(args.slice(1))
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
- // This allows dotfile directories to be committed without being gitignored
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
- // Determine the output filename (strip .hbs extension) for skip check
205
- const outputName = sourceName.endsWith('.hbs') ? sourceName.slice(0, -4) : sourceName
206
- if (skip?.includes(outputName)) {
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
- * - `_dir` is renamed to `.dir` (but `__dir` is preserved as `_dir` would be)
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 = sourceName.startsWith('_') && !sourceName.startsWith('__')
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 = sourceName.endsWith('.hbs') ? sourceName.slice(0, -4) : sourceName
289
+ const outputName = templateOutputName(sourceName)
268
290
  if (skip.includes(outputName)) continue
269
291
  outputs.push(relPath ? path.join(relPath, outputName) : outputName)
270
292
  }
@@ -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.