create-kywi-app 0.1.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/LICENSE +661 -0
- package/README.md +146 -0
- package/bin/create-kywi-app.mjs +200 -0
- package/lib/templates.mjs +1384 -0
- package/package.json +37 -0
package/README.md
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# create-kywi-app
|
|
2
|
+
|
|
3
|
+
Scaffold a new Kywi CMS project.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
npx create-kywi-app my-site
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
Dependency-free by design (`bin/create-kywi-app.mjs` uses only Node built-ins),
|
|
10
|
+
so `npx create-kywi-app` runs with nothing pre-installed. The file contents it
|
|
11
|
+
writes live in `lib/templates.mjs` (`buildFileSet(answers)`), unit-tested
|
|
12
|
+
independently of the CLI's prompting/arg-parsing.
|
|
13
|
+
|
|
14
|
+
## Usage
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
npx create-kywi-app [project-name] [options]
|
|
18
|
+
|
|
19
|
+
Options:
|
|
20
|
+
--yes, -y Use defaults, skip prompts
|
|
21
|
+
--mode <mode> coupled | headless | decoupled (default: coupled)
|
|
22
|
+
--db <provider> postgresql | mysql (default: postgresql)
|
|
23
|
+
--auth <list> comma-separated: credentials,google,github (default: credentials)
|
|
24
|
+
--help, -h Show this help
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
With no flags and a TTY, it prompts interactively (project name, DB provider,
|
|
28
|
+
auth providers, deployment mode). Pass `--yes` (or run non-interactively, e.g.
|
|
29
|
+
in CI) to skip straight to defaults + flags.
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
npx create-kywi-app my-site --yes
|
|
33
|
+
npx create-kywi-app blog --mode headless --auth credentials,google
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Deployment modes
|
|
37
|
+
|
|
38
|
+
Set via `--mode` (and written into the generated `kywi.config.ts`'s
|
|
39
|
+
`mode` field):
|
|
40
|
+
|
|
41
|
+
- **`coupled`** — the app renders the public site AND serves the API + admin.
|
|
42
|
+
`GET /` renders content from the DB-backed scope.
|
|
43
|
+
- **`headless`** — API + admin only. `GET /` returns 404.
|
|
44
|
+
- **`decoupled`** — API + admin serve here; a separate frontend consumes
|
|
45
|
+
`@kywi-software/sdk` against this app's `/api/v1`. `GET /` returns 404.
|
|
46
|
+
|
|
47
|
+
## What gets generated
|
|
48
|
+
|
|
49
|
+
A generated project **boots and works with zero manual edits**:
|
|
50
|
+
`pnpm install && pnpm migrate && pnpm seed && pnpm dev` gives you a working admin
|
|
51
|
+
at `/admin` (sign in, create + publish a page) and — in `coupled` mode — a public
|
|
52
|
+
site that renders published pages at their slug.
|
|
53
|
+
|
|
54
|
+
`buildFileSet()` writes (`lib/templates.mjs`):
|
|
55
|
+
|
|
56
|
+
```
|
|
57
|
+
package.json scripts (dev/build/start/migrate/seed) + deps incl. drizzle-kit + tsx
|
|
58
|
+
kywi.config.ts defineKywiConfig/Site/Theme + a default "Page" content type
|
|
59
|
+
tsconfig.json
|
|
60
|
+
next.config.mjs the FULL config to consume core (extensionAlias, serverExternalPackages,
|
|
61
|
+
isomorphic-dompurify stub) — every line is load-bearing
|
|
62
|
+
.env.example DATABASE_URL + AUTH_SECRET
|
|
63
|
+
.gitignore
|
|
64
|
+
README.md project-specific quick start (createdb, migrate, seed, dev, /admin)
|
|
65
|
+
lib/dompurify-stub.js webpack stub referenced by next.config
|
|
66
|
+
lib/kywi.ts server runtime: DB, API handler, content scope (memoised)
|
|
67
|
+
lib/config.ts re-export of kywi.config
|
|
68
|
+
lib/admin-auth.ts client admin helpers, re-exported from @kywi-software/core/host-client
|
|
69
|
+
lib/content-types.ts config + built-in (page/folder/link) content-type resolver
|
|
70
|
+
middleware.ts auth gate + session refresh + cookie→bearer bridge (over core/host)
|
|
71
|
+
app/api/v1/[...kywi]/route.ts the versioned API (delegates to core; lifts tokens into httpOnly cookies)
|
|
72
|
+
app/layout.tsx root <html>/<body>
|
|
73
|
+
components/admin-shell.tsx minimal admin chrome (your app's own layer)
|
|
74
|
+
app/admin/layout.tsx pass-through admin segment layout
|
|
75
|
+
app/admin/page.tsx redirect → /admin/content
|
|
76
|
+
app/admin/login/page.tsx sign-in form
|
|
77
|
+
app/admin/content/page.tsx content-type picker
|
|
78
|
+
app/admin/content/[type]/… list / new / edit + publish (core's ContentEditForm)
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
**coupled** additionally gets `app/(site)/layout.tsx` + `app/(site)/[[...slug]]/page.tsx`
|
|
82
|
+
(the public site — renders "/" and every published page at its slug). **headless**
|
|
83
|
+
and **decoupled** instead get `app/page.tsx` (returns 404 — no public rendering).
|
|
84
|
+
|
|
85
|
+
The security-critical session plumbing (JWT verify, the httpOnly cookie contract,
|
|
86
|
+
the cookie→bearer bridge) is NOT copied into every project. It lives once in
|
|
87
|
+
`@kywi-software/core/host` (+ `/host-client`); the generated `middleware.ts` and
|
|
88
|
+
API route are thin framework wiring over those primitives, so a `kywi` upgrade
|
|
89
|
+
ships session fixes without the app hand-maintaining crypto. See
|
|
90
|
+
[`docs/hosting.md`](../../docs/hosting.md) for the full host-app contract.
|
|
91
|
+
|
|
92
|
+
The generated project's own `README.md` walks through `createdb`, copying
|
|
93
|
+
`.env.example` → `.env`, `pnpm migrate`, `pnpm seed`, and `pnpm dev`. For a
|
|
94
|
+
`decoupled` deployment it also shows the `@kywi-software/sdk` usage snippet (see
|
|
95
|
+
`packages/sdk/README.md` in this monorepo for the current, verified SDK API).
|
|
96
|
+
|
|
97
|
+
## Using an unpublished checkout (local packages)
|
|
98
|
+
|
|
99
|
+
The `@kywi-software/*` packages are not published to npm yet, so the generated
|
|
100
|
+
`package.json` refs (`"@kywi-software/core": "^0.1.0"`, etc.) will not install
|
|
101
|
+
as-is. To scaffold against a **local checkout of this monorepo**, point those two
|
|
102
|
+
deps at your built local packages before `pnpm install`.
|
|
103
|
+
|
|
104
|
+
The most reliable way (mirrors a real npm install — dist-only, deduped React) is
|
|
105
|
+
to pack the packages and reference the tarballs:
|
|
106
|
+
|
|
107
|
+
```bash
|
|
108
|
+
# 1. From the monorepo, build + pack core and cli
|
|
109
|
+
pnpm --filter @kywi-software/core --filter @kywi-software/cli build
|
|
110
|
+
( cd packages/core && pnpm pack --pack-destination /tmp/kywi-pkgs )
|
|
111
|
+
( cd packages/cli && pnpm pack --pack-destination /tmp/kywi-pkgs )
|
|
112
|
+
|
|
113
|
+
# 2. Scaffold your app
|
|
114
|
+
node packages/create-kywi-app/bin/create-kywi-app.mjs my-site --yes
|
|
115
|
+
|
|
116
|
+
# 3. Repoint the two @kywi-software deps at the tarballs in my-site/package.json,
|
|
117
|
+
# AND add a pnpm override so cli's own core dependency resolves to the tarball:
|
|
118
|
+
# "dependencies": {
|
|
119
|
+
# "@kywi-software/core": "file:/tmp/kywi-pkgs/kywi-software-core-0.1.0.tgz",
|
|
120
|
+
# "@kywi-software/cli": "file:/tmp/kywi-pkgs/kywi-software-cli-0.1.0.tgz"
|
|
121
|
+
# },
|
|
122
|
+
# "pnpm": {
|
|
123
|
+
# "overrides": {
|
|
124
|
+
# "@kywi-software/core": "file:/tmp/kywi-pkgs/kywi-software-core-0.1.0.tgz",
|
|
125
|
+
# "@kywi-software/cli": "file:/tmp/kywi-pkgs/kywi-software-cli-0.1.0.tgz"
|
|
126
|
+
# }
|
|
127
|
+
# }
|
|
128
|
+
|
|
129
|
+
# 4. Then the normal flow works unchanged
|
|
130
|
+
cd my-site && pnpm install && createdb my_site && cp .env.example .env
|
|
131
|
+
# (set DATABASE_URL + AUTH_SECRET in .env)
|
|
132
|
+
pnpm migrate && pnpm seed && pnpm dev
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
`file:` to a directory (`file:../kywi/packages/core`) also works but risks a
|
|
136
|
+
duplicate React copy through the symlink; tarballs avoid that. Re-pack after any
|
|
137
|
+
change to core/cli and re-run `pnpm install`.
|
|
138
|
+
|
|
139
|
+
## Testing
|
|
140
|
+
|
|
141
|
+
```bash
|
|
142
|
+
pnpm --filter create-kywi-app test
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
`__tests__/templates.test.mjs` asserts on `buildFileSet()`'s output directly —
|
|
146
|
+
no filesystem or network access required.
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* create-kywi-app — scaffold a new Kywi CMS project.
|
|
4
|
+
*
|
|
5
|
+
* npx create-kywi-app my-site
|
|
6
|
+
* npx create-kywi-app my-site --yes # non-interactive defaults
|
|
7
|
+
* npx create-kywi-app my-site --mode headless --db postgresql --auth credentials,google
|
|
8
|
+
*
|
|
9
|
+
* Dependency-free by design: uses only Node built-ins so `npx create-kywi-app`
|
|
10
|
+
* runs with nothing pre-installed. The actual file contents live in
|
|
11
|
+
* ../lib/templates.mjs (buildFileSet), which is unit-tested independently.
|
|
12
|
+
*/
|
|
13
|
+
import { mkdir, writeFile, readdir } from 'node:fs/promises'
|
|
14
|
+
import { existsSync } from 'node:fs'
|
|
15
|
+
import { dirname, join, resolve } from 'node:path'
|
|
16
|
+
import { createInterface } from 'node:readline/promises'
|
|
17
|
+
import { stdin, stdout, argv, exit } from 'node:process'
|
|
18
|
+
import { fileURLToPath } from 'node:url'
|
|
19
|
+
import { readFileSync } from 'node:fs'
|
|
20
|
+
import { buildFileSet } from '../lib/templates.mjs'
|
|
21
|
+
|
|
22
|
+
const MODES = ['coupled', 'headless', 'decoupled']
|
|
23
|
+
const DB_PROVIDERS = ['postgresql', 'mysql']
|
|
24
|
+
const AUTH_CHOICES = ['credentials', 'google', 'github']
|
|
25
|
+
|
|
26
|
+
const KYWI_VERSION = readKywiVersion()
|
|
27
|
+
|
|
28
|
+
function readKywiVersion() {
|
|
29
|
+
try {
|
|
30
|
+
const here = dirname(fileURLToPath(import.meta.url))
|
|
31
|
+
const pkg = JSON.parse(readFileSync(join(here, '..', 'package.json'), 'utf8'))
|
|
32
|
+
return pkg.version ?? '0.1.0'
|
|
33
|
+
} catch {
|
|
34
|
+
return '0.1.0'
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ── Arg parsing ───────────────────────────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
function parseArgs(args) {
|
|
41
|
+
const opts = { name: undefined, yes: false, mode: undefined, db: undefined, auth: undefined, help: false }
|
|
42
|
+
const positional = []
|
|
43
|
+
for (let i = 0; i < args.length; i++) {
|
|
44
|
+
const arg = args[i]
|
|
45
|
+
if (arg === '--yes' || arg === '-y') opts.yes = true
|
|
46
|
+
else if (arg === '--help' || arg === '-h') opts.help = true
|
|
47
|
+
else if (arg === '--mode') opts.mode = args[++i]
|
|
48
|
+
else if (arg.startsWith('--mode=')) opts.mode = arg.slice(7)
|
|
49
|
+
else if (arg === '--db') opts.db = args[++i]
|
|
50
|
+
else if (arg.startsWith('--db=')) opts.db = arg.slice(5)
|
|
51
|
+
else if (arg === '--auth') opts.auth = args[++i]
|
|
52
|
+
else if (arg.startsWith('--auth=')) opts.auth = arg.slice(7)
|
|
53
|
+
else if (!arg.startsWith('-')) positional.push(arg)
|
|
54
|
+
}
|
|
55
|
+
if (positional.length > 0) opts.name = positional[0]
|
|
56
|
+
return opts
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function printHelp() {
|
|
60
|
+
stdout.write(`
|
|
61
|
+
create-kywi-app — scaffold a new Kywi CMS project
|
|
62
|
+
|
|
63
|
+
Usage:
|
|
64
|
+
create-kywi-app [project-name] [options]
|
|
65
|
+
|
|
66
|
+
Options:
|
|
67
|
+
--yes, -y Use defaults, skip prompts
|
|
68
|
+
--mode <mode> coupled | headless | decoupled (default: coupled)
|
|
69
|
+
--db <provider> postgresql | mysql (default: postgresql)
|
|
70
|
+
--auth <list> comma-separated: credentials,google,github (default: credentials)
|
|
71
|
+
--help, -h Show this help
|
|
72
|
+
|
|
73
|
+
Examples:
|
|
74
|
+
npx create-kywi-app my-site
|
|
75
|
+
npx create-kywi-app my-site --yes
|
|
76
|
+
npx create-kywi-app blog --mode headless --auth credentials,google
|
|
77
|
+
`)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ── Prompting ─────────────────────────────────────────────────────────────────
|
|
81
|
+
|
|
82
|
+
async function prompt(rl, question, def, choices) {
|
|
83
|
+
const hint = choices ? ` (${choices.join('/')})` : def ? ` (${def})` : ''
|
|
84
|
+
const answer = (await rl.question(`${question}${hint}: `)).trim()
|
|
85
|
+
return answer || def
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function resolveAnswers(opts) {
|
|
89
|
+
// Non-interactive path: flags + defaults, no TTY needed.
|
|
90
|
+
if (opts.yes || !stdin.isTTY) {
|
|
91
|
+
return normalize({
|
|
92
|
+
projectName: opts.name || 'my-kywi-app',
|
|
93
|
+
mode: opts.mode || 'coupled',
|
|
94
|
+
dbProvider: opts.db || 'postgresql',
|
|
95
|
+
auth: opts.auth || 'credentials',
|
|
96
|
+
})
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const rl = createInterface({ input: stdin, output: stdout })
|
|
100
|
+
try {
|
|
101
|
+
const projectName = opts.name || (await prompt(rl, 'Project name', 'my-kywi-app'))
|
|
102
|
+
const dbProvider = opts.db || (await prompt(rl, 'Database provider', 'postgresql', DB_PROVIDERS))
|
|
103
|
+
const auth = opts.auth || (await prompt(rl, 'Auth providers (comma-separated)', 'credentials'))
|
|
104
|
+
const mode = opts.mode || (await prompt(rl, 'Deployment mode', 'coupled', MODES))
|
|
105
|
+
return normalize({ projectName, mode, dbProvider, auth })
|
|
106
|
+
} finally {
|
|
107
|
+
rl.close()
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function normalize({ projectName, mode, dbProvider, auth }) {
|
|
112
|
+
const errors = []
|
|
113
|
+
if (!projectName || !/^[A-Za-z0-9._-]+$/.test(projectName)) {
|
|
114
|
+
errors.push(`Invalid project name "${projectName}". Use letters, numbers, dots, dashes, underscores.`)
|
|
115
|
+
}
|
|
116
|
+
if (!MODES.includes(mode)) errors.push(`Invalid mode "${mode}". Choose one of: ${MODES.join(', ')}.`)
|
|
117
|
+
if (!DB_PROVIDERS.includes(dbProvider)) errors.push(`Invalid db provider "${dbProvider}". Choose one of: ${DB_PROVIDERS.join(', ')}.`)
|
|
118
|
+
const authProviders = String(auth).split(',').map((s) => s.trim()).filter(Boolean)
|
|
119
|
+
for (const p of authProviders) {
|
|
120
|
+
if (!AUTH_CHOICES.includes(p)) errors.push(`Invalid auth provider "${p}". Choose from: ${AUTH_CHOICES.join(', ')}.`)
|
|
121
|
+
}
|
|
122
|
+
if (authProviders.length === 0) authProviders.push('credentials')
|
|
123
|
+
if (errors.length) {
|
|
124
|
+
const err = new Error(errors.join('\n'))
|
|
125
|
+
err.userFacing = true
|
|
126
|
+
throw err
|
|
127
|
+
}
|
|
128
|
+
return { projectName, mode, dbProvider, authProviders, kywiVersion: KYWI_VERSION }
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ── Generation ────────────────────────────────────────────────────────────────
|
|
132
|
+
|
|
133
|
+
async function generate(answers, targetDir) {
|
|
134
|
+
const files = buildFileSet(answers)
|
|
135
|
+
for (const [rel, content] of Object.entries(files)) {
|
|
136
|
+
const abs = join(targetDir, rel)
|
|
137
|
+
await mkdir(dirname(abs), { recursive: true })
|
|
138
|
+
await writeFile(abs, content, 'utf8')
|
|
139
|
+
}
|
|
140
|
+
return Object.keys(files)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function isNonEmptyDir(dir) {
|
|
144
|
+
if (!existsSync(dir)) return false
|
|
145
|
+
const entries = await readdir(dir)
|
|
146
|
+
return entries.length > 0
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ── Main ──────────────────────────────────────────────────────────────────────
|
|
150
|
+
|
|
151
|
+
async function main() {
|
|
152
|
+
const opts = parseArgs(argv.slice(2))
|
|
153
|
+
if (opts.help) {
|
|
154
|
+
printHelp()
|
|
155
|
+
return 0
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
let answers
|
|
159
|
+
try {
|
|
160
|
+
answers = await resolveAnswers(opts)
|
|
161
|
+
} catch (err) {
|
|
162
|
+
stdout.write(`\n✖ ${err.message}\n`)
|
|
163
|
+
return 1
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const targetDir = resolve(process.cwd(), answers.projectName)
|
|
167
|
+
// Edge case (TC-01.001): existing non-empty directory — clear error, no overwrite.
|
|
168
|
+
if (await isNonEmptyDir(targetDir)) {
|
|
169
|
+
stdout.write(`\n✖ Directory "${answers.projectName}" already exists and is not empty. Choose a different name or remove it.\n`)
|
|
170
|
+
return 1
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
stdout.write(`\nScaffolding ${answers.projectName} (${answers.mode} mode, ${answers.dbProvider})…\n`)
|
|
174
|
+
const written = await generate(answers, targetDir)
|
|
175
|
+
for (const f of written) stdout.write(` + ${f}\n`)
|
|
176
|
+
|
|
177
|
+
const dbName = answers.projectName.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '') || 'kywi_app'
|
|
178
|
+
stdout.write(`
|
|
179
|
+
✔ Created ${answers.projectName}/
|
|
180
|
+
|
|
181
|
+
Next steps:
|
|
182
|
+
cd ${answers.projectName}
|
|
183
|
+
pnpm install
|
|
184
|
+
createdb ${dbName}
|
|
185
|
+
cp .env.example .env # set DATABASE_URL + AUTH_SECRET
|
|
186
|
+
pnpm migrate
|
|
187
|
+
pnpm seed # prints the superadmin credentials
|
|
188
|
+
pnpm dev # http://localhost:3000
|
|
189
|
+
|
|
190
|
+
Docs: https://kywi.dev/docs
|
|
191
|
+
`)
|
|
192
|
+
return 0
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
main()
|
|
196
|
+
.then((code) => exit(code))
|
|
197
|
+
.catch((err) => {
|
|
198
|
+
stdout.write(`\n✖ Unexpected error: ${err?.stack || err}\n`)
|
|
199
|
+
exit(1)
|
|
200
|
+
})
|