@weft-js/cli 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/LICENSE +16 -0
  2. package/README.md +74 -2
  3. package/bin/weft.mjs +8 -0
  4. package/docs/async.md +40 -0
  5. package/docs/browser.md +39 -0
  6. package/docs/config.md +93 -0
  7. package/docs/deploy.md +83 -0
  8. package/docs/design.md +97 -0
  9. package/docs/fonts.md +70 -0
  10. package/docs/icons.md +102 -0
  11. package/docs/lists.md +23 -0
  12. package/docs/meta.md +82 -0
  13. package/docs/plugins.md +143 -0
  14. package/docs/props.md +77 -0
  15. package/docs/rules.md +32 -0
  16. package/docs/rust.md +223 -0
  17. package/docs/start-short.md +8 -0
  18. package/docs/start.md +422 -0
  19. package/docs/state.md +96 -0
  20. package/docs/style.md +59 -0
  21. package/docs/surface.md +111 -0
  22. package/docs/testing.md +71 -0
  23. package/docs/ui-state.md +92 -0
  24. package/docs/verify.md +48 -0
  25. package/package.json +39 -4
  26. package/src/add-rust.mjs +123 -0
  27. package/src/add.mjs +358 -0
  28. package/src/agents-md.mjs +134 -0
  29. package/src/alias.mjs +59 -0
  30. package/src/audit.mjs +366 -0
  31. package/src/browser/cli.mjs +34 -0
  32. package/src/browser/client.mjs +132 -0
  33. package/src/browser/commands.mjs +143 -0
  34. package/src/catalog.mjs +496 -0
  35. package/src/check.mjs +330 -0
  36. package/src/cli.mjs +364 -0
  37. package/src/config.d.ts +1 -0
  38. package/src/config.mjs +5 -0
  39. package/src/contributions.mjs +69 -0
  40. package/src/db-layer.mjs +33 -0
  41. package/src/deploy/cdn.mjs +64 -0
  42. package/src/deploy/files.mjs +187 -0
  43. package/src/deploy/fit.mjs +62 -0
  44. package/src/deploy/hosts.mjs +56 -0
  45. package/src/deploy/index.mjs +273 -0
  46. package/src/deploy/needs.mjs +92 -0
  47. package/src/design-checks.mjs +439 -0
  48. package/src/diag.mjs +74 -0
  49. package/src/docs.mjs +132 -0
  50. package/src/doctor.mjs +30 -0
  51. package/src/files.mjs +47 -0
  52. package/src/fix.mjs +40 -0
  53. package/src/gate.mjs +276 -0
  54. package/src/index.mjs +9 -0
  55. package/src/infer/engine.cjs +525 -0
  56. package/src/infer/facts.cjs +252 -0
  57. package/src/infer/hosts.cjs +114 -0
  58. package/src/infer/lsp.mjs +131 -0
  59. package/src/infer/session.cjs +105 -0
  60. package/src/infer/shadow.cjs +46 -0
  61. package/src/infer/worker.cjs +97 -0
  62. package/src/jsx-scan.mjs +86 -0
  63. package/src/load-config.mjs +303 -0
  64. package/src/map.mjs +110 -0
  65. package/src/mcp.mjs +195 -0
  66. package/src/meta-table.mjs +28 -0
  67. package/src/mutate.mjs +140 -0
  68. package/src/plugin-checks.mjs +89 -0
  69. package/src/plugins.mjs +172 -0
  70. package/src/project.mjs +219 -0
  71. package/src/props-checks.mjs +46 -0
  72. package/src/props-listener.mjs +38 -0
  73. package/src/react-checks.mjs +36 -0
  74. package/src/share-checks.mjs +74 -0
  75. package/src/site.mjs +19 -0
  76. package/src/slots.mjs +96 -0
  77. package/src/start.mjs +296 -0
  78. package/src/state-checks.mjs +213 -0
  79. package/src/style.mjs +320 -0
  80. package/src/surface-checks.mjs +218 -0
  81. package/src/tailwind.mjs +22 -0
  82. package/src/tasks.mjs +99 -0
  83. package/src/testdiff.mjs +157 -0
  84. package/src/tests.mjs +572 -0
  85. package/src/text-checks.mjs +172 -0
  86. package/src/tokens.mjs +24 -0
  87. package/src/trust.mjs +282 -0
  88. package/src/weft.mjs +66 -0
  89. package/src/workspace.mjs +105 -0
  90. package/ts-plugin/index.js +137 -0
  91. package/ts-plugin/package.json +1 -0
package/src/add.mjs ADDED
@@ -0,0 +1,358 @@
1
+ // `weft add`: what the wizard offers, on an app that already exists (research 34 §7). It installs a plugin and adds it to
2
+ // the config, whichever format that is. It only ever inserts, and stops rather than guess at a config it can't read.
3
+
4
+ import { execFileSync } from 'node:child_process'
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'
9
+ import { dirname, join, relative, resolve } from 'node:path'
10
+ import ts from 'typescript'
11
+ import { addRust } from './add-rust.mjs'
12
+ import { writeAgentsMd } from './agents-md.mjs'
13
+ import { findConfig, loadConfig, pluginFactory, pluginName } from './load-config.mjs'
14
+ import { sync } from './plugins.mjs'
15
+ import { cssEntry, packages, viteConfig } from './tailwind.mjs'
16
+ import { database } from './map.mjs'
17
+ import { dbLayer } from './db-layer.mjs'
18
+ import { packageOf, trustStatus } from './trust.mjs'
19
+
20
+ const usageError = message => Object.assign(new Error(message), { code: 'E_USAGE' })
21
+ const refused = message => Object.assign(new Error(message), { code: 'E_ADD' })
22
+
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' }
24
+
25
+ /** 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'])
27
+
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'] }
29
+
30
+ 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'])
33
+ }
34
+
35
+ /** What to install: for an app on local tarballs (`local.mjs`) those go straight in `package.json`, since npm refuses any
36
+ * spec for them that isn't spelled exactly as their override is. */
37
+ export function pinned(root, specs) {
38
+ const file = join(root, 'package.json')
39
+ const pkg = JSON.parse(readFileSync(file, 'utf8'))
40
+ const local = {}
41
+ const rest = specs.filter(spec => {
42
+ const name = spec.replace(/(?<=.)@.*$/, '')
43
+ const override = pkg.overrides?.[name]
44
+ if (typeof override !== 'string' || !override.startsWith('file:')) return true
45
+ local[name] = override
46
+ return false
47
+ })
48
+ if (Object.keys(local).length) writeFileSync(file, JSON.stringify({ ...pkg, dependencies: { ...pkg.dependencies, ...local } }, null, 2))
49
+ return rest
50
+ }
51
+
52
+ function installSpecs(root, manager, verb, specs) {
53
+ 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
83
+ }
84
+
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
+ const VITE = ['vite.config.ts', 'vite.config.mts', 'vite.config.js', 'vite.config.mjs']
126
+
127
+ /** The app's Vite config with Tailwind in it. Stops rather than guess at one it can't place the plugin in. */
128
+ function viteWithTailwind(file, text) {
129
+ 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.`)
131
+ return `import tailwindcss from '@tailwindcss/vite'\n${text.replace(/plugins:\s*\[/, '$&tailwindcss(), ')}`
132
+ }
133
+
134
+ /** Tailwind's packages, the Vite plugin, and the top of src/app.css, imported first by the root layout. */
135
+ function addTailwind(root, { dryRun, install }) {
136
+ if (!existsSync(join(root, 'package.json'))) throw refused('`weft add tailwind` needs a package.json. Nothing was changed.')
137
+ const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'))
138
+ const cssFile = join(root, 'src/app.css')
139
+ 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
142
+ const layoutFile = join(root, 'src/routes/_layout.tsx')
143
+ const layout = existsSync(layoutFile) ? readFileSync(layoutFile, 'utf8') : null
144
+ const has = /['"]tailwindcss['"]|tailwindcss\/utilities/.test(css)
145
+ if (has && pkg.dependencies?.tailwindcss) return { ok: true, already: true, package: 'tailwindcss', actions: [] }
146
+
147
+ const actions = []
148
+ const [manager, verb] = installer(root)
149
+ const specs = Object.entries(packages).map(([name, version]) => `${name}@${version}`)
150
+ if (install) {
151
+ actions.push(`install ${specs.join(' ')} with ${manager}`)
152
+ if (!dryRun) installSpecs(root, manager, verb, specs)
153
+ }
154
+ 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')
158
+ else if (!/['"][^'"]*app\.css['"]/.test(layout)) {
159
+ edits.push([layoutFile, `import '../app.css'\n${layout}`])
160
+ actions.push('import src/app.css first in src/routes/_layout.tsx')
161
+ }
162
+ if (dryRun) return { ok: true, package: 'tailwindcss', actions, dryRun: true }
163
+ mkdirSync(join(root, 'src'), { recursive: true })
164
+ for (const [file, text] of edits) writeFileSync(file, text)
165
+ return { ok: true, package: 'tailwindcss', actions }
166
+ }
167
+
168
+ const ENGINES = ['sqlite', 'postgres', 'mysql']
169
+ const LAYER_PLUGIN = { prisma: '@weft-js/db/plugin-prisma', native: '@weft-js/db/plugin-native' }
170
+
171
+ /** A database on an app that has none: the wiring a scaffold writes, with no todo app. Never touches what's there. */
172
+ 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.')
176
+ const { stackDeps, stackFiles } = await import('@weft-js/create/stack')
177
+ const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'))
178
+ const name = String(pkg.name ?? 'app').replace(/^@.*\//, '')
179
+ const files = stackFiles({ db: engine, layer, auth: false, name, secret: '' })
180
+ delete files['src/routes/todos/index.tsx']
181
+ 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.`)
184
+
185
+ const { deps, dev } = stackDeps({ db: engine, layer, auth: false })
186
+ const actions = []
187
+ const [manager, verb] = installer(root)
188
+ if (install) {
189
+ const specs = Object.entries({ ...deps, ...dev }).map(([n, v]) => `${n}@${v}`)
190
+ actions.push(`install ${specs.join(' ')} with ${manager}`)
191
+ if (!dryRun) installSpecs(root, manager, verb, specs)
192
+ }
193
+ actions.push(...Object.keys(files).map(f => `write ${f}`))
194
+ if (dryRun) return { ok: true, package: `${layer} on ${engine}`, actions, dryRun: true }
195
+ for (const [file, text] of Object.entries(files)) {
196
+ const path = join(root, file)
197
+ mkdirSync(dirname(path), { recursive: true })
198
+ if (file.startsWith('.env') && existsSync(path)) writeFileSync(path, `${readFileSync(path, 'utf8').trimEnd()}\n${text}`)
199
+ else writeFileSync(path, text)
200
+ }
201
+ if (LAYER_PLUGIN[layer]) actions.push(...(await addCommand(root, LAYER_PLUGIN[layer], { install: false })).actions)
202
+ actions.push('next: `weft db generate`')
203
+ return { ok: true, package: `${layer} on ${engine}`, actions }
204
+ }
205
+
206
+ /** Better Auth on an app with a database. Its tables go beside yours, and your own routes stay as they are. */
207
+ async function addAuth(root, { dryRun, install }) {
208
+ 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.')
211
+ const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'))
212
+ const db = database(root, layer).engine
213
+ const ui = '@weft-js/warp' in { ...pkg.dependencies, ...pkg.devDependencies } ? 'warp' : 'none'
214
+ const { authFiles, stackDeps } = await import('@weft-js/create/stack')
215
+ const { files, context, tables } = authFiles({ db, layer: layer.layer, ui })
216
+ 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)))
219
+ if (taken.length) throw refused(`${taken.join(', ')} already exist. Nothing was changed.`)
220
+ const envModule = join(root, 'src/env.server.ts')
221
+ 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.')
223
+
224
+ const actions = []
225
+ const [manager, verb] = installer(root)
226
+ 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}`)
228
+ if (install && specs.length) {
229
+ actions.push(`install ${specs.join(' ')} with ${manager}`)
230
+ if (!dryRun) installSpecs(root, manager, verb, specs)
231
+ }
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}`))
244
+ if (!dryRun) {
245
+ for (const [file, text] of Object.entries(edits)) {
246
+ mkdirSync(dirname(join(root, file)), { recursive: true })
247
+ writeFileSync(join(root, file), text)
248
+ }
249
+ 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`)
251
+ }
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
263
+ }
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
+ }
268
+
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 } = {}) {
302
+ 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`)
304
+ if (feature === 'rust') {
305
+ const [manager, verb] = installer(root)
306
+ return addRust(root, engine, { remote, dryRun, install, installSpecs: specs => installSpecs(root, manager, verb, specs) })
307
+ }
308
+ if (feature === 'tailwind') return addTailwind(root, { dryRun, install })
309
+ if (feature === 'db') return addDb(root, engine, { layer, dryRun, install })
310
+ if (feature === 'auth') return addAuth(root, { dryRun, install })
311
+ const pkg = ALIASES[feature] ?? (ADDONS.has(`@weft-js/${feature}`) ? `@weft-js/${feature}` : feature)
312
+ const file = findConfig(root) ?? join(root, 'weft.config.ts')
313
+ 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"
315
+ const named = names(text, pkg)
316
+ // A config can name a package that isn't installed yet, and importing it then would leave this process's module
317
+ // resolver sure the package has no `exports`, so the load after the install would fail too. Load it once it's there.
318
+ 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: [] }
320
+
321
+ const home = packageOf(pkg)
322
+
323
+ const actions = []
324
+ const [manager, verb] = installer(root)
325
+ if (install) {
326
+ actions.push(`install ${home} with ${manager}`)
327
+ if (!dryRun) installSpecs(root, manager, verb, [home])
328
+ }
329
+ 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)
332
+ 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
+
335
+ writeFileSync(file, next)
336
+ const after = await loadConfig(root)
337
+ const failed = after.diagnostics.filter(d => d.severity === 'error')
338
+ if (failed.length) {
339
+ 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}`)
341
+ }
342
+ sync(root, after)
343
+ actions.push('regenerate .weft/slots')
344
+ if (plan) {
345
+ for (const other of setup.with ?? []) actions.push(...(await addCommand(root, other, { install })).actions)
346
+ applySetup(root, plan)
347
+ actions.push(...setupActions(plan))
348
+ }
349
+ // 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'))) {
352
+ await writeAgentsMd(root)
353
+ actions.push('regenerate AGENTS.md')
354
+ }
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 } }) }
358
+ }
@@ -0,0 +1,134 @@
1
+ // `weft agents-md`: AGENTS.md generated from the installed weft (exports, the codes its compiler reports) and this
2
+ // project's tasks. Under 8 KB (PLAN §7). Never edited by hand: `--check` fails when it is stale.
3
+
4
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs'
5
+ import { join, resolve } from 'node:path'
6
+ import { sourceAlias } from './alias.mjs'
7
+ import { catalog } from './catalog.mjs'
8
+ import { commandsPage, installed, readDoc } from './docs.mjs'
9
+ import { loadConfig } from './load-config.mjs'
10
+ import { agentsSection } from './contributions.mjs'
11
+ import { tasks } from './tasks.mjs'
12
+
13
+ export const limit = 8 * 1024
14
+
15
+ /** `T001, T002, T003, P007` → `T001–T003, P007`: runs of consecutive codes as ranges (three or more). */
16
+ export function ranges(codes) {
17
+ const out = []
18
+ for (let i = 0; i < codes.length; ) {
19
+ let j = i
20
+ const num = c => Number(c.slice(1))
21
+ while (j + 1 < codes.length && codes[j + 1][0] === codes[i][0] && num(codes[j + 1]) === num(codes[j]) + 1) j++
22
+ out.push(j - i >= 2 ? `${codes[i]}–${codes[j]}` : codes.slice(i, j + 1).join(', '))
23
+ i = j + 1
24
+ }
25
+ return out.join(', ')
26
+ }
27
+ /** PLAN §3.1 primitives an agent may reach for; listed as missing when the installed weft doesn't export them. */
28
+ const planned = ['state', 'derived', 'resource', 'Boundary', 'watch', 'onMount', 'onCleanup', 'context', 'defineState', 'flush', 'untrack', 'batch']
29
+ /** Commands AGENTS.md leaves to `weft tasks --json`: rarely run by hand, or taught in their own section. */
30
+ const elsewhere = new Set(['agents-md', 'doctor', 'mcp', 'plugins', 'start', 'style', 'surfaces', 'tasks', 'tokens'])
31
+ /** The app-facing part of subpaths whose other exports are helpers compiled code calls. */
32
+ const entryPoints = { '@weft-js/core/dom': ['render', 'hydrate'], '@weft-js/core/ssr': ['renderToString', 'renderToStringAsync'], '@weft-js/core/browser': ['online', 'visible', 'lifecycle', 'media', 'on', 'interval'] }
33
+
34
+ /** The Full stack section, with this app's alias for its source. */
35
+ function fullStack(root) {
36
+ const alias = sourceAlias(root)
37
+ const text = readDoc('start-short')
38
+ return alias ? text.replaceAll('#/', alias) : text.replace(/ Import your own code as [^(]*\(`src\/server\/todos`\); generated files are/, ' Generated files are')
39
+ }
40
+
41
+ /** This project's AGENTS.md, written from what the installed weft reports: the text, where it goes, and whether weft's own part is over the limit. */
42
+ export async function agentsMd(root) {
43
+ root = resolve(root)
44
+ const info = installed(root)
45
+ const list = await tasks(root)
46
+ const sections = (await loadConfig(root)).contributions.filter(c => c.manifest.agents).map(c => agentsSection(c.manifest.agents))
47
+ const core = info.exports['@weft-js/core'] ?? []
48
+ const missing = planned.filter(n => !core.includes(n))
49
+
50
+ // Style-group codes (D0xx, W060) and `weft style` belong to the Styling section, which only a weft with @weft-js/core/style gets.
51
+ const styled = info.exports['@weft-js/core/style']?.includes('group')
52
+ const dcodes = catalog.filter(e => e.code.startsWith('D0') && (e.by === 'cli' || info.status[e.code]?.implemented)).map(e => e.code).sort()
53
+ const startApp = existsSync(join(root, 'src', 'routes'))
54
+ const tcodes = catalog.filter(e => e.code.startsWith('T') && info.status[e.code]?.implemented).map(e => e.code)
55
+
56
+ // with plugins: the `weft plugins` command and their sections; without, neither
57
+ const page = plugins => [
58
+ `# AGENTS.md`,
59
+ '',
60
+ `<!-- Generated by \`weft agents-md\` from weft@${info.weft.version}. Do not edit: change weft.config.ts and regenerate. -->`,
61
+ '',
62
+ `This is a **weft** app. It is not React: components run once, state is a box, and`,
63
+ `React hooks don't exist. Run \`weft check --json\` after every change; each diagnostic gives code, location, why and fix.`,
64
+ '',
65
+ 'Start with `.weft/map.md` (`weft map`): what this app has and what to call.',
66
+ '',
67
+ '## Rules',
68
+ '',
69
+ readDoc('rules'),
70
+ ...(core.includes('resource')
71
+ ? ['10. **Async data is `resource(signal => fetch(…))`** (`.value .pending .error .refresh()`), never `derived(async …)` (W012).',
72
+ core.includes('Boundary') ? ' Loading and errors: `<Boundary pending={…} error={(e, retry) => …}>`. More: `weft docs async`.' : '']
73
+ : []),
74
+ '',
75
+ '**Leave props untyped**: `weft docs props`.',
76
+ '',
77
+ `## API in weft@${info.weft.version}`,
78
+ '',
79
+ `- \`@weft-js/core\`: ${core.filter(n => planned.includes(n)).map(n => `\`${n}\``).join(', ')}` + (core.includes('state') ? ', `state.linked`' : ''),
80
+ ...['@weft-js/core/dom', '@weft-js/core/ssr'].map((mod, i, all) => {
81
+ const names = entryPoints[mod].filter(n => info.exports[mod]?.includes(n))
82
+ const last = all.slice(i + 1).every(m => !entryPoints[m].some(n => info.exports[m]?.includes(n)))
83
+ return names.length ? `- \`${mod}\`: ${names.map(n => `\`${n}\``).join(', ')}.${last ? ' Never import the rest.' : ''}` : ''
84
+ }).filter(Boolean),
85
+ info.exports['@weft-js/core/browser'] ? `- \`@weft-js/core/browser\`: ${entryPoints['@weft-js/core/browser'].filter(n => info.exports['@weft-js/core/browser'].includes(n)).map(n => `\`${n}\``).join(', ')}. Create them in setup (W051); server-rendered \`media\` needs \`ssr\` (W054). More: \`weft docs browser\`; hover and press as state: \`weft docs ui-state\`.` : '',
86
+ info.exports['@weft-js/core/surface'] ? '- `@weft-js/core/surface`: canvas and WebGL, never raw `<canvas>`: `weft docs surface`.' : '',
87
+ info.exports['@weft-js/core/test'] ? `- \`@weft-js/core/test\`: ${info.exports['@weft-js/core/test'].filter(n => !['canvasCalls', 'cleanup', 'fakeMeasure', 'leaks'].includes(n)).map(n => `\`${n}\``).join(', ')}, and \`expect(fn).toTouchAtMost(n, { runs })\`. Query by role/label/text; \`user\` actions and \`clock.advance\` end in \`flush()\`, so never sleep or \`waitFor\`.` : '',
88
+ missing.length ? `- Not in this version yet: ${missing.map(n => `\`${n}\``).join(', ')}. Don't import them.` : '',
89
+ '',
90
+ '## Diagnostics',
91
+ '',
92
+ '`weft docs <code>` explains one. Also flagged: raw `addEventListener`, `setInterval` or sockets with no cleanup (W050), and a `watch` that folds a stream (W052) or copies a live value into `state` (W056).',
93
+ '',
94
+ tcodes.length ? `Test checks (\`weft check\` on test files): ${ranges(tcodes)}. No assertion, tautologies, sleeps, \`container\`/selectors/test ids, mocking weft, snapshots, conditional \`expect\`, \`.only\`/\`.skip\`. To allow one: \`// weft-allow T00x: <reason>\`.` : '',
95
+ '',
96
+ 'Before a change is done, see it work (`weft browser snapshot|act|assert`, or `__weft__.agent` in your own browser) and run `weft test --gate`: new tests must fail on the old code and kill a mutant (T010, T011, T020). `weft build` ends by auditing the pages it built at a phone\'s and a desktop\'s width (L-codes: names, contrast, console, the head, sideways scroll, layout shift) and fails on an error; `weft audit --url <deploy>` checks a live site. More: `weft docs verify`.',
97
+ '',
98
+ ...(startApp
99
+ ? ['## Full stack', '', fullStack(root), '']
100
+ : []),
101
+ ...(styled
102
+ ? ['## Styling',
103
+ '',
104
+ `**Run \`weft style find <what it is>\` first**, then reuse a group or add an axis value; never class lists. New: \`const card = group('card', { base: { pad: 'group' }, axes: { status: { late: { tone: 'critical' } } } })\` from \`@weft-js/core/style\`, \`<li {...card({ status })}>\`. Tokens only. ${ranges(dcodes)}, W060: \`weft docs style\`, \`weft docs design\`; \`weft fix\` applies the safe fixes.`,
105
+ '']
106
+ : []),
107
+ '## Commands',
108
+ '',
109
+ commandsPage({ ...list, tasks: list.tasks.filter(t => !elsewhere.has(t.name)) }, { planned: false }),
110
+ '',
111
+ '`weft tasks --json` lists the rest.',
112
+ '',
113
+ ...(plugins.length ? ['## Plugins', '', ...plugins] : []),
114
+ '## More',
115
+ '',
116
+ `\`weft docs <topic>\` or \`weft mcp\`: \`rules\`, \`props\`, \`lists\`, \`async\`, \`testing\`, \`browser\`, \`verify\`, ${startApp ? '`start`, ' : ''}${info.exports['@weft-js/core/style'] ? '`style`, ' : ''}${info.exports['@weft-js/core/surface'] ? '`surface`, ' : ''}\`config\`, \`diagnostics\`, \`commands\`.`,
117
+ '',
118
+ ]
119
+ .filter((line, i, all) => !(line === '' && all[i - 1] === ''))
120
+ .join('\n')
121
+
122
+ const text = page(sections)
123
+ const bytes = Buffer.byteLength(text)
124
+ // the limit is weft's own text: plugin sections have their own 1 KB each (P112) on top
125
+ return { path: join(root, 'AGENTS.md'), text, bytes, overLimit: Buffer.byteLength(page([])) > limit }
126
+ }
127
+
128
+ /** Write AGENTS.md, or with `check` only compare. Returns whether the file on disk was current. */
129
+ export async function writeAgentsMd(root, { check = false } = {}) {
130
+ const out = await agentsMd(root)
131
+ const current = existsSync(out.path) && readFileSync(out.path, 'utf8') === out.text
132
+ if (!check && !current) writeFileSync(out.path, out.text)
133
+ return { ...out, current, written: !check && !current }
134
+ }
package/src/alias.mjs ADDED
@@ -0,0 +1,59 @@
1
+ // An app's import aliases are its own: tsconfig `paths` (which Vite follows too), plus package.json `imports` if it has
2
+ // them. `#/*` is only what create-weft writes. `local` is how the checks follow a project import, so an aliased file
3
+ // gets the same analysis as a relative one.
4
+
5
+ import { existsSync, readFileSync } from 'node:fs'
6
+ import { dirname, join, resolve } from 'node:path'
7
+ import ts from 'typescript'
8
+
9
+ const cache = new Map()
10
+
11
+ /** `{ prefix, suffix, target }` for each alias declared in `dir`, longest prefix first; `target` has a `*` to fill. */
12
+ function rulesIn(dir) {
13
+ const rules = []
14
+ const add = (pattern, target) => {
15
+ const [prefix, suffix = ''] = pattern.split('*')
16
+ rules.push({ prefix, suffix, exact: !pattern.includes('*'), target })
17
+ }
18
+ const tsconfig = join(dir, 'tsconfig.json')
19
+ if (existsSync(tsconfig)) {
20
+ const host = { ...ts.sys, readDirectory: () => [] }
21
+ const { options } = ts.parseJsonConfigFileContent(ts.readConfigFile(tsconfig, path => ts.sys.readFile(path)).config, host, dir)
22
+ const base = options.pathsBasePath ?? options.baseUrl ?? dir
23
+ for (const [pattern, [target]] of Object.entries(options.paths ?? {})) add(pattern, resolve(base, target))
24
+ }
25
+ const pkg = join(dir, 'package.json')
26
+ if (existsSync(pkg)) {
27
+ for (const [pattern, target] of Object.entries(JSON.parse(readFileSync(pkg, 'utf8')).imports ?? {})) {
28
+ if (typeof target === 'string' && target.startsWith('.')) add(pattern, resolve(dir, target))
29
+ }
30
+ }
31
+ return rules.sort((a, b) => b.prefix.length - a.prefix.length)
32
+ }
33
+
34
+ /** The rules of the nearest project root above `dir`. */
35
+ function rulesFor(dir) {
36
+ if (cache.has(dir)) return cache.get(dir)
37
+ const parent = dirname(dir)
38
+ const isRoot = existsSync(join(dir, 'tsconfig.json')) || existsSync(join(dir, 'package.json'))
39
+ const rules = isRoot ? rulesIn(dir) : parent === dir ? [] : rulesFor(parent)
40
+ cache.set(dir, rules)
41
+ return rules
42
+ }
43
+
44
+ /** The absolute path a project import points at, before extensions; null for a package. */
45
+ export function local(fromAbs, spec) {
46
+ if (spec.startsWith('.')) return join(dirname(fromAbs), spec)
47
+ for (const { prefix, suffix, exact, target } of rulesFor(dirname(fromAbs))) {
48
+ if (exact ? spec === prefix : spec.startsWith(prefix) && spec.endsWith(suffix)) {
49
+ return exact ? target : target.replace('*', spec.slice(prefix.length, spec.length - suffix.length))
50
+ }
51
+ }
52
+ return null
53
+ }
54
+
55
+ /** The prefix that maps to `<root>/src/`, like `#/`; null when the app has none. */
56
+ export function sourceAlias(root) {
57
+ const rule = rulesFor(resolve(root)).find(r => r.target === join(resolve(root), 'src', '*') && !r.suffix)
58
+ return rule?.prefix ?? null
59
+ }