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 +6 -4
- package/package.json +3 -3
- package/partials/agents.md +12 -2
- package/src/commands/add.js +250 -29
- package/src/commands/build.js +3 -2
- package/src/commands/deploy.js +170 -64
- package/src/framework-index.json +3 -3
- package/src/index.js +27 -23
- package/src/utils/destination-prompt.js +136 -0
- package/src/utils/workspace.js +15 -5
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
|
|
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
|
|
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: `
|
|
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.
|
|
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.
|
|
51
|
+
"@uniweb/build": "0.14.29"
|
|
52
52
|
},
|
|
53
53
|
"peerDependenciesMeta": {
|
|
54
54
|
"@uniweb/build": {
|
package/partials/agents.md
CHANGED
|
@@ -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
|
|
151
|
-
#
|
|
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.
|
package/src/commands/add.js
CHANGED
|
@@ -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
|
-
//
|
|
974
|
-
//
|
|
975
|
-
//
|
|
976
|
-
|
|
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
|
|
988
|
-
log(`
|
|
989
|
-
log(`
|
|
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
|
-
|
|
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>
|
|
1177
|
-
|
|
1178
|
-
--
|
|
1179
|
-
|
|
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 #
|
|
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
|
}
|
package/src/commands/build.js
CHANGED
|
@@ -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
|
|
923
|
-
log(` ${colors.bright}uniweb
|
|
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
|
}
|
package/src/commands/deploy.js
CHANGED
|
@@ -1,29 +1,41 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Deploy Command — ship a site to its resolved target.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
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 →
|
|
22
|
-
*
|
|
23
|
-
*
|
|
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 {
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
100
|
-
//
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
//
|
|
112
|
-
//
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
|
|
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
|
|
132
|
-
say.dim('`uniweb deploy --host=…`
|
|
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
|
-
|
|
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
|
|
197
|
-
say.dim(`dist/
|
|
198
|
-
say.dim(`Target
|
|
199
|
-
say.dim(`
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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
|
-
//
|
|
266
|
-
//
|
|
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) {
|
package/src/framework-index.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 1,
|
|
3
|
-
"generatedAt": "2026-07-
|
|
3
|
+
"generatedAt": "2026-07-21T14:39:07.990Z",
|
|
4
4
|
"packages": {
|
|
5
5
|
"@uniweb/build": {
|
|
6
|
-
"version": "0.14.
|
|
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.
|
|
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
|
|
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
|
|
1098
|
+
uniweb deploy [options]
|
|
1099
1099
|
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
(
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
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
|
-
|
|
1110
|
-
cloudflare-pages Cloudflare Pages
|
|
1111
|
-
netlify Netlify
|
|
1112
|
-
vercel Vercel
|
|
1113
|
-
|
|
1114
|
-
|
|
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 →
|
|
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
|
|
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
|
|
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 (
|
|
1532
|
-
|
|
1533
|
-
|
|
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
|
+
}
|
package/src/utils/workspace.js
CHANGED
|
@@ -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
|
|
194
|
-
* @returns {Promise<
|
|
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 => (
|
|
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
|
|
216
|
+
return response.value ?? null
|
|
207
217
|
}
|