kilo-cms 0.1.1 → 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 +129 -30
- package/package.json +3 -1
- package/src/admin/pages/ai-settings.tsx +2 -4
- package/src/admin/pages/api-tokens.tsx +2 -5
- package/src/admin/pages/collection-list.tsx +3 -5
- package/src/admin/pages/collection-record.tsx +3 -5
- package/src/admin/pages/dashboard.tsx +2 -4
- package/src/admin/pages/email-settings.tsx +2 -5
- package/src/admin/pages/locales-settings.tsx +2 -5
- package/src/admin/pages/media.tsx +2 -4
- package/src/admin/pages/review-queue.tsx +2 -3
- package/src/admin/pages/role-edit.tsx +2 -5
- package/src/admin/pages/roles.tsx +2 -5
- package/src/admin/pages/scheduled.tsx +3 -5
- package/src/admin/pages/seo-settings.tsx +2 -4
- package/src/admin/pages/single-record.tsx +3 -5
- package/src/admin/pages/site-settings.tsx +2 -4
- package/src/admin/pages/user-edit.tsx +2 -5
- package/src/admin/pages/user-new.tsx +2 -5
- package/src/admin/pages/users.tsx +2 -5
- package/src/admin/pages/webhooks-settings.tsx +2 -5
- package/src/admin/require-session.ts +21 -0
- package/src/cli/index.mjs +248 -16
- package/src/middleware.ts +28 -0
- package/src/schema/index.ts +18 -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,27 +115,81 @@ 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
|
+
}
|
|
156
|
+
```
|
|
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
|
+
|
|
168
|
+
### Middleware
|
|
169
|
+
|
|
170
|
+
Kilo CMS ships an Edge-safe, cookie-presence-only pre-filter that redirects unauthenticated
|
|
171
|
+
visitors away from `/admin/*` before a page even renders — a fast filter, not the real
|
|
172
|
+
security boundary (every admin page/route still validates the session and RBAC permissions
|
|
173
|
+
for real). Your own `middleware.ts` re-exports it and defines `matcher` itself (Next.js
|
|
174
|
+
statically analyzes `matcher` in the file it scans — it can't be re-exported through another
|
|
175
|
+
module):
|
|
176
|
+
|
|
177
|
+
```ts
|
|
178
|
+
// middleware.ts
|
|
179
|
+
export { kiloAdminMiddleware as middleware } from 'kilo-cms/middleware'
|
|
180
|
+
|
|
181
|
+
export const config = {
|
|
182
|
+
matcher: ['/admin/:path*'],
|
|
183
|
+
}
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
Admin pages use `requireAdminSession()` from `kilo-cms/admin/require-session` instead of
|
|
187
|
+
hand-rolling `getSessionWithPermissions()` + `redirect()`:
|
|
188
|
+
|
|
189
|
+
```ts
|
|
190
|
+
import { requireAdminSession } from 'kilo-cms/admin/require-session'
|
|
191
|
+
|
|
192
|
+
const result = await requireAdminSession((r) => r.can('projects', 'read'), '/admin/dashboard')
|
|
136
193
|
```
|
|
137
194
|
|
|
138
195
|
## CLI
|
|
@@ -140,20 +197,52 @@ export { GET, PUT } from 'kilo-cms/routes/admin/dashboard'
|
|
|
140
197
|
Run from your own app's directory:
|
|
141
198
|
|
|
142
199
|
```sh
|
|
143
|
-
npx kilo-cms add-collection <slug>
|
|
144
|
-
npx kilo-cms sync
|
|
145
|
-
npx kilo-cms migrate
|
|
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
|
|
146
204
|
```
|
|
147
205
|
|
|
148
206
|
`add-collection` scaffolds the two files and prints the exact lines to paste into
|
|
149
207
|
`kilo.config.ts` — deliberately not an auto-codemod (a script that edits your config file
|
|
150
|
-
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.
|
|
151
225
|
|
|
152
226
|
`migrate` applies Kilo CMS's own schema (auth, RBAC, media, settings, dev-tools, admin-ui,
|
|
153
227
|
locales — see `src/schema/`) against your `DATABASE_URL`, tracked in its own
|
|
154
228
|
`kilo_cms_migrations` table, independent of your own app's migration history for its content
|
|
155
229
|
tables.
|
|
156
230
|
|
|
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:
|
|
236
|
+
|
|
237
|
+
```sh
|
|
238
|
+
npx kilo-cms init --name "Your Name" --email you@example.com --password "at least 12 characters" [--url http://localhost:3000]
|
|
239
|
+
```
|
|
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
|
+
|
|
157
246
|
## Package layout
|
|
158
247
|
|
|
159
248
|
| Export | What it is |
|
|
@@ -162,25 +251,35 @@ tables.
|
|
|
162
251
|
| `kilo-cms/collections` | Client-safe: field types, `defineCollectionFields()`, the registry, value helpers |
|
|
163
252
|
| `kilo-cms/collections/table` | Server-only: `defineCollectionTable()` |
|
|
164
253
|
| `kilo-cms/collections/server` | Server-only: validation, workflow, filters, the query engine |
|
|
165
|
-
| `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()` |
|
|
166
255
|
| `kilo-cms/auth`, `kilo-cms/auth-client` | `createKiloAuth()` factory + the better-auth React client |
|
|
167
256
|
| `kilo-cms/admin/*` | Admin UI pages and shared components |
|
|
257
|
+
| `kilo-cms/admin/require-session` | `requireAdminSession()` — the shared session/RBAC guard for admin pages |
|
|
258
|
+
| `kilo-cms/middleware` | `kiloAdminMiddleware` — Edge-safe cookie-presence pre-filter for `/admin/*` |
|
|
168
259
|
| `kilo-cms/routes/*` | Route Handler logic for the admin API, public API helpers, auth, cron |
|
|
169
260
|
|
|
170
261
|
## Status / roadmap
|
|
171
262
|
|
|
172
263
|
Built by extracting a working, in-production CMS out of the app it was originally embedded
|
|
173
|
-
in — see [`
|
|
264
|
+
in — see [`kilostudio.id`'s `WORKLOG.md`](https://github.com/kilostudio/kilostudio.id) for the
|
|
174
265
|
full history of that extraction. Not yet done:
|
|
175
266
|
|
|
176
|
-
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
command).
|
|
267
|
+
- A single catch-all admin route instead of one thin file per page (deliberately skipped for
|
|
268
|
+
now — today's thin per-page files are already minimal; a hand-rolled catch-all router would
|
|
269
|
+
trade that for real complexity with no proven need yet).
|
|
180
270
|
- A second, independent consumer project to prove the install story beyond the one app this
|
|
181
271
|
was extracted from.
|
|
182
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
|
+
|
|
183
281
|
## License
|
|
184
282
|
|
|
185
|
-
Proprietary — all rights reserved.
|
|
186
|
-
|
|
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.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",
|
|
@@ -39,6 +39,8 @@
|
|
|
39
39
|
"./dashboard": "./src/dashboard.ts",
|
|
40
40
|
"./routes/admin/*": "./src/routes/admin/*/route.ts",
|
|
41
41
|
"./routes/*": "./src/routes/*.ts",
|
|
42
|
+
"./middleware": "./src/middleware.ts",
|
|
43
|
+
"./admin/require-session": "./src/admin/require-session.ts",
|
|
42
44
|
"./admin/*": "./src/admin/*.tsx",
|
|
43
45
|
"./admin.css": "./src/admin/admin.css",
|
|
44
46
|
"./globals.css": "./src/admin/globals.css"
|
|
@@ -1,9 +1,7 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { getSessionWithPermissions } from '../../permissions'
|
|
1
|
+
import { requireAdminSession } from '../require-session'
|
|
3
2
|
import { AiSettingsEditor } from '../AiSettingsEditor'
|
|
4
3
|
|
|
5
4
|
export default async function AiSettingsPage() {
|
|
6
|
-
const result = await
|
|
7
|
-
if (!result || !result.can('settings', 'read')) redirect('/admin/login')
|
|
5
|
+
const result = await requireAdminSession((r) => r.can('settings', 'read'), '/admin/login')
|
|
8
6
|
return <AiSettingsEditor userName={result.session.user.name} canManageAi={result.hasSystem('ai.manage')} />
|
|
9
7
|
}
|
|
@@ -1,10 +1,7 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { getSessionWithPermissions } from '../../permissions'
|
|
1
|
+
import { requireAdminSession } from '../require-session'
|
|
3
2
|
import { ApiTokensEditor } from '../ApiTokensEditor'
|
|
4
3
|
|
|
5
4
|
export default async function ApiTokensPage() {
|
|
6
|
-
const result = await
|
|
7
|
-
if (!result) redirect('/admin/login')
|
|
8
|
-
if (!result.hasSystem('developers.manage')) redirect('/admin/settings')
|
|
5
|
+
const result = await requireAdminSession((r) => r.hasSystem('developers.manage'), '/admin/settings')
|
|
9
6
|
return <ApiTokensEditor userName={result.session.user.name} />
|
|
10
7
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { notFound
|
|
2
|
-
import {
|
|
1
|
+
import { notFound } from 'next/navigation'
|
|
2
|
+
import { requireAdminSession } from '../require-session'
|
|
3
3
|
import { getCollection } from '../../collections'
|
|
4
4
|
import { CollectionListEditor } from '../CollectionListEditor'
|
|
5
5
|
|
|
@@ -7,9 +7,7 @@ export default async function CollectionListPage({ params }: { params: Promise<{
|
|
|
7
7
|
const { collection } = await params
|
|
8
8
|
const config = getCollection(collection)
|
|
9
9
|
if (!config) notFound()
|
|
10
|
-
const result = await
|
|
11
|
-
if (!result) redirect('/admin/login')
|
|
12
|
-
if (!result.can(config.slug, 'read')) redirect('/admin/dashboard')
|
|
10
|
+
const result = await requireAdminSession((r) => r.can(config.slug, 'read'), '/admin/dashboard')
|
|
13
11
|
return (
|
|
14
12
|
<CollectionListEditor
|
|
15
13
|
config={config}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { notFound
|
|
2
|
-
import {
|
|
1
|
+
import { notFound } from 'next/navigation'
|
|
2
|
+
import { requireAdminSession } from '../require-session'
|
|
3
3
|
import { getCollection } from '../../collections'
|
|
4
4
|
import { CollectionRecordEditor } from '../CollectionRecordEditor'
|
|
5
5
|
|
|
@@ -7,9 +7,7 @@ export default async function CollectionRecordPage({ params }: { params: Promise
|
|
|
7
7
|
const { collection, id } = await params
|
|
8
8
|
const config = getCollection(collection)
|
|
9
9
|
if (!config) notFound()
|
|
10
|
-
const result = await
|
|
11
|
-
if (!result) redirect('/admin/login')
|
|
12
|
-
if (!result.can(config.slug, 'read')) redirect('/admin/dashboard')
|
|
10
|
+
const result = await requireAdminSession((r) => r.can(config.slug, 'read'), '/admin/dashboard')
|
|
13
11
|
return (
|
|
14
12
|
<CollectionRecordEditor
|
|
15
13
|
config={config}
|
|
@@ -1,10 +1,8 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { getSessionWithPermissions } from '../../permissions'
|
|
1
|
+
import { requireAdminSession } from '../require-session'
|
|
3
2
|
import { getDashboardSources } from '../../dashboard'
|
|
4
3
|
import { DashboardBuilder } from '../DashboardBuilder'
|
|
5
4
|
|
|
6
5
|
export default async function DashboardPage() {
|
|
7
|
-
const result = await
|
|
8
|
-
if (!result || !result.can('settings', 'read')) redirect('/admin/login')
|
|
6
|
+
const result = await requireAdminSession((r) => r.can('settings', 'read'), '/admin/login')
|
|
9
7
|
return <DashboardBuilder userName={result.session.user.name} canUpdate={result.can('settings', 'update')} dashboardSources={getDashboardSources()} />
|
|
10
8
|
}
|
|
@@ -1,10 +1,7 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { getSessionWithPermissions } from '../../permissions'
|
|
1
|
+
import { requireAdminSession } from '../require-session'
|
|
3
2
|
import { EmailSettingsEditor } from '../EmailSettingsEditor'
|
|
4
3
|
|
|
5
4
|
export default async function EmailSettingsPage() {
|
|
6
|
-
const result = await
|
|
7
|
-
if (!result) redirect('/admin/login')
|
|
8
|
-
if (!result.hasSystem('developers.manage')) redirect('/admin/settings')
|
|
5
|
+
const result = await requireAdminSession((r) => r.hasSystem('developers.manage'), '/admin/settings')
|
|
9
6
|
return <EmailSettingsEditor userName={result.session.user.name} />
|
|
10
7
|
}
|
|
@@ -1,10 +1,7 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { getSessionWithPermissions } from '../../permissions'
|
|
1
|
+
import { requireAdminSession } from '../require-session'
|
|
3
2
|
import { LocalesEditor } from '../LocalesEditor'
|
|
4
3
|
|
|
5
4
|
export default async function LocalesPage() {
|
|
6
|
-
const result = await
|
|
7
|
-
if (!result) redirect('/admin/login')
|
|
8
|
-
if (!result.hasSystem('locales.manage')) redirect('/admin/settings')
|
|
5
|
+
const result = await requireAdminSession((r) => r.hasSystem('locales.manage'), '/admin/settings')
|
|
9
6
|
return <LocalesEditor userName={result.session.user.name} />
|
|
10
7
|
}
|
|
@@ -1,9 +1,7 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { getSessionWithPermissions } from '../../permissions'
|
|
1
|
+
import { requireAdminSession } from '../require-session'
|
|
3
2
|
import { MediaLibrary } from '../MediaLibrary'
|
|
4
3
|
|
|
5
4
|
export default async function MediaPage() {
|
|
6
|
-
const result = await
|
|
7
|
-
if (!result) redirect('/admin/login')
|
|
5
|
+
const result = await requireAdminSession()
|
|
8
6
|
return <MediaLibrary userName={result.session.user.name} />
|
|
9
7
|
}
|
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
import { redirect } from 'next/navigation'
|
|
2
|
-
import {
|
|
2
|
+
import { requireAdminSession } from '../require-session'
|
|
3
3
|
import { getCollection, isCollectionSlug, reviewableSlugs, type CollectionSlug } from '../../collections'
|
|
4
4
|
import { ReviewQueueEditor, type ReviewCollectionMeta } from '../ReviewQueueEditor'
|
|
5
5
|
|
|
6
6
|
export default async function ReviewQueuePage() {
|
|
7
|
-
const result = await
|
|
8
|
-
if (!result) redirect('/admin/login')
|
|
7
|
+
const result = await requireAdminSession()
|
|
9
8
|
const reviewSlugs = reviewableSlugs.filter((slug): slug is CollectionSlug => isCollectionSlug(slug))
|
|
10
9
|
const canReview = Object.fromEntries(reviewSlugs.map((slug) => [slug, result.can(slug, 'review')])) as Record<CollectionSlug, boolean>
|
|
11
10
|
const canApprove = Object.fromEntries(reviewSlugs.map((slug) => [slug, result.can(slug, 'approve')])) as Record<CollectionSlug, boolean>
|
|
@@ -1,11 +1,8 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { getSessionWithPermissions } from '../../permissions'
|
|
1
|
+
import { requireAdminSession } from '../require-session'
|
|
3
2
|
import { RoleEditor } from '../RoleEditor'
|
|
4
3
|
|
|
5
4
|
export default async function RoleEditPage({ params }: { params: Promise<{ id: string }> }) {
|
|
6
|
-
const result = await
|
|
7
|
-
if (!result) redirect('/admin/login')
|
|
8
|
-
if (!result.hasSystem('roles.manage')) redirect('/admin')
|
|
5
|
+
const result = await requireAdminSession((r) => r.hasSystem('roles.manage'), '/admin')
|
|
9
6
|
const { id } = await params
|
|
10
7
|
return <RoleEditor id={id} userName={result.session.user.name} />
|
|
11
8
|
}
|
|
@@ -1,10 +1,7 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { getSessionWithPermissions } from '../../permissions'
|
|
1
|
+
import { requireAdminSession } from '../require-session'
|
|
3
2
|
import { RolesEditor } from '../RolesEditor'
|
|
4
3
|
|
|
5
4
|
export default async function RolesPage() {
|
|
6
|
-
const result = await
|
|
7
|
-
if (!result) redirect('/admin/login')
|
|
8
|
-
if (!result.hasSystem('roles.manage')) redirect('/admin')
|
|
5
|
+
const result = await requireAdminSession((r) => r.hasSystem('roles.manage'), '/admin')
|
|
9
6
|
return <RolesEditor userName={result.session.user.name} />
|
|
10
7
|
}
|
|
@@ -1,12 +1,10 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { getSessionWithPermissions } from '../../permissions'
|
|
1
|
+
import { requireAdminSession } from '../require-session'
|
|
3
2
|
import { getCollection, schedulingSlugs, type CollectionSlug } from '../../collections'
|
|
4
3
|
import { ScheduledEditor, type ScheduledCollectionMeta } from '../ScheduledEditor'
|
|
5
4
|
|
|
6
5
|
export default async function ScheduledPage() {
|
|
7
|
-
const result = await
|
|
8
|
-
const readableSlugs = schedulingSlugs.filter((slug): slug is CollectionSlug =>
|
|
9
|
-
if (!result || !readableSlugs.length) redirect('/admin/login')
|
|
6
|
+
const result = await requireAdminSession((r) => schedulingSlugs.some((slug) => r.can(slug, 'read')), '/admin/login')
|
|
7
|
+
const readableSlugs = schedulingSlugs.filter((slug): slug is CollectionSlug => result.can(slug, 'read'))
|
|
10
8
|
const meta: ScheduledCollectionMeta = Object.fromEntries(
|
|
11
9
|
readableSlugs.map((slug) => {
|
|
12
10
|
const config = getCollection(slug)!
|
|
@@ -1,9 +1,7 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { getSessionWithPermissions } from '../../permissions'
|
|
1
|
+
import { requireAdminSession } from '../require-session'
|
|
3
2
|
import { SeoSettingsEditor } from '../SeoSettingsEditor'
|
|
4
3
|
|
|
5
4
|
export default async function SeoSettingsPage() {
|
|
6
|
-
const result = await
|
|
7
|
-
if (!result || !result.can('settings', 'read')) redirect('/admin/login')
|
|
5
|
+
const result = await requireAdminSession((r) => r.can('settings', 'read'), '/admin/login')
|
|
8
6
|
return <SeoSettingsEditor userName={result.session.user.name} />
|
|
9
7
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { notFound
|
|
2
|
-
import {
|
|
1
|
+
import { notFound } from 'next/navigation'
|
|
2
|
+
import { requireAdminSession } from '../require-session'
|
|
3
3
|
import { getSingle } from '../../collections'
|
|
4
4
|
import { SingleRecordEditor } from '../SingleRecordEditor'
|
|
5
5
|
|
|
@@ -7,9 +7,7 @@ export default async function SingleRecordPage({ params }: { params: Promise<{ t
|
|
|
7
7
|
const { type } = await params
|
|
8
8
|
const config = getSingle(type)
|
|
9
9
|
if (!config) notFound()
|
|
10
|
-
const result = await
|
|
11
|
-
if (!result) redirect('/admin/login')
|
|
12
|
-
if (!result.can(config.slug, 'read')) redirect('/admin/dashboard')
|
|
10
|
+
const result = await requireAdminSession((r) => r.can(config.slug, 'read'), '/admin/dashboard')
|
|
13
11
|
return (
|
|
14
12
|
<SingleRecordEditor
|
|
15
13
|
config={config}
|
|
@@ -1,9 +1,7 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { getSessionWithPermissions } from '../../permissions'
|
|
1
|
+
import { requireAdminSession } from '../require-session'
|
|
3
2
|
import { SiteSettingsEditor } from '../SiteSettingsEditor'
|
|
4
3
|
|
|
5
4
|
export default async function SettingsPage() {
|
|
6
|
-
const result = await
|
|
7
|
-
if (!result || !result.can('settings', 'read')) redirect('/admin/login')
|
|
5
|
+
const result = await requireAdminSession((r) => r.can('settings', 'read'), '/admin/login')
|
|
8
6
|
return <SiteSettingsEditor userName={result.session.user.name} />
|
|
9
7
|
}
|
|
@@ -1,11 +1,8 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { getSessionWithPermissions } from '../../permissions'
|
|
1
|
+
import { requireAdminSession } from '../require-session'
|
|
3
2
|
import { UserEditor } from '../UserEditor'
|
|
4
3
|
|
|
5
4
|
export default async function UserEditPage({ params }: { params: Promise<{ id: string }> }) {
|
|
6
|
-
const result = await
|
|
7
|
-
if (!result) redirect('/admin/login')
|
|
8
|
-
if (!result.hasSystem('users.manage')) redirect('/admin')
|
|
5
|
+
const result = await requireAdminSession((r) => r.hasSystem('users.manage'), '/admin')
|
|
9
6
|
const { id } = await params
|
|
10
7
|
return <UserEditor id={id} userName={result.session.user.name} />
|
|
11
8
|
}
|
|
@@ -1,10 +1,7 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { getSessionWithPermissions } from '../../permissions'
|
|
1
|
+
import { requireAdminSession } from '../require-session'
|
|
3
2
|
import { UserEditor } from '../UserEditor'
|
|
4
3
|
|
|
5
4
|
export default async function NewUserPage() {
|
|
6
|
-
const result = await
|
|
7
|
-
if (!result) redirect('/admin/login')
|
|
8
|
-
if (!result.hasSystem('users.manage')) redirect('/admin')
|
|
5
|
+
const result = await requireAdminSession((r) => r.hasSystem('users.manage'), '/admin')
|
|
9
6
|
return <UserEditor id="new" userName={result.session.user.name} />
|
|
10
7
|
}
|
|
@@ -1,10 +1,7 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { getSessionWithPermissions } from '../../permissions'
|
|
1
|
+
import { requireAdminSession } from '../require-session'
|
|
3
2
|
import { UsersEditor } from '../UsersEditor'
|
|
4
3
|
|
|
5
4
|
export default async function UsersPage() {
|
|
6
|
-
const result = await
|
|
7
|
-
if (!result) redirect('/admin/login')
|
|
8
|
-
if (!result.hasSystem('users.manage')) redirect('/admin')
|
|
5
|
+
const result = await requireAdminSession((r) => r.hasSystem('users.manage'), '/admin')
|
|
9
6
|
return <UsersEditor userName={result.session.user.name} />
|
|
10
7
|
}
|
|
@@ -1,10 +1,7 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { getSessionWithPermissions } from '../../permissions'
|
|
1
|
+
import { requireAdminSession } from '../require-session'
|
|
3
2
|
import { WebhooksEditor } from '../WebhooksEditor'
|
|
4
3
|
|
|
5
4
|
export default async function WebhooksPage() {
|
|
6
|
-
const result = await
|
|
7
|
-
if (!result) redirect('/admin/login')
|
|
8
|
-
if (!result.hasSystem('developers.manage')) redirect('/admin/settings')
|
|
5
|
+
const result = await requireAdminSession((r) => r.hasSystem('developers.manage'), '/admin/settings')
|
|
9
6
|
return <WebhooksEditor userName={result.session.user.name} />
|
|
10
7
|
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { redirect } from 'next/navigation'
|
|
2
|
+
import { getSessionWithPermissions, type SessionWithPermissions } from '../permissions'
|
|
3
|
+
|
|
4
|
+
/** For Server Component admin pages only — `redirect()` from `next/navigation` only works
|
|
5
|
+
* inside the RSC render tree, never call this from a Route Handler (use `requireAction`/
|
|
6
|
+
* `requireSystem` from `../permissions` there instead, which return null on failure and let
|
|
7
|
+
* the route decide how to respond).
|
|
8
|
+
*
|
|
9
|
+
* Replaces the repeated
|
|
10
|
+
* const result = await getSessionWithPermissions()
|
|
11
|
+
* if (!result) redirect('/admin/login')
|
|
12
|
+
* if (!result.can(...)) redirect('/admin/dashboard')
|
|
13
|
+
* pattern with one call. `check` is optional — omit it for a page that only needs "is logged
|
|
14
|
+
* in" (its real authorization logic is more than a single predicate, e.g. it depends on data
|
|
15
|
+
* computed from the session first). */
|
|
16
|
+
export async function requireAdminSession(check?: (result: SessionWithPermissions) => boolean, redirectTo = '/admin/dashboard'): Promise<SessionWithPermissions> {
|
|
17
|
+
const result = await getSessionWithPermissions()
|
|
18
|
+
if (!result) redirect('/admin/login')
|
|
19
|
+
if (check && !check(result)) redirect(redirectTo)
|
|
20
|
+
return result
|
|
21
|
+
}
|
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
|
`
|
|
@@ -128,11 +262,92 @@ function migrate() {
|
|
|
128
262
|
console.log('\nRun your own `db:generate`/`db:migrate` next for this app\'s own content tables.')
|
|
129
263
|
}
|
|
130
264
|
|
|
265
|
+
// --- init -----------------------------------------------------------------------------------
|
|
266
|
+
|
|
267
|
+
function parseFlags(argv) {
|
|
268
|
+
const flags = {}
|
|
269
|
+
for (let i = 0; i < argv.length; i++) {
|
|
270
|
+
const arg = argv[i]
|
|
271
|
+
if (arg.startsWith('--')) flags[arg.slice(2)] = argv[i + 1]
|
|
272
|
+
}
|
|
273
|
+
return flags
|
|
274
|
+
}
|
|
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
|
+
|
|
303
|
+
async function init(argv) {
|
|
304
|
+
const flags = parseFlags(argv)
|
|
305
|
+
const addedSecrets = ensureEnvSecrets()
|
|
306
|
+
const url = flags.url ?? 'http://localhost:3000'
|
|
307
|
+
const token = flags.token ?? process.env.CMS_SETUP_TOKEN
|
|
308
|
+
const { name, email, password } = flags
|
|
309
|
+
|
|
310
|
+
if (!token) fail('missing --token (or set CMS_SETUP_TOKEN in your environment before running this).')
|
|
311
|
+
if (!name || !email || !password) fail('usage: kilo-cms init --name "..." --email you@example.com --password "..." [--url http://localhost:3000] [--token ...]')
|
|
312
|
+
|
|
313
|
+
migrate()
|
|
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
|
+
|
|
325
|
+
console.log(`\nCreating first admin at ${url}/api/setup/admin ...`)
|
|
326
|
+
console.log('(this requires your app to already be running — start it in another terminal first if it isn\'t.)')
|
|
327
|
+
|
|
328
|
+
let response
|
|
329
|
+
try {
|
|
330
|
+
response = await fetch(`${url}/api/setup/admin`, {
|
|
331
|
+
method: 'POST',
|
|
332
|
+
headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` },
|
|
333
|
+
body: JSON.stringify({ name, email, password }),
|
|
334
|
+
})
|
|
335
|
+
} catch (error) {
|
|
336
|
+
fail(`could not reach ${url} — is the app running? (${error instanceof Error ? error.message : String(error)})`)
|
|
337
|
+
return
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const body = await response.json().catch(() => null)
|
|
341
|
+
if (!response.ok) fail(`setup failed (${response.status}): ${body?.error ?? 'unknown error'}`)
|
|
342
|
+
|
|
343
|
+
console.log(`\nDone — admin user created (${email}). You can log in at ${url}/admin/login.`)
|
|
344
|
+
}
|
|
345
|
+
|
|
131
346
|
// --- dispatch -------------------------------------------------------------------------------
|
|
132
347
|
|
|
133
348
|
switch (command) {
|
|
134
349
|
case 'add-collection':
|
|
135
|
-
addCollection(args[0])
|
|
350
|
+
addCollection(args[0], parseFlags(args.slice(1)))
|
|
136
351
|
break
|
|
137
352
|
case 'sync':
|
|
138
353
|
sync()
|
|
@@ -140,10 +355,27 @@ switch (command) {
|
|
|
140
355
|
case 'migrate':
|
|
141
356
|
migrate()
|
|
142
357
|
break
|
|
358
|
+
case 'init':
|
|
359
|
+
await init(args)
|
|
360
|
+
break
|
|
143
361
|
default:
|
|
144
362
|
console.log(`kilo-cms — usage:
|
|
145
|
-
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"
|
|
146
375
|
kilo-cms sync regenerate .kilo/types.gen.ts from src/collections/*
|
|
147
|
-
kilo-cms migrate apply kilo-cms's own package-owned migrations
|
|
376
|
+
kilo-cms migrate apply kilo-cms's own package-owned migrations
|
|
377
|
+
kilo-cms init generate missing secrets into .env, migrate, then create
|
|
378
|
+
the first admin user
|
|
379
|
+
(--name, --email, --password, [--url], [--token or $CMS_SETUP_TOKEN])`)
|
|
148
380
|
process.exit(command ? 1 : 0)
|
|
149
381
|
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { NextResponse, type NextRequest } from 'next/server'
|
|
2
|
+
import { getSessionCookie } from 'better-auth/cookies'
|
|
3
|
+
|
|
4
|
+
/** Optimistic, Edge-safe redirect for unauthenticated requests to admin pages — a fast
|
|
5
|
+
* pre-filter based on cookie PRESENCE only (`getSessionCookie` reads the cookie, no DB call,
|
|
6
|
+
* safe to run in Edge middleware where a raw Postgres connection isn't available). This is
|
|
7
|
+
* NOT the real security boundary: a forged or expired cookie passes this check. Every admin
|
|
8
|
+
* page still calls `requireAdminSession()` (pages) or `requireAction`/`requireSystem` (API
|
|
9
|
+
* routes), which validate the session for real and check actual RBAC permissions — this only
|
|
10
|
+
* saves an unauthenticated visitor the round trip of hitting a page that would reject them
|
|
11
|
+
* anyway, and centralizes what used to be an `if (!result) redirect('/admin/login')` repeated
|
|
12
|
+
* on every single admin page.
|
|
13
|
+
*
|
|
14
|
+
* The host's own `middleware.ts` re-exports this as `middleware` and defines `config.matcher`
|
|
15
|
+
* itself (Next.js statically analyzes `matcher` at build time — it has to be a literal array
|
|
16
|
+
* in the file Next.js is scanning, not re-exported through another module). */
|
|
17
|
+
export function kiloAdminMiddleware(request: NextRequest): NextResponse {
|
|
18
|
+
const { pathname } = request.nextUrl
|
|
19
|
+
if (pathname === '/admin/login') return NextResponse.next()
|
|
20
|
+
|
|
21
|
+
const sessionCookie = getSessionCookie(request)
|
|
22
|
+
if (!sessionCookie) {
|
|
23
|
+
const url = request.nextUrl.clone()
|
|
24
|
+
url.pathname = '/admin/login'
|
|
25
|
+
return NextResponse.redirect(url)
|
|
26
|
+
}
|
|
27
|
+
return NextResponse.next()
|
|
28
|
+
}
|
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
|
+
}
|