portage-cli 0.8.0 → 0.10.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.
@@ -0,0 +1,154 @@
1
+ require "uri"
2
+ require "portage/ucp"
3
+ require_relative "handoff_only"
4
+ require_relative "handoff_host"
5
+ require_relative "check_next_step"
6
+ require_relative "webmcp"
7
+ require_relative "browser_profile"
8
+
9
+ module Portage
10
+ module Cli
11
+ # `portage check <url>` — "can Portage buy from this store, and how?"
12
+ #
13
+ # Wraps Portage::Ucp::Check (native `/.well-known/ucp`, platform
14
+ # detection, live adapter probe) and adds what only the CLI knows, so
15
+ # the answer matches what `portage buy` would actually do: the
16
+ # hand-off-only list (HandoffHost, shared with Buy), whether the adapter
17
+ # gem is installed, and whether the page exposes WebMCP tools.
18
+ #
19
+ # Precedence mirrors Buy#call: hand-off-only host, then native UCP, then
20
+ # WebMCP, then a platform adapter, then nothing. A hand-off-only host
21
+ # short-circuits before any request is made.
22
+ #
23
+ # Read-only and quiet: plain GETs only (through Ucp::Check), never a
24
+ # cart, never a checkout. WebMCP needs a live page, and Portage never
25
+ # launches a browser for this: it only reads tools from a tab the
26
+ # Portage browser profile already has open on the store's host
27
+ # (`portage browser profile open --url ...`). With no such tab, or with
28
+ # portage-ucp-webmcp not installed, `webmcp.status` is "skipped" and says
29
+ # why. A `webmcp_bridge:` can be injected instead (specs, library use).
30
+ class Check
31
+ CART_CAP = "dev.ucp.shopping.cart".freeze
32
+ CHECKOUT_CAP = "dev.ucp.shopping.checkout".freeze
33
+
34
+ # Verdicts `portage check` exits 0 for.
35
+ USABLE_VERDICTS = %w[automated webmcp].freeze
36
+
37
+ NO_TAB_REASON = "no Portage browser profile tab is open on this store " \
38
+ "(portage browser profile open --url URL), and check never launches a browser".freeze
39
+
40
+ def self.call(url, **) = new(url, **).call
41
+
42
+ # @param webmcp_bridge [#list_tools, nil] a bridge over the store's page.
43
+ # @param handoff_only [HandoffOnly]
44
+ # @param checker [#call] url -> Portage::Ucp::Check report (injectable).
45
+ def initialize(url, webmcp_bridge: nil, handoff_only: HandoffOnly.new, checker: Portage::Ucp::Check.method(:call))
46
+ raw = url.to_s.strip
47
+ @uri = URI.parse(raw =~ %r{\Ahttps?://}i ? raw : "https://#{raw}")
48
+ @webmcp_bridge = webmcp_bridge
49
+ @handoff_only = handoff_only
50
+ @checker = checker
51
+ end
52
+
53
+ def call
54
+ return handoff_only_report if HandoffHost.restricted?(@uri.host, handoff_only: @handoff_only)
55
+
56
+ report = @checker.call(@uri.to_s).merge(handoff_only: false)
57
+ report = report.merge(adapter: adapter_for(report))
58
+ report = report.merge(webmcp: webmcp_for(report))
59
+ verdict = verdict_for(report)
60
+ report.merge(verdict: verdict, next_step: CheckNextStep.call(verdict, report))
61
+ end
62
+
63
+ private
64
+
65
+ def handoff_only_report
66
+ { url: @uri.to_s, native_ucp: nil, platform: nil, recommended_gem: nil, handoff_only: true, adapter: nil,
67
+ webmcp: webmcp_skipped("#{@uri.host} is hand-off only, so it isn't probed"),
68
+ verdict: "handoff",
69
+ next_step: "#{@uri.host} restricts automated purchasing agents. Portage opens the page and you buy." }
70
+ end
71
+
72
+ # --- adapter ---
73
+
74
+ def adapter_for(report)
75
+ platform = Portage::Ucp::Resolver::PLATFORMS.find { |p| p.name == report[:platform] }
76
+ return nil unless platform
77
+
78
+ { gem: platform.gem, installed: gem_installed?(platform),
79
+ missing_env: Portage::Ucp::Resolver.missing_env(platform, Portage::Ucp::Resolver.env_for(platform)) }
80
+ end
81
+
82
+ # Same test the live probe uses: the adapter's require_path either
83
+ # loads or it doesn't. Loading has no I/O.
84
+ def gem_installed?(platform)
85
+ require platform.require_path
86
+ true
87
+ rescue LoadError
88
+ false
89
+ end
90
+
91
+ # --- webmcp ---
92
+
93
+ def webmcp_for(report)
94
+ return webmcp_skipped("the store speaks UCP natively") if report[:native_ucp]
95
+ return webmcp_skipped("portage-ucp-webmcp isn't installed") unless Webmcp.available?
96
+
97
+ bridge = @webmcp_bridge || existing_tab_bridge
98
+ return webmcp_skipped(NO_TAB_REASON) unless bridge
99
+
100
+ detect_webmcp(bridge)
101
+ rescue StandardError => e
102
+ { status: "error", tools: [], reason: "#{e.class}: #{e.message}" }
103
+ end
104
+
105
+ def webmcp_skipped(reason) = { status: "skipped", tools: [], reason: reason }
106
+
107
+ def detect_webmcp(bridge)
108
+ tools = bridge.list_tools
109
+ names = tools.map { |tool| (tool["name"] || tool[:name]).to_s }
110
+ return { status: "none", tools: [], reason: "the page registers no WebMCP tools" } if names.empty?
111
+
112
+ preset = Portage::Ucp::WebMcp::Presets.detect(tools)
113
+ session = Portage::Ucp::WebMcp.connect(bridge: bridge, preset: preset)
114
+ return { status: "available", tools: names, reason: nil } if cart_and_checkout?(session)
115
+
116
+ { status: "none", tools: names,
117
+ reason: "the page's WebMCP tools aren't a cart and checkout set Portage recognises" }
118
+ end
119
+
120
+ def cart_and_checkout?(session) = session.advertises?(CART_CAP) && session.advertises?(CHECKOUT_CAP)
121
+
122
+ # Only ever a tab that is already open on the store's host: no new tab
123
+ # is created, no browser is started, nothing is navigated.
124
+ def existing_tab_bridge
125
+ profile = BrowserProfile::Profile.new
126
+ return nil unless profile.status[:running]
127
+
128
+ tab = BrowserProfile::Cdp.list(port: profile.port).find { |t| t["type"] == "page" && same_host?(t["url"]) }
129
+ ws_url = tab && tab["webSocketDebuggerUrl"]
130
+ return nil unless ws_url
131
+
132
+ BrowserProfile::Bridge.new(socket: BrowserProfile::CdpSocket.connect(ws_url),
133
+ allowlist: BrowserProfile::Allowlist.new(hosts: [@uri.host]))
134
+ end
135
+
136
+ def same_host?(url)
137
+ URI(url.to_s).host == @uri.host
138
+ rescue URI::InvalidURIError
139
+ false
140
+ end
141
+
142
+ # --- verdict ---
143
+
144
+ def verdict_for(report)
145
+ return "automated" if report[:native_ucp]
146
+ return "webmcp" if report.dig(:webmcp, :status) == "available"
147
+ return "automated" if report.dig(:live_probe, :status) == "ok"
148
+ return "handoff" if report[:platform]
149
+
150
+ "unsupported"
151
+ end
152
+ end
153
+ end
154
+ end
@@ -0,0 +1,51 @@
1
+ module Portage
2
+ module Cli
3
+ # The plain-English `next_step` of a Check report, one sentence per
4
+ # verdict — kept apart from Check so its detection logic stays readable.
5
+ module CheckNextStep
6
+ def self.call(verdict, report)
7
+ case verdict
8
+ when "automated" then automated(report)
9
+ when "webmcp" then "Portage will build the cart; you pay in your browser."
10
+ when "handoff" then adapter(report)
11
+ else unsupported(report)
12
+ end
13
+ end
14
+
15
+ def self.automated(report)
16
+ return "Nothing to do — this store speaks UCP natively." if report[:native_ucp]
17
+
18
+ "Nothing more to set up — the #{report[:platform]} adapter (#{report.dig(:adapter, :gem)}) is installed " \
19
+ "and answered a live probe."
20
+ end
21
+
22
+ def self.adapter(report)
23
+ adapter = report[:adapter]
24
+ return "#{report[:url]} restricts automated purchasing agents. Portage opens the page and you buy." unless
25
+ adapter
26
+
27
+ actions = []
28
+ actions << "install #{adapter[:gem]}" unless adapter[:installed]
29
+ actions << "set #{adapter[:missing_env].join(', ')}" if adapter[:missing_env].any?
30
+ return probe_failed(report) if actions.empty?
31
+
32
+ "To automate this #{report[:platform]} store (adapters act as the store's owner), " \
33
+ "#{actions.join(' and ')}. Until then Portage opens the store and you buy."
34
+ end
35
+
36
+ def self.probe_failed(report)
37
+ reason = report.dig(:live_probe, :reason)
38
+ "The #{report[:platform]} adapter is set up but its live probe failed#{" (#{reason})" if reason}. " \
39
+ "Run `portage doctor`; until then Portage opens the store and you buy."
40
+ end
41
+
42
+ def self.unsupported(report)
43
+ webmcp = report[:webmcp]
44
+ note = webmcp[:status] == "skipped" ? " (WebMCP not checked: #{webmcp[:reason]})" : ""
45
+ "No UCP manifest, known platform or WebMCP cart found#{note}. Portage can only open the store " \
46
+ "for you to browse."
47
+ end
48
+ private_class_method :automated, :adapter, :probe_failed, :unsupported
49
+ end
50
+ end
51
+ end
@@ -1,6 +1,7 @@
1
1
  require "uri"
2
2
  require_relative "config"
3
3
  require_relative "setting"
4
+ require_relative "browser_opener"
4
5
 
5
6
  module Portage
6
7
  module Cli
@@ -14,13 +15,11 @@ module Portage
14
15
  # --auto-open / --no-auto-open) beats PORTAGE_AUTO_OPEN_CHECKOUT, which
15
16
  # beats ~/.portage/config.json's "auto_open_checkout" (Config).
16
17
  #
17
- # No new gem for the actual open — every other shell-out in this repo
18
- # (PaymentMethods::KeychainBackend, SecretServiceBackend) hand-rolls
19
- # `system` rather than pulling in launchy for something the OS already
20
- # provides. `system(cmd, url)` (array form, never an interpolated
21
- # string) so a merchant-controlled checkout_url can't inject into a
22
- # shell.
18
+ # The open itself is BrowserOpener's array-form `system` shell-out, so
19
+ # a merchant-controlled checkout_url can't inject into a shell.
23
20
  class CheckoutHandoff
21
+ include BrowserOpener
22
+
24
23
  ENV_VAR = "PORTAGE_AUTO_OPEN_CHECKOUT".freeze
25
24
  CONFIG_KEY = "auto_open_checkout".freeze
26
25
 
@@ -47,24 +46,6 @@ module Portage
47
46
  rescue URI::InvalidURIError
48
47
  false
49
48
  end
50
-
51
- def open_browser(url)
52
- command = platform_command
53
- return false unless command
54
-
55
- !!system(command, url)
56
- rescue StandardError => e
57
- warn "portage: couldn't open #{url} (#{e.message})"
58
- false
59
- end
60
-
61
- def platform_command
62
- case RbConfig::CONFIG["host_os"]
63
- when /darwin/i then "open"
64
- when /linux|bsd/i then "xdg-open"
65
- when /mswin|mingw|cygwin/i then "start"
66
- end
67
- end
68
49
  end
69
50
  end
70
51
  end
@@ -1,4 +1,5 @@
1
1
  require "uri"
2
+ require "securerandom"
2
3
  require "portage/ucp"
3
4
  require "portage/ucp/client"
4
5
 
@@ -62,7 +63,7 @@ module Portage
62
63
  sourced = source_offers
63
64
  return report(candidates: candidates, message: no_candidates_message) if nothing_to_go_on?(candidates, sourced)
64
65
 
65
- offers = rank(sourced + probed.flat_map { |store| offers_for(store) })
66
+ offers = rank(sourced + probed.flat_map { |store| offers_for(store) }).map { |o| with_offer_ref(o) }
66
67
  report(candidates: candidates, stores: store_summaries(stores), offers: offers,
67
68
  message: summary(candidates, stores, offers))
68
69
  rescue Portage::Ucp::Client::MissingAgentProfileError
@@ -78,6 +79,12 @@ module Portage
78
79
  # the complexity budget.
79
80
  def nothing_to_go_on?(candidates, sourced) = candidates.empty? && sourced.empty?
80
81
 
82
+ # A short opaque id `portage buy --offer` resolves from history, so a
83
+ # later step can point at one offer without re-sending its store,
84
+ # product id and query. Added last, after ranking, so it never
85
+ # influences the order.
86
+ def with_offer_ref(offer) = { offer_ref: "of_#{SecureRandom.hex(3)}" }.merge(offer)
87
+
81
88
  # --- Step 1: ask the backends who might sell this ---
82
89
 
83
90
  def candidate_origins
@@ -0,0 +1,33 @@
1
+ require_relative "handoff_only"
2
+ require_relative "offer_sources"
3
+
4
+ module Portage
5
+ module Cli
6
+ # The one answer to "is this host hand-off only?" that both `buy` and
7
+ # `check` give, so `portage check` never says something `portage buy`
8
+ # wouldn't do. Three sources, in the order Buy has always consulted them:
9
+ # the user's own list (HandoffOnly, Amazon by default), the built-in
10
+ # retail hand-off hosts (Walmart, eBay, Best Buy), and Etsy for an
11
+ # ordinary buyer — portage-ucp-etsy is a seller-side adapter, so a shop
12
+ # owner with their own ETSY_* credentials set is not hand-off only.
13
+ module HandoffHost
14
+ def self.restricted?(host, handoff_only: HandoffOnly.new)
15
+ return true if handoff_only.host?(host)
16
+ return true if OfferSources.retail_handoff_host?(host)
17
+
18
+ etsy_buyer_host?(host)
19
+ end
20
+
21
+ def self.etsy_buyer_host?(host)
22
+ HandoffOnly.matches_any?(host, %w[etsy.com]) && !etsy_adapter_configured?
23
+ end
24
+
25
+ def self.etsy_adapter_configured?
26
+ platform = Portage::Ucp::Resolver::PLATFORMS.find { |p| p.name == "Etsy" }
27
+ return false unless platform
28
+
29
+ Portage::Ucp::Resolver.missing_env(platform, Portage::Ucp::Resolver.env_for(platform)).empty?
30
+ end
31
+ end
32
+ end
33
+ end
@@ -1,5 +1,6 @@
1
1
  require "json"
2
2
  require "fileutils"
3
+ require "securerandom"
3
4
 
4
5
  module Portage
5
6
  module Cli
@@ -40,11 +41,43 @@ module Portage
40
41
 
41
42
  # @param url [String, nil] the store, for a `portage buy` that never
42
43
  # reached a checkout there; nil for a cross-store `portage find`.
43
- def record_search(query:, offer_count:, message:, url: nil)
44
- append("searches", { "query" => query, "url" => url, "offer_count" => offer_count, "message" => message,
44
+ # @param offers [Array<Hash>] a `find`'s (or `compare`'s) offers, kept
45
+ # so `portage buy --offer REF` can resolve an `offer_ref` later (see
46
+ # #offer). A search that keeps offers also gets a `search_id`, which
47
+ # `portage pick --search` names it by (docs/plans/
48
+ # human-pick-and-approve.md Phase 2).
49
+ # @return [Hash] the saved entry.
50
+ def record_search(query:, offer_count:, message:, url: nil, offers: [])
51
+ kept = offers.empty? ? nil : saved_offers(offers)
52
+ append("searches", { "search_id" => kept && "se_#{SecureRandom.hex(4)}", "query" => query, "url" => url,
53
+ "offer_count" => offer_count, "message" => message, "offers" => kept,
45
54
  "at" => @now }.compact)
46
55
  end
47
56
 
57
+ # @param id [String, nil] a `search_id`, or nil/"LAST" for the most
58
+ # recent search that kept offers.
59
+ # @return [Hash, nil]
60
+ def search(id = nil)
61
+ with_offers = store["searches"].select { |search| Array(search["offers"]).any? }
62
+ return with_offers.last if id.nil? || id.casecmp?("last")
63
+
64
+ with_offers.reverse.find { |search| search["search_id"] == id }
65
+ end
66
+
67
+ # @return [Hash, nil] the saved offer for `ref` (store, product_id,
68
+ # title, amount, currency, url, checkout, found_at) plus the catalog
69
+ # `query` to buy it with — the offer's own when it was saved with
70
+ # one (a compare result, searched by the compared product's title),
71
+ # else that of the search that found it — from the most recent
72
+ # search that holds it.
73
+ def offer(ref)
74
+ store["searches"].reverse_each do |search|
75
+ found = Array(search["offers"]).find { |saved| saved["offer_ref"] == ref }
76
+ return { "query" => search["query"] }.merge(found) if found
77
+ end
78
+ nil
79
+ end
80
+
48
81
  def purchases(limit: MAX_ENTRIES) = store["purchases"].last(limit)
49
82
 
50
83
  def searches(limit: MAX_ENTRIES) = store["searches"].last(limit)
@@ -58,6 +91,18 @@ module Portage
58
91
 
59
92
  private
60
93
 
94
+ # Takes a report's symbol-keyed offers or already-saved string-keyed
95
+ # ones (`pick` re-saves the offer it compared alongside the results).
96
+ def saved_offers(offers)
97
+ offers.map do |offer|
98
+ offer = offer.transform_keys(&:to_s)
99
+ saved = { "offer_ref" => offer["offer_ref"], "store" => offer["store"], "product_id" => offer["product_id"],
100
+ "title" => offer["title"], "amount" => offer["amount"], "currency" => offer["currency"],
101
+ "url" => offer["url"], "checkout" => offer["checkout"], "found_at" => offer["found_at"] || @now }
102
+ offer["query"] ? saved.merge("query" => offer["query"]) : saved
103
+ end
104
+ end
105
+
61
106
  def append(kind, entry)
62
107
  store[kind] = (store[kind] + [entry]).last(MAX_ENTRIES)
63
108
  write
@@ -0,0 +1,118 @@
1
+ module Portage
2
+ module Cli
3
+ # The one way portage-cli asks a person something during a buy —
4
+ # `portage pick` (loop step 3), `portage approve` (step 5), the numbered
5
+ # pick `portage buy --query` shows, and the confirmation that lowers
6
+ # `--require-approval` (docs/plans/human-pick-and-approve.md Phase 2,
7
+ # Design § 3).
8
+ #
9
+ # Two surfaces:
10
+ #
11
+ # - `tty` reads and writes `/dev/tty`, not stdin/stdout, so it still
12
+ # reaches the person when stdout is piped to an agent. An answer typed
13
+ # there is recorded `by: "person"`. With no controlling terminal
14
+ # (opening `/dev/tty` fails, e.g. ENXIO) it raises NoTerminal, which
15
+ # each command reports as a clean `no_terminal` outcome.
16
+ # - `agent` prompts nobody: the command returns a `needs_*` outcome with
17
+ # render-ready choices, and the agent relays the person's answer
18
+ # (`--choose`, `--relayed-yes`), recorded `by: "agent_relayed"`.
19
+ #
20
+ # `via: "auto"` (the default) is `tty` when there's a controlling
21
+ # terminal and the run isn't `--json`, else `agent`.
22
+ #
23
+ # `terminal:` is injectable (anything with #gets/#print/#puts), so specs
24
+ # never open the real `/dev/tty`; the default is HumanPrompt.open_terminal.
25
+ class HumanPrompt
26
+ PATH = "/dev/tty".freeze
27
+ VIAS = %w[auto tty agent].freeze
28
+ BY_PERSON = "person".freeze
29
+ BY_AGENT = "agent_relayed".freeze
30
+
31
+ class NoTerminal < StandardError; end
32
+
33
+ def self.open_terminal = File.open(PATH, "r+")
34
+
35
+ # @param via [String] one of VIAS.
36
+ # @param json [Boolean] whether the run is `--json` (only read by `auto`).
37
+ # @param terminal [#gets, #print, #puts, nil] an already-open terminal.
38
+ def initialize(via: "auto", json: false, terminal: nil)
39
+ raise ArgumentError, "Unknown --via #{via.inspect} (auto, tty or agent)" unless VIAS.include?(via)
40
+
41
+ @via = via
42
+ @json = json
43
+ @terminal = terminal
44
+ end
45
+
46
+ # @return [String] "tty" or "agent".
47
+ def surface
48
+ @surface ||= case @via
49
+ when "tty", "agent" then @via
50
+ else !@json && terminal ? "tty" : "agent"
51
+ end
52
+ end
53
+
54
+ def tty? = surface == "tty"
55
+
56
+ # A numbered pick. `v N` calls `view` with that choice and asks again
57
+ # — viewing is never an answer. Blank (or end of input) cancels; any
58
+ # other answer that isn't a listed number asks again.
59
+ # @param choices [Array<Hash>] each with `:label`.
60
+ # @param view [#call, nil] choice -> message to show.
61
+ # @return [Integer, nil] the chosen index, nil when cancelled.
62
+ def choose(question, choices, view: nil)
63
+ choices.each_with_index { |choice, i| say(" #{i + 1}. #{choice[:label]}") }
64
+ hint = view ? ", v N to view" : ""
65
+ loop do
66
+ answer = ask("#{question} (1-#{choices.length}#{hint}, Enter to cancel): ")
67
+ return nil if answer.empty?
68
+
69
+ index = chosen_index(answer, choices.length)
70
+ next say("Not one of the choices.") unless index
71
+ return index unless answer.match?(/\Av/i)
72
+
73
+ say(view ? view.call(choices[index]) : "Nothing to view.")
74
+ end
75
+ end
76
+
77
+ # A yes/no. `v` calls `view` and asks again; only "y"/"yes" is a yes.
78
+ # @return [Boolean]
79
+ def confirm(question, view: nil)
80
+ loop do
81
+ answer = ask("#{question} [y/N#{', v to view' if view}] ").downcase
82
+ return %w[y yes].include?(answer) unless answer == "v" && view
83
+
84
+ say(view.call)
85
+ end
86
+ end
87
+
88
+ def say(text) = terminal!.puts(text)
89
+
90
+ private
91
+
92
+ def ask(question)
93
+ terminal!.print(question)
94
+ terminal!.flush if terminal!.respond_to?(:flush)
95
+ terminal!.gets.to_s.strip
96
+ end
97
+
98
+ # "3" or "v 3" (1-based), within range.
99
+ def chosen_index(answer, count)
100
+ number = answer[/\A(?:v\s*)?(\d+)\z/i, 1]
101
+ return nil unless number
102
+
103
+ number.to_i.between?(1, count) ? number.to_i - 1 : nil
104
+ end
105
+
106
+ def terminal!
107
+ terminal || raise(NoTerminal, "No terminal to ask on (#{PATH} can't be opened) — run with --via agent " \
108
+ "and relay the person's answer, or run this from a terminal.")
109
+ end
110
+
111
+ def terminal
112
+ @terminal ||= self.class.open_terminal
113
+ rescue SystemCallError, IOError
114
+ nil
115
+ end
116
+ end
117
+ end
118
+ end
@@ -0,0 +1,18 @@
1
+ module Portage
2
+ module Cli
3
+ # How portage-cli prints an amount in minor units, in one place, so
4
+ # `find`/`history`/`buy` output, Buy's `quote_changed` message and the
5
+ # `approve` summary (docs/plans/human-pick-and-approve.md Phase 2) never
6
+ # disagree on what a total looks like. No FX and no zero-decimal
7
+ # currencies, same as Cli.to_minor_units.
8
+ module Money
9
+ module_function
10
+
11
+ # @param amount [Integer] minor units.
12
+ # @param currency [String, nil]
13
+ def format_amount(amount, currency)
14
+ "#{format('%.2f', amount / 100.0)}#{" #{currency}" if currency}"
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,38 @@
1
+ require_relative "product_page"
2
+ require_relative "money"
3
+
4
+ module Portage
5
+ module Cli
6
+ # One offer as a render-ready choice (docs/plans/human-pick-and-approve.md
7
+ # Phase 2): what `portage pick` returns in `needs_pick`'s `choices[]`,
8
+ # and what HumanPrompt#choose lists for `pick` and `buy --query` alike.
9
+ # `url` is the product page as find/compare returned it; the agent shows
10
+ # it as a link next to the choice.
11
+ module OfferChoice
12
+ module_function
13
+
14
+ # @param offer [Hash] a find report's symbol-keyed offer or a saved
15
+ # (History) string-keyed one.
16
+ def for(offer)
17
+ offer = offer.transform_keys(&:to_s)
18
+ { ref: offer["offer_ref"], label: label(offer), store: offer["store"], product_id: offer["product_id"],
19
+ title: offer["title"], amount: offer["amount"], currency: offer["currency"],
20
+ checkout: offer["checkout"], url: offer["url"] }
21
+ end
22
+
23
+ def label(offer)
24
+ price = offer["amount"] ? Money.format_amount(offer["amount"], offer["currency"]) : "price n/a"
25
+ parts = [offer["store"], offer["title"], price]
26
+ parts << "browse only" if offer["checkout"] == false
27
+ parts.compact.join(" — ")
28
+ end
29
+
30
+ # The tty `v N` answer for one choice: the page's own outcome message.
31
+ def view_message(choice)
32
+ return "Nothing to view for that choice." unless choice[:store]
33
+
34
+ ProductPage.new(url: choice[:url], store: choice[:store]).open[:message]
35
+ end
36
+ end
37
+ end
38
+ end