@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/README.md +12 -89
- package/dist/cli.mjs +1757 -609
- package/dist/index.d.mts +263 -9
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +755 -489
- package/dist/index.mjs.map +1 -1
- package/dist/{typegen-C30frihW.mjs → typegen-C-H8pg-y.mjs} +450 -11
- package/dist/typegen-C-H8pg-y.mjs.map +1 -0
- package/package.json +9 -14
- package/dist/typegen-C30frihW.mjs.map +0 -1
package/dist/cli.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @forinda/kickjs-cli
|
|
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
|
-
/**
|
|
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: "^
|
|
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(`
|
|
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(`
|
|
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(`
|
|
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(`
|
|
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(`
|
|
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(`
|
|
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 //
|
|
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(`
|
|
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(`
|
|
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(`
|
|
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(`
|
|
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(`
|
|
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(`
|
|
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
|
-
/**
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
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
|
-
|
|
884
|
-
kick
|
|
885
|
-
kick
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
1105
|
-
2. Run
|
|
1106
|
-
3.
|
|
1107
|
-
|
|
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(
|
|
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
|
-
|
|
1263
|
-
strategies: [
|
|
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: [
|
|
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
|
-
|
|
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
|
|
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(
|
|
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** —
|
|
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>('
|
|
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
|
|
4176
|
+
await writeFileSafe(filePath, `import { defineAdapter, type AdapterContext, type AdapterMiddleware } from '@forinda/kickjs'
|
|
3816
4177
|
|
|
3817
|
-
|
|
3818
|
-
|
|
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
|
-
*
|
|
3828
|
-
*
|
|
3829
|
-
*
|
|
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
|
|
3833
|
-
name
|
|
3834
|
-
|
|
3835
|
-
|
|
3836
|
-
|
|
3837
|
-
|
|
3838
|
-
|
|
3839
|
-
|
|
3840
|
-
|
|
3841
|
-
|
|
3842
|
-
|
|
3843
|
-
|
|
3844
|
-
|
|
3845
|
-
|
|
3846
|
-
|
|
3847
|
-
|
|
3848
|
-
|
|
3849
|
-
|
|
3850
|
-
|
|
3851
|
-
|
|
3852
|
-
|
|
3853
|
-
|
|
3854
|
-
|
|
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
|
-
|
|
3863
|
-
|
|
3864
|
-
|
|
3865
|
-
|
|
3866
|
-
|
|
3867
|
-
|
|
3868
|
-
|
|
3869
|
-
|
|
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
|
-
|
|
3875
|
-
|
|
3876
|
-
|
|
3877
|
-
|
|
3878
|
-
|
|
3879
|
-
|
|
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
|
-
|
|
3884
|
-
|
|
3885
|
-
|
|
3886
|
-
|
|
3887
|
-
|
|
3888
|
-
|
|
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
|
-
|
|
3894
|
-
|
|
3895
|
-
|
|
3896
|
-
|
|
3897
|
-
|
|
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 `
|
|
4274
|
+
* Scaffold a `definePlugin()` factory under `src/plugins/<name>.plugin.ts`.
|
|
3908
4275
|
*
|
|
3909
|
-
*
|
|
3910
|
-
*
|
|
3911
|
-
*
|
|
3912
|
-
*
|
|
3913
|
-
* the
|
|
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
|
|
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
|
-
*
|
|
4296
|
+
* Configuration for the ${pascal} plugin.
|
|
3926
4297
|
*
|
|
3927
|
-
* Plugins typically take a small
|
|
3928
|
-
*
|
|
3929
|
-
*
|
|
3930
|
-
*
|
|
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}
|
|
3934
|
-
// Add your plugin
|
|
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
|
|
3943
|
-
*
|
|
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)\`
|
|
4319
|
+
* 1. \`register(container)\` — runs before user modules load. Use
|
|
3950
4320
|
* it to bind services that modules depend on.
|
|
3951
|
-
* 2. \`modules()\`
|
|
3952
|
-
* 3. \`adapters()\`
|
|
3953
|
-
* 4. \`middleware()\`
|
|
3954
|
-
* 5. \`onReady(container)\`
|
|
3955
|
-
* 6. \`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 { ${
|
|
4330
|
+
* import { ${pascal}Plugin } from './plugins/${kebab}.plugin'
|
|
3961
4331
|
*
|
|
3962
4332
|
* export const app = await bootstrap({
|
|
3963
4333
|
* modules,
|
|
3964
|
-
* plugins: [${
|
|
4334
|
+
* plugins: [${pascal}Plugin({ /* config overrides *\\/ })],
|
|
3965
4335
|
* })
|
|
3966
4336
|
* \`\`\`
|
|
3967
4337
|
*/
|
|
3968
|
-
export
|
|
3969
|
-
|
|
3970
|
-
|
|
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(
|
|
3979
|
-
// Example:
|
|
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
|
|
4367
|
+
* Plugin adapters mount before user adapters.
|
|
3997
4368
|
*/
|
|
3998
4369
|
adapters(): AppAdapter[] {
|
|
3999
4370
|
return [
|
|
4000
|
-
//
|
|
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():
|
|
4379
|
+
middleware(): unknown[] {
|
|
4009
4380
|
return [
|
|
4010
4381
|
// helmet(),
|
|
4011
|
-
// myCustomMiddleware(
|
|
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(
|
|
4021
|
-
// const
|
|
4022
|
-
//
|
|
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(
|
|
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(
|
|
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>('
|
|
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
|
|
6017
|
-
// importing this file (or having it on
|
|
6018
|
-
// \`container.resolve()\`,
|
|
6019
|
-
//
|
|
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,
|
|
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
|
-
|
|
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
|
|
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
|
|
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) {
|