@forinda/kickjs-cli 3.2.0 → 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.2.0
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
@@ -769,7 +771,7 @@ generated \`KickRoutes\` namespace (refreshed on \`kick dev\` and \`kick typegen
769
771
  \`\`\`ts
770
772
  import { Controller, Get, Post, type Ctx } from '@forinda/kickjs'
771
773
 
772
- @Controller('/users')
774
+ @Controller()
773
775
  export class UserController {
774
776
  @Get('/')
775
777
  async findAll(ctx: Ctx<KickRoutes.UserController['findAll']>) {
@@ -1050,7 +1052,7 @@ Run tests:
1050
1052
  ## Decorators Reference
1051
1053
 
1052
1054
  ### Route Decorators
1053
- - \`@Controller('/path')\` — define controller prefix
1055
+ - \`@Controller()\` — mark a class as an HTTP controller (path comes from \`routes().path\`)
1054
1056
  - \`@Get('/'), @Post('/'), @Put('/'), @Delete('/'), @Patch('/')\` — HTTP methods
1055
1057
  - \`@Middleware(fn)\` — attach middleware
1056
1058
  - \`@Public()\` — skip authentication (requires @forinda/kickjs-auth)
@@ -1197,7 +1199,7 @@ Then:
1197
1199
  If not using generators:
1198
1200
 
1199
1201
  - [ ] Create \`src/modules/<name>/<name>.controller.ts\`
1200
- - [ ] Add \`@Controller('/path')\` decorator
1202
+ - [ ] Add \`@Controller()\` decorator
1201
1203
  - [ ] Add route handlers with \`@Get()\`, \`@Post()\`, etc.
1202
1204
  - [ ] Create module file implementing \`AppModule\` with \`routes()\` returning \`{ path, router: buildRoutes(Controller), controller }\`
1203
1205
  - [ ] Register module in \`src/modules/index.ts\` (\`AppModuleClass[]\` array)
@@ -1259,8 +1261,8 @@ import { AuthAdapter, JwtStrategy } from '@forinda/kickjs-auth'
1259
1261
  bootstrap({
1260
1262
  modules,
1261
1263
  adapters: [
1262
- new AuthAdapter({
1263
- strategies: [new JwtStrategy({ secret: process.env.JWT_SECRET! })],
1264
+ AuthAdapter({
1265
+ strategies: [JwtStrategy({ secret: process.env.JWT_SECRET! })],
1264
1266
  }),
1265
1267
  ],
1266
1268
  })
@@ -1290,7 +1292,7 @@ import { WsAdapter } from '@forinda/kickjs-ws'
1290
1292
 
1291
1293
  bootstrap({
1292
1294
  modules,
1293
- adapters: [new WsAdapter()],
1295
+ adapters: [WsAdapter()],
1294
1296
  })
1295
1297
  \`\`\`
1296
1298
 
@@ -1394,7 +1396,7 @@ These work anywhere — scripts, plain files, outside \`@Service\`/\`@Controller
1394
1396
  ### HTTP Routes
1395
1397
  | Decorator | Purpose |
1396
1398
  |-----------|---------|
1397
- | \`@Controller('/path')\` | Define route prefix |
1399
+ | \`@Controller()\` | Define route prefix |
1398
1400
  | \`@Get('/'), @Post('/')\` | HTTP method handlers |
1399
1401
  | \`@Middleware(fn)\` | Attach middleware |
1400
1402
  | \`@Public()\` | Skip auth (requires auth adapter) |
@@ -1915,6 +1917,228 @@ function pluralizePascal(name) {
1915
1917
  return pkg.plural(name);
1916
1918
  }
1917
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
1918
2142
  //#region src/generators/templates/module-index.ts
1919
2143
  const repoLabelMap = {
1920
2144
  inmemory: "in-memory",
@@ -4441,7 +4665,7 @@ import type { RequestContext } from '@forinda/kickjs'
4441
4665
  import { Autowired } from '@forinda/kickjs'
4442
4666
  import { AuthService } from './auth.service'
4443
4667
 
4444
- @Controller('/auth')
4668
+ @Controller()
4445
4669
  @Authenticated()
4446
4670
  export class AuthController {
4447
4671
  @Autowired() private authService!: AuthService
@@ -4523,7 +4747,7 @@ import type { RequestContext } from '@forinda/kickjs'
4523
4747
  import { Autowired } from '@forinda/kickjs'
4524
4748
  import { AuthService } from './auth.service'
4525
4749
 
4526
- @Controller('/auth')
4750
+ @Controller()
4527
4751
  @Authenticated()
4528
4752
  export class AuthController {
4529
4753
  @Autowired() private authService!: AuthService
@@ -5330,14 +5554,14 @@ const BUILTIN_REPO_TYPES = [
5330
5554
  "inmemory",
5331
5555
  "prisma"
5332
5556
  ];
5333
- /** Resolve module config with backward-compatible fallbacks from top-level fields */
5557
+ /** Resolve module config from `modules.*` block. */
5334
5558
  function resolveModuleConfig(config) {
5335
5559
  if (!config) return {};
5336
5560
  const mc = {
5337
- dir: config.modules?.dir ?? config.modulesDir,
5338
- repo: config.modules?.repo ?? config.defaultRepo,
5339
- schemaDir: config.modules?.schemaDir ?? config.schemaDir,
5340
- 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,
5341
5565
  prismaClientPath: config.modules?.prismaClientPath
5342
5566
  };
5343
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.`);
@@ -5365,7 +5589,10 @@ async function loadKickConfig(cwd) {
5365
5589
  try {
5366
5590
  const { pathToFileURL } = await import("node:url");
5367
5591
  const mod = await import(pathToFileURL(filepath).href);
5368
- 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;
5369
5596
  } catch (err) {
5370
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.`);
5371
5598
  continue;
@@ -5373,6 +5600,59 @@ async function loadKickConfig(cwd) {
5373
5600
  }
5374
5601
  return null;
5375
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
+ }
5376
5656
  //#endregion
5377
5657
  //#region src/typegen/scanner.ts
5378
5658
  /** Decorators that mark a class as DI-managed */
@@ -5428,6 +5708,28 @@ const BARE_CREATE_TOKEN_REGEX = /createToken\s*(?:<[^>]*>)?\s*\(\s*['"`]([^'"`]+
5428
5708
  /** Match `@Inject('literal')` — only literals; computed args are skipped */
5429
5709
  const INJECT_LITERAL_REGEX = /@Inject\s*\(\s*['"`]([^'"`]+)['"`]\s*\)/g;
5430
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
+ /**
5431
5733
  * Locate the start of a route decorator: `@Get(`, `@Post(`, etc.
5432
5734
  * Used by `extractRoutesFromSource`; the rest of the route declaration
5433
5735
  * (balanced parens, stacked decorators, method name) is parsed by walking
@@ -5774,6 +6076,120 @@ function extractInjectsFromSource(source, filePath, cwd) {
5774
6076
  return out;
5775
6077
  }
5776
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
+ /**
5777
6193
  * Default search order for the env schema file. Newer projects keep
5778
6194
  * the schema under `src/config/` so the framework's "config" concept
5779
6195
  * has a single home; older scaffolds dropped it at `src/env.ts` (kept
@@ -5844,6 +6260,8 @@ async function scanProject(opts) {
5844
6260
  const routes = [];
5845
6261
  const tokens = [];
5846
6262
  const injects = [];
6263
+ const pluginsAndAdapters = [];
6264
+ const augmentations = [];
5847
6265
  const sources = /* @__PURE__ */ new Map();
5848
6266
  for (const file of files) {
5849
6267
  let source;
@@ -5856,6 +6274,8 @@ async function scanProject(opts) {
5856
6274
  classes.push(...extractClassesFromSource(source, file, opts.cwd));
5857
6275
  tokens.push(...extractTokensFromSource(source, file, opts.cwd));
5858
6276
  injects.push(...extractInjectsFromSource(source, file, opts.cwd));
6277
+ pluginsAndAdapters.push(...extractPluginsAndAdaptersFromSource(source, file, opts.cwd));
6278
+ augmentations.push(...extractAugmentationsFromSource(source, file, opts.cwd));
5859
6279
  }
5860
6280
  for (const [file, source] of sources) {
5861
6281
  const classesInFile = classes.filter((c) => c.filePath === file);
@@ -5868,16 +6288,149 @@ async function scanProject(opts) {
5868
6288
  tokens.sort((a, b) => a.name.localeCompare(b.name) || a.relativePath.localeCompare(b.relativePath));
5869
6289
  injects.sort((a, b) => a.name.localeCompare(b.name) || a.relativePath.localeCompare(b.relativePath));
5870
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));
5871
6293
  return {
5872
6294
  classes,
5873
6295
  routes,
5874
6296
  tokens,
5875
6297
  injects,
5876
6298
  collisions: findCollisions(classes),
5877
- 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
5878
6302
  };
5879
6303
  }
5880
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
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;
6432
+ }
6433
+ //#endregion
5881
6434
  //#region src/typegen/generator.ts
5882
6435
  /**
5883
6436
  * Generates `.d.ts` files inside `.kickjs/types/` from the discovered
@@ -6013,12 +6566,17 @@ function renderIndex(includeEnv) {
6013
6566
  export type { ServiceToken } from './services'
6014
6567
  export type { ModuleToken } from './modules'
6015
6568
 
6016
- // The registry, routes, and env augmentations are loaded as side-effects —
6017
- // importing this file (or having it on tsconfig include) is enough for
6018
- // \`container.resolve()\`, \`Ctx<KickRoutes.UserController['getUser']>\`,
6019
- // 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.
6020
6575
  import './registry'
6021
6576
  import './routes'
6577
+ import './plugins'
6578
+ import './augmentations'
6579
+ import './assets'
6022
6580
  ${includeEnv ? "import './env'\n" : ""}`;
6023
6581
  }
6024
6582
  /**
@@ -6213,9 +6771,90 @@ ${interfaces.join("\n")}
6213
6771
  export {}
6214
6772
  `;
6215
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
+ }
6216
6852
  /** Write all generated `.d.ts` files to `outDir` */
6217
6853
  async function generateTypes(opts) {
6218
- 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;
6219
6858
  if (collisions.length > 0 && !allowDuplicates) throw new TokenCollisionError(collisions);
6220
6859
  await mkdir(outDir, { recursive: true });
6221
6860
  const registryFile = join(outDir, "registry.d.ts");
@@ -6223,6 +6862,9 @@ async function generateTypes(opts) {
6223
6862
  const modulesFile = join(outDir, "modules.d.ts");
6224
6863
  const routesFile = join(outDir, "routes.ts");
6225
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");
6226
6868
  const indexFile = join(outDir, "index.d.ts");
6227
6869
  const collidingNames = new Set(collisions.map((c) => c.className));
6228
6870
  const registryContent = renderRegistry(classes, registryFile, collidingNames);
@@ -6239,17 +6881,26 @@ async function generateTypes(opts) {
6239
6881
  const modulesContent = renderUnion("ModuleToken", modules, "(no @Module classes discovered — `kick g module <name>` to add one)");
6240
6882
  const routesContent = renderRoutes(routes, routesFile, schemaValidator);
6241
6883
  const envContent = renderEnv(env, envFile);
6884
+ const pluginsContent = renderPlugins(pluginsAndAdapters);
6885
+ const augmentationsContent = renderAugmentations(augmentations);
6886
+ const assetsContent = renderAssetTypes(assets);
6242
6887
  const indexContent = renderIndex(envContent !== null);
6243
6888
  await writeFile(registryFile, registryContent, "utf-8");
6244
6889
  await writeFile(servicesFile, servicesContent, "utf-8");
6245
6890
  await writeFile(modulesFile, modulesContent, "utf-8");
6246
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");
6247
6895
  await writeFile(indexFile, indexContent, "utf-8");
6248
6896
  const written = [
6249
6897
  registryFile,
6250
6898
  servicesFile,
6251
6899
  modulesFile,
6252
6900
  routesFile,
6901
+ pluginsFile,
6902
+ augmentationsFile,
6903
+ assetsFile,
6253
6904
  indexFile
6254
6905
  ];
6255
6906
  if (envContent) {
@@ -6257,17 +6908,62 @@ async function generateTypes(opts) {
6257
6908
  written.push(envFile);
6258
6909
  }
6259
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;
6260
6913
  return {
6261
6914
  registryEntries: classTokens.length,
6262
6915
  serviceTokens: new Set(allServices).size,
6263
6916
  moduleTokens: modules.length,
6264
6917
  routeEntries: routes.length,
6918
+ pluginEntries: uniquePluginNames,
6919
+ augmentationEntries: uniqueAugmentations,
6920
+ assetEntries: assets.count,
6265
6921
  envWritten: envContent !== null,
6266
6922
  written,
6267
6923
  resolvedCollisions: collisions.length
6268
6924
  };
6269
6925
  }
6270
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
6271
6967
  //#region src/typegen/index.ts
6272
6968
  /**
6273
6969
  * Public entry point for the KickJS typegen module.
@@ -6312,6 +7008,7 @@ async function runTypegen(opts = {}) {
6312
7008
  cwd,
6313
7009
  envFile: envFile === false ? void 0 : envFile
6314
7010
  });
7011
+ const assets = discoverAssets(opts.assetMap, cwd);
6315
7012
  const result = await generateTypes({
6316
7013
  classes: scan.classes,
6317
7014
  routes: scan.routes,
@@ -6319,20 +7016,36 @@ async function runTypegen(opts = {}) {
6319
7016
  injects: scan.injects,
6320
7017
  collisions: scan.collisions,
6321
7018
  env: envFile === false ? null : scan.env,
7019
+ pluginsAndAdapters: scan.pluginsAndAdapters,
7020
+ augmentations: scan.augmentations,
7021
+ assets,
6322
7022
  outDir,
6323
7023
  allowDuplicates,
6324
7024
  schemaValidator
6325
7025
  });
7026
+ const tokenWarnings = validateTokenConventions(scan.tokens);
6326
7027
  const elapsed = Date.now() - start;
6327
7028
  if (!silent) {
6328
7029
  const where = outDir.replace(cwd + "/", "");
6329
7030
  const collisionNote = result.resolvedCollisions > 0 ? `, ${result.resolvedCollisions} collisions namespaced` : "";
6330
7031
  const envNote = result.envWritten ? ", env typed" : "";
6331
- 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
+ }
6332
7044
  }
6333
7045
  return {
6334
7046
  scan,
6335
- result
7047
+ result,
7048
+ tokenWarnings
6336
7049
  };
6337
7050
  }
6338
7051
  /**
@@ -6489,10 +7202,23 @@ const GENERATORS = [
6489
7202
  description: "Generate kick.config.ts"
6490
7203
  }
6491
7204
  ];
6492
- function printGeneratorList() {
6493
- console.log("\n Available generators:\n");
7205
+ async function printGeneratorList() {
7206
+ console.log("\n Built-in generators:\n");
6494
7207
  const maxName = Math.max(...GENERATORS.map((g) => g.name.length));
6495
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
+ }
6496
7222
  console.log();
6497
7223
  }
6498
7224
  /**
@@ -6529,7 +7255,7 @@ async function runModuleGeneration(names, opts, dryRun) {
6529
7255
  function registerGenerateCommand(program) {
6530
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) => {
6531
7257
  if (opts.list) {
6532
- printGeneratorList();
7258
+ await printGeneratorList();
6533
7259
  return;
6534
7260
  }
6535
7261
  if (!names || names.length === 0) {
@@ -6538,6 +7264,20 @@ function registerGenerateCommand(program) {
6538
7264
  }
6539
7265
  const dryRun = isDryRun(cmd);
6540
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
+ }
6541
7281
  await runModuleGeneration(names, opts, dryRun);
6542
7282
  });
6543
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) => {
@@ -6767,6 +7507,132 @@ function runNodeWithEnv(entry, env, cwd) {
6767
7507
  });
6768
7508
  if (result.status !== 0) process.exit(result.status ?? 1);
6769
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
+ }
6770
7636
  //#endregion
6771
7637
  //#region src/commands/run.ts
6772
7638
  /**
@@ -6795,7 +7661,8 @@ async function startDevServer(_entry, port) {
6795
7661
  schemaValidator,
6796
7662
  envFile,
6797
7663
  srcDir: devConfig?.typegen?.srcDir,
6798
- outDir: devConfig?.typegen?.outDir
7664
+ outDir: devConfig?.typegen?.outDir,
7665
+ assetMap: devConfig?.assetMap
6799
7666
  });
6800
7667
  } catch (err) {
6801
7668
  console.warn(` kick typegen: skipped (${err?.message ?? err})`);
@@ -6820,7 +7687,8 @@ async function startDevServer(_entry, port) {
6820
7687
  schemaValidator,
6821
7688
  envFile,
6822
7689
  srcDir: devConfig?.typegen?.srcDir,
6823
- outDir: devConfig?.typegen?.outDir
7690
+ outDir: devConfig?.typegen?.outDir,
7691
+ assetMap: devConfig?.assetMap
6824
7692
  }).catch(() => {});
6825
7693
  }, 100);
6826
7694
  };
@@ -6853,7 +7721,8 @@ function registerRunCommands(program) {
6853
7721
  const { createRequire } = await import("node:module");
6854
7722
  const { build } = await import(pathToFileURL(createRequire(resolve("package.json")).resolve("vite")).href);
6855
7723
  await build({ configFile: resolve("vite.config.ts") });
6856
- const copyDirs = (await loadKickConfig(process.cwd()))?.copyDirs ?? [];
7724
+ const config = await loadKickConfig(process.cwd());
7725
+ const copyDirs = config?.copyDirs ?? [];
6857
7726
  if (copyDirs.length > 0) {
6858
7727
  console.log("\n Copying directories to dist...");
6859
7728
  for (const entry of copyDirs) {
@@ -6870,8 +7739,32 @@ function registerRunCommands(program) {
6870
7739
  console.log(` ✓ ${src} → ${dest}`);
6871
7740
  }
6872
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
+ }
6873
7751
  console.log("\n Build complete.\n");
6874
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
+ });
6875
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) => {
6876
7769
  const env = { NODE_ENV: "production" };
6877
7770
  if (opts.port) env.PORT = String(opts.port);
@@ -6903,7 +7796,6 @@ function registerInfoCommand(program) {
6903
7796
  Packages:
6904
7797
  @forinda/kickjs workspace
6905
7798
  @forinda/kickjs-vite workspace
6906
- @forinda/kickjs-config workspace
6907
7799
  @forinda/kickjs-cli workspace
6908
7800
  `);
6909
7801
  });
@@ -8141,7 +9033,8 @@ function registerTypegenCommand(program) {
8141
9033
  silent: opts.silent,
8142
9034
  allowDuplicates: opts.allowDuplicates,
8143
9035
  schemaValidator,
8144
- envFile
9036
+ envFile,
9037
+ assetMap: config?.assetMap
8145
9038
  };
8146
9039
  try {
8147
9040
  if (opts.watch) {