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.
Files changed (71) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +559 -0
  3. data/README.md +266 -5
  4. data/known-stores/categories.yml +1263 -0
  5. data/lib/portage/cli/agent_profile_url.rb +30 -0
  6. data/lib/portage/cli/browser_import/categorize.rb +59 -0
  7. data/lib/portage/cli/browser_import/confirm.rb +35 -0
  8. data/lib/portage/cli/browser_import/domains.rb +47 -0
  9. data/lib/portage/cli/browser_import/filter.rb +91 -0
  10. data/lib/portage/cli/browser_import/importer.rb +248 -0
  11. data/lib/portage/cli/browser_import/plist_xml.rb +72 -0
  12. data/lib/portage/cli/browser_import/prober.rb +60 -0
  13. data/lib/portage/cli/browser_import/profiles.rb +114 -0
  14. data/lib/portage/cli/browser_import/readers.rb +179 -0
  15. data/lib/portage/cli/browser_import/saver.rb +62 -0
  16. data/lib/portage/cli/browser_import/sqlite.rb +68 -0
  17. data/lib/portage/cli/browser_import.rb +23 -0
  18. data/lib/portage/cli/browser_profile/allowlist.rb +40 -0
  19. data/lib/portage/cli/browser_profile/bridge.rb +120 -0
  20. data/lib/portage/cli/browser_profile/browsers.rb +69 -0
  21. data/lib/portage/cli/browser_profile/cdp.rb +67 -0
  22. data/lib/portage/cli/browser_profile/cdp_socket.rb +186 -0
  23. data/lib/portage/cli/browser_profile/errors.rb +26 -0
  24. data/lib/portage/cli/browser_profile/launcher.rb +34 -0
  25. data/lib/portage/cli/browser_profile/profile.rb +93 -0
  26. data/lib/portage/cli/browser_profile.rb +25 -0
  27. data/lib/portage/cli/buy.rb +521 -29
  28. data/lib/portage/cli/classifier.rb +158 -0
  29. data/lib/portage/cli/compare.rb +3 -0
  30. data/lib/portage/cli/doctor.rb +155 -1
  31. data/lib/portage/cli/dot_env.rb +55 -0
  32. data/lib/portage/cli/find.rb +96 -12
  33. data/lib/portage/cli/handoff_agents.rb +186 -0
  34. data/lib/portage/cli/handoff_only.rb +94 -0
  35. data/lib/portage/cli/handoff_reconciler.rb +15 -1
  36. data/lib/portage/cli/handoff_target.rb +61 -0
  37. data/lib/portage/cli/index/builder.rb +335 -0
  38. data/lib/portage/cli/index/exporter.rb +91 -0
  39. data/lib/portage/cli/index/known_cache.rb +155 -0
  40. data/lib/portage/cli/index/product_store.rb +101 -0
  41. data/lib/portage/cli/index/sources/browser.rb +31 -0
  42. data/lib/portage/cli/index/sources/shopify_catalog.rb +82 -0
  43. data/lib/portage/cli/index/sources/stores_file.rb +58 -0
  44. data/lib/portage/cli/index/sources/webmcp_sweep.rb +29 -0
  45. data/lib/portage/cli/index/sources/wikidata.rb +95 -0
  46. data/lib/portage/cli/index/sources.rb +44 -0
  47. data/lib/portage/cli/index/store.rb +109 -0
  48. data/lib/portage/cli/index.rb +20 -0
  49. data/lib/portage/cli/known_stores_url.rb +15 -0
  50. data/lib/portage/cli/offer_sources.rb +460 -0
  51. data/lib/portage/cli/payment_methods.rb +24 -3
  52. data/lib/portage/cli/search_backends.rb +337 -12
  53. data/lib/portage/cli/setup_wizard/prompt.rb +67 -0
  54. data/lib/portage/cli/setup_wizard/steps/agent_profile.rb +60 -0
  55. data/lib/portage/cli/setup_wizard/steps/browser_import.rb +29 -0
  56. data/lib/portage/cli/setup_wizard/steps/handoff.rb +100 -0
  57. data/lib/portage/cli/setup_wizard/steps/index_build.rb +31 -0
  58. data/lib/portage/cli/setup_wizard/steps/policy.rb +60 -0
  59. data/lib/portage/cli/setup_wizard/steps/retailer_keys.rb +55 -0
  60. data/lib/portage/cli/setup_wizard/steps/search_keys.rb +54 -0
  61. data/lib/portage/cli/setup_wizard/steps/shipping.rb +51 -0
  62. data/lib/portage/cli/setup_wizard.rb +74 -0
  63. data/lib/portage/cli/version.rb +1 -1
  64. data/lib/portage/cli/webmcp.rb +10 -3
  65. data/lib/portage/cli/webmcp_autofill_confirm.rb +38 -0
  66. data/lib/portage/cli/webmcp_autofill_fields.rb +60 -0
  67. data/lib/portage/cli/webmcp_autofill_mode.rb +39 -0
  68. data/lib/portage/cli/webmcp_mapping_confirm.rb +68 -0
  69. data/lib/portage/cli/webmcp_mappings.rb +84 -0
  70. data/lib/portage/cli.rb +525 -5
  71. 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