@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.
- package/dist/builtins-BdvmVAJ1.mjs +3740 -0
- package/dist/builtins-Du70nybS.mjs +1066 -0
- package/dist/builtins-Du70nybS.mjs.map +1 -0
- package/dist/cli.mjs +2 -120
- package/dist/config-Dzw8Ws4d.mjs +11 -0
- package/dist/config-lCKbrRnt.mjs +12 -0
- package/dist/{config-DDrgs-I3.mjs.map → config-lCKbrRnt.mjs.map} +1 -1
- package/dist/generator-extension-Cp5FUUAw.mjs +2687 -0
- package/dist/generator-extension-Cp5FUUAw.mjs.map +1 -0
- package/dist/index.mjs +2 -5
- package/dist/plugin-Dv2gKsuC.mjs +11 -0
- package/dist/plugin-VPl_QQGb.mjs +12 -0
- package/dist/{plugin-6_YlK-JG.mjs.map → plugin-VPl_QQGb.mjs.map} +1 -1
- package/dist/rolldown-runtime-B6QC8dMY.mjs +11 -0
- package/dist/{run-plugins-B1R0HG0g.mjs → run-plugins-CM1Af-4B.mjs} +2 -3
- package/dist/typegen-C6ZfoYTC.mjs +114 -0
- package/dist/typegen-CBI7dNXr.mjs +115 -0
- package/dist/{typegen-DugZmi-0.mjs.map → typegen-CBI7dNXr.mjs.map} +1 -1
- package/dist/types-n4LRUF_c.mjs +12 -0
- package/dist/{types-CGB8BiQh.mjs.map → types-n4LRUF_c.mjs.map} +1 -1
- package/package.json +5 -5
- package/dist/builtins-BW3g09hP.mjs +0 -8538
- package/dist/builtins-C_VfEGdg.mjs +0 -4182
- package/dist/builtins-C_VfEGdg.mjs.map +0 -1
- package/dist/config-DDrgs-I3.mjs +0 -171
- package/dist/config-DsQe2yzy.mjs +0 -169
- package/dist/generator-extension-DRNQpoZP.mjs +0 -4380
- package/dist/generator-extension-DRNQpoZP.mjs.map +0 -1
- package/dist/plugin-6_YlK-JG.mjs +0 -71
- package/dist/plugin-CQ0yYXyr.mjs +0 -80
- package/dist/rolldown-runtime-CYBbkZNy.mjs +0 -24
- package/dist/typegen-CYCsmCRF.mjs +0 -1351
- package/dist/typegen-DugZmi-0.mjs +0 -1353
- 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
|