uniweb 0.12.11 → 0.12.13

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.11",
3
+ "version": "0.12.13",
4
4
  "description": "Create structured Vite + React sites with content/code separation",
5
5
  "type": "module",
6
6
  "bin": {
@@ -41,14 +41,14 @@
41
41
  "js-yaml": "^4.1.0",
42
42
  "prompts": "^2.4.2",
43
43
  "tar": "^7.0.0",
44
- "@uniweb/runtime": "0.8.13",
45
44
  "@uniweb/core": "0.7.11",
46
- "@uniweb/kit": "0.9.11"
45
+ "@uniweb/kit": "0.9.11",
46
+ "@uniweb/runtime": "0.8.13"
47
47
  },
48
48
  "peerDependencies": {
49
49
  "@uniweb/build": "0.14.2",
50
- "@uniweb/semantic-parser": "1.1.17",
51
- "@uniweb/content-reader": "1.1.10"
50
+ "@uniweb/content-reader": "1.1.10",
51
+ "@uniweb/semantic-parser": "1.1.17"
52
52
  },
53
53
  "peerDependenciesMeta": {
54
54
  "@uniweb/build": {
@@ -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
  }
@@ -1,16 +1,47 @@
1
1
  /**
2
- * uniweb update - Update generated project files
2
+ * uniweb update Update the CLI itself, and (in a Uniweb project) the
3
+ * project's AGENTS.md.
3
4
  *
4
- * Regenerates AGENTS.md from the installed CLI version.
5
+ * Two responsibilities, in priority order:
6
+ *
7
+ * 1. **Self-update the global install.** Most users running `uniweb update`
8
+ * expect the verb to update the CLI binary itself (this is what `npm
9
+ * update -g`, `gh update`, `claude update`, etc. all do). The CLI
10
+ * detects the relevant package manager and runs the global-install
11
+ * command for it (`npm i -g uniweb@latest`, `pnpm add -g uniweb@latest`,
12
+ * `yarn global add uniweb@latest`). In TTY, prompts before executing.
13
+ * In non-interactive mode, prints the command and exits — never runs an
14
+ * unconfirmed self-update from a script.
15
+ *
16
+ * 2. **Refresh AGENTS.md** (only when the cwd resolves to a *Uniweb*
17
+ * project — checked via `package.json::devDependencies::uniweb` or
18
+ * `dependencies::uniweb` at the workspace root). The previous
19
+ * implementation walked up looking for ANY pnpm-workspace.yaml or
20
+ * `package.json::workspaces` root, which falsely identified unrelated
21
+ * monorepos as Uniweb projects and wrote AGENTS.md into them.
22
+ *
23
+ * Flags:
24
+ * --agents-only Skip self-update; only refresh AGENTS.md.
25
+ * --no-agents Skip AGENTS.md; only self-update.
26
+ * --yes Skip the confirmation prompt before self-update.
27
+ * --non-interactive Auto-detected; never runs unconfirmed self-update.
28
+ *
29
+ * Project-local case (CLI lives in node_modules, not global): self-update
30
+ * isn't possible — that's a project decision (bump the dep in
31
+ * package.json). The verb prints that explanation and proceeds with the
32
+ * AGENTS.md refresh path only.
5
33
  */
6
34
 
7
- import { existsSync, writeFileSync } from 'node:fs'
8
- import { join, resolve } from 'node:path'
35
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs'
36
+ import { join } from 'node:path'
37
+ import { spawn } from 'node:child_process'
38
+ import prompts from 'prompts'
39
+
9
40
  import { findWorkspaceRoot } from '../utils/workspace.js'
10
41
  import { readAgentsVersion, generateAgentsContent } from '../utils/agents-stamp.js'
11
42
  import { getCliVersion } from '../versions.js'
43
+ import { isNonInteractive } from '../utils/interactive.js'
12
44
 
13
- // ANSI colors
14
45
  const colors = {
15
46
  reset: '\x1b[0m',
16
47
  bright: '\x1b[1m',
@@ -18,7 +49,7 @@ const colors = {
18
49
  red: '\x1b[31m',
19
50
  green: '\x1b[32m',
20
51
  yellow: '\x1b[33m',
21
- blue: '\x1b[36m'
52
+ cyan: '\x1b[36m',
22
53
  }
23
54
 
24
55
  const success = (msg) => console.log(`${colors.green}✓${colors.reset} ${msg}`)
@@ -26,30 +57,196 @@ const warn = (msg) => console.log(`${colors.yellow}⚠${colors.reset} ${msg}`)
26
57
  const error = (msg) => console.log(`${colors.red}✗${colors.reset} ${msg}`)
27
58
  const log = console.log
28
59
 
29
- export async function update(args = []) {
30
- const workspaceDir = findWorkspaceRoot(process.cwd())
60
+ /**
61
+ * Detect whether this CLI is running from a global install. Mirrors the
62
+ * logic in index.js::isGlobalInstall — when global, process.argv[1]
63
+ * points outside any node_modules.
64
+ */
65
+ function isGlobalInstall() {
66
+ const scriptPath = process.argv[1]
67
+ if (!scriptPath) return false
68
+ return !scriptPath.split('/').includes('node_modules') &&
69
+ !scriptPath.split('\\').includes('node_modules')
70
+ }
31
71
 
32
- if (!workspaceDir) {
33
- error('Not in a Uniweb workspace')
34
- log(`${colors.dim}Run this command from your project root or a package directory.${colors.reset}`)
35
- process.exit(1)
72
+ /**
73
+ * Find a *Uniweb* workspace root from cwd. Stricter than findWorkspaceRoot
74
+ * also requires that the workspace's root package.json declares uniweb
75
+ * as a dep or devDep. Otherwise the previous behavior (walking up to any
76
+ * pnpm-workspace.yaml) writes AGENTS.md into unrelated monorepos.
77
+ */
78
+ function findUniwebWorkspace(cwd) {
79
+ const workspaceDir = findWorkspaceRoot(cwd)
80
+ if (!workspaceDir) return null
81
+ const pkgPath = join(workspaceDir, 'package.json')
82
+ if (!existsSync(pkgPath)) return null
83
+ try {
84
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'))
85
+ const hasUniwebDep = !!(pkg.devDependencies?.uniweb || pkg.dependencies?.uniweb)
86
+ return hasUniwebDep ? workspaceDir : null
87
+ } catch {
88
+ return null
36
89
  }
90
+ }
37
91
 
92
+ /**
93
+ * Detect the package manager that owns the global install. Heuristic
94
+ * based on the CLI's filesystem path — pnpm and yarn berry use distinctive
95
+ * directory layouts; npm is the fallback.
96
+ *
97
+ * @returns {'pnpm'|'yarn'|'npm'}
98
+ */
99
+ function detectGlobalPm() {
100
+ const path = (process.argv[1] || '').toLowerCase()
101
+ if (path.includes('/pnpm/') || path.includes('\\pnpm\\')) return 'pnpm'
102
+ if (path.includes('/yarn/') || path.includes('\\yarn\\')) return 'yarn'
103
+ return 'npm'
104
+ }
105
+
106
+ /**
107
+ * Build the global-install command for a given PM.
108
+ */
109
+ function globalInstallCmd(pm) {
110
+ if (pm === 'pnpm') return 'pnpm add -g uniweb@latest'
111
+ if (pm === 'yarn') return 'yarn global add uniweb@latest'
112
+ return 'npm i -g uniweb@latest'
113
+ }
114
+
115
+ /**
116
+ * Fetch the latest published version. Returns null on network error.
117
+ */
118
+ async function fetchLatestVersion() {
119
+ try {
120
+ const res = await fetch('https://registry.npmjs.org/uniweb/latest')
121
+ if (!res.ok) return null
122
+ const data = await res.json()
123
+ return data?.version || null
124
+ } catch {
125
+ return null
126
+ }
127
+ }
128
+
129
+ /**
130
+ * Compare two semver strings: 1 if a>b, -1 if a<b, 0 if equal.
131
+ */
132
+ function compareSemver(a, b) {
133
+ const pa = a.split('.').map(Number)
134
+ const pb = b.split('.').map(Number)
135
+ for (let i = 0; i < 3; i++) {
136
+ if ((pa[i] || 0) > (pb[i] || 0)) return 1
137
+ if ((pa[i] || 0) < (pb[i] || 0)) return -1
138
+ }
139
+ return 0
140
+ }
141
+
142
+ /**
143
+ * Run a shell command, inheriting stdio. Resolves with the exit code.
144
+ */
145
+ function runCommand(cmd) {
146
+ return new Promise((resolve) => {
147
+ const [bin, ...rest] = cmd.split(' ')
148
+ const child = spawn(bin, rest, { stdio: 'inherit' })
149
+ child.on('close', code => resolve(code ?? 0))
150
+ child.on('error', () => resolve(1))
151
+ })
152
+ }
153
+
154
+ export async function update(args = []) {
155
+ const agentsOnly = args.includes('--agents-only')
156
+ const skipAgents = args.includes('--no-agents')
157
+ const skipPrompt = args.includes('--yes') || isNonInteractive(args)
158
+ const isGlobal = isGlobalInstall()
159
+ const workspaceDir = findUniwebWorkspace(process.cwd())
160
+ const inProject = !!workspaceDir
38
161
  const cliVersion = getCliVersion()
39
- const agentsPath = join(workspaceDir, 'AGENTS.md')
40
- const currentVersion = readAgentsVersion(agentsPath)
41
162
 
42
- if (currentVersion === cliVersion) {
43
- success(`AGENTS.md is already up to date (v${cliVersion})`)
163
+ // ─── Step 1: Self-update path ─────────────────────────────────
164
+ if (!agentsOnly) {
165
+ if (!isGlobal) {
166
+ // Project-local: can't self-update meaningfully.
167
+ log(`${colors.dim}Running the project-local CLI (v${cliVersion}). This copy is pinned by your${colors.reset}`)
168
+ log(`${colors.dim}project's package.json. To update it, bump${colors.reset} ${colors.cyan}uniweb${colors.reset}${colors.dim} in${colors.reset} ${colors.cyan}package.json${colors.reset}${colors.dim} and re-install.${colors.reset}`)
169
+ log('')
170
+ } else {
171
+ const latest = await fetchLatestVersion()
172
+ if (latest === null) {
173
+ warn('Could not reach the npm registry to check for updates.')
174
+ log(`${colors.dim}Current: ${cliVersion}. Try later, or run${colors.reset} ${colors.cyan}${globalInstallCmd(detectGlobalPm())}${colors.reset}${colors.dim} manually.${colors.reset}`)
175
+ log('')
176
+ } else if (compareSemver(latest, cliVersion) <= 0) {
177
+ success(`uniweb is up to date (v${cliVersion}).`)
178
+ log('')
179
+ } else {
180
+ const pm = detectGlobalPm()
181
+ const cmd = globalInstallCmd(pm)
182
+ log(`${colors.yellow}Update available:${colors.reset} ${colors.dim}${cliVersion}${colors.reset} → ${colors.cyan}${latest}${colors.reset}`)
183
+ log(`${colors.dim}Detected package manager:${colors.reset} ${pm}`)
184
+ log(`${colors.dim}Will run:${colors.reset} ${colors.cyan}${cmd}${colors.reset}`)
185
+ log('')
186
+
187
+ if (skipPrompt) {
188
+ log(`${colors.dim}Non-interactive — skipping self-update. Run the command above to update.${colors.reset}`)
189
+ log('')
190
+ } else {
191
+ const { go } = await prompts({
192
+ type: 'confirm',
193
+ name: 'go',
194
+ message: `Run \`${cmd}\` now?`,
195
+ initial: true,
196
+ })
197
+ if (go) {
198
+ const code = await runCommand(cmd)
199
+ if (code === 0) {
200
+ success(`Self-update complete.`)
201
+ } else {
202
+ error(`Self-update failed (exit ${code}). Run the command above manually if needed.`)
203
+ }
204
+ log('')
205
+ } else {
206
+ log(`${colors.dim}Skipped self-update.${colors.reset}`)
207
+ log('')
208
+ }
209
+ }
210
+ }
211
+ }
212
+ }
213
+
214
+ // ─── Step 2: AGENTS.md refresh path ───────────────────────────
215
+ if (skipAgents) return
216
+ if (!inProject) {
217
+ if (agentsOnly) {
218
+ error('Not in a Uniweb project (no `uniweb` dep in the workspace root package.json).')
219
+ log(`${colors.dim}Run this command from inside a project created by${colors.reset} ${colors.cyan}uniweb create${colors.reset}${colors.dim}.${colors.reset}`)
220
+ process.exit(1)
221
+ }
222
+ // Self-update-only path. Quietly skip AGENTS.md.
44
223
  return
45
224
  }
46
225
 
226
+ const agentsPath = join(workspaceDir, 'AGENTS.md')
227
+ const currentAgentsVersion = readAgentsVersion(agentsPath)
228
+ if (currentAgentsVersion === cliVersion) {
229
+ success(`AGENTS.md is already up to date (v${cliVersion}).`)
230
+ return
231
+ }
232
+
233
+ // Prompt before writing in TTY, unless --yes / non-interactive (in which
234
+ // case we err on the side of doing the right thing — refresh — since the
235
+ // user explicitly invoked `uniweb update` from a Uniweb project).
236
+ if (!skipPrompt && !agentsOnly) {
237
+ const action = currentAgentsVersion ? `Update AGENTS.md (v${currentAgentsVersion} → v${cliVersion})?` : `Create AGENTS.md (v${cliVersion})?`
238
+ const { yes } = await prompts({ type: 'confirm', name: 'yes', message: action, initial: true })
239
+ if (!yes) {
240
+ log(`${colors.dim}Skipped AGENTS.md.${colors.reset}`)
241
+ return
242
+ }
243
+ }
244
+
47
245
  const content = generateAgentsContent()
48
246
  writeFileSync(agentsPath, content)
49
-
50
- if (currentVersion) {
51
- success(`Updated AGENTS.md (v${currentVersion} → v${cliVersion})`)
247
+ if (currentAgentsVersion) {
248
+ success(`Updated AGENTS.md (v${currentAgentsVersion} → v${cliVersion}).`)
52
249
  } else {
53
- success(`Created AGENTS.md (v${cliVersion})`)
250
+ success(`Created AGENTS.md (v${cliVersion}).`)
54
251
  }
55
252
  }
package/src/index.js CHANGED
@@ -103,8 +103,12 @@ function getCliVersion() {
103
103
  /**
104
104
  * Commands that always run from the global CLI (no project context needed)
105
105
  */
106
+ // Commands that always run from the global CLI, never delegating to a
107
+ // project-local copy. `update` is here because its primary job is to
108
+ // self-update the GLOBAL install — delegating it to project-local would
109
+ // short-circuit that intent.
106
110
  const STANDALONE_COMMANDS = new Set([
107
- 'create', '--help', '-h', '--version', '-v', 'login',
111
+ 'create', '--help', '-h', '--version', '-v', 'login', 'update',
108
112
  ])
109
113
 
110
114
  /**
@@ -441,8 +445,20 @@ async function main() {
441
445
  const pm = detectPackageManager()
442
446
 
443
447
  // Handle --version / -v
448
+ //
449
+ // Output convention: the version goes to stdout (parseable, scriptable —
450
+ // `version=$(uniweb --version)` should keep working). Any staleness
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.
444
454
  if (command === '--version' || command === '-v') {
445
455
  console.log(`uniweb ${getCliVersion()}`)
456
+ if (isGlobalInstall()) {
457
+ try {
458
+ const { maybeNotifyFromCache } = await import('./utils/update-check.js')
459
+ maybeNotifyFromCache(getCliVersion(), 'soft')
460
+ } catch { /* ignore */ }
461
+ }
446
462
  return
447
463
  }
448
464
 
@@ -1047,12 +1063,30 @@ ${colors.cyan}${colors.bright}uniweb rename${colors.reset} ${colors.dim}— Rena
1047
1063
 
1048
1064
  ${colors.bright}Usage:${colors.reset}
1049
1065
  uniweb rename foundation <old> <new>
1066
+ uniweb rename site <old> <new>
1067
+ uniweb rename extension <old> <new>
1068
+
1069
+ Each subcommand updates a different set of touch points:
1070
+
1071
+ foundation: package.json::name + folder + every dependent site's
1072
+ package.json (dep key + file: path) + every site.yml::foundation +
1073
+ pnpm-workspace.yaml / package.json::workspaces + root scripts.
1050
1074
 
1051
- Today supports renaming foundations only. Updates folder name, foundation
1052
- package.json::name, every dependent site's site.yml::foundation, every
1053
- dependent site's package.json::dependencies, pnpm-workspace.yaml, and
1054
- package.json::workspaces. Transactional bails on conflict before any
1055
- filesystem mutation.
1075
+ site: package.json::name + folder + workspace manifests + root
1076
+ scripts (\`dev\` / \`preview\` are filtered by site name).
1077
+
1078
+ extension: package.json::name + folder + every site.yml::extensions
1079
+ URL whose path matches the old folder + workspace manifests.
1080
+ (Sites don't carry a \`file:\` dep on extensions — they load by
1081
+ URL at runtime, so no per-site package.json updates.)
1082
+
1083
+ Transactional — bails on conflict (target name taken, target not found,
1084
+ folder collision, type mismatch) before any filesystem mutation.
1085
+
1086
+ Type guards: \`rename foundation\` against an extension errors and
1087
+ points at \`rename extension\` (and vice versa). They share a build
1088
+ shape but the touch-point sets differ; using the wrong subcommand
1089
+ would update the wrong things.
1056
1090
  `,
1057
1091
  login: `
1058
1092
  ${colors.cyan}${colors.bright}uniweb login${colors.reset} ${colors.dim}— Log in to your Uniweb account${colors.reset}
@@ -1139,14 +1173,28 @@ Prints the parsed content shape of a markdown file or folder — the
1139
1173
  Useful for debugging "why isn't my section getting X?".
1140
1174
  `,
1141
1175
  update: `
1142
- ${colors.cyan}${colors.bright}uniweb update${colors.reset} ${colors.dim}— Update AGENTS.md to match installed CLI version${colors.reset}
1176
+ ${colors.cyan}${colors.bright}uniweb update${colors.reset} ${colors.dim}— Update the CLI itself, plus AGENTS.md when in a project${colors.reset}
1143
1177
 
1144
1178
  ${colors.bright}Usage:${colors.reset}
1145
- uniweb update
1146
-
1147
- Refreshes the project's AGENTS.md from the CLI's bundled version. Run
1148
- after upgrading the \`uniweb\` package to pick up new content authoring
1149
- patterns and platform documentation.
1179
+ uniweb update Self-update + (in project) refresh AGENTS.md
1180
+ uniweb update --agents-only Only refresh AGENTS.md (skip self-update)
1181
+ uniweb update --no-agents Only self-update (skip AGENTS.md)
1182
+ uniweb update --yes Skip the confirmation prompts
1183
+
1184
+ ${colors.bright}What it does:${colors.reset}
1185
+ 1. Self-update the global install via npm / pnpm / yarn (auto-detected).
1186
+ In TTY, prompts before running. In CI / non-interactive, prints the
1187
+ command and exits without running it.
1188
+ 2. If the cwd resolves to a Uniweb project (root package.json declares
1189
+ \`uniweb\` as a dep), refreshes AGENTS.md from the CLI's bundled
1190
+ version. Outside a Uniweb project, this step is skipped — the
1191
+ command will not write AGENTS.md into unrelated directories.
1192
+
1193
+ ${colors.bright}Project-local installs:${colors.reset}
1194
+ When the running CLI is project-local (lives in node_modules), self-
1195
+ update is a no-op — the version is pinned by your project's
1196
+ package.json. The verb prints that explanation and proceeds with the
1197
+ AGENTS.md refresh path only.
1150
1198
  `,
1151
1199
  }
1152
1200
 
@@ -1165,12 +1213,12 @@ ${colors.bright}Usage:${colors.reset}
1165
1213
  ${colors.bright}Commands:${colors.reset}
1166
1214
  create [name] Create a new project
1167
1215
  add <type> [name] Add a foundation, site, or extension to a project
1168
- rename <type> Rename a workspace package (foundation today)
1216
+ rename <type> Rename a foundation, site, or extension across the workspace
1169
1217
  dev Start a dev server for a site
1170
1218
  build Build the current project
1171
1219
  deploy Deploy a site to Uniweb hosting
1172
1220
  export Export a self-contained site for third-party hosting
1173
- publish Publish a foundation to the Uniweb catalog (deliberate; for site-bound foundations, use deploy)
1221
+ publish Publish a foundation to the Uniweb registry
1174
1222
  invite <email> Create a foundation invite for a client
1175
1223
  handoff <email> Hand off a site to a client
1176
1224
  inspect <path> Inspect parsed content shape of a markdown file or folder
@@ -1294,15 +1342,15 @@ ${colors.bright}Examples:${colors.reset}
1294
1342
  uniweb create my-project --template ./my-template # Local template
1295
1343
 
1296
1344
  cd my-project
1297
- uniweb add project docs # Add docs/foundation/ + docs/site/
1345
+ uniweb add project docs # Add docs/src/ + docs/site/
1298
1346
  uniweb add project docs --from academic # Co-located pair + academic content
1299
- uniweb add foundation # Add foundation at root
1300
- uniweb add site blog --foundation marketing # Add site wired to marketing
1301
- uniweb add extension effects --site site # Add extensions/effects/
1347
+ uniweb add marketing # Add marketing/ at root
1348
+ uniweb add site blog --foundation marketing # Add site/ wired to marketing
1349
+ uniweb add extension effects --site site # Add effects/ at root
1302
1350
 
1303
1351
  uniweb build
1304
- uniweb build --target foundation
1305
- cd foundation && uniweb docs # Generate COMPONENTS.md
1352
+ uniweb build --target src # Build src/ package
1353
+ cd src && uniweb docs # Generate COMPONENTS.md
1306
1354
 
1307
1355
  ${colors.bright}Install:${colors.reset}
1308
1356
  npm i -g uniweb Global install (recommended)
@@ -74,26 +74,33 @@ function printNotification(current, latest, tone = 'soft') {
74
74
  }
75
75
 
76
76
  /**
77
- * Synchronously read the cache and print an eager notification if a newer
77
+ * Synchronously read the cache and print a notification if a newer
78
78
  * version is known. No network fetch — only reads what `startUpdateCheck`
79
79
  * has previously cached. Returns true if a notification was printed.
80
80
  *
81
- * Use this for staleness-sensitive verbs (`create`) BEFORE the verb does
82
- * its work, so the user sees the warning before any files are written
83
- * from CLI-bundled templates. For other verbs, the trailing soft
84
- * notification from startUpdateCheck() is sufficient.
81
+ * Two call sites today, with different tone needs:
82
+ * - `create` (tone='eager'): loud leading notice templates ship with
83
+ * the CLI, the user is about to scaffold files, this matters.
84
+ * - `--version` / `-v` (tone='soft'): brief trailing notice — the user
85
+ * was already asking about version, mention staleness while we're
86
+ * here. Goes to stderr so scripts capturing stdout aren't affected.
85
87
  *
86
88
  * @param {string} currentVersion
89
+ * @param {'eager'|'soft'} [tone='eager']
87
90
  * @returns {boolean} true if a notification was printed
88
91
  */
89
- export function maybeEagerNotification(currentVersion) {
92
+ export function maybeNotifyFromCache(currentVersion, tone = 'eager') {
90
93
  const state = readState()
91
94
  if (!state.latestVersion) return false
92
95
  if (compareSemver(state.latestVersion, currentVersion) <= 0) return false
93
- printNotification(currentVersion, state.latestVersion, 'eager')
96
+ printNotification(currentVersion, state.latestVersion, tone)
94
97
  return true
95
98
  }
96
99
 
100
+ // Old name preserved as alias — `create` calls it without a tone arg
101
+ // and gets the eager default. Keeps that call site unchanged.
102
+ export const maybeEagerNotification = maybeNotifyFromCache
103
+
97
104
  /**
98
105
  * Start a non-blocking update check.
99
106
  *