uniweb 0.12.19 → 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.
@@ -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
  }