portage-cli 0.12.0 → 0.13.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.
@@ -2,19 +2,13 @@ require "json"
2
2
  require "fileutils"
3
3
  require "securerandom"
4
4
  require "uri"
5
- require "net/http"
6
5
  require "portage/ucp"
7
6
  require "portage/ucp/client"
8
- require "portage/ucp/support/connection"
9
7
 
10
- # Buy::PermissiveAuthenticator (adapter-loopback auth) is used by
11
- # #adapter_session below — not require_relative'd here to avoid a load
12
- # cycle (buy.rb will require this file too, for PaymentMethods.default);
13
- # cli.rb requires "cli/buy" before "cli/payment_methods", so it's already
14
- # loaded by the time #enroll actually runs.
15
8
  require_relative "payment_methods/keychain_backend"
16
9
  require_relative "payment_methods/secret_service_backend"
17
10
  require_relative "payment_methods/env_backend"
11
+ require_relative "adapter_session"
18
12
  require_relative "user_agent"
19
13
  require_relative "handoff_only"
20
14
 
@@ -220,7 +214,8 @@ module Portage
220
214
  # Same native-manifest-first, own-store-adapter-fallback discovery as
221
215
  # Buy#call — duplicated rather than extracted since Buy's version is
222
216
  # entangled with cart/checkout-specific branching this only needs the
223
- # session object from.
217
+ # session object from. The adapter fallback itself is AdapterSession,
218
+ # shared with HandoffReconciler.
224
219
  def discover_session(url)
225
220
  native = Portage::Ucp::Client.discover(url, headers: UserAgent.headers)
226
221
  native if native
@@ -228,29 +223,11 @@ module Portage
228
223
  adapter_session(url)
229
224
  end
230
225
 
226
+ # The homepage fetch stays on the :payment route (forced direct unless
227
+ # a proxy is named for it), redirects included.
231
228
  def adapter_session(url)
232
- uri = URI.parse(url.to_s =~ %r{\Ahttps?://}i ? url.to_s : "https://#{url}")
233
- body, headers = fetch_homepage(uri)
234
- platform = body && Portage::Ucp::Resolver.detect_platform(body, headers)
235
- return nil unless platform
236
-
237
- env = Portage::Ucp::Resolver.env_for(platform)
238
- return nil if Portage::Ucp::Resolver.missing_env(platform, env).any?
239
-
240
- adapter = Portage::Ucp::Resolver.build_adapter(platform, env)
241
- Portage::Ucp::Client.for_adapter(adapter, authenticator: Buy::PermissiveAuthenticator.new)
242
- rescue StandardError
243
- nil
244
- end
245
-
246
- def fetch_homepage(uri)
247
- response = Portage::Ucp::Support::Connection.start(uri, route: :payment, open_timeout: 5,
248
- read_timeout: 5) do |http|
249
- http.get(uri.request_uri, UserAgent.headers)
250
- end
251
- response.is_a?(Net::HTTPSuccess) ? [response.body, response.to_hash] : [nil, {}]
252
- rescue StandardError
253
- [nil, {}]
229
+ uri = normalized_uri(url)
230
+ uri && AdapterSession.call(uri, route: :payment)
254
231
  end
255
232
 
256
233
  def payment_enrollment_advertised?(session)
@@ -1,6 +1,7 @@
1
1
  require_relative "notifier"
2
2
  require_relative "macos_notifier"
3
3
  require_relative "reconcile_notify"
4
+ require_relative "money"
4
5
 
5
6
  module Portage
6
7
  module Cli
@@ -55,11 +56,7 @@ module Portage
55
56
  parts.join(" — ")
56
57
  end
57
58
 
58
- def format_amount(amount, currency)
59
- return nil unless amount
60
-
61
- "#{format('%.2f', amount / 100.0)}#{" #{currency}" if currency}"
62
- end
59
+ def format_amount(amount, currency) = amount && Money.format_amount(amount, currency)
63
60
  end
64
61
  end
65
62
  end
@@ -0,0 +1,40 @@
1
+ require_relative "../../dot_env"
2
+
3
+ module Portage
4
+ module Cli
5
+ class SetupWizard
6
+ module Steps
7
+ # Shared body of the wizard steps that only prompt for a few env vars
8
+ # and write the answers to ~/.portage/.env (Shipping, SearchKeys,
9
+ # RetailerKeys). A subclass defines #title, #default_yes?, FIELDS
10
+ # ({ "ENV_VAR" => "label" }), #intro and #unchanged_message, and
11
+ # overrides #secret? for fields that must never echo back.
12
+ class EnvKeysStep
13
+ def initialize(prompt:) = @prompt = prompt
14
+
15
+ def call
16
+ @prompt.say(intro)
17
+ assignments = collect
18
+ return @prompt.say(unchanged_message) if assignments.empty?
19
+
20
+ path = DotEnv.update!(assignments)
21
+ @prompt.say("Saved #{assignments.keys.join(', ')} to #{path} (chmod 600).")
22
+ end
23
+
24
+ private
25
+
26
+ def fields = self.class::FIELDS
27
+ def secret?(_var) = false
28
+
29
+ def collect
30
+ fields.each_with_object({}) do |(var, label), assignments|
31
+ hint = ENV.fetch(var, nil).to_s.empty? ? "not set" : "already set"
32
+ answer = secret?(var) ? @prompt.ask_secret(label, hint: hint) : @prompt.ask(label, hint: hint)
33
+ assignments[var] = answer if answer
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -1,4 +1,4 @@
1
- require_relative "../../dot_env"
1
+ require_relative "env_keys_step"
2
2
 
3
3
  module Portage
4
4
  module Cli
@@ -12,7 +12,7 @@ module Portage
12
12
  # setting a key here only makes `find` show more real offers, it
13
13
  # never lets `portage buy` complete a purchase at any of these
14
14
  # retailers.
15
- class RetailerKeys
15
+ class RetailerKeys < EnvKeysStep
16
16
  FIELDS = {
17
17
  "WALMART_AFFILIATE_API_KEY" => "Walmart Affiliate API key",
18
18
  "EBAY_BROWSE_ACCESS_TOKEN" => "eBay Browse API access token (Buy It Now search only)",
@@ -21,33 +21,21 @@ module Portage
21
21
  "AMAZON_CREATORS_ACCESS_TOKEN" => "Amazon Creators API access token"
22
22
  }.freeze
23
23
 
24
- def initialize(prompt:) = @prompt = prompt
25
-
26
24
  def title = "Retailer offer sources"
27
25
  def default_yes? = false
28
26
 
29
- def call
30
- @prompt.say("Optional: official buyer-side APIs for Walmart, eBay (Buy It Now only), Best " \
31
- "Buy, Etsy and Amazon. Each needs its own key from that retailer's developer " \
32
- "program — skip any you don't have. None of these ever completes a purchase: " \
33
- "every offer they return still ends in hand-off, same as Amazon today. Neither " \
34
- "key is echoed back; Enter keeps whatever's already set.")
35
- assignments = collect
36
- return @prompt.say("Left retailer offer source keys unchanged.") if assignments.empty?
37
-
38
- path = DotEnv.update!(assignments)
39
- @prompt.say("Saved #{assignments.keys.join(', ')} to #{path} (chmod 600).")
40
- end
41
-
42
27
  private
43
28
 
44
- def collect
45
- FIELDS.each_with_object({}) do |(var, label), assignments|
46
- hint = ENV.fetch(var, nil).to_s.empty? ? "not set" : "already set"
47
- answer = @prompt.ask_secret(label, hint: hint)
48
- assignments[var] = answer if answer
49
- end
29
+ def intro
30
+ "Optional: official buyer-side APIs for Walmart, eBay (Buy It Now only), Best " \
31
+ "Buy, Etsy and Amazon. Each needs its own key from that retailer's developer " \
32
+ "program — skip any you don't have. None of these ever completes a purchase: " \
33
+ "every offer they return still ends in hand-off, same as Amazon today. Neither " \
34
+ "key is echoed back; Enter keeps whatever's already set."
50
35
  end
36
+
37
+ def unchanged_message = "Left retailer offer source keys unchanged."
38
+ def secret?(_var) = true
51
39
  end
52
40
  end
53
41
  end
@@ -1,4 +1,4 @@
1
- require_relative "../../dot_env"
1
+ require_relative "env_keys_step"
2
2
 
3
3
  module Portage
4
4
  module Cli
@@ -10,7 +10,7 @@ module Portage
10
10
  # with no URL only ever resolves DuckDuckGo's Instant Answer API,
11
11
  # which is keyless but only ever matches a specific brand/product
12
12
  # name, not an open-ended query ("coffee", "hiking boots").
13
- class SearchKeys
13
+ class SearchKeys < EnvKeysStep
14
14
  FIELDS = {
15
15
  "BRAVE_SEARCH_API_KEY" => "Brave Search API key",
16
16
  "GOOGLE_CSE_KEY" => "Google Programmable Search API key",
@@ -21,32 +21,20 @@ module Portage
21
21
  # only the two API keys are secrets that never echo back.
22
22
  SECRET = %w[BRAVE_SEARCH_API_KEY GOOGLE_CSE_KEY].freeze
23
23
 
24
- def initialize(prompt:) = @prompt = prompt
25
-
26
24
  def title = "Search API keys"
27
25
  def default_yes? = true
28
26
 
29
- def call
30
- @prompt.say("DuckDuckGo's free API is already active and needs no key, but only resolves a " \
31
- "specific brand/product name — set either Brave's key, or both Google fields, for " \
32
- "real open-ended search. Neither key is echoed back; Enter keeps whatever's " \
33
- "already set.")
34
- assignments = collect
35
- return @prompt.say("Left search API keys unchanged.") if assignments.empty?
36
-
37
- path = DotEnv.update!(assignments)
38
- @prompt.say("Saved #{assignments.keys.join(', ')} to #{path} (chmod 600).")
39
- end
40
-
41
27
  private
42
28
 
43
- def collect
44
- FIELDS.each_with_object({}) do |(var, label), assignments|
45
- hint = ENV.fetch(var, nil).to_s.empty? ? "not set" : "already set"
46
- answer = SECRET.include?(var) ? @prompt.ask_secret(label, hint: hint) : @prompt.ask(label, hint: hint)
47
- assignments[var] = answer if answer
48
- end
29
+ def intro
30
+ "DuckDuckGo's free API is already active and needs no key, but only resolves a " \
31
+ "specific brand/product name — set either Brave's key, or both Google fields, for " \
32
+ "real open-ended search. Neither key is echoed back; Enter keeps whatever's " \
33
+ "already set."
49
34
  end
35
+
36
+ def unchanged_message = "Left search API keys unchanged."
37
+ def secret?(var) = SECRET.include?(var)
50
38
  end
51
39
  end
52
40
  end
@@ -1,5 +1,5 @@
1
1
  require_relative "../../shipping_profile"
2
- require_relative "../../dot_env"
2
+ require_relative "env_keys_step"
3
3
 
4
4
  module Portage
5
5
  module Cli
@@ -12,7 +12,7 @@ module Portage
12
12
  # store prices in no market and can report in-stock items as out of
13
13
  # stock (see Doctor#shipping_finding, which this step exists to
14
14
  # resolve).
15
- class Shipping
15
+ class Shipping < EnvKeysStep
16
16
  LABELS = {
17
17
  street_address: "Street address", extended_address: "Apt/suite (optional)",
18
18
  address_locality: "City", address_region: "State/region (optional)",
@@ -21,29 +21,15 @@ module Portage
21
21
  phone_number: "Phone (optional)"
22
22
  }.freeze
23
23
 
24
- def initialize(prompt:) = @prompt = prompt
24
+ FIELDS = ShippingProfile::ENV_VARS.to_h { |key, var| [var, LABELS.fetch(key)] }.freeze
25
25
 
26
26
  def title = "Shipping address"
27
27
  def default_yes? = true
28
28
 
29
- def call
30
- @prompt.say("Enter keeps whatever's already set for a field.")
31
- assignments = collect
32
- return @prompt.say("Left shipping address unchanged.") if assignments.empty?
33
-
34
- path = DotEnv.update!(assignments)
35
- @prompt.say("Saved #{assignments.keys.join(', ')} to #{path} (chmod 600).")
36
- end
37
-
38
29
  private
39
30
 
40
- def collect
41
- ShippingProfile::ENV_VARS.each_with_object({}) do |(key, var), assignments|
42
- hint = ENV.fetch(var, nil).to_s.empty? ? "not set" : "already set"
43
- answer = @prompt.ask(LABELS.fetch(key), hint: hint)
44
- assignments[var] = answer if answer
45
- end
46
- end
31
+ def intro = "Enter keeps whatever's already set for a field."
32
+ def unchanged_message = "Left shipping address unchanged."
47
33
  end
48
34
  end
49
35
  end
@@ -1,5 +1,5 @@
1
1
  module Portage
2
2
  module Cli
3
- VERSION = "0.12.0".freeze
3
+ VERSION = "0.13.0".freeze
4
4
  end
5
5
  end