uniweb 0.13.16 → 0.14.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.
Files changed (65) hide show
  1. package/README.md +1 -1
  2. package/package.json +7 -7
  3. package/partials/agents.md +51 -0
  4. package/src/backend/client.js +173 -40
  5. package/src/backend/data-bundle.js +15 -3
  6. package/src/backend/foundation-bring-along.js +76 -21
  7. package/src/backend/payment-handoff.js +28 -9
  8. package/src/backend/site-media.js +147 -19
  9. package/src/backend/site-sync.js +362 -40
  10. package/src/commands/add.js +575 -273
  11. package/src/commands/build.js +160 -57
  12. package/src/commands/clone.js +79 -26
  13. package/src/commands/content.js +11 -7
  14. package/src/commands/deploy.js +80 -32
  15. package/src/commands/dev.js +39 -17
  16. package/src/commands/docs.js +44 -16
  17. package/src/commands/doctor.js +338 -82
  18. package/src/commands/export.js +15 -7
  19. package/src/commands/handoff.js +6 -2
  20. package/src/commands/i18n.js +248 -99
  21. package/src/commands/inspect.js +48 -19
  22. package/src/commands/invite.js +9 -3
  23. package/src/commands/org.js +19 -5
  24. package/src/commands/publish.js +229 -60
  25. package/src/commands/pull.js +157 -48
  26. package/src/commands/push.js +144 -42
  27. package/src/commands/refresh.js +37 -10
  28. package/src/commands/register.js +235 -64
  29. package/src/commands/rename.js +126 -46
  30. package/src/commands/runtime.js +74 -24
  31. package/src/commands/status.js +48 -15
  32. package/src/commands/sync.js +7 -2
  33. package/src/commands/template.js +12 -4
  34. package/src/commands/update.js +263 -87
  35. package/src/commands/validate.js +115 -32
  36. package/src/framework-index.json +16 -14
  37. package/src/index.js +298 -145
  38. package/src/templates/fetchers/github.js +7 -7
  39. package/src/templates/fetchers/npm.js +3 -3
  40. package/src/templates/fetchers/release.js +21 -18
  41. package/src/templates/index.js +34 -12
  42. package/src/templates/processor.js +44 -12
  43. package/src/templates/resolver.js +42 -15
  44. package/src/templates/validator.js +14 -7
  45. package/src/utils/asset-upload.js +90 -22
  46. package/src/utils/code-upload.js +62 -37
  47. package/src/utils/config.js +31 -10
  48. package/src/utils/dep-survey.js +33 -7
  49. package/src/utils/destination-prompt.js +27 -17
  50. package/src/utils/git.js +66 -22
  51. package/src/utils/host-prompt.js +4 -3
  52. package/src/utils/install-integrity.js +8 -4
  53. package/src/utils/interactive.js +3 -3
  54. package/src/utils/json-file.js +6 -2
  55. package/src/utils/names.js +15 -6
  56. package/src/utils/placement.js +16 -8
  57. package/src/utils/registry-auth.js +185 -57
  58. package/src/utils/registry-orgs.js +142 -53
  59. package/src/utils/runtime-upload.js +52 -14
  60. package/src/utils/scaffold.js +55 -14
  61. package/src/utils/site-content-refs.js +3 -1
  62. package/src/utils/update-check.js +43 -17
  63. package/src/utils/workspace.js +13 -7
  64. package/src/versions.js +18 -7
  65. package/templates/site/_gitignore +5 -0
@@ -2,10 +2,21 @@
2
2
  * uniweb doctor - Diagnose project configuration issues
3
3
  */
4
4
 
5
- import { existsSync, readFileSync, readdirSync, writeFileSync, mkdirSync } from 'node:fs'
5
+ import {
6
+ existsSync,
7
+ readFileSync,
8
+ readdirSync,
9
+ writeFileSync,
10
+ mkdirSync,
11
+ rmSync
12
+ } from 'node:fs'
6
13
  import { join, resolve, basename, dirname, relative } from 'node:path'
7
14
  import yaml from 'js-yaml'
8
- import { resolveFoundationSrcPath, classifyPackage, isExtensionPackage as buildIsExtensionPackage } from '@uniweb/build'
15
+ import {
16
+ resolveFoundationSrcPath,
17
+ classifyPackage,
18
+ isExtensionPackage as buildIsExtensionPackage
19
+ } from '@uniweb/build'
9
20
  import { loadDeployYml } from '@uniweb/build/site'
10
21
  import { listAdapters } from '@uniweb/build/hosts'
11
22
  import { getCliVersion } from '../versions.js'
@@ -15,6 +26,7 @@ import { surveyWorkspaceDeps } from '../utils/dep-survey.js'
15
26
  import { discoverFoundations, discoverSites } from '../utils/discover.js'
16
27
  import { checkSiteInstall } from '../utils/install-integrity.js'
17
28
  import { findWorkspaceRoot } from '../utils/workspace.js'
29
+ import { DATA_DIR } from '@uniweb/core/data-paths'
18
30
 
19
31
  /**
20
32
  * Parse the `--fix [<issue-id>]` flag.
@@ -125,7 +137,11 @@ function loadPackageJson(dir) {
125
137
  function loadSiteYml(dir) {
126
138
  const ymlPath = join(dir, 'site.yml')
127
139
  const yamlPath = join(dir, 'site.yaml')
128
- const configPath = existsSync(ymlPath) ? ymlPath : existsSync(yamlPath) ? yamlPath : null
140
+ const configPath = existsSync(ymlPath)
141
+ ? ymlPath
142
+ : existsSync(yamlPath)
143
+ ? yamlPath
144
+ : null
129
145
  if (!configPath) return null
130
146
  try {
131
147
  return yaml.load(readFileSync(configPath, 'utf8'))
@@ -134,6 +150,103 @@ function loadSiteYml(dir) {
134
150
  }
135
151
  }
136
152
 
153
+ /**
154
+ * Diagnose the compiled-collection output directory.
155
+ *
156
+ * `public/<DATA_DIR>/` holds what the build compiles from `collections/`, and
157
+ * nothing else — `collections/` is the only supported way to provide
158
+ * structured data. Two consequences, both checked here:
159
+ *
160
+ * 1. **The mapping is a bijection.** Every entry should be backed by a
161
+ * declared collection. An entry that isn't is stale — most often a
162
+ * collection removed from `site.yml`, whose compiled records stay on disk
163
+ * and keep being deployed. The build reconciles *within* a collection it
164
+ * still knows about; it cannot reconcile one it has never heard of, which
165
+ * is exactly the case that needs a declaration to compare against.
166
+ *
167
+ * 2. **It should not be committed.** It is generated into the source tree
168
+ * rather than `dist/`, so without an ignore rule it looks permanent, gets
169
+ * committed, and then looks like something you may edit. That is how
170
+ * hand-authored files ended up there in the first place.
171
+ */
172
+ function checkGeneratedDataDir({ sitePath, siteName, siteYml, issues, shouldFix, fixed }) {
173
+ const dataDir = join(sitePath, 'public', DATA_DIR)
174
+ const declared = new Set(Object.keys(siteYml.collections || {}))
175
+
176
+ if (existsSync(dataDir)) {
177
+ // A collection `x` owns `x.json` (the cascade) and `x/` (per-record files
178
+ // when it declares `deferred:`). Anything else is unaccounted for.
179
+ const orphans = readdirSync(dataDir, { withFileTypes: true })
180
+ .filter((entry) => {
181
+ const name = entry.isDirectory()
182
+ ? entry.name
183
+ : entry.name.replace(/\.json$/i, '')
184
+ return !declared.has(name)
185
+ })
186
+ .map((entry) => (entry.isDirectory() ? `${entry.name}/` : entry.name))
187
+
188
+ if (orphans.length > 0) {
189
+ const id = 'orphaned-collection-output'
190
+ issues.push({
191
+ id,
192
+ type: 'warning',
193
+ site: siteName,
194
+ message: `${orphans.length} entr${orphans.length === 1 ? 'y' : 'ies'} in public/${DATA_DIR}/ with no declared collection`
195
+ })
196
+ warn(`[${id}] Stale output in public/${DATA_DIR}/: ${orphans.join(', ')}`)
197
+ log(
198
+ ` No collection in site.yml produces ${orphans.length === 1 ? 'it' : 'these'}. ` +
199
+ `${orphans.length === 1 ? 'It is' : 'They are'} still served and deployed.`
200
+ )
201
+ if (shouldFix(id)) {
202
+ for (const entry of orphans) {
203
+ rmSync(join(dataDir, entry.replace(/\/$/, '')), { recursive: true, force: true })
204
+ }
205
+ fixed(`removed ${orphans.length} stale entr${orphans.length === 1 ? 'y' : 'ies'} from public/${DATA_DIR}/`)
206
+ } else {
207
+ log(` ${colors.dim}Run \`uniweb doctor --fix ${id}\` to remove ${orphans.length === 1 ? 'it' : 'them'}.${colors.reset}`)
208
+ }
209
+ }
210
+ }
211
+
212
+ // The ignore rule. Checked whether or not the directory exists yet — the
213
+ // point is to have it in place *before* the first build writes there.
214
+ const gitignorePath = join(sitePath, '.gitignore')
215
+ const rule = `public/${DATA_DIR}/`
216
+ const existing = existsSync(gitignorePath) ? readFileSync(gitignorePath, 'utf8') : null
217
+ const covered =
218
+ existing !== null &&
219
+ existing
220
+ .split('\n')
221
+ .map((line) => line.trim().replace(/\/$/, ''))
222
+ .includes(rule.replace(/\/$/, ''))
223
+
224
+ if (!covered) {
225
+ const id = 'generated-data-not-ignored'
226
+ issues.push({
227
+ id,
228
+ type: 'warning',
229
+ site: siteName,
230
+ message: `public/${DATA_DIR}/ is generated but not gitignored`
231
+ })
232
+ warn(`[${id}] public/${DATA_DIR}/ is build output but not in .gitignore`)
233
+ log(` Committing it means a diff on every build, and it invites editing what the build overwrites.`)
234
+ if (shouldFix(id)) {
235
+ const body = existing === null ? '' : existing.replace(/\n*$/, '\n')
236
+ writeFileSync(
237
+ gitignorePath,
238
+ `${body}\n# Compiled collections — generated from collections/\n${rule}\n`
239
+ )
240
+ fixed(`added ${rule} to ${gitignorePath}`)
241
+ if (existsSync(dataDir)) {
242
+ log(` ${colors.dim}Already-committed files need \`git rm -r --cached public/${DATA_DIR}\`.${colors.reset}`)
243
+ }
244
+ } else {
245
+ log(` ${colors.dim}Run \`uniweb doctor --fix ${id}\` to add it.${colors.reset}`)
246
+ }
247
+ }
248
+ }
249
+
137
250
  /**
138
251
  * Main doctor command
139
252
  */
@@ -147,7 +260,8 @@ export async function doctor(args = []) {
147
260
  // each diagnostic below so the rewrite happens with full context.
148
261
  const fixFlag = parseFixFlag(args)
149
262
  const shouldFix = (id) => fixFlag === 'all' || fixFlag === id
150
- const fixed = (msg) => console.log(` ${colors.green}↳ Fixed:${colors.reset} ${msg}`)
263
+ const fixed = (msg) =>
264
+ console.log(` ${colors.green}↳ Fixed:${colors.reset} ${msg}`)
151
265
 
152
266
  const projectDir = resolve(process.cwd())
153
267
 
@@ -157,7 +271,9 @@ export async function doctor(args = []) {
157
271
 
158
272
  if (!workspaceDir) {
159
273
  error('Not in a Uniweb workspace')
160
- log(`${colors.dim}Run this command from your project root or a site/foundation directory.${colors.reset}`)
274
+ log(
275
+ `${colors.dim}Run this command from your project root or a site/foundation directory.${colors.reset}`
276
+ )
161
277
  process.exit(1)
162
278
  }
163
279
 
@@ -181,31 +297,42 @@ export async function doctor(args = []) {
181
297
  issues.push({
182
298
  id: 'workspace-yaml-malformed',
183
299
  type: 'error',
184
- message: 'pnpm-workspace.yaml is malformed and could not be parsed.',
300
+ message: 'pnpm-workspace.yaml is malformed and could not be parsed.'
185
301
  })
186
302
  error('pnpm-workspace.yaml is malformed and could not be parsed.')
187
303
  }
188
304
  const rootPkg = loadPackageJson(workspaceDir)
189
- const pkgWorkspaces = Array.isArray(rootPkg?.workspaces) ? rootPkg.workspaces : []
305
+ const pkgWorkspaces = Array.isArray(rootPkg?.workspaces)
306
+ ? rootPkg.workspaces
307
+ : []
190
308
  const ymlSet = new Set(ymlPackages)
191
309
  const pkgSet = new Set(pkgWorkspaces)
192
- const onlyInYml = [...ymlSet].filter(g => !pkgSet.has(g))
193
- const onlyInPkg = [...pkgSet].filter(g => !ymlSet.has(g))
310
+ const onlyInYml = [...ymlSet].filter((g) => !pkgSet.has(g))
311
+ const onlyInPkg = [...pkgSet].filter((g) => !ymlSet.has(g))
194
312
  if (onlyInYml.length || onlyInPkg.length) {
195
313
  const issue = {
196
314
  id: 'workspace-manifests-out-of-sync',
197
315
  type: 'warn',
198
316
  message: `pnpm-workspace.yaml and package.json::workspaces declare different package globs.`,
199
- details: { onlyInYml, onlyInPkg },
317
+ details: { onlyInYml, onlyInPkg }
200
318
  }
201
319
  issues.push(issue)
202
- warn(`[workspace-manifests-out-of-sync] pnpm-workspace.yaml and package.json::workspaces are out of sync:`)
203
- if (onlyInYml.length) log(` only in pnpm-workspace.yaml: ${onlyInYml.join(', ')}`)
204
- if (onlyInPkg.length) log(` only in package.json::workspaces: ${onlyInPkg.join(', ')}`)
320
+ warn(
321
+ `[workspace-manifests-out-of-sync] pnpm-workspace.yaml and package.json::workspaces are out of sync:`
322
+ )
323
+ if (onlyInYml.length)
324
+ log(` only in pnpm-workspace.yaml: ${onlyInYml.join(', ')}`)
325
+ if (onlyInPkg.length)
326
+ log(` only in package.json::workspaces: ${onlyInPkg.join(', ')}`)
205
327
  if (shouldFix('workspace-manifests-out-of-sync')) {
206
328
  // Canonical resolution: write the union to both manifests.
207
- const union = Array.from(new Set([...ymlPackages, ...pkgWorkspaces])).sort()
208
- writeFileSync(ymlPath, yaml.dump({ packages: union }, { flowLevel: -1, quotingType: '"' }))
329
+ const union = Array.from(
330
+ new Set([...ymlPackages, ...pkgWorkspaces])
331
+ ).sort()
332
+ writeFileSync(
333
+ ymlPath,
334
+ yaml.dump({ packages: union }, { flowLevel: -1, quotingType: '"' })
335
+ )
209
336
  const rootPkgPath = join(workspaceDir, 'package.json')
210
337
  const rootPkgSrc = readFileSync(rootPkgPath, 'utf-8')
211
338
  const rootPkg = JSON.parse(rootPkgSrc)
@@ -214,7 +341,9 @@ export async function doctor(args = []) {
214
341
  issue.fixed = true
215
342
  fixed(`wrote union [${union.join(', ')}] to both manifests`)
216
343
  } else {
217
- log(` ${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}`)
344
+ log(
345
+ ` ${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}`
346
+ )
218
347
  }
219
348
  }
220
349
  }
@@ -243,7 +372,10 @@ export async function doctor(args = []) {
243
372
  } else {
244
373
  success(`Found ${foundations.length} foundation(s):`)
245
374
  for (const f of foundations) {
246
- const nameMismatch = f.name !== f.folderName ? ` ${colors.dim}(folder: ${f.folderName}/)${colors.reset}` : ''
375
+ const nameMismatch =
376
+ f.name !== f.folderName
377
+ ? ` ${colors.dim}(folder: ${f.folderName}/)${colors.reset}`
378
+ : ''
247
379
  log(` • ${f.name}${nameMismatch}`)
248
380
  }
249
381
  }
@@ -252,16 +384,19 @@ export async function doctor(args = []) {
252
384
  log('')
253
385
  success(`Found ${extensions.length} extension(s):`)
254
386
  for (const e of extensions) {
255
- const nameMismatch = e.name !== e.folderName ? ` ${colors.dim}(folder: ${e.folderName}/)${colors.reset}` : ''
387
+ const nameMismatch =
388
+ e.name !== e.folderName
389
+ ? ` ${colors.dim}(folder: ${e.folderName}/)${colors.reset}`
390
+ : ''
256
391
  log(` • ${e.name}${nameMismatch}`)
257
392
  }
258
393
  }
259
394
 
260
395
  // Discover sites via the canonical workspace globs (same rationale as
261
396
  // foundations above: respects whatever layout the user chose).
262
- const sites = (await discoverSites(workspaceDir)).map(s => ({
397
+ const sites = (await discoverSites(workspaceDir)).map((s) => ({
263
398
  path: join(workspaceDir, s.path),
264
- name: s.name,
399
+ name: s.name
265
400
  }))
266
401
 
267
402
  if (sites.length === 0) {
@@ -286,13 +421,21 @@ export async function doctor(args = []) {
286
421
  info(`Checking site: ${siteName}`)
287
422
 
288
423
  if (!siteYml) {
289
- issues.push({ type: 'error', site: siteName, message: 'Missing site.yml' })
424
+ issues.push({
425
+ type: 'error',
426
+ site: siteName,
427
+ message: 'Missing site.yml'
428
+ })
290
429
  error('Missing site.yml')
291
430
  continue
292
431
  }
293
432
 
294
433
  if (!sitePkg) {
295
- issues.push({ type: 'error', site: siteName, message: 'Missing package.json' })
434
+ issues.push({
435
+ type: 'error',
436
+ site: siteName,
437
+ message: 'Missing package.json'
438
+ })
296
439
  error('Missing package.json')
297
440
  continue
298
441
  }
@@ -309,14 +452,37 @@ export async function doctor(args = []) {
309
452
  id: 'agents-index-relative-links',
310
453
  type: 'warning',
311
454
  site: siteName,
312
- message: 'llms.txt will use root-relative links because seo.baseUrl is unset',
455
+ message:
456
+ 'llms.txt will use root-relative links because seo.baseUrl is unset'
313
457
  }
314
458
  issues.push(issue)
315
459
  warn(`[agents-index-relative-links] llms.txt links will be root-relative`)
316
- log(` Set ${colors.green}seo.baseUrl${colors.reset} in site.yml so agents get absolute URLs`)
317
- log(` (this also turns on sitemap.xml and robots.txt, which are skipped without it)`)
460
+ log(
461
+ ` Set ${colors.green}seo.baseUrl${colors.reset} in site.yml so agents get absolute URLs`
462
+ )
463
+ log(
464
+ ` (this also turns on sitemap.xml and robots.txt, which are skipped without it)`
465
+ )
318
466
  }
319
467
 
468
+ // `public/<DATA_DIR>/` is the build's output directory and nothing else —
469
+ // `collections/` is the only supported way to provide structured data. That
470
+ // makes the mapping a bijection: every entry there should be backed by a
471
+ // declared collection, so anything else is stale, and identifiable.
472
+ //
473
+ // It matters because the directory is written into the source tree rather
474
+ // than dist/, so what lands there persists and gets deployed. A collection
475
+ // removed from site.yml leaves its compiled records behind — visible to
476
+ // anyone who knows the URL, listed by nothing.
477
+ checkGeneratedDataDir({
478
+ sitePath,
479
+ siteName,
480
+ siteYml,
481
+ issues,
482
+ shouldFix,
483
+ fixed
484
+ })
485
+
320
486
  const foundationName = siteYml.foundation
321
487
  if (!foundationName) {
322
488
  warn('No foundation specified in site.yml (using runtime loading?)')
@@ -324,28 +490,41 @@ export async function doctor(args = []) {
324
490
  }
325
491
 
326
492
  // Check if foundation name matches a known foundation
327
- const matchingFoundation = foundations.find(f => f.name === foundationName)
493
+ const matchingFoundation = foundations.find(
494
+ (f) => f.name === foundationName
495
+ )
328
496
 
329
497
  if (!matchingFoundation) {
330
498
  // Check if it might match a folder name instead
331
- const folderMatch = foundations.find(f => f.folderName === foundationName)
499
+ const folderMatch = foundations.find(
500
+ (f) => f.folderName === foundationName
501
+ )
332
502
  if (folderMatch) {
333
503
  const issue = {
334
504
  id: 'foundation-name-mismatch',
335
505
  type: 'error',
336
506
  site: siteName,
337
- message: `Foundation mismatch: site.yml uses folder name "${foundationName}" instead of package name "${folderMatch.name}"`,
507
+ message: `Foundation mismatch: site.yml uses folder name "${foundationName}" instead of package name "${folderMatch.name}"`
338
508
  }
339
509
  issues.push(issue)
340
510
  error(`[foundation-name-mismatch] Foundation mismatch:`)
341
- log(` site.yml says: ${colors.yellow}foundation: ${foundationName}${colors.reset}`)
342
- log(` This matches the folder name, but the package name is: ${colors.green}${folderMatch.name}${colors.reset}`)
511
+ log(
512
+ ` site.yml says: ${colors.yellow}foundation: ${foundationName}${colors.reset}`
513
+ )
514
+ log(
515
+ ` This matches the folder name, but the package name is: ${colors.green}${folderMatch.name}${colors.reset}`
516
+ )
343
517
  if (shouldFix('foundation-name-mismatch')) {
344
518
  const siteYmlPath = join(sitePath, 'site.yml')
345
519
  const updated = { ...siteYml, foundation: folderMatch.name }
346
- writeFileSync(siteYmlPath, yaml.dump(updated, { flowLevel: -1, quotingType: "'" }))
520
+ writeFileSync(
521
+ siteYmlPath,
522
+ yaml.dump(updated, { flowLevel: -1, quotingType: "'" })
523
+ )
347
524
  issue.fixed = true
348
- fixed(`${relative(workspaceDir, siteYmlPath)} now references "${folderMatch.name}"`)
525
+ fixed(
526
+ `${relative(workspaceDir, siteYmlPath)} now references "${folderMatch.name}"`
527
+ )
349
528
  } else {
350
529
  log('')
351
530
  log(` ${colors.dim}To fix, update site.yml:${colors.reset}`)
@@ -379,21 +558,30 @@ export async function doctor(args = []) {
379
558
  id: 'missing-foundation-dep',
380
559
  type: 'error',
381
560
  site: siteName,
382
- message: `Missing dependency "${foundationName}" in package.json`,
561
+ message: `Missing dependency "${foundationName}" in package.json`
383
562
  }
384
563
  issues.push(issue)
385
- error(`[missing-foundation-dep] Missing dependency "${foundationName}" in package.json`)
564
+ error(
565
+ `[missing-foundation-dep] Missing dependency "${foundationName}" in package.json`
566
+ )
386
567
  const expectedPath = `file:${relative(sitePath, matchingFoundation.path)}`
387
568
  if (shouldFix('missing-foundation-dep')) {
388
569
  const sitePkgPath = join(sitePath, 'package.json')
389
570
  const updatedPkg = { ...sitePkg }
390
- updatedPkg.dependencies = { ...(updatedPkg.dependencies || {}), [foundationName]: expectedPath }
571
+ updatedPkg.dependencies = {
572
+ ...(updatedPkg.dependencies || {}),
573
+ [foundationName]: expectedPath
574
+ }
391
575
  writeJsonPreservingStyle(sitePkgPath, updatedPkg)
392
576
  issue.fixed = true
393
- fixed(`added "${foundationName}": "${expectedPath}" to ${relative(workspaceDir, sitePkgPath)}`)
577
+ fixed(
578
+ `added "${foundationName}": "${expectedPath}" to ${relative(workspaceDir, sitePkgPath)}`
579
+ )
394
580
  } else {
395
581
  log('')
396
- log(` ${colors.dim}Add to site's package.json dependencies:${colors.reset}`)
582
+ log(
583
+ ` ${colors.dim}Add to site's package.json dependencies:${colors.reset}`
584
+ )
397
585
  log(` "${foundationName}": "${expectedPath}"`)
398
586
  }
399
587
  continue
@@ -406,7 +594,7 @@ export async function doctor(args = []) {
406
594
  id: 'stale-file-path',
407
595
  type: 'error',
408
596
  site: siteName,
409
- message: `Dependency path doesn't exist: ${depValue}`,
597
+ message: `Dependency path doesn't exist: ${depValue}`
410
598
  }
411
599
  issues.push(issue)
412
600
  error(`[stale-file-path] Dependency path doesn't exist: ${depValue}`)
@@ -414,10 +602,15 @@ export async function doctor(args = []) {
414
602
  if (shouldFix('stale-file-path')) {
415
603
  const sitePkgPath = join(sitePath, 'package.json')
416
604
  const updatedPkg = { ...sitePkg }
417
- updatedPkg.dependencies = { ...(updatedPkg.dependencies || {}), [foundationName]: expectedPath }
605
+ updatedPkg.dependencies = {
606
+ ...(updatedPkg.dependencies || {}),
607
+ [foundationName]: expectedPath
608
+ }
418
609
  writeJsonPreservingStyle(sitePkgPath, updatedPkg)
419
610
  issue.fixed = true
420
- fixed(`updated "${foundationName}" to "${expectedPath}" in ${relative(workspaceDir, sitePkgPath)}`)
611
+ fixed(
612
+ `updated "${foundationName}" to "${expectedPath}" in ${relative(workspaceDir, sitePkgPath)}`
613
+ )
421
614
  } else {
422
615
  log('')
423
616
  log(` ${colors.dim}Update site's package.json:${colors.reset}`)
@@ -439,7 +632,12 @@ export async function doctor(args = []) {
439
632
  matchingFoundation,
440
633
  workspaceDir
441
634
  )) {
442
- issues.push({ id: finding.id, type: finding.severity, site: siteName, message: finding.message })
635
+ issues.push({
636
+ id: finding.id,
637
+ type: finding.severity,
638
+ site: siteName,
639
+ message: finding.message
640
+ })
443
641
  error(`[${finding.id}] ${finding.message}`)
444
642
  log(`${colors.dim}${finding.detail}${colors.reset}`)
445
643
  log('')
@@ -516,7 +714,7 @@ export async function doctor(args = []) {
516
714
  // Check if any foundation with extension: true is wired as a primary foundation
517
715
  for (const f of foundations) {
518
716
  if (isExtensionPackage(f.path)) {
519
- const sitesUsingAsPrimary = sites.filter(s => {
717
+ const sitesUsingAsPrimary = sites.filter((s) => {
520
718
  const siteYml = loadSiteYml(s.path)
521
719
  return siteYml?.foundation === f.name
522
720
  })
@@ -526,8 +724,12 @@ export async function doctor(args = []) {
526
724
  site: site.name,
527
725
  message: `Foundation "${f.name}" declares extension: true but is wired as the primary foundation. It should be in extensions: instead.`
528
726
  })
529
- warn(`"${f.name}" is an extension but used as primary foundation in site "${site.name}"`)
530
- log(` ${colors.dim}Move it to extensions: in site.yml instead of foundation:${colors.reset}`)
727
+ warn(
728
+ `"${f.name}" is an extension but used as primary foundation in site "${site.name}"`
729
+ )
730
+ log(
731
+ ` ${colors.dim}Move it to extensions: in site.yml instead of foundation:${colors.reset}`
732
+ )
531
733
  }
532
734
  }
533
735
  }
@@ -541,16 +743,23 @@ export async function doctor(args = []) {
541
743
  if (typeof extUrl !== 'string') continue
542
744
 
543
745
  // Skip remote URLs — can't validate those
544
- if (extUrl.startsWith('http://') || extUrl.startsWith('https://')) continue
746
+ if (extUrl.startsWith('http://') || extUrl.startsWith('https://'))
747
+ continue
545
748
 
546
749
  // Local extension URL: check if it maps to a known extension
547
750
  // URLs like /effects/foundation.js or /extensions/effects/foundation.js
548
751
  const urlParts = extUrl.replace(/^\//, '').split('/')
549
- const extName = urlParts.length >= 2 ? urlParts[urlParts.length - 2] : urlParts[0]
752
+ const extName =
753
+ urlParts.length >= 2 ? urlParts[urlParts.length - 2] : urlParts[0]
550
754
 
551
755
  // Check if a matching extension exists and is built
552
- const matchingExt = extensions.find(e => e.folderName === extName || e.name === extName)
553
- if (matchingExt && !existsSync(join(matchingExt.path, 'dist', 'entry.js'))) {
756
+ const matchingExt = extensions.find(
757
+ (e) => e.folderName === extName || e.name === extName
758
+ )
759
+ if (
760
+ matchingExt &&
761
+ !existsSync(join(matchingExt.path, 'dist', 'entry.js'))
762
+ ) {
554
763
  issues.push({
555
764
  type: 'warn',
556
765
  site: site.name,
@@ -582,9 +791,11 @@ export async function doctor(args = []) {
582
791
  id: 'deploy-yml-malformed',
583
792
  type: 'error',
584
793
  site: siteName,
585
- message: `deploy.yml is malformed: ${err.message}`,
794
+ message: `deploy.yml is malformed: ${err.message}`
586
795
  })
587
- error(`[deploy-yml-malformed] ${relative(workspaceDir, deployYmlPath)}: ${err.message}`)
796
+ error(
797
+ `[deploy-yml-malformed] ${relative(workspaceDir, deployYmlPath)}: ${err.message}`
798
+ )
588
799
  continue
589
800
  }
590
801
  if (!deployYml) continue
@@ -598,10 +809,14 @@ export async function doctor(args = []) {
598
809
  id: 'deploy-yml-default-unknown-target',
599
810
  type: 'error',
600
811
  site: siteName,
601
- message: `deploy.yml: default '${deployYml.default}' is not in targets (known: ${targetNames.join(', ') || '(none)'})`,
812
+ message: `deploy.yml: default '${deployYml.default}' is not in targets (known: ${targetNames.join(', ') || '(none)'})`
602
813
  })
603
- error(`[deploy-yml-default-unknown-target] ${siteName}: default '${deployYml.default}' is not declared under \`targets:\`.`)
604
- log(` ${colors.dim}Add it to targets: or change default: to one of: ${targetNames.join(', ') || '(none)'}.${colors.reset}`)
814
+ error(
815
+ `[deploy-yml-default-unknown-target] ${siteName}: default '${deployYml.default}' is not declared under \`targets:\`.`
816
+ )
817
+ log(
818
+ ` ${colors.dim}Add it to targets: or change default: to one of: ${targetNames.join(', ') || '(none)'}.${colors.reset}`
819
+ )
605
820
  }
606
821
 
607
822
  // Each target must have a host, and host should be a known adapter.
@@ -612,9 +827,11 @@ export async function doctor(args = []) {
612
827
  id: 'deploy-yml-target-missing-host',
613
828
  type: 'error',
614
829
  site: siteName,
615
- message: `deploy.yml: targets.${name} is missing \`host\``,
830
+ message: `deploy.yml: targets.${name} is missing \`host\``
616
831
  })
617
- error(`[deploy-yml-target-missing-host] ${siteName}: targets.${name} has no \`host\` field.`)
832
+ error(
833
+ `[deploy-yml-target-missing-host] ${siteName}: targets.${name} has no \`host\` field.`
834
+ )
618
835
  continue
619
836
  }
620
837
  // 'uniweb' is the platform default and isn't in the static-host
@@ -624,10 +841,14 @@ export async function doctor(args = []) {
624
841
  id: 'deploy-yml-unknown-host',
625
842
  type: 'warn',
626
843
  site: siteName,
627
- message: `deploy.yml: targets.${name}.host '${cfg.host}' is not a known adapter`,
844
+ message: `deploy.yml: targets.${name}.host '${cfg.host}' is not a known adapter`
628
845
  })
629
- warn(`[deploy-yml-unknown-host] ${siteName}: targets.${name}.host: '${cfg.host}' is not a known built-in adapter.`)
630
- log(` ${colors.dim}Known: ${[...knownAdapters].sort().join(', ')}, plus 'uniweb'.${colors.reset}`)
846
+ warn(
847
+ `[deploy-yml-unknown-host] ${siteName}: targets.${name}.host: '${cfg.host}' is not a known built-in adapter.`
848
+ )
849
+ log(
850
+ ` ${colors.dim}Known: ${[...knownAdapters].sort().join(', ')}, plus 'uniweb'.${colors.reset}`
851
+ )
631
852
  }
632
853
  }
633
854
 
@@ -637,7 +858,9 @@ export async function doctor(args = []) {
637
858
  // configured. A drift here means a deploy will silently lose the
638
859
  // custom domain.
639
860
  const cnamePath = join(sitePath, 'public', 'CNAME')
640
- const ghTarget = Object.entries(targets).find(([, c]) => c?.host === 'github-pages')
861
+ const ghTarget = Object.entries(targets).find(
862
+ ([, c]) => c?.host === 'github-pages'
863
+ )
641
864
  if (ghTarget) {
642
865
  const [ghName, ghCfg] = ghTarget
643
866
  if (ghCfg.domain) {
@@ -655,9 +878,10 @@ export async function doctor(args = []) {
655
878
  id: 'github-pages-cname-mismatch',
656
879
  type: 'warn',
657
880
  site: siteName,
658
- message: actual === null
659
- ? `deploy.yml: targets.${ghName}.domain is '${expected}' but ${relative(workspaceDir, cnamePath)} is missing`
660
- : `deploy.yml: targets.${ghName}.domain is '${expected}' but ${relative(workspaceDir, cnamePath)} contains '${actual}'`,
881
+ message:
882
+ actual === null
883
+ ? `deploy.yml: targets.${ghName}.domain is '${expected}' but ${relative(workspaceDir, cnamePath)} is missing`
884
+ : `deploy.yml: targets.${ghName}.domain is '${expected}' but ${relative(workspaceDir, cnamePath)} contains '${actual}'`
661
885
  }
662
886
  issues.push(issue)
663
887
  warn(`[github-pages-cname-mismatch] ${siteName}: ${issue.message}`)
@@ -668,7 +892,9 @@ export async function doctor(args = []) {
668
892
  issue.fixed = true
669
893
  fixed(`wrote ${relative(workspaceDir, cnamePath)} = ${expected}`)
670
894
  } else {
671
- log(` ${colors.dim}Re-run \`uniweb add ci --domain ${expected} --force\` (or pass --fix to write CNAME from deploy.yml).${colors.reset}`)
895
+ log(
896
+ ` ${colors.dim}Re-run \`uniweb add ci --domain ${expected} --force\` (or pass --fix to write CNAME from deploy.yml).${colors.reset}`
897
+ )
672
898
  }
673
899
  }
674
900
  } else if (existsSync(cnamePath)) {
@@ -685,10 +911,14 @@ export async function doctor(args = []) {
685
911
  id: 'github-pages-cname-orphan',
686
912
  type: 'warn',
687
913
  site: siteName,
688
- message: `${relative(workspaceDir, cnamePath)} exists ('${actual}') but deploy.yml's github-pages target has no \`domain\``,
914
+ message: `${relative(workspaceDir, cnamePath)} exists ('${actual}') but deploy.yml's github-pages target has no \`domain\``
689
915
  })
690
- warn(`[github-pages-cname-orphan] ${siteName}: ${relative(workspaceDir, cnamePath)} exists ('${actual}') but the github-pages target has no \`domain\` set.`)
691
- log(` ${colors.dim}Either add \`domain: ${actual}\` to targets.${ghName}, or delete the CNAME if you no longer want a custom domain.${colors.reset}`)
916
+ warn(
917
+ `[github-pages-cname-orphan] ${siteName}: ${relative(workspaceDir, cnamePath)} exists ('${actual}') but the github-pages target has no \`domain\` set.`
918
+ )
919
+ log(
920
+ ` ${colors.dim}Either add \`domain: ${actual}\` to targets.${ghName}, or delete the CNAME if you no longer want a custom domain.${colors.reset}`
921
+ )
692
922
  }
693
923
  }
694
924
 
@@ -698,17 +928,26 @@ export async function doctor(args = []) {
698
928
  // Not an error — site.yml's value is just dormant for that target —
699
929
  // but worth flagging so the user knows the value isn't taking effect.
700
930
  const siteYml = loadSiteYml(sitePath)
701
- const workflowPath = join(workspaceDir, '.github/workflows/deploy-github-pages.yml')
931
+ const workflowPath = join(
932
+ workspaceDir,
933
+ '.github/workflows/deploy-github-pages.yml'
934
+ )
702
935
  if (siteYml?.base && existsSync(workflowPath) && ghTarget) {
703
936
  issues.push({
704
937
  id: 'site-base-overridden-by-workflow',
705
938
  type: 'warn',
706
939
  site: siteName,
707
- message: `site.yml::base ('${siteYml.base}') is dormant — the GH Pages workflow sets UNIWEB_BASE at build time, which takes precedence`,
940
+ message: `site.yml::base ('${siteYml.base}') is dormant — the GH Pages workflow sets UNIWEB_BASE at build time, which takes precedence`
708
941
  })
709
- warn(`[site-base-overridden-by-workflow] ${siteName}: site.yml has \`base: ${siteYml.base}\`, but the GH Pages workflow sets UNIWEB_BASE.`)
710
- log(` ${colors.dim}site.yml::base is the fallback when no UNIWEB_BASE env var is set. The workflow always sets it, so this value is ignored on GH Pages deploys.${colors.reset}`)
711
- log(` ${colors.dim}If the GH Pages deploy is the only target, you can remove \`base:\` from site.yml. Otherwise leave it — it still applies to other deploy targets.${colors.reset}`)
942
+ warn(
943
+ `[site-base-overridden-by-workflow] ${siteName}: site.yml has \`base: ${siteYml.base}\`, but the GH Pages workflow sets UNIWEB_BASE.`
944
+ )
945
+ log(
946
+ ` ${colors.dim}site.yml::base is the fallback when no UNIWEB_BASE env var is set. The workflow always sets it, so this value is ignored on GH Pages deploys.${colors.reset}`
947
+ )
948
+ log(
949
+ ` ${colors.dim}If the GH Pages deploy is the only target, you can remove \`base:\` from site.yml. Otherwise leave it — it still applies to other deploy targets.${colors.reset}`
950
+ )
712
951
  }
713
952
  }
714
953
 
@@ -717,12 +956,17 @@ export async function doctor(args = []) {
717
956
  // Check @uniweb/* dep alignment with the running CLI
718
957
  log('')
719
958
  const depSurvey = await surveyWorkspaceDeps(workspaceDir)
720
- const behindDeps = depSurvey.rows.filter(r => r.status === 'behind')
959
+ const behindDeps = depSurvey.rows.filter((r) => r.status === 'behind')
721
960
  if (behindDeps.length > 0) {
722
- const names = [...new Set(behindDeps.map(r => r.name))].sort()
723
- warn(`${behindDeps.length} workspace dep declaration${behindDeps.length === 1 ? '' : 's'} lag the CLI (v${cliVersion}): ${names.join(', ')}`)
961
+ const names = [...new Set(behindDeps.map((r) => r.name))].sort()
962
+ warn(
963
+ `${behindDeps.length} workspace dep declaration${behindDeps.length === 1 ? '' : 's'} lag the CLI (v${cliVersion}): ${names.join(', ')}`
964
+ )
724
965
  info(`Run: uniweb update`)
725
- issues.push({ type: 'warn', message: `${behindDeps.length} @uniweb/* dep declaration(s) behind CLI v${cliVersion}` })
966
+ issues.push({
967
+ type: 'warn',
968
+ message: `${behindDeps.length} @uniweb/* dep declaration(s) behind CLI v${cliVersion}`
969
+ })
726
970
  } else if (depSurvey.anyAhead) {
727
971
  success(`@uniweb/* deps are aligned or ahead of the CLI (v${cliVersion})`)
728
972
  } else {
@@ -746,7 +990,10 @@ export async function doctor(args = []) {
746
990
  } else if (agentsVersion !== cliVersion) {
747
991
  warn(`AGENTS.md is outdated (v${agentsVersion} → v${cliVersion})`)
748
992
  info(`Run: uniweb update`)
749
- issues.push({ type: 'warn', message: `AGENTS.md outdated (v${agentsVersion} → v${cliVersion})` })
993
+ issues.push({
994
+ type: 'warn',
995
+ message: `AGENTS.md outdated (v${agentsVersion} → v${cliVersion})`
996
+ })
750
997
  } else {
751
998
  success(`AGENTS.md is up to date (v${cliVersion})`)
752
999
  }
@@ -757,9 +1004,9 @@ export async function doctor(args = []) {
757
1004
 
758
1005
  // Fixed issues no longer count toward errors/warnings — they're
759
1006
  // resolved during this run. Exit code is based on what remains.
760
- const errors = issues.filter(i => i.type === 'error' && !i.fixed)
761
- const warnings = issues.filter(i => i.type === 'warn' && !i.fixed)
762
- const fixedCount = issues.filter(i => i.fixed).length
1007
+ const errors = issues.filter((i) => i.type === 'error' && !i.fixed)
1008
+ const warnings = issues.filter((i) => i.type === 'warn' && !i.fixed)
1009
+ const fixedCount = issues.filter((i) => i.fixed).length
763
1010
 
764
1011
  if (errors.length === 0 && warnings.length === 0 && fixedCount === 0) {
765
1012
  log('')
@@ -774,13 +1021,22 @@ export async function doctor(args = []) {
774
1021
  log(`${colors.red}${errors.length} error(s) remaining${colors.reset}`)
775
1022
  }
776
1023
  if (warnings.length > 0) {
777
- log(`${colors.yellow}${warnings.length} warning(s) remaining${colors.reset}`)
1024
+ log(
1025
+ `${colors.yellow}${warnings.length} warning(s) remaining${colors.reset}`
1026
+ )
778
1027
  }
779
1028
  if (fixFlag && (errors.length > 0 || warnings.length > 0)) {
780
- log(`${colors.dim}Some issues were not auto-fixable. See the diagnostics above.${colors.reset}`)
1029
+ log(
1030
+ `${colors.dim}Some issues were not auto-fixable. See the diagnostics above.${colors.reset}`
1031
+ )
781
1032
  }
782
1033
  log('')
783
1034
  }
784
1035
 
785
- return { issues, errors: errors.length, warnings: warnings.length, fixed: fixedCount }
1036
+ return {
1037
+ issues,
1038
+ errors: errors.length,
1039
+ warnings: warnings.length,
1040
+ fixed: fixedCount
1041
+ }
786
1042
  }