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 +63 -11
- package/assets/agent-patterns.md +4 -2
- package/bin/create-kywi-app.mjs +24 -2
- package/lib/templates.mjs +193 -27
- package/lib/upgrade.mjs +221 -8
- package/package.json +1 -1
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
|
|
73
|
-
patterns (docs/agents/AGENT-PATTERNS.md,
|
|
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
|
|
107
|
-
delegates**: the two `app/(site)` route files
|
|
108
|
-
`kywi.layers.ts`, the API route, the four
|
|
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
|
|
166
|
-
`.claude/skills/<slug>/SKILL.md`). What
|
|
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
|
|
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.
|
package/assets/agent-patterns.md
CHANGED
|
@@ -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
|
|
733
|
-
|
|
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.
|
package/bin/create-kywi-app.mjs
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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/.
|
|
57
|
-
//
|
|
58
|
-
//
|
|
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
|
|
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
|
|
561
|
-
*
|
|
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
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
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
|
-
*
|
|
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:
|
|
219
|
-
//
|
|
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
|
|
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