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.
- package/LICENSE +201 -0
- package/README.md +532 -3
- package/package.json +60 -11
- package/partials/adding-pages.hbs +77 -0
- package/partials/agents.md +3000 -0
- package/partials/ai-assistance.hbs +28 -0
- package/partials/components-docs.hbs +13 -0
- package/partials/config-reference.hbs +66 -0
- package/partials/deployment.hbs +13 -0
- package/partials/learn-more.hbs +6 -0
- package/partials/quick-start.hbs +8 -0
- package/partials/search-docs.hbs +110 -0
- package/src/backend/asset-download.js +222 -0
- package/src/backend/client.js +986 -0
- package/src/backend/foundation-bring-along.js +692 -0
- package/src/backend/payment-handoff.js +362 -0
- package/src/backend/service-request.js +210 -0
- package/src/backend/site-media.js +255 -0
- package/src/backend/site-preview.js +77 -0
- package/src/backend/site-sync.js +2515 -0
- package/src/backend/workspace.js +234 -0
- package/src/commands/add.js +1900 -0
- package/src/commands/build.js +1214 -0
- package/src/commands/clone.js +466 -0
- package/src/commands/content.js +202 -0
- package/src/commands/deploy.js +537 -0
- package/src/commands/dev.js +202 -0
- package/src/commands/docs.js +530 -0
- package/src/commands/doctor.js +1982 -0
- package/src/commands/export.js +116 -0
- package/src/commands/families.js +82 -0
- package/src/commands/forget.js +197 -0
- package/src/commands/handoff.js +21 -0
- package/src/commands/i18n.js +1707 -0
- package/src/commands/inspect.js +375 -0
- package/src/commands/invite.js +24 -0
- package/src/commands/org.js +96 -0
- package/src/commands/publish.js +1087 -0
- package/src/commands/pull.js +1257 -0
- package/src/commands/push.js +663 -0
- package/src/commands/refresh.js +212 -0
- package/src/commands/register.js +1092 -0
- package/src/commands/rename.js +647 -0
- package/src/commands/site.js +358 -0
- package/src/commands/snapshot.js +461 -0
- package/src/commands/starter.js +206 -0
- package/src/commands/status.js +302 -0
- package/src/commands/sync.js +93 -0
- package/src/commands/template.js +107 -0
- package/src/commands/update.js +887 -0
- package/src/commands/validate.js +376 -0
- package/src/framework-index.json +248 -0
- package/src/index.js +2241 -0
- package/src/templates/fetchers/github.js +113 -0
- package/src/templates/fetchers/npm.js +85 -0
- package/src/templates/fetchers/release.js +92 -0
- package/src/templates/index.js +190 -0
- package/src/templates/processor.js +333 -0
- package/src/templates/resolver.js +232 -0
- package/src/templates/validator.js +155 -0
- package/src/utils/agents-stamp.js +110 -0
- package/src/utils/args.js +126 -0
- package/src/utils/asset-upload.js +270 -0
- package/src/utils/bytes.js +30 -0
- package/src/utils/code-upload.js +394 -0
- package/src/utils/config.js +417 -0
- package/src/utils/conformance.js +274 -0
- package/src/utils/dep-survey.js +176 -0
- package/src/utils/destination-prompt.js +146 -0
- package/src/utils/discover.js +75 -0
- package/src/utils/env.js +22 -0
- package/src/utils/exit.js +30 -0
- package/src/utils/fetch-retry.js +109 -0
- package/src/utils/flag-guard.js +246 -0
- package/src/utils/foundation-name.js +179 -0
- package/src/utils/git.js +342 -0
- package/src/utils/host-prompt.js +51 -0
- package/src/utils/install-integrity.js +210 -0
- package/src/utils/interactive.js +118 -0
- package/src/utils/json-file.js +72 -0
- package/src/utils/names.js +91 -0
- package/src/utils/placement.js +151 -0
- package/src/utils/pm.js +165 -0
- package/src/utils/preview.js +20 -0
- package/src/utils/pull-written.js +132 -0
- package/src/utils/records-guard.js +88 -0
- package/src/utils/registry-auth.js +994 -0
- package/src/utils/registry-orgs.js +290 -0
- package/src/utils/scaffold.js +483 -0
- package/src/utils/schemaless-report.js +30 -0
- package/src/utils/semver-precedence.js +80 -0
- package/src/utils/session-file.js +95 -0
- package/src/utils/site-content-refs.js +23 -0
- package/src/utils/site-copies.js +123 -0
- package/src/utils/site-data-upload.js +302 -0
- package/src/utils/site-identity.js +137 -0
- package/src/utils/snapshot-settings.js +112 -0
- package/src/utils/update-check.js +302 -0
- package/src/utils/uwx-read.js +157 -0
- package/src/utils/workspace.js +223 -0
- package/src/versions.js +467 -0
- package/starter/foundation/main.js +50 -0
- package/starter/foundation/sections/Section/index.jsx +95 -0
- package/starter/foundation/sections/Section/meta.js +44 -0
- package/starter/site/pages/about/1-about.md.hbs +13 -0
- package/starter/site/pages/about/page.yml +3 -0
- package/starter/site/pages/home/1-welcome.md.hbs +14 -0
- package/starter/site/pages/home/page.yml +3 -0
- package/templates/foundation/_gitignore +5 -0
- package/templates/foundation/components/.gitkeep +0 -0
- package/templates/foundation/main.js.hbs +33 -0
- package/templates/foundation/package.json.hbs +51 -0
- package/templates/foundation/sections/.gitkeep +0 -0
- package/templates/foundation/styles.css +6 -0
- package/templates/foundation/vite.config.js +3 -0
- package/templates/site/_gitignore +12 -0
- package/templates/site/entry.js +7 -0
- package/templates/site/index.html.hbs +13 -0
- package/templates/site/package.json.hbs +27 -0
- package/templates/site/public/favicon.svg +7 -0
- package/templates/site/site.yml.hbs +91 -0
- package/templates/site/theme.yml +99 -0
- package/templates/site/vite.config.js +7 -0
- package/templates/workspace/AGENTS.md.hbs +2 -0
- package/templates/workspace/README.md.hbs +87 -0
- package/templates/workspace/_gitignore +29 -0
- package/templates/workspace/_vscode/settings.json +6 -0
- package/templates/workspace/package.json.hbs +20 -0
- 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
|
+
|