@weft-js/cli 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (171) hide show
  1. package/README.md +38 -14
  2. package/bin/weft.mjs +8 -1
  3. package/docs/async.md +16 -6
  4. package/docs/browser.md +22 -8
  5. package/docs/config.md +39 -9
  6. package/docs/core.md +30 -0
  7. package/docs/deploy.md +108 -0
  8. package/docs/design.md +12 -3
  9. package/docs/entrypoints.md +79 -0
  10. package/docs/fonts.md +12 -2
  11. package/docs/icons.md +13 -7
  12. package/docs/lists.md +3 -1
  13. package/docs/meta.md +13 -3
  14. package/docs/plugins.md +32 -9
  15. package/docs/props.md +13 -2
  16. package/docs/reactivity.md +26 -0
  17. package/docs/rules.md +19 -12
  18. package/docs/rust.md +47 -17
  19. package/docs/start-short.md +3 -2
  20. package/docs/start.md +226 -52
  21. package/docs/state.md +33 -7
  22. package/docs/style.md +31 -8
  23. package/docs/surface.md +61 -15
  24. package/docs/testing.md +15 -5
  25. package/docs/theme.md +182 -0
  26. package/docs/ui-state.md +19 -6
  27. package/docs/verify.md +13 -0
  28. package/package.json +13 -4
  29. package/src/add/config.mjs +93 -0
  30. package/src/add/errors.mjs +2 -0
  31. package/src/add/setup.mjs +88 -0
  32. package/src/add-rust.mjs +82 -27
  33. package/src/add.mjs +209 -181
  34. package/src/agents-md.mjs +144 -43
  35. package/src/alias.mjs +16 -5
  36. package/src/audit/page.mjs +313 -0
  37. package/src/audit/report.mjs +69 -0
  38. package/src/audit/utils.mjs +5 -0
  39. package/src/audit.mjs +174 -188
  40. package/src/browser/cli.mjs +24 -5
  41. package/src/browser/client.mjs +189 -25
  42. package/src/browser/commands.mjs +166 -51
  43. package/src/catalog/audit.mjs +129 -0
  44. package/src/catalog/auth.mjs +40 -0
  45. package/src/catalog/compiler.mjs +337 -0
  46. package/src/catalog/contracts.mjs +18 -0
  47. package/src/catalog/database.mjs +52 -0
  48. package/src/catalog/deploy.mjs +117 -0
  49. package/src/catalog/design.mjs +159 -0
  50. package/src/catalog/forms.mjs +25 -0
  51. package/src/catalog/hydration.mjs +29 -0
  52. package/src/catalog/icons.mjs +24 -0
  53. package/src/catalog/media.mjs +163 -0
  54. package/src/catalog/metadata.mjs +116 -0
  55. package/src/catalog/plugins.mjs +228 -0
  56. package/src/catalog/realtime.mjs +195 -0
  57. package/src/catalog/rust.mjs +75 -0
  58. package/src/catalog/server-functions.mjs +92 -0
  59. package/src/catalog/state.mjs +39 -0
  60. package/src/catalog/style-groups.mjs +54 -0
  61. package/src/catalog/tests.mjs +127 -0
  62. package/src/catalog/text.mjs +200 -0
  63. package/src/catalog/ui-state.mjs +42 -0
  64. package/src/catalog.mjs +68 -444
  65. package/src/check/runtime.mjs +280 -0
  66. package/src/check.mjs +199 -207
  67. package/src/cli/authoring.mjs +213 -0
  68. package/src/cli/project.mjs +302 -0
  69. package/src/cli.mjs +228 -224
  70. package/src/config.mjs +1 -1
  71. package/src/contributions.mjs +75 -25
  72. package/src/db-layer.mjs +13 -5
  73. package/src/deploy/cdn.mjs +142 -0
  74. package/src/deploy/files.mjs +266 -0
  75. package/src/deploy/fit.mjs +166 -0
  76. package/src/deploy/hosts.mjs +126 -0
  77. package/src/deploy/index/cli.mjs +20 -0
  78. package/src/deploy/index/environment.mjs +69 -0
  79. package/src/deploy/index/steps.mjs +87 -0
  80. package/src/deploy/index.mjs +387 -0
  81. package/src/deploy/needs.mjs +149 -0
  82. package/src/design-checks/accessible-names.mjs +62 -0
  83. package/src/design-checks/contrast.mjs +43 -0
  84. package/src/design-checks/csp.mjs +60 -0
  85. package/src/design-checks/files.mjs +59 -0
  86. package/src/design-checks/heading-links.mjs +61 -0
  87. package/src/design-checks/interaction.mjs +65 -0
  88. package/src/design-checks/jsx.mjs +67 -0
  89. package/src/design-checks/layout-values.mjs +68 -0
  90. package/src/design-checks/native-controls.mjs +48 -0
  91. package/src/design-checks/outline.mjs +39 -0
  92. package/src/design-checks/reporter.mjs +37 -0
  93. package/src/design-checks/rules.mjs +274 -0
  94. package/src/design-checks/values.mjs +262 -0
  95. package/src/design-checks.mjs +10 -400
  96. package/src/diag.mjs +32 -8
  97. package/src/docs.mjs +138 -42
  98. package/src/doctor.mjs +20 -8
  99. package/src/files.mjs +18 -5
  100. package/src/fix.mjs +10 -2
  101. package/src/gate/after.mjs +40 -0
  102. package/src/gate/before.mjs +54 -0
  103. package/src/gate/classify.mjs +72 -0
  104. package/src/gate/mutation.mjs +209 -0
  105. package/src/gate/resolve-cases.mjs +22 -0
  106. package/src/gate/result.mjs +63 -0
  107. package/src/gate/run.mjs +75 -0
  108. package/src/gate.mjs +104 -202
  109. package/src/infer/box-props.cjs +140 -0
  110. package/src/infer/core-boxes.cjs +46 -0
  111. package/src/infer/derived-writes.cjs +95 -0
  112. package/src/infer/engine/checks.cjs +193 -0
  113. package/src/infer/engine/flow.cjs +293 -0
  114. package/src/infer/engine/report.cjs +158 -0
  115. package/src/infer/engine/utils.cjs +15 -0
  116. package/src/infer/engine.cjs +108 -368
  117. package/src/infer/facts/sites.cjs +114 -0
  118. package/src/infer/facts.cjs +210 -117
  119. package/src/infer/hosts.cjs +69 -22
  120. package/src/infer/lsp.mjs +149 -40
  121. package/src/infer/session.cjs +73 -17
  122. package/src/infer/shadow.cjs +13 -5
  123. package/src/infer/worker.cjs +57 -9
  124. package/src/jsx-scan.mjs +63 -21
  125. package/src/load-config/schema.mjs +281 -0
  126. package/src/load-config.mjs +144 -116
  127. package/src/map.mjs +93 -26
  128. package/src/mcp/browser-tools.mjs +155 -0
  129. package/src/mcp.mjs +147 -69
  130. package/src/meta-table.mjs +43 -8
  131. package/src/mutate.mjs +120 -36
  132. package/src/plugin-checks.mjs +52 -23
  133. package/src/plugins.mjs +125 -39
  134. package/src/project.mjs +170 -79
  135. package/src/props-checks.mjs +24 -6
  136. package/src/props-listener.mjs +27 -9
  137. package/src/react-checks.mjs +35 -4
  138. package/src/server-state-checks.mjs +34 -0
  139. package/src/share-checks.mjs +121 -37
  140. package/src/site.mjs +33 -6
  141. package/src/slots.mjs +104 -19
  142. package/src/start/build.mjs +116 -0
  143. package/src/start/routes.mjs +38 -0
  144. package/src/start.mjs +194 -92
  145. package/src/state-checks/core-state.mjs +223 -0
  146. package/src/state-checks/modules.mjs +17 -0
  147. package/src/state-checks/scan.mjs +232 -0
  148. package/src/state-checks/server-graph.mjs +65 -0
  149. package/src/state-checks.mjs +109 -133
  150. package/src/style/find.mjs +121 -0
  151. package/src/style.mjs +133 -127
  152. package/src/surface-checks.mjs +212 -46
  153. package/src/tailwind.mjs +5 -4
  154. package/src/tasks.mjs +189 -27
  155. package/src/test-rules/context.mjs +109 -0
  156. package/src/test-rules/file-rules.mjs +279 -0
  157. package/src/test-rules/rule-helpers.mjs +244 -0
  158. package/src/test-rules/syntax.mjs +223 -0
  159. package/src/test-rules/test-rules.mjs +127 -0
  160. package/src/testdiff.mjs +94 -32
  161. package/src/tests.mjs +31 -483
  162. package/src/text-checks.mjs +229 -69
  163. package/src/tokens.mjs +12 -4
  164. package/src/trust/scan.mjs +95 -0
  165. package/src/trust.mjs +214 -102
  166. package/src/weft.mjs +41 -9
  167. package/src/workspace.mjs +96 -19
  168. package/ts-plugin/context.js +57 -0
  169. package/ts-plugin/index.js +4 -124
  170. package/ts-plugin/mapping.js +65 -0
  171. package/ts-plugin/proxy.js +144 -0
@@ -0,0 +1,126 @@
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',
18
+ name: 'Netlify',
19
+ kind: 'functions',
20
+ preset: 'netlify',
21
+ sockets: false,
22
+ held: false,
23
+ disk: 'none',
24
+ sidecars: false,
25
+ many: true,
26
+ prebuilt: true,
27
+ env: ['NETLIFY_AUTH_TOKEN', 'NETLIFY_SITE_ID'],
28
+ deploys: true,
29
+ cost: 'free tier; usage past it is billed in credits',
30
+ pricing: 'https://www.netlify.com/pricing/',
31
+ docs: 'https://docs.netlify.com/cli/get-started/',
32
+ },
33
+ {
34
+ id: 'vercel',
35
+ name: 'Vercel',
36
+ kind: 'functions',
37
+ preset: 'vercel',
38
+ sockets: false,
39
+ held: false,
40
+ disk: 'none',
41
+ sidecars: false,
42
+ many: true,
43
+ prebuilt: true,
44
+ env: ['VERCEL_TOKEN', 'VERCEL_ORG_ID', 'VERCEL_PROJECT_ID'],
45
+ deploys: true,
46
+ cost: 'Hobby is free for non-commercial use; Pro is per seat',
47
+ pricing: 'https://vercel.com/pricing',
48
+ docs: 'https://vercel.com/docs/cli',
49
+ },
50
+ {
51
+ id: 'cloudflare',
52
+ name: 'Cloudflare Workers',
53
+ kind: 'workers',
54
+ preset: 'cloudflare_durable',
55
+ sockets: true,
56
+ held: false,
57
+ disk: 'none',
58
+ sidecars: false,
59
+ many: true,
60
+ prebuilt: true,
61
+ env: ['CLOUDFLARE_API_TOKEN', 'CLOUDFLARE_ACCOUNT_ID'],
62
+ deploys: true,
63
+ cost: 'free tier includes Workers and SQLite-backed Durable Objects',
64
+ pricing: 'https://developers.cloudflare.com/workers/platform/pricing/',
65
+ docs: 'https://developers.cloudflare.com/workers/wrangler/',
66
+ },
67
+ {
68
+ id: 'fly',
69
+ name: 'Fly.io',
70
+ kind: 'server',
71
+ sockets: true,
72
+ held: true,
73
+ disk: 'volume',
74
+ sidecars: true,
75
+ many: false,
76
+ prebuilt: false,
77
+ env: ['FLY_API_TOKEN'],
78
+ deploys: true,
79
+ cost: 'pay as you go, no free tier for new organizations',
80
+ pricing: 'https://fly.io/docs/about/pricing/',
81
+ docs: 'https://fly.io/docs/flyctl/',
82
+ },
83
+ {
84
+ id: 'render',
85
+ name: 'Render',
86
+ kind: 'server',
87
+ sockets: true,
88
+ held: true,
89
+ disk: 'paid-disk',
90
+ sidecars: true,
91
+ many: false,
92
+ prebuilt: false,
93
+ env: ['RENDER_DEPLOY_HOOK_URL'],
94
+ deploys: true,
95
+ cost: 'free instance sleeps after 15 idle minutes and has no disk; paid plans add a disk',
96
+ pricing: 'https://render.com/pricing',
97
+ docs: 'https://render.com/docs/blueprint-spec',
98
+ },
99
+ {
100
+ id: 'aws',
101
+ name: 'AWS Lambda',
102
+ kind: 'functions',
103
+ preset: 'aws_lambda',
104
+ sockets: false,
105
+ held: false,
106
+ disk: 'none',
107
+ sidecars: false,
108
+ many: true,
109
+ prebuilt: true,
110
+ env: [],
111
+ deploys: false,
112
+ cost: 'Lambda has a free tier; a server would go on App Runner, billed per running instance',
113
+ pricing: 'https://aws.amazon.com/pricing/',
114
+ docs: 'https://nitro.build/deploy/providers/aws',
115
+ },
116
+ ]
117
+
118
+ export const TARGETS = HOSTS.filter((h) => h.deploys).map((h) => h.id)
119
+
120
+ export const host = (id) => HOSTS.find((h) => h.id === id)
121
+
122
+ /** `runtime` in the config as the server preset a container runs. */
123
+ const SERVER_PRESETS = { node: 'node', bun: 'bun', deno: 'deno_server' }
124
+
125
+ /** The preset a build for this host uses. */
126
+ export const presetFor = (h, runtime = 'node') => h.preset ?? SERVER_PRESETS[runtime] ?? 'node'
@@ -0,0 +1,20 @@
1
+ import { spawnSync } from 'node:child_process'
2
+
3
+ const refused = (message) => Object.assign(new Error(message), { code: 'E_REFUSED' })
4
+
5
+ /** The host's CLI: on PATH, else through npx for the ones on npm. */
6
+ export function cliFor(
7
+ name,
8
+ {
9
+ which = (cmd) =>
10
+ spawnSync(process.platform === 'win32' ? 'where' : 'which', [cmd], { encoding: 'utf8' })
11
+ .status === 0,
12
+ } = {},
13
+ ) {
14
+ const npm = { netlify: 'netlify-cli', vercel: 'vercel', wrangler: 'wrangler' }
15
+ for (const cmd of name === 'flyctl' ? ['flyctl', 'fly'] : [name]) if (which(cmd)) return [cmd]
16
+ if (npm[name]) return ['npx', '--yes', npm[name]]
17
+ throw refused(
18
+ `\`${name}\` isn't installed: ${name === 'flyctl' ? 'https://fly.io/docs/flyctl/install/' : `npm install -g ${npm[name] ?? name}`}`,
19
+ )
20
+ }
@@ -0,0 +1,69 @@
1
+ import { existsSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+
4
+ /** A variable a step reads, and whether its absence stops the deploy (E007). */
5
+ export function missingEnv(root, target, config, env) {
6
+ const ci = !!env.CI
7
+ const linked = {
8
+ netlify: existsSync(join(root, '.netlify/state.json')) || !!config.deploy?.netlify?.site,
9
+ vercel: existsSync(join(root, '.vercel/project.json')),
10
+ }
11
+ // a token: the CLI's own login stands in, except in CI. An id: needed unless the project is linked.
12
+ const token = (k, how) => [k, ci ? 'error' : 'warning', how, ": the CLI's own login is used"]
13
+ const id = (k, known, how) => [k, known ? null : 'error', how, '']
14
+ const need = {
15
+ netlify: [
16
+ token(
17
+ 'NETLIFY_AUTH_TOKEN',
18
+ '`netlify login` here, or a personal access token from User settings → Applications',
19
+ ),
20
+ id(
21
+ 'NETLIFY_SITE_ID',
22
+ linked.netlify,
23
+ '`netlify link`, `deploy.netlify.site`, or the site id from Site configuration',
24
+ ),
25
+ ],
26
+ vercel: [
27
+ token('VERCEL_TOKEN', '`vercel login` here, or a token from Account Settings → Tokens'),
28
+ id('VERCEL_ORG_ID', linked.vercel, '`vercel link` writes it to .vercel/project.json'),
29
+ id('VERCEL_PROJECT_ID', linked.vercel, '`vercel link` writes it to .vercel/project.json'),
30
+ ],
31
+ cloudflare: [
32
+ token('CLOUDFLARE_API_TOKEN', '`wrangler login` here, or a token with Workers Scripts: Edit'),
33
+ id('CLOUDFLARE_ACCOUNT_ID', !ci, 'the account id on the Workers overview page'),
34
+ ],
35
+ fly: [token('FLY_API_TOKEN', '`flyctl auth login` here, or `flyctl tokens create deploy`')],
36
+ render: [id('RENDER_DEPLOY_HOOK_URL', false, "the service's Settings → Deploy Hook")],
37
+ }[target]
38
+ return need
39
+ .filter(([k, severity]) => severity && !env[k])
40
+ .map(([k, severity, how, local]) => ({
41
+ code: 'E007',
42
+ severity,
43
+ file: 'weft.config.ts',
44
+ why: `\`${k}\` is unset${severity === 'warning' ? local : ''}`,
45
+ fix: `${how}. Set it in the environment (never a file)`,
46
+ }))
47
+ }
48
+
49
+ /** The URL a host's CLI printed. */
50
+ export function urlFrom(step, stdout) {
51
+ if (step.url === 'json') {
52
+ try {
53
+ const j = JSON.parse(stdout.slice(stdout.indexOf('{')))
54
+ return j.url ?? j.deploy_url
55
+ } catch {
56
+ return
57
+ }
58
+ }
59
+ if (step.url === 'last-line')
60
+ return stdout
61
+ .trim()
62
+ .split('\n')
63
+ .reverse()
64
+ .find((l) => l.trim().startsWith('https://'))
65
+ ?.trim()
66
+ if (step.url === 'workers') return /https:\/\/[\w.-]+\.workers\.dev/.exec(stdout)?.[0]
67
+ if (step.url === 'fly') return /https:\/\/[\w.-]+\.fly\.dev/.exec(stdout)?.[0]
68
+ return step.url
69
+ }
@@ -0,0 +1,87 @@
1
+ import { host, presetFor } from '../hosts.mjs'
2
+ import { needsOf } from '../needs.mjs'
3
+
4
+ /**
5
+ * The steps `weft deploy` takes for a target, as data: a dry run prints them, `--yes` runs them. A step reads env vars
6
+ * by name; their values are filled in only as it runs.
7
+ */
8
+ export function deploySteps(root, config, { target, prod = false } = {}) {
9
+ const h = host(target)
10
+ const deploy = config.deploy ?? {}
11
+ const runtime = config.runtime ?? 'node'
12
+ const needs = needsOf(root, config)
13
+ const steps = []
14
+ const build = {
15
+ kind: 'build',
16
+ what: `weft build --preset ${presetFor(h, runtime)}`,
17
+ preset: presetFor(h, runtime),
18
+ }
19
+ if (target === 'netlify') {
20
+ const site = process.env.NETLIFY_SITE_ID
21
+ ? []
22
+ : deploy.netlify?.site
23
+ ? ['--site', deploy.netlify.site]
24
+ : []
25
+ steps.push(build, {
26
+ kind: 'run',
27
+ cli: 'netlify',
28
+ args: [
29
+ 'deploy',
30
+ '--dir',
31
+ 'dist',
32
+ '--functions',
33
+ '.netlify/functions-internal',
34
+ '--json',
35
+ ...site,
36
+ ...(prod ? ['--prod'] : []),
37
+ ],
38
+ reads: ['NETLIFY_AUTH_TOKEN', 'NETLIFY_SITE_ID'],
39
+ url: 'json',
40
+ })
41
+ }
42
+ if (target === 'vercel')
43
+ steps.push(build, {
44
+ kind: 'run',
45
+ cli: 'vercel',
46
+ args: ['deploy', '--prebuilt', '--yes', ...(prod ? ['--prod'] : [])],
47
+ token: 'VERCEL_TOKEN',
48
+ reads: ['VERCEL_TOKEN', 'VERCEL_ORG_ID', 'VERCEL_PROJECT_ID'],
49
+ url: 'last-line',
50
+ })
51
+ if (target === 'cloudflare')
52
+ steps.push(build, {
53
+ kind: 'run',
54
+ cli: 'wrangler',
55
+ args: ['deploy'],
56
+ cwd: '.output',
57
+ reads: ['CLOUDFLARE_API_TOKEN', 'CLOUDFLARE_ACCOUNT_ID'],
58
+ url: 'workers',
59
+ })
60
+ if (target === 'fly') {
61
+ // one machine unless a bus reaches the others: a second machine would split the rooms, and a volume is per machine
62
+ const one = needs.memoryBus || deploy.fly?.volume
63
+ steps.push({
64
+ kind: 'run',
65
+ cli: 'flyctl',
66
+ args: [
67
+ 'deploy',
68
+ '--remote-only',
69
+ ...(deploy.fly?.app ? ['--app', deploy.fly.app] : []),
70
+ ...(one ? ['--ha=false'] : []),
71
+ ],
72
+ reads: ['FLY_API_TOKEN'],
73
+ url: deploy.fly?.app ? `https://${deploy.fly.app}.fly.dev` : 'fly',
74
+ })
75
+ }
76
+ if (target === 'render')
77
+ steps.push({
78
+ kind: 'hook',
79
+ what: 'POST $RENDER_DEPLOY_HOOK_URL (Render builds the pushed commit)',
80
+ reads: ['RENDER_DEPLOY_HOOK_URL'],
81
+ url: deploy.render?.service ? `https://${deploy.render.service}.onrender.com` : undefined,
82
+ })
83
+ for (const s of steps)
84
+ if (s.cli)
85
+ s.what = `${s.cli} ${s.args.join(' ')}${s.token ? ` --token $${s.token}` : ''}${s.cwd ? ` (in ${s.cwd}/)` : ''}`
86
+ return steps
87
+ }
@@ -0,0 +1,387 @@
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 { 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 } from './hosts.mjs'
14
+ import { needsOf } from './needs.mjs'
15
+ import { cliFor } from './index/cli.mjs'
16
+ import { deploySteps } from './index/steps.mjs'
17
+ import { missingEnv, urlFrom } from './index/environment.mjs'
18
+
19
+ export { deploySteps, urlFrom }
20
+
21
+ export { HOSTS, TARGETS, fit, hostsReport, needsOf }
22
+
23
+ const usage = (message) => Object.assign(new Error(message), { code: 'E_USAGE' })
24
+ const refused = (message) => Object.assign(new Error(message), { code: 'E_REFUSED' })
25
+
26
+ /** A finding as a diagnostic, pointing at the file that shows the need. */
27
+ function toDiagnostic(root, f) {
28
+ let text = ''
29
+ try {
30
+ text = readFileSync(join(root, f.file), 'utf8')
31
+ } catch {}
32
+ const at = f.line
33
+ ? { span: [f.line, 1, f.line, 1] }
34
+ : text && f.needle
35
+ ? locate(text, f.needle)
36
+ : {}
37
+ return diagnostic({
38
+ code: f.code,
39
+ severity: f.severity,
40
+ file: f.file,
41
+ ...at,
42
+ why: f.why,
43
+ help: f.fix,
44
+ })
45
+ }
46
+
47
+ /** The target this run is about: `--target`, then `deploy.target`, then the host whose preset `--preset` names. */
48
+ export function targetOf(config, { target, preset } = {}) {
49
+ const id =
50
+ target ?? config.deploy?.target ?? (preset && HOSTS.find((h) => h.preset === preset)?.id)
51
+ if (id && !host(id)) throw usage(`unknown deploy target \`${id}\`: use ${TARGETS.join(', ')}`)
52
+ return id
53
+ }
54
+
55
+ /** E001–E004, E006, E008–E014 for `weft check`: the configured target's fit, its files, written secrets, CDN setup. */
56
+ export function deployChecks(root, config, { preset } = {}) {
57
+ const out = writtenSecrets(root).map((s) => ({
58
+ code: 'E008',
59
+ severity: 'error',
60
+ file: s.file,
61
+ line: s.line,
62
+ why: `${s.file}:${s.line} has ${s.what} in it: it would be committed, and anyone with the repository could deploy or bill as you`,
63
+ fix: "Move it to the environment (the host's settings, or GitHub's secrets for the workflow), and rotate it: it has been seen",
64
+ }))
65
+ let id
66
+ try {
67
+ id = targetOf(config, { preset })
68
+ } catch {
69
+ id = undefined
70
+ }
71
+ const deploy = config.deploy ?? {}
72
+ if (id) {
73
+ out.push(...fit(needsOf(root, config), host(id), { deploy }).findings)
74
+ if (deploy.target === id && host(id).deploys) {
75
+ for (const f of fileStates(root, filesFor(initContext(root, config, id)))) {
76
+ if (f.state === 'missing' || f.state === 'stale')
77
+ out.push({
78
+ code: 'E006',
79
+ severity: 'warning',
80
+ file: f.path,
81
+ why: `\`${f.path}\` is ${f.state === 'missing' ? 'missing' : 'from an older config'}: ${host(id).name} would deploy ${f.state === 'missing' ? 'without it' : 'what it said then'}`,
82
+ fix: '`weft deploy init` writes it (a file edited by hand is left alone)',
83
+ })
84
+ }
85
+ }
86
+ }
87
+ out.push(...cdnFindings(config))
88
+ return out.map((f) => toDiagnostic(root, f))
89
+ }
90
+
91
+ /** What the generators read. */
92
+ export function initContext(root, config, target) {
93
+ return {
94
+ target,
95
+ deploy: config.deploy ?? {},
96
+ needs: needsOf(root, config),
97
+ runtime: config.runtime ?? 'node',
98
+ manager: managerOf(root),
99
+ }
100
+ }
101
+
102
+ /** `weft deploy hosts`, and the `deploy` part of `weft doctor`: every host's fit and the suggestion (E005 when none is set). */
103
+ export function hostsCommand(root, config) {
104
+ const needs = needsOf(root, config)
105
+ const report = hostsReport(needs, { deploy: config.deploy })
106
+ const used = Object.entries(needs)
107
+ .filter(([k, v]) => k !== 'runtime' && v)
108
+ .map(([k, v]) => ({ need: k, ...v }))
109
+ const diagnostics = config.deploy?.target
110
+ ? []
111
+ : [
112
+ diagnostic({
113
+ code: 'E005',
114
+ severity: 'info',
115
+ file: findConfig(root) ? relative(root, findConfig(root)) : 'weft.config.ts',
116
+ why: `No \`deploy.target\`. ${report.fits
117
+ .filter((f) => f.fits && f.deploys)
118
+ .map((f) => f.name)
119
+ .join(
120
+ ', ',
121
+ )} can run this app; weft suggests ${host(report.suggestion.host).name}: ${report.suggestion.why}.`,
122
+ help: `\`weft deploy init ${report.suggestion.host}\``,
123
+ }),
124
+ ]
125
+ return { needs: used, ...report, target: config.deploy?.target ?? null, diagnostics }
126
+ }
127
+
128
+ export function formatHosts(r) {
129
+ const lines = [
130
+ `needs: ${r.needs.length ? r.needs.map((n) => `${n.what} (${n.file})`).join(', ') : "nothing a CDN with functions can't give"}`,
131
+ '',
132
+ ]
133
+ for (const f of r.fits) {
134
+ const mark = f.fits ? (f.findings.length ? '~' : '✓') : '✗'
135
+ lines.push(
136
+ `${mark} ${f.host.padEnd(11)} ${f.name}${f.deploys ? '' : " (fit only: `weft deploy` doesn't drive it yet)"}`,
137
+ )
138
+ for (const x of f.findings) lines.push(` ${x.code} ${x.why}. Fix: ${x.fix}`)
139
+ lines.push(` cost: ${f.cost} (${f.pricing})`)
140
+ }
141
+ lines.push(
142
+ '',
143
+ `suggested: ${r.suggestion.host}, because ${r.suggestion.why}${r.suggestion.alternative ? `. Next best: ${r.suggestion.alternative}` : ''}`,
144
+ )
145
+ return lines.join('\n')
146
+ }
147
+
148
+ /** The object literal `export default` gives, directly or through `defineConfig(…)`. */
149
+ function configObject(sf) {
150
+ for (const st of sf.statements) {
151
+ if (!ts.isExportAssignment(st)) continue
152
+ const value = ts.isCallExpression(st.expression) ? st.expression.arguments[0] : st.expression
153
+ if (value && ts.isObjectLiteralExpression(value)) return value
154
+ }
155
+ }
156
+
157
+ /** The `deploy` block init writes when the config has none: the names a host needs, from package.json, and a disk when data needs one. */
158
+ export function defaultDeploy(root, target, needs) {
159
+ let name = 'my-app'
160
+ try {
161
+ name =
162
+ JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'))
163
+ .name?.replace(/^@[^/]+\//, '')
164
+ .replace(/[^a-z0-9-]/gi, '-')
165
+ .toLowerCase() || name
166
+ } catch {}
167
+ const kept = needs.sqlite || needs.uploads
168
+ const per = {
169
+ netlify: { site: name },
170
+ vercel: { project: name },
171
+ cloudflare: { name },
172
+ fly: { app: name, region: 'iad', ...(kept && { volume: { name: 'data', sizeGb: 1 } }) },
173
+ render: { service: name, ...(kept && { plan: 'starter', disk: { sizeGb: 1 } }) },
174
+ }
175
+ return { target, [target]: per[target], ci: 'github', branch: 'main' }
176
+ }
177
+
178
+ const literal = (v) => {
179
+ if (v && typeof v === 'object')
180
+ return `{ ${Object.entries(v)
181
+ .map(([k, x]) => `${k}: ${literal(x)}`)
182
+ .join(', ')} }`
183
+ return typeof v === 'string' ? `'${v}'` : String(v)
184
+ }
185
+
186
+ /** The config with `deploy` added. Refuses to touch one that already has it, or that isn't a plain object. */
187
+ export function withDeploy(file, text, deploy) {
188
+ if (isDataConfig(file)) {
189
+ if (file.endsWith('.json'))
190
+ return `${JSON.stringify({ ...JSON.parse(text), deploy }, null, 2)}\n`
191
+ return `${text.replace(/\n*$/, '\n')}deploy:\n${Object.entries(deploy)
192
+ .map(([k, v]) => ` ${k}: ${JSON.stringify(v)}`)
193
+ .join('\n')}\n`
194
+ }
195
+ const sf = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true)
196
+ const object = configObject(sf)
197
+ if (!object)
198
+ throw refused(
199
+ `${file} doesn't export a plain object, so \`weft deploy init\` can't add \`deploy\` to it. Add \`deploy: ${literal(deploy)}\` by hand. Nothing was changed.`,
200
+ )
201
+ const block = `deploy: ${literal(deploy)},`
202
+ const last = object.properties.at(-1)
203
+ return last
204
+ ? text.slice(0, object.properties.end) +
205
+ `${object.properties.hasTrailingComma ? '' : ','}\n ${block}` +
206
+ text.slice(object.properties.end)
207
+ : text.slice(0, object.getStart(sf) + 1) +
208
+ `\n ${block}\n` +
209
+ text.slice(object.getStart(sf) + 1)
210
+ }
211
+
212
+ /**
213
+ * `weft deploy init [target]`: the host's files and workflow, and `deploy` in the config when it has none. Refuses a
214
+ * target the app doesn't fit unless `unsafe` lets the codes through.
215
+ */
216
+ export function initCommand(root, config, { target, dryRun = false, force = false, unsafe } = {}) {
217
+ const needs = needsOf(root, config)
218
+ const id = targetOf(config, { target }) ?? hostsReport(needs).suggestion.host
219
+ const h = host(id)
220
+ if (!h.deploys)
221
+ throw usage(
222
+ `\`weft deploy\` doesn't drive ${h.name} yet (research 46 §11): ${TARGETS.join(', ')} it does`,
223
+ )
224
+ if (config.deploy?.target && target && config.deploy.target !== target)
225
+ throw refused(
226
+ `\`deploy.target\` is \`${config.deploy.target}\` in the config. Change it there to move to \`${target}\`, then run this again. Nothing was changed.`,
227
+ )
228
+ const deploy = config.deploy ?? defaultDeploy(root, id, needs)
229
+ const result = fit(needs, h, { deploy })
230
+ const blocking = result.findings.filter((f) => f.severity === 'error' && !unsafe?.allows(f))
231
+ if (blocking.length)
232
+ throw refused(
233
+ `${h.name} can't run this app:\n${blocking.map((f) => ` ${f.code} ${f.file}: ${f.why}.\n Fix: ${f.fix}`).join('\n')}\n\`weft deploy hosts\` shows which hosts can. Nothing was changed.`,
234
+ )
235
+ const ctx = { ...initContext(root, { ...config, deploy }, id) }
236
+ const states = fileStates(root, filesFor(ctx))
237
+ const configFile = findConfig(root)
238
+ const actions = []
239
+ let written = []
240
+ if (!config.deploy) {
241
+ const file = configFile ?? join(root, 'weft.config.ts')
242
+ const text = configFile
243
+ ? readFileSync(configFile, 'utf8')
244
+ : "import { defineConfig } from '@weft-js/core/config'\n\nexport default defineConfig({})\n"
245
+ actions.push(`${relative(root, file)}: ${configFile ? '' : '(new) '}deploy: ${literal(deploy)}`)
246
+ if (!dryRun) writeFileSync(file, withDeploy(file, text, deploy))
247
+ }
248
+ for (const f of states)
249
+ actions.push(
250
+ `${f.path}: ${{ missing: 'write', stale: 'rewrite (from an older config)', current: 'current', edited: force ? 'overwrite (edited by hand; --force)' : 'edited by hand: left alone', foreign: force ? 'overwrite (--force)' : 'not written by weft: left alone' }[f.state]}`,
251
+ )
252
+ if (!dryRun) written = writeFiles(root, states, { force })
253
+ const next = [
254
+ ...{
255
+ netlify: [
256
+ `netlify sites:create --name ${deploy.netlify?.site ?? '<name>'} (once; or link an existing site with \`netlify link\`)`,
257
+ ],
258
+ vercel: [
259
+ `vercel link --project ${deploy.vercel?.project ?? '<name>'} (once: writes .vercel/project.json with the org and project ids)`,
260
+ ],
261
+ cloudflare: ['wrangler login (once, locally; CI reads CLOUDFLARE_API_TOKEN)'],
262
+ fly: [
263
+ `flyctl apps create ${deploy.fly?.app ?? '<name>'} (once; billed per running machine)`,
264
+ ...(deploy.fly?.volume
265
+ ? [
266
+ `flyctl volumes create ${deploy.fly.volume.name ?? 'data'} --size ${deploy.fly.volume.sizeGb ?? 1} --region ${deploy.fly.region ?? 'iad'} -a ${deploy.fly.app} (once; billed)`,
267
+ ]
268
+ : []),
269
+ ],
270
+ render: [
271
+ 'Create the service from render.yaml: Render dashboard → New → Blueprint, on this repository (once)',
272
+ "Copy the service's deploy hook (Settings → Deploy Hook) into RENDER_DEPLOY_HOOK_URL",
273
+ ],
274
+ }[id],
275
+ ...((deploy.ci ?? 'github') === 'github'
276
+ ? [
277
+ `gh secret set ${h.env.join(' / ')} (each one, from the host's dashboard: weft never writes them)`,
278
+ ]
279
+ : []),
280
+ `weft deploy (a dry run: shows what --yes would run)`,
281
+ ]
282
+ return {
283
+ ok: true,
284
+ target: id,
285
+ dryRun,
286
+ actions,
287
+ written,
288
+ next,
289
+ findings: result.findings.map((f) => toDiagnostic(root, f)),
290
+ }
291
+ }
292
+
293
+ /**
294
+ * `weft deploy`: fit, then the steps. Without `yes` nothing runs: the result says what would. `build` is `weft build`
295
+ * in-process (start.mjs's buildCommand), `exec` runs a host CLI; both are injectable for tests.
296
+ */
297
+ export async function deployCommand(
298
+ root,
299
+ config,
300
+ {
301
+ target,
302
+ prod = false,
303
+ yes = false,
304
+ unsafe,
305
+ env = process.env,
306
+ build,
307
+ exec = defaultExec,
308
+ which,
309
+ post = (url) => fetch(url, { method: 'POST' }),
310
+ } = {},
311
+ ) {
312
+ const id = targetOf(config, { target })
313
+ if (!id) {
314
+ const s = hostsReport(needsOf(root, config)).suggestion
315
+ throw refused(
316
+ `no deploy target: set \`deploy.target\` (\`weft deploy init\` does, and writes the host's files), or pass --target. weft suggests \`${s.host}\`: ${s.why}`,
317
+ )
318
+ }
319
+ const h = host(id)
320
+ if (!h.deploys) throw usage(`\`weft deploy\` doesn't drive ${h.name} yet (research 46 §11)`)
321
+ if (h.kind === 'server' && !prod)
322
+ throw usage(`${h.name} has no preview deploys: pass --prod to deploy production`)
323
+ const needs = needsOf(root, config)
324
+ const findings = fit(needs, h, { deploy: config.deploy ?? {}, local: !env.CI }).findings
325
+ const blocking = findings.filter((f) => f.severity === 'error' && !unsafe?.allows(f))
326
+ if (blocking.length)
327
+ throw refused(
328
+ `${h.name} can't run this app:\n${blocking.map((f) => ` ${f.code} ${f.file}: ${f.why}.\n Fix: ${f.fix}`).join('\n')}\nTo deploy anyway: --unsafe-allow ${[...new Set(blocking.map((f) => f.code))].join(',')}`,
329
+ )
330
+ const steps = deploySteps(root, config, { target: id, prod })
331
+ const envFindings = missingEnv(root, id, config, env)
332
+ const diagnostics = [...findings, ...envFindings, ...cdnFindings(config, { env })].map((f) =>
333
+ toDiagnostic(root, f),
334
+ )
335
+ if (!yes)
336
+ return {
337
+ ok: true,
338
+ dryRun: true,
339
+ target: id,
340
+ prod,
341
+ steps: steps.map((s) => ({ what: s.what, reads: s.reads ?? [] })),
342
+ diagnostics,
343
+ }
344
+ const stop = envFindings.find((f) => f.severity === 'error')
345
+ if (stop) throw refused(`E007: ${stop.why}. ${stop.fix}.`)
346
+ let url
347
+ for (const s of steps) {
348
+ if (s.kind === 'build') {
349
+ const built = await build(root, { preset: s.preset })
350
+ diagnostics.push(...built.diagnostics)
351
+ if (!built.ok) throw refused('weft build failed: nothing was deployed. Fix the errors above.')
352
+ } else if (s.kind === 'hook') {
353
+ const res = await post(env.RENDER_DEPLOY_HOOK_URL)
354
+ if (!res.ok)
355
+ throw refused(
356
+ `Render's deploy hook answered ${res.status}: nothing was deployed. Check RENDER_DEPLOY_HOOK_URL.`,
357
+ )
358
+ url = s.url
359
+ } else {
360
+ const [cmd, ...pre] = cliFor(s.cli, { which })
361
+ const args = [
362
+ ...pre,
363
+ ...s.args,
364
+ ...(s.token && env[s.token] ? ['--token', env[s.token]] : []),
365
+ ]
366
+ const r = await Promise.resolve(exec(cmd, args, { cwd: join(root, s.cwd ?? ''), env }))
367
+ if (r.code !== 0)
368
+ throw refused(
369
+ `${s.cli} failed (exit ${r.code}): nothing more was run.\n${(r.stderr || r.stdout).trim().split('\n').slice(-15).join('\n')}`,
370
+ )
371
+ url = urlFrom(s, r.stdout) ?? url
372
+ }
373
+ }
374
+ return { ok: true, dryRun: false, target: id, prod, url: url ?? null, diagnostics }
375
+ }
376
+
377
+ /** A host CLI with its progress on stderr, as it goes, and stdout kept for the URL. */
378
+ function defaultExec(cmd, args, { cwd, env }) {
379
+ const r = spawnSync(cmd, args, {
380
+ cwd,
381
+ env,
382
+ encoding: 'utf8',
383
+ stdio: ['inherit', 'pipe', 'inherit'],
384
+ maxBuffer: 64 * 1024 * 1024,
385
+ })
386
+ return { code: r.status ?? 1, stdout: r.stdout ?? '', stderr: r.error?.message ?? '' }
387
+ }