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,67 @@
1
+ require "net/http"
2
+ require "uri"
3
+ require "json"
4
+ require "portage/ucp/support/connection"
5
+
6
+ module Portage
7
+ module Cli
8
+ module BrowserProfile
9
+ # The plain-HTTP half of Chrome DevTools Protocol: the
10
+ # `http://127.0.0.1:<port>/json/*` endpoints every Chromium browser
11
+ # exposes once started with `--remote-debugging-port`. Used for
12
+ # "is the profile running, and what tab is it on" (`.version`/
13
+ # `.list`) and to open a tab already navigated to a URL (`.new_tab`)
14
+ # without needing the WebSocket layer (CdpSocket) at all for that.
15
+ #
16
+ # Every call is read-only against the local debugging port, GET/PUT
17
+ # requests only, `route: :probe` (same route category ProbeCache's
18
+ # own `/.well-known/ucp` probes use) so it's subject to whatever
19
+ # proxy config a `--no-proxy` list already exempts localhost from.
20
+ # A failure (not running, timeout, bad JSON) returns nil rather than
21
+ # raising — callers (Profile) decide what that means.
22
+ module Cdp
23
+ TIMEOUT = 3
24
+ HOST = "127.0.0.1".freeze
25
+
26
+ def self.version(port:, host: HOST) = get(host, port, "/json/version")
27
+
28
+ def self.list(port:, host: HOST) = Array(get(host, port, "/json/list"))
29
+
30
+ # Opens a new tab already navigated to `url` — Chrome's own
31
+ # `/json/new?<url>` endpoint, a PUT since it creates something
32
+ # server-side.
33
+ def self.new_tab(port:, url:, host: HOST)
34
+ get(host, port, "/json/new?#{URI.encode_www_form_component(url)}", method: :put)
35
+ end
36
+
37
+ # `/json/close/<id>` answers plain text ("Target is closing"), not
38
+ # JSON — checked by success status only, never parsed as JSON.
39
+ def self.close_tab(port:, id:, host: HOST)
40
+ uri = URI("http://#{host}:#{port}/json/close/#{id}")
41
+ fetch(uri, :get).is_a?(Net::HTTPSuccess)
42
+ rescue StandardError
43
+ false
44
+ end
45
+
46
+ def self.get(host, port, path, method: :get)
47
+ uri = URI("http://#{host}:#{port}#{path}")
48
+ response = fetch(uri, method)
49
+ return nil unless response.is_a?(Net::HTTPSuccess)
50
+
51
+ JSON.parse(response.body)
52
+ rescue StandardError
53
+ nil
54
+ end
55
+ private_class_method :get
56
+
57
+ def self.fetch(uri, method)
58
+ Portage::Ucp::Support::Connection.start(uri, route: :probe, open_timeout: TIMEOUT,
59
+ read_timeout: TIMEOUT) do |http|
60
+ method == :put ? http.request(Net::HTTP::Put.new(uri.request_uri)) : http.get(uri.request_uri)
61
+ end
62
+ end
63
+ private_class_method :fetch
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,186 @@
1
+ require "socket"
2
+ require "securerandom"
3
+ require "digest/sha1"
4
+ require "base64"
5
+ require "json"
6
+ require "uri"
7
+ require "timeout"
8
+
9
+ module Portage
10
+ module Cli
11
+ module BrowserProfile
12
+ # A minimal, dependency-free WebSocket JSON-RPC client for a Chrome
13
+ # DevTools Protocol target — just enough to call `Runtime.evaluate`/
14
+ # `Page.navigate`/`Page.enable` on a page's own `webSocketDebuggerUrl`
15
+ # synchronously, one request at a time. Not a general WebSocket
16
+ # client: text frames only, no compression extension, and no
17
+ # concurrent in-flight requests — Bridge never has two CDP calls
18
+ # outstanding at once, so `#call` blocks until its own response (by
19
+ # `id`) comes back, discarding any unsolicited event frame in
20
+ # between.
21
+ #
22
+ # `transport:` is injectable (any object answering `#write`/`#read`/
23
+ # `#close`, e.g. a real TCPSocket) so specs never open a real socket
24
+ # — see cdp_socket_spec.rb.
25
+ class CdpSocket
26
+ GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11".freeze
27
+ TIMEOUT = 30
28
+
29
+ def self.connect(ws_url, transport: nil, timeout: TIMEOUT)
30
+ uri = URI.parse(ws_url)
31
+ transport ||= TCPSocket.new(uri.host, uri.port)
32
+ handshake!(transport, uri, timeout: timeout)
33
+ new(transport)
34
+ end
35
+
36
+ def initialize(transport)
37
+ @transport = transport
38
+ @next_id = 1
39
+ end
40
+
41
+ # @return [Hash] the CDP "result" object.
42
+ # @raise [RuntimeError] the CDP "error" object's message, when the
43
+ # browser itself rejected the command (bad method/params, no
44
+ # such target) — distinct from an *evaluated script* raising,
45
+ # which comes back as a normal result with `exceptionDetails`.
46
+ def call(method, params = {})
47
+ id = @next_id
48
+ @next_id += 1
49
+ send_frame(JSON.generate(id: id, method: method, params: params))
50
+ await_response(id)
51
+ end
52
+
53
+ def close
54
+ @transport.close
55
+ rescue StandardError
56
+ nil
57
+ end
58
+
59
+ private
60
+
61
+ def await_response(id)
62
+ loop do
63
+ message = JSON.parse(read_message)
64
+ next if message["id"] != id
65
+
66
+ raise message.dig("error", "message").to_s if message["error"]
67
+
68
+ return message["result"] || {}
69
+ end
70
+ end
71
+
72
+ # --- outbound framing (client -> server frames MUST be masked,
73
+ # RFC 6455 §5.1) ---
74
+
75
+ def send_frame(payload)
76
+ bytes = payload.b
77
+ mask = SecureRandom.random_bytes(4)
78
+ @transport.write(frame_header(0x1, bytes.bytesize) + mask + xor(bytes, mask))
79
+ end
80
+
81
+ def send_pong(payload)
82
+ bytes = payload.to_s.b
83
+ mask = SecureRandom.random_bytes(4)
84
+ @transport.write(frame_header(0xA, bytes.bytesize) + mask + xor(bytes, mask))
85
+ end
86
+
87
+ def frame_header(opcode, length)
88
+ first = 0x80 | opcode # FIN=1
89
+ return [first, 0x80 | length].pack("CC") if length <= 125
90
+ return [first, 0x80 | 126, length].pack("CCn") if length <= 0xFFFF
91
+
92
+ [first, 0x80 | 127, length].pack("CCQ>")
93
+ end
94
+
95
+ def xor(bytes, mask)
96
+ bytes.each_byte.with_index.map { |byte, i| byte ^ mask.getbyte(i % 4) }.pack("C*")
97
+ end
98
+
99
+ # --- inbound framing (server -> client frames are never masked)
100
+ # ---
101
+
102
+ # Reads whole logical messages (following any continuation
103
+ # frames), answering pings and dropping pongs, until it has one
104
+ # worth handing to #await_response.
105
+ def read_message
106
+ buffer = +""
107
+ loop do
108
+ fin, opcode, payload = read_one_frame
109
+ case opcode
110
+ when 0x9 then send_pong(payload)
111
+ when 0x8 then raise "CDP socket closed by the browser"
112
+ when 0xA then nil
113
+ else buffer << payload
114
+ end
115
+ return buffer if fin && [0x0, 0x1].include?(opcode)
116
+ end
117
+ end
118
+
119
+ def read_one_frame
120
+ b1, b2 = read_exactly(2).unpack("CC")
121
+ fin = b1[7] == 1
122
+ opcode = b1 & 0x0F
123
+ masked = b2[7] == 1
124
+ [fin, opcode, read_payload(b2 & 0x7F, masked)]
125
+ end
126
+
127
+ def read_payload(length, masked)
128
+ length = read_exactly(2).unpack1("n") if length == 126
129
+ length = read_exactly(8).unpack1("Q>") if length == 127
130
+ mask = read_exactly(4) if masked
131
+ payload = length.positive? ? read_exactly(length) : +""
132
+ mask ? xor(payload, mask) : payload
133
+ end
134
+
135
+ def read_exactly(length)
136
+ return +"" if length <= 0
137
+
138
+ Timeout.timeout(TIMEOUT) do
139
+ data = +""
140
+ while data.bytesize < length
141
+ chunk = @transport.read(length - data.bytesize)
142
+ raise "CDP socket closed by the browser" if chunk.nil?
143
+
144
+ data << chunk
145
+ end
146
+ data
147
+ end
148
+ end
149
+
150
+ # --- handshake (RFC 6455 §4) ---
151
+
152
+ def self.handshake!(transport, uri, timeout:)
153
+ key = SecureRandom.base64(16)
154
+ Timeout.timeout(timeout) { transport.write(handshake_request(uri, key)) }
155
+ headers = Timeout.timeout(timeout) { read_headers(transport) }
156
+ verify_handshake!(headers, key)
157
+ end
158
+ private_class_method :handshake!
159
+
160
+ def self.handshake_request(uri, key)
161
+ path = uri.path.to_s.empty? ? "/" : uri.path
162
+ path += "?#{uri.query}" if uri.query
163
+ "GET #{path} HTTP/1.1\r\nHost: #{uri.host}:#{uri.port}\r\nUpgrade: websocket\r\n" \
164
+ "Connection: Upgrade\r\nSec-WebSocket-Key: #{key}\r\nSec-WebSocket-Version: 13\r\n\r\n"
165
+ end
166
+ private_class_method :handshake_request
167
+
168
+ def self.read_headers(transport)
169
+ data = +""
170
+ data << transport.read(1) until data.end_with?("\r\n\r\n")
171
+ data
172
+ end
173
+ private_class_method :read_headers
174
+
175
+ def self.verify_handshake!(headers, key)
176
+ raise "CDP handshake failed: #{headers.lines.first}" unless headers.start_with?("HTTP/1.1 101")
177
+
178
+ expected = Base64.strict_encode64(Digest::SHA1.digest(key + GUID))
179
+ accept = headers[/Sec-WebSocket-Accept:\s*(\S+)/i, 1]
180
+ raise "CDP handshake failed: unexpected Sec-WebSocket-Accept" unless accept == expected
181
+ end
182
+ private_class_method :verify_handshake!
183
+ end
184
+ end
185
+ end
186
+ end
@@ -0,0 +1,26 @@
1
+ module Portage
2
+ module Cli
3
+ module BrowserProfile
4
+ # Base for everything this feature raises — a caller (cli.rb, Buy)
5
+ # can rescue this one class instead of naming each subclass.
6
+ class Error < StandardError; end
7
+
8
+ # `Browsers.binary_for` found nothing installed for the requested
9
+ # browser.
10
+ class BrowserNotFoundError < Error; end
11
+
12
+ # The profile process never answered its own CDP endpoint within the
13
+ # launch wait — a slow machine, a browser that failed to start, or a
14
+ # port already held by something else.
15
+ class LaunchError < Error; end
16
+
17
+ # docs/plans/buy-skill-and-local-browser.md Phase 6: driving stayed
18
+ # inside the allowed domains (the store being bought from, plus its
19
+ # checkout host once known) or this stopped the run rather than
20
+ # continue on an unexpected host. Never rescued into "try again
21
+ # somewhere else" — the whole point is that nothing here recovers by
22
+ # itself.
23
+ class DomainNotAllowedError < Error; end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,34 @@
1
+ module Portage
2
+ module Cli
3
+ module BrowserProfile
4
+ # The one place `Process.spawn` is called from this feature —
5
+ # injectable (`spawn:`) so Profile's own specs never start a real
6
+ # browser (docs/plans/buy-skill-and-local-browser.md Phase 6's own
7
+ # "specs must not launch a real browser" rule).
8
+ #
9
+ # Flags: `--user-data-dir` is always the dedicated Portage profile
10
+ # directory (never the browser's default one — Chrome 136+ refuses
11
+ # remote debugging on the default profile anyway, and this never
12
+ # tries), `--remote-debugging-port` is this profile's own fixed
13
+ # port, and `--no-first-run`/`--no-default-browser-check` keep the
14
+ # launch from popping up onboarding UI the first time. Detached
15
+ # (its own process group, stdout/stderr discarded) so `portage
16
+ # browser profile open` returns immediately rather than blocking on
17
+ # the browser's own lifetime.
18
+ class Launcher
19
+ def initialize(spawn: ->(*args) { Process.spawn(*args) })
20
+ @spawn = spawn
21
+ end
22
+
23
+ def launch(binary:, profile_dir:, port:, url: nil)
24
+ argv = [binary, "--user-data-dir=#{profile_dir}", "--remote-debugging-port=#{port}",
25
+ "--no-first-run", "--no-default-browser-check"]
26
+ argv << url if url
27
+ pid = @spawn.call(*argv, %i[out err] => File::NULL, pgroup: true)
28
+ Process.detach(pid)
29
+ pid
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,93 @@
1
+ require "fileutils"
2
+ require_relative "browsers"
3
+ require_relative "cdp"
4
+ require_relative "errors"
5
+ require_relative "launcher"
6
+
7
+ module Portage
8
+ module Cli
9
+ module BrowserProfile
10
+ # `portage browser profile init|open|status` (docs/plans/
11
+ # buy-skill-and-local-browser.md Phase 6): a dedicated Chromium
12
+ # profile directory under ~/.portage/browser/<browser>/profile,
13
+ # launched with remote debugging bound to that profile only —
14
+ # never the browser's own default profile, and never a directory
15
+ # this class didn't create itself. The user signs into their
16
+ # shopping sites in this profile once; nothing here ever reads a
17
+ # credential, cookie or autofill store from it (that's Chromium's
18
+ # own job once the shopper is looking at the page).
19
+ class Profile
20
+ ROOT = File.join(Dir.home, ".portage", "browser").freeze
21
+ DEFAULT_PORT = 9223
22
+
23
+ attr_reader :browser, :port, :dir
24
+
25
+ def initialize(browser: "chrome", root: ROOT, port: DEFAULT_PORT, launcher: Launcher.new, cdp: Cdp,
26
+ poll_attempts: 20, poll_interval: 0.25, sleep_fn: ->(s) { sleep s })
27
+ raise ArgumentError, "unsupported browser #{browser.inspect} (want one of #{Browsers::CHROMIUM})" unless
28
+ Browsers::CHROMIUM.include?(browser)
29
+
30
+ @browser = browser
31
+ @dir = File.join(root, browser, "profile")
32
+ @port = port
33
+ @launcher = launcher
34
+ @cdp = cdp
35
+ @poll_attempts = poll_attempts
36
+ @poll_interval = poll_interval
37
+ @sleep_fn = sleep_fn
38
+ end
39
+
40
+ # Never launches anything — just makes sure the dedicated profile
41
+ # directory exists, so `portage browser profile open` always has
42
+ # somewhere of its own to point `--user-data-dir` at.
43
+ def init!
44
+ FileUtils.mkdir_p(@dir)
45
+ { browser: @browser, dir: @dir, port: @port, created: true }
46
+ end
47
+
48
+ # @return [Hash] `running:` plus, when running, whatever
49
+ # `/json/version` reports (Browser, protocolVersion, …) — no
50
+ # HTTP request is ever made to anything but 127.0.0.1:`port`.
51
+ def status
52
+ version = @cdp.version(port: @port)
53
+ base = { running: !version.nil?, browser: @browser, dir: @dir, port: @port }
54
+ version ? base.merge(version) : base
55
+ end
56
+
57
+ # Launches the browser if it isn't already running on this
58
+ # profile's port, then either opens a new tab at `url` or hands
59
+ # back the first existing tab. Idempotent: calling this against an
60
+ # already-running profile just attaches.
61
+ def open!(url: nil)
62
+ launch! unless status[:running]
63
+ attach(url)
64
+ end
65
+
66
+ private
67
+
68
+ def launch!
69
+ binary = Browsers.binary_for(@browser)
70
+ raise BrowserNotFoundError, "#{@browser} isn't installed on this machine" unless binary
71
+
72
+ FileUtils.mkdir_p(@dir)
73
+ @launcher.launch(binary: binary, profile_dir: @dir, port: @port)
74
+ wait_until_running!
75
+ end
76
+
77
+ def wait_until_running!
78
+ @poll_attempts.times do
79
+ return true if status[:running]
80
+
81
+ @sleep_fn.call(@poll_interval)
82
+ end
83
+ raise LaunchError, "#{@browser} didn't answer its remote-debugging port (#{@port}) in time"
84
+ end
85
+
86
+ def attach(url)
87
+ target = url ? @cdp.new_tab(port: @port, url: url) : @cdp.list(port: @port).first
88
+ { running: true, browser: @browser, dir: @dir, port: @port, target: target }
89
+ end
90
+ end
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,25 @@
1
+ require_relative "browser_profile/errors"
2
+ require_relative "browser_profile/browsers"
3
+ require_relative "browser_profile/cdp"
4
+ require_relative "browser_profile/cdp_socket"
5
+ require_relative "browser_profile/allowlist"
6
+ require_relative "browser_profile/bridge"
7
+ require_relative "browser_profile/launcher"
8
+ require_relative "browser_profile/profile"
9
+
10
+ module Portage
11
+ module Cli
12
+ # `portage browser profile init|open|status` — the Portage browser
13
+ # profile (docs/plans/buy-skill-and-local-browser.md Phase 6, Tier B).
14
+ # A dedicated Chromium-family profile directory under
15
+ # ~/.portage/browser/, never the browser's own default profile, that
16
+ # `portage buy --handoff-target profile` drives as a WebMCP bridge
17
+ # (see Bridge) limited to a domain allowlist (Allowlist) — the store
18
+ # being bought from, plus its checkout host once known. The user signs
19
+ # into their shopping sites in it once; Portage never reads a
20
+ # credential/cookie/autofill store from it, never touches a payment
21
+ # field, and never clicks pay.
22
+ module BrowserProfile
23
+ end
24
+ end
25
+ end