@lightnet/cli 4.4.2 → 4.6.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 +18 -0
- package/package.json +1 -1
- package/src/index.js +66 -0
- package/src/r2.js +536 -0
- package/src/support/r2.js +202 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
# @lightnet/cli
|
|
2
2
|
|
|
3
|
+
## 4.6.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- [#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.
|
|
8
|
+
|
|
9
|
+
- [#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.
|
|
10
|
+
|
|
11
|
+
- [#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.
|
|
12
|
+
|
|
13
|
+
- [#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.
|
|
14
|
+
|
|
15
|
+
## 4.5.0
|
|
16
|
+
|
|
17
|
+
### Minor Changes
|
|
18
|
+
|
|
19
|
+
- [#437](https://github.com/LightNetDev/LightNet/pull/437) [`91446d3`](https://github.com/LightNetDev/LightNet/commit/91446d3102da0383dfffa836578c02286d0258c9) - Add `lightnet r2` commands for listing, deleting, and copying Cloudflare R2 files through the project R2 configuration.
|
|
20
|
+
|
|
3
21
|
## 4.4.2
|
|
4
22
|
|
|
5
23
|
### Patch Changes
|
package/package.json
CHANGED
package/src/index.js
CHANGED
|
@@ -7,6 +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, moveR2, removeR2 } from "./r2.js"
|
|
10
11
|
import { CliError } from "./support/cli-error.js"
|
|
11
12
|
import { PromptCancelled } from "./support/prompt-cancel.js"
|
|
12
13
|
const { version } = pkg
|
|
@@ -70,6 +71,71 @@ program
|
|
|
70
71
|
}
|
|
71
72
|
})
|
|
72
73
|
|
|
74
|
+
const r2Command = program
|
|
75
|
+
.command("r2")
|
|
76
|
+
.description("manage Cloudflare R2 files with rclone")
|
|
77
|
+
|
|
78
|
+
r2Command
|
|
79
|
+
.command("ls")
|
|
80
|
+
.description("list R2 objects")
|
|
81
|
+
.argument("[path]", "R2 path or prefix")
|
|
82
|
+
.action(async (path) => {
|
|
83
|
+
try {
|
|
84
|
+
await listR2(path)
|
|
85
|
+
} catch (error) {
|
|
86
|
+
handleCommandError(error)
|
|
87
|
+
}
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
r2Command
|
|
91
|
+
.command("rm")
|
|
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')
|
|
96
|
+
.option("-r, --recursive", "delete a directory/prefix recursively")
|
|
97
|
+
.option(
|
|
98
|
+
"-f, --force",
|
|
99
|
+
"delete without confirmation, except when cleaning the bucket root",
|
|
100
|
+
)
|
|
101
|
+
.action(async (path, options) => {
|
|
102
|
+
try {
|
|
103
|
+
await removeR2(path, options)
|
|
104
|
+
} catch (error) {
|
|
105
|
+
handleCommandError(error)
|
|
106
|
+
}
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
r2Command
|
|
110
|
+
.command("cp")
|
|
111
|
+
.description(
|
|
112
|
+
'copy files between R2 and local paths, or inside R2; prefix R2 paths with "r2:"',
|
|
113
|
+
)
|
|
114
|
+
.argument("<source>", 'source path; use "r2:<path>" for R2')
|
|
115
|
+
.argument("<destination>", 'destination path; use "r2:<path>" for R2')
|
|
116
|
+
.option("-f, --force", "overwrite existing files without confirmation")
|
|
117
|
+
.action(async (source, destination, options) => {
|
|
118
|
+
try {
|
|
119
|
+
await copyR2(source, destination, options)
|
|
120
|
+
} catch (error) {
|
|
121
|
+
handleCommandError(error)
|
|
122
|
+
}
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
r2Command
|
|
126
|
+
.command("mv")
|
|
127
|
+
.description("move or rename an R2 file or directory/prefix")
|
|
128
|
+
.argument("<source>", "R2 source path")
|
|
129
|
+
.argument("<destination>", "R2 destination path")
|
|
130
|
+
.option("-f, --force", "overwrite existing destination without confirmation")
|
|
131
|
+
.action(async (source, destination, options) => {
|
|
132
|
+
try {
|
|
133
|
+
await moveR2(source, destination, options)
|
|
134
|
+
} catch (error) {
|
|
135
|
+
handleCommandError(error)
|
|
136
|
+
}
|
|
137
|
+
})
|
|
138
|
+
|
|
73
139
|
await program.parseAsync()
|
|
74
140
|
process.exitCode = commandExitCode
|
|
75
141
|
|
package/src/r2.js
ADDED
|
@@ -0,0 +1,536 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
import { rm, stat } from "node:fs/promises"
|
|
4
|
+
import { basename, join, posix, resolve } from "node:path"
|
|
5
|
+
import {
|
|
6
|
+
cwd as processCwd,
|
|
7
|
+
env as processEnv,
|
|
8
|
+
stdin,
|
|
9
|
+
stdout,
|
|
10
|
+
} from "node:process"
|
|
11
|
+
|
|
12
|
+
import { confirm, isCancel, log, text } from "@clack/prompts"
|
|
13
|
+
|
|
14
|
+
import { CliError } from "./support/cli-error.js"
|
|
15
|
+
import { cancelPrompt } from "./support/prompt-cancel.js"
|
|
16
|
+
import { createR2Client } from "./support/r2.js"
|
|
17
|
+
|
|
18
|
+
const remotePathPrefix = "r2:"
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @typedef {{
|
|
22
|
+
* force?: boolean
|
|
23
|
+
* }} R2CopyOptions
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* @typedef {{
|
|
28
|
+
* force?: boolean
|
|
29
|
+
* }} R2MoveOptions
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* @typedef {{
|
|
34
|
+
* remote: boolean
|
|
35
|
+
* path: string
|
|
36
|
+
* }} CopyPath
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
/** @typedef {"file"|"directory"|"missing"} CopyPathType */
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* @typedef {{
|
|
43
|
+
* force?: boolean
|
|
44
|
+
* recursive?: boolean
|
|
45
|
+
* }} R2RemoveOptions
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* @typedef {{
|
|
50
|
+
* cwd?: string
|
|
51
|
+
* invocationCwd?: string
|
|
52
|
+
* isInteractive?: boolean
|
|
53
|
+
* promptConfirm?: (message: string) => Promise<boolean>
|
|
54
|
+
* promptText?: (message: string) => Promise<string>
|
|
55
|
+
* writeStdout?: (message: string) => void
|
|
56
|
+
* }} R2Runtime
|
|
57
|
+
*/
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* @param {string|undefined} path
|
|
61
|
+
* @param {R2Runtime} [runtime]
|
|
62
|
+
*/
|
|
63
|
+
export async function listR2(path, runtime = {}) {
|
|
64
|
+
const client = createR2CommandClient(runtime)
|
|
65
|
+
const output = await client.list(path)
|
|
66
|
+
const writeStdout =
|
|
67
|
+
runtime.writeStdout ??
|
|
68
|
+
((message) => {
|
|
69
|
+
stdout.write(message)
|
|
70
|
+
})
|
|
71
|
+
writeStdout(output)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* @param {string} path
|
|
76
|
+
* @param {R2RemoveOptions} options
|
|
77
|
+
* @param {R2Runtime} [runtime]
|
|
78
|
+
*/
|
|
79
|
+
export async function removeR2(path, options, runtime = {}) {
|
|
80
|
+
const r2Path = normalizeR2Path(path)
|
|
81
|
+
const isBucketRoot = isR2BucketRootPath(path)
|
|
82
|
+
if (isBucketRoot && !options.recursive) {
|
|
83
|
+
throw new CliError(
|
|
84
|
+
'Refusing to delete the R2 bucket root without "--recursive".',
|
|
85
|
+
)
|
|
86
|
+
}
|
|
87
|
+
if (!r2Path && !isBucketRoot) {
|
|
88
|
+
throw new CliError("Refusing to delete the R2 bucket root.")
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const interactive = getInteractive(runtime)
|
|
92
|
+
if (isBucketRoot && !interactive) {
|
|
93
|
+
throw new CliError(
|
|
94
|
+
"Cleaning the R2 bucket root requires interactive confirmation.",
|
|
95
|
+
)
|
|
96
|
+
}
|
|
97
|
+
if (!options.force && !interactive) {
|
|
98
|
+
throw new CliError(
|
|
99
|
+
'R2 deletion requires confirmation. Re-run with "--force" in non-interactive environments.',
|
|
100
|
+
)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (isBucketRoot) {
|
|
104
|
+
log.warn(
|
|
105
|
+
"This will delete ALL files in the configured R2 bucket. This cannot be undone.",
|
|
106
|
+
)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const shouldDelete = isBucketRoot
|
|
110
|
+
? await getPromptConfirm(runtime)(
|
|
111
|
+
"Delete all files in the R2 bucket? [y/N] ",
|
|
112
|
+
)
|
|
113
|
+
: options.force ||
|
|
114
|
+
(await getPromptConfirm(runtime)(`Delete R2 path "${r2Path}"? [y/N] `))
|
|
115
|
+
|
|
116
|
+
if (!shouldDelete) {
|
|
117
|
+
return
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const client = createR2CommandClient(runtime)
|
|
121
|
+
await client.remove(r2Path, {
|
|
122
|
+
recursive: options.recursive === true,
|
|
123
|
+
})
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* @param {string} source
|
|
128
|
+
* @param {string} destination
|
|
129
|
+
* @param {R2CopyOptions} [options]
|
|
130
|
+
* @param {R2Runtime} [runtime]
|
|
131
|
+
*/
|
|
132
|
+
export async function copyR2(source, destination, options = {}, runtime = {}) {
|
|
133
|
+
const sourcePath = parseCopyPath(source)
|
|
134
|
+
const destinationPath = parseCopyPath(destination)
|
|
135
|
+
if (!sourcePath.remote && !destinationPath.remote) {
|
|
136
|
+
throw new CliError(
|
|
137
|
+
`R2 copy requires at least one R2 path. Prefix R2 paths with "${remotePathPrefix}".`,
|
|
138
|
+
)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const client = createR2CommandClient(runtime)
|
|
142
|
+
const sourceType = await getCopyPathType(sourcePath, client, runtime)
|
|
143
|
+
if (sourceType === "missing") {
|
|
144
|
+
throw new CliError(`Copy source "${source}" does not exist.`)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const destinationType = await getCopyPathType(
|
|
148
|
+
destinationPath,
|
|
149
|
+
client,
|
|
150
|
+
runtime,
|
|
151
|
+
)
|
|
152
|
+
const targetPath = getCopyTargetPath({
|
|
153
|
+
destination: destinationPath,
|
|
154
|
+
destinationType,
|
|
155
|
+
runtime,
|
|
156
|
+
source: sourcePath,
|
|
157
|
+
sourceType,
|
|
158
|
+
})
|
|
159
|
+
const targetType = await getCopyPathType(targetPath, client, runtime)
|
|
160
|
+
const shouldUseCopyTo =
|
|
161
|
+
sourceType === "file" &&
|
|
162
|
+
(targetType !== "directory" || isDirectoryLikeCopyPath(destinationPath))
|
|
163
|
+
|
|
164
|
+
await confirmCopyOverwrite({
|
|
165
|
+
destination,
|
|
166
|
+
force: options.force === true,
|
|
167
|
+
runtime,
|
|
168
|
+
sourceType,
|
|
169
|
+
targetPath,
|
|
170
|
+
targetType,
|
|
171
|
+
})
|
|
172
|
+
await replaceCopyTargetBeforeCopy({
|
|
173
|
+
client,
|
|
174
|
+
runtime,
|
|
175
|
+
sourceType,
|
|
176
|
+
targetPath,
|
|
177
|
+
targetType,
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
await client.copy(
|
|
181
|
+
await toRcloneCopyPath(sourcePath, client, runtime),
|
|
182
|
+
await toRcloneCopyPath(targetPath, client, runtime),
|
|
183
|
+
{ to: shouldUseCopyTo },
|
|
184
|
+
)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* @param {string} source
|
|
189
|
+
* @param {string} destination
|
|
190
|
+
* @param {R2MoveOptions} options
|
|
191
|
+
* @param {R2Runtime} [runtime]
|
|
192
|
+
*/
|
|
193
|
+
export async function moveR2(source, destination, options, runtime = {}) {
|
|
194
|
+
const sourcePath = parseR2OnlyPath(source, "Move source")
|
|
195
|
+
const destinationPath = parseR2OnlyPath(destination, "Move destination")
|
|
196
|
+
if (isR2BucketRootPath(source) || isR2BucketRootPath(destination)) {
|
|
197
|
+
throw new CliError("Refusing to move the R2 bucket root.")
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const client = createR2CommandClient(runtime)
|
|
201
|
+
const sourceType = await getCopyPathType(sourcePath, client, runtime)
|
|
202
|
+
if (sourceType === "missing") {
|
|
203
|
+
throw new CliError(`Move source "${source}" does not exist.`)
|
|
204
|
+
}
|
|
205
|
+
const destinationType = await getCopyPathType(
|
|
206
|
+
destinationPath,
|
|
207
|
+
client,
|
|
208
|
+
runtime,
|
|
209
|
+
)
|
|
210
|
+
const targetPath = getCopyTargetPath({
|
|
211
|
+
destination: destinationPath,
|
|
212
|
+
destinationType,
|
|
213
|
+
runtime,
|
|
214
|
+
source: sourcePath,
|
|
215
|
+
sourceType,
|
|
216
|
+
})
|
|
217
|
+
const targetType = await getCopyPathType(targetPath, client, runtime)
|
|
218
|
+
const shouldUseMoveTo =
|
|
219
|
+
sourceType === "file" &&
|
|
220
|
+
(targetType !== "directory" || isDirectoryLikeCopyPath(destinationPath))
|
|
221
|
+
|
|
222
|
+
await confirmCopyOverwrite({
|
|
223
|
+
destination,
|
|
224
|
+
force: options.force === true,
|
|
225
|
+
runtime,
|
|
226
|
+
sourceType,
|
|
227
|
+
targetPath,
|
|
228
|
+
targetType,
|
|
229
|
+
})
|
|
230
|
+
await replaceCopyTargetBeforeCopy({
|
|
231
|
+
client,
|
|
232
|
+
runtime,
|
|
233
|
+
sourceType,
|
|
234
|
+
targetPath,
|
|
235
|
+
targetType,
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
await client.move(
|
|
239
|
+
await toRcloneCopyPath(sourcePath, client, runtime),
|
|
240
|
+
await toRcloneCopyPath(targetPath, client, runtime),
|
|
241
|
+
{ to: shouldUseMoveTo },
|
|
242
|
+
)
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* @param {{
|
|
247
|
+
* client: ReturnType<typeof createR2CommandClient>,
|
|
248
|
+
* runtime: R2Runtime,
|
|
249
|
+
* sourceType: CopyPathType,
|
|
250
|
+
* targetPath: CopyPath,
|
|
251
|
+
* targetType: CopyPathType
|
|
252
|
+
* }} args
|
|
253
|
+
*/
|
|
254
|
+
async function replaceCopyTargetBeforeCopy({
|
|
255
|
+
client,
|
|
256
|
+
runtime,
|
|
257
|
+
sourceType,
|
|
258
|
+
targetPath,
|
|
259
|
+
targetType,
|
|
260
|
+
}) {
|
|
261
|
+
if (sourceType !== "directory" || targetType !== "directory") {
|
|
262
|
+
return
|
|
263
|
+
}
|
|
264
|
+
if (targetPath.remote) {
|
|
265
|
+
await client.remove(targetPath.path, { recursive: true })
|
|
266
|
+
return
|
|
267
|
+
}
|
|
268
|
+
await rm(resolveLocalPath(targetPath.path, runtime), {
|
|
269
|
+
force: true,
|
|
270
|
+
recursive: true,
|
|
271
|
+
})
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* @param {R2Runtime} runtime
|
|
276
|
+
*/
|
|
277
|
+
function createR2CommandClient(runtime) {
|
|
278
|
+
return createR2Client({
|
|
279
|
+
cwd: runtime.cwd ?? processCwd(),
|
|
280
|
+
interactive: getInteractive(runtime),
|
|
281
|
+
promptText: getPromptText(runtime),
|
|
282
|
+
})
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* @param {R2Runtime} runtime
|
|
287
|
+
*/
|
|
288
|
+
function getInteractive(runtime) {
|
|
289
|
+
return runtime.isInteractive ?? Boolean(stdin.isTTY && stdout.isTTY)
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* @param {R2Runtime} runtime
|
|
294
|
+
*/
|
|
295
|
+
function getPromptText(runtime) {
|
|
296
|
+
return runtime.promptText ?? defaultPromptText
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* @param {R2Runtime} runtime
|
|
301
|
+
*/
|
|
302
|
+
function getPromptConfirm(runtime) {
|
|
303
|
+
return runtime.promptConfirm ?? defaultPromptConfirm
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* @param {string} value
|
|
308
|
+
*/
|
|
309
|
+
function parseCopyPath(value) {
|
|
310
|
+
if (!value.startsWith(remotePathPrefix)) {
|
|
311
|
+
return { remote: false, path: value }
|
|
312
|
+
}
|
|
313
|
+
return { remote: true, path: normalizeR2Path(value) }
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* @param {string} value
|
|
318
|
+
* @param {string} label
|
|
319
|
+
* @returns {CopyPath}
|
|
320
|
+
*/
|
|
321
|
+
function parseR2OnlyPath(value, label) {
|
|
322
|
+
if (value.startsWith(remotePathPrefix)) {
|
|
323
|
+
return { remote: true, path: normalizeR2Path(value) }
|
|
324
|
+
}
|
|
325
|
+
if (value.startsWith("/") || value.startsWith("./") || value === ".") {
|
|
326
|
+
throw new CliError(`${label} must be an R2 path, not a local path.`)
|
|
327
|
+
}
|
|
328
|
+
return { remote: true, path: normalizeR2Path(value) }
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* @param {{
|
|
333
|
+
* destination: CopyPath,
|
|
334
|
+
* destinationType: CopyPathType,
|
|
335
|
+
* runtime: R2Runtime,
|
|
336
|
+
* source: CopyPath,
|
|
337
|
+
* sourceType: CopyPathType
|
|
338
|
+
* }} args
|
|
339
|
+
* @returns {CopyPath}
|
|
340
|
+
*/
|
|
341
|
+
function getCopyTargetPath({
|
|
342
|
+
destination,
|
|
343
|
+
destinationType,
|
|
344
|
+
runtime,
|
|
345
|
+
source,
|
|
346
|
+
sourceType,
|
|
347
|
+
}) {
|
|
348
|
+
if (sourceType === "directory") {
|
|
349
|
+
if (destinationType === "file") {
|
|
350
|
+
throw new CliError("Cannot copy a directory to an existing file.")
|
|
351
|
+
}
|
|
352
|
+
return destination
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
if (
|
|
356
|
+
destinationType === "directory" ||
|
|
357
|
+
(destinationType === "missing" && isDirectoryLikeCopyPath(destination))
|
|
358
|
+
) {
|
|
359
|
+
return {
|
|
360
|
+
...destination,
|
|
361
|
+
path: joinCopyPath(destination, getCopyPathName(source, runtime)),
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
return destination
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* @param {{
|
|
370
|
+
* destination: string,
|
|
371
|
+
* force: boolean,
|
|
372
|
+
* runtime: R2Runtime,
|
|
373
|
+
* sourceType: CopyPathType,
|
|
374
|
+
* targetPath: CopyPath,
|
|
375
|
+
* targetType: CopyPathType
|
|
376
|
+
* }} args
|
|
377
|
+
*/
|
|
378
|
+
async function confirmCopyOverwrite({
|
|
379
|
+
destination,
|
|
380
|
+
force,
|
|
381
|
+
runtime,
|
|
382
|
+
sourceType,
|
|
383
|
+
targetPath,
|
|
384
|
+
targetType,
|
|
385
|
+
}) {
|
|
386
|
+
if (targetType === "missing") {
|
|
387
|
+
return
|
|
388
|
+
}
|
|
389
|
+
if (
|
|
390
|
+
sourceType === "file" &&
|
|
391
|
+
targetType === "directory" &&
|
|
392
|
+
!isDirectoryLikeCopyPath(targetPath)
|
|
393
|
+
) {
|
|
394
|
+
throw new CliError("Cannot overwrite an existing directory with a file.")
|
|
395
|
+
}
|
|
396
|
+
if (force) {
|
|
397
|
+
return
|
|
398
|
+
}
|
|
399
|
+
if (!getInteractive(runtime)) {
|
|
400
|
+
throw new CliError(
|
|
401
|
+
'Copy would overwrite existing files. Re-run with "--force" in non-interactive environments.',
|
|
402
|
+
)
|
|
403
|
+
}
|
|
404
|
+
const shouldOverwrite = await getPromptConfirm(runtime)(
|
|
405
|
+
`Overwrite existing destination "${destination}"? [y/N] `,
|
|
406
|
+
)
|
|
407
|
+
if (!shouldOverwrite) {
|
|
408
|
+
cancelPrompt()
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* @param {CopyPath} copyPath
|
|
414
|
+
* @param {ReturnType<typeof createR2CommandClient>} client
|
|
415
|
+
* @param {R2Runtime} runtime
|
|
416
|
+
* @returns {Promise<CopyPathType>}
|
|
417
|
+
*/
|
|
418
|
+
async function getCopyPathType(copyPath, client, runtime) {
|
|
419
|
+
if (copyPath.remote) {
|
|
420
|
+
return client.getPathType(copyPath.path)
|
|
421
|
+
}
|
|
422
|
+
try {
|
|
423
|
+
const stats = await stat(resolveLocalPath(copyPath.path, runtime))
|
|
424
|
+
return stats.isDirectory() ? "directory" : "file"
|
|
425
|
+
} catch (error) {
|
|
426
|
+
if (isLocalNotFoundError(error)) {
|
|
427
|
+
return "missing"
|
|
428
|
+
}
|
|
429
|
+
throw error
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* @param {CopyPath} copyPath
|
|
435
|
+
* @param {ReturnType<typeof createR2CommandClient>} client
|
|
436
|
+
* @param {R2Runtime} runtime
|
|
437
|
+
*/
|
|
438
|
+
async function toRcloneCopyPath(copyPath, client, runtime) {
|
|
439
|
+
return copyPath.remote
|
|
440
|
+
? await client.toRemotePath(copyPath.path)
|
|
441
|
+
: resolveLocalPath(copyPath.path, runtime)
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* @param {CopyPath} copyPath
|
|
446
|
+
*/
|
|
447
|
+
function isDirectoryLikeCopyPath(copyPath) {
|
|
448
|
+
return copyPath.path === "." || copyPath.path.endsWith("/")
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* @param {CopyPath} parent
|
|
453
|
+
* @param {string} child
|
|
454
|
+
*/
|
|
455
|
+
function joinCopyPath(parent, child) {
|
|
456
|
+
if (parent.remote) {
|
|
457
|
+
return parent.path ? posix.join(parent.path, child) : child
|
|
458
|
+
}
|
|
459
|
+
return join(parent.path, child)
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* @param {CopyPath} copyPath
|
|
464
|
+
* @param {R2Runtime} runtime
|
|
465
|
+
*/
|
|
466
|
+
function getCopyPathName(copyPath, runtime) {
|
|
467
|
+
return copyPath.remote
|
|
468
|
+
? posix.basename(copyPath.path.replace(/\/+$/, ""))
|
|
469
|
+
: basename(resolveLocalPath(copyPath.path, runtime))
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* @param {string} path
|
|
474
|
+
* @param {R2Runtime} runtime
|
|
475
|
+
*/
|
|
476
|
+
function resolveLocalPath(path, runtime) {
|
|
477
|
+
return resolve(getInvocationCwd(runtime), path)
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* @param {R2Runtime} runtime
|
|
482
|
+
*/
|
|
483
|
+
function getInvocationCwd(runtime) {
|
|
484
|
+
return runtime.invocationCwd ?? processEnv.INIT_CWD ?? processCwd()
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/**
|
|
488
|
+
* @param {string|undefined} path
|
|
489
|
+
*/
|
|
490
|
+
function normalizeR2Path(path) {
|
|
491
|
+
return path?.replace(/^r2:/, "").replace(/^\/+/, "") ?? ""
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/**
|
|
495
|
+
* @param {string} path
|
|
496
|
+
*/
|
|
497
|
+
function isR2BucketRootPath(path) {
|
|
498
|
+
return path.trim() === "/"
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/**
|
|
502
|
+
* @param {unknown} error
|
|
503
|
+
*/
|
|
504
|
+
function isLocalNotFoundError(error) {
|
|
505
|
+
return (
|
|
506
|
+
typeof error === "object" &&
|
|
507
|
+
error !== null &&
|
|
508
|
+
"code" in error &&
|
|
509
|
+
error.code === "ENOENT"
|
|
510
|
+
)
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/**
|
|
514
|
+
* @param {string} message
|
|
515
|
+
*/
|
|
516
|
+
async function defaultPromptText(message) {
|
|
517
|
+
const answer = await text({ message })
|
|
518
|
+
if (isCancel(answer)) {
|
|
519
|
+
cancelPrompt()
|
|
520
|
+
}
|
|
521
|
+
return String(answer)
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/**
|
|
525
|
+
* @param {string} message
|
|
526
|
+
*/
|
|
527
|
+
async function defaultPromptConfirm(message) {
|
|
528
|
+
const answer = await confirm({
|
|
529
|
+
message,
|
|
530
|
+
initialValue: false,
|
|
531
|
+
})
|
|
532
|
+
if (isCancel(answer)) {
|
|
533
|
+
cancelPrompt()
|
|
534
|
+
}
|
|
535
|
+
return answer
|
|
536
|
+
}
|
package/src/support/r2.js
CHANGED
|
@@ -90,7 +90,7 @@ export function createR2FileStorage({
|
|
|
90
90
|
/** @returns {Promise<string>} */
|
|
91
91
|
const getSecretAccessKey = async () => {
|
|
92
92
|
if (!secretAccessKey) {
|
|
93
|
-
secretAccessKey = await promptRequiredSecret()
|
|
93
|
+
secretAccessKey = await promptRequiredSecret({ interactive })
|
|
94
94
|
}
|
|
95
95
|
return secretAccessKey
|
|
96
96
|
}
|
|
@@ -166,6 +166,157 @@ export function createR2FileStorage({
|
|
|
166
166
|
return storage
|
|
167
167
|
}
|
|
168
168
|
|
|
169
|
+
/**
|
|
170
|
+
* @param {{
|
|
171
|
+
* cwd: string
|
|
172
|
+
* interactive: boolean
|
|
173
|
+
* promptText: (message: string) => Promise<string>
|
|
174
|
+
* }} args
|
|
175
|
+
*/
|
|
176
|
+
export function createR2Client({ cwd, interactive, promptText }) {
|
|
177
|
+
/** @type {R2Config|undefined} */
|
|
178
|
+
let config
|
|
179
|
+
/** @type {string|undefined} */
|
|
180
|
+
let secretAccessKey
|
|
181
|
+
|
|
182
|
+
/** @returns {Promise<R2Config>} */
|
|
183
|
+
const getConfig = async () => {
|
|
184
|
+
if (config) {
|
|
185
|
+
return config
|
|
186
|
+
}
|
|
187
|
+
const initializedConfig = await initR2Config({
|
|
188
|
+
cwd,
|
|
189
|
+
interactive,
|
|
190
|
+
promptText,
|
|
191
|
+
})
|
|
192
|
+
config = initializedConfig
|
|
193
|
+
return initializedConfig
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** @returns {Promise<string>} */
|
|
197
|
+
const getSecretAccessKey = async () => {
|
|
198
|
+
if (!secretAccessKey) {
|
|
199
|
+
secretAccessKey = await promptRequiredSecret({ interactive })
|
|
200
|
+
}
|
|
201
|
+
return secretAccessKey
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return {
|
|
205
|
+
/**
|
|
206
|
+
* @param {string} [path]
|
|
207
|
+
*/
|
|
208
|
+
async list(path) {
|
|
209
|
+
const r2Config = await getConfig()
|
|
210
|
+
const result = await runConfiguredRclone({
|
|
211
|
+
args: [
|
|
212
|
+
"lsf",
|
|
213
|
+
makeR2Path(r2Config, normalizeR2ObjectPath(path)),
|
|
214
|
+
"--recursive",
|
|
215
|
+
"--files-only",
|
|
216
|
+
],
|
|
217
|
+
cwd,
|
|
218
|
+
config: r2Config,
|
|
219
|
+
secretAccessKey: await getSecretAccessKey(),
|
|
220
|
+
})
|
|
221
|
+
return result.stdout
|
|
222
|
+
},
|
|
223
|
+
/**
|
|
224
|
+
* @param {string} path
|
|
225
|
+
* @param {{recursive?: boolean}} [options]
|
|
226
|
+
*/
|
|
227
|
+
async remove(path, options = {}) {
|
|
228
|
+
const r2Config = await getConfig()
|
|
229
|
+
const secretAccessKey = await getSecretAccessKey()
|
|
230
|
+
const r2Path = makeR2Path(r2Config, normalizeR2ObjectPath(path))
|
|
231
|
+
await runConfiguredRclone({
|
|
232
|
+
args: options.recursive
|
|
233
|
+
? ["delete", r2Path, "--rmdirs"]
|
|
234
|
+
: ["deletefile", r2Path],
|
|
235
|
+
cwd,
|
|
236
|
+
config: r2Config,
|
|
237
|
+
secretAccessKey,
|
|
238
|
+
})
|
|
239
|
+
},
|
|
240
|
+
/**
|
|
241
|
+
* @param {string} source
|
|
242
|
+
* @param {string} destination
|
|
243
|
+
* @param {{to?: boolean}} [options]
|
|
244
|
+
*/
|
|
245
|
+
async copy(source, destination, options = {}) {
|
|
246
|
+
const r2Config = await getConfig()
|
|
247
|
+
await runConfiguredRclone({
|
|
248
|
+
args: [options.to ? "copyto" : "copy", source, destination],
|
|
249
|
+
cwd,
|
|
250
|
+
config: r2Config,
|
|
251
|
+
secretAccessKey: await getSecretAccessKey(),
|
|
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
|
+
},
|
|
311
|
+
/**
|
|
312
|
+
* @param {string} path
|
|
313
|
+
*/
|
|
314
|
+
async toRemotePath(path) {
|
|
315
|
+
return makeR2Path(await getConfig(), normalizeR2ObjectPath(path))
|
|
316
|
+
},
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
169
320
|
/**
|
|
170
321
|
* @param {string} path
|
|
171
322
|
*/
|
|
@@ -366,6 +517,7 @@ async function runConfiguredRclone({ args, cwd, config, secretAccessKey }) {
|
|
|
366
517
|
"auto",
|
|
367
518
|
"--s3-endpoint",
|
|
368
519
|
getR2Endpoint(config.accountId),
|
|
520
|
+
"--s3-no-check-bucket",
|
|
369
521
|
],
|
|
370
522
|
cwd,
|
|
371
523
|
env,
|
|
@@ -400,14 +552,21 @@ async function promptForR2Config({ cwd, promptText }) {
|
|
|
400
552
|
}
|
|
401
553
|
|
|
402
554
|
/**
|
|
555
|
+
* @param {{interactive?: boolean}} [args]
|
|
403
556
|
* @returns {Promise<string>}
|
|
404
557
|
*/
|
|
405
|
-
async function promptRequiredSecret() {
|
|
558
|
+
async function promptRequiredSecret({ interactive = true } = {}) {
|
|
406
559
|
const sessionSecret = processEnv[sessionSecretEnvName]
|
|
407
560
|
if (typeof sessionSecret === "string" && sessionSecret.trim()) {
|
|
408
561
|
return sessionSecret
|
|
409
562
|
}
|
|
410
563
|
|
|
564
|
+
if (!interactive) {
|
|
565
|
+
throw new CliError(
|
|
566
|
+
`Missing R2 secret access key. Set ${sessionSecretEnvName} in non-interactive environments.`,
|
|
567
|
+
)
|
|
568
|
+
}
|
|
569
|
+
|
|
411
570
|
while (true) {
|
|
412
571
|
const value = await password({
|
|
413
572
|
message: "R2 secretAccessKey:",
|
|
@@ -422,6 +581,47 @@ async function promptRequiredSecret() {
|
|
|
422
581
|
}
|
|
423
582
|
}
|
|
424
583
|
|
|
584
|
+
/**
|
|
585
|
+
* @param {string} [path]
|
|
586
|
+
*/
|
|
587
|
+
function normalizeR2ObjectPath(path) {
|
|
588
|
+
return path?.replace(/^r2:/, "").replace(/^\/+/, "") ?? ""
|
|
589
|
+
}
|
|
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
|
+
|
|
425
625
|
/**
|
|
426
626
|
* @param {(message:string)=>Promise<string>} promptText
|
|
427
627
|
* @param {string} message
|