@forinda/kickjs-cli 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/dist/cli.js +1264 -0
- package/dist/cli.js.map +1 -0
- package/dist/index.d.ts +115 -0
- package/dist/index.js +1065 -0
- package/dist/index.js.map +1 -0
- package/package.json +47 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,1264 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __name = (target, value) => __defProp(target, "name", { value, configurable: true });
|
|
4
|
+
|
|
5
|
+
// src/cli.ts
|
|
6
|
+
import { Command } from "commander";
|
|
7
|
+
|
|
8
|
+
// src/commands/init.ts
|
|
9
|
+
import { resolve } from "path";
|
|
10
|
+
|
|
11
|
+
// src/generators/project.ts
|
|
12
|
+
import { join } from "path";
|
|
13
|
+
|
|
14
|
+
// src/utils/fs.ts
|
|
15
|
+
import { writeFile, mkdir, access, readFile } from "fs/promises";
|
|
16
|
+
import { dirname } from "path";
|
|
17
|
+
async function writeFileSafe(filePath, content) {
|
|
18
|
+
await mkdir(dirname(filePath), {
|
|
19
|
+
recursive: true
|
|
20
|
+
});
|
|
21
|
+
await writeFile(filePath, content, "utf-8");
|
|
22
|
+
}
|
|
23
|
+
__name(writeFileSafe, "writeFileSafe");
|
|
24
|
+
async function fileExists(filePath) {
|
|
25
|
+
try {
|
|
26
|
+
await access(filePath);
|
|
27
|
+
return true;
|
|
28
|
+
} catch {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
__name(fileExists, "fileExists");
|
|
33
|
+
|
|
34
|
+
// src/generators/project.ts
|
|
35
|
+
async function initProject(options) {
|
|
36
|
+
const { name, directory, packageManager = "pnpm" } = options;
|
|
37
|
+
const dir = directory;
|
|
38
|
+
console.log(`
|
|
39
|
+
Creating KickJS project: ${name}
|
|
40
|
+
`);
|
|
41
|
+
await writeFileSafe(join(dir, "package.json"), JSON.stringify({
|
|
42
|
+
name,
|
|
43
|
+
version: "0.1.0",
|
|
44
|
+
type: "module",
|
|
45
|
+
scripts: {
|
|
46
|
+
dev: "kick dev",
|
|
47
|
+
"dev:debug": "kick dev:debug",
|
|
48
|
+
build: "kick build",
|
|
49
|
+
start: "kick start",
|
|
50
|
+
test: "vitest run",
|
|
51
|
+
"test:watch": "vitest",
|
|
52
|
+
typecheck: "tsc --noEmit",
|
|
53
|
+
lint: "eslint src/",
|
|
54
|
+
format: "prettier --write src/"
|
|
55
|
+
},
|
|
56
|
+
dependencies: {
|
|
57
|
+
"@forinda/kickjs-core": "^0.1.0",
|
|
58
|
+
"@forinda/kickjs-http": "^0.1.0",
|
|
59
|
+
"@forinda/kickjs-config": "^0.1.0",
|
|
60
|
+
"@forinda/kickjs-swagger": "^0.1.0",
|
|
61
|
+
express: "^5.1.0",
|
|
62
|
+
"reflect-metadata": "^0.2.2",
|
|
63
|
+
zod: "^4.3.6",
|
|
64
|
+
pino: "^10.3.1",
|
|
65
|
+
"pino-pretty": "^13.1.3"
|
|
66
|
+
},
|
|
67
|
+
devDependencies: {
|
|
68
|
+
"@forinda/kickjs-cli": "^0.1.0",
|
|
69
|
+
"@swc/core": "^1.7.28",
|
|
70
|
+
"@types/express": "^5.0.6",
|
|
71
|
+
"@types/node": "^24.5.2",
|
|
72
|
+
"unplugin-swc": "^1.5.9",
|
|
73
|
+
vite: "^7.3.1",
|
|
74
|
+
"vite-node": "^5.3.0",
|
|
75
|
+
vitest: "^3.2.4",
|
|
76
|
+
typescript: "^5.9.2",
|
|
77
|
+
prettier: "^3.8.1"
|
|
78
|
+
}
|
|
79
|
+
}, null, 2));
|
|
80
|
+
await writeFileSafe(join(dir, "vite.config.ts"), `import { defineConfig } from 'vite'
|
|
81
|
+
import { resolve } from 'path'
|
|
82
|
+
import swc from 'unplugin-swc'
|
|
83
|
+
|
|
84
|
+
export default defineConfig({
|
|
85
|
+
plugins: [swc.vite()],
|
|
86
|
+
resolve: {
|
|
87
|
+
alias: {
|
|
88
|
+
'@': resolve(__dirname, 'src'),
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
server: {
|
|
92
|
+
watch: { usePolling: false },
|
|
93
|
+
hmr: true,
|
|
94
|
+
},
|
|
95
|
+
build: {
|
|
96
|
+
target: 'node20',
|
|
97
|
+
ssr: true,
|
|
98
|
+
outDir: 'dist',
|
|
99
|
+
sourcemap: true,
|
|
100
|
+
rollupOptions: {
|
|
101
|
+
input: resolve(__dirname, 'src/index.ts'),
|
|
102
|
+
output: { format: 'esm' },
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
})
|
|
106
|
+
`);
|
|
107
|
+
await writeFileSafe(join(dir, "tsconfig.json"), JSON.stringify({
|
|
108
|
+
compilerOptions: {
|
|
109
|
+
target: "ES2022",
|
|
110
|
+
module: "ESNext",
|
|
111
|
+
moduleResolution: "bundler",
|
|
112
|
+
lib: [
|
|
113
|
+
"ES2022"
|
|
114
|
+
],
|
|
115
|
+
types: [
|
|
116
|
+
"node"
|
|
117
|
+
],
|
|
118
|
+
strict: true,
|
|
119
|
+
esModuleInterop: true,
|
|
120
|
+
skipLibCheck: true,
|
|
121
|
+
sourceMap: true,
|
|
122
|
+
declaration: true,
|
|
123
|
+
experimentalDecorators: true,
|
|
124
|
+
emitDecoratorMetadata: true,
|
|
125
|
+
outDir: "dist",
|
|
126
|
+
rootDir: "src",
|
|
127
|
+
paths: {
|
|
128
|
+
"@/*": [
|
|
129
|
+
"./src/*"
|
|
130
|
+
]
|
|
131
|
+
}
|
|
132
|
+
},
|
|
133
|
+
include: [
|
|
134
|
+
"src"
|
|
135
|
+
]
|
|
136
|
+
}, null, 2));
|
|
137
|
+
await writeFileSafe(join(dir, ".prettierrc"), JSON.stringify({
|
|
138
|
+
semi: false,
|
|
139
|
+
singleQuote: true,
|
|
140
|
+
trailingComma: "all",
|
|
141
|
+
printWidth: 100,
|
|
142
|
+
tabWidth: 2
|
|
143
|
+
}, null, 2));
|
|
144
|
+
await writeFileSafe(join(dir, ".gitignore"), `node_modules/
|
|
145
|
+
dist/
|
|
146
|
+
.env
|
|
147
|
+
coverage/
|
|
148
|
+
.DS_Store
|
|
149
|
+
*.tsbuildinfo
|
|
150
|
+
`);
|
|
151
|
+
await writeFileSafe(join(dir, ".env"), `PORT=3000
|
|
152
|
+
NODE_ENV=development
|
|
153
|
+
`);
|
|
154
|
+
await writeFileSafe(join(dir, ".env.example"), `PORT=3000
|
|
155
|
+
NODE_ENV=development
|
|
156
|
+
`);
|
|
157
|
+
await writeFileSafe(join(dir, "src/index.ts"), `import 'reflect-metadata'
|
|
158
|
+
import { bootstrap } from '@forinda/kickjs-http'
|
|
159
|
+
import { SwaggerAdapter } from '@forinda/kickjs-swagger'
|
|
160
|
+
import { modules } from './modules'
|
|
161
|
+
|
|
162
|
+
bootstrap({
|
|
163
|
+
modules,
|
|
164
|
+
adapters: [
|
|
165
|
+
new SwaggerAdapter({
|
|
166
|
+
info: { title: '${name}', version: '0.1.0' },
|
|
167
|
+
}),
|
|
168
|
+
],
|
|
169
|
+
})
|
|
170
|
+
`);
|
|
171
|
+
await writeFileSafe(join(dir, "src/modules/index.ts"), `import type { AppModuleClass } from '@forinda/kickjs-core'
|
|
172
|
+
|
|
173
|
+
export const modules: AppModuleClass[] = []
|
|
174
|
+
`);
|
|
175
|
+
await writeFileSafe(join(dir, "vitest.config.ts"), `import { defineConfig } from 'vitest/config'
|
|
176
|
+
import swc from 'unplugin-swc'
|
|
177
|
+
|
|
178
|
+
export default defineConfig({
|
|
179
|
+
plugins: [swc.vite()],
|
|
180
|
+
test: {
|
|
181
|
+
globals: true,
|
|
182
|
+
environment: 'node',
|
|
183
|
+
include: ['src/**/*.test.ts'],
|
|
184
|
+
},
|
|
185
|
+
})
|
|
186
|
+
`);
|
|
187
|
+
console.log(" Project scaffolded successfully!");
|
|
188
|
+
console.log();
|
|
189
|
+
console.log(" Next steps:");
|
|
190
|
+
console.log(` cd ${name}`);
|
|
191
|
+
console.log(` ${packageManager} install`);
|
|
192
|
+
console.log(` kick g module user`);
|
|
193
|
+
console.log(` kick dev`);
|
|
194
|
+
console.log();
|
|
195
|
+
console.log(" Commands:");
|
|
196
|
+
console.log(" kick dev Start dev server with Vite HMR");
|
|
197
|
+
console.log(" kick build Production build via Vite");
|
|
198
|
+
console.log(" kick start Run production build");
|
|
199
|
+
console.log(" kick g module X Generate a DDD module");
|
|
200
|
+
console.log();
|
|
201
|
+
}
|
|
202
|
+
__name(initProject, "initProject");
|
|
203
|
+
|
|
204
|
+
// src/commands/init.ts
|
|
205
|
+
function registerInitCommand(program) {
|
|
206
|
+
program.command("new <name>").alias("init").description("Create a new KickJS project").option("-d, --directory <dir>", "Target directory (defaults to project name)").option("--pm <manager>", "Package manager: pnpm | npm | yarn", "pnpm").action(async (name, opts) => {
|
|
207
|
+
const directory = resolve(opts.directory || name);
|
|
208
|
+
await initProject({
|
|
209
|
+
name,
|
|
210
|
+
directory,
|
|
211
|
+
packageManager: opts.pm
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
__name(registerInitCommand, "registerInitCommand");
|
|
216
|
+
|
|
217
|
+
// src/commands/generate.ts
|
|
218
|
+
import { resolve as resolve2 } from "path";
|
|
219
|
+
|
|
220
|
+
// src/generators/module.ts
|
|
221
|
+
import { join as join2 } from "path";
|
|
222
|
+
|
|
223
|
+
// src/utils/naming.ts
|
|
224
|
+
function toPascalCase(name) {
|
|
225
|
+
return name.replace(/[-_\s]+(.)?/g, (_, c) => c ? c.toUpperCase() : "").replace(/^(.)/, (c) => c.toUpperCase());
|
|
226
|
+
}
|
|
227
|
+
__name(toPascalCase, "toPascalCase");
|
|
228
|
+
function toCamelCase(name) {
|
|
229
|
+
const pascal = toPascalCase(name);
|
|
230
|
+
return pascal.charAt(0).toLowerCase() + pascal.slice(1);
|
|
231
|
+
}
|
|
232
|
+
__name(toCamelCase, "toCamelCase");
|
|
233
|
+
function toKebabCase(name) {
|
|
234
|
+
return name.replace(/([a-z])([A-Z])/g, "$1-$2").replace(/[\s_]+/g, "-").toLowerCase();
|
|
235
|
+
}
|
|
236
|
+
__name(toKebabCase, "toKebabCase");
|
|
237
|
+
function pluralize(name) {
|
|
238
|
+
if (name.endsWith("s")) return name;
|
|
239
|
+
if (name.endsWith("x") || name.endsWith("z")) return name + "es";
|
|
240
|
+
if (name.endsWith("sh") || name.endsWith("ch")) return name + "es";
|
|
241
|
+
if (name.endsWith("y") && !/[aeiou]y$/.test(name)) return name.slice(0, -1) + "ies";
|
|
242
|
+
return name + "s";
|
|
243
|
+
}
|
|
244
|
+
__name(pluralize, "pluralize");
|
|
245
|
+
function pluralizePascal(name) {
|
|
246
|
+
if (name.endsWith("s")) return name;
|
|
247
|
+
if (name.endsWith("x") || name.endsWith("z")) return name + "es";
|
|
248
|
+
if (name.endsWith("sh") || name.endsWith("ch")) return name + "es";
|
|
249
|
+
if (name.endsWith("y") && !/[aeiou]y$/i.test(name)) return name.slice(0, -1) + "ies";
|
|
250
|
+
return name + "s";
|
|
251
|
+
}
|
|
252
|
+
__name(pluralizePascal, "pluralizePascal");
|
|
253
|
+
|
|
254
|
+
// src/generators/module.ts
|
|
255
|
+
import { readFile as readFile2, writeFile as writeFile2 } from "fs/promises";
|
|
256
|
+
async function generateModule(options) {
|
|
257
|
+
const { name, modulesDir, noEntity, noTests, repo = "inmemory", minimal } = options;
|
|
258
|
+
const kebab = toKebabCase(name);
|
|
259
|
+
const pascal = toPascalCase(name);
|
|
260
|
+
const camel = toCamelCase(name);
|
|
261
|
+
const plural = pluralize(kebab);
|
|
262
|
+
const pluralPascal = pluralizePascal(pascal);
|
|
263
|
+
const moduleDir = join2(modulesDir, plural);
|
|
264
|
+
const files = [];
|
|
265
|
+
const write = /* @__PURE__ */ __name(async (relativePath, content) => {
|
|
266
|
+
const fullPath = join2(moduleDir, relativePath);
|
|
267
|
+
await writeFileSafe(fullPath, content);
|
|
268
|
+
files.push(fullPath);
|
|
269
|
+
}, "write");
|
|
270
|
+
await write("index.ts", `/**
|
|
271
|
+
* ${pascal} Module
|
|
272
|
+
*
|
|
273
|
+
* Self-contained feature module following Domain-Driven Design (DDD).
|
|
274
|
+
* Registers dependencies in the DI container and declares HTTP routes.
|
|
275
|
+
*
|
|
276
|
+
* Structure:
|
|
277
|
+
* presentation/ \u2014 HTTP controllers (entry points)
|
|
278
|
+
* application/ \u2014 Use cases (orchestration) and DTOs (validation)
|
|
279
|
+
* domain/ \u2014 Entities, value objects, repository interfaces, domain services
|
|
280
|
+
* infrastructure/ \u2014 Repository implementations (in-memory, Drizzle, Prisma, etc.)
|
|
281
|
+
*/
|
|
282
|
+
import { Container, type AppModule, type ModuleRoutes } from '@forinda/kickjs-core'
|
|
283
|
+
import { buildRoutes } from '@forinda/kickjs-http'
|
|
284
|
+
import { ${pascal.toUpperCase()}_REPOSITORY } from './domain/repositories/${kebab}.repository'
|
|
285
|
+
import { ${repo === "inmemory" ? `InMemory${pascal}Repository` : `Drizzle${pascal}Repository`} } from './infrastructure/repositories/${repo === "inmemory" ? `in-memory-${kebab}` : `drizzle-${kebab}`}.repository'
|
|
286
|
+
import { ${pascal}Controller } from './presentation/${kebab}.controller'
|
|
287
|
+
|
|
288
|
+
// Eagerly load decorated classes so @Service()/@Repository() decorators register in the DI container
|
|
289
|
+
import.meta.glob(
|
|
290
|
+
['./domain/services/**/*.ts', './application/use-cases/**/*.ts', '!./**/*.test.ts'],
|
|
291
|
+
{ eager: true },
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
export class ${pascal}Module implements AppModule {
|
|
295
|
+
/**
|
|
296
|
+
* Register module dependencies in the DI container.
|
|
297
|
+
* Bind repository interface tokens to their implementations here.
|
|
298
|
+
* To swap implementations (e.g. in-memory -> Drizzle), change the factory target.
|
|
299
|
+
*/
|
|
300
|
+
register(container: Container): void {
|
|
301
|
+
container.registerFactory(${pascal.toUpperCase()}_REPOSITORY, () =>
|
|
302
|
+
container.resolve(${repo === "inmemory" ? `InMemory${pascal}Repository` : `Drizzle${pascal}Repository`}),
|
|
303
|
+
)
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Declare HTTP routes for this module.
|
|
308
|
+
* The path is prefixed with the global apiPrefix and version (e.g. /api/v1/${plural}).
|
|
309
|
+
* Passing 'controller' enables automatic OpenAPI spec generation via SwaggerAdapter.
|
|
310
|
+
*/
|
|
311
|
+
routes(): ModuleRoutes {
|
|
312
|
+
return {
|
|
313
|
+
path: '/${plural}',
|
|
314
|
+
router: buildRoutes(${pascal}Controller),
|
|
315
|
+
controller: ${pascal}Controller,
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
`);
|
|
320
|
+
await write(`presentation/${kebab}.controller.ts`, `/**
|
|
321
|
+
* ${pascal} Controller
|
|
322
|
+
*
|
|
323
|
+
* Presentation layer \u2014 handles HTTP requests and delegates to use cases.
|
|
324
|
+
* Each method receives a RequestContext with typed body, params, and query.
|
|
325
|
+
*
|
|
326
|
+
* Decorators:
|
|
327
|
+
* @Controller(path?) \u2014 registers this class as an HTTP controller
|
|
328
|
+
* @Get/@Post/@Put/@Delete(path?, validation?) \u2014 defines routes with optional Zod validation
|
|
329
|
+
* @Autowired() \u2014 injects dependencies lazily from the DI container
|
|
330
|
+
* @Middleware(...handlers) \u2014 attach middleware at class or method level
|
|
331
|
+
*
|
|
332
|
+
* Add Swagger decorators (@ApiTags, @ApiOperation, @ApiResponse) from @forinda/kickjs-swagger
|
|
333
|
+
* for automatic OpenAPI documentation.
|
|
334
|
+
*/
|
|
335
|
+
import { Controller, Get, Post, Put, Delete, Autowired } from '@forinda/kickjs-core'
|
|
336
|
+
import { RequestContext } from '@forinda/kickjs-http'
|
|
337
|
+
import { Create${pascal}UseCase } from '../application/use-cases/create-${kebab}.use-case'
|
|
338
|
+
import { Get${pascal}UseCase } from '../application/use-cases/get-${kebab}.use-case'
|
|
339
|
+
import { List${pluralPascal}UseCase } from '../application/use-cases/list-${plural}.use-case'
|
|
340
|
+
import { Update${pascal}UseCase } from '../application/use-cases/update-${kebab}.use-case'
|
|
341
|
+
import { Delete${pascal}UseCase } from '../application/use-cases/delete-${kebab}.use-case'
|
|
342
|
+
import { create${pascal}Schema } from '../application/dtos/create-${kebab}.dto'
|
|
343
|
+
import { update${pascal}Schema } from '../application/dtos/update-${kebab}.dto'
|
|
344
|
+
|
|
345
|
+
@Controller()
|
|
346
|
+
export class ${pascal}Controller {
|
|
347
|
+
@Autowired() private create${pascal}UseCase!: Create${pascal}UseCase
|
|
348
|
+
@Autowired() private get${pascal}UseCase!: Get${pascal}UseCase
|
|
349
|
+
@Autowired() private list${pluralPascal}UseCase!: List${pluralPascal}UseCase
|
|
350
|
+
@Autowired() private update${pascal}UseCase!: Update${pascal}UseCase
|
|
351
|
+
@Autowired() private delete${pascal}UseCase!: Delete${pascal}UseCase
|
|
352
|
+
|
|
353
|
+
@Post('/', { body: create${pascal}Schema })
|
|
354
|
+
async create(ctx: RequestContext) {
|
|
355
|
+
const result = await this.create${pascal}UseCase.execute(ctx.body)
|
|
356
|
+
ctx.created(result)
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
@Get('/')
|
|
360
|
+
async list(ctx: RequestContext) {
|
|
361
|
+
const result = await this.list${pluralPascal}UseCase.execute()
|
|
362
|
+
ctx.json(result)
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
@Get('/:id')
|
|
366
|
+
async getById(ctx: RequestContext) {
|
|
367
|
+
const result = await this.get${pascal}UseCase.execute(ctx.params.id)
|
|
368
|
+
if (!result) return ctx.notFound('${pascal} not found')
|
|
369
|
+
ctx.json(result)
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
@Put('/:id', { body: update${pascal}Schema })
|
|
373
|
+
async update(ctx: RequestContext) {
|
|
374
|
+
const result = await this.update${pascal}UseCase.execute(ctx.params.id, ctx.body)
|
|
375
|
+
ctx.json(result)
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
@Delete('/:id')
|
|
379
|
+
async remove(ctx: RequestContext) {
|
|
380
|
+
await this.delete${pascal}UseCase.execute(ctx.params.id)
|
|
381
|
+
ctx.noContent()
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
`);
|
|
385
|
+
await write(`application/dtos/create-${kebab}.dto.ts`, `import { z } from 'zod'
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* Create ${pascal} DTO \u2014 Zod schema for validating POST request bodies.
|
|
389
|
+
* This schema is passed to @Post('/', { body: create${pascal}Schema }) for automatic validation.
|
|
390
|
+
* It also generates OpenAPI request body docs when SwaggerAdapter is used.
|
|
391
|
+
*
|
|
392
|
+
* Add more fields as needed. Supported Zod types:
|
|
393
|
+
* z.string(), z.number(), z.boolean(), z.enum([...]),
|
|
394
|
+
* z.array(), z.object(), .optional(), .default(), .transform()
|
|
395
|
+
*/
|
|
396
|
+
export const create${pascal}Schema = z.object({
|
|
397
|
+
name: z.string().min(1, 'Name is required').max(200),
|
|
398
|
+
})
|
|
399
|
+
|
|
400
|
+
export type Create${pascal}DTO = z.infer<typeof create${pascal}Schema>
|
|
401
|
+
`);
|
|
402
|
+
await write(`application/dtos/update-${kebab}.dto.ts`, `import { z } from 'zod'
|
|
403
|
+
|
|
404
|
+
export const update${pascal}Schema = z.object({
|
|
405
|
+
name: z.string().min(1).max(200).optional(),
|
|
406
|
+
})
|
|
407
|
+
|
|
408
|
+
export type Update${pascal}DTO = z.infer<typeof update${pascal}Schema>
|
|
409
|
+
`);
|
|
410
|
+
await write(`application/dtos/${kebab}-response.dto.ts`, `export interface ${pascal}ResponseDTO {
|
|
411
|
+
id: string
|
|
412
|
+
name: string
|
|
413
|
+
createdAt: string
|
|
414
|
+
updatedAt: string
|
|
415
|
+
}
|
|
416
|
+
`);
|
|
417
|
+
const useCases = [
|
|
418
|
+
{
|
|
419
|
+
file: `create-${kebab}.use-case.ts`,
|
|
420
|
+
content: `/**
|
|
421
|
+
* Create ${pascal} Use Case
|
|
422
|
+
*
|
|
423
|
+
* Application layer \u2014 orchestrates a single business operation.
|
|
424
|
+
* Use cases are thin: validate input (via DTO), call domain/repo, return response.
|
|
425
|
+
* Keep business rules in the domain service, not here.
|
|
426
|
+
*/
|
|
427
|
+
import { Service, Inject } from '@forinda/kickjs-core'
|
|
428
|
+
import { ${pascal.toUpperCase()}_REPOSITORY, type I${pascal}Repository } from '../../domain/repositories/${kebab}.repository'
|
|
429
|
+
import type { Create${pascal}DTO } from '../dtos/create-${kebab}.dto'
|
|
430
|
+
import type { ${pascal}ResponseDTO } from '../dtos/${kebab}-response.dto'
|
|
431
|
+
|
|
432
|
+
@Service()
|
|
433
|
+
export class Create${pascal}UseCase {
|
|
434
|
+
constructor(
|
|
435
|
+
@Inject(${pascal.toUpperCase()}_REPOSITORY) private readonly repo: I${pascal}Repository,
|
|
436
|
+
) {}
|
|
437
|
+
|
|
438
|
+
async execute(dto: Create${pascal}DTO): Promise<${pascal}ResponseDTO> {
|
|
439
|
+
return this.repo.create(dto)
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
`
|
|
443
|
+
},
|
|
444
|
+
{
|
|
445
|
+
file: `get-${kebab}.use-case.ts`,
|
|
446
|
+
content: `import { Service, Inject } from '@forinda/kickjs-core'
|
|
447
|
+
import { ${pascal.toUpperCase()}_REPOSITORY, type I${pascal}Repository } from '../../domain/repositories/${kebab}.repository'
|
|
448
|
+
import type { ${pascal}ResponseDTO } from '../dtos/${kebab}-response.dto'
|
|
449
|
+
|
|
450
|
+
@Service()
|
|
451
|
+
export class Get${pascal}UseCase {
|
|
452
|
+
constructor(
|
|
453
|
+
@Inject(${pascal.toUpperCase()}_REPOSITORY) private readonly repo: I${pascal}Repository,
|
|
454
|
+
) {}
|
|
455
|
+
|
|
456
|
+
async execute(id: string): Promise<${pascal}ResponseDTO | null> {
|
|
457
|
+
return this.repo.findById(id)
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
`
|
|
461
|
+
},
|
|
462
|
+
{
|
|
463
|
+
file: `list-${plural}.use-case.ts`,
|
|
464
|
+
content: `import { Service, Inject } from '@forinda/kickjs-core'
|
|
465
|
+
import { ${pascal.toUpperCase()}_REPOSITORY, type I${pascal}Repository } from '../../domain/repositories/${kebab}.repository'
|
|
466
|
+
import type { ${pascal}ResponseDTO } from '../dtos/${kebab}-response.dto'
|
|
467
|
+
|
|
468
|
+
@Service()
|
|
469
|
+
export class List${pluralPascal}UseCase {
|
|
470
|
+
constructor(
|
|
471
|
+
@Inject(${pascal.toUpperCase()}_REPOSITORY) private readonly repo: I${pascal}Repository,
|
|
472
|
+
) {}
|
|
473
|
+
|
|
474
|
+
async execute(): Promise<${pascal}ResponseDTO[]> {
|
|
475
|
+
return this.repo.findAll()
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
`
|
|
479
|
+
},
|
|
480
|
+
{
|
|
481
|
+
file: `update-${kebab}.use-case.ts`,
|
|
482
|
+
content: `import { Service, Inject } from '@forinda/kickjs-core'
|
|
483
|
+
import { ${pascal.toUpperCase()}_REPOSITORY, type I${pascal}Repository } from '../../domain/repositories/${kebab}.repository'
|
|
484
|
+
import type { Update${pascal}DTO } from '../dtos/update-${kebab}.dto'
|
|
485
|
+
import type { ${pascal}ResponseDTO } from '../dtos/${kebab}-response.dto'
|
|
486
|
+
|
|
487
|
+
@Service()
|
|
488
|
+
export class Update${pascal}UseCase {
|
|
489
|
+
constructor(
|
|
490
|
+
@Inject(${pascal.toUpperCase()}_REPOSITORY) private readonly repo: I${pascal}Repository,
|
|
491
|
+
) {}
|
|
492
|
+
|
|
493
|
+
async execute(id: string, dto: Update${pascal}DTO): Promise<${pascal}ResponseDTO> {
|
|
494
|
+
return this.repo.update(id, dto)
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
`
|
|
498
|
+
},
|
|
499
|
+
{
|
|
500
|
+
file: `delete-${kebab}.use-case.ts`,
|
|
501
|
+
content: `import { Service, Inject } from '@forinda/kickjs-core'
|
|
502
|
+
import { ${pascal.toUpperCase()}_REPOSITORY, type I${pascal}Repository } from '../../domain/repositories/${kebab}.repository'
|
|
503
|
+
|
|
504
|
+
@Service()
|
|
505
|
+
export class Delete${pascal}UseCase {
|
|
506
|
+
constructor(
|
|
507
|
+
@Inject(${pascal.toUpperCase()}_REPOSITORY) private readonly repo: I${pascal}Repository,
|
|
508
|
+
) {}
|
|
509
|
+
|
|
510
|
+
async execute(id: string): Promise<void> {
|
|
511
|
+
await this.repo.delete(id)
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
`
|
|
515
|
+
}
|
|
516
|
+
];
|
|
517
|
+
for (const uc of useCases) {
|
|
518
|
+
await write(`application/use-cases/${uc.file}`, uc.content);
|
|
519
|
+
}
|
|
520
|
+
await write(`domain/repositories/${kebab}.repository.ts`, `/**
|
|
521
|
+
* ${pascal} Repository Interface
|
|
522
|
+
*
|
|
523
|
+
* Domain layer \u2014 defines the contract for data access.
|
|
524
|
+
* The interface lives in the domain layer; implementations live in infrastructure.
|
|
525
|
+
* This inversion of dependencies keeps the domain pure and testable.
|
|
526
|
+
*
|
|
527
|
+
* To swap implementations (e.g. in-memory -> Drizzle -> Prisma),
|
|
528
|
+
* change the factory in the module's register() method.
|
|
529
|
+
*/
|
|
530
|
+
import type { ${pascal}ResponseDTO } from '../../application/dtos/${kebab}-response.dto'
|
|
531
|
+
import type { Create${pascal}DTO } from '../../application/dtos/create-${kebab}.dto'
|
|
532
|
+
import type { Update${pascal}DTO } from '../../application/dtos/update-${kebab}.dto'
|
|
533
|
+
|
|
534
|
+
export interface I${pascal}Repository {
|
|
535
|
+
findById(id: string): Promise<${pascal}ResponseDTO | null>
|
|
536
|
+
findAll(): Promise<${pascal}ResponseDTO[]>
|
|
537
|
+
create(dto: Create${pascal}DTO): Promise<${pascal}ResponseDTO>
|
|
538
|
+
update(id: string, dto: Update${pascal}DTO): Promise<${pascal}ResponseDTO>
|
|
539
|
+
delete(id: string): Promise<void>
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
export const ${pascal.toUpperCase()}_REPOSITORY = Symbol('I${pascal}Repository')
|
|
543
|
+
`);
|
|
544
|
+
await write(`domain/services/${kebab}-domain.service.ts`, `/**
|
|
545
|
+
* ${pascal} Domain Service
|
|
546
|
+
*
|
|
547
|
+
* Domain layer \u2014 contains business rules that don't belong to a single entity.
|
|
548
|
+
* Use this for cross-entity logic, validation rules, and domain invariants.
|
|
549
|
+
* Keep it free of HTTP/framework concerns.
|
|
550
|
+
*/
|
|
551
|
+
import { Service, Inject, HttpException } from '@forinda/kickjs-core'
|
|
552
|
+
import { ${pascal.toUpperCase()}_REPOSITORY, type I${pascal}Repository } from '../repositories/${kebab}.repository'
|
|
553
|
+
|
|
554
|
+
@Service()
|
|
555
|
+
export class ${pascal}DomainService {
|
|
556
|
+
constructor(
|
|
557
|
+
@Inject(${pascal.toUpperCase()}_REPOSITORY) private readonly repo: I${pascal}Repository,
|
|
558
|
+
) {}
|
|
559
|
+
|
|
560
|
+
async ensureExists(id: string): Promise<void> {
|
|
561
|
+
const entity = await this.repo.findById(id)
|
|
562
|
+
if (!entity) {
|
|
563
|
+
throw HttpException.notFound('${pascal} not found')
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
`);
|
|
568
|
+
if (repo === "inmemory") {
|
|
569
|
+
await write(`infrastructure/repositories/in-memory-${kebab}.repository.ts`, `/**
|
|
570
|
+
* In-Memory ${pascal} Repository
|
|
571
|
+
*
|
|
572
|
+
* Infrastructure layer \u2014 implements the repository interface using a Map.
|
|
573
|
+
* Useful for prototyping and testing. Replace with a database implementation
|
|
574
|
+
* (Drizzle, Prisma, etc.) for production use.
|
|
575
|
+
*
|
|
576
|
+
* @Repository() registers this class in the DI container as a singleton.
|
|
577
|
+
*/
|
|
578
|
+
import { randomUUID } from 'node:crypto'
|
|
579
|
+
import { Repository, HttpException } from '@forinda/kickjs-core'
|
|
580
|
+
import type { I${pascal}Repository } from '../../domain/repositories/${kebab}.repository'
|
|
581
|
+
import type { ${pascal}ResponseDTO } from '../../application/dtos/${kebab}-response.dto'
|
|
582
|
+
import type { Create${pascal}DTO } from '../../application/dtos/create-${kebab}.dto'
|
|
583
|
+
import type { Update${pascal}DTO } from '../../application/dtos/update-${kebab}.dto'
|
|
584
|
+
|
|
585
|
+
@Repository()
|
|
586
|
+
export class InMemory${pascal}Repository implements I${pascal}Repository {
|
|
587
|
+
private store = new Map<string, ${pascal}ResponseDTO>()
|
|
588
|
+
|
|
589
|
+
async findById(id: string): Promise<${pascal}ResponseDTO | null> {
|
|
590
|
+
return this.store.get(id) ?? null
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
async findAll(): Promise<${pascal}ResponseDTO[]> {
|
|
594
|
+
return Array.from(this.store.values())
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
async create(dto: Create${pascal}DTO): Promise<${pascal}ResponseDTO> {
|
|
598
|
+
const now = new Date().toISOString()
|
|
599
|
+
const entity: ${pascal}ResponseDTO = {
|
|
600
|
+
id: randomUUID(),
|
|
601
|
+
name: dto.name,
|
|
602
|
+
createdAt: now,
|
|
603
|
+
updatedAt: now,
|
|
604
|
+
}
|
|
605
|
+
this.store.set(entity.id, entity)
|
|
606
|
+
return entity
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
async update(id: string, dto: Update${pascal}DTO): Promise<${pascal}ResponseDTO> {
|
|
610
|
+
const existing = this.store.get(id)
|
|
611
|
+
if (!existing) throw HttpException.notFound('${pascal} not found')
|
|
612
|
+
const updated = { ...existing, ...dto, updatedAt: new Date().toISOString() }
|
|
613
|
+
this.store.set(id, updated)
|
|
614
|
+
return updated
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
async delete(id: string): Promise<void> {
|
|
618
|
+
if (!this.store.has(id)) throw HttpException.notFound('${pascal} not found')
|
|
619
|
+
this.store.delete(id)
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
`);
|
|
623
|
+
}
|
|
624
|
+
if (!noEntity && !minimal) {
|
|
625
|
+
await write(`domain/entities/${kebab}.entity.ts`, `/**
|
|
626
|
+
* ${pascal} Entity
|
|
627
|
+
*
|
|
628
|
+
* Domain layer \u2014 the core business object.
|
|
629
|
+
* Uses a private constructor with static factory methods (create, reconstitute)
|
|
630
|
+
* to enforce invariants. Properties are accessed via getters to maintain encapsulation.
|
|
631
|
+
*
|
|
632
|
+
* Patterns used:
|
|
633
|
+
* - Private constructor: prevents direct instantiation
|
|
634
|
+
* - create(): factory for new entities (generates ID, sets timestamps)
|
|
635
|
+
* - reconstitute(): factory for rebuilding from persistence (no side effects)
|
|
636
|
+
* - changeName(): mutation method that enforces business rules
|
|
637
|
+
*/
|
|
638
|
+
import { ${pascal}Id } from '../value-objects/${kebab}-id.vo'
|
|
639
|
+
|
|
640
|
+
interface ${pascal}Props {
|
|
641
|
+
id: ${pascal}Id
|
|
642
|
+
name: string
|
|
643
|
+
createdAt: Date
|
|
644
|
+
updatedAt: Date
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
export class ${pascal} {
|
|
648
|
+
private constructor(private props: ${pascal}Props) {}
|
|
649
|
+
|
|
650
|
+
static create(params: { name: string }): ${pascal} {
|
|
651
|
+
const now = new Date()
|
|
652
|
+
return new ${pascal}({
|
|
653
|
+
id: ${pascal}Id.create(),
|
|
654
|
+
name: params.name,
|
|
655
|
+
createdAt: now,
|
|
656
|
+
updatedAt: now,
|
|
657
|
+
})
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
static reconstitute(props: ${pascal}Props): ${pascal} {
|
|
661
|
+
return new ${pascal}(props)
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
get id(): ${pascal}Id {
|
|
665
|
+
return this.props.id
|
|
666
|
+
}
|
|
667
|
+
get name(): string {
|
|
668
|
+
return this.props.name
|
|
669
|
+
}
|
|
670
|
+
get createdAt(): Date {
|
|
671
|
+
return this.props.createdAt
|
|
672
|
+
}
|
|
673
|
+
get updatedAt(): Date {
|
|
674
|
+
return this.props.updatedAt
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
changeName(name: string): void {
|
|
678
|
+
if (!name || name.trim().length === 0) {
|
|
679
|
+
throw new Error('Name cannot be empty')
|
|
680
|
+
}
|
|
681
|
+
this.props.name = name.trim()
|
|
682
|
+
this.props.updatedAt = new Date()
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
toJSON() {
|
|
686
|
+
return {
|
|
687
|
+
id: this.props.id.toString(),
|
|
688
|
+
name: this.props.name,
|
|
689
|
+
createdAt: this.props.createdAt.toISOString(),
|
|
690
|
+
updatedAt: this.props.updatedAt.toISOString(),
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
`);
|
|
695
|
+
await write(`domain/value-objects/${kebab}-id.vo.ts`, `/**
|
|
696
|
+
* ${pascal} ID Value Object
|
|
697
|
+
*
|
|
698
|
+
* Domain layer \u2014 wraps a primitive ID with type safety and validation.
|
|
699
|
+
* Value objects are immutable and compared by value, not reference.
|
|
700
|
+
*
|
|
701
|
+
* ${pascal}Id.create() \u2014 generate a new UUID
|
|
702
|
+
* ${pascal}Id.from(id) \u2014 wrap an existing ID string (validates non-empty)
|
|
703
|
+
* id.equals(other) \u2014 compare two IDs by value
|
|
704
|
+
*/
|
|
705
|
+
import { randomUUID } from 'node:crypto'
|
|
706
|
+
|
|
707
|
+
export class ${pascal}Id {
|
|
708
|
+
private constructor(private readonly value: string) {}
|
|
709
|
+
|
|
710
|
+
static create(): ${pascal}Id {
|
|
711
|
+
return new ${pascal}Id(randomUUID())
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
static from(id: string): ${pascal}Id {
|
|
715
|
+
if (!id || id.trim().length === 0) {
|
|
716
|
+
throw new Error('${pascal}Id cannot be empty')
|
|
717
|
+
}
|
|
718
|
+
return new ${pascal}Id(id)
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
toString(): string {
|
|
722
|
+
return this.value
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
equals(other: ${pascal}Id): boolean {
|
|
726
|
+
return this.value === other.value
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
`);
|
|
730
|
+
}
|
|
731
|
+
await autoRegisterModule(modulesDir, pascal, plural);
|
|
732
|
+
return files;
|
|
733
|
+
}
|
|
734
|
+
__name(generateModule, "generateModule");
|
|
735
|
+
async function autoRegisterModule(modulesDir, pascal, plural) {
|
|
736
|
+
const indexPath = join2(modulesDir, "index.ts");
|
|
737
|
+
const exists = await fileExists(indexPath);
|
|
738
|
+
if (!exists) {
|
|
739
|
+
await writeFileSafe(indexPath, `import type { AppModuleClass } from '@forinda/kickjs-core'
|
|
740
|
+
import { ${pascal}Module } from './${plural}'
|
|
741
|
+
|
|
742
|
+
export const modules: AppModuleClass[] = [${pascal}Module]
|
|
743
|
+
`);
|
|
744
|
+
return;
|
|
745
|
+
}
|
|
746
|
+
let content = await readFile2(indexPath, "utf-8");
|
|
747
|
+
const importLine = `import { ${pascal}Module } from './${plural}'`;
|
|
748
|
+
if (!content.includes(`${pascal}Module`)) {
|
|
749
|
+
const lastImportIdx = content.lastIndexOf("import ");
|
|
750
|
+
if (lastImportIdx !== -1) {
|
|
751
|
+
const lineEnd = content.indexOf("\n", lastImportIdx);
|
|
752
|
+
content = content.slice(0, lineEnd + 1) + importLine + "\n" + content.slice(lineEnd + 1);
|
|
753
|
+
} else {
|
|
754
|
+
content = importLine + "\n" + content;
|
|
755
|
+
}
|
|
756
|
+
content = content.replace(/(=\s*\[)([\s\S]*?)(])/, (_match, open, existing, close) => {
|
|
757
|
+
const trimmed = existing.trim();
|
|
758
|
+
if (!trimmed) {
|
|
759
|
+
return `${open}${pascal}Module${close}`;
|
|
760
|
+
}
|
|
761
|
+
const needsComma = trimmed.endsWith(",") ? "" : ",";
|
|
762
|
+
return `${open}${existing.trimEnd()}${needsComma} ${pascal}Module${close}`;
|
|
763
|
+
});
|
|
764
|
+
}
|
|
765
|
+
await writeFile2(indexPath, content, "utf-8");
|
|
766
|
+
}
|
|
767
|
+
__name(autoRegisterModule, "autoRegisterModule");
|
|
768
|
+
|
|
769
|
+
// src/generators/adapter.ts
|
|
770
|
+
import { join as join3 } from "path";
|
|
771
|
+
async function generateAdapter(options) {
|
|
772
|
+
const { name, outDir } = options;
|
|
773
|
+
const kebab = toKebabCase(name);
|
|
774
|
+
const pascal = toPascalCase(name);
|
|
775
|
+
const files = [];
|
|
776
|
+
const filePath = join3(outDir, `${kebab}.adapter.ts`);
|
|
777
|
+
await writeFileSafe(filePath, `import type { Express } from 'express'
|
|
778
|
+
import type { AppAdapter, AdapterMiddleware, Container } from '@forinda/kickjs-core'
|
|
779
|
+
|
|
780
|
+
export interface ${pascal}AdapterOptions {
|
|
781
|
+
// Add your adapter configuration here
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
/**
|
|
785
|
+
* ${pascal} adapter.
|
|
786
|
+
*
|
|
787
|
+
* Hooks into the Application lifecycle to add middleware, routes,
|
|
788
|
+
* or external service connections.
|
|
789
|
+
*
|
|
790
|
+
* Usage:
|
|
791
|
+
* bootstrap({
|
|
792
|
+
* adapters: [new ${pascal}Adapter({ ... })],
|
|
793
|
+
* })
|
|
794
|
+
*/
|
|
795
|
+
export class ${pascal}Adapter implements AppAdapter {
|
|
796
|
+
name = '${pascal}Adapter'
|
|
797
|
+
|
|
798
|
+
constructor(private options: ${pascal}AdapterOptions = {}) {}
|
|
799
|
+
|
|
800
|
+
/**
|
|
801
|
+
* Return middleware entries that the Application will mount.
|
|
802
|
+
* Use \`phase\` to control where in the pipeline they run:
|
|
803
|
+
* 'beforeGlobal' | 'afterGlobal' | 'beforeRoutes' | 'afterRoutes'
|
|
804
|
+
*/
|
|
805
|
+
middleware(): AdapterMiddleware[] {
|
|
806
|
+
return [
|
|
807
|
+
// Example: add a custom header to all responses
|
|
808
|
+
// {
|
|
809
|
+
// phase: 'beforeGlobal',
|
|
810
|
+
// handler: (_req: any, res: any, next: any) => {
|
|
811
|
+
// res.setHeader('X-${pascal}', 'true')
|
|
812
|
+
// next()
|
|
813
|
+
// },
|
|
814
|
+
// },
|
|
815
|
+
// Example: scope middleware to a specific path
|
|
816
|
+
// {
|
|
817
|
+
// phase: 'beforeRoutes',
|
|
818
|
+
// path: '/api/v1/admin',
|
|
819
|
+
// handler: myAdminMiddleware(),
|
|
820
|
+
// },
|
|
821
|
+
]
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
/**
|
|
825
|
+
* Called before global middleware.
|
|
826
|
+
* Use this to mount routes that bypass the middleware stack
|
|
827
|
+
* (health checks, docs UI, static assets).
|
|
828
|
+
*/
|
|
829
|
+
beforeMount(app: Express, container: Container): void {
|
|
830
|
+
// Example: mount a status route
|
|
831
|
+
// app.get('/${kebab}/status', (_req, res) => {
|
|
832
|
+
// res.json({ status: 'ok' })
|
|
833
|
+
// })
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
/**
|
|
837
|
+
* Called after modules and routes are registered, before the server starts.
|
|
838
|
+
* Use this for late-stage DI registrations or config validation.
|
|
839
|
+
*/
|
|
840
|
+
beforeStart(app: Express, container: Container): void {
|
|
841
|
+
// Example: register a service in the DI container
|
|
842
|
+
// container.registerInstance(MY_TOKEN, new MyService(this.options))
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
/**
|
|
846
|
+
* Called after the HTTP server is listening.
|
|
847
|
+
* Use this to attach to the raw http.Server (Socket.IO, gRPC, etc).
|
|
848
|
+
*/
|
|
849
|
+
afterStart(server: any, container: Container): void {
|
|
850
|
+
// Example: attach Socket.IO
|
|
851
|
+
// const io = new Server(server)
|
|
852
|
+
// container.registerInstance(SOCKET_IO, io)
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
/**
|
|
856
|
+
* Called on graceful shutdown. Clean up connections.
|
|
857
|
+
*/
|
|
858
|
+
async shutdown(): Promise<void> {
|
|
859
|
+
// Example: close a connection pool
|
|
860
|
+
// await this.pool.end()
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
`);
|
|
864
|
+
files.push(filePath);
|
|
865
|
+
return files;
|
|
866
|
+
}
|
|
867
|
+
__name(generateAdapter, "generateAdapter");
|
|
868
|
+
|
|
869
|
+
// src/generators/middleware.ts
|
|
870
|
+
import { join as join4 } from "path";
|
|
871
|
+
async function generateMiddleware(options) {
|
|
872
|
+
const { name, outDir } = options;
|
|
873
|
+
const kebab = toKebabCase(name);
|
|
874
|
+
const camel = toCamelCase(name);
|
|
875
|
+
const files = [];
|
|
876
|
+
const filePath = join4(outDir, `${kebab}.middleware.ts`);
|
|
877
|
+
await writeFileSafe(filePath, `import type { Request, Response, NextFunction } from 'express'
|
|
878
|
+
|
|
879
|
+
export interface ${toPascalCase(name)}Options {
|
|
880
|
+
// Add configuration options here
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
/**
|
|
884
|
+
* ${toPascalCase(name)} middleware.
|
|
885
|
+
*
|
|
886
|
+
* Usage in bootstrap:
|
|
887
|
+
* middleware: [${camel}()]
|
|
888
|
+
*
|
|
889
|
+
* Usage with adapter:
|
|
890
|
+
* middleware() { return [{ handler: ${camel}(), phase: 'afterGlobal' }] }
|
|
891
|
+
*
|
|
892
|
+
* Usage with @Middleware decorator:
|
|
893
|
+
* @Middleware(${camel}())
|
|
894
|
+
*/
|
|
895
|
+
export function ${camel}(options: ${toPascalCase(name)}Options = {}) {
|
|
896
|
+
return (req: Request, res: Response, next: NextFunction) => {
|
|
897
|
+
// Implement your middleware logic here
|
|
898
|
+
next()
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
`);
|
|
902
|
+
files.push(filePath);
|
|
903
|
+
return files;
|
|
904
|
+
}
|
|
905
|
+
__name(generateMiddleware, "generateMiddleware");
|
|
906
|
+
|
|
907
|
+
// src/generators/guard.ts
|
|
908
|
+
import { join as join5 } from "path";
|
|
909
|
+
async function generateGuard(options) {
|
|
910
|
+
const { name, outDir } = options;
|
|
911
|
+
const kebab = toKebabCase(name);
|
|
912
|
+
const camel = toCamelCase(name);
|
|
913
|
+
const pascal = toPascalCase(name);
|
|
914
|
+
const files = [];
|
|
915
|
+
const filePath = join5(outDir, `${kebab}.guard.ts`);
|
|
916
|
+
await writeFileSafe(filePath, `import { Container, HttpException } from '@forinda/kickjs-core'
|
|
917
|
+
import type { RequestContext } from '@forinda/kickjs-http'
|
|
918
|
+
|
|
919
|
+
/**
|
|
920
|
+
* ${pascal} guard.
|
|
921
|
+
*
|
|
922
|
+
* Guards protect routes by checking conditions before the handler runs.
|
|
923
|
+
* Return early with an error response to block access.
|
|
924
|
+
*
|
|
925
|
+
* Usage:
|
|
926
|
+
* @Middleware(${camel}Guard)
|
|
927
|
+
* @Get('/protected')
|
|
928
|
+
* async handler(ctx: RequestContext) { ... }
|
|
929
|
+
*/
|
|
930
|
+
export async function ${camel}Guard(ctx: RequestContext, next: () => void): Promise<void> {
|
|
931
|
+
// Example: check for an authorization header
|
|
932
|
+
const header = ctx.headers.authorization
|
|
933
|
+
if (!header?.startsWith('Bearer ')) {
|
|
934
|
+
ctx.res.status(401).json({ message: 'Missing or invalid authorization header' })
|
|
935
|
+
return
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
const token = header.slice(7)
|
|
939
|
+
|
|
940
|
+
try {
|
|
941
|
+
// Verify the token using a service from the DI container
|
|
942
|
+
// const container = Container.getInstance()
|
|
943
|
+
// const authService = container.resolve(AuthService)
|
|
944
|
+
// const payload = authService.verifyToken(token)
|
|
945
|
+
// ctx.set('auth', payload)
|
|
946
|
+
|
|
947
|
+
next()
|
|
948
|
+
} catch {
|
|
949
|
+
ctx.res.status(401).json({ message: 'Invalid or expired token' })
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
`);
|
|
953
|
+
files.push(filePath);
|
|
954
|
+
return files;
|
|
955
|
+
}
|
|
956
|
+
__name(generateGuard, "generateGuard");
|
|
957
|
+
|
|
958
|
+
// src/generators/service.ts
|
|
959
|
+
import { join as join6 } from "path";
|
|
960
|
+
async function generateService(options) {
|
|
961
|
+
const { name, outDir } = options;
|
|
962
|
+
const kebab = toKebabCase(name);
|
|
963
|
+
const pascal = toPascalCase(name);
|
|
964
|
+
const files = [];
|
|
965
|
+
const filePath = join6(outDir, `${kebab}.service.ts`);
|
|
966
|
+
await writeFileSafe(filePath, `import { Service } from '@forinda/kickjs-core'
|
|
967
|
+
|
|
968
|
+
@Service()
|
|
969
|
+
export class ${pascal}Service {
|
|
970
|
+
// Inject dependencies via constructor
|
|
971
|
+
// constructor(
|
|
972
|
+
// @Inject(MY_REPO) private readonly repo: IMyRepository,
|
|
973
|
+
// ) {}
|
|
974
|
+
}
|
|
975
|
+
`);
|
|
976
|
+
files.push(filePath);
|
|
977
|
+
return files;
|
|
978
|
+
}
|
|
979
|
+
__name(generateService, "generateService");
|
|
980
|
+
|
|
981
|
+
// src/generators/controller.ts
|
|
982
|
+
import { join as join7 } from "path";
|
|
983
|
+
async function generateController(options) {
|
|
984
|
+
const { name, outDir } = options;
|
|
985
|
+
const kebab = toKebabCase(name);
|
|
986
|
+
const pascal = toPascalCase(name);
|
|
987
|
+
const files = [];
|
|
988
|
+
const filePath = join7(outDir, `${kebab}.controller.ts`);
|
|
989
|
+
await writeFileSafe(filePath, `import { Controller, Get, Post, Autowired } from '@forinda/kickjs-core'
|
|
990
|
+
import type { RequestContext } from '@forinda/kickjs-http'
|
|
991
|
+
|
|
992
|
+
@Controller()
|
|
993
|
+
export class ${pascal}Controller {
|
|
994
|
+
// @Autowired() private myService!: MyService
|
|
995
|
+
|
|
996
|
+
@Get('/')
|
|
997
|
+
async list(ctx: RequestContext) {
|
|
998
|
+
ctx.json({ message: '${pascal} list' })
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
@Post('/')
|
|
1002
|
+
async create(ctx: RequestContext) {
|
|
1003
|
+
ctx.created({ message: '${pascal} created', data: ctx.body })
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
`);
|
|
1007
|
+
files.push(filePath);
|
|
1008
|
+
return files;
|
|
1009
|
+
}
|
|
1010
|
+
__name(generateController, "generateController");
|
|
1011
|
+
|
|
1012
|
+
// src/generators/dto.ts
|
|
1013
|
+
import { join as join8 } from "path";
|
|
1014
|
+
async function generateDto(options) {
|
|
1015
|
+
const { name, outDir } = options;
|
|
1016
|
+
const kebab = toKebabCase(name);
|
|
1017
|
+
const pascal = toPascalCase(name);
|
|
1018
|
+
const camel = toCamelCase(name);
|
|
1019
|
+
const files = [];
|
|
1020
|
+
const filePath = join8(outDir, `${kebab}.dto.ts`);
|
|
1021
|
+
await writeFileSafe(filePath, `import { z } from 'zod'
|
|
1022
|
+
|
|
1023
|
+
export const ${camel}Schema = z.object({
|
|
1024
|
+
// Define your schema fields here
|
|
1025
|
+
name: z.string().min(1).max(200),
|
|
1026
|
+
})
|
|
1027
|
+
|
|
1028
|
+
export type ${pascal}DTO = z.infer<typeof ${camel}Schema>
|
|
1029
|
+
`);
|
|
1030
|
+
files.push(filePath);
|
|
1031
|
+
return files;
|
|
1032
|
+
}
|
|
1033
|
+
__name(generateDto, "generateDto");
|
|
1034
|
+
|
|
1035
|
+
// src/commands/generate.ts
|
|
1036
|
+
function printGenerated(files) {
|
|
1037
|
+
const cwd = process.cwd();
|
|
1038
|
+
console.log(`
|
|
1039
|
+
Generated ${files.length} file${files.length === 1 ? "" : "s"}:`);
|
|
1040
|
+
for (const f of files) {
|
|
1041
|
+
console.log(` ${f.replace(cwd + "/", "")}`);
|
|
1042
|
+
}
|
|
1043
|
+
console.log();
|
|
1044
|
+
}
|
|
1045
|
+
__name(printGenerated, "printGenerated");
|
|
1046
|
+
function registerGenerateCommand(program) {
|
|
1047
|
+
const gen = program.command("generate").alias("g").description("Generate code scaffolds");
|
|
1048
|
+
gen.command("module <name>").description("Generate a full DDD module with all layers").option("--no-entity", "Skip entity and value object generation").option("--no-tests", "Skip test file generation").option("--repo <type>", "Repository implementation: inmemory | drizzle", "inmemory").option("--minimal", "Only generate index.ts and controller").option("--modules-dir <dir>", "Modules directory", "src/modules").action(async (name, opts) => {
|
|
1049
|
+
const files = await generateModule({
|
|
1050
|
+
name,
|
|
1051
|
+
modulesDir: resolve2(opts.modulesDir),
|
|
1052
|
+
noEntity: opts.entity === false,
|
|
1053
|
+
noTests: opts.tests === false,
|
|
1054
|
+
repo: opts.repo,
|
|
1055
|
+
minimal: opts.minimal
|
|
1056
|
+
});
|
|
1057
|
+
printGenerated(files);
|
|
1058
|
+
});
|
|
1059
|
+
gen.command("adapter <name>").description("Generate an AppAdapter with lifecycle hooks and middleware support").option("-o, --out <dir>", "Output directory", "src/adapters").action(async (name, opts) => {
|
|
1060
|
+
const files = await generateAdapter({
|
|
1061
|
+
name,
|
|
1062
|
+
outDir: resolve2(opts.out)
|
|
1063
|
+
});
|
|
1064
|
+
printGenerated(files);
|
|
1065
|
+
});
|
|
1066
|
+
gen.command("middleware <name>").description("Generate an Express middleware function").option("-o, --out <dir>", "Output directory", "src/middleware").action(async (name, opts) => {
|
|
1067
|
+
const files = await generateMiddleware({
|
|
1068
|
+
name,
|
|
1069
|
+
outDir: resolve2(opts.out)
|
|
1070
|
+
});
|
|
1071
|
+
printGenerated(files);
|
|
1072
|
+
});
|
|
1073
|
+
gen.command("guard <name>").description("Generate a route guard (auth, roles, etc.)").option("-o, --out <dir>", "Output directory", "src/guards").action(async (name, opts) => {
|
|
1074
|
+
const files = await generateGuard({
|
|
1075
|
+
name,
|
|
1076
|
+
outDir: resolve2(opts.out)
|
|
1077
|
+
});
|
|
1078
|
+
printGenerated(files);
|
|
1079
|
+
});
|
|
1080
|
+
gen.command("service <name>").description("Generate a @Service() class").option("-o, --out <dir>", "Output directory", "src/services").action(async (name, opts) => {
|
|
1081
|
+
const files = await generateService({
|
|
1082
|
+
name,
|
|
1083
|
+
outDir: resolve2(opts.out)
|
|
1084
|
+
});
|
|
1085
|
+
printGenerated(files);
|
|
1086
|
+
});
|
|
1087
|
+
gen.command("controller <name>").description("Generate a @Controller() class with basic routes").option("-o, --out <dir>", "Output directory", "src/controllers").action(async (name, opts) => {
|
|
1088
|
+
const files = await generateController({
|
|
1089
|
+
name,
|
|
1090
|
+
outDir: resolve2(opts.out)
|
|
1091
|
+
});
|
|
1092
|
+
printGenerated(files);
|
|
1093
|
+
});
|
|
1094
|
+
gen.command("dto <name>").description("Generate a Zod DTO schema").option("-o, --out <dir>", "Output directory", "src/dtos").action(async (name, opts) => {
|
|
1095
|
+
const files = await generateDto({
|
|
1096
|
+
name,
|
|
1097
|
+
outDir: resolve2(opts.out)
|
|
1098
|
+
});
|
|
1099
|
+
printGenerated(files);
|
|
1100
|
+
});
|
|
1101
|
+
}
|
|
1102
|
+
__name(registerGenerateCommand, "registerGenerateCommand");
|
|
1103
|
+
|
|
1104
|
+
// src/utils/shell.ts
|
|
1105
|
+
import { execSync } from "child_process";
|
|
1106
|
+
function runShellCommand(command, cwd) {
|
|
1107
|
+
execSync(command, {
|
|
1108
|
+
cwd,
|
|
1109
|
+
stdio: "inherit"
|
|
1110
|
+
});
|
|
1111
|
+
}
|
|
1112
|
+
__name(runShellCommand, "runShellCommand");
|
|
1113
|
+
|
|
1114
|
+
// src/commands/run.ts
|
|
1115
|
+
function registerRunCommands(program) {
|
|
1116
|
+
program.command("dev").description("Start development server with Vite HMR (zero-downtime reload)").option("-e, --entry <file>", "Entry file", "src/index.ts").option("-p, --port <port>", "Port number").action((opts) => {
|
|
1117
|
+
const envVars = [];
|
|
1118
|
+
if (opts.port) envVars.push(`PORT=${opts.port}`);
|
|
1119
|
+
const cmd = `npx vite-node --watch ${opts.entry}`;
|
|
1120
|
+
const fullCmd = envVars.length ? `${envVars.join(" ")} ${cmd}` : cmd;
|
|
1121
|
+
console.log(`
|
|
1122
|
+
KickJS dev server starting...`);
|
|
1123
|
+
console.log(` Entry: ${opts.entry}`);
|
|
1124
|
+
console.log(` HMR: enabled (vite-node)
|
|
1125
|
+
`);
|
|
1126
|
+
try {
|
|
1127
|
+
runShellCommand(fullCmd);
|
|
1128
|
+
} catch {
|
|
1129
|
+
}
|
|
1130
|
+
});
|
|
1131
|
+
program.command("build").description("Build for production via Vite").action(() => {
|
|
1132
|
+
console.log("\n Building for production...\n");
|
|
1133
|
+
runShellCommand("npx vite build");
|
|
1134
|
+
});
|
|
1135
|
+
program.command("start").description("Start production server").option("-e, --entry <file>", "Entry file", "dist/index.js").option("-p, --port <port>", "Port number").action((opts) => {
|
|
1136
|
+
const envVars = [
|
|
1137
|
+
"NODE_ENV=production"
|
|
1138
|
+
];
|
|
1139
|
+
if (opts.port) envVars.push(`PORT=${opts.port}`);
|
|
1140
|
+
runShellCommand(`${envVars.join(" ")} node ${opts.entry}`);
|
|
1141
|
+
});
|
|
1142
|
+
program.command("dev:debug").description("Start dev server with Node.js inspector").option("-e, --entry <file>", "Entry file", "src/index.ts").option("-p, --port <port>", "Port number").action((opts) => {
|
|
1143
|
+
const envVars = opts.port ? `PORT=${opts.port} ` : "";
|
|
1144
|
+
try {
|
|
1145
|
+
runShellCommand(`${envVars}npx vite-node --inspect --watch ${opts.entry}`);
|
|
1146
|
+
} catch {
|
|
1147
|
+
}
|
|
1148
|
+
});
|
|
1149
|
+
}
|
|
1150
|
+
__name(registerRunCommands, "registerRunCommands");
|
|
1151
|
+
|
|
1152
|
+
// src/commands/info.ts
|
|
1153
|
+
import { platform, release, arch } from "os";
|
|
1154
|
+
function registerInfoCommand(program) {
|
|
1155
|
+
program.command("info").description("Print system and framework info").action(() => {
|
|
1156
|
+
console.log(`
|
|
1157
|
+
KickJS CLI
|
|
1158
|
+
|
|
1159
|
+
System:
|
|
1160
|
+
OS: ${platform()} ${release()} (${arch()})
|
|
1161
|
+
Node: ${process.version}
|
|
1162
|
+
|
|
1163
|
+
Packages:
|
|
1164
|
+
@forinda/kickjs-core workspace
|
|
1165
|
+
@forinda/kickjs-http workspace
|
|
1166
|
+
@forinda/kickjs-config workspace
|
|
1167
|
+
@forinda/kickjs-cli workspace
|
|
1168
|
+
`);
|
|
1169
|
+
});
|
|
1170
|
+
}
|
|
1171
|
+
__name(registerInfoCommand, "registerInfoCommand");
|
|
1172
|
+
|
|
1173
|
+
// src/commands/custom.ts
|
|
1174
|
+
function registerCustomCommands(program, config) {
|
|
1175
|
+
if (!config?.commands?.length) return;
|
|
1176
|
+
for (const cmd of config.commands) {
|
|
1177
|
+
registerSingleCommand(program, cmd);
|
|
1178
|
+
}
|
|
1179
|
+
}
|
|
1180
|
+
__name(registerCustomCommands, "registerCustomCommands");
|
|
1181
|
+
function registerSingleCommand(program, def) {
|
|
1182
|
+
const command = program.command(def.name).description(def.description);
|
|
1183
|
+
if (def.aliases) {
|
|
1184
|
+
for (const alias of def.aliases) {
|
|
1185
|
+
command.alias(alias);
|
|
1186
|
+
}
|
|
1187
|
+
}
|
|
1188
|
+
command.allowUnknownOption(true);
|
|
1189
|
+
command.argument("[args...]", "Additional arguments passed to the command");
|
|
1190
|
+
command.action((args) => {
|
|
1191
|
+
const extraArgs = args.join(" ");
|
|
1192
|
+
const steps = Array.isArray(def.steps) ? def.steps : [
|
|
1193
|
+
def.steps
|
|
1194
|
+
];
|
|
1195
|
+
for (const step of steps) {
|
|
1196
|
+
const finalCmd = extraArgs ? `${step} ${extraArgs}` : step;
|
|
1197
|
+
console.log(` $ ${finalCmd}`);
|
|
1198
|
+
try {
|
|
1199
|
+
runShellCommand(finalCmd);
|
|
1200
|
+
} catch (err) {
|
|
1201
|
+
console.error(` Command failed: ${def.name}`);
|
|
1202
|
+
process.exitCode = 1;
|
|
1203
|
+
return;
|
|
1204
|
+
}
|
|
1205
|
+
}
|
|
1206
|
+
});
|
|
1207
|
+
}
|
|
1208
|
+
__name(registerSingleCommand, "registerSingleCommand");
|
|
1209
|
+
|
|
1210
|
+
// src/config.ts
|
|
1211
|
+
import { readFile as readFile3, access as access2 } from "fs/promises";
|
|
1212
|
+
import { join as join9 } from "path";
|
|
1213
|
+
var CONFIG_FILES = [
|
|
1214
|
+
"kick.config.ts",
|
|
1215
|
+
"kick.config.js",
|
|
1216
|
+
"kick.config.mjs",
|
|
1217
|
+
"kick.config.json"
|
|
1218
|
+
];
|
|
1219
|
+
async function loadKickConfig(cwd) {
|
|
1220
|
+
for (const filename of CONFIG_FILES) {
|
|
1221
|
+
const filepath = join9(cwd, filename);
|
|
1222
|
+
try {
|
|
1223
|
+
await access2(filepath);
|
|
1224
|
+
} catch {
|
|
1225
|
+
continue;
|
|
1226
|
+
}
|
|
1227
|
+
if (filename.endsWith(".json")) {
|
|
1228
|
+
const content = await readFile3(filepath, "utf-8");
|
|
1229
|
+
return JSON.parse(content);
|
|
1230
|
+
}
|
|
1231
|
+
try {
|
|
1232
|
+
const { pathToFileURL } = await import("url");
|
|
1233
|
+
const mod = await import(pathToFileURL(filepath).href);
|
|
1234
|
+
return mod.default ?? mod;
|
|
1235
|
+
} catch (err) {
|
|
1236
|
+
if (filename.endsWith(".ts")) {
|
|
1237
|
+
console.warn(`Warning: Failed to load ${filename}. TypeScript config files require a runtime loader (e.g. tsx, ts-node) or use kick.config.js/.mjs instead.`);
|
|
1238
|
+
}
|
|
1239
|
+
continue;
|
|
1240
|
+
}
|
|
1241
|
+
}
|
|
1242
|
+
return null;
|
|
1243
|
+
}
|
|
1244
|
+
__name(loadKickConfig, "loadKickConfig");
|
|
1245
|
+
|
|
1246
|
+
// src/cli.ts
|
|
1247
|
+
async function main() {
|
|
1248
|
+
const program = new Command();
|
|
1249
|
+
program.name("kick").description("KickJS \u2014 A production-grade, decorator-driven Node.js framework").version("0.1.0");
|
|
1250
|
+
const config = await loadKickConfig(process.cwd());
|
|
1251
|
+
registerInitCommand(program);
|
|
1252
|
+
registerGenerateCommand(program);
|
|
1253
|
+
registerRunCommands(program);
|
|
1254
|
+
registerInfoCommand(program);
|
|
1255
|
+
registerCustomCommands(program, config);
|
|
1256
|
+
program.showHelpAfterError();
|
|
1257
|
+
await program.parseAsync(process.argv);
|
|
1258
|
+
}
|
|
1259
|
+
__name(main, "main");
|
|
1260
|
+
main().catch((err) => {
|
|
1261
|
+
console.error(err instanceof Error ? err.message : err);
|
|
1262
|
+
process.exitCode = 1;
|
|
1263
|
+
});
|
|
1264
|
+
//# sourceMappingURL=cli.js.map
|