@forinda/kickjs-cli 2.1.0 → 2.2.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/cli.mjs +1277 -78
- package/dist/index.d.mts +56 -0
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +150 -55
- package/dist/index.mjs.map +1 -1
- package/dist/typegen-UejiKdXA.mjs +886 -0
- package/dist/typegen-UejiKdXA.mjs.map +1 -0
- package/package.json +16 -13
package/dist/cli.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @forinda/kickjs-cli v2.1
|
|
2
|
+
* @forinda/kickjs-cli v2.2.1
|
|
3
3
|
*
|
|
4
4
|
* Copyright (c) Felix Orinda
|
|
5
5
|
*
|
|
@@ -10,12 +10,24 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import { Command } from "commander";
|
|
12
12
|
import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync } from "node:fs";
|
|
13
|
-
import { basename, dirname, join, resolve } from "node:path";
|
|
13
|
+
import { basename, dirname, join, relative, resolve, sep } from "node:path";
|
|
14
14
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
15
15
|
import { createInterface } from "node:readline";
|
|
16
16
|
import { execSync, fork } from "node:child_process";
|
|
17
|
-
import { access, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
17
|
+
import { access, mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises";
|
|
18
18
|
import { arch, platform, release } from "node:os";
|
|
19
|
+
//#region \0rolldown/runtime.js
|
|
20
|
+
var __defProp = Object.defineProperty;
|
|
21
|
+
var __exportAll = (all, no_symbols) => {
|
|
22
|
+
let target = {};
|
|
23
|
+
for (var name in all) __defProp(target, name, {
|
|
24
|
+
get: all[name],
|
|
25
|
+
enumerable: true
|
|
26
|
+
});
|
|
27
|
+
if (!no_symbols) __defProp(target, Symbol.toStringTag, { value: "Module" });
|
|
28
|
+
return target;
|
|
29
|
+
};
|
|
30
|
+
//#endregion
|
|
19
31
|
//#region src/utils/fs.ts
|
|
20
32
|
let _dryRun = false;
|
|
21
33
|
/** Enable/disable dry run mode globally for all writeFileSafe calls */
|
|
@@ -108,7 +120,7 @@ function generatePackageJson(name, template, kickjsVersion) {
|
|
|
108
120
|
*/
|
|
109
121
|
function generateViteConfig() {
|
|
110
122
|
return `import { defineConfig } from 'vite'
|
|
111
|
-
import { resolve } from 'path'
|
|
123
|
+
import { resolve } from 'node:path'
|
|
112
124
|
import swc from 'unplugin-swc'
|
|
113
125
|
import { kickjsVitePlugin } from '@forinda/kickjs-vite'
|
|
114
126
|
|
|
@@ -158,10 +170,13 @@ function generateTsConfig() {
|
|
|
158
170
|
experimentalDecorators: true,
|
|
159
171
|
emitDecoratorMetadata: true,
|
|
160
172
|
outDir: "dist",
|
|
161
|
-
rootDir: "src",
|
|
162
173
|
paths: { "@/*": ["./src/*"] }
|
|
163
174
|
},
|
|
164
|
-
include: [
|
|
175
|
+
include: [
|
|
176
|
+
"src",
|
|
177
|
+
".kickjs/types/**/*.d.ts",
|
|
178
|
+
".kickjs/types/**/*.ts"
|
|
179
|
+
]
|
|
165
180
|
}, null, 2);
|
|
166
181
|
}
|
|
167
182
|
/** Generate .prettierrc with project formatting rules */
|
|
@@ -199,6 +214,7 @@ dist/
|
|
|
199
214
|
coverage/
|
|
200
215
|
.DS_Store
|
|
201
216
|
*.tsbuildinfo
|
|
217
|
+
.kickjs/
|
|
202
218
|
`;
|
|
203
219
|
}
|
|
204
220
|
/** Generate .gitattributes for consistent line endings */
|
|
@@ -359,6 +375,41 @@ import { HelloModule } from './hello/hello.module'
|
|
|
359
375
|
export const modules: AppModuleClass[] = [HelloModule]
|
|
360
376
|
`;
|
|
361
377
|
}
|
|
378
|
+
/**
|
|
379
|
+
* Generate `src/env.ts` — the project's typed env schema.
|
|
380
|
+
*
|
|
381
|
+
* Default-exports a `defineEnv(...)` schema so `kick typegen` can
|
|
382
|
+
* infer it into the global `KickEnv` registry. After typegen runs:
|
|
383
|
+
*
|
|
384
|
+
* @Value('DATABASE_URL') private url!: Env<'DATABASE_URL'>
|
|
385
|
+
* process.env.DATABASE_URL // typed as string
|
|
386
|
+
*
|
|
387
|
+
* Both autocomplete and type-check at compile time.
|
|
388
|
+
*/
|
|
389
|
+
function generateEnvFile() {
|
|
390
|
+
return `import { defineEnv } from '@forinda/kickjs-config'
|
|
391
|
+
import { z } from 'zod'
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Project environment schema.
|
|
395
|
+
*
|
|
396
|
+
* Extend the base schema with your application's variables. The
|
|
397
|
+
* default export is the contract \`kick typegen\` reads to populate
|
|
398
|
+
* the global \`KickEnv\` registry — that's what makes \`@Value('FOO')\`
|
|
399
|
+
* autocomplete and \`process.env.FOO\` typed.
|
|
400
|
+
*
|
|
401
|
+
* @example
|
|
402
|
+
* DATABASE_URL: z.string().url(),
|
|
403
|
+
* JWT_SECRET: z.string().min(32),
|
|
404
|
+
* REDIS_URL: z.string().url().optional(),
|
|
405
|
+
*/
|
|
406
|
+
export default defineEnv((base) =>
|
|
407
|
+
base.extend({
|
|
408
|
+
// DATABASE_URL: z.string().url(),
|
|
409
|
+
}),
|
|
410
|
+
)
|
|
411
|
+
`;
|
|
412
|
+
}
|
|
362
413
|
/** Generate src/modules/hello/hello.service.ts */
|
|
363
414
|
function generateHelloService() {
|
|
364
415
|
return `import { Service } from '@forinda/kickjs'
|
|
@@ -377,20 +428,25 @@ export class HelloService {
|
|
|
377
428
|
}
|
|
378
429
|
/** Generate src/modules/hello/hello.controller.ts */
|
|
379
430
|
function generateHelloController() {
|
|
380
|
-
return `import { Controller, Get, Autowired, type
|
|
431
|
+
return `import { Controller, Get, Autowired, type Ctx } from '@forinda/kickjs'
|
|
381
432
|
import { HelloService } from './hello.service'
|
|
382
433
|
|
|
434
|
+
// \`Ctx<KickRoutes.HelloController['<method>']>\` is generated by
|
|
435
|
+
// \`kick typegen\` (auto-run on \`kick dev\`). The first run after a fresh
|
|
436
|
+
// scaffold creates \`.kickjs/types/routes.ts\` so this file typechecks.
|
|
437
|
+
// See https://forinda.github.io/kick-js/guide/typegen.
|
|
438
|
+
|
|
383
439
|
@Controller()
|
|
384
440
|
export class HelloController {
|
|
385
|
-
@Autowired() private helloService!: HelloService
|
|
441
|
+
@Autowired() private readonly helloService!: HelloService
|
|
386
442
|
|
|
387
443
|
@Get('/')
|
|
388
|
-
index(ctx:
|
|
444
|
+
index(ctx: Ctx<KickRoutes.HelloController['index']>) {
|
|
389
445
|
ctx.json(this.helloService.greet('World'))
|
|
390
446
|
}
|
|
391
447
|
|
|
392
448
|
@Get('/health')
|
|
393
|
-
health(ctx:
|
|
449
|
+
health(ctx: Ctx<KickRoutes.HelloController['health']>) {
|
|
394
450
|
ctx.json(this.helloService.healthCheck())
|
|
395
451
|
}
|
|
396
452
|
}
|
|
@@ -402,6 +458,13 @@ function generateHelloModule() {
|
|
|
402
458
|
import { HelloController } from './hello.controller'
|
|
403
459
|
|
|
404
460
|
export class HelloModule implements AppModule {
|
|
461
|
+
// \`register(container)\` is optional — only implement it when you need
|
|
462
|
+
// to bind a token to a concrete implementation, e.g.
|
|
463
|
+
// register(container) {
|
|
464
|
+
// container.registerFactory(USER_REPOSITORY, () => container.resolve(InMemoryUserRepository))
|
|
465
|
+
// }
|
|
466
|
+
// The HelloService uses @Service() so the decorator handles registration.
|
|
467
|
+
|
|
405
468
|
routes(): ModuleRoutes {
|
|
406
469
|
return {
|
|
407
470
|
path: '/hello',
|
|
@@ -428,6 +491,13 @@ export default defineConfig({
|
|
|
428
491
|
pluralize: true,
|
|
429
492
|
},
|
|
430
493
|
|
|
494
|
+
// \`kick typegen\` populates \`.kickjs/types/\` so \`Ctx<KickRoutes.X['method']>\`
|
|
495
|
+
// resolves to fully-typed params/body/query. Auto-runs on \`kick dev\`.
|
|
496
|
+
// Set \`schemaValidator: false\` to skip schema-driven body typing entirely.
|
|
497
|
+
typegen: {
|
|
498
|
+
schemaValidator: 'zod',
|
|
499
|
+
},
|
|
500
|
+
|
|
431
501
|
commands: [
|
|
432
502
|
{
|
|
433
503
|
name: 'test',
|
|
@@ -590,20 +660,22 @@ ${template === "graphql" ? "├── resolvers/ # GraphQL resolvers\n"
|
|
|
590
660
|
|
|
591
661
|
### Controllers
|
|
592
662
|
|
|
593
|
-
Use decorators to define routes
|
|
663
|
+
Use decorators to define routes. Annotate \`ctx\` with \`Ctx<KickRoutes.X['method']>\`
|
|
664
|
+
to get fully-typed \`ctx.params\`, \`ctx.body\`, and \`ctx.query\` from the
|
|
665
|
+
generated \`KickRoutes\` namespace (refreshed on \`kick dev\` and \`kick typegen\`).
|
|
594
666
|
|
|
595
667
|
\`\`\`ts
|
|
596
|
-
import { Controller, Get, Post,
|
|
668
|
+
import { Controller, Get, Post, type Ctx } from '@forinda/kickjs'
|
|
597
669
|
|
|
598
670
|
@Controller('/users')
|
|
599
671
|
export class UserController {
|
|
600
672
|
@Get('/')
|
|
601
|
-
async findAll(ctx:
|
|
673
|
+
async findAll(ctx: Ctx<KickRoutes.UserController['findAll']>) {
|
|
602
674
|
return ctx.json({ users: [] })
|
|
603
675
|
}
|
|
604
676
|
|
|
605
677
|
@Post('/')
|
|
606
|
-
async create(ctx:
|
|
678
|
+
async create(ctx: Ctx<KickRoutes.UserController['create']>) {
|
|
607
679
|
const data = ctx.body
|
|
608
680
|
return ctx.created({ user: data })
|
|
609
681
|
}
|
|
@@ -646,7 +718,8 @@ export class UserModule {}
|
|
|
646
718
|
|
|
647
719
|
### RequestContext
|
|
648
720
|
|
|
649
|
-
Every controller method receives \`ctx
|
|
721
|
+
Every controller method receives a \`ctx\` (alias \`Ctx<TRoute>\` or the
|
|
722
|
+
loose \`RequestContext\`):
|
|
650
723
|
|
|
651
724
|
\`\`\`ts
|
|
652
725
|
ctx.body // Request body (parsed JSON)
|
|
@@ -1153,6 +1226,7 @@ async function initProject(options) {
|
|
|
1153
1226
|
await writeFileSafe(join(dir, ".gitattributes"), generateGitAttributes());
|
|
1154
1227
|
await writeFileSafe(join(dir, ".env"), generateEnv());
|
|
1155
1228
|
await writeFileSafe(join(dir, ".env.example"), generateEnvExample());
|
|
1229
|
+
await writeFileSafe(join(dir, "src/env.ts"), generateEnvFile());
|
|
1156
1230
|
await writeFileSafe(join(dir, "src/index.ts"), generateEntryFile(name, template, cliPkg.version));
|
|
1157
1231
|
await writeFileSafe(join(dir, "src/modules/index.ts"), generateModulesIndex());
|
|
1158
1232
|
await writeFileSafe(join(dir, "src/modules/hello/hello.service.ts"), generateHelloService());
|
|
@@ -1197,6 +1271,14 @@ async function initProject(options) {
|
|
|
1197
1271
|
console.log(`\n Warning: ${packageManager} install failed. Run it manually.`);
|
|
1198
1272
|
}
|
|
1199
1273
|
}
|
|
1274
|
+
try {
|
|
1275
|
+
const { runTypegen } = await Promise.resolve().then(() => typegen_exports);
|
|
1276
|
+
await runTypegen({
|
|
1277
|
+
cwd: dir,
|
|
1278
|
+
allowDuplicates: true,
|
|
1279
|
+
silent: true
|
|
1280
|
+
});
|
|
1281
|
+
} catch {}
|
|
1200
1282
|
console.log("\n Project scaffolded successfully!");
|
|
1201
1283
|
console.log();
|
|
1202
1284
|
const needsCd = dir !== process.cwd();
|
|
@@ -1542,8 +1624,7 @@ export class ${pascal}Module implements AppModule {
|
|
|
1542
1624
|
/** DDD controller — injects use-cases, nested import paths */
|
|
1543
1625
|
function generateController$1(ctx) {
|
|
1544
1626
|
const { pascal, kebab, plural = "", pluralPascal = "" } = ctx;
|
|
1545
|
-
return `import { Controller, Get, Post, Put, Delete, Autowired, ApiQueryParams } from '@forinda/kickjs'
|
|
1546
|
-
import type { RequestContext } from '@forinda/kickjs'
|
|
1627
|
+
return `import { Controller, Get, Post, Put, Delete, Autowired, ApiQueryParams, type Ctx } from '@forinda/kickjs'
|
|
1547
1628
|
import { ApiTags } from '@forinda/kickjs-swagger'
|
|
1548
1629
|
import { Create${pascal}UseCase } from '../application/use-cases/create-${kebab}.use-case'
|
|
1549
1630
|
import { Get${pascal}UseCase } from '../application/use-cases/get-${kebab}.use-case'
|
|
@@ -1554,18 +1635,23 @@ import { create${pascal}Schema } from '../application/dtos/create-${kebab}.dto'
|
|
|
1554
1635
|
import { update${pascal}Schema } from '../application/dtos/update-${kebab}.dto'
|
|
1555
1636
|
import { ${pascal.toUpperCase()}_QUERY_CONFIG } from '../constants'
|
|
1556
1637
|
|
|
1638
|
+
// Each handler annotates its \`ctx\` with \`Ctx<KickRoutes.${pascal}Controller['<method>']>\`
|
|
1639
|
+
// so \`ctx.params\`, \`ctx.body\`, and \`ctx.query\` are typed end-to-end.
|
|
1640
|
+
// The \`KickRoutes\` namespace is generated by \`kick typegen\` (auto-run on
|
|
1641
|
+
// \`kick dev\`) — see https://forinda.github.io/kick-js/guide/typegen.
|
|
1642
|
+
|
|
1557
1643
|
@Controller()
|
|
1558
1644
|
export class ${pascal}Controller {
|
|
1559
|
-
@Autowired() private create${pascal}UseCase!: Create${pascal}UseCase
|
|
1560
|
-
@Autowired() private get${pascal}UseCase!: Get${pascal}UseCase
|
|
1561
|
-
@Autowired() private list${pluralPascal}UseCase!: List${pluralPascal}UseCase
|
|
1562
|
-
@Autowired() private update${pascal}UseCase!: Update${pascal}UseCase
|
|
1563
|
-
@Autowired() private delete${pascal}UseCase!: Delete${pascal}UseCase
|
|
1645
|
+
@Autowired() private readonly create${pascal}UseCase!: Create${pascal}UseCase
|
|
1646
|
+
@Autowired() private readonly get${pascal}UseCase!: Get${pascal}UseCase
|
|
1647
|
+
@Autowired() private readonly list${pluralPascal}UseCase!: List${pluralPascal}UseCase
|
|
1648
|
+
@Autowired() private readonly update${pascal}UseCase!: Update${pascal}UseCase
|
|
1649
|
+
@Autowired() private readonly delete${pascal}UseCase!: Delete${pascal}UseCase
|
|
1564
1650
|
|
|
1565
1651
|
@Get('/')
|
|
1566
1652
|
@ApiTags('${pascal}')
|
|
1567
1653
|
@ApiQueryParams(${pascal.toUpperCase()}_QUERY_CONFIG)
|
|
1568
|
-
async list(ctx:
|
|
1654
|
+
async list(ctx: Ctx<KickRoutes.${pascal}Controller['list']>) {
|
|
1569
1655
|
return ctx.paginate(
|
|
1570
1656
|
(parsed) => this.list${pluralPascal}UseCase.execute(parsed),
|
|
1571
1657
|
${pascal.toUpperCase()}_QUERY_CONFIG,
|
|
@@ -1574,7 +1660,7 @@ export class ${pascal}Controller {
|
|
|
1574
1660
|
|
|
1575
1661
|
@Get('/:id')
|
|
1576
1662
|
@ApiTags('${pascal}')
|
|
1577
|
-
async getById(ctx:
|
|
1663
|
+
async getById(ctx: Ctx<KickRoutes.${pascal}Controller['getById']>) {
|
|
1578
1664
|
const result = await this.get${pascal}UseCase.execute(ctx.params.id)
|
|
1579
1665
|
if (!result) return ctx.notFound('${pascal} not found')
|
|
1580
1666
|
ctx.json(result)
|
|
@@ -1582,21 +1668,21 @@ export class ${pascal}Controller {
|
|
|
1582
1668
|
|
|
1583
1669
|
@Post('/', { body: create${pascal}Schema, name: 'Create${pascal}' })
|
|
1584
1670
|
@ApiTags('${pascal}')
|
|
1585
|
-
async create(ctx:
|
|
1671
|
+
async create(ctx: Ctx<KickRoutes.${pascal}Controller['create']>) {
|
|
1586
1672
|
const result = await this.create${pascal}UseCase.execute(ctx.body)
|
|
1587
1673
|
ctx.created(result)
|
|
1588
1674
|
}
|
|
1589
1675
|
|
|
1590
1676
|
@Put('/:id', { body: update${pascal}Schema, name: 'Update${pascal}' })
|
|
1591
1677
|
@ApiTags('${pascal}')
|
|
1592
|
-
async update(ctx:
|
|
1678
|
+
async update(ctx: Ctx<KickRoutes.${pascal}Controller['update']>) {
|
|
1593
1679
|
const result = await this.update${pascal}UseCase.execute(ctx.params.id, ctx.body)
|
|
1594
1680
|
ctx.json(result)
|
|
1595
1681
|
}
|
|
1596
1682
|
|
|
1597
1683
|
@Delete('/:id')
|
|
1598
1684
|
@ApiTags('${pascal}')
|
|
1599
|
-
async remove(ctx:
|
|
1685
|
+
async remove(ctx: Ctx<KickRoutes.${pascal}Controller['remove']>) {
|
|
1600
1686
|
await this.delete${pascal}UseCase.execute(ctx.params.id)
|
|
1601
1687
|
ctx.noContent()
|
|
1602
1688
|
}
|
|
@@ -1605,24 +1691,28 @@ export class ${pascal}Controller {
|
|
|
1605
1691
|
}
|
|
1606
1692
|
/** REST controller — injects service directly, flat import paths */
|
|
1607
1693
|
function generateRestController(ctx) {
|
|
1608
|
-
const { pascal, kebab
|
|
1694
|
+
const { pascal, kebab } = ctx;
|
|
1609
1695
|
const camel = pascal.charAt(0).toLowerCase() + pascal.slice(1);
|
|
1610
|
-
return `import { Controller, Get, Post, Put, Delete, Autowired, ApiQueryParams } from '@forinda/kickjs'
|
|
1611
|
-
import type { RequestContext } from '@forinda/kickjs'
|
|
1696
|
+
return `import { Controller, Get, Post, Put, Delete, Autowired, ApiQueryParams, type Ctx } from '@forinda/kickjs'
|
|
1612
1697
|
import { ApiTags } from '@forinda/kickjs-swagger'
|
|
1613
1698
|
import { ${pascal}Service } from './${kebab}.service'
|
|
1614
1699
|
import { create${pascal}Schema } from './dtos/create-${kebab}.dto'
|
|
1615
1700
|
import { update${pascal}Schema } from './dtos/update-${kebab}.dto'
|
|
1616
1701
|
import { ${pascal.toUpperCase()}_QUERY_CONFIG } from './${kebab}.constants'
|
|
1617
1702
|
|
|
1703
|
+
// Each handler annotates its \`ctx\` with \`Ctx<KickRoutes.${pascal}Controller['<method>']>\`
|
|
1704
|
+
// so \`ctx.params\`, \`ctx.body\`, and \`ctx.query\` are typed end-to-end.
|
|
1705
|
+
// The \`KickRoutes\` namespace is generated by \`kick typegen\` (auto-run on
|
|
1706
|
+
// \`kick dev\`) — see https://forinda.github.io/kick-js/guide/typegen.
|
|
1707
|
+
|
|
1618
1708
|
@Controller()
|
|
1619
1709
|
export class ${pascal}Controller {
|
|
1620
|
-
@Autowired() private ${camel}Service!: ${pascal}Service
|
|
1710
|
+
@Autowired() private readonly ${camel}Service!: ${pascal}Service
|
|
1621
1711
|
|
|
1622
1712
|
@Get('/')
|
|
1623
1713
|
@ApiTags('${pascal}')
|
|
1624
1714
|
@ApiQueryParams(${pascal.toUpperCase()}_QUERY_CONFIG)
|
|
1625
|
-
async list(ctx:
|
|
1715
|
+
async list(ctx: Ctx<KickRoutes.${pascal}Controller['list']>) {
|
|
1626
1716
|
return ctx.paginate(
|
|
1627
1717
|
(parsed) => this.${camel}Service.findPaginated(parsed),
|
|
1628
1718
|
${pascal.toUpperCase()}_QUERY_CONFIG,
|
|
@@ -1631,7 +1721,7 @@ export class ${pascal}Controller {
|
|
|
1631
1721
|
|
|
1632
1722
|
@Get('/:id')
|
|
1633
1723
|
@ApiTags('${pascal}')
|
|
1634
|
-
async getById(ctx:
|
|
1724
|
+
async getById(ctx: Ctx<KickRoutes.${pascal}Controller['getById']>) {
|
|
1635
1725
|
const result = await this.${camel}Service.findById(ctx.params.id)
|
|
1636
1726
|
if (!result) return ctx.notFound('${pascal} not found')
|
|
1637
1727
|
ctx.json(result)
|
|
@@ -1639,21 +1729,21 @@ export class ${pascal}Controller {
|
|
|
1639
1729
|
|
|
1640
1730
|
@Post('/', { body: create${pascal}Schema, name: 'Create${pascal}' })
|
|
1641
1731
|
@ApiTags('${pascal}')
|
|
1642
|
-
async create(ctx:
|
|
1732
|
+
async create(ctx: Ctx<KickRoutes.${pascal}Controller['create']>) {
|
|
1643
1733
|
const result = await this.${camel}Service.create(ctx.body)
|
|
1644
1734
|
ctx.created(result)
|
|
1645
1735
|
}
|
|
1646
1736
|
|
|
1647
1737
|
@Put('/:id', { body: update${pascal}Schema, name: 'Update${pascal}' })
|
|
1648
1738
|
@ApiTags('${pascal}')
|
|
1649
|
-
async update(ctx:
|
|
1739
|
+
async update(ctx: Ctx<KickRoutes.${pascal}Controller['update']>) {
|
|
1650
1740
|
const result = await this.${camel}Service.update(ctx.params.id, ctx.body)
|
|
1651
1741
|
ctx.json(result)
|
|
1652
1742
|
}
|
|
1653
1743
|
|
|
1654
1744
|
@Delete('/:id')
|
|
1655
1745
|
@ApiTags('${pascal}')
|
|
1656
|
-
async remove(ctx:
|
|
1746
|
+
async remove(ctx: Ctx<KickRoutes.${pascal}Controller['remove']>) {
|
|
1657
1747
|
await this.${camel}Service.delete(ctx.params.id)
|
|
1658
1748
|
ctx.noContent()
|
|
1659
1749
|
}
|
|
@@ -1834,6 +1924,7 @@ function generateRepositoryInterface(ctx) {
|
|
|
1834
1924
|
*
|
|
1835
1925
|
* To swap implementations, change the factory in the module's register() method.
|
|
1836
1926
|
*/
|
|
1927
|
+
import { createToken } from '@forinda/kickjs'
|
|
1837
1928
|
import type { ${pascal}ResponseDTO } from '${dtoPrefix}/${kebab}-response.dto'
|
|
1838
1929
|
import type { Create${pascal}DTO } from '${dtoPrefix}/create-${kebab}.dto'
|
|
1839
1930
|
import type { Update${pascal}DTO } from '${dtoPrefix}/update-${kebab}.dto'
|
|
@@ -1848,7 +1939,13 @@ export interface I${pascal}Repository {
|
|
|
1848
1939
|
delete(id: string): Promise<void>
|
|
1849
1940
|
}
|
|
1850
1941
|
|
|
1851
|
-
|
|
1942
|
+
/**
|
|
1943
|
+
* Collision-safe DI token bound to \`I${pascal}Repository\`.
|
|
1944
|
+
* \`container.resolve(${pascal.toUpperCase()}_REPOSITORY)\` and
|
|
1945
|
+
* \`@Inject(${pascal.toUpperCase()}_REPOSITORY)\` both return the typed
|
|
1946
|
+
* interface — no manual generic, no \`any\` cast.
|
|
1947
|
+
*/
|
|
1948
|
+
export const ${pascal.toUpperCase()}_REPOSITORY = createToken<I${pascal}Repository>('${pascal}/Repository')
|
|
1852
1949
|
`;
|
|
1853
1950
|
}
|
|
1854
1951
|
function generateInMemoryRepository(ctx) {
|
|
@@ -2376,8 +2473,7 @@ export class ${pascal}Module implements AppModule {
|
|
|
2376
2473
|
/** CQRS controller — dispatches to command/query handlers */
|
|
2377
2474
|
function generateCqrsController(ctx) {
|
|
2378
2475
|
const { pascal, kebab, plural = "", pluralPascal = "" } = ctx;
|
|
2379
|
-
return `import { Controller, Get, Post, Put, Delete, Autowired, ApiQueryParams } from '@forinda/kickjs'
|
|
2380
|
-
import type { RequestContext } from '@forinda/kickjs'
|
|
2476
|
+
return `import { Controller, Get, Post, Put, Delete, Autowired, ApiQueryParams, type Ctx } from '@forinda/kickjs'
|
|
2381
2477
|
import { ApiTags } from '@forinda/kickjs-swagger'
|
|
2382
2478
|
import { Create${pascal}Command } from './commands/create-${kebab}.command'
|
|
2383
2479
|
import { Update${pascal}Command } from './commands/update-${kebab}.command'
|
|
@@ -2388,18 +2484,23 @@ import { create${pascal}Schema } from './dtos/create-${kebab}.dto'
|
|
|
2388
2484
|
import { update${pascal}Schema } from './dtos/update-${kebab}.dto'
|
|
2389
2485
|
import { ${pascal.toUpperCase()}_QUERY_CONFIG } from './${kebab}.constants'
|
|
2390
2486
|
|
|
2487
|
+
// Each handler annotates its \`ctx\` with \`Ctx<KickRoutes.${pascal}Controller['<method>']>\`
|
|
2488
|
+
// so \`ctx.params\`, \`ctx.body\`, and \`ctx.query\` are typed end-to-end.
|
|
2489
|
+
// The \`KickRoutes\` namespace is generated by \`kick typegen\` (auto-run on
|
|
2490
|
+
// \`kick dev\`) — see https://forinda.github.io/kick-js/guide/typegen.
|
|
2491
|
+
|
|
2391
2492
|
@Controller()
|
|
2392
2493
|
export class ${pascal}Controller {
|
|
2393
|
-
@Autowired() private create${pascal}Command!: Create${pascal}Command
|
|
2394
|
-
@Autowired() private update${pascal}Command!: Update${pascal}Command
|
|
2395
|
-
@Autowired() private delete${pascal}Command!: Delete${pascal}Command
|
|
2396
|
-
@Autowired() private get${pascal}Query!: Get${pascal}Query
|
|
2397
|
-
@Autowired() private list${pluralPascal}Query!: List${pluralPascal}Query
|
|
2494
|
+
@Autowired() private readonly create${pascal}Command!: Create${pascal}Command
|
|
2495
|
+
@Autowired() private readonly update${pascal}Command!: Update${pascal}Command
|
|
2496
|
+
@Autowired() private readonly delete${pascal}Command!: Delete${pascal}Command
|
|
2497
|
+
@Autowired() private readonly get${pascal}Query!: Get${pascal}Query
|
|
2498
|
+
@Autowired() private readonly list${pluralPascal}Query!: List${pluralPascal}Query
|
|
2398
2499
|
|
|
2399
2500
|
@Get('/')
|
|
2400
2501
|
@ApiTags('${pascal}')
|
|
2401
2502
|
@ApiQueryParams(${pascal.toUpperCase()}_QUERY_CONFIG)
|
|
2402
|
-
async list(ctx:
|
|
2503
|
+
async list(ctx: Ctx<KickRoutes.${pascal}Controller['list']>) {
|
|
2403
2504
|
return ctx.paginate(
|
|
2404
2505
|
(parsed) => this.list${pluralPascal}Query.execute(parsed),
|
|
2405
2506
|
${pascal.toUpperCase()}_QUERY_CONFIG,
|
|
@@ -2408,7 +2509,7 @@ export class ${pascal}Controller {
|
|
|
2408
2509
|
|
|
2409
2510
|
@Get('/:id')
|
|
2410
2511
|
@ApiTags('${pascal}')
|
|
2411
|
-
async getById(ctx:
|
|
2512
|
+
async getById(ctx: Ctx<KickRoutes.${pascal}Controller['getById']>) {
|
|
2412
2513
|
const result = await this.get${pascal}Query.execute(ctx.params.id)
|
|
2413
2514
|
if (!result) return ctx.notFound('${pascal} not found')
|
|
2414
2515
|
ctx.json(result)
|
|
@@ -2416,21 +2517,21 @@ export class ${pascal}Controller {
|
|
|
2416
2517
|
|
|
2417
2518
|
@Post('/', { body: create${pascal}Schema, name: 'Create${pascal}' })
|
|
2418
2519
|
@ApiTags('${pascal}')
|
|
2419
|
-
async create(ctx:
|
|
2520
|
+
async create(ctx: Ctx<KickRoutes.${pascal}Controller['create']>) {
|
|
2420
2521
|
const result = await this.create${pascal}Command.execute(ctx.body)
|
|
2421
2522
|
ctx.created(result)
|
|
2422
2523
|
}
|
|
2423
2524
|
|
|
2424
2525
|
@Put('/:id', { body: update${pascal}Schema, name: 'Update${pascal}' })
|
|
2425
2526
|
@ApiTags('${pascal}')
|
|
2426
|
-
async update(ctx:
|
|
2527
|
+
async update(ctx: Ctx<KickRoutes.${pascal}Controller['update']>) {
|
|
2427
2528
|
const result = await this.update${pascal}Command.execute(ctx.params.id, ctx.body)
|
|
2428
2529
|
ctx.json(result)
|
|
2429
2530
|
}
|
|
2430
2531
|
|
|
2431
2532
|
@Delete('/:id')
|
|
2432
2533
|
@ApiTags('${pascal}')
|
|
2433
|
-
async remove(ctx:
|
|
2534
|
+
async remove(ctx: Ctx<KickRoutes.${pascal}Controller['remove']>) {
|
|
2434
2535
|
await this.delete${pascal}Command.execute(ctx.params.id)
|
|
2435
2536
|
ctx.noContent()
|
|
2436
2537
|
}
|
|
@@ -2829,13 +2930,15 @@ async function generateMinimalFiles(ctx) {
|
|
|
2829
2930
|
kebab,
|
|
2830
2931
|
plural
|
|
2831
2932
|
}));
|
|
2832
|
-
await write(`${kebab}.controller.ts`, `import { Controller, Get } from '@forinda/kickjs'
|
|
2833
|
-
|
|
2933
|
+
await write(`${kebab}.controller.ts`, `import { Controller, Get, type Ctx } from '@forinda/kickjs'
|
|
2934
|
+
|
|
2935
|
+
// \`Ctx<KickRoutes.${pascal}Controller['<method>']>\` is generated by
|
|
2936
|
+
// \`kick typegen\` (auto-run on \`kick dev\`).
|
|
2834
2937
|
|
|
2835
2938
|
@Controller()
|
|
2836
2939
|
export class ${pascal}Controller {
|
|
2837
2940
|
@Get('/')
|
|
2838
|
-
async list(ctx:
|
|
2941
|
+
async list(ctx: Ctx<KickRoutes.${pascal}Controller['list']>) {
|
|
2839
2942
|
ctx.json({ message: '${pascal} list' })
|
|
2840
2943
|
}
|
|
2841
2944
|
}
|
|
@@ -3567,20 +3670,24 @@ async function generateController(options) {
|
|
|
3567
3670
|
const pascal = toPascalCase(name);
|
|
3568
3671
|
const files = [];
|
|
3569
3672
|
const filePath = join(outDir, `${kebab}.controller.ts`);
|
|
3570
|
-
await writeFileSafe(filePath, `import { Controller, Get, Post,
|
|
3571
|
-
|
|
3673
|
+
await writeFileSafe(filePath, `import { Controller, Get, Post, type Ctx } from '@forinda/kickjs'
|
|
3674
|
+
|
|
3675
|
+
// \`Ctx<KickRoutes.${pascal}Controller['<method>']>\` is generated by
|
|
3676
|
+
// \`kick typegen\` (auto-run on \`kick dev\`). After the first run, your IDE
|
|
3677
|
+
// will autocomplete \`ctx.params\`, \`ctx.body\`, and \`ctx.query\`.
|
|
3678
|
+
// See https://forinda.github.io/kick-js/guide/typegen for details.
|
|
3572
3679
|
|
|
3573
3680
|
@Controller()
|
|
3574
3681
|
export class ${pascal}Controller {
|
|
3575
|
-
// @Autowired() private myService!: MyService
|
|
3682
|
+
// @Autowired() private readonly myService!: MyService
|
|
3576
3683
|
|
|
3577
3684
|
@Get('/')
|
|
3578
|
-
async list(ctx:
|
|
3685
|
+
async list(ctx: Ctx<KickRoutes.${pascal}Controller['list']>) {
|
|
3579
3686
|
ctx.json({ message: '${pascal} list' })
|
|
3580
3687
|
}
|
|
3581
3688
|
|
|
3582
3689
|
@Post('/')
|
|
3583
|
-
async create(ctx:
|
|
3690
|
+
async create(ctx: Ctx<KickRoutes.${pascal}Controller['create']>) {
|
|
3584
3691
|
ctx.created({ message: '${pascal} created', data: ctx.body })
|
|
3585
3692
|
}
|
|
3586
3693
|
}
|
|
@@ -3913,7 +4020,7 @@ async function generateScaffold(options) {
|
|
|
3913
4020
|
await writeFileSafe(fullPath, content);
|
|
3914
4021
|
files.push(fullPath);
|
|
3915
4022
|
};
|
|
3916
|
-
await write("index.ts", genModuleIndex(pascal, kebab, plural
|
|
4023
|
+
await write("index.ts", genModuleIndex(pascal, kebab, plural));
|
|
3917
4024
|
await write("constants.ts", genConstants(pascal, fields));
|
|
3918
4025
|
await write(`presentation/${kebab}.controller.ts`, genController(pascal, kebab, plural, pluralPascal));
|
|
3919
4026
|
await write(`application/dtos/create-${kebab}.dto.ts`, genCreateDTO(pascal, fields));
|
|
@@ -4100,30 +4207,44 @@ export class ${pascal}Id {
|
|
|
4100
4207
|
}
|
|
4101
4208
|
`;
|
|
4102
4209
|
}
|
|
4103
|
-
function genModuleIndex(pascal, kebab, plural
|
|
4104
|
-
return `import type
|
|
4210
|
+
function genModuleIndex(pascal, kebab, plural) {
|
|
4211
|
+
return `import { type AppModule, type ModuleRoutes, Container, buildRoutes } from '@forinda/kickjs'
|
|
4105
4212
|
import { ${pascal}Controller } from './presentation/${kebab}.controller'
|
|
4106
|
-
import { ${pascal}DomainService } from './domain/services/${kebab}-domain.service'
|
|
4107
4213
|
import { ${pascal.toUpperCase()}_REPOSITORY } from './domain/repositories/${kebab}.repository'
|
|
4108
4214
|
import { InMemory${pascal}Repository } from './infrastructure/repositories/in-memory-${kebab}.repository'
|
|
4109
4215
|
|
|
4216
|
+
// Eagerly load decorated classes so @Service()/@Repository() decorators
|
|
4217
|
+
// register in the DI container before the application bootstraps.
|
|
4218
|
+
import.meta.glob(
|
|
4219
|
+
['./domain/services/**/*.ts', './application/use-cases/**/*.ts', '!./**/*.test.ts'],
|
|
4220
|
+
{ eager: true },
|
|
4221
|
+
)
|
|
4222
|
+
|
|
4110
4223
|
export class ${pascal}Module implements AppModule {
|
|
4111
|
-
|
|
4224
|
+
/**
|
|
4225
|
+
* Bind the repository token to its concrete implementation.
|
|
4226
|
+
* Decorator-managed classes (@Service, @Controller, @Repository) are
|
|
4227
|
+
* registered automatically — only token-to-impl bindings need to live here.
|
|
4228
|
+
*/
|
|
4229
|
+
register(container: Container): void {
|
|
4112
4230
|
container.registerFactory(
|
|
4113
4231
|
${pascal.toUpperCase()}_REPOSITORY,
|
|
4114
4232
|
() => container.resolve(InMemory${pascal}Repository),
|
|
4115
4233
|
)
|
|
4116
4234
|
}
|
|
4117
4235
|
|
|
4118
|
-
routes() {
|
|
4119
|
-
return {
|
|
4236
|
+
routes(): ModuleRoutes {
|
|
4237
|
+
return {
|
|
4238
|
+
path: '/${plural}',
|
|
4239
|
+
router: buildRoutes(${pascal}Controller),
|
|
4240
|
+
controller: ${pascal}Controller,
|
|
4241
|
+
}
|
|
4120
4242
|
}
|
|
4121
4243
|
}
|
|
4122
4244
|
`;
|
|
4123
4245
|
}
|
|
4124
4246
|
function genController(pascal, kebab, plural, pluralPascal) {
|
|
4125
|
-
return `import { Controller, Get, Post, Put, Delete, Autowired, ApiQueryParams } from '@forinda/kickjs'
|
|
4126
|
-
import type { RequestContext } from '@forinda/kickjs'
|
|
4247
|
+
return `import { Controller, Get, Post, Put, Delete, Autowired, ApiQueryParams, type Ctx } from '@forinda/kickjs'
|
|
4127
4248
|
import { ApiTags } from '@forinda/kickjs-swagger'
|
|
4128
4249
|
import { Create${pascal}UseCase } from '../application/use-cases/create-${kebab}.use-case'
|
|
4129
4250
|
import { Get${pascal}UseCase } from '../application/use-cases/get-${kebab}.use-case'
|
|
@@ -4134,18 +4255,23 @@ import { create${pascal}Schema } from '../application/dtos/create-${kebab}.dto'
|
|
|
4134
4255
|
import { update${pascal}Schema } from '../application/dtos/update-${kebab}.dto'
|
|
4135
4256
|
import { ${pascal.toUpperCase()}_QUERY_CONFIG } from '../constants'
|
|
4136
4257
|
|
|
4258
|
+
// Each handler annotates its \`ctx\` with \`Ctx<KickRoutes.${pascal}Controller['<method>']>\`
|
|
4259
|
+
// so \`ctx.params\`, \`ctx.body\`, and \`ctx.query\` are typed end-to-end.
|
|
4260
|
+
// The \`KickRoutes\` namespace is generated by \`kick typegen\` (auto-run on
|
|
4261
|
+
// \`kick dev\`) — see https://forinda.github.io/kick-js/guide/typegen.
|
|
4262
|
+
|
|
4137
4263
|
@Controller()
|
|
4138
4264
|
export class ${pascal}Controller {
|
|
4139
|
-
@Autowired() private create${pascal}UseCase!: Create${pascal}UseCase
|
|
4140
|
-
@Autowired() private get${pascal}UseCase!: Get${pascal}UseCase
|
|
4141
|
-
@Autowired() private list${pluralPascal}UseCase!: List${pluralPascal}UseCase
|
|
4142
|
-
@Autowired() private update${pascal}UseCase!: Update${pascal}UseCase
|
|
4143
|
-
@Autowired() private delete${pascal}UseCase!: Delete${pascal}UseCase
|
|
4265
|
+
@Autowired() private readonly create${pascal}UseCase!: Create${pascal}UseCase
|
|
4266
|
+
@Autowired() private readonly get${pascal}UseCase!: Get${pascal}UseCase
|
|
4267
|
+
@Autowired() private readonly list${pluralPascal}UseCase!: List${pluralPascal}UseCase
|
|
4268
|
+
@Autowired() private readonly update${pascal}UseCase!: Update${pascal}UseCase
|
|
4269
|
+
@Autowired() private readonly delete${pascal}UseCase!: Delete${pascal}UseCase
|
|
4144
4270
|
|
|
4145
4271
|
@Get('/')
|
|
4146
4272
|
@ApiTags('${pascal}')
|
|
4147
4273
|
@ApiQueryParams(${pascal.toUpperCase()}_QUERY_CONFIG)
|
|
4148
|
-
async list(ctx:
|
|
4274
|
+
async list(ctx: Ctx<KickRoutes.${pascal}Controller['list']>) {
|
|
4149
4275
|
return ctx.paginate(
|
|
4150
4276
|
(parsed) => this.list${pluralPascal}UseCase.execute(parsed),
|
|
4151
4277
|
${pascal.toUpperCase()}_QUERY_CONFIG,
|
|
@@ -4154,7 +4280,7 @@ export class ${pascal}Controller {
|
|
|
4154
4280
|
|
|
4155
4281
|
@Get('/:id')
|
|
4156
4282
|
@ApiTags('${pascal}')
|
|
4157
|
-
async getById(ctx:
|
|
4283
|
+
async getById(ctx: Ctx<KickRoutes.${pascal}Controller['getById']>) {
|
|
4158
4284
|
const result = await this.get${pascal}UseCase.execute(ctx.params.id)
|
|
4159
4285
|
if (!result) return ctx.notFound('${pascal} not found')
|
|
4160
4286
|
ctx.json(result)
|
|
@@ -4162,21 +4288,21 @@ export class ${pascal}Controller {
|
|
|
4162
4288
|
|
|
4163
4289
|
@Post('/', { body: create${pascal}Schema, name: 'Create${pascal}' })
|
|
4164
4290
|
@ApiTags('${pascal}')
|
|
4165
|
-
async create(ctx:
|
|
4291
|
+
async create(ctx: Ctx<KickRoutes.${pascal}Controller['create']>) {
|
|
4166
4292
|
const result = await this.create${pascal}UseCase.execute(ctx.body)
|
|
4167
4293
|
ctx.created(result)
|
|
4168
4294
|
}
|
|
4169
4295
|
|
|
4170
4296
|
@Put('/:id', { body: update${pascal}Schema, name: 'Update${pascal}' })
|
|
4171
4297
|
@ApiTags('${pascal}')
|
|
4172
|
-
async update(ctx:
|
|
4298
|
+
async update(ctx: Ctx<KickRoutes.${pascal}Controller['update']>) {
|
|
4173
4299
|
const result = await this.update${pascal}UseCase.execute(ctx.params.id, ctx.body)
|
|
4174
4300
|
ctx.json(result)
|
|
4175
4301
|
}
|
|
4176
4302
|
|
|
4177
4303
|
@Delete('/:id')
|
|
4178
4304
|
@ApiTags('${pascal}')
|
|
4179
|
-
async remove(ctx:
|
|
4305
|
+
async remove(ctx: Ctx<KickRoutes.${pascal}Controller['remove']>) {
|
|
4180
4306
|
await this.delete${pascal}UseCase.execute(ctx.params.id)
|
|
4181
4307
|
ctx.noContent()
|
|
4182
4308
|
}
|
|
@@ -4184,7 +4310,8 @@ export class ${pascal}Controller {
|
|
|
4184
4310
|
`;
|
|
4185
4311
|
}
|
|
4186
4312
|
function genRepositoryInterface(pascal, kebab) {
|
|
4187
|
-
return `import
|
|
4313
|
+
return `import { createToken } from '@forinda/kickjs'
|
|
4314
|
+
import type { ${pascal}ResponseDTO } from '../../application/dtos/${kebab}-response.dto'
|
|
4188
4315
|
import type { Create${pascal}DTO } from '../../application/dtos/create-${kebab}.dto'
|
|
4189
4316
|
import type { Update${pascal}DTO } from '../../application/dtos/update-${kebab}.dto'
|
|
4190
4317
|
import type { ParsedQuery } from '@forinda/kickjs'
|
|
@@ -4198,7 +4325,13 @@ export interface I${pascal}Repository {
|
|
|
4198
4325
|
delete(id: string): Promise<void>
|
|
4199
4326
|
}
|
|
4200
4327
|
|
|
4201
|
-
|
|
4328
|
+
/**
|
|
4329
|
+
* Collision-safe DI token bound to \`I${pascal}Repository\`.
|
|
4330
|
+
* \`container.resolve(${pascal.toUpperCase()}_REPOSITORY)\` and
|
|
4331
|
+
* \`@Inject(${pascal.toUpperCase()}_REPOSITORY)\` both return the typed
|
|
4332
|
+
* interface — no manual generic, no \`any\` cast.
|
|
4333
|
+
*/
|
|
4334
|
+
export const ${pascal.toUpperCase()}_REPOSITORY = createToken<I${pascal}Repository>('${pascal}/Repository')
|
|
4202
4335
|
`;
|
|
4203
4336
|
}
|
|
4204
4337
|
function genDomainService(pascal, kebab) {
|
|
@@ -4400,6 +4533,949 @@ async function loadKickConfig(cwd) {
|
|
|
4400
4533
|
return null;
|
|
4401
4534
|
}
|
|
4402
4535
|
//#endregion
|
|
4536
|
+
//#region src/typegen/scanner.ts
|
|
4537
|
+
/** Decorators that mark a class as DI-managed */
|
|
4538
|
+
const DECORATOR_NAMES = [
|
|
4539
|
+
"Service",
|
|
4540
|
+
"Controller",
|
|
4541
|
+
"Repository",
|
|
4542
|
+
"Injectable",
|
|
4543
|
+
"Component",
|
|
4544
|
+
"Module"
|
|
4545
|
+
];
|
|
4546
|
+
const DEFAULT_EXTENSIONS = [
|
|
4547
|
+
".ts",
|
|
4548
|
+
".tsx",
|
|
4549
|
+
".mts",
|
|
4550
|
+
".cts"
|
|
4551
|
+
];
|
|
4552
|
+
const DEFAULT_EXCLUDES = [
|
|
4553
|
+
"node_modules",
|
|
4554
|
+
".kickjs",
|
|
4555
|
+
"dist",
|
|
4556
|
+
"build",
|
|
4557
|
+
".test.",
|
|
4558
|
+
".spec.",
|
|
4559
|
+
".d.ts"
|
|
4560
|
+
];
|
|
4561
|
+
/**
|
|
4562
|
+
* Match a class-level decorator immediately followed by an exported
|
|
4563
|
+
* class declaration. Captures decorator name and class name.
|
|
4564
|
+
*/
|
|
4565
|
+
const DECORATED_CLASS_REGEX = new RegExp(String.raw`@(${DECORATOR_NAMES.join("|")})\s*\([^)]*\)` + String.raw`(?:\s*@[A-Z]\w*(?:\s*\([^)]*\))?)*` + String.raw`\s*export\s+(default\s+)?(?:abstract\s+)?class\s+(\w+)`, "g");
|
|
4566
|
+
/**
|
|
4567
|
+
* Match a `createToken<T>('name')` call with optional `export const X =`
|
|
4568
|
+
* or `const X =` prefix. Tolerates whitespace and the type parameter
|
|
4569
|
+
* being absent (`createToken('name')`).
|
|
4570
|
+
*/
|
|
4571
|
+
const CREATE_TOKEN_REGEX = /(?:export\s+)?const\s+(\w+)\s*(?::\s*[^=]+)?=\s*createToken\s*(?:<[^>]*>)?\s*\(\s*['"`]([^'"`]+)['"`]\s*\)/g;
|
|
4572
|
+
/**
|
|
4573
|
+
* Match a bare `createToken<T>('name')` call (no const assignment) so
|
|
4574
|
+
* we still pick up dynamically-used tokens.
|
|
4575
|
+
*/
|
|
4576
|
+
const BARE_CREATE_TOKEN_REGEX = /createToken\s*(?:<[^>]*>)?\s*\(\s*['"`]([^'"`]+)['"`]\s*\)/g;
|
|
4577
|
+
/** Match `@Inject('literal')` — only literals; computed args are skipped */
|
|
4578
|
+
const INJECT_LITERAL_REGEX = /@Inject\s*\(\s*['"`]([^'"`]+)['"`]\s*\)/g;
|
|
4579
|
+
/**
|
|
4580
|
+
* Match a route decorator immediately followed by a method declaration.
|
|
4581
|
+
* Captures the HTTP verb, path literal (or empty), and method name.
|
|
4582
|
+
*
|
|
4583
|
+
* Tolerates:
|
|
4584
|
+
* - Optional second arg to the route decorator (`@Get('/path', { ... })`)
|
|
4585
|
+
* - Stacked decorators between the route and the method (`@Get('/') @Use(...)`)
|
|
4586
|
+
* - Path-less decorators (`@Get()` → defaults to `/`)
|
|
4587
|
+
* - `async` modifier on the method
|
|
4588
|
+
*
|
|
4589
|
+
* Run within a class body slice (see extractRoutesFromSource) so the
|
|
4590
|
+
* captured method name is unambiguously a method on that class.
|
|
4591
|
+
*/
|
|
4592
|
+
const ROUTE_METHOD_REGEX = new RegExp(String.raw`@(${[
|
|
4593
|
+
"Get",
|
|
4594
|
+
"Post",
|
|
4595
|
+
"Put",
|
|
4596
|
+
"Delete",
|
|
4597
|
+
"Patch"
|
|
4598
|
+
].join("|")})\s*\(` + String.raw`(?:\s*['"\`]([^'"\`]*)['"\`])?[^)]*\)` + String.raw`(?:\s*@[A-Z]\w*(?:\s*\([^)]*\))?)*` + String.raw`\s*(?:public\s+|private\s+|protected\s+)?(?:async\s+)?` + String.raw`([a-zA-Z_]\w*)\s*\(`, "g");
|
|
4599
|
+
/** Extract `:placeholder` segments from an Express route path */
|
|
4600
|
+
function extractPathParams(path) {
|
|
4601
|
+
return (path.match(/:([a-zA-Z_]\w*)/g) ?? []).map((m) => m.slice(1));
|
|
4602
|
+
}
|
|
4603
|
+
/**
|
|
4604
|
+
* Given the matched text of a route decorator + method declaration, return
|
|
4605
|
+
* the substring inside the route decorator's argument list (between the
|
|
4606
|
+
* outermost `(` and `)`). Returns `null` if no parens are found.
|
|
4607
|
+
*
|
|
4608
|
+
* Example input:
|
|
4609
|
+
* `@Post('/', { body: createTaskSchema, name: 'CreateTask' }) async create(`
|
|
4610
|
+
* Returns:
|
|
4611
|
+
* `'/', { body: createTaskSchema, name: 'CreateTask' }`
|
|
4612
|
+
*/
|
|
4613
|
+
function extractRouteOptionsArg(matchedText) {
|
|
4614
|
+
const open = matchedText.indexOf("(");
|
|
4615
|
+
if (open < 0) return null;
|
|
4616
|
+
let depth = 1;
|
|
4617
|
+
for (let i = open + 1; i < matchedText.length; i++) {
|
|
4618
|
+
const ch = matchedText[i];
|
|
4619
|
+
if (ch === "(") depth++;
|
|
4620
|
+
else if (ch === ")") {
|
|
4621
|
+
depth--;
|
|
4622
|
+
if (depth === 0) return matchedText.slice(open + 1, i);
|
|
4623
|
+
}
|
|
4624
|
+
}
|
|
4625
|
+
return null;
|
|
4626
|
+
}
|
|
4627
|
+
/**
|
|
4628
|
+
* Extract a bare identifier value from a single field in an object literal
|
|
4629
|
+
* embedded in a string. Returns `null` if the field is missing or its value
|
|
4630
|
+
* isn't a bare identifier (e.g. an inline object, function call, etc.).
|
|
4631
|
+
*
|
|
4632
|
+
* Example: `extractObjectFieldIdentifier("'/' , { body: createTaskSchema }", 'body')`
|
|
4633
|
+
* returns `'createTaskSchema'`.
|
|
4634
|
+
*/
|
|
4635
|
+
function extractObjectFieldIdentifier(text, field) {
|
|
4636
|
+
const m = new RegExp(String.raw`\b${field}\s*:\s*([A-Za-z_$][\w$]*)`, "g").exec(text);
|
|
4637
|
+
if (!m) return null;
|
|
4638
|
+
return m[1];
|
|
4639
|
+
}
|
|
4640
|
+
/**
|
|
4641
|
+
* Resolve a bare identifier to its module source by inspecting the file's
|
|
4642
|
+
* top-level imports and same-file `const` declarations.
|
|
4643
|
+
*
|
|
4644
|
+
* - `import { X } from './path'` → returns `'./path'`
|
|
4645
|
+
* - `import X from './path'` (default import) → returns `'./path'`
|
|
4646
|
+
* - `import * as X from './path'` → returns `'./path'`
|
|
4647
|
+
* - `const X = z.object(...)` (same file) → returns `null` (caller emits a self-import)
|
|
4648
|
+
*
|
|
4649
|
+
* Returns `null` when the identifier cannot be resolved.
|
|
4650
|
+
*/
|
|
4651
|
+
function resolveImportSource(source, identifier) {
|
|
4652
|
+
const named = new RegExp(String.raw`import\s*(?:type\s+)?\{[^}]*\b${identifier}\b[^}]*\}\s*from\s*['"\`]([^'"\`]+)['"\`]`).exec(source);
|
|
4653
|
+
if (named) return named[1];
|
|
4654
|
+
const def = new RegExp(String.raw`import\s+(?:type\s+)?${identifier}\s+from\s*['"\`]([^'"\`]+)['"\`]`).exec(source);
|
|
4655
|
+
if (def) return def[1];
|
|
4656
|
+
const ns = new RegExp(String.raw`import\s*\*\s*as\s+${identifier}\s+from\s*['"\`]([^'"\`]+)['"\`]`).exec(source);
|
|
4657
|
+
if (ns) return ns[1];
|
|
4658
|
+
if (new RegExp(String.raw`(?:^|\n)\s*(?:export\s+)?const\s+${identifier}\b`).test(source)) return "";
|
|
4659
|
+
return null;
|
|
4660
|
+
}
|
|
4661
|
+
/**
|
|
4662
|
+
* Extract whitelist arrays from an `@ApiQueryParams(...)` decorator
|
|
4663
|
+
* within `decoratorBlock`. Handles two forms:
|
|
4664
|
+
*
|
|
4665
|
+
* - Inline literal: `@ApiQueryParams({ filterable: ['a', 'b'], ... })`
|
|
4666
|
+
* - Const reference: `@ApiQueryParams(SOME_CONFIG)` — looks up
|
|
4667
|
+
* `const SOME_CONFIG = { ... }` in the same file (`fullSource`).
|
|
4668
|
+
*
|
|
4669
|
+
* Returns `null` if no `@ApiQueryParams` is present. Returns
|
|
4670
|
+
* `{ filterable: [], sortable: [], searchable: [] }` if the decorator
|
|
4671
|
+
* is present but no fields could be statically extracted (opaque
|
|
4672
|
+
* imports, column-object configs, function calls, etc.).
|
|
4673
|
+
*/
|
|
4674
|
+
function extractApiQueryParams(decoratorBlock, fullSource) {
|
|
4675
|
+
const apiMatch = /@ApiQueryParams\s*\(\s*([\s\S]*?)\s*\)\s*$/.exec(decoratorBlock);
|
|
4676
|
+
if (!apiMatch) {
|
|
4677
|
+
const loose = /@ApiQueryParams\s*\(([\s\S]*?)\)/.exec(decoratorBlock);
|
|
4678
|
+
if (!loose) return null;
|
|
4679
|
+
return parseApiQueryParamsArg(loose[1].trim(), fullSource);
|
|
4680
|
+
}
|
|
4681
|
+
return parseApiQueryParamsArg(apiMatch[1].trim(), fullSource);
|
|
4682
|
+
}
|
|
4683
|
+
function parseApiQueryParamsArg(arg, fullSource) {
|
|
4684
|
+
if (arg.startsWith("{")) return parseInlineConfigLiteral(arg);
|
|
4685
|
+
const idMatch = /^([A-Za-z_]\w*)/.exec(arg);
|
|
4686
|
+
if (idMatch) {
|
|
4687
|
+
const ident = idMatch[1];
|
|
4688
|
+
const constMatch = new RegExp(String.raw`const\s+${ident}\s*(?::\s*[^=]+)?=\s*(\{[\s\S]*?\n\})`, "m").exec(fullSource);
|
|
4689
|
+
if (constMatch) return parseInlineConfigLiteral(constMatch[1]);
|
|
4690
|
+
}
|
|
4691
|
+
return {
|
|
4692
|
+
filterable: [],
|
|
4693
|
+
sortable: [],
|
|
4694
|
+
searchable: []
|
|
4695
|
+
};
|
|
4696
|
+
}
|
|
4697
|
+
/** Extract a string array literal for one config key from an inline object literal */
|
|
4698
|
+
function extractStringArray(literal, key) {
|
|
4699
|
+
const m = new RegExp(String.raw`${key}\s*:\s*\[([\s\S]*?)\]`).exec(literal);
|
|
4700
|
+
if (!m) return [];
|
|
4701
|
+
return Array.from(m[1].matchAll(/['"`]([^'"`]+)['"`]/g)).map((x) => x[1]);
|
|
4702
|
+
}
|
|
4703
|
+
/** Parse an inline `{ filterable: [...], sortable: [...], searchable: [...] }` literal */
|
|
4704
|
+
function parseInlineConfigLiteral(literal) {
|
|
4705
|
+
return {
|
|
4706
|
+
filterable: extractStringArray(literal, "filterable"),
|
|
4707
|
+
sortable: extractStringArray(literal, "sortable"),
|
|
4708
|
+
searchable: extractStringArray(literal, "searchable")
|
|
4709
|
+
};
|
|
4710
|
+
}
|
|
4711
|
+
/** Recursively walk a directory and yield matching file paths */
|
|
4712
|
+
async function walk(dir, opts) {
|
|
4713
|
+
const exts = opts.extensions ?? DEFAULT_EXTENSIONS;
|
|
4714
|
+
const excludes = opts.exclude ?? DEFAULT_EXCLUDES;
|
|
4715
|
+
const out = [];
|
|
4716
|
+
let entries;
|
|
4717
|
+
try {
|
|
4718
|
+
entries = await readdir(dir, {
|
|
4719
|
+
withFileTypes: true,
|
|
4720
|
+
encoding: "utf-8"
|
|
4721
|
+
});
|
|
4722
|
+
} catch {
|
|
4723
|
+
return out;
|
|
4724
|
+
}
|
|
4725
|
+
for (const entry of entries) {
|
|
4726
|
+
const full = join(dir, entry.name);
|
|
4727
|
+
const rel = relative(opts.cwd, full);
|
|
4728
|
+
if (excludes.some((ex) => rel.includes(ex))) continue;
|
|
4729
|
+
if (entry.isDirectory()) out.push(...await walk(full, opts));
|
|
4730
|
+
else if (entry.isFile()) {
|
|
4731
|
+
if (exts.some((ext) => entry.name.endsWith(ext))) out.push(full);
|
|
4732
|
+
}
|
|
4733
|
+
}
|
|
4734
|
+
return out;
|
|
4735
|
+
}
|
|
4736
|
+
/** Compute the forward-slash relative path used in scanner output */
|
|
4737
|
+
function toRelative(filePath, cwd) {
|
|
4738
|
+
return relative(cwd, filePath).split(sep).join("/");
|
|
4739
|
+
}
|
|
4740
|
+
/** Extract decorated classes from a single source file */
|
|
4741
|
+
function extractClassesFromSource(source, filePath, cwd) {
|
|
4742
|
+
const out = [];
|
|
4743
|
+
const relPath = toRelative(filePath, cwd);
|
|
4744
|
+
DECORATED_CLASS_REGEX.lastIndex = 0;
|
|
4745
|
+
let match;
|
|
4746
|
+
while ((match = DECORATED_CLASS_REGEX.exec(source)) !== null) {
|
|
4747
|
+
const [, decorator, defaultMarker, className] = match;
|
|
4748
|
+
out.push({
|
|
4749
|
+
className,
|
|
4750
|
+
decorator,
|
|
4751
|
+
filePath,
|
|
4752
|
+
relativePath: relPath,
|
|
4753
|
+
isDefault: Boolean(defaultMarker)
|
|
4754
|
+
});
|
|
4755
|
+
}
|
|
4756
|
+
return out;
|
|
4757
|
+
}
|
|
4758
|
+
/** Extract `createToken('name')` definitions from a single source file */
|
|
4759
|
+
function extractTokensFromSource(source, filePath, cwd) {
|
|
4760
|
+
const out = [];
|
|
4761
|
+
const relPath = toRelative(filePath, cwd);
|
|
4762
|
+
const seen = /* @__PURE__ */ new Set();
|
|
4763
|
+
CREATE_TOKEN_REGEX.lastIndex = 0;
|
|
4764
|
+
let match;
|
|
4765
|
+
while ((match = CREATE_TOKEN_REGEX.exec(source)) !== null) {
|
|
4766
|
+
const [full, variable, name] = match;
|
|
4767
|
+
seen.add(full);
|
|
4768
|
+
out.push({
|
|
4769
|
+
name,
|
|
4770
|
+
variable,
|
|
4771
|
+
filePath,
|
|
4772
|
+
relativePath: relPath
|
|
4773
|
+
});
|
|
4774
|
+
}
|
|
4775
|
+
BARE_CREATE_TOKEN_REGEX.lastIndex = 0;
|
|
4776
|
+
while ((match = BARE_CREATE_TOKEN_REGEX.exec(source)) !== null) {
|
|
4777
|
+
if (seen.has(match[0])) continue;
|
|
4778
|
+
out.push({
|
|
4779
|
+
name: match[1],
|
|
4780
|
+
variable: null,
|
|
4781
|
+
filePath,
|
|
4782
|
+
relativePath: relPath
|
|
4783
|
+
});
|
|
4784
|
+
}
|
|
4785
|
+
return out;
|
|
4786
|
+
}
|
|
4787
|
+
/**
|
|
4788
|
+
* Extract route handlers from a source file.
|
|
4789
|
+
*
|
|
4790
|
+
* For each decorated class in `classesInFile`, slices the source from
|
|
4791
|
+
* the class declaration to the next class (or EOF) and runs the route
|
|
4792
|
+
* decorator regex within that slice. The result is a list of routes
|
|
4793
|
+
* tagged with their owning controller.
|
|
4794
|
+
*
|
|
4795
|
+
* Heuristic note: this assumes classes are not nested. KickJS controllers
|
|
4796
|
+
* are top-level by convention so this holds in practice.
|
|
4797
|
+
*/
|
|
4798
|
+
function extractRoutesFromSource(source, filePath, cwd, classesInFile) {
|
|
4799
|
+
const out = [];
|
|
4800
|
+
if (classesInFile.length === 0) return out;
|
|
4801
|
+
const relPath = toRelative(filePath, cwd);
|
|
4802
|
+
const positions = [];
|
|
4803
|
+
for (const cls of classesInFile) {
|
|
4804
|
+
const m = new RegExp(String.raw`class\s+${cls.className}\b`).exec(source);
|
|
4805
|
+
if (m?.index !== void 0) positions.push({
|
|
4806
|
+
cls,
|
|
4807
|
+
start: m.index
|
|
4808
|
+
});
|
|
4809
|
+
}
|
|
4810
|
+
positions.sort((a, b) => a.start - b.start);
|
|
4811
|
+
for (let i = 0; i < positions.length; i++) {
|
|
4812
|
+
const { cls, start } = positions[i];
|
|
4813
|
+
const end = i + 1 < positions.length ? positions[i + 1].start : source.length;
|
|
4814
|
+
const block = source.slice(start, end);
|
|
4815
|
+
ROUTE_METHOD_REGEX.lastIndex = 0;
|
|
4816
|
+
let match;
|
|
4817
|
+
while ((match = ROUTE_METHOD_REGEX.exec(block)) !== null) {
|
|
4818
|
+
const [matchedText, verb, pathLiteral, methodName] = match;
|
|
4819
|
+
const path = pathLiteral && pathLiteral.length > 0 ? pathLiteral : "/";
|
|
4820
|
+
const apiQp = extractApiQueryParams(matchedText, source);
|
|
4821
|
+
const routeArgs = extractRouteOptionsArg(matchedText);
|
|
4822
|
+
const bodyId = routeArgs ? extractObjectFieldIdentifier(routeArgs, "body") : null;
|
|
4823
|
+
const queryId = routeArgs ? extractObjectFieldIdentifier(routeArgs, "query") : null;
|
|
4824
|
+
const paramsId = routeArgs ? extractObjectFieldIdentifier(routeArgs, "params") : null;
|
|
4825
|
+
out.push({
|
|
4826
|
+
controller: cls.className,
|
|
4827
|
+
method: methodName,
|
|
4828
|
+
httpMethod: verb.toUpperCase(),
|
|
4829
|
+
path,
|
|
4830
|
+
pathParams: extractPathParams(path),
|
|
4831
|
+
queryFilterable: apiQp?.filterable ?? null,
|
|
4832
|
+
querySortable: apiQp?.sortable ?? null,
|
|
4833
|
+
querySearchable: apiQp?.searchable ?? null,
|
|
4834
|
+
bodySchema: bodyId ? {
|
|
4835
|
+
identifier: bodyId,
|
|
4836
|
+
source: resolveImportSource(source, bodyId)
|
|
4837
|
+
} : null,
|
|
4838
|
+
querySchema: queryId ? {
|
|
4839
|
+
identifier: queryId,
|
|
4840
|
+
source: resolveImportSource(source, queryId)
|
|
4841
|
+
} : null,
|
|
4842
|
+
paramsSchema: paramsId ? {
|
|
4843
|
+
identifier: paramsId,
|
|
4844
|
+
source: resolveImportSource(source, paramsId)
|
|
4845
|
+
} : null,
|
|
4846
|
+
filePath,
|
|
4847
|
+
relativePath: relPath
|
|
4848
|
+
});
|
|
4849
|
+
}
|
|
4850
|
+
}
|
|
4851
|
+
return out;
|
|
4852
|
+
}
|
|
4853
|
+
/** Extract `@Inject('literal')` calls from a single source file */
|
|
4854
|
+
function extractInjectsFromSource(source, filePath, cwd) {
|
|
4855
|
+
const out = [];
|
|
4856
|
+
const relPath = toRelative(filePath, cwd);
|
|
4857
|
+
INJECT_LITERAL_REGEX.lastIndex = 0;
|
|
4858
|
+
let match;
|
|
4859
|
+
while ((match = INJECT_LITERAL_REGEX.exec(source)) !== null) out.push({
|
|
4860
|
+
name: match[1],
|
|
4861
|
+
filePath,
|
|
4862
|
+
relativePath: relPath
|
|
4863
|
+
});
|
|
4864
|
+
return out;
|
|
4865
|
+
}
|
|
4866
|
+
/**
|
|
4867
|
+
* Look for an env schema file at `<cwd>/<envFile>`. Returns a
|
|
4868
|
+
* `DiscoveredEnv` if the file exists and contains both a
|
|
4869
|
+
* `defineEnv(...)` call and a default export — the two markers we
|
|
4870
|
+
* need before it's safe to emit `import type schema from '...'` in
|
|
4871
|
+
* the generator.
|
|
4872
|
+
*
|
|
4873
|
+
* Returns `null` for any other state (file missing, no defineEnv, no
|
|
4874
|
+
* default export) so the generator skips env typing silently. Users
|
|
4875
|
+
* who want env typing must opt in by writing `src/env.ts` to the
|
|
4876
|
+
* documented shape.
|
|
4877
|
+
*/
|
|
4878
|
+
async function detectEnvFile(cwd, envFile) {
|
|
4879
|
+
const abs = resolve(cwd, envFile);
|
|
4880
|
+
let source;
|
|
4881
|
+
try {
|
|
4882
|
+
source = await readFile(abs, "utf-8");
|
|
4883
|
+
} catch {
|
|
4884
|
+
return null;
|
|
4885
|
+
}
|
|
4886
|
+
if (!/\bdefineEnv\s*\(/.test(source)) return null;
|
|
4887
|
+
if (!/export\s+default\b/.test(source)) return null;
|
|
4888
|
+
return {
|
|
4889
|
+
filePath: abs,
|
|
4890
|
+
relativePath: toRelative(abs, cwd)
|
|
4891
|
+
};
|
|
4892
|
+
}
|
|
4893
|
+
/** Detect duplicate class names across files */
|
|
4894
|
+
function findCollisions(classes) {
|
|
4895
|
+
const groups = /* @__PURE__ */ new Map();
|
|
4896
|
+
for (const cls of classes) {
|
|
4897
|
+
const arr = groups.get(cls.className) ?? [];
|
|
4898
|
+
arr.push(cls);
|
|
4899
|
+
groups.set(cls.className, arr);
|
|
4900
|
+
}
|
|
4901
|
+
const collisions = [];
|
|
4902
|
+
for (const [className, group] of groups) if (new Set(group.map((c) => c.filePath)).size > 1) collisions.push({
|
|
4903
|
+
className,
|
|
4904
|
+
classes: group
|
|
4905
|
+
});
|
|
4906
|
+
collisions.sort((a, b) => a.className.localeCompare(b.className));
|
|
4907
|
+
return collisions;
|
|
4908
|
+
}
|
|
4909
|
+
/**
|
|
4910
|
+
* Scan a project for decorated classes, createToken definitions, and
|
|
4911
|
+
* `@Inject` literal usages.
|
|
4912
|
+
*/
|
|
4913
|
+
async function scanProject(opts) {
|
|
4914
|
+
const files = await walk(resolve(opts.root), opts);
|
|
4915
|
+
const classes = [];
|
|
4916
|
+
const routes = [];
|
|
4917
|
+
const tokens = [];
|
|
4918
|
+
const injects = [];
|
|
4919
|
+
const sources = /* @__PURE__ */ new Map();
|
|
4920
|
+
for (const file of files) {
|
|
4921
|
+
let source;
|
|
4922
|
+
try {
|
|
4923
|
+
source = await readFile(file, "utf-8");
|
|
4924
|
+
} catch {
|
|
4925
|
+
continue;
|
|
4926
|
+
}
|
|
4927
|
+
sources.set(file, source);
|
|
4928
|
+
classes.push(...extractClassesFromSource(source, file, opts.cwd));
|
|
4929
|
+
tokens.push(...extractTokensFromSource(source, file, opts.cwd));
|
|
4930
|
+
injects.push(...extractInjectsFromSource(source, file, opts.cwd));
|
|
4931
|
+
}
|
|
4932
|
+
for (const [file, source] of sources) {
|
|
4933
|
+
const classesInFile = classes.filter((c) => c.filePath === file);
|
|
4934
|
+
routes.push(...extractRoutesFromSource(source, file, opts.cwd, classesInFile));
|
|
4935
|
+
}
|
|
4936
|
+
classes.sort((a, b) => {
|
|
4937
|
+
if (a.className !== b.className) return a.className.localeCompare(b.className);
|
|
4938
|
+
return a.relativePath.localeCompare(b.relativePath);
|
|
4939
|
+
});
|
|
4940
|
+
tokens.sort((a, b) => a.name.localeCompare(b.name) || a.relativePath.localeCompare(b.relativePath));
|
|
4941
|
+
injects.sort((a, b) => a.name.localeCompare(b.name) || a.relativePath.localeCompare(b.relativePath));
|
|
4942
|
+
routes.sort((a, b) => a.controller.localeCompare(b.controller) || a.method.localeCompare(b.method));
|
|
4943
|
+
return {
|
|
4944
|
+
classes,
|
|
4945
|
+
routes,
|
|
4946
|
+
tokens,
|
|
4947
|
+
injects,
|
|
4948
|
+
collisions: findCollisions(classes),
|
|
4949
|
+
env: await detectEnvFile(opts.cwd, opts.envFile ?? "src/env.ts")
|
|
4950
|
+
};
|
|
4951
|
+
}
|
|
4952
|
+
//#endregion
|
|
4953
|
+
//#region src/typegen/generator.ts
|
|
4954
|
+
/**
|
|
4955
|
+
* Generates `.d.ts` files inside `.kickjs/types/` from the discovered
|
|
4956
|
+
* decorated classes and DI tokens. Pattern modeled on React Router's
|
|
4957
|
+
* `.react-router/types/` directory.
|
|
4958
|
+
*
|
|
4959
|
+
* Outputs:
|
|
4960
|
+
* - `.kickjs/types/registry.d.ts` — module augmentation for `KickJsRegistry`
|
|
4961
|
+
* that gives `container.resolve('UserService')` the right return type.
|
|
4962
|
+
* - `.kickjs/types/services.d.ts` — string-literal union of all known
|
|
4963
|
+
* service-style tokens for tooling autocomplete.
|
|
4964
|
+
* - `.kickjs/types/modules.d.ts` — string-literal union of discovered
|
|
4965
|
+
* module class names.
|
|
4966
|
+
* - `.kickjs/types/index.d.ts` — re-exports the above (single import target).
|
|
4967
|
+
* - `.kickjs/.gitignore` — gitignores the whole folder so generated files
|
|
4968
|
+
* never get committed.
|
|
4969
|
+
*
|
|
4970
|
+
* ## Collision behaviour
|
|
4971
|
+
*
|
|
4972
|
+
* If `findCollisions()` returns any duplicate class names:
|
|
4973
|
+
* - **Default (`allowDuplicates: false`)** — `generateTypes` throws a
|
|
4974
|
+
* `TokenCollisionError` with a clear message listing every conflicting
|
|
4975
|
+
* file. The caller (CLI) prints it and exits non-zero. Nothing is
|
|
4976
|
+
* written to disk.
|
|
4977
|
+
* - **`allowDuplicates: true`** — colliding classes are auto-namespaced
|
|
4978
|
+
* by their relative file path so the registry keys become e.g.
|
|
4979
|
+
* `'modules/users/UserService'` instead of `'UserService'`. Non-colliding
|
|
4980
|
+
* classes still get bare `'ClassName'` keys (smart default).
|
|
4981
|
+
*
|
|
4982
|
+
* @module @forinda/kickjs-cli/typegen/generator
|
|
4983
|
+
*/
|
|
4984
|
+
/** Header written to every generated file */
|
|
4985
|
+
const HEADER = `/* eslint-disable */
|
|
4986
|
+
// AUTO-GENERATED by \`kick typegen\`. DO NOT EDIT.
|
|
4987
|
+
// Re-run with \`kick typegen\` or rely on \`kick dev\` to refresh.
|
|
4988
|
+
`;
|
|
4989
|
+
/** Decorators whose classes participate in the DI registry augmentation */
|
|
4990
|
+
const REGISTRY_DECORATORS = new Set([
|
|
4991
|
+
"Service",
|
|
4992
|
+
"Repository",
|
|
4993
|
+
"Injectable",
|
|
4994
|
+
"Component"
|
|
4995
|
+
]);
|
|
4996
|
+
/** Thrown by `generateTypes` when collisions are found and not allowed */
|
|
4997
|
+
var TokenCollisionError = class extends Error {
|
|
4998
|
+
collisions;
|
|
4999
|
+
constructor(collisions) {
|
|
5000
|
+
super(formatCollisionMessage(collisions));
|
|
5001
|
+
this.name = "TokenCollisionError";
|
|
5002
|
+
this.collisions = collisions;
|
|
5003
|
+
}
|
|
5004
|
+
};
|
|
5005
|
+
/** Build a human-readable message describing every collision */
|
|
5006
|
+
function formatCollisionMessage(collisions) {
|
|
5007
|
+
const lines = ["kick typegen: token collision detected"];
|
|
5008
|
+
for (const c of collisions) {
|
|
5009
|
+
lines.push("");
|
|
5010
|
+
lines.push(` ${c.classes.length} classes named '${c.className}':`);
|
|
5011
|
+
for (const cls of c.classes) lines.push(` - ${cls.relativePath}`);
|
|
5012
|
+
}
|
|
5013
|
+
lines.push("");
|
|
5014
|
+
lines.push("Resolutions:");
|
|
5015
|
+
lines.push(" (a) Rename one of the classes");
|
|
5016
|
+
lines.push(" (b) Use createToken<T>('namespaced/Name') and import the token explicitly — see @forinda/kickjs");
|
|
5017
|
+
lines.push(" (c) Pass --allow-duplicates to namespace the registry keys automatically");
|
|
5018
|
+
lines.push(" (e.g. 'modules/users/UserService' instead of 'UserService')");
|
|
5019
|
+
return lines.join("\n");
|
|
5020
|
+
}
|
|
5021
|
+
/** Compute the module specifier (without extension) used inside `import('...')` */
|
|
5022
|
+
function importSpecifierFor(targetFile, fromFile) {
|
|
5023
|
+
let rel = relative(dirname(fromFile), targetFile).split(sep).join("/");
|
|
5024
|
+
rel = rel.replace(/\.(ts|tsx|mts|cts)$/i, "");
|
|
5025
|
+
if (!rel.startsWith(".")) rel = "./" + rel;
|
|
5026
|
+
return rel;
|
|
5027
|
+
}
|
|
5028
|
+
/**
|
|
5029
|
+
* Build the namespaced registry key for a colliding class.
|
|
5030
|
+
* Strips the `src/` prefix and the file extension, then appends the
|
|
5031
|
+
* class name. Example: `src/modules/users/user.service.ts` + `UserService`
|
|
5032
|
+
* → `modules/users/UserService`.
|
|
5033
|
+
*/
|
|
5034
|
+
function namespacedKeyFor(cls) {
|
|
5035
|
+
const parts = cls.relativePath.replace(/^src\//, "").replace(/\.(ts|tsx|mts|cts)$/i, "").split("/");
|
|
5036
|
+
parts.pop();
|
|
5037
|
+
const ns = parts.join("/");
|
|
5038
|
+
return ns ? `${ns}/${cls.className}` : cls.className;
|
|
5039
|
+
}
|
|
5040
|
+
/**
|
|
5041
|
+
* Render the `KickJsRegistry` module augmentation. Each entry maps a
|
|
5042
|
+
* string token to the imported class type.
|
|
5043
|
+
*
|
|
5044
|
+
* Default-exported classes are imported as `import('...').default`.
|
|
5045
|
+
*
|
|
5046
|
+
* `collidingNames` lists class names that should be auto-namespaced;
|
|
5047
|
+
* everything else gets a bare key.
|
|
5048
|
+
*/
|
|
5049
|
+
function renderRegistry(classes, outFile, collidingNames) {
|
|
5050
|
+
const seen = /* @__PURE__ */ new Set();
|
|
5051
|
+
const entries = [];
|
|
5052
|
+
for (const c of classes) {
|
|
5053
|
+
if (!REGISTRY_DECORATORS.has(c.decorator)) continue;
|
|
5054
|
+
const key = collidingNames.has(c.className) ? namespacedKeyFor(c) : c.className;
|
|
5055
|
+
if (seen.has(key)) continue;
|
|
5056
|
+
seen.add(key);
|
|
5057
|
+
const spec = importSpecifierFor(c.filePath, outFile);
|
|
5058
|
+
const ref = c.isDefault ? `import('${spec}').default` : `import('${spec}').${c.className}`;
|
|
5059
|
+
entries.push(` '${key}': ${ref}`);
|
|
5060
|
+
}
|
|
5061
|
+
return `${HEADER}
|
|
5062
|
+
declare module '@forinda/kickjs' {
|
|
5063
|
+
interface KickJsRegistry {
|
|
5064
|
+
${entries.length ? entries.join("\n") : " // (no services discovered yet — run `kick g service <name>` to add one)"}
|
|
5065
|
+
}
|
|
5066
|
+
}
|
|
5067
|
+
|
|
5068
|
+
export {}
|
|
5069
|
+
`;
|
|
5070
|
+
}
|
|
5071
|
+
/** Render a string-literal union type containing the given names */
|
|
5072
|
+
function renderUnion(typeName, names, emptyComment) {
|
|
5073
|
+
if (names.length === 0) return `${HEADER}
|
|
5074
|
+
// ${emptyComment}
|
|
5075
|
+
export type ${typeName} = never
|
|
5076
|
+
`;
|
|
5077
|
+
return `${HEADER}
|
|
5078
|
+
export type ${typeName} =
|
|
5079
|
+
${[...new Set(names)].sort().map((n) => ` | '${n}'`).join("\n")}
|
|
5080
|
+
`;
|
|
5081
|
+
}
|
|
5082
|
+
/** Render the barrel index that re-exports the union types */
|
|
5083
|
+
function renderIndex(includeEnv) {
|
|
5084
|
+
return `${HEADER}
|
|
5085
|
+
export type { ServiceToken } from './services'
|
|
5086
|
+
export type { ModuleToken } from './modules'
|
|
5087
|
+
|
|
5088
|
+
// The registry, routes, and env augmentations are loaded as side-effects —
|
|
5089
|
+
// importing this file (or having it on tsconfig include) is enough for
|
|
5090
|
+
// \`container.resolve()\`, \`Ctx<KickRoutes.UserController['getUser']>\`,
|
|
5091
|
+
// and \`@Value('PORT')\` to resolve.
|
|
5092
|
+
import './registry'
|
|
5093
|
+
import './routes'
|
|
5094
|
+
${includeEnv ? "import './env'\n" : ""}`;
|
|
5095
|
+
}
|
|
5096
|
+
/**
|
|
5097
|
+
* Render the `query` field's TypeScript type for a single route.
|
|
5098
|
+
*
|
|
5099
|
+
* - When `@ApiQueryParams` is absent (`queryFilterable === null`), emits
|
|
5100
|
+
* `unknown` so the user gets nothing extra.
|
|
5101
|
+
* - When the decorator is present, emits an object literal whose keys
|
|
5102
|
+
* are the standard query string keys (`filter`, `sort`, `q`, `page`,
|
|
5103
|
+
* `limit`). `sort` is narrowed to a string-literal union of allowed
|
|
5104
|
+
* field names with optional `-` direction prefix.
|
|
5105
|
+
*/
|
|
5106
|
+
function renderQueryShape(m) {
|
|
5107
|
+
if (m.queryFilterable === null) return "unknown";
|
|
5108
|
+
const sortable = m.querySortable ?? [];
|
|
5109
|
+
return `{ filter?: string | string[]; sort?: ${sortable.length > 0 ? sortable.flatMap((f) => [`'${f}'`, `'-${f}'`]).join(" | ") : "string"}; q?: string; page?: string; limit?: string }`;
|
|
5110
|
+
}
|
|
5111
|
+
/** Render JSDoc lines summarising the @ApiQueryParams whitelist */
|
|
5112
|
+
function renderQueryDocLines(m) {
|
|
5113
|
+
const lines = [];
|
|
5114
|
+
if (m.queryFilterable && m.queryFilterable.length > 0) lines.push(`Filterable: ${m.queryFilterable.join(", ")}`);
|
|
5115
|
+
if (m.querySortable && m.querySortable.length > 0) lines.push(`Sortable: ${m.querySortable.join(", ")}`);
|
|
5116
|
+
if (m.querySearchable && m.querySearchable.length > 0) lines.push(`Searchable: ${m.querySearchable.join(", ")}`);
|
|
5117
|
+
return lines;
|
|
5118
|
+
}
|
|
5119
|
+
/**
|
|
5120
|
+
* Plan a schema import for hoisting at the top of `routes.ts`. Returns
|
|
5121
|
+
* the alias the in-namespace code should use, or `null` if the schema
|
|
5122
|
+
* cannot be referenced (no validator configured, or source unresolvable).
|
|
5123
|
+
*
|
|
5124
|
+
* Aliases are unique per (alias-counter) so two schemas named
|
|
5125
|
+
* `createTaskSchema` from different modules don't collide.
|
|
5126
|
+
*/
|
|
5127
|
+
function planSchemaImport(schema, routeFilePath, routesOutFile, schemaValidator, imports) {
|
|
5128
|
+
if (!schema || schemaValidator !== "zod") return null;
|
|
5129
|
+
if (schema.source === null) return null;
|
|
5130
|
+
const specifier = resolveSchemaImportSpecifier(schema.source, routeFilePath, routesOutFile);
|
|
5131
|
+
if (specifier === "unknown") return null;
|
|
5132
|
+
const key = `${specifier}::${schema.identifier}`;
|
|
5133
|
+
let alias = imports.get(key)?.specifier;
|
|
5134
|
+
if (!alias) {
|
|
5135
|
+
alias = `_S${imports.size}`;
|
|
5136
|
+
imports.set(key, {
|
|
5137
|
+
identifier: schema.identifier,
|
|
5138
|
+
specifier: alias
|
|
5139
|
+
});
|
|
5140
|
+
} else alias = imports.get(key).specifier;
|
|
5141
|
+
return alias;
|
|
5142
|
+
}
|
|
5143
|
+
/** Build the `import type { ... } from '...'` lines for hoisted schema imports */
|
|
5144
|
+
function renderSchemaImports(imports) {
|
|
5145
|
+
if (imports.size === 0) return "";
|
|
5146
|
+
const lines = [];
|
|
5147
|
+
for (const [key, value] of imports) {
|
|
5148
|
+
const [path] = key.split("::");
|
|
5149
|
+
lines.push(`import type { ${value.identifier} as ${value.specifier} } from '${path}'`);
|
|
5150
|
+
}
|
|
5151
|
+
return lines.join("\n") + "\n";
|
|
5152
|
+
}
|
|
5153
|
+
/**
|
|
5154
|
+
* Compute the import specifier the generated `routes.d.ts` should use to
|
|
5155
|
+
* reach a schema declared either in the controller file (empty string)
|
|
5156
|
+
* or imported from elsewhere (relative path or bare module name).
|
|
5157
|
+
*
|
|
5158
|
+
* - Bare module names (`zod`, `@scope/pkg`) are returned as-is.
|
|
5159
|
+
* - Relative paths (`./users.dto`, `../shared/schema`) are resolved
|
|
5160
|
+
* against the controller's file path, then re-relativised against the
|
|
5161
|
+
* directory containing `routes.d.ts`.
|
|
5162
|
+
* - Empty string (same-file schema) becomes a relative path from the
|
|
5163
|
+
* `routes.d.ts` directory back to the controller file.
|
|
5164
|
+
*/
|
|
5165
|
+
function resolveSchemaImportSpecifier(source, routeFilePath, routesOutFile) {
|
|
5166
|
+
if (source === null) return "unknown";
|
|
5167
|
+
const routesDir = dirname(routesOutFile);
|
|
5168
|
+
if (source === "") {
|
|
5169
|
+
let rel = relative(routesDir, routeFilePath).split(sep).join("/");
|
|
5170
|
+
rel = rel.replace(/\.(ts|tsx|mts|cts)$/i, "");
|
|
5171
|
+
if (!rel.startsWith(".")) rel = "./" + rel;
|
|
5172
|
+
return rel;
|
|
5173
|
+
}
|
|
5174
|
+
if (!source.startsWith(".") && !source.startsWith("/")) return source;
|
|
5175
|
+
let rel = relative(routesDir, resolve(dirname(routeFilePath), source)).split(sep).join("/");
|
|
5176
|
+
rel = rel.replace(/\.(ts|tsx|mts|cts)$/i, "");
|
|
5177
|
+
if (!rel.startsWith(".")) rel = "./" + rel;
|
|
5178
|
+
return rel;
|
|
5179
|
+
}
|
|
5180
|
+
/**
|
|
5181
|
+
* Render the `KickEnv` + `NodeJS.ProcessEnv` augmentation file from a
|
|
5182
|
+
* detected env schema. Mirrors the routes.ts pattern: emits as a `.ts`
|
|
5183
|
+
* file (not `.d.ts`) so the top-level `import type schema from '...'`
|
|
5184
|
+
* actually resolves under `moduleResolution: 'bundler'`.
|
|
5185
|
+
*
|
|
5186
|
+
* Returns `null` when no env file was discovered, so the caller can
|
|
5187
|
+
* skip writing the file altogether (rather than emitting an empty
|
|
5188
|
+
* augmentation that would shadow `KickEnv` to a useless `{}`).
|
|
5189
|
+
*/
|
|
5190
|
+
function renderEnv(env, envOutFile) {
|
|
5191
|
+
if (!env) return null;
|
|
5192
|
+
let rel = relative(dirname(envOutFile), env.filePath).split(sep).join("/");
|
|
5193
|
+
rel = rel.replace(/\.(ts|tsx|mts|cts)$/i, "");
|
|
5194
|
+
if (!rel.startsWith(".")) rel = "./" + rel;
|
|
5195
|
+
return `${HEADER}
|
|
5196
|
+
// Importing the schema as a type lets us infer its shape without
|
|
5197
|
+
// pulling in any runtime code. \`Awaited<>\` strips an accidental
|
|
5198
|
+
// Promise wrap on dynamic-imported defaults.
|
|
5199
|
+
import type _envSchema from '${rel}'
|
|
5200
|
+
|
|
5201
|
+
// Local type alias — interfaces can only \`extend\` an identifier,
|
|
5202
|
+
// not an inline import expression, so we resolve the schema's
|
|
5203
|
+
// inferred shape into a named type first.
|
|
5204
|
+
type _KickEnvShape = import('zod').infer<typeof _envSchema>
|
|
5205
|
+
|
|
5206
|
+
declare global {
|
|
5207
|
+
/**
|
|
5208
|
+
* Typed environment registry. Augmented from \`${env.relativePath}\`
|
|
5209
|
+
* so \`@Value('PORT')\`, \`Env<'PORT'>\`, and \`process.env.PORT\` are
|
|
5210
|
+
* all type-safe and autocomplete.
|
|
5211
|
+
*/
|
|
5212
|
+
interface KickEnv extends _KickEnvShape {}
|
|
5213
|
+
|
|
5214
|
+
// eslint-disable-next-line @typescript-eslint/no-namespace
|
|
5215
|
+
namespace NodeJS {
|
|
5216
|
+
/**
|
|
5217
|
+
* Narrow \`process.env\` so known keys exist as \`string\` (the raw
|
|
5218
|
+
* pre-Zod-coercion form). \`@Value\` and the \`ConfigService\` apply
|
|
5219
|
+
* the schema's transforms internally; access \`process.env\` directly
|
|
5220
|
+
* only when you need the raw string. Unknown keys still resolve to
|
|
5221
|
+
* \`string | undefined\` via the base @types/node declaration.
|
|
5222
|
+
*/
|
|
5223
|
+
interface ProcessEnv extends Record<keyof KickEnv, string> {}
|
|
5224
|
+
}
|
|
5225
|
+
}
|
|
5226
|
+
|
|
5227
|
+
export {}
|
|
5228
|
+
`;
|
|
5229
|
+
}
|
|
5230
|
+
/**
|
|
5231
|
+
* Render the `KickRoutes` global namespace augmentation. Each interface
|
|
5232
|
+
* inside corresponds to a controller class; each property is a single
|
|
5233
|
+
* route method on that controller, conforming to `RouteShape`.
|
|
5234
|
+
*
|
|
5235
|
+
* Fills `params` from URL patterns, `query` from `@ApiQueryParams`, and
|
|
5236
|
+
* `body`/`query`/`params` (when schema-validated) from the configured
|
|
5237
|
+
* schema validator. `response` is emitted as `unknown`.
|
|
5238
|
+
*/
|
|
5239
|
+
function renderRoutes(routes, routesOutFile, schemaValidator) {
|
|
5240
|
+
if (routes.length === 0) return `${HEADER}
|
|
5241
|
+
// (no routes discovered yet — annotate a controller method with
|
|
5242
|
+
// @Get/@Post/@Put/@Delete/@Patch and re-run \`kick typegen\`)
|
|
5243
|
+
declare global {
|
|
5244
|
+
// eslint-disable-next-line @typescript-eslint/no-namespace
|
|
5245
|
+
namespace KickRoutes {}
|
|
5246
|
+
}
|
|
5247
|
+
|
|
5248
|
+
export {}
|
|
5249
|
+
`;
|
|
5250
|
+
const byController = /* @__PURE__ */ new Map();
|
|
5251
|
+
for (const r of routes) {
|
|
5252
|
+
const arr = byController.get(r.controller) ?? [];
|
|
5253
|
+
arr.push(r);
|
|
5254
|
+
byController.set(r.controller, arr);
|
|
5255
|
+
}
|
|
5256
|
+
const schemaImports = /* @__PURE__ */ new Map();
|
|
5257
|
+
const renderField = (schema, routeFilePath) => {
|
|
5258
|
+
const alias = planSchemaImport(schema, routeFilePath, routesOutFile, schemaValidator, schemaImports);
|
|
5259
|
+
return alias ? `import('zod').infer<typeof ${alias}>` : null;
|
|
5260
|
+
};
|
|
5261
|
+
const interfaces = [];
|
|
5262
|
+
for (const [controller, methods] of byController) {
|
|
5263
|
+
const lines = [` interface ${controller} {`];
|
|
5264
|
+
for (const m of methods) {
|
|
5265
|
+
const urlParamsType = m.pathParams.length > 0 ? `{ ${m.pathParams.map((p) => `${p}: string`).join("; ")} }` : "{}";
|
|
5266
|
+
const bodySchemaType = renderField(m.bodySchema, m.filePath);
|
|
5267
|
+
const querySchemaType = renderField(m.querySchema, m.filePath);
|
|
5268
|
+
const paramsType = renderField(m.paramsSchema, m.filePath) ?? urlParamsType;
|
|
5269
|
+
const bodyType = bodySchemaType ?? "unknown";
|
|
5270
|
+
const queryType = querySchemaType ?? renderQueryShape(m);
|
|
5271
|
+
const docLines = renderQueryDocLines(m);
|
|
5272
|
+
lines.push(` /**`, ` * ${m.httpMethod} ${m.path}`, ...docLines.map((d) => ` * ${d}`), ` */`, ` ${m.method}: {`, ` params: ${paramsType}`, ` body: ${bodyType}`, ` query: ${queryType}`, ` response: unknown`, ` }`);
|
|
5273
|
+
}
|
|
5274
|
+
lines.push(" }");
|
|
5275
|
+
interfaces.push(lines.join("\n"));
|
|
5276
|
+
}
|
|
5277
|
+
return `${HEADER}${renderSchemaImports(schemaImports)}
|
|
5278
|
+
declare global {
|
|
5279
|
+
// eslint-disable-next-line @typescript-eslint/no-namespace
|
|
5280
|
+
namespace KickRoutes {
|
|
5281
|
+
${interfaces.join("\n")}
|
|
5282
|
+
}
|
|
5283
|
+
}
|
|
5284
|
+
|
|
5285
|
+
export {}
|
|
5286
|
+
`;
|
|
5287
|
+
}
|
|
5288
|
+
/** Write all generated `.d.ts` files to `outDir` */
|
|
5289
|
+
async function generateTypes(opts) {
|
|
5290
|
+
const { classes, routes = [], tokens = [], injects = [], collisions = [], env = null, outDir, allowDuplicates = false, schemaValidator = false } = opts;
|
|
5291
|
+
if (collisions.length > 0 && !allowDuplicates) throw new TokenCollisionError(collisions);
|
|
5292
|
+
await mkdir(outDir, { recursive: true });
|
|
5293
|
+
const registryFile = join(outDir, "registry.d.ts");
|
|
5294
|
+
const servicesFile = join(outDir, "services.d.ts");
|
|
5295
|
+
const modulesFile = join(outDir, "modules.d.ts");
|
|
5296
|
+
const routesFile = join(outDir, "routes.ts");
|
|
5297
|
+
const envFile = join(outDir, "env.ts");
|
|
5298
|
+
const indexFile = join(outDir, "index.d.ts");
|
|
5299
|
+
const collidingNames = new Set(collisions.map((c) => c.className));
|
|
5300
|
+
const registryContent = renderRegistry(classes, registryFile, collidingNames);
|
|
5301
|
+
const classTokens = classes.filter((c) => REGISTRY_DECORATORS.has(c.decorator)).map((c) => collidingNames.has(c.className) ? namespacedKeyFor(c) : c.className);
|
|
5302
|
+
const tokenLiterals = tokens.map((t) => t.name);
|
|
5303
|
+
const injectLiterals = injects.map((i) => i.name);
|
|
5304
|
+
const allServices = [
|
|
5305
|
+
...classTokens,
|
|
5306
|
+
...tokenLiterals,
|
|
5307
|
+
...injectLiterals
|
|
5308
|
+
];
|
|
5309
|
+
const modules = classes.filter((c) => c.decorator === "Module").map((c) => c.className);
|
|
5310
|
+
const servicesContent = renderUnion("ServiceToken", allServices, "(no tokens discovered — declare with createToken<T>() or `kick g service <name>`)");
|
|
5311
|
+
const modulesContent = renderUnion("ModuleToken", modules, "(no @Module classes discovered — `kick g module <name>` to add one)");
|
|
5312
|
+
const routesContent = renderRoutes(routes, routesFile, schemaValidator);
|
|
5313
|
+
const envContent = renderEnv(env, envFile);
|
|
5314
|
+
const indexContent = renderIndex(envContent !== null);
|
|
5315
|
+
await writeFile(registryFile, registryContent, "utf-8");
|
|
5316
|
+
await writeFile(servicesFile, servicesContent, "utf-8");
|
|
5317
|
+
await writeFile(modulesFile, modulesContent, "utf-8");
|
|
5318
|
+
await writeFile(routesFile, routesContent, "utf-8");
|
|
5319
|
+
await writeFile(indexFile, indexContent, "utf-8");
|
|
5320
|
+
const written = [
|
|
5321
|
+
registryFile,
|
|
5322
|
+
servicesFile,
|
|
5323
|
+
modulesFile,
|
|
5324
|
+
routesFile,
|
|
5325
|
+
indexFile
|
|
5326
|
+
];
|
|
5327
|
+
if (envContent) {
|
|
5328
|
+
await writeFile(envFile, envContent, "utf-8");
|
|
5329
|
+
written.push(envFile);
|
|
5330
|
+
}
|
|
5331
|
+
await writeFile(join(dirname(outDir), ".gitignore"), "# Auto-generated by kick typegen\n*\n", "utf-8");
|
|
5332
|
+
return {
|
|
5333
|
+
registryEntries: classTokens.length,
|
|
5334
|
+
serviceTokens: new Set(allServices).size,
|
|
5335
|
+
moduleTokens: modules.length,
|
|
5336
|
+
routeEntries: routes.length,
|
|
5337
|
+
envWritten: envContent !== null,
|
|
5338
|
+
written,
|
|
5339
|
+
resolvedCollisions: collisions.length
|
|
5340
|
+
};
|
|
5341
|
+
}
|
|
5342
|
+
//#endregion
|
|
5343
|
+
//#region src/typegen/index.ts
|
|
5344
|
+
/**
|
|
5345
|
+
* Public entry point for the KickJS typegen module.
|
|
5346
|
+
*
|
|
5347
|
+
* Used by:
|
|
5348
|
+
* - `kick typegen` (one-shot or watch mode)
|
|
5349
|
+
* - `kick dev` (auto-runs once before Vite starts; refreshes when files change)
|
|
5350
|
+
*
|
|
5351
|
+
* @module @forinda/kickjs-cli/typegen
|
|
5352
|
+
*/
|
|
5353
|
+
var typegen_exports = /* @__PURE__ */ __exportAll({
|
|
5354
|
+
runTypegen: () => runTypegen,
|
|
5355
|
+
watchTypegen: () => watchTypegen
|
|
5356
|
+
});
|
|
5357
|
+
/** Resolve options to absolute paths */
|
|
5358
|
+
function resolveOptions(opts) {
|
|
5359
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
5360
|
+
return {
|
|
5361
|
+
cwd,
|
|
5362
|
+
srcDir: resolve(cwd, opts.srcDir ?? "src"),
|
|
5363
|
+
outDir: resolve(cwd, opts.outDir ?? ".kickjs/types"),
|
|
5364
|
+
silent: opts.silent ?? false,
|
|
5365
|
+
allowDuplicates: opts.allowDuplicates ?? false,
|
|
5366
|
+
schemaValidator: opts.schemaValidator ?? false,
|
|
5367
|
+
envFile: opts.envFile ?? "src/env.ts"
|
|
5368
|
+
};
|
|
5369
|
+
}
|
|
5370
|
+
/**
|
|
5371
|
+
* Run a single typegen pass: scan source files, generate `.d.ts` files.
|
|
5372
|
+
*
|
|
5373
|
+
* Returns the discovered scan result alongside the generation result so
|
|
5374
|
+
* callers (`kick dev`, devtools) can log them or feed them to other tools.
|
|
5375
|
+
*
|
|
5376
|
+
* Throws `TokenCollisionError` if duplicate class names are found and
|
|
5377
|
+
* `allowDuplicates` is false.
|
|
5378
|
+
*/
|
|
5379
|
+
async function runTypegen(opts = {}) {
|
|
5380
|
+
const { cwd, srcDir, outDir, silent, allowDuplicates, schemaValidator, envFile } = resolveOptions(opts);
|
|
5381
|
+
const start = Date.now();
|
|
5382
|
+
const scan = await scanProject({
|
|
5383
|
+
root: srcDir,
|
|
5384
|
+
cwd,
|
|
5385
|
+
envFile: envFile === false ? void 0 : envFile
|
|
5386
|
+
});
|
|
5387
|
+
const result = await generateTypes({
|
|
5388
|
+
classes: scan.classes,
|
|
5389
|
+
routes: scan.routes,
|
|
5390
|
+
tokens: scan.tokens,
|
|
5391
|
+
injects: scan.injects,
|
|
5392
|
+
collisions: scan.collisions,
|
|
5393
|
+
env: envFile === false ? null : scan.env,
|
|
5394
|
+
outDir,
|
|
5395
|
+
allowDuplicates,
|
|
5396
|
+
schemaValidator
|
|
5397
|
+
});
|
|
5398
|
+
const elapsed = Date.now() - start;
|
|
5399
|
+
if (!silent) {
|
|
5400
|
+
const where = outDir.replace(cwd + "/", "");
|
|
5401
|
+
const collisionNote = result.resolvedCollisions > 0 ? `, ${result.resolvedCollisions} collisions namespaced` : "";
|
|
5402
|
+
const envNote = result.envWritten ? ", env typed" : "";
|
|
5403
|
+
console.log(` kick typegen → ${result.serviceTokens} services, ${result.routeEntries} routes, ${result.moduleTokens} modules${envNote}${collisionNote} → ${where} (${elapsed}ms)`);
|
|
5404
|
+
}
|
|
5405
|
+
return {
|
|
5406
|
+
scan,
|
|
5407
|
+
result
|
|
5408
|
+
};
|
|
5409
|
+
}
|
|
5410
|
+
/**
|
|
5411
|
+
* Watch mode for `kick typegen --watch`.
|
|
5412
|
+
*
|
|
5413
|
+
* Uses Node's built-in `fs.watch` (recursive, available on Linux 22+ and
|
|
5414
|
+
* macOS 19+). Falls back gracefully if recursive watch is not supported.
|
|
5415
|
+
*
|
|
5416
|
+
* Debounces re-runs by 100ms so a bulk file change (e.g. `kick g module`
|
|
5417
|
+
* creating 5 files at once) emits one regen, not five.
|
|
5418
|
+
*
|
|
5419
|
+
* In watch mode collisions are reported but never thrown — the watcher
|
|
5420
|
+
* keeps running so the user can fix the rename and the next scan
|
|
5421
|
+
* recovers automatically.
|
|
5422
|
+
*
|
|
5423
|
+
* Returns a `stop()` function that closes the watcher.
|
|
5424
|
+
*/
|
|
5425
|
+
async function watchTypegen(opts = {}) {
|
|
5426
|
+
const resolved = resolveOptions(opts);
|
|
5427
|
+
const { srcDir, silent } = resolved;
|
|
5428
|
+
const runOpts = {
|
|
5429
|
+
...resolved,
|
|
5430
|
+
allowDuplicates: true
|
|
5431
|
+
};
|
|
5432
|
+
await safeRun(runOpts, silent);
|
|
5433
|
+
const { watch } = await import("node:fs");
|
|
5434
|
+
let timer = null;
|
|
5435
|
+
const trigger = (filename) => {
|
|
5436
|
+
if (!filename) return;
|
|
5437
|
+
if (!/\.(ts|tsx|mts|cts)$/.test(filename)) return;
|
|
5438
|
+
if (filename.includes(".kickjs")) return;
|
|
5439
|
+
if (filename.endsWith(".d.ts")) return;
|
|
5440
|
+
if (timer) clearTimeout(timer);
|
|
5441
|
+
timer = setTimeout(() => {
|
|
5442
|
+
safeRun(runOpts, silent);
|
|
5443
|
+
}, 100);
|
|
5444
|
+
};
|
|
5445
|
+
let watcher;
|
|
5446
|
+
try {
|
|
5447
|
+
watcher = watch(srcDir, { recursive: true }, (_event, filename) => {
|
|
5448
|
+
trigger(filename);
|
|
5449
|
+
});
|
|
5450
|
+
} catch (err) {
|
|
5451
|
+
if (!silent) console.warn(` kick typegen: watch mode unavailable (${err?.message ?? err}). Falling back to polling.`);
|
|
5452
|
+
const interval = setInterval(() => {
|
|
5453
|
+
safeRun({
|
|
5454
|
+
...runOpts,
|
|
5455
|
+
silent: true
|
|
5456
|
+
}, true);
|
|
5457
|
+
}, 2e3);
|
|
5458
|
+
return () => clearInterval(interval);
|
|
5459
|
+
}
|
|
5460
|
+
return () => {
|
|
5461
|
+
if (timer) clearTimeout(timer);
|
|
5462
|
+
watcher.close();
|
|
5463
|
+
};
|
|
5464
|
+
}
|
|
5465
|
+
/** Run typegen swallowing errors so the watcher loop never dies */
|
|
5466
|
+
async function safeRun(opts, silent) {
|
|
5467
|
+
try {
|
|
5468
|
+
await runTypegen(opts);
|
|
5469
|
+
} catch (err) {
|
|
5470
|
+
if (silent) return;
|
|
5471
|
+
if (err instanceof TokenCollisionError) console.error("\n" + err.message + "\n");
|
|
5472
|
+
else {
|
|
5473
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
5474
|
+
console.error(` kick typegen failed: ${msg}`);
|
|
5475
|
+
}
|
|
5476
|
+
}
|
|
5477
|
+
}
|
|
5478
|
+
//#endregion
|
|
4403
5479
|
//#region src/commands/generate.ts
|
|
4404
5480
|
/** Check if --dry-run was passed on the parent generate command */
|
|
4405
5481
|
function isDryRun(cmd) {
|
|
@@ -4412,6 +5488,29 @@ function printGenerated(files, dryRun = false) {
|
|
|
4412
5488
|
if (dryRun) console.log("\n (dry run — no files were written)");
|
|
4413
5489
|
console.log();
|
|
4414
5490
|
}
|
|
5491
|
+
/**
|
|
5492
|
+
* Refresh `.kickjs/types/*` after a generator that emitted controllers,
|
|
5493
|
+
* so the new `Ctx<KickRoutes.X['method']>` references resolve in the
|
|
5494
|
+
* user's editor without waiting for `kick dev`.
|
|
5495
|
+
*
|
|
5496
|
+
* Loads `kick.config.ts` for `typegen.schemaValidator`. Failures are
|
|
5497
|
+
* non-fatal — typegen problems should never block code generation.
|
|
5498
|
+
*/
|
|
5499
|
+
async function runPostTypegen(dryRun) {
|
|
5500
|
+
if (dryRun) return;
|
|
5501
|
+
try {
|
|
5502
|
+
const cfg = await loadKickConfig(process.cwd());
|
|
5503
|
+
await runTypegen({
|
|
5504
|
+
cwd: process.cwd(),
|
|
5505
|
+
allowDuplicates: true,
|
|
5506
|
+
silent: true,
|
|
5507
|
+
schemaValidator: cfg?.typegen?.schemaValidator ?? "zod",
|
|
5508
|
+
envFile: cfg?.typegen?.envFile,
|
|
5509
|
+
srcDir: cfg?.typegen?.srcDir,
|
|
5510
|
+
outDir: cfg?.typegen?.outDir
|
|
5511
|
+
});
|
|
5512
|
+
} catch {}
|
|
5513
|
+
}
|
|
4415
5514
|
const GENERATORS = [
|
|
4416
5515
|
{
|
|
4417
5516
|
name: "module <name>",
|
|
@@ -4500,6 +5599,7 @@ function registerGenerateCommand(program) {
|
|
|
4500
5599
|
allFiles.push(...files);
|
|
4501
5600
|
}
|
|
4502
5601
|
printGenerated(allFiles, dryRun);
|
|
5602
|
+
await runPostTypegen(dryRun);
|
|
4503
5603
|
});
|
|
4504
5604
|
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, cmd) => {
|
|
4505
5605
|
const dryRun = isDryRun(cmd);
|
|
@@ -4560,6 +5660,7 @@ function registerGenerateCommand(program) {
|
|
|
4560
5660
|
modulesDir,
|
|
4561
5661
|
pattern: config?.pattern
|
|
4562
5662
|
}), dryRun);
|
|
5663
|
+
await runPostTypegen(dryRun);
|
|
4563
5664
|
});
|
|
4564
5665
|
gen.command("dto <name>").description("Generate a Zod DTO schema\n Use -m to scope it to a module: kick g dto create-user -m users").option("-o, --out <dir>", "Output directory (overrides --module)").option("-m, --module <module>", "Place inside a module folder").action(async (name, opts, cmd) => {
|
|
4565
5666
|
const dryRun = isDryRun(cmd);
|
|
@@ -4623,6 +5724,7 @@ function registerGenerateCommand(program) {
|
|
|
4623
5724
|
console.log(`\n Scaffolded ${name} with ${fields.length} field(s):`);
|
|
4624
5725
|
for (const f of fields) console.log(` ${f.name}: ${f.type}${f.optional ? " (optional)" : ""}`);
|
|
4625
5726
|
printGenerated(files, dryRun);
|
|
5727
|
+
await runPostTypegen(dryRun);
|
|
4626
5728
|
});
|
|
4627
5729
|
gen.command("config").description("Generate a kick.config.ts at the project root").option("--modules-dir <dir>", "Modules directory path", "src/modules").option("--repo <type>", "Default repository type: inmemory | drizzle | prisma", "inmemory").option("-f, --force", "Overwrite existing kick.config.ts without prompting").action(async (opts, cmd) => {
|
|
4628
5730
|
const dryRun = isDryRun(cmd);
|
|
@@ -4661,16 +5763,54 @@ function runShellCommand(command, cwd) {
|
|
|
4661
5763
|
*/
|
|
4662
5764
|
async function startDevServer(_entry, port) {
|
|
4663
5765
|
if (port) process.env.PORT = port;
|
|
5766
|
+
const cwd = process.cwd();
|
|
5767
|
+
const devConfig = await loadKickConfig(cwd);
|
|
5768
|
+
const schemaValidator = devConfig?.typegen?.schemaValidator ?? "zod";
|
|
5769
|
+
const envFile = devConfig?.typegen?.envFile;
|
|
5770
|
+
try {
|
|
5771
|
+
await runTypegen({
|
|
5772
|
+
cwd,
|
|
5773
|
+
allowDuplicates: true,
|
|
5774
|
+
schemaValidator,
|
|
5775
|
+
envFile,
|
|
5776
|
+
srcDir: devConfig?.typegen?.srcDir,
|
|
5777
|
+
outDir: devConfig?.typegen?.outDir
|
|
5778
|
+
});
|
|
5779
|
+
} catch (err) {
|
|
5780
|
+
console.warn(` kick typegen: skipped (${err?.message ?? err})`);
|
|
5781
|
+
}
|
|
4664
5782
|
const { createRequire } = await import("node:module");
|
|
4665
5783
|
const { createServer } = await import(createRequire(resolve("package.json")).resolve("vite"));
|
|
4666
5784
|
const server = await createServer({
|
|
4667
5785
|
configFile: resolve("vite.config.ts"),
|
|
4668
5786
|
server: { port: port ? parseInt(port, 10) : void 0 }
|
|
4669
5787
|
});
|
|
5788
|
+
let typegenTimer = null;
|
|
5789
|
+
const scheduleTypegen = (file) => {
|
|
5790
|
+
if (!/\.(ts|tsx|mts|cts)$/.test(file)) return;
|
|
5791
|
+
if (file.includes(".kickjs")) return;
|
|
5792
|
+
if (file.endsWith(".d.ts")) return;
|
|
5793
|
+
if (typegenTimer) clearTimeout(typegenTimer);
|
|
5794
|
+
typegenTimer = setTimeout(() => {
|
|
5795
|
+
runTypegen({
|
|
5796
|
+
cwd,
|
|
5797
|
+
silent: true,
|
|
5798
|
+
allowDuplicates: true,
|
|
5799
|
+
schemaValidator,
|
|
5800
|
+
envFile,
|
|
5801
|
+
srcDir: devConfig?.typegen?.srcDir,
|
|
5802
|
+
outDir: devConfig?.typegen?.outDir
|
|
5803
|
+
}).catch(() => {});
|
|
5804
|
+
}, 100);
|
|
5805
|
+
};
|
|
5806
|
+
server.watcher.on("add", scheduleTypegen);
|
|
5807
|
+
server.watcher.on("unlink", scheduleTypegen);
|
|
5808
|
+
server.watcher.on("change", scheduleTypegen);
|
|
4670
5809
|
await server.listen();
|
|
4671
5810
|
server.printUrls();
|
|
4672
5811
|
console.log(`\n KickJS dev server running (Vite + @forinda/kickjs-vite)\n`);
|
|
4673
5812
|
const shutdown = async () => {
|
|
5813
|
+
if (typegenTimer) clearTimeout(typegenTimer);
|
|
4674
5814
|
await server.close();
|
|
4675
5815
|
process.exit(0);
|
|
4676
5816
|
};
|
|
@@ -5323,6 +6463,64 @@ function registerRemoveCommand(program) {
|
|
|
5323
6463
|
});
|
|
5324
6464
|
}
|
|
5325
6465
|
//#endregion
|
|
6466
|
+
//#region src/commands/typegen.ts
|
|
6467
|
+
/**
|
|
6468
|
+
* Parse the `--schema-validator` CLI flag. Returns `undefined` if the
|
|
6469
|
+
* flag was not passed (so the config default applies), `'zod'` if a
|
|
6470
|
+
* supported value was passed, or `false` if explicitly disabled.
|
|
6471
|
+
*/
|
|
6472
|
+
function parseSchemaValidatorFlag(value) {
|
|
6473
|
+
if (value === void 0) return void 0;
|
|
6474
|
+
if (value === "false" || value === "off" || value === "none") return false;
|
|
6475
|
+
if (value === "zod") return "zod";
|
|
6476
|
+
console.warn(` kick typegen: unknown --schema-validator '${value}' (only 'zod' and 'false' are supported). Falling back to project config.`);
|
|
6477
|
+
}
|
|
6478
|
+
/**
|
|
6479
|
+
* Parse the `--env-file` CLI flag. Returns `undefined` to fall through
|
|
6480
|
+
* to the config default, `false` when the user disables env typing
|
|
6481
|
+
* with `--env-file false`, or the literal path string otherwise.
|
|
6482
|
+
*/
|
|
6483
|
+
function parseEnvFileFlag(value) {
|
|
6484
|
+
if (value === void 0) return void 0;
|
|
6485
|
+
if (value === "false" || value === "off" || value === "none") return false;
|
|
6486
|
+
return value;
|
|
6487
|
+
}
|
|
6488
|
+
function registerTypegenCommand(program) {
|
|
6489
|
+
program.command("typegen").description("Generate type-safe DI registry and module types into .kickjs/types/").option("-w, --watch", "Watch source files and regenerate on change").option("-s, --src <dir>", "Source directory to scan", "src").option("-o, --out <dir>", "Output directory", ".kickjs/types").option("--silent", "Suppress output").option("--allow-duplicates", "Auto-namespace duplicate class names instead of failing (use with caution)").option("--schema-validator <name>", "Schema validator for body/query/params typing (currently 'zod' or 'false')").option("--env-file <path>", "Path to env schema file for KickEnv typing (default 'src/env.ts'; pass 'false' to disable)").action(async (opts) => {
|
|
6490
|
+
const cwd = process.cwd();
|
|
6491
|
+
const config = await loadKickConfig(cwd);
|
|
6492
|
+
const schemaValidator = parseSchemaValidatorFlag(opts.schemaValidator) ?? config?.typegen?.schemaValidator ?? "zod";
|
|
6493
|
+
const envFile = parseEnvFileFlag(opts.envFile) ?? config?.typegen?.envFile;
|
|
6494
|
+
const baseOpts = {
|
|
6495
|
+
cwd,
|
|
6496
|
+
srcDir: opts.src ?? config?.typegen?.srcDir,
|
|
6497
|
+
outDir: opts.out ?? config?.typegen?.outDir,
|
|
6498
|
+
silent: opts.silent,
|
|
6499
|
+
allowDuplicates: opts.allowDuplicates,
|
|
6500
|
+
schemaValidator,
|
|
6501
|
+
envFile
|
|
6502
|
+
};
|
|
6503
|
+
try {
|
|
6504
|
+
if (opts.watch) {
|
|
6505
|
+
const stop = await watchTypegen(baseOpts);
|
|
6506
|
+
if (!opts.silent) console.log(" kick typegen: watching for changes (Ctrl-C to exit)");
|
|
6507
|
+
const shutdown = () => {
|
|
6508
|
+
stop();
|
|
6509
|
+
process.exit(0);
|
|
6510
|
+
};
|
|
6511
|
+
process.on("SIGINT", shutdown);
|
|
6512
|
+
process.on("SIGTERM", shutdown);
|
|
6513
|
+
await new Promise(() => {});
|
|
6514
|
+
} else await runTypegen(baseOpts);
|
|
6515
|
+
} catch (err) {
|
|
6516
|
+
if (err instanceof TokenCollisionError) console.error("\n" + err.message + "\n");
|
|
6517
|
+
else if (err instanceof Error) console.error(`\n kick typegen failed: ${err.message}`);
|
|
6518
|
+
else console.error(`\n kick typegen failed: ${JSON.stringify(err)}`);
|
|
6519
|
+
process.exit(1);
|
|
6520
|
+
}
|
|
6521
|
+
});
|
|
6522
|
+
}
|
|
6523
|
+
//#endregion
|
|
5326
6524
|
//#region src/cli.ts
|
|
5327
6525
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
5328
6526
|
const pkg = JSON.parse(readFileSync(join(__dirname, "..", "package.json"), "utf-8"));
|
|
@@ -5339,6 +6537,7 @@ async function main() {
|
|
|
5339
6537
|
registerListCommand(program);
|
|
5340
6538
|
registerTinkerCommand(program);
|
|
5341
6539
|
registerRemoveCommand(program);
|
|
6540
|
+
registerTypegenCommand(program);
|
|
5342
6541
|
registerCustomCommands(program, config);
|
|
5343
6542
|
program.showHelpAfterError();
|
|
5344
6543
|
await program.parseAsync(process.argv);
|