@forinda/kickjs-cli 5.2.0 → 5.2.3

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.
Files changed (34) hide show
  1. package/dist/builtins-BdvmVAJ1.mjs +3740 -0
  2. package/dist/builtins-Du70nybS.mjs +1066 -0
  3. package/dist/builtins-Du70nybS.mjs.map +1 -0
  4. package/dist/cli.mjs +2 -120
  5. package/dist/config-Dzw8Ws4d.mjs +11 -0
  6. package/dist/config-lCKbrRnt.mjs +12 -0
  7. package/dist/{config-DDrgs-I3.mjs.map → config-lCKbrRnt.mjs.map} +1 -1
  8. package/dist/generator-extension-Cp5FUUAw.mjs +2687 -0
  9. package/dist/generator-extension-Cp5FUUAw.mjs.map +1 -0
  10. package/dist/index.mjs +2 -5
  11. package/dist/plugin-Dv2gKsuC.mjs +11 -0
  12. package/dist/plugin-VPl_QQGb.mjs +12 -0
  13. package/dist/{plugin-6_YlK-JG.mjs.map → plugin-VPl_QQGb.mjs.map} +1 -1
  14. package/dist/rolldown-runtime-B6QC8dMY.mjs +11 -0
  15. package/dist/{run-plugins-B1R0HG0g.mjs → run-plugins-CM1Af-4B.mjs} +2 -3
  16. package/dist/typegen-C6ZfoYTC.mjs +114 -0
  17. package/dist/typegen-CBI7dNXr.mjs +115 -0
  18. package/dist/{typegen-DugZmi-0.mjs.map → typegen-CBI7dNXr.mjs.map} +1 -1
  19. package/dist/types-n4LRUF_c.mjs +12 -0
  20. package/dist/{types-CGB8BiQh.mjs.map → types-n4LRUF_c.mjs.map} +1 -1
  21. package/package.json +5 -5
  22. package/dist/builtins-BW3g09hP.mjs +0 -8538
  23. package/dist/builtins-C_VfEGdg.mjs +0 -4182
  24. package/dist/builtins-C_VfEGdg.mjs.map +0 -1
  25. package/dist/config-DDrgs-I3.mjs +0 -171
  26. package/dist/config-DsQe2yzy.mjs +0 -169
  27. package/dist/generator-extension-DRNQpoZP.mjs +0 -4380
  28. package/dist/generator-extension-DRNQpoZP.mjs.map +0 -1
  29. package/dist/plugin-6_YlK-JG.mjs +0 -71
  30. package/dist/plugin-CQ0yYXyr.mjs +0 -80
  31. package/dist/rolldown-runtime-CYBbkZNy.mjs +0 -24
  32. package/dist/typegen-CYCsmCRF.mjs +0 -1351
  33. package/dist/typegen-DugZmi-0.mjs +0 -1353
  34. package/dist/types-CGB8BiQh.mjs +0 -25
@@ -1,4182 +0,0 @@
1
- /**
2
- * @forinda/kickjs-cli v5.2.0
3
- *
4
- * Copyright (c) Felix Orinda
5
- *
6
- * This source code is licensed under the MIT license found in the
7
- * LICENSE file in the root directory of this source tree.
8
- *
9
- * @license MIT
10
- */
11
- import { A as httpMethodColor, C as intro, D as select, E as outro, F as writeFileSafe, M as severityColor, N as fileExists, O as spinner, P as setDryRun, S as confirm, T as multiSelect, _ as pluralize, a as initProject, b as toKebabCase, c as generateKickJsSkills, d as generateService, f as generateGuard, g as resolveRepoType, h as generateModule, j as pc, k as text, l as generateDto, m as generateAdapter, n as tryDispatchPluginGenerator, o as generateAgents, p as generateMiddleware, s as generateClaude, t as listPluginGenerators, u as generateController, v as pluralizePascal, w as log, x as toPascalCase, y as toCamelCase } from "./generator-extension-DRNQpoZP.mjs";
12
- import { a as resolveModuleConfig, i as loadKickConfig, o as resolveTokenScope, t as PACKAGE_MANAGERS } from "./config-DDrgs-I3.mjs";
13
- import { n as defineCliPlugin } from "./types-CGB8BiQh.mjs";
14
- import { n as mergeCliPlugins } from "./plugin-6_YlK-JG.mjs";
15
- import { a as discoverAssets, i as TokenCollisionError, o as renderAssetTypes, r as watchTypegen, s as scanProject, t as runTypegen$1 } from "./typegen-DugZmi-0.mjs";
16
- import path, { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
17
- import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
18
- import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
19
- import { execSync, fork, spawn, spawnSync } from "node:child_process";
20
- import { pathToFileURL } from "node:url";
21
- import { glob } from "glob";
22
- import { groupAssetKeys } from "@forinda/kickjs";
23
- import { arch, platform, release } from "node:os";
24
- import { generate, migrateDown, migrateLatest, migrateRollback, migrateStatus, migrateUp, renderSchemaSource, resolveDbConfig } from "@forinda/kickjs-db";
25
- //#region src/commands/add.ts
26
- /** Registry of KickJS packages and their required peer dependencies */
27
- const PACKAGE_REGISTRY = {
28
- kickjs: {
29
- pkg: "@forinda/kickjs",
30
- peers: ["express"],
31
- description: "Unified framework: DI, decorators, routing, middleware",
32
- core: true
33
- },
34
- vite: {
35
- pkg: "@forinda/kickjs-vite",
36
- peers: ["vite"],
37
- description: "Vite plugin: dev server, HMR, module discovery",
38
- dev: true,
39
- core: true
40
- },
41
- cli: {
42
- pkg: "@forinda/kickjs-cli",
43
- peers: [],
44
- description: "CLI tool and code generators",
45
- dev: true,
46
- core: true
47
- },
48
- swagger: {
49
- pkg: "@forinda/kickjs-swagger",
50
- peers: [],
51
- description: "OpenAPI spec + Swagger UI + ReDoc"
52
- },
53
- db: {
54
- pkg: "@forinda/kickjs-db",
55
- peers: [],
56
- description: "kick/db core — schema DSL, migrations, KickDbClient, customType"
57
- },
58
- "db-pg": {
59
- pkg: "@forinda/kickjs-db-pg",
60
- peers: ["pg"],
61
- description: "kick/db PostgreSQL dialect + adapter (pgDialect, pgAdapter)"
62
- },
63
- drizzle: {
64
- pkg: "@forinda/kickjs-drizzle",
65
- peers: ["drizzle-orm"],
66
- description: "Drizzle ORM adapter + query builder"
67
- },
68
- prisma: {
69
- pkg: "@forinda/kickjs-prisma",
70
- peers: ["@prisma/client"],
71
- description: "Prisma adapter + query builder"
72
- },
73
- ws: {
74
- pkg: "@forinda/kickjs-ws",
75
- peers: ["socket.io"],
76
- description: "WebSocket with @WsController decorators"
77
- },
78
- devtools: {
79
- pkg: "@forinda/kickjs-devtools",
80
- peers: [],
81
- description: "Development dashboard — routes, DI, metrics, health",
82
- dev: true
83
- },
84
- auth: {
85
- pkg: "@forinda/kickjs-auth",
86
- peers: ["jsonwebtoken"],
87
- description: "Authentication — JWT, API key, and custom strategies"
88
- },
89
- queue: {
90
- pkg: "@forinda/kickjs-queue",
91
- peers: [],
92
- description: "Queue adapter (BullMQ/RabbitMQ/Kafka)"
93
- },
94
- "queue:bullmq": {
95
- pkg: "@forinda/kickjs-queue",
96
- peers: ["bullmq", "ioredis"],
97
- description: "Queue with BullMQ + Redis"
98
- },
99
- "queue:rabbitmq": {
100
- pkg: "@forinda/kickjs-queue",
101
- peers: ["amqplib"],
102
- description: "Queue with RabbitMQ"
103
- },
104
- "queue:kafka": {
105
- pkg: "@forinda/kickjs-queue",
106
- peers: ["kafkajs"],
107
- description: "Queue with Kafka"
108
- },
109
- mcp: {
110
- pkg: "@forinda/kickjs-mcp",
111
- peers: ["@modelcontextprotocol/sdk"],
112
- description: "Model Context Protocol server — expose @Controller endpoints as AI tools"
113
- },
114
- testing: {
115
- pkg: "@forinda/kickjs-testing",
116
- peers: [],
117
- description: "Test utilities and TestModule builder",
118
- dev: true
119
- }
120
- };
121
- /**
122
- * Walk up from `fromDir` to filesystem root, returning the first
123
- * directory that contains `name`. Lets monorepo sub-packages pick up
124
- * lockfiles and `packageManager` fields living at the workspace root.
125
- */
126
- function findUp(name, fromDir = process.cwd()) {
127
- let current = fromDir;
128
- while (true) {
129
- if (existsSync(resolve(current, name))) return current;
130
- const parent = dirname(current);
131
- if (parent === current) return null;
132
- current = parent;
133
- }
134
- }
135
- function detectFromLockfile() {
136
- if (findUp("pnpm-lock.yaml")) return "pnpm";
137
- if (findUp("yarn.lock")) return "yarn";
138
- if (findUp("bun.lockb") || findUp("bun.lock")) return "bun";
139
- if (findUp("package-lock.json")) return "npm";
140
- return null;
141
- }
142
- /**
143
- * Read `packageManager` from the nearest ancestor `package.json` that
144
- * declares the field (corepack convention: `"pnpm@10.0.0"`). Climbs so
145
- * monorepo sub-packages inherit the workspace pm even when their own
146
- * package.json omits the field.
147
- */
148
- function packageManagerFromPackageJson() {
149
- let dir = process.cwd();
150
- while (dir) {
151
- const pkgPath = resolve(dir, "package.json");
152
- if (existsSync(pkgPath)) try {
153
- const field = JSON.parse(readFileSync(pkgPath, "utf-8")).packageManager;
154
- if (typeof field === "string") {
155
- const name = field.split("@")[0];
156
- if (PACKAGE_MANAGERS.includes(name)) return name;
157
- }
158
- } catch {}
159
- const parent = dirname(dir);
160
- if (parent === dir) return null;
161
- dir = parent;
162
- }
163
- return null;
164
- }
165
- /**
166
- * Resolve which package manager to use, in priority order:
167
- * 1. `--pm` CLI flag
168
- * 2. `packageManager` in kick.config
169
- * 3. `packageManager` in nearest ancestor package.json (corepack)
170
- * 4. Nearest ancestor lockfile (pnpm-lock.yaml → yarn.lock → bun.lock → package-lock.json)
171
- * 5. `'npm'` fallback
172
- *
173
- * Returns the chosen pm plus the source for callers that want to log
174
- * the resolution path.
175
- */
176
- async function resolvePackageManagerWithSource(flagPm) {
177
- if (flagPm && PACKAGE_MANAGERS.includes(flagPm)) return {
178
- pm: flagPm,
179
- source: "flag"
180
- };
181
- const config = await loadKickConfig(process.cwd());
182
- if (config?.packageManager && PACKAGE_MANAGERS.includes(config.packageManager)) return {
183
- pm: config.packageManager,
184
- source: "config"
185
- };
186
- const fromPkg = packageManagerFromPackageJson();
187
- if (fromPkg) return {
188
- pm: fromPkg,
189
- source: "package.json"
190
- };
191
- const fromLock = detectFromLockfile();
192
- if (fromLock) return {
193
- pm: fromLock,
194
- source: "lockfile"
195
- };
196
- return {
197
- pm: "npm",
198
- source: "default"
199
- };
200
- }
201
- /** Convenience wrapper for callers that don't care about the source. */
202
- async function resolvePackageManager(flagPm) {
203
- const { pm } = await resolvePackageManagerWithSource(flagPm);
204
- return pm;
205
- }
206
- /**
207
- * Print the package catalog. By default shows just the three core
208
- * packages every project always has — the optional list churns
209
- * (packages added, deprecated, removed) and a long enumeration in CLI
210
- * output / docs goes stale within a release. Pass `all = true` to dump
211
- * everything; that's what `kick add --list --all` triggers when an
212
- * adopter genuinely wants the live catalog.
213
- */
214
- function printPackageList(all = false) {
215
- const entries = Object.entries(PACKAGE_REGISTRY);
216
- const maxName = Math.max(...entries.map(([k]) => k.length));
217
- const core = entries.filter(([, info]) => info.core);
218
- const optional = entries.filter(([, info]) => !info.core);
219
- const formatRow = ([name, info]) => {
220
- const padded = name.padEnd(maxName + 2);
221
- const peers = info.peers.length ? ` (+ ${info.peers.join(", ")})` : "";
222
- return ` ${padded} ${info.description}${peers}`;
223
- };
224
- console.log("\n Core packages (always installed by `kick new`):\n");
225
- for (const row of core) console.log(formatRow(row));
226
- if (all) {
227
- console.log("\n Optional packages (add as needed):\n");
228
- for (const row of optional) console.log(formatRow(row));
229
- } else {
230
- console.log(`\n Plus ${optional.length} optional packages (auth, swagger, db, queue, …).`);
231
- console.log(" Run `kick add --list --all` for the full catalog.");
232
- }
233
- console.log("\n Usage: kick add auth drizzle swagger");
234
- console.log(" kick add queue:bullmq");
235
- console.log();
236
- }
237
- function registerListCommand(program) {
238
- program.command("list").alias("ls").description("List KickJS packages (core only; pair with --all for the full catalog)").option("--all", "Include the full optional catalog").action((opts) => {
239
- printPackageList(Boolean(opts.all));
240
- });
241
- }
242
- function registerAddCommand(program) {
243
- program.command("add [packages...]").description("Add KickJS packages with their required dependencies").option("--pm <manager>", "Package manager override").option("-D, --dev", "Install as dev dependency").option("--list", "List packages (core only by default; pair with --all)").option("--all", "When listing, include the full optional catalog").action(async (packages, opts) => {
244
- if (opts.list || packages.length === 0) {
245
- printPackageList(Boolean(opts.all));
246
- return;
247
- }
248
- const { pm, source } = await resolvePackageManagerWithSource(opts.pm);
249
- console.log(`\n Using ${pm} (resolved from ${source})`);
250
- const forceDevFlag = opts.dev;
251
- const prodDeps = /* @__PURE__ */ new Set();
252
- const devDeps = /* @__PURE__ */ new Set();
253
- const unknown = [];
254
- for (const name of packages) {
255
- const entry = PACKAGE_REGISTRY[name];
256
- if (!entry) {
257
- unknown.push(name);
258
- continue;
259
- }
260
- const target = forceDevFlag || entry.dev ? devDeps : prodDeps;
261
- target.add(entry.pkg);
262
- for (const peer of entry.peers) target.add(peer);
263
- }
264
- if (unknown.length > 0) {
265
- console.log(`\n Unknown packages: ${unknown.join(", ")}`);
266
- console.log(" Run \"kick add --list\" to see available packages.\n");
267
- if (prodDeps.size === 0 && devDeps.size === 0) return;
268
- }
269
- if (prodDeps.size > 0) {
270
- const deps = Array.from(prodDeps);
271
- const cmd = `${pm} add ${deps.join(" ")}`;
272
- console.log(`\n Installing ${deps.length} dependency(ies):`);
273
- for (const dep of deps) console.log(` + ${dep}`);
274
- console.log();
275
- try {
276
- execSync(cmd, { stdio: "inherit" });
277
- } catch {
278
- console.log(`\n Installation failed. Run manually:\n ${cmd}\n`);
279
- }
280
- }
281
- if (devDeps.size > 0) {
282
- const deps = Array.from(devDeps);
283
- const cmd = `${pm} add -D ${deps.join(" ")}`;
284
- console.log(`\n Installing ${deps.length} dev dependency(ies):`);
285
- for (const dep of deps) console.log(` + ${dep} (dev)`);
286
- console.log();
287
- try {
288
- execSync(cmd, { stdio: "inherit" });
289
- } catch {
290
- console.log(`\n Installation failed. Run manually:\n ${cmd}\n`);
291
- }
292
- }
293
- console.log(" Done!\n");
294
- });
295
- }
296
- //#endregion
297
- //#region src/commands/init.ts
298
- /** All optional packages available for selection */
299
- const OPTIONAL_PACKAGES = [
300
- {
301
- value: "auth",
302
- label: "Auth",
303
- hint: "JWT, OAuth, API keys"
304
- },
305
- {
306
- value: "swagger",
307
- label: "Swagger",
308
- hint: "OpenAPI docs"
309
- },
310
- {
311
- value: "ws",
312
- label: "WebSocket",
313
- hint: "rooms, heartbeat"
314
- },
315
- {
316
- value: "queue",
317
- label: "Queue",
318
- hint: "BullMQ/RabbitMQ/Kafka"
319
- },
320
- {
321
- value: "devtools",
322
- label: "DevTools",
323
- hint: "debug dashboard"
324
- }
325
- ];
326
- function registerInitCommand(program) {
327
- program.command("new [name]").alias("init").description("Create a new KickJS project (use \".\" for current directory)").option("-d, --directory <dir>", "Target directory (defaults to project name)").option("--pm <manager>", "Package manager: pnpm | npm | yarn | bun").option("--git", "Initialize git repository").option("--no-git", "Skip git initialization").option("--install", "Install dependencies after scaffolding").option("--no-install", "Skip dependency installation").option("-f, --force", "Remove existing files without prompting").option("-t, --template <type>", "Project template: rest | ddd | cqrs | minimal").option("-r, --repo <type>", "Default repository: prisma | drizzle | inmemory | custom").option("--packages <packages>", "Comma-separated packages to include (e.g. auth,swagger,ws,queue)").option("-y, --yes", "Pick safe defaults for every prompt (template=minimal, repo=inmemory, no extras, git+install on)").option("--non-interactive", "alias for --yes").action(async (name, opts) => {
328
- intro("KickJS — Create a new project");
329
- const yes = Boolean(opts.yes || opts.nonInteractive);
330
- if (!name) if (yes) name = "my-api";
331
- else name = await text({
332
- message: "Project name",
333
- placeholder: "my-api",
334
- defaultValue: "my-api"
335
- });
336
- let directory;
337
- if (name === ".") {
338
- directory = resolve(".");
339
- name = basename(directory);
340
- } else directory = resolve(opts.directory || name);
341
- if (existsSync(directory)) {
342
- const entries = readdirSync(directory);
343
- if (entries.length > 0) {
344
- if (opts.force) log.warn(`Clearing existing files in ${directory}`);
345
- else if (yes) {
346
- log.warn(`Directory "${name}" is not empty. Pass --force to clear it.`);
347
- outro("Aborted.");
348
- return;
349
- } else {
350
- log.warn(`Directory "${name}" is not empty:`);
351
- const shown = entries.slice(0, 5);
352
- for (const entry of shown) log.message(` - ${entry}`);
353
- if (entries.length > 5) log.message(` ... and ${entries.length - 5} more`);
354
- if (!await confirm({
355
- message: pc.red("Remove all existing files and proceed?"),
356
- initialValue: false
357
- })) {
358
- outro("Aborted.");
359
- return;
360
- }
361
- }
362
- for (const entry of entries) rmSync(resolve(directory, entry), {
363
- recursive: true,
364
- force: true
365
- });
366
- }
367
- }
368
- let template = opts.template;
369
- if (!template) if (yes) template = "minimal";
370
- else template = await select({
371
- message: "Project template",
372
- options: [
373
- {
374
- value: "rest",
375
- label: "REST API",
376
- hint: "Express + Swagger"
377
- },
378
- {
379
- value: "ddd",
380
- label: "DDD",
381
- hint: "Domain-Driven Design modules"
382
- },
383
- {
384
- value: "cqrs",
385
- label: "CQRS",
386
- hint: "Commands, Queries, Events + WS/Queue"
387
- },
388
- {
389
- value: "minimal",
390
- label: "Minimal",
391
- hint: "bare Express"
392
- }
393
- ]
394
- });
395
- let packageManager = opts.pm;
396
- if (!packageManager) if (yes) packageManager = await resolvePackageManager(void 0);
397
- else packageManager = await select({
398
- message: "Package manager",
399
- options: [
400
- {
401
- value: "pnpm",
402
- label: "pnpm"
403
- },
404
- {
405
- value: "npm",
406
- label: "npm"
407
- },
408
- {
409
- value: "yarn",
410
- label: "yarn"
411
- },
412
- {
413
- value: "bun",
414
- label: "bun"
415
- }
416
- ]
417
- });
418
- let defaultRepo = opts.repo;
419
- if (!defaultRepo) if (yes) defaultRepo = "inmemory";
420
- else {
421
- defaultRepo = await select({
422
- message: "Default repository/ORM",
423
- options: [
424
- {
425
- value: "prisma",
426
- label: "Prisma"
427
- },
428
- {
429
- value: "drizzle",
430
- label: "Drizzle"
431
- },
432
- {
433
- value: "inmemory",
434
- label: "In-Memory"
435
- },
436
- {
437
- value: "custom",
438
- label: "Custom",
439
- hint: "specify later"
440
- }
441
- ]
442
- });
443
- if (defaultRepo === "custom") defaultRepo = await text({
444
- message: "Custom repository name",
445
- defaultValue: "custom"
446
- });
447
- }
448
- let selectedPackages;
449
- if (opts.packages !== void 0) {
450
- const raw = opts.packages.trim().toLowerCase();
451
- if (raw === "" || raw === "none" || raw === "false") selectedPackages = [];
452
- else selectedPackages = opts.packages.split(",").map((p) => p.trim()).filter(Boolean);
453
- } else if (yes) selectedPackages = [];
454
- else selectedPackages = await multiSelect({
455
- message: "Select packages to include",
456
- options: [...OPTIONAL_PACKAGES],
457
- required: false
458
- });
459
- let initGit;
460
- if (opts.git === void 0) initGit = yes ? true : await confirm({
461
- message: "Initialize git repository?",
462
- initialValue: true
463
- });
464
- else initGit = opts.git;
465
- let installDeps;
466
- if (opts.install === void 0) installDeps = yes ? true : await confirm({
467
- message: "Install dependencies?",
468
- initialValue: true
469
- });
470
- else installDeps = opts.install;
471
- await initProject({
472
- name,
473
- directory,
474
- packageManager,
475
- initGit,
476
- installDeps,
477
- template,
478
- defaultRepo,
479
- packages: selectedPackages
480
- });
481
- outro(`Done! Next steps: ${pc.cyan(`cd ${name} && ${packageManager} dev`)}`);
482
- });
483
- }
484
- //#endregion
485
- //#region src/generators/plugin.ts
486
- /**
487
- * Scaffold a `definePlugin()` factory under `src/plugins/<name>.plugin.ts`.
488
- *
489
- * v4 standardised on the `definePlugin()` factory pattern (architecture
490
- * §21.2.2) — same surface as `defineAdapter()`, so adopters learn one
491
- * mental model. The generated template uses the factory shape with a
492
- * typed config object, defaults block, and a build function returning
493
- * the underlying KickPlugin hooks.
494
- */
495
- async function generatePlugin(options) {
496
- const { name, outDir } = options;
497
- const kebab = toKebabCase(name);
498
- const pascal = toPascalCase(name);
499
- const files = [];
500
- const filePath = join(outDir, `${kebab}.plugin.ts`);
501
- await writeFileSafe(filePath, `import {
502
- definePlugin,
503
- type AppAdapter,
504
- type AppModuleClass,
505
- type Container,
506
- type ContributorRegistrations,
507
- } from '@forinda/kickjs'
508
-
509
- /**
510
- * Configuration for the ${pascal} plugin.
511
- *
512
- * Plugins typically take a small config object so callers can tune
513
- * behaviour at bootstrap time. Keep the shape narrow — anything
514
- * derived from the environment should be read inside the build
515
- * function via getEnv(), not forced onto the caller.
516
- */
517
- export interface ${pascal}PluginConfig {
518
- // Add your plugin config here, e.g.:
519
- // enabled?: boolean
520
- // apiKey?: string
521
- }
522
-
523
- /**
524
- * ${pascal} plugin — built via \`definePlugin()\` so callers get the
525
- * factory's call / \`.scoped()\` / \`.async()\` surfaces for free.
526
- *
527
- * A plugin bundles DI bindings, modules, adapters, and middleware
528
- * into one object that can be added to \`bootstrap({ plugins })\`.
529
- *
530
- * Lifecycle order (each hook is optional — delete the ones you don't
531
- * need and keep only the surface your plugin actually uses):
532
- *
533
- * 1. \`register(container)\` — runs before user modules load. Use
534
- * it to bind services that modules depend on.
535
- * 2. \`modules()\` — plugin modules load before user modules.
536
- * 3. \`adapters()\` — plugin adapters mount before user adapters.
537
- * 4. \`middleware()\` — plugin middleware runs before user middleware.
538
- * 5. \`contributors()\` — Context Contributors merged into every route.
539
- * 6. \`onReady(container)\` — runs after the app has fully bootstrapped.
540
- * 7. \`shutdown()\` — runs on graceful shutdown.
541
- *
542
- * @example
543
- * \`\`\`ts
544
- * import { bootstrap } from '@forinda/kickjs'
545
- * import { ${pascal}Plugin } from './plugins/${kebab}.plugin'
546
- *
547
- * export const app = await bootstrap({
548
- * modules,
549
- * plugins: [${pascal}Plugin({ /* config overrides *\\/ })],
550
- * })
551
- * \`\`\`
552
- */
553
- export const ${pascal}Plugin = definePlugin<${pascal}PluginConfig>({
554
- name: '${pascal}Plugin',
555
- defaults: {
556
- // Default config values go here
557
- },
558
- build: (_config, { name: _name }) => ({
559
- /**
560
- * Register DI bindings before modules load.
561
- * Use \`container.registerInstance(TOKEN, value)\` for singletons
562
- * and \`container.registerFactory(TOKEN, () => ...)\` for lazy
563
- * constructions.
564
- */
565
- register(_container: Container): void {
566
- // Example: _container.registerInstance(MY_TOKEN, new MyService(_config))
567
- },
568
-
569
- /**
570
- * Return module classes this plugin contributes to the app.
571
- * These load before user modules, so plugin controllers and
572
- * services are available for user code to \`@Autowired\`.
573
- */
574
- modules(): AppModuleClass[] {
575
- return [
576
- // ExampleModule,
577
- ]
578
- },
579
-
580
- /**
581
- * Return adapter instances to be added to the application.
582
- * Plugin adapters mount before user adapters.
583
- */
584
- adapters(): AppAdapter[] {
585
- return [
586
- // MyAdapter({ ... }),
587
- ]
588
- },
589
-
590
- /**
591
- * Return Express middleware entries to be added to the global
592
- * pipeline. Plugin middleware runs before user-defined middleware.
593
- */
594
- middleware(): unknown[] {
595
- return [
596
- // helmet(),
597
- // myCustomMiddleware(_config),
598
- ]
599
- },
600
-
601
- /**
602
- * Return Context Contributors to merge into every route's pipeline.
603
- * Plugins contribute at the same \`'adapter'\` precedence level as
604
- * adapters — overrideable per-route at the method / class / module
605
- * level. See https://forinda.github.io/kick-js/guide/context-decorators
606
- *
607
- * Delete this hook if your plugin doesn't ship typed per-request values.
608
- */
609
- contributors(): ContributorRegistrations {
610
- return [
611
- // Example:
612
- // import { defineHttpContextDecorator } from '@forinda/kickjs'
613
- // declare module '@forinda/kickjs' { interface ContextMeta { ${kebab}: { foo: string } } }
614
- // const Load${pascal} = defineHttpContextDecorator({
615
- // key: '${kebab}',
616
- // resolve: (ctx) => ({ foo: ctx.req.headers['x-${kebab}'] as string }),
617
- // })
618
- // return [Load${pascal}.registration]
619
- ]
620
- },
621
-
622
- /**
623
- * Called after the application has fully bootstrapped. Use this
624
- * for post-startup work like logging, health checks, or warming
625
- * a cache. Runs once per process.
626
- */
627
- async onReady(_container: Container): Promise<void> {
628
- // const log = _container.resolve(Logger)
629
- // log.info('${pascal} plugin ready')
630
- },
631
-
632
- /**
633
- * Called during graceful shutdown. Clean up any long-lived
634
- * resources this plugin owns (connections, timers, subscriptions).
635
- */
636
- async shutdown(): Promise<void> {
637
- // Example: await this.connection?.close()
638
- },
639
- }),
640
- })
641
- `);
642
- files.push(filePath);
643
- return files;
644
- }
645
- //#endregion
646
- //#region src/generators/config.ts
647
- async function generateConfig(options) {
648
- const filePath = join(options.outDir, "kick.config.ts");
649
- const modulesDir = options.modulesDir ?? "src/modules";
650
- const defaultRepo = options.defaultRepo ?? "inmemory";
651
- if (existsSync(filePath) && !options.force) {
652
- if (!await confirm({
653
- message: "kick.config.ts already exists. Overwrite?",
654
- initialValue: false
655
- })) {
656
- console.log("\n Skipped — existing kick.config.ts preserved.");
657
- return [];
658
- }
659
- }
660
- await writeFileSafe(filePath, `import { defineConfig } from '@forinda/kickjs-cli'
661
-
662
- export default defineConfig({
663
- modules: {
664
- dir: '${modulesDir}',
665
- repo: '${defaultRepo}',
666
- pluralize: true,
667
- },
668
-
669
- typegen: {
670
- schemaValidator: 'zod',
671
- },
672
-
673
- commands: [
674
- {
675
- name: 'test',
676
- description: 'Run tests with Vitest',
677
- steps: 'npx vitest run',
678
- },
679
- {
680
- name: 'format',
681
- description: 'Format code with Prettier',
682
- steps: 'npx prettier --write src/',
683
- },
684
- {
685
- name: 'format:check',
686
- description: 'Check formatting without writing',
687
- steps: 'npx prettier --check src/',
688
- },
689
- {
690
- name: 'ci:check',
691
- description: 'Run typecheck + format check',
692
- steps: ['npx tsc --noEmit', 'npx prettier --check src/'],
693
- aliases: ['verify'],
694
- },
695
- ],
696
- })
697
- `);
698
- return [filePath];
699
- }
700
- //#endregion
701
- //#region src/generators/agent-docs.ts
702
- const VALID_TEMPLATES = new Set([
703
- "rest",
704
- "ddd",
705
- "cqrs",
706
- "minimal"
707
- ]);
708
- function detectName(outDir, override) {
709
- if (override) return override;
710
- try {
711
- const pkg = JSON.parse(readFileSync(join(outDir, "package.json"), "utf-8"));
712
- if (pkg.name) return pkg.name.replace(/^@[^/]+\//, "");
713
- } catch {}
714
- return outDir.split("/").findLast(Boolean) ?? "app";
715
- }
716
- function detectPm(outDir, override) {
717
- if (override) return override;
718
- try {
719
- const pkg = JSON.parse(readFileSync(join(outDir, "package.json"), "utf-8"));
720
- if (pkg.packageManager) return pkg.packageManager.split("@")[0];
721
- } catch {}
722
- return "pnpm";
723
- }
724
- async function detectTemplate(outDir, override) {
725
- if (override) return override;
726
- try {
727
- const pattern = (await loadKickConfig(outDir))?.pattern;
728
- if (pattern && VALID_TEMPLATES.has(pattern)) return pattern;
729
- } catch {}
730
- return "ddd";
731
- }
732
- async function generateAgentDocs(options) {
733
- const only = options.only ?? "all";
734
- const name = detectName(options.outDir, options.name);
735
- const pm = detectPm(options.outDir, options.pm);
736
- const template = await detectTemplate(options.outDir, options.template);
737
- const wantsAgents = only === "agents" || only === "both" || only === "all";
738
- const wantsClaude = only === "claude" || only === "both" || only === "all";
739
- const wantsSkills = only === "skills" || only === "all";
740
- const targets = [];
741
- if (wantsAgents) targets.push({
742
- file: join(options.outDir, "AGENTS.md"),
743
- render: () => generateAgents(name, template, pm)
744
- });
745
- if (wantsClaude) targets.push({
746
- file: join(options.outDir, "CLAUDE.md"),
747
- render: () => generateClaude(name, template, pm)
748
- });
749
- if (wantsSkills) targets.push({
750
- file: join(options.outDir, "kickjs-skills.md"),
751
- render: () => generateKickJsSkills(name, template, pm)
752
- });
753
- const written = [];
754
- for (const { file, render } of targets) {
755
- if (existsSync(file) && !options.force) {
756
- if (!await confirm({
757
- message: `${file.replace(options.outDir + "/", "")} already exists. Overwrite?`,
758
- initialValue: false
759
- })) {
760
- console.log(` Skipped — existing ${file.replace(options.outDir + "/", "")} preserved.`);
761
- continue;
762
- }
763
- }
764
- await writeFileSafe(file, render());
765
- written.push(file);
766
- }
767
- return written;
768
- }
769
- //#endregion
770
- //#region src/generators/auth-scaffold.ts
771
- /**
772
- * Generate a complete auth module with registration, login, logout,
773
- * and password hashing. Uses PasswordService and the configured strategy.
774
- */
775
- async function generateAuthScaffold(options = {}) {
776
- const strategy = options.strategy ?? "jwt";
777
- const outDir = options.outDir ?? "src/modules/auth";
778
- const dtoDir = join(outDir, "dto");
779
- const files = [];
780
- const modulePath = join(outDir, "auth.module.ts");
781
- await writeFileSafe(modulePath, `import { Module } from '@forinda/kickjs'
782
- import { AuthController } from './auth.controller'
783
- import { AuthService } from './auth.service'
784
-
785
- @Module({
786
- controllers: [AuthController],
787
- services: [AuthService],
788
- })
789
- export class AuthModule {}
790
- `);
791
- files.push(modulePath);
792
- const controllerPath = join(outDir, "auth.controller.ts");
793
- await writeFileSafe(controllerPath, strategy === "jwt" ? jwtControllerTemplate() : sessionControllerTemplate());
794
- files.push(controllerPath);
795
- const servicePath = join(outDir, "auth.service.ts");
796
- await writeFileSafe(servicePath, strategy === "jwt" ? jwtServiceTemplate() : sessionServiceTemplate());
797
- files.push(servicePath);
798
- const registerDtoPath = join(dtoDir, "register.dto.ts");
799
- await writeFileSafe(registerDtoPath, `import { z } from 'zod'
800
-
801
- export const RegisterDto = z.object({
802
- email: z.string().email(),
803
- password: z.string().min(8),
804
- name: z.string().min(1).optional(),
805
- })
806
-
807
- export type RegisterInput = z.infer<typeof RegisterDto>
808
- `);
809
- files.push(registerDtoPath);
810
- const loginDtoPath = join(dtoDir, "login.dto.ts");
811
- await writeFileSafe(loginDtoPath, `import { z } from 'zod'
812
-
813
- export const LoginDto = z.object({
814
- email: z.string().email(),
815
- password: z.string().min(1),
816
- })
817
-
818
- export type LoginInput = z.infer<typeof LoginDto>
819
- `);
820
- files.push(loginDtoPath);
821
- const testPath = join(outDir, "auth.test.ts");
822
- await writeFileSafe(testPath, `import { describe, it, expect } from 'vitest'
823
-
824
- describe('Auth Module', () => {
825
- it.todo('POST /register — creates a new user')
826
- it.todo('POST /login — returns token for valid credentials')
827
- it.todo('POST /login — rejects invalid credentials')
828
- it.todo('POST /logout — invalidates session/token')
829
- it.todo('GET /me — returns authenticated user')
830
- })
831
- `);
832
- files.push(testPath);
833
- if (options.roleGuards !== false) {
834
- const guardPath = join(outDir, "auth.guard.ts");
835
- await writeFileSafe(guardPath, `import { Roles } from '@forinda/kickjs-auth'
836
-
837
- /**
838
- * Role-based access guard.
839
- * Usage: @Roles('admin') on a controller method.
840
- *
841
- * The AuthAdapter extracts the user's roles from the JWT/session
842
- * and the framework checks them automatically.
843
- */
844
- export const AdminOnly = Roles('admin')
845
- export const ManagerOnly = Roles('manager')
846
- `);
847
- files.push(guardPath);
848
- }
849
- return files;
850
- }
851
- function jwtControllerTemplate() {
852
- return `import { Controller, Post, Get } from '@forinda/kickjs'
853
- import { Authenticated, Public } from '@forinda/kickjs-auth'
854
- import type { RequestContext } from '@forinda/kickjs'
855
- import { Autowired } from '@forinda/kickjs'
856
- import { AuthService } from './auth.service'
857
-
858
- @Controller()
859
- @Authenticated()
860
- export class AuthController {
861
- @Autowired() private authService!: AuthService
862
-
863
- @Post('/register')
864
- @Public()
865
- async register(ctx: RequestContext) {
866
- const result = await this.authService.register(ctx.body)
867
- return ctx.created(result)
868
- }
869
-
870
- @Post('/login')
871
- @Public()
872
- async login(ctx: RequestContext) {
873
- const result = await this.authService.login(ctx.body)
874
- if (!result) return ctx.badRequest('Invalid credentials')
875
- return ctx.json(result)
876
- }
877
-
878
- @Post('/logout')
879
- async logout(ctx: RequestContext) {
880
- return ctx.json({ message: 'Logged out' })
881
- }
882
-
883
- @Get('/me')
884
- async me(ctx: RequestContext) {
885
- return ctx.json({ user: ctx.user })
886
- }
887
- }
888
- `;
889
- }
890
- function jwtServiceTemplate() {
891
- return `import { Service, Autowired } from '@forinda/kickjs'
892
- import { PasswordService } from '@forinda/kickjs-auth'
893
- import type { RegisterInput } from './dto/register.dto'
894
- import type { LoginInput } from './dto/login.dto'
895
-
896
- // TODO: Replace with your User repository
897
- const users = new Map<string, { id: string; email: string; name?: string; passwordHash: string }>()
898
-
899
- @Service()
900
- export class AuthService {
901
- @Autowired() private password!: PasswordService
902
-
903
- async register(input: RegisterInput) {
904
- const { email, password, name } = input
905
-
906
- if (users.has(email)) {
907
- throw new Error('User already exists')
908
- }
909
-
910
- const passwordHash = await this.password.hash(password)
911
- const id = crypto.randomUUID()
912
- users.set(email, { id, email, name, passwordHash })
913
-
914
- return { id, email, name }
915
- }
916
-
917
- async login(input: LoginInput) {
918
- const { email, password } = input
919
- const user = users.get(email)
920
- if (!user) return null
921
-
922
- const valid = await this.password.verify(user.passwordHash, password)
923
- if (!valid) return null
924
-
925
- // TODO: Generate JWT token here
926
- // const token = jwt.sign({ sub: user.id, email: user.email }, process.env.JWT_SECRET!)
927
- return { user: { id: user.id, email: user.email, name: user.name } }
928
- }
929
- }
930
- `;
931
- }
932
- function sessionControllerTemplate() {
933
- return `import { Controller, Post, Get } from '@forinda/kickjs'
934
- import { Authenticated, Public } from '@forinda/kickjs-auth'
935
- import { sessionLogin, sessionLogout } from '@forinda/kickjs-auth'
936
- import type { RequestContext } from '@forinda/kickjs'
937
- import { Autowired } from '@forinda/kickjs'
938
- import { AuthService } from './auth.service'
939
-
940
- @Controller()
941
- @Authenticated()
942
- export class AuthController {
943
- @Autowired() private authService!: AuthService
944
-
945
- @Post('/register')
946
- @Public()
947
- async register(ctx: RequestContext) {
948
- const result = await this.authService.register(ctx.body)
949
- return ctx.created(result)
950
- }
951
-
952
- @Post('/login')
953
- @Public()
954
- async login(ctx: RequestContext) {
955
- const user = await this.authService.login(ctx.body)
956
- if (!user) return ctx.badRequest('Invalid credentials')
957
- await sessionLogin(ctx.session, user)
958
- return ctx.json({ message: 'Logged in', user })
959
- }
960
-
961
- @Post('/logout')
962
- async logout(ctx: RequestContext) {
963
- await sessionLogout(ctx.session)
964
- return ctx.json({ message: 'Logged out' })
965
- }
966
-
967
- @Get('/me')
968
- async me(ctx: RequestContext) {
969
- return ctx.json({ user: ctx.user })
970
- }
971
- }
972
- `;
973
- }
974
- function sessionServiceTemplate() {
975
- return `import { Service, Autowired } from '@forinda/kickjs'
976
- import { PasswordService } from '@forinda/kickjs-auth'
977
- import type { RegisterInput } from './dto/register.dto'
978
- import type { LoginInput } from './dto/login.dto'
979
-
980
- // TODO: Replace with your User repository
981
- const users = new Map<string, { id: string; email: string; name?: string; passwordHash: string }>()
982
-
983
- @Service()
984
- export class AuthService {
985
- @Autowired() private password!: PasswordService
986
-
987
- async register(input: RegisterInput) {
988
- const { email, password, name } = input
989
-
990
- if (users.has(email)) {
991
- throw new Error('User already exists')
992
- }
993
-
994
- const passwordHash = await this.password.hash(password)
995
- const id = crypto.randomUUID()
996
- users.set(email, { id, email, name, passwordHash })
997
-
998
- return { id, email, name }
999
- }
1000
-
1001
- async login(input: LoginInput) {
1002
- const { email, password } = input
1003
- const user = users.get(email)
1004
- if (!user) return null
1005
-
1006
- const valid = await this.password.verify(user.passwordHash, password)
1007
- if (!valid) return null
1008
-
1009
- return { id: user.id, email: user.email, name: user.name }
1010
- }
1011
- }
1012
- `;
1013
- }
1014
- //#endregion
1015
- //#region src/generators/job.ts
1016
- async function generateJob(options) {
1017
- const { name, outDir } = options;
1018
- const pascal = toPascalCase(name);
1019
- const kebab = toKebabCase(name);
1020
- const camel = toCamelCase(name);
1021
- const queueName = options.queue ?? `${kebab}-queue`;
1022
- const files = [];
1023
- const write = async (relativePath, content) => {
1024
- const fullPath = join(outDir, relativePath);
1025
- await writeFileSafe(fullPath, content);
1026
- files.push(fullPath);
1027
- };
1028
- await write(`${kebab}.job.ts`, `import { Inject } from '@forinda/kickjs'
1029
- import { Job, Process, QUEUE_MANAGER, type QueueService } from '@forinda/kickjs-queue'
1030
-
1031
- /**
1032
- * ${pascal} Job Processor
1033
- *
1034
- * Decorators:
1035
- * @Job(queueName) — marks this class as a job processor for a queue
1036
- * @Process(jobName?) — marks a method as the handler for a specific job type
1037
- * - Without a name: handles all jobs in the queue
1038
- * - With a name: handles only jobs matching that name
1039
- *
1040
- * To add jobs to this queue from a service or controller:
1041
- * @Inject(QUEUE_MANAGER) private queue: QueueService
1042
- * await this.queue.add('${queueName}', '${camel}', { ... })
1043
- */
1044
- @Job('${queueName}')
1045
- export class ${pascal}Job {
1046
- @Process()
1047
- async handle(job: { name: string; data: any; id?: string }) {
1048
- console.log(\`Processing \${job.name} (id: \${job.id})\`, job.data)
1049
-
1050
- // TODO: Implement job logic here
1051
- // Example:
1052
- // await this.emailService.send(job.data.to, job.data.subject, job.data.body)
1053
- }
1054
-
1055
- @Process('${camel}.priority')
1056
- async handlePriority(job: { name: string; data: any; id?: string }) {
1057
- console.log(\`Priority job: \${job.name}\`, job.data)
1058
- // Handle high-priority variant of this job
1059
- }
1060
- }
1061
- `);
1062
- return files;
1063
- }
1064
- //#endregion
1065
- //#region src/generators/scaffold.ts
1066
- /**
1067
- * Supported field types and their mappings:
1068
- * string → z.string()
1069
- * text → z.string() (alias, hints at longer content)
1070
- * number → z.number()
1071
- * int → z.number().int()
1072
- * float → z.number()
1073
- * boolean → z.boolean()
1074
- * date → z.string().datetime()
1075
- * email → z.string().email()
1076
- * url → z.string().url()
1077
- * uuid → z.string().uuid()
1078
- * json → z.any()
1079
- * enum:a,b → z.enum(['a','b'])
1080
- *
1081
- * Mark optional fields — three equivalent syntaxes:
1082
- * body:text:optional ← recommended (shell-safe, no quoting needed)
1083
- * body?:text ← needs quoting in bash/zsh ("body?:text")
1084
- * body:text? ← needs quoting in bash/zsh ("body:text?")
1085
- */
1086
- const TYPE_MAP = {
1087
- string: {
1088
- ts: "string",
1089
- zod: "z.string()"
1090
- },
1091
- text: {
1092
- ts: "string",
1093
- zod: "z.string()"
1094
- },
1095
- number: {
1096
- ts: "number",
1097
- zod: "z.number()"
1098
- },
1099
- int: {
1100
- ts: "number",
1101
- zod: "z.number().int()"
1102
- },
1103
- float: {
1104
- ts: "number",
1105
- zod: "z.number()"
1106
- },
1107
- boolean: {
1108
- ts: "boolean",
1109
- zod: "z.boolean()"
1110
- },
1111
- date: {
1112
- ts: "string",
1113
- zod: "z.string().datetime()"
1114
- },
1115
- email: {
1116
- ts: "string",
1117
- zod: "z.string().email()"
1118
- },
1119
- url: {
1120
- ts: "string",
1121
- zod: "z.string().url()"
1122
- },
1123
- uuid: {
1124
- ts: "string",
1125
- zod: "z.string().uuid()"
1126
- },
1127
- json: {
1128
- ts: "any",
1129
- zod: "z.any()"
1130
- }
1131
- };
1132
- function parseFields(raw) {
1133
- return raw.map((f) => {
1134
- const colonIdx = f.indexOf(":");
1135
- if (colonIdx === -1) throw new Error(`Invalid field: "${f}". Use format: name:type (e.g. title:string)`);
1136
- let namePart = f.slice(0, colonIdx);
1137
- let typePart = f.slice(colonIdx + 1);
1138
- if (!namePart || !typePart) throw new Error(`Invalid field: "${f}". Use format: name:type (e.g. title:string)`);
1139
- let optional = false;
1140
- if (typePart.endsWith(":optional")) {
1141
- typePart = typePart.slice(0, -9);
1142
- optional = true;
1143
- }
1144
- if (namePart.endsWith("?")) {
1145
- namePart = namePart.slice(0, -1);
1146
- optional = true;
1147
- }
1148
- if (typePart.endsWith("?")) {
1149
- typePart = typePart.slice(0, -1);
1150
- optional = true;
1151
- }
1152
- const cleanType = typePart;
1153
- if (cleanType.startsWith("enum:")) {
1154
- const values = cleanType.slice(5).split(",");
1155
- return {
1156
- name: namePart,
1157
- type: "enum",
1158
- tsType: values.map((v) => `'${v}'`).join(" | "),
1159
- zodType: `z.enum([${values.map((v) => `'${v}'`).join(", ")}])`,
1160
- optional
1161
- };
1162
- }
1163
- const mapped = TYPE_MAP[cleanType];
1164
- if (!mapped) {
1165
- const validTypes = [...Object.keys(TYPE_MAP), "enum:a,b,c"].join(", ");
1166
- throw new Error(`Unknown field type: "${cleanType}". Valid types: ${validTypes}`);
1167
- }
1168
- return {
1169
- name: namePart,
1170
- type: cleanType,
1171
- tsType: mapped.ts,
1172
- zodType: mapped.zod,
1173
- optional
1174
- };
1175
- });
1176
- }
1177
- async function generateScaffold(options) {
1178
- const { name, fields, modulesDir, noEntity, noTests: _noTests, repo = "inmemory", tokenScope = "app" } = options;
1179
- const shouldPluralize = options.pluralize !== false;
1180
- const kebab = toKebabCase(name);
1181
- const pascal = toPascalCase(name);
1182
- toCamelCase(name);
1183
- const plural = shouldPluralize ? pluralize(kebab) : kebab;
1184
- const pluralPascal = shouldPluralize ? pluralizePascal(pascal) : pascal;
1185
- const moduleDir = join(modulesDir, plural);
1186
- const files = [];
1187
- const write = async (relativePath, content) => {
1188
- const fullPath = join(moduleDir, relativePath);
1189
- await writeFileSafe(fullPath, content);
1190
- files.push(fullPath);
1191
- };
1192
- await write(`${kebab}.module.ts`, genModuleIndex(pascal, kebab, plural));
1193
- await write("constants.ts", genConstants(pascal, fields));
1194
- await write(`presentation/${kebab}.controller.ts`, genController(pascal, kebab, plural, pluralPascal));
1195
- await write(`application/dtos/create-${kebab}.dto.ts`, genCreateDTO(pascal, fields));
1196
- await write(`application/dtos/update-${kebab}.dto.ts`, genUpdateDTO(pascal, fields));
1197
- await write(`application/dtos/${kebab}-response.dto.ts`, genResponseDTO(pascal, fields));
1198
- const useCases = genUseCases(pascal, kebab, plural, pluralPascal);
1199
- for (const uc of useCases) await write(`application/use-cases/${uc.file}`, uc.content);
1200
- await write(`domain/repositories/${kebab}.repository.ts`, genRepositoryInterface(pascal, kebab, tokenScope));
1201
- await write(`domain/services/${kebab}-domain.service.ts`, genDomainService(pascal, kebab));
1202
- if (repo === "inmemory") await write(`infrastructure/repositories/in-memory-${kebab}.repository.ts`, genInMemoryRepository(pascal, kebab, fields));
1203
- if (!noEntity) {
1204
- await write(`domain/entities/${kebab}.entity.ts`, genEntity(pascal, kebab, fields));
1205
- await write(`domain/value-objects/${kebab}-id.vo.ts`, genValueObject(pascal));
1206
- }
1207
- await autoRegisterModule(modulesDir, pascal, plural, kebab);
1208
- return files;
1209
- }
1210
- function genCreateDTO(pascal, fields) {
1211
- return `import { z } from 'zod'
1212
-
1213
- export const create${pascal}Schema = z.object({
1214
- ${fields.map((f) => {
1215
- const base = f.zodType;
1216
- return ` ${f.name}: ${base}${f.optional ? ".optional()" : ""},`;
1217
- }).join("\n")}
1218
- })
1219
-
1220
- export type Create${pascal}DTO = z.infer<typeof create${pascal}Schema>
1221
- `;
1222
- }
1223
- function genUpdateDTO(pascal, fields) {
1224
- return `import { z } from 'zod'
1225
-
1226
- export const update${pascal}Schema = z.object({
1227
- ${fields.map((f) => ` ${f.name}: ${f.zodType}.optional(),`).join("\n")}
1228
- })
1229
-
1230
- export type Update${pascal}DTO = z.infer<typeof update${pascal}Schema>
1231
- `;
1232
- }
1233
- function genResponseDTO(pascal, fields) {
1234
- return `export interface ${pascal}ResponseDTO {
1235
- id: string
1236
- ${fields.map((f) => ` ${f.name}${f.optional ? "?" : ""}: ${f.tsType}`).join("\n")}
1237
- createdAt: string
1238
- updatedAt: string
1239
- }
1240
- `;
1241
- }
1242
- function genConstants(pascal, fields) {
1243
- const stringFields = fields.filter((f) => f.tsType === "string").map((f) => `'${f.name}'`);
1244
- fields.filter((f) => f.tsType === "number").map((f) => `'${f.name}'`);
1245
- const allFieldNames = fields.map((f) => `'${f.name}'`);
1246
- const filterable = [...allFieldNames].join(", ");
1247
- const sortable = [
1248
- ...allFieldNames,
1249
- "'createdAt'",
1250
- "'updatedAt'"
1251
- ].join(", ");
1252
- const searchable = stringFields.length > 0 ? stringFields.join(", ") : "'name'";
1253
- return `import type { ApiQueryParamsConfig } from '@forinda/kickjs'
1254
-
1255
- export const ${pascal.toUpperCase()}_QUERY_CONFIG: ApiQueryParamsConfig = {
1256
- filterable: [${filterable}],
1257
- sortable: [${sortable}],
1258
- searchable: [${searchable}],
1259
- }
1260
- `;
1261
- }
1262
- function genInMemoryRepository(pascal, kebab, fields) {
1263
- return `import { randomUUID } from 'node:crypto'
1264
- import { Repository, HttpException } from '@forinda/kickjs'
1265
- import type { ParsedQuery } from '@forinda/kickjs'
1266
- import type { I${pascal}Repository } from '../../domain/repositories/${kebab}.repository'
1267
- import type { ${pascal}ResponseDTO } from '../../application/dtos/${kebab}-response.dto'
1268
- import type { Create${pascal}DTO } from '../../application/dtos/create-${kebab}.dto'
1269
- import type { Update${pascal}DTO } from '../../application/dtos/update-${kebab}.dto'
1270
-
1271
- @Repository()
1272
- export class InMemory${pascal}Repository implements I${pascal}Repository {
1273
- private store = new Map<string, ${pascal}ResponseDTO>()
1274
-
1275
- async findById(id: string): Promise<${pascal}ResponseDTO | null> {
1276
- return this.store.get(id) ?? null
1277
- }
1278
-
1279
- async findAll(): Promise<${pascal}ResponseDTO[]> {
1280
- return Array.from(this.store.values())
1281
- }
1282
-
1283
- async findPaginated(parsed: ParsedQuery): Promise<{ data: ${pascal}ResponseDTO[]; total: number }> {
1284
- const all = Array.from(this.store.values())
1285
- const data = all.slice(parsed.pagination.offset, parsed.pagination.offset + parsed.pagination.limit)
1286
- return { data, total: all.length }
1287
- }
1288
-
1289
- async create(dto: Create${pascal}DTO): Promise<${pascal}ResponseDTO> {
1290
- const now = new Date().toISOString()
1291
- const entity: ${pascal}ResponseDTO = {
1292
- id: randomUUID(),
1293
- ${fields.map((f) => ` ${f.name}: dto.${f.name},`).join("\n")}
1294
- createdAt: now,
1295
- updatedAt: now,
1296
- }
1297
- this.store.set(entity.id, entity)
1298
- return entity
1299
- }
1300
-
1301
- async update(id: string, dto: Update${pascal}DTO): Promise<${pascal}ResponseDTO> {
1302
- const existing = this.store.get(id)
1303
- if (!existing) throw HttpException.notFound('${pascal} not found')
1304
- const updated = { ...existing, ...dto, updatedAt: new Date().toISOString() }
1305
- this.store.set(id, updated)
1306
- return updated
1307
- }
1308
-
1309
- async delete(id: string): Promise<void> {
1310
- if (!this.store.has(id)) throw HttpException.notFound('${pascal} not found')
1311
- this.store.delete(id)
1312
- }
1313
- }
1314
- `;
1315
- }
1316
- function genEntity(pascal, kebab, fields) {
1317
- return `import { ${pascal}Id } from '../value-objects/${kebab}-id.vo'
1318
-
1319
- interface ${pascal}Props {
1320
- id: ${pascal}Id
1321
- ${fields.map((f) => ` ${f.name}${f.optional ? "?" : ""}: ${f.tsType}`).join("\n")}
1322
- createdAt: Date
1323
- updatedAt: Date
1324
- }
1325
-
1326
- export class ${pascal} {
1327
- private constructor(private props: ${pascal}Props) {}
1328
-
1329
- static create(params: { ${fields.filter((f) => !f.optional).map((f) => `${f.name}: ${f.tsType}`).join("; ")} }): ${pascal} {
1330
- const now = new Date()
1331
- return new ${pascal}({
1332
- id: ${pascal}Id.create(),
1333
- ${fields.filter((f) => !f.optional).map((f) => ` ${f.name}: params.${f.name},`).join("\n")}
1334
- createdAt: now,
1335
- updatedAt: now,
1336
- })
1337
- }
1338
-
1339
- static reconstitute(props: ${pascal}Props): ${pascal} {
1340
- return new ${pascal}(props)
1341
- }
1342
-
1343
- get id(): ${pascal}Id { return this.props.id }
1344
- ${fields.map((f) => ` get ${f.name}(): ${f.tsType}${f.optional ? " | undefined" : ""} {
1345
- return this.props.${f.name}
1346
- }`).join("\n")}
1347
- get createdAt(): Date { return this.props.createdAt }
1348
- get updatedAt(): Date { return this.props.updatedAt }
1349
-
1350
- toJSON() {
1351
- return {
1352
- id: this.props.id.toString(),
1353
- ${fields.map((f) => ` ${f.name}: this.props.${f.name},`).join("\n")}
1354
- createdAt: this.props.createdAt.toISOString(),
1355
- updatedAt: this.props.updatedAt.toISOString(),
1356
- }
1357
- }
1358
- }
1359
- `;
1360
- }
1361
- function genValueObject(pascal) {
1362
- return `import { randomUUID } from 'node:crypto'
1363
-
1364
- export class ${pascal}Id {
1365
- private constructor(private readonly value: string) {}
1366
-
1367
- static create(): ${pascal}Id { return new ${pascal}Id(randomUUID()) }
1368
-
1369
- static from(id: string): ${pascal}Id {
1370
- if (!id || id.trim().length === 0) throw new Error('${pascal}Id cannot be empty')
1371
- return new ${pascal}Id(id)
1372
- }
1373
-
1374
- toString(): string { return this.value }
1375
- equals(other: ${pascal}Id): boolean { return this.value === other.value }
1376
- }
1377
- `;
1378
- }
1379
- function genModuleIndex(pascal, kebab, plural) {
1380
- return `import { type AppModule, type ModuleRoutes, Container, buildRoutes } from '@forinda/kickjs'
1381
- import { ${pascal}Controller } from './presentation/${kebab}.controller'
1382
- import { ${pascal.toUpperCase()}_REPOSITORY } from './domain/repositories/${kebab}.repository'
1383
- import { InMemory${pascal}Repository } from './infrastructure/repositories/in-memory-${kebab}.repository'
1384
-
1385
- // Eagerly load decorated classes so @Service()/@Repository() decorators
1386
- // register in the DI container before the application bootstraps.
1387
- import.meta.glob(
1388
- ['./domain/services/**/*.ts', './application/use-cases/**/*.ts', '!./**/*.test.ts'],
1389
- { eager: true },
1390
- )
1391
-
1392
- export class ${pascal}Module implements AppModule {
1393
- /**
1394
- * Bind the repository token to its concrete implementation.
1395
- * Decorator-managed classes (@Service, @Controller, @Repository) are
1396
- * registered automatically — only token-to-impl bindings need to live here.
1397
- */
1398
- register(container: Container): void {
1399
- container.registerFactory(
1400
- ${pascal.toUpperCase()}_REPOSITORY,
1401
- () => container.resolve(InMemory${pascal}Repository),
1402
- )
1403
- }
1404
-
1405
- routes(): ModuleRoutes {
1406
- return {
1407
- path: '/${plural}',
1408
- router: buildRoutes(${pascal}Controller),
1409
- controller: ${pascal}Controller,
1410
- }
1411
- }
1412
- }
1413
- `;
1414
- }
1415
- function genController(pascal, kebab, plural, pluralPascal) {
1416
- return `import { Controller, Get, Post, Put, Delete, Autowired, ApiQueryParams, type Ctx } from '@forinda/kickjs'
1417
- import { ApiTags } from '@forinda/kickjs-swagger'
1418
- import { Create${pascal}UseCase } from '../application/use-cases/create-${kebab}.use-case'
1419
- import { Get${pascal}UseCase } from '../application/use-cases/get-${kebab}.use-case'
1420
- import { List${pluralPascal}UseCase } from '../application/use-cases/list-${plural}.use-case'
1421
- import { Update${pascal}UseCase } from '../application/use-cases/update-${kebab}.use-case'
1422
- import { Delete${pascal}UseCase } from '../application/use-cases/delete-${kebab}.use-case'
1423
- import { create${pascal}Schema } from '../application/dtos/create-${kebab}.dto'
1424
- import { update${pascal}Schema } from '../application/dtos/update-${kebab}.dto'
1425
- import { ${pascal.toUpperCase()}_QUERY_CONFIG } from '../constants'
1426
-
1427
- // Each handler annotates its \`ctx\` with \`Ctx<KickRoutes.${pascal}Controller['<method>']>\`
1428
- // so \`ctx.params\`, \`ctx.body\`, and \`ctx.query\` are typed end-to-end.
1429
- // The \`KickRoutes\` namespace is generated by \`kick typegen\` (auto-run on
1430
- // \`kick dev\`) — see https://forinda.github.io/kick-js/guide/typegen.
1431
-
1432
- @Controller()
1433
- export class ${pascal}Controller {
1434
- @Autowired() private readonly create${pascal}UseCase!: Create${pascal}UseCase
1435
- @Autowired() private readonly get${pascal}UseCase!: Get${pascal}UseCase
1436
- @Autowired() private readonly list${pluralPascal}UseCase!: List${pluralPascal}UseCase
1437
- @Autowired() private readonly update${pascal}UseCase!: Update${pascal}UseCase
1438
- @Autowired() private readonly delete${pascal}UseCase!: Delete${pascal}UseCase
1439
-
1440
- @Get('/')
1441
- @ApiTags('${pascal}')
1442
- @ApiQueryParams(${pascal.toUpperCase()}_QUERY_CONFIG)
1443
- async list(ctx: Ctx<KickRoutes.${pascal}Controller['list']>) {
1444
- return ctx.paginate(
1445
- (parsed) => this.list${pluralPascal}UseCase.execute(parsed),
1446
- ${pascal.toUpperCase()}_QUERY_CONFIG,
1447
- )
1448
- }
1449
-
1450
- @Get('/:id')
1451
- @ApiTags('${pascal}')
1452
- async getById(ctx: Ctx<KickRoutes.${pascal}Controller['getById']>) {
1453
- const result = await this.get${pascal}UseCase.execute(ctx.params.id)
1454
- if (!result) return ctx.notFound('${pascal} not found')
1455
- ctx.json(result)
1456
- }
1457
-
1458
- @Post('/', { body: create${pascal}Schema, name: 'Create${pascal}' })
1459
- @ApiTags('${pascal}')
1460
- async create(ctx: Ctx<KickRoutes.${pascal}Controller['create']>) {
1461
- const result = await this.create${pascal}UseCase.execute(ctx.body)
1462
- ctx.created(result)
1463
- }
1464
-
1465
- @Put('/:id', { body: update${pascal}Schema, name: 'Update${pascal}' })
1466
- @ApiTags('${pascal}')
1467
- async update(ctx: Ctx<KickRoutes.${pascal}Controller['update']>) {
1468
- const result = await this.update${pascal}UseCase.execute(ctx.params.id, ctx.body)
1469
- ctx.json(result)
1470
- }
1471
-
1472
- @Delete('/:id')
1473
- @ApiTags('${pascal}')
1474
- async remove(ctx: Ctx<KickRoutes.${pascal}Controller['remove']>) {
1475
- await this.delete${pascal}UseCase.execute(ctx.params.id)
1476
- ctx.noContent()
1477
- }
1478
- }
1479
- `;
1480
- }
1481
- function genRepositoryInterface(pascal, kebab, tokenScope) {
1482
- return `import { createToken } from '@forinda/kickjs'
1483
- import type { ${pascal}ResponseDTO } from '../../application/dtos/${kebab}-response.dto'
1484
- import type { Create${pascal}DTO } from '../../application/dtos/create-${kebab}.dto'
1485
- import type { Update${pascal}DTO } from '../../application/dtos/update-${kebab}.dto'
1486
- import type { ParsedQuery } from '@forinda/kickjs'
1487
-
1488
- export interface I${pascal}Repository {
1489
- findById(id: string): Promise<${pascal}ResponseDTO | null>
1490
- findAll(): Promise<${pascal}ResponseDTO[]>
1491
- findPaginated(parsed: ParsedQuery): Promise<{ data: ${pascal}ResponseDTO[]; total: number }>
1492
- create(dto: Create${pascal}DTO): Promise<${pascal}ResponseDTO>
1493
- update(id: string, dto: Update${pascal}DTO): Promise<${pascal}ResponseDTO>
1494
- delete(id: string): Promise<void>
1495
- }
1496
-
1497
- /**
1498
- * Collision-safe DI token bound to \`I${pascal}Repository\`.
1499
- * \`container.resolve(${pascal.toUpperCase()}_REPOSITORY)\` and
1500
- * \`@Inject(${pascal.toUpperCase()}_REPOSITORY)\` both return the typed
1501
- * interface — no manual generic, no \`any\` cast.
1502
- *
1503
- * The \`'${tokenScope}/'\` prefix matches the project scope so
1504
- * \`kick-lint\`'s \`token-reserved-prefix\` rule never fires —
1505
- * adopters must NOT use the reserved \`'kick/'\` namespace.
1506
- */
1507
- export const ${pascal.toUpperCase()}_REPOSITORY = createToken<I${pascal}Repository>('${tokenScope}/${kebab}/repository')
1508
- `;
1509
- }
1510
- function genDomainService(pascal, kebab) {
1511
- return `import { Service, Inject, HttpException } from '@forinda/kickjs'
1512
- import { ${pascal.toUpperCase()}_REPOSITORY, type I${pascal}Repository } from '../repositories/${kebab}.repository'
1513
-
1514
- @Service()
1515
- export class ${pascal}DomainService {
1516
- constructor(
1517
- @Inject(${pascal.toUpperCase()}_REPOSITORY) private readonly repo: I${pascal}Repository,
1518
- ) {}
1519
-
1520
- async ensureExists(id: string): Promise<void> {
1521
- const entity = await this.repo.findById(id)
1522
- if (!entity) throw HttpException.notFound('${pascal} not found')
1523
- }
1524
- }
1525
- `;
1526
- }
1527
- function genUseCases(pascal, kebab, plural, pluralPascal) {
1528
- return [
1529
- {
1530
- file: `create-${kebab}.use-case.ts`,
1531
- content: `import { Service, Inject } from '@forinda/kickjs'
1532
- import { ${pascal.toUpperCase()}_REPOSITORY, type I${pascal}Repository } from '../../domain/repositories/${kebab}.repository'
1533
- import type { Create${pascal}DTO } from '../dtos/create-${kebab}.dto'
1534
-
1535
- @Service()
1536
- export class Create${pascal}UseCase {
1537
- constructor(@Inject(${pascal.toUpperCase()}_REPOSITORY) private repo: I${pascal}Repository) {}
1538
- async execute(dto: Create${pascal}DTO) { return this.repo.create(dto) }
1539
- }
1540
- `
1541
- },
1542
- {
1543
- file: `get-${kebab}.use-case.ts`,
1544
- content: `import { Service, Inject } from '@forinda/kickjs'
1545
- import { ${pascal.toUpperCase()}_REPOSITORY, type I${pascal}Repository } from '../../domain/repositories/${kebab}.repository'
1546
-
1547
- @Service()
1548
- export class Get${pascal}UseCase {
1549
- constructor(@Inject(${pascal.toUpperCase()}_REPOSITORY) private repo: I${pascal}Repository) {}
1550
- async execute(id: string) { return this.repo.findById(id) }
1551
- }
1552
- `
1553
- },
1554
- {
1555
- file: `list-${plural}.use-case.ts`,
1556
- content: `import { Service, Inject } from '@forinda/kickjs'
1557
- import type { ParsedQuery } from '@forinda/kickjs'
1558
- import { ${pascal.toUpperCase()}_REPOSITORY, type I${pascal}Repository } from '../../domain/repositories/${kebab}.repository'
1559
-
1560
- @Service()
1561
- export class List${pluralPascal}UseCase {
1562
- constructor(@Inject(${pascal.toUpperCase()}_REPOSITORY) private repo: I${pascal}Repository) {}
1563
- async execute(parsed: ParsedQuery) { return this.repo.findPaginated(parsed) }
1564
- }
1565
- `
1566
- },
1567
- {
1568
- file: `update-${kebab}.use-case.ts`,
1569
- content: `import { Service, Inject } from '@forinda/kickjs'
1570
- import { ${pascal.toUpperCase()}_REPOSITORY, type I${pascal}Repository } from '../../domain/repositories/${kebab}.repository'
1571
- import type { Update${pascal}DTO } from '../dtos/update-${kebab}.dto'
1572
-
1573
- @Service()
1574
- export class Update${pascal}UseCase {
1575
- constructor(@Inject(${pascal.toUpperCase()}_REPOSITORY) private repo: I${pascal}Repository) {}
1576
- async execute(id: string, dto: Update${pascal}DTO) { return this.repo.update(id, dto) }
1577
- }
1578
- `
1579
- },
1580
- {
1581
- file: `delete-${kebab}.use-case.ts`,
1582
- content: `import { Service, Inject } from '@forinda/kickjs'
1583
- import { ${pascal.toUpperCase()}_REPOSITORY, type I${pascal}Repository } from '../../domain/repositories/${kebab}.repository'
1584
-
1585
- @Service()
1586
- export class Delete${pascal}UseCase {
1587
- constructor(@Inject(${pascal.toUpperCase()}_REPOSITORY) private repo: I${pascal}Repository) {}
1588
- async execute(id: string) { return this.repo.delete(id) }
1589
- }
1590
- `
1591
- }
1592
- ];
1593
- }
1594
- async function autoRegisterModule(modulesDir, pascal, plural, kebab) {
1595
- const indexPath = join(modulesDir, "index.ts");
1596
- const exists = await fileExists(indexPath);
1597
- const importPath = `./${plural}/${kebab}.module`;
1598
- if (!exists) {
1599
- await writeFileSafe(indexPath, `import type { AppModuleClass } from '@forinda/kickjs'\nimport { ${pascal}Module } from '${importPath}'\n\nexport const modules: AppModuleClass[] = [${pascal}Module]\n`);
1600
- return;
1601
- }
1602
- let content = await readFile(indexPath, "utf-8");
1603
- const importLine = `import { ${pascal}Module } from '${importPath}'`;
1604
- if (!content.includes(`${pascal}Module`)) {
1605
- const lastImportIdx = content.lastIndexOf("import ");
1606
- if (lastImportIdx !== -1) {
1607
- const lineEnd = content.indexOf("\n", lastImportIdx);
1608
- content = content.slice(0, lineEnd + 1) + importLine + "\n" + content.slice(lineEnd + 1);
1609
- } else content = importLine + "\n" + content;
1610
- content = content.replace(/(=\s*\[)([\s\S]*?)(])/, (_match, open, existing, close) => {
1611
- const trimmed = existing.trim();
1612
- if (!trimmed) return `${open}${pascal}Module${close}`;
1613
- const needsComma = trimmed.endsWith(",") ? "" : ",";
1614
- return `${open}${existing.trimEnd()}${needsComma} ${pascal}Module${close}`;
1615
- });
1616
- }
1617
- await writeFile(indexPath, content, "utf-8");
1618
- }
1619
- //#endregion
1620
- //#region src/generators/test.ts
1621
- async function generateTest(options) {
1622
- const { name, moduleName, modulesDir } = options;
1623
- const shouldPluralize = options.pluralize ?? true;
1624
- const kebab = toKebabCase(name);
1625
- const pascal = toPascalCase(name);
1626
- const files = [];
1627
- let outDir;
1628
- if (options.outDir) outDir = resolve(options.outDir);
1629
- else if (moduleName) {
1630
- const modKebab = toKebabCase(moduleName);
1631
- const modFolder = shouldPluralize ? pluralize(modKebab) : modKebab;
1632
- outDir = resolve(join(modulesDir ?? "src/modules", modFolder, "__tests__"));
1633
- } else outDir = resolve("src/__tests__");
1634
- const filePath = join(outDir, `${kebab}.test.ts`);
1635
- await writeFileSafe(filePath, `import { describe, it, expect, beforeEach } from 'vitest'
1636
- import { Container } from '@forinda/kickjs'
1637
-
1638
- describe('${pascal}', () => {
1639
- beforeEach(() => {
1640
- Container.reset()
1641
- })
1642
-
1643
- it('should be defined', () => {
1644
- // TODO: Import and test your class/function here
1645
- expect(true).toBe(true)
1646
- })
1647
-
1648
- it('should handle the happy path', async () => {
1649
- // TODO: Set up test data and assertions
1650
- expect(true).toBe(true)
1651
- })
1652
-
1653
- it('should handle edge cases', async () => {
1654
- // TODO: Test error handling, empty inputs, etc.
1655
- expect(true).toBe(true)
1656
- })
1657
- })
1658
- `);
1659
- files.push(filePath);
1660
- return files;
1661
- }
1662
- //#endregion
1663
- //#region src/commands/generate.ts
1664
- const AGENT_DOCS_ONLY_VALUES = [
1665
- "agents",
1666
- "claude",
1667
- "skills",
1668
- "both",
1669
- "all"
1670
- ];
1671
- /** Check if --dry-run was passed on the parent generate command */
1672
- function isDryRun(cmd) {
1673
- return (cmd.parent?.opts())?.dryRun ?? false;
1674
- }
1675
- function printGenerated(files, dryRun = false) {
1676
- const cwd = process.cwd();
1677
- console.log(`\n ${dryRun ? "Would generate" : "Generated"} ${files.length} file${files.length === 1 ? "" : "s"}:`);
1678
- for (const f of files) console.log(` ${f.replace(cwd + "/", "")}`);
1679
- if (dryRun) console.log("\n (dry run — no files were written)");
1680
- console.log();
1681
- }
1682
- /**
1683
- * Refresh `.kickjs/types/*` after a generator that emitted controllers,
1684
- * so the new `Ctx<KickRoutes.X['method']>` references resolve in the
1685
- * user's editor without waiting for `kick dev`.
1686
- *
1687
- * Loads `kick.config.ts` for `typegen.schemaValidator`. Failures are
1688
- * non-fatal — typegen problems should never block code generation.
1689
- */
1690
- async function runPostTypegen(dryRun) {
1691
- if (dryRun) return;
1692
- try {
1693
- const cfg = await loadKickConfig(process.cwd());
1694
- await runTypegen$1({
1695
- cwd: process.cwd(),
1696
- allowDuplicates: true,
1697
- silent: true,
1698
- schemaValidator: cfg?.typegen?.schemaValidator ?? "zod",
1699
- envFile: cfg?.typegen?.envFile,
1700
- srcDir: cfg?.typegen?.srcDir,
1701
- outDir: cfg?.typegen?.outDir
1702
- });
1703
- } catch {}
1704
- }
1705
- const GENERATORS = [
1706
- {
1707
- name: "module <name>",
1708
- description: "Full DDD module (controller, DTOs, use-cases, repo)"
1709
- },
1710
- {
1711
- name: "scaffold <name> <fields...>",
1712
- description: "CRUD module from field definitions"
1713
- },
1714
- {
1715
- name: "controller <name>",
1716
- description: "@Controller() class [-m module]"
1717
- },
1718
- {
1719
- name: "service <name>",
1720
- description: "@Service() singleton [-m module]"
1721
- },
1722
- {
1723
- name: "middleware <name>",
1724
- description: "Express middleware function [-m module]"
1725
- },
1726
- {
1727
- name: "guard <name>",
1728
- description: "Route guard (auth, roles, etc.) [-m module]"
1729
- },
1730
- {
1731
- name: "dto <name>",
1732
- description: "Zod DTO schema [-m module]"
1733
- },
1734
- {
1735
- name: "adapter <name>",
1736
- description: "AppAdapter with lifecycle hooks (app-level only)"
1737
- },
1738
- {
1739
- name: "test <name>",
1740
- description: "Vitest test scaffold [-m module]"
1741
- },
1742
- {
1743
- name: "job <name>",
1744
- description: "Queue @Job processor"
1745
- },
1746
- {
1747
- name: "config",
1748
- description: "Generate kick.config.ts"
1749
- },
1750
- {
1751
- name: "agents",
1752
- description: "Regenerate AGENTS.md + CLAUDE.md + kickjs-skills.md from upstream templates"
1753
- }
1754
- ];
1755
- async function printGeneratorList() {
1756
- console.log("\n Built-in generators:\n");
1757
- const maxName = Math.max(...GENERATORS.map((g) => g.name.length));
1758
- for (const g of GENERATORS) console.log(` kick g ${g.name.padEnd(maxName + 2)} ${g.description}`);
1759
- const config = await loadKickConfig(process.cwd());
1760
- const merged = mergeCliPlugins(config?.plugins ?? [], config?.commands ?? []);
1761
- const discovery = await listPluginGenerators(process.cwd(), merged.generators);
1762
- if (discovery.generators.length > 0) {
1763
- console.log("\n Plugin generators:\n");
1764
- const pluginMax = Math.max(...discovery.generators.map((g) => `${g.spec.name} <name>`.length));
1765
- for (const { source, spec } of discovery.generators) {
1766
- const usage = `${spec.name} <name>`;
1767
- console.log(` kick g ${usage.padEnd(pluginMax + 2)} ${spec.description} [${source}]`);
1768
- }
1769
- }
1770
- if (discovery.failed.length > 0) {
1771
- console.log("\n Failed to load:\n");
1772
- for (const { source, reason } of discovery.failed) console.log(` ${source} — ${reason}`);
1773
- }
1774
- console.log();
1775
- }
1776
- /**
1777
- * Generate one or more modules. Shared by `kick g module <names...>` and
1778
- * the bare `kick g <names...>` shortcut.
1779
- */
1780
- async function runModuleGeneration(names, opts, dryRun) {
1781
- const config = await loadKickConfig(process.cwd());
1782
- const mc = resolveModuleConfig(config);
1783
- const modulesDir = opts.modulesDir ?? mc.dir ?? "src/modules";
1784
- const repo = opts.repo ?? resolveRepoType(mc.repo);
1785
- const pattern = opts.pattern ?? config?.pattern ?? "ddd";
1786
- const shouldPluralize = opts.pluralize === false ? false : mc.pluralize ?? true;
1787
- const tokenScope = resolveTokenScope(config, process.cwd());
1788
- const allFiles = [];
1789
- for (const name of names) {
1790
- const files = await generateModule({
1791
- name,
1792
- modulesDir: resolve(modulesDir),
1793
- noEntity: opts.entity === false,
1794
- noTests: opts.tests === false,
1795
- repo,
1796
- minimal: opts.minimal,
1797
- force: opts.force,
1798
- pattern,
1799
- dryRun,
1800
- pluralize: shouldPluralize,
1801
- prismaClientPath: mc.prismaClientPath,
1802
- tokenScope
1803
- });
1804
- allFiles.push(...files);
1805
- }
1806
- printGenerated(allFiles, dryRun);
1807
- await runPostTypegen(dryRun);
1808
- }
1809
- function registerGenerateCommand(program) {
1810
- const gen = program.command("generate [names...]").alias("g").description("Generate code scaffolds — bare form `kick g <name>` is shorthand for `kick g module <name>`").option("--list", "List all available generators").option("--dry-run", "Preview files that would be generated without writing them").option("--no-entity", "Skip entity and value object generation (module shortcut)").option("--no-tests", "Skip test file generation (module shortcut)").option("--repo <type>", "Repository implementation: inmemory | drizzle | prisma").option("--pattern <pattern>", "Override project pattern: rest | ddd | cqrs | minimal").option("--minimal", "Shorthand for --pattern minimal").option("--modules-dir <dir>", "Modules directory").option("--no-pluralize", "Use singular names (skip auto-pluralization)").option("-f, --force", "Overwrite existing files without prompting").action(async (names, opts, cmd) => {
1811
- if (opts.list) {
1812
- await printGeneratorList();
1813
- return;
1814
- }
1815
- if (!names || names.length === 0) {
1816
- gen.help();
1817
- return;
1818
- }
1819
- const dryRun = isDryRun(cmd);
1820
- setDryRun(dryRun);
1821
- if (names.length >= 2) {
1822
- const [generatorName, itemName, ...rest] = names;
1823
- const cfg = await loadKickConfig(process.cwd());
1824
- const merged = mergeCliPlugins(cfg?.plugins ?? [], cfg?.commands ?? []);
1825
- const result = await tryDispatchPluginGenerator({
1826
- generatorName,
1827
- itemName,
1828
- args: rest,
1829
- flags: opts,
1830
- cwd: process.cwd()
1831
- }, merged.generators);
1832
- if (result) {
1833
- printGenerated(result.files, dryRun);
1834
- return;
1835
- }
1836
- }
1837
- await runModuleGeneration(names, opts, dryRun);
1838
- });
1839
- gen.command("module <names...>").description("Generate one or more modules (e.g. kick g module user task project)").option("--no-entity", "Skip entity and value object generation").option("--no-tests", "Skip test file generation").option("--repo <type>", "Repository implementation: inmemory | drizzle | prisma").option("--pattern <pattern>", "Override project pattern: rest | ddd | cqrs | minimal").option("--minimal", "Shorthand for --pattern minimal").option("--modules-dir <dir>", "Modules directory").option("--no-pluralize", "Use singular names (skip auto-pluralization)").option("-f, --force", "Overwrite existing files without prompting").action(async (names, opts, cmd) => {
1840
- const dryRun = isDryRun(cmd);
1841
- setDryRun(dryRun);
1842
- await runModuleGeneration(names, opts, dryRun);
1843
- });
1844
- gen.command("adapter <name>").description("Generate an AppAdapter with lifecycle hooks and middleware support").option("-o, --out <dir>", "Output directory", "src/adapters").action(async (name, opts, cmd) => {
1845
- const dryRun = isDryRun(cmd);
1846
- setDryRun(dryRun);
1847
- printGenerated(await generateAdapter({
1848
- name,
1849
- outDir: resolve(opts.out)
1850
- }), dryRun);
1851
- });
1852
- gen.command("plugin <name>").description("Generate a KickPlugin with DI, modules, adapters, middleware, and lifecycle hooks").option("-o, --out <dir>", "Output directory", "src/plugins").action(async (name, opts, cmd) => {
1853
- const dryRun = isDryRun(cmd);
1854
- setDryRun(dryRun);
1855
- printGenerated(await generatePlugin({
1856
- name,
1857
- outDir: resolve(opts.out)
1858
- }), dryRun);
1859
- });
1860
- gen.command("middleware <name>").description("Generate an Express middleware function\n Use -m to scope it to a module: kick g middleware auth -m users").option("-o, --out <dir>", "Output directory (overrides --module)").option("-m, --module <module>", "Place inside a module folder").action(async (name, opts, cmd) => {
1861
- const dryRun = isDryRun(cmd);
1862
- setDryRun(dryRun);
1863
- const config = await loadKickConfig(process.cwd());
1864
- const mc = resolveModuleConfig(config);
1865
- const modulesDir = mc.dir ?? "src/modules";
1866
- printGenerated(await generateMiddleware({
1867
- name,
1868
- outDir: opts.out,
1869
- moduleName: opts.module,
1870
- modulesDir,
1871
- pattern: config?.pattern,
1872
- pluralize: mc.pluralize ?? true
1873
- }), dryRun);
1874
- });
1875
- gen.command("guard <name>").description("Generate a route guard (auth, roles, etc.)\n Use -m to scope it to a module: kick g guard admin -m users").option("-o, --out <dir>", "Output directory (overrides --module)").option("-m, --module <module>", "Place inside a module folder").action(async (name, opts, cmd) => {
1876
- const dryRun = isDryRun(cmd);
1877
- setDryRun(dryRun);
1878
- const config = await loadKickConfig(process.cwd());
1879
- const mc = resolveModuleConfig(config);
1880
- const modulesDir = mc.dir ?? "src/modules";
1881
- printGenerated(await generateGuard({
1882
- name,
1883
- outDir: opts.out,
1884
- moduleName: opts.module,
1885
- modulesDir,
1886
- pattern: config?.pattern,
1887
- pluralize: mc.pluralize ?? true
1888
- }), dryRun);
1889
- });
1890
- gen.command("service <name>").description("Generate a @Service() class\n Use -m to scope it to a module: kick g service payment -m orders").option("-o, --out <dir>", "Output directory (overrides --module)").option("-m, --module <module>", "Place inside a module folder").action(async (name, opts, cmd) => {
1891
- const dryRun = isDryRun(cmd);
1892
- setDryRun(dryRun);
1893
- const config = await loadKickConfig(process.cwd());
1894
- const mc = resolveModuleConfig(config);
1895
- const modulesDir = mc.dir ?? "src/modules";
1896
- printGenerated(await generateService({
1897
- name,
1898
- outDir: opts.out,
1899
- moduleName: opts.module,
1900
- modulesDir,
1901
- pattern: config?.pattern,
1902
- pluralize: mc.pluralize ?? true
1903
- }), dryRun);
1904
- });
1905
- gen.command("controller <name>").description("Generate a @Controller() class with basic routes\n Use -m to scope it to a module: kick g controller auth -m users").option("-o, --out <dir>", "Output directory (overrides --module)").option("-m, --module <module>", "Place inside a module folder").action(async (name, opts, cmd) => {
1906
- const dryRun = isDryRun(cmd);
1907
- setDryRun(dryRun);
1908
- const config = await loadKickConfig(process.cwd());
1909
- const mc = resolveModuleConfig(config);
1910
- const modulesDir = mc.dir ?? "src/modules";
1911
- printGenerated(await generateController({
1912
- name,
1913
- outDir: opts.out,
1914
- moduleName: opts.module,
1915
- modulesDir,
1916
- pattern: config?.pattern,
1917
- pluralize: mc.pluralize ?? true
1918
- }), dryRun);
1919
- await runPostTypegen(dryRun);
1920
- });
1921
- gen.command("dto <name>").description("Generate a Zod DTO schema\n Use -m to scope it to a module: kick g dto create-user -m users").option("-o, --out <dir>", "Output directory (overrides --module)").option("-m, --module <module>", "Place inside a module folder").action(async (name, opts, cmd) => {
1922
- const dryRun = isDryRun(cmd);
1923
- setDryRun(dryRun);
1924
- const config = await loadKickConfig(process.cwd());
1925
- const mc = resolveModuleConfig(config);
1926
- const modulesDir = mc.dir ?? "src/modules";
1927
- printGenerated(await generateDto({
1928
- name,
1929
- outDir: opts.out,
1930
- moduleName: opts.module,
1931
- modulesDir,
1932
- pattern: config?.pattern,
1933
- pluralize: mc.pluralize ?? true
1934
- }), dryRun);
1935
- });
1936
- gen.command("test <name>").description("Generate a Vitest test scaffold\n Use -m to scope it to a module: kick g test user-service -m users").option("-o, --out <dir>", "Output directory (overrides --module)").option("-m, --module <module>", "Place inside a module's __tests__/ folder").action(async (name, opts, cmd) => {
1937
- const dryRun = isDryRun(cmd);
1938
- setDryRun(dryRun);
1939
- const mc = resolveModuleConfig(await loadKickConfig(process.cwd()));
1940
- const modulesDir = mc.dir ?? "src/modules";
1941
- printGenerated(await generateTest({
1942
- name,
1943
- outDir: opts.out,
1944
- moduleName: opts.module,
1945
- modulesDir,
1946
- pluralize: mc.pluralize ?? true
1947
- }), dryRun);
1948
- });
1949
- gen.command("job <name>").description("Generate a @Job queue processor with @Process handlers").option("-o, --out <dir>", "Output directory", "src/jobs").option("-q, --queue <name>", "Queue name (default: <name>-queue)").action(async (name, opts, cmd) => {
1950
- const dryRun = isDryRun(cmd);
1951
- setDryRun(dryRun);
1952
- printGenerated(await generateJob({
1953
- name,
1954
- outDir: resolve(opts.out),
1955
- queue: opts.queue
1956
- }), dryRun);
1957
- });
1958
- gen.command("scaffold <name> [fields...]").description("Generate a full CRUD module from field definitions\n Example: kick g scaffold Post title:string body:text:optional published:boolean:optional\n Types: string, text, number, int, float, boolean, date, email, url, uuid, json, enum:a,b,c\n Optional: append :optional (shell-safe): description:text:optional\n or use ? with quoting: \"description:text?\" or \"description?:text\"").option("--no-entity", "Skip entity and value object generation").option("--no-tests", "Skip test file generation").option("--no-pluralize", "Use singular names (skip auto-pluralization)").option("--modules-dir <dir>", "Modules directory").action(async (name, rawFields, opts, cmd) => {
1959
- const dryRun = isDryRun(cmd);
1960
- setDryRun(dryRun);
1961
- if (rawFields.length === 0) {
1962
- console.error("\n Error: At least one field is required.\n Usage: kick g scaffold <name> <field:type> [field:type...]\n Example: kick g scaffold Post title:string body:text:optional published:boolean:optional\n Optional: append :optional (shell-safe, no quoting needed)\n");
1963
- process.exit(1);
1964
- }
1965
- const config = await loadKickConfig(process.cwd());
1966
- const mc = resolveModuleConfig(config);
1967
- const modulesDir = opts.modulesDir ?? mc.dir ?? "src/modules";
1968
- const fields = parseFields(rawFields);
1969
- const tokenScope = resolveTokenScope(config, process.cwd());
1970
- const files = await generateScaffold({
1971
- name,
1972
- fields,
1973
- modulesDir: resolve(modulesDir),
1974
- noEntity: opts.entity === false,
1975
- noTests: opts.tests === false,
1976
- pluralize: opts.pluralize === false ? false : mc.pluralize ?? true,
1977
- tokenScope
1978
- });
1979
- console.log(`\n Scaffolded ${name} with ${fields.length} field(s):`);
1980
- for (const f of fields) console.log(` ${f.name}: ${f.type}${f.optional ? " (optional)" : ""}`);
1981
- printGenerated(files, dryRun);
1982
- await runPostTypegen(dryRun);
1983
- });
1984
- gen.command("auth-scaffold").description("Generate a complete auth module (register, login, logout, password hashing)\n Includes controller, service, DTOs, and test stubs.").option("-s, --strategy <type>", "Auth strategy: jwt | session").option("--role-guards", "Generate role-based guards (default: true)").option("--no-role-guards", "Skip role-based guard generation").option("-o, --out <dir>", "Output directory", "src/modules/auth").action(async (opts, cmd) => {
1985
- const dryRun = isDryRun(cmd);
1986
- setDryRun(dryRun);
1987
- let strategy = opts.strategy;
1988
- if (!strategy) strategy = await select({
1989
- message: "Auth strategy",
1990
- options: [{
1991
- value: "jwt",
1992
- label: "JWT",
1993
- hint: "stateless token-based auth"
1994
- }, {
1995
- value: "session",
1996
- label: "Session",
1997
- hint: "server-side session with cookies"
1998
- }]
1999
- });
2000
- let roleGuards = opts.roleGuards;
2001
- if (roleGuards === void 0) roleGuards = await confirm({
2002
- message: "Generate role-based guards?",
2003
- initialValue: true
2004
- });
2005
- printGenerated(await generateAuthScaffold({
2006
- strategy,
2007
- outDir: opts.out,
2008
- roleGuards
2009
- }), dryRun);
2010
- });
2011
- gen.command("config").description("Generate a kick.config.ts at the project root").option("--modules-dir <dir>", "Modules directory path", "src/modules").option("--repo <type>", "Default repository type: inmemory | drizzle | prisma", "inmemory").option("-f, --force", "Overwrite existing kick.config.ts without prompting").action(async (opts, cmd) => {
2012
- const dryRun = isDryRun(cmd);
2013
- setDryRun(dryRun);
2014
- printGenerated(await generateConfig({
2015
- outDir: resolve("."),
2016
- modulesDir: opts.modulesDir,
2017
- defaultRepo: opts.repo,
2018
- force: opts.force
2019
- }), dryRun);
2020
- });
2021
- 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 | ddd | cqrs | minimal").option("-f, --force", "Overwrite existing files without prompting").action(async (opts, cmd) => {
2022
- const dryRun = isDryRun(cmd);
2023
- setDryRun(dryRun);
2024
- const only = opts.only ?? "all";
2025
- if (!AGENT_DOCS_ONLY_VALUES.includes(only)) {
2026
- console.error(` Invalid --only value: ${only}. Expected: ${AGENT_DOCS_ONLY_VALUES.join(" | ")}`);
2027
- process.exitCode = 1;
2028
- return;
2029
- }
2030
- printGenerated(await generateAgentDocs({
2031
- outDir: resolve("."),
2032
- only,
2033
- name: opts.name,
2034
- pm: opts.pm,
2035
- template: opts.template,
2036
- force: opts.force
2037
- }), dryRun);
2038
- });
2039
- }
2040
- //#endregion
2041
- //#region src/utils/shell.ts
2042
- /**
2043
- * Cross-platform way to launch a Node.js process with a set of
2044
- * environment variables. Uses `spawnSync` with an argument array so no
2045
- * shell is involved — the `VAR=value node ...` POSIX prefix syntax that
2046
- * `runShellCommand` relied on breaks on cmd.exe and PowerShell.
2047
- */
2048
- function runNodeWithEnv(entry, env, cwd) {
2049
- const result = spawnSync(process.execPath, [entry], {
2050
- cwd,
2051
- stdio: "inherit",
2052
- env: {
2053
- ...process.env,
2054
- ...env
2055
- }
2056
- });
2057
- if (result.status !== 0) process.exit(result.status ?? 1);
2058
- }
2059
- //#endregion
2060
- //#region src/typegen/runner.ts
2061
- const TYPES_DIR = ".kickjs/types";
2062
- const BANNER_PREFIX = "/* AUTO-GENERATED by kick typegen — do not edit. Plugin: ";
2063
- async function runTypegen(opts) {
2064
- const typesDirAbs = path.resolve(opts.cwd, TYPES_DIR);
2065
- await mkdir(typesDirAbs, { recursive: true });
2066
- const scanCache = /* @__PURE__ */ new Map();
2067
- const scanFn = opts.scan ?? scanProject;
2068
- const getScanResult = (scanOpts) => {
2069
- const key = stableScanKey(scanOpts);
2070
- let pending = scanCache.get(key);
2071
- if (!pending) {
2072
- pending = scanFn(scanOpts);
2073
- scanCache.set(key, pending);
2074
- }
2075
- return pending;
2076
- };
2077
- const ctx = {
2078
- cwd: opts.cwd,
2079
- config: opts.config,
2080
- async importTs(abs) {
2081
- return await import(pathToFileURL(abs).href);
2082
- },
2083
- async writeFile(relPath, contents) {
2084
- const abs = path.resolve(opts.cwd, relPath);
2085
- await mkdir(path.dirname(abs), { recursive: true });
2086
- await writeFile(abs, contents, "utf8");
2087
- },
2088
- getScanResult,
2089
- log: console
2090
- };
2091
- const results = [];
2092
- for (const plugin of opts.plugins) {
2093
- const out = await plugin.generate(ctx);
2094
- if (out === null) {
2095
- results.push({
2096
- id: plugin.id,
2097
- status: "skipped"
2098
- });
2099
- continue;
2100
- }
2101
- const ext = plugin.outExtension ?? ".d.ts";
2102
- const file = path.join(typesDirAbs, `${plugin.id.replace(/\//g, "__")}${ext}`);
2103
- const next = `${BANNER_PREFIX}${plugin.id} */\n\n` + out + "\n";
2104
- let prev = "";
2105
- if (existsSync(file)) prev = await readFile(file, "utf8");
2106
- if (prev === next) {
2107
- results.push({
2108
- id: plugin.id,
2109
- status: "unchanged",
2110
- outFile: file
2111
- });
2112
- continue;
2113
- }
2114
- if (opts.check) throw new Error(`kick typegen --check: drift detected for ${plugin.id} (${file})`);
2115
- await writeFile(file, next, "utf8");
2116
- results.push({
2117
- id: plugin.id,
2118
- status: "written",
2119
- outFile: file
2120
- });
2121
- }
2122
- return results;
2123
- }
2124
- /**
2125
- * Order-independent cache key for `ScanOptions`. Builds the key from
2126
- * the known fields in a fixed order so semantically equal options
2127
- * always produce the same key regardless of how the caller built the
2128
- * object literal. Arrays (extensions / exclude) are sorted before
2129
- * joining so `['.ts', '.tsx']` and `['.tsx', '.ts']` collide.
2130
- */
2131
- function stableScanKey(opts) {
2132
- const extensions = (opts.extensions ?? []).slice().toSorted().join(",");
2133
- const exclude = (opts.exclude ?? []).slice().toSorted().join(",");
2134
- return [
2135
- `root=${opts.root}`,
2136
- `cwd=${opts.cwd}`,
2137
- `extensions=${extensions}`,
2138
- `exclude=${exclude}`,
2139
- `envFile=${opts.envFile ?? ""}`
2140
- ].join("|");
2141
- }
2142
- //#endregion
2143
- //#region src/typegen/disable-filter.ts
2144
- /**
2145
- * Returns three buckets:
2146
- * - `enabled`: plugins that should run
2147
- * - `skipped`: plugins explicitly disabled by id
2148
- * - `unknown`: disable ids that didn't match any registered plugin
2149
- * — surfaced as a warning to catch typos without breaking the run
2150
- */
2151
- function applyDisableFilter(typegens, disable) {
2152
- const disabledSet = new Set(disable);
2153
- const enabled = [];
2154
- const skipped = [];
2155
- const matchedDisable = /* @__PURE__ */ new Set();
2156
- for (const tg of typegens) if (disabledSet.has(tg.id)) {
2157
- skipped.push(tg);
2158
- matchedDisable.add(tg.id);
2159
- } else enabled.push(tg);
2160
- return {
2161
- enabled,
2162
- skipped,
2163
- unknown: [...disabledSet].filter((id) => !matchedDisable.has(id))
2164
- };
2165
- }
2166
- //#endregion
2167
- //#region src/typegen/run-plugins.ts
2168
- async function runAllPluginTypegens(opts) {
2169
- const { enabled, skipped, unknown } = applyDisableFilter(mergeCliPlugins([...builtinCliPlugins, ...opts.config?.plugins ?? []], opts.config?.commands ?? []).typegens, opts.config?.typegen?.disable ?? []);
2170
- if (!opts.silent && skipped.length > 0) for (const tg of skipped) console.log(` ${tg.id}: disabled (typegen.disable)`);
2171
- if (!opts.silent && unknown.length > 0) console.warn(` kick typegen: disable list references unknown id(s): ${unknown.map((id) => `'${id}'`).join(", ")}. Run \`kick typegen --list\` to see registered ids.`);
2172
- if (enabled.length === 0) return [];
2173
- try {
2174
- const results = await runTypegen({
2175
- cwd: opts.cwd,
2176
- config: opts.config ?? {},
2177
- plugins: enabled,
2178
- check: opts.check
2179
- });
2180
- if (!opts.silent) for (const r of results) console.log(` ${r.id}: ${r.status}`);
2181
- return results;
2182
- } catch (err) {
2183
- if (!opts.silent) {
2184
- const msg = err instanceof Error ? err.message : String(err);
2185
- console.warn(` kick typegen plugins: skipped (${msg})`);
2186
- }
2187
- return [];
2188
- }
2189
- }
2190
- /**
2191
- * Run the full asset build for a loaded config:
2192
- *
2193
- * 1. For each `assetMap` entry, glob → copy → manifest stub.
2194
- * 2. Write `dist/.kickjs-assets.json`.
2195
- *
2196
- * Returns a summary including the manifest contents. No-op (and no
2197
- * manifest written) when `assetMap` is empty / missing — the build
2198
- * pipeline shouldn't litter `dist/` with empty manifests for
2199
- * adopters who don't use the feature.
2200
- */
2201
- async function buildAssets(config, opts) {
2202
- const { cwd, silent = false } = opts;
2203
- const distDir = opts.distDir ?? config?.build?.outDir ?? "dist";
2204
- const map = config?.assetMap;
2205
- if (!map || Object.keys(map).length === 0) return null;
2206
- const log = silent ? () => {} : console.log;
2207
- const distAbs = resolve(cwd, distDir);
2208
- mkdirSync(distAbs, { recursive: true });
2209
- const summary = [];
2210
- const manifestEntries = {};
2211
- for (const [namespace, entry] of Object.entries(map)) {
2212
- const result = await processEntry(namespace, entry, cwd, distAbs);
2213
- summary.push(result.entrySummary);
2214
- Object.assign(manifestEntries, result.manifestSlice);
2215
- log(` ✓ ${namespace}: ${result.entrySummary.filesCopied} file(s) → ${result.entrySummary.dest}`);
2216
- }
2217
- const manifest = {
2218
- version: 1,
2219
- entries: manifestEntries
2220
- };
2221
- const manifestPath = join(distAbs, ".kickjs-assets.json");
2222
- writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + "\n", "utf-8");
2223
- log(` ✓ wrote manifest → ${relative(cwd, manifestPath)} (${Object.keys(manifestEntries).length} entries)`);
2224
- return {
2225
- manifestPath,
2226
- entries: summary,
2227
- manifest
2228
- };
2229
- }
2230
- /** Per-entry inner pipeline — extracted for unit-test reuse. */
2231
- async function processEntry(namespace, entry, cwd, distAbs) {
2232
- const srcAbs = resolve(cwd, entry.src);
2233
- const destAbs = entry.dest ? resolve(cwd, entry.dest) : join(distAbs, namespace);
2234
- if (escapesRoot(destAbs, cwd)) {
2235
- console.warn(` ⚠ assetMap.${namespace}.dest ('${entry.dest}') resolves outside the project root — skipping copy`);
2236
- return {
2237
- entrySummary: {
2238
- namespace,
2239
- src: entry.src,
2240
- dest: relative(cwd, destAbs),
2241
- filesCopied: 0
2242
- },
2243
- manifestSlice: {}
2244
- };
2245
- }
2246
- if (!existsSync(srcAbs) || !isDirectorySync(srcAbs)) return {
2247
- entrySummary: {
2248
- namespace,
2249
- src: entry.src,
2250
- dest: relative(cwd, destAbs),
2251
- filesCopied: 0
2252
- },
2253
- manifestSlice: {}
2254
- };
2255
- const matches = await glob(entry.glob ?? "**/*", {
2256
- cwd: srcAbs,
2257
- nodir: true,
2258
- dot: false,
2259
- posix: true
2260
- });
2261
- mkdirSync(destAbs, { recursive: true });
2262
- const manifestSlice = {};
2263
- const { pairs, collisionGroupsResolved } = groupAssetKeys(namespace, [...matches].toSorted(), { strategy: entry.keys ?? "auto" });
2264
- for (const { rel: relPath, key } of pairs) {
2265
- const srcFile = join(srcAbs, relPath);
2266
- const destFile = join(destAbs, relPath);
2267
- mkdirSync(dirname(destFile), { recursive: true });
2268
- cpSync(srcFile, destFile);
2269
- manifestSlice[key] = toManifestRelative(distAbs, destFile);
2270
- }
2271
- if (collisionGroupsResolved > 0) console.log(` ℹ assetMap.${namespace}: auto-resolved ${collisionGroupsResolved} basename collision(s) by keeping extensions (set 'keys: "strip"' to opt back into legacy last-write-wins behaviour, or 'keys: "with-extension"' to keep all keys verbose).`);
2272
- return {
2273
- entrySummary: {
2274
- namespace,
2275
- src: entry.src,
2276
- dest: relative(cwd, destAbs),
2277
- filesCopied: matches.length
2278
- },
2279
- manifestSlice
2280
- };
2281
- }
2282
- /**
2283
- * Make `destFile` relative to the manifest's directory + force POSIX
2284
- * separators so the manifest is byte-stable across platforms.
2285
- */
2286
- function toManifestRelative(manifestDir, destFile) {
2287
- return relative(manifestDir, destFile).split(/[\\/]/).filter(Boolean).join("/");
2288
- }
2289
- /**
2290
- * Project-root escape check that's safe across symlinks + drive letters.
2291
- * `path.relative` returns `..` segments when the target sits above root,
2292
- * and an absolute path when the two live on different roots (Windows).
2293
- * `startsWith(root)` would miss both cases.
2294
- */
2295
- function escapesRoot(path, root) {
2296
- const rel = relative(root, path);
2297
- if (rel === "") return false;
2298
- return rel.startsWith("..") || isAbsolute(rel);
2299
- }
2300
- /** Pure helper — `false` for missing, non-dir, or unreadable paths. */
2301
- function isDirectorySync(path) {
2302
- try {
2303
- return statSync(path).isDirectory();
2304
- } catch {
2305
- return false;
2306
- }
2307
- }
2308
- //#endregion
2309
- //#region src/commands/run.ts
2310
- /**
2311
- * Start the Vite dev server with @forinda/kickjs-vite plugin.
2312
- *
2313
- * The plugin (configured in the user's vite.config.ts) handles:
2314
- * - SSR environment setup (kickjs:core)
2315
- * - Module auto-discovery (kickjs:module-discovery)
2316
- * - Selective HMR invalidation (kickjs:hmr)
2317
- * - Virtual module generation (kickjs:virtual-modules)
2318
- * - Express mounting + httpServer piping (kickjs:dev-server)
2319
- *
2320
- * This function just creates the Vite server, listens, and handles shutdown.
2321
- * Vite owns the HTTP port — Express runs as post-middleware on Vite's server.
2322
- */
2323
- /**
2324
- * Resolve whether the dev server's chokidar should poll instead of
2325
- * relying on `fs.watch` events. CLI flag wins over env var; default
2326
- * is event-based (faster, lower CPU). Polling is the right choice in
2327
- * Docker bind mounts, WSL crossing the WSL/Windows boundary, NFS,
2328
- * and some old Linux kernels where new-file events get dropped.
2329
- */
2330
- function resolvePolling(flag) {
2331
- if (typeof flag === "boolean") return flag;
2332
- const env = process.env.KICKJS_WATCH_POLLING;
2333
- return env === "1" || env === "true";
2334
- }
2335
- async function startDevServer(_entry, port, opts = {}) {
2336
- if (port) process.env.PORT = port;
2337
- const polling = resolvePolling(opts.polling);
2338
- const cwd = process.cwd();
2339
- const devConfig = await loadKickConfig(cwd);
2340
- const schemaValidator = devConfig?.typegen?.schemaValidator ?? "zod";
2341
- const envFile = devConfig?.typegen?.envFile;
2342
- try {
2343
- await runTypegen$1({
2344
- cwd,
2345
- allowDuplicates: true,
2346
- schemaValidator,
2347
- envFile,
2348
- srcDir: devConfig?.typegen?.srcDir,
2349
- outDir: devConfig?.typegen?.outDir,
2350
- assetMap: devConfig?.assetMap,
2351
- runPlugins: false
2352
- });
2353
- } catch (err) {
2354
- console.warn(` kick typegen: skipped (${err?.message ?? err})`);
2355
- }
2356
- await runAllPluginTypegens({
2357
- cwd,
2358
- config: devConfig
2359
- });
2360
- const { createRequire } = await import("node:module");
2361
- const { createServer } = await import(pathToFileURL(createRequire(resolve("package.json")).resolve("vite")).href);
2362
- const server = await createServer({
2363
- configFile: resolve("vite.config.ts"),
2364
- server: {
2365
- port: port ? parseInt(port, 10) : void 0,
2366
- ...polling ? { watch: {
2367
- usePolling: true,
2368
- interval: 100
2369
- } } : {}
2370
- }
2371
- });
2372
- 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)) : [];
2373
- const isAssetFile = (file) => assetSrcRoots.some((root) => file === root || file.startsWith(`${root}/`));
2374
- let typegenTimer = null;
2375
- const scheduleTypegen = (file) => {
2376
- if (file.includes(".kickjs")) return;
2377
- if (file.endsWith(".d.ts")) return;
2378
- const isTs = /\.(ts|tsx|mts|cts)$/.test(file);
2379
- const isAsset = isAssetFile(file);
2380
- if (!isTs && !isAsset) return;
2381
- if (typegenTimer) clearTimeout(typegenTimer);
2382
- typegenTimer = setTimeout(() => {
2383
- runTypegen$1({
2384
- cwd,
2385
- silent: true,
2386
- allowDuplicates: true,
2387
- schemaValidator,
2388
- envFile,
2389
- srcDir: devConfig?.typegen?.srcDir,
2390
- outDir: devConfig?.typegen?.outDir,
2391
- assetMap: devConfig?.assetMap,
2392
- runPlugins: false
2393
- }).catch(() => {});
2394
- runAllPluginTypegens({
2395
- cwd,
2396
- config: devConfig,
2397
- silent: true
2398
- }).catch(() => {});
2399
- }, 100);
2400
- };
2401
- server.watcher.on("add", scheduleTypegen);
2402
- server.watcher.on("unlink", scheduleTypegen);
2403
- server.watcher.on("change", scheduleTypegen);
2404
- if (assetSrcRoots.length > 0) server.watcher.add(assetSrcRoots);
2405
- await server.listen();
2406
- server.printUrls();
2407
- console.log(`\n KickJS dev server running (Vite + @forinda/kickjs-vite)\n`);
2408
- const shutdown = async () => {
2409
- if (typegenTimer) clearTimeout(typegenTimer);
2410
- await server.close();
2411
- process.exit(0);
2412
- };
2413
- process.on("SIGINT", shutdown);
2414
- process.on("SIGTERM", shutdown);
2415
- }
2416
- function registerRunCommands(program) {
2417
- program.command("dev").description("Start development server with Vite HMR (zero-downtime reload)").option("-e, --entry <file>", "Entry file", "src/index.ts").option("-p, --port <port>", "Port number").option("--polling", "Force chokidar to poll for file changes (Docker / WSL / NFS / older kernels)").action(async (opts) => {
2418
- try {
2419
- await startDevServer(opts.entry, opts.port, { polling: opts.polling });
2420
- } catch (err) {
2421
- if (err.code === "ERR_MODULE_NOT_FOUND" && err.message?.includes("vite")) console.error("\n Error: vite is not installed.\n Run: pnpm add -D vite unplugin-swc\n");
2422
- else console.error("\n Dev server failed:", err.message ?? err);
2423
- process.exit(1);
2424
- }
2425
- });
2426
- program.command("build").description("Build for production via Vite").action(async () => {
2427
- console.log("\n Building for production...\n");
2428
- const { createRequire } = await import("node:module");
2429
- const { build } = await import(pathToFileURL(createRequire(resolve("package.json")).resolve("vite")).href);
2430
- await build({ configFile: resolve("vite.config.ts") });
2431
- const config = await loadKickConfig(process.cwd());
2432
- const copyDirs = config?.copyDirs ?? [];
2433
- if (copyDirs.length > 0) {
2434
- console.log("\n Copying directories to dist...");
2435
- for (const entry of copyDirs) {
2436
- const src = typeof entry === "string" ? entry : entry.src;
2437
- const dest = typeof entry === "string" ? join("dist", entry) : entry.dest ?? join("dist", src);
2438
- const srcPath = resolve(src);
2439
- const destPath = resolve(dest);
2440
- if (!existsSync(srcPath)) {
2441
- console.log(` ⚠ Skipped ${src} (not found)`);
2442
- continue;
2443
- }
2444
- mkdirSync(destPath, { recursive: true });
2445
- cpSync(srcPath, destPath, { recursive: true });
2446
- console.log(` ✓ ${src} → ${dest}`);
2447
- }
2448
- }
2449
- if (config?.assetMap && Object.keys(config.assetMap).length > 0) {
2450
- console.log("\n Building asset map...");
2451
- try {
2452
- await buildAssets(config, { cwd: process.cwd() });
2453
- } catch (err) {
2454
- console.error(` ✗ asset build failed: ${err instanceof Error ? err.message : String(err)}`);
2455
- process.exit(1);
2456
- }
2457
- }
2458
- console.log("\n Build complete.\n");
2459
- });
2460
- program.command("build:assets").description("Rebuild the .kickjs-assets.json manifest under the configured outDir (no JS rebuild)").action(async () => {
2461
- const config = await loadKickConfig(process.cwd());
2462
- if (!config?.assetMap || Object.keys(config.assetMap).length === 0) {
2463
- console.log(" No assetMap entries — nothing to build.");
2464
- return;
2465
- }
2466
- console.log("\n Building asset map...");
2467
- try {
2468
- await buildAssets(config, { cwd: process.cwd() });
2469
- console.log("\n Asset build complete.\n");
2470
- } catch (err) {
2471
- console.error(` ✗ ${err instanceof Error ? err.message : String(err)}`);
2472
- process.exit(1);
2473
- }
2474
- });
2475
- program.command("start").description("Start production server").option("-e, --entry <file>", "Entry file", "dist/index.js").option("-p, --port <port>", "Port number").action((opts) => {
2476
- const env = { NODE_ENV: "production" };
2477
- if (opts.port) env.PORT = String(opts.port);
2478
- runNodeWithEnv(opts.entry, env);
2479
- });
2480
- program.command("dev:debug").description("Start dev server with Node.js inspector attached").option("-e, --entry <file>", "Entry file", "src/index.ts").option("-p, --port <port>", "Port number").option("--inspect-port <port>", "Inspector port", "9229").action(async (opts) => {
2481
- const inspectPort = opts.inspectPort ?? "9229";
2482
- process.env.NODE_OPTIONS = `--inspect=0.0.0.0:${inspectPort}`;
2483
- console.log(` Debugger: ws://0.0.0.0:${inspectPort}`);
2484
- try {
2485
- await startDevServer(opts.entry, opts.port);
2486
- } catch (err) {
2487
- console.error("\n Dev server (debug) failed:", err.message ?? err);
2488
- process.exit(1);
2489
- }
2490
- });
2491
- }
2492
- //#endregion
2493
- //#region src/commands/info.ts
2494
- function registerInfoCommand(program) {
2495
- program.command("info").description("Print system and framework info").action(() => {
2496
- console.log(`
2497
- KickJS CLI
2498
-
2499
- System:
2500
- OS: ${platform()} ${release()} (${arch()})
2501
- Node: ${process.version}
2502
-
2503
- Packages:
2504
- @forinda/kickjs workspace
2505
- @forinda/kickjs-vite workspace
2506
- @forinda/kickjs-cli workspace
2507
- `);
2508
- });
2509
- }
2510
- //#endregion
2511
- //#region src/commands/inspect.ts
2512
- const { bold, dim, green, red, yellow, blue } = pc;
2513
- function formatUptime(seconds) {
2514
- const d = Math.floor(seconds / 86400);
2515
- const h = Math.floor(seconds % 86400 / 3600);
2516
- const m = Math.floor(seconds % 3600 / 60);
2517
- const s = seconds % 60;
2518
- const parts = [];
2519
- if (d) parts.push(`${d}d`);
2520
- if (h) parts.push(`${h}h`);
2521
- if (m) parts.push(`${m}m`);
2522
- parts.push(`${s}s`);
2523
- return parts.join(" ");
2524
- }
2525
- async function fetchJson(url) {
2526
- const res = await fetch(url, { signal: AbortSignal.timeout(5e3) });
2527
- if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
2528
- return res.json();
2529
- }
2530
- async function fetchEndpoint(base, path) {
2531
- try {
2532
- return await fetchJson(`${base}${path}`);
2533
- } catch {
2534
- return null;
2535
- }
2536
- }
2537
- async function fetchAll(base) {
2538
- const [health, metrics, routes, container, ws] = await Promise.all([
2539
- fetchEndpoint(base, "/health"),
2540
- fetchEndpoint(base, "/metrics"),
2541
- fetchEndpoint(base, "/routes"),
2542
- fetchEndpoint(base, "/container"),
2543
- fetchEndpoint(base, "/ws")
2544
- ]);
2545
- return {
2546
- health,
2547
- metrics,
2548
- routes,
2549
- container,
2550
- ws
2551
- };
2552
- }
2553
- function printSummary(base, data) {
2554
- const { health, metrics, routes, container, ws } = data;
2555
- const line = dim("─".repeat(60));
2556
- console.log();
2557
- console.log(bold(` KickJS Inspector`) + dim(` → ${base}`));
2558
- console.log(line);
2559
- if (health) {
2560
- const statusText = health.status === "healthy" ? green("● healthy") : red("● " + health.status);
2561
- console.log(` ${bold("Health:")} ${statusText}`);
2562
- } else console.log(` ${bold("Health:")} ${red("● unreachable")}`);
2563
- if (metrics) {
2564
- const rate = ((metrics.errorRate ?? 0) * 100).toFixed(1);
2565
- const rateColor = metrics.errorRate > .1 ? red : metrics.errorRate > 0 ? yellow : green;
2566
- console.log(` ${bold("Uptime:")} ${formatUptime(metrics.uptimeSeconds)}`);
2567
- console.log(` ${bold("Requests:")} ${metrics.requests}`);
2568
- console.log(` ${bold("Errors:")} ${metrics.serverErrors} server, ${metrics.clientErrors ?? 0} client ${dim("(")}${rateColor(rate + "%")}${dim(")")}`);
2569
- }
2570
- if (container) console.log(` ${bold("DI:")} ${container.count} bindings`);
2571
- if (ws && ws.enabled) console.log(` ${bold("WS:")} ${ws.connections ?? 0} connections, ${ws.namespaces ?? 0} namespaces`);
2572
- if (routes?.routes?.length) {
2573
- console.log();
2574
- console.log(bold(" Routes"));
2575
- console.log(line);
2576
- console.log(` ${dim("METHOD")} ${dim("PATH".padEnd(36))} ${dim("CONTROLLER")}`);
2577
- for (const r of routes.routes) {
2578
- const path = r.path.length > 36 ? r.path.slice(0, 33) + "..." : r.path.padEnd(36);
2579
- console.log(` ${httpMethodColor(r.method)} ${path} ${blue(r.controller)}.${dim(r.handler)}`);
2580
- }
2581
- }
2582
- console.log(line);
2583
- console.log();
2584
- }
2585
- function registerInspectCommand(program) {
2586
- program.command("inspect [url]").description("Connect to a running KickJS app and display debug info").option("-p, --port <port>", "Override port").option("-w, --watch", "Poll every 5 seconds").option("-j, --json", "Output raw JSON").action(async (url, opts) => {
2587
- let base = url ?? "http://localhost:3000";
2588
- if (opts.port) try {
2589
- const parsed = new URL(base);
2590
- parsed.port = opts.port;
2591
- base = parsed.origin;
2592
- } catch {
2593
- base = `http://localhost:${opts.port}`;
2594
- }
2595
- const debugBase = `${base.replace(/\/$/, "")}/_debug`;
2596
- const run = async () => {
2597
- try {
2598
- const data = await fetchAll(debugBase);
2599
- if (opts.json) console.log(JSON.stringify(data, null, 2));
2600
- else printSummary(base, data);
2601
- } catch (err) {
2602
- if (opts.json) console.log(JSON.stringify({ error: String(err) }));
2603
- else {
2604
- console.error(red(` ✖ Could not connect to ${base}`));
2605
- console.error(dim(` ${err instanceof Error ? err.message : String(err)}`));
2606
- }
2607
- if (!opts.watch) process.exitCode = 1;
2608
- }
2609
- };
2610
- if (opts.watch) {
2611
- const poll = async () => {
2612
- process.stdout.write("\x1B[2J\x1B[H");
2613
- await run();
2614
- };
2615
- await poll();
2616
- setInterval(poll, 5e3);
2617
- } else await run();
2618
- });
2619
- }
2620
- //#endregion
2621
- //#region src/explain/known-issues.ts
2622
- function includesAll(haystack, needles) {
2623
- const lower = haystack.toLowerCase();
2624
- return needles.every((n) => lower.includes(n.toLowerCase()));
2625
- }
2626
- function includesAny(haystack, needles) {
2627
- const lower = haystack.toLowerCase();
2628
- return needles.some((n) => lower.includes(n.toLowerCase()));
2629
- }
2630
- const KNOWN_ISSUES = [
2631
- { match(input, _ctx) {
2632
- const hasConfigGetUndefined = includesAll(input, ["config", "get"]) && includesAny(input, ["undefined", "null"]);
2633
- const hasValueUndefined = input.includes("@Value") && includesAny(input, ["undefined", "is not defined"]);
2634
- if (!hasConfigGetUndefined && !hasValueUndefined) return null;
2635
- return {
2636
- confidence: hasConfigGetUndefined && hasValueUndefined ? 90 : 75,
2637
- diagnosis: {
2638
- id: "env-schema-not-registered",
2639
- title: "ConfigService.get() returns undefined for user-defined keys",
2640
- explanation: "Your src/index.ts is missing `import \"./config\"`. That side-effect import\nregisters the env schema with kickjs at module-load time. Without it,\nConfigService falls back to the base schema (PORT/NODE_ENV/LOG_LEVEL only)\nand every user-defined key reads as undefined. @Value() may *appear* to\nwork via a raw process.env fallback, but Zod coercion and schema defaults\nare silently skipped.",
2641
- fix: "Add this line to src/index.ts near the top, before bootstrap() runs:",
2642
- codeBefore: "import 'reflect-metadata'\nimport { bootstrap } from '@forinda/kickjs'\nimport { modules } from './modules'\n",
2643
- codeAfter: "import 'reflect-metadata'\nimport './config' // ← add this — registers env schema\nimport { bootstrap } from '@forinda/kickjs'\nimport { modules } from './modules'\n",
2644
- docs: "https://forinda.github.io/kick-js/guide/configuration.html#wiring-the-schema-at-startup"
2645
- }
2646
- };
2647
- } },
2648
- { match(input, _ctx) {
2649
- const hasTestContext = includesAny(input, [
2650
- "vitest",
2651
- "test",
2652
- "spec",
2653
- "__tests__",
2654
- ".test."
2655
- ]);
2656
- if (!includesAny(input, [
2657
- "already registered",
2658
- "already exists",
2659
- "duplicate",
2660
- "has been registered"
2661
- ])) return null;
2662
- return {
2663
- confidence: hasTestContext ? 85 : 60,
2664
- diagnosis: {
2665
- id: "container-not-reset-in-tests",
2666
- title: "DI container leaks between test cases",
2667
- explanation: "KickJS decorators register classes on the global Container at import time.\nWhen vitest re-imports your modules across tests, the same class can be\nregistered twice and the container throws. The fix is to wipe the\ncontainer between tests so each case starts fresh.",
2668
- fix: "Add Container.reset() to a beforeEach hook in the failing test file:",
2669
- codeAfter: "import { describe, it, beforeEach } from 'vitest'\nimport { Container } from '@forinda/kickjs'\n\ndescribe('UserController', () => {\n beforeEach(() => Container.reset())\n\n it('does the thing', async () => { /* ... */ })\n})",
2670
- docs: "https://forinda.github.io/kick-js/guide/testing.html"
2671
- }
2672
- };
2673
- } },
2674
- { match(input, _ctx) {
2675
- if (!(input.includes("@Module") || includesAll(input, ["Module", "is not a function"]) || includesAll(input, ["Module", "no exported member"]))) return null;
2676
- return {
2677
- confidence: 80,
2678
- diagnosis: {
2679
- id: "module-decorator-not-found",
2680
- title: "KickJS does not have a @Module decorator (different pattern from NestJS)",
2681
- explanation: "NestJS uses @Module({ controllers, providers }). KickJS uses an interface\npattern instead: a class implements AppModule and exposes routes() that\nreturns the controller wiring. This was a deliberate choice — modules\nbecome explicit values rather than metadata, which makes them easier to\ncompose, test, and serialize.",
2682
- fix: "Replace the @Module decorator with an AppModule class:",
2683
- codeBefore: "import { Module } from '@forinda/kickjs' // ← does not exist\nimport { UserController } from './user.controller'\n\n@Module({\n controllers: [UserController],\n})\nexport class UserModule {}",
2684
- codeAfter: "import { type AppModule, type ModuleRoutes, buildRoutes } from '@forinda/kickjs'\nimport { UserController } from './user.controller'\n\nexport class UserModule implements AppModule {\n routes(): ModuleRoutes {\n return {\n path: '/users',\n router: buildRoutes(UserController),\n controller: UserController,\n }\n }\n}",
2685
- docs: "https://forinda.github.io/kick-js/guide/project-structure.html"
2686
- }
2687
- };
2688
- } },
2689
- { match(input, _ctx) {
2690
- if (!/KickRoutes\s*\[\s*['"](GET|POST|PUT|PATCH|DELETE)/i.test(input)) return null;
2691
- return {
2692
- confidence: 95,
2693
- diagnosis: {
2694
- id: "legacy-kick-routes-bracket-syntax",
2695
- title: "KickRoutes['POST /users'] is the legacy v1 syntax",
2696
- explanation: "KickJS v2 changed the typegen output from a flat string-keyed map to a\nnamespaced shape: KickRoutes.UserController[\"create\"] instead of\nKickRoutes[\"POST /users\"]. The new form is per-controller, per-method,\nand matches the actual class names so refactors propagate via\nrename-symbol instead of grep.",
2697
- fix: "Update the Ctx<...> type parameter to use the namespace form:",
2698
- codeBefore: "@Post('/', { body: createUserSchema })\ncreate(ctx: Ctx<KickRoutes['POST /users']>) { /* ... */ }",
2699
- codeAfter: "@Post('/', { body: createUserSchema, name: 'CreateUser' })\ncreate(ctx: Ctx<KickRoutes.UserController['create']>) { /* ... */ }",
2700
- docs: "https://forinda.github.io/kick-js/guide/typegen.html"
2701
- }
2702
- };
2703
- } },
2704
- { match(input, _ctx) {
2705
- const hasCluster = includesAny(input, [
2706
- "cluster",
2707
- "workers",
2708
- "two ports",
2709
- "duplicate server"
2710
- ]);
2711
- const hasDevSignal = includesAny(input, [
2712
- "kick dev",
2713
- "vite",
2714
- "eaddrinuse",
2715
- "5173",
2716
- "5174",
2717
- "two servers"
2718
- ]);
2719
- if (!hasCluster || !hasDevSignal) return null;
2720
- return {
2721
- confidence: 85,
2722
- diagnosis: {
2723
- id: "cluster-in-vite-dev",
2724
- title: "Cluster mode is incompatible with `kick dev` (Vite owns the server)",
2725
- explanation: "In dev mode, Vite owns the HTTP server. If your bootstrap passes\ncluster: { workers: N }, the framework forks N workers, each of which\nspins up its own Vite instance on a separate port. The fix landed in\nv2.2.5: McpAdapter (and bootstrap()) now detects Vite dev mode and\nsilently skips cluster, with a warning. If you see this on an older\nversion, upgrade or guard the cluster option behind NODE_ENV.",
2726
- fix: "Either upgrade to v2.2.5+ or gate cluster mode on production:",
2727
- codeAfter: "export const app = await bootstrap({\n modules,\n cluster: process.env.NODE_ENV === 'production' ? { workers: 4 } : false,\n})",
2728
- docs: "https://forinda.github.io/kick-js/guide/cluster.html"
2729
- }
2730
- };
2731
- } },
2732
- { match(input, _ctx) {
2733
- if (!includesAny(input, [
2734
- "reflect-metadata",
2735
- "Reflect.getMetadata is not a function",
2736
- "Reflect.defineMetadata",
2737
- "design:type",
2738
- "design:paramtypes"
2739
- ])) return null;
2740
- return {
2741
- confidence: 90,
2742
- diagnosis: {
2743
- id: "reflect-metadata-missing",
2744
- title: "reflect-metadata is not loaded — DI cannot read decorator types",
2745
- explanation: "The DI container reads constructor parameter types via the\nreflect-metadata polyfill. The polyfill must be imported once,\nbefore any decorator runs. Most projects do this at the top of\nsrc/index.ts; missing the import causes obscure \"design:paramtypes\"\nor \"Reflect.getMetadata is not a function\" errors at runtime.",
2746
- fix: "Add the import at the very top of src/index.ts:",
2747
- codeAfter: "import 'reflect-metadata' // ← must be the FIRST import\nimport './config'\nimport { bootstrap } from '@forinda/kickjs'\nimport { modules } from './modules'\n\nexport const app = await bootstrap({ modules })",
2748
- docs: "https://forinda.github.io/kick-js/guide/dependency-injection.html"
2749
- }
2750
- };
2751
- } },
2752
- { match(input, _ctx) {
2753
- if (!includesAny(input, [
2754
- "404",
2755
- "cannot get",
2756
- "cannot post",
2757
- "no route"
2758
- ])) return null;
2759
- return {
2760
- confidence: 50,
2761
- diagnosis: {
2762
- id: "module-not-registered",
2763
- title: "A 404 may indicate a module is not in the modules array",
2764
- explanation: "KickJS only mounts modules listed in `src/modules/index.ts`. If you\ngenerated a module via `kick g module foo` but the routes don't appear,\nthe most likely cause is that the module is missing from the exported\narray. The CLI usually wires this automatically, but a hand-edit can\ndrop the entry.",
2765
- fix: "Open src/modules/index.ts and verify the module is in the array:",
2766
- codeAfter: "import type { AppModuleClass } from '@forinda/kickjs'\nimport { UserModule } from './users/user.module'\nimport { TaskModule } from './tasks/task.module' // ← was this missing?\n\nexport const modules: AppModuleClass[] = [UserModule, TaskModule]",
2767
- docs: "https://forinda.github.io/kick-js/guide/project-structure.html"
2768
- }
2769
- };
2770
- } }
2771
- ];
2772
- /**
2773
- * Run every matcher against the input and return the highest-confidence
2774
- * hit, or `null` if no matcher cleared the 40-confidence threshold.
2775
- */
2776
- function findBestMatch(input, ctx) {
2777
- let best = null;
2778
- for (const issue of KNOWN_ISSUES) {
2779
- let match = null;
2780
- try {
2781
- match = issue.match(input, ctx);
2782
- } catch {
2783
- continue;
2784
- }
2785
- if (!match || match.confidence < 40) continue;
2786
- if (!best || match.confidence > best.confidence) best = match;
2787
- }
2788
- return best;
2789
- }
2790
- //#endregion
2791
- //#region src/explain/ai-fallback.ts
2792
- /**
2793
- * Ask the configured LLM for a diagnosis of `options.input`.
2794
- *
2795
- * Returns a discriminated result; callers should never assume the
2796
- * LLM was reachable or produced valid output. The function catches
2797
- * every expected failure mode and maps it to a friendly `unavailable`
2798
- * or `error` result — the CLI can then decide how to present it.
2799
- */
2800
- async function askAi(options) {
2801
- const provider = options.provider ?? "openai";
2802
- const apiKey = process.env.OPENAI_API_KEY;
2803
- if (provider === "openai" && !apiKey) return {
2804
- kind: "unavailable",
2805
- reason: "OPENAI_API_KEY environment variable is not set",
2806
- suggestion: "Set OPENAI_API_KEY in your shell, e.g.\n export OPENAI_API_KEY=\"sk-...\"\n\nThen re-run `kick explain --ai \"<your error>\"`."
2807
- };
2808
- let aiModule;
2809
- try {
2810
- aiModule = await import("@forinda/kickjs-ai");
2811
- } catch {
2812
- return {
2813
- kind: "unavailable",
2814
- reason: "@forinda/kickjs-ai is not installed",
2815
- suggestion: "Install the AI package to enable the LLM fallback:\n kick add ai\n\nOr manually:\n pnpm add @forinda/kickjs-ai"
2816
- };
2817
- }
2818
- const { OpenAIProvider } = aiModule;
2819
- const instance = new OpenAIProvider({
2820
- apiKey,
2821
- defaultChatModel: options.model ?? "gpt-4o-mini"
2822
- });
2823
- const systemPrompt = buildSystemPrompt(options.cwd);
2824
- const userPrompt = `Error or stack trace:\n\n${options.input.trim()}`;
2825
- try {
2826
- const diagnosis = parseDiagnosisFromResponse((await instance.chat({ messages: [{
2827
- role: "system",
2828
- content: systemPrompt
2829
- }, {
2830
- role: "user",
2831
- content: userPrompt
2832
- }] })).content);
2833
- if (!diagnosis) return {
2834
- kind: "error",
2835
- message: "The LLM responded but the payload was not valid JSON in the expected shape. Try again, or file an issue with the error text."
2836
- };
2837
- return {
2838
- kind: "ok",
2839
- diagnosis
2840
- };
2841
- } catch (err) {
2842
- return {
2843
- kind: "error",
2844
- message: `LLM request failed: ${err instanceof Error ? err.message : String(err)}`
2845
- };
2846
- }
2847
- }
2848
- /**
2849
- * Build the system prompt that tells the LLM what KickJS is and how
2850
- * to structure its response. The prompt is deliberately prescriptive:
2851
- * the caller needs a JSON payload it can render via the same formatter
2852
- * the known-issues path uses, so freeform text doesn't work.
2853
- *
2854
- * Keep this prompt short — every token counts at inference time and
2855
- * the CLI is often called interactively.
2856
- */
2857
- function buildSystemPrompt(cwd) {
2858
- return [
2859
- "You are a diagnostic assistant for KickJS, a decorator-driven Node.js",
2860
- "framework built on Express 5 and TypeScript. KickJS projects use:",
2861
- " - @Controller, @Get, @Post, @Autowired, @Service, @Value decorators",
2862
- " - An AppModule interface with a routes() method (NOT a @Module decorator)",
2863
- " - Zod schemas as both runtime validators and OpenAPI sources",
2864
- " - Ctx<KickRoutes.ControllerName['method']> for typed request context",
2865
- " - src/config/index.ts with defineEnv/loadEnv for env schema",
2866
- " - A side-effect `import \"./config\"` in src/index.ts to register the schema",
2867
- " - Container.reset() in beforeEach for DI test isolation",
2868
- "",
2869
- "When the user gives you an error message or stack trace, produce a",
2870
- "structured diagnosis that helps them fix the bug. You MUST respond",
2871
- "with a single JSON object (no surrounding prose, no markdown fences)",
2872
- "matching this shape:",
2873
- "",
2874
- "{",
2875
- " \"id\": \"<kebab-case-identifier>\",",
2876
- " \"title\": \"<one-line problem summary>\",",
2877
- " \"explanation\": \"<multi-line explanation of what is wrong>\",",
2878
- " \"fix\": \"<multi-line instructions for fixing the problem>\",",
2879
- " \"codeBefore\": \"<optional: broken code snippet>\",",
2880
- " \"codeAfter\": \"<optional: corrected code snippet>\",",
2881
- " \"docs\": \"<optional: KickJS doc URL that discusses this topic>\"",
2882
- "}",
2883
- "",
2884
- "The KickJS docs live at https://forinda.github.io/kick-js/ — prefer",
2885
- "that domain for any doc links you suggest.",
2886
- cwd ? `The project is located at ${cwd}.` : ""
2887
- ].filter((line) => line.length > 0).join("\n");
2888
- }
2889
- /**
2890
- * Extract a `Diagnosis` object from the LLM response content.
2891
- *
2892
- * Tries three strategies in order:
2893
- * 1. Parse the whole content as JSON directly
2894
- * 2. Strip a surrounding markdown fence (```json ... ```)
2895
- * 3. Find the first balanced `{ ... }` block and parse that
2896
- *
2897
- * Returns null if none of the strategies produce a valid object with
2898
- * at least the required fields (id, title, explanation, fix).
2899
- */
2900
- function parseDiagnosisFromResponse(content) {
2901
- const attempts = [
2902
- content,
2903
- stripMarkdownFence(content),
2904
- extractFirstJsonObject(content)
2905
- ].filter((s) => s !== null);
2906
- for (const attempt of attempts) try {
2907
- const parsed = JSON.parse(attempt);
2908
- if (isValidDiagnosis(parsed)) return parsed;
2909
- } catch {
2910
- continue;
2911
- }
2912
- return null;
2913
- }
2914
- function stripMarkdownFence(text) {
2915
- const match = text.match(/```(?:json)?\s*\n([\s\S]*?)```/);
2916
- return match ? match[1]?.trim() ?? null : null;
2917
- }
2918
- function extractFirstJsonObject(text) {
2919
- const start = text.indexOf("{");
2920
- if (start === -1) return null;
2921
- let depth = 0;
2922
- let inString = false;
2923
- let escape = false;
2924
- for (let i = start; i < text.length; i++) {
2925
- const ch = text[i];
2926
- if (escape) {
2927
- escape = false;
2928
- continue;
2929
- }
2930
- if (ch === "\\" && inString) {
2931
- escape = true;
2932
- continue;
2933
- }
2934
- if (ch === "\"") {
2935
- inString = !inString;
2936
- continue;
2937
- }
2938
- if (inString) continue;
2939
- if (ch === "{") depth++;
2940
- if (ch === "}") {
2941
- depth--;
2942
- if (depth === 0) return text.slice(start, i + 1);
2943
- }
2944
- }
2945
- return null;
2946
- }
2947
- function isValidDiagnosis(value) {
2948
- if (value === null || typeof value !== "object") return false;
2949
- const v = value;
2950
- return typeof v.id === "string" && typeof v.title === "string" && typeof v.explanation === "string" && typeof v.fix === "string";
2951
- }
2952
- //#endregion
2953
- //#region src/commands/explain.ts
2954
- /**
2955
- * `kick explain` — explain a KickJS error and suggest a fix.
2956
- *
2957
- * The command takes an error message (positional arg, --message flag,
2958
- * or stdin), runs it through a registry of known KickJS pitfalls, and
2959
- * prints the highest-confidence diagnosis with a code fix and a doc
2960
- * link. If no matcher hits, it prints a "no match" message — the
2961
- * --ai flag (planned) will fall back to an LLM call against the
2962
- * registered AiProvider.
2963
- *
2964
- * The known-issues registry lives in src/explain/known-issues.ts and
2965
- * is the single source of truth for KickJS-specific advice. Adding a
2966
- * new entry takes ~30 lines and gives every user a permanent fix path.
2967
- *
2968
- * @example
2969
- * ```bash
2970
- * # As a positional arg
2971
- * kick explain "config.get('DATABASE_URL') returned undefined"
2972
- *
2973
- * # Via stdin (pipe a stack trace)
2974
- * pnpm test 2>&1 | kick explain
2975
- *
2976
- * # Via --message flag
2977
- * kick explain --message "Reflect.getMetadata is not a function"
2978
- * ```
2979
- */
2980
- function registerExplainCommand(program) {
2981
- program.command("explain [message]").description("Explain a KickJS error and suggest a fix").option("-m, --message <text>", "Error message to explain (alternative to positional arg)").option("--ai", "Fall back to LLM if no known-issue matches (requires @forinda/kickjs-ai)").option("--model <name>", "Model name for the --ai fallback", "gpt-4o-mini").option("--json", "Output the diagnosis as JSON for tooling integration").action(async (positional, opts) => {
2982
- const input = await resolveInput(positional, opts.message);
2983
- if (!input || input.trim().length === 0) {
2984
- process.stderr.write("Error: no input provided.\n\nPass a message as a positional arg, --message flag, or pipe via stdin:\n kick explain \"config.get returned undefined\"\n pnpm test 2>&1 | kick explain\n");
2985
- process.exit(1);
2986
- }
2987
- const ctx = buildExplainContext();
2988
- const match = findBestMatch(input, ctx);
2989
- if (opts.json && match) {
2990
- process.stdout.write(JSON.stringify({
2991
- matched: true,
2992
- ...match
2993
- }, null, 2) + "\n");
2994
- return;
2995
- }
2996
- if (match) {
2997
- printDiagnosis(input, match.diagnosis, match.confidence);
2998
- return;
2999
- }
3000
- if (!opts.ai) {
3001
- if (opts.json) {
3002
- process.stdout.write(JSON.stringify({ matched: false }, null, 2) + "\n");
3003
- process.exit(2);
3004
- }
3005
- printNoMatch(input, false);
3006
- process.exit(2);
3007
- }
3008
- const result = await askAi({
3009
- input,
3010
- model: opts.model,
3011
- cwd: ctx.cwd
3012
- });
3013
- if (opts.json) {
3014
- process.stdout.write(JSON.stringify(aiResultToJson(result), null, 2) + "\n");
3015
- process.exit(result.kind === "ok" ? 0 : 2);
3016
- }
3017
- printAiResult(input, result);
3018
- process.exit(result.kind === "ok" ? 0 : 2);
3019
- });
3020
- }
3021
- /** Serialize an AskAiResult for `--json` output. */
3022
- function aiResultToJson(result) {
3023
- if (result.kind === "ok") return {
3024
- matched: true,
3025
- source: "ai",
3026
- diagnosis: result.diagnosis
3027
- };
3028
- if (result.kind === "unavailable") return {
3029
- matched: false,
3030
- aiUnavailable: true,
3031
- reason: result.reason
3032
- };
3033
- return {
3034
- matched: false,
3035
- aiError: true,
3036
- error: result.message
3037
- };
3038
- }
3039
- /** Render an AskAiResult to stdout using the same formatting as local matches. */
3040
- function printAiResult(input, result) {
3041
- if (result.kind === "ok") {
3042
- printDiagnosis(input, result.diagnosis, -1, true);
3043
- return;
3044
- }
3045
- if (result.kind === "unavailable") {
3046
- process.stdout.write(`\n Explaining: ${truncate(input.trim(), 200)}\n\n`);
3047
- process.stdout.write(` AI fallback unavailable: ${result.reason}\n\n`);
3048
- process.stdout.write(`${indent(result.suggestion, " ")}\n\n`);
3049
- return;
3050
- }
3051
- process.stdout.write(`\n Explaining: ${truncate(input.trim(), 200)}\n\n`);
3052
- process.stdout.write(` AI fallback error: ${result.message}\n\n`);
3053
- }
3054
- /**
3055
- * Resolve the error text from positional arg, --message flag, or stdin.
3056
- *
3057
- * Precedence: positional > flag > stdin. We only read stdin if neither
3058
- * of the first two were provided AND stdin is not a TTY (i.e. something
3059
- * is being piped in). Reading from a real TTY would hang waiting for
3060
- * the user to type, which is never what they want.
3061
- */
3062
- async function resolveInput(positional, flag) {
3063
- if (positional && positional.trim().length > 0) return positional;
3064
- if (flag && flag.trim().length > 0) return flag;
3065
- if (process.stdin.isTTY) return "";
3066
- return readStdinAll();
3067
- }
3068
- function readStdinAll() {
3069
- return new Promise((resolve, reject) => {
3070
- let buffer = "";
3071
- process.stdin.setEncoding("utf8");
3072
- process.stdin.on("data", (chunk) => {
3073
- buffer += chunk;
3074
- });
3075
- process.stdin.on("end", () => resolve(buffer));
3076
- process.stdin.on("error", reject);
3077
- });
3078
- }
3079
- /**
3080
- * Build a small context object the matchers can use to check project
3081
- * state — e.g. "does this project have a src/config/index.ts?".
3082
- *
3083
- * Kept intentionally minimal to avoid pulling the full kick.config
3084
- * loader into a fast-path command. Matchers should treat this as
3085
- * best-effort and degrade gracefully when ctx is undefined.
3086
- */
3087
- function buildExplainContext() {
3088
- const cwd = process.cwd();
3089
- return {
3090
- cwd,
3091
- hasFile: (path) => existsSync(resolve(cwd, path))
3092
- };
3093
- }
3094
- function printDiagnosis(input, d, confidence, aiLabel = false) {
3095
- const inputSnippet = truncate(input.trim(), 200);
3096
- const label = aiLabel ? "AI-generated — verify before applying" : labelConfidence(confidence);
3097
- process.stdout.write(`\n Explaining: ${inputSnippet}\n`);
3098
- process.stdout.write(`\n Match: ${d.id} (${label})\n`);
3099
- process.stdout.write(` Title: ${d.title}\n`);
3100
- process.stdout.write(`\n Diagnosis:\n${indent(d.explanation, " ")}\n`);
3101
- process.stdout.write(`\n Fix:\n${indent(d.fix, " ")}\n`);
3102
- if (d.codeBefore) process.stdout.write(`\n Before:\n${indent(d.codeBefore, " ")}\n`);
3103
- if (d.codeAfter) process.stdout.write(`\n After:\n${indent(d.codeAfter, " ")}\n`);
3104
- if (d.docs) process.stdout.write(`\n Docs: ${d.docs}\n`);
3105
- process.stdout.write("\n");
3106
- }
3107
- function printNoMatch(input, aiRequested) {
3108
- const snippet = truncate(input.trim(), 200);
3109
- process.stdout.write(`\n Explaining: ${snippet}\n\n`);
3110
- if (aiRequested) process.stdout.write(" No known-issue matched, and --ai fallback is not yet wired.\n When @forinda/kickjs-ai ships its provider implementations,\n this command will call the configured LLM with the error +\n project context and return a structured fix.\n\n");
3111
- else process.stdout.write(" No known-issue matched. Things you can try:\n\n 1. Check the framework docs for the error keywords:\n https://forinda.github.io/kick-js/\n\n 2. Re-run with --ai to fall back to an LLM (requires\n @forinda/kickjs-ai with a configured provider):\n kick explain --ai \"<your error>\"\n\n 3. File an issue with the error text:\n https://github.com/forinda/kick-js/issues/new\n\n");
3112
- }
3113
- function indent(text, prefix) {
3114
- return text.split("\n").map((line) => `${prefix}${line}`).join("\n");
3115
- }
3116
- function truncate(text, max) {
3117
- if (text.length <= max) return text;
3118
- return text.slice(0, max - 1) + "…";
3119
- }
3120
- function labelConfidence(score) {
3121
- if (score >= 90) return "high confidence";
3122
- if (score >= 70) return "good match";
3123
- if (score >= 50) return "medium confidence";
3124
- return "low confidence — verify manually";
3125
- }
3126
- //#endregion
3127
- //#region src/commands/mcp.ts
3128
- /**
3129
- * `kick mcp` — Model Context Protocol commands.
3130
- *
3131
- * Two subcommands:
3132
- * - `kick mcp` (default → `start`): runs the built application as an
3133
- * MCP server over stdio. The user's app must already wire `McpAdapter`
3134
- * from `@forinda/kickjs-mcp` into its bootstrap. The CLI just spawns
3135
- * the built entry as a subprocess with `KICK_MCP_STDIO=1`, which the
3136
- * adapter detects and uses to switch its transport from
3137
- * StreamableHTTP to stdio. The subprocess inherits stdin/stdout/stderr
3138
- * so the MCP wire protocol flows directly between the parent process
3139
- * (the MCP client — Claude Code, Cursor, etc.) and the child app.
3140
- * - `kick mcp init`: generates a `.mcp.json` config file pointing at
3141
- * this project, ready to drop into a Claude Code / Cursor workspace.
3142
- *
3143
- * Logs MUST go to stderr in stdio mode — anything written to stdout
3144
- * corrupts the JSON-RPC protocol stream. Pino's default stream is
3145
- * stderr already, so this works out of the box for KickJS apps using
3146
- * the framework's bundled logger.
3147
- */
3148
- function registerMcpCommand(program) {
3149
- const mcp = program.command("mcp").description("Model Context Protocol commands (start | init)");
3150
- mcp.command("start", { isDefault: true }).description("Run the built application as an MCP server over stdio").option("-e, --entry <file>", "Entry file", "dist/index.js").option("--node-arg <arg...>", "Extra arguments to pass to node").action(runMcpServer);
3151
- mcp.command("init").description("Generate .mcp.json for Claude Code / Cursor / Zed").option("-n, --name <name>", "Server name (defaults to package.json name)").option("-o, --out <file>", "Output file", ".mcp.json").option("-f, --force", "Overwrite an existing entry without prompting").option("--global", "Write to ~/.mcp.json instead of the project root").action(initMcpConfig);
3152
- }
3153
- function runMcpServer(opts) {
3154
- const entry = resolve(opts.entry);
3155
- if (!existsSync(entry)) {
3156
- process.stderr.write(`Error: entry file not found: ${entry}\n\nBuild the app first with \`kick build\`, or pass a custom entry:\n kick mcp -e dist/server.js\n`);
3157
- process.exit(1);
3158
- }
3159
- const nodeArgs = [...opts.nodeArg ?? [], entry];
3160
- const child = spawn(process.execPath, nodeArgs, {
3161
- stdio: "inherit",
3162
- env: {
3163
- ...process.env,
3164
- KICK_MCP_STDIO: "1",
3165
- NODE_ENV: process.env.NODE_ENV ?? "production"
3166
- }
3167
- });
3168
- child.on("error", (err) => {
3169
- process.stderr.write(`Failed to start MCP server: ${err.message}\n`);
3170
- process.exit(1);
3171
- });
3172
- child.on("exit", (code, signal) => {
3173
- if (signal) {
3174
- process.kill(process.pid, signal);
3175
- return;
3176
- }
3177
- process.exit(code ?? 0);
3178
- });
3179
- const forward = (signal) => {
3180
- if (!child.killed) child.kill(signal);
3181
- };
3182
- process.on("SIGINT", () => forward("SIGINT"));
3183
- process.on("SIGTERM", () => forward("SIGTERM"));
3184
- }
3185
- function initMcpConfig(opts) {
3186
- const cwd = process.cwd();
3187
- const projectName = readPackageName(cwd) ?? basename(cwd);
3188
- const serverName = opts.name ?? projectName;
3189
- const outPath = opts.global ? resolve(process.env.HOME ?? ".", ".mcp.json") : resolve(cwd, opts.out);
3190
- const entry = {
3191
- command: "kick",
3192
- args: ["mcp"],
3193
- cwd
3194
- };
3195
- let config = { mcpServers: {} };
3196
- if (existsSync(outPath)) try {
3197
- const raw = readFileSync(outPath, "utf8");
3198
- const parsed = JSON.parse(raw);
3199
- if (parsed && typeof parsed === "object" && parsed.mcpServers) config = { mcpServers: { ...parsed.mcpServers } };
3200
- } catch (err) {
3201
- const message = err instanceof Error ? err.message : String(err);
3202
- process.stderr.write(`Error: existing ${outPath} is not valid JSON (${message}).\nFix the file or pass --force to overwrite the entry.\n`);
3203
- process.exit(1);
3204
- }
3205
- if (config.mcpServers[serverName] && !opts.force) {
3206
- process.stderr.write(`Error: an entry for "${serverName}" already exists in ${outPath}.\nPass --force to overwrite it, or use --name to pick a different key.\n`);
3207
- process.exit(1);
3208
- }
3209
- config.mcpServers[serverName] = entry;
3210
- writeFileSync(outPath, JSON.stringify(config, null, 2) + "\n", "utf8");
3211
- process.stdout.write(`\n ✓ Wrote MCP server entry "${serverName}" to ${outPath}\n\n To activate it:\n 1. Build your app: kick build\n 2. Restart your MCP client (Claude Code, Cursor, Zed)\n 3. The server should appear in the client's tool picker\n\n`);
3212
- }
3213
- /**
3214
- * Read the `name` field from the project's `package.json`. Returns
3215
- * null if the file is missing or unparseable — callers fall back to
3216
- * the directory name in that case.
3217
- */
3218
- function readPackageName(cwd) {
3219
- const pkgPath = resolve(cwd, "package.json");
3220
- if (!existsSync(pkgPath)) return null;
3221
- try {
3222
- const raw = readFileSync(pkgPath, "utf8");
3223
- const parsed = JSON.parse(raw);
3224
- if (typeof parsed.name === "string") return parsed.name;
3225
- return null;
3226
- } catch {
3227
- return null;
3228
- }
3229
- }
3230
- //#endregion
3231
- //#region src/commands/tinker.ts
3232
- function registerTinkerCommand(program) {
3233
- program.command("tinker").description("Interactive REPL with DI container and services loaded").option("-e, --entry <file>", "Entry file to load", "src/index.ts").action(async (opts) => {
3234
- const cwd = process.cwd();
3235
- const entryPath = resolve(cwd, opts.entry);
3236
- if (!existsSync(entryPath)) {
3237
- console.error(`\n Error: ${opts.entry} not found.\n`);
3238
- process.exit(1);
3239
- }
3240
- const tsxBin = findBin(cwd, "tsx");
3241
- if (!tsxBin) {
3242
- console.error("\n Error: tsx not found. Install it: pnpm add -D tsx\n");
3243
- process.exit(1);
3244
- }
3245
- const tinkerScript = generateTinkerScript(entryPath, opts.entry);
3246
- const tmpFile = join(cwd, ".kick-tinker.mjs");
3247
- const { writeFileSync, unlinkSync } = await import("node:fs");
3248
- writeFileSync(tmpFile, tinkerScript, "utf-8");
3249
- try {
3250
- const child = fork(tmpFile, [], {
3251
- cwd,
3252
- execPath: tsxBin,
3253
- stdio: "inherit"
3254
- });
3255
- await new Promise((resolve) => {
3256
- child.on("exit", () => resolve());
3257
- });
3258
- } finally {
3259
- try {
3260
- unlinkSync(tmpFile);
3261
- } catch {}
3262
- }
3263
- });
3264
- }
3265
- function generateTinkerScript(entryPath, displayPath) {
3266
- return `
3267
- import 'reflect-metadata'
3268
-
3269
- // Prevent bootstrap() from starting the HTTP server
3270
- process.env.KICK_TINKER = '1'
3271
-
3272
- console.log('\\n 🔧 KickJS Tinker')
3273
- console.log(' Loading: ${displayPath}\\n')
3274
-
3275
- // Load core
3276
- let Container, Logger, HttpException, HttpStatus
3277
- try {
3278
- const core = await import('@forinda/kickjs')
3279
- Container = core.Container
3280
- Logger = core.Logger
3281
- HttpException = core.HttpException
3282
- HttpStatus = core.HttpStatus
3283
- } catch {
3284
- console.error(' Error: @forinda/kickjs not found.')
3285
- console.error(' Install it: pnpm add @forinda/kickjs\\n')
3286
- process.exit(1)
3287
- }
3288
-
3289
- // Load entry to trigger decorator registration
3290
- try {
3291
- await import('${pathToFileURL(entryPath).href}')
3292
- } catch (err) {
3293
- console.warn(' Warning: ' + err.message)
3294
- console.warn(' Container may be partially initialized.\\n')
3295
- }
3296
-
3297
- const container = Container.getInstance()
3298
-
3299
- // Start REPL
3300
- const repl = await import('node:repl')
3301
- const server = repl.start({ prompt: 'kick> ', useGlobal: true })
3302
-
3303
- server.context.container = container
3304
- server.context.Container = Container
3305
- server.context.resolve = (token) => container.resolve(token)
3306
- server.context.Logger = Logger
3307
- server.context.HttpException = HttpException
3308
- server.context.HttpStatus = HttpStatus
3309
-
3310
- console.log(' Available globals:')
3311
- console.log(' container — DI container instance')
3312
- console.log(' resolve(T) — shorthand for container.resolve(T)')
3313
- console.log(' Container, Logger, HttpException, HttpStatus')
3314
- console.log()
3315
-
3316
- server.on('exit', () => {
3317
- console.log('\\n Goodbye!\\n')
3318
- process.exit(0)
3319
- })
3320
- `;
3321
- }
3322
- function findBin(startDir, name) {
3323
- let dir = startDir;
3324
- while (true) {
3325
- const candidate = join(dir, "node_modules", ".bin", name);
3326
- if (existsSync(candidate)) return candidate;
3327
- const parent = resolve(dir, "..");
3328
- if (parent === dir) break;
3329
- dir = parent;
3330
- }
3331
- return null;
3332
- }
3333
- //#endregion
3334
- //#region src/generators/remove-module.ts
3335
- /**
3336
- * Remove a module — deletes its directory and unregisters it from the modules index.
3337
- */
3338
- async function removeModule(options) {
3339
- const { name, modulesDir, force } = options;
3340
- const shouldPluralize = options.pluralize !== false;
3341
- const kebab = toKebabCase(name);
3342
- const pascal = toPascalCase(name);
3343
- const plural = shouldPluralize ? pluralize(kebab) : kebab;
3344
- const moduleDir = join(modulesDir, plural);
3345
- if (!await fileExists(moduleDir)) {
3346
- console.log(`\n Module not found: ${moduleDir}\n`);
3347
- return;
3348
- }
3349
- if (!force) {
3350
- if (!await confirm({
3351
- message: pc.red(`Delete module '${plural}' at ${moduleDir}? This cannot be undone.`),
3352
- initialValue: false
3353
- })) {
3354
- console.log("\n Cancelled.\n");
3355
- return;
3356
- }
3357
- }
3358
- await rm(moduleDir, {
3359
- recursive: true,
3360
- force: true
3361
- });
3362
- console.log(` Deleted: ${moduleDir}`);
3363
- const indexPath = join(modulesDir, "index.ts");
3364
- if (await fileExists(indexPath)) {
3365
- let content = await readFile(indexPath, "utf-8");
3366
- const originalContent = content;
3367
- const importPattern = new RegExp(`^import\\s*\\{\\s*${pascal}Module\\s*\\}\\s*from\\s*['"][^'"]*${plural}(?:/[^'"]*)?['"].*\\n?`, "gm");
3368
- content = content.replace(importPattern, "");
3369
- content = content.replace(new RegExp(`\\s*,?\\s*${pascal}Module\\s*,?`, "g"), (match) => {
3370
- const startsWithComma = match.trimStart().startsWith(",");
3371
- const endsWithComma = match.trimEnd().endsWith(",");
3372
- if (startsWithComma && endsWithComma) return ",";
3373
- return "";
3374
- });
3375
- content = content.replace(/,(\s*])/, "$1");
3376
- content = content.replace(/\n{3,}/g, "\n\n");
3377
- if (content !== originalContent) {
3378
- await writeFile(indexPath, content, "utf-8");
3379
- console.log(` Unregistered: ${pascal}Module from ${indexPath}`);
3380
- }
3381
- }
3382
- console.log(`\n Module '${plural}' removed.\n`);
3383
- }
3384
- //#endregion
3385
- //#region src/commands/remove.ts
3386
- function registerRemoveCommand(program) {
3387
- program.command("remove").alias("rm").description("Remove generated code").command("module <names...>").description("Remove one or more modules (e.g. kick rm module user task)").option("--modules-dir <dir>", "Modules directory").option("--no-pluralize", "Use singular module name").option("-f, --force", "Skip confirmation prompt").action(async (names, opts) => {
3388
- const mc = resolveModuleConfig(await loadKickConfig(process.cwd()));
3389
- const modulesDir = opts.modulesDir ?? mc.dir ?? "src/modules";
3390
- const shouldPluralize = opts.pluralize === false ? false : mc.pluralize ?? true;
3391
- for (const name of names) await removeModule({
3392
- name,
3393
- modulesDir: resolve(modulesDir),
3394
- force: opts.force,
3395
- pluralize: shouldPluralize
3396
- });
3397
- });
3398
- }
3399
- //#endregion
3400
- //#region src/commands/typegen.ts
3401
- /**
3402
- * Parse the `--schema-validator` CLI flag. Returns `undefined` if the
3403
- * flag was not passed (so the config default applies), `'zod'` if a
3404
- * supported value was passed, or `false` if explicitly disabled.
3405
- */
3406
- function parseSchemaValidatorFlag(value) {
3407
- if (value === void 0) return void 0;
3408
- if (value === "false" || value === "off" || value === "none") return false;
3409
- if (value === "zod") return "zod";
3410
- console.warn(` kick typegen: unknown --schema-validator '${value}' (only 'zod' and 'false' are supported). Falling back to project config.`);
3411
- }
3412
- /**
3413
- * Parse the `--env-file` CLI flag. Returns `undefined` to fall through
3414
- * to the config default, `false` when the user disables env typing
3415
- * with `--env-file false`, or the literal path string otherwise.
3416
- */
3417
- function parseEnvFileFlag(value) {
3418
- if (value === void 0) return void 0;
3419
- if (value === "false" || value === "off" || value === "none") return false;
3420
- return value;
3421
- }
3422
- function registerTypegenCommand(program) {
3423
- program.command("typegen").description("Generate type-safe DI registry and module types into .kickjs/types/").option("-w, --watch", "Watch source files and regenerate on change").option("-s, --src <dir>", "Source directory to scan", "src").option("-o, --out <dir>", "Output directory", ".kickjs/types").option("--silent", "Suppress output").option("--allow-duplicates", "Auto-namespace duplicate class names instead of failing (use with caution)").option("--schema-validator <name>", "Schema validator for body/query/params typing (currently 'zod' or 'false')").option("--env-file <path>", "Path to env schema file for KickEnv typing (default 'src/env.ts'; pass 'false' to disable)").option("--check", "CI gate: fail on plugin-typegen drift instead of writing").option("--list", "List every registered typegen plugin id (use to populate `typegen.disable`)").action(async (opts) => {
3424
- const cwd = process.cwd();
3425
- const config = await loadKickConfig(cwd);
3426
- if (opts.list) {
3427
- const { mergeCliPlugins } = await import("./plugin-6_YlK-JG.mjs").then((n) => n.t);
3428
- const { builtinCliPlugins } = await import("./builtins-C_VfEGdg.mjs");
3429
- const merged = mergeCliPlugins([...builtinCliPlugins, ...config?.plugins ?? []], config?.commands ?? []);
3430
- const disabled = new Set(config?.typegen?.disable ?? []);
3431
- if (merged.typegens.length === 0) {
3432
- console.log(" No typegen plugins registered.");
3433
- return;
3434
- }
3435
- const idWidth = Math.max(...merged.typegens.map((t) => t.id.length));
3436
- console.log("\n Registered typegen plugins:\n");
3437
- for (const tg of merged.typegens) {
3438
- const status = disabled.has(tg.id) ? " (disabled)" : "";
3439
- console.log(` ${tg.id.padEnd(idWidth + 2)}inputs: ${tg.inputs.join(", ") || "(none)"}${status}`);
3440
- }
3441
- console.log();
3442
- return;
3443
- }
3444
- const schemaValidator = parseSchemaValidatorFlag(opts.schemaValidator) ?? config?.typegen?.schemaValidator ?? "zod";
3445
- const envFile = parseEnvFileFlag(opts.envFile) ?? config?.typegen?.envFile;
3446
- const baseOpts = {
3447
- cwd,
3448
- srcDir: opts.src ?? config?.typegen?.srcDir,
3449
- outDir: opts.out ?? config?.typegen?.outDir,
3450
- silent: opts.silent,
3451
- allowDuplicates: opts.allowDuplicates,
3452
- schemaValidator,
3453
- envFile,
3454
- assetMap: config?.assetMap,
3455
- runPlugins: false
3456
- };
3457
- try {
3458
- if (opts.watch) {
3459
- const stop = await watchTypegen(baseOpts);
3460
- if (!opts.silent) console.log(" kick typegen: watching for changes (Ctrl-C to exit)");
3461
- const shutdown = () => {
3462
- stop();
3463
- process.exit(0);
3464
- };
3465
- process.on("SIGINT", shutdown);
3466
- process.on("SIGTERM", shutdown);
3467
- await new Promise(() => {});
3468
- } else {
3469
- await runTypegen$1(baseOpts);
3470
- const results = await runAllPluginTypegens({
3471
- cwd,
3472
- config: config ?? null,
3473
- silent: opts.silent,
3474
- check: opts.check
3475
- });
3476
- if (opts.check && results.some((r) => r.status === "written")) process.exit(1);
3477
- }
3478
- } catch (err) {
3479
- if (err instanceof TokenCollisionError) console.error("\n" + err.message + "\n");
3480
- else if (err instanceof Error) console.error(`\n kick typegen failed: ${err.message}`);
3481
- else console.error(`\n kick typegen failed: ${JSON.stringify(err)}`);
3482
- process.exit(1);
3483
- }
3484
- });
3485
- }
3486
- //#endregion
3487
- //#region src/commands/check.ts
3488
- /** Recursively collect all .ts files under a directory */
3489
- function collectTsFiles(dir) {
3490
- const files = [];
3491
- if (!existsSync(dir)) return files;
3492
- const entries = readdirSync(dir, { withFileTypes: true });
3493
- for (const entry of entries) {
3494
- const fullPath = join(dir, entry.name);
3495
- if (entry.isDirectory()) {
3496
- if ([
3497
- "node_modules",
3498
- "dist",
3499
- ".kickjs",
3500
- ".git"
3501
- ].includes(entry.name)) continue;
3502
- files.push(...collectTsFiles(fullPath));
3503
- } else if (entry.isFile() && /\.tsx?$/.test(entry.name) && !entry.name.endsWith(".d.ts")) files.push(fullPath);
3504
- }
3505
- return files;
3506
- }
3507
- /** Read a file safely, returning empty string on failure */
3508
- function safeRead(filepath) {
3509
- try {
3510
- return readFileSync(filepath, "utf-8");
3511
- } catch {
3512
- return "";
3513
- }
3514
- }
3515
- const WEAK_SECRETS = new Set([
3516
- "secret",
3517
- "changeme",
3518
- "password",
3519
- "test",
3520
- "default",
3521
- ""
3522
- ]);
3523
- function checkJwtSecret(cwd, sourceContents) {
3524
- const envContent = safeRead(join(cwd, ".env"));
3525
- if (envContent) {
3526
- const match = envContent.match(/^JWT_SECRET\s*=\s*['"]?([^'"\n]*)['"]?/m);
3527
- if (match) {
3528
- const value = match[1].trim();
3529
- if (WEAK_SECRETS.has(value.toLowerCase()) || value.length < 32) return {
3530
- severity: "CRITICAL",
3531
- message: "JWT_SECRET appears to be a default value or too short (< 32 chars) — change it"
3532
- };
3533
- }
3534
- }
3535
- for (const content of sourceContents) for (const pattern of [/JWT_SECRET['"]?\s*[:=]\s*['"]?(secret|changeme|password|test|default)['"]?/i, /secret\s*[:=]\s*['"]?(secret|changeme|password|test|default)['"]?/i]) if (pattern.test(content)) return {
3536
- severity: "CRITICAL",
3537
- message: "JWT_SECRET appears to be a default value in source code — use an environment variable"
3538
- };
3539
- return null;
3540
- }
3541
- function checkCorsOrigin(sourceContents) {
3542
- for (const content of sourceContents) if (/cors\s*\(/.test(content) && /origin\s*:\s*['"]\*['"]/.test(content)) return {
3543
- severity: "CRITICAL",
3544
- message: "CORS origin is '*' — restrict to your domains"
3545
- };
3546
- return null;
3547
- }
3548
- function checkRateLimiting(sourceContents) {
3549
- for (const content of sourceContents) if (/rateLimit/i.test(content) || /@RateLimit/i.test(content)) return null;
3550
- return {
3551
- severity: "WARNING",
3552
- message: "No rate limiting detected — add rateLimit() middleware or @RateLimit decorator"
3553
- };
3554
- }
3555
- function checkNodeEnv() {
3556
- if (process.env.NODE_ENV !== "production") return {
3557
- severity: "WARNING",
3558
- message: `NODE_ENV is '${process.env.NODE_ENV ?? "undefined"}', not 'production'`
3559
- };
3560
- return null;
3561
- }
3562
- function checkTokenStore(sourceContents) {
3563
- let hasTokenStore = false;
3564
- let usesMemoryStore = false;
3565
- for (const content of sourceContents) {
3566
- if (/tokenStore/i.test(content)) hasTokenStore = true;
3567
- if (/MemoryTokenStore/i.test(content)) usesMemoryStore = true;
3568
- }
3569
- if (usesMemoryStore) return {
3570
- severity: "WARNING",
3571
- message: "MemoryTokenStore detected — use a persistent store (Redis, DB) for production deployments"
3572
- };
3573
- if (!hasTokenStore) return {
3574
- severity: "WARNING",
3575
- message: "No token revocation store detected — consider adding one for auth token management"
3576
- };
3577
- return null;
3578
- }
3579
- function checkHelmet(sourceContents) {
3580
- for (const content of sourceContents) if (/helmet\s*\(/.test(content)) {
3581
- if (/security\s*\.\s*helmet\s*.*false/.test(content)) return {
3582
- severity: "WARNING",
3583
- message: "Helmet security headers are disabled — enable them for production"
3584
- };
3585
- return {
3586
- severity: "INFO",
3587
- message: "Helmet security headers active"
3588
- };
3589
- }
3590
- return {
3591
- severity: "WARNING",
3592
- message: "Helmet not detected — add helmet() middleware for security headers"
3593
- };
3594
- }
3595
- function checkAuthAdapter(sourceContents) {
3596
- for (const content of sourceContents) if (/AuthAdapter/i.test(content)) return {
3597
- severity: "INFO",
3598
- message: "AuthAdapter configured"
3599
- };
3600
- return {
3601
- severity: "INFO",
3602
- message: "No AuthAdapter detected — add one if your app requires authentication"
3603
- };
3604
- }
3605
- function runDeployChecks(cwd) {
3606
- const sourceContents = collectTsFiles(join(cwd, "src")).map((f) => safeRead(f));
3607
- const results = [];
3608
- const jwtResult = checkJwtSecret(cwd, sourceContents);
3609
- if (jwtResult) results.push(jwtResult);
3610
- const corsResult = checkCorsOrigin(sourceContents);
3611
- if (corsResult) results.push(corsResult);
3612
- const rateLimitResult = checkRateLimiting(sourceContents);
3613
- if (rateLimitResult) results.push(rateLimitResult);
3614
- const nodeEnvResult = checkNodeEnv();
3615
- if (nodeEnvResult) results.push(nodeEnvResult);
3616
- const tokenStoreResult = checkTokenStore(sourceContents);
3617
- if (tokenStoreResult) results.push(tokenStoreResult);
3618
- results.push(checkHelmet(sourceContents));
3619
- results.push(checkAuthAdapter(sourceContents));
3620
- return results;
3621
- }
3622
- function registerCheckCommand(program) {
3623
- program.command("check").description("Audit project for common issues").option("--deploy", "Run production readiness checks").action((opts) => {
3624
- if (!opts.deploy) {
3625
- console.log("\n Usage: kick check --deploy\n\n Available checks:\n --deploy Audit for production readiness (security, config, best practices)\n");
3626
- return;
3627
- }
3628
- const cwd = process.cwd();
3629
- intro("KickJS Deploy Check");
3630
- const s = spinner();
3631
- s.start("Scanning project...");
3632
- const results = runDeployChecks(cwd);
3633
- s.stop("Scan complete");
3634
- const order = {
3635
- CRITICAL: 0,
3636
- WARNING: 1,
3637
- INFO: 2
3638
- };
3639
- results.sort((a, b) => order[a.severity] - order[b.severity]);
3640
- for (const r of results) log.message(`${severityColor(r.severity)} ${r.message}`);
3641
- const critical = results.filter((r) => r.severity === "CRITICAL").length;
3642
- const warnings = results.filter((r) => r.severity === "WARNING").length;
3643
- const info = results.filter((r) => r.severity === "INFO").length;
3644
- const warnLabel = warnings === 1 ? "warning" : "warnings";
3645
- const summary = [
3646
- critical > 0 ? pc.red(`${critical} critical`) : `${critical} critical`,
3647
- warnings > 0 ? pc.yellow(`${warnings} ${warnLabel}`) : `${warnings} ${warnLabel}`,
3648
- `${info} info`
3649
- ].join(", ");
3650
- if (critical > 0) {
3651
- outro(pc.red(`${summary} — fix critical issues before deploying`));
3652
- process.exit(1);
3653
- } else outro(pc.green(`${summary} — looking good!`));
3654
- });
3655
- }
3656
- //#endregion
3657
- //#region src/commands/db.ts
3658
- async function loadConfig(opts) {
3659
- return resolveDbConfig({ configPath: path.resolve(process.cwd(), opts.config) });
3660
- }
3661
- /**
3662
- * Resolve a MigrationAdapter from config:
3663
- * 1. config.adapter() — explicit factory wins.
3664
- * 2. config.connectionString — built-in pgAdapter path; we dynamically
3665
- * import @forinda/kickjs-db-pg + pg so the CLI doesn't pull pg into
3666
- * non-PG workflows.
3667
- */
3668
- async function resolveAdapter(config) {
3669
- if (config.adapter) {
3670
- const adapter = await config.adapter();
3671
- return {
3672
- adapter,
3673
- cleanup: async () => adapter.close()
3674
- };
3675
- }
3676
- if (!config.connectionString) throw new Error("kickjs-db: no adapter resolved — set db.connectionString (or DATABASE_URL) in kick.config.ts, or supply db.adapter() factory");
3677
- const dialect = config.dialect ?? "postgres";
3678
- if (dialect !== "postgres") throw new Error(`kickjs-db: built-in CLI adapter only supports postgres in M1 (dialect=${dialect}); use db.adapter() factory for other dialects`);
3679
- const [{ pgAdapter }, pg] = await Promise.all([import("@forinda/kickjs-db-pg"), import("pg")]);
3680
- const pool = new pg.default.Pool({ connectionString: config.connectionString });
3681
- const adapter = pgAdapter({ pool });
3682
- return {
3683
- adapter,
3684
- cleanup: async () => {
3685
- await adapter.close();
3686
- await pool.end();
3687
- }
3688
- };
3689
- }
3690
- function printStatusTable(status) {
3691
- if (status.length === 0) {
3692
- console.log("No migrations.");
3693
- return;
3694
- }
3695
- console.table(status.map((s) => ({
3696
- id: s.id,
3697
- state: s.state,
3698
- batch: s.batch ?? "-",
3699
- reviewed: s.reviewed,
3700
- applied: s.appliedAt ?? "-"
3701
- })));
3702
- }
3703
- function registerDbCommands(program) {
3704
- const db = program.command("db").description("Database commands (kickjs-db)");
3705
- db.command("generate <name>").description("Generate a new migration from schema diff").option("-c, --config <path>", "Path to kick.config.ts", "kick.config.ts").option("-e, --empty", "Skip schema diff and create an empty migration shell (data migration, seed, freeform SQL)").action(async (name, opts) => {
3706
- const cwd = process.cwd();
3707
- const result = await generate({
3708
- name,
3709
- config: await loadConfig(opts),
3710
- cwd,
3711
- empty: opts.empty
3712
- });
3713
- if (result.status === "no-changes") {
3714
- console.log("No schema changes detected.");
3715
- return;
3716
- }
3717
- if (result.empty) {
3718
- console.log(`Created empty migration ${result.migrationDir} (author up.sql + down.sql).`);
3719
- return;
3720
- }
3721
- const plural = result.changeCount === 1 ? "" : "s";
3722
- console.log(`Created migration ${result.migrationDir} (${result.changeCount} change${plural}).`);
3723
- });
3724
- const migrate = db.command("migrate").description("Migration runner subcommands");
3725
- migrate.command("latest").description("Apply all pending migrations in a new batch").option("-c, --config <path>", "Path to kick.config.ts", "kick.config.ts").action(async (opts) => {
3726
- const config = await loadConfig(opts);
3727
- const { adapter, cleanup } = await resolveAdapter(config);
3728
- try {
3729
- const r = await migrateLatest({
3730
- adapter,
3731
- migrationsDir: config.migrationsDir
3732
- });
3733
- if (r.applied.length === 0) console.log("No pending migrations.");
3734
- else console.log(`Applied batch ${r.batch}: ${r.applied.join(", ")}`);
3735
- } finally {
3736
- await cleanup();
3737
- }
3738
- });
3739
- migrate.command("up").description("Apply the next single pending migration").option("-c, --config <path>", "Path to kick.config.ts", "kick.config.ts").action(async (opts) => {
3740
- const config = await loadConfig(opts);
3741
- const { adapter, cleanup } = await resolveAdapter(config);
3742
- try {
3743
- const r = await migrateUp({
3744
- adapter,
3745
- migrationsDir: config.migrationsDir
3746
- });
3747
- if (r.applied.length === 0) console.log("No pending migrations.");
3748
- else console.log(`Applied ${r.applied[0]} (batch ${r.batch})`);
3749
- } finally {
3750
- await cleanup();
3751
- }
3752
- });
3753
- migrate.command("down").description("Reverse the most recent applied migration").option("-c, --config <path>", "Path to kick.config.ts", "kick.config.ts").action(async (opts) => {
3754
- const config = await loadConfig(opts);
3755
- const { adapter, cleanup } = await resolveAdapter(config);
3756
- try {
3757
- const r = await migrateDown({
3758
- adapter,
3759
- migrationsDir: config.migrationsDir
3760
- });
3761
- if (!r.reversed) console.log("Nothing to reverse.");
3762
- else console.log(`Reversed ${r.reversed}.`);
3763
- } finally {
3764
- await cleanup();
3765
- }
3766
- });
3767
- migrate.command("rollback").description("Reverse the entire last batch as a single unit").option("-c, --config <path>", "Path to kick.config.ts", "kick.config.ts").action(async (opts) => {
3768
- const config = await loadConfig(opts);
3769
- const { adapter, cleanup } = await resolveAdapter(config);
3770
- try {
3771
- const r = await migrateRollback({
3772
- adapter,
3773
- migrationsDir: config.migrationsDir
3774
- });
3775
- if (r.reversed.length === 0) console.log("Nothing to roll back.");
3776
- else console.log(`Rolled back batch ${r.batch}: ${r.reversed.join(", ")}`);
3777
- } finally {
3778
- await cleanup();
3779
- }
3780
- });
3781
- migrate.command("status").description("Print applied + pending migrations").option("-c, --config <path>", "Path to kick.config.ts", "kick.config.ts").action(async (opts) => {
3782
- const config = await loadConfig(opts);
3783
- const { adapter, cleanup } = await resolveAdapter(config);
3784
- try {
3785
- printStatusTable(await migrateStatus({
3786
- adapter,
3787
- migrationsDir: config.migrationsDir
3788
- }));
3789
- } finally {
3790
- await cleanup();
3791
- }
3792
- });
3793
- db.command("introspect").description("Generate a TypeScript schema file from a live database").option("-c, --config <path>", "Path to kick.config.ts", "kick.config.ts").option("--out <path>", "Output file (defaults to db.schemaPath from config)").option("--json", "Print the raw SchemaSnapshot JSON to stdout instead of writing TS source").action(async (opts) => {
3794
- const config = await loadConfig(opts);
3795
- const { adapter, cleanup } = await resolveAdapter(config);
3796
- try {
3797
- const snapshot = await adapter.introspect();
3798
- if (opts.json) {
3799
- console.log(JSON.stringify(snapshot, null, 2));
3800
- return;
3801
- }
3802
- const out = opts.out ?? config.schemaPath;
3803
- await writeFile(out, renderSchemaSource(snapshot), "utf8");
3804
- const tableCount = Object.keys(snapshot.tables).length;
3805
- console.log(`Wrote ${out} (${tableCount} table${tableCount === 1 ? "" : "s"}).`);
3806
- } finally {
3807
- await cleanup();
3808
- }
3809
- });
3810
- }
3811
- //#endregion
3812
- //#region src/typegen/builtin/db.ts
3813
- const DEFAULT_SCHEMA_PATHS = [
3814
- "src/db/schema.ts",
3815
- "src/db/schema/index.ts",
3816
- "src/db/schema"
3817
- ];
3818
- const kickDbTypegen = () => ({
3819
- id: "kick/db",
3820
- inputs: ["src/db/schema.ts", "src/db/schema/**/*.ts"],
3821
- async generate(ctx) {
3822
- const schemaAbs = resolveSchema(ctx.cwd);
3823
- if (!schemaAbs) return null;
3824
- const typesDir = path.resolve(ctx.cwd, ".kickjs/types");
3825
- return [
3826
- `import type { SchemaToTypes, KickDbClient } from '@forinda/kickjs-db'`,
3827
- `import type * as appSchema from '${posix(path.relative(typesDir, schemaAbs)).replace(/\.ts$/, "").replace(/\/index$/, "")}'`,
3828
- ``,
3829
- `declare global {`,
3830
- ` interface KickDbSchema extends SchemaToTypes<typeof appSchema> {}`,
3831
- `}`,
3832
- ``,
3833
- `declare module '@forinda/kickjs-db' {`,
3834
- ` interface KickDbRegister {`,
3835
- ` db: KickDbClient<KickDbSchema>`,
3836
- ` }`,
3837
- `}`
3838
- ].join("\n");
3839
- }
3840
- });
3841
- function resolveSchema(cwd) {
3842
- for (const candidate of DEFAULT_SCHEMA_PATHS) {
3843
- const abs = path.resolve(cwd, candidate);
3844
- if (candidate.endsWith(".ts")) {
3845
- if (existsSync(abs)) return abs;
3846
- } else {
3847
- const idx = path.join(abs, "index.ts");
3848
- if (existsSync(idx)) return idx;
3849
- }
3850
- }
3851
- return null;
3852
- }
3853
- function posix(p) {
3854
- return p.replace(/\\/g, "/");
3855
- }
3856
- //#endregion
3857
- //#region src/typegen/builtin/assets.ts
3858
- const kickAssetsTypegen = () => ({
3859
- id: "kick/assets",
3860
- inputs: [
3861
- "kick.config.ts",
3862
- "kick.config.js",
3863
- "kick.config.mjs"
3864
- ],
3865
- async generate(ctx) {
3866
- if (!existsSync(path.resolve(ctx.cwd, "kick.config.ts"))) return null;
3867
- const config = await loadKickConfig(ctx.cwd);
3868
- if (!config?.assetMap) return null;
3869
- const discovered = discoverAssets(config.assetMap, ctx.cwd);
3870
- if (discovered.count === 0) return null;
3871
- return renderAssetTypes(discovered);
3872
- }
3873
- });
3874
- //#endregion
3875
- //#region src/typegen/render/routes.ts
3876
- const ROUTES_HEADER = `/* eslint-disable */
3877
- // AUTO-GENERATED by \`kick typegen\`. DO NOT EDIT.
3878
- // Re-run with \`kick typegen\` or rely on \`kick dev\` to refresh.
3879
- `;
3880
- /**
3881
- * Render the `KickRoutes` global namespace augmentation. Each interface
3882
- * inside corresponds to a controller class; each property is a single
3883
- * route method on that controller, conforming to `RouteShape`.
3884
- *
3885
- * Fills `params` from URL patterns, `query` from `@ApiQueryParams`, and
3886
- * `body`/`query`/`params` (when schema-validated) from the configured
3887
- * schema validator. `response` is emitted as `unknown`.
3888
- */
3889
- function renderRoutes(routes, routesOutFile, schemaValidator) {
3890
- if (routes.length === 0) return `${ROUTES_HEADER}
3891
- // (no routes discovered yet — annotate a controller method with
3892
- // @Get/@Post/@Put/@Delete/@Patch and re-run \`kick typegen\`)
3893
- declare global {
3894
- // eslint-disable-next-line @typescript-eslint/no-namespace
3895
- namespace KickRoutes {}
3896
- }
3897
-
3898
- export {}
3899
- `;
3900
- const byController = /* @__PURE__ */ new Map();
3901
- for (const r of routes) {
3902
- const arr = byController.get(r.controller) ?? [];
3903
- arr.push(r);
3904
- byController.set(r.controller, arr);
3905
- }
3906
- const schemaImports = /* @__PURE__ */ new Map();
3907
- const renderField = (schema, routeFilePath) => {
3908
- const alias = planSchemaImport(schema, routeFilePath, routesOutFile, schemaValidator, schemaImports);
3909
- return alias ? `import('zod').infer<typeof ${alias}>` : null;
3910
- };
3911
- const interfaces = [];
3912
- for (const [controller, methods] of byController) {
3913
- const lines = [` interface ${controller} {`];
3914
- for (const m of methods) {
3915
- const urlParamsType = m.pathParams.length > 0 ? `{ ${m.pathParams.map((p) => `${p}: string`).join("; ")} }` : "{}";
3916
- const bodySchemaType = renderField(m.bodySchema, m.filePath);
3917
- const querySchemaType = renderField(m.querySchema, m.filePath);
3918
- const paramsType = renderField(m.paramsSchema, m.filePath) ?? urlParamsType;
3919
- const bodyType = bodySchemaType ?? "unknown";
3920
- const queryType = querySchemaType ?? renderQueryShape(m);
3921
- const docLines = renderQueryDocLines(m);
3922
- lines.push(` /**`, ` * ${m.httpMethod} ${m.path}`, ...docLines.map((d) => ` * ${d}`), ` */`, ` ${m.method}: {`, ` params: ${paramsType}`, ` body: ${bodyType}`, ` query: ${queryType}`, ` response: unknown`, ` }`);
3923
- }
3924
- lines.push(" }");
3925
- interfaces.push(lines.join("\n"));
3926
- }
3927
- return `${ROUTES_HEADER}${renderSchemaImports(schemaImports)}
3928
- declare global {
3929
- // eslint-disable-next-line @typescript-eslint/no-namespace
3930
- namespace KickRoutes {
3931
- ${interfaces.join("\n")}
3932
- }
3933
- }
3934
-
3935
- export {}
3936
- `;
3937
- }
3938
- function renderQueryShape(m) {
3939
- if (m.queryFilterable === null) return "unknown";
3940
- const sortable = m.querySortable ?? [];
3941
- return `{ filter?: string | string[]; sort?: ${sortable.length > 0 ? sortable.flatMap((f) => [`'${f}'`, `'-${f}'`]).join(" | ") : "string"}; q?: string; page?: string; limit?: string }`;
3942
- }
3943
- function renderQueryDocLines(m) {
3944
- const lines = [];
3945
- if (m.queryFilterable && m.queryFilterable.length > 0) lines.push(`Filterable: ${m.queryFilterable.join(", ")}`);
3946
- if (m.querySortable && m.querySortable.length > 0) lines.push(`Sortable: ${m.querySortable.join(", ")}`);
3947
- if (m.querySearchable && m.querySearchable.length > 0) lines.push(`Searchable: ${m.querySearchable.join(", ")}`);
3948
- return lines;
3949
- }
3950
- /**
3951
- * Plan a schema import for hoisting at the top of `routes.ts`. Returns
3952
- * the alias the in-namespace code should use, or `null` if the schema
3953
- * cannot be referenced (no validator configured, or source unresolvable).
3954
- */
3955
- function planSchemaImport(schema, routeFilePath, routesOutFile, schemaValidator, imports) {
3956
- if (!schema || schemaValidator !== "zod") return null;
3957
- if (schema.source === null) return null;
3958
- const specifier = resolveSchemaImportSpecifier(schema.source, routeFilePath, routesOutFile);
3959
- if (specifier === "unknown") return null;
3960
- const key = `${specifier}::${schema.identifier}`;
3961
- let alias = imports.get(key)?.specifier;
3962
- if (!alias) {
3963
- alias = `_S${imports.size}`;
3964
- imports.set(key, {
3965
- identifier: schema.identifier,
3966
- specifier: alias
3967
- });
3968
- } else alias = imports.get(key).specifier;
3969
- return alias;
3970
- }
3971
- function renderSchemaImports(imports) {
3972
- if (imports.size === 0) return "";
3973
- const lines = [];
3974
- for (const [key, value] of imports) {
3975
- const [path] = key.split("::");
3976
- lines.push(`import type { ${value.identifier} as ${value.specifier} } from '${path}'`);
3977
- }
3978
- return lines.join("\n") + "\n";
3979
- }
3980
- /**
3981
- * Compute the import specifier the generated `routes.d.ts` should use
3982
- * to reach a schema declared either in the controller file (empty
3983
- * string) or imported from elsewhere (relative path or bare module).
3984
- */
3985
- function resolveSchemaImportSpecifier(source, routeFilePath, routesOutFile) {
3986
- if (source === null) return "unknown";
3987
- const routesDir = dirname(routesOutFile);
3988
- if (source === "") {
3989
- let rel = relative(routesDir, routeFilePath).split(sep).join("/");
3990
- rel = rel.replace(/\.(ts|tsx|mts|cts)$/i, "");
3991
- if (!rel.startsWith(".")) rel = "./" + rel;
3992
- return rel;
3993
- }
3994
- if (!source.startsWith(".") && !source.startsWith("/")) return source;
3995
- let rel = relative(routesDir, resolve(dirname(routeFilePath), source)).split(sep).join("/");
3996
- rel = rel.replace(/\.(ts|tsx|mts|cts)$/i, "");
3997
- if (!rel.startsWith(".")) rel = "./" + rel;
3998
- return rel;
3999
- }
4000
- //#endregion
4001
- //#region src/typegen/builtin/routes.ts
4002
- const kickRoutesTypegen = () => ({
4003
- id: "kick/routes",
4004
- outExtension: ".ts",
4005
- inputs: ["src/**/*.controller.ts", "src/**/*.module.ts"],
4006
- async generate(ctx) {
4007
- const scan = await ctx.getScanResult({
4008
- root: resolveSrcDir$1(ctx),
4009
- cwd: ctx.cwd,
4010
- envFile: resolveEnvFile$1(ctx)
4011
- });
4012
- const schemaValidator = ctx.config?.typegen?.schemaValidator ?? "zod";
4013
- const outFile = path.resolve(ctx.cwd, ".kickjs/types/kick__routes.ts");
4014
- return renderRoutes(scan.routes, outFile, schemaValidator);
4015
- }
4016
- });
4017
- function resolveSrcDir$1(ctx) {
4018
- return path.resolve(ctx.cwd, ctx.config?.typegen?.srcDir ?? "src");
4019
- }
4020
- function resolveEnvFile$1(ctx) {
4021
- const cfg = ctx.config?.typegen?.envFile;
4022
- if (cfg === false) return void 0;
4023
- return cfg;
4024
- }
4025
- //#endregion
4026
- //#region src/typegen/render/env.ts
4027
- const ENV_HEADER = `/* eslint-disable */
4028
- // AUTO-GENERATED by \`kick typegen\`. DO NOT EDIT.
4029
- // Re-run with \`kick typegen\` or rely on \`kick dev\` to refresh.
4030
- `;
4031
- /**
4032
- * Render the `KickEnv` + `NodeJS.ProcessEnv` augmentation file from a
4033
- * detected env schema. Returns `null` when no env file was discovered,
4034
- * so the caller can skip emission entirely (rather than emitting an
4035
- * empty augmentation that would shadow `KickEnv` to a useless `{}`).
4036
- */
4037
- function renderEnv(env, envOutFile) {
4038
- if (!env) return null;
4039
- let rel = relative(dirname(envOutFile), env.filePath).split(sep).join("/");
4040
- rel = rel.replace(/\.(ts|tsx|mts|cts)$/i, "");
4041
- if (!rel.startsWith(".")) rel = "./" + rel;
4042
- return `${ENV_HEADER}
4043
- // Importing the schema as a type lets us infer its shape without
4044
- // pulling in any runtime code. \`Awaited<>\` strips an accidental
4045
- // Promise wrap on dynamic-imported defaults.
4046
- import type _envSchema from '${rel}'
4047
-
4048
- // Local type alias — interfaces can only \`extend\` an identifier,
4049
- // not an inline import expression, so we resolve the schema's
4050
- // inferred shape into a named type first.
4051
- type _KickEnvShape = import('zod').infer<typeof _envSchema>
4052
-
4053
- declare global {
4054
- /**
4055
- * Typed environment registry. Augmented from \`${env.relativePath}\`
4056
- * so \`@Value('PORT')\`, \`Env<'PORT'>\`, and \`process.env.PORT\` are
4057
- * all type-safe and autocomplete.
4058
- */
4059
- interface KickEnv extends _KickEnvShape {}
4060
-
4061
- // eslint-disable-next-line @typescript-eslint/no-namespace
4062
- namespace NodeJS {
4063
- /**
4064
- * Narrow \`process.env\` so known keys exist as \`string\` (the raw
4065
- * pre-Zod-coercion form). \`@Value\` and the \`ConfigService\` apply
4066
- * the schema's transforms internally; access \`process.env\` directly
4067
- * only when you need the raw string. Unknown keys still resolve to
4068
- * \`string | undefined\` via the base @types/node declaration.
4069
- */
4070
- interface ProcessEnv extends Record<keyof KickEnv, string> {}
4071
- }
4072
- }
4073
-
4074
- export {}
4075
- `;
4076
- }
4077
- //#endregion
4078
- //#region src/typegen/builtin/env.ts
4079
- const kickEnvTypegen = () => ({
4080
- id: "kick/env",
4081
- outExtension: ".ts",
4082
- inputs: [
4083
- "src/env.ts",
4084
- "src/**/env.ts",
4085
- "src/**/*.env.ts"
4086
- ],
4087
- async generate(ctx) {
4088
- const envFile = resolveEnvFile(ctx);
4089
- if (envFile === false) return null;
4090
- const scan = await ctx.getScanResult({
4091
- root: resolveSrcDir(ctx),
4092
- cwd: ctx.cwd,
4093
- envFile
4094
- });
4095
- if (!scan.env) return null;
4096
- const outFile = path.resolve(ctx.cwd, ".kickjs/types/kick__env.ts");
4097
- return renderEnv(scan.env, outFile);
4098
- }
4099
- });
4100
- function resolveSrcDir(ctx) {
4101
- return path.resolve(ctx.cwd, ctx.config?.typegen?.srcDir ?? "src");
4102
- }
4103
- function resolveEnvFile(ctx) {
4104
- return ctx.config?.typegen?.envFile;
4105
- }
4106
- //#endregion
4107
- //#region src/plugin/builtins.ts
4108
- const builtinCliPlugins = [
4109
- defineCliPlugin({
4110
- name: "kick/init",
4111
- register: registerInitCommand
4112
- }),
4113
- defineCliPlugin({
4114
- name: "kick/generate",
4115
- register: registerGenerateCommand
4116
- }),
4117
- defineCliPlugin({
4118
- name: "kick/run",
4119
- register: registerRunCommands
4120
- }),
4121
- defineCliPlugin({
4122
- name: "kick/info",
4123
- register: registerInfoCommand
4124
- }),
4125
- defineCliPlugin({
4126
- name: "kick/inspect",
4127
- register: registerInspectCommand
4128
- }),
4129
- defineCliPlugin({
4130
- name: "kick/add",
4131
- register: registerAddCommand
4132
- }),
4133
- defineCliPlugin({
4134
- name: "kick/list",
4135
- register: registerListCommand
4136
- }),
4137
- defineCliPlugin({
4138
- name: "kick/explain",
4139
- register: registerExplainCommand
4140
- }),
4141
- defineCliPlugin({
4142
- name: "kick/mcp",
4143
- register: registerMcpCommand
4144
- }),
4145
- defineCliPlugin({
4146
- name: "kick/tinker",
4147
- register: registerTinkerCommand
4148
- }),
4149
- defineCliPlugin({
4150
- name: "kick/remove",
4151
- register: registerRemoveCommand
4152
- }),
4153
- defineCliPlugin({
4154
- name: "kick/typegen",
4155
- register: registerTypegenCommand
4156
- }),
4157
- defineCliPlugin({
4158
- name: "kick/check",
4159
- register: registerCheckCommand
4160
- }),
4161
- defineCliPlugin({
4162
- name: "kick/db",
4163
- register: registerDbCommands,
4164
- typegens: [kickDbTypegen()]
4165
- }),
4166
- defineCliPlugin({
4167
- name: "kick/assets",
4168
- typegens: [kickAssetsTypegen()]
4169
- }),
4170
- defineCliPlugin({
4171
- name: "kick/routes",
4172
- typegens: [kickRoutesTypegen()]
4173
- }),
4174
- defineCliPlugin({
4175
- name: "kick/env",
4176
- typegens: [kickEnvTypegen()]
4177
- })
4178
- ];
4179
- //#endregion
4180
- export { builtinCliPlugins, applyDisableFilter as n, runAllPluginTypegens as t };
4181
-
4182
- //# sourceMappingURL=builtins-C_VfEGdg.mjs.map