@forinda/kickjs-cli 2.2.4 → 2.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.mjs +991 -84
- package/dist/index.d.mts +7 -1
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +178 -59
- package/dist/index.mjs.map +1 -1
- package/dist/{typegen-zoIEma5k.mjs → typegen-CsTiIxmu.mjs} +2 -2
- package/dist/{typegen-zoIEma5k.mjs.map → typegen-CsTiIxmu.mjs.map} +1 -1
- package/package.json +7 -4
package/dist/cli.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @forinda/kickjs-cli v2.
|
|
2
|
+
* @forinda/kickjs-cli v2.3.0
|
|
3
3
|
*
|
|
4
4
|
* Copyright (c) Felix Orinda
|
|
5
5
|
*
|
|
@@ -9,12 +9,13 @@
|
|
|
9
9
|
* @license MIT
|
|
10
10
|
*/
|
|
11
11
|
import { Command } from "commander";
|
|
12
|
-
import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync } from "node:fs";
|
|
12
|
+
import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
|
|
13
13
|
import { basename, dirname, join, relative, resolve, sep } from "node:path";
|
|
14
14
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
15
15
|
import { createInterface } from "node:readline";
|
|
16
|
-
import { execSync, fork } from "node:child_process";
|
|
16
|
+
import { execSync, fork, spawn } from "node:child_process";
|
|
17
17
|
import { access, mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises";
|
|
18
|
+
import pkg from "pluralize";
|
|
18
19
|
import { arch, platform, release } from "node:os";
|
|
19
20
|
//#region \0rolldown/runtime.js
|
|
20
21
|
var __defProp = Object.defineProperty;
|
|
@@ -742,18 +743,30 @@ export class UserService {
|
|
|
742
743
|
|
|
743
744
|
### Modules
|
|
744
745
|
|
|
745
|
-
|
|
746
|
+
Modules implement \`AppModule\` and wire controllers via \`buildRoutes()\`:
|
|
746
747
|
|
|
747
748
|
\`\`\`ts
|
|
748
|
-
import {
|
|
749
|
+
import { type AppModule, type ModuleRoutes, buildRoutes } from '@forinda/kickjs'
|
|
749
750
|
import { UserController } from './user.controller'
|
|
750
|
-
import { UserService } from './user.service'
|
|
751
751
|
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
752
|
+
export class UserModule implements AppModule {
|
|
753
|
+
routes(): ModuleRoutes {
|
|
754
|
+
return {
|
|
755
|
+
path: '/users',
|
|
756
|
+
router: buildRoutes(UserController),
|
|
757
|
+
controller: UserController,
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
\`\`\`
|
|
762
|
+
|
|
763
|
+
Register all modules in \`src/modules/index.ts\`:
|
|
764
|
+
|
|
765
|
+
\`\`\`ts
|
|
766
|
+
import type { AppModuleClass } from '@forinda/kickjs'
|
|
767
|
+
import { UserModule } from './user/user.module'
|
|
768
|
+
|
|
769
|
+
export const modules: AppModuleClass[] = [UserModule]
|
|
757
770
|
\`\`\`
|
|
758
771
|
|
|
759
772
|
### RequestContext
|
|
@@ -864,6 +877,86 @@ Hot-reload of \`.env\` changes during dev is wired up automatically via
|
|
|
864
877
|
\`envWatchPlugin()\` in \`vite.config.ts\` — edit \`.env\`, the dev server
|
|
865
878
|
reloads, and the next \`config.get()\` re-parses with the new values.
|
|
866
879
|
|
|
880
|
+
### Standalone Env Utilities (No DI Required)
|
|
881
|
+
|
|
882
|
+
These functions work anywhere — scripts, CLI tools, plain files, outside \`@Service\`/\`@Controller\`:
|
|
883
|
+
|
|
884
|
+
\`\`\`ts
|
|
885
|
+
import { defineEnv, loadEnv, getEnv, reloadEnv, resetEnvCache, baseEnvSchema } from '@forinda/kickjs/config'
|
|
886
|
+
import { z } from 'zod'
|
|
887
|
+
|
|
888
|
+
// Define and parse schema
|
|
889
|
+
const schema = defineEnv((base) =>
|
|
890
|
+
base.extend({ DATABASE_URL: z.string().url() })
|
|
891
|
+
)
|
|
892
|
+
const env = loadEnv(schema) // Parse + validate process.env
|
|
893
|
+
console.log(env.PORT) // 3000 (coerced to number)
|
|
894
|
+
console.log(env.DATABASE_URL) // validated URL string
|
|
895
|
+
|
|
896
|
+
// Get single value
|
|
897
|
+
const port = getEnv('PORT') // typed after kick typegen
|
|
898
|
+
|
|
899
|
+
// Reload after .env changes (HMR calls this automatically)
|
|
900
|
+
reloadEnv()
|
|
901
|
+
|
|
902
|
+
// Reset cache in tests that swap schemas
|
|
903
|
+
resetEnvCache()
|
|
904
|
+
\`\`\`
|
|
905
|
+
|
|
906
|
+
| Function | Purpose |
|
|
907
|
+
|----------|---------|
|
|
908
|
+
| \`defineEnv(fn)\` | Extend base schema with custom Zod keys |
|
|
909
|
+
| \`loadEnv(schema?)\` | Parse \`process.env\`, validate, cache, return typed object |
|
|
910
|
+
| \`getEnv(key, schema?)\` | Get single validated env value |
|
|
911
|
+
| \`reloadEnv()\` | Re-read \`.env\` from disk, re-parse with same schema |
|
|
912
|
+
| \`resetEnvCache()\` | Clear parsed cache AND registered schema (for tests) |
|
|
913
|
+
| \`baseEnvSchema\` | Base Zod schema: \`PORT\`, \`NODE_ENV\`, \`LOG_LEVEL\` |
|
|
914
|
+
|
|
915
|
+
## Standalone Utilities (No DI Required)
|
|
916
|
+
|
|
917
|
+
These utilities work outside decorated classes:
|
|
918
|
+
|
|
919
|
+
### Logger
|
|
920
|
+
|
|
921
|
+
\`\`\`ts
|
|
922
|
+
import { Logger, createLogger } from '@forinda/kickjs'
|
|
923
|
+
|
|
924
|
+
const log = Logger.for('MyScript') // Static factory
|
|
925
|
+
log.info('Processing started')
|
|
926
|
+
log.error('Something failed')
|
|
927
|
+
|
|
928
|
+
const log2 = createLogger('Worker') // Function form
|
|
929
|
+
\`\`\`
|
|
930
|
+
|
|
931
|
+
### Injection Tokens
|
|
932
|
+
|
|
933
|
+
\`\`\`ts
|
|
934
|
+
import { createToken } from '@forinda/kickjs'
|
|
935
|
+
|
|
936
|
+
// Type-safe DI tokens for factory/interface binding
|
|
937
|
+
const DB_URL = createToken<string>('config.database.url')
|
|
938
|
+
const FEATURE_FLAGS = createToken<FeatureFlags>('app.features')
|
|
939
|
+
\`\`\`
|
|
940
|
+
|
|
941
|
+
### Reactivity
|
|
942
|
+
|
|
943
|
+
\`\`\`ts
|
|
944
|
+
import { ref, computed, watch, reactive } from '@forinda/kickjs'
|
|
945
|
+
|
|
946
|
+
const count = ref(0)
|
|
947
|
+
const doubled = computed(() => count.value * 2)
|
|
948
|
+
const stop = watch(() => count.value, (val) => console.log(val))
|
|
949
|
+
count.value++ // logs 1
|
|
950
|
+
\`\`\`
|
|
951
|
+
|
|
952
|
+
### HTTP Errors
|
|
953
|
+
|
|
954
|
+
\`\`\`ts
|
|
955
|
+
import { HttpException, HttpStatus } from '@forinda/kickjs'
|
|
956
|
+
|
|
957
|
+
throw new HttpException(HttpStatus.NOT_FOUND, 'User not found')
|
|
958
|
+
\`\`\`
|
|
959
|
+
|
|
867
960
|
## Testing
|
|
868
961
|
|
|
869
962
|
Tests live in \`src/**/*.test.ts\`:
|
|
@@ -898,7 +991,6 @@ Run tests:
|
|
|
898
991
|
- \`@Roles('admin', 'user')\` — role-based access control
|
|
899
992
|
|
|
900
993
|
### DI Decorators
|
|
901
|
-
- \`@Module({ controllers, providers, imports })\` — define module
|
|
902
994
|
- \`@Service()\` — singleton service (DI-registered)
|
|
903
995
|
- \`@Repository()\` — repository (semantic alias for @Service)
|
|
904
996
|
- \`@Autowired()\` — property injection
|
|
@@ -976,7 +1068,7 @@ ${template === "ddd" ? `\`\`\`
|
|
|
976
1068
|
├── <name>.repository.ts # Data access (@Repository)
|
|
977
1069
|
├── <name>.dto.ts # Request/response schemas (Zod)
|
|
978
1070
|
├── <name>.entity.ts # Domain entity (optional)
|
|
979
|
-
└── <name>.module.ts # Module definition (
|
|
1071
|
+
└── <name>.module.ts # Module definition (implements AppModule)
|
|
980
1072
|
\`\`\`
|
|
981
1073
|
` : template === "cqrs" ? `\`\`\`
|
|
982
1074
|
<name>/
|
|
@@ -990,7 +1082,7 @@ ${template === "ddd" ? `\`\`\`
|
|
|
990
1082
|
│ └── <name>-created.event.ts
|
|
991
1083
|
├── <name>.controller.ts # HTTP routes
|
|
992
1084
|
├── <name>.repository.ts # Data access
|
|
993
|
-
└── <name>.module.ts # Module definition
|
|
1085
|
+
└── <name>.module.ts # Module definition (implements AppModule)
|
|
994
1086
|
\`\`\`
|
|
995
1087
|
` : template === "graphql" ? `\`\`\`
|
|
996
1088
|
resolvers/
|
|
@@ -1003,7 +1095,7 @@ resolvers/
|
|
|
1003
1095
|
├── <name>.controller.ts # HTTP routes (@Controller)
|
|
1004
1096
|
├── <name>.service.ts # Business logic (@Service)
|
|
1005
1097
|
├── <name>.dto.ts # Request/response schemas (Zod)
|
|
1006
|
-
└── <name>.module.ts # Module definition (
|
|
1098
|
+
└── <name>.module.ts # Module definition (implements AppModule)
|
|
1007
1099
|
\`\`\`
|
|
1008
1100
|
` : `\`\`\`
|
|
1009
1101
|
src/
|
|
@@ -1039,8 +1131,8 @@ If not using generators:
|
|
|
1039
1131
|
- [ ] Create \`src/modules/<name>/<name>.controller.ts\`
|
|
1040
1132
|
- [ ] Add \`@Controller('/path')\` decorator
|
|
1041
1133
|
- [ ] Add route handlers with \`@Get()\`, \`@Post()\`, etc.
|
|
1042
|
-
- [ ] Create module file with
|
|
1043
|
-
- [ ] Register module in \`src/modules/index.ts\`
|
|
1134
|
+
- [ ] Create module file implementing \`AppModule\` with \`routes()\` returning \`{ path, router: buildRoutes(Controller), controller }\`
|
|
1135
|
+
- [ ] Register module in \`src/modules/index.ts\` (\`AppModuleClass[]\` array)
|
|
1044
1136
|
- [ ] Test with \`kick dev\`
|
|
1045
1137
|
|
|
1046
1138
|
### Manual Service
|
|
@@ -1048,7 +1140,7 @@ If not using generators:
|
|
|
1048
1140
|
- [ ] Create \`src/modules/<name>/<name>.service.ts\`
|
|
1049
1141
|
- [ ] Add \`@Service()\` decorator
|
|
1050
1142
|
- [ ] Inject dependencies with \`@Autowired()\`
|
|
1051
|
-
- [ ]
|
|
1143
|
+
- [ ] Inject via \`@Autowired()\` where needed
|
|
1052
1144
|
- [ ] Write unit tests
|
|
1053
1145
|
|
|
1054
1146
|
### New Middleware
|
|
@@ -1073,9 +1165,12 @@ Use \`kick add\` to install KickJS packages with correct peer dependencies:
|
|
|
1073
1165
|
### Generate CRUD Module
|
|
1074
1166
|
|
|
1075
1167
|
\`\`\`bash
|
|
1076
|
-
kick g scaffold user name:string email:string age:number
|
|
1168
|
+
kick g scaffold user name:string email:string:optional age:number
|
|
1077
1169
|
\`\`\`
|
|
1078
1170
|
|
|
1171
|
+
Append \`:optional\` for optional fields (shell-safe, no quoting needed).
|
|
1172
|
+
Quoted \`?\` syntax also works: \`"email:string?"\` or \`"email?:string"\`.
|
|
1173
|
+
|
|
1079
1174
|
This creates a full CRUD module with:
|
|
1080
1175
|
- Controller with GET, POST, PUT, DELETE routes
|
|
1081
1176
|
- Service with business logic
|
|
@@ -1191,7 +1286,17 @@ private config!: ConfigService
|
|
|
1191
1286
|
const port = this.config.get('PORT') // typed: number
|
|
1192
1287
|
\`\`\`
|
|
1193
1288
|
|
|
1194
|
-
3. **
|
|
1289
|
+
3. **Standalone utilities** (no DI — works in scripts, CLI, plain files):
|
|
1290
|
+
\`\`\`ts
|
|
1291
|
+
import { loadEnv, getEnv, reloadEnv, resetEnvCache } from '@forinda/kickjs/config'
|
|
1292
|
+
|
|
1293
|
+
const env = loadEnv(schema) // Parse + validate all vars
|
|
1294
|
+
const port = getEnv('PORT') // Single value lookup
|
|
1295
|
+
reloadEnv() // Re-read .env from disk
|
|
1296
|
+
resetEnvCache() // Full reset (for tests)
|
|
1297
|
+
\`\`\`
|
|
1298
|
+
|
|
1299
|
+
4. **Direct \`process.env\`** — avoid in app code; bypasses Zod
|
|
1195
1300
|
coercion and the typed \`KickEnv\` registry.
|
|
1196
1301
|
|
|
1197
1302
|
> **Pitfall**: never delete \`import './config'\` from \`src/index.ts\`.
|
|
@@ -1200,6 +1305,22 @@ const port = this.config.get('PORT') // typed: number
|
|
|
1200
1305
|
> \`@Value()\` only works because of its raw \`process.env\` fallback —
|
|
1201
1306
|
> Zod coercion + schema defaults are silently skipped.
|
|
1202
1307
|
|
|
1308
|
+
## Standalone Utilities (No DI Required)
|
|
1309
|
+
|
|
1310
|
+
These work anywhere — scripts, plain files, outside \`@Service\`/\`@Controller\`:
|
|
1311
|
+
|
|
1312
|
+
| Utility | Import | Example |
|
|
1313
|
+
|---------|--------|---------|
|
|
1314
|
+
| \`Logger.for(name)\` | \`@forinda/kickjs\` | \`const log = Logger.for('MyScript')\` |
|
|
1315
|
+
| \`createLogger(name)\` | \`@forinda/kickjs\` | \`const log = createLogger('Worker')\` |
|
|
1316
|
+
| \`createToken<T>(name)\` | \`@forinda/kickjs\` | \`const TOKEN = createToken<string>('db.url')\` |
|
|
1317
|
+
| \`ref(value)\` | \`@forinda/kickjs\` | \`const count = ref(0)\` |
|
|
1318
|
+
| \`computed(fn)\` | \`@forinda/kickjs\` | \`const doubled = computed(() => count.value * 2)\` |
|
|
1319
|
+
| \`watch(source, cb)\` | \`@forinda/kickjs\` | \`watch(() => count.value, (v) => log(v))\` |
|
|
1320
|
+
| \`reactive(obj)\` | \`@forinda/kickjs\` | \`const state = reactive({ count: 0 })\` |
|
|
1321
|
+
| \`HttpException\` | \`@forinda/kickjs\` | \`throw new HttpException(404, 'Not found')\` |
|
|
1322
|
+
| \`HttpStatus\` | \`@forinda/kickjs\` | \`HttpStatus.NOT_FOUND // 404\` |
|
|
1323
|
+
|
|
1203
1324
|
## Key Decorators
|
|
1204
1325
|
|
|
1205
1326
|
### HTTP Routes
|
|
@@ -1214,7 +1335,7 @@ const port = this.config.get('PORT') // typed: number
|
|
|
1214
1335
|
### Dependency Injection
|
|
1215
1336
|
| Decorator | Purpose |
|
|
1216
1337
|
|-----------|---------|
|
|
1217
|
-
|
|
|
1338
|
+
| \`AppModule\` interface | Define feature module (implements \`routes()\`) |
|
|
1218
1339
|
| \`@Service()\` | Register singleton service |
|
|
1219
1340
|
| \`@Repository()\` | Register repository |
|
|
1220
1341
|
| \`@Autowired()\` | Property injection |
|
|
@@ -1310,6 +1431,26 @@ async function initProject(options) {
|
|
|
1310
1431
|
await writeFileSafe(join(dir, "README.md"), generateReadme(name, template, packageManager));
|
|
1311
1432
|
await writeFileSafe(join(dir, "CLAUDE.md"), generateClaude(name, template, packageManager));
|
|
1312
1433
|
await writeFileSafe(join(dir, "AGENTS.md"), generateAgents(name, template, packageManager));
|
|
1434
|
+
if (options.installDeps) {
|
|
1435
|
+
console.log(`\n Installing dependencies with ${packageManager}...\n`);
|
|
1436
|
+
try {
|
|
1437
|
+
execSync(`${packageManager} install`, {
|
|
1438
|
+
cwd: dir,
|
|
1439
|
+
stdio: "inherit"
|
|
1440
|
+
});
|
|
1441
|
+
console.log("\n Dependencies installed successfully!");
|
|
1442
|
+
} catch {
|
|
1443
|
+
console.log(`\n Warning: ${packageManager} install failed. Run it manually.`);
|
|
1444
|
+
}
|
|
1445
|
+
}
|
|
1446
|
+
try {
|
|
1447
|
+
const { runTypegen } = await Promise.resolve().then(() => typegen_exports);
|
|
1448
|
+
await runTypegen({
|
|
1449
|
+
cwd: dir,
|
|
1450
|
+
allowDuplicates: true,
|
|
1451
|
+
silent: true
|
|
1452
|
+
});
|
|
1453
|
+
} catch {}
|
|
1313
1454
|
if (options.initGit) try {
|
|
1314
1455
|
execSync("git init", {
|
|
1315
1456
|
cwd: dir,
|
|
@@ -1331,26 +1472,6 @@ async function initProject(options) {
|
|
|
1331
1472
|
} catch {
|
|
1332
1473
|
log("Warning: git init failed (git may not be installed)");
|
|
1333
1474
|
}
|
|
1334
|
-
if (options.installDeps) {
|
|
1335
|
-
console.log(`\n Installing dependencies with ${packageManager}...\n`);
|
|
1336
|
-
try {
|
|
1337
|
-
execSync(`${packageManager} install`, {
|
|
1338
|
-
cwd: dir,
|
|
1339
|
-
stdio: "inherit"
|
|
1340
|
-
});
|
|
1341
|
-
console.log("\n Dependencies installed successfully!");
|
|
1342
|
-
} catch {
|
|
1343
|
-
console.log(`\n Warning: ${packageManager} install failed. Run it manually.`);
|
|
1344
|
-
}
|
|
1345
|
-
}
|
|
1346
|
-
try {
|
|
1347
|
-
const { runTypegen } = await Promise.resolve().then(() => typegen_exports);
|
|
1348
|
-
await runTypegen({
|
|
1349
|
-
cwd: dir,
|
|
1350
|
-
allowDuplicates: true,
|
|
1351
|
-
silent: true
|
|
1352
|
-
});
|
|
1353
|
-
} catch {}
|
|
1354
1475
|
console.log("\n Project scaffolded successfully!");
|
|
1355
1476
|
console.log();
|
|
1356
1477
|
const needsCd = dir !== process.cwd();
|
|
@@ -1522,26 +1643,18 @@ function toKebabCase(name) {
|
|
|
1522
1643
|
}
|
|
1523
1644
|
/**
|
|
1524
1645
|
* Pluralize a kebab-case name for directory/file names.
|
|
1525
|
-
*
|
|
1646
|
+
* Uses the `pluralize` npm package for correct English pluralization
|
|
1647
|
+
* including irregulars (person → people, status → statuses, child → children).
|
|
1526
1648
|
*/
|
|
1527
1649
|
function pluralize(name) {
|
|
1528
|
-
|
|
1529
|
-
if (name.endsWith("x") || name.endsWith("z")) return name + "es";
|
|
1530
|
-
if (name.endsWith("sh") || name.endsWith("ch")) return name + "es";
|
|
1531
|
-
if (name.endsWith("y") && !/[aeiou]y$/.test(name)) return name.slice(0, -1) + "ies";
|
|
1532
|
-
return name + "s";
|
|
1650
|
+
return pkg.plural(name);
|
|
1533
1651
|
}
|
|
1534
1652
|
/**
|
|
1535
1653
|
* Pluralize a PascalCase name for class identifiers.
|
|
1536
|
-
* If already plural (ends in 's'), returns as-is.
|
|
1537
1654
|
* Used for `List${pluralPascal}UseCase` to avoid `ListUserssUseCase`.
|
|
1538
1655
|
*/
|
|
1539
1656
|
function pluralizePascal(name) {
|
|
1540
|
-
|
|
1541
|
-
if (name.endsWith("x") || name.endsWith("z")) return name + "es";
|
|
1542
|
-
if (name.endsWith("sh") || name.endsWith("ch")) return name + "es";
|
|
1543
|
-
if (name.endsWith("y") && !/[aeiou]y$/i.test(name)) return name.slice(0, -1) + "ies";
|
|
1544
|
-
return name + "s";
|
|
1657
|
+
return pkg.plural(name);
|
|
1545
1658
|
}
|
|
1546
1659
|
//#endregion
|
|
1547
1660
|
//#region src/generators/templates/module-index.ts
|
|
@@ -3541,6 +3654,140 @@ export class ${pascal}Adapter implements AppAdapter {
|
|
|
3541
3654
|
return files;
|
|
3542
3655
|
}
|
|
3543
3656
|
//#endregion
|
|
3657
|
+
//#region src/generators/plugin.ts
|
|
3658
|
+
/**
|
|
3659
|
+
* Scaffold a `KickPlugin` under `src/plugins/<name>.plugin.ts`.
|
|
3660
|
+
*
|
|
3661
|
+
* Plugins are the canonical place to wire DI bindings, load extra
|
|
3662
|
+
* modules, add middleware, or attach startup hooks without writing a
|
|
3663
|
+
* full adapter. The generated template implements every optional
|
|
3664
|
+
* `KickPlugin` hook with commented examples so users can uncomment
|
|
3665
|
+
* the ones they need and delete the rest.
|
|
3666
|
+
*/
|
|
3667
|
+
async function generatePlugin(options) {
|
|
3668
|
+
const { name, outDir } = options;
|
|
3669
|
+
const kebab = toKebabCase(name);
|
|
3670
|
+
const pascal = toPascalCase(name);
|
|
3671
|
+
const factoryName = `${toCamelCase(name)}Plugin`;
|
|
3672
|
+
const files = [];
|
|
3673
|
+
const filePath = join(outDir, `${kebab}.plugin.ts`);
|
|
3674
|
+
await writeFileSafe(filePath, `import type { KickPlugin, Container, AppAdapter, AppModuleClass } from '@forinda/kickjs'
|
|
3675
|
+
|
|
3676
|
+
/**
|
|
3677
|
+
* Options for the ${pascal} plugin.
|
|
3678
|
+
*
|
|
3679
|
+
* Plugins typically take a small options object in their factory so
|
|
3680
|
+
* callers can configure them inline at bootstrap time. Keep the
|
|
3681
|
+
* shape narrow — anything derived from the environment should be
|
|
3682
|
+
* read via \`getEnv\` inside the plugin itself, not forced onto the
|
|
3683
|
+
* caller.
|
|
3684
|
+
*/
|
|
3685
|
+
export interface ${pascal}PluginOptions {
|
|
3686
|
+
// Add your plugin options here, for example:
|
|
3687
|
+
// enabled?: boolean
|
|
3688
|
+
// apiKey?: string
|
|
3689
|
+
}
|
|
3690
|
+
|
|
3691
|
+
/**
|
|
3692
|
+
* ${pascal} plugin.
|
|
3693
|
+
*
|
|
3694
|
+
* A \`KickPlugin\` bundles DI bindings, modules, adapters, and
|
|
3695
|
+
* middleware into one object that can be added to \`bootstrap({ plugins })\`.
|
|
3696
|
+
* Every hook is optional — delete the ones you don't need and keep
|
|
3697
|
+
* only the surface your plugin actually uses.
|
|
3698
|
+
*
|
|
3699
|
+
* Lifecycle order:
|
|
3700
|
+
*
|
|
3701
|
+
* 1. \`register(container)\` — runs before user modules load. Use
|
|
3702
|
+
* it to bind services that modules depend on.
|
|
3703
|
+
* 2. \`modules()\` — plugin modules load before user modules.
|
|
3704
|
+
* 3. \`adapters()\` — plugin adapters are added before user adapters.
|
|
3705
|
+
* 4. \`middleware()\` — plugin middleware runs before user middleware.
|
|
3706
|
+
* 5. \`onReady(container)\` — runs after the app has fully bootstrapped.
|
|
3707
|
+
* 6. \`shutdown()\` — runs on graceful shutdown.
|
|
3708
|
+
*
|
|
3709
|
+
* @example
|
|
3710
|
+
* \`\`\`ts
|
|
3711
|
+
* import { bootstrap } from '@forinda/kickjs'
|
|
3712
|
+
* import { ${factoryName} } from './plugins/${kebab}.plugin'
|
|
3713
|
+
*
|
|
3714
|
+
* export const app = await bootstrap({
|
|
3715
|
+
* modules,
|
|
3716
|
+
* plugins: [${factoryName}({ /* options */ })],
|
|
3717
|
+
* })
|
|
3718
|
+
* \`\`\`
|
|
3719
|
+
*/
|
|
3720
|
+
export function ${factoryName}(options: ${pascal}PluginOptions = {}): KickPlugin {
|
|
3721
|
+
return {
|
|
3722
|
+
name: '${kebab}',
|
|
3723
|
+
|
|
3724
|
+
/**
|
|
3725
|
+
* Register DI bindings before modules load.
|
|
3726
|
+
* Use \`container.registerInstance(TOKEN, value)\` for singletons
|
|
3727
|
+
* and \`container.registerFactory(TOKEN, () => ...)\` for lazy
|
|
3728
|
+
* constructions.
|
|
3729
|
+
*/
|
|
3730
|
+
register(container: Container): void {
|
|
3731
|
+
// Example: bind a configured service to a DI token
|
|
3732
|
+
// container.registerInstance(MY_TOKEN, new MyService(options))
|
|
3733
|
+
},
|
|
3734
|
+
|
|
3735
|
+
/**
|
|
3736
|
+
* Return module classes this plugin contributes to the app.
|
|
3737
|
+
* These load before user modules, so plugin controllers and
|
|
3738
|
+
* services are available for user code to \`@Autowired\`.
|
|
3739
|
+
*/
|
|
3740
|
+
modules(): AppModuleClass[] {
|
|
3741
|
+
return [
|
|
3742
|
+
// ExampleModule,
|
|
3743
|
+
]
|
|
3744
|
+
},
|
|
3745
|
+
|
|
3746
|
+
/**
|
|
3747
|
+
* Return adapter instances to be added to the application.
|
|
3748
|
+
* Plugin adapters are added before user adapters.
|
|
3749
|
+
*/
|
|
3750
|
+
adapters(): AppAdapter[] {
|
|
3751
|
+
return [
|
|
3752
|
+
// new MyAdapter({ ... }),
|
|
3753
|
+
]
|
|
3754
|
+
},
|
|
3755
|
+
|
|
3756
|
+
/**
|
|
3757
|
+
* Return Express middleware entries to be added to the global
|
|
3758
|
+
* pipeline. Plugin middleware runs before user-defined middleware.
|
|
3759
|
+
*/
|
|
3760
|
+
middleware(): any[] {
|
|
3761
|
+
return [
|
|
3762
|
+
// helmet(),
|
|
3763
|
+
// myCustomMiddleware(options),
|
|
3764
|
+
]
|
|
3765
|
+
},
|
|
3766
|
+
|
|
3767
|
+
/**
|
|
3768
|
+
* Called after the application has fully bootstrapped. Use this
|
|
3769
|
+
* for post-startup work like logging, health checks, or warming
|
|
3770
|
+
* a cache. Runs once per process.
|
|
3771
|
+
*/
|
|
3772
|
+
async onReady(container: Container): Promise<void> {
|
|
3773
|
+
// const logger = container.resolve(Logger)
|
|
3774
|
+
// logger.info('${pascal} plugin ready')
|
|
3775
|
+
},
|
|
3776
|
+
|
|
3777
|
+
/**
|
|
3778
|
+
* Called during graceful shutdown. Clean up any long-lived
|
|
3779
|
+
* resources this plugin owns (connections, timers, subscriptions).
|
|
3780
|
+
*/
|
|
3781
|
+
async shutdown(): Promise<void> {
|
|
3782
|
+
// await this.connection?.close()
|
|
3783
|
+
},
|
|
3784
|
+
}
|
|
3785
|
+
}
|
|
3786
|
+
`);
|
|
3787
|
+
files.push(filePath);
|
|
3788
|
+
return files;
|
|
3789
|
+
}
|
|
3790
|
+
//#endregion
|
|
3544
3791
|
//#region src/utils/resolve-out-dir.ts
|
|
3545
3792
|
/**
|
|
3546
3793
|
* DDD folder mapping — nested layered architecture.
|
|
@@ -3585,13 +3832,14 @@ const CQRS_FOLDER_MAP = {
|
|
|
3585
3832
|
* 3. Standalone default directory
|
|
3586
3833
|
*/
|
|
3587
3834
|
function resolveOutDir(options) {
|
|
3588
|
-
const { type, outDir, moduleName, modulesDir = "src/modules", defaultDir, pattern = "ddd" } = options;
|
|
3835
|
+
const { type, outDir, moduleName, modulesDir = "src/modules", defaultDir, pattern = "ddd", shouldPluralize = true } = options;
|
|
3589
3836
|
if (outDir) return resolve(outDir);
|
|
3590
3837
|
if (moduleName) {
|
|
3591
3838
|
const folderMap = pattern === "ddd" ? DDD_FOLDER_MAP : pattern === "cqrs" ? CQRS_FOLDER_MAP : FLAT_FOLDER_MAP;
|
|
3592
|
-
const
|
|
3839
|
+
const kebab = toKebabCase(moduleName);
|
|
3840
|
+
const folder = shouldPluralize ? pluralize(kebab) : kebab;
|
|
3593
3841
|
const subfolder = folderMap[type] ?? "";
|
|
3594
|
-
const base = join(modulesDir,
|
|
3842
|
+
const base = join(modulesDir, folder);
|
|
3595
3843
|
return resolve(subfolder ? join(base, subfolder) : base);
|
|
3596
3844
|
}
|
|
3597
3845
|
return resolve(defaultDir);
|
|
@@ -3606,7 +3854,8 @@ async function generateMiddleware(options) {
|
|
|
3606
3854
|
moduleName,
|
|
3607
3855
|
modulesDir,
|
|
3608
3856
|
defaultDir: "src/middleware",
|
|
3609
|
-
pattern
|
|
3857
|
+
pattern,
|
|
3858
|
+
shouldPluralize: options.pluralize ?? true
|
|
3610
3859
|
});
|
|
3611
3860
|
const kebab = toKebabCase(name);
|
|
3612
3861
|
const camel = toCamelCase(name);
|
|
@@ -3650,7 +3899,8 @@ async function generateGuard(options) {
|
|
|
3650
3899
|
moduleName,
|
|
3651
3900
|
modulesDir,
|
|
3652
3901
|
defaultDir: "src/guards",
|
|
3653
|
-
pattern
|
|
3902
|
+
pattern,
|
|
3903
|
+
shouldPluralize: options.pluralize ?? true
|
|
3654
3904
|
});
|
|
3655
3905
|
const kebab = toKebabCase(name);
|
|
3656
3906
|
const camel = toCamelCase(name);
|
|
@@ -3707,7 +3957,8 @@ async function generateService(options) {
|
|
|
3707
3957
|
moduleName,
|
|
3708
3958
|
modulesDir,
|
|
3709
3959
|
defaultDir: "src/services",
|
|
3710
|
-
pattern
|
|
3960
|
+
pattern,
|
|
3961
|
+
shouldPluralize: options.pluralize ?? true
|
|
3711
3962
|
});
|
|
3712
3963
|
const kebab = toKebabCase(name);
|
|
3713
3964
|
const pascal = toPascalCase(name);
|
|
@@ -3736,7 +3987,8 @@ async function generateController(options) {
|
|
|
3736
3987
|
moduleName,
|
|
3737
3988
|
modulesDir,
|
|
3738
3989
|
defaultDir: "src/controllers",
|
|
3739
|
-
pattern
|
|
3990
|
+
pattern,
|
|
3991
|
+
shouldPluralize: options.pluralize ?? true
|
|
3740
3992
|
});
|
|
3741
3993
|
const kebab = toKebabCase(name);
|
|
3742
3994
|
const pascal = toPascalCase(name);
|
|
@@ -3777,7 +4029,8 @@ async function generateDto(options) {
|
|
|
3777
4029
|
moduleName,
|
|
3778
4030
|
modulesDir,
|
|
3779
4031
|
defaultDir: "src/dtos",
|
|
3780
|
-
pattern
|
|
4032
|
+
pattern,
|
|
4033
|
+
shouldPluralize: options.pluralize ?? true
|
|
3781
4034
|
});
|
|
3782
4035
|
const kebab = toKebabCase(name);
|
|
3783
4036
|
const pascal = toPascalCase(name);
|
|
@@ -3996,7 +4249,10 @@ export class ${pascal}Job {
|
|
|
3996
4249
|
* json → z.any()
|
|
3997
4250
|
* enum:a,b → z.enum(['a','b'])
|
|
3998
4251
|
*
|
|
3999
|
-
*
|
|
4252
|
+
* Mark optional fields — three equivalent syntaxes:
|
|
4253
|
+
* body:text:optional ← recommended (shell-safe, no quoting needed)
|
|
4254
|
+
* body?:text ← needs quoting in bash/zsh ("body?:text")
|
|
4255
|
+
* body:text? ← needs quoting in bash/zsh ("body:text?")
|
|
4000
4256
|
*/
|
|
4001
4257
|
const TYPE_MAP = {
|
|
4002
4258
|
string: {
|
|
@@ -4048,11 +4304,23 @@ function parseFields(raw) {
|
|
|
4048
4304
|
return raw.map((f) => {
|
|
4049
4305
|
const colonIdx = f.indexOf(":");
|
|
4050
4306
|
if (colonIdx === -1) throw new Error(`Invalid field: "${f}". Use format: name:type (e.g. title:string)`);
|
|
4051
|
-
|
|
4052
|
-
|
|
4307
|
+
let namePart = f.slice(0, colonIdx);
|
|
4308
|
+
let typePart = f.slice(colonIdx + 1);
|
|
4053
4309
|
if (!namePart || !typePart) throw new Error(`Invalid field: "${f}". Use format: name:type (e.g. title:string)`);
|
|
4054
|
-
|
|
4055
|
-
|
|
4310
|
+
let optional = false;
|
|
4311
|
+
if (typePart.endsWith(":optional")) {
|
|
4312
|
+
typePart = typePart.slice(0, -9);
|
|
4313
|
+
optional = true;
|
|
4314
|
+
}
|
|
4315
|
+
if (namePart.endsWith("?")) {
|
|
4316
|
+
namePart = namePart.slice(0, -1);
|
|
4317
|
+
optional = true;
|
|
4318
|
+
}
|
|
4319
|
+
if (typePart.endsWith("?")) {
|
|
4320
|
+
typePart = typePart.slice(0, -1);
|
|
4321
|
+
optional = true;
|
|
4322
|
+
}
|
|
4323
|
+
const cleanType = typePart;
|
|
4056
4324
|
if (cleanType.startsWith("enum:")) {
|
|
4057
4325
|
const values = cleanType.slice(5).split(",");
|
|
4058
4326
|
return {
|
|
@@ -4517,14 +4785,16 @@ async function autoRegisterModule(modulesDir, pascal, plural) {
|
|
|
4517
4785
|
//#region src/generators/test.ts
|
|
4518
4786
|
async function generateTest(options) {
|
|
4519
4787
|
const { name, moduleName, modulesDir } = options;
|
|
4788
|
+
const shouldPluralize = options.pluralize ?? true;
|
|
4520
4789
|
const kebab = toKebabCase(name);
|
|
4521
4790
|
const pascal = toPascalCase(name);
|
|
4522
4791
|
const files = [];
|
|
4523
4792
|
let outDir;
|
|
4524
4793
|
if (options.outDir) outDir = resolve(options.outDir);
|
|
4525
4794
|
else if (moduleName) {
|
|
4526
|
-
const
|
|
4527
|
-
|
|
4795
|
+
const modKebab = toKebabCase(moduleName);
|
|
4796
|
+
const modFolder = shouldPluralize ? pluralize(modKebab) : modKebab;
|
|
4797
|
+
outDir = resolve(join(modulesDir ?? "src/modules", modFolder, "__tests__"));
|
|
4528
4798
|
} else outDir = resolve("src/__tests__");
|
|
4529
4799
|
const filePath = join(outDir, `${kebab}.test.ts`);
|
|
4530
4800
|
await writeFileSafe(filePath, `import { describe, it, expect, beforeEach } from 'vitest'
|
|
@@ -5699,56 +5969,72 @@ function registerGenerateCommand(program) {
|
|
|
5699
5969
|
outDir: resolve(opts.out)
|
|
5700
5970
|
}), dryRun);
|
|
5701
5971
|
});
|
|
5972
|
+
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) => {
|
|
5973
|
+
const dryRun = isDryRun(cmd);
|
|
5974
|
+
setDryRun(dryRun);
|
|
5975
|
+
printGenerated(await generatePlugin({
|
|
5976
|
+
name,
|
|
5977
|
+
outDir: resolve(opts.out)
|
|
5978
|
+
}), dryRun);
|
|
5979
|
+
});
|
|
5702
5980
|
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) => {
|
|
5703
5981
|
const dryRun = isDryRun(cmd);
|
|
5704
5982
|
setDryRun(dryRun);
|
|
5705
5983
|
const config = await loadKickConfig(process.cwd());
|
|
5706
|
-
const
|
|
5984
|
+
const mc = resolveModuleConfig(config);
|
|
5985
|
+
const modulesDir = mc.dir ?? "src/modules";
|
|
5707
5986
|
printGenerated(await generateMiddleware({
|
|
5708
5987
|
name,
|
|
5709
5988
|
outDir: opts.out,
|
|
5710
5989
|
moduleName: opts.module,
|
|
5711
5990
|
modulesDir,
|
|
5712
|
-
pattern: config?.pattern
|
|
5991
|
+
pattern: config?.pattern,
|
|
5992
|
+
pluralize: mc.pluralize ?? true
|
|
5713
5993
|
}), dryRun);
|
|
5714
5994
|
});
|
|
5715
5995
|
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) => {
|
|
5716
5996
|
const dryRun = isDryRun(cmd);
|
|
5717
5997
|
setDryRun(dryRun);
|
|
5718
5998
|
const config = await loadKickConfig(process.cwd());
|
|
5719
|
-
const
|
|
5999
|
+
const mc = resolveModuleConfig(config);
|
|
6000
|
+
const modulesDir = mc.dir ?? "src/modules";
|
|
5720
6001
|
printGenerated(await generateGuard({
|
|
5721
6002
|
name,
|
|
5722
6003
|
outDir: opts.out,
|
|
5723
6004
|
moduleName: opts.module,
|
|
5724
6005
|
modulesDir,
|
|
5725
|
-
pattern: config?.pattern
|
|
6006
|
+
pattern: config?.pattern,
|
|
6007
|
+
pluralize: mc.pluralize ?? true
|
|
5726
6008
|
}), dryRun);
|
|
5727
6009
|
});
|
|
5728
6010
|
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) => {
|
|
5729
6011
|
const dryRun = isDryRun(cmd);
|
|
5730
6012
|
setDryRun(dryRun);
|
|
5731
6013
|
const config = await loadKickConfig(process.cwd());
|
|
5732
|
-
const
|
|
6014
|
+
const mc = resolveModuleConfig(config);
|
|
6015
|
+
const modulesDir = mc.dir ?? "src/modules";
|
|
5733
6016
|
printGenerated(await generateService({
|
|
5734
6017
|
name,
|
|
5735
6018
|
outDir: opts.out,
|
|
5736
6019
|
moduleName: opts.module,
|
|
5737
6020
|
modulesDir,
|
|
5738
|
-
pattern: config?.pattern
|
|
6021
|
+
pattern: config?.pattern,
|
|
6022
|
+
pluralize: mc.pluralize ?? true
|
|
5739
6023
|
}), dryRun);
|
|
5740
6024
|
});
|
|
5741
6025
|
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) => {
|
|
5742
6026
|
const dryRun = isDryRun(cmd);
|
|
5743
6027
|
setDryRun(dryRun);
|
|
5744
6028
|
const config = await loadKickConfig(process.cwd());
|
|
5745
|
-
const
|
|
6029
|
+
const mc = resolveModuleConfig(config);
|
|
6030
|
+
const modulesDir = mc.dir ?? "src/modules";
|
|
5746
6031
|
printGenerated(await generateController({
|
|
5747
6032
|
name,
|
|
5748
6033
|
outDir: opts.out,
|
|
5749
6034
|
moduleName: opts.module,
|
|
5750
6035
|
modulesDir,
|
|
5751
|
-
pattern: config?.pattern
|
|
6036
|
+
pattern: config?.pattern,
|
|
6037
|
+
pluralize: mc.pluralize ?? true
|
|
5752
6038
|
}), dryRun);
|
|
5753
6039
|
await runPostTypegen(dryRun);
|
|
5754
6040
|
});
|
|
@@ -5756,24 +6042,28 @@ function registerGenerateCommand(program) {
|
|
|
5756
6042
|
const dryRun = isDryRun(cmd);
|
|
5757
6043
|
setDryRun(dryRun);
|
|
5758
6044
|
const config = await loadKickConfig(process.cwd());
|
|
5759
|
-
const
|
|
6045
|
+
const mc = resolveModuleConfig(config);
|
|
6046
|
+
const modulesDir = mc.dir ?? "src/modules";
|
|
5760
6047
|
printGenerated(await generateDto({
|
|
5761
6048
|
name,
|
|
5762
6049
|
outDir: opts.out,
|
|
5763
6050
|
moduleName: opts.module,
|
|
5764
6051
|
modulesDir,
|
|
5765
|
-
pattern: config?.pattern
|
|
6052
|
+
pattern: config?.pattern,
|
|
6053
|
+
pluralize: mc.pluralize ?? true
|
|
5766
6054
|
}), dryRun);
|
|
5767
6055
|
});
|
|
5768
6056
|
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) => {
|
|
5769
6057
|
const dryRun = isDryRun(cmd);
|
|
5770
6058
|
setDryRun(dryRun);
|
|
5771
|
-
const
|
|
6059
|
+
const mc = resolveModuleConfig(await loadKickConfig(process.cwd()));
|
|
6060
|
+
const modulesDir = mc.dir ?? "src/modules";
|
|
5772
6061
|
printGenerated(await generateTest({
|
|
5773
6062
|
name,
|
|
5774
6063
|
outDir: opts.out,
|
|
5775
6064
|
moduleName: opts.module,
|
|
5776
|
-
modulesDir
|
|
6065
|
+
modulesDir,
|
|
6066
|
+
pluralize: mc.pluralize ?? true
|
|
5777
6067
|
}), dryRun);
|
|
5778
6068
|
});
|
|
5779
6069
|
gen.command("resolver <name>").description("Generate a GraphQL @Resolver class with @Query and @Mutation methods").option("-o, --out <dir>", "Output directory", "src/resolvers").action(async (name, opts, cmd) => {
|
|
@@ -5793,11 +6083,11 @@ function registerGenerateCommand(program) {
|
|
|
5793
6083
|
queue: opts.queue
|
|
5794
6084
|
}), dryRun);
|
|
5795
6085
|
});
|
|
5796
|
-
gen.command("scaffold <name> [fields...]").description("Generate a full CRUD module from field definitions\n Example: kick g scaffold Post title:string body:text published:boolean
|
|
6086
|
+
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) => {
|
|
5797
6087
|
const dryRun = isDryRun(cmd);
|
|
5798
6088
|
setDryRun(dryRun);
|
|
5799
6089
|
if (rawFields.length === 0) {
|
|
5800
|
-
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 published:boolean\n");
|
|
6090
|
+
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");
|
|
5801
6091
|
process.exit(1);
|
|
5802
6092
|
}
|
|
5803
6093
|
const mc = resolveModuleConfig(await loadKickConfig(process.cwd()));
|
|
@@ -6292,6 +6582,11 @@ const PACKAGE_REGISTRY = {
|
|
|
6292
6582
|
peers: [],
|
|
6293
6583
|
description: "Multi-channel notifications — email, Slack, Discord, webhook"
|
|
6294
6584
|
},
|
|
6585
|
+
mcp: {
|
|
6586
|
+
pkg: "@forinda/kickjs-mcp",
|
|
6587
|
+
peers: ["@modelcontextprotocol/sdk"],
|
|
6588
|
+
description: "Model Context Protocol server — expose @Controller endpoints as AI tools"
|
|
6589
|
+
},
|
|
6295
6590
|
testing: {
|
|
6296
6591
|
pkg: "@forinda/kickjs-testing",
|
|
6297
6592
|
peers: [],
|
|
@@ -6375,6 +6670,616 @@ function registerAddCommand(program) {
|
|
|
6375
6670
|
});
|
|
6376
6671
|
}
|
|
6377
6672
|
//#endregion
|
|
6673
|
+
//#region src/explain/known-issues.ts
|
|
6674
|
+
function includesAll(haystack, needles) {
|
|
6675
|
+
const lower = haystack.toLowerCase();
|
|
6676
|
+
return needles.every((n) => lower.includes(n.toLowerCase()));
|
|
6677
|
+
}
|
|
6678
|
+
function includesAny(haystack, needles) {
|
|
6679
|
+
const lower = haystack.toLowerCase();
|
|
6680
|
+
return needles.some((n) => lower.includes(n.toLowerCase()));
|
|
6681
|
+
}
|
|
6682
|
+
const KNOWN_ISSUES = [
|
|
6683
|
+
{ match(input, _ctx) {
|
|
6684
|
+
const hasConfigGetUndefined = includesAll(input, ["config", "get"]) && includesAny(input, ["undefined", "null"]);
|
|
6685
|
+
const hasValueUndefined = input.includes("@Value") && includesAny(input, ["undefined", "is not defined"]);
|
|
6686
|
+
if (!hasConfigGetUndefined && !hasValueUndefined) return null;
|
|
6687
|
+
return {
|
|
6688
|
+
confidence: hasConfigGetUndefined && hasValueUndefined ? 90 : 75,
|
|
6689
|
+
diagnosis: {
|
|
6690
|
+
id: "env-schema-not-registered",
|
|
6691
|
+
title: "ConfigService.get() returns undefined for user-defined keys",
|
|
6692
|
+
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.",
|
|
6693
|
+
fix: "Add this line to src/index.ts near the top, before bootstrap() runs:",
|
|
6694
|
+
codeBefore: "import 'reflect-metadata'\nimport { bootstrap } from '@forinda/kickjs'\nimport { modules } from './modules'\n",
|
|
6695
|
+
codeAfter: "import 'reflect-metadata'\nimport './config' // ← add this — registers env schema\nimport { bootstrap } from '@forinda/kickjs'\nimport { modules } from './modules'\n",
|
|
6696
|
+
docs: "https://forinda.github.io/kick-js/guide/configuration.html#wiring-the-schema-at-startup"
|
|
6697
|
+
}
|
|
6698
|
+
};
|
|
6699
|
+
} },
|
|
6700
|
+
{ match(input, _ctx) {
|
|
6701
|
+
const hasTestContext = includesAny(input, [
|
|
6702
|
+
"vitest",
|
|
6703
|
+
"test",
|
|
6704
|
+
"spec",
|
|
6705
|
+
"__tests__",
|
|
6706
|
+
".test."
|
|
6707
|
+
]);
|
|
6708
|
+
if (!includesAny(input, [
|
|
6709
|
+
"already registered",
|
|
6710
|
+
"already exists",
|
|
6711
|
+
"duplicate",
|
|
6712
|
+
"has been registered"
|
|
6713
|
+
])) return null;
|
|
6714
|
+
return {
|
|
6715
|
+
confidence: hasTestContext ? 85 : 60,
|
|
6716
|
+
diagnosis: {
|
|
6717
|
+
id: "container-not-reset-in-tests",
|
|
6718
|
+
title: "DI container leaks between test cases",
|
|
6719
|
+
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.",
|
|
6720
|
+
fix: "Add Container.reset() to a beforeEach hook in the failing test file:",
|
|
6721
|
+
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})",
|
|
6722
|
+
docs: "https://forinda.github.io/kick-js/guide/testing.html"
|
|
6723
|
+
}
|
|
6724
|
+
};
|
|
6725
|
+
} },
|
|
6726
|
+
{ match(input, _ctx) {
|
|
6727
|
+
if (!(input.includes("@Module") || includesAll(input, ["Module", "is not a function"]) || includesAll(input, ["Module", "no exported member"]))) return null;
|
|
6728
|
+
return {
|
|
6729
|
+
confidence: 80,
|
|
6730
|
+
diagnosis: {
|
|
6731
|
+
id: "module-decorator-not-found",
|
|
6732
|
+
title: "KickJS does not have a @Module decorator (different pattern from NestJS)",
|
|
6733
|
+
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.",
|
|
6734
|
+
fix: "Replace the @Module decorator with an AppModule class:",
|
|
6735
|
+
codeBefore: "import { Module } from '@forinda/kickjs' // ← does not exist\nimport { UserController } from './user.controller'\n\n@Module({\n controllers: [UserController],\n})\nexport class UserModule {}",
|
|
6736
|
+
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}",
|
|
6737
|
+
docs: "https://forinda.github.io/kick-js/guide/project-structure.html"
|
|
6738
|
+
}
|
|
6739
|
+
};
|
|
6740
|
+
} },
|
|
6741
|
+
{ match(input, _ctx) {
|
|
6742
|
+
if (!/KickRoutes\s*\[\s*['"](GET|POST|PUT|PATCH|DELETE)/i.test(input)) return null;
|
|
6743
|
+
return {
|
|
6744
|
+
confidence: 95,
|
|
6745
|
+
diagnosis: {
|
|
6746
|
+
id: "legacy-kick-routes-bracket-syntax",
|
|
6747
|
+
title: "KickRoutes['POST /users'] is the legacy v1 syntax",
|
|
6748
|
+
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.",
|
|
6749
|
+
fix: "Update the Ctx<...> type parameter to use the namespace form:",
|
|
6750
|
+
codeBefore: "@Post('/', { body: createUserSchema })\ncreate(ctx: Ctx<KickRoutes['POST /users']>) { /* ... */ }",
|
|
6751
|
+
codeAfter: "@Post('/', { body: createUserSchema, name: 'CreateUser' })\ncreate(ctx: Ctx<KickRoutes.UserController['create']>) { /* ... */ }",
|
|
6752
|
+
docs: "https://forinda.github.io/kick-js/guide/typegen.html"
|
|
6753
|
+
}
|
|
6754
|
+
};
|
|
6755
|
+
} },
|
|
6756
|
+
{ match(input, _ctx) {
|
|
6757
|
+
const hasCluster = includesAny(input, [
|
|
6758
|
+
"cluster",
|
|
6759
|
+
"workers",
|
|
6760
|
+
"two ports",
|
|
6761
|
+
"duplicate server"
|
|
6762
|
+
]);
|
|
6763
|
+
const hasDevSignal = includesAny(input, [
|
|
6764
|
+
"kick dev",
|
|
6765
|
+
"vite",
|
|
6766
|
+
"eaddrinuse",
|
|
6767
|
+
"5173",
|
|
6768
|
+
"5174",
|
|
6769
|
+
"two servers"
|
|
6770
|
+
]);
|
|
6771
|
+
if (!hasCluster || !hasDevSignal) return null;
|
|
6772
|
+
return {
|
|
6773
|
+
confidence: 85,
|
|
6774
|
+
diagnosis: {
|
|
6775
|
+
id: "cluster-in-vite-dev",
|
|
6776
|
+
title: "Cluster mode is incompatible with `kick dev` (Vite owns the server)",
|
|
6777
|
+
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.",
|
|
6778
|
+
fix: "Either upgrade to v2.2.5+ or gate cluster mode on production:",
|
|
6779
|
+
codeAfter: "export const app = await bootstrap({\n modules,\n cluster: process.env.NODE_ENV === 'production' ? { workers: 4 } : false,\n})",
|
|
6780
|
+
docs: "https://forinda.github.io/kick-js/guide/cluster.html"
|
|
6781
|
+
}
|
|
6782
|
+
};
|
|
6783
|
+
} },
|
|
6784
|
+
{ match(input, _ctx) {
|
|
6785
|
+
if (!includesAny(input, [
|
|
6786
|
+
"reflect-metadata",
|
|
6787
|
+
"Reflect.getMetadata is not a function",
|
|
6788
|
+
"Reflect.defineMetadata",
|
|
6789
|
+
"design:type",
|
|
6790
|
+
"design:paramtypes"
|
|
6791
|
+
])) return null;
|
|
6792
|
+
return {
|
|
6793
|
+
confidence: 90,
|
|
6794
|
+
diagnosis: {
|
|
6795
|
+
id: "reflect-metadata-missing",
|
|
6796
|
+
title: "reflect-metadata is not loaded — DI cannot read decorator types",
|
|
6797
|
+
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.",
|
|
6798
|
+
fix: "Add the import at the very top of src/index.ts:",
|
|
6799
|
+
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 })",
|
|
6800
|
+
docs: "https://forinda.github.io/kick-js/guide/dependency-injection.html"
|
|
6801
|
+
}
|
|
6802
|
+
};
|
|
6803
|
+
} },
|
|
6804
|
+
{ match(input, _ctx) {
|
|
6805
|
+
if (!includesAny(input, [
|
|
6806
|
+
"404",
|
|
6807
|
+
"cannot get",
|
|
6808
|
+
"cannot post",
|
|
6809
|
+
"no route"
|
|
6810
|
+
])) return null;
|
|
6811
|
+
return {
|
|
6812
|
+
confidence: 50,
|
|
6813
|
+
diagnosis: {
|
|
6814
|
+
id: "module-not-registered",
|
|
6815
|
+
title: "A 404 may indicate a module is not in the modules array",
|
|
6816
|
+
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.",
|
|
6817
|
+
fix: "Open src/modules/index.ts and verify the module is in the array:",
|
|
6818
|
+
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]",
|
|
6819
|
+
docs: "https://forinda.github.io/kick-js/guide/project-structure.html"
|
|
6820
|
+
}
|
|
6821
|
+
};
|
|
6822
|
+
} }
|
|
6823
|
+
];
|
|
6824
|
+
/**
|
|
6825
|
+
* Run every matcher against the input and return the highest-confidence
|
|
6826
|
+
* hit, or `null` if no matcher cleared the 40-confidence threshold.
|
|
6827
|
+
*/
|
|
6828
|
+
function findBestMatch(input, ctx) {
|
|
6829
|
+
let best = null;
|
|
6830
|
+
for (const issue of KNOWN_ISSUES) {
|
|
6831
|
+
let match = null;
|
|
6832
|
+
try {
|
|
6833
|
+
match = issue.match(input, ctx);
|
|
6834
|
+
} catch {
|
|
6835
|
+
continue;
|
|
6836
|
+
}
|
|
6837
|
+
if (!match || match.confidence < 40) continue;
|
|
6838
|
+
if (!best || match.confidence > best.confidence) best = match;
|
|
6839
|
+
}
|
|
6840
|
+
return best;
|
|
6841
|
+
}
|
|
6842
|
+
//#endregion
|
|
6843
|
+
//#region src/explain/ai-fallback.ts
|
|
6844
|
+
/**
|
|
6845
|
+
* Ask the configured LLM for a diagnosis of `options.input`.
|
|
6846
|
+
*
|
|
6847
|
+
* Returns a discriminated result; callers should never assume the
|
|
6848
|
+
* LLM was reachable or produced valid output. The function catches
|
|
6849
|
+
* every expected failure mode and maps it to a friendly `unavailable`
|
|
6850
|
+
* or `error` result — the CLI can then decide how to present it.
|
|
6851
|
+
*/
|
|
6852
|
+
async function askAi(options) {
|
|
6853
|
+
const provider = options.provider ?? "openai";
|
|
6854
|
+
const apiKey = process.env.OPENAI_API_KEY;
|
|
6855
|
+
if (provider === "openai" && !apiKey) return {
|
|
6856
|
+
kind: "unavailable",
|
|
6857
|
+
reason: "OPENAI_API_KEY environment variable is not set",
|
|
6858
|
+
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>\"`."
|
|
6859
|
+
};
|
|
6860
|
+
let aiModule;
|
|
6861
|
+
try {
|
|
6862
|
+
aiModule = await import("@forinda/kickjs-ai");
|
|
6863
|
+
} catch {
|
|
6864
|
+
return {
|
|
6865
|
+
kind: "unavailable",
|
|
6866
|
+
reason: "@forinda/kickjs-ai is not installed",
|
|
6867
|
+
suggestion: "Install the AI package to enable the LLM fallback:\n kick add ai\n\nOr manually:\n pnpm add @forinda/kickjs-ai"
|
|
6868
|
+
};
|
|
6869
|
+
}
|
|
6870
|
+
const { OpenAIProvider } = aiModule;
|
|
6871
|
+
const instance = new OpenAIProvider({
|
|
6872
|
+
apiKey,
|
|
6873
|
+
defaultChatModel: options.model ?? "gpt-4o-mini"
|
|
6874
|
+
});
|
|
6875
|
+
const systemPrompt = buildSystemPrompt(options.cwd);
|
|
6876
|
+
const userPrompt = `Error or stack trace:\n\n${options.input.trim()}`;
|
|
6877
|
+
try {
|
|
6878
|
+
const diagnosis = parseDiagnosisFromResponse((await instance.chat({ messages: [{
|
|
6879
|
+
role: "system",
|
|
6880
|
+
content: systemPrompt
|
|
6881
|
+
}, {
|
|
6882
|
+
role: "user",
|
|
6883
|
+
content: userPrompt
|
|
6884
|
+
}] })).content);
|
|
6885
|
+
if (!diagnosis) return {
|
|
6886
|
+
kind: "error",
|
|
6887
|
+
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."
|
|
6888
|
+
};
|
|
6889
|
+
return {
|
|
6890
|
+
kind: "ok",
|
|
6891
|
+
diagnosis
|
|
6892
|
+
};
|
|
6893
|
+
} catch (err) {
|
|
6894
|
+
return {
|
|
6895
|
+
kind: "error",
|
|
6896
|
+
message: `LLM request failed: ${err instanceof Error ? err.message : String(err)}`
|
|
6897
|
+
};
|
|
6898
|
+
}
|
|
6899
|
+
}
|
|
6900
|
+
/**
|
|
6901
|
+
* Build the system prompt that tells the LLM what KickJS is and how
|
|
6902
|
+
* to structure its response. The prompt is deliberately prescriptive:
|
|
6903
|
+
* the caller needs a JSON payload it can render via the same formatter
|
|
6904
|
+
* the known-issues path uses, so freeform text doesn't work.
|
|
6905
|
+
*
|
|
6906
|
+
* Keep this prompt short — every token counts at inference time and
|
|
6907
|
+
* the CLI is often called interactively.
|
|
6908
|
+
*/
|
|
6909
|
+
function buildSystemPrompt(cwd) {
|
|
6910
|
+
return [
|
|
6911
|
+
"You are a diagnostic assistant for KickJS, a decorator-driven Node.js",
|
|
6912
|
+
"framework built on Express 5 and TypeScript. KickJS projects use:",
|
|
6913
|
+
" - @Controller, @Get, @Post, @Autowired, @Service, @Value decorators",
|
|
6914
|
+
" - An AppModule interface with a routes() method (NOT a @Module decorator)",
|
|
6915
|
+
" - Zod schemas as both runtime validators and OpenAPI sources",
|
|
6916
|
+
" - Ctx<KickRoutes.ControllerName['method']> for typed request context",
|
|
6917
|
+
" - src/config/index.ts with defineEnv/loadEnv for env schema",
|
|
6918
|
+
" - A side-effect `import \"./config\"` in src/index.ts to register the schema",
|
|
6919
|
+
" - Container.reset() in beforeEach for DI test isolation",
|
|
6920
|
+
"",
|
|
6921
|
+
"When the user gives you an error message or stack trace, produce a",
|
|
6922
|
+
"structured diagnosis that helps them fix the bug. You MUST respond",
|
|
6923
|
+
"with a single JSON object (no surrounding prose, no markdown fences)",
|
|
6924
|
+
"matching this shape:",
|
|
6925
|
+
"",
|
|
6926
|
+
"{",
|
|
6927
|
+
" \"id\": \"<kebab-case-identifier>\",",
|
|
6928
|
+
" \"title\": \"<one-line problem summary>\",",
|
|
6929
|
+
" \"explanation\": \"<multi-line explanation of what is wrong>\",",
|
|
6930
|
+
" \"fix\": \"<multi-line instructions for fixing the problem>\",",
|
|
6931
|
+
" \"codeBefore\": \"<optional: broken code snippet>\",",
|
|
6932
|
+
" \"codeAfter\": \"<optional: corrected code snippet>\",",
|
|
6933
|
+
" \"docs\": \"<optional: KickJS doc URL that discusses this topic>\"",
|
|
6934
|
+
"}",
|
|
6935
|
+
"",
|
|
6936
|
+
"The KickJS docs live at https://forinda.github.io/kick-js/ — prefer",
|
|
6937
|
+
"that domain for any doc links you suggest.",
|
|
6938
|
+
cwd ? `The project is located at ${cwd}.` : ""
|
|
6939
|
+
].filter((line) => line.length > 0).join("\n");
|
|
6940
|
+
}
|
|
6941
|
+
/**
|
|
6942
|
+
* Extract a `Diagnosis` object from the LLM response content.
|
|
6943
|
+
*
|
|
6944
|
+
* Tries three strategies in order:
|
|
6945
|
+
* 1. Parse the whole content as JSON directly
|
|
6946
|
+
* 2. Strip a surrounding markdown fence (```json ... ```)
|
|
6947
|
+
* 3. Find the first balanced `{ ... }` block and parse that
|
|
6948
|
+
*
|
|
6949
|
+
* Returns null if none of the strategies produce a valid object with
|
|
6950
|
+
* at least the required fields (id, title, explanation, fix).
|
|
6951
|
+
*/
|
|
6952
|
+
function parseDiagnosisFromResponse(content) {
|
|
6953
|
+
const attempts = [
|
|
6954
|
+
content,
|
|
6955
|
+
stripMarkdownFence(content),
|
|
6956
|
+
extractFirstJsonObject(content)
|
|
6957
|
+
].filter((s) => s !== null);
|
|
6958
|
+
for (const attempt of attempts) try {
|
|
6959
|
+
const parsed = JSON.parse(attempt);
|
|
6960
|
+
if (isValidDiagnosis(parsed)) return parsed;
|
|
6961
|
+
} catch {
|
|
6962
|
+
continue;
|
|
6963
|
+
}
|
|
6964
|
+
return null;
|
|
6965
|
+
}
|
|
6966
|
+
function stripMarkdownFence(text) {
|
|
6967
|
+
const match = text.match(/```(?:json)?\s*\n([\s\S]*?)```/);
|
|
6968
|
+
return match ? match[1]?.trim() ?? null : null;
|
|
6969
|
+
}
|
|
6970
|
+
function extractFirstJsonObject(text) {
|
|
6971
|
+
const start = text.indexOf("{");
|
|
6972
|
+
if (start === -1) return null;
|
|
6973
|
+
let depth = 0;
|
|
6974
|
+
let inString = false;
|
|
6975
|
+
let escape = false;
|
|
6976
|
+
for (let i = start; i < text.length; i++) {
|
|
6977
|
+
const ch = text[i];
|
|
6978
|
+
if (escape) {
|
|
6979
|
+
escape = false;
|
|
6980
|
+
continue;
|
|
6981
|
+
}
|
|
6982
|
+
if (ch === "\\" && inString) {
|
|
6983
|
+
escape = true;
|
|
6984
|
+
continue;
|
|
6985
|
+
}
|
|
6986
|
+
if (ch === "\"") {
|
|
6987
|
+
inString = !inString;
|
|
6988
|
+
continue;
|
|
6989
|
+
}
|
|
6990
|
+
if (inString) continue;
|
|
6991
|
+
if (ch === "{") depth++;
|
|
6992
|
+
if (ch === "}") {
|
|
6993
|
+
depth--;
|
|
6994
|
+
if (depth === 0) return text.slice(start, i + 1);
|
|
6995
|
+
}
|
|
6996
|
+
}
|
|
6997
|
+
return null;
|
|
6998
|
+
}
|
|
6999
|
+
function isValidDiagnosis(value) {
|
|
7000
|
+
if (value === null || typeof value !== "object") return false;
|
|
7001
|
+
const v = value;
|
|
7002
|
+
return typeof v.id === "string" && typeof v.title === "string" && typeof v.explanation === "string" && typeof v.fix === "string";
|
|
7003
|
+
}
|
|
7004
|
+
//#endregion
|
|
7005
|
+
//#region src/commands/explain.ts
|
|
7006
|
+
/**
|
|
7007
|
+
* `kick explain` — explain a KickJS error and suggest a fix.
|
|
7008
|
+
*
|
|
7009
|
+
* The command takes an error message (positional arg, --message flag,
|
|
7010
|
+
* or stdin), runs it through a registry of known KickJS pitfalls, and
|
|
7011
|
+
* prints the highest-confidence diagnosis with a code fix and a doc
|
|
7012
|
+
* link. If no matcher hits, it prints a "no match" message — the
|
|
7013
|
+
* --ai flag (planned) will fall back to an LLM call against the
|
|
7014
|
+
* registered AiProvider.
|
|
7015
|
+
*
|
|
7016
|
+
* The known-issues registry lives in src/explain/known-issues.ts and
|
|
7017
|
+
* is the single source of truth for KickJS-specific advice. Adding a
|
|
7018
|
+
* new entry takes ~30 lines and gives every user a permanent fix path.
|
|
7019
|
+
*
|
|
7020
|
+
* @example
|
|
7021
|
+
* ```bash
|
|
7022
|
+
* # As a positional arg
|
|
7023
|
+
* kick explain "config.get('DATABASE_URL') returned undefined"
|
|
7024
|
+
*
|
|
7025
|
+
* # Via stdin (pipe a stack trace)
|
|
7026
|
+
* pnpm test 2>&1 | kick explain
|
|
7027
|
+
*
|
|
7028
|
+
* # Via --message flag
|
|
7029
|
+
* kick explain --message "Reflect.getMetadata is not a function"
|
|
7030
|
+
* ```
|
|
7031
|
+
*/
|
|
7032
|
+
function registerExplainCommand(program) {
|
|
7033
|
+
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) => {
|
|
7034
|
+
const input = await resolveInput(positional, opts.message);
|
|
7035
|
+
if (!input || input.trim().length === 0) {
|
|
7036
|
+
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");
|
|
7037
|
+
process.exit(1);
|
|
7038
|
+
}
|
|
7039
|
+
const ctx = buildExplainContext();
|
|
7040
|
+
const match = findBestMatch(input, ctx);
|
|
7041
|
+
if (opts.json && match) {
|
|
7042
|
+
process.stdout.write(JSON.stringify({
|
|
7043
|
+
matched: true,
|
|
7044
|
+
...match
|
|
7045
|
+
}, null, 2) + "\n");
|
|
7046
|
+
return;
|
|
7047
|
+
}
|
|
7048
|
+
if (match) {
|
|
7049
|
+
printDiagnosis(input, match.diagnosis, match.confidence);
|
|
7050
|
+
return;
|
|
7051
|
+
}
|
|
7052
|
+
if (!opts.ai) {
|
|
7053
|
+
if (opts.json) {
|
|
7054
|
+
process.stdout.write(JSON.stringify({ matched: false }, null, 2) + "\n");
|
|
7055
|
+
process.exit(2);
|
|
7056
|
+
}
|
|
7057
|
+
printNoMatch(input, false);
|
|
7058
|
+
process.exit(2);
|
|
7059
|
+
}
|
|
7060
|
+
const result = await askAi({
|
|
7061
|
+
input,
|
|
7062
|
+
model: opts.model,
|
|
7063
|
+
cwd: ctx.cwd
|
|
7064
|
+
});
|
|
7065
|
+
if (opts.json) {
|
|
7066
|
+
process.stdout.write(JSON.stringify(aiResultToJson(result), null, 2) + "\n");
|
|
7067
|
+
process.exit(result.kind === "ok" ? 0 : 2);
|
|
7068
|
+
}
|
|
7069
|
+
printAiResult(input, result);
|
|
7070
|
+
process.exit(result.kind === "ok" ? 0 : 2);
|
|
7071
|
+
});
|
|
7072
|
+
}
|
|
7073
|
+
/** Serialize an AskAiResult for `--json` output. */
|
|
7074
|
+
function aiResultToJson(result) {
|
|
7075
|
+
if (result.kind === "ok") return {
|
|
7076
|
+
matched: true,
|
|
7077
|
+
source: "ai",
|
|
7078
|
+
diagnosis: result.diagnosis
|
|
7079
|
+
};
|
|
7080
|
+
if (result.kind === "unavailable") return {
|
|
7081
|
+
matched: false,
|
|
7082
|
+
aiUnavailable: true,
|
|
7083
|
+
reason: result.reason
|
|
7084
|
+
};
|
|
7085
|
+
return {
|
|
7086
|
+
matched: false,
|
|
7087
|
+
aiError: true,
|
|
7088
|
+
error: result.message
|
|
7089
|
+
};
|
|
7090
|
+
}
|
|
7091
|
+
/** Render an AskAiResult to stdout using the same formatting as local matches. */
|
|
7092
|
+
function printAiResult(input, result) {
|
|
7093
|
+
if (result.kind === "ok") {
|
|
7094
|
+
printDiagnosis(input, result.diagnosis, -1, true);
|
|
7095
|
+
return;
|
|
7096
|
+
}
|
|
7097
|
+
if (result.kind === "unavailable") {
|
|
7098
|
+
process.stdout.write(`\n Explaining: ${truncate(input.trim(), 200)}\n\n`);
|
|
7099
|
+
process.stdout.write(` AI fallback unavailable: ${result.reason}\n\n`);
|
|
7100
|
+
process.stdout.write(`${indent(result.suggestion, " ")}\n\n`);
|
|
7101
|
+
return;
|
|
7102
|
+
}
|
|
7103
|
+
process.stdout.write(`\n Explaining: ${truncate(input.trim(), 200)}\n\n`);
|
|
7104
|
+
process.stdout.write(` AI fallback error: ${result.message}\n\n`);
|
|
7105
|
+
}
|
|
7106
|
+
/**
|
|
7107
|
+
* Resolve the error text from positional arg, --message flag, or stdin.
|
|
7108
|
+
*
|
|
7109
|
+
* Precedence: positional > flag > stdin. We only read stdin if neither
|
|
7110
|
+
* of the first two were provided AND stdin is not a TTY (i.e. something
|
|
7111
|
+
* is being piped in). Reading from a real TTY would hang waiting for
|
|
7112
|
+
* the user to type, which is never what they want.
|
|
7113
|
+
*/
|
|
7114
|
+
async function resolveInput(positional, flag) {
|
|
7115
|
+
if (positional && positional.trim().length > 0) return positional;
|
|
7116
|
+
if (flag && flag.trim().length > 0) return flag;
|
|
7117
|
+
if (process.stdin.isTTY) return "";
|
|
7118
|
+
return readStdinAll();
|
|
7119
|
+
}
|
|
7120
|
+
function readStdinAll() {
|
|
7121
|
+
return new Promise((resolve, reject) => {
|
|
7122
|
+
let buffer = "";
|
|
7123
|
+
process.stdin.setEncoding("utf8");
|
|
7124
|
+
process.stdin.on("data", (chunk) => {
|
|
7125
|
+
buffer += chunk;
|
|
7126
|
+
});
|
|
7127
|
+
process.stdin.on("end", () => resolve(buffer));
|
|
7128
|
+
process.stdin.on("error", reject);
|
|
7129
|
+
});
|
|
7130
|
+
}
|
|
7131
|
+
/**
|
|
7132
|
+
* Build a small context object the matchers can use to check project
|
|
7133
|
+
* state — e.g. "does this project have a src/config/index.ts?".
|
|
7134
|
+
*
|
|
7135
|
+
* Kept intentionally minimal to avoid pulling the full kick.config
|
|
7136
|
+
* loader into a fast-path command. Matchers should treat this as
|
|
7137
|
+
* best-effort and degrade gracefully when ctx is undefined.
|
|
7138
|
+
*/
|
|
7139
|
+
function buildExplainContext() {
|
|
7140
|
+
const cwd = process.cwd();
|
|
7141
|
+
return {
|
|
7142
|
+
cwd,
|
|
7143
|
+
hasFile: (path) => existsSync(resolve(cwd, path))
|
|
7144
|
+
};
|
|
7145
|
+
}
|
|
7146
|
+
function printDiagnosis(input, d, confidence, aiLabel = false) {
|
|
7147
|
+
const inputSnippet = truncate(input.trim(), 200);
|
|
7148
|
+
const label = aiLabel ? "AI-generated — verify before applying" : labelConfidence(confidence);
|
|
7149
|
+
process.stdout.write(`\n Explaining: ${inputSnippet}\n`);
|
|
7150
|
+
process.stdout.write(`\n Match: ${d.id} (${label})\n`);
|
|
7151
|
+
process.stdout.write(` Title: ${d.title}\n`);
|
|
7152
|
+
process.stdout.write(`\n Diagnosis:\n${indent(d.explanation, " ")}\n`);
|
|
7153
|
+
process.stdout.write(`\n Fix:\n${indent(d.fix, " ")}\n`);
|
|
7154
|
+
if (d.codeBefore) process.stdout.write(`\n Before:\n${indent(d.codeBefore, " ")}\n`);
|
|
7155
|
+
if (d.codeAfter) process.stdout.write(`\n After:\n${indent(d.codeAfter, " ")}\n`);
|
|
7156
|
+
if (d.docs) process.stdout.write(`\n Docs: ${d.docs}\n`);
|
|
7157
|
+
process.stdout.write("\n");
|
|
7158
|
+
}
|
|
7159
|
+
function printNoMatch(input, aiRequested) {
|
|
7160
|
+
const snippet = truncate(input.trim(), 200);
|
|
7161
|
+
process.stdout.write(`\n Explaining: ${snippet}\n\n`);
|
|
7162
|
+
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");
|
|
7163
|
+
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");
|
|
7164
|
+
}
|
|
7165
|
+
function indent(text, prefix) {
|
|
7166
|
+
return text.split("\n").map((line) => `${prefix}${line}`).join("\n");
|
|
7167
|
+
}
|
|
7168
|
+
function truncate(text, max) {
|
|
7169
|
+
if (text.length <= max) return text;
|
|
7170
|
+
return text.slice(0, max - 1) + "…";
|
|
7171
|
+
}
|
|
7172
|
+
function labelConfidence(score) {
|
|
7173
|
+
if (score >= 90) return "high confidence";
|
|
7174
|
+
if (score >= 70) return "good match";
|
|
7175
|
+
if (score >= 50) return "medium confidence";
|
|
7176
|
+
return "low confidence — verify manually";
|
|
7177
|
+
}
|
|
7178
|
+
//#endregion
|
|
7179
|
+
//#region src/commands/mcp.ts
|
|
7180
|
+
/**
|
|
7181
|
+
* `kick mcp` — Model Context Protocol commands.
|
|
7182
|
+
*
|
|
7183
|
+
* Two subcommands:
|
|
7184
|
+
* - `kick mcp` (default → `start`): runs the built application as an
|
|
7185
|
+
* MCP server over stdio. The user's app must already wire `McpAdapter`
|
|
7186
|
+
* from `@forinda/kickjs-mcp` into its bootstrap. The CLI just spawns
|
|
7187
|
+
* the built entry as a subprocess with `KICK_MCP_STDIO=1`, which the
|
|
7188
|
+
* adapter detects and uses to switch its transport from
|
|
7189
|
+
* StreamableHTTP to stdio. The subprocess inherits stdin/stdout/stderr
|
|
7190
|
+
* so the MCP wire protocol flows directly between the parent process
|
|
7191
|
+
* (the MCP client — Claude Code, Cursor, etc.) and the child app.
|
|
7192
|
+
* - `kick mcp init`: generates a `.mcp.json` config file pointing at
|
|
7193
|
+
* this project, ready to drop into a Claude Code / Cursor workspace.
|
|
7194
|
+
*
|
|
7195
|
+
* Logs MUST go to stderr in stdio mode — anything written to stdout
|
|
7196
|
+
* corrupts the JSON-RPC protocol stream. Pino's default stream is
|
|
7197
|
+
* stderr already, so this works out of the box for KickJS apps using
|
|
7198
|
+
* the framework's bundled logger.
|
|
7199
|
+
*/
|
|
7200
|
+
function registerMcpCommand(program) {
|
|
7201
|
+
const mcp = program.command("mcp").description("Model Context Protocol commands (start | init)");
|
|
7202
|
+
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);
|
|
7203
|
+
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);
|
|
7204
|
+
}
|
|
7205
|
+
function runMcpServer(opts) {
|
|
7206
|
+
const entry = resolve(opts.entry);
|
|
7207
|
+
if (!existsSync(entry)) {
|
|
7208
|
+
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`);
|
|
7209
|
+
process.exit(1);
|
|
7210
|
+
}
|
|
7211
|
+
const nodeArgs = [...opts.nodeArg ?? [], entry];
|
|
7212
|
+
const child = spawn(process.execPath, nodeArgs, {
|
|
7213
|
+
stdio: "inherit",
|
|
7214
|
+
env: {
|
|
7215
|
+
...process.env,
|
|
7216
|
+
KICK_MCP_STDIO: "1",
|
|
7217
|
+
NODE_ENV: process.env.NODE_ENV ?? "production"
|
|
7218
|
+
}
|
|
7219
|
+
});
|
|
7220
|
+
child.on("error", (err) => {
|
|
7221
|
+
process.stderr.write(`Failed to start MCP server: ${err.message}\n`);
|
|
7222
|
+
process.exit(1);
|
|
7223
|
+
});
|
|
7224
|
+
child.on("exit", (code, signal) => {
|
|
7225
|
+
if (signal) {
|
|
7226
|
+
process.kill(process.pid, signal);
|
|
7227
|
+
return;
|
|
7228
|
+
}
|
|
7229
|
+
process.exit(code ?? 0);
|
|
7230
|
+
});
|
|
7231
|
+
const forward = (signal) => {
|
|
7232
|
+
if (!child.killed) child.kill(signal);
|
|
7233
|
+
};
|
|
7234
|
+
process.on("SIGINT", () => forward("SIGINT"));
|
|
7235
|
+
process.on("SIGTERM", () => forward("SIGTERM"));
|
|
7236
|
+
}
|
|
7237
|
+
function initMcpConfig(opts) {
|
|
7238
|
+
const cwd = process.cwd();
|
|
7239
|
+
const projectName = readPackageName(cwd) ?? basename(cwd);
|
|
7240
|
+
const serverName = opts.name ?? projectName;
|
|
7241
|
+
const outPath = opts.global ? resolve(process.env.HOME ?? ".", ".mcp.json") : resolve(cwd, opts.out);
|
|
7242
|
+
const entry = {
|
|
7243
|
+
command: "kick",
|
|
7244
|
+
args: ["mcp"],
|
|
7245
|
+
cwd
|
|
7246
|
+
};
|
|
7247
|
+
let config = { mcpServers: {} };
|
|
7248
|
+
if (existsSync(outPath)) try {
|
|
7249
|
+
const raw = readFileSync(outPath, "utf8");
|
|
7250
|
+
const parsed = JSON.parse(raw);
|
|
7251
|
+
if (parsed && typeof parsed === "object" && parsed.mcpServers) config = { mcpServers: { ...parsed.mcpServers } };
|
|
7252
|
+
} catch (err) {
|
|
7253
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
7254
|
+
process.stderr.write(`Error: existing ${outPath} is not valid JSON (${message}).\nFix the file or pass --force to overwrite the entry.\n`);
|
|
7255
|
+
process.exit(1);
|
|
7256
|
+
}
|
|
7257
|
+
if (config.mcpServers[serverName] && !opts.force) {
|
|
7258
|
+
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`);
|
|
7259
|
+
process.exit(1);
|
|
7260
|
+
}
|
|
7261
|
+
config.mcpServers[serverName] = entry;
|
|
7262
|
+
writeFileSync(outPath, JSON.stringify(config, null, 2) + "\n", "utf8");
|
|
7263
|
+
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`);
|
|
7264
|
+
}
|
|
7265
|
+
/**
|
|
7266
|
+
* Read the `name` field from the project's `package.json`. Returns
|
|
7267
|
+
* null if the file is missing or unparseable — callers fall back to
|
|
7268
|
+
* the directory name in that case.
|
|
7269
|
+
*/
|
|
7270
|
+
function readPackageName(cwd) {
|
|
7271
|
+
const pkgPath = resolve(cwd, "package.json");
|
|
7272
|
+
if (!existsSync(pkgPath)) return null;
|
|
7273
|
+
try {
|
|
7274
|
+
const raw = readFileSync(pkgPath, "utf8");
|
|
7275
|
+
const parsed = JSON.parse(raw);
|
|
7276
|
+
if (typeof parsed.name === "string") return parsed.name;
|
|
7277
|
+
return null;
|
|
7278
|
+
} catch {
|
|
7279
|
+
return null;
|
|
7280
|
+
}
|
|
7281
|
+
}
|
|
7282
|
+
//#endregion
|
|
6378
7283
|
//#region src/commands/tinker.ts
|
|
6379
7284
|
function registerTinkerCommand(program) {
|
|
6380
7285
|
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) => {
|
|
@@ -6613,10 +7518,10 @@ function registerTypegenCommand(program) {
|
|
|
6613
7518
|
//#endregion
|
|
6614
7519
|
//#region src/cli.ts
|
|
6615
7520
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
6616
|
-
const pkg = JSON.parse(readFileSync(join(__dirname, "..", "package.json"), "utf-8"));
|
|
7521
|
+
const pkg$1 = JSON.parse(readFileSync(join(__dirname, "..", "package.json"), "utf-8"));
|
|
6617
7522
|
async function main() {
|
|
6618
7523
|
const program = new Command();
|
|
6619
|
-
program.name("kick").description("KickJS — A production-grade, decorator-driven Node.js framework").version(pkg.version);
|
|
7524
|
+
program.name("kick").description("KickJS — A production-grade, decorator-driven Node.js framework").version(pkg$1.version);
|
|
6620
7525
|
const config = await loadKickConfig(process.cwd());
|
|
6621
7526
|
registerInitCommand(program);
|
|
6622
7527
|
registerGenerateCommand(program);
|
|
@@ -6625,6 +7530,8 @@ async function main() {
|
|
|
6625
7530
|
registerInspectCommand(program);
|
|
6626
7531
|
registerAddCommand(program);
|
|
6627
7532
|
registerListCommand(program);
|
|
7533
|
+
registerExplainCommand(program);
|
|
7534
|
+
registerMcpCommand(program);
|
|
6628
7535
|
registerTinkerCommand(program);
|
|
6629
7536
|
registerRemoveCommand(program);
|
|
6630
7537
|
registerTypegenCommand(program);
|