uniweb 0.12.46 → 0.12.48

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
@@ -321,7 +321,7 @@ uniweb add ci --host=github-pages
321
321
 
322
322
  `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.
323
323
 
324
- **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.
324
+ **Other free static hosts.** Cloudflare Pages, Netlify, and Vercel work three ways: `uniweb add ci --host=<adapter>` scaffolds a workflow (and a per-PR preview that comments the URL), connecting the repo through their dashboard lets the host build it with no config at all, or `uniweb deploy --host=<adapter>` uploads from your machine.
325
325
 
326
326
  **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.
327
327
 
@@ -346,15 +346,17 @@ Markdown in a git repo and content in the Uniweb apps can share the same site. D
346
346
 
347
347
  | Command | What it does |
348
348
  | --- | --- |
349
- | `uniweb add ci --host=<adapter>` | Scaffold a CI workflow in your repo (today: `github-pages`). The host runs `uniweb build` on each push. |
349
+ | `uniweb deploy` | Ask where the site should go, then do it. Remembers the answer in `deploy.yml`. |
350
+ | `uniweb add ci --host=<adapter>` | Scaffold a CI workflow (+ PR previews) so every push deploys. `github-pages`, `cloudflare-pages`, `netlify`, `vercel`. |
351
+ | `uniweb add ci --target foundation` | Publish foundations at permanent versioned GitHub Pages URLs — the free alternative to the catalog. |
350
352
  | `uniweb publish` | Go live on Uniweb hosting (paid) — syncs content, brings the site's foundation along, and serves dynamically. The canonical verb for Uniweb hosting. |
351
- | `uniweb deploy --host=<adapter>` | Ship to a third-party static host in one step — builds `dist/`, uploads, invalidates. |
353
+ | `uniweb deploy --host=<adapter>` | Ship to a third-party static host in one step — builds `dist/`, uploads, invalidates. Drives that host's own CLI. |
352
354
  | `uniweb export` | Produce a self-contained `dist/` for any static host. You upload it yourself. `--host=<adapter>` adds host-specific helper files. |
353
355
  | `uniweb register --scope @org` | Register a foundation to the registry (path 2). |
354
356
  | `uniweb build` | Inspect a build locally. For shipping, use `publish` (Uniweb hosting) or `deploy`/`export` (static hosts). |
355
357
  | `uniweb update` | Align this project with the CLI you're running: bump `@uniweb/*` deps in every `package.json` to the CLI's matrix (then install), and refresh `AGENTS.md`. Pins to *this* CLI's matrix — run `npx uniweb@latest update` to align to the latest release. Updating the CLI itself is your package manager's job (`npm i -g uniweb@latest`). |
356
358
 
357
- `--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.
359
+ `--host=<adapter>` is the same option across `deploy`, `export`, and `add ci`. Built-in adapters: `github-pages`, `cloudflare-pages`, `netlify`, `vercel`, `s3-cloudfront`, and `generic-static` (an artifact shape for `export`, not a deploy target). Each adapter implements only the operations it supports, and the CLI never offers one it can't perform: `add ci` lists only hosts that can scaffold a workflow, and `deploy`'s picker lists only destinations with a working deploy hook.
358
360
 
359
361
  ---
360
362
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "uniweb",
3
- "version": "0.12.46",
3
+ "version": "0.12.48",
4
4
  "description": "Create structured Vite + React sites with content/code separation",
5
5
  "type": "module",
6
6
  "bin": {
@@ -41,14 +41,14 @@
41
41
  "js-yaml": "^4.1.0",
42
42
  "prompts": "^2.4.2",
43
43
  "tar": "^7.0.0",
44
- "@uniweb/kit": "0.9.24",
45
44
  "@uniweb/core": "0.7.20",
45
+ "@uniweb/kit": "0.9.24",
46
46
  "@uniweb/runtime": "0.8.26"
47
47
  },
48
48
  "peerDependencies": {
49
+ "@uniweb/build": "0.14.30",
49
50
  "@uniweb/content-reader": "1.1.12",
50
- "@uniweb/semantic-parser": "1.1.17",
51
- "@uniweb/build": "0.14.28"
51
+ "@uniweb/semantic-parser": "1.1.17"
52
52
  },
53
53
  "peerDependenciesMeta": {
54
54
  "@uniweb/build": {
@@ -142,15 +142,23 @@ pnpm install # Install dependencies
142
142
  pnpm build # Build for production
143
143
  pnpm preview # Preview production build (SSG + SPA)
144
144
 
145
+ # Ship a site — `uniweb deploy` asks where, if you haven't chosen yet
146
+ uniweb deploy # Wizard: pick a destination, then deploy (or set up CI)
147
+
145
148
  # Ship a site to Uniweb hosting (needs `uniweb login`)
146
149
  uniweb publish # The smart path: bring the foundation along (releasing it to the
147
150
  # catalog if its code changed), sync content, and go live
148
151
 
149
152
  # Ship a site to a third-party host instead
150
- uniweb deploy --host=<adapter> # Deploy to a static host: cloudflare-pages, netlify,
151
- # vercel, github-pages, s3-cloudfront, generic-static
153
+ uniweb add ci --host=<adapter> # Set up CI so every push deploys — usually the best answer for a
154
+ # free host. github-pages, cloudflare-pages, netlify, vercel
155
+ uniweb deploy --host=<adapter> # Build + upload now, from this machine. Same four, plus
156
+ # s3-cloudfront. Drives that host's CLI (wrangler/netlify/vercel/aws)
152
157
  uniweb export # Build dist/ for any static host (no Uniweb account)
153
158
 
159
+ # Publish a foundation for free, at permanent versioned URLs
160
+ uniweb add ci --target foundation # GitHub Pages workflow → foundations/<name>/<version>/entry.js
161
+
154
162
  # Sync a site with the Uniweb backend (git-style; needs `uniweb login`)
155
163
  uniweb push # Push local content to the backend (creates the site on first push)
156
164
  uniweb pull # Pull backend content back to local files
@@ -174,6 +182,8 @@ uniweb <command> --help # Per-command help (no side effects)
174
182
 
175
183
  `uniweb publish` brings the site's local foundation along — releasing it to the catalog under your `@org` when its code changed — so a single site needs no separate `uniweb register` step.
176
184
 
185
+ **Choosing where a site goes.** `uniweb deploy` never assumes a host: with nothing configured it opens a picker listing only destinations it can actually act on, and records the choice in `deploy.yml` so later runs go straight there. For a free static host, prefer `uniweb add ci --host=<adapter>` over a manual `uniweb deploy` — one command, then every push deploys, and on Cloudflare Pages / Netlify / Vercel it also adds a per-PR preview that comments the URL on the pull request. Destination config (bucket, project name, site id) lives in `deploy.yml` beside `site.yml`; host credentials come from the environment, never from that committed file.
186
+
177
187
  **Registering data schemas.** A foundation that defines data schemas (`@/article`, …) uses `uniweb register` to register the foundation together with those schemas in the Uniweb registry — so content authors can create and manage entities of those types. It requires authentication: run `uniweb login`, or supply a bearer token directly with `--token <bearer>` (or the `UNIWEB_TOKEN` env var). Point at a specific registry with `--registry <url>` (or `UNIWEB_REGISTER_URL`). Preview without auth using `--dry-run` (or `-o <file>` to write the submission), and set the org scope with `--scope @org` (default: the foundation's `package.json` `uniweb.scope`).
178
188
 
179
189
  **Registering standard or shared schemas (no foundation).** Data schemas can also be registered on their own — without a foundation — straight from a schemas package. Run `uniweb register` from a package that *exports* schemas (`@uniweb/schemas`, or any `@org/schemas` you maintain), or from a bare folder of `schemas/*.{yml,json,js}` files: it detects the schemas-only package automatically and submits just the data schemas (no foundation) under `--scope`. This is how the standard schemas are published under `@std`, and how an org publishes its own shared `@org/schemas` once for many foundations to reference. The same flags apply (`--scope`, `--dry-run`, `-o`, `--token`, `--registry`); `--dry-run` (or `-o <file>`) previews the exact submission without authenticating.
@@ -13,7 +13,7 @@
13
13
 
14
14
  import { existsSync } from 'node:fs'
15
15
  import { readFile, writeFile, mkdir } from 'node:fs/promises'
16
- import { join, relative } from 'node:path'
16
+ import { join, relative, basename } from 'node:path'
17
17
  import prompts from 'prompts'
18
18
  import yaml from 'js-yaml'
19
19
  import { resolveFoundationSrcPath } from '@uniweb/build'
@@ -27,11 +27,11 @@ import {
27
27
  import { discoverFoundations, discoverSites } from '../utils/discover.js'
28
28
  import { validatePackageName, getExistingPackageNames, resolveUniqueName } from '../utils/names.js'
29
29
  import { findWorkspaceRoot } from '../utils/workspace.js'
30
- import { detectPackageManager, filterCmd, installCmd } from '../utils/pm.js'
30
+ import { detectPackageManager, detectWorkspacePm, filterCmd, installCmd } from '../utils/pm.js'
31
31
  import { isNonInteractive, getCliPrefix, stripNonInteractiveFlag, formatOptions } from '../utils/interactive.js'
32
32
  import { resolveTemplate } from '../templates/index.js'
33
33
  import { validateTemplate } from '../templates/validator.js'
34
- import { getVersionsForTemplates, PNPM_VERSION } from '../versions.js'
34
+ import { getVersionsForTemplates, resolveCiPnpmVersion, resolveCiNodeVersion } from '../versions.js'
35
35
 
36
36
  // Colors for terminal output
37
37
  const colors = {
@@ -64,16 +64,53 @@ function parseArgs(args) {
64
64
  host: null,
65
65
  force: false,
66
66
  domain: null,
67
+ // `add ci` only: what the workflow publishes ('site' | 'foundation'),
68
+ // the name to register under on the host, and whether to also
69
+ // scaffold PR-preview workflows.
70
+ target: null,
71
+ projectName: null,
72
+ previews: true,
67
73
  }
68
74
 
69
75
  // Booleans (no value) consumed up-front so the value-flag loop below
70
76
  // doesn't accidentally swallow the next positional.
71
- const BOOLEAN_FLAGS = new Set(['--force'])
77
+ const BOOLEAN_FLAGS = new Set(['--force', '--no-previews'])
78
+
79
+ // Value flags, mapped to their result key. Both spellings are accepted:
80
+ // `--host github-pages` and `--host=github-pages`.
81
+ //
82
+ // The `=` form used to be dropped silently — every branch matched on an
83
+ // exact `args[i] === '--flag'`, so `--host=github-pages` fell through
84
+ // and left `host` null. It went unnoticed because `add ci` defaulted to
85
+ // github-pages when no host was given, making the one command our docs
86
+ // print (`uniweb add ci --host=github-pages`) appear to work while
87
+ // actually ignoring the flag. Requiring a real host surfaced it.
88
+ const VALUE_FLAGS = {
89
+ '--path': 'path',
90
+ '--project': 'project',
91
+ '--foundation': 'foundation',
92
+ '--site': 'site',
93
+ '--from': 'from',
94
+ '--host': 'host',
95
+ '--domain': 'domain',
96
+ '--target': 'target',
97
+ '--project-name': 'projectName',
98
+ }
99
+
100
+ /** Split `--flag=value` into [flag, value]; `--flag` into [flag, null]. */
101
+ const splitFlag = (arg) => {
102
+ const eq = arg.indexOf('=')
103
+ return eq === -1 ? [arg, null] : [arg.slice(0, eq), arg.slice(eq + 1)]
104
+ }
72
105
 
73
106
  // Find positional name (first arg after subcommand that's not a flag).
74
107
  for (let i = 1; i < args.length; i++) {
75
108
  if (args[i].startsWith('--')) {
76
- if (!BOOLEAN_FLAGS.has(args[i])) i++ // skip flag value
109
+ const [flag, inlineValue] = splitFlag(args[i])
110
+ // Only skip the next arg when the value is genuinely separate.
111
+ // Previously an `=`-form flag also consumed the following arg,
112
+ // which could swallow the positional name.
113
+ if (!BOOLEAN_FLAGS.has(flag) && inlineValue === null) i++
77
114
  continue
78
115
  }
79
116
  if (!result.name) {
@@ -83,22 +120,18 @@ function parseArgs(args) {
83
120
 
84
121
  // Parse flags
85
122
  for (let i = 1; i < args.length; i++) {
86
- if (args[i] === '--path' && args[i + 1]) {
87
- result.path = args[++i]
88
- } else if (args[i] === '--project' && args[i + 1]) {
89
- result.project = args[++i]
90
- } else if (args[i] === '--foundation' && args[i + 1]) {
91
- result.foundation = args[++i]
92
- } else if (args[i] === '--site' && args[i + 1]) {
93
- result.site = args[++i]
94
- } else if (args[i] === '--from' && args[i + 1]) {
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') {
123
+ const [flag, inlineValue] = splitFlag(args[i])
124
+ const key = VALUE_FLAGS[flag]
125
+ if (key) {
126
+ if (inlineValue !== null) {
127
+ result[key] = inlineValue
128
+ } else if (args[i + 1] !== undefined && !args[i + 1].startsWith('--')) {
129
+ result[key] = args[++i]
130
+ }
131
+ } else if (flag === '--force') {
101
132
  result.force = true
133
+ } else if (flag === '--no-previews') {
134
+ result.previews = false
102
135
  }
103
136
  }
104
137
 
@@ -192,7 +225,16 @@ export async function add(rawArgs) {
192
225
  await addSection(rootDir, parsed)
193
226
  break
194
227
  case 'ci':
195
- await addCi(rootDir, parsed, pm)
228
+ // The scaffolded workflow must install the way THIS WORKSPACE
229
+ // installs, which is a property of the repo (its lockfile), not of
230
+ // how the CLI happened to be launched. `detectPackageManager()`
231
+ // reads npm_config_user_agent, so `npx uniweb add ci` in a pnpm
232
+ // workspace produced a workflow running `npm ci` — which fails on
233
+ // the first CI run, because there is no package-lock.json to
234
+ // install from. npx is a normal way to run the CLI, so this broke
235
+ // the common case. Fall back to the invocation only when the repo
236
+ // has no lockfile to read.
237
+ await addCi(rootDir, parsed, detectWorkspacePm(rootDir) || pm)
196
238
  break
197
239
  default:
198
240
  error(`Unknown subcommand: ${parsed.subcommand}`)
@@ -962,18 +1004,65 @@ export default function ${name}({ content, params }) {
962
1004
  async function addCi(rootDir, opts, pm = 'pnpm') {
963
1005
  // Lazy-load the host registry so the CLI doesn't pay this import on
964
1006
  // every add invocation.
965
- let getAdapter
1007
+ let getAdapter, listAdapters
966
1008
  try {
967
- ({ getAdapter } = await import('@uniweb/build/hosts'))
1009
+ ({ getAdapter, listAdapters } = await import('@uniweb/build/hosts'))
968
1010
  } catch {
969
1011
  error('Failed to load host adapter registry from @uniweb/build/hosts.')
970
1012
  process.exit(1)
971
1013
  }
972
1014
 
973
- // Resolve host. With one CI-capable adapter today (github-pages),
974
- // default silently when --host isn't passed. If more adapters add
975
- // initCi later, this becomes a picker.
976
- const host = opts.host || 'github-pages'
1015
+ // What the workflow publishes. 'foundation' scaffolds the versioned
1016
+ // distribution path (foundations/<name>/<version>/entry.js) the free
1017
+ // alternative to the catalog for a foundation product. Resolved BEFORE
1018
+ // the host, because it narrows which hosts are even eligible.
1019
+ const target = opts.target || 'site'
1020
+ if (!['site', 'foundation'].includes(target)) {
1021
+ error(`Unknown --target '${target}'. Expected 'site' or 'foundation'.`)
1022
+ process.exit(1)
1023
+ }
1024
+
1025
+ // Every adapter that can scaffold CI for this target. Derived from
1026
+ // capability, never a hardcoded list — an adapter that grows an initCi
1027
+ // shows up here with no edit to this file.
1028
+ const ciHosts = listAdapters()
1029
+ .map(name => getAdapter(name))
1030
+ .filter(a => typeof a.initCi === 'function')
1031
+ .filter(a => (target === 'foundation' ? a.display?.foundationCi === true : true))
1032
+ .sort((a, b) => (a.display?.order ?? 999) - (b.display?.order ?? 999))
1033
+
1034
+ if (ciHosts.length === 0) {
1035
+ error(`No host can scaffold CI for a ${target}.`)
1036
+ process.exit(1)
1037
+ }
1038
+
1039
+ let host = opts.host
1040
+ if (!host) {
1041
+ if (ciHosts.length === 1) {
1042
+ host = ciHosts[0].name
1043
+ } else if (isNonInteractive(process.argv)) {
1044
+ error('`uniweb add ci` needs a host.')
1045
+ log(`Available: ${ciHosts.map(a => a.name).join(', ')}`)
1046
+ process.exit(1)
1047
+ } else {
1048
+ const choice = await prompts({
1049
+ type: 'select',
1050
+ name: 'host',
1051
+ message: 'Which host should the workflow deploy to?',
1052
+ choices: ciHosts.map(a => ({
1053
+ title: `${a.display?.title || a.name} · ${a.display?.qualifier || ''}`.trim().replace(/ ·\s*$/, ''),
1054
+ description: a.display?.summary,
1055
+ value: a.name,
1056
+ })),
1057
+ }, {
1058
+ onCancel: () => {
1059
+ log('\nCancelled.')
1060
+ process.exit(0)
1061
+ },
1062
+ })
1063
+ host = choice.host
1064
+ }
1065
+ }
977
1066
 
978
1067
  let adapter
979
1068
  try {
@@ -984,12 +1073,28 @@ async function addCi(rootDir, opts, pm = 'pnpm') {
984
1073
  }
985
1074
 
986
1075
  if (typeof adapter.initCi !== 'function') {
987
- error(`Host '${host}' does not provide a CI workflow yet.`)
988
- log(`Currently supported: github-pages.`)
989
- log(`Other hosts may use platform-side integrations (e.g., Vercel/Netlify connect via dashboard).`)
1076
+ error(`Host '${host}' does not provide a CI workflow.`)
1077
+ log(`Hosts that do: ${ciHosts.map(a => a.name).join(', ')}`)
1078
+ log(`Others are dashboard-driven connect the repo in the host's UI instead.`)
990
1079
  process.exit(1)
991
1080
  }
992
1081
 
1082
+ // Guard the explicit-host case. Without this, `--host netlify --target
1083
+ // foundation` would call an initCi that ignores `target` and silently
1084
+ // scaffold a SITE workflow — the wrong artifact, with no error.
1085
+ if (target === 'foundation' && adapter.display?.foundationCi !== true) {
1086
+ error(`Host '${host}' cannot publish a foundation.`)
1087
+ log(`Hosts that can: ${ciHosts.map(a => a.name).join(', ')}`)
1088
+ log('')
1089
+ log('Foundation publishing needs permanent versioned URLs, which the')
1090
+ log('gh-pages branch layout provides. Other hosts overwrite on deploy.')
1091
+ process.exit(1)
1092
+ }
1093
+
1094
+ if (target === 'foundation') {
1095
+ return addFoundationCi(rootDir, { ...opts, host }, adapter, pm)
1096
+ }
1097
+
993
1098
  // Validate --domain (lightweight: must look like a hostname). The
994
1099
  // adapter decides what to do with it; today only github-pages uses
995
1100
  // it (writes a CNAME, switches UNIWEB_BASE to root).
@@ -1048,7 +1153,11 @@ async function addCi(rootDir, opts, pm = 'pnpm') {
1048
1153
  const rootPkg = JSON.parse(
1049
1154
  await readFile(join(rootDir, 'package.json'), 'utf-8').catch(() => '{}')
1050
1155
  )
1051
- const nodeVersion = parseNodeMajor(rootPkg.engines?.node) || '20'
1156
+ // CI must run the project's own toolchain. The pnpm major comes from
1157
+ // its `packageManager` field when declared; the node major is the
1158
+ // project's floor, raised if that pnpm needs more.
1159
+ const pnpmVersion = resolveCiPnpmVersion(rootPkg)
1160
+ const nodeVersion = resolveCiNodeVersion(rootPkg.engines?.node, pm, pnpmVersion)
1052
1161
 
1053
1162
  const siteDir = join(rootDir, site.path)
1054
1163
  if (!resolvedDomain) {
@@ -1067,25 +1176,16 @@ async function addCi(rootDir, opts, pm = 'pnpm') {
1067
1176
  const result = await adapter.initCi({
1068
1177
  rootDir,
1069
1178
  site,
1179
+ target: 'site',
1070
1180
  packageManager: pm,
1071
1181
  nodeVersion,
1072
- pnpmVersion: PNPM_VERSION,
1182
+ pnpmVersion,
1073
1183
  domain: resolvedDomain,
1184
+ previews: opts.previews !== false,
1185
+ projectName: resolveHostProjectName(rootDir, rootPkg, site, sites.length, opts),
1074
1186
  })
1075
1187
 
1076
- // Write files. Refuse to overwrite without --force so re-running
1077
- // doesn't silently clobber edits the user made to the workflow.
1078
- for (const file of result.files) {
1079
- const fullPath = join(rootDir, file.path)
1080
- if (existsSync(fullPath) && !opts.force) {
1081
- error(`File already exists: ${file.path}`)
1082
- log(`Re-run with --force to overwrite.`)
1083
- process.exit(1)
1084
- }
1085
- await mkdir(join(fullPath, '..'), { recursive: true })
1086
- await writeFile(fullPath, file.content)
1087
- success(`Wrote ${file.path}`)
1088
- }
1188
+ await writeCiFiles(rootDir, result.files, opts.force)
1089
1189
 
1090
1190
  // Persist the adapter's target config into deploy.yml so the user's
1091
1191
  // intent (host + adapter-specific fields like `domain`) is remembered
@@ -1124,6 +1224,151 @@ async function addCi(rootDir, opts, pm = 'pnpm') {
1124
1224
  }
1125
1225
  }
1126
1226
 
1227
+ /**
1228
+ * The name to register this site under on the host (Cloudflare Pages
1229
+ * project, etc.).
1230
+ *
1231
+ * The site package is very often called literally `site` — the scaffold's
1232
+ * default — which makes a terrible project name on a shared host. So:
1233
+ * an explicit `--project-name` wins; otherwise prefer the workspace's own
1234
+ * name (usually the repo name) when the site's name is one of the generic
1235
+ * scaffold defaults; suffix with the site name only when the workspace
1236
+ * holds more than one site and they'd otherwise collide.
1237
+ */
1238
+ const GENERIC_SITE_NAMES = new Set(['site', 'sites', 'www', 'web', 'app'])
1239
+
1240
+ /**
1241
+ * Public URL segment for a foundation published to GitHub Pages.
1242
+ *
1243
+ * Same problem as the site project name, and it matters more here:
1244
+ * this segment is baked into a PERMANENT url that consuming sites pin
1245
+ * (`foundations/<name>/<version>/entry.js`), so `src` — the single-project
1246
+ * scaffold's directory *and* package name — would be a poor forever-name.
1247
+ * Prefer a meaningful package/directory name; fall back to the workspace
1248
+ * name; disambiguate only when several foundations would collide.
1249
+ */
1250
+ const GENERIC_FOUNDATION_NAMES = new Set(['src', 'foundation', 'foundations', 'lib'])
1251
+
1252
+ function resolveFoundationPublicNames(rootDir, rootPkg, foundations) {
1253
+ const workspaceName = stripScope(rootPkg?.name) || basename(rootDir)
1254
+
1255
+ return foundations.map((f, i) => {
1256
+ const pkgName = stripScope(f.name)
1257
+ const dirName = basename(f.path)
1258
+ const candidate = !GENERIC_FOUNDATION_NAMES.has(pkgName)
1259
+ ? pkgName
1260
+ : !GENERIC_FOUNDATION_NAMES.has(dirName)
1261
+ ? dirName
1262
+ : foundations.length > 1
1263
+ ? `${workspaceName}-${i + 1}`
1264
+ : workspaceName
1265
+ return { name: candidate, path: f.path }
1266
+ })
1267
+ }
1268
+
1269
+ function resolveHostProjectName(rootDir, rootPkg, site, siteCount, opts) {
1270
+ if (opts.projectName) return opts.projectName
1271
+
1272
+ const workspaceName = stripScope(rootPkg?.name) || basename(rootDir)
1273
+ const siteName = stripScope(site.name)
1274
+
1275
+ if (!GENERIC_SITE_NAMES.has(siteName)) return siteName
1276
+ if (siteCount > 1) return `${workspaceName}-${siteName}`
1277
+ return workspaceName
1278
+ }
1279
+
1280
+ function stripScope(name) {
1281
+ if (!name || typeof name !== 'string') return null
1282
+ return name.startsWith('@') ? name.split('/').pop() : name
1283
+ }
1284
+
1285
+ /**
1286
+ * Write scaffolded CI files. Refuses to overwrite without --force so
1287
+ * re-running doesn't silently clobber edits the user made to a workflow.
1288
+ */
1289
+ async function writeCiFiles(rootDir, files, force) {
1290
+ for (const file of files) {
1291
+ const fullPath = join(rootDir, file.path)
1292
+ if (existsSync(fullPath) && !force) {
1293
+ error(`File already exists: ${file.path}`)
1294
+ log(`Re-run with --force to overwrite.`)
1295
+ process.exit(1)
1296
+ }
1297
+ await mkdir(join(fullPath, '..'), { recursive: true })
1298
+ await writeFile(fullPath, file.content)
1299
+ success(`Wrote ${file.path}`)
1300
+ }
1301
+ }
1302
+
1303
+ /**
1304
+ * `uniweb add ci --target foundation` — scaffold the workflow that
1305
+ * publishes built foundations at permanent versioned URLs.
1306
+ *
1307
+ * This is the free distribution path for a foundation product: sites
1308
+ * reference `https://<user>.github.io/<repo>/foundations/<name>/<ver>/entry.js`
1309
+ * from site.yml. No catalog, no propagation, no license gating — but no
1310
+ * cost either, and the URLs never break.
1311
+ *
1312
+ * Unlike the site path there is no deploy.yml to write: deploy.yml
1313
+ * records where a *site* is deployed, and a foundation isn't a site.
1314
+ */
1315
+ async function addFoundationCi(rootDir, opts, adapter, pm) {
1316
+ const all = await discoverFoundations(rootDir)
1317
+ if (all.length === 0) {
1318
+ error('No foundation found in this workspace.')
1319
+ log('Add one with `uniweb add foundation` first.')
1320
+ process.exit(1)
1321
+ }
1322
+
1323
+ // --foundation <name> narrows to one; otherwise publish them all,
1324
+ // which is what a multi-foundation repo almost always wants (each gets
1325
+ // its own versioned directory, so there's no collision).
1326
+ let foundations = all
1327
+ if (opts.foundation) {
1328
+ const match = all.find(f => f.name === opts.foundation || basename(f.path) === opts.foundation)
1329
+ if (!match) {
1330
+ error(`Foundation '${opts.foundation}' not found.`)
1331
+ log(`Available: ${all.map(f => f.name).join(', ')}`)
1332
+ process.exit(1)
1333
+ }
1334
+ foundations = [match]
1335
+ }
1336
+
1337
+ const rootPkg = JSON.parse(
1338
+ await readFile(join(rootDir, 'package.json'), 'utf-8').catch(() => '{}')
1339
+ )
1340
+ // CI must run the project's own toolchain. The pnpm major comes from
1341
+ // its `packageManager` field when declared; the node major is the
1342
+ // project's floor, raised if that pnpm needs more.
1343
+ const pnpmVersion = resolveCiPnpmVersion(rootPkg)
1344
+ const nodeVersion = resolveCiNodeVersion(rootPkg.engines?.node, pm, pnpmVersion)
1345
+
1346
+ let result
1347
+ try {
1348
+ result = await adapter.initCi({
1349
+ rootDir,
1350
+ foundations: resolveFoundationPublicNames(rootDir, rootPkg, foundations),
1351
+ target: 'foundation',
1352
+ packageManager: pm,
1353
+ nodeVersion,
1354
+ pnpmVersion,
1355
+ })
1356
+ } catch (err) {
1357
+ error(err.message)
1358
+ process.exit(1)
1359
+ }
1360
+
1361
+ await writeCiFiles(rootDir, result.files, opts.force)
1362
+
1363
+ if (result.postInstructions?.length) {
1364
+ log('')
1365
+ log(`${colors.bright}Next steps:${colors.reset}`)
1366
+ for (const line of result.postInstructions) {
1367
+ log(line ? ` ${line}` : '')
1368
+ }
1369
+ }
1370
+ }
1371
+
1127
1372
  function isLikelyDomain(value) {
1128
1373
  if (typeof value !== 'string' || value.length === 0 || value.length > 253) return false
1129
1374
  // Reject schemes, paths, ports, whitespace, leading/trailing dots/hyphens.
@@ -1173,10 +1418,21 @@ ${colors.bright}Section Options:${colors.reset}
1173
1418
  --foundation <n> Foundation to add section to (prompted if multiple exist)
1174
1419
 
1175
1420
  ${colors.bright}CI Options:${colors.reset}
1176
- --host <name> Host adapter (default: github-pages)
1177
- --site <name> Site the workflow builds (prompted if multiple exist)
1178
- --domain <host> Custom domain (writes CNAME, serves at root)
1179
- --force Overwrite an existing workflow file
1421
+ --host <name> github-pages | cloudflare-pages | netlify | vercel
1422
+ (prompted when omitted)
1423
+ --target <what> site (default) | foundation
1424
+ 'foundation' publishes built foundations at permanent
1425
+ versioned URLs — the free alternative to the catalog
1426
+ --site <name> Site the workflow builds (prompted if multiple exist)
1427
+ --foundation <name> With --target foundation: publish just this one
1428
+ --domain <host> Custom domain (GitHub Pages: writes CNAME, serves at root)
1429
+ --project-name <name> Name to register under on the host (default: the
1430
+ workspace name; Cloudflare Pages project, etc.)
1431
+ --no-previews Skip the per-PR preview workflow
1432
+ --force Overwrite an existing workflow file
1433
+
1434
+ ${colors.dim}Hosts that support PR previews: cloudflare-pages, netlify, vercel.
1435
+ GitHub Pages has no preview environment, so it scaffolds a deploy workflow only.${colors.reset}
1180
1436
 
1181
1437
  ${colors.bright}Examples:${colors.reset}
1182
1438
  uniweb add project docs # Create docs/foundation/ + docs/site/
@@ -1190,8 +1446,11 @@ ${colors.bright}Examples:${colors.reset}
1190
1446
  uniweb add section Hero --foundation ui # Target specific foundation
1191
1447
  uniweb add foundation --project docs # Create ./docs/foundation/ (co-located)
1192
1448
  uniweb add site --project docs # Create ./docs/site/ (co-located)
1193
- uniweb add ci # Add GitHub Pages deploy workflow
1449
+ uniweb add ci # Pick a host, add a deploy workflow
1194
1450
  uniweb add ci --host github-pages --site marketing # Pick host + site explicitly
1451
+ uniweb add ci --host netlify # Deploy + PR-preview workflows
1452
+ uniweb add ci --host vercel --no-previews # Deploy workflow only
1195
1453
  uniweb add ci --domain mysite.com # Custom domain → writes CNAME + UNIWEB_BASE=/
1454
+ uniweb add ci --target foundation # Publish foundations at versioned URLs
1196
1455
  `)
1197
1456
  }
@@ -919,8 +919,9 @@ function showNextSteps(hasFoundations, hasSites) {
919
919
  if (hasSites) {
920
920
  log('')
921
921
  log(`${colors.bright}Ship a site:${colors.reset}`)
922
- log(` ${colors.bright}uniweb publish${colors.reset} Uniweb hosting (brings the foundation along)`)
923
- log(` ${colors.bright}uniweb deploy --host${colors.reset}=… Third-party host`)
922
+ log(` ${colors.bright}uniweb deploy${colors.reset} Pick a host and ship (asks where, then remembers)`)
923
+ log(` ${colors.bright}uniweb add ci --host${colors.reset}=… Deploy on every push (free hosts; adds PR previews)`)
924
+ log(` ${colors.bright}uniweb publish${colors.reset} Uniweb Cloud (brings the foundation along)`)
924
925
  log(` Or upload ${colors.cyan}dist/${colors.reset} (\`uniweb export\`) to any static host`)
925
926
  }
926
927
  }
@@ -1,29 +1,41 @@
1
1
  /**
2
2
  * Deploy Command — ship a site to its resolved target.
3
3
  *
4
- * `uniweb deploy` resolves WHERE a site goes from deploy.yml (+ `--host` /
5
- * `--target`) and ships it there:
6
- * - THIRD-PARTY host (`s3-cloudfront`, `cloudflare-pages`, `github-pages`,
7
- * `generic-static`, …): build `dist/` in bundle mode and hand it to the
8
- * host adapter for upload + invalidation.
9
- * - UNIWEB hosting target (an explicit `--host=uniweb`, or a `uniweb` target
10
- * in deploy.yml): DELEGATE to `uniweb publish` the smart path (sync +
11
- * dynamic hosting, brings the foundation along). So deploy.yml stays one
12
- * actionable "where this site deploys" record, uniweb included.
4
+ * With a destination already known (a `--host` flag, or a target in
5
+ * deploy.yml) `uniweb deploy` acts on it directly. With NO destination
6
+ * known it runs the WIZARD — "Where should this site go?" — rather than
7
+ * assuming one. Assuming a host was the old behavior and it was wrong;
8
+ * so was the picker that replaced it, which listed every registered
9
+ * adapter when only one could actually be deployed to. The wizard offers
10
+ * only destinations that can be acted on, and says what acting will do.
13
11
  *
14
- * `uniweb publish` is the canonical direct verb for Uniweb hosting (reach for it
15
- * by default); `uniweb export` writes a self-contained artifact you upload
16
- * yourself.
12
+ * Four outcomes, from the wizard or resolved directly:
13
+ * - THIRD-PARTY host, upload now: build `dist/` in bundle mode and hand
14
+ * it to the adapter's deploy hook (`github-pages`, `cloudflare-pages`,
15
+ * `netlify`, `vercel`, `s3-cloudfront`).
16
+ * - THIRD-PARTY host, set up CI: delegate to `uniweb add ci --host=…`,
17
+ * which scaffolds a workflow so every push deploys. For most free
18
+ * hosts this is the better answer, and the wizard says so.
19
+ * - UNIWEB hosting (`--host=uniweb`, or a `uniweb` target in deploy.yml,
20
+ * or "Uniweb Cloud" in the wizard): DELEGATE to `uniweb publish` — the
21
+ * smart path (sync + dynamic hosting, brings the foundation along). So
22
+ * deploy.yml stays one actionable "where this site deploys" record.
23
+ * - SOMEWHERE ELSE: delegate to `uniweb export` for a self-contained
24
+ * artifact you upload yourself.
25
+ *
26
+ * `uniweb publish` remains the canonical direct verb for Uniweb hosting.
17
27
  *
18
28
  * Host resolution:
19
29
  * 1. --target <name> picks a target from deploy.yml (full config)
20
30
  * 2. deploy.yml's `default:` target when no flag is given
21
- * 3. with no deploy.yml at all, NO host is chosen → deploy prompts for a
22
- * third-party adapter (interactive) rather than assuming Uniweb;
23
- * non-interactive → an actionable error pointing at `publish` / `--host`
31
+ * 3. with no deploy.yml at all, NO host is chosen → the wizard runs
32
+ * (interactive); non-interactive an actionable error listing the
33
+ * real options
24
34
  * 4. --host <name> is a one-off override (does NOT persist to deploy.yml)
35
+ * 5. --host with no value jumps straight to the wizard
25
36
  *
26
37
  * Usage:
38
+ * uniweb deploy Wizard: pick a destination
27
39
  * uniweb deploy --host <name> Build bundle-mode dist/ + hand to the host adapter
28
40
  * uniweb deploy --host=uniweb Delegate to `uniweb publish` (Uniweb hosting)
29
41
  * uniweb deploy --target <name> Pick a target from deploy.yml
@@ -33,12 +45,12 @@
33
45
  * Escape hatch: UNIWEB_SKIP_BUILD=1 reuses an existing dist/.
34
46
  */
35
47
 
36
- import { existsSync } from 'node:fs'
48
+ import { existsSync, readFileSync } from 'node:fs'
37
49
  import { resolve, join } from 'node:path'
38
50
  import { execSync } from 'node:child_process'
39
51
 
40
52
  import { loadDeployYml, resolveTarget, recordLastDeploy } from '@uniweb/build/site'
41
- import { promptForHost } from '../utils/host-prompt.js'
53
+ import { promptForDestination } from '../utils/destination-prompt.js'
42
54
  import { readFlagValue } from '../utils/args.js'
43
55
  import { parseBoolEnv } from '../utils/env.js'
44
56
 
@@ -76,10 +88,10 @@ export async function deploy(args = []) {
76
88
  // Host dispatch. Resolution order:
77
89
  // 1. --target <name> picks a target from deploy.yml
78
90
  // 2. deploy.yml's `default:` target when no flag is given
79
- // 3. with no deploy.yml, the implicit default is host: 'uniweb'
91
+ // 3. with no deploy.yml, NO host is chosen → the wizard decides
80
92
  // 4. --host <name> is a one-off override (does not persist on success)
81
93
  const targetFromFlag = readFlagValue(args, '--target')
82
- let hostFromFlag = readFlagValue(args, '--host')
94
+ const hostFromFlag = readFlagValue(args, '--host')
83
95
  const noSave = args.includes('--no-save')
84
96
 
85
97
  let deployYml
@@ -96,50 +108,94 @@ export async function deploy(args = []) {
96
108
  say.err(err.message)
97
109
  process.exit(1)
98
110
  }
99
- // --host with no value → interactive picker. Pre-selects the resolved
100
- // target's host so Enter does the obvious thing.
101
- if (hostFromFlag === null) {
102
- try {
103
- hostFromFlag = await promptForHost({ args, preselect: resolved.host })
104
- } catch (err) {
105
- say.err(err.message)
106
- process.exit(1)
107
- }
108
- }
109
- let host = hostFromFlag || resolved.host
110
-
111
- // A Uniweb-hosting target is `publish`'s flow. When the user EXPLICITLY chose
112
- // uniweb (a `--host=uniweb`, or a `uniweb` target in deploy.yml), DELEGATE to
113
- // `uniweb publish` so deploy.yml stays one actionable record. When NO host was
114
- // chosen (the implicit default with no deploy.yml), don't assume uniweb:
115
- // prompt for a third-party adapter (interactive) or point at publish / --host
116
- // (non-interactive). promptForHost lists only third-party adapters.
117
- if (host === 'uniweb') {
118
- const explicitUniweb = hostFromFlag === 'uniweb' || (resolved.fromFile && resolved.host === 'uniweb')
119
- if (explicitUniweb) {
120
- say.info('Uniweb hosting target running `uniweb publish`.')
121
- console.log('')
122
- // publish ignores deploy's --host/--target; --dry-run/--no-save/--backend
123
- // /--token pass straight through.
124
- const { publish } = await import('./publish.js')
125
- const result = await publish(args)
126
- process.exit(result?.exitCode ?? 0)
127
- }
111
+
112
+ // Resolve a PLAN — {kind, host?, action?} before doing anything. A
113
+ // destination is "known" when the user named it (--host) or deploy.yml
114
+ // records one. Otherwise the wizard asks; we never assume.
115
+ //
116
+ // resolveTarget returns host:'uniweb' with fromFile:false as its
117
+ // no-deploy.yml fallback. That is NOT a chosen destination — treat it
118
+ // as "unknown" so a bare `deploy` in a fresh project opens the wizard
119
+ // instead of silently heading for the paid product.
120
+ let plan
121
+ // `--host` with NO value (readFlagValue → null) is an explicit "ask me",
122
+ // and it must outrank deploy.yml: the user typed the flag precisely to
123
+ // choose something other than what's recorded. Omitting the flag
124
+ // entirely ( undefined) is the different case where deploy.yml wins.
125
+ const wantsWizard = hostFromFlag === null
126
+ const knownHost = wantsWizard
127
+ ? null
128
+ : (hostFromFlag || (resolved.fromFile ? resolved.host : null))
129
+
130
+ if (knownHost === 'uniweb') {
131
+ plan = { kind: 'uniweb' }
132
+ } else if (knownHost) {
133
+ plan = { kind: 'adapter', host: knownHost, action: 'deploy' }
134
+ } else {
128
135
  if (isNonInteractive(args)) {
129
- say.err('`uniweb deploy` needs a host. For Uniweb hosting use `uniweb publish`; for a third-party host pass `--host=<adapter>`.')
136
+ if (wantsWizard) {
137
+ // They asked to pick, but there's no way to ask. Never silently
138
+ // fall back to deploy.yml — that would deploy somewhere the user
139
+ // was in the middle of overriding.
140
+ const { listAdapters } = await import('@uniweb/build/hosts')
141
+ say.err('`--host` requires a value when running non-interactively.')
142
+ say.dim(`Known adapters: ${listAdapters().join(', ')}.`)
143
+ say.dim('For Uniweb Cloud use `uniweb publish` (or --host=uniweb).')
144
+ process.exit(1)
145
+ }
146
+ say.err('`uniweb deploy` needs a destination.')
130
147
  console.log('')
131
- say.dim('`uniweb publish` Uniweb hosting (sync + dynamic hosting; brings the foundation along)')
132
- say.dim('`uniweb deploy --host=…` Third-party host (s3-cloudfront, cloudflare-pages, github-pages, generic-static)')
148
+ say.dim('`uniweb publish` Uniweb Cloud (sync + dynamic hosting; brings the foundation along)')
149
+ say.dim('`uniweb deploy --host=…` A third-party host run `uniweb deploy --help` for the list')
150
+ say.dim('`uniweb add ci --host=…` Set up CI so every push deploys')
133
151
  say.dim('`uniweb export` Self-contained dist/ artifact you upload anywhere')
134
152
  process.exit(1)
135
153
  }
136
- say.info('`uniweb deploy` ships to a third-party host. (For Uniweb hosting, run `uniweb publish`.)')
137
154
  try {
138
- host = await promptForHost({ args })
155
+ plan = await promptForDestination({ args, preselect: resolved.fromFile ? resolved.host : null })
139
156
  } catch (err) {
140
157
  say.err(err.message)
141
158
  process.exit(1)
142
159
  }
160
+ if (!plan) {
161
+ console.log('\nDeploy cancelled.')
162
+ process.exit(0)
163
+ }
164
+ }
165
+
166
+ // Uniweb Cloud is `publish`'s flow — delegate so deploy.yml stays one
167
+ // actionable record and there's a single implementation of go-live.
168
+ if (plan.kind === 'uniweb') {
169
+ say.info('Uniweb Cloud → running `uniweb publish`.')
170
+ console.log('')
171
+ // publish ignores deploy's --host/--target; --dry-run/--no-save/--backend
172
+ // /--token pass straight through.
173
+ const { publish } = await import('./publish.js')
174
+ const result = await publish(args)
175
+ process.exit(result?.exitCode ?? 0)
176
+ }
177
+
178
+ if (plan.kind === 'export') {
179
+ // `export` has no --dry-run of its own, so handle it here rather than
180
+ // passing a flag it would ignore — a dry run that actually ran a full
181
+ // build would be a lie.
182
+ if (dryRun) {
183
+ say.info('Dry run — would run `uniweb export` to build a self-contained dist/.')
184
+ return
185
+ }
186
+ say.info('Building a self-contained artifact → running `uniweb export`.')
187
+ console.log('')
188
+ const { exportSite } = await import('./export.js')
189
+ await exportSite([])
190
+ return
191
+ }
192
+
193
+ // CI setup is `add ci`'s job. Shell out rather than re-implement: add
194
+ // owns site discovery, package-manager detection, the --force guard,
195
+ // and the deploy.yml target write.
196
+ if (plan.action === 'ci') {
197
+ await delegateToAddCi(siteDir, plan.host, dryRun)
198
+ return
143
199
  }
144
200
 
145
201
  // Auto-save scope: 'off' from --no-save OR an ad-hoc --host override (we don't
@@ -148,13 +204,48 @@ export async function deploy(args = []) {
148
204
  const hostOverridden = !!hostFromFlag && hostFromFlag !== resolved.host
149
205
  const autoSave = noSave || hostOverridden ? 'off' : resolved.autoSave
150
206
 
151
- await deployStaticHost(siteDir, host, resolved, {
207
+ await deployStaticHost(siteDir, plan.host, resolved, {
152
208
  dryRun,
153
209
  autoSave,
154
210
  hostOverridden,
155
211
  })
156
212
  }
157
213
 
214
+ // ─── CI delegation ──────────────────────────────────────────
215
+
216
+ async function delegateToAddCi(siteDir, host, dryRun) {
217
+ const workspaceRoot = findWorkspaceRoot(siteDir) || siteDir
218
+
219
+ // Forward the site we already resolved. Without this, `add ci` re-runs
220
+ // its own discovery and asks "which site?" a second time in a
221
+ // multi-site workspace — right after the user answered that in the
222
+ // wizard. `add ci --site` matches on the package name.
223
+ let siteFlag = ''
224
+ try {
225
+ const pkg = JSON.parse(readFileSync(join(siteDir, 'package.json'), 'utf8'))
226
+ if (pkg.name) siteFlag = ` --site ${JSON.stringify(pkg.name)}`
227
+ } catch {
228
+ // No readable package.json — let `add ci` resolve the site itself.
229
+ }
230
+
231
+ const cmd = `add ci --host ${JSON.stringify(host)}${siteFlag}`
232
+ if (dryRun) {
233
+ say.info(`Dry run — would run \`uniweb ${cmd}\` in ${workspaceRoot}`)
234
+ return
235
+ }
236
+ say.info(`Setting up CI → running \`uniweb ${cmd}\`.`)
237
+ console.log('')
238
+ try {
239
+ execSync(`node ${JSON.stringify(process.argv[1])} ${cmd}`, {
240
+ cwd: workspaceRoot,
241
+ stdio: 'inherit',
242
+ })
243
+ } catch {
244
+ // add ci already printed the reason and set the exit code.
245
+ process.exit(1)
246
+ }
247
+ }
248
+
158
249
  // ─── Static-host deploy (S3+CloudFront, etc.) ─────────────────
159
250
  //
160
251
  // Picked when the resolved deploy.yml target (or --host override) names a
@@ -193,13 +284,23 @@ async function deployStaticHost(siteDir, hostName, resolved, { dryRun, autoSave,
193
284
 
194
285
  if (dryRun) {
195
286
  say.info(`Dry run — would deploy via host adapter: ${c.bold}${adapter.name}${c.reset}`)
196
- say.dim(`Site dir : ${siteDir}`)
197
- say.dim(`dist/ : ${existsSync(distDir) ? 'exists (would not rebuild)' : 'missing (would build)'}`)
198
- say.dim(`Target : ${resolved.targetName}`)
199
- say.dim(`bucket : ${deployConfig.bucket || '(unset)'}`)
200
- say.dim(`distributionId : ${deployConfig.distributionId || '(unset)'}`)
201
- say.dim(`region : ${deployConfig.region || '(unset)'}`)
202
- say.dim(`profile : ${deployConfig.profile || '(default AWS chain)'}`)
287
+ say.dim(`Site dir : ${siteDir}`)
288
+ say.dim(`dist/ : ${existsSync(distDir) ? 'exists (would not rebuild)' : 'missing (would build)'}`)
289
+ say.dim(`Target : ${resolved.targetName}`)
290
+ if (adapter.display?.pushWith) say.dim(`Uploads by: ${adapter.display.pushWith}`)
291
+ // Echo the resolved target's config as-is. Each adapter reads
292
+ // different keys (bucket/distributionId for s3, projectName for
293
+ // Cloudflare, siteId for Netlify), so listing a fixed set would be
294
+ // wrong for four of the five.
295
+ const configKeys = Object.keys(deployConfig)
296
+ if (configKeys.length) {
297
+ say.dim('Config :')
298
+ for (const key of configKeys.sort()) {
299
+ say.dim(` ${key}: ${typeof deployConfig[key] === 'object' ? JSON.stringify(deployConfig[key]) : deployConfig[key]}`)
300
+ }
301
+ } else {
302
+ say.dim('Config : (none in deploy.yml — the adapter will say what it needs)')
303
+ }
203
304
  return
204
305
  }
205
306
 
@@ -234,8 +335,9 @@ async function deployStaticHost(siteDir, hostName, resolved, { dryRun, autoSave,
234
335
 
235
336
  // Hand off to the adapter. DeployError is the structured shape from
236
337
  // @uniweb/build/hosts/s3-cloudfront — translate to user-facing output.
338
+ let deployResult
237
339
  try {
238
- await adapter.deploy({
340
+ deployResult = await adapter.deploy({
239
341
  distDir,
240
342
  deployConfig,
241
343
  env: process.env,
@@ -262,8 +364,12 @@ async function deployStaticHost(siteDir, hostName, resolved, { dryRun, autoSave,
262
364
  lastDeploy: {
263
365
  at: new Date().toISOString(),
264
366
  host: hostName,
265
- // Static hosts know their public URL only via the user's CDN config;
266
- // we don't have it on hand. Future: pull from a known field.
367
+ // Adapters that drive a host CLI get the public URL back from it
368
+ // (wrangler/netlify/vercel print it; github-pages derives it from
369
+ // the git remote). s3-cloudfront can't — the public URL lives in
370
+ // the user's CloudFront/DNS config, which we never see — so the
371
+ // field is simply omitted there rather than guessed.
372
+ ...(deployResult?.url ? { url: deployResult.url } : {}),
267
373
  },
268
374
  })
269
375
  if (hostOverridden && !dryRun) {
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "generatedAt": "2026-07-18T13:09:21.219Z",
3
+ "generatedAt": "2026-07-21T20:48:30.091Z",
4
4
  "packages": {
5
5
  "@uniweb/build": {
6
- "version": "0.14.28",
6
+ "version": "0.14.30",
7
7
  "path": "framework/build",
8
8
  "deps": [
9
9
  "@uniweb/content-reader",
@@ -99,7 +99,7 @@
99
99
  "deps": []
100
100
  },
101
101
  "@uniweb/unipress": {
102
- "version": "0.4.35",
102
+ "version": "0.4.37",
103
103
  "path": "framework/unipress",
104
104
  "deps": [
105
105
  "@uniweb/build",
package/src/index.js CHANGED
@@ -1092,40 +1092,42 @@ function printCommandHelp(command) {
1092
1092
  if (command === 'release') command = 'register'
1093
1093
  const blocks = {
1094
1094
  deploy: `
1095
- ${colors.cyan}${colors.bright}uniweb deploy${colors.reset} ${colors.dim}— Ship a site to its resolved target${colors.reset}
1095
+ ${colors.cyan}${colors.bright}uniweb deploy${colors.reset} ${colors.dim}— Ship a site to a host${colors.reset}
1096
1096
 
1097
1097
  ${colors.bright}Usage:${colors.reset}
1098
- uniweb deploy --host <name> [options]
1098
+ uniweb deploy [options]
1099
1099
 
1100
- Ships a site to its resolved target. A THIRD-PARTY host builds dist/ (bundle
1101
- mode) and hands it to a host adapter for upload + invalidation. A UNIWEB target
1102
- (\`--host=uniweb\`, or a \`uniweb\` target in deploy.yml) delegates to
1103
- ${colors.cyan}uniweb publish${colors.reset} (sync + dynamic serving; brings the foundation along) the
1104
- canonical direct verb for Uniweb hosting. With no host chosen, deploy prompts for
1105
- a third-party adapter. For a self-contained dist/ you upload yourself, use
1106
- ${colors.cyan}uniweb export${colors.reset}.
1100
+ With no destination configured, opens the WIZARD "Where should this site go?" —
1101
+ and only offers destinations that can actually be acted on. Pick a host and it
1102
+ asks whether to set up CI (every push deploys) or upload from this machine now.
1103
+ Your answer is remembered in deploy.yml, so later runs go straight there.
1104
+
1105
+ Choosing ${colors.bright}Uniweb Cloud${colors.reset} delegates to ${colors.cyan}uniweb publish${colors.reset} (sync + dynamic serving;
1106
+ brings the foundation along). Choosing ${colors.bright}Somewhere else${colors.reset} runs ${colors.cyan}uniweb export${colors.reset}.
1107
1107
 
1108
1108
  ${colors.bright}Hosts:${colors.reset}
1109
- uniweb Uniweb hosting (delegates to \`uniweb publish\`)
1110
- cloudflare-pages Cloudflare Pages (build artifact + adapter postBuild)
1111
- netlify Netlify (alias of cloudflare-pages adapter)
1112
- vercel Vercel (build-only deploy via \`npx vercel\`)
1113
- github-pages GitHub Pages (build-onlypush dist/ to gh-pages)
1114
- s3-cloudfront AWS S3 + CloudFront (uploads + invalidates via CLI)
1115
- generic-static Plain static-host build, no host-specific helpers
1109
+ github-pages GitHub Pages CI workflow, or commit dist/ to gh-pages
1110
+ cloudflare-pages Cloudflare Pages CI workflow + PR previews, or wrangler
1111
+ netlify Netlify CI workflow + PR previews, or the netlify CLI
1112
+ vercel Vercel — CI workflow + PR previews, or the vercel CLI
1113
+ s3-cloudfront AWS S3 + CloudFront uploads + invalidates via the aws CLI
1114
+ uniweb Uniweb Cloud (delegates to \`uniweb publish\`)
1116
1115
 
1117
1116
  ${colors.bright}Options:${colors.reset}
1118
- --host <name> The host to ship to (no value → interactive third-party picker, TTY only)
1117
+ --host <name> The host to ship to (no value → the wizard, TTY only)
1119
1118
  --target <name> Pick a target from deploy.yml (default: deploy.yml's \`default:\`)
1120
1119
  --dry-run Resolve the target + adapter; print summary; upload nothing
1121
1120
  --no-save Skip the auto-save of lastDeploy in deploy.yml
1122
1121
  --non-interactive Fail with usage info instead of prompting
1123
1122
 
1124
1123
  ${colors.bright}Examples:${colors.reset}
1125
- uniweb deploy --host=cloudflare-pages # Build + upload to Cloudflare Pages
1124
+ uniweb deploy # Wizard: pick a destination
1125
+ uniweb deploy --host=cloudflare-pages # Build + upload via wrangler
1126
1126
  uniweb deploy --host=s3-cloudfront # Build + upload + invalidate
1127
1127
  uniweb deploy --host=uniweb # → delegates to \`uniweb publish\`
1128
1128
  uniweb deploy --target=preview # Named target from deploy.yml
1129
+
1130
+ ${colors.dim}To deploy on every push instead, see \`uniweb add ci --help\`.${colors.reset}
1129
1131
  `,
1130
1132
  publish: `
1131
1133
  ${colors.cyan}${colors.bright}uniweb publish${colors.reset} ${colors.dim}— Publish a site to Uniweb hosting (the smart path)${colors.reset}
@@ -1223,6 +1225,7 @@ ${colors.bright}Subcommands:${colors.reset}
1223
1225
  add site [name] Add a site (--from, --foundation, --path, --project)
1224
1226
  add extension <name> Add an extension (--from, --site, --path)
1225
1227
  add section <name> Add a section type to a foundation (--foundation)
1228
+ add ci Add a CI workflow so every push deploys (--host, --target)
1226
1229
 
1227
1230
  ${colors.bright}Common options:${colors.reset}
1228
1231
  --from <template> Source content from a template
@@ -1480,7 +1483,7 @@ ${colors.bright}Commands:${colors.reset}
1480
1483
  dev Start a dev server for a site
1481
1484
  build Build the current project
1482
1485
  publish Publish a site to Uniweb hosting (smart: foundation + sync + go live)
1483
- deploy Deploy a site to a third-party host (--host=<adapter>)
1486
+ deploy Ship a site to a host (asks where, if not yet configured)
1484
1487
  export Export a self-contained site for third-party hosting
1485
1488
  register Register a foundation + its data schemas with the backend registry
1486
1489
  release Release a foundation version (synonym of register)
@@ -1509,6 +1512,7 @@ ${colors.bright}Add Subcommands:${colors.reset}
1509
1512
  add site [name] Add a site (--from, --foundation, --path, --project)
1510
1513
  add extension <name> Add an extension (--from, --site, --path)
1511
1514
  add section <name> Add a section type to a foundation (--foundation)
1515
+ add ci Add a CI workflow so every push deploys (--host, --target)
1512
1516
 
1513
1517
  ${colors.bright}Global Options:${colors.reset}
1514
1518
  --version, -v Show version
@@ -1528,13 +1532,13 @@ ${colors.bright}Publish Options:${colors.reset}
1528
1532
  \`uniweb deploy --host=<name>\`.
1529
1533
 
1530
1534
  ${colors.bright}Deploy Options:${colors.reset}
1531
- --host <name> The host to ship to (no value interactive third-party
1532
- picker, TTY only). Third-party: cloudflare-pages, netlify,
1533
- vercel, github-pages, s3-cloudfront, generic-static.
1534
- \`--host=uniweb\` delegates to \`uniweb publish\`.
1535
+ --host <name> The host to ship to. Omit it (TTY) to open the wizard:
1536
+ github-pages, cloudflare-pages, netlify, vercel,
1537
+ s3-cloudfront, or \`uniweb\` (delegates to \`uniweb publish\`).
1535
1538
  --target <name> Pick a target from deploy.yml (default: deploy.yml's \`default:\`)
1536
1539
  --dry-run Resolve the target + adapter; print summary; upload nothing
1537
1540
  --no-save Skip the auto-save of lastDeploy in deploy.yml
1541
+ To deploy on every push instead, see \`uniweb add ci --help\`.
1538
1542
 
1539
1543
  ${colors.bright}Dev Options:${colors.reset}
1540
1544
  <site> Site name to run (positional)
@@ -0,0 +1,136 @@
1
+ /**
2
+ * The deploy wizard — "Where should this site go?"
3
+ *
4
+ * Distinct from `host-prompt.js`, and the split matters. `promptForHost`
5
+ * answers a *build* question — which host-specific helper files should
6
+ * land in `dist/` — so it lists every registered adapter, including
7
+ * shape-only entries like `generic-static`. This module answers a
8
+ * *shipping* question, so it lists destinations you can actually act on
9
+ * and says what acting will do.
10
+ *
11
+ * The bug that motivated the split: the deploy path used to reuse
12
+ * `promptForHost`, offering six adapters when only one implemented a
13
+ * deploy hook. Five of six choices dead-ended on "does not implement a
14
+ * deploy step". A picker must never offer a door that doesn't open — so
15
+ * entries are derived from real adapter capability (`deploy` / `initCi`),
16
+ * not from a registry key list.
17
+ *
18
+ * Two synthetic entries round out the menu:
19
+ * - Uniweb Cloud — not an adapter; it delegates to `uniweb publish`.
20
+ * Listed after the third-party hosts rather than first: the framework
21
+ * is backend-optional and standalone-first, so leading a generic
22
+ * `deploy` with the paid product would read as an upsell.
23
+ * - Somewhere else — delegates to `uniweb export`.
24
+ */
25
+
26
+ import { promptSelect } from './workspace.js'
27
+ import { isNonInteractive } from './interactive.js'
28
+
29
+ export const UNIWEB_DESTINATION = {
30
+ value: { kind: 'uniweb' },
31
+ title: 'Uniweb Cloud · paid, dynamic + visual editing',
32
+ description:
33
+ 'Sync + dynamic SSR, visual editing for content authors, foundation propagation. Runs `uniweb publish`.',
34
+ }
35
+
36
+ export const EXPORT_DESTINATION = {
37
+ value: { kind: 'export' },
38
+ title: 'Somewhere else · export a folder',
39
+ description:
40
+ 'Builds a self-contained dist/ you upload yourself. Runs `uniweb export`.',
41
+ }
42
+
43
+ /**
44
+ * Build the destination list from the adapter registry.
45
+ *
46
+ * @returns {Promise<Array<{value: object, title: string, description: string}>>}
47
+ */
48
+ export async function buildDestinationChoices() {
49
+ const { listAdapters, getAdapter } = await import('@uniweb/build/hosts')
50
+
51
+ const adapters = listAdapters()
52
+ .map(name => getAdapter(name))
53
+ .filter(a => a.display?.wizard !== false)
54
+ // Never offer a door that doesn't open.
55
+ .filter(a => typeof a.deploy === 'function' || typeof a.initCi === 'function')
56
+ .sort((a, b) => (a.display?.order ?? 999) - (b.display?.order ?? 999))
57
+
58
+ const choices = adapters.map(a => ({
59
+ value: { kind: 'adapter', host: a.name },
60
+ title: `${a.display?.title || a.name} · ${a.display?.qualifier || ''}`.trim().replace(/ ·\s*$/, ''),
61
+ description: a.display?.summary || '',
62
+ }))
63
+
64
+ return [...choices, UNIWEB_DESTINATION, EXPORT_DESTINATION]
65
+ }
66
+
67
+ /**
68
+ * Ask *how* to ship to an adapter that supports both paths. Skipped when
69
+ * the adapter only supports one — no point asking a question with a
70
+ * single answer.
71
+ *
72
+ * @returns {Promise<'ci'|'deploy'|null>} null when cancelled.
73
+ */
74
+ async function promptForAction(adapter) {
75
+ const canCi = typeof adapter.initCi === 'function'
76
+ const canDeploy = typeof adapter.deploy === 'function'
77
+
78
+ if (canCi && !canDeploy) return 'ci'
79
+ if (canDeploy && !canCi) return 'deploy'
80
+
81
+ const label = adapter.display?.title || adapter.name
82
+ const previews = adapter.display?.previews
83
+ ? ' Pull requests get their own preview URL.'
84
+ : ''
85
+
86
+ return promptSelect(`${label} — how?`, [
87
+ {
88
+ value: 'ci',
89
+ title: 'Set it up to deploy on every push · recommended',
90
+ description:
91
+ `Writes a GitHub Actions workflow. One-time setup — after this, pushing to the default branch deploys.${previews}`,
92
+ },
93
+ {
94
+ value: 'deploy',
95
+ title: 'Upload from this machine now',
96
+ description:
97
+ `Builds dist/ here and pushes it with ${adapter.display?.pushWith || 'the host CLI'}.`,
98
+ },
99
+ ])
100
+ }
101
+
102
+ /**
103
+ * Run the wizard.
104
+ *
105
+ * @param {object} opts
106
+ * @param {string[]} opts.args — Argv, used only to gate non-interactive mode.
107
+ * @param {string|null} [opts.preselect] — Host name to float to the top, so
108
+ * Enter repeats what deploy.yml already records.
109
+ * @returns {Promise<{kind: string, host?: string, action?: string}|null>}
110
+ * null when the user cancels.
111
+ * @throws {Error} When non-interactive — the caller prints the guidance.
112
+ */
113
+ export async function promptForDestination({ args = [], preselect = null } = {}) {
114
+ if (isNonInteractive(args)) {
115
+ throw new Error('Cannot prompt for a destination when running non-interactively.')
116
+ }
117
+
118
+ let choices = await buildDestinationChoices()
119
+
120
+ // Float the remembered target so Enter does the obvious thing.
121
+ if (preselect) {
122
+ const idx = choices.findIndex(c => c.value.kind === 'adapter' && c.value.host === preselect)
123
+ if (idx > 0) choices = [choices[idx], ...choices.filter((_, i) => i !== idx)]
124
+ }
125
+
126
+ const picked = await promptSelect('Where should this site go?', choices)
127
+ if (!picked) return null
128
+ if (picked.kind !== 'adapter') return picked
129
+
130
+ const { getAdapter } = await import('@uniweb/build/hosts')
131
+ const adapter = getAdapter(picked.host)
132
+ const action = await promptForAction(adapter)
133
+ if (!action) return null
134
+
135
+ return { ...picked, action }
136
+ }
@@ -188,10 +188,16 @@ export function isWorkspaceRoot(dir = process.cwd()) {
188
188
  }
189
189
 
190
190
  /**
191
- * Interactive prompt to select from multiple options
191
+ * Interactive prompt to select from multiple options.
192
+ *
193
+ * Accepts plain strings (title === value) or `{ title, value, description }`
194
+ * objects. `prompts` reveals a choice's description only while that row is
195
+ * highlighted, so put the always-visible qualifier in `title` and the
196
+ * fuller explanation in `description`.
197
+ *
192
198
  * @param {string} message - Prompt message
193
- * @param {string[]} choices - Array of choices
194
- * @returns {Promise<string|null>} - Selected choice or null if cancelled
199
+ * @param {Array<string|{title: string, value?: any, description?: string}>} choices
200
+ * @returns {Promise<any|null>} - Selected value, or null if cancelled
195
201
  */
196
202
  export async function promptSelect(message, choices) {
197
203
  const prompts = (await import('prompts')).default
@@ -200,8 +206,12 @@ export async function promptSelect(message, choices) {
200
206
  type: 'select',
201
207
  name: 'value',
202
208
  message,
203
- choices: choices.map(c => ({ title: c, value: c })),
209
+ choices: choices.map(c => (
210
+ typeof c === 'string'
211
+ ? { title: c, value: c }
212
+ : { title: c.title, value: c.value !== undefined ? c.value : c.title, description: c.description }
213
+ )),
204
214
  })
205
215
 
206
- return response.value || null
216
+ return response.value ?? null
207
217
  }
package/src/versions.js CHANGED
@@ -53,16 +53,86 @@ let resolvedVersions = null
53
53
  export const REACT_VERSION = '^19.0.0'
54
54
 
55
55
  /**
56
- * The pnpm major the framework's generated CI standardizes on.
56
+ * Default pnpm major for generated CI, used only when the project does not
57
+ * say which pnpm it uses (see `resolveCiPnpmVersion`).
57
58
  *
58
- * Scaffolded CI workflows (`uniweb add ci --host=github-pages`) pin pnpm
59
- * through `pnpm/action-setup`. This is the one place that value lives, so the
60
- * pin tracks a single supported major instead of drifting as a hardcoded
61
- * literal inside each host adapter. A bare major installs the latest patch of
62
- * that major at CI run time. Bump this when the framework moves to a new pnpm
63
- * major (latest stable is 11.x as of this writing; pnpm 12 is still alpha).
59
+ * **This is deliberately 10, not the newest release.** CI must run the same
60
+ * toolchain the developer runs, or it reports failures that have nothing to
61
+ * do with their code. Pinning 11 while projects were on 10 produced exactly
62
+ * that, twice over, on a real repo:
63
+ *
64
+ * - pnpm 11 refuses any dependency published in the last 24 hours
65
+ * (`ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION`). Publish a package and its
66
+ * consumer's CI fails until the next day.
67
+ * - pnpm 11 changed build-script approval: `onlyBuiltDependencies` in
68
+ * `pnpm-workspace.yaml` no longer suffices, and the install exits 1 with
69
+ * `ERR_PNPM_IGNORED_BUILDS`. Uniweb sites use `sharp` for image
70
+ * processing, so this breaks essentially every site's first CI run.
71
+ *
72
+ * Neither is a bug in pnpm — both are reasonable hardening. They are simply
73
+ * not things a generated workflow should impose on a project that has not
74
+ * opted into that major.
75
+ *
76
+ * A bare major installs the latest patch of that major at CI run time. Before
77
+ * bumping this, check the new major's release notes for install-time policy
78
+ * changes, and update `PNPM_MIN_NODE` in the same commit.
64
79
  */
65
- export const PNPM_VERSION = '11'
80
+ export const PNPM_VERSION = '10'
81
+
82
+ /**
83
+ * Minimum Node major each supported pnpm major will run on, from that
84
+ * release's own `engines.node`.
85
+ *
86
+ * pnpm 11 declares `>=22.13` and imports `node:sqlite`, which does not exist
87
+ * before Node 22 — pairing it with Node 20 fails at `pnpm install` with
88
+ * `ERR_UNKNOWN_BUILTIN_MODULE` before the build is attempted. That was the
89
+ * default until 2026-07-21, because the CI node major came from the
90
+ * project's `engines.node` and the workspace template declares `>=20.19`.
91
+ *
92
+ * Verify with `npm view pnpm@<major> engines` when adding an entry.
93
+ */
94
+ export const PNPM_MIN_NODE = { '10': 18, '11': 22 }
95
+
96
+ /**
97
+ * Resolve the pnpm major a generated CI workflow should install.
98
+ *
99
+ * `packageManager` (the corepack field) is authoritative when present — it is
100
+ * the project stating which pnpm it is developed and locked against, so CI
101
+ * should honour it rather than impose a different major.
102
+ *
103
+ * `pnpm-lock.yaml`'s `lockfileVersion` is NOT usable as a signal: pnpm 10 and
104
+ * 11 both write `9.0`, so it cannot distinguish them. Checked, not assumed.
105
+ *
106
+ * @param {object|null} rootPkg — the workspace root's parsed package.json.
107
+ * @returns {string} pnpm major, as a string for YAML interpolation.
108
+ */
109
+ export function resolveCiPnpmVersion(rootPkg) {
110
+ const declared = rootPkg?.packageManager
111
+ const match = typeof declared === 'string' ? declared.match(/^pnpm@(\d+)/) : null
112
+ return match ? match[1] : PNPM_VERSION
113
+ }
114
+
115
+ /**
116
+ * Resolve the Node major a generated CI workflow should install.
117
+ *
118
+ * Respects the project's own floor (`engines.node`) but never drops below
119
+ * what the pinned package manager needs. The project's declared minimum is a
120
+ * *lower* bound on what its code needs, not a ceiling — running CI on a newer
121
+ * Node is fine; running it on one the package manager refuses to start on is
122
+ * not.
123
+ *
124
+ * @param {string|null|undefined} enginesNode — the project's `engines.node`.
125
+ * @param {'pnpm'|'npm'|'yarn'} packageManager
126
+ * @param {string} [pnpmVersion] — the resolved pnpm major, when pnpm.
127
+ * @param {string} [fallback='20'] — used when engines.node is absent/unparseable.
128
+ * @returns {string} Node major, as a string for YAML interpolation.
129
+ */
130
+ export function resolveCiNodeVersion(enginesNode, packageManager, pnpmVersion = PNPM_VERSION, fallback = '20') {
131
+ const match = enginesNode ? String(enginesNode).match(/(\d+)/) : null
132
+ const declared = match ? Number(match[1]) : Number(fallback)
133
+ const floor = packageManager === 'pnpm' ? (PNPM_MIN_NODE[pnpmVersion] ?? 0) : 0
134
+ return String(Math.max(declared, floor))
135
+ }
66
136
 
67
137
  /**
68
138
  * Get the CLI's own package.json