uniweb 1.0.0 → 1.0.2

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.
Files changed (129) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +532 -3
  3. package/package.json +60 -11
  4. package/partials/adding-pages.hbs +77 -0
  5. package/partials/agents.md +3000 -0
  6. package/partials/ai-assistance.hbs +28 -0
  7. package/partials/components-docs.hbs +13 -0
  8. package/partials/config-reference.hbs +66 -0
  9. package/partials/deployment.hbs +13 -0
  10. package/partials/learn-more.hbs +6 -0
  11. package/partials/quick-start.hbs +8 -0
  12. package/partials/search-docs.hbs +110 -0
  13. package/src/backend/asset-download.js +222 -0
  14. package/src/backend/client.js +986 -0
  15. package/src/backend/foundation-bring-along.js +692 -0
  16. package/src/backend/payment-handoff.js +362 -0
  17. package/src/backend/service-request.js +210 -0
  18. package/src/backend/site-media.js +255 -0
  19. package/src/backend/site-preview.js +77 -0
  20. package/src/backend/site-sync.js +2515 -0
  21. package/src/backend/workspace.js +234 -0
  22. package/src/commands/add.js +1900 -0
  23. package/src/commands/build.js +1214 -0
  24. package/src/commands/clone.js +466 -0
  25. package/src/commands/content.js +202 -0
  26. package/src/commands/deploy.js +537 -0
  27. package/src/commands/dev.js +202 -0
  28. package/src/commands/docs.js +530 -0
  29. package/src/commands/doctor.js +1982 -0
  30. package/src/commands/export.js +116 -0
  31. package/src/commands/families.js +82 -0
  32. package/src/commands/forget.js +197 -0
  33. package/src/commands/handoff.js +21 -0
  34. package/src/commands/i18n.js +1707 -0
  35. package/src/commands/inspect.js +375 -0
  36. package/src/commands/invite.js +24 -0
  37. package/src/commands/org.js +96 -0
  38. package/src/commands/publish.js +1087 -0
  39. package/src/commands/pull.js +1257 -0
  40. package/src/commands/push.js +663 -0
  41. package/src/commands/refresh.js +212 -0
  42. package/src/commands/register.js +1092 -0
  43. package/src/commands/rename.js +647 -0
  44. package/src/commands/site.js +358 -0
  45. package/src/commands/snapshot.js +461 -0
  46. package/src/commands/starter.js +206 -0
  47. package/src/commands/status.js +302 -0
  48. package/src/commands/sync.js +93 -0
  49. package/src/commands/template.js +107 -0
  50. package/src/commands/update.js +887 -0
  51. package/src/commands/validate.js +376 -0
  52. package/src/framework-index.json +248 -0
  53. package/src/index.js +2241 -0
  54. package/src/templates/fetchers/github.js +113 -0
  55. package/src/templates/fetchers/npm.js +85 -0
  56. package/src/templates/fetchers/release.js +92 -0
  57. package/src/templates/index.js +190 -0
  58. package/src/templates/processor.js +333 -0
  59. package/src/templates/resolver.js +232 -0
  60. package/src/templates/validator.js +155 -0
  61. package/src/utils/agents-stamp.js +110 -0
  62. package/src/utils/args.js +126 -0
  63. package/src/utils/asset-upload.js +270 -0
  64. package/src/utils/bytes.js +30 -0
  65. package/src/utils/code-upload.js +394 -0
  66. package/src/utils/config.js +417 -0
  67. package/src/utils/conformance.js +274 -0
  68. package/src/utils/dep-survey.js +176 -0
  69. package/src/utils/destination-prompt.js +146 -0
  70. package/src/utils/discover.js +75 -0
  71. package/src/utils/env.js +22 -0
  72. package/src/utils/exit.js +30 -0
  73. package/src/utils/fetch-retry.js +109 -0
  74. package/src/utils/flag-guard.js +246 -0
  75. package/src/utils/foundation-name.js +179 -0
  76. package/src/utils/git.js +342 -0
  77. package/src/utils/host-prompt.js +51 -0
  78. package/src/utils/install-integrity.js +210 -0
  79. package/src/utils/interactive.js +118 -0
  80. package/src/utils/json-file.js +72 -0
  81. package/src/utils/names.js +91 -0
  82. package/src/utils/placement.js +151 -0
  83. package/src/utils/pm.js +165 -0
  84. package/src/utils/preview.js +20 -0
  85. package/src/utils/pull-written.js +132 -0
  86. package/src/utils/records-guard.js +88 -0
  87. package/src/utils/registry-auth.js +994 -0
  88. package/src/utils/registry-orgs.js +290 -0
  89. package/src/utils/scaffold.js +483 -0
  90. package/src/utils/schemaless-report.js +30 -0
  91. package/src/utils/semver-precedence.js +80 -0
  92. package/src/utils/session-file.js +95 -0
  93. package/src/utils/site-content-refs.js +23 -0
  94. package/src/utils/site-copies.js +123 -0
  95. package/src/utils/site-data-upload.js +302 -0
  96. package/src/utils/site-identity.js +137 -0
  97. package/src/utils/snapshot-settings.js +112 -0
  98. package/src/utils/update-check.js +302 -0
  99. package/src/utils/uwx-read.js +157 -0
  100. package/src/utils/workspace.js +223 -0
  101. package/src/versions.js +467 -0
  102. package/starter/foundation/main.js +50 -0
  103. package/starter/foundation/sections/Section/index.jsx +95 -0
  104. package/starter/foundation/sections/Section/meta.js +44 -0
  105. package/starter/site/pages/about/1-about.md.hbs +13 -0
  106. package/starter/site/pages/about/page.yml +3 -0
  107. package/starter/site/pages/home/1-welcome.md.hbs +14 -0
  108. package/starter/site/pages/home/page.yml +3 -0
  109. package/templates/foundation/_gitignore +5 -0
  110. package/templates/foundation/components/.gitkeep +0 -0
  111. package/templates/foundation/main.js.hbs +33 -0
  112. package/templates/foundation/package.json.hbs +51 -0
  113. package/templates/foundation/sections/.gitkeep +0 -0
  114. package/templates/foundation/styles.css +6 -0
  115. package/templates/foundation/vite.config.js +3 -0
  116. package/templates/site/_gitignore +12 -0
  117. package/templates/site/entry.js +7 -0
  118. package/templates/site/index.html.hbs +13 -0
  119. package/templates/site/package.json.hbs +27 -0
  120. package/templates/site/public/favicon.svg +7 -0
  121. package/templates/site/site.yml.hbs +91 -0
  122. package/templates/site/theme.yml +99 -0
  123. package/templates/site/vite.config.js +7 -0
  124. package/templates/workspace/AGENTS.md.hbs +2 -0
  125. package/templates/workspace/README.md.hbs +87 -0
  126. package/templates/workspace/_gitignore +29 -0
  127. package/templates/workspace/_vscode/settings.json +6 -0
  128. package/templates/workspace/package.json.hbs +20 -0
  129. package/templates/workspace/pnpm-workspace.yaml.hbs +26 -0
package/src/index.js ADDED
@@ -0,0 +1,2241 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Uniweb CLI
5
+ *
6
+ * Scaffolds new Uniweb sites and foundations, builds projects, and generates docs.
7
+ *
8
+ * Install globally:
9
+ * npm i -g uniweb
10
+ *
11
+ * Usage:
12
+ * uniweb create [project-name]
13
+ * uniweb create --template marketing
14
+ * uniweb add foundation [name]
15
+ * uniweb build
16
+ * uniweb docs
17
+ *
18
+ * Global install delegation:
19
+ * When installed globally, project-bound commands (build, docs, etc.) are
20
+ * delegated to the project-local CLI if one exists in node_modules. This
21
+ * ensures version alignment between the CLI and @uniweb/build.
22
+ */
23
+
24
+ import { existsSync, readFileSync } from 'node:fs'
25
+ import { execSync, spawn as spawnChild } from 'node:child_process'
26
+ import { resolve, join, relative, dirname, basename } from 'node:path'
27
+ import { fileURLToPath } from 'node:url'
28
+ import prompts from 'prompts'
29
+ // `doctor`, `add`, `publish`, and `deploy` are loaded lazily via
30
+ // importProjectCommand() — each imports `@uniweb/build` at the top,
31
+ // so a static import here would crash `npx uniweb@latest create …`
32
+ // (no @uniweb/build in the npx scratch dir) before any command runs.
33
+ // Same pattern as `build` and `docs`.
34
+ import { i18n } from './commands/i18n.js'
35
+ import { inspect } from './commands/inspect.js'
36
+ import { invite } from './commands/invite.js'
37
+ import { handoff } from './commands/handoff.js'
38
+ import { update } from './commands/update.js'
39
+ import { clone } from './commands/clone.js'
40
+ import { template } from './commands/template.js'
41
+ import {
42
+ resolveTemplate,
43
+ parseTemplateId,
44
+ buildTemplateChoices,
45
+ templatePickerPageSize
46
+ } from './templates/index.js'
47
+ import { validateTemplate } from './templates/validator.js'
48
+ import {
49
+ scaffoldWorkspace,
50
+ scaffoldFoundation,
51
+ scaffoldSite,
52
+ applyContent,
53
+ applyStarter,
54
+ mergeTemplateDependencies,
55
+ getWorkspaceTemplateOutputs
56
+ } from './utils/scaffold.js'
57
+ import {
58
+ normalizeFoundationName,
59
+ ensureFoundationName
60
+ } from './utils/foundation-name.js'
61
+ import {
62
+ detectPackageManager,
63
+ detectWorkspacePm,
64
+ filterCmd,
65
+ installCmd,
66
+ runCmd,
67
+ isPnpmAvailable
68
+ } from './utils/pm.js'
69
+ import {
70
+ isNonInteractive,
71
+ getCliPrefix,
72
+ stripNonInteractiveFlag,
73
+ formatOptions
74
+ } from './utils/interactive.js'
75
+ import { findWorkspaceRoot } from './utils/workspace.js'
76
+ import { exitWhenDrained } from './utils/exit.js'
77
+
78
+ // Colors for terminal output
79
+ const colors = {
80
+ reset: '\x1b[0m',
81
+ bright: '\x1b[1m',
82
+ dim: '\x1b[2m',
83
+ cyan: '\x1b[36m',
84
+ green: '\x1b[32m',
85
+ yellow: '\x1b[33m',
86
+ red: '\x1b[31m'
87
+ }
88
+
89
+ // Template choices for the interactive prompt — built-ins first, then every
90
+ // official template in this CLI's snapshot (the list `create` can download),
91
+ // with "Blank" last. Composed by buildTemplateChoices() in templates/resolver.js.
92
+ const TEMPLATE_CHOICES = buildTemplateChoices()
93
+
94
+ // Files that may pre-exist in the target dir during `uniweb create .` and
95
+ // will be silently overwritten by the scaffold. Anything else colliding
96
+ // causes the verb to abort. README and .gitignore are the only two files
97
+ // the workspace template writes that overlap with what `gh repo create`
98
+ // puts in a fresh repo, and the scaffold's versions are more useful in
99
+ // this context (Vite/Node-aware .gitignore, project-shaped README).
100
+ const IN_PLACE_OVERWRITE_ALLOWED = new Set(['README.md', '.gitignore'])
101
+
102
+ /**
103
+ * Slugify a directory name into a valid project slug — lowercase,
104
+ * `[a-z0-9-]+`, no leading/trailing/duplicated hyphens. Matches the
105
+ * validation regex used for the interactive name prompt.
106
+ *
107
+ * @param {string} name
108
+ * @returns {string} Slugified name; empty if no valid characters remain.
109
+ */
110
+ function slugifyName(name) {
111
+ return String(name)
112
+ .toLowerCase()
113
+ .replace(/[^a-z0-9-]+/g, '-')
114
+ .replace(/^-+|-+$/g, '')
115
+ .replace(/-{2,}/g, '-')
116
+ }
117
+
118
+ function log(message) {
119
+ console.log(message)
120
+ }
121
+
122
+ function success(message) {
123
+ console.log(`${colors.green}✓${colors.reset} ${message}`)
124
+ }
125
+
126
+ function error(message) {
127
+ console.error(`${colors.red}✗${colors.reset} ${message}`)
128
+ }
129
+
130
+ function title(message) {
131
+ console.log(`\n${colors.cyan}${colors.bright}${message}${colors.reset}\n`)
132
+ }
133
+
134
+ // CLI version (read once, lazily)
135
+ const __dirname = dirname(fileURLToPath(import.meta.url))
136
+ let _cliVersion = null
137
+ function getCliVersion() {
138
+ if (!_cliVersion) {
139
+ const pkg = JSON.parse(
140
+ readFileSync(join(__dirname, '..', 'package.json'), 'utf8')
141
+ )
142
+ _cliVersion = pkg.version
143
+ }
144
+ return _cliVersion
145
+ }
146
+
147
+ /**
148
+ * Commands that always run from the global CLI (no project context needed)
149
+ */
150
+ // Commands that always run from the global CLI, never delegating to a
151
+ // project-local copy. `update` is here because it reconciles the project
152
+ // against *this* CLI's version matrix — when run from a (newer) global
153
+ // install that's the whole point; delegating to the project-local copy
154
+ // would align the project to the version it already has, i.e. a no-op.
155
+ const STANDALONE_COMMANDS = new Set([
156
+ 'create',
157
+ 'clone',
158
+ '--help',
159
+ '-h',
160
+ '--version',
161
+ '-v',
162
+ 'login',
163
+ 'logout',
164
+ 'update'
165
+ ])
166
+
167
+ /**
168
+ * Check if this CLI is running from a global install.
169
+ * When installed globally, process.argv[1] points outside any node_modules.
170
+ * When run via npx or as a local dependency, it's inside node_modules.
171
+ */
172
+ function isGlobalInstall() {
173
+ const scriptPath = process.argv[1]
174
+ if (!scriptPath) return false
175
+ // Normalize path separators for Windows compatibility
176
+ return (
177
+ !scriptPath.split('/').includes('node_modules') &&
178
+ !scriptPath.split('\\').includes('node_modules')
179
+ )
180
+ }
181
+
182
+ /**
183
+ * Find the project-local CLI entry point, if one exists.
184
+ * Walks up from cwd looking for node_modules/uniweb/src/index.js.
185
+ */
186
+ function findLocalCli() {
187
+ let dir = process.cwd()
188
+ while (true) {
189
+ const localCli = join(dir, 'node_modules', 'uniweb', 'src', 'index.js')
190
+ if (existsSync(localCli)) return localCli
191
+ const parent = dirname(dir)
192
+ if (parent === dir) break
193
+ dir = parent
194
+ }
195
+ return null
196
+ }
197
+
198
+ /**
199
+ * Delegate execution to the project-local CLI.
200
+ * Spawns the local CLI with the same arguments and inherits stdio.
201
+ * Warns if the local version differs from the global version.
202
+ */
203
+ function delegateToLocal(localCliPath) {
204
+ // Check for version mismatch between global and local CLI
205
+ try {
206
+ const localPkgPath = join(dirname(localCliPath), '..', 'package.json')
207
+ const localPkg = JSON.parse(readFileSync(localPkgPath, 'utf8'))
208
+ const globalVersion = getCliVersion()
209
+ if (localPkg.version && localPkg.version !== globalVersion) {
210
+ const yellow = '\x1b[33m'
211
+ const dim = '\x1b[2m'
212
+ const reset = '\x1b[0m'
213
+ console.error(
214
+ `${yellow}Note:${reset} Global CLI is ${dim}${globalVersion}${reset}, project has ${dim}${localPkg.version}${reset} ${dim}(using project version)${reset}`
215
+ )
216
+ }
217
+ } catch {
218
+ /* ignore — version check is best-effort */
219
+ }
220
+
221
+ return new Promise((resolve, reject) => {
222
+ const child = spawnChild(
223
+ process.execPath,
224
+ [localCliPath, ...process.argv.slice(2)],
225
+ { stdio: 'inherit' }
226
+ )
227
+ child.on('close', (code) => exitWhenDrained(code ?? 0))
228
+ child.on('error', reject)
229
+ })
230
+ }
231
+
232
+ /**
233
+ * Import a command module that may depend on an optional @uniweb/* peer
234
+ * (`@uniweb/build` above all). Sixteen commands load through here, so this
235
+ * is the error most users meet when something is not installed.
236
+ *
237
+ * ⛔ IT MUST NOT NAME A CAUSE IT HAS NOT CHECKED. This said "This command
238
+ * must be run from inside a Uniweb project" for every `ERR_MODULE_NOT_FOUND`
239
+ * on a `@uniweb/*` specifier — a guess, and wrong in the two cases that do
240
+ * not involve your location at all: a workspace whose dependencies are simply
241
+ * not installed, and a CLI installed on its own (the optional peers are not
242
+ * installed with it, so `npm install uniweb --prefix X` yields exactly this).
243
+ * Project resolution is cwd-based (`findWorkspaceRoot`) and always was, so
244
+ * the check the message needed was one call away and already imported here.
245
+ *
246
+ * ⚠️ A wrong cause is worse than a vague one: it does not merely fail to
247
+ * explain, it DIRECTS the investigation. Reported 2026-09-09 by a consumer
248
+ * who lost an hour testing the cause this text named, having already done
249
+ * the `cd` and the install it advised.
250
+ */
251
+ async function importProjectCommand(modulePath) {
252
+ try {
253
+ return await import(modulePath)
254
+ } catch (err) {
255
+ if (
256
+ err.code !== 'ERR_MODULE_NOT_FOUND' ||
257
+ !err.message?.includes('@uniweb/')
258
+ ) {
259
+ throw err
260
+ }
261
+
262
+ // Name the package that actually failed to resolve, rather than making
263
+ // the reader guess which of the optional peers this command needed.
264
+ const missing =
265
+ err.message.match(/'(@uniweb\/[^']+)'/)?.[1] ?? 'a @uniweb/* package'
266
+ const root = findWorkspaceRoot(process.cwd())
267
+
268
+ // ⛔ THREE STATES, and the second and third look identical from here — the
269
+ // import failed either way. Discriminate on what is ON DISK, because
270
+ // "declared but not installed" and "installed but not reachable from where
271
+ // THIS CLI lives" have opposite remedies, and telling a fully-installed
272
+ // workspace to run its installer is the same wrong-cause defect again.
273
+ const workspaceModules = root ? join(root, 'node_modules') : null
274
+ const workspaceInstalled = !!workspaceModules && existsSync(workspaceModules)
275
+ const localCli = root ? join(workspaceModules, '.bin', 'uniweb') : null
276
+
277
+ error(`Cannot load ${missing}, which this command needs.`)
278
+ log('')
279
+
280
+ if (!root) {
281
+ log(
282
+ `No Uniweb workspace found above ${colors.dim}${process.cwd()}${colors.reset}.`
283
+ )
284
+ log(` ${colors.cyan}cd${colors.reset} into a project, or create one:`)
285
+ log(` ${colors.cyan}uniweb create my-project${colors.reset}`)
286
+ log('')
287
+ log(
288
+ `${colors.dim}Driving projects from a standalone CLI? ${missing} is an optional${colors.reset}`
289
+ )
290
+ log(
291
+ `${colors.dim}peer, so a bare \`npm i uniweb\` does not bring it:${colors.reset}`
292
+ )
293
+ log(` ${colors.cyan}npm install uniweb ${missing}${colors.reset}`)
294
+ } else if (!workspaceInstalled) {
295
+ // Declared, not on disk. Prefer the workspace's own package manager,
296
+ // read off its lockfile — but a workspace that has NEVER been installed
297
+ // has no lockfile to read, so fall back the same way `create` does
298
+ // rather than to whichever PM happens to be running this process.
299
+ const pm =
300
+ detectWorkspacePm(root) ||
301
+ (isPnpmAvailable() ? 'pnpm' : detectPackageManager())
302
+ log(`Uniweb workspace: ${colors.dim}${root}${colors.reset}`)
303
+ log(`Its dependencies are not installed:`)
304
+ log(` ${colors.cyan}${installCmd(pm)}${colors.reset}`)
305
+ } else {
306
+ // Installed, and still unreachable: Node resolves a command's imports
307
+ // from the CLI's own location, so a CLI installed elsewhere cannot see
308
+ // this workspace's copy. Both remedies are real; the local bin is the
309
+ // one that guarantees the project's own pinned toolchain does the work.
310
+ log(
311
+ `Uniweb workspace: ${colors.dim}${root}${colors.reset} ${colors.dim}(dependencies are installed)${colors.reset}`
312
+ )
313
+ log(
314
+ `This CLI resolves its imports from its own install location, not from`
315
+ )
316
+ log(`the workspace, so it cannot see the workspace's ${missing}.`)
317
+ log('')
318
+ log(`Use the project's own CLI:`)
319
+ log(` ${colors.cyan}${localCli}${colors.reset}`)
320
+ log(`or install the peer next to this one:`)
321
+ log(` ${colors.cyan}npm install uniweb ${missing}${colors.reset}`)
322
+ }
323
+
324
+ await exitWhenDrained(1)
325
+ }
326
+ }
327
+
328
+ /**
329
+ * Create a project using the new package template flow (default)
330
+ */
331
+ async function createFromPackageTemplates(
332
+ projectDir,
333
+ projectName,
334
+ options = {}
335
+ ) {
336
+ const { onProgress, onWarning, pm = 'pnpm', includeStarter = true } = options
337
+
338
+ onProgress?.('Setting up workspace...')
339
+
340
+ // 1. Scaffold workspace.
341
+ // dev/build go through `uniweb` verbs so the scripts stay PM-agnostic
342
+ // (the verb resolves the right PM at runtime instead of locking the
343
+ // root scripts to whichever PM ran `npx uniweb create`). preview stays
344
+ // PM-filtered until a `uniweb preview` verb exists.
345
+ await scaffoldWorkspace(
346
+ projectDir,
347
+ {
348
+ projectName,
349
+ workspaceGlobs: ['site', 'src'],
350
+ scripts: {
351
+ dev: 'uniweb dev',
352
+ build: 'uniweb build',
353
+ preview: filterCmd(pm, 'site', 'preview')
354
+ }
355
+ },
356
+ { onProgress, onWarning }
357
+ )
358
+
359
+ // 2. Scaffold foundation (folder: src/, package name: src)
360
+ // The folder name 'src' carries the meaning — a foundation is the site's
361
+ // source code. The package name 'src' keeps it unique within the
362
+ // workspace, since 'site' is taken by the site package. Neither is the
363
+ // foundation's NAME — what it registers as — which is main.js's `name`:
364
+ // the project's, since a project's code is its foundation.
365
+ onProgress?.('Creating foundation...')
366
+ const registryName = normalizeFoundationName(projectName)
367
+ await scaffoldFoundation(
368
+ join(projectDir, 'src'),
369
+ {
370
+ name: 'src',
371
+ registryName,
372
+ projectName,
373
+ isExtension: false
374
+ },
375
+ { onProgress, onWarning }
376
+ )
377
+
378
+ // 3. Scaffold site
379
+ onProgress?.('Creating site...')
380
+ await scaffoldSite(
381
+ join(projectDir, 'site'),
382
+ {
383
+ name: 'site',
384
+ projectName,
385
+ foundationName: 'src',
386
+ foundationPath: 'file:../src',
387
+ foundationRef: 'src'
388
+ },
389
+ { onProgress, onWarning }
390
+ )
391
+
392
+ // 4. Apply starter content (unless creating a "none" project). Its main.js
393
+ // replaces the scaffolded one, so the name is given again.
394
+ if (includeStarter) {
395
+ onProgress?.('Adding starter content...')
396
+ await applyStarter(projectDir, { projectName }, { onProgress, onWarning })
397
+ ensureFoundationName(join(projectDir, 'src'), registryName)
398
+ }
399
+
400
+ success(`Created project: ${projectName}`)
401
+ }
402
+
403
+ /**
404
+ * Create a blank workspace (no packages, grow with `add`)
405
+ */
406
+ async function createBlankWorkspace(projectDir, projectName, options = {}) {
407
+ const { onProgress, onWarning } = options
408
+
409
+ onProgress?.('Setting up blank workspace...')
410
+
411
+ await scaffoldWorkspace(
412
+ projectDir,
413
+ {
414
+ projectName,
415
+ workspaceGlobs: [],
416
+ scripts: {
417
+ build: 'uniweb build'
418
+ }
419
+ },
420
+ { onProgress, onWarning }
421
+ )
422
+
423
+ success(`Created blank workspace: ${projectName}`)
424
+ }
425
+
426
+ /**
427
+ * Create a project from a format 2 content template
428
+ *
429
+ * Scaffolds workspace structure from package templates, then overlays
430
+ * content (sections, pages, theme) from the content template.
431
+ */
432
+ async function createFromContentTemplate(
433
+ projectDir,
434
+ projectName,
435
+ metadata,
436
+ templateRootPath,
437
+ options = {}
438
+ ) {
439
+ const { onProgress, onWarning, pm = 'pnpm' } = options
440
+
441
+ // Determine packages to create
442
+ // Default single-foundation single-site project uses package names
443
+ // 'src' (folder: src/) and 'site' (folder: site/). The folder
444
+ // convention is set in computePlacement() below.
445
+ const packages = metadata.packages || [
446
+ { type: 'foundation', name: 'src' },
447
+ { type: 'site', name: 'site', foundation: 'src' }
448
+ ]
449
+
450
+ // Compute placement for each package
451
+ const placed = computePlacement(packages)
452
+
453
+ // Compute workspace globs and scripts from placement
454
+ const workspaceGlobs = placed.map((p) => p.relativePath)
455
+ const sites = placed.filter((p) => p.type === 'site')
456
+ const scripts = {
457
+ build: 'uniweb build'
458
+ }
459
+ // dev goes through `uniweb` (PM-agnostic; see computeRootScripts).
460
+ // preview stays PM-filtered until a `uniweb preview` verb exists.
461
+ if (sites.length === 1) {
462
+ scripts.dev = 'uniweb dev'
463
+ scripts.preview = filterCmd(pm, sites[0].name, 'preview')
464
+ } else {
465
+ for (const s of sites) {
466
+ scripts[`dev:${s.name}`] = `uniweb dev ${s.name}`
467
+ scripts[`preview:${s.name}`] = filterCmd(pm, s.name, 'preview')
468
+ }
469
+ // First site gets unqualified aliases
470
+ if (sites.length > 0) {
471
+ scripts.dev = 'uniweb dev'
472
+ scripts.preview = filterCmd(pm, sites[0].name, 'preview')
473
+ }
474
+ }
475
+
476
+ // 1. Scaffold workspace
477
+ onProgress?.('Setting up workspace...')
478
+ await scaffoldWorkspace(
479
+ projectDir,
480
+ {
481
+ projectName,
482
+ workspaceGlobs,
483
+ scripts
484
+ },
485
+ { onProgress, onWarning }
486
+ )
487
+
488
+ // 2. Scaffold and apply content for each package
489
+ const claimed = new Set() // names this project's foundations register as
490
+ for (const pkg of placed) {
491
+ const fullPath = join(projectDir, pkg.relativePath)
492
+
493
+ // A foundation's name — what it registers as — is the template's name for
494
+ // the package when that is one, else the project's (`src` and `foundation`
495
+ // name a folder); `<project>-<package>` if another package here took it. A
496
+ // template's own main.js `name` outranks all of them.
497
+ let registryName = null
498
+ if (pkg.type === 'foundation' || pkg.type === 'extension') {
499
+ registryName =
500
+ [
501
+ normalizeFoundationName(pkg.name) || normalizeFoundationName(projectName),
502
+ normalizeFoundationName(`${projectName}-${pkg.name}`)
503
+ ].find((n) => n && !claimed.has(n)) || null
504
+ if (registryName) claimed.add(registryName)
505
+ }
506
+
507
+ if (pkg.type === 'foundation' || pkg.type === 'extension') {
508
+ onProgress?.(`Creating ${pkg.type}: ${pkg.name}...`)
509
+ await scaffoldFoundation(
510
+ fullPath,
511
+ {
512
+ name: pkg.name,
513
+ registryName,
514
+ projectName,
515
+ isExtension: pkg.type === 'extension'
516
+ },
517
+ { onProgress, onWarning }
518
+ )
519
+ } else if (pkg.type === 'site') {
520
+ // Find the foundation this site wires to
521
+ const foundationName = pkg.foundation || 'src'
522
+ const foundationPkg = placed.find(
523
+ (p) => p.type === 'foundation' && p.name === foundationName
524
+ )
525
+ const foundationPath = foundationPkg
526
+ ? computeFoundationFilePath(
527
+ pkg.relativePath,
528
+ foundationPkg.relativePath
529
+ )
530
+ : 'file:../src'
531
+
532
+ onProgress?.(`Creating site: ${pkg.name}...`)
533
+ // Always write `foundation: <name>` to site.yml — the value is
534
+ // never the implicit default in the new layout (the build's
535
+ // `detectFoundationType` defaults to 'foundation' when absent,
536
+ // which doesn't match 'src').
537
+ await scaffoldSite(
538
+ fullPath,
539
+ {
540
+ name: pkg.name,
541
+ projectName,
542
+ foundationName,
543
+ foundationPath,
544
+ foundationRef: foundationName
545
+ },
546
+ { onProgress, onWarning }
547
+ )
548
+ }
549
+
550
+ // Apply content from the matching content directory
551
+ const contentDir = findContentDirFor(metadata.contentDirs, pkg)
552
+ if (contentDir) {
553
+ onProgress?.(`Applying ${metadata.name} content to ${pkg.name}...`)
554
+ await applyContent(
555
+ contentDir.dir,
556
+ fullPath,
557
+ { projectName },
558
+ { onProgress, onWarning }
559
+ )
560
+ ensureFoundationName(fullPath, registryName)
561
+ }
562
+
563
+ // Merge template dependencies into package.json
564
+ if (metadata.dependencies) {
565
+ const deps =
566
+ metadata.dependencies[pkg.name] || metadata.dependencies[pkg.type]
567
+ if (deps) {
568
+ await mergeTemplateDependencies(join(fullPath, 'package.json'), deps)
569
+ }
570
+ }
571
+ }
572
+
573
+ success(`Created project: ${projectName}`)
574
+ }
575
+
576
+ /**
577
+ * Compute placement (relative paths) for packages
578
+ *
579
+ * Rules:
580
+ * - 1 foundation → src/ (folder name is 'src' regardless of package name;
581
+ * the package name is typically 'src' or
582
+ * whatever the template declared)
583
+ * - Multiple foundations → foundations/{name}/
584
+ * - Extensions → extensions/{name}/
585
+ * - 1 site named "site" → site/
586
+ * - Multiple sites → sites/{name}/
587
+ */
588
+ function computePlacement(packages) {
589
+ const foundations = packages.filter((p) => p.type === 'foundation')
590
+ const extensions = packages.filter((p) => p.type === 'extension')
591
+ const sites = packages.filter((p) => p.type === 'site')
592
+
593
+ const placed = []
594
+
595
+ for (const f of foundations) {
596
+ if (foundations.length === 1) {
597
+ placed.push({ ...f, relativePath: 'src' })
598
+ } else {
599
+ placed.push({ ...f, relativePath: `foundations/${f.name}` })
600
+ }
601
+ }
602
+
603
+ for (const e of extensions) {
604
+ placed.push({ ...e, relativePath: `extensions/${e.name}` })
605
+ }
606
+
607
+ for (const s of sites) {
608
+ if (sites.length === 1 && s.name === 'site') {
609
+ placed.push({ ...s, relativePath: 'site' })
610
+ } else {
611
+ placed.push({ ...s, relativePath: `sites/${s.name}` })
612
+ }
613
+ }
614
+
615
+ return placed
616
+ }
617
+
618
+ /**
619
+ * Find the content directory that matches a placed package
620
+ */
621
+ function findContentDirFor(contentDirs, pkg) {
622
+ if (!contentDirs) return null
623
+ // Match by name first, then by type
624
+ return (
625
+ contentDirs.find((d) => d.name === pkg.name) ||
626
+ contentDirs.find((d) => d.type === pkg.type && d.name === pkg.type)
627
+ )
628
+ }
629
+
630
+ /**
631
+ * Compute relative file: path from site to foundation
632
+ */
633
+ function computeFoundationFilePath(sitePath, foundationPath) {
634
+ const rel = relative(sitePath, foundationPath)
635
+ return `file:${rel}`
636
+ }
637
+
638
+ async function main() {
639
+ const rawArgs = process.argv.slice(2)
640
+ const nonInteractive = isNonInteractive(rawArgs)
641
+ const args = stripNonInteractiveFlag(rawArgs)
642
+ const command = args[0]
643
+ const pm = detectPackageManager()
644
+
645
+ // ⛔ THE VARIABLE THAT AIMS A PROCESS IS UNIWEB_SERVER — UNIWEB_REGISTER_URL until 2026-10-07.
646
+ // Set ALONE, the old name is refused rather than ignored: ignored, a script that meant a
647
+ // local or staging backend would go to the default one, uniweb.app, and say nothing. Set
648
+ // beside the new one it is ignored, so a script can carry both while CLIs of both ages run it.
649
+ if (process.env.UNIWEB_REGISTER_URL && !process.env.UNIWEB_SERVER) {
650
+ error('UNIWEB_REGISTER_URL is now UNIWEB_SERVER — rename it, and the command goes to the same backend.')
651
+ await exitWhenDrained(2)
652
+ }
653
+
654
+ // Handle --version / -v
655
+ //
656
+ // Output convention: the version goes to stdout (parseable, scriptable —
657
+ // `version=$(uniweb --version)` should keep working). Any staleness
658
+ // notice goes to stderr, so it shows in interactive terminals but
659
+ // doesn't pollute captured output.
660
+ //
661
+ // Two staleness paths split by stdout TTY-ness:
662
+ // - TTY (interactive user typed it): fetch the registry with a tight
663
+ // timeout. Accuracy matters — a fresh install would otherwise see
664
+ // no notice on its first invocation (the cache is empty). Network
665
+ // latency is acceptable here, capped at ~1.5s by the abort timeout.
666
+ // - Non-TTY (script captured stdout, piped through, etc.): cache-only.
667
+ // Scripts must stay fast and offline-safe. The `gh --version` /
668
+ // `claude --version` convention.
669
+ if (command === '--version' || command === '-v') {
670
+ console.log(`uniweb ${getCliVersion()}`)
671
+ if (isGlobalInstall()) {
672
+ try {
673
+ const { fetchAndNotifyIfNewer, maybeNotifyFromCache } =
674
+ await import('./utils/update-check.js')
675
+ if (process.stdout.isTTY) {
676
+ await fetchAndNotifyIfNewer(getCliVersion())
677
+ } else {
678
+ maybeNotifyFromCache(getCliVersion())
679
+ }
680
+ } catch {
681
+ /* ignore */
682
+ }
683
+ }
684
+ return
685
+ }
686
+
687
+ // Global install launcher: delegate project-bound commands to local CLI
688
+ const global = isGlobalInstall()
689
+ if (global && command && !STANDALONE_COMMANDS.has(command)) {
690
+ const localCli = findLocalCli()
691
+ if (localCli) {
692
+ await delegateToLocal(localCli)
693
+ return
694
+ }
695
+ // No local CLI found — fall through and try to run the command directly.
696
+ // Commands that need @uniweb/build will get a helpful error via importProjectCommand().
697
+ }
698
+
699
+ // Update check for global installs.
700
+ //
701
+ // - `create` / `clone` STOP when a newer release exists, before anything is
702
+ // written: a project starts on the packages and templates of the CLI that
703
+ // creates it, and a global install runs these two itself — there is no
704
+ // project CLI to delegate to yet (refuseOutdatedProjectStart). Their
705
+ // `--help` still prints.
706
+ // - every other verb gets a trailing notice (showUpdateNotification): they
707
+ // are project-bound (delegated to local node_modules) or do not depend on
708
+ // the version, and the local-vs-global mismatch warning in
709
+ // delegateToLocal covers the first case.
710
+ let showUpdateNotification = () => {}
711
+ if (global) {
712
+ let updateCheck = null
713
+ try {
714
+ updateCheck = await import('./utils/update-check.js')
715
+ } catch {
716
+ // Update check is optional — don't fail if the module is missing
717
+ }
718
+ const startsProject = command === 'create' || command === 'clone'
719
+ const asksForHelp = args.slice(1).some((a) => a === '--help' || a === '-h')
720
+ if (
721
+ updateCheck &&
722
+ startsProject &&
723
+ !asksForHelp &&
724
+ (await updateCheck.refuseOutdatedProjectStart(getCliVersion(), args))
725
+ ) {
726
+ await exitWhenDrained(1)
727
+ }
728
+ if (updateCheck) showUpdateNotification = updateCheck.startUpdateCheck(getCliVersion())
729
+ }
730
+
731
+ // Show help
732
+ if (!command || command === '--help' || command === '-h') {
733
+ showHelp()
734
+ await showUpdateNotification()
735
+ return
736
+ }
737
+
738
+ // Per-command --help: short-circuit BEFORE the command's side effects run.
739
+ // Critical for `deploy --help` (used to open a browser to production for
740
+ // login because deploy.js doesn't parse --help and ensureAuth ran first).
741
+ // Falls back to the global help when a command has no dedicated block.
742
+ if (args.slice(1).some((a) => a === '--help' || a === '-h')) {
743
+ const printed = printCommandHelp(command)
744
+ if (printed) {
745
+ await showUpdateNotification()
746
+ return
747
+ }
748
+ // No dedicated block — show global help as a useful fallback rather
749
+ // than executing the command (which often has side effects).
750
+ showHelp()
751
+ await showUpdateNotification()
752
+ return
753
+ }
754
+
755
+ // Handle build command (dynamic import — depends on @uniweb/build)
756
+ if (command === 'build') {
757
+ const { build } = await importProjectCommand('./commands/build.js')
758
+ await build(args.slice(1))
759
+ await showUpdateNotification()
760
+ return
761
+ }
762
+
763
+ // Handle dev command — thin wrapper that shells to the package manager's
764
+ // workspace-filtered `dev` script (mirrors what `uniweb create` writes
765
+ // into the root package.json::scripts.dev). Lazy import keeps startup
766
+ // fast when the user is not running dev.
767
+ if (command === 'dev') {
768
+ const { dev } = await import('./commands/dev.js')
769
+ await dev(args.slice(1))
770
+ return
771
+ }
772
+
773
+ // Handle snapshot command (dynamic import — depends on @uniweb/build; loads
774
+ // @uniweb/snapshot itself, from the workspace, and says how to add it if absent)
775
+ if (command === 'snapshot') {
776
+ const { snapshot } = await importProjectCommand('./commands/snapshot.js')
777
+ await snapshot(args.slice(1))
778
+ await showUpdateNotification()
779
+ return
780
+ }
781
+
782
+ // Handle docs command (dynamic import — depends on @uniweb/build)
783
+ if (command === 'docs') {
784
+ const { docs } = await importProjectCommand('./commands/docs.js')
785
+ await docs(args.slice(1))
786
+ await showUpdateNotification()
787
+ return
788
+ }
789
+
790
+ // Handle i18n command
791
+ if (command === 'i18n') {
792
+ await i18n(args.slice(1))
793
+ return
794
+ }
795
+
796
+ // Handle doctor command (dynamic import — depends on @uniweb/build)
797
+ if (command === 'doctor') {
798
+ const { doctor } = await importProjectCommand('./commands/doctor.js')
799
+ const result = await doctor(args.slice(1))
800
+ await exitWhenDrained(result?.errors > 0 ? 1 : 0)
801
+ }
802
+
803
+ // Handle validate command (dynamic import — depends on @uniweb/build)
804
+ if (command === 'validate') {
805
+ const { validate } = await importProjectCommand('./commands/validate.js')
806
+ const result = await validate(args.slice(1))
807
+ await exitWhenDrained(result?.exitCode ?? 0)
808
+ }
809
+
810
+ // Handle register command (dynamic import — depends on @uniweb/build).
811
+ // `release` is a code-only synonym: `register` reads naturally the first
812
+ // time, `release` for updates (shipping-model.md §6.1). Same act, same code.
813
+ if (command === 'register' || command === 'release') {
814
+ const { register } = await importProjectCommand('./commands/register.js')
815
+ const result = await register(args.slice(1))
816
+ await exitWhenDrained(result?.exitCode ?? 0)
817
+ }
818
+
819
+ // Handle push command (dynamic import — depends on @uniweb/build)
820
+ if (command === 'push') {
821
+ const { push } = await importProjectCommand('./commands/push.js')
822
+ const result = await push(args.slice(1))
823
+ await exitWhenDrained(result?.exitCode ?? 0)
824
+ }
825
+
826
+ // Handle pull command (dynamic import — depends on @uniweb/build)
827
+ if (command === 'pull') {
828
+ const { pull } = await importProjectCommand('./commands/pull.js')
829
+ const result = await pull(args.slice(1))
830
+ await exitWhenDrained(result?.exitCode ?? 0)
831
+ }
832
+
833
+ // Handle sync command (refresh + push; both halves imported, never reimplemented)
834
+ if (command === 'sync') {
835
+ const { sync } = await importProjectCommand('./commands/sync.js')
836
+ const result = await sync(args.slice(1))
837
+ await exitWhenDrained(result?.exitCode ?? 0)
838
+ }
839
+
840
+ // Handle refresh command (dynamic import — depends on @uniweb/build via pull)
841
+ if (command === 'refresh') {
842
+ const { refresh } = await importProjectCommand('./commands/refresh.js')
843
+ const result = await refresh(args.slice(1))
844
+ await exitWhenDrained(result?.exitCode ?? 0)
845
+ }
846
+
847
+ // Handle forget — remove one backend's local records (sync.json, cache, deploy
848
+ // records), or with --all everything a copied project inherited. Local only: every
849
+ // site still exists on its backend. Needs @uniweb/build (the store).
850
+ if (command === 'forget') {
851
+ const { forget } = await importProjectCommand('./commands/forget.js')
852
+ const result = await forget(args.slice(1))
853
+ await exitWhenDrained(result?.exitCode ?? 0)
854
+ }
855
+
856
+ // Handle status command (dynamic import — offline emit via @uniweb/build)
857
+ if (command === 'status') {
858
+ const { status } = await importProjectCommand('./commands/status.js')
859
+ const result = await status(args.slice(1))
860
+ await exitWhenDrained(result?.exitCode ?? 0)
861
+ }
862
+
863
+ // Handle clone command (global — bootstraps a new project from a backend site;
864
+ // STANDALONE, so a global `uniweb clone` runs here instead of delegating to a
865
+ // project-local CLI that doesn't exist yet. clone.js avoids any static
866
+ // @uniweb/build import, then delegates the projection to the project-local
867
+ // `uniweb pull` after install.)
868
+ if (command === 'clone') {
869
+ const result = await clone(args.slice(1))
870
+ await exitWhenDrained(result?.exitCode ?? 0)
871
+ }
872
+
873
+ // Handle update command
874
+ if (command === 'update') {
875
+ await update(args.slice(1))
876
+ return
877
+ }
878
+
879
+ // Handle inspect command
880
+ if (command === 'inspect') {
881
+ await inspect(args.slice(1))
882
+ return
883
+ }
884
+
885
+ // Handle add command (dynamic import — depends on @uniweb/build)
886
+ if (command === 'add') {
887
+ const { add } = await importProjectCommand('./commands/add.js')
888
+ await add(args.slice(1))
889
+ return
890
+ }
891
+
892
+ // Handle rename command (dynamic import — depends on @uniweb/build via deps)
893
+ if (command === 'rename') {
894
+ const { rename } = await importProjectCommand('./commands/rename.js')
895
+ await rename(args.slice(1))
896
+ return
897
+ }
898
+
899
+ // Handle publish command — the smart Uniweb-hosting path: bring the
900
+ // foundation along, sync, settle payment, go live. Distinct from `deploy`
901
+ // (third-party hosts) and `register`/`release` (foundation code → catalog).
902
+ if (command === 'publish') {
903
+ const { publish } = await importProjectCommand('./commands/publish.js')
904
+ const result = await publish(args.slice(1))
905
+ await exitWhenDrained(result?.exitCode ?? 0)
906
+ }
907
+
908
+ // Handle deploy command — third-party hosts only (dynamic import — @uniweb/build)
909
+ if (command === 'deploy') {
910
+ const { deploy } = await importProjectCommand('./commands/deploy.js')
911
+ await deploy(args.slice(1))
912
+ return
913
+ }
914
+
915
+ // Handle export command (dynamic import — depends on @uniweb/build)
916
+ if (command === 'export') {
917
+ const { exportSite } = await importProjectCommand('./commands/export.js')
918
+ await exportSite(args.slice(1))
919
+ return
920
+ }
921
+
922
+ // Handle content command (dynamic import — depends on @uniweb/build/uwx)
923
+ if (command === 'content') {
924
+ const { content } = await importProjectCommand('./commands/content.js')
925
+ await content(args.slice(1))
926
+ return
927
+ }
928
+
929
+ // Handle login command — the backend (username/password · paste a token ·
930
+ // --token <bearer>). ⭐ `--server <url>` names it; without the flag it is the DEFAULT
931
+ // backend — UNIWEB_SERVER, else ~/.uniweb/config.json, else https://uniweb.app —
932
+ // and never the project's backend *[Diego, 2026-09-21: "the default backend for login, if
933
+ // not specified, is uniweb.app"]*. ⭐ Except a WORKSPACE SWITCH — `--org` / `--personal`
934
+ // and no way of signing in — which acts on the backend you are logged in to *[Diego,
935
+ // 2026-10-06]* (`resolveLoginOrigin`). The backend logged in to becomes CURRENT, and
936
+ // every backend command goes there (only UNIWEB_SERVER outranks it) — which is why
937
+ // this is the one place a backend is chosen.
938
+ //
939
+ // ⛔ Until 2026-09-21 a bare login went to the backend of the project in the cwd, and
940
+ // asked when the machine knew several backends. Both are gone: the default is the
941
+ // default, and any other backend is named.
942
+ if (command === 'login') {
943
+ const loginArgs = args.slice(1)
944
+ const { readFlagValue } = await import('./utils/args.js')
945
+ const { runRegistryLogin } = await import('./utils/registry-auth.js')
946
+ const { resolveLoginOrigin } = await import('./utils/config.js')
947
+ // `--backend` was this flag's name until 2026-10-07 — renamed so it is not read as a
948
+ // site's `backend` service. Refused with the new name, never treated as unknown and dropped:
949
+ // dropped, the login would go to the default backend.
950
+ if (loginArgs.some((a) => a === '--backend' || a.startsWith('--backend='))) {
951
+ console.error('\x1b[31m✗\x1b[0m `--backend` is now `--server`: uniweb login --server <url>')
952
+ await exitWhenDrained(2)
953
+ }
954
+ let apiBase
955
+ try {
956
+ apiBase = resolveLoginOrigin(readFlagValue(loginArgs, '--server'), loginArgs)
957
+ } catch (err) {
958
+ console.error(
959
+ `\x1b[31m✗\x1b[0m ${err.message} — e.g. uniweb login --server http://localhost:8080`
960
+ )
961
+ await exitWhenDrained(2)
962
+ }
963
+ await runRegistryLogin({ apiBase, args: loginArgs })
964
+ return
965
+ }
966
+
967
+ // Handle logout — THE session. ⭐ There is at most one *[Diego, 2026-09-21: "`uniweb
968
+ // logout` always logs you out of the one backend you may be logged in"]*, so logout
969
+ // takes no options: `--backend` and `--all` existed only while sessions were kept per
970
+ // backend. ⚠️ Local only — the token is forgotten here, not revoked on the backend.
971
+ if (command === 'logout') {
972
+ const stray = args.slice(1).find((a) => a.startsWith('-') && a !== '--non-interactive')
973
+ if (stray) {
974
+ console.error(
975
+ `\x1b[31m✗\x1b[0m \`uniweb logout\` takes no options (got ${stray}) — it logs you out of the backend you are logged in to.`
976
+ )
977
+ await exitWhenDrained(2)
978
+ }
979
+ const { clearRegistryAuth } = await import('./utils/registry-auth.js')
980
+ const { loggedInOrigin } = await import('./utils/config.js')
981
+ const origin = loggedInOrigin()
982
+ const cleared = await clearRegistryAuth()
983
+ if (!cleared.length) {
984
+ console.log('Not logged in — nothing to clear.')
985
+ return
986
+ }
987
+ console.log(`\x1b[32m✓\x1b[0m Logged out${origin ? ` of ${origin}` : ''}.`)
988
+ // A file from before sessions were single can hold several; all of them go.
989
+ const others = cleared.filter((o) => o !== origin)
990
+ if (others.length)
991
+ console.log(`\x1b[2mAlso removed stored sessions for: ${others.join(', ')}\x1b[0m`)
992
+ return
993
+ }
994
+
995
+ // Handle site command — the sites in a workspace: list them, unpublish or delete one.
996
+ // Runs anywhere: inside a project it can act on the site the project is synced to.
997
+ if (command === 'site') {
998
+ const { site } = await import('./commands/site.js')
999
+ const result = await site(args.slice(1))
1000
+ // Its long list is where a bare exit was first caught cutting a pipe short — pinned by
1001
+ // test/site-list-pipe.test.js; why every exit waits: utils/exit.js.
1002
+ await exitWhenDrained(result?.exitCode ?? 0)
1003
+ }
1004
+
1005
+ // Handle org command (new-backend orgs/units — publish-scope management)
1006
+ if (command === 'org') {
1007
+ const { org } = await import('./commands/org.js')
1008
+ await org(args.slice(1))
1009
+ return
1010
+ }
1011
+
1012
+ // Handle invite command
1013
+ if (command === 'invite') {
1014
+ await invite(args.slice(1))
1015
+ return
1016
+ }
1017
+
1018
+ // Handle handoff command
1019
+ if (command === 'handoff') {
1020
+ await handoff(args.slice(1))
1021
+ return
1022
+ }
1023
+
1024
+ // Handle families command — the standard section families this CLI ships with
1025
+ if (command === 'families') {
1026
+ const { families } = await import('./commands/families.js')
1027
+ await families(args.slice(1))
1028
+ return
1029
+ }
1030
+
1031
+ // Handle template command
1032
+ if (command === 'template') {
1033
+ const result = await template(args.slice(1))
1034
+ await exitWhenDrained(result?.exitCode ?? 0)
1035
+ }
1036
+
1037
+ // Handle create command
1038
+ if (command !== 'create') {
1039
+ error(`Unknown command: ${command}`)
1040
+ showHelp()
1041
+ await exitWhenDrained(1)
1042
+ }
1043
+
1044
+ title('Uniweb Project Generator')
1045
+
1046
+ // Guard: prevent creating nested workspaces
1047
+ const existingRoot = findWorkspaceRoot(process.cwd())
1048
+ if (existingRoot) {
1049
+ error(`Already inside a Uniweb workspace: ${existingRoot}`)
1050
+ log(`\nTo add packages to this workspace, use:`)
1051
+ log(` ${colors.cyan}uniweb add foundation [name]${colors.reset}`)
1052
+ log(` ${colors.cyan}uniweb add site [name]${colors.reset}`)
1053
+ log(
1054
+ ` ${colors.cyan}uniweb add foundation --from <template>${colors.reset}\n`
1055
+ )
1056
+ await exitWhenDrained(1)
1057
+ }
1058
+
1059
+ // Parse arguments
1060
+ let projectName = args[1]
1061
+ let templateType = null // null = use new package template flow
1062
+
1063
+ // In-place mode: `uniweb create .` scaffolds into the current working
1064
+ // directory instead of creating a new one. Pairs with the GitHub-first
1065
+ // workflow where the user already cloned an empty repo (README.md and
1066
+ // optionally .gitignore present) and wants to scaffold inside it.
1067
+ const inPlace = projectName === '.'
1068
+ if (inPlace) {
1069
+ // Clear the positional so downstream logic (template prompt, name
1070
+ // prompt, etc.) doesn't see `.` as a literal name. The actual project
1071
+ // name is derived below from the cwd basename or `--name`.
1072
+ projectName = null
1073
+ }
1074
+
1075
+ // Check for --template flag
1076
+ const templateIndex = args.indexOf('--template')
1077
+ if (templateIndex !== -1 && args[templateIndex + 1]) {
1078
+ templateType = args[templateIndex + 1]
1079
+ // Validate template identifier (will throw if invalid)
1080
+ try {
1081
+ parseTemplateId(templateType)
1082
+ } catch (err) {
1083
+ error(`Invalid template: ${err.message}`)
1084
+ await exitWhenDrained(1)
1085
+ }
1086
+ }
1087
+
1088
+ // Check for --name flag. Accepts both `--name foo` and `--name=foo`.
1089
+ let displayName = null
1090
+ const nameIndex = args.indexOf('--name')
1091
+ if (nameIndex !== -1 && args[nameIndex + 1]) {
1092
+ displayName = args[nameIndex + 1]
1093
+ } else {
1094
+ const nameEq = args.find((a) => a.startsWith('--name='))
1095
+ if (nameEq) displayName = nameEq.slice('--name='.length)
1096
+ }
1097
+
1098
+ // Check for --blank flag
1099
+ let isBlank = args.includes('--blank')
1100
+
1101
+ // Handle --template blank as alias for --blank
1102
+ if (templateType === 'blank') {
1103
+ isBlank = true
1104
+ templateType = null
1105
+ }
1106
+
1107
+ // Check for --no-git flag
1108
+ const noGit = args.includes('--no-git')
1109
+
1110
+ // Skip positional name if it starts with -- (it's a flag, not a name)
1111
+ if (projectName && projectName.startsWith('--')) {
1112
+ projectName = null
1113
+ }
1114
+
1115
+ const prefix = getCliPrefix()
1116
+
1117
+ // In-place: derive the project name from the cwd basename (slugified),
1118
+ // or use --name when provided. Skip the interactive name prompt below.
1119
+ if (inPlace) {
1120
+ if (displayName) {
1121
+ projectName = displayName
1122
+ } else {
1123
+ const dirName = basename(process.cwd())
1124
+ const slug = slugifyName(dirName)
1125
+ if (!slug) {
1126
+ error(
1127
+ `Could not derive a valid project name from the current directory ("${dirName}").`
1128
+ )
1129
+ log(`Re-run with ${colors.cyan}--name=<your-name>${colors.reset}.`)
1130
+ await exitWhenDrained(1)
1131
+ }
1132
+ projectName = slug
1133
+ if (slug !== dirName) {
1134
+ log(
1135
+ `${colors.dim}Project name:${colors.reset} ${slug} ${colors.dim}(slugified from "${dirName}")${colors.reset}`
1136
+ )
1137
+ } else {
1138
+ log(`${colors.dim}Project name:${colors.reset} ${slug}`)
1139
+ }
1140
+ }
1141
+ }
1142
+
1143
+ // Non-interactive: fail with actionable message instead of prompting
1144
+ if (nonInteractive && !projectName) {
1145
+ error(`Missing project name.\n`)
1146
+ log(`Usage: ${prefix} create <project-name> [--template <name>] [--blank]`)
1147
+ await exitWhenDrained(1)
1148
+ }
1149
+
1150
+ // Non-interactive: default to starter when no template specified
1151
+ if (nonInteractive && !templateType && !isBlank) {
1152
+ templateType = 'starter'
1153
+ }
1154
+
1155
+ // Interactive prompts (skipped in in-place mode — name was derived above)
1156
+ const response = await prompts(
1157
+ [
1158
+ {
1159
+ type: projectName ? null : 'text',
1160
+ name: 'projectName',
1161
+ message: 'Project name:',
1162
+ initial: 'website',
1163
+ validate: (value) => {
1164
+ if (!value) return 'Project name is required'
1165
+ if (!/^[a-z0-9-]+$/.test(value)) {
1166
+ return 'Project name can only contain lowercase letters, numbers, and hyphens'
1167
+ }
1168
+ return true
1169
+ }
1170
+ }
1171
+ ],
1172
+ {
1173
+ onCancel: () => {
1174
+ log('\nScaffolding cancelled.')
1175
+ process.exit(0) // a prompt: a terminal, so nothing to drain — and it must stop here, now
1176
+ }
1177
+ }
1178
+ )
1179
+
1180
+ projectName = projectName || response.projectName
1181
+
1182
+ if (!projectName) {
1183
+ error('Missing project name')
1184
+ await exitWhenDrained(1)
1185
+ }
1186
+
1187
+ // Prompt for template if not specified via --template or --blank
1188
+ if (!templateType && !isBlank) {
1189
+ const templateResponse = await prompts(
1190
+ {
1191
+ type: 'select',
1192
+ name: 'template',
1193
+ message: 'Template:',
1194
+ choices: TEMPLATE_CHOICES,
1195
+ initial: 1,
1196
+ optionsPerPage: templatePickerPageSize(TEMPLATE_CHOICES.length)
1197
+ },
1198
+ {
1199
+ onCancel: () => {
1200
+ log('\nScaffolding cancelled.')
1201
+ process.exit(0) // a prompt: a terminal, so nothing to drain — and it must stop here, now
1202
+ }
1203
+ }
1204
+ )
1205
+ templateType = templateResponse.template
1206
+ // Handle "blank" selection from interactive prompt
1207
+ if (templateType === 'blank') {
1208
+ isBlank = true
1209
+ templateType = null
1210
+ }
1211
+ }
1212
+
1213
+ const effectiveName = displayName || projectName
1214
+
1215
+ // Resolve target directory. In-place mode scaffolds into the cwd;
1216
+ // otherwise create `./<projectName>`.
1217
+ const projectDir = inPlace
1218
+ ? process.cwd()
1219
+ : resolve(process.cwd(), projectName)
1220
+
1221
+ if (!inPlace && existsSync(projectDir)) {
1222
+ error(`Directory already exists: ${projectName}`)
1223
+ await exitWhenDrained(1)
1224
+ }
1225
+
1226
+ if (inPlace) {
1227
+ // Conflict check: enumerate the workspace template's would-write paths
1228
+ // (the only stage that touches the project root) and bail if any
1229
+ // collide with existing files outside the small allowlist.
1230
+ //
1231
+ // Allowlist: README.md and .gitignore overwrite cleanly. README is
1232
+ // typically GitHub-generated boilerplate; .gitignore should be ours
1233
+ // since the scaffold ships sensible Vite/Node ignores.
1234
+ const outputs = await getWorkspaceTemplateOutputs({ blank: isBlank })
1235
+ const conflicts = []
1236
+ for (const rel of outputs) {
1237
+ const full = resolve(projectDir, rel)
1238
+ if (existsSync(full) && !IN_PLACE_OVERWRITE_ALLOWED.has(rel)) {
1239
+ conflicts.push(rel)
1240
+ }
1241
+ }
1242
+ if (conflicts.length > 0) {
1243
+ error(`Cannot scaffold in place — these files would be overwritten:`)
1244
+ for (const c of conflicts) log(` ${colors.yellow}${c}${colors.reset}`)
1245
+ log('')
1246
+ log(
1247
+ `Move or remove them, then re-run ${colors.cyan}uniweb create .${colors.reset}.`
1248
+ )
1249
+ await exitWhenDrained(1)
1250
+ }
1251
+ }
1252
+
1253
+ // Template routing logic
1254
+ const progressCb = (msg) => log(` ${colors.dim}${msg}${colors.reset}`)
1255
+ const warningCb = (msg) =>
1256
+ log(` ${colors.yellow}Warning: ${msg}${colors.reset}`)
1257
+
1258
+ if (isBlank) {
1259
+ // Blank workspace (--blank or --template blank)
1260
+ log('\nCreating blank workspace...')
1261
+ await createBlankWorkspace(projectDir, effectiveName, {
1262
+ onProgress: progressCb,
1263
+ onWarning: warningCb
1264
+ })
1265
+ } else if (templateType === 'none') {
1266
+ // Foundation + site with no content
1267
+ log('\nCreating project...')
1268
+ await createFromPackageTemplates(projectDir, effectiveName, {
1269
+ onProgress: progressCb,
1270
+ onWarning: warningCb,
1271
+ pm,
1272
+ includeStarter: false
1273
+ })
1274
+ } else if (templateType === 'starter') {
1275
+ // Starter: foundation + site + sample content
1276
+ log('\nCreating project...')
1277
+ await createFromPackageTemplates(projectDir, effectiveName, {
1278
+ onProgress: progressCb,
1279
+ onWarning: warningCb,
1280
+ pm
1281
+ })
1282
+ } else {
1283
+ // External: official/npm/github/local
1284
+ log(`\nResolving template: ${templateType}...`)
1285
+
1286
+ try {
1287
+ const resolved = await resolveTemplate(templateType, {
1288
+ onProgress: progressCb
1289
+ })
1290
+
1291
+ log(
1292
+ `\nCreating project from ${resolved.name || resolved.package || `${resolved.owner}/${resolved.repo}`}...`
1293
+ )
1294
+
1295
+ // Validate and apply as format 2 content template
1296
+ const metadata = await validateTemplate(resolved.path)
1297
+
1298
+ try {
1299
+ await createFromContentTemplate(
1300
+ projectDir,
1301
+ effectiveName,
1302
+ metadata,
1303
+ resolved.path,
1304
+ {
1305
+ onProgress: progressCb,
1306
+ onWarning: warningCb,
1307
+ pm
1308
+ }
1309
+ )
1310
+ } finally {
1311
+ if (resolved.cleanup) await resolved.cleanup()
1312
+ }
1313
+ } catch (err) {
1314
+ error(`Failed to apply template: ${err.message}`)
1315
+ log('')
1316
+ log(`${colors.yellow}Troubleshooting:${colors.reset}`)
1317
+ log(` • Check your network connection`)
1318
+ log(
1319
+ ` • Official templates require GitHub access (may be blocked by corporate networks)`
1320
+ )
1321
+ log(
1322
+ ` • Try the starter template instead: ${colors.cyan}uniweb create ${projectName} --template starter${colors.reset}`
1323
+ )
1324
+ await exitWhenDrained(1)
1325
+ }
1326
+ }
1327
+
1328
+ // Initialize git repository
1329
+ if (!noGit) {
1330
+ // Skip git init if already inside a git repo (common for monorepos/workspaces)
1331
+ let insideGitRepo = false
1332
+ try {
1333
+ execSync('git rev-parse --is-inside-work-tree', {
1334
+ cwd: projectDir,
1335
+ stdio: 'ignore'
1336
+ })
1337
+ insideGitRepo = true
1338
+ } catch {
1339
+ // Not inside a git repo — proceed with init
1340
+ }
1341
+
1342
+ if (insideGitRepo) {
1343
+ log(
1344
+ ` ${colors.dim}Skipping git init — already inside a git repository${colors.reset}`
1345
+ )
1346
+ } else {
1347
+ try {
1348
+ execSync('git --version', { stdio: 'ignore' })
1349
+ try {
1350
+ execSync('git init', { cwd: projectDir, stdio: 'ignore' })
1351
+ execSync('git add -A', { cwd: projectDir, stdio: 'ignore' })
1352
+ execSync('git commit -m "Initial commit from uniweb"', {
1353
+ cwd: projectDir,
1354
+ stdio: 'ignore'
1355
+ })
1356
+ success('Git repository initialized')
1357
+ } catch {
1358
+ log(
1359
+ ` ${colors.yellow}Warning: Git repository initialized but initial commit failed${colors.reset}`
1360
+ )
1361
+ log(
1362
+ ` ${colors.dim}Run 'git commit -m "Initial commit"' after configuring git${colors.reset}`
1363
+ )
1364
+ }
1365
+ } catch {
1366
+ // git not available — skip silently
1367
+ }
1368
+ }
1369
+ }
1370
+
1371
+ // Success message
1372
+ title('Project created successfully!')
1373
+
1374
+ // Recommend pnpm (the framework's package manager) when it's installed,
1375
+ // otherwise fall back to however the user invoked the CLI. The scaffold
1376
+ // supports both — the root package.json declares npm `workspaces` and a
1377
+ // pnpm-workspace.yaml ships next to it.
1378
+ const recPm = isPnpmAvailable() ? 'pnpm' : pm
1379
+
1380
+ if (isBlank) {
1381
+ log(`Next steps:\n`)
1382
+ if (!inPlace) log(` ${colors.cyan}cd ${projectName}${colors.reset}`)
1383
+ log(` ${colors.cyan}${prefix} add project${colors.reset}`)
1384
+ log(` ${colors.cyan}${installCmd(recPm)}${colors.reset}`)
1385
+ log(
1386
+ ` ${colors.cyan}${runCmd(recPm, 'dev')}${colors.reset} ${colors.dim}# start the dev server${colors.reset}`
1387
+ )
1388
+ } else {
1389
+ log(`Next steps:\n`)
1390
+ if (!inPlace) log(` ${colors.cyan}cd ${projectName}${colors.reset}`)
1391
+ log(` ${colors.cyan}${installCmd(recPm)}${colors.reset}`)
1392
+ log(
1393
+ ` ${colors.cyan}${runCmd(recPm, 'dev')}${colors.reset} ${colors.dim}# start the dev server${colors.reset}`
1394
+ )
1395
+ }
1396
+ if (recPm !== 'pnpm') {
1397
+ log('')
1398
+ log(
1399
+ ` ${colors.dim}Tip: pnpm is recommended (https://pnpm.io) — npm works too.${colors.reset}`
1400
+ )
1401
+ }
1402
+ log('')
1403
+ log(
1404
+ ` ${colors.dim}See ${colors.reset}${colors.cyan}${prefix} <command> --help${colors.reset}${colors.dim} for command-specific options.${colors.reset}`
1405
+ )
1406
+ log('')
1407
+
1408
+ await showUpdateNotification()
1409
+ }
1410
+
1411
+ /**
1412
+ * Print help for a specific command. Returns true if a dedicated help
1413
+ * block exists for the command, false to signal "fall back to global
1414
+ * help."
1415
+ *
1416
+ * Help text intentionally lives next to the dispatcher rather than in
1417
+ * the per-command files because most help-seekers haven't run that
1418
+ * command yet — keeping it here means `uniweb foo --help` prints
1419
+ * without loading @uniweb/build or any project context.
1420
+ */
1421
+ function printCommandHelp(command) {
1422
+ // `release` is a synonym of `register` (shipping-model.md §6.1) — show the
1423
+ // same help block.
1424
+ if (command === 'release') command = 'register'
1425
+ const blocks = {
1426
+ deploy: `
1427
+ ${colors.cyan}${colors.bright}uniweb deploy${colors.reset} ${colors.dim}— Ship a site to a host${colors.reset}
1428
+
1429
+ ${colors.bright}Usage:${colors.reset}
1430
+ uniweb deploy [options]
1431
+
1432
+ With no destination configured, opens the WIZARD — "Where should this site go?" —
1433
+ and only offers destinations that can actually be acted on. Pick a host and it
1434
+ asks whether to set up CI (every push deploys) or upload from this machine now.
1435
+ Your answer is remembered in deploy.yml, so later runs go straight there.
1436
+
1437
+ Choosing ${colors.bright}Uniweb Cloud${colors.reset} delegates to ${colors.cyan}uniweb publish${colors.reset} (sync + dynamic serving;
1438
+ brings the foundation along). Choosing ${colors.bright}Somewhere else${colors.reset} runs ${colors.cyan}uniweb export${colors.reset}.
1439
+
1440
+ ${colors.bright}Hosts:${colors.reset}
1441
+ github-pages GitHub Pages — CI workflow, or commit dist/ to gh-pages
1442
+ cloudflare-pages Cloudflare Pages — CI workflow + PR previews, or wrangler
1443
+ netlify Netlify — CI workflow + PR previews, or the netlify CLI
1444
+ vercel Vercel — CI workflow + PR previews, or the vercel CLI
1445
+ s3-cloudfront AWS S3 + CloudFront — uploads + invalidates via the aws CLI
1446
+ uniweb Uniweb Cloud (delegates to \`uniweb publish\`)
1447
+
1448
+ ${colors.bright}Options:${colors.reset}
1449
+ --host <name> The host to ship to (no value → the wizard, TTY only)
1450
+ --target <name> Pick a target from deploy.yml (default: deploy.yml's \`default:\`)
1451
+ --dry-run Resolve the target + adapter; print summary; upload nothing
1452
+ --no-save Do not record this deploy in deploy.yml
1453
+ --no-validate Skip the content-conformance check (a static host warns;
1454
+ Uniweb Cloud stops on content that does not conform)
1455
+ --non-interactive Fail with usage info instead of prompting
1456
+
1457
+ ${colors.bright}Examples:${colors.reset}
1458
+ uniweb deploy # Wizard: pick a destination
1459
+ uniweb deploy --host=cloudflare-pages # Build + upload via wrangler
1460
+ uniweb deploy --host=s3-cloudfront # Build + upload + invalidate
1461
+ uniweb deploy --host=uniweb # → delegates to \`uniweb publish\`
1462
+ uniweb deploy --target=preview # Named target from deploy.yml
1463
+
1464
+ ${colors.dim}To deploy on every push instead, see \`uniweb add ci --help\`.${colors.reset}
1465
+ `,
1466
+ publish: `
1467
+ ${colors.cyan}${colors.bright}uniweb publish${colors.reset} ${colors.dim}— Publish a site to Uniweb hosting (the smart path)${colors.reset}
1468
+
1469
+ ${colors.bright}Usage:${colors.reset}
1470
+ uniweb publish [options]
1471
+
1472
+ The most ergonomic command in the tool: \`uniweb login && uniweb publish\` reads
1473
+ your project and makes the site live on Uniweb hosting. It resolves which site,
1474
+ BRINGS THE FOUNDATION ALONG (releases the local foundation when its code
1475
+ changed — under the next version when its own is registered; commit the
1476
+ package.json it writes), syncs content, settles payment when go-live needs it
1477
+ (a browser handoff), and goes live. A published-registry foundation needs no
1478
+ release; an already-paid site opens no browser.
1479
+
1480
+ For a third-party host use \`uniweb deploy --host=<name>\`; to register a
1481
+ FOUNDATION on its own use \`uniweb register\` (alias \`uniweb release\`).
1482
+
1483
+ ${colors.bright}Options:${colors.reset}
1484
+ --dry-run Resolve everything; release/sync/POST nothing
1485
+ --yes Skip confirmations (CI); never block on a prompt
1486
+ --no-release Ship the content against the code already released; release
1487
+ nothing. For editing content and a component in one sitting
1488
+ when only the content should go live. Refused if the
1489
+ foundation has never been released — there is nothing to
1490
+ bind to, and the app cannot open such a site.
1491
+ --bump When the registry holds a newer version of the foundation
1492
+ than yours, release yours above it instead of stopping
1493
+ --no-save Do not record this deploy in deploy.yml
1494
+ --no-validate Skip the content-conformance check, which stops a publish
1495
+ whose content does not conform to its data schemas
1496
+ --org @org Work in @org for this publish, instead of the workspace
1497
+ --personal chosen at \`uniweb login\` (or your personal workspace). A
1498
+ site this publish creates is created — owned — there, and a
1499
+ site the backend keeps in another workspace stops it.
1500
+ `,
1501
+ create: `
1502
+ ${colors.cyan}${colors.bright}uniweb create${colors.reset} ${colors.dim}— Create a new project${colors.reset}
1503
+
1504
+ ${colors.bright}Usage:${colors.reset}
1505
+ uniweb create [name] [options]
1506
+ uniweb create . Scaffold into the current directory
1507
+
1508
+ ${colors.bright}Options:${colors.reset}
1509
+ --template <type> Project template (default: starter)
1510
+ Built-in: starter, none, blank
1511
+ Official: marketing, docs, … (\`uniweb template list\`)
1512
+ Local: ./path/to/template
1513
+ npm: @scope/template-name
1514
+ GitHub: github:user/repo or https://github.com/user/repo
1515
+ --blank Create an empty workspace (grow with \`uniweb add\`)
1516
+ --name <name> Project name (overrides slugified basename when used with \`.\`)
1517
+ --no-git Skip git repository initialization
1518
+
1519
+ ${colors.bright}In-place mode (\`uniweb create .\`):${colors.reset}
1520
+ Pairs with the GitHub-first workflow — clone an empty repo locally
1521
+ (README, optional .gitignore), then scaffold inside it. Project name
1522
+ is the cwd basename, slugified to a valid npm name. Pass \`--name\` to
1523
+ override. Pre-existing \`README.md\` and \`.gitignore\` are overwritten;
1524
+ any other collision aborts with the list of conflicting files. Skips
1525
+ \`git init\` when a \`.git/\` directory already exists.
1526
+
1527
+ ${colors.bright}Examples:${colors.reset}
1528
+ uniweb create my-project # Foundation + site + starter content
1529
+ uniweb create my-project --template marketing # Official template
1530
+ uniweb create my-project --blank # Empty workspace
1531
+ uniweb create . # Scaffold into current dir
1532
+ uniweb create . --template docs # In place + a content template
1533
+ uniweb create . --name=my-app # In place, explicit slug
1534
+ `,
1535
+ dev: `
1536
+ ${colors.cyan}${colors.bright}uniweb dev${colors.reset} ${colors.dim}— Start a dev server for a site${colors.reset}
1537
+
1538
+ ${colors.bright}Usage:${colors.reset}
1539
+ uniweb dev Start dev server for the (single) site
1540
+ uniweb dev <site> Start dev server for a specific site
1541
+ uniweb dev --site <name> Same, with explicit flag form
1542
+
1543
+ Thin wrapper around the package manager's workspace-filtered \`dev\`
1544
+ script (\`pnpm --filter <site> dev\` or \`npm -w <site> run dev\`). Picks
1545
+ the single site automatically; for multi-site workspaces the first
1546
+ site runs by default with a notice pointing at \`--site\` for explicit
1547
+ selection.
1548
+ `,
1549
+ snapshot: `
1550
+ ${colors.cyan}${colors.bright}uniweb snapshot${colors.reset} ${colors.dim}— Compose a preview image of a site${colors.reset}
1551
+
1552
+ ${colors.bright}Usage:${colors.reset}
1553
+ uniweb snapshot [<site>] [options]
1554
+ uniweb snapshot --compare [options]
1555
+
1556
+ Opens the site in a headless Chrome, captures it, and composes the captures into
1557
+ one image: by default ${colors.bright}site/public/preview.webp${colors.reset}, recorded as ${colors.cyan}preview:${colors.reset} in site.yml
1558
+ when site.yml has none, or only the app's generated one. A URL or image path you
1559
+ wrote is never replaced.
1560
+
1561
+ A page that scrolls gets the ${colors.bright}split${colors.reset} layout: the first view in a browser window,
1562
+ beside a long strip of the page. A page that does not scroll as a page (a docs
1563
+ shell, an app) gets ${colors.bright}device${colors.reset}: a desktop window and a phone.
1564
+
1565
+ Needs ${colors.cyan}@uniweb/snapshot${colors.reset} in the workspace (\`pnpm add -D -w @uniweb/snapshot\`) and
1566
+ Google Chrome, Microsoft Edge, or a Chromium named by $UNIWEB_SNAPSHOT_BROWSER.
1567
+
1568
+ ${colors.bright}Where the site comes from:${colors.reset}
1569
+ (default) Build the site, then capture dist/
1570
+ --no-build Capture the existing dist/ as is
1571
+ --dev Capture the site's Vite dev server (no build)
1572
+ --url <address> Capture a site that is already running
1573
+
1574
+ ${colors.bright}The look:${colors.reset}
1575
+ --layout <name> auto (default), split, device
1576
+ --gap <px> Space between the two frames (split default: 48)
1577
+ --overlap <px> Overlap them instead (device default: 31)
1578
+ --strip <width> split: fit (default), or 1:N — narrower shows more of a long page
1579
+ --side <side> Where the strip or phone goes: right (default), left
1580
+ --frame <style> browser (default, with a title bar), plain
1581
+ --tone <name> Background: auto (default), light, deep
1582
+
1583
+ ${colors.bright}Choosing and keeping a look:${colors.reset}
1584
+ --compare One capture, several looks on one sheet, each captioned with
1585
+ its flags (site/.uniweb/snapshot/compare.webp)
1586
+ --save Keep this run's flags in site/snapshot.yml, the site's defaults
1587
+
1588
+ ${colors.bright}Options:${colors.reset}
1589
+ --site <name> The site (default: the one you are in, or the only one)
1590
+ --route <path> The page to capture (default: the home page)
1591
+ --size <WxH> Image size in CSS pixels (default: 1600x1000)
1592
+ --scale <n> 1 (default) or 2 for a double-density image
1593
+ --quality <n> Encoder quality, 1–100 (default: 82)
1594
+ --out <file> Where to write it; .webp, .png, .jpg or .avif
1595
+ --hide <selector> Hide matching elements before capturing (repeatable)
1596
+ --no-set-preview Write the image without touching site.yml
1597
+ `,
1598
+ build: `
1599
+ ${colors.cyan}${colors.bright}uniweb build${colors.reset} ${colors.dim}— Build the current project${colors.reset}
1600
+
1601
+ ${colors.bright}Usage:${colors.reset}
1602
+ uniweb build [options]
1603
+
1604
+ At workspace root, builds all foundations first, then all sites.
1605
+ Pre-rendering is enabled by default when build.prerender: true in site.yml.
1606
+
1607
+ ${colors.bright}Options:${colors.reset}
1608
+ --target <type> Build target (foundation, site) — auto-detected if not specified
1609
+ --prerender Force pre-rendering (overrides site.yml)
1610
+ --no-prerender Skip pre-rendering (overrides site.yml)
1611
+ --foundation-dir Path to foundation directory (for prerendering)
1612
+ --host <name> Apply host-specific postBuild (e.g., cloudflare-pages emits _redirects)
1613
+ --platform <name> (Deprecated alias for --host)
1614
+ `,
1615
+ add: `
1616
+ ${colors.cyan}${colors.bright}Uniweb Add${colors.reset}
1617
+
1618
+ Add projects, foundations, sites, extensions, section types, or CI workflows to your workspace.
1619
+
1620
+ ${colors.bright}Usage:${colors.reset}
1621
+ uniweb add project [name] [options]
1622
+ uniweb add foundation [name] [options]
1623
+ uniweb add site [name] [options]
1624
+ uniweb add extension <name> [options]
1625
+ uniweb add section <name> [options]
1626
+ uniweb add ci [options]
1627
+
1628
+ ${colors.bright}Common Options:${colors.reset}
1629
+ --from <template> Apply content from a template after scaffolding
1630
+ --path <dir> The folder to put the package in
1631
+ --non-interactive Fail with usage info instead of prompting
1632
+
1633
+ ${colors.bright}Foundation Options:${colors.reset}
1634
+ --project <name> Group under a project directory (co-located layout)
1635
+
1636
+ ${colors.bright}Site Options:${colors.reset}
1637
+ --foundation <n> Foundation to wire to (prompted if multiple exist)
1638
+ --project <name> Group under a project directory (co-located layout)
1639
+
1640
+ ${colors.bright}Extension Options:${colors.reset}
1641
+ --site <name> Site to wire extension URL into
1642
+
1643
+ ${colors.bright}Section Options:${colors.reset}
1644
+ --foundation <n> Foundation to add section to (prompted if multiple exist)
1645
+ --starter Generate starter content from the section's \`content:\`
1646
+ declaration — what an author would begin editing. Works on
1647
+ a section that already exists (nothing is written), and on
1648
+ a new one (the scaffold gets a matching declaration)
1649
+ --preset <name> Frontmatter the starter content with this preset's params
1650
+ --write <file> Write the starter markdown to a file instead of printing it
1651
+ --json Emit the content structure and ProseMirror doc instead
1652
+
1653
+ ${colors.bright}CI Options:${colors.reset}
1654
+ --host <name> github-pages | cloudflare-pages | netlify | vercel
1655
+ (prompted when omitted)
1656
+ --target <what> site (default) | foundation
1657
+ 'foundation' publishes built foundations at permanent
1658
+ versioned URLs — the free alternative to the catalog
1659
+ --site <name> Site the workflow builds (prompted if multiple exist)
1660
+ --foundation <name> With --target foundation: publish just this one
1661
+ --domain <host> Custom domain (GitHub Pages: writes CNAME, serves at root)
1662
+ --project-name <name> Name to register under on the host (default: the
1663
+ workspace name; Cloudflare Pages project, etc.)
1664
+ --no-previews Skip the per-PR preview workflow
1665
+ --force Overwrite an existing workflow file
1666
+
1667
+ ${colors.dim}Hosts that support PR previews: cloudflare-pages, netlify, vercel.
1668
+ GitHub Pages has no preview environment, so it scaffolds a deploy workflow only.${colors.reset}
1669
+
1670
+ ${colors.bright}Examples:${colors.reset}
1671
+ uniweb add project docs # Create docs/src/ + docs/site/
1672
+ uniweb add project docs --from academic # Co-located pair + academic content
1673
+ uniweb add foundation # Create ./src/ at root
1674
+ uniweb add foundation ui # Create ./ui/ — the folder you name
1675
+ uniweb add foundation foundations/ui # Create ./foundations/ui/
1676
+ uniweb add site # Create ./site/ at root
1677
+ uniweb add site blog --foundation marketing # Create ./blog/ wired to marketing
1678
+ uniweb add extension effects --site site # Create ./effects/ — the folder you name
1679
+ uniweb add extension extensions/effects # Create ./extensions/effects/
1680
+ uniweb add section Hero # Create Hero section type
1681
+ uniweb add section Hero --foundation ui # Target specific foundation
1682
+ uniweb add section Hero --starter # Starter content for an existing Hero
1683
+ uniweb add section Pricing --starter # Scaffold Pricing + content that fills it
1684
+ uniweb add section Hero --starter --preset split # Frontmatter it with the 'split' preset
1685
+ uniweb add section Hero --starter --json # The structure + ProseMirror, for a script
1686
+ uniweb add foundation --project docs # Create ./docs/src/ (co-located)
1687
+ uniweb add site --project docs # Create ./docs/site/ (co-located)
1688
+ uniweb add ci # Pick a host, add a deploy workflow
1689
+ uniweb add ci --host github-pages --site marketing # Pick host + site explicitly
1690
+ uniweb add ci --host netlify # Deploy + PR-preview workflows
1691
+ uniweb add ci --host vercel --no-previews # Deploy workflow only
1692
+ uniweb add ci --domain mysite.com # Custom domain → writes CNAME + UNIWEB_BASE=/
1693
+ uniweb add ci --target foundation # Publish foundations at versioned URLs
1694
+ `,
1695
+ export: `
1696
+ ${colors.cyan}${colors.bright}uniweb export${colors.reset} ${colors.dim}— Export a self-contained site for third-party hosting${colors.reset}
1697
+
1698
+ ${colors.bright}Usage:${colors.reset}
1699
+ uniweb export [options]
1700
+
1701
+ Builds dist/ and prints upload examples for common static hosts. No login,
1702
+ no deploy step — you push the artifact to your host of choice yourself.
1703
+ For Uniweb-hosted sites, use \`uniweb deploy\`.
1704
+
1705
+ ${colors.bright}Options:${colors.reset}
1706
+ --no-prerender Skip per-page prerendered HTML
1707
+ --host <name> Apply host-specific postBuild (cloudflare-pages, github-pages, …)
1708
+ `,
1709
+ doctor: `
1710
+ ${colors.cyan}${colors.bright}uniweb doctor${colors.reset} ${colors.dim}— Diagnose project configuration issues${colors.reset}
1711
+
1712
+ ${colors.bright}Usage:${colors.reset}
1713
+ uniweb doctor [options]
1714
+
1715
+ ${colors.bright}Options:${colors.reset}
1716
+ --fix Apply fixes for safely-fixable issues
1717
+ --fix <issue-id> Apply fix for a specific issue id only
1718
+ --non-interactive Fail with usage info instead of prompting
1719
+
1720
+ Exit code is 1 if errors are found (warnings only → exit 0).
1721
+ `,
1722
+ validate: `
1723
+ ${colors.cyan}${colors.bright}uniweb validate${colors.reset} ${colors.dim}— Check your content against your foundation's data schemas${colors.reset}
1724
+
1725
+ ${colors.bright}Usage:${colors.reset}
1726
+ uniweb validate [path] [options]
1727
+
1728
+ Checks each section's file-based data inputs against the schema your
1729
+ foundation declared for that input (meta.js \`data:\`). Answers "does my
1730
+ data match what I promised?" — distinct from \`doctor\`, which checks your
1731
+ project against framework conventions.
1732
+
1733
+ Every record file is checked against the schema its folder names, whether or
1734
+ not a section reads it. A violation fails the run; \`push\` and \`publish\` refuse
1735
+ the same findings. The live render path stays tolerant. Remote (\`url:\`)
1736
+ inputs are not in the project, so they're reported as deferred, never silently
1737
+ skipped. A reference field's value is not checked against the entity it names.
1738
+
1739
+ ${colors.bright}Options:${colors.reset}
1740
+ --lax Report violations without failing (exit 0)
1741
+ --json Machine-readable output (for CI annotations)
1742
+ --site <name> Check one site in a multi-site workspace
1743
+
1744
+ Exit codes: 0 clean (or --lax), 1 violations, 2 setup error.
1745
+ `,
1746
+ register: `
1747
+ ${colors.cyan}${colors.bright}uniweb register${colors.reset} ${colors.dim}— Register (release) a foundation + its data schemas with the backend registry${colors.reset}
1748
+
1749
+ ${colors.bright}Usage:${colors.reset}
1750
+ uniweb register [options]
1751
+ uniweb release [options] ${colors.dim}(synonym — reads naturally for updates)${colors.reset}
1752
+
1753
+ Builds one \`.uwx\` document and submits it to the registry over HTTP. Run
1754
+ \`uniweb login\` first. \`register\`/\`release\` are for
1755
+ FOUNDATIONS (and schemas) — code only; \`uniweb publish\` makes a synced SITE
1756
+ live (and brings its foundation along); \`uniweb deploy\` hosts on a third-party
1757
+ host. \`register\` and \`release\` are the same act: \`register\` reads naturally
1758
+ the first time, \`release\` for updates.
1759
+
1760
+ Auto-detects what you run it in:
1761
+ • a foundation the foundation + the data schemas it defines/renders
1762
+ • a schemas-only pkg just its data schemas, no foundation — e.g. @uniweb/schemas,
1763
+ any @org/schemas package, or a bare schemas/*.yml folder
1764
+
1765
+ A foundation's scope is part of its name — name: '@scope/<name>' in main.js. A scope
1766
+ is a namespace: your personal one (@<your handle>, no org needed) or an org's. A bare
1767
+ name takes --scope, else the workspace you work in (an org's scope, or your personal
1768
+ one), and register writes it into the name.
1769
+
1770
+ Schema scopes:
1771
+ @/name your own schema, in the foundation's scope (@/x -> @org/x)
1772
+ @std/name a shared standard schema (from @uniweb/schemas)
1773
+ @org/name another org's published schema, referenced by name
1774
+
1775
+ ${colors.bright}Options:${colors.reset}
1776
+ --scope @scope A foundation with a bare name: register under @scope — yours or an
1777
+ org's — and write it into the name (refused when the name has another
1778
+ scope). A schemas-only package: publish under @scope; default:
1779
+ package.json uniweb.scope
1780
+ --org @org Work in @org for this command: a bare name registers under it
1781
+ --personal Work in your personal workspace: a bare name registers under your
1782
+ personal scope
1783
+ --dry-run Print the .uwx; submit nothing
1784
+ -o, --output <f> Write the .uwx to a file; submit nothing
1785
+ --non-interactive Fail with usage info instead of prompting
1786
+
1787
+ Run from a foundation, a schemas-only package, or a workspace with a single foundation.
1788
+ `,
1789
+ rename: `
1790
+ ${colors.cyan}${colors.bright}uniweb rename${colors.reset} ${colors.dim}— Rename a workspace package${colors.reset}
1791
+
1792
+ ${colors.bright}Usage:${colors.reset}
1793
+ uniweb rename foundation <old> <new>
1794
+ uniweb rename site <old> <new>
1795
+ uniweb rename extension <old> <new>
1796
+
1797
+ Each subcommand updates a different set of touch points:
1798
+
1799
+ foundation: package.json::name + folder + every dependent site's
1800
+ package.json (dep key + file: path) + every site.yml::foundation +
1801
+ pnpm-workspace.yaml / package.json::workspaces + root scripts.
1802
+
1803
+ site: package.json::name + folder + workspace manifests + root
1804
+ scripts (\`dev\` / \`preview\` are filtered by site name).
1805
+
1806
+ extension: package.json::name + folder + every site.yml::extensions
1807
+ URL whose path matches the old folder + workspace manifests.
1808
+ (Sites don't carry a \`file:\` dep on extensions — they load by
1809
+ URL at runtime, so no per-site package.json updates.)
1810
+
1811
+ Transactional — bails on conflict (target name taken, target not found,
1812
+ folder collision, type mismatch) before any filesystem mutation.
1813
+
1814
+ Type guards: \`rename foundation\` against an extension errors and
1815
+ points at \`rename extension\` (and vice versa). They share a build
1816
+ shape but the touch-point sets differ; using the wrong subcommand
1817
+ would update the wrong things.
1818
+ `,
1819
+ login: `
1820
+ ${colors.cyan}${colors.bright}uniweb login${colors.reset} ${colors.dim}— Log in to your Uniweb account${colors.reset}
1821
+
1822
+ ${colors.bright}Usage:${colors.reset}
1823
+ uniweb login [options]
1824
+
1825
+ Authenticates with a backend and stores its session in
1826
+ ~/.uniweb/registry-auth.json. ${colors.bright}One backend at a time:${colors.reset} logging in to another one
1827
+ logs you out of the first (once the new login succeeds). The backend you are
1828
+ logged in to is where the backend commands go (push, pull, publish, status,
1829
+ register, clone) — so this is how you switch. Already logged in to it, login
1830
+ does nothing; add --password, --browser, --token-paste or --token to log in again.
1831
+
1832
+ Without --server: https://uniweb.app (or \$UNIWEB_SERVER). No command talks
1833
+ to a backend you are not logged in to — run one before logging in and it asks first.
1834
+
1835
+ ${colors.bright}The workspace you work in.${colors.reset} A login works in ONE workspace — your
1836
+ personal one, or an organization's — and every push, pull and publish works in it:
1837
+ a site it creates is created there, and a site kept in another workspace is refused.
1838
+ With no organization it is your personal workspace; with organizations you are asked,
1839
+ or name it. Already logged in, \`uniweb login --org @other\` (or \`--personal\`) switches
1840
+ the workspace on the backend you are logged in to, without logging in again.
1841
+
1842
+ ${colors.bright}Options:${colors.reset}
1843
+ --server <url> The backend to log in to (without it: the default backend — or, for
1844
+ --org / --personal alone, the one you are logged in to)
1845
+ --org @org Work in @org (an organization you belong to)
1846
+ --personal Work in your personal workspace
1847
+ --token <bearer> Seed + verify a session from a bearer token (non-interactive)
1848
+ --browser Force the browser method
1849
+ --password Force the username/password method
1850
+ --token-paste Force the paste-a-token prompt
1851
+
1852
+ In non-interactive mode (no TTY — an agent, a script), pass \`--token <bearer>\` with
1853
+ \`--org @org\` or \`--personal\`, or set \`UNIWEB_USERNAME\` + \`UNIWEB_PASSWORD\`.
1854
+ A login with organizations that names no workspace signs you in but exits 2;
1855
+ \`uniweb login --org @org\` (or \`--personal\`) finishes it without signing in again.
1856
+ \`uniweb logout\` logs you out.
1857
+
1858
+ ${colors.bright}Or skip the login.${colors.reset} With \`UNIWEB_TOKEN\` set — and \`UNIWEB_WORKSPACE=@org\` or
1859
+ \`personal\` — every backend command in that process uses the token, ahead of any
1860
+ session, and nothing is stored. \`uniweb login\` there only checks the token with the
1861
+ backend those commands go to — \`UNIWEB_SERVER\`, else the one you are logged in to,
1862
+ else the default — and says who and where they work as.
1863
+ `,
1864
+ refresh: `
1865
+ ${colors.cyan}${colors.bright}uniweb refresh${colors.reset} ${colors.dim}— Catch up with teammates AND app authors${colors.reset}
1866
+
1867
+ ${colors.bright}Usage:${colors.reset}
1868
+ uniweb refresh [options]
1869
+
1870
+ A developer on a synced site has TWO external sources: the git remote (teammates'
1871
+ commits) and the backend (content authors' edits in the app). Run only \`git pull\`
1872
+ and you miss every app edit; run only \`uniweb pull\` and you miss your teammates.
1873
+ This does both — git first, then \`pull --merge\`.
1874
+
1875
+ ${colors.bright}READ-ONLY.${colors.reset} It never pushes and cannot ship anything, so it is safe to run
1876
+ reflexively. Exits non-zero if a merge leaves conflicts.
1877
+
1878
+ ${colors.bright}Options:${colors.reset}
1879
+ --no-git Skip the git remote; backend only
1880
+ --no-backend Skip the backend; git only
1881
+
1882
+ \`--force\` and \`--merge\` are NOT accepted here: this verb builds the delegated
1883
+ pull's arguments itself, and to pull \`--force\` means "discard my local work".
1884
+ Use \`uniweb sync\` when you also mean to share.
1885
+ `,
1886
+ sync: `
1887
+ ${colors.cyan}${colors.bright}uniweb sync${colors.reset} ${colors.dim}— Catch up, then share${colors.reset}
1888
+
1889
+ ${colors.bright}Usage:${colors.reset}
1890
+ uniweb sync [options]
1891
+
1892
+ \`uniweb refresh\` followed by \`uniweb push\`, and nothing more. It adds no
1893
+ guarantees of its own — refresh stops on conflicts, push refuses when the backend
1894
+ has moved under you, and sync inherits both. \`uniweb refresh && uniweb push\`
1895
+ behaves identically.
1896
+
1897
+ ${colors.bright}It does NOT publish.${colors.reset} Sync brings your copy and the backend DRAFT into
1898
+ agreement; going live stays a separate act (\`uniweb publish\`). So the worst case
1899
+ of an unintended sync is work-in-progress visible to authors, not shipped to
1900
+ visitors.
1901
+
1902
+ ${colors.bright}Options:${colors.reset}
1903
+ Accepts every option of both halves — see \`uniweb refresh --help\` and the push
1904
+ options in \`uniweb --help\`. The common ones:
1905
+
1906
+ --no-git Skip the git remote half of the refresh
1907
+ --force Overwrite upstream changes (reaches the PUSH half only)
1908
+
1909
+ \`--force\` means "overwrite upstream" to push but "discard my local work" to pull,
1910
+ so it is deliberately kept away from the refresh half.
1911
+ `,
1912
+ forget: `
1913
+ ${colors.cyan}${colors.bright}uniweb forget${colors.reset} ${colors.dim}— Remove what this project recorded about where it synced${colors.reset}
1914
+
1915
+ ${colors.bright}Usage:${colors.reset}
1916
+ uniweb forget --server <url>
1917
+ uniweb forget --all
1918
+
1919
+ Local files only. Every site stays where it is on its backend.
1920
+
1921
+ ${colors.bright}Options:${colors.reset}
1922
+ --server <url> Forget one backend: its entries in sync.json and in the local
1923
+ cache, and its deploy records in deploy.yml. Its targets stay,
1924
+ so the next publish there creates a new site.
1925
+ --all For a COPY of a project that should become a new one. Deletes
1926
+ sync.json, deploy.yml and the local cache — everything naming
1927
+ the original's sites and destinations. Without it, the copy's
1928
+ first push updates the original's site.
1929
+
1930
+ Record files keep their \`$uuid\`: it is the record's own id, not a backend's.
1931
+
1932
+ To duplicate a project:
1933
+ cp -r my-site my-site-2 && cd my-site-2 && uniweb forget --all
1934
+ `,
1935
+ invite: `
1936
+ ${colors.cyan}${colors.bright}uniweb invite${colors.reset} ${colors.dim}— (reserved; not available on the new backend yet)${colors.reset}
1937
+
1938
+ The foundation client-invite flow was retired with the legacy backend.
1939
+ Invite clients to your foundation from the Uniweb app for now.
1940
+ `,
1941
+ handoff: `
1942
+ ${colors.cyan}${colors.bright}uniweb handoff${colors.reset} ${colors.dim}— (reserved; not available on the new backend yet)${colors.reset}
1943
+
1944
+ The site-handoff flow was retired with the legacy backend.
1945
+ Manage client sites from the Uniweb app for now.
1946
+ `,
1947
+ families: `
1948
+ ${colors.cyan}${colors.bright}uniweb families${colors.reset} ${colors.dim}— The standard section families${colors.reset}
1949
+
1950
+ ${colors.bright}Usage:${colors.reset}
1951
+ uniweb families List them, grouped
1952
+ uniweb families <filter> Match an id, a label or a group name
1953
+ uniweb families --json Machine-readable (for scripts)
1954
+
1955
+ ${colors.bright}What a family is for:${colors.reset}
1956
+ It tells an editor which illustration to show for a section type, and gives
1957
+ it a label it can translate. Declare it in a section's meta.js:
1958
+
1959
+ ${colors.dim}export default { title: 'Researcher Profile', family: 'profile' }${colors.reset}
1960
+
1961
+ ${colors.bright}Most components need no declaration.${colors.reset} The section type IS the component
1962
+ name, so \`Hero\`, \`Footer\` and \`Pricing\` already resolve. \`family:\` is for the
1963
+ ones whose name does not give it away — \`ProfileHero\`, \`CvEntry\`.
1964
+
1965
+ An unrecognized value is legal: it falls back to a generic illustration and
1966
+ nothing breaks, so a foundation with its own vocabulary is never blocked.
1967
+
1968
+ \`uniweb doctor\` reports what each of your sections resolved to, and suggests
1969
+ a family for the ones it recognizes by another name.
1970
+
1971
+ ${colors.bright}Note:${colors.reset} this is the roster ${colors.bright}this CLI ships with${colors.reset}, not a live fetch — and it is
1972
+ the same list \`doctor\` matches against.
1973
+ `,
1974
+ template: `
1975
+ ${colors.cyan}${colors.bright}uniweb template${colors.reset} ${colors.dim}— Official templates${colors.reset}
1976
+
1977
+ ${colors.bright}Usage:${colors.reset}
1978
+ uniweb template list List the official templates
1979
+ uniweb template list --json Same, machine-readable (for scripts)
1980
+
1981
+ ${colors.bright}What \`list\` reports:${colors.reset}
1982
+ The roster ${colors.bright}this CLI ships with${colors.reset} — a snapshot baked in at publish time,
1983
+ not a live fetch. \`create\` downloads these templates from the release the same
1984
+ snapshot names, which is why \`--json\` stamps \`cliVersion\`. A template newer than
1985
+ your CLI is not listed: \`npx uniweb@latest template list\` shows the current roster.
1986
+
1987
+ It is the ${colors.bright}official${colors.reset} roster, not the set of resolvable names: an unknown
1988
+ name is not fatal, since \`create\` falls through to npm \`@uniweb/template-<name>\`.
1989
+ `,
1990
+ docs: `
1991
+ ${colors.cyan}${colors.bright}uniweb docs${colors.reset} ${colors.dim}— Generate component documentation${colors.reset}
1992
+
1993
+ ${colors.bright}Subcommands:${colors.reset}
1994
+ docs Generate COMPONENTS.md from foundation schema
1995
+ docs site Show site.yml configuration reference
1996
+ docs page Show page.yml configuration reference
1997
+ docs meta Show component meta.js reference
1998
+
1999
+ ${colors.bright}Options:${colors.reset}
2000
+ --output <file> Output filename (default: COMPONENTS.md)
2001
+ --from-source Read meta.js files directly instead of schema.json
2002
+ `,
2003
+ i18n: `
2004
+ ${colors.cyan}${colors.bright}uniweb i18n${colors.reset} ${colors.dim}— Internationalization workflow${colors.reset}
2005
+
2006
+ ${colors.bright}Subcommands:${colors.reset}
2007
+ i18n extract Extract translatable strings to manifest
2008
+ i18n sync Update manifest with content changes
2009
+ i18n status Show translation coverage per locale
2010
+ `,
2011
+ inspect: `
2012
+ ${colors.cyan}${colors.bright}uniweb inspect${colors.reset} ${colors.dim}— Inspect parsed content shape${colors.reset}
2013
+
2014
+ ${colors.bright}Usage:${colors.reset}
2015
+ uniweb inspect <path>
2016
+
2017
+ Prints the parsed content shape of a markdown file or folder — the
2018
+ { content, params, items, … } object that components actually receive.
2019
+ Useful for debugging "why isn't my section getting X?".
2020
+ `,
2021
+ update: `
2022
+ ${colors.cyan}${colors.bright}uniweb update${colors.reset} ${colors.dim}— Align this project with the running CLI${colors.reset}
2023
+
2024
+ ${colors.bright}Usage:${colors.reset}
2025
+ uniweb update Align deps + refresh AGENTS.md
2026
+ uniweb update --deps-only Only align workspace @uniweb/* deps
2027
+ uniweb update --agents-only Only refresh AGENTS.md
2028
+ uniweb update --no-deps Skip the deps-alignment step
2029
+ uniweb update --no-agents Skip the AGENTS.md step
2030
+ uniweb update --dry-run Print survey + would-be writes; no mutations
2031
+ uniweb update --allow-mismatch Refresh AGENTS.md even if declared deps lag
2032
+ uniweb update --yes Don't prompt — apply edits and run the install
2033
+
2034
+ ${colors.bright}What it does:${colors.reset}
2035
+ Prints a version survey first (CLI version, AGENTS.md stamp, every
2036
+ @uniweb/* + uniweb dep declared in workspace package.json files,
2037
+ marked aligned / behind / ahead). Then two steps:
2038
+
2039
+ 1. ${colors.bright}Align workspace deps${colors.reset} to this CLI's bundled version matrix —
2040
+ rewrites the @uniweb/* + uniweb keys in each package.json that lags
2041
+ (deps ahead of the matrix are left alone — never downgraded;
2042
+ existing indentation is preserved), then offers to run the
2043
+ workspace's package manager (lockfile-detected: pnpm-lock.yaml →
2044
+ pnpm, yarn.lock → yarn, package-lock.json → npm). If the install
2045
+ fails, the package.json edits are kept and a revert command printed.
2046
+ 2. ${colors.bright}Refresh AGENTS.md${colors.reset} from this CLI's bundled partial. Won't run
2047
+ if deps were edited but not installed (node_modules would be behind
2048
+ package.json), or while declared deps still lag the CLI (would
2049
+ document features not in your packages) — pass --allow-mismatch for
2050
+ the latter.
2051
+
2052
+ ${colors.bright}Which matrix?${colors.reset}
2053
+ \`update\` pins to the version matrix *this* CLI shipped with — not
2054
+ necessarily the latest release. To reconcile against the latest release
2055
+ without touching a global install, run \`npx uniweb@latest update\`. This
2056
+ command does NOT update the CLI itself; use your package manager
2057
+ (\`npm i -g uniweb@latest\`, \`pnpm add -g uniweb@latest\`, …).
2058
+
2059
+ ${colors.bright}Project-local installs:${colors.reset}
2060
+ When run from a project-local CLI (in node_modules), it aligns the
2061
+ project to that pinned version — bump \`uniweb\` in package.json (or use
2062
+ \`npx uniweb@latest update\`) to align to something newer.
2063
+ `
2064
+ }
2065
+
2066
+ if (!blocks[command]) return false
2067
+ log(blocks[command])
2068
+ return true
2069
+ }
2070
+
2071
+ function showHelp() {
2072
+ log(`
2073
+ ${colors.cyan}${colors.bright}Uniweb CLI${colors.reset} ${colors.dim}v${getCliVersion()}${colors.reset}
2074
+
2075
+ ${colors.bright}Usage:${colors.reset}
2076
+ uniweb <command> [options]
2077
+
2078
+ ${colors.bright}Commands:${colors.reset}
2079
+ create [name] Create a new project
2080
+ clone <site-uuid> Clone a backend site into a local file project
2081
+ add <type> [name] Add a foundation, site, or extension to a project
2082
+ rename <type> Rename a foundation, site, or extension across the workspace
2083
+ dev Start a dev server for a site
2084
+ build Build the current project
2085
+ snapshot Compose a preview image of a site (its site.yml preview:)
2086
+ publish Publish a site to Uniweb hosting (smart: foundation + sync + go live)
2087
+ deploy Ship a site to a host (asks where, if not yet configured)
2088
+ export Export a self-contained site for third-party hosting
2089
+ register Register a foundation + its data schemas with the backend registry
2090
+ release Release a foundation version (synonym of register)
2091
+ push Push a site's content to the backend
2092
+ pull Pull a site's content from the backend
2093
+ refresh Catch up with the git remote AND the backend (never pushes)
2094
+ sync Catch up, then push (refresh + push)
2095
+ status Show a site's sync state (unpushed content, foundation)
2096
+ forget Forget a backend, or --all to make a copied project a new one
2097
+ site <cmd> The sites in a workspace: list, unpublish, delete
2098
+ inspect <path> Inspect parsed content shape of a markdown file or folder
2099
+ docs Generate component documentation
2100
+ families List the standard section families (for meta.js family:)
2101
+ template list List the official templates (for create --template)
2102
+ doctor Diagnose project configuration issues
2103
+ validate Check your content against your foundation's data schemas
2104
+ update Align workspace deps + AGENTS.md to the running CLI
2105
+ i18n <cmd> Internationalization (extract, sync, status)
2106
+ login Log in to your Uniweb account
2107
+ logout Log out of the backend you are logged in to
2108
+
2109
+ ${colors.bright}Create Options:${colors.reset}
2110
+ --template <type> Project template (default: starter)
2111
+ --blank Create an empty workspace (grow with uniweb add)
2112
+ --name <name> Project display name
2113
+ --no-git Skip git repository initialization
2114
+
2115
+ ${colors.bright}Add Subcommands:${colors.reset}
2116
+ add project [name] Add a co-located foundation + site pair
2117
+ add foundation [name] Add a foundation (--from, --path, --project)
2118
+ add site [name] Add a site (--from, --foundation, --path, --project)
2119
+ add extension <name> Add an extension (--from, --site, --path)
2120
+ add section <name> Add a section type to a foundation (--foundation, --starter)
2121
+ add ci Add a CI workflow so every push deploys (--host, --target)
2122
+
2123
+ ${colors.bright}Global Options:${colors.reset}
2124
+ --version, -v Show version
2125
+ --non-interactive Fail with usage info instead of prompting
2126
+ Auto-detected when CI=true or no TTY (pipes, agents)
2127
+
2128
+ Backend commands (push, pull, publish, status, register, clone) go to the
2129
+ backend you are logged in to, with its session. Switch with
2130
+ \`uniweb login --server <url>\` (add \`--token <bearer>\` to sign in with a token);
2131
+ a script can aim and authenticate one process with UNIWEB_SERVER and
2132
+ UNIWEB_TOKEN instead.
2133
+
2134
+ ${colors.bright}Push Options:${colors.reset}
2135
+ --dry-run Report what would be pushed; send nothing (-o <file> writes the .uwx)
2136
+ --no-release Send content against the already-released code; release nothing
2137
+ --bump Release above a newer registered foundation version
2138
+ --force Overwrite upstream changes (drop the staleness gate)
2139
+ --all Send every record (bypass the changed-only cache)
2140
+ --org @org Work in @org for this push, not your login's workspace
2141
+ --personal Work in your personal workspace for this push
2142
+ --no-validate Skip the content-conformance check (it stops a push)
2143
+
2144
+ ${colors.bright}Publish Options:${colors.reset}
2145
+ --dry-run Resolve everything; release/sync/POST nothing
2146
+ --yes Skip confirmations (CI); never block on a prompt
2147
+ --no-release Ship content against the already-released code; release nothing
2148
+ --bump Release above a newer registered foundation version
2149
+ --org @org Work in @org for this publish, not your login's workspace
2150
+ --personal Work in your personal workspace for this publish
2151
+ --no-save Do not record this deploy in deploy.yml
2152
+ --no-validate Skip the content-conformance check (it stops a publish)
2153
+
2154
+ uniweb publish is the smart Uniweb-hosting path: it brings the site's
2155
+ foundation along, syncs, and goes live. To register a foundation on its own
2156
+ use \`uniweb register\` (alias \`uniweb release\`); for a third-party host use
2157
+ \`uniweb deploy --host=<name>\`.
2158
+
2159
+ ${colors.bright}Deploy Options:${colors.reset}
2160
+ --host <name> The host to ship to. Omit it (TTY) to open the wizard:
2161
+ github-pages, cloudflare-pages, netlify, vercel,
2162
+ s3-cloudfront, or \`uniweb\` (delegates to \`uniweb publish\`).
2163
+ --target <name> Pick a target from deploy.yml (default: deploy.yml's \`default:\`)
2164
+ --dry-run Resolve the target + adapter; print summary; upload nothing
2165
+ --no-save Do not record this deploy in deploy.yml
2166
+ --no-validate Skip the content-conformance check (a static host warns)
2167
+ To deploy on every push instead, see \`uniweb add ci --help\`.
2168
+
2169
+ ${colors.bright}Dev Options:${colors.reset}
2170
+ <site> Site name to run (positional)
2171
+ --site <name> Site name to run (explicit form)
2172
+
2173
+ ${colors.bright}Export Options:${colors.reset}
2174
+ --no-prerender Skip per-page prerendered HTML
2175
+ --host <name> Apply host-specific postBuild (cloudflare-pages, github-pages, …)
2176
+
2177
+ ${colors.bright}Build Options:${colors.reset}
2178
+ --target <type> Build target (foundation, site) — auto-detected if not specified
2179
+ --prerender Force pre-rendering (overrides site.yml)
2180
+ --no-prerender Skip pre-rendering (overrides site.yml)
2181
+ --foundation-dir Path to foundation directory (for prerendering)
2182
+ --host <name> Apply host-specific postBuild (cloudflare-pages, s3-cloudfront, …)
2183
+ --platform <name> (Deprecated alias for --host)
2184
+
2185
+ At workspace root, builds all foundations first, then all sites.
2186
+ Pre-rendering is enabled by default when build.prerender: true in site.yml.
2187
+ See \`uniweb <command> --help\` for command-specific detail and examples.
2188
+
2189
+ ${colors.bright}Docs Subcommands:${colors.reset}
2190
+ docs Generate COMPONENTS.md from foundation schema
2191
+ docs site Show site.yml configuration reference
2192
+ docs page Show page.yml configuration reference
2193
+ docs meta Show component meta.js reference
2194
+
2195
+ ${colors.bright}Docs Options:${colors.reset}
2196
+ --output <file> Output filename (default: COMPONENTS.md)
2197
+ --from-source Read meta.js files directly instead of schema.json
2198
+
2199
+ ${colors.bright}i18n Commands:${colors.reset}
2200
+ extract Extract translatable strings to manifest
2201
+ sync Update manifest with content changes
2202
+ status Show translation coverage per locale
2203
+
2204
+ ${colors.bright}Template Types:${colors.reset}
2205
+ starter Foundation + site + sample content (default)
2206
+ none Foundation + site with no content
2207
+ marketing Official marketing template
2208
+ ./path/to/template Local directory
2209
+ @scope/template-name npm package
2210
+ github:user/repo GitHub repository
2211
+ https://github.com/user/repo GitHub URL
2212
+
2213
+ ${colors.bright}Examples:${colors.reset}
2214
+ uniweb create my-project # Foundation + site + starter content
2215
+ uniweb create my-project --template none # Foundation + site, no content
2216
+ uniweb create my-project --blank # Empty workspace
2217
+ uniweb create my-project --template marketing # Official template
2218
+ uniweb create my-project --template ./my-template # Local template
2219
+
2220
+ cd my-project
2221
+ uniweb add project docs # Add docs/src/ + docs/site/
2222
+ uniweb add project docs --from academic # Co-located pair + academic content
2223
+ uniweb add foundation marketing # Add marketing/ at root
2224
+ uniweb add site blog --foundation marketing # Add blog/ wired to marketing
2225
+ uniweb add extension effects --site site # Add effects/
2226
+
2227
+ uniweb build
2228
+ uniweb build --target src # Build src/ package
2229
+ cd src && uniweb docs # Generate COMPONENTS.md
2230
+
2231
+ ${colors.bright}Install:${colors.reset}
2232
+ npm i -g uniweb Global install (recommended)
2233
+ npx uniweb <command> Run without installing
2234
+ `)
2235
+ }
2236
+
2237
+ // Run CLI
2238
+ main().catch(async (err) => {
2239
+ error(err.message)
2240
+ await exitWhenDrained(1)
2241
+ })