kilo-cms 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,13 +1,16 @@
1
1
  # Kilo CMS
2
2
 
3
- An installable, config-driven CMS engine for Next.js an admin panel, RBAC, a generic
4
- field-type engine, and an API, mounted into your own app. Define your content schema via
5
- config, not by hand-editing a registry. Modeled on how [Filament](https://filamentphp.com/)
6
- installs into a Laravel app.
3
+ A schema-driven, config-driven CMS engine, built specifically for Next.js. Instead of
4
+ generating code or hand-editing a central registry, you describe your content model as plain
5
+ config — fields, relations, validation, list views and Kilo CMS turns that into a working
6
+ admin panel, RBAC, and API, mounted directly inside your own app's routes.
7
7
 
8
8
  > **Status**: pre-1.0, proven end-to-end against one real production app
9
- > ([`the-usual-dev`](https://github.com/revaldyas/the-usual-dev)'s `apps/site`), not yet used
9
+ > ([`kilostudio.id`](https://github.com/kilostudio/kilostudio.id)'s `apps/site`), not yet used
10
10
  > by a second, independent project. API surface may still change.
11
+ >
12
+ > **Looking for collaborators.** The repo is currently private; reach out if you want in as a
13
+ > collaborator.
11
14
 
12
15
  ## What you get
13
16
 
@@ -112,29 +115,56 @@ export const auth = createKiloAuth({
112
115
  export * from 'kilo-cms/schema'
113
116
  export * from '../collections/projects/table'
114
117
 
115
- import { schema as kiloSchema } from 'kilo-cms/schema'
118
+ import { mergeSchema } from 'kilo-cms/schema'
116
119
  import { projects } from '../collections/projects/table'
117
- export const schema = { ...kiloSchema, projects }
120
+ export const schema = mergeSchema({ projects })
118
121
  ```
119
122
 
120
- Registration is tied to `db.ts`'s own first import (not a `register()`-hook-only approach) —
121
- Next.js's build-time static generation spawns separate worker processes, and a hook like
122
- `instrumentation.ts` isn't guaranteed to fire in every one of them before a page module
123
- evaluates. Every admin page/route stub should start with `import '@/lib/db'` before its
124
- re-export, for the same reason:
123
+ `mergeSchema({ ...yourTables })` is just `{ ...kiloSchema, ...yourTables }` it exists so
124
+ adding a content table only touches the object you pass in, never a second copy of `kiloSchema`
125
+ hand-spread somewhere else. The `export * from '../collections/projects/table'` line above it
126
+ still has to stay, though: drizzle-kit finds tables by scanning a schema file's *top-level
127
+ exports*, not by evaluating a merged object at its own config-load time. If you'd rather not
128
+ maintain that re-export line per collection either, point drizzle-kit's own `schema` option at
129
+ a glob instead — it accepts an array of paths/globs natively:
125
130
 
126
131
  ```ts
127
- // app/(admin)/admin/dashboard/page.tsx
128
- import '@/lib/db'
129
- export { default } from 'kilo-cms/admin/pages/dashboard'
132
+ // drizzle.config.ts
133
+ export default defineConfig({
134
+ schema: ['./src/lib/schema.ts', './src/collections/*/table.ts'],
135
+ // ...
136
+ })
130
137
  ```
131
138
 
139
+ That way `src/lib/schema.ts` only needs to exist for `export * from 'kilo-cms/schema'` and the
140
+ `mergeSchema(...)` runtime object — every host content table is picked up by the glob
141
+ automatically the moment its `table.ts` exists, with no per-collection edit to this file at all.
142
+
143
+ ### Registration: `instrumentation.ts`
144
+
145
+ Call `defineKiloConfig()` once, as early as possible, from Next.js's
146
+ [`instrumentation.ts`](https://nextjs.org/docs/app/guides/instrumentation) `register()` hook —
147
+ it runs once per server process, before any request is handled:
148
+
132
149
  ```ts
133
- // app/api/admin/dashboard/route.ts
134
- import '@/lib/db'
135
- export { GET, PUT } from 'kilo-cms/routes/admin/dashboard'
150
+ // instrumentation.ts
151
+ export async function register() {
152
+ if (process.env.NEXT_RUNTIME === 'nodejs') {
153
+ await import('@/lib/db')
154
+ }
155
+ }
136
156
  ```
137
157
 
158
+ This is enough for every *dynamic* route (every admin page/route, since none of them are
159
+ statically generated) — the process instrumentation ran in is the same one that later serves
160
+ those requests. It is **not** enough on its own for anything reachable from
161
+ `generateStaticParams` on your public-facing pages: Next's build-time static generation spawns
162
+ separate worker processes, and `instrumentation.ts` isn't guaranteed to have run in all of
163
+ them before a page module evaluates. For those pages, importing `@/lib/db` (directly, or
164
+ transitively through whatever data-fetching module they already import) is what actually
165
+ guarantees registration in that worker — `instrumentation.ts` is a convenience for the admin
166
+ side, not a replacement for that.
167
+
138
168
  ### Middleware
139
169
 
140
170
  Kilo CMS ships an Edge-safe, cookie-presence-only pre-filter that redirects unauthenticated
@@ -167,29 +197,52 @@ const result = await requireAdminSession((r) => r.can('projects', 'read'), '/adm
167
197
  Run from your own app's directory:
168
198
 
169
199
  ```sh
170
- npx kilo-cms add-collection <slug> # scaffold a new collection's fields.ts + table.ts
171
- npx kilo-cms sync # regenerate .kilo/types.gen.ts from src/collections/*
172
- npx kilo-cms migrate # apply Kilo CMS's own package-owned migrations
173
- npx kilo-cms init # migrate, then create the first admin user
200
+ npx kilo-cms add-collection <slug> [--fields "..."] # scaffold a new collection's fields.ts + table.ts
201
+ npx kilo-cms sync # regenerate .kilo/types.gen.ts from src/collections/*
202
+ npx kilo-cms migrate # apply Kilo CMS's own package-owned migrations
203
+ npx kilo-cms init # fill in missing .env secrets, migrate, create the first admin user
174
204
  ```
175
205
 
176
206
  `add-collection` scaffolds the two files and prints the exact lines to paste into
177
207
  `kilo.config.ts` — deliberately not an auto-codemod (a script that edits your config file
178
- wrong is worse than a 10-second copy-paste).
208
+ wrong is worse than a 10-second copy-paste). Without `--fields`, you get the same
209
+ title/slug/sortOrder starter as before. With `--fields`, it builds exactly the fields you list
210
+ instead — a plain-text shorthand for the common case, not a substitute for hand-editing
211
+ `fields.ts` afterward for anything it doesn't cover:
212
+
213
+ ```sh
214
+ npx kilo-cms add-collection testimonials \
215
+ --fields "quote:textarea(required,rows=4),author:text(required,listPrimary,maxLength=80),rating:rating(max=5),featured:boolean,logo:image"
216
+ ```
217
+
218
+ Each entry is `key:type` or `key:type(opt,opt2=value)`. Supported types: `text`, `textarea`,
219
+ `richtext`, `number`, `boolean`, `select`, `multiselect`, `tags`, `image`, `file`, `date`,
220
+ `datetime`, `color`, `rating`. Options: `required`, `nullable`, `listColumn`, `listPrimary`,
221
+ `searchable`, `sortable`, `filterable`, `readOnly`, `maxLength=N`, `rows=N`,
222
+ `format=slug|email|url|password|tel`, `options=a|b|c` (select/multiselect). Relations, joins,
223
+ arrays, blocks, groups, and JSON fields aren't part of this shorthand — add those by hand, same
224
+ as you'd tune anything else the generator scaffolds.
179
225
 
180
226
  `migrate` applies Kilo CMS's own schema (auth, RBAC, media, settings, dev-tools, admin-ui,
181
227
  locales — see `src/schema/`) against your `DATABASE_URL`, tracked in its own
182
228
  `kilo_cms_migrations` table, independent of your own app's migration history for its content
183
229
  tables.
184
230
 
185
- `init` runs `migrate`, then creates your first admin user via the existing
186
- `/api/setup/admin` route it needs your app already running (`npm run dev` in another
187
- terminal) since that route executes inside your app's own Next.js runtime:
231
+ `init` fills in `BETTER_AUTH_SECRET` and `CMS_SETUP_TOKEN` in your `.env` if either is missing
232
+ (generated, never overwriting a value that's already there), runs `migrate`, then creates your
233
+ first admin user via the existing `/api/setup/admin` route it needs your app already running
234
+ (`npm run dev` in another terminal) since that route executes inside your app's own Next.js
235
+ runtime:
188
236
 
189
237
  ```sh
190
- CMS_SETUP_TOKEN=... npx kilo-cms init --name "Your Name" --email you@example.com --password "at least 12 characters" [--url http://localhost:3000]
238
+ npx kilo-cms init --name "Your Name" --email you@example.com --password "at least 12 characters" [--url http://localhost:3000]
191
239
  ```
192
240
 
241
+ The only variable you're still expected to set yourself is `DATABASE_URL`. If `init` had to
242
+ generate a fresh `CMS_SETUP_TOKEN`, it stops after `migrate` and asks you to restart your dev
243
+ server first — the already-running process only read `.env` once, at its own startup, so it
244
+ can't see a token that didn't exist yet. Run the same `init` command again after restarting.
245
+
193
246
  ## Package layout
194
247
 
195
248
  | Export | What it is |
@@ -198,7 +251,7 @@ CMS_SETUP_TOKEN=... npx kilo-cms init --name "Your Name" --email you@example.com
198
251
  | `kilo-cms/collections` | Client-safe: field types, `defineCollectionFields()`, the registry, value helpers |
199
252
  | `kilo-cms/collections/table` | Server-only: `defineCollectionTable()` |
200
253
  | `kilo-cms/collections/server` | Server-only: validation, workflow, filters, the query engine |
201
- | `kilo-cms/schema` | Kilo CMS's own drizzle tables (auth, RBAC, media, settings, dev-tools, admin-ui, locales) |
254
+ | `kilo-cms/schema` | Kilo CMS's own drizzle tables (auth, RBAC, media, settings, dev-tools, admin-ui, locales) + `mergeSchema()` |
202
255
  | `kilo-cms/auth`, `kilo-cms/auth-client` | `createKiloAuth()` factory + the better-auth React client |
203
256
  | `kilo-cms/admin/*` | Admin UI pages and shared components |
204
257
  | `kilo-cms/admin/require-session` | `requireAdminSession()` — the shared session/RBAC guard for admin pages |
@@ -208,7 +261,7 @@ CMS_SETUP_TOKEN=... npx kilo-cms init --name "Your Name" --email you@example.com
208
261
  ## Status / roadmap
209
262
 
210
263
  Built by extracting a working, in-production CMS out of the app it was originally embedded
211
- in — see [`the-usual-dev`'s `WORKLOG.md`](https://github.com/revaldyas/the-usual-dev) for the
264
+ in — see [`kilostudio.id`'s `WORKLOG.md`](https://github.com/kilostudio/kilostudio.id) for the
212
265
  full history of that extraction. Not yet done:
213
266
 
214
267
  - A single catch-all admin route instead of one thin file per page (deliberately skipped for
@@ -217,7 +270,16 @@ full history of that extraction. Not yet done:
217
270
  - A second, independent consumer project to prove the install story beyond the one app this
218
271
  was extracted from.
219
272
 
273
+ ## Versioning
274
+
275
+ Semantic versioning, while the major version stays `0`: a **minor** bump (`0.x.0`) means new or
276
+ changed public API surface (a new export, a new CLI command); a **patch** bump (`0.x.y`) means
277
+ a bug fix with no public API change. Every release is documented in
278
+ [`CHANGELOG.md`](./CHANGELOG.md) and tagged in git (`v0.1.0`, `v0.1.1`, `v0.2.0`, ...) — check
279
+ the changelog before upgrading a consumer's dependency range, especially across a minor bump.
280
+
220
281
  ## License
221
282
 
222
- Proprietary — all rights reserved. Not yet licensed for redistribution; contact the author
223
- before using this outside of an explicitly authorized project.
283
+ Proprietary — all rights reserved. This is not an open-source project: the repo is private,
284
+ and access is by invitation as a collaborator, not by public license. Contact the author before
285
+ using this outside of an explicitly authorized project.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kilo-cms",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "An installable, config-driven CMS engine for Next.js — admin panel, RBAC, a generic field-type engine, and an API, mounted into your own app. Define your content schema via config, not a hand-maintained registry.",
5
5
  "keywords": [
6
6
  "cms",
package/src/cli/index.mjs CHANGED
@@ -2,10 +2,11 @@
2
2
  // Plain ESM — deliberately not TypeScript, so this runs on any Node (22.6+) without a loader,
3
3
  // build step, or dependency on how the host project executes TS. Run from the HOST app's own
4
4
  // directory (e.g. `cd apps/site && npx kilo-cms <command>`), same way `drizzle-kit` is run.
5
- import { existsSync, mkdirSync, readdirSync, statSync, writeFileSync } from 'node:fs'
5
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs'
6
6
  import { join, dirname } from 'node:path'
7
7
  import { fileURLToPath } from 'node:url'
8
8
  import { spawnSync } from 'node:child_process'
9
+ import { randomBytes } from 'node:crypto'
9
10
 
10
11
  const cliDir = dirname(fileURLToPath(import.meta.url))
11
12
  const packageRoot = join(cliDir, '..', '..') // packages/kilo-cms
@@ -29,8 +30,120 @@ function singularize(label) {
29
30
 
30
31
  // --- add-collection ---------------------------------------------------------------------
31
32
 
32
- function addCollection(slug) {
33
- if (!slug) fail('usage: kilo-cms add-collection <slug>')
33
+ function keyToLabel(key) {
34
+ const words = key.replace(/([a-z0-9])([A-Z])/g, '$1 $2').replace(/[-_]/g, ' ').trim()
35
+ return words.charAt(0).toUpperCase() + words.slice(1)
36
+ }
37
+
38
+ // `title:text(required,listPrimary),summary:textarea(rows=4),cover:image` — a plain-text
39
+ // shorthand for the common case, not a replacement for hand-editing fields.ts for anything
40
+ // past what this covers (relation/join/array/blocks/group/json/point aren't supported here;
41
+ // add those by hand afterward, same as the reviewer's own guidance: generate the shape, then
42
+ // adjust it, rather than have a generator so clever it becomes its own thing to debug).
43
+ const SUPPORTED_FIELD_TYPES = new Set(['text', 'textarea', 'richtext', 'number', 'boolean', 'select', 'multiselect', 'tags', 'image', 'file', 'date', 'datetime', 'color', 'rating'])
44
+
45
+ // Splits on top-level commas only — a plain `.split(',')` would also cut apart the
46
+ // comma-separated options list inside a field's own `(...)`, e.g. "quote:textarea(required,rows=4)".
47
+ function splitTopLevel(raw) {
48
+ const parts = []
49
+ let depth = 0
50
+ let current = ''
51
+ for (const char of raw) {
52
+ if (char === '(') depth++
53
+ if (char === ')') depth--
54
+ if (char === ',' && depth === 0) {
55
+ parts.push(current)
56
+ current = ''
57
+ } else {
58
+ current += char
59
+ }
60
+ }
61
+ if (current) parts.push(current)
62
+ return parts
63
+ }
64
+
65
+ function parseFieldSpecs(raw) {
66
+ return splitTopLevel(raw).map((entry) => {
67
+ const match = entry.trim().match(/^([a-zA-Z][a-zA-Z0-9]*):([a-z]+)(?:\(([^)]*)\))?$/)
68
+ if (!match) fail(`could not parse field "${entry.trim()}" — expected "key:type" or "key:type(opt,opt2=value)".`)
69
+ const [, key, type, optsRaw] = match
70
+ if (!SUPPORTED_FIELD_TYPES.has(type)) fail(`"${type}" (on field "${key}") isn't a type this generator scaffolds — supported: ${[...SUPPORTED_FIELD_TYPES].join(', ')}. Add it by hand instead.`)
71
+ const opts = {}
72
+ for (const pair of (optsRaw ?? '').split(',').map((s) => s.trim()).filter(Boolean)) {
73
+ const [optKey, optValue] = pair.split('=')
74
+ opts[optKey] = optValue ?? true
75
+ }
76
+ return { key, type, opts }
77
+ })
78
+ }
79
+
80
+ // One field spec -> its `fields: [...]` entry (defineCollectionFields shape).
81
+ function fieldConfigFor(spec) {
82
+ const { key, type, opts } = spec
83
+ const base = { key, type, label: keyToLabel(key), group: 'overview' }
84
+ if (opts.required) base.required = true
85
+ if (opts.nullable) base.nullable = true
86
+ if (opts.listColumn || opts.listPrimary) base.listColumn = true
87
+ if (opts.listPrimary) base.listPrimary = true
88
+ if (opts.searchable) base.searchable = true
89
+ if (opts.sortable) base.sortable = true
90
+ if (opts.filterable) base.filterable = true
91
+ if (opts.readOnly) base.readOnly = true
92
+
93
+ if (type === 'text' || type === 'textarea') {
94
+ if (opts.maxLength) base.maxLength = Number(opts.maxLength)
95
+ if (type === 'textarea' && opts.rows) base.rows = Number(opts.rows)
96
+ if (type === 'text' && opts.format) base.format = opts.format
97
+ }
98
+ if (type === 'number' && opts.integer) base.integer = true
99
+ if (type === 'rating' && opts.max) base.max = Number(opts.max)
100
+ if ((type === 'select' || type === 'multiselect') && opts.options) {
101
+ const values = String(opts.options).split('|').filter(Boolean)
102
+ base.options = values.map((value) => ({ value, label: keyToLabel(value) }))
103
+ } else if (type === 'select' || type === 'multiselect') {
104
+ base.options = [{ value: 'value-one', label: 'Value one' }, { value: 'value-two', label: 'Value two' }]
105
+ }
106
+
107
+ const entries = Object.entries(base).map(([k, v]) => `${k}: ${JSON.stringify(v)}`)
108
+ return ` { ${entries.join(', ')} }`
109
+ }
110
+
111
+ // One field spec -> its Drizzle column (table.ts shape). Deliberately conservative: every
112
+ // column defaults to nullable text/jsonb unless `required` was set, since guessing wrong here
113
+ // means a migration to fix later — safer to under-commit and let the user tighten it by hand.
114
+ function columnFor(spec) {
115
+ const { key, type, opts } = spec
116
+ const notNull = opts.required && !opts.nullable
117
+ switch (type) {
118
+ case 'text':
119
+ case 'select':
120
+ case 'image':
121
+ case 'file':
122
+ case 'color':
123
+ return ` ${key}: text('${key}')${notNull ? '.notNull()' : ''},`
124
+ case 'textarea':
125
+ return ` ${key}: text('${key}')${notNull ? '.notNull()' : ".default('')"},`
126
+ case 'richtext':
127
+ return ` ${key}: jsonb('${key}').$type<RichTextDocument>().notNull().default({ type: 'doc', content: [{ type: 'paragraph' }] }),`
128
+ case 'number':
129
+ case 'rating':
130
+ return ` ${key}: integer('${key}')${notNull ? '.notNull()' : '.default(0)'},`
131
+ case 'boolean':
132
+ return ` ${key}: boolean('${key}').notNull().default(false),`
133
+ case 'multiselect':
134
+ case 'tags':
135
+ return ` ${key}: jsonb('${key}').$type<string[]>().notNull().default([]),`
136
+ case 'date':
137
+ return ` ${key}: date('${key}')${notNull ? '.notNull()' : ''},`
138
+ case 'datetime':
139
+ return ` ${key}: timestamp('${key}')${notNull ? '.notNull()' : ''},`
140
+ default:
141
+ throw new Error(`kilo-cms internal error: field type "${type}" is in SUPPORTED_FIELD_TYPES but columnFor() doesn't handle it.`)
142
+ }
143
+ }
144
+
145
+ function addCollection(slug, flags) {
146
+ if (!slug) fail('usage: kilo-cms add-collection <slug> [--fields "key:type(opt,opt2),key2:type"]')
34
147
  if (!/^[a-z][a-zA-Z0-9]*$/.test(slug)) fail(`"${slug}" should be camelCase, starting with a lowercase letter (e.g. "projectCategories").`)
35
148
 
36
149
  const collectionsDir = join(cwd, 'src', 'collections')
@@ -42,6 +155,16 @@ function addCollection(slug) {
42
155
  const label = slugToLabel(slug)
43
156
  const labelSingular = singularize(label)
44
157
  const varName = `${slug}Fields`
158
+ const tableName = `cms_${slug.replace(/([a-z0-9])([A-Z])/g, '$1_$2').toLowerCase()}`
159
+
160
+ const customSpecs = flags?.fields ? parseFieldSpecs(flags.fields) : null
161
+
162
+ const fieldsBody = customSpecs
163
+ ? customSpecs.map(fieldConfigFor).join(',\n')
164
+ : ` { key: 'title', type: 'text', label: 'Title', group: 'overview', required: true, maxLength: 120, listColumn: true, listPrimary: true, searchable: true, sortable: true },
165
+ { key: 'slug', type: 'text', label: 'Slug', group: 'overview', width: 'half', required: true, format: 'slug', slugFrom: 'title', maxLength: 120, listColumn: true, searchable: true, sortable: true },
166
+ { key: 'sortOrder', type: 'number', label: 'Sort order', group: 'overview', integer: true, readOnly: true }`
167
+ const titleFieldKey = customSpecs ? customSpecs[0]?.key ?? 'title' : 'title'
45
168
 
46
169
  const fieldsFile = `import { defineCollectionFields } from 'kilo-cms/collections'
47
170
 
@@ -50,7 +173,7 @@ export const ${varName} = defineCollectionFields({
50
173
  slug: '${slug}',
51
174
  label: '${label}',
52
175
  labelSingular: '${labelSingular}',
53
- titleField: 'title',
176
+ titleField: '${titleFieldKey}',
54
177
  defaultSort: { field: 'sortOrder', dir: 'asc' },
55
178
  touchUpdatedAt: true,
56
179
  groups: [
@@ -58,25 +181,36 @@ export const ${varName} = defineCollectionFields({
58
181
  { id: 'record', label: 'Record', description: 'Managed by the CMS' },
59
182
  ],
60
183
  fields: [
61
- { key: 'title', type: 'text', label: 'Title', group: 'overview', required: true, maxLength: 120, listColumn: true, listPrimary: true, searchable: true, sortable: true },
62
- { key: 'slug', type: 'text', label: 'Slug', group: 'overview', width: 'half', required: true, format: 'slug', slugFrom: 'title', maxLength: 120, listColumn: true, searchable: true, sortable: true },
63
- { key: 'sortOrder', type: 'number', label: 'Sort order', group: 'overview', integer: true, readOnly: true },
184
+ ${fieldsBody},
64
185
  { key: 'createdAt', type: 'datetime', label: 'Created', group: 'record', readOnly: true, nullable: true, width: 'half', sortable: true },
65
186
  { key: 'updatedAt', type: 'datetime', label: 'Last updated', group: 'record', readOnly: true, nullable: true, width: 'half', sortable: true, listColumn: true },
66
187
  ],
67
188
  })
68
189
  `
69
190
 
191
+ const needsRichText = customSpecs?.some((s) => s.type === 'richtext')
192
+ const needsJsonb = customSpecs?.some((s) => ['richtext', 'multiselect', 'tags'].includes(s.type))
193
+ const needsBoolean = customSpecs?.some((s) => s.type === 'boolean')
194
+ const needsDate = customSpecs?.some((s) => s.type === 'date')
195
+ const needsTimestamp = customSpecs?.some((s) => s.type === 'datetime')
196
+
197
+ const columnImports = ['pgTable', 'text', 'integer']
198
+ if (needsJsonb) columnImports.push('jsonb')
199
+ if (needsBoolean) columnImports.push('boolean')
200
+ if (needsDate) columnImports.push('date')
201
+ if (needsTimestamp) columnImports.push('timestamp')
202
+
203
+ const customColumns = customSpecs ? customSpecs.map(columnFor).join('\n') : null
204
+ const defaultColumns = ` slug: text('slug').notNull().unique(),\n title: text('title').notNull(),\n sortOrder: integer('sort_order').notNull().default(0),`
205
+
70
206
  const tableFile = `import 'server-only'
71
- import { pgTable, text, integer } from 'drizzle-orm/pg-core'
207
+ import { ${[...new Set(columnImports)].join(', ')} } from 'drizzle-orm/pg-core'
72
208
  import { timestamps } from 'kilo-cms/schema'
73
- import { defineCollectionTable } from 'kilo-cms/collections/table'
209
+ import { defineCollectionTable } from 'kilo-cms/collections/table'${needsRichText ? "\nimport type { RichTextDocument } from 'kilo-cms/richtext'" : ''}
74
210
 
75
- export const ${slug} = defineCollectionTable('${slug}', pgTable('cms_${slug.replace(/([a-z0-9])([A-Z])/g, '$1_$2').toLowerCase()}', {
211
+ export const ${slug} = defineCollectionTable('${slug}', pgTable('${tableName}', {
76
212
  id: text('id').primaryKey(),
77
- slug: text('slug').notNull().unique(),
78
- title: text('title').notNull(),
79
- sortOrder: integer('sort_order').notNull().default(0),
213
+ ${customColumns ?? defaultColumns}
80
214
  ...timestamps,
81
215
  }))
82
216
  `
@@ -139,8 +273,36 @@ function parseFlags(argv) {
139
273
  return flags
140
274
  }
141
275
 
276
+ /**
277
+ * Fills in `BETTER_AUTH_SECRET` and `CMS_SETUP_TOKEN` in the host's `.env` if either is
278
+ * missing — the two secrets a fresh install needs before it can even start, that otherwise
279
+ * have nothing meaningful to default to, so a new user has to know to invent them by hand.
280
+ * Never touches a key that's already present (in the file OR already set in the environment) —
281
+ * this only fills gaps, it doesn't rotate or overwrite anything.
282
+ */
283
+ function ensureEnvSecrets() {
284
+ const envPath = join(cwd, '.env')
285
+ const existing = existsSync(envPath) ? readFileSync(envPath, 'utf8') : ''
286
+ const hasKey = (key) => new RegExp(`^${key}=`, 'm').test(existing) || Boolean(process.env[key])
287
+
288
+ const missing = []
289
+ if (!hasKey('BETTER_AUTH_SECRET')) missing.push(['BETTER_AUTH_SECRET', randomBytes(32).toString('hex')])
290
+ if (!hasKey('CMS_SETUP_TOKEN')) missing.push(['CMS_SETUP_TOKEN', randomBytes(24).toString('hex')])
291
+
292
+ if (!missing.length) return []
293
+
294
+ const block = `\n# Added by \`npx kilo-cms init\` on ${new Date().toISOString().slice(0, 10)}\n${missing.map(([key, value]) => `${key}=${value}`).join('\n')}\n`
295
+ appendFileSync(envPath, block)
296
+ for (const [key, value] of missing) process.env[key] = value
297
+
298
+ const keys = missing.map(([key]) => key)
299
+ console.log(`Added ${keys.join(' and ')} to .env (generated — not written anywhere else, keep this file out of version control).`)
300
+ return keys
301
+ }
302
+
142
303
  async function init(argv) {
143
304
  const flags = parseFlags(argv)
305
+ const addedSecrets = ensureEnvSecrets()
144
306
  const url = flags.url ?? 'http://localhost:3000'
145
307
  const token = flags.token ?? process.env.CMS_SETUP_TOKEN
146
308
  const { name, email, password } = flags
@@ -150,6 +312,16 @@ async function init(argv) {
150
312
 
151
313
  migrate()
152
314
 
315
+ // A freshly generated CMS_SETUP_TOKEN only exists in .env, not in whatever process is already
316
+ // running the host app — that process read its env once, at its own startup. Calling
317
+ // /api/setup/admin right now would fail with a confusing "Unauthorized" (it's still checking
318
+ // the OLD, unset value), so stop here and have the user restart the app first.
319
+ if (addedSecrets.includes('CMS_SETUP_TOKEN')) {
320
+ console.log('\nCMS_SETUP_TOKEN was just generated, so it\'s not loaded into your already-running app yet.')
321
+ console.log('Restart your dev server (so it picks up the new .env), then run this same `kilo-cms init` command again.')
322
+ return
323
+ }
324
+
153
325
  console.log(`\nCreating first admin at ${url}/api/setup/admin ...`)
154
326
  console.log('(this requires your app to already be running — start it in another terminal first if it isn\'t.)')
155
327
 
@@ -175,7 +347,7 @@ async function init(argv) {
175
347
 
176
348
  switch (command) {
177
349
  case 'add-collection':
178
- addCollection(args[0])
350
+ addCollection(args[0], parseFlags(args.slice(1)))
179
351
  break
180
352
  case 'sync':
181
353
  sync()
@@ -188,10 +360,22 @@ switch (command) {
188
360
  break
189
361
  default:
190
362
  console.log(`kilo-cms — usage:
191
- kilo-cms add-collection <slug> scaffold a new collection's fields.ts + table.ts
363
+ kilo-cms add-collection <slug> [--fields "key:type(opt,opt2),key2:type"]
364
+ scaffold a new collection's fields.ts + table.ts.
365
+ Without --fields: the default title/slug/sortOrder template.
366
+ With --fields: builds exactly the fields you list (plus id/
367
+ createdAt/updatedAt, always added). Supported types: text,
368
+ textarea, richtext, number, boolean, select, multiselect,
369
+ tags, image, file, date, datetime, color, rating. Options:
370
+ required, nullable, listColumn, listPrimary, searchable,
371
+ sortable, filterable, readOnly, maxLength=N, rows=N,
372
+ format=slug|email|url, options=a|b|c.
373
+ Example: --fields "title:text(required,listPrimary),
374
+ summary:textarea(rows=4),cover:image"
192
375
  kilo-cms sync regenerate .kilo/types.gen.ts from src/collections/*
193
376
  kilo-cms migrate apply kilo-cms's own package-owned migrations
194
- kilo-cms init migrate, then create the first admin user
377
+ kilo-cms init generate missing secrets into .env, migrate, then create
378
+ the first admin user
195
379
  (--name, --email, --password, [--url], [--token or $CMS_SETUP_TOKEN])`)
196
380
  process.exit(command ? 1 : 0)
197
381
  }
@@ -31,3 +31,21 @@ export const schema = {
31
31
  contentViews, dashboards,
32
32
  cmsLocales,
33
33
  }
34
+
35
+ /**
36
+ * Merges kilo-cms's own tables with a host's content tables into the one object both
37
+ * `drizzle({ client, schema })` and drizzle-kit's config need. Purely `{ ...schema, ...tables }`
38
+ * under the hood — the value isn't the logic, it's giving the host ONE call instead of
39
+ * hand-spreading `schema` themselves, so adding a content table only touches the `tables`
40
+ * object passed in here, never a second place in the host's own schema file.
41
+ *
42
+ * This does NOT replace `export * from '../collections/<slug>/table'` re-exports in the host's
43
+ * own schema barrel — drizzle-kit's schema introspection needs each table as a real top-level
44
+ * export it can find by scanning the module (or, more simply, point drizzle-kit's own `schema`
45
+ * config option at a glob like `['./src/lib/schema.ts', './src/collections/*\/table.ts']`,
46
+ * which drizzle-kit supports natively and removes the re-export lines entirely — see this
47
+ * package's README "Quickstart" section).
48
+ */
49
+ export function mergeSchema<T extends Record<string, unknown>>(tables: T): typeof schema & T {
50
+ return { ...schema, ...tables }
51
+ }