@forinda/kickjs-cli 3.2.0 → 4.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * @forinda/kickjs-cli v3.2.0
2
+ * @forinda/kickjs-cli v4.1.0
3
3
  *
4
4
  * Copyright (c) Felix Orinda
5
5
  *
@@ -8,15 +8,17 @@
8
8
  *
9
9
  * @license MIT
10
10
  */
11
+ import { createRequire } from "node:module";
11
12
  import { Command } from "commander";
12
- import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
13
- import { basename, dirname, join, relative, resolve, sep } from "node:path";
13
+ import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
14
+ import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep } from "node:path";
14
15
  import { fileURLToPath, pathToFileURL } from "node:url";
15
16
  import { execSync, fork, spawn, spawnSync } from "node:child_process";
16
17
  import { access, mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises";
17
18
  import * as clack from "@clack/prompts";
18
19
  import pc from "picocolors";
19
20
  import pkg from "pluralize";
21
+ import { glob, globSync } from "glob";
20
22
  import { arch, platform, release } from "node:os";
21
23
  //#region \0rolldown/runtime.js
22
24
  var __defProp = Object.defineProperty;
@@ -36,11 +38,60 @@ let _dryRun = false;
36
38
  function setDryRun(enabled) {
37
39
  _dryRun = enabled;
38
40
  }
39
- /** Write a file, creating parent directories if needed. Skips writing in dry run mode. */
41
+ /** Extensions prettier can format. Anything else is written verbatim. */
42
+ const FORMATTABLE = new Set([
43
+ ".ts",
44
+ ".tsx",
45
+ ".js",
46
+ ".jsx",
47
+ ".mjs",
48
+ ".cjs",
49
+ ".json",
50
+ ".md"
51
+ ]);
52
+ /**
53
+ * Write a file, creating parent directories if needed.
54
+ *
55
+ * After write, runs prettier against the file when:
56
+ * - format-on-write is enabled (default)
57
+ * - the extension is in {@link FORMATTABLE}
58
+ * - prettier resolves from the user's project (or our own cwd)
59
+ *
60
+ * Failures (missing prettier, unparseable source, prettier crash) are
61
+ * swallowed silently — formatting is a polish step, not a correctness
62
+ * gate. The pre-existing pre-commit hook still catches anything we
63
+ * couldn't format.
64
+ *
65
+ * Skips writing entirely in dry run mode.
66
+ */
40
67
  async function writeFileSafe(filePath, content) {
41
68
  if (_dryRun) return;
42
69
  await mkdir(dirname(filePath), { recursive: true });
43
70
  await writeFile(filePath, content, "utf-8");
71
+ if (FORMATTABLE.has(extname(filePath))) await formatFile(filePath, content).catch(() => {});
72
+ }
73
+ let _prettier = void 0;
74
+ /** Resolve prettier from the user's project; cache the result (or null) for the process. */
75
+ function resolvePrettier(cwd) {
76
+ if (_prettier !== void 0) return _prettier;
77
+ try {
78
+ _prettier = createRequire(join(cwd, "package.json"))("prettier");
79
+ } catch {
80
+ _prettier = null;
81
+ }
82
+ return _prettier;
83
+ }
84
+ async function formatFile(filePath, content) {
85
+ const prettier = resolvePrettier(process.cwd());
86
+ if (!prettier) return;
87
+ if ((await prettier.getFileInfo(filePath, { resolveConfig: true })).ignored) return;
88
+ const config = await prettier.resolveConfig(filePath) ?? {};
89
+ const formatted = await prettier.format(content, {
90
+ ...config,
91
+ filepath: filePath
92
+ });
93
+ if (formatted === content) return;
94
+ await writeFile(filePath, formatted, "utf-8");
44
95
  }
45
96
  /** Check if a file exists */
46
97
  async function fileExists(filePath) {
@@ -113,7 +164,7 @@ function generatePackageJson(name, template, kickjsVersion, packages = []) {
113
164
  "unplugin-swc": "^1.5.9",
114
165
  vite: "^8.0.3",
115
166
  vitest: "^4.1.2",
116
- typescript: "^5.9.2",
167
+ typescript: "^6.0.3",
117
168
  prettier: "^3.8.1"
118
169
  }
119
170
  }, null, 2);
@@ -296,15 +347,15 @@ function generateEntryFile(name, template, version, packages = []) {
296
347
  const gqlAdapters = [];
297
348
  if (packages.includes("devtools")) {
298
349
  gqlImports.push(`import { DevToolsAdapter } from '@forinda/kickjs-devtools'`);
299
- gqlAdapters.push(` new DevToolsAdapter(),`);
350
+ gqlAdapters.push(` DevToolsAdapter(),`);
300
351
  }
301
352
  if (packages.includes("otel")) {
302
353
  gqlImports.push(`import { OtelAdapter } from '@forinda/kickjs-otel'`);
303
- gqlAdapters.push(` new OtelAdapter({ serviceName: '${name}' }),`);
354
+ gqlAdapters.push(` OtelAdapter({ serviceName: '${name}' }),`);
304
355
  }
305
356
  if (packages.includes("swagger")) {
306
357
  gqlImports.push(`import { SwaggerAdapter } from '@forinda/kickjs-swagger'`);
307
- gqlAdapters.push(` new SwaggerAdapter({ info: { title: '${name}', version: '${version}' } }),`);
358
+ gqlAdapters.push(` SwaggerAdapter({ info: { title: '${name}', version: '${version}' } }),`);
308
359
  }
309
360
  return `import 'reflect-metadata'
310
361
  // Side-effect import — registers the extended env schema with kickjs
@@ -337,15 +388,15 @@ ${gqlAdapters.length ? gqlAdapters.join("\n") + "\n" : ""} new GraphQLAdapter
337
388
  const cqrsAdapters = [];
338
389
  if (packages.includes("otel")) {
339
390
  cqrsImports.push(`import { OtelAdapter } from '@forinda/kickjs-otel'`);
340
- cqrsAdapters.push(` new OtelAdapter({ serviceName: '${name}' }),`);
391
+ cqrsAdapters.push(` OtelAdapter({ serviceName: '${name}' }),`);
341
392
  }
342
393
  if (packages.includes("devtools")) {
343
394
  cqrsImports.push(`import { DevToolsAdapter } from '@forinda/kickjs-devtools'`);
344
- cqrsAdapters.push(` new DevToolsAdapter(),`);
395
+ cqrsAdapters.push(` DevToolsAdapter(),`);
345
396
  }
346
397
  if (packages.includes("swagger")) {
347
398
  cqrsImports.push(`import { SwaggerAdapter } from '@forinda/kickjs-swagger'`);
348
- cqrsAdapters.push(` new SwaggerAdapter({\n info: { title: '${name}', version: '${version}' },\n }),`);
399
+ cqrsAdapters.push(` SwaggerAdapter({\n info: { title: '${name}', version: '${version}' },\n }),`);
349
400
  }
350
401
  if (packages.includes("graphql")) {
351
402
  cqrsImports.push(`import { GraphQLAdapter } from '@forinda/kickjs-graphql'`);
@@ -364,7 +415,7 @@ ${cqrsImports.length ? cqrsImports.join("\n") + "\n" : ""}import { modules } fro
364
415
 
365
416
  // Export the app for the Vite plugin (dev mode)
366
417
  export const app = await bootstrap({
367
- modules,${cqrsImports.length ? `\n adapters: [\n${cqrsAdapters.join("\n")}\n // Uncomment for WebSocket support:\n // new WsAdapter(),\n // Uncomment when Redis is available:\n // new QueueAdapter({\n // provider: new BullMQProvider({ host: 'localhost', port: 6379 }),\n // }),\n ],` : `\n adapters: [\n // Uncomment for WebSocket support:\n // new WsAdapter(),\n // Uncomment when Redis is available:\n // new QueueAdapter({\n // provider: new BullMQProvider({ host: 'localhost', port: 6379 }),\n // }),\n ],`}
418
+ modules,${cqrsImports.length ? `\n adapters: [\n${cqrsAdapters.join("\n")}\n // Uncomment for WebSocket support:\n // WsAdapter(),\n // Uncomment when Redis is available:\n // QueueAdapter({\n // provider: new BullMQProvider({ host: 'localhost', port: 6379 }),\n // }),\n ],` : `\n adapters: [\n // Uncomment for WebSocket support:\n // WsAdapter(),\n // Uncomment when Redis is available:\n // QueueAdapter({\n // provider: new BullMQProvider({ host: 'localhost', port: 6379 }),\n // }),\n ],`}
368
419
  })
369
420
  `;
370
421
  }
@@ -373,15 +424,15 @@ export const app = await bootstrap({
373
424
  const adapters = [];
374
425
  if (packages.includes("swagger")) {
375
426
  imports.push(`import { SwaggerAdapter } from '@forinda/kickjs-swagger'`);
376
- adapters.push(` new SwaggerAdapter({ info: { title: '${name}', version: '${version}' } }),`);
427
+ adapters.push(` SwaggerAdapter({ info: { title: '${name}', version: '${version}' } }),`);
377
428
  }
378
429
  if (packages.includes("devtools")) {
379
430
  imports.push(`import { DevToolsAdapter } from '@forinda/kickjs-devtools'`);
380
- adapters.push(` new DevToolsAdapter(),`);
431
+ adapters.push(` DevToolsAdapter(),`);
381
432
  }
382
433
  if (packages.includes("otel")) {
383
434
  imports.push(`import { OtelAdapter } from '@forinda/kickjs-otel'`);
384
- adapters.push(` new OtelAdapter({ serviceName: '${name}' }),`);
435
+ adapters.push(` OtelAdapter({ serviceName: '${name}' }),`);
385
436
  }
386
437
  if (packages.includes("graphql")) {
387
438
  imports.push(`import { GraphQLAdapter } from '@forinda/kickjs-graphql'`);
@@ -405,15 +456,15 @@ export const app = await bootstrap({ modules${adapters.length ? `,\n adapters:
405
456
  const restAdapters = [];
406
457
  if (packages.includes("devtools")) {
407
458
  restImports.push(`import { DevToolsAdapter } from '@forinda/kickjs-devtools'`);
408
- restAdapters.push(` new DevToolsAdapter(),`);
459
+ restAdapters.push(` DevToolsAdapter(),`);
409
460
  }
410
461
  if (packages.includes("swagger")) {
411
462
  restImports.push(`import { SwaggerAdapter } from '@forinda/kickjs-swagger'`);
412
- restAdapters.push(` new SwaggerAdapter({\n info: { title: '${name}', version: '${version}' },\n }),`);
463
+ restAdapters.push(` SwaggerAdapter({\n info: { title: '${name}', version: '${version}' },\n }),`);
413
464
  }
414
465
  if (packages.includes("otel")) {
415
466
  restImports.push(`import { OtelAdapter } from '@forinda/kickjs-otel'`);
416
- restAdapters.push(` new OtelAdapter({ serviceName: '${name}' }),`);
467
+ restAdapters.push(` OtelAdapter({ serviceName: '${name}' }),`);
417
468
  }
418
469
  return `import 'reflect-metadata'
419
470
  // Side-effect import — registers the extended env schema with kickjs
@@ -707,404 +758,223 @@ Copy \`.env.example\` to \`.env\` and configure:
707
758
  - [CLI Reference](https://forinda.github.io/kick-js/api/cli.html)
708
759
  `;
709
760
  }
710
- /** Generate CLAUDE.md with AI development guide */
711
- function generateClaude(name, template, pm) {
712
- return `# CLAUDE.md — ${name} Development Guide
713
-
714
- > **Read \`AGENTS.md\` first.** It is the canonical, multi-agent reference for this project (Claude, Copilot, Codex, Gemini, etc.). This file contains the same project context distilled for Claude, plus Claude-specific notes. When the two disagree on anything substantive, treat \`AGENTS.md\` as authoritative and flag the discrepancy.
715
-
716
- ## Project Overview
717
-
718
- This is a **${{
719
- rest: "REST API",
720
- graphql: "GraphQL API",
721
- ddd: "Domain-Driven Design",
722
- cqrs: "CQRS + Event-Driven",
723
- minimal: "Minimal Express"
724
- }[template] ?? "REST API"}** application built with [KickJS](https://forinda.github.io/kick-js/) — a decorator-driven Node.js framework on Express 5 and TypeScript.
725
-
726
- ## Quick Commands
727
-
728
- \`\`\`bash
729
- ${pm} install # Install dependencies
730
- kick dev # Start dev server with HMR
731
- kick build # Production build via Vite
732
- kick start # Run production build
733
- ${pm} run test # Run tests with Vitest
734
- ${pm} run typecheck # TypeScript type checking
735
- ${pm} run format # Format code with Prettier
736
- \`\`\`
737
-
738
- ## Project Structure
739
-
740
- \`\`\`
741
- src/
742
- ├── index.ts # Application bootstrap
743
- ├── modules/ # Feature modules (DDD/CQRS pattern)
744
- │ └── index.ts # Module registry
745
- ${template === "graphql" ? "├── resolvers/ # GraphQL resolvers\n" : ""}└── ...
746
- \`\`\`
747
-
748
- ## Package Manager
749
-
750
- - Always use **${pm}** for this project
751
- - Run \`${pm} install\` to sync dependencies
752
- - Never mix package managers (npm/yarn/pnpm)
753
-
754
- ## Code Style
755
-
756
- - **Prettier** no semicolons, single quotes, trailing commas, 100 char width
757
- - **TypeScript strict mode** — all types required
758
- - Format before committing: \`${pm} run format\`
759
- - Type check with: \`${pm} run typecheck\`
760
-
761
- ## Key Patterns
762
-
763
- ### Controllers
764
-
765
- Use decorators to define routes. Annotate \`ctx\` with \`Ctx<KickRoutes.X['method']>\`
766
- to get fully-typed \`ctx.params\`, \`ctx.body\`, and \`ctx.query\` from the
767
- generated \`KickRoutes\` namespace (refreshed on \`kick dev\` and \`kick typegen\`).
768
-
769
- \`\`\`ts
770
- import { Controller, Get, Post, type Ctx } from '@forinda/kickjs'
771
-
772
- @Controller('/users')
773
- export class UserController {
774
- @Get('/')
775
- async findAll(ctx: Ctx<KickRoutes.UserController['findAll']>) {
776
- return ctx.json({ users: [] })
777
- }
778
-
779
- @Post('/')
780
- async create(ctx: Ctx<KickRoutes.UserController['create']>) {
781
- const data = ctx.body
782
- return ctx.created({ user: data })
783
- }
784
- }
785
- \`\`\`
786
-
787
- ### Services
788
-
789
- Inject dependencies with \`@Service()\` and \`@Autowired()\`:
790
-
791
- \`\`\`ts
792
- import { Service, Autowired } from '@forinda/kickjs'
793
-
794
- @Service()
795
- export class UserService {
796
- @Autowired()
797
- private userRepository!: UserRepository
798
-
799
- async findAll() {
800
- return this.userRepository.findAll()
801
- }
802
- }
803
- \`\`\`
804
-
805
- ### Modules
806
-
807
- Modules implement \`AppModule\` and wire controllers via \`buildRoutes()\`.
808
-
809
- > **Naming matters.** Module files **must** be named \`<name>.module.ts\` and live under \`src/modules/\`. The Vite plugin auto-discovers files matching \`*.module.[tj]sx?\` for HMR — a misnamed file (e.g., \`projects.ts\`) won't trigger a graceful module rebuild on save and will require a full server restart. The CLI generator (\`kick g module <name>\`) follows this convention automatically.
810
-
811
- \`\`\`ts
812
- // src/modules/users/users.module.ts (named <feature>.module.ts)
813
- import { type AppModule, type ModuleRoutes, buildRoutes } from '@forinda/kickjs'
814
- import { UserController } from './user.controller'
815
-
816
- export class UserModule implements AppModule {
817
- routes(): ModuleRoutes {
818
- return {
819
- path: '/users',
820
- router: buildRoutes(UserController),
821
- controller: UserController,
822
- }
823
- }
824
- }
825
- \`\`\`
826
-
827
- Register all modules in \`src/modules/index.ts\`:
828
-
829
- \`\`\`ts
830
- import type { AppModuleClass } from '@forinda/kickjs'
831
- import { UserModule } from './user/user.module'
832
-
833
- export const modules: AppModuleClass[] = [UserModule]
834
- \`\`\`
835
-
836
- ### RequestContext
837
-
838
- Every controller method receives a \`ctx\` (alias \`Ctx<TRoute>\` or the
839
- loose \`RequestContext\`):
840
-
841
- \`\`\`ts
842
- ctx.body // Request body (parsed JSON)
843
- ctx.params // Route params
844
- ctx.query // Query string
845
- ctx.headers // Request headers
846
- ctx.requestId // Auto-generated request ID
847
- ctx.session // Session data (if session middleware enabled)
848
- ctx.file // Uploaded file (single)
849
- ctx.files // Uploaded files (multiple)
850
-
851
- // Pagination helpers
852
- ctx.qs(config) // Parse query with filters/sort/pagination
853
- ctx.paginate(handler) // Auto-paginated response
854
-
855
- // Response helpers
856
- ctx.json(data) // 200 OK with JSON
857
- ctx.created(data) // 201 Created
858
- ctx.noContent() // 204 No Content
859
- ctx.notFound() // 404 Not Found
860
- ctx.badRequest(msg) // 400 Bad Request
861
- \`\`\`
862
-
863
- > **Context decorators** — when a middleware's only job is to populate \`ctx.set/get\` for the handler to read, prefer \`defineContextDecorator()\` over \`@Middleware()\`. Typed via \`ContextMeta\`, supports \`dependsOn\` ordering, validates the pipeline at boot. Full pattern reference in \`AGENTS.md\` and at <https://forinda.github.io/kick-js/guide/context-decorators>.
864
-
865
- ## CLI Generators
866
-
867
- Generate code with the \`kick\` CLI:
868
-
869
- \`\`\`bash
870
- kick g module <name> # Full module (controller, service, DTOs, repo)
871
- kick g scaffold <name> <fields> # CRUD module from field definitions
872
- kick g controller <name> # Standalone controller
873
- kick g service <name> # Service class
874
- kick g middleware <name> # Express middleware
875
- kick g guard <name> # Route guard (auth, roles)
876
- kick g adapter <name> # AppAdapter with lifecycle hooks
877
- kick g dto <name> # Zod DTO schema
878
- ${template === "graphql" ? "kick g resolver <name> # GraphQL resolver\n" : ""}${template === "cqrs" ? "kick g job <name> # Queue job processor\n" : ""}\`\`\`
879
-
880
- ## Adding Packages
761
+ /**
762
+ * Generate CLAUDE.md.
763
+ *
764
+ * v4 update: this file is intentionally thin. AGENTS.md is the
765
+ * canonical, multi-agent project reference (Claude / Copilot /
766
+ * Codex / Gemini / etc.) — duplicating it here meant two files
767
+ * drifting out of sync after every framework change. The generated
768
+ * CLAUDE.md now redirects there + adds Claude-specific affordances
769
+ * only.
770
+ */
771
+ function generateClaude(name, _template, pm) {
772
+ return `# CLAUDE.md — ${name}
773
+
774
+ **Read \`./AGENTS.md\` first.** It is the canonical, multi-agent
775
+ reference for this project (Claude, Copilot, Codex, Gemini, etc.) —
776
+ project conventions, structure, decorator patterns, env wiring, CLI
777
+ generators, every gotcha.
778
+
779
+ **Then read \`./kickjs-skills.md\`.** That file is the task-oriented
780
+ skill index short, rigid recipes keyed to triggers ("add-module",
781
+ "write-controller-test", "bootstrap-export", "deny-list", …). Use it
782
+ as the playbook when executing common KickJS workflows.
783
+
784
+ This file is a thin Claude-specific layer on top of those two; when
785
+ they disagree on anything substantive, treat \`AGENTS.md\` as
786
+ authoritative and flag the discrepancy.
787
+
788
+ ## Why two files
789
+
790
+ \`AGENTS.md\` is what every agent reads. \`CLAUDE.md\` is what
791
+ Claude Code automatically loads as project context on each
792
+ conversation. Keeping CLAUDE.md slim avoids two files drifting; the
793
+ redirect above ensures Claude pulls the canonical content without
794
+ us copy-pasting.
795
+
796
+ ## Claude-specific notes
797
+
798
+ - **Slash commands** — \`/help\` for Claude Code commands; \`/init\`
799
+ to refresh project memory if AGENTS.md changes substantially.
800
+ - **Feedback** — file issues at <https://github.com/anthropics/claude-code/issues>.
801
+ - **Persistent memory** Claude maintains user/feedback/project/
802
+ reference memories under \`.claude/memory/\`. If you ask for
803
+ something that contradicts a remembered preference, Claude flags
804
+ it before acting; corrections update memory automatically.
805
+ - **Long-running tasks** — \`/loop\` and \`/schedule\` for recurring
806
+ or background work. Useful for "wait for the deploy then open a
807
+ cleanup PR" or "every Monday triage the issue board" patterns.
808
+
809
+ ## Quick reference (full version in AGENTS.md)
881
810
 
882
811
  \`\`\`bash
883
- kick add auth # JWT, API key, OAuth strategies
884
- kick add swagger # OpenAPI docs from decorators
885
- kick add ws # WebSocket support
886
- kick add queue # Background jobs (BullMQ/RabbitMQ/Kafka)
887
- kick add mailer # Email (SMTP, Resend, SES)
888
- kick add cron # Scheduled tasks
889
- kick add prisma # Prisma ORM adapter
890
- kick add drizzle # Drizzle ORM adapter
891
- kick add otel # OpenTelemetry tracing
892
- kick add --list # Show all available packages
893
- \`\`\`
894
-
895
- ## Environment Configuration
896
-
897
- The project's typed env schema lives in **\`src/config/index.ts\`** —
898
- extend the base schema there with your application-specific keys, and
899
- the schema is auto-registered with kickjs at module load. The companion
900
- \`src/index.ts\` imports it as a side effect (\`import './config'\`) **before**
901
- \`bootstrap()\` runs, so every \`@Service\`, \`@Controller\`, \`@Value\`, and
902
- \`ConfigService\` resolution sees the validated extended values.
903
-
904
- > **Do not delete \`import './config'\` from \`src/index.ts\`.** It is the
905
- > registration step that wires \`ConfigService\` to your env schema.
906
- > Without it, \`config.get('YOUR_KEY')\` returns \`undefined\` for every
907
- > user-defined key and \`@Value('YOUR_KEY')\` only works because of a
908
- > raw \`process.env\` fallback (Zod coercion + defaults are skipped).
909
-
910
- Edit \`.env\` for variable values. Access them with \`@Value()\`:
911
-
912
- \`\`\`ts
913
- import { Value } from '@forinda/kickjs'
914
-
915
- @Service()
916
- export class ApiService {
917
- @Value('API_KEY')
918
- private apiKey!: string
919
-
920
- @Value('PORT', 3000) // With default
921
- private port!: number
922
- }
812
+ ${pm} install # Install dependencies
813
+ kick dev # Dev server with HMR + typegen
814
+ kick build && kick start # Production
815
+ ${pm} run test # Vitest
816
+ ${pm} run typecheck # tsc --noEmit
817
+ ${pm} run format # Prettier
923
818
  \`\`\`
924
819
 
925
- Or use \`ConfigService\`:
926
-
927
- \`\`\`ts
928
- import { Service, Autowired, ConfigService } from '@forinda/kickjs'
929
-
930
- @Service()
931
- export class AppService {
932
- @Autowired()
933
- private config!: ConfigService
820
+ ## v4 framework reminders
934
821
 
935
- getPort() {
936
- // typed: number, Zod-coerced from baseEnvSchema
937
- return this.config.get('PORT')
938
- }
939
- }
940
- \`\`\`
941
-
942
- Hot-reload of \`.env\` changes during dev is wired up automatically via
943
- \`envWatchPlugin()\` in \`vite.config.ts\` — edit \`.env\`, the dev server
944
- reloads, and the next \`config.get()\` re-parses with the new values.
945
-
946
- ### Standalone Env Utilities (No DI Required)
947
-
948
- These functions work anywhere — scripts, CLI tools, plain files, outside \`@Service\`/\`@Controller\`:
949
-
950
- \`\`\`ts
951
- import { defineEnv, loadEnv, getEnv, reloadEnv, resetEnvCache, baseEnvSchema } from '@forinda/kickjs/config'
952
- import { z } from 'zod'
953
-
954
- // Define and parse schema
955
- const schema = defineEnv((base) =>
956
- base.extend({ DATABASE_URL: z.string().url() })
957
- )
958
- const env = loadEnv(schema) // Parse + validate process.env
959
- console.log(env.PORT) // 3000 (coerced to number)
960
- console.log(env.DATABASE_URL) // validated URL string
961
-
962
- // Get single value
963
- const port = getEnv('PORT') // typed after kick typegen
964
-
965
- // Reload after .env changes (HMR calls this automatically)
966
- reloadEnv()
967
-
968
- // Reset cache in tests that swap schemas
969
- resetEnvCache()
970
- \`\`\`
971
-
972
- | Function | Purpose |
973
- |----------|---------|
974
- | \`defineEnv(fn)\` | Extend base schema with custom Zod keys |
975
- | \`loadEnv(schema?)\` | Parse \`process.env\`, validate, cache, return typed object |
976
- | \`getEnv(key, schema?)\` | Get single validated env value |
977
- | \`reloadEnv()\` | Re-read \`.env\` from disk, re-parse with same schema |
978
- | \`resetEnvCache()\` | Clear parsed cache AND registered schema (for tests) |
979
- | \`baseEnvSchema\` | Base Zod schema: \`PORT\`, \`NODE_ENV\`, \`LOG_LEVEL\` |
980
-
981
- ## Standalone Utilities (No DI Required)
822
+ When generating or modifying code in this project, stay aligned with the v4 conventions documented in \`AGENTS.md\`:
982
823
 
983
- These utilities work outside decorated classes:
824
+ - **Adapters**: \`defineAdapter()\` factory never \`class implements AppAdapter\`.
825
+ - **Plugins**: \`definePlugin()\` factory — never plain function returning \`KickPlugin\`.
826
+ - **DI tokens**: slash-delimited \`<scope>/<area>/<key>\` (e.g. \`'app/users/repository'\`). First-party uses the reserved \`'kick/'\` prefix; this project owns its own scope.
827
+ - **Decorators**: \`@Controller()\` (no path arg — mount prefix comes from \`routes().path\`).
828
+ - **Module entry file** MUST be named \`<name>.module.ts\` and live under \`src/modules/<name>/\`. The Vite plugin auto-discovers \`*.module.[tj]sx?\` for graceful HMR — a misnamed \`projects.ts\` silently degrades every save into a full restart.
829
+ - **Env**: schema lives in \`src/config/index.ts\`; \`import './config'\` MUST be the first import in \`src/index.ts\` (side-effect registers the schema before any \`@Value\` resolves).
830
+ - **Assets**: drop new template files into \`src/templates/<namespace>/\`; the dev watcher auto-rebuilds the \`KickAssets\` augmentation + \`assets.x.y()\` re-walks on next call. No restart, no manual build.
831
+ - **Context Contributors** (\`defineContextDecorator\`) over \`@Middleware()\` for ctx-population work.
832
+ - **Repos under tests**: \`Container.create()\` for isolation — never \`new Container()\` or \`getInstance().reset()\`.
833
+ - **Bootstrap export**: \`src/index.ts\` must end with \`export const app = await bootstrap({ ... })\`. The Vite plugin and \`createTestApp\` import the named \`app\`; without the export, HMR silently degrades to full restarts.
834
+ - **Thin entry file**: aggregate \`modules\`, \`middleware\`, \`plugins\`, \`adapters\` in their own folders (\`src/modules/index.ts\`, \`src/middleware/index.ts\`, …) and pass them by name to \`bootstrap()\` — never inline the lists in \`src/index.ts\`.
835
+ - **Refresh these files**: \`kick g agents -f\` regenerates \`AGENTS.md\` + \`CLAUDE.md\` from the latest CLI templates. Hand-edited content is overwritten — keep customisation in \`AGENTS.local.md\`.
984
836
 
985
- ### Logger
986
-
987
- \`\`\`ts
988
- import { Logger, createLogger } from '@forinda/kickjs'
989
-
990
- const log = Logger.for('MyScript') // Static factory
991
- log.info('Processing started')
992
- log.error('Something failed')
993
-
994
- const log2 = createLogger('Worker') // Function form
995
- \`\`\`
996
-
997
- ### Injection Tokens
998
-
999
- \`\`\`ts
1000
- import { createToken } from '@forinda/kickjs'
1001
-
1002
- // Type-safe DI tokens for factory/interface binding
1003
- const DB_URL = createToken<string>('config.database.url')
1004
- const FEATURE_FLAGS = createToken<FeatureFlags>('app.features')
1005
- \`\`\`
1006
-
1007
- ### Reactivity
1008
-
1009
- \`\`\`ts
1010
- import { ref, computed, watch, reactive } from '@forinda/kickjs'
1011
-
1012
- const count = ref(0)
1013
- const doubled = computed(() => count.value * 2)
1014
- const stop = watch(() => count.value, (val) => console.log(val))
1015
- count.value++ // logs 1
1016
- \`\`\`
1017
-
1018
- ### HTTP Errors
1019
-
1020
- \`\`\`ts
1021
- import { HttpException, HttpStatus } from '@forinda/kickjs'
1022
-
1023
- throw new HttpException(HttpStatus.NOT_FOUND, 'User not found')
1024
- \`\`\`
1025
-
1026
- ## Testing
1027
-
1028
- Tests live in \`src/**/*.test.ts\`:
1029
-
1030
- \`\`\`ts
1031
- import { describe, it, expect, beforeEach } from 'vitest'
1032
- import { Container } from '@forinda/kickjs'
1033
- import { createTestApp } from '@forinda/kickjs-testing'
1034
-
1035
- describe('UserController', () => {
1036
- beforeEach(() => Container.reset())
1037
-
1038
- it('should return users', async () => {
1039
- const app = await createTestApp([UserModule])
1040
- const res = await app.get('/users')
1041
- expect(res.status).toBe(200)
1042
- })
1043
- })
1044
- \`\`\`
1045
-
1046
- Run tests:
1047
- - \`${pm} run test\` — run all tests
1048
- - \`${pm} run test:watch\` — watch mode
1049
-
1050
- ## Decorators Reference
1051
-
1052
- ### Route Decorators
1053
- - \`@Controller('/path')\` — define controller prefix
1054
- - \`@Get('/'), @Post('/'), @Put('/'), @Delete('/'), @Patch('/')\` — HTTP methods
1055
- - \`@Middleware(fn)\` — attach middleware
1056
- - \`@Public()\` — skip authentication (requires @forinda/kickjs-auth)
1057
- - \`@Roles('admin', 'user')\` — role-based access control
1058
-
1059
- ### DI Decorators
1060
- - \`@Service()\` — singleton service (DI-registered)
1061
- - \`@Repository()\` — repository (semantic alias for @Service)
1062
- - \`@Autowired()\` — property injection
1063
- - \`@Inject('token')\` — token-based injection
1064
- - \`@Value('ENV_VAR')\` — inject config value
1065
-
1066
- ${template === "cqrs" ? `### CQRS/Event Decorators
1067
- - \`@Job('job-name')\` — queue job handler
1068
- - \`@Process('queue-name')\` — queue processor
1069
- - \`@Cron('0 * * * *')\` — cron schedule
1070
- - \`@WsController('/path')\` — WebSocket controller
1071
- - \`@Subscribe('event')\` — WebSocket event handler
1072
-
1073
- ` : ""}${template === "graphql" ? `### GraphQL Decorators
1074
- - \`@Resolver()\` — GraphQL resolver
1075
- - \`@Query()\` — GraphQL query
1076
- - \`@Mutation()\` — GraphQL mutation
1077
- - \`@Arg('name')\` — resolver argument
1078
-
1079
- ` : ""}## Common Pitfalls
1080
-
1081
- 1. **Decorators fire at import time** — make sure to import module classes in \`src/modules/index.ts\`
1082
- 2. **Tests need \`Container.reset()\`** — call in \`beforeEach\` to isolate DI state
1083
- 3. **Always use \`ctx.body\`** — never \`req.body\` directly
1084
- 4. **DI requires \`reflect-metadata\`** — already imported in \`src/index.ts\`
1085
- 5. **Vite HMR requires proper cleanup** — adapters should implement \`shutdown()\`
1086
- 6. **Never delete \`import './config'\` from \`src/index.ts\`** — that side-effect import registers the env schema with kickjs. Without it \`ConfigService.get('YOUR_KEY')\` returns \`undefined\` for every user-defined key. \`@Value('YOUR_KEY')\` *appears* to keep working but only via a raw \`process.env\` fallback (Zod coercion + schema defaults are silently skipped).
1087
-
1088
- ## Learn More
1089
-
1090
- - [KickJS Documentation](https://forinda.github.io/kick-js/)
1091
- - [API Reference](https://forinda.github.io/kick-js/api/)
1092
- - [CLI Commands](https://forinda.github.io/kick-js/guide/cli-commands.html)
1093
- - [Decorators Guide](https://forinda.github.io/kick-js/guide/decorators.html)
837
+ For everything else (controllers, services, modules, RequestContext API, generators, CLI commands, package additions, env wiring, troubleshooting) → \`AGENTS.md\`.
1094
838
  `;
1095
839
  }
1096
840
  /** Generate AGENTS.md with AI agent guide */
1097
841
  function generateAgents(name, template, pm) {
1098
842
  return `# AGENTS.md — AI Agent Guide for ${name}
1099
843
 
1100
- This guide helps AI agents (Claude, Copilot, etc.) work effectively on this KickJS application.
844
+ This guide is the **canonical, multi-agent reference** for this KickJS
845
+ application — Claude, Copilot, Codex, Gemini, etc. all read it first.
846
+ Per-agent files (\`CLAUDE.md\`, \`GEMINI.md\`, etc.) are thin layers that
847
+ add tool-specific affordances on top.
1101
848
 
1102
849
  ## Before You Start
1103
850
 
1104
- 1. Read \`CLAUDE.md\` for project conventions and commands
1105
- 2. Run \`${pm} install\` to install dependencies
1106
- 3. Run \`kick dev\` to verify the app starts
1107
- 4. Read the [KickJS documentation](https://forinda.github.io/kick-js/) for framework details
851
+ 1. Run \`${pm} install\` to install dependencies
852
+ 2. Run \`kick dev\` to verify the app starts
853
+ 3. Read the [KickJS documentation](https://forinda.github.io/kick-js/) for framework details
854
+
855
+ ## v4 Conventions (don't skip)
856
+
857
+ KickJS v4 made a handful of structural changes from v3. Internalise these
858
+ before generating or modifying code — they are the source of most agent
859
+ mistakes:
860
+
861
+ - **Adapters** — \`defineAdapter()\` factory. Never write \`class Foo implements AppAdapter\`.
862
+
863
+ \`\`\`ts
864
+ export const MyAdapter = defineAdapter<MyOptions>({
865
+ name: 'MyAdapter',
866
+ defaults: { ... },
867
+ build: (config) => ({
868
+ beforeMount({ app }) { /* ... */ },
869
+ afterStart({ server }) { /* ... */ },
870
+ }),
871
+ })
872
+ \`\`\`
873
+
874
+ - **Plugins** — \`definePlugin()\` factory. Same shape, never plain function returning \`KickPlugin\`.
875
+
876
+ - **DI tokens** — slash-delimited \`<scope>/<area>/<key>\`, lower-case, no \`:\` separators:
877
+
878
+ \`\`\`ts
879
+ const USERS_REPO = createToken<UsersRepo>('app/users/repository')
880
+ const DB = createToken<Database>('app/db/connection')
881
+ \`\`\`
882
+
883
+ The \`kick/\` prefix is reserved for first-party packages; this project
884
+ owns its own scope (\`app/\`, your domain name, etc.).
885
+
886
+ - **\`@Controller()\`** takes **no path argument**. Mount prefix comes from
887
+ the module's \`routes()\` return value, not the decorator. \`@Controller('/users')\`
888
+ is a v3 leftover; the linter and codegen reject it.
889
+
890
+ - **Env wiring** — \`src/config/index.ts\` calls \`loadEnv(envSchema)\` as a
891
+ side effect. \`src/index.ts\` MUST have \`import './config'\` as its **first**
892
+ import (before \`bootstrap()\`). Without it, \`ConfigService.get('YOUR_KEY')\`
893
+ returns \`undefined\` and \`@Value()\` only works via raw \`process.env\` fallback
894
+ (Zod coercion + defaults silently skipped).
895
+
896
+ - **Module entry files MUST be named \`<name>.module.ts\`** — see the Vite
897
+ HMR contract at the top of "Module Pattern" below. The CLI enforces this;
898
+ hand-rolled files must too.
899
+
900
+ - **Assets** — drop new template files into \`src/templates/<namespace>/\`
901
+ (or wherever \`kick.config.ts\` points). The dev watcher auto-rebuilds the
902
+ \`KickAssets\` augmentation; \`assets.x.y()\` re-walks on next call. No restart,
903
+ no manual build step.
904
+
905
+ - **Context over \`@Middleware()\`** — when a middleware's only job is to
906
+ populate \`ctx.set('key', value)\`, use \`defineHttpContextDecorator()\`
907
+ (HTTP) or \`defineContextDecorator()\` (transport-agnostic) instead.
908
+ Typed via \`ContextMeta\`, ordered via \`dependsOn\`, validated at boot.
909
+ Reserve \`@Middleware()\` for response short-circuit / stream mutation /
910
+ pre-route-matching work.
911
+
912
+ Two ground rules around the data flow — both stem from the fact that
913
+ every per-request stage gets its OWN \`RequestContext\` instance, all
914
+ reading/writing the SAME \`AsyncLocalStorage\`-backed Map:
915
+ - **\`resolve\` and \`onError\` must RETURN the value.** The runner
916
+ writes it via \`ctx.set(reg.key, value)\` on your behalf. Direct
917
+ property assignment (\`ctx.tenant = …\`) sticks to the contributor
918
+ instance only — the handler instance never sees it.
919
+ - **Read across instances via \`ctx.set\` / \`ctx.get\`** (or
920
+ \`requestStore.getStore()?.values.get('key')\` from a service that
921
+ has no \`ctx\` reference). \`ctx.req\` works because the underlying
922
+ Express request is shared; bespoke property assignments don't.
923
+
924
+ - **Test isolation** — default to \`Container.create()\` for fresh DI state.
925
+ Never \`new Container()\` and never \`getInstance().reset()\` — both leak
926
+ registrations between tests.
927
+
928
+ \`\`\`ts
929
+ const container = Container.create()
930
+ // ... register test-scoped providers, run, discard
931
+ \`\`\`
932
+
933
+ - **Bootstrap export** — \`src/index.ts\` MUST end with
934
+ \`export const app = await bootstrap({ ... })\`. The Vite plugin imports
935
+ the named \`app\` symbol to drive HMR module swaps; testing helpers
936
+ (\`createTestApp\`) and the OpenAPI introspector also rely on it. Drop
937
+ the \`export\` and \`kick dev\` will silently fall back to a full restart
938
+ on every save while \`createTestApp\` complains about a missing handle.
939
+
940
+ - **Keep \`src/index.ts\` thin** — collect plugins, modules, middleware, and
941
+ adapters in dedicated folders and re-export aggregated arrays. Do **not**
942
+ inline registration in the entry file:
943
+
944
+ \`\`\`ts
945
+ // src/modules/index.ts
946
+ export const modules: AppModuleClass[] = [HelloModule, UsersModule, ...]
947
+
948
+ // src/middleware/index.ts
949
+ export const middleware = [helmet(), cors(), requestId(), ...]
950
+
951
+ // src/plugins/index.ts
952
+ export const plugins = [MetricsPlugin(), AuditPlugin()]
953
+
954
+ // src/adapters/index.ts
955
+ export const adapters = [SwaggerAdapter({ ... }), DevToolsAdapter()]
956
+ \`\`\`
957
+
958
+ \`\`\`ts
959
+ // src/index.ts — stays small; one import per category
960
+ import 'reflect-metadata'
961
+ import './config'
962
+ import { bootstrap } from '@forinda/kickjs'
963
+ import { modules } from './modules'
964
+ import { middleware } from './middleware'
965
+ import { plugins } from './plugins'
966
+ import { adapters } from './adapters'
967
+
968
+ export const app = await bootstrap({ modules, middleware, plugins, adapters })
969
+ \`\`\`
970
+
971
+ This keeps the entry file diff-friendly, scales to dozens of modules
972
+ without git churn, and lets each domain own its own registration list.
973
+ The generators (\`kick g module\`, \`kick g middleware\`, \`kick g plugin\`,
974
+ \`kick g adapter\`) follow this layout — manual additions should too.
975
+
976
+ Everything else (controllers, services, modules, RequestContext API, generators,
977
+ package additions, env access patterns, troubleshooting) is detailed below.
1108
978
 
1109
979
  ## Where to Find Things
1110
980
 
@@ -1115,6 +985,7 @@ This guide helps AI agents (Claude, Copilot, etc.) work effectively on this Kick
1115
985
  | Entry point | \`src/index.ts\` |
1116
986
  | Module registry | \`src/modules/index.ts\` |
1117
987
  | Feature modules | \`src/modules/<module-name>/\` |
988
+ | **Module entry file** | \`src/modules/<name>/<name>.module.ts\` (filename suffix is required — see Vite HMR contract below) |
1118
989
  ${template === "graphql" ? "| GraphQL resolvers | `src/resolvers/` |\n" : ""}| Env values | \`.env\` |
1119
990
  | Env schema (Zod) | \`src/config/index.ts\` |
1120
991
  | TypeScript config | \`tsconfig.json\` |
@@ -1197,7 +1068,7 @@ Then:
1197
1068
  If not using generators:
1198
1069
 
1199
1070
  - [ ] Create \`src/modules/<name>/<name>.controller.ts\`
1200
- - [ ] Add \`@Controller('/path')\` decorator
1071
+ - [ ] Add \`@Controller()\` decorator
1201
1072
  - [ ] Add route handlers with \`@Get()\`, \`@Post()\`, etc.
1202
1073
  - [ ] Create module file implementing \`AppModule\` with \`routes()\` returning \`{ path, router: buildRoutes(Controller), controller }\`
1203
1074
  - [ ] Register module in \`src/modules/index.ts\` (\`AppModuleClass[]\` array)
@@ -1259,8 +1130,8 @@ import { AuthAdapter, JwtStrategy } from '@forinda/kickjs-auth'
1259
1130
  bootstrap({
1260
1131
  modules,
1261
1132
  adapters: [
1262
- new AuthAdapter({
1263
- strategies: [new JwtStrategy({ secret: process.env.JWT_SECRET! })],
1133
+ AuthAdapter({
1134
+ strategies: [JwtStrategy({ secret: process.env.JWT_SECRET! })],
1264
1135
  }),
1265
1136
  ],
1266
1137
  })
@@ -1290,7 +1161,7 @@ import { WsAdapter } from '@forinda/kickjs-ws'
1290
1161
 
1291
1162
  bootstrap({
1292
1163
  modules,
1293
- adapters: [new WsAdapter()],
1164
+ adapters: [WsAdapter()],
1294
1165
  })
1295
1166
  \`\`\`
1296
1167
 
@@ -1310,14 +1181,13 @@ import { Container } from '@forinda/kickjs'
1310
1181
  import { createTestApp } from '@forinda/kickjs-testing'
1311
1182
 
1312
1183
  describe('UserController', () => {
1313
- beforeEach(() => {
1314
- Container.reset() // Important: isolate DI state
1315
- })
1316
-
1317
1184
  it('should return users', async () => {
1318
- const app = await createTestApp([UserModule])
1185
+ // Container.create() isolated DI state per test, never new Container()
1186
+ // and never getInstance().reset() (both leak registrations between tests).
1187
+ const container = Container.create()
1188
+ const app = await createTestApp([UserModule], { container })
1319
1189
  const res = await app.get('/users')
1320
-
1190
+
1321
1191
  expect(res.status).toBe(200)
1322
1192
  expect(res.body).toHaveProperty('users')
1323
1193
  })
@@ -1381,7 +1251,7 @@ These work anywhere — scripts, plain files, outside \`@Service\`/\`@Controller
1381
1251
  |---------|--------|---------|
1382
1252
  | \`Logger.for(name)\` | \`@forinda/kickjs\` | \`const log = Logger.for('MyScript')\` |
1383
1253
  | \`createLogger(name)\` | \`@forinda/kickjs\` | \`const log = createLogger('Worker')\` |
1384
- | \`createToken<T>(name)\` | \`@forinda/kickjs\` | \`const TOKEN = createToken<string>('db.url')\` |
1254
+ | \`createToken<T>(name)\` | \`@forinda/kickjs\` | \`const TOKEN = createToken<string>('app/db/url')\` |
1385
1255
  | \`ref(value)\` | \`@forinda/kickjs\` | \`const count = ref(0)\` |
1386
1256
  | \`computed(fn)\` | \`@forinda/kickjs\` | \`const doubled = computed(() => count.value * 2)\` |
1387
1257
  | \`watch(source, cb)\` | \`@forinda/kickjs\` | \`watch(() => count.value, (v) => log(v))\` |
@@ -1394,7 +1264,7 @@ These work anywhere — scripts, plain files, outside \`@Service\`/\`@Controller
1394
1264
  ### HTTP Routes
1395
1265
  | Decorator | Purpose |
1396
1266
  |-----------|---------|
1397
- | \`@Controller('/path')\` | Define route prefix |
1267
+ | \`@Controller()\` | Define route prefix |
1398
1268
  | \`@Get('/'), @Post('/')\` | HTTP method handlers |
1399
1269
  | \`@Middleware(fn)\` | Attach middleware |
1400
1270
  | \`@Public()\` | Skip auth (requires auth adapter) |
@@ -1451,13 +1321,15 @@ ${template === "graphql" ? `### GraphQL
1451
1321
 
1452
1322
  1. **Forgot to register module** — Add to \`src/modules/index.ts\` exports array
1453
1323
  2. **DI not working** — Ensure \`reflect-metadata\` is imported in \`src/index.ts\`
1454
- 3. **Tests failing randomly** — Missing \`Container.reset()\` in \`beforeEach\`
1324
+ 3. **Tests failing randomly** — Sharing the global container between tests. Default to \`Container.create()\` per test (or per \`beforeEach\`) instead of \`new Container()\` / \`getInstance().reset()\`
1455
1325
  4. **Routes not found** — Check controller path and module registration
1456
1326
  5. **HMR not working** — Two checks: (a) \`vite.config.ts\` has \`hmr: true\`; (b) module file is named \`<name>.module.ts\` (or \`.tsx\`/\`.js\`/\`.jsx\`) and lives under \`src/modules/\`. The Vite plugin auto-discovers \`*.module.[tj]sx?\` for graceful HMR — a misnamed module file (e.g., \`projects.ts\`) silently degrades to a full restart on every save.
1457
1327
  6. **Decorators not working** — Check \`tsconfig.json\` has \`experimentalDecorators: true\`
1458
1328
  7. **\`config.get('YOUR_KEY')\` returns \`undefined\`** — \`src/index.ts\` is missing \`import './config'\`. That side-effect import registers the env schema with kickjs (\`loadEnv(envSchema)\` runs at module load). Without it, \`ConfigService\` falls back to the base schema (\`PORT\`/\`NODE_ENV\`/\`LOG_LEVEL\` only) and every user-defined key reads as \`undefined\`. \`@Value()\` may *appear* to work because of a raw \`process.env\` fallback, but Zod coercion and schema defaults are silently skipped — investigate \`src/index.ts\` and \`src/config/index.ts\` first.
1459
1329
  8. **Used \`@Middleware()\` to compute a value for \`ctx\`** — prefer \`defineContextDecorator()\` (see Context Decorators above). It's typed via \`ContextMeta\`, supports \`dependsOn\` for ordering, and validates the pipeline at boot. \`@Middleware()\` is for response short-circuiting, stream mutation, and pre-route-matching work.
1460
1330
  9. **Context contributor's \`dependsOn\` key not produced anywhere** — boot throws \`MissingContributorError\` naming the dependent and the route. Either remove the dep or register a contributor that produces the key (at any precedence level: method/class/module/adapter/global).
1331
+ 10. **\`bootstrap()\` not exported** — \`src/index.ts\` calls \`await bootstrap({ ... })\` but discards the return value (no \`export const app = ...\`). Vite HMR can't locate the running instance, so module saves degrade to full restarts; \`createTestApp\`/\`@forinda/kickjs-testing\` consumers can't import the handle either. Always: \`export const app = await bootstrap({ ... })\`.
1332
+ 11. **Refresh AGENTS.md / CLAUDE.md after a framework upgrade** — these files are scaffolded by the CLI and don't auto-update. Run \`kick g agents -f\` (or \`kick g agent-docs -f\`) to regenerate from the latest CLI templates after \`kick add\` / version bumps. Hand-edited sections will be overwritten — keep customisation in a separate file like \`AGENTS.local.md\`.
1461
1333
 
1462
1334
  ## CLI Commands Reference
1463
1335
 
@@ -1490,6 +1362,261 @@ ${template === "graphql" ? `### GraphQL
1490
1362
  - [Testing](https://forinda.github.io/kick-js/api/testing.html)
1491
1363
  `;
1492
1364
  }
1365
+ /**
1366
+ * Generate `kickjs-skills.md` — task-oriented "skill" recipes for AI
1367
+ * agents (Claude superpowers, Copilot, etc.). Where AGENTS.md is the
1368
+ * narrative reference, this file lists short, rigid workflows the agent
1369
+ * should follow when it sees the corresponding trigger.
1370
+ */
1371
+ function generateKickJsSkills(name, _template, pm) {
1372
+ return `# kickjs-skills.md — Task Skills for AI Agents (${name})
1373
+
1374
+ This file is the agent-facing **skills index** for KickJS work in this
1375
+ repo. Each block below is a short, rigid workflow keyed to a specific
1376
+ trigger ("user wants to add a module", "tests are leaking state", etc.).
1377
+
1378
+ - Reference docs (narrative, exhaustive) → \`AGENTS.md\`.
1379
+ - Tool-specific notes → \`CLAUDE.md\`, \`GEMINI.md\`, etc.
1380
+ - **This file** → step-by-step recipes the agent should *execute*.
1381
+
1382
+ Re-run \`kick g agents -f --only skills\` after framework upgrades to refresh.
1383
+
1384
+ ---
1385
+
1386
+ ## Skill: add-module
1387
+
1388
+ \`\`\`yaml
1389
+ name: kickjs-add-module
1390
+ description: Use when the user asks to add a new feature module (controller + service + repo + DTOs).
1391
+ \`\`\`
1392
+
1393
+ **Trigger phrases**: "add a users module", "scaffold tasks", "new feature for X".
1394
+
1395
+ **Steps**:
1396
+ 1. Run \`kick g module <name>\` (use plural form if the project pluralizes — check \`kick.config.ts\`).
1397
+ 2. Verify the new folder under \`src/modules/<name>/\` contains \`<name>.module.ts\` (filename suffix is mandatory for HMR).
1398
+ 3. Confirm the module appears in \`src/modules/index.ts\` exports — generator does this automatically; verify if you bypassed it.
1399
+ 4. Open \`<name>.dto.ts\` and tighten the Zod schemas to real fields (the generator emits placeholders).
1400
+ 5. Run \`${pm} run typecheck\` and \`${pm} run test\` before claiming done.
1401
+
1402
+ **Red flags** (stop and ask):
1403
+ - File created as \`<name>.ts\` instead of \`<name>.module.ts\` — Vite won't HMR it.
1404
+ - Module not registered in \`src/modules/index.ts\`.
1405
+ - \`@Controller('/path')\` with a path argument — that's a v3 pattern; remove it (mount comes from \`routes().path\`).
1406
+
1407
+ ---
1408
+
1409
+ ## Skill: add-adapter
1410
+
1411
+ \`\`\`yaml
1412
+ name: kickjs-add-adapter
1413
+ description: Use when wiring a new lifecycle integration (Swagger, DevTools, Auth, custom).
1414
+ \`\`\`
1415
+
1416
+ **Steps**:
1417
+ 1. \`kick g adapter <name>\` to scaffold the boilerplate, OR install via \`kick add <package>\` for first-party adapters.
1418
+ 2. The generated file uses \`defineAdapter()\` — never \`class implements AppAdapter\`.
1419
+ 3. Add the adapter instance to \`src/adapters/index.ts\` (don't inline in \`src/index.ts\`).
1420
+ 4. If the adapter contributes to \`ctx.set/get\`, prefer \`AppAdapter.contributors?()\` over a wrapping middleware.
1421
+ 5. Verify with \`kick dev\` that the adapter's lifecycle logs fire.
1422
+
1423
+ **Red flags**:
1424
+ - Inlining the adapter list directly in \`src/index.ts\` (entry file should stay thin).
1425
+ - Returning a plain object instead of going through \`defineAdapter()\` — type inference for \`config\` will be wrong.
1426
+
1427
+ ---
1428
+
1429
+ ## Skill: write-controller-test
1430
+
1431
+ \`\`\`yaml
1432
+ name: kickjs-write-controller-test
1433
+ description: Use when adding a Vitest test that exercises an HTTP route or DI graph.
1434
+ \`\`\`
1435
+
1436
+ **Template** (copy/paste, adjust):
1437
+
1438
+ \`\`\`ts
1439
+ import { describe, it, expect } from 'vitest'
1440
+ import { Container } from '@forinda/kickjs'
1441
+ import { createTestApp } from '@forinda/kickjs-testing'
1442
+
1443
+ describe('UserController', () => {
1444
+ it('returns users', async () => {
1445
+ const container = Container.create() // isolated DI per test
1446
+ const app = await createTestApp([UserModule], { container })
1447
+ const res = await app.get('/users')
1448
+ expect(res.status).toBe(200)
1449
+ })
1450
+ })
1451
+ \`\`\`
1452
+
1453
+ **Red flags**:
1454
+ - \`new Container()\` — wrong; use \`Container.create()\`.
1455
+ - \`Container.getInstance().reset()\` — wrong; same fix.
1456
+ - Sharing a container across \`it()\` blocks — leaks registrations.
1457
+
1458
+ ---
1459
+
1460
+ ## Skill: env-wiring-check
1461
+
1462
+ \`\`\`yaml
1463
+ name: kickjs-env-wiring-check
1464
+ description: Use when ConfigService.get('SOME_KEY') returns undefined or @Value silently falls back to process.env.
1465
+ \`\`\`
1466
+
1467
+ **Diagnosis**:
1468
+ 1. Open \`src/index.ts\`. The **first non-\`reflect-metadata\`** import MUST be \`import './config'\`.
1469
+ 2. Open \`src/config/index.ts\`. It MUST call \`loadEnv(envSchema)\` as a top-level side effect.
1470
+ 3. The new key MUST be declared in the Zod schema there. \`@Value('NEW_KEY')\` won't work without a schema entry (it'll fall back to raw \`process.env\` and skip Zod coercion silently).
1471
+
1472
+ **Fix**: add the key to the schema; ensure both side-effect imports above are present.
1473
+
1474
+ ---
1475
+
1476
+ ## Skill: bootstrap-export
1477
+
1478
+ \`\`\`yaml
1479
+ name: kickjs-bootstrap-export
1480
+ description: Use when HMR is silently doing full restarts on every save, or createTestApp can't find the app handle.
1481
+ \`\`\`
1482
+
1483
+ **Check** \`src/index.ts\`'s last line:
1484
+
1485
+ \`\`\`ts
1486
+ // CORRECT
1487
+ export const app = await bootstrap({ ... })
1488
+
1489
+ // WRONG (HMR degrades to full restart, createTestApp loses the handle)
1490
+ await bootstrap({ ... })
1491
+ \`\`\`
1492
+
1493
+ The Vite plugin imports the named \`app\` symbol; testing helpers do too.
1494
+
1495
+ ---
1496
+
1497
+ ## Skill: thin-entry-file
1498
+
1499
+ \`\`\`yaml
1500
+ name: kickjs-thin-entry-file
1501
+ description: Use when src/index.ts is accumulating module/middleware/plugin/adapter literals.
1502
+ \`\`\`
1503
+
1504
+ **Refactor target**:
1505
+
1506
+ \`\`\`ts
1507
+ // src/modules/index.ts
1508
+ export const modules: AppModuleClass[] = [HelloModule, UsersModule, ...]
1509
+
1510
+ // src/middleware/index.ts
1511
+ export const middleware = [helmet(), cors(), requestId(), ...]
1512
+
1513
+ // src/plugins/index.ts
1514
+ export const plugins = [MetricsPlugin(), ...]
1515
+
1516
+ // src/adapters/index.ts
1517
+ export const adapters = [SwaggerAdapter({ ... }), DevToolsAdapter()]
1518
+
1519
+ // src/index.ts — stays small
1520
+ import 'reflect-metadata'
1521
+ import './config'
1522
+ import { bootstrap } from '@forinda/kickjs'
1523
+ import { modules } from './modules'
1524
+ import { middleware } from './middleware'
1525
+ import { plugins } from './plugins'
1526
+ import { adapters } from './adapters'
1527
+ export const app = await bootstrap({ modules, middleware, plugins, adapters })
1528
+ \`\`\`
1529
+
1530
+ **Red flags**: any \`new SomeAdapter()\` or \`SomePlugin()\` literal inside \`bootstrap({ ... })\` instead of imported from a category folder.
1531
+
1532
+ ---
1533
+
1534
+ ## Skill: context-contributor
1535
+
1536
+ \`\`\`yaml
1537
+ name: kickjs-context-contributor
1538
+ description: Use when a middleware's only job is to set ctx values consumed elsewhere — replace with defineHttpContextDecorator (HTTP) or defineContextDecorator (transport-agnostic).
1539
+ \`\`\`
1540
+
1541
+ **Pattern** (HTTP — most common):
1542
+
1543
+ \`\`\`ts
1544
+ import { defineHttpContextDecorator, type RequestContext } from '@forinda/kickjs'
1545
+
1546
+ const LoadTenant = defineHttpContextDecorator({
1547
+ key: 'tenant',
1548
+ deps: { repo: TENANT_REPO },
1549
+ resolve: (ctx, { repo }) => repo.findById(ctx.req.headers['x-tenant-id'] as string),
1550
+ })
1551
+
1552
+ const LoadProject = defineHttpContextDecorator({
1553
+ key: 'project',
1554
+ dependsOn: ['tenant'],
1555
+ resolve: (ctx) => projectsRepo.find(ctx.get('tenant')!.id, ctx.params.id),
1556
+ })
1557
+
1558
+ @LoadTenant
1559
+ @LoadProject
1560
+ @Get('/projects/:id')
1561
+ getProject(ctx: RequestContext) { ctx.json(ctx.get('project')) }
1562
+ \`\`\`
1563
+
1564
+ Use \`defineContextDecorator\` (no Http prefix) when authoring a contributor that must run across HTTP, WebSocket, queue, and cron transports — \`Ctx\` defaults to the smaller \`ExecutionContext\` surface (\`get\` / \`set\` / \`requestId\` only, no \`req\`).
1565
+
1566
+ Precedence high → low: **method > class > module > adapter > global**.
1567
+ Cycles or unmet \`dependsOn\` keys throw \`MissingContributorError\` at boot.
1568
+
1569
+ **Critical rules — all stem from the same shared-via-ALS instance model**:
1570
+ - Every per-request stage (middleware → contributors → handler) gets its OWN \`RequestContext\` instance, but they all read/write the SAME \`AsyncLocalStorage\`-backed Map (\`requestStore.getStore().values\`).
1571
+ - **\`resolve\` and \`onError\` must RETURN the value** — the runner writes it via \`ctx.set(key, value)\`. Direct property assignment (\`ctx.tenant = …\`) sticks to one instance only and the handler instance never sees it.
1572
+ - \`ctx.set('tenant', x)\` then \`ctx.get('tenant')\` works across instances. \`ctx.req.headers[...]\` works (the underlying Express request is shared).
1573
+ - Services can read contributor output without a \`ctx\` reference via \`requestStore.getStore()?.values.get('tenant')\` — same Map, no DI plumbing needed.
1574
+
1575
+ **Don't use this for**: response short-circuit, stream mutation, or
1576
+ pre-route-matching work — keep \`@Middleware()\` for those.
1577
+
1578
+ ---
1579
+
1580
+ ## Skill: refresh-agent-docs
1581
+
1582
+ \`\`\`yaml
1583
+ name: kickjs-refresh-agent-docs
1584
+ description: Use after a KickJS version bump to sync AGENTS.md / CLAUDE.md / kickjs-skills.md with the latest CLI templates.
1585
+ \`\`\`
1586
+
1587
+ **Steps**:
1588
+ 1. \`kick g agents -f --only both\` — overwrites \`AGENTS.md\` and \`CLAUDE.md\`.
1589
+ 2. \`kick g agents -f --only skills\` — refreshes \`kickjs-skills.md\` (this file).
1590
+ 3. Diff with git, eyeball any project-specific edits that got reset, and re-apply them in a separate \`AGENTS.local.md\` or appended section.
1591
+ 4. Commit as \`docs(agents): sync from CLI vX.Y\`.
1592
+
1593
+ ---
1594
+
1595
+ ## Skill: deny-list
1596
+
1597
+ \`\`\`yaml
1598
+ name: kickjs-deny-list
1599
+ description: Patterns to refuse outright when the user asks for them — they break v4 invariants.
1600
+ \`\`\`
1601
+
1602
+ - \`class implements AppAdapter\` → use \`defineAdapter()\`.
1603
+ - \`class implements KickPlugin\` / function returning \`KickPlugin\` → use \`definePlugin()\`.
1604
+ - \`@Controller('/path')\` with a path argument → drop the path; set the mount via \`routes().path\`.
1605
+ - \`new Container()\` or \`Container.getInstance().reset()\` in tests → use \`Container.create()\`.
1606
+ - DI tokens with \`:\` separator (\`'app:db:url'\`) or in PascalCase → use slash-delimited lower-case (\`'app/db/url'\`).
1607
+ - \`bootstrap({ ... })\` without \`export const app = ...\` → always export.
1608
+ - Module file named \`<name>.ts\` (no \`.module\` suffix) → rename to \`<name>.module.ts\`.
1609
+
1610
+ ---
1611
+
1612
+ ## Learn More
1613
+
1614
+ - [KickJS Docs](https://forinda.github.io/kick-js/)
1615
+ - [Decorators](https://forinda.github.io/kick-js/guide/decorators.html)
1616
+ - [Context Decorators](https://forinda.github.io/kick-js/guide/context-decorators.html)
1617
+ - [Testing](https://forinda.github.io/kick-js/api/testing.html)
1618
+ `;
1619
+ }
1493
1620
  //#endregion
1494
1621
  //#region src/generators/project.ts
1495
1622
  const __dirname$1 = dirname(fileURLToPath(import.meta.url));
@@ -1522,6 +1649,7 @@ async function initProject(options) {
1522
1649
  await writeFileSafe(join(dir, "README.md"), generateReadme(name, template, packageManager));
1523
1650
  await writeFileSafe(join(dir, "CLAUDE.md"), generateClaude(name, template, packageManager));
1524
1651
  await writeFileSafe(join(dir, "AGENTS.md"), generateAgents(name, template, packageManager));
1652
+ await writeFileSafe(join(dir, "kickjs-skills.md"), generateKickJsSkills(name, template, packageManager));
1525
1653
  if (options.installDeps) {
1526
1654
  console.log(`\n Installing dependencies with ${packageManager}...\n`);
1527
1655
  try {
@@ -1915,6 +2043,228 @@ function pluralizePascal(name) {
1915
2043
  return pkg.plural(name);
1916
2044
  }
1917
2045
  //#endregion
2046
+ //#region src/generator-extension/context.ts
2047
+ /** Convert any string to snake_case (`UserPost` / `user-post` → `user_post`). */
2048
+ function toSnakeCase(name) {
2049
+ return toKebabCase(name).replace(/-/g, "_");
2050
+ }
2051
+ /**
2052
+ * Build a {@link GeneratorContext} from the raw name + invocation
2053
+ * arguments. Centralises the case-transformation logic so every plugin
2054
+ * generator sees the same shape regardless of how the name was typed
2055
+ * on the command line (`Post` vs `post` vs `user_post`).
2056
+ */
2057
+ function buildGeneratorContext(input) {
2058
+ const cwd = input.cwd ?? process.cwd();
2059
+ const usePlural = input.pluralize ?? true;
2060
+ const pascal = toPascalCase(input.name);
2061
+ const camel = toCamelCase(input.name);
2062
+ const kebab = toKebabCase(input.name);
2063
+ const snake = toSnakeCase(input.name);
2064
+ const ctx = {
2065
+ name: input.name,
2066
+ pascal,
2067
+ camel,
2068
+ kebab,
2069
+ snake,
2070
+ modulesDir: input.modulesDir ?? "src/modules",
2071
+ cwd,
2072
+ args: input.args ?? [],
2073
+ flags: input.flags ?? {}
2074
+ };
2075
+ if (usePlural) {
2076
+ const pluralKebab = pluralize(kebab);
2077
+ ctx.pluralKebab = pluralKebab;
2078
+ ctx.pluralPascal = toPascalCase(pluralKebab);
2079
+ ctx.pluralCamel = toCamelCase(pluralKebab);
2080
+ }
2081
+ return ctx;
2082
+ }
2083
+ /** Resolve a generator output path against the context's cwd. */
2084
+ function resolveGeneratorPath(ctx, path) {
2085
+ return resolve(ctx.cwd, path);
2086
+ }
2087
+ /**
2088
+ * Dynamic-import a generator manifest file. Wraps `pathToFileURL` so
2089
+ * callers don't have to think about Windows/Unix path quirks.
2090
+ */
2091
+ async function importManifest(absPath) {
2092
+ return import(pathToFileURL(absPath).href);
2093
+ }
2094
+ //#endregion
2095
+ //#region src/generator-extension/discover.ts
2096
+ /**
2097
+ * Discover generator manifests shipped by every kickjs plugin in the
2098
+ * project's direct deps. Spec rationale: walking the
2099
+ * `node_modules/@scope/kickjs-name/` tree is one option, but reading
2100
+ * the project's own `package.json` and resolving each dep through
2101
+ * Node's module resolver gives:
2102
+ *
2103
+ * 1. Predictable scoping — only deps the project actually declared
2104
+ * get scanned, no surprises from transitive packages
2105
+ * 2. pnpm `.pnpm` store compatibility — `createRequire().resolve()`
2106
+ * handles the symlinked layout correctly
2107
+ * 3. Clear error attribution — the source package name is always
2108
+ * known before the import happens
2109
+ *
2110
+ * The walk is shallow (direct deps only). Transitive plugins that want
2111
+ * to expose generators must be re-exported by a direct dep.
2112
+ *
2113
+ * Caches per-cwd inside one CLI invocation so a single `kick g` call
2114
+ * does the disk + import work exactly once even when multiple
2115
+ * generators dispatch through the same registry.
2116
+ */
2117
+ const cache = /* @__PURE__ */ new Map();
2118
+ async function discoverPluginGenerators(cwd) {
2119
+ const cached = cache.get(cwd);
2120
+ if (cached) return cached;
2121
+ const promise = doDiscover(cwd);
2122
+ cache.set(cwd, promise);
2123
+ return promise;
2124
+ }
2125
+ async function doDiscover(cwd) {
2126
+ const projectPkgPath = resolve(cwd, "package.json");
2127
+ if (!existsSync(projectPkgPath)) return {
2128
+ generators: [],
2129
+ loaded: [],
2130
+ failed: []
2131
+ };
2132
+ const depNames = collectDepNames(JSON.parse(await readFile(projectPkgPath, "utf-8")));
2133
+ const require = createRequire(resolve(cwd, "package.json"));
2134
+ const generators = [];
2135
+ const loaded = [];
2136
+ const failed = [];
2137
+ for (const depName of depNames) {
2138
+ let depPkgPath;
2139
+ try {
2140
+ depPkgPath = require.resolve(`${depName}/package.json`);
2141
+ } catch {
2142
+ continue;
2143
+ }
2144
+ let depPkg;
2145
+ try {
2146
+ depPkg = JSON.parse(await readFile(depPkgPath, "utf-8"));
2147
+ } catch (err) {
2148
+ failed.push({
2149
+ source: depName,
2150
+ reason: `failed to parse package.json: ${err}`
2151
+ });
2152
+ continue;
2153
+ }
2154
+ if (!depPkg.kickjs?.generators) continue;
2155
+ const entryRel = depPkg.kickjs.generators;
2156
+ const entryAbs = resolve(dirname(depPkgPath), entryRel);
2157
+ if (!existsSync(entryAbs)) {
2158
+ failed.push({
2159
+ source: depName,
2160
+ reason: `kickjs.generators points to missing file: ${entryRel}`
2161
+ });
2162
+ continue;
2163
+ }
2164
+ let mod;
2165
+ try {
2166
+ mod = await importManifest(entryAbs);
2167
+ } catch (err) {
2168
+ failed.push({
2169
+ source: depName,
2170
+ reason: `failed to import manifest: ${err}`
2171
+ });
2172
+ continue;
2173
+ }
2174
+ const manifest = mod.default;
2175
+ if (!Array.isArray(manifest)) {
2176
+ failed.push({
2177
+ source: depName,
2178
+ reason: `manifest's default export is not an array of GeneratorSpec`
2179
+ });
2180
+ continue;
2181
+ }
2182
+ for (const entry of manifest) {
2183
+ if (!isGeneratorSpec(entry)) {
2184
+ failed.push({
2185
+ source: depName,
2186
+ reason: `manifest entry is not a valid GeneratorSpec (missing name/files)`
2187
+ });
2188
+ continue;
2189
+ }
2190
+ generators.push({
2191
+ source: depName,
2192
+ spec: entry
2193
+ });
2194
+ }
2195
+ loaded.push(depName);
2196
+ }
2197
+ return {
2198
+ generators,
2199
+ loaded,
2200
+ failed
2201
+ };
2202
+ }
2203
+ function collectDepNames(pkg) {
2204
+ const set = /* @__PURE__ */ new Set();
2205
+ for (const block of [
2206
+ pkg.dependencies,
2207
+ pkg.devDependencies,
2208
+ pkg.peerDependencies
2209
+ ]) {
2210
+ if (!block) continue;
2211
+ for (const name of Object.keys(block)) set.add(name);
2212
+ }
2213
+ return Array.from(set);
2214
+ }
2215
+ function isGeneratorSpec(entry) {
2216
+ if (!entry || typeof entry !== "object") return false;
2217
+ const e = entry;
2218
+ return typeof e.name === "string" && typeof e.files === "function";
2219
+ }
2220
+ //#endregion
2221
+ //#region src/generator-extension/dispatch.ts
2222
+ /**
2223
+ * Look up a plugin generator by name and run it. Returns `null` when
2224
+ * no plugin generator matches — callers can then fall through to the
2225
+ * built-in dispatch (module / scaffold / etc.).
2226
+ *
2227
+ * The lookup is FIRST-MATCH-WINS in dependency declaration order: if
2228
+ * two plugins claim the same generator name, the one whose package was
2229
+ * resolved first wins. Adopters with conflicts should rename the
2230
+ * generator on their side or pin one of the plugins to a different
2231
+ * version.
2232
+ */
2233
+ async function tryDispatchPluginGenerator(input) {
2234
+ const cwd = input.cwd ?? process.cwd();
2235
+ const match = findGenerator(await discoverPluginGenerators(cwd), input.generatorName);
2236
+ if (!match) return null;
2237
+ return runGenerator(match.spec, match.source, input, cwd);
2238
+ }
2239
+ /** Public helper for `kick g --list` — returns every discovered plugin generator. */
2240
+ async function listPluginGenerators(cwd) {
2241
+ return discoverPluginGenerators(cwd);
2242
+ }
2243
+ function findGenerator(discovery, name) {
2244
+ return discovery.generators.find((g) => g.spec.name === name);
2245
+ }
2246
+ async function runGenerator(spec, source, input, cwd) {
2247
+ const ctx = buildGeneratorContext({
2248
+ name: input.itemName,
2249
+ args: input.args,
2250
+ flags: input.flags,
2251
+ modulesDir: input.modulesDir,
2252
+ pluralize: input.pluralize,
2253
+ cwd
2254
+ });
2255
+ const files = await spec.files(ctx);
2256
+ const written = [];
2257
+ for (const file of files) {
2258
+ const absPath = resolveGeneratorPath(ctx, file.path);
2259
+ await writeFileSafe(absPath, file.content);
2260
+ written.push(absPath);
2261
+ }
2262
+ return {
2263
+ files: written,
2264
+ source
2265
+ };
2266
+ }
2267
+ //#endregion
1918
2268
  //#region src/generators/templates/module-index.ts
1919
2269
  const repoLabelMap = {
1920
2270
  inmemory: "in-memory",
@@ -2388,7 +2738,7 @@ export interface I${pascal}Repository {
2388
2738
  * \`@Inject(${pascal.toUpperCase()}_REPOSITORY)\` both return the typed
2389
2739
  * interface — no manual generic, no \`any\` cast.
2390
2740
  */
2391
- export const ${pascal.toUpperCase()}_REPOSITORY = createToken<I${pascal}Repository>('${pascal}/Repository')
2741
+ export const ${pascal.toUpperCase()}_REPOSITORY = createToken<I${pascal}Repository>('app/${kebab}/repository')
2392
2742
  `;
2393
2743
  }
2394
2744
  function generateInMemoryRepository(ctx) {
@@ -3806,97 +4156,114 @@ export const modules: AppModuleClass[] = [${pascal}Module]
3806
4156
  }
3807
4157
  //#endregion
3808
4158
  //#region src/generators/adapter.ts
4159
+ /**
4160
+ * Scaffold a `defineAdapter()` factory under `src/adapters/<name>.adapter.ts`.
4161
+ *
4162
+ * v4 dropped the `class implements AppAdapter` pattern in favour of the
4163
+ * `defineAdapter()` factory (architecture.md §21.3.4). The generated
4164
+ * template uses the new factory shape so adopters get a working
4165
+ * adapter with all four lifecycle hooks (beforeMount, beforeStart,
4166
+ * afterStart, shutdown), a typed config object with defaults, and the
4167
+ * factory's call / `.scoped()` / `.async()` surfaces — without
4168
+ * writing a single class.
4169
+ */
3809
4170
  async function generateAdapter(options) {
3810
4171
  const { name, outDir } = options;
3811
4172
  const kebab = toKebabCase(name);
3812
4173
  const pascal = toPascalCase(name);
3813
4174
  const files = [];
3814
4175
  const filePath = join(outDir, `${kebab}.adapter.ts`);
3815
- await writeFileSafe(filePath, `import type { AppAdapter, AdapterContext, AdapterMiddleware } from '@forinda/kickjs'
4176
+ await writeFileSafe(filePath, `import { defineAdapter, type AdapterContext, type AdapterMiddleware } from '@forinda/kickjs'
3816
4177
 
3817
- export interface ${pascal}AdapterOptions {
3818
- // Add your adapter configuration here
4178
+ /**
4179
+ * Configuration for the ${pascal} adapter.
4180
+ *
4181
+ * Adapters typically take a small config object so callers can tune
4182
+ * behaviour at bootstrap time. Keep the shape narrow — anything
4183
+ * derived from the environment should be read inside the build
4184
+ * function via getEnv(), not forced onto the caller.
4185
+ */
4186
+ export interface ${pascal}AdapterConfig {
4187
+ // Add your adapter configuration here, e.g.:
4188
+ // enabled?: boolean
4189
+ // apiKey?: string
3819
4190
  }
3820
4191
 
3821
4192
  /**
3822
- * ${pascal} adapter.
4193
+ * ${pascal} adapter — built via \`defineAdapter()\` so callers get the
4194
+ * factory's call / \`.scoped()\` / \`.async()\` surfaces for free.
3823
4195
  *
3824
4196
  * Hooks into the Application lifecycle to add middleware, routes,
3825
4197
  * or external service connections.
3826
4198
  *
3827
- * Usage:
3828
- * bootstrap({
3829
- * adapters: [new ${pascal}Adapter({ ... })],
3830
- * })
4199
+ * @example
4200
+ * \`\`\`ts
4201
+ * import { bootstrap } from '@forinda/kickjs'
4202
+ * import { ${pascal}Adapter } from './adapters/${kebab}.adapter'
4203
+ *
4204
+ * bootstrap({
4205
+ * modules,
4206
+ * adapters: [${pascal}Adapter({ /* config overrides *\\/ })],
4207
+ * })
4208
+ * \`\`\`
3831
4209
  */
3832
- export class ${pascal}Adapter implements AppAdapter {
3833
- name = '${pascal}Adapter'
3834
-
3835
- constructor(private options: ${pascal}AdapterOptions = {}) {}
3836
-
3837
- /**
3838
- * Return middleware entries that the Application will mount.
3839
- * Use \`phase\` to control where in the pipeline they run:
3840
- * 'beforeGlobal' | 'afterGlobal' | 'beforeRoutes' | 'afterRoutes'
3841
- */
3842
- middleware(): AdapterMiddleware[] {
3843
- return [
3844
- // Example: add a custom header to all responses
3845
- // {
3846
- // phase: 'beforeGlobal',
3847
- // handler: (_req: any, res: any, next: any) => {
3848
- // res.setHeader('X-${pascal}', 'true')
3849
- // next()
3850
- // },
3851
- // },
3852
- // Example: scope middleware to a specific path
3853
- // {
3854
- // phase: 'beforeRoutes',
3855
- // path: '/api/v1/admin',
3856
- // handler: myAdminMiddleware(),
3857
- // },
3858
- ]
3859
- }
4210
+ export const ${pascal}Adapter = defineAdapter<${pascal}AdapterConfig>({
4211
+ name: '${pascal}Adapter',
4212
+ defaults: {
4213
+ // Default config values go here
4214
+ },
4215
+ build: (_config, { name: _name }) => ({
4216
+ /**
4217
+ * Return middleware entries that the Application will mount.
4218
+ * \`phase\` controls where in the pipeline they run:
4219
+ * 'beforeGlobal' | 'afterGlobal' | 'beforeRoutes' | 'afterRoutes'.
4220
+ */
4221
+ middleware(): AdapterMiddleware[] {
4222
+ return [
4223
+ // Example: add a custom header to all responses
4224
+ // {
4225
+ // phase: 'beforeGlobal',
4226
+ // handler: (_req, res, next) => {
4227
+ // res.setHeader('X-${pascal}', 'true')
4228
+ // next()
4229
+ // },
4230
+ // },
4231
+ ]
4232
+ },
3860
4233
 
3861
- /**
3862
- * Called before global middleware.
3863
- * Use this to mount routes that bypass the middleware stack
3864
- * (health checks, docs UI, static assets).
3865
- */
3866
- beforeMount({ app }: AdapterContext): void {
3867
- // Example: mount a status route
3868
- // app.get('/${kebab}/status', (_req, res) => {
3869
- // res.json({ status: 'ok' })
3870
- // })
3871
- }
4234
+ /**
4235
+ * Called before global middleware. Use this to mount routes that
4236
+ * bypass the middleware stack (health checks, docs UI, static
4237
+ * assets).
4238
+ */
4239
+ beforeMount(_ctx: AdapterContext): void {
4240
+ // Example:
4241
+ // _ctx.app.get('/${kebab}/status', (_req, res) => res.json({ status: 'ok' }))
4242
+ },
3872
4243
 
3873
- /**
3874
- * Called after modules and routes are registered, before the server starts.
3875
- * Use this for late-stage DI registrations or config validation.
3876
- */
3877
- beforeStart({ container }: AdapterContext): void {
3878
- // Example: register a service in the DI container
3879
- // container.registerInstance(MY_TOKEN, new MyService(this.options))
3880
- }
4244
+ /**
4245
+ * Called after modules and routes are registered, before the
4246
+ * server starts. Use this for late-stage DI registrations or
4247
+ * config validation.
4248
+ */
4249
+ beforeStart(_ctx: AdapterContext): void {
4250
+ // Example: _ctx.container.bindToken(MY_TOKEN, new MyService(_config))
4251
+ },
3881
4252
 
3882
- /**
3883
- * Called after the HTTP server is listening.
3884
- * Use this to attach to the raw http.Server (Socket.IO, gRPC, etc).
3885
- */
3886
- afterStart({ server, container }: AdapterContext): void {
3887
- // Example: attach Socket.IO
3888
- // const io = new Server(server)
3889
- // container.registerInstance(SOCKET_IO, io)
3890
- }
4253
+ /**
4254
+ * Called after the HTTP server is listening. Use this to attach
4255
+ * to the raw http.Server (Socket.IO, gRPC, etc).
4256
+ */
4257
+ afterStart(_ctx: AdapterContext): void {
4258
+ // Example: const io = new Server(_ctx.server)
4259
+ },
3891
4260
 
3892
- /**
3893
- * Called on graceful shutdown. Clean up connections.
3894
- */
3895
- async shutdown(): Promise<void> {
3896
- // Example: close a connection pool
3897
- // await this.pool.end()
3898
- }
3899
- }
4261
+ /** Called on graceful shutdown. Clean up connections. */
4262
+ async shutdown(): Promise<void> {
4263
+ // Example: await this.pool.end()
4264
+ },
4265
+ }),
4266
+ })
3900
4267
  `);
3901
4268
  files.push(filePath);
3902
4269
  return files;
@@ -3904,80 +4271,84 @@ export class ${pascal}Adapter implements AppAdapter {
3904
4271
  //#endregion
3905
4272
  //#region src/generators/plugin.ts
3906
4273
  /**
3907
- * Scaffold a `KickPlugin` under `src/plugins/<name>.plugin.ts`.
4274
+ * Scaffold a `definePlugin()` factory under `src/plugins/<name>.plugin.ts`.
3908
4275
  *
3909
- * Plugins are the canonical place to wire DI bindings, load extra
3910
- * modules, add middleware, or attach startup hooks without writing a
3911
- * full adapter. The generated template implements every optional
3912
- * `KickPlugin` hook with commented examples so users can uncomment
3913
- * the ones they need and delete the rest.
4276
+ * v4 standardised on the `definePlugin()` factory pattern (architecture
4277
+ * §21.2.2) same surface as `defineAdapter()`, so adopters learn one
4278
+ * mental model. The generated template uses the factory shape with a
4279
+ * typed config object, defaults block, and a build function returning
4280
+ * the underlying KickPlugin hooks.
3914
4281
  */
3915
4282
  async function generatePlugin(options) {
3916
4283
  const { name, outDir } = options;
3917
4284
  const kebab = toKebabCase(name);
3918
4285
  const pascal = toPascalCase(name);
3919
- const factoryName = `${toCamelCase(name)}Plugin`;
3920
4286
  const files = [];
3921
4287
  const filePath = join(outDir, `${kebab}.plugin.ts`);
3922
- await writeFileSafe(filePath, `import type { KickPlugin, Container, AppAdapter, AppModuleClass } from '@forinda/kickjs'
4288
+ await writeFileSafe(filePath, `import {
4289
+ definePlugin,
4290
+ type AppAdapter,
4291
+ type AppModuleClass,
4292
+ type Container,
4293
+ } from '@forinda/kickjs'
3923
4294
 
3924
4295
  /**
3925
- * Options for the ${pascal} plugin.
4296
+ * Configuration for the ${pascal} plugin.
3926
4297
  *
3927
- * Plugins typically take a small options object in their factory so
3928
- * callers can configure them inline at bootstrap time. Keep the
3929
- * shape narrow — anything derived from the environment should be
3930
- * read via \`getEnv\` inside the plugin itself, not forced onto the
3931
- * caller.
4298
+ * Plugins typically take a small config object so callers can tune
4299
+ * behaviour at bootstrap time. Keep the shape narrow — anything
4300
+ * derived from the environment should be read inside the build
4301
+ * function via getEnv(), not forced onto the caller.
3932
4302
  */
3933
- export interface ${pascal}PluginOptions {
3934
- // Add your plugin options here, for example:
4303
+ export interface ${pascal}PluginConfig {
4304
+ // Add your plugin config here, e.g.:
3935
4305
  // enabled?: boolean
3936
4306
  // apiKey?: string
3937
4307
  }
3938
4308
 
3939
4309
  /**
3940
- * ${pascal} plugin.
4310
+ * ${pascal} plugin — built via \`definePlugin()\` so callers get the
4311
+ * factory's call / \`.scoped()\` / \`.async()\` surfaces for free.
3941
4312
  *
3942
- * A \`KickPlugin\` bundles DI bindings, modules, adapters, and
3943
- * middleware into one object that can be added to \`bootstrap({ plugins })\`.
3944
- * Every hook is optional — delete the ones you don't need and keep
3945
- * only the surface your plugin actually uses.
4313
+ * A plugin bundles DI bindings, modules, adapters, and middleware
4314
+ * into one object that can be added to \`bootstrap({ plugins })\`.
3946
4315
  *
3947
- * Lifecycle order:
4316
+ * Lifecycle order (each hook is optional — delete the ones you don't
4317
+ * need and keep only the surface your plugin actually uses):
3948
4318
  *
3949
- * 1. \`register(container)\` — runs before user modules load. Use
4319
+ * 1. \`register(container)\` — runs before user modules load. Use
3950
4320
  * it to bind services that modules depend on.
3951
- * 2. \`modules()\` — plugin modules load before user modules.
3952
- * 3. \`adapters()\` — plugin adapters are added before user adapters.
3953
- * 4. \`middleware()\` — plugin middleware runs before user middleware.
3954
- * 5. \`onReady(container)\` — runs after the app has fully bootstrapped.
3955
- * 6. \`shutdown()\` — runs on graceful shutdown.
4321
+ * 2. \`modules()\` — plugin modules load before user modules.
4322
+ * 3. \`adapters()\` — plugin adapters mount before user adapters.
4323
+ * 4. \`middleware()\` — plugin middleware runs before user middleware.
4324
+ * 5. \`onReady(container)\` — runs after the app has fully bootstrapped.
4325
+ * 6. \`shutdown()\` — runs on graceful shutdown.
3956
4326
  *
3957
4327
  * @example
3958
4328
  * \`\`\`ts
3959
4329
  * import { bootstrap } from '@forinda/kickjs'
3960
- * import { ${factoryName} } from './plugins/${kebab}.plugin'
4330
+ * import { ${pascal}Plugin } from './plugins/${kebab}.plugin'
3961
4331
  *
3962
4332
  * export const app = await bootstrap({
3963
4333
  * modules,
3964
- * plugins: [${factoryName}({})],
4334
+ * plugins: [${pascal}Plugin({ /* config overrides *\\/ })],
3965
4335
  * })
3966
4336
  * \`\`\`
3967
4337
  */
3968
- export function ${factoryName}(options: ${pascal}PluginOptions = {}): KickPlugin {
3969
- return {
3970
- name: '${kebab}',
3971
-
4338
+ export const ${pascal}Plugin = definePlugin<${pascal}PluginConfig>({
4339
+ name: '${pascal}Plugin',
4340
+ defaults: {
4341
+ // Default config values go here
4342
+ },
4343
+ build: (_config, { name: _name }) => ({
3972
4344
  /**
3973
4345
  * Register DI bindings before modules load.
3974
4346
  * Use \`container.registerInstance(TOKEN, value)\` for singletons
3975
4347
  * and \`container.registerFactory(TOKEN, () => ...)\` for lazy
3976
4348
  * constructions.
3977
4349
  */
3978
- register(container: Container): void {
3979
- // Example: bind a configured service to a DI token
3980
- // container.registerInstance(MY_TOKEN, new MyService(options))
4350
+ register(_container: Container): void {
4351
+ // Example: _container.registerInstance(MY_TOKEN, new MyService(_config))
3981
4352
  },
3982
4353
 
3983
4354
  /**
@@ -3993,11 +4364,11 @@ export function ${factoryName}(options: ${pascal}PluginOptions = {}): KickPlugin
3993
4364
 
3994
4365
  /**
3995
4366
  * Return adapter instances to be added to the application.
3996
- * Plugin adapters are added before user adapters.
4367
+ * Plugin adapters mount before user adapters.
3997
4368
  */
3998
4369
  adapters(): AppAdapter[] {
3999
4370
  return [
4000
- // new MyAdapter({ ... }),
4371
+ // MyAdapter({ ... }),
4001
4372
  ]
4002
4373
  },
4003
4374
 
@@ -4005,10 +4376,10 @@ export function ${factoryName}(options: ${pascal}PluginOptions = {}): KickPlugin
4005
4376
  * Return Express middleware entries to be added to the global
4006
4377
  * pipeline. Plugin middleware runs before user-defined middleware.
4007
4378
  */
4008
- middleware(): any[] {
4379
+ middleware(): unknown[] {
4009
4380
  return [
4010
4381
  // helmet(),
4011
- // myCustomMiddleware(options),
4382
+ // myCustomMiddleware(_config),
4012
4383
  ]
4013
4384
  },
4014
4385
 
@@ -4017,9 +4388,9 @@ export function ${factoryName}(options: ${pascal}PluginOptions = {}): KickPlugin
4017
4388
  * for post-startup work like logging, health checks, or warming
4018
4389
  * a cache. Runs once per process.
4019
4390
  */
4020
- async onReady(container: Container): Promise<void> {
4021
- // const logger = container.resolve(Logger)
4022
- // logger.info('${pascal} plugin ready')
4391
+ async onReady(_container: Container): Promise<void> {
4392
+ // const log = _container.resolve(Logger)
4393
+ // log.info('${pascal} plugin ready')
4023
4394
  },
4024
4395
 
4025
4396
  /**
@@ -4027,10 +4398,10 @@ export function ${factoryName}(options: ${pascal}PluginOptions = {}): KickPlugin
4027
4398
  * resources this plugin owns (connections, timers, subscriptions).
4028
4399
  */
4029
4400
  async shutdown(): Promise<void> {
4030
- // await this.connection?.close()
4401
+ // Example: await this.connection?.close()
4031
4402
  },
4032
- }
4033
- }
4403
+ }),
4404
+ })
4034
4405
  `);
4035
4406
  files.push(filePath);
4036
4407
  return files;
@@ -4353,6 +4724,188 @@ export default defineConfig({
4353
4724
  return [filePath];
4354
4725
  }
4355
4726
  //#endregion
4727
+ //#region src/config.ts
4728
+ const PACKAGE_MANAGERS = [
4729
+ "pnpm",
4730
+ "npm",
4731
+ "yarn",
4732
+ "bun"
4733
+ ];
4734
+ const BUILTIN_REPO_TYPES = [
4735
+ "drizzle",
4736
+ "inmemory",
4737
+ "prisma"
4738
+ ];
4739
+ /** Resolve module config from `modules.*` block. */
4740
+ function resolveModuleConfig(config) {
4741
+ if (!config) return {};
4742
+ const mc = {
4743
+ dir: config.modules?.dir,
4744
+ repo: config.modules?.repo,
4745
+ schemaDir: config.modules?.schemaDir,
4746
+ pluralize: config.modules?.pluralize,
4747
+ prismaClientPath: config.modules?.prismaClientPath
4748
+ };
4749
+ if (mc.repo && typeof mc.repo === "string" && !BUILTIN_REPO_TYPES.includes(mc.repo)) console.warn(` Warning: modules.repo '${mc.repo}' is not a built-in type (${BUILTIN_REPO_TYPES.join(", ")}). It will generate a stub repository. Use { name: '${mc.repo}' } to silence this warning.`);
4750
+ return mc;
4751
+ }
4752
+ const CONFIG_FILES = [
4753
+ "kick.config.ts",
4754
+ "kick.config.js",
4755
+ "kick.config.mjs",
4756
+ "kick.config.json"
4757
+ ];
4758
+ /** Load kick.config.* from the project root */
4759
+ async function loadKickConfig(cwd) {
4760
+ for (const filename of CONFIG_FILES) {
4761
+ const filepath = join(cwd, filename);
4762
+ try {
4763
+ await access(filepath);
4764
+ } catch {
4765
+ continue;
4766
+ }
4767
+ if (filename.endsWith(".json")) {
4768
+ const content = await readFile(filepath, "utf-8");
4769
+ return JSON.parse(content);
4770
+ }
4771
+ try {
4772
+ const { pathToFileURL } = await import("node:url");
4773
+ const mod = await import(pathToFileURL(filepath).href);
4774
+ const config = mod.default ?? mod;
4775
+ const warnings = validateAssetMap(config, cwd);
4776
+ for (const warning of warnings) console.warn(` Warning: ${warning}`);
4777
+ return config;
4778
+ } catch (err) {
4779
+ if (filename.endsWith(".ts")) console.warn(`Warning: Failed to load ${filename}. TypeScript config files require a runtime loader (e.g. tsx, ts-node) or use kick.config.js/.mjs instead.`);
4780
+ continue;
4781
+ }
4782
+ }
4783
+ return null;
4784
+ }
4785
+ /**
4786
+ * Validate `assetMap` entries on a loaded config. Returns a list of
4787
+ * human-readable warnings; the caller decides how to surface them
4788
+ * (typically `console.warn`). Never throws — `kick g` and other
4789
+ * unrelated commands should keep working even when the assetMap is
4790
+ * misconfigured.
4791
+ *
4792
+ * Checks:
4793
+ *
4794
+ * - Each entry's `src` is a non-empty string.
4795
+ * - The `src` directory exists on disk (otherwise the typegen + build
4796
+ * steps will fail later with cryptic errors).
4797
+ * - `dest` doesn't escape the project root (defensive — a `dest:
4798
+ * '../../etc'` typo could write files outside the workspace).
4799
+ * - The namespace key is a non-empty string and doesn't include a
4800
+ * `/` (would conflict with the `<namespace>/<key>` manifest format).
4801
+ */
4802
+ function validateAssetMap(config, cwd) {
4803
+ const warnings = [];
4804
+ if (!config?.assetMap) return warnings;
4805
+ const root = resolve(cwd);
4806
+ for (const [namespace, entry] of Object.entries(config.assetMap)) {
4807
+ if (!namespace || namespace.includes("/")) {
4808
+ warnings.push(`assetMap key '${namespace}' is invalid — must be a non-empty string without '/'`);
4809
+ continue;
4810
+ }
4811
+ if (typeof entry?.src !== "string" || entry.src.length === 0) {
4812
+ warnings.push(`assetMap.${namespace} is missing a non-empty 'src' field`);
4813
+ continue;
4814
+ }
4815
+ if (!existsSync(resolve(cwd, entry.src))) warnings.push(`assetMap.${namespace}.src ('${entry.src}') does not exist — typegen + build will fail`);
4816
+ if (entry.dest) {
4817
+ if (escapesRoot$1(resolve(cwd, entry.dest), root)) warnings.push(`assetMap.${namespace}.dest ('${entry.dest}') resolves outside the project root — refusing to copy`);
4818
+ }
4819
+ }
4820
+ return warnings;
4821
+ }
4822
+ /**
4823
+ * Returns true when `path` (absolute) resolves outside of `root`
4824
+ * (also absolute). Uses `path.relative` for accuracy:
4825
+ *
4826
+ * - The result is empty when paths are identical (inside).
4827
+ * - It starts with `..` when the path traverses outside the root.
4828
+ * - It's absolute (Windows: cross-drive) when there's no relative
4829
+ * path between them.
4830
+ *
4831
+ * Avoids the prefix-match pitfalls of `startsWith` (e.g. `/app`
4832
+ * matching `/app2/...`, or case-mismatches on macOS / Windows).
4833
+ */
4834
+ function escapesRoot$1(path, root) {
4835
+ const rel = relative(root, path);
4836
+ return rel === "" ? false : rel.startsWith("..") || isAbsolute(rel);
4837
+ }
4838
+ //#endregion
4839
+ //#region src/generators/agent-docs.ts
4840
+ const VALID_TEMPLATES = new Set([
4841
+ "rest",
4842
+ "graphql",
4843
+ "ddd",
4844
+ "cqrs",
4845
+ "minimal"
4846
+ ]);
4847
+ function detectName(outDir, override) {
4848
+ if (override) return override;
4849
+ try {
4850
+ const pkg = JSON.parse(readFileSync(join(outDir, "package.json"), "utf-8"));
4851
+ if (pkg.name) return pkg.name.replace(/^@[^/]+\//, "");
4852
+ } catch {}
4853
+ return outDir.split("/").filter(Boolean).pop() ?? "app";
4854
+ }
4855
+ function detectPm(outDir, override) {
4856
+ if (override) return override;
4857
+ try {
4858
+ const pkg = JSON.parse(readFileSync(join(outDir, "package.json"), "utf-8"));
4859
+ if (pkg.packageManager) return pkg.packageManager.split("@")[0];
4860
+ } catch {}
4861
+ return "pnpm";
4862
+ }
4863
+ async function detectTemplate(outDir, override) {
4864
+ if (override) return override;
4865
+ try {
4866
+ const pattern = (await loadKickConfig(outDir))?.pattern;
4867
+ if (pattern && VALID_TEMPLATES.has(pattern)) return pattern;
4868
+ } catch {}
4869
+ return "ddd";
4870
+ }
4871
+ async function generateAgentDocs(options) {
4872
+ const only = options.only ?? "all";
4873
+ const name = detectName(options.outDir, options.name);
4874
+ const pm = detectPm(options.outDir, options.pm);
4875
+ const template = await detectTemplate(options.outDir, options.template);
4876
+ const wantsAgents = only === "agents" || only === "both" || only === "all";
4877
+ const wantsClaude = only === "claude" || only === "both" || only === "all";
4878
+ const wantsSkills = only === "skills" || only === "all";
4879
+ const targets = [];
4880
+ if (wantsAgents) targets.push({
4881
+ file: join(options.outDir, "AGENTS.md"),
4882
+ render: () => generateAgents(name, template, pm)
4883
+ });
4884
+ if (wantsClaude) targets.push({
4885
+ file: join(options.outDir, "CLAUDE.md"),
4886
+ render: () => generateClaude(name, template, pm)
4887
+ });
4888
+ if (wantsSkills) targets.push({
4889
+ file: join(options.outDir, "kickjs-skills.md"),
4890
+ render: () => generateKickJsSkills(name, template, pm)
4891
+ });
4892
+ const written = [];
4893
+ for (const { file, render } of targets) {
4894
+ if (existsSync(file) && !options.force) {
4895
+ if (!await confirm({
4896
+ message: `${file.replace(options.outDir + "/", "")} already exists. Overwrite?`,
4897
+ initialValue: false
4898
+ })) {
4899
+ console.log(` Skipped — existing ${file.replace(options.outDir + "/", "")} preserved.`);
4900
+ continue;
4901
+ }
4902
+ }
4903
+ await writeFileSafe(file, render());
4904
+ written.push(file);
4905
+ }
4906
+ return written;
4907
+ }
4908
+ //#endregion
4356
4909
  //#region src/generators/auth-scaffold.ts
4357
4910
  /**
4358
4911
  * Generate a complete auth module with registration, login, logout,
@@ -4441,7 +4994,7 @@ import type { RequestContext } from '@forinda/kickjs'
4441
4994
  import { Autowired } from '@forinda/kickjs'
4442
4995
  import { AuthService } from './auth.service'
4443
4996
 
4444
- @Controller('/auth')
4997
+ @Controller()
4445
4998
  @Authenticated()
4446
4999
  export class AuthController {
4447
5000
  @Autowired() private authService!: AuthService
@@ -4523,7 +5076,7 @@ import type { RequestContext } from '@forinda/kickjs'
4523
5076
  import { Autowired } from '@forinda/kickjs'
4524
5077
  import { AuthService } from './auth.service'
4525
5078
 
4526
- @Controller('/auth')
5079
+ @Controller()
4527
5080
  @Authenticated()
4528
5081
  export class AuthController {
4529
5082
  @Autowired() private authService!: AuthService
@@ -5162,7 +5715,7 @@ export interface I${pascal}Repository {
5162
5715
  * \`@Inject(${pascal.toUpperCase()}_REPOSITORY)\` both return the typed
5163
5716
  * interface — no manual generic, no \`any\` cast.
5164
5717
  */
5165
- export const ${pascal.toUpperCase()}_REPOSITORY = createToken<I${pascal}Repository>('${pascal}/Repository')
5718
+ export const ${pascal.toUpperCase()}_REPOSITORY = createToken<I${pascal}Repository>('app/${kebab}/repository')
5166
5719
  `;
5167
5720
  }
5168
5721
  function genDomainService(pascal, kebab) {
@@ -5318,62 +5871,6 @@ describe('${pascal}', () => {
5318
5871
  return files;
5319
5872
  }
5320
5873
  //#endregion
5321
- //#region src/config.ts
5322
- const PACKAGE_MANAGERS = [
5323
- "pnpm",
5324
- "npm",
5325
- "yarn",
5326
- "bun"
5327
- ];
5328
- const BUILTIN_REPO_TYPES = [
5329
- "drizzle",
5330
- "inmemory",
5331
- "prisma"
5332
- ];
5333
- /** Resolve module config with backward-compatible fallbacks from top-level fields */
5334
- function resolveModuleConfig(config) {
5335
- if (!config) return {};
5336
- const mc = {
5337
- dir: config.modules?.dir ?? config.modulesDir,
5338
- repo: config.modules?.repo ?? config.defaultRepo,
5339
- schemaDir: config.modules?.schemaDir ?? config.schemaDir,
5340
- pluralize: config.modules?.pluralize ?? config.pluralize,
5341
- prismaClientPath: config.modules?.prismaClientPath
5342
- };
5343
- if (mc.repo && typeof mc.repo === "string" && !BUILTIN_REPO_TYPES.includes(mc.repo)) console.warn(` Warning: modules.repo '${mc.repo}' is not a built-in type (${BUILTIN_REPO_TYPES.join(", ")}). It will generate a stub repository. Use { name: '${mc.repo}' } to silence this warning.`);
5344
- return mc;
5345
- }
5346
- const CONFIG_FILES = [
5347
- "kick.config.ts",
5348
- "kick.config.js",
5349
- "kick.config.mjs",
5350
- "kick.config.json"
5351
- ];
5352
- /** Load kick.config.* from the project root */
5353
- async function loadKickConfig(cwd) {
5354
- for (const filename of CONFIG_FILES) {
5355
- const filepath = join(cwd, filename);
5356
- try {
5357
- await access(filepath);
5358
- } catch {
5359
- continue;
5360
- }
5361
- if (filename.endsWith(".json")) {
5362
- const content = await readFile(filepath, "utf-8");
5363
- return JSON.parse(content);
5364
- }
5365
- try {
5366
- const { pathToFileURL } = await import("node:url");
5367
- const mod = await import(pathToFileURL(filepath).href);
5368
- return mod.default ?? mod;
5369
- } catch (err) {
5370
- if (filename.endsWith(".ts")) console.warn(`Warning: Failed to load ${filename}. TypeScript config files require a runtime loader (e.g. tsx, ts-node) or use kick.config.js/.mjs instead.`);
5371
- continue;
5372
- }
5373
- }
5374
- return null;
5375
- }
5376
- //#endregion
5377
5874
  //#region src/typegen/scanner.ts
5378
5875
  /** Decorators that mark a class as DI-managed */
5379
5876
  const DECORATOR_NAMES = [
@@ -5428,6 +5925,28 @@ const BARE_CREATE_TOKEN_REGEX = /createToken\s*(?:<[^>]*>)?\s*\(\s*['"`]([^'"`]+
5428
5925
  /** Match `@Inject('literal')` — only literals; computed args are skipped */
5429
5926
  const INJECT_LITERAL_REGEX = /@Inject\s*\(\s*['"`]([^'"`]+)['"`]\s*\)/g;
5430
5927
  /**
5928
+ * Match the start of a `defineAdapter(...)` or `definePlugin(...)` call,
5929
+ * tolerating optional `<TConfig, TExtra>` generics. Captures the helper
5930
+ * name. The callsite's first-arg object is parsed forward via
5931
+ * `findBalancedClose` so nested objects/parens don't confuse us.
5932
+ */
5933
+ const DEFINE_HELPER_START = /\b(defineAdapter|definePlugin)\s*(?:<[^>]*>)?\s*\(/g;
5934
+ /**
5935
+ * Match a class declaration whose `implements` clause includes `AppAdapter`.
5936
+ * Captures the class name. Used to pick up the (rare, post-defineAdapter)
5937
+ * legacy class-style adapters so their literal `name = '...'` field can
5938
+ * still feed `KickJsPluginRegistry`.
5939
+ */
5940
+ const APP_ADAPTER_CLASS_REGEX = new RegExp(String.raw`export\s+(?:default\s+)?(?:abstract\s+)?class\s+(\w+)` + String.raw`(?:\s+extends\s+\w+(?:<[^>]*>)?)?` + String.raw`\s+implements\s+[^{]*\bAppAdapter\b`, "g");
5941
+ /** Match a string-literal `name = '...'` field on a class body. */
5942
+ const CLASS_NAME_FIELD_REGEX = /\bname\s*(?::\s*[^=]+)?=\s*['"`]([^'"`]+)['"`]/;
5943
+ /**
5944
+ * Match the start of a `defineAugmentation('Name', ...)` call. Captures
5945
+ * the literal name. The optional second-arg object is parsed forward so
5946
+ * `description` / `example` can be pulled out.
5947
+ */
5948
+ const DEFINE_AUGMENTATION_START = /\bdefineAugmentation\s*\(\s*['"`]([^'"`]+)['"`]\s*(,\s*\{)?/g;
5949
+ /**
5431
5950
  * Locate the start of a route decorator: `@Get(`, `@Post(`, etc.
5432
5951
  * Used by `extractRoutesFromSource`; the rest of the route declaration
5433
5952
  * (balanced parens, stacked decorators, method name) is parsed by walking
@@ -5774,6 +6293,120 @@ function extractInjectsFromSource(source, filePath, cwd) {
5774
6293
  return out;
5775
6294
  }
5776
6295
  /**
6296
+ * Extract the bounds of an object literal that begins at `openBracePos`
6297
+ * (the index of the `{` character). Returns the index of the matching `}`
6298
+ * or -1 if no match is found. Counts balanced braces only — does not
6299
+ * understand string literals so a `{` or `}` inside a string inside the
6300
+ * object will skew the depth counter (matches `findBalancedClose`).
6301
+ */
6302
+ function findBalancedBrace(text, openBracePos) {
6303
+ let depth = 1;
6304
+ for (let i = openBracePos + 1; i < text.length; i++) {
6305
+ const ch = text[i];
6306
+ if (ch === "{") depth++;
6307
+ else if (ch === "}") {
6308
+ depth--;
6309
+ if (depth === 0) return i;
6310
+ }
6311
+ }
6312
+ return -1;
6313
+ }
6314
+ /**
6315
+ * Extract plugins/adapters declared via `defineAdapter({ name: '...' })`
6316
+ * or `definePlugin({ name: '...' })` calls and via class-style adapters
6317
+ * (`class XxxAdapter implements AppAdapter` with a string-literal `name`
6318
+ * field).
6319
+ *
6320
+ * Only the literal `name:` field feeds the result — the symbol on the LHS
6321
+ * is irrelevant since `dependsOn` references the runtime name.
6322
+ */
6323
+ function extractPluginsAndAdaptersFromSource(source, filePath, cwd) {
6324
+ const out = [];
6325
+ const relPath = toRelative(filePath, cwd);
6326
+ const seen = /* @__PURE__ */ new Set();
6327
+ DEFINE_HELPER_START.lastIndex = 0;
6328
+ let helperMatch;
6329
+ while ((helperMatch = DEFINE_HELPER_START.exec(source)) !== null) {
6330
+ const helper = helperMatch[1];
6331
+ const openParen = DEFINE_HELPER_START.lastIndex - 1;
6332
+ const closeParen = findBalancedClose(source, openParen);
6333
+ if (closeParen < 0) continue;
6334
+ const callArgs = source.slice(openParen + 1, closeParen);
6335
+ const nameMatch = /\bname\s*:\s*['"`]([^'"`]+)['"`]/.exec(callArgs);
6336
+ if (!nameMatch) continue;
6337
+ const name = nameMatch[1];
6338
+ const dedupeKey = `${helper}::${name}::${filePath}`;
6339
+ if (seen.has(dedupeKey)) continue;
6340
+ seen.add(dedupeKey);
6341
+ out.push({
6342
+ kind: helper === "definePlugin" ? "plugin" : "adapter",
6343
+ name,
6344
+ filePath,
6345
+ relativePath: relPath
6346
+ });
6347
+ }
6348
+ APP_ADAPTER_CLASS_REGEX.lastIndex = 0;
6349
+ let classMatch;
6350
+ while ((classMatch = APP_ADAPTER_CLASS_REGEX.exec(source)) !== null) {
6351
+ const classStart = classMatch.index;
6352
+ const bracePos = source.indexOf("{", classStart);
6353
+ if (bracePos < 0) continue;
6354
+ const closeBrace = findBalancedBrace(source, bracePos);
6355
+ if (closeBrace < 0) continue;
6356
+ const body = source.slice(bracePos + 1, closeBrace);
6357
+ const nameMatch = CLASS_NAME_FIELD_REGEX.exec(body);
6358
+ if (!nameMatch) continue;
6359
+ const name = nameMatch[1];
6360
+ const dedupeKey = `class::${name}::${filePath}`;
6361
+ if (seen.has(dedupeKey)) continue;
6362
+ seen.add(dedupeKey);
6363
+ out.push({
6364
+ kind: "adapter",
6365
+ name,
6366
+ filePath,
6367
+ relativePath: relPath
6368
+ });
6369
+ }
6370
+ return out;
6371
+ }
6372
+ /**
6373
+ * Extract `defineAugmentation('Name', { description, example })` calls
6374
+ * from a source file. The metadata object is optional — when absent both
6375
+ * `description` and `example` resolve to `null`.
6376
+ */
6377
+ function extractAugmentationsFromSource(source, filePath, cwd) {
6378
+ const out = [];
6379
+ const relPath = toRelative(filePath, cwd);
6380
+ DEFINE_AUGMENTATION_START.lastIndex = 0;
6381
+ let match;
6382
+ while ((match = DEFINE_AUGMENTATION_START.exec(source)) !== null) {
6383
+ const name = match[1];
6384
+ let description = null;
6385
+ let example = null;
6386
+ if (match[2]) {
6387
+ const bracePos = source.indexOf("{", match.index + match[0].length - 1);
6388
+ if (bracePos >= 0) {
6389
+ const closeBrace = findBalancedBrace(source, bracePos);
6390
+ if (closeBrace >= 0) {
6391
+ const body = source.slice(bracePos + 1, closeBrace);
6392
+ const descMatch = /\bdescription\s*:\s*['"`]([^'"`]+)['"`]/.exec(body);
6393
+ const exampleMatch = /\bexample\s*:\s*['"`]([^'"`]+)['"`]/.exec(body);
6394
+ description = descMatch ? descMatch[1] : null;
6395
+ example = exampleMatch ? exampleMatch[1] : null;
6396
+ }
6397
+ }
6398
+ }
6399
+ out.push({
6400
+ name,
6401
+ description,
6402
+ example,
6403
+ filePath,
6404
+ relativePath: relPath
6405
+ });
6406
+ }
6407
+ return out;
6408
+ }
6409
+ /**
5777
6410
  * Default search order for the env schema file. Newer projects keep
5778
6411
  * the schema under `src/config/` so the framework's "config" concept
5779
6412
  * has a single home; older scaffolds dropped it at `src/env.ts` (kept
@@ -5844,6 +6477,8 @@ async function scanProject(opts) {
5844
6477
  const routes = [];
5845
6478
  const tokens = [];
5846
6479
  const injects = [];
6480
+ const pluginsAndAdapters = [];
6481
+ const augmentations = [];
5847
6482
  const sources = /* @__PURE__ */ new Map();
5848
6483
  for (const file of files) {
5849
6484
  let source;
@@ -5856,6 +6491,8 @@ async function scanProject(opts) {
5856
6491
  classes.push(...extractClassesFromSource(source, file, opts.cwd));
5857
6492
  tokens.push(...extractTokensFromSource(source, file, opts.cwd));
5858
6493
  injects.push(...extractInjectsFromSource(source, file, opts.cwd));
6494
+ pluginsAndAdapters.push(...extractPluginsAndAdaptersFromSource(source, file, opts.cwd));
6495
+ augmentations.push(...extractAugmentationsFromSource(source, file, opts.cwd));
5859
6496
  }
5860
6497
  for (const [file, source] of sources) {
5861
6498
  const classesInFile = classes.filter((c) => c.filePath === file);
@@ -5868,15 +6505,148 @@ async function scanProject(opts) {
5868
6505
  tokens.sort((a, b) => a.name.localeCompare(b.name) || a.relativePath.localeCompare(b.relativePath));
5869
6506
  injects.sort((a, b) => a.name.localeCompare(b.name) || a.relativePath.localeCompare(b.relativePath));
5870
6507
  routes.sort((a, b) => a.controller.localeCompare(b.controller) || a.method.localeCompare(b.method));
6508
+ pluginsAndAdapters.sort((a, b) => a.name.localeCompare(b.name) || a.relativePath.localeCompare(b.relativePath));
6509
+ augmentations.sort((a, b) => a.name.localeCompare(b.name) || a.relativePath.localeCompare(b.relativePath));
5871
6510
  return {
5872
6511
  classes,
5873
6512
  routes,
5874
6513
  tokens,
5875
6514
  injects,
5876
6515
  collisions: findCollisions(classes),
5877
- env: await detectEnvFile(opts.cwd, opts.envFile ?? "src/env.ts")
6516
+ env: await detectEnvFile(opts.cwd, opts.envFile ?? "src/env.ts"),
6517
+ pluginsAndAdapters,
6518
+ augmentations
6519
+ };
6520
+ }
6521
+ //#endregion
6522
+ //#region src/typegen/asset-types.ts
6523
+ /**
6524
+ * Walks every `assetMap` entry's source directory + emits a typed
6525
+ * `KickAssets` ambient augmentation (assets-plan.md PR 4). Generates
6526
+ * `.kickjs/types/assets.d.ts` so adopters get autocomplete on
6527
+ * `assets.<namespace>.<key>` and `@Asset('<namespace>/<key>')`.
6528
+ *
6529
+ * Pure module — no side effects beyond what the caller does with the
6530
+ * returned content. Mirrors the shape of `renderPlugins` /
6531
+ * `renderRegistry` in the generator so the typegen output stays
6532
+ * consistent across surfaces.
6533
+ *
6534
+ * @module @forinda/kickjs-cli/typegen/asset-types
6535
+ */
6536
+ function discoverAssets(assetMap, cwd) {
6537
+ if (!assetMap) return {
6538
+ entries: [],
6539
+ count: 0
6540
+ };
6541
+ const seen = /* @__PURE__ */ new Map();
6542
+ for (const [namespace, entry] of Object.entries(assetMap)) {
6543
+ if (!entry || typeof entry.src !== "string") continue;
6544
+ const srcAbs = resolve(cwd, entry.src);
6545
+ if (!isDir(srcAbs)) continue;
6546
+ const matches = globSync(entry.glob ?? "**/*", {
6547
+ cwd: srcAbs,
6548
+ nodir: true,
6549
+ dot: false,
6550
+ posix: true
6551
+ });
6552
+ matches.sort();
6553
+ for (const rel of matches) {
6554
+ const key = stripExt$1(rel);
6555
+ const logical = `${namespace}/${key}`;
6556
+ seen.set(logical, {
6557
+ namespace,
6558
+ key
6559
+ });
6560
+ }
6561
+ }
6562
+ return {
6563
+ entries: [...seen.values()],
6564
+ count: seen.size
5878
6565
  };
5879
6566
  }
6567
+ function renderAssetTypes(discovered) {
6568
+ const HEADER = `/* eslint-disable */
6569
+ // AUTO-GENERATED by \`kick typegen\`. DO NOT EDIT.
6570
+ // Re-run with \`kick typegen\` or rely on \`kick dev\` to refresh.
6571
+ `;
6572
+ if (discovered.entries.length === 0) return `${HEADER}
6573
+ declare module '@forinda/kickjs' {
6574
+ /**
6575
+ * Map of every typed asset discovered in the project's assetMap.
6576
+ * (No assetMap entries discovered yet — declare with
6577
+ * \`assetMap: { name: { src: 'src/...' } }\` in kick.config.ts.)
6578
+ */
6579
+ interface KickAssets {}
6580
+ }
6581
+
6582
+ export {}
6583
+ `;
6584
+ const tree = {};
6585
+ for (const entry of discovered.entries) {
6586
+ const path = `${entry.namespace}/${entry.key}`.split("/");
6587
+ let node = tree;
6588
+ for (let i = 0; i < path.length - 1; i++) {
6589
+ const part = path[i];
6590
+ const existing = node[part];
6591
+ if (existing === LEAF) {
6592
+ const promoted = {};
6593
+ node[part] = promoted;
6594
+ node = promoted;
6595
+ } else {
6596
+ if (!existing) node[part] = {};
6597
+ node = node[part];
6598
+ }
6599
+ }
6600
+ const leaf = path[path.length - 1];
6601
+ if (typeof node[leaf] === "object") continue;
6602
+ node[leaf] = LEAF;
6603
+ }
6604
+ return `${HEADER}
6605
+ declare module '@forinda/kickjs' {
6606
+ /**
6607
+ * Map of every typed asset discovered in the project's assetMap.
6608
+ * Each leaf is a \`() => string\` thunk that returns the resolved
6609
+ * absolute path for the file in the current run mode (dev → src,
6610
+ * prod → dist).
6611
+ */
6612
+ interface KickAssets {
6613
+ ${renderTree(tree, " ")}
6614
+ }
6615
+ }
6616
+
6617
+ export {}
6618
+ `;
6619
+ }
6620
+ const LEAF = Symbol("asset-leaf");
6621
+ function renderTree(node, indent) {
6622
+ const keys = Object.keys(node).sort();
6623
+ const lines = [];
6624
+ for (const key of keys) {
6625
+ const child = node[key];
6626
+ const safeKey = isIdentifier(key) ? key : JSON.stringify(key);
6627
+ if (child === LEAF) lines.push(`${indent}${safeKey}: () => string`);
6628
+ else {
6629
+ lines.push(`${indent}${safeKey}: {`);
6630
+ lines.push(renderTree(child, `${indent} `));
6631
+ lines.push(`${indent}}`);
6632
+ }
6633
+ }
6634
+ return lines.join("\n");
6635
+ }
6636
+ function isIdentifier(str) {
6637
+ return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(str);
6638
+ }
6639
+ function isDir(path) {
6640
+ try {
6641
+ return statSync(path).isDirectory();
6642
+ } catch {
6643
+ return false;
6644
+ }
6645
+ }
6646
+ function stripExt$1(path) {
6647
+ const ext = extname(path);
6648
+ return ext ? path.slice(0, -ext.length) : path;
6649
+ }
5880
6650
  //#endregion
5881
6651
  //#region src/typegen/generator.ts
5882
6652
  /**
@@ -6013,12 +6783,17 @@ function renderIndex(includeEnv) {
6013
6783
  export type { ServiceToken } from './services'
6014
6784
  export type { ModuleToken } from './modules'
6015
6785
 
6016
- // The registry, routes, and env augmentations are loaded as side-effects —
6017
- // importing this file (or having it on tsconfig include) is enough for
6018
- // \`container.resolve()\`, \`Ctx<KickRoutes.UserController['getUser']>\`,
6019
- // and \`@Value('PORT')\` to resolve.
6786
+ // The registry, routes, plugins, assets, and env augmentations are
6787
+ // loaded as side-effects — importing this file (or having it on
6788
+ // tsconfig include) is enough for \`container.resolve()\`,
6789
+ // \`Ctx<KickRoutes.UserController['getUser']>\`,
6790
+ // \`dependsOn: ['TenantAdapter']\`, \`assets.mails.welcome()\`, and
6791
+ // \`@Value('PORT')\` to resolve.
6020
6792
  import './registry'
6021
6793
  import './routes'
6794
+ import './plugins'
6795
+ import './augmentations'
6796
+ import './assets'
6022
6797
  ${includeEnv ? "import './env'\n" : ""}`;
6023
6798
  }
6024
6799
  /**
@@ -6213,9 +6988,94 @@ ${interfaces.join("\n")}
6213
6988
  export {}
6214
6989
  `;
6215
6990
  }
6991
+ /**
6992
+ * Render the `KickJsPluginRegistry` augmentation. Each entry maps the
6993
+ * literal `name` field of a plugin/adapter to a marker type (the
6994
+ * registry value isn't load-bearing at runtime — `dependsOn` only cares
6995
+ * about `keyof`, so any non-`never` type works). We emit `'plugin'` /
6996
+ * `'adapter'` strings so DevTools can later read the registry to tell
6997
+ * the kinds apart without a second source of truth.
6998
+ *
6999
+ * When the project has no discoverable plugins/adapters, the augmentation
7000
+ * is intentionally empty rather than skipped so the `keyof` constraint
7001
+ * resolves to `never` (which is harmless — `dependsOn: []` still works).
7002
+ */
7003
+ function renderPlugins(items) {
7004
+ const byName = /* @__PURE__ */ new Map();
7005
+ for (const item of items) if (!byName.has(item.name)) byName.set(item.name, item);
7006
+ const entries = [...byName.values()].sort((a, b) => a.name.localeCompare(b.name)).map((item) => ` '${item.name}': '${item.kind}'`).join("\n");
7007
+ return `${HEADER}
7008
+ declare module '@forinda/kickjs' {
7009
+ /**
7010
+ * Map of every plugin/adapter \`name\` discovered in the project. The
7011
+ * value type is the kind tag (\`'plugin'\` or \`'adapter'\`); the
7012
+ * \`keyof\` of this interface narrows \`dependsOn\` so misspelled deps
7013
+ * become compile errors instead of boot-time \`MissingMountDepError\`.
7014
+ */
7015
+ interface KickJsPluginRegistry {
7016
+ ${entries ? entries : " // (no plugins/adapters discovered yet — `defineAdapter`/`definePlugin` calls feed this)"}
7017
+ }
7018
+ }
7019
+
7020
+ export {}
7021
+ `;
7022
+ }
7023
+ /**
7024
+ * Render the augmentation manifest — one block per `defineAugmentation`
7025
+ * call discovered in the project. The output is a `.d.ts` file that
7026
+ * does nothing at runtime but acts as in-IDE documentation: adopters
7027
+ * jumping into it see every interface their plugins offer for
7028
+ * augmentation, alongside any `description` / `example` the plugin
7029
+ * authors provided.
7030
+ */
7031
+ function renderAugmentations(items) {
7032
+ if (items.length === 0) return `${HEADER}
7033
+ // No augmentations discovered.
7034
+ //
7035
+ // Plugins advertise augmentable interfaces via:
7036
+ //
7037
+ // import { defineAugmentation } from '@forinda/kickjs'
7038
+ // defineAugmentation('FeatureFlags', {
7039
+ // description: 'Feature flag shape consumed by FlagsPlugin',
7040
+ // example: '{ beta: boolean; rolloutPercentage: number }',
7041
+ // })
7042
+ //
7043
+ // See \`docs/guide/typegen.md#augmentations\` for the full pattern.
7044
+ export {}
7045
+ `;
7046
+ const byName = /* @__PURE__ */ new Map();
7047
+ for (const item of items) if (!byName.has(item.name)) byName.set(item.name, item);
7048
+ const blocks = [];
7049
+ for (const item of [...byName.values()].sort((a, b) => a.name.localeCompare(b.name))) {
7050
+ const docLines = [];
7051
+ if (item.description) for (const line of item.description.split("\n")) docLines.push(` * ${line}`);
7052
+ if (item.example) {
7053
+ docLines.push(` * @example`, ` * \`\`\`ts`);
7054
+ for (const line of item.example.split("\n")) docLines.push(` * ${line}`);
7055
+ docLines.push(` * \`\`\``);
7056
+ }
7057
+ docLines.push(` * @see ${item.relativePath}`);
7058
+ blocks.push([
7059
+ "/**",
7060
+ ...docLines,
7061
+ " */",
7062
+ `export interface ${item.name}Augmentation {}`
7063
+ ].join("\n"));
7064
+ }
7065
+ return `${HEADER}
7066
+ // Catalogue of augmentable interfaces in this project. The interfaces
7067
+ // below are documentation only — augment the source-of-truth interfaces
7068
+ // in your own \`d.ts\` files (the framework declares the actual types).
7069
+
7070
+ ${blocks.join("\n\n")}
7071
+ `;
7072
+ }
6216
7073
  /** Write all generated `.d.ts` files to `outDir` */
6217
7074
  async function generateTypes(opts) {
6218
- const { classes, routes = [], tokens = [], injects = [], collisions = [], env = null, outDir, allowDuplicates = false, schemaValidator = false } = opts;
7075
+ const { classes, routes = [], tokens = [], injects = [], collisions = [], env = null, pluginsAndAdapters = [], augmentations = [], assets = {
7076
+ entries: [],
7077
+ count: 0
7078
+ }, outDir, allowDuplicates = false, schemaValidator = false } = opts;
6219
7079
  if (collisions.length > 0 && !allowDuplicates) throw new TokenCollisionError(collisions);
6220
7080
  await mkdir(outDir, { recursive: true });
6221
7081
  const registryFile = join(outDir, "registry.d.ts");
@@ -6223,6 +7083,9 @@ async function generateTypes(opts) {
6223
7083
  const modulesFile = join(outDir, "modules.d.ts");
6224
7084
  const routesFile = join(outDir, "routes.ts");
6225
7085
  const envFile = join(outDir, "env.ts");
7086
+ const pluginsFile = join(outDir, "plugins.d.ts");
7087
+ const augmentationsFile = join(outDir, "augmentations.d.ts");
7088
+ const assetsFile = join(outDir, "assets.d.ts");
6226
7089
  const indexFile = join(outDir, "index.d.ts");
6227
7090
  const collidingNames = new Set(collisions.map((c) => c.className));
6228
7091
  const registryContent = renderRegistry(classes, registryFile, collidingNames);
@@ -6239,17 +7102,26 @@ async function generateTypes(opts) {
6239
7102
  const modulesContent = renderUnion("ModuleToken", modules, "(no @Module classes discovered — `kick g module <name>` to add one)");
6240
7103
  const routesContent = renderRoutes(routes, routesFile, schemaValidator);
6241
7104
  const envContent = renderEnv(env, envFile);
7105
+ const pluginsContent = renderPlugins(pluginsAndAdapters);
7106
+ const augmentationsContent = renderAugmentations(augmentations);
7107
+ const assetsContent = renderAssetTypes(assets);
6242
7108
  const indexContent = renderIndex(envContent !== null);
6243
7109
  await writeFile(registryFile, registryContent, "utf-8");
6244
7110
  await writeFile(servicesFile, servicesContent, "utf-8");
6245
7111
  await writeFile(modulesFile, modulesContent, "utf-8");
6246
7112
  await writeFile(routesFile, routesContent, "utf-8");
7113
+ await writeFile(pluginsFile, pluginsContent, "utf-8");
7114
+ await writeFile(augmentationsFile, augmentationsContent, "utf-8");
7115
+ await writeFile(assetsFile, assetsContent, "utf-8");
6247
7116
  await writeFile(indexFile, indexContent, "utf-8");
6248
7117
  const written = [
6249
7118
  registryFile,
6250
7119
  servicesFile,
6251
7120
  modulesFile,
6252
7121
  routesFile,
7122
+ pluginsFile,
7123
+ augmentationsFile,
7124
+ assetsFile,
6253
7125
  indexFile
6254
7126
  ];
6255
7127
  if (envContent) {
@@ -6257,17 +7129,62 @@ async function generateTypes(opts) {
6257
7129
  written.push(envFile);
6258
7130
  }
6259
7131
  await writeFile(join(dirname(outDir), ".gitignore"), "# Auto-generated by kick typegen\n*\n", "utf-8");
7132
+ const uniquePluginNames = new Set(pluginsAndAdapters.map((p) => p.name)).size;
7133
+ const uniqueAugmentations = new Set(augmentations.map((a) => a.name)).size;
6260
7134
  return {
6261
7135
  registryEntries: classTokens.length,
6262
7136
  serviceTokens: new Set(allServices).size,
6263
7137
  moduleTokens: modules.length,
6264
7138
  routeEntries: routes.length,
7139
+ pluginEntries: uniquePluginNames,
7140
+ augmentationEntries: uniqueAugmentations,
7141
+ assetEntries: assets.count,
6265
7142
  envWritten: envContent !== null,
6266
7143
  written,
6267
7144
  resolvedCollisions: collisions.length
6268
7145
  };
6269
7146
  }
6270
7147
  //#endregion
7148
+ //#region src/typegen/token-conventions.ts
7149
+ /**
7150
+ * Regex for the §22.2 token shape. Breakdown:
7151
+ *
7152
+ * - `^(kick\/)?` — optional reserved framework prefix.
7153
+ * - `([a-z][\w-]*\/[A-Z]\w*)` — `<scope>/<PascalKey>`. Scope is
7154
+ * lowercase, key is PascalCase.
7155
+ * - `(\/.+)?` — optional `/suffix` for sub-flavours
7156
+ * (e.g. `mycorp/Cache/redis`).
7157
+ * - `(:[a-z][\w-]+(:[a-z][\w-]+)*)?` — optional `:instance` (and
7158
+ * further `:extra` colon-sections) for `.scoped()` shards.
7159
+ */
7160
+ const TOKEN_CONVENTION_REGEX = /^(kick\/)?([a-z][\w-]*\/[A-Z]\w*)(\/.+)?(:[a-z][\w-]+(:[a-z][\w-]+)*)?$/;
7161
+ const LEGACY_PREFIX = "kickjs.";
7162
+ function validateTokenConventions(tokens) {
7163
+ const warnings = [];
7164
+ for (const token of tokens) {
7165
+ const name = token.name;
7166
+ if (name.startsWith(LEGACY_PREFIX)) continue;
7167
+ if (TOKEN_CONVENTION_REGEX.test(name)) continue;
7168
+ warnings.push({
7169
+ token: name,
7170
+ variable: token.variable,
7171
+ filePath: token.relativePath,
7172
+ reason: "does not match `<scope>/<PascalKey>[/<suffix>][:<instance>]`",
7173
+ suggestion: suggestRename(name)
7174
+ });
7175
+ }
7176
+ return warnings;
7177
+ }
7178
+ function suggestRename(name) {
7179
+ if (/^[A-Z]\w*$/.test(name)) return `'<scope>/${name}' (e.g. 'mycorp/${name}')`;
7180
+ if (name.includes(".")) return `consider '<scope>/PascalKey' instead of dotted form`;
7181
+ const slashLower = /^([a-z][\w-]*)\/([a-z]\w*)$/.exec(name);
7182
+ if (slashLower) {
7183
+ const [, scope, key] = slashLower;
7184
+ return `'${scope}/${key.charAt(0).toUpperCase()}${key.slice(1)}'`;
7185
+ }
7186
+ }
7187
+ //#endregion
6271
7188
  //#region src/typegen/index.ts
6272
7189
  /**
6273
7190
  * Public entry point for the KickJS typegen module.
@@ -6312,6 +7229,7 @@ async function runTypegen(opts = {}) {
6312
7229
  cwd,
6313
7230
  envFile: envFile === false ? void 0 : envFile
6314
7231
  });
7232
+ const assets = discoverAssets(opts.assetMap, cwd);
6315
7233
  const result = await generateTypes({
6316
7234
  classes: scan.classes,
6317
7235
  routes: scan.routes,
@@ -6319,20 +7237,36 @@ async function runTypegen(opts = {}) {
6319
7237
  injects: scan.injects,
6320
7238
  collisions: scan.collisions,
6321
7239
  env: envFile === false ? null : scan.env,
7240
+ pluginsAndAdapters: scan.pluginsAndAdapters,
7241
+ augmentations: scan.augmentations,
7242
+ assets,
6322
7243
  outDir,
6323
7244
  allowDuplicates,
6324
7245
  schemaValidator
6325
7246
  });
7247
+ const tokenWarnings = validateTokenConventions(scan.tokens);
6326
7248
  const elapsed = Date.now() - start;
6327
7249
  if (!silent) {
6328
7250
  const where = outDir.replace(cwd + "/", "");
6329
7251
  const collisionNote = result.resolvedCollisions > 0 ? `, ${result.resolvedCollisions} collisions namespaced` : "";
6330
7252
  const envNote = result.envWritten ? ", env typed" : "";
6331
- console.log(` kick typegen ${result.serviceTokens} services, ${result.routeEntries} routes, ${result.moduleTokens} modules${envNote}${collisionNote} ${where} (${elapsed}ms)`);
7253
+ const pluginNote = result.pluginEntries > 0 ? `, ${result.pluginEntries} plugins/adapters` : "";
7254
+ const augNote = result.augmentationEntries > 0 ? `, ${result.augmentationEntries} augmentations` : "";
7255
+ const assetNote = result.assetEntries > 0 ? `, ${result.assetEntries} assets` : "";
7256
+ console.log(` kick typegen → ${result.serviceTokens} services, ${result.routeEntries} routes, ${result.moduleTokens} modules${pluginNote}${augNote}${assetNote}${envNote}${collisionNote} → ${where} (${elapsed}ms)`);
7257
+ if (tokenWarnings.length > 0) {
7258
+ console.warn(` kick typegen: ${tokenWarnings.length} token(s) don't match the §22.2 convention:`);
7259
+ for (const warning of tokenWarnings) {
7260
+ const variableNote = warning.variable ? ` [${warning.variable}]` : "";
7261
+ console.warn(` '${warning.token}' (${warning.filePath})${variableNote} — ${warning.reason}`);
7262
+ if (warning.suggestion) console.warn(` → suggestion: ${warning.suggestion}`);
7263
+ }
7264
+ }
6332
7265
  }
6333
7266
  return {
6334
7267
  scan,
6335
- result
7268
+ result,
7269
+ tokenWarnings
6336
7270
  };
6337
7271
  }
6338
7272
  /**
@@ -6405,6 +7339,13 @@ async function safeRun(opts, silent) {
6405
7339
  }
6406
7340
  //#endregion
6407
7341
  //#region src/commands/generate.ts
7342
+ const AGENT_DOCS_ONLY_VALUES = [
7343
+ "agents",
7344
+ "claude",
7345
+ "skills",
7346
+ "both",
7347
+ "all"
7348
+ ];
6408
7349
  /** Check if --dry-run was passed on the parent generate command */
6409
7350
  function isDryRun(cmd) {
6410
7351
  return (cmd.parent?.opts())?.dryRun ?? false;
@@ -6487,12 +7428,29 @@ const GENERATORS = [
6487
7428
  {
6488
7429
  name: "config",
6489
7430
  description: "Generate kick.config.ts"
7431
+ },
7432
+ {
7433
+ name: "agents",
7434
+ description: "Regenerate AGENTS.md + CLAUDE.md + kickjs-skills.md from upstream templates"
6490
7435
  }
6491
7436
  ];
6492
- function printGeneratorList() {
6493
- console.log("\n Available generators:\n");
7437
+ async function printGeneratorList() {
7438
+ console.log("\n Built-in generators:\n");
6494
7439
  const maxName = Math.max(...GENERATORS.map((g) => g.name.length));
6495
7440
  for (const g of GENERATORS) console.log(` kick g ${g.name.padEnd(maxName + 2)} ${g.description}`);
7441
+ const discovery = await listPluginGenerators(process.cwd());
7442
+ if (discovery.generators.length > 0) {
7443
+ console.log("\n Plugin generators:\n");
7444
+ const pluginMax = Math.max(...discovery.generators.map((g) => `${g.spec.name} <name>`.length));
7445
+ for (const { source, spec } of discovery.generators) {
7446
+ const usage = `${spec.name} <name>`;
7447
+ console.log(` kick g ${usage.padEnd(pluginMax + 2)} ${spec.description} [${source}]`);
7448
+ }
7449
+ }
7450
+ if (discovery.failed.length > 0) {
7451
+ console.log("\n Failed to load:\n");
7452
+ for (const { source, reason } of discovery.failed) console.log(` ${source} — ${reason}`);
7453
+ }
6496
7454
  console.log();
6497
7455
  }
6498
7456
  /**
@@ -6529,7 +7487,7 @@ async function runModuleGeneration(names, opts, dryRun) {
6529
7487
  function registerGenerateCommand(program) {
6530
7488
  const gen = program.command("generate [names...]").alias("g").description("Generate code scaffolds — bare form `kick g <name>` is shorthand for `kick g module <name>`").option("--list", "List all available generators").option("--dry-run", "Preview files that would be generated without writing them").option("--no-entity", "Skip entity and value object generation (module shortcut)").option("--no-tests", "Skip test file generation (module shortcut)").option("--repo <type>", "Repository implementation: inmemory | drizzle | prisma").option("--pattern <pattern>", "Override project pattern: rest | ddd | cqrs | minimal").option("--minimal", "Shorthand for --pattern minimal").option("--modules-dir <dir>", "Modules directory").option("--no-pluralize", "Use singular names (skip auto-pluralization)").option("-f, --force", "Overwrite existing files without prompting").action(async (names, opts, cmd) => {
6531
7489
  if (opts.list) {
6532
- printGeneratorList();
7490
+ await printGeneratorList();
6533
7491
  return;
6534
7492
  }
6535
7493
  if (!names || names.length === 0) {
@@ -6538,6 +7496,20 @@ function registerGenerateCommand(program) {
6538
7496
  }
6539
7497
  const dryRun = isDryRun(cmd);
6540
7498
  setDryRun(dryRun);
7499
+ if (names.length >= 2) {
7500
+ const [generatorName, itemName, ...rest] = names;
7501
+ const result = await tryDispatchPluginGenerator({
7502
+ generatorName,
7503
+ itemName,
7504
+ args: rest,
7505
+ flags: opts,
7506
+ cwd: process.cwd()
7507
+ });
7508
+ if (result) {
7509
+ printGenerated(result.files, dryRun);
7510
+ return;
7511
+ }
7512
+ }
6541
7513
  await runModuleGeneration(names, opts, dryRun);
6542
7514
  });
6543
7515
  gen.command("module <names...>").description("Generate one or more modules (e.g. kick g module user task project)").option("--no-entity", "Skip entity and value object generation").option("--no-tests", "Skip test file generation").option("--repo <type>", "Repository implementation: inmemory | drizzle | prisma").option("--pattern <pattern>", "Override project pattern: rest | ddd | cqrs | minimal").option("--minimal", "Shorthand for --pattern minimal").option("--modules-dir <dir>", "Modules directory").option("--no-pluralize", "Use singular names (skip auto-pluralization)").option("-f, --force", "Overwrite existing files without prompting").action(async (names, opts, cmd) => {
@@ -6727,6 +7699,24 @@ function registerGenerateCommand(program) {
6727
7699
  force: opts.force
6728
7700
  }), dryRun);
6729
7701
  });
7702
+ gen.command("agents").alias("agent-docs").alias("ai-docs").description("Regenerate AGENTS.md + CLAUDE.md + kickjs-skills.md (sync after framework upgrades)").option("--only <which>", "Limit scope: agents | claude | skills | both (agents+claude) | all (default: all)", "all").option("--name <name>", "Project name (defaults to package.json name)").option("--pm <pm>", "Package manager (defaults to package.json packageManager)").option("--template <template>", "Template: rest | graphql | ddd | cqrs | minimal").option("-f, --force", "Overwrite existing files without prompting").action(async (opts, cmd) => {
7703
+ const dryRun = isDryRun(cmd);
7704
+ setDryRun(dryRun);
7705
+ const only = opts.only ?? "all";
7706
+ if (!AGENT_DOCS_ONLY_VALUES.includes(only)) {
7707
+ console.error(` Invalid --only value: ${only}. Expected: ${AGENT_DOCS_ONLY_VALUES.join(" | ")}`);
7708
+ process.exitCode = 1;
7709
+ return;
7710
+ }
7711
+ printGenerated(await generateAgentDocs({
7712
+ outDir: resolve("."),
7713
+ only,
7714
+ name: opts.name,
7715
+ pm: opts.pm,
7716
+ template: opts.template,
7717
+ force: opts.force
7718
+ }), dryRun);
7719
+ });
6730
7720
  }
6731
7721
  //#endregion
6732
7722
  //#region src/utils/shell.ts
@@ -6767,6 +7757,132 @@ function runNodeWithEnv(entry, env, cwd) {
6767
7757
  });
6768
7758
  if (result.status !== 0) process.exit(result.status ?? 1);
6769
7759
  }
7760
+ /**
7761
+ * Run the full asset build for a loaded config:
7762
+ *
7763
+ * 1. For each `assetMap` entry, glob → copy → manifest stub.
7764
+ * 2. Write `dist/.kickjs-assets.json`.
7765
+ *
7766
+ * Returns a summary including the manifest contents. No-op (and no
7767
+ * manifest written) when `assetMap` is empty / missing — the build
7768
+ * pipeline shouldn't litter `dist/` with empty manifests for
7769
+ * adopters who don't use the feature.
7770
+ */
7771
+ async function buildAssets(config, opts) {
7772
+ const { cwd, silent = false } = opts;
7773
+ const distDir = opts.distDir ?? config?.build?.outDir ?? "dist";
7774
+ const map = config?.assetMap;
7775
+ if (!map || Object.keys(map).length === 0) return null;
7776
+ const log = silent ? () => {} : console.log;
7777
+ const distAbs = resolve(cwd, distDir);
7778
+ mkdirSync(distAbs, { recursive: true });
7779
+ const summary = [];
7780
+ const manifestEntries = {};
7781
+ for (const [namespace, entry] of Object.entries(map)) {
7782
+ const result = await processEntry(namespace, entry, cwd, distAbs);
7783
+ summary.push(result.entrySummary);
7784
+ Object.assign(manifestEntries, result.manifestSlice);
7785
+ log(` ✓ ${namespace}: ${result.entrySummary.filesCopied} file(s) → ${result.entrySummary.dest}`);
7786
+ }
7787
+ const manifest = {
7788
+ version: 1,
7789
+ entries: manifestEntries
7790
+ };
7791
+ const manifestPath = join(distAbs, ".kickjs-assets.json");
7792
+ writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + "\n", "utf-8");
7793
+ log(` ✓ wrote manifest → ${relative(cwd, manifestPath)} (${Object.keys(manifestEntries).length} entries)`);
7794
+ return {
7795
+ manifestPath,
7796
+ entries: summary,
7797
+ manifest
7798
+ };
7799
+ }
7800
+ /** Per-entry inner pipeline — extracted for unit-test reuse. */
7801
+ async function processEntry(namespace, entry, cwd, distAbs) {
7802
+ const srcAbs = resolve(cwd, entry.src);
7803
+ const destAbs = entry.dest ? resolve(cwd, entry.dest) : join(distAbs, namespace);
7804
+ if (escapesRoot(destAbs, cwd)) {
7805
+ console.warn(` ⚠ assetMap.${namespace}.dest ('${entry.dest}') resolves outside the project root — skipping copy`);
7806
+ return {
7807
+ entrySummary: {
7808
+ namespace,
7809
+ src: entry.src,
7810
+ dest: relative(cwd, destAbs),
7811
+ filesCopied: 0
7812
+ },
7813
+ manifestSlice: {}
7814
+ };
7815
+ }
7816
+ if (!existsSync(srcAbs) || !isDirectorySync(srcAbs)) return {
7817
+ entrySummary: {
7818
+ namespace,
7819
+ src: entry.src,
7820
+ dest: relative(cwd, destAbs),
7821
+ filesCopied: 0
7822
+ },
7823
+ manifestSlice: {}
7824
+ };
7825
+ const matches = await glob(entry.glob ?? "**/*", {
7826
+ cwd: srcAbs,
7827
+ nodir: true,
7828
+ dot: false,
7829
+ posix: true
7830
+ });
7831
+ mkdirSync(destAbs, { recursive: true });
7832
+ const manifestSlice = {};
7833
+ const keyOwner = /* @__PURE__ */ new Map();
7834
+ for (const relPath of matches.sort()) {
7835
+ const srcFile = join(srcAbs, relPath);
7836
+ const destFile = join(destAbs, relPath);
7837
+ mkdirSync(dirname(destFile), { recursive: true });
7838
+ cpSync(srcFile, destFile);
7839
+ const logicalKey = `${namespace}/${stripExt(relPath)}`;
7840
+ const previous = keyOwner.get(logicalKey);
7841
+ if (previous) console.warn(` ⚠ assetMap collision in '${namespace}': '${previous}' and '${relPath}' both flatten to key '${logicalKey}'. Last-alphabetical wins ('${relPath}'). Rename one of them or set assetMap.${namespace}.glob to filter by extension.`);
7842
+ keyOwner.set(logicalKey, relPath);
7843
+ manifestSlice[logicalKey] = toManifestRelative(distAbs, destFile);
7844
+ }
7845
+ return {
7846
+ entrySummary: {
7847
+ namespace,
7848
+ src: entry.src,
7849
+ dest: relative(cwd, destAbs),
7850
+ filesCopied: matches.length
7851
+ },
7852
+ manifestSlice
7853
+ };
7854
+ }
7855
+ /** Strip the final extension from a file path (`mails/welcome.ejs` → `mails/welcome`). */
7856
+ function stripExt(path) {
7857
+ const ext = extname(path);
7858
+ return ext ? path.slice(0, -ext.length) : path;
7859
+ }
7860
+ /**
7861
+ * Make `destFile` relative to the manifest's directory + force POSIX
7862
+ * separators so the manifest is byte-stable across platforms.
7863
+ */
7864
+ function toManifestRelative(manifestDir, destFile) {
7865
+ return relative(manifestDir, destFile).split(/[\\/]/).filter(Boolean).join("/");
7866
+ }
7867
+ /**
7868
+ * Project-root escape check that's safe across symlinks + drive letters.
7869
+ * `path.relative` returns `..` segments when the target sits above root,
7870
+ * and an absolute path when the two live on different roots (Windows).
7871
+ * `startsWith(root)` would miss both cases.
7872
+ */
7873
+ function escapesRoot(path, root) {
7874
+ const rel = relative(root, path);
7875
+ if (rel === "") return false;
7876
+ return rel.startsWith("..") || isAbsolute(rel);
7877
+ }
7878
+ /** Pure helper — `false` for missing, non-dir, or unreadable paths. */
7879
+ function isDirectorySync(path) {
7880
+ try {
7881
+ return statSync(path).isDirectory();
7882
+ } catch {
7883
+ return false;
7884
+ }
7885
+ }
6770
7886
  //#endregion
6771
7887
  //#region src/commands/run.ts
6772
7888
  /**
@@ -6795,7 +7911,8 @@ async function startDevServer(_entry, port) {
6795
7911
  schemaValidator,
6796
7912
  envFile,
6797
7913
  srcDir: devConfig?.typegen?.srcDir,
6798
- outDir: devConfig?.typegen?.outDir
7914
+ outDir: devConfig?.typegen?.outDir,
7915
+ assetMap: devConfig?.assetMap
6799
7916
  });
6800
7917
  } catch (err) {
6801
7918
  console.warn(` kick typegen: skipped (${err?.message ?? err})`);
@@ -6806,11 +7923,15 @@ async function startDevServer(_entry, port) {
6806
7923
  configFile: resolve("vite.config.ts"),
6807
7924
  server: { port: port ? parseInt(port, 10) : void 0 }
6808
7925
  });
7926
+ const assetSrcRoots = devConfig?.assetMap ? Object.values(devConfig.assetMap).map((entry) => entry?.src).filter((src) => typeof src === "string" && src.length > 0).map((src) => resolve(cwd, src)) : [];
7927
+ const isAssetFile = (file) => assetSrcRoots.some((root) => file === root || file.startsWith(`${root}/`));
6809
7928
  let typegenTimer = null;
6810
7929
  const scheduleTypegen = (file) => {
6811
- if (!/\.(ts|tsx|mts|cts)$/.test(file)) return;
6812
7930
  if (file.includes(".kickjs")) return;
6813
7931
  if (file.endsWith(".d.ts")) return;
7932
+ const isTs = /\.(ts|tsx|mts|cts)$/.test(file);
7933
+ const isAsset = isAssetFile(file);
7934
+ if (!isTs && !isAsset) return;
6814
7935
  if (typegenTimer) clearTimeout(typegenTimer);
6815
7936
  typegenTimer = setTimeout(() => {
6816
7937
  runTypegen({
@@ -6820,13 +7941,15 @@ async function startDevServer(_entry, port) {
6820
7941
  schemaValidator,
6821
7942
  envFile,
6822
7943
  srcDir: devConfig?.typegen?.srcDir,
6823
- outDir: devConfig?.typegen?.outDir
7944
+ outDir: devConfig?.typegen?.outDir,
7945
+ assetMap: devConfig?.assetMap
6824
7946
  }).catch(() => {});
6825
7947
  }, 100);
6826
7948
  };
6827
7949
  server.watcher.on("add", scheduleTypegen);
6828
7950
  server.watcher.on("unlink", scheduleTypegen);
6829
7951
  server.watcher.on("change", scheduleTypegen);
7952
+ if (assetSrcRoots.length > 0) server.watcher.add(assetSrcRoots);
6830
7953
  await server.listen();
6831
7954
  server.printUrls();
6832
7955
  console.log(`\n KickJS dev server running (Vite + @forinda/kickjs-vite)\n`);
@@ -6853,7 +7976,8 @@ function registerRunCommands(program) {
6853
7976
  const { createRequire } = await import("node:module");
6854
7977
  const { build } = await import(pathToFileURL(createRequire(resolve("package.json")).resolve("vite")).href);
6855
7978
  await build({ configFile: resolve("vite.config.ts") });
6856
- const copyDirs = (await loadKickConfig(process.cwd()))?.copyDirs ?? [];
7979
+ const config = await loadKickConfig(process.cwd());
7980
+ const copyDirs = config?.copyDirs ?? [];
6857
7981
  if (copyDirs.length > 0) {
6858
7982
  console.log("\n Copying directories to dist...");
6859
7983
  for (const entry of copyDirs) {
@@ -6870,8 +7994,32 @@ function registerRunCommands(program) {
6870
7994
  console.log(` ✓ ${src} → ${dest}`);
6871
7995
  }
6872
7996
  }
7997
+ if (config?.assetMap && Object.keys(config.assetMap).length > 0) {
7998
+ console.log("\n Building asset map...");
7999
+ try {
8000
+ await buildAssets(config, { cwd: process.cwd() });
8001
+ } catch (err) {
8002
+ console.error(` ✗ asset build failed: ${err instanceof Error ? err.message : String(err)}`);
8003
+ process.exit(1);
8004
+ }
8005
+ }
6873
8006
  console.log("\n Build complete.\n");
6874
8007
  });
8008
+ program.command("build:assets").description("Rebuild the .kickjs-assets.json manifest under the configured outDir (no JS rebuild)").action(async () => {
8009
+ const config = await loadKickConfig(process.cwd());
8010
+ if (!config?.assetMap || Object.keys(config.assetMap).length === 0) {
8011
+ console.log(" No assetMap entries — nothing to build.");
8012
+ return;
8013
+ }
8014
+ console.log("\n Building asset map...");
8015
+ try {
8016
+ await buildAssets(config, { cwd: process.cwd() });
8017
+ console.log("\n Asset build complete.\n");
8018
+ } catch (err) {
8019
+ console.error(` ✗ ${err instanceof Error ? err.message : String(err)}`);
8020
+ process.exit(1);
8021
+ }
8022
+ });
6875
8023
  program.command("start").description("Start production server").option("-e, --entry <file>", "Entry file", "dist/index.js").option("-p, --port <port>", "Port number").action((opts) => {
6876
8024
  const env = { NODE_ENV: "production" };
6877
8025
  if (opts.port) env.PORT = String(opts.port);
@@ -6903,7 +8051,6 @@ function registerInfoCommand(program) {
6903
8051
  Packages:
6904
8052
  @forinda/kickjs workspace
6905
8053
  @forinda/kickjs-vite workspace
6906
- @forinda/kickjs-config workspace
6907
8054
  @forinda/kickjs-cli workspace
6908
8055
  `);
6909
8056
  });
@@ -8141,7 +9288,8 @@ function registerTypegenCommand(program) {
8141
9288
  silent: opts.silent,
8142
9289
  allowDuplicates: opts.allowDuplicates,
8143
9290
  schemaValidator,
8144
- envFile
9291
+ envFile,
9292
+ assetMap: config?.assetMap
8145
9293
  };
8146
9294
  try {
8147
9295
  if (opts.watch) {