uniweb 1.0.0 → 1.0.2

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.
Files changed (129) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +532 -3
  3. package/package.json +60 -11
  4. package/partials/adding-pages.hbs +77 -0
  5. package/partials/agents.md +3000 -0
  6. package/partials/ai-assistance.hbs +28 -0
  7. package/partials/components-docs.hbs +13 -0
  8. package/partials/config-reference.hbs +66 -0
  9. package/partials/deployment.hbs +13 -0
  10. package/partials/learn-more.hbs +6 -0
  11. package/partials/quick-start.hbs +8 -0
  12. package/partials/search-docs.hbs +110 -0
  13. package/src/backend/asset-download.js +222 -0
  14. package/src/backend/client.js +986 -0
  15. package/src/backend/foundation-bring-along.js +692 -0
  16. package/src/backend/payment-handoff.js +362 -0
  17. package/src/backend/service-request.js +210 -0
  18. package/src/backend/site-media.js +255 -0
  19. package/src/backend/site-preview.js +77 -0
  20. package/src/backend/site-sync.js +2515 -0
  21. package/src/backend/workspace.js +234 -0
  22. package/src/commands/add.js +1900 -0
  23. package/src/commands/build.js +1214 -0
  24. package/src/commands/clone.js +466 -0
  25. package/src/commands/content.js +202 -0
  26. package/src/commands/deploy.js +537 -0
  27. package/src/commands/dev.js +202 -0
  28. package/src/commands/docs.js +530 -0
  29. package/src/commands/doctor.js +1982 -0
  30. package/src/commands/export.js +116 -0
  31. package/src/commands/families.js +82 -0
  32. package/src/commands/forget.js +197 -0
  33. package/src/commands/handoff.js +21 -0
  34. package/src/commands/i18n.js +1707 -0
  35. package/src/commands/inspect.js +375 -0
  36. package/src/commands/invite.js +24 -0
  37. package/src/commands/org.js +96 -0
  38. package/src/commands/publish.js +1087 -0
  39. package/src/commands/pull.js +1257 -0
  40. package/src/commands/push.js +663 -0
  41. package/src/commands/refresh.js +212 -0
  42. package/src/commands/register.js +1092 -0
  43. package/src/commands/rename.js +647 -0
  44. package/src/commands/site.js +358 -0
  45. package/src/commands/snapshot.js +461 -0
  46. package/src/commands/starter.js +206 -0
  47. package/src/commands/status.js +302 -0
  48. package/src/commands/sync.js +93 -0
  49. package/src/commands/template.js +107 -0
  50. package/src/commands/update.js +887 -0
  51. package/src/commands/validate.js +376 -0
  52. package/src/framework-index.json +248 -0
  53. package/src/index.js +2241 -0
  54. package/src/templates/fetchers/github.js +113 -0
  55. package/src/templates/fetchers/npm.js +85 -0
  56. package/src/templates/fetchers/release.js +92 -0
  57. package/src/templates/index.js +190 -0
  58. package/src/templates/processor.js +333 -0
  59. package/src/templates/resolver.js +232 -0
  60. package/src/templates/validator.js +155 -0
  61. package/src/utils/agents-stamp.js +110 -0
  62. package/src/utils/args.js +126 -0
  63. package/src/utils/asset-upload.js +270 -0
  64. package/src/utils/bytes.js +30 -0
  65. package/src/utils/code-upload.js +394 -0
  66. package/src/utils/config.js +417 -0
  67. package/src/utils/conformance.js +274 -0
  68. package/src/utils/dep-survey.js +176 -0
  69. package/src/utils/destination-prompt.js +146 -0
  70. package/src/utils/discover.js +75 -0
  71. package/src/utils/env.js +22 -0
  72. package/src/utils/exit.js +30 -0
  73. package/src/utils/fetch-retry.js +109 -0
  74. package/src/utils/flag-guard.js +246 -0
  75. package/src/utils/foundation-name.js +179 -0
  76. package/src/utils/git.js +342 -0
  77. package/src/utils/host-prompt.js +51 -0
  78. package/src/utils/install-integrity.js +210 -0
  79. package/src/utils/interactive.js +118 -0
  80. package/src/utils/json-file.js +72 -0
  81. package/src/utils/names.js +91 -0
  82. package/src/utils/placement.js +151 -0
  83. package/src/utils/pm.js +165 -0
  84. package/src/utils/preview.js +20 -0
  85. package/src/utils/pull-written.js +132 -0
  86. package/src/utils/records-guard.js +88 -0
  87. package/src/utils/registry-auth.js +994 -0
  88. package/src/utils/registry-orgs.js +290 -0
  89. package/src/utils/scaffold.js +483 -0
  90. package/src/utils/schemaless-report.js +30 -0
  91. package/src/utils/semver-precedence.js +80 -0
  92. package/src/utils/session-file.js +95 -0
  93. package/src/utils/site-content-refs.js +23 -0
  94. package/src/utils/site-copies.js +123 -0
  95. package/src/utils/site-data-upload.js +302 -0
  96. package/src/utils/site-identity.js +137 -0
  97. package/src/utils/snapshot-settings.js +112 -0
  98. package/src/utils/update-check.js +302 -0
  99. package/src/utils/uwx-read.js +157 -0
  100. package/src/utils/workspace.js +223 -0
  101. package/src/versions.js +467 -0
  102. package/starter/foundation/main.js +50 -0
  103. package/starter/foundation/sections/Section/index.jsx +95 -0
  104. package/starter/foundation/sections/Section/meta.js +44 -0
  105. package/starter/site/pages/about/1-about.md.hbs +13 -0
  106. package/starter/site/pages/about/page.yml +3 -0
  107. package/starter/site/pages/home/1-welcome.md.hbs +14 -0
  108. package/starter/site/pages/home/page.yml +3 -0
  109. package/templates/foundation/_gitignore +5 -0
  110. package/templates/foundation/components/.gitkeep +0 -0
  111. package/templates/foundation/main.js.hbs +33 -0
  112. package/templates/foundation/package.json.hbs +51 -0
  113. package/templates/foundation/sections/.gitkeep +0 -0
  114. package/templates/foundation/styles.css +6 -0
  115. package/templates/foundation/vite.config.js +3 -0
  116. package/templates/site/_gitignore +12 -0
  117. package/templates/site/entry.js +7 -0
  118. package/templates/site/index.html.hbs +13 -0
  119. package/templates/site/package.json.hbs +27 -0
  120. package/templates/site/public/favicon.svg +7 -0
  121. package/templates/site/site.yml.hbs +91 -0
  122. package/templates/site/theme.yml +99 -0
  123. package/templates/site/vite.config.js +7 -0
  124. package/templates/workspace/AGENTS.md.hbs +2 -0
  125. package/templates/workspace/README.md.hbs +87 -0
  126. package/templates/workspace/_gitignore +29 -0
  127. package/templates/workspace/_vscode/settings.json +6 -0
  128. package/templates/workspace/package.json.hbs +20 -0
  129. package/templates/workspace/pnpm-workspace.yaml.hbs +26 -0
@@ -0,0 +1,887 @@
1
+ /**
2
+ * uniweb update — Reconcile a Uniweb workspace's state with the CLI that's
3
+ * running this command. One job, two convergence steps:
4
+ *
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.
10
+ *
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.
27
+ *
28
+ * Flags:
29
+ * --agents-only Only refresh AGENTS.md (skip the deps step).
30
+ * --deps-only Only align deps (skip the AGENTS.md step).
31
+ * --no-agents Skip the AGENTS.md step.
32
+ * --no-deps Skip the deps-alignment step.
33
+ * --dry-run Print survey + would-be writes; no mutations.
34
+ * --verbose List every surveyed dep, including aligned ones
35
+ * (the default collapses those to a count).
36
+ * --allow-mismatch Refresh AGENTS.md even if declared deps lag.
37
+ * --yes Don't prompt — apply edits and run the install.
38
+ * --non-interactive Auto-detected; prints the plan, never mutates
39
+ * (combine with --yes to apply non-interactively).
40
+ */
41
+
42
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs'
43
+ import { join } from 'node:path'
44
+ import { spawn } from 'node:child_process'
45
+ import prompts from 'prompts'
46
+
47
+ import { findWorkspaceRoot } from '../utils/workspace.js'
48
+ import {
49
+ readAgentsVersion,
50
+ refreshAgentsContent,
51
+ PROJECT_NOTES_START,
52
+ PROJECT_NOTES_END
53
+ } from '../utils/agents-stamp.js'
54
+ import { getCliVersion } from '../versions.js'
55
+ import { isNonInteractive } from '../utils/interactive.js'
56
+ import {
57
+ detectWorkspacePm,
58
+ installCmd,
59
+ detectGlobalCliPm,
60
+ globalCliUpdateCmd
61
+ } from '../utils/pm.js'
62
+ import { writeJsonPreservingStyle } from '../utils/json-file.js'
63
+ import { surveyWorkspaceDeps, compareSemver } from '../utils/dep-survey.js'
64
+ import {
65
+ checkWorkspaceInstall,
66
+ readDeclaredFoundation
67
+ } from '../utils/install-integrity.js'
68
+ import { getLatestVersion } from '../utils/update-check.js'
69
+
70
+ const colors = {
71
+ reset: '\x1b[0m',
72
+ bright: '\x1b[1m',
73
+ dim: '\x1b[2m',
74
+ red: '\x1b[31m',
75
+ green: '\x1b[32m',
76
+ yellow: '\x1b[33m',
77
+ cyan: '\x1b[36m'
78
+ }
79
+
80
+ const success = (msg) => console.log(`${colors.green}✓${colors.reset} ${msg}`)
81
+ const warn = (msg) => console.log(`${colors.yellow}⚠${colors.reset} ${msg}`)
82
+ const error = (msg) => console.log(`${colors.red}✗${colors.reset} ${msg}`)
83
+ const info = (msg) => console.log(`${colors.cyan}ℹ${colors.reset} ${msg}`)
84
+ const log = console.log
85
+
86
+ /**
87
+ * Detect whether this CLI is running from a global install — when global,
88
+ * process.argv[1] points outside any node_modules. Mirrors
89
+ * index.js::isGlobalInstall.
90
+ */
91
+ function isGlobalInstall() {
92
+ const scriptPath = process.argv[1]
93
+ if (!scriptPath) return false
94
+ return (
95
+ !scriptPath.split('/').includes('node_modules') &&
96
+ !scriptPath.split('\\').includes('node_modules')
97
+ )
98
+ }
99
+
100
+ /**
101
+ * Detect whether this CLI was launched via `npx uniweb …` (or `npm exec`).
102
+ * npx materializes the package under `…/_npx/<hash>/node_modules/uniweb/…`,
103
+ * which `isGlobalInstall()` can't distinguish from a real project-local
104
+ * dependency (both contain `node_modules`).
105
+ */
106
+ function isNpxInvocation() {
107
+ const p = (process.argv[1] || '').replace(/\\/g, '/')
108
+ return p.includes('/_npx/')
109
+ }
110
+
111
+ /**
112
+ * Find a *Uniweb* workspace root from cwd. Stricter than findWorkspaceRoot
113
+ * — also requires that the workspace's root package.json declares uniweb
114
+ * as a dep or devDep. Otherwise we'd write AGENTS.md and edit package.json
115
+ * files in unrelated monorepos.
116
+ */
117
+ function findUniwebWorkspace(cwd) {
118
+ const workspaceDir = findWorkspaceRoot(cwd)
119
+ if (!workspaceDir) return null
120
+ const pkgPath = join(workspaceDir, 'package.json')
121
+ if (!existsSync(pkgPath)) return null
122
+ try {
123
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'))
124
+ const hasUniwebDep = !!(
125
+ pkg.devDependencies?.uniweb || pkg.dependencies?.uniweb
126
+ )
127
+ return hasUniwebDep ? workspaceDir : null
128
+ } catch {
129
+ return null
130
+ }
131
+ }
132
+
133
+ /**
134
+ * Trailing advisory: a newer CLI exists, and this run could not have used it.
135
+ *
136
+ * Placed AFTER the work, not before, because it is a what-to-do-next — the
137
+ * same reason update-check calls its post-command notice the 'soft' tone.
138
+ * It also has to be the last thing on screen when it fires: a run that ends
139
+ * on `✓ deps aligned` + `✓ AGENTS.md up to date` reads as "all good", and
140
+ * those ticks are measured against THIS CLI's matrix. True, and beside the
141
+ * point, when the CLI itself is two releases back.
142
+ *
143
+ * The remedy is per-provenance because the wrong one is useless advice:
144
+ * - project-local — the version is pinned by the project's package.json,
145
+ * so a global install is irrelevant. `npx uniweb@latest update` both
146
+ * aligns the deps AND rewrites that pin, so it is self-healing; say so,
147
+ * or the reader assumes they'll be back here next release.
148
+ * - global — updating the global install is the durable fix.
149
+ * - npx — skipped entirely. The version was chosen explicitly on the
150
+ * command line, and the lead-in already named it.
151
+ *
152
+ * Exported for tests: this notice must fire when behind and stay silent
153
+ * when current, and it's the half of the command that had no coverage.
154
+ *
155
+ * @returns {boolean} true if a notice was printed.
156
+ */
157
+ export function printStaleCliNotice({
158
+ cliVersion,
159
+ latest,
160
+ isNpx,
161
+ isGlobal,
162
+ globalPm
163
+ }) {
164
+ if (isNpx) return false
165
+ if (!latest || compareSemver(latest, cliVersion) <= 0) return false
166
+
167
+ log('')
168
+ log(
169
+ `${colors.yellow}⚠${colors.reset} ${colors.bright}A newer uniweb is available:${colors.reset} ${colors.dim}v${cliVersion}${colors.reset} → ${colors.cyan}v${latest}${colors.reset}`
170
+ )
171
+ if (isGlobal) {
172
+ log(
173
+ `${colors.dim}This run aligned the project to v${cliVersion}'s matrix. To update the CLI:${colors.reset} ${colors.cyan}${globalCliUpdateCmd(globalPm)}${colors.reset}`
174
+ )
175
+ log(
176
+ `${colors.dim}Or align to the latest release without a global install:${colors.reset} ${colors.cyan}npx uniweb@latest update${colors.reset}`
177
+ )
178
+ } else {
179
+ log(
180
+ `${colors.dim}This run aligned the project to v${cliVersion}'s matrix — the version your project pins.${colors.reset}`
181
+ )
182
+ log(
183
+ `${colors.dim}To move to v${latest}:${colors.reset} ${colors.cyan}npx uniweb@latest update${colors.reset} ${colors.dim}(updates the pin too).${colors.reset}`
184
+ )
185
+ }
186
+ log('')
187
+ return true
188
+ }
189
+
190
+ /** Run a shell command, inheriting stdio. Resolves with the exit code. */
191
+ function runCommand(cmd, cwd) {
192
+ return new Promise((resolve) => {
193
+ const [bin, ...rest] = cmd.split(' ')
194
+ const child = spawn(bin, rest, { stdio: 'inherit', cwd })
195
+ child.on('close', (code) => resolve(code ?? 0))
196
+ child.on('error', () => resolve(1))
197
+ })
198
+ }
199
+
200
+ /**
201
+ * Print the survey report grouped by package directory.
202
+ *
203
+ * Only rows that need attention get a line. An aligned dep renders as
204
+ * `0.9.37 → 0.9.37 aligned` — an identity mapping that says nothing, and
205
+ * on a typical workspace there are six of them. That is the whole screen
206
+ * on the command's most common outcome (a no-op), and it buries the one
207
+ * line that isn't noise. Aligned deps collapse to a count instead.
208
+ *
209
+ * `--verbose` restores the full table for anyone auditing the matrix.
210
+ *
211
+ * Exported for tests: the collapse is the point of the function, and a
212
+ * regression here is invisible — the command still works, it just stops
213
+ * being readable.
214
+ */
215
+ export function printSurvey(
216
+ report,
217
+ cliVersion,
218
+ agentsVersion,
219
+ { verbose = false } = {}
220
+ ) {
221
+ log('')
222
+ log(`${colors.bright}uniweb CLI:${colors.reset} v${cliVersion}`)
223
+ log(
224
+ `${colors.bright}AGENTS.md stamp:${colors.reset} ${agentsVersion ? 'v' + agentsVersion : colors.dim + '(none)' + colors.reset}`
225
+ )
226
+ log('')
227
+
228
+ if (report.rows.length === 0) {
229
+ log(
230
+ `${colors.dim}No @uniweb/* deps found in workspace package.json files.${colors.reset}`
231
+ )
232
+ log('')
233
+ return
234
+ }
235
+
236
+ const needsAttention = report.rows.filter((r) => r.status !== 'aligned')
237
+ const shown = verbose ? report.rows : needsAttention
238
+ const alignedCount = report.rows.length - needsAttention.length
239
+
240
+ // Everything aligned and not auditing: step 1's success line says so in
241
+ // one sentence. A header over an empty table is worse than no header.
242
+ if (shown.length === 0) return
243
+
244
+ const byDir = {}
245
+ for (const row of shown) {
246
+ if (!byDir[row.relDir]) byDir[row.relDir] = []
247
+ byDir[row.relDir].push(row)
248
+ }
249
+
250
+ log(`${colors.bright}Workspace deps (declared):${colors.reset}`)
251
+ for (const [dir, dirRows] of Object.entries(byDir)) {
252
+ log(` ${colors.dim}${dir}/${colors.reset}`)
253
+ const maxName = Math.max(...dirRows.map((r) => r.name.length))
254
+ for (const row of dirRows) {
255
+ const padding = ' '.repeat(maxName - row.name.length)
256
+ let icon, statusText
257
+ if (row.status === 'aligned') {
258
+ icon = `${colors.green}✓${colors.reset}`
259
+ statusText = `${colors.dim}aligned${colors.reset}`
260
+ } else if (row.status === 'behind') {
261
+ icon = `${colors.yellow}✗${colors.reset}`
262
+ // ⛔ `behind` alone was the whole message, in one colour, for every
263
+ // distance. The minor slot is where our breaking changes live, in 1.x as in
264
+ // 0.x (`publish.js` derives it from a breaking marker and nothing else), so
265
+ // `^0.14.1 → ^0.16.0` is two releases a consumer must act on and
266
+ // `^0.15.0 → ^0.15.2` is not — and the table said the same thing about
267
+ // both. Naming the class is the difference between a list and a signal.
268
+ statusText = row.breaking
269
+ ? `${colors.red}${row.bump} · BREAKING${colors.reset}`
270
+ : `${colors.yellow}${row.bump}${colors.reset}`
271
+ } else {
272
+ icon = `${colors.cyan}↑${colors.reset}`
273
+ statusText = `${colors.cyan}ahead of CLI${colors.reset}`
274
+ }
275
+ log(
276
+ ` ${icon} ${row.name}${padding} ${row.current.padEnd(10)} → ${row.target.padEnd(10)} ${statusText}`
277
+ )
278
+ }
279
+ }
280
+ if (!verbose && alignedCount > 0) {
281
+ log(
282
+ ` ${colors.dim}(${alignedCount} other${alignedCount === 1 ? '' : 's'} already aligned — ${colors.reset}${colors.cyan}--verbose${colors.reset}${colors.dim} to list)${colors.reset}`
283
+ )
284
+ }
285
+
286
+ // ⭐ **A summary, because the table scrolls and `--yes` does not stop.** The
287
+ // per-row label above is invisible to the case that matters most: a CI or an
288
+ // agent running `update --yes`, where nobody reads a table and the only
289
+ // artifact is a log. This block names the packages, so "what did that
290
+ // upgrade cross?" is answerable afterwards from the log alone.
291
+ //
292
+ // ⚖️ It reports; it does not gate. Crossing a minor IS the ordinary way
293
+ // to take a Uniweb update — gating it would gate nearly every real upgrade
294
+ // and make the verb useless. The decision stays the operator's; what changed
295
+ // is that they can now make it knowingly.
296
+ const breakingRows = report.rows.filter((r) => r.breaking)
297
+ if (breakingRows.length > 0) {
298
+ const names = [...new Set(breakingRows.map((r) => r.name))]
299
+ log('')
300
+ log(
301
+ `${colors.red}⚠${colors.reset} ${colors.bright}${names.length} package${names.length === 1 ? '' : 's'} cross${names.length === 1 ? 'es' : ''} a breaking boundary:${colors.reset}`
302
+ )
303
+ for (const name of names) {
304
+ const r = breakingRows.find((x) => x.name === name)
305
+ log(` ${colors.red}${name}${colors.reset} ${r.current} → ${r.target} ${colors.dim}(${r.bump})${colors.reset}`)
306
+ }
307
+ log(
308
+ ` ${colors.dim}A minor version is where Uniweb's breaking changes go, so these are releases${colors.reset}`
309
+ )
310
+ log(
311
+ ` ${colors.dim}you may need to act on. Read their changelogs before shipping.${colors.reset}`
312
+ )
313
+ }
314
+ log('')
315
+ }
316
+
317
+ /**
318
+ * Apply the CLI's matrix to workspace package.json files — but only to
319
+ * deps that *lag* the matrix (survey status `behind`). Deps that are
320
+ * `ahead` are left alone: `update` never downgrades. Indentation and the
321
+ * trailing newline of each file are preserved (a one-key bump shouldn't
322
+ * reflow the whole file). Returns the list of paths that actually changed.
323
+ */
324
+ function applyDepUpdates(workspaceDir, surveyRows, dryRun) {
325
+ const behind = surveyRows.filter((r) => r.status === 'behind')
326
+ const byDir = {}
327
+ for (const row of behind) {
328
+ const dir = row.relDir === '(root)' ? '' : row.relDir
329
+ if (!byDir[dir]) byDir[dir] = []
330
+ byDir[dir].push(row)
331
+ }
332
+
333
+ const edited = []
334
+ for (const [relDir, dirRows] of Object.entries(byDir)) {
335
+ const pkgDir = relDir ? join(workspaceDir, relDir) : workspaceDir
336
+ const pkgPath = join(pkgDir, 'package.json')
337
+ if (!existsSync(pkgPath)) continue
338
+ const original = readFileSync(pkgPath, 'utf8')
339
+ let pkg
340
+ try {
341
+ pkg = JSON.parse(original)
342
+ } catch {
343
+ continue
344
+ }
345
+
346
+ let changed = false
347
+ for (const row of dirRows) {
348
+ const section = pkg[row.section]
349
+ if (
350
+ section &&
351
+ section[row.name] !== undefined &&
352
+ section[row.name] !== row.target
353
+ ) {
354
+ section[row.name] = row.target
355
+ changed = true
356
+ }
357
+ }
358
+
359
+ if (changed) {
360
+ edited.push(pkgPath)
361
+ if (!dryRun) writeJsonPreservingStyle(pkgPath, pkg, original)
362
+ }
363
+ }
364
+
365
+ return edited
366
+ }
367
+
368
+ function relativize(path, root) {
369
+ return path.startsWith(root) ? path.slice(root.length + 1) : path
370
+ }
371
+
372
+ export async function update(args = []) {
373
+ const agentsOnly = args.includes('--agents-only')
374
+ const depsOnly = args.includes('--deps-only')
375
+ const skipAgents = args.includes('--no-agents') || depsOnly
376
+ const skipDeps = args.includes('--no-deps') || agentsOnly
377
+ const dryRun = args.includes('--dry-run')
378
+ const allowMismatch = args.includes('--allow-mismatch')
379
+ const verbose = args.includes('--verbose')
380
+ const hasYes = args.includes('--yes')
381
+ const nonInteractive = isNonInteractive(args)
382
+ const isGlobal = isGlobalInstall()
383
+ const isNpx = isNpxInvocation()
384
+ const workspaceDir = findUniwebWorkspace(process.cwd())
385
+ const inProject = !!workspaceDir
386
+ const cliVersion = getCliVersion()
387
+
388
+ if ((agentsOnly || depsOnly) && !inProject) {
389
+ error(
390
+ `${agentsOnly ? '--agents-only' : '--deps-only'} requires a Uniweb project (no \`uniweb\` dep in the workspace root).`
391
+ )
392
+ log(
393
+ `${colors.dim}Run this command from inside a project created by${colors.reset} ${colors.cyan}uniweb create${colors.reset}${colors.dim}.${colors.reset}`
394
+ )
395
+ process.exit(1)
396
+ }
397
+
398
+ // ── Survey first (always, when in a Uniweb project) ──────────────
399
+ let survey = null
400
+ let agentsVersion = null
401
+ if (inProject) {
402
+ survey = await surveyWorkspaceDeps(workspaceDir)
403
+ agentsVersion = readAgentsVersion(join(workspaceDir, 'AGENTS.md'))
404
+ printSurvey(survey, cliVersion, agentsVersion, { verbose })
405
+ }
406
+
407
+ // ── This command reconciles the *project*, not the CLI ───────────
408
+ // Which CLI is running is a lead-in — it belongs before the work. Whether
409
+ // that CLI is STALE is a what-to-do-next, and it is printed after the work
410
+ // by printStaleCliNotice() so it lands last instead of under two green
411
+ // ticks. The lookup happens here because the check must run on every
412
+ // provenance, not just a global install.
413
+ //
414
+ // Why the project-local path needs it MORE than the global one: a global
415
+ // CLI also gets the general notifier in index.js, but that is gated on
416
+ // `if (global)` — so a project-local run gets no staleness signal from
417
+ // anywhere. That is exactly the case where the user cannot work it out
418
+ // themselves, because the project pins the version. It was the quiet path
419
+ // and it should have been the loud one.
420
+ let installPm = inProject ? detectWorkspacePm(workspaceDir) : null
421
+ const globalPm = isGlobal ? detectGlobalCliPm() : null
422
+ // Non-TTY reads cache only: scripted runs stay fast and offline-safe, the
423
+ // same convention `--version` follows in index.js. Skipped entirely when
424
+ // there is nothing to reconcile — the notice is never reached from those
425
+ // paths, and looking it up anyway would spend a network call on nothing.
426
+ const latestCli =
427
+ isNpx || !inProject
428
+ ? null
429
+ : await getLatestVersion({ allowNetwork: !!process.stdout.isTTY })
430
+
431
+ if (isNpx) {
432
+ log(
433
+ `${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}`
434
+ )
435
+ log(
436
+ `${colors.dim}(To install the CLI:${colors.reset} ${colors.cyan}npm i -g uniweb${colors.reset}${colors.dim}.)${colors.reset}`
437
+ )
438
+ log('')
439
+ } else if (!isGlobal) {
440
+ // Project-local copy (lives in this project's node_modules).
441
+ log(
442
+ `${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}`
443
+ )
444
+ log('')
445
+ }
446
+
447
+ if (!inProject) {
448
+ log(
449
+ `${colors.dim}Not inside a Uniweb project — nothing to reconcile.${colors.reset}`
450
+ )
451
+ log(
452
+ `${colors.dim}Run this from a project created by${colors.reset} ${colors.cyan}uniweb create${colors.reset}${colors.dim}.${colors.reset}`
453
+ )
454
+ log('')
455
+ return
456
+ }
457
+
458
+ // ── Step 1: Deps alignment ───────────────────────────────────────
459
+ let depsEdited = false // package.json files were rewritten
460
+ let installRan = false // `<pm> install` ran and succeeded
461
+ let editedPaths = []
462
+
463
+ if (!skipDeps && survey) {
464
+ if (!survey.anyDrift) {
465
+ // The count carries the work the collapsed table no longer shows.
466
+ success(
467
+ `Workspace deps are aligned with the CLI (${survey.rows.length} checked).`
468
+ )
469
+ if (survey.anyAhead) {
470
+ log(
471
+ `${colors.dim}(Some deps are ahead of the CLI's bundled matrix — left untouched.)${colors.reset}`
472
+ )
473
+ }
474
+ if (!existsSync(join(workspaceDir, 'node_modules'))) {
475
+ warn(
476
+ `No ${colors.bright}node_modules${colors.reset} in the workspace — run ${colors.cyan}${installCmd(installPm || 'pnpm')}${colors.reset} to install.`
477
+ )
478
+ }
479
+ log('')
480
+ } else {
481
+ log(
482
+ `${colors.yellow}⚠${colors.reset} Some workspace deps lag the CLI's bundled matrix.`
483
+ )
484
+ log('')
485
+
486
+ // Decide whether to write the package.json edits.
487
+ let proceed
488
+ if (dryRun) {
489
+ proceed = false
490
+ } else if (hasYes) {
491
+ proceed = true
492
+ } else if (nonInteractive) {
493
+ proceed = false
494
+ info(
495
+ `${colors.dim}Non-interactive — printing the alignment plan; not editing files.${colors.reset}`
496
+ )
497
+ log(
498
+ `${colors.dim}To apply, re-run with${colors.reset} ${colors.cyan}--yes${colors.reset}${colors.dim}, or align manually:${colors.reset}`
499
+ )
500
+ log(` ${colors.cyan}pnpm update "@uniweb/*" uniweb -r${colors.reset}`)
501
+ log('')
502
+ } else {
503
+ const { go } = await prompts({
504
+ type: 'confirm',
505
+ name: 'go',
506
+ message: `Edit workspace package.json files to align with v${cliVersion}?`,
507
+ initial: true
508
+ })
509
+ proceed = !!go
510
+ }
511
+
512
+ if (dryRun) {
513
+ const wouldEdit = applyDepUpdates(workspaceDir, survey.rows, true)
514
+ if (wouldEdit.length > 0) {
515
+ info('Dry-run: would update package.json in:')
516
+ for (const path of wouldEdit)
517
+ log(
518
+ ` ${colors.dim}- ${relativize(path, workspaceDir)}${colors.reset}`
519
+ )
520
+ if (installPm) {
521
+ log(
522
+ `${colors.dim}Then would run:${colors.reset} ${colors.cyan}${installCmd(installPm)}${colors.reset}`
523
+ )
524
+ } else {
525
+ log(
526
+ `${colors.dim}Then would prompt for an install command (no lockfile detected).${colors.reset}`
527
+ )
528
+ }
529
+ log('')
530
+ }
531
+ } else if (proceed) {
532
+ editedPaths = applyDepUpdates(workspaceDir, survey.rows, false)
533
+ depsEdited = editedPaths.length > 0
534
+ if (!depsEdited) {
535
+ info('No package.json files needed changes.')
536
+ log('')
537
+ } else {
538
+ for (const path of editedPaths)
539
+ success(`Updated ${relativize(path, workspaceDir)}`)
540
+ log('')
541
+
542
+ // Resolve the workspace PM (lockfile-driven). If absent, ask.
543
+ if (!installPm) {
544
+ if (nonInteractive) {
545
+ warn(
546
+ 'No lockfile in workspace root — cannot pick an install command for you.'
547
+ )
548
+ log(
549
+ `${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}`
550
+ )
551
+ log('')
552
+ } else {
553
+ const { picked } = await prompts({
554
+ type: 'select',
555
+ name: 'picked',
556
+ message:
557
+ 'No lockfile found. Which package manager does this workspace use?',
558
+ choices: [
559
+ { title: 'pnpm', value: 'pnpm' },
560
+ { title: 'yarn', value: 'yarn' },
561
+ { title: 'npm', value: 'npm' },
562
+ { title: "skip — I'll install manually", value: null }
563
+ ]
564
+ })
565
+ installPm = picked || null
566
+ }
567
+ }
568
+
569
+ if (installPm) {
570
+ const cmd = installCmd(installPm)
571
+ let runInstall
572
+ if (hasYes) {
573
+ runInstall = true
574
+ } else if (nonInteractive) {
575
+ runInstall = false
576
+ info(
577
+ `${colors.dim}Non-interactive — run the install yourself:${colors.reset} ${colors.cyan}${cmd}${colors.reset}`
578
+ )
579
+ log('')
580
+ } else {
581
+ const { go } = await prompts({
582
+ type: 'confirm',
583
+ name: 'go',
584
+ message: `Run \`${cmd}\` now?`,
585
+ initial: true
586
+ })
587
+ runInstall = !!go
588
+ }
589
+
590
+ if (runInstall) {
591
+ const code = await runCommand(cmd, workspaceDir)
592
+ if (code === 0) {
593
+ installRan = true
594
+ success('Install complete.')
595
+ log('')
596
+ } else {
597
+ error(
598
+ `Install failed (exit ${code}). package.json edits are intact.`
599
+ )
600
+ const editedRel = editedPaths
601
+ .map((p) => relativize(p, workspaceDir))
602
+ .join(' ')
603
+ log(
604
+ `${colors.dim}To revert:${colors.reset} ${colors.cyan}git checkout -- ${editedRel}${colors.reset}`
605
+ )
606
+ log(
607
+ `${colors.dim}To retry: ${colors.reset} ${colors.cyan}${cmd}${colors.reset}`
608
+ )
609
+ log('')
610
+ process.exit(code)
611
+ }
612
+ } else {
613
+ log(
614
+ `${colors.dim}Skipped install. Edits saved; run${colors.reset} ${colors.cyan}${cmd}${colors.reset} ${colors.dim}to apply them.${colors.reset}`
615
+ )
616
+ log('')
617
+ }
618
+ }
619
+ }
620
+ } else {
621
+ log(`${colors.dim}Skipped deps alignment.${colors.reset}`)
622
+ log('')
623
+ }
624
+ }
625
+ }
626
+
627
+ // ── Step 2: AGENTS.md ────────────────────────────────────────────
628
+ let agentsResult = null // 'created' | 'updated' | 'current' | 'skipped'
629
+
630
+ if (!skipAgents) {
631
+ agentsResult = await refreshAgents({
632
+ workspaceDir,
633
+ cliVersion,
634
+ allowMismatch,
635
+ dryRun,
636
+ hasYes,
637
+ nonInteractive,
638
+ agentsOnly,
639
+ depsEdited,
640
+ installRan,
641
+ installPm
642
+ })
643
+ }
644
+
645
+ // ── Verify the install actually took ─────────────────────────────
646
+ // An install that was interrupted, or that satisfied a `file:` foundation
647
+ // with a copy instead of a link, leaves a tree that no longer matches what
648
+ // was just written. Builds keep working — they read the workspace source —
649
+ // so the mismatch only shows up later, as a dev server serving stale code,
650
+ // by which point this command is nobody's first suspect. It is the most
651
+ // likely command to produce that state, so it is the right one to check.
652
+ if (!dryRun && installRan) {
653
+ await reportInstallIntegrity(workspaceDir)
654
+ }
655
+
656
+ // ── Closing summary ──────────────────────────────────────────────
657
+ if (
658
+ !dryRun &&
659
+ (depsEdited || agentsResult === 'created' || agentsResult === 'updated')
660
+ ) {
661
+ printSummary({
662
+ editedPaths,
663
+ depsEdited,
664
+ installRan,
665
+ installPm,
666
+ agentsResult,
667
+ cliVersion
668
+ })
669
+ }
670
+
671
+ // Last, deliberately — see printStaleCliNotice. Everything above reports
672
+ // against THIS CLI's matrix; if the CLI itself is behind, that is the note
673
+ // the reader should leave with.
674
+ printStaleCliNotice({
675
+ cliVersion,
676
+ latest: latestCli,
677
+ isNpx,
678
+ isGlobal,
679
+ globalPm
680
+ })
681
+ }
682
+
683
+ /**
684
+ * Report any drift between the workspace and the tree that was just installed.
685
+ */
686
+ async function reportInstallIntegrity(workspaceDir) {
687
+ try {
688
+ // Imported here, not at the top: discover.js reaches @uniweb/build, an
689
+ // optional peer that must stay off the CLI's startup path so `npx uniweb
690
+ // create` works in an empty directory. update.js is loaded at startup.
691
+ const { discoverSites, discoverFoundations } =
692
+ await import('../utils/discover.js')
693
+
694
+ const sites = (await discoverSites(workspaceDir)).map((s) => ({
695
+ name: s.name,
696
+ path: join(workspaceDir, s.path),
697
+ foundation: readDeclaredFoundation(join(workspaceDir, s.path))
698
+ }))
699
+ const foundations = (await discoverFoundations(workspaceDir)).map((f) => ({
700
+ name: f.name,
701
+ path: join(workspaceDir, f.path)
702
+ }))
703
+
704
+ const findings = checkWorkspaceInstall(sites, foundations, workspaceDir)
705
+ if (!findings.length) return
706
+
707
+ log('')
708
+ warn('The install finished, but dev will not resolve what is declared:')
709
+ for (const finding of findings) {
710
+ log(` ${finding.message}`)
711
+ }
712
+ log(
713
+ ` ${colors.dim}${findings[0].remedy}. \`uniweb doctor\` explains in full.${colors.reset}`
714
+ )
715
+ } catch {
716
+ // A post-flight check must never turn a successful update into a failure.
717
+ }
718
+ }
719
+
720
+ /**
721
+ * Step 2 — regenerate AGENTS.md from the CLI's bundled partial, stamped
722
+ * with the CLI version. Guarded: won't run if the deps were edited but
723
+ * not installed (the doc would describe code that isn't on disk yet), and
724
+ * won't run if declared deps still lag the CLI unless --allow-mismatch.
725
+ *
726
+ * @returns {'created'|'updated'|'current'|'skipped'} what happened.
727
+ */
728
+ async function refreshAgents(ctx) {
729
+ const {
730
+ workspaceDir,
731
+ cliVersion,
732
+ allowMismatch,
733
+ dryRun,
734
+ hasYes,
735
+ nonInteractive,
736
+ agentsOnly,
737
+ depsEdited,
738
+ installRan,
739
+ installPm
740
+ } = ctx
741
+
742
+ // Deps were rewritten but not installed → node_modules is now behind
743
+ // package.json. Refreshing the doc here would document features the
744
+ // installed code doesn't have. No override — run the install first.
745
+ if (depsEdited && !installRan) {
746
+ const cmd = installCmd(installPm || 'pnpm')
747
+ warn(
748
+ 'AGENTS.md refresh skipped: package.json was updated but not installed.'
749
+ )
750
+ log(
751
+ `${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}`
752
+ )
753
+ log('')
754
+ return 'skipped'
755
+ }
756
+
757
+ // Re-survey: deps may have just been edited+installed, which clears the
758
+ // declared-deps gate.
759
+ const finalSurvey = await surveyWorkspaceDeps(workspaceDir)
760
+ if (finalSurvey.anyDrift && !allowMismatch) {
761
+ warn('AGENTS.md refresh skipped: workspace deps still lag the CLI.')
762
+ log(
763
+ `${colors.dim}AGENTS.md from v${cliVersion} would document features not in your installed packages.${colors.reset}`
764
+ )
765
+ log(
766
+ `${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}`
767
+ )
768
+ log('')
769
+ if (agentsOnly) process.exit(1)
770
+ return 'skipped'
771
+ }
772
+
773
+ const agentsPath = join(workspaceDir, 'AGENTS.md')
774
+ const currentAgentsVersion = readAgentsVersion(agentsPath)
775
+ if (currentAgentsVersion === cliVersion) {
776
+ success(`AGENTS.md is already up to date (v${cliVersion}).`)
777
+ return 'current'
778
+ }
779
+
780
+ // The project's notes travel to the new file (`refreshAgentsContent`). Markers that do not pair
781
+ // up leave AGENTS.md as it is: where a block ends would be a guess, and a wrong one loses notes.
782
+ const refreshed = refreshAgentsContent(
783
+ existsSync(agentsPath) ? readFileSync(agentsPath, 'utf8') : null
784
+ )
785
+ if (refreshed.error) {
786
+ warn(`AGENTS.md left as it is: ${refreshed.error}.`)
787
+ log(
788
+ `${colors.dim}Your project notes are kept between ${PROJECT_NOTES_START} and ${PROJECT_NOTES_END}, each on a line of its own. Fix the markers, then re-run${colors.reset} ${colors.cyan}uniweb update${colors.reset}${colors.dim}.${colors.reset}`
789
+ )
790
+ log('')
791
+ if (agentsOnly) process.exit(1)
792
+ return 'skipped'
793
+ }
794
+ const keeping = refreshed.kept
795
+ ? `, keeping ${refreshed.kept === 1 ? 'its project notes' : `its ${refreshed.kept} project-notes blocks`}`
796
+ : ''
797
+
798
+ if (dryRun) {
799
+ info(
800
+ `Dry-run: would ${currentAgentsVersion ? `update AGENTS.md (v${currentAgentsVersion} → v${cliVersion})` : `create AGENTS.md (v${cliVersion})`}${keeping}.`
801
+ )
802
+ return 'skipped'
803
+ }
804
+
805
+ if (!hasYes && !nonInteractive && !agentsOnly) {
806
+ const action = currentAgentsVersion
807
+ ? `Update AGENTS.md (v${currentAgentsVersion} → v${cliVersion})?`
808
+ : `Create AGENTS.md (v${cliVersion})?`
809
+ const { yes } = await prompts({
810
+ type: 'confirm',
811
+ name: 'yes',
812
+ message: action,
813
+ initial: true
814
+ })
815
+ if (!yes) {
816
+ log(`${colors.dim}Skipped AGENTS.md.${colors.reset}`)
817
+ return 'skipped'
818
+ }
819
+ }
820
+
821
+ writeFileSync(agentsPath, refreshed.content)
822
+ if (currentAgentsVersion) {
823
+ success(`Updated AGENTS.md (v${currentAgentsVersion} → v${cliVersion})${keeping}.`)
824
+ return 'updated'
825
+ }
826
+ success(`Created AGENTS.md (v${cliVersion}).`)
827
+ return 'created'
828
+ }
829
+
830
+ /**
831
+ * Print a compact recap of what changed, plus a review hint.
832
+ *
833
+ * Exported for tests: the dev-server restart notice must fire exactly when the
834
+ * installed packages changed, and stay quiet otherwise (this runs on every
835
+ * `uniweb update`, including no-op ones).
836
+ */
837
+ export function printSummary({
838
+ editedPaths,
839
+ depsEdited,
840
+ installRan,
841
+ installPm,
842
+ agentsResult,
843
+ cliVersion
844
+ }) {
845
+ log(`${colors.bright}Summary${colors.reset}`)
846
+ if (depsEdited) {
847
+ log(
848
+ ` ${colors.green}✓${colors.reset} package.json updated in ${editedPaths.length} file${editedPaths.length === 1 ? '' : 's'}`
849
+ )
850
+ if (installRan) {
851
+ log(
852
+ ` ${colors.green}✓${colors.reset} ${installCmd(installPm || 'pnpm')} completed`
853
+ )
854
+ } else {
855
+ log(
856
+ ` ${colors.yellow}⚠${colors.reset} install NOT run — run ${colors.cyan}${installCmd(installPm || 'pnpm')}${colors.reset} to apply`
857
+ )
858
+ }
859
+ }
860
+ if (agentsResult === 'created')
861
+ log(` ${colors.green}✓${colors.reset} AGENTS.md created (v${cliVersion})`)
862
+ else if (agentsResult === 'updated')
863
+ log(` ${colors.green}✓${colors.reset} AGENTS.md updated (v${cliVersion})`)
864
+ else if (agentsResult === 'skipped' && depsEdited)
865
+ log(` ${colors.dim}·${colors.reset} AGENTS.md not refreshed (see above)`)
866
+ log(
867
+ `${colors.dim}Review changes with${colors.reset} ${colors.cyan}git diff${colors.reset}${colors.dim}, then commit.${colors.reset}`
868
+ )
869
+
870
+ // Only when the installed packages actually changed under a process that may
871
+ // still be running. This failure is nastier than it sounds: hot reload picks
872
+ // up your source but not a swapped dependency, so a dev server started before
873
+ // the update keeps serving the OLD framework against your NEW project code.
874
+ // The result looks like a bug you just introduced, and the one place nobody
875
+ // thinks to look is the server that has been running fine all along.
876
+ if (depsEdited && installRan) {
877
+ log('')
878
+ log(
879
+ `${colors.yellow}↻${colors.reset} ${colors.bright}Restart any running dev server.${colors.reset}`
880
+ )
881
+ log(
882
+ `${colors.dim}Hot reload picks up your source, not swapped dependencies — a running${colors.reset} ${colors.cyan}uniweb dev${colors.reset} ${colors.dim}keeps using the framework version it started with.${colors.reset}`
883
+ )
884
+ }
885
+
886
+ log('')
887
+ }