@uniweb/build 0.14.28 → 0.14.29
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 +3 -3
- package/src/hosts/ci-workflow.js +136 -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
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Netlify host adapter
|
|
3
|
+
*
|
|
4
|
+
* Shares Cloudflare Pages' `_redirects` emission — the format originated
|
|
5
|
+
* at Netlify and the two hosts read it identically — but owns its own
|
|
6
|
+
* deploy and CI scaffolding, which is why this is a canonical adapter
|
|
7
|
+
* rather than the alias it used to be. (The registry header states the
|
|
8
|
+
* rule: adapters that need to *behave* differently per name become
|
|
9
|
+
* canonical entries, not aliases. `netlify deploy` and `wrangler pages
|
|
10
|
+
* deploy` are different tools with different auth.)
|
|
11
|
+
*
|
|
12
|
+
* deploy: drives the `netlify` CLI with `--json` so the deploy URL comes
|
|
13
|
+
* back structured instead of scraped from human output.
|
|
14
|
+
*
|
|
15
|
+
* initCi: emits a GitHub Actions workflow (push → production deploy) and,
|
|
16
|
+
* opt-in, a pull-request preview workflow using Netlify deploy aliases.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { DeployError, spawnTool, readCredential, credentialHint } from './deploy-utils.js'
|
|
20
|
+
import { emitRedirectsFile } from './cloudflare-pages.js'
|
|
21
|
+
import {
|
|
22
|
+
setupSteps,
|
|
23
|
+
uniwebBuildCommand,
|
|
24
|
+
pushTrigger,
|
|
25
|
+
prCommentStep,
|
|
26
|
+
workflowHeader,
|
|
27
|
+
} from './ci-workflow.js'
|
|
28
|
+
|
|
29
|
+
const NETLIFY_INSTALL = [
|
|
30
|
+
'Install the Netlify CLI:',
|
|
31
|
+
' npm install -g netlify-cli',
|
|
32
|
+
' # or run it without installing: npx netlify-cli …',
|
|
33
|
+
'',
|
|
34
|
+
'Then authenticate with `netlify login`, or set NETLIFY_AUTH_TOKEN.',
|
|
35
|
+
].join('\n')
|
|
36
|
+
|
|
37
|
+
function translateNetlifyError(code, stderr) {
|
|
38
|
+
const out = stderr.trim()
|
|
39
|
+
|
|
40
|
+
if (/Not authorized|401|invalid token|Access Denied/i.test(out)) {
|
|
41
|
+
return new DeployError(
|
|
42
|
+
'Netlify rejected the credentials.',
|
|
43
|
+
{
|
|
44
|
+
hint: credentialHint({
|
|
45
|
+
what: 'a Netlify personal access token',
|
|
46
|
+
envVars: ['NETLIFY_AUTH_TOKEN'],
|
|
47
|
+
docsUrl: 'Create one at https://app.netlify.com/user/applications#personal-access-tokens',
|
|
48
|
+
}),
|
|
49
|
+
}
|
|
50
|
+
)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (/site not found|Site not found|404/i.test(out)) {
|
|
54
|
+
return new DeployError(
|
|
55
|
+
'That Netlify site does not exist (or the token cannot see it).',
|
|
56
|
+
{
|
|
57
|
+
hint: [
|
|
58
|
+
'Create it once, or link an existing one:',
|
|
59
|
+
' netlify sites:create --name my-site',
|
|
60
|
+
' # or, from the site directory: netlify link',
|
|
61
|
+
'',
|
|
62
|
+
'Then set `siteId` on the target in deploy.yml, or export NETLIFY_SITE_ID.',
|
|
63
|
+
].join('\n'),
|
|
64
|
+
}
|
|
65
|
+
)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return null
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Parse `netlify deploy --json` output. The CLI prints a single JSON
|
|
73
|
+
* object; older versions prefix it with progress noise, so scan for the
|
|
74
|
+
* first `{`. Best-effort — a parse miss only costs us the echoed URL.
|
|
75
|
+
*/
|
|
76
|
+
export function parseNetlifyJson(stdout) {
|
|
77
|
+
const start = stdout.indexOf('{')
|
|
78
|
+
if (start === -1) return null
|
|
79
|
+
try {
|
|
80
|
+
return JSON.parse(stdout.slice(start))
|
|
81
|
+
} catch {
|
|
82
|
+
return null
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function deploy({ distDir, deployConfig = {}, env = process.env, log = () => {} }) {
|
|
87
|
+
const siteId = readCredential(deployConfig, env, 'siteId', 'NETLIFY_SITE_ID')
|
|
88
|
+
if (!siteId) {
|
|
89
|
+
throw new DeployError(
|
|
90
|
+
'Netlify needs a site id.',
|
|
91
|
+
{
|
|
92
|
+
hint: [
|
|
93
|
+
'Add it to the target in deploy.yml:',
|
|
94
|
+
'',
|
|
95
|
+
' targets:',
|
|
96
|
+
' production:',
|
|
97
|
+
' host: netlify',
|
|
98
|
+
' siteId: 1a2b3c4d-…. # Site settings → General → Site ID',
|
|
99
|
+
'',
|
|
100
|
+
'Or export NETLIFY_SITE_ID. Create a site with `netlify sites:create`.',
|
|
101
|
+
].join('\n'),
|
|
102
|
+
}
|
|
103
|
+
)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const subprocessEnv = { ...env }
|
|
107
|
+
if (!env.NETLIFY_AUTH_TOKEN) {
|
|
108
|
+
log(' No NETLIFY_AUTH_TOKEN set — falling back to your `netlify login` session.')
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// --alias produces a named preview deploy; without it (and with --prod)
|
|
112
|
+
// this publishes to the site's production URL.
|
|
113
|
+
const isPreview = !!deployConfig.alias
|
|
114
|
+
const args = ['deploy', `--dir=${distDir}`, `--site=${siteId}`, '--json']
|
|
115
|
+
if (isPreview) {
|
|
116
|
+
args.push(`--alias=${deployConfig.alias}`)
|
|
117
|
+
} else {
|
|
118
|
+
args.push('--prod')
|
|
119
|
+
}
|
|
120
|
+
if (deployConfig.message) args.push(`--message=${deployConfig.message}`)
|
|
121
|
+
|
|
122
|
+
log(`\n→ Deploying to Netlify site ${siteId}${isPreview ? ` (preview: ${deployConfig.alias})` : ''}`)
|
|
123
|
+
// --json means stdout is a machine payload; don't echo it as progress.
|
|
124
|
+
const { stdout } = await spawnTool('netlify', args, {
|
|
125
|
+
env: subprocessEnv,
|
|
126
|
+
log,
|
|
127
|
+
install: NETLIFY_INSTALL,
|
|
128
|
+
translate: translateNetlifyError,
|
|
129
|
+
quiet: true,
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
const result = parseNetlifyJson(stdout)
|
|
133
|
+
const url = result?.deploy_url || result?.url || null
|
|
134
|
+
log('\n✓ Deploy complete.')
|
|
135
|
+
if (url) log(` ${url}`)
|
|
136
|
+
return { url }
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async function initCi({
|
|
140
|
+
site,
|
|
141
|
+
packageManager = 'pnpm',
|
|
142
|
+
nodeVersion = '20',
|
|
143
|
+
pnpmVersion = '11',
|
|
144
|
+
domain = null,
|
|
145
|
+
previews = true,
|
|
146
|
+
}) {
|
|
147
|
+
const sitePath = site.path
|
|
148
|
+
const build = uniwebBuildCommand({ packageManager, host: 'netlify' })
|
|
149
|
+
const setup = setupSteps({ packageManager, nodeVersion, pnpmVersion })
|
|
150
|
+
|
|
151
|
+
const files = [{
|
|
152
|
+
path: '.github/workflows/deploy-netlify.yml',
|
|
153
|
+
content: `${workflowHeader({
|
|
154
|
+
title: 'Deploy to Netlify',
|
|
155
|
+
command: 'uniweb add ci --host=netlify',
|
|
156
|
+
notes: [
|
|
157
|
+
'Requires two repository secrets (Settings → Secrets and variables → Actions):',
|
|
158
|
+
' NETLIFY_AUTH_TOKEN — personal access token',
|
|
159
|
+
' NETLIFY_SITE_ID — Site settings → General → Site ID',
|
|
160
|
+
],
|
|
161
|
+
})}
|
|
162
|
+
|
|
163
|
+
name: Deploy to Netlify
|
|
164
|
+
|
|
165
|
+
${pushTrigger()}
|
|
166
|
+
|
|
167
|
+
concurrency:
|
|
168
|
+
group: netlify-production
|
|
169
|
+
cancel-in-progress: true
|
|
170
|
+
|
|
171
|
+
jobs:
|
|
172
|
+
build-deploy:
|
|
173
|
+
runs-on: ubuntu-latest
|
|
174
|
+
env:
|
|
175
|
+
NETLIFY_AUTH_TOKEN: \${{ secrets.NETLIFY_AUTH_TOKEN }}
|
|
176
|
+
NETLIFY_SITE_ID: \${{ secrets.NETLIFY_SITE_ID }}
|
|
177
|
+
steps:
|
|
178
|
+
${setup}
|
|
179
|
+
- run: ${build}
|
|
180
|
+
working-directory: ${sitePath}
|
|
181
|
+
- name: Publish to Netlify
|
|
182
|
+
run: npx netlify-cli deploy --prod --dir=${sitePath}/dist --message="\${{ github.event.head_commit.message }}"
|
|
183
|
+
`,
|
|
184
|
+
}]
|
|
185
|
+
|
|
186
|
+
if (previews) {
|
|
187
|
+
files.push({
|
|
188
|
+
path: '.github/workflows/preview-netlify.yml',
|
|
189
|
+
content: `${workflowHeader({
|
|
190
|
+
title: 'Preview deploy for pull requests — Netlify',
|
|
191
|
+
command: 'uniweb add ci --host=netlify',
|
|
192
|
+
notes: [
|
|
193
|
+
'Each PR deploys to a named alias (pr-<number>) and the URL is',
|
|
194
|
+
'posted as a comment on the PR.',
|
|
195
|
+
'',
|
|
196
|
+
'No teardown job: Netlify keeps deploys immutable by design and',
|
|
197
|
+
'expires aliases with the site\'s retention policy. Deleting them',
|
|
198
|
+
'per-PR would fight the platform rather than help.',
|
|
199
|
+
],
|
|
200
|
+
})}
|
|
201
|
+
|
|
202
|
+
name: Preview (Netlify)
|
|
203
|
+
|
|
204
|
+
on:
|
|
205
|
+
pull_request:
|
|
206
|
+
types: [opened, synchronize, reopened]
|
|
207
|
+
|
|
208
|
+
permissions:
|
|
209
|
+
contents: read
|
|
210
|
+
pull-requests: write
|
|
211
|
+
|
|
212
|
+
concurrency:
|
|
213
|
+
group: netlify-preview-\${{ github.event.pull_request.number }}
|
|
214
|
+
cancel-in-progress: true
|
|
215
|
+
|
|
216
|
+
jobs:
|
|
217
|
+
preview:
|
|
218
|
+
runs-on: ubuntu-latest
|
|
219
|
+
env:
|
|
220
|
+
NETLIFY_AUTH_TOKEN: \${{ secrets.NETLIFY_AUTH_TOKEN }}
|
|
221
|
+
NETLIFY_SITE_ID: \${{ secrets.NETLIFY_SITE_ID }}
|
|
222
|
+
steps:
|
|
223
|
+
${setup}
|
|
224
|
+
- run: ${build}
|
|
225
|
+
working-directory: ${sitePath}
|
|
226
|
+
- name: Publish preview
|
|
227
|
+
id: publish
|
|
228
|
+
run: |
|
|
229
|
+
URL=$(npx netlify-cli deploy \\
|
|
230
|
+
--dir=${sitePath}/dist \\
|
|
231
|
+
--alias=pr-\${{ github.event.pull_request.number }} \\
|
|
232
|
+
--json | jq -r '.deploy_url')
|
|
233
|
+
echo "url=$URL" >> "$GITHUB_OUTPUT"
|
|
234
|
+
${prCommentStep({
|
|
235
|
+
urlExpression: '${{ steps.publish.outputs.url }}',
|
|
236
|
+
hostLabel: 'Netlify',
|
|
237
|
+
})}
|
|
238
|
+
`,
|
|
239
|
+
})
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const targetConfig = { host: 'netlify' }
|
|
243
|
+
if (domain) targetConfig.domain = domain
|
|
244
|
+
|
|
245
|
+
const postInstructions = [
|
|
246
|
+
'Create the site once: `netlify sites:create --name <name>` (or link an existing one).',
|
|
247
|
+
'Add two repository secrets under Settings → Secrets and variables → Actions:',
|
|
248
|
+
' NETLIFY_AUTH_TOKEN (https://app.netlify.com/user/applications)',
|
|
249
|
+
' NETLIFY_SITE_ID (Site settings → General → Site ID)',
|
|
250
|
+
'Commit and push the workflow — the deploy runs on every push to the default branch.',
|
|
251
|
+
]
|
|
252
|
+
if (previews) {
|
|
253
|
+
postInstructions.push('Pull requests deploy to a pr-<number> alias, commented on the PR.')
|
|
254
|
+
}
|
|
255
|
+
if (domain) {
|
|
256
|
+
postInstructions.push(`Attach ${domain} under Domain management in the Netlify dashboard.`)
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
return { files, postInstructions, targetConfig }
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const adapter = {
|
|
263
|
+
name: 'netlify',
|
|
264
|
+
display: {
|
|
265
|
+
order: 30,
|
|
266
|
+
pushWith: 'the netlify CLI',
|
|
267
|
+
title: 'Netlify',
|
|
268
|
+
qualifier: 'free, CI on push',
|
|
269
|
+
summary: 'Static hosting with deploy previews. Set up a workflow, or upload from here with the netlify CLI.',
|
|
270
|
+
ci: true,
|
|
271
|
+
previews: true,
|
|
272
|
+
},
|
|
273
|
+
async postBuild({ distDir, localeConfigs, onProgress }) {
|
|
274
|
+
// Same _redirects contract as Cloudflare Pages.
|
|
275
|
+
await emitRedirectsFile(distDir, localeConfigs, onProgress)
|
|
276
|
+
},
|
|
277
|
+
deploy,
|
|
278
|
+
initCi,
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
export default adapter
|
|
@@ -30,6 +30,8 @@ import { existsSync } from 'node:fs'
|
|
|
30
30
|
import { join } from 'node:path'
|
|
31
31
|
import { spawn } from 'node:child_process'
|
|
32
32
|
|
|
33
|
+
import { DeployError } from './deploy-utils.js'
|
|
34
|
+
|
|
33
35
|
const FUNCTION_SOURCE = `// CloudFront Function — viewer-request — directory-index resolution.
|
|
34
36
|
//
|
|
35
37
|
// Attach to the default cache behavior of your CloudFront distribution.
|
|
@@ -183,13 +185,9 @@ async function augmentManifest(distDir, deployConfig) {
|
|
|
183
185
|
* Deploy hook *
|
|
184
186
|
* ------------------------------------------------------------------ */
|
|
185
187
|
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
this.name = 'DeployError'
|
|
190
|
-
this.hint = hint
|
|
191
|
-
}
|
|
192
|
-
}
|
|
188
|
+
// DeployError now lives in deploy-utils.js — every adapter that shells
|
|
189
|
+
// out to a CLI needs the same shape. Re-exported at the bottom of this
|
|
190
|
+
// file so existing importers (tests) keep resolving it here.
|
|
193
191
|
|
|
194
192
|
/**
|
|
195
193
|
* Run an `aws` subcommand with stdout piped to the user. stderr is
|
|
@@ -502,6 +500,15 @@ async function deploy({ distDir, deployConfig = {}, env = process.env, log = ()
|
|
|
502
500
|
|
|
503
501
|
const adapter = {
|
|
504
502
|
name: 's3-cloudfront',
|
|
503
|
+
display: {
|
|
504
|
+
order: 50,
|
|
505
|
+
pushWith: 'the aws CLI',
|
|
506
|
+
title: 'S3 + CloudFront',
|
|
507
|
+
qualifier: 'your AWS account',
|
|
508
|
+
summary: 'Builds and uploads to your own bucket, then invalidates the CDN. Needs the aws CLI and a provisioned distribution.',
|
|
509
|
+
ci: false,
|
|
510
|
+
previews: false,
|
|
511
|
+
},
|
|
505
512
|
postBuild,
|
|
506
513
|
deploy,
|
|
507
514
|
}
|
package/src/hosts/vercel.js
CHANGED
|
@@ -1,17 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Vercel host adapter
|
|
3
3
|
*
|
|
4
|
-
* Vercel auto-resolves directory-index requests
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* to drop — postBuild is intentionally empty.
|
|
8
|
-
*
|
|
9
|
-
* Lifecycle: Git-driven. The user sets up Vercel's GitHub integration,
|
|
10
|
-
* configures `npx uniweb build` as the build command, and pushes; Vercel
|
|
11
|
-
* runs the build and serves the result. `uniweb deploy --host=vercel`
|
|
12
|
-
* is intentionally not supported — there is no CLI-push path. See the
|
|
13
|
-
* deploy command's "host adapter does not implement a deploy step" error
|
|
14
|
-
* for the user-facing message when someone tries.
|
|
4
|
+
* Vercel auto-resolves directory-index requests and serves whatever lands
|
|
5
|
+
* in the output directory. The framework has no helper files to drop —
|
|
6
|
+
* postBuild is intentionally empty.
|
|
15
7
|
*
|
|
16
8
|
* `vercel.json` emission is not done by default. Most Vercel projects
|
|
17
9
|
* don't need one (the defaults already handle directory-index, SPA
|
|
@@ -19,18 +11,254 @@
|
|
|
19
11
|
* their own `vercel.json` next to `site.yml` and the build leaves it
|
|
20
12
|
* alone.
|
|
21
13
|
*
|
|
14
|
+
* Two lifecycles are supported, and they're genuinely different:
|
|
15
|
+
* - Git-driven — connect the repo in Vercel's dashboard and let it
|
|
16
|
+
* build. Nothing to scaffold.
|
|
17
|
+
* - CLI-push — `uniweb deploy --host=vercel` uploads an already-built
|
|
18
|
+
* `dist/` via the `vercel` CLI. This is the path that lets a build
|
|
19
|
+
* run on your machine (or in a workflow you control) rather than in
|
|
20
|
+
* Vercel's builder.
|
|
21
|
+
*
|
|
22
22
|
* Registered as its own canonical adapter (not an alias of
|
|
23
23
|
* generic-static) so the deploy manifest, dry-run output, and
|
|
24
24
|
* deploy.yml's `host:` field record `vercel` literally — readers should
|
|
25
25
|
* see what the user picked, not the canonical implementation behind it.
|
|
26
26
|
*/
|
|
27
27
|
|
|
28
|
+
import { DeployError, spawnTool, readCredential, credentialHint } from './deploy-utils.js'
|
|
29
|
+
import {
|
|
30
|
+
setupSteps,
|
|
31
|
+
uniwebBuildCommand,
|
|
32
|
+
pushTrigger,
|
|
33
|
+
prCommentStep,
|
|
34
|
+
workflowHeader,
|
|
35
|
+
} from './ci-workflow.js'
|
|
36
|
+
|
|
37
|
+
const VERCEL_INSTALL = [
|
|
38
|
+
'Install the Vercel CLI:',
|
|
39
|
+
' npm install -g vercel',
|
|
40
|
+
' # or run it without installing: npx vercel …',
|
|
41
|
+
'',
|
|
42
|
+
'Then authenticate with `vercel login`, or set VERCEL_TOKEN.',
|
|
43
|
+
].join('\n')
|
|
44
|
+
|
|
45
|
+
function translateVercelError(code, stderr) {
|
|
46
|
+
const out = stderr.trim()
|
|
47
|
+
|
|
48
|
+
if (/not authorized|Invalid token|forbidden|401/i.test(out)) {
|
|
49
|
+
return new DeployError(
|
|
50
|
+
'Vercel rejected the credentials.',
|
|
51
|
+
{
|
|
52
|
+
hint: credentialHint({
|
|
53
|
+
what: 'a Vercel access token',
|
|
54
|
+
envVars: ['VERCEL_TOKEN'],
|
|
55
|
+
docsUrl: 'Create one at https://vercel.com/account/tokens',
|
|
56
|
+
}),
|
|
57
|
+
}
|
|
58
|
+
)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (/Project not found|project does not exist/i.test(out)) {
|
|
62
|
+
return new DeployError(
|
|
63
|
+
'That Vercel project does not exist (or the token cannot see it).',
|
|
64
|
+
{
|
|
65
|
+
hint: [
|
|
66
|
+
'Link the directory to a project once:',
|
|
67
|
+
' vercel link',
|
|
68
|
+
'',
|
|
69
|
+
'In CI, set VERCEL_ORG_ID and VERCEL_PROJECT_ID instead — both are',
|
|
70
|
+
'written to .vercel/project.json by `vercel link`.',
|
|
71
|
+
].join('\n'),
|
|
72
|
+
}
|
|
73
|
+
)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return null
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Last https:// URL printed on stdout is the deployment URL. */
|
|
80
|
+
export function extractVercelUrl(stdout) {
|
|
81
|
+
const matches = stdout.match(/https:\/\/\S+\.vercel\.app\S*/g)
|
|
82
|
+
if (!matches || !matches.length) return null
|
|
83
|
+
return matches[matches.length - 1].replace(/[.,)]+$/, '')
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function deploy({ distDir, deployConfig = {}, env = process.env, log = () => {} }) {
|
|
87
|
+
const token = readCredential(deployConfig, env, null, 'VERCEL_TOKEN')
|
|
88
|
+
const orgId = readCredential(deployConfig, env, 'orgId', 'VERCEL_ORG_ID')
|
|
89
|
+
const projectId = readCredential(deployConfig, env, 'projectId', 'VERCEL_PROJECT_ID')
|
|
90
|
+
|
|
91
|
+
const subprocessEnv = { ...env }
|
|
92
|
+
if (orgId) subprocessEnv.VERCEL_ORG_ID = orgId
|
|
93
|
+
if (projectId) subprocessEnv.VERCEL_PROJECT_ID = projectId
|
|
94
|
+
if (!token && !env.VERCEL_TOKEN) {
|
|
95
|
+
log(' No VERCEL_TOKEN set — falling back to your `vercel login` session.')
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Deploying a plain directory of static files: Vercel treats it as a
|
|
99
|
+
// static deploy with no build step, which is exactly right — `uniweb
|
|
100
|
+
// build` already produced the artifact.
|
|
101
|
+
const isPreview = deployConfig.preview === true
|
|
102
|
+
const args = ['deploy', distDir, '--yes']
|
|
103
|
+
if (!isPreview) args.push('--prod')
|
|
104
|
+
if (token) args.push(`--token=${token}`)
|
|
105
|
+
if (deployConfig.scope) args.push(`--scope=${deployConfig.scope}`)
|
|
106
|
+
|
|
107
|
+
log(`\n→ Deploying to Vercel${isPreview ? ' (preview)' : ''}`)
|
|
108
|
+
const { stdout } = await spawnTool('vercel', args, {
|
|
109
|
+
env: subprocessEnv,
|
|
110
|
+
log,
|
|
111
|
+
install: VERCEL_INSTALL,
|
|
112
|
+
translate: translateVercelError,
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
const url = extractVercelUrl(stdout)
|
|
116
|
+
log('\n✓ Deploy complete.')
|
|
117
|
+
if (url) log(` ${url}`)
|
|
118
|
+
return { url }
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function initCi({
|
|
122
|
+
site,
|
|
123
|
+
packageManager = 'pnpm',
|
|
124
|
+
nodeVersion = '20',
|
|
125
|
+
pnpmVersion = '11',
|
|
126
|
+
domain = null,
|
|
127
|
+
previews = true,
|
|
128
|
+
}) {
|
|
129
|
+
const sitePath = site.path
|
|
130
|
+
const build = uniwebBuildCommand({ packageManager, host: 'vercel' })
|
|
131
|
+
const setup = setupSteps({ packageManager, nodeVersion, pnpmVersion })
|
|
132
|
+
|
|
133
|
+
const files = [{
|
|
134
|
+
path: '.github/workflows/deploy-vercel.yml',
|
|
135
|
+
content: `${workflowHeader({
|
|
136
|
+
title: 'Deploy to Vercel',
|
|
137
|
+
command: 'uniweb add ci --host=vercel',
|
|
138
|
+
notes: [
|
|
139
|
+
'Requires three repository secrets (Settings → Secrets and variables → Actions):',
|
|
140
|
+
' VERCEL_TOKEN — https://vercel.com/account/tokens',
|
|
141
|
+
' VERCEL_ORG_ID — from .vercel/project.json after `vercel link`',
|
|
142
|
+
' VERCEL_PROJECT_ID — same file',
|
|
143
|
+
'',
|
|
144
|
+
'Only needed if you want the build to run here rather than in',
|
|
145
|
+
'Vercel\'s builder. Connecting the repo in the Vercel dashboard is',
|
|
146
|
+
'the zero-config alternative — in that case delete this workflow.',
|
|
147
|
+
],
|
|
148
|
+
})}
|
|
149
|
+
|
|
150
|
+
name: Deploy to Vercel
|
|
151
|
+
|
|
152
|
+
${pushTrigger()}
|
|
153
|
+
|
|
154
|
+
concurrency:
|
|
155
|
+
group: vercel-production
|
|
156
|
+
cancel-in-progress: true
|
|
157
|
+
|
|
158
|
+
jobs:
|
|
159
|
+
build-deploy:
|
|
160
|
+
runs-on: ubuntu-latest
|
|
161
|
+
env:
|
|
162
|
+
VERCEL_ORG_ID: \${{ secrets.VERCEL_ORG_ID }}
|
|
163
|
+
VERCEL_PROJECT_ID: \${{ secrets.VERCEL_PROJECT_ID }}
|
|
164
|
+
steps:
|
|
165
|
+
${setup}
|
|
166
|
+
- run: ${build}
|
|
167
|
+
working-directory: ${sitePath}
|
|
168
|
+
- name: Publish to Vercel
|
|
169
|
+
run: npx vercel deploy ${sitePath}/dist --prod --yes --token=\${{ secrets.VERCEL_TOKEN }}
|
|
170
|
+
`,
|
|
171
|
+
}]
|
|
172
|
+
|
|
173
|
+
if (previews) {
|
|
174
|
+
files.push({
|
|
175
|
+
path: '.github/workflows/preview-vercel.yml',
|
|
176
|
+
content: `${workflowHeader({
|
|
177
|
+
title: 'Preview deploy for pull requests — Vercel',
|
|
178
|
+
command: 'uniweb add ci --host=vercel',
|
|
179
|
+
notes: [
|
|
180
|
+
'Each PR gets a Vercel preview deployment, posted as a comment.',
|
|
181
|
+
'',
|
|
182
|
+
'No teardown job: Vercel manages preview-deployment retention',
|
|
183
|
+
'itself. If the repo is connected to Vercel through the dashboard,',
|
|
184
|
+
'previews already happen natively — delete this workflow instead of',
|
|
185
|
+
'running both.',
|
|
186
|
+
],
|
|
187
|
+
})}
|
|
188
|
+
|
|
189
|
+
name: Preview (Vercel)
|
|
190
|
+
|
|
191
|
+
on:
|
|
192
|
+
pull_request:
|
|
193
|
+
types: [opened, synchronize, reopened]
|
|
194
|
+
|
|
195
|
+
permissions:
|
|
196
|
+
contents: read
|
|
197
|
+
pull-requests: write
|
|
198
|
+
|
|
199
|
+
concurrency:
|
|
200
|
+
group: vercel-preview-\${{ github.event.pull_request.number }}
|
|
201
|
+
cancel-in-progress: true
|
|
202
|
+
|
|
203
|
+
jobs:
|
|
204
|
+
preview:
|
|
205
|
+
runs-on: ubuntu-latest
|
|
206
|
+
env:
|
|
207
|
+
VERCEL_ORG_ID: \${{ secrets.VERCEL_ORG_ID }}
|
|
208
|
+
VERCEL_PROJECT_ID: \${{ secrets.VERCEL_PROJECT_ID }}
|
|
209
|
+
steps:
|
|
210
|
+
${setup}
|
|
211
|
+
- run: ${build}
|
|
212
|
+
working-directory: ${sitePath}
|
|
213
|
+
- name: Publish preview
|
|
214
|
+
id: publish
|
|
215
|
+
run: |
|
|
216
|
+
URL=$(npx vercel deploy ${sitePath}/dist --yes --token=\${{ secrets.VERCEL_TOKEN }})
|
|
217
|
+
echo "url=$URL" >> "$GITHUB_OUTPUT"
|
|
218
|
+
${prCommentStep({
|
|
219
|
+
urlExpression: '${{ steps.publish.outputs.url }}',
|
|
220
|
+
hostLabel: 'Vercel',
|
|
221
|
+
})}
|
|
222
|
+
`,
|
|
223
|
+
})
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const targetConfig = { host: 'vercel' }
|
|
227
|
+
if (domain) targetConfig.domain = domain
|
|
228
|
+
|
|
229
|
+
const postInstructions = [
|
|
230
|
+
'Link the project once: `vercel link` (writes .vercel/project.json).',
|
|
231
|
+
'Add three repository secrets under Settings → Secrets and variables → Actions:',
|
|
232
|
+
' VERCEL_TOKEN, VERCEL_ORG_ID, VERCEL_PROJECT_ID',
|
|
233
|
+
'Commit and push the workflow — the deploy runs on every push to the default branch.',
|
|
234
|
+
'',
|
|
235
|
+
'Alternative: connect the repo in the Vercel dashboard and delete these',
|
|
236
|
+
'workflows. Vercel then builds and previews natively with no secrets.',
|
|
237
|
+
]
|
|
238
|
+
if (domain) {
|
|
239
|
+
postInstructions.push(`Attach ${domain} under the project's Domains tab.`)
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
return { files, postInstructions, targetConfig }
|
|
243
|
+
}
|
|
244
|
+
|
|
28
245
|
const adapter = {
|
|
29
246
|
name: 'vercel',
|
|
247
|
+
display: {
|
|
248
|
+
order: 40,
|
|
249
|
+
pushWith: 'the vercel CLI',
|
|
250
|
+
title: 'Vercel',
|
|
251
|
+
qualifier: 'free tier, CI on push',
|
|
252
|
+
summary: 'Static hosting with native preview deployments. Connect the repo in Vercel\'s dashboard, or upload from here.',
|
|
253
|
+
ci: true,
|
|
254
|
+
previews: true,
|
|
255
|
+
},
|
|
30
256
|
async postBuild() {
|
|
31
257
|
// Intentionally empty. Vercel's defaults handle directory-index,
|
|
32
258
|
// SPA fallback, and asset caching without per-site config.
|
|
33
259
|
},
|
|
260
|
+
deploy,
|
|
261
|
+
initCi,
|
|
34
262
|
}
|
|
35
263
|
|
|
36
264
|
export default adapter
|