bank20baht-cli 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/application/create.d.ts +16 -0
- package/dist/application/create.js +35 -0
- package/dist/application/ports.d.ts +23 -0
- package/dist/application/ports.js +1 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +75 -0
- package/dist/domain/blueprint.d.ts +23 -0
- package/dist/domain/blueprint.js +6 -0
- package/dist/domain/create-plan.d.ts +24 -0
- package/dist/domain/create-plan.js +44 -0
- package/dist/domain/naming.d.ts +2 -0
- package/dist/domain/naming.js +18 -0
- package/dist/infrastructure/fs-writer.d.ts +6 -0
- package/dist/infrastructure/fs-writer.js +20 -0
- package/dist/infrastructure/hbs-renderer.d.ts +4 -0
- package/dist/infrastructure/hbs-renderer.js +7 -0
- package/dist/infrastructure/shell-runner.d.ts +4 -0
- package/dist/infrastructure/shell-runner.js +11 -0
- package/dist/infrastructure/template-source.d.ts +7 -0
- package/dist/infrastructure/template-source.js +25 -0
- package/package.json +50 -0
- package/templates/create/README.md.hbs +32 -0
- package/templates/create/_github/workflows/ci.yml +56 -0
- package/templates/create/_gitignore +5 -0
- package/templates/create/_husky/pre-commit +3 -0
- package/templates/create/_prettierignore +3 -0
- package/templates/create/_prettierrc.json +4 -0
- package/templates/create/apps/api/package.json.hbs +21 -0
- package/templates/create/apps/api/src/index.test.ts.hbs +10 -0
- package/templates/create/apps/api/src/index.ts.hbs +14 -0
- package/templates/create/apps/api/tsconfig.json +7 -0
- package/templates/create/apps/web/index.html.hbs +12 -0
- package/templates/create/apps/web/package.json.hbs +23 -0
- package/templates/create/apps/web/src/App.tsx.hbs +20 -0
- package/templates/create/apps/web/src/health.test.ts.hbs +14 -0
- package/templates/create/apps/web/src/health.ts.hbs +6 -0
- package/templates/create/apps/web/src/main.tsx.hbs +9 -0
- package/templates/create/apps/web/src/vite-env.d.ts.hbs +1 -0
- package/templates/create/apps/web/tsconfig.json +8 -0
- package/templates/create/apps/web/vite.config.ts.hbs +6 -0
- package/templates/create/bahtui.json.hbs +7 -0
- package/templates/create/eslint.config.js +30 -0
- package/templates/create/knip.json +9 -0
- package/templates/create/libs/shared/eden/index.ts.hbs +9 -0
- package/templates/create/libs/shared/eden/package.json.hbs +15 -0
- package/templates/create/libs/shared/eden/tsconfig.json +4 -0
- package/templates/create/package.json.hbs +37 -0
- package/templates/create/tsconfig.base.json +17 -0
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { type CreateOptions } from '../domain/create-plan.js';
|
|
2
|
+
import type { CommandRunner, FileWriter, Logger, Renderer, TemplateSource } from './ports.js';
|
|
3
|
+
export interface CreateDeps {
|
|
4
|
+
templates: TemplateSource;
|
|
5
|
+
renderer: Renderer;
|
|
6
|
+
writer: FileWriter;
|
|
7
|
+
runner: CommandRunner;
|
|
8
|
+
log: Logger;
|
|
9
|
+
}
|
|
10
|
+
export interface CreateInput extends CreateOptions {
|
|
11
|
+
dryRun: boolean;
|
|
12
|
+
force: boolean;
|
|
13
|
+
}
|
|
14
|
+
export declare function runCreate(input: CreateInput, deps: CreateDeps): void;
|
|
15
|
+
export declare class CreateError extends Error {
|
|
16
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { planCreate } from '../domain/create-plan.js';
|
|
2
|
+
import { validateProjectName } from '../domain/naming.js';
|
|
3
|
+
export function runCreate(input, deps) {
|
|
4
|
+
const errors = validateProjectName(input.name);
|
|
5
|
+
if (errors.length > 0) {
|
|
6
|
+
for (const e of errors)
|
|
7
|
+
deps.log.error(e);
|
|
8
|
+
throw new CreateError('Invalid project name.');
|
|
9
|
+
}
|
|
10
|
+
const blueprint = planCreate(input, deps.templates.load('create'), (raw, data) => deps.renderer.render(raw, data));
|
|
11
|
+
if (input.dryRun) {
|
|
12
|
+
deps.log.info(`dry-run — would create in ${blueprint.root}:`);
|
|
13
|
+
for (const f of blueprint.files)
|
|
14
|
+
deps.log.info(` ${f.path}`);
|
|
15
|
+
for (const c of blueprint.postCommands)
|
|
16
|
+
deps.log.info(` $ ${c.cmd} ${c.args.join(' ')}`);
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
deps.writer.ensureUsableDir(blueprint.root, input.force);
|
|
20
|
+
deps.writer.writeFiles(blueprint);
|
|
21
|
+
deps.log.step(`${blueprint.files.length} files written to ${blueprint.root}`);
|
|
22
|
+
for (const c of blueprint.postCommands) {
|
|
23
|
+
deps.log.step(c.label);
|
|
24
|
+
deps.runner.run(c.cmd, c.args, blueprint.root);
|
|
25
|
+
}
|
|
26
|
+
deps.log.info('');
|
|
27
|
+
deps.log.info(`Done. Next:`);
|
|
28
|
+
deps.log.info(` cd ${input.name}`);
|
|
29
|
+
if (!input.install)
|
|
30
|
+
deps.log.info(' bun install');
|
|
31
|
+
deps.log.info(' bun run dev # web :5173 + api :3001');
|
|
32
|
+
deps.log.info(' bun run lint && bun run test && bun run build');
|
|
33
|
+
}
|
|
34
|
+
export class CreateError extends Error {
|
|
35
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { Blueprint } from '../domain/blueprint.js';
|
|
2
|
+
import type { TemplateFile } from '../domain/create-plan.js';
|
|
3
|
+
/** Loads a template set (e.g. "create") shipped inside the CLI package. */
|
|
4
|
+
export interface TemplateSource {
|
|
5
|
+
load(set: string): TemplateFile[];
|
|
6
|
+
}
|
|
7
|
+
export interface Renderer {
|
|
8
|
+
render(raw: string, data: Record<string, unknown>): string;
|
|
9
|
+
}
|
|
10
|
+
export interface FileWriter {
|
|
11
|
+
/** Throws unless `dir` is missing/empty (or `force` is set). */
|
|
12
|
+
ensureUsableDir(dir: string, force: boolean): void;
|
|
13
|
+
writeFiles(blueprint: Blueprint): void;
|
|
14
|
+
}
|
|
15
|
+
export interface CommandRunner {
|
|
16
|
+
/** Runs to completion with inherited stdio; throws on non-zero exit. */
|
|
17
|
+
run(cmd: string, args: string[], cwd: string): void;
|
|
18
|
+
}
|
|
19
|
+
export interface Logger {
|
|
20
|
+
info(message: string): void;
|
|
21
|
+
step(message: string): void;
|
|
22
|
+
error(message: string): void;
|
|
23
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/cli.d.ts
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
import { readFileSync } from 'node:fs';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { runCreate, CreateError } from './application/create.js';
|
|
6
|
+
import { DiskTemplateSource } from './infrastructure/template-source.js';
|
|
7
|
+
import { HbsRenderer } from './infrastructure/hbs-renderer.js';
|
|
8
|
+
import { DiskFileWriter } from './infrastructure/fs-writer.js';
|
|
9
|
+
import { ShellRunner } from './infrastructure/shell-runner.js';
|
|
10
|
+
const HELP = `bahtui — bun-workspace DDD scaffolding (React + Elysia + Eden)
|
|
11
|
+
|
|
12
|
+
Usage:
|
|
13
|
+
bahtui create <name> [options] scaffold a new monorepo in ./<name>
|
|
14
|
+
|
|
15
|
+
Options:
|
|
16
|
+
--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
|
|
20
|
+
-h, --help this help
|
|
21
|
+
-v, --version print version
|
|
22
|
+
`;
|
|
23
|
+
function version() {
|
|
24
|
+
const pkg = fileURLToPath(new URL('../package.json', import.meta.url));
|
|
25
|
+
return JSON.parse(readFileSync(pkg, 'utf8')).version;
|
|
26
|
+
}
|
|
27
|
+
const log = {
|
|
28
|
+
info: (m) => console.log(m),
|
|
29
|
+
step: (m) => console.log(`• ${m}`),
|
|
30
|
+
error: (m) => console.error(`✖ ${m}`),
|
|
31
|
+
};
|
|
32
|
+
function main(argv) {
|
|
33
|
+
const args = argv.filter((a) => !a.startsWith('-'));
|
|
34
|
+
const flags = new Set(argv.filter((a) => a.startsWith('-')));
|
|
35
|
+
const [command, name] = args;
|
|
36
|
+
if (flags.has('-v') || flags.has('--version')) {
|
|
37
|
+
log.info(version());
|
|
38
|
+
return 0;
|
|
39
|
+
}
|
|
40
|
+
if (command === undefined || flags.has('-h') || flags.has('--help')) {
|
|
41
|
+
log.info(HELP);
|
|
42
|
+
return command === undefined && !flags.has('-h') && !flags.has('--help') ? 1 : 0;
|
|
43
|
+
}
|
|
44
|
+
if (command !== 'create') {
|
|
45
|
+
log.error(`Unknown command "${command}".`);
|
|
46
|
+
log.info(HELP);
|
|
47
|
+
return 1;
|
|
48
|
+
}
|
|
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
|
+
catch (err) {
|
|
71
|
+
log.error(err instanceof CreateError ? err.message : String(err));
|
|
72
|
+
return 1;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
process.exit(main(process.argv.slice(2)));
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A Blueprint is WHAT will be generated, as plain data — no fs, no side
|
|
3
|
+
* effects. Adapters (fs-writer, shell-runner) make it real; --dry-run just
|
|
4
|
+
* prints it. This split is the testing seam for every generator.
|
|
5
|
+
*/
|
|
6
|
+
export interface FileSpec {
|
|
7
|
+
/** Path relative to the blueprint root. */
|
|
8
|
+
path: string;
|
|
9
|
+
contents: string;
|
|
10
|
+
}
|
|
11
|
+
export interface PostCommand {
|
|
12
|
+
cmd: string;
|
|
13
|
+
args: string[];
|
|
14
|
+
/** Human-readable step label for progress output. */
|
|
15
|
+
label: string;
|
|
16
|
+
}
|
|
17
|
+
export interface Blueprint {
|
|
18
|
+
/** Absolute directory every FileSpec.path is relative to. */
|
|
19
|
+
root: string;
|
|
20
|
+
files: FileSpec[];
|
|
21
|
+
/** Commands to run inside `root`, in order, after files are written. */
|
|
22
|
+
postCommands: PostCommand[];
|
|
23
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { Blueprint } from './blueprint.js';
|
|
2
|
+
export interface CreateOptions {
|
|
3
|
+
name: string;
|
|
4
|
+
/** Absolute path of the directory to create the project in. */
|
|
5
|
+
targetDir: string;
|
|
6
|
+
install: boolean;
|
|
7
|
+
git: boolean;
|
|
8
|
+
}
|
|
9
|
+
/** A template file as loaded from disk by the infrastructure layer. */
|
|
10
|
+
export interface TemplateFile {
|
|
11
|
+
/** Path relative to the template set root, e.g. "apps/api/src/index.ts.hbs". */
|
|
12
|
+
relPath: string;
|
|
13
|
+
raw: string;
|
|
14
|
+
}
|
|
15
|
+
export type RenderFn = (raw: string, data: Record<string, unknown>) => string;
|
|
16
|
+
/**
|
|
17
|
+
* Template naming conventions (the whole contract between templates/ and here):
|
|
18
|
+
* - `*.hbs` → rendered with the data below, suffix stripped.
|
|
19
|
+
* anything else → copied verbatim (so files with `${{ }}` like ci.yml
|
|
20
|
+
* never meet handlebars).
|
|
21
|
+
* - leading `_` on a basename → `.` (npm pack silently drops/renames real
|
|
22
|
+
* .gitignore files inside packages, so templates ship `_gitignore`).
|
|
23
|
+
*/
|
|
24
|
+
export declare function planCreate(options: CreateOptions, templates: TemplateFile[], render: RenderFn): Blueprint;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Template naming conventions (the whole contract between templates/ and here):
|
|
3
|
+
* - `*.hbs` → rendered with the data below, suffix stripped.
|
|
4
|
+
* anything else → copied verbatim (so files with `${{ }}` like ci.yml
|
|
5
|
+
* never meet handlebars).
|
|
6
|
+
* - leading `_` on a basename → `.` (npm pack silently drops/renames real
|
|
7
|
+
* .gitignore files inside packages, so templates ship `_gitignore`).
|
|
8
|
+
*/
|
|
9
|
+
export function planCreate(options, templates, render) {
|
|
10
|
+
const data = { name: options.name };
|
|
11
|
+
const files = templates
|
|
12
|
+
.map((t) => {
|
|
13
|
+
const isTemplate = t.relPath.endsWith('.hbs');
|
|
14
|
+
const path = undot(isTemplate ? t.relPath.slice(0, -'.hbs'.length) : t.relPath);
|
|
15
|
+
const contents = isTemplate ? render(t.raw, data) : t.raw;
|
|
16
|
+
return { path, contents };
|
|
17
|
+
})
|
|
18
|
+
.sort((a, b) => (a.path < b.path ? -1 : 1)); // codepoint order — stable across locales
|
|
19
|
+
const postCommands = [];
|
|
20
|
+
if (options.git) {
|
|
21
|
+
postCommands.push({ cmd: 'git', args: ['init', '-b', 'main'], label: 'git init' });
|
|
22
|
+
}
|
|
23
|
+
if (options.install) {
|
|
24
|
+
// after git init so husky's prepare hook finds .git
|
|
25
|
+
postCommands.push({ cmd: 'bun', args: ['install'], label: 'bun install' });
|
|
26
|
+
}
|
|
27
|
+
if (options.git && options.install) {
|
|
28
|
+
// the first commit runs the freshly installed pre-commit hook — the
|
|
29
|
+
// generated repo proves its own toolchain before the user touches it
|
|
30
|
+
postCommands.push({ cmd: 'git', args: ['add', '-A'], label: 'git add' });
|
|
31
|
+
postCommands.push({
|
|
32
|
+
cmd: 'git',
|
|
33
|
+
args: ['commit', '-m', 'chore: scaffold with bank20baht-cli'],
|
|
34
|
+
label: 'git commit (pre-commit hook runs here)',
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
return { root: options.targetDir, files, postCommands };
|
|
38
|
+
}
|
|
39
|
+
function undot(relPath) {
|
|
40
|
+
return relPath
|
|
41
|
+
.split('/')
|
|
42
|
+
.map((part) => (part.startsWith('_') ? `.${part.slice(1)}` : part))
|
|
43
|
+
.join('/');
|
|
44
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/** Project-name rules: must be usable as npm scope, directory, and package name. */
|
|
2
|
+
const NAME_PATTERN = /^[a-z][a-z0-9-]*$/;
|
|
3
|
+
const RESERVED = new Set(['node_modules', 'apps', 'libs', 'src', 'dist', 'test', 'bun']);
|
|
4
|
+
export function validateProjectName(name) {
|
|
5
|
+
const errors = [];
|
|
6
|
+
if (name.length === 0)
|
|
7
|
+
errors.push('Project name is required.');
|
|
8
|
+
else if (name.length > 64)
|
|
9
|
+
errors.push('Project name must be 64 characters or fewer.');
|
|
10
|
+
if (name.length > 0 && !NAME_PATTERN.test(name)) {
|
|
11
|
+
errors.push(`"${name}" is not a valid project name — use lowercase letters, digits and dashes, starting with a letter (it becomes the @${name}/* package scope).`);
|
|
12
|
+
}
|
|
13
|
+
if (RESERVED.has(name))
|
|
14
|
+
errors.push(`"${name}" is a reserved name.`);
|
|
15
|
+
if (name.endsWith('-'))
|
|
16
|
+
errors.push('Project name must not end with a dash.');
|
|
17
|
+
return errors;
|
|
18
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { Blueprint } from '../domain/blueprint.js';
|
|
2
|
+
import type { FileWriter } from '../application/ports.js';
|
|
3
|
+
export declare class DiskFileWriter implements FileWriter {
|
|
4
|
+
ensureUsableDir(dir: string, force: boolean): void;
|
|
5
|
+
writeFiles(blueprint: Blueprint): void;
|
|
6
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readdirSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
export class DiskFileWriter {
|
|
4
|
+
ensureUsableDir(dir, force) {
|
|
5
|
+
if (!existsSync(dir))
|
|
6
|
+
return;
|
|
7
|
+
if (readdirSync(dir).length === 0)
|
|
8
|
+
return;
|
|
9
|
+
if (force)
|
|
10
|
+
return;
|
|
11
|
+
throw new Error(`${dir} already exists and is not empty (use --force to write anyway).`);
|
|
12
|
+
}
|
|
13
|
+
writeFiles(blueprint) {
|
|
14
|
+
for (const file of blueprint.files) {
|
|
15
|
+
const abs = join(blueprint.root, file.path);
|
|
16
|
+
mkdirSync(dirname(abs), { recursive: true });
|
|
17
|
+
writeFileSync(abs, file.contents);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
export class ShellRunner {
|
|
3
|
+
run(cmd, args, cwd) {
|
|
4
|
+
const result = spawnSync(cmd, args, { cwd, stdio: 'inherit' });
|
|
5
|
+
if (result.error)
|
|
6
|
+
throw result.error;
|
|
7
|
+
if (result.status !== 0) {
|
|
8
|
+
throw new Error(`${cmd} ${args.join(' ')} exited with code ${result.status}`);
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { TemplateFile } from '../domain/create-plan.js';
|
|
2
|
+
import type { TemplateSource } from '../application/ports.js';
|
|
3
|
+
export declare class DiskTemplateSource implements TemplateSource {
|
|
4
|
+
private readonly root;
|
|
5
|
+
constructor(root?: string);
|
|
6
|
+
load(set: string): TemplateFile[];
|
|
7
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { readdirSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { join, relative, sep } from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
/** templates/ sits at the package root, next to dist/ — hence ../../ from here. */
|
|
5
|
+
const TEMPLATES_ROOT = fileURLToPath(new URL('../../templates', import.meta.url));
|
|
6
|
+
export class DiskTemplateSource {
|
|
7
|
+
constructor(root = TEMPLATES_ROOT) {
|
|
8
|
+
this.root = root;
|
|
9
|
+
}
|
|
10
|
+
load(set) {
|
|
11
|
+
const setRoot = join(this.root, set);
|
|
12
|
+
const entries = readdirSync(setRoot, { recursive: true, withFileTypes: true });
|
|
13
|
+
const files = [];
|
|
14
|
+
for (const entry of entries) {
|
|
15
|
+
if (!entry.isFile())
|
|
16
|
+
continue;
|
|
17
|
+
const abs = join(entry.parentPath, entry.name);
|
|
18
|
+
files.push({
|
|
19
|
+
relPath: relative(setRoot, abs).split(sep).join('/'),
|
|
20
|
+
raw: readFileSync(abs, 'utf8'),
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
return files;
|
|
24
|
+
}
|
|
25
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "bank20baht-cli",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Scaffold bun-workspace DDD monorepos (React + Elysia + Eden) and bahtui generators.",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "Nattapong Promthong",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/bank20baht/bahtui.git",
|
|
11
|
+
"directory": "packages/cli"
|
|
12
|
+
},
|
|
13
|
+
"homepage": "https://github.com/bank20baht/bahtui#readme",
|
|
14
|
+
"bugs": {
|
|
15
|
+
"url": "https://github.com/bank20baht/bahtui/issues"
|
|
16
|
+
},
|
|
17
|
+
"keywords": [
|
|
18
|
+
"cli",
|
|
19
|
+
"scaffold",
|
|
20
|
+
"generator",
|
|
21
|
+
"ddd",
|
|
22
|
+
"bun",
|
|
23
|
+
"elysia",
|
|
24
|
+
"eden",
|
|
25
|
+
"react"
|
|
26
|
+
],
|
|
27
|
+
"publishConfig": {
|
|
28
|
+
"access": "public"
|
|
29
|
+
},
|
|
30
|
+
"bin": {
|
|
31
|
+
"bahtui": "./dist/cli.js"
|
|
32
|
+
},
|
|
33
|
+
"files": [
|
|
34
|
+
"dist",
|
|
35
|
+
"templates"
|
|
36
|
+
],
|
|
37
|
+
"scripts": {
|
|
38
|
+
"build": "tsc -p tsconfig.json",
|
|
39
|
+
"test": "vitest run",
|
|
40
|
+
"test:watch": "vitest"
|
|
41
|
+
},
|
|
42
|
+
"dependencies": {
|
|
43
|
+
"handlebars": "^4.7.8"
|
|
44
|
+
},
|
|
45
|
+
"devDependencies": {
|
|
46
|
+
"@types/node": "^22",
|
|
47
|
+
"typescript": "^5.8.3",
|
|
48
|
+
"vitest": "^3.2.4"
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# {{name}}
|
|
2
|
+
|
|
3
|
+
Bun-workspace monorepo scaffolded by [`bank20baht-cli`](https://github.com/bank20baht/bahtui) —
|
|
4
|
+
React + Elysia wired end-to-end with Eden (type-safe client, zero codegen).
|
|
5
|
+
|
|
6
|
+
## Quickstart
|
|
7
|
+
|
|
8
|
+
```sh
|
|
9
|
+
bun install
|
|
10
|
+
bun run dev # web on :5173, api on :3001
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Layout (DDD taxonomy)
|
|
14
|
+
|
|
15
|
+
```
|
|
16
|
+
apps/web React 19 + Vite
|
|
17
|
+
apps/api Elysia — exports `type App`, the Eden type anchor
|
|
18
|
+
libs/shared/eden treaty<App> — the ONE typed http client
|
|
19
|
+
libs/domain/<d>/model (later) entities + form JSON, framework-free
|
|
20
|
+
libs/domain/<d>/api (later) Elysia plugin for the domain
|
|
21
|
+
libs/domain/<d>/feature-*(later) React feature slices
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Dependency direction: features and api libs depend on `model`; `model` depends
|
|
25
|
+
on nothing; the frontend never imports server code — types travel through Eden.
|
|
26
|
+
|
|
27
|
+
## Toolchain
|
|
28
|
+
|
|
29
|
+
- `bun run lint` / `format:check` / `knip` / `deps:circular` — also run by CI
|
|
30
|
+
and by the pre-commit hook (lint-staged → knip → madge).
|
|
31
|
+
- `bun run test` — bun test in every app.
|
|
32
|
+
- `bun run build` — typecheck + vite build.
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
|
|
8
|
+
concurrency:
|
|
9
|
+
group: ci-${{ github.ref }}
|
|
10
|
+
cancel-in-progress: true
|
|
11
|
+
|
|
12
|
+
permissions:
|
|
13
|
+
contents: read
|
|
14
|
+
|
|
15
|
+
jobs:
|
|
16
|
+
lint:
|
|
17
|
+
runs-on: ubuntu-latest
|
|
18
|
+
steps:
|
|
19
|
+
- uses: actions/checkout@v5
|
|
20
|
+
- uses: oven-sh/setup-bun@v2
|
|
21
|
+
with:
|
|
22
|
+
bun-version: latest
|
|
23
|
+
- name: Install deps
|
|
24
|
+
run: bun install --frozen-lockfile
|
|
25
|
+
- name: ESLint
|
|
26
|
+
run: bun run lint
|
|
27
|
+
- name: Prettier
|
|
28
|
+
run: bun run format:check
|
|
29
|
+
- name: Knip (dead code)
|
|
30
|
+
run: bun run knip
|
|
31
|
+
- name: Circular dependencies (madge)
|
|
32
|
+
run: bun run deps:circular
|
|
33
|
+
|
|
34
|
+
test:
|
|
35
|
+
runs-on: ubuntu-latest
|
|
36
|
+
steps:
|
|
37
|
+
- uses: actions/checkout@v5
|
|
38
|
+
- uses: oven-sh/setup-bun@v2
|
|
39
|
+
with:
|
|
40
|
+
bun-version: latest
|
|
41
|
+
- name: Install deps
|
|
42
|
+
run: bun install --frozen-lockfile
|
|
43
|
+
- name: Test
|
|
44
|
+
run: bun run test
|
|
45
|
+
|
|
46
|
+
build:
|
|
47
|
+
runs-on: ubuntu-latest
|
|
48
|
+
steps:
|
|
49
|
+
- uses: actions/checkout@v5
|
|
50
|
+
- uses: oven-sh/setup-bun@v2
|
|
51
|
+
with:
|
|
52
|
+
bun-version: latest
|
|
53
|
+
- name: Install deps
|
|
54
|
+
run: bun install --frozen-lockfile
|
|
55
|
+
- name: Build
|
|
56
|
+
run: bun run build
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@{{name}}/api",
|
|
3
|
+
"private": true,
|
|
4
|
+
"type": "module",
|
|
5
|
+
"exports": {
|
|
6
|
+
".": "./src/index.ts"
|
|
7
|
+
},
|
|
8
|
+
"scripts": {
|
|
9
|
+
"dev": "bun run --watch src/index.ts",
|
|
10
|
+
"test": "bun test",
|
|
11
|
+
"build": "tsc --noEmit"
|
|
12
|
+
},
|
|
13
|
+
"dependencies": {
|
|
14
|
+
"@elysiajs/cors": "^1.3.0",
|
|
15
|
+
"elysia": "^1.3.0"
|
|
16
|
+
},
|
|
17
|
+
"devDependencies": {
|
|
18
|
+
"@types/bun": "^1.2.0",
|
|
19
|
+
"typescript": "^5.8.3"
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test';
|
|
2
|
+
import { app } from './index';
|
|
3
|
+
|
|
4
|
+
describe('GET /health', () => {
|
|
5
|
+
it('reports the service as ok', async () => {
|
|
6
|
+
const res = await app.handle(new Request('http://localhost/health'));
|
|
7
|
+
expect(res.status).toBe(200);
|
|
8
|
+
expect(await res.json()).toEqual({ status: 'ok', service: '{{name}}-api' });
|
|
9
|
+
});
|
|
10
|
+
});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { Elysia } from 'elysia';
|
|
2
|
+
import { cors } from '@elysiajs/cors';
|
|
3
|
+
|
|
4
|
+
export const app = new Elysia()
|
|
5
|
+
.use(cors())
|
|
6
|
+
.get('/health', () => ({ status: 'ok', service: '{{name}}-api' }));
|
|
7
|
+
|
|
8
|
+
/** Eden anchor — the whole API's type surface, consumed by libs/shared/eden. */
|
|
9
|
+
export type App = typeof app;
|
|
10
|
+
|
|
11
|
+
if (import.meta.main) {
|
|
12
|
+
app.listen(3001);
|
|
13
|
+
console.log(`{{name}}-api listening on http://localhost:${app.server?.port}`);
|
|
14
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<title>{{name}}</title>
|
|
7
|
+
</head>
|
|
8
|
+
<body>
|
|
9
|
+
<div id="root"></div>
|
|
10
|
+
<script type="module" src="/src/main.tsx"></script>
|
|
11
|
+
</body>
|
|
12
|
+
</html>
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@{{name}}/web",
|
|
3
|
+
"private": true,
|
|
4
|
+
"type": "module",
|
|
5
|
+
"scripts": {
|
|
6
|
+
"dev": "vite",
|
|
7
|
+
"test": "bun test",
|
|
8
|
+
"build": "tsc --noEmit && vite build"
|
|
9
|
+
},
|
|
10
|
+
"dependencies": {
|
|
11
|
+
"@{{name}}/eden": "workspace:*",
|
|
12
|
+
"react": "^19.1.0",
|
|
13
|
+
"react-dom": "^19.1.0"
|
|
14
|
+
},
|
|
15
|
+
"devDependencies": {
|
|
16
|
+
"@types/bun": "^1.2.0",
|
|
17
|
+
"@types/react": "^19.1.0",
|
|
18
|
+
"@types/react-dom": "^19.1.0",
|
|
19
|
+
"@vitejs/plugin-react": "^5.0.0",
|
|
20
|
+
"typescript": "^5.8.3",
|
|
21
|
+
"vite": "^7.0.0"
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
|
+
import { api } from '@{{name}}/eden';
|
|
3
|
+
import { describeHealth } from './health';
|
|
4
|
+
|
|
5
|
+
export default function App() {
|
|
6
|
+
const [status, setStatus] = useState('checking api…');
|
|
7
|
+
|
|
8
|
+
useEffect(() => {
|
|
9
|
+
api.health.get().then(({ data, error }) => {
|
|
10
|
+
setStatus(error ? `api unreachable (start it: bun run dev)` : describeHealth(data));
|
|
11
|
+
});
|
|
12
|
+
}, []);
|
|
13
|
+
|
|
14
|
+
return (
|
|
15
|
+
<main style={ { fontFamily: 'system-ui', padding: '2rem' } }>
|
|
16
|
+
<h1>{{name}}</h1>
|
|
17
|
+
<p>{status}</p>
|
|
18
|
+
</main>
|
|
19
|
+
);
|
|
20
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test';
|
|
2
|
+
import { describeHealth } from './health';
|
|
3
|
+
|
|
4
|
+
describe('describeHealth', () => {
|
|
5
|
+
it('formats a healthy response', () => {
|
|
6
|
+
expect(describeHealth({ status: 'ok', service: '{{name}}-api' })).toBe(
|
|
7
|
+
'{{name}}-api is ok',
|
|
8
|
+
);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it('handles a missing response', () => {
|
|
12
|
+
expect(describeHealth(null)).toBe('no response from api');
|
|
13
|
+
});
|
|
14
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
/// <reference types="vite/client" />
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import js from '@eslint/js';
|
|
2
|
+
import tseslint from 'typescript-eslint';
|
|
3
|
+
import reactHooks from 'eslint-plugin-react-hooks';
|
|
4
|
+
import prettier from 'eslint-config-prettier';
|
|
5
|
+
import globals from 'globals';
|
|
6
|
+
|
|
7
|
+
export default tseslint.config(
|
|
8
|
+
{ ignores: ['**/dist/**', '**/node_modules/**'] },
|
|
9
|
+
js.configs.recommended,
|
|
10
|
+
...tseslint.configs.recommended,
|
|
11
|
+
{
|
|
12
|
+
languageOptions: {
|
|
13
|
+
globals: { ...globals.browser, ...globals.node },
|
|
14
|
+
},
|
|
15
|
+
rules: {
|
|
16
|
+
'@typescript-eslint/no-unused-vars': [
|
|
17
|
+
'error',
|
|
18
|
+
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_' },
|
|
19
|
+
],
|
|
20
|
+
'@typescript-eslint/consistent-type-imports': 'error',
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
files: ['apps/web/**/*.{ts,tsx}'],
|
|
25
|
+
plugins: { 'react-hooks': reactHooks },
|
|
26
|
+
rules: reactHooks.configs.recommended.rules,
|
|
27
|
+
},
|
|
28
|
+
// must come last — disables rules that fight Prettier
|
|
29
|
+
prettier,
|
|
30
|
+
);
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { treaty } from '@elysiajs/eden';
|
|
2
|
+
import type { App } from '@{{name}}/api';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The ONE http client of the monorepo. Fully typed end-to-end: every route,
|
|
6
|
+
* body and response comes from apps/api's `App` type — no codegen, no drift.
|
|
7
|
+
* Point it elsewhere per environment by editing this single spot.
|
|
8
|
+
*/
|
|
9
|
+
export const api = treaty<App>('http://localhost:3001');
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@{{name}}/eden",
|
|
3
|
+
"private": true,
|
|
4
|
+
"type": "module",
|
|
5
|
+
"exports": {
|
|
6
|
+
".": "./index.ts"
|
|
7
|
+
},
|
|
8
|
+
"dependencies": {
|
|
9
|
+
"@elysiajs/eden": "^1.3.0"
|
|
10
|
+
},
|
|
11
|
+
"devDependencies": {
|
|
12
|
+
"@{{name}}/api": "workspace:*",
|
|
13
|
+
"typescript": "^5.8.3"
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "{{name}}-monorepo",
|
|
3
|
+
"private": true,
|
|
4
|
+
"type": "module",
|
|
5
|
+
"workspaces": ["apps/*", "libs/**"],
|
|
6
|
+
"scripts": {
|
|
7
|
+
"dev": "bun run --filter './apps/*' dev",
|
|
8
|
+
"build": "bun run --filter './apps/*' build",
|
|
9
|
+
"test": "bun run --filter './apps/*' test",
|
|
10
|
+
"lint": "eslint .",
|
|
11
|
+
"lint:fix": "eslint . --fix",
|
|
12
|
+
"format": "prettier --write .",
|
|
13
|
+
"format:check": "prettier --check .",
|
|
14
|
+
"knip": "knip",
|
|
15
|
+
"deps:circular": "madge --circular --extensions ts,tsx apps libs",
|
|
16
|
+
"prepare": "husky"
|
|
17
|
+
},
|
|
18
|
+
"lint-staged": {
|
|
19
|
+
"*.{ts,tsx}": ["eslint --fix", "prettier --write"],
|
|
20
|
+
"*.{css,json,js,mjs}": ["prettier --write"]
|
|
21
|
+
},
|
|
22
|
+
"trustedDependencies": ["esbuild"],
|
|
23
|
+
"devDependencies": {
|
|
24
|
+
"@eslint/js": "^10.0.1",
|
|
25
|
+
"eslint": "^10.6.0",
|
|
26
|
+
"eslint-config-prettier": "^10.1.8",
|
|
27
|
+
"eslint-plugin-react-hooks": "^7.1.1",
|
|
28
|
+
"globals": "^17.7.0",
|
|
29
|
+
"husky": "^9.1.7",
|
|
30
|
+
"knip": "^6.24.0",
|
|
31
|
+
"lint-staged": "^17.0.8",
|
|
32
|
+
"madge": "^8.0.0",
|
|
33
|
+
"prettier": "^3.9.4",
|
|
34
|
+
"typescript": "^5.8.3",
|
|
35
|
+
"typescript-eslint": "^8.62.1"
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "bundler",
|
|
6
|
+
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
|
7
|
+
"strict": true,
|
|
8
|
+
"noUncheckedIndexedAccess": true,
|
|
9
|
+
"noImplicitOverride": true,
|
|
10
|
+
"forceConsistentCasingInFileNames": true,
|
|
11
|
+
"skipLibCheck": true,
|
|
12
|
+
"isolatedModules": true,
|
|
13
|
+
"verbatimModuleSyntax": true,
|
|
14
|
+
"resolveJsonModule": true,
|
|
15
|
+
"noEmit": true
|
|
16
|
+
}
|
|
17
|
+
}
|