uniweb 1.1.0 → 1.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +4 -4
- package/partials/agents.md +1 -0
- package/src/backend/client.js +3 -1
- package/src/backend/foundation-bring-along.js +17 -7
- package/src/commands/add.js +118 -68
- package/src/commands/build.js +64 -138
- package/src/commands/clone.js +15 -3
- package/src/commands/dev.js +57 -0
- package/src/commands/doctor.js +48 -44
- package/src/commands/rename.js +11 -26
- package/src/commands/status.js +93 -4
- package/src/framework-index.json +3 -3
- package/src/utils/config.js +98 -90
- package/src/utils/discover.js +17 -20
- package/src/utils/placement.js +55 -0
- package/src/utils/scaffold.js +1 -1
- package/src/utils/workspace-packages.js +431 -0
- package/src/utils/workspace.js +10 -71
package/src/commands/build.js
CHANGED
|
@@ -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 {
|
|
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
|
-
*
|
|
294
|
-
* `
|
|
295
|
-
*
|
|
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
|
|
348
|
-
*
|
|
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 =
|
|
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
|
-
*
|
|
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
|
-
*
|
|
528
|
-
*
|
|
529
|
-
*
|
|
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
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
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 =
|
|
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
|
-
|
|
931
|
-
|
|
932
|
-
for (const
|
|
933
|
-
const
|
|
934
|
-
|
|
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
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
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,
|
package/src/commands/clone.js
CHANGED
|
@@ -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 {
|
|
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
|
|
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.
|
package/src/commands/dev.js
CHANGED
|
@@ -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
|
package/src/commands/doctor.js
CHANGED
|
@@ -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
|
|
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:
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
)
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
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
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
if (
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
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
|
}
|
package/src/commands/rename.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
154
|
-
*
|
|
155
|
-
*
|
|
156
|
-
*
|
|
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
|
/**
|