@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 +12 -0
- package/package.json +1 -1
- package/src/index.js +24 -15
- package/src/r2.js +336 -204
- package/src/support/r2.js +14 -4
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
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(
|
|
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 (
|
|
105
|
+
.action(async (paths, options) => {
|
|
102
106
|
try {
|
|
103
107
|
options.longForce = hasLongForceFlag()
|
|
104
|
-
await removeR2(
|
|
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(
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
.
|
|
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.
|
|
124
|
-
await copyR2(
|
|
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("<
|
|
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
|
-
.
|
|
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(
|
|
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 {
|
|
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 {
|
|
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
|
-
*
|
|
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}
|
|
101
|
+
* @param {string|string[]} paths
|
|
89
102
|
* @param {R2RemoveOptions} options
|
|
90
103
|
* @param {R2Runtime} [runtime]
|
|
91
104
|
*/
|
|
92
|
-
export async function removeR2(
|
|
105
|
+
export async function removeR2(paths, options, runtime = {}) {
|
|
93
106
|
intro("r2 rm")
|
|
94
|
-
const
|
|
95
|
-
const
|
|
96
|
-
|
|
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
|
-
|
|
102
|
-
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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 (
|
|
122
|
-
|
|
123
|
-
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
-
|
|
142
|
-
|
|
197
|
+
if (!shouldDelete) {
|
|
198
|
+
cancelPrompt()
|
|
199
|
+
}
|
|
143
200
|
}
|
|
144
201
|
|
|
145
|
-
const
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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}
|
|
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(
|
|
229
|
+
export async function copyR2(paths, options = {}, runtime = {}) {
|
|
161
230
|
intro("r2 cp")
|
|
162
|
-
const
|
|
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 (
|
|
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
|
-
|
|
182
|
-
|
|
183
|
-
destinationType
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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}
|
|
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(
|
|
332
|
+
export async function moveR2(paths, options = {}, runtime = {}) {
|
|
234
333
|
intro("r2 mv")
|
|
235
|
-
const
|
|
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 (
|
|
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
|
-
|
|
252
|
-
|
|
253
|
-
destinationType
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
runtime
|
|
267
|
-
sourceType
|
|
268
|
-
|
|
269
|
-
|
|
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
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
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
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
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
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
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
|
-
|
|
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
|
-
'
|
|
622
|
+
'Transfer would overwrite existing files. Re-run with "--force" in non-interactive environments.',
|
|
496
623
|
)
|
|
497
624
|
}
|
|
498
|
-
const
|
|
499
|
-
|
|
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: [
|
|
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: [
|
|
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,
|