@lightnet/cli 4.5.0 → 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 CHANGED
@@ -1,5 +1,17 @@
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
+
3
15
  ## 4.5.0
4
16
 
5
17
  ### 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.5.0",
6
+ "version": "4.6.0",
7
7
  "publishConfig": {
8
8
  "access": "public"
9
9
  },
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,10 +89,15 @@ r2Command
89
89
 
90
90
  r2Command
91
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")
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("-f, --force", "delete without confirmation")
97
+ .option(
98
+ "-f, --force",
99
+ "delete without confirmation, except when cleaning the bucket root",
100
+ )
96
101
  .action(async (path, options) => {
97
102
  try {
98
103
  await removeR2(path, options)
@@ -104,13 +109,28 @@ r2Command
104
109
  r2Command
105
110
  .command("cp")
106
111
  .description(
107
- 'copy files between R2 and the local filesystem; prefix the R2 side with "r2:"',
112
+ 'copy files between R2 and local paths, or inside R2; prefix R2 paths with "r2:"',
108
113
  )
109
114
  .argument("<source>", 'source path; use "r2:<path>" for R2')
110
115
  .argument("<destination>", 'destination path; use "r2:<path>" for R2')
111
- .action(async (source, destination) => {
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) => {
112
132
  try {
113
- await copyR2(source, destination)
133
+ await moveR2(source, destination, options)
114
134
  } catch (error) {
115
135
  handleCommandError(error)
116
136
  }
package/src/r2.js CHANGED
@@ -1,6 +1,7 @@
1
1
  // @ts-check
2
2
 
3
- import { resolve } from "node:path"
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,6 +17,27 @@ import { createR2Client } from "./support/r2.js"
16
17
 
17
18
  const remotePathPrefix = "r2:"
18
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
+
19
41
  /**
20
42
  * @typedef {{
21
43
  * force?: boolean
@@ -56,20 +78,40 @@ export async function listR2(path, runtime = {}) {
56
78
  */
57
79
  export async function removeR2(path, options, runtime = {}) {
58
80
  const r2Path = normalizeR2Path(path)
59
- if (!r2Path) {
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) {
60
88
  throw new CliError("Refusing to delete the R2 bucket root.")
61
89
  }
62
90
 
63
91
  const interactive = getInteractive(runtime)
92
+ if (isBucketRoot && !interactive) {
93
+ throw new CliError(
94
+ "Cleaning the R2 bucket root requires interactive confirmation.",
95
+ )
96
+ }
64
97
  if (!options.force && !interactive) {
65
98
  throw new CliError(
66
99
  'R2 deletion requires confirmation. Re-run with "--force" in non-interactive environments.',
67
100
  )
68
101
  }
69
102
 
70
- const shouldDelete =
71
- options.force ||
72
- (await getPromptConfirm(runtime)(`Delete R2 path "${r2Path}"? [y/N] `))
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] `))
73
115
 
74
116
  if (!shouldDelete) {
75
117
  return
@@ -84,26 +126,149 @@ export async function removeR2(path, options, runtime = {}) {
84
126
  /**
85
127
  * @param {string} source
86
128
  * @param {string} destination
129
+ * @param {R2CopyOptions} [options]
87
130
  * @param {R2Runtime} [runtime]
88
131
  */
89
- export async function copyR2(source, destination, runtime = {}) {
132
+ export async function copyR2(source, destination, options = {}, runtime = {}) {
90
133
  const sourcePath = parseCopyPath(source)
91
134
  const destinationPath = parseCopyPath(destination)
92
- if (sourcePath.remote === destinationPath.remote) {
135
+ if (!sourcePath.remote && !destinationPath.remote) {
93
136
  throw new CliError(
94
- `R2 copy requires exactly one R2 path. Prefix the remote side with "${remotePathPrefix}".`,
137
+ `R2 copy requires at least one R2 path. Prefix R2 paths with "${remotePathPrefix}".`,
95
138
  )
96
139
  }
97
140
 
98
141
  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)
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
+ })
105
237
 
106
- await client.copy(rcloneSource, rcloneDestination)
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
+ })
107
272
  }
108
273
 
109
274
  /**
@@ -148,6 +313,162 @@ function parseCopyPath(value) {
148
313
  return { remote: true, path: normalizeR2Path(value) }
149
314
  }
150
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
+
151
472
  /**
152
473
  * @param {string} path
153
474
  * @param {R2Runtime} runtime
@@ -170,6 +491,25 @@ function normalizeR2Path(path) {
170
491
  return path?.replace(/^r2:/, "").replace(/^\/+/, "") ?? ""
171
492
  }
172
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
+
173
513
  /**
174
514
  * @param {string} message
175
515
  */
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