@lightnet/cli 4.5.0 → 4.6.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.6.1
4
+
5
+ ### Patch Changes
6
+
7
+ - [#441](https://github.com/LightNetDev/LightNet/pull/441) [`b643414`](https://github.com/LightNetDev/LightNet/commit/b643414852ed1e6d80fa013dff9e1189c9f7ba35) - Protect R2 bucket-root delete and copy replacement with a visible warning and require the long `--force` flag to bypass confirmation.
8
+
9
+ ## 4.6.0
10
+
11
+ ### Minor Changes
12
+
13
+ - [#439](https://github.com/LightNetDev/LightNet/pull/439) [`2572437`](https://github.com/LightNetDev/LightNet/commit/25724372a47ac8fcac1fd5959c442d6619f5b758) - Update `lightnet r2 cp` to support Unix-like file overwrite behavior and prompt before overwriting unless `-f` is provided.
14
+
15
+ - [#439](https://github.com/LightNetDev/LightNet/pull/439) [`2572437`](https://github.com/LightNetDev/LightNet/commit/25724372a47ac8fcac1fd5959c442d6619f5b758) - Allow `lightnet r2 cp` to copy files and prefixes inside the configured R2 bucket with `r2:` source and destination paths.
16
+
17
+ - [#439](https://github.com/LightNetDev/LightNet/pull/439) [`2572437`](https://github.com/LightNetDev/LightNet/commit/25724372a47ac8fcac1fd5959c442d6619f5b758) - Add `lightnet r2 mv` for moving and renaming files and prefixes inside the configured R2 bucket.
18
+
19
+ - [#439](https://github.com/LightNetDev/LightNet/pull/439) [`2572437`](https://github.com/LightNetDev/LightNet/commit/25724372a47ac8fcac1fd5959c442d6619f5b758) - Update `lightnet r2 rm` to use OS-style deletion flags with `-r` for recursive prefix removal and `-f` for skipping confirmation.
20
+
3
21
  ## 4.5.0
4
22
 
5
23
  ### Minor Changes
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "description": "CLI for managing LightNet projects",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
- "version": "4.5.0",
6
+ "version": "4.6.1",
7
7
  "publishConfig": {
8
8
  "access": "public"
9
9
  },
package/src/index.js CHANGED
@@ -7,7 +7,7 @@ import pkg from "../package.json" with { type: "json" }
7
7
  import { checkFiles } from "./check-files.js"
8
8
  import { checkLinks } from "./check-links.js"
9
9
  import { checkTranslations } from "./check-translations.js"
10
- import { copyR2, listR2, removeR2 } from "./r2.js"
10
+ import { copyR2, listR2, moveR2, removeR2 } from "./r2.js"
11
11
  import { CliError } from "./support/cli-error.js"
12
12
  import { PromptCancelled } from "./support/prompt-cancel.js"
13
13
  const { version } = pkg
@@ -89,12 +89,18 @@ r2Command
89
89
 
90
90
  r2Command
91
91
  .command("rm")
92
- .description("delete an R2 file; use -r to delete a directory/prefix")
93
- .argument("<path>", "R2 file path, or directory/prefix with -r")
92
+ .description(
93
+ 'delete an R2 file; use -r to delete a directory/prefix or "/" to clean the bucket',
94
+ )
95
+ .argument("<path>", 'R2 file path, directory/prefix with -r, or "/" with -r')
94
96
  .option("-r, --recursive", "delete a directory/prefix recursively")
95
- .option("-f, --force", "delete without confirmation")
97
+ .option(
98
+ "-f, --force",
99
+ 'delete without confirmation; use "--force" to clean the bucket root without confirmation',
100
+ )
96
101
  .action(async (path, options) => {
97
102
  try {
103
+ options.longForce = hasLongForceFlag()
98
104
  await removeR2(path, options)
99
105
  } catch (error) {
100
106
  handleCommandError(error)
@@ -104,13 +110,32 @@ r2Command
104
110
  r2Command
105
111
  .command("cp")
106
112
  .description(
107
- 'copy files between R2 and the local filesystem; prefix the R2 side with "r2:"',
113
+ 'copy files between R2 and local paths, or inside R2; prefix R2 paths with "r2:"',
108
114
  )
109
115
  .argument("<source>", 'source path; use "r2:<path>" for R2')
110
116
  .argument("<destination>", 'destination path; use "r2:<path>" for R2')
111
- .action(async (source, destination) => {
117
+ .option(
118
+ "-f, --force",
119
+ 'overwrite without confirmation; use "--force" to replace the bucket root without confirmation',
120
+ )
121
+ .action(async (source, destination, options) => {
112
122
  try {
113
- await copyR2(source, destination)
123
+ options.longForce = hasLongForceFlag()
124
+ await copyR2(source, destination, options)
125
+ } catch (error) {
126
+ handleCommandError(error)
127
+ }
128
+ })
129
+
130
+ r2Command
131
+ .command("mv")
132
+ .description("move or rename an R2 file or directory/prefix")
133
+ .argument("<source>", "R2 source path")
134
+ .argument("<destination>", "R2 destination path")
135
+ .option("-f, --force", "overwrite existing destination without confirmation")
136
+ .action(async (source, destination, options) => {
137
+ try {
138
+ await moveR2(source, destination, options)
114
139
  } catch (error) {
115
140
  handleCommandError(error)
116
141
  }
@@ -134,3 +159,7 @@ function handleCommandError(error) {
134
159
  }
135
160
  throw error
136
161
  }
162
+
163
+ function hasLongForceFlag() {
164
+ return process.argv.includes("--force")
165
+ }
package/src/r2.js CHANGED
@@ -1,6 +1,7 @@
1
1
  // @ts-check
2
2
 
3
- import { resolve } from "node:path"
3
+ import { rm, stat } from "node:fs/promises"
4
+ import { basename, join, posix, resolve } from "node:path"
4
5
  import {
5
6
  cwd as processCwd,
6
7
  env as processEnv,
@@ -8,7 +9,7 @@ import {
8
9
  stdout,
9
10
  } from "node:process"
10
11
 
11
- import { confirm, isCancel, text } from "@clack/prompts"
12
+ import { confirm, isCancel, log, text } from "@clack/prompts"
12
13
 
13
14
  import { CliError } from "./support/cli-error.js"
14
15
  import { cancelPrompt } from "./support/prompt-cancel.js"
@@ -16,10 +17,33 @@ import { createR2Client } from "./support/r2.js"
16
17
 
17
18
  const remotePathPrefix = "r2:"
18
19
 
20
+ /**
21
+ * @typedef {{
22
+ * force?: boolean
23
+ * longForce?: boolean
24
+ * }} R2CopyOptions
25
+ */
26
+
27
+ /**
28
+ * @typedef {{
29
+ * force?: boolean
30
+ * }} R2MoveOptions
31
+ */
32
+
33
+ /**
34
+ * @typedef {{
35
+ * remote: boolean
36
+ * path: string
37
+ * }} CopyPath
38
+ */
39
+
40
+ /** @typedef {"file"|"directory"|"missing"} CopyPathType */
41
+
19
42
  /**
20
43
  * @typedef {{
21
44
  * force?: boolean
22
45
  * recursive?: boolean
46
+ * longForce?: boolean
23
47
  * }} R2RemoveOptions
24
48
  */
25
49
 
@@ -56,23 +80,54 @@ export async function listR2(path, runtime = {}) {
56
80
  */
57
81
  export async function removeR2(path, options, runtime = {}) {
58
82
  const r2Path = normalizeR2Path(path)
59
- if (!r2Path) {
83
+ const isBucketRoot = isR2BucketRootPath(path)
84
+ if (isBucketRoot && !options.recursive) {
85
+ throw new CliError(
86
+ 'Refusing to delete the R2 bucket root without "--recursive".',
87
+ )
88
+ }
89
+ if (!r2Path && !isBucketRoot) {
60
90
  throw new CliError("Refusing to delete the R2 bucket root.")
61
91
  }
62
92
 
63
93
  const interactive = getInteractive(runtime)
94
+ if (isBucketRoot && options.force && !options.longForce && !interactive) {
95
+ throw new CliError(
96
+ 'Ignoring "-f" for R2 bucket-root cleanup. If you are sure you want to delete the entire R2 bucket, use "--force".',
97
+ )
98
+ }
99
+ if (isBucketRoot && !interactive && !options.longForce) {
100
+ throw new CliError(
101
+ 'Cleaning the R2 bucket root requires interactive confirmation. If you are sure you want to delete the entire R2 bucket, use "--force".',
102
+ )
103
+ }
104
+ if (isBucketRoot && options.force && !options.longForce) {
105
+ log.warn(
106
+ 'Ignoring "-f" for R2 bucket-root cleanup. Use "--force" if you are sure you want to delete the entire R2 bucket.',
107
+ )
108
+ }
64
109
  if (!options.force && !interactive) {
65
110
  throw new CliError(
66
111
  'R2 deletion requires confirmation. Re-run with "--force" in non-interactive environments.',
67
112
  )
68
113
  }
69
114
 
70
- const shouldDelete =
71
- options.force ||
72
- (await getPromptConfirm(runtime)(`Delete R2 path "${r2Path}"? [y/N] `))
115
+ if (isBucketRoot) {
116
+ log.warn(
117
+ "This will delete ALL files in the configured R2 bucket. This cannot be undone.",
118
+ )
119
+ }
120
+
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] `))
73
128
 
74
129
  if (!shouldDelete) {
75
- return
130
+ cancelPrompt()
76
131
  }
77
132
 
78
133
  const client = createR2CommandClient(runtime)
@@ -84,26 +139,207 @@ export async function removeR2(path, options, runtime = {}) {
84
139
  /**
85
140
  * @param {string} source
86
141
  * @param {string} destination
142
+ * @param {R2CopyOptions} [options]
87
143
  * @param {R2Runtime} [runtime]
88
144
  */
89
- export async function copyR2(source, destination, runtime = {}) {
145
+ export async function copyR2(source, destination, options = {}, runtime = {}) {
90
146
  const sourcePath = parseCopyPath(source)
91
147
  const destinationPath = parseCopyPath(destination)
92
- if (sourcePath.remote === destinationPath.remote) {
148
+ if (!sourcePath.remote && !destinationPath.remote) {
93
149
  throw new CliError(
94
- `R2 copy requires exactly one R2 path. Prefix the remote side with "${remotePathPrefix}".`,
150
+ `R2 copy requires at least one R2 path. Prefix R2 paths with "${remotePathPrefix}".`,
95
151
  )
96
152
  }
97
153
 
98
154
  const client = createR2CommandClient(runtime)
99
- const rcloneSource = sourcePath.remote
100
- ? await client.toRemotePath(sourcePath.path)
101
- : resolveLocalPath(sourcePath.path, runtime)
102
- const rcloneDestination = destinationPath.remote
103
- ? await client.toRemotePath(destinationPath.path)
104
- : resolveLocalPath(destinationPath.path, runtime)
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
+ const destinationType = await getCopyPathType(
161
+ destinationPath,
162
+ client,
163
+ runtime,
164
+ )
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))
105
176
 
106
- await client.copy(rcloneSource, rcloneDestination)
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
+ await confirmCopyOverwrite({
186
+ destination,
187
+ force: options.force === true,
188
+ runtime,
189
+ sourceType,
190
+ targetPath,
191
+ targetType,
192
+ })
193
+ }
194
+ await replaceCopyTargetBeforeCopy({
195
+ client,
196
+ runtime,
197
+ sourceType,
198
+ targetPath,
199
+ targetType,
200
+ })
201
+
202
+ await client.copy(
203
+ await toRcloneCopyPath(sourcePath, client, runtime),
204
+ await toRcloneCopyPath(targetPath, client, runtime),
205
+ { to: shouldUseCopyTo },
206
+ )
207
+ }
208
+
209
+ /**
210
+ * @param {string} source
211
+ * @param {string} destination
212
+ * @param {R2MoveOptions} options
213
+ * @param {R2Runtime} [runtime]
214
+ */
215
+ export async function moveR2(source, destination, options, runtime = {}) {
216
+ const sourcePath = parseR2OnlyPath(source, "Move source")
217
+ const destinationPath = parseR2OnlyPath(destination, "Move destination")
218
+ if (isR2BucketRootPath(source) || isR2BucketRootPath(destination)) {
219
+ throw new CliError("Refusing to move the R2 bucket root.")
220
+ }
221
+
222
+ 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
+ const destinationType = await getCopyPathType(
228
+ destinationPath,
229
+ client,
230
+ runtime,
231
+ )
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))
243
+
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
+ })
259
+
260
+ await client.move(
261
+ await toRcloneCopyPath(sourcePath, client, runtime),
262
+ await toRcloneCopyPath(targetPath, client, runtime),
263
+ { to: shouldUseMoveTo },
264
+ )
265
+ }
266
+
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
+ }
295
+
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
+ )
335
+ }
336
+ const shouldOverwrite = await getPromptConfirm(runtime)(
337
+ "Replace all files in the R2 bucket? [y/N] ",
338
+ )
339
+ if (!shouldOverwrite) {
340
+ cancelPrompt()
341
+ }
342
+ return true
107
343
  }
108
344
 
109
345
  /**
@@ -148,6 +384,162 @@ function parseCopyPath(value) {
148
384
  return { remote: true, path: normalizeR2Path(value) }
149
385
  }
150
386
 
387
+ /**
388
+ * @param {string} value
389
+ * @param {string} label
390
+ * @returns {CopyPath}
391
+ */
392
+ function parseR2OnlyPath(value, label) {
393
+ if (value.startsWith(remotePathPrefix)) {
394
+ return { remote: true, path: normalizeR2Path(value) }
395
+ }
396
+ if (value.startsWith("/") || value.startsWith("./") || value === ".") {
397
+ throw new CliError(`${label} must be an R2 path, not a local path.`)
398
+ }
399
+ return { remote: true, path: normalizeR2Path(value) }
400
+ }
401
+
402
+ /**
403
+ * @param {{
404
+ * destination: CopyPath,
405
+ * destinationType: CopyPathType,
406
+ * runtime: R2Runtime,
407
+ * source: CopyPath,
408
+ * sourceType: CopyPathType
409
+ * }} args
410
+ * @returns {CopyPath}
411
+ */
412
+ function getCopyTargetPath({
413
+ destination,
414
+ destinationType,
415
+ runtime,
416
+ source,
417
+ sourceType,
418
+ }) {
419
+ if (sourceType === "directory") {
420
+ if (destinationType === "file") {
421
+ throw new CliError("Cannot copy a directory to an existing file.")
422
+ }
423
+ return destination
424
+ }
425
+
426
+ if (
427
+ destinationType === "directory" ||
428
+ (destinationType === "missing" && isDirectoryLikeCopyPath(destination))
429
+ ) {
430
+ return {
431
+ ...destination,
432
+ path: joinCopyPath(destination, getCopyPathName(source, runtime)),
433
+ }
434
+ }
435
+
436
+ return destination
437
+ }
438
+
439
+ /**
440
+ * @param {{
441
+ * destination: string,
442
+ * force: boolean,
443
+ * runtime: R2Runtime,
444
+ * sourceType: CopyPathType,
445
+ * targetPath: CopyPath,
446
+ * targetType: CopyPathType
447
+ * }} args
448
+ */
449
+ async function confirmCopyOverwrite({
450
+ destination,
451
+ force,
452
+ runtime,
453
+ sourceType,
454
+ targetPath,
455
+ targetType,
456
+ }) {
457
+ if (targetType === "missing") {
458
+ return
459
+ }
460
+ if (
461
+ sourceType === "file" &&
462
+ targetType === "directory" &&
463
+ !isDirectoryLikeCopyPath(targetPath)
464
+ ) {
465
+ throw new CliError("Cannot overwrite an existing directory with a file.")
466
+ }
467
+ if (force) {
468
+ return
469
+ }
470
+ if (!getInteractive(runtime)) {
471
+ throw new CliError(
472
+ 'Copy would overwrite existing files. Re-run with "--force" in non-interactive environments.',
473
+ )
474
+ }
475
+ const shouldOverwrite = await getPromptConfirm(runtime)(
476
+ `Overwrite existing destination "${destination}"? [y/N] `,
477
+ )
478
+ if (!shouldOverwrite) {
479
+ cancelPrompt()
480
+ }
481
+ }
482
+
483
+ /**
484
+ * @param {CopyPath} copyPath
485
+ * @param {ReturnType<typeof createR2CommandClient>} client
486
+ * @param {R2Runtime} runtime
487
+ * @returns {Promise<CopyPathType>}
488
+ */
489
+ async function getCopyPathType(copyPath, client, runtime) {
490
+ if (copyPath.remote) {
491
+ return client.getPathType(copyPath.path)
492
+ }
493
+ try {
494
+ const stats = await stat(resolveLocalPath(copyPath.path, runtime))
495
+ return stats.isDirectory() ? "directory" : "file"
496
+ } catch (error) {
497
+ if (isLocalNotFoundError(error)) {
498
+ return "missing"
499
+ }
500
+ throw error
501
+ }
502
+ }
503
+
504
+ /**
505
+ * @param {CopyPath} copyPath
506
+ * @param {ReturnType<typeof createR2CommandClient>} client
507
+ * @param {R2Runtime} runtime
508
+ */
509
+ async function toRcloneCopyPath(copyPath, client, runtime) {
510
+ return copyPath.remote
511
+ ? await client.toRemotePath(copyPath.path)
512
+ : resolveLocalPath(copyPath.path, runtime)
513
+ }
514
+
515
+ /**
516
+ * @param {CopyPath} copyPath
517
+ */
518
+ function isDirectoryLikeCopyPath(copyPath) {
519
+ return copyPath.path === "." || copyPath.path.endsWith("/")
520
+ }
521
+
522
+ /**
523
+ * @param {CopyPath} parent
524
+ * @param {string} child
525
+ */
526
+ function joinCopyPath(parent, child) {
527
+ if (parent.remote) {
528
+ return parent.path ? posix.join(parent.path, child) : child
529
+ }
530
+ return join(parent.path, child)
531
+ }
532
+
533
+ /**
534
+ * @param {CopyPath} copyPath
535
+ * @param {R2Runtime} runtime
536
+ */
537
+ function getCopyPathName(copyPath, runtime) {
538
+ return copyPath.remote
539
+ ? posix.basename(copyPath.path.replace(/\/+$/, ""))
540
+ : basename(resolveLocalPath(copyPath.path, runtime))
541
+ }
542
+
151
543
  /**
152
544
  * @param {string} path
153
545
  * @param {R2Runtime} runtime
@@ -170,6 +562,32 @@ function normalizeR2Path(path) {
170
562
  return path?.replace(/^r2:/, "").replace(/^\/+/, "") ?? ""
171
563
  }
172
564
 
565
+ /**
566
+ * @param {string} path
567
+ */
568
+ function isR2BucketRootPath(path) {
569
+ return path.trim() === "/"
570
+ }
571
+
572
+ /**
573
+ * @param {CopyPath} path
574
+ */
575
+ function isR2BucketRootCopyPath(path) {
576
+ return path.remote && path.path === ""
577
+ }
578
+
579
+ /**
580
+ * @param {unknown} error
581
+ */
582
+ function isLocalNotFoundError(error) {
583
+ return (
584
+ typeof error === "object" &&
585
+ error !== null &&
586
+ "code" in error &&
587
+ error.code === "ENOENT"
588
+ )
589
+ }
590
+
173
591
  /**
174
592
  * @param {string} message
175
593
  */
package/src/support/r2.js CHANGED
@@ -240,16 +240,74 @@ export function createR2Client({ cwd, interactive, promptText }) {
240
240
  /**
241
241
  * @param {string} source
242
242
  * @param {string} destination
243
+ * @param {{to?: boolean}} [options]
243
244
  */
244
- async copy(source, destination) {
245
+ async copy(source, destination, options = {}) {
245
246
  const r2Config = await getConfig()
246
247
  await runConfiguredRclone({
247
- args: ["copy", source, destination],
248
+ args: [options.to ? "copyto" : "copy", source, destination],
248
249
  cwd,
249
250
  config: r2Config,
250
251
  secretAccessKey: await getSecretAccessKey(),
251
252
  })
252
253
  },
254
+ /**
255
+ * @param {string} source
256
+ * @param {string} destination
257
+ * @param {{to?: boolean}} [options]
258
+ */
259
+ async move(source, destination, options = {}) {
260
+ const r2Config = await getConfig()
261
+ await runConfiguredRclone({
262
+ args: [options.to ? "moveto" : "move", source, destination],
263
+ cwd,
264
+ config: r2Config,
265
+ secretAccessKey: await getSecretAccessKey(),
266
+ })
267
+ },
268
+ /**
269
+ * @param {string} path
270
+ * @returns {Promise<"file"|"directory"|"missing">}
271
+ */
272
+ async getPathType(path) {
273
+ const normalizedPath = normalizeR2ObjectPath(path)
274
+ if (!normalizedPath) {
275
+ return "directory"
276
+ }
277
+ const r2Config = await getConfig()
278
+ try {
279
+ const result = await runConfiguredRclone({
280
+ args: [
281
+ "lsjson",
282
+ makeR2Path(r2Config, normalizedPath),
283
+ "--max-depth",
284
+ "1",
285
+ "--no-mimetype",
286
+ "--no-modtime",
287
+ ],
288
+ cwd,
289
+ config: r2Config,
290
+ secretAccessKey: await getSecretAccessKey(),
291
+ })
292
+ const entries = parseRcloneJsonList(result.stdout)
293
+ if (entries.length === 0) {
294
+ return "missing"
295
+ }
296
+ if (
297
+ entries.length === 1 &&
298
+ !entries[0].isDir &&
299
+ entries[0].name === getR2PathName(normalizedPath)
300
+ ) {
301
+ return "file"
302
+ }
303
+ return "directory"
304
+ } catch (error) {
305
+ if (isRcloneNotFoundError(error)) {
306
+ return "missing"
307
+ }
308
+ throw error
309
+ }
310
+ },
253
311
  /**
254
312
  * @param {string} path
255
313
  */
@@ -530,6 +588,40 @@ function normalizeR2ObjectPath(path) {
530
588
  return path?.replace(/^r2:/, "").replace(/^\/+/, "") ?? ""
531
589
  }
532
590
 
591
+ /**
592
+ * @param {string} output
593
+ * @returns {{name:string, isDir:boolean}[]}
594
+ */
595
+ function parseRcloneJsonList(output) {
596
+ const parsed = JSON.parse(output)
597
+ if (!Array.isArray(parsed)) {
598
+ throw new CliError("Unexpected rclone JSON listing output.")
599
+ }
600
+ return parsed
601
+ .filter((item) => isPlainObject(item) && typeof item.Name === "string")
602
+ .map((item) => ({
603
+ name: String(item.Name),
604
+ isDir: item.IsDir === true,
605
+ }))
606
+ }
607
+
608
+ /**
609
+ * @param {string} path
610
+ */
611
+ function getR2PathName(path) {
612
+ return path.replace(/\/+$/, "").split("/").at(-1) ?? ""
613
+ }
614
+
615
+ /**
616
+ * @param {unknown} error
617
+ */
618
+ function isRcloneNotFoundError(error) {
619
+ return (
620
+ error instanceof Error &&
621
+ /not found|directory not found|object not found/i.test(error.message)
622
+ )
623
+ }
624
+
533
625
  /**
534
626
  * @param {(message:string)=>Promise<string>} promptText
535
627
  * @param {string} message