@uniweb/build 0.14.28 → 0.14.30
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/package.json +1 -1
- package/src/hosts/ci-workflow.js +151 -0
- package/src/hosts/cloudflare-pages.js +265 -4
- package/src/hosts/deploy-utils.js +151 -0
- package/src/hosts/generic-static.js +12 -0
- package/src/hosts/github-pages.js +389 -39
- package/src/hosts/index.js +15 -6
- package/src/hosts/netlify.js +281 -0
- package/src/hosts/s3-cloudfront.js +14 -7
- package/src/hosts/vercel.js +239 -11
package/package.json
CHANGED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared GitHub Actions scaffolding for host adapters' `initCi` hooks.
|
|
3
|
+
*
|
|
4
|
+
* Every scaffolded workflow does the same four things before it diverges:
|
|
5
|
+
* check out, set up the package manager, install, run `uniweb build
|
|
6
|
+
* --host=<name>`. Only the publish step is host-specific. These helpers
|
|
7
|
+
* own the shared prefix so each adapter's file stays readable and a fix
|
|
8
|
+
* to (say) the pnpm setup lands everywhere at once.
|
|
9
|
+
*
|
|
10
|
+
* Everything here returns YAML *fragments* at a known indentation (6
|
|
11
|
+
* spaces — the depth of a step under `jobs.<id>.steps`). Adapters compose
|
|
12
|
+
* them into a full document with a template literal.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Checkout + package-manager setup + install, as workflow steps.
|
|
17
|
+
*
|
|
18
|
+
* @param {object} opts
|
|
19
|
+
* @param {'pnpm'|'npm'} [opts.packageManager]
|
|
20
|
+
* @param {string} [opts.nodeVersion]
|
|
21
|
+
* @param {string} [opts.pnpmVersion]
|
|
22
|
+
* @param {boolean} [opts.checkout] — Include the checkout step (default true).
|
|
23
|
+
* @returns {string} YAML fragment, no trailing newline.
|
|
24
|
+
*/
|
|
25
|
+
export function setupSteps({
|
|
26
|
+
packageManager = 'pnpm',
|
|
27
|
+
nodeVersion = '20',
|
|
28
|
+
pnpmVersion = '11',
|
|
29
|
+
checkout = true,
|
|
30
|
+
} = {}) {
|
|
31
|
+
const lines = []
|
|
32
|
+
if (checkout) lines.push(' - uses: actions/checkout@v4')
|
|
33
|
+
|
|
34
|
+
// The install command has to match the repo's lockfile: `npm ci` fails
|
|
35
|
+
// outright without package-lock.json, and so does
|
|
36
|
+
// `pnpm install --frozen-lockfile` without pnpm-lock.yaml. Callers must
|
|
37
|
+
// pass the WORKSPACE's package manager (detected from its lockfile),
|
|
38
|
+
// not the one that happened to launch the CLI.
|
|
39
|
+
if (packageManager === 'pnpm') {
|
|
40
|
+
lines.push(' - uses: pnpm/action-setup@v4')
|
|
41
|
+
lines.push(' with:')
|
|
42
|
+
lines.push(` version: ${pnpmVersion}`)
|
|
43
|
+
lines.push(' - uses: actions/setup-node@v4')
|
|
44
|
+
lines.push(' with:')
|
|
45
|
+
lines.push(` node-version: '${nodeVersion}'`)
|
|
46
|
+
lines.push(' cache: pnpm')
|
|
47
|
+
lines.push(' - run: pnpm install --frozen-lockfile')
|
|
48
|
+
} else if (packageManager === 'yarn') {
|
|
49
|
+
lines.push(' - uses: actions/setup-node@v4')
|
|
50
|
+
lines.push(' with:')
|
|
51
|
+
lines.push(` node-version: '${nodeVersion}'`)
|
|
52
|
+
lines.push(' cache: yarn')
|
|
53
|
+
// Accepted by Yarn 1 and aliased to --immutable by Berry.
|
|
54
|
+
lines.push(' - run: yarn install --frozen-lockfile')
|
|
55
|
+
} else {
|
|
56
|
+
lines.push(' - uses: actions/setup-node@v4')
|
|
57
|
+
lines.push(' with:')
|
|
58
|
+
lines.push(` node-version: '${nodeVersion}'`)
|
|
59
|
+
lines.push(' cache: npm')
|
|
60
|
+
lines.push(' - run: npm ci')
|
|
61
|
+
}
|
|
62
|
+
return lines.join('\n')
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The `uniweb build` invocation for a package manager + host.
|
|
67
|
+
*
|
|
68
|
+
* @param {object} opts
|
|
69
|
+
* @param {'pnpm'|'npm'} [opts.packageManager]
|
|
70
|
+
* @param {string} opts.host — Adapter name passed to --host.
|
|
71
|
+
* @returns {string}
|
|
72
|
+
*/
|
|
73
|
+
export function uniwebBuildCommand({ packageManager = 'pnpm', host }) {
|
|
74
|
+
const runner =
|
|
75
|
+
packageManager === 'pnpm' ? 'pnpm exec'
|
|
76
|
+
: packageManager === 'yarn' ? 'yarn exec'
|
|
77
|
+
: 'npx'
|
|
78
|
+
return `${runner} uniweb build --host=${host}`
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The push trigger. Both default-branch names are listed so the workflow
|
|
83
|
+
* fires whether the repo uses 'main' or 'master' — GHA only triggers on a
|
|
84
|
+
* branch that exists, so the unused name is a harmless no-op.
|
|
85
|
+
*
|
|
86
|
+
* @returns {string} YAML fragment for the `on:` block's push trigger.
|
|
87
|
+
*/
|
|
88
|
+
export function pushTrigger() {
|
|
89
|
+
return `on:
|
|
90
|
+
push:
|
|
91
|
+
# Both names are listed so the workflow fires whether the repo uses
|
|
92
|
+
# 'main' (GitHub's current default) or 'master'. GHA only triggers on
|
|
93
|
+
# a branch that exists, so the unused name is a harmless no-op. Users
|
|
94
|
+
# on a different default (trunk, develop) edit this list directly.
|
|
95
|
+
branches: [main, master]
|
|
96
|
+
workflow_dispatch:`
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* A step that comments the deploy URL on the pull request, replacing its
|
|
101
|
+
* own previous comment rather than stacking one per push.
|
|
102
|
+
*
|
|
103
|
+
* Uses `peter-evans/create-or-update-comment` via a find step, which
|
|
104
|
+
* needs no extra token beyond the default GITHUB_TOKEN with
|
|
105
|
+
* `pull-requests: write`.
|
|
106
|
+
*
|
|
107
|
+
* @param {object} opts
|
|
108
|
+
* @param {string} opts.urlExpression — GHA expression yielding the URL.
|
|
109
|
+
* @param {string} opts.hostLabel — Human host name for the comment body.
|
|
110
|
+
* @returns {string} YAML fragment.
|
|
111
|
+
*/
|
|
112
|
+
export function prCommentStep({ urlExpression, hostLabel }) {
|
|
113
|
+
return ` - name: Find previous preview comment
|
|
114
|
+
uses: peter-evans/find-comment@v3
|
|
115
|
+
id: fc
|
|
116
|
+
with:
|
|
117
|
+
issue-number: \${{ github.event.pull_request.number }}
|
|
118
|
+
comment-author: 'github-actions[bot]'
|
|
119
|
+
body-includes: '<!-- uniweb-preview -->'
|
|
120
|
+
- name: Comment the preview URL
|
|
121
|
+
uses: peter-evans/create-or-update-comment@v4
|
|
122
|
+
with:
|
|
123
|
+
comment-id: \${{ steps.fc.outputs.comment-id }}
|
|
124
|
+
issue-number: \${{ github.event.pull_request.number }}
|
|
125
|
+
edit-mode: replace
|
|
126
|
+
body: |
|
|
127
|
+
<!-- uniweb-preview -->
|
|
128
|
+
**Preview ready** — ${hostLabel}
|
|
129
|
+
|
|
130
|
+
${urlExpression}
|
|
131
|
+
|
|
132
|
+
Built from \`\${{ github.event.pull_request.head.sha }}\`.`
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Header comment block for a generated workflow.
|
|
137
|
+
*
|
|
138
|
+
* @param {object} opts
|
|
139
|
+
* @param {string} opts.title
|
|
140
|
+
* @param {string} opts.command — The `uniweb add ci …` line that made it.
|
|
141
|
+
* @param {string[]} [opts.notes] — Extra lines, each prefixed with '# '.
|
|
142
|
+
* @returns {string}
|
|
143
|
+
*/
|
|
144
|
+
export function workflowHeader({ title, command, notes = [] }) {
|
|
145
|
+
const lines = [`# ${title}`, `# Generated by \`${command}\`. Safe to edit.`]
|
|
146
|
+
if (notes.length) {
|
|
147
|
+
lines.push('#')
|
|
148
|
+
for (const n of notes) lines.push(n ? `# ${n}` : '#')
|
|
149
|
+
}
|
|
150
|
+
return lines.join('\n')
|
|
151
|
+
}
|
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Cloudflare Pages host adapter
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* host evaluates at request time. This is the format
|
|
6
|
-
* uses and is also accepted unchanged by Netlify (the
|
|
7
|
-
* there and the two hosts are compatible at this
|
|
4
|
+
* postBuild: emits a `_redirects` file describing redirect/rewrite
|
|
5
|
+
* directives the host evaluates at request time. This is the format
|
|
6
|
+
* Cloudflare Pages uses and is also accepted unchanged by Netlify (the
|
|
7
|
+
* format originated there and the two hosts are compatible at this
|
|
8
|
+
* layer) — `netlify.js` reuses `emitRedirectsFile` for exactly that
|
|
9
|
+
* reason. The two are separate adapters because they *deploy*
|
|
10
|
+
* differently, which is the line the registry header draws.
|
|
8
11
|
*
|
|
9
12
|
* Format: `source destination status`
|
|
10
13
|
* 302 = redirect (browser URL changes)
|
|
@@ -15,12 +18,36 @@
|
|
|
15
18
|
*
|
|
16
19
|
* This is the framework's historical default postBuild output and remains
|
|
17
20
|
* the default when no `--host` flag is passed to `uniweb build`.
|
|
21
|
+
*
|
|
22
|
+
* deploy: drives `wrangler pages deploy`. Needs a project name (from the
|
|
23
|
+
* deploy.yml target) and a Cloudflare API token + account id from the
|
|
24
|
+
* environment.
|
|
25
|
+
*
|
|
26
|
+
* initCi: emits a GitHub Actions workflow that builds and deploys on
|
|
27
|
+
* every push, plus (opt-in) a pull-request preview workflow.
|
|
18
28
|
*/
|
|
19
29
|
|
|
20
30
|
import { readFile, writeFile } from 'node:fs/promises'
|
|
21
31
|
import { existsSync } from 'node:fs'
|
|
22
32
|
import { join } from 'node:path'
|
|
23
33
|
|
|
34
|
+
import { DeployError, spawnTool, readCredential, credentialHint } from './deploy-utils.js'
|
|
35
|
+
import {
|
|
36
|
+
setupSteps,
|
|
37
|
+
uniwebBuildCommand,
|
|
38
|
+
pushTrigger,
|
|
39
|
+
prCommentStep,
|
|
40
|
+
workflowHeader,
|
|
41
|
+
} from './ci-workflow.js'
|
|
42
|
+
|
|
43
|
+
const WRANGLER_INSTALL = [
|
|
44
|
+
'Install wrangler (Cloudflare\'s CLI):',
|
|
45
|
+
' npm install -g wrangler',
|
|
46
|
+
' # or run it without installing: npx wrangler …',
|
|
47
|
+
'',
|
|
48
|
+
'Then authenticate with `wrangler login`, or set CLOUDFLARE_API_TOKEN.',
|
|
49
|
+
].join('\n')
|
|
50
|
+
|
|
24
51
|
/**
|
|
25
52
|
* Build the `_redirects` body from per-locale page metadata.
|
|
26
53
|
*
|
|
@@ -68,11 +95,245 @@ export async function emitRedirectsFile(distDir, localeConfigs, onProgress = ()
|
|
|
68
95
|
return { written: true, count: entries.length }
|
|
69
96
|
}
|
|
70
97
|
|
|
98
|
+
/**
|
|
99
|
+
* Pull the deployment URL out of wrangler's output. Wrangler prints a
|
|
100
|
+
* line like "✨ Deployment complete! Take a peek over at https://….pages.dev".
|
|
101
|
+
* Best-effort — a miss just means we don't echo the URL.
|
|
102
|
+
*/
|
|
103
|
+
export function extractPagesUrl(stdout) {
|
|
104
|
+
const match = stdout.match(/https:\/\/[a-z0-9-]+\.[a-z0-9-]+\.pages\.dev\S*/i)
|
|
105
|
+
|| stdout.match(/https:\/\/[a-z0-9-]+\.pages\.dev\S*/i)
|
|
106
|
+
return match ? match[0].replace(/[.,)]+$/, '') : null
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function translateWranglerError(code, stderr) {
|
|
110
|
+
const out = stderr.trim()
|
|
111
|
+
|
|
112
|
+
if (/Authentication error|\[code: 10000\]|not authenticated/i.test(out)) {
|
|
113
|
+
return new DeployError(
|
|
114
|
+
'Cloudflare rejected the credentials.',
|
|
115
|
+
{
|
|
116
|
+
hint: credentialHint({
|
|
117
|
+
what: 'a Cloudflare API token with the "Cloudflare Pages — Edit" permission',
|
|
118
|
+
envVars: ['CLOUDFLARE_API_TOKEN', 'CLOUDFLARE_ACCOUNT_ID'],
|
|
119
|
+
docsUrl: 'Create one at https://dash.cloudflare.com/profile/api-tokens',
|
|
120
|
+
}),
|
|
121
|
+
}
|
|
122
|
+
)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (/Project not found|could not find project/i.test(out)) {
|
|
126
|
+
return new DeployError(
|
|
127
|
+
'That Cloudflare Pages project does not exist.',
|
|
128
|
+
{
|
|
129
|
+
hint: [
|
|
130
|
+
'Create it once (either is fine):',
|
|
131
|
+
' wrangler pages project create <name>',
|
|
132
|
+
' # or via the dashboard: Workers & Pages → Create → Pages',
|
|
133
|
+
'',
|
|
134
|
+
'Then set `projectName` on the target in deploy.yml.',
|
|
135
|
+
].join('\n'),
|
|
136
|
+
}
|
|
137
|
+
)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return null
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function deploy({ distDir, deployConfig = {}, env = process.env, log = () => {} }) {
|
|
144
|
+
const projectName = deployConfig.projectName || deployConfig.project
|
|
145
|
+
if (!projectName) {
|
|
146
|
+
throw new DeployError(
|
|
147
|
+
'Cloudflare Pages needs a project name.',
|
|
148
|
+
{
|
|
149
|
+
hint: [
|
|
150
|
+
'Add it to the target in deploy.yml:',
|
|
151
|
+
'',
|
|
152
|
+
' targets:',
|
|
153
|
+
' production:',
|
|
154
|
+
' host: cloudflare-pages',
|
|
155
|
+
' projectName: my-site',
|
|
156
|
+
'',
|
|
157
|
+
'The project must already exist — `wrangler pages project create my-site`.',
|
|
158
|
+
].join('\n'),
|
|
159
|
+
}
|
|
160
|
+
)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const token = readCredential(deployConfig, env, null, 'CLOUDFLARE_API_TOKEN')
|
|
164
|
+
const accountId = readCredential(deployConfig, env, 'accountId', 'CLOUDFLARE_ACCOUNT_ID')
|
|
165
|
+
|
|
166
|
+
// wrangler can also use an interactive `wrangler login` session, so a
|
|
167
|
+
// missing token is not fatal — only warn when neither is present.
|
|
168
|
+
const subprocessEnv = { ...env }
|
|
169
|
+
if (accountId) subprocessEnv.CLOUDFLARE_ACCOUNT_ID = accountId
|
|
170
|
+
if (!token && !env.CLOUDFLARE_API_TOKEN) {
|
|
171
|
+
log(' No CLOUDFLARE_API_TOKEN set — falling back to your `wrangler login` session.')
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const args = ['pages', 'deploy', distDir, `--project-name=${projectName}`]
|
|
175
|
+
if (deployConfig.branch) args.push(`--branch=${deployConfig.branch}`)
|
|
176
|
+
if (deployConfig.commitDirty !== false) args.push('--commit-dirty=true')
|
|
177
|
+
|
|
178
|
+
log(`\n→ Deploying to Cloudflare Pages project '${projectName}'`)
|
|
179
|
+
const { stdout } = await spawnTool('wrangler', args, {
|
|
180
|
+
env: subprocessEnv,
|
|
181
|
+
log,
|
|
182
|
+
install: WRANGLER_INSTALL,
|
|
183
|
+
translate: translateWranglerError,
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
const url = extractPagesUrl(stdout)
|
|
187
|
+
log('\n✓ Deploy complete.')
|
|
188
|
+
if (url) log(` ${url}`)
|
|
189
|
+
return { url }
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Scaffold GitHub Actions workflows for Cloudflare Pages.
|
|
194
|
+
*
|
|
195
|
+
* Cloudflare Pages can also build from a dashboard-connected repo, but a
|
|
196
|
+
* committed workflow is the reproducible option: the build runs with the
|
|
197
|
+
* project's own toolchain versions and the same `uniweb build` invocation
|
|
198
|
+
* a developer runs locally.
|
|
199
|
+
*/
|
|
200
|
+
async function initCi({
|
|
201
|
+
site,
|
|
202
|
+
packageManager = 'pnpm',
|
|
203
|
+
nodeVersion = '20',
|
|
204
|
+
pnpmVersion = '11',
|
|
205
|
+
domain = null,
|
|
206
|
+
projectName = null,
|
|
207
|
+
previews = true,
|
|
208
|
+
}) {
|
|
209
|
+
const sitePath = site.path
|
|
210
|
+
const project = projectName || site.name
|
|
211
|
+
const build = uniwebBuildCommand({ packageManager, host: 'cloudflare-pages' })
|
|
212
|
+
const setup = setupSteps({ packageManager, nodeVersion, pnpmVersion })
|
|
213
|
+
|
|
214
|
+
const files = [{
|
|
215
|
+
path: '.github/workflows/deploy-cloudflare-pages.yml',
|
|
216
|
+
content: `${workflowHeader({
|
|
217
|
+
title: 'Deploy to Cloudflare Pages',
|
|
218
|
+
command: 'uniweb add ci --host=cloudflare-pages',
|
|
219
|
+
notes: [
|
|
220
|
+
'Requires two repository secrets (Settings → Secrets and variables → Actions):',
|
|
221
|
+
' CLOUDFLARE_API_TOKEN — token with the "Cloudflare Pages — Edit" permission',
|
|
222
|
+
' CLOUDFLARE_ACCOUNT_ID — found in the Cloudflare dashboard sidebar',
|
|
223
|
+
],
|
|
224
|
+
})}
|
|
225
|
+
|
|
226
|
+
name: Deploy to Cloudflare Pages
|
|
227
|
+
|
|
228
|
+
${pushTrigger()}
|
|
229
|
+
|
|
230
|
+
concurrency:
|
|
231
|
+
group: cloudflare-pages-production
|
|
232
|
+
cancel-in-progress: true
|
|
233
|
+
|
|
234
|
+
jobs:
|
|
235
|
+
build-deploy:
|
|
236
|
+
runs-on: ubuntu-latest
|
|
237
|
+
steps:
|
|
238
|
+
${setup}
|
|
239
|
+
- run: ${build}
|
|
240
|
+
working-directory: ${sitePath}
|
|
241
|
+
- name: Publish to Cloudflare Pages
|
|
242
|
+
uses: cloudflare/wrangler-action@v3
|
|
243
|
+
with:
|
|
244
|
+
apiToken: \${{ secrets.CLOUDFLARE_API_TOKEN }}
|
|
245
|
+
accountId: \${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
|
246
|
+
command: pages deploy ${sitePath}/dist --project-name=${project} --branch=main
|
|
247
|
+
`,
|
|
248
|
+
}]
|
|
249
|
+
|
|
250
|
+
if (previews) {
|
|
251
|
+
files.push({
|
|
252
|
+
path: '.github/workflows/preview-cloudflare-pages.yml',
|
|
253
|
+
content: `${workflowHeader({
|
|
254
|
+
title: 'Preview deploy for pull requests — Cloudflare Pages',
|
|
255
|
+
command: 'uniweb add ci --host=cloudflare-pages',
|
|
256
|
+
notes: [
|
|
257
|
+
'Each PR gets its own preview URL, posted as a comment on the PR.',
|
|
258
|
+
'',
|
|
259
|
+
'No teardown job: Cloudflare owns preview-deployment lifecycle and',
|
|
260
|
+
'retires them on its own schedule. A delete step here would need to',
|
|
261
|
+
'track deployment ids across runs for no real benefit.',
|
|
262
|
+
],
|
|
263
|
+
})}
|
|
264
|
+
|
|
265
|
+
name: Preview (Cloudflare Pages)
|
|
266
|
+
|
|
267
|
+
on:
|
|
268
|
+
pull_request:
|
|
269
|
+
types: [opened, synchronize, reopened]
|
|
270
|
+
|
|
271
|
+
permissions:
|
|
272
|
+
contents: read
|
|
273
|
+
pull-requests: write
|
|
274
|
+
|
|
275
|
+
concurrency:
|
|
276
|
+
group: cloudflare-pages-preview-\${{ github.event.pull_request.number }}
|
|
277
|
+
cancel-in-progress: true
|
|
278
|
+
|
|
279
|
+
jobs:
|
|
280
|
+
preview:
|
|
281
|
+
runs-on: ubuntu-latest
|
|
282
|
+
steps:
|
|
283
|
+
${setup}
|
|
284
|
+
- run: ${build}
|
|
285
|
+
working-directory: ${sitePath}
|
|
286
|
+
- name: Publish preview
|
|
287
|
+
id: publish
|
|
288
|
+
uses: cloudflare/wrangler-action@v3
|
|
289
|
+
with:
|
|
290
|
+
apiToken: \${{ secrets.CLOUDFLARE_API_TOKEN }}
|
|
291
|
+
accountId: \${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
|
292
|
+
command: pages deploy ${sitePath}/dist --project-name=${project} --branch=pr-\${{ github.event.pull_request.number }}
|
|
293
|
+
${prCommentStep({
|
|
294
|
+
urlExpression: '${{ steps.publish.outputs.deployment-url }}',
|
|
295
|
+
hostLabel: 'Cloudflare Pages',
|
|
296
|
+
})}
|
|
297
|
+
`,
|
|
298
|
+
})
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const targetConfig = { host: 'cloudflare-pages', projectName: project }
|
|
302
|
+
if (domain) targetConfig.domain = domain
|
|
303
|
+
|
|
304
|
+
const postInstructions = [
|
|
305
|
+
`Create the Pages project once: \`wrangler pages project create ${project}\``,
|
|
306
|
+
'Add two repository secrets under Settings → Secrets and variables → Actions:',
|
|
307
|
+
' CLOUDFLARE_API_TOKEN (Cloudflare Pages — Edit)',
|
|
308
|
+
' CLOUDFLARE_ACCOUNT_ID',
|
|
309
|
+
'Commit and push the workflow — the deploy runs on every push to the default branch.',
|
|
310
|
+
]
|
|
311
|
+
if (previews) {
|
|
312
|
+
postInstructions.push('Pull requests get their own preview URL, commented on the PR.')
|
|
313
|
+
}
|
|
314
|
+
if (domain) {
|
|
315
|
+
postInstructions.push(`Attach ${domain} in the Pages project's Custom domains tab.`)
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
return { files, postInstructions, targetConfig }
|
|
319
|
+
}
|
|
320
|
+
|
|
71
321
|
const adapter = {
|
|
72
322
|
name: 'cloudflare-pages',
|
|
323
|
+
display: {
|
|
324
|
+
order: 20,
|
|
325
|
+
pushWith: 'wrangler',
|
|
326
|
+
title: 'Cloudflare Pages',
|
|
327
|
+
qualifier: 'free, CI on push',
|
|
328
|
+
summary: 'Unlimited-bandwidth static hosting on Cloudflare\'s CDN. Set up a workflow, or upload from here with wrangler.',
|
|
329
|
+
ci: true,
|
|
330
|
+
previews: true,
|
|
331
|
+
},
|
|
73
332
|
async postBuild({ distDir, localeConfigs, onProgress }) {
|
|
74
333
|
await emitRedirectsFile(distDir, localeConfigs, onProgress)
|
|
75
334
|
},
|
|
335
|
+
deploy,
|
|
336
|
+
initCi,
|
|
76
337
|
}
|
|
77
338
|
|
|
78
339
|
export default adapter
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared deploy-hook plumbing
|
|
3
|
+
*
|
|
4
|
+
* Every host adapter that ships a `deploy` hook drives a third-party CLI
|
|
5
|
+
* (`aws`, `wrangler`, `netlify`, `vercel`, `git`). They all need the same
|
|
6
|
+
* three things:
|
|
7
|
+
*
|
|
8
|
+
* 1. A structured error the CLI layer can render. `deploy.js` keys off
|
|
9
|
+
* `err.name === 'DeployError'` to print `message` + an indented
|
|
10
|
+
* `hint` block instead of a stack trace, so the class name is part
|
|
11
|
+
* of the contract — don't rename it.
|
|
12
|
+
* 2. ENOENT translated into "here's how to install that tool", which is
|
|
13
|
+
* the single most common failure for a first-time deploy.
|
|
14
|
+
* 3. A non-zero exit translated into something actionable. That part is
|
|
15
|
+
* tool-specific, so `spawnTool` takes an optional `translate`
|
|
16
|
+
* callback and falls back to a generic message carrying stderr.
|
|
17
|
+
*
|
|
18
|
+
* Adapters own their translations; this module owns the mechanics.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { spawn } from 'node:child_process'
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Error shape the CLI renders specially. `hint` is a pre-formatted
|
|
25
|
+
* multi-line block printed verbatim under the message.
|
|
26
|
+
*/
|
|
27
|
+
export class DeployError extends Error {
|
|
28
|
+
constructor(message, { hint } = {}) {
|
|
29
|
+
super(message)
|
|
30
|
+
this.name = 'DeployError'
|
|
31
|
+
this.hint = hint
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Run a CLI tool, streaming stdout to `log` and capturing stderr for
|
|
37
|
+
* error translation.
|
|
38
|
+
*
|
|
39
|
+
* @param {string} cmd — Executable name, resolved on PATH.
|
|
40
|
+
* @param {string[]} args
|
|
41
|
+
* @param {object} opts
|
|
42
|
+
* @param {Record<string,string>} [opts.env]
|
|
43
|
+
* @param {(msg: string) => void} [opts.log]
|
|
44
|
+
* @param {string} [opts.cwd]
|
|
45
|
+
* @param {string} [opts.install] — Install instructions used to build the
|
|
46
|
+
* ENOENT hint. Strongly recommended: a missing tool is the most common
|
|
47
|
+
* first-run failure and a bare "command not found" is a dead end.
|
|
48
|
+
* @param {(code: number, stderr: string) => DeployError|null} [opts.translate]
|
|
49
|
+
* Maps a non-zero exit to a specific DeployError. Return null to fall
|
|
50
|
+
* through to the generic message.
|
|
51
|
+
* @param {boolean} [opts.quiet] — Capture stdout without echoing it.
|
|
52
|
+
* @returns {Promise<{stdout: string, stderr: string}>}
|
|
53
|
+
* @throws {DeployError}
|
|
54
|
+
*/
|
|
55
|
+
export function spawnTool(cmd, args, opts = {}) {
|
|
56
|
+
const { env, log = () => {}, cwd, install, translate, quiet = false } = opts
|
|
57
|
+
|
|
58
|
+
return new Promise((resolve, reject) => {
|
|
59
|
+
const child = spawn(cmd, args, {
|
|
60
|
+
env,
|
|
61
|
+
cwd,
|
|
62
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
let stdout = ''
|
|
66
|
+
let stderr = ''
|
|
67
|
+
|
|
68
|
+
child.stdout.on('data', (chunk) => {
|
|
69
|
+
const s = chunk.toString()
|
|
70
|
+
stdout += s
|
|
71
|
+
if (!quiet) log(s.replace(/\n$/, ''))
|
|
72
|
+
})
|
|
73
|
+
child.stderr.on('data', (chunk) => { stderr += chunk.toString() })
|
|
74
|
+
|
|
75
|
+
child.on('error', (err) => {
|
|
76
|
+
if (err.code === 'ENOENT') {
|
|
77
|
+
reject(new DeployError(
|
|
78
|
+
`\`${cmd}\` is not installed or not on PATH.`,
|
|
79
|
+
{ hint: install || `Install ${cmd} and make sure it's on your PATH, then retry.` }
|
|
80
|
+
))
|
|
81
|
+
return
|
|
82
|
+
}
|
|
83
|
+
reject(err)
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
child.on('close', (code) => {
|
|
87
|
+
if (code === 0) {
|
|
88
|
+
resolve({ stdout, stderr })
|
|
89
|
+
return
|
|
90
|
+
}
|
|
91
|
+
const specific = translate ? translate(code, stderr) : null
|
|
92
|
+
if (specific) {
|
|
93
|
+
reject(specific)
|
|
94
|
+
return
|
|
95
|
+
}
|
|
96
|
+
const tail = stderr.trim() || stdout.trim()
|
|
97
|
+
reject(new DeployError(
|
|
98
|
+
`\`${cmd} ${args[0] ?? ''}\` failed (exit ${code}).`,
|
|
99
|
+
{ hint: tail ? tail.split('\n').slice(-20).join('\n') : undefined }
|
|
100
|
+
))
|
|
101
|
+
})
|
|
102
|
+
})
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Read a credential from the resolved deploy target first, then the
|
|
107
|
+
* environment. Adapters accept tokens either way: `deploy.yml` is
|
|
108
|
+
* committed, so a token belongs in the environment — but the config path
|
|
109
|
+
* exists for values that are merely identifiers (account/site/project
|
|
110
|
+
* ids), which are safe to commit and tedious to re-export per shell.
|
|
111
|
+
*
|
|
112
|
+
* @param {object} deployConfig
|
|
113
|
+
* @param {Record<string,string>} env
|
|
114
|
+
* @param {string} configKey
|
|
115
|
+
* @param {string|string[]} envKeys — Checked in order.
|
|
116
|
+
* @returns {string|undefined}
|
|
117
|
+
*/
|
|
118
|
+
export function readCredential(deployConfig, env, configKey, envKeys) {
|
|
119
|
+
const fromConfig = deployConfig?.[configKey]
|
|
120
|
+
if (fromConfig) return String(fromConfig)
|
|
121
|
+
for (const key of [envKeys].flat()) {
|
|
122
|
+
if (env?.[key]) return env[key]
|
|
123
|
+
}
|
|
124
|
+
return undefined
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Build the "set this and retry" hint for a missing credential. Kept in
|
|
129
|
+
* one place so every adapter phrases it the same way.
|
|
130
|
+
*
|
|
131
|
+
* @param {object} spec
|
|
132
|
+
* @param {string} spec.what — Human name, e.g. 'a Cloudflare API token'.
|
|
133
|
+
* @param {string[]} spec.envVars
|
|
134
|
+
* @param {string} [spec.configKey] — deploy.yml key, when committing the
|
|
135
|
+
* value is safe (ids, not secrets).
|
|
136
|
+
* @param {string} [spec.docsUrl]
|
|
137
|
+
* @returns {string}
|
|
138
|
+
*/
|
|
139
|
+
export function credentialHint({ what, envVars, configKey, docsUrl }) {
|
|
140
|
+
const lines = [`Provide ${what}:`]
|
|
141
|
+
for (const v of envVars) lines.push(` export ${v}=…`)
|
|
142
|
+
if (configKey) {
|
|
143
|
+
lines.push('')
|
|
144
|
+
lines.push(`Or set \`${configKey}\` on the target in deploy.yml (safe to commit — it's an id, not a secret).`)
|
|
145
|
+
}
|
|
146
|
+
if (docsUrl) {
|
|
147
|
+
lines.push('')
|
|
148
|
+
lines.push(docsUrl)
|
|
149
|
+
}
|
|
150
|
+
return lines.join('\n')
|
|
151
|
+
}
|
|
@@ -14,6 +14,18 @@
|
|
|
14
14
|
|
|
15
15
|
const adapter = {
|
|
16
16
|
name: 'generic-static',
|
|
17
|
+
display: {
|
|
18
|
+
order: 90,
|
|
19
|
+
title: 'Generic static host',
|
|
20
|
+
qualifier: 'no helper files',
|
|
21
|
+
summary: 'A plain dist/ with no host-specific output. Pick this for a self-managed nginx, Caddy, or any host that needs nothing extra.',
|
|
22
|
+
ci: false,
|
|
23
|
+
previews: false,
|
|
24
|
+
// Not a destination in its own right — it names an *artifact shape*,
|
|
25
|
+
// which is the `export --host` question, not "where should this go?".
|
|
26
|
+
// The deploy wizard offers "Somewhere else" instead, which exports.
|
|
27
|
+
wizard: false,
|
|
28
|
+
},
|
|
17
29
|
async postBuild() {
|
|
18
30
|
// Intentionally empty.
|
|
19
31
|
},
|