uniweb 0.12.46 → 0.12.47

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.47",
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
49
  "@uniweb/content-reader": "1.1.12",
50
50
  "@uniweb/semantic-parser": "1.1.17",
51
- "@uniweb/build": "0.14.28"
51
+ "@uniweb/build": "0.14.29"
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'
@@ -64,11 +64,17 @@ 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'])
72
78
 
73
79
  // Find positional name (first arg after subcommand that's not a flag).
74
80
  for (let i = 1; i < args.length; i++) {
@@ -97,8 +103,14 @@ function parseArgs(args) {
97
103
  result.host = args[++i]
98
104
  } else if (args[i] === '--domain' && args[i + 1]) {
99
105
  result.domain = args[++i]
106
+ } else if (args[i] === '--target' && args[i + 1]) {
107
+ result.target = args[++i]
108
+ } else if (args[i] === '--project-name' && args[i + 1]) {
109
+ result.projectName = args[++i]
100
110
  } else if (args[i] === '--force') {
101
111
  result.force = true
112
+ } else if (args[i] === '--no-previews') {
113
+ result.previews = false
102
114
  }
103
115
  }
104
116
 
@@ -962,18 +974,65 @@ export default function ${name}({ content, params }) {
962
974
  async function addCi(rootDir, opts, pm = 'pnpm') {
963
975
  // Lazy-load the host registry so the CLI doesn't pay this import on
964
976
  // every add invocation.
965
- let getAdapter
977
+ let getAdapter, listAdapters
966
978
  try {
967
- ({ getAdapter } = await import('@uniweb/build/hosts'))
979
+ ({ getAdapter, listAdapters } = await import('@uniweb/build/hosts'))
968
980
  } catch {
969
981
  error('Failed to load host adapter registry from @uniweb/build/hosts.')
970
982
  process.exit(1)
971
983
  }
972
984
 
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'
985
+ // What the workflow publishes. 'foundation' scaffolds the versioned
986
+ // distribution path (foundations/<name>/<version>/entry.js) the free
987
+ // alternative to the catalog for a foundation product. Resolved BEFORE
988
+ // the host, because it narrows which hosts are even eligible.
989
+ const target = opts.target || 'site'
990
+ if (!['site', 'foundation'].includes(target)) {
991
+ error(`Unknown --target '${target}'. Expected 'site' or 'foundation'.`)
992
+ process.exit(1)
993
+ }
994
+
995
+ // Every adapter that can scaffold CI for this target. Derived from
996
+ // capability, never a hardcoded list — an adapter that grows an initCi
997
+ // shows up here with no edit to this file.
998
+ const ciHosts = listAdapters()
999
+ .map(name => getAdapter(name))
1000
+ .filter(a => typeof a.initCi === 'function')
1001
+ .filter(a => (target === 'foundation' ? a.display?.foundationCi === true : true))
1002
+ .sort((a, b) => (a.display?.order ?? 999) - (b.display?.order ?? 999))
1003
+
1004
+ if (ciHosts.length === 0) {
1005
+ error(`No host can scaffold CI for a ${target}.`)
1006
+ process.exit(1)
1007
+ }
1008
+
1009
+ let host = opts.host
1010
+ if (!host) {
1011
+ if (ciHosts.length === 1) {
1012
+ host = ciHosts[0].name
1013
+ } else if (isNonInteractive(process.argv)) {
1014
+ error('`uniweb add ci` needs a host.')
1015
+ log(`Available: ${ciHosts.map(a => a.name).join(', ')}`)
1016
+ process.exit(1)
1017
+ } else {
1018
+ const choice = await prompts({
1019
+ type: 'select',
1020
+ name: 'host',
1021
+ message: 'Which host should the workflow deploy to?',
1022
+ choices: ciHosts.map(a => ({
1023
+ title: `${a.display?.title || a.name} · ${a.display?.qualifier || ''}`.trim().replace(/ ·\s*$/, ''),
1024
+ description: a.display?.summary,
1025
+ value: a.name,
1026
+ })),
1027
+ }, {
1028
+ onCancel: () => {
1029
+ log('\nCancelled.')
1030
+ process.exit(0)
1031
+ },
1032
+ })
1033
+ host = choice.host
1034
+ }
1035
+ }
977
1036
 
978
1037
  let adapter
979
1038
  try {
@@ -984,12 +1043,28 @@ async function addCi(rootDir, opts, pm = 'pnpm') {
984
1043
  }
985
1044
 
986
1045
  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).`)
1046
+ error(`Host '${host}' does not provide a CI workflow.`)
1047
+ log(`Hosts that do: ${ciHosts.map(a => a.name).join(', ')}`)
1048
+ log(`Others are dashboard-driven connect the repo in the host's UI instead.`)
990
1049
  process.exit(1)
991
1050
  }
992
1051
 
1052
+ // Guard the explicit-host case. Without this, `--host netlify --target
1053
+ // foundation` would call an initCi that ignores `target` and silently
1054
+ // scaffold a SITE workflow — the wrong artifact, with no error.
1055
+ if (target === 'foundation' && adapter.display?.foundationCi !== true) {
1056
+ error(`Host '${host}' cannot publish a foundation.`)
1057
+ log(`Hosts that can: ${ciHosts.map(a => a.name).join(', ')}`)
1058
+ log('')
1059
+ log('Foundation publishing needs permanent versioned URLs, which the')
1060
+ log('gh-pages branch layout provides. Other hosts overwrite on deploy.')
1061
+ process.exit(1)
1062
+ }
1063
+
1064
+ if (target === 'foundation') {
1065
+ return addFoundationCi(rootDir, { ...opts, host }, adapter, pm)
1066
+ }
1067
+
993
1068
  // Validate --domain (lightweight: must look like a hostname). The
994
1069
  // adapter decides what to do with it; today only github-pages uses
995
1070
  // it (writes a CNAME, switches UNIWEB_BASE to root).
@@ -1067,25 +1142,16 @@ async function addCi(rootDir, opts, pm = 'pnpm') {
1067
1142
  const result = await adapter.initCi({
1068
1143
  rootDir,
1069
1144
  site,
1145
+ target: 'site',
1070
1146
  packageManager: pm,
1071
1147
  nodeVersion,
1072
1148
  pnpmVersion: PNPM_VERSION,
1073
1149
  domain: resolvedDomain,
1150
+ previews: opts.previews !== false,
1151
+ projectName: resolveHostProjectName(rootDir, rootPkg, site, sites.length, opts),
1074
1152
  })
1075
1153
 
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
- }
1154
+ await writeCiFiles(rootDir, result.files, opts.force)
1089
1155
 
1090
1156
  // Persist the adapter's target config into deploy.yml so the user's
1091
1157
  // intent (host + adapter-specific fields like `domain`) is remembered
@@ -1124,6 +1190,147 @@ async function addCi(rootDir, opts, pm = 'pnpm') {
1124
1190
  }
1125
1191
  }
1126
1192
 
1193
+ /**
1194
+ * The name to register this site under on the host (Cloudflare Pages
1195
+ * project, etc.).
1196
+ *
1197
+ * The site package is very often called literally `site` — the scaffold's
1198
+ * default — which makes a terrible project name on a shared host. So:
1199
+ * an explicit `--project-name` wins; otherwise prefer the workspace's own
1200
+ * name (usually the repo name) when the site's name is one of the generic
1201
+ * scaffold defaults; suffix with the site name only when the workspace
1202
+ * holds more than one site and they'd otherwise collide.
1203
+ */
1204
+ const GENERIC_SITE_NAMES = new Set(['site', 'sites', 'www', 'web', 'app'])
1205
+
1206
+ /**
1207
+ * Public URL segment for a foundation published to GitHub Pages.
1208
+ *
1209
+ * Same problem as the site project name, and it matters more here:
1210
+ * this segment is baked into a PERMANENT url that consuming sites pin
1211
+ * (`foundations/<name>/<version>/entry.js`), so `src` — the single-project
1212
+ * scaffold's directory *and* package name — would be a poor forever-name.
1213
+ * Prefer a meaningful package/directory name; fall back to the workspace
1214
+ * name; disambiguate only when several foundations would collide.
1215
+ */
1216
+ const GENERIC_FOUNDATION_NAMES = new Set(['src', 'foundation', 'foundations', 'lib'])
1217
+
1218
+ function resolveFoundationPublicNames(rootDir, rootPkg, foundations) {
1219
+ const workspaceName = stripScope(rootPkg?.name) || basename(rootDir)
1220
+
1221
+ return foundations.map((f, i) => {
1222
+ const pkgName = stripScope(f.name)
1223
+ const dirName = basename(f.path)
1224
+ const candidate = !GENERIC_FOUNDATION_NAMES.has(pkgName)
1225
+ ? pkgName
1226
+ : !GENERIC_FOUNDATION_NAMES.has(dirName)
1227
+ ? dirName
1228
+ : foundations.length > 1
1229
+ ? `${workspaceName}-${i + 1}`
1230
+ : workspaceName
1231
+ return { name: candidate, path: f.path }
1232
+ })
1233
+ }
1234
+
1235
+ function resolveHostProjectName(rootDir, rootPkg, site, siteCount, opts) {
1236
+ if (opts.projectName) return opts.projectName
1237
+
1238
+ const workspaceName = stripScope(rootPkg?.name) || basename(rootDir)
1239
+ const siteName = stripScope(site.name)
1240
+
1241
+ if (!GENERIC_SITE_NAMES.has(siteName)) return siteName
1242
+ if (siteCount > 1) return `${workspaceName}-${siteName}`
1243
+ return workspaceName
1244
+ }
1245
+
1246
+ function stripScope(name) {
1247
+ if (!name || typeof name !== 'string') return null
1248
+ return name.startsWith('@') ? name.split('/').pop() : name
1249
+ }
1250
+
1251
+ /**
1252
+ * Write scaffolded CI files. Refuses to overwrite without --force so
1253
+ * re-running doesn't silently clobber edits the user made to a workflow.
1254
+ */
1255
+ async function writeCiFiles(rootDir, files, force) {
1256
+ for (const file of files) {
1257
+ const fullPath = join(rootDir, file.path)
1258
+ if (existsSync(fullPath) && !force) {
1259
+ error(`File already exists: ${file.path}`)
1260
+ log(`Re-run with --force to overwrite.`)
1261
+ process.exit(1)
1262
+ }
1263
+ await mkdir(join(fullPath, '..'), { recursive: true })
1264
+ await writeFile(fullPath, file.content)
1265
+ success(`Wrote ${file.path}`)
1266
+ }
1267
+ }
1268
+
1269
+ /**
1270
+ * `uniweb add ci --target foundation` — scaffold the workflow that
1271
+ * publishes built foundations at permanent versioned URLs.
1272
+ *
1273
+ * This is the free distribution path for a foundation product: sites
1274
+ * reference `https://<user>.github.io/<repo>/foundations/<name>/<ver>/entry.js`
1275
+ * from site.yml. No catalog, no propagation, no license gating — but no
1276
+ * cost either, and the URLs never break.
1277
+ *
1278
+ * Unlike the site path there is no deploy.yml to write: deploy.yml
1279
+ * records where a *site* is deployed, and a foundation isn't a site.
1280
+ */
1281
+ async function addFoundationCi(rootDir, opts, adapter, pm) {
1282
+ const all = await discoverFoundations(rootDir)
1283
+ if (all.length === 0) {
1284
+ error('No foundation found in this workspace.')
1285
+ log('Add one with `uniweb add foundation` first.')
1286
+ process.exit(1)
1287
+ }
1288
+
1289
+ // --foundation <name> narrows to one; otherwise publish them all,
1290
+ // which is what a multi-foundation repo almost always wants (each gets
1291
+ // its own versioned directory, so there's no collision).
1292
+ let foundations = all
1293
+ if (opts.foundation) {
1294
+ const match = all.find(f => f.name === opts.foundation || basename(f.path) === opts.foundation)
1295
+ if (!match) {
1296
+ error(`Foundation '${opts.foundation}' not found.`)
1297
+ log(`Available: ${all.map(f => f.name).join(', ')}`)
1298
+ process.exit(1)
1299
+ }
1300
+ foundations = [match]
1301
+ }
1302
+
1303
+ const rootPkg = JSON.parse(
1304
+ await readFile(join(rootDir, 'package.json'), 'utf-8').catch(() => '{}')
1305
+ )
1306
+ const nodeVersion = parseNodeMajor(rootPkg.engines?.node) || '20'
1307
+
1308
+ let result
1309
+ try {
1310
+ result = await adapter.initCi({
1311
+ rootDir,
1312
+ foundations: resolveFoundationPublicNames(rootDir, rootPkg, foundations),
1313
+ target: 'foundation',
1314
+ packageManager: pm,
1315
+ nodeVersion,
1316
+ pnpmVersion: PNPM_VERSION,
1317
+ })
1318
+ } catch (err) {
1319
+ error(err.message)
1320
+ process.exit(1)
1321
+ }
1322
+
1323
+ await writeCiFiles(rootDir, result.files, opts.force)
1324
+
1325
+ if (result.postInstructions?.length) {
1326
+ log('')
1327
+ log(`${colors.bright}Next steps:${colors.reset}`)
1328
+ for (const line of result.postInstructions) {
1329
+ log(line ? ` ${line}` : '')
1330
+ }
1331
+ }
1332
+ }
1333
+
1127
1334
  function isLikelyDomain(value) {
1128
1335
  if (typeof value !== 'string' || value.length === 0 || value.length > 253) return false
1129
1336
  // Reject schemes, paths, ports, whitespace, leading/trailing dots/hyphens.
@@ -1173,10 +1380,21 @@ ${colors.bright}Section Options:${colors.reset}
1173
1380
  --foundation <n> Foundation to add section to (prompted if multiple exist)
1174
1381
 
1175
1382
  ${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
1383
+ --host <name> github-pages | cloudflare-pages | netlify | vercel
1384
+ (prompted when omitted)
1385
+ --target <what> site (default) | foundation
1386
+ 'foundation' publishes built foundations at permanent
1387
+ versioned URLs — the free alternative to the catalog
1388
+ --site <name> Site the workflow builds (prompted if multiple exist)
1389
+ --foundation <name> With --target foundation: publish just this one
1390
+ --domain <host> Custom domain (GitHub Pages: writes CNAME, serves at root)
1391
+ --project-name <name> Name to register under on the host (default: the
1392
+ workspace name; Cloudflare Pages project, etc.)
1393
+ --no-previews Skip the per-PR preview workflow
1394
+ --force Overwrite an existing workflow file
1395
+
1396
+ ${colors.dim}Hosts that support PR previews: cloudflare-pages, netlify, vercel.
1397
+ GitHub Pages has no preview environment, so it scaffolds a deploy workflow only.${colors.reset}
1180
1398
 
1181
1399
  ${colors.bright}Examples:${colors.reset}
1182
1400
  uniweb add project docs # Create docs/foundation/ + docs/site/
@@ -1190,8 +1408,11 @@ ${colors.bright}Examples:${colors.reset}
1190
1408
  uniweb add section Hero --foundation ui # Target specific foundation
1191
1409
  uniweb add foundation --project docs # Create ./docs/foundation/ (co-located)
1192
1410
  uniweb add site --project docs # Create ./docs/site/ (co-located)
1193
- uniweb add ci # Add GitHub Pages deploy workflow
1411
+ uniweb add ci # Pick a host, add a deploy workflow
1194
1412
  uniweb add ci --host github-pages --site marketing # Pick host + site explicitly
1413
+ uniweb add ci --host netlify # Deploy + PR-preview workflows
1414
+ uniweb add ci --host vercel --no-previews # Deploy workflow only
1195
1415
  uniweb add ci --domain mysite.com # Custom domain → writes CNAME + UNIWEB_BASE=/
1416
+ uniweb add ci --target foundation # Publish foundations at versioned URLs
1196
1417
  `)
1197
1418
  }
@@ -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-21T14:39:07.990Z",
4
4
  "packages": {
5
5
  "@uniweb/build": {
6
- "version": "0.14.28",
6
+ "version": "0.14.29",
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.36",
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
  }