@michaelthielemann/kestrel 1.2.1 → 1.4.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.
Files changed (51) hide show
  1. package/README.md +33 -3
  2. package/layers/admin/app/components/PageFields.vue +25 -0
  3. package/layers/admin/app/composables/useEditForm.ts +5 -0
  4. package/layers/admin/app/pages/admin/[collection]/[id].nuxt.test.ts +200 -0
  5. package/layers/auth/nuxt.config.ts +0 -0
  6. package/layers/auth/server/api/auth/session.get.ts +0 -0
  7. package/layers/auth/server/utils/password.ts +0 -0
  8. package/layers/auth/server/utils/session.ts +5 -2
  9. package/layers/collections/nuxt.config.ts +0 -0
  10. package/layers/core/app/composables/layouts.ts +5 -0
  11. package/layers/core/app/utils/layouts.ts +38 -0
  12. package/layers/core/modules/auto-discovery/index.ts +11 -1
  13. package/layers/core/modules/auto-discovery/virtual.d.ts +3 -0
  14. package/layers/core/modules/kestrel/app-shell.ts +55 -0
  15. package/layers/core/modules/kestrel/index.ts +17 -0
  16. package/layers/core/server/api/[collection]/[id]/translations.get.ts +0 -0
  17. package/layers/core/server/api/[collection]/options.get.test.ts +60 -0
  18. package/layers/core/server/api/[collection]/translations.get.test.ts +90 -0
  19. package/layers/core/server/utils/blocks.ts +0 -0
  20. package/layers/core/server/utils/seo.ts +0 -0
  21. package/layers/fields/nuxt.config.ts +0 -0
  22. package/layers/fields/server/utils/buildTable.ts +8 -2
  23. package/layers/media/server/api/media/[id].get.ts +0 -0
  24. package/layers/media/server/api/media/index.get.ts +0 -0
  25. package/layers/public/app/app.vue +0 -0
  26. package/layers/public/app/error.vue +0 -0
  27. package/layers/public/app/layouts/default.vue +0 -0
  28. package/layers/public/app/pages/[...slug].vue +31 -15
  29. package/layers/public/app/utils/page-layout.ts +18 -0
  30. package/layers/ui/app/assets/scss/_reset.scss +0 -0
  31. package/layers/ui/app/assets/scss/main.scss +0 -0
  32. package/layers/ui/app/components/ui/Alert.vue +0 -0
  33. package/layers/ui/app/i18n/de.ts +3 -0
  34. package/layers/ui/app/i18n/en.ts +3 -0
  35. package/package.json +6 -2
  36. package/scripts/copy-create-payload.mjs +49 -0
  37. package/scripts/hash-password.mjs +6 -15
  38. package/scripts/kestrel.mjs +216 -0
  39. package/scripts/lib/cli.mjs +114 -0
  40. package/scripts/lib/password.mjs +19 -0
  41. package/scripts/lib/scaffold.mjs +174 -0
  42. package/templates/starter/README.md +65 -0
  43. package/templates/starter/_env.example +17 -0
  44. package/templates/starter/_gitignore +26 -0
  45. package/templates/starter/_package.json +22 -0
  46. package/templates/starter/app/app.vue +7 -0
  47. package/templates/starter/app/blocks/Prose.vue +12 -0
  48. package/templates/starter/app/layouts/default.vue +6 -0
  49. package/templates/starter/nuxt.config.ts +20 -0
  50. package/templates/starter/pnpm-workspace.yaml +8 -0
  51. package/templates/starter/tsconfig.json +3 -0
@@ -0,0 +1,90 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest'
2
+ import { createError } from 'h3'
3
+ import Database from 'better-sqlite3'
4
+ import { drizzle } from 'drizzle-orm/better-sqlite3'
5
+ import { buildCollection } from '../../../../fields/server/utils/buildCollection'
6
+ import { defineCollection } from '../../utils/defineCollection'
7
+ import { create, resolveTranslations } from '../../utils/crud'
8
+ import { requireCollection } from '../../utils/http'
9
+ import { clearRegistry, registerCollection } from '../../utils/registry'
10
+ import { desiredSchema } from '../../schema/desired'
11
+ import { diffSchema } from '../../schema/diff'
12
+ import { renderSqlite } from '../../schema/render-sqlite'
13
+
14
+ const pages = buildCollection(defineCollection({
15
+ name: 'pages', mode: 'multi', translatable: true, pageLike: true,
16
+ fields: { title: { type: 'text', required: true } },
17
+ }))
18
+ const notes = buildCollection(defineCollection({
19
+ name: 'notes', mode: 'multi', translatable: false,
20
+ fields: { title: { type: 'text', required: true } },
21
+ }))
22
+
23
+ // `context.params` is where Nitro puts the route params the real `requireCollection` reads via h3.
24
+ interface FakeEvent { query: Record<string, unknown>; context: { params: Record<string, string> } }
25
+
26
+ let db: ReturnType<typeof drizzle>
27
+
28
+ // The handler is a Nitro route: its auto-imported helpers are plain globals in a node test — bound to the
29
+ // REAL implementations so nothing here can pass against a stub the server does not have.
30
+ Object.assign(globalThis, {
31
+ defineEventHandler: (handler: unknown) => handler,
32
+ createError,
33
+ getQuery: (event: FakeEvent) => event.query,
34
+ useDb: () => db,
35
+ requireCollection,
36
+ resolveTranslations,
37
+ })
38
+
39
+ // Calling the handler directly says nothing about the path ever reaching it: that the literal `translations`
40
+ // segment wins over the sibling `[id]` route is only provable on a real server → `test/e2e/api.test.ts`.
41
+ const handler = (await import('./translations.get')).default as unknown as (event: FakeEvent) => Record<string, number | null>
42
+ const get = (collection: string, query: Record<string, unknown>) => handler({ query, context: { params: { collection } } })
43
+
44
+ beforeEach(() => {
45
+ const sqlite = new Database(':memory:')
46
+ for (const stmt of renderSqlite(diffSchema(desiredSchema([pages.table, notes.table]), {}))) sqlite.exec(stmt)
47
+ db = drizzle(sqlite)
48
+ clearRegistry()
49
+ registerCollection(pages)
50
+ registerCollection(notes)
51
+ })
52
+ afterEach(() => clearRegistry())
53
+
54
+ describe('GET /api/{collection}/translations?group=', () => {
55
+ it('resolves the group\'s locale → sibling id map without a record id', () => {
56
+ const en = create(db, pages, { title: 'Home' }) as Record<string, unknown>
57
+ const de = create(db, pages, { title: 'Start', locale: 'de', translationGroup: en.translationGroup as string }) as Record<string, unknown>
58
+ expect(get('pages', { group: en.translationGroup })).toEqual({ en: en.id, de: de.id })
59
+ })
60
+
61
+ it('reports a locale with no sibling as null (what the "+ create" affordance keys off)', () => {
62
+ const en = create(db, pages, { title: 'Only EN' }) as Record<string, unknown>
63
+ expect(get('pages', { group: en.translationGroup })).toEqual({ en: en.id, de: null })
64
+ })
65
+
66
+ it('returns exactly the per-record map for the same group (one map builder, two entry points)', () => {
67
+ const en = create(db, pages, { title: 'Home' }) as Record<string, unknown>
68
+ create(db, pages, { title: 'Start', locale: 'de', translationGroup: en.translationGroup as string })
69
+ expect(get('pages', { group: en.translationGroup })).toEqual(resolveTranslations(db, pages, en.id as number))
70
+ })
71
+
72
+ it('400s without a group rather than answering for an arbitrary one', () => {
73
+ expect(() => get('pages', {})).toThrowError(expect.objectContaining({ statusCode: 400 }))
74
+ expect(() => get('pages', { group: ' ' })).toThrowError(expect.objectContaining({ statusCode: 400 }))
75
+ })
76
+
77
+ it('404s for a group that has no rows', () => {
78
+ create(db, pages, { title: 'Home' })
79
+ expect(() => get('pages', { group: 'nope' })).toThrowError(expect.objectContaining({ statusCode: 404 }))
80
+ })
81
+
82
+ it('400s (never 500s) for a collection without translations — it has no group column at all', () => {
83
+ create(db, notes, { title: 'Note' })
84
+ expect(() => get('notes', { group: 'g1' })).toThrowError(/Translations are not enabled/)
85
+ })
86
+
87
+ it('404s for an unknown collection', () => {
88
+ expect(() => get('nope', { group: 'g1' })).toThrowError(expect.objectContaining({ statusCode: 404 }))
89
+ })
90
+ })
File without changes
File without changes
File without changes
@@ -17,7 +17,7 @@ function reservedColumns(def: CollectionDef): { js: Set<string>; db: Set<string>
17
17
  if (def.translatable) add('locale', 'locale')
18
18
  if (def.mode === 'single') add('singletonKey', 'singleton_key')
19
19
  else if (def.translatable) add('translationGroup', 'translation_group')
20
- if (def.pageLike) add('path', 'path')
20
+ if (def.pageLike) { add('path', 'path'); add('layout', 'layout') }
21
21
  if (def.status) add('status', 'status')
22
22
  if (def.seo) add('seo', 'seo')
23
23
  if (def.blocks?.enabled) add('content', 'content')
@@ -67,7 +67,13 @@ export function buildTable(def: CollectionDef): SQLiteTable {
67
67
  if (def.translatable) cols.locale = text('locale').notNull()
68
68
  if (def.mode === 'single') cols.singletonKey = text('singleton_key').notNull()
69
69
  else if (def.translatable) cols.translationGroup = text('translation_group').notNull()
70
- if (def.pageLike) cols.path = text('path')
70
+ if (def.pageLike) {
71
+ cols.path = text('path')
72
+ // Nullable with no default: an editor's "inherit" must be distinguishable from an explicit `default`,
73
+ // and the render decides the fallback (see resolvePageLayout) so a deleted layout file degrades in one
74
+ // place instead of being frozen into every row.
75
+ cols.layout = text('layout')
76
+ }
71
77
  if (def.status) cols.status = text('status').notNull().default('draft')
72
78
  if (def.seo) cols.seo = text('seo', { mode: 'json' }).$type<SeoMeta>().notNull().default(sql`'{}'`)
73
79
  if (def.blocks?.enabled) cols.content = text('content', { mode: 'json' }).$type<Block[]>().notNull().default(sql`'[]'`)
File without changes
File without changes
File without changes
File without changes
File without changes
@@ -1,6 +1,14 @@
1
1
  <script setup lang="ts">
2
+ import type { LayoutKey } from 'nuxt/app'
3
+
4
+ // The record decides its own layout, so route-meta resolution is opted out of and this page renders the
5
+ // `<NuxtLayout>` itself. Side effect worth knowing: the layout becomes a CHILD of the page, so it can read
6
+ // `usePublicPageState()` during SSR — as its parent it rendered before the page had written it.
7
+ definePageMeta({ layout: false })
8
+
2
9
  interface RenderedPage {
3
10
  title?: string
11
+ layout?: string | null
4
12
  seo?: {
5
13
  title?: string
6
14
  description?: string
@@ -36,6 +44,12 @@ const { data: resolved } = await useAsyncData(`page:${locale}:${path}`, () =>
36
44
  }),
37
45
  )
38
46
  const page = computed(() => resolved.value?.page ?? null)
47
+ // `fallback` below only rescues a truthy name that is missing from the layout map, so the empty cases have
48
+ // to be coalesced here — see resolvePageLayout. The cast is the one honest bridge in this file: the stored
49
+ // name is arbitrary editor data, while `NuxtLayout` types `name` as the union of layouts that existed at
50
+ // build time. Narrowing to that union is impossible for a value read from the DB, and `fallback` is exactly
51
+ // the runtime guard for a name outside it.
52
+ const pageLayout = computed(() => resolvePageLayout(page.value?.layout) as LayoutKey)
39
53
 
40
54
  // The layout (language menu & co.) needs the resolved record and its collection; pages and layouts share
41
55
  // no other channel, so mirror the fetch result into the shared state — reactively, so client-side
@@ -113,21 +127,23 @@ useSeoMeta({
113
127
  </script>
114
128
 
115
129
  <template>
116
- <article>
117
- <!-- Only ever shown to an authenticated admin previewing an unpublished page (drafts never resolve
118
- for anonymous visitors or the static render), so it never ships to the public/static site.
119
- Suppressed inside the editor preview iframe the editor's own status ampel covers it. -->
120
- <div v-if="isDraftPreview && !previewActive" class="kestrel-draft-badge" role="status">
121
- <span class="kestrel-draft-badge__dot" aria-hidden="true" />
122
- Draft preview not published
123
- </div>
124
- <!-- Editor preview: the bridge swaps in the editor's live (unsaved) tree over postMessage and makes
125
- blocks selectable; the saved content renders until the first message. Normal path unchanged. -->
126
- <LazyKestrelPreviewBridge v-if="previewActive" :blocks="(page?.content as any[]) ?? []" v-slot="{ blocks }">
127
- <BlockRenderer :blocks="(blocks as any[])" />
128
- </LazyKestrelPreviewBridge>
129
- <BlockRenderer v-else :blocks="(page?.content as any[]) ?? []" />
130
- </article>
130
+ <NuxtLayout :name="pageLayout" fallback="default">
131
+ <article>
132
+ <!-- Only ever shown to an authenticated admin previewing an unpublished page (drafts never resolve
133
+ for anonymous visitors or the static render), so it never ships to the public/static site.
134
+ Suppressed inside the editor preview iframe — the editor's own status ampel covers it. -->
135
+ <div v-if="isDraftPreview && !previewActive" class="kestrel-draft-badge" role="status">
136
+ <span class="kestrel-draft-badge__dot" aria-hidden="true" />
137
+ Draft preview — not published
138
+ </div>
139
+ <!-- Editor preview: the bridge swaps in the editor's live (unsaved) tree over postMessage and makes
140
+ blocks selectable; the saved content renders until the first message. Normal path unchanged. -->
141
+ <LazyKestrelPreviewBridge v-if="previewActive" :blocks="(page?.content as any[]) ?? []" v-slot="{ blocks }">
142
+ <BlockRenderer :blocks="(blocks as any[])" />
143
+ </LazyKestrelPreviewBridge>
144
+ <BlockRenderer v-else :blocks="(page?.content as any[]) ?? []" />
145
+ </article>
146
+ </NuxtLayout>
131
147
  </template>
132
148
 
133
149
  <style scoped>
@@ -0,0 +1,18 @@
1
+ /** The layout every page falls back to. Nuxt's own name for the unnamed layout, and the one the public
2
+ * layer ships (`layers/public/app/layouts/default.vue`), so it is always present. */
3
+ export const DEFAULT_LAYOUT = 'default'
4
+
5
+ /**
6
+ * The layout name to render a page in, from its stored `layout` column.
7
+ *
8
+ * Must never return an empty value. The catch-all declares `definePageMeta({ layout: false })` so the page
9
+ * owns its own `<NuxtLayout>`; that makes `route.meta.layout` the literal `false`, and NuxtLayout resolves
10
+ * `unref(props.name) ?? route.meta.layout ?? …` — `??` keeps `false`, which fails its `hasLayout` check and
11
+ * renders the page with NO layout wrapper. The `fallback` prop does not cover this: it only applies to a
12
+ * truthy name absent from the layout map. Coalescing here is what keeps an unset column rendering the
13
+ * normal site frame.
14
+ */
15
+ export function resolvePageLayout(stored: string | null | undefined): string {
16
+ const name = typeof stored === 'string' ? stored.trim() : ''
17
+ return name || DEFAULT_LAYOUT
18
+ }
File without changes
File without changes
File without changes
@@ -133,6 +133,9 @@ export const de: Catalog = {
133
133
 
134
134
  'pageSettings.slugLabel': 'Slug',
135
135
  'pageSettings.slugHint': 'URL-Pfad, z. B. /about (leer = automatisch aus dem Titel)',
136
+ 'pageSettings.layoutLabel': 'Layout',
137
+ 'pageSettings.layoutHint': 'Mit welchem Layout diese Seite gerendert wird.',
138
+ 'pageSettings.layoutDefault': 'Standard (default)',
136
139
  'pageSettings.statusLabel': 'Status',
137
140
  'pageSettings.statusHint': 'Entwurf bleibt offline; Veröffentlicht rendert die Seite.',
138
141
  'pageSettings.statusDraft': 'Entwurf',
@@ -144,6 +144,9 @@ export const en: Catalog = {
144
144
  // page settings (system fields on pageLike collections)
145
145
  'pageSettings.slugLabel': 'Slug',
146
146
  'pageSettings.slugHint': 'URL path, e.g. /about (blank = auto-generated from the title)',
147
+ 'pageSettings.layoutLabel': 'Layout',
148
+ 'pageSettings.layoutHint': 'Which layout renders this page.',
149
+ 'pageSettings.layoutDefault': 'Standard (default)',
147
150
  'pageSettings.statusLabel': 'Status',
148
151
  'pageSettings.statusHint': 'Draft stays off the live site; Published renders it.',
149
152
  'pageSettings.statusDraft': 'Draft',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@michaelthielemann/kestrel",
3
- "version": "1.2.1",
3
+ "version": "1.4.0",
4
4
  "description": "A slim, collection-driven Nuxt 4 CMS meta-layer with a runtime schema engine. Add `extends: ['@michaelthielemann/kestrel']`, define collections, and the database migrates itself.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Michael Thielemann <283621694+MichaelThielemann@users.noreply.github.com>",
@@ -21,18 +21,22 @@
21
21
  ],
22
22
  "type": "module",
23
23
  "main": "./nuxt.config.ts",
24
+ "bin": {
25
+ "kestrel": "./scripts/kestrel.mjs"
26
+ },
24
27
  "packageManager": "pnpm@11.9.0",
25
28
  "publishConfig": {
26
29
  "access": "public",
27
30
  "provenance": true
28
31
  },
29
- "//publish": "Consumed as a meta-layer: `extends: ['@michaelthielemann/kestrel']`. `main` points at the layer's nuxt.config so the BARE specifier resolves — without it c12 can't resolve the package, silently drops the whole layer (\"Cannot extend config from …\"), and every auto-import/component vanishes. Use `main`, NOT `exports` (exports would gate subpaths like `@michaelthielemann/kestrel/scripts/hash-password.mjs` + the deep config-type import). `files` ships the entry + sub-layers + operator scripts; the `!**/*.test.ts` globs strip tests. Releases run from the tag workflow via npm trusted publishing (OIDC), so no npm token exists anywhere.",
32
+ "//publish": "Consumed as a meta-layer: `extends: ['@michaelthielemann/kestrel']`. `main` points at the layer's nuxt.config so the BARE specifier resolves — without it c12 can't resolve the package, silently drops the whole layer (\"Cannot extend config from …\"), and every auto-import/component vanishes. Use `main`, NOT `exports` (exports would gate subpaths like `@michaelthielemann/kestrel/scripts/hash-password.mjs` + the deep config-type import); `bin` resolves by path from the package root, so it coexists with `main` untouched. `files` ships the entry + sub-layers + operator scripts + the `kestrel init` templates; the `!**/*.test.ts` globs strip tests — they are GLOBAL, so nothing under `templates/` may be named `*.test.ts` or it vanishes from the tarball. Template dotfiles are `_`-prefixed for the same class of reason: npm strips a literal `.gitignore` from a tarball and then applies it, taking its listed siblings with it. Releases run from the tag workflow via npm trusted publishing (OIDC), so no npm token exists anywhere.",
30
33
  "files": [
31
34
  "NOTICE",
32
35
  "nuxt.config.ts",
33
36
  "kestrel.config.ts",
34
37
  "layers",
35
38
  "scripts",
39
+ "templates",
36
40
  "!**/*.test.ts",
37
41
  "!**/*.dom.test.ts",
38
42
  "!**/*.nuxt.test.ts"
@@ -0,0 +1,49 @@
1
+ import { cpSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
2
+ import { dirname, join, resolve } from 'node:path'
3
+ import { fileURLToPath } from 'node:url'
4
+
5
+ // create-kestrel ships copies of the engine's templates + scaffold lib. Generating them at pack time
6
+ // keeps one source of truth; committing them would let the two drift. See ADR-0005.
7
+ const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
8
+ const PKG = join(ROOT, 'packages/create-kestrel')
9
+ const LIB_FILES = ['scaffold.mjs', 'password.mjs', 'cli.mjs']
10
+ const GENERATED = ['templates', 'lib', 'LICENSE', 'NOTICE']
11
+
12
+ const manifestPath = join(PKG, 'package.json')
13
+
14
+ const clean = () => {
15
+ for (const entry of GENERATED) rmSync(join(PKG, entry), { recursive: true, force: true })
16
+ }
17
+
18
+ if (process.argv.includes('--clean')) {
19
+ clean()
20
+ } else {
21
+ clean()
22
+ const engine = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8'))
23
+ const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'))
24
+
25
+ // The version cannot be corrected here: npm derives the tarball name from the manifest as it stood
26
+ // BEFORE prepack, so a rewrite yields a tarball whose name and contents disagree. Refuse instead —
27
+ // test/create-kestrel.test.ts keeps the two manifests in lockstep so this never fires in practice.
28
+ if (manifest.version !== engine.version) {
29
+ console.error(`create-kestrel is at ${manifest.version} but the engine is at ${engine.version} — bump both.`)
30
+ process.exit(1)
31
+ }
32
+
33
+ cpSync(join(ROOT, 'templates'), join(PKG, 'templates'), { recursive: true })
34
+ mkdirSync(join(PKG, 'lib'), { recursive: true })
35
+ for (const file of LIB_FILES) cpSync(join(ROOT, 'scripts/lib', file), join(PKG, 'lib', file))
36
+ for (const file of ['LICENSE', 'NOTICE']) {
37
+ if (existsSync(join(ROOT, file))) cpSync(join(ROOT, file), join(PKG, file))
38
+ }
39
+
40
+ // A generated file, not a key in the committed manifest: `postpack` is not guaranteed to run (a failed
41
+ // publish skips it), and a stamp left behind in package.json would be committed and then silently pin
42
+ // stale ranges. Deleting `lib/` removes this with everything else.
43
+ const stamp = {
44
+ nuxt: engine.dependencies?.nuxt,
45
+ typescript: engine.dependencies?.typescript,
46
+ 'vue-tsc': engine.devDependencies?.['vue-tsc'],
47
+ }
48
+ writeFileSync(join(PKG, 'lib', 'engine-meta.mjs'), `export default ${JSON.stringify(stamp, null, 2)}\n`)
49
+ }
@@ -1,26 +1,17 @@
1
- import { scryptSync, randomBytes } from 'node:crypto'
2
1
  import { createInterface } from 'node:readline'
2
+ import { hashPassword } from './lib/password.mjs'
3
3
 
4
- const N = 2 ** 17
5
- const r = 8
6
- const p = 1
7
- const KEYLEN = 64
8
- const MAXMEM = 256 * 1024 * 1024
9
-
10
- function make(password) {
11
- const salt = randomBytes(16)
12
- const hash = scryptSync(password, salt, KEYLEN, { N, r, p, maxmem: MAXMEM })
13
- return `scrypt$${N}$${r}$${p}$${salt.toString('base64url')}$${hash.toString('base64url')}`
14
- }
15
-
4
+ // Not used at runtime: this prints a value an operator pastes into KESTREL_ADMIN_PASSWORD_HASH. Kept as a
5
+ // standalone entry point because docs/consuming-kestrel.md tells consumers to run it straight out of
6
+ // node_modules, which must keep working whether or not the `kestrel` bin is on PATH.
16
7
  const arg = process.argv[2]
17
8
  if (arg) {
18
- process.stdout.write(make(arg) + '\n')
9
+ process.stdout.write(hashPassword(arg) + '\n')
19
10
  } else {
20
11
  const rl = createInterface({ input: process.stdin, terminal: false })
21
12
  process.stderr.write('Enter password, then press Enter:\n')
22
13
  rl.on('line', (line) => {
23
- process.stdout.write(make(line) + '\n')
14
+ process.stdout.write(hashPassword(line) + '\n')
24
15
  rl.close()
25
16
  })
26
17
  }
@@ -0,0 +1,216 @@
1
+ #!/usr/bin/env node
2
+ import { existsSync, mkdirSync, readFileSync } from 'node:fs'
3
+ import { join, resolve, basename, relative } from 'node:path'
4
+ import { fileURLToPath } from 'node:url'
5
+ import { createInterface } from 'node:readline/promises'
6
+ import { hashPassword, sessionSecret } from './lib/password.mjs'
7
+ import { PACKAGE_NAME, diagnoseProject, mergeEnv, mergePackageJson, renderTemplate, targetName, toPackageName } from './lib/scaffold.mjs'
8
+ import { Cancelled, MIN_PASSWORD_LENGTH, makePaint, out, parseArgs, promptPassword, readIf, readStdin, walk, write } from './lib/cli.mjs'
9
+
10
+ // Node builtins only, no build step: runs the same from a checkout, from node_modules and via `pnpm dlx`.
11
+ const PKG_ROOT = resolve(fileURLToPath(import.meta.url), '../..')
12
+ const TEMPLATES = join(PKG_ROOT, 'templates')
13
+ const pkg = JSON.parse(readFileSync(join(PKG_ROOT, 'package.json'), 'utf8'))
14
+
15
+ const { dim, bold, red, yellow, green } = makePaint()
16
+ const fail = (s) => {
17
+ process.stderr.write(`${red('error')} ${s}\n`)
18
+ process.exit(1)
19
+ }
20
+
21
+ const templateVars = (name) => ({
22
+ name,
23
+ version: `^${pkg.version}`,
24
+ nuxtVersion: pkg.dependencies?.nuxt ?? '^4.4.8',
25
+ typescriptVersion: pkg.dependencies?.typescript ?? '^6.0.3',
26
+ vueTscVersion: pkg.devDependencies?.['vue-tsc'] ?? '^3.3.7',
27
+ })
28
+
29
+ function inspect(target) {
30
+ return diagnoseProject({
31
+ packageJson: readIf(join(target, 'package.json')),
32
+ nuxtConfig: readIf(join(target, 'nuxt.config.ts')) ?? readIf(join(target, 'nuxt.config.js')),
33
+ appVue: readIf(join(target, 'app', 'app.vue')) ?? readIf(join(target, 'app.vue')),
34
+ env: readIf(join(target, '.env')),
35
+ })
36
+ }
37
+
38
+ function printFindings(found) {
39
+ for (const d of found) {
40
+ out(`${d.level === 'error' ? red('✖ error') : yellow('▲ warn ')} ${d.message}`)
41
+ out()
42
+ }
43
+ }
44
+
45
+ async function init(positional, flags) {
46
+ const target = resolve(positional[0] ?? '.')
47
+ const templateDir = join(TEMPLATES, 'starter')
48
+ if (!existsSync(templateDir)) fail(`template payload missing at ${templateDir} — reinstall ${PACKAGE_NAME}.`)
49
+
50
+ // Validate before touching disk: a half-scaffolded project is worse than a refused one.
51
+ const manifestPath = join(target, 'package.json')
52
+ const existingManifest = readIf(manifestPath)
53
+ if (existingManifest !== null) {
54
+ try {
55
+ JSON.parse(existingManifest)
56
+ } catch {
57
+ fail(`${manifestPath} is not valid JSON — fix it first; refusing to scaffold over a broken manifest.`)
58
+ }
59
+ }
60
+
61
+ let password = typeof flags.password === 'string' ? flags.password : undefined
62
+ if (password !== undefined && password.length < MIN_PASSWORD_LENGTH) {
63
+ fail(`--password must be at least ${MIN_PASSWORD_LENGTH} characters (an empty one would leave /admin open).`)
64
+ }
65
+
66
+ mkdirSync(target, { recursive: true })
67
+ const projectName = toPackageName(typeof flags.name === 'string' ? flags.name : basename(target))
68
+
69
+ out()
70
+ out(`${bold('Kestrel')} ${dim(`v${pkg.version}`)} — setting up ${bold(relative(process.cwd(), target) || '.')}`)
71
+ out()
72
+
73
+ if (password === undefined && !flags.yes && process.stdin.isTTY) {
74
+ const rl = createInterface({ input: process.stdin, output: process.stdout })
75
+ try {
76
+ password = await promptPassword(rl, { warn: (m) => out(yellow(m)) })
77
+ } catch (err) {
78
+ rl.close()
79
+ if (err instanceof Cancelled) fail('cancelled')
80
+ throw err
81
+ }
82
+ rl.close()
83
+ out()
84
+ }
85
+
86
+ const vars = templateVars(projectName)
87
+ const created = []
88
+ const kept = []
89
+ const merged = []
90
+
91
+ for (const rel of walk(templateDir).sort()) {
92
+ const name = targetName(rel)
93
+ const dest = join(target, name)
94
+ const body = renderTemplate(readFileSync(join(templateDir, rel), 'utf8'), vars)
95
+ const existing = readIf(dest)
96
+
97
+ // A manifest is never overwritten, not even with --force: the caller most likely already ran
98
+ // `pnpm add`, and replacing their dependencies and version is not a scaffold, it is data loss.
99
+ if (name === 'package.json' && existing !== null) {
100
+ const { merged: result, added } = mergePackageJson(JSON.parse(existing), JSON.parse(body))
101
+ if (added.length) {
102
+ write(dest, `${JSON.stringify(result, null, 2)}\n`)
103
+ merged.push(`package.json ${dim(`(+ ${added.join(', ')})`)}`)
104
+ } else kept.push('package.json')
105
+ continue
106
+ }
107
+ if (existing !== null && !flags.force) {
108
+ kept.push(name)
109
+ continue
110
+ }
111
+ write(dest, body)
112
+ created.push(name)
113
+ }
114
+
115
+ // Seed from `.env.example` so the generated file keeps its per-key comments.
116
+ const envPath = join(target, '.env')
117
+ const hadEnv = existsSync(envPath)
118
+ const envEntries = { KESTREL_SESSION_SECRET: sessionSecret(), KESTREL_SECURE_COOKIES: 'false' }
119
+ if (password !== undefined) envEntries.KESTREL_ADMIN_PASSWORD_HASH = hashPassword(password)
120
+ const seed = hadEnv ? readFileSync(envPath, 'utf8') : (readIf(join(target, '.env.example')) ?? '')
121
+ const { text, written } = mergeEnv(seed, envEntries)
122
+ if (written.length) {
123
+ write(envPath, text, 0o600)
124
+ ;(hadEnv ? merged : created).push(`.env ${dim(`(${written.join(', ')})`)}`)
125
+ } else kept.push('.env')
126
+
127
+ const report = (label, items, paint) => {
128
+ for (const f of items) out(` ${paint(label)} ${f}`)
129
+ }
130
+ report('created', created, green)
131
+ report('updated', merged, green)
132
+ report('kept ', kept, dim)
133
+
134
+ // Existing files are kept, so init must never report success over a project that still can't serve /admin.
135
+ const remaining = inspect(target)
136
+ if (remaining.length) {
137
+ out()
138
+ out(bold('Still to fix:'))
139
+ out()
140
+ printFindings(remaining)
141
+ }
142
+
143
+ out()
144
+ out(bold('Next:'))
145
+ const rel = relative(process.cwd(), target)
146
+ if (rel) out(` cd ${rel}`)
147
+ out(' pnpm install')
148
+ out(' pnpm dev')
149
+ out()
150
+ out(` Admin: ${bold('http://localhost:3000/admin')}`)
151
+ out()
152
+ return remaining.some((d) => d.level === 'error') ? 1 : 0
153
+ }
154
+
155
+ function doctor(positional) {
156
+ const target = resolve(positional[0] ?? '.')
157
+ const found = inspect(target)
158
+
159
+ out()
160
+ if (!found.length) {
161
+ out(`${green('✔')} ${relative(process.cwd(), target) || '.'} looks like a working Kestrel project.`)
162
+ out()
163
+ return 0
164
+ }
165
+ printFindings(found)
166
+ return found.some((d) => d.level === 'error') ? 1 : 0
167
+ }
168
+
169
+ async function hashPasswordCommand(positional) {
170
+ let password = positional[0]
171
+ if (password === undefined) {
172
+ if (process.stdin.isTTY) {
173
+ const rl = createInterface({ input: process.stdin, output: process.stdout })
174
+ try {
175
+ password = await promptPassword(rl, { warn: (m) => out(yellow(m)) })
176
+ } catch (err) {
177
+ rl.close()
178
+ if (err instanceof Cancelled) fail('cancelled')
179
+ throw err
180
+ }
181
+ rl.close()
182
+ } else password = await readStdin()
183
+ }
184
+ if (!password) fail('no password given')
185
+ out(hashPassword(password))
186
+ }
187
+
188
+ function help() {
189
+ out(`
190
+ ${bold('kestrel')} ${dim(`v${pkg.version}`)}
191
+
192
+ ${bold('kestrel init')} [dir] scaffold a runnable Kestrel project (default: the current directory)
193
+ ${bold('kestrel doctor')} [dir] check a project for the things that silently break /admin
194
+ ${bold('kestrel hash-password')} [p] print a KESTREL_ADMIN_PASSWORD_HASH value
195
+ ${bold('kestrel secret')} print a KESTREL_SESSION_SECRET value
196
+
197
+ ${bold('init flags')}
198
+ --name <name> package name (default: the directory name, slugified)
199
+ --password <pw> set the admin password without prompting
200
+ --yes never prompt; leaves KESTREL_ADMIN_PASSWORD_HASH for you to fill in
201
+ --force overwrite existing files (package.json and .env are always merged, never replaced)
202
+
203
+ ${dim('Existing files are kept and re-running init is safe. To create a NEW project: pnpm create kestrel')}
204
+ `)
205
+ }
206
+
207
+ const { flags, positional } = parseArgs(process.argv.slice(2), ['yes', 'force', 'help', 'version'])
208
+ const command = positional.shift()
209
+
210
+ if (flags.version || command === 'version') out(pkg.version)
211
+ else if (flags.help || !command || command === 'help') help()
212
+ else if (command === 'init') process.exitCode = await init(positional, flags)
213
+ else if (command === 'doctor') process.exitCode = doctor(positional)
214
+ else if (command === 'hash-password') await hashPasswordCommand(positional)
215
+ else if (command === 'secret') out(sessionSecret())
216
+ else fail(`unknown command "${command}" — run \`kestrel help\`.`)