@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/README.md +19 -0
- package/bin/rimelight.js +78 -0
- package/package.json +44 -0
- package/src/index.js +1305 -0
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
|
+
}
|