@lightnet/cli 4.3.0 → 4.4.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/CHANGELOG.md CHANGED
@@ -1,5 +1,29 @@
1
1
  # @lightnet/cli
2
2
 
3
+ ## 4.4.1
4
+
5
+ ### Patch Changes
6
+
7
+ - [#431](https://github.com/LightNetDev/LightNet/pull/431) [`aa5968f`](https://github.com/LightNetDev/LightNet/commit/aa5968f26112f93121f03e7aafdfb04018186032) - Delete orphaned R2 objects from `check-files --fix --r2` in batches instead of spawning one rclone process per file.
8
+
9
+ - [#431](https://github.com/LightNetDev/LightNet/pull/431) [`aa5968f`](https://github.com/LightNetDev/LightNet/commit/aa5968f26112f93121f03e7aafdfb04018186032) - Show progress feedback while `check-files` and `check-links` perform long-running checks.
10
+
11
+ - [#431](https://github.com/LightNetDev/LightNet/pull/431) [`aa5968f`](https://github.com/LightNetDev/LightNet/commit/aa5968f26112f93121f03e7aafdfb04018186032) - Improve `check-files` performance by avoiding duplicate R2 listings and parallelizing independent filesystem work.
12
+
13
+ - [#431](https://github.com/LightNetDev/LightNet/pull/431) [`aa5968f`](https://github.com/LightNetDev/LightNet/commit/aa5968f26112f93121f03e7aafdfb04018186032) - Protect R2 objects referenced by media content links from `check-files --fix --r2` cleanup and warn that they should use upload content.
14
+
15
+ - [#431](https://github.com/LightNetDev/LightNet/pull/431) [`aa5968f`](https://github.com/LightNetDev/LightNet/commit/aa5968f26112f93121f03e7aafdfb04018186032) - Decode percent-encoded R2 public URLs when matching `check-files --r2` references against bucket objects.
16
+
17
+ ## 4.4.0
18
+
19
+ ### Minor Changes
20
+
21
+ - [#429](https://github.com/LightNetDev/LightNet/pull/429) [`00faa68`](https://github.com/LightNetDev/LightNet/commit/00faa6806bbdb74aef665d71b813449f2a15137f) - Add `check-translations --no-build` and `check-translations --build` support and improve CLI check commands.
22
+
23
+ ### Patch Changes
24
+
25
+ - [#429](https://github.com/LightNetDev/LightNet/pull/429) [`00faa68`](https://github.com/LightNetDev/LightNet/commit/00faa6806bbdb74aef665d71b813449f2a15137f) - Rename `check-files --yes` to `check-files --no-confirm` for clearer unattended deletion.
26
+
3
27
  ## 4.3.0
4
28
 
5
29
  ### Minor Changes
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "description": "CLI for managing LightNet projects",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
- "version": "4.3.0",
6
+ "version": "4.4.1",
7
7
  "publishConfig": {
8
8
  "access": "public"
9
9
  },
@@ -3,7 +3,15 @@
3
3
  import { join, posix, resolve } from "node:path"
4
4
  import { cwd as processCwd, stdin, stdout } from "node:process"
5
5
 
6
- import { confirm, intro, isCancel, log, outro, text } from "@clack/prompts"
6
+ import {
7
+ confirm,
8
+ intro,
9
+ isCancel,
10
+ log,
11
+ outro,
12
+ spinner,
13
+ text,
14
+ } from "@clack/prompts"
7
15
 
8
16
  import { CliError } from "./support/cli-error.js"
9
17
  import { contentCollections } from "./support/content-collections.js"
@@ -25,7 +33,7 @@ const categoryImagesDir = "src/content/categories/images"
25
33
  * @typedef {{
26
34
  * scope?: string
27
35
  * fix?: boolean
28
- * yes?: boolean
36
+ * confirm?: boolean
29
37
  * r2?: boolean
30
38
  * }} CheckFilesOptions
31
39
  */
@@ -48,6 +56,8 @@ const categoryImagesDir = "src/content/categories/images"
48
56
  * }} FileReference
49
57
  */
50
58
 
59
+ /** @typedef {MissingReference} WrongTypeReference */
60
+
51
61
  /**
52
62
  * @typedef {{
53
63
  * cwd?: string
@@ -79,14 +89,15 @@ export async function checkFiles(options, runtime = {}) {
79
89
  await assertLightNetSiteRoot(cwd)
80
90
 
81
91
  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
- )
92
+ const [mediaItems, categories] = await Promise.all([
93
+ collections.getMediaItems(),
94
+ collections.getCategories(),
95
+ ])
87
96
 
88
97
  /** @type {MissingReference[]} */
89
98
  let missingContentFiles = []
99
+ /** @type {WrongTypeReference[]} */
100
+ let wrongTypeR2ContentFiles = []
90
101
  /** @type {string[]} */
91
102
  let orphanedContentFiles = []
92
103
  /** @type {string[]} */
@@ -104,18 +115,40 @@ export async function checkFiles(options, runtime = {}) {
104
115
  const categoryThumbnailStorage = files(cwd, { rootDir: categoryImagesDir })
105
116
 
106
117
  if (includeContentFiles) {
118
+ contentFileStorage = options.r2
119
+ ? createR2FileStorage({
120
+ cwd,
121
+ interactive,
122
+ promptConfirm,
123
+ promptText,
124
+ })
125
+ : contentFiles(cwd)
126
+ contentFileStorage = await contentFileStorage.init()
127
+ }
128
+
129
+ log.message(
130
+ `Checking ${mediaItems.length} media items and ${categories.length} categories.`,
131
+ )
132
+
133
+ if (includeContentFiles) {
134
+ if (!contentFileStorage) {
135
+ throw new CliError("Missing file storage for content validation.")
136
+ }
137
+ const initializedContentFileStorage = contentFileStorage
107
138
  if (options.r2) {
108
- contentFileStorage = createR2FileStorage({
109
- cwd,
110
- interactive,
111
- promptConfirm,
112
- promptText,
113
- })
114
- const r2Result = await validateContentFiles({
115
- mediaItems,
116
- storage: contentFileStorage,
139
+ const r2Result = await runSpinner({
140
+ error: "Content file check failed.",
141
+ start: "Listing R2 objects and checking content file references",
142
+ stop: (result) => formatContentCheckSummary(result),
143
+ task: () =>
144
+ validateContentFiles({
145
+ includeLinkReferences: true,
146
+ mediaItems,
147
+ storage: initializedContentFileStorage,
148
+ }),
117
149
  })
118
150
  missingContentFiles = r2Result.missingReferences
151
+ wrongTypeR2ContentFiles = r2Result.wrongTypeReferences
119
152
  orphanedContentFiles = r2Result.orphanedFiles
120
153
  if (r2Result.referenceCount === 0) {
121
154
  warnings.push(
@@ -123,10 +156,15 @@ export async function checkFiles(options, runtime = {}) {
123
156
  )
124
157
  }
125
158
  } else {
126
- contentFileStorage = contentFiles(cwd)
127
- const localContentResult = await validateContentFiles({
128
- mediaItems,
129
- storage: contentFileStorage,
159
+ const localContentResult = await runSpinner({
160
+ error: "Content file check failed.",
161
+ start: "Checking local content files",
162
+ stop: (result) => formatContentCheckSummary(result),
163
+ task: () =>
164
+ validateContentFiles({
165
+ mediaItems,
166
+ storage: initializedContentFileStorage,
167
+ }),
130
168
  })
131
169
  missingContentFiles = localContentResult.missingReferences
132
170
  orphanedContentFiles = localContentResult.orphanedFiles
@@ -139,12 +177,19 @@ export async function checkFiles(options, runtime = {}) {
139
177
  }
140
178
 
141
179
  if (includeThumbnails) {
142
- const thumbnailsResult = await validateThumbnails(
143
- mediaItems,
144
- categories,
145
- mediaThumbnailStorage,
146
- categoryThumbnailStorage,
147
- )
180
+ const thumbnailsResult = await runSpinner({
181
+ error: "Thumbnail check failed.",
182
+ start: "Checking thumbnails",
183
+ stop: (result) =>
184
+ `Checked thumbnails: ${result.orphanedMediaThumbnails.length} orphaned media, ${result.orphanedCategoryThumbnails.length} orphaned categories.`,
185
+ task: () =>
186
+ validateThumbnails(
187
+ mediaItems,
188
+ categories,
189
+ mediaThumbnailStorage,
190
+ categoryThumbnailStorage,
191
+ ),
192
+ })
148
193
  orphanedMediaThumbnails = thumbnailsResult.orphanedMediaThumbnails
149
194
  orphanedCategoryThumbnails = thumbnailsResult.orphanedCategoryThumbnails
150
195
  }
@@ -185,38 +230,66 @@ export async function checkFiles(options, runtime = {}) {
185
230
  }
186
231
 
187
232
  if (deletions.length > 0) {
188
- if (!options.yes && !interactive) {
233
+ const shouldSkipConfirmation = options.confirm === false
234
+ if (!shouldSkipConfirmation && !interactive) {
189
235
  throw new CliError(
190
- 'Deletion requires confirmation. Re-run with "--fix --yes" in non-interactive environments.',
236
+ 'Deletion requires confirmation. Re-run with "--fix --no-confirm" in non-interactive environments.',
191
237
  )
192
238
  }
193
239
  const shouldDelete =
194
- options.yes ||
240
+ shouldSkipConfirmation ||
195
241
  (await confirmDeletion({
196
242
  deletions,
197
243
  promptConfirm,
198
244
  }))
199
245
 
200
246
  if (shouldDelete) {
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
- ]
247
+ const { deletedContentTargets, deletedLocalTargets } = await runSpinner(
248
+ {
249
+ error: "File deletion failed.",
250
+ start: formatDeletionProgress("Deleting", deletions.length),
251
+ stop: (result) => {
252
+ const deletedCount =
253
+ result.deletedContentTargets.length +
254
+ result.deletedLocalTargets.length
255
+ return formatDeletionProgress("Deleted", deletedCount)
256
+ },
257
+ task: async () => {
258
+ const [
259
+ deletedContentTargets,
260
+ deletedMediaThumbnailTargets,
261
+ deletedCategoryThumbnailTargets,
262
+ ] = await Promise.all([
263
+ contentFileStorage
264
+ ? contentFileStorage.delete(
265
+ contentDeletions.map((deletion) => deletion.target),
266
+ )
267
+ : [],
268
+ mediaThumbnailStorage.delete(
269
+ localDeletions
270
+ .filter((deletion) =>
271
+ deletion.target.startsWith(mediaImagesDir),
272
+ )
273
+ .map((deletion) => deletion.target),
274
+ ),
275
+ categoryThumbnailStorage.delete(
276
+ localDeletions
277
+ .filter((deletion) =>
278
+ deletion.target.startsWith(categoryImagesDir),
279
+ )
280
+ .map((deletion) => deletion.target),
281
+ ),
282
+ ])
283
+ return {
284
+ deletedContentTargets,
285
+ deletedLocalTargets: [
286
+ ...deletedMediaThumbnailTargets,
287
+ ...deletedCategoryThumbnailTargets,
288
+ ],
289
+ }
290
+ },
291
+ },
292
+ )
220
293
  const deletedTargets = new Set([
221
294
  ...deletedContentTargets,
222
295
  ...deletedLocalTargets,
@@ -244,6 +317,7 @@ export async function checkFiles(options, runtime = {}) {
244
317
  const hasIssues =
245
318
  warnings.length > 0 ||
246
319
  missingContentFiles.length > 0 ||
320
+ wrongTypeR2ContentFiles.length > 0 ||
247
321
  orphanedContentFiles.length > 0 ||
248
322
  orphanedMediaThumbnails.length > 0 ||
249
323
  orphanedCategoryThumbnails.length > 0
@@ -261,6 +335,10 @@ export async function checkFiles(options, runtime = {}) {
261
335
  "Missing referenced content files",
262
336
  missingContentFiles,
263
337
  )
338
+ printWrongTypeReferenceSection(
339
+ "R2 objects referenced as links",
340
+ wrongTypeR2ContentFiles,
341
+ )
264
342
  printSection(
265
343
  options.r2 ? "Orphaned R2 objects" : "Orphaned local content files",
266
344
  orphanedContentFiles.map((filePath) =>
@@ -306,6 +384,64 @@ function printMissingReferenceSection(title, items) {
306
384
  }
307
385
  }
308
386
 
387
+ /**
388
+ * @param {string} title
389
+ * @param {WrongTypeReference[]} items
390
+ */
391
+ function printWrongTypeReferenceSection(title, items) {
392
+ if (items.length === 0) {
393
+ return
394
+ }
395
+ log.warn(`${title} (${items.length})`)
396
+ for (const item of items) {
397
+ log.message(
398
+ `• ${item.path} should use type "upload" (referenced by ${item.sources.toSorted().join(", ")})`,
399
+ )
400
+ }
401
+ }
402
+
403
+ /**
404
+ * @template T
405
+ * @param {{
406
+ * error: string
407
+ * start: string
408
+ * stop: (result: T) => string
409
+ * task: () => Promise<T>
410
+ * }} args
411
+ * @returns {Promise<T>}
412
+ */
413
+ async function runSpinner({ error, start, stop, task }) {
414
+ const activeSpinner = spinner()
415
+ activeSpinner.start(start)
416
+ try {
417
+ const result = await task()
418
+ activeSpinner.stop(stop(result))
419
+ return result
420
+ } catch (caughtError) {
421
+ activeSpinner.error(error)
422
+ throw caughtError
423
+ }
424
+ }
425
+
426
+ /**
427
+ * @param {{
428
+ * missingReferences: MissingReference[]
429
+ * orphanedFiles: string[]
430
+ * referenceCount: number
431
+ * }} result
432
+ */
433
+ function formatContentCheckSummary(result) {
434
+ return `Checked content files: ${result.referenceCount} references, ${result.missingReferences.length} missing, ${result.orphanedFiles.length} orphaned.`
435
+ }
436
+
437
+ /**
438
+ * @param {string} action
439
+ * @param {number} count
440
+ */
441
+ function formatDeletionProgress(action, count) {
442
+ return `${action} ${count} orphaned file${count === 1 ? "" : "s"}.`
443
+ }
444
+
309
445
  /**
310
446
  * @param {string|undefined} rawScope
311
447
  */
@@ -368,10 +504,15 @@ async function validateThumbnails(
368
504
  .filter((value) => value !== undefined),
369
505
  )
370
506
 
371
- const initializedMediaStorage = await mediaThumbnailStorage.init()
372
- const initializedCategoryStorage = await categoryThumbnailStorage.init()
373
- const mediaFiles = await initializedMediaStorage.list()
374
- const categoryFiles = await initializedCategoryStorage.list()
507
+ const [initializedMediaStorage, initializedCategoryStorage] =
508
+ await Promise.all([
509
+ mediaThumbnailStorage.init(),
510
+ categoryThumbnailStorage.init(),
511
+ ])
512
+ const [mediaFiles, categoryFiles] = await Promise.all([
513
+ initializedMediaStorage.list(),
514
+ initializedCategoryStorage.list(),
515
+ ])
375
516
 
376
517
  return {
377
518
  orphanedMediaThumbnails: mediaFiles.filter(
@@ -385,18 +526,28 @@ async function validateThumbnails(
385
526
 
386
527
  /**
387
528
  * @param {{
529
+ * includeLinkReferences?: boolean
388
530
  * mediaItems: MediaItem[]
389
531
  * storage: FileStorage
390
532
  * }} args
391
533
  */
392
- async function validateContentFiles({ mediaItems, storage }) {
534
+ async function validateContentFiles({
535
+ includeLinkReferences = false,
536
+ mediaItems,
537
+ storage,
538
+ }) {
393
539
  const initializedStorage = await storage.init()
394
- const references = collectContentReferences(mediaItems, initializedStorage)
540
+ const { references, wrongTypeReferences } = collectContentReferences(
541
+ mediaItems,
542
+ initializedStorage,
543
+ { includeLinkReferences },
544
+ )
395
545
  if (references.size === 0) {
396
546
  return {
397
547
  missingReferences: /** @type {MissingReference[]} */ ([]),
398
548
  orphanedFiles: /** @type {string[]} */ ([]),
399
549
  referenceCount: 0,
550
+ wrongTypeReferences: /** @type {WrongTypeReference[]} */ ([]),
400
551
  }
401
552
  }
402
553
 
@@ -415,41 +566,74 @@ async function validateContentFiles({ mediaItems, storage }) {
415
566
  .filter((filePath) => !references.has(filePath))
416
567
  .sort()
417
568
 
569
+ const existingWrongTypeReferences = [...wrongTypeReferences.entries()]
570
+ .filter(([path]) => fileSet.has(path))
571
+ .map(([, reference]) => ({
572
+ path: reference.displayPath,
573
+ sources: [...reference.sources].toSorted(),
574
+ }))
575
+ .sort((a, b) => a.path.localeCompare(b.path))
576
+
418
577
  return {
419
578
  missingReferences,
420
579
  orphanedFiles,
421
580
  referenceCount: references.size,
581
+ wrongTypeReferences: existingWrongTypeReferences,
422
582
  }
423
583
  }
424
584
 
425
585
  /**
426
586
  * @param {MediaItem[]} mediaItems
427
587
  * @param {FileStorage} storage
588
+ * @param {{includeLinkReferences:boolean}} options
428
589
  */
429
- function collectContentReferences(mediaItems, storage) {
590
+ function collectContentReferences(mediaItems, storage, options) {
430
591
  const references = new Map()
592
+ const wrongTypeReferences = new Map()
431
593
  for (const item of mediaItems) {
432
594
  const sourceFileName = posix.basename(toPosixPath(item.path))
433
595
  for (const contentItem of item.content) {
434
- if (contentItem.type !== "upload") {
596
+ const isLinkReference = contentItem.type === "link"
597
+ if (
598
+ contentItem.type !== "upload" &&
599
+ !(options.includeLinkReferences && isLinkReference)
600
+ ) {
435
601
  continue
436
602
  }
437
603
  const filePath = storage.toPath(contentItem.url)
438
604
  if (!filePath) {
439
605
  continue
440
606
  }
441
- const current = references.get(filePath)
442
- if (current) {
443
- current.sources.add(sourceFileName)
444
- } else {
445
- references.set(filePath, {
446
- displayPath: contentItem.url,
447
- sources: new Set([sourceFileName]),
448
- })
607
+ addFileReference(references, filePath, contentItem.url, sourceFileName)
608
+ if (isLinkReference) {
609
+ addFileReference(
610
+ wrongTypeReferences,
611
+ filePath,
612
+ contentItem.url,
613
+ sourceFileName,
614
+ )
449
615
  }
450
616
  }
451
617
  }
452
- return references
618
+ return { references, wrongTypeReferences }
619
+ }
620
+
621
+ /**
622
+ * @param {Map<string, FileReference>} references
623
+ * @param {string} filePath
624
+ * @param {string} displayPath
625
+ * @param {string} sourceFileName
626
+ */
627
+ function addFileReference(references, filePath, displayPath, sourceFileName) {
628
+ const current = references.get(filePath)
629
+ if (current) {
630
+ current.sources.add(sourceFileName)
631
+ return
632
+ }
633
+ references.set(filePath, {
634
+ displayPath,
635
+ sources: new Set([sourceFileName]),
636
+ })
453
637
  }
454
638
 
455
639
  /**
@@ -3,7 +3,7 @@
3
3
  import { posix, resolve } from "node:path"
4
4
  import { cwd as processCwd, stdin, stdout } from "node:process"
5
5
 
6
- import { intro, isCancel, log, outro, text } from "@clack/prompts"
6
+ import { intro, isCancel, log, outro, progress, text } from "@clack/prompts"
7
7
 
8
8
  import {
9
9
  cliConfigFileName,
@@ -96,12 +96,10 @@ export async function checkLinks(options, runtime = {}) {
96
96
  return true
97
97
  }
98
98
 
99
- const results = await mapConcurrent(
100
- references,
101
- defaultConcurrency,
102
- (reference) =>
103
- checkLink(reference.resolvedUrl, { fetch: runFetch, timeoutMs }),
104
- )
99
+ const results = await checkReferencesWithProgress(references, {
100
+ fetch: runFetch,
101
+ timeoutMs,
102
+ })
105
103
  const checkedLinks = references
106
104
  .map((reference, index) => ({ reference, result: results[index] }))
107
105
  .sort((a, b) =>
@@ -141,6 +139,46 @@ export async function checkLinks(options, runtime = {}) {
141
139
  return false
142
140
  }
143
141
 
142
+ /**
143
+ * @param {LinkReference[]} references
144
+ * @param {{fetch: typeof fetch, timeoutMs: number}} options
145
+ */
146
+ async function checkReferencesWithProgress(references, options) {
147
+ const linkProgress = progress({ max: references.length })
148
+ let completed = 0
149
+ linkProgress.start(`Checking links (0/${references.length})`)
150
+
151
+ try {
152
+ const results = await mapConcurrent(
153
+ references,
154
+ defaultConcurrency,
155
+ async (reference) => {
156
+ const result = await checkLink(reference.resolvedUrl, options)
157
+ completed += 1
158
+ linkProgress.advance(
159
+ 1,
160
+ `Checking links (${completed}/${references.length})`,
161
+ )
162
+ return result
163
+ },
164
+ )
165
+ const protectedCount = results.filter((result) =>
166
+ isProtectedLink(result),
167
+ ).length
168
+ const unavailableCount = results.filter(
169
+ (result) => !result.ok && !isProtectedLink(result),
170
+ ).length
171
+ const availableCount = results.length - protectedCount - unavailableCount
172
+ linkProgress.stop(
173
+ `Checked links: ${availableCount} available, ${protectedCount} protected, ${unavailableCount} unavailable.`,
174
+ )
175
+ return results
176
+ } catch (error) {
177
+ linkProgress.error("Link check failed.")
178
+ throw error
179
+ }
180
+ }
181
+
144
182
  /**
145
183
  * @param {{
146
184
  * cwd: string
@@ -22,6 +22,12 @@ import { confirm, intro, log, outro, taskLog } from "@clack/prompts"
22
22
  * }} Languages
23
23
  */
24
24
 
25
+ /**
26
+ * @typedef {{
27
+ * build?: boolean
28
+ * }} CheckTranslationsOptions
29
+ */
30
+
25
31
  const lightnetCachePath = resolve(cwd(), "node_modules", ".cache", "lightnet")
26
32
 
27
33
  /** @type {{type:Translation["type"], title:string, action:string}[]} */
@@ -44,14 +50,18 @@ const translationSources = [
44
50
  },
45
51
  ]
46
52
 
47
- export async function checkTranslations() {
53
+ /**
54
+ * @param {CheckTranslationsOptions} [options]
55
+ */
56
+ export async function checkTranslations(options = {}) {
48
57
  intro("check-translations")
49
58
 
50
- const buildAvailable = await runBuild()
59
+ const buildAvailable = await runBuild(options.build)
51
60
  if (!buildAvailable) {
52
61
  outro("Build failed. 🚧")
53
62
  return false
54
63
  }
64
+
55
65
  const translations = await readTranslations()
56
66
  const languages = await readLanguages()
57
67
  if (!translations || !languages || translations.length === 0) {
@@ -84,12 +94,19 @@ export async function checkTranslations() {
84
94
  return false
85
95
  }
86
96
 
87
- async function runBuild() {
88
- const shouldRunBuild = await confirm({
89
- message:
90
- "Run pnpm build now? Command requires an up-to-date dist/ directory.",
91
- initialValue: false,
92
- })
97
+ /**
98
+ *
99
+ * @param {boolean|undefined} build
100
+ * @returns
101
+ */
102
+ async function runBuild(build) {
103
+ const shouldRunBuild =
104
+ build ??
105
+ (await confirm({
106
+ message:
107
+ "Run pnpm build now? Command requires an up-to-date dist/ directory.",
108
+ initialValue: false,
109
+ }))
93
110
  if (!shouldRunBuild) {
94
111
  return true
95
112
  }
package/src/index.js CHANGED
@@ -21,8 +21,10 @@ program
21
21
  program
22
22
  .command("check-translations")
23
23
  .description("check if last build has been missing any translations")
24
- .action(async () => {
25
- const checkSuccessful = await checkTranslations()
24
+ .option("--build", "run pnpm build before checking translations")
25
+ .option("--no-build", "skip the build prompt and use the latest dist/ output")
26
+ .action(async (options) => {
27
+ const checkSuccessful = await checkTranslations(options)
26
28
  commandExitCode = checkSuccessful ? 0 : 1
27
29
  })
28
30
 
@@ -32,7 +34,7 @@ program
32
34
  "check for missing and orphaned content files and thumbnails in a LightNet site",
33
35
  )
34
36
  .option("--fix", "remove orphaned files")
35
- .option("--yes", "skip deletion confirmation when used with --fix")
37
+ .option("--no-confirm", "skip deletion confirmation when used with --fix")
36
38
  .option(
37
39
  "--r2",
38
40
  "validate remote content files in Cloudflare R2 instead of public/files",
@@ -6,9 +6,7 @@ import { resolve } from "node:path"
6
6
  import { CliError } from "./cli-error.js"
7
7
  import { pathExists } from "./filesystem.js"
8
8
 
9
- export const cliConfigFileName = ".lightnet-cli.config.json"
10
-
11
- const gitIgnoreEntry = `${cliConfigFileName}\n`
9
+ export const cliConfigFileName = "lightnet-cli.json"
12
10
 
13
11
  /**
14
12
  * @param {string} cwd
@@ -36,22 +34,4 @@ export async function writeCliConfig(cwd, config) {
36
34
  `${JSON.stringify(config, null, 2)}\n`,
37
35
  "utf8",
38
36
  )
39
- await ensureGitignoreContainsConfig(cwd)
40
- }
41
-
42
- /**
43
- * @param {string} cwd
44
- */
45
- async function ensureGitignoreContainsConfig(cwd) {
46
- const filePath = resolve(cwd, ".gitignore")
47
- const contents = (await pathExists(filePath))
48
- ? await readFile(filePath, "utf8")
49
- : ""
50
- if (contents.includes(cliConfigFileName)) {
51
- return
52
- }
53
- const next = contents
54
- ? `${contents}${contents.endsWith("\n") ? "" : "\n"}${gitIgnoreEntry}`
55
- : gitIgnoreEntry
56
- await writeFile(filePath, next, "utf8")
57
37
  }
@@ -58,23 +58,24 @@ export function files(cwd, options) {
58
58
  * @param {string[]} paths
59
59
  */
60
60
  async delete(paths) {
61
- /** @type {string[]} */
62
- const removed = []
63
- for (const path of paths) {
64
- try {
65
- await unlink(
66
- resolve(
67
- cwd,
68
- options.toAbsolutePath?.(path) ??
69
- toLocalFilePath(options.rootDir, path),
70
- ),
71
- )
72
- removed.push(path)
73
- } catch {
74
- // The caller prints per-file failures based on the returned paths.
75
- }
76
- }
77
- return removed
61
+ const removals = await Promise.all(
62
+ paths.map(async (path) => {
63
+ try {
64
+ await unlink(
65
+ resolve(
66
+ cwd,
67
+ options.toAbsolutePath?.(path) ??
68
+ toLocalFilePath(options.rootDir, path),
69
+ ),
70
+ )
71
+ return path
72
+ } catch {
73
+ // The caller prints per-file failures based on the returned paths.
74
+ return undefined
75
+ }
76
+ }),
77
+ )
78
+ return removals.filter((path) => path !== undefined)
78
79
  },
79
80
  /**
80
81
  * @param {string} url
@@ -161,13 +162,17 @@ async function walkFiles(dirPath) {
161
162
  /** @type {string[]} */
162
163
  const files = []
163
164
  const entries = await readdir(dirPath, { withFileTypes: true })
164
- for (const entry of entries) {
165
- const entryPath = join(dirPath, entry.name)
166
- if (entry.isDirectory()) {
167
- files.push(...(await walkFiles(entryPath)))
168
- } else if (entry.isFile()) {
169
- files.push(entryPath)
170
- }
165
+ const nestedFiles = await Promise.all(
166
+ entries.map(async (entry) => {
167
+ const entryPath = join(dirPath, entry.name)
168
+ if (entry.isDirectory()) {
169
+ return walkFiles(entryPath)
170
+ }
171
+ return entry.isFile() ? [entryPath] : []
172
+ }),
173
+ )
174
+ for (const item of nestedFiles) {
175
+ files.push(...item)
171
176
  }
172
177
  return files
173
178
  }
package/src/support/r2.js CHANGED
@@ -1,6 +1,9 @@
1
1
  // @ts-check
2
2
 
3
3
  import { execFile } from "node:child_process"
4
+ import { mkdtemp, rm, writeFile } from "node:fs/promises"
5
+ import { tmpdir } from "node:os"
6
+ import { join } from "node:path"
4
7
  import { env as processEnv } from "node:process"
5
8
  import { promisify } from "node:util"
6
9
 
@@ -15,6 +18,7 @@ import { CliError } from "./cli-error.js"
15
18
 
16
19
  const execFileAsync = promisify(execFile)
17
20
  const sessionSecretEnvName = "LIGHTNET_R2_SECRET_ACCESS_KEY"
21
+ const r2DeleteBatchSize = 1000
18
22
 
19
23
  /**
20
24
  * @typedef {{
@@ -117,18 +121,16 @@ export function createR2FileStorage({
117
121
  const storage = {
118
122
  async init() {
119
123
  await getConfig()
124
+ await getSecretAccessKey()
120
125
  return storage
121
126
  },
122
127
  async list() {
123
128
  return runWithRefresh(async (config) => {
124
- const secretAccessKey = await getSecretAccessKey()
125
- const sharedArgs = {
129
+ const bucketObjects = await listR2Objects({
126
130
  cwd,
127
131
  config,
128
- secretAccessKey,
129
- }
130
- await assertR2Access(sharedArgs)
131
- const bucketObjects = await listR2Objects(sharedArgs)
132
+ secretAccessKey: await getSecretAccessKey(),
133
+ })
132
134
  return bucketObjects.map((item) => item.key).sort()
133
135
  })
134
136
  },
@@ -138,22 +140,12 @@ export function createR2FileStorage({
138
140
  async delete(paths) {
139
141
  const r2Config = await getConfig()
140
142
  const secretAccessKey = await getSecretAccessKey()
141
- /** @type {string[]} */
142
- const removed = []
143
- for (const path of paths) {
144
- try {
145
- await runConfiguredRclone({
146
- args: ["deletefile", makeR2Path(r2Config, path)],
147
- cwd,
148
- config: r2Config,
149
- secretAccessKey,
150
- })
151
- removed.push(path)
152
- } catch {
153
- // The caller prints per-file failures based on the returned paths.
154
- }
155
- }
156
- return removed
143
+ return deleteR2Objects({
144
+ config: r2Config,
145
+ cwd,
146
+ paths,
147
+ secretAccessKey,
148
+ })
157
149
  },
158
150
  /**
159
151
  * @param {string} url
@@ -167,32 +159,134 @@ export function createR2FileStorage({
167
159
  return undefined
168
160
  }
169
161
  const path = url.slice(normalizedBase.length).replace(/^\/+/, "")
170
- return path || undefined
162
+ return decodeR2ObjectPath(path) || undefined
171
163
  },
172
164
  }
173
165
  return storage
174
166
  }
175
167
 
168
+ /**
169
+ * @param {string} path
170
+ */
171
+ function decodeR2ObjectPath(path) {
172
+ try {
173
+ return decodeURIComponent(path)
174
+ } catch {
175
+ return path
176
+ }
177
+ }
178
+
176
179
  /**
177
180
  * @param {{
181
+ * config: R2Config
178
182
  * cwd: string
183
+ * paths: string[]
184
+ * secretAccessKey: string
185
+ * }} args
186
+ */
187
+ async function deleteR2Objects({ config, cwd, paths, secretAccessKey }) {
188
+ /** @type {string[]} */
189
+ const removed = []
190
+ const batchablePaths = paths.filter((path) => !path.includes("\n"))
191
+ const singleDeletePaths = paths.filter((path) => path.includes("\n"))
192
+
193
+ for (
194
+ let index = 0;
195
+ index < batchablePaths.length;
196
+ index += r2DeleteBatchSize
197
+ ) {
198
+ const batch = batchablePaths.slice(index, index + r2DeleteBatchSize)
199
+ try {
200
+ await deleteR2ObjectBatch({ config, cwd, paths: batch, secretAccessKey })
201
+ removed.push(...batch)
202
+ } catch {
203
+ removed.push(
204
+ ...(await deleteR2ObjectsIndividually({
205
+ config,
206
+ cwd,
207
+ paths: batch,
208
+ secretAccessKey,
209
+ })),
210
+ )
211
+ }
212
+ }
213
+
214
+ removed.push(
215
+ ...(await deleteR2ObjectsIndividually({
216
+ config,
217
+ cwd,
218
+ paths: singleDeletePaths,
219
+ secretAccessKey,
220
+ })),
221
+ )
222
+
223
+ return removed
224
+ }
225
+
226
+ /**
227
+ * @param {{
179
228
  * config: R2Config
229
+ * cwd: string
230
+ * paths: string[]
180
231
  * secretAccessKey: string
181
232
  * }} args
182
233
  */
183
- async function assertR2Access(args) {
234
+ async function deleteR2ObjectBatch({ config, cwd, paths, secretAccessKey }) {
235
+ if (paths.length === 0) {
236
+ return
237
+ }
238
+
239
+ const tempDir = await mkdtemp(join(tmpdir(), "lightnet-r2-delete-"))
240
+ const fileListPath = join(tempDir, "files.txt")
184
241
  try {
242
+ await writeFile(fileListPath, `${paths.join("\n")}\n`, "utf8")
185
243
  await runConfiguredRclone({
186
- args: ["lsjson", makeR2Path(args.config), "--files-only"],
187
- ...args,
244
+ args: [
245
+ "delete",
246
+ makeR2Path(config),
247
+ "--files-from-raw",
248
+ fileListPath,
249
+ "--fast-list",
250
+ ],
251
+ cwd,
252
+ config,
253
+ secretAccessKey,
188
254
  })
189
- } catch (error) {
190
- throw new CliError(
191
- error instanceof Error
192
- ? `Unable to access R2 bucket "${args.config.bucket}": ${error.message}`
193
- : `Unable to access R2 bucket "${args.config.bucket}".`,
194
- )
255
+ } finally {
256
+ await rm(tempDir, { force: true, recursive: true })
257
+ }
258
+ }
259
+
260
+ /**
261
+ * @param {{
262
+ * config: R2Config
263
+ * cwd: string
264
+ * paths: string[]
265
+ * secretAccessKey: string
266
+ * }} args
267
+ */
268
+ async function deleteR2ObjectsIndividually({
269
+ config,
270
+ cwd,
271
+ paths,
272
+ secretAccessKey,
273
+ }) {
274
+ /** @type {string[]} */
275
+ const removed = []
276
+ for (const path of paths) {
277
+ try {
278
+ await runConfiguredRclone({
279
+ args: ["deletefile", makeR2Path(config, path)],
280
+ cwd,
281
+ config,
282
+ secretAccessKey,
283
+ })
284
+ removed.push(path)
285
+ } catch {
286
+ // The caller prints per-file failures based on the returned paths.
287
+ }
195
288
  }
289
+ return removed
196
290
  }
197
291
 
198
292
  /**
@@ -203,18 +297,28 @@ async function assertR2Access(args) {
203
297
  * }} args
204
298
  */
205
299
  async function listR2Objects(args) {
206
- const { stdout: listJson } = await runConfiguredRclone({
207
- args: [
208
- "lsjson",
209
- makeR2Path(args.config),
210
- "--recursive",
211
- "--files-only",
212
- "--fast-list",
213
- "--no-mimetype",
214
- "--no-modtime",
215
- ],
216
- ...args,
217
- })
300
+ let listJson
301
+ try {
302
+ const result = await runConfiguredRclone({
303
+ args: [
304
+ "lsjson",
305
+ makeR2Path(args.config),
306
+ "--recursive",
307
+ "--files-only",
308
+ "--fast-list",
309
+ "--no-mimetype",
310
+ "--no-modtime",
311
+ ],
312
+ ...args,
313
+ })
314
+ listJson = result.stdout
315
+ } catch (error) {
316
+ throw new CliError(
317
+ error instanceof Error
318
+ ? `Unable to access R2 bucket "${args.config.bucket}": ${error.message}`
319
+ : `Unable to access R2 bucket "${args.config.bucket}".`,
320
+ )
321
+ }
218
322
 
219
323
  let parsed
220
324
  try {