@lightnet/cli 4.7.0 → 4.8.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.8.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [#449](https://github.com/LightNetDev/LightNet/pull/449) [`b4b1ba9`](https://github.com/LightNetDev/LightNet/commit/b4b1ba9e591af8f6870f2b3eb04029820a008393) - Add repeatable `--exclude` glob exclusions to `check-links` for explicitly exempt media URLs.
8
+
9
+ ### Patch Changes
10
+
11
+ - [#449](https://github.com/LightNetDev/LightNet/pull/449) [`b4b1ba9`](https://github.com/LightNetDev/LightNet/commit/b4b1ba9e591af8f6870f2b3eb04029820a008393) - Update dependencies
12
+
13
+ - [#449](https://github.com/LightNetDev/LightNet/pull/449) [`b4b1ba9`](https://github.com/LightNetDev/LightNet/commit/b4b1ba9e591af8f6870f2b3eb04029820a008393) - Support non interactive usage of check-translations cli script.
14
+
15
+ - [#449](https://github.com/LightNetDev/LightNet/pull/449) [`b4b1ba9`](https://github.com/LightNetDev/LightNet/commit/b4b1ba9e591af8f6870f2b3eb04029820a008393) - Internal fix typings after dependency update.
16
+
17
+ ## 4.7.1
18
+
19
+ ### Patch Changes
20
+
21
+ - [#447](https://github.com/LightNetDev/LightNet/pull/447) [`0d8fece`](https://github.com/LightNetDev/LightNet/commit/0d8fece3527a67a21c6272b422c62152948b32a1) - Only prompt for `lightnet r2 cp` and `lightnet r2 mv` when files would actually be overwritten, and list the destination files before confirmation.
22
+
3
23
  ## 4.7.0
4
24
 
5
25
  ### 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.7.0",
6
+ "version": "4.8.0",
7
7
  "publishConfig": {
8
8
  "access": "public"
9
9
  },
@@ -25,10 +25,16 @@
25
25
  "CHANGELOG.md"
26
26
  ],
27
27
  "dependencies": {
28
- "@clack/prompts": "^1.7.0",
28
+ "@clack/prompts": "^1.8.0",
29
29
  "commander": "^15.0.0"
30
30
  },
31
+ "devDependencies": {
32
+ "vitest": "^5.0.0"
33
+ },
31
34
  "engines": {
32
35
  "node": ">=22"
36
+ },
37
+ "scripts": {
38
+ "test": "vitest"
33
39
  }
34
40
  }
@@ -772,5 +772,5 @@ async function defaultPromptConfirm(message) {
772
772
  if (isCancel(answer)) {
773
773
  cancelPrompt()
774
774
  }
775
- return answer
775
+ return Boolean(answer)
776
776
  }
@@ -1,6 +1,6 @@
1
1
  // @ts-check
2
2
 
3
- import { posix, resolve } from "node:path"
3
+ import { matchesGlob, posix, resolve } from "node:path"
4
4
  import { cwd as processCwd, stdin, stdout } from "node:process"
5
5
 
6
6
  import { intro, isCancel, log, outro, progress, text } from "@clack/prompts"
@@ -28,6 +28,7 @@ const requestHeaders = {
28
28
 
29
29
  /**
30
30
  * @typedef {{
31
+ * exclude?: string[]
31
32
  * timeout?: string
32
33
  * }} CheckLinksOptions
33
34
  */
@@ -49,6 +50,14 @@ const requestHeaders = {
49
50
  * }} LinkReference
50
51
  */
51
52
 
53
+ /**
54
+ * @typedef {{
55
+ * displayUrl: string
56
+ * patterns: Set<string>
57
+ * sources: Set<string>
58
+ * }} ExcludedLinkReference
59
+ */
60
+
52
61
  /**
53
62
  * @typedef {{
54
63
  * ok: boolean
@@ -73,6 +82,7 @@ export async function checkLinks(options, runtime = {}) {
73
82
  "--timeout",
74
83
  defaultTimeoutMs,
75
84
  )
85
+ const excludePatterns = parseExcludePatterns(options.exclude)
76
86
 
77
87
  intro("check-links")
78
88
 
@@ -82,17 +92,28 @@ export async function checkLinks(options, runtime = {}) {
82
92
  const mediaItems = await collections.getMediaItems()
83
93
  const siteUrl = await initSiteUrl({
84
94
  cwd,
95
+ excludePatterns,
85
96
  interactive,
86
97
  mediaItems,
87
98
  promptText,
88
99
  })
89
- const references = collectLinkReferences(mediaItems, siteUrl)
100
+ const { excludedReferences, references } = collectLinkReferences(
101
+ mediaItems,
102
+ siteUrl,
103
+ excludePatterns,
104
+ )
90
105
  log.message(
91
106
  `Checking ${references.length} unique links from ${mediaItems.length} media items.`,
92
107
  )
93
108
 
109
+ printExcludedLinks(excludedReferences)
110
+
94
111
  if (references.length === 0) {
95
- log.warn("No media content URLs found.")
112
+ log.warn(
113
+ excludedReferences.length > 0
114
+ ? "No media content URLs require checking."
115
+ : "No media content URLs found.",
116
+ )
96
117
  outro("No issues found. 🎉")
97
118
  return true
98
119
  }
@@ -179,14 +200,25 @@ async function checkReferencesWithProgress(references, options) {
179
200
  /**
180
201
  * @param {{
181
202
  * cwd: string
203
+ * excludePatterns: string[]
182
204
  * interactive: boolean
183
205
  * mediaItems: MediaItem[]
184
206
  * promptText: (message: string) => Promise<string>
185
207
  * }} args
186
208
  */
187
- async function initSiteUrl({ cwd, interactive, mediaItems, promptText }) {
209
+ async function initSiteUrl({
210
+ cwd,
211
+ excludePatterns,
212
+ interactive,
213
+ mediaItems,
214
+ promptText,
215
+ }) {
188
216
  const needsSiteUrl = mediaItems.some((item) =>
189
- item.content.some((contentItem) => isRootRelativeUrl(contentItem.url)),
217
+ item.content.some(
218
+ (contentItem) =>
219
+ isRootRelativeUrl(contentItem.url) &&
220
+ findMatchingPatterns(contentItem.url, excludePatterns).length === 0,
221
+ ),
190
222
  )
191
223
  if (!needsSiteUrl) {
192
224
  return undefined
@@ -236,13 +268,36 @@ function parseSiteUrl(config) {
236
268
  /**
237
269
  * @param {MediaItem[]} mediaItems
238
270
  * @param {string|undefined} siteUrl
271
+ * @param {string[]} excludePatterns
239
272
  */
240
- function collectLinkReferences(mediaItems, siteUrl) {
273
+ function collectLinkReferences(mediaItems, siteUrl, excludePatterns) {
241
274
  /** @type {Map<string, LinkReference>} */
242
275
  const references = new Map()
276
+ /** @type {Map<string, ExcludedLinkReference>} */
277
+ const excludedReferences = new Map()
243
278
  for (const item of mediaItems) {
244
279
  const sourceFileName = posix.basename(toPosixPath(item.path))
245
280
  for (const contentItem of item.content) {
281
+ const matchingPatterns = findMatchingPatterns(
282
+ contentItem.url,
283
+ excludePatterns,
284
+ )
285
+ if (matchingPatterns.length > 0) {
286
+ const current = excludedReferences.get(contentItem.url)
287
+ if (current) {
288
+ current.sources.add(sourceFileName)
289
+ for (const pattern of matchingPatterns) {
290
+ current.patterns.add(pattern)
291
+ }
292
+ } else {
293
+ excludedReferences.set(contentItem.url, {
294
+ displayUrl: contentItem.url,
295
+ patterns: new Set(matchingPatterns),
296
+ sources: new Set([sourceFileName]),
297
+ })
298
+ }
299
+ continue
300
+ }
246
301
  const resolvedUrl = resolveUrl(contentItem.url, siteUrl)
247
302
  const current = references.get(resolvedUrl)
248
303
  if (current) {
@@ -259,9 +314,14 @@ function collectLinkReferences(mediaItems, siteUrl) {
259
314
  }
260
315
  }
261
316
  }
262
- return [...references.values()].sort((a, b) =>
263
- a.displayUrl.localeCompare(b.displayUrl),
264
- )
317
+ return {
318
+ excludedReferences: [...excludedReferences.values()].sort((a, b) =>
319
+ a.displayUrl.localeCompare(b.displayUrl),
320
+ ),
321
+ references: [...references.values()].sort((a, b) =>
322
+ a.displayUrl.localeCompare(b.displayUrl),
323
+ ),
324
+ }
265
325
  }
266
326
 
267
327
  /**
@@ -334,6 +394,25 @@ function printProtectedLinks(links) {
334
394
  }
335
395
  }
336
396
 
397
+ /**
398
+ * @param {ExcludedLinkReference[]} links
399
+ */
400
+ function printExcludedLinks(links) {
401
+ if (links.length === 0) {
402
+ return
403
+ }
404
+
405
+ log.info(`Excluded links (${links.length})`)
406
+ for (const link of links) {
407
+ const patterns = [...link.patterns]
408
+ .toSorted()
409
+ .map((pattern) => `"${pattern}"`)
410
+ log.message(
411
+ `• ${link.displayUrl}\n Excluded by: ${patterns.join(", ")}\n${formatReferenceSources(link.sources)}`,
412
+ )
413
+ }
414
+ }
415
+
337
416
  /**
338
417
  * @param {Set<string>} sources
339
418
  */
@@ -426,6 +505,63 @@ function parsePositiveInteger(rawValue, optionName, defaultValue) {
426
505
  return value
427
506
  }
428
507
 
508
+ /**
509
+ * @param {string[]|undefined} patterns
510
+ */
511
+ function parseExcludePatterns(patterns) {
512
+ return (patterns ?? []).map((pattern) => {
513
+ if (!isValidGlobPattern(pattern)) {
514
+ throw new CliError(`Invalid "--exclude" glob pattern "${pattern}".`)
515
+ }
516
+ return pattern
517
+ })
518
+ }
519
+
520
+ /**
521
+ * @param {string} url
522
+ * @param {string[]} patterns
523
+ */
524
+ function findMatchingPatterns(url, patterns) {
525
+ return patterns.filter((pattern) => matchesGlob(url, pattern))
526
+ }
527
+
528
+ /**
529
+ * Node's glob matcher treats unclosed groups as non-matches, so validate them
530
+ * explicitly to turn common malformed patterns into actionable CLI errors.
531
+ *
532
+ * @param {string} pattern
533
+ */
534
+ function isValidGlobPattern(pattern) {
535
+ /** @type {Partial<Record<string, string>>} */
536
+ const pairs = { "[": "]", "{": "}", "(": ")" }
537
+ /** @type {string[]} */
538
+ const expectedClosers = []
539
+ let escaped = false
540
+
541
+ for (const character of pattern) {
542
+ if (escaped) {
543
+ escaped = false
544
+ continue
545
+ }
546
+ if (character === "\\") {
547
+ escaped = true
548
+ continue
549
+ }
550
+ const closer = pairs[character]
551
+ if (closer) {
552
+ expectedClosers.push(closer)
553
+ continue
554
+ }
555
+ if (character === "]" || character === "}" || character === ")") {
556
+ if (expectedClosers.pop() !== character) {
557
+ return false
558
+ }
559
+ }
560
+ }
561
+
562
+ return !escaped && expectedClosers.length === 0
563
+ }
564
+
429
565
  /**
430
566
  * @param {string} value
431
567
  * @param {string} label
@@ -3,7 +3,7 @@
3
3
  import { spawn } from "node:child_process"
4
4
  import { readFile } from "node:fs/promises"
5
5
  import { resolve } from "node:path"
6
- import { cwd } from "node:process"
6
+ import { cwd, stdin, stdout } from "node:process"
7
7
 
8
8
  import { confirm, intro, isCancel, log, outro, taskLog } from "@clack/prompts"
9
9
 
@@ -30,7 +30,14 @@ import { cancelPrompt } from "./support/prompt-cancel.js"
30
30
  * }} CheckTranslationsOptions
31
31
  */
32
32
 
33
- const lightnetCachePath = resolve(cwd(), "node_modules", ".cache", "lightnet")
33
+ /**
34
+ * @typedef {{
35
+ * cwd?: string
36
+ * isInteractive?: boolean
37
+ * promptRunBuild?: () => Promise<boolean>
38
+ * write?: (message: string) => void
39
+ * }} CheckTranslationsRuntime
40
+ */
34
41
 
35
42
  /** @type {{type:Translation["type"], title:string, action:string}[]} */
36
43
  const translationSources = [
@@ -54,18 +61,33 @@ const translationSources = [
54
61
 
55
62
  /**
56
63
  * @param {CheckTranslationsOptions} [options]
64
+ * @param {CheckTranslationsRuntime} [runtime]
57
65
  */
58
- export async function checkTranslations(options = {}) {
59
- intro("check-translations")
66
+ export async function checkTranslations(options = {}, runtime = {}) {
67
+ const lightnetCachePath = resolve(
68
+ runtime.cwd ?? cwd(),
69
+ "node_modules",
70
+ ".cache",
71
+ "lightnet",
72
+ )
73
+ const interactive =
74
+ runtime.isInteractive ?? Boolean(stdin.isTTY && stdout.isTTY)
75
+ const output = createOutput(interactive, runtime.write)
76
+
77
+ output.intro("check-translations")
60
78
 
61
- const buildAvailable = await runBuild(options.build)
79
+ const buildAvailable = await runBuild(options.build, {
80
+ interactive,
81
+ output,
82
+ promptRunBuild: runtime.promptRunBuild,
83
+ })
62
84
  if (!buildAvailable) {
63
- outro("Build failed. 🚧")
85
+ output.outro("Build failed. 🚧")
64
86
  return false
65
87
  }
66
88
 
67
- const translations = await readTranslations()
68
- const languages = await readLanguages()
89
+ const translations = await readTranslations(lightnetCachePath, output)
90
+ const languages = await readLanguages(lightnetCachePath, output)
69
91
  if (!translations || !languages || translations.length === 0) {
70
92
  return false
71
93
  }
@@ -77,7 +99,7 @@ export async function checkTranslations(options = {}) {
77
99
  .filter((translation) => translation.missingLocales.length > 0)
78
100
 
79
101
  if (incompleteTranslations.length === 0) {
80
- outro("No issues found. 🎉")
102
+ output.outro("No issues found. 🎉")
81
103
  return true
82
104
  }
83
105
 
@@ -86,29 +108,42 @@ export async function checkTranslations(options = {}) {
86
108
  (translation) => translation.type,
87
109
  )
88
110
 
89
- log.error("Translation check failed")
111
+ output.error("Translation check failed")
90
112
  for (const source of translationSources) {
91
- printMissingTranslations(source, grouped[source.type])
113
+ printMissingTranslations(source, grouped[source.type], output)
92
114
  }
93
115
 
94
- outro("Issues found. 🚧")
116
+ output.outro("Issues found. 🚧")
95
117
 
96
118
  return false
97
119
  }
98
120
 
99
121
  /**
100
- *
101
122
  * @param {boolean|undefined} build
102
- * @returns
123
+ * @param {{
124
+ * interactive: boolean
125
+ * output: ReturnType<typeof createOutput>
126
+ * promptRunBuild?: () => Promise<boolean>
127
+ * }} runtime
103
128
  */
104
- async function runBuild(build) {
105
- const shouldRunBuild = build ?? (await promptRunBuild())
129
+ async function runBuild(build, { interactive, output, promptRunBuild }) {
130
+ const shouldRunBuild =
131
+ build ??
132
+ (interactive ? await (promptRunBuild ?? defaultPromptRunBuild)() : false)
106
133
  if (!shouldRunBuild) {
107
134
  return true
108
135
  }
109
- const buildLog = taskLog({
110
- title: "Running pnpm build",
111
- })
136
+ const buildLog = interactive
137
+ ? taskLog({ title: "Running pnpm build" })
138
+ : {
139
+ error: output.error,
140
+ message: output.message,
141
+ success: output.message,
142
+ }
143
+
144
+ if (!interactive) {
145
+ output.message("Running pnpm build")
146
+ }
112
147
 
113
148
  const child = spawn("pnpm", ["build"], {
114
149
  shell: process.platform === "win32",
@@ -154,7 +189,7 @@ async function runBuild(build) {
154
189
  }
155
190
  }
156
191
 
157
- async function promptRunBuild() {
192
+ async function defaultPromptRunBuild() {
158
193
  const answer = await confirm({
159
194
  message:
160
195
  "Run pnpm build now? Command requires an up-to-date dist/ directory.",
@@ -170,13 +205,14 @@ async function promptRunBuild() {
170
205
  *
171
206
  * @param {{title:string, action:string}} source
172
207
  * @param {(Translation & {missingLocales:string[]})[]|undefined} translations
208
+ * @param {ReturnType<typeof createOutput>} output
173
209
  */
174
- function printMissingTranslations(source, translations) {
210
+ function printMissingTranslations(source, translations, output) {
175
211
  if (!translations || translations.length === 0) {
176
212
  return
177
213
  }
178
214
 
179
- log.warn(source.title)
215
+ output.warn(source.title)
180
216
  translations
181
217
  .toSorted(
182
218
  (t1, t2) =>
@@ -184,10 +220,10 @@ function printMissingTranslations(source, translations) {
184
220
  t1.key.localeCompare(t2.key),
185
221
  )
186
222
  .forEach(({ key, missingLocales }) => {
187
- log.message(`• ${key} > Missing: ${missingLocales.join(", ")}`)
223
+ output.message(`• ${key} > Missing: ${missingLocales.join(", ")}`)
188
224
  })
189
225
 
190
- log.message(`Action: ${source.action}`)
226
+ output.message(`Action: ${source.action}`)
191
227
  }
192
228
 
193
229
  /**
@@ -201,9 +237,11 @@ function getMissingLocales(translation, languages) {
201
237
  }
202
238
 
203
239
  /**
240
+ * @param {string} lightnetCachePath
241
+ * @param {ReturnType<typeof createOutput>} output
204
242
  * @returns {Promise<Translation[]|undefined>}
205
243
  */
206
- async function readTranslations() {
244
+ async function readTranslations(lightnetCachePath, output) {
207
245
  try {
208
246
  const translationsText = await readFile(
209
247
  resolve(lightnetCachePath, "translations.jsonl"),
@@ -214,16 +252,18 @@ async function readTranslations() {
214
252
  .filter((line) => line.trim())
215
253
  .map((line) => JSON.parse(line))
216
254
  } catch {
217
- log.error("No translation build cache found.")
218
- log.error("Action: Run build and try lightnet check-translations again.")
255
+ output.error("No translation build cache found.")
256
+ output.error("Action: Run build and try lightnet check-translations again.")
219
257
  return undefined
220
258
  }
221
259
  }
222
260
 
223
261
  /**
262
+ * @param {string} lightnetCachePath
263
+ * @param {ReturnType<typeof createOutput>} output
224
264
  * @returns {Promise<Languages|undefined>}
225
265
  */
226
- async function readLanguages() {
266
+ async function readLanguages(lightnetCachePath, output) {
227
267
  try {
228
268
  const languagesText = await readFile(
229
269
  resolve(lightnetCachePath, "languages.json"),
@@ -231,8 +271,37 @@ async function readLanguages() {
231
271
  )
232
272
  return JSON.parse(languagesText)
233
273
  } catch {
234
- log.error("No language manifest found from the last build.")
235
- log.error("Action: Run build and try lightnet check-translations again.")
274
+ output.error("No language manifest found from the last build.")
275
+ output.error("Action: Run build and try lightnet check-translations again.")
236
276
  return undefined
237
277
  }
238
278
  }
279
+
280
+ /**
281
+ * @param {boolean} interactive
282
+ * @param {(message: string) => void | undefined} write
283
+ */
284
+ function createOutput(
285
+ interactive,
286
+ write = (message) => {
287
+ stdout.write(`${message}\n`)
288
+ },
289
+ ) {
290
+ if (interactive) {
291
+ return {
292
+ error: log.error,
293
+ intro,
294
+ message: log.message,
295
+ outro,
296
+ warn: log.warn,
297
+ }
298
+ }
299
+
300
+ return {
301
+ error: write,
302
+ intro: write,
303
+ message: write,
304
+ outro: write,
305
+ warn: write,
306
+ }
307
+ }
package/src/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  // @ts-check
3
3
 
4
- import { Command } from "commander"
4
+ import { Command, Option } from "commander"
5
5
 
6
6
  import pkg from "../package.json" with { type: "json" }
7
7
  import { checkFiles } from "./check-files.js"
@@ -62,6 +62,14 @@ program
62
62
  .command("check-links")
63
63
  .description("check media content links in a LightNet site")
64
64
  .option("--timeout <ms>", "request timeout per link in milliseconds")
65
+ .addOption(
66
+ new Option(
67
+ "--exclude <pattern>",
68
+ "exclude media content URLs matching a glob pattern (repeatable)",
69
+ )
70
+ .default([])
71
+ .argParser((pattern, patterns) => [...patterns, pattern]),
72
+ )
65
73
  .action(async (options) => {
66
74
  try {
67
75
  const checkSuccessful = await checkLinks(options)
package/src/r2.js CHANGED
@@ -1,6 +1,6 @@
1
1
  // @ts-check
2
2
 
3
- import { stat } from "node:fs/promises"
3
+ import { readdir, stat } from "node:fs/promises"
4
4
  import { basename, join, posix, resolve } from "node:path"
5
5
  import {
6
6
  cwd as processCwd,
@@ -78,6 +78,7 @@ const defaultRcloneOptions = {
78
78
  * isInteractive?: boolean
79
79
  * promptConfirm?: (message: string) => Promise<boolean>
80
80
  * promptText?: (message: string) => Promise<string>
81
+ * createR2Client?: typeof createR2Client
81
82
  * writeStdout?: (message: string) => void
82
83
  * }} R2Runtime
83
84
  */
@@ -285,10 +286,12 @@ export async function copyR2(paths, options = {}, runtime = {}) {
285
286
  (targetType !== "directory" || isDirectoryLikeCopyPath(destinationPath))
286
287
 
287
288
  await confirmCopyOverwrite({
289
+ client,
288
290
  destination,
289
291
  force: options.force === true,
290
292
  noClobber: options.noClobber === true,
291
293
  runtime,
294
+ sourcePath,
292
295
  sourceType,
293
296
  targetPath,
294
297
  targetType,
@@ -383,10 +386,12 @@ export async function moveR2(paths, options = {}, runtime = {}) {
383
386
  (targetType !== "directory" || isDirectoryLikeCopyPath(destinationPath))
384
387
 
385
388
  await confirmCopyOverwrite({
389
+ client,
386
390
  destination,
387
391
  force: options.force === true,
388
392
  noClobber: options.noClobber === true,
389
393
  runtime,
394
+ sourcePath,
390
395
  sourceType,
391
396
  targetPath,
392
397
  targetType,
@@ -426,7 +431,7 @@ export async function moveR2(paths, options = {}, runtime = {}) {
426
431
  * @param {R2Runtime} runtime
427
432
  */
428
433
  function createR2CommandClient(runtime) {
429
- return createR2Client({
434
+ return (runtime.createR2Client ?? createR2Client)({
430
435
  cwd: runtime.cwd ?? processCwd(),
431
436
  interactive: getInteractive(runtime),
432
437
  promptText: getPromptText(runtime),
@@ -580,20 +585,24 @@ function getCopyTargetPath({
580
585
 
581
586
  /**
582
587
  * @param {{
588
+ * client: ReturnType<typeof createR2CommandClient>,
583
589
  * destination: string,
584
590
  * force: boolean,
585
591
  * noClobber: boolean,
586
592
  * runtime: R2Runtime,
593
+ * sourcePath: CopyPath,
587
594
  * sourceType: CopyPathType,
588
595
  * targetPath: CopyPath,
589
596
  * targetType: CopyPathType
590
597
  * }} args
591
598
  */
592
599
  async function confirmCopyOverwrite({
600
+ client,
593
601
  destination,
594
602
  force,
595
603
  noClobber,
596
604
  runtime,
605
+ sourcePath,
597
606
  sourceType,
598
607
  targetPath,
599
608
  targetType,
@@ -617,11 +626,27 @@ async function confirmCopyOverwrite({
617
626
  if (noClobber) {
618
627
  return
619
628
  }
629
+ const overwrittenPaths =
630
+ sourceType === "directory"
631
+ ? await getDirectoryOverwritePaths({
632
+ client,
633
+ runtime,
634
+ sourcePath,
635
+ targetPath,
636
+ })
637
+ : [formatCopyPath(targetPath)]
638
+ if (overwrittenPaths.length === 0) {
639
+ return
640
+ }
620
641
  if (!getInteractive(runtime)) {
621
642
  throw new CliError(
622
643
  'Transfer would overwrite existing files. Re-run with "--force" in non-interactive environments.',
623
644
  )
624
645
  }
646
+ log.warn(`Files to overwrite (${overwrittenPaths.length})`)
647
+ for (const overwrittenPath of overwrittenPaths) {
648
+ log.message(`• ${overwrittenPath}`)
649
+ }
625
650
  const message =
626
651
  sourceType === "directory"
627
652
  ? `Overwrite any existing files under destination "${destination}"? [y/N] `
@@ -632,6 +657,94 @@ async function confirmCopyOverwrite({
632
657
  }
633
658
  }
634
659
 
660
+ /**
661
+ * @param {{
662
+ * client: ReturnType<typeof createR2CommandClient>
663
+ * runtime: R2Runtime
664
+ * sourcePath: CopyPath
665
+ * targetPath: CopyPath
666
+ * }} args
667
+ */
668
+ async function getDirectoryOverwritePaths({
669
+ client,
670
+ runtime,
671
+ sourcePath,
672
+ targetPath,
673
+ }) {
674
+ const sourceFiles = await listCopyFiles(sourcePath, client, runtime)
675
+ if (sourceFiles.length === 0) {
676
+ return []
677
+ }
678
+ const targetFiles = new Set(await listCopyFiles(targetPath, client, runtime))
679
+ return sourceFiles
680
+ .filter((relativePath) => targetFiles.has(relativePath))
681
+ .map((relativePath) =>
682
+ formatCopyPath({
683
+ ...targetPath,
684
+ path: joinCopyPath(targetPath, relativePath),
685
+ }),
686
+ )
687
+ }
688
+
689
+ /**
690
+ * @param {CopyPath} copyPath
691
+ * @param {ReturnType<typeof createR2CommandClient>} client
692
+ * @param {R2Runtime} runtime
693
+ */
694
+ async function listCopyFiles(copyPath, client, runtime) {
695
+ if (copyPath.remote) {
696
+ return parseRcloneFileList(await client.list(copyPath.path))
697
+ }
698
+ return listLocalFiles(resolveLocalPath(copyPath.path, runtime))
699
+ }
700
+
701
+ /**
702
+ * @param {string} root
703
+ * @param {string} [prefix]
704
+ * @returns {Promise<string[]>}
705
+ */
706
+ async function listLocalFiles(root, prefix = "") {
707
+ const entries = await readdir(root, { withFileTypes: true })
708
+ const files = []
709
+ for (const entry of entries) {
710
+ const relativePath = prefix
711
+ ? joinRelativePath(prefix, entry.name)
712
+ : entry.name
713
+ const absolutePath = join(root, entry.name)
714
+ if (entry.isDirectory()) {
715
+ files.push(...(await listLocalFiles(absolutePath, relativePath)))
716
+ } else if (entry.isFile()) {
717
+ files.push(relativePath)
718
+ }
719
+ }
720
+ return files
721
+ }
722
+
723
+ /**
724
+ * @param {string} output
725
+ */
726
+ function parseRcloneFileList(output) {
727
+ return output
728
+ .split(/\r?\n/)
729
+ .map((line) => line.trim())
730
+ .filter(Boolean)
731
+ }
732
+
733
+ /**
734
+ * @param {string} parent
735
+ * @param {string} child
736
+ */
737
+ function joinRelativePath(parent, child) {
738
+ return parent ? posix.join(parent, child) : child
739
+ }
740
+
741
+ /**
742
+ * @param {CopyPath} copyPath
743
+ */
744
+ function formatCopyPath(copyPath) {
745
+ return copyPath.remote ? `${remotePathPrefix}${copyPath.path}` : copyPath.path
746
+ }
747
+
635
748
  /**
636
749
  * @param {CopyPath} copyPath
637
750
  * @param {ReturnType<typeof createR2CommandClient>} client
package/src/support/r2.js CHANGED
@@ -617,9 +617,10 @@ async function promptRequiredSecret({ interactive = true } = {}) {
617
617
  if (isCancel(value)) {
618
618
  cancelPrompt()
619
619
  }
620
- if (value.trim()) {
621
- processEnv[sessionSecretEnvName] = value
622
- return value
620
+ const secret = typeof value === "string" ? value : ""
621
+ if (secret.trim()) {
622
+ processEnv[sessionSecretEnvName] = secret
623
+ return secret
623
624
  }
624
625
  }
625
626
  }