uniweb 0.12.2 → 0.12.4

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.
@@ -2,12 +2,30 @@
2
2
  * uniweb doctor - Diagnose project configuration issues
3
3
  */
4
4
 
5
- import { existsSync, readFileSync, readdirSync } from 'node:fs'
5
+ import { existsSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'
6
6
  import { join, resolve, basename, dirname, relative } from 'node:path'
7
7
  import yaml from 'js-yaml'
8
8
  import { resolveFoundationSrcPath, classifyPackage, isExtensionPackage as buildIsExtensionPackage } from '@uniweb/build'
9
9
  import { getCliVersion } from '../versions.js'
10
10
  import { readAgentsVersion } from '../utils/agents-stamp.js'
11
+ import { discoverFoundations, discoverSites } from '../utils/config.js'
12
+ import { findWorkspaceRoot } from '../utils/workspace.js'
13
+
14
+ /**
15
+ * Parse the `--fix [<issue-id>]` flag.
16
+ *
17
+ * Returns:
18
+ * null — no auto-fix requested.
19
+ * 'all' — fix every auto-fixable issue.
20
+ * '<issue-id>' — fix only issues with this id.
21
+ */
22
+ function parseFixFlag(args) {
23
+ const idx = args.indexOf('--fix')
24
+ if (idx === -1) return null
25
+ const next = args[idx + 1]
26
+ if (!next || next.startsWith('--')) return 'all'
27
+ return next
28
+ }
11
29
 
12
30
  // ANSI colors
13
31
  const colors = {
@@ -119,29 +137,20 @@ export async function doctor(args = []) {
119
137
  log(`${colors.blue}${colors.bright}Uniweb Doctor${colors.reset}`)
120
138
  log(`${colors.dim}Checking project configuration...${colors.reset}`)
121
139
 
122
- const projectDir = resolve(process.cwd())
123
-
124
- // Detect project type and find workspace root
125
- let workspaceDir = projectDir
140
+ // Auto-fix mode. `--fix` alone fixes every recoverable issue;
141
+ // `--fix <issue-id>` targets one. Per-issue fix logic lives next to
142
+ // each diagnostic below so the rewrite happens with full context.
143
+ const fixFlag = parseFixFlag(args)
144
+ const shouldFix = (id) => fixFlag === 'all' || fixFlag === id
145
+ const fixed = (msg) => console.log(` ${colors.green}↳ Fixed:${colors.reset} ${msg}`)
126
146
 
127
- if (isSite(projectDir)) {
128
- workspaceDir = dirname(projectDir)
129
- if (basename(workspaceDir) === 'sites') {
130
- workspaceDir = dirname(workspaceDir)
131
- }
132
- } else if (isFoundation(projectDir)) {
133
- workspaceDir = dirname(projectDir)
134
- const parentName = basename(workspaceDir)
135
- if (parentName === 'foundations' || parentName === 'extensions') {
136
- workspaceDir = dirname(workspaceDir)
137
- }
138
- }
147
+ const projectDir = resolve(process.cwd())
139
148
 
140
- // Check workspace structure
141
- const hasWorkspaceConfig = existsSync(join(workspaceDir, 'pnpm-workspace.yaml')) ||
142
- existsSync(join(workspaceDir, 'package.json'))
149
+ // Find workspace root via the canonical primitive (recognizes
150
+ // pnpm-workspace.yaml or package.json::workspaces).
151
+ const workspaceDir = findWorkspaceRoot(projectDir)
143
152
 
144
- if (!hasWorkspaceConfig) {
153
+ if (!workspaceDir) {
145
154
  error('Not in a Uniweb workspace')
146
155
  log(`${colors.dim}Run this command from your project root or a site/foundation directory.${colors.reset}`)
147
156
  process.exit(1)
@@ -150,65 +159,76 @@ export async function doctor(args = []) {
150
159
  log('')
151
160
  info(`Workspace: ${workspaceDir}`)
152
161
 
153
- // Find all foundations
154
- const foundations = []
155
-
156
- // Check single-foundation layout
157
- const foundationDir = join(workspaceDir, 'foundation')
158
- if (isFoundation(foundationDir)) {
159
- const pkg = loadPackageJson(foundationDir)
160
- foundations.push({
161
- path: foundationDir,
162
- name: pkg?.name || 'foundation',
163
- folderName: 'foundation'
164
- })
165
- }
166
-
167
- // Check multi-foundation layout
168
- const foundationsDir = join(workspaceDir, 'foundations')
169
- if (existsSync(foundationsDir)) {
162
+ // Workspace manifest sync — keep `pnpm-workspace.yaml::packages` and
163
+ // `package.json::workspaces` aligned. The CLI writes both on every
164
+ // mutation (see addWorkspaceGlob in utils/config.js), but a user who
165
+ // manually edits one can introduce drift. Drift breaks projects that
166
+ // switch package managers (pnpm-workspace.yaml is pnpm-only;
167
+ // package.json::workspaces is what npm and yarn read).
168
+ const issues = []
169
+ const ymlPath = join(workspaceDir, 'pnpm-workspace.yaml')
170
+ if (existsSync(ymlPath)) {
171
+ let ymlPackages = []
170
172
  try {
171
- const entries = readdirSync(foundationsDir, { withFileTypes: true })
172
- for (const entry of entries) {
173
- if (entry.isDirectory()) {
174
- const foundationPath = join(foundationsDir, entry.name)
175
- if (isFoundation(foundationPath)) {
176
- const pkg = loadPackageJson(foundationPath)
177
- foundations.push({
178
- path: foundationPath,
179
- name: pkg?.name || entry.name,
180
- folderName: entry.name
181
- })
182
- }
183
- }
184
- }
173
+ ymlPackages = yaml.load(readFileSync(ymlPath, 'utf8'))?.packages || []
185
174
  } catch {
186
- // Ignore errors
175
+ // Malformed yaml — flag separately
176
+ issues.push({
177
+ id: 'workspace-yaml-malformed',
178
+ type: 'error',
179
+ message: 'pnpm-workspace.yaml is malformed and could not be parsed.',
180
+ })
181
+ error('pnpm-workspace.yaml is malformed and could not be parsed.')
182
+ }
183
+ const rootPkg = loadPackageJson(workspaceDir)
184
+ const pkgWorkspaces = Array.isArray(rootPkg?.workspaces) ? rootPkg.workspaces : []
185
+ const ymlSet = new Set(ymlPackages)
186
+ const pkgSet = new Set(pkgWorkspaces)
187
+ const onlyInYml = [...ymlSet].filter(g => !pkgSet.has(g))
188
+ const onlyInPkg = [...pkgSet].filter(g => !ymlSet.has(g))
189
+ if (onlyInYml.length || onlyInPkg.length) {
190
+ const issue = {
191
+ id: 'workspace-manifests-out-of-sync',
192
+ type: 'warn',
193
+ message: `pnpm-workspace.yaml and package.json::workspaces declare different package globs.`,
194
+ details: { onlyInYml, onlyInPkg },
195
+ }
196
+ issues.push(issue)
197
+ warn(`[workspace-manifests-out-of-sync] pnpm-workspace.yaml and package.json::workspaces are out of sync:`)
198
+ if (onlyInYml.length) log(` only in pnpm-workspace.yaml: ${onlyInYml.join(', ')}`)
199
+ if (onlyInPkg.length) log(` only in package.json::workspaces: ${onlyInPkg.join(', ')}`)
200
+ if (shouldFix('workspace-manifests-out-of-sync')) {
201
+ // Canonical resolution: write the union to both manifests.
202
+ const union = Array.from(new Set([...ymlPackages, ...pkgWorkspaces])).sort()
203
+ writeFileSync(ymlPath, yaml.dump({ packages: union }, { flowLevel: -1, quotingType: '"' }))
204
+ const rootPkgPath = join(workspaceDir, 'package.json')
205
+ const rootPkg = JSON.parse(readFileSync(rootPkgPath, 'utf-8'))
206
+ rootPkg.workspaces = union
207
+ writeFileSync(rootPkgPath, JSON.stringify(rootPkg, null, 2) + '\n')
208
+ issue.fixed = true
209
+ fixed(`wrote union [${union.join(', ')}] to both manifests`)
210
+ } else {
211
+ 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}`)
212
+ }
187
213
  }
188
214
  }
189
215
 
190
- // Discover extensions
216
+ // Discover foundations + sites via the canonical workspace globs.
217
+ // Doctor used to walk fixed paths (`foundation/`, `foundations/*`) which
218
+ // missed the default-path `src/` shape that Thread D made canonical.
219
+ // Using the same primitives every other command uses keeps doctor in
220
+ // step with whatever layout the workspace has.
221
+ const discovered = await discoverFoundations(workspaceDir)
222
+ const foundations = []
191
223
  const extensions = []
192
-
193
- const extensionsDir = join(workspaceDir, 'extensions')
194
- if (existsSync(extensionsDir)) {
195
- try {
196
- const entries = readdirSync(extensionsDir, { withFileTypes: true })
197
- for (const entry of entries) {
198
- if (entry.isDirectory()) {
199
- const extensionPath = join(extensionsDir, entry.name)
200
- if (isFoundation(extensionPath)) {
201
- const pkg = loadPackageJson(extensionPath)
202
- extensions.push({
203
- path: extensionPath,
204
- name: pkg?.name || entry.name,
205
- folderName: entry.name
206
- })
207
- }
208
- }
209
- }
210
- } catch {
211
- // Ignore errors
224
+ for (const f of discovered) {
225
+ const fullPath = join(workspaceDir, f.path)
226
+ const folderName = basename(f.path)
227
+ const entry = { path: fullPath, name: f.name, folderName }
228
+ if (buildIsExtensionPackage(fullPath)) {
229
+ extensions.push(entry)
230
+ } else {
231
+ foundations.push(entry)
212
232
  }
213
233
  }
214
234
 
@@ -231,32 +251,12 @@ export async function doctor(args = []) {
231
251
  }
232
252
  }
233
253
 
234
- // Find all sites
235
- const sites = []
236
-
237
- // Check single-site layout
238
- const siteDir = join(workspaceDir, 'site')
239
- if (isSite(siteDir)) {
240
- sites.push({ path: siteDir, name: 'site' })
241
- }
242
-
243
- // Check multi-site layout
244
- const sitesDir = join(workspaceDir, 'sites')
245
- if (existsSync(sitesDir)) {
246
- try {
247
- const entries = readdirSync(sitesDir, { withFileTypes: true })
248
- for (const entry of entries) {
249
- if (entry.isDirectory()) {
250
- const sitePath = join(sitesDir, entry.name)
251
- if (isSite(sitePath)) {
252
- sites.push({ path: sitePath, name: entry.name })
253
- }
254
- }
255
- }
256
- } catch {
257
- // Ignore errors
258
- }
259
- }
254
+ // Discover sites via the canonical workspace globs (same rationale as
255
+ // foundations above: respects whatever layout the user chose).
256
+ const sites = (await discoverSites(workspaceDir)).map(s => ({
257
+ path: join(workspaceDir, s.path),
258
+ name: s.name,
259
+ }))
260
260
 
261
261
  if (sites.length === 0) {
262
262
  warn('No sites found')
@@ -268,9 +268,8 @@ export async function doctor(args = []) {
268
268
  }
269
269
  }
270
270
 
271
- // Check each site
272
- const issues = []
273
-
271
+ // Check each site (issues array was declared earlier alongside the
272
+ // workspace-manifest-sync check).
274
273
  for (const site of sites) {
275
274
  const siteName = site.name
276
275
  const sitePath = site.path
@@ -305,17 +304,27 @@ export async function doctor(args = []) {
305
304
  // Check if it might match a folder name instead
306
305
  const folderMatch = foundations.find(f => f.folderName === foundationName)
307
306
  if (folderMatch) {
308
- issues.push({
307
+ const issue = {
308
+ id: 'foundation-name-mismatch',
309
309
  type: 'error',
310
310
  site: siteName,
311
- message: `Foundation mismatch: site.yml uses folder name "${foundationName}" instead of package name "${folderMatch.name}"`
312
- })
313
- error(`Foundation mismatch:`)
311
+ message: `Foundation mismatch: site.yml uses folder name "${foundationName}" instead of package name "${folderMatch.name}"`,
312
+ }
313
+ issues.push(issue)
314
+ error(`[foundation-name-mismatch] Foundation mismatch:`)
314
315
  log(` site.yml says: ${colors.yellow}foundation: ${foundationName}${colors.reset}`)
315
316
  log(` This matches the folder name, but the package name is: ${colors.green}${folderMatch.name}${colors.reset}`)
316
- log('')
317
- log(` ${colors.dim}To fix, update site.yml:${colors.reset}`)
318
- log(` foundation: ${folderMatch.name}`)
317
+ if (shouldFix('foundation-name-mismatch')) {
318
+ const siteYmlPath = join(sitePath, 'site.yml')
319
+ const updated = { ...siteYml, foundation: folderMatch.name }
320
+ writeFileSync(siteYmlPath, yaml.dump(updated, { flowLevel: -1, quotingType: "'" }))
321
+ issue.fixed = true
322
+ fixed(`${relative(workspaceDir, siteYmlPath)} now references "${folderMatch.name}"`)
323
+ } else {
324
+ log('')
325
+ log(` ${colors.dim}To fix, update site.yml:${colors.reset}`)
326
+ log(` foundation: ${folderMatch.name}`)
327
+ }
319
328
  continue
320
329
  }
321
330
 
@@ -340,30 +349,54 @@ export async function doctor(args = []) {
340
349
  const depValue = deps[foundationName]
341
350
 
342
351
  if (!depValue) {
343
- issues.push({
352
+ const issue = {
353
+ id: 'missing-foundation-dep',
344
354
  type: 'error',
345
355
  site: siteName,
346
- message: `Missing dependency "${foundationName}" in package.json`
347
- })
348
- error(`Missing dependency "${foundationName}" in package.json`)
349
- log('')
350
- log(` ${colors.dim}Add to site's package.json dependencies:${colors.reset}`)
351
- log(` "${foundationName}": "file:${relative(sitePath, matchingFoundation.path)}"`)
356
+ message: `Missing dependency "${foundationName}" in package.json`,
357
+ }
358
+ issues.push(issue)
359
+ error(`[missing-foundation-dep] Missing dependency "${foundationName}" in package.json`)
360
+ const expectedPath = `file:${relative(sitePath, matchingFoundation.path)}`
361
+ if (shouldFix('missing-foundation-dep')) {
362
+ const sitePkgPath = join(sitePath, 'package.json')
363
+ const updatedPkg = { ...sitePkg }
364
+ updatedPkg.dependencies = { ...(updatedPkg.dependencies || {}), [foundationName]: expectedPath }
365
+ writeFileSync(sitePkgPath, JSON.stringify(updatedPkg, null, 2) + '\n')
366
+ issue.fixed = true
367
+ fixed(`added "${foundationName}": "${expectedPath}" to ${relative(workspaceDir, sitePkgPath)}`)
368
+ } else {
369
+ log('')
370
+ log(` ${colors.dim}Add to site's package.json dependencies:${colors.reset}`)
371
+ log(` "${foundationName}": "${expectedPath}"`)
372
+ }
352
373
  continue
353
374
  }
354
375
 
355
376
  if (depValue.startsWith('file:')) {
356
377
  const depPath = join(sitePath, depValue.slice(5))
357
378
  if (!existsSync(depPath)) {
358
- issues.push({
379
+ const issue = {
380
+ id: 'stale-file-path',
359
381
  type: 'error',
360
382
  site: siteName,
361
- message: `Dependency path doesn't exist: ${depValue}`
362
- })
363
- error(`Dependency path doesn't exist: ${depValue}`)
364
- log('')
365
- log(` ${colors.dim}Update site's package.json:${colors.reset}`)
366
- log(` "${foundationName}": "file:${relative(sitePath, matchingFoundation.path)}"`)
383
+ message: `Dependency path doesn't exist: ${depValue}`,
384
+ }
385
+ issues.push(issue)
386
+ error(`[stale-file-path] Dependency path doesn't exist: ${depValue}`)
387
+ const expectedPath = `file:${relative(sitePath, matchingFoundation.path)}`
388
+ if (shouldFix('stale-file-path')) {
389
+ const sitePkgPath = join(sitePath, 'package.json')
390
+ const updatedPkg = { ...sitePkg }
391
+ updatedPkg.dependencies = { ...(updatedPkg.dependencies || {}), [foundationName]: expectedPath }
392
+ writeFileSync(sitePkgPath, JSON.stringify(updatedPkg, null, 2) + '\n')
393
+ issue.fixed = true
394
+ fixed(`updated "${foundationName}" to "${expectedPath}" in ${relative(workspaceDir, sitePkgPath)}`)
395
+ } else {
396
+ log('')
397
+ log(` ${colors.dim}Update site's package.json:${colors.reset}`)
398
+ log(` "${foundationName}": "${expectedPath}"`)
399
+ }
367
400
  continue
368
401
  }
369
402
  success(`Dependency path: ${depValue}`)
@@ -512,23 +545,32 @@ export async function doctor(args = []) {
512
545
  log('')
513
546
  log('─'.repeat(50))
514
547
 
515
- const errors = issues.filter(i => i.type === 'error')
516
- const warnings = issues.filter(i => i.type === 'warn')
548
+ // Fixed issues no longer count toward errors/warnings — they're
549
+ // resolved during this run. Exit code is based on what remains.
550
+ const errors = issues.filter(i => i.type === 'error' && !i.fixed)
551
+ const warnings = issues.filter(i => i.type === 'warn' && !i.fixed)
552
+ const fixedCount = issues.filter(i => i.fixed).length
517
553
 
518
- if (errors.length === 0 && warnings.length === 0) {
554
+ if (errors.length === 0 && warnings.length === 0 && fixedCount === 0) {
519
555
  log('')
520
556
  log(`${colors.green}${colors.bright}All checks passed!${colors.reset}`)
521
557
  log('')
522
558
  } else {
523
559
  log('')
560
+ if (fixedCount > 0) {
561
+ log(`${colors.green}${fixedCount} issue(s) fixed${colors.reset}`)
562
+ }
524
563
  if (errors.length > 0) {
525
- log(`${colors.red}${errors.length} error(s)${colors.reset}`)
564
+ log(`${colors.red}${errors.length} error(s) remaining${colors.reset}`)
526
565
  }
527
566
  if (warnings.length > 0) {
528
- log(`${colors.yellow}${warnings.length} warning(s)${colors.reset}`)
567
+ log(`${colors.yellow}${warnings.length} warning(s) remaining${colors.reset}`)
568
+ }
569
+ if (fixFlag && (errors.length > 0 || warnings.length > 0)) {
570
+ log(`${colors.dim}Some issues were not auto-fixable. See the diagnostics above.${colors.reset}`)
529
571
  }
530
572
  log('')
531
573
  }
532
574
 
533
- return { issues, errors: errors.length, warnings: warnings.length }
575
+ return { issues, errors: errors.length, warnings: warnings.length, fixed: fixedCount }
534
576
  }
@@ -160,7 +160,7 @@ function showWebHandoff(email, name) {
160
160
  console.log('')
161
161
  info(`Web-based handoff`)
162
162
  console.log('')
163
- console.log(` 1. Create a site on ${colors.cyan}hub.uniweb.app${colors.reset} using ${colors.bright}${name}${colors.reset}`)
163
+ console.log(` 1. Create a site on ${colors.cyan}uniweb.app${colors.reset} using ${colors.bright}${name}${colors.reset}`)
164
164
  console.log(` 2. Add pages and content`)
165
165
  console.log(` 3. Transfer ownership to ${colors.bright}${email}${colors.reset}:`)
166
166
  console.log(` ${colors.dim}Settings → Transfer site${colors.reset}`)
@@ -275,10 +275,10 @@ async function handleCreate(args, email) {
275
275
  console.log('')
276
276
  if (isExtension) {
277
277
  console.log(` ${colors.dim}When ${invite.email} adds ${name} to their site${colors.reset}`)
278
- console.log(` ${colors.dim}on hub.uniweb.app or Studio, it will be authorized automatically.${colors.reset}`)
278
+ console.log(` ${colors.dim}on uniweb.app or Studio, it will be authorized automatically.${colors.reset}`)
279
279
  } else {
280
280
  console.log(` ${colors.dim}When ${invite.email} creates a site with ${name}${colors.reset}`)
281
- console.log(` ${colors.dim}on hub.uniweb.app or Studio, it will be authorized automatically.${colors.reset}`)
281
+ console.log(` ${colors.dim}on uniweb.app or Studio, it will be authorized automatically.${colors.reset}`)
282
282
  }
283
283
  } catch (err) {
284
284
  error(err.message)