cloudflare-r2-cli 1.0.0 → 1.2.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.
data/docs/FEATURES.md CHANGED
@@ -7,7 +7,7 @@ Uploads a file to the configured Cloudflare R2 bucket.
7
7
  **Usage:**
8
8
 
9
9
  ```console
10
- $ r2 upload <file>
10
+ $ r2 upload <file> [--key <key>]
11
11
  ```
12
12
 
13
13
  **Examples:**
@@ -15,6 +15,7 @@ $ r2 upload <file>
15
15
  ```console
16
16
  $ r2 upload image.jpg
17
17
  $ r2 upload ./images/photo.png
18
+ $ r2 upload ./images/photo.png --key uploads/photo.png
18
19
  ```
19
20
 
20
21
  **Behavior:**
@@ -22,12 +23,39 @@ $ r2 upload ./images/photo.png
22
23
  * Validates that the given file exists.
23
24
  * Validates that the path is not a directory.
24
25
  * Opens the file in binary read mode.
26
+ * Defines the `Content-Type` of the object from the extension of its key, falling back to `application/octet-stream` when the extension is not mapped.
25
27
  * Uploads the content to the configured Cloudflare R2 bucket.
26
- * Uses the file name as the object key in the bucket.
28
+ * Uses the file name as the object key in the bucket, or the custom key given via `--key`.
27
29
  * Displays a success message after the upload.
28
30
 
29
31
  On error, the corresponding message is displayed and the CLI exits with a non-zero status code.
30
32
 
33
+ ## Download
34
+
35
+ Downloads a file stored in the configured Cloudflare R2 bucket.
36
+
37
+ **Usage:**
38
+
39
+ ```console
40
+ $ r2 download <key> [--output <path>]
41
+ ```
42
+
43
+ **Examples:**
44
+
45
+ ```console
46
+ $ r2 download image.jpg
47
+ $ r2 download image.jpg --output ./images/photo.png
48
+ ```
49
+
50
+ **Behavior:**
51
+
52
+ * Receives the object key to download.
53
+ * Streams the content directly to the destination file, so large objects do not need to be fully loaded into memory.
54
+ * Writes the content to a file named after the object key in the current directory, unless `--output` chooses a custom destination path.
55
+ * Displays a success message after the download.
56
+
57
+ On error, the corresponding message is displayed and the CLI exits with a non-zero status code.
58
+
31
59
  ## Delete
32
60
 
33
61
  Deletes a file stored in the configured Cloudflare R2 bucket.
@@ -35,23 +63,40 @@ Deletes a file stored in the configured Cloudflare R2 bucket.
35
63
  **Usage:**
36
64
 
37
65
  ```console
38
- $ r2 delete <file>
66
+ $ r2 delete <file> [--force]
39
67
  ```
40
68
 
41
- **Example:**
69
+ **Examples:**
42
70
 
43
71
  ```console
44
72
  $ r2 delete image.jpg
73
+ $ r2 delete image.jpg --force
45
74
  ```
46
75
 
47
76
  **Behavior:**
48
77
 
49
78
  * Receives the name of the file to delete.
79
+ * Asks for the confirmation of the deletion before sending the request, unless `--force` is given.
50
80
  * Uses the file name as the object key.
51
81
  * Requests the object deletion from Cloudflare R2.
52
82
  * Considers the operation successful when the storage completes the request without errors.
53
83
  * Displays a success message after the operation.
54
84
 
85
+ **Confirmation:**
86
+
87
+ The prompt is written to the standard output and the answer is read from the standard input:
88
+
89
+ ```console
90
+ $ r2 delete image.jpg
91
+ Delete "image.jpg" from the bucket? [y/N]
92
+ ```
93
+
94
+ The deletion only proceeds with an affirmative answer (`y` or `yes`); any other answer, including an empty one, aborts the operation. In non-interactive executions, such as scripts and pipelines, the confirmation cannot be requested, so the CLI aborts with an explanatory message and status code `1`. Use `--force` to delete without confirmation:
95
+
96
+ ```console
97
+ $ r2 delete image.jpg --force
98
+ ```
99
+
55
100
  The feature does not perform a follow-up query to check that the object no longer exists. The success confirmation is based on the result of the deletion operation provided by the storage layer.
56
101
 
57
102
  On error, the corresponding message is displayed and the CLI exits with a non-zero status code.
@@ -62,14 +107,85 @@ Lists the files stored in the configured Cloudflare R2 bucket.
62
107
 
63
108
  **Usage:**
64
109
 
110
+ ```console
111
+ $ r2 list [--prefix <prefix>]
112
+ ```
113
+
114
+ **Examples:**
115
+
65
116
  ```console
66
117
  $ r2 list
118
+ $ r2 list --prefix uploads/
67
119
  ```
68
120
 
69
121
  **Behavior:**
70
122
 
71
123
  * Queries the objects stored in the configured bucket.
124
+ * Follows the pagination of the bucket, requesting every page until the last one, so all objects are returned.
125
+ * Lists only the objects whose keys start with the given prefix, when `--prefix` is used.
72
126
  * Displays the files found.
73
- * Returns all objects without pagination or control over the amount of returned objects.
74
127
 
75
128
  On error, the corresponding message is displayed and the CLI exits with a non-zero status code.
129
+
130
+ ## Exists
131
+
132
+ Checks whether an object exists in the configured Cloudflare R2 bucket.
133
+
134
+ **Usage:**
135
+
136
+ ```console
137
+ $ r2 exists <key>
138
+ ```
139
+
140
+ **Examples:**
141
+
142
+ ```console
143
+ $ r2 exists image.jpg
144
+ ```
145
+
146
+ **Behavior:**
147
+
148
+ * Requests the metadata of the object, instead of its content, so the check does not depend on the object size.
149
+ * Displays `Object exists: <key>` and exits with status code `0` when the object exists.
150
+ * Displays `Object not found: <key>` and exits with status code `1` when the object does not exist, allowing the command to be used in scripts.
151
+
152
+ On error, the corresponding message is displayed and the CLI exits with a non-zero status code.
153
+
154
+ ## Reliability
155
+
156
+ ### Automatic retries
157
+
158
+ Transient failures while communicating with Cloudflare R2, such as brief network instabilities, are retried automatically with exponential backoff:
159
+
160
+ | Attempt | Wait before the attempt |
161
+ | ------- | ----------------------- |
162
+ | 1 | — |
163
+ | 2 | 0.5s |
164
+ | 3 | 1s |
165
+
166
+ The wait is capped at 5s, and the operation fails after the last attempt, reporting the mapped domain error. Failures that are not transient, such as missing credentials, an invalid bucket or a missing object, are reported immediately, without retries.
167
+
168
+ The retries are applied to every storage operation and restart from the beginning of the data: the uploaded content is rewound and the downloaded content is written from the start again, so a retried request never sends an incomplete body or leaves a partially written file.
169
+
170
+ ### Diagnostics
171
+
172
+ With `--verbose`, every retry is reported on the error output with the operation, the failure cause and the wait applied:
173
+
174
+ ```console
175
+ $ r2 upload image.jpg --verbose
176
+ Retrying upload of image.jpg in 0.5s (attempt 2 of 3) after a transient failure: Seahorse::Client::NetworkingError: ...
177
+ ```
178
+
179
+ ## Global Options
180
+
181
+ Every command accepts the global `--verbose` flag, which writes detailed diagnostic information to the error output without changing the standard output:
182
+
183
+ ```console
184
+ $ r2 list --verbose
185
+ $ r2 upload image.jpg --verbose
186
+ $ r2 download image.jpg --verbose
187
+ $ r2 delete image.jpg --force --verbose
188
+ $ r2 exists image.jpg --verbose
189
+ ```
190
+
191
+ Without the flag, diagnostics are discarded by a null logger and only the standard output is produced.
data/docs/SECURITY.md CHANGED
@@ -9,6 +9,10 @@ The project follows the following security practices:
9
9
  - **Isolated test environment** — E2E tests use a dedicated bucket
10
10
  (`R2_TEST_BUCKET`) separated from the default application bucket.
11
11
 
12
+ - **Confirmation of destructive operations** — `r2 delete` asks for
13
+ confirmation before deleting and requires `--force` in non-interactive
14
+ executions, avoiding accidental deletions in scripts and pipelines.
15
+
12
16
  - **Ignored environment files** — `.env` files are excluded from version
13
17
  control. Only the `.env.example` template is versioned.
14
18
 
data/lib/r2/cli.rb CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "thor"
4
4
  require_relative "errors"
5
+ require_relative "logging"
5
6
 
6
7
  module R2
7
8
  # Command line interface of the project.
@@ -9,6 +10,11 @@ module R2
9
10
  # Acts as a minimal orchestrator: interprets the user's input, delegates
10
11
  # the execution to the responsible components and presents the results.
11
12
  class CLI < Thor
13
+ class_option :verbose,
14
+ type: :boolean,
15
+ default: false,
16
+ desc: "Displays detailed information during execution"
17
+
12
18
  # Initializes the CLI with its dependencies.
13
19
  #
14
20
  # Dependencies are loaded lazily: they are only created when the first
@@ -17,14 +23,18 @@ module R2
17
23
  #
18
24
  # @param configuration [Configuration] application configuration
19
25
  # @param storage [Storage] storage used in object operations
26
+ # @param logger [#debug, nil] logger used for diagnostics
20
27
  def initialize(
21
28
  *,
22
29
  configuration: nil,
23
- storage: nil
30
+ storage: nil,
31
+ logger: nil
24
32
  )
25
33
  super(*)
26
34
  @configuration = configuration
27
35
  @storage = storage
36
+ @injected_logger = logger
37
+ @logger = nil
28
38
  end
29
39
 
30
40
  # Ensures that Thor exits with a non-zero status code when an
@@ -46,28 +56,33 @@ module R2
46
56
  exit 1
47
57
  end
48
58
 
49
- desc "upload FILE", "Uploads an image to R2"
59
+ desc "upload FILE", "Uploads a file to R2"
60
+ method_option :key,
61
+ type: :string,
62
+ desc: "Custom object key used in the bucket"
50
63
 
51
64
  long_desc <<~LONGDESC
52
- Uploads an image to the configured Cloudflare R2 bucket.
53
- The object key in the bucket will be the name of the given file.
65
+ Uploads a file to the configured Cloudflare R2 bucket.
66
+ By default, the object key is the name of the given file.
67
+ Use --key to store the object under a custom key.
54
68
 
55
69
  Examples:
56
70
 
57
71
  $ r2 upload image.jpg
58
72
 
59
- $ r2 upload ./images/photo.png
73
+ $ r2 upload ./images/photo.png --key uploads/photo.png
60
74
  LONGDESC
61
75
 
62
- # Uploads an image to the configured Cloudflare R2 bucket.
63
- #
64
- # The object key in the bucket will be the name of the given file.
76
+ # Uploads a file to the configured Cloudflare R2 bucket.
65
77
  #
66
78
  # @param file [String] path of the file to upload
67
79
  def upload(file)
80
+ key = options[:key] || File.basename(file)
81
+ logger.debug("Starting upload: #{file.inspect} as #{key.inspect}.")
68
82
  body = open_file(file)
69
- storage.upload(key: File.basename(file), body: body)
70
- puts "Image uploaded successfully: #{File.basename(file)}"
83
+ storage.upload(key: key, body: body)
84
+ puts "Uploaded successfully: #{key}"
85
+ logger.debug("Finished upload: #{key.inspect}.")
71
86
  rescue Errors::Error => e
72
87
  warn "Error: #{e.message}"
73
88
  exit 1
@@ -75,14 +90,57 @@ module R2
75
90
  body&.close
76
91
  end
77
92
 
93
+ desc "download KEY", "Downloads a file from R2"
94
+ method_option :output,
95
+ type: :string,
96
+ desc: "Local path where the content is written"
97
+
98
+ long_desc <<~LONGDESC
99
+ Downloads a file from the configured Cloudflare R2 bucket.
100
+ By default, the content is written to a file with the object
101
+ key base name in the current directory.
102
+ Use --output to choose a custom destination path.
103
+
104
+ Examples:
105
+
106
+ $ r2 download image.jpg
107
+
108
+ $ r2 download image.jpg --output ./images/photo.png
109
+ LONGDESC
110
+
111
+ # Downloads a file from the configured Cloudflare R2 bucket.
112
+ #
113
+ # @param key [String] object key in the bucket
114
+ def download(key)
115
+ destination = options[:output] || File.basename(key)
116
+ logger.debug("Starting download: #{key.inspect} to #{destination.inspect}.")
117
+ ensure_destination_writable(destination)
118
+ storage.download(key: key, destination: destination)
119
+ puts "Downloaded successfully: #{destination}"
120
+ logger.debug("Finished download: #{key.inspect}.")
121
+ rescue Errors::Error => e
122
+ warn "Error: #{e.message}"
123
+ exit 1
124
+ end
125
+
78
126
  desc "delete FILE", "Deletes a file from R2"
127
+ method_option :force,
128
+ type: :boolean,
129
+ default: false,
130
+ desc: "Deletes without asking for confirmation"
79
131
 
80
132
  long_desc <<~LONGDESC
81
133
  Deletes a file from the configured Cloudflare R2 bucket.
82
134
 
135
+ The deletion is confirmed before the request is sent. Since a
136
+ non-interactive execution cannot ask the user, it requires the
137
+ --force option to proceed.
138
+
83
139
  Examples:
84
140
 
85
141
  $ r2 delete image.jpg
142
+
143
+ $ r2 delete image.jpg --force
86
144
  LONGDESC
87
145
 
88
146
  # Deletes a file from the configured Cloudflare R2 bucket.
@@ -92,30 +150,76 @@ module R2
92
150
  #
93
151
  # @param file [String] name of the file to delete
94
152
  def delete(file)
153
+ logger.debug("Starting delete: #{file.inspect}.")
154
+ confirm_deletion(file)
95
155
  storage.delete(key: file)
96
- puts "File deleted successfully: #{file}"
156
+ puts "Deleted successfully: #{file}"
157
+ logger.debug("Finished delete: #{file.inspect}.")
97
158
  rescue Errors::Error => e
98
159
  warn "Error: #{e.message}"
99
160
  exit 1
100
161
  end
101
162
 
102
163
  desc "list", "Lists the files stored in R2"
164
+ method_option :prefix,
165
+ type: :string,
166
+ desc: "Lists only the objects whose keys start with the given prefix"
103
167
 
104
168
  long_desc <<~LONGDESC
105
169
  Lists the files stored in the configured Cloudflare R2 bucket.
106
170
 
171
+ Every object is listed, following the pagination of the bucket.
172
+ Use --prefix to list only the objects whose keys start with the
173
+ given prefix.
174
+
107
175
  Examples:
108
176
 
109
177
  $ r2 list
178
+
179
+ $ r2 list --prefix uploads/
110
180
  LONGDESC
111
181
 
112
182
  # Lists the files stored in the configured Cloudflare R2 bucket.
113
183
  def list
114
- files = storage.list
184
+ logger.debug("Starting list: prefix=#{options[:prefix].inspect}.")
185
+ files = storage.list(prefix: options[:prefix])
115
186
 
116
187
  files.each do |file|
117
188
  puts file
118
189
  end
190
+ logger.debug("Finished list: #{files.size} object(s).")
191
+ rescue Errors::Error => e
192
+ warn "Error: #{e.message}"
193
+ exit 1
194
+ end
195
+
196
+ desc "exists KEY", "Checks whether an object exists in R2"
197
+
198
+ long_desc <<~LONGDESC
199
+ Checks whether an object exists in the configured Cloudflare R2
200
+ bucket. The exit status is 0 when the object exists and 1 when it
201
+ does not, which allows the command to be used in scripts.
202
+
203
+ Examples:
204
+
205
+ $ r2 exists image.jpg
206
+ LONGDESC
207
+
208
+ # Checks whether an object exists in the configured Cloudflare R2
209
+ # bucket.
210
+ #
211
+ # @param key [String] object key in the bucket
212
+ def exists(key)
213
+ logger.debug("Starting existence check: #{key.inspect}.")
214
+
215
+ if storage.exists?(key: key)
216
+ puts "Object exists: #{key}"
217
+ logger.debug("Finished existence check: #{key.inspect} exists.")
218
+ else
219
+ puts "Object not found: #{key}"
220
+ logger.debug("Finished existence check: #{key.inspect} does not exist.")
221
+ exit 1
222
+ end
119
223
  rescue Errors::Error => e
120
224
  warn "Error: #{e.message}"
121
225
  exit 1
@@ -153,9 +257,126 @@ module R2
153
257
 
154
258
  # Returns the storage, creating it lazily on first use.
155
259
  #
260
+ # Shares the CLI logger with the storage so diagnostics follow
261
+ # the requested verbosity.
262
+ #
156
263
  # @return [Storage] storage used in object operations
157
264
  def storage
158
- @storage ||= Storage.new(configuration)
265
+ @storage ||= Storage.new(configuration, logger: logger)
266
+ end
267
+
268
+ # Returns the logger used for diagnostics.
269
+ #
270
+ # An injected logger is always used as-is. Otherwise, a logger is
271
+ # built from the `--verbose` flag: verbose output goes to the
272
+ # error output, while the default is a null logger that silently
273
+ # ignores debug messages.
274
+ #
275
+ # @return [#debug] logger used for diagnostics
276
+ def logger
277
+ return @logger if logger_ready?
278
+
279
+ @logger_verbose = verbose?
280
+ @logger = build_logger
281
+ end
282
+
283
+ # Indicates whether verbose output was requested.
284
+ #
285
+ # @return [Boolean] true when `--verbose` was given
286
+ def verbose?
287
+ options[:verbose] == true
288
+ end
289
+
290
+ # Indicates whether the logger already matches the current verbosity.
291
+ #
292
+ # @return [Boolean] true when the logger is ready to use
293
+ def logger_ready?
294
+ !@logger.nil? && defined?(@logger_verbose) && @logger_verbose == verbose?
295
+ end
296
+
297
+ # Builds the logger for the current verbosity.
298
+ #
299
+ # @return [#debug] logger used for diagnostics
300
+ def build_logger
301
+ return @injected_logger unless @injected_logger.nil?
302
+
303
+ R2::Logging.build(verbose: verbose?)
304
+ end
305
+
306
+ # Ensures the download destination can be written.
307
+ #
308
+ # @param destination [String] local path where the content is written
309
+ # @raise [Errors::InvalidFileError] if the destination is a directory
310
+ # @raise [Errors::PermissionError] if the destination cannot be written
311
+ def ensure_destination_writable(destination)
312
+ if File.directory?(destination)
313
+ raise Errors::InvalidFileError, "The destination path is a directory: #{destination}"
314
+ end
315
+
316
+ parent = File.dirname(destination)
317
+
318
+ raise Errors::FileNotFoundError, "Destination directory not found: #{parent}" unless File.directory?(parent)
319
+
320
+ return if File.writable?(parent) && (!File.exist?(destination) || File.writable?(destination))
321
+
322
+ raise Errors::PermissionError, "Permission denied to write the file: #{destination}"
323
+ end
324
+
325
+ # Asks the user to confirm the deletion of an object.
326
+ #
327
+ # The confirmation is skipped when `--force` is given. Without it the
328
+ # deletion only happens after an affirmative answer, which requires an
329
+ # interactive input: scripts and pipelines cannot be asked, so they
330
+ # must opt out explicitly, avoiding accidental deletions.
331
+ #
332
+ # @param key [String] object key in the bucket
333
+ # @raise [Errors::ConfirmationRequiredError] when the input is not
334
+ # interactive and `--force` was not given
335
+ # @raise [Errors::AbortedError] when the deletion is not confirmed
336
+ def confirm_deletion(key)
337
+ if options[:force]
338
+ logger.debug("Skipping the deletion confirmation: --force was given.")
339
+ return
340
+ end
341
+
342
+ raise Errors::ConfirmationRequiredError, confirmation_required_message(key) unless interactive_input?
343
+
344
+ return if affirmative?(ask_deletion_confirmation(key))
345
+
346
+ raise Errors::AbortedError, "Deletion aborted: #{key}"
347
+ end
348
+
349
+ # Writes the deletion confirmation prompt and reads the answer.
350
+ #
351
+ # @param key [String] object key in the bucket
352
+ # @return [String, nil] answer given by the user
353
+ def ask_deletion_confirmation(key)
354
+ $stdout.print("Delete #{key.inspect} from the bucket? [y/N] ")
355
+ $stdin.gets
356
+ end
357
+
358
+ # Indicates whether the standard input can be used to ask the user.
359
+ #
360
+ # @return [Boolean] true when the input is a terminal
361
+ def interactive_input?
362
+ $stdin.respond_to?(:tty?) && $stdin.tty?
363
+ end
364
+
365
+ # Indicates whether the given answer confirms the operation.
366
+ #
367
+ # @param answer [String, nil] answer given by the user
368
+ # @return [Boolean] true when the answer is affirmative
369
+ def affirmative?(answer)
370
+ answer.to_s.strip.match?(/\Ay(es)?\z/i)
371
+ end
372
+
373
+ # Builds the message shown when the confirmation cannot be requested.
374
+ #
375
+ # @param key [String] object key in the bucket
376
+ # @return [String] message explaining how to proceed
377
+ def confirmation_required_message(key)
378
+ "Deletion of #{key} requires confirmation. Run the command in an " \
379
+ "interactive terminal or use --force to delete it without confirmation."
159
380
  end
160
381
  end
161
382
  end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module R2
4
+ # Determines the content type of an object from its key.
5
+ #
6
+ # The mapping covers the formats commonly handled by the CLI. Keys whose
7
+ # extension is not mapped fall back to the generic binary type, which is
8
+ # the same behavior offered by S3-compatible services.
9
+ #
10
+ # Reference: https://developer.mozilla.org/docs/Web/HTTP/Basics_of_HTTP/MIME_types
11
+ module ContentType
12
+ # Content type used when the object extension is not mapped.
13
+ DEFAULT = "application/octet-stream"
14
+
15
+ # Content types by file extension.
16
+ TYPES = {
17
+ ".avif" => "image/avif",
18
+ ".bmp" => "image/bmp",
19
+ ".csv" => "text/csv",
20
+ ".doc" => "application/msword",
21
+ ".docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
22
+ ".gif" => "image/gif",
23
+ ".gz" => "application/gzip",
24
+ ".htm" => "text/html",
25
+ ".html" => "text/html",
26
+ ".ico" => "image/vnd.microsoft.icon",
27
+ ".jpeg" => "image/jpeg",
28
+ ".jpg" => "image/jpeg",
29
+ ".js" => "text/javascript",
30
+ ".json" => "application/json",
31
+ ".md" => "text/markdown",
32
+ ".mp3" => "audio/mpeg",
33
+ ".mp4" => "video/mp4",
34
+ ".pdf" => "application/pdf",
35
+ ".png" => "image/png",
36
+ ".svg" => "image/svg+xml",
37
+ ".tar" => "application/x-tar",
38
+ ".tif" => "image/tiff",
39
+ ".tiff" => "image/tiff",
40
+ ".txt" => "text/plain",
41
+ ".wav" => "audio/wav",
42
+ ".webp" => "image/webp",
43
+ ".xls" => "application/vnd.ms-excel",
44
+ ".xlsx" => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
45
+ ".xml" => "application/xml",
46
+ ".yaml" => "application/yaml",
47
+ ".yml" => "application/yaml",
48
+ ".zip" => "application/zip"
49
+ }.freeze
50
+
51
+ # Determines the content type of the given object key.
52
+ #
53
+ # The extension is compared in lowercase, so keys stored with
54
+ # uppercase names are also recognized.
55
+ #
56
+ # @param key [String] object key in the bucket
57
+ # @return [String] content type corresponding to the key extension
58
+ def self.for(key)
59
+ TYPES.fetch(extension(key), DEFAULT)
60
+ end
61
+
62
+ # Extracts the extension of the given object key.
63
+ #
64
+ # @param key [String] object key in the bucket
65
+ # @return [String] lowercase extension, including the leading dot
66
+ def self.extension(key)
67
+ File.extname(key.to_s).downcase
68
+ end
69
+ end
70
+ end
data/lib/r2/errors.rb CHANGED
@@ -31,6 +31,10 @@ module R2
31
31
  class BucketNotFoundError < Error
32
32
  end
33
33
 
34
+ # The requested object does not exist in the bucket.
35
+ class ObjectNotFoundError < Error
36
+ end
37
+
34
38
  # Network failure while communicating with Cloudflare R2.
35
39
  class NetworkError < Error
36
40
  end
@@ -38,5 +42,14 @@ module R2
38
42
  # Unclassified failure in the storage layer.
39
43
  class StorageError < Error
40
44
  end
45
+
46
+ # The confirmation required before a destructive operation could not
47
+ # be requested, because the execution is not interactive.
48
+ class ConfirmationRequiredError < Error
49
+ end
50
+
51
+ # The user did not confirm a destructive operation.
52
+ class AbortedError < Error
53
+ end
41
54
  end
42
55
  end
data/lib/r2/logging.rb ADDED
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "logger"
4
+
5
+ module R2
6
+ # Builds and configures loggers used for diagnostics.
7
+ module Logging
8
+ # Discards all diagnostic messages.
9
+ #
10
+ # Used as the default when no logger is provided, keeping
11
+ # collaborators free from nil checks.
12
+ class NullLogger
13
+ # Ignores a debug message.
14
+ #
15
+ # @param _message [String] message to ignore
16
+ # @return [nil]
17
+ def debug(_message); end
18
+ end
19
+
20
+ # Builds a logger according to the requested verbosity.
21
+ #
22
+ # @param verbose [Boolean] whether detailed output is enabled
23
+ # @param output [IO] destination of the diagnostic messages
24
+ # @return [Logger, NullLogger] configured logger
25
+ def self.build(verbose: false, output: $stderr)
26
+ return NullLogger.new unless verbose
27
+
28
+ Logger.new(output).tap do |log|
29
+ log.level = Logger::DEBUG
30
+ log.formatter = proc { |_severity, _datetime, _progname, message| "#{message}\n" }
31
+ end
32
+ end
33
+ end
34
+ end