@lightnet/cli 4.4.1 → 4.4.2

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,19 @@
1
1
  # @lightnet/cli
2
2
 
3
+ ## 4.4.2
4
+
5
+ ### Patch Changes
6
+
7
+ - [#433](https://github.com/LightNetDev/LightNet/pull/433) [`dbde36e`](https://github.com/LightNetDev/LightNet/commit/dbde36e7a25bf6470783e9594260b666e09c2783) - Make `check-files` summaries clearer by explaining what was compared and what needs attention.
8
+
9
+ - [#433](https://github.com/LightNetDev/LightNet/pull/433) [`dbde36e`](https://github.com/LightNetDev/LightNet/commit/dbde36e7a25bf6470783e9594260b666e09c2783) - Handle Ctrl+C cancellation consistently for interactive `check-*` command prompts.
10
+
11
+ - [#433](https://github.com/LightNetDev/LightNet/pull/433) [`dbde36e`](https://github.com/LightNetDev/LightNet/commit/dbde36e7a25bf6470783e9594260b666e09c2783) - Replace `check-files --fix --no-confirm` with `check-files --fix-without-confirm` for non-interactive cleanup.
12
+
13
+ - [#433](https://github.com/LightNetDev/LightNet/pull/433) [`dbde36e`](https://github.com/LightNetDev/LightNet/commit/dbde36e7a25bf6470783e9594260b666e09c2783) - Treat warning-only `check-files` runs as successful when no local content file references are found.
14
+
15
+ - [#433](https://github.com/LightNetDev/LightNet/pull/433) [`dbde36e`](https://github.com/LightNetDev/LightNet/commit/dbde36e7a25bf6470783e9594260b666e09c2783) - Improve `check-files` and `check-links` output for long referenced paths and URLs.
16
+
3
17
  ## 4.4.1
4
18
 
5
19
  ### Patch 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.4.1",
6
+ "version": "4.4.2",
7
7
  "publishConfig": {
8
8
  "access": "public"
9
9
  },
@@ -25,8 +25,8 @@
25
25
  "CHANGELOG.md"
26
26
  ],
27
27
  "dependencies": {
28
- "commander": "^15.0.0",
29
- "@clack/prompts": "^1.6.0"
28
+ "@clack/prompts": "^1.7.0",
29
+ "commander": "^15.0.0"
30
30
  },
31
31
  "engines": {
32
32
  "node": ">=22"
@@ -21,6 +21,7 @@ import {
21
21
  pathExists,
22
22
  toLocalContentDisplayPath,
23
23
  } from "./support/filesystem.js"
24
+ import { cancelPrompt } from "./support/prompt-cancel.js"
24
25
  import { createR2FileStorage } from "./support/r2.js"
25
26
 
26
27
  const supportedScopes = new Set(["content-files", "thumbnails"])
@@ -33,7 +34,7 @@ const categoryImagesDir = "src/content/categories/images"
33
34
  * @typedef {{
34
35
  * scope?: string
35
36
  * fix?: boolean
36
- * confirm?: boolean
37
+ * fixWithoutConfirm?: boolean
37
38
  * r2?: boolean
38
39
  * }} CheckFilesOptions
39
40
  */
@@ -170,7 +171,7 @@ export async function checkFiles(options, runtime = {}) {
170
171
  orphanedContentFiles = localContentResult.orphanedFiles
171
172
  if (localContentResult.referenceCount === 0) {
172
173
  warnings.push(
173
- 'No local "/files" content file references found. Use "--scope=thumbnails" or run with "--r2".',
174
+ 'No local "/files" content file references found. If your content files are stored in R2, consider re-running with "--r2".',
174
175
  )
175
176
  }
176
177
  }
@@ -180,8 +181,7 @@ export async function checkFiles(options, runtime = {}) {
180
181
  const thumbnailsResult = await runSpinner({
181
182
  error: "Thumbnail check failed.",
182
183
  start: "Checking thumbnails",
183
- stop: (result) =>
184
- `Checked thumbnails: ${result.orphanedMediaThumbnails.length} orphaned media, ${result.orphanedCategoryThumbnails.length} orphaned categories.`,
184
+ stop: (result) => formatThumbnailCheckSummary(result),
185
185
  task: () =>
186
186
  validateThumbnails(
187
187
  mediaItems,
@@ -194,7 +194,7 @@ export async function checkFiles(options, runtime = {}) {
194
194
  orphanedCategoryThumbnails = thumbnailsResult.orphanedCategoryThumbnails
195
195
  }
196
196
 
197
- if (options.fix) {
197
+ if (options.fix || options.fixWithoutConfirm) {
198
198
  /** @type {{displayPath:string, target:string}[]} */
199
199
  const deletions = []
200
200
  /** @type {{displayPath:string, target:string}[]} */
@@ -230,10 +230,10 @@ export async function checkFiles(options, runtime = {}) {
230
230
  }
231
231
 
232
232
  if (deletions.length > 0) {
233
- const shouldSkipConfirmation = options.confirm === false
233
+ const shouldSkipConfirmation = options.fixWithoutConfirm === true
234
234
  if (!shouldSkipConfirmation && !interactive) {
235
235
  throw new CliError(
236
- 'Deletion requires confirmation. Re-run with "--fix --no-confirm" in non-interactive environments.',
236
+ 'Deletion requires confirmation. Re-run with "--fix-without-confirm" in non-interactive environments.',
237
237
  )
238
238
  }
239
239
  const shouldDelete =
@@ -315,22 +315,21 @@ export async function checkFiles(options, runtime = {}) {
315
315
  }
316
316
 
317
317
  const hasIssues =
318
- warnings.length > 0 ||
319
318
  missingContentFiles.length > 0 ||
320
319
  wrongTypeR2ContentFiles.length > 0 ||
321
320
  orphanedContentFiles.length > 0 ||
322
321
  orphanedMediaThumbnails.length > 0 ||
323
322
  orphanedCategoryThumbnails.length > 0
324
323
 
324
+ for (const warning of warnings) {
325
+ log.warn(warning)
326
+ }
327
+
325
328
  if (!hasIssues && removedItems.length === 0) {
326
329
  outro("No issues found. 🎉")
327
330
  return true
328
331
  }
329
332
 
330
- for (const warning of warnings) {
331
- log.warn(warning)
332
- }
333
-
334
333
  printMissingReferenceSection(
335
334
  "Missing referenced content files",
336
335
  missingContentFiles,
@@ -378,9 +377,7 @@ function printMissingReferenceSection(title, items) {
378
377
  }
379
378
  log.error(`${title} (${items.length})`)
380
379
  for (const item of items) {
381
- log.message(
382
- `• ${item.path} (referenced by ${item.sources.toSorted().join(", ")})`,
383
- )
380
+ log.message(`• ${item.path}\n${formatReferenceSources(item.sources)}`)
384
381
  }
385
382
  }
386
383
 
@@ -395,11 +392,25 @@ function printWrongTypeReferenceSection(title, items) {
395
392
  log.warn(`${title} (${items.length})`)
396
393
  for (const item of items) {
397
394
  log.message(
398
- `• ${item.path} should use type "upload" (referenced by ${item.sources.toSorted().join(", ")})`,
395
+ `• ${item.path}\n Expected type: "upload"\n${formatReferenceSources(item.sources)}`,
399
396
  )
400
397
  }
401
398
  }
402
399
 
400
+ /**
401
+ * @param {string[]} sources
402
+ */
403
+ function formatReferenceSources(sources) {
404
+ const sortedSources = sources.toSorted()
405
+ if (sortedSources.length === 1) {
406
+ return ` Referenced by: ${sortedSources[0]}`
407
+ }
408
+ return [
409
+ " Referenced by:",
410
+ ...sortedSources.map((source) => ` - ${source}`),
411
+ ].join("\n")
412
+ }
413
+
403
414
  /**
404
415
  * @template T
405
416
  * @param {{
@@ -425,13 +436,58 @@ async function runSpinner({ error, start, stop, task }) {
425
436
 
426
437
  /**
427
438
  * @param {{
439
+ * fileCount: number
428
440
  * missingReferences: MissingReference[]
429
441
  * orphanedFiles: string[]
430
442
  * referenceCount: number
431
443
  * }} result
432
444
  */
433
445
  function formatContentCheckSummary(result) {
434
- return `Checked content files: ${result.referenceCount} references, ${result.missingReferences.length} missing, ${result.orphanedFiles.length} orphaned.`
446
+ const issues = [
447
+ formatFindingCount(result.missingReferences.length, "missing reference"),
448
+ formatFindingCount(result.orphanedFiles.length, "orphaned file"),
449
+ ]
450
+
451
+ if (result.referenceCount === 0 && result.fileCount === 0) {
452
+ return `Content files: none found.`
453
+ }
454
+
455
+ return `Content files: ${formatCount(result.referenceCount, "reference")}, ${formatCount(result.fileCount, "file")}; ${issues.join(", ")}.`
456
+ }
457
+
458
+ /**
459
+ * @param {{
460
+ * fileCount: number
461
+ * orphanedCategoryThumbnails: string[]
462
+ * orphanedMediaThumbnails: string[]
463
+ * referenceCount: number
464
+ * }} result
465
+ */
466
+ function formatThumbnailCheckSummary(result) {
467
+ const orphanedThumbnails =
468
+ result.orphanedMediaThumbnails.length +
469
+ result.orphanedCategoryThumbnails.length
470
+
471
+ return `Thumbnails: ${formatCount(result.referenceCount, "reference")}, ${formatCount(result.fileCount, "file")}; orphaned: ${orphanedThumbnails} (${result.orphanedMediaThumbnails.length} media, ${result.orphanedCategoryThumbnails.length} categories).`
472
+ }
473
+
474
+ /**
475
+ * @param {number} count
476
+ * @param {string} singular
477
+ */
478
+ function formatFindingCount(count, singular) {
479
+ if (count === 0) {
480
+ return `no ${singular}s`
481
+ }
482
+ return formatCount(count, singular)
483
+ }
484
+
485
+ /**
486
+ * @param {number} count
487
+ * @param {string} singular
488
+ */
489
+ function formatCount(count, singular) {
490
+ return `${count} ${singular}${count === 1 ? "" : "s"}`
435
491
  }
436
492
 
437
493
  /**
@@ -515,12 +571,14 @@ async function validateThumbnails(
515
571
  ])
516
572
 
517
573
  return {
574
+ fileCount: mediaFiles.length + categoryFiles.length,
518
575
  orphanedMediaThumbnails: mediaFiles.filter(
519
576
  (file) => !mediaReferences.has(file),
520
577
  ),
521
578
  orphanedCategoryThumbnails: categoryFiles.filter(
522
579
  (file) => !categoryReferences.has(file),
523
580
  ),
581
+ referenceCount: mediaReferences.size + categoryReferences.size,
524
582
  }
525
583
  }
526
584
 
@@ -542,8 +600,10 @@ async function validateContentFiles({
542
600
  initializedStorage,
543
601
  { includeLinkReferences },
544
602
  )
603
+ const files = await initializedStorage.list()
545
604
  if (references.size === 0) {
546
605
  return {
606
+ fileCount: files.length,
547
607
  missingReferences: /** @type {MissingReference[]} */ ([]),
548
608
  orphanedFiles: /** @type {string[]} */ ([]),
549
609
  referenceCount: 0,
@@ -551,7 +611,6 @@ async function validateContentFiles({
551
611
  }
552
612
  }
553
613
 
554
- const files = await initializedStorage.list()
555
614
  const fileSet = new Set(files)
556
615
 
557
616
  const missingReferences = [...references.entries()]
@@ -575,6 +634,7 @@ async function validateContentFiles({
575
634
  .sort((a, b) => a.path.localeCompare(b.path))
576
635
 
577
636
  return {
637
+ fileCount: files.length,
578
638
  missingReferences,
579
639
  orphanedFiles,
580
640
  referenceCount: references.size,
@@ -695,7 +755,10 @@ function toPosixPath(filePath) {
695
755
  */
696
756
  async function defaultPromptText(message) {
697
757
  const answer = await text({ message })
698
- return isCancel(answer) ? "" : String(answer)
758
+ if (isCancel(answer)) {
759
+ cancelPrompt()
760
+ }
761
+ return String(answer)
699
762
  }
700
763
 
701
764
  /**
@@ -706,5 +769,8 @@ async function defaultPromptConfirm(message) {
706
769
  message,
707
770
  initialValue: false,
708
771
  })
709
- return isCancel(answer) ? false : answer
772
+ if (isCancel(answer)) {
773
+ cancelPrompt()
774
+ }
775
+ return answer
710
776
  }
@@ -13,6 +13,7 @@ import {
13
13
  import { CliError } from "./support/cli-error.js"
14
14
  import { contentCollections } from "./support/content-collections.js"
15
15
  import { pathExists } from "./support/filesystem.js"
16
+ import { cancelPrompt } from "./support/prompt-cancel.js"
16
17
 
17
18
  const mediaDir = "src/content/media"
18
19
  const defaultConcurrency = 24
@@ -126,11 +127,7 @@ export async function checkLinks(options, runtime = {}) {
126
127
  log.error(`Unavailable links (${unavailableLinks.length})`)
127
128
  for (const { reference, result } of unavailableLinks) {
128
129
  log.message(
129
- ${reference.displayUrl} (${formatFailure(result)}, referenced by ${[
130
- ...reference.sources,
131
- ]
132
- .toSorted()
133
- .join(", ")})`,
130
+ `• ${reference.displayUrl}\n Result: ${formatFailure(result)}\n${formatReferenceSources(reference.sources)}`,
134
131
  )
135
132
  }
136
133
 
@@ -332,15 +329,25 @@ function printProtectedLinks(links) {
332
329
  log.warn(`Protected or forbidden links (${links.length})`)
333
330
  for (const { reference, result } of links) {
334
331
  log.message(
335
- ${reference.displayUrl} (${formatFailure(result)}, referenced by ${[
336
- ...reference.sources,
337
- ]
338
- .toSorted()
339
- .join(", ")})`,
332
+ `• ${reference.displayUrl}\n Result: ${formatFailure(result)}\n${formatReferenceSources(reference.sources)}`,
340
333
  )
341
334
  }
342
335
  }
343
336
 
337
+ /**
338
+ * @param {Set<string>} sources
339
+ */
340
+ function formatReferenceSources(sources) {
341
+ const sortedSources = [...sources].toSorted()
342
+ if (sortedSources.length === 1) {
343
+ return ` Referenced by: ${sortedSources[0]}`
344
+ }
345
+ return [
346
+ " Referenced by:",
347
+ ...sortedSources.map((source) => ` - ${source}`),
348
+ ].join("\n")
349
+ }
350
+
344
351
  /**
345
352
  * @param {string} url
346
353
  * @param {{
@@ -483,7 +490,10 @@ function toPosixPath(filePath) {
483
490
  */
484
491
  async function defaultPromptText(message) {
485
492
  const answer = await text({ message })
486
- return isCancel(answer) ? "" : String(answer)
493
+ if (isCancel(answer)) {
494
+ cancelPrompt()
495
+ }
496
+ return String(answer)
487
497
  }
488
498
 
489
499
  /**
@@ -5,7 +5,9 @@ import { readFile } from "node:fs/promises"
5
5
  import { resolve } from "node:path"
6
6
  import { cwd } from "node:process"
7
7
 
8
- import { confirm, intro, log, outro, taskLog } from "@clack/prompts"
8
+ import { confirm, intro, isCancel, log, outro, taskLog } from "@clack/prompts"
9
+
10
+ import { cancelPrompt } from "./support/prompt-cancel.js"
9
11
 
10
12
  /**
11
13
  * @typedef {{
@@ -100,13 +102,7 @@ export async function checkTranslations(options = {}) {
100
102
  * @returns
101
103
  */
102
104
  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
- }))
105
+ const shouldRunBuild = build ?? (await promptRunBuild())
110
106
  if (!shouldRunBuild) {
111
107
  return true
112
108
  }
@@ -158,6 +154,18 @@ async function runBuild(build) {
158
154
  }
159
155
  }
160
156
 
157
+ async function promptRunBuild() {
158
+ const answer = await confirm({
159
+ message:
160
+ "Run pnpm build now? Command requires an up-to-date dist/ directory.",
161
+ initialValue: false,
162
+ })
163
+ if (isCancel(answer)) {
164
+ cancelPrompt()
165
+ }
166
+ return answer
167
+ }
168
+
161
169
  /**
162
170
  *
163
171
  * @param {{title:string, action:string}} source
package/src/index.js CHANGED
@@ -8,6 +8,7 @@ import { checkFiles } from "./check-files.js"
8
8
  import { checkLinks } from "./check-links.js"
9
9
  import { checkTranslations } from "./check-translations.js"
10
10
  import { CliError } from "./support/cli-error.js"
11
+ import { PromptCancelled } from "./support/prompt-cancel.js"
11
12
  const { version } = pkg
12
13
 
13
14
  const program = new Command()
@@ -24,8 +25,12 @@ program
24
25
  .option("--build", "run pnpm build before checking translations")
25
26
  .option("--no-build", "skip the build prompt and use the latest dist/ output")
26
27
  .action(async (options) => {
27
- const checkSuccessful = await checkTranslations(options)
28
- commandExitCode = checkSuccessful ? 0 : 1
28
+ try {
29
+ const checkSuccessful = await checkTranslations(options)
30
+ commandExitCode = checkSuccessful ? 0 : 1
31
+ } catch (error) {
32
+ handleCommandError(error)
33
+ }
29
34
  })
30
35
 
31
36
  program
@@ -34,7 +39,7 @@ program
34
39
  "check for missing and orphaned content files and thumbnails in a LightNet site",
35
40
  )
36
41
  .option("--fix", "remove orphaned files")
37
- .option("--no-confirm", "skip deletion confirmation when used with --fix")
42
+ .option("--fix-without-confirm", "remove orphaned files without confirmation")
38
43
  .option(
39
44
  "--r2",
40
45
  "validate remote content files in Cloudflare R2 instead of public/files",
@@ -48,12 +53,7 @@ program
48
53
  const checkSuccessful = await checkFiles(options)
49
54
  commandExitCode = checkSuccessful ? 0 : 1
50
55
  } catch (error) {
51
- if (error instanceof CliError) {
52
- console.error(error.message)
53
- commandExitCode = 1
54
- return
55
- }
56
- throw error
56
+ handleCommandError(error)
57
57
  }
58
58
  })
59
59
 
@@ -66,14 +66,25 @@ program
66
66
  const checkSuccessful = await checkLinks(options)
67
67
  commandExitCode = checkSuccessful ? 0 : 1
68
68
  } catch (error) {
69
- if (error instanceof CliError) {
70
- console.error(error.message)
71
- commandExitCode = 1
72
- return
73
- }
74
- throw error
69
+ handleCommandError(error)
75
70
  }
76
71
  })
77
72
 
78
73
  await program.parseAsync()
79
74
  process.exitCode = commandExitCode
75
+
76
+ /**
77
+ * @param {unknown} error
78
+ */
79
+ function handleCommandError(error) {
80
+ if (error instanceof PromptCancelled) {
81
+ commandExitCode = 0
82
+ return
83
+ }
84
+ if (error instanceof CliError) {
85
+ console.error(error.message)
86
+ commandExitCode = 1
87
+ return
88
+ }
89
+ throw error
90
+ }
@@ -0,0 +1,13 @@
1
+ // @ts-check
2
+
3
+ import { cancel } from "@clack/prompts"
4
+
5
+ export class PromptCancelled extends Error {}
6
+
7
+ /**
8
+ * @returns {never}
9
+ */
10
+ export function cancelPrompt() {
11
+ cancel("Operation cancelled.")
12
+ throw new PromptCancelled()
13
+ }
package/src/support/r2.js CHANGED
@@ -15,6 +15,7 @@ import {
15
15
  writeCliConfig,
16
16
  } from "./cli-config.js"
17
17
  import { CliError } from "./cli-error.js"
18
+ import { cancelPrompt } from "./prompt-cancel.js"
18
19
 
19
20
  const execFileAsync = promisify(execFile)
20
21
  const sessionSecretEnvName = "LIGHTNET_R2_SECRET_ACCESS_KEY"
@@ -412,7 +413,7 @@ async function promptRequiredSecret() {
412
413
  message: "R2 secretAccessKey:",
413
414
  })
414
415
  if (isCancel(value)) {
415
- throw new CliError("Secret access key prompt cancelled.")
416
+ cancelPrompt()
416
417
  }
417
418
  if (value.trim()) {
418
419
  processEnv[sessionSecretEnvName] = value