@lightnet/cli 4.6.2 → 4.7.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,23 @@
1
1
  # @lightnet/cli
2
2
 
3
+ ## 4.7.1
4
+
5
+ ### Patch Changes
6
+
7
+ - [#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.
8
+
9
+ ## 4.7.0
10
+
11
+ ### Minor Changes
12
+
13
+ - [#445](https://github.com/LightNetDev/LightNet/pull/445) [`53b6a0d`](https://github.com/LightNetDev/LightNet/commit/53b6a0db8d9e6dca38c808d925007cb2ae343076) - Add a `--progress` option to `lightnet r2 cp`, `lightnet r2 mv`, and `lightnet r2 rm`.
14
+
15
+ - [#445](https://github.com/LightNetDev/LightNet/pull/445) [`53b6a0d`](https://github.com/LightNetDev/LightNet/commit/53b6a0db8d9e6dca38c808d925007cb2ae343076) - Update `lightnet r2 cp` to follow Unix-style copy semantics for files and directories.
16
+
17
+ - [#445](https://github.com/LightNetDev/LightNet/pull/445) [`53b6a0d`](https://github.com/LightNetDev/LightNet/commit/53b6a0db8d9e6dca38c808d925007cb2ae343076) - Update `lightnet r2 mv` to use safer Unix-like move semantics inside the configured R2 bucket.
18
+
19
+ - [#445](https://github.com/LightNetDev/LightNet/pull/445) [`53b6a0d`](https://github.com/LightNetDev/LightNet/commit/53b6a0db8d9e6dca38c808d925007cb2ae343076) - Update `lightnet r2 rm` to follow safer Unix-like delete semantics inside the configured R2 bucket.
20
+
3
21
  ## 4.6.2
4
22
 
5
23
  ### 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.6.2",
6
+ "version": "4.7.1",
7
7
  "publishConfig": {
8
8
  "access": "public"
9
9
  },
@@ -28,7 +28,13 @@
28
28
  "@clack/prompts": "^1.7.0",
29
29
  "commander": "^15.0.0"
30
30
  },
31
+ "devDependencies": {
32
+ "vitest": "^4.1.9"
33
+ },
31
34
  "engines": {
32
35
  "node": ">=22"
36
+ },
37
+ "scripts": {
38
+ "test": "vitest"
33
39
  }
34
40
  }
package/src/index.js CHANGED
@@ -92,16 +92,20 @@ r2Command
92
92
  .description(
93
93
  'delete an R2 file; use -r to delete a directory/prefix or "/" to clean the bucket',
94
94
  )
95
- .argument("<path>", 'R2 file path, directory/prefix with -r, or "/" with -r')
95
+ .argument(
96
+ "<paths...>",
97
+ 'R2 file path(s), directory/prefix paths with -r, or "/" with -r',
98
+ )
96
99
  .option("-r, --recursive", "delete a directory/prefix recursively")
100
+ .option("--progress", "show operation progress")
97
101
  .option(
98
102
  "-f, --force",
99
103
  'delete without confirmation; use "--force" to clean the bucket root without confirmation',
100
104
  )
101
- .action(async (path, options) => {
105
+ .action(async (paths, options) => {
102
106
  try {
103
107
  options.longForce = hasLongForceFlag()
104
- await removeR2(path, options)
108
+ await removeR2(paths, options)
105
109
  } catch (error) {
106
110
  handleCommandError(error)
107
111
  }
@@ -112,16 +116,20 @@ r2Command
112
116
  .description(
113
117
  'copy files between R2 and local paths, or inside R2; prefix R2 paths with "r2:"',
114
118
  )
115
- .argument("<source>", 'source path; use "r2:<path>" for R2')
116
- .argument("<destination>", 'destination path; use "r2:<path>" for R2')
117
- .option(
118
- "-f, --force",
119
- 'overwrite without confirmation; use "--force" to replace the bucket root without confirmation',
119
+ .argument(
120
+ "<paths...>",
121
+ 'source path(s) followed by a destination path; use "r2:<path>" for R2',
120
122
  )
121
- .action(async (source, destination, options) => {
123
+ .option("-r, --recursive", "copy directories recursively")
124
+ .option("-R", "copy directories recursively")
125
+ .option("-a, --archive", "copy directories recursively")
126
+ .option("-f, --force", "overwrite existing files without confirmation")
127
+ .option("-n, --no-clobber", "skip existing destination files")
128
+ .option("--progress", "show operation progress")
129
+ .action(async (paths, options) => {
122
130
  try {
123
- options.longForce = hasLongForceFlag()
124
- await copyR2(source, destination, options)
131
+ options.recursive = options.recursive || options.R || options.archive
132
+ await copyR2(paths, options)
125
133
  } catch (error) {
126
134
  handleCommandError(error)
127
135
  }
@@ -130,12 +138,13 @@ r2Command
130
138
  r2Command
131
139
  .command("mv")
132
140
  .description("move or rename an R2 file or directory/prefix")
133
- .argument("<source>", "R2 source path")
134
- .argument("<destination>", "R2 destination path")
141
+ .argument("<paths...>", "R2 source path(s) followed by a destination path")
135
142
  .option("-f, --force", "overwrite existing destination without confirmation")
136
- .action(async (source, destination, options) => {
143
+ .option("-n, --no-clobber", "skip existing destination files")
144
+ .option("--progress", "show operation progress")
145
+ .action(async (paths, options) => {
137
146
  try {
138
- await moveR2(source, destination, options)
147
+ await moveR2(paths, options)
139
148
  } catch (error) {
140
149
  handleCommandError(error)
141
150
  }
package/src/r2.js CHANGED
@@ -1,6 +1,6 @@
1
1
  // @ts-check
2
2
 
3
- import { rm, 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,
@@ -9,7 +9,15 @@ import {
9
9
  stdout,
10
10
  } from "node:process"
11
11
 
12
- import { confirm, intro, isCancel, log, outro, text } from "@clack/prompts"
12
+ import {
13
+ confirm,
14
+ intro,
15
+ isCancel,
16
+ log,
17
+ outro,
18
+ progress,
19
+ text,
20
+ } from "@clack/prompts"
13
21
 
14
22
  import { CliError } from "./support/cli-error.js"
15
23
  import { cancelPrompt } from "./support/prompt-cancel.js"
@@ -31,13 +39,17 @@ const defaultRcloneOptions = {
31
39
  /**
32
40
  * @typedef {{
33
41
  * force?: boolean
34
- * longForce?: boolean
42
+ * noClobber?: boolean
43
+ * progress?: boolean
44
+ * recursive?: boolean
35
45
  * }} R2CopyOptions
36
46
  */
37
47
 
38
48
  /**
39
49
  * @typedef {{
40
50
  * force?: boolean
51
+ * noClobber?: boolean
52
+ * progress?: boolean
41
53
  * }} R2MoveOptions
42
54
  */
43
55
 
@@ -55,6 +67,7 @@ const defaultRcloneOptions = {
55
67
  * force?: boolean
56
68
  * recursive?: boolean
57
69
  * longForce?: boolean
70
+ * progress?: boolean
58
71
  * }} R2RemoveOptions
59
72
  */
60
73
 
@@ -65,6 +78,7 @@ const defaultRcloneOptions = {
65
78
  * isInteractive?: boolean
66
79
  * promptConfirm?: (message: string) => Promise<boolean>
67
80
  * promptText?: (message: string) => Promise<string>
81
+ * createR2Client?: typeof createR2Client
68
82
  * writeStdout?: (message: string) => void
69
83
  * }} R2Runtime
70
84
  */
@@ -85,297 +99,378 @@ export async function listR2(path, runtime = {}) {
85
99
  }
86
100
 
87
101
  /**
88
- * @param {string} path
102
+ * @param {string|string[]} paths
89
103
  * @param {R2RemoveOptions} options
90
104
  * @param {R2Runtime} [runtime]
91
105
  */
92
- export async function removeR2(path, options, runtime = {}) {
106
+ export async function removeR2(paths, options, runtime = {}) {
93
107
  intro("r2 rm")
94
- const r2Path = normalizeR2Path(path)
95
- const isBucketRoot = isR2BucketRootPath(path)
96
- if (isBucketRoot && !options.recursive) {
108
+ const removePaths = Array.isArray(paths) ? paths : [paths]
109
+ const rootPaths = removePaths.filter((path) => isR2BucketRootPath(path))
110
+ const hasBucketRoot = rootPaths.length > 0
111
+ if (removePaths.length === 0) {
112
+ throw new CliError("R2 deletion requires at least one path.")
113
+ }
114
+ if (hasBucketRoot && removePaths.length > 1) {
115
+ throw new CliError(
116
+ "Refusing to delete the R2 bucket root with other paths.",
117
+ )
118
+ }
119
+ if (hasBucketRoot && !options.recursive) {
97
120
  throw new CliError(
98
121
  'Refusing to delete the R2 bucket root without "--recursive".',
99
122
  )
100
123
  }
101
- if (!r2Path && !isBucketRoot) {
102
- throw new CliError("Refusing to delete the R2 bucket root.")
124
+
125
+ const r2Paths = removePaths.map((path) => normalizeR2Path(path))
126
+ for (const [index, r2Path] of r2Paths.entries()) {
127
+ if (!r2Path && !isR2BucketRootPath(removePaths[index])) {
128
+ throw new CliError("Refusing to delete the R2 bucket root.")
129
+ }
103
130
  }
104
131
 
105
132
  const interactive = getInteractive(runtime)
106
- if (isBucketRoot && options.force && !options.longForce && !interactive) {
133
+ if (hasBucketRoot && options.force && !options.longForce && !interactive) {
107
134
  throw new CliError(
108
135
  'Ignoring "-f" for R2 bucket-root cleanup. If you are sure you want to delete the entire R2 bucket, use "--force".',
109
136
  )
110
137
  }
111
- if (isBucketRoot && !interactive && !options.longForce) {
138
+ if (hasBucketRoot && !interactive && !options.longForce) {
112
139
  throw new CliError(
113
140
  'Cleaning the R2 bucket root requires interactive confirmation. If you are sure you want to delete the entire R2 bucket, use "--force".',
114
141
  )
115
142
  }
116
- if (isBucketRoot && options.force && !options.longForce) {
143
+ if (hasBucketRoot && options.force && !options.longForce) {
117
144
  log.warn(
118
145
  'Ignoring "-f" for R2 bucket-root cleanup. Use "--force" if you are sure you want to delete the entire R2 bucket.',
119
146
  )
120
147
  }
121
- if (!options.force && !interactive) {
122
- throw new CliError(
123
- 'R2 deletion requires confirmation. Re-run with "--force" in non-interactive environments.',
148
+ if (hasBucketRoot) {
149
+ log.warn(
150
+ "This will delete ALL files in the configured R2 bucket. This cannot be undone.",
124
151
  )
125
152
  }
126
153
 
127
- if (isBucketRoot) {
128
- log.warn(
129
- "This will delete ALL files in the configured R2 bucket. This cannot be undone.",
154
+ const client = createR2CommandClient(runtime)
155
+ const targets = []
156
+ for (const [index, r2Path] of r2Paths.entries()) {
157
+ const originalPath = removePaths[index]
158
+ const isBucketRoot = isR2BucketRootPath(originalPath)
159
+ const pathType = isBucketRoot
160
+ ? "directory"
161
+ : await getCopyPathType({ remote: true, path: r2Path }, client, runtime)
162
+
163
+ if (pathType === "missing") {
164
+ if (options.force) {
165
+ continue
166
+ }
167
+ throw new CliError(`R2 path "${r2Path}" does not exist.`)
168
+ }
169
+ if (pathType === "directory" && !options.recursive) {
170
+ throw new CliError(
171
+ `R2 path "${r2Path}" is a directory. Re-run with "--recursive" to delete directories.`,
172
+ )
173
+ }
174
+ targets.push({
175
+ isBucketRoot,
176
+ path: r2Path,
177
+ recursive: pathType === "directory",
178
+ })
179
+ }
180
+
181
+ if (targets.length > 0 && !options.force && !interactive) {
182
+ throw new CliError(
183
+ 'R2 deletion requires confirmation. Re-run with "--force" in non-interactive environments.',
130
184
  )
131
185
  }
132
186
 
133
- const shouldDelete = isBucketRoot
134
- ? options.longForce ||
135
- (await getPromptConfirm(runtime)(
136
- "Delete all files in the R2 bucket? [y/N] ",
137
- ))
138
- : options.force ||
139
- (await getPromptConfirm(runtime)(`Delete R2 path "${r2Path}"? [y/N] `))
187
+ for (const target of targets) {
188
+ const shouldDelete = target.isBucketRoot
189
+ ? options.longForce ||
190
+ (await getPromptConfirm(runtime)(
191
+ "Delete all files in the R2 bucket? [y/N] ",
192
+ ))
193
+ : options.force ||
194
+ (await getPromptConfirm(runtime)(
195
+ `Delete R2 path "${target.path}"? [y/N] `,
196
+ ))
140
197
 
141
- if (!shouldDelete) {
142
- cancelPrompt()
198
+ if (!shouldDelete) {
199
+ cancelPrompt()
200
+ }
143
201
  }
144
202
 
145
- const client = createR2CommandClient(runtime)
146
- await client.remove(r2Path, {
147
- recursive: options.recursive === true,
148
- rcloneOptions:
149
- options.recursive === true ? defaultRcloneOptions : undefined,
203
+ const removeProgress = createR2Progress({
204
+ enabled: options.progress === true && targets.length > 0,
205
+ label: "Deleting R2 paths",
206
+ max: targets.length,
207
+ success: "R2 delete complete.",
150
208
  })
209
+ try {
210
+ for (const target of targets) {
211
+ await client.remove(target.path, {
212
+ recursive: target.recursive,
213
+ rcloneOptions: target.recursive ? defaultRcloneOptions : undefined,
214
+ })
215
+ removeProgress.advance()
216
+ }
217
+ removeProgress.stop()
218
+ } catch (error) {
219
+ removeProgress.error("R2 delete failed.")
220
+ throw error
221
+ }
151
222
  outro("R2 delete complete.")
152
223
  }
153
224
 
154
225
  /**
155
- * @param {string} source
156
- * @param {string} destination
226
+ * @param {string|string[]} paths
157
227
  * @param {R2CopyOptions} [options]
158
228
  * @param {R2Runtime} [runtime]
159
229
  */
160
- export async function copyR2(source, destination, options = {}, runtime = {}) {
230
+ export async function copyR2(paths, options = {}, runtime = {}) {
161
231
  intro("r2 cp")
162
- const sourcePath = parseCopyPath(source)
232
+ const { sources, destination } = parseCopyArguments(paths)
233
+ if (options.force && options.noClobber) {
234
+ throw new CliError('Cannot use "--force" with "--no-clobber".')
235
+ }
236
+ const sourcePaths = sources.map((source) => parseCopyPath(source))
163
237
  const destinationPath = parseCopyPath(destination)
164
- if (!sourcePath.remote && !destinationPath.remote) {
238
+ if (
239
+ !destinationPath.remote &&
240
+ sourcePaths.every((sourcePath) => !sourcePath.remote)
241
+ ) {
165
242
  throw new CliError(
166
243
  `R2 copy requires at least one R2 path. Prefix R2 paths with "${remotePathPrefix}".`,
167
244
  )
168
245
  }
169
246
 
170
247
  const client = createR2CommandClient(runtime)
171
- const sourceType = await getCopyPathType(sourcePath, client, runtime)
172
- if (sourceType === "missing") {
173
- throw new CliError(`Copy source "${source}" does not exist.`)
174
- }
175
-
176
248
  const destinationType = await getCopyPathType(
177
249
  destinationPath,
178
250
  client,
179
251
  runtime,
180
252
  )
181
- const targetPath = getCopyTargetPath({
182
- destination: destinationPath,
183
- destinationType,
184
- runtime,
185
- source: sourcePath,
186
- sourceType,
187
- })
188
- const targetType = await getCopyPathType(targetPath, client, runtime)
189
- const shouldUseCopyTo =
190
- sourceType === "file" &&
191
- (targetType !== "directory" || isDirectoryLikeCopyPath(destinationPath))
253
+ if (
254
+ sourcePaths.length > 1 &&
255
+ destinationType !== "directory" &&
256
+ !isDirectoryLikeCopyPath(destinationPath)
257
+ ) {
258
+ throw new CliError(
259
+ "Copying multiple sources requires a directory destination.",
260
+ )
261
+ }
262
+
263
+ const operations = []
264
+ for (const [index, sourcePath] of sourcePaths.entries()) {
265
+ const source = sources[index]
266
+ const sourceType = await getCopyPathType(sourcePath, client, runtime)
267
+ if (sourceType === "missing") {
268
+ throw new CliError(`Copy source "${source}" does not exist.`)
269
+ }
270
+ if (sourceType === "directory" && !options.recursive) {
271
+ throw new CliError(
272
+ `Copy source "${source}" is a directory. Re-run with "--recursive" to copy directories.`,
273
+ )
274
+ }
275
+
276
+ const targetPath = getCopyTargetPath({
277
+ destination: destinationPath,
278
+ destinationType,
279
+ runtime,
280
+ source: sourcePath,
281
+ sourceType,
282
+ })
283
+ const targetType = await getCopyPathType(targetPath, client, runtime)
284
+ const shouldUseCopyTo =
285
+ sourceType === "file" &&
286
+ (targetType !== "directory" || isDirectoryLikeCopyPath(destinationPath))
192
287
 
193
- const confirmedRootOverwrite = await confirmCopyRootOverwrite({
194
- force: options.force === true,
195
- longForce: options.longForce === true,
196
- runtime,
197
- targetPath,
198
- targetType,
199
- })
200
- if (!confirmedRootOverwrite) {
201
288
  await confirmCopyOverwrite({
289
+ client,
202
290
  destination,
203
291
  force: options.force === true,
292
+ noClobber: options.noClobber === true,
204
293
  runtime,
294
+ sourcePath,
205
295
  sourceType,
206
296
  targetPath,
207
297
  targetType,
208
298
  })
299
+
300
+ operations.push({ shouldUseCopyTo, sourcePath, targetPath })
209
301
  }
210
- await replaceCopyTargetBeforeCopy({
211
- client,
212
- rcloneOptions: defaultRcloneOptions,
213
- runtime,
214
- sourceType,
215
- targetPath,
216
- targetType,
217
- })
218
302
 
219
- await client.copy(
220
- await toRcloneCopyPath(sourcePath, client, runtime),
221
- await toRcloneCopyPath(targetPath, client, runtime),
222
- { rcloneOptions: defaultRcloneOptions, to: shouldUseCopyTo },
223
- )
303
+ const copyProgress = createR2Progress({
304
+ enabled: options.progress === true,
305
+ label: "Copying R2 paths",
306
+ max: operations.length,
307
+ success: "R2 copy complete.",
308
+ })
309
+ try {
310
+ for (const operation of operations) {
311
+ await client.copy(
312
+ await toRcloneCopyPath(operation.sourcePath, client, runtime),
313
+ await toRcloneCopyPath(operation.targetPath, client, runtime),
314
+ {
315
+ ignoreExisting: options.noClobber === true,
316
+ rcloneOptions: defaultRcloneOptions,
317
+ to: operation.shouldUseCopyTo,
318
+ },
319
+ )
320
+ copyProgress.advance()
321
+ }
322
+ copyProgress.stop()
323
+ } catch (error) {
324
+ copyProgress.error("R2 copy failed.")
325
+ throw error
326
+ }
224
327
  outro("R2 copy complete.")
225
328
  }
226
329
 
227
330
  /**
228
- * @param {string} source
229
- * @param {string} destination
331
+ * @param {string|string[]} paths
230
332
  * @param {R2MoveOptions} options
231
333
  * @param {R2Runtime} [runtime]
232
334
  */
233
- export async function moveR2(source, destination, options, runtime = {}) {
335
+ export async function moveR2(paths, options = {}, runtime = {}) {
234
336
  intro("r2 mv")
235
- const sourcePath = parseR2OnlyPath(source, "Move source")
337
+ const { sources, destination } = parseCopyArguments(paths)
338
+ if (options.force && options.noClobber) {
339
+ throw new CliError('Cannot use "--force" with "--no-clobber".')
340
+ }
341
+ const sourcePaths = sources.map((source) =>
342
+ parseR2OnlyPath(source, "Move source"),
343
+ )
236
344
  const destinationPath = parseR2OnlyPath(destination, "Move destination")
237
- if (isR2BucketRootPath(source) || isR2BucketRootPath(destination)) {
345
+ if (
346
+ sources.some((source) => isR2BucketRootPath(source)) ||
347
+ isR2BucketRootPath(destination)
348
+ ) {
238
349
  throw new CliError("Refusing to move the R2 bucket root.")
239
350
  }
240
351
 
241
352
  const client = createR2CommandClient(runtime)
242
- const sourceType = await getCopyPathType(sourcePath, client, runtime)
243
- if (sourceType === "missing") {
244
- throw new CliError(`Move source "${source}" does not exist.`)
245
- }
246
353
  const destinationType = await getCopyPathType(
247
354
  destinationPath,
248
355
  client,
249
356
  runtime,
250
357
  )
251
- const targetPath = getCopyTargetPath({
252
- destination: destinationPath,
253
- destinationType,
254
- runtime,
255
- source: sourcePath,
256
- sourceType,
257
- })
258
- const targetType = await getCopyPathType(targetPath, client, runtime)
259
- const shouldUseMoveTo =
260
- sourceType === "file" &&
261
- (targetType !== "directory" || isDirectoryLikeCopyPath(destinationPath))
358
+ if (
359
+ sourcePaths.length > 1 &&
360
+ destinationType !== "directory" &&
361
+ !isDirectoryLikeCopyPath(destinationPath)
362
+ ) {
363
+ throw new CliError(
364
+ "Moving multiple sources requires a directory destination.",
365
+ )
366
+ }
262
367
 
263
- await confirmCopyOverwrite({
264
- destination,
265
- force: options.force === true,
266
- runtime,
267
- sourceType,
268
- targetPath,
269
- targetType,
270
- })
271
- await replaceCopyTargetBeforeCopy({
272
- client,
273
- rcloneOptions: defaultRcloneOptions,
274
- runtime,
275
- sourceType,
276
- targetPath,
277
- targetType,
278
- })
368
+ const operations = []
369
+ for (const [index, sourcePath] of sourcePaths.entries()) {
370
+ const source = sources[index]
371
+ const sourceType = await getCopyPathType(sourcePath, client, runtime)
372
+ if (sourceType === "missing") {
373
+ throw new CliError(`Move source "${source}" does not exist.`)
374
+ }
279
375
 
280
- await client.move(
281
- await toRcloneCopyPath(sourcePath, client, runtime),
282
- await toRcloneCopyPath(targetPath, client, runtime),
283
- { rcloneOptions: defaultRcloneOptions, to: shouldUseMoveTo },
284
- )
285
- outro("R2 move complete.")
286
- }
376
+ const targetPath = getCopyTargetPath({
377
+ destination: destinationPath,
378
+ destinationType,
379
+ runtime,
380
+ source: sourcePath,
381
+ sourceType,
382
+ })
383
+ const targetType = await getCopyPathType(targetPath, client, runtime)
384
+ const shouldUseMoveTo =
385
+ sourceType === "file" &&
386
+ (targetType !== "directory" || isDirectoryLikeCopyPath(destinationPath))
287
387
 
288
- /**
289
- * @param {{
290
- * client: ReturnType<typeof createR2CommandClient>,
291
- * rcloneOptions?: RcloneOptions,
292
- * runtime: R2Runtime,
293
- * sourceType: CopyPathType,
294
- * targetPath: CopyPath,
295
- * targetType: CopyPathType
296
- * }} args
297
- */
298
- async function replaceCopyTargetBeforeCopy({
299
- client,
300
- rcloneOptions,
301
- runtime,
302
- sourceType,
303
- targetPath,
304
- targetType,
305
- }) {
306
- if (sourceType !== "directory" || targetType !== "directory") {
307
- return
308
- }
309
- if (targetPath.remote) {
310
- await client.remove(targetPath.path, { recursive: true, rcloneOptions })
311
- return
312
- }
313
- await rm(resolveLocalPath(targetPath.path, runtime), {
314
- force: true,
315
- recursive: true,
316
- })
317
- }
388
+ await confirmCopyOverwrite({
389
+ client,
390
+ destination,
391
+ force: options.force === true,
392
+ noClobber: options.noClobber === true,
393
+ runtime,
394
+ sourcePath,
395
+ sourceType,
396
+ targetPath,
397
+ targetType,
398
+ })
318
399
 
319
- /**
320
- * @param {{
321
- * force: boolean,
322
- * longForce: boolean,
323
- * runtime: R2Runtime,
324
- * targetPath: CopyPath,
325
- * targetType: CopyPathType
326
- * }} args
327
- */
328
- async function confirmCopyRootOverwrite({
329
- force,
330
- longForce,
331
- runtime,
332
- targetPath,
333
- targetType,
334
- }) {
335
- if (!isR2BucketRootCopyPath(targetPath) || targetType === "missing") {
336
- return false
337
- }
338
- log.warn(
339
- "This will replace ALL files in the configured R2 bucket. This cannot be undone.",
340
- )
341
- if (longForce) {
342
- return true
343
- }
344
- if (force && !getInteractive(runtime)) {
345
- throw new CliError(
346
- 'Ignoring "-f" for R2 bucket-root replacement. If you are sure you want to replace the entire R2 bucket, use "--force".',
347
- )
348
- }
349
- if (!getInteractive(runtime)) {
350
- throw new CliError(
351
- 'Replacing the R2 bucket root requires interactive confirmation. If you are sure you want to replace the entire R2 bucket, use "--force".',
352
- )
353
- }
354
- if (force) {
355
- log.warn(
356
- 'Ignoring "-f" for R2 bucket-root replacement. Use "--force" if you are sure you want to replace the entire R2 bucket.',
357
- )
400
+ operations.push({ shouldUseMoveTo, sourcePath, targetPath })
358
401
  }
359
- const shouldOverwrite = await getPromptConfirm(runtime)(
360
- "Replace all files in the R2 bucket? [y/N] ",
361
- )
362
- if (!shouldOverwrite) {
363
- cancelPrompt()
402
+
403
+ const moveProgress = createR2Progress({
404
+ enabled: options.progress === true,
405
+ label: "Moving R2 paths",
406
+ max: operations.length,
407
+ success: "R2 move complete.",
408
+ })
409
+ try {
410
+ for (const operation of operations) {
411
+ await client.move(
412
+ await toRcloneCopyPath(operation.sourcePath, client, runtime),
413
+ await toRcloneCopyPath(operation.targetPath, client, runtime),
414
+ {
415
+ ignoreExisting: options.noClobber === true,
416
+ rcloneOptions: defaultRcloneOptions,
417
+ to: operation.shouldUseMoveTo,
418
+ },
419
+ )
420
+ moveProgress.advance()
421
+ }
422
+ moveProgress.stop()
423
+ } catch (error) {
424
+ moveProgress.error("R2 move failed.")
425
+ throw error
364
426
  }
365
- return true
427
+ outro("R2 move complete.")
366
428
  }
367
429
 
368
430
  /**
369
431
  * @param {R2Runtime} runtime
370
432
  */
371
433
  function createR2CommandClient(runtime) {
372
- return createR2Client({
434
+ return (runtime.createR2Client ?? createR2Client)({
373
435
  cwd: runtime.cwd ?? processCwd(),
374
436
  interactive: getInteractive(runtime),
375
437
  promptText: getPromptText(runtime),
376
438
  })
377
439
  }
378
440
 
441
+ /**
442
+ * @param {{enabled: boolean, label: string, max: number, success: string}} options
443
+ */
444
+ function createR2Progress({ enabled, label, max, success }) {
445
+ if (!enabled || max === 0) {
446
+ return {
447
+ advance() {},
448
+ error() {},
449
+ stop() {},
450
+ }
451
+ }
452
+
453
+ const operationProgress = progress({ max })
454
+ let completed = 0
455
+ operationProgress.start(`${label} (0/${max})`)
456
+
457
+ return {
458
+ advance() {
459
+ completed += 1
460
+ operationProgress.advance(1, `${label} (${completed}/${max})`)
461
+ },
462
+ /**
463
+ * @param {string} message
464
+ */
465
+ error(message) {
466
+ operationProgress.error(message)
467
+ },
468
+ stop() {
469
+ operationProgress.stop(success)
470
+ },
471
+ }
472
+ }
473
+
379
474
  /**
380
475
  * @param {R2Runtime} runtime
381
476
  */
@@ -407,6 +502,23 @@ function parseCopyPath(value) {
407
502
  return { remote: true, path: normalizeR2Path(value) }
408
503
  }
409
504
 
505
+ /**
506
+ * @param {string|string[]} paths
507
+ * @returns {{sources: string[], destination: string}}
508
+ */
509
+ function parseCopyArguments(paths) {
510
+ const copyPaths = Array.isArray(paths) ? paths : [paths]
511
+ if (copyPaths.length < 2) {
512
+ throw new CliError(
513
+ "R2 copy requires at least one source and a destination.",
514
+ )
515
+ }
516
+ return {
517
+ sources: copyPaths.slice(0, -1),
518
+ destination: copyPaths[copyPaths.length - 1],
519
+ }
520
+ }
521
+
410
522
  /**
411
523
  * @param {string} value
412
524
  * @param {string} label
@@ -443,6 +555,18 @@ function getCopyTargetPath({
443
555
  if (destinationType === "file") {
444
556
  throw new CliError("Cannot copy a directory to an existing file.")
445
557
  }
558
+ if (isCopyPathContentsOnly(source)) {
559
+ return destination
560
+ }
561
+ if (
562
+ destinationType === "directory" ||
563
+ isDirectoryLikeCopyPath(destination)
564
+ ) {
565
+ return {
566
+ ...destination,
567
+ path: joinCopyPath(destination, getCopyPathName(source, runtime)),
568
+ }
569
+ }
446
570
  return destination
447
571
  }
448
572
 
@@ -461,18 +585,24 @@ function getCopyTargetPath({
461
585
 
462
586
  /**
463
587
  * @param {{
588
+ * client: ReturnType<typeof createR2CommandClient>,
464
589
  * destination: string,
465
590
  * force: boolean,
591
+ * noClobber: boolean,
466
592
  * runtime: R2Runtime,
593
+ * sourcePath: CopyPath,
467
594
  * sourceType: CopyPathType,
468
595
  * targetPath: CopyPath,
469
596
  * targetType: CopyPathType
470
597
  * }} args
471
598
  */
472
599
  async function confirmCopyOverwrite({
600
+ client,
473
601
  destination,
474
602
  force,
603
+ noClobber,
475
604
  runtime,
605
+ sourcePath,
476
606
  sourceType,
477
607
  targetPath,
478
608
  targetType,
@@ -480,6 +610,9 @@ async function confirmCopyOverwrite({
480
610
  if (targetType === "missing") {
481
611
  return
482
612
  }
613
+ if (sourceType === "directory" && targetType === "file") {
614
+ throw new CliError("Cannot transfer a directory to an existing file.")
615
+ }
483
616
  if (
484
617
  sourceType === "file" &&
485
618
  targetType === "directory" &&
@@ -490,19 +623,128 @@ async function confirmCopyOverwrite({
490
623
  if (force) {
491
624
  return
492
625
  }
626
+ if (noClobber) {
627
+ return
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
+ }
493
641
  if (!getInteractive(runtime)) {
494
642
  throw new CliError(
495
- 'Copy would overwrite existing files. Re-run with "--force" in non-interactive environments.',
643
+ 'Transfer would overwrite existing files. Re-run with "--force" in non-interactive environments.',
496
644
  )
497
645
  }
498
- const shouldOverwrite = await getPromptConfirm(runtime)(
499
- `Overwrite existing destination "${destination}"? [y/N] `,
500
- )
646
+ log.warn(`Files to overwrite (${overwrittenPaths.length})`)
647
+ for (const overwrittenPath of overwrittenPaths) {
648
+ log.message(`• ${overwrittenPath}`)
649
+ }
650
+ const message =
651
+ sourceType === "directory"
652
+ ? `Overwrite any existing files under destination "${destination}"? [y/N] `
653
+ : `Overwrite existing destination "${destination}"? [y/N] `
654
+ const shouldOverwrite = await getPromptConfirm(runtime)(message)
501
655
  if (!shouldOverwrite) {
502
656
  cancelPrompt()
503
657
  }
504
658
  }
505
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
+
506
748
  /**
507
749
  * @param {CopyPath} copyPath
508
750
  * @param {ReturnType<typeof createR2CommandClient>} client
@@ -542,6 +784,16 @@ function isDirectoryLikeCopyPath(copyPath) {
542
784
  return copyPath.path === "." || copyPath.path.endsWith("/")
543
785
  }
544
786
 
787
+ /**
788
+ * @param {CopyPath} copyPath
789
+ */
790
+ function isCopyPathContentsOnly(copyPath) {
791
+ const path = copyPath.remote
792
+ ? copyPath.path
793
+ : copyPath.path.replaceAll("\\", "/")
794
+ return path === "." || path.endsWith("/.")
795
+ }
796
+
545
797
  /**
546
798
  * @param {CopyPath} parent
547
799
  * @param {string} child
@@ -592,13 +844,6 @@ function isR2BucketRootPath(path) {
592
844
  return path.trim() === "/"
593
845
  }
594
846
 
595
- /**
596
- * @param {CopyPath} path
597
- */
598
- function isR2BucketRootCopyPath(path) {
599
- return path.remote && path.path === ""
600
- }
601
-
602
847
  /**
603
848
  * @param {unknown} error
604
849
  */
package/src/support/r2.js CHANGED
@@ -248,12 +248,17 @@ export function createR2Client({ cwd, interactive, promptText }) {
248
248
  /**
249
249
  * @param {string} source
250
250
  * @param {string} destination
251
- * @param {{to?: boolean, rcloneOptions?: RcloneOptions}} [options]
251
+ * @param {{ignoreExisting?: boolean, to?: boolean, rcloneOptions?: RcloneOptions}} [options]
252
252
  */
253
253
  async copy(source, destination, options = {}) {
254
254
  const r2Config = await getConfig()
255
255
  await runConfiguredRclone({
256
- args: [options.to ? "copyto" : "copy", source, destination],
256
+ args: [
257
+ options.to ? "copyto" : "copy",
258
+ source,
259
+ destination,
260
+ ...(options.ignoreExisting ? ["--ignore-existing"] : []),
261
+ ],
257
262
  cwd,
258
263
  config: r2Config,
259
264
  rcloneOptions: options.rcloneOptions,
@@ -263,12 +268,17 @@ export function createR2Client({ cwd, interactive, promptText }) {
263
268
  /**
264
269
  * @param {string} source
265
270
  * @param {string} destination
266
- * @param {{to?: boolean, rcloneOptions?: RcloneOptions}} [options]
271
+ * @param {{ignoreExisting?: boolean, to?: boolean, rcloneOptions?: RcloneOptions}} [options]
267
272
  */
268
273
  async move(source, destination, options = {}) {
269
274
  const r2Config = await getConfig()
270
275
  await runConfiguredRclone({
271
- args: [options.to ? "moveto" : "move", source, destination],
276
+ args: [
277
+ options.to ? "moveto" : "move",
278
+ source,
279
+ destination,
280
+ ...(options.ignoreExisting ? ["--ignore-existing"] : []),
281
+ ],
272
282
  cwd,
273
283
  config: r2Config,
274
284
  rcloneOptions: options.rcloneOptions,