@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 +22 -0
- package/package.json +1 -1
- package/src/index.js +24 -15
- package/src/r2.js +352 -197
- package/src/support/r2.js +59 -9
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
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,24 +9,47 @@ 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"
|
|
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
|
-
*
|
|
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}
|
|
101
|
+
* @param {string|string[]} paths
|
|
78
102
|
* @param {R2RemoveOptions} options
|
|
79
103
|
* @param {R2Runtime} [runtime]
|
|
80
104
|
*/
|
|
81
|
-
export async function removeR2(
|
|
82
|
-
|
|
83
|
-
const
|
|
84
|
-
|
|
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
|
-
|
|
90
|
-
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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 (
|
|
110
|
-
|
|
111
|
-
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
|
|
130
|
-
|
|
197
|
+
if (!shouldDelete) {
|
|
198
|
+
cancelPrompt()
|
|
199
|
+
}
|
|
131
200
|
}
|
|
132
201
|
|
|
133
|
-
const
|
|
134
|
-
|
|
135
|
-
|
|
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}
|
|
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(
|
|
146
|
-
|
|
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 (
|
|
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
|
-
|
|
166
|
-
|
|
167
|
-
destinationType
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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}
|
|
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(
|
|
216
|
-
|
|
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 (
|
|
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
|
-
|
|
233
|
-
|
|
234
|
-
destinationType
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
runtime
|
|
248
|
-
sourceType
|
|
249
|
-
|
|
250
|
-
|
|
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
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
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
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
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
|
-
|
|
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
|
-
'
|
|
622
|
+
'Transfer would overwrite existing files. Re-run with "--force" in non-interactive environments.',
|
|
473
623
|
)
|
|
474
624
|
}
|
|
475
|
-
const
|
|
476
|
-
|
|
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: [
|
|
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: [
|
|
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({
|
|
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 =
|
|
702
|
-
|
|
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
|
}
|