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/lib/r2/retry.rb ADDED
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ module R2
4
+ # Automatic retries with exponential backoff.
5
+ #
6
+ # Transient failures, such as brief network instabilities, are retried a
7
+ # limited number of times. The wait between the attempts grows
8
+ # exponentially and is capped, so the remote service has time to recover
9
+ # without making the user wait indefinitely.
10
+ #
11
+ # Only the error classes informed as transient are retried; every other
12
+ # failure is raised immediately, preserving the domain error mapping.
13
+ module Retry
14
+ # Total number of attempts: the first execution plus the retries.
15
+ DEFAULT_MAX_ATTEMPTS = 3
16
+
17
+ # Wait applied after the first failed attempt, in seconds.
18
+ DEFAULT_BASE_DELAY = 0.5
19
+
20
+ # Upper limit of the wait between attempts, in seconds.
21
+ DEFAULT_MAX_DELAY = 5.0
22
+
23
+ # Waiting strategy used when nothing else is provided.
24
+ DEFAULT_SLEEPER = ->(seconds) { sleep(seconds) }
25
+
26
+ # Description used in the diagnostics when the caller gives none.
27
+ DEFAULT_DESCRIPTION = "operation"
28
+
29
+ # Retry settings shared by the operations of a client.
30
+ class Policy
31
+ attr_reader :max_attempts, :base_delay, :max_delay
32
+
33
+ # @param max_attempts [Integer] total number of attempts
34
+ # @param base_delay [Float] wait after the first failure, in seconds
35
+ # @param max_delay [Float] upper limit of the wait, in seconds
36
+ def initialize(
37
+ max_attempts: DEFAULT_MAX_ATTEMPTS,
38
+ base_delay: DEFAULT_BASE_DELAY,
39
+ max_delay: DEFAULT_MAX_DELAY
40
+ )
41
+ @max_attempts = max_attempts
42
+ @base_delay = base_delay
43
+ @max_delay = max_delay
44
+ end
45
+
46
+ # Wait applied after the given failed attempt.
47
+ #
48
+ # @param attempt [Integer] number of the attempt that just failed
49
+ # @return [Float] seconds to wait before the next attempt
50
+ def delay_for(attempt)
51
+ [@base_delay * (2**(attempt - 1)), @max_delay].min
52
+ end
53
+ end
54
+
55
+ # Runs the block, retrying the transient failures it raises.
56
+ #
57
+ # @param policy [Policy] retry settings
58
+ # @param retry_on [Array<Class>] error classes considered transient
59
+ # @param logger [#debug, nil] logger used for diagnostics
60
+ # @param sleeper [#call, nil] waiting strategy used between attempts
61
+ # @param description [String, nil] operation description used in the diagnostics
62
+ # @yield the operation to run
63
+ # @return [Object] result of the block
64
+ # @raise [StandardError] error of the last attempt when the retries are exhausted
65
+ def self.call(policy: Policy.new, retry_on: [], logger: nil, sleeper: nil, description: nil)
66
+ attempt = 0
67
+ description ||= DEFAULT_DESCRIPTION
68
+
69
+ begin
70
+ attempt += 1
71
+ yield
72
+ rescue StandardError => e
73
+ raise unless transient?(e, retry_on) && attempt < policy.max_attempts
74
+
75
+ delay = policy.delay_for(attempt)
76
+ logger&.debug(retry_message(attempt, policy, description, e, delay))
77
+ (sleeper || DEFAULT_SLEEPER).call(delay)
78
+ retry
79
+ end
80
+ end
81
+
82
+ # Indicates whether an error is considered transient.
83
+ #
84
+ # @param error [StandardError] error raised by the operation
85
+ # @param retry_on [Array<Class>] error classes considered transient
86
+ # @return [Boolean] true when the failure may be retried
87
+ def self.transient?(error, retry_on)
88
+ retry_on.any? { |error_class| error.is_a?(error_class) }
89
+ end
90
+
91
+ # Builds the diagnostic message of a retry.
92
+ #
93
+ # @param attempt [Integer] number of the attempt that just failed
94
+ # @param policy [Policy] retry settings
95
+ # @param description [String] description of the operation
96
+ # @param error [StandardError] error raised by the attempt
97
+ # @param delay [Float] seconds to wait before the next attempt
98
+ # @return [String] message describing the retry
99
+ def self.retry_message(attempt, policy, description, error, delay)
100
+ "Retrying #{description} in #{delay}s (attempt #{attempt + 1} of " \
101
+ "#{policy.max_attempts}) after a transient failure: " \
102
+ "#{error.class}: #{error.message}"
103
+ end
104
+ end
105
+ end
data/lib/r2/storage.rb CHANGED
@@ -1,19 +1,34 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "aws-sdk-s3"
4
+ require_relative "content_type"
4
5
  require_relative "errors"
6
+ require_relative "logging"
7
+ require_relative "retry"
5
8
 
6
9
  module R2
7
10
  # Storage layer responsible for communicating with Cloudflare R2.
8
11
  #
9
12
  # Uses the `aws-sdk-s3` gem with the S3-compatible endpoint provided by
10
13
  # the application configuration.
14
+ #
15
+ # Transient network failures are retried with exponential backoff
16
+ # (see `R2::Retry`), so brief instabilities do not fail the operation.
11
17
  class Storage
18
+ # Error classes considered transient and therefore retried.
19
+ RETRYABLE_ERRORS = [Seahorse::Client::NetworkingError].freeze
20
+
12
21
  # Initializes the storage with the configured credentials and bucket.
13
22
  #
14
23
  # @param config [Configuration] application configuration
15
- def initialize(config)
24
+ # @param logger [#debug, nil] logger used for diagnostics
25
+ # @param retry_policy [Retry::Policy, nil] retry settings, defaults to the project policy
26
+ # @param sleeper [#call, nil] waiting strategy used between attempts
27
+ def initialize(config, logger: nil, retry_policy: nil, sleeper: nil)
16
28
  @bucket = config.bucket
29
+ @logger = logger || R2::Logging::NullLogger.new
30
+ @retry_policy = retry_policy || Retry::Policy.new
31
+ @sleeper = sleeper
17
32
  @s3 = Aws::S3::Client.new(
18
33
  region: config.region,
19
34
  access_key_id: config.access_key_id,
@@ -28,17 +43,33 @@ module R2
28
43
  # Receives content already prepared by the layer that uses the storage
29
44
  # and delivers it to Cloudflare R2.
30
45
  #
46
+ # The content type is determined from the object key, unless an
47
+ # explicit value is given, so the stored object is served with the
48
+ # correct type instead of the generic binary type.
49
+ #
50
+ # When the content comes from a stream, it is rewound before every
51
+ # attempt, since a retry must read the content from the beginning.
52
+ #
31
53
  # @param key [String] object key in the bucket
32
54
  # @param body [IO, String] content of the object to upload
55
+ # @param content_type [String, nil] content type stored in the object metadata
33
56
  # @raise [Errors::Error] if the operation fails
34
- def upload(key:, body:)
35
- @s3.put_object(
36
- bucket: @bucket,
37
- key: key,
38
- body: body
39
- )
57
+ def upload(key:, body:, content_type: nil)
58
+ type = content_type || ContentType.for(key)
59
+ @logger.debug("Uploading object #{key.inspect} as #{type.inspect} to bucket #{@bucket.inspect}.")
60
+ with_retries(operation: "upload", key: key) do
61
+ body.rewind if body.respond_to?(:rewind)
62
+ @s3.put_object(
63
+ bucket: @bucket,
64
+ key: key,
65
+ body: body,
66
+ content_type: type
67
+ )
68
+ end
69
+ @logger.debug("Upload of object #{key.inspect} completed.")
70
+ nil
40
71
  rescue StandardError => e
41
- raise_storage_error(e)
72
+ raise_storage_error(e, operation: "upload", key: key)
42
73
  end
43
74
 
44
75
  # Deletes an object from the configured bucket.
@@ -49,45 +80,173 @@ module R2
49
80
  # @param key [String] object key in the bucket
50
81
  # @raise [Errors::Error] if the operation fails
51
82
  def delete(key:)
52
- @s3.delete_object(
53
- bucket: @bucket,
54
- key: key
55
- )
83
+ @logger.debug("Deleting object #{key.inspect} from bucket #{@bucket.inspect}.")
84
+ with_retries(operation: "delete", key: key) do
85
+ @s3.delete_object(
86
+ bucket: @bucket,
87
+ key: key
88
+ )
89
+ end
90
+ @logger.debug("Deletion of object #{key.inspect} completed.")
91
+ nil
56
92
  rescue StandardError => e
57
- raise_storage_error(e)
93
+ raise_storage_error(e, operation: "delete", key: key)
58
94
  end
59
95
 
60
96
  # Lists the objects stored in the configured bucket.
61
97
  #
98
+ # Every page of the listing is requested, so all the stored objects
99
+ # are returned regardless of the amount of keys in the bucket.
100
+ #
101
+ # @param prefix [String, nil] lists only the objects whose keys start with the prefix
62
102
  # @return [Array<String>] keys of the stored objects
63
103
  # @raise [Errors::Error] if the operation fails
64
- def list
65
- response = @s3.list_objects_v2(bucket: @bucket)
66
- response.contents.map(&:key)
104
+ def list(prefix: nil)
105
+ @logger.debug("Listing objects in bucket #{@bucket.inspect} with prefix #{prefix.inspect}.")
106
+ keys = []
107
+ token = nil
108
+
109
+ loop do
110
+ response = list_page(token, prefix)
111
+ keys.concat(response.contents.map(&:key))
112
+ token = response.next_continuation_token
113
+ break if token.nil? || token.empty?
114
+ end
115
+
116
+ @logger.debug("Found #{keys.size} object(s) in bucket #{@bucket.inspect}.")
117
+ keys
118
+ rescue StandardError => e
119
+ raise_storage_error(e, operation: "list")
120
+ end
121
+
122
+ # Downloads an object from the configured bucket.
123
+ #
124
+ # The content is streamed directly to the destination file, so large
125
+ # objects do not need to be fully loaded into memory. Every attempt
126
+ # writes the file from the beginning, avoiding a partially written
127
+ # content when a retry is needed.
128
+ #
129
+ # @param key [String] object key in the bucket
130
+ # @param destination [String] local path where the content is written
131
+ # @return [String] destination path
132
+ # @raise [Errors::Error] if the operation fails
133
+ def download(key:, destination:)
134
+ @logger.debug("Downloading object #{key.inspect} to #{destination.inspect}.")
135
+ with_retries(operation: "download", key: key) do
136
+ File.open(destination, "wb") do |file|
137
+ @s3.get_object(bucket: @bucket, key: key, response_target: file)
138
+ end
139
+ end
140
+ @logger.debug("Download of object #{key.inspect} completed.")
141
+ destination
67
142
  rescue StandardError => e
68
- raise_storage_error(e)
143
+ raise_storage_error(e, operation: "download", key: key, destination: destination)
144
+ end
145
+
146
+ # Checks whether an object exists in the configured bucket.
147
+ #
148
+ # The object metadata is requested instead of the content, so the
149
+ # check is cheap regardless of the object size.
150
+ #
151
+ # @param key [String] object key in the bucket
152
+ # @return [Boolean] true when the object exists
153
+ # @raise [Errors::Error] if the operation fails
154
+ def exists?(key:)
155
+ @logger.debug("Checking whether object #{key.inspect} exists in bucket #{@bucket.inspect}.")
156
+ with_retries(operation: "exists", key: key) do
157
+ @s3.head_object(bucket: @bucket, key: key)
158
+ end
159
+ @logger.debug("Object #{key.inspect} was found in bucket #{@bucket.inspect}.")
160
+ true
161
+ rescue Aws::S3::Errors::NoSuchKey, Aws::S3::Errors::NotFound
162
+ @logger.debug("Object #{key.inspect} was not found in bucket #{@bucket.inspect}.")
163
+ false
164
+ rescue StandardError => e
165
+ raise_storage_error(e, operation: "exists", key: key)
69
166
  end
70
167
 
71
168
  private
72
169
 
170
+ # Requests a single page of the object listing.
171
+ #
172
+ # @param token [String, nil] continuation token of the page
173
+ # @param prefix [String, nil] lists only the objects whose keys start with the prefix
174
+ # @return [Object] response of the listing operation
175
+ def list_page(token, prefix)
176
+ params = { bucket: @bucket }
177
+ params[:prefix] = prefix unless prefix.nil?
178
+ params[:continuation_token] = token unless token.nil?
179
+
180
+ with_retries(operation: "list") { @s3.list_objects_v2(**params) }
181
+ end
182
+
183
+ # Runs the given block retrying transient network failures.
184
+ #
185
+ # @param operation [String] operation being performed
186
+ # @param key [String, nil] object key involved in the operation
187
+ # @yield the request to run against the storage
188
+ # @return [Object] result of the block
189
+ def with_retries(operation:, key: nil, &)
190
+ R2::Retry.call(
191
+ policy: @retry_policy,
192
+ retry_on: RETRYABLE_ERRORS,
193
+ logger: @logger,
194
+ sleeper: @sleeper,
195
+ description: describe_operation(operation, key),
196
+ &
197
+ )
198
+ end
199
+
200
+ # Builds the description of an operation used in the diagnostics.
201
+ #
202
+ # @param operation [String] operation being performed
203
+ # @param key [String, nil] object key involved in the operation
204
+ # @return [String] operation description
205
+ def describe_operation(operation, key)
206
+ return "#{operation} on bucket #{@bucket}" if key.nil?
207
+
208
+ "#{operation} of #{key}"
209
+ end
210
+
73
211
  # Converts storage layer errors into project domain errors, avoiding
74
212
  # exposing internal details of the implementations.
75
213
  #
76
214
  # The original message is preserved when useful.
77
215
  #
78
216
  # @param error [StandardError] original error
217
+ # @param operation [String] operation being performed
218
+ # @param key [String, nil] object key involved in the operation
219
+ # @param destination [String, nil] destination path involved
79
220
  # @raise [Errors::Error] subclass matching the cause of the error
80
- def raise_storage_error(error)
221
+ def raise_storage_error(error, operation:, key: nil, destination: nil)
222
+ raise error if error.is_a?(Errors::Error)
223
+
224
+ @logger.debug("Operation #{operation} failed: #{error.class}: #{error.message}")
225
+
226
+ mapped = map_storage_error(error, key: key, destination: destination)
227
+ raise mapped unless mapped.nil?
228
+
229
+ raise Errors::StorageError, error.message
230
+ end
231
+
232
+ # Maps known storage failures to domain errors.
233
+ #
234
+ # @param error [StandardError] original error
235
+ # @param key [String, nil] object key involved in the operation
236
+ # @param destination [String, nil] destination path involved
237
+ # @return [Errors::Error, nil] mapped domain error, if recognized
238
+ def map_storage_error(error, key:, destination:)
81
239
  case error
82
240
  when Aws::Errors::MissingCredentialsError
83
- raise Errors::ConfigurationError,
84
- "Missing or invalid credential environment variables."
241
+ Errors::ConfigurationError.new("Missing or invalid credential environment variables.")
85
242
  when Aws::S3::Errors::NoSuchBucket
86
- raise Errors::BucketNotFoundError, "Bucket not found: #{@bucket}"
243
+ Errors::BucketNotFoundError.new("Bucket not found: #{@bucket}")
244
+ when Aws::S3::Errors::NoSuchKey, Aws::S3::Errors::NotFound
245
+ Errors::ObjectNotFoundError.new("Object not found: #{key}")
87
246
  when Seahorse::Client::NetworkingError
88
- raise Errors::NetworkError, error.message
89
- else
90
- raise Errors::StorageError, error.message
247
+ Errors::NetworkError.new(error.message)
248
+ when Errno::EACCES, Errno::EPERM
249
+ Errors::PermissionError.new("Permission denied to write the file: #{destination || key}")
91
250
  end
92
251
  end
93
252
  end
data/lib/r2/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module R2
4
- VERSION = "1.0.0"
4
+ VERSION = "1.2.0"
5
5
  end
data/lib/r2.rb CHANGED
@@ -2,6 +2,9 @@
2
2
 
3
3
  require "r2/cli"
4
4
  require "r2/configuration"
5
+ require "r2/content_type"
5
6
  require "r2/errors"
7
+ require "r2/logging"
8
+ require "r2/retry"
6
9
  require "r2/storage"
7
10
  require "r2/version"
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: cloudflare-r2-cli
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.0
4
+ version: 1.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - rpzerosixcode
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-08-30 00:00:00.000000000 Z
11
+ date: 2026-09-21 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: aws-sdk-s3
@@ -117,25 +117,20 @@ extensions: []
117
117
  extra_rdoc_files: []
118
118
  files:
119
119
  - CHANGELOG.md
120
- - LICENCE
120
+ - LICENSE
121
121
  - README.md
122
122
  - Rakefile
123
123
  - bin/r2
124
124
  - docs/ARCHITECTURE.md
125
- - docs/DECISIONS.md
126
- - docs/DEVELOPMENT.md
127
125
  - docs/FEATURES.md
128
- - docs/ROADMAP.md
129
126
  - docs/SECURITY.md
130
- - docs/architecture/cli.md
131
- - docs/architecture/configuration.md
132
- - docs/architecture/errors.md
133
- - docs/architecture/storage.md
134
- - docs/architecture/testing.md
135
127
  - lib/r2.rb
136
128
  - lib/r2/cli.rb
137
129
  - lib/r2/configuration.rb
130
+ - lib/r2/content_type.rb
138
131
  - lib/r2/errors.rb
132
+ - lib/r2/logging.rb
133
+ - lib/r2/retry.rb
139
134
  - lib/r2/storage.rb
140
135
  - lib/r2/version.rb
141
136
  homepage: https://github.com/rpzerosixcode/cloudflare-r2-cli
data/docs/DECISIONS.md DELETED
@@ -1,30 +0,0 @@
1
- # Decisions
2
-
3
- This document records the main architectural and project-level decisions.
4
-
5
- ## Name
6
-
7
- The publication name of the project is **`cloudflare-r2-cli`**.
8
-
9
- For command usage, **`r2`** is used.
10
-
11
- ## Content
12
-
13
- The project's public content is maintained in **English**.
14
-
15
- ## Changelog
16
-
17
- Formal changelog maintenance starts with version **`1.0.0`** in
18
- [CHANGELOG.md](../CHANGELOG.md).
19
-
20
- ## Versioning
21
-
22
- The project follows **Semantic Versioning**, starting with version **`1.0.0`**.
23
-
24
- ## Dependency Injection
25
-
26
- Dependencies should preferably be provided through **dependency injection**,
27
- avoiding unnecessary coupling to concrete implementations.
28
-
29
- The project does not use a dependency injection container. Dependencies are
30
- provided directly by the components that require them.
data/docs/DEVELOPMENT.md DELETED
@@ -1,65 +0,0 @@
1
- # Development
2
-
3
- ## Branches
4
-
5
- The project uses two main branches:
6
-
7
- * `develop`: development.
8
- * `main`: stable version.
9
-
10
- ## Pull Requests
11
-
12
- Changes between branches must be made through Pull Requests.
13
-
14
- Pull Requests must be clear, objective and pass the required checks before merging.
15
-
16
- ## Commits
17
-
18
- Commits must follow the **Conventional Commits** convention, using types such as:
19
-
20
- * `feat`: new feature.
21
- * `fix`: bug fix.
22
- * `docs`: documentation change.
23
- * `refactor`: refactoring without behavior change.
24
- * `test`: creation or change of tests.
25
- * `chore`: maintenance tasks.
26
-
27
- ## Continuous Integration
28
-
29
- The project uses **GitHub Actions** to automatically validate changes on every
30
- push to the `develop` and `main` branches and on Pull Requests.
31
-
32
- The workflow defined in `.github/workflows/ci.yml` runs:
33
-
34
- * **Lint** — RuboCop.
35
- * **Tests** — unit, integration and E2E suites. The E2E scenarios are marked
36
- as pending when the test credentials are not configured in the repository
37
- secrets (`R2_ACCESS_KEY_ID`, `R2_SECRET_ACCESS_KEY`, `R2_ENDPOINT`,
38
- `R2_REGION` and `R2_TEST_BUCKET`).
39
- * **Packaging** — gem build (`rake build`).
40
-
41
- ## Releases
42
-
43
- Releases are published from tags in the `v*` format (for example, `v1.0.0`).
44
-
45
- The workflow defined in `.github/workflows/release.yml`:
46
-
47
- * validates the project (lint, tests and packaging);
48
- * publishes the gem to RubyGems using the `RUBYGEMS_API_KEY` repository secret;
49
- * creates a GitHub Release with the packed gem attached.
50
-
51
- To release a new version:
52
-
53
- 1. Update the version in `lib/r2/version.rb` and the changelog in `CHANGELOG.md`.
54
- 2. Merge the changes into `main`.
55
- 3. Create and push the version tag:
56
- ```console
57
- $ git tag v1.0.0
58
- $ git push origin v1.0.0
59
- ```
60
-
61
- ## Principles
62
-
63
- Development must prioritize simplicity, organization and code maintenance.
64
-
65
- Changes must remain aligned with the current scope of the project and its documentation.
data/docs/ROADMAP.md DELETED
@@ -1,69 +0,0 @@
1
- # Roadmap
2
-
3
- The roadmap tracks the planned evolution of the project.
4
-
5
- ## MVP
6
-
7
- The MVP was completed through the phases below.
8
-
9
- ### Phase 1 — Initial Preparation
10
-
11
- Initial structure and fundamental project definitions.
12
-
13
- ### Phase 2 — Features
14
-
15
- Implementation of the essential MVP features.
16
-
17
- #### Phase 2.1 — Upload
18
-
19
- Implementation of the upload feature.
20
-
21
- #### Phase 2.2 — Delete
22
-
23
- Implementation of the delete feature.
24
-
25
- #### Phase 2.3 — List
26
-
27
- Implementation of the list feature.
28
-
29
- ### Phase 3 — Test Coverage
30
-
31
- Implementation and expansion of the project's test coverage.
32
-
33
- ### Phase 4 — Refinement and Stabilization
34
-
35
- Review, refinement and stabilization of the project.
36
-
37
- - **Portability** — ensure the CLI works in different environments and operating systems.
38
- - **Consistency** — review and standardize code, tests, messages and behaviors.
39
- - **Error handling** — review exception handling and ensure clear, safe messages.
40
- - **Security** — review settings and ensure sensitive information is not exposed.
41
- - **Documentation** — review and update the public documentation according to the current state of the project.
42
- - **Development context** — remove or isolate documentation exclusively related to the development process.
43
- - **Packaging** — validate the build, installation and execution of the distributed package.
44
- - **Continuous integration** — integrate the CI flow into the development process, ensuring automated execution of tests and checks.
45
- - **Final validation** — run the full test suite and validate the project in a clean environment.
46
-
47
- ### Phase 5 — Release
48
-
49
- Preparation and publication of the first stable version of the project.
50
-
51
- - **Versioning** — adopt semantic versioning from `1.0.0`.
52
- - **Changelog** — start formal changelog maintenance from `1.0.0`.
53
- - **Documentation** — normalize the public documentation according to the stable version.
54
- - **Development context** — remove or isolate development-specific documentation that is no longer relevant.
55
- - **MVP context** — remove or update MVP-specific notes and references that no longer apply to the stable version.
56
- - **Translation** — translate and standardize the project content to English.
57
- - **Release validation** — validate the version, build and release artifacts before publication.
58
- - **Publication** — publish the `cloudflare-r2-cli` package on RubyGems.
59
- - **Post-release validation** — install the published package in a clean environment and confirm it works.
60
-
61
- ## Future Evolution
62
-
63
- Possible next steps for the project:
64
-
65
- - **Pagination and control of the number of returned objects** in the `list` command.
66
- - **Additional configuration sources** such as files and command-line flags.
67
- - **Multipart uploads** for large files.
68
-
69
- > **Note:** The focus will remain a **minimally scalable base** and **essential features**, not optimizations.
@@ -1,24 +0,0 @@
1
- # CLI
2
-
3
- The CLI is responsible for interpreting the user's input, executing the
4
- corresponding actions and presenting the results.
5
-
6
- The `thor` gem is used to define and execute the commands.
7
-
8
- ## Responsibility
9
-
10
- The CLI acts as a **minimal orchestrator**, coordinating the operations at a
11
- high level.
12
-
13
- ## Boundaries
14
-
15
- The CLI must not implement business rules, directly handle files or know
16
- details of the implementations and services used.
17
-
18
- The execution of the operations must be delegated to the responsible
19
- components.
20
-
21
- ## Commands
22
-
23
- The available commands and their behaviors are documented in
24
- [FEATURES.md](../FEATURES.md).
@@ -1,24 +0,0 @@
1
- # Configuration
2
-
3
- The `Configuration` centralizes the application settings.
4
-
5
- ## Source
6
-
7
- The settings are obtained directly from **environment variables**. The other
8
- components must not access `ENV` directly.
9
-
10
- ## Variables
11
-
12
- The required variables are `R2_ACCESS_KEY_ID`, `R2_SECRET_ACCESS_KEY`,
13
- `R2_ENDPOINT` and `R2_BUCKET`.
14
-
15
- The `R2_REGION` variable is optional and, when absent, uses the default value
16
- `auto`, recommended for Cloudflare R2.
17
-
18
- When a required variable is absent, the `Configuration` raises
19
- `R2::Errors::ConfigurationError` with a message indicating the variable.
20
-
21
- ## Evolution
22
-
23
- The source of the settings may be diversified in the future if a real need
24
- arises.
@@ -1,25 +0,0 @@
1
- # Errors
2
-
3
- Errors are centralized to standardize their handling and keep the application
4
- behavior consistent.
5
-
6
- ## Hierarchy
7
-
8
- All domain errors inherit from `R2::Errors::Error`:
9
-
10
- * `ConfigurationError` — required configuration missing or invalid.
11
- * `FileNotFoundError` — the given file does not exist.
12
- * `InvalidFileError` — the given path is not a file.
13
- * `PermissionError` — no permission to read the given file.
14
- * `BucketNotFoundError` — the configured bucket does not exist.
15
- * `NetworkError` — network failure while communicating with Cloudflare R2.
16
- * `StorageError` — unclassified failure in the storage layer.
17
-
18
- ## Handling
19
-
20
- The specific exceptions of the implementations are converted to the
21
- `R2::Errors` hierarchy, avoiding exposing internal details of the libraries
22
- and allowing consumers to catch the generic error or a specific error.
23
-
24
- The CLI catches `R2::Errors::Error`, presents the message on the error output
25
- and exits with a non-zero status code.