@lightnet/cli 4.4.1 → 4.5.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,25 @@
1
1
  # @lightnet/cli
2
2
 
3
+ ## 4.5.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [#437](https://github.com/LightNetDev/LightNet/pull/437) [`91446d3`](https://github.com/LightNetDev/LightNet/commit/91446d3102da0383dfffa836578c02286d0258c9) - Add `lightnet r2` commands for listing, deleting, and copying Cloudflare R2 files through the project R2 configuration.
8
+
9
+ ## 4.4.2
10
+
11
+ ### Patch Changes
12
+
13
+ - [#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.
14
+
15
+ - [#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.
16
+
17
+ - [#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.
18
+
19
+ - [#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.
20
+
21
+ - [#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.
22
+
3
23
  ## 4.4.1
4
24
 
5
25
  ### 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.5.0",
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
@@ -7,7 +7,9 @@ import pkg from "../package.json" with { type: "json" }
7
7
  import { checkFiles } from "./check-files.js"
8
8
  import { checkLinks } from "./check-links.js"
9
9
  import { checkTranslations } from "./check-translations.js"
10
+ import { copyR2, listR2, removeR2 } from "./r2.js"
10
11
  import { CliError } from "./support/cli-error.js"
12
+ import { PromptCancelled } from "./support/prompt-cancel.js"
11
13
  const { version } = pkg
12
14
 
13
15
  const program = new Command()
@@ -24,8 +26,12 @@ program
24
26
  .option("--build", "run pnpm build before checking translations")
25
27
  .option("--no-build", "skip the build prompt and use the latest dist/ output")
26
28
  .action(async (options) => {
27
- const checkSuccessful = await checkTranslations(options)
28
- commandExitCode = checkSuccessful ? 0 : 1
29
+ try {
30
+ const checkSuccessful = await checkTranslations(options)
31
+ commandExitCode = checkSuccessful ? 0 : 1
32
+ } catch (error) {
33
+ handleCommandError(error)
34
+ }
29
35
  })
30
36
 
31
37
  program
@@ -34,7 +40,7 @@ program
34
40
  "check for missing and orphaned content files and thumbnails in a LightNet site",
35
41
  )
36
42
  .option("--fix", "remove orphaned files")
37
- .option("--no-confirm", "skip deletion confirmation when used with --fix")
43
+ .option("--fix-without-confirm", "remove orphaned files without confirmation")
38
44
  .option(
39
45
  "--r2",
40
46
  "validate remote content files in Cloudflare R2 instead of public/files",
@@ -48,12 +54,7 @@ program
48
54
  const checkSuccessful = await checkFiles(options)
49
55
  commandExitCode = checkSuccessful ? 0 : 1
50
56
  } catch (error) {
51
- if (error instanceof CliError) {
52
- console.error(error.message)
53
- commandExitCode = 1
54
- return
55
- }
56
- throw error
57
+ handleCommandError(error)
57
58
  }
58
59
  })
59
60
 
@@ -66,14 +67,70 @@ program
66
67
  const checkSuccessful = await checkLinks(options)
67
68
  commandExitCode = checkSuccessful ? 0 : 1
68
69
  } catch (error) {
69
- if (error instanceof CliError) {
70
- console.error(error.message)
71
- commandExitCode = 1
72
- return
73
- }
74
- throw error
70
+ handleCommandError(error)
71
+ }
72
+ })
73
+
74
+ const r2Command = program
75
+ .command("r2")
76
+ .description("manage Cloudflare R2 files with rclone")
77
+
78
+ r2Command
79
+ .command("ls")
80
+ .description("list R2 objects")
81
+ .argument("[path]", "R2 path or prefix")
82
+ .action(async (path) => {
83
+ try {
84
+ await listR2(path)
85
+ } catch (error) {
86
+ handleCommandError(error)
87
+ }
88
+ })
89
+
90
+ r2Command
91
+ .command("rm")
92
+ .description("delete an R2 file; use -r to delete a directory/prefix")
93
+ .argument("<path>", "R2 file path, or directory/prefix with -r")
94
+ .option("-r, --recursive", "delete a directory/prefix recursively")
95
+ .option("-f, --force", "delete without confirmation")
96
+ .action(async (path, options) => {
97
+ try {
98
+ await removeR2(path, options)
99
+ } catch (error) {
100
+ handleCommandError(error)
101
+ }
102
+ })
103
+
104
+ r2Command
105
+ .command("cp")
106
+ .description(
107
+ 'copy files between R2 and the local filesystem; prefix the R2 side with "r2:"',
108
+ )
109
+ .argument("<source>", 'source path; use "r2:<path>" for R2')
110
+ .argument("<destination>", 'destination path; use "r2:<path>" for R2')
111
+ .action(async (source, destination) => {
112
+ try {
113
+ await copyR2(source, destination)
114
+ } catch (error) {
115
+ handleCommandError(error)
75
116
  }
76
117
  })
77
118
 
78
119
  await program.parseAsync()
79
120
  process.exitCode = commandExitCode
121
+
122
+ /**
123
+ * @param {unknown} error
124
+ */
125
+ function handleCommandError(error) {
126
+ if (error instanceof PromptCancelled) {
127
+ commandExitCode = 0
128
+ return
129
+ }
130
+ if (error instanceof CliError) {
131
+ console.error(error.message)
132
+ commandExitCode = 1
133
+ return
134
+ }
135
+ throw error
136
+ }
package/src/r2.js ADDED
@@ -0,0 +1,196 @@
1
+ // @ts-check
2
+
3
+ import { resolve } from "node:path"
4
+ import {
5
+ cwd as processCwd,
6
+ env as processEnv,
7
+ stdin,
8
+ stdout,
9
+ } from "node:process"
10
+
11
+ import { confirm, isCancel, text } from "@clack/prompts"
12
+
13
+ import { CliError } from "./support/cli-error.js"
14
+ import { cancelPrompt } from "./support/prompt-cancel.js"
15
+ import { createR2Client } from "./support/r2.js"
16
+
17
+ const remotePathPrefix = "r2:"
18
+
19
+ /**
20
+ * @typedef {{
21
+ * force?: boolean
22
+ * recursive?: boolean
23
+ * }} R2RemoveOptions
24
+ */
25
+
26
+ /**
27
+ * @typedef {{
28
+ * cwd?: string
29
+ * invocationCwd?: string
30
+ * isInteractive?: boolean
31
+ * promptConfirm?: (message: string) => Promise<boolean>
32
+ * promptText?: (message: string) => Promise<string>
33
+ * writeStdout?: (message: string) => void
34
+ * }} R2Runtime
35
+ */
36
+
37
+ /**
38
+ * @param {string|undefined} path
39
+ * @param {R2Runtime} [runtime]
40
+ */
41
+ export async function listR2(path, runtime = {}) {
42
+ const client = createR2CommandClient(runtime)
43
+ const output = await client.list(path)
44
+ const writeStdout =
45
+ runtime.writeStdout ??
46
+ ((message) => {
47
+ stdout.write(message)
48
+ })
49
+ writeStdout(output)
50
+ }
51
+
52
+ /**
53
+ * @param {string} path
54
+ * @param {R2RemoveOptions} options
55
+ * @param {R2Runtime} [runtime]
56
+ */
57
+ export async function removeR2(path, options, runtime = {}) {
58
+ const r2Path = normalizeR2Path(path)
59
+ if (!r2Path) {
60
+ throw new CliError("Refusing to delete the R2 bucket root.")
61
+ }
62
+
63
+ const interactive = getInteractive(runtime)
64
+ if (!options.force && !interactive) {
65
+ throw new CliError(
66
+ 'R2 deletion requires confirmation. Re-run with "--force" in non-interactive environments.',
67
+ )
68
+ }
69
+
70
+ const shouldDelete =
71
+ options.force ||
72
+ (await getPromptConfirm(runtime)(`Delete R2 path "${r2Path}"? [y/N] `))
73
+
74
+ if (!shouldDelete) {
75
+ return
76
+ }
77
+
78
+ const client = createR2CommandClient(runtime)
79
+ await client.remove(r2Path, {
80
+ recursive: options.recursive === true,
81
+ })
82
+ }
83
+
84
+ /**
85
+ * @param {string} source
86
+ * @param {string} destination
87
+ * @param {R2Runtime} [runtime]
88
+ */
89
+ export async function copyR2(source, destination, runtime = {}) {
90
+ const sourcePath = parseCopyPath(source)
91
+ const destinationPath = parseCopyPath(destination)
92
+ if (sourcePath.remote === destinationPath.remote) {
93
+ throw new CliError(
94
+ `R2 copy requires exactly one R2 path. Prefix the remote side with "${remotePathPrefix}".`,
95
+ )
96
+ }
97
+
98
+ const client = createR2CommandClient(runtime)
99
+ const rcloneSource = sourcePath.remote
100
+ ? await client.toRemotePath(sourcePath.path)
101
+ : resolveLocalPath(sourcePath.path, runtime)
102
+ const rcloneDestination = destinationPath.remote
103
+ ? await client.toRemotePath(destinationPath.path)
104
+ : resolveLocalPath(destinationPath.path, runtime)
105
+
106
+ await client.copy(rcloneSource, rcloneDestination)
107
+ }
108
+
109
+ /**
110
+ * @param {R2Runtime} runtime
111
+ */
112
+ function createR2CommandClient(runtime) {
113
+ return createR2Client({
114
+ cwd: runtime.cwd ?? processCwd(),
115
+ interactive: getInteractive(runtime),
116
+ promptText: getPromptText(runtime),
117
+ })
118
+ }
119
+
120
+ /**
121
+ * @param {R2Runtime} runtime
122
+ */
123
+ function getInteractive(runtime) {
124
+ return runtime.isInteractive ?? Boolean(stdin.isTTY && stdout.isTTY)
125
+ }
126
+
127
+ /**
128
+ * @param {R2Runtime} runtime
129
+ */
130
+ function getPromptText(runtime) {
131
+ return runtime.promptText ?? defaultPromptText
132
+ }
133
+
134
+ /**
135
+ * @param {R2Runtime} runtime
136
+ */
137
+ function getPromptConfirm(runtime) {
138
+ return runtime.promptConfirm ?? defaultPromptConfirm
139
+ }
140
+
141
+ /**
142
+ * @param {string} value
143
+ */
144
+ function parseCopyPath(value) {
145
+ if (!value.startsWith(remotePathPrefix)) {
146
+ return { remote: false, path: value }
147
+ }
148
+ return { remote: true, path: normalizeR2Path(value) }
149
+ }
150
+
151
+ /**
152
+ * @param {string} path
153
+ * @param {R2Runtime} runtime
154
+ */
155
+ function resolveLocalPath(path, runtime) {
156
+ return resolve(getInvocationCwd(runtime), path)
157
+ }
158
+
159
+ /**
160
+ * @param {R2Runtime} runtime
161
+ */
162
+ function getInvocationCwd(runtime) {
163
+ return runtime.invocationCwd ?? processEnv.INIT_CWD ?? processCwd()
164
+ }
165
+
166
+ /**
167
+ * @param {string|undefined} path
168
+ */
169
+ function normalizeR2Path(path) {
170
+ return path?.replace(/^r2:/, "").replace(/^\/+/, "") ?? ""
171
+ }
172
+
173
+ /**
174
+ * @param {string} message
175
+ */
176
+ async function defaultPromptText(message) {
177
+ const answer = await text({ message })
178
+ if (isCancel(answer)) {
179
+ cancelPrompt()
180
+ }
181
+ return String(answer)
182
+ }
183
+
184
+ /**
185
+ * @param {string} message
186
+ */
187
+ async function defaultPromptConfirm(message) {
188
+ const answer = await confirm({
189
+ message,
190
+ initialValue: false,
191
+ })
192
+ if (isCancel(answer)) {
193
+ cancelPrompt()
194
+ }
195
+ return answer
196
+ }
@@ -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"
@@ -89,7 +90,7 @@ export function createR2FileStorage({
89
90
  /** @returns {Promise<string>} */
90
91
  const getSecretAccessKey = async () => {
91
92
  if (!secretAccessKey) {
92
- secretAccessKey = await promptRequiredSecret()
93
+ secretAccessKey = await promptRequiredSecret({ interactive })
93
94
  }
94
95
  return secretAccessKey
95
96
  }
@@ -165,6 +166,99 @@ export function createR2FileStorage({
165
166
  return storage
166
167
  }
167
168
 
169
+ /**
170
+ * @param {{
171
+ * cwd: string
172
+ * interactive: boolean
173
+ * promptText: (message: string) => Promise<string>
174
+ * }} args
175
+ */
176
+ export function createR2Client({ cwd, interactive, promptText }) {
177
+ /** @type {R2Config|undefined} */
178
+ let config
179
+ /** @type {string|undefined} */
180
+ let secretAccessKey
181
+
182
+ /** @returns {Promise<R2Config>} */
183
+ const getConfig = async () => {
184
+ if (config) {
185
+ return config
186
+ }
187
+ const initializedConfig = await initR2Config({
188
+ cwd,
189
+ interactive,
190
+ promptText,
191
+ })
192
+ config = initializedConfig
193
+ return initializedConfig
194
+ }
195
+
196
+ /** @returns {Promise<string>} */
197
+ const getSecretAccessKey = async () => {
198
+ if (!secretAccessKey) {
199
+ secretAccessKey = await promptRequiredSecret({ interactive })
200
+ }
201
+ return secretAccessKey
202
+ }
203
+
204
+ return {
205
+ /**
206
+ * @param {string} [path]
207
+ */
208
+ async list(path) {
209
+ const r2Config = await getConfig()
210
+ const result = await runConfiguredRclone({
211
+ args: [
212
+ "lsf",
213
+ makeR2Path(r2Config, normalizeR2ObjectPath(path)),
214
+ "--recursive",
215
+ "--files-only",
216
+ ],
217
+ cwd,
218
+ config: r2Config,
219
+ secretAccessKey: await getSecretAccessKey(),
220
+ })
221
+ return result.stdout
222
+ },
223
+ /**
224
+ * @param {string} path
225
+ * @param {{recursive?: boolean}} [options]
226
+ */
227
+ async remove(path, options = {}) {
228
+ const r2Config = await getConfig()
229
+ const secretAccessKey = await getSecretAccessKey()
230
+ const r2Path = makeR2Path(r2Config, normalizeR2ObjectPath(path))
231
+ await runConfiguredRclone({
232
+ args: options.recursive
233
+ ? ["delete", r2Path, "--rmdirs"]
234
+ : ["deletefile", r2Path],
235
+ cwd,
236
+ config: r2Config,
237
+ secretAccessKey,
238
+ })
239
+ },
240
+ /**
241
+ * @param {string} source
242
+ * @param {string} destination
243
+ */
244
+ async copy(source, destination) {
245
+ const r2Config = await getConfig()
246
+ await runConfiguredRclone({
247
+ args: ["copy", source, destination],
248
+ cwd,
249
+ config: r2Config,
250
+ secretAccessKey: await getSecretAccessKey(),
251
+ })
252
+ },
253
+ /**
254
+ * @param {string} path
255
+ */
256
+ async toRemotePath(path) {
257
+ return makeR2Path(await getConfig(), normalizeR2ObjectPath(path))
258
+ },
259
+ }
260
+ }
261
+
168
262
  /**
169
263
  * @param {string} path
170
264
  */
@@ -365,6 +459,7 @@ async function runConfiguredRclone({ args, cwd, config, secretAccessKey }) {
365
459
  "auto",
366
460
  "--s3-endpoint",
367
461
  getR2Endpoint(config.accountId),
462
+ "--s3-no-check-bucket",
368
463
  ],
369
464
  cwd,
370
465
  env,
@@ -399,20 +494,27 @@ async function promptForR2Config({ cwd, promptText }) {
399
494
  }
400
495
 
401
496
  /**
497
+ * @param {{interactive?: boolean}} [args]
402
498
  * @returns {Promise<string>}
403
499
  */
404
- async function promptRequiredSecret() {
500
+ async function promptRequiredSecret({ interactive = true } = {}) {
405
501
  const sessionSecret = processEnv[sessionSecretEnvName]
406
502
  if (typeof sessionSecret === "string" && sessionSecret.trim()) {
407
503
  return sessionSecret
408
504
  }
409
505
 
506
+ if (!interactive) {
507
+ throw new CliError(
508
+ `Missing R2 secret access key. Set ${sessionSecretEnvName} in non-interactive environments.`,
509
+ )
510
+ }
511
+
410
512
  while (true) {
411
513
  const value = await password({
412
514
  message: "R2 secretAccessKey:",
413
515
  })
414
516
  if (isCancel(value)) {
415
- throw new CliError("Secret access key prompt cancelled.")
517
+ cancelPrompt()
416
518
  }
417
519
  if (value.trim()) {
418
520
  processEnv[sessionSecretEnvName] = value
@@ -421,6 +523,13 @@ async function promptRequiredSecret() {
421
523
  }
422
524
  }
423
525
 
526
+ /**
527
+ * @param {string} [path]
528
+ */
529
+ function normalizeR2ObjectPath(path) {
530
+ return path?.replace(/^r2:/, "").replace(/^\/+/, "") ?? ""
531
+ }
532
+
424
533
  /**
425
534
  * @param {(message:string)=>Promise<string>} promptText
426
535
  * @param {string} message