@lightnet/cli 4.6.2 → 4.7.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,17 @@
1
1
  # @lightnet/cli
2
2
 
3
+ ## 4.7.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [#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`.
8
+
9
+ - [#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.
10
+
11
+ - [#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.
12
+
13
+ - [#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.
14
+
3
15
  ## 4.6.2
4
16
 
5
17
  ### 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.0",
7
7
  "publishConfig": {
8
8
  "access": "public"
9
9
  },
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 { 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
 
@@ -85,284 +98,328 @@ export async function listR2(path, runtime = {}) {
85
98
  }
86
99
 
87
100
  /**
88
- * @param {string} path
101
+ * @param {string|string[]} paths
89
102
  * @param {R2RemoveOptions} options
90
103
  * @param {R2Runtime} [runtime]
91
104
  */
92
- export async function removeR2(path, options, runtime = {}) {
105
+ export async function removeR2(paths, options, runtime = {}) {
93
106
  intro("r2 rm")
94
- const r2Path = normalizeR2Path(path)
95
- const isBucketRoot = isR2BucketRootPath(path)
96
- if (isBucketRoot && !options.recursive) {
107
+ const removePaths = Array.isArray(paths) ? paths : [paths]
108
+ const rootPaths = removePaths.filter((path) => isR2BucketRootPath(path))
109
+ const hasBucketRoot = rootPaths.length > 0
110
+ if (removePaths.length === 0) {
111
+ throw new CliError("R2 deletion requires at least one path.")
112
+ }
113
+ if (hasBucketRoot && removePaths.length > 1) {
114
+ throw new CliError(
115
+ "Refusing to delete the R2 bucket root with other paths.",
116
+ )
117
+ }
118
+ if (hasBucketRoot && !options.recursive) {
97
119
  throw new CliError(
98
120
  'Refusing to delete the R2 bucket root without "--recursive".',
99
121
  )
100
122
  }
101
- if (!r2Path && !isBucketRoot) {
102
- throw new CliError("Refusing to delete the R2 bucket root.")
123
+
124
+ const r2Paths = removePaths.map((path) => normalizeR2Path(path))
125
+ for (const [index, r2Path] of r2Paths.entries()) {
126
+ if (!r2Path && !isR2BucketRootPath(removePaths[index])) {
127
+ throw new CliError("Refusing to delete the R2 bucket root.")
128
+ }
103
129
  }
104
130
 
105
131
  const interactive = getInteractive(runtime)
106
- if (isBucketRoot && options.force && !options.longForce && !interactive) {
132
+ if (hasBucketRoot && options.force && !options.longForce && !interactive) {
107
133
  throw new CliError(
108
134
  'Ignoring "-f" for R2 bucket-root cleanup. If you are sure you want to delete the entire R2 bucket, use "--force".',
109
135
  )
110
136
  }
111
- if (isBucketRoot && !interactive && !options.longForce) {
137
+ if (hasBucketRoot && !interactive && !options.longForce) {
112
138
  throw new CliError(
113
139
  'Cleaning the R2 bucket root requires interactive confirmation. If you are sure you want to delete the entire R2 bucket, use "--force".',
114
140
  )
115
141
  }
116
- if (isBucketRoot && options.force && !options.longForce) {
142
+ if (hasBucketRoot && options.force && !options.longForce) {
117
143
  log.warn(
118
144
  'Ignoring "-f" for R2 bucket-root cleanup. Use "--force" if you are sure you want to delete the entire R2 bucket.',
119
145
  )
120
146
  }
121
- if (!options.force && !interactive) {
122
- throw new CliError(
123
- 'R2 deletion requires confirmation. Re-run with "--force" in non-interactive environments.',
147
+ if (hasBucketRoot) {
148
+ log.warn(
149
+ "This will delete ALL files in the configured R2 bucket. This cannot be undone.",
124
150
  )
125
151
  }
126
152
 
127
- if (isBucketRoot) {
128
- log.warn(
129
- "This will delete ALL files in the configured R2 bucket. This cannot be undone.",
153
+ const client = createR2CommandClient(runtime)
154
+ const targets = []
155
+ for (const [index, r2Path] of r2Paths.entries()) {
156
+ const originalPath = removePaths[index]
157
+ const isBucketRoot = isR2BucketRootPath(originalPath)
158
+ const pathType = isBucketRoot
159
+ ? "directory"
160
+ : await getCopyPathType({ remote: true, path: r2Path }, client, runtime)
161
+
162
+ if (pathType === "missing") {
163
+ if (options.force) {
164
+ continue
165
+ }
166
+ throw new CliError(`R2 path "${r2Path}" does not exist.`)
167
+ }
168
+ if (pathType === "directory" && !options.recursive) {
169
+ throw new CliError(
170
+ `R2 path "${r2Path}" is a directory. Re-run with "--recursive" to delete directories.`,
171
+ )
172
+ }
173
+ targets.push({
174
+ isBucketRoot,
175
+ path: r2Path,
176
+ recursive: pathType === "directory",
177
+ })
178
+ }
179
+
180
+ if (targets.length > 0 && !options.force && !interactive) {
181
+ throw new CliError(
182
+ 'R2 deletion requires confirmation. Re-run with "--force" in non-interactive environments.',
130
183
  )
131
184
  }
132
185
 
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] `))
186
+ for (const target of targets) {
187
+ const shouldDelete = target.isBucketRoot
188
+ ? options.longForce ||
189
+ (await getPromptConfirm(runtime)(
190
+ "Delete all files in the R2 bucket? [y/N] ",
191
+ ))
192
+ : options.force ||
193
+ (await getPromptConfirm(runtime)(
194
+ `Delete R2 path "${target.path}"? [y/N] `,
195
+ ))
140
196
 
141
- if (!shouldDelete) {
142
- cancelPrompt()
197
+ if (!shouldDelete) {
198
+ cancelPrompt()
199
+ }
143
200
  }
144
201
 
145
- const client = createR2CommandClient(runtime)
146
- await client.remove(r2Path, {
147
- recursive: options.recursive === true,
148
- rcloneOptions:
149
- options.recursive === true ? defaultRcloneOptions : undefined,
202
+ const removeProgress = createR2Progress({
203
+ enabled: options.progress === true && targets.length > 0,
204
+ label: "Deleting R2 paths",
205
+ max: targets.length,
206
+ success: "R2 delete complete.",
150
207
  })
208
+ try {
209
+ for (const target of targets) {
210
+ await client.remove(target.path, {
211
+ recursive: target.recursive,
212
+ rcloneOptions: target.recursive ? defaultRcloneOptions : undefined,
213
+ })
214
+ removeProgress.advance()
215
+ }
216
+ removeProgress.stop()
217
+ } catch (error) {
218
+ removeProgress.error("R2 delete failed.")
219
+ throw error
220
+ }
151
221
  outro("R2 delete complete.")
152
222
  }
153
223
 
154
224
  /**
155
- * @param {string} source
156
- * @param {string} destination
225
+ * @param {string|string[]} paths
157
226
  * @param {R2CopyOptions} [options]
158
227
  * @param {R2Runtime} [runtime]
159
228
  */
160
- export async function copyR2(source, destination, options = {}, runtime = {}) {
229
+ export async function copyR2(paths, options = {}, runtime = {}) {
161
230
  intro("r2 cp")
162
- const sourcePath = parseCopyPath(source)
231
+ const { sources, destination } = parseCopyArguments(paths)
232
+ if (options.force && options.noClobber) {
233
+ throw new CliError('Cannot use "--force" with "--no-clobber".')
234
+ }
235
+ const sourcePaths = sources.map((source) => parseCopyPath(source))
163
236
  const destinationPath = parseCopyPath(destination)
164
- if (!sourcePath.remote && !destinationPath.remote) {
237
+ if (
238
+ !destinationPath.remote &&
239
+ sourcePaths.every((sourcePath) => !sourcePath.remote)
240
+ ) {
165
241
  throw new CliError(
166
242
  `R2 copy requires at least one R2 path. Prefix R2 paths with "${remotePathPrefix}".`,
167
243
  )
168
244
  }
169
245
 
170
246
  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
247
  const destinationType = await getCopyPathType(
177
248
  destinationPath,
178
249
  client,
179
250
  runtime,
180
251
  )
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))
252
+ if (
253
+ sourcePaths.length > 1 &&
254
+ destinationType !== "directory" &&
255
+ !isDirectoryLikeCopyPath(destinationPath)
256
+ ) {
257
+ throw new CliError(
258
+ "Copying multiple sources requires a directory destination.",
259
+ )
260
+ }
261
+
262
+ const operations = []
263
+ for (const [index, sourcePath] of sourcePaths.entries()) {
264
+ const source = sources[index]
265
+ const sourceType = await getCopyPathType(sourcePath, client, runtime)
266
+ if (sourceType === "missing") {
267
+ throw new CliError(`Copy source "${source}" does not exist.`)
268
+ }
269
+ if (sourceType === "directory" && !options.recursive) {
270
+ throw new CliError(
271
+ `Copy source "${source}" is a directory. Re-run with "--recursive" to copy directories.`,
272
+ )
273
+ }
274
+
275
+ const targetPath = getCopyTargetPath({
276
+ destination: destinationPath,
277
+ destinationType,
278
+ runtime,
279
+ source: sourcePath,
280
+ sourceType,
281
+ })
282
+ const targetType = await getCopyPathType(targetPath, client, runtime)
283
+ const shouldUseCopyTo =
284
+ sourceType === "file" &&
285
+ (targetType !== "directory" || isDirectoryLikeCopyPath(destinationPath))
192
286
 
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
287
  await confirmCopyOverwrite({
202
288
  destination,
203
289
  force: options.force === true,
290
+ noClobber: options.noClobber === true,
204
291
  runtime,
205
292
  sourceType,
206
293
  targetPath,
207
294
  targetType,
208
295
  })
296
+
297
+ operations.push({ shouldUseCopyTo, sourcePath, targetPath })
209
298
  }
210
- await replaceCopyTargetBeforeCopy({
211
- client,
212
- rcloneOptions: defaultRcloneOptions,
213
- runtime,
214
- sourceType,
215
- targetPath,
216
- targetType,
217
- })
218
299
 
219
- await client.copy(
220
- await toRcloneCopyPath(sourcePath, client, runtime),
221
- await toRcloneCopyPath(targetPath, client, runtime),
222
- { rcloneOptions: defaultRcloneOptions, to: shouldUseCopyTo },
223
- )
300
+ const copyProgress = createR2Progress({
301
+ enabled: options.progress === true,
302
+ label: "Copying R2 paths",
303
+ max: operations.length,
304
+ success: "R2 copy complete.",
305
+ })
306
+ try {
307
+ for (const operation of operations) {
308
+ await client.copy(
309
+ await toRcloneCopyPath(operation.sourcePath, client, runtime),
310
+ await toRcloneCopyPath(operation.targetPath, client, runtime),
311
+ {
312
+ ignoreExisting: options.noClobber === true,
313
+ rcloneOptions: defaultRcloneOptions,
314
+ to: operation.shouldUseCopyTo,
315
+ },
316
+ )
317
+ copyProgress.advance()
318
+ }
319
+ copyProgress.stop()
320
+ } catch (error) {
321
+ copyProgress.error("R2 copy failed.")
322
+ throw error
323
+ }
224
324
  outro("R2 copy complete.")
225
325
  }
226
326
 
227
327
  /**
228
- * @param {string} source
229
- * @param {string} destination
328
+ * @param {string|string[]} paths
230
329
  * @param {R2MoveOptions} options
231
330
  * @param {R2Runtime} [runtime]
232
331
  */
233
- export async function moveR2(source, destination, options, runtime = {}) {
332
+ export async function moveR2(paths, options = {}, runtime = {}) {
234
333
  intro("r2 mv")
235
- const sourcePath = parseR2OnlyPath(source, "Move source")
334
+ const { sources, destination } = parseCopyArguments(paths)
335
+ if (options.force && options.noClobber) {
336
+ throw new CliError('Cannot use "--force" with "--no-clobber".')
337
+ }
338
+ const sourcePaths = sources.map((source) =>
339
+ parseR2OnlyPath(source, "Move source"),
340
+ )
236
341
  const destinationPath = parseR2OnlyPath(destination, "Move destination")
237
- if (isR2BucketRootPath(source) || isR2BucketRootPath(destination)) {
342
+ if (
343
+ sources.some((source) => isR2BucketRootPath(source)) ||
344
+ isR2BucketRootPath(destination)
345
+ ) {
238
346
  throw new CliError("Refusing to move the R2 bucket root.")
239
347
  }
240
348
 
241
349
  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
350
  const destinationType = await getCopyPathType(
247
351
  destinationPath,
248
352
  client,
249
353
  runtime,
250
354
  )
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))
355
+ if (
356
+ sourcePaths.length > 1 &&
357
+ destinationType !== "directory" &&
358
+ !isDirectoryLikeCopyPath(destinationPath)
359
+ ) {
360
+ throw new CliError(
361
+ "Moving multiple sources requires a directory destination.",
362
+ )
363
+ }
262
364
 
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
- })
365
+ const operations = []
366
+ for (const [index, sourcePath] of sourcePaths.entries()) {
367
+ const source = sources[index]
368
+ const sourceType = await getCopyPathType(sourcePath, client, runtime)
369
+ if (sourceType === "missing") {
370
+ throw new CliError(`Move source "${source}" does not exist.`)
371
+ }
279
372
 
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
- }
373
+ const targetPath = getCopyTargetPath({
374
+ destination: destinationPath,
375
+ destinationType,
376
+ runtime,
377
+ source: sourcePath,
378
+ sourceType,
379
+ })
380
+ const targetType = await getCopyPathType(targetPath, client, runtime)
381
+ const shouldUseMoveTo =
382
+ sourceType === "file" &&
383
+ (targetType !== "directory" || isDirectoryLikeCopyPath(destinationPath))
287
384
 
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
- }
385
+ await confirmCopyOverwrite({
386
+ destination,
387
+ force: options.force === true,
388
+ noClobber: options.noClobber === true,
389
+ runtime,
390
+ sourceType,
391
+ targetPath,
392
+ targetType,
393
+ })
318
394
 
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
- )
395
+ operations.push({ shouldUseMoveTo, sourcePath, targetPath })
353
396
  }
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
- )
358
- }
359
- const shouldOverwrite = await getPromptConfirm(runtime)(
360
- "Replace all files in the R2 bucket? [y/N] ",
361
- )
362
- if (!shouldOverwrite) {
363
- cancelPrompt()
397
+
398
+ const moveProgress = createR2Progress({
399
+ enabled: options.progress === true,
400
+ label: "Moving R2 paths",
401
+ max: operations.length,
402
+ success: "R2 move complete.",
403
+ })
404
+ try {
405
+ for (const operation of operations) {
406
+ await client.move(
407
+ await toRcloneCopyPath(operation.sourcePath, client, runtime),
408
+ await toRcloneCopyPath(operation.targetPath, client, runtime),
409
+ {
410
+ ignoreExisting: options.noClobber === true,
411
+ rcloneOptions: defaultRcloneOptions,
412
+ to: operation.shouldUseMoveTo,
413
+ },
414
+ )
415
+ moveProgress.advance()
416
+ }
417
+ moveProgress.stop()
418
+ } catch (error) {
419
+ moveProgress.error("R2 move failed.")
420
+ throw error
364
421
  }
365
- return true
422
+ outro("R2 move complete.")
366
423
  }
367
424
 
368
425
  /**
@@ -376,6 +433,39 @@ function createR2CommandClient(runtime) {
376
433
  })
377
434
  }
378
435
 
436
+ /**
437
+ * @param {{enabled: boolean, label: string, max: number, success: string}} options
438
+ */
439
+ function createR2Progress({ enabled, label, max, success }) {
440
+ if (!enabled || max === 0) {
441
+ return {
442
+ advance() {},
443
+ error() {},
444
+ stop() {},
445
+ }
446
+ }
447
+
448
+ const operationProgress = progress({ max })
449
+ let completed = 0
450
+ operationProgress.start(`${label} (0/${max})`)
451
+
452
+ return {
453
+ advance() {
454
+ completed += 1
455
+ operationProgress.advance(1, `${label} (${completed}/${max})`)
456
+ },
457
+ /**
458
+ * @param {string} message
459
+ */
460
+ error(message) {
461
+ operationProgress.error(message)
462
+ },
463
+ stop() {
464
+ operationProgress.stop(success)
465
+ },
466
+ }
467
+ }
468
+
379
469
  /**
380
470
  * @param {R2Runtime} runtime
381
471
  */
@@ -407,6 +497,23 @@ function parseCopyPath(value) {
407
497
  return { remote: true, path: normalizeR2Path(value) }
408
498
  }
409
499
 
500
+ /**
501
+ * @param {string|string[]} paths
502
+ * @returns {{sources: string[], destination: string}}
503
+ */
504
+ function parseCopyArguments(paths) {
505
+ const copyPaths = Array.isArray(paths) ? paths : [paths]
506
+ if (copyPaths.length < 2) {
507
+ throw new CliError(
508
+ "R2 copy requires at least one source and a destination.",
509
+ )
510
+ }
511
+ return {
512
+ sources: copyPaths.slice(0, -1),
513
+ destination: copyPaths[copyPaths.length - 1],
514
+ }
515
+ }
516
+
410
517
  /**
411
518
  * @param {string} value
412
519
  * @param {string} label
@@ -443,6 +550,18 @@ function getCopyTargetPath({
443
550
  if (destinationType === "file") {
444
551
  throw new CliError("Cannot copy a directory to an existing file.")
445
552
  }
553
+ if (isCopyPathContentsOnly(source)) {
554
+ return destination
555
+ }
556
+ if (
557
+ destinationType === "directory" ||
558
+ isDirectoryLikeCopyPath(destination)
559
+ ) {
560
+ return {
561
+ ...destination,
562
+ path: joinCopyPath(destination, getCopyPathName(source, runtime)),
563
+ }
564
+ }
446
565
  return destination
447
566
  }
448
567
 
@@ -463,6 +582,7 @@ function getCopyTargetPath({
463
582
  * @param {{
464
583
  * destination: string,
465
584
  * force: boolean,
585
+ * noClobber: boolean,
466
586
  * runtime: R2Runtime,
467
587
  * sourceType: CopyPathType,
468
588
  * targetPath: CopyPath,
@@ -472,6 +592,7 @@ function getCopyTargetPath({
472
592
  async function confirmCopyOverwrite({
473
593
  destination,
474
594
  force,
595
+ noClobber,
475
596
  runtime,
476
597
  sourceType,
477
598
  targetPath,
@@ -480,6 +601,9 @@ async function confirmCopyOverwrite({
480
601
  if (targetType === "missing") {
481
602
  return
482
603
  }
604
+ if (sourceType === "directory" && targetType === "file") {
605
+ throw new CliError("Cannot transfer a directory to an existing file.")
606
+ }
483
607
  if (
484
608
  sourceType === "file" &&
485
609
  targetType === "directory" &&
@@ -490,14 +614,19 @@ async function confirmCopyOverwrite({
490
614
  if (force) {
491
615
  return
492
616
  }
617
+ if (noClobber) {
618
+ return
619
+ }
493
620
  if (!getInteractive(runtime)) {
494
621
  throw new CliError(
495
- 'Copy would overwrite existing files. Re-run with "--force" in non-interactive environments.',
622
+ 'Transfer would overwrite existing files. Re-run with "--force" in non-interactive environments.',
496
623
  )
497
624
  }
498
- const shouldOverwrite = await getPromptConfirm(runtime)(
499
- `Overwrite existing destination "${destination}"? [y/N] `,
500
- )
625
+ const message =
626
+ sourceType === "directory"
627
+ ? `Overwrite any existing files under destination "${destination}"? [y/N] `
628
+ : `Overwrite existing destination "${destination}"? [y/N] `
629
+ const shouldOverwrite = await getPromptConfirm(runtime)(message)
501
630
  if (!shouldOverwrite) {
502
631
  cancelPrompt()
503
632
  }
@@ -542,6 +671,16 @@ function isDirectoryLikeCopyPath(copyPath) {
542
671
  return copyPath.path === "." || copyPath.path.endsWith("/")
543
672
  }
544
673
 
674
+ /**
675
+ * @param {CopyPath} copyPath
676
+ */
677
+ function isCopyPathContentsOnly(copyPath) {
678
+ const path = copyPath.remote
679
+ ? copyPath.path
680
+ : copyPath.path.replaceAll("\\", "/")
681
+ return path === "." || path.endsWith("/.")
682
+ }
683
+
545
684
  /**
546
685
  * @param {CopyPath} parent
547
686
  * @param {string} child
@@ -592,13 +731,6 @@ function isR2BucketRootPath(path) {
592
731
  return path.trim() === "/"
593
732
  }
594
733
 
595
- /**
596
- * @param {CopyPath} path
597
- */
598
- function isR2BucketRootCopyPath(path) {
599
- return path.remote && path.path === ""
600
- }
601
-
602
734
  /**
603
735
  * @param {unknown} error
604
736
  */
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,