@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.
@@ -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
- class DeployError extends Error {
187
- constructor(message, { hint } = {}) {
188
- super(message)
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
  }
@@ -1,17 +1,9 @@
1
1
  /**
2
2
  * Vercel host adapter
3
3
  *
4
- * Vercel auto-resolves directory-index requests, runs the project's
5
- * configured build command, and serves whatever lands in the configured
6
- * output directory (we use `dist/`). The framework has no helper files
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