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.
Files changed (87) hide show
  1. package/README.md +61 -2
  2. package/dist/application/create.js +13 -2
  3. package/dist/application/generate.js +95 -0
  4. package/dist/args.js +1 -0
  5. package/dist/cli.js +41 -4
  6. package/dist/domain/create-plan.js +21 -3
  7. package/dist/domain/deploy-plan.js +82 -0
  8. package/dist/domain/form-dsl.js +2 -0
  9. package/dist/domain/remote-plan.js +156 -0
  10. package/package.json +4 -3
  11. package/templates/angular/README.md.hbs +46 -0
  12. package/templates/angular/_dependency-cruiser.mjs +51 -0
  13. package/templates/angular/apps/server/package.json +30 -0
  14. package/templates/angular/apps/server/src/app.ts +14 -0
  15. package/templates/angular/apps/server/src/index.ts +8 -0
  16. package/templates/angular/apps/server/test/app.test.ts +12 -0
  17. package/templates/angular/apps/server/tsconfig.json +7 -0
  18. package/templates/angular/apps/server/tsconfig.types.json +12 -0
  19. package/templates/angular/apps/shell/_editorconfig +17 -0
  20. package/templates/angular/apps/shell/angular.json +115 -0
  21. package/templates/angular/apps/shell/federation.config.mjs +44 -0
  22. package/templates/angular/apps/shell/package.json +42 -0
  23. package/templates/angular/apps/shell/public/federation.manifest.json +1 -0
  24. package/templates/angular/apps/shell/src/app/app.config.ts +20 -0
  25. package/templates/angular/apps/shell/src/app/app.css +4 -0
  26. package/templates/angular/apps/shell/src/app/app.html.hbs +16 -0
  27. package/templates/angular/apps/shell/src/app/app.routes.ts.hbs +39 -0
  28. package/templates/angular/apps/shell/src/app/app.ts +57 -0
  29. package/templates/angular/apps/shell/src/app/core/federation/load-remote.ts +31 -0
  30. package/templates/angular/apps/shell/src/app/core/federation/manifest.spec.ts +46 -0
  31. package/templates/angular/apps/shell/src/app/core/federation/manifest.ts +37 -0
  32. package/templates/angular/apps/shell/src/app/core/federation/remote-loader.ts +20 -0
  33. package/templates/angular/apps/shell/src/app/core/federation/timeout.ts +17 -0
  34. package/templates/angular/apps/shell/src/app/modules/home/home.component.ts.hbs +46 -0
  35. package/templates/angular/apps/shell/src/app/modules/unavailable/unavailable.component.ts +39 -0
  36. package/templates/angular/apps/shell/src/bootstrap.ts +25 -0
  37. package/templates/angular/apps/shell/src/environments/environment.development.ts +10 -0
  38. package/templates/angular/apps/shell/src/environments/environment.ts +10 -0
  39. package/templates/angular/apps/shell/src/index.html.hbs +12 -0
  40. package/templates/angular/apps/shell/src/main.ts +43 -0
  41. package/templates/angular/apps/shell/src/styles.css +19 -0
  42. package/templates/angular/apps/shell/tsconfig.app.json +10 -0
  43. package/templates/angular/apps/shell/tsconfig.federation.json +4 -0
  44. package/templates/angular/apps/shell/tsconfig.json.hbs +39 -0
  45. package/templates/angular/apps/shell/tsconfig.spec.json +9 -0
  46. package/templates/angular/bahtui.json +6 -0
  47. package/templates/angular/eslint.config.ts.hbs +28 -0
  48. package/templates/angular/knip.ts +27 -0
  49. package/templates/angular/package.json.hbs +52 -0
  50. package/templates/angular/packages/kit/package.json.hbs +27 -0
  51. package/templates/angular/packages/kit/src/api.ts +21 -0
  52. package/templates/angular/packages/kit/src/errors.ts +28 -0
  53. package/templates/angular/packages/kit/src/index.ts +2 -0
  54. package/templates/angular/packages/kit/test/errors.test.ts +28 -0
  55. package/templates/angular/packages/kit/tsconfig.json +9 -0
  56. package/templates/create/README.md.hbs +1 -1
  57. package/templates/create/_gitignore +1 -0
  58. package/templates/create/apps/web/index.html.hbs +8 -0
  59. package/templates/create/apps/web/package.json.hbs +1 -0
  60. package/templates/create/apps/web/src/App.tsx.hbs +18 -7
  61. package/templates/create/apps/web/src/app.css +27 -0
  62. package/templates/create/apps/web/src/health.test.ts.hbs +18 -4
  63. package/templates/create/apps/web/src/health.ts.hbs +8 -0
  64. package/templates/create/apps/web/src/main.tsx.hbs +12 -1
  65. package/templates/create/package.json.hbs +15 -5
  66. package/templates/deploy/deploy/__project__.service.hbs +23 -0
  67. package/templates/deploy/deploy/env.example.hbs +4 -0
  68. package/templates/deploy/deploy/install.sh.hbs +58 -0
  69. package/templates/deploy/deploy/nginx.conf.hbs +67 -0
  70. package/templates/deploy/scripts/deploy.ts.hbs +33 -0
  71. package/templates/deploy/scripts/preview.ts.hbs +57 -0
  72. package/templates/deploy/scripts/release.ts.hbs +48 -0
  73. package/templates/new/_gitignore +3 -0
  74. package/templates/new/_prettierignore +1 -0
  75. package/templates/new/package.json.hbs +2 -2
  76. package/templates/remote/_editorconfig +17 -0
  77. package/templates/remote/angular.json.hbs +95 -0
  78. package/templates/remote/federation.config.mjs.hbs +48 -0
  79. package/templates/remote/package.json.hbs +37 -0
  80. package/templates/remote/src/app/__name__.routes.ts.hbs +12 -0
  81. package/templates/remote/src/app/modules/__name__/__name__.component.ts.hbs +37 -0
  82. package/templates/remote/src/app/services/__name__.service.ts.hbs +14 -0
  83. package/templates/remote/src/index.html.hbs +11 -0
  84. package/templates/remote/src/main.ts.hbs +3 -0
  85. package/templates/remote/tsconfig.app.json +10 -0
  86. package/templates/remote/tsconfig.federation.json +4 -0
  87. 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, PRESET_TEMPLATE_SET } from '../domain/create-plan.js';
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 blueprint = planCreate(input, deps.templates.load(PRESET_TEMPLATE_SET[input.preset]), (raw, data) => deps.renderer.render(raw, data));
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
@@ -10,6 +10,7 @@ export const KNOWN_FLAGS = new Set([
10
10
  '--no-git',
11
11
  '--skip-install',
12
12
  '--skip-git',
13
+ '--angular',
13
14
  ]);
14
15
  /**
15
16
  * Value-carrying flags pulled out before positional parsing. Supports both
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 === 'new' ? 'workspace' : 'full',
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
- export const PRESET_TEMPLATE_SET = {
2
- full: 'create',
3
- workspace: 'new',
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
+ }
@@ -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.5.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.6.0"
48
+ "bank20baht-validator": "^0.7.0"
48
49
  },
49
50
  "devDependencies": {
50
51
  "@types/node": "^22",