@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/cli.mjs ADDED
@@ -0,0 +1,364 @@
1
+ // Argument parsing and output for `weft`. JSON on stdout with --json, human text on stderr.
2
+ // Exit codes: 0 ok, 1 diagnostics with errors (a build refused on one, or a stale AGENTS.md under --check), 2 usage or internal error.
3
+
4
+ import { fix } from './fix.mjs'
5
+ import { writeTokens } from './tokens.mjs'
6
+ import { createRequire } from 'node:module'
7
+ import { existsSync } from 'node:fs'
8
+ import { join, resolve } from 'node:path'
9
+ import { pathToFileURL } from 'node:url'
10
+ import { addCommand } from './add.mjs'
11
+ import { writeAgentsMd } from './agents-md.mjs'
12
+ import { check } from './check.mjs'
13
+ import { formatHuman, sortDiagnostics } from './diag.mjs'
14
+ import { docs } from './docs.mjs'
15
+ import { compilerLine, doctor, formatDoctor } from './doctor.mjs'
16
+ import { formatMap, writeMap } from './map.mjs'
17
+ import { serve } from './mcp.mjs'
18
+ import { explain, list, sync } from './plugins.mjs'
19
+ import { approveCommand, auditCommand, formatTrust } from './trust.mjs'
20
+ import { loadConfig } from './load-config.mjs'
21
+ import { listSurfaces } from './surface-checks.mjs'
22
+ import { runTask, tasks } from './tasks.mjs'
23
+ import { formatGate, gate } from './gate.mjs'
24
+ import { browserCommand } from './browser/cli.mjs'
25
+ import { metaTable } from './meta-table.mjs'
26
+ import { audit } from './audit.mjs'
27
+ import { buildCommand, dbCommand, devCommand, doctorCommand, routesCommand, startCommand, unsafeAllowed } from './start.mjs'
28
+ import { deployCommand, formatHosts, hostsCommand, initCommand } from './deploy/index.mjs'
29
+ import { explainTarget, findGroups, formatExplain, formatFind, styleMatrix, writeMatrixFile } from './style.mjs'
30
+
31
+ const usage = `usage: weft <command> [--json] [--root <dir>]
32
+
33
+ check [files…] [--preset <p>] compile in check mode; W-, P- and D-diagnostics with fixes (--verbose: which compiler ran)
34
+ doctor [--preset <p>] which compiler this install uses, whether Rust is around (it's optional), and what live data does on the target
35
+ fix [files…] apply the safe autofixes
36
+ tokens warp's resolved tokens as DTCG, in .weft/tokens/
37
+ test --gate the test gate on the diff: fail-before (T010), mutants on changed lines (T011), no weakening (T020)
38
+ [--base <ref>] [--budget <sec>] [--max-mutants <n>] [--refactor] [--no-mutation]
39
+ browser <cmd> drive the running app (\`weft browser help\`): open, snapshot, act, assert, net, time, record…
40
+ lsp a language server for editors on TypeScript 7: tsc --lsp with inferred props
41
+ tsc type-check the whole project with inferred props (tsc or tsgo; for CI)
42
+ dev [--port <n>] the dev server for an app with src/routes (Vite + Nitro)
43
+ build [--runtime node|bun|deno] [--preset <p>] production build to .output, and .weft/contract.json; then the
44
+ built pages are audited (L-codes) and an error fails the build
45
+ [--unsafe-allow <codes>] go past these codes (R002, L003, a family like L, or all): forces a deploy
46
+ audit [routes…] [--url <u>] [--all] [--max <n>] the built pages, or a running site, in a clean Chrome at a phone's
47
+ and a desktop's width: axe-core's WCAG rules, console, deprecations, head, sideways scroll, shift
48
+ deploy [--prod] [--yes] [--target t] fit check, build for the target, then the host's own CLI; without --yes a dry
49
+ run that prints what would run. Netlify, Vercel, Cloudflare, Fly, Render; the live URL at the end
50
+ deploy hosts which hosts can run this app, why not, what each costs, and the one weft suggests
51
+ deploy init [target] [--dry-run] [--force] the host's files (netlify.toml, fly.toml + Dockerfile…), a GitHub
52
+ workflow that checks and deploys on push, and \`deploy\` in the config
53
+ start [--runtime r] run .output/ on its runtime (the config's \`runtime\`, node by default)
54
+ db generate [name] a migration for the schema's changes (it applies on the next request)
55
+ db live a migration with change triggers for the tables in \`changes\`
56
+ db listen the change feed as its own process, for an app on functions
57
+ routes [--meta] the route table; --meta: every route's merged metadata, tags, card mode and H-diagnostics
58
+ tasks commands and project tasks
59
+ run <task> run a project task from weft.config.ts
60
+ docs [topic|code] docs for agents (rules, props, lists, async, testing, config, diagnostics, commands, W014…)
61
+ agents-md [--check] write AGENTS.md (--check: exit 1 when stale; --stdout: print it)
62
+ map [--stdout] write .weft/map.md and .json: a matrix of what to call in this app
63
+ add <feature> install a plugin and wire it into the config; also tailwind, auth, db [engine] [--layer drizzle|prisma|native], and rust [name] [--remote] [--dry-run] [--no-install]
64
+ plugins [list] plugins, the slots they provide and bind, permissions, diagnostics
65
+ plugins explain <slot> binding chain: config → plugin → module → .weft/slots/<id>.ts
66
+ plugins sync regenerate .weft/slots/*, .weft/slots.d.ts and .weft/tokens/*
67
+ plugins audit each plugin's permissions and code against .weft/plugins.lock.json, and what it uses undeclared
68
+ plugins approve [name…] [--all] record them in the lock; with no names, show what's waiting and write nothing
69
+ style find <query> style groups that fit what you're about to style (\`card\`, \`status badge\`): reuse before you write
70
+ style explain <group|selector> [--<axis> <value>] [--states a,b] [--accent intent:token] [--part p]
71
+ resolved CSS for one variation, and where each value came from
72
+ style matrix write .weft/styles.json (every group: axes, parts, accents, resolved CSS, uses)
73
+ mcp MCP server on stdio: docs, check, tasks, style_find, style_explain, test_gate, browser_*`
74
+
75
+ /** Flags that take a value (`--base main` or `--base=main`), and boolean flags, beyond the global ones. */
76
+ const valued = new Set(['layer', 'base', 'budget', 'max-mutants', 'concurrency', 'url', 'tab', 'out', 'component', 'from', 'props', 'name', 'scope', 'timeout', 'preset', 'port', 'runtime', 'unsafe-allow', 'max', 'target'])
77
+ /** `weft style explain` takes the group's own axes as flags (`--status late`); these are its fixed ones. */
78
+ const styleFlags = new Set(['states', 'accent', 'part'])
79
+ const booleans = new Set(['dry-run', 'no-install', 'remote', 'gate', 'refactor', 'no-mutation', 'interactive', 'delta', 'graph-only', 'no-graph', 'headed', 'full', 'reload', 'meta', 'all', 'verbose', 'prod', 'yes', 'force'])
80
+
81
+ export function parse(argv) {
82
+ const out = { command: argv[0], positional: [], json: false, root: process.cwd(), check: false, stdout: false, flags: {} }
83
+ for (let i = 1; i < argv.length; i++) {
84
+ const a = argv[i]
85
+ if (a === '--json') out.json = true
86
+ else if (a === '--check') out.check = true
87
+ else if (a === '--stdout') out.stdout = true
88
+ else if (a === '--root') out.root = resolve(argv[++i] ?? '.')
89
+ else if (a.startsWith('--root=')) out.root = resolve(a.slice(7))
90
+ else if (a.startsWith('--')) {
91
+ const [name, inline] = a.slice(2).split(/=(.*)/s)
92
+ if (valued.has(name)) out.flags[name] = inline ?? argv[++i]
93
+ else if (booleans.has(name) && inline === undefined) out.flags[name] = true
94
+ else if (out.command === 'style' && styleFlags.has(name)) out.flags[name] = inline ?? argv[++i]
95
+ else if (out.command === 'style' && /^[a-z][\w-]*$/i.test(name) && (inline ?? argv[i + 1]) !== undefined) (out.flags.axes ??= {})[name] = inline ?? argv[++i]
96
+ else throw Object.assign(new Error(`unknown flag ${a}`), { code: 'E_USAGE' })
97
+ } else out.positional.push(a)
98
+ }
99
+ return out
100
+ }
101
+
102
+ export async function main(argv, io = { stdout: process.stdout, stderr: process.stderr }) {
103
+ const print = data => io.stdout.write(`${JSON.stringify(data, null, 2)}\n`)
104
+ const say = text => text && io.stderr.write(`${text}\n`)
105
+ let args
106
+ try {
107
+ args = parse(argv)
108
+ switch (args.command) {
109
+ case 'lsp': {
110
+ const { lsp } = await import('./infer/lsp.mjs')
111
+ const out = lsp(args.root)
112
+ return await new Promise(ok => { out.server.on('exit', () => ok(0)) })
113
+ }
114
+ case 'tsc': {
115
+ const { propTsc } = await import('./props-checks.mjs')
116
+ const diagnostics = sortDiagnostics(await propTsc(args.root))
117
+ if (args.json) print({ ok: !diagnostics.some(d => d.severity === 'error'), diagnostics })
118
+ say(formatHuman(diagnostics))
119
+ say(`weft tsc: ${diagnostics.filter(d => d.severity === 'error').length} errors`)
120
+ return diagnostics.some(d => d.severity === 'error') ? 1 : 0
121
+ }
122
+ case 'check': {
123
+ const result = await check(args.root, { files: args.positional, preset: args.flags.preset })
124
+ if (args.json) print(result)
125
+ say(formatHuman(result.diagnostics))
126
+ if (args.flags.verbose) say(compilerLine(doctor(args.root).compiler))
127
+ say(`weft check: ${result.summary.files} files, ${result.summary.errors} errors, ${result.summary.warnings} warnings (${result.ms} ms)`)
128
+ return result.ok ? 0 : 1
129
+ }
130
+ case 'doctor': {
131
+ // What this install compiles with, always; what live data does on the target, for an app that has any.
132
+ const result = doctor(args.root)
133
+ const plan = args.flags.preset || existsSync(join(args.root, 'src/realtime.server.ts')) ? await doctorCommand(args.root, { preset: args.flags.preset, runtime: args.flags.runtime }) : undefined
134
+ const hosts = hostsCommand(args.root, (await loadConfig(args.root)).config)
135
+ if (args.json) print({ ...result, live: plan, deploy: hosts })
136
+ else say(`${formatDoctor(result)}\n\nweft doctor: deploy${hosts.target ? ` (target ${hosts.target})` : ''}\n${formatHosts(hosts).replace(/^/gm, ' ')}`)
137
+ if (!plan) return 0
138
+ say(`weft doctor: ${plan.preset} (${plan.host})\n live mode: ${plan.mode}\n ${plan.says}${plan.required.length ? `\n required: ${plan.required.join(', ')}` : ''}${plan.options.length ? `\n options:\n${plan.options.map(o => ` - ${o}`).join('\n')}` : ''}`)
139
+ say(formatHuman(plan.diagnostics))
140
+ return plan.diagnostics.some(d => d.severity === 'error') ? 1 : 0
141
+ }
142
+ case 'test': {
143
+ if (!args.flags.gate) throw Object.assign(new Error('`weft test` runs the gate with --gate; run the suite itself with vitest'), { code: 'E_USAGE' })
144
+ const num = name => (args.flags[name] === undefined ? undefined : Number(args.flags[name]))
145
+ const result = await gate(args.root, {
146
+ base: args.flags.base, budgetSec: num('budget'), maxMutants: num('max-mutants'), concurrency: num('concurrency'),
147
+ refactor: !!args.flags.refactor, mutation: !args.flags['no-mutation'], log: line => say(line),
148
+ })
149
+ if (args.json) print(result)
150
+ say(formatHuman(result.diagnostics))
151
+ say(formatGate(result))
152
+ say(`weft test --gate: ${result.ok ? 'pass' : 'fail'}, ${result.summary.changedTests} changed tests, ${result.summary.errors} errors, ${result.summary.warnings} warnings (${result.ms} ms)`)
153
+ return result.ok ? 0 : 1
154
+ }
155
+ case 'browser':
156
+ return await browserCommand(args, { print, say, io })
157
+ case 'dev': {
158
+ const server = await devCommand(args.root, { port: args.flags.port && Number(args.flags.port) })
159
+ say(`weft dev: ${server.resolvedUrls.local[0]}`)
160
+ await new Promise(ok => { process.once('SIGINT', ok) })
161
+ await server.close()
162
+ return 0
163
+ }
164
+ case 'build': {
165
+ const result = await buildCommand(args.root, { preset: args.flags.preset, runtime: args.flags.runtime, unsafeAllow: args.flags['unsafe-allow'] })
166
+ if (args.json) print(result)
167
+ say(formatHuman(result.diagnostics))
168
+ say(`weft build: ${result.preset} → ${result.output}, ${result.functions} functions in the contract, ${result.audited} pages audited (${result.ms} ms)`)
169
+ if (!result.ok) say('weft build: failed. The output is written, but fix the errors above before it ships, or `--unsafe-allow <code>` to force it.')
170
+ return result.ok ? 0 : 1
171
+ }
172
+ case 'audit': {
173
+ const result = await audit(args.root, { url: args.flags.url, routes: args.positional.length ? args.positional : undefined, all: !!args.flags.all, max: args.flags.max && Number(args.flags.max) })
174
+ if (args.json) print(result)
175
+ say(formatHuman(result.diagnostics))
176
+ say(`weft audit: ${result.skipped ?? `${result.pages.length} pages at a phone's and a desktop's width, ${result.diagnostics.length} findings (${result.ms} ms)`}`)
177
+ return result.ok ? 0 : 1
178
+ }
179
+ case 'deploy': {
180
+ const loaded = await loadConfig(args.root)
181
+ const sub = args.positional[0]
182
+ if (sub === 'hosts') {
183
+ const result = hostsCommand(args.root, loaded.config)
184
+ if (args.json) print(result)
185
+ else say(formatHosts(result))
186
+ return 0
187
+ }
188
+ const unsafe = unsafeAllowed(args.flags['unsafe-allow'], loaded.config)
189
+ if (sub === 'init') {
190
+ const result = initCommand(args.root, loaded.config, { target: args.positional[1] ?? args.flags.target, dryRun: !!args.flags['dry-run'], force: !!args.flags.force, unsafe })
191
+ if (args.json) print(result)
192
+ say(formatHuman(result.findings))
193
+ say([`weft deploy init ${result.target}${result.dryRun ? ' (dry run: nothing written)' : ''}:`, ...result.actions.map(a => ` ${a}`), '', 'then, once:', ...result.next.map(n => ` ${n}`)].join('\n'))
194
+ return 0
195
+ }
196
+ if (sub !== undefined) throw Object.assign(new Error(`weft deploy ${sub}: use \`weft deploy\`, \`weft deploy hosts\` or \`weft deploy init [target]\``), { code: 'E_USAGE' })
197
+ const result = await deployCommand(args.root, loaded.config, { target: args.flags.target, prod: !!args.flags.prod, yes: !!args.flags.yes, unsafe, build: (root, o) => buildCommand(root, { ...o, unsafeAllow: args.flags['unsafe-allow'] }) })
198
+ if (args.json) print(result)
199
+ say(formatHuman(result.diagnostics))
200
+ if (result.dryRun) say([`weft deploy: a dry run for ${result.target}${result.prod ? ' (production)' : ' (a preview)'}. With --yes it runs:`, ...result.steps.map(s => ` ${s.what}${s.reads.length ? `\n reads ${s.reads.join(', ')} from the environment` : ''}`)].join('\n'))
201
+ else say(`weft deploy: ${result.target}${result.prod ? ' (production)' : ''} → ${result.url ?? 'deployed; the host printed no URL'}`)
202
+ return 0
203
+ }
204
+ case 'start':
205
+ return await startCommand(args.root, { runtime: args.flags.runtime })
206
+ case 'db': {
207
+ const result = await dbCommand(args.root, args.positional[0], args.positional[1])
208
+ if (args.json) print(result)
209
+ say(result.output)
210
+ return 0
211
+ }
212
+ case 'routes': {
213
+ const result = await routesCommand(args.root, { meta: !!args.flags.meta, preset: args.flags.preset })
214
+ if (args.json) print(result)
215
+ else say(args.flags.meta ? metaTable(result) : result.routes.map(r => `${r.path.padEnd(24)} ${r.file}`).join('\n'))
216
+ say(formatHuman(result.diagnostics))
217
+ return result.ok ? 0 : 1
218
+ }
219
+ case 'surfaces': {
220
+ const surfaces = listSurfaces(args.root)
221
+ if (args.json) print({ surfaces })
222
+ else say(surfaces.length ? surfaces.map(x => `${String(x.name).padEnd(20)} ${String(x.kind).padEnd(10)} ${x.file}:${x.line} props: ${x.props.join(', ') || '(none)'}`).join('\n') : 'No surfaces yet: `weft docs surface`.')
223
+ return 0
224
+ }
225
+ case 'tasks': {
226
+ const result = await tasks(args.root)
227
+ if (args.json) print(result)
228
+ else say(result.tasks.map(t => `${t.available ? ' ' : '·'} ${t.name.padEnd(10)} ${t.description ?? ''}`).join('\n'))
229
+ say(formatHuman(result.diagnostics))
230
+ return result.ok ? 0 : 1
231
+ }
232
+ case 'run': {
233
+ if (!args.positional[0]) throw Object.assign(new Error('weft run <task>'), { code: 'E_USAGE' })
234
+ return await runTask(args.root, args.positional[0])
235
+ }
236
+ case 'docs': {
237
+ const page = await docs(args.root, args.positional[0])
238
+ if (args.json) print(page)
239
+ else io.stdout.write(`${page.text}\n`)
240
+ return 0
241
+ }
242
+ case 'agents-md': {
243
+ const out = await writeAgentsMd(args.root, { check: args.check || args.stdout })
244
+ if (args.stdout) io.stdout.write(out.text)
245
+ if (args.json) print({ path: out.path, bytes: out.bytes, limit: 8192, overLimit: out.overLimit, current: out.current, written: out.written })
246
+ say(`AGENTS.md: ${out.bytes} bytes${out.overLimit ? ' (over the 8 KB limit)' : ''}, ${out.written ? 'written' : out.current ? 'current' : 'stale'}`)
247
+ return (args.check && !out.current) || out.overLimit ? 1 : 0
248
+ }
249
+ case 'map': {
250
+ const map = await writeMap(args.root)
251
+ if (args.json) print(map)
252
+ else if (args.stdout) process.stdout.write(formatMap(map))
253
+ else say(`.weft/map.md: ${map.rows.length} rows`)
254
+ return 0
255
+ }
256
+ case 'icons': {
257
+ if (args.positional[0] !== 'publish' || !args.positional[1]) { say('usage: weft icons publish <dir>'); return 1 }
258
+ const { publish } = await import(pathToFileURL(createRequire(join(args.root, 'package.json')).resolve('@weft-js/warp-icons/compile')).href)
259
+ const out = await publish(resolve(args.root, args.positional[1]))
260
+ if (args.json) print(out)
261
+ else say(`${out.files} files for ${out.icons} icons in ${out.dir}. Nothing was uploaded: host that folder, then set icons({ source }).`)
262
+ return 0
263
+ }
264
+ case 'add': {
265
+ const result = await addCommand(args.root, args.positional[0], { dryRun: args.flags['dry-run'], install: !args.flags['no-install'], engine: args.positional[1], layer: args.flags.layer, remote: args.flags.remote })
266
+ if (args.json) print(result)
267
+ else say(result.already ? `${result.package} is already in ${result.config}` : result.actions.map(a => `${args.flags['dry-run'] ? 'would ' : ''}${a}`).join('\n'))
268
+ return 0
269
+ }
270
+ case 'plugins': {
271
+ const sub = args.positional[0] ?? 'list'
272
+ if (sub === 'list') {
273
+ const result = await list(args.root)
274
+ if (args.json) print(result)
275
+ else say(result.plugins.map(p => `${p.name}@${p.version ?? '?'} ${p.provides.map(s => `${s.slot}${s.key ? `[${s.key}]` : ''}${s.bound ? '' : ' (not bound)'}`).join(', ') || 'no slots'}${p.tokens ? ` tokens --${p.tokens}-*` : ''}`).join('\n') || 'no plugins')
276
+ say(formatHuman(result.diagnostics))
277
+ return result.ok ? 0 : 1
278
+ }
279
+ if (sub === 'explain') {
280
+ const result = await explain(args.root, args.positional[1])
281
+ if (args.json) print(result)
282
+ else say(JSON.stringify(result.binding, null, 2))
283
+ return 0
284
+ }
285
+ if (sub === 'sync') {
286
+ const loaded = await loadConfig(args.root)
287
+ const errors = loaded.diagnostics.filter(d => d.severity === 'error')
288
+ const written = loaded.diagnostics.some(d => d.code === 'P007') ? [] : sync(args.root, loaded)
289
+ if (args.json) print({ ok: errors.length === 0, written, diagnostics: loaded.diagnostics })
290
+ say(formatHuman(loaded.diagnostics))
291
+ say(`weft plugins sync: ${written.length} files written`)
292
+ return errors.length ? 1 : 0
293
+ }
294
+ if (sub === 'audit') {
295
+ const loaded = await loadConfig(args.root)
296
+ const result = auditCommand(args.root, loaded)
297
+ if (args.json) print(result)
298
+ else say(result.plugins.map(formatTrust).join('\n') || 'no plugins')
299
+ say(formatHuman(result.diagnostics))
300
+ return result.ok ? 0 : 1
301
+ }
302
+ if (sub === 'approve') {
303
+ const result = approveCommand(args.root, await loadConfig(args.root), { names: args.positional.slice(1), all: !!args.flags.all })
304
+ if (args.json) print(result)
305
+ else say([...(result.written ? result.approved : result.pending).map(formatTrust), result.written ? `${result.lock}: ${result.approved.length} approved` : result.pending.length ? 'nothing written: name a plugin, or pass --all' : 'nothing waiting', ...result.written ? result.pending.map(p => `still waiting: ${p.name}`) : [], ...(result.gitignore ? ['.gitignore: the lock is no longer ignored, so commit it'] : [])].join('\n'))
306
+ return result.ok ? 0 : 1
307
+ }
308
+ throw Object.assign(new Error(`unknown plugins subcommand ${String(sub)}`), { code: 'E_USAGE' })
309
+ }
310
+ case 'fix': {
311
+ const result = await fix(args.root, { files: args.positional })
312
+ if (args.json) print(result)
313
+ else say(result.applied.length ? result.applied.map(a => `${a.file}:${a.span[0]}:${a.span[1]} ${a.code}`).join('\n') : 'nothing to fix')
314
+ return 0
315
+ }
316
+ case 'tokens': {
317
+ const result = await writeTokens(args.root)
318
+ if (args.json) print(result)
319
+ else say(`${result.dir}: ${result.files.length} files`)
320
+ return 0
321
+ }
322
+ case 'style': {
323
+ const sub = args.positional[0]
324
+ if (sub === 'find') {
325
+ const result = findGroups((await styleMatrix(args.root)).matrix, args.positional.slice(1).join(' '))
326
+ if (args.json) print(result)
327
+ else say(formatFind(result))
328
+ return 0
329
+ }
330
+ if (sub === 'explain') {
331
+ const accent = args.flags.accent ? Object.fromEntries(args.flags.accent.split(/[\s,]+/).filter(Boolean).map(w => w.split(':'))) : undefined
332
+ const states = args.flags.states?.split(',').map(s => s.trim()).filter(Boolean)
333
+ const result = explainTarget(await styleMatrix(args.root), args.positional.slice(1).join(' '), { axes: args.flags.axes, states, accent, part: args.flags.part })
334
+ if (args.json) print(result)
335
+ else say(formatExplain(result))
336
+ return 0
337
+ }
338
+ if (sub === 'matrix') {
339
+ const { matrix } = await styleMatrix(args.root)
340
+ const written = args.stdout ? false : writeMatrixFile(args.root, matrix)
341
+ if (args.stdout || args.json) print(matrix)
342
+ say(`.weft/styles.json: ${Object.keys(matrix.groups).length} groups, ${matrix.diagnostics.length} diagnostics, ${args.stdout ? 'not written' : written ? 'written' : 'current'}`)
343
+ return 0
344
+ }
345
+ throw Object.assign(new Error(`weft style ${sub ?? ''}: use find <query>, explain <group|selector> or matrix`), { code: 'E_USAGE' })
346
+ }
347
+ case 'mcp':
348
+ await serve(args.root)
349
+ return 0
350
+ case undefined:
351
+ case 'help':
352
+ case '--help':
353
+ say(usage)
354
+ return args.command ? 0 : 2
355
+ default:
356
+ throw Object.assign(new Error(`unknown command ${args.command}`), { code: 'E_USAGE' })
357
+ }
358
+ } catch (error) {
359
+ const code = error.code ?? 'E_INTERNAL'
360
+ if (args?.json || argv.includes('--json')) print({ ok: false, error: { code, message: error.message } })
361
+ say(`weft: ${error.message}${code === 'E_USAGE' ? `\n\n${usage}` : ''}`)
362
+ return code === 'E_REFUSED' ? 1 : 2
363
+ }
364
+ }
@@ -0,0 +1 @@
1
+ export * from '@weft-js/core/config'
package/src/config.mjs ADDED
@@ -0,0 +1,5 @@
1
+ // `defineConfig` for weft.config.ts. Identity at runtime; the types (config.d.ts) are the point.
2
+ // Apps import it from `@weft-js/core/config`; the CLI's loader falls back here when a project's weft doesn't resolve.
3
+
4
+ /** @type {import('@weft-js/core/config').defineConfig} */
5
+ export const defineConfig = config => config
@@ -0,0 +1,69 @@
1
+ // What each plugin's `contribute()` returned, checked against the limits. Apart from plugins.mjs because loading a config needs it and the plugin commands load configs.
2
+
3
+ export const agentsLimit = 1024
4
+ /** Rows a plugin may add to the agent map. */
5
+ const mapLimit = 5
6
+ const byName = (a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)
7
+
8
+ /** JSON with sorted keys and functions named, so two runs of a pure `contribute()` compare equal. */
9
+ function stable(value) {
10
+ return JSON.stringify(value, (_, v) => {
11
+ if (typeof v === 'function') return `[function ${v.name}]`
12
+ if (v && typeof v === 'object' && !Array.isArray(v)) return Object.fromEntries(Object.keys(v).sort().map(k => [k, v[k]]))
13
+ return v
14
+ })
15
+ }
16
+
17
+ /**
18
+ * Run `contribute()` for every plugin instance that has one (data-only entries are skipped), in name order.
19
+ * @param {object[]} plugins unique entries from `plugins`
20
+ * @param {(code: string, why: string, near: string, extra?: object) => void} report
21
+ * @param {{ root: string, command?: string }} env
22
+ * @returns {{ name: string, version?: string, namespace?: string, permissions?: object, manifest: object }[]}
23
+ */
24
+ export function contributions(plugins, report, env) {
25
+ const out = []
26
+ const tokenOwner = new Map()
27
+ const catalogOwner = new Map()
28
+ for (const p of [...plugins].sort(byName)) {
29
+ if (typeof p.contribute !== 'function') continue
30
+ const run = () => p.contribute({ command: env.command ?? 'check', mode: 'development', root: env.root })
31
+ let manifest
32
+ try {
33
+ manifest = run()
34
+ } catch (error) {
35
+ const code = typeof error?.code === 'string' ? error.code : 'P007'
36
+ report(code, `\`${p.name}\`: ${String(error?.message ?? error).split('\n')[0]}`, p.name, { owner: code === 'P007' ? 'core' : p.name, help: error?.help })
37
+ continue
38
+ }
39
+ let again
40
+ try {
41
+ again = run()
42
+ } catch {}
43
+ if (!again || stable(again) !== stable(manifest)) report('P117', `\`${p.name}\`'s contribute() returned a different manifest on a second call.`, p.name)
44
+
45
+ const ns = p.namespace ?? `${p.name.replace(/^@[^/]+\//, '').replace(/^plugin-/, '')}/`
46
+ for (const d of manifest.diagnostics ?? []) {
47
+ if (!d.code.startsWith(ns)) report('P111', `\`${p.name}\` declares \`${d.code}\`, outside its namespace \`${ns}\`.`, p.name)
48
+ }
49
+ const agents = manifest.agents
50
+ if (agents) {
51
+ const bytes = Buffer.byteLength(agentsSection(agents))
52
+ if (bytes > agentsLimit) report('P112', `\`${p.name}\`'s AGENTS section is ${bytes} bytes (limit ${agentsLimit}).`, p.name)
53
+ }
54
+ if ((manifest.map?.length ?? 0) > mapLimit) report('P112', `\`${p.name}\` adds ${manifest.map.length} map rows (limit ${mapLimit}).`, p.name)
55
+ const tns = manifest.tokens?.namespace
56
+ if (tns) {
57
+ if (tokenOwner.has(tns)) report('P113', `Token namespace \`${tns}\` is contributed by both \`${tokenOwner.get(tns)}\` and \`${p.name}\`.`, p.name)
58
+ else tokenOwner.set(tns, p.name)
59
+ }
60
+ for (const c of manifest.catalog ?? []) {
61
+ if (catalogOwner.has(c.name)) report('P113', `Catalog entry \`${c.name}\` is contributed by both \`${catalogOwner.get(c.name)}\` and \`${p.name}\`.`, p.name)
62
+ else catalogOwner.set(c.name, p.name)
63
+ }
64
+ out.push({ name: p.name, version: p.version, namespace: ns, permissions: p.permissions ?? {}, manifest })
65
+ }
66
+ return out
67
+ }
68
+
69
+ export const agentsSection = agents => `### ${agents.title}\n\n${agents.text.trim()}\n`
@@ -0,0 +1,33 @@
1
+ import { spawnSync } from 'node:child_process'
2
+ import { existsSync, readFileSync } from 'node:fs'
3
+ import { createRequire } from 'node:module'
4
+ import { dirname, join } from 'node:path'
5
+ import { loadConfig } from './load-config.mjs'
6
+
7
+ /** The app's data layer: the plugin that declares one, or Drizzle, which is built in. Null with no database. */
8
+ export async function dbLayer(root, loaded) {
9
+ const { contributions = [] } = loaded ?? await loadConfig(root)
10
+ const plugin = contributions.find(c => c.manifest.db)?.manifest.db
11
+ if (plugin) return plugin
12
+ // Drizzle has no plugin to say so: its config file, or a client from `@weft-js/start/db/*`, does.
13
+ const entry = join(root, 'src/db/index.server.ts')
14
+ return existsSync(join(root, 'drizzle.config.ts')) || (existsSync(entry) && readFileSync(entry, 'utf8').includes('@weft-js/start/db/')) ? DRIZZLE : null
15
+ }
16
+
17
+ const DRIZZLE = {
18
+ layer: 'drizzle',
19
+ schema: 'src/db/schema.ts',
20
+ migrations: 'drizzle',
21
+ generate(root) {
22
+ let bin
23
+ try {
24
+ const req = createRequire(join(root, 'package.json'))
25
+ bin = join(dirname(req.resolve('drizzle-kit')), 'bin.cjs')
26
+ } catch {
27
+ throw Object.assign(new Error('drizzle-kit is not installed in this project'), { code: 'E_USAGE' })
28
+ }
29
+ const r = spawnSync(process.execPath, [bin, 'generate'], { cwd: root, encoding: 'utf8' })
30
+ if (r.status !== 0) throw Object.assign(new Error(`drizzle-kit generate failed:\n${r.stdout}${r.stderr}`), { code: 'E_INTERNAL' })
31
+ return r.stdout.trim()
32
+ },
33
+ }
@@ -0,0 +1,64 @@
1
+ // What a half-set-up deploy looks like, without touching a provider (research 46 §8, §9): a credential written into a
2
+ // file (E008), Cloudflare in front of nothing or in front of a CDN (E010, E011), and Cloudinary without what it needs
3
+ // (E012–E014).
4
+
5
+ import { existsSync, readFileSync, readdirSync } from 'node:fs'
6
+ import { join } from 'node:path'
7
+ import { host } from './hosts.mjs'
8
+
9
+ /** Credentials with a shape of their own, and a value assigned to a key that names one. */
10
+ const SHAPED = [
11
+ [/\bnfp_[A-Za-z0-9]{20,}/, 'a Netlify token'],
12
+ [/\bFlyV1\s+fm\d_[A-Za-z0-9+/=_-]{20,}/, 'a Fly token'],
13
+ [/api\.render\.com\/deploy\/srv-[\w-]+\?key=[\w-]+/, 'a Render deploy hook (its key deploys the service)'],
14
+ [/cloudinary:\/\/\d+:[^@\s'"]+@/, 'a Cloudinary URL with its secret'],
15
+ ]
16
+ const KEYED = /\b[\w-]*(token|secret|api[_-]?key|password)[\w-]*["']?\s*[:=]\s*["']([^"'\s$]{16,})["']/i
17
+
18
+ const FILES = ['weft.config.ts', 'weft.config.mts', 'weft.config.js', 'weft.config.mjs', 'weft.config.json', 'weft.config.yaml', 'weft.config.yml', 'netlify.toml', 'vercel.json', 'wrangler.toml', 'fly.toml', 'render.yaml', 'Dockerfile']
19
+
20
+ /** E008: a credential in a file that gets committed, comments included. An env var's name, or `${{ secrets.X }}`, is never one. */
21
+ export function writtenSecrets(root) {
22
+ const out = []
23
+ const wf = join(root, '.github/workflows')
24
+ const files = [...FILES, ...(existsSync(wf) ? readdirSync(wf).filter(f => /\.ya?ml$/.test(f)).map(f => `.github/workflows/${f}`) : [])]
25
+ for (const file of files) {
26
+ let text
27
+ try { text = readFileSync(join(root, file), 'utf8') } catch { continue }
28
+ text.split('\n').forEach((line, i) => {
29
+ const shaped = SHAPED.find(([re]) => re.test(line))
30
+ const keyed = !shaped && KEYED.exec(line)
31
+ if (shaped || keyed) out.push({ file, line: i + 1, what: shaped ? shaped[1] : `a value for \`${keyed[0].split(/["':=\s]/)[0]}\`` })
32
+ })
33
+ }
34
+ return out
35
+ }
36
+
37
+ /** Hosts whose own domain says the proxy isn't in front. */
38
+ const OWN = /\.(fly\.dev|onrender\.com|netlify\.app|vercel\.app|workers\.dev|pages\.dev)$/
39
+
40
+ /** E010–E014 from the config, and the environment when `env` is given (a deploy or `weft doctor`). */
41
+ export function cdnFindings(config = {}, { env } = {}) {
42
+ const out = []
43
+ const cdn = config.deploy?.cdn ?? {}
44
+ const target = config.deploy?.target && host(config.deploy.target)
45
+ const add = (code, severity, why, fix) => out.push({ code, severity, file: 'weft.config.ts', why, fix })
46
+ if (cdn.proxy === 'cloudflare') {
47
+ let url
48
+ try { url = config.site?.url && new URL(config.site.url).hostname } catch {}
49
+ if (url && OWN.test(url)) add('E010', 'warning', `\`deploy.cdn.proxy\` is Cloudflare, and \`site.url\` is \`${url}\`, the host's own domain: nothing goes through the proxy, and canonical URLs point past it`, 'Set `site.url` to the domain Cloudflare proxies, with its DNS record proxied (orange cloud) to the host')
50
+ if (target && target.kind !== 'server') add('E011', 'warning', `\`deploy.cdn.proxy\` puts Cloudflare in front of ${target.name}, which is a CDN already: two caches, and a deploy purges only its own`, 'Drop `cdn.proxy`, or deploy to a server host (`fly`, `render`) behind it')
51
+ }
52
+ const images = cdn.images
53
+ if (images?.provider === 'cloudinary') {
54
+ const plugins = (config.plugins ?? []).map(p => p?.name ?? p)
55
+ if (!plugins.some(n => typeof n === 'string' && n.includes('plugin-media'))) add('E012', 'error', '`deploy.cdn.images` is Cloudinary, and `@weft-js/plugin-media` is not in `plugins`: nothing would send it an image', '`weft add media`, or drop `cdn.images`')
56
+ const given = env?.CLOUDINARY_URL
57
+ const cloud = given && /@([\w-]+)\/?$/.exec(given)?.[1]
58
+ if (!images.cloud && !given) add('E013', 'error', '`deploy.cdn.images` names no `cloud`, and `CLOUDINARY_URL` is unset: there is no account to send images to', "Set `cdn.images.cloud` to the cloud's name, and `CLOUDINARY_URL` in the host's environment (never in a file)")
59
+ else if (cloud && images.cloud && cloud !== images.cloud) add('E013', 'error', `\`cdn.images.cloud\` is \`${images.cloud}\`, and \`CLOUDINARY_URL\` here names \`${cloud}\`: one of them is another account`, 'Make them name the same cloud')
60
+ else if (env && !given) add('E013', 'warning', '`CLOUDINARY_URL` is unset here: the server needs it in the host\'s environment', 'Set it in the host\'s environment settings, never in a file')
61
+ add('E014', 'info', '`cdn.images` is checked, but this weft doesn\'t send uploads to Cloudinary yet: they still go to `media({ server: { dir } })`', 'Nothing yet: research 46 §9 is the plan. On a function host, uploads need a server host with a disk until then (E003)')
62
+ }
63
+ return out
64
+ }