@weft-js/cli 0.1.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/docs/deploy.md +83 -0
- package/package.json +3 -3
- package/src/audit.mjs +36 -3
- package/src/browser/client.mjs +1 -1
- package/src/catalog.mjs +18 -1
- package/src/check.mjs +2 -0
- package/src/cli.mjs +37 -5
- package/src/deploy/cdn.mjs +64 -0
- package/src/deploy/files.mjs +187 -0
- package/src/deploy/fit.mjs +62 -0
- package/src/deploy/hosts.mjs +56 -0
- package/src/deploy/index.mjs +273 -0
- package/src/deploy/needs.mjs +92 -0
- package/src/docs.mjs +1 -0
- package/src/load-config.mjs +30 -1
- package/src/mcp.mjs +1 -1
- package/src/start.mjs +5 -1
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @weft-js/cli
|
|
2
2
|
|
|
3
|
-
> **Early release (0.1.
|
|
3
|
+
> **Early release (0.1.1).** APIs will change between minor versions.
|
|
4
4
|
|
|
5
5
|
The `weft` command. It runs an app (`dev`, `build`, `start`), checks it (`check`, `tsc`, `fix`), serves its docs to people
|
|
6
6
|
and agents (`docs`, `agents-md`, `mcp`) and manages plugins (`add`, `plugins`). An app made with `npm create @weft-js`
|
package/docs/deploy.md
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# Deploy: from `weft build` to a live URL
|
|
2
|
+
|
|
3
|
+
weft reads what the app uses and says which hosts can run it, before a build and before an account exists. Then it
|
|
4
|
+
writes the host's files and a workflow, and `weft deploy` runs the host's own CLI and reports the URL.
|
|
5
|
+
|
|
6
|
+
```bash
|
|
7
|
+
weft deploy hosts # which hosts fit, why not, what each costs, and the suggestion
|
|
8
|
+
weft deploy init # the suggested host's files, a GitHub workflow, and `deploy` in weft.config.ts
|
|
9
|
+
weft deploy # a dry run: the build and the exact commands --yes would run
|
|
10
|
+
weft deploy --yes # a preview deploy; add --prod for production
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
`weft deploy` without `--yes` runs nothing. An agent runs the dry run on its own, and asks before `--yes`: a deploy
|
|
14
|
+
publishes, and a host may bill for it. Nothing in weft creates a site, an app, a volume or a DNS record; `init` prints the
|
|
15
|
+
one command per host that does, for you to run.
|
|
16
|
+
|
|
17
|
+
## Which host
|
|
18
|
+
|
|
19
|
+
| The app uses | Netlify / Vercel | Cloudflare | Fly / Render |
|
|
20
|
+
|---|---|---|---|
|
|
21
|
+
| pages, queries, mutations, forms, `stream()` | ✓ | ✓ | ✓ |
|
|
22
|
+
| `channel`, `sharedState`, `realtime({ share })` | ✗ E001 | ✓ (Durable Objects) | ✓ |
|
|
23
|
+
| `.live({ required })`, `remote()` sidecars | ✗ E002 | ✗ E002 | ✓ |
|
|
24
|
+
| a SQLite file, media uploads | ✗ E003 | ✗ E003 | with a volume or disk (billed) |
|
|
25
|
+
| `changes` without an external listener | degraded (R014) | degraded | ✓ |
|
|
26
|
+
|
|
27
|
+
The suggestion: Netlify when nothing needs a server that stays up (a CDN with functions is the cheapest and fastest
|
|
28
|
+
host), Cloudflare when channels are all that need one, Fly when data must last on disk, else Fly or Render.
|
|
29
|
+
|
|
30
|
+
A function host blocked by a channel has a third way, the split: pages on the function host, realtime on a small server.
|
|
31
|
+
It needs a second origin and a cookie both can read, and is designed (research 46 §10), not built.
|
|
32
|
+
|
|
33
|
+
## `deploy` in weft.config.ts
|
|
34
|
+
|
|
35
|
+
```ts
|
|
36
|
+
deploy: {
|
|
37
|
+
target: 'fly', // netlify | vercel | cloudflare | fly | render
|
|
38
|
+
fly: { app: 'shop', region: 'iad', volume: { name: 'data', sizeGb: 1 } },
|
|
39
|
+
ci: 'github', // the workflow; false for none
|
|
40
|
+
branch: 'main',
|
|
41
|
+
cdn: { proxy: 'cloudflare', images: { provider: 'cloudinary', cloud: 'shop' } },
|
|
42
|
+
}
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Names, regions and sizes only. Every token comes from the environment, and one written into the config, a host file or a
|
|
46
|
+
workflow is **E008**. With `deploy.target` set, `weft build` builds for it, and `weft check` reports E001–E003 for it.
|
|
47
|
+
|
|
48
|
+
A volume or disk mounts at `/app/.data`: SQLite's default `file:.data/app.db` is already there, and uploads go to
|
|
49
|
+
`.data/media` (`WEFT_MEDIA_DIR`).
|
|
50
|
+
|
|
51
|
+
## What each target writes and runs
|
|
52
|
+
|
|
53
|
+
| Target | Files | `weft deploy --yes` runs | Reads |
|
|
54
|
+
|---|---|---|---|
|
|
55
|
+
| netlify | `netlify.toml` | `weft build --preset netlify`, `netlify deploy --dir dist --functions .netlify/functions-internal --json` | `NETLIFY_AUTH_TOKEN`, `NETLIFY_SITE_ID` |
|
|
56
|
+
| vercel | `vercel.json` | `weft build --preset vercel`, `vercel deploy --prebuilt` | `VERCEL_TOKEN`, `VERCEL_ORG_ID`, `VERCEL_PROJECT_ID` |
|
|
57
|
+
| cloudflare | `wrangler.toml` | `weft build --preset cloudflare_durable`, `wrangler deploy` in `.output/` | `CLOUDFLARE_API_TOKEN`, `CLOUDFLARE_ACCOUNT_ID` |
|
|
58
|
+
| fly | `fly.toml`, `Dockerfile`, `.dockerignore` | `flyctl deploy --remote-only` (Fly builds the image) | `FLY_API_TOKEN` |
|
|
59
|
+
| render | `render.yaml`, `Dockerfile`, `.dockerignore` | the deploy hook (Render builds the pushed commit) | `RENDER_DEPLOY_HOOK_URL` |
|
|
60
|
+
|
|
61
|
+
Every target also gets `.github/workflows/weft-deploy.yml`: on push to `branch`, install, `weft check`, then `weft deploy
|
|
62
|
+
--yes --prod`; on a pull request, a preview where the host has them. Add the variables as repository secrets (`gh secret
|
|
63
|
+
set NAME`). Netlify, Vercel and Cloudflare deploy what was built, so build where the host runs: the workflow builds on
|
|
64
|
+
Linux, and a native addon built on a Mac is **E004**.
|
|
65
|
+
|
|
66
|
+
Each written file starts with `# weft deploy init <target>, sha <hash>`. Change the config and `weft check` reports the
|
|
67
|
+
file as stale (**E006**); `init` rewrites it. A file you edited keeps its old hash, and `init` leaves it alone unless
|
|
68
|
+
`--force`.
|
|
69
|
+
|
|
70
|
+
## CDN and media
|
|
71
|
+
|
|
72
|
+
- **Cloudflare in front of a server host** (`cdn.proxy: 'cloudflare'`): proxy the domain to the Fly or Render origin.
|
|
73
|
+
Hashed assets are already `immutable`, and pages are `private` unless a route caches, so the proxy can't serve one
|
|
74
|
+
user's page to another. E010: `site.url` is still the host's own domain. E011: a proxy in front of a CDN host.
|
|
75
|
+
- **Cloudinary for uploads** (`cdn.images`): checked (E012 without `@weft-js/plugin-media`, E013 without a cloud or with
|
|
76
|
+
two), not yet wired: uploads still go to the media server's disk (E014). `?media` imports never need it: the build
|
|
77
|
+
already makes sized AVIF and WebP files that any CDN serves.
|
|
78
|
+
|
|
79
|
+
## Codes
|
|
80
|
+
|
|
81
|
+
E001 sockets on a function host, E002 a held process or a sidecar, E003 data on a disk that doesn't last, E004 a native
|
|
82
|
+
addon deployed from another OS, E005 no target (with the suggestion), E006 a stale host file, E007 an unset variable,
|
|
83
|
+
E008 a written credential, E009 the memory bus on many instances, E010–E014 CDN and media. `weft docs E003` explains one.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@weft-js/cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"description": "The weft command: dev, build, check, the agent layer and the MCP server",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -15,14 +15,14 @@
|
|
|
15
15
|
"node": ">=26"
|
|
16
16
|
},
|
|
17
17
|
"dependencies": {
|
|
18
|
-
"@weft-js/create": "0.1.
|
|
18
|
+
"@weft-js/create": "0.1.1",
|
|
19
19
|
"axe-core": "4.14.0",
|
|
20
20
|
"typescript": "6.0.3",
|
|
21
21
|
"yaml": "2.9.1",
|
|
22
22
|
"zod": "4.6.7"
|
|
23
23
|
},
|
|
24
24
|
"peerDependencies": {
|
|
25
|
-
"@weft-js/core": "^0.1.
|
|
25
|
+
"@weft-js/core": "^0.1.1"
|
|
26
26
|
},
|
|
27
27
|
"publishConfig": {
|
|
28
28
|
"access": "public"
|
package/src/audit.mjs
CHANGED
|
@@ -99,7 +99,8 @@ export function serveStatic(dir) {
|
|
|
99
99
|
const missing = join(dir, '404.html')
|
|
100
100
|
if (!file) {
|
|
101
101
|
res.writeHead(404, { 'content-type': TYPES['.html'] })
|
|
102
|
-
|
|
102
|
+
// a doctype even here: a frame showing this would put Chrome in Quirks Mode, an issue of the audit's own making
|
|
103
|
+
return res.end(existsSync(missing) ? readFileSync(missing) : '<!doctype html><title>Not found</title><p>Not found</p>')
|
|
103
104
|
}
|
|
104
105
|
res.writeHead(200, { 'content-type': TYPES[extname(file)] ?? 'application/octet-stream' })
|
|
105
106
|
res.end(readFileSync(file))
|
|
@@ -163,6 +164,29 @@ const SHIFT = `new Promise(ok => {
|
|
|
163
164
|
setTimeout(() => ok({ cls: Math.round(cls * 1000) / 1000, moved }), 50)
|
|
164
165
|
})`
|
|
165
166
|
|
|
167
|
+
/**
|
|
168
|
+
* The app's own pages as a test on a path (`src/routes`, `[id]` and `[...rest]` included). A build served as files has
|
|
169
|
+
* only its prerendered pages: a request for one of the others is the server's to answer, not a missing file.
|
|
170
|
+
*/
|
|
171
|
+
export function appRoutes(root) {
|
|
172
|
+
const dir = join(root, 'src', 'routes')
|
|
173
|
+
if (!existsSync(dir)) return () => false
|
|
174
|
+
const tests = []
|
|
175
|
+
const walk = (at, prefix) => {
|
|
176
|
+
for (const e of readdirSync(at, { withFileTypes: true })) {
|
|
177
|
+
const name = e.name.replace(/^\(.*\)$/, '')
|
|
178
|
+
if (e.isDirectory()) { walk(join(at, e.name), name ? `${prefix}/${name}` : prefix); continue }
|
|
179
|
+
if (!/\.[jt]sx?$/.test(e.name) || /\.(server|test|spec)\./.test(e.name) || e.name.startsWith('_')) continue
|
|
180
|
+
const leaf = e.name.replace(/\.[jt]sx?$/, '')
|
|
181
|
+
const path = leaf === 'index' ? prefix : `${prefix}/${leaf}`
|
|
182
|
+
const re = path.split('/').map(seg => (/^\[\.\.\.\w+\]$/.test(seg) ? '\0' : /^\[\w+\]$/.test(seg) ? '[^/]+' : seg.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))).join('/').replace(/\/\0/g, '(?:/.+)?')
|
|
183
|
+
tests.push(new RegExp(`^${re || ''}/?$`))
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
walk(dir, '')
|
|
187
|
+
return path => tests.some(t => t.test(path))
|
|
188
|
+
}
|
|
189
|
+
|
|
166
190
|
/** In the page: what a crawler reads from the head, and what pokes past a phone's right edge. */
|
|
167
191
|
const HEAD = `(() => {
|
|
168
192
|
const meta = n => document.querySelector('meta[name="' + n + '"]')?.getAttribute('content')?.trim() ?? null
|
|
@@ -181,11 +205,12 @@ const HEAD = `(() => {
|
|
|
181
205
|
return {
|
|
182
206
|
title: document.title.trim(), lang: document.documentElement.getAttribute('lang') ?? '', description: meta('description'),
|
|
183
207
|
viewport: meta('viewport'), robots: meta('robots'), scroll: document.documentElement.scrollWidth - innerWidth, wide,
|
|
208
|
+
icon: !!document.querySelector('link[rel~="icon"]'),
|
|
184
209
|
}
|
|
185
210
|
})()`
|
|
186
211
|
|
|
187
212
|
/** Audit one page in one form factor: what Chrome said while it loaded, its head, its layout, and axe-core's rules. */
|
|
188
|
-
async function auditPage(browser, url, form, statics) {
|
|
213
|
+
async function auditPage(browser, url, form, statics, served) {
|
|
189
214
|
const { targetId } = await browser.send('Target.createTarget', { url: 'about:blank' })
|
|
190
215
|
const { sessionId } = await browser.send('Target.attachToTarget', { targetId, flatten: true })
|
|
191
216
|
const send = (m, p) => browser.send(m, p, sessionId)
|
|
@@ -209,6 +234,9 @@ async function auditPage(browser, url, form, statics) {
|
|
|
209
234
|
} else if (m.method === 'Log.entryAdded' && p.entry.level === 'error') {
|
|
210
235
|
// a build's pages served as files have no server: its realtime and function routes answer 404 here and nowhere else
|
|
211
236
|
if (statics && /\/_weft\/(rt|ws|fn|data|m|x)\b/.test(`${p.entry.url ?? ''} ${p.entry.text}`)) return
|
|
237
|
+
if (statics && p.entry.url && /status of 404/.test(p.entry.text) && served(new URL(p.entry.url).pathname)) return
|
|
238
|
+
// the favicon: Chrome asks for it lazily, once a session, so the head probe asks instead (below)
|
|
239
|
+
if (/\/favicon\.ico$/.test(p.entry.url ?? '')) return
|
|
212
240
|
found.push({ code: 'L011', key: `${p.entry.url ?? ''} ${p.entry.text}`, at: where(p.entry.url), text: p.entry.text })
|
|
213
241
|
} else if (m.method === 'Audits.issueAdded') {
|
|
214
242
|
const { code, details } = p.issue
|
|
@@ -229,6 +257,10 @@ async function auditPage(browser, url, form, statics) {
|
|
|
229
257
|
if (document && document.status >= 400) found.push({ code: 'L025', key: String(document.status), text: `HTTP ${document.status}` })
|
|
230
258
|
const header = Object.entries(document?.headers ?? {}).find(([k]) => k.toLowerCase() === 'x-robots-tag')?.[1]
|
|
231
259
|
const head = (await send('Runtime.evaluate', { expression: HEAD, returnByValue: true })).result.value
|
|
260
|
+
if (!head.icon) {
|
|
261
|
+
const status = (await send('Runtime.evaluate', { expression: `fetch('/favicon.ico', { method: 'HEAD' }).then(r => r.status, () => 0)`, awaitPromise: true, returnByValue: true })).result.value
|
|
262
|
+
if (status >= 400) found.push({ code: 'L011', key: 'favicon', at: '/favicon.ico', text: `HTTP ${status}` })
|
|
263
|
+
}
|
|
232
264
|
if (!head.title) found.push({ code: 'L020', key: 'title' })
|
|
233
265
|
if (!head.description) found.push({ code: 'L021', key: 'description' })
|
|
234
266
|
if (!head.lang) found.push({ code: 'L022', key: 'lang' })
|
|
@@ -300,6 +332,7 @@ export async function audit(root, options = {}) {
|
|
|
300
332
|
base = server.url
|
|
301
333
|
}
|
|
302
334
|
routes ??= ['/']
|
|
335
|
+
const served = appRoutes(root)
|
|
303
336
|
const browser = await chrome(exe)
|
|
304
337
|
const findings = new Map()
|
|
305
338
|
try {
|
|
@@ -307,7 +340,7 @@ export async function audit(root, options = {}) {
|
|
|
307
340
|
let i = 0
|
|
308
341
|
const worker = async () => {
|
|
309
342
|
for (let job; (job = jobs[i++]);) {
|
|
310
|
-
for (const f of await auditPage(browser, `${base}${job.route}`, job.form, !!server)) {
|
|
343
|
+
for (const f of await auditPage(browser, `${base}${job.route}`, job.form, !!server, served)) {
|
|
311
344
|
const key = `${f.code} ${f.key}`
|
|
312
345
|
const seen = findings.get(key) ?? { ...f, routes: new Set(), forms: new Set() }
|
|
313
346
|
seen.routes.add(job.route)
|
package/src/browser/client.mjs
CHANGED
|
@@ -83,7 +83,7 @@ export async function launch(root, route = '/', { url, headed = false } = {}) {
|
|
|
83
83
|
const base = await devUrl(root, url)
|
|
84
84
|
const profile = join(root, '.weft', 'browser', 'profile')
|
|
85
85
|
mkdirSync(profile, { recursive: true })
|
|
86
|
-
const args = [`--user-data-dir=${profile}`, '--remote-debugging-port=0', '--no-first-run', '--no-default-browser-check', '--disable-background-timer-throttling', '--disable-renderer-backgrounding', ...(headed ? [] : ['--headless=new']), `${base}${route.startsWith('/') ? route : `/${route}`}`]
|
|
86
|
+
const args = [`--user-data-dir=${profile}`, '--remote-debugging-port=0', '--no-first-run', '--no-default-browser-check', '--disable-background-timer-throttling', '--disable-renderer-backgrounding', ...(headed ? [] : ['--headless=new']), ...(process.getuid?.() === 0 ? ['--no-sandbox'] : []) /* Chrome won't start as root with its sandbox: a container, CI */, `${base}${route.startsWith('/') ? route : `/${route}`}`]
|
|
87
87
|
const child = spawn(exe, args, { detached: true, stdio: 'ignore' })
|
|
88
88
|
child.unref()
|
|
89
89
|
writeFileSync(stateFile(root), JSON.stringify({ pid: child.pid, profile, exe }))
|
package/src/catalog.mjs
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// research 11 §8.5 and 15 §7.5 as built in research 26 §7 (Phase 2's X-codes, M001–M013), research 27 §6 (Q001–Q010,
|
|
4
4
|
// server functions, and S011), research 28 §7 (Q011–Q015, the database; A001–A004, auth; C001–C002, the contract),
|
|
5
5
|
// research 29 §7 (S001, S002, S012, S013, state on the server; F001–F003, forms; M016–M018, the media route), research
|
|
6
|
-
// 30 §8 (W052, W053, T013, R001–R023, realtime), research 36 §8 and 39 (R101–R109, Rust), plus the codes proposed in research 17 (marked `proposed`).
|
|
6
|
+
// 30 §8 (W052, W053, T013, R001–R023, realtime), research 36 §8 and 39 (R101–R109, Rust), research 46 §12 (E001–E014, deploy), plus the codes proposed in research 17 (marked `proposed`).
|
|
7
7
|
//
|
|
8
8
|
// W-codes come from the compiler. Each has an `example` that should trigger it; `probe()` runs the installed
|
|
9
9
|
// compiler over the examples, so AGENTS.md and `docs` only claim what this weft version actually reports. T-codes
|
|
@@ -367,7 +367,24 @@ export const catalog = [
|
|
|
367
367
|
{ code: 'R021', severity: 'error', by: 'cli', catches: 'A `verify` target isn\'t available here: fails `weft build`, and `weft check` warns and skips', fix: 'Set `WEFT_STAGING_URL`, `STAGING_DATABASE_URL`, or the variable its target names' },
|
|
368
368
|
{ code: 'R022', severity: 'error', by: 'cli', catches: 'A `verify` target that looks like production, or that nothing marks as not: a round trip writes rows', fix: 'Name it in `live.targets` with `production: false`; a name or host that says production is always refused' },
|
|
369
369
|
{ code: 'R023', severity: 'warning', by: 'cli', catches: 'A `verify` passed with only one kind of target, so some links went untested', fix: 'Give it both a URL and a database' },
|
|
370
|
+
{ code: 'R024', severity: 'error', by: 'cli', catches: 'A `channel` or `sharedState` built for a target that can\'t hold a WebSocket (Netlify, Vercel, AWS Lambda): `weft build` fails, and where it is let through the page never dials, the channel\'s `status` is `unavailable` and a send rejects with R024', fix: '`weft deploy hosts`, then a server host (`fly`, `render`); or `--unsafe-allow R024` with why, for a demo that may stay offline there' },
|
|
370
371
|
{ code: 'R018', severity: 'warning', by: 'cli', catches: 'A table in `changes` that no migration gives a change trigger (`weft check`, `weft build`): the server would fall back to polling', fix: 'Run `weft db live`' },
|
|
372
|
+
// Deploy (research 46): what a host can run, the files and env a deploy needs, CDN and media setup. `weft check` reports
|
|
373
|
+
// them for `deploy.target` (or the host `--preset` names), `weft deploy` before it builds, `weft doctor` for every host.
|
|
374
|
+
{ code: 'E001', severity: 'error', by: 'cli', catches: 'A `channel`, `sharedState` or `realtime({ share })` on a deploy target that can\'t hold a WebSocket (Netlify, Vercel, AWS Lambda): the page loads and never connects (at runtime: R024)', fix: "`deploy: { target: 'fly' }` or `'render'`, `'cloudflare'` (Durable Objects), or the split: pages here, realtime on a server (`weft docs deploy`)" },
|
|
375
|
+
{ code: 'E002', severity: 'error', by: 'cli', catches: 'Something that needs a process between requests on a target that keeps none: a `required` live query (R013), or a `remote()` sidecar', fix: "A server host (`fly`, `render`); for a feed, `weft db listen` on one with `listener: 'external'`; for a sidecar, deploy it on its own with `rust.services.<name>.external: true`" },
|
|
376
|
+
{ code: 'E003', severity: 'error', by: 'cli', catches: 'A SQLite file or media uploads on local disk, on a target whose disk is gone after a request or a deploy, or on a server host with no volume or disk configured (a warning in `weft deploy hosts`)', fix: '`deploy.fly.volume` or `deploy.render.disk` (both billed; they mount at `/app/.data`), or a hosted database' },
|
|
377
|
+
{ code: 'E004', severity: 'warning', by: 'cli', catches: 'A native npm addon (`better-sqlite3`, `sharp`, `bcrypt`…) built on this machine and deployed from here to a Linux function host', fix: "Deploy from CI (`deploy.ci: 'github'` builds on Linux), or drop the addon" },
|
|
378
|
+
{ code: 'E005', severity: 'info', by: 'cli', catches: 'No `deploy.target` (`weft doctor`, `weft deploy hosts`): which hosts can run this app, and the one weft suggests', fix: '`weft deploy init <target>`' },
|
|
379
|
+
{ code: 'E006', severity: 'warning', by: 'cli', catches: 'A file `weft deploy init` writes for `deploy.target` (`fly.toml`, `Dockerfile`, the workflow…) is missing, or was written from an older config', fix: '`weft deploy init` (a file edited by hand is left alone; `--force` overwrites it)' },
|
|
380
|
+
{ code: 'E007', severity: 'error', by: 'cli', catches: 'A variable `weft deploy --yes` reads is unset (an error in CI or for an id the CLI can\'t find; a warning where the host CLI\'s own login stands in)', fix: "Set it in the environment, or log in with the host's CLI here. Never in a file" },
|
|
381
|
+
{ code: 'E008', severity: 'error', by: 'cli', catches: "A deploy credential in a committed file: a Netlify or Fly token, a Render deploy hook, a Cloudinary URL with its secret, or a long value for a key named token, secret, api key or password, in the config, a host file or a workflow", fix: 'Move it to the environment (host settings, or GitHub secrets as `${{ secrets.NAME }}`), and rotate it' },
|
|
382
|
+
{ code: 'E009', severity: 'warning', by: 'cli', catches: 'The memory bus on a target that runs many instances: a message reaches one instance\'s clients (`weft build` fails on it as R002)', fix: "`weft add redis` or `weft add nats`, `postgresBus({ url })`, or `durableObjectBus` on Cloudflare" },
|
|
383
|
+
{ code: 'E010', severity: 'warning', by: 'cli', catches: '`deploy.cdn.proxy` is Cloudflare, and `site.url` is the host\'s own domain (`*.fly.dev`, `*.onrender.com`): nothing goes through the proxy', fix: 'Set `site.url` to the domain Cloudflare proxies' },
|
|
384
|
+
{ code: 'E011', severity: 'warning', by: 'cli', catches: '`deploy.cdn.proxy` in front of a host that is a CDN already (Netlify, Vercel, Cloudflare): two caches, and a deploy purges only its own', fix: 'Drop `cdn.proxy`, or deploy to `fly` or `render` behind it' },
|
|
385
|
+
{ code: 'E012', severity: 'error', by: 'cli', catches: '`deploy.cdn.images` is Cloudinary, and `@weft-js/plugin-media` isn\'t in `plugins`', fix: '`weft add media`, or drop `cdn.images`' },
|
|
386
|
+
{ code: 'E013', severity: 'error', by: 'cli', catches: '`deploy.cdn.images` with no `cloud` and no `CLOUDINARY_URL`, or the two naming different clouds (a warning when only the env var is unset, at a deploy)', fix: "Set `cdn.images.cloud`, and `CLOUDINARY_URL` in the host's environment" },
|
|
387
|
+
{ code: 'E014', severity: 'info', by: 'cli', catches: '`deploy.cdn.images` is checked, and this weft doesn\'t send uploads to Cloudinary yet: they go to `media({ server: { dir } })`', fix: 'Nothing yet (research 46 §9); on a function host, uploads need a server host with a disk (E003)' },
|
|
371
388
|
{ code: 'R101', severity: 'error', by: 'cli', catches: 'A function with `impl` has no `output`, a type JSON can\'t carry (`z.date()`, `z.bigint()`, `z.void()`, `z.custom()`), or a one-way `.transform()` on its input (`weft build`, `weft check`, `weft dev`)', fix: 'Use a codec: `z.codec(z.iso.datetime(), z.date(), { decode, encode })`. `z.null()` says a function returns nothing' },
|
|
372
389
|
{ code: 'R102', severity: 'error', by: 'cli', catches: 'A plain `z.union` of objects in a function with `impl`: it has no tag, so Rust can\'t tell its arms apart reliably. Scalar unions and `.nullable()` are fine', fix: "Use `z.discriminatedUnion`, or set `rust: { unions: 'allow' }`" },
|
|
373
390
|
{ code: 'R103', severity: 'error', by: 'cli', catches: 'A sidecar doesn\'t match the contract: cargo stops where a generated type or method no longer fits (with the function\'s id), a binary built from another contract, an output the `output` schema rejects, or input serde refuses', fix: 'Fix the Rust to fit the TS declaration, which is the source of truth; `.weft/rust/<service>.rs` shows what\'s generated' },
|
package/src/check.mjs
CHANGED
|
@@ -26,6 +26,7 @@ import { textChecks } from './text-checks.mjs'
|
|
|
26
26
|
import { loadCompiler, resolveWeft } from './weft.mjs'
|
|
27
27
|
import { dbLayer } from './db-layer.mjs'
|
|
28
28
|
import { routeDiagnostics, schemaHash, siteOf, start } from './start.mjs'
|
|
29
|
+
import { deployChecks } from './deploy/index.mjs'
|
|
29
30
|
|
|
30
31
|
const posix = p => p.split('\\').join('/')
|
|
31
32
|
|
|
@@ -161,6 +162,7 @@ export async function check(root, options = {}) {
|
|
|
161
162
|
...schemaDrift(root, await dbLayer(root, loaded)),
|
|
162
163
|
...(await rustChecks(root, loaded)),
|
|
163
164
|
...(await liveChecks(root, loaded, options.preset)),
|
|
165
|
+
...(loaded.diagnostics.some(d => d.code === 'P007') ? [] : deployChecks(root, config, { preset: options.preset })),
|
|
164
166
|
]
|
|
165
167
|
diagnostics.push(...await routeChecks(root, loaded, given, new Set(loaded.diagnostics.map(d => d.code))))
|
|
166
168
|
sortDiagnostics(diagnostics)
|
package/src/cli.mjs
CHANGED
|
@@ -24,7 +24,8 @@ import { formatGate, gate } from './gate.mjs'
|
|
|
24
24
|
import { browserCommand } from './browser/cli.mjs'
|
|
25
25
|
import { metaTable } from './meta-table.mjs'
|
|
26
26
|
import { audit } from './audit.mjs'
|
|
27
|
-
import { buildCommand, dbCommand, devCommand, doctorCommand, routesCommand, startCommand } from './start.mjs'
|
|
27
|
+
import { buildCommand, dbCommand, devCommand, doctorCommand, routesCommand, startCommand, unsafeAllowed } from './start.mjs'
|
|
28
|
+
import { deployCommand, formatHosts, hostsCommand, initCommand } from './deploy/index.mjs'
|
|
28
29
|
import { explainTarget, findGroups, formatExplain, formatFind, styleMatrix, writeMatrixFile } from './style.mjs'
|
|
29
30
|
|
|
30
31
|
const usage = `usage: weft <command> [--json] [--root <dir>]
|
|
@@ -44,6 +45,11 @@ const usage = `usage: weft <command> [--json] [--root <dir>]
|
|
|
44
45
|
[--unsafe-allow <codes>] go past these codes (R002, L003, a family like L, or all): forces a deploy
|
|
45
46
|
audit [routes…] [--url <u>] [--all] [--max <n>] the built pages, or a running site, in a clean Chrome at a phone's
|
|
46
47
|
and a desktop's width: axe-core's WCAG rules, console, deprecations, head, sideways scroll, shift
|
|
48
|
+
deploy [--prod] [--yes] [--target t] fit check, build for the target, then the host's own CLI; without --yes a dry
|
|
49
|
+
run that prints what would run. Netlify, Vercel, Cloudflare, Fly, Render; the live URL at the end
|
|
50
|
+
deploy hosts which hosts can run this app, why not, what each costs, and the one weft suggests
|
|
51
|
+
deploy init [target] [--dry-run] [--force] the host's files (netlify.toml, fly.toml + Dockerfile…), a GitHub
|
|
52
|
+
workflow that checks and deploys on push, and \`deploy\` in the config
|
|
47
53
|
start [--runtime r] run .output/ on its runtime (the config's \`runtime\`, node by default)
|
|
48
54
|
db generate [name] a migration for the schema's changes (it applies on the next request)
|
|
49
55
|
db live a migration with change triggers for the tables in \`changes\`
|
|
@@ -67,10 +73,10 @@ const usage = `usage: weft <command> [--json] [--root <dir>]
|
|
|
67
73
|
mcp MCP server on stdio: docs, check, tasks, style_find, style_explain, test_gate, browser_*`
|
|
68
74
|
|
|
69
75
|
/** Flags that take a value (`--base main` or `--base=main`), and boolean flags, beyond the global ones. */
|
|
70
|
-
const valued = new Set(['layer', 'base', 'budget', 'max-mutants', 'concurrency', 'url', 'tab', 'out', 'component', 'from', 'props', 'name', 'scope', 'timeout', 'preset', 'port', 'runtime', 'unsafe-allow', 'max'])
|
|
76
|
+
const valued = new Set(['layer', 'base', 'budget', 'max-mutants', 'concurrency', 'url', 'tab', 'out', 'component', 'from', 'props', 'name', 'scope', 'timeout', 'preset', 'port', 'runtime', 'unsafe-allow', 'max', 'target'])
|
|
71
77
|
/** `weft style explain` takes the group's own axes as flags (`--status late`); these are its fixed ones. */
|
|
72
78
|
const styleFlags = new Set(['states', 'accent', 'part'])
|
|
73
|
-
const booleans = new Set(['dry-run', 'no-install', 'remote', 'gate', 'refactor', 'no-mutation', 'interactive', 'delta', 'graph-only', 'no-graph', 'headed', 'full', 'reload', 'meta', 'all', 'verbose'])
|
|
79
|
+
const booleans = new Set(['dry-run', 'no-install', 'remote', 'gate', 'refactor', 'no-mutation', 'interactive', 'delta', 'graph-only', 'no-graph', 'headed', 'full', 'reload', 'meta', 'all', 'verbose', 'prod', 'yes', 'force'])
|
|
74
80
|
|
|
75
81
|
export function parse(argv) {
|
|
76
82
|
const out = { command: argv[0], positional: [], json: false, root: process.cwd(), check: false, stdout: false, flags: {} }
|
|
@@ -125,8 +131,9 @@ export async function main(argv, io = { stdout: process.stdout, stderr: process.
|
|
|
125
131
|
// What this install compiles with, always; what live data does on the target, for an app that has any.
|
|
126
132
|
const result = doctor(args.root)
|
|
127
133
|
const plan = args.flags.preset || existsSync(join(args.root, 'src/realtime.server.ts')) ? await doctorCommand(args.root, { preset: args.flags.preset, runtime: args.flags.runtime }) : undefined
|
|
128
|
-
|
|
129
|
-
|
|
134
|
+
const hosts = hostsCommand(args.root, (await loadConfig(args.root)).config)
|
|
135
|
+
if (args.json) print({ ...result, live: plan, deploy: hosts })
|
|
136
|
+
else say(`${formatDoctor(result)}\n\nweft doctor: deploy${hosts.target ? ` (target ${hosts.target})` : ''}\n${formatHosts(hosts).replace(/^/gm, ' ')}`)
|
|
130
137
|
if (!plan) return 0
|
|
131
138
|
say(`weft doctor: ${plan.preset} (${plan.host})\n live mode: ${plan.mode}\n ${plan.says}${plan.required.length ? `\n required: ${plan.required.join(', ')}` : ''}${plan.options.length ? `\n options:\n${plan.options.map(o => ` - ${o}`).join('\n')}` : ''}`)
|
|
132
139
|
say(formatHuman(plan.diagnostics))
|
|
@@ -169,6 +176,31 @@ export async function main(argv, io = { stdout: process.stdout, stderr: process.
|
|
|
169
176
|
say(`weft audit: ${result.skipped ?? `${result.pages.length} pages at a phone's and a desktop's width, ${result.diagnostics.length} findings (${result.ms} ms)`}`)
|
|
170
177
|
return result.ok ? 0 : 1
|
|
171
178
|
}
|
|
179
|
+
case 'deploy': {
|
|
180
|
+
const loaded = await loadConfig(args.root)
|
|
181
|
+
const sub = args.positional[0]
|
|
182
|
+
if (sub === 'hosts') {
|
|
183
|
+
const result = hostsCommand(args.root, loaded.config)
|
|
184
|
+
if (args.json) print(result)
|
|
185
|
+
else say(formatHosts(result))
|
|
186
|
+
return 0
|
|
187
|
+
}
|
|
188
|
+
const unsafe = unsafeAllowed(args.flags['unsafe-allow'], loaded.config)
|
|
189
|
+
if (sub === 'init') {
|
|
190
|
+
const result = initCommand(args.root, loaded.config, { target: args.positional[1] ?? args.flags.target, dryRun: !!args.flags['dry-run'], force: !!args.flags.force, unsafe })
|
|
191
|
+
if (args.json) print(result)
|
|
192
|
+
say(formatHuman(result.findings))
|
|
193
|
+
say([`weft deploy init ${result.target}${result.dryRun ? ' (dry run: nothing written)' : ''}:`, ...result.actions.map(a => ` ${a}`), '', 'then, once:', ...result.next.map(n => ` ${n}`)].join('\n'))
|
|
194
|
+
return 0
|
|
195
|
+
}
|
|
196
|
+
if (sub !== undefined) throw Object.assign(new Error(`weft deploy ${sub}: use \`weft deploy\`, \`weft deploy hosts\` or \`weft deploy init [target]\``), { code: 'E_USAGE' })
|
|
197
|
+
const result = await deployCommand(args.root, loaded.config, { target: args.flags.target, prod: !!args.flags.prod, yes: !!args.flags.yes, unsafe, build: (root, o) => buildCommand(root, { ...o, unsafeAllow: args.flags['unsafe-allow'] }) })
|
|
198
|
+
if (args.json) print(result)
|
|
199
|
+
say(formatHuman(result.diagnostics))
|
|
200
|
+
if (result.dryRun) say([`weft deploy: a dry run for ${result.target}${result.prod ? ' (production)' : ' (a preview)'}. With --yes it runs:`, ...result.steps.map(s => ` ${s.what}${s.reads.length ? `\n reads ${s.reads.join(', ')} from the environment` : ''}`)].join('\n'))
|
|
201
|
+
else say(`weft deploy: ${result.target}${result.prod ? ' (production)' : ''} → ${result.url ?? 'deployed; the host printed no URL'}`)
|
|
202
|
+
return 0
|
|
203
|
+
}
|
|
172
204
|
case 'start':
|
|
173
205
|
return await startCommand(args.root, { runtime: args.flags.runtime })
|
|
174
206
|
case 'db': {
|
|
@@ -0,0 +1,64 @@
|
|
|
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
|
+
[/api\.render\.com\/deploy\/srv-[\w-]+\?key=[\w-]+/, 'a Render deploy hook (its key deploys the service)'],
|
|
14
|
+
[/cloudinary:\/\/\d+:[^@\s'"]+@/, 'a Cloudinary URL with its secret'],
|
|
15
|
+
]
|
|
16
|
+
const KEYED = /\b[\w-]*(token|secret|api[_-]?key|password)[\w-]*["']?\s*[:=]\s*["']([^"'\s$]{16,})["']/i
|
|
17
|
+
|
|
18
|
+
const FILES = ['weft.config.ts', 'weft.config.mts', 'weft.config.js', 'weft.config.mjs', 'weft.config.json', 'weft.config.yaml', 'weft.config.yml', 'netlify.toml', 'vercel.json', 'wrangler.toml', 'fly.toml', 'render.yaml', 'Dockerfile']
|
|
19
|
+
|
|
20
|
+
/** E008: a credential in a file that gets committed, comments included. An env var's name, or `${{ secrets.X }}`, is never one. */
|
|
21
|
+
export function writtenSecrets(root) {
|
|
22
|
+
const out = []
|
|
23
|
+
const wf = join(root, '.github/workflows')
|
|
24
|
+
const files = [...FILES, ...(existsSync(wf) ? readdirSync(wf).filter(f => /\.ya?ml$/.test(f)).map(f => `.github/workflows/${f}`) : [])]
|
|
25
|
+
for (const file of files) {
|
|
26
|
+
let text
|
|
27
|
+
try { text = readFileSync(join(root, file), 'utf8') } catch { continue }
|
|
28
|
+
text.split('\n').forEach((line, i) => {
|
|
29
|
+
const shaped = SHAPED.find(([re]) => re.test(line))
|
|
30
|
+
const keyed = !shaped && KEYED.exec(line)
|
|
31
|
+
if (shaped || keyed) out.push({ file, line: i + 1, what: shaped ? shaped[1] : `a value for \`${keyed[0].split(/["':=\s]/)[0]}\`` })
|
|
32
|
+
})
|
|
33
|
+
}
|
|
34
|
+
return out
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Hosts whose own domain says the proxy isn't in front. */
|
|
38
|
+
const OWN = /\.(fly\.dev|onrender\.com|netlify\.app|vercel\.app|workers\.dev|pages\.dev)$/
|
|
39
|
+
|
|
40
|
+
/** E010–E014 from the config, and the environment when `env` is given (a deploy or `weft doctor`). */
|
|
41
|
+
export function cdnFindings(config = {}, { env } = {}) {
|
|
42
|
+
const out = []
|
|
43
|
+
const cdn = config.deploy?.cdn ?? {}
|
|
44
|
+
const target = config.deploy?.target && host(config.deploy.target)
|
|
45
|
+
const add = (code, severity, why, fix) => out.push({ code, severity, file: 'weft.config.ts', why, fix })
|
|
46
|
+
if (cdn.proxy === 'cloudflare') {
|
|
47
|
+
let url
|
|
48
|
+
try { url = config.site?.url && new URL(config.site.url).hostname } catch {}
|
|
49
|
+
if (url && OWN.test(url)) add('E010', 'warning', `\`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`, 'Set `site.url` to the domain Cloudflare proxies, with its DNS record proxied (orange cloud) to the host')
|
|
50
|
+
if (target && target.kind !== 'server') add('E011', 'warning', `\`deploy.cdn.proxy\` puts Cloudflare in front of ${target.name}, which is a CDN already: two caches, and a deploy purges only its own`, 'Drop `cdn.proxy`, or deploy to a server host (`fly`, `render`) behind it')
|
|
51
|
+
}
|
|
52
|
+
const images = cdn.images
|
|
53
|
+
if (images?.provider === 'cloudinary') {
|
|
54
|
+
const plugins = (config.plugins ?? []).map(p => p?.name ?? p)
|
|
55
|
+
if (!plugins.some(n => typeof n === 'string' && n.includes('plugin-media'))) add('E012', 'error', '`deploy.cdn.images` is Cloudinary, and `@weft-js/plugin-media` is not in `plugins`: nothing would send it an image', '`weft add media`, or drop `cdn.images`')
|
|
56
|
+
const given = env?.CLOUDINARY_URL
|
|
57
|
+
const cloud = given && /@([\w-]+)\/?$/.exec(given)?.[1]
|
|
58
|
+
if (!images.cloud && !given) add('E013', 'error', '`deploy.cdn.images` names no `cloud`, and `CLOUDINARY_URL` is unset: there is no account to send images to', "Set `cdn.images.cloud` to the cloud's name, and `CLOUDINARY_URL` in the host's environment (never in a file)")
|
|
59
|
+
else if (cloud && images.cloud && cloud !== images.cloud) add('E013', 'error', `\`cdn.images.cloud\` is \`${images.cloud}\`, and \`CLOUDINARY_URL\` here names \`${cloud}\`: one of them is another account`, 'Make them name the same cloud')
|
|
60
|
+
else if (env && !given) add('E013', 'warning', '`CLOUDINARY_URL` is unset here: the server needs it in the host\'s environment', 'Set it in the host\'s environment settings, never in a file')
|
|
61
|
+
add('E014', 'info', '`cdn.images` is checked, but this weft doesn\'t send uploads to Cloudinary yet: they still go to `media({ server: { dir } })`', 'Nothing yet: research 46 §9 is the plan. On a function host, uploads need a server host with a disk until then (E003)')
|
|
62
|
+
}
|
|
63
|
+
return out
|
|
64
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
// The files a host needs, written by `weft deploy init` and compared by `weft check` (research 46 §7). Each text file
|
|
2
|
+
// starts with `# weft deploy init <target>, sha <hash>`, the hash of what follows: a matching hash that differs from what
|
|
3
|
+
// init writes now is stale (E006), and a hash that doesn't match was edited by hand, so weft leaves it alone.
|
|
4
|
+
|
|
5
|
+
import { createHash } from 'node:crypto'
|
|
6
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
7
|
+
import { dirname, join } from 'node:path'
|
|
8
|
+
import { host, presetFor } from './hosts.mjs'
|
|
9
|
+
|
|
10
|
+
/** The lockfile's manager: how the image and the workflow install. */
|
|
11
|
+
const MANAGERS = [
|
|
12
|
+
['pnpm-lock.yaml', { name: 'pnpm', install: 'corepack enable && pnpm install --frozen-lockfile', x: 'pnpm exec', cache: 'pnpm' }],
|
|
13
|
+
['yarn.lock', { name: 'yarn', install: 'corepack enable && yarn install --immutable', x: 'yarn', cache: 'yarn' }],
|
|
14
|
+
['bun.lock', { name: 'bun', install: 'bun install --frozen-lockfile', x: 'bunx', cache: '' }],
|
|
15
|
+
['package-lock.json', { name: 'npm', install: 'npm ci', x: 'npx', cache: 'npm' }],
|
|
16
|
+
]
|
|
17
|
+
export const managerOf = root => (MANAGERS.find(([lock]) => existsSync(join(root, lock))) ?? [null, { name: 'npm', install: 'npm install', x: 'npx', cache: '' }])[1]
|
|
18
|
+
|
|
19
|
+
/** Where a server host keeps what must last: SQLite's default `file:.data/app.db` lands on it unchanged. */
|
|
20
|
+
export const DATA = '/app/.data'
|
|
21
|
+
|
|
22
|
+
const sha = body => createHash('sha256').update(body).digest('hex').slice(0, 12)
|
|
23
|
+
const stamp = (target, body) => `# weft deploy init ${target}, sha ${sha(body)}\n${body}`
|
|
24
|
+
const STAMP = /^# weft deploy init (\w+), sha ([0-9a-f]{12})\n/
|
|
25
|
+
|
|
26
|
+
const IMAGES = { node: 'node:26-slim', bun: 'oven/bun:1-slim', deno: 'denoland/deno:latest' }
|
|
27
|
+
const RUN = { node: '["node", ".output/server/index.mjs"]', bun: '["bun", "run", ".output/server/index.mjs"]', deno: '["deno", "run", "-A", ".output/server/index.mjs"]' }
|
|
28
|
+
|
|
29
|
+
/** A two-stage image: build with the lockfile's manager, run the server. A sidecar needs cargo to build and `weft start` to run. */
|
|
30
|
+
function dockerfile({ runtime, manager, needs }) {
|
|
31
|
+
const rust = needs.sidecar || needs.native
|
|
32
|
+
const lines = [
|
|
33
|
+
`FROM ${IMAGES.node} AS build`,
|
|
34
|
+
'WORKDIR /app',
|
|
35
|
+
...(rust ? ['RUN apt-get update && apt-get install -y --no-install-recommends curl ca-certificates build-essential && curl -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal', 'ENV PATH=/root/.cargo/bin:$PATH'] : []),
|
|
36
|
+
'COPY . .',
|
|
37
|
+
`RUN ${manager.install}`,
|
|
38
|
+
`RUN ${manager.x} weft build --preset ${presetFor({}, runtime)}`,
|
|
39
|
+
'',
|
|
40
|
+
`FROM ${needs.sidecar ? IMAGES.node : IMAGES[runtime] ?? IMAGES.node}`,
|
|
41
|
+
'WORKDIR /app',
|
|
42
|
+
'ENV NODE_ENV=production HOST=0.0.0.0 PORT=8080',
|
|
43
|
+
// `weft start` runs each sidecar next to the server; without one the server is all there is
|
|
44
|
+
...(needs.sidecar ? ['COPY --from=build /app ./', 'EXPOSE 8080', `CMD ["${manager.x.split(' ')[0]}", ${manager.x.includes(' ') ? `"${manager.x.split(' ')[1]}", ` : ''}"weft", "start"]`] : ['COPY --from=build /app/.output ./.output', 'EXPOSE 8080', `CMD ${RUN[runtime] ?? RUN.node}`]),
|
|
45
|
+
]
|
|
46
|
+
return `${lines.join('\n')}\n`
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const DOCKERIGNORE = 'node_modules\n.output\n.weft/*\n!.weft/plugins.lock.json\n.env\n.env.*\n.git\n.netlify\n.vercel\ntarget\n'
|
|
50
|
+
|
|
51
|
+
function flyToml({ deploy, needs }) {
|
|
52
|
+
const fly = deploy.fly ?? {}
|
|
53
|
+
const held = needs.required || needs.feed || needs.sidecar
|
|
54
|
+
const lines = [
|
|
55
|
+
`app = ${JSON.stringify(fly.app ?? 'my-app')}`,
|
|
56
|
+
`primary_region = ${JSON.stringify(fly.region ?? 'iad')}`,
|
|
57
|
+
'',
|
|
58
|
+
'[env]',
|
|
59
|
+
' PORT = "8080"',
|
|
60
|
+
...(needs.uploads && fly.volume ? [` WEFT_MEDIA_DIR = "${fly.volume.path ?? DATA}/media"`] : []),
|
|
61
|
+
'',
|
|
62
|
+
'[http_service]',
|
|
63
|
+
' internal_port = 8080',
|
|
64
|
+
' force_https = true',
|
|
65
|
+
' auto_stop_machines = "stop"',
|
|
66
|
+
' auto_start_machines = true',
|
|
67
|
+
// a change feed is a listener: a stopped machine hears nothing
|
|
68
|
+
` min_machines_running = ${held ? 1 : 0}`,
|
|
69
|
+
...(fly.volume ? ['', '[[mounts]]', ` source = ${JSON.stringify(fly.volume.name ?? 'data')}`, ` destination = ${JSON.stringify(fly.volume.path ?? DATA)}`, ` initial_size = "${fly.volume.sizeGb ?? 1}gb"`] : []),
|
|
70
|
+
'',
|
|
71
|
+
'[[vm]]',
|
|
72
|
+
` memory = ${JSON.stringify(fly.memory ?? '512mb')}`,
|
|
73
|
+
]
|
|
74
|
+
return `${lines.join('\n')}\n`
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function renderYaml({ deploy, needs }) {
|
|
78
|
+
const r = deploy.render ?? {}
|
|
79
|
+
const lines = [
|
|
80
|
+
'services:',
|
|
81
|
+
' - type: web',
|
|
82
|
+
` name: ${r.service ?? 'my-app'}`,
|
|
83
|
+
' runtime: docker',
|
|
84
|
+
` plan: ${r.plan ?? (r.disk ? 'starter' : 'free')}`,
|
|
85
|
+
` region: ${r.region ?? 'oregon'}`,
|
|
86
|
+
// the workflow deploys once `weft check` passes, through the deploy hook
|
|
87
|
+
' autoDeploy: false',
|
|
88
|
+
' healthCheckPath: /',
|
|
89
|
+
' envVars:',
|
|
90
|
+
' - key: NODE_ENV',
|
|
91
|
+
' value: production',
|
|
92
|
+
...(needs.uploads && r.disk ? [' - key: WEFT_MEDIA_DIR', ` value: ${r.disk.path ?? DATA}/media`] : []),
|
|
93
|
+
...(r.disk ? [' disk:', ' name: data', ` mountPath: ${r.disk.path ?? DATA}`, ` sizeGB: ${r.disk.sizeGb ?? 1}`] : []),
|
|
94
|
+
]
|
|
95
|
+
return `${lines.join('\n')}\n`
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const netlifyToml = ({ manager }) => `[build]\n command = "${manager.x} weft build --preset netlify"\n publish = "dist"\n\n[build.environment]\n NODE_VERSION = "26"\n`
|
|
99
|
+
|
|
100
|
+
const vercelJson = ({ manager }) => `${JSON.stringify({ $schema: 'https://openapi.vercel.sh/vercel.json', framework: null, installCommand: manager.install.replace(/^corepack enable && /, ''), buildCommand: `${manager.x} weft build --preset vercel` }, null, 2)}\n`
|
|
101
|
+
|
|
102
|
+
const wranglerToml = ({ deploy }) => `name = ${JSON.stringify(deploy.cloudflare?.name ?? 'my-app')}\ncompatibility_flags = ["nodejs_compat"]\n`
|
|
103
|
+
|
|
104
|
+
/** The workflow: on push to the branch, check and deploy to production; on a pull request, a preview where the host has one. */
|
|
105
|
+
function workflow({ target, deploy, manager }) {
|
|
106
|
+
const h = host(target)
|
|
107
|
+
const previews = h.kind !== 'server'
|
|
108
|
+
const node = manager.name === 'bun' ? [' - uses: oven-sh/setup-bun@v2'] : [' - uses: actions/setup-node@v4', ' with:', ' node-version: 26', ...(manager.cache ? [` cache: ${manager.cache}`] : [])]
|
|
109
|
+
const lines = [
|
|
110
|
+
'name: weft deploy',
|
|
111
|
+
'on:',
|
|
112
|
+
' push:',
|
|
113
|
+
` branches: [${deploy.branch ?? 'main'}]`,
|
|
114
|
+
...(previews ? [' pull_request:'] : []),
|
|
115
|
+
'permissions:',
|
|
116
|
+
' contents: read',
|
|
117
|
+
'concurrency:',
|
|
118
|
+
' group: weft-deploy-${{ github.ref }}',
|
|
119
|
+
' cancel-in-progress: true',
|
|
120
|
+
'jobs:',
|
|
121
|
+
' deploy:',
|
|
122
|
+
' runs-on: ubuntu-latest',
|
|
123
|
+
' steps:',
|
|
124
|
+
' - uses: actions/checkout@v4',
|
|
125
|
+
...node,
|
|
126
|
+
...(target === 'fly' ? [' - uses: superfly/flyctl-actions/setup-flyctl@master'] : []),
|
|
127
|
+
` - run: ${manager.install}`,
|
|
128
|
+
` - run: ${manager.x} weft check`,
|
|
129
|
+
` - run: ${manager.x} weft deploy --yes${previews ? " ${{ github.event_name == 'push' && '--prod' || '' }}" : ' --prod'}`,
|
|
130
|
+
' env:',
|
|
131
|
+
...h.env.map(k => ` ${k}: \${{ secrets.${k} }}`),
|
|
132
|
+
]
|
|
133
|
+
return `${lines.join('\n')}\n`
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const WORKFLOW = '.github/workflows/weft-deploy.yml'
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* What `weft deploy init <target>` writes, as `{ path, text }`, every text one stamped.
|
|
140
|
+
* @param {{ target: string, deploy: any, needs: any, runtime: string, manager: any }} ctx
|
|
141
|
+
*/
|
|
142
|
+
export function filesFor(ctx) {
|
|
143
|
+
const { target } = ctx
|
|
144
|
+
const out = []
|
|
145
|
+
const text = (path, body) => out.push({ path, text: stamp(target, body) })
|
|
146
|
+
if (target === 'netlify') text('netlify.toml', netlifyToml(ctx))
|
|
147
|
+
if (target === 'vercel') out.push({ path: 'vercel.json', text: vercelJson(ctx), json: true })
|
|
148
|
+
if (target === 'cloudflare') text('wrangler.toml', wranglerToml(ctx))
|
|
149
|
+
if (target === 'fly') text('fly.toml', flyToml(ctx))
|
|
150
|
+
if (target === 'render') text('render.yaml', renderYaml(ctx))
|
|
151
|
+
if (target === 'fly' || target === 'render') {
|
|
152
|
+
text('Dockerfile', dockerfile(ctx))
|
|
153
|
+
out.push({ path: '.dockerignore', text: DOCKERIGNORE, plain: true })
|
|
154
|
+
}
|
|
155
|
+
if ((ctx.deploy.ci ?? 'github') === 'github') text(WORKFLOW, workflow(ctx))
|
|
156
|
+
return out
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Each generated file's state: `missing`, `current`, `stale` (weft's, from an older config), `edited` (by hand: left
|
|
161
|
+
* alone), or `foreign` (no stamp: not weft's).
|
|
162
|
+
*/
|
|
163
|
+
export function fileStates(root, files) {
|
|
164
|
+
return files.map(f => {
|
|
165
|
+
const abs = join(root, f.path)
|
|
166
|
+
if (!existsSync(abs)) return { ...f, state: 'missing' }
|
|
167
|
+
const now = readFileSync(abs, 'utf8')
|
|
168
|
+
if (now === f.text) return { ...f, state: 'current' }
|
|
169
|
+
if (f.json) return { ...f, state: JSON.parse(now || '{}').buildCommand?.includes('weft build --preset vercel') ? 'edited' : 'stale' }
|
|
170
|
+
if (f.plain) return { ...f, state: 'edited' }
|
|
171
|
+
const m = STAMP.exec(now)
|
|
172
|
+
if (!m) return { ...f, state: 'foreign' }
|
|
173
|
+
return { ...f, state: sha(now.slice(m[0].length)) === m[2] ? 'stale' : 'edited' }
|
|
174
|
+
})
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Write what's missing or stale. Never a file weft didn't write, or one edited since. */
|
|
178
|
+
export function writeFiles(root, states, { force = false } = {}) {
|
|
179
|
+
const written = []
|
|
180
|
+
for (const f of states) {
|
|
181
|
+
if (f.state === 'current' || (!force && (f.state === 'edited' || f.state === 'foreign'))) continue
|
|
182
|
+
mkdirSync(dirname(join(root, f.path)), { recursive: true })
|
|
183
|
+
writeFileSync(join(root, f.path), f.text)
|
|
184
|
+
written.push(f.path)
|
|
185
|
+
}
|
|
186
|
+
return written
|
|
187
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// Which hosts can run an app, and which one to suggest (research 46 §4). A finding is a need the host can't meet: an
|
|
2
|
+
// error when nothing on that host fixes it, a warning when it runs degraded or needs a paid add-on.
|
|
3
|
+
|
|
4
|
+
import { HOSTS } from './hosts.mjs'
|
|
5
|
+
|
|
6
|
+
const SERVERS = "`deploy: { target: 'fly' }` or `'render'`: a server that stays up"
|
|
7
|
+
const SPLIT = 'or keep the pages here and run realtime on a small server (the split, `weft docs deploy`)'
|
|
8
|
+
|
|
9
|
+
/** What a disk need asks of a host, with the config that gives it one. */
|
|
10
|
+
function disk(h, need, cfg = {}, assume) {
|
|
11
|
+
if (h.disk === 'none') return { severity: 'error', fix: need.what === 'a SQLite file' ? `${SERVERS}, with a volume or disk, or a hosted database here (Postgres on Neon or Supabase, Turso for SQLite)` : `${SERVERS}, with a volume or disk: this weft has no object store for uploads yet (research 46 §9)` }
|
|
12
|
+
const has = h.id === 'fly' ? cfg.fly?.volume : cfg.render?.disk
|
|
13
|
+
if (has) return
|
|
14
|
+
const add = h.id === 'fly' ? "`deploy.fly.volume: { name: 'data', sizeGb: 1 }` (billed)" : "`deploy.render.disk: { sizeGb: 1 }` (a paid plan)"
|
|
15
|
+
return { severity: assume ? 'warning' : 'error', fix: `add ${add}: it mounts at \`/app/.data\`, where SQLite's default \`file:.data/app.db\` already is, and uploads go to \`.data/media\`` }
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @param {ReturnType<import('./needs.mjs').needsOf>} needs
|
|
20
|
+
* @param {import('./hosts.mjs').Host} h
|
|
21
|
+
* @param {{ deploy?: any, assume?: boolean, local?: boolean }} [options] `assume`: a paid add-on is a warning (the hosts list,
|
|
22
|
+
* before a target is chosen). `local`: the build is deployed from this machine.
|
|
23
|
+
* @returns {{ host: string, fits: boolean, findings: { code: string, severity: string, file: string, why: string, fix: string }[] }}
|
|
24
|
+
*/
|
|
25
|
+
export function fit(needs, h, { deploy = {}, assume = false, local = false } = {}) {
|
|
26
|
+
const findings = []
|
|
27
|
+
const add = (code, severity, need, why, fix) => findings.push({ code, severity, file: need.file, why, fix })
|
|
28
|
+
if (needs.sockets && !h.sockets) add('E001', 'error', needs.sockets, `${needs.sockets.what} holds a WebSocket, and ${h.name} ends each process with its request: the page would load and never connect (R024)`, `${SERVERS}, or \`'cloudflare'\` (Durable Objects), ${SPLIT}`)
|
|
29
|
+
if (needs.required && !h.held) add('E002', 'error', needs.required, `${needs.required.what} needs a change feed that stays connected, and ${h.name} keeps no process between requests (R013)`, `${SERVERS}, run \`weft db listen\` on one with \`listener: 'external'\`, or drop \`required\``)
|
|
30
|
+
if (needs.sidecar && !h.sidecars) add('E002', 'error', needs.sidecar, `${needs.sidecar.what} is a second process next to the server, and ${h.name} runs only the server`, `${SERVERS} (the image runs both), or deploy the sidecar on its own and set \`rust.services.<name>.external: true\` with its \`WEFT_RUST_<NAME>_URL\``)
|
|
31
|
+
if (needs.native && h.kind === 'workers') add('R104', 'error', needs.native, `${needs.native.what} loads an addon or a \`.wasm\` file, and ${h.name} has no file system`, "use `remote()`, or a server host")
|
|
32
|
+
for (const key of ['sqlite', 'uploads']) {
|
|
33
|
+
const need = needs[key]
|
|
34
|
+
const d = need && disk(h, need, deploy, assume)
|
|
35
|
+
if (d) add('E003', d.severity, need, `${need.what} is kept on local disk, and ${h.disk === 'none' ? `${h.name}'s disk is gone after each request` : `${h.name} wipes it on each deploy without one`}`, d.fix)
|
|
36
|
+
}
|
|
37
|
+
if (needs.feed && !h.held) add('R014', 'warning', needs.feed, `${needs.feed.what} needs a process that stays up: on ${h.name}, writes from outside the app won't reach \`.live()\``, `${SERVERS}, or \`weft db listen\` on one with \`listener: 'external'\``)
|
|
38
|
+
if (needs.memoryBus && h.many) add('E009', 'warning', needs.memoryBus, `${needs.memoryBus.what} reaches the clients of one instance, and ${h.name} runs many`, h.id === 'cloudflare' ? '`durableObjectBus` in `src/realtime.server.ts`' : '`weft add redis` or `weft add nats`, or `postgresBus({ url })` in `src/realtime.server.ts`')
|
|
39
|
+
if (needs.addons && local && h.prebuilt && process.platform !== 'linux') add('E004', 'warning', needs.addons, `${needs.addons.what} has a compiled addon, built here for ${process.platform}, and ${h.name} runs Linux`, 'deploy from CI (`deploy.ci: \'github\'` builds on Linux), or drop the addon')
|
|
40
|
+
return { host: h.id, fits: !findings.some(f => f.severity === 'error'), findings }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Every host, fitted, and the one to suggest (research 46 §4), with why. */
|
|
44
|
+
export function hostsReport(needs, { deploy = {} } = {}) {
|
|
45
|
+
const fits = HOSTS.map(h => ({ ...fit(needs, h, { deploy, assume: true }), name: h.name, kind: h.kind, cost: h.cost, pricing: h.pricing, deploys: h.deploys }))
|
|
46
|
+
return { fits, suggestion: suggest(needs, fits) }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** @returns {{ host: string, why: string, alternative?: string }} */
|
|
50
|
+
export function suggest(needs, fits = HOSTS.map(h => fit(needs, h, { assume: true }))) {
|
|
51
|
+
const ok = id => fits.find(f => f.host === id)?.fits
|
|
52
|
+
const clean = id => ok(id) && !fits.find(f => f.host === id).findings.length
|
|
53
|
+
const held = needs.required || needs.sidecar || needs.feed
|
|
54
|
+
const kept = needs.sqlite || needs.uploads
|
|
55
|
+
if (!needs.sockets && !held && !kept) {
|
|
56
|
+
if (clean('netlify')) return { host: 'netlify', why: 'nothing here needs a server that stays up: a CDN with functions behind it is the cheapest and fastest host', alternative: 'vercel' }
|
|
57
|
+
if (ok('netlify')) return { host: 'netlify', why: 'nothing here needs a server that stays up, once the warnings are fixed', alternative: 'vercel' }
|
|
58
|
+
}
|
|
59
|
+
if (needs.sockets && !held && !kept && !needs.native && clean('cloudflare')) return { host: 'cloudflare', why: 'channels need a socket, and a Durable Object holds one for each room without a server to run', alternative: 'fly' }
|
|
60
|
+
if (kept) return { host: 'fly', why: `${kept.what} needs a disk that lasts: a Fly volume is per machine and cheap`, alternative: 'render' }
|
|
61
|
+
return { host: 'fly', why: `${(needs.sockets ?? held).what} needs a server that stays up, and Fly runs the app as it is. Render's free instance does too, but sleeps after 15 idle minutes`, alternative: 'render' }
|
|
62
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// The hosts `weft deploy` knows (research 46 §3): what each can run, its preset, what its CLI reads, and a cost hint.
|
|
2
|
+
// The fit (fit.mjs) and the generated files (files.mjs) both read this table, so they can't disagree about a host.
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* @typedef {{
|
|
6
|
+
* id: string, name: string, kind: 'functions' | 'workers' | 'server',
|
|
7
|
+
* preset?: string, sockets: boolean, held: boolean, disk: 'none' | 'volume' | 'paid-disk', sidecars: boolean,
|
|
8
|
+
* many: boolean, prebuilt: boolean, env: string[], cost: string, pricing: string, docs: string, deploys: boolean,
|
|
9
|
+
* }} Host
|
|
10
|
+
* `preset`: the Nitro preset, or none for the runtime's server preset. `prebuilt`: what is built here is what ships, so a
|
|
11
|
+
* native addon from this machine goes too. `deploys`: `weft deploy` drives it (AWS is a fit only, research 46 §11).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** @type {Host[]} */
|
|
15
|
+
export const HOSTS = [
|
|
16
|
+
{
|
|
17
|
+
id: 'netlify', name: 'Netlify', kind: 'functions', preset: 'netlify', sockets: false, held: false, disk: 'none', sidecars: false, many: true, prebuilt: true,
|
|
18
|
+
env: ['NETLIFY_AUTH_TOKEN', 'NETLIFY_SITE_ID'], deploys: true,
|
|
19
|
+
cost: 'free tier; usage past it is billed in credits', pricing: 'https://www.netlify.com/pricing/', docs: 'https://docs.netlify.com/cli/get-started/',
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
id: 'vercel', name: 'Vercel', kind: 'functions', preset: 'vercel', sockets: false, held: false, disk: 'none', sidecars: false, many: true, prebuilt: true,
|
|
23
|
+
env: ['VERCEL_TOKEN', 'VERCEL_ORG_ID', 'VERCEL_PROJECT_ID'], deploys: true,
|
|
24
|
+
cost: 'Hobby is free for non-commercial use; Pro is per seat', pricing: 'https://vercel.com/pricing', docs: 'https://vercel.com/docs/cli',
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
id: 'cloudflare', name: 'Cloudflare Workers', kind: 'workers', preset: 'cloudflare_durable', sockets: true, held: false, disk: 'none', sidecars: false, many: true, prebuilt: true,
|
|
28
|
+
env: ['CLOUDFLARE_API_TOKEN', 'CLOUDFLARE_ACCOUNT_ID'], deploys: true,
|
|
29
|
+
cost: 'free tier includes Workers and SQLite-backed Durable Objects', pricing: 'https://developers.cloudflare.com/workers/platform/pricing/', docs: 'https://developers.cloudflare.com/workers/wrangler/',
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
id: 'fly', name: 'Fly.io', kind: 'server', sockets: true, held: true, disk: 'volume', sidecars: true, many: false, prebuilt: false,
|
|
33
|
+
env: ['FLY_API_TOKEN'], deploys: true,
|
|
34
|
+
cost: 'pay as you go, no free tier for new organizations', pricing: 'https://fly.io/docs/about/pricing/', docs: 'https://fly.io/docs/flyctl/',
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
id: 'render', name: 'Render', kind: 'server', sockets: true, held: true, disk: 'paid-disk', sidecars: true, many: false, prebuilt: false,
|
|
38
|
+
env: ['RENDER_DEPLOY_HOOK_URL'], deploys: true,
|
|
39
|
+
cost: 'free instance sleeps after 15 idle minutes and has no disk; paid plans add a disk', pricing: 'https://render.com/pricing', docs: 'https://render.com/docs/blueprint-spec',
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
id: 'aws', name: 'AWS Lambda', kind: 'functions', preset: 'aws_lambda', sockets: false, held: false, disk: 'none', sidecars: false, many: true, prebuilt: true,
|
|
43
|
+
env: [], deploys: false,
|
|
44
|
+
cost: 'Lambda has a free tier; a server would go on App Runner, billed per running instance', pricing: 'https://aws.amazon.com/pricing/', docs: 'https://nitro.build/deploy/providers/aws',
|
|
45
|
+
},
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
export const TARGETS = HOSTS.filter(h => h.deploys).map(h => h.id)
|
|
49
|
+
|
|
50
|
+
export const host = id => HOSTS.find(h => h.id === id)
|
|
51
|
+
|
|
52
|
+
/** `runtime` in the config as the server preset a container runs. */
|
|
53
|
+
const SERVER_PRESETS = { node: 'node', bun: 'bun', deno: 'deno_server' }
|
|
54
|
+
|
|
55
|
+
/** The preset a build for this host uses. */
|
|
56
|
+
export const presetFor = (h, runtime = 'node') => h.preset ?? SERVER_PRESETS[runtime] ?? 'node'
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
// `weft deploy`, `weft deploy hosts`, `weft deploy init`, and what `weft check` and `weft doctor` say about the deploy
|
|
2
|
+
// (research 46). Nothing here talks to a provider except `weft deploy --yes`, which runs the host's own CLI.
|
|
3
|
+
|
|
4
|
+
import { spawnSync } from 'node:child_process'
|
|
5
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
|
|
6
|
+
import { join, relative } from 'node:path'
|
|
7
|
+
import ts from 'typescript'
|
|
8
|
+
import { diagnostic, locate } from '../diag.mjs'
|
|
9
|
+
import { findConfig, isDataConfig } from '../load-config.mjs'
|
|
10
|
+
import { cdnFindings, writtenSecrets } from './cdn.mjs'
|
|
11
|
+
import { fileStates, filesFor, managerOf, writeFiles } from './files.mjs'
|
|
12
|
+
import { fit, hostsReport } from './fit.mjs'
|
|
13
|
+
import { HOSTS, TARGETS, host, presetFor } from './hosts.mjs'
|
|
14
|
+
import { needsOf } from './needs.mjs'
|
|
15
|
+
|
|
16
|
+
export { HOSTS, TARGETS, fit, hostsReport, needsOf }
|
|
17
|
+
|
|
18
|
+
const usage = message => Object.assign(new Error(message), { code: 'E_USAGE' })
|
|
19
|
+
const refused = message => Object.assign(new Error(message), { code: 'E_REFUSED' })
|
|
20
|
+
|
|
21
|
+
/** A finding as a diagnostic, pointing at the file that shows the need. */
|
|
22
|
+
function toDiagnostic(root, f) {
|
|
23
|
+
let text = ''
|
|
24
|
+
try { text = readFileSync(join(root, f.file), 'utf8') } catch {}
|
|
25
|
+
const at = f.line ? { span: [f.line, 1, f.line, 1] } : text && f.needle ? locate(text, f.needle) : {}
|
|
26
|
+
return diagnostic({ code: f.code, severity: f.severity, file: f.file, ...at, why: f.why, help: f.fix })
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The target this run is about: `--target`, then `deploy.target`, then the host whose preset `--preset` names. */
|
|
30
|
+
export function targetOf(config, { target, preset } = {}) {
|
|
31
|
+
const id = target ?? config.deploy?.target ?? (preset && HOSTS.find(h => h.preset === preset)?.id)
|
|
32
|
+
if (id && !host(id)) throw usage(`unknown deploy target \`${id}\`: use ${TARGETS.join(', ')}`)
|
|
33
|
+
return id
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** E001–E004, E006, E008–E014 for `weft check`: the configured target's fit, its files, written secrets, CDN setup. */
|
|
37
|
+
export function deployChecks(root, config, { preset } = {}) {
|
|
38
|
+
const out = writtenSecrets(root).map(s => ({ code: 'E008', severity: 'error', file: s.file, line: s.line, why: `${s.file}:${s.line} has ${s.what} in it: it would be committed, and anyone with the repository could deploy or bill as you`, fix: 'Move it to the environment (the host\'s settings, or GitHub\'s secrets for the workflow), and rotate it: it has been seen' }))
|
|
39
|
+
let id
|
|
40
|
+
try { id = targetOf(config, { preset }) } catch { id = undefined }
|
|
41
|
+
const deploy = config.deploy ?? {}
|
|
42
|
+
if (id) {
|
|
43
|
+
out.push(...fit(needsOf(root, config), host(id), { deploy }).findings)
|
|
44
|
+
if (deploy.target === id && host(id).deploys) {
|
|
45
|
+
for (const f of fileStates(root, filesFor(initContext(root, config, id)))) {
|
|
46
|
+
if (f.state === 'missing' || f.state === 'stale') out.push({ code: 'E006', severity: 'warning', file: f.path, why: `\`${f.path}\` is ${f.state === 'missing' ? 'missing' : 'from an older config'}: ${host(id).name} would deploy ${f.state === 'missing' ? 'without it' : 'what it said then'}`, fix: '`weft deploy init` writes it (a file edited by hand is left alone)' })
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
out.push(...cdnFindings(config))
|
|
51
|
+
return out.map(f => toDiagnostic(root, f))
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** What the generators read. */
|
|
55
|
+
export function initContext(root, config, target) {
|
|
56
|
+
return { target, deploy: config.deploy ?? {}, needs: needsOf(root, config), runtime: config.runtime ?? 'node', manager: managerOf(root) }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** `weft deploy hosts`, and the `deploy` part of `weft doctor`: every host's fit and the suggestion (E005 when none is set). */
|
|
60
|
+
export function hostsCommand(root, config) {
|
|
61
|
+
const needs = needsOf(root, config)
|
|
62
|
+
const report = hostsReport(needs, { deploy: config.deploy })
|
|
63
|
+
const used = Object.entries(needs).filter(([k, v]) => k !== 'runtime' && v).map(([k, v]) => ({ need: k, ...v }))
|
|
64
|
+
const diagnostics = config.deploy?.target ? [] : [diagnostic({ code: 'E005', severity: 'info', file: findConfig(root) ? relative(root, findConfig(root)) : 'weft.config.ts', why: `No \`deploy.target\`. ${report.fits.filter(f => f.fits && f.deploys).map(f => f.name).join(', ')} can run this app; weft suggests ${host(report.suggestion.host).name}: ${report.suggestion.why}.`, help: `\`weft deploy init ${report.suggestion.host}\`` })]
|
|
65
|
+
return { needs: used, ...report, target: config.deploy?.target ?? null, diagnostics }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function formatHosts(r) {
|
|
69
|
+
const lines = [`needs: ${r.needs.length ? r.needs.map(n => `${n.what} (${n.file})`).join(', ') : 'nothing a CDN with functions can\'t give'}`, '']
|
|
70
|
+
for (const f of r.fits) {
|
|
71
|
+
const mark = f.fits ? (f.findings.length ? '~' : '✓') : '✗'
|
|
72
|
+
lines.push(`${mark} ${f.host.padEnd(11)} ${f.name}${f.deploys ? '' : ' (fit only: `weft deploy` doesn\'t drive it yet)'}`)
|
|
73
|
+
for (const x of f.findings) lines.push(` ${x.code} ${x.why}. Fix: ${x.fix}`)
|
|
74
|
+
lines.push(` cost: ${f.cost} (${f.pricing})`)
|
|
75
|
+
}
|
|
76
|
+
lines.push('', `suggested: ${r.suggestion.host}, because ${r.suggestion.why}${r.suggestion.alternative ? `. Next best: ${r.suggestion.alternative}` : ''}`)
|
|
77
|
+
return lines.join('\n')
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The object literal `export default` gives, directly or through `defineConfig(…)`. */
|
|
81
|
+
function configObject(sf) {
|
|
82
|
+
for (const st of sf.statements) {
|
|
83
|
+
if (!ts.isExportAssignment(st)) continue
|
|
84
|
+
const value = ts.isCallExpression(st.expression) ? st.expression.arguments[0] : st.expression
|
|
85
|
+
if (value && ts.isObjectLiteralExpression(value)) return value
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** The `deploy` block init writes when the config has none: the names a host needs, from package.json, and a disk when data needs one. */
|
|
90
|
+
export function defaultDeploy(root, target, needs) {
|
|
91
|
+
let name = 'my-app'
|
|
92
|
+
try { name = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')).name?.replace(/^@[^/]+\//, '').replace(/[^a-z0-9-]/gi, '-').toLowerCase() || name } catch {}
|
|
93
|
+
const kept = needs.sqlite || needs.uploads
|
|
94
|
+
const per = {
|
|
95
|
+
netlify: { site: name }, vercel: { project: name }, cloudflare: { name },
|
|
96
|
+
fly: { app: name, region: 'iad', ...(kept && { volume: { name: 'data', sizeGb: 1 } }) },
|
|
97
|
+
render: { service: name, ...(kept && { plan: 'starter', disk: { sizeGb: 1 } }) },
|
|
98
|
+
}
|
|
99
|
+
return { target, [target]: per[target], ci: 'github', branch: 'main' }
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const literal = (v, pad = ' ') => {
|
|
103
|
+
if (v && typeof v === 'object') return `{ ${Object.entries(v).map(([k, x]) => `${k}: ${literal(x, pad)}`).join(', ')} }`
|
|
104
|
+
return typeof v === 'string' ? `'${v}'` : String(v)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** The config with `deploy` added. Refuses to touch one that already has it, or that isn't a plain object. */
|
|
108
|
+
export function withDeploy(file, text, deploy) {
|
|
109
|
+
if (isDataConfig(file)) {
|
|
110
|
+
if (file.endsWith('.json')) return `${JSON.stringify({ ...JSON.parse(text), deploy }, null, 2)}\n`
|
|
111
|
+
return `${text.replace(/\n*$/, '\n')}deploy:\n${Object.entries(deploy).map(([k, v]) => ` ${k}: ${JSON.stringify(v)}`).join('\n')}\n`
|
|
112
|
+
}
|
|
113
|
+
const sf = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true)
|
|
114
|
+
const object = configObject(sf)
|
|
115
|
+
if (!object) throw refused(`${file} doesn't export a plain object, so \`weft deploy init\` can't add \`deploy\` to it. Add \`deploy: ${literal(deploy)}\` by hand. Nothing was changed.`)
|
|
116
|
+
const block = `deploy: ${literal(deploy)},`
|
|
117
|
+
const last = object.properties.at(-1)
|
|
118
|
+
return last ? text.slice(0, object.properties.end) + `${object.properties.hasTrailingComma ? '' : ','}\n ${block}` + text.slice(object.properties.end) : text.slice(0, object.getStart(sf) + 1) + `\n ${block}\n` + text.slice(object.getStart(sf) + 1)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* `weft deploy init [target]`: the host's files and workflow, and `deploy` in the config when it has none. Refuses a
|
|
123
|
+
* target the app doesn't fit unless `unsafe` lets the codes through.
|
|
124
|
+
*/
|
|
125
|
+
export function initCommand(root, config, { target, dryRun = false, force = false, unsafe } = {}) {
|
|
126
|
+
const needs = needsOf(root, config)
|
|
127
|
+
const id = targetOf(config, { target }) ?? hostsReport(needs).suggestion.host
|
|
128
|
+
const h = host(id)
|
|
129
|
+
if (!h.deploys) throw usage(`\`weft deploy\` doesn't drive ${h.name} yet (research 46 §11): ${TARGETS.join(', ')} it does`)
|
|
130
|
+
if (config.deploy?.target && target && config.deploy.target !== target) throw refused(`\`deploy.target\` is \`${config.deploy.target}\` in the config. Change it there to move to \`${target}\`, then run this again. Nothing was changed.`)
|
|
131
|
+
const deploy = config.deploy ?? defaultDeploy(root, id, needs)
|
|
132
|
+
const result = fit(needs, h, { deploy })
|
|
133
|
+
const blocking = result.findings.filter(f => f.severity === 'error' && !unsafe?.allows(f))
|
|
134
|
+
if (blocking.length) throw refused(`${h.name} can't run this app:\n${blocking.map(f => ` ${f.code} ${f.file}: ${f.why}.\n Fix: ${f.fix}`).join('\n')}\n\`weft deploy hosts\` shows which hosts can. Nothing was changed.`)
|
|
135
|
+
const ctx = { ...initContext(root, { ...config, deploy }, id) }
|
|
136
|
+
const states = fileStates(root, filesFor(ctx))
|
|
137
|
+
const configFile = findConfig(root)
|
|
138
|
+
const actions = []
|
|
139
|
+
let written = []
|
|
140
|
+
if (!config.deploy) {
|
|
141
|
+
const file = configFile ?? join(root, 'weft.config.ts')
|
|
142
|
+
const text = configFile ? readFileSync(configFile, 'utf8') : "import { defineConfig } from '@weft-js/core/config'\n\nexport default defineConfig({})\n"
|
|
143
|
+
actions.push(`${relative(root, file)}: ${configFile ? '' : '(new) '}deploy: ${literal(deploy)}`)
|
|
144
|
+
if (!dryRun) writeFileSync(file, withDeploy(file, text, deploy))
|
|
145
|
+
}
|
|
146
|
+
for (const f of states) actions.push(`${f.path}: ${{ missing: 'write', stale: 'rewrite (from an older config)', current: 'current', edited: force ? 'overwrite (edited by hand; --force)' : 'edited by hand: left alone', foreign: force ? 'overwrite (--force)' : 'not written by weft: left alone' }[f.state]}`)
|
|
147
|
+
if (!dryRun) written = writeFiles(root, states, { force })
|
|
148
|
+
const next = [
|
|
149
|
+
...{
|
|
150
|
+
netlify: [`netlify sites:create --name ${deploy.netlify?.site ?? '<name>'} (once; or link an existing site with \`netlify link\`)`],
|
|
151
|
+
vercel: [`vercel link --project ${deploy.vercel?.project ?? '<name>'} (once: writes .vercel/project.json with the org and project ids)`],
|
|
152
|
+
cloudflare: ['wrangler login (once, locally; CI reads CLOUDFLARE_API_TOKEN)'],
|
|
153
|
+
fly: [`flyctl apps create ${deploy.fly?.app ?? '<name>'} (once; billed per running machine)`, ...(deploy.fly?.volume ? [`flyctl volumes create ${deploy.fly.volume.name ?? 'data'} --size ${deploy.fly.volume.sizeGb ?? 1} --region ${deploy.fly.region ?? 'iad'} -a ${deploy.fly.app} (once; billed)`] : [])],
|
|
154
|
+
render: ['Create the service from render.yaml: Render dashboard → New → Blueprint, on this repository (once)', 'Copy the service\'s deploy hook (Settings → Deploy Hook) into RENDER_DEPLOY_HOOK_URL'],
|
|
155
|
+
}[id],
|
|
156
|
+
...((deploy.ci ?? 'github') === 'github' ? [`gh secret set ${h.env.join(' / ')} (each one, from the host's dashboard: weft never writes them)`] : []),
|
|
157
|
+
`weft deploy (a dry run: shows what --yes would run)`,
|
|
158
|
+
]
|
|
159
|
+
return { ok: true, target: id, dryRun, actions, written, next, findings: result.findings.map(f => toDiagnostic(root, f)) }
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** The host's CLI: on PATH, else through npx for the ones on npm. */
|
|
163
|
+
function cliFor(name, { which = cmd => spawnSync(process.platform === 'win32' ? 'where' : 'which', [cmd], { encoding: 'utf8' }).status === 0 } = {}) {
|
|
164
|
+
const npm = { netlify: 'netlify-cli', vercel: 'vercel', wrangler: 'wrangler' }
|
|
165
|
+
for (const cmd of name === 'flyctl' ? ['flyctl', 'fly'] : [name]) if (which(cmd)) return [cmd]
|
|
166
|
+
if (npm[name]) return ['npx', '--yes', npm[name]]
|
|
167
|
+
throw refused(`\`${name}\` isn't installed: ${name === 'flyctl' ? 'https://fly.io/docs/flyctl/install/' : `npm install -g ${npm[name] ?? name}`}`)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* The steps `weft deploy` takes for a target, as data: a dry run prints them, `--yes` runs them. A step reads env vars
|
|
172
|
+
* by name; their values are filled in only as it runs.
|
|
173
|
+
*/
|
|
174
|
+
export function deploySteps(root, config, { target, prod = false, which } = {}) {
|
|
175
|
+
const h = host(target)
|
|
176
|
+
const deploy = config.deploy ?? {}
|
|
177
|
+
const runtime = config.runtime ?? 'node'
|
|
178
|
+
const needs = needsOf(root, config)
|
|
179
|
+
const steps = []
|
|
180
|
+
const build = { kind: 'build', what: `weft build --preset ${presetFor(h, runtime)}`, preset: presetFor(h, runtime) }
|
|
181
|
+
if (target === 'netlify') {
|
|
182
|
+
const site = process.env.NETLIFY_SITE_ID ? [] : deploy.netlify?.site ? ['--site', deploy.netlify.site] : []
|
|
183
|
+
steps.push(build, { kind: 'run', cli: 'netlify', args: ['deploy', '--dir', 'dist', '--functions', '.netlify/functions-internal', '--json', ...site, ...(prod ? ['--prod'] : [])], reads: ['NETLIFY_AUTH_TOKEN', 'NETLIFY_SITE_ID'], url: 'json' })
|
|
184
|
+
}
|
|
185
|
+
if (target === 'vercel') steps.push(build, { kind: 'run', cli: 'vercel', args: ['deploy', '--prebuilt', '--yes', ...(prod ? ['--prod'] : [])], token: 'VERCEL_TOKEN', reads: ['VERCEL_TOKEN', 'VERCEL_ORG_ID', 'VERCEL_PROJECT_ID'], url: 'last-line' })
|
|
186
|
+
if (target === 'cloudflare') steps.push(build, { kind: 'run', cli: 'wrangler', args: ['deploy'], cwd: '.output', reads: ['CLOUDFLARE_API_TOKEN', 'CLOUDFLARE_ACCOUNT_ID'], url: 'workers' })
|
|
187
|
+
if (target === 'fly') {
|
|
188
|
+
// one machine unless a bus reaches the others: a second machine would split the rooms, and a volume is per machine
|
|
189
|
+
const one = needs.memoryBus || deploy.fly?.volume
|
|
190
|
+
steps.push({ kind: 'run', cli: 'flyctl', args: ['deploy', '--remote-only', ...(deploy.fly?.app ? ['--app', deploy.fly.app] : []), ...(one ? ['--ha=false'] : [])], reads: ['FLY_API_TOKEN'], url: deploy.fly?.app ? `https://${deploy.fly.app}.fly.dev` : 'fly' })
|
|
191
|
+
}
|
|
192
|
+
if (target === 'render') steps.push({ kind: 'hook', what: 'POST $RENDER_DEPLOY_HOOK_URL (Render builds the pushed commit)', reads: ['RENDER_DEPLOY_HOOK_URL'], url: deploy.render?.service ? `https://${deploy.render.service}.onrender.com` : undefined })
|
|
193
|
+
for (const s of steps) if (s.cli) s.what = `${s.cli} ${s.args.join(' ')}${s.token ? ` --token $${s.token}` : ''}${s.cwd ? ` (in ${s.cwd}/)` : ''}`
|
|
194
|
+
return steps
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** A variable a step reads, and whether its absence stops the deploy (E007). */
|
|
198
|
+
function missingEnv(root, target, config, env) {
|
|
199
|
+
const ci = !!env.CI
|
|
200
|
+
const linked = { netlify: existsSync(join(root, '.netlify/state.json')) || !!config.deploy?.netlify?.site, vercel: existsSync(join(root, '.vercel/project.json')) }
|
|
201
|
+
// a token: the CLI's own login stands in, except in CI. An id: needed unless the project is linked.
|
|
202
|
+
const token = (k, how) => [k, ci ? 'error' : 'warning', how, ': the CLI\'s own login is used']
|
|
203
|
+
const id = (k, known, how) => [k, known ? null : 'error', how, '']
|
|
204
|
+
const need = {
|
|
205
|
+
netlify: [token('NETLIFY_AUTH_TOKEN', '`netlify login` here, or a personal access token from User settings → Applications'), id('NETLIFY_SITE_ID', linked.netlify, '`netlify link`, `deploy.netlify.site`, or the site id from Site configuration')],
|
|
206
|
+
vercel: [token('VERCEL_TOKEN', '`vercel login` here, or a token from Account Settings → Tokens'), id('VERCEL_ORG_ID', linked.vercel, '`vercel link` writes it to .vercel/project.json'), id('VERCEL_PROJECT_ID', linked.vercel, '`vercel link` writes it to .vercel/project.json')],
|
|
207
|
+
cloudflare: [token('CLOUDFLARE_API_TOKEN', '`wrangler login` here, or a token with Workers Scripts: Edit'), id('CLOUDFLARE_ACCOUNT_ID', !ci, 'the account id on the Workers overview page')],
|
|
208
|
+
fly: [token('FLY_API_TOKEN', '`flyctl auth login` here, or `flyctl tokens create deploy`')],
|
|
209
|
+
render: [id('RENDER_DEPLOY_HOOK_URL', false, "the service's Settings → Deploy Hook")],
|
|
210
|
+
}[target]
|
|
211
|
+
return need.filter(([k, severity]) => severity && !env[k]).map(([k, severity, how, local]) => ({ code: 'E007', severity, file: 'weft.config.ts', why: `\`${k}\` is unset${severity === 'warning' ? local : ''}`, fix: `${how}. Set it in the environment (never a file)` }))
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** The URL a host's CLI printed. */
|
|
215
|
+
export function urlFrom(step, stdout) {
|
|
216
|
+
if (step.url === 'json') {
|
|
217
|
+
try { const j = JSON.parse(stdout.slice(stdout.indexOf('{'))); return j.url ?? j.deploy_url } catch { return }
|
|
218
|
+
}
|
|
219
|
+
if (step.url === 'last-line') return stdout.trim().split('\n').reverse().find(l => /^https:\/\//.test(l.trim()))?.trim()
|
|
220
|
+
if (step.url === 'workers') return /https:\/\/[\w.-]+\.workers\.dev/.exec(stdout)?.[0]
|
|
221
|
+
if (step.url === 'fly') return /https:\/\/[\w.-]+\.fly\.dev/.exec(stdout)?.[0]
|
|
222
|
+
return step.url
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* `weft deploy`: fit, then the steps. Without `yes` nothing runs: the result says what would. `build` is `weft build`
|
|
227
|
+
* in-process (start.mjs's buildCommand), `exec` runs a host CLI; both are injectable for tests.
|
|
228
|
+
*/
|
|
229
|
+
export async function deployCommand(root, config, { target, prod = false, yes = false, unsafe, env = process.env, build, exec = defaultExec, which, post = (url) => fetch(url, { method: 'POST' }) } = {}) {
|
|
230
|
+
const id = targetOf(config, { target })
|
|
231
|
+
if (!id) {
|
|
232
|
+
const s = hostsReport(needsOf(root, config)).suggestion
|
|
233
|
+
throw refused(`no deploy target: set \`deploy.target\` (\`weft deploy init\` does, and writes the host's files), or pass --target. weft suggests \`${s.host}\`: ${s.why}`)
|
|
234
|
+
}
|
|
235
|
+
const h = host(id)
|
|
236
|
+
if (!h.deploys) throw usage(`\`weft deploy\` doesn't drive ${h.name} yet (research 46 §11)`)
|
|
237
|
+
if (h.kind === 'server' && !prod) throw usage(`${h.name} has no preview deploys: pass --prod to deploy production`)
|
|
238
|
+
const needs = needsOf(root, config)
|
|
239
|
+
const findings = fit(needs, h, { deploy: config.deploy ?? {}, local: !env.CI }).findings
|
|
240
|
+
const blocking = findings.filter(f => f.severity === 'error' && !unsafe?.allows(f))
|
|
241
|
+
if (blocking.length) throw refused(`${h.name} can't run this app:\n${blocking.map(f => ` ${f.code} ${f.file}: ${f.why}.\n Fix: ${f.fix}`).join('\n')}\nTo deploy anyway: --unsafe-allow ${[...new Set(blocking.map(f => f.code))].join(',')}`)
|
|
242
|
+
const steps = deploySteps(root, config, { target: id, prod, which })
|
|
243
|
+
const envFindings = missingEnv(root, id, config, env)
|
|
244
|
+
const diagnostics = [...findings, ...envFindings, ...cdnFindings(config, { env })].map(f => toDiagnostic(root, f))
|
|
245
|
+
if (!yes) return { ok: true, dryRun: true, target: id, prod, steps: steps.map(s => ({ what: s.what, reads: s.reads ?? [] })), diagnostics }
|
|
246
|
+
const stop = envFindings.find(f => f.severity === 'error')
|
|
247
|
+
if (stop) throw refused(`E007: ${stop.why}. ${stop.fix}.`)
|
|
248
|
+
let url
|
|
249
|
+
for (const s of steps) {
|
|
250
|
+
if (s.kind === 'build') {
|
|
251
|
+
const built = await build(root, { preset: s.preset })
|
|
252
|
+
diagnostics.push(...built.diagnostics)
|
|
253
|
+
if (!built.ok) throw refused('weft build failed: nothing was deployed. Fix the errors above.')
|
|
254
|
+
} else if (s.kind === 'hook') {
|
|
255
|
+
const res = await post(env.RENDER_DEPLOY_HOOK_URL)
|
|
256
|
+
if (!res.ok) throw refused(`Render's deploy hook answered ${res.status}: nothing was deployed. Check RENDER_DEPLOY_HOOK_URL.`)
|
|
257
|
+
url = s.url
|
|
258
|
+
} else {
|
|
259
|
+
const [cmd, ...pre] = cliFor(s.cli, { which })
|
|
260
|
+
const args = [...pre, ...s.args, ...(s.token && env[s.token] ? ['--token', env[s.token]] : [])]
|
|
261
|
+
const r = await exec(cmd, args, { cwd: join(root, s.cwd ?? ''), env })
|
|
262
|
+
if (r.code !== 0) throw refused(`${s.cli} failed (exit ${r.code}): nothing more was run.\n${(r.stderr || r.stdout).trim().split('\n').slice(-15).join('\n')}`)
|
|
263
|
+
url = urlFrom(s, r.stdout) ?? url
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
return { ok: true, dryRun: false, target: id, prod, url: url ?? null, diagnostics }
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** A host CLI with its progress on stderr, as it goes, and stdout kept for the URL. */
|
|
270
|
+
function defaultExec(cmd, args, { cwd, env }) {
|
|
271
|
+
const r = spawnSync(cmd, args, { cwd, env, encoding: 'utf8', stdio: ['inherit', 'pipe', 'inherit'], maxBuffer: 64 * 1024 * 1024 })
|
|
272
|
+
return { code: r.status ?? 1, stdout: r.stdout ?? '', stderr: r.error?.message ?? '' }
|
|
273
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// What an app needs from a host (research 46 §2), read from its files in one pass: no Vite server, no build. Each need
|
|
2
|
+
// carries the file that shows it, so a diagnostic can point there.
|
|
3
|
+
|
|
4
|
+
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'
|
|
5
|
+
import { join, relative } from 'node:path'
|
|
6
|
+
|
|
7
|
+
const SKIP = new Set(['node_modules', '.weft', '.output', 'dist', '.netlify', '.vercel'])
|
|
8
|
+
/** npm packages with a compiled addon: one built on a Mac doesn't load on a Linux function. */
|
|
9
|
+
const ADDONS = /^(better-sqlite3|sqlite3|sharp|bcrypt|argon2|canvas|@node-rs\/.+|@napi-rs\/canvas|bufferutil|utf-8-validate)$/
|
|
10
|
+
/** A bus that reaches every instance. The memory bus is what's left when none is named. */
|
|
11
|
+
const BUSES = /\b(postgresBus|redisBus|natsBus|durableObjectBus)\s*\(|@weft-js\/realtime-(redis|nats|durable-object)/
|
|
12
|
+
|
|
13
|
+
const posix = p => p.replaceAll('\\', '/')
|
|
14
|
+
|
|
15
|
+
function sources(root) {
|
|
16
|
+
const out = []
|
|
17
|
+
const walk = dir => {
|
|
18
|
+
for (const d of readdirSync(dir, { withFileTypes: true })) {
|
|
19
|
+
const f = join(dir, d.name)
|
|
20
|
+
if (d.isDirectory()) { if (!SKIP.has(d.name)) walk(f) } else if (/\.[cm]?[jt]sx?$/.test(d.name) && !/\.(test|spec)\.|\.d\.ts$/.test(d.name)) out.push(f)
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
if (existsSync(join(root, 'src'))) walk(join(root, 'src'))
|
|
24
|
+
return out
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** `.weft/contract.json`, when it's newer than every source file: what the last build saw is then what's there. */
|
|
28
|
+
function freshContract(root, files) {
|
|
29
|
+
const file = join(root, '.weft', 'contract.json')
|
|
30
|
+
if (!existsSync(file)) return
|
|
31
|
+
const at = statSync(file).mtimeMs
|
|
32
|
+
if (files.some(f => statSync(f).mtimeMs > at)) return
|
|
33
|
+
try { return JSON.parse(readFileSync(file, 'utf8')) } catch {}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const read = file => { try { return readFileSync(file, 'utf8') } catch { return '' } }
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* @param {string} root
|
|
40
|
+
* @param {any} [config] the loaded weft.config
|
|
41
|
+
* @returns {{ sockets?: Need, required?: Need, feed?: Need, memoryBus?: Need, sidecar?: Need, native?: Need, sqlite?: Need, uploads?: Need, addons?: Need, runtime: string }}
|
|
42
|
+
* @typedef {{ file: string, what: string }} Need
|
|
43
|
+
*/
|
|
44
|
+
export function needsOf(root, config = {}) {
|
|
45
|
+
const files = sources(root)
|
|
46
|
+
const rel = f => posix(relative(root, f))
|
|
47
|
+
const needs = { runtime: config.runtime ?? 'node' }
|
|
48
|
+
const contract = freshContract(root, files)
|
|
49
|
+
const realtimeFile = join(root, 'src/realtime.server.ts')
|
|
50
|
+
const realtime = read(realtimeFile)
|
|
51
|
+
let streams = false
|
|
52
|
+
if (contract) {
|
|
53
|
+
const live = contract.functions?.find(f => f.kind === 'channel' || f.kind === 'shared')
|
|
54
|
+
if (live) needs.sockets = { file: '.weft/contract.json', what: `\`${live.name}\` is a ${live.kind === 'shared' ? '`sharedState`' : '`channel`'}` }
|
|
55
|
+
streams = contract.functions?.some(f => ['stream', 'channel', 'shared'].includes(f.kind))
|
|
56
|
+
const native = Object.entries(contract.services ?? {}).find(([, s]) => s.kind === 'native')
|
|
57
|
+
if (native) needs.native = { file: '.weft/contract.json', what: `\`native('${native[0]}')\`` }
|
|
58
|
+
}
|
|
59
|
+
for (const f of files) {
|
|
60
|
+
const text = read(f)
|
|
61
|
+
const server = /^src\/server\/|\.server\.[cm]?[jt]sx?$/.test(rel(f))
|
|
62
|
+
if (!contract && server) {
|
|
63
|
+
const m = /\b(channel|sharedState)\s*[(<]/.exec(text)
|
|
64
|
+
if (m && !needs.sockets) needs.sockets = { file: rel(f), what: `a \`${m[1]}\`` }
|
|
65
|
+
if (/\b(stream|channel|sharedState)\s*[(<]/.test(text)) streams = true
|
|
66
|
+
}
|
|
67
|
+
if (/\.live\(/.test(text)) streams = true
|
|
68
|
+
if (/['"]@weft-js\/start\/rust['"]/.test(text)) {
|
|
69
|
+
for (const m of text.matchAll(/\bremote\(\s*['"`]rust:\/\/([\w-]+)/g)) if (!config.rust?.services?.[m[1]]?.external) needs.sidecar ??= { file: rel(f), what: `\`remote('rust://${m[1]}')\`` }
|
|
70
|
+
const n = /\bnative\(\s*['"`]([\w-]+)/.exec(text)
|
|
71
|
+
if (n) needs.native ??= { file: rel(f), what: `\`native('${n[1]}')\`` }
|
|
72
|
+
}
|
|
73
|
+
if (!/listener:\s*['"]external['"]/.test(realtime) && /\.live\(([^;\n]*?)\brequired:\s*true|\blive:\s*\{[^}]*required:\s*true/.test(text)) needs.required ??= { file: rel(f), what: 'a live query marked `required`' }
|
|
74
|
+
if (/\bctx\.media\b|\bz\.media\b/.test(text)) needs.uploads ??= { file: rel(f), what: 'media uploads' }
|
|
75
|
+
}
|
|
76
|
+
if (!needs.sockets && /\bshare\s*[:,}]/.test(realtime)) needs.sockets = { file: 'src/realtime.server.ts', what: '`realtime({ share })`' }
|
|
77
|
+
if (/\bchanges\s*[:,}]/.test(realtime) && !/listener:\s*['"]external['"]/.test(realtime)) needs.feed = { file: 'src/realtime.server.ts', what: '`changes`' }
|
|
78
|
+
const plugins = (config.plugins ?? []).map(p => p?.name ?? p).filter(n => typeof n === 'string')
|
|
79
|
+
const configText = ['weft.config.ts', 'weft.config.mts', 'weft.config.js', 'weft.config.mjs'].map(n => read(join(root, n))).join('\n')
|
|
80
|
+
if ((streams || needs.sockets) && !BUSES.test(realtime) && !plugins.some(n => /realtime-(redis|nats|durable-object)/.test(n)) && !BUSES.test(configText)) needs.memoryBus = { file: existsSync(realtimeFile) ? 'src/realtime.server.ts' : 'src', what: 'the memory bus' }
|
|
81
|
+
if (!needs.uploads && /encoder:\s*['"]server['"]/.test(configText)) needs.uploads = { file: 'weft.config.ts', what: "`media({ encoder: 'server' })`" }
|
|
82
|
+
if (needs.uploads && !plugins.some(n => n.includes('plugin-media')) && !configText.includes('plugin-media')) delete needs.uploads // M017 says that one
|
|
83
|
+
const db = read(join(root, 'src/db/index.server.ts'))
|
|
84
|
+
const drizzle = read(join(root, 'drizzle.config.ts'))
|
|
85
|
+
if (/\/db\/sqlite['"]/.test(db) || (/dialect:\s*['"](sqlite|turso)['"]/.test(drizzle) && !/libsql:|turso/.test(drizzle + db))) needs.sqlite = { file: db ? 'src/db/index.server.ts' : 'drizzle.config.ts', what: 'a SQLite file' }
|
|
86
|
+
try {
|
|
87
|
+
const pkg = JSON.parse(read(join(root, 'package.json')) || '{}')
|
|
88
|
+
const addon = Object.keys(pkg.dependencies ?? {}).find(n => ADDONS.test(n))
|
|
89
|
+
if (addon) needs.addons = { file: 'package.json', what: `\`${addon}\`` }
|
|
90
|
+
} catch {}
|
|
91
|
+
return needs
|
|
92
|
+
}
|
package/src/docs.mjs
CHANGED
|
@@ -22,6 +22,7 @@ export const topics = {
|
|
|
22
22
|
async: 'resource, <Boundary pending error>, @weft-js/core/ssr and hydrate; W012',
|
|
23
23
|
start: 'full stack: routes, load, query, mutation, the database, auth, realtime, runtimes; Q001–Q016, A001–A004, C001–C002, F001–F003, R001–R023',
|
|
24
24
|
rust: 'Rust bodies: native() addons with a WASM fallback, remote() sidecars, weft add rust, impl, the weft crate, build.rs, Ctx, touch, weft dev reload, deployment; R101–R109',
|
|
25
|
+
deploy: 'Deploy: which hosts can run the app, weft deploy hosts/init, the push workflow, Netlify, Vercel, Cloudflare, Fly, Render, CDN and Cloudinary; E001–E014',
|
|
25
26
|
plugins: 'Build a plugin: slots, server extensions, check hooks, docs, `weft add`, conformance. Walks through how @weft-js/pay is built',
|
|
26
27
|
testing: '@weft-js/core/test: render, screen, user, flush, clock, touches and toTouchAtMost(n, { runs }); T001–T009',
|
|
27
28
|
browser: '@weft-js/core/browser: online, visible, lifecycle, media, on, interval; the net/page fakes; W050, W051, W054, W056, T012',
|
package/src/load-config.mjs
CHANGED
|
@@ -13,7 +13,7 @@ import { contributions } from './contributions.mjs'
|
|
|
13
13
|
import { bindSlots } from './slots.mjs'
|
|
14
14
|
|
|
15
15
|
export const configNames = ['weft.config.ts', 'weft.config.mts', 'weft.config.js', 'weft.config.mjs', 'weft.config.json', 'weft.config.yaml', 'weft.config.yml']
|
|
16
|
-
export const builtinNames = ['add', 'map', 'dev', 'build', 'start', 'db', 'test', 'check', 'doctor', 'fix', 'tokens', 'browser', 'mcp', 'tasks', 'run', 'create', 'docs', 'agents-md', 'plugins']
|
|
16
|
+
export const builtinNames = ['add', 'map', 'dev', 'build', 'start', 'db', 'test', 'check', 'doctor', 'fix', 'tokens', 'browser', 'mcp', 'tasks', 'run', 'create', 'docs', 'agents-md', 'plugins', 'deploy']
|
|
17
17
|
|
|
18
18
|
const configModule = pathToFileURL(join(fileURLToPath(new URL('.', import.meta.url)), 'config.mjs')).href
|
|
19
19
|
const nativeRequire = createRequire(import.meta.url)
|
|
@@ -46,6 +46,14 @@ function hook() {
|
|
|
46
46
|
return { url: configModule, shortCircuit: true }
|
|
47
47
|
}
|
|
48
48
|
}
|
|
49
|
+
// the app's own imports are bundler-style (`./src/expression`): try what Vite would before failing
|
|
50
|
+
if (/^\.{1,2}\//.test(specifier) && !/\.[cm]?[jt]sx?$|\.json$/.test(specifier)) {
|
|
51
|
+
for (const end of ['', '.ts', '.tsx', '.mts', '.js', '/index.ts', '/index.tsx', '/index.js']) {
|
|
52
|
+
try {
|
|
53
|
+
return next(specifier + end, context)
|
|
54
|
+
} catch {}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
49
57
|
return next(specifier, context)
|
|
50
58
|
},
|
|
51
59
|
load(url, context, next) {
|
|
@@ -147,6 +155,27 @@ export const configSchema = z.strictObject({
|
|
|
147
155
|
unions: z.literal('allow').optional(),
|
|
148
156
|
services: z.record(z.string(), z.strictObject({ crate: z.string().optional(), external: z.boolean().optional(), wasm: z.literal(false).optional() })).optional(),
|
|
149
157
|
}).optional(),
|
|
158
|
+
/** `weft deploy` (research 46): names, regions and sizes. Credentials come from the environment, never here (E008). */
|
|
159
|
+
deploy: z.strictObject({
|
|
160
|
+
target: z.enum(['netlify', 'vercel', 'cloudflare', 'fly', 'render']).optional(),
|
|
161
|
+
netlify: z.strictObject({ site: z.string().min(1).optional() }).optional(),
|
|
162
|
+
vercel: z.strictObject({ project: z.string().regex(/^[a-z0-9][a-z0-9-]{0,62}$/i, 'a name: letters, digits and dashes').optional() }).optional(),
|
|
163
|
+
cloudflare: z.strictObject({ name: z.string().regex(/^[a-z0-9][a-z0-9-]{0,62}$/i, 'a name: letters, digits and dashes').optional() }).optional(),
|
|
164
|
+
fly: z.strictObject({
|
|
165
|
+
app: z.string().regex(/^[a-z0-9][a-z0-9-]{0,62}$/i, 'a name: letters, digits and dashes').optional(), region: z.string().regex(/^[a-z]{3}$/, 'a Fly region code, like `iad`').optional(), memory: z.string().regex(/^\d+(mb|gb)$/, 'like `512mb`').optional(),
|
|
166
|
+
volume: z.strictObject({ name: z.string().regex(/^[a-z0-9_]+$/, 'lowercase letters, digits and underscores').optional(), path: z.string().startsWith('/').optional(), sizeGb: z.number().int().positive().optional() }).optional(),
|
|
167
|
+
}).optional(),
|
|
168
|
+
render: z.strictObject({
|
|
169
|
+
service: z.string().regex(/^[a-z0-9][a-z0-9-]{0,62}$/i, 'a name: letters, digits and dashes').optional(), plan: z.string().optional(), region: z.string().optional(),
|
|
170
|
+
disk: z.strictObject({ path: z.string().startsWith('/').optional(), sizeGb: z.number().int().positive().optional() }).optional(),
|
|
171
|
+
}).optional(),
|
|
172
|
+
ci: z.union([z.literal('github'), z.literal(false)]).optional(),
|
|
173
|
+
branch: z.string().min(1).optional(),
|
|
174
|
+
cdn: z.strictObject({
|
|
175
|
+
proxy: z.literal('cloudflare').optional(),
|
|
176
|
+
images: z.strictObject({ provider: z.literal('cloudinary'), cloud: z.string().regex(/^[\w-]+$/, "the cloud's name, not its URL").optional() }).optional(),
|
|
177
|
+
}).optional(),
|
|
178
|
+
}).optional(),
|
|
150
179
|
/** Named targets for a query's `live: { verify: 'staging' }`. `db` is an env var's name. */
|
|
151
180
|
live: z.strictObject({
|
|
152
181
|
targets: z.record(z.string(), z.strictObject({ url: z.string().optional(), db: z.string().optional(), production: z.boolean().optional() })).optional(),
|
package/src/mcp.mjs
CHANGED
|
@@ -136,7 +136,7 @@ export async function handle(root, msg) {
|
|
|
136
136
|
return reply({
|
|
137
137
|
protocolVersion: versions.includes(asked) ? asked : versions[0],
|
|
138
138
|
capabilities: { tools: { listChanged: false } },
|
|
139
|
-
serverInfo: { name: 'weft', version: '0.1.
|
|
139
|
+
serverInfo: { name: 'weft', version: '0.1.1' },
|
|
140
140
|
instructions: 'weft is a fine-grained reactive TSX framework, not React. Read `docs` topic `rules` first, and run `check` after every edit. Every page sets `meta` from its own context as part of building it (`docs` topic `meta`). Style with groups from @weft-js/core/style, not class lists: `style_find` before you write one. Verify behavior in the running app with the browser_* tools (or `__weft__.agent` in your own browser), and run `test_gate` before you call a change done.',
|
|
141
141
|
})
|
|
142
142
|
}
|
package/src/start.mjs
CHANGED
|
@@ -16,6 +16,7 @@ import { loadConfig } from './load-config.mjs'
|
|
|
16
16
|
import { sync } from './plugins.mjs'
|
|
17
17
|
import { requireTrust } from './trust.mjs'
|
|
18
18
|
import { attachProps } from './props-listener.mjs'
|
|
19
|
+
import { host, presetFor } from './deploy/hosts.mjs'
|
|
19
20
|
|
|
20
21
|
export async function start(root, sub) {
|
|
21
22
|
let file
|
|
@@ -36,7 +37,7 @@ async function site(root) {
|
|
|
36
37
|
if (!loaded.diagnostics.some(d => d.code === 'P007') && loaded.config?.plugins?.length) sync(root, loaded)
|
|
37
38
|
// A build on a config that doesn't load would quietly drop every plugin: fonts, tokens and slots.
|
|
38
39
|
const bad = loaded.diagnostics.find(d => d.code === 'P007')
|
|
39
|
-
if (bad) throw Object.assign(new Error(`P007: ${bad.message ?? bad.why}`), { code: '
|
|
40
|
+
if (bad) throw Object.assign(new Error(`P007: ${bad.message ?? bad.why}`), { code: 'E_REFUSED' }) // the config's fault, not the command's
|
|
40
41
|
return { site: siteOf(loaded), hydration: loaded.config?.hydration, diagnostics: loaded.diagnostics }
|
|
41
42
|
}
|
|
42
43
|
|
|
@@ -102,6 +103,9 @@ export async function buildCommand(root, { preset, runtime, unsafeAllow } = {})
|
|
|
102
103
|
const t = performance.now()
|
|
103
104
|
const config = await site(root)
|
|
104
105
|
const chosen = runtime ?? (await loadConfig(root)).config?.runtime ?? hostRuntime()
|
|
106
|
+
// `deploy.target` picks the preset when nothing else does: a build for Fly is the runtime's server, for Netlify `netlify`
|
|
107
|
+
const target = !preset && !runtime && (await loadConfig(root)).config?.deploy?.target
|
|
108
|
+
if (target) preset = presetFor(host(target), chosen)
|
|
105
109
|
if (!preset && !PRESETS[chosen]) throw Object.assign(new Error(`unknown runtime "${chosen}": use ${Object.keys(PRESETS).join(', ')}`), { code: 'E_USAGE' })
|
|
106
110
|
preset ??= PRESETS[chosen]
|
|
107
111
|
// A build holds no secrets: an app's env check stands down, and server modules load without a database.
|