sdls 0.1.0 → 0.1.2

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 134c42aa266627ae45c1e085d2b33e36dda4a1a54ff91aac0c7c8af973f55ddd
4
- data.tar.gz: 39ebe584b534cc372ec2de8c4c7f486e4b2075734fb8bab14207f1cea2ca9a18
3
+ metadata.gz: 1649a83c3850e7e6a8657edfa6fce674ca844211d5264ef016dd9ddc6c413e8f
4
+ data.tar.gz: f826f7983ee0a67f1c1aabaeb6e172df8541f76c63415dbbf028600d479beeef
5
5
  SHA512:
6
- metadata.gz: 0a87ee27110e3e2adcecc1ee5ebdc22208a45edd552dbc2319c918f7120e78bae355fe54ee61e796536cc2e50f6c7661d91edcff9eef099152a8ada29c30f1f5
7
- data.tar.gz: fe91dc17db9b7123d927583f3b10cfaec1265f1802ff279880a99123f5a8fe57f920072cafc41429bc2907dedfbcd5507c7c4945388178925ffea8c53870b394
6
+ metadata.gz: 17ab00c3765268aa845339d54e65caea98c69e791d3f9f1d13345ab6aea5c82bc58576449b0921b1e6d9720163e30c03337ad0e1b6fbd3241b60601fe13b13d1
7
+ data.tar.gz: 54eca13f2a055533da40642741dd1e897816ea0daed83b59108cc92d53040bdeac0d29ebe3a5cdedf9e4ed19dd43c055113f3ee43a5ef0d5005d2470647f3d9f
data/lib/sdls/cli.rb CHANGED
@@ -48,8 +48,8 @@ module SDLS
48
48
  puts " host: #{current_config.host}"
49
49
  puts " username: #{current_config.username}"
50
50
  puts " password: [REDACTED]"
51
- puts " op_item_name: #{current_config.op_item_name}" if current_config.op_item_name
52
- puts " directories: #{current_config.directories.join(", ")}" if current_config.directories.any?
51
+ puts " op_item_name: #{current_config.op_item_name || "[NOT SET]"}"
52
+ puts " directories: #{current_config.directories.join(", ")}" if current_config.directories&.any?
53
53
  end
54
54
 
55
55
  desc "connect", "Verify connectivity and authentication with the server"
data/lib/sdls/client.rb CHANGED
@@ -2,6 +2,7 @@ require "net/http"
2
2
  require "uri"
3
3
  require "json"
4
4
  require "open3"
5
+ require "tty-prompt"
5
6
 
6
7
  module SDLS
7
8
  class Client
@@ -13,8 +14,8 @@ module SDLS
13
14
  end
14
15
 
15
16
  def authenticate(otp: nil)
16
- uri = build_uri("/webapi/auth.cgi", auth_params(otp))
17
- response_body = make_request(uri)
17
+ uri = URI.join(@host, "/webapi/auth.cgi")
18
+ response_body = make_post_request(uri, auth_params(otp))
18
19
 
19
20
  handle_auth_response(response_body, otp)
20
21
  rescue => e
@@ -26,7 +27,7 @@ module SDLS
26
27
  sid = authenticate
27
28
  return false unless sid
28
29
 
29
- uri = build_uri("/webapi/DownloadStation/task.cgi")
30
+ uri = URI.join(@host, "/webapi/DownloadStation/task.cgi")
30
31
  data = download_params(magnet, destination, sid)
31
32
 
32
33
  response = Net::HTTP.post_form(uri, data)
@@ -43,14 +44,8 @@ module SDLS
43
44
 
44
45
  private
45
46
 
46
- def build_uri(endpoint, params = nil)
47
- uri = URI.parse("#{@host}#{endpoint}")
48
- uri.query = URI.encode_www_form(params) if params
49
- uri
50
- end
51
-
52
- def make_request(uri)
53
- response = Net::HTTP.get_response(uri)
47
+ def make_post_request(uri, params)
48
+ response = Net::HTTP.post_form(uri, params)
54
49
  raise "HTTP error: #{response.code}" unless response.is_a?(Net::HTTPSuccess)
55
50
  JSON.parse(response.body)
56
51
  end
@@ -96,10 +91,31 @@ module SDLS
96
91
  end
97
92
 
98
93
  def retry_with_otp
99
- puts "OTP required for authentication. Fetching from 1Password..."
100
- fetched_otp = fetch_otp_from_1password
101
- return authenticate(otp: fetched_otp) if fetched_otp
102
- raise "Could not retrieve OTP from 1Password"
94
+ puts "OTP required for authentication."
95
+
96
+ # Try 1Password first if configured and available
97
+ if @op_item_name && onepassword_cli_available?
98
+ puts "Fetching OTP from 1Password..."
99
+ fetched_otp = fetch_otp_from_1password
100
+ return authenticate(otp: fetched_otp) if fetched_otp
101
+ puts "Failed to retrieve OTP from 1Password, falling back to manual entry."
102
+ end
103
+
104
+ # Fallback to manual OTP entry
105
+ fetched_manual_otp = prompt_for_manual_otp
106
+ return authenticate(otp: fetched_manual_otp) if fetched_manual_otp
107
+
108
+ raise "Could not retrieve OTP"
109
+ end
110
+
111
+ def onepassword_cli_available?
112
+ return @op_cli_available unless @op_cli_available.nil?
113
+
114
+ @op_cli_available = if ENV.key?("SDLS_FORCE_OP_CLI")
115
+ ENV["SDLS_FORCE_OP_CLI"] == "true"
116
+ else
117
+ system("which op > /dev/null 2>&1")
118
+ end
103
119
  end
104
120
 
105
121
  def fetch_otp_from_1password
@@ -113,5 +129,10 @@ module SDLS
113
129
  nil
114
130
  end
115
131
  end
132
+
133
+ def prompt_for_manual_otp
134
+ prompt = TTY::Prompt.new
135
+ prompt.mask("Please enter your OTP code:")
136
+ end
116
137
  end
117
138
  end
data/lib/sdls/config.rb CHANGED
@@ -1,31 +1,189 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "yaml"
4
+ require "open3"
5
+ require "tty-prompt"
4
6
 
5
7
  module SDLS
6
- REQUIRED_KEYS = %i[host username password]
8
+ # Configuration errors
9
+ class ConfigError < StandardError; end
10
+
11
+ class OnePasswordError < StandardError; end
12
+
13
+ # Core configuration keys that must be present
14
+ REQUIRED_KEYS = %i[host].freeze
15
+
7
16
  Config = Data.define(:host, :username, :password, :op_item_name, :directories) do
8
- def self.load(path)
9
- raise "Configuration file not found: #{path}" unless File.exist?(path)
17
+ class << self
18
+ def load(path, prompt: nil)
19
+ validate_file_exists!(path)
20
+ data = load_and_parse_yaml(path)
21
+ credentials = resolve_credentials(data, prompt: prompt)
10
22
 
11
- data = YAML.load_file(path)
12
- data = {} unless data.is_a?(Hash)
13
- data = symbolize_keys(data)
23
+ new(**data.merge(credentials))
24
+ rescue Psych::SyntaxError => e
25
+ raise ConfigError, "Error parsing configuration file (#{path}): #{e.message}"
26
+ end
14
27
 
15
- missing_keys = REQUIRED_KEYS - data.keys
16
- nil_keys = REQUIRED_KEYS.select { |k| data[k].nil? }
28
+ private
17
29
 
18
- if missing_keys.any? || nil_keys.any?
19
- raise "Configuration file (#{path}) is missing required keys or values: #{(missing_keys + nil_keys).uniq.join(", ")}"
30
+ def validate_file_exists!(path)
31
+ raise ConfigError, "Configuration file not found: #{path}" unless File.exist?(path)
20
32
  end
21
33
 
22
- new(**data)
23
- rescue Psych::SyntaxError => e
24
- raise "Error parsing configuration file (#{path}): #{e.message}"
25
- end
34
+ def load_and_parse_yaml(path)
35
+ data = YAML.load_file(path)
36
+ data = {} unless data.is_a?(Hash)
37
+ data = symbolize_keys(data)
38
+
39
+ validate_required_keys!(data, path)
40
+ set_defaults(data)
41
+ end
42
+
43
+ def validate_required_keys!(data, path)
44
+ missing_keys = REQUIRED_KEYS - data.keys
45
+ nil_keys = REQUIRED_KEYS.select { |k| data[k].nil? || data[k].to_s.strip.empty? }
46
+
47
+ if missing_keys.any? || nil_keys.any?
48
+ invalid_keys = (missing_keys + nil_keys).uniq
49
+ raise ConfigError, "Configuration file (#{path}) is missing required keys or values: #{invalid_keys.join(", ")}"
50
+ end
51
+ end
52
+
53
+ def set_defaults(data)
54
+ data[:op_item_name] ||= nil
55
+ data[:directories] ||= []
56
+ data
57
+ end
58
+
59
+ def symbolize_keys(hash)
60
+ hash.to_h { |k, v| [k.to_sym, v] }
61
+ end
62
+
63
+ def resolve_credentials(data, prompt: nil)
64
+ op_item_name = data[:op_item_name]
65
+
66
+ # Check if we have both username and password from config
67
+ has_config_username = data[:username] && !data[:username].to_s.strip.empty?
68
+ has_config_password = data[:password] && !data[:password].to_s.strip.empty?
69
+
70
+ # If we have both credentials from config, use them
71
+ if has_config_username && has_config_password
72
+ return {
73
+ username: data[:username],
74
+ password: data[:password]
75
+ }
76
+ end
77
+
78
+ # If 1Password item is specified and we're missing some credentials, try to fetch from there
79
+ if op_item_name && !op_item_name.to_s.strip.empty? && (!has_config_username || !has_config_password)
80
+ onepassword_credentials = fetch_credentials_from_1password(op_item_name)
81
+ if onepassword_credentials[:success]
82
+ final_username = has_config_username ? data[:username] : onepassword_credentials[:username]
83
+ final_password = has_config_password ? data[:password] : onepassword_credentials[:password]
84
+
85
+ # If we still don't have username or password after 1Password, prompt for them
86
+ final_username ||= prompt_for_credential("username", mask: false, prompt: prompt)
87
+ final_password ||= prompt_for_credential("password", mask: true, prompt: prompt)
88
+
89
+ return {
90
+ username: final_username,
91
+ password: final_password
92
+ }
93
+ end
94
+ end
95
+
96
+ # Fallback to config file and manual entry
97
+ {
98
+ username: resolve_username(data[:username], nil, prompt: prompt),
99
+ password: resolve_password(data[:password], nil, prompt: prompt)
100
+ }
101
+ end
102
+
103
+ def resolve_username(config_username, op_username, prompt: nil)
104
+ # Priority 1: 1Password username if available
105
+ return op_username if op_username && !op_username.strip.empty?
26
106
 
27
- def self.symbolize_keys(hash)
28
- hash.to_h { |k, v| [k.to_sym, v] }
107
+ # Priority 2: Config file username if available
108
+ return config_username if config_username && !config_username.strip.empty?
109
+
110
+ # Priority 3: Manual entry
111
+ prompt_for_credential("username", mask: false, prompt: prompt)
112
+ end
113
+
114
+ def resolve_password(config_password, op_password, prompt: nil)
115
+ # Priority 1: 1Password password if available
116
+ return op_password if op_password && !op_password.strip.empty?
117
+
118
+ # Priority 2: Config file password if available
119
+ return config_password if config_password && !config_password.strip.empty?
120
+
121
+ # Priority 3: Manual entry
122
+ prompt_for_credential("password", mask: true, prompt: prompt)
123
+ end
124
+
125
+ def fetch_credentials_from_1password(op_item_name)
126
+ return {success: false} unless onepassword_cli_available?
127
+
128
+ puts "Fetching credentials from 1Password for item: #{op_item_name}..."
129
+
130
+ username = fetch_field_from_1password(op_item_name, "username")
131
+ password = fetch_field_from_1password(op_item_name, "password")
132
+
133
+ puts "1Password item '#{op_item_name}' retrieved successfully."
134
+ puts "Username: #{username.nil? ? "not found" : username}"
135
+
136
+ if username || password
137
+ success_msg = []
138
+ success_msg << "username" if username
139
+ success_msg << "password" if password
140
+ puts "Successfully retrieved #{success_msg.join(" and ")} from 1Password"
141
+
142
+ {success: true, username: username, password: password}
143
+ else
144
+ puts "No credentials found in 1Password item"
145
+ {success: false}
146
+ end
147
+ rescue OnePasswordError => e
148
+ puts "1Password error: #{e.message}"
149
+ {success: false}
150
+ rescue => e
151
+ puts "Unexpected error fetching credentials from 1Password: #{e.message}"
152
+ {success: false}
153
+ end
154
+
155
+ def fetch_field_from_1password(op_item_name, field)
156
+ stdout, _, status = Open3.capture3(
157
+ "op", "item", "get", op_item_name, "--fields", field, "--reveal"
158
+ )
159
+
160
+ if status.success? && !stdout.strip.empty?
161
+ stdout.strip
162
+ end
163
+ rescue => e
164
+ raise OnePasswordError, "Failed to retrieve #{field} from 1Password: #{e.message}"
165
+ end
166
+
167
+ def onepassword_cli_available?
168
+ return @op_cli_available unless @op_cli_available.nil?
169
+
170
+ @op_cli_available = if ENV.key?("SDLS_FORCE_OP_CLI")
171
+ ENV["SDLS_FORCE_OP_CLI"] == "true"
172
+ else
173
+ system("which op > /dev/null 2>&1")
174
+ end
175
+ end
176
+
177
+ def prompt_for_credential(type, mask: false, prompt: nil)
178
+ puts "No #{type} available, please enter manually:"
179
+ prompt ||= TTY::Prompt.new
180
+
181
+ if mask
182
+ prompt.mask("Please enter your #{type}:")
183
+ else
184
+ prompt.ask("Please enter your #{type}:")
185
+ end
186
+ end
29
187
  end
30
188
  end
31
189
  end
data/lib/sdls/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SDLS
4
- VERSION = "0.1.0"
4
+ VERSION = "0.1.2"
5
5
  end
metadata CHANGED
@@ -1,16 +1,58 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: sdls
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.1.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Camillo Visini
8
8
  bindir: bin
9
9
  cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
- dependencies: []
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: thor
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '1.3'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '1.3'
26
+ - !ruby/object:Gem::Dependency
27
+ name: tty-prompt
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '0.23'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '0.23'
40
+ - !ruby/object:Gem::Dependency
41
+ name: clipboard
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '2.0'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '2.0'
12
54
  description: A command-line interface for managing downloads on Synology Download
13
- Station
55
+ Station.
14
56
  executables:
15
57
  - sdls
16
58
  extensions: []