create-kywi-app 0.18.0 → 0.20.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 CHANGED
@@ -83,18 +83,50 @@ CLAUDE.md thin pointer that sends a Claude Code session
83
83
  .claude/skills/kywi-personalization/SKILL.md Claude Code project skill: confirm the use case, then
84
84
  build audiences, page variants, or A/B experiments — loaded
85
85
  automatically when the owner wants personalization or A/B testing
86
- lib/kywi.ts server runtime: DB, API handler, content scope (memoised)
87
- lib/config.ts re-export of kywi.config
88
- middleware.ts auth gate + session refresh + cookiebearer bridge (over core/host)
89
- app/api/v1/[...kywi]/route.ts the versioned API (delegates to core; lifts tokens into httpOnly cookies)
86
+ lib/kywi.ts the ONE wiring point: kywi.config.ts core's render path (stamped)
87
+ kywi.layers.ts GENERATED layer registry: imports every site/theme layer so core can
88
+ resolve overrides theme sitecore at build time (never edit; upgrade regenerates) (stamped)
89
+ sites/default/index.ts this site's layer: re-exports lib/modules.tsx as `modules` (add `templates` to override)
90
+ sites/default/themes/default/index.tsx the default theme's layer: `templates.nav` → components/site-nav.tsx (coupled only)
91
+ middleware.ts re-exports core's middleware (auth gate, session refresh, cookie→bearer bridge) (stamped)
92
+ app/api/v1/[...kywi]/route.ts the versioned API — a thin delegate to core's `createApiRoute` (cookie lifting lives in core) (stamped)
93
+ app/llms.txt/route.ts etc. the root AX routes — thin delegates to core's `createAxRoute` (stamped: all 4 of
94
+ llms.txt, llms-full.txt, sitemap.xml, robots.txt)
90
95
  app/layout.tsx root <html>/<body>
91
96
  app/icon.svg favicon
92
- app/admin/[[...admin]]/page.tsx mounts the FULL core admin (KywiAdminApp, every surface) at /admin
97
+ app/admin/[[...admin]]/page.tsx mounts the FULL core admin (KywiAdminApp, every surface) at /admin (stamped)
93
98
  ```
94
99
 
95
100
  **coupled** additionally gets `app/(site)/layout.tsx` + `app/(site)/[[...slug]]/page.tsx`
96
- (the public site — renders "/" and every published page at its slug). **headless**
97
- and **decoupled** instead get `app/page.tsx` (returns 404 — no public rendering).
101
+ (the public site — renders "/" and every published page at its slug, both **stamped**),
102
+ plus `app/(site)/site.css` and `components/site-nav.tsx` (project-owned, unstamped).
103
+ **headless** and **decoupled** instead get `app/page.tsx` (returns 404 — no public
104
+ rendering, unstamped).
105
+
106
+ All eleven of the files marked **(stamped)** above are **thin, version-stamped
107
+ delegates**: the two `app/(site)` route files, `middleware.ts`, `lib/kywi.ts`,
108
+ `kywi.layers.ts`, the API route, the four AX root routes and the admin catch-all
109
+ page. Each opens with a `// kywi-render v2 (create-kywi-app <version>)
110
+ sha256:<12 hex>` comment — the hash is over the file's own body, so
111
+ `create-kywi-app upgrade` (see below) can tell an untouched generated file
112
+ (safe to replace) from one you've edited (refused) without a diff against
113
+ every historical emission. The
114
+ public render path itself — content and locale resolution, SEO metadata, JSON-LD,
115
+ feed and nav hydration, personalization, A/B assignment and the front-of-site
116
+ editor — is shipped as `@kywi-software/core/next`, so `pnpm up
117
+ @kywi-software/core` upgrades it. Before this, ~1,370 lines of it were copied
118
+ into every project, where no upgrade could ever reach them again. What a project
119
+ owns: `kywi.config.ts`, `app/(site)/site.css`, `lib/modules.tsx`,
120
+ `components/site-nav.tsx` and the layer files under `sites/**`.
121
+
122
+ **The layer chain.** Every named artifact core renders — a module renderer, the nav,
123
+ the front-edit overlay, the personalization runtime, the `/kywi.js` loader — is resolved
124
+ per request as **theme → site → core**, first hit wins, from the registry `kywi.layers.ts`
125
+ imports. To override one, export it by name from `sites/<site>/index.ts` (`modules` or
126
+ `templates`) or from `sites/<site>/themes/<theme>/index.tsx`; never edit core or the
127
+ generated files. Core validates the registry at startup and fails loudly when a configured
128
+ site or theme is missing from it, when two themes of one site disagree on region names, or
129
+ when a layer overrides a module name neither core nor `kywi.config.ts` defines.
98
130
 
99
131
  Note what's *not* in this list: there is no `components/admin-shell.tsx`, no
100
132
  per-route `app/admin/content/**`, no `lib/admin-auth.ts`. Since 0.2.0 the whole
@@ -137,10 +169,11 @@ It writes the same five files (`AGENTS.md`, `CLAUDE.md`, and the three
137
169
  (otherwise it exits with an error), reads the project name from `package.json`
138
170
  (falling back to the directory name) and the deployment mode from the config.
139
171
  - **Writes the real `AGENTS.md` header** — the "where things live" section is
140
- generated from the landmarks it actually finds on disk (e.g. `lib/site.ts`,
141
- `lib/modules.tsx`, the public `app/(site)/[[...slug]]/page.tsx`), so a
142
- hand-built app that predates part of the scaffold gets an honest header rather
143
- than one that asserts files it doesn't have.
172
+ generated from the landmarks it actually finds on disk (e.g. `lib/modules.tsx`,
173
+ the public `app/(site)/[[...slug]]/page.tsx`, and a `lib/site.ts` which only
174
+ a project scaffolded before the layered render path still has), so a hand-built
175
+ or older app gets an honest header rather than one that asserts files it
176
+ doesn't have, or claims a thin delegate where it holds its own copy.
144
177
  - **Never clobbers by default** — existing files are skipped (a customized
145
178
  `AGENTS.md` is left untouched); pass `--force` to overwrite. It prints a
146
179
  created/updated/skipped summary either way.
@@ -148,6 +181,128 @@ It writes the same five files (`AGENTS.md`, `CLAUDE.md`, and the three
148
181
  Use `@latest` so an existing project picks up the newest guidance regardless of
149
182
  which `create-kywi-app` version originally scaffolded it.
150
183
 
184
+ ## Upgrading a project
185
+
186
+ Every generated render-path file is version-stamped (see the eleven **(stamped)**
187
+ files above), so `create-kywi-app upgrade` can prove which ones are still exactly
188
+ what it emitted and safe to replace, versus which ones carry your own edits and
189
+ must not be touched. Run it **from the project's root**:
190
+
191
+ ```bash
192
+ npx create-kywi-app@latest upgrade --dry-run # see the full report, write nothing
193
+ npx create-kywi-app@latest upgrade # apply it
194
+ ```
195
+
196
+ What it does, one line per file in this order — landmarks, then layer files,
197
+ then retired pre-0.19 glue:
198
+
199
+ - **`created`** — a landmark file the project is missing, or a layer file
200
+ (`sites/<id>/index.ts`, `sites/<id>/themes/<theme>/index.tsx`) a site declared
201
+ in `kywi.config.ts` has none of yet.
202
+ - **`replaced`** — a stamped landmark whose hash proves it is still untouched;
203
+ swapped for the current emission.
204
+ - **`current`** — a stamped landmark that already matches the current emission
205
+ byte for byte.
206
+ - **`kept`** — a layer file under `sites/**` that already exists. Layer files are
207
+ created once and never compared or overwritten again — from the moment they
208
+ exist they are the project's.
209
+ - **`REFUSED (edited)`** / **`REFUSED (unstamped)`** — a landmark whose hash
210
+ doesn't match (someone's work is in it) or that carries no stamp at all
211
+ (predates the layered render path, or was hand-written). The exception: a
212
+ project scaffolded by 0.19.0 has six landmarks (the API route, the four AX
213
+ root routes, the admin catch-all page) that 0.19.0 emitted with no stamp at
214
+ all — `upgrade` recognises those six by their exact 0.19.0 bytes and
215
+ `replace`s them like any other untouched file, so a 0.19.0 project upgrades
216
+ with a plain `upgrade`, no `--force` needed.
217
+ - **`REFUSED (legacy)`** — one of the six retired pre-0.19 files
218
+ (`lib/site.ts`, `lib/config.ts`, `lib/kywi-js-loader.ts`,
219
+ `components/kywi-js-loader.tsx`, `components/personalization-runtime.tsx`,
220
+ `app/(site)/kywi-front-edit.tsx`) is still present; their contents now live
221
+ in `@kywi-software/core/next`, and leaving a stale copy shadows the real
222
+ thing. `--force` turns this into **`removed`** instead.
223
+ - **`backed up: <path> → .kywi-upgrade/<timestamp>/<path>`** — printed above any
224
+ `replaced`/`removed` line that `--force` produced from a refusal: the original
225
+ is copied there before it's touched.
226
+ - **`warn: <path> was ejected from @kywi-software/core <v>; installed is <v>`** —
227
+ advisory only, for a file `create-kywi-app eject` (below) put under `sites/**`
228
+ whose stamped core version no longer matches the installed one.
229
+ - **`note: …`** — advisory hints: stale `AGENTS.md` guidance (`create-kywi-app
230
+ agents --force` refreshes it), or an installed `@kywi-software/core` older
231
+ than the generated files target.
232
+
233
+ Without `--force`, an edited or unstamped file (and the six retired files) is
234
+ **refused, not touched** — the command reports it and leaves it exactly as it
235
+ is. Re-run with `--force` to replace/remove refused files too; each one is
236
+ backed up under `.kywi-upgrade/<timestamp>/` first, so nothing is discarded.
237
+ `--dry-run` runs the identical plan and prints the identical report, but writes
238
+ nothing.
239
+
240
+ **Exit codes:** `0` — nothing was refused (with `--force`, every refusable file
241
+ was replaced/removed instead). `2` — one or more files are still refused
242
+ because `--force` wasn't passed — a CI job should treat this as "the project
243
+ isn't caught up yet." `1` — no `kywi.config.ts` here, or it couldn't be scanned
244
+ (the layer registry is generated from it, so the command would rather refuse to
245
+ write anything than emit one that fails `assertLayerContracts` at boot).
246
+
247
+ **What it never touches:** `kywi.config.ts`, `package.json`, `lib/modules.tsx`,
248
+ `components/site-nav.tsx`, `app/(site)/site.css`, `app/layout.tsx`, and any
249
+ `sites/**` file that already exists.
250
+
251
+ **Where your edits belong now**, once a file is refused or you're migrating off
252
+ a pre-0.19 project:
253
+
254
+ - module renderers → `sites/<site>/index.ts`
255
+ - nav / front-edit / personalization runtime / JS loader → `create-kywi-app
256
+ eject <artifact>` (below)
257
+ - styles → `app/(site)/site.css`
258
+
259
+ ## Ejecting a core artifact
260
+
261
+ Four of core's render-path client components ship as readable `.tsx` sources
262
+ inside `@kywi-software/core`'s `eject/` folder, one per name:
263
+
264
+ | artifact | source file |
265
+ | -------------------------- | ------------------------------- |
266
+ | `nav` | `site-nav.tsx` |
267
+ | `frontEdit` | `front-edit.tsx` |
268
+ | `personalizationRuntime` | `personalization-runtime.tsx` |
269
+ | `jsLoader` | `kywi-js-loader.tsx` |
270
+
271
+ ```bash
272
+ npx create-kywi-app@latest eject <artifact> [--site <id>] [--theme <name>] [--layer site|theme] [--force]
273
+ ```
274
+
275
+ It copies the named artifact into the project as a layer override you own —
276
+ `sites/<site>/themes/<theme>/<file>.tsx` by default (`--layer theme`; `--site`
277
+ defaults to the first site `kywi.config.ts` declares, `--theme` to that site's
278
+ own configured theme), or `sites/<site>/<file>.tsx` with `--layer site`
279
+ (`--theme` is meaningless there — a site layer isn't per-theme). The copy opens with two stamp
280
+ lines: `'use client'` first (a directive after any other line is inert, so this
281
+ has to stay first), then `// kywi-eject <artifact> (@kywi-software/core
282
+ <version>)` — the provenance `create-kywi-app upgrade` reads to warn when
283
+ core's copy has since moved on.
284
+
285
+ When the target layer's index file still has the shape `create-kywi-app`
286
+ generates, `eject` also **wires** the copy into that layer's `templates`
287
+ export automatically (dropping any import that pointed the same entry at its
288
+ previous component, so a re-eject with `--force` doesn't stack imports). When
289
+ the index has been hand-edited into some other shape, it refuses to touch that
290
+ file and prints the two lines to add by hand instead — an import line and a
291
+ `<artifact>: <ExportName>,` entry for inside `templates`.
292
+
293
+ `--force` here means something different than in `upgrade`: it overwrites an
294
+ **existing ejected file** at the target path (without it, `eject` refuses to
295
+ clobber one). Needs `node_modules` installed, and an installed
296
+ `@kywi-software/core >= 0.20.0` (the first release that ships `eject/`).
297
+
298
+ Ejecting is a deliberate, one-artifact opt-out of core upgrades: from that
299
+ moment the file is the project's own, `pnpm up @kywi-software/core` can no
300
+ longer reach it, and `create-kywi-app upgrade` only warns — via the `warn:`
301
+ line above — when the installed core has moved past the version it was copied
302
+ from. Reach for it when you need to change the behaviour of one of these four
303
+ components; everything else (module renderers, styles) has a lighter-weight
304
+ home — see "Where your edits belong now" above.
305
+
151
306
  ## Installing the generated packages
152
307
 
153
308
  All `@kywi-software/*` packages (`core`, `cli`, `sdk`, `mcp`, `js`) are
@@ -18,6 +18,8 @@ import { stdin, stdout, argv, exit } from 'node:process'
18
18
  import { fileURLToPath } from 'node:url'
19
19
  import { readFileSync } from 'node:fs'
20
20
  import { buildFileSet, guidanceFileSet, detectAgentsLandmarks } from '../lib/templates.mjs'
21
+ import { planUpgrade, applyUpgrade, formatReport, exitCodeFor } from '../lib/upgrade.mjs'
22
+ import { planEject, applyEject, wiringHints } from '../lib/eject.mjs'
21
23
 
22
24
  const MODES = ['coupled', 'headless', 'decoupled']
23
25
  const DB_PROVIDERS = ['postgresql', 'mysql']
@@ -38,19 +40,41 @@ function readKywiVersion() {
38
40
  // ── Arg parsing ───────────────────────────────────────────────────────────────
39
41
 
40
42
  function parseArgs(args) {
41
- const opts = { name: undefined, yes: false, mode: undefined, db: undefined, auth: undefined, help: false, force: false, positional: [] }
43
+ const opts = {
44
+ name: undefined,
45
+ yes: false,
46
+ mode: undefined,
47
+ db: undefined,
48
+ auth: undefined,
49
+ help: false,
50
+ force: false,
51
+ // Parsed for the subcommands that offer a preview of the edits they would
52
+ // make; `eject` always writes, so it ignores this one.
53
+ dryRun: false,
54
+ site: undefined,
55
+ theme: undefined,
56
+ layer: undefined,
57
+ positional: [],
58
+ }
42
59
  const positional = opts.positional
43
60
  for (let i = 0; i < args.length; i++) {
44
61
  const arg = args[i]
45
62
  if (arg === '--yes' || arg === '-y') opts.yes = true
46
63
  else if (arg === '--help' || arg === '-h') opts.help = true
47
64
  else if (arg === '--force' || arg === '-f') opts.force = true
65
+ else if (arg === '--dry-run') opts.dryRun = true
48
66
  else if (arg === '--mode') opts.mode = args[++i]
49
67
  else if (arg.startsWith('--mode=')) opts.mode = arg.slice(7)
50
68
  else if (arg === '--db') opts.db = args[++i]
51
69
  else if (arg.startsWith('--db=')) opts.db = arg.slice(5)
52
70
  else if (arg === '--auth') opts.auth = args[++i]
53
71
  else if (arg.startsWith('--auth=')) opts.auth = arg.slice(7)
72
+ else if (arg === '--site') opts.site = args[++i]
73
+ else if (arg.startsWith('--site=')) opts.site = arg.slice(7)
74
+ else if (arg === '--theme') opts.theme = args[++i]
75
+ else if (arg.startsWith('--theme=')) opts.theme = arg.slice(8)
76
+ else if (arg === '--layer') opts.layer = args[++i]
77
+ else if (arg.startsWith('--layer=')) opts.layer = arg.slice(8)
54
78
  else if (!arg.startsWith('-')) positional.push(arg)
55
79
  }
56
80
  if (positional.length > 0) opts.name = positional[0]
@@ -68,13 +92,30 @@ Usage:
68
92
  .claude/skills/) in the CURRENT
69
93
  Kywi project — run it from the
70
94
  project root
95
+ create-kywi-app upgrade [--dry-run] [--force]
96
+ Bring the CURRENT project's
97
+ generated render path up to this
98
+ create-kywi-app: replace stamped,
99
+ unedited files, create what is
100
+ missing, and report anything it
101
+ refuses to touch
102
+ create-kywi-app eject <artifact> Copy one of core's render
103
+ artifacts into this project as a
104
+ layer override you own
71
105
 
72
106
  Options:
73
107
  --yes, -y Use defaults, skip prompts
74
108
  --mode <mode> coupled | headless | decoupled (default: coupled)
75
109
  --db <provider> postgresql | mysql (default: postgresql)
76
110
  --auth <list> comma-separated: credentials,google,github (default: credentials)
111
+ --site <id> eject: which site's layer (default: the first site in kywi.config.ts)
112
+ --theme <name> eject: which theme's layer (default: that site's theme)
113
+ --layer <layer> eject: site | theme (default: theme)
77
114
  --force, -f agents: overwrite existing guidance files (default: skip them)
115
+ upgrade: replace edited/unstamped/legacy files too — each
116
+ original is copied to .kywi-upgrade/<timestamp>/ first
117
+ --dry-run upgrade: print the full report, write nothing
118
+ eject: overwrite an existing override
78
119
  --help, -h Show this help
79
120
 
80
121
  Examples:
@@ -83,6 +124,13 @@ Examples:
83
124
  npx create-kywi-app blog --mode headless --auth credentials,google
84
125
  npx create-kywi-app@latest agents # add guidance to an existing project
85
126
  npx create-kywi-app@latest agents --force # refresh it to the latest version
127
+ npx create-kywi-app@latest upgrade --dry-run # see what an upgrade would change
128
+ npx create-kywi-app@latest upgrade # apply it
129
+
130
+ Exit codes (upgrade): 0 nothing refused · 2 something was refused · 1 the project
131
+ or its kywi.config.ts could not be read.
132
+ npx create-kywi-app@latest eject nav # own the site nav in this theme
133
+ npx create-kywi-app@latest eject frontEdit --layer site
86
134
  `)
87
135
  }
88
136
 
@@ -256,8 +304,83 @@ async function runAgentsCommand(opts) {
256
304
  return 0
257
305
  }
258
306
 
307
+ // ── `upgrade` subcommand ──────────────────────────────────────────────────────
308
+
309
+ /**
310
+ * `create-kywi-app upgrade [--dry-run] [--force]` — bring an existing project's
311
+ * generated render path up to this create-kywi-app's emission. The whole
312
+ * decision lives in ../lib/upgrade.mjs (planned, applied, formatted and scored
313
+ * separately so it is unit-testable and so `--dry-run` cannot drift from a real
314
+ * run); this wrapper is just cwd + stdout + exit code.
315
+ */
316
+ async function runUpgradeCommand(opts) {
317
+ const plan = planUpgrade(process.cwd(), { force: opts.force, dryRun: opts.dryRun })
318
+ const applied = applyUpgrade(plan, { dryRun: opts.dryRun })
319
+ stdout.write(formatReport(plan, applied))
320
+ return exitCodeFor(plan)
321
+ }
322
+
323
+ // ── `eject` subcommand ────────────────────────────────────────────────────────
324
+
325
+ /**
326
+ * `create-kywi-app eject <artifact> [--site <id>] [--theme <name>] [--layer site|theme] [--force]`
327
+ *
328
+ * Copies one of core's render artifacts (`nav`, `frontEdit`,
329
+ * `personalizationRuntime`, `jsLoader`) into this project's layer chain as an
330
+ * override the project owns, and wires it into that layer's `templates` export.
331
+ * The mechanics live in ../lib/eject.mjs; this is argument handling and the
332
+ * report — including the cost, stated plainly: the copy stops following core.
333
+ */
334
+ async function runEjectCommand(opts) {
335
+ const cwd = process.cwd()
336
+ // positional[0] is the subcommand name itself.
337
+ const artifact = opts.positional[1]
338
+
339
+ let plan
340
+ let result
341
+ try {
342
+ plan = planEject(cwd, { artifact, site: opts.site, theme: opts.theme, layer: opts.layer })
343
+ result = applyEject(plan, { force: opts.force })
344
+ } catch (err) {
345
+ if (!err?.userFacing) throw err
346
+ stdout.write(`\n✖ ${err.message}\n`)
347
+ return err.exitCode ?? 1
348
+ }
349
+
350
+ stdout.write(`\n${result.overwrote ? 'overwrote' : 'ejected'}: ${plan.targetRel}\n`)
351
+ if (result.wired) {
352
+ stdout.write(`wired: ${plan.indexRel}\n`)
353
+ } else {
354
+ const [importLine, entryLine] = wiringHints(plan)
355
+ stdout.write(`not wired (edit by hand): ${plan.indexRel}\n`)
356
+ stdout.write(` ${importLine}\n`)
357
+ stdout.write(` ${entryLine} ← inside its \`templates\` object\n`)
358
+ }
359
+
360
+ const where = plan.theme ? `site "${plan.site}", theme "${plan.theme}"` : `site "${plan.site}"`
361
+ stdout.write(
362
+ `\n✔ ${plan.artifact} is now a ${plan.layer}-layer override for ${where}. ` +
363
+ `It no longer follows core upgrades; \`create-kywi-app upgrade\` will warn when core ${plan.coreVersion} moves.\n`,
364
+ )
365
+ return 0
366
+ }
367
+
259
368
  // ── Main ──────────────────────────────────────────────────────────────────────
260
369
 
370
+ /**
371
+ * Subcommands that operate on the CURRENT project rather than scaffolding a new
372
+ * one. A bare first positional that is not a key here is a project name, which
373
+ * is why the table is consulted before anything else runs. Null-prototyped so a
374
+ * project literally named `constructor` or `toString` scaffolds instead of
375
+ * dispatching to something off Object.prototype.
376
+ */
377
+ const COMMANDS = {
378
+ __proto__: null,
379
+ agents: runAgentsCommand,
380
+ upgrade: runUpgradeCommand,
381
+ eject: runEjectCommand,
382
+ }
383
+
261
384
  async function main() {
262
385
  const opts = parseArgs(argv.slice(2))
263
386
  if (opts.help) {
@@ -265,11 +388,10 @@ async function main() {
265
388
  return 0
266
389
  }
267
390
 
268
- // Subcommand: `create-kywi-app agents [--force]` refreshes the agent-guidance
269
- // files in an existing project instead of scaffolding a new one.
270
- if (opts.positional[0] === 'agents') {
271
- return runAgentsCommand(opts)
272
- }
391
+ // A subcommand (`agents`, `upgrade`, `eject`) acts on the CURRENT project instead of
392
+ // scaffolding a new one.
393
+ const cmd = COMMANDS[opts.positional[0]]
394
+ if (cmd) return cmd(opts)
273
395
 
274
396
  let answers
275
397
  try {