create-kywi-app 0.23.0 → 0.25.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
@@ -60,7 +60,7 @@ pages at their slug.
60
60
 
61
61
  ```
62
62
  package.json scripts (dev/build/start/migrate/seed) + deps incl. drizzle-kit + tsx
63
- kywi.config.ts defineKywiConfig/Site/Theme + a default "Page" content type
63
+ kywi.config.ts defineKywiConfig/Site/Theme + a default "Page" content type in defineContentType
64
64
  tsconfig.json
65
65
  next.config.mjs the FULL config to consume core (transpilePackages, extensionAlias,
66
66
  serverExternalPackages) — every line is load-bearing
@@ -69,8 +69,15 @@ next.config.mjs the FULL config to consume core (transpilePac
69
69
  README.md project-specific quick start (createdb, migrate, seed, dev, /admin, admin.features)
70
70
  AGENTS.md guidance for AI agents working on this site: an app-specific
71
71
  header (where things live, the /admin URL, the CLI, the three
72
- scaffolded Claude Code skills) + Kywi's official building
73
- patterns (docs/agents/AGENT-PATTERNS.md, verbatim)
72
+ scaffolded Claude Code skills, a pointer to AGENTS.local.md) +
73
+ Kywi's official building patterns (docs/agents/AGENT-PATTERNS.md,
74
+ verbatim). AGENTS.local.md itself is never generated: it is
75
+ where the project's own instructions go
76
+ .mcp.json wires both Kywi MCP servers for an agent client: kywi-site (content,
77
+ nav, media over this app's API at ${KYWI_API_URL}, key from
78
+ ${KYWI_API_KEY}, an env reference, never a value) and kywi-dev
79
+ (edits ./kywi.config.ts on disk). Safe to commit; the key stays in
80
+ the gitignored .env / .env.local
74
81
  CLAUDE.md thin pointer that sends a Claude Code session to AGENTS.md first,
75
82
  plus a nudge toward the kywi-content-model skill (and the other
76
83
  two scaffolded skills)
@@ -99,14 +106,17 @@ app/admin/[[...admin]]/page.tsx mounts the FULL core admin (KywiAdminApp, eve
99
106
 
100
107
  **coupled** additionally gets `app/(site)/layout.tsx` + `app/(site)/[[...slug]]/page.tsx`
101
108
  (the public site — renders "/" and every published page at its slug, both **stamped**),
109
+ `app/kywi.js/route.ts` (**stamped**: core serves the installed `@kywi-software/js`
110
+ browser bundle at `/kywi.js`, so `pnpm up @kywi-software/js` is the whole upgrade
111
+ and there is no copy step),
102
112
  plus `app/(site)/site.css` and `components/site-nav.tsx` (project-owned, unstamped).
103
113
  **headless** and **decoupled** instead get `app/page.tsx` (returns 404 — no public
104
114
  rendering, unstamped).
105
115
 
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>)
116
+ All twelve of the files marked **(stamped)** above are **thin, version-stamped
117
+ delegates**: the two `app/(site)` route files and `app/kywi.js/route.ts` (coupled
118
+ only), `middleware.ts`, `lib/kywi.ts`, `kywi.layers.ts`, the API route, the four
119
+ AX root routes and the admin catch-all page. Each opens with a `// kywi-render v2 (create-kywi-app <version>)
110
120
  sha256:<12 hex>` comment — the hash is over the file's own body, so
111
121
  `create-kywi-app upgrade` (see below) can tell an untouched generated file
112
122
  (safe to replace) from one you've edited (refused) without a diff against
@@ -128,6 +138,16 @@ generated files. Core validates the registry at startup and fails loudly when a
128
138
  site or theme is missing from it, when two themes of one site disagree on region names, or
129
139
  when a layer overrides a module name neither core nor `kywi.config.ts` defines.
130
140
 
141
+ **`templates.hydrateLayout` reads the site from `ctx`, never from the runtime.** The
142
+ transform is called per request with `ctx: { runtime, siteId, site }`, where `ctx.siteId`
143
+ is the DB id of the site *this request* is for. `ctx.runtime.siteId` is the default site,
144
+ fixed at boot, so a query scoped by it serves site A's data on site B's host:
145
+
146
+ ```ts
147
+ hydrateLayout: async (layout, ctx) =>
148
+ withFeatured(layout, await ctx.runtime.scope.content.getBySlug('featured', ctx.siteId)), // not ctx.runtime.siteId
149
+ ```
150
+
131
151
  Note what's *not* in this list: there is no `components/admin-shell.tsx`, no
132
152
  per-route `app/admin/content/**`, no `lib/admin-auth.ts`. Since 0.2.0 the whole
133
153
  admin is one component (`KywiAdminApp`) shipped by `@kywi-software/core` — the
@@ -162,8 +182,9 @@ npx create-kywi-app@latest agents # add any missing guidance files
162
182
  npx create-kywi-app@latest agents --force # overwrite them with the latest
163
183
  ```
164
184
 
165
- It writes the same five files (`AGENTS.md`, `CLAUDE.md`, and the three
166
- `.claude/skills/<slug>/SKILL.md`). What it does:
185
+ It writes the same guidance files a fresh scaffold gets (`AGENTS.md`,
186
+ `CLAUDE.md`, `.mcp.json` and the `.claude/skills/<slug>/SKILL.md` skills). What
187
+ it does:
167
188
 
168
189
  - **Detects the project** — requires a `kywi.config.ts` in the current directory
169
190
  (otherwise it exits with an error), reads the project name from `package.json`
@@ -176,14 +197,28 @@ It writes the same five files (`AGENTS.md`, `CLAUDE.md`, and the three
176
197
  doesn't have, or claims a thin delegate where it holds its own copy.
177
198
  - **Never clobbers by default** — existing files are skipped (a customized
178
199
  `AGENTS.md` is left untouched); pass `--force` to overwrite. It prints a
179
- created/updated/skipped summary either way.
200
+ created/updated/skipped/kept summary either way.
201
+ - **Merges `.mcp.json` instead of replacing it** — a project may wire its own
202
+ MCP servers there. Without `--force` it adds whichever of `kywi-site` /
203
+ `kywi-dev` is missing; with `--force` it resets those two entries. Any other
204
+ server is kept as-is, and a file that is not valid JSON is left alone and
205
+ reported as skipped. To use the site server, create an API key (admin → Web
206
+ Services → API Keys) and export `KYWI_API_KEY` (and `KYWI_API_URL` for a
207
+ deployed site) in the shell you start your agent from; `.mcp.json` only
208
+ references them.
209
+ - **Never writes `AGENTS.local.md`** — that file is where this project's own
210
+ agent instructions belong (deploy steps, site conventions, anything specific
211
+ to this site). `AGENTS.md` and `CLAUDE.md` point at it; `create-kywi-app`
212
+ never creates, rewrites or deletes it, so `agents --force` cannot lose what is
213
+ in it. When it exists, the run reports it as `kept`. Do not put site-specific
214
+ guidance in `AGENTS.md` itself: the next `--force` refresh replaces it.
180
215
 
181
216
  Use `@latest` so an existing project picks up the newest guidance regardless of
182
217
  which `create-kywi-app` version originally scaffolded it.
183
218
 
184
219
  ## Upgrading a project
185
220
 
186
- Every generated render-path file is version-stamped (see the eleven **(stamped)**
221
+ Every generated render-path file is version-stamped (see the twelve **(stamped)**
187
222
  files above), so `create-kywi-app upgrade` can prove which ones are still exactly
188
223
  what it emitted and safe to replace, versus which ones carry your own edits and
189
224
  must not be touched. Run it **from the project's root**:
@@ -201,6 +236,11 @@ then retired pre-0.19 glue:
201
236
  in `kywi.config.ts` has none of yet.
202
237
  - **`replaced`** — a stamped landmark whose hash proves it is still untouched;
203
238
  swapped for the current emission.
239
+ - **`relabeled`** — a stamped landmark whose body is byte-identical to the
240
+ current emission and whose stamp only names an older create-kywi-app (`(stamp
241
+ says create-kywi-app 0.22.0; body identical, not rewritten)`). Nothing changed
242
+ in the code, so the file is left exactly as it is: a release that changed no
243
+ render file costs no commit. It counts as caught up, like `current`.
204
244
  - **`current`** — a stamped landmark that already matches the current emission
205
245
  byte for byte.
206
246
  - **`kept`** — a layer file under `sites/**` that already exists. Layer files are
@@ -223,6 +263,18 @@ then retired pre-0.19 glue:
223
263
  - **`backed up: <path> → .kywi-upgrade/<timestamp>/<path>`** — printed above any
224
264
  `replaced`/`removed` line that `--force` produced from a refusal: the original
225
265
  is copied there before it's touched.
266
+ - **`warn: public/kywi.js is a copied bundle; …`** / **`warn: package.json does
267
+ not depend on @kywi-software/js, …`** — advisory only, coupled projects: core
268
+ now serves `/kywi.js` from `app/kywi.js/route.ts` (which `upgrade` creates), so
269
+ a hand-copied `public/kywi.js` (and any script that copies it) should go, and
270
+ the project needs `@kywi-software/js` as a dependency for the route to have a
271
+ bundle to serve. `upgrade` never edits `package.json` or `public/` itself.
272
+ - **`warn: <file> imports <target>, which upgrade removes — import from
273
+ @kywi-software/core/next instead`** — advisory only: a project file of yours
274
+ (any `.ts`/`.tsx` outside `node_modules`, `.next` and `.kywi-upgrade`) has a
275
+ relative or `@/` import that resolves to one of the retired files above, or
276
+ imports a name (for example `KywiRuntime`) from a `lib/kywi.ts` that the
277
+ regenerated thin file no longer exports. Repoint it before you build.
226
278
  - **`warn: <path> was ejected from @kywi-software/core <v>; installed is <v>`** —
227
279
  advisory only, for a file `create-kywi-app eject` (below) put under `sites/**`
228
280
  whose stamped core version no longer matches the installed one.
@@ -729,8 +729,10 @@ Four traps, each of which fails silently:
729
729
  audience arm first, so a visitor who matches an audience never enters the
730
730
  split. Don't run an experiment and an audience variant on the same container.
731
731
  4. **The client runtime needs `/kywi.js`.** Self-ID, behavioral re-evaluation
732
- and the transparency panel no-op silently unless the built
733
- `@kywi-software/js` bundle is at `public/kywi.js`.
732
+ and the transparency panel no-op silently unless the app serves `/kywi.js`.
733
+ A coupled scaffold does, through `app/kywi.js/route.ts` (core's
734
+ `createKywiJsRoute`) over the installed `@kywi-software/js`: keep that
735
+ package as a dependency, and never copy the bundle into `public/`.
734
736
 
735
737
  Standing rule: **the default arm must be complete on its own.** It is what
736
738
  search engines, answer engines, and every opted-out visitor receive.
@@ -17,7 +17,9 @@ import { createInterface } from 'node:readline/promises'
17
17
  import { stdin, stdout, argv, exit } from 'node:process'
18
18
  import { fileURLToPath } from 'node:url'
19
19
  import { readFileSync } from 'node:fs'
20
- import { buildFileSet, guidanceFileSet, detectAgentsLandmarks } from '../lib/templates.mjs'
20
+ import {
21
+ buildFileSet, guidanceFileSet, detectAgentsLandmarks, AGENTS_LOCAL_PATH, MCP_JSON_PATH, mergeMcpJson,
22
+ } from '../lib/templates.mjs'
21
23
  import { planUpgrade, applyUpgrade, formatReport, exitCodeFor } from '../lib/upgrade.mjs'
22
24
  import { planEject, applyEject, wiringHints } from '../lib/eject.mjs'
23
25
 
@@ -280,6 +282,19 @@ async function runAgentsCommand(opts) {
280
282
  for (const [rel, content] of Object.entries(files)) {
281
283
  const abs = join(cwd, rel)
282
284
  const exists = existsSync(abs)
285
+ // .mcp.json may wire the project's own servers too: merge, never replace.
286
+ if (rel === MCP_JSON_PATH && exists) {
287
+ const merged = mergeMcpJson(readFileSync(abs, 'utf8'), answers, { force: opts.force })
288
+ if (merged.action === 'skipped') {
289
+ stdout.write(` skipped (${merged.reason === 'invalid' ? 'not valid JSON' : 'exists'}): ${rel}\n`)
290
+ skipped++
291
+ } else {
292
+ await writeFile(abs, merged.content, 'utf8')
293
+ stdout.write(` updated: ${rel}${merged.added.length > 0 ? ` (added ${merged.added.join(', ')})` : ''}\n`)
294
+ updated++
295
+ }
296
+ continue
297
+ }
283
298
  if (exists && !opts.force) {
284
299
  stdout.write(` skipped (exists): ${rel}\n`)
285
300
  skipped++
@@ -296,7 +311,14 @@ async function runAgentsCommand(opts) {
296
311
  }
297
312
  }
298
313
 
299
- stdout.write(`\n✔ ${created} created, ${updated} updated, ${skipped} skipped.\n`)
314
+ // The project's own guidance is never written, only acknowledged (#272).
315
+ let kept = 0
316
+ if (existsSync(join(cwd, AGENTS_LOCAL_PATH))) {
317
+ stdout.write(` kept: ${AGENTS_LOCAL_PATH} (this project's own instructions; never written)\n`)
318
+ kept++
319
+ }
320
+
321
+ stdout.write(`\n✔ ${created} created, ${updated} updated, ${skipped} skipped, ${kept} kept.\n`)
300
322
  if (skipped > 0) {
301
323
  stdout.write(` Re-run with --force to overwrite the skipped file(s).\n`)
302
324
  }
package/lib/templates.mjs CHANGED
@@ -53,9 +53,9 @@ function packageJson(a) {
53
53
  // — see `ensureAdminStylesheet` in core's front-edit.tsx). The SYNC stays
54
54
  // project-owned even though the overlay is core's: core fetches
55
55
  // /kywi-admin.css.version at runtime, and only the app can put a file in its
56
- // own public/. Unlike public/kywi.js (opt-in, manually placed when
57
- // personalization.clientRuntime is enabled — see README), this is a CORE
58
- // feature every coupled-mode app has, so the sync is automatic.
56
+ // own public/. (The browser runtime is NOT synced this way: core serves
57
+ // /kywi.js itself, from the installed @kywi-software/js, through the stamped
58
+ // app/kywi.js/route.ts — #236. Upgrading the package is the whole upgrade.)
59
59
  const syncScripts =
60
60
  a.mode === 'coupled'
61
61
  ? {
@@ -91,6 +91,9 @@ function packageJson(a) {
91
91
  // Server-side image processing used by core's media pipeline. Listed in
92
92
  // next.config's serverExternalPackages, so it must be installed.
93
93
  sharp: '^0.34.0',
94
+ // The browser runtime core serves at /kywi.js (app/kywi.js/route.ts):
95
+ // nav enhancement and the personalization client layer. Coupled only.
96
+ ...(a.mode === 'coupled' ? { '@kywi-software/js': CORE_RANGE(a.kywiVersion) } : {}),
94
97
  },
95
98
  devDependencies: {
96
99
  '@types/node': '^20.0.0',
@@ -184,7 +187,7 @@ try {
184
187
  /** @param {Answers} a */
185
188
  function kywiConfig(a) {
186
189
  const providers = a.authProviders.map((p) => `'${p}'`).join(', ')
187
- return `import { defineKywiConfig, defineSite, defineTheme, defineModule } from '@kywi-software/core/config'
190
+ return `import { defineKywiConfig, defineSite, defineTheme, defineContentType, defineModule } from '@kywi-software/core/config'
188
191
  import { assertProductionAuthSecret } from '@kywi-software/core/host'
189
192
 
190
193
  // Refuse to build/start in production with a missing or dev-fallback AUTH_SECRET
@@ -268,14 +271,16 @@ export default defineKywiConfig({
268
271
  // (admin → Content Types). Admin-created types are runtime-managed, so the
269
272
  // site owner can add a field next year without a developer or a migration.
270
273
  // Add a type HERE only when the *developer* must own its shape — code depends
271
- // on it, it's reviewed in git, it migrates with \`pnpm migrate\`.
274
+ // on it, it's reviewed in git, it migrates with \`pnpm migrate\`. Wrap each one
275
+ // in \`defineContentType({ … })\`: it applies the type defaults a bare object
276
+ // literal skips.
272
277
  contentTypes: [
273
- {
278
+ defineContentType({
274
279
  name: 'page',
275
280
  label: 'Page',
276
281
  baseType: 'content',
277
282
  fields: [],
278
- },
283
+ }),
279
284
  ],
280
285
 
281
286
  // Custom layout modules (\`defineModule\`) — the palette additions this project
@@ -381,7 +386,16 @@ declare module '*.css'
381
386
 
382
387
  // ── next.config.mjs ───────────────────────────────────────────────────────────
383
388
 
384
- function nextConfig() {
389
+ function nextConfig(a) {
390
+ // Serverless hosts ship only the files Next traces into each function, and
391
+ // core's /kywi.js route reads the bundle with fs at request time, which the
392
+ // tracer cannot follow. Coupled only: the other modes have no such route.
393
+ const tracing = a.mode === 'coupled'
394
+ ? `
395
+ // core serves /kywi.js from the installed @kywi-software/js (app/kywi.js/route.ts);
396
+ // trace the bundle into that function so serverless deploys include it.
397
+ outputFileTracingIncludes: { '/kywi.js': ['./node_modules/@kywi-software/js/dist/kywi.js'] },`
398
+ : ''
385
399
  return `/**
386
400
  * Next.js config for a Kywi app. Every setting here is required to consume
387
401
  * @kywi-software/core; removing any of them breaks the build:
@@ -406,7 +420,7 @@ const nextConfig = {
406
420
  'nodemailer',
407
421
  'bcryptjs',
408
422
  '@aws-sdk/client-s3',
409
- ],
423
+ ],${tracing}
410
424
  webpack(config) {
411
425
  config.resolve.extensionAlias = {
412
426
  ...config.resolve.extensionAlias,
@@ -437,6 +451,15 @@ DATABASE_URL=postgres://localhost:5432/CHANGE_ME
437
451
  # 32+ char random string. Generate: openssl rand -base64 32
438
452
  # Required in production — the app refuses to start without it.
439
453
  AUTH_SECRET=CHANGE_ME
454
+
455
+ # Agent access over MCP (.mcp.json). The site server talks to this app's REST
456
+ # API; .mcp.json only REFERENCES the key as \${KYWI_API_KEY}, so the key itself
457
+ # never lands in a committed file. Create one in admin → Web Services → API
458
+ # Keys, keep it here (or in .env.local), and export both variables in the shell
459
+ # you start your agent from. The developer server needs no key: it edits
460
+ # kywi.config.ts on disk.
461
+ # KYWI_API_URL=http://localhost:3000/api/v1
462
+ # KYWI_API_KEY=
440
463
  `
441
464
  }
442
465
 
@@ -454,6 +477,7 @@ function faviconSvg() {
454
477
  function gitignore() {
455
478
  return `node_modules
456
479
  .next
480
+ # Secrets, including the KYWI_API_KEY that .mcp.json references.
457
481
  .env
458
482
  .env.local
459
483
  *.log
@@ -469,7 +493,7 @@ public/kywi-admin.css.version
469
493
  // ── the generated render path: version stamp + landmarks ─────────────────────
470
494
 
471
495
  /**
472
- * Version of the GENERATED RENDER PATH's SHAPE — the eleven generated files
496
+ * Version of the GENERATED RENDER PATH's SHAPE — the generated files
473
497
  * {@link RENDER_LANDMARK_PATHS} names, independent of the package version. Bump
474
498
  * it when their shape changes in a way an existing project should pick up, so a
475
499
  * project can compare its stamp against a newer create-kywi-app and know its
@@ -547,7 +571,7 @@ export function splitRenderStamp(text) {
547
571
  */
548
572
  export const RENDER_STAMP_RE = /^\/\/ kywi-render v(\d+) \(create-kywi-app ([^)]+)\)(?: sha256:([0-9a-f]{12}))?$/m
549
573
 
550
- /** @typedef {'runtime'|'middleware'|'siteLayout'|'sitePage'|'layers'|'apiRoute'|'axLlms'|'axLlmsFull'|'axSitemap'|'axRobots'|'adminHost'} RenderLandmark */
574
+ /** @typedef {'runtime'|'middleware'|'siteLayout'|'sitePage'|'layers'|'apiRoute'|'axLlms'|'axLlmsFull'|'axSitemap'|'axRobots'|'adminHost'|'kywiJs'} RenderLandmark */
551
575
 
552
576
  /**
553
577
  * Landmark → its relative path, for the RENDER PATH: one entry per generated
@@ -557,8 +581,9 @@ export const RENDER_STAMP_RE = /^\/\/ kywi-render v(\d+) \(create-kywi-app ([^)]
557
581
  * the E8 drift guard (`__tests__/render-drift.test.mjs`) and, in plan E3, the
558
582
  * `upgrade` command.
559
583
  *
560
- * ALL ELEVEN generated render files are listed, not just the thin delegates
561
- * (plan E3 Task 1). The membership rule is "create-kywi-app wrote it and owns
584
+ * ALL generated render files are listed, not just the thin delegates (plan
585
+ * E3 Task 1): eleven at E3, plus `kywiJs` (#236), the coupled-only
586
+ * `/kywi.js` route. The membership rule is "create-kywi-app wrote it and owns
562
587
  * its shape", because that is what `upgrade` must be able to find and — when
563
588
  * the stamp's hash proves the file untouched — replace. The API route, the four
564
589
  * AX root routes and the admin host page qualify on exactly that basis; the
@@ -579,6 +604,7 @@ export const RENDER_LANDMARK_PATHS = {
579
604
  axSitemap: 'app/sitemap.xml/route.ts',
580
605
  axRobots: 'app/robots.txt/route.ts',
581
606
  adminHost: 'app/admin/[[...admin]]/page.tsx',
607
+ kywiJs: 'app/kywi.js/route.ts',
582
608
  }
583
609
 
584
610
  /**
@@ -624,6 +650,7 @@ export function renderLandmarks(mode) {
624
650
  axSitemap: true,
625
651
  axRobots: true,
626
652
  adminHost: true,
653
+ kywiJs: coupled,
627
654
  }
628
655
  }
629
656
 
@@ -1242,6 +1269,27 @@ export const dynamic = 'force-dynamic'
1242
1269
  `)
1243
1270
  }
1244
1271
 
1272
+ /**
1273
+ * `/kywi.js` — the `@kywi-software/js` browser runtime, served by core's
1274
+ * `createKywiJsRoute` from the package this app installed (#236). Core's public
1275
+ * render path injects `<script src="/kywi.js">` for the nav enhancement and the
1276
+ * personalization client layer; before this route a project had to copy the
1277
+ * bundle into `public/` and keep the copy in step with the package by hand.
1278
+ * Coupled only, like the `app/(site)` routes it serves.
1279
+ */
1280
+ function kywiJsRoute() {
1281
+ return stamped(`import { createKywiJsRoute } from '@kywi-software/core/next'
1282
+
1283
+ const route = createKywiJsRoute()
1284
+
1285
+ /** \`/kywi.js\` — the installed @kywi-software/js browser bundle, served by core (#236). */
1286
+ export const GET = route.GET
1287
+ export const HEAD = route.HEAD
1288
+
1289
+ export const dynamic = 'force-dynamic'
1290
+ `)
1291
+ }
1292
+
1245
1293
  // ── API route (thin proxy over @kywi-software/core/host) ──────────────────────
1246
1294
 
1247
1295
  function apiRoute() {
@@ -1485,19 +1533,26 @@ Audiences, experiments and the self-ID widget you configure in the admin resolve
1485
1533
 
1486
1534
  The **self-ID widget**, live re-evaluation, the transparency bar and behavioral
1487
1535
  signals are an optional *client* layer. Enable it by setting
1488
- \`personalization: { clientRuntime: true }\` on your theme in \`kywi.config.ts\` and
1489
- dropping the built \`@kywi-software/js\` browser bundle at \`public/kywi.js\`;
1490
- core's \`PersonalizationRuntime\` (mounted by the public page) loads it
1491
- best-effort. Without it, the server-rendered variant is what every visitor sees.
1492
-
1493
- The same \`public/kywi.js\` bundle also drives the header nav's hover-intent,
1494
- Escape/arrow-key and viewport-edge-flip enhancement — independent of
1495
- personalization. Core's site layout loads it whenever the site has
1496
- a nav, whether or not \`clientRuntime\` is on, so the nav gets that polish on
1497
- every site once the bundle is in place; the nav renders and works fully without
1498
- it either way. (\`public/kywi-admin.css\`, the front-of-site editor's stylesheet,
1499
- is a separate file synced automatically on \`dev\`/\`build\` — see "Front-of-site
1500
- editor" above — unlike \`kywi.js\`, which you place yourself.)
1536
+ \`personalization: { clientRuntime: true }\` on your theme in \`kywi.config.ts\`;
1537
+ core's \`PersonalizationRuntime\` (mounted by the public page) loads the browser
1538
+ runtime from \`/kywi.js\` best-effort. Without it, the server-rendered variant is
1539
+ what every visitor sees.
1540
+
1541
+ \`/kywi.js\` is served by core: \`app/kywi.js/route.ts\` (stamped) delegates to
1542
+ \`createKywiJsRoute\`, which reads the bundle from the \`@kywi-software/js\` this
1543
+ project depends on, with a short revalidating cache and an ETag. There is no copy
1544
+ step: \`pnpm up @kywi-software/js\` is the whole upgrade, and the next request
1545
+ serves the new bundle. Keep \`@kywi-software/js\` on the same version as
1546
+ \`@kywi-software/core\`. \`next.config.mjs\` traces the bundle into that route
1547
+ (\`outputFileTracingIncludes\`) so serverless deploys ship it.
1548
+
1549
+ The same bundle also drives the header nav's hover-intent, Escape/arrow-key and
1550
+ viewport-edge-flip enhancement — independent of personalization. Core's site
1551
+ layout loads it whenever the site has a nav, whether or not \`clientRuntime\` is
1552
+ on; the nav renders and works fully without it either way.
1553
+ (\`public/kywi-admin.css\`, the front-of-site editor's stylesheet, is a separate
1554
+ file synced automatically on \`dev\`/\`build\` — see "Front-of-site editor"
1555
+ above.)
1501
1556
 
1502
1557
  ## Locales
1503
1558
 
@@ -1901,6 +1956,11 @@ export function agentsMd(a, landmarks = scaffoldLandmarks(a.mode)) {
1901
1956
  ${intro}
1902
1957
  ${modeSentence}
1903
1958
 
1959
+ ${AGENTS_LOCAL_POINTER}
1960
+ It is yours: \`create-kywi-app\` never writes it, and \`create-kywi-app agents
1961
+ --force\` never touches it. Where it disagrees with the generic guidance below,
1962
+ it wins.
1963
+
1904
1964
  ## Where things live
1905
1965
 
1906
1966
  ${bullets.join('\n')}
@@ -1926,6 +1986,18 @@ CMS instead of hardcoding it.**
1926
1986
  return header + agentPatternsDoc()
1927
1987
  }
1928
1988
 
1989
+ /**
1990
+ * Where a project keeps its OWN agent instructions (#272). The generator never
1991
+ * writes this file, in any command: `agents --force` rewrites AGENTS.md,
1992
+ * CLAUDE.md and the skills wholesale, so site-specific guidance kept in any of
1993
+ * those would be deleted on the next refresh. AGENTS.md and CLAUDE.md point at
1994
+ * it instead, and `agents` reports it `kept` when it exists.
1995
+ */
1996
+ export const AGENTS_LOCAL_PATH = 'AGENTS.local.md'
1997
+
1998
+ /** The pointer line AGENTS.md carries to {@link AGENTS_LOCAL_PATH}. */
1999
+ export const AGENTS_LOCAL_POINTER = `See \`${AGENTS_LOCAL_PATH}\` for this project's own instructions.`
2000
+
1929
2001
  /**
1930
2002
  * CLAUDE.md — a thin pointer so a Claude Code session reads AGENTS.md first.
1931
2003
  * @param {Answers} a
@@ -1936,12 +2008,102 @@ export function claudeMd(a) {
1936
2008
  This is a **Kywi CMS** project. **Read \`AGENTS.md\` before building anything** — it
1937
2009
  holds Kywi's official building patterns and a map of where things live in this app.
1938
2010
 
2011
+ This project's own instructions, if it has any, are in \`AGENTS.local.md\` (never written by \`create-kywi-app\`).
2012
+
1939
2013
  Core principle: **model content in the CMS instead of hardcoding it** — see \`AGENTS.md\`.
1940
2014
 
1941
2015
  Before building out a new content area, use the \`kywi-content-model\` skill (design the model, get sign-off) — skills in \`.claude/skills/\` also cover collections, custom modules, and personalization.
1942
2016
  `
1943
2017
  }
1944
2018
 
2019
+ // ── .mcp.json (#297) ──────────────────────────────────────────────────────────
2020
+
2021
+ /** Where an MCP client (Claude Code and others) reads a project's servers. */
2022
+ export const MCP_JSON_PATH = '.mcp.json'
2023
+
2024
+ /**
2025
+ * The two Kywi MCP servers, keyed by the names `@kywi-software/mcp`'s README
2026
+ * uses. Both run through `npx --package` pinned to this release's range, so the
2027
+ * file works whether or not the project installed `@kywi-software/mcp`.
2028
+ *
2029
+ * - `kywi-site` — content, nav and media over this app's REST API. The URL and
2030
+ * the key are `\${VAR}` references the client expands from its environment,
2031
+ * so the key is never written to a file that gets committed: it lives in the
2032
+ * gitignored `.env` / `.env.local`. The server reads its key from
2033
+ * `KYWI_MCP_API_KEY`; the project-facing name is `KYWI_API_KEY`. The `:-`
2034
+ * defaults matter: a client refuses a config whose variable is unset with no
2035
+ * default, which would take the developer server down with it.
2036
+ * - `kywi-dev` — reads and edits `kywi.config.ts` on disk (filesystem mode),
2037
+ * relative to the directory the agent was started in: the project root.
2038
+ * @param {Answers} a
2039
+ * @returns {Record<string, { command: string, args: string[], env: Record<string, string> }>}
2040
+ */
2041
+ export function kywiMcpServers(a) {
2042
+ const pkg = `@kywi-software/mcp@${CORE_RANGE(a.kywiVersion)}`
2043
+ return {
2044
+ 'kywi-site': {
2045
+ command: 'npx',
2046
+ args: ['-y', '--package', pkg, 'kywi-mcp-site'],
2047
+ env: {
2048
+ KYWI_API_URL: '${KYWI_API_URL:-http://localhost:3000/api/v1}',
2049
+ KYWI_MCP_API_KEY: '${KYWI_API_KEY:-}',
2050
+ },
2051
+ },
2052
+ 'kywi-dev': {
2053
+ command: 'npx',
2054
+ args: ['-y', '--package', pkg, 'kywi-mcp-dev'],
2055
+ env: {
2056
+ KYWI_CONFIG_PATH: './kywi.config.ts',
2057
+ KYWI_MCP_MODE: 'filesystem',
2058
+ },
2059
+ },
2060
+ }
2061
+ }
2062
+
2063
+ /** @param {Answers} a */
2064
+ export function mcpJson(a) {
2065
+ return `${JSON.stringify({ mcpServers: kywiMcpServers(a) }, null, 2)}\n`
2066
+ }
2067
+
2068
+ /**
2069
+ * Fold the Kywi servers into a project's EXISTING `.mcp.json` — the `agents`
2070
+ * command's write for that one file. A project may wire other servers there, so
2071
+ * replacing the file wholesale (what `--force` does to the guidance files) would
2072
+ * delete them. Without `force`, only missing Kywi entries are added; with it,
2073
+ * the Kywi entries are reset to the current emission. Anything else in the file
2074
+ * is carried over as-is. A file that is not JSON is never touched.
2075
+ * @param {string} existing the current file's text
2076
+ * @param {Answers} a
2077
+ * @param {{ force?: boolean }} [options]
2078
+ * @returns {{ action: 'updated'|'skipped', content: string|null, added: string[], reason: 'exists'|'invalid'|null }}
2079
+ */
2080
+ export function mergeMcpJson(existing, a, { force = false } = {}) {
2081
+ let parsed
2082
+ try {
2083
+ parsed = JSON.parse(existing)
2084
+ } catch {
2085
+ parsed = null
2086
+ }
2087
+ const servers = parsed?.mcpServers
2088
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed) ||
2089
+ (servers !== undefined && (servers === null || typeof servers !== 'object' || Array.isArray(servers)))) {
2090
+ return { action: 'skipped', content: null, added: [], reason: 'invalid' }
2091
+ }
2092
+ const ours = kywiMcpServers(a)
2093
+ const added = Object.keys(ours).filter((name) => !(servers && name in servers))
2094
+ if (!force && added.length === 0) return { action: 'skipped', content: null, added, reason: 'exists' }
2095
+ const merged = { ...(servers ?? {}) }
2096
+ for (const [name, server] of Object.entries(ours)) {
2097
+ if (force || !(name in merged)) merged[name] = server
2098
+ }
2099
+ return {
2100
+ action: 'updated',
2101
+ content: `${JSON.stringify({ ...parsed, mcpServers: merged }, null, 2)}\n`,
2102
+ added,
2103
+ reason: null,
2104
+ }
2105
+ }
2106
+
1945
2107
  /**
1946
2108
  * The agent-guidance files every Kywi app should carry: AGENTS.md, an app-specific
1947
2109
  * header + Kywi's canonical patterns doc; CLAUDE.md, a thin pointer into it; and
@@ -1959,6 +2121,7 @@ export function guidanceFileSet(a, landmarks = scaffoldLandmarks(a.mode)) {
1959
2121
  const files = {
1960
2122
  'AGENTS.md': agentsMd(a, landmarks),
1961
2123
  'CLAUDE.md': claudeMd(a),
2124
+ [MCP_JSON_PATH]: mcpJson(a),
1962
2125
  }
1963
2126
  for (const { slug, asset } of SKILLS) {
1964
2127
  files[`.claude/skills/${slug}/SKILL.md`] = skillDoc(asset)
@@ -1981,7 +2144,7 @@ export function buildFileSet(answers) {
1981
2144
  // Ambient module declarations the generated code needs in order to type-check
1982
2145
  // (see `cssTypes`). Every mode: the admin catch-all imports the stylesheet too.
1983
2146
  'types/css.d.ts': cssTypes(),
1984
- 'next.config.mjs': nextConfig(),
2147
+ 'next.config.mjs': nextConfig(answers),
1985
2148
  '.env.example': envExample(),
1986
2149
  '.gitignore': gitignore(),
1987
2150
  'README.md': readme(answers),
@@ -2040,6 +2203,9 @@ export function buildFileSet(answers) {
2040
2203
  // explicitly, because the header nav is the thing a site restyles first.
2041
2204
  files['components/site-nav.tsx'] = siteNavComponent()
2042
2205
  files['app/(site)/[[...slug]]/page.tsx'] = siteSlugPage()
2206
+ // The browser runtime, served by core from the installed @kywi-software/js
2207
+ // (RENDER_LANDMARK_PATHS.kywiJs, #236). No copy step, no sync script.
2208
+ files['app/kywi.js/route.ts'] = kywiJsRoute()
2043
2209
  // Syncs the admin stylesheet + a cache-busting version marker into
2044
2210
  // public/ on every predev/prebuild — see the doc comment on
2045
2211
  // syncKywiAdminCssScript and packageJson's syncScripts.
package/lib/upgrade.mjs CHANGED
@@ -38,15 +38,18 @@
38
38
  import {
39
39
  copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync,
40
40
  } from 'node:fs'
41
- import { basename, dirname, join, relative } from 'node:path'
41
+ import { basename, dirname, join, posix, relative, sep } from 'node:path'
42
42
  import {
43
43
  RENDER_LANDMARK_PATHS,
44
44
  LEGACY_RENDER_FILES,
45
45
  KNOWN_UNSTAMPED_EMISSIONS,
46
46
  GUIDANCE_VERSION,
47
+ AGENTS_LOCAL_PATH,
47
48
  buildFileSet,
48
49
  detectRenderLandmarks,
49
50
  hashRenderBody,
51
+ RENDER_VERSION,
52
+ splitRenderStamp,
50
53
  kywiLayers,
51
54
  layerFilesFor,
52
55
  packageVersion,
@@ -69,7 +72,14 @@ const EJECT_STAMP_RE = /^\/\/ kywi-eject (\S+) \(@kywi-software\/core ([^)]+)\)$
69
72
  const GUIDANCE_STAMP_RE = /^<!-- kywi-agent-guidance v(\d+) \(create-kywi-app ([^)]+)\) -->$/
70
73
 
71
74
  /**
72
- * @typedef {'create'|'replace'|'current'|'kept'|'refused'|'removed'} UpgradeAction
75
+ * `relabel` is a landmark whose BODY is byte-identical to the current emission
76
+ * and whose stamp differs only in the generator label (`create-kywi-app 0.22.0`
77
+ * where this release says `0.23.0`). It is never rewritten: a stamp-only rewrite
78
+ * would turn every release into an all-files commit with no functional change
79
+ * (#280). It is still counted apart from `current`, so the summary does not
80
+ * claim the bytes match when only the code does.
81
+ *
82
+ * @typedef {'create'|'replace'|'relabel'|'current'|'kept'|'refused'|'removed'} UpgradeAction
73
83
  * @typedef {{
74
84
  * kind: 'landmark'|'layer'|'legacy',
75
85
  * path: string,
@@ -77,6 +87,7 @@ const GUIDANCE_STAMP_RE = /^<!-- kywi-agent-guidance v(\d+) \(create-kywi-app ([
77
87
  * reason: 'edited'|'unstamped'|'legacy'|null,
78
88
  * content: string|null,
79
89
  * backup: string|null,
90
+ * stampedBy?: string|null,
80
91
  * }} UpgradeEntry
81
92
  */
82
93
 
@@ -177,6 +188,18 @@ export function planUpgrade(projectDir, { force = false, dryRun = false } = {})
177
188
  }
178
189
 
179
190
  const stamped = d.version !== null
191
+
192
+ // Same stamp version, intact, and the body that follows the stamp hashes
193
+ // exactly like this release's body: only the CLI label in the stamp line
194
+ // differs. Nothing to replace (#280).
195
+ if (
196
+ stamped && d.version === RENDER_VERSION && d.intact === true &&
197
+ d.hash === hashRenderBody(splitRenderStamp(expected).body)
198
+ ) {
199
+ plan.entries.push({ ...entry('landmark', rel, 'relabel', null, null), stampedBy: d.cliVersion })
200
+ continue
201
+ }
202
+
180
203
  // `intact === null` on a stamped file means the stamp carries no hash, which
181
204
  // is only legitimate for the pre-E3 v1 stamp. A v2 stamp without a hash was
182
205
  // hand-assembled, so its integrity is unknowable and it is treated as
@@ -215,8 +238,11 @@ export function planUpgrade(projectDir, { force = false, dryRun = false } = {})
215
238
 
216
239
  if (plan.entries.some((e) => e.backup)) plan.backupDir = backupDir
217
240
 
218
- // 4. Advisory only, in report order: ejected artifacts, then the two version
219
- // hints. None of these ever cause a write or change the exit code.
241
+ // 4. Advisory only, in report order: project files that import what this run
242
+ // removes, ejected artifacts, then the two version hints. None of these
243
+ // ever cause a write or change the exit code.
244
+ plan.warns.push(...danglingImportWarns(projectDir, plan, expectedFor('runtime', RENDER_LANDMARK_PATHS.runtime)))
245
+ if (wanted.kywiJs) plan.warns.push(...kywiJsWarns(projectDir))
220
246
  const installedCore = installedCoreVersion(projectDir)
221
247
  for (const { path, artifact, version } of scanEjectedArtifacts(projectDir)) {
222
248
  if (installedCore === null || version === installedCore) continue
@@ -230,7 +256,7 @@ export function planUpgrade(projectDir, { force = false, dryRun = false } = {})
230
256
  const guidance = staleGuidance(projectDir)
231
257
  if (guidance !== null) {
232
258
  plan.notes.push(
233
- `AGENTS.md guidance is from create-kywi-app ${guidance}; run \`create-kywi-app agents --force\` to refresh.`,
259
+ `AGENTS.md guidance is from create-kywi-app ${guidance}; run \`create-kywi-app agents --force\` to refresh (it never touches ${AGENTS_LOCAL_PATH}, where this project's own instructions belong).`,
234
260
  )
235
261
  }
236
262
 
@@ -267,6 +293,191 @@ function sanitizeProjectName(name) {
267
293
 
268
294
  // ── the advisory scanners ─────────────────────────────────────────────────────
269
295
 
296
+ /**
297
+ * Project files that will stop compiling after this run (#271). `upgrade` only
298
+ * classifies landmarks, so a project-owned file importing one of the retired
299
+ * glue files (`lib/site.ts`, …) or a name the old `lib/kywi.ts` exported and the
300
+ * thin one does not (`KywiRuntime`) used to lose its import target without a
301
+ * word, and the build broke afterwards. This greps every `.ts`/`.tsx` outside
302
+ * `node_modules`, `.next`, `.git` and `.kywi-upgrade` for relative (or `@/`)
303
+ * import specifiers that resolve to such a file. A string scan rather than a
304
+ * parse: a false positive costs one warn line, never a write.
305
+ *
306
+ * @param {string} projectDir
307
+ * @param {ReturnType<typeof planUpgrade>} plan entries already decided
308
+ * @param {string} runtimeEmission the `lib/kywi.ts` this run would write
309
+ * @returns {string[]}
310
+ */
311
+ function danglingImportWarns(projectDir, plan, runtimeEmission) {
312
+ const runtimeRel = RENDER_LANDMARK_PATHS.runtime
313
+ const legacy = plan.entries.filter((e) => e.kind === 'legacy').map((e) => e.path)
314
+ const runtimeReplaced = plan.entries.some((e) => e.path === runtimeRel && e.action === 'replace')
315
+ if (legacy.length === 0 && !runtimeReplaced) return []
316
+
317
+ const runtimeExports = exportedNames(runtimeEmission)
318
+ // Files this run rewrites or removes are not the project's problem to repoint.
319
+ const rewritten = new Set(
320
+ plan.entries.filter((e) => e.action === 'replace' || e.action === 'removed' || e.kind === 'legacy').map((e) => e.path),
321
+ )
322
+ const removal = plan.force ? 'this upgrade removes' : '`upgrade --force` removes'
323
+ const warns = []
324
+ for (const file of projectSourceFiles(projectDir)) {
325
+ if (rewritten.has(file)) continue
326
+ let text
327
+ try {
328
+ text = readFileSync(join(projectDir, file), 'utf8')
329
+ } catch {
330
+ continue
331
+ }
332
+ const code = stripComments(text)
333
+ const seen = new Set()
334
+ for (const { specifier, names } of importsOf(code)) {
335
+ const candidates = resolveCandidates(file, specifier)
336
+ if (candidates === null) continue
337
+ const target = legacy.find((rel) => candidates.includes(rel))
338
+ if (target !== undefined && !seen.has(target)) {
339
+ seen.add(target)
340
+ warns.push(`${file} imports ${target}, which ${removal} — import from @kywi-software/core/next instead`)
341
+ }
342
+ if (runtimeReplaced && candidates.includes(runtimeRel)) {
343
+ for (const name of names) {
344
+ if (runtimeExports.has(name) || seen.has(`${runtimeRel}#${name}`)) continue
345
+ seen.add(`${runtimeRel}#${name}`)
346
+ warns.push(
347
+ `${file} imports ${name} from ${runtimeRel}, which the regenerated file no longer exports — import it from @kywi-software/core/next instead`,
348
+ )
349
+ }
350
+ }
351
+ }
352
+ }
353
+ return warns
354
+ }
355
+
356
+ /**
357
+ * A coupled project from before core served `/kywi.js` (#236) copied the
358
+ * `@kywi-software/js` bundle into `public/kywi.js` itself (by hand, or with a
359
+ * `sync:runtime`-style script), and may not depend on the package at all.
360
+ * `upgrade` never edits `package.json` or `public/`, so both are warns: the
361
+ * copy is the stale bundle this route exists to retire, and without the
362
+ * dependency the route has nothing to serve.
363
+ * @returns {string[]}
364
+ */
365
+ function kywiJsWarns(projectDir) {
366
+ const warns = []
367
+ if (existsSync(join(projectDir, 'public/kywi.js'))) {
368
+ warns.push(
369
+ `public/kywi.js is a copied bundle; core now serves /kywi.js from ${RENDER_LANDMARK_PATHS.kywiJs} — delete public/kywi.js and any script that copies it`,
370
+ )
371
+ }
372
+ let pkg = null
373
+ try {
374
+ pkg = JSON.parse(readFileSync(join(projectDir, 'package.json'), 'utf8'))
375
+ } catch {
376
+ /* no/invalid package.json — nothing to check */
377
+ }
378
+ if (pkg !== null && !pkg?.dependencies?.['@kywi-software/js'] && !pkg?.devDependencies?.['@kywi-software/js']) {
379
+ warns.push('package.json does not depend on @kywi-software/js, so /kywi.js will 404 — pnpm add @kywi-software/js')
380
+ }
381
+ return warns
382
+ }
383
+
384
+ const SCAN_SKIP_DIRS =new Set(['node_modules', '.next', '.git', '.kywi-upgrade'])
385
+
386
+ /** Every `.ts`/`.tsx` under the project, as sorted posix-relative paths. */
387
+ function projectSourceFiles(projectDir) {
388
+ const out = []
389
+ const stack = [projectDir]
390
+ while (stack.length > 0) {
391
+ const dir = stack.pop()
392
+ let dirents
393
+ try {
394
+ dirents = readdirSync(dir, { withFileTypes: true })
395
+ } catch {
396
+ continue
397
+ }
398
+ for (const dirent of dirents) {
399
+ const abs = join(dir, dirent.name)
400
+ if (dirent.isDirectory()) {
401
+ if (!SCAN_SKIP_DIRS.has(dirent.name)) stack.push(abs)
402
+ } else if (/\.tsx?$/.test(dirent.name) && !dirent.name.endsWith('.d.ts')) {
403
+ out.push(relative(projectDir, abs).split(sep).join('/'))
404
+ }
405
+ }
406
+ }
407
+ return out.sort()
408
+ }
409
+
410
+ /** Line and block comments out, so a commented-out import never warns. */
411
+ function stripComments(text) {
412
+ return text.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:'"`])\/\/.*$/gm, '$1')
413
+ }
414
+
415
+ /**
416
+ * Every module specifier in `code`, with the named bindings it imports (empty
417
+ * for a side-effect, namespace, default or dynamic import).
418
+ * @returns {Array<{ specifier: string, names: string[] }>}
419
+ */
420
+ function importsOf(code) {
421
+ const out = []
422
+ const re = /\b(?:import|export)\s+(?:type\s+)?([^;'"]*?)\s*\bfrom\s*(['"])([^'"]+)\2|\bimport\s*\(?\s*(['"])([^'"]+)\4/g
423
+ let match
424
+ while ((match = re.exec(code)) !== null) {
425
+ if (match[3] !== undefined) {
426
+ const braces = /\{([^}]*)\}/.exec(match[1])
427
+ const names = braces === null
428
+ ? []
429
+ : braces[1].split(',')
430
+ .map((part) => part.trim().replace(/^type\s+/, '').split(/\s+as\s+/)[0].trim())
431
+ .filter(Boolean)
432
+ out.push({ specifier: match[3], names })
433
+ } else {
434
+ out.push({ specifier: match[5], names: [] })
435
+ }
436
+ }
437
+ return out
438
+ }
439
+
440
+ /**
441
+ * The project-relative paths a relative or `@/` specifier could resolve to
442
+ * (the scaffold's tsconfig maps `@/*` to the project root), or `null` for a
443
+ * package import.
444
+ * @returns {string[]|null}
445
+ */
446
+ function resolveCandidates(importer, specifier) {
447
+ let base
448
+ if (specifier.startsWith('./') || specifier.startsWith('../')) {
449
+ base = posix.normalize(posix.join(posix.dirname(importer), specifier))
450
+ } else if (specifier.startsWith('@/')) {
451
+ base = posix.normalize(specifier.slice(2))
452
+ } else {
453
+ return null
454
+ }
455
+ const stem = base.replace(/\.(?:m?jsx?|tsx?)$/, '')
456
+ return [base, `${stem}.ts`, `${stem}.tsx`, `${base}/index.ts`, `${base}/index.tsx`]
457
+ }
458
+
459
+ /** The names a generated module exports, read off its `export` statements. */
460
+ function exportedNames(code) {
461
+ const names = new Set()
462
+ for (const m of code.matchAll(/\bexport\s+(?:declare\s+)?(?:async\s+)?(?:const|let|var|function\*?|class|type|interface|enum)\s+([A-Za-z_$][\w$]*)/g)) {
463
+ names.add(m[1])
464
+ }
465
+ for (const m of code.matchAll(/\bexport\s+(?:const|let|var)\s*\{([^}]*)\}/g)) {
466
+ for (const part of m[1].split(',')) {
467
+ const name = part.split(':').pop().split('=')[0].trim()
468
+ if (name) names.add(name)
469
+ }
470
+ }
471
+ for (const m of code.matchAll(/\bexport\s+(?:type\s+)?\{([^}]*)\}/g)) {
472
+ for (const part of m[1].split(',')) {
473
+ const name = part.trim().replace(/^type\s+/, '').split(/\s+as\s+/).pop().trim()
474
+ if (name) names.add(name)
475
+ }
476
+ }
477
+ if (/\bexport\s+default\b/.test(code)) names.add('default')
478
+ return names
479
+ }
480
+
270
481
  /**
271
482
  * The `@kywi-software/core` version this project actually runs: the installed
272
483
  * package if there is one (the truth), else the declared dependency range with
@@ -448,13 +659,15 @@ export function formatReport(plan, applied) {
448
659
  const to = backedUp.get(e.path)
449
660
  if (to) out.push(` backed up: ${e.path} → ${to}\n`)
450
661
  if (e.action === 'refused') out.push(` REFUSED (${e.reason}): ${e.path}\n`)
451
- else out.push(` ${ACTION_LABEL[e.action]}: ${e.path}\n`)
662
+ else if (e.action === 'relabel') {
663
+ out.push(` relabeled: ${e.path} (stamp says create-kywi-app ${e.stampedBy}; body identical, not rewritten)\n`)
664
+ } else out.push(` ${ACTION_LABEL[e.action]}: ${e.path}\n`)
452
665
  }
453
666
  for (const warn of plan.warns) out.push(` warn: ${warn}\n`)
454
667
  for (const note of plan.notes) out.push(` note: ${note}\n`)
455
668
 
456
669
  const n = countActions(plan)
457
- out.push(`\n✔ ${n.create} created, ${n.replace} replaced, ${n.current} current, ${n.kept} kept, ${n.refused} refused.\n`)
670
+ out.push(`\n✔ ${n.create} created, ${n.replace} replaced, ${n.relabel} relabeled, ${n.current} current, ${n.kept} kept, ${n.refused} refused.\n`)
458
671
  if (n.refused > 0) {
459
672
  out.push(
460
673
  ' Re-run with --force to replace the refused file(s) (each is backed up under .kywi-upgrade/ first). ' +
@@ -469,7 +682,7 @@ export function formatReport(plan, applied) {
469
682
  }
470
683
 
471
684
  function countActions(plan) {
472
- const n = { create: 0, replace: 0, current: 0, kept: 0, refused: 0 }
685
+ const n = { create: 0, replace: 0, relabel: 0, current: 0, kept: 0, refused: 0 }
473
686
  for (const e of plan.entries) {
474
687
  if (e.action === 'refused') n.refused++
475
688
  else if (e.action in n) n[e.action]++
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-kywi-app",
3
- "version": "0.23.0",
3
+ "version": "0.25.0",
4
4
  "description": "Scaffold a new Kywi CMS project — npx create-kywi-app my-site",
5
5
  "type": "module",
6
6
  "homepage": "https://github.com/Kywi-Software/kywi-cms#readme",