sdls 0.1.1 → 0.1.3

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