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/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
+ })