@lightnet/cli 4.2.0 → 4.4.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,81 +1,59 @@
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 {{
34
26
  * scope?: string
35
27
  * fix?: boolean
36
- * yes?: boolean
28
+ * confirm?: boolean
37
29
  * r2?: boolean
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,79 +140,103 @@ 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) {
218
- if (!options.yes && !interactive) {
188
+ const shouldSkipConfirmation = options.confirm === false
189
+ if (!shouldSkipConfirmation && !interactive) {
219
190
  throw new CliError(
220
- 'Deletion requires confirmation. Re-run with "--fix --yes" in non-interactive environments.',
191
+ 'Deletion requires confirmation. Re-run with "--fix --no-confirm" in non-interactive environments.',
221
192
  )
222
193
  }
223
194
  const shouldDelete =
224
- options.yes ||
195
+ shouldSkipConfirmation ||
225
196
  (await confirmDeletion({
226
197
  deletions,
227
- logger,
228
198
  promptConfirm,
229
199
  }))
230
200
 
231
201
  if (shouldDelete) {
232
- removedItems = await deleteItems({
233
- cwd,
234
- logger,
235
- r2Config,
236
- deletions,
237
- options,
238
- promptSecret,
239
- runRclone,
240
- })
202
+ const deletedContentTargets = contentFileStorage
203
+ ? await contentFileStorage.delete(
204
+ contentDeletions.map((deletion) => deletion.target),
205
+ )
206
+ : []
207
+ const deletedLocalTargets = [
208
+ ...(await mediaThumbnailStorage.delete(
209
+ localDeletions
210
+ .filter((deletion) => deletion.target.startsWith(mediaImagesDir))
211
+ .map((deletion) => deletion.target),
212
+ )),
213
+ ...(await categoryThumbnailStorage.delete(
214
+ localDeletions
215
+ .filter((deletion) =>
216
+ deletion.target.startsWith(categoryImagesDir),
217
+ )
218
+ .map((deletion) => deletion.target),
219
+ )),
220
+ ]
221
+ const deletedTargets = new Set([
222
+ ...deletedContentTargets,
223
+ ...deletedLocalTargets,
224
+ ])
225
+ for (const deletion of [...contentDeletions, ...localDeletions]) {
226
+ if (deletedTargets.has(deletion.target)) {
227
+ removedItems.push(deletion.displayPath)
228
+ } else {
229
+ log.error(`Failed to delete "${deletion.displayPath}".`)
230
+ }
231
+ }
241
232
  orphanedContentFiles = orphanedContentFiles.filter(
242
- (item) => !removedItems.includes(item),
233
+ (item) => !deletedTargets.has(item),
243
234
  )
244
235
  orphanedMediaThumbnails = orphanedMediaThumbnails.filter(
245
- (item) => !removedItems.includes(item),
236
+ (item) => !deletedTargets.has(item),
246
237
  )
247
238
  orphanedCategoryThumbnails = orphanedCategoryThumbnails.filter(
248
- (item) => !removedItems.includes(item),
239
+ (item) => !deletedTargets.has(item),
249
240
  )
250
241
  }
251
242
  }
@@ -259,64 +250,59 @@ export async function checkFiles(options, runtime = {}) {
259
250
  orphanedCategoryThumbnails.length > 0
260
251
 
261
252
  if (!hasIssues && removedItems.length === 0) {
253
+ outro("No issues found. 🎉")
262
254
  return true
263
255
  }
264
256
 
265
257
  for (const warning of warnings) {
266
- logger.error(warning)
258
+ log.warn(warning)
267
259
  }
268
260
 
269
261
  printMissingReferenceSection(
270
- logger,
271
262
  "Missing referenced content files",
272
263
  missingContentFiles,
273
264
  )
274
265
  printSection(
275
- logger,
276
266
  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,
267
+ orphanedContentFiles.map((filePath) =>
268
+ formatContentFilePath(filePath, options),
269
+ ),
284
270
  )
285
- printSection(logger, "Removed items", removedItems)
271
+ printSection("Orphaned media thumbnails", orphanedMediaThumbnails)
272
+ printSection("Orphaned category thumbnails", orphanedCategoryThumbnails)
273
+ printSection("Removed items", removedItems)
274
+
275
+ outro(hasIssues ? "Issues found. 🚧" : "Cleanup complete. 🧹")
286
276
 
287
277
  return !hasIssues
288
278
  }
289
279
 
290
280
  /**
291
- * @param {Logger} logger
292
281
  * @param {string} title
293
282
  * @param {string[]} items
294
283
  */
295
- function printSection(logger, title, items) {
284
+ function printSection(title, items) {
296
285
  if (items.length === 0) {
297
286
  return
298
287
  }
299
- logger.error("")
300
- logger.error(`${title} (${items.length})`)
288
+ log.warn(`${title} (${items.length})`)
301
289
  for (const item of items) {
302
- logger.error(`- ${item}`)
290
+ log.message(`• ${item}`)
303
291
  }
304
292
  }
305
293
 
306
294
  /**
307
- * @param {Logger} logger
308
295
  * @param {string} title
309
296
  * @param {MissingReference[]} items
310
297
  */
311
- function printMissingReferenceSection(logger, title, items) {
298
+ function printMissingReferenceSection(title, items) {
312
299
  if (items.length === 0) {
313
300
  return
314
301
  }
315
- logger.error("")
316
- logger.error(`${title} (${items.length})`)
302
+ log.error(`${title} (${items.length})`)
317
303
  for (const item of items) {
318
- logger.error(
319
- `- ${item.path} (referenced by ${item.sources.toSorted().join(", ")})`,
304
+ log.message(
305
+ `• ${item.path} (referenced by ${item.sources.toSorted().join(", ")})`,
320
306
  )
321
307
  }
322
308
  }
@@ -361,106 +347,17 @@ async function assertLightNetSiteRoot(cwd) {
361
347
  }
362
348
 
363
349
  /**
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
350
+ * @param {MediaItem[]} mediaItems
351
+ * @param {Category[]} categories
352
+ * @param {FileStorage} mediaThumbnailStorage
353
+ * @param {FileStorage} categoryThumbnailStorage
462
354
  */
463
- async function validateThumbnails(cwd, mediaItems, categories) {
355
+ async function validateThumbnails(
356
+ mediaItems,
357
+ categories,
358
+ mediaThumbnailStorage,
359
+ categoryThumbnailStorage,
360
+ ) {
464
361
  const mediaReferences = new Set(
465
362
  mediaItems
466
363
  .map((item) => toThumbnailPath("media", item.image))
@@ -472,17 +369,10 @@ async function validateThumbnails(cwd, mediaItems, categories) {
472
369
  .filter((value) => value !== undefined),
473
370
  )
474
371
 
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
- )
372
+ const initializedMediaStorage = await mediaThumbnailStorage.init()
373
+ const initializedCategoryStorage = await categoryThumbnailStorage.init()
374
+ const mediaFiles = await initializedMediaStorage.list()
375
+ const categoryFiles = await initializedCategoryStorage.list()
486
376
 
487
377
  return {
488
378
  orphanedMediaThumbnails: mediaFiles.filter(
@@ -495,51 +385,66 @@ async function validateThumbnails(cwd, mediaItems, categories) {
495
385
  }
496
386
 
497
387
  /**
498
- * @param {ParsedMediaItem[]} mediaItems
388
+ * @param {{
389
+ * mediaItems: MediaItem[]
390
+ * storage: FileStorage
391
+ * }} args
499
392
  */
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
- }
393
+ async function validateContentFiles({ mediaItems, storage }) {
394
+ const initializedStorage = await storage.init()
395
+ const references = collectContentReferences(mediaItems, initializedStorage)
396
+ if (references.size === 0) {
397
+ return {
398
+ missingReferences: /** @type {MissingReference[]} */ ([]),
399
+ orphanedFiles: /** @type {string[]} */ ([]),
400
+ referenceCount: 0,
513
401
  }
514
402
  }
515
- return references
403
+
404
+ const files = await initializedStorage.list()
405
+ const fileSet = new Set(files)
406
+
407
+ const missingReferences = [...references.entries()]
408
+ .filter(([path]) => !fileSet.has(path))
409
+ .map(([, reference]) => ({
410
+ path: reference.displayPath,
411
+ sources: [...reference.sources].toSorted(),
412
+ }))
413
+ .sort((a, b) => a.path.localeCompare(b.path))
414
+
415
+ const orphanedFiles = files
416
+ .filter((filePath) => !references.has(filePath))
417
+ .sort()
418
+
419
+ return {
420
+ missingReferences,
421
+ orphanedFiles,
422
+ referenceCount: references.size,
423
+ }
516
424
  }
517
425
 
518
426
  /**
519
- * @param {ParsedMediaItem[]} mediaItems
520
- * @param {string} publicUrl
427
+ * @param {MediaItem[]} mediaItems
428
+ * @param {FileStorage} storage
521
429
  */
522
- function collectR2ContentReferences(mediaItems, publicUrl) {
430
+ function collectContentReferences(mediaItems, storage) {
523
431
  const references = new Map()
524
- const normalizedBase = publicUrl.replace(/\/+$/, "")
525
432
  for (const item of mediaItems) {
526
433
  const sourceFileName = posix.basename(toPosixPath(item.path))
527
434
  for (const contentItem of item.content) {
528
- if (
529
- contentItem.type !== "upload" ||
530
- !contentItem.url.startsWith(normalizedBase)
531
- ) {
435
+ if (contentItem.type !== "upload") {
436
+ continue
437
+ }
438
+ const filePath = storage.toPath(contentItem.url)
439
+ if (!filePath) {
532
440
  continue
533
441
  }
534
- const relativePath = contentItem.url
535
- .slice(normalizedBase.length)
536
- .replace(/^\/+/, "")
537
- const current = references.get(contentItem.url)
442
+ const current = references.get(filePath)
538
443
  if (current) {
539
444
  current.sources.add(sourceFileName)
540
445
  } else {
541
- references.set(contentItem.url, {
542
- key: relativePath,
446
+ references.set(filePath, {
447
+ displayPath: contentItem.url,
543
448
  sources: new Set([sourceFileName]),
544
449
  })
545
450
  }
@@ -548,58 +453,19 @@ function collectR2ContentReferences(mediaItems, publicUrl) {
548
453
  return references
549
454
  }
550
455
 
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
456
  /**
595
457
  * @param {"media"|"categories"} kind
596
458
  * @param {string|undefined} image
597
459
  */
598
460
  function toThumbnailPath(kind, image) {
599
- if (typeof image !== "string" || !image.startsWith("./images/")) {
461
+ if (typeof image !== "string") {
600
462
  return undefined
601
463
  }
602
- const relativePath = image.slice("./images/".length)
464
+ const relativePath = image.startsWith("./images/")
465
+ ? image.slice("./images/".length)
466
+ : image.startsWith("images/")
467
+ ? image.slice("images/".length)
468
+ : undefined
603
469
  if (
604
470
  !relativePath ||
605
471
  relativePath.startsWith("/") ||
@@ -613,194 +479,25 @@ function toThumbnailPath(kind, image) {
613
479
  }
614
480
 
615
481
  /**
616
- * @param {{
617
- * deletions: {displayPath:string, kind:"local-file"|"r2-object", target:string}[]
618
- * logger: Logger
619
- * promptConfirm: (message:string)=>Promise<boolean>
620
- * }} args
482
+ * @param {string} path
483
+ * @param {CheckFilesOptions} options
621
484
  */
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] ")
485
+ function formatContentFilePath(path, options) {
486
+ return options.r2 ? path : toLocalContentDisplayPath(path)
629
487
  }
630
488
 
631
489
  /**
632
490
  * @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}>
491
+ * deletions: {displayPath:string, target:string}[]
492
+ * promptConfirm: (message:string)=>Promise<boolean>
640
493
  * }} args
641
494
  */
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 = []
495
+ async function confirmDeletion({ deletions, promptConfirm }) {
496
+ log.warn(`Files to remove (${deletions.length})`)
654
497
  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
498
+ log.message(`• ${deletion.displayPath}`)
803
499
  }
500
+ return promptConfirm("Delete these files? [y/N] ")
804
501
  }
805
502
 
806
503
  /**
@@ -812,22 +509,19 @@ function toPosixPath(filePath) {
812
509
 
813
510
  /**
814
511
  * @param {string} message
815
- * @param {{input: NodeJS.ReadableStream, output: NodeJS.WritableStream}} io
816
512
  */
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
- }
513
+ async function defaultPromptText(message) {
514
+ const answer = await text({ message })
515
+ return isCancel(answer) ? "" : String(answer)
824
516
  }
825
517
 
826
518
  /**
827
519
  * @param {string} message
828
- * @param {{input: NodeJS.ReadableStream, output: NodeJS.WritableStream}} io
829
520
  */
830
- async function defaultPromptConfirm(message, io) {
831
- const answer = (await defaultPromptText(message, io)).trim().toLowerCase()
832
- return answer === "y" || answer === "yes"
521
+ async function defaultPromptConfirm(message) {
522
+ const answer = await confirm({
523
+ message,
524
+ initialValue: false,
525
+ })
526
+ return isCancel(answer) ? false : answer
833
527
  }