portage-cli 0.7.5 → 0.8.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 +559 -0
- data/README.md +266 -5
- data/known-stores/categories.yml +1263 -0
- data/lib/portage/cli/agent_profile_url.rb +30 -0
- data/lib/portage/cli/browser_import/categorize.rb +59 -0
- data/lib/portage/cli/browser_import/confirm.rb +35 -0
- data/lib/portage/cli/browser_import/domains.rb +47 -0
- data/lib/portage/cli/browser_import/filter.rb +91 -0
- data/lib/portage/cli/browser_import/importer.rb +248 -0
- data/lib/portage/cli/browser_import/plist_xml.rb +72 -0
- data/lib/portage/cli/browser_import/prober.rb +60 -0
- data/lib/portage/cli/browser_import/profiles.rb +114 -0
- data/lib/portage/cli/browser_import/readers.rb +179 -0
- data/lib/portage/cli/browser_import/saver.rb +62 -0
- data/lib/portage/cli/browser_import/sqlite.rb +68 -0
- data/lib/portage/cli/browser_import.rb +23 -0
- data/lib/portage/cli/browser_profile/allowlist.rb +40 -0
- data/lib/portage/cli/browser_profile/bridge.rb +120 -0
- data/lib/portage/cli/browser_profile/browsers.rb +69 -0
- data/lib/portage/cli/browser_profile/cdp.rb +67 -0
- data/lib/portage/cli/browser_profile/cdp_socket.rb +186 -0
- data/lib/portage/cli/browser_profile/errors.rb +26 -0
- data/lib/portage/cli/browser_profile/launcher.rb +34 -0
- data/lib/portage/cli/browser_profile/profile.rb +93 -0
- data/lib/portage/cli/browser_profile.rb +25 -0
- data/lib/portage/cli/buy.rb +521 -29
- data/lib/portage/cli/classifier.rb +158 -0
- data/lib/portage/cli/compare.rb +3 -0
- data/lib/portage/cli/doctor.rb +155 -1
- data/lib/portage/cli/dot_env.rb +55 -0
- data/lib/portage/cli/find.rb +96 -12
- data/lib/portage/cli/handoff_agents.rb +186 -0
- data/lib/portage/cli/handoff_only.rb +94 -0
- data/lib/portage/cli/handoff_reconciler.rb +15 -1
- data/lib/portage/cli/handoff_target.rb +61 -0
- data/lib/portage/cli/index/builder.rb +335 -0
- data/lib/portage/cli/index/exporter.rb +91 -0
- data/lib/portage/cli/index/known_cache.rb +155 -0
- data/lib/portage/cli/index/product_store.rb +101 -0
- data/lib/portage/cli/index/sources/browser.rb +31 -0
- data/lib/portage/cli/index/sources/shopify_catalog.rb +82 -0
- data/lib/portage/cli/index/sources/stores_file.rb +58 -0
- data/lib/portage/cli/index/sources/webmcp_sweep.rb +29 -0
- data/lib/portage/cli/index/sources/wikidata.rb +95 -0
- data/lib/portage/cli/index/sources.rb +44 -0
- data/lib/portage/cli/index/store.rb +109 -0
- data/lib/portage/cli/index.rb +20 -0
- data/lib/portage/cli/known_stores_url.rb +15 -0
- data/lib/portage/cli/offer_sources.rb +460 -0
- data/lib/portage/cli/payment_methods.rb +24 -3
- data/lib/portage/cli/search_backends.rb +337 -12
- data/lib/portage/cli/setup_wizard/prompt.rb +67 -0
- data/lib/portage/cli/setup_wizard/steps/agent_profile.rb +60 -0
- data/lib/portage/cli/setup_wizard/steps/browser_import.rb +29 -0
- data/lib/portage/cli/setup_wizard/steps/handoff.rb +100 -0
- data/lib/portage/cli/setup_wizard/steps/index_build.rb +31 -0
- data/lib/portage/cli/setup_wizard/steps/policy.rb +60 -0
- data/lib/portage/cli/setup_wizard/steps/retailer_keys.rb +55 -0
- data/lib/portage/cli/setup_wizard/steps/search_keys.rb +54 -0
- data/lib/portage/cli/setup_wizard/steps/shipping.rb +51 -0
- data/lib/portage/cli/setup_wizard.rb +74 -0
- data/lib/portage/cli/version.rb +1 -1
- data/lib/portage/cli/webmcp.rb +10 -3
- data/lib/portage/cli/webmcp_autofill_confirm.rb +38 -0
- data/lib/portage/cli/webmcp_autofill_fields.rb +60 -0
- data/lib/portage/cli/webmcp_autofill_mode.rb +39 -0
- data/lib/portage/cli/webmcp_mapping_confirm.rb +68 -0
- data/lib/portage/cli/webmcp_mappings.rb +84 -0
- data/lib/portage/cli.rb +525 -5
- metadata +58 -2
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "net/http"
|
|
3
|
+
require "open3"
|
|
4
|
+
require "timeout"
|
|
5
|
+
require "uri"
|
|
6
|
+
require "portage/ucp/support/connection"
|
|
7
|
+
require_relative "config"
|
|
8
|
+
require_relative "user_agent"
|
|
9
|
+
|
|
10
|
+
module Portage
|
|
11
|
+
module Cli
|
|
12
|
+
# `--handoff-target agent:<name>` (docs/plans/buy-skill-and-local-browser.md
|
|
13
|
+
# Phase 5, decision 3): looks up a named agent in
|
|
14
|
+
# ~/.portage/config.json's `handoff_agents` and, only when it's
|
|
15
|
+
# explicitly `"approved": true`, hands it the same JSON payload
|
|
16
|
+
# `--notify-webhook` sends (Buy#handoff_notify_payload) — checkout URL
|
|
17
|
+
# plus the approved cart summary (items, qty, total, store). No
|
|
18
|
+
# credentials, payment tokens or shipping details beyond what the
|
|
19
|
+
# checkout URL already holds ever go into that payload.
|
|
20
|
+
#
|
|
21
|
+
# Config shape:
|
|
22
|
+
# "handoff_agents": {
|
|
23
|
+
# "openclaw": { "command": ["openclaw", "handoff"], "approved": true },
|
|
24
|
+
# "storefront": { "webhook": "https://example.com/hooks/handoff", "approved": true }
|
|
25
|
+
# }
|
|
26
|
+
#
|
|
27
|
+
# An entry with no "approved": true (or no entry at all) is never
|
|
28
|
+
# invoked — #lookup returns nil, and the caller (Buy) reports why and
|
|
29
|
+
# falls back to `print` behavior.
|
|
30
|
+
class HandoffAgents
|
|
31
|
+
CONFIG_KEY = "handoff_agents".freeze
|
|
32
|
+
|
|
33
|
+
def initialize(config: Config.load)
|
|
34
|
+
@config = config
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# @return [#call, nil] an object responding to #call(payload) that
|
|
38
|
+
# returns nil on success or a delivery-failure message — nil when
|
|
39
|
+
# `name` has no config entry, or the entry isn't approved.
|
|
40
|
+
def lookup(name)
|
|
41
|
+
entry = agents[name.to_s]
|
|
42
|
+
return nil unless entry.is_a?(Hash) && entry["approved"] == true
|
|
43
|
+
|
|
44
|
+
return Command.new(entry["command"]) if entry["command"]
|
|
45
|
+
return Webhook.new(entry["webhook"]) if entry["webhook"]
|
|
46
|
+
|
|
47
|
+
nil
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def agents
|
|
53
|
+
raw = @config.get(CONFIG_KEY)
|
|
54
|
+
raw.is_a?(Hash) ? raw : {}
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Runs the configured argv (never a shell string — merchant-controlled
|
|
58
|
+
# text never reaches a shell), with the payload as JSON on stdin, a
|
|
59
|
+
# scrubbed environment (only ENV_ALLOWLIST passed through —
|
|
60
|
+
# PORTAGE_*, API keys and every other ambient var are dropped), and a
|
|
61
|
+
# hard timeout that kills the process on expiry. A non-zero exit
|
|
62
|
+
# means "not delivered", same posture as Notifier's non-2xx.
|
|
63
|
+
class Command
|
|
64
|
+
TIMEOUT = 30
|
|
65
|
+
# Grace period between SIGTERM and SIGKILL once TIMEOUT is hit — long
|
|
66
|
+
# enough for a well-behaved process to notice and exit, short enough
|
|
67
|
+
# that a hung/ignoring one doesn't stall the hand-off much further.
|
|
68
|
+
KILL_GRACE = 2
|
|
69
|
+
BODY_EXCERPT = 200
|
|
70
|
+
# Only the basics a well-behaved subprocess needs to run at all —
|
|
71
|
+
# never PORTAGE_*, a payment/API key, or a shipping variable.
|
|
72
|
+
ENV_ALLOWLIST = %w[PATH HOME LANG LC_ALL TERM TMPDIR TZ].freeze
|
|
73
|
+
|
|
74
|
+
def initialize(argv)
|
|
75
|
+
@argv = Array(argv).map(&:to_s)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def call(payload)
|
|
79
|
+
return "agent command is empty" if @argv.empty?
|
|
80
|
+
|
|
81
|
+
stdout, stderr, status = run(payload)
|
|
82
|
+
return nil if status&.success?
|
|
83
|
+
|
|
84
|
+
"agent command exited #{status&.exitstatus.inspect}: #{excerpt(stderr, stdout)}"
|
|
85
|
+
rescue Timeout::Error
|
|
86
|
+
"agent command timed out after #{TIMEOUT}s"
|
|
87
|
+
rescue StandardError => e
|
|
88
|
+
"agent command failed: #{e.message}"
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
private
|
|
92
|
+
|
|
93
|
+
# Reads stdout/stderr on their own threads — same posture as
|
|
94
|
+
# Open3.capture3 — so a child that fills the stderr pipe while this
|
|
95
|
+
# blocks reading stdout to EOF (or vice versa) can't deadlock the
|
|
96
|
+
# hand-off. Open3.popen3's block form always joins `wait_thr` in its
|
|
97
|
+
# own `ensure` once this block returns, so a timeout has to actually
|
|
98
|
+
# kill the child (and wait for it to die) *before* returning from
|
|
99
|
+
# here — otherwise that `ensure` blocks forever on a process nothing
|
|
100
|
+
# is waiting on anymore, and the TIMEOUT constant does nothing.
|
|
101
|
+
def run(payload)
|
|
102
|
+
env = ENV.to_h.slice(*ENV_ALLOWLIST)
|
|
103
|
+
out = nil
|
|
104
|
+
err = nil
|
|
105
|
+
status = nil
|
|
106
|
+
Open3.popen3(env, *@argv, unsetenv_others: true) do |stdin, stdout, stderr, wait_thr|
|
|
107
|
+
write_and_close(stdin, payload)
|
|
108
|
+
out_thread = Thread.new { stdout.read.to_s }
|
|
109
|
+
err_thread = Thread.new { stderr.read.to_s }
|
|
110
|
+
status = wait_with_timeout(wait_thr)
|
|
111
|
+
out = out_thread.value
|
|
112
|
+
err = err_thread.value
|
|
113
|
+
end
|
|
114
|
+
[out, err, status]
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def wait_with_timeout(wait_thr)
|
|
118
|
+
Timeout.timeout(TIMEOUT) { wait_thr.value }
|
|
119
|
+
rescue Timeout::Error
|
|
120
|
+
kill_and_wait(wait_thr)
|
|
121
|
+
raise
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# SIGTERM first, SIGKILL after KILL_GRACE if it's still alive —
|
|
125
|
+
# either way this doesn't return until `wait_thr.value` resolves, so
|
|
126
|
+
# the process is confirmed dead (and reaped) before #run's caller
|
|
127
|
+
# ever gets to leave the `Open3.popen3` block.
|
|
128
|
+
def kill_and_wait(wait_thr)
|
|
129
|
+
Process.kill("TERM", wait_thr.pid)
|
|
130
|
+
Timeout.timeout(KILL_GRACE) { wait_thr.value }
|
|
131
|
+
rescue Errno::ESRCH
|
|
132
|
+
nil
|
|
133
|
+
rescue Timeout::Error
|
|
134
|
+
kill_quietly(wait_thr.pid)
|
|
135
|
+
wait_thr.value
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def kill_quietly(pid)
|
|
139
|
+
Process.kill("KILL", pid)
|
|
140
|
+
rescue Errno::ESRCH
|
|
141
|
+
nil
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def write_and_close(stdin, payload)
|
|
145
|
+
stdin.write(JSON.generate(payload))
|
|
146
|
+
ensure
|
|
147
|
+
stdin.close
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def excerpt(stderr, stdout) = (stderr.to_s.empty? ? stdout.to_s : stderr.to_s).strip[0, BODY_EXCERPT]
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# POSTs the payload as JSON — https only, same connection helper and
|
|
154
|
+
# short timeout as Notifier, failures swallowed into the return
|
|
155
|
+
# value rather than raised.
|
|
156
|
+
class Webhook
|
|
157
|
+
TIMEOUT = 10
|
|
158
|
+
BODY_EXCERPT = 200
|
|
159
|
+
|
|
160
|
+
def initialize(url)
|
|
161
|
+
@url = url.to_s
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def call(payload)
|
|
165
|
+
return "agent webhook must be https" unless @url.start_with?("https://")
|
|
166
|
+
|
|
167
|
+
response = post(URI(@url), JSON.generate(payload))
|
|
168
|
+
return nil if response.is_a?(Net::HTTPSuccess)
|
|
169
|
+
|
|
170
|
+
"agent webhook answered #{response.code}: #{response.body.to_s[0, BODY_EXCERPT]}"
|
|
171
|
+
rescue StandardError => e
|
|
172
|
+
"agent webhook POST failed: #{e.message}"
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
private
|
|
176
|
+
|
|
177
|
+
def post(uri, body)
|
|
178
|
+
Portage::Ucp::Support::Connection.start(uri, route: :notify, open_timeout: TIMEOUT,
|
|
179
|
+
read_timeout: TIMEOUT) do |http|
|
|
180
|
+
http.post(uri.request_uri, body, UserAgent.headers.merge("Content-Type" => "application/json"))
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
end
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
require "uri"
|
|
2
|
+
require_relative "config"
|
|
3
|
+
|
|
4
|
+
module Portage
|
|
5
|
+
module Cli
|
|
6
|
+
# Tier C (docs/plans/buy-skill-and-local-browser.md Phase 5): the single
|
|
7
|
+
# place that decides whether a host is hand-off only — Amazon (every
|
|
8
|
+
# marketplace) by default, plus whatever the user adds or removes in
|
|
9
|
+
# ~/.portage/config.json's "handoff_only_hosts". `Buy#call`, `Find`,
|
|
10
|
+
# `Index::Builder` and `BrowserImport::Importer` all read this list
|
|
11
|
+
# (Importer already had an injectable `handoff_only_hosts:` seam for it)
|
|
12
|
+
# rather than each hard-coding or re-deriving their own — the one
|
|
13
|
+
# invariant every caller depends on is "never probed, never fetched,
|
|
14
|
+
# never automated", and that only holds if there's exactly one place
|
|
15
|
+
# deciding host membership.
|
|
16
|
+
#
|
|
17
|
+
# Absent key: the shipped default list. Present key (even an empty
|
|
18
|
+
# array): the user's list *is* the list — they can drop Amazon entirely
|
|
19
|
+
# or add other hosts, and #host? only ever consults what #hosts returns.
|
|
20
|
+
class HandoffOnly
|
|
21
|
+
CONFIG_KEY = "handoff_only_hosts".freeze
|
|
22
|
+
|
|
23
|
+
# Every Amazon marketplace TLD, current as of this phase — the user's
|
|
24
|
+
# own config.json list can extend or shrink this once it's present.
|
|
25
|
+
DEFAULT_HOSTS = %w[
|
|
26
|
+
amazon.com amazon.co.uk amazon.de amazon.fr amazon.it amazon.es amazon.nl amazon.se amazon.pl
|
|
27
|
+
amazon.com.be amazon.ie amazon.ca amazon.com.mx amazon.com.br amazon.co.jp amazon.in amazon.com.au
|
|
28
|
+
amazon.sg amazon.ae amazon.sa amazon.eg amazon.com.tr amazon.cn
|
|
29
|
+
].freeze
|
|
30
|
+
|
|
31
|
+
# Facts, not legal advice (docs/plans/buy-skill-and-local-browser.md
|
|
32
|
+
# decision 5): what the site's terms say, plus the as-is/no-warranty
|
|
33
|
+
# line. Reused verbatim by Buy's `handoff_only` report, `doctor`,
|
|
34
|
+
# `portage setup`'s wizard, and the `buy` skill's own reference doc.
|
|
35
|
+
LEGAL_NOTICE = "This retailer's terms restrict automated purchasing agents, so Portage opens the page " \
|
|
36
|
+
"and you complete the purchase. Portage is open-source software provided as-is, " \
|
|
37
|
+
"without warranty.".freeze
|
|
38
|
+
|
|
39
|
+
def initialize(config: Config.load)
|
|
40
|
+
@config = config
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# @return [Array<String>] lowercase base hosts (no scheme, no path,
|
|
44
|
+
# no "www."). A user entry of "www.example.com", "example.com/",
|
|
45
|
+
# or "https://www.example.com/s?k=x" all normalize to the same
|
|
46
|
+
# "example.com" — the same normalization
|
|
47
|
+
# `BrowserImport::Importer`/`Domains` already apply to a history or
|
|
48
|
+
# bookmark domain, so a host written any of those ways behaves the
|
|
49
|
+
# same everywhere it's checked.
|
|
50
|
+
def hosts
|
|
51
|
+
configured = @config.get(CONFIG_KEY)
|
|
52
|
+
return DEFAULT_HOSTS unless configured.is_a?(Array)
|
|
53
|
+
|
|
54
|
+
configured.filter_map { |h| self.class.normalize_entry(h) }
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# @param host [String, nil] a bare hostname, e.g. "www.amazon.co.uk".
|
|
58
|
+
def host?(host) = self.class.matches_any?(host, hosts)
|
|
59
|
+
|
|
60
|
+
# Amazon-ness is a fact about the domain, independent of whatever the
|
|
61
|
+
# user's own config currently lists (they may have removed it from
|
|
62
|
+
# #hosts and still be on an amazon.* origin) — used by Buy to decide
|
|
63
|
+
# whether it knows a search/cart-add URL pattern for this host at all.
|
|
64
|
+
def self.amazon?(host) = matches_any?(host, DEFAULT_HOSTS)
|
|
65
|
+
|
|
66
|
+
def self.matches_any?(host, list)
|
|
67
|
+
normalized = host.to_s.downcase.strip
|
|
68
|
+
return false if normalized.empty?
|
|
69
|
+
|
|
70
|
+
list.any? { |base| normalized == base || normalized.end_with?(".#{base}") }
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# A bare host ("amazon.co.uk"), one already carrying "www." ("www.
|
|
74
|
+
# amazon.co.uk"), or a full URL a user pasted in ("https://www.
|
|
75
|
+
# amazon.co.uk/s?k=x") — a scheme means this is a URL, parsed for its
|
|
76
|
+
# host; otherwise it's a bare host/host-with-path, so only the part
|
|
77
|
+
# before the first "/" or "?" counts.
|
|
78
|
+
def self.normalize_entry(value)
|
|
79
|
+
text = value.to_s.strip
|
|
80
|
+
return nil if text.empty?
|
|
81
|
+
|
|
82
|
+
host = text.include?("://") ? uri_host(text) : text[%r{\A[^/?\s]+}]
|
|
83
|
+
host = host.to_s.downcase.delete_prefix("www.")
|
|
84
|
+
host.empty? ? nil : host
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def self.uri_host(text)
|
|
88
|
+
URI.parse(text).host
|
|
89
|
+
rescue URI::InvalidURIError
|
|
90
|
+
nil
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -8,6 +8,7 @@ require_relative "homepage_fetch"
|
|
|
8
8
|
require_relative "handoff_spend_mode"
|
|
9
9
|
require_relative "reconcile_notifier"
|
|
10
10
|
require_relative "permissive_authenticator"
|
|
11
|
+
require_relative "handoff_only"
|
|
11
12
|
require_relative "handoff_reconciler/wire_adapters"
|
|
12
13
|
|
|
13
14
|
module Portage
|
|
@@ -48,13 +49,15 @@ module Portage
|
|
|
48
49
|
def initialize(transaction_log: Portage::Ucp::Support::TransactionLog.new,
|
|
49
50
|
order_ledger: Portage::Ucp::Support::OrderLedger.new,
|
|
50
51
|
journal: Portage::Ucp::Journal::PurchaseJournal.new,
|
|
51
|
-
notifier: ReconcileNotifier.new, spend_mode: HandoffSpendMode.resolve, clock: -> { Time.now }
|
|
52
|
+
notifier: ReconcileNotifier.new, spend_mode: HandoffSpendMode.resolve, clock: -> { Time.now },
|
|
53
|
+
handoff_only: nil)
|
|
52
54
|
@transaction_log = transaction_log
|
|
53
55
|
@order_ledger = order_ledger
|
|
54
56
|
@journal = journal
|
|
55
57
|
@notifier = notifier
|
|
56
58
|
@spend_mode = spend_mode
|
|
57
59
|
@clock = clock
|
|
60
|
+
@handoff_only = handoff_only || HandoffOnly.new
|
|
58
61
|
end
|
|
59
62
|
|
|
60
63
|
# @param record [Hash] a TransactionLog record, as returned by
|
|
@@ -216,6 +219,17 @@ module Portage
|
|
|
216
219
|
raise ReconnectError, "no store_url on this record" if store_url.to_s.empty?
|
|
217
220
|
|
|
218
221
|
uri = URI.parse(store_url)
|
|
222
|
+
# Defense in depth (docs/plans/buy-skill-and-local-browser.md
|
|
223
|
+
# Phase 5): nothing in this gem should ever write a hand-off-only
|
|
224
|
+
# host's checkout into TransactionLog as a pending `settled_by:
|
|
225
|
+
# "shopper"` record in the first place (Buy's `handoff_only`
|
|
226
|
+
# outcome never calls #hand_off/#record_pending_handoff at all),
|
|
227
|
+
# but this is the one place every reconcile path fetches a store
|
|
228
|
+
# again from a saved record, so it's guarded here too rather than
|
|
229
|
+
# trusted to stay that way.
|
|
230
|
+
raise ReconnectError, "#{uri.host} is hand-off only — never reconciled automatically" \
|
|
231
|
+
if @handoff_only.host?(uri.host)
|
|
232
|
+
|
|
219
233
|
native_session(uri) || adapter_session(uri) ||
|
|
220
234
|
raise(ReconnectError, "no automated path back into #{store_url}")
|
|
221
235
|
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
require_relative "config"
|
|
2
|
+
require_relative "setting"
|
|
3
|
+
|
|
4
|
+
module Portage
|
|
5
|
+
module Cli
|
|
6
|
+
# `portage buy --handoff-target` (docs/plans/buy-skill-and-local-browser.md
|
|
7
|
+
# Phase 5): which of the four hand-off targets a dead-end checkout_url
|
|
8
|
+
# goes to. Same Setting precedence as CheckoutHandoff/Notifier — a
|
|
9
|
+
# per-invocation override beats PORTAGE_HANDOFF_TARGET, which beats
|
|
10
|
+
# ~/.portage/config.json's "handoff_target" — and defaults to "default"
|
|
11
|
+
# (today's CheckoutHandoff auto-open behavior) when nothing is set at
|
|
12
|
+
# any level.
|
|
13
|
+
#
|
|
14
|
+
# Raises ArgumentError on anything else, so `Cli.run_buy` can treat a
|
|
15
|
+
# bad value as a usage error the same way it already does for a bad
|
|
16
|
+
# --min-confidence (see Cli.confidence_check) — built once, up front,
|
|
17
|
+
# before a checkout is ever attempted.
|
|
18
|
+
class HandoffTarget
|
|
19
|
+
ENV_VAR = "PORTAGE_HANDOFF_TARGET".freeze
|
|
20
|
+
CONFIG_KEY = "handoff_target".freeze
|
|
21
|
+
DEFAULT = "default".freeze
|
|
22
|
+
KNOWN_KINDS = %w[default print profile].freeze
|
|
23
|
+
AGENT_PATTERN = /\Aagent:(.+)\z/
|
|
24
|
+
|
|
25
|
+
attr_reader :kind, :agent_name
|
|
26
|
+
|
|
27
|
+
def initialize(override: nil, config: Config.load)
|
|
28
|
+
value = Setting.resolve(override: override, env: ENV_VAR, config: config, config_key: CONFIG_KEY)
|
|
29
|
+
value = value.to_s.strip
|
|
30
|
+
parse!(value.empty? ? DEFAULT : value)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def default? = @kind == "default"
|
|
34
|
+
def print? = @kind == "print"
|
|
35
|
+
def profile? = @kind == "profile"
|
|
36
|
+
def agent? = @kind == "agent"
|
|
37
|
+
|
|
38
|
+
# `agent:<name>`'s own label for a report/dispatch — "agent:<name>"
|
|
39
|
+
# for an agent target, or just the kind otherwise.
|
|
40
|
+
def label = agent? ? "agent:#{agent_name}" : @kind
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def parse!(value)
|
|
45
|
+
if KNOWN_KINDS.include?(value)
|
|
46
|
+
@kind = value
|
|
47
|
+
return
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
match = AGENT_PATTERN.match(value)
|
|
51
|
+
unless match
|
|
52
|
+
raise ArgumentError, "Unknown --handoff-target #{value.inspect} — use default, print, profile, " \
|
|
53
|
+
"or agent:<name>."
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
@kind = "agent"
|
|
57
|
+
@agent_name = match[1]
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|