@weft-js/cli 0.1.0 → 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 +108 -0
- 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 +174 -188
- 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 +68 -444
- package/src/check/runtime.mjs +280 -0
- package/src/check.mjs +199 -207
- package/src/cli/authoring.mjs +213 -0
- package/src/cli/project.mjs +302 -0
- package/src/cli.mjs +228 -224
- package/src/config.mjs +1 -1
- package/src/contributions.mjs +75 -25
- package/src/db-layer.mjs +13 -5
- package/src/deploy/cdn.mjs +142 -0
- package/src/deploy/files.mjs +266 -0
- package/src/deploy/fit.mjs +166 -0
- package/src/deploy/hosts.mjs +126 -0
- 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 +387 -0
- package/src/deploy/needs.mjs +149 -0
- 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 -42
- 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 +144 -116
- 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 +194 -92
- 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/db-layer.mjs
CHANGED
|
@@ -6,12 +6,15 @@ import { loadConfig } from './load-config.mjs'
|
|
|
6
6
|
|
|
7
7
|
/** The app's data layer: the plugin that declares one, or Drizzle, which is built in. Null with no database. */
|
|
8
8
|
export async function dbLayer(root, loaded) {
|
|
9
|
-
const { contributions = [] } = loaded ?? await loadConfig(root)
|
|
10
|
-
const plugin = contributions.find(c => c.manifest.db)?.manifest.db
|
|
9
|
+
const { contributions = [] } = loaded ?? (await loadConfig(root))
|
|
10
|
+
const plugin = contributions.find((c) => c.manifest.db)?.manifest.db
|
|
11
11
|
if (plugin) return plugin
|
|
12
12
|
// Drizzle has no plugin to say so: its config file, or a client from `@weft-js/start/db/*`, does.
|
|
13
13
|
const entry = join(root, 'src/db/index.server.ts')
|
|
14
|
-
return existsSync(join(root, 'drizzle.config.ts')) ||
|
|
14
|
+
return existsSync(join(root, 'drizzle.config.ts')) ||
|
|
15
|
+
(existsSync(entry) && readFileSync(entry, 'utf8').includes('@weft-js/start/db/'))
|
|
16
|
+
? DRIZZLE
|
|
17
|
+
: null
|
|
15
18
|
}
|
|
16
19
|
|
|
17
20
|
const DRIZZLE = {
|
|
@@ -24,10 +27,15 @@ const DRIZZLE = {
|
|
|
24
27
|
const req = createRequire(join(root, 'package.json'))
|
|
25
28
|
bin = join(dirname(req.resolve('drizzle-kit')), 'bin.cjs')
|
|
26
29
|
} catch {
|
|
27
|
-
throw Object.assign(new Error('drizzle-kit is not installed in this project'), {
|
|
30
|
+
throw Object.assign(new Error('drizzle-kit is not installed in this project'), {
|
|
31
|
+
code: 'E_USAGE',
|
|
32
|
+
})
|
|
28
33
|
}
|
|
29
34
|
const r = spawnSync(process.execPath, [bin, 'generate'], { cwd: root, encoding: 'utf8' })
|
|
30
|
-
if (r.status !== 0)
|
|
35
|
+
if (r.status !== 0)
|
|
36
|
+
throw Object.assign(new Error(`drizzle-kit generate failed:\n${r.stdout}${r.stderr}`), {
|
|
37
|
+
code: 'E_INTERNAL',
|
|
38
|
+
})
|
|
31
39
|
return r.stdout.trim()
|
|
32
40
|
},
|
|
33
41
|
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// What a half-set-up deploy looks like, without touching a provider (research 46 §8, §9): a credential written into a
|
|
2
|
+
// file (E008), Cloudflare in front of nothing or in front of a CDN (E010, E011), and Cloudinary without what it needs
|
|
3
|
+
// (E012–E014).
|
|
4
|
+
|
|
5
|
+
import { existsSync, readFileSync, readdirSync } from 'node:fs'
|
|
6
|
+
import { join } from 'node:path'
|
|
7
|
+
import { host } from './hosts.mjs'
|
|
8
|
+
|
|
9
|
+
/** Credentials with a shape of their own, and a value assigned to a key that names one. */
|
|
10
|
+
const SHAPED = [
|
|
11
|
+
[/\bnfp_[A-Za-z0-9]{20,}/, 'a Netlify token'],
|
|
12
|
+
[/\bFlyV1\s+fm\d_[A-Za-z0-9+/=_-]{20,}/, 'a Fly token'],
|
|
13
|
+
[
|
|
14
|
+
/api\.render\.com\/deploy\/srv-[\w-]+\?key=[\w-]+/,
|
|
15
|
+
'a Render deploy hook (its key deploys the service)',
|
|
16
|
+
],
|
|
17
|
+
[/cloudinary:\/\/\d+:[^@\s'"]+@/, 'a Cloudinary URL with its secret'],
|
|
18
|
+
]
|
|
19
|
+
const KEYED =
|
|
20
|
+
/\b[\w-]*(token|secret|api[_-]?key|password)[\w-]*["']?\s*[:=]\s*["']([^"'\s$]{16,})["']/i
|
|
21
|
+
|
|
22
|
+
const FILES = [
|
|
23
|
+
'weft.config.ts',
|
|
24
|
+
'weft.config.mts',
|
|
25
|
+
'weft.config.js',
|
|
26
|
+
'weft.config.mjs',
|
|
27
|
+
'weft.config.json',
|
|
28
|
+
'weft.config.yaml',
|
|
29
|
+
'weft.config.yml',
|
|
30
|
+
'netlify.toml',
|
|
31
|
+
'vercel.json',
|
|
32
|
+
'wrangler.toml',
|
|
33
|
+
'fly.toml',
|
|
34
|
+
'render.yaml',
|
|
35
|
+
'Dockerfile',
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
/** E008: a credential in a file that gets committed, comments included. An env var's name, or `${{ secrets.X }}`, is never one. */
|
|
39
|
+
export function writtenSecrets(root) {
|
|
40
|
+
const out = []
|
|
41
|
+
const wf = join(root, '.github/workflows')
|
|
42
|
+
const files = [
|
|
43
|
+
...FILES,
|
|
44
|
+
...(existsSync(wf)
|
|
45
|
+
? readdirSync(wf)
|
|
46
|
+
.filter((f) => /\.ya?ml$/.test(f))
|
|
47
|
+
.map((f) => `.github/workflows/${f}`)
|
|
48
|
+
: []),
|
|
49
|
+
]
|
|
50
|
+
for (const file of files) {
|
|
51
|
+
let text
|
|
52
|
+
try {
|
|
53
|
+
text = readFileSync(join(root, file), 'utf8')
|
|
54
|
+
} catch {
|
|
55
|
+
continue
|
|
56
|
+
}
|
|
57
|
+
text.split('\n').forEach((line, i) => {
|
|
58
|
+
const shaped = SHAPED.find(([re]) => re.test(line))
|
|
59
|
+
const keyed = !shaped && KEYED.exec(line)
|
|
60
|
+
if (shaped || keyed)
|
|
61
|
+
out.push({
|
|
62
|
+
file,
|
|
63
|
+
line: i + 1,
|
|
64
|
+
what: shaped ? shaped[1] : `a value for \`${keyed[0].split(/["':=\s]/)[0]}\``,
|
|
65
|
+
})
|
|
66
|
+
})
|
|
67
|
+
}
|
|
68
|
+
return out
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Hosts whose own domain says the proxy isn't in front. */
|
|
72
|
+
const OWN = /\.(fly\.dev|onrender\.com|netlify\.app|vercel\.app|workers\.dev|pages\.dev)$/
|
|
73
|
+
|
|
74
|
+
/** E010–E014 from the config, and the environment when `env` is given (a deploy or `weft doctor`). */
|
|
75
|
+
export function cdnFindings(config = {}, { env } = {}) {
|
|
76
|
+
const out = []
|
|
77
|
+
const cdn = config.deploy?.cdn ?? {}
|
|
78
|
+
const target = config.deploy?.target && host(config.deploy.target)
|
|
79
|
+
const add = (code, severity, why, fix) =>
|
|
80
|
+
out.push({ code, severity, file: 'weft.config.ts', why, fix })
|
|
81
|
+
if (cdn.proxy === 'cloudflare') {
|
|
82
|
+
let url
|
|
83
|
+
try {
|
|
84
|
+
url = config.site?.url && new URL(config.site.url).hostname
|
|
85
|
+
} catch {}
|
|
86
|
+
if (url && OWN.test(url))
|
|
87
|
+
add(
|
|
88
|
+
'E010',
|
|
89
|
+
'warning',
|
|
90
|
+
`\`deploy.cdn.proxy\` is Cloudflare, and \`site.url\` is \`${url}\`, the host's own domain: nothing goes through the proxy, and canonical URLs point past it`,
|
|
91
|
+
'Set `site.url` to the domain Cloudflare proxies, with its DNS record proxied (orange cloud) to the host',
|
|
92
|
+
)
|
|
93
|
+
if (target && target.kind !== 'server')
|
|
94
|
+
add(
|
|
95
|
+
'E011',
|
|
96
|
+
'warning',
|
|
97
|
+
`\`deploy.cdn.proxy\` puts Cloudflare in front of ${target.name}, which is a CDN already: two caches, and a deploy purges only its own`,
|
|
98
|
+
'Drop `cdn.proxy`, or deploy to a server host (`fly`, `render`) behind it',
|
|
99
|
+
)
|
|
100
|
+
}
|
|
101
|
+
const images = cdn.images
|
|
102
|
+
if (images?.provider === 'cloudinary') {
|
|
103
|
+
const plugins = (config.plugins ?? []).map((p) => p?.name ?? p)
|
|
104
|
+
if (!plugins.some((n) => typeof n === 'string' && n.includes('plugin-media')))
|
|
105
|
+
add(
|
|
106
|
+
'E012',
|
|
107
|
+
'error',
|
|
108
|
+
'`deploy.cdn.images` is Cloudinary, and `@weft-js/plugin-media` is not in `plugins`: nothing would send it an image',
|
|
109
|
+
'`weft add media`, or drop `cdn.images`',
|
|
110
|
+
)
|
|
111
|
+
const given = env?.CLOUDINARY_URL
|
|
112
|
+
const cloud = given && /@([\w-]+)\/?$/.exec(given)?.[1]
|
|
113
|
+
if (!images.cloud && !given)
|
|
114
|
+
add(
|
|
115
|
+
'E013',
|
|
116
|
+
'error',
|
|
117
|
+
'`deploy.cdn.images` names no `cloud`, and `CLOUDINARY_URL` is unset: there is no account to send images to',
|
|
118
|
+
"Set `cdn.images.cloud` to the cloud's name, and `CLOUDINARY_URL` in the host's environment (never in a file)",
|
|
119
|
+
)
|
|
120
|
+
else if (cloud && images.cloud && cloud !== images.cloud)
|
|
121
|
+
add(
|
|
122
|
+
'E013',
|
|
123
|
+
'error',
|
|
124
|
+
`\`cdn.images.cloud\` is \`${images.cloud}\`, and \`CLOUDINARY_URL\` here names \`${cloud}\`: one of them is another account`,
|
|
125
|
+
'Make them name the same cloud',
|
|
126
|
+
)
|
|
127
|
+
else if (env && !given)
|
|
128
|
+
add(
|
|
129
|
+
'E013',
|
|
130
|
+
'warning',
|
|
131
|
+
"`CLOUDINARY_URL` is unset here: the server needs it in the host's environment",
|
|
132
|
+
"Set it in the host's environment settings, never in a file",
|
|
133
|
+
)
|
|
134
|
+
add(
|
|
135
|
+
'E014',
|
|
136
|
+
'info',
|
|
137
|
+
"`cdn.images` is checked, but this weft doesn't send uploads to Cloudinary yet: they still go to `media({ server: { dir } })`",
|
|
138
|
+
'Nothing yet: research 46 §9 is the plan. On a function host, uploads need a server host with a disk until then (E003)',
|
|
139
|
+
)
|
|
140
|
+
}
|
|
141
|
+
return out
|
|
142
|
+
}
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
// The files a host needs, written by `weft deploy init` and compared by `weft check` (research 46 §7). Each text file
|
|
2
|
+
// starts with `# weft deploy init <target>, sha <hash>`, the hash of what follows: a matching hash that differs from what
|
|
3
|
+
// init writes now is stale (E006), and a hash that doesn't match was edited by hand, so weft leaves it alone.
|
|
4
|
+
|
|
5
|
+
import { createHash } from 'node:crypto'
|
|
6
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
7
|
+
import { dirname, join } from 'node:path'
|
|
8
|
+
import { host, presetFor } from './hosts.mjs'
|
|
9
|
+
|
|
10
|
+
/** The lockfile's manager: how the image and the workflow install. */
|
|
11
|
+
const MANAGERS = [
|
|
12
|
+
[
|
|
13
|
+
'pnpm-lock.yaml',
|
|
14
|
+
{
|
|
15
|
+
name: 'pnpm',
|
|
16
|
+
install: 'corepack enable && pnpm install --frozen-lockfile',
|
|
17
|
+
x: 'pnpm exec',
|
|
18
|
+
cache: 'pnpm',
|
|
19
|
+
},
|
|
20
|
+
],
|
|
21
|
+
[
|
|
22
|
+
'yarn.lock',
|
|
23
|
+
{
|
|
24
|
+
name: 'yarn',
|
|
25
|
+
install: 'corepack enable && yarn install --immutable',
|
|
26
|
+
x: 'yarn',
|
|
27
|
+
cache: 'yarn',
|
|
28
|
+
},
|
|
29
|
+
],
|
|
30
|
+
['bun.lock', { name: 'bun', install: 'bun install --frozen-lockfile', x: 'bunx', cache: '' }],
|
|
31
|
+
['package-lock.json', { name: 'npm', install: 'npm ci', x: 'npx', cache: 'npm' }],
|
|
32
|
+
]
|
|
33
|
+
export const managerOf = (root) =>
|
|
34
|
+
(MANAGERS.find(([lock]) => existsSync(join(root, lock))) ?? [
|
|
35
|
+
null,
|
|
36
|
+
{ name: 'npm', install: 'npm install', x: 'npx', cache: '' },
|
|
37
|
+
])[1]
|
|
38
|
+
|
|
39
|
+
/** Where a server host keeps what must last: SQLite's default `file:.data/app.db` lands on it unchanged. */
|
|
40
|
+
export const DATA = '/app/.data'
|
|
41
|
+
|
|
42
|
+
const sha = (body) => createHash('sha256').update(body).digest('hex').slice(0, 12)
|
|
43
|
+
const stamp = (target, body) => `# weft deploy init ${target}, sha ${sha(body)}\n${body}`
|
|
44
|
+
const STAMP = /^# weft deploy init (\w+), sha ([0-9a-f]{12})\n/
|
|
45
|
+
|
|
46
|
+
const IMAGES = { node: 'node:26-slim', bun: 'oven/bun:1-slim', deno: 'denoland/deno:latest' }
|
|
47
|
+
const RUN = {
|
|
48
|
+
node: '["node", ".output/server/index.mjs"]',
|
|
49
|
+
bun: '["bun", "run", ".output/server/index.mjs"]',
|
|
50
|
+
deno: '["deno", "run", "-A", ".output/server/index.mjs"]',
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** A two-stage image: build with the lockfile's manager, run the server. A sidecar needs cargo to build and `weft start` to run. */
|
|
54
|
+
function dockerfile({ runtime, manager, needs }) {
|
|
55
|
+
const rust = needs.sidecar || needs.native
|
|
56
|
+
const lines = [
|
|
57
|
+
`FROM ${IMAGES.node} AS build`,
|
|
58
|
+
'WORKDIR /app',
|
|
59
|
+
...(rust
|
|
60
|
+
? [
|
|
61
|
+
'RUN apt-get update && apt-get install -y --no-install-recommends curl ca-certificates build-essential && curl -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal',
|
|
62
|
+
'ENV PATH=/root/.cargo/bin:$PATH',
|
|
63
|
+
]
|
|
64
|
+
: []),
|
|
65
|
+
'COPY . .',
|
|
66
|
+
`RUN ${manager.install}`,
|
|
67
|
+
`RUN ${manager.x} weft build --preset ${presetFor({}, runtime)}`,
|
|
68
|
+
'',
|
|
69
|
+
`FROM ${needs.sidecar ? IMAGES.node : (IMAGES[runtime] ?? IMAGES.node)}`,
|
|
70
|
+
'WORKDIR /app',
|
|
71
|
+
'ENV NODE_ENV=production HOST=0.0.0.0 PORT=8080',
|
|
72
|
+
// `weft start` runs each sidecar next to the server; without one the server is all there is
|
|
73
|
+
...(needs.sidecar
|
|
74
|
+
? [
|
|
75
|
+
'COPY --from=build /app ./',
|
|
76
|
+
'EXPOSE 8080',
|
|
77
|
+
`CMD ["${manager.x.split(' ')[0]}", ${manager.x.includes(' ') ? `"${manager.x.split(' ')[1]}", ` : ''}"weft", "start"]`,
|
|
78
|
+
]
|
|
79
|
+
: [
|
|
80
|
+
'COPY --from=build /app/.output ./.output',
|
|
81
|
+
'EXPOSE 8080',
|
|
82
|
+
`CMD ${RUN[runtime] ?? RUN.node}`,
|
|
83
|
+
]),
|
|
84
|
+
]
|
|
85
|
+
return `${lines.join('\n')}\n`
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const DOCKERIGNORE =
|
|
89
|
+
'node_modules\n.output\n.weft/*\n!.weft/plugins.lock.json\n.env\n.env.*\n.git\n.netlify\n.vercel\ntarget\n'
|
|
90
|
+
|
|
91
|
+
function flyToml({ deploy, needs }) {
|
|
92
|
+
const fly = deploy.fly ?? {}
|
|
93
|
+
const held = needs.required || needs.feed || needs.sidecar
|
|
94
|
+
const lines = [
|
|
95
|
+
`app = ${JSON.stringify(fly.app ?? 'my-app')}`,
|
|
96
|
+
`primary_region = ${JSON.stringify(fly.region ?? 'iad')}`,
|
|
97
|
+
'',
|
|
98
|
+
'[env]',
|
|
99
|
+
' PORT = "8080"',
|
|
100
|
+
...(needs.uploads && fly.volume
|
|
101
|
+
? [` WEFT_MEDIA_DIR = "${fly.volume.path ?? DATA}/media"`]
|
|
102
|
+
: []),
|
|
103
|
+
'',
|
|
104
|
+
'[http_service]',
|
|
105
|
+
' internal_port = 8080',
|
|
106
|
+
' force_https = true',
|
|
107
|
+
' auto_stop_machines = "stop"',
|
|
108
|
+
' auto_start_machines = true',
|
|
109
|
+
// a change feed is a listener: a stopped machine hears nothing
|
|
110
|
+
` min_machines_running = ${held ? 1 : 0}`,
|
|
111
|
+
...(fly.volume
|
|
112
|
+
? [
|
|
113
|
+
'',
|
|
114
|
+
'[[mounts]]',
|
|
115
|
+
` source = ${JSON.stringify(fly.volume.name ?? 'data')}`,
|
|
116
|
+
` destination = ${JSON.stringify(fly.volume.path ?? DATA)}`,
|
|
117
|
+
` initial_size = "${fly.volume.sizeGb ?? 1}gb"`,
|
|
118
|
+
]
|
|
119
|
+
: []),
|
|
120
|
+
'',
|
|
121
|
+
'[[vm]]',
|
|
122
|
+
` memory = ${JSON.stringify(fly.memory ?? '512mb')}`,
|
|
123
|
+
]
|
|
124
|
+
return `${lines.join('\n')}\n`
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function renderYaml({ deploy, needs }) {
|
|
128
|
+
const r = deploy.render ?? {}
|
|
129
|
+
const lines = [
|
|
130
|
+
'services:',
|
|
131
|
+
' - type: web',
|
|
132
|
+
` name: ${r.service ?? 'my-app'}`,
|
|
133
|
+
' runtime: docker',
|
|
134
|
+
` plan: ${r.plan ?? (r.disk ? 'starter' : 'free')}`,
|
|
135
|
+
` region: ${r.region ?? 'oregon'}`,
|
|
136
|
+
// the workflow deploys once `weft check` passes, through the deploy hook
|
|
137
|
+
' autoDeploy: false',
|
|
138
|
+
' healthCheckPath: /',
|
|
139
|
+
' envVars:',
|
|
140
|
+
' - key: NODE_ENV',
|
|
141
|
+
' value: production',
|
|
142
|
+
...(needs.uploads && r.disk
|
|
143
|
+
? [' - key: WEFT_MEDIA_DIR', ` value: ${r.disk.path ?? DATA}/media`]
|
|
144
|
+
: []),
|
|
145
|
+
...(r.disk
|
|
146
|
+
? [
|
|
147
|
+
' disk:',
|
|
148
|
+
' name: data',
|
|
149
|
+
` mountPath: ${r.disk.path ?? DATA}`,
|
|
150
|
+
` sizeGB: ${r.disk.sizeGb ?? 1}`,
|
|
151
|
+
]
|
|
152
|
+
: []),
|
|
153
|
+
]
|
|
154
|
+
return `${lines.join('\n')}\n`
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const netlifyToml = ({ manager }) =>
|
|
158
|
+
`[build]\n command = "${manager.x} weft build --preset netlify"\n publish = "dist"\n\n[build.environment]\n NODE_VERSION = "26"\n`
|
|
159
|
+
|
|
160
|
+
const vercelJson = ({ manager }) =>
|
|
161
|
+
`${JSON.stringify({ $schema: 'https://openapi.vercel.sh/vercel.json', framework: null, installCommand: manager.install.replace(/^corepack enable && /, ''), buildCommand: `${manager.x} weft build --preset vercel` }, null, 2)}\n`
|
|
162
|
+
|
|
163
|
+
const wranglerToml = ({ deploy }) =>
|
|
164
|
+
`name = ${JSON.stringify(deploy.cloudflare?.name ?? 'my-app')}\ncompatibility_flags = ["nodejs_compat"]\n`
|
|
165
|
+
|
|
166
|
+
/** The workflow: on push to the branch, check and deploy to production; on a pull request, a preview where the host has one. */
|
|
167
|
+
function workflow({ target, deploy, manager }) {
|
|
168
|
+
const h = host(target)
|
|
169
|
+
const previews = h.kind !== 'server'
|
|
170
|
+
const node =
|
|
171
|
+
manager.name === 'bun'
|
|
172
|
+
? [' - uses: oven-sh/setup-bun@v2']
|
|
173
|
+
: [
|
|
174
|
+
' - uses: actions/setup-node@v4',
|
|
175
|
+
' with:',
|
|
176
|
+
' node-version: 26',
|
|
177
|
+
...(manager.cache ? [` cache: ${manager.cache}`] : []),
|
|
178
|
+
]
|
|
179
|
+
const lines = [
|
|
180
|
+
'name: weft deploy',
|
|
181
|
+
'on:',
|
|
182
|
+
' push:',
|
|
183
|
+
` branches: [${deploy.branch ?? 'main'}]`,
|
|
184
|
+
...(previews ? [' pull_request:'] : []),
|
|
185
|
+
'permissions:',
|
|
186
|
+
' contents: read',
|
|
187
|
+
'concurrency:',
|
|
188
|
+
// oxlint-disable-next-line no-template-curly-in-string -- a GitHub Actions expression, not a JS template
|
|
189
|
+
' group: weft-deploy-${{ github.ref }}',
|
|
190
|
+
' cancel-in-progress: true',
|
|
191
|
+
'jobs:',
|
|
192
|
+
' deploy:',
|
|
193
|
+
' runs-on: ubuntu-latest',
|
|
194
|
+
' steps:',
|
|
195
|
+
' - uses: actions/checkout@v4',
|
|
196
|
+
...node,
|
|
197
|
+
...(target === 'fly' ? [' - uses: superfly/flyctl-actions/setup-flyctl@master'] : []),
|
|
198
|
+
` - run: ${manager.install}`,
|
|
199
|
+
` - run: ${manager.x} weft check`,
|
|
200
|
+
// oxlint-disable-next-line no-template-curly-in-string -- a GitHub Actions expression, not a JS template
|
|
201
|
+
` - run: ${manager.x} weft deploy --yes${previews ? " ${{ github.event_name == 'push' && '--prod' || '' }}" : ' --prod'}`,
|
|
202
|
+
' env:',
|
|
203
|
+
...h.env.map((k) => ` ${k}: \${{ secrets.${k} }}`),
|
|
204
|
+
]
|
|
205
|
+
return `${lines.join('\n')}\n`
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const WORKFLOW = '.github/workflows/weft-deploy.yml'
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* What `weft deploy init <target>` writes, as `{ path, text }`, every text one stamped.
|
|
212
|
+
* @param {{ target: string, deploy: any, needs: any, runtime: string, manager: any }} ctx
|
|
213
|
+
*/
|
|
214
|
+
export function filesFor(ctx) {
|
|
215
|
+
const { target } = ctx
|
|
216
|
+
const out = []
|
|
217
|
+
const text = (path, body) => out.push({ path, text: stamp(target, body) })
|
|
218
|
+
if (target === 'netlify') text('netlify.toml', netlifyToml(ctx))
|
|
219
|
+
if (target === 'vercel') out.push({ path: 'vercel.json', text: vercelJson(ctx), json: true })
|
|
220
|
+
if (target === 'cloudflare') text('wrangler.toml', wranglerToml(ctx))
|
|
221
|
+
if (target === 'fly') text('fly.toml', flyToml(ctx))
|
|
222
|
+
if (target === 'render') text('render.yaml', renderYaml(ctx))
|
|
223
|
+
if (target === 'fly' || target === 'render') {
|
|
224
|
+
text('Dockerfile', dockerfile(ctx))
|
|
225
|
+
out.push({ path: '.dockerignore', text: DOCKERIGNORE, plain: true })
|
|
226
|
+
}
|
|
227
|
+
if ((ctx.deploy.ci ?? 'github') === 'github') text(WORKFLOW, workflow(ctx))
|
|
228
|
+
return out
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Each generated file's state: `missing`, `current`, `stale` (weft's, from an older config), `edited` (by hand: left
|
|
233
|
+
* alone), or `foreign` (no stamp: not weft's).
|
|
234
|
+
*/
|
|
235
|
+
export function fileStates(root, files) {
|
|
236
|
+
return files.map((f) => {
|
|
237
|
+
const abs = join(root, f.path)
|
|
238
|
+
if (!existsSync(abs)) return { ...f, state: 'missing' }
|
|
239
|
+
const now = readFileSync(abs, 'utf8')
|
|
240
|
+
if (now === f.text) return { ...f, state: 'current' }
|
|
241
|
+
if (f.json)
|
|
242
|
+
return {
|
|
243
|
+
...f,
|
|
244
|
+
state: JSON.parse(now || '{}').buildCommand?.includes('weft build --preset vercel')
|
|
245
|
+
? 'edited'
|
|
246
|
+
: 'stale',
|
|
247
|
+
}
|
|
248
|
+
if (f.plain) return { ...f, state: 'edited' }
|
|
249
|
+
const m = STAMP.exec(now)
|
|
250
|
+
if (!m) return { ...f, state: 'foreign' }
|
|
251
|
+
return { ...f, state: sha(now.slice(m[0].length)) === m[2] ? 'stale' : 'edited' }
|
|
252
|
+
})
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** Write what's missing or stale. Never a file weft didn't write, or one edited since. */
|
|
256
|
+
export function writeFiles(root, states, { force = false } = {}) {
|
|
257
|
+
const written = []
|
|
258
|
+
for (const f of states) {
|
|
259
|
+
if (f.state === 'current' || (!force && (f.state === 'edited' || f.state === 'foreign')))
|
|
260
|
+
continue
|
|
261
|
+
mkdirSync(dirname(join(root, f.path)), { recursive: true })
|
|
262
|
+
writeFileSync(join(root, f.path), f.text)
|
|
263
|
+
written.push(f.path)
|
|
264
|
+
}
|
|
265
|
+
return written
|
|
266
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
// Which hosts can run an app, and which one to suggest (research 46 §4). A finding is a need the host can't meet: an
|
|
2
|
+
// error when nothing on that host fixes it, a warning when it runs degraded or needs a paid add-on.
|
|
3
|
+
|
|
4
|
+
import { HOSTS } from './hosts.mjs'
|
|
5
|
+
|
|
6
|
+
const SERVERS = "`deploy: { target: 'fly' }` or `'render'`: a server that stays up"
|
|
7
|
+
const SPLIT =
|
|
8
|
+
'or keep the pages here and run realtime on a small server (the split, `weft docs deploy`)'
|
|
9
|
+
|
|
10
|
+
/** What a disk need asks of a host, with the config that gives it one. */
|
|
11
|
+
function disk(h, need, cfg = {}, assume) {
|
|
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
|
+
}
|
|
20
|
+
const has = h.id === 'fly' ? cfg.fly?.volume : cfg.render?.disk
|
|
21
|
+
if (has) return
|
|
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
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* @param {ReturnType<import('./needs.mjs').needsOf>} needs
|
|
34
|
+
* @param {import('./hosts.mjs').Host} h
|
|
35
|
+
* @param {{ deploy?: any, assume?: boolean, local?: boolean }} [options] `assume`: a paid add-on is a warning (the hosts list,
|
|
36
|
+
* before a target is chosen). `local`: the build is deployed from this machine.
|
|
37
|
+
* @returns {{ host: string, fits: boolean, findings: { code: string, severity: string, file: string, why: string, fix: string }[] }}
|
|
38
|
+
*/
|
|
39
|
+
export function fit(needs, h, { deploy = {}, assume = false, local = false } = {}) {
|
|
40
|
+
const findings = []
|
|
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
|
+
)
|
|
75
|
+
for (const key of ['sqlite', 'uploads']) {
|
|
76
|
+
const need = needs[key]
|
|
77
|
+
const d = need && disk(h, need, deploy, assume)
|
|
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
|
+
)
|
|
86
|
+
}
|
|
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 }
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Every host, fitted, and the one to suggest (research 46 §4), with why. */
|
|
117
|
+
export function hostsReport(needs, { deploy = {} } = {}) {
|
|
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
|
+
}))
|
|
126
|
+
return { fits, suggestion: suggest(needs, fits) }
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** @returns {{ host: string, why: string, alternative?: string }} */
|
|
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
|
|
133
|
+
const held = needs.required || needs.sidecar || needs.feed
|
|
134
|
+
const kept = needs.sqlite || needs.uploads
|
|
135
|
+
if (!needs.sockets && !held && !kept) {
|
|
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',
|
|
165
|
+
}
|
|
166
|
+
}
|