@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 CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * @forinda/kickjs-cli v2.1.0
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: ["src"]
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 RequestContext } from '@forinda/kickjs'
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: RequestContext) {
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: RequestContext) {
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, RequestContext } from '@forinda/kickjs'
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: RequestContext) {
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: RequestContext) {
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: RequestContext\`:
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: RequestContext) {
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: RequestContext) {
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: RequestContext) {
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: RequestContext) {
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: RequestContext) {
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, plural = "", pluralPascal = "" } = ctx;
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: RequestContext) {
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: RequestContext) {
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: RequestContext) {
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: RequestContext) {
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: RequestContext) {
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
- export const ${pascal.toUpperCase()}_REPOSITORY = Symbol('I${pascal}Repository')
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: RequestContext) {
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: RequestContext) {
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: RequestContext) {
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: RequestContext) {
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: RequestContext) {
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
- import type { RequestContext } from '@forinda/kickjs'
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: RequestContext) {
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, Autowired } from '@forinda/kickjs'
3571
- import type { RequestContext } from '@forinda/kickjs'
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: RequestContext) {
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: RequestContext) {
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, repo));
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, repo) {
4104
- return `import type { AppModule, AppModuleClass } from '@forinda/kickjs'
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
- register(container: any): void {
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 { prefix: '/${plural}', controllers: [${pascal}Controller] }
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: RequestContext) {
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: RequestContext) {
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: RequestContext) {
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: RequestContext) {
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: RequestContext) {
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 type { ${pascal}ResponseDTO } from '../../application/dtos/${kebab}-response.dto'
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
- export const ${pascal.toUpperCase()}_REPOSITORY = Symbol('I${pascal}Repository')
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);