uniweb 1.0.2 → 1.2.0

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.
@@ -58,14 +58,15 @@ import {
58
58
  discoverComponents,
59
59
  resolveFoundationSrcPath,
60
60
  classifyPackage,
61
- isExtensionPackage
61
+ isExtensionPackage,
62
+ detectFoundationType
62
63
  } from '@uniweb/build'
63
64
  import {
64
65
  resolveDefaultLocale,
65
66
  normalizeLanguageList
66
67
  } from '@uniweb/core/locale-config'
67
68
  import { readSiteConfig } from '@uniweb/build/site'
68
- import { readWorkspaceConfig, resolveGlob } from '../utils/config.js'
69
+ import { workspacePackages } from '../utils/workspace-packages.js'
69
70
 
70
71
  // Colors for terminal output
71
72
  const colors = {
@@ -289,10 +290,11 @@ async function buildFoundation(projectDir, options = {}) {
289
290
  *
290
291
  * Freshness rule: a built artifact (`dist/entry.js` or the legacy
291
292
  * `dist/foundation.js`) exists AND its mtime is >= the newest mtime of
292
- * any tracked source file. Tracked sources: every file under
293
- * `<foundation>/src/`, plus root-level `package.json`, `foundation.js`,
294
- * `meta.js` (the structural files that drive entry generation and
295
- * schema). Build outputs and node_modules are skipped.
293
+ * any tracked source file. Tracked sources: every file under the
294
+ * package's source root (the package itself when flat, `src/` when
295
+ * nested — `isFoundationSourceNewerThan`), plus root-level `package.json`,
296
+ * `foundation.js`, `meta.js` (the structural files that drive entry
297
+ * generation and schema). Build outputs and node_modules are skipped.
296
298
  *
297
299
  * Both artifact names are accepted because the foundation build emitter
298
300
  * was renamed `dist/foundation.js → dist/entry.js` in `@uniweb/build`
@@ -300,7 +302,7 @@ async function buildFoundation(projectDir, options = {}) {
300
302
  * the legacy name; we don't want the cascade to keep rebuilding such a
301
303
  * foundation forever just because the new name is missing.
302
304
  */
303
- async function ensureFoundationFresh(foundationDir, label = 'foundation') {
305
+ export async function ensureFoundationFresh(foundationDir, label = 'foundation') {
304
306
  const distArtifact = findFoundationDistArtifact(foundationDir)
305
307
 
306
308
  if (!distArtifact) {
@@ -344,17 +346,26 @@ function findFoundationDistArtifact(foundationDir) {
344
346
 
345
347
  /**
346
348
  * Returns true if any tracked foundation source file has an mtime newer
347
- * than `referenceMtime`. Walks `<foundationDir>/src/` recursively (skipping
348
- * dotfiles, node_modules, and dist). Also stats the root structural files.
349
+ * than `referenceMtime`. Walks the package's source root — the folder
350
+ * `package.json::main` names (`resolveFoundationSrcPath`): the package itself
351
+ * for a flat package, `src/` for a nested one — recursively, skipping dotfiles,
352
+ * node_modules, dist and the generated entry. Also stats the root structural
353
+ * files.
354
+ *
355
+ * ⛔ Until 2026-10-13 it walked `<foundationDir>/src/` whatever the layout, and
356
+ * every package scaffolded since the flat layout keeps its sources at its root —
357
+ * so an edit to a section never made one stale. Measured: touching a section of
358
+ * a flat foundation and building its site rebuilt nothing, and prerender rendered
359
+ * the static HTML with the foundation as last built.
349
360
  */
350
- function isFoundationSourceNewerThan(foundationDir, referenceMtime) {
361
+ export function isFoundationSourceNewerThan(foundationDir, referenceMtime) {
351
362
  const rootFiles = ['package.json', 'foundation.js', 'meta.js']
352
363
  for (const f of rootFiles) {
353
364
  const p = join(foundationDir, f)
354
365
  if (existsSync(p) && statSync(p).mtimeMs > referenceMtime) return true
355
366
  }
356
367
 
357
- const srcDir = join(foundationDir, 'src')
368
+ const srcDir = resolveFoundationSrcPath(foundationDir)
358
369
  if (!existsSync(srcDir)) return false
359
370
 
360
371
  const stack = [srcDir]
@@ -370,6 +381,7 @@ function isFoundationSourceNewerThan(foundationDir, referenceMtime) {
370
381
  if (
371
382
  e.name === 'node_modules' ||
372
383
  e.name === 'dist' ||
384
+ e.name === '_entry.generated.js' ||
373
385
  e.name.startsWith('.')
374
386
  )
375
387
  continue
@@ -522,75 +534,34 @@ async function generateLocalizedHtml(projectDir, i18nConfig) {
522
534
  }
523
535
 
524
536
  /**
525
- * Resolve foundation directory based on site config and project structure
537
+ * The site's local foundation folder — what `site.yml::foundation` names, found by
538
+ * the build's one resolver (`detectFoundationType`, `@uniweb/build`) — or null
539
+ * when the foundation is a URL or a catalog ref, or does not resolve.
540
+ *
541
+ * Best-effort on purpose: the site's own build resolves the same declaration
542
+ * through the same function and raises the canonical error, so null here only
543
+ * skips what needs a local foundation — the freshness cascade, prerender's
544
+ * import, link mode's theme defaults.
545
+ *
546
+ * ⛔ Until 2026-10-13 this file held two copies of that resolver, weaker in
547
+ * different ways: one checked a same-named folder beside the site before the
548
+ * site's `file:` dep and took any folder, so a site named like its foundation
549
+ * resolved to itself; the other looked in `foundations/<name>` only when the
550
+ * site sat in a folder called `sites`, then fell back to `../foundation`
551
+ * whatever the site declared.
526
552
  *
527
- * Priority:
528
- * 1. Read site's package.json to find foundation dependency path (most reliable)
529
- * 2. Check foundations/{name} for multi-site projects
530
- * 3. Check ../foundation for single-site projects
531
- * 4. Check ../{name} as fallback
553
+ * @param {string} projectDir - the site's folder
554
+ * @param {object|null} [siteConfig] - its `site.yml`, when already read
555
+ * @returns {string|null}
532
556
  */
533
557
  function resolveFoundationDir(projectDir, siteConfig) {
534
- const foundationName = siteConfig?.foundation
535
-
536
- // First, try to resolve from site's package.json dependencies
537
- // This is the most reliable method as it matches how Vite resolves imports
538
- if (foundationName) {
539
- const pkgPath = join(projectDir, 'package.json')
540
- if (existsSync(pkgPath)) {
541
- try {
542
- const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'))
543
- const deps = { ...pkg.dependencies, ...pkg.devDependencies }
544
- const depValue = deps[foundationName]
545
-
546
- // Check for file: protocol (local dependency)
547
- if (depValue && depValue.startsWith('file:')) {
548
- const relativePath = depValue.slice(5) // Remove 'file:' prefix
549
- const resolvedPath = join(projectDir, relativePath)
550
- if (existsSync(resolvedPath)) {
551
- return resolvedPath
552
- }
553
- }
554
- } catch {
555
- // Ignore JSON parse errors, fall through to other methods
556
- }
557
- }
558
- }
559
-
560
- // Check if we're in a multi-site structure (site is under sites/)
561
- const parentDir = join(projectDir, '..')
562
- const grandParentDir = join(projectDir, '..', '..')
563
- const isMultiSite =
564
- parentDir.endsWith('/sites') || parentDir.endsWith('\\sites')
565
-
566
- if (isMultiSite && foundationName) {
567
- // Multi-site: look for foundations/{name}
568
- const multiFoundationDir = join(
569
- grandParentDir,
570
- 'foundations',
571
- foundationName
572
- )
573
- if (existsSync(multiFoundationDir)) {
574
- return multiFoundationDir
575
- }
576
- }
577
-
578
- // Single site: ../foundation
579
- const singleFoundationDir = join(projectDir, '..', 'foundation')
580
- if (existsSync(singleFoundationDir)) {
581
- return singleFoundationDir
582
- }
583
-
584
- // Fallback: try to find by foundation name in parent
585
- if (foundationName) {
586
- const namedFoundationDir = join(parentDir, foundationName)
587
- if (existsSync(namedFoundationDir)) {
588
- return namedFoundationDir
589
- }
558
+ const cfg = siteConfig || readSiteConfig(projectDir)
559
+ try {
560
+ const info = detectFoundationType(cfg?.foundation, projectDir)
561
+ return info.type === 'local' ? info.path : null
562
+ } catch {
563
+ return null
590
564
  }
591
-
592
- // Ultimate fallback
593
- return singleFoundationDir
594
565
  }
595
566
 
596
567
  /**
@@ -633,10 +604,7 @@ async function buildSiteLink(projectDir, options = {}) {
633
604
  // theme variable defaults from `foundation.js::theme.vars`. When the
634
605
  // foundation is purely a registry ref (no local sibling), this stays
635
606
  // null and theme defaults come from theme.yml only.
636
- const foundationDir = await resolveFoundationDirForSite(
637
- projectDir,
638
- siteConfig
639
- ).catch(() => null)
607
+ const foundationDir = resolveFoundationDir(projectDir, siteConfig)
640
608
 
641
609
  // Link mode does NOT (re)build the foundation. It reads only the
642
610
  // foundation's SOURCE config (foundation.js::theme.vars, passed as
@@ -709,44 +677,6 @@ async function buildSiteLink(projectDir, options = {}) {
709
677
  )
710
678
  }
711
679
 
712
- /**
713
- * Best-effort resolution of the local foundation directory for a site,
714
- * used by `buildSiteLink` to pass `foundationPath` to the data pipeline.
715
- *
716
- * Mirrors a subset of `@uniweb/build`'s `detectFoundationType` semantics:
717
- * when the site declares `foundation: <name>` and a sibling/file: dep
718
- * resolves to a local foundation, return its path. When the foundation
719
- * is a registry ref or URL, return null (data pipeline still works
720
- * without a local foundation; theme defaults just come from theme.yml).
721
- */
722
- async function resolveFoundationDirForSite(siteDir, siteConfig) {
723
- const cfg = siteConfig || readSiteConfig(siteDir)
724
- const foundation = cfg?.foundation
725
- if (!foundation || typeof foundation !== 'string') return null
726
- // Registry ref or URL — no local foundation.
727
- if (/^@[a-z0-9_-]+\/[a-z0-9_-]+@/.test(foundation)) return null
728
- if (foundation.startsWith('http://') || foundation.startsWith('https://'))
729
- return null
730
-
731
- // Workspace sibling.
732
- const sibling = resolve(siteDir, '..', foundation)
733
- if (existsSync(sibling)) return sibling
734
-
735
- // file: dep declared in package.json.
736
- try {
737
- const pkg = JSON.parse(readFileSync(join(siteDir, 'package.json'), 'utf-8'))
738
- const dep = pkg.dependencies?.[foundation]
739
- if (typeof dep === 'string' && dep.startsWith('file:')) {
740
- const filePath = resolve(siteDir, dep.slice(5))
741
- if (existsSync(filePath)) return filePath
742
- }
743
- } catch {
744
- /* no package.json or malformed — fall through */
745
- }
746
-
747
- return null
748
- }
749
-
750
680
  /**
751
681
  * Build a site
752
682
  */
@@ -844,8 +774,10 @@ async function buildSite(projectDir, options = {}) {
844
774
  const { prerenderSite } = await import('@uniweb/build/prerender')
845
775
 
846
776
  const result = await prerenderSite(projectDir, {
777
+ // `undefined`, not null, when there is none: prerenderSite skips a URL or
778
+ // catalog foundation before it reads this, and defaults it otherwise.
847
779
  foundationDir:
848
- foundationDir || resolveFoundationDir(projectDir, siteConfig),
780
+ foundationDir || resolveFoundationDir(projectDir, siteConfig) || undefined,
849
781
  host,
850
782
  onProgress: (msg) => log(` ${colors.dim}${msg}${colors.reset}`)
851
783
  })
@@ -927,23 +859,20 @@ async function discoverWorkspacePackages(workspaceDir) {
927
859
  const extensions = []
928
860
  const sites = []
929
861
 
930
- const { packages } = await readWorkspaceConfig(workspaceDir)
931
-
932
- for (const pattern of packages) {
933
- const dirs = await resolveGlob(workspaceDir, pattern)
934
- for (const dir of dirs) {
935
- const fullPath = join(workspaceDir, dir)
936
- const name = dir.split('/').pop()
862
+ // The workspace's packages as pnpm reads its patterns — each folder once. Named
863
+ // by their path: in a workspace of projects every foundation's folder is `src`.
864
+ for (const dir of workspacePackages(workspaceDir)) {
865
+ const fullPath = join(workspaceDir, dir)
866
+ const name = dir
937
867
 
938
- if (isFoundation(fullPath)) {
939
- if (isExtensionDir(fullPath)) {
940
- extensions.push({ name, path: fullPath })
941
- } else {
942
- foundations.push({ name, path: fullPath })
943
- }
944
- } else if (isSite(fullPath)) {
945
- sites.push({ name, path: fullPath })
868
+ if (isFoundation(fullPath)) {
869
+ if (isExtensionDir(fullPath)) {
870
+ extensions.push({ name, path: fullPath })
871
+ } else {
872
+ foundations.push({ name, path: fullPath })
946
873
  }
874
+ } else if (isSite(fullPath)) {
875
+ sites.push({ name, path: fullPath })
947
876
  }
948
877
  }
949
878
 
@@ -1193,10 +1122,7 @@ export async function build(args = []) {
1193
1122
  // to the local foundation when the user runs `uniweb build` from a
1194
1123
  // site dir on a fresh checkout where dist/ doesn't exist yet.
1195
1124
  const resolvedFoundationDir =
1196
- foundationDir ||
1197
- (await resolveFoundationDirForSite(projectDir, siteConfig).catch(
1198
- () => null
1199
- ))
1125
+ foundationDir || resolveFoundationDir(projectDir, siteConfig)
1200
1126
 
1201
1127
  await buildSite(projectDir, {
1202
1128
  prerender,
@@ -54,9 +54,9 @@ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs'
54
54
  import { join, resolve, basename, dirname } from 'node:path'
55
55
  import { spawnSync } from 'node:child_process'
56
56
  import { scaffoldWorkspace, scaffoldSite } from '../utils/scaffold.js'
57
- import { resolvePlacement, SITE_KIND } from '../utils/placement.js'
57
+ import { resolvePlacement, projectConflictLines, SITE_KIND } from '../utils/placement.js'
58
58
  import { findWorkspaceRoot } from '../utils/workspace.js'
59
- import { addWorkspaceGlob } from '../utils/config.js'
59
+ import { registerWorkspacePackage } from '../utils/config.js'
60
60
  import { detectWorkspacePm, installCmd } from '../utils/pm.js'
61
61
  import { BackendClient, describeRequestError, refusalDetail } from '../backend/client.js'
62
62
  import { resolveWorkspace } from '../backend/workspace.js'
@@ -298,6 +298,18 @@ export async function clone(args = [], deps = {}) {
298
298
  { path: pathFlag, project: projectFlag },
299
299
  SITE_KIND
300
300
  )
301
+ // `--project` names the folder itself, so a name or `--path` beside it is refused.
302
+ if (placement.projectConflict) {
303
+ const [first, ...rest] = projectConflictLines(
304
+ 'site',
305
+ target,
306
+ { path: pathFlag, project: projectFlag },
307
+ `${getCliPrefix()} clone ${siteUuid}`
308
+ )
309
+ error(first)
310
+ for (const line of rest) note(line)
311
+ return { exitCode: 1 }
312
+ }
301
313
  if (placement.outsideRoot) {
302
314
  error(`--path must name a folder inside the workspace: ${placement.outsideRoot}`)
303
315
  note(
@@ -364,7 +376,7 @@ export async function clone(args = [], deps = {}) {
364
376
  `Adding site ${colors.bright}${sitePkgName}${colors.reset} to the workspace at ${colors.dim}${placement.relativePath}/${colors.reset} …`
365
377
  )
366
378
  await scaffoldSite(siteDir, siteContext, { onProgress })
367
- await addWorkspaceGlob(existingRoot, placement.relativePath)
379
+ await registerWorkspacePackage(existingRoot, placement.relativePath)
368
380
  }
369
381
 
370
382
  // 4. Seed the identity this clone was pulled by, under the backend that minted it.
@@ -149,6 +149,8 @@ export async function dev(args = []) {
149
149
  )
150
150
  }
151
151
 
152
+ await ensureSiteExtensionsBuilt(sitePath)
153
+
152
154
  console.error(
153
155
  `${DIM}→ ${command}${RESET} ${DIM}(site: ${site.name}, dir: ${sitePath})${RESET}`
154
156
  )
@@ -162,6 +164,61 @@ export async function dev(args = []) {
162
164
  })
163
165
  }
164
166
 
167
+ /**
168
+ * Build the site's workspace extensions that are missing or older than their
169
+ * sources, before its dev server starts — as `uniweb build` builds extensions
170
+ * before sites. A site loads a workspace extension from its BUILD in dev as in a
171
+ * built site (the dev server serves its `dist/`), so an unbuilt one is a 404 and a
172
+ * stale one is the code as it was. Found by the rule the build follows
173
+ * (`findSiteExtension`, `@uniweb/build`).
174
+ *
175
+ * Nothing watches an extension while the server runs: after editing one, run
176
+ * `uniweb build` and reload. A failed build warns and dev starts anyway — the
177
+ * site without its extension is still worth looking at.
178
+ */
179
+ export async function ensureSiteExtensionsBuilt(sitePath) {
180
+ let build, readSiteConfig, ensureFoundationFresh
181
+ try {
182
+ build = await import('@uniweb/build')
183
+ ;({ readSiteConfig } = await import('@uniweb/build/site'))
184
+ ;({ ensureFoundationFresh } = await import('./build.js'))
185
+ } catch {
186
+ return // Not installed yet — the dev server says so in its own words.
187
+ }
188
+ let declared
189
+ try {
190
+ declared = readSiteConfig(sitePath)?.extensions
191
+ } catch {
192
+ return // An unreadable site.yml is the dev server's to report.
193
+ }
194
+ if (!Array.isArray(declared)) return
195
+
196
+ const dirs = new Set()
197
+ for (const entry of declared) {
198
+ const named = build.extensionNameOf(typeof entry === 'string' ? entry : entry?.url)
199
+ const dir = named && build.findSiteExtension(named.name, sitePath)
200
+ if (dir) dirs.add(dir)
201
+ }
202
+ const step = process.env.UNIWEB_BUILD_STEP
203
+ process.env.UNIWEB_BUILD_STEP = 'dev' // a step of `dev`: no "what next" after each build
204
+ try {
205
+ for (const dir of dirs) {
206
+ try {
207
+ await ensureFoundationFresh(dir, 'extension')
208
+ } catch (err) {
209
+ console.error(
210
+ `${YELLOW}⚠${RESET} Could not build the extension at ${dir}: ${err.message}`
211
+ )
212
+ console.error(` The site will start without it. Fix it, then run \`uniweb build\` and reload.`)
213
+ console.error('')
214
+ }
215
+ }
216
+ } finally {
217
+ if (step === undefined) delete process.env.UNIWEB_BUILD_STEP
218
+ else process.env.UNIWEB_BUILD_STEP = step
219
+ }
220
+ }
221
+
165
222
  /**
166
223
  * Say so if the site is about to be served from an install that no longer
167
224
  * matches the workspace. Warns rather than exits — the server may still be
@@ -15,7 +15,9 @@ import yaml from 'js-yaml'
15
15
  import {
16
16
  resolveFoundationSrcPath,
17
17
  classifyPackage,
18
- isExtensionPackage as buildIsExtensionPackage
18
+ isExtensionPackage as buildIsExtensionPackage,
19
+ extensionNameOf,
20
+ findSiteExtension
19
21
  } from '@uniweb/build'
20
22
  import { loadDeployYml, AGENTS_KEYS } from '@uniweb/build/site'
21
23
  import { listAdapters } from '@uniweb/build/hosts'
@@ -26,6 +28,7 @@ import { writeJsonPreservingStyle } from '../utils/json-file.js'
26
28
  import { surveyWorkspaceDeps } from '../utils/dep-survey.js'
27
29
  import { discoverFoundations, discoverSites } from '../utils/discover.js'
28
30
  import { checkSiteInstall } from '../utils/install-integrity.js'
31
+ import { editWorkspaceFilePackages } from '../utils/workspace-packages.js'
29
32
  import { findWorkspaceRoot } from '../utils/workspace.js'
30
33
  import { DATA_DIR } from '@uniweb/core/data-paths'
31
34
  import { FAMILIES, resolveFamily } from '@uniweb/schemas/families'
@@ -1090,7 +1093,7 @@ export async function doctor(args = []) {
1090
1093
 
1091
1094
  // Workspace manifest sync — keep `pnpm-workspace.yaml::packages` and
1092
1095
  // `package.json::workspaces` aligned. The CLI writes both on every
1093
- // mutation (see addWorkspaceGlob in utils/config.js), but a user who
1096
+ // mutation (see registerWorkspacePackage in utils/config.js), but a user who
1094
1097
  // manually edits one can introduce drift. Drift breaks projects that
1095
1098
  // switch package managers (pnpm-workspace.yaml is pnpm-only;
1096
1099
  // package.json::workspaces is what npm and yarn read).
@@ -1133,21 +1136,28 @@ export async function doctor(args = []) {
1133
1136
  if (onlyInPkg.length)
1134
1137
  log(` only in package.json::workspaces: ${onlyInPkg.join(', ')}`)
1135
1138
  if (shouldFix('workspace-manifests-out-of-sync')) {
1136
- // Canonical resolution: write the union to both manifests.
1137
- const union = Array.from(
1138
- new Set([...ymlPackages, ...pkgWorkspaces])
1139
- ).sort()
1140
- writeFileSync(
1141
- ymlPath,
1142
- yaml.dump({ packages: union }, { flowLevel: -1, quotingType: '"' })
1143
- )
1144
- const rootPkgPath = join(workspaceDir, 'package.json')
1145
- const rootPkgSrc = readFileSync(rootPkgPath, 'utf-8')
1146
- const rootPkg = JSON.parse(rootPkgSrc)
1147
- rootPkg.workspaces = union
1148
- writeJsonPreservingStyle(rootPkgPath, rootPkg, rootPkgSrc)
1149
- issue.fixed = true
1150
- fixed(`wrote union [${union.join(', ')}] to both manifests`)
1139
+ // Canonical resolution: each manifest gains what only the other lists. The
1140
+ // yaml is edited in place — its comments and every other key stay.
1141
+ // ⛔ Until 2026-10-13 it was rewritten as `{ packages: union }`, deleting
1142
+ // `onlyBuiltDependencies` and `allowBuilds` with the comments that explain them.
1143
+ const nextYml = editWorkspaceFilePackages(readFileSync(ymlPath, 'utf8'), (list) => [
1144
+ ...list,
1145
+ ...onlyInPkg
1146
+ ])
1147
+ if (nextYml == null) {
1148
+ warn(
1149
+ `Could not edit the packages: list in pnpm-workspace.yaml in place, and it is not rewritten whole. Add ${onlyInPkg.join(', ')} to it by hand.`
1150
+ )
1151
+ } else {
1152
+ writeFileSync(ymlPath, nextYml)
1153
+ const rootPkgPath = join(workspaceDir, 'package.json')
1154
+ const rootPkgSrc = readFileSync(rootPkgPath, 'utf-8')
1155
+ const rootPkg = JSON.parse(rootPkgSrc)
1156
+ rootPkg.workspaces = [...pkgWorkspaces, ...onlyInYml]
1157
+ writeJsonPreservingStyle(rootPkgPath, rootPkg, rootPkgSrc)
1158
+ issue.fixed = true
1159
+ fixed(`both manifests now list [${[...ymlPackages, ...onlyInPkg].join(', ')}]`)
1160
+ }
1151
1161
  } else {
1152
1162
  log(
1153
1163
  ` ${colors.dim}Pick one set of globs and copy it to the other manifest. The two should always match — pnpm reads pnpm-workspace.yaml, npm/yarn read package.json::workspaces.${colors.reset}`
@@ -1674,37 +1684,31 @@ export async function doctor(args = []) {
1674
1684
  }
1675
1685
  }
1676
1686
 
1677
- // Validate extension URLs in site.yml
1687
+ // Validate site-relative extension URLs in site.yml — by the rule the build
1688
+ // follows to copy the extension into the site (`findSiteExtension`, from
1689
+ // `@uniweb/build`), so doctor reports what the build will do. ⛔ Until
1690
+ // 2026-10-13 doctor parsed the URL itself and matched any workspace extension
1691
+ // of that name, wherever it was, so it passed a site whose build could not
1692
+ // find its extension.
1678
1693
  for (const site of sites) {
1679
1694
  const siteYml = loadSiteYml(site.path)
1680
1695
  if (!siteYml?.extensions || !Array.isArray(siteYml.extensions)) continue
1681
1696
 
1682
- for (const extUrl of siteYml.extensions) {
1683
- if (typeof extUrl !== 'string') continue
1684
-
1685
- // Skip remote URLs — can't validate those
1686
- if (extUrl.startsWith('http://') || extUrl.startsWith('https://'))
1687
- continue
1688
-
1689
- // Local extension URL: check if it maps to a known extension
1690
- // URLs like /effects/foundation.js or /extensions/effects/foundation.js
1691
- const urlParts = extUrl.replace(/^\//, '').split('/')
1692
- const extName =
1693
- urlParts.length >= 2 ? urlParts[urlParts.length - 2] : urlParts[0]
1694
-
1695
- // Check if a matching extension exists and is built
1696
- const matchingExt = extensions.find(
1697
- (e) => e.folderName === extName || e.name === extName
1698
- )
1699
- if (
1700
- matchingExt &&
1701
- !existsSync(join(matchingExt.path, 'dist', 'entry.js'))
1702
- ) {
1703
- issues.push({
1704
- type: 'warn',
1705
- site: site.name,
1706
- message: `Extension "${extName}" referenced in site.yml is not built`
1707
- })
1697
+ for (const entry of siteYml.extensions) {
1698
+ const url = typeof entry === 'string' ? entry : entry?.url
1699
+ // Remote URLs and catalog refs are not the site's to serve.
1700
+ const named = extensionNameOf(url)
1701
+ if (!named) continue
1702
+
1703
+ const dir = findSiteExtension(named.name, site.path)
1704
+ if (!dir) {
1705
+ const message = `site.yml lists ${url}, and no workspace extension named '${named.name}' is where the site looks: ${named.name}/ or extensions/${named.name}/, in the folder the site is in or a folder above it, up to the workspace root. The build cannot copy it, and the site will 404 on it.`
1706
+ issues.push({ type: 'warn', site: site.name, message })
1707
+ warn(`[${site.name}] ${message}`)
1708
+ } else if (!existsSync(join(dir, 'dist', named.rest))) {
1709
+ const message = `Extension '${named.name}' (${relative(workspaceDir, dir)}/) referenced in site.yml is not built`
1710
+ issues.push({ type: 'warn', site: site.name, message })
1711
+ warn(`[${site.name}] ${message}`)
1708
1712
  }
1709
1713
  }
1710
1714
  }
@@ -51,8 +51,7 @@ import yaml from 'js-yaml'
51
51
  import { isExtensionPackage } from '@uniweb/build'
52
52
  import { findWorkspaceRoot } from '../utils/workspace.js'
53
53
  import {
54
- readWorkspaceConfig,
55
- writeWorkspaceConfig,
54
+ moveWorkspacePackage,
56
55
  readRootPackageJson,
57
56
  writeRootPackageJson,
58
57
  updateRootScripts
@@ -150,31 +149,17 @@ function computeNewFolderPath(rootDir, oldPath, oldName, newName) {
150
149
  }
151
150
 
152
151
  /**
153
- * Update the workspace manifests when a folder path moves. Both
154
- * pnpm-workspace.yaml and package.json::workspaces are kept in sync
155
- * (the multi-PM compatibility invariant). Wildcard entries
156
- * (`extensions/*`) are left alone — only specific paths get rewritten.
152
+ * Update the workspace manifests when a folder path moves — in place, through the
153
+ * one editor of the workspace file (`moveWorkspacePackage`): an entry naming the
154
+ * old path is renamed, a pattern (`extensions/*`) is left as written, and the new
155
+ * path is registered if no pattern covers it any more. Both pnpm-workspace.yaml and
156
+ * package.json::workspaces are kept in sync (the multi-PM compatibility invariant).
157
+ *
158
+ * ⛔ Until 2026-10-13 the yaml was rewritten from its `packages` alone, deleting its
159
+ * comments, `onlyBuiltDependencies` and `allowBuilds`.
157
160
  */
158
- async function updateWorkspaceManifestsForFolderMove(
159
- rootDir,
160
- oldPath,
161
- newPath
162
- ) {
163
- const wsConfig = await readWorkspaceConfig(rootDir)
164
- const rootPkg = await readRootPackageJson(rootDir)
165
- if (wsConfig.packages.includes(oldPath)) {
166
- const idx = wsConfig.packages.indexOf(oldPath)
167
- wsConfig.packages[idx] = newPath
168
- await writeWorkspaceConfig(rootDir, wsConfig)
169
- }
170
- if (
171
- Array.isArray(rootPkg.workspaces) &&
172
- rootPkg.workspaces.includes(oldPath)
173
- ) {
174
- const idx = rootPkg.workspaces.indexOf(oldPath)
175
- rootPkg.workspaces[idx] = newPath
176
- await writeRootPackageJson(rootDir, rootPkg)
177
- }
161
+ async function updateWorkspaceManifestsForFolderMove(rootDir, oldPath, newPath) {
162
+ await moveWorkspacePackage(rootDir, oldPath, newPath)
178
163
  }
179
164
 
180
165
  /**
@@ -304,11 +304,13 @@ export function printSurvey(
304
304
  const r = breakingRows.find((x) => x.name === name)
305
305
  log(` ${colors.red}${name}${colors.reset} ${r.current} → ${r.target} ${colors.dim}(${r.bump})${colors.reset}`)
306
306
  }
307
+ // ⛔ The second line said "Read their changelogs before shipping" until 2026-10-13,
308
+ // when most packages kept none.
307
309
  log(
308
- ` ${colors.dim}A minor version is where Uniweb's breaking changes go, so these are releases${colors.reset}`
310
+ ` ${colors.dim}A minor version is where Uniweb's breaking changes go (a major is rarer still),${colors.reset}`
309
311
  )
310
312
  log(
311
- ` ${colors.dim}you may need to act on. Read their changelogs before shipping.${colors.reset}`
313
+ ` ${colors.dim}so these are releases you may need to act on: build and check before shipping.${colors.reset}`
312
314
  )
313
315
  }
314
316
  log('')
@@ -374,6 +376,8 @@ export async function update(args = []) {
374
376
  const depsOnly = args.includes('--deps-only')
375
377
  const skipAgents = args.includes('--no-agents') || depsOnly
376
378
  const skipDeps = args.includes('--no-deps') || agentsOnly
379
+ // The flag that kept the deps step from running — what the AGENTS.md step's hint names.
380
+ const depsSkippedBy = agentsOnly ? '--agents-only' : skipDeps ? '--no-deps' : null
377
381
  const dryRun = args.includes('--dry-run')
378
382
  const allowMismatch = args.includes('--allow-mismatch')
379
383
  const verbose = args.includes('--verbose')
@@ -636,6 +640,7 @@ export async function update(args = []) {
636
640
  hasYes,
637
641
  nonInteractive,
638
642
  agentsOnly,
643
+ depsSkippedBy,
639
644
  depsEdited,
640
645
  installRan,
641
646
  installPm
@@ -734,6 +739,7 @@ async function refreshAgents(ctx) {
734
739
  hasYes,
735
740
  nonInteractive,
736
741
  agentsOnly,
742
+ depsSkippedBy,
737
743
  depsEdited,
738
744
  installRan,
739
745
  installPm
@@ -757,13 +763,21 @@ async function refreshAgents(ctx) {
757
763
  // Re-survey: deps may have just been edited+installed, which clears the
758
764
  // declared-deps gate.
759
765
  const finalSurvey = await surveyWorkspaceDeps(workspaceDir)
760
- if (finalSurvey.anyDrift && !allowMismatch) {
766
+ // ⭐ A dry run moves no deps, so they still lag here whenever the deps step has
767
+ // something to align — while the run it previews aligns and installs them before
768
+ // this step. Say what that run would do, rather than that this step would skip.
769
+ const onceInstalled = finalSurvey.anyDrift && dryRun && !depsSkippedBy
770
+ if (finalSurvey.anyDrift && !allowMismatch && !onceInstalled) {
761
771
  warn('AGENTS.md refresh skipped: workspace deps still lag the CLI.')
762
772
  log(
763
773
  `${colors.dim}AGENTS.md from v${cliVersion} would document features not in your installed packages.${colors.reset}`
764
774
  )
775
+ // ⛔ This always said "Re-run without --no-deps": wrong under --agents-only, and
776
+ // when the deps edit was declined or left to a non-interactive run's plan.
765
777
  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}`
778
+ depsSkippedBy
779
+ ? `${colors.dim}Re-run without ${colors.reset}${colors.cyan}${depsSkippedBy}${colors.reset}${colors.dim}, or pass ${colors.reset}${colors.cyan}--allow-mismatch${colors.reset}${colors.dim} to override.${colors.reset}`
780
+ : `${colors.dim}Align the deps first — accept the edit, or re-run with ${colors.reset}${colors.cyan}--yes${colors.reset}${colors.dim} — or pass ${colors.reset}${colors.cyan}--allow-mismatch${colors.reset}${colors.dim} to override.${colors.reset}`
767
781
  )
768
782
  log('')
769
783
  if (agentsOnly) process.exit(1)
@@ -797,7 +811,7 @@ async function refreshAgents(ctx) {
797
811
 
798
812
  if (dryRun) {
799
813
  info(
800
- `Dry-run: would ${currentAgentsVersion ? `update AGENTS.md (v${currentAgentsVersion} → v${cliVersion})` : `create AGENTS.md (v${cliVersion})`}${keeping}.`
814
+ `Dry-run: would ${currentAgentsVersion ? `update AGENTS.md (v${currentAgentsVersion} → v${cliVersion})` : `create AGENTS.md (v${cliVersion})`}${keeping}${onceInstalled ? ', once the deps above are installed' : ''}.`
801
815
  )
802
816
  return 'skipped'
803
817
  }