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,647 @@
1
+ /**
2
+ * Rename Command
3
+ *
4
+ * Renames packages across the workspace transactionally. Supports
5
+ * `rename foundation`, `rename site`, and `rename extension`. Each
6
+ * subcommand updates a different set of touch points:
7
+ *
8
+ * foundation: package.json::name + folder + every dependent site's
9
+ * package.json (dep key + file: path) + every site.yml::foundation
10
+ * reference + workspace manifests + root scripts.
11
+ *
12
+ * site: package.json::name + folder + workspace manifests + root
13
+ * scripts (sites are referenced by name in `pnpm --filter`-style
14
+ * scripts, hence the regen).
15
+ *
16
+ * extension: package.json::name + folder + every site.yml::extensions
17
+ * entry whose URL prefix matches the old folder path + workspace
18
+ * manifests. Sites don't carry a `file:` dep on extensions (extensions
19
+ * load by URL at runtime), so no per-site package.json updates.
20
+ *
21
+ * Extensions are technically a flavor of foundation (same build, same
22
+ * package.json shape, distinguished only by `extension: true` in
23
+ * main.js or `role: 'extension'` in the built schema). The
24
+ * rename verb still keeps them on a separate subcommand because the
25
+ * touch-point sets differ — `rename foundation` against an extension
26
+ * would update the wrong things. Each subcommand guards its target
27
+ * type and points at the right verb when wrong.
28
+ *
29
+ * Pre-flight checks run before any mutation. If anything would conflict
30
+ * (target name already taken, target not found, folder collision,
31
+ * type mismatch) we bail with a clear message and no partial state.
32
+ *
33
+ * Out of scope: registry side. What a foundation registers as is `name` in its
34
+ * main.js, which is independent of the workspace name and stays untouched —
35
+ * except for a foundation with no main.js `name`, whose package name IS its name
36
+ * (`readFoundationName`), so renaming the package renames what it would register
37
+ * as. There is no registry-rename flag — a registered version is immutable; to
38
+ * change a foundation's registered name, change main.js's `name` and register
39
+ * again (consuming sites repoint their `foundation:` ref).
40
+ *
41
+ * Usage:
42
+ * uniweb rename foundation <old> <new>
43
+ * uniweb rename site <old> <new>
44
+ * uniweb rename extension <old> <new>
45
+ */
46
+
47
+ import { existsSync } from 'node:fs'
48
+ import { readFile, writeFile, rename as fsRename } from 'node:fs/promises'
49
+ import { join, relative, dirname, basename } from 'node:path'
50
+ import yaml from 'js-yaml'
51
+ import { isExtensionPackage } from '@uniweb/build'
52
+ import { findWorkspaceRoot } from '../utils/workspace.js'
53
+ import {
54
+ readWorkspaceConfig,
55
+ writeWorkspaceConfig,
56
+ readRootPackageJson,
57
+ writeRootPackageJson,
58
+ updateRootScripts
59
+ } from '../utils/config.js'
60
+ import { discoverFoundations, discoverSites } from '../utils/discover.js'
61
+ import { writeJsonPreservingStyleAsync } from '../utils/json-file.js'
62
+ import { getExistingPackageNames, validatePackageName } from '../utils/names.js'
63
+ import { detectPackageManager, installCmd } from '../utils/pm.js'
64
+ import { getCliPrefix } from '../utils/interactive.js'
65
+ import { replaceInYamlList, setYamlKey } from '@uniweb/build/uwx'
66
+
67
+ const colors = {
68
+ reset: '\x1b[0m',
69
+ bright: '\x1b[1m',
70
+ dim: '\x1b[2m',
71
+ cyan: '\x1b[36m',
72
+ green: '\x1b[32m',
73
+ yellow: '\x1b[33m',
74
+ red: '\x1b[31m'
75
+ }
76
+
77
+ const success = (msg) => console.log(`${colors.green}✓${colors.reset} ${msg}`)
78
+ const error = (msg) => console.error(`${colors.red}✗${colors.reset} ${msg}`)
79
+ const info = (msg) => console.log(`${colors.dim}${msg}${colors.reset}`)
80
+ const log = console.log
81
+
82
+ const SUPPORTED_SUBCOMMANDS = new Set(['foundation', 'site', 'extension'])
83
+
84
+ export async function rename(args = []) {
85
+ const [subcommand, oldName, newName] = args
86
+ const prefix = getCliPrefix()
87
+
88
+ if (!subcommand || subcommand === '--help' || subcommand === '-h') {
89
+ showHelp(prefix)
90
+ return
91
+ }
92
+
93
+ if (!SUPPORTED_SUBCOMMANDS.has(subcommand)) {
94
+ error(`Unknown subcommand: ${subcommand}`)
95
+ log(`Supported: rename foundation|site|extension <old> <new>`)
96
+ process.exit(1)
97
+ }
98
+
99
+ if (!oldName || !newName) {
100
+ error('Missing arguments.')
101
+ log(`Usage: ${prefix} rename ${subcommand} <old> <new>`)
102
+ process.exit(1)
103
+ }
104
+
105
+ if (oldName === newName) {
106
+ error('Old and new names are identical — nothing to do.')
107
+ process.exit(1)
108
+ }
109
+
110
+ const rootDir = findWorkspaceRoot()
111
+ if (!rootDir) {
112
+ error('Not in a Uniweb workspace.')
113
+ log(
114
+ `Run this command from your project root or a site/foundation directory.`
115
+ )
116
+ process.exit(1)
117
+ }
118
+
119
+ if (subcommand === 'foundation') {
120
+ await renameFoundation(rootDir, oldName, newName, prefix)
121
+ } else if (subcommand === 'site') {
122
+ await renameSite(rootDir, oldName, newName, prefix)
123
+ } else if (subcommand === 'extension') {
124
+ await renameExtension(rootDir, oldName, newName, prefix)
125
+ }
126
+ }
127
+
128
+ // ─── Common helpers ──────────────────────────────────────────────
129
+
130
+ /**
131
+ * Compute the new folder path for a rename. Returns { folderWillRename,
132
+ * newPath, newDir }. The leaf is renamed to match the new package name
133
+ * only when the leaf already matched the old package name — preserves
134
+ * any folder convention the user adopted that diverges from the package
135
+ * name.
136
+ */
137
+ function computeNewFolderPath(rootDir, oldPath, oldName, newName) {
138
+ const leaf = basename(oldPath)
139
+ const folderWillRename = leaf === oldName
140
+ if (!folderWillRename) {
141
+ return {
142
+ folderWillRename,
143
+ newPath: oldPath,
144
+ newDir: join(rootDir, oldPath)
145
+ }
146
+ }
147
+ const parent = dirname(oldPath)
148
+ const newPath = parent === '.' ? newName : join(parent, newName)
149
+ return { folderWillRename, newPath, newDir: join(rootDir, newPath) }
150
+ }
151
+
152
+ /**
153
+ * Update the workspace manifests when a folder path moves. Both
154
+ * pnpm-workspace.yaml and package.json::workspaces are kept in sync
155
+ * (the multi-PM compatibility invariant). Wildcard entries
156
+ * (`extensions/*`) are left alone — only specific paths get rewritten.
157
+ */
158
+ async function updateWorkspaceManifestsForFolderMove(
159
+ rootDir,
160
+ oldPath,
161
+ newPath
162
+ ) {
163
+ const wsConfig = await readWorkspaceConfig(rootDir)
164
+ const rootPkg = await readRootPackageJson(rootDir)
165
+ if (wsConfig.packages.includes(oldPath)) {
166
+ const idx = wsConfig.packages.indexOf(oldPath)
167
+ wsConfig.packages[idx] = newPath
168
+ await writeWorkspaceConfig(rootDir, wsConfig)
169
+ }
170
+ if (
171
+ Array.isArray(rootPkg.workspaces) &&
172
+ rootPkg.workspaces.includes(oldPath)
173
+ ) {
174
+ const idx = rootPkg.workspaces.indexOf(oldPath)
175
+ rootPkg.workspaces[idx] = newPath
176
+ await writeRootPackageJson(rootDir, rootPkg)
177
+ }
178
+ }
179
+
180
+ /**
181
+ * Validate that a name is a legal package name and not already taken in
182
+ * the workspace. Bails the process with an error if either check fails.
183
+ * `src` and `site` are grandfathered as valid names because they're the
184
+ * default folder leaf for the canonical foundation and site, and `add
185
+ * foundation` / `add site` already grandfather them at scaffold time.
186
+ */
187
+ async function validateRenameName(rootDir, newName) {
188
+ if (newName !== 'src' && newName !== 'site') {
189
+ const valid = validatePackageName(newName)
190
+ if (valid !== true) {
191
+ error(valid)
192
+ process.exit(1)
193
+ }
194
+ }
195
+ const existingNames = await getExistingPackageNames(rootDir)
196
+ if (existingNames.has(newName)) {
197
+ error(
198
+ `Cannot rename: a package named ${colors.bright}${newName}${colors.reset} already exists in this workspace.`
199
+ )
200
+ process.exit(1)
201
+ }
202
+ }
203
+
204
+ /**
205
+ * Rewrite the `name` field in a package.json file.
206
+ */
207
+ async function rewritePackageJsonName(pkgPath, newName) {
208
+ const src = await readFile(pkgPath, 'utf-8')
209
+ const pkg = JSON.parse(src)
210
+ pkg.name = newName
211
+ await writeJsonPreservingStyleAsync(pkgPath, pkg, src)
212
+ }
213
+
214
+ /**
215
+ * Stop, before any file moves, when a site's `site.yml` names the package in a
216
+ * form the in-place edit cannot reach. The alternative is rewriting the whole
217
+ * file, which loses the author's comments and formatting — so we refuse and say
218
+ * how to write it instead.
219
+ */
220
+ function refuseUneditableSiteYml(sitePaths, key, hint) {
221
+ if (sitePaths.length === 0) return
222
+ for (const path of sitePaths) {
223
+ error(
224
+ `Cannot rename: ${colors.bright}${path}/site.yml${colors.reset} writes \`${key}:\` in a form this command cannot edit without rewriting the file.`
225
+ )
226
+ }
227
+ log(`Nothing was changed. ${hint}`)
228
+ log('Then run the rename again.')
229
+ process.exit(1)
230
+ }
231
+
232
+ // ─── Foundation rename ───────────────────────────────────────────
233
+
234
+ async function renameFoundation(rootDir, oldName, newName, prefix) {
235
+ await validateRenameName(rootDir, newName)
236
+
237
+ const foundations = await discoverFoundations(rootDir)
238
+ const target = foundations.find((f) => f.name === oldName)
239
+ if (!target) {
240
+ error(
241
+ `No foundation named ${colors.bright}${oldName}${colors.reset} in this workspace.`
242
+ )
243
+ if (foundations.length > 0) {
244
+ log(`Available: ${foundations.map((f) => f.name).join(', ')}`)
245
+ }
246
+ process.exit(1)
247
+ }
248
+
249
+ // Type guard — point users at the right subcommand if they're trying
250
+ // to rename an extension via the foundation verb. They share a build
251
+ // shape but their touch-point sets differ (foundation rename touches
252
+ // sites' deps + site.yml::foundation; extension rename touches
253
+ // site.yml::extensions URLs).
254
+ if (isExtensionPackage(join(rootDir, target.path))) {
255
+ error(
256
+ `${colors.bright}${oldName}${colors.reset} is an extension, not a foundation.`
257
+ )
258
+ log(`Use \`${prefix} rename extension ${oldName} ${newName}\` instead.`)
259
+ process.exit(1)
260
+ }
261
+
262
+ const oldFoundationPath = target.path
263
+ const oldFoundationDir = join(rootDir, oldFoundationPath)
264
+ const {
265
+ folderWillRename,
266
+ newPath: newFoundationPath,
267
+ newDir: newFoundationDir
268
+ } = computeNewFolderPath(rootDir, oldFoundationPath, oldName, newName)
269
+ if (folderWillRename && existsSync(newFoundationDir)) {
270
+ error(
271
+ `Cannot rename: target folder ${colors.bright}${newFoundationPath}/${colors.reset} already exists.`
272
+ )
273
+ process.exit(1)
274
+ }
275
+
276
+ // Find every site that depends on the foundation. Two signals must
277
+ // agree: site.yml::foundation === oldName AND package.json has the
278
+ // dep key. If they disagree (which doctor would have flagged) we
279
+ // still rename whichever signals point to the old name — the goal
280
+ // is to leave the workspace consistent under the new name.
281
+ const sites = await discoverSites(rootDir)
282
+ const affectedSites = []
283
+ for (const site of sites) {
284
+ const sitePkgPath = join(rootDir, site.path, 'package.json')
285
+ const siteYmlPath = join(rootDir, site.path, 'site.yml')
286
+ let pkg, pkgSrc, ymlText, ymlData
287
+ try {
288
+ pkgSrc = await readFile(sitePkgPath, 'utf-8')
289
+ pkg = JSON.parse(pkgSrc)
290
+ } catch {
291
+ pkg = null
292
+ pkgSrc = null
293
+ }
294
+ try {
295
+ ymlText = await readFile(siteYmlPath, 'utf-8')
296
+ ymlData = yaml.load(ymlText) || {}
297
+ } catch {
298
+ ymlData = null
299
+ }
300
+ const hasDep = pkg?.dependencies && oldName in pkg.dependencies
301
+ const ymlMatches = ymlData?.foundation === oldName
302
+ if (hasDep || ymlMatches) {
303
+ affectedSites.push({
304
+ path: site.path,
305
+ name: site.name,
306
+ pkg,
307
+ pkgSrc,
308
+ sitePkgPath,
309
+ siteYmlPath,
310
+ // Edited in place — `setYamlKey`, @uniweb/build — and computed HERE, before
311
+ // anything moves, so a file it cannot edit stops the rename cleanly.
312
+ newYmlText: ymlMatches ? setYamlKey(ymlText, 'foundation', newName) : null,
313
+ hasDep,
314
+ ymlMatches
315
+ })
316
+ }
317
+ }
318
+ refuseUneditableSiteYml(
319
+ affectedSites.filter((s) => s.ymlMatches && s.newYmlText === null).map((s) => s.path),
320
+ 'foundation',
321
+ `Write \`foundation: ${oldName}\` as a plain value — not through a YAML alias (\`*name\`) or merge key (\`<<:\`).`
322
+ )
323
+
324
+ // ─── Print plan, then execute ────────────────────────────────
325
+
326
+ log('')
327
+ log(
328
+ `${colors.bright}Rename foundation${colors.reset}: ${colors.yellow}${oldName}${colors.reset} → ${colors.green}${newName}${colors.reset}`
329
+ )
330
+ log('')
331
+ if (folderWillRename) {
332
+ info(` Folder: ${oldFoundationPath}/ → ${newFoundationPath}/`)
333
+ } else {
334
+ info(
335
+ ` Folder: ${oldFoundationPath}/ (unchanged — leaf doesn't match package name)`
336
+ )
337
+ }
338
+ info(` package.json::name: "${oldName}" → "${newName}"`)
339
+ if (affectedSites.length === 0) {
340
+ info(` Sites depending on this foundation: none`)
341
+ } else {
342
+ info(` Sites depending on this foundation:`)
343
+ for (const s of affectedSites) {
344
+ info(` • ${s.name} at ${s.path}/`)
345
+ }
346
+ }
347
+ log('')
348
+
349
+ if (folderWillRename) {
350
+ await fsRename(oldFoundationDir, newFoundationDir)
351
+ }
352
+ await rewritePackageJsonName(join(newFoundationDir, 'package.json'), newName)
353
+
354
+ for (const s of affectedSites) {
355
+ if (s.hasDep) {
356
+ const newRel = relative(join(rootDir, s.path), newFoundationDir) || '.'
357
+ const oldValue = s.pkg.dependencies[oldName]
358
+ delete s.pkg.dependencies[oldName]
359
+ s.pkg.dependencies[newName] = oldValue.startsWith('file:')
360
+ ? `file:${newRel}`
361
+ : oldValue // npm-pinned, leave it; rename-then-republish is out of scope.
362
+ await writeJsonPreservingStyleAsync(s.sitePkgPath, s.pkg, s.pkgSrc)
363
+ }
364
+ if (s.ymlMatches) {
365
+ await writeFile(s.siteYmlPath, s.newYmlText)
366
+ }
367
+ }
368
+
369
+ if (folderWillRename) {
370
+ await updateWorkspaceManifestsForFolderMove(
371
+ rootDir,
372
+ oldFoundationPath,
373
+ newFoundationPath
374
+ )
375
+ }
376
+
377
+ // Root scripts can reference the foundation by name (e.g. `pnpm --filter
378
+ // <name> build`). updateRootScripts regenerates them from the current
379
+ // discoverSites output, which has fresh names after the writes above.
380
+ const pm = detectPackageManager()
381
+ const freshSites = await discoverSites(rootDir)
382
+ await updateRootScripts(rootDir, freshSites, pm)
383
+
384
+ log('')
385
+ success(
386
+ `Renamed foundation ${colors.bright}${oldName}${colors.reset} → ${colors.bright}${newName}${colors.reset}`
387
+ )
388
+ printNextSteps(prefix, pm)
389
+ }
390
+
391
+ // ─── Site rename ─────────────────────────────────────────────────
392
+
393
+ async function renameSite(rootDir, oldName, newName, prefix) {
394
+ await validateRenameName(rootDir, newName)
395
+
396
+ const sites = await discoverSites(rootDir)
397
+ const target = sites.find((s) => s.name === oldName)
398
+ if (!target) {
399
+ error(
400
+ `No site named ${colors.bright}${oldName}${colors.reset} in this workspace.`
401
+ )
402
+ if (sites.length > 0) {
403
+ log(`Available: ${sites.map((s) => s.name).join(', ')}`)
404
+ }
405
+ process.exit(1)
406
+ }
407
+
408
+ const oldSitePath = target.path
409
+ const oldSiteDir = join(rootDir, oldSitePath)
410
+ const {
411
+ folderWillRename,
412
+ newPath: newSitePath,
413
+ newDir: newSiteDir
414
+ } = computeNewFolderPath(rootDir, oldSitePath, oldName, newName)
415
+ if (folderWillRename && existsSync(newSiteDir)) {
416
+ error(
417
+ `Cannot rename: target folder ${colors.bright}${newSitePath}/${colors.reset} already exists.`
418
+ )
419
+ process.exit(1)
420
+ }
421
+
422
+ log('')
423
+ log(
424
+ `${colors.bright}Rename site${colors.reset}: ${colors.yellow}${oldName}${colors.reset} → ${colors.green}${newName}${colors.reset}`
425
+ )
426
+ log('')
427
+ if (folderWillRename) {
428
+ info(` Folder: ${oldSitePath}/ → ${newSitePath}/`)
429
+ } else {
430
+ info(
431
+ ` Folder: ${oldSitePath}/ (unchanged — leaf doesn't match package name)`
432
+ )
433
+ }
434
+ info(` package.json::name: "${oldName}" → "${newName}"`)
435
+ log('')
436
+
437
+ if (folderWillRename) {
438
+ await fsRename(oldSiteDir, newSiteDir)
439
+ }
440
+ await rewritePackageJsonName(join(newSiteDir, 'package.json'), newName)
441
+ if (folderWillRename) {
442
+ await updateWorkspaceManifestsForFolderMove(
443
+ rootDir,
444
+ oldSitePath,
445
+ newSitePath
446
+ )
447
+ }
448
+
449
+ // Root scripts include `pnpm --filter <site-name>` style invocations
450
+ // for `dev` / `preview` and per-site aliases. Regenerate from fresh
451
+ // site discovery to pick up the new name.
452
+ const pm = detectPackageManager()
453
+ const freshSites = await discoverSites(rootDir)
454
+ await updateRootScripts(rootDir, freshSites, pm)
455
+
456
+ log('')
457
+ success(
458
+ `Renamed site ${colors.bright}${oldName}${colors.reset} → ${colors.bright}${newName}${colors.reset}`
459
+ )
460
+ printNextSteps(prefix, pm)
461
+ }
462
+
463
+ // ─── Extension rename ────────────────────────────────────────────
464
+
465
+ async function renameExtension(rootDir, oldName, newName, prefix) {
466
+ await validateRenameName(rootDir, newName)
467
+
468
+ // Extensions are a subset of foundations (same build, distinguished
469
+ // by `extension: true` declaration). Find via discoverFoundations,
470
+ // then verify the extension marker.
471
+ const foundations = await discoverFoundations(rootDir)
472
+ const target = foundations.find((f) => f.name === oldName)
473
+ if (!target) {
474
+ error(
475
+ `No package named ${colors.bright}${oldName}${colors.reset} in this workspace.`
476
+ )
477
+ process.exit(1)
478
+ }
479
+ if (!isExtensionPackage(join(rootDir, target.path))) {
480
+ error(
481
+ `${colors.bright}${oldName}${colors.reset} is a foundation, not an extension.`
482
+ )
483
+ log(`Use \`${prefix} rename foundation ${oldName} ${newName}\` instead.`)
484
+ process.exit(1)
485
+ }
486
+
487
+ const oldExtPath = target.path
488
+ const oldExtDir = join(rootDir, oldExtPath)
489
+ const {
490
+ folderWillRename,
491
+ newPath: newExtPath,
492
+ newDir: newExtDir
493
+ } = computeNewFolderPath(rootDir, oldExtPath, oldName, newName)
494
+ if (folderWillRename && existsSync(newExtDir)) {
495
+ error(
496
+ `Cannot rename: target folder ${colors.bright}${newExtPath}/${colors.reset} already exists.`
497
+ )
498
+ process.exit(1)
499
+ }
500
+
501
+ // Find every site referencing this extension via its site.yml's
502
+ // `extensions:` array. Sites declare extensions by URL, with the
503
+ // shape `/<workspace-relative-path>/dist/<file>` (where <file> is
504
+ // `entry.js` post-Phase-4, `foundation.js` for older builds — match
505
+ // both). Foreign URLs (https://, anything not starting with
506
+ // `/<oldExtPath>/`) are left untouched.
507
+ const oldUrlPrefix = `/${oldExtPath}/`
508
+ const newUrlPrefix = `/${newExtPath}/`
509
+ const sites = await discoverSites(rootDir)
510
+ const affectedSites = []
511
+ for (const site of sites) {
512
+ const siteYmlPath = join(rootDir, site.path, 'site.yml')
513
+ let ymlText, ymlData
514
+ try {
515
+ ymlText = await readFile(siteYmlPath, 'utf-8')
516
+ ymlData = yaml.load(ymlText) || {}
517
+ } catch {
518
+ continue
519
+ }
520
+ const exts = Array.isArray(ymlData.extensions) ? ymlData.extensions : []
521
+ const hits = exts.filter(
522
+ (e) => typeof e === 'string' && e.startsWith(oldUrlPrefix)
523
+ )
524
+ if (hits.length > 0) {
525
+ const replacements = new Map(
526
+ hits.map((e) => [e, newUrlPrefix + e.slice(oldUrlPrefix.length)])
527
+ )
528
+ affectedSites.push({
529
+ site,
530
+ siteYmlPath,
531
+ hits,
532
+ // Edited in place, and computed before anything moves — as for foundations.
533
+ newYmlText: replaceInYamlList(ymlText, 'extensions', replacements)
534
+ })
535
+ }
536
+ }
537
+ refuseUneditableSiteYml(
538
+ affectedSites.filter((a) => a.newYmlText === null).map((a) => a.site.path),
539
+ 'extensions',
540
+ 'Write `extensions:` as a plain list — not through a YAML alias (`*name`) or merge key (`<<:`).'
541
+ )
542
+
543
+ log('')
544
+ log(
545
+ `${colors.bright}Rename extension${colors.reset}: ${colors.yellow}${oldName}${colors.reset} → ${colors.green}${newName}${colors.reset}`
546
+ )
547
+ log('')
548
+ if (folderWillRename) {
549
+ info(` Folder: ${oldExtPath}/ → ${newExtPath}/`)
550
+ } else {
551
+ info(
552
+ ` Folder: ${oldExtPath}/ (unchanged — leaf doesn't match package name)`
553
+ )
554
+ }
555
+ info(` package.json::name: "${oldName}" → "${newName}"`)
556
+ if (affectedSites.length === 0) {
557
+ info(` Sites referencing this extension: none`)
558
+ } else {
559
+ info(` Sites referencing this extension:`)
560
+ for (const { site, hits } of affectedSites) {
561
+ info(
562
+ ` • ${site.name} at ${site.path}/ (${hits.length} entr${hits.length === 1 ? 'y' : 'ies'})`
563
+ )
564
+ }
565
+ }
566
+ log('')
567
+
568
+ if (folderWillRename) {
569
+ await fsRename(oldExtDir, newExtDir)
570
+ }
571
+ await rewritePackageJsonName(join(newExtDir, 'package.json'), newName)
572
+
573
+ for (const a of affectedSites) {
574
+ await writeFile(a.siteYmlPath, a.newYmlText)
575
+ }
576
+
577
+ if (folderWillRename) {
578
+ await updateWorkspaceManifestsForFolderMove(rootDir, oldExtPath, newExtPath)
579
+ }
580
+
581
+ // Extensions don't appear in root scripts (no dev/preview filter by
582
+ // extension name — the foundation's own scripts handle building).
583
+ // No updateRootScripts needed.
584
+
585
+ const pm = detectPackageManager()
586
+ log('')
587
+ success(
588
+ `Renamed extension ${colors.bright}${oldName}${colors.reset} → ${colors.bright}${newName}${colors.reset}`
589
+ )
590
+ printNextSteps(prefix, pm)
591
+ }
592
+
593
+ // ─── Shared output ───────────────────────────────────────────────
594
+
595
+ function printNextSteps(prefix, pm) {
596
+ log('')
597
+ log(
598
+ `Next: ${colors.cyan}${installCmd(pm)}${colors.reset} ${colors.dim}(refresh symlinks under the new name)${colors.reset}`
599
+ )
600
+ log(
601
+ ` ${colors.cyan}${prefix} doctor${colors.reset} ${colors.dim}(verify wiring)${colors.reset}`
602
+ )
603
+ }
604
+
605
+ function showHelp(prefix) {
606
+ log(`
607
+ ${colors.cyan}${colors.bright}Uniweb Rename${colors.reset}
608
+
609
+ Rename a package across the workspace, keeping all wiring in sync.
610
+
611
+ ${colors.bright}Usage:${colors.reset}
612
+ ${prefix} rename foundation <old-name> <new-name>
613
+ ${prefix} rename site <old-name> <new-name>
614
+ ${prefix} rename extension <old-name> <new-name>
615
+
616
+ ${colors.bright}What rename foundation does:${colors.reset}
617
+ • Updates the foundation's package.json::name.
618
+ • Renames the folder if its leaf matched the old package name.
619
+ • Updates every site's package.json dependency key + file: path.
620
+ • Updates every site's site.yml::foundation reference.
621
+ • Updates pnpm-workspace.yaml + package.json::workspaces (kept in sync).
622
+ • Regenerates root scripts.
623
+
624
+ ${colors.bright}What rename site does:${colors.reset}
625
+ • Updates the site's package.json::name.
626
+ • Renames the folder if its leaf matched the old package name.
627
+ • Updates pnpm-workspace.yaml + package.json::workspaces.
628
+ • Regenerates root scripts (\`dev\` / \`preview\` filter by site name).
629
+
630
+ ${colors.bright}What rename extension does:${colors.reset}
631
+ • Updates the extension's package.json::name.
632
+ • Renames the folder if its leaf matched the old package name.
633
+ • Updates every site.yml::extensions URL whose path matched the old folder.
634
+ • Updates pnpm-workspace.yaml + package.json::workspaces.
635
+
636
+ ${colors.bright}What rename does NOT do (any subcommand):${colors.reset}
637
+ • Push to the registry, or change what a foundation registers as — that is
638
+ \`name\` in its main.js, independent of the package name. To register under
639
+ a new name, change it there and run \`${prefix} register\` (alias
640
+ \`${prefix} release\`) — there is no rename flag.
641
+
642
+ ${colors.bright}Examples:${colors.reset}
643
+ ${prefix} rename foundation src marketing-src
644
+ ${prefix} rename site site marketing-com
645
+ ${prefix} rename extension effects animations
646
+ `)
647
+ }