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
@@ -0,0 +1,1900 @@
1
+ /**
2
+ * Add Command
3
+ *
4
+ * Adds foundations, sites, extensions, section types, or co-located projects to an existing workspace.
5
+ *
6
+ * Usage:
7
+ * uniweb add project [name] [--from <template>]
8
+ * uniweb add foundation [name] [--from <template>] [--path <dir>] [--project <name>]
9
+ * uniweb add site [name] [--from <template>] [--foundation <name>] [--path <dir>] [--project <name>]
10
+ * uniweb add extension [name] [--from <template>] [--site <name>] [--path <dir>]
11
+ * uniweb add section <name> [--foundation <name>]
12
+ */
13
+
14
+ import { existsSync } from 'node:fs'
15
+ import { readFile, writeFile, mkdir } from 'node:fs/promises'
16
+ import { join, relative, basename } from 'node:path'
17
+ import prompts from 'prompts'
18
+ import yaml from 'js-yaml'
19
+ import { resolveFoundationSrcPath, readFoundationName } from '@uniweb/build'
20
+ import {
21
+ scaffoldFoundation,
22
+ scaffoldSite,
23
+ applyContent,
24
+ applyStarter,
25
+ mergeTemplateDependencies
26
+ } from '../utils/scaffold.js'
27
+ import {
28
+ resolvePlacement,
29
+ SITE_KIND,
30
+ FOUNDATION_KIND,
31
+ EXTENSION_KIND
32
+ } from '../utils/placement.js'
33
+ import {
34
+ readWorkspaceConfig,
35
+ addWorkspaceGlob,
36
+ updateRootScripts
37
+ } from '../utils/config.js'
38
+ import { discoverFoundations, discoverSites } from '../utils/discover.js'
39
+ import { generateStarter, reportStarter, declarationFor } from './starter.js'
40
+ import {
41
+ validatePackageName,
42
+ getExistingPackageNames,
43
+ resolveUniqueName
44
+ } from '../utils/names.js'
45
+ import { findWorkspaceRoot } from '../utils/workspace.js'
46
+ import {
47
+ normalizeFoundationName,
48
+ ensureFoundationName
49
+ } from '../utils/foundation-name.js'
50
+ import {
51
+ detectPackageManager,
52
+ detectWorkspacePm,
53
+ detectInstalledPnpmVersion,
54
+ filterCmd,
55
+ installCmd
56
+ } from '../utils/pm.js'
57
+ import {
58
+ isNonInteractive,
59
+ getCliPrefix,
60
+ stripNonInteractiveFlag,
61
+ formatOptions
62
+ } from '../utils/interactive.js'
63
+ import { resolveTemplate } from '../templates/index.js'
64
+ import { validateTemplate } from '../templates/validator.js'
65
+ import {
66
+ getVersionsForTemplates,
67
+ resolveCiPnpmVersion,
68
+ resolveCiNodeVersion
69
+ } from '../versions.js'
70
+
71
+ // Colors for terminal output
72
+ const colors = {
73
+ reset: '\x1b[0m',
74
+ bright: '\x1b[1m',
75
+ dim: '\x1b[2m',
76
+ cyan: '\x1b[36m',
77
+ green: '\x1b[32m',
78
+ yellow: '\x1b[33m',
79
+ red: '\x1b[31m'
80
+ }
81
+
82
+ // Porcelain (`--json`) mode: stdout carries ONLY the JSON, so every human line
83
+ // diverts to stderr — the convention `register.js` already follows and
84
+ // `families.js` states ("stdout carries JSON and nothing else, so it pipes").
85
+ //
86
+ // ⛔ It is set per run rather than only when true, because a module-level flag
87
+ // that is only ever turned ON leaks into the next call in the same process —
88
+ // which is every call in the test suite.
89
+ let jsonMode = false
90
+ export function setAddJsonMode(on) {
91
+ jsonMode = Boolean(on)
92
+ }
93
+
94
+ function log(message) {
95
+ if (jsonMode) console.error(message)
96
+ else console.log(message)
97
+ }
98
+ function success(message) {
99
+ log(`${colors.green}✓${colors.reset} ${message}`)
100
+ }
101
+ function error(message) {
102
+ console.error(`${colors.red}✗${colors.reset} ${message}`)
103
+ }
104
+ function info(message) {
105
+ log(`${colors.dim}${message}${colors.reset}`)
106
+ }
107
+
108
+ /**
109
+ * Parse add command arguments
110
+ */
111
+ function parseArgs(args) {
112
+ const result = {
113
+ subcommand: args[0], // foundation, site, extension, ci, …
114
+ name: null,
115
+ path: null,
116
+ project: null,
117
+ foundation: null,
118
+ site: null,
119
+ from: null,
120
+ host: null,
121
+ force: false,
122
+ domain: null,
123
+ // `add ci` only: what the workflow publishes ('site' | 'foundation'),
124
+ // the name to register under on the host, and whether to also
125
+ // scaffold PR-preview workflows.
126
+ target: null,
127
+ projectName: null,
128
+ previews: true,
129
+ // `add section` only: generate starter content for the section type from
130
+ // its `content:` declaration, and which preset's params to frontmatter it
131
+ // with. `--write` sends the markdown to a file instead of stdout.
132
+ starter: false,
133
+ preset: null,
134
+ write: null,
135
+ json: false
136
+ }
137
+
138
+ // Booleans (no value) consumed up-front so the value-flag loop below
139
+ // doesn't accidentally swallow the next positional.
140
+ const BOOLEAN_FLAGS = new Set(['--force', '--no-previews', '--starter', '--json'])
141
+
142
+ // Value flags, mapped to their result key. Both spellings are accepted:
143
+ // `--host github-pages` and `--host=github-pages`.
144
+ //
145
+ // The `=` form used to be dropped silently — every branch matched on an
146
+ // exact `args[i] === '--flag'`, so `--host=github-pages` fell through
147
+ // and left `host` null. It went unnoticed because `add ci` defaulted to
148
+ // github-pages when no host was given, making the one command our docs
149
+ // print (`uniweb add ci --host=github-pages`) appear to work while
150
+ // actually ignoring the flag. Requiring a real host surfaced it.
151
+ const VALUE_FLAGS = {
152
+ '--path': 'path',
153
+ '--project': 'project',
154
+ '--foundation': 'foundation',
155
+ '--site': 'site',
156
+ '--from': 'from',
157
+ '--host': 'host',
158
+ '--domain': 'domain',
159
+ '--target': 'target',
160
+ '--project-name': 'projectName',
161
+ '--preset': 'preset',
162
+ '--write': 'write'
163
+ }
164
+
165
+ /** Split `--flag=value` into [flag, value]; `--flag` into [flag, null]. */
166
+ const splitFlag = (arg) => {
167
+ const eq = arg.indexOf('=')
168
+ return eq === -1 ? [arg, null] : [arg.slice(0, eq), arg.slice(eq + 1)]
169
+ }
170
+
171
+ // Find positional name (first arg after subcommand that's not a flag).
172
+ for (let i = 1; i < args.length; i++) {
173
+ if (args[i].startsWith('--')) {
174
+ const [flag, inlineValue] = splitFlag(args[i])
175
+ // Only skip the next arg when the value is genuinely separate.
176
+ // Previously an `=`-form flag also consumed the following arg,
177
+ // which could swallow the positional name.
178
+ if (!BOOLEAN_FLAGS.has(flag) && inlineValue === null) i++
179
+ continue
180
+ }
181
+ if (!result.name) {
182
+ result.name = args[i]
183
+ }
184
+ }
185
+
186
+ // Parse flags
187
+ for (let i = 1; i < args.length; i++) {
188
+ const [flag, inlineValue] = splitFlag(args[i])
189
+ const key = VALUE_FLAGS[flag]
190
+ if (key) {
191
+ if (inlineValue !== null) {
192
+ result[key] = inlineValue
193
+ } else if (args[i + 1] !== undefined && !args[i + 1].startsWith('--')) {
194
+ result[key] = args[++i]
195
+ }
196
+ } else if (flag === '--force') {
197
+ result.force = true
198
+ } else if (flag === '--no-previews') {
199
+ result.previews = false
200
+ } else if (flag === '--starter') {
201
+ result.starter = true
202
+ } else if (flag === '--json') {
203
+ result.json = true
204
+ }
205
+ }
206
+
207
+ return result
208
+ }
209
+
210
+ /**
211
+ * Main add command handler
212
+ */
213
+ export async function add(rawArgs) {
214
+ const nonInteractive = isNonInteractive(rawArgs)
215
+ const args = stripNonInteractiveFlag(rawArgs)
216
+
217
+ const pm = detectPackageManager()
218
+ const prefix = getCliPrefix()
219
+
220
+ // Find workspace root
221
+ const rootDir = findWorkspaceRoot()
222
+ if (!rootDir) {
223
+ error(
224
+ 'Not in a Uniweb workspace. Run this command from a project directory.'
225
+ )
226
+ error('Use "uniweb create" to create a new project first.')
227
+ process.exit(1)
228
+ }
229
+
230
+ // Interactive subcommand chooser when no args given
231
+ let parsed
232
+ if (!args.length || (args[0] && args[0].startsWith('--'))) {
233
+ if (nonInteractive) {
234
+ error(`Missing subcommand.\n`)
235
+ log(
236
+ formatOptions([
237
+ {
238
+ label: 'project',
239
+ description: 'Co-located foundation + site pair'
240
+ },
241
+ {
242
+ label: 'foundation',
243
+ description: 'Component system for content authors'
244
+ },
245
+ { label: 'site', description: 'Content site' },
246
+ { label: 'extension', description: 'Additional component package' },
247
+ { label: 'section', description: 'Section type in a foundation' },
248
+ {
249
+ label: 'ci',
250
+ description: 'CI deploy workflow for a host (e.g., GitHub Pages)'
251
+ }
252
+ ])
253
+ )
254
+ log('')
255
+ log(
256
+ `Usage: ${prefix} add <project|foundation|site|extension|section|ci> [name]`
257
+ )
258
+ process.exit(1)
259
+ }
260
+
261
+ const response = await prompts(
262
+ {
263
+ type: 'select',
264
+ name: 'subcommand',
265
+ message: 'What would you like to add?',
266
+ choices: [
267
+ {
268
+ title: 'Project',
269
+ value: 'project',
270
+ description: 'Co-located foundation + site pair'
271
+ },
272
+ {
273
+ title: 'Foundation',
274
+ value: 'foundation',
275
+ description: 'Component system for content authors'
276
+ },
277
+ { title: 'Site', value: 'site', description: 'Content site' },
278
+ {
279
+ title: 'Extension',
280
+ value: 'extension',
281
+ description: 'Additional component package'
282
+ },
283
+ {
284
+ title: 'Section',
285
+ value: 'section',
286
+ description: 'Section type in a foundation'
287
+ },
288
+ {
289
+ title: 'CI workflow',
290
+ value: 'ci',
291
+ description: 'Deploy workflow for a host (e.g., GitHub Pages)'
292
+ }
293
+ ]
294
+ },
295
+ {
296
+ onCancel: () => {
297
+ log('\nCancelled.')
298
+ process.exit(0)
299
+ }
300
+ }
301
+ )
302
+ parsed = parseArgs([response.subcommand, ...args])
303
+ } else {
304
+ parsed = parseArgs(args)
305
+ }
306
+
307
+ // Read root package.json for project name
308
+ const rootPkg = JSON.parse(
309
+ await readFile(join(rootDir, 'package.json'), 'utf-8').catch(() => '{}')
310
+ )
311
+ const projectName = rootPkg.name || 'my-project'
312
+
313
+ switch (parsed.subcommand) {
314
+ case 'project':
315
+ await addProject(rootDir, projectName, parsed, pm)
316
+ break
317
+ case 'foundation':
318
+ await addFoundation(rootDir, projectName, parsed, pm)
319
+ break
320
+ case 'site':
321
+ await addSite(rootDir, projectName, parsed, pm)
322
+ break
323
+ case 'extension':
324
+ await addExtension(rootDir, projectName, parsed, pm)
325
+ break
326
+ case 'section':
327
+ await addSection(rootDir, parsed)
328
+ break
329
+ case 'ci':
330
+ // The scaffolded workflow must install the way THIS WORKSPACE
331
+ // installs, which is a property of the repo (its lockfile), not of
332
+ // how the CLI happened to be launched. `detectPackageManager()`
333
+ // reads npm_config_user_agent, so `npx uniweb add ci` in a pnpm
334
+ // workspace produced a workflow running `npm ci` — which fails on
335
+ // the first CI run, because there is no package-lock.json to
336
+ // install from. npx is a normal way to run the CLI, so this broke
337
+ // the common case. Fall back to the invocation only when the repo
338
+ // has no lockfile to read.
339
+ await addCi(rootDir, parsed, detectWorkspacePm(rootDir) || pm)
340
+ break
341
+ default:
342
+ error(`Unknown subcommand: ${parsed.subcommand}`)
343
+ log(
344
+ `Valid subcommands: project, foundation, site, extension, section, ci`
345
+ )
346
+ process.exit(1)
347
+ }
348
+ }
349
+
350
+ /**
351
+ * Add a foundation to the workspace
352
+ */
353
+ async function addFoundation(rootDir, projectName, opts, pm = 'pnpm') {
354
+ const name = opts.name
355
+ const existingNames = await getExistingPackageNames(rootDir)
356
+
357
+ // Resolve placement first (path + package name) so we have everything we
358
+ // need to validate before scaffolding. Note: `name` here may be a bare
359
+ // name (`ui`) or a path (`foundations/ui`); resolvePlacement handles
360
+ // both. Format validation runs on the derived package name below, not
361
+ // on the raw input — slashes in the input are intentional path syntax.
362
+ const placement = resolvePlacement(rootDir, name, opts, FOUNDATION_KIND)
363
+ if (placement.outsideRoot) {
364
+ error(`--path must name a folder inside the workspace: ${placement.outsideRoot}`)
365
+ log('')
366
+ log(`The workspace root is ${colors.bright}${rootDir}${colors.reset}.`)
367
+ log(`A package outside it cannot be a workspace member.`)
368
+ process.exit(1)
369
+ }
370
+ const { relativePath } = placement
371
+ let { packageName } = placement
372
+ const fullPath = join(rootDir, relativePath)
373
+
374
+ // The foundation's own name — what it registers as, written to main.js — is
375
+ // the name asked for, before any workspace suffix below; with none (`src`),
376
+ // the project's.
377
+ const askedName =
378
+ normalizeFoundationName(packageName) || normalizeFoundationName(projectName)
379
+
380
+ // Validate the derived package name (format + reserved-name check). The
381
+ // auto-derived `src` default is grandfathered in (`src` IS reserved
382
+ // but `src` is the convention for "the package that lives in src/").
383
+ if (packageName !== 'src') {
384
+ const valid = validatePackageName(packageName)
385
+ if (valid !== true) {
386
+ error(valid)
387
+ process.exit(1)
388
+ }
389
+ }
390
+
391
+ // Collision check 1: target folder already exists.
392
+ if (existsSync(fullPath)) {
393
+ error(
394
+ `Cannot create foundation: ${colors.bright}${relativePath}/${colors.reset} already exists.`
395
+ )
396
+ log('')
397
+ log(`Pick a different name, or pass --path to choose a different folder:`)
398
+ log(
399
+ ` ${colors.cyan}${getCliPrefix()} add foundation <name>${colors.reset}`
400
+ )
401
+ log(
402
+ ` ${colors.cyan}${getCliPrefix()} add foundation <name> --path <parent-dir>${colors.reset}`
403
+ )
404
+ process.exit(1)
405
+ }
406
+
407
+ // Collision check 2: a package with the same name already exists somewhere
408
+ // in the workspace.
409
+ //
410
+ // Cross-role collisions auto-resolve. If a *site* already owns this name,
411
+ // suffix the foundation with `-src` (matching the `add project` and
412
+ // `add extension` precedents). The site keeps its name; the foundation
413
+ // gets a self-documenting suffix that says "this is the source code for
414
+ // the site that owns this name." Same-role collisions stay an error —
415
+ // two foundations with the same name is a real "be more specific"
416
+ // situation, not a disambiguation case.
417
+ if (existingNames.has(packageName)) {
418
+ const sites = await discoverSites(rootDir)
419
+ const isSiteCollision = sites.some((s) => s.name === packageName)
420
+ if (isSiteCollision) {
421
+ const suffixed = `${packageName}-src`
422
+ if (existingNames.has(suffixed)) {
423
+ error(
424
+ `Cannot create foundation: both ${colors.bright}${packageName}${colors.reset} and ${colors.bright}${suffixed}${colors.reset} are taken in this workspace.`
425
+ )
426
+ log(`Pick a different name:`)
427
+ log(
428
+ ` ${colors.cyan}${getCliPrefix()} add foundation <other-name>${colors.reset}`
429
+ )
430
+ process.exit(1)
431
+ }
432
+ info(
433
+ `Package "${packageName}" is taken by a site; using "${suffixed}" for this foundation.`
434
+ )
435
+ packageName = suffixed
436
+ } else {
437
+ error(
438
+ `Cannot create foundation: a foundation named ${colors.bright}${packageName}${colors.reset} already exists in this workspace.`
439
+ )
440
+ log(`Pick a different name:`)
441
+ log(
442
+ ` ${colors.cyan}${getCliPrefix()} add foundation <other-name>${colors.reset}`
443
+ )
444
+ process.exit(1)
445
+ }
446
+ }
447
+
448
+ // Scaffold
449
+ const registryName = await unclaimedFoundationName(rootDir, [
450
+ askedName,
451
+ normalizeFoundationName(packageName)
452
+ ])
453
+ await scaffoldFoundation(
454
+ fullPath,
455
+ {
456
+ name: packageName,
457
+ registryName,
458
+ projectName,
459
+ isExtension: false
460
+ },
461
+ {
462
+ onProgress: (msg) => info(` ${msg}`)
463
+ }
464
+ )
465
+
466
+ // Apply template content if --from specified. Its main.js replaces the
467
+ // scaffolded one, so the name is given again unless the template names it.
468
+ if (opts.from) {
469
+ await applyFromTemplate(opts.from, 'foundation', fullPath, projectName)
470
+ ensureFoundationName(fullPath, registryName)
471
+ }
472
+
473
+ // Register the package in pnpm-workspace.yaml — by exact path, not by glob.
474
+ // No glob inference: if the user wants `foundations/*` they can edit the
475
+ // workspace file themselves. This matches the "no assumptions" rule.
476
+ await addWorkspaceGlob(rootDir, relativePath)
477
+
478
+ // Update root scripts
479
+ const sites = await discoverSites(rootDir)
480
+ await updateRootScripts(rootDir, sites, pm)
481
+
482
+ success(
483
+ `Created foundation ${colors.bright}${packageName}${colors.reset} at ${relativePath}/`
484
+ )
485
+ log('')
486
+ log(`Next: ${colors.cyan}${installCmd(pm)}${colors.reset}`)
487
+ }
488
+
489
+ /**
490
+ * Add a site to the workspace
491
+ */
492
+ async function addSite(rootDir, projectName, opts, pm = 'pnpm') {
493
+ const name = opts.name
494
+ const existingNames = await getExistingPackageNames(rootDir)
495
+
496
+ // Resolve placement first (path + package name); see notes in addFoundation.
497
+ const placement = resolvePlacement(rootDir, name, opts, SITE_KIND)
498
+ if (placement.outsideRoot) {
499
+ error(`--path must name a folder inside the workspace: ${placement.outsideRoot}`)
500
+ log('')
501
+ log(`The workspace root is ${colors.bright}${rootDir}${colors.reset}.`)
502
+ log(`A package outside it cannot be a workspace member.`)
503
+ process.exit(1)
504
+ }
505
+ const { relativePath } = placement
506
+ let siteName = placement.packageName
507
+ const fullPath = join(rootDir, relativePath)
508
+
509
+ // Validate the package name (skip for the auto-derived 'site' default).
510
+ if (siteName !== 'site') {
511
+ const valid = validatePackageName(siteName)
512
+ if (valid !== true) {
513
+ error(valid)
514
+ process.exit(1)
515
+ }
516
+ }
517
+
518
+ // Collision check 1: target folder exists.
519
+ if (existsSync(fullPath)) {
520
+ error(
521
+ `Cannot create site: ${colors.bright}${relativePath}/${colors.reset} already exists.`
522
+ )
523
+ log('')
524
+ log(`Pick a different name, or pass --path to choose a different folder:`)
525
+ log(` ${colors.cyan}${getCliPrefix()} add site <name>${colors.reset}`)
526
+ log(
527
+ ` ${colors.cyan}${getCliPrefix()} add site <name> --path <parent-dir>${colors.reset}`
528
+ )
529
+ process.exit(1)
530
+ }
531
+
532
+ // Collision check 2: cross-role collisions auto-resolve with `-site`
533
+ // suffix; same-role collisions error. See the symmetric logic in
534
+ // addFoundation for the rationale.
535
+ if (existingNames.has(siteName)) {
536
+ const foundations = await discoverFoundations(rootDir)
537
+ const isFoundationCollision = foundations.some((f) => f.name === siteName)
538
+ if (isFoundationCollision) {
539
+ const suffixed = `${siteName}-site`
540
+ if (existingNames.has(suffixed)) {
541
+ error(
542
+ `Cannot create site: both ${colors.bright}${siteName}${colors.reset} and ${colors.bright}${suffixed}${colors.reset} are taken in this workspace.`
543
+ )
544
+ log(`Pick a different name:`)
545
+ log(
546
+ ` ${colors.cyan}${getCliPrefix()} add site <other-name>${colors.reset}`
547
+ )
548
+ process.exit(1)
549
+ }
550
+ info(
551
+ `Package "${siteName}" is taken by a foundation; using "${suffixed}" for this site.`
552
+ )
553
+ siteName = suffixed
554
+ } else {
555
+ error(
556
+ `Cannot create site: a site named ${colors.bright}${siteName}${colors.reset} already exists in this workspace.`
557
+ )
558
+ log(`Pick a different name:`)
559
+ log(
560
+ ` ${colors.cyan}${getCliPrefix()} add site <other-name>${colors.reset}`
561
+ )
562
+ process.exit(1)
563
+ }
564
+ }
565
+
566
+ // Resolve foundation
567
+ const foundation = await resolveFoundation(rootDir, opts.foundation)
568
+
569
+ if (foundation) {
570
+ // Compute relative path from site to foundation
571
+ const foundationPath = computeFoundationPath(relativePath, foundation.path)
572
+
573
+ // Scaffold
574
+ await scaffoldSite(
575
+ fullPath,
576
+ {
577
+ name: siteName,
578
+ projectName,
579
+ foundationName: foundation.name,
580
+ foundationPath,
581
+ foundationRef: foundation.name
582
+ },
583
+ {
584
+ onProgress: (msg) => info(` ${msg}`)
585
+ }
586
+ )
587
+ } else {
588
+ // No foundation — scaffold without wiring
589
+ await scaffoldSite(
590
+ fullPath,
591
+ {
592
+ name: siteName,
593
+ projectName,
594
+ foundationName: '',
595
+ foundationPath: ''
596
+ },
597
+ {
598
+ onProgress: (msg) => info(` ${msg}`)
599
+ }
600
+ )
601
+ log(
602
+ ` ${colors.yellow}⚠ No foundation wired. Add one later with: uniweb add foundation${colors.reset}`
603
+ )
604
+ }
605
+
606
+ // Apply template content if --from specified
607
+ if (opts.from) {
608
+ await applyFromTemplate(opts.from, 'site', fullPath, projectName)
609
+ }
610
+
611
+ // Register the package by exact path. No glob inference.
612
+ await addWorkspaceGlob(rootDir, relativePath)
613
+
614
+ // Update root scripts (discover sites after registration — includes the new one)
615
+ const sites = await discoverSites(rootDir)
616
+ if (!sites.find((s) => s.path === relativePath)) {
617
+ sites.push({ name: siteName, path: relativePath })
618
+ }
619
+ await updateRootScripts(rootDir, sites, pm)
620
+
621
+ if (foundation) {
622
+ success(
623
+ `Created site ${colors.bright}${siteName}${colors.reset} at ${relativePath}/ → foundation '${foundation.name}'`
624
+ )
625
+ } else {
626
+ success(
627
+ `Created site ${colors.bright}${siteName}${colors.reset} at ${relativePath}/`
628
+ )
629
+ }
630
+ log('')
631
+ log(
632
+ `Next: ${colors.cyan}${installCmd(pm)} && uniweb dev ${siteName}${colors.reset}`
633
+ )
634
+ if (!opts.from) {
635
+ log('')
636
+ log(
637
+ `${colors.dim}To add your first page, create ${relativePath}/pages/home/page.yml and a .md file.${colors.reset}`
638
+ )
639
+ log(
640
+ `${colors.dim}Or use --from to start with template content: uniweb add site --from starter${colors.reset}`
641
+ )
642
+ }
643
+ }
644
+
645
+ /**
646
+ * Add an extension to the workspace
647
+ */
648
+ async function addExtension(rootDir, projectName, opts, pm = 'pnpm') {
649
+ let name = opts.name
650
+ const existingNames = await getExistingPackageNames(rootDir)
651
+
652
+ // A name is asked for unless one is given, or `--path` names the folder.
653
+ if (!name && !opts.path) {
654
+ if (isNonInteractive(process.argv)) {
655
+ error(`Missing extension name.\n`)
656
+ log(`Usage: ${getCliPrefix()} add extension <name>`)
657
+ process.exit(1)
658
+ }
659
+
660
+ const response = await prompts(
661
+ {
662
+ type: 'text',
663
+ name: 'name',
664
+ message: 'Extension name:',
665
+ validate: (value) => validatePackageName(value)
666
+ },
667
+ {
668
+ onCancel: () => {
669
+ log('\nCancelled.')
670
+ process.exit(0)
671
+ }
672
+ }
673
+ )
674
+ name = response.name
675
+ }
676
+
677
+ // ⭐ Placed by the rule `add foundation` follows (utils/placement.js): the folder
678
+ // you name is the folder created — `effects` → effects/, `extensions/effects` →
679
+ // extensions/effects/ — and `--path` is the folder it goes in.
680
+ const placement = resolvePlacement(rootDir, name, opts, EXTENSION_KIND)
681
+ if (placement.outsideRoot) {
682
+ error(`--path must name a folder inside the workspace: ${placement.outsideRoot}`)
683
+ log('')
684
+ log(`The workspace root is ${colors.bright}${rootDir}${colors.reset}.`)
685
+ log(`A package outside it cannot be a workspace member.`)
686
+ process.exit(1)
687
+ }
688
+ const { relativePath } = placement
689
+ // The extension's own name: the last segment of the folder.
690
+ name = placement.packageName
691
+
692
+ // Reject reserved names (format + reserved check only — collisions handled at package name level)
693
+ const valid = validatePackageName(name)
694
+ if (valid !== true) {
695
+ error(valid)
696
+ process.exit(1)
697
+ }
698
+
699
+ // Auto-suffix package name if it collides with an existing package
700
+ const extensionPackageName = existingNames.has(name)
701
+ ? resolveUniqueName(name, '-ext', existingNames)
702
+ : name
703
+
704
+ const fullPath = join(rootDir, relativePath)
705
+
706
+ if (existsSync(fullPath)) {
707
+ error(
708
+ `Cannot create extension: ${colors.bright}${relativePath}/${colors.reset} already exists.`
709
+ )
710
+ log('')
711
+ log(`Pick a different name, or pass --path to choose a different folder.`)
712
+ process.exit(1)
713
+ }
714
+
715
+ // Scaffold foundation with extension flag. Its name — what it registers as —
716
+ // is the one asked for, not the package's suffixed one, unless a foundation in
717
+ // the workspace already registers as that.
718
+ const registryName = await unclaimedFoundationName(rootDir, [
719
+ normalizeFoundationName(name),
720
+ normalizeFoundationName(extensionPackageName)
721
+ ])
722
+ await scaffoldFoundation(
723
+ fullPath,
724
+ {
725
+ name: extensionPackageName,
726
+ registryName,
727
+ projectName,
728
+ isExtension: true
729
+ },
730
+ {
731
+ onProgress: (msg) => info(` ${msg}`)
732
+ }
733
+ )
734
+
735
+ // Apply template content if --from specified
736
+ if (opts.from) {
737
+ await applyFromTemplate(opts.from, 'extension', fullPath, projectName)
738
+ ensureFoundationName(fullPath, registryName)
739
+ }
740
+
741
+ // Register the folder it is in, as add foundation does
742
+ await addWorkspaceGlob(rootDir, relativePath)
743
+
744
+ // Wire extension to site:
745
+ // - --site <name>: explicit, wire it.
746
+ // - exactly one site: silent auto-wire (intent is unambiguous).
747
+ // - multiple sites, interactive: single-select prompt (extensions are
748
+ // typically per-site specialization — pick which site).
749
+ // - multiple sites, non-interactive: don't wire silently. Print a
750
+ // warning so the user/agent knows wiring is pending, and exit 0
751
+ // (the extension itself is fine).
752
+ // - no sites: print a note and exit 0.
753
+ let wiredSite = null
754
+ let unwiredReason = null
755
+ // A site loads a workspace extension from `/<name>/entry.js`, which the build
756
+ // finds in `<name>/` or `extensions/<name>/` (wireExtensionToSite). An entry
757
+ // pointing anywhere else would build with a warning and 404 at runtime, so
758
+ // none is written.
759
+ const reachable = relativePath === name || relativePath === `extensions/${name}`
760
+ if (!reachable) {
761
+ unwiredReason =
762
+ `A site loads a workspace extension from /${name}/entry.js, which the build finds in ` +
763
+ `${name}/ or extensions/${name}/ — not ${relativePath}/. Move the folder to one of those, ` +
764
+ `or list a URL where the built extension is served under <site>/site.yml::extensions:.`
765
+ } else if (opts.site) {
766
+ wiredSite = await wireExtensionToSite(rootDir, opts.site, relativePath)
767
+ } else {
768
+ const sites = await discoverSites(rootDir)
769
+ if (sites.length === 1) {
770
+ wiredSite = await wireExtensionToSite(rootDir, sites[0].name, relativePath)
771
+ } else if (sites.length > 1) {
772
+ if (isNonInteractive(process.argv)) {
773
+ unwiredReason = `Multiple sites in workspace; extension not wired. Re-run with --site <name>, or edit <site>/site.yml::extensions: manually.`
774
+ } else {
775
+ const sortedSites = [...sites].sort((a, b) =>
776
+ a.name.localeCompare(b.name)
777
+ )
778
+ const response = await prompts(
779
+ {
780
+ type: 'select',
781
+ name: 'site',
782
+ message: 'Which site is this extension for?',
783
+ choices: sortedSites.map((s) => ({
784
+ title: s.name,
785
+ description: s.path,
786
+ value: s.name
787
+ }))
788
+ },
789
+ {
790
+ onCancel: () => {
791
+ log('\nCancelled.')
792
+ process.exit(0)
793
+ }
794
+ }
795
+ )
796
+ if (response.site) {
797
+ wiredSite = await wireExtensionToSite(rootDir, response.site, relativePath)
798
+ }
799
+ }
800
+ } else {
801
+ unwiredReason = `No site in this workspace yet. Wire this extension into a site's site.yml::extensions: once you create one.`
802
+ }
803
+ }
804
+
805
+ // Update root scripts
806
+ const sites = await discoverSites(rootDir)
807
+ await updateRootScripts(rootDir, sites, pm)
808
+
809
+ let msg = `Created extension '${name}' at ${relativePath}/`
810
+ if (wiredSite) {
811
+ msg += ` → wired to site '${wiredSite}'`
812
+ }
813
+ success(msg)
814
+ if (unwiredReason) {
815
+ log(` ${colors.yellow}⚠ ${unwiredReason}${colors.reset}`)
816
+ }
817
+ log('')
818
+ log(`Next: ${colors.cyan}${installCmd(pm)}${colors.reset}`)
819
+ }
820
+
821
+ /**
822
+ * Add a co-located foundation + site pair to the workspace
823
+ */
824
+ async function addProject(rootDir, projectName, opts, pm = 'pnpm') {
825
+ let name = opts.name
826
+ const existingNames = await getExistingPackageNames(rootDir)
827
+
828
+ // Validate name format
829
+ if (name) {
830
+ const valid = validatePackageName(name)
831
+ if (valid !== true) {
832
+ error(valid)
833
+ process.exit(1)
834
+ }
835
+ }
836
+
837
+ // Interactive name prompt when name not provided
838
+ if (!name) {
839
+ if (isNonInteractive(process.argv)) {
840
+ error(`Missing project name.\n`)
841
+ log(`Usage: ${getCliPrefix()} add project <name>`)
842
+ process.exit(1)
843
+ }
844
+
845
+ const response = await prompts(
846
+ {
847
+ type: 'text',
848
+ name: 'name',
849
+ message: 'Project name:',
850
+ validate: (value) => validatePackageName(value)
851
+ },
852
+ {
853
+ onCancel: () => {
854
+ log('\nCancelled.')
855
+ process.exit(0)
856
+ }
857
+ }
858
+ )
859
+ name = response.name
860
+ }
861
+
862
+ // Check directory doesn't already exist
863
+ const projectDir = join(rootDir, name)
864
+ if (existsSync(projectDir)) {
865
+ error(`Directory already exists: ${name}/`)
866
+ process.exit(1)
867
+ }
868
+
869
+ // Compute package names. Co-located projects use the `-src` / `-site`
870
+ // suffix convention so package names are unique within the workspace.
871
+ // The folder structure inside the project is `src/` + `site/`, mirroring
872
+ // the single-project default layout.
873
+ const foundationPkgName = `${name}-src`
874
+ const sitePkgName = `${name}-site`
875
+
876
+ // Check package name collisions
877
+ for (const pkgName of [foundationPkgName, sitePkgName]) {
878
+ if (existingNames.has(pkgName)) {
879
+ error(`Package name '${pkgName}' already exists in this workspace.`)
880
+ process.exit(1)
881
+ }
882
+ }
883
+
884
+ const progressCb = (msg) => info(` ${msg}`)
885
+
886
+ // Scaffold foundation (folder: src/, package name: <project>-src, name — what
887
+ // it registers as — <project>)
888
+ info(`Creating foundation: ${foundationPkgName}...`)
889
+ const registryName = await unclaimedFoundationName(rootDir, [
890
+ normalizeFoundationName(name),
891
+ normalizeFoundationName(foundationPkgName)
892
+ ])
893
+ await scaffoldFoundation(
894
+ join(projectDir, 'src'),
895
+ {
896
+ name: foundationPkgName,
897
+ registryName,
898
+ projectName,
899
+ isExtension: false
900
+ },
901
+ { onProgress: progressCb }
902
+ )
903
+
904
+ // Scaffold site
905
+ info(`Creating site: ${sitePkgName}...`)
906
+ await scaffoldSite(
907
+ join(projectDir, 'site'),
908
+ {
909
+ name: sitePkgName,
910
+ projectName,
911
+ foundationName: foundationPkgName,
912
+ foundationPath: 'file:../src',
913
+ foundationRef: foundationPkgName
914
+ },
915
+ { onProgress: progressCb }
916
+ )
917
+
918
+ // Apply template content if --from specified
919
+ if (opts.from) {
920
+ await applyFromTemplate(
921
+ opts.from,
922
+ 'foundation',
923
+ join(projectDir, 'src'),
924
+ projectName
925
+ )
926
+ ensureFoundationName(join(projectDir, 'src'), registryName)
927
+ await applyFromTemplate(
928
+ opts.from,
929
+ 'site',
930
+ join(projectDir, 'site'),
931
+ projectName
932
+ )
933
+ }
934
+
935
+ // Update workspace globs for co-located layout
936
+ await addWorkspaceGlob(rootDir, '*/src')
937
+ await addWorkspaceGlob(rootDir, '*/site')
938
+
939
+ // Update root scripts
940
+ const sites = await discoverSites(rootDir)
941
+ if (!sites.find((s) => s.path === `${name}/site`)) {
942
+ sites.push({ name: sitePkgName, path: `${name}/site` })
943
+ }
944
+ await updateRootScripts(rootDir, sites, pm)
945
+
946
+ success(`Created project '${name}' at ${name}/`)
947
+ log(
948
+ ` ${colors.dim}Foundation: ${name}/src/ (${foundationPkgName})${colors.reset}`
949
+ )
950
+ log(` ${colors.dim}Site: ${name}/site/ (${sitePkgName})${colors.reset}`)
951
+ log('')
952
+ log(
953
+ `Next: ${colors.cyan}${installCmd(pm)} && uniweb dev ${sitePkgName}${colors.reset}`
954
+ )
955
+ }
956
+
957
+ /**
958
+ * Resolve which foundation to wire a site to
959
+ */
960
+ async function resolveFoundation(rootDir, foundationFlag) {
961
+ const foundations = await discoverFoundations(rootDir)
962
+
963
+ if (foundationFlag) {
964
+ // Find by name
965
+ const found = foundations.find((f) => f.name === foundationFlag)
966
+ if (found) return found
967
+
968
+ // Not found — could be a URL or new foundation
969
+ error(`Foundation '${foundationFlag}' not found in workspace.`)
970
+ log(
971
+ `Available foundations: ${foundations.map((f) => f.name).join(', ') || 'none'}`
972
+ )
973
+ process.exit(1)
974
+ }
975
+
976
+ if (foundations.length === 0) {
977
+ return null
978
+ }
979
+
980
+ if (foundations.length === 1) {
981
+ info(`Using foundation: ${foundations[0].name}`)
982
+ return foundations[0]
983
+ }
984
+
985
+ // Multiple foundations — prompt (or fail in non-interactive mode)
986
+ if (isNonInteractive(process.argv)) {
987
+ error(`Multiple foundations found. Specify which to use:\n`)
988
+ log(
989
+ formatOptions(
990
+ foundations.map((f) => ({
991
+ label: f.name,
992
+ description: f.path
993
+ }))
994
+ )
995
+ )
996
+ log('')
997
+ log(`Usage: ${getCliPrefix()} add site <name> --foundation <name>`)
998
+ process.exit(1)
999
+ }
1000
+
1001
+ const response = await prompts(
1002
+ {
1003
+ type: 'select',
1004
+ name: 'foundation',
1005
+ message: 'Which foundation should this site use?',
1006
+ choices: foundations.map((f) => ({
1007
+ title: f.name,
1008
+ description: f.path,
1009
+ value: f
1010
+ }))
1011
+ },
1012
+ {
1013
+ onCancel: () => {
1014
+ log('\nCancelled.')
1015
+ process.exit(0)
1016
+ }
1017
+ }
1018
+ )
1019
+
1020
+ return response.foundation
1021
+ }
1022
+
1023
+ /**
1024
+ * Compute the file: path from site to foundation
1025
+ */
1026
+ function computeFoundationPath(sitePath, foundationPath) {
1027
+ // Compute relative path from site dir to foundation dir
1028
+ const rel = relative(sitePath, foundationPath)
1029
+ return `file:${rel}`
1030
+ }
1031
+
1032
+ /**
1033
+ * The first of `candidates` that no foundation or extension in the workspace
1034
+ * registers as — null when each is taken or missing, and `uniweb register` asks.
1035
+ *
1036
+ * A package name is kept unique by suffixing (`ui-ext`), but what a package
1037
+ * REGISTERS as is its main.js name: two foundations named `ui` in one workspace
1038
+ * would both register `@org/ui`, and the second would land on the first's
1039
+ * version. Compared without scope, so `@acme/ui` counts as `ui`.
1040
+ *
1041
+ * @param {string} rootDir
1042
+ * @param {Array<string|null>} candidates
1043
+ * @returns {Promise<string|null>}
1044
+ */
1045
+ async function unclaimedFoundationName(rootDir, candidates) {
1046
+ const taken = new Set()
1047
+ for (const f of await discoverFoundations(rootDir)) {
1048
+ try {
1049
+ const { name } = await readFoundationName(join(rootDir, f.path))
1050
+ if (name) taken.add(name.replace(/^@[^/]+\//, ''))
1051
+ } catch {
1052
+ // An unreadable main.js — the build reports it; it claims no name here.
1053
+ }
1054
+ }
1055
+ return candidates.find((c) => c && !taken.has(c)) || null
1056
+ }
1057
+
1058
+ /**
1059
+ * Apply content from a template to a scaffolded package
1060
+ *
1061
+ * @param {string} templateId - Template identifier (official name, local path, npm, github)
1062
+ * @param {string} packageType - 'foundation', 'site', or 'extension'
1063
+ * @param {string} targetDir - Absolute path to the scaffolded package
1064
+ * @param {string} projectName - Project name for template context
1065
+ */
1066
+ async function applyFromTemplate(
1067
+ templateId,
1068
+ packageType,
1069
+ targetDir,
1070
+ projectName
1071
+ ) {
1072
+ info(`Resolving template: ${templateId}...`)
1073
+
1074
+ const resolved = await resolveTemplate(templateId, {
1075
+ onProgress: (msg) => info(` ${msg}`)
1076
+ })
1077
+
1078
+ try {
1079
+ const metadata = await validateTemplate(resolved.path)
1080
+
1081
+ // Look in contentDirs for matching package type
1082
+ const match =
1083
+ metadata.contentDirs.find((d) => d.type === packageType) ||
1084
+ metadata.contentDirs.find((d) => d.name === packageType)
1085
+ const contentDir = match ? match.dir : null
1086
+
1087
+ if (contentDir) {
1088
+ info(`Applying ${metadata.name} content...`)
1089
+ await applyContent(
1090
+ contentDir,
1091
+ targetDir,
1092
+ {
1093
+ projectName,
1094
+ versions: getVersionsForTemplates()
1095
+ },
1096
+ { onProgress: (msg) => info(` ${msg}`) }
1097
+ )
1098
+
1099
+ // Merge template dependencies
1100
+ if (metadata.dependencies) {
1101
+ const deps =
1102
+ metadata.dependencies[packageType] ||
1103
+ metadata.dependencies[match?.name]
1104
+ if (deps) {
1105
+ await mergeTemplateDependencies(join(targetDir, 'package.json'), deps)
1106
+ }
1107
+ }
1108
+
1109
+ // If site content applied, inform about expected section types
1110
+ if (packageType === 'site' && metadata.components) {
1111
+ log('')
1112
+ info(
1113
+ `This template expects section types: ${metadata.components.join(', ')}`
1114
+ )
1115
+ info(`Make sure your foundation provides them.`)
1116
+ }
1117
+ } else {
1118
+ info(
1119
+ `Template '${metadata.name}' has no ${packageType} content to apply.`
1120
+ )
1121
+ }
1122
+ } finally {
1123
+ if (resolved.cleanup) await resolved.cleanup()
1124
+ }
1125
+ }
1126
+
1127
+ /**
1128
+ * Wire an extension URL to a site's site.yml
1129
+ */
1130
+ async function wireExtensionToSite(rootDir, siteName, extensionFolder) {
1131
+ // Find the site directory
1132
+ const sites = await discoverSites(rootDir)
1133
+ const site = sites.find((s) => s.name === siteName)
1134
+ if (!site) {
1135
+ info(`Could not find site '${siteName}' to wire extension.`)
1136
+ return null
1137
+ }
1138
+
1139
+ const siteYmlPath = join(rootDir, site.path, 'site.yml')
1140
+ if (!existsSync(siteYmlPath)) {
1141
+ info(`No site.yml found at ${site.path}`)
1142
+ return null
1143
+ }
1144
+
1145
+ try {
1146
+ const content = await readFile(siteYmlPath, 'utf-8')
1147
+ const config = yaml.load(content) || {}
1148
+
1149
+ // ⭐ The site loads it from its own origin at `/<name>/entry.js`, `<name>`
1150
+ // being the extension's folder: the build finds that extension's build in
1151
+ // `<name>/` or `extensions/<name>/`, copies it into the site's output there,
1152
+ // and prerender imports it from the same place (`@uniweb/build`,
1153
+ // resolveExtensionDist) — the form the `extensions` template declares.
1154
+ // ⛔ Until 2026-10-08 this wrote `/<folder>/dist/entry.js`, which that lookup
1155
+ // reads as an extension named after the folder's FIRST segment —
1156
+ // `/extensions/effects/dist/entry.js` as one named `extensions` — so the
1157
+ // build warned it found no build, and the site 404'd on the file.
1158
+ const extensionUrl = `/${basename(extensionFolder)}/entry.js`
1159
+ if (!config.extensions) {
1160
+ config.extensions = []
1161
+ }
1162
+ if (!config.extensions.includes(extensionUrl)) {
1163
+ config.extensions.push(extensionUrl)
1164
+ }
1165
+
1166
+ await writeFile(
1167
+ siteYmlPath,
1168
+ yaml.dump(config, { flowLevel: -1, quotingType: "'" })
1169
+ )
1170
+ return siteName
1171
+ } catch (err) {
1172
+ info(`Warning: Could not update site.yml: ${err.message}`)
1173
+ return null
1174
+ }
1175
+ }
1176
+
1177
+ /**
1178
+ * Add a section type to a foundation
1179
+ */
1180
+ async function addSection(rootDir, opts) {
1181
+ setAddJsonMode(opts.json)
1182
+ let name = opts.name
1183
+
1184
+ // Interactive name prompt when not provided
1185
+ if (!name) {
1186
+ if (isNonInteractive(process.argv)) {
1187
+ error(`Missing section name.\n`)
1188
+ log(`Usage: ${getCliPrefix()} add section <Name>`)
1189
+ log(`\nSection names use PascalCase: Hero, FeatureGrid, CallToAction`)
1190
+ process.exit(1)
1191
+ }
1192
+
1193
+ const response = await prompts(
1194
+ {
1195
+ type: 'text',
1196
+ name: 'name',
1197
+ message: 'Section name (PascalCase):',
1198
+ validate: (value) =>
1199
+ /^[A-Z][a-zA-Z0-9]*$/.test(value) ||
1200
+ 'Use PascalCase: Hero, FeatureGrid, CallToAction'
1201
+ },
1202
+ {
1203
+ onCancel: () => {
1204
+ log('\nCancelled.')
1205
+ process.exit(0)
1206
+ }
1207
+ }
1208
+ )
1209
+ name = response.name
1210
+ }
1211
+
1212
+ // Validate PascalCase
1213
+ if (!/^[A-Z][a-zA-Z0-9]*$/.test(name)) {
1214
+ error(
1215
+ `Section name must be PascalCase (e.g., Hero, FeatureGrid, CallToAction).`
1216
+ )
1217
+ process.exit(1)
1218
+ }
1219
+
1220
+ // Find the foundation
1221
+ const foundations = await discoverFoundations(rootDir)
1222
+ let foundation
1223
+
1224
+ if (foundations.length === 0) {
1225
+ error('No foundation found in this workspace.')
1226
+ log(`Create one first: ${getCliPrefix()} add foundation`)
1227
+ process.exit(1)
1228
+ } else if (foundations.length === 1) {
1229
+ foundation = foundations[0]
1230
+ } else if (opts.foundation) {
1231
+ foundation = foundations.find((f) => f.name === opts.foundation)
1232
+ if (!foundation) {
1233
+ error(`Foundation '${opts.foundation}' not found.`)
1234
+ log(`Available: ${foundations.map((f) => f.name).join(', ')}`)
1235
+ process.exit(1)
1236
+ }
1237
+ } else if (isNonInteractive(process.argv)) {
1238
+ error(`Multiple foundations found. Specify which to use:\n`)
1239
+ log(
1240
+ formatOptions(
1241
+ foundations.map((f) => ({ label: f.name, description: f.path }))
1242
+ )
1243
+ )
1244
+ log('')
1245
+ log(`Usage: ${getCliPrefix()} add section ${name} --foundation <name>`)
1246
+ process.exit(1)
1247
+ } else {
1248
+ const response = await prompts(
1249
+ {
1250
+ type: 'select',
1251
+ name: 'foundation',
1252
+ message: 'Which foundation?',
1253
+ choices: foundations.map((f) => ({
1254
+ title: f.name,
1255
+ description: f.path,
1256
+ value: f
1257
+ }))
1258
+ },
1259
+ {
1260
+ onCancel: () => {
1261
+ log('\nCancelled.')
1262
+ process.exit(0)
1263
+ }
1264
+ }
1265
+ )
1266
+ foundation = response.foundation
1267
+ }
1268
+
1269
+ // Resolve sections directory — source root comes from package.json::main
1270
+ // (works for both nested `src/` layouts and flat layouts).
1271
+ const foundationDir = join(rootDir, foundation.path)
1272
+ const foundationSrc = resolveFoundationSrcPath(foundationDir)
1273
+ const sectionsDir = join(foundationSrc, 'sections')
1274
+ const sectionDir = join(sectionsDir, name)
1275
+ const relSectionPath = relative(foundationDir, sectionDir)
1276
+
1277
+ // ⭐ `--starter` ON AN EXISTING SECTION IS NOT AN ERROR. The flag asks one
1278
+ // question — *what content would an author start this section with?* — and a
1279
+ // section type that already exists is the case where it has a real `content:`
1280
+ // declaration to answer from. Refusing here would make the flag testable only
1281
+ // against stubs, which is the one case where the answer is least interesting.
1282
+ // Nothing is scaffolded and nothing is overwritten on this path.
1283
+ if (existsSync(sectionDir)) {
1284
+ if (opts.starter) {
1285
+ const { markdown, result } = await generateStarter({
1286
+ name,
1287
+ sectionDir,
1288
+ preset: opts.preset,
1289
+ json: opts.json,
1290
+ write: opts.write,
1291
+ })
1292
+ if (!opts.json) {
1293
+ if (!opts.write) log('\n' + markdown.trimEnd())
1294
+ reportStarter(result, { write: opts.write })
1295
+ }
1296
+ return
1297
+ }
1298
+ error(
1299
+ `Section '${name}' already exists at ${foundation.path}/${relSectionPath}/`
1300
+ )
1301
+ log(` ${colors.dim}--starter generates starter content for it without touching the files.${colors.reset}`)
1302
+ process.exit(1)
1303
+ }
1304
+
1305
+ // Create section directory and files
1306
+ await mkdir(sectionDir, { recursive: true })
1307
+
1308
+ const componentContent = `import { H2, P, Link, cn } from '@uniweb/kit'
1309
+
1310
+ export default function ${name}({ content, params }) {
1311
+ const { title, paragraphs = [], links = [] } = content || {}
1312
+
1313
+ return (
1314
+ <div className="max-w-4xl mx-auto px-6">
1315
+ {title && <H2 text={title} className="text-heading text-3xl font-bold" />}
1316
+ <P text={paragraphs} className="text-body mt-4" />
1317
+ {links.length > 0 && (
1318
+ <div className="mt-6 flex gap-3 flex-wrap">
1319
+ {links.map((link, i) => (
1320
+ <Link key={i} to={link.href} className={cn(
1321
+ 'px-5 py-2.5 rounded-lg font-medium transition-colors',
1322
+ i === 0
1323
+ ? 'bg-primary text-primary-foreground hover:bg-primary-hover'
1324
+ : 'bg-secondary text-secondary-foreground hover:bg-secondary-hover'
1325
+ )}>
1326
+ {link.label}
1327
+ </Link>
1328
+ ))}
1329
+ </div>
1330
+ )}
1331
+ </div>
1332
+ )
1333
+ }
1334
+ `
1335
+
1336
+ // With `--starter`, the scaffold gets a `content:` declaration derived from
1337
+ // the family the name resolves to — so the three pieces agree: a declaration,
1338
+ // a component that reads it, and content that fills it. Without the flag the
1339
+ // stub declares nothing, exactly as before.
1340
+ let starterDeclaration = ''
1341
+ let starterResult = null
1342
+ if (opts.starter) {
1343
+ const preview = await generateStarter({ name, sectionDir: sectionDir })
1344
+ starterResult = preview.result
1345
+ starterDeclaration = await declarationFor(preview.result)
1346
+ }
1347
+
1348
+ const metaContent = `export default {
1349
+ title: '${name}',
1350
+ description: '',${starterDeclaration}
1351
+ params: {},
1352
+ }
1353
+ `
1354
+
1355
+ await writeFile(join(sectionDir, 'index.jsx'), componentContent)
1356
+ await writeFile(join(sectionDir, 'meta.js'), metaContent)
1357
+
1358
+ success(`Created section '${name}' at ${foundation.path}/${relSectionPath}/`)
1359
+ log(
1360
+ ` ${colors.dim}index.jsx${colors.reset} — component (customize the JSX)`
1361
+ )
1362
+ log(
1363
+ ` ${colors.dim}meta.js${colors.reset} — metadata (add content expectations, params, presets)`
1364
+ )
1365
+ if (foundations.length === 1) {
1366
+ log('')
1367
+ log(
1368
+ `${colors.dim}The dev server will pick it up automatically.${colors.reset}`
1369
+ )
1370
+ }
1371
+
1372
+ if (starterResult) {
1373
+ // Regenerate against the declaration just written, so what is printed is
1374
+ // what this section's own `meta.js` now asks for rather than the family's
1375
+ // guess — the two agree here, and saying it from the file keeps them so.
1376
+ const { markdown, result } = await generateStarter({
1377
+ name,
1378
+ sectionDir,
1379
+ preset: opts.preset,
1380
+ json: opts.json,
1381
+ write: opts.write,
1382
+ })
1383
+ if (!opts.json) {
1384
+ log('')
1385
+ log(`${colors.dim}Starter content for a page section:${colors.reset}`)
1386
+ if (!opts.write) log('\n' + markdown.trimEnd())
1387
+ reportStarter(result, { write: opts.write })
1388
+ // The stub component reads three elements. The declaration above may name
1389
+ // more, because it comes from the family rather than from the stub — say
1390
+ // so, rather than leave a developer wondering why half the content they
1391
+ // were just handed renders as nothing.
1392
+ const stubReads = new Set(['title', 'paragraphs', 'links'])
1393
+ const unread = Object.keys(result.content).filter((slot) => !stubReads.has(slot))
1394
+ if (unread.length) {
1395
+ log(
1396
+ ` ${colors.dim}index.jsx reads title, paragraphs and links — extend it for: ${unread.join(', ')}${colors.reset}`
1397
+ )
1398
+ log('')
1399
+ }
1400
+ }
1401
+ }
1402
+ }
1403
+
1404
+ /**
1405
+ * Add a CI deploy workflow for a host adapter.
1406
+ *
1407
+ * Wires through the host-adapter registry: each adapter optionally
1408
+ * exports `initCi({ rootDir, site, packageManager, nodeVersion, pnpmVersion })`
1409
+ * returning `{ files, postInstructions }`. The CLI handles file writes,
1410
+ * --force overwrite, and consistent output. Today only github-pages
1411
+ * implements initCi; the registry's other adapters (cloudflare-pages,
1412
+ * vercel, s3-cloudfront, generic-static) error out with a clear message
1413
+ * and can plug in later.
1414
+ */
1415
+ async function addCi(rootDir, opts, pm = 'pnpm') {
1416
+ // Lazy-load the host registry so the CLI doesn't pay this import on
1417
+ // every add invocation.
1418
+ let getAdapter, listAdapters
1419
+ try {
1420
+ ;({ getAdapter, listAdapters } = await import('@uniweb/build/hosts'))
1421
+ } catch (err) {
1422
+ // ⛔ Keep the reason. A bare `catch {}` here printed one line for three
1423
+ // different failures — the package is absent, the `./hosts` subpath is
1424
+ // not exported by the installed version, or the module threw while
1425
+ // evaluating — and they have different remedies.
1426
+ error(`Failed to load the host adapter registry from @uniweb/build/hosts.`)
1427
+ error(` ${err.message}`)
1428
+ if (err.code === 'ERR_MODULE_NOT_FOUND') {
1429
+ error(` Install the workspace's dependencies, or upgrade @uniweb/build.`)
1430
+ }
1431
+ process.exit(1)
1432
+ }
1433
+
1434
+ // What the workflow publishes. 'foundation' scaffolds the versioned
1435
+ // distribution path (foundations/<name>/<version>/entry.js) — the free
1436
+ // alternative to the catalog for a foundation product. Resolved BEFORE
1437
+ // the host, because it narrows which hosts are even eligible.
1438
+ const target = opts.target || 'site'
1439
+ if (!['site', 'foundation'].includes(target)) {
1440
+ error(`Unknown --target '${target}'. Expected 'site' or 'foundation'.`)
1441
+ process.exit(1)
1442
+ }
1443
+
1444
+ // Every adapter that can scaffold CI for this target. Derived from
1445
+ // capability, never a hardcoded list — an adapter that grows an initCi
1446
+ // shows up here with no edit to this file.
1447
+ const ciHosts = listAdapters()
1448
+ .map((name) => getAdapter(name))
1449
+ .filter((a) => typeof a.initCi === 'function')
1450
+ .filter((a) =>
1451
+ target === 'foundation' ? a.display?.foundationCi === true : true
1452
+ )
1453
+ .sort((a, b) => (a.display?.order ?? 999) - (b.display?.order ?? 999))
1454
+
1455
+ if (ciHosts.length === 0) {
1456
+ error(`No host can scaffold CI for a ${target}.`)
1457
+ process.exit(1)
1458
+ }
1459
+
1460
+ let host = opts.host
1461
+ if (!host) {
1462
+ if (ciHosts.length === 1) {
1463
+ host = ciHosts[0].name
1464
+ } else if (isNonInteractive(process.argv)) {
1465
+ error('`uniweb add ci` needs a host.')
1466
+ log(`Available: ${ciHosts.map((a) => a.name).join(', ')}`)
1467
+ process.exit(1)
1468
+ } else {
1469
+ const choice = await prompts(
1470
+ {
1471
+ type: 'select',
1472
+ name: 'host',
1473
+ message: 'Which host should the workflow deploy to?',
1474
+ choices: ciHosts.map((a) => ({
1475
+ title:
1476
+ `${a.display?.title || a.name} · ${a.display?.qualifier || ''}`
1477
+ .trim()
1478
+ .replace(/ ·\s*$/, ''),
1479
+ description: a.display?.summary,
1480
+ value: a.name
1481
+ }))
1482
+ },
1483
+ {
1484
+ onCancel: () => {
1485
+ log('\nCancelled.')
1486
+ process.exit(0)
1487
+ }
1488
+ }
1489
+ )
1490
+ host = choice.host
1491
+ }
1492
+ }
1493
+
1494
+ let adapter
1495
+ try {
1496
+ adapter = getAdapter(host)
1497
+ } catch (err) {
1498
+ error(err.message)
1499
+ process.exit(1)
1500
+ }
1501
+
1502
+ if (typeof adapter.initCi !== 'function') {
1503
+ error(`Host '${host}' does not provide a CI workflow.`)
1504
+ log(`Hosts that do: ${ciHosts.map((a) => a.name).join(', ')}`)
1505
+ log(
1506
+ `Others are dashboard-driven — connect the repo in the host's UI instead.`
1507
+ )
1508
+ process.exit(1)
1509
+ }
1510
+
1511
+ // Guard the explicit-host case. Without this, `--host netlify --target
1512
+ // foundation` would call an initCi that ignores `target` and silently
1513
+ // scaffold a SITE workflow — the wrong artifact, with no error.
1514
+ if (target === 'foundation' && adapter.display?.foundationCi !== true) {
1515
+ error(`Host '${host}' cannot publish a foundation.`)
1516
+ log(`Hosts that can: ${ciHosts.map((a) => a.name).join(', ')}`)
1517
+ log('')
1518
+ log('Foundation publishing needs permanent versioned URLs, which the')
1519
+ log('gh-pages branch layout provides. Other hosts overwrite on deploy.')
1520
+ process.exit(1)
1521
+ }
1522
+
1523
+ if (target === 'foundation') {
1524
+ return addFoundationCi(rootDir, { ...opts, host }, adapter, pm)
1525
+ }
1526
+
1527
+ // Validate --domain (lightweight: must look like a hostname). The
1528
+ // adapter decides what to do with it; today only github-pages uses
1529
+ // it (writes a CNAME, switches UNIWEB_BASE to root).
1530
+ if (opts.domain && !isLikelyDomain(opts.domain)) {
1531
+ error(`Invalid --domain value: '${opts.domain}'`)
1532
+ log(
1533
+ `Expected a bare hostname (e.g., 'mysite.com' or 'docs.mysite.com'). No scheme, no path.`
1534
+ )
1535
+ process.exit(1)
1536
+ }
1537
+
1538
+ // If --domain wasn't passed, fall back to whatever's already in
1539
+ // deploy.yml's targets.<host>.domain. Lets re-running `add ci` (e.g.,
1540
+ // to refresh the workflow after a CLI upgrade) keep the domain
1541
+ // without the user re-typing it.
1542
+ const { loadDeployYml } = await import('@uniweb/build/site')
1543
+ let resolvedDomain = opts.domain
1544
+
1545
+ // Resolve site: --site flag, single site auto, prompt, or error.
1546
+ const sites = await discoverSites(rootDir)
1547
+ if (sites.length === 0) {
1548
+ error(
1549
+ 'No site found in this workspace. Add one with `uniweb add site` first.'
1550
+ )
1551
+ process.exit(1)
1552
+ }
1553
+
1554
+ let site
1555
+ if (opts.site) {
1556
+ site = sites.find((s) => s.name === opts.site)
1557
+ if (!site) {
1558
+ error(`Site '${opts.site}' not found.`)
1559
+ log(`Available sites: ${sites.map((s) => s.name).join(', ')}`)
1560
+ process.exit(1)
1561
+ }
1562
+ } else if (sites.length === 1) {
1563
+ site = sites[0]
1564
+ } else if (isNonInteractive(process.argv)) {
1565
+ error(`Multiple sites in workspace. Specify --site <name>.`)
1566
+ log(`Available sites: ${sites.map((s) => s.name).join(', ')}`)
1567
+ process.exit(1)
1568
+ } else {
1569
+ const sortedSites = [...sites].sort((a, b) => a.name.localeCompare(b.name))
1570
+ const response = await prompts(
1571
+ {
1572
+ type: 'select',
1573
+ name: 'site',
1574
+ message: 'Which site should the workflow build?',
1575
+ choices: sortedSites.map((s) => ({
1576
+ title: s.name,
1577
+ description: s.path,
1578
+ value: s
1579
+ }))
1580
+ },
1581
+ {
1582
+ onCancel: () => {
1583
+ log('\nCancelled.')
1584
+ process.exit(0)
1585
+ }
1586
+ }
1587
+ )
1588
+ site = response.site
1589
+ }
1590
+
1591
+ // Read node version from root package.json engines.node if pinned;
1592
+ // otherwise default to 20 (matches the workspace template's >=20.19).
1593
+ const rootPkg = JSON.parse(
1594
+ await readFile(join(rootDir, 'package.json'), 'utf-8').catch(() => '{}')
1595
+ )
1596
+ // CI must run the project's own toolchain. The pnpm major comes from
1597
+ // its `packageManager` field when declared; the node major is the
1598
+ // project's floor, raised if that pnpm needs more.
1599
+ const installedPnpm = detectInstalledPnpmVersion(rootDir)
1600
+ const pnpmVersion = resolveCiPnpmVersion(rootPkg, installedPnpm)
1601
+ const nodeVersion = resolveCiNodeVersion(
1602
+ rootPkg.engines?.node,
1603
+ pm,
1604
+ pnpmVersion
1605
+ )
1606
+ reportCiToolchain({
1607
+ pm,
1608
+ pnpmVersion,
1609
+ nodeVersion,
1610
+ source: rootPkg?.packageManager
1611
+ ? 'declared'
1612
+ : installedPnpm
1613
+ ? 'installed'
1614
+ : 'fallback'
1615
+ })
1616
+
1617
+ const siteDir = join(rootDir, site.path)
1618
+ if (!resolvedDomain) {
1619
+ try {
1620
+ const deployYml = await loadDeployYml(siteDir)
1621
+ const remembered = deployYml?.targets?.[host]?.domain
1622
+ if (remembered && isLikelyDomain(remembered)) {
1623
+ resolvedDomain = remembered
1624
+ info(`Using domain '${remembered}' from deploy.yml.`)
1625
+ }
1626
+ } catch {
1627
+ // Malformed deploy.yml — surface elsewhere; don't block add ci.
1628
+ }
1629
+ }
1630
+
1631
+ const result = await adapter.initCi({
1632
+ rootDir,
1633
+ site,
1634
+ target: 'site',
1635
+ packageManager: pm,
1636
+ nodeVersion,
1637
+ pnpmVersion,
1638
+ domain: resolvedDomain,
1639
+ previews: opts.previews !== false,
1640
+ projectName: resolveHostProjectName(
1641
+ rootDir,
1642
+ rootPkg,
1643
+ site,
1644
+ sites.length,
1645
+ opts
1646
+ )
1647
+ })
1648
+
1649
+ await writeCiFiles(rootDir, result.files, opts.force)
1650
+
1651
+ // Persist the adapter's target config into deploy.yml so the user's
1652
+ // intent (host + adapter-specific fields like `domain`) is remembered
1653
+ // across CLI upgrades and re-runs. github-pages deploys via GHA, not
1654
+ // via `uniweb deploy`, so without this its config would never reach
1655
+ // deploy.yml. The writer:
1656
+ // - scaffolds a fresh deploy.yml on first call (this target is the
1657
+ // default), or
1658
+ // - merges into an existing targets.<targetName> without touching
1659
+ // `default`, `autoSave`, or other targets.
1660
+ if (result.targetConfig) {
1661
+ try {
1662
+ const { recordTarget } = await import('@uniweb/build/site')
1663
+ const writeResult = await recordTarget(siteDir, {
1664
+ targetName: host,
1665
+ targetConfig: result.targetConfig
1666
+ })
1667
+ success(
1668
+ writeResult.action === 'scaffold'
1669
+ ? `Wrote ${relative(rootDir, writeResult.path)} (default target: ${host})`
1670
+ : `Updated ${relative(rootDir, writeResult.path)} (target: ${host})`
1671
+ )
1672
+ } catch (err) {
1673
+ // deploy.yml persistence is best-effort: the workflow + CNAME
1674
+ // are the load-bearing artifacts. Print a warning and continue.
1675
+ info(`Warning: could not update deploy.yml: ${err.message}`)
1676
+ }
1677
+ }
1678
+
1679
+ if (result.postInstructions?.length) {
1680
+ log('')
1681
+ log(`${colors.bright}Next steps:${colors.reset}`)
1682
+ for (const line of result.postInstructions) {
1683
+ log(line ? ` ${line}` : '')
1684
+ }
1685
+ }
1686
+ }
1687
+
1688
+ /**
1689
+ * The name to register this site under on the host (Cloudflare Pages
1690
+ * project, etc.).
1691
+ *
1692
+ * The site package is very often called literally `site` — the scaffold's
1693
+ * default — which makes a terrible project name on a shared host. So:
1694
+ * an explicit `--project-name` wins; otherwise prefer the workspace's own
1695
+ * name (usually the repo name) when the site's name is one of the generic
1696
+ * scaffold defaults; suffix with the site name only when the workspace
1697
+ * holds more than one site and they'd otherwise collide.
1698
+ */
1699
+ const GENERIC_SITE_NAMES = new Set(['site', 'sites', 'www', 'web', 'app'])
1700
+
1701
+ /**
1702
+ * Public URL segment for a foundation published to GitHub Pages.
1703
+ *
1704
+ * Same problem as the site project name, and it matters more here:
1705
+ * this segment is baked into a PERMANENT url that consuming sites pin
1706
+ * (`foundations/<name>/<version>/entry.js`), so `src` — the single-project
1707
+ * scaffold's directory *and* package name — would be a poor forever-name.
1708
+ * Prefer a meaningful package/directory name; fall back to the workspace
1709
+ * name; disambiguate only when several foundations would collide.
1710
+ */
1711
+ const GENERIC_FOUNDATION_NAMES = new Set([
1712
+ 'src',
1713
+ 'foundation',
1714
+ 'foundations',
1715
+ 'lib'
1716
+ ])
1717
+
1718
+ function resolveFoundationPublicNames(rootDir, rootPkg, foundations) {
1719
+ const workspaceName = stripScope(rootPkg?.name) || basename(rootDir)
1720
+
1721
+ return foundations.map((f, i) => {
1722
+ const pkgName = stripScope(f.name)
1723
+ const dirName = basename(f.path)
1724
+ const candidate = !GENERIC_FOUNDATION_NAMES.has(pkgName)
1725
+ ? pkgName
1726
+ : !GENERIC_FOUNDATION_NAMES.has(dirName)
1727
+ ? dirName
1728
+ : foundations.length > 1
1729
+ ? `${workspaceName}-${i + 1}`
1730
+ : workspaceName
1731
+ return { name: candidate, path: f.path }
1732
+ })
1733
+ }
1734
+
1735
+ function resolveHostProjectName(rootDir, rootPkg, site, siteCount, opts) {
1736
+ if (opts.projectName) return opts.projectName
1737
+
1738
+ const workspaceName = stripScope(rootPkg?.name) || basename(rootDir)
1739
+ const siteName = stripScope(site.name)
1740
+
1741
+ if (!GENERIC_SITE_NAMES.has(siteName)) return siteName
1742
+ if (siteCount > 1) return `${workspaceName}-${siteName}`
1743
+ return workspaceName
1744
+ }
1745
+
1746
+ function stripScope(name) {
1747
+ if (!name || typeof name !== 'string') return null
1748
+ return name.startsWith('@') ? name.split('/').pop() : name
1749
+ }
1750
+
1751
+ /**
1752
+ * Print the toolchain the generated workflow will install, and where each
1753
+ * part came from.
1754
+ *
1755
+ * These values are resolved rather than stated whenever the project is
1756
+ * silent, and a silent resolution is what let a broken pnpm/Node pairing
1757
+ * ship unnoticed. Naming the source matters too: "we read this from your
1758
+ * install" invites a different reaction than "we guessed", and only the
1759
+ * latter needs an override hint.
1760
+ */
1761
+ function reportCiToolchain({ pm, pnpmVersion, nodeVersion, source }) {
1762
+ if (pm !== 'pnpm') {
1763
+ info(`CI will use ${pm} + Node ${nodeVersion}`)
1764
+ return
1765
+ }
1766
+ const origin =
1767
+ {
1768
+ declared: '',
1769
+ installed: ' (matching your install)',
1770
+ fallback: ' (no pnpm detected — using the default)'
1771
+ }[source] ?? ''
1772
+ info(`CI will use pnpm ${pnpmVersion} + Node ${nodeVersion}${origin}`)
1773
+ if (source === 'fallback') {
1774
+ info(
1775
+ `Pin with "packageManager": "pnpm@<version>" in package.json to choose a major.`
1776
+ )
1777
+ }
1778
+ }
1779
+
1780
+ /**
1781
+ * Write scaffolded CI files. Refuses to overwrite without --force so
1782
+ * re-running doesn't silently clobber edits the user made to a workflow.
1783
+ */
1784
+ async function writeCiFiles(rootDir, files, force) {
1785
+ for (const file of files) {
1786
+ const fullPath = join(rootDir, file.path)
1787
+ if (existsSync(fullPath) && !force) {
1788
+ error(`File already exists: ${file.path}`)
1789
+ log(`Re-run with --force to overwrite.`)
1790
+ process.exit(1)
1791
+ }
1792
+ await mkdir(join(fullPath, '..'), { recursive: true })
1793
+ await writeFile(fullPath, file.content)
1794
+ success(`Wrote ${file.path}`)
1795
+ }
1796
+ }
1797
+
1798
+ /**
1799
+ * `uniweb add ci --target foundation` — scaffold the workflow that
1800
+ * publishes built foundations at permanent versioned URLs.
1801
+ *
1802
+ * This is the free distribution path for a foundation product: sites
1803
+ * reference `https://<user>.github.io/<repo>/foundations/<name>/<ver>/entry.js`
1804
+ * from site.yml. No catalog, no propagation, no license gating — but no
1805
+ * cost either, and the URLs never break.
1806
+ *
1807
+ * Unlike the site path there is no deploy.yml to write: deploy.yml
1808
+ * records where a *site* is deployed, and a foundation isn't a site.
1809
+ */
1810
+ async function addFoundationCi(rootDir, opts, adapter, pm) {
1811
+ const all = await discoverFoundations(rootDir)
1812
+ if (all.length === 0) {
1813
+ error('No foundation found in this workspace.')
1814
+ log('Add one with `uniweb add foundation` first.')
1815
+ process.exit(1)
1816
+ }
1817
+
1818
+ // --foundation <name> narrows to one; otherwise publish them all,
1819
+ // which is what a multi-foundation repo almost always wants (each gets
1820
+ // its own versioned directory, so there's no collision).
1821
+ let foundations = all
1822
+ if (opts.foundation) {
1823
+ const match = all.find(
1824
+ (f) => f.name === opts.foundation || basename(f.path) === opts.foundation
1825
+ )
1826
+ if (!match) {
1827
+ error(`Foundation '${opts.foundation}' not found.`)
1828
+ log(`Available: ${all.map((f) => f.name).join(', ')}`)
1829
+ process.exit(1)
1830
+ }
1831
+ foundations = [match]
1832
+ }
1833
+
1834
+ const rootPkg = JSON.parse(
1835
+ await readFile(join(rootDir, 'package.json'), 'utf-8').catch(() => '{}')
1836
+ )
1837
+ // CI must run the project's own toolchain. The pnpm major comes from
1838
+ // its `packageManager` field when declared; the node major is the
1839
+ // project's floor, raised if that pnpm needs more.
1840
+ const installedPnpm = detectInstalledPnpmVersion(rootDir)
1841
+ const pnpmVersion = resolveCiPnpmVersion(rootPkg, installedPnpm)
1842
+ const nodeVersion = resolveCiNodeVersion(
1843
+ rootPkg.engines?.node,
1844
+ pm,
1845
+ pnpmVersion
1846
+ )
1847
+ reportCiToolchain({
1848
+ pm,
1849
+ pnpmVersion,
1850
+ nodeVersion,
1851
+ source: rootPkg?.packageManager
1852
+ ? 'declared'
1853
+ : installedPnpm
1854
+ ? 'installed'
1855
+ : 'fallback'
1856
+ })
1857
+
1858
+ let result
1859
+ try {
1860
+ result = await adapter.initCi({
1861
+ rootDir,
1862
+ foundations: resolveFoundationPublicNames(rootDir, rootPkg, foundations),
1863
+ target: 'foundation',
1864
+ packageManager: pm,
1865
+ nodeVersion,
1866
+ pnpmVersion
1867
+ })
1868
+ } catch (err) {
1869
+ error(err.message)
1870
+ process.exit(1)
1871
+ }
1872
+
1873
+ await writeCiFiles(rootDir, result.files, opts.force)
1874
+
1875
+ if (result.postInstructions?.length) {
1876
+ log('')
1877
+ log(`${colors.bright}Next steps:${colors.reset}`)
1878
+ for (const line of result.postInstructions) {
1879
+ log(line ? ` ${line}` : '')
1880
+ }
1881
+ }
1882
+ }
1883
+
1884
+ function isLikelyDomain(value) {
1885
+ if (typeof value !== 'string' || value.length === 0 || value.length > 253)
1886
+ return false
1887
+ // Reject schemes, paths, ports, whitespace, leading/trailing dots/hyphens.
1888
+ // Each label is 1–63 chars of [a-z0-9-], no leading/trailing hyphen.
1889
+ // The TLD must be at least 2 characters of letters.
1890
+ return /^(?=.{1,253}$)([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}$/i.test(
1891
+ value
1892
+ )
1893
+ }
1894
+
1895
+ function parseNodeMajor(engines) {
1896
+ if (!engines) return null
1897
+ const match = String(engines).match(/(\d+)/)
1898
+ return match ? match[1] : null
1899
+ }
1900
+