@forinda/kickjs-cli 8.7.9 → 8.9.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/bin.js +2 -2
- package/dist/{agent-docs-BLBI7YID.mjs → agent-docs-DUB-1Z6Q.mjs} +47 -22
- package/dist/agent-docs-DUB-1Z6Q.mjs.map +1 -0
- package/dist/{agent-docs-Ep7kO8om.mjs → agent-docs-bVMPt2m5.mjs} +46 -21
- package/dist/{build-DQGyJjPJ.mjs → build-C30mBgw6.mjs} +3 -3
- package/dist/build-C30mBgw6.mjs.map +1 -0
- package/dist/{build-NKAoV4wt.mjs → build-xQXbdDpw.mjs} +2 -2
- package/dist/{builtins-CAznAas6.mjs → builtins-CtpLZ4FY.mjs} +2 -2
- package/dist/{builtins-DrRpYzbB.mjs → builtins-DiWfXc-D.mjs} +125 -111
- package/dist/cli.mjs +2 -2
- package/dist/config-YvIngROH.mjs +12 -0
- package/dist/config-ZHuFjsKI.mjs +13 -0
- package/dist/config-ZHuFjsKI.mjs.map +1 -0
- package/dist/{doctor-CYVZFw9C.mjs → doctor-CnsnRW7T.mjs} +10 -10
- package/dist/doctor-CnsnRW7T.mjs.map +1 -0
- package/dist/fs-Bm2St4gk.mjs +11 -0
- package/dist/fs-BvGbbHY0.mjs +12 -0
- package/dist/fs-BvGbbHY0.mjs.map +1 -0
- package/dist/fullstack-B0g06sSQ.mjs +96 -0
- package/dist/fullstack-B0g06sSQ.mjs.map +1 -0
- package/dist/fullstack-CZ9XQYk-.mjs +95 -0
- package/dist/index.d.mts +16 -14
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +2 -2
- package/dist/plugin-BfdvW6_r.mjs +11 -0
- package/dist/{plugin-Dx_Z_-gx.mjs → plugin-Bs0soxrU.mjs} +2 -2
- package/dist/project-B_qDdkYQ.mjs +113 -0
- package/dist/project-B_qDdkYQ.mjs.map +1 -0
- package/dist/project-Bp-KfBNb.mjs +112 -0
- package/dist/project-root-B9BrO1na.mjs +12 -0
- package/dist/project-root-B9BrO1na.mjs.map +1 -0
- package/dist/project-root-LYOxQHXp.mjs +11 -0
- package/dist/{prompts-QNb3FEUG.mjs → prompts-BEAqcbPT.mjs} +2 -2
- package/dist/prompts-BEAqcbPT.mjs.map +1 -0
- package/dist/{rolldown-runtime-D4R0ptAp.mjs → rolldown-runtime-Da-NDvKz.mjs} +1 -1
- package/dist/{run-plugins-DFoIjByI.mjs → run-plugins-0AfIgIsZ.mjs} +95 -81
- package/dist/run-plugins-0AfIgIsZ.mjs.map +1 -0
- package/dist/typegen-BzgWveE_.mjs +107 -0
- package/dist/typegen-BzgWveE_.mjs.map +1 -0
- package/dist/typegen-up-UkaNc.mjs +106 -0
- package/dist/{types-CsiChRIU.mjs → types-ChFFRT8E.mjs} +2 -2
- package/package.json +6 -6
- package/templates/base/files/src/serverless.ts +6 -0
- package/templates/host-config/files/netlify.toml +1 -1
- package/templates/template-inertia/feature.json +29 -0
- package/templates/template-inertia/files/_dot_env.append +2 -0
- package/templates/template-inertia/files/_dot_env.example.append +2 -0
- package/templates/template-inertia/files/_dot_env.test.append +1 -0
- package/templates/template-inertia/files/_dot_env.test.example.append +1 -0
- package/templates/template-inertia/files/public/favicon.svg +1 -0
- package/templates/template-inertia/files/public/robots.txt +4 -0
- package/templates/template-inertia/files/src/client/Layout.tsx +20 -0
- package/templates/template-inertia/files/src/client/app.css +8 -0
- package/templates/template-inertia/files/src/client/main.tsx +14 -0
- package/templates/template-inertia/files/src/client/pages/Error.tsx +14 -0
- package/templates/template-inertia/files/src/client/pages/Home.tsx +13 -0
- package/templates/template-inertia/files/src/client/pages/Posts/Index.tsx +38 -0
- package/templates/template-inertia/files/src/client/ssr.tsx +17 -0
- package/templates/template-inertia/files/src/index.ts +48 -0
- package/templates/template-inertia/files/src/modules/index.ts +7 -0
- package/templates/template-inertia/files/src/modules/pages/pages.controller.ts +37 -0
- package/templates/template-inertia/files/src/modules/pages/pages.module.ts +12 -0
- package/templates/template-inertia/files/tsconfig.json +22 -0
- package/templates/template-inertia/files/vite.client.config.ts +30 -0
- package/templates/template-inertia/files/vite.config.ts +44 -0
- package/templates/web-kick/files/web/src/api.ts +5 -2
- package/templates/web-kick/files/web/tsconfig.json +1 -1
- package/dist/agent-docs-BLBI7YID.mjs.map +0 -1
- package/dist/build-DQGyJjPJ.mjs.map +0 -1
- package/dist/config-BELa-bQp.mjs +0 -13
- package/dist/config-BELa-bQp.mjs.map +0 -1
- package/dist/config-kVPKaftt.mjs +0 -12
- package/dist/doctor-CYVZFw9C.mjs.map +0 -1
- package/dist/fs-DeTT9ZK-.mjs +0 -11
- package/dist/fs-nag0OfKW.mjs +0 -12
- package/dist/fs-nag0OfKW.mjs.map +0 -1
- package/dist/fullstack-s_aMeFzj.mjs +0 -95
- package/dist/fullstack-uCHI9HHB.mjs +0 -96
- package/dist/fullstack-uCHI9HHB.mjs.map +0 -1
- package/dist/plugin-C77kofvz.mjs +0 -11
- package/dist/project-6r0RJYMr.mjs +0 -46
- package/dist/project-DzjSBUTp.mjs +0 -47
- package/dist/project-DzjSBUTp.mjs.map +0 -1
- package/dist/project-root-BRTvnJkL.mjs +0 -12
- package/dist/project-root-BRTvnJkL.mjs.map +0 -1
- package/dist/project-root-Cw17cNBj.mjs +0 -11
- package/dist/prompts-QNb3FEUG.mjs.map +0 -1
- package/dist/run-plugins-DFoIjByI.mjs.map +0 -1
- package/dist/typegen-8MO7N1hc.mjs +0 -107
- package/dist/typegen-8MO7N1hc.mjs.map +0 -1
- package/dist/typegen-vfWafXuR.mjs +0 -106
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @forinda/kickjs-cli v8.
|
|
2
|
+
* @forinda/kickjs-cli v8.9.0
|
|
3
3
|
*
|
|
4
4
|
* Copyright (c) Felix Orinda
|
|
5
5
|
*
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
*
|
|
9
9
|
* @license MIT
|
|
10
10
|
*/
|
|
11
|
-
import{t as e}from"./rolldown-runtime-
|
|
11
|
+
import{t as e}from"./rolldown-runtime-Da-NDvKz.mjs";import{a as t}from"./config-YvIngROH.mjs";import{r as n}from"./fs-Bm2St4gk.mjs";import{existsSync as r,readFileSync as i}from"node:fs";import{join as a}from"node:path";import*as o from"@clack/prompts";import s from"picocolors";const c={GET:s.green,POST:s.cyan,PUT:s.yellow,PATCH:s.magenta,DELETE:s.red};function l(e){return(c[e]??s.dim)(e.padEnd(7))}function u(e){let t=`[${e}]`.padEnd(10);switch(e){case`CRITICAL`:return s.red(t);case`WARNING`:return s.yellow(t);case`INFO`:return s.blue(s.dim(t));default:return t}}s.green(`✓`),s.red(`✖`),s.yellow(`⚠`),s.blue(`ℹ`);function d(e){o.intro(s.bgCyan(s.black(` ${e} `)))}function f(e){o.outro(e)}function p(e){o.isCancel(e)&&(o.cancel(`Operation cancelled.`),process.exit(0))}async function m(e){let t=await o.text(e);return p(t),t}async function h(e){let t=await o.select(e);return p(t),t}async function g(e){let t=await o.multiselect(e);return p(t),t}async function _(e){let t=await o.confirm(e);return p(t),t}function v(){return o.spinner()}const y=o.log;function b(e,t,n){return`# CLAUDE.md — ${e}
|
|
12
12
|
|
|
13
13
|
**Read \`./.agents/AGENTS.md\` first.** It is the canonical, multi-agent
|
|
14
14
|
reference for this project (Claude, Copilot, Codex, Gemini, etc.) —
|
|
@@ -101,12 +101,16 @@ This is a WORKSPACE root — the KickJS API lives in \`server/\`, the typed web
|
|
|
101
101
|
app in \`web/\`. Run both with \`${n===`pnpm`?`pnpm dev`:`${n} run dev:server + ${n} run dev:web`}\`.
|
|
102
102
|
|
|
103
103
|
The type loop (do not break it):
|
|
104
|
-
1. \`server/\` handlers RETURN their payloads → \`kick typegen\`
|
|
105
|
-
\`
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
104
|
+
1. \`server/\` handlers RETURN their payloads → \`kick typegen\` emits
|
|
105
|
+
\`server/.kickjs/types/kick__client.d.ts\`: the ambient \`KickClientApi\`
|
|
106
|
+
namespace, every response resolved to a plain shape. \`kick dev\` writes it
|
|
107
|
+
at startup but NOT on save — run \`kick typegen\` in \`server/\` after changing a route or a
|
|
108
|
+
response (the root \`build\` script runs it too).
|
|
109
|
+
2. \`web/tsconfig.json\` lists that file in \`compilerOptions.types\`.
|
|
110
|
+
3. \`web/src/api.ts\` = \`createClient<KickClientApi.Api>({ baseUrl })\`
|
|
111
|
+
— every call site is typed from the server's handlers. \`baseUrl\` is the
|
|
112
|
+
relative \`/api/v1\` (Vite proxies it in dev) unless \`VITE_API_URL\` points at
|
|
113
|
+
an API on another origin.
|
|
110
114
|
|
|
111
115
|
Rules: kick commands (\`kick g\`, \`kick typegen\`, \`kick dev\`) run in
|
|
112
116
|
\`server/\`; never import server runtime code into \`web/\` (the d.ts bridge is
|
|
@@ -142,7 +146,7 @@ Rules that keep generated code correct on **every** engine:
|
|
|
142
146
|
Don't assume \`ctx.req\` is an \`express.Request\` in portable code.
|
|
143
147
|
- **Global middleware** in \`bootstrap({ middlewares })\` is connect-style
|
|
144
148
|
\`(req, res, next)\` — it runs on all engines (Fastify via \`@fastify/middie\`,
|
|
145
|
-
h3 via \`
|
|
149
|
+
h3 via \`fromNodeHandler\`). But on Fastify / h3 the engine parses the body
|
|
146
150
|
natively, so the default \`express.json()\` is **auto-skipped** (\`nativeBodyParsing\`).
|
|
147
151
|
Don't add \`express.json()\` manually on those engines.
|
|
148
152
|
- **File uploads** work on all three: \`@FileUpload({ mode, fieldName, ... })\` →
|
|
@@ -631,7 +635,7 @@ plugins: [
|
|
|
631
635
|
**Red flags**:
|
|
632
636
|
- Any \`new SomeAdapter()\` / \`SomePlugin()\` literal inside \`bootstrap({ ... })\` instead of imported from a category folder.
|
|
633
637
|
- Mixing middleware signatures: \`bootstrap({ middlewares })\` is **raw Express** \`(req, res, next)\`; \`@Middleware()\` decorators are \`(ctx, next)\`; adapter middleware is connect-style too (mounted through the runtime seam, so it is not Express-only). Wrong shape in the wrong slot throws "Cannot read properties of undefined".
|
|
634
|
-
- \`bootstrap({ register: ... })\` — that option doesn't exist. Use an inline plugin.`},{slug:`context-contributor`,frontmatterName:`kickjs-context-contributor`,description:`Use when a middleware's only job is to set ctx values consumed elsewhere — replace with defineHttpContextDecorator (HTTP) or defineContextDecorator (transport-agnostic).`,body:"**Pattern** (HTTP — most common):\n\n```ts\nimport { defineHttpContextDecorator, type RequestContext } from '@forinda/kickjs'\n\n// Augment ContextMeta — required for ctx.get('tenant') to be typed\ndeclare module '@forinda/kickjs' {\n interface ContextMeta {\n tenant: { id: string; name: string }\n }\n}\n\n// The `declare module` block above is all you need for typed `ctx.get`.\n\nconst LoadTenant = defineHttpContextDecorator({\n key: 'tenant',\n deps: { repo: TENANT_REPO }, // typed DI\n resolve: (ctx, { repo }) => repo.findById(ctx.req.headers['x-tenant-id'] as string),\n})\n\nconst LoadProject = defineHttpContextDecorator({\n key: 'project',\n dependsOn: ['tenant'], // typo'd key = tsc error\n resolve: (ctx) => projectsRepo.find(ctx.get('tenant')!.id, ctx.params.id),\n})\n\n@LoadTenant\n@LoadProject\n@Get('/projects/:id')\ngetProject(ctx: RequestContext) {\n return ctx.get('project')\n}\n```\n\nUse `defineContextDecorator` (no Http prefix) only when the contributor must run across HTTP, WebSocket, queue, and cron transports — `Ctx` defaults to the smaller `ExecutionContext` surface (`get` / `set` / `requestId` only, no `req`).\n\n**Five precedence levels** (high → low):\n**method > class > module > adapter > global**\n\nSame-key collisions WITHIN a precedence level throw `DuplicateContributorError`. Across levels, the higher precedence silently overrides — a feature, not a bug, but debug it by giving resolvers distinguishable return values.\n\n**Boot-time validation**:\n- Cycles in `dependsOn` → `ContributorCycleError`.\n- `dependsOn` referring to an unknown key → `MissingContributorError`.\n- Both errors fail boot, not first request.\n\n**Critical rules — all stem from the same shared-via-ALS instance model**:\n- Every per-request stage (middleware → contributors → handler) gets its OWN `RequestContext` instance, but they all read/write the SAME `AsyncLocalStorage`-backed bag.\n- **`resolve` and `onError` must RETURN the value** — the runner writes it via `ctx.set(key, value)`. Direct property assignment (`ctx.tenant = …`) sticks to one instance only and the handler instance never sees it.\n- `ctx.set('tenant', x)` then `ctx.get('tenant')` works across instances. `ctx.req.headers[...]` works (the underlying node request is shared). Note it is a node `IncomingMessage` on Fastify and h3, not an `express.Request`.\n- Services with no `ctx` reference: `getRequestValue('tenant')` returns `MetaValue<'tenant'> | undefined` (typed via the augmented `ContextMeta`). For `requestId` use `getRequestStore()`.\n- **No `setRequestValue` — writes flow through `ctx.set` or a contributor's return value.** Avoids \"spooky action at a distance\" where any service can pollute the per-request bag.\n\n**Error matrix**:\n- `optional: true` — `resolve` throws → key left unset; downstream sees `ctx.get(key) === undefined`.\n- `optional: false` (default) + `onError` — return a fallback value to write; return `undefined` to skip; throw to forward to the request error handler.\n- `optional: false` + no `onError` — throw propagates straight to the request error handler.\n\n**Don't use this for**: response short-circuit, stream mutation, or pre-route-matching work — keep `@Middleware()` for those.\n\n**Red flags**:\n- `ctx.get('key')!` — the non-null assertion compiles even when the producing decorator isn't on the route. Use `ctx.require('key')`.\n- `contributors: [LoadX]` at a module / adapter / bootstrap site — those take registrations: `LoadX.registration` or `LoadX.with({ ... }).registration`.\n- A `paramDefaults` value that every call site overrides (`action: 'settings:read'`) — drop it and let the compiler require the field at each site.\n- `defineContextDecorator<'k', Deps, Params>(spec)` positional form for a parameterised contributor — use `.withParams<Params>()(spec)` or `deps` inference is lost.\n- `ctx.tenant = x` instead of returning the value from `resolve` — sticks to one instance only.\n- Plugin / adapter authors using bare keys (`'state'`) instead of namespaced (`'@my-plugin/state'`) — collides with adopter keys.\n- `getRequestValue<string>('traceId')` — generic is the **key** type, not value type."},{slug:`guard-vs-middleware-vs-contributor`,frontmatterName:`kickjs-guard-vs-middleware-vs-contributor`,description:`Use when adding auth checks, request-scoped values, or anything "before the handler" — picks between a guard, a middleware, and a context contributor.`,body:"**KickJS has no guard primitive.** There is no `@Guard`, no `canActivate`, no guard\nclass to implement. A guard IS a middleware — the word only names a convention\nand a directory. If you are porting NestJS habits, this is the first thing to\nunlearn.\n\n| | Guard | Middleware | Context contributor |\n|---|---|---|---|\n| What it is | a middleware, by convention | a middleware | a declarative value producer |\n| Signature | `(ctx, next)` | `(ctx, next)` on `@Middleware()`, `(req, res, next)` in `bootstrap({ middlewares })` | `resolve(ctx, deps)` returning a value |\n| Can end the request | yes | yes | **no** |\n| Runs | in the middleware stage | in the middleware stage | after ALL middleware |\n| Attached by | `@Middleware(fn)` | `@Middleware(fn)` or `bootstrap({ middlewares })` | `@LoadX`, or a registration at module / adapter / bootstrap level |\n| Generator | `kick g guard <name>` | `kick g middleware <name>` | `kick g contributor <name>` |\n| Lands in | `src/guards/<name>.guard.ts` | `src/middleware/<name>.middleware.ts` | `src/contributors/<name>.contributor.ts` |\n\nAdd `-m <module>` to any of those generators to place the file inside a module\ninstead of the app-level directory.\n\n**Choosing**: does the thing ever need to STOP the request?\n- Yes, and it is authorization → guard.\n- Yes, anything else (rate limit, body rewrite, response stream, work before route matching) → middleware.\n- No, it only computes a value the handler or a service reads off `ctx` → contributor. Typed, ordered, boot-validated, and it cannot silently swallow the request.\n\n**Ordering — guards run BEFORE contributors.** Every runtime does the same\nthing: run `entry.middlewares` in order, bail if the response was written,\nthen run the contributor pipeline, then the handler. So:\n\n```ts\n// WRONG — tenant is always undefined here.\nexport async function tenantGuard(ctx: RequestContext, next: () => void) {\n const tenant = ctx.get('tenant') // contributors have not run yet\n if (!tenant) return ctx.problem.forbidden()\n next()\n}\n```\n\nA guard that needs a resolved value must resolve it itself, or the check\nbelongs in the contributor's own `resolve` (throw from there and the request\nerror handler takes over).\n\n**Write responses with `ctx.*`, never `ctx.res`.** `ctx.res` is the ENGINE-NATIVE\nresponse object, so `ctx.res.status(401).json(...)` only works on Express —\n`FastifyReply` has no `.json()` and h3's event has no `.status()`. Use\n`ctx.problem.unauthorized({ detail })` (RFC 9457) or `ctx.json(body, status)`,\nand the helper for anything else you would reach `ctx.res` for:\n`ctx.download(buffer, filename, contentType?)` for a generated file,\n`ctx.html(content)`, `ctx.redirect(url, status?)`, `ctx.sse()` for a stream.\nAll of them work on all four runtimes. Never hand `ctx.redirect` a destination\ntaken from the request unchecked — allow-list it, or accept only a same-site path\n(one leading `/`, not `//`) — or the route is an open redirect.\n\n**Guard shape**:\n\n```ts\nimport type { RequestContext } from '@forinda/kickjs'\n\nexport async function adminGuard(ctx: RequestContext, next: () => void): Promise<void> {\n // Requires the session() middleware. Type the key once:\n // declare module '@forinda/kickjs' { interface SessionData { role?: 'admin' | 'member' } }\n const role = ctx.session?.data.role\n if (!role) {\n ctx.problem.unauthorized({ detail: 'Not signed in' })\n return // do NOT call next()\n }\n if (role !== 'admin') {\n ctx.problem.forbidden({ detail: 'Admin only' })\n return\n }\n next()\n}\n```\n\n```ts\n@Middleware(adminGuard)\n@Get('/admin/stats')\nstats(ctx: RequestContext) { ... }\n```\n\nRole checks that are purely declarative (`@Public()`, `@Roles('admin')`,\n`@Can(...)`) are yours to define — see the BYO Auth recipe in the docs.\nHand-write a guard only for logic those don't express.\n\n**Red flags**:\n- `class AdminGuard implements CanActivate` / `@UseGuards()` — NestJS, not KickJS. Export a `(ctx, next)` function and attach with `@Middleware()`.\n- A guard calling `next()` AND writing a response — pick one; writing then continuing double-sends.\n- A guard reading `ctx.get(...)` for a contributor-produced key — contributors have not run yet.\n- `ctx.res.status(403).json(...)` in a guard — Express-only. Use `ctx.problem.forbidden()`.\n- A \"guard\" that only sets a value and always calls `next()` — that is a contributor wearing a guard's name.\n- Passing a `(ctx, next)` guard to `bootstrap({ middlewares })` — global middleware is connect-style `(req, res, next)`. Mount guards per-route with `@Middleware()`.\n"},{slug:`query-parsing-list-endpoint`,frontmatterName:`kickjs-query-parsing-list-endpoint`,description:`Use when adding a paginated/filterable list route — emit ctx.qs + ctx.paginate with an allow-list.`,body:"**Canonical list endpoint**:\n\n```ts\n@Get('/')\nasync list(ctx: Ctx<KickRoutes.TodoController['list']>) {\n const parsed = ctx.qs({\n filterable: ['status', 'priority', 'assigneeId'], // allow-list, MUST be set\n sortable: ['createdAt', 'updatedAt', 'priority'],\n searchColumns: ['title', 'description'], // free-text search targets\n })\n\n return ctx.paginate(async () => {\n const { data, total } = await this.service.list(parsed)\n return { data, total }\n }, parsed)\n}\n```\n\n**Operator format** (fixed): `?filter=field:op:value` where `op ∈ eq | neq | gt | gte | lt | lte | between | in | contains | starts | ends`. Sort is `?sort=field:asc|desc`. Only the first two colons are delimiters, so timestamps work (`createdAt:gt:2026-01-01T00:00:00Z`).\n\n**Drizzle adopters** — pass a `DrizzleQueryParamsConfig` with column refs:\n\n```ts\nconst TASK_QUERY_CONFIG = {\n filterable: { status: tasks.status, priority: tasks.priority },\n sortable: { createdAt: tasks.createdAt },\n searchColumns: [tasks.title, tasks.description],\n}\nconst parsed = ctx.qs(TASK_QUERY_CONFIG)\n```\n\n**ORM-agnostic builders** — implement `QueryBuilderAdapter<TResult, TConfig>` with `build(parsed, config)`. The Drizzle + Prisma adapters live here.\n\n**Red flags**:\n- Reading `req.query.status` directly — bypasses the allow-list; opens unbounded filtering. Use `ctx.qs({ filterable })`.\n- Omitting `filterable` / `sortable` allow-list — every client-supplied filter is **silently dropped** (security default, but looks like a bug).\n- Hand-building the pagination meta in the controller — inconsistent response shape across endpoints. Always use `ctx.paginate()`.\n- Returning a bare array from a list endpoint when pagination is implied — breaks the `PaginatedResponse<T>` contract.\n- Mixing string `searchable` config with column `searchColumns` (Drizzle) — silently no-ops.\n\n**Nuances**:\n- `limit` is capped at 100 server-side; `q` (search) is truncated to 200 chars. Don't re-validate client-side.\n- Sort direction defaults to `asc` when omitted (`?sort=createdAt` ≡ `?sort=createdAt:asc`)."},{slug:`use-asset-manager`,frontmatterName:`kickjs-use-asset-manager`,description:`Use when code reads template files / JSON fixtures via fs.readFile + path arithmetic — switch to assets.<ns>.<key>() and the kick.config.ts assetMap.`,body:"**Configure** `kick.config.ts`:\n\n```ts\nexport default defineConfig({\n assetMap: {\n mails: { src: 'src/templates/mails' },\n reports: { src: 'src/templates/reports', glob: '**/*.{ejs,html}' },\n },\n})\n```\n\n**Consume** via the typed Proxy — no `__dirname` arithmetic, dev/prod paths handled:\n\n```ts\nimport { assets } from '@forinda/kickjs'\n\nconst html = await assets.mails.welcome() // typed: tsc errors on bad key\n```\n\n**Class-field decorator** (lazy getter, swappable in tests):\n\n```ts\nclass WelcomeMailService {\n @Asset('mails/welcome') private welcomeTemplate!: () => Promise<string>\n\n async send(to: string) {\n const body = await this.welcomeTemplate()\n }\n}\n```\n\n**Dynamic dispatch** (CMS templates, codegen) — `resolveAsset(ns, key)` throws `UnknownAssetError` with `{ namespace, key }` fields when the key is missing.\n\n**Test fixtures** — swap via env override + cache clear:\n\n```ts\nbeforeEach(() => {\n process.env.KICK_ASSETS_ROOT = path.resolve('__fixtures__/assets')\n clearAssetCache()\n})\nafterEach(() => {\n delete process.env.KICK_ASSETS_ROOT\n clearAssetCache()\n})\n```\n\n**Red flags**:\n- Hand-rolled `process.env.NODE_ENV === 'production' ? join(__dirname, '../templates') : join(__dirname, 'templates')` — exactly what the asset manager replaces.\n- `keys: 'strip'` setting in `assetMap.<ns>` when basenames may collide — silent last-walk-wins data loss. Default `'auto'` keeps extensions only for colliding groups.\n- Non-default Vite `outDir` without mirroring in `kick.config.ts` — manifest writes at `dist/.kickjs-assets.json` but the resolver can't find it. Mirror via `build.outDir`.\n- Forgetting to re-run `kick typegen` after adding files — `assets.mails.newTemplate` is a tsc error even though the file ships. `kick dev` does this on-change; one-shot CI builds need `kick build` (or `kick build:assets` for manifest-only).\n- Same-name `welcome.ejs` + `welcome/login.ejs` — directory wins in the typed surface; the `.ejs` file still copies but isn't addressable.\n\n**Nuances**:\n- Resolution pipeline (cached): `KICK_ASSETS_ROOT` env override > built manifest at `build.outDir` / `dist` / `build` / `out` > dev-fallback in-memory walk. Manifest presence = \"running from built dist.\"\n- Dev-mode glob matcher is a lite implementation — `**/*`, `**/*.ext`, `**/*.{a,b}` are guaranteed; exotic globs warn-once and accept everything. Run `kick build:assets` to exercise the real glob engine."},{slug:`cli-commands-cheatsheet`,frontmatterName:`kickjs-cli-commands-cheatsheet`,description:`Use as a quick reference for the most common kick CLI workflows — scaffolding, dev/build/start, generation, inspection.`,body:`**Top commands**:
|
|
638
|
+
- \`bootstrap({ register: ... })\` — that option doesn't exist. Use an inline plugin.`},{slug:`context-contributor`,frontmatterName:`kickjs-context-contributor`,description:`Use when a middleware's only job is to set ctx values consumed elsewhere — replace with defineHttpContextDecorator (HTTP) or defineContextDecorator (transport-agnostic).`,body:"**Pattern** (HTTP — most common):\n\n```ts\nimport { defineHttpContextDecorator, type RequestContext } from '@forinda/kickjs'\n\n// Augment ContextMeta — required for ctx.get('tenant') to be typed\ndeclare module '@forinda/kickjs' {\n interface ContextMeta {\n tenant: { id: string; name: string }\n }\n}\n\n// The `declare module` block above is all you need for typed `ctx.get`.\n\nconst LoadTenant = defineHttpContextDecorator({\n key: 'tenant',\n deps: { repo: TENANT_REPO }, // typed DI\n resolve: (ctx, { repo }) => repo.findById(ctx.req.headers['x-tenant-id'] as string),\n})\n\nconst LoadProject = defineHttpContextDecorator({\n key: 'project',\n dependsOn: ['tenant'], // typo'd key = tsc error\n resolve: (ctx) => projectsRepo.find(ctx.get('tenant')!.id, ctx.params.id),\n})\n\n@LoadTenant\n@LoadProject\n@Get('/projects/:id')\ngetProject(ctx: RequestContext) {\n return ctx.get('project')\n}\n```\n\nUse `defineContextDecorator` (no Http prefix) only when the contributor must run across HTTP, WebSocket, queue, and cron transports — `Ctx` defaults to the smaller `ExecutionContext` surface (`get` / `set` / `requestId` only, no `req`).\n\n**Five precedence levels** (high → low):\n**method > class > module > adapter > global**\n\nSame-key collisions WITHIN a precedence level throw `DuplicateContributorError`. Across levels, the higher precedence silently overrides — a feature, not a bug, but debug it by giving resolvers distinguishable return values.\n\n**Boot-time validation**:\n- Cycles in `dependsOn` → `ContributorCycleError`.\n- `dependsOn` referring to an unknown key → `MissingContributorError`.\n- Both errors fail boot, not first request.\n\n**Critical rules — all stem from the same shared-via-ALS instance model**:\n- Every per-request stage (middleware → contributors → handler) gets its OWN `RequestContext` instance, but they all read/write the SAME `AsyncLocalStorage`-backed bag.\n- **`resolve` and `onError` must RETURN the value** — the runner writes it via `ctx.set(key, value)`. Direct property assignment (`ctx.tenant = …`) sticks to one instance only and the handler instance never sees it.\n- `ctx.set('tenant', x)` then `ctx.get('tenant')` works across instances. `ctx.req.headers[...]` works (the underlying node request is shared). Note it is a node `IncomingMessage` on Fastify and a web-backed request on h3, not an `express.Request`.\n- Services with no `ctx` reference: `getRequestValue('tenant')` returns `MetaValue<'tenant'> | undefined` (typed via the augmented `ContextMeta`). For `requestId` use `getRequestStore()`.\n- **No `setRequestValue` — writes flow through `ctx.set` or a contributor's return value.** Avoids \"spooky action at a distance\" where any service can pollute the per-request bag.\n\n**Error matrix**:\n- `optional: true` — `resolve` throws → key left unset; downstream sees `ctx.get(key) === undefined`.\n- `optional: false` (default) + `onError` — return a fallback value to write; return `undefined` to skip; throw to forward to the request error handler.\n- `optional: false` + no `onError` — throw propagates straight to the request error handler.\n\n**Don't use this for**: response short-circuit, stream mutation, or pre-route-matching work — keep `@Middleware()` for those.\n\n**Red flags**:\n- `ctx.get('key')!` — the non-null assertion compiles even when the producing decorator isn't on the route. Use `ctx.require('key')`.\n- `contributors: [LoadX]` at a module / adapter / bootstrap site — those take registrations: `LoadX.registration` or `LoadX.with({ ... }).registration`.\n- A `paramDefaults` value that every call site overrides (`action: 'settings:read'`) — drop it and let the compiler require the field at each site.\n- `defineContextDecorator<'k', Deps, Params>(spec)` positional form for a parameterised contributor — use `.withParams<Params>()(spec)` or `deps` inference is lost.\n- `ctx.tenant = x` instead of returning the value from `resolve` — sticks to one instance only.\n- Plugin / adapter authors using bare keys (`'state'`) instead of namespaced (`'@my-plugin/state'`) — collides with adopter keys.\n- `getRequestValue<string>('traceId')` — generic is the **key** type, not value type."},{slug:`guard-vs-middleware-vs-contributor`,frontmatterName:`kickjs-guard-vs-middleware-vs-contributor`,description:`Use when adding auth checks, request-scoped values, or anything "before the handler" — picks between a guard, a middleware, and a context contributor.`,body:"**KickJS has no guard primitive.** There is no `@Guard`, no `canActivate`, no guard\nclass to implement. A guard IS a middleware — the word only names a convention\nand a directory. If you are porting NestJS habits, this is the first thing to\nunlearn.\n\n| | Guard | Middleware | Context contributor |\n|---|---|---|---|\n| What it is | a middleware, by convention | a middleware | a declarative value producer |\n| Signature | `(ctx, next)` | `(ctx, next)` on `@Middleware()`, `(req, res, next)` in `bootstrap({ middlewares })` | `resolve(ctx, deps)` returning a value |\n| Can end the request | yes | yes | **no** |\n| Runs | in the middleware stage | in the middleware stage | after ALL middleware |\n| Attached by | `@Middleware(fn)` | `@Middleware(fn)` or `bootstrap({ middlewares })` | `@LoadX`, or a registration at module / adapter / bootstrap level |\n| Generator | `kick g guard <name>` | `kick g middleware <name>` | `kick g contributor <name>` |\n| Lands in | `src/guards/<name>.guard.ts` | `src/middleware/<name>.middleware.ts` | `src/contributors/<name>.contributor.ts` |\n\nAdd `-m <module>` to any of those generators to place the file inside a module\ninstead of the app-level directory.\n\n**Choosing**: does the thing ever need to STOP the request?\n- Yes, and it is authorization → guard.\n- Yes, anything else (rate limit, body rewrite, response stream, work before route matching) → middleware.\n- No, it only computes a value the handler or a service reads off `ctx` → contributor. Typed, ordered, boot-validated, and it cannot silently swallow the request.\n\n**Ordering — guards run BEFORE contributors.** Every runtime does the same\nthing: run `entry.middlewares` in order, bail if the response was written,\nthen run the contributor pipeline, then the handler. So:\n\n```ts\n// WRONG — tenant is always undefined here.\nexport async function tenantGuard(ctx: RequestContext, next: () => void) {\n const tenant = ctx.get('tenant') // contributors have not run yet\n if (!tenant) return ctx.problem.forbidden()\n next()\n}\n```\n\nA guard that needs a resolved value must resolve it itself, or the check\nbelongs in the contributor's own `resolve` (throw from there and the request\nerror handler takes over).\n\n**Write responses with `ctx.*`, never `ctx.res`.** `ctx.res` is the ENGINE-NATIVE\nresponse object, so `ctx.res.status(401).json(...)` only works on Express —\n`FastifyReply` has no `.json()` and h3's event has no `.status()`. Use\n`ctx.problem.unauthorized({ detail })` (RFC 9457) or `ctx.json(body, status)`,\nand the helper for anything else you would reach `ctx.res` for:\n`ctx.download(buffer, filename, contentType?)` for a generated file,\n`ctx.html(content)`, `ctx.redirect(url, status?)`, `ctx.sse()` for a stream.\nAll of them work on all four runtimes. Never hand `ctx.redirect` a destination\ntaken from the request unchecked — allow-list it, or accept only a same-site path\n(one leading `/`, not `//`) — or the route is an open redirect.\n\n**Guard shape**:\n\n```ts\nimport type { RequestContext } from '@forinda/kickjs'\n\nexport async function adminGuard(ctx: RequestContext, next: () => void): Promise<void> {\n // Requires the session() middleware. Type the key once:\n // declare module '@forinda/kickjs' { interface SessionData { role?: 'admin' | 'member' } }\n const role = ctx.session?.data.role\n if (!role) {\n ctx.problem.unauthorized({ detail: 'Not signed in' })\n return // do NOT call next()\n }\n if (role !== 'admin') {\n ctx.problem.forbidden({ detail: 'Admin only' })\n return\n }\n next()\n}\n```\n\n```ts\n@Middleware(adminGuard)\n@Get('/admin/stats')\nstats(ctx: RequestContext) { ... }\n```\n\nRole checks that are purely declarative (`@Public()`, `@Roles('admin')`,\n`@Can(...)`) are yours to define — see the BYO Auth recipe in the docs.\nHand-write a guard only for logic those don't express.\n\n**Red flags**:\n- `class AdminGuard implements CanActivate` / `@UseGuards()` — NestJS, not KickJS. Export a `(ctx, next)` function and attach with `@Middleware()`.\n- A guard calling `next()` AND writing a response — pick one; writing then continuing double-sends.\n- A guard reading `ctx.get(...)` for a contributor-produced key — contributors have not run yet.\n- `ctx.res.status(403).json(...)` in a guard — Express-only. Use `ctx.problem.forbidden()`.\n- A \"guard\" that only sets a value and always calls `next()` — that is a contributor wearing a guard's name.\n- Passing a `(ctx, next)` guard to `bootstrap({ middlewares })` — global middleware is connect-style `(req, res, next)`. Mount guards per-route with `@Middleware()`.\n"},{slug:`query-parsing-list-endpoint`,frontmatterName:`kickjs-query-parsing-list-endpoint`,description:`Use when adding a paginated/filterable list route — emit ctx.qs + ctx.paginate with an allow-list.`,body:"**Canonical list endpoint**:\n\n```ts\n@Get('/')\nasync list(ctx: Ctx<KickRoutes.TodoController['list']>) {\n const parsed = ctx.qs({\n filterable: ['status', 'priority', 'assigneeId'], // allow-list, MUST be set\n sortable: ['createdAt', 'updatedAt', 'priority'],\n searchColumns: ['title', 'description'], // free-text search targets\n })\n\n return ctx.paginate(async () => {\n const { data, total } = await this.service.list(parsed)\n return { data, total }\n }, parsed)\n}\n```\n\n**Operator format** (fixed): `?filter=field:op:value` where `op ∈ eq | neq | gt | gte | lt | lte | between | in | contains | starts | ends`. Sort is `?sort=field:asc|desc`. Only the first two colons are delimiters, so timestamps work (`createdAt:gt:2026-01-01T00:00:00Z`).\n\n**Drizzle adopters** — pass a `DrizzleQueryParamsConfig` with column refs:\n\n```ts\nconst TASK_QUERY_CONFIG = {\n filterable: { status: tasks.status, priority: tasks.priority },\n sortable: { createdAt: tasks.createdAt },\n searchColumns: [tasks.title, tasks.description],\n}\nconst parsed = ctx.qs(TASK_QUERY_CONFIG)\n```\n\n**ORM-agnostic builders** — implement `QueryBuilderAdapter<TResult, TConfig>` with `build(parsed, config)`. The Drizzle + Prisma adapters live here.\n\n**Red flags**:\n- Reading `req.query.status` directly — bypasses the allow-list; opens unbounded filtering. Use `ctx.qs({ filterable })`.\n- Omitting `filterable` / `sortable` allow-list — every client-supplied filter is **silently dropped** (security default, but looks like a bug).\n- Hand-building the pagination meta in the controller — inconsistent response shape across endpoints. Always use `ctx.paginate()`.\n- Returning a bare array from a list endpoint when pagination is implied — breaks the `PaginatedResponse<T>` contract.\n- Mixing string `searchable` config with column `searchColumns` (Drizzle) — silently no-ops.\n\n**Nuances**:\n- `limit` is capped at 100 server-side; `q` (search) is truncated to 200 chars. Don't re-validate client-side.\n- Sort direction defaults to `asc` when omitted (`?sort=createdAt` ≡ `?sort=createdAt:asc`)."},{slug:`use-asset-manager`,frontmatterName:`kickjs-use-asset-manager`,description:`Use when code reads template files / JSON fixtures via fs.readFile + path arithmetic — switch to assets.<ns>.<key>() and the kick.config.ts assetMap.`,body:"**Configure** `kick.config.ts`:\n\n```ts\nexport default defineConfig({\n assetMap: {\n mails: { src: 'src/templates/mails' },\n reports: { src: 'src/templates/reports', glob: '**/*.{ejs,html}' },\n },\n})\n```\n\n**Consume** via the typed Proxy — no `__dirname` arithmetic, dev/prod paths handled:\n\n```ts\nimport { assets } from '@forinda/kickjs'\n\nconst html = await assets.mails.welcome() // typed: tsc errors on bad key\n```\n\n**Class-field decorator** (lazy getter, swappable in tests):\n\n```ts\nclass WelcomeMailService {\n @Asset('mails/welcome') private welcomeTemplate!: () => Promise<string>\n\n async send(to: string) {\n const body = await this.welcomeTemplate()\n }\n}\n```\n\n**Dynamic dispatch** (CMS templates, codegen) — `resolveAsset(ns, key)` throws `UnknownAssetError` with `{ namespace, key }` fields when the key is missing.\n\n**Test fixtures** — swap via env override + cache clear:\n\n```ts\nbeforeEach(() => {\n process.env.KICK_ASSETS_ROOT = path.resolve('__fixtures__/assets')\n clearAssetCache()\n})\nafterEach(() => {\n delete process.env.KICK_ASSETS_ROOT\n clearAssetCache()\n})\n```\n\n**Red flags**:\n- Hand-rolled `process.env.NODE_ENV === 'production' ? join(__dirname, '../templates') : join(__dirname, 'templates')` — exactly what the asset manager replaces.\n- `keys: 'strip'` setting in `assetMap.<ns>` when basenames may collide — silent last-walk-wins data loss. Default `'auto'` keeps extensions only for colliding groups.\n- Non-default Vite `outDir` without mirroring in `kick.config.ts` — manifest writes at `dist/.kickjs-assets.json` but the resolver can't find it. Mirror via `build.outDir`.\n- Forgetting to re-run `kick typegen` after adding files — `assets.mails.newTemplate` is a tsc error even though the file ships. `kick dev` does this on-change; one-shot CI builds need `kick build` (or `kick build:assets` for manifest-only).\n- Same-name `welcome.ejs` + `welcome/login.ejs` — directory wins in the typed surface; the `.ejs` file still copies but isn't addressable.\n\n**Nuances**:\n- Resolution pipeline (cached): `KICK_ASSETS_ROOT` env override > built manifest at `build.outDir` / `dist` / `build` / `out` > dev-fallback in-memory walk. Manifest presence = \"running from built dist.\"\n- Dev-mode glob matcher is a lite implementation — `**/*`, `**/*.ext`, `**/*.{a,b}` are guaranteed; exotic globs warn-once and accept everything. Run `kick build:assets` to exercise the real glob engine."},{slug:`cli-commands-cheatsheet`,frontmatterName:`kickjs-cli-commands-cheatsheet`,description:`Use as a quick reference for the most common kick CLI workflows — scaffolding, dev/build/start, generation, inspection.`,body:`**Top commands**:
|
|
635
639
|
- \`kick new <name>\` — start a new project (prompts for template / repo / pm).
|
|
636
640
|
- \`kick dev\` — local dev server with Vite HMR.
|
|
637
641
|
- \`kick build\` — production bundle via Vite.
|
|
@@ -671,27 +675,45 @@ kick g config --force --repo postgres # Drop a kick.config.ts i
|
|
|
671
675
|
- Writing \`"name:type?"\` for optional scaffold fields — \`?\` is a shell glob in bash/zsh; use \`name:type:optional\`.
|
|
672
676
|
- Running \`kick new <name> --yes\` in a non-empty directory expecting it to wipe — \`--yes\` aborts without \`--force\`; pair them when destruction is intended.
|
|
673
677
|
- Skipping \`kick g config\` on a legacy project then wondering why generators ignore \`modules.dir\` / \`modules.repo\`.
|
|
674
|
-
- Editing \`kick.config.ts\` with deprecated top-level \`modulesDir\` / \`defaultRepo\` / \`schemaDir\` / \`pluralize\` instead of the nested \`modules\` block.`},{slug:`route-flags`,frontmatterName:`kickjs-route-flags`,description:`Use when a fact about a route (public, CSRF-exempt, unmetered, expose as a tool) has to be read by auth, CSRF, rate limiting, OpenAPI or MCP — instead of repeating it as paths in each consumer.`,body:"A route flag is a named, inheritable fact about a route. It carries no\nbehaviour: it records something every consumer can read, so \"this endpoint is\nopen\" is declared once instead of restated as an auth contributor, a\n`csrf ignorePaths` entry and a `rateLimit skipPaths` entry — two of which are\npathname strings that cannot express `/users/:id`.\n\n**Steps**:\n1. Declare it once, in its own module:\n ```ts\n import { defineRouteFlag } from '@forinda/kickjs'\n\n export const Public = defineRouteFlag('auth.public')\n export const Limit = defineRouteFlag<{ rpm: number }>('rate.limit')\n ```\n2. Apply it. A class flag is inherited by every route under it; `.off` on a\n method removes it:\n ```ts\n @Public // every route below inherits it\n @Controller()\n export class WebhooksController {\n @Get('/health')\n health(ctx: RequestContext) {} // public\n\n @Public.off // the method wins\n @Post('/admin')\n admin(ctx: RequestContext) {} // not public\n\n @Limit({ rpm: 10 }) // flags can carry a value\n @Post('/login')\n login(ctx: RequestContext) {}\n }\n ```\n3. Read it from the consumer that already exists, rather than a new one.\n **Where a consumer is mounted decides how it reads flags** — everything\n after route matching gets `ctx.route`; anything before it does not:\n\n | Consumer | Mounted at | Reads flags by |\n | --- | --- | --- |\n | Handler | the route itself | `ctx.route?.flags.has('auth.public')` |\n | Guard / `@Middleware()` | `@Middleware(fn)` on a method or controller class | `ctx.route`, or the built-ins' `exemptWhen` |\n | `csrfGuard()` / `rateLimitGuard()` | same — `@Middleware()`, class or method | `exemptWhen: 'csrf.exempt'` |\n | Contributor | method > class > module `contributors()` > adapter `contributors()` > `bootstrap({ contributors })` | `skipWhen` / `onlyWhen` on the registration |\n | Global middleware (`rateLimit()`) | `bootstrap({ middlewares: [...] })` — runs **before** matching | the route policy table (`bindRoutePolicy`); `ctx.route` is `undefined` here |\n | Adapter middleware | `AppAdapter.middleware()`, phase `beforeGlobal` … `afterRoutes` | the policy table — the handler is Express-style `(req, res, next)`, so there is no `ctx` in any phase |\n | OpenAPI | `SwaggerAdapter({ publicFlag: 'auth.public' })` | read once at startup, not per request |\n | MCP / AI tools | `McpAdapter({ exposeWhen: 'mcp.tool', hideWhen: 'mcp.hidden' })` | read once at startup |\n\n The contributor row is the one that buys the most: those five levels mean a\n plugin's contributor can be skipped on one route (`skipWhen`) without\n forking it, and a method-level registration beats the adapter's.\n\n4. Run `kick typegen`. It writes the `KickRouteFlags` augmentation, so flag\n names autocomplete and a typo fails `tsc` instead of silently never matching.\n\n**Red flags**:\n- Writing `@Public(false)` to remove a flag — removal is `@Public.off`. A\n resolved flag is present or absent; `false` is a legitimate *value* for a\n flag declared `boolean`, and a presence check would still answer `true`.\n- A new consumer growing its own `skipPaths` / `ignorePaths` list — that is\n the duplication flags exist to remove, and path strings break on params and\n on any `apiPrefix` change.\n- `@Public.off` on a route that never inherited the flag — a no-op, and\n usually a sign the expected class-level flag is not there.\n- Reading `ctx.route` in global middleware — it is `undefined` before a route\n is matched (steps 1-6 of the pipeline). Move the check into a guard mounted\n with `@Middleware()`, or read the policy table.\n- Inventing a flag name per consumer (`auth.open`, `public`, `isPublic`). The\n framework names none of them; pick one name per fact and reuse it."},{slug:`cli-plugin`,frontmatterName:`kickjs-cli-plugin`,description:`Use when adding a kick <name> command, a kick g <name> generator, or a typegen emitter to this project or a package it ships.`,body:"A CLI plugin contributes commands, generators and typegen emitters to the\n`kick` CLI. Every built-in command ships as one, so the contract is the same\none the framework uses.\n\n**Steps**:\n1. Write the plugin. All four contribution kinds are optional:\n ```ts\n // kick-tool.ts\n import { defineCliPlugin } from '@forinda/kickjs-cli'\n\n export const toolPlugin = (options: { watch?: boolean } = {}) =>\n defineCliPlugin({\n // Stable id, unique across plugins — it names the plugin in conflict errors.\n name: 'my-tool',\n // Declarative: same shape as kick.config.ts `commands`.\n commands: [{ name: 'db:migrate', description: 'Apply migrations', steps: 'node ./scripts/migrate.js' }],\n // Programmatic: full commander API, when you need options or subcommands.\n register(program, ctx) {\n program\n .command('my-tool')\n .description('Do the thing')\n .option('--watch', 'Watch mode')\n .action(async () => {\n ctx.log(`running in ${ctx.projectRoot}`)\n // ctx.config is the loaded kick.config.ts\n })\n },\n })\n ```\n2. Register it in `kick.config.ts`:\n ```ts\n import { toolPlugin } from './kick-tool'\n\n export default defineConfig({ plugins: [toolPlugin()] })\n ```\n3. Check it is loaded, per contribution kind: `kick --help` lists a command,\n `kick g --list` lists a generator, `kick typegen` runs an emitter. A\n plugin that fails to load takes the whole CLI down with a conflict error,\n so silence here means the config never referenced it.\n\n**Which kind to use**: `commands` for a shell one-liner, `register` for\noptions / subcommands / async work, `generators` (`defineGenerator`) for\n`kick g <name>` scaffolders, `typegens` for a file under `.kickjs/types/`.\n\n**Red flags**:\n- Confusing this with a runtime plugin: `defineCliPlugin` (from\n `@forinda/kickjs-cli`) extends the CLI; `definePlugin` (from\n `@forinda/kickjs`) hooks the running app. They share nothing.\n- Claiming a command name a built-in owns (`build:netlify`, `typegen`,\n `generate`) — duplicate declarative names are a startup conflict, and\n `register` has no conflict detection at all, so it silently loses. Namespace\n your own (`my-tool:build`).\n- Doing real work inside `register` — it runs at CLI startup for every\n command, so it must only register. Put the work in the action.\n- Importing heavy dependencies at module scope. `await import()` them inside\n the action, so an unrelated `kick dev` does not pay for them."},{slug:`deploy`,frontmatterName:`kickjs-deploy`,description:`Use when deploying this app — Netlify, Vercel or Cloudflare Workers — or when an API route returns the SPA shell, the platform build fails, or the function is missing from a deploy.`,body:`**
|
|
678
|
+
- Editing \`kick.config.ts\` with deprecated top-level \`modulesDir\` / \`defaultRepo\` / \`schemaDir\` / \`pluralize\` instead of the nested \`modules\` block.`},{slug:`route-flags`,frontmatterName:`kickjs-route-flags`,description:`Use when a fact about a route (public, CSRF-exempt, unmetered, expose as a tool) has to be read by auth, CSRF, rate limiting, OpenAPI or MCP — instead of repeating it as paths in each consumer.`,body:"A route flag is a named, inheritable fact about a route. It carries no\nbehaviour: it records something every consumer can read, so \"this endpoint is\nopen\" is declared once instead of restated as an auth contributor, a\n`csrf ignorePaths` entry and a `rateLimit skipPaths` entry — two of which are\npathname strings that cannot express `/users/:id`.\n\n**Steps**:\n1. Declare it once, in its own module:\n ```ts\n import { defineRouteFlag } from '@forinda/kickjs'\n\n export const Public = defineRouteFlag('auth.public')\n export const Limit = defineRouteFlag<{ rpm: number }>('rate.limit')\n ```\n2. Apply it. A class flag is inherited by every route under it; `.off` on a\n method removes it:\n ```ts\n @Public // every route below inherits it\n @Controller()\n export class WebhooksController {\n @Get('/health')\n health(ctx: RequestContext) {} // public\n\n @Public.off // the method wins\n @Post('/admin')\n admin(ctx: RequestContext) {} // not public\n\n @Limit({ rpm: 10 }) // flags can carry a value\n @Post('/login')\n login(ctx: RequestContext) {}\n }\n ```\n3. Read it from the consumer that already exists, rather than a new one.\n **Where a consumer is mounted decides how it reads flags** — everything\n after route matching gets `ctx.route`; anything before it does not:\n\n | Consumer | Mounted at | Reads flags by |\n | --- | --- | --- |\n | Handler | the route itself | `ctx.route?.flags.has('auth.public')` |\n | Guard / `@Middleware()` | `@Middleware(fn)` on a method or controller class | `ctx.route`, or the built-ins' `exemptWhen` |\n | `csrfGuard()` / `rateLimitGuard()` | same — `@Middleware()`, class or method | `exemptWhen: 'csrf.exempt'` |\n | Contributor | method > class > module `contributors()` > adapter `contributors()` > `bootstrap({ contributors })` | `skipWhen` / `onlyWhen` on the registration |\n | Global middleware (`rateLimit()`) | `bootstrap({ middlewares: [...] })` — runs **before** matching | the route policy table (`bindRoutePolicy`); `ctx.route` is `undefined` here |\n | Adapter middleware | `AppAdapter.middleware()`, phase `beforeGlobal` … `afterRoutes` | the policy table — the handler is Express-style `(req, res, next)`, so there is no `ctx` in any phase |\n | OpenAPI | `SwaggerAdapter({ publicFlag: 'auth.public' })` | read once at startup, not per request |\n | MCP / AI tools | `McpAdapter({ exposeWhen: 'mcp.tool', hideWhen: 'mcp.hidden' })` | read once at startup |\n\n The contributor row is the one that buys the most: those five levels mean a\n plugin's contributor can be skipped on one route (`skipWhen`) without\n forking it, and a method-level registration beats the adapter's.\n\n4. Run `kick typegen`. It writes the `KickRouteFlags` augmentation, so flag\n names autocomplete and a typo fails `tsc` instead of silently never matching.\n\n**Red flags**:\n- Writing `@Public(false)` to remove a flag — removal is `@Public.off`. A\n resolved flag is present or absent; `false` is a legitimate *value* for a\n flag declared `boolean`, and a presence check would still answer `true`.\n- A new consumer growing its own `skipPaths` / `ignorePaths` list — that is\n the duplication flags exist to remove, and path strings break on params and\n on any `apiPrefix` change.\n- `@Public.off` on a route that never inherited the flag — a no-op, and\n usually a sign the expected class-level flag is not there.\n- Reading `ctx.route` in global middleware — it is `undefined` before a route\n is matched (steps 1-6 of the pipeline). Move the check into a guard mounted\n with `@Middleware()`, or read the policy table.\n- Inventing a flag name per consumer (`auth.open`, `public`, `isPublic`). The\n framework names none of them; pick one name per fact and reuse it."},{slug:`cli-plugin`,frontmatterName:`kickjs-cli-plugin`,description:`Use when adding a kick <name> command, a kick g <name> generator, or a typegen emitter to this project or a package it ships.`,body:"A CLI plugin contributes commands, generators and typegen emitters to the\n`kick` CLI. Every built-in command ships as one, so the contract is the same\none the framework uses.\n\n**Steps**:\n1. Write the plugin. All four contribution kinds are optional:\n ```ts\n // kick-tool.ts\n import { defineCliPlugin } from '@forinda/kickjs-cli'\n\n export const toolPlugin = (options: { watch?: boolean } = {}) =>\n defineCliPlugin({\n // Stable id, unique across plugins — it names the plugin in conflict errors.\n name: 'my-tool',\n // Declarative: same shape as kick.config.ts `commands`.\n commands: [{ name: 'db:migrate', description: 'Apply migrations', steps: 'node ./scripts/migrate.js' }],\n // Programmatic: full commander API, when you need options or subcommands.\n register(program, ctx) {\n program\n .command('my-tool')\n .description('Do the thing')\n .option('--watch', 'Watch mode')\n .action(async () => {\n ctx.log(`running in ${ctx.projectRoot}`)\n // ctx.config is the loaded kick.config.ts\n })\n },\n })\n ```\n2. Register it in `kick.config.ts`:\n ```ts\n import { toolPlugin } from './kick-tool'\n\n export default defineConfig({ plugins: [toolPlugin()] })\n ```\n3. Check it is loaded, per contribution kind: `kick --help` lists a command,\n `kick g --list` lists a generator, `kick typegen` runs an emitter. A\n plugin that fails to load takes the whole CLI down with a conflict error,\n so silence here means the config never referenced it.\n\n**Which kind to use**: `commands` for a shell one-liner, `register` for\noptions / subcommands / async work, `generators` (`defineGenerator`) for\n`kick g <name>` scaffolders, `typegens` for a file under `.kickjs/types/`.\n\n**Red flags**:\n- Confusing this with a runtime plugin: `defineCliPlugin` (from\n `@forinda/kickjs-cli`) extends the CLI; `definePlugin` (from\n `@forinda/kickjs`) hooks the running app. They share nothing.\n- Claiming a command name a built-in owns (`build:netlify`, `typegen`,\n `generate`) — duplicate declarative names are a startup conflict, and\n `register` has no conflict detection at all, so it silently loses. Namespace\n your own (`my-tool:build`).\n- Doing real work inside `register` — it runs at CLI startup for every\n command, so it must only register. Put the work in the action.\n- Importing heavy dependencies at module scope. `await import()` them inside\n the action, so an unrelated `kick dev` does not pay for them."},{slug:`deploy`,frontmatterName:`kickjs-deploy`,description:`Use when deploying this app — a Node host or Docker, Netlify, Vercel or Cloudflare Workers — or when an API route returns the SPA shell, the platform build fails, or the function is missing from a deploy.`,body:`**A Node host or a container** runs the long-running server. The project
|
|
679
|
+
ships a \`Dockerfile\`; without Docker:
|
|
680
|
+
|
|
681
|
+
\`\`\`bash
|
|
682
|
+
${e} run build && NODE_ENV=production node dist/index.js
|
|
683
|
+
\`\`\`
|
|
684
|
+
|
|
685
|
+
Start it with \`node\`, not \`kick start\`: the CLI is a dev dependency, gone
|
|
686
|
+
after a production install. The image installs from the lockfile, so commit it.
|
|
687
|
+
|
|
688
|
+
**Netlify and Vercel** run the app as a Node function; the CLI builds both:
|
|
675
689
|
|
|
676
690
|
\`\`\`bash
|
|
677
691
|
${e} run build:netlify # → .netlify/v1/functions/api.mjs
|
|
678
692
|
${e} run build:vercel # → .vercel/output (Build Output API v3)
|
|
679
693
|
\`\`\`
|
|
680
694
|
|
|
681
|
-
Both bundle \`src/serverless.ts
|
|
682
|
-
|
|
683
|
-
|
|
695
|
+
Both bundle \`src/serverless.ts\`:
|
|
696
|
+
\`createHandler(() => import('./index'))\` serves the app \`src/index.ts\`
|
|
697
|
+
describes — its \`bootstrap()\` call builds the app without listening — so
|
|
698
|
+
there is one set of options. Add middleware and adapters to \`src/index.ts\`
|
|
699
|
+
as usual.
|
|
684
700
|
|
|
685
701
|
Settings are detected from the layout: a workspace member beside a \`web\`
|
|
686
|
-
package publishes \`web/dist\` and routes \`/api/*\`;
|
|
687
|
-
|
|
702
|
+
package publishes \`web/dist\` and routes \`/api/*\`; an Inertia app publishes
|
|
703
|
+
\`dist/client\`, packages the asset manifest and SSR bundle with the function,
|
|
704
|
+
and lets it take every other path; a standalone API takes every path.
|
|
705
|
+
Override in \`kick.config.ts\`:
|
|
688
706
|
|
|
689
707
|
\`\`\`ts
|
|
690
708
|
export default defineConfig({
|
|
691
|
-
deploy: { apiPath: '/api', staticDir: '../web/dist' },
|
|
709
|
+
deploy: { apiPath: '/api', staticDir: '../web/dist', include: ['data/geo.json'] },
|
|
692
710
|
})
|
|
693
711
|
\`\`\`
|
|
694
712
|
|
|
713
|
+
\`include\` lists files the app reads at runtime; they are packaged with the
|
|
714
|
+
function. Memory does not survive between function instances: keep sessions in
|
|
715
|
+
\`session({ store: 'cookie' })\` or a shared store, and data in a database.
|
|
716
|
+
|
|
695
717
|
**Cloudflare Workers** is a different entry, not a third flag on this one.
|
|
696
718
|
Workers have no \`node:http\`, so \`createHandler\` cannot run there; use the
|
|
697
719
|
web entry over h3 v2:
|
|
@@ -730,14 +752,17 @@ plugins. Serve static files from Workers Assets or a separate deploy.
|
|
|
730
752
|
**Red flags**:
|
|
731
753
|
- An \`/api/*\` route returning \`index.html\` on Netlify — the function is not
|
|
732
754
|
routed. Check the deploy summary says a function was deployed, keep
|
|
733
|
-
\`preferStatic\` out of
|
|
734
|
-
unset so it reads the function at the repo root.
|
|
755
|
+
\`preferStatic\` out of a fullstack function's config, and leave Netlify's
|
|
756
|
+
**Package directory** unset so it reads the function at the repo root.
|
|
757
|
+
- Validation errors or flash messages that vanish on Netlify / Vercel — the
|
|
758
|
+
session lives in one instance's memory. Use \`session({ store: 'cookie' })\`.
|
|
735
759
|
- Committing \`.netlify/\` or \`.vercel/\` — both are build output.
|
|
736
760
|
- A frontend that builds somewhere other than \`web/dist\` and only one of
|
|
737
761
|
\`deploy.staticDir\`, \`SpaAdapter({ clientDir })\` and netlify.toml's
|
|
738
762
|
\`publish\` updated. All three name that directory.
|
|
739
763
|
- Pointing a platform at \`src/index.ts\` — it calls \`bootstrap()\`, which
|
|
740
|
-
listens on a port and registers signal handlers inside the function.
|
|
764
|
+
listens on a port and registers signal handlers inside the function. The
|
|
765
|
+
platform entry is \`src/serverless.ts\`.
|
|
741
766
|
- Expecting \`kick build\` alone to produce a deployable function; it builds
|
|
742
767
|
the server. Run the target command after it.`},{slug:`docs-lookup`,frontmatterName:`kickjs-docs-lookup`,description:`Use FIRST when unsure about any KickJS API, option, or behaviour — before guessing from memory or inferring from surrounding code.`,body:`These skills are deliberately short — they cover the traps, not the whole API
|
|
743
768
|
surface. When the answer is not in one of them, read the docs rather than
|
|
@@ -847,4 +872,4 @@ Codex / Cursor / Gemini / Claude Code without copy-pasting.
|
|
|
847
872
|
\`kick g agents --only copilot -f\` regenerates this file from the
|
|
848
873
|
CLI template. Hand-edited content is overwritten — keep customisation
|
|
849
874
|
in \`.agents/COPILOT.local.md\`.
|
|
850
|
-
`}var E
|
|
875
|
+
`}var E=/* @__PURE__ */ e({generateAgentDocs:()=>N});const D=`.agents`,O=/* @__PURE__ */ new Set([`rest`,`minimal`,`fullstack`,`inertia`]);function k(e,t){if(t)return t;try{let t=JSON.parse(i(a(e,`package.json`),`utf-8`));if(t.name)return t.name.replace(/^@[^/]+\//,``)}catch{}return e.split(`/`).findLast(Boolean)??`app`}function A(e,t){if(t)return t;try{let t=JSON.parse(i(a(e,`package.json`),`utf-8`));if(t.packageManager)return t.packageManager.split(`@`)[0]}catch{}return`pnpm`}async function j(e){try{return(await t(e))?.modules?.style===`class`?`class`:`define`}catch{return`define`}}async function M(e,n){if(n)return n;try{let n=(await t(e))?.pattern;if(n&&O.has(n))return n}catch{}return`rest`}async function N(e){let t=e.only??`all`,i=k(e.outDir,e.name),o=A(e.outDir,e.pm),s=await M(e.outDir,e.template),c=await j(e.outDir),l=t===`agents`||t===`both`||t===`all`,u=t===`claude`||t===`both`||t===`all`,d=t===`skills`||t===`all`,f=t===`gemini`||t===`all`,p=t===`copilot`||t===`all`,m=[];if(l&&m.push({file:a(e.outDir,D,`AGENTS.md`),render:()=>x(i,s,o)}),u&&m.push({file:a(e.outDir,`CLAUDE.md`),render:()=>b(i,s,o)}),d)for(let t of C(i,s,o,c))m.push({file:a(e.outDir,D,`skills`,t.slug,`SKILL.md`),render:()=>t.content});f&&m.push({file:a(e.outDir,D,`GEMINI.md`),render:()=>w(i,s,o)}),p&&m.push({file:a(e.outDir,D,`COPILOT.md`),render:()=>T(i,s,o)});let h=[];for(let{file:t,render:i}of m)r(t)&&!e.force&&!await _({message:`${t.replace(e.outDir+`/`,``)} already exists. Overwrite?`,initialValue:!1})?console.log(` Skipped — existing ${t.replace(e.outDir+`/`,``)} preserved.`):(await n(t,i()),h.push(t));return h}export{y as a,h as c,l as d,s as f,d as i,v as l,N as n,g as o,u as p,_ as r,f as s,E as t,m as u};
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @forinda/kickjs-cli v8.
|
|
2
|
+
* @forinda/kickjs-cli v8.9.0
|
|
3
3
|
*
|
|
4
4
|
* Copyright (c) Felix Orinda
|
|
5
5
|
*
|
|
@@ -8,6 +8,6 @@
|
|
|
8
8
|
*
|
|
9
9
|
* @license MIT
|
|
10
10
|
*/
|
|
11
|
-
import{t as e}from"./rolldown-runtime-
|
|
11
|
+
import{t as e}from"./rolldown-runtime-Da-NDvKz.mjs";import{dirname as t,isAbsolute as n,join as r,relative as i,resolve as a}from"node:path";import{cpSync as o,existsSync as s,mkdirSync as c,statSync as l,writeFileSync as u}from"node:fs";import{glob as d}from"glob";import{groupAssetKeys as f}from"@forinda/kickjs";var p=/* @__PURE__ */ e({ASSET_MANIFEST_VERSION:()=>1,buildAssets:()=>m});async function m(e,t){let{cwd:n,silent:o=!1}=t,s=t.distDir??e?.build?.outDir??`dist`,l=e?.assetMap;if(!l||Object.keys(l).length===0)return null;let d=o?()=>{}:console.log,f=a(n,s);c(f,{recursive:!0});let p=[],m={};for(let[e,t]of Object.entries(l)){let r=await h(e,t,n,f);p.push(r.entrySummary),Object.assign(m,r.manifestSlice),d(` ✓ ${e}: ${r.entrySummary.filesCopied} file(s) → ${r.entrySummary.dest}`)}let g={version:1,entries:m},_=r(f,`.kickjs-assets.json`);return u(_,JSON.stringify(g,null,2)+`
|
|
12
12
|
`,`utf-8`),d(` ✓ wrote manifest → ${i(n,_)} (${Object.keys(m).length} entries)`),{manifestPath:_,entries:p,manifest:g}}async function h(e,n,l,u){let p=a(l,n.src),m=n.dest?a(l,n.dest):r(u,e);if(v(m,l))return console.warn(` ⚠ assetMap.${e}.dest ('${n.dest}') resolves outside the project root — skipping copy`),{entrySummary:{namespace:e,src:n.src,dest:i(l,m),filesCopied:0},manifestSlice:{}};if(!s(p)||!y(p))return{entrySummary:{namespace:e,src:n.src,dest:i(l,m),filesCopied:0},manifestSlice:{}};let h=n.glob??`**/*`,b=await d(h,{cwd:p,nodir:!0,dot:!1,posix:!0});c(m,{recursive:!0});let x={},S=[...b].toSorted(),{pairs:C,collisionGroupsResolved:w}=f(e,S,{strategy:n.keys??`auto`}),T=0;for(let{rel:e,key:n}of C){let i=r(p,e),a=r(m,e);x[n]=_(u,a),!g(i,a)&&(c(t(a),{recursive:!0}),o(i,a),T++)}return w>0&&console.log(` ℹ assetMap.${e}: auto-resolved ${w} basename collision(s) by keeping extensions (set 'keys: "strip"' to opt back into legacy last-write-wins behaviour, or 'keys: "with-extension"' to keep all keys verbose).`),{entrySummary:{namespace:e,src:n.src,dest:i(l,m),filesCopied:T},manifestSlice:x}}function g(e,t){if(!s(t))return!1;try{let n=l(e),r=l(t);return r.size===n.size&&r.mtimeMs>=n.mtimeMs}catch{return!1}}function _(e,t){return i(e,t).split(/[\\/]/).filter(Boolean).join(`/`)}function v(e,t){let r=i(t,e);return r===``?!1:r.startsWith(`..`)||n(r)}function y(e){try{return l(e).isDirectory()}catch{return!1}}export{p as n,m as t};
|
|
13
|
-
//# sourceMappingURL=build-
|
|
13
|
+
//# sourceMappingURL=build-C30mBgw6.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"build-C30mBgw6.mjs","names":["__exportAll","build_exports","buildAssets","opts","distDir","config","map","log","silent","distAbs","resolve","cwd","mkdirSync","summary","manifestEntries","namespace","entry","result","processEntry","manifest","manifestPath","join","writeFileSync","relative","srcAbs","destAbs","escapesRoot","existsSync","isDirectorySync","pattern","matches","glob","manifestSlice","sorted","groupAssetKeys","filesCopied","relPath","pairs","srcFile","destFile","key","toManifestRelative","dirname","cpSync","collisionGroupsResolved","isUpToDate","s","statSync","d","manifestDir","rel","root","path","isAbsolute"],"sources":["../src/asset-manager/build.ts"],"sourcesContent":["/**\n * Build pipeline for `assetMap` entries (asset-manager PR 2).\n *\n * For each entry, walks `src/<...>` matching the configured `glob`,\n * copies matches into `dest` (default `dist/<name>/`), then emits a\n * `dist/.kickjs-assets.json` manifest mapping logical\n * `<namespace>/<key>` keys to repo-relative paths inside `dist/`.\n *\n * Pure function on top of `node:fs` + `glob` — no shell, no side\n * effects beyond the configured directory writes. The build entry-\n * point in `commands/run.ts` calls `buildAssets` after the existing\n * `copyDirs` step.\n *\n * @module @forinda/kickjs-cli/asset-manager/build\n */\n\nimport { cpSync, existsSync, mkdirSync, statSync, writeFileSync } from 'node:fs'\nimport { dirname, isAbsolute, join, relative, resolve } from 'node:path'\nimport { glob } from 'glob'\nimport { groupAssetKeys } from '@forinda/kickjs'\nimport type { AssetMapEntry, KickConfig } from '../config'\n\n/** Wire-format version for `dist/.kickjs-assets.json`. Bump on shape change. */\nexport const ASSET_MANIFEST_VERSION = 1 as const\n\n/** On-disk manifest format (`dist/.kickjs-assets.json`). */\nexport interface AssetManifest {\n version: typeof ASSET_MANIFEST_VERSION\n /**\n * Logical key → manifest-relative path. Logical key is\n * `<namespace>/<key>` where `<key>` is the file path under `src`\n * with the extension stripped + path separators normalised.\n *\n * Path values are relative to the manifest file's directory so the\n * runtime can resolve them with a single `path.resolve(manifestDir,\n * entry)` regardless of where dist/ lives.\n */\n entries: Record<string, string>\n}\n\nexport interface BuildAssetsOptions {\n /** Project root — resolved for every relative path in the entry. */\n cwd: string\n /**\n * Output dir for the manifest + per-namespace asset copies. When\n * omitted, falls back to `config.build?.outDir` (resolved against\n * `cwd`), then to `dist/` under cwd. Adopters with a custom Vite\n * `build.outDir` should set `kick.config.ts.build.outDir` to match.\n */\n distDir?: string\n /** Suppress per-entry log lines. Default: false. */\n silent?: boolean\n}\n\n/** One entry in the per-build summary returned by `buildAssets`. */\nexport interface BuildAssetsEntryResult {\n namespace: string\n src: string\n dest: string\n /**\n * Number of files actually written this run. On an incremental\n * rebuild where nothing changed this is 0 even though the manifest\n * still lists every matched file.\n */\n filesCopied: number\n}\n\n/** Aggregated outcome of `buildAssets`. */\nexport interface BuildAssetsResult {\n manifestPath: string\n entries: BuildAssetsEntryResult[]\n /** `entries` merged into a single record — useful for tests + tooling. */\n manifest: AssetManifest\n}\n\n/**\n * Run the full asset build for a loaded config:\n *\n * 1. For each `assetMap` entry, glob → copy → manifest stub.\n * 2. Write `dist/.kickjs-assets.json`.\n *\n * Returns a summary including the manifest contents. No-op (and no\n * manifest written) when `assetMap` is empty / missing — the build\n * pipeline shouldn't litter `dist/` with empty manifests for\n * adopters who don't use the feature.\n */\nexport async function buildAssets(\n config: KickConfig | null,\n opts: BuildAssetsOptions,\n): Promise<BuildAssetsResult | null> {\n const { cwd, silent = false } = opts\n // Resolution order: explicit opts.distDir → config.build.outDir → 'dist'.\n // The CLI build command passes nothing explicit, so adopters control\n // the output via kick.config.ts.build.outDir alone.\n const distDir = opts.distDir ?? config?.build?.outDir ?? 'dist'\n const map = config?.assetMap\n if (!map || Object.keys(map).length === 0) return null\n\n const log = silent ? () => {} : console.log\n\n const distAbs = resolve(cwd, distDir)\n mkdirSync(distAbs, { recursive: true })\n\n const summary: BuildAssetsEntryResult[] = []\n const manifestEntries: Record<string, string> = {}\n\n for (const [namespace, entry] of Object.entries(map)) {\n const result = await processEntry(namespace, entry, cwd, distAbs)\n summary.push(result.entrySummary)\n Object.assign(manifestEntries, result.manifestSlice)\n log(\n ` ✓ ${namespace}: ${result.entrySummary.filesCopied} file(s) → ${result.entrySummary.dest}`,\n )\n }\n\n const manifest: AssetManifest = {\n version: ASSET_MANIFEST_VERSION,\n entries: manifestEntries,\n }\n const manifestPath = join(distAbs, '.kickjs-assets.json')\n writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\\n', 'utf-8')\n log(\n ` ✓ wrote manifest → ${relative(cwd, manifestPath)} (${Object.keys(manifestEntries).length} entries)`,\n )\n\n return { manifestPath, entries: summary, manifest }\n}\n\n/** Per-entry inner pipeline — extracted for unit-test reuse. */\nasync function processEntry(\n namespace: string,\n entry: AssetMapEntry,\n cwd: string,\n distAbs: string,\n): Promise<{\n entrySummary: BuildAssetsEntryResult\n manifestSlice: Record<string, string>\n}> {\n const srcAbs = resolve(cwd, entry.src)\n const destAbs = entry.dest ? resolve(cwd, entry.dest) : join(distAbs, namespace)\n\n // Defensive: refuse to write outside the project root (cwd) even\n // though validateAssetMap warned about it at config-load time. The\n // build step shouldn't trust upstream warnings — a typo like\n // `dest: '../../'` would otherwise sprinkle files outside the\n // workspace despite the warning being printed.\n if (escapesRoot(destAbs, cwd)) {\n console.warn(\n ` ⚠ assetMap.${namespace}.dest ('${entry.dest}') resolves outside the project root — skipping copy`,\n )\n return {\n entrySummary: { namespace, src: entry.src, dest: relative(cwd, destAbs), filesCopied: 0 },\n manifestSlice: {},\n }\n }\n\n // Treat src-not-a-directory the same as src-missing — `glob` would\n // throw if pointed at a file, surfacing as a generic build failure\n // instead of a clean 0-files entry. Matches the validator's warning\n // shape (already emitted at config-load time for the missing case).\n if (!existsSync(srcAbs) || !isDirectorySync(srcAbs)) {\n return {\n entrySummary: { namespace, src: entry.src, dest: relative(cwd, destAbs), filesCopied: 0 },\n manifestSlice: {},\n }\n }\n\n const pattern = entry.glob ?? '**/*'\n // `glob` returns paths relative to `cwd` when `cwd` is set —\n // exactly the slugs we want for the manifest keys.\n const matches = await glob(pattern, {\n cwd: srcAbs,\n nodir: true,\n dot: false,\n posix: true,\n })\n\n mkdirSync(destAbs, { recursive: true })\n\n const manifestSlice: Record<string, string> = {}\n // Sort the walk so the manifest is byte-stable across platforms.\n // The `groupAssetKeys` helper preserves input order, so a stable\n // sort here makes the resulting manifest stable too.\n const sorted = [...matches].toSorted()\n const { pairs, collisionGroupsResolved } = groupAssetKeys(namespace, sorted, {\n strategy: entry.keys ?? 'auto',\n })\n\n // Copy the files and write the keyed manifest slice. Both come from\n // the same `pairs` order so the on-disk layout matches the\n // manifest's iteration order — easier to grep in cold-start\n // debugging.\n //\n // Incremental: skip the copy when the destination is already\n // up-to-date (exists, same size, mtime ≥ source). `cpSync` stamps the\n // copy with the current time, so an unchanged source always satisfies\n // `dest.mtime ≥ src.mtime` on the next run — turning a re-build into a\n // pure stat sweep instead of re-copying every asset. The manifest\n // slice is written unconditionally so the manifest stays complete.\n let filesCopied = 0\n for (const { rel: relPath, key } of pairs) {\n const srcFile = join(srcAbs, relPath)\n const destFile = join(destAbs, relPath)\n manifestSlice[key] = toManifestRelative(distAbs, destFile)\n if (isUpToDate(srcFile, destFile)) continue\n mkdirSync(dirname(destFile), { recursive: true })\n cpSync(srcFile, destFile)\n filesCopied++\n }\n\n if (collisionGroupsResolved > 0) {\n console.log(\n ` ℹ assetMap.${namespace}: auto-resolved ${collisionGroupsResolved} basename collision(s) by keeping extensions ` +\n `(set 'keys: \"strip\"' to opt back into legacy last-write-wins behaviour, or 'keys: \"with-extension\"' to keep all keys verbose).`,\n )\n }\n\n return {\n entrySummary: {\n namespace,\n src: entry.src,\n dest: relative(cwd, destAbs),\n filesCopied,\n },\n manifestSlice,\n }\n}\n\n/**\n * Is `destFile` already a current copy of `srcFile`? True when the\n * destination exists with the same byte size and an mtime no older than\n * the source. Used to skip redundant copies on an incremental rebuild.\n */\nfunction isUpToDate(srcFile: string, destFile: string): boolean {\n if (!existsSync(destFile)) return false\n try {\n const s = statSync(srcFile)\n const d = statSync(destFile)\n return d.size === s.size && d.mtimeMs >= s.mtimeMs\n } catch {\n return false\n }\n}\n\n/**\n * Make `destFile` relative to the manifest's directory + force POSIX\n * separators so the manifest is byte-stable across platforms.\n */\nfunction toManifestRelative(manifestDir: string, destFile: string): string {\n const rel = relative(manifestDir, destFile)\n // path.relative returns OS-native separators; the manifest is JSON\n // and the runtime uses path.resolve which handles either, but a\n // forward-slash manifest is grep-friendly + diff-stable.\n return rel.split(/[\\\\/]/).filter(Boolean).join('/')\n}\n\n/**\n * Pure manifest writer — handy for tests that want to assert against\n * a hand-crafted manifest without exercising the full pipeline.\n */\nexport function writeAssetManifest(distDir: string, manifest: AssetManifest): string {\n const path = join(distDir, '.kickjs-assets.json')\n mkdirSync(distDir, { recursive: true })\n writeFileSync(path, JSON.stringify(manifest, null, 2) + '\\n', 'utf-8')\n return path\n}\n\n/**\n * Read + parse a manifest from disk. Returns `null` on missing or\n * malformed file rather than throwing — the runtime resolver wants\n * to fall through to dev-mode lookup in that case.\n */\nexport function readAssetManifest(distDir: string): AssetManifest | null {\n const path = join(distDir, '.kickjs-assets.json')\n if (!existsSync(path)) return null\n try {\n // eslint-disable-next-line @typescript-eslint/no-require-imports\n const fs = require('node:fs') as typeof import('node:fs')\n const raw = fs.readFileSync(path, 'utf-8')\n const parsed = JSON.parse(raw) as Partial<AssetManifest>\n if (parsed.version !== ASSET_MANIFEST_VERSION) return null\n if (!parsed.entries || typeof parsed.entries !== 'object') return null\n return parsed as AssetManifest\n } catch {\n return null\n }\n}\n\n/**\n * Project-root escape check that's safe across symlinks + drive letters.\n * `path.relative` returns `..` segments when the target sits above root,\n * and an absolute path when the two live on different roots (Windows).\n * `startsWith(root)` would miss both cases.\n */\nfunction escapesRoot(path: string, root: string): boolean {\n const rel = relative(root, path)\n if (rel === '') return false\n return rel.startsWith('..') || isAbsolute(rel)\n}\n\n/** Pure helper — `false` for missing, non-dir, or unreadable paths. */\nfunction isDirectorySync(path: string): boolean {\n try {\n return statSync(path).isDirectory()\n } catch {\n return false\n }\n}\n"],"mappings":";;;;;;;;;;qYAsFA,eAAsBE,EACpB,EACA,EACmC,CACnC,GAAM,CAAE,MAAK,SAAS,IAAUC,EAI1BC,EAAUD,EAAK,SAAWE,GAAQ,OAAO,QAAU,OACnDC,EAAMD,GAAQ,SACpB,GAAI,CAACC,GAAO,OAAO,KAAKA,CAAG,CAAC,CAAC,SAAW,EAAG,OAAO,KAElD,IAAMC,EAAMC,MAAe,CAAC,EAAI,QAAQ,IAElCC,EAAUC,EAAQC,EAAKP,CAAO,EACpCQ,EAAUH,EAAS,CAAE,UAAW,EAAK,CAAC,EAEtC,IAAMI,EAAoC,CAAC,EACrCC,EAA0C,CAAC,EAEjD,IAAK,GAAM,CAACC,EAAWC,KAAU,OAAO,QAAQV,CAAG,EAAG,CACpD,IAAMW,EAAS,MAAMC,EAAaH,EAAWC,EAAOL,EAAKF,CAAO,EAChEI,EAAQ,KAAKI,EAAO,YAAY,EAChC,OAAO,OAAOH,EAAiBG,EAAO,aAAa,EACnDV,EACE,SAASQ,EAAU,IAAIE,EAAO,aAAa,YAAY,aAAaA,EAAO,aAAa,MAC1F,CACF,CAEA,IAAME,EAA0B,CAC9B,QAAA,EACA,QAASL,CACX,EACMM,EAAeC,EAAKZ,EAAS,qBAAqB,EAMxD,OALAa,EAAcF,EAAc,KAAK,UAAUD,EAAU,KAAM,CAAC,EAAI;EAAM,OAAO,EAC7EZ,EACE,0BAA0BgB,EAASZ,EAAKS,CAAY,EAAE,IAAI,OAAO,KAAKN,CAAe,CAAC,CAAC,OAAO,UAChG,EAEO,CAAE,eAAc,QAASD,EAAS,UAAS,CACpD,CAGA,eAAeK,EACb,EACA,EACA,EACA,EAIC,CACD,IAAMM,EAASd,EAAQC,EAAKK,EAAM,GAAG,EAC/BS,EAAUT,EAAM,KAAON,EAAQC,EAAKK,EAAM,IAAI,EAAIK,EAAKZ,EAASM,CAAS,EAO/E,GAAIW,EAAYD,EAASd,CAAG,EAI1B,OAHA,QAAQ,KACN,gBAAgBI,EAAU,UAAUC,EAAM,KAAK,qDACjD,EACO,CACL,aAAc,CAAE,YAAW,IAAKA,EAAM,IAAK,KAAMO,EAASZ,EAAKc,CAAO,EAAG,YAAa,CAAE,EACxF,cAAe,CAAC,CAClB,EAOF,GAAI,CAACE,EAAWH,CAAM,GAAK,CAACI,EAAgBJ,CAAM,EAChD,MAAO,CACL,aAAc,CAAE,YAAW,IAAKR,EAAM,IAAK,KAAMO,EAASZ,EAAKc,CAAO,EAAG,YAAa,CAAE,EACxF,cAAe,CAAC,CAClB,EAGF,IAAMI,EAAUb,EAAM,MAAQ,OAGxBc,EAAU,MAAMC,EAAKF,EAAS,CAClC,IAAKL,EACL,MAAO,GACP,IAAK,GACL,MAAO,EACT,CAAC,EAEDZ,EAAUa,EAAS,CAAE,UAAW,EAAK,CAAC,EAEtC,IAAMO,EAAwC,CAAC,EAIzCC,EAAS,CAAC,GAAGH,CAAO,CAAC,CAAC,SAAS,EAC/B,CAAE,QAAO,2BAA4BI,EAAenB,EAAWkB,EAAQ,CAC3E,SAAUjB,EAAM,MAAQ,MAC1B,CAAC,EAaGmB,EAAc,EAClB,IAAK,GAAM,CAAE,IAAKC,EAAS,SAASC,EAAO,CACzC,IAAMC,EAAUjB,EAAKG,EAAQY,CAAO,EAC9BG,EAAWlB,EAAKI,EAASW,CAAO,EACtC,EAAcI,GAAOC,EAAmBhC,EAAS8B,CAAQ,EACrD,GAAWD,EAASC,CAAQ,IAChC3B,EAAU8B,EAAQH,CAAQ,EAAG,CAAE,UAAW,EAAK,CAAC,EAChDI,EAAOL,EAASC,CAAQ,EACxB,IACF,CASA,OAPIK,EAA0B,GAC5B,QAAQ,IACN,gBAAgB7B,EAAU,kBAAkB6B,EAAwB,4KAEtE,EAGK,CACL,aAAc,CACZ,YACA,IAAK5B,EAAM,IACX,KAAMO,EAASZ,EAAKc,CAAO,EAC3B,aACF,EACA,eACF,CACF,CAOA,SAASoB,EAAW,EAAiB,EAA2B,CAC9D,GAAI,CAAClB,EAAWY,CAAQ,EAAG,MAAO,GAClC,GAAI,CACF,IAAMO,EAAIC,EAAST,CAAO,EACpBU,EAAID,EAASR,CAAQ,EAC3B,OAAOS,EAAE,OAASF,EAAE,MAAQE,EAAE,SAAWF,EAAE,OAC7C,MAAQ,CACN,MAAO,EACT,CACF,CAMA,SAASL,EAAmB,EAAqB,EAA0B,CAKzE,OAJYlB,EAAS0B,EAAaV,CAIzB,CAAC,CAAC,MAAM,OAAO,CAAC,CAAC,OAAO,OAAO,CAAC,CAAC,KAAK,GAAG,CACpD,CAwCA,SAASb,EAAY,EAAc,EAAuB,CACxD,IAAMwB,EAAM3B,EAAS4B,EAAMC,CAAI,EAE/B,OADIF,IAAQ,GAAW,GAChBA,EAAI,WAAW,IAAI,GAAKG,EAAWH,CAAG,CAC/C,CAGA,SAAStB,EAAgB,EAAuB,CAC9C,GAAI,CACF,OAAOmB,EAASK,CAAI,CAAC,CAAC,YAAY,CACpC,MAAQ,CACN,MAAO,EACT,CACF"}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @forinda/kickjs-cli v8.
|
|
2
|
+
* @forinda/kickjs-cli v8.9.0
|
|
3
3
|
*
|
|
4
4
|
* Copyright (c) Felix Orinda
|
|
5
5
|
*
|
|
@@ -8,5 +8,5 @@
|
|
|
8
8
|
*
|
|
9
9
|
* @license MIT
|
|
10
10
|
*/
|
|
11
|
-
import{t as e}from"./rolldown-runtime-
|
|
11
|
+
import{t as e}from"./rolldown-runtime-Da-NDvKz.mjs";import{cpSync as t,existsSync as n,mkdirSync as r,statSync as i,writeFileSync as a}from"node:fs";import{dirname as o,isAbsolute as s,join as c,relative as l,resolve as u}from"node:path";import{glob as d}from"glob";import{groupAssetKeys as f}from"@forinda/kickjs";var p=/* @__PURE__ */ e({ASSET_MANIFEST_VERSION:()=>1,buildAssets:()=>m});async function m(e,t){let{cwd:n,silent:i=!1}=t,o=t.distDir??e?.build?.outDir??`dist`,s=e?.assetMap;if(!s||Object.keys(s).length===0)return null;let d=i?()=>{}:console.log,f=u(n,o);r(f,{recursive:!0});let p=[],m={};for(let[e,t]of Object.entries(s)){let r=await h(e,t,n,f);p.push(r.entrySummary),Object.assign(m,r.manifestSlice),d(` ✓ ${e}: ${r.entrySummary.filesCopied} file(s) → ${r.entrySummary.dest}`)}let g={version:1,entries:m},_=c(f,`.kickjs-assets.json`);return a(_,JSON.stringify(g,null,2)+`
|
|
12
12
|
`,`utf-8`),d(` ✓ wrote manifest → ${l(n,_)} (${Object.keys(m).length} entries)`),{manifestPath:_,entries:p,manifest:g}}async function h(e,i,a,s){let p=u(a,i.src),m=i.dest?u(a,i.dest):c(s,e);if(v(m,a))return console.warn(` ⚠ assetMap.${e}.dest ('${i.dest}') resolves outside the project root — skipping copy`),{entrySummary:{namespace:e,src:i.src,dest:l(a,m),filesCopied:0},manifestSlice:{}};if(!n(p)||!y(p))return{entrySummary:{namespace:e,src:i.src,dest:l(a,m),filesCopied:0},manifestSlice:{}};let h=i.glob??`**/*`,b=await d(h,{cwd:p,nodir:!0,dot:!1,posix:!0});r(m,{recursive:!0});let x={},S=[...b].toSorted(),{pairs:C,collisionGroupsResolved:w}=f(e,S,{strategy:i.keys??`auto`}),T=0;for(let{rel:e,key:n}of C){let i=c(p,e),a=c(m,e);x[n]=_(s,a),!g(i,a)&&(r(o(a),{recursive:!0}),t(i,a),T++)}return w>0&&console.log(` ℹ assetMap.${e}: auto-resolved ${w} basename collision(s) by keeping extensions (set 'keys: "strip"' to opt back into legacy last-write-wins behaviour, or 'keys: "with-extension"' to keep all keys verbose).`),{entrySummary:{namespace:e,src:i.src,dest:l(a,m),filesCopied:T},manifestSlice:x}}function g(e,t){if(!n(t))return!1;try{let n=i(e),r=i(t);return r.size===n.size&&r.mtimeMs>=n.mtimeMs}catch{return!1}}function _(e,t){return l(e,t).split(/[\\/]/).filter(Boolean).join(`/`)}function v(e,t){let n=l(t,e);return n===``?!1:n.startsWith(`..`)||s(n)}function y(e){try{return i(e).isDirectory()}catch{return!1}}export{p as n,m as t};
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @forinda/kickjs-cli v8.
|
|
2
|
+
* @forinda/kickjs-cli v8.9.0
|
|
3
3
|
*
|
|
4
4
|
* Copyright (c) Felix Orinda
|
|
5
5
|
*
|
|
@@ -8,4 +8,4 @@
|
|
|
8
8
|
*
|
|
9
9
|
* @license MIT
|
|
10
10
|
*/
|
|
11
|
-
|
|
11
|
+
export{t as builtinCliPlugins}from"./run-plugins-0AfIgIsZ.mjs";
|