@lightnet/cli 4.6.0 → 4.6.2

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,21 @@
1
1
  # @lightnet/cli
2
2
 
3
+ ## 4.6.2
4
+
5
+ ### Patch Changes
6
+
7
+ - [#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.
8
+
9
+ - [#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.
10
+
11
+ - [#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.
12
+
13
+ ## 4.6.1
14
+
15
+ ### Patch Changes
16
+
17
+ - [#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.
18
+
3
19
  ## 4.6.0
4
20
 
5
21
  ### Minor 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.6.0",
6
+ "version": "4.6.2",
7
7
  "publishConfig": {
8
8
  "access": "public"
9
9
  },
package/src/index.js CHANGED
@@ -96,10 +96,11 @@ r2Command
96
96
  .option("-r, --recursive", "delete a directory/prefix recursively")
97
97
  .option(
98
98
  "-f, --force",
99
- "delete without confirmation, except when cleaning the bucket root",
99
+ 'delete without confirmation; use "--force" to clean the bucket root without confirmation',
100
100
  )
101
101
  .action(async (path, options) => {
102
102
  try {
103
+ options.longForce = hasLongForceFlag()
103
104
  await removeR2(path, options)
104
105
  } catch (error) {
105
106
  handleCommandError(error)
@@ -113,9 +114,13 @@ r2Command
113
114
  )
114
115
  .argument("<source>", 'source path; use "r2:<path>" for R2')
115
116
  .argument("<destination>", 'destination path; use "r2:<path>" for R2')
116
- .option("-f, --force", "overwrite existing files without confirmation")
117
+ .option(
118
+ "-f, --force",
119
+ 'overwrite without confirmation; use "--force" to replace the bucket root without confirmation',
120
+ )
117
121
  .action(async (source, destination, options) => {
118
122
  try {
123
+ options.longForce = hasLongForceFlag()
119
124
  await copyR2(source, destination, options)
120
125
  } catch (error) {
121
126
  handleCommandError(error)
@@ -154,3 +159,7 @@ function handleCommandError(error) {
154
159
  }
155
160
  throw error
156
161
  }
162
+
163
+ function hasLongForceFlag() {
164
+ return process.argv.includes("--force")
165
+ }
package/src/r2.js CHANGED
@@ -9,17 +9,29 @@ import {
9
9
  stdout,
10
10
  } from "node:process"
11
11
 
12
- import { confirm, isCancel, log, text } from "@clack/prompts"
12
+ import { confirm, intro, isCancel, log, outro, text } from "@clack/prompts"
13
13
 
14
14
  import { CliError } from "./support/cli-error.js"
15
15
  import { cancelPrompt } from "./support/prompt-cancel.js"
16
16
  import { createR2Client } from "./support/r2.js"
17
17
 
18
18
  const remotePathPrefix = "r2:"
19
+ const defaultRcloneOptions = {
20
+ checkers: 16,
21
+ transfers: 8,
22
+ }
23
+
24
+ /**
25
+ * @typedef {{
26
+ * transfers?: number
27
+ * checkers?: number
28
+ * }} RcloneOptions
29
+ */
19
30
 
20
31
  /**
21
32
  * @typedef {{
22
33
  * force?: boolean
34
+ * longForce?: boolean
23
35
  * }} R2CopyOptions
24
36
  */
25
37
 
@@ -42,6 +54,7 @@ const remotePathPrefix = "r2:"
42
54
  * @typedef {{
43
55
  * force?: boolean
44
56
  * recursive?: boolean
57
+ * longForce?: boolean
45
58
  * }} R2RemoveOptions
46
59
  */
47
60
 
@@ -77,6 +90,7 @@ export async function listR2(path, runtime = {}) {
77
90
  * @param {R2Runtime} [runtime]
78
91
  */
79
92
  export async function removeR2(path, options, runtime = {}) {
93
+ intro("r2 rm")
80
94
  const r2Path = normalizeR2Path(path)
81
95
  const isBucketRoot = isR2BucketRootPath(path)
82
96
  if (isBucketRoot && !options.recursive) {
@@ -89,9 +103,19 @@ export async function removeR2(path, options, runtime = {}) {
89
103
  }
90
104
 
91
105
  const interactive = getInteractive(runtime)
92
- if (isBucketRoot && !interactive) {
106
+ if (isBucketRoot && options.force && !options.longForce && !interactive) {
107
+ throw new CliError(
108
+ 'Ignoring "-f" for R2 bucket-root cleanup. If you are sure you want to delete the entire R2 bucket, use "--force".',
109
+ )
110
+ }
111
+ if (isBucketRoot && !interactive && !options.longForce) {
93
112
  throw new CliError(
94
- "Cleaning the R2 bucket root requires interactive confirmation.",
113
+ 'Cleaning the R2 bucket root requires interactive confirmation. If you are sure you want to delete the entire R2 bucket, use "--force".',
114
+ )
115
+ }
116
+ if (isBucketRoot && options.force && !options.longForce) {
117
+ log.warn(
118
+ 'Ignoring "-f" for R2 bucket-root cleanup. Use "--force" if you are sure you want to delete the entire R2 bucket.',
95
119
  )
96
120
  }
97
121
  if (!options.force && !interactive) {
@@ -107,20 +131,24 @@ export async function removeR2(path, options, runtime = {}) {
107
131
  }
108
132
 
109
133
  const shouldDelete = isBucketRoot
110
- ? await getPromptConfirm(runtime)(
134
+ ? options.longForce ||
135
+ (await getPromptConfirm(runtime)(
111
136
  "Delete all files in the R2 bucket? [y/N] ",
112
- )
137
+ ))
113
138
  : options.force ||
114
139
  (await getPromptConfirm(runtime)(`Delete R2 path "${r2Path}"? [y/N] `))
115
140
 
116
141
  if (!shouldDelete) {
117
- return
142
+ cancelPrompt()
118
143
  }
119
144
 
120
145
  const client = createR2CommandClient(runtime)
121
146
  await client.remove(r2Path, {
122
147
  recursive: options.recursive === true,
148
+ rcloneOptions:
149
+ options.recursive === true ? defaultRcloneOptions : undefined,
123
150
  })
151
+ outro("R2 delete complete.")
124
152
  }
125
153
 
126
154
  /**
@@ -130,6 +158,7 @@ export async function removeR2(path, options, runtime = {}) {
130
158
  * @param {R2Runtime} [runtime]
131
159
  */
132
160
  export async function copyR2(source, destination, options = {}, runtime = {}) {
161
+ intro("r2 cp")
133
162
  const sourcePath = parseCopyPath(source)
134
163
  const destinationPath = parseCopyPath(destination)
135
164
  if (!sourcePath.remote && !destinationPath.remote) {
@@ -161,16 +190,26 @@ export async function copyR2(source, destination, options = {}, runtime = {}) {
161
190
  sourceType === "file" &&
162
191
  (targetType !== "directory" || isDirectoryLikeCopyPath(destinationPath))
163
192
 
164
- await confirmCopyOverwrite({
165
- destination,
193
+ const confirmedRootOverwrite = await confirmCopyRootOverwrite({
166
194
  force: options.force === true,
195
+ longForce: options.longForce === true,
167
196
  runtime,
168
- sourceType,
169
197
  targetPath,
170
198
  targetType,
171
199
  })
200
+ if (!confirmedRootOverwrite) {
201
+ await confirmCopyOverwrite({
202
+ destination,
203
+ force: options.force === true,
204
+ runtime,
205
+ sourceType,
206
+ targetPath,
207
+ targetType,
208
+ })
209
+ }
172
210
  await replaceCopyTargetBeforeCopy({
173
211
  client,
212
+ rcloneOptions: defaultRcloneOptions,
174
213
  runtime,
175
214
  sourceType,
176
215
  targetPath,
@@ -180,8 +219,9 @@ export async function copyR2(source, destination, options = {}, runtime = {}) {
180
219
  await client.copy(
181
220
  await toRcloneCopyPath(sourcePath, client, runtime),
182
221
  await toRcloneCopyPath(targetPath, client, runtime),
183
- { to: shouldUseCopyTo },
222
+ { rcloneOptions: defaultRcloneOptions, to: shouldUseCopyTo },
184
223
  )
224
+ outro("R2 copy complete.")
185
225
  }
186
226
 
187
227
  /**
@@ -191,6 +231,7 @@ export async function copyR2(source, destination, options = {}, runtime = {}) {
191
231
  * @param {R2Runtime} [runtime]
192
232
  */
193
233
  export async function moveR2(source, destination, options, runtime = {}) {
234
+ intro("r2 mv")
194
235
  const sourcePath = parseR2OnlyPath(source, "Move source")
195
236
  const destinationPath = parseR2OnlyPath(destination, "Move destination")
196
237
  if (isR2BucketRootPath(source) || isR2BucketRootPath(destination)) {
@@ -229,6 +270,7 @@ export async function moveR2(source, destination, options, runtime = {}) {
229
270
  })
230
271
  await replaceCopyTargetBeforeCopy({
231
272
  client,
273
+ rcloneOptions: defaultRcloneOptions,
232
274
  runtime,
233
275
  sourceType,
234
276
  targetPath,
@@ -238,13 +280,15 @@ export async function moveR2(source, destination, options, runtime = {}) {
238
280
  await client.move(
239
281
  await toRcloneCopyPath(sourcePath, client, runtime),
240
282
  await toRcloneCopyPath(targetPath, client, runtime),
241
- { to: shouldUseMoveTo },
283
+ { rcloneOptions: defaultRcloneOptions, to: shouldUseMoveTo },
242
284
  )
285
+ outro("R2 move complete.")
243
286
  }
244
287
 
245
288
  /**
246
289
  * @param {{
247
290
  * client: ReturnType<typeof createR2CommandClient>,
291
+ * rcloneOptions?: RcloneOptions,
248
292
  * runtime: R2Runtime,
249
293
  * sourceType: CopyPathType,
250
294
  * targetPath: CopyPath,
@@ -253,6 +297,7 @@ export async function moveR2(source, destination, options, runtime = {}) {
253
297
  */
254
298
  async function replaceCopyTargetBeforeCopy({
255
299
  client,
300
+ rcloneOptions,
256
301
  runtime,
257
302
  sourceType,
258
303
  targetPath,
@@ -262,7 +307,7 @@ async function replaceCopyTargetBeforeCopy({
262
307
  return
263
308
  }
264
309
  if (targetPath.remote) {
265
- await client.remove(targetPath.path, { recursive: true })
310
+ await client.remove(targetPath.path, { recursive: true, rcloneOptions })
266
311
  return
267
312
  }
268
313
  await rm(resolveLocalPath(targetPath.path, runtime), {
@@ -271,6 +316,55 @@ async function replaceCopyTargetBeforeCopy({
271
316
  })
272
317
  }
273
318
 
319
+ /**
320
+ * @param {{
321
+ * force: boolean,
322
+ * longForce: boolean,
323
+ * runtime: R2Runtime,
324
+ * targetPath: CopyPath,
325
+ * targetType: CopyPathType
326
+ * }} args
327
+ */
328
+ async function confirmCopyRootOverwrite({
329
+ force,
330
+ longForce,
331
+ runtime,
332
+ targetPath,
333
+ targetType,
334
+ }) {
335
+ if (!isR2BucketRootCopyPath(targetPath) || targetType === "missing") {
336
+ return false
337
+ }
338
+ log.warn(
339
+ "This will replace ALL files in the configured R2 bucket. This cannot be undone.",
340
+ )
341
+ if (longForce) {
342
+ return true
343
+ }
344
+ if (force && !getInteractive(runtime)) {
345
+ throw new CliError(
346
+ 'Ignoring "-f" for R2 bucket-root replacement. If you are sure you want to replace the entire R2 bucket, use "--force".',
347
+ )
348
+ }
349
+ if (!getInteractive(runtime)) {
350
+ throw new CliError(
351
+ 'Replacing the R2 bucket root requires interactive confirmation. If you are sure you want to replace the entire R2 bucket, use "--force".',
352
+ )
353
+ }
354
+ if (force) {
355
+ log.warn(
356
+ 'Ignoring "-f" for R2 bucket-root replacement. Use "--force" if you are sure you want to replace the entire R2 bucket.',
357
+ )
358
+ }
359
+ const shouldOverwrite = await getPromptConfirm(runtime)(
360
+ "Replace all files in the R2 bucket? [y/N] ",
361
+ )
362
+ if (!shouldOverwrite) {
363
+ cancelPrompt()
364
+ }
365
+ return true
366
+ }
367
+
274
368
  /**
275
369
  * @param {R2Runtime} runtime
276
370
  */
@@ -498,6 +592,13 @@ function isR2BucketRootPath(path) {
498
592
  return path.trim() === "/"
499
593
  }
500
594
 
595
+ /**
596
+ * @param {CopyPath} path
597
+ */
598
+ function isR2BucketRootCopyPath(path) {
599
+ return path.remote && path.path === ""
600
+ }
601
+
501
602
  /**
502
603
  * @param {unknown} error
503
604
  */
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,13 +241,14 @@ 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 {{to?: boolean, rcloneOptions?: RcloneOptions}} [options]
244
252
  */
245
253
  async copy(source, destination, options = {}) {
246
254
  const r2Config = await getConfig()
@@ -248,13 +256,14 @@ export function createR2Client({ cwd, interactive, promptText }) {
248
256
  args: [options.to ? "copyto" : "copy", source, destination],
249
257
  cwd,
250
258
  config: r2Config,
259
+ rcloneOptions: options.rcloneOptions,
251
260
  secretAccessKey: await getSecretAccessKey(),
252
261
  })
253
262
  },
254
263
  /**
255
264
  * @param {string} source
256
265
  * @param {string} destination
257
- * @param {{to?: boolean}} [options]
266
+ * @param {{to?: boolean, rcloneOptions?: RcloneOptions}} [options]
258
267
  */
259
268
  async move(source, destination, options = {}) {
260
269
  const r2Config = await getConfig()
@@ -262,6 +271,7 @@ export function createR2Client({ cwd, interactive, promptText }) {
262
271
  args: [options.to ? "moveto" : "move", source, destination],
263
272
  cwd,
264
273
  config: r2Config,
274
+ rcloneOptions: options.rcloneOptions,
265
275
  secretAccessKey: await getSecretAccessKey(),
266
276
  })
267
277
  },
@@ -496,10 +506,17 @@ async function listR2Objects(args) {
496
506
  * args: string[]
497
507
  * cwd: string
498
508
  * config: R2Config
509
+ * rcloneOptions?: RcloneOptions
499
510
  * secretAccessKey: string
500
511
  * }} args
501
512
  */
502
- async function runConfiguredRclone({ args, cwd, config, secretAccessKey }) {
513
+ async function runConfiguredRclone({
514
+ args,
515
+ cwd,
516
+ config,
517
+ rcloneOptions,
518
+ secretAccessKey,
519
+ }) {
503
520
  const env = {
504
521
  ...processEnv,
505
522
  RCLONE_S3_SECRET_ACCESS_KEY: secretAccessKey,
@@ -518,6 +535,7 @@ async function runConfiguredRclone({ args, cwd, config, secretAccessKey }) {
518
535
  "--s3-endpoint",
519
536
  getR2Endpoint(config.accountId),
520
537
  "--s3-no-check-bucket",
538
+ ...formatRcloneOptions(rcloneOptions),
521
539
  ],
522
540
  cwd,
523
541
  env,
@@ -527,6 +545,21 @@ async function runConfiguredRclone({ args, cwd, config, secretAccessKey }) {
527
545
  }
528
546
  }
529
547
 
548
+ /**
549
+ * @param {RcloneOptions|undefined} options
550
+ */
551
+ function formatRcloneOptions(options) {
552
+ /** @type {string[]} */
553
+ const args = []
554
+ if (options?.transfers !== undefined) {
555
+ args.push("--transfers", String(options.transfers))
556
+ }
557
+ if (options?.checkers !== undefined) {
558
+ args.push("--checkers", String(options.checkers))
559
+ }
560
+ return args
561
+ }
562
+
530
563
  /**
531
564
  * @param {{cwd:string, promptText:(message:string)=>Promise<string>}} args
532
565
  */
@@ -698,9 +731,16 @@ async function defaultRunRclone(args, cwd, env) {
698
731
  */
699
732
  function getSanitizedRcloneError(error) {
700
733
  if (isPlainObject(error)) {
701
- const processError = /** @type {{stderr?: unknown, stdout?: unknown}} */ (
702
- error
703
- )
734
+ const processError =
735
+ /** @type {{code?: unknown, stderr?: unknown, stdout?: unknown}} */ (
736
+ error
737
+ )
738
+ if (processError.code === "ENOENT") {
739
+ return [
740
+ "rclone is required for lightnet r2 commands but was not found on PATH.",
741
+ "Install rclone: https://rclone.org/install/",
742
+ ].join("\n")
743
+ }
704
744
  if (typeof processError.stderr === "string" && processError.stderr.trim()) {
705
745
  return processError.stderr.trim()
706
746
  }