bank20baht-cli 0.5.0 → 0.7.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 +61 -2
- package/dist/application/create.js +13 -2
- package/dist/application/generate.js +95 -0
- package/dist/args.js +1 -0
- package/dist/cli.js +41 -4
- package/dist/domain/create-plan.js +21 -3
- package/dist/domain/deploy-plan.js +82 -0
- package/dist/domain/form-dsl.js +2 -0
- package/dist/domain/remote-plan.js +156 -0
- package/package.json +4 -3
- package/templates/angular/README.md.hbs +46 -0
- package/templates/angular/_dependency-cruiser.mjs +51 -0
- package/templates/angular/apps/server/package.json +30 -0
- package/templates/angular/apps/server/src/app.ts +14 -0
- package/templates/angular/apps/server/src/index.ts +8 -0
- package/templates/angular/apps/server/test/app.test.ts +12 -0
- package/templates/angular/apps/server/tsconfig.json +7 -0
- package/templates/angular/apps/server/tsconfig.types.json +12 -0
- package/templates/angular/apps/shell/_editorconfig +17 -0
- package/templates/angular/apps/shell/angular.json +115 -0
- package/templates/angular/apps/shell/federation.config.mjs +44 -0
- package/templates/angular/apps/shell/package.json +42 -0
- package/templates/angular/apps/shell/public/federation.manifest.json +1 -0
- package/templates/angular/apps/shell/src/app/app.config.ts +20 -0
- package/templates/angular/apps/shell/src/app/app.css +4 -0
- package/templates/angular/apps/shell/src/app/app.html.hbs +16 -0
- package/templates/angular/apps/shell/src/app/app.routes.ts.hbs +39 -0
- package/templates/angular/apps/shell/src/app/app.ts +57 -0
- package/templates/angular/apps/shell/src/app/core/federation/load-remote.ts +31 -0
- package/templates/angular/apps/shell/src/app/core/federation/manifest.spec.ts +46 -0
- package/templates/angular/apps/shell/src/app/core/federation/manifest.ts +37 -0
- package/templates/angular/apps/shell/src/app/core/federation/remote-loader.ts +20 -0
- package/templates/angular/apps/shell/src/app/core/federation/timeout.ts +17 -0
- package/templates/angular/apps/shell/src/app/modules/home/home.component.ts.hbs +46 -0
- package/templates/angular/apps/shell/src/app/modules/unavailable/unavailable.component.ts +39 -0
- package/templates/angular/apps/shell/src/bootstrap.ts +25 -0
- package/templates/angular/apps/shell/src/environments/environment.development.ts +10 -0
- package/templates/angular/apps/shell/src/environments/environment.ts +10 -0
- package/templates/angular/apps/shell/src/index.html.hbs +12 -0
- package/templates/angular/apps/shell/src/main.ts +43 -0
- package/templates/angular/apps/shell/src/styles.css +19 -0
- package/templates/angular/apps/shell/tsconfig.app.json +10 -0
- package/templates/angular/apps/shell/tsconfig.federation.json +4 -0
- package/templates/angular/apps/shell/tsconfig.json.hbs +39 -0
- package/templates/angular/apps/shell/tsconfig.spec.json +9 -0
- package/templates/angular/bahtui.json +6 -0
- package/templates/angular/eslint.config.ts.hbs +28 -0
- package/templates/angular/knip.ts +27 -0
- package/templates/angular/package.json.hbs +52 -0
- package/templates/angular/packages/kit/package.json.hbs +27 -0
- package/templates/angular/packages/kit/src/api.ts +21 -0
- package/templates/angular/packages/kit/src/errors.ts +28 -0
- package/templates/angular/packages/kit/src/index.ts +2 -0
- package/templates/angular/packages/kit/test/errors.test.ts +28 -0
- package/templates/angular/packages/kit/tsconfig.json +9 -0
- package/templates/create/README.md.hbs +1 -1
- package/templates/create/_gitignore +1 -0
- package/templates/create/apps/web/index.html.hbs +8 -0
- package/templates/create/apps/web/package.json.hbs +1 -0
- package/templates/create/apps/web/src/App.tsx.hbs +18 -7
- package/templates/create/apps/web/src/app.css +27 -0
- package/templates/create/apps/web/src/health.test.ts.hbs +18 -4
- package/templates/create/apps/web/src/health.ts.hbs +8 -0
- package/templates/create/apps/web/src/main.tsx.hbs +12 -1
- package/templates/create/package.json.hbs +15 -5
- package/templates/deploy/deploy/__project__.service.hbs +23 -0
- package/templates/deploy/deploy/env.example.hbs +4 -0
- package/templates/deploy/deploy/install.sh.hbs +58 -0
- package/templates/deploy/deploy/nginx.conf.hbs +67 -0
- package/templates/deploy/scripts/deploy.ts.hbs +33 -0
- package/templates/deploy/scripts/preview.ts.hbs +57 -0
- package/templates/deploy/scripts/release.ts.hbs +48 -0
- package/templates/new/_gitignore +3 -0
- package/templates/new/_prettierignore +1 -0
- package/templates/new/package.json.hbs +2 -2
- package/templates/remote/_editorconfig +17 -0
- package/templates/remote/angular.json.hbs +95 -0
- package/templates/remote/federation.config.mjs.hbs +48 -0
- package/templates/remote/package.json.hbs +37 -0
- package/templates/remote/src/app/__name__.routes.ts.hbs +12 -0
- package/templates/remote/src/app/modules/__name__/__name__.component.ts.hbs +37 -0
- package/templates/remote/src/app/services/__name__.service.ts.hbs +14 -0
- package/templates/remote/src/index.html.hbs +11 -0
- package/templates/remote/src/main.ts.hbs +3 -0
- package/templates/remote/tsconfig.app.json +10 -0
- package/templates/remote/tsconfig.federation.json +4 -0
- package/templates/remote/tsconfig.json.hbs +36 -0
package/README.md
CHANGED
|
@@ -20,9 +20,12 @@ steps.
|
|
|
20
20
|
| ---------------------------------------- | ----------------------------------------------------------------- |
|
|
21
21
|
| `bahtui new <name>` | Bare bun workspace in `./<name>` — toolchain only, no apps |
|
|
22
22
|
| `bahtui create <name>` | Full DDD monorepo in `./<name>` — React + Elysia + Eden |
|
|
23
|
+
| `bahtui create <name> --angular` | Angular Native Federation host + one remote + Elysia + shared kit |
|
|
23
24
|
| `bahtui init` | Write `bahtui.json` into an existing repo (enables the generators) |
|
|
24
25
|
| `bahtui g model <domain>` | `libs/domain/<domain>/model` package |
|
|
25
26
|
| `bahtui g form <domain>/<name> --fields` | Engine-validated `FormDefinition` JSON inside that model package |
|
|
27
|
+
| `bahtui g remote <name>` | Angular workspace: another remote, wired into the host and checks |
|
|
28
|
+
| `bahtui g deploy [nginx]` | Angular workspace: nginx + systemd deploy, release/deploy/preview |
|
|
26
29
|
|
|
27
30
|
### `bahtui new <name>`
|
|
28
31
|
|
|
@@ -72,7 +75,7 @@ Add a matching entry to `knip.ts` so dead-code checks cover it.
|
|
|
72
75
|
Same flags as `new`, but scaffolds a complete stack:
|
|
73
76
|
|
|
74
77
|
```
|
|
75
|
-
apps/web React 19 + Vite
|
|
78
|
+
apps/web React 19 + Vite on bank20baht-ui (tokens, <baht-*> elements, dark toggle)
|
|
76
79
|
apps/api Elysia — exports `type App`, the Eden type anchor
|
|
77
80
|
libs/shared/eden treaty<App> — the one typed HTTP client
|
|
78
81
|
bahtui.json generator config (see below)
|
|
@@ -84,6 +87,62 @@ cd shop
|
|
|
84
87
|
bun run dev # web on :5173, api on :3001
|
|
85
88
|
```
|
|
86
89
|
|
|
90
|
+
### `bahtui create <name> --angular`
|
|
91
|
+
|
|
92
|
+
Same flags. The `new` toolchain, plus microfrontends on
|
|
93
|
+
[Native Federation](https://www.npmjs.com/package/@angular-architects/native-federation):
|
|
94
|
+
|
|
95
|
+
```
|
|
96
|
+
apps/shell host (:4200): bank20baht-ui-angular app shell, navigator built from the
|
|
97
|
+
federation manifest, a fallback page for each remote that cannot load
|
|
98
|
+
apps/hello-web remote (:4201): one page calling GET /api/hello, exposed as ./Routes
|
|
99
|
+
apps/server Elysia under /api; exports `type App`, emitted to dist/types for the web apps
|
|
100
|
+
packages/kit typed ngx-eden client + error text, compiled into each app as @<name>/kit
|
|
101
|
+
bahtui.json { "frontend": "angular", "apps": { "web": "apps/shell", ... } }
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
`eslint.config.ts` adds the `angular()` and `bahtui()` presets for the web apps, `knip.ts` a
|
|
105
|
+
workspace per app, `.dependency-cruiser.mjs` "apps never import each other", "core/, services/,
|
|
106
|
+
shared/ know no page" and "packages know no app". The generated repo passes its own
|
|
107
|
+
`bun run check` (`bun run --filter bank20baht-cli accept:angular` proves it: scaffold, install,
|
|
108
|
+
`g remote`, check). What it gets right on purpose:
|
|
109
|
+
|
|
110
|
+
- `features.ignoreUnusedDeps: false` in every `federation.config.mjs` (its scan refuses the kit,
|
|
111
|
+
which lives outside the app folder), so every dependency is shared and each app lists
|
|
112
|
+
bank20baht-ui's optional peers `marked`, `dompurify`, `flatpickr`; `/^marked\/bin/` is skipped.
|
|
113
|
+
- `fileReplacements` on `esbuild:development`, not `build:development`.
|
|
114
|
+
- Server types reach the web apps as built declarations, and the server's imports are
|
|
115
|
+
extensionless (Angular's compiler rejects `./x.ts` specifiers).
|
|
116
|
+
- A remote that is down at boot gets a fallback page under its URL prefix.
|
|
117
|
+
|
|
118
|
+
### `bahtui g remote <name>`
|
|
119
|
+
|
|
120
|
+
In a `create --angular` workspace: `apps/<name>-web` on the next free port, mounted at
|
|
121
|
+
`/<name>`, plus a line in `eslint.config.ts`, `knip.ts`, `.dependency-cruiser.mjs`, the dev
|
|
122
|
+
manifest (`apps/shell/src/environments/environment.development.ts`) and the deployed one
|
|
123
|
+
(`apps/shell/public/federation.manifest.json`). The TS/JS files carry a `// bahtui:remotes` line
|
|
124
|
+
the entry goes above; if one is gone, nothing is written and the error names the file. Run
|
|
125
|
+
`bun install` afterwards.
|
|
126
|
+
|
|
127
|
+
### `bahtui g deploy [nginx]`
|
|
128
|
+
|
|
129
|
+
In a `create --angular` workspace, one web server for everything:
|
|
130
|
+
|
|
131
|
+
```
|
|
132
|
+
deploy/nginx.conf host at /, remotes at /remotes/<app>/, /federation.manifest.json from
|
|
133
|
+
/etc/<name>/ (edit it there to add or repoint a remote), /api/ proxied
|
|
134
|
+
deploy/<name>.service systemd unit running the bundled server as the <name> user
|
|
135
|
+
deploy/env.example /etc/<name>/env, written once
|
|
136
|
+
deploy/install.sh runs on the target as root: nginx, bun and the user on the first run,
|
|
137
|
+
then swaps the release in and restarts (safe to rerun)
|
|
138
|
+
scripts/release.ts bun run release: build, lay the result out in .release/
|
|
139
|
+
scripts/deploy.ts bun run deploy root@host: release, tar over ssh, install.sh
|
|
140
|
+
scripts/preview.ts bun run preview: serve .release/ like nginx does, on :8080
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
It adds the three scripts to `package.json` and `.release` to `.gitignore`, `.prettierignore` and
|
|
144
|
+
the eslint ignores. It refuses an existing `deploy/` (or existing script names) without `--force`.
|
|
145
|
+
|
|
87
146
|
### `bahtui init`
|
|
88
147
|
|
|
89
148
|
Writes `bahtui.json` into the current directory so `bahtui g …` knows where things live.
|
|
@@ -126,7 +185,7 @@ Field DSL — comma-separated `key:type[:modifier…]`:
|
|
|
126
185
|
| Part | Values |
|
|
127
186
|
| ---------- | -------------------------------------------------------------------------------------------------------------- |
|
|
128
187
|
| `key` | `[a-z][a-z0-9_]*`; the label is derived (`due_date` → "Due Date") |
|
|
129
|
-
| `type` | `text` `textarea` `markdown` `number` `email` `password` `dropdown` `multiselect` `checkbox` `radio` `toggle` `date` `file` `rating` `range` `reference` |
|
|
188
|
+
| `type` | `text` `textarea` `markdown` `number` `currency` `email` `password` `dropdown` `multiselect` `checkbox` `radio` `toggle` `date` `file` `rating` `range` `reference` `tags` |
|
|
130
189
|
| modifiers | `required` · `options=a\|b\|c` · `min=N` · `max=N` · `step=N` · `ref=LOOKUP` · `flag=NAME` |
|
|
131
190
|
|
|
132
191
|
Invalid DSL (unknown type, empty options, non-numeric `min`) fails before anything is
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { planCreate,
|
|
1
|
+
import { ANGULAR_HOST, overlayTemplates, planCreate, PRESET_TEMPLATE_SETS, } from '../domain/create-plan.js';
|
|
2
|
+
import { withFirstRemote } from '../domain/remote-plan.js';
|
|
2
3
|
import { validateProjectName } from '../domain/naming.js';
|
|
3
4
|
function logDryRun(blueprint, deps) {
|
|
4
5
|
deps.log.info(`dry-run — would create in ${blueprint.root}:`);
|
|
@@ -35,7 +36,12 @@ export function runCreate(input, deps) {
|
|
|
35
36
|
deps.log.error(e);
|
|
36
37
|
throw new CreateError('Invalid project name.');
|
|
37
38
|
}
|
|
38
|
-
const
|
|
39
|
+
const render = (raw, data) => deps.renderer.render(raw, data);
|
|
40
|
+
const sets = PRESET_TEMPLATE_SETS[input.preset].map((set) => deps.templates.load(set));
|
|
41
|
+
const planned = planCreate(input, overlayTemplates(sets), render);
|
|
42
|
+
const blueprint = input.preset === 'angular'
|
|
43
|
+
? withFirstRemote(planned, input.name, ANGULAR_HOST, deps.templates.load('remote'), render)
|
|
44
|
+
: planned;
|
|
39
45
|
if (input.dryRun) {
|
|
40
46
|
logDryRun(blueprint, deps);
|
|
41
47
|
return;
|
|
@@ -51,6 +57,11 @@ const NEXT_STEPS = {
|
|
|
51
57
|
'bun run dev # web :5173 + api :3001',
|
|
52
58
|
'bun run lint && bun run test && bun run build',
|
|
53
59
|
],
|
|
60
|
+
angular: [
|
|
61
|
+
'bun run dev # shell :4200, hello-web :4201, server :3000',
|
|
62
|
+
'bahtui g remote <name> # add another remote',
|
|
63
|
+
'bun run check # lint + format:check + typecheck + madge + deps + knip + dup + build + test',
|
|
64
|
+
],
|
|
54
65
|
workspace: [
|
|
55
66
|
'mkdir -p apps/<name> && add a package.json with dev/build/typecheck scripts',
|
|
56
67
|
'bun run dev # runs every apps/*/ dev script in parallel',
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { buildFormDefinition, FormDslError } from '../domain/form-dsl.js';
|
|
2
2
|
import { DEFAULT_CONFIG, modelDir, planForm, planInit, planModel, validateDomainName, } from '../domain/generate-plan.js';
|
|
3
|
+
import { DEFAULT_API_PORT, DeployPlanError, planDeploy } from '../domain/deploy-plan.js';
|
|
4
|
+
import { nextRemotePort, planRemoteFiles, remoteFor, RemoteWiringError, validateRemoteName, wireRemote, wiringPaths, } from '../domain/remote-plan.js';
|
|
3
5
|
export class GenerateError extends Error {
|
|
4
6
|
}
|
|
5
7
|
/**
|
|
@@ -119,3 +121,96 @@ export function runGenerate(input, deps) {
|
|
|
119
121
|
}
|
|
120
122
|
emit(planForm(input.root, config, domain, name, form), input.dryRun, deps);
|
|
121
123
|
}
|
|
124
|
+
function readWiring(root, host, reader) {
|
|
125
|
+
const paths = wiringPaths(host);
|
|
126
|
+
const read = (path) => {
|
|
127
|
+
const text = reader.read(`${root}/${path}`);
|
|
128
|
+
if (text === null)
|
|
129
|
+
throw new GenerateError(`${path} is missing — g remote edits it.`);
|
|
130
|
+
return text;
|
|
131
|
+
};
|
|
132
|
+
return {
|
|
133
|
+
eslint: read(paths.eslint),
|
|
134
|
+
knip: read(paths.knip),
|
|
135
|
+
depcruise: read(paths.depcruise),
|
|
136
|
+
devManifest: read(paths.devManifest),
|
|
137
|
+
manifest: read(paths.manifest),
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
function projectName(root, reader) {
|
|
141
|
+
const raw = reader.read(`${root}/package.json`);
|
|
142
|
+
const name = raw === null ? undefined : JSON.parse(raw).name;
|
|
143
|
+
if (typeof name !== 'string' || !/^[a-z][a-z0-9-]*$/.test(name)) {
|
|
144
|
+
throw new GenerateError('package.json needs the project name (it scopes the @<name>/kit path).');
|
|
145
|
+
}
|
|
146
|
+
return name;
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* `bahtui g remote <name>` in an Angular workspace (`bahtui create --angular`): a new Native
|
|
150
|
+
* Federation remote under `apps/<name>-web`, added to the host's manifests, eslint, knip and
|
|
151
|
+
* dependency-cruiser. Nothing is written when any of those files cannot take it.
|
|
152
|
+
*/
|
|
153
|
+
export function runGenerateRemote(input, deps) {
|
|
154
|
+
const config = loadConfig(input.root, deps.reader);
|
|
155
|
+
if (config.frontend !== 'angular') {
|
|
156
|
+
throw new GenerateError('g remote needs an Angular workspace (bahtui.json "frontend": "angular", from bahtui create --angular).');
|
|
157
|
+
}
|
|
158
|
+
const host = config.apps.web;
|
|
159
|
+
if (host === undefined)
|
|
160
|
+
throw new GenerateError('bahtui.json needs "apps.web", the host app.');
|
|
161
|
+
validateConfigPath('apps.web', host);
|
|
162
|
+
const errors = validateRemoteName(input.name);
|
|
163
|
+
if (errors.length > 0)
|
|
164
|
+
throw new GenerateError(errors.join(' '));
|
|
165
|
+
const current = readWiring(input.root, host, deps.reader);
|
|
166
|
+
const remote = remoteFor(input.name, nextRemotePort(current.devManifest));
|
|
167
|
+
if (deps.reader.exists(`${input.root}/apps/${remote.app}`) && !input.force) {
|
|
168
|
+
throw new GenerateError(`apps/${remote.app} already exists — use --force.`);
|
|
169
|
+
}
|
|
170
|
+
const files = planned(RemoteWiringError, () => [
|
|
171
|
+
...planRemoteFiles(remote, projectName(input.root, deps.reader), deps.templates.load('remote'), (raw, data) => deps.renderer.render(raw, data)),
|
|
172
|
+
...wireRemote(current, host, remote),
|
|
173
|
+
]);
|
|
174
|
+
emitThen(input, files, deps, `apps/${remote.app} is on :${remote.port} and mounted at /${remote.name}. Run bun install, then bun run dev.`);
|
|
175
|
+
}
|
|
176
|
+
/** Runs a domain plan, reporting its own error class as a GenerateError. */
|
|
177
|
+
function planned(domainError, plan) {
|
|
178
|
+
try {
|
|
179
|
+
return plan();
|
|
180
|
+
}
|
|
181
|
+
catch (err) {
|
|
182
|
+
if (err instanceof domainError)
|
|
183
|
+
throw new GenerateError(err.message);
|
|
184
|
+
throw err;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
/** Writes (or, on --dry-run, lists) the files, then says what comes next. */
|
|
188
|
+
function emitThen(input, files, deps, next) {
|
|
189
|
+
emit({ root: input.root, files, postCommands: [] }, input.dryRun, deps);
|
|
190
|
+
if (!input.dryRun)
|
|
191
|
+
deps.log.info(next);
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* `bahtui g deploy [nginx]` in an Angular workspace: `deploy/` (nginx.conf, a systemd unit,
|
|
195
|
+
* env.example, install.sh) and `scripts/{release,deploy,preview}.ts`, wired into package.json,
|
|
196
|
+
* `.gitignore`, `.prettierignore` and eslint. Nothing is written when an edit cannot apply.
|
|
197
|
+
*/
|
|
198
|
+
export function runGenerateDeploy(input, deps) {
|
|
199
|
+
const config = loadConfig(input.root, deps.reader);
|
|
200
|
+
if (config.frontend !== 'angular' || config.apps.web === undefined) {
|
|
201
|
+
throw new GenerateError('g deploy needs an Angular workspace (bahtui.json "frontend": "angular", from bahtui create --angular).');
|
|
202
|
+
}
|
|
203
|
+
validateConfigPath('apps.web', config.apps.web);
|
|
204
|
+
const host = config.apps.web.split('/').pop() ?? config.apps.web;
|
|
205
|
+
if (deps.reader.exists(`${input.root}/deploy`) && !input.force) {
|
|
206
|
+
throw new GenerateError('deploy/ already exists — use --force.');
|
|
207
|
+
}
|
|
208
|
+
const read = (path) => deps.reader.read(`${input.root}/${path}`) ?? '';
|
|
209
|
+
const files = planned(DeployPlanError, () => planDeploy({ project: projectName(input.root, deps.reader), host, apiPort: DEFAULT_API_PORT }, {
|
|
210
|
+
packageJson: read('package.json'),
|
|
211
|
+
gitignore: read('.gitignore'),
|
|
212
|
+
prettierignore: read('.prettierignore'),
|
|
213
|
+
eslint: read('eslint.config.ts'),
|
|
214
|
+
}, deps.templates.load('deploy'), (raw, data) => deps.renderer.render(raw, data), input.force));
|
|
215
|
+
emitThen(input, files, deps, 'Next: bun run release (builds .release/), bun run preview (serves it on :8080), bun run deploy root@<host>.');
|
|
216
|
+
}
|
package/dist/args.js
CHANGED
package/dist/cli.js
CHANGED
|
@@ -3,28 +3,35 @@ 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
|
+
import { runGenerate, runGenerateDeploy, runGenerateRemote, runInit, GenerateError, } from './application/generate.js';
|
|
7
7
|
import { DiskTemplateSource } from './infrastructure/template-source.js';
|
|
8
8
|
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
12
|
import { KNOWN_FLAGS, parseArgs } from './args.js';
|
|
13
|
-
const HELP = `bahtui — bun-workspace DDD scaffolding (React + Elysia + Eden)
|
|
13
|
+
const HELP = `bahtui — bun-workspace DDD scaffolding (React or Angular + Elysia + Eden)
|
|
14
14
|
|
|
15
15
|
Usage:
|
|
16
16
|
bahtui new <name> [options] bare bun workspace in ./<name> — toolchain only
|
|
17
17
|
(eslint, prettier, knip, husky, commitlint, gitleaks, jscpd, depcruise, CI), no apps
|
|
18
18
|
bahtui create <name> [options] full monorepo in ./<name> (React + Elysia + Eden)
|
|
19
|
+
bahtui create <name> --angular Angular Native Federation host + one remote + Elysia
|
|
20
|
+
server + shared web kit, on the new toolchain
|
|
19
21
|
bahtui init [options] write bahtui.json into an existing repo
|
|
20
22
|
bahtui g model <domain> libs/domain/<d>/model package
|
|
21
23
|
bahtui g form <domain>/<name> --fields "key:type:mod,…"
|
|
22
24
|
engine-validated FormDefinition JSON
|
|
25
|
+
bahtui g remote <name> Angular workspace: apps/<name>-web remote, added to the
|
|
26
|
+
host manifests, eslint, knip and dependency-cruiser
|
|
27
|
+
bahtui g deploy [nginx] Angular workspace: deploy/ (nginx, systemd, install.sh)
|
|
28
|
+
and release / deploy / preview scripts
|
|
23
29
|
Field DSL:
|
|
24
30
|
title:text:required,priority:dropdown:options=low|medium|high,qty:number:min=1
|
|
25
31
|
|
|
26
32
|
Options:
|
|
27
33
|
--fields <dsl> g form: the field list (required)
|
|
34
|
+
--angular create: the Angular Native Federation scaffold instead of React
|
|
28
35
|
--frontend <react|none> init: default react
|
|
29
36
|
--backend <elysia|none> init: default elysia
|
|
30
37
|
--dry-run print what would be generated, write nothing
|
|
@@ -73,6 +80,11 @@ function preflight({ flags }) {
|
|
|
73
80
|
}
|
|
74
81
|
return null;
|
|
75
82
|
}
|
|
83
|
+
function presetFor(a, command) {
|
|
84
|
+
if (command === 'new')
|
|
85
|
+
return 'workspace';
|
|
86
|
+
return a.flags.has('--angular') ? 'angular' : 'full';
|
|
87
|
+
}
|
|
76
88
|
function commandCreate(a, command) {
|
|
77
89
|
if (!a.name) {
|
|
78
90
|
log.error(`${command} needs a project name: bahtui ${command} <name>`);
|
|
@@ -80,7 +92,7 @@ function commandCreate(a, command) {
|
|
|
80
92
|
}
|
|
81
93
|
runCreate({
|
|
82
94
|
name: a.name,
|
|
83
|
-
preset: command
|
|
95
|
+
preset: presetFor(a, command),
|
|
84
96
|
targetDir: resolve(process.cwd(), a.name),
|
|
85
97
|
install: !a.flags.has('--no-install') && !a.flags.has('--skip-install'),
|
|
86
98
|
git: !a.flags.has('--no-git') && !a.flags.has('--skip-git'),
|
|
@@ -107,8 +119,12 @@ function commandInit(a, generateDeps) {
|
|
|
107
119
|
}
|
|
108
120
|
function commandGenerate(a, generateDeps) {
|
|
109
121
|
const { name, sub } = a;
|
|
122
|
+
if (name === 'remote')
|
|
123
|
+
return commandRemote(a, generateDeps);
|
|
124
|
+
if (name === 'deploy')
|
|
125
|
+
return commandDeploy(a, generateDeps);
|
|
110
126
|
if (name !== 'model' && name !== 'form') {
|
|
111
|
-
log.error('Unknown generator — bahtui g model <domain> | g form <domain>/<name
|
|
127
|
+
log.error('Unknown generator — bahtui g model <domain> | g form <domain>/<name> | g remote <name> | g deploy.');
|
|
112
128
|
return 1;
|
|
113
129
|
}
|
|
114
130
|
if (!sub) {
|
|
@@ -125,6 +141,27 @@ function commandGenerate(a, generateDeps) {
|
|
|
125
141
|
}, generateDeps);
|
|
126
142
|
return 0;
|
|
127
143
|
}
|
|
144
|
+
function commandRemote(a, generateDeps) {
|
|
145
|
+
if (!a.sub) {
|
|
146
|
+
log.error('g remote needs a name: bahtui g remote <name>');
|
|
147
|
+
return 1;
|
|
148
|
+
}
|
|
149
|
+
runGenerateRemote({
|
|
150
|
+
root: process.cwd(),
|
|
151
|
+
name: a.sub,
|
|
152
|
+
dryRun: a.flags.has('--dry-run'),
|
|
153
|
+
force: a.flags.has('--force'),
|
|
154
|
+
}, { ...generateDeps, templates: new DiskTemplateSource(), renderer: new TemplateRenderer() });
|
|
155
|
+
return 0;
|
|
156
|
+
}
|
|
157
|
+
function commandDeploy(a, generateDeps) {
|
|
158
|
+
if (a.sub !== undefined && a.sub !== 'nginx') {
|
|
159
|
+
log.error(`g deploy knows one target, nginx (got "${a.sub}").`);
|
|
160
|
+
return 1;
|
|
161
|
+
}
|
|
162
|
+
runGenerateDeploy({ root: process.cwd(), dryRun: a.flags.has('--dry-run'), force: a.flags.has('--force') }, { ...generateDeps, templates: new DiskTemplateSource(), renderer: new TemplateRenderer() });
|
|
163
|
+
return 0;
|
|
164
|
+
}
|
|
128
165
|
function dispatch(a, command) {
|
|
129
166
|
const generateDeps = {
|
|
130
167
|
reader: new DiskFileReader(),
|
|
@@ -1,7 +1,25 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
/** Template sets per preset; a later set's file replaces an earlier one at the same path. */
|
|
2
|
+
export const PRESET_TEMPLATE_SETS = {
|
|
3
|
+
full: ['create'],
|
|
4
|
+
workspace: ['new'],
|
|
5
|
+
angular: ['new', 'angular'],
|
|
4
6
|
};
|
|
7
|
+
/** The Angular preset's host app folder. */
|
|
8
|
+
export const ANGULAR_HOST = 'apps/shell';
|
|
9
|
+
/**
|
|
10
|
+
* Lays template sets over each other by output path (`x.hbs` and `x` are the same file). A
|
|
11
|
+
* `_gitkeep` placeholder is dropped from any folder a later set puts files in.
|
|
12
|
+
*/
|
|
13
|
+
export function overlayTemplates(sets) {
|
|
14
|
+
const byPath = new Map();
|
|
15
|
+
for (const set of sets)
|
|
16
|
+
for (const t of set)
|
|
17
|
+
byPath.set(t.relPath.replace(/\.hbs$/, ''), t);
|
|
18
|
+
const all = [...byPath.values()];
|
|
19
|
+
const dirOf = (p) => p.slice(0, p.lastIndexOf('/') + 1);
|
|
20
|
+
return all.filter((t) => !t.relPath.endsWith('_gitkeep') ||
|
|
21
|
+
!all.some((o) => o !== t && o.relPath.startsWith(dirOf(t.relPath))));
|
|
22
|
+
}
|
|
5
23
|
/**
|
|
6
24
|
* Template naming conventions (the whole contract between templates/ and here):
|
|
7
25
|
* - `*.hbs` → rendered with the data below, suffix stripped.
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/** The server template listens on `PORT`, 3000 unless set. */
|
|
2
|
+
export const DEFAULT_API_PORT = 3000;
|
|
3
|
+
export class DeployPlanError extends Error {
|
|
4
|
+
}
|
|
5
|
+
const SCRIPTS = {
|
|
6
|
+
release: 'bun run scripts/release.ts',
|
|
7
|
+
deploy: 'bun run scripts/deploy.ts',
|
|
8
|
+
preview: 'bun run scripts/preview.ts',
|
|
9
|
+
};
|
|
10
|
+
const RELEASE_DIR = '.release';
|
|
11
|
+
const ESLINT_IGNORE = [
|
|
12
|
+
'// What `bun run release` builds: bundled output, not source.',
|
|
13
|
+
"{ ignores: ['.release/**'] },",
|
|
14
|
+
];
|
|
15
|
+
function withLine(text, line) {
|
|
16
|
+
if (text.split('\n').some((l) => l.trim() === line))
|
|
17
|
+
return text;
|
|
18
|
+
return `${text.endsWith('\n') || text === '' ? text : `${text}\n`}${line}\n`;
|
|
19
|
+
}
|
|
20
|
+
function withScripts(text) {
|
|
21
|
+
let pkg;
|
|
22
|
+
try {
|
|
23
|
+
pkg = JSON.parse(text);
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
throw new DeployPlanError('package.json is not valid JSON.');
|
|
27
|
+
}
|
|
28
|
+
if (typeof pkg !== 'object' || pkg === null || Array.isArray(pkg)) {
|
|
29
|
+
throw new DeployPlanError('package.json must hold a JSON object.');
|
|
30
|
+
}
|
|
31
|
+
const record = pkg;
|
|
32
|
+
const scripts = (record.scripts ?? {});
|
|
33
|
+
const taken = Object.keys(SCRIPTS).filter((name) => name in scripts);
|
|
34
|
+
if (taken.length > 0) {
|
|
35
|
+
throw new DeployPlanError(`package.json already has ${taken.map((t) => `"${t}"`).join(', ')} — remove them or use --force.`);
|
|
36
|
+
}
|
|
37
|
+
return JSON.stringify({ ...record, scripts: { ...scripts, ...SCRIPTS } }, null, 2) + '\n';
|
|
38
|
+
}
|
|
39
|
+
function withEslintIgnore(text) {
|
|
40
|
+
if (text.includes("'.release/**'"))
|
|
41
|
+
return text;
|
|
42
|
+
const lines = text.split('\n');
|
|
43
|
+
const at = lines.findIndex((l) => l.includes('defineConfig(['));
|
|
44
|
+
if (at === -1) {
|
|
45
|
+
throw new DeployPlanError(`eslint.config.ts has no "defineConfig([" to add the release folder to. Add this by hand:\n${ESLINT_IGNORE.join('\n')}`);
|
|
46
|
+
}
|
|
47
|
+
lines.splice(at + 1, 0, ...ESLINT_IGNORE.map((l) => ` ${l}`));
|
|
48
|
+
return lines.join('\n');
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* The deploy files rendered from `templates/deploy` (`__project__` in a path is the project
|
|
52
|
+
* name), plus the edits: package.json scripts, `.release` in `.gitignore` / `.prettierignore`, and
|
|
53
|
+
* an eslint ignore for it. `force` replaces existing release/deploy/preview scripts.
|
|
54
|
+
*/
|
|
55
|
+
export function planDeploy(target, current, templates, render, force = false) {
|
|
56
|
+
const files = templates.map((t) => {
|
|
57
|
+
const isTemplate = t.relPath.endsWith('.hbs');
|
|
58
|
+
const rel = isTemplate ? t.relPath.slice(0, -'.hbs'.length) : t.relPath;
|
|
59
|
+
return {
|
|
60
|
+
path: rel.replaceAll('__project__', target.project),
|
|
61
|
+
contents: isTemplate ? render(t.raw, { ...target }) : t.raw,
|
|
62
|
+
};
|
|
63
|
+
});
|
|
64
|
+
const packageJson = force ? withoutScripts(current.packageJson) : current.packageJson;
|
|
65
|
+
return [
|
|
66
|
+
...files,
|
|
67
|
+
{ path: 'package.json', contents: withScripts(packageJson) },
|
|
68
|
+
{ path: '.gitignore', contents: withLine(current.gitignore, RELEASE_DIR) },
|
|
69
|
+
{ path: '.prettierignore', contents: withLine(current.prettierignore, RELEASE_DIR) },
|
|
70
|
+
{ path: 'eslint.config.ts', contents: withEslintIgnore(current.eslint) },
|
|
71
|
+
];
|
|
72
|
+
}
|
|
73
|
+
function withoutScripts(text) {
|
|
74
|
+
try {
|
|
75
|
+
const pkg = JSON.parse(text);
|
|
76
|
+
const scripts = Object.entries(pkg.scripts ?? {}).filter(([name]) => !(name in SCRIPTS));
|
|
77
|
+
return JSON.stringify({ ...pkg, scripts: Object.fromEntries(scripts) });
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
return text;
|
|
81
|
+
}
|
|
82
|
+
}
|
package/dist/domain/form-dsl.js
CHANGED
|
@@ -15,6 +15,7 @@ const FIELD_TYPES = new Set([
|
|
|
15
15
|
'textarea',
|
|
16
16
|
'markdown',
|
|
17
17
|
'number',
|
|
18
|
+
'currency',
|
|
18
19
|
'email',
|
|
19
20
|
'password',
|
|
20
21
|
'dropdown',
|
|
@@ -27,6 +28,7 @@ const FIELD_TYPES = new Set([
|
|
|
27
28
|
'rating',
|
|
28
29
|
'range',
|
|
29
30
|
'reference',
|
|
31
|
+
'tags',
|
|
30
32
|
]);
|
|
31
33
|
const KEY_PATTERN = /^[a-z][a-z0-9_]*$/;
|
|
32
34
|
const titleCase = (key) => key
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
const REMOTE_MARKER = '// bahtui:remotes';
|
|
2
|
+
/** The host serves on 4200; remotes take the ports after it. */
|
|
3
|
+
const FIRST_REMOTE_PORT = 4201;
|
|
4
|
+
/** Template paths spell the remote's name `__name__`. */
|
|
5
|
+
const NAME_TOKEN = '__name__';
|
|
6
|
+
const REMOTE_NAME = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
|
|
7
|
+
export function validateRemoteName(name) {
|
|
8
|
+
if (!REMOTE_NAME.test(name)) {
|
|
9
|
+
return [
|
|
10
|
+
`"${name}" is not a valid remote name — lowercase letters, digits and single dashes, starting with a letter.`,
|
|
11
|
+
];
|
|
12
|
+
}
|
|
13
|
+
if (name.endsWith('-web')) {
|
|
14
|
+
return [`"${name}": leave "-web" off, the app folder adds it (apps/${name}).`];
|
|
15
|
+
}
|
|
16
|
+
return [];
|
|
17
|
+
}
|
|
18
|
+
export function remoteFor(name, port) {
|
|
19
|
+
const words = name.split('-');
|
|
20
|
+
const cap = (w) => w.charAt(0).toUpperCase() + w.slice(1);
|
|
21
|
+
return {
|
|
22
|
+
name,
|
|
23
|
+
app: `${name}-web`,
|
|
24
|
+
title: words.map(cap).join(' '),
|
|
25
|
+
pascal: words.map(cap).join(''),
|
|
26
|
+
port,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
/** Where the remote wiring lives, relative to the workspace root, for a host app folder. */
|
|
30
|
+
export function wiringPaths(host) {
|
|
31
|
+
return {
|
|
32
|
+
eslint: 'eslint.config.ts',
|
|
33
|
+
knip: 'knip.ts',
|
|
34
|
+
depcruise: '.dependency-cruiser.mjs',
|
|
35
|
+
devManifest: `${host}/src/environments/environment.development.ts`,
|
|
36
|
+
manifest: `${host}/public/federation.manifest.json`,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
export class RemoteWiringError extends Error {
|
|
40
|
+
}
|
|
41
|
+
/** The port after the highest one the dev manifest already uses. */
|
|
42
|
+
export function nextRemotePort(devManifest) {
|
|
43
|
+
const ports = [...devManifest.matchAll(/localhost:(\d+)\//g)].map((m) => Number(m[1]));
|
|
44
|
+
return ports.length === 0 ? FIRST_REMOTE_PORT : Math.max(...ports) + 1;
|
|
45
|
+
}
|
|
46
|
+
/** Renders `templates/remote` for one remote, under `apps/<name>-web/`. */
|
|
47
|
+
export function planRemoteFiles(remote, project, templates, render) {
|
|
48
|
+
const data = { ...remote, project };
|
|
49
|
+
return templates.map((t) => {
|
|
50
|
+
const isTemplate = t.relPath.endsWith('.hbs');
|
|
51
|
+
const rel = (isTemplate ? t.relPath.slice(0, -'.hbs'.length) : t.relPath).replaceAll(NAME_TOKEN, remote.name);
|
|
52
|
+
return {
|
|
53
|
+
path: `apps/${remote.app}/${rel}`,
|
|
54
|
+
contents: isTemplate ? render(t.raw, data) : t.raw,
|
|
55
|
+
};
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
function insertAtMarker(text, file, lines) {
|
|
59
|
+
const at = text.split('\n').findIndex((l) => l.trim() === REMOTE_MARKER);
|
|
60
|
+
if (at === -1) {
|
|
61
|
+
throw new RemoteWiringError(`${file} has no "${REMOTE_MARKER}" line to add the remote above. Add it back, or add this by hand:\n${lines.join('\n')}`);
|
|
62
|
+
}
|
|
63
|
+
const all = text.split('\n');
|
|
64
|
+
const indent = /^\s*/.exec(all[at] ?? '')?.[0] ?? '';
|
|
65
|
+
all.splice(at, 0, ...lines.map((l) => (l === '' ? l : indent + l)));
|
|
66
|
+
return all.join('\n');
|
|
67
|
+
}
|
|
68
|
+
function addToManifest(text, file, remote) {
|
|
69
|
+
let json;
|
|
70
|
+
try {
|
|
71
|
+
json = JSON.parse(text);
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
throw new RemoteWiringError(`${file} is not valid JSON.`);
|
|
75
|
+
}
|
|
76
|
+
if (typeof json !== 'object' || json === null || Array.isArray(json)) {
|
|
77
|
+
throw new RemoteWiringError(`${file} must hold a JSON object of remotes.`);
|
|
78
|
+
}
|
|
79
|
+
const entry = {
|
|
80
|
+
entry: `/remotes/${remote.app}/remoteEntry.json`,
|
|
81
|
+
title: remote.title,
|
|
82
|
+
base: remote.name,
|
|
83
|
+
nav: [{ label: remote.title, path: remote.name }],
|
|
84
|
+
};
|
|
85
|
+
return JSON.stringify({ ...json, [remote.app]: entry }, null, 2) + '\n';
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Adds a remote to the host workspace: the eslint and knip workspaces, the dependency-cruiser
|
|
89
|
+
* app list, and both federation manifests (dev ports, deployed paths).
|
|
90
|
+
*/
|
|
91
|
+
export function wireRemote(current, host, remote) {
|
|
92
|
+
const paths = wiringPaths(host);
|
|
93
|
+
if (current.knip.includes(`'apps/${remote.app}'`)) {
|
|
94
|
+
throw new RemoteWiringError(`${paths.knip} already lists apps/${remote.app}.`);
|
|
95
|
+
}
|
|
96
|
+
return [
|
|
97
|
+
{
|
|
98
|
+
path: paths.eslint,
|
|
99
|
+
contents: insertAtMarker(current.eslint, paths.eslint, [`'apps/${remote.app}/src/**',`]),
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
path: paths.knip,
|
|
103
|
+
contents: insertAtMarker(current.knip, paths.knip, [
|
|
104
|
+
`'apps/${remote.app}': remote('${remote.name}'),`,
|
|
105
|
+
]),
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
path: paths.depcruise,
|
|
109
|
+
contents: insertAtMarker(current.depcruise, paths.depcruise, [`'${remote.app}',`]),
|
|
110
|
+
},
|
|
111
|
+
{
|
|
112
|
+
path: paths.devManifest,
|
|
113
|
+
contents: insertAtMarker(current.devManifest, paths.devManifest, [
|
|
114
|
+
`'${remote.app}': {`,
|
|
115
|
+
` entry: 'http://localhost:${remote.port}/remoteEntry.json',`,
|
|
116
|
+
` title: '${remote.title}',`,
|
|
117
|
+
` base: '${remote.name}',`,
|
|
118
|
+
` nav: [{ label: '${remote.title}', path: '${remote.name}' }],`,
|
|
119
|
+
`},`,
|
|
120
|
+
]),
|
|
121
|
+
},
|
|
122
|
+
{ path: paths.manifest, contents: addToManifest(current.manifest, paths.manifest, remote) },
|
|
123
|
+
];
|
|
124
|
+
}
|
|
125
|
+
/** Replaces or adds files by path, keeping codepoint order. */
|
|
126
|
+
function mergeFiles(base, changes) {
|
|
127
|
+
const byPath = new Map(base.map((f) => [f.path, f]));
|
|
128
|
+
for (const f of changes)
|
|
129
|
+
byPath.set(f.path, f);
|
|
130
|
+
return [...byPath.values()].sort((a, b) => (a.path < b.path ? -1 : 1));
|
|
131
|
+
}
|
|
132
|
+
/** The Angular scaffold's first remote, wired into the files `planCreate` produced. */
|
|
133
|
+
export function withFirstRemote(blueprint, project, host, remoteTemplates, render, name = 'hello') {
|
|
134
|
+
const paths = wiringPaths(host);
|
|
135
|
+
const read = (path) => {
|
|
136
|
+
const file = blueprint.files.find((f) => f.path === path);
|
|
137
|
+
if (!file)
|
|
138
|
+
throw new RemoteWiringError(`template set lacks ${path}`);
|
|
139
|
+
return file.contents;
|
|
140
|
+
};
|
|
141
|
+
const current = {
|
|
142
|
+
eslint: read(paths.eslint),
|
|
143
|
+
knip: read(paths.knip),
|
|
144
|
+
depcruise: read(paths.depcruise),
|
|
145
|
+
devManifest: read(paths.devManifest),
|
|
146
|
+
manifest: read(paths.manifest),
|
|
147
|
+
};
|
|
148
|
+
const remote = remoteFor(name, nextRemotePort(current.devManifest));
|
|
149
|
+
return {
|
|
150
|
+
...blueprint,
|
|
151
|
+
files: mergeFiles(blueprint.files, [
|
|
152
|
+
...planRemoteFiles(remote, project, remoteTemplates, render),
|
|
153
|
+
...wireRemote(current, host, remote),
|
|
154
|
+
]),
|
|
155
|
+
};
|
|
156
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bank20baht-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.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",
|
|
@@ -41,10 +41,11 @@
|
|
|
41
41
|
"build": "tsc -p tsconfig.json",
|
|
42
42
|
"test": "vitest run",
|
|
43
43
|
"test:watch": "vitest",
|
|
44
|
-
"test:coverage": "vitest run --coverage"
|
|
44
|
+
"test:coverage": "vitest run --coverage",
|
|
45
|
+
"accept:angular": "bun scripts/accept-angular.ts"
|
|
45
46
|
},
|
|
46
47
|
"dependencies": {
|
|
47
|
-
"bank20baht-validator": "^0.
|
|
48
|
+
"bank20baht-validator": "^0.7.0"
|
|
48
49
|
},
|
|
49
50
|
"devDependencies": {
|
|
50
51
|
"@types/node": "^22",
|