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,167 @@
1
+ require_relative "history"
2
+ require_relative "human_prompt"
3
+ require_relative "product_page"
4
+ require_relative "offer_choice"
5
+
6
+ module Portage
7
+ module Cli
8
+ # `portage pick` — loop step 3, the person picks the store
9
+ # (docs/plans/human-pick-and-approve.md Phase 2, Design § 4). The
10
+ # choices are a saved search's offers (History, `--search LAST` by
11
+ # default) plus one more, "Compare an offer across stores", which runs
12
+ # `compare` on the offer the person names and shows the pick again over
13
+ # its results (the compared offer first, so it can still be chosen).
14
+ #
15
+ # On the `tty` surface the person answers on /dev/tty (`by: "person"`).
16
+ # On the `agent` surface it returns `needs_pick` with `choices[]`; the
17
+ # agent relays the answer with `--choose REF` (`by: "agent_relayed"`)
18
+ # or `--compare REF`. `--view REF` only opens that offer's product page.
19
+ #
20
+ # Returns a report hash; Cli.run_pick prints it.
21
+ class Pick
22
+ COMPARE_REF = "compare".freeze
23
+ COMPARE_LABEL = "Compare an offer across stores".freeze
24
+
25
+ # @param comparer [#call] a saved (string-keyed) offer -> a Compare report.
26
+ def initialize(prompt:, search: nil, choose: nil, view: nil, compare: nil, history: History.new,
27
+ comparer: nil)
28
+ @prompt = prompt
29
+ @search_id = search
30
+ @choose = choose
31
+ @view = view
32
+ @compare = compare
33
+ @history = history
34
+ @comparer = comparer
35
+ end
36
+
37
+ def call
38
+ return view_offer if @view
39
+
40
+ search = @history.search(@search_id)
41
+ return search_not_found unless search
42
+ return compare_from_flag(search) if @compare
43
+ return relayed(search) if @choose
44
+ return needs_pick(search) unless @prompt.tty?
45
+
46
+ ask(search)
47
+ rescue HumanPrompt::NoTerminal => e
48
+ { outcome: "no_terminal", message: e.message }
49
+ end
50
+
51
+ private
52
+
53
+ # --- tty ---
54
+
55
+ def ask(search)
56
+ loop do
57
+ offers = search["offers"]
58
+ index = @prompt.choose("Pick an offer", choices(search), view: OfferChoice.method(:view_message))
59
+ return cancelled unless index
60
+ return picked(search, offers[index], HumanPrompt::BY_PERSON) if index < offers.length
61
+
62
+ search = ask_compare(search)
63
+ return cancelled unless search
64
+ end
65
+ end
66
+
67
+ # @return [Hash, nil] the search to pick from next (the compare's, or
68
+ # the same one when it found nothing), nil when cancelled.
69
+ def ask_compare(search)
70
+ offers = search["offers"]
71
+ index = @prompt.choose("Compare which offer?", offers.map { |o| OfferChoice.for(o) },
72
+ view: OfferChoice.method(:view_message))
73
+ return nil unless index
74
+
75
+ compared, message = compared(search, offers[index])
76
+ @prompt.say(message)
77
+ compared
78
+ end
79
+
80
+ # --- agent ---
81
+
82
+ def needs_pick(search, message: nil)
83
+ { outcome: "needs_pick", search_id: search["search_id"], query: search["query"], choices: choices(search),
84
+ message: message || "Show these choices to the person (with each url as a link), then relay their " \
85
+ "answer: `portage pick --search #{search['search_id']} --choose REF`, or " \
86
+ "`--compare REF` for \"#{COMPARE_LABEL}\"." }
87
+ end
88
+
89
+ def relayed(search)
90
+ offer = offer_in(search, @choose)
91
+ return not_in_search(search, @choose) unless offer
92
+
93
+ picked(search, offer, HumanPrompt::BY_AGENT)
94
+ end
95
+
96
+ def compare_from_flag(search)
97
+ offer = offer_in(search, @compare)
98
+ return not_in_search(search, @compare) unless offer
99
+
100
+ compared, message = compared(search, offer)
101
+ @prompt.tty? ? ask(compared) : needs_pick(compared, message: message)
102
+ end
103
+
104
+ # --- compare ---
105
+
106
+ # Saved as a search of its own, so its offers get refs `buy --offer`
107
+ # and `pick --choose` resolve: the compared offer first (bought by its
108
+ # own query), then the results (bought by the compared product's
109
+ # title, which is what compare searched for).
110
+ # @return [Array(Hash, String)] the search to show next, and a message.
111
+ def compared(search, offer)
112
+ report = @comparer.call(offer)
113
+ return [search, report[:message].to_s] if Array(report[:offers]).empty?
114
+
115
+ origin = offer.merge("query" => offer["query"] || search["query"])
116
+ results = report[:offers].map { |o| o.merge(query: report[:query]) }
117
+ saved = @history.record_search(query: "compare: #{offer['store']} (product #{offer['product_id']})",
118
+ offer_count: results.length, message: report[:message],
119
+ offers: [origin] + results)
120
+ [saved, report[:message].to_s]
121
+ end
122
+
123
+ # --- view ---
124
+
125
+ def view_offer
126
+ offer = @history.offer(@view)
127
+ return unknown_offer(@view) unless offer
128
+
129
+ ProductPage.new(url: offer["url"], store: offer["store"]).open.merge(offer_ref: @view)
130
+ end
131
+
132
+ # --- shapes ---
133
+
134
+ def choices(search)
135
+ search["offers"].map { |o| OfferChoice.for(o) } +
136
+ [{ ref: COMPARE_REF, label: COMPARE_LABEL, url: nil,
137
+ relay: "portage pick --search #{search['search_id']} --compare REF" }]
138
+ end
139
+
140
+ def offer_in(search, ref) = search["offers"].find { |o| o["offer_ref"] == ref }
141
+
142
+ def picked(search, offer, by)
143
+ { outcome: "picked", search_id: search["search_id"], offer_ref: offer["offer_ref"], store: offer["store"],
144
+ product_id: offer["product_id"], title: offer["title"], url: offer["url"], by: by,
145
+ message: "Picked #{offer['title']} from #{offer['store']} — next: " \
146
+ "`portage buy --offer #{offer['offer_ref']} --dry-run`." }
147
+ end
148
+
149
+ def cancelled = { outcome: "cancelled", message: "Nothing picked." }
150
+
151
+ def search_not_found
152
+ latest = @search_id.nil? || @search_id.casecmp?("last")
153
+ what = latest ? "No saved search with offers" : "No search #{@search_id}"
154
+ { outcome: "search_not_found", search_id: @search_id, message: "#{what} — run `portage find` first." }
155
+ end
156
+
157
+ def not_in_search(search, ref)
158
+ { outcome: "offer_not_found", search_id: search["search_id"], offer_ref: ref,
159
+ message: "#{ref} isn't one of search #{search['search_id']}'s offers." }
160
+ end
161
+
162
+ def unknown_offer(ref)
163
+ { outcome: "offer_not_found", offer_ref: ref, message: "No saved offer #{ref} — run `portage find` again." }
164
+ end
165
+ end
166
+ end
167
+ end
@@ -0,0 +1,84 @@
1
+ require "uri"
2
+ require_relative "browser_opener"
3
+
4
+ module Portage
5
+ module Cli
6
+ # Opens the store's page for an offer or quote, so the person can look
7
+ # before they pick or approve (docs/plans/human-pick-and-approve.md
8
+ # Phase 2, "View the product page"). Used by `pick --view`, `approve
9
+ # --view` and the `v`/`v N` answers at a tty prompt. Never gated by the
10
+ # auto-open setting: the person asked for this page by name.
11
+ #
12
+ # The URL came from the store, so it's untrusted: only an http(s) URL on
13
+ # the offer's own store host is opened, and anything else is refused
14
+ # with `view_refused` rather than handed to the OS opener.
15
+ #
16
+ # Host rule: the URL's host must equal the store's host, compared
17
+ # case-insensitively with one leading `www.` ignored on either side
18
+ # (`shop.example` and `www.shop.example` are the same shop). Any other
19
+ # subdomain (`cdn.shop.example`, `shop.example.evil.test`) is refused —
20
+ # a store that keeps product pages on another host just can't be
21
+ # viewed from here. A URL carrying credentials (`user@host`) is refused
22
+ # too.
23
+ class ProductPage
24
+ include BrowserOpener
25
+
26
+ # @param url [String, nil] the product page as find/compare returned it.
27
+ # @param store [String] the offer's store origin (or a bare host).
28
+ def initialize(url:, store:)
29
+ @url = url
30
+ @store = store
31
+ end
32
+
33
+ # @return [Hash] `outcome: "viewed"` (with `opened:`) or
34
+ # `outcome: "view_refused"` (with the reason as `message:`).
35
+ def open
36
+ reason = refusal
37
+ return { outcome: "view_refused", url: @url, store: @store, message: reason } if reason
38
+
39
+ opened = open_browser(@url)
40
+ { outcome: "viewed", url: @url, opened: opened,
41
+ message: opened ? "Opened #{@url}." : "Couldn't open a browser — the page is #{@url}" }
42
+ end
43
+
44
+ # @return [String, nil] why the page can't be opened, nil when it can.
45
+ def refusal
46
+ return "No product page on record for this offer." if @url.to_s.strip.empty?
47
+
48
+ uri = web_uri(@url)
49
+ return "Not an http(s) URL: #{@url}" unless uri
50
+ return "Refusing a URL with credentials in it: #{@url}" if uri.userinfo
51
+ return nil if self.class.same_shop?(uri.host, store_host)
52
+
53
+ "#{uri.host} isn't the offer's store (#{store_host || @store}) — not opening #{@url}"
54
+ end
55
+
56
+ def self.same_shop?(host, store_host)
57
+ return false unless host && store_host
58
+
59
+ host_key(host) == host_key(store_host)
60
+ end
61
+
62
+ def self.host_key(host) = host.downcase.delete_prefix("www.")
63
+ private_class_method :host_key
64
+
65
+ private
66
+
67
+ def store_host
68
+ raw = @store.to_s.strip
69
+ parse(raw.match?(%r{\Ahttps?://}i) ? raw : "https://#{raw}")&.host
70
+ end
71
+
72
+ def web_uri(url)
73
+ uri = parse(url)
74
+ uri if uri&.host && %w[http https].include?(uri.scheme&.downcase)
75
+ end
76
+
77
+ def parse(url)
78
+ URI.parse(url.to_s.strip)
79
+ rescue URI::InvalidURIError
80
+ nil
81
+ end
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,82 @@
1
+ require "json"
2
+ require "fileutils"
3
+ require "securerandom"
4
+
5
+ module Portage
6
+ module Cli
7
+ # Local record of the priced checkouts `portage buy --dry-run` has shown,
8
+ # one JSON file per quote under ~/.portage/quotes/. A quote is what
9
+ # `buy --quote QUOTE_ID --yes` later buys: it pins the store, product,
10
+ # quantity and the total that was shown, so the run that charges can
11
+ # refuse if the price has moved since (see Cli.buy_from_quote).
12
+ #
13
+ # Quotes never expire. Each is single use: #consume stamps `used_at`
14
+ # rather than deleting the file, so a spent quote is still told apart
15
+ # from one that never existed.
16
+ class Quotes
17
+ DIR = File.join(Dir.home, ".portage", "quotes").freeze
18
+ ID_FORMAT = /\Aqt_[0-9a-f]{12}\z/
19
+
20
+ def initialize(dir: DIR, now: Time.now)
21
+ @dir = dir
22
+ @now = now.to_i
23
+ end
24
+
25
+ # @param total [Integer, nil] minor units of `currency`.
26
+ # @param title [String, nil] what the checkout holds, for `portage
27
+ # approve`'s summary.
28
+ # @param url [String, nil] the offer's product page, for `approve --view`.
29
+ # @return [Hash, nil] the saved quote (string keys, `quote_id` set), or
30
+ # nil when it couldn't be written — a quote that can't be saved just
31
+ # isn't offered, never a failed dry run.
32
+ def create(store:, product_id:, qty:, total:, currency:, query: nil, offer_ref: nil, title: nil, url: nil)
33
+ quote = { "quote_id" => "qt_#{SecureRandom.hex(6)}", "offer_ref" => offer_ref, "store" => store,
34
+ "product_id" => product_id, "query" => query, "qty" => qty, "total" => total,
35
+ "currency" => currency, "title" => title, "url" => url, "created_at" => @now,
36
+ "approved" => false }
37
+ write(quote)
38
+ end
39
+
40
+ # @return [Hash, nil] nil for an unknown (or malformed) id.
41
+ def find(quote_id)
42
+ return nil unless quote_id.to_s.match?(ID_FORMAT)
43
+
44
+ parsed = JSON.parse(File.read(path(quote_id)))
45
+ parsed if parsed.is_a?(Hash)
46
+ rescue StandardError
47
+ nil
48
+ end
49
+
50
+ # Records the person's yes to this quote's total
51
+ # (docs/plans/human-pick-and-approve.md Phase 2): `by` is "person" for
52
+ # a yes typed at the tty, "agent_relayed" for one an agent passed on.
53
+ # A relayed yes never downgrades a quote the person already approved.
54
+ # @return [Hash, nil] the updated quote, nil when unknown or unwritable.
55
+ def approve(quote_id, by:)
56
+ quote = find(quote_id)
57
+ return nil unless quote
58
+ return quote if quote["approved_by"] == "person"
59
+
60
+ write(quote.merge("approved" => true, "approved_by" => by, "approved_at" => @now))
61
+ end
62
+
63
+ # Marks the quote spent. Best-effort, like every other local record.
64
+ def consume(quote_id)
65
+ quote = find(quote_id)
66
+ write(quote.merge("used_at" => @now)) if quote
67
+ end
68
+
69
+ private
70
+
71
+ def path(quote_id) = File.join(@dir, "#{quote_id}.json")
72
+
73
+ def write(quote)
74
+ FileUtils.mkdir_p(@dir)
75
+ File.write(path(quote["quote_id"]), JSON.generate(quote))
76
+ quote
77
+ rescue StandardError
78
+ nil
79
+ end
80
+ end
81
+ end
82
+ end
@@ -1,5 +1,5 @@
1
1
  module Portage
2
2
  module Cli
3
- VERSION = "0.8.0".freeze
3
+ VERSION = "0.10.0".freeze
4
4
  end
5
5
  end