@lightnet/cli 4.2.0 → 4.3.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.
@@ -1,33 +1,25 @@
1
1
  // @ts-check
2
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"
3
+ import { join, posix, resolve } from "node:path"
4
+ import { cwd as processCwd, stdin, stdout } from "node:process"
5
+
6
+ import { confirm, intro, isCancel, log, outro, text } from "@clack/prompts"
14
7
 
15
8
  import { CliError } from "./support/cli-error.js"
9
+ import { contentCollections } from "./support/content-collections.js"
16
10
  import {
17
- deleteR2Objects,
18
- ensureR2Config,
19
- validateR2ReferencesWithRefresh,
20
- } from "./support/r2.js"
11
+ contentFiles,
12
+ files,
13
+ pathExists,
14
+ toLocalContentDisplayPath,
15
+ } from "./support/filesystem.js"
16
+ import { createR2FileStorage } from "./support/r2.js"
21
17
 
22
- const execFileAsync = promisify(execFile)
23
- const localJunkFileNames = new Set([".DS_Store"])
24
18
  const supportedScopes = new Set(["content-files", "thumbnails"])
25
19
 
26
20
  const mediaDir = "src/content/media"
27
21
  const mediaImagesDir = "src/content/media/images"
28
- const categoriesDir = "src/content/categories"
29
22
  const categoryImagesDir = "src/content/categories/images"
30
- const publicFilesDir = "public/files"
31
23
 
32
24
  /**
33
25
  * @typedef {{
@@ -38,44 +30,30 @@ const publicFilesDir = "public/files"
38
30
  * }} CheckFilesOptions
39
31
  */
40
32
 
41
- /**
42
- * @typedef {{
43
- * path: string
44
- * content: Array<{type: "upload"|"link", url: string}>
45
- * image?: string
46
- * }} ParsedMediaItem
47
- */
33
+ /** @typedef {import("./support/content-collections.js").MediaItem} MediaItem */
34
+ /** @typedef {import("./support/content-collections.js").Category} Category */
35
+ /** @typedef {import("./support/filesystem.js").FileStorage} FileStorage */
48
36
 
49
37
  /**
50
38
  * @typedef {{
51
39
  * path: string
52
- * image?: string
53
- * }} ParsedCategory
40
+ * sources: string[]
41
+ * }} MissingReference
54
42
  */
55
43
 
56
44
  /**
57
45
  * @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
46
+ * displayPath: string
47
+ * sources: Set<string>
48
+ * }} FileReference
68
49
  */
69
50
 
70
51
  /**
71
52
  * @typedef {{
72
53
  * cwd?: string
73
- * logger?: Logger
74
54
  * isInteractive?: boolean
75
55
  * promptText?: (message: string) => Promise<string>
76
56
  * 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
57
  * }} CheckFilesRuntime
80
58
  */
81
59
 
@@ -85,34 +63,27 @@ const publicFilesDir = "public/files"
85
63
  */
86
64
  export async function checkFiles(options, runtime = {}) {
87
65
  const cwd = runtime.cwd ?? processCwd()
88
- const logger = runtime.logger ?? console
89
66
  const interactive =
90
67
  runtime.isInteractive ?? Boolean(stdin.isTTY && stdout.isTTY)
91
68
  const promptText =
92
- runtime.promptText ??
93
- (async (message) =>
94
- defaultPromptText(message, { input: stdin, output: stdout }))
69
+ runtime.promptText ?? (async (message) => defaultPromptText(message))
95
70
  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
- }))
71
+ runtime.promptConfirm ?? (async (message) => defaultPromptConfirm(message))
107
72
 
108
73
  const scopes = parseScopes(options.scope)
109
74
  const includeContentFiles = scopes.has("content-files")
110
75
  const includeThumbnails = scopes.has("thumbnails")
111
76
 
77
+ intro("check-files")
78
+
112
79
  await assertLightNetSiteRoot(cwd)
113
80
 
114
- const mediaItems = await readMediaItems(cwd)
115
- const categories = await readCategories(cwd)
81
+ const collections = contentCollections(cwd)
82
+ const mediaItems = await collections.getMediaItems()
83
+ const categories = await collections.getCategories()
84
+ log.message(
85
+ `Checking ${mediaItems.length} media items and ${categories.length} categories.`,
86
+ )
116
87
 
117
88
  /** @type {MissingReference[]} */
118
89
  let missingContentFiles = []
@@ -127,40 +98,36 @@ export async function checkFiles(options, runtime = {}) {
127
98
  /** @type {string[]} */
128
99
  const warnings = []
129
100
 
130
- let r2Config = undefined
101
+ /** @type {FileStorage|undefined} */
102
+ let contentFileStorage = undefined
103
+ const mediaThumbnailStorage = files(cwd, { rootDir: mediaImagesDir })
104
+ const categoryThumbnailStorage = files(cwd, { rootDir: categoryImagesDir })
131
105
 
132
106
  if (includeContentFiles) {
133
107
  if (options.r2) {
134
- r2Config = await ensureR2Config({
135
- cwd,
136
- interactive,
137
- logger,
138
- promptText,
139
- })
140
- const r2Result = await validateR2ReferencesWithRefresh({
108
+ contentFileStorage = createR2FileStorage({
141
109
  cwd,
142
110
  interactive,
143
- logger,
144
111
  promptConfirm,
145
112
  promptText,
146
- promptSecret,
147
- config: r2Config,
148
- references: collectR2ContentReferences(mediaItems, r2Config.publicUrl),
149
- runRclone,
150
113
  })
151
- r2Config = r2Result.config
114
+ const r2Result = await validateContentFiles({
115
+ mediaItems,
116
+ storage: contentFileStorage,
117
+ })
152
118
  missingContentFiles = r2Result.missingReferences
153
- orphanedContentFiles = r2Result.orphanedObjects
119
+ orphanedContentFiles = r2Result.orphanedFiles
154
120
  if (r2Result.referenceCount === 0) {
155
121
  warnings.push(
156
122
  'No R2-backed content file references found. Use "--scope=thumbnails" or check your "--r2" setup.',
157
123
  )
158
124
  }
159
125
  } else {
160
- const localContentResult = await validateLocalContentFiles(
161
- cwd,
126
+ contentFileStorage = contentFiles(cwd)
127
+ const localContentResult = await validateContentFiles({
162
128
  mediaItems,
163
- )
129
+ storage: contentFileStorage,
130
+ })
164
131
  missingContentFiles = localContentResult.missingReferences
165
132
  orphanedContentFiles = localContentResult.orphanedFiles
166
133
  if (localContentResult.referenceCount === 0) {
@@ -173,45 +140,48 @@ export async function checkFiles(options, runtime = {}) {
173
140
 
174
141
  if (includeThumbnails) {
175
142
  const thumbnailsResult = await validateThumbnails(
176
- cwd,
177
143
  mediaItems,
178
144
  categories,
145
+ mediaThumbnailStorage,
146
+ categoryThumbnailStorage,
179
147
  )
180
148
  orphanedMediaThumbnails = thumbnailsResult.orphanedMediaThumbnails
181
149
  orphanedCategoryThumbnails = thumbnailsResult.orphanedCategoryThumbnails
182
150
  }
183
151
 
184
152
  if (options.fix) {
185
- /** @type {{displayPath:string, kind:"local-file"|"r2-object", target:string}[]} */
153
+ /** @type {{displayPath:string, target:string}[]} */
186
154
  const deletions = []
155
+ /** @type {{displayPath:string, target:string}[]} */
156
+ const contentDeletions = []
157
+ /** @type {{displayPath:string, target:string}[]} */
158
+ const localDeletions = []
187
159
  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
- })
160
+ if (!contentFileStorage) {
161
+ throw new CliError("Missing file storage for content deletion.")
200
162
  }
163
+ const deletion = {
164
+ displayPath: formatContentFilePath(filePath, options),
165
+ target: filePath,
166
+ }
167
+ contentDeletions.push(deletion)
168
+ deletions.push(deletion)
201
169
  }
202
170
  for (const filePath of orphanedMediaThumbnails) {
203
- deletions.push({
171
+ const deletion = {
204
172
  displayPath: filePath,
205
- kind: "local-file",
206
- target: resolve(cwd, filePath),
207
- })
173
+ target: filePath,
174
+ }
175
+ localDeletions.push(deletion)
176
+ deletions.push(deletion)
208
177
  }
209
178
  for (const filePath of orphanedCategoryThumbnails) {
210
- deletions.push({
179
+ const deletion = {
211
180
  displayPath: filePath,
212
- kind: "local-file",
213
- target: resolve(cwd, filePath),
214
- })
181
+ target: filePath,
182
+ }
183
+ localDeletions.push(deletion)
184
+ deletions.push(deletion)
215
185
  }
216
186
 
217
187
  if (deletions.length > 0) {
@@ -224,28 +194,48 @@ export async function checkFiles(options, runtime = {}) {
224
194
  options.yes ||
225
195
  (await confirmDeletion({
226
196
  deletions,
227
- logger,
228
197
  promptConfirm,
229
198
  }))
230
199
 
231
200
  if (shouldDelete) {
232
- removedItems = await deleteItems({
233
- cwd,
234
- logger,
235
- r2Config,
236
- deletions,
237
- options,
238
- promptSecret,
239
- runRclone,
240
- })
201
+ const deletedContentTargets = contentFileStorage
202
+ ? await contentFileStorage.delete(
203
+ contentDeletions.map((deletion) => deletion.target),
204
+ )
205
+ : []
206
+ const deletedLocalTargets = [
207
+ ...(await mediaThumbnailStorage.delete(
208
+ localDeletions
209
+ .filter((deletion) => deletion.target.startsWith(mediaImagesDir))
210
+ .map((deletion) => deletion.target),
211
+ )),
212
+ ...(await categoryThumbnailStorage.delete(
213
+ localDeletions
214
+ .filter((deletion) =>
215
+ deletion.target.startsWith(categoryImagesDir),
216
+ )
217
+ .map((deletion) => deletion.target),
218
+ )),
219
+ ]
220
+ const deletedTargets = new Set([
221
+ ...deletedContentTargets,
222
+ ...deletedLocalTargets,
223
+ ])
224
+ for (const deletion of [...contentDeletions, ...localDeletions]) {
225
+ if (deletedTargets.has(deletion.target)) {
226
+ removedItems.push(deletion.displayPath)
227
+ } else {
228
+ log.error(`Failed to delete "${deletion.displayPath}".`)
229
+ }
230
+ }
241
231
  orphanedContentFiles = orphanedContentFiles.filter(
242
- (item) => !removedItems.includes(item),
232
+ (item) => !deletedTargets.has(item),
243
233
  )
244
234
  orphanedMediaThumbnails = orphanedMediaThumbnails.filter(
245
- (item) => !removedItems.includes(item),
235
+ (item) => !deletedTargets.has(item),
246
236
  )
247
237
  orphanedCategoryThumbnails = orphanedCategoryThumbnails.filter(
248
- (item) => !removedItems.includes(item),
238
+ (item) => !deletedTargets.has(item),
249
239
  )
250
240
  }
251
241
  }
@@ -259,64 +249,59 @@ export async function checkFiles(options, runtime = {}) {
259
249
  orphanedCategoryThumbnails.length > 0
260
250
 
261
251
  if (!hasIssues && removedItems.length === 0) {
252
+ outro("No issues found. 🎉")
262
253
  return true
263
254
  }
264
255
 
265
256
  for (const warning of warnings) {
266
- logger.error(warning)
257
+ log.warn(warning)
267
258
  }
268
259
 
269
260
  printMissingReferenceSection(
270
- logger,
271
261
  "Missing referenced content files",
272
262
  missingContentFiles,
273
263
  )
274
264
  printSection(
275
- logger,
276
265
  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,
266
+ orphanedContentFiles.map((filePath) =>
267
+ formatContentFilePath(filePath, options),
268
+ ),
284
269
  )
285
- printSection(logger, "Removed items", removedItems)
270
+ printSection("Orphaned media thumbnails", orphanedMediaThumbnails)
271
+ printSection("Orphaned category thumbnails", orphanedCategoryThumbnails)
272
+ printSection("Removed items", removedItems)
273
+
274
+ outro(hasIssues ? "Issues found. 🚧" : "Cleanup complete. 🧹")
286
275
 
287
276
  return !hasIssues
288
277
  }
289
278
 
290
279
  /**
291
- * @param {Logger} logger
292
280
  * @param {string} title
293
281
  * @param {string[]} items
294
282
  */
295
- function printSection(logger, title, items) {
283
+ function printSection(title, items) {
296
284
  if (items.length === 0) {
297
285
  return
298
286
  }
299
- logger.error("")
300
- logger.error(`${title} (${items.length})`)
287
+ log.warn(`${title} (${items.length})`)
301
288
  for (const item of items) {
302
- logger.error(`- ${item}`)
289
+ log.message(`• ${item}`)
303
290
  }
304
291
  }
305
292
 
306
293
  /**
307
- * @param {Logger} logger
308
294
  * @param {string} title
309
295
  * @param {MissingReference[]} items
310
296
  */
311
- function printMissingReferenceSection(logger, title, items) {
297
+ function printMissingReferenceSection(title, items) {
312
298
  if (items.length === 0) {
313
299
  return
314
300
  }
315
- logger.error("")
316
- logger.error(`${title} (${items.length})`)
301
+ log.error(`${title} (${items.length})`)
317
302
  for (const item of items) {
318
- logger.error(
319
- `- ${item.path} (referenced by ${item.sources.toSorted().join(", ")})`,
303
+ log.message(
304
+ `• ${item.path} (referenced by ${item.sources.toSorted().join(", ")})`,
320
305
  )
321
306
  }
322
307
  }
@@ -361,106 +346,17 @@ async function assertLightNetSiteRoot(cwd) {
361
346
  }
362
347
 
363
348
  /**
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
349
+ * @param {MediaItem[]} mediaItems
350
+ * @param {Category[]} categories
351
+ * @param {FileStorage} mediaThumbnailStorage
352
+ * @param {FileStorage} categoryThumbnailStorage
462
353
  */
463
- async function validateThumbnails(cwd, mediaItems, categories) {
354
+ async function validateThumbnails(
355
+ mediaItems,
356
+ categories,
357
+ mediaThumbnailStorage,
358
+ categoryThumbnailStorage,
359
+ ) {
464
360
  const mediaReferences = new Set(
465
361
  mediaItems
466
362
  .map((item) => toThumbnailPath("media", item.image))
@@ -472,17 +368,10 @@ async function validateThumbnails(cwd, mediaItems, categories) {
472
368
  .filter((value) => value !== undefined),
473
369
  )
474
370
 
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
- )
371
+ const initializedMediaStorage = await mediaThumbnailStorage.init()
372
+ const initializedCategoryStorage = await categoryThumbnailStorage.init()
373
+ const mediaFiles = await initializedMediaStorage.list()
374
+ const categoryFiles = await initializedCategoryStorage.list()
486
375
 
487
376
  return {
488
377
  orphanedMediaThumbnails: mediaFiles.filter(
@@ -495,51 +384,66 @@ async function validateThumbnails(cwd, mediaItems, categories) {
495
384
  }
496
385
 
497
386
  /**
498
- * @param {ParsedMediaItem[]} mediaItems
387
+ * @param {{
388
+ * mediaItems: MediaItem[]
389
+ * storage: FileStorage
390
+ * }} args
499
391
  */
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
- }
392
+ async function validateContentFiles({ mediaItems, storage }) {
393
+ const initializedStorage = await storage.init()
394
+ const references = collectContentReferences(mediaItems, initializedStorage)
395
+ if (references.size === 0) {
396
+ return {
397
+ missingReferences: /** @type {MissingReference[]} */ ([]),
398
+ orphanedFiles: /** @type {string[]} */ ([]),
399
+ referenceCount: 0,
513
400
  }
514
401
  }
515
- return references
402
+
403
+ const files = await initializedStorage.list()
404
+ const fileSet = new Set(files)
405
+
406
+ const missingReferences = [...references.entries()]
407
+ .filter(([path]) => !fileSet.has(path))
408
+ .map(([, reference]) => ({
409
+ path: reference.displayPath,
410
+ sources: [...reference.sources].toSorted(),
411
+ }))
412
+ .sort((a, b) => a.path.localeCompare(b.path))
413
+
414
+ const orphanedFiles = files
415
+ .filter((filePath) => !references.has(filePath))
416
+ .sort()
417
+
418
+ return {
419
+ missingReferences,
420
+ orphanedFiles,
421
+ referenceCount: references.size,
422
+ }
516
423
  }
517
424
 
518
425
  /**
519
- * @param {ParsedMediaItem[]} mediaItems
520
- * @param {string} publicUrl
426
+ * @param {MediaItem[]} mediaItems
427
+ * @param {FileStorage} storage
521
428
  */
522
- function collectR2ContentReferences(mediaItems, publicUrl) {
429
+ function collectContentReferences(mediaItems, storage) {
523
430
  const references = new Map()
524
- const normalizedBase = publicUrl.replace(/\/+$/, "")
525
431
  for (const item of mediaItems) {
526
432
  const sourceFileName = posix.basename(toPosixPath(item.path))
527
433
  for (const contentItem of item.content) {
528
- if (
529
- contentItem.type !== "upload" ||
530
- !contentItem.url.startsWith(normalizedBase)
531
- ) {
434
+ if (contentItem.type !== "upload") {
435
+ continue
436
+ }
437
+ const filePath = storage.toPath(contentItem.url)
438
+ if (!filePath) {
532
439
  continue
533
440
  }
534
- const relativePath = contentItem.url
535
- .slice(normalizedBase.length)
536
- .replace(/^\/+/, "")
537
- const current = references.get(contentItem.url)
441
+ const current = references.get(filePath)
538
442
  if (current) {
539
443
  current.sources.add(sourceFileName)
540
444
  } else {
541
- references.set(contentItem.url, {
542
- key: relativePath,
445
+ references.set(filePath, {
446
+ displayPath: contentItem.url,
543
447
  sources: new Set([sourceFileName]),
544
448
  })
545
449
  }
@@ -548,58 +452,19 @@ function collectR2ContentReferences(mediaItems, publicUrl) {
548
452
  return references
549
453
  }
550
454
 
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
455
  /**
595
456
  * @param {"media"|"categories"} kind
596
457
  * @param {string|undefined} image
597
458
  */
598
459
  function toThumbnailPath(kind, image) {
599
- if (typeof image !== "string" || !image.startsWith("./images/")) {
460
+ if (typeof image !== "string") {
600
461
  return undefined
601
462
  }
602
- const relativePath = image.slice("./images/".length)
463
+ const relativePath = image.startsWith("./images/")
464
+ ? image.slice("./images/".length)
465
+ : image.startsWith("images/")
466
+ ? image.slice("images/".length)
467
+ : undefined
603
468
  if (
604
469
  !relativePath ||
605
470
  relativePath.startsWith("/") ||
@@ -613,194 +478,25 @@ function toThumbnailPath(kind, image) {
613
478
  }
614
479
 
615
480
  /**
616
- * @param {{
617
- * deletions: {displayPath:string, kind:"local-file"|"r2-object", target:string}[]
618
- * logger: Logger
619
- * promptConfirm: (message:string)=>Promise<boolean>
620
- * }} args
481
+ * @param {string} path
482
+ * @param {CheckFilesOptions} options
621
483
  */
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] ")
484
+ function formatContentFilePath(path, options) {
485
+ return options.r2 ? path : toLocalContentDisplayPath(path)
629
486
  }
630
487
 
631
488
  /**
632
489
  * @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}>
490
+ * deletions: {displayPath:string, target:string}[]
491
+ * promptConfirm: (message:string)=>Promise<boolean>
640
492
  * }} args
641
493
  */
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 = []
494
+ async function confirmDeletion({ deletions, promptConfirm }) {
495
+ log.warn(`Files to remove (${deletions.length})`)
654
496
  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
497
+ log.message(`• ${deletion.displayPath}`)
803
498
  }
499
+ return promptConfirm("Delete these files? [y/N] ")
804
500
  }
805
501
 
806
502
  /**
@@ -812,22 +508,19 @@ function toPosixPath(filePath) {
812
508
 
813
509
  /**
814
510
  * @param {string} message
815
- * @param {{input: NodeJS.ReadableStream, output: NodeJS.WritableStream}} io
816
511
  */
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
- }
512
+ async function defaultPromptText(message) {
513
+ const answer = await text({ message })
514
+ return isCancel(answer) ? "" : String(answer)
824
515
  }
825
516
 
826
517
  /**
827
518
  * @param {string} message
828
- * @param {{input: NodeJS.ReadableStream, output: NodeJS.WritableStream}} io
829
519
  */
830
- async function defaultPromptConfirm(message, io) {
831
- const answer = (await defaultPromptText(message, io)).trim().toLowerCase()
832
- return answer === "y" || answer === "yes"
520
+ async function defaultPromptConfirm(message) {
521
+ const answer = await confirm({
522
+ message,
523
+ initialValue: false,
524
+ })
525
+ return isCancel(answer) ? false : answer
833
526
  }