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 +1 -1
- package/package.json +5 -5
- package/partials/agents.md +1 -2
- package/src/commands/rename.js +313 -95
- package/src/commands/update.js +217 -20
- package/src/index.js +68 -20
- package/src/utils/update-check.js +14 -7
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.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/
|
|
51
|
-
"@uniweb/
|
|
50
|
+
"@uniweb/content-reader": "1.1.10",
|
|
51
|
+
"@uniweb/semantic-parser": "1.1.17"
|
|
52
52
|
},
|
|
53
53
|
"peerDependenciesMeta": {
|
|
54
54
|
"@uniweb/build": {
|
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/commands/update.js
CHANGED
|
@@ -1,16 +1,47 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* uniweb update
|
|
2
|
+
* uniweb update — Update the CLI itself, and (in a Uniweb project) the
|
|
3
|
+
* project's AGENTS.md.
|
|
3
4
|
*
|
|
4
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
30
|
-
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
43
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
package.json::
|
|
1055
|
-
|
|
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
|
|
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
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
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
|
|
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
|
|
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/
|
|
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
|
|
1300
|
-
uniweb add site blog --foundation marketing # Add site wired to marketing
|
|
1301
|
-
uniweb add extension effects --site site # Add
|
|
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
|
|
1305
|
-
cd
|
|
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
|
|
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
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
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
|
|
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,
|
|
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
|
*
|