@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 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
@@ -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.6.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, 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