@rimelight/cli 0.0.1

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/src/index.js ADDED
@@ -0,0 +1,1305 @@
1
+ import path from "node:path"
2
+ import fs from "node:fs/promises"
3
+ import { existsSync, readdirSync } from "node:fs"
4
+ import * as prompts from "@clack/prompts"
5
+ import pc from "picocolors"
6
+ import spawn from "cross-spawn"
7
+
8
+ const starterPath = path.resolve(import.meta.dirname, "../../starter")
9
+
10
+ // Helper to parse JSON with comments (JSONC)
11
+ function parseJsonc(content) {
12
+ let stripped = content.replace(/(?:^|\s)\/\*[\s\S]*?\*\//g, "")
13
+ stripped = stripped.replace(/(^|[^\\:])\/\/.*$/gm, "$1")
14
+ return JSON.parse(stripped)
15
+ }
16
+
17
+ // Helper to copy directory recursively
18
+ async function copyDir(src, dest) {
19
+ await fs.mkdir(dest, { recursive: true })
20
+ const entries = await fs.readdir(src, { withFileTypes: true })
21
+
22
+ const promises = entries.map(async (entry) => {
23
+ if (
24
+ entry.name === "node_modules" ||
25
+ entry.name === "dist" ||
26
+ entry.name === ".astro" ||
27
+ entry.name === ".git" ||
28
+ entry.name === ".wrangler"
29
+ ) {
30
+ return
31
+ }
32
+
33
+ const srcPath = path.join(src, entry.name)
34
+ const destPath = path.join(dest, entry.name)
35
+
36
+ if (entry.isDirectory()) {
37
+ await copyDir(srcPath, destPath)
38
+ } else {
39
+ await fs.copyFile(srcPath, destPath)
40
+ }
41
+ })
42
+
43
+ await Promise.all(promises)
44
+ }
45
+
46
+ // Helper to update astro.config.ts and wrangler.jsonc with the chosen domain
47
+ async function updateDomainConfigs(resolvedTargetDir, domain, displayProjectName) {
48
+ // Update astro.config.ts
49
+ const astroConfigPath = path.join(resolvedTargetDir, "astro.config.ts")
50
+ if (existsSync(astroConfigPath) && domain) {
51
+ let content = await fs.readFile(astroConfigPath, "utf-8")
52
+ content = content.replace(/site:\s*["'][^"']*["']/g, `site: "https://${domain}"`)
53
+ await fs.writeFile(astroConfigPath, content, "utf-8")
54
+ }
55
+
56
+ // Update wrangler.jsonc
57
+ const wranglerPath = path.join(resolvedTargetDir, "wrangler.jsonc")
58
+ if (existsSync(wranglerPath)) {
59
+ const content = await fs.readFile(wranglerPath, "utf-8")
60
+ // Parse safely
61
+ const wrangler = parseJsonc(content)
62
+
63
+ if (domain) {
64
+ // Derive wrangler name (e.g. my-app-dot-com)
65
+ const baseName = domain.replace(/\./g, "-dot-")
66
+ wrangler.name = baseName.toLowerCase().replace(/[^a-z0-9-]/g, "-")
67
+ wrangler.routes = [
68
+ { pattern: domain, custom_domain: true },
69
+ { pattern: `www.${domain}`, custom_domain: true }
70
+ ]
71
+ } else {
72
+ // If no domain is passed, just update name to project name without touching routes
73
+ wrangler.name = displayProjectName.toLowerCase().replace(/[^a-z0-9-]/g, "-")
74
+ }
75
+
76
+ await fs.writeFile(wranglerPath, JSON.stringify(wrangler, null, 2), "utf-8")
77
+ }
78
+ }
79
+
80
+ export async function runCreate(options) {
81
+ const isInteractive = process.stdout.isTTY && !process.env.CI
82
+
83
+ if (isInteractive) {
84
+ prompts.intro(pc.magenta(pc.bold("◇ Welcome to Rimelight!")))
85
+
86
+ // 1. Prompt for project name/directory
87
+ let targetDir = options.projectName
88
+ if (!targetDir) {
89
+ const response = await prompts.text({
90
+ message: "Where should we create your new project?",
91
+ placeholder: "./my-rimelight-app",
92
+ validate: (value) => {
93
+ if (!value.trim()) return "Please enter a directory path"
94
+ if (existsSync(path.resolve(value)) && readdirSync(path.resolve(value)).length > 0) {
95
+ return "Directory is not empty"
96
+ }
97
+ return undefined
98
+ }
99
+ })
100
+
101
+ if (prompts.isCancel(response)) {
102
+ prompts.cancel("Scaffolding cancelled.")
103
+ process.exit(0)
104
+ }
105
+ targetDir = response
106
+ }
107
+
108
+ const resolvedTargetDir = path.resolve(targetDir)
109
+ const displayProjectName = path.basename(resolvedTargetDir)
110
+
111
+ // Prompt for website domain (optionally)
112
+ let domain = options.domain
113
+ if (domain === undefined) {
114
+ const response = await prompts.text({
115
+ message: "What is the website domain for this project? (Optional)",
116
+ placeholder: "e.g. example.com",
117
+ validate: (value) => {
118
+ if (!value || !value.trim()) return undefined
119
+ // Simple domain check
120
+ if (!/^[a-z0-9][a-z0-9-]{0,61}[a-z0-9](?:\.[a-z]{2,})+$/i.test(value.trim())) {
121
+ return "Please enter a valid domain name (e.g. example.com)"
122
+ }
123
+ return undefined
124
+ }
125
+ })
126
+
127
+ if (prompts.isCancel(response)) {
128
+ prompts.cancel("Scaffolding cancelled.")
129
+ process.exit(0)
130
+ }
131
+ domain = response ? response.trim() : ""
132
+ }
133
+
134
+ // Check if directory exists and is not empty
135
+ if (existsSync(resolvedTargetDir)) {
136
+ const files = await fs.readdir(resolvedTargetDir)
137
+ if (files.length > 0) {
138
+ const overwrite = await prompts.select({
139
+ message: `Target directory "${targetDir}" is not empty. Overwrite?`,
140
+ options: [
141
+ { value: "cancel", label: "No, cancel scaffolding" },
142
+ { value: "overwrite", label: "Yes, delete existing files and continue" }
143
+ ]
144
+ })
145
+
146
+ if (prompts.isCancel(overwrite) || overwrite === "cancel") {
147
+ prompts.cancel("Scaffolding cancelled.")
148
+ process.exit(0)
149
+ }
150
+
151
+ const spinner = prompts.spinner()
152
+ spinner.start("Clearing target directory")
153
+ await Promise.all(
154
+ files.map((file) =>
155
+ fs.rm(path.join(resolvedTargetDir, file), { recursive: true, force: true })
156
+ )
157
+ )
158
+ spinner.stop("Target directory cleared")
159
+ }
160
+ }
161
+
162
+ // 2. Prompt for package manager
163
+ let packageManager = options.packageManager
164
+ if (!packageManager) {
165
+ const response = await prompts.select({
166
+ message: "Which package manager do you want to use?",
167
+ options: [
168
+ { value: "pnpm", label: "pnpm (Recommended)", hint: "Fast, disk space efficient" },
169
+ { value: "npm", label: "npm", hint: "Default package manager" },
170
+ { value: "yarn", label: "yarn", hint: "Classic package manager" },
171
+ { value: "bun", label: "bun", hint: "Ultra-fast runner & installer" }
172
+ ],
173
+ initialValue: "pnpm"
174
+ })
175
+
176
+ if (prompts.isCancel(response)) {
177
+ prompts.cancel("Scaffolding cancelled.")
178
+ process.exit(0)
179
+ }
180
+ packageManager = response
181
+ }
182
+
183
+ // 3. Prompt for Git initialization
184
+ let initGit = options.git
185
+ if (initGit === undefined) {
186
+ const response = await prompts.confirm({
187
+ message: "Initialize a new Git repository?",
188
+ initialValue: true
189
+ })
190
+
191
+ if (prompts.isCancel(response)) {
192
+ prompts.cancel("Scaffolding cancelled.")
193
+ process.exit(0)
194
+ }
195
+ initGit = response
196
+ }
197
+
198
+ // 4. Prompt for dependency installation
199
+ let installDeps = options.install
200
+ if (installDeps === undefined) {
201
+ const response = await prompts.confirm({
202
+ message: `Install dependencies automatically using ${packageManager}?`,
203
+ initialValue: true
204
+ })
205
+
206
+ if (prompts.isCancel(response)) {
207
+ prompts.cancel("Scaffolding cancelled.")
208
+ process.exit(0)
209
+ }
210
+ installDeps = response
211
+ }
212
+
213
+ // 5. Scaffolding stage
214
+ const spinner = prompts.spinner()
215
+ spinner.start("Scaffolding your Rimelight project...")
216
+
217
+ try {
218
+ // Copy template
219
+ await copyDir(starterPath, resolvedTargetDir)
220
+
221
+ // Update package.json
222
+ const packageJsonPath = path.join(resolvedTargetDir, "package.json")
223
+ if (existsSync(packageJsonPath)) {
224
+ const content = await fs.readFile(packageJsonPath, "utf-8")
225
+ const packageJson = JSON.parse(content)
226
+ packageJson.name = displayProjectName
227
+ packageJson.private = true
228
+ await fs.writeFile(packageJsonPath, JSON.stringify(packageJson, null, 2), "utf-8")
229
+ }
230
+
231
+ // Update astro.config.ts and wrangler.jsonc with custom domain
232
+ await updateDomainConfigs(resolvedTargetDir, domain, displayProjectName)
233
+
234
+ spinner.stop(pc.green("✔ Scaffolding complete!"))
235
+ } catch (error) {
236
+ spinner.stop(pc.red("✖ Scaffolding failed."))
237
+ console.error(error)
238
+ process.exit(1)
239
+ }
240
+
241
+ // 6. Initialize Git
242
+ if (initGit) {
243
+ const gitSpinner = prompts.spinner()
244
+ gitSpinner.start("Initializing Git repository...")
245
+ const gitInit = spawn.sync("git", ["init"], { cwd: resolvedTargetDir })
246
+ if (gitInit.status === 0) {
247
+ gitSpinner.stop(pc.green("✔ Git repository initialized!"))
248
+ } else {
249
+ gitSpinner.stop(pc.yellow("⚠ Git initialization failed."))
250
+ }
251
+ }
252
+
253
+ // 7. Install dependencies
254
+ if (installDeps) {
255
+ const installSpinner = prompts.spinner()
256
+ installSpinner.start(`Installing dependencies with ${packageManager}...`)
257
+
258
+ const install = spawn.sync(packageManager, ["install"], {
259
+ cwd: resolvedTargetDir,
260
+ stdio: "ignore"
261
+ })
262
+
263
+ if (install.status === 0) {
264
+ installSpinner.stop(pc.green("✔ Dependencies successfully installed!"))
265
+ } else {
266
+ installSpinner.stop(
267
+ pc.red(
268
+ `✖ Dependency installation failed. Please run "${packageManager} install" manually.`
269
+ )
270
+ )
271
+ }
272
+ }
273
+
274
+ // 8. Outro
275
+ prompts.outro(pc.magenta(pc.bold("◇ Enjoy building!")))
276
+
277
+ console.log("\nNext Steps:")
278
+ const relativePath = path.relative(process.cwd(), resolvedTargetDir)
279
+ const cdCmd = relativePath ? `cd ${relativePath} && ` : ""
280
+ console.log(pc.cyan(` ${cdCmd}${packageManager} run dev`))
281
+ console.log("")
282
+ } else {
283
+ // Non-interactive/headless fallback mode (TTY unavailable)
284
+ const targetDir = options.projectName || "./my-rimelight-app"
285
+ const resolvedTargetDir = path.resolve(targetDir)
286
+ const displayProjectName = path.basename(resolvedTargetDir)
287
+ const domain = options.domain || ""
288
+ const packageManager = options.packageManager || "pnpm"
289
+ const initGit = options.git !== false
290
+ const installDeps = options.install !== false
291
+
292
+ console.log(pc.magenta(pc.bold("◇ Rimelight CLI (Non-interactive Mode)")))
293
+ console.log(`Creating project: ${pc.cyan(displayProjectName)} at ${pc.cyan(targetDir)}`)
294
+
295
+ if (existsSync(resolvedTargetDir)) {
296
+ const files = await fs.readdir(resolvedTargetDir)
297
+ if (files.length > 0) {
298
+ console.log(pc.yellow(`Target directory is not empty. Overwriting existing files...`))
299
+ await Promise.all(
300
+ files.map((file) =>
301
+ fs.rm(path.join(resolvedTargetDir, file), { recursive: true, force: true })
302
+ )
303
+ )
304
+ }
305
+ }
306
+
307
+ console.log(`Copying files from starter template...`)
308
+ await copyDir(starterPath, resolvedTargetDir)
309
+
310
+ const packageJsonPath = path.join(resolvedTargetDir, "package.json")
311
+ if (existsSync(packageJsonPath)) {
312
+ const content = await fs.readFile(packageJsonPath, "utf-8")
313
+ const packageJson = JSON.parse(content)
314
+ packageJson.name = displayProjectName
315
+ packageJson.private = true
316
+ await fs.writeFile(packageJsonPath, JSON.stringify(packageJson, null, 2), "utf-8")
317
+ }
318
+
319
+ // Update astro.config.ts and wrangler.jsonc with custom domain
320
+ await updateDomainConfigs(resolvedTargetDir, domain, displayProjectName)
321
+
322
+ console.log(pc.green("✔ Scaffolding complete!"))
323
+
324
+ if (initGit) {
325
+ console.log("Initializing Git repository...")
326
+ const gitInit = spawn.sync("git", ["init"], { cwd: resolvedTargetDir })
327
+ if (gitInit.status === 0) {
328
+ console.log(pc.green("✔ Git repository initialized!"))
329
+ } else {
330
+ console.log(pc.yellow("⚠ Git initialization failed."))
331
+ }
332
+ }
333
+
334
+ if (installDeps) {
335
+ console.log(`Installing dependencies using ${packageManager}...`)
336
+ const install = spawn.crossSpawn
337
+ ? spawn.crossSpawn(packageManager, ["install"], {
338
+ cwd: resolvedTargetDir,
339
+ stdio: "ignore"
340
+ })
341
+ : spawn.sync(packageManager, ["install"], {
342
+ cwd: resolvedTargetDir,
343
+ stdio: "ignore"
344
+ })
345
+ if (install.status === 0) {
346
+ console.log(pc.green("✔ Dependencies successfully installed!"))
347
+ } else {
348
+ console.log(
349
+ pc.red(`✖ Dependency installation failed. Run "${packageManager} install" manually.`)
350
+ )
351
+ }
352
+ }
353
+
354
+ console.log("\nNext Steps:")
355
+ const relativePath = path.relative(process.cwd(), resolvedTargetDir)
356
+ const cdCmd = relativePath ? `cd ${relativePath} && ` : ""
357
+ console.log(pc.cyan(` ${cdCmd}${packageManager} run dev`))
358
+ console.log("")
359
+ }
360
+ }
361
+
362
+ // Helper to get nested properties in an object
363
+ function getNested(obj, propPath) {
364
+ const parts = propPath.split(".")
365
+ let current = obj
366
+ for (const part of parts) {
367
+ if (current === undefined || current === null) return undefined
368
+ current = current[part]
369
+ }
370
+ return current
371
+ }
372
+
373
+ // Helper to set nested properties in an object
374
+ function setNested(obj, propPath, value) {
375
+ const parts = propPath.split(".")
376
+ let current = obj
377
+ for (let i = 0; i < parts.length - 1; i++) {
378
+ const part = parts[i]
379
+ if (current[part] === undefined || current[part] === null) {
380
+ current[part] = {}
381
+ }
382
+ current = current[part]
383
+ }
384
+ current[parts[parts.length - 1]] = value
385
+ }
386
+
387
+ // Generic index finder for arrays containing primitives or objects
388
+ function findArrayItemIndex(arr, item) {
389
+ const itemStr = JSON.stringify(item)
390
+ return arr.findIndex((x) => JSON.stringify(x) === itemStr)
391
+ }
392
+
393
+ // Inject an item at the exact index matched semantically against the starter array order
394
+ function injectIntoArray(targetArr, starterArr, value) {
395
+ const sIdx = findArrayItemIndex(starterArr, value)
396
+ if (sIdx === -1) {
397
+ targetArr.push(value)
398
+ return targetArr
399
+ }
400
+
401
+ let inserted = false
402
+ // Look for preceding neighbor that exists in targetArr
403
+ for (let i = sIdx - 1; i >= 0; i--) {
404
+ const precedingVal = starterArr[i]
405
+ const tIdx = findArrayItemIndex(targetArr, precedingVal)
406
+ if (tIdx !== -1) {
407
+ targetArr.splice(tIdx + 1, 0, value)
408
+ inserted = true
409
+ break
410
+ }
411
+ }
412
+
413
+ if (!inserted) {
414
+ // Look for succeeding neighbor that exists in targetArr
415
+ for (let i = sIdx + 1; i < starterArr.length; i++) {
416
+ const succeedingVal = starterArr[i]
417
+ const tIdx = findArrayItemIndex(targetArr, succeedingVal)
418
+ if (tIdx !== -1) {
419
+ targetArr.splice(tIdx, 0, value)
420
+ inserted = true
421
+ break
422
+ }
423
+ }
424
+ }
425
+
426
+ if (!inserted) {
427
+ targetArr.push(value)
428
+ }
429
+
430
+ return targetArr
431
+ }
432
+
433
+ const cleanLine = (l) => l.trim().replace(/\s+/g, "")
434
+
435
+ // Inject a missing line cleanly at its semantic index inside targetLines based on starterLines order
436
+ function injectIntoLines(targetLines, starterLines, value) {
437
+ const valueClean = cleanLine(value)
438
+
439
+ const sIdx = starterLines.findIndex((x) => cleanLine(x) === valueClean)
440
+ if (sIdx === -1) {
441
+ targetLines.push(value)
442
+ return targetLines
443
+ }
444
+
445
+ let inserted = false
446
+ // Look for preceding neighbor in starterLines that exists in targetLines
447
+ for (let i = sIdx - 1; i >= 0; i--) {
448
+ const precedingVal = starterLines[i]
449
+ const precedingValClean = cleanLine(precedingVal)
450
+ if (!precedingValClean) continue
451
+ const tIdx = targetLines.findIndex((x) => cleanLine(x) === precedingValClean)
452
+ if (tIdx !== -1) {
453
+ targetLines.splice(tIdx + 1, 0, value)
454
+ inserted = true
455
+ break
456
+ }
457
+ }
458
+
459
+ if (!inserted) {
460
+ // Look for succeeding neighbor in starterLines that exists in targetLines
461
+ for (let i = sIdx + 1; i < starterLines.length; i++) {
462
+ const succeedingVal = starterLines[i]
463
+ const succeedingValClean = cleanLine(succeedingVal)
464
+ if (!succeedingValClean) continue
465
+ const tIdx = targetLines.findIndex((x) => cleanLine(x) === succeedingValClean)
466
+ if (tIdx !== -1) {
467
+ targetLines.splice(tIdx, 0, value)
468
+ inserted = true
469
+ break
470
+ }
471
+ }
472
+ }
473
+
474
+ if (!inserted) {
475
+ targetLines.push(value)
476
+ }
477
+
478
+ return targetLines
479
+ }
480
+
481
+ // Reorder target object keys to cleanly match starter template order, preserving custom keys
482
+ function reorderKeys(targetObj, starterObj) {
483
+ const ordered = {}
484
+ for (const key of Object.keys(starterObj)) {
485
+ if (targetObj[key] !== undefined) {
486
+ if (
487
+ typeof starterObj[key] === "object" &&
488
+ starterObj[key] !== null &&
489
+ typeof targetObj[key] === "object" &&
490
+ targetObj[key] !== null &&
491
+ !Array.isArray(starterObj[key])
492
+ ) {
493
+ ordered[key] = reorderKeys(targetObj[key], starterObj[key])
494
+ } else {
495
+ ordered[key] = targetObj[key]
496
+ }
497
+ }
498
+ }
499
+ for (const key of Object.keys(targetObj)) {
500
+ if (ordered[key] === undefined) {
501
+ ordered[key] = targetObj[key]
502
+ }
503
+ }
504
+ return ordered
505
+ }
506
+
507
+ // Resolve domains in injected lines specifically for astro.config.ts
508
+ function resolveDomainInLine(line, targetContent) {
509
+ const siteMatch = targetContent.match(/site:\s*["']https?:\/\/([^"']+)["']/)
510
+ const targetDomain = siteMatch ? siteMatch[1] : ""
511
+ if (targetDomain) {
512
+ return line
513
+ .replace(/starter\.rimelight\.com/g, targetDomain)
514
+ .replace(/starter-dot-rimelight-dot-com/g, targetDomain.replace(/\./g, "-dot-"))
515
+ }
516
+ return line
517
+ }
518
+
519
+ // Helper to structurally diff JSON/JSONC
520
+ function getStructuredJsonDiffs(starterContent, targetContent, file) {
521
+ const starterObj = parseJsonc(starterContent)
522
+ const targetObj = parseJsonc(targetContent)
523
+
524
+ const diffs = []
525
+
526
+ const diffKeys = (sObj, tObj, prefix = "") => {
527
+ // Check if key insertion order differs for semantic standardizing
528
+ const sKeys = Object.keys(sObj)
529
+ const tKeys = Object.keys(tObj).filter((k) => sKeys.includes(k))
530
+ const keyOrderDiffers = sKeys.some((k, idx) => tKeys[idx] !== k)
531
+ if (keyOrderDiffers && sKeys.length > 0) {
532
+ diffs.push({
533
+ type: "out_of_order_keys",
534
+ path: prefix || ".",
535
+ message: `[Out of Order] keys in "${prefix || "root"}" differ from starter order`
536
+ })
537
+ }
538
+
539
+ for (const [key, val] of Object.entries(sObj)) {
540
+ const nestedPath = prefix ? `${prefix}.${key}` : key
541
+
542
+ if (file === "wrangler.jsonc" && (nestedPath === "name" || nestedPath === "routes")) {
543
+ continue
544
+ }
545
+
546
+ if (tObj[key] === undefined) {
547
+ diffs.push({
548
+ type: "missing",
549
+ path: nestedPath,
550
+ value: val,
551
+ message: `missing key "${nestedPath}"`
552
+ })
553
+ } else if (Array.isArray(val) && Array.isArray(tObj[key])) {
554
+ // Element-level list checking
555
+ let arrayMissing = false
556
+ for (const item of val) {
557
+ const itemString = JSON.stringify(item)
558
+ const found = tObj[key].some((tItem) => JSON.stringify(tItem) === itemString)
559
+ if (!found) {
560
+ arrayMissing = true
561
+ diffs.push({
562
+ type: "array_missing",
563
+ path: nestedPath,
564
+ value: item,
565
+ message: `missing array entry ${itemString} in "${nestedPath}"`
566
+ })
567
+ }
568
+ }
569
+
570
+ // Element ordering checking
571
+ if (!arrayMissing && val.length > 0) {
572
+ const tFiltered = tObj[key].filter((tItem) =>
573
+ val.some((sItem) => JSON.stringify(sItem) === JSON.stringify(tItem))
574
+ )
575
+ const orderDiffers = val.some(
576
+ (sItem, idx) => JSON.stringify(sItem) !== JSON.stringify(tFiltered[idx])
577
+ )
578
+ if (orderDiffers) {
579
+ const reordered = []
580
+ for (const sItem of val) {
581
+ reordered.push(sItem)
582
+ }
583
+ for (const tItem of tObj[key]) {
584
+ if (!val.some((sItem) => JSON.stringify(sItem) === JSON.stringify(tItem))) {
585
+ reordered.push(tItem)
586
+ }
587
+ }
588
+ diffs.push({
589
+ type: "out_of_order_array",
590
+ path: nestedPath,
591
+ value: reordered,
592
+ message: `[Out of Order] array elements in "${nestedPath}" differ from starter order`
593
+ })
594
+ }
595
+ }
596
+ } else if (
597
+ typeof val === "object" &&
598
+ val !== null &&
599
+ typeof tObj[key] === "object" &&
600
+ tObj[key] !== null
601
+ ) {
602
+ diffKeys(val, tObj[key], nestedPath)
603
+ } else if (JSON.stringify(tObj[key]) !== JSON.stringify(val)) {
604
+ diffs.push({
605
+ type: "differing",
606
+ path: nestedPath,
607
+ value: val,
608
+ currentValue: tObj[key],
609
+ message: `differing key "${nestedPath}":\n ├─ Current: ${JSON.stringify(tObj[key])}\n └─ Reference: ${JSON.stringify(val)}`
610
+ })
611
+ }
612
+ }
613
+ }
614
+
615
+ diffKeys(starterObj, targetObj)
616
+ return diffs
617
+ }
618
+
619
+ const cleanLines = (c) =>
620
+ c
621
+ .split("\n")
622
+ .map((l) => l.trim())
623
+ .filter(Boolean)
624
+
625
+ // Helper to diff files line-by-line
626
+ function getStructuredLineDiffs(starterContent, targetContent, file, targetDir) {
627
+ let normalizedTarget = targetContent
628
+ let normalizedStarter = starterContent
629
+
630
+ if (file === "astro.config.ts") {
631
+ // Normalize both reference and target domains to example.com to avoid stagged mismatch loops
632
+ normalizedStarter = normalizedStarter
633
+ .replace(/starter\.rimelight\.com/g, "example.com")
634
+ .replace(/starter-dot-rimelight-dot-com/g, "example-dot-com")
635
+
636
+ const siteMatch = targetContent.match(/site:\s*["']https?:\/\/([^"']+)["']/)
637
+ if (siteMatch) {
638
+ const domain = siteMatch[1]
639
+ normalizedTarget = normalizedTarget
640
+ .replace(new RegExp(domain, "g"), "example.com")
641
+ .replace(/starter-dot-rimelight-dot-com/g, "example-dot-com")
642
+ }
643
+ if (targetDir && targetDir.includes("marcelocfilho.com")) {
644
+ normalizedTarget = normalizedTarget.replace(/\/\/ /g, "")
645
+ }
646
+ }
647
+
648
+ const starterLines = cleanLines(normalizedStarter)
649
+ const targetLines = cleanLines(normalizedTarget)
650
+
651
+ const diffs = []
652
+ for (const line of starterLines) {
653
+ if (/^[{}[\],()]+$/.test(line)) continue // ignore structural syntax-only lines
654
+ const normalizedLine = line.replace(/\s+/g, "")
655
+ const found = targetLines.some((tl) => tl.replace(/\s+/g, "") === normalizedLine)
656
+ if (!found) {
657
+ diffs.push({
658
+ type: "missing_line",
659
+ line,
660
+ message: `missing config entry: "${line}"`
661
+ })
662
+ }
663
+ }
664
+ return diffs
665
+ }
666
+
667
+ // Helper to dynamically calculate line-level differences between config files
668
+ function getDiffLines(starterContent, targetContent, file, targetDir) {
669
+ // Parse JSONC config structurally (tsconfig, wrangler, knip, lunaria, etc.)
670
+ if (file.endsWith(".json") || file.endsWith(".jsonc")) {
671
+ try {
672
+ const diffs = getStructuredJsonDiffs(starterContent, targetContent, file)
673
+ return diffs.map((d) => d.message)
674
+ } catch {
675
+ // Fallback to line diff if JSON parse fails
676
+ }
677
+ }
678
+
679
+ const diffs = getStructuredLineDiffs(starterContent, targetContent, file, targetDir)
680
+ return diffs.map((d) => d.message)
681
+ }
682
+
683
+ export async function runCompare(options) {
684
+ const targetDir = path.resolve(options.targetDir)
685
+ const displayProjectName = path.basename(targetDir)
686
+
687
+ console.log(pc.magenta(pc.bold(`◇ Rimelight Compare: ${displayProjectName}`)))
688
+ console.log(`Comparing project configs against starter template...\n`)
689
+
690
+ if (!existsSync(targetDir)) {
691
+ console.error(pc.red(`✖ Target directory "${options.targetDir}" does not exist.`))
692
+ process.exit(1)
693
+ }
694
+
695
+ const files = [
696
+ ".gitignore",
697
+ ".node-version",
698
+ "package.json",
699
+ "astro.config.ts",
700
+ "tsconfig.json",
701
+ "vite.config.ts",
702
+ "wrangler.jsonc",
703
+ "uno.config.ts",
704
+ "knip.json",
705
+ "lunaria.config.json",
706
+ ".env.example",
707
+ "pnpm-workspace.yaml"
708
+ ]
709
+
710
+ let totalDiffs = 0
711
+
712
+ const filesData = await Promise.all(
713
+ files.map(async (file) => {
714
+ const starterFilePath = path.join(starterPath, file)
715
+ const targetFilePath = path.join(targetDir, file)
716
+
717
+ if (!existsSync(targetFilePath)) {
718
+ return { file, exists: false }
719
+ }
720
+
721
+ try {
722
+ const [starterContent, targetContent] = await Promise.all([
723
+ fs.readFile(starterFilePath, "utf-8"),
724
+ fs.readFile(targetFilePath, "utf-8")
725
+ ])
726
+ return { file, exists: true, starterContent, targetContent }
727
+ } catch (err) {
728
+ return { file, exists: true, error: err }
729
+ }
730
+ })
731
+ )
732
+
733
+ for (const data of filesData) {
734
+ const { file, exists, starterContent, targetContent, error } = data
735
+
736
+ if (!exists) {
737
+ console.log(`${pc.red("✖")} ${pc.bold(file)}: ${pc.red("MISSING")}`)
738
+ totalDiffs++
739
+ continue
740
+ }
741
+
742
+ if (error) {
743
+ console.log(`${pc.red("✖")} ${pc.bold(file)}: ${pc.red("ERROR")} (${error.message})`)
744
+ totalDiffs++
745
+ continue
746
+ }
747
+
748
+ if (file === "package.json") {
749
+ try {
750
+ const starterPkg = JSON.parse(starterContent)
751
+ const targetPkg = JSON.parse(targetContent)
752
+
753
+ const pkgDiffs = []
754
+
755
+ // Engines Node
756
+ if (starterPkg.engines?.node !== targetPkg.engines?.node) {
757
+ pkgDiffs.push(
758
+ `engines.node:\n ├─ Current: "${targetPkg.engines?.node || ""}"\n └─ Reference: "${starterPkg.engines?.node || ""}"`
759
+ )
760
+ }
761
+
762
+ // Package Manager
763
+ if (starterPkg.packageManager !== targetPkg.packageManager) {
764
+ pkgDiffs.push(
765
+ `packageManager:\n ├─ Current: "${targetPkg.packageManager || ""}"\n └─ Reference: "${starterPkg.packageManager || ""}"`
766
+ )
767
+ }
768
+
769
+ // Scripts
770
+ for (const [key, val] of Object.entries(starterPkg.scripts || {})) {
771
+ if (!targetPkg.scripts?.[key]) {
772
+ pkgDiffs.push(`missing script "${key}":\n └─ Reference: "${val}"`)
773
+ } else if (targetPkg.scripts[key] !== val) {
774
+ pkgDiffs.push(
775
+ `differing script "${key}":\n ├─ Current: "${targetPkg.scripts[key]}"\n └─ Reference: "${val}"`
776
+ )
777
+ }
778
+ }
779
+
780
+ // Dependencies (matching only)
781
+ for (const [key, val] of Object.entries(starterPkg.dependencies || {})) {
782
+ if (!targetPkg.dependencies?.[key]) {
783
+ pkgDiffs.push(`missing dependency "${key}":\n └─ Reference: "${val}"`)
784
+ } else if (targetPkg.dependencies[key] !== val) {
785
+ pkgDiffs.push(
786
+ `differing dependency "${key}":\n ├─ Current: "${targetPkg.dependencies[key]}"\n └─ Reference: "${val}"`
787
+ )
788
+ }
789
+ }
790
+
791
+ // devDependencies (matching only)
792
+ for (const [key, val] of Object.entries(starterPkg.devDependencies || {})) {
793
+ if (!targetPkg.devDependencies?.[key]) {
794
+ pkgDiffs.push(`missing devDependency "${key}":\n └─ Reference: "${val}"`)
795
+ } else if (targetPkg.devDependencies[key] !== val) {
796
+ pkgDiffs.push(
797
+ `differing devDependency "${key}":\n ├─ Current: "${targetPkg.devDependencies[key]}"\n └─ Reference: "${val}"`
798
+ )
799
+ }
800
+ }
801
+
802
+ if (pkgDiffs.length > 0) {
803
+ console.log(`${pc.yellow("⚠")} ${pc.bold(file)}: ${pc.yellow("DIFFERING")}`)
804
+ for (const diff of pkgDiffs) {
805
+ console.log(` └─ ${pc.dim(diff)}`)
806
+ }
807
+ totalDiffs += pkgDiffs.length
808
+ } else {
809
+ console.log(`${pc.green("✔")} ${pc.bold(file)}: ${pc.green("UP TO DATE")}`)
810
+ }
811
+ } catch (err) {
812
+ console.log(`${pc.red("✖")} ${pc.bold(file)}: ${pc.red("INVALID JSON")} (${err.message})`)
813
+ totalDiffs++
814
+ }
815
+ } else {
816
+ const diffs = getDiffLines(starterContent, targetContent, file, targetDir)
817
+
818
+ if (diffs.length > 0) {
819
+ console.log(`${pc.yellow("⚠")} ${pc.bold(file)}: ${pc.yellow("DIFFERING")}`)
820
+ for (const diff of diffs) {
821
+ console.log(` └─ ${pc.dim(diff)}`)
822
+ }
823
+ totalDiffs += diffs.length
824
+ } else {
825
+ console.log(`${pc.green("✔")} ${pc.bold(file)}: ${pc.green("UP TO DATE")}`)
826
+ }
827
+ }
828
+ }
829
+
830
+ console.log("\n" + pc.magenta(pc.bold("◇ Comparison Complete")))
831
+ if (totalDiffs > 0) {
832
+ console.log(
833
+ pc.yellow(
834
+ `Found ${totalDiffs} differences. Run "rimelight update [path]" to standardize them.`
835
+ )
836
+ )
837
+ } else {
838
+ console.log(pc.green("✔ All configuration files are up to date with the starter template!"))
839
+ }
840
+ }
841
+
842
+ export async function runUpdate(options) {
843
+ const targetDir = path.resolve(options.targetDir)
844
+ const displayProjectName = path.basename(targetDir)
845
+
846
+ const isInteractive = process.stdout.isTTY && !process.env.CI
847
+
848
+ prompts.intro(pc.magenta(pc.bold(`◇ Rimelight Update: ${displayProjectName}`)))
849
+ console.log(`Standardizing project configs against starter template...\n`)
850
+
851
+ if (!existsSync(targetDir)) {
852
+ prompts.cancel(pc.red(`✖ Target directory "${options.targetDir}" does not exist.`))
853
+ process.exit(1)
854
+ }
855
+
856
+ const files = [
857
+ ".gitignore",
858
+ ".node-version",
859
+ "tsconfig.json",
860
+ "vite.config.ts",
861
+ "uno.config.ts",
862
+ "knip.json",
863
+ "lunaria.config.json",
864
+ ".env.example",
865
+ "pnpm-workspace.yaml",
866
+ "astro.config.ts",
867
+ "wrangler.jsonc"
868
+ ]
869
+
870
+ const pendingWrites = [] // Accumulate in-memory changes: { filePath, content, actionDescription }
871
+
872
+ try {
873
+ // Pre-read config files
874
+ const starterExists = files.map((file) => existsSync(path.join(starterPath, file)))
875
+ const targetExists = files.map((file) => existsSync(path.join(targetDir, file)))
876
+
877
+ const preReadStarter = await Promise.all(
878
+ files.map((file, i) =>
879
+ starterExists[i] ? fs.readFile(path.join(starterPath, file), "utf-8") : Promise.resolve("")
880
+ )
881
+ )
882
+ const preReadTarget = await Promise.all(
883
+ files.map((file, i) =>
884
+ starterExists[i] && targetExists[i]
885
+ ? fs.readFile(path.join(targetDir, file), "utf-8")
886
+ : Promise.resolve("")
887
+ )
888
+ )
889
+
890
+ // Pre-read package.json files
891
+ const targetPkgPath = path.join(targetDir, "package.json")
892
+ const starterPkgPath = path.join(starterPath, "package.json")
893
+ const targetPkgExists = existsSync(targetPkgPath)
894
+
895
+ const [starterPkgContent, targetPkgContent] = await Promise.all([
896
+ fs.readFile(starterPkgPath, "utf-8").catch(() => "{}"),
897
+ targetPkgExists ? fs.readFile(targetPkgPath, "utf-8") : Promise.resolve("{}")
898
+ ])
899
+
900
+ const starterPkg = JSON.parse(starterPkgContent)
901
+ const targetPkg = JSON.parse(targetPkgContent)
902
+
903
+ // Helper functions to update diffs recursively instead of using loop with await
904
+ const handleJsonDiff = async (currentTargetContent, starterContent, file) => {
905
+ let diffs = []
906
+ try {
907
+ diffs = getStructuredJsonDiffs(starterContent, currentTargetContent, file)
908
+ } catch {
909
+ // Fallback to line diff
910
+ }
911
+
912
+ if (diffs.length === 0) return currentTargetContent
913
+
914
+ console.log(`${pc.yellow("⚠")} ${pc.bold(file)}: ${pc.yellow("DIFFERING")}`)
915
+ for (const diff of diffs) {
916
+ console.log(` └─ ${pc.dim(diff.message)}`)
917
+ }
918
+
919
+ let selectedDiffMessages = []
920
+ if (isInteractive) {
921
+ const selection = await prompts.multiselect({
922
+ message: `Select keys to update in ${file} (Select none to finish):`,
923
+ options: diffs.map((diff) => ({
924
+ value: diff.message,
925
+ label: diff.message
926
+ })),
927
+ required: false
928
+ })
929
+
930
+ if (prompts.isCancel(selection)) {
931
+ prompts.cancel("Update cancelled.")
932
+ process.exit(0)
933
+ }
934
+ selectedDiffMessages = selection
935
+ } else {
936
+ // Apply all in non-interactive mode
937
+ selectedDiffMessages = diffs.map((d) => d.message)
938
+ }
939
+
940
+ if (selectedDiffMessages.length === 0) {
941
+ return currentTargetContent
942
+ }
943
+
944
+ let targetObj = parseJsonc(currentTargetContent)
945
+ for (const diff of diffs) {
946
+ if (selectedDiffMessages.includes(diff.message)) {
947
+ if (diff.type === "array_missing") {
948
+ const arr = getNested(targetObj, diff.path) || []
949
+ const starterObj = parseJsonc(starterContent)
950
+ const starterArr = getNested(starterObj, diff.path) || []
951
+ const newArr = injectIntoArray(arr, starterArr, diff.value)
952
+ setNested(targetObj, diff.path, newArr)
953
+ } else if (diff.type === "out_of_order_array") {
954
+ setNested(targetObj, diff.path, diff.value)
955
+ } else if (diff.type === "out_of_order_keys") {
956
+ targetObj = reorderKeys(targetObj, parseJsonc(starterContent))
957
+ } else {
958
+ setNested(targetObj, diff.path, diff.value)
959
+ }
960
+ }
961
+ }
962
+
963
+ if (file === "wrangler.jsonc") {
964
+ const parsed = parseJsonc(targetPkgContent)
965
+ targetObj.name = parsed.name
966
+ targetObj.routes = parsed.routes
967
+ }
968
+
969
+ const nextContent = JSON.stringify(targetObj, null, 2)
970
+ console.log(
971
+ pc.green(`✔ Staged ${selectedDiffMessages.length} updates for "${file}" in memory.\n`)
972
+ )
973
+
974
+ if (!isInteractive) return nextContent
975
+ return handleJsonDiff(nextContent, starterContent, file)
976
+ }
977
+
978
+ const handleLineDiff = async (currentTargetContent, starterContent, file) => {
979
+ const diffs = getStructuredLineDiffs(starterContent, currentTargetContent, file, targetDir)
980
+ if (diffs.length === 0) return currentTargetContent
981
+
982
+ console.log(`${pc.yellow("⚠")} ${pc.bold(file)}: ${pc.yellow("DIFFERING")}`)
983
+ for (const diff of diffs) {
984
+ console.log(` └─ ${pc.dim(diff.message)}`)
985
+ }
986
+
987
+ let selectedLines = []
988
+ if (isInteractive) {
989
+ const selection = await prompts.multiselect({
990
+ message: `Select entries to add to ${file} (Select none to finish):`,
991
+ options: diffs.map((diff) => ({
992
+ value: diff.line,
993
+ label: diff.line
994
+ })),
995
+ required: false
996
+ })
997
+
998
+ if (prompts.isCancel(selection)) {
999
+ prompts.cancel("Update cancelled.")
1000
+ process.exit(0)
1001
+ }
1002
+ selectedLines = selection
1003
+ } else {
1004
+ selectedLines = diffs.map((d) => d.line)
1005
+ }
1006
+
1007
+ if (selectedLines.length === 0) {
1008
+ return currentTargetContent
1009
+ }
1010
+
1011
+ const starterLines = starterContent.split("\n")
1012
+ let targetLines = currentTargetContent.split("\n")
1013
+
1014
+ for (const diff of diffs) {
1015
+ if (selectedLines.includes(diff.line)) {
1016
+ let resolvedLine = diff.line
1017
+ if (file === "astro.config.ts") {
1018
+ resolvedLine = resolveDomainInLine(diff.line, targetPkgContent)
1019
+ }
1020
+ targetLines = injectIntoLines(targetLines, starterLines, resolvedLine)
1021
+ }
1022
+ }
1023
+
1024
+ const nextContent = targetLines.join("\n")
1025
+ console.log(pc.green(`✔ Staged ${selectedLines.length} entries for "${file}" in memory.\n`))
1026
+
1027
+ if (!isInteractive) return nextContent
1028
+ return handleLineDiff(nextContent, starterContent, file)
1029
+ }
1030
+
1031
+ // 1. Process standard configuration files recursively to avoid loops with await
1032
+ const processFiles = async (index) => {
1033
+ if (index >= files.length) return
1034
+ const file = files[index]
1035
+ const targetFilePath = path.join(targetDir, file)
1036
+
1037
+ if (starterExists[index]) {
1038
+ if (!targetExists[index]) {
1039
+ // File is missing entirely
1040
+ console.log(`${pc.red("✖")} ${pc.bold(file)}: ${pc.red("MISSING")}`)
1041
+ let shouldCreate = true
1042
+ if (isInteractive) {
1043
+ shouldCreate = await prompts.confirm({
1044
+ message: `Create missing file "${file}" from starter template?`,
1045
+ initialValue: true
1046
+ })
1047
+ if (prompts.isCancel(shouldCreate)) {
1048
+ prompts.cancel("Update cancelled.")
1049
+ process.exit(0)
1050
+ }
1051
+ }
1052
+ if (shouldCreate) {
1053
+ pendingWrites.push({
1054
+ filePath: targetFilePath,
1055
+ content: preReadStarter[index],
1056
+ actionDescription: `Create missing file "${file}"`
1057
+ })
1058
+ console.log(pc.green(`✔ Scheduled creation of "${file}".`))
1059
+ } else {
1060
+ console.log(pc.yellow(`ℹ Skipped file "${file}".`))
1061
+ }
1062
+ await processFiles(index + 1)
1063
+ return
1064
+ }
1065
+
1066
+ const starterContent = preReadStarter[index]
1067
+ const targetContent = preReadTarget[index]
1068
+
1069
+ // Parse/Diff dynamically based on file type
1070
+ if (file.endsWith(".json") || file.endsWith(".jsonc")) {
1071
+ const finalContent = await handleJsonDiff(targetContent, starterContent, file)
1072
+ if (finalContent !== targetContent) {
1073
+ pendingWrites.push({
1074
+ filePath: targetFilePath,
1075
+ content: finalContent,
1076
+ actionDescription: `Standardize JSON structures in "${file}"`
1077
+ })
1078
+ console.log(pc.green(`✔ Scheduled batch updates for "${file}".`))
1079
+ }
1080
+ } else if (
1081
+ file === ".gitignore" ||
1082
+ file === ".env.example" ||
1083
+ file === "pnpm-workspace.yaml" ||
1084
+ file === ".node-version" ||
1085
+ file === "astro.config.ts" ||
1086
+ file === "vite.config.ts" ||
1087
+ file === "uno.config.ts"
1088
+ ) {
1089
+ const finalContent = await handleLineDiff(targetContent, starterContent, file)
1090
+ if (finalContent !== targetContent) {
1091
+ pendingWrites.push({
1092
+ filePath: targetFilePath,
1093
+ content: finalContent,
1094
+ actionDescription: `Standardize entry list in "${file}"`
1095
+ })
1096
+ console.log(pc.green(`✔ Scheduled batch updates for "${file}".`))
1097
+ }
1098
+ }
1099
+ }
1100
+ await processFiles(index + 1)
1101
+ }
1102
+
1103
+ await processFiles(0)
1104
+
1105
+ // 2. Standardize package.json preserving custom dependencies
1106
+ if (targetPkgExists) {
1107
+ const pkgOptions = []
1108
+
1109
+ // Engines Node
1110
+ if (starterPkg.engines?.node !== targetPkg.engines?.node) {
1111
+ pkgOptions.push({
1112
+ value: { type: "engines", key: "node", value: starterPkg.engines.node },
1113
+ label: `Update engines.node: "${starterPkg.engines.node}" (Current: "${targetPkg.engines?.node || ""}")`
1114
+ })
1115
+ }
1116
+
1117
+ // Package Manager
1118
+ if (starterPkg.packageManager !== targetPkg.packageManager) {
1119
+ pkgOptions.push({
1120
+ value: { type: "packageManager", value: starterPkg.packageManager },
1121
+ label: `Update packageManager: "${starterPkg.packageManager}" (Current: "${targetPkg.packageManager || ""}")`
1122
+ })
1123
+ }
1124
+
1125
+ // Scripts
1126
+ targetPkg.scripts = targetPkg.scripts || {}
1127
+ for (const [key, val] of Object.entries(starterPkg.scripts || {})) {
1128
+ if (targetPkg.scripts[key] !== val) {
1129
+ const isMissing = !targetPkg.scripts[key]
1130
+ pkgOptions.push({
1131
+ value: { type: "scripts", key, value: val },
1132
+ label: isMissing
1133
+ ? `Add script "${key}": "${val}"`
1134
+ : `Update script "${key}": "${val}" (Current: "${targetPkg.scripts[key]}")`
1135
+ })
1136
+ }
1137
+ }
1138
+
1139
+ // Dependencies
1140
+ targetPkg.dependencies = targetPkg.dependencies || {}
1141
+ for (const [key, val] of Object.entries(starterPkg.dependencies || {})) {
1142
+ if (targetPkg.dependencies[key] !== val) {
1143
+ const isMissing = !targetPkg.dependencies[key]
1144
+ pkgOptions.push({
1145
+ value: { type: "dependencies", key, value: val },
1146
+ label: isMissing
1147
+ ? `Add dependency "${key}": "${val}"`
1148
+ : `Update dependency "${key}": "${val}" (Current: "${targetPkg.dependencies[key]}")`
1149
+ })
1150
+ }
1151
+ }
1152
+
1153
+ // devDependencies
1154
+ targetPkg.devDependencies = targetPkg.devDependencies || {}
1155
+ for (const [key, val] of Object.entries(starterPkg.devDependencies || {})) {
1156
+ if (targetPkg.devDependencies[key] !== val) {
1157
+ const isMissing = !targetPkg.devDependencies[key]
1158
+ pkgOptions.push({
1159
+ value: { type: "devDependencies", key, value: val },
1160
+ label: isMissing
1161
+ ? `Add devDependency "${key}": "${val}"`
1162
+ : `Update devDependency "${key}": "${val}" (Current: "${targetPkg.devDependencies[key]}")`
1163
+ })
1164
+ }
1165
+ }
1166
+
1167
+ if (pkgOptions.length > 0) {
1168
+ console.log(`${pc.yellow("⚠")} ${pc.bold("package.json")}: ${pc.yellow("DIFFERING")}`)
1169
+
1170
+ let selectedOpts = pkgOptions.map((o) => o.value)
1171
+ if (isInteractive) {
1172
+ const selection = await prompts.multiselect({
1173
+ message: "Select updates to apply to package.json:",
1174
+ options: pkgOptions,
1175
+ required: false
1176
+ })
1177
+
1178
+ if (prompts.isCancel(selection)) {
1179
+ prompts.cancel("Update cancelled.")
1180
+ process.exit(0)
1181
+ }
1182
+ selectedOpts = selection
1183
+ }
1184
+
1185
+ if (selectedOpts.length > 0) {
1186
+ let pkgModified = false
1187
+ for (const opt of selectedOpts) {
1188
+ pkgModified = true
1189
+ if (opt.type === "engines") {
1190
+ targetPkg.engines = { ...targetPkg.engines, [opt.key]: opt.value }
1191
+ } else if (opt.type === "packageManager") {
1192
+ targetPkg.packageManager = opt.value
1193
+ } else {
1194
+ targetPkg[opt.type][opt.key] = opt.value
1195
+ }
1196
+ }
1197
+
1198
+ if (pkgModified) {
1199
+ // Consistent scripts ordering
1200
+ const orderedScripts = {}
1201
+ const allScriptKeys = new Set([
1202
+ ...Object.keys(starterPkg.scripts || {}),
1203
+ ...Object.keys(targetPkg.scripts || {})
1204
+ ])
1205
+ const standardOrder = [
1206
+ "prepare",
1207
+ "generate-types",
1208
+ "knip",
1209
+ "astro",
1210
+ "dev",
1211
+ "build",
1212
+ "preview",
1213
+ "lunaria:build",
1214
+ "lunaria:preview"
1215
+ ]
1216
+ for (const key of standardOrder) {
1217
+ if (allScriptKeys.has(key)) {
1218
+ orderedScripts[key] = targetPkg.scripts[key]
1219
+ allScriptKeys.delete(key)
1220
+ }
1221
+ }
1222
+ for (const key of allScriptKeys) {
1223
+ orderedScripts[key] = targetPkg.scripts[key]
1224
+ }
1225
+ targetPkg.scripts = orderedScripts
1226
+
1227
+ pendingWrites.push({
1228
+ filePath: targetPkgPath,
1229
+ content: JSON.stringify(targetPkg, null, 2),
1230
+ actionDescription: `Standardize ${selectedOpts.length} entries in "package.json"`
1231
+ })
1232
+ console.log(pc.green('✔ Scheduled updates for "package.json".'))
1233
+ }
1234
+ } else {
1235
+ console.log(pc.yellow("ℹ Skipped updates for package.json."))
1236
+ }
1237
+ }
1238
+ }
1239
+
1240
+ // 3. Batch and Apply all changes at the end
1241
+ console.log("")
1242
+ if (pendingWrites.length > 0) {
1243
+ console.log(pc.bold(pc.cyan("◇ Pending changes to be applied:")))
1244
+ for (const item of pendingWrites) {
1245
+ const relativeFile = path.relative(targetDir, item.filePath)
1246
+ console.log(` ├─ ${pc.bold(relativeFile)}: ${pc.dim(item.actionDescription)}`)
1247
+ }
1248
+ console.log("")
1249
+
1250
+ let confirmApply = true
1251
+ if (isInteractive) {
1252
+ confirmApply = await prompts.confirm({
1253
+ message: `Apply all ${pendingWrites.length} scheduled changes to disk? (Files will not be modified if cancelled)`,
1254
+ initialValue: true
1255
+ })
1256
+
1257
+ if (prompts.isCancel(confirmApply) || !confirmApply) {
1258
+ prompts.cancel("Update cancelled. No files were modified.")
1259
+ process.exit(0)
1260
+ }
1261
+ }
1262
+
1263
+ if (confirmApply) {
1264
+ const writeSpinner = prompts.spinner()
1265
+ writeSpinner.start("Writing changes to configuration files...")
1266
+ await Promise.all(
1267
+ pendingWrites.map((item) => fs.writeFile(item.filePath, item.content, "utf-8"))
1268
+ )
1269
+ writeSpinner.stop(pc.green(`✔ Successfully wrote ${pendingWrites.length} files to disk!`))
1270
+ }
1271
+ } else {
1272
+ console.log(pc.green("✔ No changes were selected. All files remain unchanged."))
1273
+ }
1274
+
1275
+ prompts.outro(pc.green("✔ Standardization process complete!"))
1276
+
1277
+ let runInstall = true
1278
+ if (isInteractive) {
1279
+ runInstall = await prompts.confirm({
1280
+ message: "Run 'vp install' now?",
1281
+ initialValue: true
1282
+ })
1283
+ if (prompts.isCancel(runInstall)) {
1284
+ process.exit(0)
1285
+ }
1286
+ }
1287
+
1288
+ if (runInstall) {
1289
+ const runVpInstall = prompts.spinner()
1290
+ runVpInstall.start("Running vp install...")
1291
+ const vpInstall = spawn.sync("vp", ["install"], { cwd: targetDir, stdio: "ignore" })
1292
+ if (vpInstall.status === 0) {
1293
+ runVpInstall.stop(pc.green("✔ vp install completed successfully!"))
1294
+ } else {
1295
+ runVpInstall.stop(
1296
+ pc.yellow("⚠ vp install failed. Please run it manually in the directory.")
1297
+ )
1298
+ }
1299
+ }
1300
+ } catch (error) {
1301
+ prompts.cancel(pc.red(`✖ Update failed: ${error.message}`))
1302
+ console.error(error)
1303
+ process.exit(1)
1304
+ }
1305
+ }