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 +1 -1
- package/package.json +3 -3
- package/partials/agents.md +1 -2
- package/src/commands/rename.js +313 -95
- package/src/index.js +47 -17
- package/src/utils/update-check.js +41 -0
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.
|
|
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",
|
package/partials/agents.md
CHANGED
|
@@ -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
|
|
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
|
package/src/commands/rename.js
CHANGED
|
@@ -1,35 +1,50 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Rename Command
|
|
3
3
|
*
|
|
4
|
-
* Renames packages across the workspace transactionally.
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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,
|
|
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 (
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
99
|
-
// ─── Pre-flight ───────────────────────────────────────────────
|
|
121
|
+
// ─── Common helpers ──────────────────────────────────────────────
|
|
100
122
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
-
//
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
|
219
|
+
const oldFoundationPath = target.path
|
|
131
220
|
const oldFoundationDir = join(rootDir, oldFoundationPath)
|
|
132
|
-
const
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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}${
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
453
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
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
|
|
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
|
|
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/
|
|
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
|
|
1330
|
-
uniweb add site blog --foundation marketing # Add site wired to marketing
|
|
1331
|
-
uniweb add extension effects --site site # Add
|
|
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
|
|
1335
|
-
cd
|
|
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
|
*
|