@bull-js/cli 0.0.0-stage → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +39 -2
- package/dist/bin.d.ts +1 -0
- package/dist/bin.js +1578 -0
- package/dist/chunk-FHBUQ5I5.js +356 -0
- package/dist/chunk-KF3FEGRV.js +259 -0
- package/dist/create.d.ts +24 -0
- package/dist/create.js +13 -0
- package/dist/index.d.ts +42 -0
- package/dist/index.js +8 -0
- package/package.json +59 -4
- package/templates/default/_gitignore +3 -0
- package/templates/default/bull.config.ts +5 -0
- package/templates/default/eslint.config.js +3 -0
- package/templates/default/index.html +12 -0
- package/templates/default/package.json +29 -0
- package/templates/default/src/env.d.ts +1 -0
- package/templates/default/src/main.tsx +14 -0
- package/templates/default/src/model/counter.repository.ts +12 -0
- package/templates/default/src/styles/app.css +52 -0
- package/templates/default/src/view/bull-logo.view.tsx +15 -0
- package/templates/default/src/view/counter.view.tsx +26 -0
- package/templates/default/src/viewmodel/counter.viewmodel.test.ts +36 -0
- package/templates/default/src/viewmodel/counter.viewmodel.ts +26 -0
- package/templates/default/tsconfig.json +16 -0
package/dist/bin.js
ADDED
|
@@ -0,0 +1,1578 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
VERSION,
|
|
4
|
+
banner,
|
|
5
|
+
bullArt,
|
|
6
|
+
createCommand,
|
|
7
|
+
detectPackageManager,
|
|
8
|
+
ensureIndexHtml,
|
|
9
|
+
fail,
|
|
10
|
+
loadProject,
|
|
11
|
+
runAnalysis,
|
|
12
|
+
runScript,
|
|
13
|
+
viteConfig
|
|
14
|
+
} from "./chunk-KF3FEGRV.js";
|
|
15
|
+
import {
|
|
16
|
+
loadBullConfig
|
|
17
|
+
} from "./chunk-FHBUQ5I5.js";
|
|
18
|
+
|
|
19
|
+
// src/bin.ts
|
|
20
|
+
import { cac } from "cac";
|
|
21
|
+
import pc9 from "picocolors";
|
|
22
|
+
|
|
23
|
+
// src/commands/build.ts
|
|
24
|
+
import pc from "picocolors";
|
|
25
|
+
import { build as viteBuild } from "vite";
|
|
26
|
+
async function buildCommand(rootArg, options) {
|
|
27
|
+
banner("build");
|
|
28
|
+
const project = await loadProject(rootArg, "build", { mode: options.mode, configFile: options.config });
|
|
29
|
+
const result = runAnalysis(project);
|
|
30
|
+
if (result.errorCount > 0) {
|
|
31
|
+
console.log();
|
|
32
|
+
fail(
|
|
33
|
+
`Build cancelled: ${result.errorCount} MVVM architecture ${result.errorCount === 1 ? "violation" : "violations"}.`,
|
|
34
|
+
"Fix them and run `bull build` again."
|
|
35
|
+
);
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
if (!ensureIndexHtml(project.root)) return;
|
|
39
|
+
console.log();
|
|
40
|
+
await viteBuild(viteConfig(project, { build: { outDir: options.outDir } }));
|
|
41
|
+
console.log(`
|
|
42
|
+
${pc.green("\u2713")} ${pc.bold("Build ready")} ${pc.dim(`\u2192 ${options.outDir ?? "dist"}/`)}
|
|
43
|
+
`);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// src/commands/check.ts
|
|
47
|
+
import { serializeResult } from "@bull-js/core";
|
|
48
|
+
async function checkCommand(rootArg, options) {
|
|
49
|
+
const json = options.format === "json";
|
|
50
|
+
if (!json) banner("check");
|
|
51
|
+
const project = await loadProject(rootArg, "build");
|
|
52
|
+
if (json) {
|
|
53
|
+
const result2 = project.analyzer.analyze();
|
|
54
|
+
process.stdout.write(`${JSON.stringify(serializeResult(result2, { frames: false }), null, 2)}
|
|
55
|
+
`);
|
|
56
|
+
if (result2.errorCount > 0) process.exitCode = 1;
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
const result = runAnalysis(project);
|
|
60
|
+
if (result.errorCount > 0) process.exitCode = 1;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// src/commands/completion.ts
|
|
64
|
+
var SHELLS = ["bash", "zsh", "fish", "powershell"];
|
|
65
|
+
var GLOBAL_FLAGS = ["--help", "--version"];
|
|
66
|
+
function bash(commands, zsh) {
|
|
67
|
+
const names = commands.flatMap((c) => [c.name, ...c.aliases]).join(" ");
|
|
68
|
+
const cases = commands.map((c) => ` ${[c.name, ...c.aliases].join("|")}) flags="${[...c.flags, "--help"].join(" ")}" ;;`).join("\n");
|
|
69
|
+
return `# bull completions for ${zsh ? "zsh" : "bash"}. Add this line to your ~/.${zsh ? "zshrc" : "bashrc"}:
|
|
70
|
+
# eval "$(bull completion ${zsh ? "zsh" : "bash"})"
|
|
71
|
+
${zsh ? "autoload -U +X bashcompinit && bashcompinit\n" : ""}_bull_completions() {
|
|
72
|
+
local cur="\${COMP_WORDS[COMP_CWORD]}"
|
|
73
|
+
if [ "$COMP_CWORD" -eq 1 ]; then
|
|
74
|
+
COMPREPLY=( $(compgen -W "${names} ${GLOBAL_FLAGS.join(" ")}" -- "$cur") )
|
|
75
|
+
return
|
|
76
|
+
fi
|
|
77
|
+
local flags=""
|
|
78
|
+
case "\${COMP_WORDS[1]}" in
|
|
79
|
+
${cases}
|
|
80
|
+
esac
|
|
81
|
+
if [[ "$cur" == -* ]]; then
|
|
82
|
+
COMPREPLY=( $(compgen -W "$flags" -- "$cur") )
|
|
83
|
+
fi
|
|
84
|
+
}
|
|
85
|
+
complete -o default -F _bull_completions bull
|
|
86
|
+
`;
|
|
87
|
+
}
|
|
88
|
+
function fish(commands) {
|
|
89
|
+
const quote = (text) => text.replace(/'/g, "\\'");
|
|
90
|
+
const lines = commands.flatMap((c) => {
|
|
91
|
+
const names = [c.name, ...c.aliases];
|
|
92
|
+
return [
|
|
93
|
+
...names.map((n) => `complete -c bull -n __fish_use_subcommand -f -a ${n} -d '${quote(c.description)}'`),
|
|
94
|
+
...c.flags.map(
|
|
95
|
+
(f) => `complete -c bull -n '__fish_seen_subcommand_from ${names.join(" ")}' -l ${f.replace(/^--/, "")}`
|
|
96
|
+
)
|
|
97
|
+
];
|
|
98
|
+
});
|
|
99
|
+
return `# bull completions for fish. Save them with:
|
|
100
|
+
# bull completion fish > ~/.config/fish/completions/bull.fish
|
|
101
|
+
complete -c bull -l help -d 'Show help'
|
|
102
|
+
complete -c bull -l version -d 'Show the version'
|
|
103
|
+
${lines.join("\n")}
|
|
104
|
+
`;
|
|
105
|
+
}
|
|
106
|
+
function powershell(commands) {
|
|
107
|
+
const table = commands.map((c) => {
|
|
108
|
+
const flags = [...c.flags, "--help"].map((f) => `'${f}'`).join(", ");
|
|
109
|
+
return [c.name, ...c.aliases].map((n) => ` '${n}' = @(${flags})`).join("\n");
|
|
110
|
+
}).join("\n");
|
|
111
|
+
return `# bull completions for PowerShell. Add this line to your $PROFILE:
|
|
112
|
+
# bull completion powershell | Out-String | Invoke-Expression
|
|
113
|
+
$bullFlags = @{
|
|
114
|
+
${table}
|
|
115
|
+
}
|
|
116
|
+
Register-ArgumentCompleter -Native -CommandName bull -ScriptBlock {
|
|
117
|
+
param($wordToComplete, $commandAst, $cursorPosition)
|
|
118
|
+
$words = @($commandAst.CommandElements | Select-Object -Skip 1 | ForEach-Object { $_.ToString() })
|
|
119
|
+
if ($words.Count -eq 0 -or ($words.Count -eq 1 -and $wordToComplete)) {
|
|
120
|
+
$candidates = @($bullFlags.Keys) + @(${GLOBAL_FLAGS.map((f) => `'${f}'`).join(", ")})
|
|
121
|
+
} else {
|
|
122
|
+
$candidates = $bullFlags[$words[0]]
|
|
123
|
+
}
|
|
124
|
+
$candidates | Where-Object { $_ -like "$wordToComplete*" } | Sort-Object | ForEach-Object {
|
|
125
|
+
[System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_)
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
`;
|
|
129
|
+
}
|
|
130
|
+
function completionScript(shell, commands) {
|
|
131
|
+
switch (shell) {
|
|
132
|
+
case "bash":
|
|
133
|
+
return bash(commands, false);
|
|
134
|
+
case "zsh":
|
|
135
|
+
return bash(commands, true);
|
|
136
|
+
case "fish":
|
|
137
|
+
return fish(commands);
|
|
138
|
+
case "powershell":
|
|
139
|
+
return powershell(commands);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
function completionCommand(shellArg, commands) {
|
|
143
|
+
const shell = shellArg?.toLowerCase() === "pwsh" ? "powershell" : shellArg?.toLowerCase();
|
|
144
|
+
if (!SHELLS.includes(shell)) {
|
|
145
|
+
fail(
|
|
146
|
+
`Usage: bull completion <${SHELLS.join("|")}>`,
|
|
147
|
+
'Example: eval "$(bull completion bash)" in ~/.bashrc, or `bull completion powershell | Out-String | Invoke-Expression` in $PROFILE.'
|
|
148
|
+
);
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
process.stdout.write(completionScript(shell, commands));
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// src/commands/dev.ts
|
|
155
|
+
import fs from "fs";
|
|
156
|
+
import path from "path";
|
|
157
|
+
import { createServer, preview } from "vite";
|
|
158
|
+
async function devCommand(rootArg, options) {
|
|
159
|
+
banner("dev");
|
|
160
|
+
const project = await loadProject(rootArg, "serve", { mode: options.mode, configFile: options.config });
|
|
161
|
+
if (!ensureIndexHtml(project.root)) return;
|
|
162
|
+
const server = await createServer(
|
|
163
|
+
viteConfig(project, {
|
|
164
|
+
server: {
|
|
165
|
+
port: options.port ?? project.config.dev?.port,
|
|
166
|
+
open: options.open ?? project.config.dev?.open,
|
|
167
|
+
host: options.host
|
|
168
|
+
}
|
|
169
|
+
})
|
|
170
|
+
);
|
|
171
|
+
await server.listen();
|
|
172
|
+
server.printUrls();
|
|
173
|
+
server.bindCLIShortcuts({ print: true });
|
|
174
|
+
}
|
|
175
|
+
async function previewCommand(rootArg, options) {
|
|
176
|
+
banner("preview");
|
|
177
|
+
const project = await loadProject(rootArg, "build", { configFile: options.config });
|
|
178
|
+
const outDir = options.outDir ?? project.config.vite?.build?.outDir ?? "dist";
|
|
179
|
+
if (!fs.existsSync(path.resolve(project.root, outDir, "index.html"))) {
|
|
180
|
+
fail(`There is no build in ${outDir}/ to preview.`, "Run `bull build` first.");
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
const server = await preview(
|
|
184
|
+
viteConfig(project, {
|
|
185
|
+
build: { outDir },
|
|
186
|
+
preview: { port: options.port, open: options.open, host: options.host }
|
|
187
|
+
})
|
|
188
|
+
);
|
|
189
|
+
server.printUrls();
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// src/commands/explain.ts
|
|
193
|
+
import fs2 from "fs";
|
|
194
|
+
import path2 from "path";
|
|
195
|
+
import {
|
|
196
|
+
formatExplain,
|
|
197
|
+
formatRuleList,
|
|
198
|
+
RULE_LIST,
|
|
199
|
+
RULES,
|
|
200
|
+
ruleBySlug,
|
|
201
|
+
ruleToMarkdown
|
|
202
|
+
} from "@bull-js/core";
|
|
203
|
+
import pc2 from "picocolors";
|
|
204
|
+
function explainCommand(code, options) {
|
|
205
|
+
if (options.all && options.format === "md") {
|
|
206
|
+
const outDir = path2.resolve(options.out ?? "docs/rules");
|
|
207
|
+
fs2.mkdirSync(outDir, { recursive: true });
|
|
208
|
+
const index = [
|
|
209
|
+
"# Bull architecture rules",
|
|
210
|
+
"",
|
|
211
|
+
"| Code | Rule | Severity | Can be disabled |",
|
|
212
|
+
"|---|---|---|---|"
|
|
213
|
+
];
|
|
214
|
+
for (const meta2 of RULE_LIST) {
|
|
215
|
+
fs2.writeFileSync(path2.join(outDir, `${meta2.code}.md`), ruleToMarkdown(meta2));
|
|
216
|
+
index.push(
|
|
217
|
+
`| [${meta2.code}](./${meta2.code}.md) | ${meta2.summary} | ${meta2.severity === "error" ? "error" : "warning"} | ${meta2.configurable ? "yes" : "no"} |`
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
fs2.writeFileSync(path2.join(outDir, "README.md"), `${index.join("\n")}
|
|
221
|
+
`);
|
|
222
|
+
console.log(` ${pc2.green("\u2713")} ${RULE_LIST.length} rules documented in ${path2.relative(process.cwd(), outDir)}/`);
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
if (!code || options.all) {
|
|
226
|
+
process.stdout.write(formatRuleList());
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
const normalized = /^\d+$/.test(code) ? `MVVM${code.padStart(3, "0")}` : code.toUpperCase();
|
|
230
|
+
const meta = RULES[normalized] ?? ruleBySlug(code);
|
|
231
|
+
if (!meta) {
|
|
232
|
+
fail(`There is no rule "${code}".`, "See the full list with `bull explain`.");
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
process.stdout.write(options.format === "md" ? ruleToMarkdown(meta) : formatExplain(meta));
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// src/tooling/doctor.ts
|
|
239
|
+
import fs3 from "fs";
|
|
240
|
+
import path3 from "path";
|
|
241
|
+
import { fileURLToPath } from "url";
|
|
242
|
+
import { ProjectAnalyzer } from "@bull-js/core";
|
|
243
|
+
import pc3 from "picocolors";
|
|
244
|
+
function packageDir(from, name) {
|
|
245
|
+
let dir = path3.resolve(from);
|
|
246
|
+
for (; ; ) {
|
|
247
|
+
const candidate = path3.join(dir, "node_modules", name);
|
|
248
|
+
if (fs3.existsSync(path3.join(candidate, "package.json"))) return fs3.realpathSync.native(candidate);
|
|
249
|
+
const parent = path3.dirname(dir);
|
|
250
|
+
if (parent === dir) return void 0;
|
|
251
|
+
dir = parent;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
function versionOf(root, pkg) {
|
|
255
|
+
const dir = packageDir(root, pkg);
|
|
256
|
+
return dir ? JSON.parse(fs3.readFileSync(path3.join(dir, "package.json"), "utf8")).version : void 0;
|
|
257
|
+
}
|
|
258
|
+
function readJsonc(file2) {
|
|
259
|
+
try {
|
|
260
|
+
const text = fs3.readFileSync(file2, "utf8").replace(/\/\*[\s\S]*?\*\//g, "").replace(/(^|[^:"'])\/\/.*$/gm, "$1").replace(/,(\s*[}\]])/g, "$1");
|
|
261
|
+
return JSON.parse(text);
|
|
262
|
+
} catch {
|
|
263
|
+
return void 0;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
var minor = (v) => v.split(".").slice(0, 2).join(".");
|
|
267
|
+
async function runDoctor(root, nodeVersion = process.versions.node) {
|
|
268
|
+
const checks = [];
|
|
269
|
+
const add = (c) => checks.push(c);
|
|
270
|
+
const nodeMajor = Number(nodeVersion.split(".")[0]);
|
|
271
|
+
add(
|
|
272
|
+
nodeMajor >= 20 ? { status: "ok", title: `Node.js ${nodeVersion}` } : { status: "fail", title: `Node.js ${nodeVersion}`, hint: "Bull needs Node.js 20 or newer." }
|
|
273
|
+
);
|
|
274
|
+
if (!fs3.existsSync(path3.join(root, "package.json"))) {
|
|
275
|
+
add({
|
|
276
|
+
status: "fail",
|
|
277
|
+
title: "package.json not found",
|
|
278
|
+
hint: "Run `bull doctor` from your project root, or pass the path."
|
|
279
|
+
});
|
|
280
|
+
return checks;
|
|
281
|
+
}
|
|
282
|
+
const react = versionOf(root, "react");
|
|
283
|
+
const reactDom = versionOf(root, "react-dom");
|
|
284
|
+
if (!react || !reactDom) {
|
|
285
|
+
add({ status: "fail", title: "React is not installed", hint: "npm install react react-dom" });
|
|
286
|
+
} else if (minor(react) !== minor(reactDom)) {
|
|
287
|
+
add({
|
|
288
|
+
status: "fail",
|
|
289
|
+
title: `react ${react} and react-dom ${reactDom} do not match`,
|
|
290
|
+
hint: "Install the same version of both."
|
|
291
|
+
});
|
|
292
|
+
} else {
|
|
293
|
+
add({ status: "ok", title: `React ${react}` });
|
|
294
|
+
}
|
|
295
|
+
const runtimeDir = packageDir(root, "@bull-js/runtime");
|
|
296
|
+
if (!runtimeDir) {
|
|
297
|
+
add({ status: "fail", title: "@bull-js/runtime is not installed", hint: "npm install @bull-js/runtime" });
|
|
298
|
+
} else {
|
|
299
|
+
add({ status: "ok", title: `@bull-js/runtime ${versionOf(root, "@bull-js/runtime") ?? ""}`.trim() });
|
|
300
|
+
const fromApp = packageDir(root, "react");
|
|
301
|
+
const fromRuntime = packageDir(runtimeDir, "react");
|
|
302
|
+
if (fromApp && fromRuntime && fromApp !== fromRuntime) {
|
|
303
|
+
add({
|
|
304
|
+
status: "fail",
|
|
305
|
+
title: "Two copies of React are installed",
|
|
306
|
+
detail: `${path3.relative(root, fromApp)} and ${path3.relative(root, fromRuntime)}`,
|
|
307
|
+
hint: "Remove the nested copy (npm dedupe) so hooks share one React."
|
|
308
|
+
});
|
|
309
|
+
} else if (fromApp) {
|
|
310
|
+
add({ status: "ok", title: "A single copy of React" });
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
const vite = versionOf(path3.dirname(fileURLToPath(import.meta.url)), "vite");
|
|
314
|
+
add(
|
|
315
|
+
vite ? { status: "ok", title: `Vite ${vite}` } : { status: "warn", title: "Vite could not be resolved", hint: "Reinstall @bull-js/cli." }
|
|
316
|
+
);
|
|
317
|
+
let config = {};
|
|
318
|
+
try {
|
|
319
|
+
const loaded = await loadBullConfig(root, "serve");
|
|
320
|
+
config = loaded.config;
|
|
321
|
+
add({
|
|
322
|
+
status: "ok",
|
|
323
|
+
title: loaded.file ? `${path3.basename(loaded.file)} loads` : "No bull.config.ts (defaults are used)"
|
|
324
|
+
});
|
|
325
|
+
} catch (error) {
|
|
326
|
+
add({ status: "fail", title: "bull.config could not be loaded", detail: error.message.split("\n")[0] });
|
|
327
|
+
}
|
|
328
|
+
const analyzer = new ProjectAnalyzer(root, config);
|
|
329
|
+
const result = analyzer.analyze();
|
|
330
|
+
const configErrors = result.diagnostics.filter((d) => d.code === "MVVM900");
|
|
331
|
+
for (const d of configErrors) add({ status: "fail", title: "Invalid configuration", detail: d.message });
|
|
332
|
+
const indexHtml = path3.join(root, "index.html");
|
|
333
|
+
const entry = `/${path3.posix.relative(analyzer.config.root, analyzer.config.compositionRoot)}`;
|
|
334
|
+
if (!fs3.existsSync(indexHtml)) {
|
|
335
|
+
add({
|
|
336
|
+
status: "fail",
|
|
337
|
+
title: "index.html not found",
|
|
338
|
+
hint: `Add one with <script type="module" src="${entry}"></script>.`
|
|
339
|
+
});
|
|
340
|
+
} else if (!fs3.readFileSync(indexHtml, "utf8").includes(entry)) {
|
|
341
|
+
add({
|
|
342
|
+
status: "warn",
|
|
343
|
+
title: `index.html does not load ${entry}`,
|
|
344
|
+
hint: `Point its module script at the composition root (${entry}).`
|
|
345
|
+
});
|
|
346
|
+
} else {
|
|
347
|
+
add({ status: "ok", title: `index.html loads ${entry}` });
|
|
348
|
+
}
|
|
349
|
+
const tsconfig = readJsonc(path3.join(root, "tsconfig.json"));
|
|
350
|
+
if (!tsconfig) {
|
|
351
|
+
add({
|
|
352
|
+
status: "warn",
|
|
353
|
+
title: "tsconfig.json not found or unreadable",
|
|
354
|
+
hint: 'Use "jsx": "react-jsx" and "strict": true.'
|
|
355
|
+
});
|
|
356
|
+
} else {
|
|
357
|
+
const options = tsconfig.compilerOptions ?? {};
|
|
358
|
+
const problems = [
|
|
359
|
+
options.jsx !== "react-jsx" && options.jsx !== "react-jsxdev" ? '"jsx": "react-jsx"' : "",
|
|
360
|
+
options.strict !== true ? '"strict": true' : ""
|
|
361
|
+
].filter(Boolean);
|
|
362
|
+
add(
|
|
363
|
+
problems.length ? { status: "warn", title: "tsconfig.json could be stricter", hint: `Set ${problems.join(" and ")}.` } : { status: "ok", title: "tsconfig.json uses react-jsx and strict mode" }
|
|
364
|
+
);
|
|
365
|
+
}
|
|
366
|
+
const missing = result.diagnostics.filter((d) => d.code === "MVVM001").map((d) => d.data.dir);
|
|
367
|
+
add(
|
|
368
|
+
missing.length ? {
|
|
369
|
+
status: "fail",
|
|
370
|
+
title: `Missing layer folders: ${missing.join(", ")}`,
|
|
371
|
+
hint: "Create them, or run `bull g feature <name>`."
|
|
372
|
+
} : { status: "ok", title: "src/model, src/viewmodel and src/view exist" }
|
|
373
|
+
);
|
|
374
|
+
if (result.diagnostics.some((d) => d.code === "MVVM005")) {
|
|
375
|
+
add({
|
|
376
|
+
status: "fail",
|
|
377
|
+
title: "The composition root is missing",
|
|
378
|
+
hint: `Create ${entry.slice(1)} with createBullApp().`
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
const eslintConfig = ["eslint.config.js", "eslint.config.mjs", "eslint.config.cjs", "eslint.config.ts"].map((f) => path3.join(root, f)).find((f) => fs3.existsSync(f));
|
|
382
|
+
if (!eslintConfig) {
|
|
383
|
+
add({
|
|
384
|
+
status: "warn",
|
|
385
|
+
title: "No ESLint flat config",
|
|
386
|
+
hint: "Add @bull-js/eslint-plugin to see violations in your editor."
|
|
387
|
+
});
|
|
388
|
+
} else if (!fs3.readFileSync(eslintConfig, "utf8").includes("@bull-js/eslint-plugin")) {
|
|
389
|
+
add({
|
|
390
|
+
status: "warn",
|
|
391
|
+
title: `${path3.basename(eslintConfig)} does not use @bull-js/eslint-plugin`,
|
|
392
|
+
hint: "Spread bull.configs.recommended into it."
|
|
393
|
+
});
|
|
394
|
+
} else {
|
|
395
|
+
add({ status: "ok", title: "ESLint uses @bull-js/eslint-plugin" });
|
|
396
|
+
}
|
|
397
|
+
const architecture = result.diagnostics.filter((d) => d.code !== "MVVM900");
|
|
398
|
+
const errors = architecture.filter((d) => d.severity === "error").length;
|
|
399
|
+
add(
|
|
400
|
+
errors ? {
|
|
401
|
+
status: "fail",
|
|
402
|
+
title: `Architecture: ${errors} error(s)`,
|
|
403
|
+
hint: "Run `bull check` for details, or `bull fix` for the automatic ones."
|
|
404
|
+
} : architecture.length ? {
|
|
405
|
+
status: "warn",
|
|
406
|
+
title: `Architecture: ${architecture.length} warning(s)`,
|
|
407
|
+
hint: "Run `bull check` for details."
|
|
408
|
+
} : { status: "ok", title: "Architecture respects MVVM" }
|
|
409
|
+
);
|
|
410
|
+
return checks;
|
|
411
|
+
}
|
|
412
|
+
async function doctorCommand(rootArg) {
|
|
413
|
+
console.log(`
|
|
414
|
+
${bullArt("doctor")}
|
|
415
|
+
`);
|
|
416
|
+
const root = path3.resolve(rootArg ?? ".");
|
|
417
|
+
const checks = await runDoctor(root);
|
|
418
|
+
const icon = { ok: pc3.green("\u2713"), warn: pc3.yellow("!"), fail: pc3.red("\u2717") };
|
|
419
|
+
for (const c of checks) {
|
|
420
|
+
console.log(` ${icon[c.status]} ${c.title}${c.detail ? pc3.dim(` ${c.detail}`) : ""}`);
|
|
421
|
+
if (c.hint && c.status !== "ok") console.log(` ${pc3.dim(c.hint.replace(/`/g, ""))}`);
|
|
422
|
+
}
|
|
423
|
+
const fails = checks.filter((c) => c.status === "fail").length;
|
|
424
|
+
const warns = checks.filter((c) => c.status === "warn").length;
|
|
425
|
+
console.log(
|
|
426
|
+
`
|
|
427
|
+
${fails ? pc3.red(`${fails} problem(s)`) : pc3.green("Healthy")}${warns ? pc3.yellow(` \xB7 ${warns} suggestion(s)`) : ""}
|
|
428
|
+
`
|
|
429
|
+
);
|
|
430
|
+
if (fails) process.exitCode = 1;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// src/tooling/fix.ts
|
|
434
|
+
import fs4 from "fs";
|
|
435
|
+
import path4 from "path";
|
|
436
|
+
import {
|
|
437
|
+
fileEdges,
|
|
438
|
+
formatSummary,
|
|
439
|
+
isAutoFixable,
|
|
440
|
+
ProjectAnalyzer as ProjectAnalyzer2,
|
|
441
|
+
stripExtension,
|
|
442
|
+
toKebab
|
|
443
|
+
} from "@bull-js/core";
|
|
444
|
+
import pc4 from "picocolors";
|
|
445
|
+
function offsetAt(source, pos) {
|
|
446
|
+
let offset = 0;
|
|
447
|
+
for (let line = 1; line < pos.line; line++) {
|
|
448
|
+
const next = source.indexOf("\n", offset);
|
|
449
|
+
if (next < 0) return source.length;
|
|
450
|
+
offset = next + 1;
|
|
451
|
+
}
|
|
452
|
+
return offset + pos.column - 1;
|
|
453
|
+
}
|
|
454
|
+
function applyEdits(source, edits) {
|
|
455
|
+
let out = source;
|
|
456
|
+
let lastStart = Infinity;
|
|
457
|
+
for (const e of [...edits].sort((a, b) => b.start - a.start)) {
|
|
458
|
+
if (e.end > lastStart) continue;
|
|
459
|
+
out = out.slice(0, e.start) + e.text + out.slice(e.end);
|
|
460
|
+
lastStart = e.start;
|
|
461
|
+
}
|
|
462
|
+
return out;
|
|
463
|
+
}
|
|
464
|
+
var escapeRegExp = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
465
|
+
function planFixes(result) {
|
|
466
|
+
const { config } = result;
|
|
467
|
+
const rel = (f) => path4.posix.relative(config.root, f);
|
|
468
|
+
const sources = /* @__PURE__ */ new Map();
|
|
469
|
+
const source = (file2) => {
|
|
470
|
+
if (!sources.has(file2)) sources.set(file2, result.readSource(file2) ?? fs4.readFileSync(file2, "utf8"));
|
|
471
|
+
return sources.get(file2);
|
|
472
|
+
};
|
|
473
|
+
const edits = /* @__PURE__ */ new Map();
|
|
474
|
+
const addEdit = (file2, edit) => {
|
|
475
|
+
if (!edits.has(file2)) edits.set(file2, []);
|
|
476
|
+
edits.get(file2).push(edit);
|
|
477
|
+
};
|
|
478
|
+
const outcomes = [];
|
|
479
|
+
const renames = /* @__PURE__ */ new Map();
|
|
480
|
+
const done = (d, message, applied = true) => outcomes.push({ code: d.code, file: rel(d.file), message, applied });
|
|
481
|
+
for (const d of result.diagnostics) {
|
|
482
|
+
if (!d.file || !isAutoFixable(d)) continue;
|
|
483
|
+
const src = source(d.file);
|
|
484
|
+
if (d.code === "MVVM302" && d.range) {
|
|
485
|
+
const start = offsetAt(src, d.range.start);
|
|
486
|
+
const end = offsetAt(src, d.range.end);
|
|
487
|
+
const text = src.slice(start, end);
|
|
488
|
+
const name = d.data.member;
|
|
489
|
+
if (d.data.kind === "state") {
|
|
490
|
+
if (src.includes(`#${name}`)) {
|
|
491
|
+
done(d, `skipped: \`#${name}\` already exists`, false);
|
|
492
|
+
continue;
|
|
493
|
+
}
|
|
494
|
+
const init = /=\s*([\s\S]*?);?\s*$/.exec(text)?.[1];
|
|
495
|
+
if (!init) {
|
|
496
|
+
done(d, "skipped: could not read the initializer", false);
|
|
497
|
+
continue;
|
|
498
|
+
}
|
|
499
|
+
const lineStart = src.lastIndexOf("\n", start) + 1;
|
|
500
|
+
const indent = /^[ \t]*/.exec(src.slice(lineStart))[0];
|
|
501
|
+
addEdit(d.file, {
|
|
502
|
+
start,
|
|
503
|
+
end,
|
|
504
|
+
text: `readonly #${name} = ${init};
|
|
505
|
+
${indent}readonly ${name} = this.#${name}.readonly;`
|
|
506
|
+
});
|
|
507
|
+
for (const m of src.matchAll(new RegExp(`this\\.${escapeRegExp(name)}\\b`, "g"))) {
|
|
508
|
+
if (m.index >= start && m.index < end) continue;
|
|
509
|
+
addEdit(d.file, { start: m.index, end: m.index + m[0].length, text: `this.#${name}` });
|
|
510
|
+
}
|
|
511
|
+
done(d, `made \`${name}\` private and exposed \`${name}\` as a read-only view`);
|
|
512
|
+
} else if (d.data.kind === "mutable") {
|
|
513
|
+
const modifiers = /^((?:(?:public|override|declare)\s+)*)/.exec(text)[1];
|
|
514
|
+
addEdit(d.file, { start: start + modifiers.length, end: start + modifiers.length, text: "readonly " });
|
|
515
|
+
done(d, `marked \`${name}\` as readonly`);
|
|
516
|
+
} else if (d.data.kind === "param") {
|
|
517
|
+
const fixed = text.replace(/^(?:(?:public|private|protected|readonly)\s+)*/, "private readonly ");
|
|
518
|
+
addEdit(d.file, { start, end, text: fixed });
|
|
519
|
+
done(d, `made the dependency \`${name}\` private readonly`);
|
|
520
|
+
}
|
|
521
|
+
continue;
|
|
522
|
+
}
|
|
523
|
+
if (d.code === "MVVM100" && d.range) {
|
|
524
|
+
const vmName = d.data.viewModel;
|
|
525
|
+
const vmFile = path4.posix.join(
|
|
526
|
+
config.srcDir,
|
|
527
|
+
"viewmodel",
|
|
528
|
+
`${toKebab(vmName.replace(/ViewModel$/, ""))}.viewmodel.ts`
|
|
529
|
+
);
|
|
530
|
+
if (!fs4.existsSync(vmFile)) {
|
|
531
|
+
done(d, `skipped: there is no ${rel(vmFile)} to re-export the type from`, false);
|
|
532
|
+
continue;
|
|
533
|
+
}
|
|
534
|
+
const modelTarget = path4.posix.join(path4.posix.dirname(d.file), d.data.source);
|
|
535
|
+
let fromVm = path4.posix.relative(path4.posix.dirname(vmFile), modelTarget);
|
|
536
|
+
if (!fromVm.startsWith(".")) fromVm = `./${fromVm}`;
|
|
537
|
+
const names = d.data.imported;
|
|
538
|
+
const vmSource = source(vmFile);
|
|
539
|
+
if (!vmSource.includes(`export type { ${names} }`)) {
|
|
540
|
+
const lastImport = [...vmSource.matchAll(/^import[^;]*;[ \t]*$/gm)].at(-1);
|
|
541
|
+
const at = lastImport ? lastImport.index + lastImport[0].length + 1 : 0;
|
|
542
|
+
addEdit(vmFile, { start: at, end: at, text: `export type { ${names} } from "${fromVm}";
|
|
543
|
+
` });
|
|
544
|
+
}
|
|
545
|
+
let toVm = stripExtension(path4.posix.relative(path4.posix.dirname(d.file), vmFile));
|
|
546
|
+
if (!toVm.startsWith(".")) toVm = `./${toVm}`;
|
|
547
|
+
const start = offsetAt(src, d.range.start);
|
|
548
|
+
const end = offsetAt(src, d.range.end);
|
|
549
|
+
const statement = src.slice(start, end);
|
|
550
|
+
addEdit(d.file, { start, end, text: statement.replace(/(["'])[^"']+\1/, `$1${toVm}$1`) });
|
|
551
|
+
done(d, `the View now imports \`${names}\` from ${vmName}, which re-exports it`);
|
|
552
|
+
continue;
|
|
553
|
+
}
|
|
554
|
+
if (d.code === "MVVM003") {
|
|
555
|
+
const target = path4.posix.join(path4.posix.dirname(d.file), d.data.suggested);
|
|
556
|
+
if (fs4.existsSync(target) || [...renames.values()].includes(target)) {
|
|
557
|
+
done(d, `skipped: ${rel(target)} already exists`, false);
|
|
558
|
+
continue;
|
|
559
|
+
}
|
|
560
|
+
renames.set(d.file, target);
|
|
561
|
+
const oldSegment = stripExtension(path4.posix.basename(d.file));
|
|
562
|
+
const newSegment = stripExtension(path4.posix.basename(target));
|
|
563
|
+
let updated = 0;
|
|
564
|
+
for (const e of fileEdges(config, result.files)) {
|
|
565
|
+
if (e.to !== d.file) continue;
|
|
566
|
+
const importer = source(e.from);
|
|
567
|
+
const at = offsetAt(importer, e.range.start);
|
|
568
|
+
const statement = importer.slice(at, offsetAt(importer, e.range.end));
|
|
569
|
+
const quoted = new RegExp(`(["'])([^"']*?)${escapeRegExp(oldSegment)}(\\.[jt]sx?)?\\1`);
|
|
570
|
+
const match = quoted.exec(statement);
|
|
571
|
+
if (!match) continue;
|
|
572
|
+
const replacement = `${match[1]}${match[2]}${newSegment}${match[3] && d.data.suggested.endsWith(match[3]) ? match[3] : ""}${match[1]}`;
|
|
573
|
+
addEdit(e.from, { start: at + match.index, end: at + match.index + match[0].length, text: replacement });
|
|
574
|
+
updated++;
|
|
575
|
+
}
|
|
576
|
+
done(d, `renamed to ${rel(target)}${updated ? ` and updated ${updated} import${updated === 1 ? "" : "s"}` : ""}`);
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
const contents = /* @__PURE__ */ new Map();
|
|
580
|
+
for (const [file2, list] of edits) contents.set(file2, applyEdits(source(file2), list));
|
|
581
|
+
return { outcomes, contents, renames };
|
|
582
|
+
}
|
|
583
|
+
function applyFixPlan(plan) {
|
|
584
|
+
for (const [file2, content] of plan.contents) fs4.writeFileSync(file2, content);
|
|
585
|
+
for (const [from, to] of plan.renames) fs4.renameSync(from, to);
|
|
586
|
+
}
|
|
587
|
+
function miniDiff(before, after, colors = pc4.isColorSupported) {
|
|
588
|
+
const c = pc4.createColors(colors);
|
|
589
|
+
const a = before.split("\n");
|
|
590
|
+
const b = after.split("\n");
|
|
591
|
+
const lcs = Array.from({ length: a.length + 1 }, () => new Array(b.length + 1).fill(0));
|
|
592
|
+
for (let i2 = a.length - 1; i2 >= 0; i2--) {
|
|
593
|
+
for (let j2 = b.length - 1; j2 >= 0; j2--) {
|
|
594
|
+
lcs[i2][j2] = a[i2] === b[j2] ? lcs[i2 + 1][j2 + 1] + 1 : Math.max(lcs[i2 + 1][j2], lcs[i2][j2 + 1]);
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
const out = [];
|
|
598
|
+
let i = 0;
|
|
599
|
+
let j = 0;
|
|
600
|
+
let inHunk = false;
|
|
601
|
+
while (i < a.length || j < b.length) {
|
|
602
|
+
if (i < a.length && j < b.length && a[i] === b[j]) {
|
|
603
|
+
i++;
|
|
604
|
+
j++;
|
|
605
|
+
inHunk = false;
|
|
606
|
+
continue;
|
|
607
|
+
}
|
|
608
|
+
if (!inHunk) {
|
|
609
|
+
out.push(c.cyan(`@@ line ${j + 1} @@`));
|
|
610
|
+
inHunk = true;
|
|
611
|
+
}
|
|
612
|
+
if (i < a.length && (j >= b.length || lcs[i + 1][j] >= lcs[i][j + 1])) out.push(c.red(`- ${a[i++]}`));
|
|
613
|
+
else out.push(c.green(`+ ${b[j++]}`));
|
|
614
|
+
}
|
|
615
|
+
return out.map((l) => ` ${l}`).join("\n");
|
|
616
|
+
}
|
|
617
|
+
async function fixCommand(rootArg, options) {
|
|
618
|
+
banner(options.dryRun ? "fix (dry run)" : "fix");
|
|
619
|
+
const root = path4.resolve(rootArg ?? ".");
|
|
620
|
+
const { config } = await loadBullConfig(root, "serve");
|
|
621
|
+
const analyzer = new ProjectAnalyzer2(root, config);
|
|
622
|
+
const before = analyzer.analyze();
|
|
623
|
+
const plan = planFixes(before);
|
|
624
|
+
const rel = (f) => path4.posix.relative(analyzer.config.root, f);
|
|
625
|
+
if (plan.outcomes.length === 0) {
|
|
626
|
+
const fixable = before.diagnostics.length;
|
|
627
|
+
console.log(
|
|
628
|
+
fixable ? ` ${pc4.dim("Nothing to fix automatically.")} ${fixable} diagnostic(s) need a human: run ${pc4.cyan("bull check")}.` : ` ${pc4.green("\u2713")} Nothing to fix: the architecture is clean.`
|
|
629
|
+
);
|
|
630
|
+
return;
|
|
631
|
+
}
|
|
632
|
+
for (const o of plan.outcomes) {
|
|
633
|
+
const mark = o.applied ? pc4.green(options.dryRun ? "\u25CB" : "\u2714") : pc4.yellow("\xB7");
|
|
634
|
+
console.log(` ${mark} ${pc4.dim(o.code)} ${o.file} ${o.applied ? o.message : pc4.yellow(o.message)}`);
|
|
635
|
+
}
|
|
636
|
+
if (options.dryRun) {
|
|
637
|
+
console.log();
|
|
638
|
+
for (const [file2, content] of plan.contents) {
|
|
639
|
+
const original = before.readSource(file2) ?? fs4.readFileSync(file2, "utf8");
|
|
640
|
+
console.log(
|
|
641
|
+
` ${pc4.bold(rel(file2))}${plan.renames.has(file2) ? pc4.dim(` \u2192 ${rel(plan.renames.get(file2))}`) : ""}`
|
|
642
|
+
);
|
|
643
|
+
console.log(`${miniDiff(original, content)}
|
|
644
|
+
`);
|
|
645
|
+
}
|
|
646
|
+
for (const [from, to] of plan.renames) {
|
|
647
|
+
if (!plan.contents.has(from)) console.log(` ${pc4.bold(rel(from))} ${pc4.dim("\u2192")} ${rel(to)}
|
|
648
|
+
`);
|
|
649
|
+
}
|
|
650
|
+
console.log(` ${pc4.dim("Dry run: nothing was written. Run")} ${pc4.cyan("bull fix")} ${pc4.dim("to apply.")}`);
|
|
651
|
+
return;
|
|
652
|
+
}
|
|
653
|
+
applyFixPlan(plan);
|
|
654
|
+
analyzer.invalidate();
|
|
655
|
+
const after = analyzer.analyze();
|
|
656
|
+
console.log(`
|
|
657
|
+
${formatSummary(after)}`);
|
|
658
|
+
if (after.diagnostics.length)
|
|
659
|
+
console.log(`
|
|
660
|
+
${pc4.dim("Run")} ${pc4.cyan("bull check")} ${pc4.dim("for the details of what is left.")}`);
|
|
661
|
+
if (after.errorCount > 0) process.exitCode = 1;
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
// src/tooling/generate.ts
|
|
665
|
+
import fs5 from "fs";
|
|
666
|
+
import { createRequire } from "module";
|
|
667
|
+
import path5 from "path";
|
|
668
|
+
import { formatReport, ProjectAnalyzer as ProjectAnalyzer3, toKebab as toKebab2, toPascalCase } from "@bull-js/core";
|
|
669
|
+
import pc5 from "picocolors";
|
|
670
|
+
function normalizeName(raw) {
|
|
671
|
+
const stem = raw.trim().replace(/\.(tsx?|jsx?)$/, "").replace(/[.\-_\s](viewmodel|view|model|repository|service|entity)$/i, "").replace(/(?<=[a-z0-9])(ViewModel|View|Model|Repository|Service|Entity)$/, "");
|
|
672
|
+
const kebab = toKebab2(stem.replace(/\s+/g, "-")).replace(/[^a-z0-9-]/g, "").replace(/-+/g, "-").replace(/^-|-$/g, "");
|
|
673
|
+
if (!kebab) throw new Error(`"${raw}" is not a valid name. Use something like "todo-list" or "TodoList".`);
|
|
674
|
+
return { kebab, pascal: toPascalCase(kebab) };
|
|
675
|
+
}
|
|
676
|
+
var templates = {
|
|
677
|
+
model: (n) => `// Model: plain domain types and functions. No React, no reactive state.
|
|
678
|
+
export interface ${n.pascal} {
|
|
679
|
+
readonly id: string;
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
export function create${n.pascal}(id: string): ${n.pascal} {
|
|
683
|
+
return { id };
|
|
684
|
+
}
|
|
685
|
+
`,
|
|
686
|
+
entity: (n) => `export interface ${n.pascal} {
|
|
687
|
+
readonly id: string;
|
|
688
|
+
readonly createdAt: number;
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
export function create${n.pascal}(id: string, now = Date.now()): ${n.pascal} {
|
|
692
|
+
return { id, createdAt: now };
|
|
693
|
+
}
|
|
694
|
+
`,
|
|
695
|
+
repository: (n) => `// Model: the only layer that does I/O. Swap the in-memory list for fetch, storage or an API.
|
|
696
|
+
export class ${n.pascal}Repository {
|
|
697
|
+
#items: string[] = [];
|
|
698
|
+
|
|
699
|
+
async list(): Promise<string[]> {
|
|
700
|
+
return [...this.#items];
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
async add(item: string): Promise<void> {
|
|
704
|
+
this.#items.push(item);
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
`,
|
|
708
|
+
service: (n) => `// Model: a domain service. Inject it into ViewModels through their constructor.
|
|
709
|
+
export class ${n.pascal}Service {
|
|
710
|
+
run(input: string): string {
|
|
711
|
+
return input;
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
`,
|
|
715
|
+
viewmodel: (n) => `import { ViewModel } from "@bull-js/runtime";
|
|
716
|
+
|
|
717
|
+
export class ${n.pascal}ViewModel extends ViewModel {
|
|
718
|
+
readonly #items = this.state<string[]>([]);
|
|
719
|
+
|
|
720
|
+
readonly items = this.#items.readonly;
|
|
721
|
+
readonly isEmpty = this.computed(() => this.#items.value.length === 0);
|
|
722
|
+
|
|
723
|
+
readonly add = this.command((item: string) => this.#items.update((items) => [...items, item]));
|
|
724
|
+
}
|
|
725
|
+
`,
|
|
726
|
+
viewmodelWithRepository: (n) => `import { ViewModel } from "@bull-js/runtime";
|
|
727
|
+
import type { ${n.pascal}Repository } from "../model/${n.kebab}.repository";
|
|
728
|
+
|
|
729
|
+
export class ${n.pascal}ViewModel extends ViewModel {
|
|
730
|
+
readonly #items = this.state<string[]>([]);
|
|
731
|
+
|
|
732
|
+
readonly items = this.#items.readonly;
|
|
733
|
+
readonly isEmpty = this.computed(() => this.#items.value.length === 0);
|
|
734
|
+
|
|
735
|
+
readonly load = this.command(async () => this.#items.set(await this.repo.list()));
|
|
736
|
+
readonly add = this.command(async (item: string) => {
|
|
737
|
+
await this.repo.add(item);
|
|
738
|
+
this.#items.update((items) => [...items, item]);
|
|
739
|
+
});
|
|
740
|
+
|
|
741
|
+
constructor(private readonly repo: ${n.pascal}Repository) {
|
|
742
|
+
super();
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
protected override onActivate() {
|
|
746
|
+
void this.load.execute();
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
`,
|
|
750
|
+
viewmodelTest: (n, withRepository) => `// ViewModels are plain classes: test them without React or a DOM.
|
|
751
|
+
import { describe, expect, it } from "vitest";
|
|
752
|
+
${withRepository ? `import { ${n.pascal}Repository } from "../model/${n.kebab}.repository";
|
|
753
|
+
` : ""}import { ${n.pascal}ViewModel } from "./${n.kebab}.viewmodel";
|
|
754
|
+
|
|
755
|
+
describe("${n.pascal}ViewModel", () => {
|
|
756
|
+
it("adds items", async () => {
|
|
757
|
+
const vm = new ${n.pascal}ViewModel(${withRepository ? `new ${n.pascal}Repository()` : ""});
|
|
758
|
+
${withRepository ? "await " : ""}vm.add.execute("first");
|
|
759
|
+
expect(vm.items.value).toEqual(["first"]);
|
|
760
|
+
expect(vm.isEmpty.value).toBe(false);
|
|
761
|
+
});
|
|
762
|
+
});
|
|
763
|
+
`,
|
|
764
|
+
boundView: (n) => `import { useBinding, useViewModel } from "@bull-js/runtime/react";
|
|
765
|
+
import { ${n.pascal}ViewModel } from "../viewmodel/${n.kebab}.viewmodel";
|
|
766
|
+
|
|
767
|
+
export function ${n.pascal}View() {
|
|
768
|
+
const vm = useViewModel(${n.pascal}ViewModel);
|
|
769
|
+
const items = useBinding(vm.items);
|
|
770
|
+
const isEmpty = useBinding(vm.isEmpty);
|
|
771
|
+
|
|
772
|
+
return (
|
|
773
|
+
<section>
|
|
774
|
+
<h2>${n.pascal}</h2>
|
|
775
|
+
{isEmpty ? (
|
|
776
|
+
<p>Nothing here yet.</p>
|
|
777
|
+
) : (
|
|
778
|
+
<ul>
|
|
779
|
+
{items.map((item) => (
|
|
780
|
+
<li key={item}>{item}</li>
|
|
781
|
+
))}
|
|
782
|
+
</ul>
|
|
783
|
+
)}
|
|
784
|
+
<button onClick={() => vm.add.execute(\`Item \${items.length + 1}\`)}>Add</button>
|
|
785
|
+
</section>
|
|
786
|
+
);
|
|
787
|
+
}
|
|
788
|
+
`,
|
|
789
|
+
existingViewModelView: (n) => `import { useViewModel } from "@bull-js/runtime/react";
|
|
790
|
+
import { ${n.pascal}ViewModel } from "../viewmodel/${n.kebab}.viewmodel";
|
|
791
|
+
|
|
792
|
+
export function ${n.pascal}View() {
|
|
793
|
+
const vm = useViewModel(${n.pascal}ViewModel);
|
|
794
|
+
// Read with useBinding(vm.something) and act with vm.someCommand.execute().
|
|
795
|
+
return <section data-viewmodel={vm.constructor.name}>${n.pascal}</section>;
|
|
796
|
+
}
|
|
797
|
+
`,
|
|
798
|
+
presentationalView: (n) => `/** Presentational View: receives data and callbacks through props, owns no state. */
|
|
799
|
+
export function ${n.pascal}View(props: { title: string }) {
|
|
800
|
+
return (
|
|
801
|
+
<section>
|
|
802
|
+
<h2>{props.title}</h2>
|
|
803
|
+
</section>
|
|
804
|
+
);
|
|
805
|
+
}
|
|
806
|
+
`
|
|
807
|
+
};
|
|
808
|
+
function file(target, content) {
|
|
809
|
+
return fs5.existsSync(target) ? { path: target, content, action: "skip", note: "already exists" } : { path: target, content, action: "create" };
|
|
810
|
+
}
|
|
811
|
+
function planGenerate(type, rawName, options) {
|
|
812
|
+
const n = normalizeName(rawName);
|
|
813
|
+
const dir = (layer) => path5.join(options.srcDir, layer);
|
|
814
|
+
const files = [];
|
|
815
|
+
const hints = [];
|
|
816
|
+
const wantTest = options.test ?? hasVitest(options.root);
|
|
817
|
+
if (type === "model") {
|
|
818
|
+
const kind = options.kind ?? "model";
|
|
819
|
+
if (!(kind in templates))
|
|
820
|
+
throw new Error(`Unknown model kind "${kind}". Use model, entity, repository or service.`);
|
|
821
|
+
files.push(file(path5.join(dir("model"), `${n.kebab}.${kind}.ts`), templates[kind](n)));
|
|
822
|
+
if (kind === "repository" || kind === "service") {
|
|
823
|
+
const cls = `${n.pascal}${kind === "repository" ? "Repository" : "Service"}`;
|
|
824
|
+
hints.push(
|
|
825
|
+
`Inject \`${cls}\` into a ViewModel constructor and register it: \`c.model(${cls}, () => new ${cls}())\`.`
|
|
826
|
+
);
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
if (type === "viewmodel" || type === "feature") {
|
|
830
|
+
const withRepository = type === "feature";
|
|
831
|
+
if (withRepository) files.push(file(path5.join(dir("model"), `${n.kebab}.repository.ts`), templates.repository(n)));
|
|
832
|
+
files.push(
|
|
833
|
+
file(
|
|
834
|
+
path5.join(dir("viewmodel"), `${n.kebab}.viewmodel.ts`),
|
|
835
|
+
withRepository ? templates.viewmodelWithRepository(n) : templates.viewmodel(n)
|
|
836
|
+
)
|
|
837
|
+
);
|
|
838
|
+
if (wantTest) {
|
|
839
|
+
files.push(
|
|
840
|
+
file(path5.join(dir("viewmodel"), `${n.kebab}.viewmodel.test.ts`), templates.viewmodelTest(n, withRepository))
|
|
841
|
+
);
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
if (type === "view" || type === "feature") {
|
|
845
|
+
const vmFile = path5.join(dir("viewmodel"), `${n.kebab}.viewmodel.ts`);
|
|
846
|
+
const content = type === "feature" ? templates.boundView(n) : fs5.existsSync(vmFile) ? templates.existingViewModelView(n) : templates.presentationalView(n);
|
|
847
|
+
files.push(file(path5.join(dir("view"), `${n.kebab}.view.tsx`), content));
|
|
848
|
+
hints.push(`Render \`<${n.pascal}View />\` from another View (for example your root View).`);
|
|
849
|
+
}
|
|
850
|
+
if (type === "feature") {
|
|
851
|
+
const registration = registerInCompositionRoot(options, n);
|
|
852
|
+
if (registration) files.push(registration);
|
|
853
|
+
else {
|
|
854
|
+
hints.push(
|
|
855
|
+
`Register the feature in your composition root:
|
|
856
|
+
c.model(${n.pascal}Repository, () => new ${n.pascal}Repository());
|
|
857
|
+
c.viewModel(${n.pascal}ViewModel, (c) => new ${n.pascal}ViewModel(c.get(${n.pascal}Repository)));`
|
|
858
|
+
);
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
return { files, hints };
|
|
862
|
+
}
|
|
863
|
+
function hasVitest(root) {
|
|
864
|
+
try {
|
|
865
|
+
createRequire(path5.join(root, "package.json")).resolve("vitest/package.json");
|
|
866
|
+
return true;
|
|
867
|
+
} catch {
|
|
868
|
+
return false;
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
function registerInCompositionRoot(options, n) {
|
|
872
|
+
const rootFile = options.compositionRoot;
|
|
873
|
+
if (!fs5.existsSync(rootFile)) return void 0;
|
|
874
|
+
const source = fs5.readFileSync(rootFile, "utf8");
|
|
875
|
+
if (source.includes(`${n.pascal}ViewModel`)) return void 0;
|
|
876
|
+
const register = /register\s*:\s*\(?\s*(\w+)\s*\)?\s*=>\s*\{\r?\n?/.exec(source);
|
|
877
|
+
if (!register) return void 0;
|
|
878
|
+
const param = register[1];
|
|
879
|
+
const after = source.slice(register.index + register[0].length);
|
|
880
|
+
const indent = /^([ \t]*)\S/.exec(after)?.[1] || " ";
|
|
881
|
+
const rel = (layer, fileName) => "./" + path5.posix.relative(path5.posix.dirname(toPosix(rootFile)), toPosix(path5.join(options.srcDir, layer, fileName))).replace(/\.tsx?$/, "");
|
|
882
|
+
const imports = `import { ${n.pascal}Repository } from "${rel("model", `${n.kebab}.repository.ts`)}";
|
|
883
|
+
import { ${n.pascal}ViewModel } from "${rel("viewmodel", `${n.kebab}.viewmodel.ts`)}";
|
|
884
|
+
`;
|
|
885
|
+
const lines = `${indent}${param}.model(${n.pascal}Repository, () => new ${n.pascal}Repository());
|
|
886
|
+
${indent}${param}.viewModel(${n.pascal}ViewModel, (${param}) => new ${n.pascal}ViewModel(${param}.get(${n.pascal}Repository)));
|
|
887
|
+
`;
|
|
888
|
+
const open = source.indexOf("{", register.index + register[0].trimEnd().length - 1);
|
|
889
|
+
let depth = 0;
|
|
890
|
+
let close = -1;
|
|
891
|
+
for (let i = open; i < source.length; i++) {
|
|
892
|
+
if (source[i] === "{") depth++;
|
|
893
|
+
else if (source[i] === "}" && --depth === 0) {
|
|
894
|
+
close = i;
|
|
895
|
+
break;
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
if (close < 0) return void 0;
|
|
899
|
+
const insertAt = source.lastIndexOf("\n", close) + 1;
|
|
900
|
+
let updated = source.slice(0, insertAt) + lines + source.slice(insertAt);
|
|
901
|
+
const lastImport = [...updated.matchAll(/^import[^;]*;[ \t]*$/gm)].at(-1);
|
|
902
|
+
const importAt = lastImport ? lastImport.index + lastImport[0].length + 1 : 0;
|
|
903
|
+
updated = updated.slice(0, importAt) + imports + updated.slice(importAt);
|
|
904
|
+
return { path: rootFile, content: updated, action: "update", note: "registered the Model and ViewModel" };
|
|
905
|
+
}
|
|
906
|
+
function toPosix(p) {
|
|
907
|
+
return p.replace(/\\/g, "/");
|
|
908
|
+
}
|
|
909
|
+
var TYPES = {
|
|
910
|
+
model: "model",
|
|
911
|
+
m: "model",
|
|
912
|
+
viewmodel: "viewmodel",
|
|
913
|
+
vm: "viewmodel",
|
|
914
|
+
view: "view",
|
|
915
|
+
v: "view",
|
|
916
|
+
feature: "feature",
|
|
917
|
+
f: "feature"
|
|
918
|
+
};
|
|
919
|
+
async function generateCommand(typeArg, name, options) {
|
|
920
|
+
banner(options.dryRun ? "generate (dry run)" : "generate");
|
|
921
|
+
const type = TYPES[typeArg?.toLowerCase()];
|
|
922
|
+
if (!type || !name) {
|
|
923
|
+
fail(
|
|
924
|
+
"Usage: bull generate <feature|model|viewmodel|view> <name>",
|
|
925
|
+
"Example: `bull g feature todo-list` creates a repository, a ViewModel, its test and a View."
|
|
926
|
+
);
|
|
927
|
+
return;
|
|
928
|
+
}
|
|
929
|
+
const root = path5.resolve(options.root ?? ".");
|
|
930
|
+
const { config } = await loadBullConfig(root, "serve");
|
|
931
|
+
const analyzer = new ProjectAnalyzer3(root, config);
|
|
932
|
+
let plan;
|
|
933
|
+
try {
|
|
934
|
+
plan = planGenerate(type, name, {
|
|
935
|
+
root,
|
|
936
|
+
srcDir: analyzer.config.srcDir,
|
|
937
|
+
compositionRoot: analyzer.config.compositionRoot,
|
|
938
|
+
kind: options.kind,
|
|
939
|
+
test: options.test
|
|
940
|
+
});
|
|
941
|
+
} catch (error) {
|
|
942
|
+
fail(error.message);
|
|
943
|
+
return;
|
|
944
|
+
}
|
|
945
|
+
for (const f of plan.files) {
|
|
946
|
+
const rel = path5.relative(root, f.path).replace(/\\/g, "/");
|
|
947
|
+
if (f.action === "skip") {
|
|
948
|
+
console.log(` ${pc5.yellow("\xB7")} ${rel} ${pc5.dim(`(${f.note}, left untouched)`)}`);
|
|
949
|
+
continue;
|
|
950
|
+
}
|
|
951
|
+
if (!options.dryRun) {
|
|
952
|
+
fs5.mkdirSync(path5.dirname(f.path), { recursive: true });
|
|
953
|
+
fs5.writeFileSync(f.path, f.content);
|
|
954
|
+
}
|
|
955
|
+
const mark = f.action === "create" ? pc5.green("+") : pc5.cyan("~");
|
|
956
|
+
const verb = options.dryRun ? f.action === "create" ? "would create" : "would update" : f.action === "create" ? "" : f.note ?? "updated";
|
|
957
|
+
console.log(` ${mark} ${rel}${verb ? pc5.dim(` ${verb}`) : ""}`);
|
|
958
|
+
}
|
|
959
|
+
for (const hint of plan.hints)
|
|
960
|
+
console.log(`
|
|
961
|
+
${pc5.cyan("\u2192")} ${hint.replace(/`([^`]+)`/g, (_, c) => pc5.cyan(c))}`);
|
|
962
|
+
if (options.dryRun) return;
|
|
963
|
+
const result = analyzer.analyze();
|
|
964
|
+
console.log(`
|
|
965
|
+
${formatReport(result, { width: Math.min(process.stdout.columns || 88, 100) })}`);
|
|
966
|
+
if (result.errorCount > 0) process.exitCode = 1;
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
// src/tooling/graph.ts
|
|
970
|
+
import { exec } from "child_process";
|
|
971
|
+
import fs6 from "fs";
|
|
972
|
+
import path6 from "path";
|
|
973
|
+
import { buildImportGraph, LAYER_LABEL, ProjectAnalyzer as ProjectAnalyzer4 } from "@bull-js/core";
|
|
974
|
+
import pc6 from "picocolors";
|
|
975
|
+
var COLUMNS = ["root", "view", "viewmodel", "model", "allowed", "outside"];
|
|
976
|
+
var COLUMN_LABEL = {
|
|
977
|
+
root: "Composition root",
|
|
978
|
+
view: "View",
|
|
979
|
+
viewmodel: "ViewModel",
|
|
980
|
+
model: "Model",
|
|
981
|
+
allowed: "Resources",
|
|
982
|
+
outside: "Outside the layers"
|
|
983
|
+
};
|
|
984
|
+
function sortedNodes(graph) {
|
|
985
|
+
return [...graph.nodes].sort(
|
|
986
|
+
(a, b) => COLUMNS.indexOf(a.kind) - COLUMNS.indexOf(b.kind) || a.rel.localeCompare(b.rel)
|
|
987
|
+
);
|
|
988
|
+
}
|
|
989
|
+
function layerEdgeCounts(graph) {
|
|
990
|
+
const kindOf = new Map(graph.nodes.map((n) => [n.file, n.kind]));
|
|
991
|
+
const counts = /* @__PURE__ */ new Map();
|
|
992
|
+
for (const e of graph.edges) {
|
|
993
|
+
const from = kindOf.get(e.from);
|
|
994
|
+
const to = kindOf.get(e.to);
|
|
995
|
+
const key = `${from}\u2192${to}`;
|
|
996
|
+
const entry = counts.get(key) ?? { from, to, count: 0, violation: e.violation };
|
|
997
|
+
entry.count++;
|
|
998
|
+
counts.set(key, entry);
|
|
999
|
+
}
|
|
1000
|
+
return [...counts.values()].sort(
|
|
1001
|
+
(a, b) => COLUMNS.indexOf(a.from) - COLUMNS.indexOf(b.from) || COLUMNS.indexOf(a.to) - COLUMNS.indexOf(b.to)
|
|
1002
|
+
);
|
|
1003
|
+
}
|
|
1004
|
+
function renderTree(graph, colors = pc6.isColorSupported) {
|
|
1005
|
+
const c = pc6.createColors(colors);
|
|
1006
|
+
const relOf = new Map(graph.nodes.map((n) => [n.file, n.rel]));
|
|
1007
|
+
const out = [];
|
|
1008
|
+
let column;
|
|
1009
|
+
for (const node of sortedNodes(graph)) {
|
|
1010
|
+
if (node.kind !== column) {
|
|
1011
|
+
column = node.kind;
|
|
1012
|
+
out.push(` ${c.bold(COLUMN_LABEL[column] ?? column)}`);
|
|
1013
|
+
}
|
|
1014
|
+
out.push(` ${node.rel}`);
|
|
1015
|
+
for (const e of graph.edges.filter((edge) => edge.from === node.file)) {
|
|
1016
|
+
const target = relOf.get(e.to) ?? e.to;
|
|
1017
|
+
const type = e.typeOnly ? c.dim(" (type)") : "";
|
|
1018
|
+
if (e.violation) out.push(` ${c.red(`\u2717 \u2192 ${target}`)}${type} ${c.red(e.violation)}`);
|
|
1019
|
+
else if (e.inCycle) out.push(` ${c.yellow(`\u21BB \u2192 ${target}`)}${type} ${c.yellow("MVVM105")}`);
|
|
1020
|
+
else out.push(` ${c.dim("\u2192")} ${target}${type}`);
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
out.push("");
|
|
1024
|
+
out.push(` ${c.bold("Layer edges")}`);
|
|
1025
|
+
for (const e of layerEdgeCounts(graph)) {
|
|
1026
|
+
const label = `${LAYER_LABEL[e.from]} \u2192 ${LAYER_LABEL[e.to]}`.padEnd(36);
|
|
1027
|
+
const line = ` ${label} ${String(e.count).padStart(3)}`;
|
|
1028
|
+
out.push(e.violation ? c.red(`${line} \u2717 ${e.violation}`) : line);
|
|
1029
|
+
}
|
|
1030
|
+
const violations = graph.edges.filter((e) => e.violation).length;
|
|
1031
|
+
out.push("");
|
|
1032
|
+
out.push(
|
|
1033
|
+
violations || graph.cycles.length ? ` ${c.red("\u2717")} ${violations} edge(s) break the dependency direction, ${graph.cycles.length} cycle(s)` : ` ${c.green("\u2713")} Every edge follows View \u2500\u2500\u25B6 ViewModel \u2500\u2500\u25B6 Model`
|
|
1034
|
+
);
|
|
1035
|
+
return `${out.join("\n")}
|
|
1036
|
+
`;
|
|
1037
|
+
}
|
|
1038
|
+
function renderMermaid(graph) {
|
|
1039
|
+
const nodes = sortedNodes(graph);
|
|
1040
|
+
const id = new Map(nodes.map((n, i) => [n.file, `n${i}`]));
|
|
1041
|
+
const out = ["flowchart LR"];
|
|
1042
|
+
for (const kind of COLUMNS) {
|
|
1043
|
+
const members = nodes.filter((n) => n.kind === kind);
|
|
1044
|
+
if (!members.length) continue;
|
|
1045
|
+
out.push(` subgraph layer_${kind}["${COLUMN_LABEL[kind]}"]`);
|
|
1046
|
+
for (const n of members) out.push(` ${id.get(n.file)}["${path6.posix.basename(n.rel)}"]`);
|
|
1047
|
+
out.push(" end");
|
|
1048
|
+
}
|
|
1049
|
+
const styles = [];
|
|
1050
|
+
graph.edges.forEach((e, i) => {
|
|
1051
|
+
out.push(
|
|
1052
|
+
` ${id.get(e.from)} ${e.typeOnly ? "-.->" : "-->"}${e.violation ? `|${e.violation}|` : ""} ${id.get(e.to)}`
|
|
1053
|
+
);
|
|
1054
|
+
if (e.violation) styles.push(` linkStyle ${i} stroke:#e2456f,stroke-width:2.5px`);
|
|
1055
|
+
else if (e.inCycle) styles.push(` linkStyle ${i} stroke:#f5a524,stroke-width:2px`);
|
|
1056
|
+
});
|
|
1057
|
+
return `${[...out, ...styles].join("\n")}
|
|
1058
|
+
`;
|
|
1059
|
+
}
|
|
1060
|
+
var FILL = {
|
|
1061
|
+
root: "#f1edff",
|
|
1062
|
+
view: "#e8f7ee",
|
|
1063
|
+
viewmodel: "#efe9ff",
|
|
1064
|
+
model: "#e7f0ff",
|
|
1065
|
+
allowed: "#f4f4f5",
|
|
1066
|
+
outside: "#fde8ec"
|
|
1067
|
+
};
|
|
1068
|
+
function renderDot(graph) {
|
|
1069
|
+
const out = [
|
|
1070
|
+
"digraph bull {",
|
|
1071
|
+
" rankdir=LR; nodesep=0.3; ranksep=1.2;",
|
|
1072
|
+
' node [shape=box, style="rounded,filled", fontname="Helvetica", fontsize=11, color="#c9c4d8"];',
|
|
1073
|
+
' edge [color="#8d8aa0", arrowsize=0.7];'
|
|
1074
|
+
];
|
|
1075
|
+
for (const kind of COLUMNS) {
|
|
1076
|
+
const members = graph.nodes.filter((n) => n.kind === kind);
|
|
1077
|
+
if (!members.length) continue;
|
|
1078
|
+
out.push(
|
|
1079
|
+
` subgraph cluster_${kind} { label="${COLUMN_LABEL[kind]}"; style=rounded; color="#d9d4e8"; fontname="Helvetica";`
|
|
1080
|
+
);
|
|
1081
|
+
for (const n of members)
|
|
1082
|
+
out.push(` "${n.rel}" [label="${path6.posix.basename(n.rel)}", fillcolor="${FILL[kind]}"];`);
|
|
1083
|
+
out.push(" }");
|
|
1084
|
+
}
|
|
1085
|
+
const relOf = new Map(graph.nodes.map((n) => [n.file, n.rel]));
|
|
1086
|
+
for (const e of graph.edges) {
|
|
1087
|
+
const attrs = [];
|
|
1088
|
+
if (e.typeOnly) attrs.push("style=dashed");
|
|
1089
|
+
if (e.violation) attrs.push(`color="#e2456f", penwidth=2, label="${e.violation}", fontcolor="#e2456f", fontsize=9`);
|
|
1090
|
+
else if (e.inCycle) attrs.push('color="#f5a524", penwidth=2');
|
|
1091
|
+
out.push(` "${relOf.get(e.from)}" -> "${relOf.get(e.to)}"${attrs.length ? ` [${attrs.join(", ")}]` : ""};`);
|
|
1092
|
+
}
|
|
1093
|
+
out.push("}");
|
|
1094
|
+
return `${out.join("\n")}
|
|
1095
|
+
`;
|
|
1096
|
+
}
|
|
1097
|
+
var esc = (s) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
1098
|
+
function renderHtml(graph, title) {
|
|
1099
|
+
const NODE_W = 220;
|
|
1100
|
+
const NODE_H = 34;
|
|
1101
|
+
const GAP_Y = 12;
|
|
1102
|
+
const COL_W = 330;
|
|
1103
|
+
const TOP = 110;
|
|
1104
|
+
const LEFT = 40;
|
|
1105
|
+
const columns = COLUMNS.filter((k) => graph.nodes.some((n) => n.kind === k));
|
|
1106
|
+
const position = /* @__PURE__ */ new Map();
|
|
1107
|
+
let maxRows = 0;
|
|
1108
|
+
columns.forEach((kind, col) => {
|
|
1109
|
+
const members = graph.nodes.filter((n) => n.kind === kind).sort((a, b) => a.rel.localeCompare(b.rel));
|
|
1110
|
+
maxRows = Math.max(maxRows, members.length);
|
|
1111
|
+
for (const [row, n] of members.entries()) {
|
|
1112
|
+
position.set(n.file, { x: LEFT + col * COL_W, y: TOP + row * (NODE_H + GAP_Y) });
|
|
1113
|
+
}
|
|
1114
|
+
});
|
|
1115
|
+
const width = LEFT * 2 + (columns.length - 1) * COL_W + NODE_W + 60;
|
|
1116
|
+
const height = TOP + maxRows * (NODE_H + GAP_Y) + 40;
|
|
1117
|
+
const index = new Map(graph.nodes.map((n, i) => [n.file, i]));
|
|
1118
|
+
const edges = graph.edges.map((e, i) => {
|
|
1119
|
+
const a = position.get(e.from);
|
|
1120
|
+
const b = position.get(e.to);
|
|
1121
|
+
let d;
|
|
1122
|
+
if (Math.abs(a.x - b.x) < 1) {
|
|
1123
|
+
const x = a.x + NODE_W;
|
|
1124
|
+
const bulge = 36 + Math.min(80, Math.abs(a.y - b.y) / 4);
|
|
1125
|
+
d = `M${x},${a.y + NODE_H / 2} C${x + bulge},${a.y + NODE_H / 2} ${x + bulge},${b.y + NODE_H / 2} ${x + 4},${b.y + NODE_H / 2}`;
|
|
1126
|
+
} else if (b.x > a.x) {
|
|
1127
|
+
const sx = a.x + NODE_W;
|
|
1128
|
+
const tx = b.x - 4;
|
|
1129
|
+
const skipped = Math.round((b.x - a.x) / COL_W) - 1;
|
|
1130
|
+
if (skipped > 0) {
|
|
1131
|
+
const lift = 46 + 18 * skipped + Math.abs(a.y - b.y) / 6;
|
|
1132
|
+
const top = Math.min(a.y, b.y) - lift;
|
|
1133
|
+
d = `M${sx},${a.y + NODE_H / 2} C${sx + 70},${top} ${tx - 70},${top} ${tx},${b.y + NODE_H / 2}`;
|
|
1134
|
+
} else {
|
|
1135
|
+
const mid = (sx + tx) / 2;
|
|
1136
|
+
d = `M${sx},${a.y + NODE_H / 2} C${mid},${a.y + NODE_H / 2} ${mid},${b.y + NODE_H / 2} ${tx},${b.y + NODE_H / 2}`;
|
|
1137
|
+
}
|
|
1138
|
+
} else {
|
|
1139
|
+
const sx = a.x;
|
|
1140
|
+
const tx = b.x + NODE_W + 4;
|
|
1141
|
+
d = `M${sx},${a.y + NODE_H / 2} C${sx - 90},${a.y - 30} ${tx + 90},${b.y + NODE_H + 30} ${tx},${b.y + NODE_H / 2}`;
|
|
1142
|
+
}
|
|
1143
|
+
const cls = ["edge", e.violation ? "bad" : e.inCycle ? "cycle" : "", e.typeOnly ? "type" : ""].filter(Boolean).join(" ");
|
|
1144
|
+
const label = e.violation ? `<title>${esc(e.violation)}</title>` : "";
|
|
1145
|
+
return `<path id="e${i}" class="${cls}" data-from="${index.get(e.from)}" data-to="${index.get(e.to)}" d="${d}" marker-end="url(#${e.violation ? "arrow-bad" : "arrow"})">${label}</path>`;
|
|
1146
|
+
}).join("\n ");
|
|
1147
|
+
const headers = columns.map((kind, col) => `<text class="col" x="${LEFT + col * COL_W}" y="28">${esc(COLUMN_LABEL[kind] ?? kind)}</text>`).join("\n ");
|
|
1148
|
+
const nodes = graph.nodes.map((n) => {
|
|
1149
|
+
const p = position.get(n.file);
|
|
1150
|
+
const bad = graph.edges.some((e) => (e.from === n.file || e.to === n.file) && e.violation);
|
|
1151
|
+
const name = path6.posix.basename(n.rel);
|
|
1152
|
+
return `<g class="node ${n.kind}${bad ? " has-bad" : ""}" data-id="${index.get(n.file)}" transform="translate(${p.x},${p.y})">
|
|
1153
|
+
<title>${esc(n.rel)}</title>
|
|
1154
|
+
<rect width="${NODE_W}" height="${NODE_H}" rx="9"/>
|
|
1155
|
+
<circle cx="16" cy="${NODE_H / 2}" r="4"/>
|
|
1156
|
+
<text x="28" y="${NODE_H / 2 + 4}">${esc(name.length > 27 ? `${name.slice(0, 26)}\u2026` : name)}</text>
|
|
1157
|
+
</g>`;
|
|
1158
|
+
}).join("\n ");
|
|
1159
|
+
const violations = graph.edges.filter((e) => e.violation).length;
|
|
1160
|
+
const status = violations || graph.cycles.length ? `<span class="pill bad">\u2717 ${violations} edge(s) break the direction \xB7 ${graph.cycles.length} cycle(s)</span>` : `<span class="pill ok">\u2713 Every edge follows View \u2192 ViewModel \u2192 Model</span>`;
|
|
1161
|
+
return `<!doctype html>
|
|
1162
|
+
<html lang="en">
|
|
1163
|
+
<head>
|
|
1164
|
+
<meta charset="utf-8">
|
|
1165
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
1166
|
+
<title>${esc(title)} \xB7 Bull graph</title>
|
|
1167
|
+
<style>
|
|
1168
|
+
:root { --bg:#f7f6f3; --panel:#ffffff; --ink:#1d1b22; --muted:#77737f; --line:#e4e0ea;
|
|
1169
|
+
--view:#1f9d5a; --viewmodel:#6b4dff; --model:#2f6fe4; --root:#9a7b2f; --other:#9a95a8; --bad:#e2456f; --cycle:#e8930c; --edge:#b3aec2; }
|
|
1170
|
+
@media (prefers-color-scheme: dark) {
|
|
1171
|
+
:root { --bg:#0f0d15; --panel:#17151f; --ink:#ecebf2; --muted:#8f8b9c; --line:#2a2735;
|
|
1172
|
+
--view:#4ad48a; --viewmodel:#a48bff; --model:#6aa2ff; --root:#e0c06a; --other:#8a8597; --edge:#4c4858; }
|
|
1173
|
+
}
|
|
1174
|
+
* { box-sizing: border-box; }
|
|
1175
|
+
body { margin: 0; background: var(--bg); color: var(--ink); font: 14px/1.5 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; }
|
|
1176
|
+
header { display: flex; align-items: center; gap: 14px; padding: 20px 28px; border-bottom: 1px solid var(--line); flex-wrap: wrap; }
|
|
1177
|
+
header h1 { font-size: 17px; margin: 0; }
|
|
1178
|
+
header p { margin: 0; color: var(--muted); font-size: 13px; }
|
|
1179
|
+
.spacer { flex: 1; }
|
|
1180
|
+
.pill { font-size: 12px; font-weight: 600; padding: 4px 12px; border-radius: 999px; }
|
|
1181
|
+
.pill.ok { background: color-mix(in srgb, var(--view) 15%, transparent); color: var(--view); }
|
|
1182
|
+
.pill.bad { background: color-mix(in srgb, var(--bad) 15%, transparent); color: var(--bad); }
|
|
1183
|
+
.legend { display: flex; gap: 18px; padding: 12px 28px; color: var(--muted); font-size: 12px; flex-wrap: wrap; }
|
|
1184
|
+
.legend i { display: inline-block; width: 22px; height: 0; border-top: 2px solid var(--edge); vertical-align: middle; margin-right: 6px; }
|
|
1185
|
+
.legend .bad i { border-color: var(--bad); }
|
|
1186
|
+
.legend .cycle i { border-color: var(--cycle); }
|
|
1187
|
+
.legend .type i { border-top-style: dashed; }
|
|
1188
|
+
main { overflow: auto; padding: 8px 0 32px; }
|
|
1189
|
+
svg { display: block; margin: 0 auto; }
|
|
1190
|
+
.col { font-size: 12px; font-weight: 700; letter-spacing: .06em; text-transform: uppercase; fill: var(--muted); }
|
|
1191
|
+
.node rect { fill: var(--panel); stroke: var(--line); stroke-width: 1.2; transition: stroke .15s; }
|
|
1192
|
+
.node text { font: 12.5px ui-monospace, SFMono-Regular, Menlo, monospace; fill: var(--ink); }
|
|
1193
|
+
.node circle { fill: var(--other); }
|
|
1194
|
+
.node.view circle { fill: var(--view); } .node.viewmodel circle { fill: var(--viewmodel); }
|
|
1195
|
+
.node.model circle { fill: var(--model); } .node.root circle { fill: var(--root); }
|
|
1196
|
+
.node.has-bad rect { stroke: var(--bad); }
|
|
1197
|
+
.node { cursor: default; }
|
|
1198
|
+
.edge { fill: none; stroke: var(--edge); stroke-width: 1.4; transition: opacity .15s, stroke-width .15s; }
|
|
1199
|
+
.edge.type { stroke-dasharray: 4 4; }
|
|
1200
|
+
.edge.cycle { stroke: var(--cycle); stroke-width: 2; }
|
|
1201
|
+
.edge.bad { stroke: var(--bad); stroke-width: 2.4; }
|
|
1202
|
+
#arrow path { fill: var(--edge); } #arrow-bad path { fill: var(--bad); }
|
|
1203
|
+
svg.focus .edge { opacity: .12; } svg.focus .edge.on { opacity: 1; stroke-width: 2.6; }
|
|
1204
|
+
svg.focus .node { opacity: .35; } svg.focus .node.on { opacity: 1; }
|
|
1205
|
+
</style>
|
|
1206
|
+
</head>
|
|
1207
|
+
<body>
|
|
1208
|
+
<header>
|
|
1209
|
+
${BULL_SVG}
|
|
1210
|
+
<div><h1>${esc(title)}</h1><p>Import graph \xB7 ${graph.nodes.length} files \xB7 ${graph.edges.length} edges \xB7 hover a file to trace it</p></div>
|
|
1211
|
+
<div class="spacer"></div>
|
|
1212
|
+
${status}
|
|
1213
|
+
</header>
|
|
1214
|
+
<div class="legend">
|
|
1215
|
+
<span><i></i>import</span><span class="type"><i></i>type-only import</span>
|
|
1216
|
+
<span class="bad"><i></i>breaks View \u2192 ViewModel \u2192 Model</span><span class="cycle"><i></i>import cycle</span>
|
|
1217
|
+
</div>
|
|
1218
|
+
<main>
|
|
1219
|
+
<svg id="graph" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" role="img" aria-label="Import graph">
|
|
1220
|
+
<defs>
|
|
1221
|
+
<marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0,0 L10,5 L0,10 z"/></marker>
|
|
1222
|
+
<marker id="arrow-bad" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0,0 L10,5 L0,10 z"/></marker>
|
|
1223
|
+
</defs>
|
|
1224
|
+
<g>
|
|
1225
|
+
${headers}
|
|
1226
|
+
</g>
|
|
1227
|
+
<g>
|
|
1228
|
+
${edges}
|
|
1229
|
+
</g>
|
|
1230
|
+
<g>
|
|
1231
|
+
${nodes}
|
|
1232
|
+
</g>
|
|
1233
|
+
</svg>
|
|
1234
|
+
</main>
|
|
1235
|
+
<script>
|
|
1236
|
+
const svg = document.getElementById("graph");
|
|
1237
|
+
svg.querySelectorAll(".node").forEach((node) => {
|
|
1238
|
+
const id = node.dataset.id;
|
|
1239
|
+
node.addEventListener("mouseenter", () => {
|
|
1240
|
+
svg.classList.add("focus");
|
|
1241
|
+
node.classList.add("on");
|
|
1242
|
+
svg.querySelectorAll('.edge[data-from="' + id + '"], .edge[data-to="' + id + '"]').forEach((edge) => {
|
|
1243
|
+
edge.classList.add("on");
|
|
1244
|
+
svg.querySelector('.node[data-id="' + edge.dataset.from + '"]').classList.add("on");
|
|
1245
|
+
svg.querySelector('.node[data-id="' + edge.dataset.to + '"]').classList.add("on");
|
|
1246
|
+
});
|
|
1247
|
+
});
|
|
1248
|
+
node.addEventListener("mouseleave", () => {
|
|
1249
|
+
svg.classList.remove("focus");
|
|
1250
|
+
svg.querySelectorAll(".on").forEach((el) => el.classList.remove("on"));
|
|
1251
|
+
});
|
|
1252
|
+
});
|
|
1253
|
+
</script>
|
|
1254
|
+
</body>
|
|
1255
|
+
</html>
|
|
1256
|
+
`;
|
|
1257
|
+
}
|
|
1258
|
+
var BULL_SVG = `<svg width="36" height="36" viewBox="0 0 64 64" aria-hidden="true">
|
|
1259
|
+
<path d="M19 20C10.5 20 4.5 15 4 5.5c3.5 5.8 9 8.6 17 9.2Z" fill="#ffc9a8"/>
|
|
1260
|
+
<path d="M45 20c8.5 0 14.5-5 15-14.5c-3.5 5.8-9 8.6-17 9.2Z" fill="#ffc9a8"/>
|
|
1261
|
+
<path d="M18 15h28L32 36Z" fill="#a78bfa"/><path d="M18 15l14 21v21h-6L20.5 34Z" fill="#6b4dff"/>
|
|
1262
|
+
<path d="M46 15 32 36v21h6l5.5-23Z" fill="#f0507c"/><rect x="23" y="45" width="18" height="12" rx="6" fill="#33224a"/>
|
|
1263
|
+
</svg>`;
|
|
1264
|
+
async function graphCommand(rootArg, options) {
|
|
1265
|
+
const format = options.format ?? "tree";
|
|
1266
|
+
if (!["tree", "mermaid", "dot", "html", "json"].includes(format)) {
|
|
1267
|
+
fail(`Unknown format "${format}".`, "Use tree, mermaid, dot, html or json.");
|
|
1268
|
+
return;
|
|
1269
|
+
}
|
|
1270
|
+
const root = path6.resolve(rootArg ?? ".");
|
|
1271
|
+
const { config } = await loadBullConfig(root, "serve");
|
|
1272
|
+
const graph = buildImportGraph(new ProjectAnalyzer4(root, config).analyze());
|
|
1273
|
+
const title = path6.basename(root);
|
|
1274
|
+
const text = format === "mermaid" ? renderMermaid(graph) : format === "dot" ? renderDot(graph) : format === "json" ? `${JSON.stringify(graph, null, 2)}
|
|
1275
|
+
` : format === "html" ? renderHtml(graph, title) : void 0;
|
|
1276
|
+
if (text === void 0) {
|
|
1277
|
+
banner("graph");
|
|
1278
|
+
process.stdout.write(renderTree(graph));
|
|
1279
|
+
} else if (options.out || format === "html") {
|
|
1280
|
+
const out = path6.resolve(options.out ?? "bull-graph.html");
|
|
1281
|
+
fs6.writeFileSync(out, text);
|
|
1282
|
+
banner("graph");
|
|
1283
|
+
console.log(` ${pc6.green("\u2713")} Wrote ${pc6.bold(path6.relative(process.cwd(), out) || out)}`);
|
|
1284
|
+
if (options.open) openInBrowser(out);
|
|
1285
|
+
} else {
|
|
1286
|
+
process.stdout.write(text);
|
|
1287
|
+
}
|
|
1288
|
+
if (graph.edges.some((e) => e.violation) || graph.cycles.length) process.exitCode = 1;
|
|
1289
|
+
}
|
|
1290
|
+
function openInBrowser(file2) {
|
|
1291
|
+
const command = process.platform === "win32" ? `start "" "${file2}"` : process.platform === "darwin" ? `open "${file2}"` : `xdg-open "${file2}"`;
|
|
1292
|
+
exec(command);
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
// src/tooling/info.ts
|
|
1296
|
+
import fs7 from "fs";
|
|
1297
|
+
import path7 from "path";
|
|
1298
|
+
import { LAYER_LABEL as LAYER_LABEL2, ProjectAnalyzer as ProjectAnalyzer5 } from "@bull-js/core";
|
|
1299
|
+
import pc7 from "picocolors";
|
|
1300
|
+
function isPublic(m) {
|
|
1301
|
+
return m.access === "public" && !m.static;
|
|
1302
|
+
}
|
|
1303
|
+
function collectInfo(result, configFile) {
|
|
1304
|
+
const { config } = result;
|
|
1305
|
+
const rel = (f) => path7.posix.relative(config.root, f);
|
|
1306
|
+
const views = result.files.filter((f) => f.kind === "view" && f.facts).map((f) => ({
|
|
1307
|
+
file: rel(f.file),
|
|
1308
|
+
name: path7.posix.basename(f.file).replace(/\.view\.tsx$/, ""),
|
|
1309
|
+
binds: [
|
|
1310
|
+
...f.facts.declarators.filter((d) => d.callee === "useViewModel" && d.arg).map((d) => d.arg),
|
|
1311
|
+
// `const { pay } = useViewModel(CartViewModel)` binds it too.
|
|
1312
|
+
...f.facts.destructurings.flatMap(
|
|
1313
|
+
(d) => "callee" in d.init && d.init.callee === "useViewModel" && d.init.arg ? [d.init.arg] : []
|
|
1314
|
+
)
|
|
1315
|
+
]
|
|
1316
|
+
}));
|
|
1317
|
+
const viewModels = [];
|
|
1318
|
+
for (const f of result.files.filter((x) => x.kind === "viewmodel" && x.facts)) {
|
|
1319
|
+
for (const cls of f.facts.classes.filter((c) => c.exported && c.name && c.superClass?.endsWith("ViewModel"))) {
|
|
1320
|
+
const members = cls.members.filter(isPublic);
|
|
1321
|
+
const pick = (test) => members.filter(test).map((m) => m.name);
|
|
1322
|
+
viewModels.push({
|
|
1323
|
+
name: cls.name,
|
|
1324
|
+
file: rel(f.file),
|
|
1325
|
+
state: pick((m) => m.kind === "field" && (m.init === "readonly-view" || m.init === "state")),
|
|
1326
|
+
computed: pick((m) => m.kind === "field" && m.init === "computed"),
|
|
1327
|
+
commands: pick((m) => m.kind === "field" && m.init === "command"),
|
|
1328
|
+
values: pick((m) => m.kind === "field" && (m.init === "other" || m.init === "none")),
|
|
1329
|
+
methods: pick((m) => m.kind === "method" || m.kind === "getter"),
|
|
1330
|
+
injects: cls.members.filter((m) => m.kind === "param-property").map((m) => m.type ?? m.name),
|
|
1331
|
+
usedBy: views.filter((v) => v.binds.includes(cls.name)).map((v) => v.file)
|
|
1332
|
+
});
|
|
1333
|
+
}
|
|
1334
|
+
}
|
|
1335
|
+
let name = path7.basename(config.root);
|
|
1336
|
+
try {
|
|
1337
|
+
name = JSON.parse(fs7.readFileSync(path7.join(config.root, "package.json"), "utf8")).name ?? name;
|
|
1338
|
+
} catch {
|
|
1339
|
+
}
|
|
1340
|
+
const overrides = [
|
|
1341
|
+
...[...config.rules].map(([code, severity]) => `${code}: ${severity}`),
|
|
1342
|
+
...[...config.view.allowHooks].map((hook) => `view.allowHooks: ${hook}`),
|
|
1343
|
+
...config.alias.map(([prefix, target]) => `alias ${prefix} \u2192 ${path7.posix.relative(config.root, target) || "."}`)
|
|
1344
|
+
];
|
|
1345
|
+
return {
|
|
1346
|
+
name,
|
|
1347
|
+
root: config.root,
|
|
1348
|
+
configFile,
|
|
1349
|
+
counts: { model: result.stats.model, viewmodel: result.stats.viewmodel, view: result.stats.view },
|
|
1350
|
+
viewModels,
|
|
1351
|
+
views,
|
|
1352
|
+
overrides,
|
|
1353
|
+
errors: result.errorCount,
|
|
1354
|
+
warnings: result.warningCount
|
|
1355
|
+
};
|
|
1356
|
+
}
|
|
1357
|
+
function formatInfo(info, colors = pc7.isColorSupported) {
|
|
1358
|
+
const c = pc7.createColors(colors);
|
|
1359
|
+
const out = [];
|
|
1360
|
+
const row = (label, items, color = (s) => s) => items.length && out.push(` ${c.dim(label.padEnd(9))} ${items.map(color).join(c.dim(", "))}`);
|
|
1361
|
+
out.push(` ${c.bold(info.name)} ${c.dim(info.root)}`);
|
|
1362
|
+
out.push(` ${c.dim("config")} ${info.configFile ? path7.basename(info.configFile) : "defaults"}`);
|
|
1363
|
+
out.push("");
|
|
1364
|
+
out.push(` ${c.bold("Layers")}`);
|
|
1365
|
+
for (const layer of ["view", "viewmodel", "model"]) {
|
|
1366
|
+
out.push(` ${LAYER_LABEL2[layer].padEnd(10)} ${String(info.counts[layer]).padStart(3)} file(s)`);
|
|
1367
|
+
}
|
|
1368
|
+
out.push("");
|
|
1369
|
+
out.push(` ${c.bold("ViewModels")} ${c.dim("(public API)")}`);
|
|
1370
|
+
if (!info.viewModels.length) out.push(` ${c.dim("none yet \u2014 try `bull g feature <name>`")}`);
|
|
1371
|
+
for (const vm of info.viewModels) {
|
|
1372
|
+
out.push(` ${c.magenta(vm.name)} ${c.dim(vm.file)}`);
|
|
1373
|
+
row("state", vm.state, c.cyan);
|
|
1374
|
+
row("computed", vm.computed, c.cyan);
|
|
1375
|
+
row("commands", vm.commands, c.green);
|
|
1376
|
+
row("values", vm.values);
|
|
1377
|
+
row("methods", vm.methods);
|
|
1378
|
+
row("injects", vm.injects, c.blue);
|
|
1379
|
+
row("used by", vm.usedBy);
|
|
1380
|
+
if (!vm.usedBy.length) out.push(` ${c.dim("used by".padEnd(9))} ${c.yellow("no View binds it yet")}`);
|
|
1381
|
+
}
|
|
1382
|
+
out.push("");
|
|
1383
|
+
out.push(` ${c.bold("Views")}`);
|
|
1384
|
+
for (const v of info.views) {
|
|
1385
|
+
out.push(` ${v.file.padEnd(40)} ${v.binds.length ? c.magenta(v.binds.join(", ")) : c.dim("presentational")}`);
|
|
1386
|
+
}
|
|
1387
|
+
if (info.overrides.length) {
|
|
1388
|
+
out.push("");
|
|
1389
|
+
out.push(` ${c.bold("Config overrides")}`);
|
|
1390
|
+
for (const o of info.overrides) out.push(` ${o}`);
|
|
1391
|
+
}
|
|
1392
|
+
out.push("");
|
|
1393
|
+
out.push(
|
|
1394
|
+
info.errors || info.warnings ? ` ${c.red("\u2717")} ${info.errors} error(s), ${info.warnings} warning(s) \u2014 run ${c.cyan("bull check")}` : ` ${c.green("\u2713")} MVVM architecture ok`
|
|
1395
|
+
);
|
|
1396
|
+
return `${out.join("\n")}
|
|
1397
|
+
`;
|
|
1398
|
+
}
|
|
1399
|
+
async function infoCommand(rootArg, options) {
|
|
1400
|
+
const root = path7.resolve(rootArg ?? ".");
|
|
1401
|
+
const { config, file: file2 } = await loadBullConfig(root, "serve");
|
|
1402
|
+
const info = collectInfo(new ProjectAnalyzer5(root, config).analyze(), file2);
|
|
1403
|
+
if (options.json) {
|
|
1404
|
+
process.stdout.write(`${JSON.stringify(info, null, 2)}
|
|
1405
|
+
`);
|
|
1406
|
+
return;
|
|
1407
|
+
}
|
|
1408
|
+
banner("info");
|
|
1409
|
+
process.stdout.write(formatInfo(info));
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1412
|
+
// src/tooling/init.ts
|
|
1413
|
+
import fs8 from "fs";
|
|
1414
|
+
import path8 from "path";
|
|
1415
|
+
import pc8 from "picocolors";
|
|
1416
|
+
var CONFIG = `import { defineConfig } from "@bull-js/cli";
|
|
1417
|
+
|
|
1418
|
+
export default defineConfig({});
|
|
1419
|
+
`;
|
|
1420
|
+
var SCRIPTS = {
|
|
1421
|
+
dev: "bull",
|
|
1422
|
+
build: "bull build",
|
|
1423
|
+
preview: "bull preview",
|
|
1424
|
+
check: "bull check"
|
|
1425
|
+
};
|
|
1426
|
+
var DEPENDENCIES = ["@bull-js/runtime"];
|
|
1427
|
+
var DEV_DEPENDENCIES = ["@bull-js/cli", "@bull-js/eslint-plugin"];
|
|
1428
|
+
var LAYERS = ["model", "viewmodel", "view"];
|
|
1429
|
+
function planInit(root) {
|
|
1430
|
+
const pkgFile = path8.join(root, "package.json");
|
|
1431
|
+
if (!fs8.existsSync(pkgFile)) {
|
|
1432
|
+
throw new Error("There is no package.json here. To start from scratch use `bull create <name>`.");
|
|
1433
|
+
}
|
|
1434
|
+
const original = fs8.readFileSync(pkgFile, "utf8");
|
|
1435
|
+
const pkg = JSON.parse(original);
|
|
1436
|
+
const changes = [];
|
|
1437
|
+
const config = ["bull.config.ts", "bull.config.mts", "bull.config.js", "bull.config.mjs"].find(
|
|
1438
|
+
(f) => fs8.existsSync(path8.join(root, f))
|
|
1439
|
+
);
|
|
1440
|
+
changes.push(
|
|
1441
|
+
config ? { path: config, action: "skip", detail: "already exists" } : { path: "bull.config.ts", action: "create", detail: "empty config, ready to extend" }
|
|
1442
|
+
);
|
|
1443
|
+
for (const layer of LAYERS) {
|
|
1444
|
+
const dir = `src/${layer}`;
|
|
1445
|
+
changes.push(
|
|
1446
|
+
fs8.existsSync(path8.join(root, dir)) ? { path: `${dir}/`, action: "skip", detail: "already exists" } : { path: `${dir}/`, action: "create", detail: `${layer} layer` }
|
|
1447
|
+
);
|
|
1448
|
+
}
|
|
1449
|
+
const added = [];
|
|
1450
|
+
const kept = [];
|
|
1451
|
+
pkg.scripts ??= {};
|
|
1452
|
+
for (const [name, command] of Object.entries(SCRIPTS)) {
|
|
1453
|
+
const current = pkg.scripts[name];
|
|
1454
|
+
if (current === void 0) {
|
|
1455
|
+
pkg.scripts[name] = command;
|
|
1456
|
+
added.push(`scripts.${name}`);
|
|
1457
|
+
} else if (current !== command) {
|
|
1458
|
+
kept.push(`scripts.${name} ("${current}")`);
|
|
1459
|
+
}
|
|
1460
|
+
}
|
|
1461
|
+
const range = `^${VERSION}`;
|
|
1462
|
+
const addDeps = (field, names) => {
|
|
1463
|
+
for (const name of names) {
|
|
1464
|
+
if (pkg.dependencies?.[name] || pkg.devDependencies?.[name]) continue;
|
|
1465
|
+
pkg[field] = { ...pkg[field], [name]: range };
|
|
1466
|
+
added.push(name);
|
|
1467
|
+
}
|
|
1468
|
+
};
|
|
1469
|
+
addDeps("dependencies", DEPENDENCIES);
|
|
1470
|
+
addDeps("devDependencies", DEV_DEPENDENCIES);
|
|
1471
|
+
if (added.length) changes.push({ path: "package.json", action: "update", detail: `adds ${added.join(", ")}` });
|
|
1472
|
+
if (kept.length) changes.push({ path: "package.json", action: "skip", detail: `keeps ${kept.join(", ")}` });
|
|
1473
|
+
return { changes, pkg: added.length ? pkg : void 0 };
|
|
1474
|
+
}
|
|
1475
|
+
function initCommand(rootArg, options) {
|
|
1476
|
+
banner(options.dryRun ? "init (dry run)" : "init");
|
|
1477
|
+
const root = path8.resolve(rootArg ?? ".");
|
|
1478
|
+
let plan;
|
|
1479
|
+
try {
|
|
1480
|
+
plan = planInit(root);
|
|
1481
|
+
} catch (error) {
|
|
1482
|
+
fail(error.message);
|
|
1483
|
+
return;
|
|
1484
|
+
}
|
|
1485
|
+
for (const change of plan.changes) {
|
|
1486
|
+
if (change.action === "skip") {
|
|
1487
|
+
console.log(` ${pc8.yellow("\xB7")} ${change.path} ${pc8.dim(`(${change.detail}, left untouched)`)}`);
|
|
1488
|
+
continue;
|
|
1489
|
+
}
|
|
1490
|
+
const mark = change.action === "create" ? pc8.green("+") : pc8.cyan("~");
|
|
1491
|
+
const verb = options.dryRun ? change.action === "create" ? "would create: " : "would update: " : "";
|
|
1492
|
+
console.log(` ${mark} ${change.path} ${pc8.dim(`${verb}${change.detail}`)}`);
|
|
1493
|
+
if (options.dryRun) continue;
|
|
1494
|
+
const abs = path8.join(root, change.path);
|
|
1495
|
+
if (change.path.endsWith("/")) fs8.mkdirSync(abs, { recursive: true });
|
|
1496
|
+
else if (change.path === "bull.config.ts") fs8.writeFileSync(abs, CONFIG);
|
|
1497
|
+
}
|
|
1498
|
+
if (!options.dryRun && plan.pkg) {
|
|
1499
|
+
fs8.writeFileSync(path8.join(root, "package.json"), `${JSON.stringify(plan.pkg, null, 2)}
|
|
1500
|
+
`);
|
|
1501
|
+
}
|
|
1502
|
+
const pm = detectPackageManager();
|
|
1503
|
+
console.log(`
|
|
1504
|
+
Next steps:
|
|
1505
|
+
${pc8.cyan(`${pm} install`)}
|
|
1506
|
+
${pc8.cyan("npx bull g feature home")} ${pc8.dim("a first Model, ViewModel and View")}
|
|
1507
|
+
${pc8.cyan("npx bull doctor")} ${pc8.dim("checks the rest of the setup")}
|
|
1508
|
+
${pc8.cyan(runScript(pm, "dev"))}
|
|
1509
|
+
`);
|
|
1510
|
+
}
|
|
1511
|
+
|
|
1512
|
+
// src/bin.ts
|
|
1513
|
+
var cli = cac("bull");
|
|
1514
|
+
cli.command("[root]", "Dev server with live architecture checks").alias("dev").option("--port <port>", "Port").option("--open", "Open the browser").option("--host [host]", "Expose on the network").option("--mode <mode>", "Vite mode (defaults to development)").option("--config <file>", "Use this config file instead of bull.config.ts").action((root, options) => devCommand(root, options));
|
|
1515
|
+
cli.command("build [root]", "Checks the architecture and, if it holds, builds for production").option("--out-dir <dir>", "Output folder (defaults to dist)").option("--mode <mode>", "Vite mode (defaults to production)").option("--config <file>", "Use this config file instead of bull.config.ts").action((root, options) => buildCommand(root, options));
|
|
1516
|
+
cli.command("preview [root]", "Serves the production build").option("--port <port>", "Port").option("--open", "Open the browser").option("--host [host]", "Expose on the network").option("--out-dir <dir>", "Folder with the build (defaults to dist)").option("--config <file>", "Use this config file instead of bull.config.ts").action((root, options) => previewCommand(root, options));
|
|
1517
|
+
cli.command("check [root]", "Only checks the architecture (great for CI)").option("--format <format>", "pretty | json", { default: "pretty" }).action((root, options) => checkCommand(root, options));
|
|
1518
|
+
cli.command("explain [code]", "Explains a rule (MVVM100, 100 or view-imports-model). Without a code, lists them all").option("--all", "All rules").option("--format <format>", "text | md").option("--out <dir>", "With --all --format md: folder where the docs are written").action((code, options) => explainCommand(code, options));
|
|
1519
|
+
cli.command("create [name]", "Creates a new project with the MVVM structure (asks for the details without a name)").option("--template <template>", "Template", { default: "default" }).option("--pm <pm>", "Package manager: npm | pnpm | yarn | bun (defaults to the one running the command)").option("--no-install", "Do not install the dependencies").option("--no-git", "Do not initialize a git repository").example(" bull create my-app").example(" bull create my-app --pm pnpm --no-git").action((name, options) => createCommand(name, options));
|
|
1520
|
+
cli.command("init [root]", "Adds Bull to an existing project: config, layer folders, scripts and dependencies").option("--dry-run", "Show what would change without changing it").action((root, options) => initCommand(root, options));
|
|
1521
|
+
cli.command("generate <type> [name]", "Scaffolds a feature, model, viewmodel or view with the right names").alias("g").option("--kind <kind>", "Model kind: model | entity | repository | service").option("--dry-run", "Show what would be written without writing it").option("--no-test", "Do not generate a ViewModel test").option("--root <dir>", "Project root (defaults to the current folder)").example(" bull g feature todo-list").example(" bull g model user --kind repository").action(
|
|
1522
|
+
(type, name, options) => generateCommand(type, name, { ...options, test: options.test === false ? false : void 0 })
|
|
1523
|
+
);
|
|
1524
|
+
cli.command("graph [root]", "Shows the import graph between layers and the edges that break MVVM").option("--format <format>", "tree | mermaid | dot | html | json", { default: "tree" }).option("--out <file>", "Write the output to a file (html defaults to bull-graph.html)").option("--open", "Open the HTML graph in the browser").example(" bull graph --format html --open").example(" bull graph --format mermaid > docs/architecture.mmd").action((root, options) => graphCommand(root, options));
|
|
1525
|
+
cli.command("fix [root]", "Applies the safe automatic fixes (renames, readonly state, type re-exports)").option("--dry-run", "Show a diff of the changes without writing them").action((root, options) => fixCommand(root, options));
|
|
1526
|
+
cli.command("doctor [root]", "Checks the environment and the project setup").action((root) => doctorCommand(root));
|
|
1527
|
+
cli.command("info [root]", "Summarizes the project: layers, ViewModel APIs and which Views bind them").option("--json", "Print the summary as JSON").action((root, options) => infoCommand(root, options));
|
|
1528
|
+
cli.command("completion [shell]", "Prints shell completions: bash | zsh | fish | powershell").example(' eval "$(bull completion bash)"').example(" bull completion powershell | Out-String | Invoke-Expression").action((shell) => completionCommand(shell, completionCommands()));
|
|
1529
|
+
function completionCommands() {
|
|
1530
|
+
return cli.commands.filter((c) => c.name !== "completion").map((c) => ({
|
|
1531
|
+
name: c.name === "" ? "dev" : c.name,
|
|
1532
|
+
aliases: c.aliasNames.filter((a) => a !== "dev"),
|
|
1533
|
+
description: c.description,
|
|
1534
|
+
flags: c.options.flatMap((o) => o.rawName.match(/--[\w-]+/g) ?? [])
|
|
1535
|
+
}));
|
|
1536
|
+
}
|
|
1537
|
+
var GROUPS = [
|
|
1538
|
+
["Develop", ["", "build", "preview", "create", "init"]],
|
|
1539
|
+
["Architecture", ["check", "explain", "graph", "fix"]],
|
|
1540
|
+
["Tooling", ["generate", "doctor", "info", "completion"]]
|
|
1541
|
+
];
|
|
1542
|
+
cli.help((sections) => {
|
|
1543
|
+
sections[0] = { body: `
|
|
1544
|
+
${bullArt()}
|
|
1545
|
+
` };
|
|
1546
|
+
const commands = sections.findIndex((s) => s.title === "Commands");
|
|
1547
|
+
if (commands >= 0 && (!cli.matchedCommand || cli.matchedCommand.name === "")) {
|
|
1548
|
+
const label = (name) => {
|
|
1549
|
+
const cmd2 = cli.commands.find((c) => c.name === name);
|
|
1550
|
+
const aliases = cmd2.aliasNames.filter((a) => a !== "dev");
|
|
1551
|
+
const raw = name === "" ? "dev [root]" : cmd2.rawName;
|
|
1552
|
+
return [`${raw}${aliases.length ? pc9.dim(` (${aliases.join(", ")})`) : ""}`, cmd2.description];
|
|
1553
|
+
};
|
|
1554
|
+
const width = Math.max(...GROUPS.flatMap(([, names]) => names.map((n) => cmd(label(n)[0]).length))) + 2;
|
|
1555
|
+
const body = GROUPS.map(
|
|
1556
|
+
([title, names]) => `${pc9.bold(title)}
|
|
1557
|
+
` + names.map((n) => ` ${label(n)[0]}${" ".repeat(width - cmd(label(n)[0]).length)}${label(n)[1]}`).join("\n")
|
|
1558
|
+
).join("\n\n");
|
|
1559
|
+
sections.splice(commands, 1, { body });
|
|
1560
|
+
const more = sections.findIndex((s) => s.title?.startsWith("For more info"));
|
|
1561
|
+
if (more >= 0)
|
|
1562
|
+
sections.splice(more, 1, { body: pc9.dim("Run `bull <command> --help` for the options of each command.") });
|
|
1563
|
+
}
|
|
1564
|
+
return sections;
|
|
1565
|
+
});
|
|
1566
|
+
function cmd(text) {
|
|
1567
|
+
return text.replace(/\x1b\[[0-9;]*m/g, "");
|
|
1568
|
+
}
|
|
1569
|
+
cli.version(VERSION);
|
|
1570
|
+
try {
|
|
1571
|
+
cli.parse(process.argv, { run: false });
|
|
1572
|
+
await cli.runMatchedCommand();
|
|
1573
|
+
} catch (error) {
|
|
1574
|
+
console.error(`
|
|
1575
|
+
${pc9.red("\u2717")} ${error.message}
|
|
1576
|
+
`);
|
|
1577
|
+
process.exitCode = 1;
|
|
1578
|
+
}
|