@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.
- package/README.md +38 -14
- package/bin/weft.mjs +8 -1
- package/docs/async.md +16 -6
- package/docs/browser.md +22 -8
- package/docs/config.md +39 -9
- package/docs/core.md +30 -0
- package/docs/deploy.md +33 -8
- package/docs/design.md +12 -3
- package/docs/entrypoints.md +79 -0
- package/docs/fonts.md +12 -2
- package/docs/icons.md +13 -7
- package/docs/lists.md +3 -1
- package/docs/meta.md +13 -3
- package/docs/plugins.md +32 -9
- package/docs/props.md +13 -2
- package/docs/reactivity.md +26 -0
- package/docs/rules.md +19 -12
- package/docs/rust.md +47 -17
- package/docs/start-short.md +3 -2
- package/docs/start.md +226 -52
- package/docs/state.md +33 -7
- package/docs/style.md +31 -8
- package/docs/surface.md +61 -15
- package/docs/testing.md +15 -5
- package/docs/theme.md +182 -0
- package/docs/ui-state.md +19 -6
- package/docs/verify.md +13 -0
- package/package.json +13 -4
- package/src/add/config.mjs +93 -0
- package/src/add/errors.mjs +2 -0
- package/src/add/setup.mjs +88 -0
- package/src/add-rust.mjs +82 -27
- package/src/add.mjs +209 -181
- package/src/agents-md.mjs +144 -43
- package/src/alias.mjs +16 -5
- package/src/audit/page.mjs +313 -0
- package/src/audit/report.mjs +69 -0
- package/src/audit/utils.mjs +5 -0
- package/src/audit.mjs +159 -206
- package/src/browser/cli.mjs +24 -5
- package/src/browser/client.mjs +189 -25
- package/src/browser/commands.mjs +166 -51
- package/src/catalog/audit.mjs +129 -0
- package/src/catalog/auth.mjs +40 -0
- package/src/catalog/compiler.mjs +337 -0
- package/src/catalog/contracts.mjs +18 -0
- package/src/catalog/database.mjs +52 -0
- package/src/catalog/deploy.mjs +117 -0
- package/src/catalog/design.mjs +159 -0
- package/src/catalog/forms.mjs +25 -0
- package/src/catalog/hydration.mjs +29 -0
- package/src/catalog/icons.mjs +24 -0
- package/src/catalog/media.mjs +163 -0
- package/src/catalog/metadata.mjs +116 -0
- package/src/catalog/plugins.mjs +228 -0
- package/src/catalog/realtime.mjs +195 -0
- package/src/catalog/rust.mjs +75 -0
- package/src/catalog/server-functions.mjs +92 -0
- package/src/catalog/state.mjs +39 -0
- package/src/catalog/style-groups.mjs +54 -0
- package/src/catalog/tests.mjs +127 -0
- package/src/catalog/text.mjs +200 -0
- package/src/catalog/ui-state.mjs +42 -0
- package/src/catalog.mjs +67 -460
- package/src/check/runtime.mjs +280 -0
- package/src/check.mjs +198 -208
- package/src/cli/authoring.mjs +213 -0
- package/src/cli/project.mjs +302 -0
- package/src/cli.mjs +222 -250
- package/src/config.mjs +1 -1
- package/src/contributions.mjs +75 -25
- package/src/db-layer.mjs +13 -5
- package/src/deploy/cdn.mjs +94 -16
- package/src/deploy/files.mjs +100 -21
- package/src/deploy/fit.mjs +127 -23
- package/src/deploy/hosts.mjs +90 -20
- package/src/deploy/index/cli.mjs +20 -0
- package/src/deploy/index/environment.mjs +69 -0
- package/src/deploy/index/steps.mjs +87 -0
- package/src/deploy/index.mjs +228 -114
- package/src/deploy/needs.mjs +88 -31
- package/src/design-checks/accessible-names.mjs +62 -0
- package/src/design-checks/contrast.mjs +43 -0
- package/src/design-checks/csp.mjs +60 -0
- package/src/design-checks/files.mjs +59 -0
- package/src/design-checks/heading-links.mjs +61 -0
- package/src/design-checks/interaction.mjs +65 -0
- package/src/design-checks/jsx.mjs +67 -0
- package/src/design-checks/layout-values.mjs +68 -0
- package/src/design-checks/native-controls.mjs +48 -0
- package/src/design-checks/outline.mjs +39 -0
- package/src/design-checks/reporter.mjs +37 -0
- package/src/design-checks/rules.mjs +274 -0
- package/src/design-checks/values.mjs +262 -0
- package/src/design-checks.mjs +10 -400
- package/src/diag.mjs +32 -8
- package/src/docs.mjs +138 -43
- package/src/doctor.mjs +20 -8
- package/src/files.mjs +18 -5
- package/src/fix.mjs +10 -2
- package/src/gate/after.mjs +40 -0
- package/src/gate/before.mjs +54 -0
- package/src/gate/classify.mjs +72 -0
- package/src/gate/mutation.mjs +209 -0
- package/src/gate/resolve-cases.mjs +22 -0
- package/src/gate/result.mjs +63 -0
- package/src/gate/run.mjs +75 -0
- package/src/gate.mjs +104 -202
- package/src/infer/box-props.cjs +140 -0
- package/src/infer/core-boxes.cjs +46 -0
- package/src/infer/derived-writes.cjs +95 -0
- package/src/infer/engine/checks.cjs +193 -0
- package/src/infer/engine/flow.cjs +293 -0
- package/src/infer/engine/report.cjs +158 -0
- package/src/infer/engine/utils.cjs +15 -0
- package/src/infer/engine.cjs +108 -368
- package/src/infer/facts/sites.cjs +114 -0
- package/src/infer/facts.cjs +210 -117
- package/src/infer/hosts.cjs +69 -22
- package/src/infer/lsp.mjs +149 -40
- package/src/infer/session.cjs +73 -17
- package/src/infer/shadow.cjs +13 -5
- package/src/infer/worker.cjs +57 -9
- package/src/jsx-scan.mjs +63 -21
- package/src/load-config/schema.mjs +281 -0
- package/src/load-config.mjs +137 -138
- package/src/map.mjs +93 -26
- package/src/mcp/browser-tools.mjs +155 -0
- package/src/mcp.mjs +147 -69
- package/src/meta-table.mjs +43 -8
- package/src/mutate.mjs +120 -36
- package/src/plugin-checks.mjs +52 -23
- package/src/plugins.mjs +125 -39
- package/src/project.mjs +170 -79
- package/src/props-checks.mjs +24 -6
- package/src/props-listener.mjs +27 -9
- package/src/react-checks.mjs +35 -4
- package/src/server-state-checks.mjs +34 -0
- package/src/share-checks.mjs +121 -37
- package/src/site.mjs +33 -6
- package/src/slots.mjs +104 -19
- package/src/start/build.mjs +116 -0
- package/src/start/routes.mjs +38 -0
- package/src/start.mjs +193 -95
- package/src/state-checks/core-state.mjs +223 -0
- package/src/state-checks/modules.mjs +17 -0
- package/src/state-checks/scan.mjs +232 -0
- package/src/state-checks/server-graph.mjs +65 -0
- package/src/state-checks.mjs +109 -133
- package/src/style/find.mjs +121 -0
- package/src/style.mjs +133 -127
- package/src/surface-checks.mjs +212 -46
- package/src/tailwind.mjs +5 -4
- package/src/tasks.mjs +189 -27
- package/src/test-rules/context.mjs +109 -0
- package/src/test-rules/file-rules.mjs +279 -0
- package/src/test-rules/rule-helpers.mjs +244 -0
- package/src/test-rules/syntax.mjs +223 -0
- package/src/test-rules/test-rules.mjs +127 -0
- package/src/testdiff.mjs +94 -32
- package/src/tests.mjs +31 -483
- package/src/text-checks.mjs +229 -69
- package/src/tokens.mjs +12 -4
- package/src/trust/scan.mjs +95 -0
- package/src/trust.mjs +214 -102
- package/src/weft.mjs +41 -9
- package/src/workspace.mjs +96 -19
- package/ts-plugin/context.js +57 -0
- package/ts-plugin/index.js +4 -124
- package/ts-plugin/mapping.js +65 -0
- package/ts-plugin/proxy.js +144 -0
package/src/deploy/index.mjs
CHANGED
|
@@ -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 {
|
|
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
|
|
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 {
|
|
25
|
-
|
|
26
|
-
|
|
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 =
|
|
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 => ({
|
|
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 {
|
|
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')
|
|
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 {
|
|
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)
|
|
64
|
-
|
|
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 = [
|
|
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(
|
|
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(
|
|
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 {
|
|
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 },
|
|
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
|
|
103
|
-
if (v && typeof v === 'object')
|
|
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'))
|
|
111
|
-
|
|
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)
|
|
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
|
|
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)
|
|
130
|
-
|
|
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)
|
|
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
|
|
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)
|
|
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: [
|
|
151
|
-
|
|
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: [
|
|
154
|
-
|
|
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'
|
|
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 {
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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(
|
|
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(
|
|
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)
|
|
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)
|
|
242
|
-
|
|
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 =>
|
|
245
|
-
|
|
246
|
-
|
|
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)
|
|
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 = [
|
|
261
|
-
|
|
262
|
-
|
|
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, {
|
|
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
|
}
|