uniweb 0.12.20 → 0.12.21

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
@@ -351,7 +351,7 @@ A future version will let markdown in a git repo and content in the Uniweb apps
351
351
  | `uniweb export` | Produce a self-contained `dist/` for any static host. You upload it yourself. `--host=<adapter>` adds host-specific helper files. |
352
352
  | `uniweb publish @org/name` | Publish a foundation to the registry (path 2). |
353
353
  | `uniweb build` | Inspect a build locally. For shipping, use `deploy` or `export`. |
354
- | `uniweb update` | Reconcile workspace state with the CLI: self-update the global install, align `@uniweb/*` deps in every `package.json` to the CLI's matrix, refresh `AGENTS.md`. Refuses to outpace declared deps with a stale doc. |
354
+ | `uniweb update` | Align this project with the CLI you're running: bump `@uniweb/*` deps in every `package.json` to the CLI's matrix (then install), and refresh `AGENTS.md`. Pins to *this* CLI's matrix run `npx uniweb@latest update` to align to the latest release. Updating the CLI itself is your package manager's job (`npm i -g uniweb@latest`). |
355
355
 
356
356
  `--host=<adapter>` is the same option across `deploy`, `export`, and `add ci`. Built-in adapters: `cloudflare-pages`, `netlify`, `github-pages`, `vercel`, `s3-cloudfront`, `generic-static`. Each adapter implements only the operations it supports — `add ci` is currently `github-pages`-only because it's the only one that needs a workflow file in the repo.
357
357
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "uniweb",
3
- "version": "0.12.20",
3
+ "version": "0.12.21",
4
4
  "description": "Create structured Vite + React sites with content/code separation",
5
5
  "type": "module",
6
6
  "bin": {
@@ -41,8 +41,8 @@
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.14",
45
+ "@uniweb/core": "0.7.11",
46
46
  "@uniweb/kit": "0.9.13"
47
47
  },
48
48
  "peerDependencies": {
@@ -10,6 +10,8 @@ import { loadDeployYml } from '@uniweb/build/site'
10
10
  import { listAdapters } from '@uniweb/build/hosts'
11
11
  import { getCliVersion } from '../versions.js'
12
12
  import { readAgentsVersion } from '../utils/agents-stamp.js'
13
+ import { writeJsonPreservingStyle } from '../utils/json-file.js'
14
+ import { surveyWorkspaceDeps } from '../utils/dep-survey.js'
13
15
  import { discoverFoundations, discoverSites } from '../utils/discover.js'
14
16
  import { findWorkspaceRoot } from '../utils/workspace.js'
15
17
 
@@ -204,9 +206,10 @@ export async function doctor(args = []) {
204
206
  const union = Array.from(new Set([...ymlPackages, ...pkgWorkspaces])).sort()
205
207
  writeFileSync(ymlPath, yaml.dump({ packages: union }, { flowLevel: -1, quotingType: '"' }))
206
208
  const rootPkgPath = join(workspaceDir, 'package.json')
207
- const rootPkg = JSON.parse(readFileSync(rootPkgPath, 'utf-8'))
209
+ const rootPkgSrc = readFileSync(rootPkgPath, 'utf-8')
210
+ const rootPkg = JSON.parse(rootPkgSrc)
208
211
  rootPkg.workspaces = union
209
- writeFileSync(rootPkgPath, JSON.stringify(rootPkg, null, 2) + '\n')
212
+ writeJsonPreservingStyle(rootPkgPath, rootPkg, rootPkgSrc)
210
213
  issue.fixed = true
211
214
  fixed(`wrote union [${union.join(', ')}] to both manifests`)
212
215
  } else {
@@ -364,7 +367,7 @@ export async function doctor(args = []) {
364
367
  const sitePkgPath = join(sitePath, 'package.json')
365
368
  const updatedPkg = { ...sitePkg }
366
369
  updatedPkg.dependencies = { ...(updatedPkg.dependencies || {}), [foundationName]: expectedPath }
367
- writeFileSync(sitePkgPath, JSON.stringify(updatedPkg, null, 2) + '\n')
370
+ writeJsonPreservingStyle(sitePkgPath, updatedPkg)
368
371
  issue.fixed = true
369
372
  fixed(`added "${foundationName}": "${expectedPath}" to ${relative(workspaceDir, sitePkgPath)}`)
370
373
  } else {
@@ -391,7 +394,7 @@ export async function doctor(args = []) {
391
394
  const sitePkgPath = join(sitePath, 'package.json')
392
395
  const updatedPkg = { ...sitePkg }
393
396
  updatedPkg.dependencies = { ...(updatedPkg.dependencies || {}), [foundationName]: expectedPath }
394
- writeFileSync(sitePkgPath, JSON.stringify(updatedPkg, null, 2) + '\n')
397
+ writeJsonPreservingStyle(sitePkgPath, updatedPkg)
395
398
  issue.fixed = true
396
399
  fixed(`updated "${foundationName}" to "${expectedPath}" in ${relative(workspaceDir, sitePkgPath)}`)
397
400
  } else {
@@ -672,11 +675,27 @@ export async function doctor(args = []) {
672
675
  }
673
676
  }
674
677
 
678
+ const cliVersion = getCliVersion()
679
+
680
+ // Check @uniweb/* dep alignment with the running CLI
681
+ log('')
682
+ const depSurvey = await surveyWorkspaceDeps(workspaceDir)
683
+ const behindDeps = depSurvey.rows.filter(r => r.status === 'behind')
684
+ if (behindDeps.length > 0) {
685
+ const names = [...new Set(behindDeps.map(r => r.name))].sort()
686
+ warn(`${behindDeps.length} workspace dep declaration${behindDeps.length === 1 ? '' : 's'} lag the CLI (v${cliVersion}): ${names.join(', ')}`)
687
+ info(`Run: uniweb update`)
688
+ issues.push({ type: 'warn', message: `${behindDeps.length} @uniweb/* dep declaration(s) behind CLI v${cliVersion}` })
689
+ } else if (depSurvey.anyAhead) {
690
+ success(`@uniweb/* deps are aligned or ahead of the CLI (v${cliVersion})`)
691
+ } else {
692
+ success(`@uniweb/* deps are aligned with the CLI (v${cliVersion})`)
693
+ }
694
+
675
695
  // Check AGENTS.md freshness
676
696
  log('')
677
697
  const agentsPath = join(workspaceDir, 'AGENTS.md')
678
698
  const agentsVersion = readAgentsVersion(agentsPath)
679
- const cliVersion = getCliVersion()
680
699
 
681
700
  if (!existsSync(agentsPath)) {
682
701
  warn('AGENTS.md not found')
@@ -34,7 +34,7 @@
34
34
  */
35
35
 
36
36
  import { existsSync } from 'node:fs'
37
- import { readFile, writeFile, mkdir } from 'node:fs/promises'
37
+ import { readFile, mkdir } from 'node:fs/promises'
38
38
  import { resolve, join } from 'node:path'
39
39
  import { execSync } from 'node:child_process'
40
40
 
@@ -42,6 +42,7 @@ import { resolveFoundationSrcPath, classifyPackage } from '@uniweb/build'
42
42
  import { createLocalRegistry, RemoteRegistry } from '../utils/registry.js'
43
43
  import { ensureAuth, readAuth, writeAuth, decodeJwtPayload } from '../utils/auth.js'
44
44
  import { getRegistryUrl, getBackendUrl } from '../utils/config.js'
45
+ import { writeJsonPreservingStyleAsync } from '../utils/json-file.js'
45
46
  import { findWorkspaceRoot, findFoundations, findSites, promptSelect } from '../utils/workspace.js'
46
47
  import { isNonInteractive, getCliPrefix } from '../utils/interactive.js'
47
48
 
@@ -681,7 +682,7 @@ export async function publish(args = []) {
681
682
  if (writeBackId) {
682
683
  pkg.uniweb = pkg.uniweb || {}
683
684
  pkg.uniweb.id = foundationName
684
- await writeFile(pkgPath, JSON.stringify(pkg, null, 2) + '\n')
685
+ await writeJsonPreservingStyleAsync(pkgPath, pkg)
685
686
  info(`Wrote ${colors.cyan}uniweb.id: "${foundationName}"${colors.reset} to ${colors.dim}package.json${colors.reset}`)
686
687
  }
687
688
 
@@ -851,7 +852,7 @@ export async function publish(args = []) {
851
852
  if (writeBackId) {
852
853
  pkg.uniweb = pkg.uniweb || {}
853
854
  pkg.uniweb.id = foundationName
854
- await writeFile(pkgPath, JSON.stringify(pkg, null, 2) + '\n')
855
+ await writeJsonPreservingStyleAsync(pkgPath, pkg)
855
856
  info(`Wrote ${colors.cyan}uniweb.id: "${foundationName}"${colors.reset} to ${colors.dim}package.json${colors.reset}`)
856
857
  }
857
858
  console.log('')
@@ -54,6 +54,7 @@ import {
54
54
  updateRootScripts,
55
55
  } from '../utils/config.js'
56
56
  import { discoverFoundations, discoverSites } from '../utils/discover.js'
57
+ import { writeJsonPreservingStyleAsync } from '../utils/json-file.js'
57
58
  import { getExistingPackageNames, validatePackageName } from '../utils/names.js'
58
59
  import { detectPackageManager, installCmd } from '../utils/pm.js'
59
60
  import { getCliPrefix } from '../utils/interactive.js'
@@ -184,9 +185,10 @@ async function validateRenameName(rootDir, newName) {
184
185
  * Rewrite the `name` field in a package.json file.
185
186
  */
186
187
  async function rewritePackageJsonName(pkgPath, newName) {
187
- const pkg = JSON.parse(await readFile(pkgPath, 'utf-8'))
188
+ const src = await readFile(pkgPath, 'utf-8')
189
+ const pkg = JSON.parse(src)
188
190
  pkg.name = newName
189
- await writeFile(pkgPath, JSON.stringify(pkg, null, 2) + '\n')
191
+ await writeJsonPreservingStyleAsync(pkgPath, pkg, src)
190
192
  }
191
193
 
192
194
  // ─── Foundation rename ───────────────────────────────────────────
@@ -234,11 +236,13 @@ async function renameFoundation(rootDir, oldName, newName, prefix) {
234
236
  for (const site of sites) {
235
237
  const sitePkgPath = join(rootDir, site.path, 'package.json')
236
238
  const siteYmlPath = join(rootDir, site.path, 'site.yml')
237
- let pkg, ymlData
239
+ let pkg, pkgSrc, ymlData
238
240
  try {
239
- pkg = JSON.parse(await readFile(sitePkgPath, 'utf-8'))
241
+ pkgSrc = await readFile(sitePkgPath, 'utf-8')
242
+ pkg = JSON.parse(pkgSrc)
240
243
  } catch {
241
244
  pkg = null
245
+ pkgSrc = null
242
246
  }
243
247
  try {
244
248
  ymlData = yaml.load(await readFile(siteYmlPath, 'utf-8')) || {}
@@ -252,6 +256,7 @@ async function renameFoundation(rootDir, oldName, newName, prefix) {
252
256
  path: site.path,
253
257
  name: site.name,
254
258
  pkg,
259
+ pkgSrc,
255
260
  sitePkgPath,
256
261
  siteYmlPath,
257
262
  ymlData,
@@ -295,7 +300,7 @@ async function renameFoundation(rootDir, oldName, newName, prefix) {
295
300
  s.pkg.dependencies[newName] = oldValue.startsWith('file:')
296
301
  ? `file:${newRel}`
297
302
  : oldValue // npm-pinned, leave it; rename-then-republish is out of scope.
298
- await writeFile(s.sitePkgPath, JSON.stringify(s.pkg, null, 2) + '\n')
303
+ await writeJsonPreservingStyleAsync(s.sitePkgPath, s.pkg, s.pkgSrc)
299
304
  }
300
305
  if (s.ymlMatches) {
301
306
  const newYmlData = { ...s.ymlData, foundation: newName }
@@ -1,32 +1,40 @@
1
1
  /**
2
- * uniweb update — Reconcile a Uniweb workspace's state with the running
3
- * CLI's expectations. Three convergence steps, in order:
2
+ * uniweb update — Reconcile a Uniweb workspace's state with the CLI that's
3
+ * running this command. One job, two convergence steps:
4
4
  *
5
- * 1. Self-update the global CLI install (npm / pnpm / yarn auto-detected).
6
- * 2. Align workspace `@uniweb/*` + `uniweb` deps to the CLI's bundled
7
- * version matrix (`getResolvedVersions`), then run `<pm> install`.
8
- * 3. Refresh AGENTS.md from the CLI's bundled partial.
5
+ * 1. Align workspace `@uniweb/*` + `uniweb` deps to this CLI's bundled
6
+ * version matrix (`getResolvedVersions`) only deps that *lag* the
7
+ * matrix are touched; deps that are *ahead* are left alone (no
8
+ * downgrades) then run `<pm> install`.
9
+ * 2. Refresh AGENTS.md from this CLI's bundled partial.
9
10
  *
10
- * Why steps 2 and 3 belong together: AGENTS.md is regenerated from the
11
- * CLI's *current* partials and stamped with `cliVersion`. Refreshing it
12
- * while declared deps in `package.json` lag the CLI silently produces a
13
- * doc that documents features the installed code doesn't have. The
14
- * verb's drift gate refuses that combination unless `--allow-mismatch`
15
- * is explicit.
11
+ * Why those two belong together: AGENTS.md is regenerated from the CLI's
12
+ * *current* partials and stamped with `cliVersion`. Refreshing it while
13
+ * the installed `@uniweb/*` packages still lag the CLI silently produces a
14
+ * doc that documents features the installed code doesn't have. So step 2
15
+ * refuses to run unless step 1 actually completed (deps aligned *and*
16
+ * installed). `--allow-mismatch` overrides the declared-deps half of that
17
+ * gate; nothing overrides "you skipped the install" — run the install and
18
+ * re-run `update`.
19
+ *
20
+ * What this command does NOT do: update the CLI itself. A globally
21
+ * installed `uniweb` is updated through its package manager
22
+ * (`npm i -g uniweb@latest`, `pnpm add -g uniweb@latest`, …); the version
23
+ * notification machinery (`utils/update-check.js`, `uniweb --version`)
24
+ * surfaces when that's needed. To reconcile a project against the *latest*
25
+ * release without touching a global install, run `npx uniweb@latest
26
+ * update` — npx fetches the latest CLI, which carries its own matrix.
16
27
  *
17
28
  * Flags:
18
- * --agents-only Skip self-update + deps; only refresh AGENTS.md.
19
- * --deps-only Skip self-update + AGENTS.md; only align deps.
29
+ * --agents-only Only refresh AGENTS.md (skip the deps step).
30
+ * --deps-only Only align deps (skip the AGENTS.md step).
20
31
  * --no-agents Skip the AGENTS.md step.
21
32
  * --no-deps Skip the deps-alignment step.
22
33
  * --dry-run Print survey + would-be writes; no mutations.
23
- * --allow-mismatch Permit AGENTS.md refresh when declared deps lag.
24
- * --yes Skip confirmation prompts (still respects gates).
25
- * --non-interactive Auto-detected; never auto-installs from a script.
26
- *
27
- * Project-local case (CLI lives in node_modules, not global): self-update
28
- * is a no-op (the version is pinned by package.json). Deps + AGENTS.md
29
- * paths still run.
34
+ * --allow-mismatch Refresh AGENTS.md even if declared deps lag.
35
+ * --yes Don't prompt apply edits and run the install.
36
+ * --non-interactive Auto-detected; prints the plan, never mutates
37
+ * (combine with --yes to apply non-interactively).
30
38
  */
31
39
 
32
40
  import { existsSync, readFileSync, writeFileSync } from 'node:fs'
@@ -34,11 +42,13 @@ import { join } from 'node:path'
34
42
  import { spawn } from 'node:child_process'
35
43
  import prompts from 'prompts'
36
44
 
37
- import { findWorkspaceRoot, getWorkspacePackages } from '../utils/workspace.js'
45
+ import { findWorkspaceRoot } from '../utils/workspace.js'
38
46
  import { readAgentsVersion, generateAgentsContent } from '../utils/agents-stamp.js'
39
- import { getCliVersion, getResolvedVersions, updatePackageVersions } from '../versions.js'
47
+ import { getCliVersion } from '../versions.js'
40
48
  import { isNonInteractive } from '../utils/interactive.js'
41
- import { detectWorkspacePm, installCmd } from '../utils/pm.js'
49
+ import { detectWorkspacePm, installCmd, detectGlobalCliPm, globalCliUpdateCmd } from '../utils/pm.js'
50
+ import { writeJsonPreservingStyle } from '../utils/json-file.js'
51
+ import { surveyWorkspaceDeps, compareSemver } from '../utils/dep-survey.js'
42
52
 
43
53
  const colors = {
44
54
  reset: '\x1b[0m',
@@ -57,9 +67,9 @@ const info = (msg) => console.log(`${colors.cyan}ℹ${colors.reset} ${msg}`)
57
67
  const log = console.log
58
68
 
59
69
  /**
60
- * Detect whether this CLI is running from a global install. Mirrors the
61
- * logic in index.js::isGlobalInstall when global, process.argv[1]
62
- * points outside any node_modules.
70
+ * Detect whether this CLI is running from a global install when global,
71
+ * process.argv[1] points outside any node_modules. Mirrors
72
+ * index.js::isGlobalInstall.
63
73
  */
64
74
  function isGlobalInstall() {
65
75
  const scriptPath = process.argv[1]
@@ -68,6 +78,17 @@ function isGlobalInstall() {
68
78
  !scriptPath.split('\\').includes('node_modules')
69
79
  }
70
80
 
81
+ /**
82
+ * Detect whether this CLI was launched via `npx uniweb …` (or `npm exec`).
83
+ * npx materializes the package under `…/_npx/<hash>/node_modules/uniweb/…`,
84
+ * which `isGlobalInstall()` can't distinguish from a real project-local
85
+ * dependency (both contain `node_modules`).
86
+ */
87
+ function isNpxInvocation() {
88
+ const p = (process.argv[1] || '').replace(/\\/g, '/')
89
+ return p.includes('/_npx/')
90
+ }
91
+
71
92
  /**
72
93
  * Find a *Uniweb* workspace root from cwd. Stricter than findWorkspaceRoot
73
94
  * — also requires that the workspace's root package.json declares uniweb
@@ -88,32 +109,7 @@ function findUniwebWorkspace(cwd) {
88
109
  }
89
110
  }
90
111
 
91
- /**
92
- * Detect the package manager that owns the *global* CLI install.
93
- * Path-based (different signal than detectWorkspacePm, which reads
94
- * lockfiles in the workspace).
95
- *
96
- * @returns {'pnpm'|'yarn'|'npm'}
97
- */
98
- function detectGlobalPm() {
99
- const path = (process.argv[1] || '').toLowerCase()
100
- if (path.includes('/pnpm/') || path.includes('\\pnpm\\')) return 'pnpm'
101
- if (path.includes('/yarn/') || path.includes('\\yarn\\')) return 'yarn'
102
- return 'npm'
103
- }
104
-
105
- /**
106
- * Build the global-install command for a given PM.
107
- */
108
- function globalInstallCmd(pm) {
109
- if (pm === 'pnpm') return 'pnpm add -g uniweb@latest'
110
- if (pm === 'yarn') return 'yarn global add uniweb@latest'
111
- return 'npm i -g uniweb@latest'
112
- }
113
-
114
- /**
115
- * Fetch the latest published version. Returns null on network error.
116
- */
112
+ /** Fetch the latest published CLI version. Returns null on network error. */
117
113
  async function fetchLatestVersion() {
118
114
  try {
119
115
  const res = await fetch('https://registry.npmjs.org/uniweb/latest')
@@ -125,32 +121,7 @@ async function fetchLatestVersion() {
125
121
  }
126
122
  }
127
123
 
128
- /**
129
- * Strip a leading semver range operator (^, ~, >=, <, etc.) so two specs
130
- * can be compared by their underlying version. Range expressions like
131
- * ">=0.5 <0.7" aren't fully parsed — we take the first version-shaped
132
- * token. Sufficient for `@uniweb/*` deps which use `^x.y.z` consistently.
133
- */
134
- function stripRange(spec) {
135
- return (spec || '').replace(/^[\^~>=<\s]+/, '').trim().split(/\s+/)[0] || ''
136
- }
137
-
138
- /**
139
- * Compare two version specs (range prefix tolerated). Returns 1/-1/0.
140
- */
141
- function compareSemver(a, b) {
142
- const pa = stripRange(a).split('.').map(Number)
143
- const pb = stripRange(b).split('.').map(Number)
144
- for (let i = 0; i < 3; i++) {
145
- if ((pa[i] || 0) > (pb[i] || 0)) return 1
146
- if ((pa[i] || 0) < (pb[i] || 0)) return -1
147
- }
148
- return 0
149
- }
150
-
151
- /**
152
- * Run a shell command, inheriting stdio. Resolves with the exit code.
153
- */
124
+ /** Run a shell command, inheriting stdio. Resolves with the exit code. */
154
125
  function runCommand(cmd, cwd) {
155
126
  return new Promise((resolve) => {
156
127
  const [bin, ...rest] = cmd.split(' ')
@@ -160,60 +131,7 @@ function runCommand(cmd, cwd) {
160
131
  })
161
132
  }
162
133
 
163
- /**
164
- * Survey workspace `@uniweb/*` and `uniweb` deps against the CLI's
165
- * bundled version matrix. Returns a structured report with one row per
166
- * (package directory, dep section, dep name).
167
- *
168
- * Comparison is on *declared* versions (package.json), not installed
169
- * (node_modules) — that's what the user committed and what they'll
170
- * `git diff` after `applyDepUpdates`.
171
- */
172
- async function surveyVersions(workspaceDir) {
173
- const targets = getResolvedVersions()
174
- const packages = await getWorkspacePackages(workspaceDir)
175
- const dirs = ['', ...packages]
176
- const rows = []
177
- let anyDrift = false
178
- let anyAhead = false
179
-
180
- for (const relDir of dirs) {
181
- const pkgDir = relDir ? join(workspaceDir, relDir) : workspaceDir
182
- const pkgPath = join(pkgDir, 'package.json')
183
- if (!existsSync(pkgPath)) continue
184
- let pkg
185
- try { pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) } catch { continue }
186
-
187
- for (const sectionName of ['dependencies', 'devDependencies', 'peerDependencies']) {
188
- const section = pkg[sectionName]
189
- if (!section) continue
190
- for (const [name, current] of Object.entries(section)) {
191
- if (!(name.startsWith('@uniweb/') || name === 'uniweb')) continue
192
- const target = targets[name]
193
- if (!target) continue
194
- const cmp = compareSemver(target, current)
195
- let status
196
- if (cmp > 0) { status = 'behind'; anyDrift = true }
197
- else if (cmp < 0) { status = 'ahead'; anyAhead = true }
198
- else { status = 'aligned' }
199
- rows.push({
200
- relDir: relDir || '(root)',
201
- section: sectionName,
202
- name,
203
- current,
204
- target,
205
- status,
206
- })
207
- }
208
- }
209
- }
210
-
211
- return { targets, rows, anyDrift, anyAhead }
212
- }
213
-
214
- /**
215
- * Print the survey report grouped by package directory.
216
- */
134
+ /** Print the survey report grouped by package directory. */
217
135
  function printSurvey(report, cliVersion, agentsVersion) {
218
136
  log('')
219
137
  log(`${colors.bright}uniweb CLI:${colors.reset} v${cliVersion}`)
@@ -256,17 +174,23 @@ function printSurvey(report, cliVersion, agentsVersion) {
256
174
  }
257
175
 
258
176
  /**
259
- * Apply the CLI's bundled matrix to every workspace package.json.
260
- * `updatePackageVersions` only touches `@uniweb/*` + `uniweb` keys, so
261
- * unrelated deps (`react`, `vite`, `file:../foundation`, etc.) are left
262
- * untouched. Returns the list of paths that actually changed.
177
+ * Apply the CLI's matrix to workspace package.json files — but only to
178
+ * deps that *lag* the matrix (survey status `behind`). Deps that are
179
+ * `ahead` are left alone: `update` never downgrades. Indentation and the
180
+ * trailing newline of each file are preserved (a one-key bump shouldn't
181
+ * reflow the whole file). Returns the list of paths that actually changed.
263
182
  */
264
- async function applyDepUpdates(workspaceDir, dryRun) {
265
- const packages = await getWorkspacePackages(workspaceDir)
266
- const dirs = ['', ...packages]
267
- const edited = []
183
+ function applyDepUpdates(workspaceDir, surveyRows, dryRun) {
184
+ const behind = surveyRows.filter(r => r.status === 'behind')
185
+ const byDir = {}
186
+ for (const row of behind) {
187
+ const dir = row.relDir === '(root)' ? '' : row.relDir
188
+ if (!byDir[dir]) byDir[dir] = []
189
+ byDir[dir].push(row)
190
+ }
268
191
 
269
- for (const relDir of dirs) {
192
+ const edited = []
193
+ for (const [relDir, dirRows] of Object.entries(byDir)) {
270
194
  const pkgDir = relDir ? join(workspaceDir, relDir) : workspaceDir
271
195
  const pkgPath = join(pkgDir, 'package.json')
272
196
  if (!existsSync(pkgPath)) continue
@@ -274,12 +198,18 @@ async function applyDepUpdates(workspaceDir, dryRun) {
274
198
  let pkg
275
199
  try { pkg = JSON.parse(original) } catch { continue }
276
200
 
277
- const updated = updatePackageVersions(pkg)
278
- const newContent = JSON.stringify(updated, null, 2) + (original.endsWith('\n') ? '\n' : '')
201
+ let changed = false
202
+ for (const row of dirRows) {
203
+ const section = pkg[row.section]
204
+ if (section && section[row.name] !== undefined && section[row.name] !== row.target) {
205
+ section[row.name] = row.target
206
+ changed = true
207
+ }
208
+ }
279
209
 
280
- if (newContent !== original) {
210
+ if (changed) {
281
211
  edited.push(pkgPath)
282
- if (!dryRun) writeFileSync(pkgPath, newContent)
212
+ if (!dryRun) writeJsonPreservingStyle(pkgPath, pkg, original)
283
213
  }
284
214
  }
285
215
 
@@ -297,9 +227,10 @@ export async function update(args = []) {
297
227
  const skipDeps = args.includes('--no-deps') || agentsOnly
298
228
  const dryRun = args.includes('--dry-run')
299
229
  const allowMismatch = args.includes('--allow-mismatch')
230
+ const hasYes = args.includes('--yes')
300
231
  const nonInteractive = isNonInteractive(args)
301
- const skipPrompt = args.includes('--yes') || nonInteractive || dryRun
302
232
  const isGlobal = isGlobalInstall()
233
+ const isNpx = isNpxInvocation()
303
234
  const workspaceDir = findUniwebWorkspace(process.cwd())
304
235
  const inProject = !!workspaceDir
305
236
  const cliVersion = getCliVersion()
@@ -314,88 +245,73 @@ export async function update(args = []) {
314
245
  let survey = null
315
246
  let agentsVersion = null
316
247
  if (inProject) {
317
- survey = await surveyVersions(workspaceDir)
248
+ survey = await surveyWorkspaceDeps(workspaceDir)
318
249
  agentsVersion = readAgentsVersion(join(workspaceDir, 'AGENTS.md'))
319
250
  printSurvey(survey, cliVersion, agentsVersion)
320
251
  }
321
252
 
322
- // ── Step 1: Self-update path ─────────────────────────────────────
323
- if (!agentsOnly && !depsOnly) {
324
- if (!isGlobal) {
325
- log(`${colors.dim}Running the project-local CLI (v${cliVersion}). This copy is pinned by your${colors.reset}`)
326
- 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}`)
253
+ // ── This command reconciles the *project*, not the CLI ───────────
254
+ // Surface (but don't act on) a newer published CLI: this run aligns
255
+ // the project to *this* CLI's matrix.
256
+ let installPm = inProject ? detectWorkspacePm(workspaceDir) : null
257
+ if (isNpx) {
258
+ log(`${colors.dim}Running${colors.reset} ${colors.cyan}uniweb@${cliVersion}${colors.reset} ${colors.dim}via npx — aligning this project to v${cliVersion}'s matrix.${colors.reset}`)
259
+ log(`${colors.dim}(To install the CLI:${colors.reset} ${colors.cyan}npm i -g uniweb${colors.reset}${colors.dim}.)${colors.reset}`)
260
+ log('')
261
+ } else if (isGlobal) {
262
+ const latest = await fetchLatestVersion()
263
+ if (latest && compareSemver(latest, cliVersion) > 0) {
264
+ const pm = detectGlobalCliPm()
265
+ log(`${colors.yellow}A newer uniweb is available:${colors.reset} ${colors.dim}v${cliVersion}${colors.reset} → ${colors.cyan}v${latest}${colors.reset}`)
266
+ log(`${colors.dim}This run aligns the project to v${cliVersion}. To update the CLI:${colors.reset} ${colors.cyan}${globalCliUpdateCmd(pm)}${colors.reset}`)
267
+ log(`${colors.dim}Or, to align to the latest release without a global install:${colors.reset} ${colors.cyan}npx uniweb@latest update${colors.reset}`)
327
268
  log('')
328
- } else {
329
- const latest = await fetchLatestVersion()
330
- if (latest === null) {
331
- warn('Could not reach the npm registry to check for updates.')
332
- log(`${colors.dim}Current: ${cliVersion}. Try later, or run${colors.reset} ${colors.cyan}${globalInstallCmd(detectGlobalPm())}${colors.reset}${colors.dim} manually.${colors.reset}`)
333
- log('')
334
- } else if (compareSemver(latest, cliVersion) <= 0) {
335
- success(`uniweb is up to date (v${cliVersion}).`)
336
- log('')
337
- } else {
338
- const pm = detectGlobalPm()
339
- const cmd = globalInstallCmd(pm)
340
- log(`${colors.yellow}Update available:${colors.reset} ${colors.dim}${cliVersion}${colors.reset} → ${colors.cyan}${latest}${colors.reset}`)
341
- log(`${colors.dim}Detected package manager:${colors.reset} ${pm}`)
342
- log(`${colors.dim}Will run:${colors.reset} ${colors.cyan}${cmd}${colors.reset}`)
343
- log('')
344
-
345
- if (dryRun) {
346
- info(`${colors.dim}--dry-run: would run \`${cmd}\`.${colors.reset}`)
347
- log('')
348
- } else if (skipPrompt) {
349
- log(`${colors.dim}Non-interactive — skipping self-update. Run the command above to update.${colors.reset}`)
350
- log('')
351
- } else {
352
- const { go } = await prompts({
353
- type: 'confirm',
354
- name: 'go',
355
- message: `Run \`${cmd}\` now?`,
356
- initial: true,
357
- })
358
- if (go) {
359
- const code = await runCommand(cmd)
360
- if (code === 0) {
361
- success(`Self-update complete.`)
362
- } else {
363
- error(`Self-update failed (exit ${code}). Run the command above manually if needed.`)
364
- }
365
- log('')
366
- } else {
367
- log(`${colors.dim}Skipped self-update.${colors.reset}`)
368
- log('')
369
- }
370
- }
371
- }
372
269
  }
270
+ } else {
271
+ // Project-local copy (lives in this project's node_modules).
272
+ log(`${colors.dim}Running the project-local CLI (v${cliVersion}) — pinned by your project's${colors.reset} ${colors.cyan}package.json${colors.reset}${colors.dim}.${colors.reset}`)
273
+ log(`${colors.dim}To use a newer CLI, bump${colors.reset} ${colors.cyan}uniweb${colors.reset}${colors.dim} in${colors.reset} ${colors.cyan}package.json${colors.reset}${colors.dim} and re-install, or run${colors.reset} ${colors.cyan}npx uniweb@latest update${colors.reset}${colors.dim}.${colors.reset}`)
274
+ log('')
275
+ }
276
+
277
+ if (!inProject) {
278
+ log(`${colors.dim}Not inside a Uniweb project — nothing to reconcile.${colors.reset}`)
279
+ log(`${colors.dim}Run this from a project created by${colors.reset} ${colors.cyan}uniweb create${colors.reset}${colors.dim}.${colors.reset}`)
280
+ log('')
281
+ return
373
282
  }
374
283
 
375
- // ── Step 2: Deps alignment ───────────────────────────────────────
376
- if (!skipDeps && inProject && survey) {
284
+ // ── Step 1: Deps alignment ───────────────────────────────────────
285
+ let depsEdited = false // package.json files were rewritten
286
+ let installRan = false // `<pm> install` ran and succeeded
287
+ let editedPaths = []
288
+
289
+ if (!skipDeps && survey) {
377
290
  if (!survey.anyDrift) {
378
291
  success('Workspace deps are aligned with the CLI.')
379
292
  if (survey.anyAhead) {
380
293
  log(`${colors.dim}(Some deps are ahead of the CLI's bundled matrix — left untouched.)${colors.reset}`)
381
294
  }
295
+ if (!existsSync(join(workspaceDir, 'node_modules'))) {
296
+ warn(`No ${colors.bright}node_modules${colors.reset} in the workspace — run ${colors.cyan}${installCmd(installPm || 'pnpm')}${colors.reset} to install.`)
297
+ }
382
298
  log('')
383
299
  } else {
384
300
  log(`${colors.yellow}⚠${colors.reset} Some workspace deps lag the CLI's bundled matrix.`)
385
301
  log('')
386
302
 
303
+ // Decide whether to write the package.json edits.
387
304
  let proceed
388
305
  if (dryRun) {
389
306
  proceed = false
390
- } else if (skipPrompt) {
391
- proceed = !nonInteractive || args.includes('--yes')
392
- // In CI without --yes, refuse to mutate. The survey above is the report.
393
- if (!proceed) {
394
- info(`${colors.dim}Non-interactive — printing the alignment plan; not editing files.${colors.reset}`)
395
- log(`${colors.dim}To apply, re-run with${colors.reset} ${colors.cyan}--yes${colors.reset}${colors.dim}, or align manually:${colors.reset}`)
396
- log(` ${colors.cyan}pnpm update "@uniweb/*" uniweb -r${colors.reset}`)
397
- log('')
398
- }
307
+ } else if (hasYes) {
308
+ proceed = true
309
+ } else if (nonInteractive) {
310
+ proceed = false
311
+ info(`${colors.dim}Non-interactive — printing the alignment plan; not editing files.${colors.reset}`)
312
+ log(`${colors.dim}To apply, re-run with${colors.reset} ${colors.cyan}--yes${colors.reset}${colors.dim}, or align manually:${colors.reset}`)
313
+ log(` ${colors.cyan}pnpm update "@uniweb/*" uniweb -r${colors.reset}`)
314
+ log('')
399
315
  } else {
400
316
  const { go } = await prompts({
401
317
  type: 'confirm',
@@ -407,32 +323,29 @@ export async function update(args = []) {
407
323
  }
408
324
 
409
325
  if (dryRun) {
410
- const wouldEdit = await applyDepUpdates(workspaceDir, true)
326
+ const wouldEdit = applyDepUpdates(workspaceDir, survey.rows, true)
411
327
  if (wouldEdit.length > 0) {
412
328
  info('Dry-run: would update package.json in:')
413
329
  for (const path of wouldEdit) log(` ${colors.dim}- ${relativize(path, workspaceDir)}${colors.reset}`)
414
- const pm = detectWorkspacePm(workspaceDir)
415
- if (pm) {
416
- log(`${colors.dim}Then would run:${colors.reset} ${colors.cyan}${installCmd(pm)}${colors.reset}`)
330
+ if (installPm) {
331
+ log(`${colors.dim}Then would run:${colors.reset} ${colors.cyan}${installCmd(installPm)}${colors.reset}`)
417
332
  } else {
418
333
  log(`${colors.dim}Then would prompt for an install command (no lockfile detected).${colors.reset}`)
419
334
  }
420
335
  log('')
421
336
  }
422
337
  } else if (proceed) {
423
- const edited = await applyDepUpdates(workspaceDir, false)
424
- if (edited.length === 0) {
338
+ editedPaths = applyDepUpdates(workspaceDir, survey.rows, false)
339
+ depsEdited = editedPaths.length > 0
340
+ if (!depsEdited) {
425
341
  info('No package.json files needed changes.')
426
342
  log('')
427
343
  } else {
428
- for (const path of edited) {
429
- success(`Updated ${relativize(path, workspaceDir)}`)
430
- }
344
+ for (const path of editedPaths) success(`Updated ${relativize(path, workspaceDir)}`)
431
345
  log('')
432
346
 
433
347
  // Resolve the workspace PM (lockfile-driven). If absent, ask.
434
- let pm = detectWorkspacePm(workspaceDir)
435
- if (!pm) {
348
+ if (!installPm) {
436
349
  if (nonInteractive) {
437
350
  warn('No lockfile in workspace root — cannot pick an install command for you.')
438
351
  log(`${colors.dim}Run one of:${colors.reset} ${colors.cyan}pnpm install${colors.reset} ${colors.dim}/${colors.reset} ${colors.cyan}yarn install${colors.reset} ${colors.dim}/${colors.reset} ${colors.cyan}npm install${colors.reset}`)
@@ -449,20 +362,19 @@ export async function update(args = []) {
449
362
  { title: 'skip — I\'ll install manually', value: null },
450
363
  ],
451
364
  })
452
- pm = picked || null
365
+ installPm = picked || null
453
366
  }
454
367
  }
455
368
 
456
- if (pm) {
457
- const cmd = installCmd(pm)
369
+ if (installPm) {
370
+ const cmd = installCmd(installPm)
458
371
  let runInstall
459
- if (nonInteractive) {
372
+ if (hasYes) {
373
+ runInstall = true
374
+ } else if (nonInteractive) {
460
375
  runInstall = false
461
- info(`${colors.dim}Non-interactive — printing install command:${colors.reset}`)
462
- log(` ${colors.cyan}${cmd}${colors.reset}`)
376
+ info(`${colors.dim}Non-interactive — run the install yourself:${colors.reset} ${colors.cyan}${cmd}${colors.reset}`)
463
377
  log('')
464
- } else if (skipPrompt) {
465
- runInstall = true
466
378
  } else {
467
379
  const { go } = await prompts({
468
380
  type: 'confirm',
@@ -476,18 +388,19 @@ export async function update(args = []) {
476
388
  if (runInstall) {
477
389
  const code = await runCommand(cmd, workspaceDir)
478
390
  if (code === 0) {
391
+ installRan = true
479
392
  success('Install complete.')
480
393
  log('')
481
394
  } else {
482
395
  error(`Install failed (exit ${code}). package.json edits are intact.`)
483
- const editedRel = edited.map(p => relativize(p, workspaceDir)).join(' ')
396
+ const editedRel = editedPaths.map(p => relativize(p, workspaceDir)).join(' ')
484
397
  log(`${colors.dim}To revert:${colors.reset} ${colors.cyan}git checkout -- ${editedRel}${colors.reset}`)
485
398
  log(`${colors.dim}To retry: ${colors.reset} ${colors.cyan}${cmd}${colors.reset}`)
486
399
  log('')
487
400
  process.exit(code)
488
401
  }
489
402
  } else {
490
- log(`${colors.dim}Skipped install. Edits saved; run${colors.reset} ${colors.cyan}${cmd}${colors.reset} ${colors.dim}to apply.${colors.reset}`)
403
+ log(`${colors.dim}Skipped install. Edits saved; run${colors.reset} ${colors.cyan}${cmd}${colors.reset} ${colors.dim}to apply them.${colors.reset}`)
491
404
  log('')
492
405
  }
493
406
  }
@@ -499,52 +412,105 @@ export async function update(args = []) {
499
412
  }
500
413
  }
501
414
 
502
- // ── Step 3: AGENTS.md ────────────────────────────────────────────
503
- if (skipAgents) return
504
- if (!inProject) {
505
- // Self-update-only invocation outside a Uniweb project: quietly skip.
506
- return
415
+ // ── Step 2: AGENTS.md ────────────────────────────────────────────
416
+ let agentsResult = null // 'created' | 'updated' | 'current' | 'skipped'
417
+
418
+ if (!skipAgents) {
419
+ agentsResult = await refreshAgents({
420
+ workspaceDir, cliVersion, allowMismatch, dryRun,
421
+ hasYes, nonInteractive, agentsOnly, depsEdited, installRan, installPm,
422
+ })
423
+ }
424
+
425
+ // ── Closing summary ──────────────────────────────────────────────
426
+ if (!dryRun && (depsEdited || agentsResult === 'created' || agentsResult === 'updated')) {
427
+ printSummary({ editedPaths, depsEdited, installRan, installPm, agentsResult, cliVersion })
428
+ }
429
+ }
430
+
431
+ /**
432
+ * Step 2 — regenerate AGENTS.md from the CLI's bundled partial, stamped
433
+ * with the CLI version. Guarded: won't run if the deps were edited but
434
+ * not installed (the doc would describe code that isn't on disk yet), and
435
+ * won't run if declared deps still lag the CLI unless --allow-mismatch.
436
+ *
437
+ * @returns {'created'|'updated'|'current'|'skipped'} what happened.
438
+ */
439
+ async function refreshAgents(ctx) {
440
+ const {
441
+ workspaceDir, cliVersion, allowMismatch, dryRun,
442
+ hasYes, nonInteractive, agentsOnly, depsEdited, installRan, installPm,
443
+ } = ctx
444
+
445
+ // Deps were rewritten but not installed → node_modules is now behind
446
+ // package.json. Refreshing the doc here would document features the
447
+ // installed code doesn't have. No override — run the install first.
448
+ if (depsEdited && !installRan) {
449
+ const cmd = installCmd(installPm || 'pnpm')
450
+ warn('AGENTS.md refresh skipped: package.json was updated but not installed.')
451
+ log(`${colors.dim}Your${colors.reset} ${colors.bright}node_modules${colors.reset} ${colors.dim}is behind your${colors.reset} ${colors.bright}package.json${colors.reset}${colors.dim}. Run${colors.reset} ${colors.cyan}${cmd}${colors.reset}${colors.dim}, then re-run${colors.reset} ${colors.cyan}uniweb update${colors.reset}${colors.dim}.${colors.reset}`)
452
+ log('')
453
+ return 'skipped'
507
454
  }
508
455
 
509
- // Re-survey: deps may have just been edited, which clears the gate.
510
- const finalSurvey = await surveyVersions(workspaceDir)
456
+ // Re-survey: deps may have just been edited+installed, which clears the
457
+ // declared-deps gate.
458
+ const finalSurvey = await surveyWorkspaceDeps(workspaceDir)
511
459
  if (finalSurvey.anyDrift && !allowMismatch) {
512
460
  warn('AGENTS.md refresh skipped: workspace deps still lag the CLI.')
513
461
  log(`${colors.dim}AGENTS.md from v${cliVersion} would document features not in your installed packages.${colors.reset}`)
514
462
  log(`${colors.dim}Re-run without ${colors.reset}${colors.cyan}--no-deps${colors.reset}${colors.dim}, or pass ${colors.reset}${colors.cyan}--allow-mismatch${colors.reset}${colors.dim} to override.${colors.reset}`)
515
463
  log('')
516
464
  if (agentsOnly) process.exit(1)
517
- return
465
+ return 'skipped'
518
466
  }
519
467
 
520
468
  const agentsPath = join(workspaceDir, 'AGENTS.md')
521
469
  const currentAgentsVersion = readAgentsVersion(agentsPath)
522
470
  if (currentAgentsVersion === cliVersion) {
523
471
  success(`AGENTS.md is already up to date (v${cliVersion}).`)
524
- return
472
+ return 'current'
525
473
  }
526
474
 
527
475
  if (dryRun) {
528
476
  info(`Dry-run: would ${currentAgentsVersion ? `update AGENTS.md (v${currentAgentsVersion} → v${cliVersion})` : `create AGENTS.md (v${cliVersion})`}.`)
529
- return
477
+ return 'skipped'
530
478
  }
531
479
 
532
- if (!skipPrompt && !agentsOnly) {
480
+ if (!hasYes && !nonInteractive && !agentsOnly) {
533
481
  const action = currentAgentsVersion
534
482
  ? `Update AGENTS.md (v${currentAgentsVersion} → v${cliVersion})?`
535
483
  : `Create AGENTS.md (v${cliVersion})?`
536
484
  const { yes } = await prompts({ type: 'confirm', name: 'yes', message: action, initial: true })
537
485
  if (!yes) {
538
486
  log(`${colors.dim}Skipped AGENTS.md.${colors.reset}`)
539
- return
487
+ return 'skipped'
540
488
  }
541
489
  }
542
490
 
543
- const content = generateAgentsContent()
544
- writeFileSync(agentsPath, content)
491
+ writeFileSync(agentsPath, generateAgentsContent())
545
492
  if (currentAgentsVersion) {
546
493
  success(`Updated AGENTS.md (v${currentAgentsVersion} → v${cliVersion}).`)
547
- } else {
548
- success(`Created AGENTS.md (v${cliVersion}).`)
494
+ return 'updated'
549
495
  }
496
+ success(`Created AGENTS.md (v${cliVersion}).`)
497
+ return 'created'
498
+ }
499
+
500
+ /** Print a compact recap of what changed, plus a review hint. */
501
+ function printSummary({ editedPaths, depsEdited, installRan, installPm, agentsResult, cliVersion }) {
502
+ log(`${colors.bright}Summary${colors.reset}`)
503
+ if (depsEdited) {
504
+ log(` ${colors.green}✓${colors.reset} package.json updated in ${editedPaths.length} file${editedPaths.length === 1 ? '' : 's'}`)
505
+ if (installRan) {
506
+ log(` ${colors.green}✓${colors.reset} ${installCmd(installPm || 'pnpm')} completed`)
507
+ } else {
508
+ log(` ${colors.yellow}⚠${colors.reset} install NOT run — run ${colors.cyan}${installCmd(installPm || 'pnpm')}${colors.reset} to apply`)
509
+ }
510
+ }
511
+ if (agentsResult === 'created') log(` ${colors.green}✓${colors.reset} AGENTS.md created (v${cliVersion})`)
512
+ else if (agentsResult === 'updated') log(` ${colors.green}✓${colors.reset} AGENTS.md updated (v${cliVersion})`)
513
+ else if (agentsResult === 'skipped' && depsEdited) log(` ${colors.dim}·${colors.reset} AGENTS.md not refreshed (see above)`)
514
+ log(`${colors.dim}Review changes with${colors.reset} ${colors.cyan}git diff${colors.reset}${colors.dim}, then commit.${colors.reset}`)
515
+ log('')
550
516
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "generatedAt": "2026-05-12T15:22:16.890Z",
3
+ "generatedAt": "2026-05-13T05:21:17.590Z",
4
4
  "packages": {
5
5
  "@uniweb/build": {
6
6
  "version": "0.14.5",
@@ -49,7 +49,7 @@
49
49
  ]
50
50
  },
51
51
  "@uniweb/loom": {
52
- "version": "0.2.2",
52
+ "version": "0.2.3",
53
53
  "path": "framework/loom",
54
54
  "deps": []
55
55
  },
@@ -92,7 +92,7 @@
92
92
  "deps": []
93
93
  },
94
94
  "@uniweb/unipress": {
95
- "version": "0.4.9",
95
+ "version": "0.4.10",
96
96
  "path": "framework/unipress",
97
97
  "deps": [
98
98
  "@uniweb/build",
package/src/index.js CHANGED
@@ -128,9 +128,10 @@ function getCliVersion() {
128
128
  * Commands that always run from the global CLI (no project context needed)
129
129
  */
130
130
  // Commands that always run from the global CLI, never delegating to a
131
- // project-local copy. `update` is here because its primary job is to
132
- // self-update the GLOBAL installdelegating it to project-local would
133
- // short-circuit that intent.
131
+ // project-local copy. `update` is here because it reconciles the project
132
+ // against *this* CLI's version matrix when run from a (newer) global
133
+ // install that's the whole point; delegating to the project-local copy
134
+ // would align the project to the version it already has, i.e. a no-op.
134
135
  const STANDALONE_COMMANDS = new Set([
135
136
  'create', '--help', '-h', '--version', '-v', 'login', 'update',
136
137
  ])
@@ -1284,39 +1285,47 @@ Prints the parsed content shape of a markdown file or folder — the
1284
1285
  Useful for debugging "why isn't my section getting X?".
1285
1286
  `,
1286
1287
  update: `
1287
- ${colors.cyan}${colors.bright}uniweb update${colors.reset} ${colors.dim}— Reconcile workspace state with the running CLI${colors.reset}
1288
+ ${colors.cyan}${colors.bright}uniweb update${colors.reset} ${colors.dim}— Align this project with the running CLI${colors.reset}
1288
1289
 
1289
1290
  ${colors.bright}Usage:${colors.reset}
1290
- uniweb update Self-update + align deps + refresh AGENTS.md
1291
+ uniweb update Align deps + refresh AGENTS.md
1291
1292
  uniweb update --deps-only Only align workspace @uniweb/* deps
1292
1293
  uniweb update --agents-only Only refresh AGENTS.md
1293
1294
  uniweb update --no-deps Skip the deps-alignment step
1294
1295
  uniweb update --no-agents Skip the AGENTS.md step
1295
1296
  uniweb update --dry-run Print survey + would-be writes; no mutations
1296
1297
  uniweb update --allow-mismatch Refresh AGENTS.md even if declared deps lag
1297
- uniweb update --yes Skip confirmation prompts
1298
+ uniweb update --yes Don't prompt — apply edits and run the install
1298
1299
 
1299
1300
  ${colors.bright}What it does:${colors.reset}
1300
1301
  Prints a version survey first (CLI version, AGENTS.md stamp, every
1301
1302
  @uniweb/* + uniweb dep declared in workspace package.json files,
1302
- marked aligned / behind / ahead). Then three steps:
1303
-
1304
- 1. ${colors.bright}Self-update${colors.reset} the global install via npm / pnpm / yarn
1305
- (auto-detected). TTY prompts; non-interactive prints the command.
1306
- 2. ${colors.bright}Align workspace deps${colors.reset} to the CLI's bundled version matrix
1307
- edits every workspace package.json (only @uniweb/* + uniweb keys),
1308
- then offers to run the workspace's package manager (lockfile-detected:
1309
- pnpm-lock.yaml → pnpm, yarn.lock → yarn, package-lock.json → npm).
1310
- If the install fails, package.json edits are kept and a revert
1311
- command is printed.
1312
- 3. ${colors.bright}Refresh AGENTS.md${colors.reset} from the CLI's bundled partial. Refuses to
1313
- run while declared deps still lag the CLI (would document features
1314
- not in your installed packages); pass --allow-mismatch to override.
1303
+ marked aligned / behind / ahead). Then two steps:
1304
+
1305
+ 1. ${colors.bright}Align workspace deps${colors.reset} to this CLI's bundled version matrix
1306
+ rewrites the @uniweb/* + uniweb keys in each package.json that lags
1307
+ (deps ahead of the matrix are left alone never downgraded;
1308
+ existing indentation is preserved), then offers to run the
1309
+ workspace's package manager (lockfile-detected: pnpm-lock.yaml →
1310
+ pnpm, yarn.lock → yarn, package-lock.json → npm). If the install
1311
+ fails, the package.json edits are kept and a revert command printed.
1312
+ 2. ${colors.bright}Refresh AGENTS.md${colors.reset} from this CLI's bundled partial. Won't run
1313
+ if deps were edited but not installed (node_modules would be behind
1314
+ package.json), or while declared deps still lag the CLI (would
1315
+ document features not in your packages) pass --allow-mismatch for
1316
+ the latter.
1317
+
1318
+ ${colors.bright}Which matrix?${colors.reset}
1319
+ \`update\` pins to the version matrix *this* CLI shipped with — not
1320
+ necessarily the latest release. To reconcile against the latest release
1321
+ without touching a global install, run \`npx uniweb@latest update\`. This
1322
+ command does NOT update the CLI itself; use your package manager
1323
+ (\`npm i -g uniweb@latest\`, \`pnpm add -g uniweb@latest\`, …).
1315
1324
 
1316
1325
  ${colors.bright}Project-local installs:${colors.reset}
1317
- When the running CLI is project-local (lives in node_modules), self-
1318
- update is a no-opthe version is pinned by your project's
1319
- package.json. The deps + AGENTS.md steps still run.
1326
+ When run from a project-local CLI (in node_modules), it aligns the
1327
+ project to that pinned version bump \`uniweb\` in package.json (or use
1328
+ \`npx uniweb@latest update\`) to align to something newer.
1320
1329
  `,
1321
1330
  }
1322
1331
 
@@ -1346,7 +1355,7 @@ ${colors.bright}Commands:${colors.reset}
1346
1355
  inspect <path> Inspect parsed content shape of a markdown file or folder
1347
1356
  docs Generate component documentation
1348
1357
  doctor Diagnose project configuration issues
1349
- update Update AGENTS.md to match installed CLI version
1358
+ update Align workspace deps + AGENTS.md to the running CLI
1350
1359
  i18n <cmd> Internationalization (extract, sync, status)
1351
1360
  template publish Publish a site as a cloud template
1352
1361
  login Log in to your Uniweb account
@@ -20,6 +20,7 @@ import { join } from 'node:path'
20
20
  import { homedir } from 'node:os'
21
21
  import yaml from 'js-yaml'
22
22
  import { filterCmd } from './pm.js'
23
+ import { writeJsonPreservingStyleAsync } from './json-file.js'
23
24
 
24
25
  // ── Platform URLs ──────────────────────────────────────────────
25
26
 
@@ -154,13 +155,19 @@ export async function readRootPackageJson(rootDir) {
154
155
  }
155
156
 
156
157
  /**
157
- * Write root package.json (2-space indent)
158
+ * Write root package.json, preserving the file's existing indentation
159
+ * (newly scaffolded workspaces use 2-space; we don't reflow whatever's
160
+ * already there).
158
161
  * @param {string} rootDir - Workspace root directory
159
162
  * @param {Object} pkg - Package.json object
160
163
  */
161
164
  export async function writeRootPackageJson(rootDir, pkg) {
162
165
  const pkgPath = join(rootDir, 'package.json')
163
- await writeFile(pkgPath, JSON.stringify(pkg, null, 2) + '\n')
166
+ if (existsSync(pkgPath)) {
167
+ await writeJsonPreservingStyleAsync(pkgPath, pkg)
168
+ } else {
169
+ await writeFile(pkgPath, JSON.stringify(pkg, null, 2) + '\n')
170
+ }
164
171
  }
165
172
 
166
173
  /**
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Workspace `@uniweb/*` dependency survey.
3
+ *
4
+ * Compares the `@uniweb/*` + `uniweb` versions *declared* in every
5
+ * package.json across a workspace against the running CLI's bundled
6
+ * version matrix (`getResolvedVersions`). Shared by `uniweb update`
7
+ * (which fixes the drift) and `uniweb doctor` (which only reports it) so
8
+ * the two never disagree about what "out of date" means.
9
+ *
10
+ * Comparison is on declared specs, not installed (node_modules) versions
11
+ * — that's what's committed and what `git diff` will show after a fix.
12
+ */
13
+
14
+ import { existsSync, readFileSync } from 'node:fs'
15
+ import { join } from 'node:path'
16
+ import { getResolvedVersions } from '../versions.js'
17
+ import { getWorkspacePackages } from './workspace.js'
18
+
19
+ /**
20
+ * Strip a leading semver range operator (^, ~, >=, <, …) so two specs can
21
+ * be compared by their underlying version. Range expressions like
22
+ * ">=0.5 <0.7" aren't fully parsed — the first version-shaped token wins.
23
+ * Sufficient for `@uniweb/*` deps, which use `^x.y.z` / `x.y.z`.
24
+ * @param {string} spec
25
+ * @returns {string}
26
+ */
27
+ export function stripVersionRange(spec) {
28
+ return (spec || '').replace(/^[\^~>=<\s]+/, '').trim().split(/\s+/)[0] || ''
29
+ }
30
+
31
+ /**
32
+ * Compare two version specs (range prefix tolerated). Returns 1 / -1 / 0.
33
+ * @param {string} a
34
+ * @param {string} b
35
+ * @returns {number}
36
+ */
37
+ export function compareSemver(a, b) {
38
+ const pa = stripVersionRange(a).split('.').map(Number)
39
+ const pb = stripVersionRange(b).split('.').map(Number)
40
+ for (let i = 0; i < 3; i++) {
41
+ if ((pa[i] || 0) > (pb[i] || 0)) return 1
42
+ if ((pa[i] || 0) < (pb[i] || 0)) return -1
43
+ }
44
+ return 0
45
+ }
46
+
47
+ /**
48
+ * @typedef {object} DepRow
49
+ * @property {string} relDir Workspace-relative dir, or '(root)'.
50
+ * @property {string} section 'dependencies' | 'devDependencies' | 'peerDependencies'
51
+ * @property {string} name Package name (e.g. '@uniweb/core' or 'uniweb').
52
+ * @property {string} current The spec declared in package.json.
53
+ * @property {string} target The spec the running CLI's matrix wants.
54
+ * @property {'aligned'|'behind'|'ahead'} status current vs target.
55
+ */
56
+
57
+ /**
58
+ * Survey a workspace's declared `@uniweb/*` + `uniweb` deps against the
59
+ * running CLI's bundled matrix.
60
+ *
61
+ * @param {string} workspaceDir Absolute path to the workspace root.
62
+ * @returns {Promise<{ targets: Record<string,string>, rows: DepRow[], anyDrift: boolean, anyAhead: boolean }>}
63
+ * `anyDrift` — at least one dep lags the matrix. `anyAhead` — at least
64
+ * one dep is newer than the matrix.
65
+ */
66
+ export async function surveyWorkspaceDeps(workspaceDir) {
67
+ const targets = getResolvedVersions()
68
+ const packages = await getWorkspacePackages(workspaceDir)
69
+ const dirs = ['', ...packages]
70
+ const rows = []
71
+ let anyDrift = false
72
+ let anyAhead = false
73
+
74
+ for (const relDir of dirs) {
75
+ const pkgDir = relDir ? join(workspaceDir, relDir) : workspaceDir
76
+ const pkgPath = join(pkgDir, 'package.json')
77
+ if (!existsSync(pkgPath)) continue
78
+ let pkg
79
+ try { pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) } catch { continue }
80
+
81
+ for (const sectionName of ['dependencies', 'devDependencies', 'peerDependencies']) {
82
+ const section = pkg[sectionName]
83
+ if (!section) continue
84
+ for (const [name, current] of Object.entries(section)) {
85
+ if (!(name.startsWith('@uniweb/') || name === 'uniweb')) continue
86
+ const target = targets[name]
87
+ if (!target) continue
88
+ const cmp = compareSemver(target, current)
89
+ let status
90
+ if (cmp > 0) { status = 'behind'; anyDrift = true }
91
+ else if (cmp < 0) { status = 'ahead'; anyAhead = true }
92
+ else { status = 'aligned' }
93
+ rows.push({ relDir: relDir || '(root)', section: sectionName, name, current, target, status })
94
+ }
95
+ }
96
+ }
97
+
98
+ return { targets, rows, anyDrift, anyAhead }
99
+ }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Style-preserving JSON file writes.
3
+ *
4
+ * The CLI rewrites `package.json` in several places (`update`, `doctor`,
5
+ * `rename`, `add`). A naive `JSON.stringify(obj, null, 2)` reflows the
6
+ * *entire* file whenever the project happened to use tabs or 4-space
7
+ * indentation — turning a one-key version bump into a hundred-line diff
8
+ * (and a needless merge-conflict surface). `framework/CLAUDE.md` calls
9
+ * this out as an anti-pattern for human commits; the tooling shouldn't do
10
+ * it either. These helpers detect the file's existing indentation and
11
+ * trailing-newline convention and preserve both.
12
+ */
13
+
14
+ import { readFileSync, writeFileSync } from 'node:fs'
15
+ import { readFile, writeFile } from 'node:fs/promises'
16
+
17
+ /**
18
+ * Detect the indentation unit a JSON source string uses.
19
+ * @param {string} src
20
+ * @returns {number|string} A space count, or '\t' for tab-indented files.
21
+ * Defaults to 2 when the file has no indented lines (e.g. `{}`).
22
+ */
23
+ export function detectJsonIndent(src) {
24
+ const m = src.match(/\n([ \t]+)\S/)
25
+ if (!m) return 2
26
+ const lead = m[1]
27
+ if (lead.includes('\t')) return '\t'
28
+ return lead.length
29
+ }
30
+
31
+ /**
32
+ * Serialize `obj` to JSON using the indentation and trailing-newline
33
+ * convention of `originalSrc` (or of the file currently at `filePath`).
34
+ * @param {object} obj
35
+ * @param {string} originalSrc - The file's current text.
36
+ * @returns {string}
37
+ */
38
+ export function stringifyJsonLike(obj, originalSrc) {
39
+ const indent = detectJsonIndent(originalSrc)
40
+ const body = JSON.stringify(obj, null, indent)
41
+ return originalSrc.endsWith('\n') ? body + '\n' : body
42
+ }
43
+
44
+ /**
45
+ * Write `obj` to `filePath` as JSON, preserving the file's existing
46
+ * indentation and trailing-newline convention. Pass `originalSrc` when
47
+ * the caller already has the file contents in hand (avoids a re-read);
48
+ * otherwise the file is read to sniff its style.
49
+ * @param {string} filePath
50
+ * @param {object} obj
51
+ * @param {string|null} [originalSrc]
52
+ */
53
+ export function writeJsonPreservingStyle(filePath, obj, originalSrc = null) {
54
+ const src = originalSrc ?? readFileSync(filePath, 'utf8')
55
+ writeFileSync(filePath, stringifyJsonLike(obj, src))
56
+ }
57
+
58
+ /**
59
+ * Async counterpart to {@link writeJsonPreservingStyle}, for the CLI's
60
+ * many `node:fs/promises`-based call sites.
61
+ * @param {string} filePath
62
+ * @param {object} obj
63
+ * @param {string|null} [originalSrc]
64
+ */
65
+ export async function writeJsonPreservingStyleAsync(filePath, obj, originalSrc = null) {
66
+ const src = originalSrc ?? await readFile(filePath, 'utf8')
67
+ await writeFile(filePath, stringifyJsonLike(obj, src))
68
+ }
package/src/utils/pm.js CHANGED
@@ -40,6 +40,35 @@ export function detectWorkspacePm(workspaceRoot) {
40
40
  return null
41
41
  }
42
42
 
43
+ /**
44
+ * Detect which package manager owns a *globally installed* `uniweb` CLI
45
+ * binary, by inspecting its install path (`process.argv[1]`). pnpm and
46
+ * yarn keep global packages under recognizable directory segments;
47
+ * everything else is assumed to be npm. Only meaningful when the CLI is
48
+ * actually a global install (see index.js::isGlobalInstall) — a
49
+ * project-local or npx-launched copy is updated differently.
50
+ *
51
+ * @returns {'pnpm' | 'yarn' | 'npm'}
52
+ */
53
+ export function detectGlobalCliPm() {
54
+ const path = (process.argv[1] || '').toLowerCase().replace(/\\/g, '/')
55
+ if (path.includes('/pnpm/')) return 'pnpm'
56
+ if (path.includes('/yarn/')) return 'yarn'
57
+ return 'npm'
58
+ }
59
+
60
+ /**
61
+ * The command to (re)install the latest `uniweb` CLI globally with a
62
+ * given package manager.
63
+ * @param {'pnpm' | 'yarn' | 'npm'} pm
64
+ * @returns {string}
65
+ */
66
+ export function globalCliUpdateCmd(pm) {
67
+ if (pm === 'pnpm') return 'pnpm add -g uniweb@latest'
68
+ if (pm === 'yarn') return 'yarn global add uniweb@latest'
69
+ return 'npm i -g uniweb@latest'
70
+ }
71
+
43
72
  /**
44
73
  * Generate a workspace-filtered command.
45
74
  * pnpm: "pnpm --filter site dev"
@@ -13,6 +13,7 @@ import yaml from 'js-yaml'
13
13
  import Handlebars from 'handlebars'
14
14
  import { copyTemplateDirectory, enumerateTemplateOutputs, registerVersions } from '../templates/processor.js'
15
15
  import { getVersionsForTemplates, getCliVersion } from '../versions.js'
16
+ import { writeJsonPreservingStyleAsync } from './json-file.js'
16
17
 
17
18
  const __dirname = dirname(fileURLToPath(import.meta.url))
18
19
  const TEMPLATES_DIR = join(__dirname, '..', '..', 'templates')
@@ -380,12 +381,13 @@ function resolveDependencyVersion(rawValue) {
380
381
  */
381
382
  export async function mergeTemplateDependencies(packageJsonPath, deps) {
382
383
  if (!deps || Object.keys(deps).length === 0) return
383
- const pkg = JSON.parse(await fs.readFile(packageJsonPath, 'utf-8'))
384
+ const src = await fs.readFile(packageJsonPath, 'utf-8')
385
+ const pkg = JSON.parse(src)
384
386
  if (!pkg.dependencies) pkg.dependencies = {}
385
387
  for (const [name, version] of Object.entries(deps)) {
386
388
  if (!pkg.dependencies[name] && !pkg.devDependencies?.[name]) {
387
389
  pkg.dependencies[name] = resolveDependencyVersion(version)
388
390
  }
389
391
  }
390
- await fs.writeFile(packageJsonPath, JSON.stringify(pkg, null, 2) + '\n')
392
+ await writeJsonPreservingStyleAsync(packageJsonPath, pkg, src)
391
393
  }
@@ -9,6 +9,7 @@
9
9
  import { homedir } from 'node:os'
10
10
  import { existsSync, readFileSync, mkdirSync, writeFileSync } from 'node:fs'
11
11
  import { join } from 'node:path'
12
+ import { detectGlobalCliPm, globalCliUpdateCmd } from './pm.js'
12
13
 
13
14
  const CHECK_INTERVAL = 24 * 60 * 60 * 1000 // 1 day
14
15
  const STATE_DIR = join(homedir(), '.uniweb')
@@ -62,14 +63,15 @@ function printNotification(current, latest, tone = 'soft') {
62
63
  const cyan = '\x1b[36m'
63
64
  const dim = '\x1b[2m'
64
65
  const reset = '\x1b[0m'
66
+ const updateCmd = globalCliUpdateCmd(detectGlobalCliPm())
65
67
  console.error('')
66
68
  if (tone === 'eager') {
67
69
  console.error(`${yellow}Heads up:${reset} this CLI is ${dim}${current}${reset}; latest is ${cyan}${latest}${reset}.`)
68
- console.error(`${dim}Templates ship with the CLI — consider updating first:${reset} npm i -g uniweb`)
70
+ console.error(`${dim}Templates ship with the CLI — consider updating first:${reset} ${updateCmd}`)
69
71
  console.error(`${dim}Or run a one-shot fresh:${reset} npx uniweb@latest <command>`)
70
72
  } else {
71
73
  console.error(`${yellow}Update available:${reset} ${dim}${current}${reset} → ${cyan}${latest}${reset}`)
72
- console.error(`${dim}Run${reset} npm i -g uniweb ${dim}to update${reset}`)
74
+ console.error(`${dim}Run${reset} ${updateCmd} ${dim}to update the CLI${reset}`)
73
75
  }
74
76
  }
75
77
 
package/src/versions.js CHANGED
@@ -76,9 +76,16 @@ function getFrameworkRoot() {
76
76
 
77
77
  /**
78
78
  * Read the current on-disk version of a specific `@uniweb/*` package by
79
- * looking up `framework/<last-segment>/package.json`. Returns a caret
80
- * range string like `^0.6.0`, or null if the package isn't present on
81
- * disk (i.e. the CLI is running from npm, not from the workspace).
79
+ * looking up `framework/<last-segment>/package.json`. Returns the version
80
+ * string verbatim (e.g. `0.7.11`), or null if the package isn't present
81
+ * on disk (i.e. the CLI is running from npm, not from the workspace).
82
+ *
83
+ * Returns the version *exact*, not as a caret range, to match the shape
84
+ * published-CLI direct deps take after pnpm resolves `workspace:*` at
85
+ * publish time. Both modes converge on identical specs in
86
+ * `getResolvedVersions()`, so `uniweb update` and the scaffolder produce
87
+ * the same `package.json` whether the CLI ran from npm or from this
88
+ * monorepo.
82
89
  */
83
90
  function readWorkspaceVersion(packageName) {
84
91
  const root = getFrameworkRoot()
@@ -90,7 +97,7 @@ function readWorkspaceVersion(packageName) {
90
97
  try {
91
98
  const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'))
92
99
  if (pkg.name === packageName && pkg.version) {
93
- return `^${pkg.version}`
100
+ return pkg.version
94
101
  }
95
102
  } catch {}
96
103
  return null