bank20baht-cli 0.2.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,156 @@
1
+ # bank20baht-cli
2
+
3
+ Scaffolding CLI for bun workspaces and [bahtui](https://github.com/bank20baht/bahtui) form
4
+ projects. Ships the `bahtui` binary.
5
+
6
+ ```sh
7
+ bun add -g bank20baht-cli # or: npm i -g bank20baht-cli
8
+ bahtui --help
9
+
10
+ # one-off, no install
11
+ bunx bank20baht-cli new my-workspace
12
+ ```
13
+
14
+ Requires Node ≥ 20.12 (or bun). `bun` and `git` must be on `PATH` for the post-scaffold
15
+ steps.
16
+
17
+ ## Commands
18
+
19
+ | Command | What it does |
20
+ | ---------------------------------------- | ----------------------------------------------------------------- |
21
+ | `bahtui new <name>` | Bare bun workspace in `./<name>` — toolchain only, no apps |
22
+ | `bahtui create <name>` | Full DDD monorepo in `./<name>` — React + Elysia + Eden |
23
+ | `bahtui init` | Write `bahtui.json` into an existing repo (enables the generators) |
24
+ | `bahtui g model <domain>` | `libs/domain/<domain>/model` package |
25
+ | `bahtui g form <domain>/<name> --fields` | Engine-validated `FormDefinition` JSON inside that model package |
26
+
27
+ ### `bahtui new <name>`
28
+
29
+ Angular-style project bootstrap. Generates a bun workspace with the shared toolchain and
30
+ nothing else — you add `apps/*` and `packages/*` yourself.
31
+
32
+ ```sh
33
+ bahtui new acme
34
+ cd acme
35
+ bun run check # lint + format:check + knip + typecheck — green on the empty repo
36
+ ```
37
+
38
+ What you get:
39
+
40
+ ```
41
+ package.json workspaces apps/* + packages/*, lint-staged, check script
42
+ tsconfig.base.json strict, moduleResolution bundler, verbatimModuleSyntax
43
+ tsconfig.json typechecks scripts/ (extend the base from each workspace)
44
+ eslint.config.ts ESLint 10 + typescript-eslint + eslint-config-prettier
45
+ .prettierrc no semicolons, single quotes, printWidth 100
46
+ knip.json dead-code / unused-dependency checks
47
+ commitlint.config.js Conventional Commits
48
+ .husky/ pre-commit → gitleaks + lint-staged · commit-msg → commitlint · pre-push → bun run check:fast
49
+ .gitleaks.toml · .jscpd.json · .dependency-cruiser.cjs · renovate.json · .github/ (CI: lint, typecheck, knip/madge/deps, dup, test, build, gitleaks, audit, commitlint)
50
+ scripts/dev.ts runs every apps/*/ "dev" script in parallel with prefixed output
51
+ scripts/each.ts `bun run scripts/each.ts <script>` in every workspace that defines it
52
+ .mcp.json, CLAUDE.md zvec_grep code-search MCP wiring for Claude Code
53
+ apps/, packages/ empty (.gitkeep)
54
+ ```
55
+
56
+ By default the CLI runs `git init`, `bun install`, then makes the first commit — so the
57
+ husky hooks prove themselves before you touch anything. Flags:
58
+
59
+ ```
60
+ --skip-install (alias --no-install) skip bun install and the initial commit
61
+ --skip-git (alias --no-git) skip git init and the initial commit
62
+ --dry-run print the file list, write nothing
63
+ --force write into a non-empty directory
64
+ ```
65
+
66
+ Add a workspace by creating `apps/<name>/package.json` with `dev` / `build` / `typecheck`
67
+ scripts; `bun run dev`, `bun run build` and `bun run typecheck` pick it up automatically.
68
+ Add a matching entry to `knip.json` so dead-code checks cover it.
69
+
70
+ ### `bahtui create <name>`
71
+
72
+ Same flags as `new`, but scaffolds a complete stack:
73
+
74
+ ```
75
+ apps/web React 19 + Vite
76
+ apps/api Elysia — exports `type App`, the Eden type anchor
77
+ libs/shared/eden treaty<App> — the one typed HTTP client
78
+ bahtui.json generator config (see below)
79
+ ```
80
+
81
+ ```sh
82
+ bahtui create shop
83
+ cd shop
84
+ bun run dev # web on :5173, api on :3001
85
+ ```
86
+
87
+ ### `bahtui init`
88
+
89
+ Writes `bahtui.json` into the current directory so `bahtui g …` knows where things live.
90
+
91
+ ```sh
92
+ bahtui init # frontend react, backend elysia
93
+ bahtui init --frontend none # no React feature slices
94
+ bahtui init --backend none # no Elysia plugins
95
+ ```
96
+
97
+ ```json
98
+ {
99
+ "apps": { "web": "apps/web", "api": "apps/api" },
100
+ "libsDir": "libs",
101
+ "frontend": "react",
102
+ "backend": "elysia",
103
+ "edenClient": "libs/shared/eden"
104
+ }
105
+ ```
106
+
107
+ ### `bahtui g model <domain>`
108
+
109
+ Creates `<libsDir>/domain/<domain>/model` — a framework-free package for entities and form
110
+ JSON. `<domain>` is kebab-case.
111
+
112
+ ### `bahtui g form <domain>/<name> --fields "<dsl>"`
113
+
114
+ Builds a `FormDefinition`, validates it with
115
+ [`bank20baht-validator`](https://www.npmjs.com/package/bank20baht-validator) (the same
116
+ engine the browser components run), and writes
117
+ `<libsDir>/domain/<domain>/model/src/<name>.form.json`.
118
+
119
+ ```sh
120
+ bahtui g form ticket/create \
121
+ --fields "title:text:required,priority:dropdown:options=low|medium|high,qty:number:min=1"
122
+ ```
123
+
124
+ Field DSL — comma-separated `key:type[:modifier…]`:
125
+
126
+ | Part | Values |
127
+ | ---------- | -------------------------------------------------------------------------------------------------------------- |
128
+ | `key` | `[a-z][a-z0-9_]*`; the label is derived (`due_date` → "Due Date") |
129
+ | `type` | `text` `textarea` `number` `email` `password` `dropdown` `multiselect` `checkbox` `radio` `toggle` `date` `file` |
130
+ | modifiers | `required` · `options=a\|b\|c` · `min=N` · `max=N` · `flag=NAME` |
131
+
132
+ Invalid DSL (unknown type, empty options, non-numeric `min`) fails before anything is
133
+ written.
134
+
135
+ ## Global flags
136
+
137
+ ```
138
+ --dry-run print what would be generated, write nothing
139
+ --force overwrite existing files / write into non-empty dirs
140
+ -h, --help
141
+ -v, --version
142
+ ```
143
+
144
+ ## How it works
145
+
146
+ Every command produces a **blueprint** — plain data listing files and post-commands — then
147
+ adapters write it to disk and run the commands. `--dry-run` prints the blueprint. Template
148
+ files ending in `.hbs` get `{{name}}` substitution; everything else is copied verbatim, and a
149
+ leading `_` in a template filename becomes `.` (so `.gitignore` survives `npm pack`).
150
+
151
+ Generated file paths are confined to the target directory; a hostile `bahtui.json` cannot
152
+ write outside the project root.
153
+
154
+ ## License
155
+
156
+ MIT
@@ -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
- export function runCreate(input, deps) {
4
- const errors = validateProjectName(input.name);
5
- if (errors.length > 0) {
6
- for (const e of errors)
7
- deps.log.error(e);
8
- throw new CreateError('Invalid project name.');
9
- }
10
- const blueprint = planCreate(input, deps.templates.load(PRESET_TEMPLATE_SET[input.preset]), (raw, data) => deps.renderer.render(raw, data));
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 + typecheck',
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) => console.log(m),
43
- step: (m) => console.log(`• ${m}`),
44
- error: (m) => console.error(`✖ ${m}`),
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
- /** Boolean flags recognized anywhere on the command line (no value). */
47
- const KNOWN_FLAGS = new Set([
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
- if (command === undefined) {
112
- log.error('No command given — see usage below.');
113
- log.error(HELP);
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
- const dryRun = flags.has('--dry-run');
117
- const force = flags.has('--force');
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
- try {
124
- if (command === 'create' || command === 'new') {
125
- if (!name) {
126
- log.error(`${command} needs a project name: bahtui ${command} <name>`);
127
- return 1;
128
- }
129
- runCreate({
130
- name,
131
- preset: command === 'new' ? 'workspace' : 'full',
132
- targetDir: resolve(process.cwd(), name),
133
- install: !flags.has('--no-install') && !flags.has('--skip-install'),
134
- git: !flags.has('--no-git') && !flags.has('--skip-git'),
135
- dryRun,
136
- force,
137
- }, {
138
- templates: new DiskTemplateSource(),
139
- renderer: new TemplateRenderer(),
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;
@@ -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
- for (const [i, part] of parts.entries()) {
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.');
@@ -91,9 +95,9 @@ export function parseFieldsDsl(dsl) {
91
95
  /** DSL -> FormDefinition, engine-validated. Throws FormDslError on any problem. */
92
96
  export function buildFormDefinition(domain, name, dsl) {
93
97
  const form = {
94
- formId: `${domain}-${name}`,
95
- formName: titleCase(name.replace(/-/g, '_')).replace(/ /g, '_'),
96
- field_data: parseFieldsDsl(dsl),
98
+ form_id: `${domain}-${name}`,
99
+ form_name: titleCase(name.replace(/-/g, '_')).replace(/ /g, '_'),
100
+ fields: parseFieldsDsl(dsl),
97
101
  };
98
102
  try {
99
103
  return parseSchema(JSON.stringify(form));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bank20baht-cli",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
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.1.0"
47
+ "bank20baht-validator": "^0.3.0"
47
48
  },
48
49
  "devDependencies": {
49
50
  "@types/node": "^22",
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Architecture boundaries, checked with `bun run deps`. Add a rule per layer
3
+ * rule you want enforced (features may not import each other, shared/ knows no
4
+ * feature, ...). Every rule is `error`: a violation is a decision, not a warning.
5
+ */
6
+ const TESTS = '\\.(test|spec)\\.tsx?$'
7
+
8
+ /** @type {import('dependency-cruiser').IConfiguration} */
9
+ module.exports = {
10
+ forbidden: [
11
+ {
12
+ name: 'no-circular',
13
+ severity: 'error',
14
+ from: {},
15
+ to: { circular: true },
16
+ },
17
+ {
18
+ name: 'production-not-tests',
19
+ severity: 'error',
20
+ from: { path: '^(apps|packages)/[^/]+/src/', pathNot: TESTS },
21
+ to: { path: TESTS },
22
+ },
23
+ ],
24
+ options: {
25
+ doNotFollow: { path: 'node_modules' },
26
+ tsPreCompilationDeps: true,
27
+ exclude: { path: ['/dist/', '\\.gen\\.ts$'] },
28
+ },
29
+ }
@@ -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
@@ -0,0 +1,6 @@
1
+ # Default gitleaks rule set. Add an [[allowlists]] entry here (with a reason)
2
+ # rather than a `# gitleaks:allow` comment when a finding is a false positive.
3
+ title = "gitleaks config"
4
+
5
+ [extend]
6
+ useDefault = true
@@ -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
+ }
@@ -1,3 +1,4 @@
1
1
  node_modules
2
2
  dist
3
3
  bun.lock
4
+ coverage
@@ -0,0 +1,64 @@
1
+ import js from '@eslint/js'
2
+ import prettier from 'eslint-config-prettier'
3
+ import sonarjs from 'eslint-plugin-sonarjs'
4
+ import { defineConfig } from 'eslint/config'
5
+ import tseslint from 'typescript-eslint'
6
+
7
+ export default defineConfig([
8
+ {
9
+ ignores: [
10
+ '**/dist/**',
11
+ '**/node_modules/**',
12
+ '**/*.gen.ts',
13
+ '**/.angular/**',
14
+ '**/coverage/**',
15
+ '**/playwright-report/**',
16
+ '**/test-results/**',
17
+ ],
18
+ },
19
+ // Plain JS config files: no type information available.
20
+ {
21
+ files: ['**/*.{js,mjs,cjs}'],
22
+ extends: [js.configs.recommended],
23
+ },
24
+ // TypeScript, type-aware. Every .ts file must be covered by a tsconfig.
25
+ {
26
+ files: ['**/*.{ts,tsx}'],
27
+ extends: [
28
+ js.configs.recommended,
29
+ ...tseslint.configs.strictTypeChecked,
30
+ ...tseslint.configs.stylisticTypeChecked,
31
+ ],
32
+ languageOptions: {
33
+ parserOptions: { projectService: true, tsconfigRootDir: import.meta.dirname },
34
+ },
35
+ rules: {
36
+ '@typescript-eslint/no-unused-vars': [
37
+ 'error',
38
+ { argsIgnorePattern: '^_', varsIgnorePattern: '^_' },
39
+ ],
40
+ '@typescript-eslint/consistent-type-imports': 'error',
41
+ '@typescript-eslint/switch-exhaustiveness-check': 'error',
42
+ '@typescript-eslint/consistent-type-definitions': ['error', 'type'],
43
+ '@typescript-eslint/restrict-template-expressions': ['error', { allowNumber: true }],
44
+ },
45
+ },
46
+ // Duplication and complexity (jscpd covers whole-block clones, see .jscpd.json).
47
+ {
48
+ files: ['**/*.{ts,tsx}'],
49
+ plugins: { sonarjs },
50
+ rules: {
51
+ 'sonarjs/no-identical-functions': 'error',
52
+ 'sonarjs/no-identical-expressions': 'error',
53
+ 'sonarjs/cognitive-complexity': ['error', 15],
54
+ },
55
+ },
56
+ // Repeated literals are ordinary in tests, so the rule only guards production code.
57
+ {
58
+ files: ['**/*.{ts,tsx}'],
59
+ ignores: ['**/*.test.ts', '**/*.spec.ts'],
60
+ plugins: { sonarjs },
61
+ rules: { 'sonarjs/no-duplicate-string': ['error', { threshold: 4 }] },
62
+ },
63
+ prettier,
64
+ ])
@@ -5,5 +5,6 @@
5
5
  "entry": ["scripts/*.ts"],
6
6
  "project": ["scripts/**/*.ts"]
7
7
  }
8
- }
8
+ },
9
+ "ignoreBinaries": ["gitleaks"]
9
10
  }
@@ -2,7 +2,10 @@
2
2
  "name": "{{name}}",
3
3
  "private": true,
4
4
  "type": "module",
5
- "workspaces": ["apps/*", "packages/*"],
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,41 @@
12
15
  "format": "prettier --write .",
13
16
  "format:check": "prettier --check .",
14
17
  "knip": "knip",
15
- "check": "bun run lint && bun run format:check && bun run knip && bun run typecheck",
18
+ "madge": "madge --circular --extensions ts,tsx apps packages scripts",
19
+ "deps": "depcruise apps packages scripts --config .dependency-cruiser.cjs",
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
28
  "lint-staged": {
19
- "*.{ts,tsx}": ["eslint --fix", "prettier --write"],
20
- "*.{json,md,html,css}": ["prettier --write"]
29
+ "*.{ts,tsx}": [
30
+ "eslint --fix",
31
+ "prettier --write"
32
+ ],
33
+ "*.{json,md,html,css}": [
34
+ "prettier --write"
35
+ ]
21
36
  },
37
+ "packageManager": "bun@1.3.14",
22
38
  "devDependencies": {
23
39
  "@commitlint/cli": "^21.2.2",
24
40
  "@commitlint/config-conventional": "^21.2.2",
25
41
  "@eslint/js": "^10.0.1",
26
42
  "@types/bun": "^1.3.0",
43
+ "dependency-cruiser": "^18.3.1",
27
44
  "eslint": "^10.10.0",
28
45
  "eslint-config-prettier": "^10.1.8",
46
+ "eslint-plugin-sonarjs": "^4.2.1",
29
47
  "husky": "^9.1.7",
30
48
  "jiti": "^2.6.1",
49
+ "jscpd": "^5.3.0",
31
50
  "knip": "^6.35.1",
32
51
  "lint-staged": "^17.5.1",
52
+ "madge": "^8.0.0",
33
53
  "prettier": "^3.9.6",
34
54
  "typescript": "^5.9.3",
35
55
  "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
+ }
@@ -39,8 +39,8 @@ const procs = targets.map(({ name, cwd, color }) => {
39
39
  if (buf) console.log(prefix(buf))
40
40
  }
41
41
 
42
- pump(proc.stdout)
43
- pump(proc.stderr)
42
+ void pump(proc.stdout)
43
+ void pump(proc.stderr)
44
44
  return proc
45
45
  })
46
46
 
@@ -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], { cwd, env: process.env, stdio: ['inherit', 'inherit', 'inherit'] })
24
- if (proc.exitCode !== 0) process.exit(proc.exitCode ?? 1)
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,7 +1,7 @@
1
1
  import { existsSync, readdirSync, readFileSync } from 'node:fs'
2
2
  import { join } from 'node:path'
3
3
 
4
- export interface Workspace {
4
+ export type Workspace = {
5
5
  name: string
6
6
  cwd: string
7
7
  }
@@ -3,5 +3,5 @@
3
3
  "compilerOptions": {
4
4
  "types": ["bun"]
5
5
  },
6
- "include": ["scripts"]
6
+ "include": ["scripts", "eslint.config.ts"]
7
7
  }
@@ -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
- )