@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 +18 -0
- package/package.json +1 -1
- package/src/index.js +36 -7
- package/src/r2.js +435 -17
- package/src/support/r2.js +94 -2
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
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(
|
|
93
|
-
|
|
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(
|
|
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
|
|
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
|
-
.
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
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
|
|
148
|
+
if (!sourcePath.remote && !destinationPath.remote) {
|
|
93
149
|
throw new CliError(
|
|
94
|
-
`R2 copy requires
|
|
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
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
|
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
|