@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniweb/build",
3
- "version": "0.14.28",
3
+ "version": "0.14.29",
4
4
  "description": "Build tooling for the Uniweb Component Web Platform",
5
5
  "type": "module",
6
6
  "exports": {
@@ -59,8 +59,8 @@
59
59
  "js-yaml": "^4.1.0",
60
60
  "sharp": "^0.33.2",
61
61
  "yaml": "^2.5.0",
62
- "@uniweb/theming": "0.1.8",
63
- "@uniweb/content-writer": "0.2.6"
62
+ "@uniweb/content-writer": "0.2.6",
63
+ "@uniweb/theming": "0.1.8"
64
64
  },
65
65
  "optionalDependencies": {
66
66
  "@uniweb/runtime": "0.8.26",
@@ -0,0 +1,136 @@
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
+ if (packageManager === 'pnpm') {
35
+ lines.push(' - uses: pnpm/action-setup@v4')
36
+ lines.push(' with:')
37
+ lines.push(` version: ${pnpmVersion}`)
38
+ lines.push(' - uses: actions/setup-node@v4')
39
+ lines.push(' with:')
40
+ lines.push(` node-version: '${nodeVersion}'`)
41
+ lines.push(' cache: pnpm')
42
+ lines.push(' - run: pnpm install --frozen-lockfile')
43
+ } else {
44
+ lines.push(' - uses: actions/setup-node@v4')
45
+ lines.push(' with:')
46
+ lines.push(` node-version: '${nodeVersion}'`)
47
+ lines.push(' cache: npm')
48
+ lines.push(' - run: npm ci')
49
+ }
50
+ return lines.join('\n')
51
+ }
52
+
53
+ /**
54
+ * The `uniweb build` invocation for a package manager + host.
55
+ *
56
+ * @param {object} opts
57
+ * @param {'pnpm'|'npm'} [opts.packageManager]
58
+ * @param {string} opts.host — Adapter name passed to --host.
59
+ * @returns {string}
60
+ */
61
+ export function uniwebBuildCommand({ packageManager = 'pnpm', host }) {
62
+ const runner = packageManager === 'pnpm' ? 'pnpm exec' : 'npx'
63
+ return `${runner} uniweb build --host=${host}`
64
+ }
65
+
66
+ /**
67
+ * The push trigger. Both default-branch names are listed so the workflow
68
+ * fires whether the repo uses 'main' or 'master' — GHA only triggers on a
69
+ * branch that exists, so the unused name is a harmless no-op.
70
+ *
71
+ * @returns {string} YAML fragment for the `on:` block's push trigger.
72
+ */
73
+ export function pushTrigger() {
74
+ return `on:
75
+ push:
76
+ # Both names are listed so the workflow fires whether the repo uses
77
+ # 'main' (GitHub's current default) or 'master'. GHA only triggers on
78
+ # a branch that exists, so the unused name is a harmless no-op. Users
79
+ # on a different default (trunk, develop) edit this list directly.
80
+ branches: [main, master]
81
+ workflow_dispatch:`
82
+ }
83
+
84
+ /**
85
+ * A step that comments the deploy URL on the pull request, replacing its
86
+ * own previous comment rather than stacking one per push.
87
+ *
88
+ * Uses `peter-evans/create-or-update-comment` via a find step, which
89
+ * needs no extra token beyond the default GITHUB_TOKEN with
90
+ * `pull-requests: write`.
91
+ *
92
+ * @param {object} opts
93
+ * @param {string} opts.urlExpression — GHA expression yielding the URL.
94
+ * @param {string} opts.hostLabel — Human host name for the comment body.
95
+ * @returns {string} YAML fragment.
96
+ */
97
+ export function prCommentStep({ urlExpression, hostLabel }) {
98
+ return ` - name: Find previous preview comment
99
+ uses: peter-evans/find-comment@v3
100
+ id: fc
101
+ with:
102
+ issue-number: \${{ github.event.pull_request.number }}
103
+ comment-author: 'github-actions[bot]'
104
+ body-includes: '<!-- uniweb-preview -->'
105
+ - name: Comment the preview URL
106
+ uses: peter-evans/create-or-update-comment@v4
107
+ with:
108
+ comment-id: \${{ steps.fc.outputs.comment-id }}
109
+ issue-number: \${{ github.event.pull_request.number }}
110
+ edit-mode: replace
111
+ body: |
112
+ <!-- uniweb-preview -->
113
+ **Preview ready** — ${hostLabel}
114
+
115
+ ${urlExpression}
116
+
117
+ Built from \`\${{ github.event.pull_request.head.sha }}\`.`
118
+ }
119
+
120
+ /**
121
+ * Header comment block for a generated workflow.
122
+ *
123
+ * @param {object} opts
124
+ * @param {string} opts.title
125
+ * @param {string} opts.command — The `uniweb add ci …` line that made it.
126
+ * @param {string[]} [opts.notes] — Extra lines, each prefixed with '# '.
127
+ * @returns {string}
128
+ */
129
+ export function workflowHeader({ title, command, notes = [] }) {
130
+ const lines = [`# ${title}`, `# Generated by \`${command}\`. Safe to edit.`]
131
+ if (notes.length) {
132
+ lines.push('#')
133
+ for (const n of notes) lines.push(n ? `# ${n}` : '#')
134
+ }
135
+ return lines.join('\n')
136
+ }
@@ -1,10 +1,13 @@
1
1
  /**
2
2
  * Cloudflare Pages host adapter
3
3
  *
4
- * Emits a `_redirects` file describing redirect/rewrite directives the
5
- * host evaluates at request time. This is the format Cloudflare Pages
6
- * uses and is also accepted unchanged by Netlify (the format originated
7
- * there and the two hosts are compatible at this layer).
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
  },