bank20baht-cli 0.3.0 → 0.4.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/README.md +8 -7
- package/dist/application/create.js +29 -20
- package/dist/args.js +48 -0
- package/dist/cli.js +86 -101
- package/dist/domain/form-dsl.js +42 -38
- package/package.json +4 -3
- package/templates/new/README.md.hbs +7 -5
- package/templates/new/_dependency-cruiser.mjs +9 -0
- package/templates/new/_github/actions/setup/action.yml +15 -0
- package/templates/new/_github/workflows/ci.yml +122 -0
- package/templates/new/_gitleaks.toml +6 -0
- package/templates/new/_husky/pre-commit +7 -0
- package/templates/new/_husky/pre-push +1 -1
- package/templates/new/_jscpd.json +20 -0
- package/templates/new/_prettierignore +1 -0
- package/templates/new/commitlint.config.js +1 -3
- package/templates/new/eslint.config.ts.hbs +7 -0
- package/templates/new/knip.ts +7 -0
- package/templates/new/lint-staged.config.js +1 -0
- package/templates/new/package.json.hbs +20 -6
- package/templates/new/renovate.json +23 -0
- package/templates/new/scripts/dev.ts +2 -2
- package/templates/new/scripts/each.ts +6 -2
- package/templates/new/scripts/workspaces.ts +1 -1
- package/templates/new/tsconfig.base.json +1 -14
- package/templates/new/tsconfig.json +1 -1
- package/templates/new/_prettierrc +0 -6
- package/templates/new/eslint.config.ts +0 -24
- package/templates/new/knip.json +0 -9
package/README.md
CHANGED
|
@@ -38,14 +38,15 @@ bun run check # lint + format:check + knip + typecheck — green on the emp
|
|
|
38
38
|
What you get:
|
|
39
39
|
|
|
40
40
|
```
|
|
41
|
-
package.json workspaces apps/* + packages/*,
|
|
42
|
-
tsconfig.base.json strict
|
|
41
|
+
package.json workspaces apps/* + packages/*, check script, prettier config ref
|
|
42
|
+
tsconfig.base.json extends bank20baht-strict/tsconfig/base.json (strict + noUncheckedIndexedAccess + exactOptionalPropertyTypes ...)
|
|
43
43
|
tsconfig.json typechecks scripts/ (extend the base from each workspace)
|
|
44
|
-
eslint.config.ts ESLint 10
|
|
45
|
-
.
|
|
46
|
-
knip.
|
|
44
|
+
eslint.config.ts one line: bank20baht-strict/eslint `base` (ESLint 10, typescript-eslint strict, sonarjs, prettier)
|
|
45
|
+
lint-staged.config.js · commitlint.config.js re-exports from bank20baht-strict
|
|
46
|
+
knip.ts dead-code / unused-dependency checks (bank20baht-strict/knip)
|
|
47
47
|
commitlint.config.js Conventional Commits
|
|
48
|
-
.husky/ pre-commit → lint-staged · commit-msg → commitlint · pre-push → bun run check
|
|
48
|
+
.husky/ pre-commit → gitleaks + lint-staged · commit-msg → commitlint · pre-push → bun run check:fast
|
|
49
|
+
.gitleaks.toml · .jscpd.json · .dependency-cruiser.mjs · renovate.json · .github/ (CI: lint, typecheck, knip/madge/deps, dup, test, build, gitleaks, audit, commitlint)
|
|
49
50
|
scripts/dev.ts runs every apps/*/ "dev" script in parallel with prefixed output
|
|
50
51
|
scripts/each.ts `bun run scripts/each.ts <script>` in every workspace that defines it
|
|
51
52
|
.mcp.json, CLAUDE.md zvec_grep code-search MCP wiring for Claude Code
|
|
@@ -64,7 +65,7 @@ husky hooks prove themselves before you touch anything. Flags:
|
|
|
64
65
|
|
|
65
66
|
Add a workspace by creating `apps/<name>/package.json` with `dev` / `build` / `typecheck`
|
|
66
67
|
scripts; `bun run dev`, `bun run build` and `bun run typecheck` pick it up automatically.
|
|
67
|
-
Add a matching entry to `knip.
|
|
68
|
+
Add a matching entry to `knip.ts` so dead-code checks cover it.
|
|
68
69
|
|
|
69
70
|
### `bahtui create <name>`
|
|
70
71
|
|
|
@@ -1,24 +1,13 @@
|
|
|
1
1
|
import { planCreate, PRESET_TEMPLATE_SET } from '../domain/create-plan.js';
|
|
2
2
|
import { validateProjectName } from '../domain/naming.js';
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
if (input.dryRun) {
|
|
12
|
-
deps.log.info(`dry-run — would create in ${blueprint.root}:`);
|
|
13
|
-
for (const f of blueprint.files)
|
|
14
|
-
deps.log.info(` ${f.path}`);
|
|
15
|
-
for (const c of blueprint.postCommands)
|
|
16
|
-
deps.log.info(` $ ${c.cmd} ${c.args.join(' ')}`);
|
|
17
|
-
return;
|
|
18
|
-
}
|
|
19
|
-
deps.writer.ensureUsableDir(blueprint.root, input.force);
|
|
20
|
-
deps.writer.writeFiles(blueprint);
|
|
21
|
-
deps.log.step(`${blueprint.files.length} files written to ${blueprint.root}`);
|
|
3
|
+
function logDryRun(blueprint, deps) {
|
|
4
|
+
deps.log.info(`dry-run — would create in ${blueprint.root}:`);
|
|
5
|
+
for (const f of blueprint.files)
|
|
6
|
+
deps.log.info(` ${f.path}`);
|
|
7
|
+
for (const c of blueprint.postCommands)
|
|
8
|
+
deps.log.info(` $ ${c.cmd} ${c.args.join(' ')}`);
|
|
9
|
+
}
|
|
10
|
+
function runPostCommands(blueprint, input, deps) {
|
|
22
11
|
for (const c of blueprint.postCommands) {
|
|
23
12
|
deps.log.step(c.label);
|
|
24
13
|
try {
|
|
@@ -29,6 +18,8 @@ export function runCreate(input, deps) {
|
|
|
29
18
|
throw new CreateError(`${err instanceof Error ? err.message : String(err)}\nproject files are written — cd ${input.name} and rerun: ${retry}`);
|
|
30
19
|
}
|
|
31
20
|
}
|
|
21
|
+
}
|
|
22
|
+
function logNextSteps(input, deps) {
|
|
32
23
|
deps.log.info('');
|
|
33
24
|
deps.log.info(`Done. Next:`);
|
|
34
25
|
deps.log.info(` cd ${input.name}`);
|
|
@@ -37,6 +28,24 @@ export function runCreate(input, deps) {
|
|
|
37
28
|
for (const line of NEXT_STEPS[input.preset])
|
|
38
29
|
deps.log.info(` ${line}`);
|
|
39
30
|
}
|
|
31
|
+
export function runCreate(input, deps) {
|
|
32
|
+
const errors = validateProjectName(input.name);
|
|
33
|
+
if (errors.length > 0) {
|
|
34
|
+
for (const e of errors)
|
|
35
|
+
deps.log.error(e);
|
|
36
|
+
throw new CreateError('Invalid project name.');
|
|
37
|
+
}
|
|
38
|
+
const blueprint = planCreate(input, deps.templates.load(PRESET_TEMPLATE_SET[input.preset]), (raw, data) => deps.renderer.render(raw, data));
|
|
39
|
+
if (input.dryRun) {
|
|
40
|
+
logDryRun(blueprint, deps);
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
deps.writer.ensureUsableDir(blueprint.root, input.force);
|
|
44
|
+
deps.writer.writeFiles(blueprint);
|
|
45
|
+
deps.log.step(`${blueprint.files.length} files written to ${blueprint.root}`);
|
|
46
|
+
runPostCommands(blueprint, input, deps);
|
|
47
|
+
logNextSteps(input, deps);
|
|
48
|
+
}
|
|
40
49
|
const NEXT_STEPS = {
|
|
41
50
|
full: [
|
|
42
51
|
'bun run dev # web :5173 + api :3001',
|
|
@@ -45,7 +54,7 @@ const NEXT_STEPS = {
|
|
|
45
54
|
workspace: [
|
|
46
55
|
'mkdir -p apps/<name> && add a package.json with dev/build/typecheck scripts',
|
|
47
56
|
'bun run dev # runs every apps/*/ dev script in parallel',
|
|
48
|
-
'bun run check # lint + format:check + knip +
|
|
57
|
+
'bun run check # lint + format:check + typecheck + madge + deps + knip + dup + build + test',
|
|
49
58
|
],
|
|
50
59
|
};
|
|
51
60
|
export class CreateError extends Error {
|
package/dist/args.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/** Boolean flags recognized anywhere on the command line (no value). */
|
|
2
|
+
export const KNOWN_FLAGS = new Set([
|
|
3
|
+
'-v',
|
|
4
|
+
'--version',
|
|
5
|
+
'-h',
|
|
6
|
+
'--help',
|
|
7
|
+
'--dry-run',
|
|
8
|
+
'--force',
|
|
9
|
+
'--no-install',
|
|
10
|
+
'--no-git',
|
|
11
|
+
'--skip-install',
|
|
12
|
+
'--skip-git',
|
|
13
|
+
]);
|
|
14
|
+
/**
|
|
15
|
+
* Value-carrying flags pulled out before positional parsing. Supports both
|
|
16
|
+
* `--flag value` and `--flag=value`. If the token after `--flag value` looks
|
|
17
|
+
* like another flag (starts with `-`), the option is treated as missing
|
|
18
|
+
* rather than swallowing that flag as the value.
|
|
19
|
+
*/
|
|
20
|
+
export function takeOption(argv, flag) {
|
|
21
|
+
const eqPrefix = `${flag}=`;
|
|
22
|
+
const eqIndex = argv.findIndex((a) => a.startsWith(eqPrefix));
|
|
23
|
+
if (eqIndex !== -1) {
|
|
24
|
+
const value = argv[eqIndex].slice(eqPrefix.length);
|
|
25
|
+
argv.splice(eqIndex, 1);
|
|
26
|
+
return value;
|
|
27
|
+
}
|
|
28
|
+
const i = argv.indexOf(flag);
|
|
29
|
+
if (i === -1)
|
|
30
|
+
return undefined;
|
|
31
|
+
const value = argv[i + 1];
|
|
32
|
+
if (value === undefined || value.startsWith('-')) {
|
|
33
|
+
argv.splice(i, 1);
|
|
34
|
+
return undefined;
|
|
35
|
+
}
|
|
36
|
+
argv.splice(i, 2);
|
|
37
|
+
return value;
|
|
38
|
+
}
|
|
39
|
+
export function parseArgs(rawArgv) {
|
|
40
|
+
const argv = [...rawArgv];
|
|
41
|
+
const fields = takeOption(argv, '--fields');
|
|
42
|
+
const frontend = takeOption(argv, '--frontend');
|
|
43
|
+
const backend = takeOption(argv, '--backend');
|
|
44
|
+
const args = argv.filter((a) => !a.startsWith('-'));
|
|
45
|
+
const flags = new Set(argv.filter((a) => a.startsWith('-')));
|
|
46
|
+
const [command, name, sub] = args;
|
|
47
|
+
return { fields, frontend, backend, flags, command, name, sub };
|
|
48
|
+
}
|
package/dist/cli.js
CHANGED
|
@@ -9,11 +9,12 @@ import { TemplateRenderer } from './infrastructure/template-renderer.js';
|
|
|
9
9
|
import { DiskFileWriter } from './infrastructure/fs-writer.js';
|
|
10
10
|
import { DiskFileReader } from './infrastructure/fs-reader.js';
|
|
11
11
|
import { ShellRunner } from './infrastructure/shell-runner.js';
|
|
12
|
+
import { KNOWN_FLAGS, parseArgs } from './args.js';
|
|
12
13
|
const HELP = `bahtui — bun-workspace DDD scaffolding (React + Elysia + Eden)
|
|
13
14
|
|
|
14
15
|
Usage:
|
|
15
16
|
bahtui new <name> [options] bare bun workspace in ./<name> — toolchain only
|
|
16
|
-
(eslint, prettier, knip, husky, commitlint), no apps
|
|
17
|
+
(eslint, prettier, knip, husky, commitlint, gitleaks, jscpd, depcruise, CI), no apps
|
|
17
18
|
bahtui create <name> [options] full monorepo in ./<name> (React + Elysia + Eden)
|
|
18
19
|
bahtui init [options] write bahtui.json into an existing repo
|
|
19
20
|
bahtui g model <domain> libs/domain/<d>/model package
|
|
@@ -39,56 +40,18 @@ function version() {
|
|
|
39
40
|
return JSON.parse(readFileSync(pkg, 'utf8')).version;
|
|
40
41
|
}
|
|
41
42
|
const log = {
|
|
42
|
-
info: (m) =>
|
|
43
|
-
|
|
44
|
-
|
|
43
|
+
info: (m) => {
|
|
44
|
+
console.log(m);
|
|
45
|
+
},
|
|
46
|
+
step: (m) => {
|
|
47
|
+
console.log(`• ${m}`);
|
|
48
|
+
},
|
|
49
|
+
error: (m) => {
|
|
50
|
+
console.error(`✖ ${m}`);
|
|
51
|
+
},
|
|
45
52
|
};
|
|
46
|
-
/**
|
|
47
|
-
|
|
48
|
-
'-v',
|
|
49
|
-
'--version',
|
|
50
|
-
'-h',
|
|
51
|
-
'--help',
|
|
52
|
-
'--dry-run',
|
|
53
|
-
'--force',
|
|
54
|
-
'--no-install',
|
|
55
|
-
'--no-git',
|
|
56
|
-
'--skip-install',
|
|
57
|
-
'--skip-git',
|
|
58
|
-
]);
|
|
59
|
-
/**
|
|
60
|
-
* Value-carrying flags pulled out before positional parsing. Supports both
|
|
61
|
-
* `--flag value` and `--flag=value`. If the token after `--flag value` looks
|
|
62
|
-
* like another flag (starts with `-`), the option is treated as missing
|
|
63
|
-
* rather than swallowing that flag as the value.
|
|
64
|
-
*/
|
|
65
|
-
function takeOption(argv, flag) {
|
|
66
|
-
const eqPrefix = `${flag}=`;
|
|
67
|
-
const eqIndex = argv.findIndex((a) => a.startsWith(eqPrefix));
|
|
68
|
-
if (eqIndex !== -1) {
|
|
69
|
-
const value = argv[eqIndex].slice(eqPrefix.length);
|
|
70
|
-
argv.splice(eqIndex, 1);
|
|
71
|
-
return value;
|
|
72
|
-
}
|
|
73
|
-
const i = argv.indexOf(flag);
|
|
74
|
-
if (i === -1)
|
|
75
|
-
return undefined;
|
|
76
|
-
const value = argv[i + 1];
|
|
77
|
-
if (value === undefined || value.startsWith('-')) {
|
|
78
|
-
argv.splice(i, 1);
|
|
79
|
-
return undefined;
|
|
80
|
-
}
|
|
81
|
-
argv.splice(i, 2);
|
|
82
|
-
return value;
|
|
83
|
-
}
|
|
84
|
-
function main(rawArgv) {
|
|
85
|
-
const argv = [...rawArgv];
|
|
86
|
-
const fields = takeOption(argv, '--fields');
|
|
87
|
-
const frontend = takeOption(argv, '--frontend');
|
|
88
|
-
const backend = takeOption(argv, '--backend');
|
|
89
|
-
const args = argv.filter((a) => !a.startsWith('-'));
|
|
90
|
-
const flags = new Set(argv.filter((a) => a.startsWith('-')));
|
|
91
|
-
const [command, name, sub] = args;
|
|
53
|
+
/** Handles unknown flags, --version and --help; null = carry on. */
|
|
54
|
+
function preflight({ flags }) {
|
|
92
55
|
const unknownFlags = [...flags].filter((f) => !KNOWN_FLAGS.has(f));
|
|
93
56
|
if (unknownFlags.length > 0) {
|
|
94
57
|
log.error(`Unknown flag${unknownFlags.length > 1 ? 's' : ''}: ${unknownFlags.join(', ')}`);
|
|
@@ -108,67 +71,89 @@ function main(rawArgv) {
|
|
|
108
71
|
log.info(HELP);
|
|
109
72
|
return 0;
|
|
110
73
|
}
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
function commandCreate(a, command) {
|
|
77
|
+
if (!a.name) {
|
|
78
|
+
log.error(`${command} needs a project name: bahtui ${command} <name>`);
|
|
79
|
+
return 1;
|
|
80
|
+
}
|
|
81
|
+
runCreate({
|
|
82
|
+
name: a.name,
|
|
83
|
+
preset: command === 'new' ? 'workspace' : 'full',
|
|
84
|
+
targetDir: resolve(process.cwd(), a.name),
|
|
85
|
+
install: !a.flags.has('--no-install') && !a.flags.has('--skip-install'),
|
|
86
|
+
git: !a.flags.has('--no-git') && !a.flags.has('--skip-git'),
|
|
87
|
+
dryRun: a.flags.has('--dry-run'),
|
|
88
|
+
force: a.flags.has('--force'),
|
|
89
|
+
}, {
|
|
90
|
+
templates: new DiskTemplateSource(),
|
|
91
|
+
renderer: new TemplateRenderer(),
|
|
92
|
+
writer: new DiskFileWriter(),
|
|
93
|
+
runner: new ShellRunner(),
|
|
94
|
+
log,
|
|
95
|
+
});
|
|
96
|
+
return 0;
|
|
97
|
+
}
|
|
98
|
+
function commandInit(a, generateDeps) {
|
|
99
|
+
runInit({
|
|
100
|
+
root: process.cwd(),
|
|
101
|
+
frontend: a.frontend === 'none' ? null : 'react',
|
|
102
|
+
backend: a.backend === 'none' ? null : 'elysia',
|
|
103
|
+
dryRun: a.flags.has('--dry-run'),
|
|
104
|
+
force: a.flags.has('--force'),
|
|
105
|
+
}, generateDeps);
|
|
106
|
+
return 0;
|
|
107
|
+
}
|
|
108
|
+
function commandGenerate(a, generateDeps) {
|
|
109
|
+
const { name, sub } = a;
|
|
110
|
+
if (name !== 'model' && name !== 'form') {
|
|
111
|
+
log.error('Unknown generator — bahtui g model <domain> | g form <domain>/<name>.');
|
|
112
|
+
return 1;
|
|
113
|
+
}
|
|
114
|
+
if (!sub) {
|
|
115
|
+
log.error(`g ${name} needs a target: bahtui g ${name} <${name === 'model' ? 'domain' : 'domain/name'}>`);
|
|
114
116
|
return 1;
|
|
115
117
|
}
|
|
116
|
-
|
|
117
|
-
|
|
118
|
+
runGenerate({
|
|
119
|
+
root: process.cwd(),
|
|
120
|
+
kind: name,
|
|
121
|
+
target: sub,
|
|
122
|
+
fields: a.fields,
|
|
123
|
+
dryRun: a.flags.has('--dry-run'),
|
|
124
|
+
force: a.flags.has('--force'),
|
|
125
|
+
}, generateDeps);
|
|
126
|
+
return 0;
|
|
127
|
+
}
|
|
128
|
+
function dispatch(a, command) {
|
|
118
129
|
const generateDeps = {
|
|
119
130
|
reader: new DiskFileReader(),
|
|
120
131
|
writer: new DiskFileWriter(),
|
|
121
132
|
log,
|
|
122
133
|
};
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
writer: new DiskFileWriter(),
|
|
141
|
-
runner: new ShellRunner(),
|
|
142
|
-
log,
|
|
143
|
-
});
|
|
144
|
-
return 0;
|
|
145
|
-
}
|
|
146
|
-
if (command === 'init') {
|
|
147
|
-
runInit({
|
|
148
|
-
root: process.cwd(),
|
|
149
|
-
frontend: frontend === 'none' ? null : 'react',
|
|
150
|
-
backend: backend === 'none' ? null : 'elysia',
|
|
151
|
-
dryRun,
|
|
152
|
-
force,
|
|
153
|
-
}, generateDeps);
|
|
154
|
-
return 0;
|
|
155
|
-
}
|
|
156
|
-
if (command === 'g') {
|
|
157
|
-
if (name !== 'model' && name !== 'form') {
|
|
158
|
-
log.error('Unknown generator — bahtui g model <domain> | g form <domain>/<name>.');
|
|
159
|
-
return 1;
|
|
160
|
-
}
|
|
161
|
-
if (!sub) {
|
|
162
|
-
log.error(`g ${name} needs a target: bahtui g ${name} <${name === 'model' ? 'domain' : 'domain/name'}>`);
|
|
163
|
-
return 1;
|
|
164
|
-
}
|
|
165
|
-
runGenerate({ root: process.cwd(), kind: name, target: sub, fields, dryRun, force }, generateDeps);
|
|
166
|
-
return 0;
|
|
167
|
-
}
|
|
168
|
-
log.error(`Unknown command "${command}".`);
|
|
134
|
+
if (command === 'create' || command === 'new')
|
|
135
|
+
return commandCreate(a, command);
|
|
136
|
+
if (command === 'init')
|
|
137
|
+
return commandInit(a, generateDeps);
|
|
138
|
+
if (command === 'g')
|
|
139
|
+
return commandGenerate(a, generateDeps);
|
|
140
|
+
log.error(`Unknown command "${command}".`);
|
|
141
|
+
log.error(HELP);
|
|
142
|
+
return 1;
|
|
143
|
+
}
|
|
144
|
+
function main(rawArgv) {
|
|
145
|
+
const parsed = parseArgs(rawArgv);
|
|
146
|
+
const early = preflight(parsed);
|
|
147
|
+
if (early !== null)
|
|
148
|
+
return early;
|
|
149
|
+
if (parsed.command === undefined) {
|
|
150
|
+
log.error('No command given — see usage below.');
|
|
169
151
|
log.error(HELP);
|
|
170
152
|
return 1;
|
|
171
153
|
}
|
|
154
|
+
try {
|
|
155
|
+
return dispatch(parsed, parsed.command);
|
|
156
|
+
}
|
|
172
157
|
catch (err) {
|
|
173
158
|
log.error(err instanceof CreateError || err instanceof GenerateError ? err.message : String(err));
|
|
174
159
|
return 1;
|
package/dist/domain/form-dsl.js
CHANGED
|
@@ -37,51 +37,55 @@ function parseNumberMod(name, raw, part) {
|
|
|
37
37
|
}
|
|
38
38
|
return n;
|
|
39
39
|
}
|
|
40
|
+
function parseOptionsMod(mod, part) {
|
|
41
|
+
const opts = mod
|
|
42
|
+
.slice('options='.length)
|
|
43
|
+
.split('|')
|
|
44
|
+
.map((o) => o.trim())
|
|
45
|
+
.filter(Boolean);
|
|
46
|
+
if (opts.length === 0) {
|
|
47
|
+
throw new FormDslError(`options modifier needs at least one value in "${part}".`);
|
|
48
|
+
}
|
|
49
|
+
if (new Set(opts).size !== opts.length) {
|
|
50
|
+
throw new FormDslError(`Duplicate option keys in "${part}".`);
|
|
51
|
+
}
|
|
52
|
+
return opts.map((o) => ({ key: o, label: titleCase(o) }));
|
|
53
|
+
}
|
|
54
|
+
function applyModifier(field, mod, part) {
|
|
55
|
+
if (mod === 'required')
|
|
56
|
+
field.required = true;
|
|
57
|
+
else if (mod.startsWith('options='))
|
|
58
|
+
field.options = parseOptionsMod(mod, part);
|
|
59
|
+
else if (mod.startsWith('min='))
|
|
60
|
+
field.min = parseNumberMod('min', mod.slice(4), part);
|
|
61
|
+
else if (mod.startsWith('max='))
|
|
62
|
+
field.max = parseNumberMod('max', mod.slice(4), part);
|
|
63
|
+
else if (mod.startsWith('flag='))
|
|
64
|
+
field.flag = mod.slice(5);
|
|
65
|
+
else
|
|
66
|
+
throw new FormDslError(`Unknown modifier ${JSON.stringify(mod)} in "${part}".`);
|
|
67
|
+
}
|
|
68
|
+
function parseFieldPart(part, order) {
|
|
69
|
+
const [key, type, ...mods] = part.split(':').map((s) => s.trim());
|
|
70
|
+
if (!key || !KEY_PATTERN.test(key)) {
|
|
71
|
+
throw new FormDslError(`Bad field key ${JSON.stringify(key ?? '')} in "${part}".`);
|
|
72
|
+
}
|
|
73
|
+
if (!type || !FIELD_TYPES.has(type)) {
|
|
74
|
+
throw new FormDslError(`Unknown field type ${JSON.stringify(type ?? '')} in "${part}" — one of: ${[...FIELD_TYPES].join(', ')}.`);
|
|
75
|
+
}
|
|
76
|
+
const field = { order, type, key, label: titleCase(key) };
|
|
77
|
+
for (const mod of mods)
|
|
78
|
+
applyModifier(field, mod, part);
|
|
79
|
+
return field;
|
|
80
|
+
}
|
|
40
81
|
export function parseFieldsDsl(dsl) {
|
|
41
|
-
const fields = [];
|
|
42
82
|
const parts = dsl
|
|
43
83
|
.split(',')
|
|
44
84
|
.map((p) => p.trim())
|
|
45
85
|
.filter(Boolean);
|
|
46
86
|
if (parts.length === 0)
|
|
47
87
|
throw new FormDslError('No fields given — pass --fields "key:type,…".');
|
|
48
|
-
|
|
49
|
-
const [key, type, ...mods] = part.split(':').map((s) => s.trim());
|
|
50
|
-
if (!key || !KEY_PATTERN.test(key)) {
|
|
51
|
-
throw new FormDslError(`Bad field key ${JSON.stringify(key ?? '')} in "${part}".`);
|
|
52
|
-
}
|
|
53
|
-
if (!type || !FIELD_TYPES.has(type)) {
|
|
54
|
-
throw new FormDslError(`Unknown field type ${JSON.stringify(type ?? '')} in "${part}" — one of: ${[...FIELD_TYPES].join(', ')}.`);
|
|
55
|
-
}
|
|
56
|
-
const field = { order: i + 1, type, key, label: titleCase(key) };
|
|
57
|
-
for (const mod of mods) {
|
|
58
|
-
if (mod === 'required')
|
|
59
|
-
field.required = true;
|
|
60
|
-
else if (mod.startsWith('options=')) {
|
|
61
|
-
const opts = mod
|
|
62
|
-
.slice('options='.length)
|
|
63
|
-
.split('|')
|
|
64
|
-
.map((o) => o.trim())
|
|
65
|
-
.filter(Boolean);
|
|
66
|
-
if (opts.length === 0) {
|
|
67
|
-
throw new FormDslError(`options modifier needs at least one value in "${part}".`);
|
|
68
|
-
}
|
|
69
|
-
if (new Set(opts).size !== opts.length) {
|
|
70
|
-
throw new FormDslError(`Duplicate option keys in "${part}".`);
|
|
71
|
-
}
|
|
72
|
-
field.options = opts.map((o) => ({ key: o, label: titleCase(o) }));
|
|
73
|
-
}
|
|
74
|
-
else if (mod.startsWith('min='))
|
|
75
|
-
field.min = parseNumberMod('min', mod.slice(4), part);
|
|
76
|
-
else if (mod.startsWith('max='))
|
|
77
|
-
field.max = parseNumberMod('max', mod.slice(4), part);
|
|
78
|
-
else if (mod.startsWith('flag='))
|
|
79
|
-
field.flag = mod.slice(5);
|
|
80
|
-
else
|
|
81
|
-
throw new FormDslError(`Unknown modifier ${JSON.stringify(mod)} in "${part}".`);
|
|
82
|
-
}
|
|
83
|
-
fields.push(field);
|
|
84
|
-
}
|
|
88
|
+
const fields = parts.map((part, i) => parseFieldPart(part, i + 1));
|
|
85
89
|
const keys = fields.map((f) => f.key);
|
|
86
90
|
if (new Set(keys).size !== keys.length) {
|
|
87
91
|
throw new FormDslError('Field keys must be unique.');
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bank20baht-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Scaffold bun workspaces (bahtui new) and DDD monorepos (React + Elysia + Eden, bahtui create) plus bahtui generators.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -40,10 +40,11 @@
|
|
|
40
40
|
"scripts": {
|
|
41
41
|
"build": "tsc -p tsconfig.json",
|
|
42
42
|
"test": "vitest run",
|
|
43
|
-
"test:watch": "vitest"
|
|
43
|
+
"test:watch": "vitest",
|
|
44
|
+
"test:coverage": "vitest run --coverage"
|
|
44
45
|
},
|
|
45
46
|
"dependencies": {
|
|
46
|
-
"bank20baht-validator": "^0.
|
|
47
|
+
"bank20baht-validator": "^0.3.0"
|
|
47
48
|
},
|
|
48
49
|
"devDependencies": {
|
|
49
50
|
"@types/node": "^22",
|
|
@@ -22,15 +22,17 @@ scripts/each.ts `bun run scripts/each.ts <script>` in every workspace that
|
|
|
22
22
|
```
|
|
23
23
|
|
|
24
24
|
Add a workspace by creating `apps/<name>/package.json` (or `packages/<name>`)
|
|
25
|
-
with `dev`, `build` and `typecheck` scripts, then extend `knip.
|
|
25
|
+
with `dev`, `build` and `typecheck` scripts, then extend `knip.ts` with a
|
|
26
26
|
matching workspace entry so dead-code checks cover it.
|
|
27
27
|
|
|
28
28
|
## Toolchain
|
|
29
29
|
|
|
30
30
|
- **ESLint 10 + typescript-eslint** (`eslint.config.ts`), **Prettier** (no semi,
|
|
31
|
-
single quotes, width 100), **knip** — all run by `bun run check`.
|
|
31
|
+
single quotes, width 100), **knip** — all run by `bun run check`. Their config comes from
|
|
32
|
+
`bank20baht-strict`: bump it to take rule updates (Renovate does this); put app overrides
|
|
33
|
+
last, each with a reason.
|
|
32
34
|
- **husky** hooks: `pre-commit` → lint-staged, `commit-msg` → commitlint
|
|
33
35
|
(Conventional Commits), `pre-push` → `bun run check`.
|
|
34
|
-
- `tsconfig.base.json` —
|
|
35
|
-
`verbatimModuleSyntax`, `noUncheckedIndexedAccess
|
|
36
|
-
workspace's `tsconfig.json`.
|
|
36
|
+
- `tsconfig.base.json` — extends `bank20baht-strict/tsconfig/base.json` (strict,
|
|
37
|
+
`verbatimModuleSyntax`, `noUncheckedIndexedAccess`, `exactOptionalPropertyTypes`, ...).
|
|
38
|
+
Extend it from each workspace's `tsconfig.json`.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Architecture boundaries, checked with `bun run deps`. `generic` gives no-circular and
|
|
3
|
+
* production-not-tests for apps/* and packages/*; add layer rules (features may not import
|
|
4
|
+
* each other, shared/ knows no feature, ...) as your structure grows. Every rule is `error`:
|
|
5
|
+
* a violation is a decision, not a warning.
|
|
6
|
+
*/
|
|
7
|
+
import { makeConfig } from 'bank20baht-strict/depcruise'
|
|
8
|
+
|
|
9
|
+
export default makeConfig({ generic: true })
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
name: Setup
|
|
2
|
+
description: Bun at the version pinned in package.json, with the install cache restored, and dependencies installed.
|
|
3
|
+
runs:
|
|
4
|
+
using: composite
|
|
5
|
+
steps:
|
|
6
|
+
- uses: oven-sh/setup-bun@v2
|
|
7
|
+
with:
|
|
8
|
+
bun-version-file: package.json
|
|
9
|
+
- uses: actions/cache@v4
|
|
10
|
+
with:
|
|
11
|
+
path: ~/.bun/install/cache
|
|
12
|
+
key: bun-${{ runner.os }}-${{ hashFiles('bun.lock') }}
|
|
13
|
+
restore-keys: bun-${{ runner.os }}-
|
|
14
|
+
- run: bun install --frozen-lockfile
|
|
15
|
+
shell: bash
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
workflow_dispatch:
|
|
8
|
+
|
|
9
|
+
permissions:
|
|
10
|
+
contents: read
|
|
11
|
+
|
|
12
|
+
concurrency:
|
|
13
|
+
group: ci-${{ github.ref }}
|
|
14
|
+
cancel-in-progress: true
|
|
15
|
+
|
|
16
|
+
jobs:
|
|
17
|
+
lint:
|
|
18
|
+
name: lint · prettier
|
|
19
|
+
runs-on: ubuntu-latest
|
|
20
|
+
timeout-minutes: 10
|
|
21
|
+
steps:
|
|
22
|
+
- uses: actions/checkout@v4
|
|
23
|
+
- uses: ./.github/actions/setup
|
|
24
|
+
- run: bun run lint
|
|
25
|
+
- run: bun run format:check
|
|
26
|
+
|
|
27
|
+
typecheck:
|
|
28
|
+
name: typecheck
|
|
29
|
+
runs-on: ubuntu-latest
|
|
30
|
+
timeout-minutes: 10
|
|
31
|
+
steps:
|
|
32
|
+
- uses: actions/checkout@v4
|
|
33
|
+
- uses: ./.github/actions/setup
|
|
34
|
+
- run: bun run typecheck
|
|
35
|
+
|
|
36
|
+
structure:
|
|
37
|
+
name: knip · madge · boundaries
|
|
38
|
+
runs-on: ubuntu-latest
|
|
39
|
+
timeout-minutes: 10
|
|
40
|
+
steps:
|
|
41
|
+
- uses: actions/checkout@v4
|
|
42
|
+
- uses: ./.github/actions/setup
|
|
43
|
+
- run: bun run knip
|
|
44
|
+
- run: bun run madge
|
|
45
|
+
- run: bun run deps
|
|
46
|
+
|
|
47
|
+
dup:
|
|
48
|
+
name: duplication
|
|
49
|
+
runs-on: ubuntu-latest
|
|
50
|
+
timeout-minutes: 10
|
|
51
|
+
steps:
|
|
52
|
+
- uses: actions/checkout@v4
|
|
53
|
+
- uses: ./.github/actions/setup
|
|
54
|
+
- run: bun run dup -- --reporters console,json --output jscpd-report
|
|
55
|
+
- uses: actions/upload-artifact@v4
|
|
56
|
+
if: failure()
|
|
57
|
+
with:
|
|
58
|
+
name: jscpd-report
|
|
59
|
+
path: jscpd-report
|
|
60
|
+
retention-days: 7
|
|
61
|
+
|
|
62
|
+
test:
|
|
63
|
+
name: test
|
|
64
|
+
runs-on: ubuntu-latest
|
|
65
|
+
timeout-minutes: 10
|
|
66
|
+
steps:
|
|
67
|
+
- uses: actions/checkout@v4
|
|
68
|
+
- uses: ./.github/actions/setup
|
|
69
|
+
- run: bun run test
|
|
70
|
+
- uses: actions/upload-artifact@v4
|
|
71
|
+
if: failure()
|
|
72
|
+
with:
|
|
73
|
+
name: coverage
|
|
74
|
+
path: '**/coverage'
|
|
75
|
+
retention-days: 7
|
|
76
|
+
|
|
77
|
+
build:
|
|
78
|
+
name: build
|
|
79
|
+
runs-on: ubuntu-latest
|
|
80
|
+
timeout-minutes: 10
|
|
81
|
+
steps:
|
|
82
|
+
- uses: actions/checkout@v4
|
|
83
|
+
- uses: ./.github/actions/setup
|
|
84
|
+
- run: bun run build
|
|
85
|
+
|
|
86
|
+
gitleaks:
|
|
87
|
+
name: gitleaks
|
|
88
|
+
runs-on: ubuntu-latest
|
|
89
|
+
timeout-minutes: 5
|
|
90
|
+
steps:
|
|
91
|
+
- uses: actions/checkout@v4
|
|
92
|
+
with:
|
|
93
|
+
fetch-depth: 0
|
|
94
|
+
- uses: gitleaks/gitleaks-action@v2
|
|
95
|
+
env:
|
|
96
|
+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
97
|
+
|
|
98
|
+
audit:
|
|
99
|
+
name: dependency audit
|
|
100
|
+
runs-on: ubuntu-latest
|
|
101
|
+
timeout-minutes: 5
|
|
102
|
+
steps:
|
|
103
|
+
- uses: actions/checkout@v4
|
|
104
|
+
- uses: ./.github/actions/setup
|
|
105
|
+
# The npm advisory endpoint answers 503 now and then; retry before giving
|
|
106
|
+
# up, and never block a merge on it - a red audit is a warning to look at.
|
|
107
|
+
- run: |
|
|
108
|
+
for i in 1 2 3; do bun run audit && exit 0; echo "audit attempt $i failed"; sleep 15; done
|
|
109
|
+
exit 1
|
|
110
|
+
continue-on-error: true
|
|
111
|
+
|
|
112
|
+
commitlint:
|
|
113
|
+
name: commitlint
|
|
114
|
+
if: github.event_name == 'pull_request'
|
|
115
|
+
runs-on: ubuntu-latest
|
|
116
|
+
timeout-minutes: 5
|
|
117
|
+
steps:
|
|
118
|
+
- uses: actions/checkout@v4
|
|
119
|
+
with:
|
|
120
|
+
fetch-depth: 0
|
|
121
|
+
- uses: ./.github/actions/setup
|
|
122
|
+
- run: bunx commitlint --from ${{ github.event.pull_request.base.sha }} --to ${{ github.event.pull_request.head.sha }} --verbose
|
|
@@ -1 +1,8 @@
|
|
|
1
|
+
# Secret scan of what is about to be committed. CI runs the same scan over the
|
|
2
|
+
# whole history, so a machine without gitleaks only loses the early warning.
|
|
3
|
+
if command -v gitleaks >/dev/null 2>&1; then
|
|
4
|
+
gitleaks git --pre-commit --staged --redact --no-banner || exit 1
|
|
5
|
+
else
|
|
6
|
+
echo "gitleaks is not installed - skipping the local secret scan (CI still runs it). brew install gitleaks" >&2
|
|
7
|
+
fi
|
|
1
8
|
bunx lint-staged
|
|
@@ -1 +1 @@
|
|
|
1
|
-
bun run check
|
|
1
|
+
bun run check:fast
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"path": ["apps", "packages", "scripts"],
|
|
3
|
+
"format": ["typescript", "markup", "css"],
|
|
4
|
+
"ignore": [
|
|
5
|
+
"**/node_modules/**",
|
|
6
|
+
"**/dist/**",
|
|
7
|
+
"**/coverage/**",
|
|
8
|
+
"**/test-results/**",
|
|
9
|
+
"**/playwright-report/**",
|
|
10
|
+
"**/*.spec.ts",
|
|
11
|
+
"**/*.test.ts",
|
|
12
|
+
"**/*.d.ts",
|
|
13
|
+
"**/*.gen.ts"
|
|
14
|
+
],
|
|
15
|
+
"minTokens": 50,
|
|
16
|
+
"minLines": 5,
|
|
17
|
+
"gitignore": true,
|
|
18
|
+
"reporters": ["console"],
|
|
19
|
+
"threshold": 5
|
|
20
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { defineConfig } from 'eslint/config'
|
|
2
|
+
|
|
3
|
+
import { base } from 'bank20baht-strict/eslint'
|
|
4
|
+
|
|
5
|
+
// Every rule in `base` is an error. Add app overrides below, each with a reason:
|
|
6
|
+
// // eslint-disable-next-line <rule> -- <why>
|
|
7
|
+
export default defineConfig([...base])
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default } from 'bank20baht-strict/lint-staged'
|
|
@@ -2,7 +2,10 @@
|
|
|
2
2
|
"name": "{{name}}",
|
|
3
3
|
"private": true,
|
|
4
4
|
"type": "module",
|
|
5
|
-
"workspaces": [
|
|
5
|
+
"workspaces": [
|
|
6
|
+
"apps/*",
|
|
7
|
+
"packages/*"
|
|
8
|
+
],
|
|
6
9
|
"scripts": {
|
|
7
10
|
"dev": "bun run scripts/dev.ts",
|
|
8
11
|
"build": "bun run scripts/each.ts build",
|
|
@@ -12,24 +15,35 @@
|
|
|
12
15
|
"format": "prettier --write .",
|
|
13
16
|
"format:check": "prettier --check .",
|
|
14
17
|
"knip": "knip",
|
|
15
|
-
"
|
|
18
|
+
"madge": "madge --circular --extensions ts,tsx apps packages scripts",
|
|
19
|
+
"deps": "depcruise apps packages scripts --config .dependency-cruiser.mjs",
|
|
20
|
+
"dup": "jscpd",
|
|
21
|
+
"audit": "bun audit --audit-level=high",
|
|
22
|
+
"secrets": "gitleaks git --redact --no-banner",
|
|
23
|
+
"test": "bun run scripts/each.ts test:coverage",
|
|
24
|
+
"check:fast": "bun run lint && bun run format:check && bun run typecheck && bun run madge && bun run deps",
|
|
25
|
+
"check": "bun run check:fast && bun run knip && bun run dup && bun run build && bun run test",
|
|
16
26
|
"prepare": "husky"
|
|
17
27
|
},
|
|
18
|
-
"
|
|
19
|
-
|
|
20
|
-
"*.{json,md,html,css}": ["prettier --write"]
|
|
21
|
-
},
|
|
28
|
+
"prettier": "bank20baht-strict/prettier",
|
|
29
|
+
"packageManager": "bun@1.3.14",
|
|
22
30
|
"devDependencies": {
|
|
23
31
|
"@commitlint/cli": "^21.2.2",
|
|
24
32
|
"@commitlint/config-conventional": "^21.2.2",
|
|
33
|
+
"@eslint/css": "^2.0.0",
|
|
25
34
|
"@eslint/js": "^10.0.1",
|
|
26
35
|
"@types/bun": "^1.3.0",
|
|
36
|
+
"bank20baht-strict": "0.0.3",
|
|
37
|
+
"dependency-cruiser": "^18.3.1",
|
|
27
38
|
"eslint": "^10.10.0",
|
|
28
39
|
"eslint-config-prettier": "^10.1.8",
|
|
40
|
+
"eslint-plugin-sonarjs": "^4.2.1",
|
|
29
41
|
"husky": "^9.1.7",
|
|
30
42
|
"jiti": "^2.6.1",
|
|
43
|
+
"jscpd": "^5.3.0",
|
|
31
44
|
"knip": "^6.35.1",
|
|
32
45
|
"lint-staged": "^17.5.1",
|
|
46
|
+
"madge": "^8.0.0",
|
|
33
47
|
"prettier": "^3.9.6",
|
|
34
48
|
"typescript": "^5.9.3",
|
|
35
49
|
"typescript-eslint": "^8.70.0"
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
|
3
|
+
"extends": ["config:recommended", ":semanticCommitTypeAll(chore)"],
|
|
4
|
+
"schedule": ["before 6am on monday"],
|
|
5
|
+
"prConcurrentLimit": 4,
|
|
6
|
+
"packageRules": [
|
|
7
|
+
{
|
|
8
|
+
"description": "Everything not matched below: one weekly PR.",
|
|
9
|
+
"matchPackageNames": ["*"],
|
|
10
|
+
"groupName": "others"
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
"description": "Dev tooling: linters, formatters, test runners, type packages.",
|
|
14
|
+
"matchDepTypes": ["devDependencies"],
|
|
15
|
+
"groupName": "dev-deps"
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
"description": "GitHub Actions move together.",
|
|
19
|
+
"matchManagers": ["github-actions"],
|
|
20
|
+
"groupName": "github-actions"
|
|
21
|
+
}
|
|
22
|
+
]
|
|
23
|
+
}
|
|
@@ -20,6 +20,10 @@ if (targets.length === 0) {
|
|
|
20
20
|
|
|
21
21
|
for (const { name, cwd } of targets) {
|
|
22
22
|
console.log(`\x1b[36m[${name}]\x1b[0m bun run ${script}`)
|
|
23
|
-
const proc = Bun.spawnSync(['bun', 'run', script], {
|
|
24
|
-
|
|
23
|
+
const proc = Bun.spawnSync(['bun', 'run', script], {
|
|
24
|
+
cwd,
|
|
25
|
+
env: process.env,
|
|
26
|
+
stdio: ['inherit', 'inherit', 'inherit'],
|
|
27
|
+
})
|
|
28
|
+
if (proc.exitCode !== 0) process.exit(proc.exitCode)
|
|
25
29
|
}
|
|
@@ -1,16 +1,3 @@
|
|
|
1
1
|
{
|
|
2
|
-
"
|
|
3
|
-
"target": "ESNext",
|
|
4
|
-
"module": "ESNext",
|
|
5
|
-
"moduleResolution": "bundler",
|
|
6
|
-
"moduleDetection": "force",
|
|
7
|
-
"allowImportingTsExtensions": true,
|
|
8
|
-
"verbatimModuleSyntax": true,
|
|
9
|
-
"esModuleInterop": true,
|
|
10
|
-
"resolveJsonModule": true,
|
|
11
|
-
"strict": true,
|
|
12
|
-
"skipLibCheck": true,
|
|
13
|
-
"noUncheckedIndexedAccess": true,
|
|
14
|
-
"noEmit": true
|
|
15
|
-
}
|
|
2
|
+
"extends": "bank20baht-strict/tsconfig/base.json"
|
|
16
3
|
}
|
|
@@ -1,24 +0,0 @@
|
|
|
1
|
-
import js from '@eslint/js'
|
|
2
|
-
import prettier from 'eslint-config-prettier'
|
|
3
|
-
import tseslint from 'typescript-eslint'
|
|
4
|
-
|
|
5
|
-
export default tseslint.config(
|
|
6
|
-
{
|
|
7
|
-
ignores: ['**/dist/**', '**/node_modules/**', '**/*.gen.ts', '**/.angular/**'],
|
|
8
|
-
},
|
|
9
|
-
js.configs.recommended,
|
|
10
|
-
...tseslint.configs.recommended,
|
|
11
|
-
{
|
|
12
|
-
languageOptions: {
|
|
13
|
-
parserOptions: { tsconfigRootDir: import.meta.dirname },
|
|
14
|
-
},
|
|
15
|
-
rules: {
|
|
16
|
-
'@typescript-eslint/no-unused-vars': [
|
|
17
|
-
'error',
|
|
18
|
-
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_' },
|
|
19
|
-
],
|
|
20
|
-
'@typescript-eslint/consistent-type-imports': 'error',
|
|
21
|
-
},
|
|
22
|
-
},
|
|
23
|
-
prettier,
|
|
24
|
-
)
|