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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +86 -2
- data/README.md +109 -36
- data/docs/ARCHITECTURE.md +177 -82
- data/docs/FEATURES.md +121 -5
- data/docs/SECURITY.md +4 -0
- data/lib/r2/cli.rb +234 -13
- data/lib/r2/content_type.rb +70 -0
- data/lib/r2/errors.rb +13 -0
- data/lib/r2/logging.rb +34 -0
- data/lib/r2/retry.rb +105 -0
- data/lib/r2/storage.rb +183 -24
- data/lib/r2/version.rb +1 -1
- data/lib/r2.rb +3 -0
- metadata +6 -11
- data/docs/DECISIONS.md +0 -30
- data/docs/DEVELOPMENT.md +0 -65
- data/docs/ROADMAP.md +0 -69
- data/docs/architecture/cli.md +0 -24
- data/docs/architecture/configuration.md +0 -24
- data/docs/architecture/errors.md +0 -25
- data/docs/architecture/storage.md +0 -46
- data/docs/architecture/testing.md +0 -48
- /data/{LICENCE → LICENSE} +0 -0
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
|
-
**
|
|
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
|
|
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
|
|
53
|
-
|
|
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
|
|
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:
|
|
70
|
-
puts "
|
|
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 "
|
|
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
|
-
|
|
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
|