create-kywi-app 0.18.0 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,490 @@
1
+ /**
2
+ * `create-kywi-app upgrade` — bring an EXISTING project's generated render path
3
+ * up to the current create-kywi-app emission (plan E3 Task 6).
4
+ *
5
+ * WHAT MAKES THIS SAFE. Every render-path file the scaffold writes opens with a
6
+ * {@link renderStamp}: a version, the CLI that wrote it, and a hash of the body
7
+ * that follows. So this command never has to guess whether a file is still
8
+ * create-kywi-app's own output — `detectRenderLandmarks` tells it. A file whose
9
+ * hash still matches is untouched generated code and is replaced without
10
+ * ceremony; a file whose hash does NOT match carries someone's work and is
11
+ * REFUSED; a file with no stamp at all predates the layered shape (or was
12
+ * hand-written) and is refused for the same reason — UNLESS its whole-content
13
+ * hash matches a known historical unstamped emission ({@link
14
+ * KNOWN_UNSTAMPED_EMISSIONS}, e.g. the six render files create-kywi-app 0.19.0
15
+ * wrote with no stamp at all), in which case it is replaced exactly as an
16
+ * intact stamped file would be. `--force` overrides a real refusal, but only
17
+ * after copying the original into `.kywi-upgrade/<timestamp>/`.
18
+ *
19
+ * WHAT IT NEVER TOUCHES. The project-owned files: `kywi.config.ts`,
20
+ * `lib/modules.tsx`, `components/site-nav.tsx`, `app/(site)/site.css` and
21
+ * everything under `sites/`. The layer files under `sites/` are CREATED when a
22
+ * configured site has none (otherwise the project would not boot — core's
23
+ * `assertLayerContracts` throws), and then left alone forever.
24
+ *
25
+ * THE REGISTRY COMES FROM THE PROJECT, NOT THE SCAFFOLD. `kywi.layers.ts` must
26
+ * enumerate the sites *this* config declares, so it is regenerated from
27
+ * `scanKywiConfig(kywi.config.ts)` — and when that scan reports a problem, the
28
+ * command REFUSES to generate anything at all rather than emitting a registry
29
+ * that would fail `assertLayerContracts` at boot.
30
+ *
31
+ * Structure: {@link planUpgrade} is pure (reads the project, writes nothing),
32
+ * {@link applyUpgrade} performs the writes, {@link formatReport} renders, and
33
+ * {@link exitCodeFor} decides the process exit code. `--dry-run` runs all four
34
+ * with the writes suppressed, which is why the dry run's report and exit code
35
+ * are the real ones by construction rather than by a parallel code path.
36
+ */
37
+
38
+ import {
39
+ copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync,
40
+ } from 'node:fs'
41
+ import { basename, dirname, join, relative } from 'node:path'
42
+ import {
43
+ RENDER_LANDMARK_PATHS,
44
+ LEGACY_RENDER_FILES,
45
+ KNOWN_UNSTAMPED_EMISSIONS,
46
+ GUIDANCE_VERSION,
47
+ buildFileSet,
48
+ detectRenderLandmarks,
49
+ hashRenderBody,
50
+ kywiLayers,
51
+ layerFilesFor,
52
+ packageVersion,
53
+ registryLayout,
54
+ renderLandmarks,
55
+ } from './templates.mjs'
56
+ import { scanKywiConfig } from './config-scan.mjs'
57
+
58
+ /**
59
+ * The stamp `create-kywi-app eject` writes at the top of an artifact it copies
60
+ * out of core, recording WHICH artifact and which core version it came from.
61
+ * This command only READS it: an ejected file is project-owned from the moment
62
+ * it lands, so the most `upgrade` can do is point out that core has moved on
63
+ * underneath it. Matched against the first TWO lines, because an ejected React
64
+ * component may legitimately open with its `import`/`'use client'` line.
65
+ */
66
+ const EJECT_STAMP_RE = /^\/\/ kywi-eject (\S+) \(@kywi-software\/core ([^)]+)\)$/m
67
+
68
+ /** The one guidance stamp `agents` writes as AGENTS.md's first line. */
69
+ const GUIDANCE_STAMP_RE = /^<!-- kywi-agent-guidance v(\d+) \(create-kywi-app ([^)]+)\) -->$/
70
+
71
+ /**
72
+ * @typedef {'create'|'replace'|'current'|'kept'|'refused'|'removed'} UpgradeAction
73
+ * @typedef {{
74
+ * kind: 'landmark'|'layer'|'legacy',
75
+ * path: string,
76
+ * action: UpgradeAction,
77
+ * reason: 'edited'|'unstamped'|'legacy'|null,
78
+ * content: string|null,
79
+ * backup: string|null,
80
+ * }} UpgradeEntry
81
+ */
82
+
83
+ // ── planning ──────────────────────────────────────────────────────────────────
84
+
85
+ /**
86
+ * Decide everything the run will do, WITHOUT writing a byte. `force` is an input
87
+ * rather than a post-hoc filter because it changes an entry's outcome (a refused
88
+ * landmark becomes a backed-up replacement), and the report, the writes and the
89
+ * exit code must all agree about that single decision.
90
+ *
91
+ * @param {string} projectDir absolute path to the project root
92
+ * @param {{ force?: boolean, dryRun?: boolean }} [options]
93
+ * @returns {{
94
+ * projectDir: string, projectName: string, force: boolean, dryRun: boolean,
95
+ * ok: boolean, gate: { kind: 'no-project'|'scan', problems: string[] }|null,
96
+ * mode: string|null, modeDetected: boolean,
97
+ * entries: UpgradeEntry[], warns: string[], notes: string[], backupDir: string|null,
98
+ * }}
99
+ */
100
+ export function planUpgrade(projectDir, { force = false, dryRun = false } = {}) {
101
+ /** @type {ReturnType<typeof planUpgrade>} */
102
+ const plan = {
103
+ projectDir,
104
+ projectName: basename(projectDir) || 'kywi-app',
105
+ force: Boolean(force),
106
+ dryRun: Boolean(dryRun),
107
+ ok: false,
108
+ gate: null,
109
+ mode: null,
110
+ modeDetected: false,
111
+ entries: [],
112
+ warns: [],
113
+ notes: [],
114
+ backupDir: null,
115
+ }
116
+
117
+ const configPath = join(projectDir, 'kywi.config.ts')
118
+ if (!existsSync(configPath)) {
119
+ plan.gate = { kind: 'no-project', problems: [] }
120
+ return plan
121
+ }
122
+
123
+ const scan = scanKywiConfig(readFileSync(configPath, 'utf8'))
124
+ plan.mode = scan.mode ?? 'coupled'
125
+ plan.modeDetected = scan.mode !== null
126
+
127
+ // The refusal that matters most. `registryLayout` deliberately keeps a site
128
+ // whose theme could not be read (`theme: null`) rather than dropping it, so a
129
+ // partial scan would produce a registry that core's `assertLayerContracts`
130
+ // rejects at boot — a broken deploy instead of a message. Refuse here.
131
+ if (scan.problems.length > 0 || scan.sites.length === 0) {
132
+ plan.gate = {
133
+ kind: 'scan',
134
+ problems: scan.problems.length > 0
135
+ ? scan.problems.slice()
136
+ : ['no sites found — every Kywi project declares at least one site, and the layer registry is built from that list.'],
137
+ }
138
+ return plan
139
+ }
140
+
141
+ plan.ok = true
142
+
143
+ const layout = registryLayout(scan)
144
+ const backupDir = `.kywi-upgrade/${new Date().toISOString().replace(/:/g, '-')}`
145
+
146
+ // The current emission for this project's MODE. Every landmark but the
147
+ // registry has fixed content; the registry is built from the project's own
148
+ // config, which is the whole point of scanning it.
149
+ const emitted = buildFileSet({
150
+ projectName: sanitizeProjectName(plan.projectName),
151
+ mode: plan.mode,
152
+ dbProvider: 'postgresql',
153
+ authProviders: ['credentials'],
154
+ kywiVersion: packageVersion(),
155
+ })
156
+ const expectedFor = (key, rel) => (key === 'layers' ? kywiLayers(layout) : emitted[rel])
157
+
158
+ // 1. Landmarks, in RENDER_LANDMARK_PATHS order.
159
+ const wanted = renderLandmarks(plan.mode)
160
+ const detected = detectRenderLandmarks(projectDir)
161
+ for (const [key, rel] of Object.entries(RENDER_LANDMARK_PATHS)) {
162
+ // A landmark this mode does not have is not this project's file. A headless
163
+ // project is not missing `app/(site)/layout.tsx`; it correctly has none.
164
+ if (!wanted[key]) continue
165
+ const expected = expectedFor(key, rel)
166
+ const d = detected[key]
167
+
168
+ if (!d.present) {
169
+ plan.entries.push(entry('landmark', rel, 'create', null, expected))
170
+ continue
171
+ }
172
+
173
+ const actual = readFileSync(join(projectDir, rel), 'utf8')
174
+ if (actual === expected) {
175
+ plan.entries.push(entry('landmark', rel, 'current', null, null))
176
+ continue
177
+ }
178
+
179
+ const stamped = d.version !== null
180
+ // `intact === null` on a stamped file means the stamp carries no hash, which
181
+ // is only legitimate for the pre-E3 v1 stamp. A v2 stamp without a hash was
182
+ // hand-assembled, so its integrity is unknowable and it is treated as
183
+ // unstamped rather than trusted.
184
+ const replaceable = stamped && (d.intact === true || (d.intact === null && d.version === 1))
185
+ // An UNSTAMPED file may still be a KNOWN historical create-kywi-app
186
+ // emission — e.g. the six render files 0.19.0 wrote with no stamp at all
187
+ // (see KNOWN_UNSTAMPED_EMISSIONS's doc comment in templates.mjs). Its
188
+ // whole-content hash (there is no stamp line to split off) proves that
189
+ // just as reliably as a stamp's hash proves an intact stamped file.
190
+ const knownUnstamped = !stamped && KNOWN_UNSTAMPED_EMISSIONS[rel]?.[hashRenderBody(actual)] !== undefined
191
+ if (replaceable || knownUnstamped) {
192
+ plan.entries.push(entry('landmark', rel, 'replace', null, expected))
193
+ } else {
194
+ const reason = stamped && d.intact === false ? 'edited' : 'unstamped'
195
+ plan.entries.push(refusal('landmark', rel, reason, expected, plan.force, backupDir))
196
+ }
197
+ }
198
+
199
+ // 2. Layer files: created when a configured site has none, never compared and
200
+ // never overwritten — from the moment they exist they are the project's.
201
+ for (const [rel, content] of Object.entries(layerFilesFor(layout, plan.mode))) {
202
+ plan.entries.push(
203
+ existsSync(join(projectDir, rel))
204
+ ? entry('layer', rel, 'kept', null, null)
205
+ : entry('layer', rel, 'create', null, content),
206
+ )
207
+ }
208
+
209
+ // 3. Retired pre-E1 glue: its contents live in @kywi-software/core/next now,
210
+ // and leaving a stale copy in the project shadows the real thing.
211
+ for (const rel of LEGACY_RENDER_FILES) {
212
+ if (!existsSync(join(projectDir, rel))) continue
213
+ plan.entries.push(refusal('legacy', rel, 'legacy', null, plan.force, backupDir))
214
+ }
215
+
216
+ if (plan.entries.some((e) => e.backup)) plan.backupDir = backupDir
217
+
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.
220
+ const installedCore = installedCoreVersion(projectDir)
221
+ for (const { path, artifact, version } of scanEjectedArtifacts(projectDir)) {
222
+ if (installedCore === null || version === installedCore) continue
223
+ plan.warns.push(`${path} was ejected from @kywi-software/core ${version}; installed is ${installedCore}`)
224
+ }
225
+ if (installedCore !== null && isOlderMinor(installedCore, packageVersion())) {
226
+ plan.notes.push(
227
+ `this project depends on @kywi-software/core ${installedCore}; the generated files target ${packageVersion()}. Bump @kywi-software/* to match.`,
228
+ )
229
+ }
230
+ const guidance = staleGuidance(projectDir)
231
+ if (guidance !== null) {
232
+ plan.notes.push(
233
+ `AGENTS.md guidance is from create-kywi-app ${guidance}; run \`create-kywi-app agents --force\` to refresh.`,
234
+ )
235
+ }
236
+
237
+ return plan
238
+ }
239
+
240
+ /** @returns {UpgradeEntry} */
241
+ function entry(kind, path, action, reason, content) {
242
+ return { kind, path, action, reason, content, backup: null }
243
+ }
244
+
245
+ /**
246
+ * A file this command will not silently overwrite. Without `--force` it stays a
247
+ * refusal; with it, the original is backed up first and then replaced (a
248
+ * landmark) or removed (retired glue).
249
+ * @returns {UpgradeEntry}
250
+ */
251
+ function refusal(kind, path, reason, content, force, backupDir) {
252
+ if (!force) return { kind, path, action: 'refused', reason, content, backup: null }
253
+ return {
254
+ kind,
255
+ path,
256
+ action: kind === 'legacy' ? 'removed' : 'replace',
257
+ reason,
258
+ content,
259
+ backup: `${backupDir}/${path}`,
260
+ }
261
+ }
262
+
263
+ /** buildFileSet writes the name into package.json/README; keep it legal there. */
264
+ function sanitizeProjectName(name) {
265
+ return name.replace(/[^A-Za-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '') || 'kywi-app'
266
+ }
267
+
268
+ // ── the advisory scanners ─────────────────────────────────────────────────────
269
+
270
+ /**
271
+ * The `@kywi-software/core` version this project actually runs: the installed
272
+ * package if there is one (the truth), else the declared dependency range with
273
+ * its `^`/`~` stripped (the intent). No semver parsing — a range like
274
+ * `>=0.19 <1` comes back as-is and simply never equals a concrete version, which
275
+ * costs a warn line and never a wrong write.
276
+ * @returns {string|null}
277
+ */
278
+ function installedCoreVersion(projectDir) {
279
+ const installed = join(projectDir, 'node_modules/@kywi-software/core/package.json')
280
+ if (existsSync(installed)) {
281
+ try {
282
+ const version = JSON.parse(readFileSync(installed, 'utf8')).version
283
+ if (typeof version === 'string' && version) return version
284
+ } catch {
285
+ /* an unreadable installed package.json — fall through to the declared range */
286
+ }
287
+ }
288
+ try {
289
+ const pkg = JSON.parse(readFileSync(join(projectDir, 'package.json'), 'utf8'))
290
+ const range = pkg?.dependencies?.['@kywi-software/core']
291
+ if (typeof range === 'string' && range) return range.replace(/^[\^~]/, '')
292
+ } catch {
293
+ /* no/invalid package.json — nothing to compare against */
294
+ }
295
+ return null
296
+ }
297
+
298
+ /** `0.9.0` is older than `0.19.0`; a string compare would say otherwise. */
299
+ function isOlderMinor(installed, target) {
300
+ const parse = (v) => String(v).split('.').slice(0, 2).map((p) => Number.parseInt(p, 10))
301
+ const [im, in_] = parse(installed)
302
+ const [tm, tn] = parse(target)
303
+ if (![im, in_, tm, tn].every(Number.isFinite)) return false
304
+ return im !== tm ? im < tm : in_ < tn
305
+ }
306
+
307
+ /**
308
+ * Every `.ts`/`.tsx` under `sites/**` that opens with an eject stamp.
309
+ * @returns {Array<{ path: string, artifact: string, version: string }>}
310
+ */
311
+ function scanEjectedArtifacts(projectDir) {
312
+ const out = []
313
+ const root = join(projectDir, 'sites')
314
+ if (!existsSync(root)) return out
315
+ const stack = [root]
316
+ while (stack.length > 0) {
317
+ const dir = stack.pop()
318
+ let dirents
319
+ try {
320
+ dirents = readdirSync(dir, { withFileTypes: true })
321
+ } catch {
322
+ continue
323
+ }
324
+ for (const dirent of dirents) {
325
+ const abs = join(dir, dirent.name)
326
+ if (dirent.isDirectory()) {
327
+ stack.push(abs)
328
+ continue
329
+ }
330
+ if (!/\.tsx?$/.test(dirent.name)) continue
331
+ let head
332
+ try {
333
+ head = readFileSync(abs, 'utf8').split('\n').slice(0, 2).join('\n')
334
+ } catch {
335
+ continue
336
+ }
337
+ const match = EJECT_STAMP_RE.exec(head)
338
+ if (match) {
339
+ out.push({ path: relative(projectDir, abs), artifact: match[1], version: match[2] })
340
+ }
341
+ }
342
+ }
343
+ return out.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0))
344
+ }
345
+
346
+ /**
347
+ * The create-kywi-app version AGENTS.md's guidance stamp names, when that
348
+ * guidance is stale (older surface version, or a different CLI). `null` when
349
+ * there is no AGENTS.md or it is already current. An AGENTS.md with no stamp at
350
+ * all is stale by definition — it predates the stamp.
351
+ * @returns {string|null}
352
+ */
353
+ function staleGuidance(projectDir) {
354
+ const abs = join(projectDir, 'AGENTS.md')
355
+ if (!existsSync(abs)) return null
356
+ let firstLine
357
+ try {
358
+ firstLine = readFileSync(abs, 'utf8').split('\n')[0].trim()
359
+ } catch {
360
+ return null
361
+ }
362
+ const match = GUIDANCE_STAMP_RE.exec(firstLine)
363
+ if (match === null) return 'an unknown version'
364
+ const [, version, cliVersion] = match
365
+ if (Number(version) < GUIDANCE_VERSION || cliVersion !== packageVersion()) return cliVersion
366
+ return null
367
+ }
368
+
369
+ // ── applying ──────────────────────────────────────────────────────────────────
370
+
371
+ /**
372
+ * Perform the plan's writes. Under `dryRun` it walks the identical branches and
373
+ * skips only the syscalls, so what it REPORTS having done is what a real run
374
+ * would do — the dry run cannot drift from the real one.
375
+ *
376
+ * @param {ReturnType<typeof planUpgrade>} plan
377
+ * @param {{ dryRun?: boolean }} [options]
378
+ * @returns {{ backedUp: Array<{ path: string, to: string }>, written: string[], removed: string[] }}
379
+ */
380
+ export function applyUpgrade(plan, { dryRun = plan.dryRun } = {}) {
381
+ const applied = { backedUp: [], written: [], removed: [] }
382
+ if (!plan.ok) return applied
383
+
384
+ for (const e of plan.entries) {
385
+ // The backup comes FIRST and unconditionally: a --force that replaced a file
386
+ // before copying it would destroy the work on any failure in between.
387
+ if (e.backup) {
388
+ if (!dryRun) writeCopy(join(plan.projectDir, e.path), join(plan.projectDir, e.backup))
389
+ applied.backedUp.push({ path: e.path, to: e.backup })
390
+ }
391
+ if (e.action === 'create' || e.action === 'replace') {
392
+ if (!dryRun) {
393
+ const abs = join(plan.projectDir, e.path)
394
+ mkdirSync(dirname(abs), { recursive: true })
395
+ writeFileSync(abs, e.content, 'utf8')
396
+ }
397
+ applied.written.push(e.path)
398
+ } else if (e.action === 'removed') {
399
+ if (!dryRun) rmSync(join(plan.projectDir, e.path), { force: true })
400
+ applied.removed.push(e.path)
401
+ }
402
+ }
403
+ return applied
404
+ }
405
+
406
+ function writeCopy(from, to) {
407
+ mkdirSync(dirname(to), { recursive: true })
408
+ copyFileSync(from, to)
409
+ }
410
+
411
+ // ── reporting ─────────────────────────────────────────────────────────────────
412
+
413
+ const ACTION_LABEL = {
414
+ create: 'created',
415
+ replace: 'replaced',
416
+ current: 'current',
417
+ kept: 'kept',
418
+ removed: 'removed',
419
+ }
420
+
421
+ /**
422
+ * The whole run as text: one line per path in plan order (landmarks, then layer
423
+ * files, then retired glue), then the ejected-artifact warns, the version notes
424
+ * and the summary.
425
+ *
426
+ * @param {ReturnType<typeof planUpgrade>} plan
427
+ * @param {ReturnType<typeof applyUpgrade>} applied
428
+ * @returns {string}
429
+ */
430
+ export function formatReport(plan, applied) {
431
+ if (plan.gate?.kind === 'no-project') {
432
+ return '\n✖ No kywi.config.ts here — run `create-kywi-app upgrade` inside a Kywi project (its root).\n'
433
+ }
434
+ if (plan.gate?.kind === 'scan') {
435
+ const lines = plan.gate.problems.map((p) => `✖ kywi.config.ts: ${p}\n`)
436
+ return `\n${lines.join('')}\nNothing was written: the layer registry is generated from this file, and a registry built from a half-read config fails at boot rather than here.\n`
437
+ }
438
+
439
+ const backedUp = new Map((applied?.backedUp ?? []).map(({ path, to }) => [path, to]))
440
+ const out = []
441
+ out.push(`\nUpgrading ${plan.projectName} (${plan.mode} mode)…\n`)
442
+ if (!plan.modeDetected) {
443
+ out.push(' note: no deployment mode found in kywi.config.ts — assuming `coupled`.\n')
444
+ }
445
+ if (plan.dryRun) out.push(' (dry run — nothing will be written)\n')
446
+
447
+ for (const e of plan.entries) {
448
+ const to = backedUp.get(e.path)
449
+ if (to) out.push(` backed up: ${e.path} → ${to}\n`)
450
+ if (e.action === 'refused') out.push(` REFUSED (${e.reason}): ${e.path}\n`)
451
+ else out.push(` ${ACTION_LABEL[e.action]}: ${e.path}\n`)
452
+ }
453
+ for (const warn of plan.warns) out.push(` warn: ${warn}\n`)
454
+ for (const note of plan.notes) out.push(` note: ${note}\n`)
455
+
456
+ const n = countActions(plan)
457
+ out.push(`\n✔ ${n.create} created, ${n.replace} replaced, ${n.current} current, ${n.kept} kept, ${n.refused} refused.\n`)
458
+ if (n.refused > 0) {
459
+ out.push(
460
+ ' Re-run with --force to replace the refused file(s) (each is backed up under .kywi-upgrade/ first). ' +
461
+ 'Where your edits belong now: module renderers → sites/<site>/index.ts; ' +
462
+ 'nav / front-edit / personalization / js-loader → `create-kywi-app eject <artifact>`; ' +
463
+ 'styles → app/(site)/site.css.\n',
464
+ )
465
+ } else if (plan.backupDir !== null) {
466
+ out.push(` Originals are under ${plan.backupDir}/.\n`)
467
+ }
468
+ return out.join('')
469
+ }
470
+
471
+ function countActions(plan) {
472
+ const n = { create: 0, replace: 0, current: 0, kept: 0, refused: 0 }
473
+ for (const e of plan.entries) {
474
+ if (e.action === 'refused') n.refused++
475
+ else if (e.action in n) n[e.action]++
476
+ }
477
+ return n
478
+ }
479
+
480
+ /**
481
+ * 1 when the project could not be read at all, 2 while a refusal still stands
482
+ * (the run did NOT finish the job, and CI must notice), 0 otherwise. Independent
483
+ * of `dryRun` by construction: the plan already knows what `--force` resolves.
484
+ * @param {ReturnType<typeof planUpgrade>} plan
485
+ * @returns {0|1|2}
486
+ */
487
+ export function exitCodeFor(plan) {
488
+ if (plan.gate !== null) return 1
489
+ return plan.entries.some((e) => e.action === 'refused') ? 2 : 0
490
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-kywi-app",
3
- "version": "0.18.0",
3
+ "version": "0.20.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",