@weft-js/cli 0.0.0-stage → 0.1.1

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.
Files changed (91) hide show
  1. package/LICENSE +16 -0
  2. package/README.md +74 -2
  3. package/bin/weft.mjs +8 -0
  4. package/docs/async.md +40 -0
  5. package/docs/browser.md +39 -0
  6. package/docs/config.md +93 -0
  7. package/docs/deploy.md +83 -0
  8. package/docs/design.md +97 -0
  9. package/docs/fonts.md +70 -0
  10. package/docs/icons.md +102 -0
  11. package/docs/lists.md +23 -0
  12. package/docs/meta.md +82 -0
  13. package/docs/plugins.md +143 -0
  14. package/docs/props.md +77 -0
  15. package/docs/rules.md +32 -0
  16. package/docs/rust.md +223 -0
  17. package/docs/start-short.md +8 -0
  18. package/docs/start.md +422 -0
  19. package/docs/state.md +96 -0
  20. package/docs/style.md +59 -0
  21. package/docs/surface.md +111 -0
  22. package/docs/testing.md +71 -0
  23. package/docs/ui-state.md +92 -0
  24. package/docs/verify.md +48 -0
  25. package/package.json +39 -4
  26. package/src/add-rust.mjs +123 -0
  27. package/src/add.mjs +358 -0
  28. package/src/agents-md.mjs +134 -0
  29. package/src/alias.mjs +59 -0
  30. package/src/audit.mjs +366 -0
  31. package/src/browser/cli.mjs +34 -0
  32. package/src/browser/client.mjs +132 -0
  33. package/src/browser/commands.mjs +143 -0
  34. package/src/catalog.mjs +496 -0
  35. package/src/check.mjs +330 -0
  36. package/src/cli.mjs +364 -0
  37. package/src/config.d.ts +1 -0
  38. package/src/config.mjs +5 -0
  39. package/src/contributions.mjs +69 -0
  40. package/src/db-layer.mjs +33 -0
  41. package/src/deploy/cdn.mjs +64 -0
  42. package/src/deploy/files.mjs +187 -0
  43. package/src/deploy/fit.mjs +62 -0
  44. package/src/deploy/hosts.mjs +56 -0
  45. package/src/deploy/index.mjs +273 -0
  46. package/src/deploy/needs.mjs +92 -0
  47. package/src/design-checks.mjs +439 -0
  48. package/src/diag.mjs +74 -0
  49. package/src/docs.mjs +132 -0
  50. package/src/doctor.mjs +30 -0
  51. package/src/files.mjs +47 -0
  52. package/src/fix.mjs +40 -0
  53. package/src/gate.mjs +276 -0
  54. package/src/index.mjs +9 -0
  55. package/src/infer/engine.cjs +525 -0
  56. package/src/infer/facts.cjs +252 -0
  57. package/src/infer/hosts.cjs +114 -0
  58. package/src/infer/lsp.mjs +131 -0
  59. package/src/infer/session.cjs +105 -0
  60. package/src/infer/shadow.cjs +46 -0
  61. package/src/infer/worker.cjs +97 -0
  62. package/src/jsx-scan.mjs +86 -0
  63. package/src/load-config.mjs +303 -0
  64. package/src/map.mjs +110 -0
  65. package/src/mcp.mjs +195 -0
  66. package/src/meta-table.mjs +28 -0
  67. package/src/mutate.mjs +140 -0
  68. package/src/plugin-checks.mjs +89 -0
  69. package/src/plugins.mjs +172 -0
  70. package/src/project.mjs +219 -0
  71. package/src/props-checks.mjs +46 -0
  72. package/src/props-listener.mjs +38 -0
  73. package/src/react-checks.mjs +36 -0
  74. package/src/share-checks.mjs +74 -0
  75. package/src/site.mjs +19 -0
  76. package/src/slots.mjs +96 -0
  77. package/src/start.mjs +296 -0
  78. package/src/state-checks.mjs +213 -0
  79. package/src/style.mjs +320 -0
  80. package/src/surface-checks.mjs +218 -0
  81. package/src/tailwind.mjs +22 -0
  82. package/src/tasks.mjs +99 -0
  83. package/src/testdiff.mjs +157 -0
  84. package/src/tests.mjs +572 -0
  85. package/src/text-checks.mjs +172 -0
  86. package/src/tokens.mjs +24 -0
  87. package/src/trust.mjs +282 -0
  88. package/src/weft.mjs +66 -0
  89. package/src/workspace.mjs +105 -0
  90. package/ts-plugin/index.js +137 -0
  91. package/ts-plugin/package.json +1 -0
@@ -0,0 +1,187 @@
1
+ // The files a host needs, written by `weft deploy init` and compared by `weft check` (research 46 §7). Each text file
2
+ // starts with `# weft deploy init <target>, sha <hash>`, the hash of what follows: a matching hash that differs from what
3
+ // init writes now is stale (E006), and a hash that doesn't match was edited by hand, so weft leaves it alone.
4
+
5
+ import { createHash } from 'node:crypto'
6
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
7
+ import { dirname, join } from 'node:path'
8
+ import { host, presetFor } from './hosts.mjs'
9
+
10
+ /** The lockfile's manager: how the image and the workflow install. */
11
+ const MANAGERS = [
12
+ ['pnpm-lock.yaml', { name: 'pnpm', install: 'corepack enable && pnpm install --frozen-lockfile', x: 'pnpm exec', cache: 'pnpm' }],
13
+ ['yarn.lock', { name: 'yarn', install: 'corepack enable && yarn install --immutable', x: 'yarn', cache: 'yarn' }],
14
+ ['bun.lock', { name: 'bun', install: 'bun install --frozen-lockfile', x: 'bunx', cache: '' }],
15
+ ['package-lock.json', { name: 'npm', install: 'npm ci', x: 'npx', cache: 'npm' }],
16
+ ]
17
+ export const managerOf = root => (MANAGERS.find(([lock]) => existsSync(join(root, lock))) ?? [null, { name: 'npm', install: 'npm install', x: 'npx', cache: '' }])[1]
18
+
19
+ /** Where a server host keeps what must last: SQLite's default `file:.data/app.db` lands on it unchanged. */
20
+ export const DATA = '/app/.data'
21
+
22
+ const sha = body => createHash('sha256').update(body).digest('hex').slice(0, 12)
23
+ const stamp = (target, body) => `# weft deploy init ${target}, sha ${sha(body)}\n${body}`
24
+ const STAMP = /^# weft deploy init (\w+), sha ([0-9a-f]{12})\n/
25
+
26
+ const IMAGES = { node: 'node:26-slim', bun: 'oven/bun:1-slim', deno: 'denoland/deno:latest' }
27
+ const RUN = { node: '["node", ".output/server/index.mjs"]', bun: '["bun", "run", ".output/server/index.mjs"]', deno: '["deno", "run", "-A", ".output/server/index.mjs"]' }
28
+
29
+ /** A two-stage image: build with the lockfile's manager, run the server. A sidecar needs cargo to build and `weft start` to run. */
30
+ function dockerfile({ runtime, manager, needs }) {
31
+ const rust = needs.sidecar || needs.native
32
+ const lines = [
33
+ `FROM ${IMAGES.node} AS build`,
34
+ 'WORKDIR /app',
35
+ ...(rust ? ['RUN apt-get update && apt-get install -y --no-install-recommends curl ca-certificates build-essential && curl -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal', 'ENV PATH=/root/.cargo/bin:$PATH'] : []),
36
+ 'COPY . .',
37
+ `RUN ${manager.install}`,
38
+ `RUN ${manager.x} weft build --preset ${presetFor({}, runtime)}`,
39
+ '',
40
+ `FROM ${needs.sidecar ? IMAGES.node : IMAGES[runtime] ?? IMAGES.node}`,
41
+ 'WORKDIR /app',
42
+ 'ENV NODE_ENV=production HOST=0.0.0.0 PORT=8080',
43
+ // `weft start` runs each sidecar next to the server; without one the server is all there is
44
+ ...(needs.sidecar ? ['COPY --from=build /app ./', 'EXPOSE 8080', `CMD ["${manager.x.split(' ')[0]}", ${manager.x.includes(' ') ? `"${manager.x.split(' ')[1]}", ` : ''}"weft", "start"]`] : ['COPY --from=build /app/.output ./.output', 'EXPOSE 8080', `CMD ${RUN[runtime] ?? RUN.node}`]),
45
+ ]
46
+ return `${lines.join('\n')}\n`
47
+ }
48
+
49
+ const DOCKERIGNORE = 'node_modules\n.output\n.weft/*\n!.weft/plugins.lock.json\n.env\n.env.*\n.git\n.netlify\n.vercel\ntarget\n'
50
+
51
+ function flyToml({ deploy, needs }) {
52
+ const fly = deploy.fly ?? {}
53
+ const held = needs.required || needs.feed || needs.sidecar
54
+ const lines = [
55
+ `app = ${JSON.stringify(fly.app ?? 'my-app')}`,
56
+ `primary_region = ${JSON.stringify(fly.region ?? 'iad')}`,
57
+ '',
58
+ '[env]',
59
+ ' PORT = "8080"',
60
+ ...(needs.uploads && fly.volume ? [` WEFT_MEDIA_DIR = "${fly.volume.path ?? DATA}/media"`] : []),
61
+ '',
62
+ '[http_service]',
63
+ ' internal_port = 8080',
64
+ ' force_https = true',
65
+ ' auto_stop_machines = "stop"',
66
+ ' auto_start_machines = true',
67
+ // a change feed is a listener: a stopped machine hears nothing
68
+ ` min_machines_running = ${held ? 1 : 0}`,
69
+ ...(fly.volume ? ['', '[[mounts]]', ` source = ${JSON.stringify(fly.volume.name ?? 'data')}`, ` destination = ${JSON.stringify(fly.volume.path ?? DATA)}`, ` initial_size = "${fly.volume.sizeGb ?? 1}gb"`] : []),
70
+ '',
71
+ '[[vm]]',
72
+ ` memory = ${JSON.stringify(fly.memory ?? '512mb')}`,
73
+ ]
74
+ return `${lines.join('\n')}\n`
75
+ }
76
+
77
+ function renderYaml({ deploy, needs }) {
78
+ const r = deploy.render ?? {}
79
+ const lines = [
80
+ 'services:',
81
+ ' - type: web',
82
+ ` name: ${r.service ?? 'my-app'}`,
83
+ ' runtime: docker',
84
+ ` plan: ${r.plan ?? (r.disk ? 'starter' : 'free')}`,
85
+ ` region: ${r.region ?? 'oregon'}`,
86
+ // the workflow deploys once `weft check` passes, through the deploy hook
87
+ ' autoDeploy: false',
88
+ ' healthCheckPath: /',
89
+ ' envVars:',
90
+ ' - key: NODE_ENV',
91
+ ' value: production',
92
+ ...(needs.uploads && r.disk ? [' - key: WEFT_MEDIA_DIR', ` value: ${r.disk.path ?? DATA}/media`] : []),
93
+ ...(r.disk ? [' disk:', ' name: data', ` mountPath: ${r.disk.path ?? DATA}`, ` sizeGB: ${r.disk.sizeGb ?? 1}`] : []),
94
+ ]
95
+ return `${lines.join('\n')}\n`
96
+ }
97
+
98
+ const netlifyToml = ({ manager }) => `[build]\n command = "${manager.x} weft build --preset netlify"\n publish = "dist"\n\n[build.environment]\n NODE_VERSION = "26"\n`
99
+
100
+ const vercelJson = ({ manager }) => `${JSON.stringify({ $schema: 'https://openapi.vercel.sh/vercel.json', framework: null, installCommand: manager.install.replace(/^corepack enable && /, ''), buildCommand: `${manager.x} weft build --preset vercel` }, null, 2)}\n`
101
+
102
+ const wranglerToml = ({ deploy }) => `name = ${JSON.stringify(deploy.cloudflare?.name ?? 'my-app')}\ncompatibility_flags = ["nodejs_compat"]\n`
103
+
104
+ /** The workflow: on push to the branch, check and deploy to production; on a pull request, a preview where the host has one. */
105
+ function workflow({ target, deploy, manager }) {
106
+ const h = host(target)
107
+ const previews = h.kind !== 'server'
108
+ const node = manager.name === 'bun' ? [' - uses: oven-sh/setup-bun@v2'] : [' - uses: actions/setup-node@v4', ' with:', ' node-version: 26', ...(manager.cache ? [` cache: ${manager.cache}`] : [])]
109
+ const lines = [
110
+ 'name: weft deploy',
111
+ 'on:',
112
+ ' push:',
113
+ ` branches: [${deploy.branch ?? 'main'}]`,
114
+ ...(previews ? [' pull_request:'] : []),
115
+ 'permissions:',
116
+ ' contents: read',
117
+ 'concurrency:',
118
+ ' group: weft-deploy-${{ github.ref }}',
119
+ ' cancel-in-progress: true',
120
+ 'jobs:',
121
+ ' deploy:',
122
+ ' runs-on: ubuntu-latest',
123
+ ' steps:',
124
+ ' - uses: actions/checkout@v4',
125
+ ...node,
126
+ ...(target === 'fly' ? [' - uses: superfly/flyctl-actions/setup-flyctl@master'] : []),
127
+ ` - run: ${manager.install}`,
128
+ ` - run: ${manager.x} weft check`,
129
+ ` - run: ${manager.x} weft deploy --yes${previews ? " ${{ github.event_name == 'push' && '--prod' || '' }}" : ' --prod'}`,
130
+ ' env:',
131
+ ...h.env.map(k => ` ${k}: \${{ secrets.${k} }}`),
132
+ ]
133
+ return `${lines.join('\n')}\n`
134
+ }
135
+
136
+ const WORKFLOW = '.github/workflows/weft-deploy.yml'
137
+
138
+ /**
139
+ * What `weft deploy init <target>` writes, as `{ path, text }`, every text one stamped.
140
+ * @param {{ target: string, deploy: any, needs: any, runtime: string, manager: any }} ctx
141
+ */
142
+ export function filesFor(ctx) {
143
+ const { target } = ctx
144
+ const out = []
145
+ const text = (path, body) => out.push({ path, text: stamp(target, body) })
146
+ if (target === 'netlify') text('netlify.toml', netlifyToml(ctx))
147
+ if (target === 'vercel') out.push({ path: 'vercel.json', text: vercelJson(ctx), json: true })
148
+ if (target === 'cloudflare') text('wrangler.toml', wranglerToml(ctx))
149
+ if (target === 'fly') text('fly.toml', flyToml(ctx))
150
+ if (target === 'render') text('render.yaml', renderYaml(ctx))
151
+ if (target === 'fly' || target === 'render') {
152
+ text('Dockerfile', dockerfile(ctx))
153
+ out.push({ path: '.dockerignore', text: DOCKERIGNORE, plain: true })
154
+ }
155
+ if ((ctx.deploy.ci ?? 'github') === 'github') text(WORKFLOW, workflow(ctx))
156
+ return out
157
+ }
158
+
159
+ /**
160
+ * Each generated file's state: `missing`, `current`, `stale` (weft's, from an older config), `edited` (by hand: left
161
+ * alone), or `foreign` (no stamp: not weft's).
162
+ */
163
+ export function fileStates(root, files) {
164
+ return files.map(f => {
165
+ const abs = join(root, f.path)
166
+ if (!existsSync(abs)) return { ...f, state: 'missing' }
167
+ const now = readFileSync(abs, 'utf8')
168
+ if (now === f.text) return { ...f, state: 'current' }
169
+ if (f.json) return { ...f, state: JSON.parse(now || '{}').buildCommand?.includes('weft build --preset vercel') ? 'edited' : 'stale' }
170
+ if (f.plain) return { ...f, state: 'edited' }
171
+ const m = STAMP.exec(now)
172
+ if (!m) return { ...f, state: 'foreign' }
173
+ return { ...f, state: sha(now.slice(m[0].length)) === m[2] ? 'stale' : 'edited' }
174
+ })
175
+ }
176
+
177
+ /** Write what's missing or stale. Never a file weft didn't write, or one edited since. */
178
+ export function writeFiles(root, states, { force = false } = {}) {
179
+ const written = []
180
+ for (const f of states) {
181
+ if (f.state === 'current' || (!force && (f.state === 'edited' || f.state === 'foreign'))) continue
182
+ mkdirSync(dirname(join(root, f.path)), { recursive: true })
183
+ writeFileSync(join(root, f.path), f.text)
184
+ written.push(f.path)
185
+ }
186
+ return written
187
+ }
@@ -0,0 +1,62 @@
1
+ // Which hosts can run an app, and which one to suggest (research 46 §4). A finding is a need the host can't meet: an
2
+ // error when nothing on that host fixes it, a warning when it runs degraded or needs a paid add-on.
3
+
4
+ import { HOSTS } from './hosts.mjs'
5
+
6
+ const SERVERS = "`deploy: { target: 'fly' }` or `'render'`: a server that stays up"
7
+ const SPLIT = 'or keep the pages here and run realtime on a small server (the split, `weft docs deploy`)'
8
+
9
+ /** What a disk need asks of a host, with the config that gives it one. */
10
+ function disk(h, need, cfg = {}, assume) {
11
+ if (h.disk === 'none') return { severity: 'error', fix: need.what === 'a SQLite file' ? `${SERVERS}, with a volume or disk, or a hosted database here (Postgres on Neon or Supabase, Turso for SQLite)` : `${SERVERS}, with a volume or disk: this weft has no object store for uploads yet (research 46 §9)` }
12
+ const has = h.id === 'fly' ? cfg.fly?.volume : cfg.render?.disk
13
+ if (has) return
14
+ const add = h.id === 'fly' ? "`deploy.fly.volume: { name: 'data', sizeGb: 1 }` (billed)" : "`deploy.render.disk: { sizeGb: 1 }` (a paid plan)"
15
+ return { severity: assume ? 'warning' : 'error', fix: `add ${add}: it mounts at \`/app/.data\`, where SQLite's default \`file:.data/app.db\` already is, and uploads go to \`.data/media\`` }
16
+ }
17
+
18
+ /**
19
+ * @param {ReturnType<import('./needs.mjs').needsOf>} needs
20
+ * @param {import('./hosts.mjs').Host} h
21
+ * @param {{ deploy?: any, assume?: boolean, local?: boolean }} [options] `assume`: a paid add-on is a warning (the hosts list,
22
+ * before a target is chosen). `local`: the build is deployed from this machine.
23
+ * @returns {{ host: string, fits: boolean, findings: { code: string, severity: string, file: string, why: string, fix: string }[] }}
24
+ */
25
+ export function fit(needs, h, { deploy = {}, assume = false, local = false } = {}) {
26
+ const findings = []
27
+ const add = (code, severity, need, why, fix) => findings.push({ code, severity, file: need.file, why, fix })
28
+ if (needs.sockets && !h.sockets) add('E001', 'error', needs.sockets, `${needs.sockets.what} holds a WebSocket, and ${h.name} ends each process with its request: the page would load and never connect (R024)`, `${SERVERS}, or \`'cloudflare'\` (Durable Objects), ${SPLIT}`)
29
+ if (needs.required && !h.held) add('E002', 'error', needs.required, `${needs.required.what} needs a change feed that stays connected, and ${h.name} keeps no process between requests (R013)`, `${SERVERS}, run \`weft db listen\` on one with \`listener: 'external'\`, or drop \`required\``)
30
+ if (needs.sidecar && !h.sidecars) add('E002', 'error', needs.sidecar, `${needs.sidecar.what} is a second process next to the server, and ${h.name} runs only the server`, `${SERVERS} (the image runs both), or deploy the sidecar on its own and set \`rust.services.<name>.external: true\` with its \`WEFT_RUST_<NAME>_URL\``)
31
+ if (needs.native && h.kind === 'workers') add('R104', 'error', needs.native, `${needs.native.what} loads an addon or a \`.wasm\` file, and ${h.name} has no file system`, "use `remote()`, or a server host")
32
+ for (const key of ['sqlite', 'uploads']) {
33
+ const need = needs[key]
34
+ const d = need && disk(h, need, deploy, assume)
35
+ if (d) add('E003', d.severity, need, `${need.what} is kept on local disk, and ${h.disk === 'none' ? `${h.name}'s disk is gone after each request` : `${h.name} wipes it on each deploy without one`}`, d.fix)
36
+ }
37
+ if (needs.feed && !h.held) add('R014', 'warning', needs.feed, `${needs.feed.what} needs a process that stays up: on ${h.name}, writes from outside the app won't reach \`.live()\``, `${SERVERS}, or \`weft db listen\` on one with \`listener: 'external'\``)
38
+ if (needs.memoryBus && h.many) add('E009', 'warning', needs.memoryBus, `${needs.memoryBus.what} reaches the clients of one instance, and ${h.name} runs many`, h.id === 'cloudflare' ? '`durableObjectBus` in `src/realtime.server.ts`' : '`weft add redis` or `weft add nats`, or `postgresBus({ url })` in `src/realtime.server.ts`')
39
+ if (needs.addons && local && h.prebuilt && process.platform !== 'linux') add('E004', 'warning', needs.addons, `${needs.addons.what} has a compiled addon, built here for ${process.platform}, and ${h.name} runs Linux`, 'deploy from CI (`deploy.ci: \'github\'` builds on Linux), or drop the addon')
40
+ return { host: h.id, fits: !findings.some(f => f.severity === 'error'), findings }
41
+ }
42
+
43
+ /** Every host, fitted, and the one to suggest (research 46 §4), with why. */
44
+ export function hostsReport(needs, { deploy = {} } = {}) {
45
+ const fits = HOSTS.map(h => ({ ...fit(needs, h, { deploy, assume: true }), name: h.name, kind: h.kind, cost: h.cost, pricing: h.pricing, deploys: h.deploys }))
46
+ return { fits, suggestion: suggest(needs, fits) }
47
+ }
48
+
49
+ /** @returns {{ host: string, why: string, alternative?: string }} */
50
+ export function suggest(needs, fits = HOSTS.map(h => fit(needs, h, { assume: true }))) {
51
+ const ok = id => fits.find(f => f.host === id)?.fits
52
+ const clean = id => ok(id) && !fits.find(f => f.host === id).findings.length
53
+ const held = needs.required || needs.sidecar || needs.feed
54
+ const kept = needs.sqlite || needs.uploads
55
+ if (!needs.sockets && !held && !kept) {
56
+ if (clean('netlify')) return { host: 'netlify', why: 'nothing here needs a server that stays up: a CDN with functions behind it is the cheapest and fastest host', alternative: 'vercel' }
57
+ if (ok('netlify')) return { host: 'netlify', why: 'nothing here needs a server that stays up, once the warnings are fixed', alternative: 'vercel' }
58
+ }
59
+ if (needs.sockets && !held && !kept && !needs.native && clean('cloudflare')) return { host: 'cloudflare', why: 'channels need a socket, and a Durable Object holds one for each room without a server to run', alternative: 'fly' }
60
+ if (kept) return { host: 'fly', why: `${kept.what} needs a disk that lasts: a Fly volume is per machine and cheap`, alternative: 'render' }
61
+ return { host: 'fly', why: `${(needs.sockets ?? held).what} needs a server that stays up, and Fly runs the app as it is. Render's free instance does too, but sleeps after 15 idle minutes`, alternative: 'render' }
62
+ }
@@ -0,0 +1,56 @@
1
+ // The hosts `weft deploy` knows (research 46 §3): what each can run, its preset, what its CLI reads, and a cost hint.
2
+ // The fit (fit.mjs) and the generated files (files.mjs) both read this table, so they can't disagree about a host.
3
+
4
+ /**
5
+ * @typedef {{
6
+ * id: string, name: string, kind: 'functions' | 'workers' | 'server',
7
+ * preset?: string, sockets: boolean, held: boolean, disk: 'none' | 'volume' | 'paid-disk', sidecars: boolean,
8
+ * many: boolean, prebuilt: boolean, env: string[], cost: string, pricing: string, docs: string, deploys: boolean,
9
+ * }} Host
10
+ * `preset`: the Nitro preset, or none for the runtime's server preset. `prebuilt`: what is built here is what ships, so a
11
+ * native addon from this machine goes too. `deploys`: `weft deploy` drives it (AWS is a fit only, research 46 §11).
12
+ */
13
+
14
+ /** @type {Host[]} */
15
+ export const HOSTS = [
16
+ {
17
+ id: 'netlify', name: 'Netlify', kind: 'functions', preset: 'netlify', sockets: false, held: false, disk: 'none', sidecars: false, many: true, prebuilt: true,
18
+ env: ['NETLIFY_AUTH_TOKEN', 'NETLIFY_SITE_ID'], deploys: true,
19
+ cost: 'free tier; usage past it is billed in credits', pricing: 'https://www.netlify.com/pricing/', docs: 'https://docs.netlify.com/cli/get-started/',
20
+ },
21
+ {
22
+ id: 'vercel', name: 'Vercel', kind: 'functions', preset: 'vercel', sockets: false, held: false, disk: 'none', sidecars: false, many: true, prebuilt: true,
23
+ env: ['VERCEL_TOKEN', 'VERCEL_ORG_ID', 'VERCEL_PROJECT_ID'], deploys: true,
24
+ cost: 'Hobby is free for non-commercial use; Pro is per seat', pricing: 'https://vercel.com/pricing', docs: 'https://vercel.com/docs/cli',
25
+ },
26
+ {
27
+ id: 'cloudflare', name: 'Cloudflare Workers', kind: 'workers', preset: 'cloudflare_durable', sockets: true, held: false, disk: 'none', sidecars: false, many: true, prebuilt: true,
28
+ env: ['CLOUDFLARE_API_TOKEN', 'CLOUDFLARE_ACCOUNT_ID'], deploys: true,
29
+ cost: 'free tier includes Workers and SQLite-backed Durable Objects', pricing: 'https://developers.cloudflare.com/workers/platform/pricing/', docs: 'https://developers.cloudflare.com/workers/wrangler/',
30
+ },
31
+ {
32
+ id: 'fly', name: 'Fly.io', kind: 'server', sockets: true, held: true, disk: 'volume', sidecars: true, many: false, prebuilt: false,
33
+ env: ['FLY_API_TOKEN'], deploys: true,
34
+ cost: 'pay as you go, no free tier for new organizations', pricing: 'https://fly.io/docs/about/pricing/', docs: 'https://fly.io/docs/flyctl/',
35
+ },
36
+ {
37
+ id: 'render', name: 'Render', kind: 'server', sockets: true, held: true, disk: 'paid-disk', sidecars: true, many: false, prebuilt: false,
38
+ env: ['RENDER_DEPLOY_HOOK_URL'], deploys: true,
39
+ cost: 'free instance sleeps after 15 idle minutes and has no disk; paid plans add a disk', pricing: 'https://render.com/pricing', docs: 'https://render.com/docs/blueprint-spec',
40
+ },
41
+ {
42
+ id: 'aws', name: 'AWS Lambda', kind: 'functions', preset: 'aws_lambda', sockets: false, held: false, disk: 'none', sidecars: false, many: true, prebuilt: true,
43
+ env: [], deploys: false,
44
+ cost: 'Lambda has a free tier; a server would go on App Runner, billed per running instance', pricing: 'https://aws.amazon.com/pricing/', docs: 'https://nitro.build/deploy/providers/aws',
45
+ },
46
+ ]
47
+
48
+ export const TARGETS = HOSTS.filter(h => h.deploys).map(h => h.id)
49
+
50
+ export const host = id => HOSTS.find(h => h.id === id)
51
+
52
+ /** `runtime` in the config as the server preset a container runs. */
53
+ const SERVER_PRESETS = { node: 'node', bun: 'bun', deno: 'deno_server' }
54
+
55
+ /** The preset a build for this host uses. */
56
+ export const presetFor = (h, runtime = 'node') => h.preset ?? SERVER_PRESETS[runtime] ?? 'node'
@@ -0,0 +1,273 @@
1
+ // `weft deploy`, `weft deploy hosts`, `weft deploy init`, and what `weft check` and `weft doctor` say about the deploy
2
+ // (research 46). Nothing here talks to a provider except `weft deploy --yes`, which runs the host's own CLI.
3
+
4
+ import { spawnSync } from 'node:child_process'
5
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs'
6
+ import { join, relative } from 'node:path'
7
+ import ts from 'typescript'
8
+ import { diagnostic, locate } from '../diag.mjs'
9
+ import { findConfig, isDataConfig } from '../load-config.mjs'
10
+ import { cdnFindings, writtenSecrets } from './cdn.mjs'
11
+ import { fileStates, filesFor, managerOf, writeFiles } from './files.mjs'
12
+ import { fit, hostsReport } from './fit.mjs'
13
+ import { HOSTS, TARGETS, host, presetFor } from './hosts.mjs'
14
+ import { needsOf } from './needs.mjs'
15
+
16
+ export { HOSTS, TARGETS, fit, hostsReport, needsOf }
17
+
18
+ const usage = message => Object.assign(new Error(message), { code: 'E_USAGE' })
19
+ const refused = message => Object.assign(new Error(message), { code: 'E_REFUSED' })
20
+
21
+ /** A finding as a diagnostic, pointing at the file that shows the need. */
22
+ function toDiagnostic(root, f) {
23
+ let text = ''
24
+ try { text = readFileSync(join(root, f.file), 'utf8') } catch {}
25
+ const at = f.line ? { span: [f.line, 1, f.line, 1] } : text && f.needle ? locate(text, f.needle) : {}
26
+ return diagnostic({ code: f.code, severity: f.severity, file: f.file, ...at, why: f.why, help: f.fix })
27
+ }
28
+
29
+ /** The target this run is about: `--target`, then `deploy.target`, then the host whose preset `--preset` names. */
30
+ export function targetOf(config, { target, preset } = {}) {
31
+ const id = target ?? config.deploy?.target ?? (preset && HOSTS.find(h => h.preset === preset)?.id)
32
+ if (id && !host(id)) throw usage(`unknown deploy target \`${id}\`: use ${TARGETS.join(', ')}`)
33
+ return id
34
+ }
35
+
36
+ /** E001–E004, E006, E008–E014 for `weft check`: the configured target's fit, its files, written secrets, CDN setup. */
37
+ export function deployChecks(root, config, { preset } = {}) {
38
+ const out = writtenSecrets(root).map(s => ({ code: 'E008', severity: 'error', file: s.file, line: s.line, why: `${s.file}:${s.line} has ${s.what} in it: it would be committed, and anyone with the repository could deploy or bill as you`, fix: 'Move it to the environment (the host\'s settings, or GitHub\'s secrets for the workflow), and rotate it: it has been seen' }))
39
+ let id
40
+ try { id = targetOf(config, { preset }) } catch { id = undefined }
41
+ const deploy = config.deploy ?? {}
42
+ if (id) {
43
+ out.push(...fit(needsOf(root, config), host(id), { deploy }).findings)
44
+ if (deploy.target === id && host(id).deploys) {
45
+ for (const f of fileStates(root, filesFor(initContext(root, config, id)))) {
46
+ if (f.state === 'missing' || f.state === 'stale') out.push({ code: 'E006', severity: 'warning', file: f.path, why: `\`${f.path}\` is ${f.state === 'missing' ? 'missing' : 'from an older config'}: ${host(id).name} would deploy ${f.state === 'missing' ? 'without it' : 'what it said then'}`, fix: '`weft deploy init` writes it (a file edited by hand is left alone)' })
47
+ }
48
+ }
49
+ }
50
+ out.push(...cdnFindings(config))
51
+ return out.map(f => toDiagnostic(root, f))
52
+ }
53
+
54
+ /** What the generators read. */
55
+ export function initContext(root, config, target) {
56
+ return { target, deploy: config.deploy ?? {}, needs: needsOf(root, config), runtime: config.runtime ?? 'node', manager: managerOf(root) }
57
+ }
58
+
59
+ /** `weft deploy hosts`, and the `deploy` part of `weft doctor`: every host's fit and the suggestion (E005 when none is set). */
60
+ export function hostsCommand(root, config) {
61
+ const needs = needsOf(root, config)
62
+ const report = hostsReport(needs, { deploy: config.deploy })
63
+ const used = Object.entries(needs).filter(([k, v]) => k !== 'runtime' && v).map(([k, v]) => ({ need: k, ...v }))
64
+ const diagnostics = config.deploy?.target ? [] : [diagnostic({ code: 'E005', severity: 'info', file: findConfig(root) ? relative(root, findConfig(root)) : 'weft.config.ts', why: `No \`deploy.target\`. ${report.fits.filter(f => f.fits && f.deploys).map(f => f.name).join(', ')} can run this app; weft suggests ${host(report.suggestion.host).name}: ${report.suggestion.why}.`, help: `\`weft deploy init ${report.suggestion.host}\`` })]
65
+ return { needs: used, ...report, target: config.deploy?.target ?? null, diagnostics }
66
+ }
67
+
68
+ export function formatHosts(r) {
69
+ const lines = [`needs: ${r.needs.length ? r.needs.map(n => `${n.what} (${n.file})`).join(', ') : 'nothing a CDN with functions can\'t give'}`, '']
70
+ for (const f of r.fits) {
71
+ const mark = f.fits ? (f.findings.length ? '~' : '✓') : '✗'
72
+ lines.push(`${mark} ${f.host.padEnd(11)} ${f.name}${f.deploys ? '' : ' (fit only: `weft deploy` doesn\'t drive it yet)'}`)
73
+ for (const x of f.findings) lines.push(` ${x.code} ${x.why}. Fix: ${x.fix}`)
74
+ lines.push(` cost: ${f.cost} (${f.pricing})`)
75
+ }
76
+ lines.push('', `suggested: ${r.suggestion.host}, because ${r.suggestion.why}${r.suggestion.alternative ? `. Next best: ${r.suggestion.alternative}` : ''}`)
77
+ return lines.join('\n')
78
+ }
79
+
80
+ /** The object literal `export default` gives, directly or through `defineConfig(…)`. */
81
+ function configObject(sf) {
82
+ for (const st of sf.statements) {
83
+ if (!ts.isExportAssignment(st)) continue
84
+ const value = ts.isCallExpression(st.expression) ? st.expression.arguments[0] : st.expression
85
+ if (value && ts.isObjectLiteralExpression(value)) return value
86
+ }
87
+ }
88
+
89
+ /** The `deploy` block init writes when the config has none: the names a host needs, from package.json, and a disk when data needs one. */
90
+ export function defaultDeploy(root, target, needs) {
91
+ let name = 'my-app'
92
+ try { name = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')).name?.replace(/^@[^/]+\//, '').replace(/[^a-z0-9-]/gi, '-').toLowerCase() || name } catch {}
93
+ const kept = needs.sqlite || needs.uploads
94
+ const per = {
95
+ netlify: { site: name }, vercel: { project: name }, cloudflare: { name },
96
+ fly: { app: name, region: 'iad', ...(kept && { volume: { name: 'data', sizeGb: 1 } }) },
97
+ render: { service: name, ...(kept && { plan: 'starter', disk: { sizeGb: 1 } }) },
98
+ }
99
+ return { target, [target]: per[target], ci: 'github', branch: 'main' }
100
+ }
101
+
102
+ const literal = (v, pad = ' ') => {
103
+ if (v && typeof v === 'object') return `{ ${Object.entries(v).map(([k, x]) => `${k}: ${literal(x, pad)}`).join(', ')} }`
104
+ return typeof v === 'string' ? `'${v}'` : String(v)
105
+ }
106
+
107
+ /** The config with `deploy` added. Refuses to touch one that already has it, or that isn't a plain object. */
108
+ export function withDeploy(file, text, deploy) {
109
+ if (isDataConfig(file)) {
110
+ if (file.endsWith('.json')) return `${JSON.stringify({ ...JSON.parse(text), deploy }, null, 2)}\n`
111
+ return `${text.replace(/\n*$/, '\n')}deploy:\n${Object.entries(deploy).map(([k, v]) => ` ${k}: ${JSON.stringify(v)}`).join('\n')}\n`
112
+ }
113
+ const sf = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true)
114
+ const object = configObject(sf)
115
+ if (!object) throw refused(`${file} doesn't export a plain object, so \`weft deploy init\` can't add \`deploy\` to it. Add \`deploy: ${literal(deploy)}\` by hand. Nothing was changed.`)
116
+ const block = `deploy: ${literal(deploy)},`
117
+ const last = object.properties.at(-1)
118
+ return last ? text.slice(0, object.properties.end) + `${object.properties.hasTrailingComma ? '' : ','}\n ${block}` + text.slice(object.properties.end) : text.slice(0, object.getStart(sf) + 1) + `\n ${block}\n` + text.slice(object.getStart(sf) + 1)
119
+ }
120
+
121
+ /**
122
+ * `weft deploy init [target]`: the host's files and workflow, and `deploy` in the config when it has none. Refuses a
123
+ * target the app doesn't fit unless `unsafe` lets the codes through.
124
+ */
125
+ export function initCommand(root, config, { target, dryRun = false, force = false, unsafe } = {}) {
126
+ const needs = needsOf(root, config)
127
+ const id = targetOf(config, { target }) ?? hostsReport(needs).suggestion.host
128
+ const h = host(id)
129
+ if (!h.deploys) throw usage(`\`weft deploy\` doesn't drive ${h.name} yet (research 46 §11): ${TARGETS.join(', ')} it does`)
130
+ if (config.deploy?.target && target && config.deploy.target !== target) throw refused(`\`deploy.target\` is \`${config.deploy.target}\` in the config. Change it there to move to \`${target}\`, then run this again. Nothing was changed.`)
131
+ const deploy = config.deploy ?? defaultDeploy(root, id, needs)
132
+ const result = fit(needs, h, { deploy })
133
+ const blocking = result.findings.filter(f => f.severity === 'error' && !unsafe?.allows(f))
134
+ if (blocking.length) throw refused(`${h.name} can't run this app:\n${blocking.map(f => ` ${f.code} ${f.file}: ${f.why}.\n Fix: ${f.fix}`).join('\n')}\n\`weft deploy hosts\` shows which hosts can. Nothing was changed.`)
135
+ const ctx = { ...initContext(root, { ...config, deploy }, id) }
136
+ const states = fileStates(root, filesFor(ctx))
137
+ const configFile = findConfig(root)
138
+ const actions = []
139
+ let written = []
140
+ if (!config.deploy) {
141
+ const file = configFile ?? join(root, 'weft.config.ts')
142
+ const text = configFile ? readFileSync(configFile, 'utf8') : "import { defineConfig } from '@weft-js/core/config'\n\nexport default defineConfig({})\n"
143
+ actions.push(`${relative(root, file)}: ${configFile ? '' : '(new) '}deploy: ${literal(deploy)}`)
144
+ if (!dryRun) writeFileSync(file, withDeploy(file, text, deploy))
145
+ }
146
+ for (const f of states) actions.push(`${f.path}: ${{ missing: 'write', stale: 'rewrite (from an older config)', current: 'current', edited: force ? 'overwrite (edited by hand; --force)' : 'edited by hand: left alone', foreign: force ? 'overwrite (--force)' : 'not written by weft: left alone' }[f.state]}`)
147
+ if (!dryRun) written = writeFiles(root, states, { force })
148
+ const next = [
149
+ ...{
150
+ netlify: [`netlify sites:create --name ${deploy.netlify?.site ?? '<name>'} (once; or link an existing site with \`netlify link\`)`],
151
+ vercel: [`vercel link --project ${deploy.vercel?.project ?? '<name>'} (once: writes .vercel/project.json with the org and project ids)`],
152
+ cloudflare: ['wrangler login (once, locally; CI reads CLOUDFLARE_API_TOKEN)'],
153
+ fly: [`flyctl apps create ${deploy.fly?.app ?? '<name>'} (once; billed per running machine)`, ...(deploy.fly?.volume ? [`flyctl volumes create ${deploy.fly.volume.name ?? 'data'} --size ${deploy.fly.volume.sizeGb ?? 1} --region ${deploy.fly.region ?? 'iad'} -a ${deploy.fly.app} (once; billed)`] : [])],
154
+ render: ['Create the service from render.yaml: Render dashboard → New → Blueprint, on this repository (once)', 'Copy the service\'s deploy hook (Settings → Deploy Hook) into RENDER_DEPLOY_HOOK_URL'],
155
+ }[id],
156
+ ...((deploy.ci ?? 'github') === 'github' ? [`gh secret set ${h.env.join(' / ')} (each one, from the host's dashboard: weft never writes them)`] : []),
157
+ `weft deploy (a dry run: shows what --yes would run)`,
158
+ ]
159
+ return { ok: true, target: id, dryRun, actions, written, next, findings: result.findings.map(f => toDiagnostic(root, f)) }
160
+ }
161
+
162
+ /** The host's CLI: on PATH, else through npx for the ones on npm. */
163
+ function cliFor(name, { which = cmd => spawnSync(process.platform === 'win32' ? 'where' : 'which', [cmd], { encoding: 'utf8' }).status === 0 } = {}) {
164
+ const npm = { netlify: 'netlify-cli', vercel: 'vercel', wrangler: 'wrangler' }
165
+ for (const cmd of name === 'flyctl' ? ['flyctl', 'fly'] : [name]) if (which(cmd)) return [cmd]
166
+ if (npm[name]) return ['npx', '--yes', npm[name]]
167
+ throw refused(`\`${name}\` isn't installed: ${name === 'flyctl' ? 'https://fly.io/docs/flyctl/install/' : `npm install -g ${npm[name] ?? name}`}`)
168
+ }
169
+
170
+ /**
171
+ * The steps `weft deploy` takes for a target, as data: a dry run prints them, `--yes` runs them. A step reads env vars
172
+ * by name; their values are filled in only as it runs.
173
+ */
174
+ export function deploySteps(root, config, { target, prod = false, which } = {}) {
175
+ const h = host(target)
176
+ const deploy = config.deploy ?? {}
177
+ const runtime = config.runtime ?? 'node'
178
+ const needs = needsOf(root, config)
179
+ const steps = []
180
+ const build = { kind: 'build', what: `weft build --preset ${presetFor(h, runtime)}`, preset: presetFor(h, runtime) }
181
+ if (target === 'netlify') {
182
+ const site = process.env.NETLIFY_SITE_ID ? [] : deploy.netlify?.site ? ['--site', deploy.netlify.site] : []
183
+ steps.push(build, { kind: 'run', cli: 'netlify', args: ['deploy', '--dir', 'dist', '--functions', '.netlify/functions-internal', '--json', ...site, ...(prod ? ['--prod'] : [])], reads: ['NETLIFY_AUTH_TOKEN', 'NETLIFY_SITE_ID'], url: 'json' })
184
+ }
185
+ if (target === 'vercel') steps.push(build, { kind: 'run', cli: 'vercel', args: ['deploy', '--prebuilt', '--yes', ...(prod ? ['--prod'] : [])], token: 'VERCEL_TOKEN', reads: ['VERCEL_TOKEN', 'VERCEL_ORG_ID', 'VERCEL_PROJECT_ID'], url: 'last-line' })
186
+ if (target === 'cloudflare') steps.push(build, { kind: 'run', cli: 'wrangler', args: ['deploy'], cwd: '.output', reads: ['CLOUDFLARE_API_TOKEN', 'CLOUDFLARE_ACCOUNT_ID'], url: 'workers' })
187
+ if (target === 'fly') {
188
+ // one machine unless a bus reaches the others: a second machine would split the rooms, and a volume is per machine
189
+ const one = needs.memoryBus || deploy.fly?.volume
190
+ steps.push({ kind: 'run', cli: 'flyctl', args: ['deploy', '--remote-only', ...(deploy.fly?.app ? ['--app', deploy.fly.app] : []), ...(one ? ['--ha=false'] : [])], reads: ['FLY_API_TOKEN'], url: deploy.fly?.app ? `https://${deploy.fly.app}.fly.dev` : 'fly' })
191
+ }
192
+ if (target === 'render') steps.push({ kind: 'hook', what: 'POST $RENDER_DEPLOY_HOOK_URL (Render builds the pushed commit)', reads: ['RENDER_DEPLOY_HOOK_URL'], url: deploy.render?.service ? `https://${deploy.render.service}.onrender.com` : undefined })
193
+ for (const s of steps) if (s.cli) s.what = `${s.cli} ${s.args.join(' ')}${s.token ? ` --token $${s.token}` : ''}${s.cwd ? ` (in ${s.cwd}/)` : ''}`
194
+ return steps
195
+ }
196
+
197
+ /** A variable a step reads, and whether its absence stops the deploy (E007). */
198
+ function missingEnv(root, target, config, env) {
199
+ const ci = !!env.CI
200
+ const linked = { netlify: existsSync(join(root, '.netlify/state.json')) || !!config.deploy?.netlify?.site, vercel: existsSync(join(root, '.vercel/project.json')) }
201
+ // a token: the CLI's own login stands in, except in CI. An id: needed unless the project is linked.
202
+ const token = (k, how) => [k, ci ? 'error' : 'warning', how, ': the CLI\'s own login is used']
203
+ const id = (k, known, how) => [k, known ? null : 'error', how, '']
204
+ const need = {
205
+ netlify: [token('NETLIFY_AUTH_TOKEN', '`netlify login` here, or a personal access token from User settings → Applications'), id('NETLIFY_SITE_ID', linked.netlify, '`netlify link`, `deploy.netlify.site`, or the site id from Site configuration')],
206
+ vercel: [token('VERCEL_TOKEN', '`vercel login` here, or a token from Account Settings → Tokens'), id('VERCEL_ORG_ID', linked.vercel, '`vercel link` writes it to .vercel/project.json'), id('VERCEL_PROJECT_ID', linked.vercel, '`vercel link` writes it to .vercel/project.json')],
207
+ cloudflare: [token('CLOUDFLARE_API_TOKEN', '`wrangler login` here, or a token with Workers Scripts: Edit'), id('CLOUDFLARE_ACCOUNT_ID', !ci, 'the account id on the Workers overview page')],
208
+ fly: [token('FLY_API_TOKEN', '`flyctl auth login` here, or `flyctl tokens create deploy`')],
209
+ render: [id('RENDER_DEPLOY_HOOK_URL', false, "the service's Settings → Deploy Hook")],
210
+ }[target]
211
+ return need.filter(([k, severity]) => severity && !env[k]).map(([k, severity, how, local]) => ({ code: 'E007', severity, file: 'weft.config.ts', why: `\`${k}\` is unset${severity === 'warning' ? local : ''}`, fix: `${how}. Set it in the environment (never a file)` }))
212
+ }
213
+
214
+ /** The URL a host's CLI printed. */
215
+ export function urlFrom(step, stdout) {
216
+ if (step.url === 'json') {
217
+ try { const j = JSON.parse(stdout.slice(stdout.indexOf('{'))); return j.url ?? j.deploy_url } catch { return }
218
+ }
219
+ if (step.url === 'last-line') return stdout.trim().split('\n').reverse().find(l => /^https:\/\//.test(l.trim()))?.trim()
220
+ if (step.url === 'workers') return /https:\/\/[\w.-]+\.workers\.dev/.exec(stdout)?.[0]
221
+ if (step.url === 'fly') return /https:\/\/[\w.-]+\.fly\.dev/.exec(stdout)?.[0]
222
+ return step.url
223
+ }
224
+
225
+ /**
226
+ * `weft deploy`: fit, then the steps. Without `yes` nothing runs: the result says what would. `build` is `weft build`
227
+ * in-process (start.mjs's buildCommand), `exec` runs a host CLI; both are injectable for tests.
228
+ */
229
+ export async function deployCommand(root, config, { target, prod = false, yes = false, unsafe, env = process.env, build, exec = defaultExec, which, post = (url) => fetch(url, { method: 'POST' }) } = {}) {
230
+ const id = targetOf(config, { target })
231
+ if (!id) {
232
+ const s = hostsReport(needsOf(root, config)).suggestion
233
+ throw refused(`no deploy target: set \`deploy.target\` (\`weft deploy init\` does, and writes the host's files), or pass --target. weft suggests \`${s.host}\`: ${s.why}`)
234
+ }
235
+ const h = host(id)
236
+ if (!h.deploys) throw usage(`\`weft deploy\` doesn't drive ${h.name} yet (research 46 §11)`)
237
+ if (h.kind === 'server' && !prod) throw usage(`${h.name} has no preview deploys: pass --prod to deploy production`)
238
+ const needs = needsOf(root, config)
239
+ const findings = fit(needs, h, { deploy: config.deploy ?? {}, local: !env.CI }).findings
240
+ const blocking = findings.filter(f => f.severity === 'error' && !unsafe?.allows(f))
241
+ if (blocking.length) throw refused(`${h.name} can't run this app:\n${blocking.map(f => ` ${f.code} ${f.file}: ${f.why}.\n Fix: ${f.fix}`).join('\n')}\nTo deploy anyway: --unsafe-allow ${[...new Set(blocking.map(f => f.code))].join(',')}`)
242
+ const steps = deploySteps(root, config, { target: id, prod, which })
243
+ const envFindings = missingEnv(root, id, config, env)
244
+ const diagnostics = [...findings, ...envFindings, ...cdnFindings(config, { env })].map(f => toDiagnostic(root, f))
245
+ if (!yes) return { ok: true, dryRun: true, target: id, prod, steps: steps.map(s => ({ what: s.what, reads: s.reads ?? [] })), diagnostics }
246
+ const stop = envFindings.find(f => f.severity === 'error')
247
+ if (stop) throw refused(`E007: ${stop.why}. ${stop.fix}.`)
248
+ let url
249
+ for (const s of steps) {
250
+ if (s.kind === 'build') {
251
+ const built = await build(root, { preset: s.preset })
252
+ diagnostics.push(...built.diagnostics)
253
+ if (!built.ok) throw refused('weft build failed: nothing was deployed. Fix the errors above.')
254
+ } else if (s.kind === 'hook') {
255
+ const res = await post(env.RENDER_DEPLOY_HOOK_URL)
256
+ if (!res.ok) throw refused(`Render's deploy hook answered ${res.status}: nothing was deployed. Check RENDER_DEPLOY_HOOK_URL.`)
257
+ url = s.url
258
+ } else {
259
+ const [cmd, ...pre] = cliFor(s.cli, { which })
260
+ const args = [...pre, ...s.args, ...(s.token && env[s.token] ? ['--token', env[s.token]] : [])]
261
+ const r = await exec(cmd, args, { cwd: join(root, s.cwd ?? ''), env })
262
+ if (r.code !== 0) throw refused(`${s.cli} failed (exit ${r.code}): nothing more was run.\n${(r.stderr || r.stdout).trim().split('\n').slice(-15).join('\n')}`)
263
+ url = urlFrom(s, r.stdout) ?? url
264
+ }
265
+ }
266
+ return { ok: true, dryRun: false, target: id, prod, url: url ?? null, diagnostics }
267
+ }
268
+
269
+ /** A host CLI with its progress on stderr, as it goes, and stdout kept for the URL. */
270
+ function defaultExec(cmd, args, { cwd, env }) {
271
+ const r = spawnSync(cmd, args, { cwd, env, encoding: 'utf8', stdio: ['inherit', 'pipe', 'inherit'], maxBuffer: 64 * 1024 * 1024 })
272
+ return { code: r.status ?? 1, stdout: r.stdout ?? '', stderr: r.error?.message ?? '' }
273
+ }