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,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
|
-
#
|
|
18
|
-
#
|
|
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
|
data/lib/portage/cli/find.rb
CHANGED
|
@@ -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
|
data/lib/portage/cli/history.rb
CHANGED
|
@@ -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
|
-
|
|
44
|
-
|
|
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
|