@weft-js/cli 0.1.1 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +38 -14
- package/bin/weft.mjs +8 -1
- package/docs/async.md +16 -6
- package/docs/browser.md +22 -8
- package/docs/config.md +39 -9
- package/docs/core.md +30 -0
- package/docs/deploy.md +33 -8
- package/docs/design.md +12 -3
- package/docs/entrypoints.md +79 -0
- package/docs/fonts.md +12 -2
- package/docs/icons.md +13 -7
- package/docs/lists.md +3 -1
- package/docs/meta.md +13 -3
- package/docs/plugins.md +32 -9
- package/docs/props.md +13 -2
- package/docs/reactivity.md +26 -0
- package/docs/rules.md +19 -12
- package/docs/rust.md +47 -17
- package/docs/start-short.md +3 -2
- package/docs/start.md +226 -52
- package/docs/state.md +33 -7
- package/docs/style.md +31 -8
- package/docs/surface.md +61 -15
- package/docs/testing.md +15 -5
- package/docs/theme.md +182 -0
- package/docs/ui-state.md +19 -6
- package/docs/verify.md +13 -0
- package/package.json +13 -4
- package/src/add/config.mjs +93 -0
- package/src/add/errors.mjs +2 -0
- package/src/add/setup.mjs +88 -0
- package/src/add-rust.mjs +82 -27
- package/src/add.mjs +209 -181
- package/src/agents-md.mjs +144 -43
- package/src/alias.mjs +16 -5
- package/src/audit/page.mjs +313 -0
- package/src/audit/report.mjs +69 -0
- package/src/audit/utils.mjs +5 -0
- package/src/audit.mjs +159 -206
- package/src/browser/cli.mjs +24 -5
- package/src/browser/client.mjs +189 -25
- package/src/browser/commands.mjs +166 -51
- package/src/catalog/audit.mjs +129 -0
- package/src/catalog/auth.mjs +40 -0
- package/src/catalog/compiler.mjs +337 -0
- package/src/catalog/contracts.mjs +18 -0
- package/src/catalog/database.mjs +52 -0
- package/src/catalog/deploy.mjs +117 -0
- package/src/catalog/design.mjs +159 -0
- package/src/catalog/forms.mjs +25 -0
- package/src/catalog/hydration.mjs +29 -0
- package/src/catalog/icons.mjs +24 -0
- package/src/catalog/media.mjs +163 -0
- package/src/catalog/metadata.mjs +116 -0
- package/src/catalog/plugins.mjs +228 -0
- package/src/catalog/realtime.mjs +195 -0
- package/src/catalog/rust.mjs +75 -0
- package/src/catalog/server-functions.mjs +92 -0
- package/src/catalog/state.mjs +39 -0
- package/src/catalog/style-groups.mjs +54 -0
- package/src/catalog/tests.mjs +127 -0
- package/src/catalog/text.mjs +200 -0
- package/src/catalog/ui-state.mjs +42 -0
- package/src/catalog.mjs +67 -460
- package/src/check/runtime.mjs +280 -0
- package/src/check.mjs +198 -208
- package/src/cli/authoring.mjs +213 -0
- package/src/cli/project.mjs +302 -0
- package/src/cli.mjs +222 -250
- package/src/config.mjs +1 -1
- package/src/contributions.mjs +75 -25
- package/src/db-layer.mjs +13 -5
- package/src/deploy/cdn.mjs +94 -16
- package/src/deploy/files.mjs +100 -21
- package/src/deploy/fit.mjs +127 -23
- package/src/deploy/hosts.mjs +90 -20
- package/src/deploy/index/cli.mjs +20 -0
- package/src/deploy/index/environment.mjs +69 -0
- package/src/deploy/index/steps.mjs +87 -0
- package/src/deploy/index.mjs +228 -114
- package/src/deploy/needs.mjs +88 -31
- package/src/design-checks/accessible-names.mjs +62 -0
- package/src/design-checks/contrast.mjs +43 -0
- package/src/design-checks/csp.mjs +60 -0
- package/src/design-checks/files.mjs +59 -0
- package/src/design-checks/heading-links.mjs +61 -0
- package/src/design-checks/interaction.mjs +65 -0
- package/src/design-checks/jsx.mjs +67 -0
- package/src/design-checks/layout-values.mjs +68 -0
- package/src/design-checks/native-controls.mjs +48 -0
- package/src/design-checks/outline.mjs +39 -0
- package/src/design-checks/reporter.mjs +37 -0
- package/src/design-checks/rules.mjs +274 -0
- package/src/design-checks/values.mjs +262 -0
- package/src/design-checks.mjs +10 -400
- package/src/diag.mjs +32 -8
- package/src/docs.mjs +138 -43
- package/src/doctor.mjs +20 -8
- package/src/files.mjs +18 -5
- package/src/fix.mjs +10 -2
- package/src/gate/after.mjs +40 -0
- package/src/gate/before.mjs +54 -0
- package/src/gate/classify.mjs +72 -0
- package/src/gate/mutation.mjs +209 -0
- package/src/gate/resolve-cases.mjs +22 -0
- package/src/gate/result.mjs +63 -0
- package/src/gate/run.mjs +75 -0
- package/src/gate.mjs +104 -202
- package/src/infer/box-props.cjs +140 -0
- package/src/infer/core-boxes.cjs +46 -0
- package/src/infer/derived-writes.cjs +95 -0
- package/src/infer/engine/checks.cjs +193 -0
- package/src/infer/engine/flow.cjs +293 -0
- package/src/infer/engine/report.cjs +158 -0
- package/src/infer/engine/utils.cjs +15 -0
- package/src/infer/engine.cjs +108 -368
- package/src/infer/facts/sites.cjs +114 -0
- package/src/infer/facts.cjs +210 -117
- package/src/infer/hosts.cjs +69 -22
- package/src/infer/lsp.mjs +149 -40
- package/src/infer/session.cjs +73 -17
- package/src/infer/shadow.cjs +13 -5
- package/src/infer/worker.cjs +57 -9
- package/src/jsx-scan.mjs +63 -21
- package/src/load-config/schema.mjs +281 -0
- package/src/load-config.mjs +137 -138
- package/src/map.mjs +93 -26
- package/src/mcp/browser-tools.mjs +155 -0
- package/src/mcp.mjs +147 -69
- package/src/meta-table.mjs +43 -8
- package/src/mutate.mjs +120 -36
- package/src/plugin-checks.mjs +52 -23
- package/src/plugins.mjs +125 -39
- package/src/project.mjs +170 -79
- package/src/props-checks.mjs +24 -6
- package/src/props-listener.mjs +27 -9
- package/src/react-checks.mjs +35 -4
- package/src/server-state-checks.mjs +34 -0
- package/src/share-checks.mjs +121 -37
- package/src/site.mjs +33 -6
- package/src/slots.mjs +104 -19
- package/src/start/build.mjs +116 -0
- package/src/start/routes.mjs +38 -0
- package/src/start.mjs +193 -95
- package/src/state-checks/core-state.mjs +223 -0
- package/src/state-checks/modules.mjs +17 -0
- package/src/state-checks/scan.mjs +232 -0
- package/src/state-checks/server-graph.mjs +65 -0
- package/src/state-checks.mjs +109 -133
- package/src/style/find.mjs +121 -0
- package/src/style.mjs +133 -127
- package/src/surface-checks.mjs +212 -46
- package/src/tailwind.mjs +5 -4
- package/src/tasks.mjs +189 -27
- package/src/test-rules/context.mjs +109 -0
- package/src/test-rules/file-rules.mjs +279 -0
- package/src/test-rules/rule-helpers.mjs +244 -0
- package/src/test-rules/syntax.mjs +223 -0
- package/src/test-rules/test-rules.mjs +127 -0
- package/src/testdiff.mjs +94 -32
- package/src/tests.mjs +31 -483
- package/src/text-checks.mjs +229 -69
- package/src/tokens.mjs +12 -4
- package/src/trust/scan.mjs +95 -0
- package/src/trust.mjs +214 -102
- package/src/weft.mjs +41 -9
- package/src/workspace.mjs +96 -19
- package/ts-plugin/context.js +57 -0
- package/ts-plugin/index.js +4 -124
- package/ts-plugin/mapping.js +65 -0
- package/ts-plugin/proxy.js +144 -0
package/src/contributions.mjs
CHANGED
|
@@ -9,7 +9,12 @@ const byName = (a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)
|
|
|
9
9
|
function stable(value) {
|
|
10
10
|
return JSON.stringify(value, (_, v) => {
|
|
11
11
|
if (typeof v === 'function') return `[function ${v.name}]`
|
|
12
|
-
if (v && typeof v === 'object' && !Array.isArray(v))
|
|
12
|
+
if (v && typeof v === 'object' && !Array.isArray(v))
|
|
13
|
+
return Object.fromEntries(
|
|
14
|
+
Object.keys(v)
|
|
15
|
+
.sort()
|
|
16
|
+
.map((k) => [k, v[k]]),
|
|
17
|
+
)
|
|
13
18
|
return v
|
|
14
19
|
})
|
|
15
20
|
}
|
|
@@ -27,43 +32,88 @@ export function contributions(plugins, report, env) {
|
|
|
27
32
|
const catalogOwner = new Map()
|
|
28
33
|
for (const p of [...plugins].sort(byName)) {
|
|
29
34
|
if (typeof p.contribute !== 'function') continue
|
|
30
|
-
const run = () =>
|
|
35
|
+
const run = () =>
|
|
36
|
+
p.contribute({ command: env.command ?? 'check', mode: 'development', root: env.root })
|
|
31
37
|
let manifest
|
|
32
38
|
try {
|
|
33
39
|
manifest = run()
|
|
34
40
|
} catch (error) {
|
|
35
41
|
const code = typeof error?.code === 'string' ? error.code : 'P007'
|
|
36
|
-
report(code, `\`${p.name}\`: ${String(error?.message ?? error).split('\n')[0]}`, p.name, {
|
|
42
|
+
report(code, `\`${p.name}\`: ${String(error?.message ?? error).split('\n')[0]}`, p.name, {
|
|
43
|
+
owner: code === 'P007' ? 'core' : p.name,
|
|
44
|
+
help: error?.help,
|
|
45
|
+
})
|
|
37
46
|
continue
|
|
38
47
|
}
|
|
39
48
|
let again
|
|
40
49
|
try {
|
|
41
50
|
again = run()
|
|
42
51
|
} catch {}
|
|
43
|
-
if (!again || stable(again) !== stable(manifest))
|
|
52
|
+
if (!again || stable(again) !== stable(manifest))
|
|
53
|
+
report(
|
|
54
|
+
'P117',
|
|
55
|
+
`\`${p.name}\`'s contribute() returned a different manifest on a second call.`,
|
|
56
|
+
p.name,
|
|
57
|
+
)
|
|
44
58
|
|
|
45
|
-
const ns = p
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
}
|
|
54
|
-
if ((manifest.map?.length ?? 0) > mapLimit) report('P112', `\`${p.name}\` adds ${manifest.map.length} map rows (limit ${mapLimit}).`, p.name)
|
|
55
|
-
const tns = manifest.tokens?.namespace
|
|
56
|
-
if (tns) {
|
|
57
|
-
if (tokenOwner.has(tns)) report('P113', `Token namespace \`${tns}\` is contributed by both \`${tokenOwner.get(tns)}\` and \`${p.name}\`.`, p.name)
|
|
58
|
-
else tokenOwner.set(tns, p.name)
|
|
59
|
-
}
|
|
60
|
-
for (const c of manifest.catalog ?? []) {
|
|
61
|
-
if (catalogOwner.has(c.name)) report('P113', `Catalog entry \`${c.name}\` is contributed by both \`${catalogOwner.get(c.name)}\` and \`${p.name}\`.`, p.name)
|
|
62
|
-
else catalogOwner.set(c.name, p.name)
|
|
63
|
-
}
|
|
64
|
-
out.push({ name: p.name, version: p.version, namespace: ns, permissions: p.permissions ?? {}, manifest })
|
|
59
|
+
const ns = checkManifest(p, manifest, report, tokenOwner, catalogOwner)
|
|
60
|
+
out.push({
|
|
61
|
+
name: p.name,
|
|
62
|
+
version: p.version,
|
|
63
|
+
namespace: ns,
|
|
64
|
+
permissions: p.permissions ?? {},
|
|
65
|
+
manifest,
|
|
66
|
+
})
|
|
65
67
|
}
|
|
66
68
|
return out
|
|
67
69
|
}
|
|
68
70
|
|
|
69
|
-
|
|
71
|
+
function checkManifest(p, manifest, report, tokenOwner, catalogOwner) {
|
|
72
|
+
const ns = p.namespace ?? `${p.name.replace(/^@[^/]+\//, '').replace(/^plugin-/, '')}/`
|
|
73
|
+
for (const d of manifest.diagnostics ?? []) {
|
|
74
|
+
if (!d.code.startsWith(ns))
|
|
75
|
+
report(
|
|
76
|
+
'P111',
|
|
77
|
+
`\`${p.name}\` declares \`${d.code}\`, outside its namespace \`${ns}\`.`,
|
|
78
|
+
p.name,
|
|
79
|
+
)
|
|
80
|
+
}
|
|
81
|
+
const agents = manifest.agents
|
|
82
|
+
if (agents) {
|
|
83
|
+
const bytes = Buffer.byteLength(agentsSection(agents))
|
|
84
|
+
if (bytes > agentsLimit)
|
|
85
|
+
report(
|
|
86
|
+
'P112',
|
|
87
|
+
`\`${p.name}\`'s AGENTS section is ${bytes} bytes (limit ${agentsLimit}).`,
|
|
88
|
+
p.name,
|
|
89
|
+
)
|
|
90
|
+
}
|
|
91
|
+
if ((manifest.map?.length ?? 0) > mapLimit)
|
|
92
|
+
report(
|
|
93
|
+
'P112',
|
|
94
|
+
`\`${p.name}\` adds ${manifest.map.length} map rows (limit ${mapLimit}).`,
|
|
95
|
+
p.name,
|
|
96
|
+
)
|
|
97
|
+
const tns = manifest.tokens?.namespace
|
|
98
|
+
if (tns) {
|
|
99
|
+
if (tokenOwner.has(tns))
|
|
100
|
+
report(
|
|
101
|
+
'P113',
|
|
102
|
+
`Token namespace \`${tns}\` is contributed by both \`${tokenOwner.get(tns)}\` and \`${p.name}\`.`,
|
|
103
|
+
p.name,
|
|
104
|
+
)
|
|
105
|
+
else tokenOwner.set(tns, p.name)
|
|
106
|
+
}
|
|
107
|
+
for (const c of manifest.catalog ?? []) {
|
|
108
|
+
if (catalogOwner.has(c.name))
|
|
109
|
+
report(
|
|
110
|
+
'P113',
|
|
111
|
+
`Catalog entry \`${c.name}\` is contributed by both \`${catalogOwner.get(c.name)}\` and \`${p.name}\`.`,
|
|
112
|
+
p.name,
|
|
113
|
+
)
|
|
114
|
+
else catalogOwner.set(c.name, p.name)
|
|
115
|
+
}
|
|
116
|
+
return ns
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export const agentsSection = (agents) => `### ${agents.title}\n\n${agents.text.trim()}\n`
|
package/src/db-layer.mjs
CHANGED
|
@@ -6,12 +6,15 @@ import { loadConfig } from './load-config.mjs'
|
|
|
6
6
|
|
|
7
7
|
/** The app's data layer: the plugin that declares one, or Drizzle, which is built in. Null with no database. */
|
|
8
8
|
export async function dbLayer(root, loaded) {
|
|
9
|
-
const { contributions = [] } = loaded ?? await loadConfig(root)
|
|
10
|
-
const plugin = contributions.find(c => c.manifest.db)?.manifest.db
|
|
9
|
+
const { contributions = [] } = loaded ?? (await loadConfig(root))
|
|
10
|
+
const plugin = contributions.find((c) => c.manifest.db)?.manifest.db
|
|
11
11
|
if (plugin) return plugin
|
|
12
12
|
// Drizzle has no plugin to say so: its config file, or a client from `@weft-js/start/db/*`, does.
|
|
13
13
|
const entry = join(root, 'src/db/index.server.ts')
|
|
14
|
-
return existsSync(join(root, 'drizzle.config.ts')) ||
|
|
14
|
+
return existsSync(join(root, 'drizzle.config.ts')) ||
|
|
15
|
+
(existsSync(entry) && readFileSync(entry, 'utf8').includes('@weft-js/start/db/'))
|
|
16
|
+
? DRIZZLE
|
|
17
|
+
: null
|
|
15
18
|
}
|
|
16
19
|
|
|
17
20
|
const DRIZZLE = {
|
|
@@ -24,10 +27,15 @@ const DRIZZLE = {
|
|
|
24
27
|
const req = createRequire(join(root, 'package.json'))
|
|
25
28
|
bin = join(dirname(req.resolve('drizzle-kit')), 'bin.cjs')
|
|
26
29
|
} catch {
|
|
27
|
-
throw Object.assign(new Error('drizzle-kit is not installed in this project'), {
|
|
30
|
+
throw Object.assign(new Error('drizzle-kit is not installed in this project'), {
|
|
31
|
+
code: 'E_USAGE',
|
|
32
|
+
})
|
|
28
33
|
}
|
|
29
34
|
const r = spawnSync(process.execPath, [bin, 'generate'], { cwd: root, encoding: 'utf8' })
|
|
30
|
-
if (r.status !== 0)
|
|
35
|
+
if (r.status !== 0)
|
|
36
|
+
throw Object.assign(new Error(`drizzle-kit generate failed:\n${r.stdout}${r.stderr}`), {
|
|
37
|
+
code: 'E_INTERNAL',
|
|
38
|
+
})
|
|
31
39
|
return r.stdout.trim()
|
|
32
40
|
},
|
|
33
41
|
}
|
package/src/deploy/cdn.mjs
CHANGED
|
@@ -10,25 +10,59 @@ import { host } from './hosts.mjs'
|
|
|
10
10
|
const SHAPED = [
|
|
11
11
|
[/\bnfp_[A-Za-z0-9]{20,}/, 'a Netlify token'],
|
|
12
12
|
[/\bFlyV1\s+fm\d_[A-Za-z0-9+/=_-]{20,}/, 'a Fly token'],
|
|
13
|
-
[
|
|
13
|
+
[
|
|
14
|
+
/api\.render\.com\/deploy\/srv-[\w-]+\?key=[\w-]+/,
|
|
15
|
+
'a Render deploy hook (its key deploys the service)',
|
|
16
|
+
],
|
|
14
17
|
[/cloudinary:\/\/\d+:[^@\s'"]+@/, 'a Cloudinary URL with its secret'],
|
|
15
18
|
]
|
|
16
|
-
const KEYED =
|
|
19
|
+
const KEYED =
|
|
20
|
+
/\b[\w-]*(token|secret|api[_-]?key|password)[\w-]*["']?\s*[:=]\s*["']([^"'\s$]{16,})["']/i
|
|
17
21
|
|
|
18
|
-
const FILES = [
|
|
22
|
+
const FILES = [
|
|
23
|
+
'weft.config.ts',
|
|
24
|
+
'weft.config.mts',
|
|
25
|
+
'weft.config.js',
|
|
26
|
+
'weft.config.mjs',
|
|
27
|
+
'weft.config.json',
|
|
28
|
+
'weft.config.yaml',
|
|
29
|
+
'weft.config.yml',
|
|
30
|
+
'netlify.toml',
|
|
31
|
+
'vercel.json',
|
|
32
|
+
'wrangler.toml',
|
|
33
|
+
'fly.toml',
|
|
34
|
+
'render.yaml',
|
|
35
|
+
'Dockerfile',
|
|
36
|
+
]
|
|
19
37
|
|
|
20
38
|
/** E008: a credential in a file that gets committed, comments included. An env var's name, or `${{ secrets.X }}`, is never one. */
|
|
21
39
|
export function writtenSecrets(root) {
|
|
22
40
|
const out = []
|
|
23
41
|
const wf = join(root, '.github/workflows')
|
|
24
|
-
const files = [
|
|
42
|
+
const files = [
|
|
43
|
+
...FILES,
|
|
44
|
+
...(existsSync(wf)
|
|
45
|
+
? readdirSync(wf)
|
|
46
|
+
.filter((f) => /\.ya?ml$/.test(f))
|
|
47
|
+
.map((f) => `.github/workflows/${f}`)
|
|
48
|
+
: []),
|
|
49
|
+
]
|
|
25
50
|
for (const file of files) {
|
|
26
51
|
let text
|
|
27
|
-
try {
|
|
52
|
+
try {
|
|
53
|
+
text = readFileSync(join(root, file), 'utf8')
|
|
54
|
+
} catch {
|
|
55
|
+
continue
|
|
56
|
+
}
|
|
28
57
|
text.split('\n').forEach((line, i) => {
|
|
29
58
|
const shaped = SHAPED.find(([re]) => re.test(line))
|
|
30
59
|
const keyed = !shaped && KEYED.exec(line)
|
|
31
|
-
if (shaped || keyed)
|
|
60
|
+
if (shaped || keyed)
|
|
61
|
+
out.push({
|
|
62
|
+
file,
|
|
63
|
+
line: i + 1,
|
|
64
|
+
what: shaped ? shaped[1] : `a value for \`${keyed[0].split(/["':=\s]/)[0]}\``,
|
|
65
|
+
})
|
|
32
66
|
})
|
|
33
67
|
}
|
|
34
68
|
return out
|
|
@@ -42,23 +76,67 @@ export function cdnFindings(config = {}, { env } = {}) {
|
|
|
42
76
|
const out = []
|
|
43
77
|
const cdn = config.deploy?.cdn ?? {}
|
|
44
78
|
const target = config.deploy?.target && host(config.deploy.target)
|
|
45
|
-
const add = (code, severity, why, fix) =>
|
|
79
|
+
const add = (code, severity, why, fix) =>
|
|
80
|
+
out.push({ code, severity, file: 'weft.config.ts', why, fix })
|
|
46
81
|
if (cdn.proxy === 'cloudflare') {
|
|
47
82
|
let url
|
|
48
|
-
try {
|
|
49
|
-
|
|
50
|
-
|
|
83
|
+
try {
|
|
84
|
+
url = config.site?.url && new URL(config.site.url).hostname
|
|
85
|
+
} catch {}
|
|
86
|
+
if (url && OWN.test(url))
|
|
87
|
+
add(
|
|
88
|
+
'E010',
|
|
89
|
+
'warning',
|
|
90
|
+
`\`deploy.cdn.proxy\` is Cloudflare, and \`site.url\` is \`${url}\`, the host's own domain: nothing goes through the proxy, and canonical URLs point past it`,
|
|
91
|
+
'Set `site.url` to the domain Cloudflare proxies, with its DNS record proxied (orange cloud) to the host',
|
|
92
|
+
)
|
|
93
|
+
if (target && target.kind !== 'server')
|
|
94
|
+
add(
|
|
95
|
+
'E011',
|
|
96
|
+
'warning',
|
|
97
|
+
`\`deploy.cdn.proxy\` puts Cloudflare in front of ${target.name}, which is a CDN already: two caches, and a deploy purges only its own`,
|
|
98
|
+
'Drop `cdn.proxy`, or deploy to a server host (`fly`, `render`) behind it',
|
|
99
|
+
)
|
|
51
100
|
}
|
|
52
101
|
const images = cdn.images
|
|
53
102
|
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')))
|
|
103
|
+
const plugins = (config.plugins ?? []).map((p) => p?.name ?? p)
|
|
104
|
+
if (!plugins.some((n) => typeof n === 'string' && n.includes('plugin-media')))
|
|
105
|
+
add(
|
|
106
|
+
'E012',
|
|
107
|
+
'error',
|
|
108
|
+
'`deploy.cdn.images` is Cloudinary, and `@weft-js/plugin-media` is not in `plugins`: nothing would send it an image',
|
|
109
|
+
'`weft add media`, or drop `cdn.images`',
|
|
110
|
+
)
|
|
56
111
|
const given = env?.CLOUDINARY_URL
|
|
57
112
|
const cloud = given && /@([\w-]+)\/?$/.exec(given)?.[1]
|
|
58
|
-
if (!images.cloud && !given)
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
113
|
+
if (!images.cloud && !given)
|
|
114
|
+
add(
|
|
115
|
+
'E013',
|
|
116
|
+
'error',
|
|
117
|
+
'`deploy.cdn.images` names no `cloud`, and `CLOUDINARY_URL` is unset: there is no account to send images to',
|
|
118
|
+
"Set `cdn.images.cloud` to the cloud's name, and `CLOUDINARY_URL` in the host's environment (never in a file)",
|
|
119
|
+
)
|
|
120
|
+
else if (cloud && images.cloud && cloud !== images.cloud)
|
|
121
|
+
add(
|
|
122
|
+
'E013',
|
|
123
|
+
'error',
|
|
124
|
+
`\`cdn.images.cloud\` is \`${images.cloud}\`, and \`CLOUDINARY_URL\` here names \`${cloud}\`: one of them is another account`,
|
|
125
|
+
'Make them name the same cloud',
|
|
126
|
+
)
|
|
127
|
+
else if (env && !given)
|
|
128
|
+
add(
|
|
129
|
+
'E013',
|
|
130
|
+
'warning',
|
|
131
|
+
"`CLOUDINARY_URL` is unset here: the server needs it in the host's environment",
|
|
132
|
+
"Set it in the host's environment settings, never in a file",
|
|
133
|
+
)
|
|
134
|
+
add(
|
|
135
|
+
'E014',
|
|
136
|
+
'info',
|
|
137
|
+
"`cdn.images` is checked, but this weft doesn't send uploads to Cloudinary yet: they still go to `media({ server: { dir } })`",
|
|
138
|
+
'Nothing yet: research 46 §9 is the plan. On a function host, uploads need a server host with a disk until then (E003)',
|
|
139
|
+
)
|
|
62
140
|
}
|
|
63
141
|
return out
|
|
64
142
|
}
|
package/src/deploy/files.mjs
CHANGED
|
@@ -9,22 +9,46 @@ import { host, presetFor } from './hosts.mjs'
|
|
|
9
9
|
|
|
10
10
|
/** The lockfile's manager: how the image and the workflow install. */
|
|
11
11
|
const MANAGERS = [
|
|
12
|
-
[
|
|
13
|
-
|
|
12
|
+
[
|
|
13
|
+
'pnpm-lock.yaml',
|
|
14
|
+
{
|
|
15
|
+
name: 'pnpm',
|
|
16
|
+
install: 'corepack enable && pnpm install --frozen-lockfile',
|
|
17
|
+
x: 'pnpm exec',
|
|
18
|
+
cache: 'pnpm',
|
|
19
|
+
},
|
|
20
|
+
],
|
|
21
|
+
[
|
|
22
|
+
'yarn.lock',
|
|
23
|
+
{
|
|
24
|
+
name: 'yarn',
|
|
25
|
+
install: 'corepack enable && yarn install --immutable',
|
|
26
|
+
x: 'yarn',
|
|
27
|
+
cache: 'yarn',
|
|
28
|
+
},
|
|
29
|
+
],
|
|
14
30
|
['bun.lock', { name: 'bun', install: 'bun install --frozen-lockfile', x: 'bunx', cache: '' }],
|
|
15
31
|
['package-lock.json', { name: 'npm', install: 'npm ci', x: 'npx', cache: 'npm' }],
|
|
16
32
|
]
|
|
17
|
-
export const managerOf = root
|
|
33
|
+
export const managerOf = (root) =>
|
|
34
|
+
(MANAGERS.find(([lock]) => existsSync(join(root, lock))) ?? [
|
|
35
|
+
null,
|
|
36
|
+
{ name: 'npm', install: 'npm install', x: 'npx', cache: '' },
|
|
37
|
+
])[1]
|
|
18
38
|
|
|
19
39
|
/** Where a server host keeps what must last: SQLite's default `file:.data/app.db` lands on it unchanged. */
|
|
20
40
|
export const DATA = '/app/.data'
|
|
21
41
|
|
|
22
|
-
const sha = body => createHash('sha256').update(body).digest('hex').slice(0, 12)
|
|
42
|
+
const sha = (body) => createHash('sha256').update(body).digest('hex').slice(0, 12)
|
|
23
43
|
const stamp = (target, body) => `# weft deploy init ${target}, sha ${sha(body)}\n${body}`
|
|
24
44
|
const STAMP = /^# weft deploy init (\w+), sha ([0-9a-f]{12})\n/
|
|
25
45
|
|
|
26
46
|
const IMAGES = { node: 'node:26-slim', bun: 'oven/bun:1-slim', deno: 'denoland/deno:latest' }
|
|
27
|
-
const RUN = {
|
|
47
|
+
const RUN = {
|
|
48
|
+
node: '["node", ".output/server/index.mjs"]',
|
|
49
|
+
bun: '["bun", "run", ".output/server/index.mjs"]',
|
|
50
|
+
deno: '["deno", "run", "-A", ".output/server/index.mjs"]',
|
|
51
|
+
}
|
|
28
52
|
|
|
29
53
|
/** A two-stage image: build with the lockfile's manager, run the server. A sidecar needs cargo to build and `weft start` to run. */
|
|
30
54
|
function dockerfile({ runtime, manager, needs }) {
|
|
@@ -32,21 +56,37 @@ function dockerfile({ runtime, manager, needs }) {
|
|
|
32
56
|
const lines = [
|
|
33
57
|
`FROM ${IMAGES.node} AS build`,
|
|
34
58
|
'WORKDIR /app',
|
|
35
|
-
...(rust
|
|
59
|
+
...(rust
|
|
60
|
+
? [
|
|
61
|
+
'RUN apt-get update && apt-get install -y --no-install-recommends curl ca-certificates build-essential && curl -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal',
|
|
62
|
+
'ENV PATH=/root/.cargo/bin:$PATH',
|
|
63
|
+
]
|
|
64
|
+
: []),
|
|
36
65
|
'COPY . .',
|
|
37
66
|
`RUN ${manager.install}`,
|
|
38
67
|
`RUN ${manager.x} weft build --preset ${presetFor({}, runtime)}`,
|
|
39
68
|
'',
|
|
40
|
-
`FROM ${needs.sidecar ? IMAGES.node : IMAGES[runtime] ?? IMAGES.node}`,
|
|
69
|
+
`FROM ${needs.sidecar ? IMAGES.node : (IMAGES[runtime] ?? IMAGES.node)}`,
|
|
41
70
|
'WORKDIR /app',
|
|
42
71
|
'ENV NODE_ENV=production HOST=0.0.0.0 PORT=8080',
|
|
43
72
|
// `weft start` runs each sidecar next to the server; without one the server is all there is
|
|
44
|
-
...(needs.sidecar
|
|
73
|
+
...(needs.sidecar
|
|
74
|
+
? [
|
|
75
|
+
'COPY --from=build /app ./',
|
|
76
|
+
'EXPOSE 8080',
|
|
77
|
+
`CMD ["${manager.x.split(' ')[0]}", ${manager.x.includes(' ') ? `"${manager.x.split(' ')[1]}", ` : ''}"weft", "start"]`,
|
|
78
|
+
]
|
|
79
|
+
: [
|
|
80
|
+
'COPY --from=build /app/.output ./.output',
|
|
81
|
+
'EXPOSE 8080',
|
|
82
|
+
`CMD ${RUN[runtime] ?? RUN.node}`,
|
|
83
|
+
]),
|
|
45
84
|
]
|
|
46
85
|
return `${lines.join('\n')}\n`
|
|
47
86
|
}
|
|
48
87
|
|
|
49
|
-
const DOCKERIGNORE =
|
|
88
|
+
const DOCKERIGNORE =
|
|
89
|
+
'node_modules\n.output\n.weft/*\n!.weft/plugins.lock.json\n.env\n.env.*\n.git\n.netlify\n.vercel\ntarget\n'
|
|
50
90
|
|
|
51
91
|
function flyToml({ deploy, needs }) {
|
|
52
92
|
const fly = deploy.fly ?? {}
|
|
@@ -57,7 +97,9 @@ function flyToml({ deploy, needs }) {
|
|
|
57
97
|
'',
|
|
58
98
|
'[env]',
|
|
59
99
|
' PORT = "8080"',
|
|
60
|
-
...(needs.uploads && fly.volume
|
|
100
|
+
...(needs.uploads && fly.volume
|
|
101
|
+
? [` WEFT_MEDIA_DIR = "${fly.volume.path ?? DATA}/media"`]
|
|
102
|
+
: []),
|
|
61
103
|
'',
|
|
62
104
|
'[http_service]',
|
|
63
105
|
' internal_port = 8080',
|
|
@@ -66,7 +108,15 @@ function flyToml({ deploy, needs }) {
|
|
|
66
108
|
' auto_start_machines = true',
|
|
67
109
|
// a change feed is a listener: a stopped machine hears nothing
|
|
68
110
|
` min_machines_running = ${held ? 1 : 0}`,
|
|
69
|
-
...(fly.volume
|
|
111
|
+
...(fly.volume
|
|
112
|
+
? [
|
|
113
|
+
'',
|
|
114
|
+
'[[mounts]]',
|
|
115
|
+
` source = ${JSON.stringify(fly.volume.name ?? 'data')}`,
|
|
116
|
+
` destination = ${JSON.stringify(fly.volume.path ?? DATA)}`,
|
|
117
|
+
` initial_size = "${fly.volume.sizeGb ?? 1}gb"`,
|
|
118
|
+
]
|
|
119
|
+
: []),
|
|
70
120
|
'',
|
|
71
121
|
'[[vm]]',
|
|
72
122
|
` memory = ${JSON.stringify(fly.memory ?? '512mb')}`,
|
|
@@ -89,23 +139,43 @@ function renderYaml({ deploy, needs }) {
|
|
|
89
139
|
' envVars:',
|
|
90
140
|
' - key: NODE_ENV',
|
|
91
141
|
' value: production',
|
|
92
|
-
...(needs.uploads && r.disk
|
|
93
|
-
|
|
142
|
+
...(needs.uploads && r.disk
|
|
143
|
+
? [' - key: WEFT_MEDIA_DIR', ` value: ${r.disk.path ?? DATA}/media`]
|
|
144
|
+
: []),
|
|
145
|
+
...(r.disk
|
|
146
|
+
? [
|
|
147
|
+
' disk:',
|
|
148
|
+
' name: data',
|
|
149
|
+
` mountPath: ${r.disk.path ?? DATA}`,
|
|
150
|
+
` sizeGB: ${r.disk.sizeGb ?? 1}`,
|
|
151
|
+
]
|
|
152
|
+
: []),
|
|
94
153
|
]
|
|
95
154
|
return `${lines.join('\n')}\n`
|
|
96
155
|
}
|
|
97
156
|
|
|
98
|
-
const netlifyToml = ({ manager }) =>
|
|
157
|
+
const netlifyToml = ({ manager }) =>
|
|
158
|
+
`[build]\n command = "${manager.x} weft build --preset netlify"\n publish = "dist"\n\n[build.environment]\n NODE_VERSION = "26"\n`
|
|
99
159
|
|
|
100
|
-
const vercelJson = ({ manager }) =>
|
|
160
|
+
const vercelJson = ({ manager }) =>
|
|
161
|
+
`${JSON.stringify({ $schema: 'https://openapi.vercel.sh/vercel.json', framework: null, installCommand: manager.install.replace(/^corepack enable && /, ''), buildCommand: `${manager.x} weft build --preset vercel` }, null, 2)}\n`
|
|
101
162
|
|
|
102
|
-
const wranglerToml = ({ deploy }) =>
|
|
163
|
+
const wranglerToml = ({ deploy }) =>
|
|
164
|
+
`name = ${JSON.stringify(deploy.cloudflare?.name ?? 'my-app')}\ncompatibility_flags = ["nodejs_compat"]\n`
|
|
103
165
|
|
|
104
166
|
/** The workflow: on push to the branch, check and deploy to production; on a pull request, a preview where the host has one. */
|
|
105
167
|
function workflow({ target, deploy, manager }) {
|
|
106
168
|
const h = host(target)
|
|
107
169
|
const previews = h.kind !== 'server'
|
|
108
|
-
const node =
|
|
170
|
+
const node =
|
|
171
|
+
manager.name === 'bun'
|
|
172
|
+
? [' - uses: oven-sh/setup-bun@v2']
|
|
173
|
+
: [
|
|
174
|
+
' - uses: actions/setup-node@v4',
|
|
175
|
+
' with:',
|
|
176
|
+
' node-version: 26',
|
|
177
|
+
...(manager.cache ? [` cache: ${manager.cache}`] : []),
|
|
178
|
+
]
|
|
109
179
|
const lines = [
|
|
110
180
|
'name: weft deploy',
|
|
111
181
|
'on:',
|
|
@@ -115,6 +185,7 @@ function workflow({ target, deploy, manager }) {
|
|
|
115
185
|
'permissions:',
|
|
116
186
|
' contents: read',
|
|
117
187
|
'concurrency:',
|
|
188
|
+
// oxlint-disable-next-line no-template-curly-in-string -- a GitHub Actions expression, not a JS template
|
|
118
189
|
' group: weft-deploy-${{ github.ref }}',
|
|
119
190
|
' cancel-in-progress: true',
|
|
120
191
|
'jobs:',
|
|
@@ -126,9 +197,10 @@ function workflow({ target, deploy, manager }) {
|
|
|
126
197
|
...(target === 'fly' ? [' - uses: superfly/flyctl-actions/setup-flyctl@master'] : []),
|
|
127
198
|
` - run: ${manager.install}`,
|
|
128
199
|
` - run: ${manager.x} weft check`,
|
|
200
|
+
// oxlint-disable-next-line no-template-curly-in-string -- a GitHub Actions expression, not a JS template
|
|
129
201
|
` - run: ${manager.x} weft deploy --yes${previews ? " ${{ github.event_name == 'push' && '--prod' || '' }}" : ' --prod'}`,
|
|
130
202
|
' env:',
|
|
131
|
-
...h.env.map(k => ` ${k}: \${{ secrets.${k} }}`),
|
|
203
|
+
...h.env.map((k) => ` ${k}: \${{ secrets.${k} }}`),
|
|
132
204
|
]
|
|
133
205
|
return `${lines.join('\n')}\n`
|
|
134
206
|
}
|
|
@@ -161,12 +233,18 @@ export function filesFor(ctx) {
|
|
|
161
233
|
* alone), or `foreign` (no stamp: not weft's).
|
|
162
234
|
*/
|
|
163
235
|
export function fileStates(root, files) {
|
|
164
|
-
return files.map(f => {
|
|
236
|
+
return files.map((f) => {
|
|
165
237
|
const abs = join(root, f.path)
|
|
166
238
|
if (!existsSync(abs)) return { ...f, state: 'missing' }
|
|
167
239
|
const now = readFileSync(abs, 'utf8')
|
|
168
240
|
if (now === f.text) return { ...f, state: 'current' }
|
|
169
|
-
if (f.json)
|
|
241
|
+
if (f.json)
|
|
242
|
+
return {
|
|
243
|
+
...f,
|
|
244
|
+
state: JSON.parse(now || '{}').buildCommand?.includes('weft build --preset vercel')
|
|
245
|
+
? 'edited'
|
|
246
|
+
: 'stale',
|
|
247
|
+
}
|
|
170
248
|
if (f.plain) return { ...f, state: 'edited' }
|
|
171
249
|
const m = STAMP.exec(now)
|
|
172
250
|
if (!m) return { ...f, state: 'foreign' }
|
|
@@ -178,7 +256,8 @@ export function fileStates(root, files) {
|
|
|
178
256
|
export function writeFiles(root, states, { force = false } = {}) {
|
|
179
257
|
const written = []
|
|
180
258
|
for (const f of states) {
|
|
181
|
-
if (f.state === 'current' || (!force && (f.state === 'edited' || f.state === 'foreign')))
|
|
259
|
+
if (f.state === 'current' || (!force && (f.state === 'edited' || f.state === 'foreign')))
|
|
260
|
+
continue
|
|
182
261
|
mkdirSync(dirname(join(root, f.path)), { recursive: true })
|
|
183
262
|
writeFileSync(join(root, f.path), f.text)
|
|
184
263
|
written.push(f.path)
|