@lightnet/cli 4.4.2 → 4.5.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 +6 -0
- package/package.json +1 -1
- package/src/index.js +46 -0
- package/src/r2.js +196 -0
- package/src/support/r2.js +110 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# @lightnet/cli
|
|
2
2
|
|
|
3
|
+
## 4.5.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- [#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.
|
|
8
|
+
|
|
3
9
|
## 4.4.2
|
|
4
10
|
|
|
5
11
|
### 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, 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,51 @@ 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("delete an R2 file; use -r to delete a directory/prefix")
|
|
93
|
+
.argument("<path>", "R2 file path, or directory/prefix with -r")
|
|
94
|
+
.option("-r, --recursive", "delete a directory/prefix recursively")
|
|
95
|
+
.option("-f, --force", "delete without confirmation")
|
|
96
|
+
.action(async (path, options) => {
|
|
97
|
+
try {
|
|
98
|
+
await removeR2(path, options)
|
|
99
|
+
} catch (error) {
|
|
100
|
+
handleCommandError(error)
|
|
101
|
+
}
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
r2Command
|
|
105
|
+
.command("cp")
|
|
106
|
+
.description(
|
|
107
|
+
'copy files between R2 and the local filesystem; prefix the R2 side with "r2:"',
|
|
108
|
+
)
|
|
109
|
+
.argument("<source>", 'source path; use "r2:<path>" for R2')
|
|
110
|
+
.argument("<destination>", 'destination path; use "r2:<path>" for R2')
|
|
111
|
+
.action(async (source, destination) => {
|
|
112
|
+
try {
|
|
113
|
+
await copyR2(source, destination)
|
|
114
|
+
} catch (error) {
|
|
115
|
+
handleCommandError(error)
|
|
116
|
+
}
|
|
117
|
+
})
|
|
118
|
+
|
|
73
119
|
await program.parseAsync()
|
|
74
120
|
process.exitCode = commandExitCode
|
|
75
121
|
|
package/src/r2.js
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
import { resolve } from "node:path"
|
|
4
|
+
import {
|
|
5
|
+
cwd as processCwd,
|
|
6
|
+
env as processEnv,
|
|
7
|
+
stdin,
|
|
8
|
+
stdout,
|
|
9
|
+
} from "node:process"
|
|
10
|
+
|
|
11
|
+
import { confirm, isCancel, text } from "@clack/prompts"
|
|
12
|
+
|
|
13
|
+
import { CliError } from "./support/cli-error.js"
|
|
14
|
+
import { cancelPrompt } from "./support/prompt-cancel.js"
|
|
15
|
+
import { createR2Client } from "./support/r2.js"
|
|
16
|
+
|
|
17
|
+
const remotePathPrefix = "r2:"
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @typedef {{
|
|
21
|
+
* force?: boolean
|
|
22
|
+
* recursive?: boolean
|
|
23
|
+
* }} R2RemoveOptions
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* @typedef {{
|
|
28
|
+
* cwd?: string
|
|
29
|
+
* invocationCwd?: string
|
|
30
|
+
* isInteractive?: boolean
|
|
31
|
+
* promptConfirm?: (message: string) => Promise<boolean>
|
|
32
|
+
* promptText?: (message: string) => Promise<string>
|
|
33
|
+
* writeStdout?: (message: string) => void
|
|
34
|
+
* }} R2Runtime
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* @param {string|undefined} path
|
|
39
|
+
* @param {R2Runtime} [runtime]
|
|
40
|
+
*/
|
|
41
|
+
export async function listR2(path, runtime = {}) {
|
|
42
|
+
const client = createR2CommandClient(runtime)
|
|
43
|
+
const output = await client.list(path)
|
|
44
|
+
const writeStdout =
|
|
45
|
+
runtime.writeStdout ??
|
|
46
|
+
((message) => {
|
|
47
|
+
stdout.write(message)
|
|
48
|
+
})
|
|
49
|
+
writeStdout(output)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* @param {string} path
|
|
54
|
+
* @param {R2RemoveOptions} options
|
|
55
|
+
* @param {R2Runtime} [runtime]
|
|
56
|
+
*/
|
|
57
|
+
export async function removeR2(path, options, runtime = {}) {
|
|
58
|
+
const r2Path = normalizeR2Path(path)
|
|
59
|
+
if (!r2Path) {
|
|
60
|
+
throw new CliError("Refusing to delete the R2 bucket root.")
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const interactive = getInteractive(runtime)
|
|
64
|
+
if (!options.force && !interactive) {
|
|
65
|
+
throw new CliError(
|
|
66
|
+
'R2 deletion requires confirmation. Re-run with "--force" in non-interactive environments.',
|
|
67
|
+
)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const shouldDelete =
|
|
71
|
+
options.force ||
|
|
72
|
+
(await getPromptConfirm(runtime)(`Delete R2 path "${r2Path}"? [y/N] `))
|
|
73
|
+
|
|
74
|
+
if (!shouldDelete) {
|
|
75
|
+
return
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const client = createR2CommandClient(runtime)
|
|
79
|
+
await client.remove(r2Path, {
|
|
80
|
+
recursive: options.recursive === true,
|
|
81
|
+
})
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* @param {string} source
|
|
86
|
+
* @param {string} destination
|
|
87
|
+
* @param {R2Runtime} [runtime]
|
|
88
|
+
*/
|
|
89
|
+
export async function copyR2(source, destination, runtime = {}) {
|
|
90
|
+
const sourcePath = parseCopyPath(source)
|
|
91
|
+
const destinationPath = parseCopyPath(destination)
|
|
92
|
+
if (sourcePath.remote === destinationPath.remote) {
|
|
93
|
+
throw new CliError(
|
|
94
|
+
`R2 copy requires exactly one R2 path. Prefix the remote side with "${remotePathPrefix}".`,
|
|
95
|
+
)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const client = createR2CommandClient(runtime)
|
|
99
|
+
const rcloneSource = sourcePath.remote
|
|
100
|
+
? await client.toRemotePath(sourcePath.path)
|
|
101
|
+
: resolveLocalPath(sourcePath.path, runtime)
|
|
102
|
+
const rcloneDestination = destinationPath.remote
|
|
103
|
+
? await client.toRemotePath(destinationPath.path)
|
|
104
|
+
: resolveLocalPath(destinationPath.path, runtime)
|
|
105
|
+
|
|
106
|
+
await client.copy(rcloneSource, rcloneDestination)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* @param {R2Runtime} runtime
|
|
111
|
+
*/
|
|
112
|
+
function createR2CommandClient(runtime) {
|
|
113
|
+
return createR2Client({
|
|
114
|
+
cwd: runtime.cwd ?? processCwd(),
|
|
115
|
+
interactive: getInteractive(runtime),
|
|
116
|
+
promptText: getPromptText(runtime),
|
|
117
|
+
})
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* @param {R2Runtime} runtime
|
|
122
|
+
*/
|
|
123
|
+
function getInteractive(runtime) {
|
|
124
|
+
return runtime.isInteractive ?? Boolean(stdin.isTTY && stdout.isTTY)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* @param {R2Runtime} runtime
|
|
129
|
+
*/
|
|
130
|
+
function getPromptText(runtime) {
|
|
131
|
+
return runtime.promptText ?? defaultPromptText
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* @param {R2Runtime} runtime
|
|
136
|
+
*/
|
|
137
|
+
function getPromptConfirm(runtime) {
|
|
138
|
+
return runtime.promptConfirm ?? defaultPromptConfirm
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* @param {string} value
|
|
143
|
+
*/
|
|
144
|
+
function parseCopyPath(value) {
|
|
145
|
+
if (!value.startsWith(remotePathPrefix)) {
|
|
146
|
+
return { remote: false, path: value }
|
|
147
|
+
}
|
|
148
|
+
return { remote: true, path: normalizeR2Path(value) }
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* @param {string} path
|
|
153
|
+
* @param {R2Runtime} runtime
|
|
154
|
+
*/
|
|
155
|
+
function resolveLocalPath(path, runtime) {
|
|
156
|
+
return resolve(getInvocationCwd(runtime), path)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* @param {R2Runtime} runtime
|
|
161
|
+
*/
|
|
162
|
+
function getInvocationCwd(runtime) {
|
|
163
|
+
return runtime.invocationCwd ?? processEnv.INIT_CWD ?? processCwd()
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* @param {string|undefined} path
|
|
168
|
+
*/
|
|
169
|
+
function normalizeR2Path(path) {
|
|
170
|
+
return path?.replace(/^r2:/, "").replace(/^\/+/, "") ?? ""
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* @param {string} message
|
|
175
|
+
*/
|
|
176
|
+
async function defaultPromptText(message) {
|
|
177
|
+
const answer = await text({ message })
|
|
178
|
+
if (isCancel(answer)) {
|
|
179
|
+
cancelPrompt()
|
|
180
|
+
}
|
|
181
|
+
return String(answer)
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* @param {string} message
|
|
186
|
+
*/
|
|
187
|
+
async function defaultPromptConfirm(message) {
|
|
188
|
+
const answer = await confirm({
|
|
189
|
+
message,
|
|
190
|
+
initialValue: false,
|
|
191
|
+
})
|
|
192
|
+
if (isCancel(answer)) {
|
|
193
|
+
cancelPrompt()
|
|
194
|
+
}
|
|
195
|
+
return answer
|
|
196
|
+
}
|
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,99 @@ 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
|
+
*/
|
|
244
|
+
async copy(source, destination) {
|
|
245
|
+
const r2Config = await getConfig()
|
|
246
|
+
await runConfiguredRclone({
|
|
247
|
+
args: ["copy", source, destination],
|
|
248
|
+
cwd,
|
|
249
|
+
config: r2Config,
|
|
250
|
+
secretAccessKey: await getSecretAccessKey(),
|
|
251
|
+
})
|
|
252
|
+
},
|
|
253
|
+
/**
|
|
254
|
+
* @param {string} path
|
|
255
|
+
*/
|
|
256
|
+
async toRemotePath(path) {
|
|
257
|
+
return makeR2Path(await getConfig(), normalizeR2ObjectPath(path))
|
|
258
|
+
},
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
169
262
|
/**
|
|
170
263
|
* @param {string} path
|
|
171
264
|
*/
|
|
@@ -366,6 +459,7 @@ async function runConfiguredRclone({ args, cwd, config, secretAccessKey }) {
|
|
|
366
459
|
"auto",
|
|
367
460
|
"--s3-endpoint",
|
|
368
461
|
getR2Endpoint(config.accountId),
|
|
462
|
+
"--s3-no-check-bucket",
|
|
369
463
|
],
|
|
370
464
|
cwd,
|
|
371
465
|
env,
|
|
@@ -400,14 +494,21 @@ async function promptForR2Config({ cwd, promptText }) {
|
|
|
400
494
|
}
|
|
401
495
|
|
|
402
496
|
/**
|
|
497
|
+
* @param {{interactive?: boolean}} [args]
|
|
403
498
|
* @returns {Promise<string>}
|
|
404
499
|
*/
|
|
405
|
-
async function promptRequiredSecret() {
|
|
500
|
+
async function promptRequiredSecret({ interactive = true } = {}) {
|
|
406
501
|
const sessionSecret = processEnv[sessionSecretEnvName]
|
|
407
502
|
if (typeof sessionSecret === "string" && sessionSecret.trim()) {
|
|
408
503
|
return sessionSecret
|
|
409
504
|
}
|
|
410
505
|
|
|
506
|
+
if (!interactive) {
|
|
507
|
+
throw new CliError(
|
|
508
|
+
`Missing R2 secret access key. Set ${sessionSecretEnvName} in non-interactive environments.`,
|
|
509
|
+
)
|
|
510
|
+
}
|
|
511
|
+
|
|
411
512
|
while (true) {
|
|
412
513
|
const value = await password({
|
|
413
514
|
message: "R2 secretAccessKey:",
|
|
@@ -422,6 +523,13 @@ async function promptRequiredSecret() {
|
|
|
422
523
|
}
|
|
423
524
|
}
|
|
424
525
|
|
|
526
|
+
/**
|
|
527
|
+
* @param {string} [path]
|
|
528
|
+
*/
|
|
529
|
+
function normalizeR2ObjectPath(path) {
|
|
530
|
+
return path?.replace(/^r2:/, "").replace(/^\/+/, "") ?? ""
|
|
531
|
+
}
|
|
532
|
+
|
|
425
533
|
/**
|
|
426
534
|
* @param {(message:string)=>Promise<string>} promptText
|
|
427
535
|
* @param {string} message
|