uniweb 0.12.13 → 0.12.15

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/README.md CHANGED
@@ -294,38 +294,64 @@ pnpm dev # or npm run dev
294
294
 
295
295
  The workspace grows organically. `add` handles placement, wires dependencies, updates workspace globs, and generates root scripts. The name you provide becomes both the directory name and the package name. Use `--path` to override default placement, or `--project` for explicit co-located layouts.
296
296
 
297
- ## The Bigger Picture
297
+ ## Deployment
298
298
 
299
- The structure you start with scales without rewrites:
299
+ A Uniweb project produces two artifacts a **site** (content) and a **foundation** (code). **Who manages the content** decides what you ship:
300
300
 
301
- 1. **Single project** One site, one foundation. Develop and deploy together. Most projects stay here.
301
+ - If you (or your dev team) manage the content, you ship them together — the site and its foundation, bundled or linked.
302
+ - If someone else manages the content, you ship only the foundation. Content authors compose sites in the Uniweb apps; there's no site to deploy from your repo.
302
303
 
303
- 2. **Published foundation**Release your foundation as a dynamically linked module to [uniweb.app](https://uniweb.app). Other sites can use it without copying code.
304
+ ### Path 1You manage the content
304
305
 
305
- 3. **Multiple sites** Several sites share one foundation. Update components once, every site benefits.
306
+ You (or your dev team) write the markdown. Deploy site + foundation together.
306
307
 
307
- 4. **Platform-managed sites** Sites built on [uniweb.app](https://uniweb.app) with visual editing tools can use your foundation. You develop components locally; content teams work in the browser.
308
+ The shortest path to a live site is free, on GitHub Pages, with a custom domain:
308
309
 
309
- Start with local files deployed anywhere. The same foundation works across all these scenarios.
310
+ ```bash
311
+ npm create uniweb my-site
312
+ cd my-site
313
+ uniweb add ci --host=github-pages
314
+ # Commit, push to GitHub, enable Pages in repo settings → live site
315
+ ```
310
316
 
311
- ## Deployment
317
+ `uniweb add ci` scaffolds a GitHub Actions workflow that runs `uniweb build` on each push. Pre-rendering is on by default — static HTML, fast first paint, SEO out of the box. Use `--domain=<domain>` for a custom domain.
318
+
319
+ **Other free static hosts.** Cloudflare Pages, Netlify, and Vercel auto-build your site when you connect the repo through their dashboard — no scaffolded workflow needed.
320
+
321
+ **When to choose Uniweb hosting instead** (paid): when you need dynamic-page prerender at the edge (for collections fetched at runtime, not just at build time), foundation/runtime version propagation without redeploying every site, or edge SSR. If your site's content lives in markdown and updates ship via git, free CI is the right call.
322
+
323
+ ### Path 2 — Someone else manages the content
324
+
325
+ You're building a foundation for clients, content authors, or any team that won't write markdown. The foundation is your product; the repo's `site/` is a test harness for the code (run `pnpm dev` to preview your components against sample content). You don't deploy a site — you publish a foundation:
312
326
 
313
- A Uniweb project produces two artifacts — a **site** (content) and a **foundation** (code) — and they don't have to ship together. Two top-level modes:
327
+ ```bash
328
+ cd src
329
+ uniweb publish @your-org/foundation-name
330
+ ```
331
+
332
+ Real sites built on your foundation are managed in the **Uniweb apps** (web + desktop) — visual editors designed for non-technical authors. They never see git, markdown, yaml, or React. They see *your* components, with live previews and visual controls for the params you defined. The foundation becomes the editor's native vocabulary for that site: you keep creative control of the design system, they get an editor that feels custom-built for them.
333
+
334
+ This is the best path when site content has a life independent of the foundation's release cycle — agencies, design studios, multi-client teams, or any project where content authors aren't the same people as the developers.
314
335
 
315
- - **Standalone mode** site and foundation built into one self-contained `dist/`, deployed to any static host.
316
- - **Linked mode** — the foundation is a separate file the site loads at runtime, with two flavours:
317
- - **Site-bound** — the foundation belongs to one site and rides with it (`foundation: ~self/<name>@<version>` in `site.yml`).
318
- - **Cataloged** — the foundation is a private catalog product, published once and licensed to consuming sites (`foundation: '@<org>/<name>@<version>'`).
336
+ ### RoadmapHybrid
319
337
 
320
- `uniweb publish` ships a cataloged foundation; `uniweb deploy` ships a site (and, for site-bound, the foundation along with it). Most projects start standalone or site-bound and grow into cataloged when a foundation needs to serve more than one site.
338
+ A future version will let markdown in a git repo and content in the Uniweb apps stay in two-way sync. Authors edit visually, devs edit in their IDE, both surfaces work on the same content. On the roadmap; not available today.
321
339
 
322
- Where can you deploy?
340
+ ### Commands at a glance
323
341
 
324
- - **Free static hosts** Vercel, Cloudflare Pages, Netlify, GitHub Pages — work great when you have a site to publish. Built-in adapters: `vercel`, `cloudflare-pages`, `netlify`, `github-pages`. Lifecycle is Git-driven: connect your repo, the host runs `uniweb build` on each push, serves `dist/`. The framework auto-detects the CI host and emits the right helper files.
325
- - **AWS S3 + CloudFront** — `uniweb deploy --host=s3-cloudfront` builds, syncs, and invalidates in one command.
326
- - **Uniweb hosting** paid (starts at $14/month per site). Always serves linked sites with JIT prerender, edge SSR, locale-aware routing, foundation/runtime version propagation, and the multi-tenant CMS for non-technical content authors via the visual editor. The right choice when foundation developers or agencies build for clients who manage their own content. The catalog is private and access-segregated — foundations are commercial products licensed by site, not packages on a public registry.
342
+ | Command | What it does |
343
+ | --- | --- |
344
+ | `uniweb add ci --host=<adapter>` | Scaffold a CI workflow in your repo (today: `github-pages`). The host runs `uniweb build` on each push. |
345
+ | `uniweb deploy` | Deploy to Uniweb hosting (default). With `--host=<adapter>`, push directly to a static host — builds, uploads, invalidates in one step. |
346
+ | `uniweb export` | Produce a self-contained `dist/` for any static host. You upload it yourself. `--host=<adapter>` adds host-specific helper files. |
347
+ | `uniweb publish @org/name` | Publish a foundation to the registry (path 2). |
348
+ | `uniweb build` | Inspect a build locally. For shipping, use `deploy` or `export`. |
349
+
350
+ `--host=<adapter>` is the same option across `deploy`, `export`, and `add ci`. Built-in adapters: `cloudflare-pages`, `netlify`, `github-pages`, `vercel`, `s3-cloudfront`, `generic-static`. Each adapter implements only the operations it supports — `add ci` is currently `github-pages`-only because it's the only one that needs a workflow file in the repo.
351
+
352
+ ---
327
353
 
328
- **[Deploying](https://github.com/uniweb/docs/blob/main/development/deploying.md)** the full menu: picking a deploy path (free vs paid), standalone vs linked, site-bound vs cataloged, the two-verb model, CI-detection, and per-host recipes.
354
+ Both paths use the same framework. The difference is who edits content, where it lives, and what gets deployed. For the deeper menu — site-bound vs cataloged foundations, S3+CloudFront, manual exports, per-host recipes, foundation propagation — see → **[Deploying](https://github.com/uniweb/docs/blob/main/development/deploying.md)**.
329
355
 
330
356
  ---
331
357
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "uniweb",
3
- "version": "0.12.13",
3
+ "version": "0.12.15",
4
4
  "description": "Create structured Vite + React sites with content/code separation",
5
5
  "type": "module",
6
6
  "bin": {
@@ -42,12 +42,12 @@
42
42
  "prompts": "^2.4.2",
43
43
  "tar": "^7.0.0",
44
44
  "@uniweb/core": "0.7.11",
45
- "@uniweb/kit": "0.9.11",
46
- "@uniweb/runtime": "0.8.13"
45
+ "@uniweb/runtime": "0.8.13",
46
+ "@uniweb/kit": "0.9.11"
47
47
  },
48
48
  "peerDependencies": {
49
- "@uniweb/build": "0.14.2",
50
49
  "@uniweb/content-reader": "1.1.10",
50
+ "@uniweb/build": "0.14.3",
51
51
  "@uniweb/semantic-parser": "1.1.17"
52
52
  },
53
53
  "peerDependenciesMeta": {
@@ -54,19 +54,26 @@ function info(message) { console.log(`${colors.dim}${message}${colors.reset}`) }
54
54
  */
55
55
  function parseArgs(args) {
56
56
  const result = {
57
- subcommand: args[0], // foundation, site, extension
57
+ subcommand: args[0], // foundation, site, extension, ci, …
58
58
  name: null,
59
59
  path: null,
60
60
  project: null,
61
61
  foundation: null,
62
62
  site: null,
63
63
  from: null,
64
+ host: null,
65
+ force: false,
66
+ domain: null,
64
67
  }
65
68
 
66
- // Find positional name (first arg after subcommand that's not a flag)
69
+ // Booleans (no value) consumed up-front so the value-flag loop below
70
+ // doesn't accidentally swallow the next positional.
71
+ const BOOLEAN_FLAGS = new Set(['--force'])
72
+
73
+ // Find positional name (first arg after subcommand that's not a flag).
67
74
  for (let i = 1; i < args.length; i++) {
68
75
  if (args[i].startsWith('--')) {
69
- i++ // skip flag value
76
+ if (!BOOLEAN_FLAGS.has(args[i])) i++ // skip flag value
70
77
  continue
71
78
  }
72
79
  if (!result.name) {
@@ -86,6 +93,12 @@ function parseArgs(args) {
86
93
  result.site = args[++i]
87
94
  } else if (args[i] === '--from' && args[i + 1]) {
88
95
  result.from = args[++i]
96
+ } else if (args[i] === '--host' && args[i + 1]) {
97
+ result.host = args[++i]
98
+ } else if (args[i] === '--domain' && args[i + 1]) {
99
+ result.domain = args[++i]
100
+ } else if (args[i] === '--force') {
101
+ result.force = true
89
102
  }
90
103
  }
91
104
 
@@ -126,9 +139,10 @@ export async function add(rawArgs) {
126
139
  { label: 'site', description: 'Content site' },
127
140
  { label: 'extension', description: 'Additional component package' },
128
141
  { label: 'section', description: 'Section type in a foundation' },
142
+ { label: 'ci', description: 'CI deploy workflow for a host (e.g., GitHub Pages)' },
129
143
  ]))
130
144
  log('')
131
- log(`Usage: ${prefix} add <project|foundation|site|extension|section> [name]`)
145
+ log(`Usage: ${prefix} add <project|foundation|site|extension|section|ci> [name]`)
132
146
  process.exit(1)
133
147
  }
134
148
 
@@ -142,6 +156,7 @@ export async function add(rawArgs) {
142
156
  { title: 'Site', value: 'site', description: 'Content site' },
143
157
  { title: 'Extension', value: 'extension', description: 'Additional component package' },
144
158
  { title: 'Section', value: 'section', description: 'Section type in a foundation' },
159
+ { title: 'CI workflow', value: 'ci', description: 'Deploy workflow for a host (e.g., GitHub Pages)' },
145
160
  ],
146
161
  }, {
147
162
  onCancel: () => {
@@ -176,9 +191,12 @@ export async function add(rawArgs) {
176
191
  case 'section':
177
192
  await addSection(rootDir, parsed)
178
193
  break
194
+ case 'ci':
195
+ await addCi(rootDir, parsed, pm)
196
+ break
179
197
  default:
180
198
  error(`Unknown subcommand: ${parsed.subcommand}`)
181
- log(`Valid subcommands: project, foundation, site, extension, section`)
199
+ log(`Valid subcommands: project, foundation, site, extension, section, ci`)
182
200
  process.exit(1)
183
201
  }
184
202
  }
@@ -865,7 +883,7 @@ async function wireExtensionToSite(rootDir, siteName, extensionName, extensionPa
865
883
  const config = yaml.load(content) || {}
866
884
 
867
885
  // Add extension URL
868
- const extensionUrl = `/${extensionPath}/dist/foundation.js`
886
+ const extensionUrl = `/${extensionPath}/dist/entry.js`
869
887
  if (!config.extensions) {
870
888
  config.extensions = []
871
889
  }
@@ -1017,6 +1035,195 @@ export default function ${name}({ content, params }) {
1017
1035
  }
1018
1036
  }
1019
1037
 
1038
+ /**
1039
+ * Add a CI deploy workflow for a host adapter.
1040
+ *
1041
+ * Wires through the host-adapter registry: each adapter optionally
1042
+ * exports `initCi({ rootDir, site, packageManager, nodeVersion })`
1043
+ * returning `{ files, postInstructions }`. The CLI handles file writes,
1044
+ * --force overwrite, and consistent output. Today only github-pages
1045
+ * implements initCi; the registry's other adapters (cloudflare-pages,
1046
+ * vercel, s3-cloudfront, generic-static) error out with a clear message
1047
+ * and can plug in later.
1048
+ */
1049
+ async function addCi(rootDir, opts, pm = 'pnpm') {
1050
+ // Lazy-load the host registry so the CLI doesn't pay this import on
1051
+ // every add invocation.
1052
+ let getAdapter
1053
+ try {
1054
+ ({ getAdapter } = await import('@uniweb/build/hosts'))
1055
+ } catch {
1056
+ error('Failed to load host adapter registry from @uniweb/build/hosts.')
1057
+ process.exit(1)
1058
+ }
1059
+
1060
+ // Resolve host. With one CI-capable adapter today (github-pages),
1061
+ // default silently when --host isn't passed. If more adapters add
1062
+ // initCi later, this becomes a picker.
1063
+ const host = opts.host || 'github-pages'
1064
+
1065
+ let adapter
1066
+ try {
1067
+ adapter = getAdapter(host)
1068
+ } catch (err) {
1069
+ error(err.message)
1070
+ process.exit(1)
1071
+ }
1072
+
1073
+ if (typeof adapter.initCi !== 'function') {
1074
+ error(`Host '${host}' does not provide a CI workflow yet.`)
1075
+ log(`Currently supported: github-pages.`)
1076
+ log(`Other hosts may use platform-side integrations (e.g., Vercel/Netlify connect via dashboard).`)
1077
+ process.exit(1)
1078
+ }
1079
+
1080
+ // Validate --domain (lightweight: must look like a hostname). The
1081
+ // adapter decides what to do with it; today only github-pages uses
1082
+ // it (writes a CNAME, switches UNIWEB_BASE to root).
1083
+ if (opts.domain && !isLikelyDomain(opts.domain)) {
1084
+ error(`Invalid --domain value: '${opts.domain}'`)
1085
+ log(`Expected a bare hostname (e.g., 'mysite.com' or 'docs.mysite.com'). No scheme, no path.`)
1086
+ process.exit(1)
1087
+ }
1088
+
1089
+ // If --domain wasn't passed, fall back to whatever's already in
1090
+ // deploy.yml's targets.<host>.domain. Lets re-running `add ci` (e.g.,
1091
+ // to refresh the workflow after a CLI upgrade) keep the domain
1092
+ // without the user re-typing it.
1093
+ const { loadDeployYml } = await import('@uniweb/build/site')
1094
+ let resolvedDomain = opts.domain
1095
+
1096
+ // Resolve site: --site flag, single site auto, prompt, or error.
1097
+ const sites = await discoverSites(rootDir)
1098
+ if (sites.length === 0) {
1099
+ error('No site found in this workspace. Add one with `uniweb add site` first.')
1100
+ process.exit(1)
1101
+ }
1102
+
1103
+ let site
1104
+ if (opts.site) {
1105
+ site = sites.find(s => s.name === opts.site)
1106
+ if (!site) {
1107
+ error(`Site '${opts.site}' not found.`)
1108
+ log(`Available sites: ${sites.map(s => s.name).join(', ')}`)
1109
+ process.exit(1)
1110
+ }
1111
+ } else if (sites.length === 1) {
1112
+ site = sites[0]
1113
+ } else if (isNonInteractive(process.argv)) {
1114
+ error(`Multiple sites in workspace. Specify --site <name>.`)
1115
+ log(`Available sites: ${sites.map(s => s.name).join(', ')}`)
1116
+ process.exit(1)
1117
+ } else {
1118
+ const sortedSites = [...sites].sort((a, b) => a.name.localeCompare(b.name))
1119
+ const response = await prompts({
1120
+ type: 'select',
1121
+ name: 'site',
1122
+ message: 'Which site should the workflow build?',
1123
+ choices: sortedSites.map(s => ({ title: s.name, description: s.path, value: s })),
1124
+ }, {
1125
+ onCancel: () => {
1126
+ log('\nCancelled.')
1127
+ process.exit(0)
1128
+ },
1129
+ })
1130
+ site = response.site
1131
+ }
1132
+
1133
+ // Read node version from root package.json engines.node if pinned;
1134
+ // otherwise default to 20 (matches the workspace template's >=20.19).
1135
+ const rootPkg = JSON.parse(
1136
+ await readFile(join(rootDir, 'package.json'), 'utf-8').catch(() => '{}')
1137
+ )
1138
+ const nodeVersion = parseNodeMajor(rootPkg.engines?.node) || '20'
1139
+
1140
+ const siteDir = join(rootDir, site.path)
1141
+ if (!resolvedDomain) {
1142
+ try {
1143
+ const deployYml = await loadDeployYml(siteDir)
1144
+ const remembered = deployYml?.targets?.[host]?.domain
1145
+ if (remembered && isLikelyDomain(remembered)) {
1146
+ resolvedDomain = remembered
1147
+ info(`Using domain '${remembered}' from deploy.yml.`)
1148
+ }
1149
+ } catch {
1150
+ // Malformed deploy.yml — surface elsewhere; don't block add ci.
1151
+ }
1152
+ }
1153
+
1154
+ const result = await adapter.initCi({
1155
+ rootDir,
1156
+ site,
1157
+ packageManager: pm,
1158
+ nodeVersion,
1159
+ domain: resolvedDomain,
1160
+ })
1161
+
1162
+ // Write files. Refuse to overwrite without --force so re-running
1163
+ // doesn't silently clobber edits the user made to the workflow.
1164
+ for (const file of result.files) {
1165
+ const fullPath = join(rootDir, file.path)
1166
+ if (existsSync(fullPath) && !opts.force) {
1167
+ error(`File already exists: ${file.path}`)
1168
+ log(`Re-run with --force to overwrite.`)
1169
+ process.exit(1)
1170
+ }
1171
+ await mkdir(join(fullPath, '..'), { recursive: true })
1172
+ await writeFile(fullPath, file.content)
1173
+ success(`Wrote ${file.path}`)
1174
+ }
1175
+
1176
+ // Persist the adapter's target config into deploy.yml so the user's
1177
+ // intent (host + adapter-specific fields like `domain`) is remembered
1178
+ // across CLI upgrades and re-runs. github-pages deploys via GHA, not
1179
+ // via `uniweb deploy`, so without this its config would never reach
1180
+ // deploy.yml. The writer:
1181
+ // - scaffolds a fresh deploy.yml on first call (this target is the
1182
+ // default), or
1183
+ // - merges into an existing targets.<targetName> without touching
1184
+ // `default`, `autoSave`, or other targets.
1185
+ if (result.targetConfig) {
1186
+ try {
1187
+ const { recordTarget } = await import('@uniweb/build/site')
1188
+ const writeResult = await recordTarget(siteDir, {
1189
+ targetName: host,
1190
+ targetConfig: result.targetConfig,
1191
+ })
1192
+ success(
1193
+ writeResult.action === 'scaffold'
1194
+ ? `Wrote ${relative(rootDir, writeResult.path)} (default target: ${host})`
1195
+ : `Updated ${relative(rootDir, writeResult.path)} (target: ${host})`
1196
+ )
1197
+ } catch (err) {
1198
+ // deploy.yml persistence is best-effort: the workflow + CNAME
1199
+ // are the load-bearing artifacts. Print a warning and continue.
1200
+ info(`Warning: could not update deploy.yml: ${err.message}`)
1201
+ }
1202
+ }
1203
+
1204
+ if (result.postInstructions?.length) {
1205
+ log('')
1206
+ log(`${colors.bright}Next steps:${colors.reset}`)
1207
+ for (const line of result.postInstructions) {
1208
+ log(line ? ` ${line}` : '')
1209
+ }
1210
+ }
1211
+ }
1212
+
1213
+ function isLikelyDomain(value) {
1214
+ if (typeof value !== 'string' || value.length === 0 || value.length > 253) return false
1215
+ // Reject schemes, paths, ports, whitespace, leading/trailing dots/hyphens.
1216
+ // Each label is 1–63 chars of [a-z0-9-], no leading/trailing hyphen.
1217
+ // The TLD must be at least 2 characters of letters.
1218
+ return /^(?=.{1,253}$)([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}$/i.test(value)
1219
+ }
1220
+
1221
+ function parseNodeMajor(engines) {
1222
+ if (!engines) return null
1223
+ const match = String(engines).match(/(\d+)/)
1224
+ return match ? match[1] : null
1225
+ }
1226
+
1020
1227
  /**
1021
1228
  * Show help for the add command
1022
1229
  */
@@ -1024,7 +1231,7 @@ function showAddHelp() {
1024
1231
  log(`
1025
1232
  ${colors.cyan}${colors.bright}Uniweb Add${colors.reset}
1026
1233
 
1027
- Add projects, foundations, sites, extensions, or section types to your workspace.
1234
+ Add projects, foundations, sites, extensions, section types, or CI workflows to your workspace.
1028
1235
 
1029
1236
  ${colors.bright}Usage:${colors.reset}
1030
1237
  uniweb add project [name] [options]
@@ -1032,6 +1239,7 @@ ${colors.bright}Usage:${colors.reset}
1032
1239
  uniweb add site [name] [options]
1033
1240
  uniweb add extension <name> [options]
1034
1241
  uniweb add section <name> [options]
1242
+ uniweb add ci [options]
1035
1243
 
1036
1244
  ${colors.bright}Common Options:${colors.reset}
1037
1245
  --from <template> Apply content from a template after scaffolding
@@ -1050,6 +1258,12 @@ ${colors.bright}Extension Options:${colors.reset}
1050
1258
  ${colors.bright}Section Options:${colors.reset}
1051
1259
  --foundation <n> Foundation to add section to (prompted if multiple exist)
1052
1260
 
1261
+ ${colors.bright}CI Options:${colors.reset}
1262
+ --host <name> Host adapter (default: github-pages)
1263
+ --site <name> Site the workflow builds (prompted if multiple exist)
1264
+ --domain <host> Custom domain (writes CNAME, serves at root)
1265
+ --force Overwrite an existing workflow file
1266
+
1053
1267
  ${colors.bright}Examples:${colors.reset}
1054
1268
  uniweb add project docs # Create docs/foundation/ + docs/site/
1055
1269
  uniweb add project docs --from academic # Co-located pair + academic content
@@ -1062,5 +1276,8 @@ ${colors.bright}Examples:${colors.reset}
1062
1276
  uniweb add section Hero --foundation ui # Target specific foundation
1063
1277
  uniweb add foundation --project docs # Create ./docs/foundation/ (co-located)
1064
1278
  uniweb add site --project docs # Create ./docs/site/ (co-located)
1279
+ uniweb add ci # Add GitHub Pages deploy workflow
1280
+ uniweb add ci --host github-pages --site marketing # Pick host + site explicitly
1281
+ uniweb add ci --domain mysite.com # Custom domain → writes CNAME + UNIWEB_BASE=/
1065
1282
  `)
1066
1283
  }
@@ -645,7 +645,7 @@ async function buildSite(projectDir, options = {}) {
645
645
  log(`${colors.dim}Check that:${colors.reset}`)
646
646
  log(` • site.yml 'foundation:' matches the package name in your foundation's package.json`)
647
647
  log(` • site's package.json has a dependency pointing to the correct foundation path`)
648
- log(` • The foundation's dist/foundation.js exists (build the foundation first)`)
648
+ log(` • The foundation's dist/entry.js exists (build the foundation first)`)
649
649
  }
650
650
 
651
651
  if (process.env.UNIWEB_DEBUG) {
@@ -884,7 +884,7 @@ export async function build(args = []) {
884
884
 
885
885
  // Validate --link / --bundle are only used with site target.
886
886
  // (Foundation builds have no equivalent split — they always produce
887
- // dist/foundation.js + schema.json regardless of how a downstream
887
+ // dist/entry.js + schema.json regardless of how a downstream
888
888
  // site consumes the result.)
889
889
  if ((linkFlag || bundleFlag) && targetType === 'foundation') {
890
890
  error('--link and --bundle apply to site builds only')
@@ -2,10 +2,12 @@
2
2
  * uniweb doctor - Diagnose project configuration issues
3
3
  */
4
4
 
5
- import { existsSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'
5
+ import { existsSync, readFileSync, readdirSync, writeFileSync, mkdirSync } 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
+ import { loadDeployYml } from '@uniweb/build/site'
10
+ import { listAdapters } from '@uniweb/build/hosts'
9
11
  import { getCliVersion } from '../versions.js'
10
12
  import { readAgentsVersion } from '../utils/agents-stamp.js'
11
13
  import { discoverFoundations, discoverSites } from '../utils/config.js'
@@ -518,6 +520,158 @@ export async function doctor(args = []) {
518
520
  }
519
521
  }
520
522
 
523
+ // ─── Deploy configuration ────────────────────────────────────
524
+ // Sanity-check deploy.yml shape and surface common drift between
525
+ // deploy.yml, site.yml, and the artifacts the CLI generates.
526
+ // Failures here would otherwise only surface at deploy / first GHA
527
+ // run, where the feedback loop is slower.
528
+ log('')
529
+ info('Checking deploy configuration...')
530
+ const knownAdapters = new Set(listAdapters())
531
+ for (const site of sites) {
532
+ const sitePath = site.path
533
+ const siteName = site.name
534
+ const deployYmlPath = join(sitePath, 'deploy.yml')
535
+ if (!existsSync(deployYmlPath)) continue
536
+
537
+ let deployYml
538
+ try {
539
+ deployYml = await loadDeployYml(sitePath)
540
+ } catch (err) {
541
+ issues.push({
542
+ id: 'deploy-yml-malformed',
543
+ type: 'error',
544
+ site: siteName,
545
+ message: `deploy.yml is malformed: ${err.message}`,
546
+ })
547
+ error(`[deploy-yml-malformed] ${relative(workspaceDir, deployYmlPath)}: ${err.message}`)
548
+ continue
549
+ }
550
+ if (!deployYml) continue
551
+
552
+ const targets = deployYml.targets || {}
553
+ const targetNames = Object.keys(targets)
554
+
555
+ // default: must reference a real target.
556
+ if (deployYml.default && !targets[deployYml.default]) {
557
+ issues.push({
558
+ id: 'deploy-yml-default-unknown-target',
559
+ type: 'error',
560
+ site: siteName,
561
+ message: `deploy.yml: default '${deployYml.default}' is not in targets (known: ${targetNames.join(', ') || '(none)'})`,
562
+ })
563
+ error(`[deploy-yml-default-unknown-target] ${siteName}: default '${deployYml.default}' is not declared under \`targets:\`.`)
564
+ log(` ${colors.dim}Add it to targets: or change default: to one of: ${targetNames.join(', ') || '(none)'}.${colors.reset}`)
565
+ }
566
+
567
+ // Each target must have a host, and host should be a known adapter.
568
+ for (const [name, cfg] of Object.entries(targets)) {
569
+ if (!cfg || typeof cfg !== 'object') continue
570
+ if (!cfg.host) {
571
+ issues.push({
572
+ id: 'deploy-yml-target-missing-host',
573
+ type: 'error',
574
+ site: siteName,
575
+ message: `deploy.yml: targets.${name} is missing \`host\``,
576
+ })
577
+ error(`[deploy-yml-target-missing-host] ${siteName}: targets.${name} has no \`host\` field.`)
578
+ continue
579
+ }
580
+ // 'uniweb' is the platform default and isn't in the static-host
581
+ // adapter registry; treat it as known.
582
+ if (cfg.host !== 'uniweb' && !knownAdapters.has(cfg.host)) {
583
+ issues.push({
584
+ id: 'deploy-yml-unknown-host',
585
+ type: 'warn',
586
+ site: siteName,
587
+ message: `deploy.yml: targets.${name}.host '${cfg.host}' is not a known adapter`,
588
+ })
589
+ warn(`[deploy-yml-unknown-host] ${siteName}: targets.${name}.host: '${cfg.host}' is not a known built-in adapter.`)
590
+ log(` ${colors.dim}Known: ${[...knownAdapters].sort().join(', ')}, plus 'uniweb'.${colors.reset}`)
591
+ }
592
+ }
593
+
594
+ // github-pages-specific: if a target has `domain`, `site/public/CNAME`
595
+ // should exist with that value. Vite copies public/ into dist/, and
596
+ // GH Pages reads dist/CNAME on each deploy to keep the custom domain
597
+ // configured. A drift here means a deploy will silently lose the
598
+ // custom domain.
599
+ const cnamePath = join(sitePath, 'public', 'CNAME')
600
+ const ghTarget = Object.entries(targets).find(([, c]) => c?.host === 'github-pages')
601
+ if (ghTarget) {
602
+ const [ghName, ghCfg] = ghTarget
603
+ if (ghCfg.domain) {
604
+ const expected = String(ghCfg.domain).trim()
605
+ let actual = null
606
+ if (existsSync(cnamePath)) {
607
+ try {
608
+ actual = readFileSync(cnamePath, 'utf8').trim()
609
+ } catch {
610
+ actual = null
611
+ }
612
+ }
613
+ if (actual !== expected) {
614
+ const issue = {
615
+ id: 'github-pages-cname-mismatch',
616
+ type: 'warn',
617
+ site: siteName,
618
+ message: actual === null
619
+ ? `deploy.yml: targets.${ghName}.domain is '${expected}' but ${relative(workspaceDir, cnamePath)} is missing`
620
+ : `deploy.yml: targets.${ghName}.domain is '${expected}' but ${relative(workspaceDir, cnamePath)} contains '${actual}'`,
621
+ }
622
+ issues.push(issue)
623
+ warn(`[github-pages-cname-mismatch] ${siteName}: ${issue.message}`)
624
+ if (shouldFix('github-pages-cname-mismatch')) {
625
+ const dir = dirname(cnamePath)
626
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
627
+ writeFileSync(cnamePath, expected + '\n')
628
+ issue.fixed = true
629
+ fixed(`wrote ${relative(workspaceDir, cnamePath)} = ${expected}`)
630
+ } else {
631
+ log(` ${colors.dim}Re-run \`uniweb add ci --domain ${expected} --force\` (or pass --fix to write CNAME from deploy.yml).${colors.reset}`)
632
+ }
633
+ }
634
+ } else if (existsSync(cnamePath)) {
635
+ // CNAME exists but the github-pages target has no `domain` set —
636
+ // either deploy.yml was edited and lost the domain, or someone
637
+ // hand-authored CNAME without setting the target.
638
+ let actual = ''
639
+ try {
640
+ actual = readFileSync(cnamePath, 'utf8').trim()
641
+ } catch {
642
+ // ignore
643
+ }
644
+ issues.push({
645
+ id: 'github-pages-cname-orphan',
646
+ type: 'warn',
647
+ site: siteName,
648
+ message: `${relative(workspaceDir, cnamePath)} exists ('${actual}') but deploy.yml's github-pages target has no \`domain\``,
649
+ })
650
+ warn(`[github-pages-cname-orphan] ${siteName}: ${relative(workspaceDir, cnamePath)} exists ('${actual}') but the github-pages target has no \`domain\` set.`)
651
+ log(` ${colors.dim}Either add \`domain: ${actual}\` to targets.${ghName}, or delete the CNAME if you no longer want a custom domain.${colors.reset}`)
652
+ }
653
+ }
654
+
655
+ // site.yml::base + a generated GH Pages workflow → the workflow
656
+ // sets UNIWEB_BASE at build time, which beats site.yml::base
657
+ // (site/config.js precedence: explicit > UNIWEB_BASE > site.yml).
658
+ // Not an error — site.yml's value is just dormant for that target —
659
+ // but worth flagging so the user knows the value isn't taking effect.
660
+ const siteYml = loadSiteYml(sitePath)
661
+ const workflowPath = join(workspaceDir, '.github/workflows/deploy-github-pages.yml')
662
+ if (siteYml?.base && existsSync(workflowPath) && ghTarget) {
663
+ issues.push({
664
+ id: 'site-base-overridden-by-workflow',
665
+ type: 'warn',
666
+ site: siteName,
667
+ message: `site.yml::base ('${siteYml.base}') is dormant — the GH Pages workflow sets UNIWEB_BASE at build time, which takes precedence`,
668
+ })
669
+ warn(`[site-base-overridden-by-workflow] ${siteName}: site.yml has \`base: ${siteYml.base}\`, but the GH Pages workflow sets UNIWEB_BASE.`)
670
+ 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}`)
671
+ 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}`)
672
+ }
673
+ }
674
+
521
675
  // Check AGENTS.md freshness
522
676
  log('')
523
677
  const agentsPath = join(workspaceDir, 'AGENTS.md')
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "generatedAt": "2026-05-05T20:43:37.314Z",
3
+ "generatedAt": "2026-05-06T16:32:45.366Z",
4
4
  "packages": {
5
5
  "@uniweb/build": {
6
- "version": "0.14.2",
6
+ "version": "0.14.3",
7
7
  "path": "framework/build",
8
8
  "deps": [
9
9
  "@uniweb/content-reader",
package/src/index.js CHANGED
@@ -449,14 +449,26 @@ async function main() {
449
449
  // Output convention: the version goes to stdout (parseable, scriptable —
450
450
  // `version=$(uniweb --version)` should keep working). Any staleness
451
451
  // notice goes to stderr, so it shows in interactive terminals but
452
- // doesn't pollute captured output. Cache-only — never makes a network
453
- // call from this path.
452
+ // doesn't pollute captured output.
453
+ //
454
+ // Two staleness paths split by stdout TTY-ness:
455
+ // - TTY (interactive user typed it): fetch the registry with a tight
456
+ // timeout. Accuracy matters — a fresh install would otherwise see
457
+ // no notice on its first invocation (the cache is empty). Network
458
+ // latency is acceptable here, capped at ~1.5s by the abort timeout.
459
+ // - Non-TTY (script captured stdout, piped through, etc.): cache-only.
460
+ // Scripts must stay fast and offline-safe. The `gh --version` /
461
+ // `claude --version` convention.
454
462
  if (command === '--version' || command === '-v') {
455
463
  console.log(`uniweb ${getCliVersion()}`)
456
464
  if (isGlobalInstall()) {
457
465
  try {
458
- const { maybeNotifyFromCache } = await import('./utils/update-check.js')
459
- maybeNotifyFromCache(getCliVersion(), 'soft')
466
+ const { fetchAndNotifyIfNewer, maybeNotifyFromCache } = await import('./utils/update-check.js')
467
+ if (process.stdout.isTTY) {
468
+ await fetchAndNotifyIfNewer(getCliVersion(), { tone: 'soft' })
469
+ } else {
470
+ maybeNotifyFromCache(getCliVersion(), 'soft')
471
+ }
460
472
  } catch { /* ignore */ }
461
473
  }
462
474
  return
@@ -10,6 +10,7 @@ import { readFile, writeFile } from 'node:fs/promises'
10
10
  import { join } from 'node:path'
11
11
  import { homedir } from 'node:os'
12
12
  import yaml from 'js-yaml'
13
+ import { classifyPackage } from '@uniweb/build'
13
14
  import { filterCmd } from './pm.js'
14
15
 
15
16
  // ── Platform URLs ──────────────────────────────────────────────
@@ -228,27 +229,7 @@ export async function updateRootScripts(rootDir, sites, pm = 'pnpm') {
228
229
  * @returns {Promise<Array<{name: string, path: string}>>}
229
230
  */
230
231
  export async function discoverFoundations(rootDir) {
231
- const { packages } = await readWorkspaceConfig(rootDir)
232
- const foundations = []
233
-
234
- for (const pattern of packages) {
235
- const dirs = await resolveGlob(rootDir, pattern)
236
- for (const dir of dirs) {
237
- const pkgPath = join(rootDir, dir, 'package.json')
238
- if (!existsSync(pkgPath)) continue
239
- try {
240
- const pkg = JSON.parse(await readFile(pkgPath, 'utf-8'))
241
- // Foundation: has @uniweb/build in devDeps but NOT @uniweb/runtime in deps
242
- if (pkg.devDependencies?.['@uniweb/build'] && !pkg.dependencies?.['@uniweb/runtime']) {
243
- foundations.push({ name: pkg.name, path: dir })
244
- }
245
- } catch {
246
- // skip
247
- }
248
- }
249
- }
250
-
251
- return foundations
232
+ return discoverByKind(rootDir, 'foundation')
252
233
  }
253
234
 
254
235
  /**
@@ -257,27 +238,46 @@ export async function discoverFoundations(rootDir) {
257
238
  * @returns {Promise<Array<{name: string, path: string}>>}
258
239
  */
259
240
  export async function discoverSites(rootDir) {
241
+ return discoverByKind(rootDir, 'site')
242
+ }
243
+
244
+ /**
245
+ * Walk the workspace globs and return packages of the requested kind.
246
+ * Uses `classifyPackage` from @uniweb/build — the canonical classifier
247
+ * shared with the build pipeline, which keys on real signals (site.yml
248
+ * for sites, generated entry for foundations) rather than which
249
+ * `@uniweb/*` packages happen to be in dependencies. Templates whose
250
+ * sites pull runtime transitively through the foundation (e.g.,
251
+ * marketing) used to be invisible to the older dependency-based check.
252
+ */
253
+ async function discoverByKind(rootDir, kind) {
260
254
  const { packages } = await readWorkspaceConfig(rootDir)
261
- const sites = []
255
+ const out = []
262
256
 
263
257
  for (const pattern of packages) {
264
258
  const dirs = await resolveGlob(rootDir, pattern)
265
259
  for (const dir of dirs) {
266
- const pkgPath = join(rootDir, dir, 'package.json')
267
- if (!existsSync(pkgPath)) continue
268
- try {
269
- const pkg = JSON.parse(await readFile(pkgPath, 'utf-8'))
270
- // Site: has @uniweb/runtime in deps
271
- if (pkg.dependencies?.['@uniweb/runtime']) {
272
- sites.push({ name: pkg.name, path: dir })
260
+ const fullPath = join(rootDir, dir)
261
+ if (classifyPackage(fullPath) !== kind) continue
262
+
263
+ // Read package.json for the package name. Synthesize one from
264
+ // the directory if it's missing or malformed — we still want
265
+ // the package to surface in pickers.
266
+ const pkgPath = join(fullPath, 'package.json')
267
+ let name = dir.split('/').pop()
268
+ if (existsSync(pkgPath)) {
269
+ try {
270
+ const pkg = JSON.parse(await readFile(pkgPath, 'utf-8'))
271
+ if (pkg.name) name = pkg.name
272
+ } catch {
273
+ // keep directory-derived name
273
274
  }
274
- } catch {
275
- // skip
276
275
  }
276
+ out.push({ name, path: dir })
277
277
  }
278
278
  }
279
279
 
280
- return sites
280
+ return out
281
281
  }
282
282
 
283
283
  // Resolve a workspace glob pattern to actual directories
@@ -101,6 +101,47 @@ export function maybeNotifyFromCache(currentVersion, tone = 'eager') {
101
101
  // and gets the eager default. Keeps that call site unchanged.
102
102
  export const maybeEagerNotification = maybeNotifyFromCache
103
103
 
104
+ /**
105
+ * Fetch the latest version (with a tight timeout) and print a notice if
106
+ * a newer version is found. Updates the on-disk cache as a side effect
107
+ * so future cache-only callers benefit too.
108
+ *
109
+ * Use this for TTY invocations of `--version` / `-v` where the user is
110
+ * interactively asking about the version and a brief network wait is
111
+ * acceptable. Don't use it for non-TTY callers — scripts capturing
112
+ * stdout (`version=$(uniweb -v)`) need a fast, offline-safe path.
113
+ *
114
+ * @param {string} currentVersion
115
+ * @param {object} [opts]
116
+ * @param {number} [opts.timeoutMs=1500] Network timeout. Slow / offline
117
+ * calls return silently — never block the verb for long.
118
+ * @param {'eager'|'soft'} [opts.tone='soft'] Notification copy.
119
+ * @returns {Promise<boolean>} true if a notice was printed.
120
+ */
121
+ export async function fetchAndNotifyIfNewer(currentVersion, { timeoutMs = 1500, tone = 'soft' } = {}) {
122
+ const controller = new AbortController()
123
+ const timer = setTimeout(() => controller.abort(), timeoutMs)
124
+ let latest = null
125
+ try {
126
+ const res = await fetch('https://registry.npmjs.org/uniweb/latest', { signal: controller.signal })
127
+ if (res.ok) {
128
+ const data = await res.json()
129
+ latest = data?.version || null
130
+ }
131
+ } catch {
132
+ // Aborted, network error, parse error — all silent. The verb
133
+ // shouldn't block on update-check failures.
134
+ } finally {
135
+ clearTimeout(timer)
136
+ }
137
+ if (!latest) return false
138
+ // Refresh the cache so other code paths see this fresh result.
139
+ writeState({ lastCheck: Date.now(), latestVersion: latest })
140
+ if (compareSemver(latest, currentVersion) <= 0) return false
141
+ printNotification(currentVersion, latest, tone)
142
+ return true
143
+ }
144
+
104
145
  /**
105
146
  * Start a non-blocking update check.
106
147
  *