uniweb 0.12.12 → 0.12.14

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/README.md CHANGED
@@ -315,7 +315,7 @@ A Uniweb project produces two artifacts — a **site** (content) and a **foundat
315
315
  - **Standalone mode** — site and foundation built into one self-contained `dist/`, deployed to any static host.
316
316
  - **Linked mode** — the foundation is a separate file the site loads at runtime, with two flavours:
317
317
  - **Site-bound** — the foundation belongs to one site and rides with it (`foundation: ~self/<name>@<version>` in `site.yml`).
318
- - **Cataloged** — the foundation is a catalog product, published once and licensed to consuming sites (`foundation: '@<org>/<name>@<version>'`).
318
+ - **Cataloged** — the foundation is a private catalog product, published once and licensed to consuming sites (`foundation: '@<org>/<name>@<version>'`).
319
319
 
320
320
  `uniweb publish` ships a cataloged foundation; `uniweb deploy` ships a site (and, for site-bound, the foundation along with it). Most projects start standalone or site-bound and grow into cataloged when a foundation needs to serve more than one site.
321
321
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "uniweb",
3
- "version": "0.12.12",
3
+ "version": "0.12.14",
4
4
  "description": "Create structured Vite + React sites with content/code separation",
5
5
  "type": "module",
6
6
  "bin": {
@@ -41,9 +41,9 @@
41
41
  "js-yaml": "^4.1.0",
42
42
  "prompts": "^2.4.2",
43
43
  "tar": "^7.0.0",
44
- "@uniweb/core": "0.7.11",
45
44
  "@uniweb/runtime": "0.8.13",
46
- "@uniweb/kit": "0.9.11"
45
+ "@uniweb/kit": "0.9.11",
46
+ "@uniweb/core": "0.7.11"
47
47
  },
48
48
  "peerDependencies": {
49
49
  "@uniweb/build": "0.14.2",
@@ -148,8 +148,7 @@ uniweb deploy --host=<adapter> # Deploy to a static host: cloudflare-pages, n
148
148
  # vercel, github-pages, s3-cloudfront, generic-static
149
149
  uniweb deploy --dry-run # Resolve foundation/runtime + print summary; no writes
150
150
  uniweb export # Build dist/ for any static host (no Uniweb account)
151
- uniweb publish # Publish a foundation as a catalog product (deliberate;
152
- # for site-bound foundations use `uniweb deploy` instead)
151
+ uniweb publish # Publish a foundation to the Uniweb registry
153
152
  uniweb doctor # Diagnose project configuration issues (--fix to auto-repair)
154
153
 
155
154
  # Help
@@ -1,35 +1,50 @@
1
1
  /**
2
2
  * Rename Command
3
3
  *
4
- * Renames packages across the workspace transactionally. Currently
5
- * supports `rename foundation <old> <new>`; sites and extensions can be
6
- * added with the same scaffolding when needed.
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
7
  *
8
- * What a foundation rename touches (in order):
9
- * 1. The foundation's own package.json::name.
10
- * 2. Folder name on disk (when folder leaf matches old package name).
11
- * 3. Each site's package.json: dep key (old → new) + file: path.
12
- * 4. Each site's site.yml::foundation.
13
- * 5. pnpm-workspace.yaml::packages and package.json::workspaces
14
- * (when the folder rename moved the path).
15
- * 6. Root scripts (`pnpm --filter <old>` references).
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
+ * src/foundation.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.
16
28
  *
17
29
  * Pre-flight checks run before any mutation. If anything would conflict
18
- * (target name already taken, foundation not found, folder collision)
19
- * we bail with a clear message and no partial state.
30
+ * (target name already taken, target not found, folder collision,
31
+ * type mismatch) we bail with a clear message and no partial state.
20
32
  *
21
- * Out of scope: registry side. The publish id (`package.json::uniweb.id`)
33
+ * Out of scope: registry side. The publish id (package.json::uniweb.id)
22
34
  * is independent of the workspace name and stays untouched. Users who
23
35
  * want to also rename on the registry run `uniweb publish --name <new>`.
24
36
  *
25
37
  * Usage:
26
38
  * uniweb rename foundation <old> <new>
39
+ * uniweb rename site <old> <new>
40
+ * uniweb rename extension <old> <new>
27
41
  */
28
42
 
29
43
  import { existsSync } from 'node:fs'
30
44
  import { readFile, writeFile, rename as fsRename } from 'node:fs/promises'
31
45
  import { join, relative, dirname, basename } from 'node:path'
32
46
  import yaml from 'js-yaml'
47
+ import { isExtensionPackage } from '@uniweb/build'
33
48
  import { findWorkspaceRoot } from '../utils/workspace.js'
34
49
  import {
35
50
  discoverFoundations,
@@ -59,6 +74,8 @@ const error = (msg) => console.error(`${colors.red}✗${colors.reset} ${msg}`)
59
74
  const info = (msg) => console.log(`${colors.dim}${msg}${colors.reset}`)
60
75
  const log = console.log
61
76
 
77
+ const SUPPORTED_SUBCOMMANDS = new Set(['foundation', 'site', 'extension'])
78
+
62
79
  export async function rename(args = []) {
63
80
  const [subcommand, oldName, newName] = args
64
81
  const prefix = getCliPrefix()
@@ -68,15 +85,15 @@ export async function rename(args = []) {
68
85
  return
69
86
  }
70
87
 
71
- if (subcommand !== 'foundation') {
88
+ if (!SUPPORTED_SUBCOMMANDS.has(subcommand)) {
72
89
  error(`Unknown subcommand: ${subcommand}`)
73
- log(`Supported: rename foundation <old> <new>`)
90
+ log(`Supported: rename foundation|site|extension <old> <new>`)
74
91
  process.exit(1)
75
92
  }
76
93
 
77
94
  if (!oldName || !newName) {
78
95
  error('Missing arguments.')
79
- log(`Usage: ${prefix} rename foundation <old> <new>`)
96
+ log(`Usage: ${prefix} rename ${subcommand} <old> <new>`)
80
97
  process.exit(1)
81
98
  }
82
99
 
@@ -92,24 +109,92 @@ export async function rename(args = []) {
92
109
  process.exit(1)
93
110
  }
94
111
 
95
- await renameFoundation(rootDir, oldName, newName, prefix)
112
+ if (subcommand === 'foundation') {
113
+ await renameFoundation(rootDir, oldName, newName, prefix)
114
+ } else if (subcommand === 'site') {
115
+ await renameSite(rootDir, oldName, newName, prefix)
116
+ } else if (subcommand === 'extension') {
117
+ await renameExtension(rootDir, oldName, newName, prefix)
118
+ }
96
119
  }
97
120
 
98
- async function renameFoundation(rootDir, oldName, newName, prefix) {
99
- // ─── Pre-flight ───────────────────────────────────────────────
121
+ // ─── Common helpers ──────────────────────────────────────────────
100
122
 
101
- // Validate the new name (format + reserved-name check). `src` is
102
- // grandfathered in for the same reason add foundation grandfathers
103
- // it (the convention for "the package that lives in src/").
104
- if (newName !== 'src') {
123
+ /**
124
+ * Compute the new folder path for a rename. Returns { folderWillRename,
125
+ * newPath, newDir }. The leaf is renamed to match the new package name
126
+ * only when the leaf already matched the old package name — preserves
127
+ * any folder convention the user adopted that diverges from the package
128
+ * name.
129
+ */
130
+ function computeNewFolderPath(rootDir, oldPath, oldName, newName) {
131
+ const leaf = basename(oldPath)
132
+ const folderWillRename = leaf === oldName
133
+ if (!folderWillRename) {
134
+ return { folderWillRename, newPath: oldPath, newDir: join(rootDir, oldPath) }
135
+ }
136
+ const parent = dirname(oldPath)
137
+ const newPath = parent === '.' ? newName : join(parent, newName)
138
+ return { folderWillRename, newPath, newDir: join(rootDir, newPath) }
139
+ }
140
+
141
+ /**
142
+ * Update the workspace manifests when a folder path moves. Both
143
+ * pnpm-workspace.yaml and package.json::workspaces are kept in sync
144
+ * (the multi-PM compatibility invariant). Wildcard entries
145
+ * (`extensions/*`) are left alone — only specific paths get rewritten.
146
+ */
147
+ async function updateWorkspaceManifestsForFolderMove(rootDir, oldPath, newPath) {
148
+ const wsConfig = await readWorkspaceConfig(rootDir)
149
+ const rootPkg = await readRootPackageJson(rootDir)
150
+ if (wsConfig.packages.includes(oldPath)) {
151
+ const idx = wsConfig.packages.indexOf(oldPath)
152
+ wsConfig.packages[idx] = newPath
153
+ await writeWorkspaceConfig(rootDir, wsConfig)
154
+ }
155
+ if (Array.isArray(rootPkg.workspaces) && rootPkg.workspaces.includes(oldPath)) {
156
+ const idx = rootPkg.workspaces.indexOf(oldPath)
157
+ rootPkg.workspaces[idx] = newPath
158
+ await writeRootPackageJson(rootDir, rootPkg)
159
+ }
160
+ }
161
+
162
+ /**
163
+ * Validate that a name is a legal package name and not already taken in
164
+ * the workspace. Bails the process with an error if either check fails.
165
+ * `src` and `site` are grandfathered as valid names because they're the
166
+ * default folder leaf for the canonical foundation and site, and `add
167
+ * foundation` / `add site` already grandfather them at scaffold time.
168
+ */
169
+ async function validateRenameName(rootDir, newName) {
170
+ if (newName !== 'src' && newName !== 'site') {
105
171
  const valid = validatePackageName(newName)
106
172
  if (valid !== true) {
107
173
  error(valid)
108
174
  process.exit(1)
109
175
  }
110
176
  }
177
+ const existingNames = await getExistingPackageNames(rootDir)
178
+ if (existingNames.has(newName)) {
179
+ error(`Cannot rename: a package named ${colors.bright}${newName}${colors.reset} already exists in this workspace.`)
180
+ process.exit(1)
181
+ }
182
+ }
183
+
184
+ /**
185
+ * Rewrite the `name` field in a package.json file.
186
+ */
187
+ async function rewritePackageJsonName(pkgPath, newName) {
188
+ const pkg = JSON.parse(await readFile(pkgPath, 'utf-8'))
189
+ pkg.name = newName
190
+ await writeFile(pkgPath, JSON.stringify(pkg, null, 2) + '\n')
191
+ }
192
+
193
+ // ─── Foundation rename ───────────────────────────────────────────
194
+
195
+ async function renameFoundation(rootDir, oldName, newName, prefix) {
196
+ await validateRenameName(rootDir, newName)
111
197
 
112
- // The foundation must exist under its current name.
113
198
  const foundations = await discoverFoundations(rootDir)
114
199
  const target = foundations.find(f => f.name === oldName)
115
200
  if (!target) {
@@ -120,33 +205,24 @@ async function renameFoundation(rootDir, oldName, newName, prefix) {
120
205
  process.exit(1)
121
206
  }
122
207
 
123
- // The new name must be free across the entire workspace.
124
- const existingNames = await getExistingPackageNames(rootDir)
125
- if (existingNames.has(newName)) {
126
- error(`Cannot rename: a package named ${colors.bright}${newName}${colors.reset} already exists in this workspace.`)
208
+ // Type guard point users at the right subcommand if they're trying
209
+ // to rename an extension via the foundation verb. They share a build
210
+ // shape but their touch-point sets differ (foundation rename touches
211
+ // sites' deps + site.yml::foundation; extension rename touches
212
+ // site.yml::extensions URLs).
213
+ if (isExtensionPackage(join(rootDir, target.path))) {
214
+ error(`${colors.bright}${oldName}${colors.reset} is an extension, not a foundation.`)
215
+ log(`Use \`${prefix} rename extension ${oldName} ${newName}\` instead.`)
127
216
  process.exit(1)
128
217
  }
129
218
 
130
- const oldFoundationPath = target.path // workspace-relative
219
+ const oldFoundationPath = target.path
131
220
  const oldFoundationDir = join(rootDir, oldFoundationPath)
132
- const folderLeaf = basename(oldFoundationPath)
133
-
134
- // Decide the new folder path. Rule: if the folder leaf matches the
135
- // old package name, rename the leaf to match the new package name
136
- // (preserving any parent dirs like `foundations/`). Otherwise leave
137
- // the folder alone — the user named the folder differently than the
138
- // package, and we honor that.
139
- let newFoundationPath = oldFoundationPath
140
- let newFoundationDir = oldFoundationDir
141
- const folderWillRename = folderLeaf === oldName
142
- if (folderWillRename) {
143
- const parent = dirname(oldFoundationPath)
144
- newFoundationPath = parent === '.' ? newName : join(parent, newName)
145
- newFoundationDir = join(rootDir, newFoundationPath)
146
- if (existsSync(newFoundationDir)) {
147
- error(`Cannot rename: target folder ${colors.bright}${newFoundationPath}/${colors.reset} already exists.`)
148
- process.exit(1)
149
- }
221
+ const { folderWillRename, newPath: newFoundationPath, newDir: newFoundationDir } =
222
+ computeNewFolderPath(rootDir, oldFoundationPath, oldName, newName)
223
+ if (folderWillRename && existsSync(newFoundationDir)) {
224
+ error(`Cannot rename: target folder ${colors.bright}${newFoundationPath}/${colors.reset} already exists.`)
225
+ process.exit(1)
150
226
  }
151
227
 
152
228
  // Find every site that depends on the foundation. Two signals must
@@ -159,17 +235,15 @@ async function renameFoundation(rootDir, oldName, newName, prefix) {
159
235
  for (const site of sites) {
160
236
  const sitePkgPath = join(rootDir, site.path, 'package.json')
161
237
  const siteYmlPath = join(rootDir, site.path, 'site.yml')
162
- let pkg, ymlText, ymlData
238
+ let pkg, ymlData
163
239
  try {
164
240
  pkg = JSON.parse(await readFile(sitePkgPath, 'utf-8'))
165
241
  } catch {
166
242
  pkg = null
167
243
  }
168
244
  try {
169
- ymlText = await readFile(siteYmlPath, 'utf-8')
170
- ymlData = yaml.load(ymlText) || {}
245
+ ymlData = yaml.load(await readFile(siteYmlPath, 'utf-8')) || {}
171
246
  } catch {
172
- ymlText = null
173
247
  ymlData = null
174
248
  }
175
249
  const hasDep = pkg?.dependencies && oldName in pkg.dependencies
@@ -188,12 +262,6 @@ async function renameFoundation(rootDir, oldName, newName, prefix) {
188
262
  }
189
263
  }
190
264
 
191
- // Read workspace config + root package.json once. Both manifests
192
- // are kept in sync by addWorkspaceGlob; we update both here too
193
- // (the multi-PM compatibility invariant).
194
- const wsConfig = await readWorkspaceConfig(rootDir)
195
- const rootPkg = await readRootPackageJson(rootDir)
196
-
197
265
  // ─── Print plan, then execute ────────────────────────────────
198
266
 
199
267
  log('')
@@ -215,31 +283,19 @@ async function renameFoundation(rootDir, oldName, newName, prefix) {
215
283
  }
216
284
  log('')
217
285
 
218
- // 1. Rename the folder (if applicable). Do this first because every
219
- // later write needs paths under the new location.
220
286
  if (folderWillRename) {
221
287
  await fsRename(oldFoundationDir, newFoundationDir)
222
288
  }
289
+ await rewritePackageJsonName(join(newFoundationDir, 'package.json'), newName)
223
290
 
224
- // 2. Rewrite the foundation's package.json::name.
225
- const fndPkgPath = join(newFoundationDir, 'package.json')
226
- const fndPkg = JSON.parse(await readFile(fndPkgPath, 'utf-8'))
227
- fndPkg.name = newName
228
- await writeFile(fndPkgPath, JSON.stringify(fndPkg, null, 2) + '\n')
229
-
230
- // 3 + 4. For each affected site, update package.json deps and site.yml.
231
291
  for (const s of affectedSites) {
232
292
  if (s.hasDep) {
233
- // Rename the dep key. Recompute the file: path against the new
234
- // foundation location (the old one no longer exists if the
235
- // folder was renamed).
236
293
  const newRel = relative(join(rootDir, s.path), newFoundationDir) || '.'
237
294
  const oldValue = s.pkg.dependencies[oldName]
238
295
  delete s.pkg.dependencies[oldName]
239
296
  s.pkg.dependencies[newName] = oldValue.startsWith('file:')
240
297
  ? `file:${newRel}`
241
- : oldValue // npm-pinned, leave it; rename-then-republish would
242
- // need a separate `pnpm update` step that's out of scope.
298
+ : oldValue // npm-pinned, leave it; rename-then-republish is out of scope.
243
299
  await writeFile(s.sitePkgPath, JSON.stringify(s.pkg, null, 2) + '\n')
244
300
  }
245
301
  if (s.ymlMatches) {
@@ -248,38 +304,185 @@ async function renameFoundation(rootDir, oldName, newName, prefix) {
248
304
  }
249
305
  }
250
306
 
251
- // 5. Update workspace manifests. Both pnpm-workspace.yaml and
252
- // package.json::workspaces (sync invariant) — only the entry
253
- // matching the old folder path moves; bare globs like
254
- // `foundations/*` don't change.
255
307
  if (folderWillRename) {
256
- if (wsConfig.packages.includes(oldFoundationPath)) {
257
- const idx = wsConfig.packages.indexOf(oldFoundationPath)
258
- wsConfig.packages[idx] = newFoundationPath
259
- await writeWorkspaceConfig(rootDir, wsConfig)
260
- }
261
- if (Array.isArray(rootPkg.workspaces) && rootPkg.workspaces.includes(oldFoundationPath)) {
262
- const idx = rootPkg.workspaces.indexOf(oldFoundationPath)
263
- rootPkg.workspaces[idx] = newFoundationPath
264
- await writeRootPackageJson(rootDir, rootPkg)
308
+ await updateWorkspaceManifestsForFolderMove(rootDir, oldFoundationPath, newFoundationPath)
309
+ }
310
+
311
+ // Root scripts can reference the foundation by name (e.g. `pnpm --filter
312
+ // <name> build`). updateRootScripts regenerates them from the current
313
+ // discoverSites output, which has fresh names after the writes above.
314
+ const pm = detectPackageManager()
315
+ const freshSites = await discoverSites(rootDir)
316
+ await updateRootScripts(rootDir, freshSites, pm)
317
+
318
+ log('')
319
+ success(`Renamed foundation ${colors.bright}${oldName}${colors.reset} → ${colors.bright}${newName}${colors.reset}`)
320
+ printNextSteps(prefix, pm)
321
+ }
322
+
323
+ // ─── Site rename ─────────────────────────────────────────────────
324
+
325
+ async function renameSite(rootDir, oldName, newName, prefix) {
326
+ await validateRenameName(rootDir, newName)
327
+
328
+ const sites = await discoverSites(rootDir)
329
+ const target = sites.find(s => s.name === oldName)
330
+ if (!target) {
331
+ error(`No site named ${colors.bright}${oldName}${colors.reset} in this workspace.`)
332
+ if (sites.length > 0) {
333
+ log(`Available: ${sites.map(s => s.name).join(', ')}`)
265
334
  }
335
+ process.exit(1)
336
+ }
337
+
338
+ const oldSitePath = target.path
339
+ const oldSiteDir = join(rootDir, oldSitePath)
340
+ const { folderWillRename, newPath: newSitePath, newDir: newSiteDir } =
341
+ computeNewFolderPath(rootDir, oldSitePath, oldName, newName)
342
+ if (folderWillRename && existsSync(newSiteDir)) {
343
+ error(`Cannot rename: target folder ${colors.bright}${newSitePath}/${colors.reset} already exists.`)
344
+ process.exit(1)
266
345
  }
267
346
 
268
- // 6. Root scripts can reference the foundation by name (e.g.
269
- // `pnpm --filter <name> build`). updateRootScripts regenerates
270
- // them from the current discoverSites output, which has fresh
271
- // names after the writes above.
347
+ log('')
348
+ log(`${colors.bright}Rename site${colors.reset}: ${colors.yellow}${oldName}${colors.reset} → ${colors.green}${newName}${colors.reset}`)
349
+ log('')
350
+ if (folderWillRename) {
351
+ info(` Folder: ${oldSitePath}/ → ${newSitePath}/`)
352
+ } else {
353
+ info(` Folder: ${oldSitePath}/ (unchanged — leaf doesn't match package name)`)
354
+ }
355
+ info(` package.json::name: "${oldName}" → "${newName}"`)
356
+ log('')
357
+
358
+ if (folderWillRename) {
359
+ await fsRename(oldSiteDir, newSiteDir)
360
+ }
361
+ await rewritePackageJsonName(join(newSiteDir, 'package.json'), newName)
362
+ if (folderWillRename) {
363
+ await updateWorkspaceManifestsForFolderMove(rootDir, oldSitePath, newSitePath)
364
+ }
365
+
366
+ // Root scripts include `pnpm --filter <site-name>` style invocations
367
+ // for `dev` / `preview` and per-site aliases. Regenerate from fresh
368
+ // site discovery to pick up the new name.
272
369
  const pm = detectPackageManager()
273
370
  const freshSites = await discoverSites(rootDir)
274
371
  await updateRootScripts(rootDir, freshSites, pm)
275
372
 
276
- // ─── Done ─────────────────────────────────────────────────────
373
+ log('')
374
+ success(`Renamed site ${colors.bright}${oldName}${colors.reset} → ${colors.bright}${newName}${colors.reset}`)
375
+ printNextSteps(prefix, pm)
376
+ }
377
+
378
+ // ─── Extension rename ────────────────────────────────────────────
379
+
380
+ async function renameExtension(rootDir, oldName, newName, prefix) {
381
+ await validateRenameName(rootDir, newName)
382
+
383
+ // Extensions are a subset of foundations (same build, distinguished
384
+ // by `extension: true` declaration). Find via discoverFoundations,
385
+ // then verify the extension marker.
386
+ const foundations = await discoverFoundations(rootDir)
387
+ const target = foundations.find(f => f.name === oldName)
388
+ if (!target) {
389
+ error(`No package named ${colors.bright}${oldName}${colors.reset} in this workspace.`)
390
+ process.exit(1)
391
+ }
392
+ if (!isExtensionPackage(join(rootDir, target.path))) {
393
+ error(`${colors.bright}${oldName}${colors.reset} is a foundation, not an extension.`)
394
+ log(`Use \`${prefix} rename foundation ${oldName} ${newName}\` instead.`)
395
+ process.exit(1)
396
+ }
397
+
398
+ const oldExtPath = target.path
399
+ const oldExtDir = join(rootDir, oldExtPath)
400
+ const { folderWillRename, newPath: newExtPath, newDir: newExtDir } =
401
+ computeNewFolderPath(rootDir, oldExtPath, oldName, newName)
402
+ if (folderWillRename && existsSync(newExtDir)) {
403
+ error(`Cannot rename: target folder ${colors.bright}${newExtPath}/${colors.reset} already exists.`)
404
+ process.exit(1)
405
+ }
406
+
407
+ // Find every site referencing this extension via its site.yml's
408
+ // `extensions:` array. Sites declare extensions by URL, with the
409
+ // shape `/<workspace-relative-path>/dist/<file>` (where <file> is
410
+ // `entry.js` post-Phase-4, `foundation.js` for older builds — match
411
+ // both). Foreign URLs (https://, anything not starting with
412
+ // `/<oldExtPath>/`) are left untouched.
413
+ const oldUrlPrefix = `/${oldExtPath}/`
414
+ const newUrlPrefix = `/${newExtPath}/`
415
+ const sites = await discoverSites(rootDir)
416
+ const affectedSites = []
417
+ for (const site of sites) {
418
+ const siteYmlPath = join(rootDir, site.path, 'site.yml')
419
+ let ymlData
420
+ try {
421
+ ymlData = yaml.load(await readFile(siteYmlPath, 'utf-8')) || {}
422
+ } catch {
423
+ continue
424
+ }
425
+ const exts = Array.isArray(ymlData.extensions) ? ymlData.extensions : []
426
+ const hits = exts.filter(e => typeof e === 'string' && e.startsWith(oldUrlPrefix))
427
+ if (hits.length > 0) {
428
+ affectedSites.push({ site, ymlData, siteYmlPath, hits })
429
+ }
430
+ }
277
431
 
278
432
  log('')
279
- success(`Renamed foundation ${colors.bright}${oldName}${colors.reset} → ${colors.bright}${newName}${colors.reset}`)
433
+ log(`${colors.bright}Rename extension${colors.reset}: ${colors.yellow}${oldName}${colors.reset} → ${colors.green}${newName}${colors.reset}`)
434
+ log('')
435
+ if (folderWillRename) {
436
+ info(` Folder: ${oldExtPath}/ → ${newExtPath}/`)
437
+ } else {
438
+ info(` Folder: ${oldExtPath}/ (unchanged — leaf doesn't match package name)`)
439
+ }
440
+ info(` package.json::name: "${oldName}" → "${newName}"`)
441
+ if (affectedSites.length === 0) {
442
+ info(` Sites referencing this extension: none`)
443
+ } else {
444
+ info(` Sites referencing this extension:`)
445
+ for (const { site, hits } of affectedSites) {
446
+ info(` • ${site.name} at ${site.path}/ (${hits.length} entr${hits.length === 1 ? 'y' : 'ies'})`)
447
+ }
448
+ }
449
+ log('')
450
+
451
+ if (folderWillRename) {
452
+ await fsRename(oldExtDir, newExtDir)
453
+ }
454
+ await rewritePackageJsonName(join(newExtDir, 'package.json'), newName)
455
+
456
+ for (const a of affectedSites) {
457
+ const newExts = (a.ymlData.extensions || []).map(e =>
458
+ typeof e === 'string' && e.startsWith(oldUrlPrefix)
459
+ ? newUrlPrefix + e.slice(oldUrlPrefix.length)
460
+ : e
461
+ )
462
+ const newYmlData = { ...a.ymlData, extensions: newExts }
463
+ await writeFile(a.siteYmlPath, yaml.dump(newYmlData, { flowLevel: -1, quotingType: "'" }))
464
+ }
465
+
466
+ if (folderWillRename) {
467
+ await updateWorkspaceManifestsForFolderMove(rootDir, oldExtPath, newExtPath)
468
+ }
469
+
470
+ // Extensions don't appear in root scripts (no dev/preview filter by
471
+ // extension name — the foundation's own scripts handle building).
472
+ // No updateRootScripts needed.
473
+
474
+ const pm = detectPackageManager()
475
+ log('')
476
+ success(`Renamed extension ${colors.bright}${oldName}${colors.reset} → ${colors.bright}${newName}${colors.reset}`)
477
+ printNextSteps(prefix, pm)
478
+ }
479
+
480
+ // ─── Shared output ───────────────────────────────────────────────
481
+
482
+ function printNextSteps(prefix, pm) {
280
483
  log('')
281
484
  log(`Next: ${colors.cyan}${installCmd(pm)}${colors.reset} ${colors.dim}(refresh symlinks under the new name)${colors.reset}`)
282
- log(` ${colors.cyan}${getCliPrefix()} doctor${colors.reset} ${colors.dim}(verify wiring)${colors.reset}`)
485
+ log(` ${colors.cyan}${prefix} doctor${colors.reset} ${colors.dim}(verify wiring)${colors.reset}`)
283
486
  }
284
487
 
285
488
  function showHelp(prefix) {
@@ -290,8 +493,10 @@ Rename a package across the workspace, keeping all wiring in sync.
290
493
 
291
494
  ${colors.bright}Usage:${colors.reset}
292
495
  ${prefix} rename foundation <old-name> <new-name>
496
+ ${prefix} rename site <old-name> <new-name>
497
+ ${prefix} rename extension <old-name> <new-name>
293
498
 
294
- ${colors.bright}What it does (foundation):${colors.reset}
499
+ ${colors.bright}What rename foundation does:${colors.reset}
295
500
  • Updates the foundation's package.json::name.
296
501
  • Renames the folder if its leaf matched the old package name.
297
502
  • Updates every site's package.json dependency key + file: path.
@@ -299,12 +504,25 @@ ${colors.bright}What it does (foundation):${colors.reset}
299
504
  • Updates pnpm-workspace.yaml + package.json::workspaces (kept in sync).
300
505
  • Regenerates root scripts.
301
506
 
302
- ${colors.bright}What it does NOT do:${colors.reset}
507
+ ${colors.bright}What rename site does:${colors.reset}
508
+ • Updates the site's package.json::name.
509
+ • Renames the folder if its leaf matched the old package name.
510
+ • Updates pnpm-workspace.yaml + package.json::workspaces.
511
+ • Regenerates root scripts (\`dev\` / \`preview\` filter by site name).
512
+
513
+ ${colors.bright}What rename extension does:${colors.reset}
514
+ • Updates the extension's package.json::name.
515
+ • Renames the folder if its leaf matched the old package name.
516
+ • Updates every site.yml::extensions URL whose path matched the old folder.
517
+ • Updates pnpm-workspace.yaml + package.json::workspaces.
518
+
519
+ ${colors.bright}What rename does NOT do (any subcommand):${colors.reset}
303
520
  • Push to the registry. The publish id (package.json::uniweb.id) is
304
521
  independent. To rename on the registry too, run \`${prefix} publish --name <new>\`.
305
522
 
306
523
  ${colors.bright}Examples:${colors.reset}
307
524
  ${prefix} rename foundation src marketing-src
308
- ${prefix} rename foundation marketing acme-marketing
525
+ ${prefix} rename site site marketing-com
526
+ ${prefix} rename extension effects animations
309
527
  `)
310
528
  }
package/src/index.js CHANGED
@@ -449,14 +449,26 @@ async function main() {
449
449
  // Output convention: the version goes to stdout (parseable, scriptable —
450
450
  // `version=$(uniweb --version)` should keep working). Any staleness
451
451
  // notice goes to stderr, so it shows in interactive terminals but
452
- // doesn't pollute captured output. Cache-only — never makes a network
453
- // call from this path.
452
+ // doesn't pollute captured output.
453
+ //
454
+ // Two staleness paths split by stdout TTY-ness:
455
+ // - TTY (interactive user typed it): fetch the registry with a tight
456
+ // timeout. Accuracy matters — a fresh install would otherwise see
457
+ // no notice on its first invocation (the cache is empty). Network
458
+ // latency is acceptable here, capped at ~1.5s by the abort timeout.
459
+ // - Non-TTY (script captured stdout, piped through, etc.): cache-only.
460
+ // Scripts must stay fast and offline-safe. The `gh --version` /
461
+ // `claude --version` convention.
454
462
  if (command === '--version' || command === '-v') {
455
463
  console.log(`uniweb ${getCliVersion()}`)
456
464
  if (isGlobalInstall()) {
457
465
  try {
458
- const { maybeNotifyFromCache } = await import('./utils/update-check.js')
459
- maybeNotifyFromCache(getCliVersion(), 'soft')
466
+ const { fetchAndNotifyIfNewer, maybeNotifyFromCache } = await import('./utils/update-check.js')
467
+ if (process.stdout.isTTY) {
468
+ await fetchAndNotifyIfNewer(getCliVersion(), { tone: 'soft' })
469
+ } else {
470
+ maybeNotifyFromCache(getCliVersion(), 'soft')
471
+ }
460
472
  } catch { /* ignore */ }
461
473
  }
462
474
  return
@@ -1063,12 +1075,30 @@ ${colors.cyan}${colors.bright}uniweb rename${colors.reset} ${colors.dim}— Rena
1063
1075
 
1064
1076
  ${colors.bright}Usage:${colors.reset}
1065
1077
  uniweb rename foundation <old> <new>
1078
+ uniweb rename site <old> <new>
1079
+ uniweb rename extension <old> <new>
1080
+
1081
+ Each subcommand updates a different set of touch points:
1082
+
1083
+ foundation: package.json::name + folder + every dependent site's
1084
+ package.json (dep key + file: path) + every site.yml::foundation +
1085
+ pnpm-workspace.yaml / package.json::workspaces + root scripts.
1086
+
1087
+ site: package.json::name + folder + workspace manifests + root
1088
+ scripts (\`dev\` / \`preview\` are filtered by site name).
1089
+
1090
+ extension: package.json::name + folder + every site.yml::extensions
1091
+ URL whose path matches the old folder + workspace manifests.
1092
+ (Sites don't carry a \`file:\` dep on extensions — they load by
1093
+ URL at runtime, so no per-site package.json updates.)
1094
+
1095
+ Transactional — bails on conflict (target name taken, target not found,
1096
+ folder collision, type mismatch) before any filesystem mutation.
1066
1097
 
1067
- Today supports renaming foundations only. Updates folder name, foundation
1068
- package.json::name, every dependent site's site.yml::foundation, every
1069
- dependent site's package.json::dependencies, pnpm-workspace.yaml, and
1070
- package.json::workspaces. Transactional bails on conflict before any
1071
- filesystem mutation.
1098
+ Type guards: \`rename foundation\` against an extension errors and
1099
+ points at \`rename extension\` (and vice versa). They share a build
1100
+ shape but the touch-point sets differ; using the wrong subcommand
1101
+ would update the wrong things.
1072
1102
  `,
1073
1103
  login: `
1074
1104
  ${colors.cyan}${colors.bright}uniweb login${colors.reset} ${colors.dim}— Log in to your Uniweb account${colors.reset}
@@ -1195,12 +1225,12 @@ ${colors.bright}Usage:${colors.reset}
1195
1225
  ${colors.bright}Commands:${colors.reset}
1196
1226
  create [name] Create a new project
1197
1227
  add <type> [name] Add a foundation, site, or extension to a project
1198
- rename <type> Rename a workspace package (foundation today)
1228
+ rename <type> Rename a foundation, site, or extension across the workspace
1199
1229
  dev Start a dev server for a site
1200
1230
  build Build the current project
1201
1231
  deploy Deploy a site to Uniweb hosting
1202
1232
  export Export a self-contained site for third-party hosting
1203
- publish Publish a foundation to the Uniweb catalog (deliberate; for site-bound foundations, use deploy)
1233
+ publish Publish a foundation to the Uniweb registry
1204
1234
  invite <email> Create a foundation invite for a client
1205
1235
  handoff <email> Hand off a site to a client
1206
1236
  inspect <path> Inspect parsed content shape of a markdown file or folder
@@ -1324,15 +1354,15 @@ ${colors.bright}Examples:${colors.reset}
1324
1354
  uniweb create my-project --template ./my-template # Local template
1325
1355
 
1326
1356
  cd my-project
1327
- uniweb add project docs # Add docs/foundation/ + docs/site/
1357
+ uniweb add project docs # Add docs/src/ + docs/site/
1328
1358
  uniweb add project docs --from academic # Co-located pair + academic content
1329
- uniweb add foundation # Add foundation at root
1330
- uniweb add site blog --foundation marketing # Add site wired to marketing
1331
- uniweb add extension effects --site site # Add extensions/effects/
1359
+ uniweb add marketing # Add marketing/ at root
1360
+ uniweb add site blog --foundation marketing # Add site/ wired to marketing
1361
+ uniweb add extension effects --site site # Add effects/ at root
1332
1362
 
1333
1363
  uniweb build
1334
- uniweb build --target foundation
1335
- cd foundation && uniweb docs # Generate COMPONENTS.md
1364
+ uniweb build --target src # Build src/ package
1365
+ cd src && uniweb docs # Generate COMPONENTS.md
1336
1366
 
1337
1367
  ${colors.bright}Install:${colors.reset}
1338
1368
  npm i -g uniweb Global install (recommended)
@@ -101,6 +101,47 @@ export function maybeNotifyFromCache(currentVersion, tone = 'eager') {
101
101
  // and gets the eager default. Keeps that call site unchanged.
102
102
  export const maybeEagerNotification = maybeNotifyFromCache
103
103
 
104
+ /**
105
+ * Fetch the latest version (with a tight timeout) and print a notice if
106
+ * a newer version is found. Updates the on-disk cache as a side effect
107
+ * so future cache-only callers benefit too.
108
+ *
109
+ * Use this for TTY invocations of `--version` / `-v` where the user is
110
+ * interactively asking about the version and a brief network wait is
111
+ * acceptable. Don't use it for non-TTY callers — scripts capturing
112
+ * stdout (`version=$(uniweb -v)`) need a fast, offline-safe path.
113
+ *
114
+ * @param {string} currentVersion
115
+ * @param {object} [opts]
116
+ * @param {number} [opts.timeoutMs=1500] Network timeout. Slow / offline
117
+ * calls return silently — never block the verb for long.
118
+ * @param {'eager'|'soft'} [opts.tone='soft'] Notification copy.
119
+ * @returns {Promise<boolean>} true if a notice was printed.
120
+ */
121
+ export async function fetchAndNotifyIfNewer(currentVersion, { timeoutMs = 1500, tone = 'soft' } = {}) {
122
+ const controller = new AbortController()
123
+ const timer = setTimeout(() => controller.abort(), timeoutMs)
124
+ let latest = null
125
+ try {
126
+ const res = await fetch('https://registry.npmjs.org/uniweb/latest', { signal: controller.signal })
127
+ if (res.ok) {
128
+ const data = await res.json()
129
+ latest = data?.version || null
130
+ }
131
+ } catch {
132
+ // Aborted, network error, parse error — all silent. The verb
133
+ // shouldn't block on update-check failures.
134
+ } finally {
135
+ clearTimeout(timer)
136
+ }
137
+ if (!latest) return false
138
+ // Refresh the cache so other code paths see this fresh result.
139
+ writeState({ lastCheck: Date.now(), latestVersion: latest })
140
+ if (compareSemver(latest, currentVersion) <= 0) return false
141
+ printNotification(currentVersion, latest, tone)
142
+ return true
143
+ }
144
+
104
145
  /**
105
146
  * Start a non-blocking update check.
106
147
  *