@lightnet/cli 4.1.1 → 4.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,833 @@
1
+ // @ts-check
2
+
3
+ import { execFile } from "node:child_process"
4
+ import { access, readdir, readFile, unlink } from "node:fs/promises"
5
+ import { join, posix, relative, resolve } from "node:path"
6
+ import {
7
+ cwd as processCwd,
8
+ env as processEnv,
9
+ stdin,
10
+ stdout,
11
+ } from "node:process"
12
+ import { createInterface } from "node:readline/promises"
13
+ import { promisify } from "node:util"
14
+
15
+ import { CliError } from "./support/cli-error.js"
16
+ import {
17
+ deleteR2Objects,
18
+ ensureR2Config,
19
+ validateR2ReferencesWithRefresh,
20
+ } from "./support/r2.js"
21
+
22
+ const execFileAsync = promisify(execFile)
23
+ const localJunkFileNames = new Set([".DS_Store"])
24
+ const supportedScopes = new Set(["content-files", "thumbnails"])
25
+
26
+ const mediaDir = "src/content/media"
27
+ const mediaImagesDir = "src/content/media/images"
28
+ const categoriesDir = "src/content/categories"
29
+ const categoryImagesDir = "src/content/categories/images"
30
+ const publicFilesDir = "public/files"
31
+
32
+ /**
33
+ * @typedef {{
34
+ * scope?: string
35
+ * fix?: boolean
36
+ * yes?: boolean
37
+ * r2?: boolean
38
+ * }} CheckFilesOptions
39
+ */
40
+
41
+ /**
42
+ * @typedef {{
43
+ * path: string
44
+ * content: Array<{type: "upload"|"link", url: string}>
45
+ * image?: string
46
+ * }} ParsedMediaItem
47
+ */
48
+
49
+ /**
50
+ * @typedef {{
51
+ * path: string
52
+ * image?: string
53
+ * }} ParsedCategory
54
+ */
55
+
56
+ /**
57
+ * @typedef {{
58
+ * log: (...args: unknown[]) => void
59
+ * error: (...args: unknown[]) => void
60
+ * }} Logger
61
+ */
62
+
63
+ /**
64
+ * @typedef {{
65
+ * path: string
66
+ * sources: string[]
67
+ * }} MissingReference
68
+ */
69
+
70
+ /**
71
+ * @typedef {{
72
+ * cwd?: string
73
+ * logger?: Logger
74
+ * isInteractive?: boolean
75
+ * promptText?: (message: string) => Promise<string>
76
+ * promptConfirm?: (message: string) => Promise<boolean>
77
+ * promptSecret?: (message: string) => Promise<string>
78
+ * runRclone?: (args: string[], cwd: string, env?: NodeJS.ProcessEnv) => Promise<{stdout:string, stderr:string}>
79
+ * }} CheckFilesRuntime
80
+ */
81
+
82
+ /**
83
+ * @param {CheckFilesOptions} options
84
+ * @param {CheckFilesRuntime} [runtime]
85
+ */
86
+ export async function checkFiles(options, runtime = {}) {
87
+ const cwd = runtime.cwd ?? processCwd()
88
+ const logger = runtime.logger ?? console
89
+ const interactive =
90
+ runtime.isInteractive ?? Boolean(stdin.isTTY && stdout.isTTY)
91
+ const promptText =
92
+ runtime.promptText ??
93
+ (async (message) =>
94
+ defaultPromptText(message, { input: stdin, output: stdout }))
95
+ const promptConfirm =
96
+ runtime.promptConfirm ??
97
+ (async (message) =>
98
+ defaultPromptConfirm(message, { input: stdin, output: stdout }))
99
+ const promptSecret = runtime.promptSecret
100
+ const runRclone =
101
+ runtime.runRclone ??
102
+ ((args, commandCwd, commandEnv) =>
103
+ execFileAsync("rclone", args, {
104
+ cwd: commandCwd,
105
+ env: commandEnv ?? processEnv,
106
+ }))
107
+
108
+ const scopes = parseScopes(options.scope)
109
+ const includeContentFiles = scopes.has("content-files")
110
+ const includeThumbnails = scopes.has("thumbnails")
111
+
112
+ await assertLightNetSiteRoot(cwd)
113
+
114
+ const mediaItems = await readMediaItems(cwd)
115
+ const categories = await readCategories(cwd)
116
+
117
+ /** @type {MissingReference[]} */
118
+ let missingContentFiles = []
119
+ /** @type {string[]} */
120
+ let orphanedContentFiles = []
121
+ /** @type {string[]} */
122
+ let orphanedMediaThumbnails = []
123
+ /** @type {string[]} */
124
+ let orphanedCategoryThumbnails = []
125
+ /** @type {string[]} */
126
+ let removedItems = []
127
+ /** @type {string[]} */
128
+ const warnings = []
129
+
130
+ let r2Config = undefined
131
+
132
+ if (includeContentFiles) {
133
+ if (options.r2) {
134
+ r2Config = await ensureR2Config({
135
+ cwd,
136
+ interactive,
137
+ logger,
138
+ promptText,
139
+ })
140
+ const r2Result = await validateR2ReferencesWithRefresh({
141
+ cwd,
142
+ interactive,
143
+ logger,
144
+ promptConfirm,
145
+ promptText,
146
+ promptSecret,
147
+ config: r2Config,
148
+ references: collectR2ContentReferences(mediaItems, r2Config.publicUrl),
149
+ runRclone,
150
+ })
151
+ r2Config = r2Result.config
152
+ missingContentFiles = r2Result.missingReferences
153
+ orphanedContentFiles = r2Result.orphanedObjects
154
+ if (r2Result.referenceCount === 0) {
155
+ warnings.push(
156
+ 'No R2-backed content file references found. Use "--scope=thumbnails" or check your "--r2" setup.',
157
+ )
158
+ }
159
+ } else {
160
+ const localContentResult = await validateLocalContentFiles(
161
+ cwd,
162
+ mediaItems,
163
+ )
164
+ missingContentFiles = localContentResult.missingReferences
165
+ orphanedContentFiles = localContentResult.orphanedFiles
166
+ if (localContentResult.referenceCount === 0) {
167
+ warnings.push(
168
+ 'No local "/files" content file references found. Use "--scope=thumbnails" or run with "--r2".',
169
+ )
170
+ }
171
+ }
172
+ }
173
+
174
+ if (includeThumbnails) {
175
+ const thumbnailsResult = await validateThumbnails(
176
+ cwd,
177
+ mediaItems,
178
+ categories,
179
+ )
180
+ orphanedMediaThumbnails = thumbnailsResult.orphanedMediaThumbnails
181
+ orphanedCategoryThumbnails = thumbnailsResult.orphanedCategoryThumbnails
182
+ }
183
+
184
+ if (options.fix) {
185
+ /** @type {{displayPath:string, kind:"local-file"|"r2-object", target:string}[]} */
186
+ const deletions = []
187
+ for (const filePath of orphanedContentFiles) {
188
+ if (options.r2) {
189
+ deletions.push({
190
+ displayPath: filePath,
191
+ kind: "r2-object",
192
+ target: filePath,
193
+ })
194
+ } else {
195
+ deletions.push({
196
+ displayPath: filePath,
197
+ kind: "local-file",
198
+ target: resolve(cwd, filePath),
199
+ })
200
+ }
201
+ }
202
+ for (const filePath of orphanedMediaThumbnails) {
203
+ deletions.push({
204
+ displayPath: filePath,
205
+ kind: "local-file",
206
+ target: resolve(cwd, filePath),
207
+ })
208
+ }
209
+ for (const filePath of orphanedCategoryThumbnails) {
210
+ deletions.push({
211
+ displayPath: filePath,
212
+ kind: "local-file",
213
+ target: resolve(cwd, filePath),
214
+ })
215
+ }
216
+
217
+ if (deletions.length > 0) {
218
+ if (!options.yes && !interactive) {
219
+ throw new CliError(
220
+ 'Deletion requires confirmation. Re-run with "--fix --yes" in non-interactive environments.',
221
+ )
222
+ }
223
+ const shouldDelete =
224
+ options.yes ||
225
+ (await confirmDeletion({
226
+ deletions,
227
+ logger,
228
+ promptConfirm,
229
+ }))
230
+
231
+ if (shouldDelete) {
232
+ removedItems = await deleteItems({
233
+ cwd,
234
+ logger,
235
+ r2Config,
236
+ deletions,
237
+ options,
238
+ promptSecret,
239
+ runRclone,
240
+ })
241
+ orphanedContentFiles = orphanedContentFiles.filter(
242
+ (item) => !removedItems.includes(item),
243
+ )
244
+ orphanedMediaThumbnails = orphanedMediaThumbnails.filter(
245
+ (item) => !removedItems.includes(item),
246
+ )
247
+ orphanedCategoryThumbnails = orphanedCategoryThumbnails.filter(
248
+ (item) => !removedItems.includes(item),
249
+ )
250
+ }
251
+ }
252
+ }
253
+
254
+ const hasIssues =
255
+ warnings.length > 0 ||
256
+ missingContentFiles.length > 0 ||
257
+ orphanedContentFiles.length > 0 ||
258
+ orphanedMediaThumbnails.length > 0 ||
259
+ orphanedCategoryThumbnails.length > 0
260
+
261
+ if (!hasIssues && removedItems.length === 0) {
262
+ return true
263
+ }
264
+
265
+ for (const warning of warnings) {
266
+ logger.error(warning)
267
+ }
268
+
269
+ printMissingReferenceSection(
270
+ logger,
271
+ "Missing referenced content files",
272
+ missingContentFiles,
273
+ )
274
+ printSection(
275
+ logger,
276
+ options.r2 ? "Orphaned R2 objects" : "Orphaned local content files",
277
+ orphanedContentFiles,
278
+ )
279
+ printSection(logger, "Orphaned media thumbnails", orphanedMediaThumbnails)
280
+ printSection(
281
+ logger,
282
+ "Orphaned category thumbnails",
283
+ orphanedCategoryThumbnails,
284
+ )
285
+ printSection(logger, "Removed items", removedItems)
286
+
287
+ return !hasIssues
288
+ }
289
+
290
+ /**
291
+ * @param {Logger} logger
292
+ * @param {string} title
293
+ * @param {string[]} items
294
+ */
295
+ function printSection(logger, title, items) {
296
+ if (items.length === 0) {
297
+ return
298
+ }
299
+ logger.error("")
300
+ logger.error(`${title} (${items.length})`)
301
+ for (const item of items) {
302
+ logger.error(`- ${item}`)
303
+ }
304
+ }
305
+
306
+ /**
307
+ * @param {Logger} logger
308
+ * @param {string} title
309
+ * @param {MissingReference[]} items
310
+ */
311
+ function printMissingReferenceSection(logger, title, items) {
312
+ if (items.length === 0) {
313
+ return
314
+ }
315
+ logger.error("")
316
+ logger.error(`${title} (${items.length})`)
317
+ for (const item of items) {
318
+ logger.error(
319
+ `- ${item.path} (referenced by ${item.sources.toSorted().join(", ")})`,
320
+ )
321
+ }
322
+ }
323
+
324
+ /**
325
+ * @param {string|undefined} rawScope
326
+ */
327
+ function parseScopes(rawScope) {
328
+ const values = (rawScope ?? "content-files,thumbnails")
329
+ .split(",")
330
+ .map((value) => value.trim())
331
+ .filter(Boolean)
332
+
333
+ if (values.length === 0) {
334
+ throw new CliError(
335
+ // intentionally kept as a CLI-facing validation error
336
+ 'Expected "--scope" to include at least one of "content-files" or "thumbnails".',
337
+ )
338
+ }
339
+
340
+ const scopes = new Set(values)
341
+ for (const scope of scopes) {
342
+ if (!supportedScopes.has(scope)) {
343
+ throw new CliError(
344
+ `Unsupported scope "${scope}". Allowed values: content-files, thumbnails.`,
345
+ )
346
+ }
347
+ }
348
+
349
+ return scopes
350
+ }
351
+
352
+ /**
353
+ * @param {string} cwd
354
+ */
355
+ async function assertLightNetSiteRoot(cwd) {
356
+ if (!(await pathExists(resolve(cwd, mediaDir)))) {
357
+ throw new CliError(
358
+ 'Expected a LightNet site root with "src/content/media". Run this command inside a LightNet site project.',
359
+ )
360
+ }
361
+ }
362
+
363
+ /**
364
+ * @param {string} cwd
365
+ */
366
+ async function readMediaItems(cwd) {
367
+ const files = await listJsonFiles(resolve(cwd, mediaDir))
368
+ /** @type {ParsedMediaItem[]} */
369
+ const items = []
370
+ for (const filePath of files) {
371
+ items.push(await readMediaItem(filePath))
372
+ }
373
+ return items
374
+ }
375
+
376
+ /**
377
+ * @param {string} cwd
378
+ */
379
+ async function readCategories(cwd) {
380
+ const absoluteDir = resolve(cwd, categoriesDir)
381
+ if (!(await pathExists(absoluteDir))) {
382
+ return []
383
+ }
384
+ const files = await listJsonFiles(absoluteDir)
385
+ /** @type {ParsedCategory[]} */
386
+ const items = []
387
+ for (const filePath of files) {
388
+ items.push(await readCategory(filePath))
389
+ }
390
+ return items
391
+ }
392
+
393
+ /**
394
+ * @param {string} filePath
395
+ * @returns {Promise<ParsedMediaItem>}
396
+ */
397
+ async function readMediaItem(filePath) {
398
+ const value = await readJsonFile(filePath)
399
+ if (!isPlainObject(value)) {
400
+ throw new CliError(
401
+ `Invalid media item in "${filePath}": expected an object.`,
402
+ )
403
+ }
404
+
405
+ const image = readOptionalImageReference(value.image, filePath)
406
+ const content = readContentArray(value.content, filePath)
407
+
408
+ return {
409
+ path: filePath,
410
+ content,
411
+ image,
412
+ }
413
+ }
414
+
415
+ /**
416
+ * @param {string} filePath
417
+ * @returns {Promise<ParsedCategory>}
418
+ */
419
+ async function readCategory(filePath) {
420
+ const value = await readJsonFile(filePath)
421
+ if (!isPlainObject(value)) {
422
+ throw new CliError(`Invalid category in "${filePath}": expected an object.`)
423
+ }
424
+
425
+ return {
426
+ path: filePath,
427
+ image: readOptionalImageReference(value.image, filePath),
428
+ }
429
+ }
430
+
431
+ /**
432
+ * @param {string} cwd
433
+ * @param {ParsedMediaItem[]} mediaItems
434
+ */
435
+ async function validateLocalContentFiles(cwd, mediaItems) {
436
+ const references = collectLocalContentReferences(mediaItems)
437
+ const files = await collectLocalContentFiles(cwd)
438
+
439
+ const missingReferences = [...references.entries()]
440
+ .filter(([path]) => !files.urlSet.has(path))
441
+ .map(([path, sources]) => ({
442
+ path,
443
+ sources: [...sources].toSorted(),
444
+ }))
445
+ .sort((a, b) => a.path.localeCompare(b.path))
446
+ const orphanedFiles = [...files.pathByUrl.entries()]
447
+ .filter(([url]) => !references.has(url))
448
+ .map(([, filePath]) => filePath)
449
+ .sort()
450
+
451
+ return {
452
+ missingReferences,
453
+ orphanedFiles,
454
+ referenceCount: references.size,
455
+ }
456
+ }
457
+
458
+ /**
459
+ * @param {string} cwd
460
+ * @param {ParsedMediaItem[]} mediaItems
461
+ * @param {ParsedCategory[]} categories
462
+ */
463
+ async function validateThumbnails(cwd, mediaItems, categories) {
464
+ const mediaReferences = new Set(
465
+ mediaItems
466
+ .map((item) => toThumbnailPath("media", item.image))
467
+ .filter((value) => value !== undefined),
468
+ )
469
+ const categoryReferences = new Set(
470
+ categories
471
+ .map((item) => toThumbnailPath("categories", item.image))
472
+ .filter((value) => value !== undefined),
473
+ )
474
+
475
+ const mediaFiles = await collectLocalFiles(resolve(cwd, mediaImagesDir), {
476
+ includeRootPrefix: mediaImagesDir,
477
+ ignoreJunk: true,
478
+ })
479
+ const categoryFiles = await collectLocalFiles(
480
+ resolve(cwd, categoryImagesDir),
481
+ {
482
+ includeRootPrefix: categoryImagesDir,
483
+ ignoreJunk: true,
484
+ },
485
+ )
486
+
487
+ return {
488
+ orphanedMediaThumbnails: mediaFiles.filter(
489
+ (file) => !mediaReferences.has(file),
490
+ ),
491
+ orphanedCategoryThumbnails: categoryFiles.filter(
492
+ (file) => !categoryReferences.has(file),
493
+ ),
494
+ }
495
+ }
496
+
497
+ /**
498
+ * @param {ParsedMediaItem[]} mediaItems
499
+ */
500
+ function collectLocalContentReferences(mediaItems) {
501
+ const references = new Map()
502
+ for (const item of mediaItems) {
503
+ const sourceFileName = posix.basename(toPosixPath(item.path))
504
+ for (const contentItem of item.content) {
505
+ if (
506
+ contentItem.type === "upload" &&
507
+ contentItem.url.startsWith("/files")
508
+ ) {
509
+ const sources = references.get(contentItem.url) ?? new Set()
510
+ sources.add(sourceFileName)
511
+ references.set(contentItem.url, sources)
512
+ }
513
+ }
514
+ }
515
+ return references
516
+ }
517
+
518
+ /**
519
+ * @param {ParsedMediaItem[]} mediaItems
520
+ * @param {string} publicUrl
521
+ */
522
+ function collectR2ContentReferences(mediaItems, publicUrl) {
523
+ const references = new Map()
524
+ const normalizedBase = publicUrl.replace(/\/+$/, "")
525
+ for (const item of mediaItems) {
526
+ const sourceFileName = posix.basename(toPosixPath(item.path))
527
+ for (const contentItem of item.content) {
528
+ if (
529
+ contentItem.type !== "upload" ||
530
+ !contentItem.url.startsWith(normalizedBase)
531
+ ) {
532
+ continue
533
+ }
534
+ const relativePath = contentItem.url
535
+ .slice(normalizedBase.length)
536
+ .replace(/^\/+/, "")
537
+ const current = references.get(contentItem.url)
538
+ if (current) {
539
+ current.sources.add(sourceFileName)
540
+ } else {
541
+ references.set(contentItem.url, {
542
+ key: relativePath,
543
+ sources: new Set([sourceFileName]),
544
+ })
545
+ }
546
+ }
547
+ }
548
+ return references
549
+ }
550
+
551
+ /**
552
+ * @param {string} cwd
553
+ */
554
+ async function collectLocalContentFiles(cwd) {
555
+ const files = await collectLocalFiles(resolve(cwd, publicFilesDir), {
556
+ includeRootPrefix: publicFilesDir,
557
+ ignoreJunk: true,
558
+ })
559
+ const pathByUrl = new Map(
560
+ files.map((filePath) => [
561
+ `/${toPosixPath(relative(resolve(cwd, "public"), resolve(cwd, filePath)))}`,
562
+ filePath,
563
+ ]),
564
+ )
565
+ return {
566
+ pathByUrl,
567
+ urlSet: new Set(pathByUrl.keys()),
568
+ }
569
+ }
570
+
571
+ /**
572
+ * @param {string} absoluteDir
573
+ * @param {{includeRootPrefix:string, ignoreJunk:boolean}} options
574
+ */
575
+ async function collectLocalFiles(absoluteDir, options) {
576
+ if (!(await pathExists(absoluteDir))) {
577
+ return []
578
+ }
579
+ const files = await walkFiles(absoluteDir)
580
+ return files
581
+ .filter((filePath) =>
582
+ options.ignoreJunk
583
+ ? !localJunkFileNames.has(posix.basename(filePath))
584
+ : true,
585
+ )
586
+ .map((filePath) =>
587
+ toPosixPath(
588
+ join(options.includeRootPrefix, relative(absoluteDir, filePath)),
589
+ ),
590
+ )
591
+ .sort()
592
+ }
593
+
594
+ /**
595
+ * @param {"media"|"categories"} kind
596
+ * @param {string|undefined} image
597
+ */
598
+ function toThumbnailPath(kind, image) {
599
+ if (typeof image !== "string" || !image.startsWith("./images/")) {
600
+ return undefined
601
+ }
602
+ const relativePath = image.slice("./images/".length)
603
+ if (
604
+ !relativePath ||
605
+ relativePath.startsWith("/") ||
606
+ relativePath.includes("../")
607
+ ) {
608
+ return undefined
609
+ }
610
+ return kind === "media"
611
+ ? toPosixPath(join(mediaImagesDir, relativePath))
612
+ : toPosixPath(join(categoryImagesDir, relativePath))
613
+ }
614
+
615
+ /**
616
+ * @param {{
617
+ * deletions: {displayPath:string, kind:"local-file"|"r2-object", target:string}[]
618
+ * logger: Logger
619
+ * promptConfirm: (message:string)=>Promise<boolean>
620
+ * }} args
621
+ */
622
+ async function confirmDeletion({ deletions, logger, promptConfirm }) {
623
+ logger.error("")
624
+ logger.error(`Files to remove (${deletions.length})`)
625
+ for (const deletion of deletions) {
626
+ logger.error(`- ${deletion.displayPath}`)
627
+ }
628
+ return promptConfirm("Delete these files? [y/N] ")
629
+ }
630
+
631
+ /**
632
+ * @param {{
633
+ * cwd: string
634
+ * logger: Logger
635
+ * r2Config: import("./support/r2.js").R2Config|undefined
636
+ * deletions: {displayPath:string, kind:"local-file"|"r2-object", target:string}[]
637
+ * options: CheckFilesOptions
638
+ * promptSecret?: (message: string) => Promise<string>
639
+ * runRclone: (args: string[], cwd: string, env?: NodeJS.ProcessEnv) => Promise<{stdout:string, stderr:string}>
640
+ * }} args
641
+ */
642
+ async function deleteItems({
643
+ cwd,
644
+ logger,
645
+ r2Config,
646
+ deletions,
647
+ options,
648
+ promptSecret,
649
+ runRclone,
650
+ }) {
651
+ /** @type {string[]} */
652
+ const removed = []
653
+ const r2ObjectKeys = []
654
+ for (const deletion of deletions) {
655
+ try {
656
+ if (deletion.kind === "local-file") {
657
+ await unlink(deletion.target)
658
+ removed.push(deletion.displayPath)
659
+ } else {
660
+ if (!options.r2 || !r2Config) {
661
+ throw new CliError("Missing R2 config for remote deletion.")
662
+ }
663
+ r2ObjectKeys.push(deletion.target)
664
+ }
665
+ } catch (error) {
666
+ logger.error(
667
+ error instanceof Error
668
+ ? `Failed to delete "${deletion.displayPath}": ${error.message}`
669
+ : `Failed to delete "${deletion.displayPath}".`,
670
+ )
671
+ }
672
+ }
673
+ if (r2ObjectKeys.length > 0) {
674
+ if (!r2Config) {
675
+ throw new CliError("Missing R2 config for remote deletion.")
676
+ }
677
+ const removedRemoteItems = await deleteR2Objects({
678
+ cwd,
679
+ config: r2Config,
680
+ objectKeys: r2ObjectKeys,
681
+ promptSecret,
682
+ runRclone,
683
+ })
684
+ removed.push(...removedRemoteItems)
685
+ const failedRemoteItems = r2ObjectKeys.filter(
686
+ (item) => !removedRemoteItems.includes(item),
687
+ )
688
+ for (const item of failedRemoteItems) {
689
+ logger.error(`Failed to delete "${item}".`)
690
+ }
691
+ }
692
+ return removed
693
+ }
694
+
695
+ /**
696
+ * @param {string} filePath
697
+ */
698
+ async function readJsonFile(filePath) {
699
+ const text = await readFile(filePath, "utf8")
700
+ try {
701
+ return JSON.parse(text)
702
+ } catch {
703
+ throw new CliError(`Invalid JSON in "${filePath}".`)
704
+ }
705
+ }
706
+
707
+ /**
708
+ * @param {string} dirPath
709
+ */
710
+ async function listJsonFiles(dirPath) {
711
+ if (!(await pathExists(dirPath))) {
712
+ return []
713
+ }
714
+ const files = await walkFiles(dirPath)
715
+ return files.filter((filePath) => filePath.endsWith(".json")).sort()
716
+ }
717
+
718
+ /**
719
+ * @param {string} dirPath
720
+ */
721
+ async function walkFiles(dirPath) {
722
+ /** @type {string[]} */
723
+ const files = []
724
+ const entries = await readdir(dirPath, { withFileTypes: true })
725
+ for (const entry of entries) {
726
+ const entryPath = join(dirPath, entry.name)
727
+ if (entry.isDirectory()) {
728
+ files.push(...(await walkFiles(entryPath)))
729
+ } else if (entry.isFile()) {
730
+ files.push(entryPath)
731
+ }
732
+ }
733
+ return files
734
+ }
735
+
736
+ /**
737
+ * @param {unknown} value
738
+ * @param {string} filePath
739
+ */
740
+ function readOptionalImageReference(value, filePath) {
741
+ if (value === undefined) {
742
+ return undefined
743
+ }
744
+ if (typeof value !== "string") {
745
+ throw new CliError(
746
+ `Invalid "image" in "${filePath}": expected a string when provided.`,
747
+ )
748
+ }
749
+ return value
750
+ }
751
+
752
+ /**
753
+ * @param {unknown} value
754
+ * @param {string} filePath
755
+ * @returns {Array<{type: "upload"|"link", url: string}>}
756
+ */
757
+ function readContentArray(value, filePath) {
758
+ if (!Array.isArray(value) || value.length === 0) {
759
+ throw new CliError(
760
+ `Invalid "content" in "${filePath}": expected a non-empty array.`,
761
+ )
762
+ }
763
+
764
+ return value.map((item) => {
765
+ if (!isPlainObject(item)) {
766
+ throw new CliError(
767
+ `Invalid "content" in "${filePath}": expected array items to be objects.`,
768
+ )
769
+ }
770
+ if (item.type !== "upload" && item.type !== "link") {
771
+ throw new CliError(
772
+ `Invalid "content.type" in "${filePath}": expected "upload" or "link".`,
773
+ )
774
+ }
775
+ if (typeof item.url !== "string") {
776
+ throw new CliError(
777
+ `Invalid "content.url" in "${filePath}": expected a string.`,
778
+ )
779
+ }
780
+ return {
781
+ type: item.type,
782
+ url: item.url,
783
+ }
784
+ })
785
+ }
786
+
787
+ /**
788
+ * @param {unknown} value
789
+ */
790
+ function isPlainObject(value) {
791
+ return typeof value === "object" && value !== null && !Array.isArray(value)
792
+ }
793
+
794
+ /**
795
+ * @param {string} filePath
796
+ */
797
+ async function pathExists(filePath) {
798
+ try {
799
+ await access(filePath)
800
+ return true
801
+ } catch {
802
+ return false
803
+ }
804
+ }
805
+
806
+ /**
807
+ * @param {string} filePath
808
+ */
809
+ function toPosixPath(filePath) {
810
+ return filePath.split("\\").join("/")
811
+ }
812
+
813
+ /**
814
+ * @param {string} message
815
+ * @param {{input: NodeJS.ReadableStream, output: NodeJS.WritableStream}} io
816
+ */
817
+ async function defaultPromptText(message, io) {
818
+ const rl = createInterface(io)
819
+ try {
820
+ return await rl.question(message)
821
+ } finally {
822
+ rl.close()
823
+ }
824
+ }
825
+
826
+ /**
827
+ * @param {string} message
828
+ * @param {{input: NodeJS.ReadableStream, output: NodeJS.WritableStream}} io
829
+ */
830
+ async function defaultPromptConfirm(message, io) {
831
+ const answer = (await defaultPromptText(message, io)).trim().toLowerCase()
832
+ return answer === "y" || answer === "yes"
833
+ }