@michaelthielemann/kestrel 1.2.1 → 1.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 +33 -3
- package/layers/admin/app/pages/admin/[collection]/[id].nuxt.test.ts +200 -0
- package/layers/auth/nuxt.config.ts +0 -0
- package/layers/auth/server/api/auth/session.get.ts +0 -0
- package/layers/auth/server/utils/password.ts +0 -0
- package/layers/auth/server/utils/session.ts +5 -2
- package/layers/collections/nuxt.config.ts +0 -0
- package/layers/core/modules/kestrel/app-shell.ts +55 -0
- package/layers/core/modules/kestrel/index.ts +17 -0
- package/layers/core/server/api/[collection]/[id]/translations.get.ts +0 -0
- package/layers/core/server/api/[collection]/options.get.test.ts +60 -0
- package/layers/core/server/api/[collection]/translations.get.test.ts +90 -0
- package/layers/core/server/utils/blocks.ts +0 -0
- package/layers/core/server/utils/seo.ts +0 -0
- package/layers/fields/nuxt.config.ts +0 -0
- package/layers/media/server/api/media/[id].get.ts +0 -0
- package/layers/media/server/api/media/index.get.ts +0 -0
- package/layers/public/app/app.vue +0 -0
- package/layers/public/app/error.vue +0 -0
- package/layers/public/app/layouts/default.vue +0 -0
- package/layers/ui/app/assets/scss/_reset.scss +0 -0
- package/layers/ui/app/assets/scss/main.scss +0 -0
- package/layers/ui/app/components/ui/Alert.vue +0 -0
- package/package.json +6 -2
- package/scripts/copy-create-payload.mjs +52 -0
- package/scripts/hash-password.mjs +6 -15
- package/scripts/kestrel.mjs +216 -0
- package/scripts/lib/cli.mjs +114 -0
- package/scripts/lib/password.mjs +19 -0
- package/scripts/lib/scaffold.mjs +174 -0
- package/templates/starter/README.md +65 -0
- package/templates/starter/_env.example +17 -0
- package/templates/starter/_gitignore +26 -0
- package/templates/starter/_package.json +22 -0
- package/templates/starter/app/app.vue +7 -0
- package/templates/starter/app/blocks/Prose.vue +12 -0
- package/templates/starter/app/layouts/default.vue +6 -0
- package/templates/starter/nuxt.config.ts +20 -0
- package/templates/starter/pnpm-workspace.yaml +8 -0
- package/templates/starter/tsconfig.json +3 -0
|
@@ -0,0 +1,52 @@
|
|
|
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
|
+
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'))
|
|
17
|
+
if (manifest['//engine']) {
|
|
18
|
+
delete manifest['//engine']
|
|
19
|
+
writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`)
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (process.argv.includes('--clean')) {
|
|
24
|
+
clean()
|
|
25
|
+
} else {
|
|
26
|
+
clean()
|
|
27
|
+
const engine = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8'))
|
|
28
|
+
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'))
|
|
29
|
+
|
|
30
|
+
// The version cannot be corrected here: npm derives the tarball name from the manifest as it stood
|
|
31
|
+
// BEFORE prepack, so a rewrite yields a tarball whose name and contents disagree. Refuse instead —
|
|
32
|
+
// test/create-kestrel.test.ts keeps the two manifests in lockstep so this never fires in practice.
|
|
33
|
+
if (manifest.version !== engine.version) {
|
|
34
|
+
console.error(`create-kestrel is at ${manifest.version} but the engine is at ${engine.version} — bump both.`)
|
|
35
|
+
process.exit(1)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
cpSync(join(ROOT, 'templates'), join(PKG, 'templates'), { recursive: true })
|
|
39
|
+
mkdirSync(join(PKG, 'lib'), { recursive: true })
|
|
40
|
+
for (const file of LIB_FILES) cpSync(join(ROOT, 'scripts/lib', file), join(PKG, 'lib', file))
|
|
41
|
+
for (const file of ['LICENSE', 'NOTICE']) {
|
|
42
|
+
if (existsSync(join(ROOT, file))) cpSync(join(ROOT, file), join(PKG, file))
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Stamped so the scaffolded manifest pins the ranges the engine actually resolves.
|
|
46
|
+
manifest['//engine'] = {
|
|
47
|
+
nuxt: engine.dependencies?.nuxt,
|
|
48
|
+
typescript: engine.dependencies?.typescript,
|
|
49
|
+
'vue-tsc': engine.devDependencies?.['vue-tsc'],
|
|
50
|
+
}
|
|
51
|
+
writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`)
|
|
52
|
+
}
|
|
@@ -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
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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(
|
|
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(
|
|
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\`.`)
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync, chmodSync } from 'node:fs'
|
|
2
|
+
import { dirname, join, relative } from 'node:path'
|
|
3
|
+
|
|
4
|
+
export const MIN_PASSWORD_LENGTH = 8
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* `booleans` never consume the following token, so `init --force my-site` keeps `my-site` as the target
|
|
8
|
+
* rather than scaffolding over the current directory. `--` ends flag parsing.
|
|
9
|
+
*/
|
|
10
|
+
export function parseArgs(argv, booleans = []) {
|
|
11
|
+
const isBoolean = new Set(booleans)
|
|
12
|
+
const flags = {}
|
|
13
|
+
const positional = []
|
|
14
|
+
for (let i = 0; i < argv.length; i++) {
|
|
15
|
+
const a = argv[i]
|
|
16
|
+
if (a === '--') {
|
|
17
|
+
positional.push(...argv.slice(i + 1))
|
|
18
|
+
break
|
|
19
|
+
}
|
|
20
|
+
if (a === '-h') {
|
|
21
|
+
flags.help = true
|
|
22
|
+
continue
|
|
23
|
+
}
|
|
24
|
+
if (a === '-v') {
|
|
25
|
+
flags.version = true
|
|
26
|
+
continue
|
|
27
|
+
}
|
|
28
|
+
if (!a.startsWith('--')) {
|
|
29
|
+
positional.push(a)
|
|
30
|
+
continue
|
|
31
|
+
}
|
|
32
|
+
const [key, inline] = a.slice(2).split(/=(.*)/s)
|
|
33
|
+
if (inline !== undefined) flags[key] = inline
|
|
34
|
+
else if (!isBoolean.has(key) && i + 1 < argv.length && !argv[i + 1].startsWith('-')) flags[key] = argv[++i]
|
|
35
|
+
else flags[key] = true
|
|
36
|
+
}
|
|
37
|
+
return { flags, positional }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function makePaint(stream = process.stdout) {
|
|
41
|
+
const on = stream.isTTY && !process.env.NO_COLOR
|
|
42
|
+
const wrap = (code) => (s) => (on ? `\u001b[${code}m${s}\u001b[0m` : s)
|
|
43
|
+
return { dim: wrap(2), bold: wrap(1), red: wrap(31), yellow: wrap(33), green: wrap(32) }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export const out = (s = '') => process.stdout.write(`${s}\n`)
|
|
47
|
+
|
|
48
|
+
export const readIf = (file) => (existsSync(file) ? readFileSync(file, 'utf8') : null)
|
|
49
|
+
|
|
50
|
+
/** `mode` is applied even when the file already exists, which `writeFileSync` alone does not do. */
|
|
51
|
+
export function write(file, content, mode) {
|
|
52
|
+
mkdirSync(dirname(file), { recursive: true })
|
|
53
|
+
writeFileSync(file, content, mode === undefined ? undefined : { mode })
|
|
54
|
+
if (mode !== undefined) chmodSync(file, mode)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Every file under `dir`, as `/`-separated paths relative to it. */
|
|
58
|
+
export function walk(dir, base = dir) {
|
|
59
|
+
const found = []
|
|
60
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
61
|
+
const full = join(dir, entry.name)
|
|
62
|
+
if (entry.isDirectory()) found.push(...walk(full, base))
|
|
63
|
+
else found.push(relative(base, full).split('\\').join('/'))
|
|
64
|
+
}
|
|
65
|
+
return found
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Reads a line with the echo suppressed. readline echoes before we see the data event, so the fix is to
|
|
70
|
+
* repaint the prompt over whatever was written — unconditionally, because a pasted secret arrives as one
|
|
71
|
+
* chunk that ends in a newline and would otherwise be left on screen and in the scrollback.
|
|
72
|
+
*/
|
|
73
|
+
async function askHidden(rl, question) {
|
|
74
|
+
const repaint = () => process.stdout.write(`\u001b[2K\u001b[200D${question}`)
|
|
75
|
+
process.stdin.on('data', repaint)
|
|
76
|
+
try {
|
|
77
|
+
return (await rl.question(question)).trim()
|
|
78
|
+
} finally {
|
|
79
|
+
process.stdin.off('data', repaint)
|
|
80
|
+
repaint()
|
|
81
|
+
process.stdout.write('\n')
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export class Cancelled extends Error {}
|
|
86
|
+
|
|
87
|
+
/** Throws `Cancelled` on Ctrl-C / Ctrl-D so a caller can exit cleanly instead of on an AbortError stack. */
|
|
88
|
+
export async function promptPassword(rl, { warn = out } = {}) {
|
|
89
|
+
for (;;) {
|
|
90
|
+
let first
|
|
91
|
+
try {
|
|
92
|
+
first = await askHidden(rl, 'Admin password: ')
|
|
93
|
+
if (first.length < MIN_PASSWORD_LENGTH) {
|
|
94
|
+
warn(` at least ${MIN_PASSWORD_LENGTH} characters, please`)
|
|
95
|
+
continue
|
|
96
|
+
}
|
|
97
|
+
if (first !== (await askHidden(rl, 'Repeat password: '))) {
|
|
98
|
+
warn(' the two entries differ — try again')
|
|
99
|
+
continue
|
|
100
|
+
}
|
|
101
|
+
} catch (err) {
|
|
102
|
+
throw err?.code === 'ABORT_ERR' ? new Cancelled() : err
|
|
103
|
+
}
|
|
104
|
+
if (first === undefined) throw new Cancelled()
|
|
105
|
+
return first
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** All of stdin as a string; `''` at EOF, which the callers turn into a clean error. */
|
|
110
|
+
export async function readStdin() {
|
|
111
|
+
let text = ''
|
|
112
|
+
for await (const chunk of process.stdin) text += chunk
|
|
113
|
+
return text.split('\n')[0].trim()
|
|
114
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { scryptSync, randomBytes } from 'node:crypto'
|
|
2
|
+
|
|
3
|
+
// Must match what layers/auth parses back out. ADR-0001 covers the parameter choice.
|
|
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
|
+
/** `scrypt$N$r$p$salt$hash`, the `KESTREL_ADMIN_PASSWORD_HASH` format. */
|
|
11
|
+
export function hashPassword(password) {
|
|
12
|
+
const salt = randomBytes(16)
|
|
13
|
+
const hash = scryptSync(password, salt, KEYLEN, { N, r, p, maxmem: MAXMEM })
|
|
14
|
+
return `scrypt$${N}$${r}$${p}$${salt.toString('base64url')}$${hash.toString('base64url')}`
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function sessionSecret() {
|
|
18
|
+
return randomBytes(32).toString('base64url')
|
|
19
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
export const PACKAGE_NAME = '@michaelthielemann/kestrel'
|
|
2
|
+
|
|
3
|
+
// Template dotfiles are `_`-prefixed because npm strips a literal `.gitignore` from a tarball. ADR-0005.
|
|
4
|
+
const RENAMES = {
|
|
5
|
+
_gitignore: '.gitignore',
|
|
6
|
+
'_env.example': '.env.example',
|
|
7
|
+
'_package.json': 'package.json',
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function targetName(rel) {
|
|
11
|
+
const parts = rel.split('/')
|
|
12
|
+
const base = parts[parts.length - 1]
|
|
13
|
+
parts[parts.length - 1] = RENAMES[base] ?? base
|
|
14
|
+
return parts.join('/')
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** An unknown placeholder is left as-is so a typo is visible rather than silently blank. */
|
|
18
|
+
export function renderTemplate(src, vars) {
|
|
19
|
+
return src.replace(/\{\{(\w+)\}\}/g, (whole, key) => (key in vars ? String(vars[key]) : whole))
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function toPackageName(dirName) {
|
|
23
|
+
const slug = dirName
|
|
24
|
+
.toLowerCase()
|
|
25
|
+
.replace(/[^a-z0-9._-]+/g, '-')
|
|
26
|
+
.replace(/^[-._]+|[-._]+$/g, '')
|
|
27
|
+
return slug || 'kestrel-site'
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const KEY_RE = /^\s*([A-Z][A-Z0-9_]*)\s*=/
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Fills only keys that are absent or empty, so re-running `init` never rotates a live session secret.
|
|
34
|
+
* Returns the new text plus the keys that changed.
|
|
35
|
+
*/
|
|
36
|
+
export function mergeEnv(existing, entries) {
|
|
37
|
+
const pending = new Map(Object.entries(entries))
|
|
38
|
+
const lines = existing === '' ? [] : existing.split('\n')
|
|
39
|
+
const written = []
|
|
40
|
+
|
|
41
|
+
// A dotenv loader takes the LAST assignment of a key, so filling the first would leave the file
|
|
42
|
+
// reporting a value the app never sees.
|
|
43
|
+
const lastIndex = new Map()
|
|
44
|
+
lines.forEach((line, i) => {
|
|
45
|
+
const key = KEY_RE.exec(line)?.[1]
|
|
46
|
+
if (key) lastIndex.set(key, i)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
const out = lines.map((line, i) => {
|
|
50
|
+
const key = KEY_RE.exec(line)?.[1]
|
|
51
|
+
if (!key || !pending.has(key) || lastIndex.get(key) !== i) return line
|
|
52
|
+
const value = pending.get(key)
|
|
53
|
+
pending.delete(key)
|
|
54
|
+
if (line.slice(line.indexOf('=') + 1).trim() !== '') return line
|
|
55
|
+
written.push(key)
|
|
56
|
+
return `${key}=${value}`
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
if (pending.size) {
|
|
60
|
+
if (out.length && out[out.length - 1].trim() !== '') out.push('')
|
|
61
|
+
for (const [key, value] of pending) {
|
|
62
|
+
out.push(`${key}=${value}`)
|
|
63
|
+
written.push(key)
|
|
64
|
+
}
|
|
65
|
+
out.push('')
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return { text: out.join('\n'), written }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const isObject = (v) => typeof v === 'object' && v !== null && !Array.isArray(v)
|
|
72
|
+
|
|
73
|
+
const NESTED = ['scripts', 'dependencies', 'devDependencies']
|
|
74
|
+
// `type` decides whether every .js in the project is ESM or CJS, so injecting it silently could break a
|
|
75
|
+
// CommonJS project outright. Only these top-level keys may be introduced, and each is reported.
|
|
76
|
+
const TOP_LEVEL = ['name', 'private', 'type']
|
|
77
|
+
|
|
78
|
+
/** Additive: the project's own values always win. Returns the manifest plus every key it introduced. */
|
|
79
|
+
export function mergePackageJson(existing, template) {
|
|
80
|
+
const merged = { ...existing }
|
|
81
|
+
const added = []
|
|
82
|
+
|
|
83
|
+
for (const key of TOP_LEVEL) {
|
|
84
|
+
if (!(key in template) || key in existing) continue
|
|
85
|
+
merged[key] = template[key]
|
|
86
|
+
added.push(key)
|
|
87
|
+
}
|
|
88
|
+
for (const key of NESTED) {
|
|
89
|
+
if (!template[key]) continue
|
|
90
|
+
merged[key] = isObject(existing[key]) ? { ...template[key], ...existing[key] } : template[key]
|
|
91
|
+
for (const name of Object.keys(template[key])) {
|
|
92
|
+
if (!isObject(existing[key]) || !(name in existing[key])) added.push(`${key}.${name}`)
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return { merged, added }
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const withoutComments = (src) => src.replace(/<!--[\s\S]*?-->/g, '')
|
|
99
|
+
// `<nuxt-page />` is as valid as `<NuxtPage />`; matching only the Pascal spelling would flag a working app.
|
|
100
|
+
const kebab = (tag) => tag.replace(/(?!^)([A-Z])/g, '-$1').toLowerCase()
|
|
101
|
+
export const usesComponent = (src, tag) => new RegExp(`<\\s*(${tag}|${kebab(tag)})[\\s/>]`, 'i').test(src)
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* `kestrel doctor`, as a pure function of what was read; `null` means the file is absent.
|
|
105
|
+
* The `app.vue` rules mirror `layers/core/modules/kestrel/app-shell.ts`; a test pins the two together.
|
|
106
|
+
*/
|
|
107
|
+
export function diagnoseProject({ packageJson, nuxtConfig, appVue, env }) {
|
|
108
|
+
const found = []
|
|
109
|
+
const add = (level, message) => found.push({ level, message })
|
|
110
|
+
|
|
111
|
+
let manifest
|
|
112
|
+
if (packageJson !== null) {
|
|
113
|
+
try {
|
|
114
|
+
manifest = JSON.parse(packageJson)
|
|
115
|
+
} catch {
|
|
116
|
+
add('error', 'package.json is not valid JSON — nothing can read it, including pnpm.')
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (packageJson === null) {
|
|
121
|
+
add('error', 'no package.json — run this inside a project directory, or `kestrel init <dir>` to make one.')
|
|
122
|
+
} else if (manifest !== undefined) {
|
|
123
|
+
const deps = { ...manifest.dependencies, ...manifest.devDependencies }
|
|
124
|
+
if (!(PACKAGE_NAME in deps)) add('error', `${PACKAGE_NAME} is not a dependency — run \`pnpm add ${PACKAGE_NAME}\`.`)
|
|
125
|
+
if (!('nuxt' in deps)) {
|
|
126
|
+
add(
|
|
127
|
+
'error',
|
|
128
|
+
'nuxt is not a direct dependency. Kestrel depends on it, but a strict node_modules layout (pnpm) does not link a transitive package\'s `nuxt` binary, so `nuxt dev` will not resolve — run `pnpm add -D nuxt`.',
|
|
129
|
+
)
|
|
130
|
+
}
|
|
131
|
+
if (!manifest.scripts?.dev) add('warn', 'no `dev` script — add `"dev": "nuxt dev"` so `pnpm dev` works.')
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (nuxtConfig === null) {
|
|
135
|
+
add(
|
|
136
|
+
'error',
|
|
137
|
+
'no nuxt.config.ts. Installing the package does nothing on its own: Nuxt only loads Kestrel when the config extends it. Without this file every route, /admin included, serves the default Nuxt welcome page.',
|
|
138
|
+
)
|
|
139
|
+
} else if (!nuxtConfig.includes(PACKAGE_NAME)) {
|
|
140
|
+
add('error', `nuxt.config.ts does not extend ${PACKAGE_NAME} — add \`extends: ['${PACKAGE_NAME}']\`.`)
|
|
141
|
+
} else if (/extends\s*:\s*\[[^\]]*['"]\.\.[/\\]\.\./.test(nuxtConfig)) {
|
|
142
|
+
add(
|
|
143
|
+
'warn',
|
|
144
|
+
"a relative `extends` path of '../..' or deeper silently drops every Kestrel sub-layer (c12 treats it as a file, not a directory, because pathe reports its extension as '.'). Use the package name.",
|
|
145
|
+
)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (appVue !== null) {
|
|
149
|
+
const src = withoutComments(appVue)
|
|
150
|
+
if (!usesComponent(src, 'NuxtPage')) {
|
|
151
|
+
add(
|
|
152
|
+
'error',
|
|
153
|
+
'app/app.vue renders no <NuxtPage />, so no route renders — including /admin. It shadows the one Kestrel ships. Delete it, or wrap <NuxtPage /> in <NuxtLayout>.',
|
|
154
|
+
)
|
|
155
|
+
} else if (!usesComponent(src, 'NuxtLayout')) {
|
|
156
|
+
add('warn', 'app/app.vue renders no <NuxtLayout />, so the admin loses its navigation shell.')
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (env === null) {
|
|
161
|
+
add('error', 'no .env — sign-in at /admin answers 503 until KESTREL_ADMIN_PASSWORD_HASH is set. Run `kestrel init`.')
|
|
162
|
+
} else {
|
|
163
|
+
// Horizontal whitespace only: `\s*` would let an empty assignment match the NEXT line's value.
|
|
164
|
+
const value = (key) => new RegExp(`^[^\\S\\n]*${key}[^\\S\\n]*=[^\\S\\n]*(.+)$`, 'm').exec(env)?.[1].trim()
|
|
165
|
+
if (!value('KESTREL_ADMIN_PASSWORD_HASH')) {
|
|
166
|
+
add('error', 'KESTREL_ADMIN_PASSWORD_HASH is unset — /admin renders but sign-in answers 503. Run `kestrel hash-password`.')
|
|
167
|
+
}
|
|
168
|
+
if (!value('KESTREL_SESSION_SECRET')) {
|
|
169
|
+
add('warn', 'KESTREL_SESSION_SECRET is unset — dev falls back to a random per-process secret (sessions drop on restart) and production refuses to boot.')
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return found
|
|
174
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# {{name}}
|
|
2
|
+
|
|
3
|
+
A [Kestrel](https://github.com/MichaelThielemann/kestrel) site — a collection-driven CMS that renders
|
|
4
|
+
published content to static HTML.
|
|
5
|
+
|
|
6
|
+
```bash
|
|
7
|
+
pnpm install
|
|
8
|
+
pnpm dev
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Admin: <http://localhost:3000/admin> — sign in with the password you chose during `kestrel init`.
|
|
12
|
+
Forgot it? `pnpm hash-password` prints a fresh hash for `KESTREL_ADMIN_PASSWORD_HASH` in `.env`.
|
|
13
|
+
|
|
14
|
+
## What is here
|
|
15
|
+
|
|
16
|
+
| Path | What it does |
|
|
17
|
+
| --- | --- |
|
|
18
|
+
| `nuxt.config.ts` | Composes Kestrel (`extends`) and holds every non-secret setting under `kestrel: {}` |
|
|
19
|
+
| `.env` | Session secret + admin password hash. **Never commit it** — `.env.example` is the committed copy |
|
|
20
|
+
| `app/app.vue` | The app root. Keep `<NuxtLayout>` and `<NuxtPage />` or the admin stops rendering |
|
|
21
|
+
| `app/layouts/default.vue` | Your public site frame — header, nav, footer |
|
|
22
|
+
| `app/blocks/Prose.vue` | One block type for the page builder: schema and display in a single file |
|
|
23
|
+
| `.data/` | The SQLite database, uploads and published output. Gitignored |
|
|
24
|
+
|
|
25
|
+
## Add a collection
|
|
26
|
+
|
|
27
|
+
Drop a file in `server/collections/`; the table is created on the next dev start and the collection
|
|
28
|
+
shows up in the admin. Nothing to register.
|
|
29
|
+
|
|
30
|
+
```ts
|
|
31
|
+
// server/collections/posts.ts
|
|
32
|
+
export default defineCollection({
|
|
33
|
+
name: 'posts',
|
|
34
|
+
mode: 'multi',
|
|
35
|
+
pageLike: true, // gives records a `path` so they render to static HTML
|
|
36
|
+
status: true, // draft / published
|
|
37
|
+
seo: true,
|
|
38
|
+
blocks: { enabled: true },
|
|
39
|
+
fields: { title: { type: 'text', required: true } },
|
|
40
|
+
})
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Publish
|
|
44
|
+
|
|
45
|
+
Run `pnpm dev` at least once first: the database schema is derived from your collections and created on
|
|
46
|
+
a dev boot, and a production build never touches it. Generating against a database that does not exist
|
|
47
|
+
yet succeeds but emits an empty site plus `no such table` errors.
|
|
48
|
+
|
|
49
|
+
There are two ways to get static files out, and this project has **both** enabled:
|
|
50
|
+
|
|
51
|
+
- **One-shot:** `pnpm generate` renders everything published into `.output/public/`.
|
|
52
|
+
- **Incremental (the default at runtime):** a production run (`pnpm build && pnpm preview`) publishes on
|
|
53
|
+
boot and re-renders only the pages each content write affects, into `.data/published/`. Turn it off with
|
|
54
|
+
`kestrel: { output: { auto: false } }` in `nuxt.config.ts` if you only want the one-shot flow.
|
|
55
|
+
|
|
56
|
+
Either way, serve the resulting directory from any static host. The editing origin is not meant to be
|
|
57
|
+
public. Set `siteUrl` in `nuxt.config.ts` to the real public origin first — it is baked into canonical
|
|
58
|
+
URLs, `sitemap.xml` and `llms.txt` at build time.
|
|
59
|
+
|
|
60
|
+
## Health check
|
|
61
|
+
|
|
62
|
+
`pnpm doctor` checks this project for the things that silently break a Kestrel site: a missing
|
|
63
|
+
`extends`, an `app.vue` that renders no pages, unset auth env.
|
|
64
|
+
|
|
65
|
+
Full documentation: <https://github.com/MichaelThielemann/kestrel#documentation>
|