@forinda/kickjs-cli 4.0.0 → 4.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.mjs +989 -734
- package/dist/index.d.mts +11 -0
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +612 -468
- package/dist/index.mjs.map +1 -1
- package/dist/{typegen-vI1eqGLK.mjs → typegen-C-H8pg-y.mjs} +8 -4
- package/dist/{typegen-vI1eqGLK.mjs.map → typegen-C-H8pg-y.mjs.map} +1 -1
- package/package.json +2 -2
package/dist/cli.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @forinda/kickjs-cli v4.
|
|
2
|
+
* @forinda/kickjs-cli v4.1.0
|
|
3
3
|
*
|
|
4
4
|
* Copyright (c) Felix Orinda
|
|
5
5
|
*
|
|
@@ -38,11 +38,60 @@ let _dryRun = false;
|
|
|
38
38
|
function setDryRun(enabled) {
|
|
39
39
|
_dryRun = enabled;
|
|
40
40
|
}
|
|
41
|
-
/**
|
|
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
|
+
*/
|
|
42
67
|
async function writeFileSafe(filePath, content) {
|
|
43
68
|
if (_dryRun) return;
|
|
44
69
|
await mkdir(dirname(filePath), { recursive: true });
|
|
45
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");
|
|
46
95
|
}
|
|
47
96
|
/** Check if a file exists */
|
|
48
97
|
async function fileExists(filePath) {
|
|
@@ -115,7 +164,7 @@ function generatePackageJson(name, template, kickjsVersion, packages = []) {
|
|
|
115
164
|
"unplugin-swc": "^1.5.9",
|
|
116
165
|
vite: "^8.0.3",
|
|
117
166
|
vitest: "^4.1.2",
|
|
118
|
-
typescript: "^
|
|
167
|
+
typescript: "^6.0.3",
|
|
119
168
|
prettier: "^3.8.1"
|
|
120
169
|
}
|
|
121
170
|
}, null, 2);
|
|
@@ -709,404 +758,223 @@ Copy \`.env.example\` to \`.env\` and configure:
|
|
|
709
758
|
- [CLI Reference](https://forinda.github.io/kick-js/api/cli.html)
|
|
710
759
|
`;
|
|
711
760
|
}
|
|
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
|
-
|
|
760
|
-
|
|
761
|
-
- Type check with: \`${pm} run typecheck\`
|
|
762
|
-
|
|
763
|
-
## Key Patterns
|
|
764
|
-
|
|
765
|
-
### Controllers
|
|
766
|
-
|
|
767
|
-
Use decorators to define routes. Annotate \`ctx\` with \`Ctx<KickRoutes.X['method']>\`
|
|
768
|
-
to get fully-typed \`ctx.params\`, \`ctx.body\`, and \`ctx.query\` from the
|
|
769
|
-
generated \`KickRoutes\` namespace (refreshed on \`kick dev\` and \`kick typegen\`).
|
|
770
|
-
|
|
771
|
-
\`\`\`ts
|
|
772
|
-
import { Controller, Get, Post, type Ctx } from '@forinda/kickjs'
|
|
773
|
-
|
|
774
|
-
@Controller()
|
|
775
|
-
export class UserController {
|
|
776
|
-
@Get('/')
|
|
777
|
-
async findAll(ctx: Ctx<KickRoutes.UserController['findAll']>) {
|
|
778
|
-
return ctx.json({ users: [] })
|
|
779
|
-
}
|
|
780
|
-
|
|
781
|
-
@Post('/')
|
|
782
|
-
async create(ctx: Ctx<KickRoutes.UserController['create']>) {
|
|
783
|
-
const data = ctx.body
|
|
784
|
-
return ctx.created({ user: data })
|
|
785
|
-
}
|
|
786
|
-
}
|
|
787
|
-
\`\`\`
|
|
788
|
-
|
|
789
|
-
### Services
|
|
790
|
-
|
|
791
|
-
Inject dependencies with \`@Service()\` and \`@Autowired()\`:
|
|
792
|
-
|
|
793
|
-
\`\`\`ts
|
|
794
|
-
import { Service, Autowired } from '@forinda/kickjs'
|
|
795
|
-
|
|
796
|
-
@Service()
|
|
797
|
-
export class UserService {
|
|
798
|
-
@Autowired()
|
|
799
|
-
private userRepository!: UserRepository
|
|
800
|
-
|
|
801
|
-
async findAll() {
|
|
802
|
-
return this.userRepository.findAll()
|
|
803
|
-
}
|
|
804
|
-
}
|
|
805
|
-
\`\`\`
|
|
806
|
-
|
|
807
|
-
### Modules
|
|
808
|
-
|
|
809
|
-
Modules implement \`AppModule\` and wire controllers via \`buildRoutes()\`.
|
|
810
|
-
|
|
811
|
-
> **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.
|
|
812
|
-
|
|
813
|
-
\`\`\`ts
|
|
814
|
-
// src/modules/users/users.module.ts (named <feature>.module.ts)
|
|
815
|
-
import { type AppModule, type ModuleRoutes, buildRoutes } from '@forinda/kickjs'
|
|
816
|
-
import { UserController } from './user.controller'
|
|
817
|
-
|
|
818
|
-
export class UserModule implements AppModule {
|
|
819
|
-
routes(): ModuleRoutes {
|
|
820
|
-
return {
|
|
821
|
-
path: '/users',
|
|
822
|
-
router: buildRoutes(UserController),
|
|
823
|
-
controller: UserController,
|
|
824
|
-
}
|
|
825
|
-
}
|
|
826
|
-
}
|
|
827
|
-
\`\`\`
|
|
828
|
-
|
|
829
|
-
Register all modules in \`src/modules/index.ts\`:
|
|
830
|
-
|
|
831
|
-
\`\`\`ts
|
|
832
|
-
import type { AppModuleClass } from '@forinda/kickjs'
|
|
833
|
-
import { UserModule } from './user/user.module'
|
|
834
|
-
|
|
835
|
-
export const modules: AppModuleClass[] = [UserModule]
|
|
836
|
-
\`\`\`
|
|
837
|
-
|
|
838
|
-
### RequestContext
|
|
839
|
-
|
|
840
|
-
Every controller method receives a \`ctx\` (alias \`Ctx<TRoute>\` or the
|
|
841
|
-
loose \`RequestContext\`):
|
|
842
|
-
|
|
843
|
-
\`\`\`ts
|
|
844
|
-
ctx.body // Request body (parsed JSON)
|
|
845
|
-
ctx.params // Route params
|
|
846
|
-
ctx.query // Query string
|
|
847
|
-
ctx.headers // Request headers
|
|
848
|
-
ctx.requestId // Auto-generated request ID
|
|
849
|
-
ctx.session // Session data (if session middleware enabled)
|
|
850
|
-
ctx.file // Uploaded file (single)
|
|
851
|
-
ctx.files // Uploaded files (multiple)
|
|
852
|
-
|
|
853
|
-
// Pagination helpers
|
|
854
|
-
ctx.qs(config) // Parse query with filters/sort/pagination
|
|
855
|
-
ctx.paginate(handler) // Auto-paginated response
|
|
856
|
-
|
|
857
|
-
// Response helpers
|
|
858
|
-
ctx.json(data) // 200 OK with JSON
|
|
859
|
-
ctx.created(data) // 201 Created
|
|
860
|
-
ctx.noContent() // 204 No Content
|
|
861
|
-
ctx.notFound() // 404 Not Found
|
|
862
|
-
ctx.badRequest(msg) // 400 Bad Request
|
|
863
|
-
\`\`\`
|
|
864
|
-
|
|
865
|
-
> **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>.
|
|
866
|
-
|
|
867
|
-
## CLI Generators
|
|
868
|
-
|
|
869
|
-
Generate code with the \`kick\` CLI:
|
|
870
|
-
|
|
871
|
-
\`\`\`bash
|
|
872
|
-
kick g module <name> # Full module (controller, service, DTOs, repo)
|
|
873
|
-
kick g scaffold <name> <fields> # CRUD module from field definitions
|
|
874
|
-
kick g controller <name> # Standalone controller
|
|
875
|
-
kick g service <name> # Service class
|
|
876
|
-
kick g middleware <name> # Express middleware
|
|
877
|
-
kick g guard <name> # Route guard (auth, roles)
|
|
878
|
-
kick g adapter <name> # AppAdapter with lifecycle hooks
|
|
879
|
-
kick g dto <name> # Zod DTO schema
|
|
880
|
-
${template === "graphql" ? "kick g resolver <name> # GraphQL resolver\n" : ""}${template === "cqrs" ? "kick g job <name> # Queue job processor\n" : ""}\`\`\`
|
|
881
|
-
|
|
882
|
-
## 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)
|
|
883
810
|
|
|
884
811
|
\`\`\`bash
|
|
885
|
-
|
|
886
|
-
kick
|
|
887
|
-
kick
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
kick add prisma # Prisma ORM adapter
|
|
892
|
-
kick add drizzle # Drizzle ORM adapter
|
|
893
|
-
kick add otel # OpenTelemetry tracing
|
|
894
|
-
kick add --list # Show all available packages
|
|
895
|
-
\`\`\`
|
|
896
|
-
|
|
897
|
-
## Environment Configuration
|
|
898
|
-
|
|
899
|
-
The project's typed env schema lives in **\`src/config/index.ts\`** —
|
|
900
|
-
extend the base schema there with your application-specific keys, and
|
|
901
|
-
the schema is auto-registered with kickjs at module load. The companion
|
|
902
|
-
\`src/index.ts\` imports it as a side effect (\`import './config'\`) **before**
|
|
903
|
-
\`bootstrap()\` runs, so every \`@Service\`, \`@Controller\`, \`@Value\`, and
|
|
904
|
-
\`ConfigService\` resolution sees the validated extended values.
|
|
905
|
-
|
|
906
|
-
> **Do not delete \`import './config'\` from \`src/index.ts\`.** It is the
|
|
907
|
-
> registration step that wires \`ConfigService\` to your env schema.
|
|
908
|
-
> Without it, \`config.get('YOUR_KEY')\` returns \`undefined\` for every
|
|
909
|
-
> user-defined key and \`@Value('YOUR_KEY')\` only works because of a
|
|
910
|
-
> raw \`process.env\` fallback (Zod coercion + defaults are skipped).
|
|
911
|
-
|
|
912
|
-
Edit \`.env\` for variable values. Access them with \`@Value()\`:
|
|
913
|
-
|
|
914
|
-
\`\`\`ts
|
|
915
|
-
import { Value } from '@forinda/kickjs'
|
|
916
|
-
|
|
917
|
-
@Service()
|
|
918
|
-
export class ApiService {
|
|
919
|
-
@Value('API_KEY')
|
|
920
|
-
private apiKey!: string
|
|
921
|
-
|
|
922
|
-
@Value('PORT', 3000) // With default
|
|
923
|
-
private port!: number
|
|
924
|
-
}
|
|
925
|
-
\`\`\`
|
|
926
|
-
|
|
927
|
-
Or use \`ConfigService\`:
|
|
928
|
-
|
|
929
|
-
\`\`\`ts
|
|
930
|
-
import { Service, Autowired, ConfigService } from '@forinda/kickjs'
|
|
931
|
-
|
|
932
|
-
@Service()
|
|
933
|
-
export class AppService {
|
|
934
|
-
@Autowired()
|
|
935
|
-
private config!: ConfigService
|
|
936
|
-
|
|
937
|
-
getPort() {
|
|
938
|
-
// typed: number, Zod-coerced from baseEnvSchema
|
|
939
|
-
return this.config.get('PORT')
|
|
940
|
-
}
|
|
941
|
-
}
|
|
942
|
-
\`\`\`
|
|
943
|
-
|
|
944
|
-
Hot-reload of \`.env\` changes during dev is wired up automatically via
|
|
945
|
-
\`envWatchPlugin()\` in \`vite.config.ts\` — edit \`.env\`, the dev server
|
|
946
|
-
reloads, and the next \`config.get()\` re-parses with the new values.
|
|
947
|
-
|
|
948
|
-
### Standalone Env Utilities (No DI Required)
|
|
949
|
-
|
|
950
|
-
These functions work anywhere — scripts, CLI tools, plain files, outside \`@Service\`/\`@Controller\`:
|
|
951
|
-
|
|
952
|
-
\`\`\`ts
|
|
953
|
-
import { defineEnv, loadEnv, getEnv, reloadEnv, resetEnvCache, baseEnvSchema } from '@forinda/kickjs/config'
|
|
954
|
-
import { z } from 'zod'
|
|
955
|
-
|
|
956
|
-
// Define and parse schema
|
|
957
|
-
const schema = defineEnv((base) =>
|
|
958
|
-
base.extend({ DATABASE_URL: z.string().url() })
|
|
959
|
-
)
|
|
960
|
-
const env = loadEnv(schema) // Parse + validate process.env
|
|
961
|
-
console.log(env.PORT) // 3000 (coerced to number)
|
|
962
|
-
console.log(env.DATABASE_URL) // validated URL string
|
|
963
|
-
|
|
964
|
-
// Get single value
|
|
965
|
-
const port = getEnv('PORT') // typed after kick typegen
|
|
966
|
-
|
|
967
|
-
// Reload after .env changes (HMR calls this automatically)
|
|
968
|
-
reloadEnv()
|
|
969
|
-
|
|
970
|
-
// Reset cache in tests that swap schemas
|
|
971
|
-
resetEnvCache()
|
|
972
|
-
\`\`\`
|
|
973
|
-
|
|
974
|
-
| Function | Purpose |
|
|
975
|
-
|----------|---------|
|
|
976
|
-
| \`defineEnv(fn)\` | Extend base schema with custom Zod keys |
|
|
977
|
-
| \`loadEnv(schema?)\` | Parse \`process.env\`, validate, cache, return typed object |
|
|
978
|
-
| \`getEnv(key, schema?)\` | Get single validated env value |
|
|
979
|
-
| \`reloadEnv()\` | Re-read \`.env\` from disk, re-parse with same schema |
|
|
980
|
-
| \`resetEnvCache()\` | Clear parsed cache AND registered schema (for tests) |
|
|
981
|
-
| \`baseEnvSchema\` | Base Zod schema: \`PORT\`, \`NODE_ENV\`, \`LOG_LEVEL\` |
|
|
982
|
-
|
|
983
|
-
## Standalone Utilities (No DI Required)
|
|
984
|
-
|
|
985
|
-
These utilities work outside decorated classes:
|
|
986
|
-
|
|
987
|
-
### Logger
|
|
988
|
-
|
|
989
|
-
\`\`\`ts
|
|
990
|
-
import { Logger, createLogger } from '@forinda/kickjs'
|
|
991
|
-
|
|
992
|
-
const log = Logger.for('MyScript') // Static factory
|
|
993
|
-
log.info('Processing started')
|
|
994
|
-
log.error('Something failed')
|
|
995
|
-
|
|
996
|
-
const log2 = createLogger('Worker') // Function form
|
|
997
|
-
\`\`\`
|
|
998
|
-
|
|
999
|
-
### Injection Tokens
|
|
1000
|
-
|
|
1001
|
-
\`\`\`ts
|
|
1002
|
-
import { createToken } from '@forinda/kickjs'
|
|
1003
|
-
|
|
1004
|
-
// Type-safe DI tokens for factory/interface binding
|
|
1005
|
-
const DB_URL = createToken<string>('config.database.url')
|
|
1006
|
-
const FEATURE_FLAGS = createToken<FeatureFlags>('app.features')
|
|
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
|
|
1007
818
|
\`\`\`
|
|
1008
819
|
|
|
1009
|
-
|
|
820
|
+
## v4 framework reminders
|
|
1010
821
|
|
|
1011
|
-
|
|
1012
|
-
import { ref, computed, watch, reactive } from '@forinda/kickjs'
|
|
1013
|
-
|
|
1014
|
-
const count = ref(0)
|
|
1015
|
-
const doubled = computed(() => count.value * 2)
|
|
1016
|
-
const stop = watch(() => count.value, (val) => console.log(val))
|
|
1017
|
-
count.value++ // logs 1
|
|
1018
|
-
\`\`\`
|
|
822
|
+
When generating or modifying code in this project, stay aligned with the v4 conventions documented in \`AGENTS.md\`:
|
|
1019
823
|
|
|
1020
|
-
|
|
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\`.
|
|
1021
836
|
|
|
1022
|
-
|
|
1023
|
-
import { HttpException, HttpStatus } from '@forinda/kickjs'
|
|
1024
|
-
|
|
1025
|
-
throw new HttpException(HttpStatus.NOT_FOUND, 'User not found')
|
|
1026
|
-
\`\`\`
|
|
1027
|
-
|
|
1028
|
-
## Testing
|
|
1029
|
-
|
|
1030
|
-
Tests live in \`src/**/*.test.ts\`:
|
|
1031
|
-
|
|
1032
|
-
\`\`\`ts
|
|
1033
|
-
import { describe, it, expect, beforeEach } from 'vitest'
|
|
1034
|
-
import { Container } from '@forinda/kickjs'
|
|
1035
|
-
import { createTestApp } from '@forinda/kickjs-testing'
|
|
1036
|
-
|
|
1037
|
-
describe('UserController', () => {
|
|
1038
|
-
beforeEach(() => Container.reset())
|
|
1039
|
-
|
|
1040
|
-
it('should return users', async () => {
|
|
1041
|
-
const app = await createTestApp([UserModule])
|
|
1042
|
-
const res = await app.get('/users')
|
|
1043
|
-
expect(res.status).toBe(200)
|
|
1044
|
-
})
|
|
1045
|
-
})
|
|
1046
|
-
\`\`\`
|
|
1047
|
-
|
|
1048
|
-
Run tests:
|
|
1049
|
-
- \`${pm} run test\` — run all tests
|
|
1050
|
-
- \`${pm} run test:watch\` — watch mode
|
|
1051
|
-
|
|
1052
|
-
## Decorators Reference
|
|
1053
|
-
|
|
1054
|
-
### Route Decorators
|
|
1055
|
-
- \`@Controller()\` — mark a class as an HTTP controller (path comes from \`routes().path\`)
|
|
1056
|
-
- \`@Get('/'), @Post('/'), @Put('/'), @Delete('/'), @Patch('/')\` — HTTP methods
|
|
1057
|
-
- \`@Middleware(fn)\` — attach middleware
|
|
1058
|
-
- \`@Public()\` — skip authentication (requires @forinda/kickjs-auth)
|
|
1059
|
-
- \`@Roles('admin', 'user')\` — role-based access control
|
|
1060
|
-
|
|
1061
|
-
### DI Decorators
|
|
1062
|
-
- \`@Service()\` — singleton service (DI-registered)
|
|
1063
|
-
- \`@Repository()\` — repository (semantic alias for @Service)
|
|
1064
|
-
- \`@Autowired()\` — property injection
|
|
1065
|
-
- \`@Inject('token')\` — token-based injection
|
|
1066
|
-
- \`@Value('ENV_VAR')\` — inject config value
|
|
1067
|
-
|
|
1068
|
-
${template === "cqrs" ? `### CQRS/Event Decorators
|
|
1069
|
-
- \`@Job('job-name')\` — queue job handler
|
|
1070
|
-
- \`@Process('queue-name')\` — queue processor
|
|
1071
|
-
- \`@Cron('0 * * * *')\` — cron schedule
|
|
1072
|
-
- \`@WsController('/path')\` — WebSocket controller
|
|
1073
|
-
- \`@Subscribe('event')\` — WebSocket event handler
|
|
1074
|
-
|
|
1075
|
-
` : ""}${template === "graphql" ? `### GraphQL Decorators
|
|
1076
|
-
- \`@Resolver()\` — GraphQL resolver
|
|
1077
|
-
- \`@Query()\` — GraphQL query
|
|
1078
|
-
- \`@Mutation()\` — GraphQL mutation
|
|
1079
|
-
- \`@Arg('name')\` — resolver argument
|
|
1080
|
-
|
|
1081
|
-
` : ""}## Common Pitfalls
|
|
1082
|
-
|
|
1083
|
-
1. **Decorators fire at import time** — make sure to import module classes in \`src/modules/index.ts\`
|
|
1084
|
-
2. **Tests need \`Container.reset()\`** — call in \`beforeEach\` to isolate DI state
|
|
1085
|
-
3. **Always use \`ctx.body\`** — never \`req.body\` directly
|
|
1086
|
-
4. **DI requires \`reflect-metadata\`** — already imported in \`src/index.ts\`
|
|
1087
|
-
5. **Vite HMR requires proper cleanup** — adapters should implement \`shutdown()\`
|
|
1088
|
-
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).
|
|
1089
|
-
|
|
1090
|
-
## Learn More
|
|
1091
|
-
|
|
1092
|
-
- [KickJS Documentation](https://forinda.github.io/kick-js/)
|
|
1093
|
-
- [API Reference](https://forinda.github.io/kick-js/api/)
|
|
1094
|
-
- [CLI Commands](https://forinda.github.io/kick-js/guide/cli-commands.html)
|
|
1095
|
-
- [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\`.
|
|
1096
838
|
`;
|
|
1097
839
|
}
|
|
1098
840
|
/** Generate AGENTS.md with AI agent guide */
|
|
1099
841
|
function generateAgents(name, template, pm) {
|
|
1100
842
|
return `# AGENTS.md — AI Agent Guide for ${name}
|
|
1101
843
|
|
|
1102
|
-
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.
|
|
1103
848
|
|
|
1104
849
|
## Before You Start
|
|
1105
850
|
|
|
1106
|
-
1.
|
|
1107
|
-
2. Run
|
|
1108
|
-
3.
|
|
1109
|
-
|
|
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.
|
|
1110
978
|
|
|
1111
979
|
## Where to Find Things
|
|
1112
980
|
|
|
@@ -1117,6 +985,7 @@ This guide helps AI agents (Claude, Copilot, etc.) work effectively on this Kick
|
|
|
1117
985
|
| Entry point | \`src/index.ts\` |
|
|
1118
986
|
| Module registry | \`src/modules/index.ts\` |
|
|
1119
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) |
|
|
1120
989
|
${template === "graphql" ? "| GraphQL resolvers | `src/resolvers/` |\n" : ""}| Env values | \`.env\` |
|
|
1121
990
|
| Env schema (Zod) | \`src/config/index.ts\` |
|
|
1122
991
|
| TypeScript config | \`tsconfig.json\` |
|
|
@@ -1312,14 +1181,13 @@ import { Container } from '@forinda/kickjs'
|
|
|
1312
1181
|
import { createTestApp } from '@forinda/kickjs-testing'
|
|
1313
1182
|
|
|
1314
1183
|
describe('UserController', () => {
|
|
1315
|
-
beforeEach(() => {
|
|
1316
|
-
Container.reset() // Important: isolate DI state
|
|
1317
|
-
})
|
|
1318
|
-
|
|
1319
1184
|
it('should return users', async () => {
|
|
1320
|
-
|
|
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 })
|
|
1321
1189
|
const res = await app.get('/users')
|
|
1322
|
-
|
|
1190
|
+
|
|
1323
1191
|
expect(res.status).toBe(200)
|
|
1324
1192
|
expect(res.body).toHaveProperty('users')
|
|
1325
1193
|
})
|
|
@@ -1383,7 +1251,7 @@ These work anywhere — scripts, plain files, outside \`@Service\`/\`@Controller
|
|
|
1383
1251
|
|---------|--------|---------|
|
|
1384
1252
|
| \`Logger.for(name)\` | \`@forinda/kickjs\` | \`const log = Logger.for('MyScript')\` |
|
|
1385
1253
|
| \`createLogger(name)\` | \`@forinda/kickjs\` | \`const log = createLogger('Worker')\` |
|
|
1386
|
-
| \`createToken<T>(name)\` | \`@forinda/kickjs\` | \`const TOKEN = createToken<string>('db
|
|
1254
|
+
| \`createToken<T>(name)\` | \`@forinda/kickjs\` | \`const TOKEN = createToken<string>('app/db/url')\` |
|
|
1387
1255
|
| \`ref(value)\` | \`@forinda/kickjs\` | \`const count = ref(0)\` |
|
|
1388
1256
|
| \`computed(fn)\` | \`@forinda/kickjs\` | \`const doubled = computed(() => count.value * 2)\` |
|
|
1389
1257
|
| \`watch(source, cb)\` | \`@forinda/kickjs\` | \`watch(() => count.value, (v) => log(v))\` |
|
|
@@ -1453,42 +1321,299 @@ ${template === "graphql" ? `### GraphQL
|
|
|
1453
1321
|
|
|
1454
1322
|
1. **Forgot to register module** — Add to \`src/modules/index.ts\` exports array
|
|
1455
1323
|
2. **DI not working** — Ensure \`reflect-metadata\` is imported in \`src/index.ts\`
|
|
1456
|
-
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()\`
|
|
1457
1325
|
4. **Routes not found** — Check controller path and module registration
|
|
1458
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.
|
|
1459
1327
|
6. **Decorators not working** — Check \`tsconfig.json\` has \`experimentalDecorators: true\`
|
|
1460
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.
|
|
1461
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.
|
|
1462
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\`.
|
|
1333
|
+
|
|
1334
|
+
## CLI Commands Reference
|
|
1335
|
+
|
|
1336
|
+
| Command | Description |
|
|
1337
|
+
|---------|-------------|
|
|
1338
|
+
| \`kick dev\` | Dev server with HMR |
|
|
1339
|
+
| \`kick dev:debug\` | Dev server with debugger |
|
|
1340
|
+
| \`kick build\` | Production build |
|
|
1341
|
+
| \`kick start\` | Run production build |
|
|
1342
|
+
| \`kick g module <names...>\` | Generate one or more modules |
|
|
1343
|
+
| \`kick g scaffold <name> <fields>\` | Generate CRUD |
|
|
1344
|
+
| \`kick g controller <name>\` | Generate controller |
|
|
1345
|
+
| \`kick g service <name>\` | Generate service |
|
|
1346
|
+
| \`kick g middleware <name>\` | Generate middleware |
|
|
1347
|
+
| \`kick add <package>\` | Add KickJS package |
|
|
1348
|
+
| \`kick add --list\` | List available packages |
|
|
1349
|
+
| \`kick rm module <names...>\` | Remove one or more modules |
|
|
1350
|
+
|
|
1351
|
+
> **Note:** When using \`kick new\` in scripts or CI, pass \`-t\` (or \`--template\`) and \`-r\` (or \`--repo\`) flags to bypass interactive prompts:
|
|
1352
|
+
> \`\`\`bash
|
|
1353
|
+
> kick new my-api -t ddd -r prisma --pm ${pm} --no-git --no-install -f
|
|
1354
|
+
> \`\`\`
|
|
1355
|
+
|
|
1356
|
+
## Learn More
|
|
1357
|
+
|
|
1358
|
+
- [KickJS Docs](https://forinda.github.io/kick-js/)
|
|
1359
|
+
- [CLI Reference](https://forinda.github.io/kick-js/api/cli.html)
|
|
1360
|
+
- [Decorators Guide](https://forinda.github.io/kick-js/guide/decorators.html)
|
|
1361
|
+
- [DI System](https://forinda.github.io/kick-js/guide/dependency-injection.html)
|
|
1362
|
+
- [Testing](https://forinda.github.io/kick-js/api/testing.html)
|
|
1363
|
+
`;
|
|
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
|
|
1463
1581
|
|
|
1464
|
-
|
|
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
|
+
\`\`\`
|
|
1465
1586
|
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
| \`kick start\` | Run production build |
|
|
1472
|
-
| \`kick g module <names...>\` | Generate one or more modules |
|
|
1473
|
-
| \`kick g scaffold <name> <fields>\` | Generate CRUD |
|
|
1474
|
-
| \`kick g controller <name>\` | Generate controller |
|
|
1475
|
-
| \`kick g service <name>\` | Generate service |
|
|
1476
|
-
| \`kick g middleware <name>\` | Generate middleware |
|
|
1477
|
-
| \`kick add <package>\` | Add KickJS package |
|
|
1478
|
-
| \`kick add --list\` | List available packages |
|
|
1479
|
-
| \`kick rm module <names...>\` | Remove one or more modules |
|
|
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\`.
|
|
1480
1592
|
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
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
|
+
---
|
|
1485
1611
|
|
|
1486
1612
|
## Learn More
|
|
1487
1613
|
|
|
1488
1614
|
- [KickJS Docs](https://forinda.github.io/kick-js/)
|
|
1489
|
-
- [
|
|
1490
|
-
- [Decorators
|
|
1491
|
-
- [DI System](https://forinda.github.io/kick-js/guide/dependency-injection.html)
|
|
1615
|
+
- [Decorators](https://forinda.github.io/kick-js/guide/decorators.html)
|
|
1616
|
+
- [Context Decorators](https://forinda.github.io/kick-js/guide/context-decorators.html)
|
|
1492
1617
|
- [Testing](https://forinda.github.io/kick-js/api/testing.html)
|
|
1493
1618
|
`;
|
|
1494
1619
|
}
|
|
@@ -1524,6 +1649,7 @@ async function initProject(options) {
|
|
|
1524
1649
|
await writeFileSafe(join(dir, "README.md"), generateReadme(name, template, packageManager));
|
|
1525
1650
|
await writeFileSafe(join(dir, "CLAUDE.md"), generateClaude(name, template, packageManager));
|
|
1526
1651
|
await writeFileSafe(join(dir, "AGENTS.md"), generateAgents(name, template, packageManager));
|
|
1652
|
+
await writeFileSafe(join(dir, "kickjs-skills.md"), generateKickJsSkills(name, template, packageManager));
|
|
1527
1653
|
if (options.installDeps) {
|
|
1528
1654
|
console.log(`\n Installing dependencies with ${packageManager}...\n`);
|
|
1529
1655
|
try {
|
|
@@ -2612,7 +2738,7 @@ export interface I${pascal}Repository {
|
|
|
2612
2738
|
* \`@Inject(${pascal.toUpperCase()}_REPOSITORY)\` both return the typed
|
|
2613
2739
|
* interface — no manual generic, no \`any\` cast.
|
|
2614
2740
|
*/
|
|
2615
|
-
export const ${pascal.toUpperCase()}_REPOSITORY = createToken<I${pascal}Repository>('
|
|
2741
|
+
export const ${pascal.toUpperCase()}_REPOSITORY = createToken<I${pascal}Repository>('app/${kebab}/repository')
|
|
2616
2742
|
`;
|
|
2617
2743
|
}
|
|
2618
2744
|
function generateInMemoryRepository(ctx) {
|
|
@@ -4030,97 +4156,114 @@ export const modules: AppModuleClass[] = [${pascal}Module]
|
|
|
4030
4156
|
}
|
|
4031
4157
|
//#endregion
|
|
4032
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
|
+
*/
|
|
4033
4170
|
async function generateAdapter(options) {
|
|
4034
4171
|
const { name, outDir } = options;
|
|
4035
4172
|
const kebab = toKebabCase(name);
|
|
4036
4173
|
const pascal = toPascalCase(name);
|
|
4037
4174
|
const files = [];
|
|
4038
4175
|
const filePath = join(outDir, `${kebab}.adapter.ts`);
|
|
4039
|
-
await writeFileSafe(filePath, `import
|
|
4176
|
+
await writeFileSafe(filePath, `import { defineAdapter, type AdapterContext, type AdapterMiddleware } from '@forinda/kickjs'
|
|
4040
4177
|
|
|
4041
|
-
|
|
4042
|
-
|
|
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
|
|
4043
4190
|
}
|
|
4044
4191
|
|
|
4045
4192
|
/**
|
|
4046
|
-
* ${pascal} adapter
|
|
4193
|
+
* ${pascal} adapter — built via \`defineAdapter()\` so callers get the
|
|
4194
|
+
* factory's call / \`.scoped()\` / \`.async()\` surfaces for free.
|
|
4047
4195
|
*
|
|
4048
4196
|
* Hooks into the Application lifecycle to add middleware, routes,
|
|
4049
4197
|
* or external service connections.
|
|
4050
4198
|
*
|
|
4051
|
-
*
|
|
4052
|
-
*
|
|
4053
|
-
*
|
|
4054
|
-
*
|
|
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
|
+
* \`\`\`
|
|
4055
4209
|
*/
|
|
4056
|
-
export
|
|
4057
|
-
name
|
|
4058
|
-
|
|
4059
|
-
|
|
4060
|
-
|
|
4061
|
-
|
|
4062
|
-
|
|
4063
|
-
|
|
4064
|
-
|
|
4065
|
-
|
|
4066
|
-
|
|
4067
|
-
|
|
4068
|
-
|
|
4069
|
-
|
|
4070
|
-
|
|
4071
|
-
|
|
4072
|
-
|
|
4073
|
-
|
|
4074
|
-
|
|
4075
|
-
|
|
4076
|
-
|
|
4077
|
-
|
|
4078
|
-
|
|
4079
|
-
// path: '/api/v1/admin',
|
|
4080
|
-
// handler: myAdminMiddleware(),
|
|
4081
|
-
// },
|
|
4082
|
-
]
|
|
4083
|
-
}
|
|
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
|
+
},
|
|
4084
4233
|
|
|
4085
|
-
|
|
4086
|
-
|
|
4087
|
-
|
|
4088
|
-
|
|
4089
|
-
|
|
4090
|
-
|
|
4091
|
-
|
|
4092
|
-
|
|
4093
|
-
|
|
4094
|
-
// })
|
|
4095
|
-
}
|
|
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
|
+
},
|
|
4096
4243
|
|
|
4097
|
-
|
|
4098
|
-
|
|
4099
|
-
|
|
4100
|
-
|
|
4101
|
-
|
|
4102
|
-
|
|
4103
|
-
|
|
4104
|
-
|
|
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
|
+
},
|
|
4105
4252
|
|
|
4106
|
-
|
|
4107
|
-
|
|
4108
|
-
|
|
4109
|
-
|
|
4110
|
-
|
|
4111
|
-
|
|
4112
|
-
|
|
4113
|
-
// container.registerInstance(SOCKET_IO, io)
|
|
4114
|
-
}
|
|
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
|
+
},
|
|
4115
4260
|
|
|
4116
|
-
|
|
4117
|
-
|
|
4118
|
-
|
|
4119
|
-
|
|
4120
|
-
|
|
4121
|
-
|
|
4122
|
-
}
|
|
4123
|
-
}
|
|
4261
|
+
/** Called on graceful shutdown. Clean up connections. */
|
|
4262
|
+
async shutdown(): Promise<void> {
|
|
4263
|
+
// Example: await this.pool.end()
|
|
4264
|
+
},
|
|
4265
|
+
}),
|
|
4266
|
+
})
|
|
4124
4267
|
`);
|
|
4125
4268
|
files.push(filePath);
|
|
4126
4269
|
return files;
|
|
@@ -4128,80 +4271,84 @@ export class ${pascal}Adapter implements AppAdapter {
|
|
|
4128
4271
|
//#endregion
|
|
4129
4272
|
//#region src/generators/plugin.ts
|
|
4130
4273
|
/**
|
|
4131
|
-
* Scaffold a `
|
|
4274
|
+
* Scaffold a `definePlugin()` factory under `src/plugins/<name>.plugin.ts`.
|
|
4132
4275
|
*
|
|
4133
|
-
*
|
|
4134
|
-
*
|
|
4135
|
-
*
|
|
4136
|
-
*
|
|
4137
|
-
* 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.
|
|
4138
4281
|
*/
|
|
4139
4282
|
async function generatePlugin(options) {
|
|
4140
4283
|
const { name, outDir } = options;
|
|
4141
4284
|
const kebab = toKebabCase(name);
|
|
4142
4285
|
const pascal = toPascalCase(name);
|
|
4143
|
-
const factoryName = `${toCamelCase(name)}Plugin`;
|
|
4144
4286
|
const files = [];
|
|
4145
4287
|
const filePath = join(outDir, `${kebab}.plugin.ts`);
|
|
4146
|
-
await writeFileSafe(filePath, `import
|
|
4288
|
+
await writeFileSafe(filePath, `import {
|
|
4289
|
+
definePlugin,
|
|
4290
|
+
type AppAdapter,
|
|
4291
|
+
type AppModuleClass,
|
|
4292
|
+
type Container,
|
|
4293
|
+
} from '@forinda/kickjs'
|
|
4147
4294
|
|
|
4148
4295
|
/**
|
|
4149
|
-
*
|
|
4296
|
+
* Configuration for the ${pascal} plugin.
|
|
4150
4297
|
*
|
|
4151
|
-
* Plugins typically take a small
|
|
4152
|
-
*
|
|
4153
|
-
*
|
|
4154
|
-
*
|
|
4155
|
-
* 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.
|
|
4156
4302
|
*/
|
|
4157
|
-
export interface ${pascal}
|
|
4158
|
-
// Add your plugin
|
|
4303
|
+
export interface ${pascal}PluginConfig {
|
|
4304
|
+
// Add your plugin config here, e.g.:
|
|
4159
4305
|
// enabled?: boolean
|
|
4160
4306
|
// apiKey?: string
|
|
4161
4307
|
}
|
|
4162
4308
|
|
|
4163
4309
|
/**
|
|
4164
|
-
* ${pascal} plugin
|
|
4310
|
+
* ${pascal} plugin — built via \`definePlugin()\` so callers get the
|
|
4311
|
+
* factory's call / \`.scoped()\` / \`.async()\` surfaces for free.
|
|
4165
4312
|
*
|
|
4166
|
-
* A
|
|
4167
|
-
*
|
|
4168
|
-
* Every hook is optional — delete the ones you don't need and keep
|
|
4169
|
-
* 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 })\`.
|
|
4170
4315
|
*
|
|
4171
|
-
* 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):
|
|
4172
4318
|
*
|
|
4173
|
-
* 1. \`register(container)\`
|
|
4319
|
+
* 1. \`register(container)\` — runs before user modules load. Use
|
|
4174
4320
|
* it to bind services that modules depend on.
|
|
4175
|
-
* 2. \`modules()\`
|
|
4176
|
-
* 3. \`adapters()\`
|
|
4177
|
-
* 4. \`middleware()\`
|
|
4178
|
-
* 5. \`onReady(container)\`
|
|
4179
|
-
* 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.
|
|
4180
4326
|
*
|
|
4181
4327
|
* @example
|
|
4182
4328
|
* \`\`\`ts
|
|
4183
4329
|
* import { bootstrap } from '@forinda/kickjs'
|
|
4184
|
-
* import { ${
|
|
4330
|
+
* import { ${pascal}Plugin } from './plugins/${kebab}.plugin'
|
|
4185
4331
|
*
|
|
4186
4332
|
* export const app = await bootstrap({
|
|
4187
4333
|
* modules,
|
|
4188
|
-
* plugins: [${
|
|
4334
|
+
* plugins: [${pascal}Plugin({ /* config overrides *\\/ })],
|
|
4189
4335
|
* })
|
|
4190
4336
|
* \`\`\`
|
|
4191
4337
|
*/
|
|
4192
|
-
export
|
|
4193
|
-
|
|
4194
|
-
|
|
4195
|
-
|
|
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 }) => ({
|
|
4196
4344
|
/**
|
|
4197
4345
|
* Register DI bindings before modules load.
|
|
4198
4346
|
* Use \`container.registerInstance(TOKEN, value)\` for singletons
|
|
4199
4347
|
* and \`container.registerFactory(TOKEN, () => ...)\` for lazy
|
|
4200
4348
|
* constructions.
|
|
4201
4349
|
*/
|
|
4202
|
-
register(
|
|
4203
|
-
// Example:
|
|
4204
|
-
// container.registerInstance(MY_TOKEN, new MyService(options))
|
|
4350
|
+
register(_container: Container): void {
|
|
4351
|
+
// Example: _container.registerInstance(MY_TOKEN, new MyService(_config))
|
|
4205
4352
|
},
|
|
4206
4353
|
|
|
4207
4354
|
/**
|
|
@@ -4217,11 +4364,11 @@ export function ${factoryName}(options: ${pascal}PluginOptions = {}): KickPlugin
|
|
|
4217
4364
|
|
|
4218
4365
|
/**
|
|
4219
4366
|
* Return adapter instances to be added to the application.
|
|
4220
|
-
* Plugin adapters
|
|
4367
|
+
* Plugin adapters mount before user adapters.
|
|
4221
4368
|
*/
|
|
4222
4369
|
adapters(): AppAdapter[] {
|
|
4223
4370
|
return [
|
|
4224
|
-
//
|
|
4371
|
+
// MyAdapter({ ... }),
|
|
4225
4372
|
]
|
|
4226
4373
|
},
|
|
4227
4374
|
|
|
@@ -4229,10 +4376,10 @@ export function ${factoryName}(options: ${pascal}PluginOptions = {}): KickPlugin
|
|
|
4229
4376
|
* Return Express middleware entries to be added to the global
|
|
4230
4377
|
* pipeline. Plugin middleware runs before user-defined middleware.
|
|
4231
4378
|
*/
|
|
4232
|
-
middleware():
|
|
4379
|
+
middleware(): unknown[] {
|
|
4233
4380
|
return [
|
|
4234
4381
|
// helmet(),
|
|
4235
|
-
// myCustomMiddleware(
|
|
4382
|
+
// myCustomMiddleware(_config),
|
|
4236
4383
|
]
|
|
4237
4384
|
},
|
|
4238
4385
|
|
|
@@ -4241,9 +4388,9 @@ export function ${factoryName}(options: ${pascal}PluginOptions = {}): KickPlugin
|
|
|
4241
4388
|
* for post-startup work like logging, health checks, or warming
|
|
4242
4389
|
* a cache. Runs once per process.
|
|
4243
4390
|
*/
|
|
4244
|
-
async onReady(
|
|
4245
|
-
// const
|
|
4246
|
-
//
|
|
4391
|
+
async onReady(_container: Container): Promise<void> {
|
|
4392
|
+
// const log = _container.resolve(Logger)
|
|
4393
|
+
// log.info('${pascal} plugin ready')
|
|
4247
4394
|
},
|
|
4248
4395
|
|
|
4249
4396
|
/**
|
|
@@ -4251,10 +4398,10 @@ export function ${factoryName}(options: ${pascal}PluginOptions = {}): KickPlugin
|
|
|
4251
4398
|
* resources this plugin owns (connections, timers, subscriptions).
|
|
4252
4399
|
*/
|
|
4253
4400
|
async shutdown(): Promise<void> {
|
|
4254
|
-
// await this.connection?.close()
|
|
4401
|
+
// Example: await this.connection?.close()
|
|
4255
4402
|
},
|
|
4256
|
-
}
|
|
4257
|
-
}
|
|
4403
|
+
}),
|
|
4404
|
+
})
|
|
4258
4405
|
`);
|
|
4259
4406
|
files.push(filePath);
|
|
4260
4407
|
return files;
|
|
@@ -4487,94 +4634,276 @@ export class ${pascal}Controller {
|
|
|
4487
4634
|
ctx.created({ message: '${pascal} created', data: ctx.body })
|
|
4488
4635
|
}
|
|
4489
4636
|
}
|
|
4490
|
-
`);
|
|
4491
|
-
files.push(filePath);
|
|
4492
|
-
return files;
|
|
4637
|
+
`);
|
|
4638
|
+
files.push(filePath);
|
|
4639
|
+
return files;
|
|
4640
|
+
}
|
|
4641
|
+
//#endregion
|
|
4642
|
+
//#region src/generators/dto.ts
|
|
4643
|
+
async function generateDto(options) {
|
|
4644
|
+
const { name, moduleName, modulesDir, pattern } = options;
|
|
4645
|
+
const outDir = resolveOutDir({
|
|
4646
|
+
type: "dto",
|
|
4647
|
+
outDir: options.outDir,
|
|
4648
|
+
moduleName,
|
|
4649
|
+
modulesDir,
|
|
4650
|
+
defaultDir: "src/dtos",
|
|
4651
|
+
pattern,
|
|
4652
|
+
shouldPluralize: options.pluralize ?? true
|
|
4653
|
+
});
|
|
4654
|
+
const kebab = toKebabCase(name);
|
|
4655
|
+
const pascal = toPascalCase(name);
|
|
4656
|
+
const camel = toCamelCase(name);
|
|
4657
|
+
const files = [];
|
|
4658
|
+
const filePath = join(outDir, `${kebab}.dto.ts`);
|
|
4659
|
+
await writeFileSafe(filePath, `import { z } from 'zod'
|
|
4660
|
+
|
|
4661
|
+
export const ${camel}Schema = z.object({
|
|
4662
|
+
// Define your schema fields here
|
|
4663
|
+
name: z.string().min(1).max(200),
|
|
4664
|
+
})
|
|
4665
|
+
|
|
4666
|
+
export type ${pascal}DTO = z.infer<typeof ${camel}Schema>
|
|
4667
|
+
`);
|
|
4668
|
+
files.push(filePath);
|
|
4669
|
+
return files;
|
|
4670
|
+
}
|
|
4671
|
+
//#endregion
|
|
4672
|
+
//#region src/generators/config.ts
|
|
4673
|
+
async function generateConfig(options) {
|
|
4674
|
+
const filePath = join(options.outDir, "kick.config.ts");
|
|
4675
|
+
const modulesDir = options.modulesDir ?? "src/modules";
|
|
4676
|
+
const defaultRepo = options.defaultRepo ?? "inmemory";
|
|
4677
|
+
if (existsSync(filePath) && !options.force) {
|
|
4678
|
+
if (!await confirm({
|
|
4679
|
+
message: "kick.config.ts already exists. Overwrite?",
|
|
4680
|
+
initialValue: false
|
|
4681
|
+
})) {
|
|
4682
|
+
console.log("\n Skipped — existing kick.config.ts preserved.");
|
|
4683
|
+
return [];
|
|
4684
|
+
}
|
|
4685
|
+
}
|
|
4686
|
+
await writeFileSafe(filePath, `import { defineConfig } from '@forinda/kickjs-cli'
|
|
4687
|
+
|
|
4688
|
+
export default defineConfig({
|
|
4689
|
+
modules: {
|
|
4690
|
+
dir: '${modulesDir}',
|
|
4691
|
+
repo: '${defaultRepo}',
|
|
4692
|
+
pluralize: true,
|
|
4693
|
+
},
|
|
4694
|
+
|
|
4695
|
+
typegen: {
|
|
4696
|
+
schemaValidator: 'zod',
|
|
4697
|
+
},
|
|
4698
|
+
|
|
4699
|
+
commands: [
|
|
4700
|
+
{
|
|
4701
|
+
name: 'test',
|
|
4702
|
+
description: 'Run tests with Vitest',
|
|
4703
|
+
steps: 'npx vitest run',
|
|
4704
|
+
},
|
|
4705
|
+
{
|
|
4706
|
+
name: 'format',
|
|
4707
|
+
description: 'Format code with Prettier',
|
|
4708
|
+
steps: 'npx prettier --write src/',
|
|
4709
|
+
},
|
|
4710
|
+
{
|
|
4711
|
+
name: 'format:check',
|
|
4712
|
+
description: 'Check formatting without writing',
|
|
4713
|
+
steps: 'npx prettier --check src/',
|
|
4714
|
+
},
|
|
4715
|
+
{
|
|
4716
|
+
name: 'ci:check',
|
|
4717
|
+
description: 'Run typecheck + format check',
|
|
4718
|
+
steps: ['npx tsc --noEmit', 'npx prettier --check src/'],
|
|
4719
|
+
aliases: ['verify'],
|
|
4720
|
+
},
|
|
4721
|
+
],
|
|
4722
|
+
})
|
|
4723
|
+
`);
|
|
4724
|
+
return [filePath];
|
|
4725
|
+
}
|
|
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);
|
|
4493
4837
|
}
|
|
4494
4838
|
//#endregion
|
|
4495
|
-
//#region src/generators/
|
|
4496
|
-
|
|
4497
|
-
|
|
4498
|
-
|
|
4499
|
-
|
|
4500
|
-
|
|
4501
|
-
|
|
4502
|
-
|
|
4503
|
-
|
|
4504
|
-
|
|
4505
|
-
|
|
4506
|
-
|
|
4507
|
-
|
|
4508
|
-
|
|
4509
|
-
|
|
4510
|
-
const files = [];
|
|
4511
|
-
const filePath = join(outDir, `${kebab}.dto.ts`);
|
|
4512
|
-
await writeFileSafe(filePath, `import { z } from 'zod'
|
|
4513
|
-
|
|
4514
|
-
export const ${camel}Schema = z.object({
|
|
4515
|
-
// Define your schema fields here
|
|
4516
|
-
name: z.string().min(1).max(200),
|
|
4517
|
-
})
|
|
4518
|
-
|
|
4519
|
-
export type ${pascal}DTO = z.infer<typeof ${camel}Schema>
|
|
4520
|
-
`);
|
|
4521
|
-
files.push(filePath);
|
|
4522
|
-
return files;
|
|
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";
|
|
4523
4854
|
}
|
|
4524
|
-
|
|
4525
|
-
|
|
4526
|
-
|
|
4527
|
-
|
|
4528
|
-
|
|
4529
|
-
|
|
4530
|
-
|
|
4531
|
-
|
|
4532
|
-
|
|
4533
|
-
|
|
4534
|
-
|
|
4535
|
-
|
|
4536
|
-
|
|
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
|
+
}
|
|
4537
4902
|
}
|
|
4903
|
+
await writeFileSafe(file, render());
|
|
4904
|
+
written.push(file);
|
|
4538
4905
|
}
|
|
4539
|
-
|
|
4540
|
-
|
|
4541
|
-
export default defineConfig({
|
|
4542
|
-
modules: {
|
|
4543
|
-
dir: '${modulesDir}',
|
|
4544
|
-
repo: '${defaultRepo}',
|
|
4545
|
-
pluralize: true,
|
|
4546
|
-
},
|
|
4547
|
-
|
|
4548
|
-
typegen: {
|
|
4549
|
-
schemaValidator: 'zod',
|
|
4550
|
-
},
|
|
4551
|
-
|
|
4552
|
-
commands: [
|
|
4553
|
-
{
|
|
4554
|
-
name: 'test',
|
|
4555
|
-
description: 'Run tests with Vitest',
|
|
4556
|
-
steps: 'npx vitest run',
|
|
4557
|
-
},
|
|
4558
|
-
{
|
|
4559
|
-
name: 'format',
|
|
4560
|
-
description: 'Format code with Prettier',
|
|
4561
|
-
steps: 'npx prettier --write src/',
|
|
4562
|
-
},
|
|
4563
|
-
{
|
|
4564
|
-
name: 'format:check',
|
|
4565
|
-
description: 'Check formatting without writing',
|
|
4566
|
-
steps: 'npx prettier --check src/',
|
|
4567
|
-
},
|
|
4568
|
-
{
|
|
4569
|
-
name: 'ci:check',
|
|
4570
|
-
description: 'Run typecheck + format check',
|
|
4571
|
-
steps: ['npx tsc --noEmit', 'npx prettier --check src/'],
|
|
4572
|
-
aliases: ['verify'],
|
|
4573
|
-
},
|
|
4574
|
-
],
|
|
4575
|
-
})
|
|
4576
|
-
`);
|
|
4577
|
-
return [filePath];
|
|
4906
|
+
return written;
|
|
4578
4907
|
}
|
|
4579
4908
|
//#endregion
|
|
4580
4909
|
//#region src/generators/auth-scaffold.ts
|
|
@@ -5386,7 +5715,7 @@ export interface I${pascal}Repository {
|
|
|
5386
5715
|
* \`@Inject(${pascal.toUpperCase()}_REPOSITORY)\` both return the typed
|
|
5387
5716
|
* interface — no manual generic, no \`any\` cast.
|
|
5388
5717
|
*/
|
|
5389
|
-
export const ${pascal.toUpperCase()}_REPOSITORY = createToken<I${pascal}Repository>('
|
|
5718
|
+
export const ${pascal.toUpperCase()}_REPOSITORY = createToken<I${pascal}Repository>('app/${kebab}/repository')
|
|
5390
5719
|
`;
|
|
5391
5720
|
}
|
|
5392
5721
|
function genDomainService(pascal, kebab) {
|
|
@@ -5542,118 +5871,6 @@ describe('${pascal}', () => {
|
|
|
5542
5871
|
return files;
|
|
5543
5872
|
}
|
|
5544
5873
|
//#endregion
|
|
5545
|
-
//#region src/config.ts
|
|
5546
|
-
const PACKAGE_MANAGERS = [
|
|
5547
|
-
"pnpm",
|
|
5548
|
-
"npm",
|
|
5549
|
-
"yarn",
|
|
5550
|
-
"bun"
|
|
5551
|
-
];
|
|
5552
|
-
const BUILTIN_REPO_TYPES = [
|
|
5553
|
-
"drizzle",
|
|
5554
|
-
"inmemory",
|
|
5555
|
-
"prisma"
|
|
5556
|
-
];
|
|
5557
|
-
/** Resolve module config from `modules.*` block. */
|
|
5558
|
-
function resolveModuleConfig(config) {
|
|
5559
|
-
if (!config) return {};
|
|
5560
|
-
const mc = {
|
|
5561
|
-
dir: config.modules?.dir,
|
|
5562
|
-
repo: config.modules?.repo,
|
|
5563
|
-
schemaDir: config.modules?.schemaDir,
|
|
5564
|
-
pluralize: config.modules?.pluralize,
|
|
5565
|
-
prismaClientPath: config.modules?.prismaClientPath
|
|
5566
|
-
};
|
|
5567
|
-
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.`);
|
|
5568
|
-
return mc;
|
|
5569
|
-
}
|
|
5570
|
-
const CONFIG_FILES = [
|
|
5571
|
-
"kick.config.ts",
|
|
5572
|
-
"kick.config.js",
|
|
5573
|
-
"kick.config.mjs",
|
|
5574
|
-
"kick.config.json"
|
|
5575
|
-
];
|
|
5576
|
-
/** Load kick.config.* from the project root */
|
|
5577
|
-
async function loadKickConfig(cwd) {
|
|
5578
|
-
for (const filename of CONFIG_FILES) {
|
|
5579
|
-
const filepath = join(cwd, filename);
|
|
5580
|
-
try {
|
|
5581
|
-
await access(filepath);
|
|
5582
|
-
} catch {
|
|
5583
|
-
continue;
|
|
5584
|
-
}
|
|
5585
|
-
if (filename.endsWith(".json")) {
|
|
5586
|
-
const content = await readFile(filepath, "utf-8");
|
|
5587
|
-
return JSON.parse(content);
|
|
5588
|
-
}
|
|
5589
|
-
try {
|
|
5590
|
-
const { pathToFileURL } = await import("node:url");
|
|
5591
|
-
const mod = await import(pathToFileURL(filepath).href);
|
|
5592
|
-
const config = mod.default ?? mod;
|
|
5593
|
-
const warnings = validateAssetMap(config, cwd);
|
|
5594
|
-
for (const warning of warnings) console.warn(` Warning: ${warning}`);
|
|
5595
|
-
return config;
|
|
5596
|
-
} catch (err) {
|
|
5597
|
-
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.`);
|
|
5598
|
-
continue;
|
|
5599
|
-
}
|
|
5600
|
-
}
|
|
5601
|
-
return null;
|
|
5602
|
-
}
|
|
5603
|
-
/**
|
|
5604
|
-
* Validate `assetMap` entries on a loaded config. Returns a list of
|
|
5605
|
-
* human-readable warnings; the caller decides how to surface them
|
|
5606
|
-
* (typically `console.warn`). Never throws — `kick g` and other
|
|
5607
|
-
* unrelated commands should keep working even when the assetMap is
|
|
5608
|
-
* misconfigured.
|
|
5609
|
-
*
|
|
5610
|
-
* Checks:
|
|
5611
|
-
*
|
|
5612
|
-
* - Each entry's `src` is a non-empty string.
|
|
5613
|
-
* - The `src` directory exists on disk (otherwise the typegen + build
|
|
5614
|
-
* steps will fail later with cryptic errors).
|
|
5615
|
-
* - `dest` doesn't escape the project root (defensive — a `dest:
|
|
5616
|
-
* '../../etc'` typo could write files outside the workspace).
|
|
5617
|
-
* - The namespace key is a non-empty string and doesn't include a
|
|
5618
|
-
* `/` (would conflict with the `<namespace>/<key>` manifest format).
|
|
5619
|
-
*/
|
|
5620
|
-
function validateAssetMap(config, cwd) {
|
|
5621
|
-
const warnings = [];
|
|
5622
|
-
if (!config?.assetMap) return warnings;
|
|
5623
|
-
const root = resolve(cwd);
|
|
5624
|
-
for (const [namespace, entry] of Object.entries(config.assetMap)) {
|
|
5625
|
-
if (!namespace || namespace.includes("/")) {
|
|
5626
|
-
warnings.push(`assetMap key '${namespace}' is invalid — must be a non-empty string without '/'`);
|
|
5627
|
-
continue;
|
|
5628
|
-
}
|
|
5629
|
-
if (typeof entry?.src !== "string" || entry.src.length === 0) {
|
|
5630
|
-
warnings.push(`assetMap.${namespace} is missing a non-empty 'src' field`);
|
|
5631
|
-
continue;
|
|
5632
|
-
}
|
|
5633
|
-
if (!existsSync(resolve(cwd, entry.src))) warnings.push(`assetMap.${namespace}.src ('${entry.src}') does not exist — typegen + build will fail`);
|
|
5634
|
-
if (entry.dest) {
|
|
5635
|
-
if (escapesRoot$1(resolve(cwd, entry.dest), root)) warnings.push(`assetMap.${namespace}.dest ('${entry.dest}') resolves outside the project root — refusing to copy`);
|
|
5636
|
-
}
|
|
5637
|
-
}
|
|
5638
|
-
return warnings;
|
|
5639
|
-
}
|
|
5640
|
-
/**
|
|
5641
|
-
* Returns true when `path` (absolute) resolves outside of `root`
|
|
5642
|
-
* (also absolute). Uses `path.relative` for accuracy:
|
|
5643
|
-
*
|
|
5644
|
-
* - The result is empty when paths are identical (inside).
|
|
5645
|
-
* - It starts with `..` when the path traverses outside the root.
|
|
5646
|
-
* - It's absolute (Windows: cross-drive) when there's no relative
|
|
5647
|
-
* path between them.
|
|
5648
|
-
*
|
|
5649
|
-
* Avoids the prefix-match pitfalls of `startsWith` (e.g. `/app`
|
|
5650
|
-
* matching `/app2/...`, or case-mismatches on macOS / Windows).
|
|
5651
|
-
*/
|
|
5652
|
-
function escapesRoot$1(path, root) {
|
|
5653
|
-
const rel = relative(root, path);
|
|
5654
|
-
return rel === "" ? false : rel.startsWith("..") || isAbsolute(rel);
|
|
5655
|
-
}
|
|
5656
|
-
//#endregion
|
|
5657
5874
|
//#region src/typegen/scanner.ts
|
|
5658
5875
|
/** Decorators that mark a class as DI-managed */
|
|
5659
5876
|
const DECORATOR_NAMES = [
|
|
@@ -6831,8 +7048,12 @@ export {}
|
|
|
6831
7048
|
const blocks = [];
|
|
6832
7049
|
for (const item of [...byName.values()].sort((a, b) => a.name.localeCompare(b.name))) {
|
|
6833
7050
|
const docLines = [];
|
|
6834
|
-
if (item.description) docLines.push(` * ${
|
|
6835
|
-
if (item.example)
|
|
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
|
+
}
|
|
6836
7057
|
docLines.push(` * @see ${item.relativePath}`);
|
|
6837
7058
|
blocks.push([
|
|
6838
7059
|
"/**",
|
|
@@ -7118,6 +7339,13 @@ async function safeRun(opts, silent) {
|
|
|
7118
7339
|
}
|
|
7119
7340
|
//#endregion
|
|
7120
7341
|
//#region src/commands/generate.ts
|
|
7342
|
+
const AGENT_DOCS_ONLY_VALUES = [
|
|
7343
|
+
"agents",
|
|
7344
|
+
"claude",
|
|
7345
|
+
"skills",
|
|
7346
|
+
"both",
|
|
7347
|
+
"all"
|
|
7348
|
+
];
|
|
7121
7349
|
/** Check if --dry-run was passed on the parent generate command */
|
|
7122
7350
|
function isDryRun(cmd) {
|
|
7123
7351
|
return (cmd.parent?.opts())?.dryRun ?? false;
|
|
@@ -7200,6 +7428,10 @@ const GENERATORS = [
|
|
|
7200
7428
|
{
|
|
7201
7429
|
name: "config",
|
|
7202
7430
|
description: "Generate kick.config.ts"
|
|
7431
|
+
},
|
|
7432
|
+
{
|
|
7433
|
+
name: "agents",
|
|
7434
|
+
description: "Regenerate AGENTS.md + CLAUDE.md + kickjs-skills.md from upstream templates"
|
|
7203
7435
|
}
|
|
7204
7436
|
];
|
|
7205
7437
|
async function printGeneratorList() {
|
|
@@ -7467,6 +7699,24 @@ function registerGenerateCommand(program) {
|
|
|
7467
7699
|
force: opts.force
|
|
7468
7700
|
}), dryRun);
|
|
7469
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
|
+
});
|
|
7470
7720
|
}
|
|
7471
7721
|
//#endregion
|
|
7472
7722
|
//#region src/utils/shell.ts
|
|
@@ -7673,11 +7923,15 @@ async function startDevServer(_entry, port) {
|
|
|
7673
7923
|
configFile: resolve("vite.config.ts"),
|
|
7674
7924
|
server: { port: port ? parseInt(port, 10) : void 0 }
|
|
7675
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}/`));
|
|
7676
7928
|
let typegenTimer = null;
|
|
7677
7929
|
const scheduleTypegen = (file) => {
|
|
7678
|
-
if (!/\.(ts|tsx|mts|cts)$/.test(file)) return;
|
|
7679
7930
|
if (file.includes(".kickjs")) return;
|
|
7680
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;
|
|
7681
7935
|
if (typegenTimer) clearTimeout(typegenTimer);
|
|
7682
7936
|
typegenTimer = setTimeout(() => {
|
|
7683
7937
|
runTypegen({
|
|
@@ -7695,6 +7949,7 @@ async function startDevServer(_entry, port) {
|
|
|
7695
7949
|
server.watcher.on("add", scheduleTypegen);
|
|
7696
7950
|
server.watcher.on("unlink", scheduleTypegen);
|
|
7697
7951
|
server.watcher.on("change", scheduleTypegen);
|
|
7952
|
+
if (assetSrcRoots.length > 0) server.watcher.add(assetSrcRoots);
|
|
7698
7953
|
await server.listen();
|
|
7699
7954
|
server.printUrls();
|
|
7700
7955
|
console.log(`\n KickJS dev server running (Vite + @forinda/kickjs-vite)\n`);
|