@weft-js/cli 0.1.1 → 0.1.2

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 (171) hide show
  1. package/README.md +38 -14
  2. package/bin/weft.mjs +8 -1
  3. package/docs/async.md +16 -6
  4. package/docs/browser.md +22 -8
  5. package/docs/config.md +39 -9
  6. package/docs/core.md +30 -0
  7. package/docs/deploy.md +33 -8
  8. package/docs/design.md +12 -3
  9. package/docs/entrypoints.md +79 -0
  10. package/docs/fonts.md +12 -2
  11. package/docs/icons.md +13 -7
  12. package/docs/lists.md +3 -1
  13. package/docs/meta.md +13 -3
  14. package/docs/plugins.md +32 -9
  15. package/docs/props.md +13 -2
  16. package/docs/reactivity.md +26 -0
  17. package/docs/rules.md +19 -12
  18. package/docs/rust.md +47 -17
  19. package/docs/start-short.md +3 -2
  20. package/docs/start.md +226 -52
  21. package/docs/state.md +33 -7
  22. package/docs/style.md +31 -8
  23. package/docs/surface.md +61 -15
  24. package/docs/testing.md +15 -5
  25. package/docs/theme.md +182 -0
  26. package/docs/ui-state.md +19 -6
  27. package/docs/verify.md +13 -0
  28. package/package.json +13 -4
  29. package/src/add/config.mjs +93 -0
  30. package/src/add/errors.mjs +2 -0
  31. package/src/add/setup.mjs +88 -0
  32. package/src/add-rust.mjs +82 -27
  33. package/src/add.mjs +209 -181
  34. package/src/agents-md.mjs +144 -43
  35. package/src/alias.mjs +16 -5
  36. package/src/audit/page.mjs +313 -0
  37. package/src/audit/report.mjs +69 -0
  38. package/src/audit/utils.mjs +5 -0
  39. package/src/audit.mjs +159 -206
  40. package/src/browser/cli.mjs +24 -5
  41. package/src/browser/client.mjs +189 -25
  42. package/src/browser/commands.mjs +166 -51
  43. package/src/catalog/audit.mjs +129 -0
  44. package/src/catalog/auth.mjs +40 -0
  45. package/src/catalog/compiler.mjs +337 -0
  46. package/src/catalog/contracts.mjs +18 -0
  47. package/src/catalog/database.mjs +52 -0
  48. package/src/catalog/deploy.mjs +117 -0
  49. package/src/catalog/design.mjs +159 -0
  50. package/src/catalog/forms.mjs +25 -0
  51. package/src/catalog/hydration.mjs +29 -0
  52. package/src/catalog/icons.mjs +24 -0
  53. package/src/catalog/media.mjs +163 -0
  54. package/src/catalog/metadata.mjs +116 -0
  55. package/src/catalog/plugins.mjs +228 -0
  56. package/src/catalog/realtime.mjs +195 -0
  57. package/src/catalog/rust.mjs +75 -0
  58. package/src/catalog/server-functions.mjs +92 -0
  59. package/src/catalog/state.mjs +39 -0
  60. package/src/catalog/style-groups.mjs +54 -0
  61. package/src/catalog/tests.mjs +127 -0
  62. package/src/catalog/text.mjs +200 -0
  63. package/src/catalog/ui-state.mjs +42 -0
  64. package/src/catalog.mjs +67 -460
  65. package/src/check/runtime.mjs +280 -0
  66. package/src/check.mjs +198 -208
  67. package/src/cli/authoring.mjs +213 -0
  68. package/src/cli/project.mjs +302 -0
  69. package/src/cli.mjs +222 -250
  70. package/src/config.mjs +1 -1
  71. package/src/contributions.mjs +75 -25
  72. package/src/db-layer.mjs +13 -5
  73. package/src/deploy/cdn.mjs +94 -16
  74. package/src/deploy/files.mjs +100 -21
  75. package/src/deploy/fit.mjs +127 -23
  76. package/src/deploy/hosts.mjs +90 -20
  77. package/src/deploy/index/cli.mjs +20 -0
  78. package/src/deploy/index/environment.mjs +69 -0
  79. package/src/deploy/index/steps.mjs +87 -0
  80. package/src/deploy/index.mjs +228 -114
  81. package/src/deploy/needs.mjs +88 -31
  82. package/src/design-checks/accessible-names.mjs +62 -0
  83. package/src/design-checks/contrast.mjs +43 -0
  84. package/src/design-checks/csp.mjs +60 -0
  85. package/src/design-checks/files.mjs +59 -0
  86. package/src/design-checks/heading-links.mjs +61 -0
  87. package/src/design-checks/interaction.mjs +65 -0
  88. package/src/design-checks/jsx.mjs +67 -0
  89. package/src/design-checks/layout-values.mjs +68 -0
  90. package/src/design-checks/native-controls.mjs +48 -0
  91. package/src/design-checks/outline.mjs +39 -0
  92. package/src/design-checks/reporter.mjs +37 -0
  93. package/src/design-checks/rules.mjs +274 -0
  94. package/src/design-checks/values.mjs +262 -0
  95. package/src/design-checks.mjs +10 -400
  96. package/src/diag.mjs +32 -8
  97. package/src/docs.mjs +138 -43
  98. package/src/doctor.mjs +20 -8
  99. package/src/files.mjs +18 -5
  100. package/src/fix.mjs +10 -2
  101. package/src/gate/after.mjs +40 -0
  102. package/src/gate/before.mjs +54 -0
  103. package/src/gate/classify.mjs +72 -0
  104. package/src/gate/mutation.mjs +209 -0
  105. package/src/gate/resolve-cases.mjs +22 -0
  106. package/src/gate/result.mjs +63 -0
  107. package/src/gate/run.mjs +75 -0
  108. package/src/gate.mjs +104 -202
  109. package/src/infer/box-props.cjs +140 -0
  110. package/src/infer/core-boxes.cjs +46 -0
  111. package/src/infer/derived-writes.cjs +95 -0
  112. package/src/infer/engine/checks.cjs +193 -0
  113. package/src/infer/engine/flow.cjs +293 -0
  114. package/src/infer/engine/report.cjs +158 -0
  115. package/src/infer/engine/utils.cjs +15 -0
  116. package/src/infer/engine.cjs +108 -368
  117. package/src/infer/facts/sites.cjs +114 -0
  118. package/src/infer/facts.cjs +210 -117
  119. package/src/infer/hosts.cjs +69 -22
  120. package/src/infer/lsp.mjs +149 -40
  121. package/src/infer/session.cjs +73 -17
  122. package/src/infer/shadow.cjs +13 -5
  123. package/src/infer/worker.cjs +57 -9
  124. package/src/jsx-scan.mjs +63 -21
  125. package/src/load-config/schema.mjs +281 -0
  126. package/src/load-config.mjs +137 -138
  127. package/src/map.mjs +93 -26
  128. package/src/mcp/browser-tools.mjs +155 -0
  129. package/src/mcp.mjs +147 -69
  130. package/src/meta-table.mjs +43 -8
  131. package/src/mutate.mjs +120 -36
  132. package/src/plugin-checks.mjs +52 -23
  133. package/src/plugins.mjs +125 -39
  134. package/src/project.mjs +170 -79
  135. package/src/props-checks.mjs +24 -6
  136. package/src/props-listener.mjs +27 -9
  137. package/src/react-checks.mjs +35 -4
  138. package/src/server-state-checks.mjs +34 -0
  139. package/src/share-checks.mjs +121 -37
  140. package/src/site.mjs +33 -6
  141. package/src/slots.mjs +104 -19
  142. package/src/start/build.mjs +116 -0
  143. package/src/start/routes.mjs +38 -0
  144. package/src/start.mjs +193 -95
  145. package/src/state-checks/core-state.mjs +223 -0
  146. package/src/state-checks/modules.mjs +17 -0
  147. package/src/state-checks/scan.mjs +232 -0
  148. package/src/state-checks/server-graph.mjs +65 -0
  149. package/src/state-checks.mjs +109 -133
  150. package/src/style/find.mjs +121 -0
  151. package/src/style.mjs +133 -127
  152. package/src/surface-checks.mjs +212 -46
  153. package/src/tailwind.mjs +5 -4
  154. package/src/tasks.mjs +189 -27
  155. package/src/test-rules/context.mjs +109 -0
  156. package/src/test-rules/file-rules.mjs +279 -0
  157. package/src/test-rules/rule-helpers.mjs +244 -0
  158. package/src/test-rules/syntax.mjs +223 -0
  159. package/src/test-rules/test-rules.mjs +127 -0
  160. package/src/testdiff.mjs +94 -32
  161. package/src/tests.mjs +31 -483
  162. package/src/text-checks.mjs +229 -69
  163. package/src/tokens.mjs +12 -4
  164. package/src/trust/scan.mjs +95 -0
  165. package/src/trust.mjs +214 -102
  166. package/src/weft.mjs +41 -9
  167. package/src/workspace.mjs +96 -19
  168. package/ts-plugin/context.js +57 -0
  169. package/ts-plugin/index.js +4 -124
  170. package/ts-plugin/mapping.js +65 -0
  171. package/ts-plugin/proxy.js +144 -0
@@ -4,15 +4,29 @@
4
4
  import { HOSTS } from './hosts.mjs'
5
5
 
6
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`)'
7
+ const SPLIT =
8
+ 'or keep the pages here and run realtime on a small server (the split, `weft docs deploy`)'
8
9
 
9
10
  /** What a disk need asks of a host, with the config that gives it one. */
10
11
  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
+ if (h.disk === 'none')
13
+ return {
14
+ severity: 'error',
15
+ fix:
16
+ need.what === 'a SQLite file'
17
+ ? `${SERVERS}, with a volume or disk, or a hosted database here (Postgres on Neon or Supabase, Turso for SQLite)`
18
+ : `${SERVERS}, with a volume or disk: this weft has no object store for uploads yet (research 46 §9)`,
19
+ }
12
20
  const has = h.id === 'fly' ? cfg.fly?.volume : cfg.render?.disk
13
21
  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\`` }
22
+ const add =
23
+ h.id === 'fly'
24
+ ? "`deploy.fly.volume: { name: 'data', sizeGb: 1 }` (billed)"
25
+ : '`deploy.render.disk: { sizeGb: 1 }` (a paid plan)'
26
+ return {
27
+ severity: assume ? 'warning' : 'error',
28
+ fix: `add ${add}: it mounts at \`/app/.data\`, where SQLite's default \`file:.data/app.db\` already is, and uploads go to \`.data/media\``,
29
+ }
16
30
  }
17
31
 
18
32
  /**
@@ -24,39 +38,129 @@ function disk(h, need, cfg = {}, assume) {
24
38
  */
25
39
  export function fit(needs, h, { deploy = {}, assume = false, local = false } = {}) {
26
40
  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")
41
+ const add = (code, severity, need, why, fix) =>
42
+ findings.push({ code, severity, file: need.file, why, fix })
43
+ if (needs.sockets && !h.sockets)
44
+ add(
45
+ 'E001',
46
+ 'error',
47
+ needs.sockets,
48
+ `${needs.sockets.what} holds a WebSocket, and ${h.name} ends each process with its request: the page would load and never connect (R024)`,
49
+ `${SERVERS}, or \`'cloudflare'\` (Durable Objects), ${SPLIT}`,
50
+ )
51
+ if (needs.required && !h.held)
52
+ add(
53
+ 'E002',
54
+ 'error',
55
+ needs.required,
56
+ `${needs.required.what} needs a change feed that stays connected, and ${h.name} keeps no process between requests (R013)`,
57
+ `${SERVERS}, run \`weft db listen\` on one with \`listener: 'external'\`, or drop \`required\``,
58
+ )
59
+ if (needs.sidecar && !h.sidecars)
60
+ add(
61
+ 'E002',
62
+ 'error',
63
+ needs.sidecar,
64
+ `${needs.sidecar.what} is a second process next to the server, and ${h.name} runs only the server`,
65
+ `${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\``,
66
+ )
67
+ if (needs.native && h.kind === 'workers')
68
+ add(
69
+ 'R104',
70
+ 'error',
71
+ needs.native,
72
+ `${needs.native.what} loads an addon or a \`.wasm\` file, and ${h.name} has no file system`,
73
+ 'use `remote()`, or a server host',
74
+ )
32
75
  for (const key of ['sqlite', 'uploads']) {
33
76
  const need = needs[key]
34
77
  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)
78
+ if (d)
79
+ add(
80
+ 'E003',
81
+ d.severity,
82
+ need,
83
+ `${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`}`,
84
+ d.fix,
85
+ )
36
86
  }
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 }
87
+ if (needs.feed && !h.held)
88
+ add(
89
+ 'R014',
90
+ 'warning',
91
+ needs.feed,
92
+ `${needs.feed.what} needs a process that stays up: on ${h.name}, writes from outside the app won't reach \`.live()\``,
93
+ `${SERVERS}, or \`weft db listen\` on one with \`listener: 'external'\``,
94
+ )
95
+ if (needs.memoryBus && h.many)
96
+ add(
97
+ 'E009',
98
+ 'warning',
99
+ needs.memoryBus,
100
+ `${needs.memoryBus.what} reaches the clients of one instance, and ${h.name} runs many`,
101
+ h.id === 'cloudflare'
102
+ ? '`durableObjectBus` in `src/realtime.server.ts`'
103
+ : '`weft add redis` or `weft add nats`, or `postgresBus({ url })` in `src/realtime.server.ts`',
104
+ )
105
+ if (needs.addons && local && h.prebuilt && process.platform !== 'linux')
106
+ add(
107
+ 'E004',
108
+ 'warning',
109
+ needs.addons,
110
+ `${needs.addons.what} has a compiled addon, built here for ${process.platform}, and ${h.name} runs Linux`,
111
+ "deploy from CI (`deploy.ci: 'github'` builds on Linux), or drop the addon",
112
+ )
113
+ return { host: h.id, fits: !findings.some((f) => f.severity === 'error'), findings }
41
114
  }
42
115
 
43
116
  /** Every host, fitted, and the one to suggest (research 46 §4), with why. */
44
117
  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 }))
118
+ const fits = HOSTS.map((h) => ({
119
+ ...fit(needs, h, { deploy, assume: true }),
120
+ name: h.name,
121
+ kind: h.kind,
122
+ cost: h.cost,
123
+ pricing: h.pricing,
124
+ deploys: h.deploys,
125
+ }))
46
126
  return { fits, suggestion: suggest(needs, fits) }
47
127
  }
48
128
 
49
129
  /** @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
130
+ export function suggest(needs, fits = HOSTS.map((h) => fit(needs, h, { assume: true }))) {
131
+ const ok = (id) => fits.find((f) => f.host === id)?.fits
132
+ const clean = (id) => ok(id) && !fits.find((f) => f.host === id).findings.length
53
133
  const held = needs.required || needs.sidecar || needs.feed
54
134
  const kept = needs.sqlite || needs.uploads
55
135
  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' }
136
+ if (clean('netlify'))
137
+ return {
138
+ host: 'netlify',
139
+ why: 'nothing here needs a server that stays up: a CDN with functions behind it is the cheapest and fastest host',
140
+ alternative: 'vercel',
141
+ }
142
+ if (ok('netlify'))
143
+ return {
144
+ host: 'netlify',
145
+ why: 'nothing here needs a server that stays up, once the warnings are fixed',
146
+ alternative: 'vercel',
147
+ }
148
+ }
149
+ if (needs.sockets && !held && !kept && !needs.native && clean('cloudflare'))
150
+ return {
151
+ host: 'cloudflare',
152
+ why: 'channels need a socket, and a Durable Object holds one for each room without a server to run',
153
+ alternative: 'fly',
154
+ }
155
+ if (kept)
156
+ return {
157
+ host: 'fly',
158
+ why: `${kept.what} needs a disk that lasts: a Fly volume is per machine and cheap`,
159
+ alternative: 'render',
160
+ }
161
+ return {
162
+ host: 'fly',
163
+ 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`,
164
+ alternative: 'render',
58
165
  }
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
166
  }
@@ -14,40 +14,110 @@
14
14
  /** @type {Host[]} */
15
15
  export const HOSTS = [
16
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/',
17
+ id: 'netlify',
18
+ name: 'Netlify',
19
+ kind: 'functions',
20
+ preset: 'netlify',
21
+ sockets: false,
22
+ held: false,
23
+ disk: 'none',
24
+ sidecars: false,
25
+ many: true,
26
+ prebuilt: true,
27
+ env: ['NETLIFY_AUTH_TOKEN', 'NETLIFY_SITE_ID'],
28
+ deploys: true,
29
+ cost: 'free tier; usage past it is billed in credits',
30
+ pricing: 'https://www.netlify.com/pricing/',
31
+ docs: 'https://docs.netlify.com/cli/get-started/',
20
32
  },
21
33
  {
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',
34
+ id: 'vercel',
35
+ name: 'Vercel',
36
+ kind: 'functions',
37
+ preset: 'vercel',
38
+ sockets: false,
39
+ held: false,
40
+ disk: 'none',
41
+ sidecars: false,
42
+ many: true,
43
+ prebuilt: true,
44
+ env: ['VERCEL_TOKEN', 'VERCEL_ORG_ID', 'VERCEL_PROJECT_ID'],
45
+ deploys: true,
46
+ cost: 'Hobby is free for non-commercial use; Pro is per seat',
47
+ pricing: 'https://vercel.com/pricing',
48
+ docs: 'https://vercel.com/docs/cli',
25
49
  },
26
50
  {
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/',
51
+ id: 'cloudflare',
52
+ name: 'Cloudflare Workers',
53
+ kind: 'workers',
54
+ preset: 'cloudflare_durable',
55
+ sockets: true,
56
+ held: false,
57
+ disk: 'none',
58
+ sidecars: false,
59
+ many: true,
60
+ prebuilt: true,
61
+ env: ['CLOUDFLARE_API_TOKEN', 'CLOUDFLARE_ACCOUNT_ID'],
62
+ deploys: true,
63
+ cost: 'free tier includes Workers and SQLite-backed Durable Objects',
64
+ pricing: 'https://developers.cloudflare.com/workers/platform/pricing/',
65
+ docs: 'https://developers.cloudflare.com/workers/wrangler/',
30
66
  },
31
67
  {
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/',
68
+ id: 'fly',
69
+ name: 'Fly.io',
70
+ kind: 'server',
71
+ sockets: true,
72
+ held: true,
73
+ disk: 'volume',
74
+ sidecars: true,
75
+ many: false,
76
+ prebuilt: false,
77
+ env: ['FLY_API_TOKEN'],
78
+ deploys: true,
79
+ cost: 'pay as you go, no free tier for new organizations',
80
+ pricing: 'https://fly.io/docs/about/pricing/',
81
+ docs: 'https://fly.io/docs/flyctl/',
35
82
  },
36
83
  {
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',
84
+ id: 'render',
85
+ name: 'Render',
86
+ kind: 'server',
87
+ sockets: true,
88
+ held: true,
89
+ disk: 'paid-disk',
90
+ sidecars: true,
91
+ many: false,
92
+ prebuilt: false,
93
+ env: ['RENDER_DEPLOY_HOOK_URL'],
94
+ deploys: true,
95
+ cost: 'free instance sleeps after 15 idle minutes and has no disk; paid plans add a disk',
96
+ pricing: 'https://render.com/pricing',
97
+ docs: 'https://render.com/docs/blueprint-spec',
40
98
  },
41
99
  {
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',
100
+ id: 'aws',
101
+ name: 'AWS Lambda',
102
+ kind: 'functions',
103
+ preset: 'aws_lambda',
104
+ sockets: false,
105
+ held: false,
106
+ disk: 'none',
107
+ sidecars: false,
108
+ many: true,
109
+ prebuilt: true,
110
+ env: [],
111
+ deploys: false,
112
+ cost: 'Lambda has a free tier; a server would go on App Runner, billed per running instance',
113
+ pricing: 'https://aws.amazon.com/pricing/',
114
+ docs: 'https://nitro.build/deploy/providers/aws',
45
115
  },
46
116
  ]
47
117
 
48
- export const TARGETS = HOSTS.filter(h => h.deploys).map(h => h.id)
118
+ export const TARGETS = HOSTS.filter((h) => h.deploys).map((h) => h.id)
49
119
 
50
- export const host = id => HOSTS.find(h => h.id === id)
120
+ export const host = (id) => HOSTS.find((h) => h.id === id)
51
121
 
52
122
  /** `runtime` in the config as the server preset a container runs. */
53
123
  const SERVER_PRESETS = { node: 'node', bun: 'bun', deno: 'deno_server' }
@@ -0,0 +1,20 @@
1
+ import { spawnSync } from 'node:child_process'
2
+
3
+ const refused = (message) => Object.assign(new Error(message), { code: 'E_REFUSED' })
4
+
5
+ /** The host's CLI: on PATH, else through npx for the ones on npm. */
6
+ export function cliFor(
7
+ name,
8
+ {
9
+ which = (cmd) =>
10
+ spawnSync(process.platform === 'win32' ? 'where' : 'which', [cmd], { encoding: 'utf8' })
11
+ .status === 0,
12
+ } = {},
13
+ ) {
14
+ const npm = { netlify: 'netlify-cli', vercel: 'vercel', wrangler: 'wrangler' }
15
+ for (const cmd of name === 'flyctl' ? ['flyctl', 'fly'] : [name]) if (which(cmd)) return [cmd]
16
+ if (npm[name]) return ['npx', '--yes', npm[name]]
17
+ throw refused(
18
+ `\`${name}\` isn't installed: ${name === 'flyctl' ? 'https://fly.io/docs/flyctl/install/' : `npm install -g ${npm[name] ?? name}`}`,
19
+ )
20
+ }
@@ -0,0 +1,69 @@
1
+ import { existsSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+
4
+ /** A variable a step reads, and whether its absence stops the deploy (E007). */
5
+ export function missingEnv(root, target, config, env) {
6
+ const ci = !!env.CI
7
+ const linked = {
8
+ netlify: existsSync(join(root, '.netlify/state.json')) || !!config.deploy?.netlify?.site,
9
+ vercel: existsSync(join(root, '.vercel/project.json')),
10
+ }
11
+ // a token: the CLI's own login stands in, except in CI. An id: needed unless the project is linked.
12
+ const token = (k, how) => [k, ci ? 'error' : 'warning', how, ": the CLI's own login is used"]
13
+ const id = (k, known, how) => [k, known ? null : 'error', how, '']
14
+ const need = {
15
+ netlify: [
16
+ token(
17
+ 'NETLIFY_AUTH_TOKEN',
18
+ '`netlify login` here, or a personal access token from User settings → Applications',
19
+ ),
20
+ id(
21
+ 'NETLIFY_SITE_ID',
22
+ linked.netlify,
23
+ '`netlify link`, `deploy.netlify.site`, or the site id from Site configuration',
24
+ ),
25
+ ],
26
+ vercel: [
27
+ token('VERCEL_TOKEN', '`vercel login` here, or a token from Account Settings → Tokens'),
28
+ id('VERCEL_ORG_ID', linked.vercel, '`vercel link` writes it to .vercel/project.json'),
29
+ id('VERCEL_PROJECT_ID', linked.vercel, '`vercel link` writes it to .vercel/project.json'),
30
+ ],
31
+ cloudflare: [
32
+ token('CLOUDFLARE_API_TOKEN', '`wrangler login` here, or a token with Workers Scripts: Edit'),
33
+ id('CLOUDFLARE_ACCOUNT_ID', !ci, 'the account id on the Workers overview page'),
34
+ ],
35
+ fly: [token('FLY_API_TOKEN', '`flyctl auth login` here, or `flyctl tokens create deploy`')],
36
+ render: [id('RENDER_DEPLOY_HOOK_URL', false, "the service's Settings → Deploy Hook")],
37
+ }[target]
38
+ return need
39
+ .filter(([k, severity]) => severity && !env[k])
40
+ .map(([k, severity, how, local]) => ({
41
+ code: 'E007',
42
+ severity,
43
+ file: 'weft.config.ts',
44
+ why: `\`${k}\` is unset${severity === 'warning' ? local : ''}`,
45
+ fix: `${how}. Set it in the environment (never a file)`,
46
+ }))
47
+ }
48
+
49
+ /** The URL a host's CLI printed. */
50
+ export function urlFrom(step, stdout) {
51
+ if (step.url === 'json') {
52
+ try {
53
+ const j = JSON.parse(stdout.slice(stdout.indexOf('{')))
54
+ return j.url ?? j.deploy_url
55
+ } catch {
56
+ return
57
+ }
58
+ }
59
+ if (step.url === 'last-line')
60
+ return stdout
61
+ .trim()
62
+ .split('\n')
63
+ .reverse()
64
+ .find((l) => l.trim().startsWith('https://'))
65
+ ?.trim()
66
+ if (step.url === 'workers') return /https:\/\/[\w.-]+\.workers\.dev/.exec(stdout)?.[0]
67
+ if (step.url === 'fly') return /https:\/\/[\w.-]+\.fly\.dev/.exec(stdout)?.[0]
68
+ return step.url
69
+ }
@@ -0,0 +1,87 @@
1
+ import { host, presetFor } from '../hosts.mjs'
2
+ import { needsOf } from '../needs.mjs'
3
+
4
+ /**
5
+ * The steps `weft deploy` takes for a target, as data: a dry run prints them, `--yes` runs them. A step reads env vars
6
+ * by name; their values are filled in only as it runs.
7
+ */
8
+ export function deploySteps(root, config, { target, prod = false } = {}) {
9
+ const h = host(target)
10
+ const deploy = config.deploy ?? {}
11
+ const runtime = config.runtime ?? 'node'
12
+ const needs = needsOf(root, config)
13
+ const steps = []
14
+ const build = {
15
+ kind: 'build',
16
+ what: `weft build --preset ${presetFor(h, runtime)}`,
17
+ preset: presetFor(h, runtime),
18
+ }
19
+ if (target === 'netlify') {
20
+ const site = process.env.NETLIFY_SITE_ID
21
+ ? []
22
+ : deploy.netlify?.site
23
+ ? ['--site', deploy.netlify.site]
24
+ : []
25
+ steps.push(build, {
26
+ kind: 'run',
27
+ cli: 'netlify',
28
+ args: [
29
+ 'deploy',
30
+ '--dir',
31
+ 'dist',
32
+ '--functions',
33
+ '.netlify/functions-internal',
34
+ '--json',
35
+ ...site,
36
+ ...(prod ? ['--prod'] : []),
37
+ ],
38
+ reads: ['NETLIFY_AUTH_TOKEN', 'NETLIFY_SITE_ID'],
39
+ url: 'json',
40
+ })
41
+ }
42
+ if (target === 'vercel')
43
+ steps.push(build, {
44
+ kind: 'run',
45
+ cli: 'vercel',
46
+ args: ['deploy', '--prebuilt', '--yes', ...(prod ? ['--prod'] : [])],
47
+ token: 'VERCEL_TOKEN',
48
+ reads: ['VERCEL_TOKEN', 'VERCEL_ORG_ID', 'VERCEL_PROJECT_ID'],
49
+ url: 'last-line',
50
+ })
51
+ if (target === 'cloudflare')
52
+ steps.push(build, {
53
+ kind: 'run',
54
+ cli: 'wrangler',
55
+ args: ['deploy'],
56
+ cwd: '.output',
57
+ reads: ['CLOUDFLARE_API_TOKEN', 'CLOUDFLARE_ACCOUNT_ID'],
58
+ url: 'workers',
59
+ })
60
+ if (target === 'fly') {
61
+ // one machine unless a bus reaches the others: a second machine would split the rooms, and a volume is per machine
62
+ const one = needs.memoryBus || deploy.fly?.volume
63
+ steps.push({
64
+ kind: 'run',
65
+ cli: 'flyctl',
66
+ args: [
67
+ 'deploy',
68
+ '--remote-only',
69
+ ...(deploy.fly?.app ? ['--app', deploy.fly.app] : []),
70
+ ...(one ? ['--ha=false'] : []),
71
+ ],
72
+ reads: ['FLY_API_TOKEN'],
73
+ url: deploy.fly?.app ? `https://${deploy.fly.app}.fly.dev` : 'fly',
74
+ })
75
+ }
76
+ if (target === 'render')
77
+ steps.push({
78
+ kind: 'hook',
79
+ what: 'POST $RENDER_DEPLOY_HOOK_URL (Render builds the pushed commit)',
80
+ reads: ['RENDER_DEPLOY_HOOK_URL'],
81
+ url: deploy.render?.service ? `https://${deploy.render.service}.onrender.com` : undefined,
82
+ })
83
+ for (const s of steps)
84
+ if (s.cli)
85
+ s.what = `${s.cli} ${s.args.join(' ')}${s.token ? ` --token $${s.token}` : ''}${s.cwd ? ` (in ${s.cwd}/)` : ''}`
86
+ return steps
87
+ }