bank20baht-cli 0.0.1 → 0.1.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/dist/application/create.js +7 -1
- package/dist/application/generate.js +121 -0
- package/dist/cli.js +122 -32
- package/dist/domain/create-plan.js +1 -1
- package/dist/domain/form-dsl.js +105 -0
- package/dist/domain/generate-plan.js +60 -0
- package/dist/infrastructure/fs-reader.js +14 -0
- package/dist/infrastructure/fs-writer.js +10 -3
- package/dist/infrastructure/shell-runner.js +5 -1
- package/dist/infrastructure/template-renderer.js +16 -0
- package/dist/infrastructure/template-source.js +4 -1
- package/package.json +5 -2
- package/templates/create/apps/web/src/App.tsx.hbs +1 -1
- package/dist/application/create.d.ts +0 -16
- package/dist/application/ports.d.ts +0 -23
- package/dist/cli.d.ts +0 -2
- package/dist/domain/blueprint.d.ts +0 -23
- package/dist/domain/create-plan.d.ts +0 -24
- package/dist/domain/naming.d.ts +0 -2
- package/dist/infrastructure/fs-writer.d.ts +0 -6
- package/dist/infrastructure/hbs-renderer.d.ts +0 -4
- package/dist/infrastructure/hbs-renderer.js +0 -7
- package/dist/infrastructure/shell-runner.d.ts +0 -4
- package/dist/infrastructure/template-source.d.ts +0 -7
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Nattapong Promthong
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -21,7 +21,13 @@ export function runCreate(input, deps) {
|
|
|
21
21
|
deps.log.step(`${blueprint.files.length} files written to ${blueprint.root}`);
|
|
22
22
|
for (const c of blueprint.postCommands) {
|
|
23
23
|
deps.log.step(c.label);
|
|
24
|
-
|
|
24
|
+
try {
|
|
25
|
+
deps.runner.run(c.cmd, c.args, blueprint.root);
|
|
26
|
+
}
|
|
27
|
+
catch (err) {
|
|
28
|
+
const retry = `${c.cmd} ${c.args.join(' ')}`;
|
|
29
|
+
throw new CreateError(`${err.message}\nproject files are written — cd ${input.name} and rerun: ${retry}`);
|
|
30
|
+
}
|
|
25
31
|
}
|
|
26
32
|
deps.log.info('');
|
|
27
33
|
deps.log.info(`Done. Next:`);
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { buildFormDefinition, FormDslError } from '../domain/form-dsl.js';
|
|
2
|
+
import { DEFAULT_CONFIG, modelDir, planForm, planInit, planModel, validateDomainName, } from '../domain/generate-plan.js';
|
|
3
|
+
export class GenerateError extends Error {
|
|
4
|
+
}
|
|
5
|
+
/**
|
|
6
|
+
* bahtui.json path-ish fields feed directly into fs paths (modelDir(), eden
|
|
7
|
+
* client wiring). A cloned repo's bahtui.json is untrusted input — reject
|
|
8
|
+
* anything that could escape the project root before it ever reaches a path
|
|
9
|
+
* join (defense in depth alongside DiskFileWriter's own root confinement).
|
|
10
|
+
*/
|
|
11
|
+
const SAFE_RELATIVE_PATH = /^[A-Za-z0-9._/-]+$/;
|
|
12
|
+
function validateConfigPath(field, value) {
|
|
13
|
+
if (!SAFE_RELATIVE_PATH.test(value)) {
|
|
14
|
+
throw new GenerateError(`bahtui.json "${field}" contains invalid characters: ${JSON.stringify(value)}.`);
|
|
15
|
+
}
|
|
16
|
+
if (value.startsWith('/')) {
|
|
17
|
+
throw new GenerateError(`bahtui.json "${field}" must be a relative path: ${JSON.stringify(value)}.`);
|
|
18
|
+
}
|
|
19
|
+
if (value.split('/').includes('..')) {
|
|
20
|
+
throw new GenerateError(`bahtui.json "${field}" must not contain ".." segments: ${JSON.stringify(value)}.`);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
function isPlainObject(value) {
|
|
24
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
25
|
+
}
|
|
26
|
+
function loadConfig(root, reader) {
|
|
27
|
+
const raw = reader.read(`${root}/bahtui.json`);
|
|
28
|
+
if (raw === null) {
|
|
29
|
+
throw new GenerateError('No bahtui.json here — run "bahtui init" (or create) first.');
|
|
30
|
+
}
|
|
31
|
+
let parsed;
|
|
32
|
+
try {
|
|
33
|
+
parsed = JSON.parse(raw);
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
throw new GenerateError('bahtui.json is not valid JSON.');
|
|
37
|
+
}
|
|
38
|
+
if (!isPlainObject(parsed)) {
|
|
39
|
+
throw new GenerateError('bahtui.json must contain a JSON object.');
|
|
40
|
+
}
|
|
41
|
+
const config = { ...DEFAULT_CONFIG, ...parsed };
|
|
42
|
+
validateConfigPath('libsDir', config.libsDir);
|
|
43
|
+
if (config.edenClient !== undefined)
|
|
44
|
+
validateConfigPath('edenClient', config.edenClient);
|
|
45
|
+
return config;
|
|
46
|
+
}
|
|
47
|
+
function emit(blueprint, dryRun, deps) {
|
|
48
|
+
if (dryRun) {
|
|
49
|
+
deps.log.info(`dry-run — would write in ${blueprint.root}:`);
|
|
50
|
+
for (const f of blueprint.files)
|
|
51
|
+
deps.log.info(` ${f.path}`);
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
deps.writer.writeFiles(blueprint);
|
|
55
|
+
for (const f of blueprint.files)
|
|
56
|
+
deps.log.step(f.path);
|
|
57
|
+
}
|
|
58
|
+
/** `bahtui init` (PRD P2): bahtui.json for an existing repo, paths detected. */
|
|
59
|
+
export function runInit(input, deps) {
|
|
60
|
+
if (deps.reader.exists(`${input.root}/bahtui.json`) && !input.force && !input.dryRun) {
|
|
61
|
+
throw new GenerateError('bahtui.json already exists — use --force to overwrite.');
|
|
62
|
+
}
|
|
63
|
+
const config = {
|
|
64
|
+
...DEFAULT_CONFIG,
|
|
65
|
+
frontend: input.frontend,
|
|
66
|
+
backend: input.backend,
|
|
67
|
+
apps: {
|
|
68
|
+
...(deps.reader.exists(`${input.root}/apps/web`) ? { web: 'apps/web' } : {}),
|
|
69
|
+
...(deps.reader.exists(`${input.root}/apps/api`) ? { api: 'apps/api' } : {}),
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
if (input.backend === null)
|
|
73
|
+
delete config.edenClient;
|
|
74
|
+
emit(planInit(input.root, config), input.dryRun, deps);
|
|
75
|
+
if (!input.dryRun)
|
|
76
|
+
deps.log.info('bahtui.json written — `bahtui g model <domain>` is ready.');
|
|
77
|
+
}
|
|
78
|
+
/** `bahtui g model|form` (PRD P3). Engine-validates before any write. */
|
|
79
|
+
export function runGenerate(input, deps) {
|
|
80
|
+
const config = loadConfig(input.root, deps.reader);
|
|
81
|
+
if (input.kind === 'model') {
|
|
82
|
+
const domain = input.target;
|
|
83
|
+
const errors = validateDomainName(domain);
|
|
84
|
+
if (errors.length > 0)
|
|
85
|
+
throw new GenerateError(errors.join(' '));
|
|
86
|
+
const dir = `${input.root}/${modelDir(config, domain)}`;
|
|
87
|
+
if (deps.reader.exists(dir) && !input.force) {
|
|
88
|
+
throw new GenerateError(`${modelDir(config, domain)} already exists — use --force.`);
|
|
89
|
+
}
|
|
90
|
+
emit(planModel(input.root, config, domain), input.dryRun, deps);
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
// form: <domain>/<name>
|
|
94
|
+
const [domain, name, ...rest] = input.target.split('/');
|
|
95
|
+
if (!domain || !name || rest.length > 0) {
|
|
96
|
+
throw new GenerateError('g form needs <domain>/<name>, e.g. "ticket/create-ticket".');
|
|
97
|
+
}
|
|
98
|
+
const nameErrors = [...validateDomainName(domain), ...validateDomainName(name)];
|
|
99
|
+
if (nameErrors.length > 0)
|
|
100
|
+
throw new GenerateError(nameErrors.join(' '));
|
|
101
|
+
if (!input.fields) {
|
|
102
|
+
throw new GenerateError('g form needs --fields, e.g. --fields "title:text:required,qty:number:min=1".');
|
|
103
|
+
}
|
|
104
|
+
if (!deps.reader.exists(`${input.root}/${modelDir(config, domain)}`)) {
|
|
105
|
+
throw new GenerateError(`${modelDir(config, domain)} does not exist — run "bahtui g model ${domain}" first.`);
|
|
106
|
+
}
|
|
107
|
+
let form;
|
|
108
|
+
try {
|
|
109
|
+
form = buildFormDefinition(domain, name, input.fields); // invalid spec = zero files
|
|
110
|
+
}
|
|
111
|
+
catch (err) {
|
|
112
|
+
if (err instanceof FormDslError)
|
|
113
|
+
throw new GenerateError(err.message);
|
|
114
|
+
throw err;
|
|
115
|
+
}
|
|
116
|
+
const filePath = `${input.root}/${modelDir(config, domain)}/src/${name}.form.json`;
|
|
117
|
+
if (deps.reader.exists(filePath) && !input.force) {
|
|
118
|
+
throw new GenerateError(`${name}.form.json already exists — use --force.`);
|
|
119
|
+
}
|
|
120
|
+
emit(planForm(input.root, config, domain, name, form), input.dryRun, deps);
|
|
121
|
+
}
|
package/dist/cli.js
CHANGED
|
@@ -3,20 +3,31 @@ import { resolve } from 'node:path';
|
|
|
3
3
|
import { readFileSync } from 'node:fs';
|
|
4
4
|
import { fileURLToPath } from 'node:url';
|
|
5
5
|
import { runCreate, CreateError } from './application/create.js';
|
|
6
|
+
import { runGenerate, runInit, GenerateError } from './application/generate.js';
|
|
6
7
|
import { DiskTemplateSource } from './infrastructure/template-source.js';
|
|
7
|
-
import {
|
|
8
|
+
import { TemplateRenderer } from './infrastructure/template-renderer.js';
|
|
8
9
|
import { DiskFileWriter } from './infrastructure/fs-writer.js';
|
|
10
|
+
import { DiskFileReader } from './infrastructure/fs-reader.js';
|
|
9
11
|
import { ShellRunner } from './infrastructure/shell-runner.js';
|
|
10
12
|
const HELP = `bahtui — bun-workspace DDD scaffolding (React + Elysia + Eden)
|
|
11
13
|
|
|
12
14
|
Usage:
|
|
13
|
-
bahtui create <name> [options]
|
|
15
|
+
bahtui create <name> [options] scaffold a new monorepo in ./<name>
|
|
16
|
+
bahtui init [options] write bahtui.json into an existing repo
|
|
17
|
+
bahtui g model <domain> libs/domain/<d>/model package
|
|
18
|
+
bahtui g form <domain>/<name> --fields "key:type:mod,…"
|
|
19
|
+
engine-validated FormDefinition JSON
|
|
20
|
+
Field DSL:
|
|
21
|
+
title:text:required,priority:dropdown:options=low|medium|high,qty:number:min=1
|
|
14
22
|
|
|
15
23
|
Options:
|
|
24
|
+
--fields <dsl> g form: the field list (required)
|
|
25
|
+
--frontend <react|none> init: default react
|
|
26
|
+
--backend <elysia|none> init: default elysia
|
|
16
27
|
--dry-run print what would be generated, write nothing
|
|
17
|
-
--no-install skip "bun install" (also skips the initial commit)
|
|
18
|
-
--no-git skip git init + initial commit
|
|
19
|
-
--force
|
|
28
|
+
--no-install create: skip "bun install" (also skips the initial commit)
|
|
29
|
+
--no-git create: skip git init + initial commit
|
|
30
|
+
--force overwrite existing files/dirs
|
|
20
31
|
-h, --help this help
|
|
21
32
|
-v, --version print version
|
|
22
33
|
`;
|
|
@@ -29,46 +40,125 @@ const log = {
|
|
|
29
40
|
step: (m) => console.log(`• ${m}`),
|
|
30
41
|
error: (m) => console.error(`✖ ${m}`),
|
|
31
42
|
};
|
|
32
|
-
|
|
43
|
+
/** Boolean flags recognized anywhere on the command line (no value). */
|
|
44
|
+
const KNOWN_FLAGS = new Set([
|
|
45
|
+
'-v',
|
|
46
|
+
'--version',
|
|
47
|
+
'-h',
|
|
48
|
+
'--help',
|
|
49
|
+
'--dry-run',
|
|
50
|
+
'--force',
|
|
51
|
+
'--no-install',
|
|
52
|
+
'--no-git',
|
|
53
|
+
]);
|
|
54
|
+
/**
|
|
55
|
+
* Value-carrying flags pulled out before positional parsing. Supports both
|
|
56
|
+
* `--flag value` and `--flag=value`. If the token after `--flag value` looks
|
|
57
|
+
* like another flag (starts with `-`), the option is treated as missing
|
|
58
|
+
* rather than swallowing that flag as the value.
|
|
59
|
+
*/
|
|
60
|
+
function takeOption(argv, flag) {
|
|
61
|
+
const eqPrefix = `${flag}=`;
|
|
62
|
+
const eqIndex = argv.findIndex((a) => a.startsWith(eqPrefix));
|
|
63
|
+
if (eqIndex !== -1) {
|
|
64
|
+
const value = argv[eqIndex].slice(eqPrefix.length);
|
|
65
|
+
argv.splice(eqIndex, 1);
|
|
66
|
+
return value;
|
|
67
|
+
}
|
|
68
|
+
const i = argv.indexOf(flag);
|
|
69
|
+
if (i === -1)
|
|
70
|
+
return undefined;
|
|
71
|
+
const value = argv[i + 1];
|
|
72
|
+
if (value === undefined || value.startsWith('-')) {
|
|
73
|
+
argv.splice(i, 1);
|
|
74
|
+
return undefined;
|
|
75
|
+
}
|
|
76
|
+
argv.splice(i, 2);
|
|
77
|
+
return value;
|
|
78
|
+
}
|
|
79
|
+
function main(rawArgv) {
|
|
80
|
+
const argv = [...rawArgv];
|
|
81
|
+
const fields = takeOption(argv, '--fields');
|
|
82
|
+
const frontend = takeOption(argv, '--frontend');
|
|
83
|
+
const backend = takeOption(argv, '--backend');
|
|
33
84
|
const args = argv.filter((a) => !a.startsWith('-'));
|
|
34
85
|
const flags = new Set(argv.filter((a) => a.startsWith('-')));
|
|
35
|
-
const [command, name] = args;
|
|
86
|
+
const [command, name, sub] = args;
|
|
87
|
+
const unknownFlags = [...flags].filter((f) => !KNOWN_FLAGS.has(f));
|
|
88
|
+
if (unknownFlags.length > 0) {
|
|
89
|
+
log.error(`Unknown flag${unknownFlags.length > 1 ? 's' : ''}: ${unknownFlags.join(', ')}`);
|
|
90
|
+
return 1;
|
|
91
|
+
}
|
|
36
92
|
if (flags.has('-v') || flags.has('--version')) {
|
|
37
93
|
log.info(version());
|
|
38
94
|
return 0;
|
|
39
95
|
}
|
|
40
|
-
if (
|
|
96
|
+
if (flags.has('-h') || flags.has('--help')) {
|
|
41
97
|
log.info(HELP);
|
|
42
|
-
return
|
|
43
|
-
}
|
|
44
|
-
if (command !== 'create') {
|
|
45
|
-
log.error(`Unknown command "${command}".`);
|
|
46
|
-
log.info(HELP);
|
|
47
|
-
return 1;
|
|
98
|
+
return 0;
|
|
48
99
|
}
|
|
49
|
-
if (
|
|
50
|
-
log.error('
|
|
100
|
+
if (command === undefined) {
|
|
101
|
+
log.error('No command given — see usage below.');
|
|
102
|
+
log.error(HELP);
|
|
51
103
|
return 1;
|
|
52
104
|
}
|
|
105
|
+
const dryRun = flags.has('--dry-run');
|
|
106
|
+
const force = flags.has('--force');
|
|
107
|
+
const generateDeps = {
|
|
108
|
+
reader: new DiskFileReader(),
|
|
109
|
+
writer: new DiskFileWriter(),
|
|
110
|
+
log,
|
|
111
|
+
};
|
|
53
112
|
try {
|
|
54
|
-
|
|
55
|
-
name
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
113
|
+
if (command === 'create') {
|
|
114
|
+
if (!name) {
|
|
115
|
+
log.error('create needs a project name: bahtui create <name>');
|
|
116
|
+
return 1;
|
|
117
|
+
}
|
|
118
|
+
runCreate({
|
|
119
|
+
name,
|
|
120
|
+
targetDir: resolve(process.cwd(), name),
|
|
121
|
+
install: !flags.has('--no-install'),
|
|
122
|
+
git: !flags.has('--no-git'),
|
|
123
|
+
dryRun,
|
|
124
|
+
force,
|
|
125
|
+
}, {
|
|
126
|
+
templates: new DiskTemplateSource(),
|
|
127
|
+
renderer: new TemplateRenderer(),
|
|
128
|
+
writer: new DiskFileWriter(),
|
|
129
|
+
runner: new ShellRunner(),
|
|
130
|
+
log,
|
|
131
|
+
});
|
|
132
|
+
return 0;
|
|
133
|
+
}
|
|
134
|
+
if (command === 'init') {
|
|
135
|
+
runInit({
|
|
136
|
+
root: process.cwd(),
|
|
137
|
+
frontend: frontend === 'none' ? null : 'react',
|
|
138
|
+
backend: backend === 'none' ? null : 'elysia',
|
|
139
|
+
dryRun,
|
|
140
|
+
force,
|
|
141
|
+
}, generateDeps);
|
|
142
|
+
return 0;
|
|
143
|
+
}
|
|
144
|
+
if (command === 'g') {
|
|
145
|
+
if (name !== 'model' && name !== 'form') {
|
|
146
|
+
log.error('Unknown generator — bahtui g model <domain> | g form <domain>/<name>.');
|
|
147
|
+
return 1;
|
|
148
|
+
}
|
|
149
|
+
if (!sub) {
|
|
150
|
+
log.error(`g ${name} needs a target: bahtui g ${name} <${name === 'model' ? 'domain' : 'domain/name'}>`);
|
|
151
|
+
return 1;
|
|
152
|
+
}
|
|
153
|
+
runGenerate({ root: process.cwd(), kind: name, target: sub, fields, dryRun, force }, generateDeps);
|
|
154
|
+
return 0;
|
|
155
|
+
}
|
|
156
|
+
log.error(`Unknown command "${command}".`);
|
|
157
|
+
log.error(HELP);
|
|
158
|
+
return 1;
|
|
69
159
|
}
|
|
70
160
|
catch (err) {
|
|
71
|
-
log.error(err instanceof CreateError ? err.message : String(err));
|
|
161
|
+
log.error(err instanceof CreateError || err instanceof GenerateError ? err.message : String(err));
|
|
72
162
|
return 1;
|
|
73
163
|
}
|
|
74
164
|
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Template naming conventions (the whole contract between templates/ and here):
|
|
3
3
|
* - `*.hbs` → rendered with the data below, suffix stripped.
|
|
4
4
|
* anything else → copied verbatim (so files with `${{ }}` like ci.yml
|
|
5
|
-
* never meet
|
|
5
|
+
* never meet the renderer).
|
|
6
6
|
* - leading `_` on a basename → `.` (npm pack silently drops/renames real
|
|
7
7
|
* .gitignore files inside packages, so templates ship `_gitignore`).
|
|
8
8
|
*/
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { parseSchema } from 'bank20baht-validator';
|
|
2
|
+
/**
|
|
3
|
+
* Compact field DSL for `bahtui g form` (spec 04-adjacent, PRD P3):
|
|
4
|
+
*
|
|
5
|
+
* --fields "title:text:required,priority:dropdown:options=low|medium|high,qty:number"
|
|
6
|
+
*
|
|
7
|
+
* field = key ':' type (':' modifier)*
|
|
8
|
+
* mods = 'required' | 'options=a|b|c' | 'min=N' | 'max=N' | 'flag=NAME'
|
|
9
|
+
*
|
|
10
|
+
* The result is engine-validated (parseSchema) BEFORE any file is written —
|
|
11
|
+
* an invalid spec exits 1 with zero files (the P1 guarantee, kept).
|
|
12
|
+
*/
|
|
13
|
+
const FIELD_TYPES = new Set([
|
|
14
|
+
'text',
|
|
15
|
+
'textarea',
|
|
16
|
+
'number',
|
|
17
|
+
'email',
|
|
18
|
+
'dropdown',
|
|
19
|
+
'multiselect',
|
|
20
|
+
'checkbox',
|
|
21
|
+
'radio',
|
|
22
|
+
'toggle',
|
|
23
|
+
'date',
|
|
24
|
+
'file',
|
|
25
|
+
]);
|
|
26
|
+
const KEY_PATTERN = /^[a-z][a-z0-9_]*$/;
|
|
27
|
+
const titleCase = (key) => key
|
|
28
|
+
.split('_')
|
|
29
|
+
.map((w) => (w ? w[0].toUpperCase() + w.slice(1) : w))
|
|
30
|
+
.join(' ');
|
|
31
|
+
/** `min=`/`max=` must be a real, non-empty number — never silently coerced to NaN/0. */
|
|
32
|
+
function parseNumberMod(name, raw, part) {
|
|
33
|
+
const n = Number(raw);
|
|
34
|
+
if (raw.trim() === '' || !Number.isFinite(n)) {
|
|
35
|
+
throw new FormDslError(`Bad ${name} value ${JSON.stringify(raw)} in "${part}" — expected a number.`);
|
|
36
|
+
}
|
|
37
|
+
return n;
|
|
38
|
+
}
|
|
39
|
+
export function parseFieldsDsl(dsl) {
|
|
40
|
+
const fields = [];
|
|
41
|
+
const parts = dsl
|
|
42
|
+
.split(',')
|
|
43
|
+
.map((p) => p.trim())
|
|
44
|
+
.filter(Boolean);
|
|
45
|
+
if (parts.length === 0)
|
|
46
|
+
throw new FormDslError('No fields given — pass --fields "key:type,…".');
|
|
47
|
+
for (const [i, part] of parts.entries()) {
|
|
48
|
+
const [key, type, ...mods] = part.split(':').map((s) => s.trim());
|
|
49
|
+
if (!key || !KEY_PATTERN.test(key)) {
|
|
50
|
+
throw new FormDslError(`Bad field key ${JSON.stringify(key ?? '')} in "${part}".`);
|
|
51
|
+
}
|
|
52
|
+
if (!type || !FIELD_TYPES.has(type)) {
|
|
53
|
+
throw new FormDslError(`Unknown field type ${JSON.stringify(type ?? '')} in "${part}" — one of: ${[...FIELD_TYPES].join(', ')}.`);
|
|
54
|
+
}
|
|
55
|
+
const field = { order: i + 1, type, key, label: titleCase(key) };
|
|
56
|
+
for (const mod of mods) {
|
|
57
|
+
if (mod === 'required')
|
|
58
|
+
field.required = true;
|
|
59
|
+
else if (mod.startsWith('options=')) {
|
|
60
|
+
const opts = mod
|
|
61
|
+
.slice('options='.length)
|
|
62
|
+
.split('|')
|
|
63
|
+
.map((o) => o.trim())
|
|
64
|
+
.filter(Boolean);
|
|
65
|
+
if (opts.length === 0) {
|
|
66
|
+
throw new FormDslError(`options modifier needs at least one value in "${part}".`);
|
|
67
|
+
}
|
|
68
|
+
if (new Set(opts).size !== opts.length) {
|
|
69
|
+
throw new FormDslError(`Duplicate option keys in "${part}".`);
|
|
70
|
+
}
|
|
71
|
+
field.options = opts.map((o) => ({ key: o, label: titleCase(o) }));
|
|
72
|
+
}
|
|
73
|
+
else if (mod.startsWith('min='))
|
|
74
|
+
field.min = parseNumberMod('min', mod.slice(4), part);
|
|
75
|
+
else if (mod.startsWith('max='))
|
|
76
|
+
field.max = parseNumberMod('max', mod.slice(4), part);
|
|
77
|
+
else if (mod.startsWith('flag='))
|
|
78
|
+
field.flag = mod.slice(5);
|
|
79
|
+
else
|
|
80
|
+
throw new FormDslError(`Unknown modifier ${JSON.stringify(mod)} in "${part}".`);
|
|
81
|
+
}
|
|
82
|
+
fields.push(field);
|
|
83
|
+
}
|
|
84
|
+
const keys = fields.map((f) => f.key);
|
|
85
|
+
if (new Set(keys).size !== keys.length) {
|
|
86
|
+
throw new FormDslError('Field keys must be unique.');
|
|
87
|
+
}
|
|
88
|
+
return fields;
|
|
89
|
+
}
|
|
90
|
+
/** DSL -> FormDefinition, engine-validated. Throws FormDslError on any problem. */
|
|
91
|
+
export function buildFormDefinition(domain, name, dsl) {
|
|
92
|
+
const form = {
|
|
93
|
+
formId: `${domain}-${name}`,
|
|
94
|
+
formName: titleCase(name.replace(/-/g, '_')).replace(/ /g, '_'),
|
|
95
|
+
field_data: parseFieldsDsl(dsl),
|
|
96
|
+
};
|
|
97
|
+
try {
|
|
98
|
+
return parseSchema(JSON.stringify(form));
|
|
99
|
+
}
|
|
100
|
+
catch (err) {
|
|
101
|
+
throw new FormDslError(`Engine rejected the form spec: ${err.message}`);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
export class FormDslError extends Error {
|
|
105
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
export const DEFAULT_CONFIG = {
|
|
2
|
+
apps: { web: 'apps/web', api: 'apps/api' },
|
|
3
|
+
libsDir: 'libs',
|
|
4
|
+
frontend: 'react',
|
|
5
|
+
backend: 'elysia',
|
|
6
|
+
edenClient: 'libs/shared/eden',
|
|
7
|
+
};
|
|
8
|
+
const DOMAIN_PATTERN = /^[a-z][a-z0-9-]*$/;
|
|
9
|
+
export function validateDomainName(domain) {
|
|
10
|
+
return DOMAIN_PATTERN.test(domain)
|
|
11
|
+
? []
|
|
12
|
+
: [`"${domain}" is not a valid domain name — lowercase letters, digits, dashes.`];
|
|
13
|
+
}
|
|
14
|
+
/** `bahtui init` — write bahtui.json into an existing repo (root = cwd). */
|
|
15
|
+
export function planInit(root, config) {
|
|
16
|
+
return {
|
|
17
|
+
root,
|
|
18
|
+
files: [{ path: 'bahtui.json', contents: JSON.stringify(config, null, 2) + '\n' }],
|
|
19
|
+
postCommands: [],
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
export function modelDir(config, domain) {
|
|
23
|
+
return `${config.libsDir}/domain/${domain}/model`;
|
|
24
|
+
}
|
|
25
|
+
/** `bahtui g model <domain>` — the framework-free domain model lib (PRD). */
|
|
26
|
+
export function planModel(root, config, domain) {
|
|
27
|
+
const dir = modelDir(config, domain);
|
|
28
|
+
const pkg = {
|
|
29
|
+
name: `domain-${domain}-model`,
|
|
30
|
+
version: '0.0.0',
|
|
31
|
+
private: true,
|
|
32
|
+
type: 'module',
|
|
33
|
+
exports: { '.': './src/index.ts' },
|
|
34
|
+
};
|
|
35
|
+
return {
|
|
36
|
+
root,
|
|
37
|
+
files: [
|
|
38
|
+
{ path: `${dir}/package.json`, contents: JSON.stringify(pkg, null, 2) + '\n' },
|
|
39
|
+
{
|
|
40
|
+
path: `${dir}/src/index.ts`,
|
|
41
|
+
contents: `// ${domain} domain model — entities and form JSON live here.\n// NO react, NO elysia in this package (dependency rule: everything points AT model).\nexport {};\n`,
|
|
42
|
+
},
|
|
43
|
+
],
|
|
44
|
+
postCommands: [],
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
/** `bahtui g form <domain>/<name>` — engine-validated FormDefinition JSON. */
|
|
48
|
+
export function planForm(root, config, domain, name, form) {
|
|
49
|
+
const dir = modelDir(config, domain);
|
|
50
|
+
return {
|
|
51
|
+
root,
|
|
52
|
+
files: [
|
|
53
|
+
{
|
|
54
|
+
path: `${dir}/src/${name}.form.json`,
|
|
55
|
+
contents: JSON.stringify(form, null, 2) + '\n',
|
|
56
|
+
},
|
|
57
|
+
],
|
|
58
|
+
postCommands: [],
|
|
59
|
+
};
|
|
60
|
+
}
|
|
@@ -1,9 +1,12 @@
|
|
|
1
|
-
import { existsSync, mkdirSync, readdirSync, writeFileSync } from 'node:fs';
|
|
2
|
-
import { dirname,
|
|
1
|
+
import { existsSync, mkdirSync, readdirSync, statSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { dirname, resolve, sep } from 'node:path';
|
|
3
3
|
export class DiskFileWriter {
|
|
4
4
|
ensureUsableDir(dir, force) {
|
|
5
5
|
if (!existsSync(dir))
|
|
6
6
|
return;
|
|
7
|
+
if (!statSync(dir).isDirectory()) {
|
|
8
|
+
throw new Error(`${dir} exists and is not a directory.`);
|
|
9
|
+
}
|
|
7
10
|
if (readdirSync(dir).length === 0)
|
|
8
11
|
return;
|
|
9
12
|
if (force)
|
|
@@ -11,8 +14,12 @@ export class DiskFileWriter {
|
|
|
11
14
|
throw new Error(`${dir} already exists and is not empty (use --force to write anyway).`);
|
|
12
15
|
}
|
|
13
16
|
writeFiles(blueprint) {
|
|
17
|
+
const root = resolve(blueprint.root);
|
|
14
18
|
for (const file of blueprint.files) {
|
|
15
|
-
const abs =
|
|
19
|
+
const abs = resolve(root, file.path);
|
|
20
|
+
if (abs !== root && !abs.startsWith(root + sep)) {
|
|
21
|
+
throw new Error(`Refusing to write "${file.path}" — it resolves outside the target directory ${root}.`);
|
|
22
|
+
}
|
|
16
23
|
mkdirSync(dirname(abs), { recursive: true });
|
|
17
24
|
writeFileSync(abs, file.contents);
|
|
18
25
|
}
|
|
@@ -2,8 +2,12 @@ import { spawnSync } from 'node:child_process';
|
|
|
2
2
|
export class ShellRunner {
|
|
3
3
|
run(cmd, args, cwd) {
|
|
4
4
|
const result = spawnSync(cmd, args, { cwd, stdio: 'inherit' });
|
|
5
|
-
if (result.error)
|
|
5
|
+
if (result.error) {
|
|
6
|
+
if (result.error.code === 'ENOENT') {
|
|
7
|
+
throw new Error(`'${cmd}' not found on PATH — install it first.`);
|
|
8
|
+
}
|
|
6
9
|
throw result.error;
|
|
10
|
+
}
|
|
7
11
|
if (result.status !== 0) {
|
|
8
12
|
throw new Error(`${cmd} ${args.join(' ')} exited with code ${result.status}`);
|
|
9
13
|
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The create template set's only expression form is `{{key}}` substitution —
|
|
3
|
+
* a full template engine (handlebars, ~1.5 MB installed) buys nothing here.
|
|
4
|
+
* Unknown variables throw, matching the old handlebars strict-mode behavior.
|
|
5
|
+
* No escaping: these are code/config files, not HTML — quotes must survive.
|
|
6
|
+
*/
|
|
7
|
+
export class TemplateRenderer {
|
|
8
|
+
render(raw, data) {
|
|
9
|
+
return raw.replaceAll(/\{\{\s*([\w.]+)\s*\}\}/g, (_, key) => {
|
|
10
|
+
if (!Object.hasOwn(data, key)) {
|
|
11
|
+
throw new Error(`unknown template variable "${key}"`);
|
|
12
|
+
}
|
|
13
|
+
return String(data[key]);
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { readdirSync, readFileSync } from 'node:fs';
|
|
1
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
2
2
|
import { join, relative, sep } from 'node:path';
|
|
3
3
|
import { fileURLToPath } from 'node:url';
|
|
4
4
|
/** templates/ sits at the package root, next to dist/ — hence ../../ from here. */
|
|
@@ -9,6 +9,9 @@ export class DiskTemplateSource {
|
|
|
9
9
|
}
|
|
10
10
|
load(set) {
|
|
11
11
|
const setRoot = join(this.root, set);
|
|
12
|
+
if (!existsSync(setRoot)) {
|
|
13
|
+
throw new Error(`template set '${set}' missing — reinstall bank20baht-cli`);
|
|
14
|
+
}
|
|
12
15
|
const entries = readdirSync(setRoot, { recursive: true, withFileTypes: true });
|
|
13
16
|
const files = [];
|
|
14
17
|
for (const entry of entries) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bank20baht-cli",
|
|
3
|
-
"version": "0.0
|
|
3
|
+
"version": "0.1.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Scaffold bun-workspace DDD monorepos (React + Elysia + Eden) and bahtui generators.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -27,6 +27,9 @@
|
|
|
27
27
|
"publishConfig": {
|
|
28
28
|
"access": "public"
|
|
29
29
|
},
|
|
30
|
+
"engines": {
|
|
31
|
+
"node": ">=20.12"
|
|
32
|
+
},
|
|
30
33
|
"bin": {
|
|
31
34
|
"bahtui": "./dist/cli.js"
|
|
32
35
|
},
|
|
@@ -40,7 +43,7 @@
|
|
|
40
43
|
"test:watch": "vitest"
|
|
41
44
|
},
|
|
42
45
|
"dependencies": {
|
|
43
|
-
"
|
|
46
|
+
"bank20baht-validator": "^0.1.0"
|
|
44
47
|
},
|
|
45
48
|
"devDependencies": {
|
|
46
49
|
"@types/node": "^22",
|
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
import { type CreateOptions } from '../domain/create-plan.js';
|
|
2
|
-
import type { CommandRunner, FileWriter, Logger, Renderer, TemplateSource } from './ports.js';
|
|
3
|
-
export interface CreateDeps {
|
|
4
|
-
templates: TemplateSource;
|
|
5
|
-
renderer: Renderer;
|
|
6
|
-
writer: FileWriter;
|
|
7
|
-
runner: CommandRunner;
|
|
8
|
-
log: Logger;
|
|
9
|
-
}
|
|
10
|
-
export interface CreateInput extends CreateOptions {
|
|
11
|
-
dryRun: boolean;
|
|
12
|
-
force: boolean;
|
|
13
|
-
}
|
|
14
|
-
export declare function runCreate(input: CreateInput, deps: CreateDeps): void;
|
|
15
|
-
export declare class CreateError extends Error {
|
|
16
|
-
}
|
|
@@ -1,23 +0,0 @@
|
|
|
1
|
-
import type { Blueprint } from '../domain/blueprint.js';
|
|
2
|
-
import type { TemplateFile } from '../domain/create-plan.js';
|
|
3
|
-
/** Loads a template set (e.g. "create") shipped inside the CLI package. */
|
|
4
|
-
export interface TemplateSource {
|
|
5
|
-
load(set: string): TemplateFile[];
|
|
6
|
-
}
|
|
7
|
-
export interface Renderer {
|
|
8
|
-
render(raw: string, data: Record<string, unknown>): string;
|
|
9
|
-
}
|
|
10
|
-
export interface FileWriter {
|
|
11
|
-
/** Throws unless `dir` is missing/empty (or `force` is set). */
|
|
12
|
-
ensureUsableDir(dir: string, force: boolean): void;
|
|
13
|
-
writeFiles(blueprint: Blueprint): void;
|
|
14
|
-
}
|
|
15
|
-
export interface CommandRunner {
|
|
16
|
-
/** Runs to completion with inherited stdio; throws on non-zero exit. */
|
|
17
|
-
run(cmd: string, args: string[], cwd: string): void;
|
|
18
|
-
}
|
|
19
|
-
export interface Logger {
|
|
20
|
-
info(message: string): void;
|
|
21
|
-
step(message: string): void;
|
|
22
|
-
error(message: string): void;
|
|
23
|
-
}
|
package/dist/cli.d.ts
DELETED
|
@@ -1,23 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* A Blueprint is WHAT will be generated, as plain data — no fs, no side
|
|
3
|
-
* effects. Adapters (fs-writer, shell-runner) make it real; --dry-run just
|
|
4
|
-
* prints it. This split is the testing seam for every generator.
|
|
5
|
-
*/
|
|
6
|
-
export interface FileSpec {
|
|
7
|
-
/** Path relative to the blueprint root. */
|
|
8
|
-
path: string;
|
|
9
|
-
contents: string;
|
|
10
|
-
}
|
|
11
|
-
export interface PostCommand {
|
|
12
|
-
cmd: string;
|
|
13
|
-
args: string[];
|
|
14
|
-
/** Human-readable step label for progress output. */
|
|
15
|
-
label: string;
|
|
16
|
-
}
|
|
17
|
-
export interface Blueprint {
|
|
18
|
-
/** Absolute directory every FileSpec.path is relative to. */
|
|
19
|
-
root: string;
|
|
20
|
-
files: FileSpec[];
|
|
21
|
-
/** Commands to run inside `root`, in order, after files are written. */
|
|
22
|
-
postCommands: PostCommand[];
|
|
23
|
-
}
|
|
@@ -1,24 +0,0 @@
|
|
|
1
|
-
import type { Blueprint } from './blueprint.js';
|
|
2
|
-
export interface CreateOptions {
|
|
3
|
-
name: string;
|
|
4
|
-
/** Absolute path of the directory to create the project in. */
|
|
5
|
-
targetDir: string;
|
|
6
|
-
install: boolean;
|
|
7
|
-
git: boolean;
|
|
8
|
-
}
|
|
9
|
-
/** A template file as loaded from disk by the infrastructure layer. */
|
|
10
|
-
export interface TemplateFile {
|
|
11
|
-
/** Path relative to the template set root, e.g. "apps/api/src/index.ts.hbs". */
|
|
12
|
-
relPath: string;
|
|
13
|
-
raw: string;
|
|
14
|
-
}
|
|
15
|
-
export type RenderFn = (raw: string, data: Record<string, unknown>) => string;
|
|
16
|
-
/**
|
|
17
|
-
* Template naming conventions (the whole contract between templates/ and here):
|
|
18
|
-
* - `*.hbs` → rendered with the data below, suffix stripped.
|
|
19
|
-
* anything else → copied verbatim (so files with `${{ }}` like ci.yml
|
|
20
|
-
* never meet handlebars).
|
|
21
|
-
* - leading `_` on a basename → `.` (npm pack silently drops/renames real
|
|
22
|
-
* .gitignore files inside packages, so templates ship `_gitignore`).
|
|
23
|
-
*/
|
|
24
|
-
export declare function planCreate(options: CreateOptions, templates: TemplateFile[], render: RenderFn): Blueprint;
|
package/dist/domain/naming.d.ts
DELETED
|
@@ -1,6 +0,0 @@
|
|
|
1
|
-
import type { Blueprint } from '../domain/blueprint.js';
|
|
2
|
-
import type { FileWriter } from '../application/ports.js';
|
|
3
|
-
export declare class DiskFileWriter implements FileWriter {
|
|
4
|
-
ensureUsableDir(dir: string, force: boolean): void;
|
|
5
|
-
writeFiles(blueprint: Blueprint): void;
|
|
6
|
-
}
|
|
@@ -1,7 +0,0 @@
|
|
|
1
|
-
import type { TemplateFile } from '../domain/create-plan.js';
|
|
2
|
-
import type { TemplateSource } from '../application/ports.js';
|
|
3
|
-
export declare class DiskTemplateSource implements TemplateSource {
|
|
4
|
-
private readonly root;
|
|
5
|
-
constructor(root?: string);
|
|
6
|
-
load(set: string): TemplateFile[];
|
|
7
|
-
}
|