@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
@@ -2,7 +2,7 @@
2
2
  // (research 46). Nothing here talks to a provider except `weft deploy --yes`, which runs the host's own CLI.
3
3
 
4
4
  import { spawnSync } from 'node:child_process'
5
- import { existsSync, readFileSync, writeFileSync } from 'node:fs'
5
+ import { readFileSync, writeFileSync } from 'node:fs'
6
6
  import { join, relative } from 'node:path'
7
7
  import ts from 'typescript'
8
8
  import { diagnostic, locate } from '../diag.mjs'
@@ -10,70 +10,138 @@ import { findConfig, isDataConfig } from '../load-config.mjs'
10
10
  import { cdnFindings, writtenSecrets } from './cdn.mjs'
11
11
  import { fileStates, filesFor, managerOf, writeFiles } from './files.mjs'
12
12
  import { fit, hostsReport } from './fit.mjs'
13
- import { HOSTS, TARGETS, host, presetFor } from './hosts.mjs'
13
+ import { HOSTS, TARGETS, host } from './hosts.mjs'
14
14
  import { needsOf } from './needs.mjs'
15
+ import { cliFor } from './index/cli.mjs'
16
+ import { deploySteps } from './index/steps.mjs'
17
+ import { missingEnv, urlFrom } from './index/environment.mjs'
18
+
19
+ export { deploySteps, urlFrom }
15
20
 
16
21
  export { HOSTS, TARGETS, fit, hostsReport, needsOf }
17
22
 
18
- const usage = message => Object.assign(new Error(message), { code: 'E_USAGE' })
19
- const refused = message => Object.assign(new Error(message), { code: 'E_REFUSED' })
23
+ const usage = (message) => Object.assign(new Error(message), { code: 'E_USAGE' })
24
+ const refused = (message) => Object.assign(new Error(message), { code: 'E_REFUSED' })
20
25
 
21
26
  /** A finding as a diagnostic, pointing at the file that shows the need. */
22
27
  function toDiagnostic(root, f) {
23
28
  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 })
29
+ try {
30
+ text = readFileSync(join(root, f.file), 'utf8')
31
+ } catch {}
32
+ const at = f.line
33
+ ? { span: [f.line, 1, f.line, 1] }
34
+ : text && f.needle
35
+ ? locate(text, f.needle)
36
+ : {}
37
+ return diagnostic({
38
+ code: f.code,
39
+ severity: f.severity,
40
+ file: f.file,
41
+ ...at,
42
+ why: f.why,
43
+ help: f.fix,
44
+ })
27
45
  }
28
46
 
29
47
  /** The target this run is about: `--target`, then `deploy.target`, then the host whose preset `--preset` names. */
30
48
  export function targetOf(config, { target, preset } = {}) {
31
- const id = target ?? config.deploy?.target ?? (preset && HOSTS.find(h => h.preset === preset)?.id)
49
+ const id =
50
+ target ?? config.deploy?.target ?? (preset && HOSTS.find((h) => h.preset === preset)?.id)
32
51
  if (id && !host(id)) throw usage(`unknown deploy target \`${id}\`: use ${TARGETS.join(', ')}`)
33
52
  return id
34
53
  }
35
54
 
36
55
  /** E001–E004, E006, E008–E014 for `weft check`: the configured target's fit, its files, written secrets, CDN setup. */
37
56
  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' }))
57
+ const out = writtenSecrets(root).map((s) => ({
58
+ code: 'E008',
59
+ severity: 'error',
60
+ file: s.file,
61
+ line: s.line,
62
+ 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`,
63
+ fix: "Move it to the environment (the host's settings, or GitHub's secrets for the workflow), and rotate it: it has been seen",
64
+ }))
39
65
  let id
40
- try { id = targetOf(config, { preset }) } catch { id = undefined }
66
+ try {
67
+ id = targetOf(config, { preset })
68
+ } catch {
69
+ id = undefined
70
+ }
41
71
  const deploy = config.deploy ?? {}
42
72
  if (id) {
43
73
  out.push(...fit(needsOf(root, config), host(id), { deploy }).findings)
44
74
  if (deploy.target === id && host(id).deploys) {
45
75
  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)' })
76
+ if (f.state === 'missing' || f.state === 'stale')
77
+ out.push({
78
+ code: 'E006',
79
+ severity: 'warning',
80
+ file: f.path,
81
+ 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'}`,
82
+ fix: '`weft deploy init` writes it (a file edited by hand is left alone)',
83
+ })
47
84
  }
48
85
  }
49
86
  }
50
87
  out.push(...cdnFindings(config))
51
- return out.map(f => toDiagnostic(root, f))
88
+ return out.map((f) => toDiagnostic(root, f))
52
89
  }
53
90
 
54
91
  /** What the generators read. */
55
92
  export function initContext(root, config, target) {
56
- return { target, deploy: config.deploy ?? {}, needs: needsOf(root, config), runtime: config.runtime ?? 'node', manager: managerOf(root) }
93
+ return {
94
+ target,
95
+ deploy: config.deploy ?? {},
96
+ needs: needsOf(root, config),
97
+ runtime: config.runtime ?? 'node',
98
+ manager: managerOf(root),
99
+ }
57
100
  }
58
101
 
59
102
  /** `weft deploy hosts`, and the `deploy` part of `weft doctor`: every host's fit and the suggestion (E005 when none is set). */
60
103
  export function hostsCommand(root, config) {
61
104
  const needs = needsOf(root, config)
62
105
  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}\`` })]
106
+ const used = Object.entries(needs)
107
+ .filter(([k, v]) => k !== 'runtime' && v)
108
+ .map(([k, v]) => ({ need: k, ...v }))
109
+ const diagnostics = config.deploy?.target
110
+ ? []
111
+ : [
112
+ diagnostic({
113
+ code: 'E005',
114
+ severity: 'info',
115
+ file: findConfig(root) ? relative(root, findConfig(root)) : 'weft.config.ts',
116
+ why: `No \`deploy.target\`. ${report.fits
117
+ .filter((f) => f.fits && f.deploys)
118
+ .map((f) => f.name)
119
+ .join(
120
+ ', ',
121
+ )} can run this app; weft suggests ${host(report.suggestion.host).name}: ${report.suggestion.why}.`,
122
+ help: `\`weft deploy init ${report.suggestion.host}\``,
123
+ }),
124
+ ]
65
125
  return { needs: used, ...report, target: config.deploy?.target ?? null, diagnostics }
66
126
  }
67
127
 
68
128
  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'}`, '']
129
+ const lines = [
130
+ `needs: ${r.needs.length ? r.needs.map((n) => `${n.what} (${n.file})`).join(', ') : "nothing a CDN with functions can't give"}`,
131
+ '',
132
+ ]
70
133
  for (const f of r.fits) {
71
134
  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)'}`)
135
+ lines.push(
136
+ `${mark} ${f.host.padEnd(11)} ${f.name}${f.deploys ? '' : " (fit only: `weft deploy` doesn't drive it yet)"}`,
137
+ )
73
138
  for (const x of f.findings) lines.push(` ${x.code} ${x.why}. Fix: ${x.fix}`)
74
139
  lines.push(` cost: ${f.cost} (${f.pricing})`)
75
140
  }
76
- lines.push('', `suggested: ${r.suggestion.host}, because ${r.suggestion.why}${r.suggestion.alternative ? `. Next best: ${r.suggestion.alternative}` : ''}`)
141
+ lines.push(
142
+ '',
143
+ `suggested: ${r.suggestion.host}, because ${r.suggestion.why}${r.suggestion.alternative ? `. Next best: ${r.suggestion.alternative}` : ''}`,
144
+ )
77
145
  return lines.join('\n')
78
146
  }
79
147
 
@@ -89,33 +157,56 @@ function configObject(sf) {
89
157
  /** 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
158
  export function defaultDeploy(root, target, needs) {
91
159
  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 {}
160
+ try {
161
+ name =
162
+ JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'))
163
+ .name?.replace(/^@[^/]+\//, '')
164
+ .replace(/[^a-z0-9-]/gi, '-')
165
+ .toLowerCase() || name
166
+ } catch {}
93
167
  const kept = needs.sqlite || needs.uploads
94
168
  const per = {
95
- netlify: { site: name }, vercel: { project: name }, cloudflare: { name },
169
+ netlify: { site: name },
170
+ vercel: { project: name },
171
+ cloudflare: { name },
96
172
  fly: { app: name, region: 'iad', ...(kept && { volume: { name: 'data', sizeGb: 1 } }) },
97
173
  render: { service: name, ...(kept && { plan: 'starter', disk: { sizeGb: 1 } }) },
98
174
  }
99
175
  return { target, [target]: per[target], ci: 'github', branch: 'main' }
100
176
  }
101
177
 
102
- const literal = (v, pad = ' ') => {
103
- if (v && typeof v === 'object') return `{ ${Object.entries(v).map(([k, x]) => `${k}: ${literal(x, pad)}`).join(', ')} }`
178
+ const literal = (v) => {
179
+ if (v && typeof v === 'object')
180
+ return `{ ${Object.entries(v)
181
+ .map(([k, x]) => `${k}: ${literal(x)}`)
182
+ .join(', ')} }`
104
183
  return typeof v === 'string' ? `'${v}'` : String(v)
105
184
  }
106
185
 
107
186
  /** The config with `deploy` added. Refuses to touch one that already has it, or that isn't a plain object. */
108
187
  export function withDeploy(file, text, deploy) {
109
188
  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`
189
+ if (file.endsWith('.json'))
190
+ return `${JSON.stringify({ ...JSON.parse(text), deploy }, null, 2)}\n`
191
+ return `${text.replace(/\n*$/, '\n')}deploy:\n${Object.entries(deploy)
192
+ .map(([k, v]) => ` ${k}: ${JSON.stringify(v)}`)
193
+ .join('\n')}\n`
112
194
  }
113
195
  const sf = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true)
114
196
  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.`)
197
+ if (!object)
198
+ throw refused(
199
+ `${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.`,
200
+ )
116
201
  const block = `deploy: ${literal(deploy)},`
117
202
  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)
203
+ return last
204
+ ? text.slice(0, object.properties.end) +
205
+ `${object.properties.hasTrailingComma ? '' : ','}\n ${block}` +
206
+ text.slice(object.properties.end)
207
+ : text.slice(0, object.getStart(sf) + 1) +
208
+ `\n ${block}\n` +
209
+ text.slice(object.getStart(sf) + 1)
119
210
  }
120
211
 
121
212
  /**
@@ -126,12 +217,21 @@ export function initCommand(root, config, { target, dryRun = false, force = fals
126
217
  const needs = needsOf(root, config)
127
218
  const id = targetOf(config, { target }) ?? hostsReport(needs).suggestion.host
128
219
  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.`)
220
+ if (!h.deploys)
221
+ throw usage(
222
+ `\`weft deploy\` doesn't drive ${h.name} yet (research 46 §11): ${TARGETS.join(', ')} it does`,
223
+ )
224
+ if (config.deploy?.target && target && config.deploy.target !== target)
225
+ throw refused(
226
+ `\`deploy.target\` is \`${config.deploy.target}\` in the config. Change it there to move to \`${target}\`, then run this again. Nothing was changed.`,
227
+ )
131
228
  const deploy = config.deploy ?? defaultDeploy(root, id, needs)
132
229
  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.`)
230
+ const blocking = result.findings.filter((f) => f.severity === 'error' && !unsafe?.allows(f))
231
+ if (blocking.length)
232
+ throw refused(
233
+ `${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.`,
234
+ )
135
235
  const ctx = { ...initContext(root, { ...config, deploy }, id) }
136
236
  const states = fileStates(root, filesFor(ctx))
137
237
  const configFile = findConfig(root)
@@ -139,111 +239,109 @@ export function initCommand(root, config, { target, dryRun = false, force = fals
139
239
  let written = []
140
240
  if (!config.deploy) {
141
241
  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"
242
+ const text = configFile
243
+ ? readFileSync(configFile, 'utf8')
244
+ : "import { defineConfig } from '@weft-js/core/config'\n\nexport default defineConfig({})\n"
143
245
  actions.push(`${relative(root, file)}: ${configFile ? '' : '(new) '}deploy: ${literal(deploy)}`)
144
246
  if (!dryRun) writeFileSync(file, withDeploy(file, text, deploy))
145
247
  }
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]}`)
248
+ for (const f of states)
249
+ actions.push(
250
+ `${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]}`,
251
+ )
147
252
  if (!dryRun) written = writeFiles(root, states, { force })
148
253
  const next = [
149
254
  ...{
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)`],
255
+ netlify: [
256
+ `netlify sites:create --name ${deploy.netlify?.site ?? '<name>'} (once; or link an existing site with \`netlify link\`)`,
257
+ ],
258
+ vercel: [
259
+ `vercel link --project ${deploy.vercel?.project ?? '<name>'} (once: writes .vercel/project.json with the org and project ids)`,
260
+ ],
152
261
  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'],
262
+ fly: [
263
+ `flyctl apps create ${deploy.fly?.app ?? '<name>'} (once; billed per running machine)`,
264
+ ...(deploy.fly?.volume
265
+ ? [
266
+ `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)`,
267
+ ]
268
+ : []),
269
+ ],
270
+ render: [
271
+ 'Create the service from render.yaml: Render dashboard → New → Blueprint, on this repository (once)',
272
+ "Copy the service's deploy hook (Settings → Deploy Hook) into RENDER_DEPLOY_HOOK_URL",
273
+ ],
155
274
  }[id],
156
- ...((deploy.ci ?? 'github') === 'github' ? [`gh secret set ${h.env.join(' / ')} (each one, from the host's dashboard: weft never writes them)`] : []),
275
+ ...((deploy.ci ?? 'github') === 'github'
276
+ ? [
277
+ `gh secret set ${h.env.join(' / ')} (each one, from the host's dashboard: weft never writes them)`,
278
+ ]
279
+ : []),
157
280
  `weft deploy (a dry run: shows what --yes would run)`,
158
281
  ]
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' })
282
+ return {
283
+ ok: true,
284
+ target: id,
285
+ dryRun,
286
+ actions,
287
+ written,
288
+ next,
289
+ findings: result.findings.map((f) => toDiagnostic(root, f)),
191
290
  }
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
291
  }
224
292
 
225
293
  /**
226
294
  * `weft deploy`: fit, then the steps. Without `yes` nothing runs: the result says what would. `build` is `weft build`
227
295
  * in-process (start.mjs's buildCommand), `exec` runs a host CLI; both are injectable for tests.
228
296
  */
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' }) } = {}) {
297
+ export async function deployCommand(
298
+ root,
299
+ config,
300
+ {
301
+ target,
302
+ prod = false,
303
+ yes = false,
304
+ unsafe,
305
+ env = process.env,
306
+ build,
307
+ exec = defaultExec,
308
+ which,
309
+ post = (url) => fetch(url, { method: 'POST' }),
310
+ } = {},
311
+ ) {
230
312
  const id = targetOf(config, { target })
231
313
  if (!id) {
232
314
  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}`)
315
+ throw refused(
316
+ `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}`,
317
+ )
234
318
  }
235
319
  const h = host(id)
236
320
  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`)
321
+ if (h.kind === 'server' && !prod)
322
+ throw usage(`${h.name} has no preview deploys: pass --prod to deploy production`)
238
323
  const needs = needsOf(root, config)
239
324
  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 })
325
+ const blocking = findings.filter((f) => f.severity === 'error' && !unsafe?.allows(f))
326
+ if (blocking.length)
327
+ throw refused(
328
+ `${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(',')}`,
329
+ )
330
+ const steps = deploySteps(root, config, { target: id, prod })
243
331
  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')
332
+ const diagnostics = [...findings, ...envFindings, ...cdnFindings(config, { env })].map((f) =>
333
+ toDiagnostic(root, f),
334
+ )
335
+ if (!yes)
336
+ return {
337
+ ok: true,
338
+ dryRun: true,
339
+ target: id,
340
+ prod,
341
+ steps: steps.map((s) => ({ what: s.what, reads: s.reads ?? [] })),
342
+ diagnostics,
343
+ }
344
+ const stop = envFindings.find((f) => f.severity === 'error')
247
345
  if (stop) throw refused(`E007: ${stop.why}. ${stop.fix}.`)
248
346
  let url
249
347
  for (const s of steps) {
@@ -253,13 +351,23 @@ export async function deployCommand(root, config, { target, prod = false, yes =
253
351
  if (!built.ok) throw refused('weft build failed: nothing was deployed. Fix the errors above.')
254
352
  } else if (s.kind === 'hook') {
255
353
  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.`)
354
+ if (!res.ok)
355
+ throw refused(
356
+ `Render's deploy hook answered ${res.status}: nothing was deployed. Check RENDER_DEPLOY_HOOK_URL.`,
357
+ )
257
358
  url = s.url
258
359
  } else {
259
360
  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')}`)
361
+ const args = [
362
+ ...pre,
363
+ ...s.args,
364
+ ...(s.token && env[s.token] ? ['--token', env[s.token]] : []),
365
+ ]
366
+ const r = await Promise.resolve(exec(cmd, args, { cwd: join(root, s.cwd ?? ''), env }))
367
+ if (r.code !== 0)
368
+ throw refused(
369
+ `${s.cli} failed (exit ${r.code}): nothing more was run.\n${(r.stderr || r.stdout).trim().split('\n').slice(-15).join('\n')}`,
370
+ )
263
371
  url = urlFrom(s, r.stdout) ?? url
264
372
  }
265
373
  }
@@ -268,6 +376,12 @@ export async function deployCommand(root, config, { target, prod = false, yes =
268
376
 
269
377
  /** A host CLI with its progress on stderr, as it goes, and stdout kept for the URL. */
270
378
  function defaultExec(cmd, args, { cwd, env }) {
271
- const r = spawnSync(cmd, args, { cwd, env, encoding: 'utf8', stdio: ['inherit', 'pipe', 'inherit'], maxBuffer: 64 * 1024 * 1024 })
379
+ const r = spawnSync(cmd, args, {
380
+ cwd,
381
+ env,
382
+ encoding: 'utf8',
383
+ stdio: ['inherit', 'pipe', 'inherit'],
384
+ maxBuffer: 64 * 1024 * 1024,
385
+ })
272
386
  return { code: r.status ?? 1, stdout: r.stdout ?? '', stderr: r.error?.message ?? '' }
273
387
  }