@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.
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 +33 -8
  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 +159 -206
  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 +67 -460
  65. package/src/check/runtime.mjs +280 -0
  66. package/src/check.mjs +198 -208
  67. package/src/cli/authoring.mjs +213 -0
  68. package/src/cli/project.mjs +302 -0
  69. package/src/cli.mjs +222 -250
  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 +94 -16
  74. package/src/deploy/files.mjs +100 -21
  75. package/src/deploy/fit.mjs +127 -23
  76. package/src/deploy/hosts.mjs +90 -20
  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 +228 -114
  81. package/src/deploy/needs.mjs +88 -31
  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 -43
  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 +137 -138
  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 +193 -95
  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
package/src/add.mjs CHANGED
@@ -3,33 +3,68 @@
3
3
 
4
4
  import { execFileSync } from 'node:child_process'
5
5
  import { randomBytes } from 'node:crypto'
6
- import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'
7
- import { createRequire } from 'node:module'
8
- import { pathToFileURL } from 'node:url'
6
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
9
7
  import { dirname, join, relative, resolve } from 'node:path'
10
- import ts from 'typescript'
11
8
  import { addRust } from './add-rust.mjs'
12
9
  import { writeAgentsMd } from './agents-md.mjs'
13
- import { findConfig, loadConfig, pluginFactory, pluginName } from './load-config.mjs'
10
+ import { findConfig, loadConfig } from './load-config.mjs'
14
11
  import { sync } from './plugins.mjs'
15
12
  import { cssEntry, packages, viteConfig } from './tailwind.mjs'
16
13
  import { database } from './map.mjs'
17
14
  import { dbLayer } from './db-layer.mjs'
18
15
  import { packageOf, trustStatus } from './trust.mjs'
19
16
 
20
- const usageError = message => Object.assign(new Error(message), { code: 'E_USAGE' })
21
- const refused = message => Object.assign(new Error(message), { code: 'E_ADD' })
17
+ import { usageError, refused } from './add/errors.mjs'
18
+ import { addToTs, addToData, listed, names } from './add/config.mjs'
19
+ import { setupOf, planSetup, applySetup, setupActions, authEdits } from './add/setup.mjs'
22
20
 
23
- const ALIASES = { warp: '@weft-js/warp', icons: '@weft-js/warp-icons', text: '@weft-js/text', media: '@weft-js/plugin-media', 'media-ffmpeg': '@weft-js/plugin-media-ffmpeg', 'surface-keel': '@weft-js/plugin-surface-keel', redis: '@weft-js/realtime-redis', nats: '@weft-js/realtime-nats', pay: '@weft-js/pay', 'pay-stripe': '@weft-js/pay/stripe' }
21
+ const ALIASES = {
22
+ warp: '@weft-js/warp',
23
+ icons: '@weft-js/warp-icons',
24
+ text: '@weft-js/text',
25
+ media: '@weft-js/plugin-media',
26
+ 'media-ffmpeg': '@weft-js/plugin-media-ffmpeg',
27
+ 'surface-keel': '@weft-js/plugin-surface-keel',
28
+ redis: '@weft-js/realtime-redis',
29
+ nats: '@weft-js/realtime-nats',
30
+ pay: '@weft-js/pay',
31
+ 'pay-stripe': '@weft-js/pay/stripe',
32
+ }
24
33
 
25
34
  /** Add-ons `weft add` knows by the name after the scope, as `weft add realtime-redis`. */
26
- const ADDONS = new Set(['@weft-js/text', '@weft-js/pay', '@weft-js/plugin-media', '@weft-js/plugin-media-ffmpeg', '@weft-js/plugin-surface-keel', '@weft-js/multiplayer', '@weft-js/realtime-durable-object', '@weft-js/realtime-livekit', '@weft-js/realtime-nats', '@weft-js/realtime-redis', '@weft-js/realtime-socketio', '@weft-js/realtime-webrtc'])
35
+ const ADDONS = new Set([
36
+ '@weft-js/text',
37
+ '@weft-js/pay',
38
+ '@weft-js/plugin-media',
39
+ '@weft-js/plugin-media-ffmpeg',
40
+ '@weft-js/plugin-surface-keel',
41
+ '@weft-js/multiplayer',
42
+ '@weft-js/realtime-durable-object',
43
+ '@weft-js/realtime-livekit',
44
+ '@weft-js/realtime-nats',
45
+ '@weft-js/realtime-redis',
46
+ '@weft-js/realtime-socketio',
47
+ '@weft-js/realtime-webrtc',
48
+ ])
27
49
 
28
- const INSTALL = { 'pnpm-lock.yaml': ['pnpm', 'add'], 'bun.lock': ['bun', 'add'], 'yarn.lock': ['yarn', 'add'], 'deno.lock': ['deno', 'add'], 'package-lock.json': ['npm', 'install'] }
50
+ const INSTALL = {
51
+ 'pnpm-lock.yaml': ['pnpm', 'add'],
52
+ 'bun.lock': ['bun', 'add'],
53
+ 'yarn.lock': ['yarn', 'add'],
54
+ 'deno.lock': ['deno', 'add'],
55
+ 'package-lock.json': ['npm', 'install'],
56
+ }
29
57
 
30
58
  function installer(root) {
31
- const lock = Object.keys(INSTALL).find(f => existsSync(join(root, f)))
32
- return INSTALL[lock] ?? (process.versions.deno ? INSTALL['deno.lock'] : process.versions.bun ? INSTALL['bun.lock'] : INSTALL['package-lock.json'])
59
+ const lock = Object.keys(INSTALL).find((f) => existsSync(join(root, f)))
60
+ return (
61
+ INSTALL[lock] ??
62
+ (process.versions.deno
63
+ ? INSTALL['deno.lock']
64
+ : process.versions.bun
65
+ ? INSTALL['bun.lock']
66
+ : INSTALL['package-lock.json'])
67
+ )
33
68
  }
34
69
 
35
70
  /** What to install: for an app on local tarballs (`local.mjs`) those go straight in `package.json`, since npm refuses any
@@ -38,111 +73,58 @@ export function pinned(root, specs) {
38
73
  const file = join(root, 'package.json')
39
74
  const pkg = JSON.parse(readFileSync(file, 'utf8'))
40
75
  const local = {}
41
- const rest = specs.filter(spec => {
76
+ const rest = specs.filter((spec) => {
42
77
  const name = spec.replace(/(?<=.)@.*$/, '')
43
78
  const override = pkg.overrides?.[name]
44
79
  if (typeof override !== 'string' || !override.startsWith('file:')) return true
45
80
  local[name] = override
46
81
  return false
47
82
  })
48
- if (Object.keys(local).length) writeFileSync(file, JSON.stringify({ ...pkg, dependencies: { ...pkg.dependencies, ...local } }, null, 2))
83
+ if (Object.keys(local).length)
84
+ writeFileSync(
85
+ file,
86
+ JSON.stringify({ ...pkg, dependencies: { ...pkg.dependencies, ...local } }, null, 2),
87
+ )
49
88
  return rest
50
89
  }
51
90
 
52
91
  function installSpecs(root, manager, verb, specs) {
53
92
  const rest = pinned(root, specs)
54
- execFileSync(manager, [verb, ...(manager === 'deno' ? rest.map(spec => `npm:${spec}`) : rest)], { cwd: root, stdio: ['ignore', 'ignore', 'inherit'], shell: process.platform === 'win32' }) // npm is npm.cmd there
55
- }
56
-
57
- /** The import to write: `<pkg>/plugin` when the package has one, and however it exports its factory. */
58
- function pluginImport(root, pkg) {
59
- const require = createRequire(join(root, 'package.json'))
60
- const name = pluginName(pkg)
61
- for (const spec of [`${pkg}/plugin`, pkg]) {
62
- let path
63
- try {
64
- path = require.resolve(spec)
65
- } catch {
66
- continue
67
- }
68
- const factory = pluginFactory(createRequire(import.meta.url)(path), pkg)
69
- if (!factory) throw refused(`${spec} exports no plugin factory to call, so nothing was changed.`)
70
- return { name: factory.name, line: `import ${factory.named ? `{ ${factory.name} }` : factory.name} from '${spec}'` }
71
- }
72
- return { name, line: `import ${name} from '${pkg}'` } // not installed yet: only a dry run gets this far
73
- }
74
-
75
- /** The object literal `export default` gives, directly or through `defineConfig(…)`. */
76
- function configObject(sf) {
77
- for (const st of sf.statements) {
78
- if (!ts.isExportAssignment(st)) continue
79
- const value = ts.isCallExpression(st.expression) ? st.expression.arguments[0] : st.expression
80
- if (value && ts.isObjectLiteralExpression(value)) return value
81
- }
82
- return null
93
+ execFileSync(
94
+ manager,
95
+ [verb, ...(manager === 'deno' ? rest.map((spec) => `npm:${spec}`) : rest)],
96
+ { cwd: root, stdio: ['ignore', 'ignore', 'inherit'], shell: process.platform === 'win32' },
97
+ ) // npm is npm.cmd there
83
98
  }
84
99
 
85
- function addToTs(root, file, text, pkg) {
86
- const sf = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true)
87
- const object = configObject(sf)
88
- if (!object) throw refused(`${file} doesn't export a plain object, so \`weft add\` can't edit it. Add \`${pkg}\` to \`plugins\` by hand.`)
89
- const { name, line } = pluginImport(root, pkg)
90
- const prop = object.properties.find(p => p.name?.getText(sf) === 'plugins')
91
- if (prop && !(ts.isPropertyAssignment(prop) && ts.isArrayLiteralExpression(prop.initializer))) throw refused(`\`plugins\` in ${file} isn't a plain array, so \`weft add\` can't edit it. Add \`${pkg}\` by hand.`)
92
- const edits = []
93
- const imports = sf.statements.filter(ts.isImportDeclaration)
94
- const after = imports.at(-1)?.end ?? 0
95
- edits.push([after, `${imports.length ? '\n' : ''}${line}${imports.length ? '' : '\n'}`])
96
- if (prop) {
97
- const list = prop.initializer
98
- const last = list.elements.at(-1)
99
- edits.push(last ? [last.end, `, ${name}()`] : [list.getStart(sf) + 1, `${name}()`])
100
- } else {
101
- const last = object.properties.at(-1)
102
- edits.push(last ? [object.properties.end, `${object.properties.hasTrailingComma ? '' : ','}\n plugins: [${name}()],`] : [object.getStart(sf) + 1, `\n plugins: [${name}()],\n`])
103
- }
104
- return edits.toSorted((a, b) => b[0] - a[0]).reduce((t, [at, insert]) => t.slice(0, at) + insert + t.slice(at), text)
105
- }
106
-
107
- function addToData(file, text, pkg) {
108
- if (file.endsWith('.json')) {
109
- const data = JSON.parse(text)
110
- data.plugins = [...(data.plugins ?? []), pkg]
111
- return `${JSON.stringify(data, null, 2)}\n`
112
- }
113
- const { parseDocument } = createRequire(import.meta.url)('yaml')
114
- const doc = parseDocument(text)
115
- if (doc.has('plugins')) doc.addIn(['plugins'], pkg)
116
- else doc.set('plugins', [pkg])
117
- return doc.toString()
118
- }
119
-
120
- const listed = (config, pkg) => (config.plugins ?? []).some(p => p.name === pkg)
121
-
122
- /** The config names the package already (imported or listed), which a read can tell without loading it. */
123
- const names = (text, pkg) => new RegExp(`['"]${pkg.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&')}(/[\\w-]+)?['"]`).test(text)
124
-
125
100
  const VITE = ['vite.config.ts', 'vite.config.mts', 'vite.config.js', 'vite.config.mjs']
126
101
 
127
102
  /** The app's Vite config with Tailwind in it. Stops rather than guess at one it can't place the plugin in. */
128
103
  function viteWithTailwind(file, text) {
129
104
  if (text.includes('@tailwindcss/vite')) return text
130
- if (!/plugins:\s*\[/.test(text)) throw refused(`${file} has no \`plugins: [\` to add Tailwind to. Add \`tailwindcss()\` from @tailwindcss/vite there, then run this again. Nothing was changed.`)
105
+ if (!/plugins:\s*\[/.test(text))
106
+ throw refused(
107
+ `${file} has no \`plugins: [\` to add Tailwind to. Add \`tailwindcss()\` from @tailwindcss/vite there, then run this again. Nothing was changed.`,
108
+ )
131
109
  return `import tailwindcss from '@tailwindcss/vite'\n${text.replace(/plugins:\s*\[/, '$&tailwindcss(), ')}`
132
110
  }
133
111
 
134
112
  /** Tailwind's packages, the Vite plugin, and the top of src/app.css, imported first by the root layout. */
135
113
  function addTailwind(root, { dryRun, install }) {
136
- if (!existsSync(join(root, 'package.json'))) throw refused('`weft add tailwind` needs a package.json. Nothing was changed.')
114
+ if (!existsSync(join(root, 'package.json')))
115
+ throw refused('`weft add tailwind` needs a package.json. Nothing was changed.')
137
116
  const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'))
138
117
  const cssFile = join(root, 'src/app.css')
139
118
  const css = existsSync(cssFile) ? readFileSync(cssFile, 'utf8') : ''
140
- const viteName = VITE.find(f => existsSync(join(root, f)))
141
- const vite = viteName ? viteWithTailwind(viteName, readFileSync(join(root, viteName), 'utf8')) : viteConfig
119
+ const viteName = VITE.find((f) => existsSync(join(root, f)))
120
+ const vite = viteName
121
+ ? viteWithTailwind(viteName, readFileSync(join(root, viteName), 'utf8'))
122
+ : viteConfig
142
123
  const layoutFile = join(root, 'src/routes/_layout.tsx')
143
124
  const layout = existsSync(layoutFile) ? readFileSync(layoutFile, 'utf8') : null
144
125
  const has = /['"]tailwindcss['"]|tailwindcss\/utilities/.test(css)
145
- if (has && pkg.dependencies?.tailwindcss) return { ok: true, already: true, package: 'tailwindcss', actions: [] }
126
+ if (has && pkg.dependencies?.tailwindcss)
127
+ return { ok: true, already: true, package: 'tailwindcss', actions: [] }
146
128
 
147
129
  const actions = []
148
130
  const [manager, verb] = installer(root)
@@ -152,9 +134,20 @@ function addTailwind(root, { dryRun, install }) {
152
134
  if (!dryRun) installSpecs(root, manager, verb, specs)
153
135
  }
154
136
  const warp = '@weft-js/warp' in { ...pkg.dependencies, ...pkg.devDependencies }
155
- const edits = [[join(root, viteName ?? 'vite.config.ts'), vite], ...(has ? [] : [[cssFile, `${cssEntry(warp)}${css && '\n'}${css}`]])]
156
- actions.push(`add Tailwind to ${viteName ?? 'vite.config.ts'}`, ...(has ? [] : [`add Tailwind to src/app.css${warp ? ' (without its reset, which would override warp)' : ''}`]))
157
- if (layout === null) actions.push('import src/app.css from your root layout: there is no src/routes/_layout.tsx')
137
+ const edits = [
138
+ [join(root, viteName ?? 'vite.config.ts'), vite],
139
+ ...(has ? [] : [[cssFile, `${cssEntry(warp)}${css && '\n'}${css}`]]),
140
+ ]
141
+ actions.push(
142
+ `add Tailwind to ${viteName ?? 'vite.config.ts'}`,
143
+ ...(has
144
+ ? []
145
+ : [
146
+ `add Tailwind to src/app.css${warp ? ' (without its reset, which would override warp)' : ''}`,
147
+ ]),
148
+ )
149
+ if (layout === null)
150
+ actions.push('import src/app.css from your root layout: there is no src/routes/_layout.tsx')
158
151
  else if (!/['"][^'"]*app\.css['"]/.test(layout)) {
159
152
  edits.push([layoutFile, `import '../app.css'\n${layout}`])
160
153
  actions.push('import src/app.css first in src/routes/_layout.tsx')
@@ -170,17 +163,27 @@ const LAYER_PLUGIN = { prisma: '@weft-js/db/plugin-prisma', native: '@weft-js/db
170
163
 
171
164
  /** A database on an app that has none: the wiring a scaffold writes, with no todo app. Never touches what's there. */
172
165
  async function addDb(root, engine = 'sqlite', { layer = 'drizzle', dryRun, install }) {
173
- if (!ENGINES.includes(engine)) throw usageError(`weft add db takes ${ENGINES.join(', ')}, not \`${engine}\``)
174
- if (!['drizzle', ...Object.keys(LAYER_PLUGIN)].includes(layer)) throw usageError(`--layer takes drizzle, prisma or native, not \`${layer}\``)
175
- if (await dbLayer(root)) throw refused('This app already has a database. Changing engine or layer changes your queries, so it\'s yours to do. Nothing was changed.')
166
+ if (!ENGINES.includes(engine))
167
+ throw usageError(`weft add db takes ${ENGINES.join(', ')}, not \`${engine}\``)
168
+ if (!['drizzle', ...Object.keys(LAYER_PLUGIN)].includes(layer))
169
+ throw usageError(`--layer takes drizzle, prisma or native, not \`${layer}\``)
170
+ if (await dbLayer(root))
171
+ throw refused(
172
+ "This app already has a database. Changing engine or layer changes your queries, so it's yours to do. Nothing was changed.",
173
+ )
176
174
  const { stackDeps, stackFiles } = await import('@weft-js/create/stack')
177
175
  const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'))
178
176
  const name = String(pkg.name ?? 'app').replace(/^@.*\//, '')
179
177
  const files = stackFiles({ db: engine, layer, auth: false, name, secret: '' })
180
178
  delete files['src/routes/todos/index.tsx']
181
179
  delete files['src/server/todos.ts']
182
- const taken = Object.keys(files).filter(f => f !== '.env' && f !== '.env.example' && existsSync(join(root, f)))
183
- if (taken.length) throw refused(`${taken.join(', ')} already exist, so this can't add a database without overwriting them. Nothing was changed.`)
180
+ const taken = Object.keys(files).filter(
181
+ (f) => f !== '.env' && f !== '.env.example' && existsSync(join(root, f)),
182
+ )
183
+ if (taken.length)
184
+ throw refused(
185
+ `${taken.join(', ')} already exist, so this can't add a database without overwriting them. Nothing was changed.`,
186
+ )
184
187
 
185
188
  const { deps, dev } = stackDeps({ db: engine, layer, auth: false })
186
189
  const actions = []
@@ -190,15 +193,17 @@ async function addDb(root, engine = 'sqlite', { layer = 'drizzle', dryRun, insta
190
193
  actions.push(`install ${specs.join(' ')} with ${manager}`)
191
194
  if (!dryRun) installSpecs(root, manager, verb, specs)
192
195
  }
193
- actions.push(...Object.keys(files).map(f => `write ${f}`))
196
+ actions.push(...Object.keys(files).map((f) => `write ${f}`))
194
197
  if (dryRun) return { ok: true, package: `${layer} on ${engine}`, actions, dryRun: true }
195
198
  for (const [file, text] of Object.entries(files)) {
196
199
  const path = join(root, file)
197
200
  mkdirSync(dirname(path), { recursive: true })
198
- if (file.startsWith('.env') && existsSync(path)) writeFileSync(path, `${readFileSync(path, 'utf8').trimEnd()}\n${text}`)
201
+ if (file.startsWith('.env') && existsSync(path))
202
+ writeFileSync(path, `${readFileSync(path, 'utf8').trimEnd()}\n${text}`)
199
203
  else writeFileSync(path, text)
200
204
  }
201
- if (LAYER_PLUGIN[layer]) actions.push(...(await addCommand(root, LAYER_PLUGIN[layer], { install: false })).actions)
205
+ if (LAYER_PLUGIN[layer])
206
+ actions.push(...(await addCommand(root, LAYER_PLUGIN[layer], { install: false })).actions)
202
207
  actions.push('next: `weft db generate`')
203
208
  return { ok: true, package: `${layer} on ${engine}`, actions }
204
209
  }
@@ -206,117 +211,109 @@ async function addDb(root, engine = 'sqlite', { layer = 'drizzle', dryRun, insta
206
211
  /** Better Auth on an app with a database. Its tables go beside yours, and your own routes stay as they are. */
207
212
  async function addAuth(root, { dryRun, install }) {
208
213
  const layer = await dbLayer(root)
209
- if (!layer) throw refused('Better Auth keeps users in a database: run `weft add db` first. Nothing was changed.')
210
- if (existsSync(join(root, 'src/auth.server.ts'))) throw refused('src/auth.server.ts already exists, so this app has an auth provider. Nothing was changed.')
214
+ if (!layer)
215
+ throw refused(
216
+ 'Better Auth keeps users in a database: run `weft add db` first. Nothing was changed.',
217
+ )
218
+ if (existsSync(join(root, 'src/auth.server.ts')))
219
+ throw refused(
220
+ 'src/auth.server.ts already exists, so this app has an auth provider. Nothing was changed.',
221
+ )
211
222
  const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'))
212
223
  const db = database(root, layer).engine
213
224
  const ui = '@weft-js/warp' in { ...pkg.dependencies, ...pkg.devDependencies } ? 'warp' : 'none'
214
225
  const { authFiles, stackDeps } = await import('@weft-js/create/stack')
215
226
  const { files, context, tables } = authFiles({ db, layer: layer.layer, ui })
216
227
  const contextFile = join(root, 'src/context.server.ts')
217
- if (existsSync(contextFile) && !/export default \(\) => \(\{ db \}\)/.test(readFileSync(contextFile, 'utf8'))) throw refused('src/context.server.ts has been changed. Spread `session(auth, request)` from @weft-js/start/auth into it, then run this again. Nothing was changed.')
218
- const taken = Object.keys(files).filter(f => existsSync(join(root, f)))
228
+ if (
229
+ existsSync(contextFile) &&
230
+ !/export default \(\) => \(\{ db \}\)/.test(readFileSync(contextFile, 'utf8'))
231
+ )
232
+ throw refused(
233
+ 'src/context.server.ts has been changed. Spread `session(auth, request)` from @weft-js/start/auth into it, then run this again. Nothing was changed.',
234
+ )
235
+ const taken = Object.keys(files).filter((f) => existsSync(join(root, f)))
219
236
  if (taken.length) throw refused(`${taken.join(', ')} already exist. Nothing was changed.`)
220
237
  const envModule = join(root, 'src/env.server.ts')
221
238
  const envText = existsSync(envModule) ? readFileSync(envModule, 'utf8') : ''
222
- if (!envText.includes('BETTER_AUTH_SECRET') && !/server: \{\n/.test(envText)) throw refused('src/env.server.ts has no `server: {` block to add BETTER_AUTH_SECRET to. Nothing was changed.')
239
+ if (!envText.includes('BETTER_AUTH_SECRET') && !/server: \{\n/.test(envText))
240
+ throw refused(
241
+ 'src/env.server.ts has no `server: {` block to add BETTER_AUTH_SECRET to. Nothing was changed.',
242
+ )
223
243
 
224
244
  const actions = []
225
245
  const [manager, verb] = installer(root)
226
246
  const wanted = stackDeps({ db, layer: layer.layer, auth: true }).deps
227
- const specs = Object.entries(wanted).filter(([name]) => !(name in (pkg.dependencies ?? {}))).map(([n, v]) => `${n}@${v}`)
247
+ const specs = Object.entries(wanted)
248
+ .filter(([name]) => !(name in (pkg.dependencies ?? {})))
249
+ .map(([n, v]) => `${n}@${v}`)
228
250
  if (install && specs.length) {
229
251
  actions.push(`install ${specs.join(' ')} with ${manager}`)
230
252
  if (!dryRun) installSpecs(root, manager, verb, specs)
231
253
  }
232
- const edits = { ...files, 'src/context.server.ts': context }
233
- if (!envText.includes('BETTER_AUTH_SECRET')) edits['src/env.server.ts'] = envText.replace('server: {\n', 'server: {\n BETTER_AUTH_SECRET: z.string().min(32),\n')
234
- if (layer.layer === 'drizzle') {
235
- edits['src/db/auth.schema.ts'] = tables
236
- edits['src/db/schema.ts'] = `${readFileSync(join(root, 'src/db/schema.ts'), 'utf8').trimEnd()}\n\nexport * from './auth.schema'\n`
237
- } else if (layer.layer === 'prisma') {
238
- edits['prisma/schema.prisma'] = `${readFileSync(join(root, 'prisma/schema.prisma'), 'utf8').trimEnd()}\n${tables}`
239
- } else {
240
- const next = existsSync(join(root, 'migrations')) ? readdirSync(join(root, 'migrations')).filter(f => f.endsWith('.sql')).length : 0
241
- edits[`migrations/${String(next).padStart(4, '0')}_auth.sql`] = tables
242
- }
243
- actions.push(...Object.keys(edits).map(f => `write ${f}`))
254
+ const edits = authEdits({ root, files, context, tables, envText, layer })
255
+ actions.push(...Object.keys(edits).map((f) => `write ${f}`))
244
256
  if (!dryRun) {
245
257
  for (const [file, text] of Object.entries(edits)) {
246
258
  mkdirSync(dirname(join(root, file)), { recursive: true })
247
259
  writeFileSync(join(root, file), text)
248
260
  }
249
261
  const env = join(root, '.env')
250
- if (!(existsSync(env) && readFileSync(env, 'utf8').includes('BETTER_AUTH_SECRET'))) writeFileSync(env, `${existsSync(env) ? `${readFileSync(env, 'utf8').trimEnd()}\n` : ''}BETTER_AUTH_SECRET=${randomBytes(32).toString('base64')}\nBETTER_AUTH_URL=http://localhost:5173\n`)
262
+ if (!(existsSync(env) && readFileSync(env, 'utf8').includes('BETTER_AUTH_SECRET')))
263
+ writeFileSync(
264
+ env,
265
+ `${existsSync(env) ? `${readFileSync(env, 'utf8').trimEnd()}\n` : ''}BETTER_AUTH_SECRET=${randomBytes(32).toString('base64')}\nBETTER_AUTH_URL=http://localhost:5173\n`,
266
+ )
251
267
  }
252
- actions.push('add BETTER_AUTH_SECRET to .env', ...(layer.schema ? ['next: `weft db generate`'] : []), 'next: reload the app: the migration applies on the next request', "next: add `auth: 'user'` to what needs a user, and link to /login")
253
- return { ok: true, package: `Better Auth on ${layer.layer}`, actions, ...(dryRun && { dryRun: true }) }
254
- }
255
-
256
- /** The plugin's `setup`, once the package is installed: a plain import, since the package can't run anything by being read. */
257
- async function setupOf(root, pkg) {
258
- let path
259
- try {
260
- path = createRequire(join(root, 'package.json')).resolve(`${pkg}/plugin`)
261
- } catch {
262
- return undefined // no plugin entry, or a dry run before the install
268
+ actions.push(
269
+ 'add BETTER_AUTH_SECRET to .env',
270
+ ...(layer.schema ? ['next: `weft db generate`'] : []),
271
+ 'next: reload the app: the migration applies on the next request',
272
+ "next: add `auth: 'user'` to what needs a user, and link to /login",
273
+ )
274
+ return {
275
+ ok: true,
276
+ package: `Better Auth on ${layer.layer}`,
277
+ actions,
278
+ ...(dryRun && { dryRun: true }),
263
279
  }
264
- const mod = await import(pathToFileURL(path).href)
265
- const make = mod.default ?? Object.values(mod).find(v => typeof v === 'function')
266
- return make?.().contribute({ command: 'check', mode: 'development', root }).setup
267
280
  }
268
281
 
269
- /** What `setup` wants, checked in full before anything is written, so a refusal changes nothing. */
270
- async function planSetup(root, setup) {
271
- const layer = await dbLayer(root)
272
- if (setup.needs?.includes('db') && !layer) throw refused('This needs a database: run `weft add db` first. Nothing was changed.')
273
- if (setup.needs?.includes('auth') && !existsSync(join(root, 'src/auth.server.ts'))) throw refused('This needs users: run `weft add auth` first. Nothing was changed.')
274
- const read = rel => (existsSync(join(root, rel)) ? readFileSync(join(root, rel), 'utf8') : undefined)
275
- let plan
276
- try {
277
- plan = setup.run({ engine: layer ? database(root, layer).engine : null, layer: layer?.layer ?? null, read })
278
- } catch (error) {
279
- throw refused(`${error.message}. Nothing was changed.`)
280
- }
281
- const taken = Object.keys(plan.files ?? {}).filter(f => existsSync(join(root, f)))
282
- if (taken.length) throw refused(`${taken.join(', ')} already exist. Nothing was changed.`)
283
- return plan
284
- }
285
-
286
- function applySetup(root, plan) {
287
- for (const [file, text] of Object.entries({ ...plan.files, ...plan.edits })) {
288
- mkdirSync(dirname(join(root, file)), { recursive: true })
289
- writeFileSync(join(root, file), text)
290
- }
291
- for (const name of ['.env', '.env.example']) {
292
- const file = join(root, name)
293
- const had = existsSync(file) ? readFileSync(file, 'utf8') : ''
294
- const lines = Object.entries(plan.env ?? {}).filter(([k]) => !had.includes(`${k}=`)).map(([k, v]) => `${k}=${v}\n`)
295
- if (lines.length) writeFileSync(file, `${had && `${had.trimEnd()}\n`}${lines.join('')}`)
296
- }
297
- }
298
-
299
- const setupActions = plan => [...Object.keys({ ...plan.files, ...plan.edits }).map(f => `write ${f}`), ...(plan.env ? [`add ${Object.keys(plan.env).join(', ')} to .env and .env.example`] : []), ...(plan.next ?? []).map(n => `next: ${n}`)]
300
-
301
- export async function addCommand(root, feature, { dryRun = false, install = true, engine, layer, remote = false } = {}) {
282
+ export async function addCommand(
283
+ root,
284
+ feature,
285
+ { dryRun = false, install = true, engine, layer, remote = false } = {},
286
+ ) {
302
287
  root = resolve(root)
303
- if (!feature) throw usageError(`weft add needs a feature: db, auth, tailwind, rust, ${Object.keys(ALIASES).join(', ')}, or a package name`)
288
+ if (!feature)
289
+ throw usageError(
290
+ `weft add needs a feature: db, auth, tailwind, rust, ${Object.keys(ALIASES).join(', ')}, or a package name`,
291
+ )
304
292
  if (feature === 'rust') {
305
293
  const [manager, verb] = installer(root)
306
- return addRust(root, engine, { remote, dryRun, install, installSpecs: specs => installSpecs(root, manager, verb, specs) })
294
+ return addRust(root, engine, {
295
+ remote,
296
+ dryRun,
297
+ install,
298
+ installSpecs: (specs) => installSpecs(root, manager, verb, specs),
299
+ })
307
300
  }
308
301
  if (feature === 'tailwind') return addTailwind(root, { dryRun, install })
309
302
  if (feature === 'db') return addDb(root, engine, { layer, dryRun, install })
310
303
  if (feature === 'auth') return addAuth(root, { dryRun, install })
311
- const pkg = ALIASES[feature] ?? (ADDONS.has(`@weft-js/${feature}`) ? `@weft-js/${feature}` : feature)
304
+ const pkg =
305
+ ALIASES[feature] ?? (ADDONS.has(`@weft-js/${feature}`) ? `@weft-js/${feature}` : feature)
312
306
  const file = findConfig(root) ?? join(root, 'weft.config.ts')
313
307
  const config = relative(root, file)
314
- const text = existsSync(file) ? readFileSync(file, 'utf8') : "import { defineConfig } from '@weft-js/core/config'\n\nexport default defineConfig({})\n"
308
+ const text = existsSync(file)
309
+ ? readFileSync(file, 'utf8')
310
+ : "import { defineConfig } from '@weft-js/core/config'\n\nexport default defineConfig({})\n"
315
311
  const named = names(text, pkg)
316
312
  // A config can name a package that isn't installed yet, and importing it then would leave this process's module
317
313
  // resolver sure the package has no `exports`, so the load after the install would fail too. Load it once it's there.
318
314
  const present = existsSync(join(root, 'node_modules', packageOf(pkg), 'package.json'))
319
- if (present && (named || listed((await loadConfig(root)).config, pkg))) return { ok: true, already: true, package: pkg, config, actions: [] }
315
+ if (present && (named || listed((await loadConfig(root)).config, pkg)))
316
+ return { ok: true, already: true, package: pkg, config, actions: [] }
320
317
 
321
318
  const home = packageOf(pkg)
322
319
 
@@ -327,32 +324,63 @@ export async function addCommand(root, feature, { dryRun = false, install = true
327
324
  if (!dryRun) installSpecs(root, manager, verb, [home])
328
325
  }
329
326
  const setup = await setupOf(root, pkg)
330
- const plan = setup && await planSetup(root, setup)
331
- const next = named ? text : /\.(json|ya?ml)$/.test(file) ? addToData(file, text, pkg) : addToTs(root, file, text, pkg)
327
+ const plan = setup && (await planSetup(root, setup))
328
+ const next = named
329
+ ? text
330
+ : /\.(json|ya?ml)$/.test(file)
331
+ ? addToData(file, text, pkg)
332
+ : addToTs(root, file, text, pkg)
332
333
  if (!named) actions.push(`add ${pkg} to ${config}`)
333
- if (dryRun) return { ok: true, package: pkg, config, actions: [...actions, ...(plan ? setupActions(plan) : [])], dryRun: true }
334
+ if (dryRun)
335
+ return {
336
+ ok: true,
337
+ package: pkg,
338
+ config,
339
+ actions: [...actions, ...(plan ? setupActions(plan) : [])],
340
+ dryRun: true,
341
+ }
342
+
343
+ return finishAdd({ root, file, next, text, config, pkg, install, actions, plan, setup })
344
+ }
334
345
 
346
+ async function finishAdd({ root, file, next, text, config, pkg, install, actions, plan, setup }) {
335
347
  writeFileSync(file, next)
336
348
  const after = await loadConfig(root)
337
- const failed = after.diagnostics.filter(d => d.severity === 'error')
349
+ const failed = after.diagnostics.filter((d) => d.severity === 'error')
338
350
  if (failed.length) {
339
351
  writeFileSync(file, text)
340
- throw refused(`${config} doesn't load with ${pkg} in it${install ? ', though the package installed' : ''}, so it's back as it was: ${failed[0].code} ${failed[0].why}`)
352
+ throw refused(
353
+ `${config} doesn't load with ${pkg} in it${install ? ', though the package installed' : ''}, so it's back as it was: ${failed[0].code} ${failed[0].why}`,
354
+ )
341
355
  }
342
356
  sync(root, after)
343
357
  actions.push('regenerate .weft/slots')
344
358
  if (plan) {
345
- for (const other of setup.with ?? []) actions.push(...(await addCommand(root, other, { install })).actions)
359
+ for (const other of setup.with ?? [])
360
+ actions.push(...(await addCommand(root, other, { install })).actions)
346
361
  applySetup(root, plan)
347
362
  actions.push(...setupActions(plan))
348
363
  }
349
364
  // Never approved here: installing a plugin isn't having read what it asks for.
350
- const trust = trustStatus(root, after).plugins.find(p => p.name === pkg)
351
- if (existsSync(join(root, 'AGENTS.md'))) {
365
+ const trust = trustStatus(root, after).plugins.find((p) => p.name === pkg)
366
+ // Only the generated file: a scaffold's short one is the app's own until `weft agents-md` is asked for.
367
+ if (
368
+ existsSync(join(root, 'AGENTS.md')) &&
369
+ readFileSync(join(root, 'AGENTS.md'), 'utf8').includes('Generated by `weft agents-md`')
370
+ ) {
352
371
  await writeAgentsMd(root)
353
372
  actions.push('regenerate AGENTS.md')
354
373
  }
355
- actions.push(...(after.contributions?.find(c => c.name === pkg)?.manifest?.notes ?? []))
356
- if (trust?.blocked) actions.push(`${pkg} asks for ${[...trust.added, ...trust.hidden.map(n => `${n} (undeclared)`)].join(', ') || 'a code change'}; \`weft check\` stops until you run \`weft plugins approve ${pkg}\``)
357
- return { ok: true, package: pkg, config, actions, ...(trust?.blocked && { approve: { added: trust.added, undeclared: trust.hidden } }) }
374
+ actions.push(...(after.contributions?.find((c) => c.name === pkg)?.manifest?.notes ?? []))
375
+ if (trust?.blocked)
376
+ actions.push(
377
+ `${pkg} asks for ${[...trust.added, ...trust.hidden.map((n) => `${n} (undeclared)`)].join(', ') || 'a code change'}; \`weft check\` stops until you run \`weft plugins approve ${pkg}\``,
378
+ )
379
+ return {
380
+ ok: true,
381
+ package: pkg,
382
+ config,
383
+ actions,
384
+ ...(trust?.blocked && { approve: { added: trust.added, undeclared: trust.hidden } }),
385
+ }
358
386
  }