uniweb 0.12.46 → 0.12.48
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -4
- package/package.json +4 -4
- package/partials/agents.md +12 -2
- package/src/commands/add.js +309 -50
- 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/src/versions.js +78 -8
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.48",
|
|
4
4
|
"description": "Create structured Vite + React sites with content/code separation",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -41,14 +41,14 @@
|
|
|
41
41
|
"js-yaml": "^4.1.0",
|
|
42
42
|
"prompts": "^2.4.2",
|
|
43
43
|
"tar": "^7.0.0",
|
|
44
|
-
"@uniweb/kit": "0.9.24",
|
|
45
44
|
"@uniweb/core": "0.7.20",
|
|
45
|
+
"@uniweb/kit": "0.9.24",
|
|
46
46
|
"@uniweb/runtime": "0.8.26"
|
|
47
47
|
},
|
|
48
48
|
"peerDependencies": {
|
|
49
|
+
"@uniweb/build": "0.14.30",
|
|
49
50
|
"@uniweb/content-reader": "1.1.12",
|
|
50
|
-
"@uniweb/semantic-parser": "1.1.17"
|
|
51
|
-
"@uniweb/build": "0.14.28"
|
|
51
|
+
"@uniweb/semantic-parser": "1.1.17"
|
|
52
52
|
},
|
|
53
53
|
"peerDependenciesMeta": {
|
|
54
54
|
"@uniweb/build": {
|
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'
|
|
@@ -27,11 +27,11 @@ import {
|
|
|
27
27
|
import { discoverFoundations, discoverSites } from '../utils/discover.js'
|
|
28
28
|
import { validatePackageName, getExistingPackageNames, resolveUniqueName } from '../utils/names.js'
|
|
29
29
|
import { findWorkspaceRoot } from '../utils/workspace.js'
|
|
30
|
-
import { detectPackageManager, filterCmd, installCmd } from '../utils/pm.js'
|
|
30
|
+
import { detectPackageManager, detectWorkspacePm, filterCmd, installCmd } from '../utils/pm.js'
|
|
31
31
|
import { isNonInteractive, getCliPrefix, stripNonInteractiveFlag, formatOptions } from '../utils/interactive.js'
|
|
32
32
|
import { resolveTemplate } from '../templates/index.js'
|
|
33
33
|
import { validateTemplate } from '../templates/validator.js'
|
|
34
|
-
import { getVersionsForTemplates,
|
|
34
|
+
import { getVersionsForTemplates, resolveCiPnpmVersion, resolveCiNodeVersion } from '../versions.js'
|
|
35
35
|
|
|
36
36
|
// Colors for terminal output
|
|
37
37
|
const colors = {
|
|
@@ -64,16 +64,53 @@ function parseArgs(args) {
|
|
|
64
64
|
host: null,
|
|
65
65
|
force: false,
|
|
66
66
|
domain: null,
|
|
67
|
+
// `add ci` only: what the workflow publishes ('site' | 'foundation'),
|
|
68
|
+
// the name to register under on the host, and whether to also
|
|
69
|
+
// scaffold PR-preview workflows.
|
|
70
|
+
target: null,
|
|
71
|
+
projectName: null,
|
|
72
|
+
previews: true,
|
|
67
73
|
}
|
|
68
74
|
|
|
69
75
|
// Booleans (no value) consumed up-front so the value-flag loop below
|
|
70
76
|
// doesn't accidentally swallow the next positional.
|
|
71
|
-
const BOOLEAN_FLAGS = new Set(['--force'])
|
|
77
|
+
const BOOLEAN_FLAGS = new Set(['--force', '--no-previews'])
|
|
78
|
+
|
|
79
|
+
// Value flags, mapped to their result key. Both spellings are accepted:
|
|
80
|
+
// `--host github-pages` and `--host=github-pages`.
|
|
81
|
+
//
|
|
82
|
+
// The `=` form used to be dropped silently — every branch matched on an
|
|
83
|
+
// exact `args[i] === '--flag'`, so `--host=github-pages` fell through
|
|
84
|
+
// and left `host` null. It went unnoticed because `add ci` defaulted to
|
|
85
|
+
// github-pages when no host was given, making the one command our docs
|
|
86
|
+
// print (`uniweb add ci --host=github-pages`) appear to work while
|
|
87
|
+
// actually ignoring the flag. Requiring a real host surfaced it.
|
|
88
|
+
const VALUE_FLAGS = {
|
|
89
|
+
'--path': 'path',
|
|
90
|
+
'--project': 'project',
|
|
91
|
+
'--foundation': 'foundation',
|
|
92
|
+
'--site': 'site',
|
|
93
|
+
'--from': 'from',
|
|
94
|
+
'--host': 'host',
|
|
95
|
+
'--domain': 'domain',
|
|
96
|
+
'--target': 'target',
|
|
97
|
+
'--project-name': 'projectName',
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Split `--flag=value` into [flag, value]; `--flag` into [flag, null]. */
|
|
101
|
+
const splitFlag = (arg) => {
|
|
102
|
+
const eq = arg.indexOf('=')
|
|
103
|
+
return eq === -1 ? [arg, null] : [arg.slice(0, eq), arg.slice(eq + 1)]
|
|
104
|
+
}
|
|
72
105
|
|
|
73
106
|
// Find positional name (first arg after subcommand that's not a flag).
|
|
74
107
|
for (let i = 1; i < args.length; i++) {
|
|
75
108
|
if (args[i].startsWith('--')) {
|
|
76
|
-
|
|
109
|
+
const [flag, inlineValue] = splitFlag(args[i])
|
|
110
|
+
// Only skip the next arg when the value is genuinely separate.
|
|
111
|
+
// Previously an `=`-form flag also consumed the following arg,
|
|
112
|
+
// which could swallow the positional name.
|
|
113
|
+
if (!BOOLEAN_FLAGS.has(flag) && inlineValue === null) i++
|
|
77
114
|
continue
|
|
78
115
|
}
|
|
79
116
|
if (!result.name) {
|
|
@@ -83,22 +120,18 @@ function parseArgs(args) {
|
|
|
83
120
|
|
|
84
121
|
// Parse flags
|
|
85
122
|
for (let i = 1; i < args.length; i++) {
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
} else if (
|
|
95
|
-
result.from = args[++i]
|
|
96
|
-
} else if (args[i] === '--host' && args[i + 1]) {
|
|
97
|
-
result.host = args[++i]
|
|
98
|
-
} else if (args[i] === '--domain' && args[i + 1]) {
|
|
99
|
-
result.domain = args[++i]
|
|
100
|
-
} else if (args[i] === '--force') {
|
|
123
|
+
const [flag, inlineValue] = splitFlag(args[i])
|
|
124
|
+
const key = VALUE_FLAGS[flag]
|
|
125
|
+
if (key) {
|
|
126
|
+
if (inlineValue !== null) {
|
|
127
|
+
result[key] = inlineValue
|
|
128
|
+
} else if (args[i + 1] !== undefined && !args[i + 1].startsWith('--')) {
|
|
129
|
+
result[key] = args[++i]
|
|
130
|
+
}
|
|
131
|
+
} else if (flag === '--force') {
|
|
101
132
|
result.force = true
|
|
133
|
+
} else if (flag === '--no-previews') {
|
|
134
|
+
result.previews = false
|
|
102
135
|
}
|
|
103
136
|
}
|
|
104
137
|
|
|
@@ -192,7 +225,16 @@ export async function add(rawArgs) {
|
|
|
192
225
|
await addSection(rootDir, parsed)
|
|
193
226
|
break
|
|
194
227
|
case 'ci':
|
|
195
|
-
|
|
228
|
+
// The scaffolded workflow must install the way THIS WORKSPACE
|
|
229
|
+
// installs, which is a property of the repo (its lockfile), not of
|
|
230
|
+
// how the CLI happened to be launched. `detectPackageManager()`
|
|
231
|
+
// reads npm_config_user_agent, so `npx uniweb add ci` in a pnpm
|
|
232
|
+
// workspace produced a workflow running `npm ci` — which fails on
|
|
233
|
+
// the first CI run, because there is no package-lock.json to
|
|
234
|
+
// install from. npx is a normal way to run the CLI, so this broke
|
|
235
|
+
// the common case. Fall back to the invocation only when the repo
|
|
236
|
+
// has no lockfile to read.
|
|
237
|
+
await addCi(rootDir, parsed, detectWorkspacePm(rootDir) || pm)
|
|
196
238
|
break
|
|
197
239
|
default:
|
|
198
240
|
error(`Unknown subcommand: ${parsed.subcommand}`)
|
|
@@ -962,18 +1004,65 @@ export default function ${name}({ content, params }) {
|
|
|
962
1004
|
async function addCi(rootDir, opts, pm = 'pnpm') {
|
|
963
1005
|
// Lazy-load the host registry so the CLI doesn't pay this import on
|
|
964
1006
|
// every add invocation.
|
|
965
|
-
let getAdapter
|
|
1007
|
+
let getAdapter, listAdapters
|
|
966
1008
|
try {
|
|
967
|
-
({ getAdapter } = await import('@uniweb/build/hosts'))
|
|
1009
|
+
({ getAdapter, listAdapters } = await import('@uniweb/build/hosts'))
|
|
968
1010
|
} catch {
|
|
969
1011
|
error('Failed to load host adapter registry from @uniweb/build/hosts.')
|
|
970
1012
|
process.exit(1)
|
|
971
1013
|
}
|
|
972
1014
|
|
|
973
|
-
//
|
|
974
|
-
//
|
|
975
|
-
//
|
|
976
|
-
|
|
1015
|
+
// What the workflow publishes. 'foundation' scaffolds the versioned
|
|
1016
|
+
// distribution path (foundations/<name>/<version>/entry.js) — the free
|
|
1017
|
+
// alternative to the catalog for a foundation product. Resolved BEFORE
|
|
1018
|
+
// the host, because it narrows which hosts are even eligible.
|
|
1019
|
+
const target = opts.target || 'site'
|
|
1020
|
+
if (!['site', 'foundation'].includes(target)) {
|
|
1021
|
+
error(`Unknown --target '${target}'. Expected 'site' or 'foundation'.`)
|
|
1022
|
+
process.exit(1)
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
// Every adapter that can scaffold CI for this target. Derived from
|
|
1026
|
+
// capability, never a hardcoded list — an adapter that grows an initCi
|
|
1027
|
+
// shows up here with no edit to this file.
|
|
1028
|
+
const ciHosts = listAdapters()
|
|
1029
|
+
.map(name => getAdapter(name))
|
|
1030
|
+
.filter(a => typeof a.initCi === 'function')
|
|
1031
|
+
.filter(a => (target === 'foundation' ? a.display?.foundationCi === true : true))
|
|
1032
|
+
.sort((a, b) => (a.display?.order ?? 999) - (b.display?.order ?? 999))
|
|
1033
|
+
|
|
1034
|
+
if (ciHosts.length === 0) {
|
|
1035
|
+
error(`No host can scaffold CI for a ${target}.`)
|
|
1036
|
+
process.exit(1)
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
let host = opts.host
|
|
1040
|
+
if (!host) {
|
|
1041
|
+
if (ciHosts.length === 1) {
|
|
1042
|
+
host = ciHosts[0].name
|
|
1043
|
+
} else if (isNonInteractive(process.argv)) {
|
|
1044
|
+
error('`uniweb add ci` needs a host.')
|
|
1045
|
+
log(`Available: ${ciHosts.map(a => a.name).join(', ')}`)
|
|
1046
|
+
process.exit(1)
|
|
1047
|
+
} else {
|
|
1048
|
+
const choice = await prompts({
|
|
1049
|
+
type: 'select',
|
|
1050
|
+
name: 'host',
|
|
1051
|
+
message: 'Which host should the workflow deploy to?',
|
|
1052
|
+
choices: ciHosts.map(a => ({
|
|
1053
|
+
title: `${a.display?.title || a.name} · ${a.display?.qualifier || ''}`.trim().replace(/ ·\s*$/, ''),
|
|
1054
|
+
description: a.display?.summary,
|
|
1055
|
+
value: a.name,
|
|
1056
|
+
})),
|
|
1057
|
+
}, {
|
|
1058
|
+
onCancel: () => {
|
|
1059
|
+
log('\nCancelled.')
|
|
1060
|
+
process.exit(0)
|
|
1061
|
+
},
|
|
1062
|
+
})
|
|
1063
|
+
host = choice.host
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
977
1066
|
|
|
978
1067
|
let adapter
|
|
979
1068
|
try {
|
|
@@ -984,12 +1073,28 @@ async function addCi(rootDir, opts, pm = 'pnpm') {
|
|
|
984
1073
|
}
|
|
985
1074
|
|
|
986
1075
|
if (typeof adapter.initCi !== 'function') {
|
|
987
|
-
error(`Host '${host}' does not provide a CI workflow
|
|
988
|
-
log(`
|
|
989
|
-
log(`
|
|
1076
|
+
error(`Host '${host}' does not provide a CI workflow.`)
|
|
1077
|
+
log(`Hosts that do: ${ciHosts.map(a => a.name).join(', ')}`)
|
|
1078
|
+
log(`Others are dashboard-driven — connect the repo in the host's UI instead.`)
|
|
990
1079
|
process.exit(1)
|
|
991
1080
|
}
|
|
992
1081
|
|
|
1082
|
+
// Guard the explicit-host case. Without this, `--host netlify --target
|
|
1083
|
+
// foundation` would call an initCi that ignores `target` and silently
|
|
1084
|
+
// scaffold a SITE workflow — the wrong artifact, with no error.
|
|
1085
|
+
if (target === 'foundation' && adapter.display?.foundationCi !== true) {
|
|
1086
|
+
error(`Host '${host}' cannot publish a foundation.`)
|
|
1087
|
+
log(`Hosts that can: ${ciHosts.map(a => a.name).join(', ')}`)
|
|
1088
|
+
log('')
|
|
1089
|
+
log('Foundation publishing needs permanent versioned URLs, which the')
|
|
1090
|
+
log('gh-pages branch layout provides. Other hosts overwrite on deploy.')
|
|
1091
|
+
process.exit(1)
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
if (target === 'foundation') {
|
|
1095
|
+
return addFoundationCi(rootDir, { ...opts, host }, adapter, pm)
|
|
1096
|
+
}
|
|
1097
|
+
|
|
993
1098
|
// Validate --domain (lightweight: must look like a hostname). The
|
|
994
1099
|
// adapter decides what to do with it; today only github-pages uses
|
|
995
1100
|
// it (writes a CNAME, switches UNIWEB_BASE to root).
|
|
@@ -1048,7 +1153,11 @@ async function addCi(rootDir, opts, pm = 'pnpm') {
|
|
|
1048
1153
|
const rootPkg = JSON.parse(
|
|
1049
1154
|
await readFile(join(rootDir, 'package.json'), 'utf-8').catch(() => '{}')
|
|
1050
1155
|
)
|
|
1051
|
-
|
|
1156
|
+
// CI must run the project's own toolchain. The pnpm major comes from
|
|
1157
|
+
// its `packageManager` field when declared; the node major is the
|
|
1158
|
+
// project's floor, raised if that pnpm needs more.
|
|
1159
|
+
const pnpmVersion = resolveCiPnpmVersion(rootPkg)
|
|
1160
|
+
const nodeVersion = resolveCiNodeVersion(rootPkg.engines?.node, pm, pnpmVersion)
|
|
1052
1161
|
|
|
1053
1162
|
const siteDir = join(rootDir, site.path)
|
|
1054
1163
|
if (!resolvedDomain) {
|
|
@@ -1067,25 +1176,16 @@ async function addCi(rootDir, opts, pm = 'pnpm') {
|
|
|
1067
1176
|
const result = await adapter.initCi({
|
|
1068
1177
|
rootDir,
|
|
1069
1178
|
site,
|
|
1179
|
+
target: 'site',
|
|
1070
1180
|
packageManager: pm,
|
|
1071
1181
|
nodeVersion,
|
|
1072
|
-
pnpmVersion
|
|
1182
|
+
pnpmVersion,
|
|
1073
1183
|
domain: resolvedDomain,
|
|
1184
|
+
previews: opts.previews !== false,
|
|
1185
|
+
projectName: resolveHostProjectName(rootDir, rootPkg, site, sites.length, opts),
|
|
1074
1186
|
})
|
|
1075
1187
|
|
|
1076
|
-
|
|
1077
|
-
// doesn't silently clobber edits the user made to the workflow.
|
|
1078
|
-
for (const file of result.files) {
|
|
1079
|
-
const fullPath = join(rootDir, file.path)
|
|
1080
|
-
if (existsSync(fullPath) && !opts.force) {
|
|
1081
|
-
error(`File already exists: ${file.path}`)
|
|
1082
|
-
log(`Re-run with --force to overwrite.`)
|
|
1083
|
-
process.exit(1)
|
|
1084
|
-
}
|
|
1085
|
-
await mkdir(join(fullPath, '..'), { recursive: true })
|
|
1086
|
-
await writeFile(fullPath, file.content)
|
|
1087
|
-
success(`Wrote ${file.path}`)
|
|
1088
|
-
}
|
|
1188
|
+
await writeCiFiles(rootDir, result.files, opts.force)
|
|
1089
1189
|
|
|
1090
1190
|
// Persist the adapter's target config into deploy.yml so the user's
|
|
1091
1191
|
// intent (host + adapter-specific fields like `domain`) is remembered
|
|
@@ -1124,6 +1224,151 @@ async function addCi(rootDir, opts, pm = 'pnpm') {
|
|
|
1124
1224
|
}
|
|
1125
1225
|
}
|
|
1126
1226
|
|
|
1227
|
+
/**
|
|
1228
|
+
* The name to register this site under on the host (Cloudflare Pages
|
|
1229
|
+
* project, etc.).
|
|
1230
|
+
*
|
|
1231
|
+
* The site package is very often called literally `site` — the scaffold's
|
|
1232
|
+
* default — which makes a terrible project name on a shared host. So:
|
|
1233
|
+
* an explicit `--project-name` wins; otherwise prefer the workspace's own
|
|
1234
|
+
* name (usually the repo name) when the site's name is one of the generic
|
|
1235
|
+
* scaffold defaults; suffix with the site name only when the workspace
|
|
1236
|
+
* holds more than one site and they'd otherwise collide.
|
|
1237
|
+
*/
|
|
1238
|
+
const GENERIC_SITE_NAMES = new Set(['site', 'sites', 'www', 'web', 'app'])
|
|
1239
|
+
|
|
1240
|
+
/**
|
|
1241
|
+
* Public URL segment for a foundation published to GitHub Pages.
|
|
1242
|
+
*
|
|
1243
|
+
* Same problem as the site project name, and it matters more here:
|
|
1244
|
+
* this segment is baked into a PERMANENT url that consuming sites pin
|
|
1245
|
+
* (`foundations/<name>/<version>/entry.js`), so `src` — the single-project
|
|
1246
|
+
* scaffold's directory *and* package name — would be a poor forever-name.
|
|
1247
|
+
* Prefer a meaningful package/directory name; fall back to the workspace
|
|
1248
|
+
* name; disambiguate only when several foundations would collide.
|
|
1249
|
+
*/
|
|
1250
|
+
const GENERIC_FOUNDATION_NAMES = new Set(['src', 'foundation', 'foundations', 'lib'])
|
|
1251
|
+
|
|
1252
|
+
function resolveFoundationPublicNames(rootDir, rootPkg, foundations) {
|
|
1253
|
+
const workspaceName = stripScope(rootPkg?.name) || basename(rootDir)
|
|
1254
|
+
|
|
1255
|
+
return foundations.map((f, i) => {
|
|
1256
|
+
const pkgName = stripScope(f.name)
|
|
1257
|
+
const dirName = basename(f.path)
|
|
1258
|
+
const candidate = !GENERIC_FOUNDATION_NAMES.has(pkgName)
|
|
1259
|
+
? pkgName
|
|
1260
|
+
: !GENERIC_FOUNDATION_NAMES.has(dirName)
|
|
1261
|
+
? dirName
|
|
1262
|
+
: foundations.length > 1
|
|
1263
|
+
? `${workspaceName}-${i + 1}`
|
|
1264
|
+
: workspaceName
|
|
1265
|
+
return { name: candidate, path: f.path }
|
|
1266
|
+
})
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
function resolveHostProjectName(rootDir, rootPkg, site, siteCount, opts) {
|
|
1270
|
+
if (opts.projectName) return opts.projectName
|
|
1271
|
+
|
|
1272
|
+
const workspaceName = stripScope(rootPkg?.name) || basename(rootDir)
|
|
1273
|
+
const siteName = stripScope(site.name)
|
|
1274
|
+
|
|
1275
|
+
if (!GENERIC_SITE_NAMES.has(siteName)) return siteName
|
|
1276
|
+
if (siteCount > 1) return `${workspaceName}-${siteName}`
|
|
1277
|
+
return workspaceName
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
function stripScope(name) {
|
|
1281
|
+
if (!name || typeof name !== 'string') return null
|
|
1282
|
+
return name.startsWith('@') ? name.split('/').pop() : name
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
/**
|
|
1286
|
+
* Write scaffolded CI files. Refuses to overwrite without --force so
|
|
1287
|
+
* re-running doesn't silently clobber edits the user made to a workflow.
|
|
1288
|
+
*/
|
|
1289
|
+
async function writeCiFiles(rootDir, files, force) {
|
|
1290
|
+
for (const file of files) {
|
|
1291
|
+
const fullPath = join(rootDir, file.path)
|
|
1292
|
+
if (existsSync(fullPath) && !force) {
|
|
1293
|
+
error(`File already exists: ${file.path}`)
|
|
1294
|
+
log(`Re-run with --force to overwrite.`)
|
|
1295
|
+
process.exit(1)
|
|
1296
|
+
}
|
|
1297
|
+
await mkdir(join(fullPath, '..'), { recursive: true })
|
|
1298
|
+
await writeFile(fullPath, file.content)
|
|
1299
|
+
success(`Wrote ${file.path}`)
|
|
1300
|
+
}
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1303
|
+
/**
|
|
1304
|
+
* `uniweb add ci --target foundation` — scaffold the workflow that
|
|
1305
|
+
* publishes built foundations at permanent versioned URLs.
|
|
1306
|
+
*
|
|
1307
|
+
* This is the free distribution path for a foundation product: sites
|
|
1308
|
+
* reference `https://<user>.github.io/<repo>/foundations/<name>/<ver>/entry.js`
|
|
1309
|
+
* from site.yml. No catalog, no propagation, no license gating — but no
|
|
1310
|
+
* cost either, and the URLs never break.
|
|
1311
|
+
*
|
|
1312
|
+
* Unlike the site path there is no deploy.yml to write: deploy.yml
|
|
1313
|
+
* records where a *site* is deployed, and a foundation isn't a site.
|
|
1314
|
+
*/
|
|
1315
|
+
async function addFoundationCi(rootDir, opts, adapter, pm) {
|
|
1316
|
+
const all = await discoverFoundations(rootDir)
|
|
1317
|
+
if (all.length === 0) {
|
|
1318
|
+
error('No foundation found in this workspace.')
|
|
1319
|
+
log('Add one with `uniweb add foundation` first.')
|
|
1320
|
+
process.exit(1)
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1323
|
+
// --foundation <name> narrows to one; otherwise publish them all,
|
|
1324
|
+
// which is what a multi-foundation repo almost always wants (each gets
|
|
1325
|
+
// its own versioned directory, so there's no collision).
|
|
1326
|
+
let foundations = all
|
|
1327
|
+
if (opts.foundation) {
|
|
1328
|
+
const match = all.find(f => f.name === opts.foundation || basename(f.path) === opts.foundation)
|
|
1329
|
+
if (!match) {
|
|
1330
|
+
error(`Foundation '${opts.foundation}' not found.`)
|
|
1331
|
+
log(`Available: ${all.map(f => f.name).join(', ')}`)
|
|
1332
|
+
process.exit(1)
|
|
1333
|
+
}
|
|
1334
|
+
foundations = [match]
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1337
|
+
const rootPkg = JSON.parse(
|
|
1338
|
+
await readFile(join(rootDir, 'package.json'), 'utf-8').catch(() => '{}')
|
|
1339
|
+
)
|
|
1340
|
+
// CI must run the project's own toolchain. The pnpm major comes from
|
|
1341
|
+
// its `packageManager` field when declared; the node major is the
|
|
1342
|
+
// project's floor, raised if that pnpm needs more.
|
|
1343
|
+
const pnpmVersion = resolveCiPnpmVersion(rootPkg)
|
|
1344
|
+
const nodeVersion = resolveCiNodeVersion(rootPkg.engines?.node, pm, pnpmVersion)
|
|
1345
|
+
|
|
1346
|
+
let result
|
|
1347
|
+
try {
|
|
1348
|
+
result = await adapter.initCi({
|
|
1349
|
+
rootDir,
|
|
1350
|
+
foundations: resolveFoundationPublicNames(rootDir, rootPkg, foundations),
|
|
1351
|
+
target: 'foundation',
|
|
1352
|
+
packageManager: pm,
|
|
1353
|
+
nodeVersion,
|
|
1354
|
+
pnpmVersion,
|
|
1355
|
+
})
|
|
1356
|
+
} catch (err) {
|
|
1357
|
+
error(err.message)
|
|
1358
|
+
process.exit(1)
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1361
|
+
await writeCiFiles(rootDir, result.files, opts.force)
|
|
1362
|
+
|
|
1363
|
+
if (result.postInstructions?.length) {
|
|
1364
|
+
log('')
|
|
1365
|
+
log(`${colors.bright}Next steps:${colors.reset}`)
|
|
1366
|
+
for (const line of result.postInstructions) {
|
|
1367
|
+
log(line ? ` ${line}` : '')
|
|
1368
|
+
}
|
|
1369
|
+
}
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1127
1372
|
function isLikelyDomain(value) {
|
|
1128
1373
|
if (typeof value !== 'string' || value.length === 0 || value.length > 253) return false
|
|
1129
1374
|
// Reject schemes, paths, ports, whitespace, leading/trailing dots/hyphens.
|
|
@@ -1173,10 +1418,21 @@ ${colors.bright}Section Options:${colors.reset}
|
|
|
1173
1418
|
--foundation <n> Foundation to add section to (prompted if multiple exist)
|
|
1174
1419
|
|
|
1175
1420
|
${colors.bright}CI Options:${colors.reset}
|
|
1176
|
-
--host <name>
|
|
1177
|
-
|
|
1178
|
-
--
|
|
1179
|
-
|
|
1421
|
+
--host <name> github-pages | cloudflare-pages | netlify | vercel
|
|
1422
|
+
(prompted when omitted)
|
|
1423
|
+
--target <what> site (default) | foundation
|
|
1424
|
+
'foundation' publishes built foundations at permanent
|
|
1425
|
+
versioned URLs — the free alternative to the catalog
|
|
1426
|
+
--site <name> Site the workflow builds (prompted if multiple exist)
|
|
1427
|
+
--foundation <name> With --target foundation: publish just this one
|
|
1428
|
+
--domain <host> Custom domain (GitHub Pages: writes CNAME, serves at root)
|
|
1429
|
+
--project-name <name> Name to register under on the host (default: the
|
|
1430
|
+
workspace name; Cloudflare Pages project, etc.)
|
|
1431
|
+
--no-previews Skip the per-PR preview workflow
|
|
1432
|
+
--force Overwrite an existing workflow file
|
|
1433
|
+
|
|
1434
|
+
${colors.dim}Hosts that support PR previews: cloudflare-pages, netlify, vercel.
|
|
1435
|
+
GitHub Pages has no preview environment, so it scaffolds a deploy workflow only.${colors.reset}
|
|
1180
1436
|
|
|
1181
1437
|
${colors.bright}Examples:${colors.reset}
|
|
1182
1438
|
uniweb add project docs # Create docs/foundation/ + docs/site/
|
|
@@ -1190,8 +1446,11 @@ ${colors.bright}Examples:${colors.reset}
|
|
|
1190
1446
|
uniweb add section Hero --foundation ui # Target specific foundation
|
|
1191
1447
|
uniweb add foundation --project docs # Create ./docs/foundation/ (co-located)
|
|
1192
1448
|
uniweb add site --project docs # Create ./docs/site/ (co-located)
|
|
1193
|
-
uniweb add ci #
|
|
1449
|
+
uniweb add ci # Pick a host, add a deploy workflow
|
|
1194
1450
|
uniweb add ci --host github-pages --site marketing # Pick host + site explicitly
|
|
1451
|
+
uniweb add ci --host netlify # Deploy + PR-preview workflows
|
|
1452
|
+
uniweb add ci --host vercel --no-previews # Deploy workflow only
|
|
1195
1453
|
uniweb add ci --domain mysite.com # Custom domain → writes CNAME + UNIWEB_BASE=/
|
|
1454
|
+
uniweb add ci --target foundation # Publish foundations at versioned URLs
|
|
1196
1455
|
`)
|
|
1197
1456
|
}
|
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-21T20:48:30.091Z",
|
|
4
4
|
"packages": {
|
|
5
5
|
"@uniweb/build": {
|
|
6
|
-
"version": "0.14.
|
|
6
|
+
"version": "0.14.30",
|
|
7
7
|
"path": "framework/build",
|
|
8
8
|
"deps": [
|
|
9
9
|
"@uniweb/content-reader",
|
|
@@ -99,7 +99,7 @@
|
|
|
99
99
|
"deps": []
|
|
100
100
|
},
|
|
101
101
|
"@uniweb/unipress": {
|
|
102
|
-
"version": "0.4.
|
|
102
|
+
"version": "0.4.37",
|
|
103
103
|
"path": "framework/unipress",
|
|
104
104
|
"deps": [
|
|
105
105
|
"@uniweb/build",
|
package/src/index.js
CHANGED
|
@@ -1092,40 +1092,42 @@ function printCommandHelp(command) {
|
|
|
1092
1092
|
if (command === 'release') command = 'register'
|
|
1093
1093
|
const blocks = {
|
|
1094
1094
|
deploy: `
|
|
1095
|
-
${colors.cyan}${colors.bright}uniweb deploy${colors.reset} ${colors.dim}— Ship a site to
|
|
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
|
}
|
package/src/versions.js
CHANGED
|
@@ -53,16 +53,86 @@ let resolvedVersions = null
|
|
|
53
53
|
export const REACT_VERSION = '^19.0.0'
|
|
54
54
|
|
|
55
55
|
/**
|
|
56
|
-
*
|
|
56
|
+
* Default pnpm major for generated CI, used only when the project does not
|
|
57
|
+
* say which pnpm it uses (see `resolveCiPnpmVersion`).
|
|
57
58
|
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
59
|
+
* **This is deliberately 10, not the newest release.** CI must run the same
|
|
60
|
+
* toolchain the developer runs, or it reports failures that have nothing to
|
|
61
|
+
* do with their code. Pinning 11 while projects were on 10 produced exactly
|
|
62
|
+
* that, twice over, on a real repo:
|
|
63
|
+
*
|
|
64
|
+
* - pnpm 11 refuses any dependency published in the last 24 hours
|
|
65
|
+
* (`ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION`). Publish a package and its
|
|
66
|
+
* consumer's CI fails until the next day.
|
|
67
|
+
* - pnpm 11 changed build-script approval: `onlyBuiltDependencies` in
|
|
68
|
+
* `pnpm-workspace.yaml` no longer suffices, and the install exits 1 with
|
|
69
|
+
* `ERR_PNPM_IGNORED_BUILDS`. Uniweb sites use `sharp` for image
|
|
70
|
+
* processing, so this breaks essentially every site's first CI run.
|
|
71
|
+
*
|
|
72
|
+
* Neither is a bug in pnpm — both are reasonable hardening. They are simply
|
|
73
|
+
* not things a generated workflow should impose on a project that has not
|
|
74
|
+
* opted into that major.
|
|
75
|
+
*
|
|
76
|
+
* A bare major installs the latest patch of that major at CI run time. Before
|
|
77
|
+
* bumping this, check the new major's release notes for install-time policy
|
|
78
|
+
* changes, and update `PNPM_MIN_NODE` in the same commit.
|
|
64
79
|
*/
|
|
65
|
-
export const PNPM_VERSION = '
|
|
80
|
+
export const PNPM_VERSION = '10'
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Minimum Node major each supported pnpm major will run on, from that
|
|
84
|
+
* release's own `engines.node`.
|
|
85
|
+
*
|
|
86
|
+
* pnpm 11 declares `>=22.13` and imports `node:sqlite`, which does not exist
|
|
87
|
+
* before Node 22 — pairing it with Node 20 fails at `pnpm install` with
|
|
88
|
+
* `ERR_UNKNOWN_BUILTIN_MODULE` before the build is attempted. That was the
|
|
89
|
+
* default until 2026-07-21, because the CI node major came from the
|
|
90
|
+
* project's `engines.node` and the workspace template declares `>=20.19`.
|
|
91
|
+
*
|
|
92
|
+
* Verify with `npm view pnpm@<major> engines` when adding an entry.
|
|
93
|
+
*/
|
|
94
|
+
export const PNPM_MIN_NODE = { '10': 18, '11': 22 }
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Resolve the pnpm major a generated CI workflow should install.
|
|
98
|
+
*
|
|
99
|
+
* `packageManager` (the corepack field) is authoritative when present — it is
|
|
100
|
+
* the project stating which pnpm it is developed and locked against, so CI
|
|
101
|
+
* should honour it rather than impose a different major.
|
|
102
|
+
*
|
|
103
|
+
* `pnpm-lock.yaml`'s `lockfileVersion` is NOT usable as a signal: pnpm 10 and
|
|
104
|
+
* 11 both write `9.0`, so it cannot distinguish them. Checked, not assumed.
|
|
105
|
+
*
|
|
106
|
+
* @param {object|null} rootPkg — the workspace root's parsed package.json.
|
|
107
|
+
* @returns {string} pnpm major, as a string for YAML interpolation.
|
|
108
|
+
*/
|
|
109
|
+
export function resolveCiPnpmVersion(rootPkg) {
|
|
110
|
+
const declared = rootPkg?.packageManager
|
|
111
|
+
const match = typeof declared === 'string' ? declared.match(/^pnpm@(\d+)/) : null
|
|
112
|
+
return match ? match[1] : PNPM_VERSION
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Resolve the Node major a generated CI workflow should install.
|
|
117
|
+
*
|
|
118
|
+
* Respects the project's own floor (`engines.node`) but never drops below
|
|
119
|
+
* what the pinned package manager needs. The project's declared minimum is a
|
|
120
|
+
* *lower* bound on what its code needs, not a ceiling — running CI on a newer
|
|
121
|
+
* Node is fine; running it on one the package manager refuses to start on is
|
|
122
|
+
* not.
|
|
123
|
+
*
|
|
124
|
+
* @param {string|null|undefined} enginesNode — the project's `engines.node`.
|
|
125
|
+
* @param {'pnpm'|'npm'|'yarn'} packageManager
|
|
126
|
+
* @param {string} [pnpmVersion] — the resolved pnpm major, when pnpm.
|
|
127
|
+
* @param {string} [fallback='20'] — used when engines.node is absent/unparseable.
|
|
128
|
+
* @returns {string} Node major, as a string for YAML interpolation.
|
|
129
|
+
*/
|
|
130
|
+
export function resolveCiNodeVersion(enginesNode, packageManager, pnpmVersion = PNPM_VERSION, fallback = '20') {
|
|
131
|
+
const match = enginesNode ? String(enginesNode).match(/(\d+)/) : null
|
|
132
|
+
const declared = match ? Number(match[1]) : Number(fallback)
|
|
133
|
+
const floor = packageManager === 'pnpm' ? (PNPM_MIN_NODE[pnpmVersion] ?? 0) : 0
|
|
134
|
+
return String(Math.max(declared, floor))
|
|
135
|
+
}
|
|
66
136
|
|
|
67
137
|
/**
|
|
68
138
|
* Get the CLI's own package.json
|