@lightnet/cli 4.6.1 → 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,27 @@
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
+
15
+ ## 4.6.2
16
+
17
+ ### Patch Changes
18
+
19
+ - [#443](https://github.com/LightNetDev/LightNet/pull/443) [`6593139`](https://github.com/LightNetDev/LightNet/commit/65931398471107c03bca6bf4de675599b11db5e5) - Add Clack intro and completion messages to mutating `lightnet r2` commands while keeping `lightnet r2 ls` pipe-friendly.
20
+
21
+ - [#443](https://github.com/LightNetDev/LightNet/pull/443) [`6593139`](https://github.com/LightNetDev/LightNet/commit/65931398471107c03bca6bf4de675599b11db5e5) - Improve `lightnet r2 cp`, `lightnet r2 mv`, and recursive `lightnet r2 rm` performance by passing tuned rclone concurrency settings internally.
22
+
23
+ - [#443](https://github.com/LightNetDev/LightNet/pull/443) [`6593139`](https://github.com/LightNetDev/LightNet/commit/65931398471107c03bca6bf4de675599b11db5e5) - Show a clear install message when `lightnet r2` commands cannot find `rclone` on PATH.
24
+
3
25
  ## 4.6.1
4
26
 
5
27
  ### 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.1",
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,24 +9,47 @@ import {
9
9
  stdout,
10
10
  } from "node:process"
11
11
 
12
- import { confirm, isCancel, log, 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"
16
24
  import { createR2Client } from "./support/r2.js"
17
25
 
18
26
  const remotePathPrefix = "r2:"
27
+ const defaultRcloneOptions = {
28
+ checkers: 16,
29
+ transfers: 8,
30
+ }
31
+
32
+ /**
33
+ * @typedef {{
34
+ * transfers?: number
35
+ * checkers?: number
36
+ * }} RcloneOptions
37
+ */
19
38
 
20
39
  /**
21
40
  * @typedef {{
22
41
  * force?: boolean
23
- * longForce?: boolean
42
+ * noClobber?: boolean
43
+ * progress?: boolean
44
+ * recursive?: boolean
24
45
  * }} R2CopyOptions
25
46
  */
26
47
 
27
48
  /**
28
49
  * @typedef {{
29
50
  * force?: boolean
51
+ * noClobber?: boolean
52
+ * progress?: boolean
30
53
  * }} R2MoveOptions
31
54
  */
32
55
 
@@ -44,6 +67,7 @@ const remotePathPrefix = "r2:"
44
67
  * force?: boolean
45
68
  * recursive?: boolean
46
69
  * longForce?: boolean
70
+ * progress?: boolean
47
71
  * }} R2RemoveOptions
48
72
  */
49
73
 
@@ -74,272 +98,328 @@ export async function listR2(path, runtime = {}) {
74
98
  }
75
99
 
76
100
  /**
77
- * @param {string} path
101
+ * @param {string|string[]} paths
78
102
  * @param {R2RemoveOptions} options
79
103
  * @param {R2Runtime} [runtime]
80
104
  */
81
- export async function removeR2(path, options, runtime = {}) {
82
- const r2Path = normalizeR2Path(path)
83
- const isBucketRoot = isR2BucketRootPath(path)
84
- if (isBucketRoot && !options.recursive) {
105
+ export async function removeR2(paths, options, runtime = {}) {
106
+ intro("r2 rm")
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) {
85
119
  throw new CliError(
86
120
  'Refusing to delete the R2 bucket root without "--recursive".',
87
121
  )
88
122
  }
89
- if (!r2Path && !isBucketRoot) {
90
- 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
+ }
91
129
  }
92
130
 
93
131
  const interactive = getInteractive(runtime)
94
- if (isBucketRoot && options.force && !options.longForce && !interactive) {
132
+ if (hasBucketRoot && options.force && !options.longForce && !interactive) {
95
133
  throw new CliError(
96
134
  'Ignoring "-f" for R2 bucket-root cleanup. If you are sure you want to delete the entire R2 bucket, use "--force".',
97
135
  )
98
136
  }
99
- if (isBucketRoot && !interactive && !options.longForce) {
137
+ if (hasBucketRoot && !interactive && !options.longForce) {
100
138
  throw new CliError(
101
139
  'Cleaning the R2 bucket root requires interactive confirmation. If you are sure you want to delete the entire R2 bucket, use "--force".',
102
140
  )
103
141
  }
104
- if (isBucketRoot && options.force && !options.longForce) {
142
+ if (hasBucketRoot && options.force && !options.longForce) {
105
143
  log.warn(
106
144
  'Ignoring "-f" for R2 bucket-root cleanup. Use "--force" if you are sure you want to delete the entire R2 bucket.',
107
145
  )
108
146
  }
109
- if (!options.force && !interactive) {
110
- throw new CliError(
111
- '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.",
112
150
  )
113
151
  }
114
152
 
115
- if (isBucketRoot) {
116
- log.warn(
117
- "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.',
118
183
  )
119
184
  }
120
185
 
121
- const shouldDelete = isBucketRoot
122
- ? options.longForce ||
123
- (await getPromptConfirm(runtime)(
124
- "Delete all files in the R2 bucket? [y/N] ",
125
- ))
126
- : options.force ||
127
- (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
+ ))
128
196
 
129
- if (!shouldDelete) {
130
- cancelPrompt()
197
+ if (!shouldDelete) {
198
+ cancelPrompt()
199
+ }
131
200
  }
132
201
 
133
- const client = createR2CommandClient(runtime)
134
- await client.remove(r2Path, {
135
- recursive: options.recursive === true,
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.",
136
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
+ }
221
+ outro("R2 delete complete.")
137
222
  }
138
223
 
139
224
  /**
140
- * @param {string} source
141
- * @param {string} destination
225
+ * @param {string|string[]} paths
142
226
  * @param {R2CopyOptions} [options]
143
227
  * @param {R2Runtime} [runtime]
144
228
  */
145
- export async function copyR2(source, destination, options = {}, runtime = {}) {
146
- const sourcePath = parseCopyPath(source)
229
+ export async function copyR2(paths, options = {}, runtime = {}) {
230
+ intro("r2 cp")
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))
147
236
  const destinationPath = parseCopyPath(destination)
148
- if (!sourcePath.remote && !destinationPath.remote) {
237
+ if (
238
+ !destinationPath.remote &&
239
+ sourcePaths.every((sourcePath) => !sourcePath.remote)
240
+ ) {
149
241
  throw new CliError(
150
242
  `R2 copy requires at least one R2 path. Prefix R2 paths with "${remotePathPrefix}".`,
151
243
  )
152
244
  }
153
245
 
154
246
  const client = createR2CommandClient(runtime)
155
- const sourceType = await getCopyPathType(sourcePath, client, runtime)
156
- if (sourceType === "missing") {
157
- throw new CliError(`Copy source "${source}" does not exist.`)
158
- }
159
-
160
247
  const destinationType = await getCopyPathType(
161
248
  destinationPath,
162
249
  client,
163
250
  runtime,
164
251
  )
165
- const targetPath = getCopyTargetPath({
166
- destination: destinationPath,
167
- destinationType,
168
- runtime,
169
- source: sourcePath,
170
- sourceType,
171
- })
172
- const targetType = await getCopyPathType(targetPath, client, runtime)
173
- const shouldUseCopyTo =
174
- sourceType === "file" &&
175
- (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))
176
286
 
177
- const confirmedRootOverwrite = await confirmCopyRootOverwrite({
178
- force: options.force === true,
179
- longForce: options.longForce === true,
180
- runtime,
181
- targetPath,
182
- targetType,
183
- })
184
- if (!confirmedRootOverwrite) {
185
287
  await confirmCopyOverwrite({
186
288
  destination,
187
289
  force: options.force === true,
290
+ noClobber: options.noClobber === true,
188
291
  runtime,
189
292
  sourceType,
190
293
  targetPath,
191
294
  targetType,
192
295
  })
296
+
297
+ operations.push({ shouldUseCopyTo, sourcePath, targetPath })
193
298
  }
194
- await replaceCopyTargetBeforeCopy({
195
- client,
196
- runtime,
197
- sourceType,
198
- targetPath,
199
- targetType,
200
- })
201
299
 
202
- await client.copy(
203
- await toRcloneCopyPath(sourcePath, client, runtime),
204
- await toRcloneCopyPath(targetPath, client, runtime),
205
- { to: shouldUseCopyTo },
206
- )
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
+ }
324
+ outro("R2 copy complete.")
207
325
  }
208
326
 
209
327
  /**
210
- * @param {string} source
211
- * @param {string} destination
328
+ * @param {string|string[]} paths
212
329
  * @param {R2MoveOptions} options
213
330
  * @param {R2Runtime} [runtime]
214
331
  */
215
- export async function moveR2(source, destination, options, runtime = {}) {
216
- const sourcePath = parseR2OnlyPath(source, "Move source")
332
+ export async function moveR2(paths, options = {}, runtime = {}) {
333
+ intro("r2 mv")
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
+ )
217
341
  const destinationPath = parseR2OnlyPath(destination, "Move destination")
218
- if (isR2BucketRootPath(source) || isR2BucketRootPath(destination)) {
342
+ if (
343
+ sources.some((source) => isR2BucketRootPath(source)) ||
344
+ isR2BucketRootPath(destination)
345
+ ) {
219
346
  throw new CliError("Refusing to move the R2 bucket root.")
220
347
  }
221
348
 
222
349
  const client = createR2CommandClient(runtime)
223
- const sourceType = await getCopyPathType(sourcePath, client, runtime)
224
- if (sourceType === "missing") {
225
- throw new CliError(`Move source "${source}" does not exist.`)
226
- }
227
350
  const destinationType = await getCopyPathType(
228
351
  destinationPath,
229
352
  client,
230
353
  runtime,
231
354
  )
232
- const targetPath = getCopyTargetPath({
233
- destination: destinationPath,
234
- destinationType,
235
- runtime,
236
- source: sourcePath,
237
- sourceType,
238
- })
239
- const targetType = await getCopyPathType(targetPath, client, runtime)
240
- const shouldUseMoveTo =
241
- sourceType === "file" &&
242
- (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
+ }
243
364
 
244
- await confirmCopyOverwrite({
245
- destination,
246
- force: options.force === true,
247
- runtime,
248
- sourceType,
249
- targetPath,
250
- targetType,
251
- })
252
- await replaceCopyTargetBeforeCopy({
253
- client,
254
- runtime,
255
- sourceType,
256
- targetPath,
257
- targetType,
258
- })
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
+ }
259
372
 
260
- await client.move(
261
- await toRcloneCopyPath(sourcePath, client, runtime),
262
- await toRcloneCopyPath(targetPath, client, runtime),
263
- { to: shouldUseMoveTo },
264
- )
265
- }
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))
266
384
 
267
- /**
268
- * @param {{
269
- * client: ReturnType<typeof createR2CommandClient>,
270
- * runtime: R2Runtime,
271
- * sourceType: CopyPathType,
272
- * targetPath: CopyPath,
273
- * targetType: CopyPathType
274
- * }} args
275
- */
276
- async function replaceCopyTargetBeforeCopy({
277
- client,
278
- runtime,
279
- sourceType,
280
- targetPath,
281
- targetType,
282
- }) {
283
- if (sourceType !== "directory" || targetType !== "directory") {
284
- return
285
- }
286
- if (targetPath.remote) {
287
- await client.remove(targetPath.path, { recursive: true })
288
- return
289
- }
290
- await rm(resolveLocalPath(targetPath.path, runtime), {
291
- force: true,
292
- recursive: true,
293
- })
294
- }
385
+ await confirmCopyOverwrite({
386
+ destination,
387
+ force: options.force === true,
388
+ noClobber: options.noClobber === true,
389
+ runtime,
390
+ sourceType,
391
+ targetPath,
392
+ targetType,
393
+ })
295
394
 
296
- /**
297
- * @param {{
298
- * force: boolean,
299
- * longForce: boolean,
300
- * runtime: R2Runtime,
301
- * targetPath: CopyPath,
302
- * targetType: CopyPathType
303
- * }} args
304
- */
305
- async function confirmCopyRootOverwrite({
306
- force,
307
- longForce,
308
- runtime,
309
- targetPath,
310
- targetType,
311
- }) {
312
- if (!isR2BucketRootCopyPath(targetPath) || targetType === "missing") {
313
- return false
314
- }
315
- log.warn(
316
- "This will replace ALL files in the configured R2 bucket. This cannot be undone.",
317
- )
318
- if (longForce) {
319
- return true
320
- }
321
- if (force && !getInteractive(runtime)) {
322
- throw new CliError(
323
- 'Ignoring "-f" for R2 bucket-root replacement. If you are sure you want to replace the entire R2 bucket, use "--force".',
324
- )
325
- }
326
- if (!getInteractive(runtime)) {
327
- throw new CliError(
328
- 'Replacing the R2 bucket root requires interactive confirmation. If you are sure you want to replace the entire R2 bucket, use "--force".',
329
- )
330
- }
331
- if (force) {
332
- log.warn(
333
- 'Ignoring "-f" for R2 bucket-root replacement. Use "--force" if you are sure you want to replace the entire R2 bucket.',
334
- )
395
+ operations.push({ shouldUseMoveTo, sourcePath, targetPath })
335
396
  }
336
- const shouldOverwrite = await getPromptConfirm(runtime)(
337
- "Replace all files in the R2 bucket? [y/N] ",
338
- )
339
- if (!shouldOverwrite) {
340
- 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
341
421
  }
342
- return true
422
+ outro("R2 move complete.")
343
423
  }
344
424
 
345
425
  /**
@@ -353,6 +433,39 @@ function createR2CommandClient(runtime) {
353
433
  })
354
434
  }
355
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
+
356
469
  /**
357
470
  * @param {R2Runtime} runtime
358
471
  */
@@ -384,6 +497,23 @@ function parseCopyPath(value) {
384
497
  return { remote: true, path: normalizeR2Path(value) }
385
498
  }
386
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
+
387
517
  /**
388
518
  * @param {string} value
389
519
  * @param {string} label
@@ -420,6 +550,18 @@ function getCopyTargetPath({
420
550
  if (destinationType === "file") {
421
551
  throw new CliError("Cannot copy a directory to an existing file.")
422
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
+ }
423
565
  return destination
424
566
  }
425
567
 
@@ -440,6 +582,7 @@ function getCopyTargetPath({
440
582
  * @param {{
441
583
  * destination: string,
442
584
  * force: boolean,
585
+ * noClobber: boolean,
443
586
  * runtime: R2Runtime,
444
587
  * sourceType: CopyPathType,
445
588
  * targetPath: CopyPath,
@@ -449,6 +592,7 @@ function getCopyTargetPath({
449
592
  async function confirmCopyOverwrite({
450
593
  destination,
451
594
  force,
595
+ noClobber,
452
596
  runtime,
453
597
  sourceType,
454
598
  targetPath,
@@ -457,6 +601,9 @@ async function confirmCopyOverwrite({
457
601
  if (targetType === "missing") {
458
602
  return
459
603
  }
604
+ if (sourceType === "directory" && targetType === "file") {
605
+ throw new CliError("Cannot transfer a directory to an existing file.")
606
+ }
460
607
  if (
461
608
  sourceType === "file" &&
462
609
  targetType === "directory" &&
@@ -467,14 +614,19 @@ async function confirmCopyOverwrite({
467
614
  if (force) {
468
615
  return
469
616
  }
617
+ if (noClobber) {
618
+ return
619
+ }
470
620
  if (!getInteractive(runtime)) {
471
621
  throw new CliError(
472
- '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.',
473
623
  )
474
624
  }
475
- const shouldOverwrite = await getPromptConfirm(runtime)(
476
- `Overwrite existing destination "${destination}"? [y/N] `,
477
- )
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)
478
630
  if (!shouldOverwrite) {
479
631
  cancelPrompt()
480
632
  }
@@ -519,6 +671,16 @@ function isDirectoryLikeCopyPath(copyPath) {
519
671
  return copyPath.path === "." || copyPath.path.endsWith("/")
520
672
  }
521
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
+
522
684
  /**
523
685
  * @param {CopyPath} parent
524
686
  * @param {string} child
@@ -569,13 +731,6 @@ function isR2BucketRootPath(path) {
569
731
  return path.trim() === "/"
570
732
  }
571
733
 
572
- /**
573
- * @param {CopyPath} path
574
- */
575
- function isR2BucketRootCopyPath(path) {
576
- return path.remote && path.path === ""
577
- }
578
-
579
734
  /**
580
735
  * @param {unknown} error
581
736
  */
package/src/support/r2.js CHANGED
@@ -21,6 +21,13 @@ const execFileAsync = promisify(execFile)
21
21
  const sessionSecretEnvName = "LIGHTNET_R2_SECRET_ACCESS_KEY"
22
22
  const r2DeleteBatchSize = 1000
23
23
 
24
+ /**
25
+ * @typedef {{
26
+ * transfers?: number
27
+ * checkers?: number
28
+ * }} RcloneOptions
29
+ */
30
+
24
31
  /**
25
32
  * @typedef {{
26
33
  * publicUrl: string
@@ -222,7 +229,7 @@ export function createR2Client({ cwd, interactive, promptText }) {
222
229
  },
223
230
  /**
224
231
  * @param {string} path
225
- * @param {{recursive?: boolean}} [options]
232
+ * @param {{recursive?: boolean, rcloneOptions?: RcloneOptions}} [options]
226
233
  */
227
234
  async remove(path, options = {}) {
228
235
  const r2Config = await getConfig()
@@ -234,34 +241,47 @@ export function createR2Client({ cwd, interactive, promptText }) {
234
241
  : ["deletefile", r2Path],
235
242
  cwd,
236
243
  config: r2Config,
244
+ rcloneOptions: options.rcloneOptions,
237
245
  secretAccessKey,
238
246
  })
239
247
  },
240
248
  /**
241
249
  * @param {string} source
242
250
  * @param {string} destination
243
- * @param {{to?: boolean}} [options]
251
+ * @param {{ignoreExisting?: boolean, to?: boolean, rcloneOptions?: RcloneOptions}} [options]
244
252
  */
245
253
  async copy(source, destination, options = {}) {
246
254
  const r2Config = await getConfig()
247
255
  await runConfiguredRclone({
248
- args: [options.to ? "copyto" : "copy", source, destination],
256
+ args: [
257
+ options.to ? "copyto" : "copy",
258
+ source,
259
+ destination,
260
+ ...(options.ignoreExisting ? ["--ignore-existing"] : []),
261
+ ],
249
262
  cwd,
250
263
  config: r2Config,
264
+ rcloneOptions: options.rcloneOptions,
251
265
  secretAccessKey: await getSecretAccessKey(),
252
266
  })
253
267
  },
254
268
  /**
255
269
  * @param {string} source
256
270
  * @param {string} destination
257
- * @param {{to?: boolean}} [options]
271
+ * @param {{ignoreExisting?: boolean, to?: boolean, rcloneOptions?: RcloneOptions}} [options]
258
272
  */
259
273
  async move(source, destination, options = {}) {
260
274
  const r2Config = await getConfig()
261
275
  await runConfiguredRclone({
262
- args: [options.to ? "moveto" : "move", source, destination],
276
+ args: [
277
+ options.to ? "moveto" : "move",
278
+ source,
279
+ destination,
280
+ ...(options.ignoreExisting ? ["--ignore-existing"] : []),
281
+ ],
263
282
  cwd,
264
283
  config: r2Config,
284
+ rcloneOptions: options.rcloneOptions,
265
285
  secretAccessKey: await getSecretAccessKey(),
266
286
  })
267
287
  },
@@ -496,10 +516,17 @@ async function listR2Objects(args) {
496
516
  * args: string[]
497
517
  * cwd: string
498
518
  * config: R2Config
519
+ * rcloneOptions?: RcloneOptions
499
520
  * secretAccessKey: string
500
521
  * }} args
501
522
  */
502
- async function runConfiguredRclone({ args, cwd, config, secretAccessKey }) {
523
+ async function runConfiguredRclone({
524
+ args,
525
+ cwd,
526
+ config,
527
+ rcloneOptions,
528
+ secretAccessKey,
529
+ }) {
503
530
  const env = {
504
531
  ...processEnv,
505
532
  RCLONE_S3_SECRET_ACCESS_KEY: secretAccessKey,
@@ -518,6 +545,7 @@ async function runConfiguredRclone({ args, cwd, config, secretAccessKey }) {
518
545
  "--s3-endpoint",
519
546
  getR2Endpoint(config.accountId),
520
547
  "--s3-no-check-bucket",
548
+ ...formatRcloneOptions(rcloneOptions),
521
549
  ],
522
550
  cwd,
523
551
  env,
@@ -527,6 +555,21 @@ async function runConfiguredRclone({ args, cwd, config, secretAccessKey }) {
527
555
  }
528
556
  }
529
557
 
558
+ /**
559
+ * @param {RcloneOptions|undefined} options
560
+ */
561
+ function formatRcloneOptions(options) {
562
+ /** @type {string[]} */
563
+ const args = []
564
+ if (options?.transfers !== undefined) {
565
+ args.push("--transfers", String(options.transfers))
566
+ }
567
+ if (options?.checkers !== undefined) {
568
+ args.push("--checkers", String(options.checkers))
569
+ }
570
+ return args
571
+ }
572
+
530
573
  /**
531
574
  * @param {{cwd:string, promptText:(message:string)=>Promise<string>}} args
532
575
  */
@@ -698,9 +741,16 @@ async function defaultRunRclone(args, cwd, env) {
698
741
  */
699
742
  function getSanitizedRcloneError(error) {
700
743
  if (isPlainObject(error)) {
701
- const processError = /** @type {{stderr?: unknown, stdout?: unknown}} */ (
702
- error
703
- )
744
+ const processError =
745
+ /** @type {{code?: unknown, stderr?: unknown, stdout?: unknown}} */ (
746
+ error
747
+ )
748
+ if (processError.code === "ENOENT") {
749
+ return [
750
+ "rclone is required for lightnet r2 commands but was not found on PATH.",
751
+ "Install rclone: https://rclone.org/install/",
752
+ ].join("\n")
753
+ }
704
754
  if (typeof processError.stderr === "string" && processError.stderr.trim()) {
705
755
  return processError.stderr.trim()
706
756
  }