bank20baht-cli 0.0.1 → 0.0.2

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.
@@ -0,0 +1,33 @@
1
+ import type { FileWriter, Logger } from './ports.js';
2
+ /** Read-only fs the generators need: existence checks + bahtui.json. */
3
+ export interface FileReader {
4
+ exists(path: string): boolean;
5
+ read(path: string): string | null;
6
+ }
7
+ export interface GenerateDeps {
8
+ reader: FileReader;
9
+ writer: FileWriter;
10
+ log: Logger;
11
+ }
12
+ export declare class GenerateError extends Error {
13
+ }
14
+ export interface InitInput {
15
+ root: string;
16
+ frontend: 'react' | null;
17
+ backend: 'elysia' | null;
18
+ dryRun: boolean;
19
+ force: boolean;
20
+ }
21
+ /** `bahtui init` (PRD P2): bahtui.json for an existing repo, paths detected. */
22
+ export declare function runInit(input: InitInput, deps: GenerateDeps): void;
23
+ export interface GenerateInput {
24
+ root: string;
25
+ kind: 'model' | 'form';
26
+ /** `<domain>` for model, `<domain>/<name>` for form. */
27
+ target: string;
28
+ fields?: string;
29
+ dryRun: boolean;
30
+ force: boolean;
31
+ }
32
+ /** `bahtui g model|form` (PRD P3). Engine-validates before any write. */
33
+ export declare function runGenerate(input: GenerateInput, deps: GenerateDeps): void;
@@ -0,0 +1,91 @@
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
+ function loadConfig(root, reader) {
6
+ const raw = reader.read(`${root}/bahtui.json`);
7
+ if (raw === null) {
8
+ throw new GenerateError('No bahtui.json here — run "bahtui init" (or create) first.');
9
+ }
10
+ try {
11
+ return { ...DEFAULT_CONFIG, ...JSON.parse(raw) };
12
+ }
13
+ catch {
14
+ throw new GenerateError('bahtui.json is not valid JSON.');
15
+ }
16
+ }
17
+ function emit(blueprint, dryRun, deps) {
18
+ if (dryRun) {
19
+ deps.log.info(`dry-run — would write in ${blueprint.root}:`);
20
+ for (const f of blueprint.files)
21
+ deps.log.info(` ${f.path}`);
22
+ return;
23
+ }
24
+ deps.writer.writeFiles(blueprint);
25
+ for (const f of blueprint.files)
26
+ deps.log.step(f.path);
27
+ }
28
+ /** `bahtui init` (PRD P2): bahtui.json for an existing repo, paths detected. */
29
+ export function runInit(input, deps) {
30
+ if (deps.reader.exists(`${input.root}/bahtui.json`) && !input.force && !input.dryRun) {
31
+ throw new GenerateError('bahtui.json already exists — use --force to overwrite.');
32
+ }
33
+ const config = {
34
+ ...DEFAULT_CONFIG,
35
+ frontend: input.frontend,
36
+ backend: input.backend,
37
+ apps: {
38
+ ...(deps.reader.exists(`${input.root}/apps/web`) ? { web: 'apps/web' } : {}),
39
+ ...(deps.reader.exists(`${input.root}/apps/api`) ? { api: 'apps/api' } : {}),
40
+ },
41
+ };
42
+ if (input.backend === null)
43
+ delete config.edenClient;
44
+ emit(planInit(input.root, config), input.dryRun, deps);
45
+ if (!input.dryRun)
46
+ deps.log.info('bahtui.json written — `bahtui g model <domain>` is ready.');
47
+ }
48
+ /** `bahtui g model|form` (PRD P3). Engine-validates before any write. */
49
+ export function runGenerate(input, deps) {
50
+ const config = loadConfig(input.root, deps.reader);
51
+ if (input.kind === 'model') {
52
+ const domain = input.target;
53
+ const errors = validateDomainName(domain);
54
+ if (errors.length > 0)
55
+ throw new GenerateError(errors.join(' '));
56
+ const dir = `${input.root}/${modelDir(config, domain)}`;
57
+ if (deps.reader.exists(dir) && !input.force) {
58
+ throw new GenerateError(`${modelDir(config, domain)} already exists — use --force.`);
59
+ }
60
+ emit(planModel(input.root, config, domain), input.dryRun, deps);
61
+ return;
62
+ }
63
+ // form: <domain>/<name>
64
+ const [domain, name, ...rest] = input.target.split('/');
65
+ if (!domain || !name || rest.length > 0) {
66
+ throw new GenerateError('g form needs <domain>/<name>, e.g. "ticket/create-ticket".');
67
+ }
68
+ const nameErrors = [...validateDomainName(domain), ...validateDomainName(name)];
69
+ if (nameErrors.length > 0)
70
+ throw new GenerateError(nameErrors.join(' '));
71
+ if (!input.fields) {
72
+ throw new GenerateError('g form needs --fields, e.g. --fields "title:text:required,qty:number:min=1".');
73
+ }
74
+ if (!deps.reader.exists(`${input.root}/${modelDir(config, domain)}`)) {
75
+ throw new GenerateError(`${modelDir(config, domain)} does not exist — run "bahtui g model ${domain}" first.`);
76
+ }
77
+ let form;
78
+ try {
79
+ form = buildFormDefinition(domain, name, input.fields); // invalid spec = zero files
80
+ }
81
+ catch (err) {
82
+ if (err instanceof FormDslError)
83
+ throw new GenerateError(err.message);
84
+ throw err;
85
+ }
86
+ const filePath = `${input.root}/${modelDir(config, domain)}/src/${name}.form.json`;
87
+ if (deps.reader.exists(filePath) && !input.force) {
88
+ throw new GenerateError(`${name}.form.json already exists — use --force.`);
89
+ }
90
+ emit(planForm(input.root, config, domain, name, form), input.dryRun, deps);
91
+ }
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
8
  import { HbsRenderer } from './infrastructure/hbs-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] scaffold a new monorepo in ./<name>
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 write into an existing non-empty directory
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,10 +40,23 @@ const log = {
29
40
  step: (m) => console.log(`• ${m}`),
30
41
  error: (m) => console.error(`✖ ${m}`),
31
42
  };
32
- function main(argv) {
43
+ /** Value-carrying flags (--flag value) pulled out before positional parsing. */
44
+ function takeOption(argv, flag) {
45
+ const i = argv.indexOf(flag);
46
+ if (i === -1)
47
+ return undefined;
48
+ const value = argv[i + 1];
49
+ argv.splice(i, value !== undefined && !value.startsWith('-') ? 2 : 1);
50
+ return value;
51
+ }
52
+ function main(rawArgv) {
53
+ const argv = [...rawArgv];
54
+ const fields = takeOption(argv, '--fields');
55
+ const frontend = takeOption(argv, '--frontend');
56
+ const backend = takeOption(argv, '--backend');
33
57
  const args = argv.filter((a) => !a.startsWith('-'));
34
58
  const flags = new Set(argv.filter((a) => a.startsWith('-')));
35
- const [command, name] = args;
59
+ const [command, name, sub] = args;
36
60
  if (flags.has('-v') || flags.has('--version')) {
37
61
  log.info(version());
38
62
  return 0;
@@ -41,34 +65,63 @@ function main(argv) {
41
65
  log.info(HELP);
42
66
  return command === undefined && !flags.has('-h') && !flags.has('--help') ? 1 : 0;
43
67
  }
44
- if (command !== 'create') {
68
+ const dryRun = flags.has('--dry-run');
69
+ const force = flags.has('--force');
70
+ const generateDeps = {
71
+ reader: new DiskFileReader(),
72
+ writer: new DiskFileWriter(),
73
+ log,
74
+ };
75
+ try {
76
+ if (command === 'create') {
77
+ if (!name) {
78
+ log.error('create needs a project name: bahtui create <name>');
79
+ return 1;
80
+ }
81
+ runCreate({
82
+ name,
83
+ targetDir: resolve(process.cwd(), name),
84
+ install: !flags.has('--no-install'),
85
+ git: !flags.has('--no-git'),
86
+ dryRun,
87
+ force,
88
+ }, {
89
+ templates: new DiskTemplateSource(),
90
+ renderer: new HbsRenderer(),
91
+ writer: new DiskFileWriter(),
92
+ runner: new ShellRunner(),
93
+ log,
94
+ });
95
+ return 0;
96
+ }
97
+ if (command === 'init') {
98
+ runInit({
99
+ root: process.cwd(),
100
+ frontend: frontend === 'none' ? null : 'react',
101
+ backend: backend === 'none' ? null : 'elysia',
102
+ dryRun,
103
+ force,
104
+ }, generateDeps);
105
+ return 0;
106
+ }
107
+ if (command === 'g') {
108
+ if (name !== 'model' && name !== 'form') {
109
+ log.error('Unknown generator — bahtui g model <domain> | g form <domain>/<name>.');
110
+ return 1;
111
+ }
112
+ if (!sub) {
113
+ log.error(`g ${name} needs a target: bahtui g ${name} <${name === 'model' ? 'domain' : 'domain/name'}>`);
114
+ return 1;
115
+ }
116
+ runGenerate({ root: process.cwd(), kind: name, target: sub, fields, dryRun, force }, generateDeps);
117
+ return 0;
118
+ }
45
119
  log.error(`Unknown command "${command}".`);
46
120
  log.info(HELP);
47
121
  return 1;
48
122
  }
49
- if (!name) {
50
- log.error('create needs a project name: bahtui create <name>');
51
- return 1;
52
- }
53
- try {
54
- runCreate({
55
- name,
56
- targetDir: resolve(process.cwd(), name),
57
- install: !flags.has('--no-install'),
58
- git: !flags.has('--no-git'),
59
- dryRun: flags.has('--dry-run'),
60
- force: flags.has('--force'),
61
- }, {
62
- templates: new DiskTemplateSource(),
63
- renderer: new HbsRenderer(),
64
- writer: new DiskFileWriter(),
65
- runner: new ShellRunner(),
66
- log,
67
- });
68
- return 0;
69
- }
70
123
  catch (err) {
71
- log.error(err instanceof CreateError ? err.message : String(err));
124
+ log.error(err instanceof CreateError || err instanceof GenerateError ? err.message : String(err));
72
125
  return 1;
73
126
  }
74
127
  }
@@ -0,0 +1,20 @@
1
+ import { type FormDefinition } from 'bank20baht-validator';
2
+ export interface DslField {
3
+ order: number;
4
+ type: string;
5
+ key: string;
6
+ label: string;
7
+ required?: boolean;
8
+ options?: Array<{
9
+ key: string;
10
+ label: string;
11
+ }>;
12
+ min?: number;
13
+ max?: number;
14
+ flag?: string;
15
+ }
16
+ export declare function parseFieldsDsl(dsl: string): DslField[];
17
+ /** DSL -> FormDefinition, engine-validated. Throws FormDslError on any problem. */
18
+ export declare function buildFormDefinition(domain: string, name: string, dsl: string): FormDefinition;
19
+ export declare class FormDslError extends Error {
20
+ }
@@ -0,0 +1,90 @@
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
+ export function parseFieldsDsl(dsl) {
32
+ const fields = [];
33
+ const parts = dsl
34
+ .split(',')
35
+ .map((p) => p.trim())
36
+ .filter(Boolean);
37
+ if (parts.length === 0)
38
+ throw new FormDslError('No fields given — pass --fields "key:type,…".');
39
+ for (const [i, part] of parts.entries()) {
40
+ const [key, type, ...mods] = part.split(':').map((s) => s.trim());
41
+ if (!key || !KEY_PATTERN.test(key)) {
42
+ throw new FormDslError(`Bad field key ${JSON.stringify(key ?? '')} in "${part}".`);
43
+ }
44
+ if (!type || !FIELD_TYPES.has(type)) {
45
+ throw new FormDslError(`Unknown field type ${JSON.stringify(type ?? '')} in "${part}" — one of: ${[...FIELD_TYPES].join(', ')}.`);
46
+ }
47
+ const field = { order: i + 1, type, key, label: titleCase(key) };
48
+ for (const mod of mods) {
49
+ if (mod === 'required')
50
+ field.required = true;
51
+ else if (mod.startsWith('options=')) {
52
+ field.options = mod
53
+ .slice('options='.length)
54
+ .split('|')
55
+ .filter(Boolean)
56
+ .map((o) => ({ key: o, label: titleCase(o) }));
57
+ }
58
+ else if (mod.startsWith('min='))
59
+ field.min = Number(mod.slice(4));
60
+ else if (mod.startsWith('max='))
61
+ field.max = Number(mod.slice(4));
62
+ else if (mod.startsWith('flag='))
63
+ field.flag = mod.slice(5);
64
+ else
65
+ throw new FormDslError(`Unknown modifier ${JSON.stringify(mod)} in "${part}".`);
66
+ }
67
+ fields.push(field);
68
+ }
69
+ const keys = fields.map((f) => f.key);
70
+ if (new Set(keys).size !== keys.length) {
71
+ throw new FormDslError('Field keys must be unique.');
72
+ }
73
+ return fields;
74
+ }
75
+ /** DSL -> FormDefinition, engine-validated. Throws FormDslError on any problem. */
76
+ export function buildFormDefinition(domain, name, dsl) {
77
+ const form = {
78
+ formId: `${domain}-${name}`,
79
+ formName: titleCase(name.replace(/-/g, '_')).replace(/ /g, '_'),
80
+ field_data: parseFieldsDsl(dsl),
81
+ };
82
+ try {
83
+ return parseSchema(JSON.stringify(form));
84
+ }
85
+ catch (err) {
86
+ throw new FormDslError(`Engine rejected the form spec: ${err.message}`);
87
+ }
88
+ }
89
+ export class FormDslError extends Error {
90
+ }
@@ -0,0 +1,23 @@
1
+ import type { FormDefinition } from 'bank20baht-validator';
2
+ import type { Blueprint } from './blueprint.js';
3
+ /** bahtui.json — written by create/init, read by every `g` command (PRD). */
4
+ export interface BahtuiConfig {
5
+ apps: {
6
+ web?: string;
7
+ api?: string;
8
+ };
9
+ libsDir: string;
10
+ frontend: 'react' | null;
11
+ backend: 'elysia' | null;
12
+ edenClient?: string;
13
+ wrapperPath?: string;
14
+ }
15
+ export declare const DEFAULT_CONFIG: BahtuiConfig;
16
+ export declare function validateDomainName(domain: string): string[];
17
+ /** `bahtui init` — write bahtui.json into an existing repo (root = cwd). */
18
+ export declare function planInit(root: string, config: BahtuiConfig): Blueprint;
19
+ export declare function modelDir(config: BahtuiConfig, domain: string): string;
20
+ /** `bahtui g model <domain>` — the framework-free domain model lib (PRD). */
21
+ export declare function planModel(root: string, config: BahtuiConfig, domain: string): Blueprint;
22
+ /** `bahtui g form <domain>/<name>` — engine-validated FormDefinition JSON. */
23
+ export declare function planForm(root: string, config: BahtuiConfig, domain: string, name: string, form: FormDefinition): Blueprint;
@@ -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
+ }
@@ -0,0 +1,5 @@
1
+ import type { FileReader } from '../application/generate.js';
2
+ export declare class DiskFileReader implements FileReader {
3
+ exists(path: string): boolean;
4
+ read(path: string): string | null;
5
+ }
@@ -0,0 +1,14 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ export class DiskFileReader {
3
+ exists(path) {
4
+ return existsSync(path);
5
+ }
6
+ read(path) {
7
+ try {
8
+ return readFileSync(path, 'utf8');
9
+ }
10
+ catch {
11
+ return null;
12
+ }
13
+ }
14
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bank20baht-cli",
3
- "version": "0.0.1",
3
+ "version": "0.0.2",
4
4
  "type": "module",
5
5
  "description": "Scaffold bun-workspace DDD monorepos (React + Elysia + Eden) and bahtui generators.",
6
6
  "license": "MIT",
@@ -40,7 +40,8 @@
40
40
  "test:watch": "vitest"
41
41
  },
42
42
  "dependencies": {
43
- "handlebars": "^4.7.8"
43
+ "handlebars": "^4.7.8",
44
+ "bank20baht-validator": "^0.0.5"
44
45
  },
45
46
  "devDependencies": {
46
47
  "@types/node": "^22",