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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +69 -0
- data/README.md +52 -3
- data/lib/portage/cli/approval_policy.rb +56 -0
- data/lib/portage/cli/approve.rb +135 -0
- data/lib/portage/cli/browser_opener.rb +38 -0
- data/lib/portage/cli/buy.rb +42 -25
- data/lib/portage/cli/check.rb +154 -0
- data/lib/portage/cli/check_next_step.rb +51 -0
- data/lib/portage/cli/checkout_handoff.rb +5 -24
- data/lib/portage/cli/find.rb +8 -1
- data/lib/portage/cli/handoff_host.rb +33 -0
- data/lib/portage/cli/history.rb +47 -2
- data/lib/portage/cli/human_prompt.rb +118 -0
- data/lib/portage/cli/money.rb +18 -0
- data/lib/portage/cli/offer_choice.rb +38 -0
- data/lib/portage/cli/pick.rb +167 -0
- data/lib/portage/cli/product_page.rb +84 -0
- data/lib/portage/cli/quotes.rb +82 -0
- data/lib/portage/cli/version.rb +1 -1
- data/lib/portage/cli.rb +459 -43
- metadata +15 -3
|
@@ -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
|
data/lib/portage/cli/version.rb
CHANGED