@forinda/kickjs-cli 3.1.3 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * @forinda/kickjs-cli v3.1.3
2
+ * @forinda/kickjs-cli v4.0.0
3
3
  *
4
4
  * Copyright (c) Felix Orinda
5
5
  *
@@ -8,15 +8,17 @@
8
8
  *
9
9
  * @license MIT
10
10
  */
11
+ import { createRequire } from "node:module";
11
12
  import { Command } from "commander";
12
- import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
13
- import { basename, dirname, join, relative, resolve, sep } from "node:path";
13
+ import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
14
+ import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep } from "node:path";
14
15
  import { fileURLToPath, pathToFileURL } from "node:url";
15
16
  import { execSync, fork, spawn, spawnSync } from "node:child_process";
16
17
  import { access, mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises";
17
18
  import * as clack from "@clack/prompts";
18
19
  import pc from "picocolors";
19
20
  import pkg from "pluralize";
21
+ import { glob, globSync } from "glob";
20
22
  import { arch, platform, release } from "node:os";
21
23
  //#region \0rolldown/runtime.js
22
24
  var __defProp = Object.defineProperty;
@@ -296,15 +298,15 @@ function generateEntryFile(name, template, version, packages = []) {
296
298
  const gqlAdapters = [];
297
299
  if (packages.includes("devtools")) {
298
300
  gqlImports.push(`import { DevToolsAdapter } from '@forinda/kickjs-devtools'`);
299
- gqlAdapters.push(` new DevToolsAdapter(),`);
301
+ gqlAdapters.push(` DevToolsAdapter(),`);
300
302
  }
301
303
  if (packages.includes("otel")) {
302
304
  gqlImports.push(`import { OtelAdapter } from '@forinda/kickjs-otel'`);
303
- gqlAdapters.push(` new OtelAdapter({ serviceName: '${name}' }),`);
305
+ gqlAdapters.push(` OtelAdapter({ serviceName: '${name}' }),`);
304
306
  }
305
307
  if (packages.includes("swagger")) {
306
308
  gqlImports.push(`import { SwaggerAdapter } from '@forinda/kickjs-swagger'`);
307
- gqlAdapters.push(` new SwaggerAdapter({ info: { title: '${name}', version: '${version}' } }),`);
309
+ gqlAdapters.push(` SwaggerAdapter({ info: { title: '${name}', version: '${version}' } }),`);
308
310
  }
309
311
  return `import 'reflect-metadata'
310
312
  // Side-effect import — registers the extended env schema with kickjs
@@ -337,15 +339,15 @@ ${gqlAdapters.length ? gqlAdapters.join("\n") + "\n" : ""} new GraphQLAdapter
337
339
  const cqrsAdapters = [];
338
340
  if (packages.includes("otel")) {
339
341
  cqrsImports.push(`import { OtelAdapter } from '@forinda/kickjs-otel'`);
340
- cqrsAdapters.push(` new OtelAdapter({ serviceName: '${name}' }),`);
342
+ cqrsAdapters.push(` OtelAdapter({ serviceName: '${name}' }),`);
341
343
  }
342
344
  if (packages.includes("devtools")) {
343
345
  cqrsImports.push(`import { DevToolsAdapter } from '@forinda/kickjs-devtools'`);
344
- cqrsAdapters.push(` new DevToolsAdapter(),`);
346
+ cqrsAdapters.push(` DevToolsAdapter(),`);
345
347
  }
346
348
  if (packages.includes("swagger")) {
347
349
  cqrsImports.push(`import { SwaggerAdapter } from '@forinda/kickjs-swagger'`);
348
- cqrsAdapters.push(` new SwaggerAdapter({\n info: { title: '${name}', version: '${version}' },\n }),`);
350
+ cqrsAdapters.push(` SwaggerAdapter({\n info: { title: '${name}', version: '${version}' },\n }),`);
349
351
  }
350
352
  if (packages.includes("graphql")) {
351
353
  cqrsImports.push(`import { GraphQLAdapter } from '@forinda/kickjs-graphql'`);
@@ -364,7 +366,7 @@ ${cqrsImports.length ? cqrsImports.join("\n") + "\n" : ""}import { modules } fro
364
366
 
365
367
  // Export the app for the Vite plugin (dev mode)
366
368
  export const app = await bootstrap({
367
- modules,${cqrsImports.length ? `\n adapters: [\n${cqrsAdapters.join("\n")}\n // Uncomment for WebSocket support:\n // new WsAdapter(),\n // Uncomment when Redis is available:\n // new QueueAdapter({\n // provider: new BullMQProvider({ host: 'localhost', port: 6379 }),\n // }),\n ],` : `\n adapters: [\n // Uncomment for WebSocket support:\n // new WsAdapter(),\n // Uncomment when Redis is available:\n // new QueueAdapter({\n // provider: new BullMQProvider({ host: 'localhost', port: 6379 }),\n // }),\n ],`}
369
+ modules,${cqrsImports.length ? `\n adapters: [\n${cqrsAdapters.join("\n")}\n // Uncomment for WebSocket support:\n // WsAdapter(),\n // Uncomment when Redis is available:\n // QueueAdapter({\n // provider: new BullMQProvider({ host: 'localhost', port: 6379 }),\n // }),\n ],` : `\n adapters: [\n // Uncomment for WebSocket support:\n // WsAdapter(),\n // Uncomment when Redis is available:\n // QueueAdapter({\n // provider: new BullMQProvider({ host: 'localhost', port: 6379 }),\n // }),\n ],`}
368
370
  })
369
371
  `;
370
372
  }
@@ -373,15 +375,15 @@ export const app = await bootstrap({
373
375
  const adapters = [];
374
376
  if (packages.includes("swagger")) {
375
377
  imports.push(`import { SwaggerAdapter } from '@forinda/kickjs-swagger'`);
376
- adapters.push(` new SwaggerAdapter({ info: { title: '${name}', version: '${version}' } }),`);
378
+ adapters.push(` SwaggerAdapter({ info: { title: '${name}', version: '${version}' } }),`);
377
379
  }
378
380
  if (packages.includes("devtools")) {
379
381
  imports.push(`import { DevToolsAdapter } from '@forinda/kickjs-devtools'`);
380
- adapters.push(` new DevToolsAdapter(),`);
382
+ adapters.push(` DevToolsAdapter(),`);
381
383
  }
382
384
  if (packages.includes("otel")) {
383
385
  imports.push(`import { OtelAdapter } from '@forinda/kickjs-otel'`);
384
- adapters.push(` new OtelAdapter({ serviceName: '${name}' }),`);
386
+ adapters.push(` OtelAdapter({ serviceName: '${name}' }),`);
385
387
  }
386
388
  if (packages.includes("graphql")) {
387
389
  imports.push(`import { GraphQLAdapter } from '@forinda/kickjs-graphql'`);
@@ -405,15 +407,15 @@ export const app = await bootstrap({ modules${adapters.length ? `,\n adapters:
405
407
  const restAdapters = [];
406
408
  if (packages.includes("devtools")) {
407
409
  restImports.push(`import { DevToolsAdapter } from '@forinda/kickjs-devtools'`);
408
- restAdapters.push(` new DevToolsAdapter(),`);
410
+ restAdapters.push(` DevToolsAdapter(),`);
409
411
  }
410
412
  if (packages.includes("swagger")) {
411
413
  restImports.push(`import { SwaggerAdapter } from '@forinda/kickjs-swagger'`);
412
- restAdapters.push(` new SwaggerAdapter({\n info: { title: '${name}', version: '${version}' },\n }),`);
414
+ restAdapters.push(` SwaggerAdapter({\n info: { title: '${name}', version: '${version}' },\n }),`);
413
415
  }
414
416
  if (packages.includes("otel")) {
415
417
  restImports.push(`import { OtelAdapter } from '@forinda/kickjs-otel'`);
416
- restAdapters.push(` new OtelAdapter({ serviceName: '${name}' }),`);
418
+ restAdapters.push(` OtelAdapter({ serviceName: '${name}' }),`);
417
419
  }
418
420
  return `import 'reflect-metadata'
419
421
  // Side-effect import — registers the extended env schema with kickjs
@@ -711,6 +713,8 @@ Copy \`.env.example\` to \`.env\` and configure:
711
713
  function generateClaude(name, template, pm) {
712
714
  return `# CLAUDE.md — ${name} Development Guide
713
715
 
716
+ > **Read \`AGENTS.md\` first.** It is the canonical, multi-agent reference for this project (Claude, Copilot, Codex, Gemini, etc.). This file contains the same project context distilled for Claude, plus Claude-specific notes. When the two disagree on anything substantive, treat \`AGENTS.md\` as authoritative and flag the discrepancy.
717
+
714
718
  ## Project Overview
715
719
 
716
720
  This is a **${{
@@ -767,7 +771,7 @@ generated \`KickRoutes\` namespace (refreshed on \`kick dev\` and \`kick typegen
767
771
  \`\`\`ts
768
772
  import { Controller, Get, Post, type Ctx } from '@forinda/kickjs'
769
773
 
770
- @Controller('/users')
774
+ @Controller()
771
775
  export class UserController {
772
776
  @Get('/')
773
777
  async findAll(ctx: Ctx<KickRoutes.UserController['findAll']>) {
@@ -802,9 +806,12 @@ export class UserService {
802
806
 
803
807
  ### Modules
804
808
 
805
- Modules implement \`AppModule\` and wire controllers via \`buildRoutes()\`:
809
+ Modules implement \`AppModule\` and wire controllers via \`buildRoutes()\`.
810
+
811
+ > **Naming matters.** Module files **must** be named \`<name>.module.ts\` and live under \`src/modules/\`. The Vite plugin auto-discovers files matching \`*.module.[tj]sx?\` for HMR — a misnamed file (e.g., \`projects.ts\`) won't trigger a graceful module rebuild on save and will require a full server restart. The CLI generator (\`kick g module <name>\`) follows this convention automatically.
806
812
 
807
813
  \`\`\`ts
814
+ // src/modules/users/users.module.ts (named <feature>.module.ts)
808
815
  import { type AppModule, type ModuleRoutes, buildRoutes } from '@forinda/kickjs'
809
816
  import { UserController } from './user.controller'
810
817
 
@@ -855,6 +862,8 @@ ctx.notFound() // 404 Not Found
855
862
  ctx.badRequest(msg) // 400 Bad Request
856
863
  \`\`\`
857
864
 
865
+ > **Context decorators** — when a middleware's only job is to populate \`ctx.set/get\` for the handler to read, prefer \`defineContextDecorator()\` over \`@Middleware()\`. Typed via \`ContextMeta\`, supports \`dependsOn\` ordering, validates the pipeline at boot. Full pattern reference in \`AGENTS.md\` and at <https://forinda.github.io/kick-js/guide/context-decorators>.
866
+
858
867
  ## CLI Generators
859
868
 
860
869
  Generate code with the \`kick\` CLI:
@@ -1043,7 +1052,7 @@ Run tests:
1043
1052
  ## Decorators Reference
1044
1053
 
1045
1054
  ### Route Decorators
1046
- - \`@Controller('/path')\` — define controller prefix
1055
+ - \`@Controller()\` — mark a class as an HTTP controller (path comes from \`routes().path\`)
1047
1056
  - \`@Get('/'), @Post('/'), @Put('/'), @Delete('/'), @Patch('/')\` — HTTP methods
1048
1057
  - \`@Middleware(fn)\` — attach middleware
1049
1058
  - \`@Public()\` — skip authentication (requires @forinda/kickjs-auth)
@@ -1118,6 +1127,8 @@ ${template === "graphql" ? "| GraphQL resolvers | `src/resolvers/` |\n" : ""}| E
1118
1127
 
1119
1128
  ### Module Pattern (${template.toUpperCase()})
1120
1129
 
1130
+ > **Vite HMR auto-discovery contract:** module files **must** be named \`<name>.module.ts\` (or \`.tsx\`/\`.js\`/\`.jsx\`) and live under \`src/modules/\`. The Vite plugin scans for \`*.module.[tj]sx?\` to drive graceful HMR rebuilds; renaming a file to \`projects.ts\` (no \`.module\`) silently breaks HMR — saves trigger a full restart instead of a swap. The CLI generator (\`kick g module <name>\`) follows the convention; manual files must too.
1131
+
1121
1132
  Each module in \`src/modules/<name>/\` typically contains:
1122
1133
 
1123
1134
  ${template === "ddd" ? `\`\`\`
@@ -1188,7 +1199,7 @@ Then:
1188
1199
  If not using generators:
1189
1200
 
1190
1201
  - [ ] Create \`src/modules/<name>/<name>.controller.ts\`
1191
- - [ ] Add \`@Controller('/path')\` decorator
1202
+ - [ ] Add \`@Controller()\` decorator
1192
1203
  - [ ] Add route handlers with \`@Get()\`, \`@Post()\`, etc.
1193
1204
  - [ ] Create module file implementing \`AppModule\` with \`routes()\` returning \`{ path, router: buildRoutes(Controller), controller }\`
1194
1205
  - [ ] Register module in \`src/modules/index.ts\` (\`AppModuleClass[]\` array)
@@ -1250,8 +1261,8 @@ import { AuthAdapter, JwtStrategy } from '@forinda/kickjs-auth'
1250
1261
  bootstrap({
1251
1262
  modules,
1252
1263
  adapters: [
1253
- new AuthAdapter({
1254
- strategies: [new JwtStrategy({ secret: process.env.JWT_SECRET! })],
1264
+ AuthAdapter({
1265
+ strategies: [JwtStrategy({ secret: process.env.JWT_SECRET! })],
1255
1266
  }),
1256
1267
  ],
1257
1268
  })
@@ -1281,7 +1292,7 @@ import { WsAdapter } from '@forinda/kickjs-ws'
1281
1292
 
1282
1293
  bootstrap({
1283
1294
  modules,
1284
- adapters: [new WsAdapter()],
1295
+ adapters: [WsAdapter()],
1285
1296
  })
1286
1297
  \`\`\`
1287
1298
 
@@ -1385,7 +1396,7 @@ These work anywhere — scripts, plain files, outside \`@Service\`/\`@Controller
1385
1396
  ### HTTP Routes
1386
1397
  | Decorator | Purpose |
1387
1398
  |-----------|---------|
1388
- | \`@Controller('/path')\` | Define route prefix |
1399
+ | \`@Controller()\` | Define route prefix |
1389
1400
  | \`@Get('/'), @Post('/')\` | HTTP method handlers |
1390
1401
  | \`@Middleware(fn)\` | Attach middleware |
1391
1402
  | \`@Public()\` | Skip auth (requires auth adapter) |
@@ -1401,6 +1412,27 @@ These work anywhere — scripts, plain files, outside \`@Service\`/\`@Controller
1401
1412
  | \`@Inject('token')\` | Token-based injection |
1402
1413
  | \`@Value('VAR')\` | Inject env variable |
1403
1414
 
1415
+ ### Context Decorators
1416
+
1417
+ Typed, ordered way to populate \`ctx.set/get\` keys before the handler runs.
1418
+ Use this **instead of \`@Middleware()\`** when the middleware's only output
1419
+ is a value other code reads off \`ctx\`.
1420
+
1421
+ | Concept | Where it lives |
1422
+ |---------|----------------|
1423
+ | \`defineContextDecorator({ key, deps, dependsOn, optional, onError, resolve })\` | \`@forinda/kickjs\` |
1424
+ | Method/class decorator | \`@LoadX\` on a controller method/class |
1425
+ | Module hook | \`AppModule.contributors?(): ContributorRegistration[]\` |
1426
+ | Adapter hook | \`AppAdapter.contributors?(): ContributorRegistration[]\` |
1427
+ | Global registration | \`bootstrap({ contributors: [LoadX.registration] })\` |
1428
+ | Type augmentation | \`declare module '@forinda/kickjs' { interface ContextMeta { ... } }\` |
1429
+
1430
+ Precedence high → low: **method > class > module > adapter > global**.
1431
+ Cycles and missing \`dependsOn\` keys throw at \`app.setup()\` (boot fails
1432
+ fast). The \`onError\` hook is async-permitted.
1433
+
1434
+ Full guide: <https://forinda.github.io/kick-js/guide/context-decorators>.
1435
+
1404
1436
  ${template === "graphql" ? `### GraphQL
1405
1437
  | Decorator | Purpose |
1406
1438
  |-----------|---------|
@@ -1423,9 +1455,11 @@ ${template === "graphql" ? `### GraphQL
1423
1455
  2. **DI not working** — Ensure \`reflect-metadata\` is imported in \`src/index.ts\`
1424
1456
  3. **Tests failing randomly** — Missing \`Container.reset()\` in \`beforeEach\`
1425
1457
  4. **Routes not found** — Check controller path and module registration
1426
- 5. **HMR not working** — Verify \`vite.config.ts\` has \`hmr: true\`
1458
+ 5. **HMR not working** — Two checks: (a) \`vite.config.ts\` has \`hmr: true\`; (b) module file is named \`<name>.module.ts\` (or \`.tsx\`/\`.js\`/\`.jsx\`) and lives under \`src/modules/\`. The Vite plugin auto-discovers \`*.module.[tj]sx?\` for graceful HMR — a misnamed module file (e.g., \`projects.ts\`) silently degrades to a full restart on every save.
1427
1459
  6. **Decorators not working** — Check \`tsconfig.json\` has \`experimentalDecorators: true\`
1428
1460
  7. **\`config.get('YOUR_KEY')\` returns \`undefined\`** — \`src/index.ts\` is missing \`import './config'\`. That side-effect import registers the env schema with kickjs (\`loadEnv(envSchema)\` runs at module load). Without it, \`ConfigService\` falls back to the base schema (\`PORT\`/\`NODE_ENV\`/\`LOG_LEVEL\` only) and every user-defined key reads as \`undefined\`. \`@Value()\` may *appear* to work because of a raw \`process.env\` fallback, but Zod coercion and schema defaults are silently skipped — investigate \`src/index.ts\` and \`src/config/index.ts\` first.
1461
+ 8. **Used \`@Middleware()\` to compute a value for \`ctx\`** — prefer \`defineContextDecorator()\` (see Context Decorators above). It's typed via \`ContextMeta\`, supports \`dependsOn\` for ordering, and validates the pipeline at boot. \`@Middleware()\` is for response short-circuiting, stream mutation, and pre-route-matching work.
1462
+ 9. **Context contributor's \`dependsOn\` key not produced anywhere** — boot throws \`MissingContributorError\` naming the dependent and the route. Either remove the dep or register a contributor that produces the key (at any precedence level: method/class/module/adapter/global).
1429
1463
 
1430
1464
  ## CLI Commands Reference
1431
1465
 
@@ -1883,6 +1917,228 @@ function pluralizePascal(name) {
1883
1917
  return pkg.plural(name);
1884
1918
  }
1885
1919
  //#endregion
1920
+ //#region src/generator-extension/context.ts
1921
+ /** Convert any string to snake_case (`UserPost` / `user-post` → `user_post`). */
1922
+ function toSnakeCase(name) {
1923
+ return toKebabCase(name).replace(/-/g, "_");
1924
+ }
1925
+ /**
1926
+ * Build a {@link GeneratorContext} from the raw name + invocation
1927
+ * arguments. Centralises the case-transformation logic so every plugin
1928
+ * generator sees the same shape regardless of how the name was typed
1929
+ * on the command line (`Post` vs `post` vs `user_post`).
1930
+ */
1931
+ function buildGeneratorContext(input) {
1932
+ const cwd = input.cwd ?? process.cwd();
1933
+ const usePlural = input.pluralize ?? true;
1934
+ const pascal = toPascalCase(input.name);
1935
+ const camel = toCamelCase(input.name);
1936
+ const kebab = toKebabCase(input.name);
1937
+ const snake = toSnakeCase(input.name);
1938
+ const ctx = {
1939
+ name: input.name,
1940
+ pascal,
1941
+ camel,
1942
+ kebab,
1943
+ snake,
1944
+ modulesDir: input.modulesDir ?? "src/modules",
1945
+ cwd,
1946
+ args: input.args ?? [],
1947
+ flags: input.flags ?? {}
1948
+ };
1949
+ if (usePlural) {
1950
+ const pluralKebab = pluralize(kebab);
1951
+ ctx.pluralKebab = pluralKebab;
1952
+ ctx.pluralPascal = toPascalCase(pluralKebab);
1953
+ ctx.pluralCamel = toCamelCase(pluralKebab);
1954
+ }
1955
+ return ctx;
1956
+ }
1957
+ /** Resolve a generator output path against the context's cwd. */
1958
+ function resolveGeneratorPath(ctx, path) {
1959
+ return resolve(ctx.cwd, path);
1960
+ }
1961
+ /**
1962
+ * Dynamic-import a generator manifest file. Wraps `pathToFileURL` so
1963
+ * callers don't have to think about Windows/Unix path quirks.
1964
+ */
1965
+ async function importManifest(absPath) {
1966
+ return import(pathToFileURL(absPath).href);
1967
+ }
1968
+ //#endregion
1969
+ //#region src/generator-extension/discover.ts
1970
+ /**
1971
+ * Discover generator manifests shipped by every kickjs plugin in the
1972
+ * project's direct deps. Spec rationale: walking the
1973
+ * `node_modules/@scope/kickjs-name/` tree is one option, but reading
1974
+ * the project's own `package.json` and resolving each dep through
1975
+ * Node's module resolver gives:
1976
+ *
1977
+ * 1. Predictable scoping — only deps the project actually declared
1978
+ * get scanned, no surprises from transitive packages
1979
+ * 2. pnpm `.pnpm` store compatibility — `createRequire().resolve()`
1980
+ * handles the symlinked layout correctly
1981
+ * 3. Clear error attribution — the source package name is always
1982
+ * known before the import happens
1983
+ *
1984
+ * The walk is shallow (direct deps only). Transitive plugins that want
1985
+ * to expose generators must be re-exported by a direct dep.
1986
+ *
1987
+ * Caches per-cwd inside one CLI invocation so a single `kick g` call
1988
+ * does the disk + import work exactly once even when multiple
1989
+ * generators dispatch through the same registry.
1990
+ */
1991
+ const cache = /* @__PURE__ */ new Map();
1992
+ async function discoverPluginGenerators(cwd) {
1993
+ const cached = cache.get(cwd);
1994
+ if (cached) return cached;
1995
+ const promise = doDiscover(cwd);
1996
+ cache.set(cwd, promise);
1997
+ return promise;
1998
+ }
1999
+ async function doDiscover(cwd) {
2000
+ const projectPkgPath = resolve(cwd, "package.json");
2001
+ if (!existsSync(projectPkgPath)) return {
2002
+ generators: [],
2003
+ loaded: [],
2004
+ failed: []
2005
+ };
2006
+ const depNames = collectDepNames(JSON.parse(await readFile(projectPkgPath, "utf-8")));
2007
+ const require = createRequire(resolve(cwd, "package.json"));
2008
+ const generators = [];
2009
+ const loaded = [];
2010
+ const failed = [];
2011
+ for (const depName of depNames) {
2012
+ let depPkgPath;
2013
+ try {
2014
+ depPkgPath = require.resolve(`${depName}/package.json`);
2015
+ } catch {
2016
+ continue;
2017
+ }
2018
+ let depPkg;
2019
+ try {
2020
+ depPkg = JSON.parse(await readFile(depPkgPath, "utf-8"));
2021
+ } catch (err) {
2022
+ failed.push({
2023
+ source: depName,
2024
+ reason: `failed to parse package.json: ${err}`
2025
+ });
2026
+ continue;
2027
+ }
2028
+ if (!depPkg.kickjs?.generators) continue;
2029
+ const entryRel = depPkg.kickjs.generators;
2030
+ const entryAbs = resolve(dirname(depPkgPath), entryRel);
2031
+ if (!existsSync(entryAbs)) {
2032
+ failed.push({
2033
+ source: depName,
2034
+ reason: `kickjs.generators points to missing file: ${entryRel}`
2035
+ });
2036
+ continue;
2037
+ }
2038
+ let mod;
2039
+ try {
2040
+ mod = await importManifest(entryAbs);
2041
+ } catch (err) {
2042
+ failed.push({
2043
+ source: depName,
2044
+ reason: `failed to import manifest: ${err}`
2045
+ });
2046
+ continue;
2047
+ }
2048
+ const manifest = mod.default;
2049
+ if (!Array.isArray(manifest)) {
2050
+ failed.push({
2051
+ source: depName,
2052
+ reason: `manifest's default export is not an array of GeneratorSpec`
2053
+ });
2054
+ continue;
2055
+ }
2056
+ for (const entry of manifest) {
2057
+ if (!isGeneratorSpec(entry)) {
2058
+ failed.push({
2059
+ source: depName,
2060
+ reason: `manifest entry is not a valid GeneratorSpec (missing name/files)`
2061
+ });
2062
+ continue;
2063
+ }
2064
+ generators.push({
2065
+ source: depName,
2066
+ spec: entry
2067
+ });
2068
+ }
2069
+ loaded.push(depName);
2070
+ }
2071
+ return {
2072
+ generators,
2073
+ loaded,
2074
+ failed
2075
+ };
2076
+ }
2077
+ function collectDepNames(pkg) {
2078
+ const set = /* @__PURE__ */ new Set();
2079
+ for (const block of [
2080
+ pkg.dependencies,
2081
+ pkg.devDependencies,
2082
+ pkg.peerDependencies
2083
+ ]) {
2084
+ if (!block) continue;
2085
+ for (const name of Object.keys(block)) set.add(name);
2086
+ }
2087
+ return Array.from(set);
2088
+ }
2089
+ function isGeneratorSpec(entry) {
2090
+ if (!entry || typeof entry !== "object") return false;
2091
+ const e = entry;
2092
+ return typeof e.name === "string" && typeof e.files === "function";
2093
+ }
2094
+ //#endregion
2095
+ //#region src/generator-extension/dispatch.ts
2096
+ /**
2097
+ * Look up a plugin generator by name and run it. Returns `null` when
2098
+ * no plugin generator matches — callers can then fall through to the
2099
+ * built-in dispatch (module / scaffold / etc.).
2100
+ *
2101
+ * The lookup is FIRST-MATCH-WINS in dependency declaration order: if
2102
+ * two plugins claim the same generator name, the one whose package was
2103
+ * resolved first wins. Adopters with conflicts should rename the
2104
+ * generator on their side or pin one of the plugins to a different
2105
+ * version.
2106
+ */
2107
+ async function tryDispatchPluginGenerator(input) {
2108
+ const cwd = input.cwd ?? process.cwd();
2109
+ const match = findGenerator(await discoverPluginGenerators(cwd), input.generatorName);
2110
+ if (!match) return null;
2111
+ return runGenerator(match.spec, match.source, input, cwd);
2112
+ }
2113
+ /** Public helper for `kick g --list` — returns every discovered plugin generator. */
2114
+ async function listPluginGenerators(cwd) {
2115
+ return discoverPluginGenerators(cwd);
2116
+ }
2117
+ function findGenerator(discovery, name) {
2118
+ return discovery.generators.find((g) => g.spec.name === name);
2119
+ }
2120
+ async function runGenerator(spec, source, input, cwd) {
2121
+ const ctx = buildGeneratorContext({
2122
+ name: input.itemName,
2123
+ args: input.args,
2124
+ flags: input.flags,
2125
+ modulesDir: input.modulesDir,
2126
+ pluralize: input.pluralize,
2127
+ cwd
2128
+ });
2129
+ const files = await spec.files(ctx);
2130
+ const written = [];
2131
+ for (const file of files) {
2132
+ const absPath = resolveGeneratorPath(ctx, file.path);
2133
+ await writeFileSafe(absPath, file.content);
2134
+ written.push(absPath);
2135
+ }
2136
+ return {
2137
+ files: written,
2138
+ source
2139
+ };
2140
+ }
2141
+ //#endregion
1886
2142
  //#region src/generators/templates/module-index.ts
1887
2143
  const repoLabelMap = {
1888
2144
  inmemory: "in-memory",
@@ -4409,7 +4665,7 @@ import type { RequestContext } from '@forinda/kickjs'
4409
4665
  import { Autowired } from '@forinda/kickjs'
4410
4666
  import { AuthService } from './auth.service'
4411
4667
 
4412
- @Controller('/auth')
4668
+ @Controller()
4413
4669
  @Authenticated()
4414
4670
  export class AuthController {
4415
4671
  @Autowired() private authService!: AuthService
@@ -4491,7 +4747,7 @@ import type { RequestContext } from '@forinda/kickjs'
4491
4747
  import { Autowired } from '@forinda/kickjs'
4492
4748
  import { AuthService } from './auth.service'
4493
4749
 
4494
- @Controller('/auth')
4750
+ @Controller()
4495
4751
  @Authenticated()
4496
4752
  export class AuthController {
4497
4753
  @Autowired() private authService!: AuthService
@@ -5298,14 +5554,14 @@ const BUILTIN_REPO_TYPES = [
5298
5554
  "inmemory",
5299
5555
  "prisma"
5300
5556
  ];
5301
- /** Resolve module config with backward-compatible fallbacks from top-level fields */
5557
+ /** Resolve module config from `modules.*` block. */
5302
5558
  function resolveModuleConfig(config) {
5303
5559
  if (!config) return {};
5304
5560
  const mc = {
5305
- dir: config.modules?.dir ?? config.modulesDir,
5306
- repo: config.modules?.repo ?? config.defaultRepo,
5307
- schemaDir: config.modules?.schemaDir ?? config.schemaDir,
5308
- pluralize: config.modules?.pluralize ?? config.pluralize,
5561
+ dir: config.modules?.dir,
5562
+ repo: config.modules?.repo,
5563
+ schemaDir: config.modules?.schemaDir,
5564
+ pluralize: config.modules?.pluralize,
5309
5565
  prismaClientPath: config.modules?.prismaClientPath
5310
5566
  };
5311
5567
  if (mc.repo && typeof mc.repo === "string" && !BUILTIN_REPO_TYPES.includes(mc.repo)) console.warn(` Warning: modules.repo '${mc.repo}' is not a built-in type (${BUILTIN_REPO_TYPES.join(", ")}). It will generate a stub repository. Use { name: '${mc.repo}' } to silence this warning.`);
@@ -5333,7 +5589,10 @@ async function loadKickConfig(cwd) {
5333
5589
  try {
5334
5590
  const { pathToFileURL } = await import("node:url");
5335
5591
  const mod = await import(pathToFileURL(filepath).href);
5336
- return mod.default ?? mod;
5592
+ const config = mod.default ?? mod;
5593
+ const warnings = validateAssetMap(config, cwd);
5594
+ for (const warning of warnings) console.warn(` Warning: ${warning}`);
5595
+ return config;
5337
5596
  } catch (err) {
5338
5597
  if (filename.endsWith(".ts")) console.warn(`Warning: Failed to load ${filename}. TypeScript config files require a runtime loader (e.g. tsx, ts-node) or use kick.config.js/.mjs instead.`);
5339
5598
  continue;
@@ -5341,6 +5600,59 @@ async function loadKickConfig(cwd) {
5341
5600
  }
5342
5601
  return null;
5343
5602
  }
5603
+ /**
5604
+ * Validate `assetMap` entries on a loaded config. Returns a list of
5605
+ * human-readable warnings; the caller decides how to surface them
5606
+ * (typically `console.warn`). Never throws — `kick g` and other
5607
+ * unrelated commands should keep working even when the assetMap is
5608
+ * misconfigured.
5609
+ *
5610
+ * Checks:
5611
+ *
5612
+ * - Each entry's `src` is a non-empty string.
5613
+ * - The `src` directory exists on disk (otherwise the typegen + build
5614
+ * steps will fail later with cryptic errors).
5615
+ * - `dest` doesn't escape the project root (defensive — a `dest:
5616
+ * '../../etc'` typo could write files outside the workspace).
5617
+ * - The namespace key is a non-empty string and doesn't include a
5618
+ * `/` (would conflict with the `<namespace>/<key>` manifest format).
5619
+ */
5620
+ function validateAssetMap(config, cwd) {
5621
+ const warnings = [];
5622
+ if (!config?.assetMap) return warnings;
5623
+ const root = resolve(cwd);
5624
+ for (const [namespace, entry] of Object.entries(config.assetMap)) {
5625
+ if (!namespace || namespace.includes("/")) {
5626
+ warnings.push(`assetMap key '${namespace}' is invalid — must be a non-empty string without '/'`);
5627
+ continue;
5628
+ }
5629
+ if (typeof entry?.src !== "string" || entry.src.length === 0) {
5630
+ warnings.push(`assetMap.${namespace} is missing a non-empty 'src' field`);
5631
+ continue;
5632
+ }
5633
+ if (!existsSync(resolve(cwd, entry.src))) warnings.push(`assetMap.${namespace}.src ('${entry.src}') does not exist — typegen + build will fail`);
5634
+ if (entry.dest) {
5635
+ if (escapesRoot$1(resolve(cwd, entry.dest), root)) warnings.push(`assetMap.${namespace}.dest ('${entry.dest}') resolves outside the project root — refusing to copy`);
5636
+ }
5637
+ }
5638
+ return warnings;
5639
+ }
5640
+ /**
5641
+ * Returns true when `path` (absolute) resolves outside of `root`
5642
+ * (also absolute). Uses `path.relative` for accuracy:
5643
+ *
5644
+ * - The result is empty when paths are identical (inside).
5645
+ * - It starts with `..` when the path traverses outside the root.
5646
+ * - It's absolute (Windows: cross-drive) when there's no relative
5647
+ * path between them.
5648
+ *
5649
+ * Avoids the prefix-match pitfalls of `startsWith` (e.g. `/app`
5650
+ * matching `/app2/...`, or case-mismatches on macOS / Windows).
5651
+ */
5652
+ function escapesRoot$1(path, root) {
5653
+ const rel = relative(root, path);
5654
+ return rel === "" ? false : rel.startsWith("..") || isAbsolute(rel);
5655
+ }
5344
5656
  //#endregion
5345
5657
  //#region src/typegen/scanner.ts
5346
5658
  /** Decorators that mark a class as DI-managed */
@@ -5396,6 +5708,28 @@ const BARE_CREATE_TOKEN_REGEX = /createToken\s*(?:<[^>]*>)?\s*\(\s*['"`]([^'"`]+
5396
5708
  /** Match `@Inject('literal')` — only literals; computed args are skipped */
5397
5709
  const INJECT_LITERAL_REGEX = /@Inject\s*\(\s*['"`]([^'"`]+)['"`]\s*\)/g;
5398
5710
  /**
5711
+ * Match the start of a `defineAdapter(...)` or `definePlugin(...)` call,
5712
+ * tolerating optional `<TConfig, TExtra>` generics. Captures the helper
5713
+ * name. The callsite's first-arg object is parsed forward via
5714
+ * `findBalancedClose` so nested objects/parens don't confuse us.
5715
+ */
5716
+ const DEFINE_HELPER_START = /\b(defineAdapter|definePlugin)\s*(?:<[^>]*>)?\s*\(/g;
5717
+ /**
5718
+ * Match a class declaration whose `implements` clause includes `AppAdapter`.
5719
+ * Captures the class name. Used to pick up the (rare, post-defineAdapter)
5720
+ * legacy class-style adapters so their literal `name = '...'` field can
5721
+ * still feed `KickJsPluginRegistry`.
5722
+ */
5723
+ const APP_ADAPTER_CLASS_REGEX = new RegExp(String.raw`export\s+(?:default\s+)?(?:abstract\s+)?class\s+(\w+)` + String.raw`(?:\s+extends\s+\w+(?:<[^>]*>)?)?` + String.raw`\s+implements\s+[^{]*\bAppAdapter\b`, "g");
5724
+ /** Match a string-literal `name = '...'` field on a class body. */
5725
+ const CLASS_NAME_FIELD_REGEX = /\bname\s*(?::\s*[^=]+)?=\s*['"`]([^'"`]+)['"`]/;
5726
+ /**
5727
+ * Match the start of a `defineAugmentation('Name', ...)` call. Captures
5728
+ * the literal name. The optional second-arg object is parsed forward so
5729
+ * `description` / `example` can be pulled out.
5730
+ */
5731
+ const DEFINE_AUGMENTATION_START = /\bdefineAugmentation\s*\(\s*['"`]([^'"`]+)['"`]\s*(,\s*\{)?/g;
5732
+ /**
5399
5733
  * Locate the start of a route decorator: `@Get(`, `@Post(`, etc.
5400
5734
  * Used by `extractRoutesFromSource`; the rest of the route declaration
5401
5735
  * (balanced parens, stacked decorators, method name) is parsed by walking
@@ -5742,6 +6076,120 @@ function extractInjectsFromSource(source, filePath, cwd) {
5742
6076
  return out;
5743
6077
  }
5744
6078
  /**
6079
+ * Extract the bounds of an object literal that begins at `openBracePos`
6080
+ * (the index of the `{` character). Returns the index of the matching `}`
6081
+ * or -1 if no match is found. Counts balanced braces only — does not
6082
+ * understand string literals so a `{` or `}` inside a string inside the
6083
+ * object will skew the depth counter (matches `findBalancedClose`).
6084
+ */
6085
+ function findBalancedBrace(text, openBracePos) {
6086
+ let depth = 1;
6087
+ for (let i = openBracePos + 1; i < text.length; i++) {
6088
+ const ch = text[i];
6089
+ if (ch === "{") depth++;
6090
+ else if (ch === "}") {
6091
+ depth--;
6092
+ if (depth === 0) return i;
6093
+ }
6094
+ }
6095
+ return -1;
6096
+ }
6097
+ /**
6098
+ * Extract plugins/adapters declared via `defineAdapter({ name: '...' })`
6099
+ * or `definePlugin({ name: '...' })` calls and via class-style adapters
6100
+ * (`class XxxAdapter implements AppAdapter` with a string-literal `name`
6101
+ * field).
6102
+ *
6103
+ * Only the literal `name:` field feeds the result — the symbol on the LHS
6104
+ * is irrelevant since `dependsOn` references the runtime name.
6105
+ */
6106
+ function extractPluginsAndAdaptersFromSource(source, filePath, cwd) {
6107
+ const out = [];
6108
+ const relPath = toRelative(filePath, cwd);
6109
+ const seen = /* @__PURE__ */ new Set();
6110
+ DEFINE_HELPER_START.lastIndex = 0;
6111
+ let helperMatch;
6112
+ while ((helperMatch = DEFINE_HELPER_START.exec(source)) !== null) {
6113
+ const helper = helperMatch[1];
6114
+ const openParen = DEFINE_HELPER_START.lastIndex - 1;
6115
+ const closeParen = findBalancedClose(source, openParen);
6116
+ if (closeParen < 0) continue;
6117
+ const callArgs = source.slice(openParen + 1, closeParen);
6118
+ const nameMatch = /\bname\s*:\s*['"`]([^'"`]+)['"`]/.exec(callArgs);
6119
+ if (!nameMatch) continue;
6120
+ const name = nameMatch[1];
6121
+ const dedupeKey = `${helper}::${name}::${filePath}`;
6122
+ if (seen.has(dedupeKey)) continue;
6123
+ seen.add(dedupeKey);
6124
+ out.push({
6125
+ kind: helper === "definePlugin" ? "plugin" : "adapter",
6126
+ name,
6127
+ filePath,
6128
+ relativePath: relPath
6129
+ });
6130
+ }
6131
+ APP_ADAPTER_CLASS_REGEX.lastIndex = 0;
6132
+ let classMatch;
6133
+ while ((classMatch = APP_ADAPTER_CLASS_REGEX.exec(source)) !== null) {
6134
+ const classStart = classMatch.index;
6135
+ const bracePos = source.indexOf("{", classStart);
6136
+ if (bracePos < 0) continue;
6137
+ const closeBrace = findBalancedBrace(source, bracePos);
6138
+ if (closeBrace < 0) continue;
6139
+ const body = source.slice(bracePos + 1, closeBrace);
6140
+ const nameMatch = CLASS_NAME_FIELD_REGEX.exec(body);
6141
+ if (!nameMatch) continue;
6142
+ const name = nameMatch[1];
6143
+ const dedupeKey = `class::${name}::${filePath}`;
6144
+ if (seen.has(dedupeKey)) continue;
6145
+ seen.add(dedupeKey);
6146
+ out.push({
6147
+ kind: "adapter",
6148
+ name,
6149
+ filePath,
6150
+ relativePath: relPath
6151
+ });
6152
+ }
6153
+ return out;
6154
+ }
6155
+ /**
6156
+ * Extract `defineAugmentation('Name', { description, example })` calls
6157
+ * from a source file. The metadata object is optional — when absent both
6158
+ * `description` and `example` resolve to `null`.
6159
+ */
6160
+ function extractAugmentationsFromSource(source, filePath, cwd) {
6161
+ const out = [];
6162
+ const relPath = toRelative(filePath, cwd);
6163
+ DEFINE_AUGMENTATION_START.lastIndex = 0;
6164
+ let match;
6165
+ while ((match = DEFINE_AUGMENTATION_START.exec(source)) !== null) {
6166
+ const name = match[1];
6167
+ let description = null;
6168
+ let example = null;
6169
+ if (match[2]) {
6170
+ const bracePos = source.indexOf("{", match.index + match[0].length - 1);
6171
+ if (bracePos >= 0) {
6172
+ const closeBrace = findBalancedBrace(source, bracePos);
6173
+ if (closeBrace >= 0) {
6174
+ const body = source.slice(bracePos + 1, closeBrace);
6175
+ const descMatch = /\bdescription\s*:\s*['"`]([^'"`]+)['"`]/.exec(body);
6176
+ const exampleMatch = /\bexample\s*:\s*['"`]([^'"`]+)['"`]/.exec(body);
6177
+ description = descMatch ? descMatch[1] : null;
6178
+ example = exampleMatch ? exampleMatch[1] : null;
6179
+ }
6180
+ }
6181
+ }
6182
+ out.push({
6183
+ name,
6184
+ description,
6185
+ example,
6186
+ filePath,
6187
+ relativePath: relPath
6188
+ });
6189
+ }
6190
+ return out;
6191
+ }
6192
+ /**
5745
6193
  * Default search order for the env schema file. Newer projects keep
5746
6194
  * the schema under `src/config/` so the framework's "config" concept
5747
6195
  * has a single home; older scaffolds dropped it at `src/env.ts` (kept
@@ -5812,6 +6260,8 @@ async function scanProject(opts) {
5812
6260
  const routes = [];
5813
6261
  const tokens = [];
5814
6262
  const injects = [];
6263
+ const pluginsAndAdapters = [];
6264
+ const augmentations = [];
5815
6265
  const sources = /* @__PURE__ */ new Map();
5816
6266
  for (const file of files) {
5817
6267
  let source;
@@ -5824,6 +6274,8 @@ async function scanProject(opts) {
5824
6274
  classes.push(...extractClassesFromSource(source, file, opts.cwd));
5825
6275
  tokens.push(...extractTokensFromSource(source, file, opts.cwd));
5826
6276
  injects.push(...extractInjectsFromSource(source, file, opts.cwd));
6277
+ pluginsAndAdapters.push(...extractPluginsAndAdaptersFromSource(source, file, opts.cwd));
6278
+ augmentations.push(...extractAugmentationsFromSource(source, file, opts.cwd));
5827
6279
  }
5828
6280
  for (const [file, source] of sources) {
5829
6281
  const classesInFile = classes.filter((c) => c.filePath === file);
@@ -5836,14 +6288,147 @@ async function scanProject(opts) {
5836
6288
  tokens.sort((a, b) => a.name.localeCompare(b.name) || a.relativePath.localeCompare(b.relativePath));
5837
6289
  injects.sort((a, b) => a.name.localeCompare(b.name) || a.relativePath.localeCompare(b.relativePath));
5838
6290
  routes.sort((a, b) => a.controller.localeCompare(b.controller) || a.method.localeCompare(b.method));
6291
+ pluginsAndAdapters.sort((a, b) => a.name.localeCompare(b.name) || a.relativePath.localeCompare(b.relativePath));
6292
+ augmentations.sort((a, b) => a.name.localeCompare(b.name) || a.relativePath.localeCompare(b.relativePath));
5839
6293
  return {
5840
6294
  classes,
5841
6295
  routes,
5842
6296
  tokens,
5843
6297
  injects,
5844
6298
  collisions: findCollisions(classes),
5845
- env: await detectEnvFile(opts.cwd, opts.envFile ?? "src/env.ts")
6299
+ env: await detectEnvFile(opts.cwd, opts.envFile ?? "src/env.ts"),
6300
+ pluginsAndAdapters,
6301
+ augmentations
6302
+ };
6303
+ }
6304
+ //#endregion
6305
+ //#region src/typegen/asset-types.ts
6306
+ /**
6307
+ * Walks every `assetMap` entry's source directory + emits a typed
6308
+ * `KickAssets` ambient augmentation (assets-plan.md PR 4). Generates
6309
+ * `.kickjs/types/assets.d.ts` so adopters get autocomplete on
6310
+ * `assets.<namespace>.<key>` and `@Asset('<namespace>/<key>')`.
6311
+ *
6312
+ * Pure module — no side effects beyond what the caller does with the
6313
+ * returned content. Mirrors the shape of `renderPlugins` /
6314
+ * `renderRegistry` in the generator so the typegen output stays
6315
+ * consistent across surfaces.
6316
+ *
6317
+ * @module @forinda/kickjs-cli/typegen/asset-types
6318
+ */
6319
+ function discoverAssets(assetMap, cwd) {
6320
+ if (!assetMap) return {
6321
+ entries: [],
6322
+ count: 0
5846
6323
  };
6324
+ const seen = /* @__PURE__ */ new Map();
6325
+ for (const [namespace, entry] of Object.entries(assetMap)) {
6326
+ if (!entry || typeof entry.src !== "string") continue;
6327
+ const srcAbs = resolve(cwd, entry.src);
6328
+ if (!isDir(srcAbs)) continue;
6329
+ const matches = globSync(entry.glob ?? "**/*", {
6330
+ cwd: srcAbs,
6331
+ nodir: true,
6332
+ dot: false,
6333
+ posix: true
6334
+ });
6335
+ matches.sort();
6336
+ for (const rel of matches) {
6337
+ const key = stripExt$1(rel);
6338
+ const logical = `${namespace}/${key}`;
6339
+ seen.set(logical, {
6340
+ namespace,
6341
+ key
6342
+ });
6343
+ }
6344
+ }
6345
+ return {
6346
+ entries: [...seen.values()],
6347
+ count: seen.size
6348
+ };
6349
+ }
6350
+ function renderAssetTypes(discovered) {
6351
+ const HEADER = `/* eslint-disable */
6352
+ // AUTO-GENERATED by \`kick typegen\`. DO NOT EDIT.
6353
+ // Re-run with \`kick typegen\` or rely on \`kick dev\` to refresh.
6354
+ `;
6355
+ if (discovered.entries.length === 0) return `${HEADER}
6356
+ declare module '@forinda/kickjs' {
6357
+ /**
6358
+ * Map of every typed asset discovered in the project's assetMap.
6359
+ * (No assetMap entries discovered yet — declare with
6360
+ * \`assetMap: { name: { src: 'src/...' } }\` in kick.config.ts.)
6361
+ */
6362
+ interface KickAssets {}
6363
+ }
6364
+
6365
+ export {}
6366
+ `;
6367
+ const tree = {};
6368
+ for (const entry of discovered.entries) {
6369
+ const path = `${entry.namespace}/${entry.key}`.split("/");
6370
+ let node = tree;
6371
+ for (let i = 0; i < path.length - 1; i++) {
6372
+ const part = path[i];
6373
+ const existing = node[part];
6374
+ if (existing === LEAF) {
6375
+ const promoted = {};
6376
+ node[part] = promoted;
6377
+ node = promoted;
6378
+ } else {
6379
+ if (!existing) node[part] = {};
6380
+ node = node[part];
6381
+ }
6382
+ }
6383
+ const leaf = path[path.length - 1];
6384
+ if (typeof node[leaf] === "object") continue;
6385
+ node[leaf] = LEAF;
6386
+ }
6387
+ return `${HEADER}
6388
+ declare module '@forinda/kickjs' {
6389
+ /**
6390
+ * Map of every typed asset discovered in the project's assetMap.
6391
+ * Each leaf is a \`() => string\` thunk that returns the resolved
6392
+ * absolute path for the file in the current run mode (dev → src,
6393
+ * prod → dist).
6394
+ */
6395
+ interface KickAssets {
6396
+ ${renderTree(tree, " ")}
6397
+ }
6398
+ }
6399
+
6400
+ export {}
6401
+ `;
6402
+ }
6403
+ const LEAF = Symbol("asset-leaf");
6404
+ function renderTree(node, indent) {
6405
+ const keys = Object.keys(node).sort();
6406
+ const lines = [];
6407
+ for (const key of keys) {
6408
+ const child = node[key];
6409
+ const safeKey = isIdentifier(key) ? key : JSON.stringify(key);
6410
+ if (child === LEAF) lines.push(`${indent}${safeKey}: () => string`);
6411
+ else {
6412
+ lines.push(`${indent}${safeKey}: {`);
6413
+ lines.push(renderTree(child, `${indent} `));
6414
+ lines.push(`${indent}}`);
6415
+ }
6416
+ }
6417
+ return lines.join("\n");
6418
+ }
6419
+ function isIdentifier(str) {
6420
+ return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(str);
6421
+ }
6422
+ function isDir(path) {
6423
+ try {
6424
+ return statSync(path).isDirectory();
6425
+ } catch {
6426
+ return false;
6427
+ }
6428
+ }
6429
+ function stripExt$1(path) {
6430
+ const ext = extname(path);
6431
+ return ext ? path.slice(0, -ext.length) : path;
5847
6432
  }
5848
6433
  //#endregion
5849
6434
  //#region src/typegen/generator.ts
@@ -5981,12 +6566,17 @@ function renderIndex(includeEnv) {
5981
6566
  export type { ServiceToken } from './services'
5982
6567
  export type { ModuleToken } from './modules'
5983
6568
 
5984
- // The registry, routes, and env augmentations are loaded as side-effects —
5985
- // importing this file (or having it on tsconfig include) is enough for
5986
- // \`container.resolve()\`, \`Ctx<KickRoutes.UserController['getUser']>\`,
5987
- // and \`@Value('PORT')\` to resolve.
6569
+ // The registry, routes, plugins, assets, and env augmentations are
6570
+ // loaded as side-effects — importing this file (or having it on
6571
+ // tsconfig include) is enough for \`container.resolve()\`,
6572
+ // \`Ctx<KickRoutes.UserController['getUser']>\`,
6573
+ // \`dependsOn: ['TenantAdapter']\`, \`assets.mails.welcome()\`, and
6574
+ // \`@Value('PORT')\` to resolve.
5988
6575
  import './registry'
5989
6576
  import './routes'
6577
+ import './plugins'
6578
+ import './augmentations'
6579
+ import './assets'
5990
6580
  ${includeEnv ? "import './env'\n" : ""}`;
5991
6581
  }
5992
6582
  /**
@@ -6181,9 +6771,90 @@ ${interfaces.join("\n")}
6181
6771
  export {}
6182
6772
  `;
6183
6773
  }
6774
+ /**
6775
+ * Render the `KickJsPluginRegistry` augmentation. Each entry maps the
6776
+ * literal `name` field of a plugin/adapter to a marker type (the
6777
+ * registry value isn't load-bearing at runtime — `dependsOn` only cares
6778
+ * about `keyof`, so any non-`never` type works). We emit `'plugin'` /
6779
+ * `'adapter'` strings so DevTools can later read the registry to tell
6780
+ * the kinds apart without a second source of truth.
6781
+ *
6782
+ * When the project has no discoverable plugins/adapters, the augmentation
6783
+ * is intentionally empty rather than skipped so the `keyof` constraint
6784
+ * resolves to `never` (which is harmless — `dependsOn: []` still works).
6785
+ */
6786
+ function renderPlugins(items) {
6787
+ const byName = /* @__PURE__ */ new Map();
6788
+ for (const item of items) if (!byName.has(item.name)) byName.set(item.name, item);
6789
+ const entries = [...byName.values()].sort((a, b) => a.name.localeCompare(b.name)).map((item) => ` '${item.name}': '${item.kind}'`).join("\n");
6790
+ return `${HEADER}
6791
+ declare module '@forinda/kickjs' {
6792
+ /**
6793
+ * Map of every plugin/adapter \`name\` discovered in the project. The
6794
+ * value type is the kind tag (\`'plugin'\` or \`'adapter'\`); the
6795
+ * \`keyof\` of this interface narrows \`dependsOn\` so misspelled deps
6796
+ * become compile errors instead of boot-time \`MissingMountDepError\`.
6797
+ */
6798
+ interface KickJsPluginRegistry {
6799
+ ${entries ? entries : " // (no plugins/adapters discovered yet — `defineAdapter`/`definePlugin` calls feed this)"}
6800
+ }
6801
+ }
6802
+
6803
+ export {}
6804
+ `;
6805
+ }
6806
+ /**
6807
+ * Render the augmentation manifest — one block per `defineAugmentation`
6808
+ * call discovered in the project. The output is a `.d.ts` file that
6809
+ * does nothing at runtime but acts as in-IDE documentation: adopters
6810
+ * jumping into it see every interface their plugins offer for
6811
+ * augmentation, alongside any `description` / `example` the plugin
6812
+ * authors provided.
6813
+ */
6814
+ function renderAugmentations(items) {
6815
+ if (items.length === 0) return `${HEADER}
6816
+ // No augmentations discovered.
6817
+ //
6818
+ // Plugins advertise augmentable interfaces via:
6819
+ //
6820
+ // import { defineAugmentation } from '@forinda/kickjs'
6821
+ // defineAugmentation('FeatureFlags', {
6822
+ // description: 'Feature flag shape consumed by FlagsPlugin',
6823
+ // example: '{ beta: boolean; rolloutPercentage: number }',
6824
+ // })
6825
+ //
6826
+ // See \`docs/guide/typegen.md#augmentations\` for the full pattern.
6827
+ export {}
6828
+ `;
6829
+ const byName = /* @__PURE__ */ new Map();
6830
+ for (const item of items) if (!byName.has(item.name)) byName.set(item.name, item);
6831
+ const blocks = [];
6832
+ for (const item of [...byName.values()].sort((a, b) => a.name.localeCompare(b.name))) {
6833
+ const docLines = [];
6834
+ if (item.description) docLines.push(` * ${item.description}`);
6835
+ if (item.example) docLines.push(` * @example`, ` * \`\`\`ts`, ` * ${item.example}`, ` * \`\`\``);
6836
+ docLines.push(` * @see ${item.relativePath}`);
6837
+ blocks.push([
6838
+ "/**",
6839
+ ...docLines,
6840
+ " */",
6841
+ `export interface ${item.name}Augmentation {}`
6842
+ ].join("\n"));
6843
+ }
6844
+ return `${HEADER}
6845
+ // Catalogue of augmentable interfaces in this project. The interfaces
6846
+ // below are documentation only — augment the source-of-truth interfaces
6847
+ // in your own \`d.ts\` files (the framework declares the actual types).
6848
+
6849
+ ${blocks.join("\n\n")}
6850
+ `;
6851
+ }
6184
6852
  /** Write all generated `.d.ts` files to `outDir` */
6185
6853
  async function generateTypes(opts) {
6186
- const { classes, routes = [], tokens = [], injects = [], collisions = [], env = null, outDir, allowDuplicates = false, schemaValidator = false } = opts;
6854
+ const { classes, routes = [], tokens = [], injects = [], collisions = [], env = null, pluginsAndAdapters = [], augmentations = [], assets = {
6855
+ entries: [],
6856
+ count: 0
6857
+ }, outDir, allowDuplicates = false, schemaValidator = false } = opts;
6187
6858
  if (collisions.length > 0 && !allowDuplicates) throw new TokenCollisionError(collisions);
6188
6859
  await mkdir(outDir, { recursive: true });
6189
6860
  const registryFile = join(outDir, "registry.d.ts");
@@ -6191,6 +6862,9 @@ async function generateTypes(opts) {
6191
6862
  const modulesFile = join(outDir, "modules.d.ts");
6192
6863
  const routesFile = join(outDir, "routes.ts");
6193
6864
  const envFile = join(outDir, "env.ts");
6865
+ const pluginsFile = join(outDir, "plugins.d.ts");
6866
+ const augmentationsFile = join(outDir, "augmentations.d.ts");
6867
+ const assetsFile = join(outDir, "assets.d.ts");
6194
6868
  const indexFile = join(outDir, "index.d.ts");
6195
6869
  const collidingNames = new Set(collisions.map((c) => c.className));
6196
6870
  const registryContent = renderRegistry(classes, registryFile, collidingNames);
@@ -6207,17 +6881,26 @@ async function generateTypes(opts) {
6207
6881
  const modulesContent = renderUnion("ModuleToken", modules, "(no @Module classes discovered — `kick g module <name>` to add one)");
6208
6882
  const routesContent = renderRoutes(routes, routesFile, schemaValidator);
6209
6883
  const envContent = renderEnv(env, envFile);
6884
+ const pluginsContent = renderPlugins(pluginsAndAdapters);
6885
+ const augmentationsContent = renderAugmentations(augmentations);
6886
+ const assetsContent = renderAssetTypes(assets);
6210
6887
  const indexContent = renderIndex(envContent !== null);
6211
6888
  await writeFile(registryFile, registryContent, "utf-8");
6212
6889
  await writeFile(servicesFile, servicesContent, "utf-8");
6213
6890
  await writeFile(modulesFile, modulesContent, "utf-8");
6214
6891
  await writeFile(routesFile, routesContent, "utf-8");
6892
+ await writeFile(pluginsFile, pluginsContent, "utf-8");
6893
+ await writeFile(augmentationsFile, augmentationsContent, "utf-8");
6894
+ await writeFile(assetsFile, assetsContent, "utf-8");
6215
6895
  await writeFile(indexFile, indexContent, "utf-8");
6216
6896
  const written = [
6217
6897
  registryFile,
6218
6898
  servicesFile,
6219
6899
  modulesFile,
6220
6900
  routesFile,
6901
+ pluginsFile,
6902
+ augmentationsFile,
6903
+ assetsFile,
6221
6904
  indexFile
6222
6905
  ];
6223
6906
  if (envContent) {
@@ -6225,17 +6908,62 @@ async function generateTypes(opts) {
6225
6908
  written.push(envFile);
6226
6909
  }
6227
6910
  await writeFile(join(dirname(outDir), ".gitignore"), "# Auto-generated by kick typegen\n*\n", "utf-8");
6911
+ const uniquePluginNames = new Set(pluginsAndAdapters.map((p) => p.name)).size;
6912
+ const uniqueAugmentations = new Set(augmentations.map((a) => a.name)).size;
6228
6913
  return {
6229
6914
  registryEntries: classTokens.length,
6230
6915
  serviceTokens: new Set(allServices).size,
6231
6916
  moduleTokens: modules.length,
6232
6917
  routeEntries: routes.length,
6918
+ pluginEntries: uniquePluginNames,
6919
+ augmentationEntries: uniqueAugmentations,
6920
+ assetEntries: assets.count,
6233
6921
  envWritten: envContent !== null,
6234
6922
  written,
6235
6923
  resolvedCollisions: collisions.length
6236
6924
  };
6237
6925
  }
6238
6926
  //#endregion
6927
+ //#region src/typegen/token-conventions.ts
6928
+ /**
6929
+ * Regex for the §22.2 token shape. Breakdown:
6930
+ *
6931
+ * - `^(kick\/)?` — optional reserved framework prefix.
6932
+ * - `([a-z][\w-]*\/[A-Z]\w*)` — `<scope>/<PascalKey>`. Scope is
6933
+ * lowercase, key is PascalCase.
6934
+ * - `(\/.+)?` — optional `/suffix` for sub-flavours
6935
+ * (e.g. `mycorp/Cache/redis`).
6936
+ * - `(:[a-z][\w-]+(:[a-z][\w-]+)*)?` — optional `:instance` (and
6937
+ * further `:extra` colon-sections) for `.scoped()` shards.
6938
+ */
6939
+ const TOKEN_CONVENTION_REGEX = /^(kick\/)?([a-z][\w-]*\/[A-Z]\w*)(\/.+)?(:[a-z][\w-]+(:[a-z][\w-]+)*)?$/;
6940
+ const LEGACY_PREFIX = "kickjs.";
6941
+ function validateTokenConventions(tokens) {
6942
+ const warnings = [];
6943
+ for (const token of tokens) {
6944
+ const name = token.name;
6945
+ if (name.startsWith(LEGACY_PREFIX)) continue;
6946
+ if (TOKEN_CONVENTION_REGEX.test(name)) continue;
6947
+ warnings.push({
6948
+ token: name,
6949
+ variable: token.variable,
6950
+ filePath: token.relativePath,
6951
+ reason: "does not match `<scope>/<PascalKey>[/<suffix>][:<instance>]`",
6952
+ suggestion: suggestRename(name)
6953
+ });
6954
+ }
6955
+ return warnings;
6956
+ }
6957
+ function suggestRename(name) {
6958
+ if (/^[A-Z]\w*$/.test(name)) return `'<scope>/${name}' (e.g. 'mycorp/${name}')`;
6959
+ if (name.includes(".")) return `consider '<scope>/PascalKey' instead of dotted form`;
6960
+ const slashLower = /^([a-z][\w-]*)\/([a-z]\w*)$/.exec(name);
6961
+ if (slashLower) {
6962
+ const [, scope, key] = slashLower;
6963
+ return `'${scope}/${key.charAt(0).toUpperCase()}${key.slice(1)}'`;
6964
+ }
6965
+ }
6966
+ //#endregion
6239
6967
  //#region src/typegen/index.ts
6240
6968
  /**
6241
6969
  * Public entry point for the KickJS typegen module.
@@ -6280,6 +7008,7 @@ async function runTypegen(opts = {}) {
6280
7008
  cwd,
6281
7009
  envFile: envFile === false ? void 0 : envFile
6282
7010
  });
7011
+ const assets = discoverAssets(opts.assetMap, cwd);
6283
7012
  const result = await generateTypes({
6284
7013
  classes: scan.classes,
6285
7014
  routes: scan.routes,
@@ -6287,20 +7016,36 @@ async function runTypegen(opts = {}) {
6287
7016
  injects: scan.injects,
6288
7017
  collisions: scan.collisions,
6289
7018
  env: envFile === false ? null : scan.env,
7019
+ pluginsAndAdapters: scan.pluginsAndAdapters,
7020
+ augmentations: scan.augmentations,
7021
+ assets,
6290
7022
  outDir,
6291
7023
  allowDuplicates,
6292
7024
  schemaValidator
6293
7025
  });
7026
+ const tokenWarnings = validateTokenConventions(scan.tokens);
6294
7027
  const elapsed = Date.now() - start;
6295
7028
  if (!silent) {
6296
7029
  const where = outDir.replace(cwd + "/", "");
6297
7030
  const collisionNote = result.resolvedCollisions > 0 ? `, ${result.resolvedCollisions} collisions namespaced` : "";
6298
7031
  const envNote = result.envWritten ? ", env typed" : "";
6299
- console.log(` kick typegen ${result.serviceTokens} services, ${result.routeEntries} routes, ${result.moduleTokens} modules${envNote}${collisionNote} ${where} (${elapsed}ms)`);
7032
+ const pluginNote = result.pluginEntries > 0 ? `, ${result.pluginEntries} plugins/adapters` : "";
7033
+ const augNote = result.augmentationEntries > 0 ? `, ${result.augmentationEntries} augmentations` : "";
7034
+ const assetNote = result.assetEntries > 0 ? `, ${result.assetEntries} assets` : "";
7035
+ console.log(` kick typegen → ${result.serviceTokens} services, ${result.routeEntries} routes, ${result.moduleTokens} modules${pluginNote}${augNote}${assetNote}${envNote}${collisionNote} → ${where} (${elapsed}ms)`);
7036
+ if (tokenWarnings.length > 0) {
7037
+ console.warn(` kick typegen: ${tokenWarnings.length} token(s) don't match the §22.2 convention:`);
7038
+ for (const warning of tokenWarnings) {
7039
+ const variableNote = warning.variable ? ` [${warning.variable}]` : "";
7040
+ console.warn(` '${warning.token}' (${warning.filePath})${variableNote} — ${warning.reason}`);
7041
+ if (warning.suggestion) console.warn(` → suggestion: ${warning.suggestion}`);
7042
+ }
7043
+ }
6300
7044
  }
6301
7045
  return {
6302
7046
  scan,
6303
- result
7047
+ result,
7048
+ tokenWarnings
6304
7049
  };
6305
7050
  }
6306
7051
  /**
@@ -6457,10 +7202,23 @@ const GENERATORS = [
6457
7202
  description: "Generate kick.config.ts"
6458
7203
  }
6459
7204
  ];
6460
- function printGeneratorList() {
6461
- console.log("\n Available generators:\n");
7205
+ async function printGeneratorList() {
7206
+ console.log("\n Built-in generators:\n");
6462
7207
  const maxName = Math.max(...GENERATORS.map((g) => g.name.length));
6463
7208
  for (const g of GENERATORS) console.log(` kick g ${g.name.padEnd(maxName + 2)} ${g.description}`);
7209
+ const discovery = await listPluginGenerators(process.cwd());
7210
+ if (discovery.generators.length > 0) {
7211
+ console.log("\n Plugin generators:\n");
7212
+ const pluginMax = Math.max(...discovery.generators.map((g) => `${g.spec.name} <name>`.length));
7213
+ for (const { source, spec } of discovery.generators) {
7214
+ const usage = `${spec.name} <name>`;
7215
+ console.log(` kick g ${usage.padEnd(pluginMax + 2)} ${spec.description} [${source}]`);
7216
+ }
7217
+ }
7218
+ if (discovery.failed.length > 0) {
7219
+ console.log("\n Failed to load:\n");
7220
+ for (const { source, reason } of discovery.failed) console.log(` ${source} — ${reason}`);
7221
+ }
6464
7222
  console.log();
6465
7223
  }
6466
7224
  /**
@@ -6497,7 +7255,7 @@ async function runModuleGeneration(names, opts, dryRun) {
6497
7255
  function registerGenerateCommand(program) {
6498
7256
  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) => {
6499
7257
  if (opts.list) {
6500
- printGeneratorList();
7258
+ await printGeneratorList();
6501
7259
  return;
6502
7260
  }
6503
7261
  if (!names || names.length === 0) {
@@ -6506,6 +7264,20 @@ function registerGenerateCommand(program) {
6506
7264
  }
6507
7265
  const dryRun = isDryRun(cmd);
6508
7266
  setDryRun(dryRun);
7267
+ if (names.length >= 2) {
7268
+ const [generatorName, itemName, ...rest] = names;
7269
+ const result = await tryDispatchPluginGenerator({
7270
+ generatorName,
7271
+ itemName,
7272
+ args: rest,
7273
+ flags: opts,
7274
+ cwd: process.cwd()
7275
+ });
7276
+ if (result) {
7277
+ printGenerated(result.files, dryRun);
7278
+ return;
7279
+ }
7280
+ }
6509
7281
  await runModuleGeneration(names, opts, dryRun);
6510
7282
  });
6511
7283
  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) => {
@@ -6735,6 +7507,132 @@ function runNodeWithEnv(entry, env, cwd) {
6735
7507
  });
6736
7508
  if (result.status !== 0) process.exit(result.status ?? 1);
6737
7509
  }
7510
+ /**
7511
+ * Run the full asset build for a loaded config:
7512
+ *
7513
+ * 1. For each `assetMap` entry, glob → copy → manifest stub.
7514
+ * 2. Write `dist/.kickjs-assets.json`.
7515
+ *
7516
+ * Returns a summary including the manifest contents. No-op (and no
7517
+ * manifest written) when `assetMap` is empty / missing — the build
7518
+ * pipeline shouldn't litter `dist/` with empty manifests for
7519
+ * adopters who don't use the feature.
7520
+ */
7521
+ async function buildAssets(config, opts) {
7522
+ const { cwd, silent = false } = opts;
7523
+ const distDir = opts.distDir ?? config?.build?.outDir ?? "dist";
7524
+ const map = config?.assetMap;
7525
+ if (!map || Object.keys(map).length === 0) return null;
7526
+ const log = silent ? () => {} : console.log;
7527
+ const distAbs = resolve(cwd, distDir);
7528
+ mkdirSync(distAbs, { recursive: true });
7529
+ const summary = [];
7530
+ const manifestEntries = {};
7531
+ for (const [namespace, entry] of Object.entries(map)) {
7532
+ const result = await processEntry(namespace, entry, cwd, distAbs);
7533
+ summary.push(result.entrySummary);
7534
+ Object.assign(manifestEntries, result.manifestSlice);
7535
+ log(` ✓ ${namespace}: ${result.entrySummary.filesCopied} file(s) → ${result.entrySummary.dest}`);
7536
+ }
7537
+ const manifest = {
7538
+ version: 1,
7539
+ entries: manifestEntries
7540
+ };
7541
+ const manifestPath = join(distAbs, ".kickjs-assets.json");
7542
+ writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + "\n", "utf-8");
7543
+ log(` ✓ wrote manifest → ${relative(cwd, manifestPath)} (${Object.keys(manifestEntries).length} entries)`);
7544
+ return {
7545
+ manifestPath,
7546
+ entries: summary,
7547
+ manifest
7548
+ };
7549
+ }
7550
+ /** Per-entry inner pipeline — extracted for unit-test reuse. */
7551
+ async function processEntry(namespace, entry, cwd, distAbs) {
7552
+ const srcAbs = resolve(cwd, entry.src);
7553
+ const destAbs = entry.dest ? resolve(cwd, entry.dest) : join(distAbs, namespace);
7554
+ if (escapesRoot(destAbs, cwd)) {
7555
+ console.warn(` ⚠ assetMap.${namespace}.dest ('${entry.dest}') resolves outside the project root — skipping copy`);
7556
+ return {
7557
+ entrySummary: {
7558
+ namespace,
7559
+ src: entry.src,
7560
+ dest: relative(cwd, destAbs),
7561
+ filesCopied: 0
7562
+ },
7563
+ manifestSlice: {}
7564
+ };
7565
+ }
7566
+ if (!existsSync(srcAbs) || !isDirectorySync(srcAbs)) return {
7567
+ entrySummary: {
7568
+ namespace,
7569
+ src: entry.src,
7570
+ dest: relative(cwd, destAbs),
7571
+ filesCopied: 0
7572
+ },
7573
+ manifestSlice: {}
7574
+ };
7575
+ const matches = await glob(entry.glob ?? "**/*", {
7576
+ cwd: srcAbs,
7577
+ nodir: true,
7578
+ dot: false,
7579
+ posix: true
7580
+ });
7581
+ mkdirSync(destAbs, { recursive: true });
7582
+ const manifestSlice = {};
7583
+ const keyOwner = /* @__PURE__ */ new Map();
7584
+ for (const relPath of matches.sort()) {
7585
+ const srcFile = join(srcAbs, relPath);
7586
+ const destFile = join(destAbs, relPath);
7587
+ mkdirSync(dirname(destFile), { recursive: true });
7588
+ cpSync(srcFile, destFile);
7589
+ const logicalKey = `${namespace}/${stripExt(relPath)}`;
7590
+ const previous = keyOwner.get(logicalKey);
7591
+ if (previous) console.warn(` ⚠ assetMap collision in '${namespace}': '${previous}' and '${relPath}' both flatten to key '${logicalKey}'. Last-alphabetical wins ('${relPath}'). Rename one of them or set assetMap.${namespace}.glob to filter by extension.`);
7592
+ keyOwner.set(logicalKey, relPath);
7593
+ manifestSlice[logicalKey] = toManifestRelative(distAbs, destFile);
7594
+ }
7595
+ return {
7596
+ entrySummary: {
7597
+ namespace,
7598
+ src: entry.src,
7599
+ dest: relative(cwd, destAbs),
7600
+ filesCopied: matches.length
7601
+ },
7602
+ manifestSlice
7603
+ };
7604
+ }
7605
+ /** Strip the final extension from a file path (`mails/welcome.ejs` → `mails/welcome`). */
7606
+ function stripExt(path) {
7607
+ const ext = extname(path);
7608
+ return ext ? path.slice(0, -ext.length) : path;
7609
+ }
7610
+ /**
7611
+ * Make `destFile` relative to the manifest's directory + force POSIX
7612
+ * separators so the manifest is byte-stable across platforms.
7613
+ */
7614
+ function toManifestRelative(manifestDir, destFile) {
7615
+ return relative(manifestDir, destFile).split(/[\\/]/).filter(Boolean).join("/");
7616
+ }
7617
+ /**
7618
+ * Project-root escape check that's safe across symlinks + drive letters.
7619
+ * `path.relative` returns `..` segments when the target sits above root,
7620
+ * and an absolute path when the two live on different roots (Windows).
7621
+ * `startsWith(root)` would miss both cases.
7622
+ */
7623
+ function escapesRoot(path, root) {
7624
+ const rel = relative(root, path);
7625
+ if (rel === "") return false;
7626
+ return rel.startsWith("..") || isAbsolute(rel);
7627
+ }
7628
+ /** Pure helper — `false` for missing, non-dir, or unreadable paths. */
7629
+ function isDirectorySync(path) {
7630
+ try {
7631
+ return statSync(path).isDirectory();
7632
+ } catch {
7633
+ return false;
7634
+ }
7635
+ }
6738
7636
  //#endregion
6739
7637
  //#region src/commands/run.ts
6740
7638
  /**
@@ -6763,7 +7661,8 @@ async function startDevServer(_entry, port) {
6763
7661
  schemaValidator,
6764
7662
  envFile,
6765
7663
  srcDir: devConfig?.typegen?.srcDir,
6766
- outDir: devConfig?.typegen?.outDir
7664
+ outDir: devConfig?.typegen?.outDir,
7665
+ assetMap: devConfig?.assetMap
6767
7666
  });
6768
7667
  } catch (err) {
6769
7668
  console.warn(` kick typegen: skipped (${err?.message ?? err})`);
@@ -6788,7 +7687,8 @@ async function startDevServer(_entry, port) {
6788
7687
  schemaValidator,
6789
7688
  envFile,
6790
7689
  srcDir: devConfig?.typegen?.srcDir,
6791
- outDir: devConfig?.typegen?.outDir
7690
+ outDir: devConfig?.typegen?.outDir,
7691
+ assetMap: devConfig?.assetMap
6792
7692
  }).catch(() => {});
6793
7693
  }, 100);
6794
7694
  };
@@ -6821,7 +7721,8 @@ function registerRunCommands(program) {
6821
7721
  const { createRequire } = await import("node:module");
6822
7722
  const { build } = await import(pathToFileURL(createRequire(resolve("package.json")).resolve("vite")).href);
6823
7723
  await build({ configFile: resolve("vite.config.ts") });
6824
- const copyDirs = (await loadKickConfig(process.cwd()))?.copyDirs ?? [];
7724
+ const config = await loadKickConfig(process.cwd());
7725
+ const copyDirs = config?.copyDirs ?? [];
6825
7726
  if (copyDirs.length > 0) {
6826
7727
  console.log("\n Copying directories to dist...");
6827
7728
  for (const entry of copyDirs) {
@@ -6838,8 +7739,32 @@ function registerRunCommands(program) {
6838
7739
  console.log(` ✓ ${src} → ${dest}`);
6839
7740
  }
6840
7741
  }
7742
+ if (config?.assetMap && Object.keys(config.assetMap).length > 0) {
7743
+ console.log("\n Building asset map...");
7744
+ try {
7745
+ await buildAssets(config, { cwd: process.cwd() });
7746
+ } catch (err) {
7747
+ console.error(` ✗ asset build failed: ${err instanceof Error ? err.message : String(err)}`);
7748
+ process.exit(1);
7749
+ }
7750
+ }
6841
7751
  console.log("\n Build complete.\n");
6842
7752
  });
7753
+ program.command("build:assets").description("Rebuild the .kickjs-assets.json manifest under the configured outDir (no JS rebuild)").action(async () => {
7754
+ const config = await loadKickConfig(process.cwd());
7755
+ if (!config?.assetMap || Object.keys(config.assetMap).length === 0) {
7756
+ console.log(" No assetMap entries — nothing to build.");
7757
+ return;
7758
+ }
7759
+ console.log("\n Building asset map...");
7760
+ try {
7761
+ await buildAssets(config, { cwd: process.cwd() });
7762
+ console.log("\n Asset build complete.\n");
7763
+ } catch (err) {
7764
+ console.error(` ✗ ${err instanceof Error ? err.message : String(err)}`);
7765
+ process.exit(1);
7766
+ }
7767
+ });
6843
7768
  program.command("start").description("Start production server").option("-e, --entry <file>", "Entry file", "dist/index.js").option("-p, --port <port>", "Port number").action((opts) => {
6844
7769
  const env = { NODE_ENV: "production" };
6845
7770
  if (opts.port) env.PORT = String(opts.port);
@@ -6871,7 +7796,6 @@ function registerInfoCommand(program) {
6871
7796
  Packages:
6872
7797
  @forinda/kickjs workspace
6873
7798
  @forinda/kickjs-vite workspace
6874
- @forinda/kickjs-config workspace
6875
7799
  @forinda/kickjs-cli workspace
6876
7800
  `);
6877
7801
  });
@@ -8109,7 +9033,8 @@ function registerTypegenCommand(program) {
8109
9033
  silent: opts.silent,
8110
9034
  allowDuplicates: opts.allowDuplicates,
8111
9035
  schemaValidator,
8112
- envFile
9036
+ envFile,
9037
+ assetMap: config?.assetMap
8113
9038
  };
8114
9039
  try {
8115
9040
  if (opts.watch) {