bank20baht-cli 0.3.0 → 0.3.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/README.md CHANGED
@@ -45,7 +45,8 @@ eslint.config.ts ESLint 10 + typescript-eslint + eslint-config-prettier
45
45
  .prettierrc no semicolons, single quotes, printWidth 100
46
46
  knip.json dead-code / unused-dependency checks
47
47
  commitlint.config.js Conventional Commits
48
- .husky/ pre-commit → lint-staged · commit-msg → commitlint · pre-push → bun run check
48
+ .husky/ pre-commit → gitleaks + lint-staged · commit-msg → commitlint · pre-push → bun run check:fast
49
+ .gitleaks.toml · .jscpd.json · .dependency-cruiser.cjs · renovate.json · .github/ (CI: lint, typecheck, knip/madge/deps, dup, test, build, gitleaks, audit, commitlint)
49
50
  scripts/dev.ts runs every apps/*/ "dev" script in parallel with prefixed output
50
51
  scripts/each.ts `bun run scripts/each.ts <script>` in every workspace that defines it
51
52
  .mcp.json, CLAUDE.md zvec_grep code-search MCP wiring for Claude Code
@@ -1,24 +1,13 @@
1
1
  import { planCreate, PRESET_TEMPLATE_SET } from '../domain/create-plan.js';
2
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(PRESET_TEMPLATE_SET[input.preset]), (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}`);
3
+ function logDryRun(blueprint, deps) {
4
+ deps.log.info(`dry-run — would create in ${blueprint.root}:`);
5
+ for (const f of blueprint.files)
6
+ deps.log.info(` ${f.path}`);
7
+ for (const c of blueprint.postCommands)
8
+ deps.log.info(` $ ${c.cmd} ${c.args.join(' ')}`);
9
+ }
10
+ function runPostCommands(blueprint, input, deps) {
22
11
  for (const c of blueprint.postCommands) {
23
12
  deps.log.step(c.label);
24
13
  try {
@@ -29,6 +18,8 @@ export function runCreate(input, deps) {
29
18
  throw new CreateError(`${err instanceof Error ? err.message : String(err)}\nproject files are written — cd ${input.name} and rerun: ${retry}`);
30
19
  }
31
20
  }
21
+ }
22
+ function logNextSteps(input, deps) {
32
23
  deps.log.info('');
33
24
  deps.log.info(`Done. Next:`);
34
25
  deps.log.info(` cd ${input.name}`);
@@ -37,6 +28,24 @@ export function runCreate(input, deps) {
37
28
  for (const line of NEXT_STEPS[input.preset])
38
29
  deps.log.info(` ${line}`);
39
30
  }
31
+ export function runCreate(input, deps) {
32
+ const errors = validateProjectName(input.name);
33
+ if (errors.length > 0) {
34
+ for (const e of errors)
35
+ deps.log.error(e);
36
+ throw new CreateError('Invalid project name.');
37
+ }
38
+ const blueprint = planCreate(input, deps.templates.load(PRESET_TEMPLATE_SET[input.preset]), (raw, data) => deps.renderer.render(raw, data));
39
+ if (input.dryRun) {
40
+ logDryRun(blueprint, deps);
41
+ return;
42
+ }
43
+ deps.writer.ensureUsableDir(blueprint.root, input.force);
44
+ deps.writer.writeFiles(blueprint);
45
+ deps.log.step(`${blueprint.files.length} files written to ${blueprint.root}`);
46
+ runPostCommands(blueprint, input, deps);
47
+ logNextSteps(input, deps);
48
+ }
40
49
  const NEXT_STEPS = {
41
50
  full: [
42
51
  'bun run dev # web :5173 + api :3001',
@@ -45,7 +54,7 @@ const NEXT_STEPS = {
45
54
  workspace: [
46
55
  'mkdir -p apps/<name> && add a package.json with dev/build/typecheck scripts',
47
56
  'bun run dev # runs every apps/*/ dev script in parallel',
48
- 'bun run check # lint + format:check + knip + typecheck',
57
+ 'bun run check # lint + format:check + typecheck + madge + deps + knip + dup + build + test',
49
58
  ],
50
59
  };
51
60
  export class CreateError extends Error {
package/dist/args.js ADDED
@@ -0,0 +1,48 @@
1
+ /** Boolean flags recognized anywhere on the command line (no value). */
2
+ export const KNOWN_FLAGS = new Set([
3
+ '-v',
4
+ '--version',
5
+ '-h',
6
+ '--help',
7
+ '--dry-run',
8
+ '--force',
9
+ '--no-install',
10
+ '--no-git',
11
+ '--skip-install',
12
+ '--skip-git',
13
+ ]);
14
+ /**
15
+ * Value-carrying flags pulled out before positional parsing. Supports both
16
+ * `--flag value` and `--flag=value`. If the token after `--flag value` looks
17
+ * like another flag (starts with `-`), the option is treated as missing
18
+ * rather than swallowing that flag as the value.
19
+ */
20
+ export function takeOption(argv, flag) {
21
+ const eqPrefix = `${flag}=`;
22
+ const eqIndex = argv.findIndex((a) => a.startsWith(eqPrefix));
23
+ if (eqIndex !== -1) {
24
+ const value = argv[eqIndex].slice(eqPrefix.length);
25
+ argv.splice(eqIndex, 1);
26
+ return value;
27
+ }
28
+ const i = argv.indexOf(flag);
29
+ if (i === -1)
30
+ return undefined;
31
+ const value = argv[i + 1];
32
+ if (value === undefined || value.startsWith('-')) {
33
+ argv.splice(i, 1);
34
+ return undefined;
35
+ }
36
+ argv.splice(i, 2);
37
+ return value;
38
+ }
39
+ export function parseArgs(rawArgv) {
40
+ const argv = [...rawArgv];
41
+ const fields = takeOption(argv, '--fields');
42
+ const frontend = takeOption(argv, '--frontend');
43
+ const backend = takeOption(argv, '--backend');
44
+ const args = argv.filter((a) => !a.startsWith('-'));
45
+ const flags = new Set(argv.filter((a) => a.startsWith('-')));
46
+ const [command, name, sub] = args;
47
+ return { fields, frontend, backend, flags, command, name, sub };
48
+ }
package/dist/cli.js CHANGED
@@ -9,11 +9,12 @@ 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
+ import { KNOWN_FLAGS, parseArgs } from './args.js';
12
13
  const HELP = `bahtui — bun-workspace DDD scaffolding (React + Elysia + Eden)
13
14
 
14
15
  Usage:
15
16
  bahtui new <name> [options] bare bun workspace in ./<name> — toolchain only
16
- (eslint, prettier, knip, husky, commitlint), no apps
17
+ (eslint, prettier, knip, husky, commitlint, gitleaks, jscpd, depcruise, CI), no apps
17
18
  bahtui create <name> [options] full monorepo in ./<name> (React + Elysia + Eden)
18
19
  bahtui init [options] write bahtui.json into an existing repo
19
20
  bahtui g model <domain> libs/domain/<d>/model package
@@ -39,56 +40,18 @@ function version() {
39
40
  return JSON.parse(readFileSync(pkg, 'utf8')).version;
40
41
  }
41
42
  const log = {
42
- info: (m) => console.log(m),
43
- step: (m) => console.log(`• ${m}`),
44
- error: (m) => console.error(`✖ ${m}`),
43
+ info: (m) => {
44
+ console.log(m);
45
+ },
46
+ step: (m) => {
47
+ console.log(`• ${m}`);
48
+ },
49
+ error: (m) => {
50
+ console.error(`✖ ${m}`);
51
+ },
45
52
  };
46
- /** Boolean flags recognized anywhere on the command line (no value). */
47
- const KNOWN_FLAGS = new Set([
48
- '-v',
49
- '--version',
50
- '-h',
51
- '--help',
52
- '--dry-run',
53
- '--force',
54
- '--no-install',
55
- '--no-git',
56
- '--skip-install',
57
- '--skip-git',
58
- ]);
59
- /**
60
- * Value-carrying flags pulled out before positional parsing. Supports both
61
- * `--flag value` and `--flag=value`. If the token after `--flag value` looks
62
- * like another flag (starts with `-`), the option is treated as missing
63
- * rather than swallowing that flag as the value.
64
- */
65
- function takeOption(argv, flag) {
66
- const eqPrefix = `${flag}=`;
67
- const eqIndex = argv.findIndex((a) => a.startsWith(eqPrefix));
68
- if (eqIndex !== -1) {
69
- const value = argv[eqIndex].slice(eqPrefix.length);
70
- argv.splice(eqIndex, 1);
71
- return value;
72
- }
73
- const i = argv.indexOf(flag);
74
- if (i === -1)
75
- return undefined;
76
- const value = argv[i + 1];
77
- if (value === undefined || value.startsWith('-')) {
78
- argv.splice(i, 1);
79
- return undefined;
80
- }
81
- argv.splice(i, 2);
82
- return value;
83
- }
84
- function main(rawArgv) {
85
- const argv = [...rawArgv];
86
- const fields = takeOption(argv, '--fields');
87
- const frontend = takeOption(argv, '--frontend');
88
- const backend = takeOption(argv, '--backend');
89
- const args = argv.filter((a) => !a.startsWith('-'));
90
- const flags = new Set(argv.filter((a) => a.startsWith('-')));
91
- const [command, name, sub] = args;
53
+ /** Handles unknown flags, --version and --help; null = carry on. */
54
+ function preflight({ flags }) {
92
55
  const unknownFlags = [...flags].filter((f) => !KNOWN_FLAGS.has(f));
93
56
  if (unknownFlags.length > 0) {
94
57
  log.error(`Unknown flag${unknownFlags.length > 1 ? 's' : ''}: ${unknownFlags.join(', ')}`);
@@ -108,67 +71,89 @@ function main(rawArgv) {
108
71
  log.info(HELP);
109
72
  return 0;
110
73
  }
111
- if (command === undefined) {
112
- log.error('No command given — see usage below.');
113
- log.error(HELP);
74
+ return null;
75
+ }
76
+ function commandCreate(a, command) {
77
+ if (!a.name) {
78
+ log.error(`${command} needs a project name: bahtui ${command} <name>`);
79
+ return 1;
80
+ }
81
+ runCreate({
82
+ name: a.name,
83
+ preset: command === 'new' ? 'workspace' : 'full',
84
+ targetDir: resolve(process.cwd(), a.name),
85
+ install: !a.flags.has('--no-install') && !a.flags.has('--skip-install'),
86
+ git: !a.flags.has('--no-git') && !a.flags.has('--skip-git'),
87
+ dryRun: a.flags.has('--dry-run'),
88
+ force: a.flags.has('--force'),
89
+ }, {
90
+ templates: new DiskTemplateSource(),
91
+ renderer: new TemplateRenderer(),
92
+ writer: new DiskFileWriter(),
93
+ runner: new ShellRunner(),
94
+ log,
95
+ });
96
+ return 0;
97
+ }
98
+ function commandInit(a, generateDeps) {
99
+ runInit({
100
+ root: process.cwd(),
101
+ frontend: a.frontend === 'none' ? null : 'react',
102
+ backend: a.backend === 'none' ? null : 'elysia',
103
+ dryRun: a.flags.has('--dry-run'),
104
+ force: a.flags.has('--force'),
105
+ }, generateDeps);
106
+ return 0;
107
+ }
108
+ function commandGenerate(a, generateDeps) {
109
+ const { name, sub } = a;
110
+ if (name !== 'model' && name !== 'form') {
111
+ log.error('Unknown generator — bahtui g model <domain> | g form <domain>/<name>.');
112
+ return 1;
113
+ }
114
+ if (!sub) {
115
+ log.error(`g ${name} needs a target: bahtui g ${name} <${name === 'model' ? 'domain' : 'domain/name'}>`);
114
116
  return 1;
115
117
  }
116
- const dryRun = flags.has('--dry-run');
117
- const force = flags.has('--force');
118
+ runGenerate({
119
+ root: process.cwd(),
120
+ kind: name,
121
+ target: sub,
122
+ fields: a.fields,
123
+ dryRun: a.flags.has('--dry-run'),
124
+ force: a.flags.has('--force'),
125
+ }, generateDeps);
126
+ return 0;
127
+ }
128
+ function dispatch(a, command) {
118
129
  const generateDeps = {
119
130
  reader: new DiskFileReader(),
120
131
  writer: new DiskFileWriter(),
121
132
  log,
122
133
  };
123
- try {
124
- if (command === 'create' || command === 'new') {
125
- if (!name) {
126
- log.error(`${command} needs a project name: bahtui ${command} <name>`);
127
- return 1;
128
- }
129
- runCreate({
130
- name,
131
- preset: command === 'new' ? 'workspace' : 'full',
132
- targetDir: resolve(process.cwd(), name),
133
- install: !flags.has('--no-install') && !flags.has('--skip-install'),
134
- git: !flags.has('--no-git') && !flags.has('--skip-git'),
135
- dryRun,
136
- force,
137
- }, {
138
- templates: new DiskTemplateSource(),
139
- renderer: new TemplateRenderer(),
140
- writer: new DiskFileWriter(),
141
- runner: new ShellRunner(),
142
- log,
143
- });
144
- return 0;
145
- }
146
- if (command === 'init') {
147
- runInit({
148
- root: process.cwd(),
149
- frontend: frontend === 'none' ? null : 'react',
150
- backend: backend === 'none' ? null : 'elysia',
151
- dryRun,
152
- force,
153
- }, generateDeps);
154
- return 0;
155
- }
156
- if (command === 'g') {
157
- if (name !== 'model' && name !== 'form') {
158
- log.error('Unknown generator — bahtui g model <domain> | g form <domain>/<name>.');
159
- return 1;
160
- }
161
- if (!sub) {
162
- log.error(`g ${name} needs a target: bahtui g ${name} <${name === 'model' ? 'domain' : 'domain/name'}>`);
163
- return 1;
164
- }
165
- runGenerate({ root: process.cwd(), kind: name, target: sub, fields, dryRun, force }, generateDeps);
166
- return 0;
167
- }
168
- log.error(`Unknown command "${command}".`);
134
+ if (command === 'create' || command === 'new')
135
+ return commandCreate(a, command);
136
+ if (command === 'init')
137
+ return commandInit(a, generateDeps);
138
+ if (command === 'g')
139
+ return commandGenerate(a, generateDeps);
140
+ log.error(`Unknown command "${command}".`);
141
+ log.error(HELP);
142
+ return 1;
143
+ }
144
+ function main(rawArgv) {
145
+ const parsed = parseArgs(rawArgv);
146
+ const early = preflight(parsed);
147
+ if (early !== null)
148
+ return early;
149
+ if (parsed.command === undefined) {
150
+ log.error('No command given — see usage below.');
169
151
  log.error(HELP);
170
152
  return 1;
171
153
  }
154
+ try {
155
+ return dispatch(parsed, parsed.command);
156
+ }
172
157
  catch (err) {
173
158
  log.error(err instanceof CreateError || err instanceof GenerateError ? err.message : String(err));
174
159
  return 1;
@@ -37,51 +37,55 @@ function parseNumberMod(name, raw, part) {
37
37
  }
38
38
  return n;
39
39
  }
40
+ function parseOptionsMod(mod, part) {
41
+ const opts = mod
42
+ .slice('options='.length)
43
+ .split('|')
44
+ .map((o) => o.trim())
45
+ .filter(Boolean);
46
+ if (opts.length === 0) {
47
+ throw new FormDslError(`options modifier needs at least one value in "${part}".`);
48
+ }
49
+ if (new Set(opts).size !== opts.length) {
50
+ throw new FormDslError(`Duplicate option keys in "${part}".`);
51
+ }
52
+ return opts.map((o) => ({ key: o, label: titleCase(o) }));
53
+ }
54
+ function applyModifier(field, mod, part) {
55
+ if (mod === 'required')
56
+ field.required = true;
57
+ else if (mod.startsWith('options='))
58
+ field.options = parseOptionsMod(mod, part);
59
+ else if (mod.startsWith('min='))
60
+ field.min = parseNumberMod('min', mod.slice(4), part);
61
+ else if (mod.startsWith('max='))
62
+ field.max = parseNumberMod('max', mod.slice(4), part);
63
+ else if (mod.startsWith('flag='))
64
+ field.flag = mod.slice(5);
65
+ else
66
+ throw new FormDslError(`Unknown modifier ${JSON.stringify(mod)} in "${part}".`);
67
+ }
68
+ function parseFieldPart(part, order) {
69
+ const [key, type, ...mods] = part.split(':').map((s) => s.trim());
70
+ if (!key || !KEY_PATTERN.test(key)) {
71
+ throw new FormDslError(`Bad field key ${JSON.stringify(key ?? '')} in "${part}".`);
72
+ }
73
+ if (!type || !FIELD_TYPES.has(type)) {
74
+ throw new FormDslError(`Unknown field type ${JSON.stringify(type ?? '')} in "${part}" — one of: ${[...FIELD_TYPES].join(', ')}.`);
75
+ }
76
+ const field = { order, type, key, label: titleCase(key) };
77
+ for (const mod of mods)
78
+ applyModifier(field, mod, part);
79
+ return field;
80
+ }
40
81
  export function parseFieldsDsl(dsl) {
41
- const fields = [];
42
82
  const parts = dsl
43
83
  .split(',')
44
84
  .map((p) => p.trim())
45
85
  .filter(Boolean);
46
86
  if (parts.length === 0)
47
87
  throw new FormDslError('No fields given — pass --fields "key:type,…".');
48
- for (const [i, part] of parts.entries()) {
49
- const [key, type, ...mods] = part.split(':').map((s) => s.trim());
50
- if (!key || !KEY_PATTERN.test(key)) {
51
- throw new FormDslError(`Bad field key ${JSON.stringify(key ?? '')} in "${part}".`);
52
- }
53
- if (!type || !FIELD_TYPES.has(type)) {
54
- throw new FormDslError(`Unknown field type ${JSON.stringify(type ?? '')} in "${part}" — one of: ${[...FIELD_TYPES].join(', ')}.`);
55
- }
56
- const field = { order: i + 1, type, key, label: titleCase(key) };
57
- for (const mod of mods) {
58
- if (mod === 'required')
59
- field.required = true;
60
- else if (mod.startsWith('options=')) {
61
- const opts = mod
62
- .slice('options='.length)
63
- .split('|')
64
- .map((o) => o.trim())
65
- .filter(Boolean);
66
- if (opts.length === 0) {
67
- throw new FormDslError(`options modifier needs at least one value in "${part}".`);
68
- }
69
- if (new Set(opts).size !== opts.length) {
70
- throw new FormDslError(`Duplicate option keys in "${part}".`);
71
- }
72
- field.options = opts.map((o) => ({ key: o, label: titleCase(o) }));
73
- }
74
- else if (mod.startsWith('min='))
75
- field.min = parseNumberMod('min', mod.slice(4), part);
76
- else if (mod.startsWith('max='))
77
- field.max = parseNumberMod('max', mod.slice(4), part);
78
- else if (mod.startsWith('flag='))
79
- field.flag = mod.slice(5);
80
- else
81
- throw new FormDslError(`Unknown modifier ${JSON.stringify(mod)} in "${part}".`);
82
- }
83
- fields.push(field);
84
- }
88
+ const fields = parts.map((part, i) => parseFieldPart(part, i + 1));
85
89
  const keys = fields.map((f) => f.key);
86
90
  if (new Set(keys).size !== keys.length) {
87
91
  throw new FormDslError('Field keys must be unique.');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bank20baht-cli",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
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",
@@ -40,10 +40,11 @@
40
40
  "scripts": {
41
41
  "build": "tsc -p tsconfig.json",
42
42
  "test": "vitest run",
43
- "test:watch": "vitest"
43
+ "test:watch": "vitest",
44
+ "test:coverage": "vitest run --coverage"
44
45
  },
45
46
  "dependencies": {
46
- "bank20baht-validator": "^0.2.0"
47
+ "bank20baht-validator": "^0.3.0"
47
48
  },
48
49
  "devDependencies": {
49
50
  "@types/node": "^22",
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Architecture boundaries, checked with `bun run deps`. Add a rule per layer
3
+ * rule you want enforced (features may not import each other, shared/ knows no
4
+ * feature, ...). Every rule is `error`: a violation is a decision, not a warning.
5
+ */
6
+ const TESTS = '\\.(test|spec)\\.tsx?$'
7
+
8
+ /** @type {import('dependency-cruiser').IConfiguration} */
9
+ module.exports = {
10
+ forbidden: [
11
+ {
12
+ name: 'no-circular',
13
+ severity: 'error',
14
+ from: {},
15
+ to: { circular: true },
16
+ },
17
+ {
18
+ name: 'production-not-tests',
19
+ severity: 'error',
20
+ from: { path: '^(apps|packages)/[^/]+/src/', pathNot: TESTS },
21
+ to: { path: TESTS },
22
+ },
23
+ ],
24
+ options: {
25
+ doNotFollow: { path: 'node_modules' },
26
+ tsPreCompilationDeps: true,
27
+ exclude: { path: ['/dist/', '\\.gen\\.ts$'] },
28
+ },
29
+ }
@@ -0,0 +1,15 @@
1
+ name: Setup
2
+ description: Bun at the version pinned in package.json, with the install cache restored, and dependencies installed.
3
+ runs:
4
+ using: composite
5
+ steps:
6
+ - uses: oven-sh/setup-bun@v2
7
+ with:
8
+ bun-version-file: package.json
9
+ - uses: actions/cache@v4
10
+ with:
11
+ path: ~/.bun/install/cache
12
+ key: bun-${{ runner.os }}-${{ hashFiles('bun.lock') }}
13
+ restore-keys: bun-${{ runner.os }}-
14
+ - run: bun install --frozen-lockfile
15
+ shell: bash
@@ -0,0 +1,122 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+ workflow_dispatch:
8
+
9
+ permissions:
10
+ contents: read
11
+
12
+ concurrency:
13
+ group: ci-${{ github.ref }}
14
+ cancel-in-progress: true
15
+
16
+ jobs:
17
+ lint:
18
+ name: lint · prettier
19
+ runs-on: ubuntu-latest
20
+ timeout-minutes: 10
21
+ steps:
22
+ - uses: actions/checkout@v4
23
+ - uses: ./.github/actions/setup
24
+ - run: bun run lint
25
+ - run: bun run format:check
26
+
27
+ typecheck:
28
+ name: typecheck
29
+ runs-on: ubuntu-latest
30
+ timeout-minutes: 10
31
+ steps:
32
+ - uses: actions/checkout@v4
33
+ - uses: ./.github/actions/setup
34
+ - run: bun run typecheck
35
+
36
+ structure:
37
+ name: knip · madge · boundaries
38
+ runs-on: ubuntu-latest
39
+ timeout-minutes: 10
40
+ steps:
41
+ - uses: actions/checkout@v4
42
+ - uses: ./.github/actions/setup
43
+ - run: bun run knip
44
+ - run: bun run madge
45
+ - run: bun run deps
46
+
47
+ dup:
48
+ name: duplication
49
+ runs-on: ubuntu-latest
50
+ timeout-minutes: 10
51
+ steps:
52
+ - uses: actions/checkout@v4
53
+ - uses: ./.github/actions/setup
54
+ - run: bun run dup -- --reporters console,json --output jscpd-report
55
+ - uses: actions/upload-artifact@v4
56
+ if: failure()
57
+ with:
58
+ name: jscpd-report
59
+ path: jscpd-report
60
+ retention-days: 7
61
+
62
+ test:
63
+ name: test
64
+ runs-on: ubuntu-latest
65
+ timeout-minutes: 10
66
+ steps:
67
+ - uses: actions/checkout@v4
68
+ - uses: ./.github/actions/setup
69
+ - run: bun run test
70
+ - uses: actions/upload-artifact@v4
71
+ if: failure()
72
+ with:
73
+ name: coverage
74
+ path: '**/coverage'
75
+ retention-days: 7
76
+
77
+ build:
78
+ name: build
79
+ runs-on: ubuntu-latest
80
+ timeout-minutes: 10
81
+ steps:
82
+ - uses: actions/checkout@v4
83
+ - uses: ./.github/actions/setup
84
+ - run: bun run build
85
+
86
+ gitleaks:
87
+ name: gitleaks
88
+ runs-on: ubuntu-latest
89
+ timeout-minutes: 5
90
+ steps:
91
+ - uses: actions/checkout@v4
92
+ with:
93
+ fetch-depth: 0
94
+ - uses: gitleaks/gitleaks-action@v2
95
+ env:
96
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
97
+
98
+ audit:
99
+ name: dependency audit
100
+ runs-on: ubuntu-latest
101
+ timeout-minutes: 5
102
+ steps:
103
+ - uses: actions/checkout@v4
104
+ - uses: ./.github/actions/setup
105
+ # The npm advisory endpoint answers 503 now and then; retry before giving
106
+ # up, and never block a merge on it - a red audit is a warning to look at.
107
+ - run: |
108
+ for i in 1 2 3; do bun run audit && exit 0; echo "audit attempt $i failed"; sleep 15; done
109
+ exit 1
110
+ continue-on-error: true
111
+
112
+ commitlint:
113
+ name: commitlint
114
+ if: github.event_name == 'pull_request'
115
+ runs-on: ubuntu-latest
116
+ timeout-minutes: 5
117
+ steps:
118
+ - uses: actions/checkout@v4
119
+ with:
120
+ fetch-depth: 0
121
+ - uses: ./.github/actions/setup
122
+ - run: bunx commitlint --from ${{ github.event.pull_request.base.sha }} --to ${{ github.event.pull_request.head.sha }} --verbose
@@ -0,0 +1,6 @@
1
+ # Default gitleaks rule set. Add an [[allowlists]] entry here (with a reason)
2
+ # rather than a `# gitleaks:allow` comment when a finding is a false positive.
3
+ title = "gitleaks config"
4
+
5
+ [extend]
6
+ useDefault = true
@@ -1 +1,8 @@
1
+ # Secret scan of what is about to be committed. CI runs the same scan over the
2
+ # whole history, so a machine without gitleaks only loses the early warning.
3
+ if command -v gitleaks >/dev/null 2>&1; then
4
+ gitleaks git --pre-commit --staged --redact --no-banner || exit 1
5
+ else
6
+ echo "gitleaks is not installed - skipping the local secret scan (CI still runs it). brew install gitleaks" >&2
7
+ fi
1
8
  bunx lint-staged
@@ -1 +1 @@
1
- bun run check
1
+ bun run check:fast
@@ -0,0 +1,20 @@
1
+ {
2
+ "path": ["apps", "packages", "scripts"],
3
+ "format": ["typescript", "markup", "css"],
4
+ "ignore": [
5
+ "**/node_modules/**",
6
+ "**/dist/**",
7
+ "**/coverage/**",
8
+ "**/test-results/**",
9
+ "**/playwright-report/**",
10
+ "**/*.spec.ts",
11
+ "**/*.test.ts",
12
+ "**/*.d.ts",
13
+ "**/*.gen.ts"
14
+ ],
15
+ "minTokens": 50,
16
+ "minLines": 5,
17
+ "gitignore": true,
18
+ "reporters": ["console"],
19
+ "threshold": 5
20
+ }
@@ -1,3 +1,4 @@
1
1
  node_modules
2
2
  dist
3
3
  bun.lock
4
+ coverage
@@ -0,0 +1,64 @@
1
+ import js from '@eslint/js'
2
+ import prettier from 'eslint-config-prettier'
3
+ import sonarjs from 'eslint-plugin-sonarjs'
4
+ import { defineConfig } from 'eslint/config'
5
+ import tseslint from 'typescript-eslint'
6
+
7
+ export default defineConfig([
8
+ {
9
+ ignores: [
10
+ '**/dist/**',
11
+ '**/node_modules/**',
12
+ '**/*.gen.ts',
13
+ '**/.angular/**',
14
+ '**/coverage/**',
15
+ '**/playwright-report/**',
16
+ '**/test-results/**',
17
+ ],
18
+ },
19
+ // Plain JS config files: no type information available.
20
+ {
21
+ files: ['**/*.{js,mjs,cjs}'],
22
+ extends: [js.configs.recommended],
23
+ },
24
+ // TypeScript, type-aware. Every .ts file must be covered by a tsconfig.
25
+ {
26
+ files: ['**/*.{ts,tsx}'],
27
+ extends: [
28
+ js.configs.recommended,
29
+ ...tseslint.configs.strictTypeChecked,
30
+ ...tseslint.configs.stylisticTypeChecked,
31
+ ],
32
+ languageOptions: {
33
+ parserOptions: { projectService: true, tsconfigRootDir: import.meta.dirname },
34
+ },
35
+ rules: {
36
+ '@typescript-eslint/no-unused-vars': [
37
+ 'error',
38
+ { argsIgnorePattern: '^_', varsIgnorePattern: '^_' },
39
+ ],
40
+ '@typescript-eslint/consistent-type-imports': 'error',
41
+ '@typescript-eslint/switch-exhaustiveness-check': 'error',
42
+ '@typescript-eslint/consistent-type-definitions': ['error', 'type'],
43
+ '@typescript-eslint/restrict-template-expressions': ['error', { allowNumber: true }],
44
+ },
45
+ },
46
+ // Duplication and complexity (jscpd covers whole-block clones, see .jscpd.json).
47
+ {
48
+ files: ['**/*.{ts,tsx}'],
49
+ plugins: { sonarjs },
50
+ rules: {
51
+ 'sonarjs/no-identical-functions': 'error',
52
+ 'sonarjs/no-identical-expressions': 'error',
53
+ 'sonarjs/cognitive-complexity': ['error', 15],
54
+ },
55
+ },
56
+ // Repeated literals are ordinary in tests, so the rule only guards production code.
57
+ {
58
+ files: ['**/*.{ts,tsx}'],
59
+ ignores: ['**/*.test.ts', '**/*.spec.ts'],
60
+ plugins: { sonarjs },
61
+ rules: { 'sonarjs/no-duplicate-string': ['error', { threshold: 4 }] },
62
+ },
63
+ prettier,
64
+ ])
@@ -5,5 +5,6 @@
5
5
  "entry": ["scripts/*.ts"],
6
6
  "project": ["scripts/**/*.ts"]
7
7
  }
8
- }
8
+ },
9
+ "ignoreBinaries": ["gitleaks"]
9
10
  }
@@ -2,7 +2,10 @@
2
2
  "name": "{{name}}",
3
3
  "private": true,
4
4
  "type": "module",
5
- "workspaces": ["apps/*", "packages/*"],
5
+ "workspaces": [
6
+ "apps/*",
7
+ "packages/*"
8
+ ],
6
9
  "scripts": {
7
10
  "dev": "bun run scripts/dev.ts",
8
11
  "build": "bun run scripts/each.ts build",
@@ -12,24 +15,41 @@
12
15
  "format": "prettier --write .",
13
16
  "format:check": "prettier --check .",
14
17
  "knip": "knip",
15
- "check": "bun run lint && bun run format:check && bun run knip && bun run typecheck",
18
+ "madge": "madge --circular --extensions ts,tsx apps packages scripts",
19
+ "deps": "depcruise apps packages scripts --config .dependency-cruiser.cjs",
20
+ "dup": "jscpd",
21
+ "audit": "bun audit --audit-level=high",
22
+ "secrets": "gitleaks git --redact --no-banner",
23
+ "test": "bun run scripts/each.ts test:coverage",
24
+ "check:fast": "bun run lint && bun run format:check && bun run typecheck && bun run madge && bun run deps",
25
+ "check": "bun run check:fast && bun run knip && bun run dup && bun run build && bun run test",
16
26
  "prepare": "husky"
17
27
  },
18
28
  "lint-staged": {
19
- "*.{ts,tsx}": ["eslint --fix", "prettier --write"],
20
- "*.{json,md,html,css}": ["prettier --write"]
29
+ "*.{ts,tsx}": [
30
+ "eslint --fix",
31
+ "prettier --write"
32
+ ],
33
+ "*.{json,md,html,css}": [
34
+ "prettier --write"
35
+ ]
21
36
  },
37
+ "packageManager": "bun@1.3.14",
22
38
  "devDependencies": {
23
39
  "@commitlint/cli": "^21.2.2",
24
40
  "@commitlint/config-conventional": "^21.2.2",
25
41
  "@eslint/js": "^10.0.1",
26
42
  "@types/bun": "^1.3.0",
43
+ "dependency-cruiser": "^18.3.1",
27
44
  "eslint": "^10.10.0",
28
45
  "eslint-config-prettier": "^10.1.8",
46
+ "eslint-plugin-sonarjs": "^4.2.1",
29
47
  "husky": "^9.1.7",
30
48
  "jiti": "^2.6.1",
49
+ "jscpd": "^5.3.0",
31
50
  "knip": "^6.35.1",
32
51
  "lint-staged": "^17.5.1",
52
+ "madge": "^8.0.0",
33
53
  "prettier": "^3.9.6",
34
54
  "typescript": "^5.9.3",
35
55
  "typescript-eslint": "^8.70.0"
@@ -0,0 +1,23 @@
1
+ {
2
+ "$schema": "https://docs.renovatebot.com/renovate-schema.json",
3
+ "extends": ["config:recommended", ":semanticCommitTypeAll(chore)"],
4
+ "schedule": ["before 6am on monday"],
5
+ "prConcurrentLimit": 4,
6
+ "packageRules": [
7
+ {
8
+ "description": "Everything not matched below: one weekly PR.",
9
+ "matchPackageNames": ["*"],
10
+ "groupName": "others"
11
+ },
12
+ {
13
+ "description": "Dev tooling: linters, formatters, test runners, type packages.",
14
+ "matchDepTypes": ["devDependencies"],
15
+ "groupName": "dev-deps"
16
+ },
17
+ {
18
+ "description": "GitHub Actions move together.",
19
+ "matchManagers": ["github-actions"],
20
+ "groupName": "github-actions"
21
+ }
22
+ ]
23
+ }
@@ -39,8 +39,8 @@ const procs = targets.map(({ name, cwd, color }) => {
39
39
  if (buf) console.log(prefix(buf))
40
40
  }
41
41
 
42
- pump(proc.stdout)
43
- pump(proc.stderr)
42
+ void pump(proc.stdout)
43
+ void pump(proc.stderr)
44
44
  return proc
45
45
  })
46
46
 
@@ -20,6 +20,10 @@ if (targets.length === 0) {
20
20
 
21
21
  for (const { name, cwd } of targets) {
22
22
  console.log(`\x1b[36m[${name}]\x1b[0m bun run ${script}`)
23
- const proc = Bun.spawnSync(['bun', 'run', script], { cwd, env: process.env, stdio: ['inherit', 'inherit', 'inherit'] })
24
- if (proc.exitCode !== 0) process.exit(proc.exitCode ?? 1)
23
+ const proc = Bun.spawnSync(['bun', 'run', script], {
24
+ cwd,
25
+ env: process.env,
26
+ stdio: ['inherit', 'inherit', 'inherit'],
27
+ })
28
+ if (proc.exitCode !== 0) process.exit(proc.exitCode)
25
29
  }
@@ -1,7 +1,7 @@
1
1
  import { existsSync, readdirSync, readFileSync } from 'node:fs'
2
2
  import { join } from 'node:path'
3
3
 
4
- export interface Workspace {
4
+ export type Workspace = {
5
5
  name: string
6
6
  cwd: string
7
7
  }
@@ -3,5 +3,5 @@
3
3
  "compilerOptions": {
4
4
  "types": ["bun"]
5
5
  },
6
- "include": ["scripts"]
6
+ "include": ["scripts", "eslint.config.ts"]
7
7
  }
@@ -1,24 +0,0 @@
1
- import js from '@eslint/js'
2
- import prettier from 'eslint-config-prettier'
3
- import tseslint from 'typescript-eslint'
4
-
5
- export default tseslint.config(
6
- {
7
- ignores: ['**/dist/**', '**/node_modules/**', '**/*.gen.ts', '**/.angular/**'],
8
- },
9
- js.configs.recommended,
10
- ...tseslint.configs.recommended,
11
- {
12
- languageOptions: {
13
- parserOptions: { tsconfigRootDir: import.meta.dirname },
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
- prettier,
24
- )