@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 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
@@ -3,7 +3,7 @@
3
3
  "description": "CLI for managing LightNet projects",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
- "version": "4.4.2",
6
+ "version": "4.5.0",
7
7
  "publishConfig": {
8
8
  "access": "public"
9
9
  },
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