create-kywi-app 0.18.0 → 0.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +166 -11
- package/bin/create-kywi-app.mjs +128 -6
- package/lib/config-scan.mjs +385 -0
- package/lib/eject.mjs +424 -0
- package/lib/templates.mjs +619 -1871
- package/lib/upgrade.mjs +490 -0
- package/package.json +1 -1
package/lib/upgrade.mjs
ADDED
|
@@ -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