kilo-cms 0.2.0 → 0.3.1
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 +93 -31
- package/package.json +1 -1
- package/src/admin/CmsSidebar.tsx +2 -0
- package/src/admin/SiteSettingsEditor.tsx +5 -0
- package/src/admin/admin.css +11 -2
- package/src/cli/index.mjs +200 -16
- package/src/schema/index.ts +18 -0
- package/src/version.ts +5 -0
package/README.md
CHANGED
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
# Kilo CMS
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
config,
|
|
6
|
-
|
|
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
|
-
> ([`
|
|
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 {
|
|
118
|
+
import { mergeSchema } from 'kilo-cms/schema'
|
|
116
119
|
import { projects } from '../collections/projects/table'
|
|
117
|
-
export const schema = {
|
|
120
|
+
export const schema = mergeSchema({ projects })
|
|
118
121
|
```
|
|
119
122
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
//
|
|
128
|
-
|
|
129
|
-
|
|
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
|
-
//
|
|
134
|
-
|
|
135
|
-
|
|
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>
|
|
171
|
-
npx kilo-cms sync
|
|
172
|
-
npx kilo-cms migrate
|
|
173
|
-
npx kilo-cms init
|
|
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`
|
|
186
|
-
|
|
187
|
-
|
|
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
|
-
|
|
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 [`
|
|
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.
|
|
223
|
-
|
|
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.
|
|
3
|
+
"version": "0.3.1",
|
|
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/admin/CmsSidebar.tsx
CHANGED
|
@@ -7,6 +7,7 @@ import { authClient } from '../auth-client'
|
|
|
7
7
|
import type { ContentViewSettings } from '../collections/content-view'
|
|
8
8
|
import { useCmsSiteSettings } from './CmsSiteSettingsProvider'
|
|
9
9
|
import { useCmsTheme } from './CmsThemeProvider'
|
|
10
|
+
import { KILO_CMS_VERSION } from '../version'
|
|
10
11
|
|
|
11
12
|
type Section = 'dashboard' | 'scheduled' | 'review-queue' | 'media' | 'settings' | 'users' | 'roles' | 'collections' | 'singles'
|
|
12
13
|
type IconName = 'grid' | 'calendar' | 'image' | 'gear' | 'logout' | 'users' | 'shield' | 'inbox' | 'layers' | 'database' | 'star' | 'folder' | 'trash'
|
|
@@ -143,5 +144,6 @@ export function CmsSidebar({ active, userName, contentContext, mediaContext }: {
|
|
|
143
144
|
<div className="cms-context-title"><p className="cms-nav-label">CMS feature</p><strong>{featureContext.title}</strong></div>
|
|
144
145
|
<div className="cms-context-groups">{featureContext.groups.map((group) => <section className="cms-context-group" key={group.label}><Link href={group.links[0]?.href ?? '/admin/settings'}>{group.label}</Link><div>{group.links.map((link) => <Link href={link.href} className={pathname === link.href ? 'active' : ''} key={link.href}>{link.label}</Link>)}</div></section>)}</div>
|
|
145
146
|
</nav> : null}
|
|
147
|
+
<p className="cms-sidebar-version" title="Kilo CMS version">v{KILO_CMS_VERSION}</p>
|
|
146
148
|
</aside>
|
|
147
149
|
}
|
|
@@ -14,6 +14,7 @@ import { Button } from './Button'
|
|
|
14
14
|
import { cmsAppearancePresets, cmsColorKeys, cmsSizeKeys, normalizeCmsAppearance, resolvedAppearanceTokens, type CmsAppearance, type CmsColorKey, type CmsSizeKey } from '../cms-appearance'
|
|
15
15
|
import { allApprovalContentTypes, defaultPublishingWorkflow, normalizePublishingWorkflow, type ApprovalContentType, type PublishingWorkflow } from '../publishing-workflow'
|
|
16
16
|
import { contentTypeDefs } from '../content-types'
|
|
17
|
+
import { KILO_CMS_VERSION } from '../version'
|
|
17
18
|
|
|
18
19
|
type Settings = { siteName: string; logoLight: string | null; logoDark: string | null; metaTitle: string; metaDescription: string; metaImage: string | null; cmsAppearance: CmsAppearance; publishingWorkflow: PublishingWorkflow; createdAt?: string; updatedAt?: string }
|
|
19
20
|
type Errors = Partial<Record<'siteName', string>>
|
|
@@ -91,6 +92,10 @@ export function SiteSettingsEditor({ userName }: { userName: string }) {
|
|
|
91
92
|
<CheckboxGroupField label="Require approval before publish" hint="This rule is enforced by the CMS API, including direct publish requests." value={settings.publishingWorkflow.approvalRequired} onChange={(approvalRequired) => update('publishingWorkflow', { approvalRequired: approvalRequired.filter((item): item is ApprovalContentType => (allApprovalContentTypes as string[]).includes(item)) })} options={approvalOptions} />
|
|
92
93
|
</section>
|
|
93
94
|
<CmsAppearanceEditor value={settings.cmsAppearance} onChange={(cmsAppearance) => { update('cmsAppearance', cmsAppearance); setAppearance(cmsAppearance) }} />
|
|
95
|
+
<section className="cms-section">
|
|
96
|
+
<div className="cms-section-heading"><p className="section-label">About</p><p>The Kilo CMS version this admin is running — include it when reporting a bug.</p></div>
|
|
97
|
+
<div className="cms-meta-list"><div><span>Kilo CMS version</span><span>v{KILO_CMS_VERSION}</span></div></div>
|
|
98
|
+
</section>
|
|
94
99
|
</div><aside className="cms-editor-sidebar"><p>{status}</p><Button variant="primary" type="button" onClick={save} icon="↗">Save settings</Button><div className="cms-meta-list"><div><span>Created</span><span>{formatDateTime(settings.createdAt)}</span></div><div><span>Updated</span><span>{formatDateTime(settings.updatedAt)}</span></div></div></aside></div>}
|
|
95
100
|
</section></main>
|
|
96
101
|
}
|
package/src/admin/admin.css
CHANGED
|
@@ -232,7 +232,7 @@
|
|
|
232
232
|
height: 100vh;
|
|
233
233
|
display: grid;
|
|
234
234
|
grid-template-columns: 46px minmax(0, 1fr);
|
|
235
|
-
grid-template-rows: auto minmax(0, 1fr);
|
|
235
|
+
grid-template-rows: auto minmax(0, 1fr) auto;
|
|
236
236
|
column-gap: 16px;
|
|
237
237
|
padding: 0 4px 8px 0;
|
|
238
238
|
border-right: 1px solid var(--line);
|
|
@@ -6634,4 +6634,13 @@
|
|
|
6634
6634
|
.cms-theme-presets {
|
|
6635
6635
|
grid-template-columns: 1fr 1fr
|
|
6636
6636
|
}
|
|
6637
|
-
}
|
|
6637
|
+
}
|
|
6638
|
+
.cms-sidebar-version {
|
|
6639
|
+
grid-column: 1 / -1;
|
|
6640
|
+
grid-row: 3;
|
|
6641
|
+
margin: 0;
|
|
6642
|
+
padding: 8px 0 0;
|
|
6643
|
+
font-size: 11px;
|
|
6644
|
+
text-align: center;
|
|
6645
|
+
color: var(--muted)
|
|
6646
|
+
}
|
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
|
|
33
|
-
|
|
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: '
|
|
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
|
-
|
|
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 {
|
|
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('
|
|
211
|
+
export const ${slug} = defineCollectionTable('${slug}', pgTable('${tableName}', {
|
|
76
212
|
id: text('id').primaryKey(),
|
|
77
|
-
|
|
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>
|
|
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
|
|
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
|
}
|
package/src/schema/index.ts
CHANGED
|
@@ -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
|
+
}
|