portage-cli 0.12.0 → 0.13.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4e0a17085649f21644b4e373afc590ab69b92cfdbf23c4b83fbf7ed33b1cc1c8
4
- data.tar.gz: 75e4435e369e70f529af29ce9891208615d2f0d458df616dbf11a032f8276ed3
3
+ metadata.gz: 7c58c0040e6d1f58b5cf9dbbe64c91273c198a4cfc8119477e1fcbbf04c4c4dd
4
+ data.tar.gz: ba48da2d241487885a286eb8044e493f6727b7e7d4b96f3b9dc7d748b1644f67
5
5
  SHA512:
6
- metadata.gz: d79a48ad6cf39bad956cafbb2e9f3317485af02a14fa3305306a4185839ce3add20326025e9eb4cfa0e0e1709ecbade149b5b2e9109627fb9b3b6112f11e91ed
7
- data.tar.gz: 5bbf4e713f496fef30925cb4f59de1ad929e1a51b960c804f6bb60bbce39c8dd0856d3efcd5641f175a25bebfd7868358d63a827d0e6123a3d5b9763a7d27ead
6
+ metadata.gz: 388d5ea84ba8ba1279862b57b07cf36db2164cec50752638ae3eb2d924fd81c56a569ea2d107994fc56f3d1989ab0d5b567fed3e0a12e9b3e7c65a291b95e198
7
+ data.tar.gz: fd18e9aa2c798935143742e961936c359ff68722a3f8bad2f20489322456df3467f042ef9c653a4f72f167fc4a765a08bd81680e01ec6c2a0b26fb0ce934b477
data/CHANGELOG.md CHANGED
@@ -6,6 +6,26 @@ pre-1.0, so APIs may still shift between minor versions.
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.13.0] - 2026-10-05
10
+
11
+ - **Fix: `payment_methods` adapter fallback no longer silently fails.** `PaymentMethods#adapter_session` referenced `Buy::PermissiveAuthenticator`, which doesn't exist (the class is `Portage::Cli::PermissiveAuthenticator`); the resulting `NameError` was swallowed by the method's `rescue StandardError`, so enrolling against your own adapter-backed store always reported no session. It now requires and uses the right constant. Patch-level bug fix.
12
+
13
+ - Documentation only, no code change. Comments and changelog entries that cited `docs/plans/homebrew-distribution.md`, which is retired, now cite a permalink at commit d51200f. Patch-level.
14
+
15
+ - **Removed `Portage::Cli::BrowserProfile::Cdp.close_tab`.** Nothing in the CLI called it. It is a public class method, so this is **breaking** for any caller using it directly; minor-level for a pre-1.0 gem.
16
+
17
+ - Internal: the Shipping, SearchKeys and RetailerKeys setup-wizard steps now share one `SetupWizard::Steps::EnvKeysStep` base class instead of three copies of the same prompt-and-save body. No behaviour change.
18
+
19
+ - **`portage browser import` no longer needs the `sqlite3` command-line tool.** It reads the copied Chromium, Firefox and Safari history databases through the `sqlite3` gem, which `portage-cli` already depends on for the local index, instead of shelling out to the system `sqlite3` CLI. The copy-to-tmpdir step (with the `-wal` file) and the UTF-8 scrub are unchanged. `error: "reader_unavailable"` now means a SQLite error reading the copy (for example a corrupt or non-SQLite file) or, for Safari bookmarks, a missing `plutil`; `docs/api/cli-json.md` and the `buy` skill's `references/outcomes.md` say so. `BrowserImport::Sqlite.new` no longer takes a `command:` keyword (it only existed for a spec), which is **breaking** for any caller passing it; minor-level for a pre-1.0 gem.
20
+
21
+ - **Fix: `payment_methods` enroll now finds your own store when its homepage redirects** (for example apex to `www`). The adapter fallback's homepage fetch followed no redirects; it now follows up to five, like `buy`'s, and every hop stays on the `payment` proxy route (forced direct unless a proxy is named for it). Internally, `PaymentMethods` and `HandoffReconciler` now share one `Cli::AdapterSession` for that fallback, and `HomepageFetch.call` takes a `route:` keyword (default `:store`). Patch-level.
22
+
23
+ - Internal: `HandoffAgents::Webhook` and `Notifier` now make their JSON POST through one `Notifier.post_json` class method instead of two copies. Timeouts, the agent webhook's https-only check and the error messages are unchanged, and the agent webhook still never falls back to `PORTAGE_NOTIFY_WEBHOOK_URL`. No behaviour change.
24
+
25
+ - Internal: `BrowserImport::PlistXml` decodes entities with `CGI.unescapeHTML` (from the default `cgi/escape` library) instead of its own table. The same for the five named entities and decimal and hex character references; an uppercase `A` now decodes (it used to become NUL) and an out-of-range code point is left as written (it used to raise `RangeError`). No gemspec change.
26
+
27
+ - Internal: the private `Cli.format_amount` wrapper is gone; its four callers in `cli.rb` call `Cli::Money.format_amount` directly. No behaviour change.
28
+
9
29
  ## [0.12.0] - 2026-10-01
10
30
 
11
31
  - **Release note: the confidence check wants `portage-ucp-decision` 0.1.2.** `portage-ucp-decision` stays an optional install, not a dependency, but 0.1.2 is the version that rejects a malformed backend answer (see its changelog); the README says so.
@@ -785,7 +805,7 @@ pre-1.0, so APIs may still shift between minor versions.
785
805
  ## [0.7.4] - 2026-09-25
786
806
 
787
807
  - **`portage doctor` reports how it was installed, and warns when another
788
- `portage` shadows it** (`docs/plans/homebrew-distribution.md` Phase 4).
808
+ `portage` shadows it** ([plan](https://github.com/tomtom87/Portage/blob/d51200f/docs/plans/homebrew-distribution.md) Phase 4).
789
809
  New findings, all offline and without shelling out to `brew`:
790
810
  - `install`: `homebrew` (with the Cellar keg) when this gem or its Ruby
791
811
  lives under `HOMEBREW_PREFIX/Cellar/portage/` (`$HOMEBREW_PREFIX`,
data/README.md CHANGED
@@ -512,7 +512,7 @@ probes per run), throttled, with progress output. Sources:
512
512
  | --- | --- | --- |
513
513
  | `shopify_catalog` | Merchant origins and product identities, one query per top-level taxonomy node, from `catalog.shopify.com`'s open catalog | on |
514
514
  | `stores_file` | Your own `~/.portage/stores.yml` | on |
515
- | `browser` | Whatever `portage browser import` (below) already saved — this source itself never reads a browser | on, but yields nothing unless you've run `browser import` |
515
+ | `browser` | Whatever `portage browser import` (below) already saved — this source itself never reads a browser | opt-in (`--sources browser`); yields nothing unless you've run `browser import` |
516
516
  | `wikidata` | Retailers'/brands' official sites via a public SPARQL query | opt-in (`--sources wikidata`) |
517
517
  | `webmcp_sweep` | Which WebMCP preset an origin matches, when a bridge is attached | opt-in, needs a bridge |
518
518
  | `storefront_products` | Each indexed Shopify store's own `/products.json`: title, brand, handle, URL, first image, options and variant ids, mapped through the UCP `Product` shape with price and availability dropped | opt-in (`--sources storefront_products`, or `index add URL --crawl`) |
@@ -0,0 +1,34 @@
1
+ require "portage/ucp"
2
+ require "portage/ucp/client"
3
+ require_relative "homepage_fetch"
4
+ require_relative "permissive_authenticator"
5
+
6
+ module Portage
7
+ module Cli
8
+ # The own-store loopback fallback HandoffReconciler and PaymentMethods
9
+ # share, mirroring Buy's own (#adapter_flow): sniff the platform from
10
+ # the store's homepage and, when that platform's env vars are set,
11
+ # build its adapter and wrap it in a loopback client. A later process
12
+ # must not assume an earlier one's session or credentials still exist,
13
+ # only that the env vars are still set.
14
+ module AdapterSession
15
+ # @param route [Symbol] the Support::Connection route the homepage
16
+ # fetch goes over (PaymentMethods passes :payment).
17
+ # @return [Portage::Ucp::Client::Session, nil] nil when the platform
18
+ # isn't recognised, its env is incomplete, or anything fails.
19
+ def self.call(uri, route: :store)
20
+ body, headers = HomepageFetch.call(uri, route: route)
21
+ platform = body && Portage::Ucp::Resolver.detect_platform(body, headers)
22
+ return nil unless platform
23
+
24
+ env = Portage::Ucp::Resolver.env_for(platform)
25
+ return nil unless Portage::Ucp::Resolver.missing_env(platform, env).empty?
26
+
27
+ adapter = Portage::Ucp::Resolver.build_adapter(platform, env)
28
+ Portage::Ucp::Client.for_adapter(adapter, authenticator: PermissiveAuthenticator.new)
29
+ rescue StandardError
30
+ nil
31
+ end
32
+ end
33
+ end
34
+ end
@@ -88,7 +88,7 @@ module Portage
88
88
  # @return [Hash] the proposal: counts, `kept` (domains to save) and
89
89
  # `products` (only with include_product_pages) — or `error:` /
90
90
  # `message:` when the browser's files couldn't be read (Safari
91
- # without Full Disk Access, no sqlite3, no profile found).
91
+ # without Full Disk Access, a SQLite error, no plutil, no profile found).
92
92
  def plan(options)
93
93
  profiles = Profiles.locate(options.browser, root: options.root)
94
94
  return no_profile(options) if profiles.empty?
@@ -1,4 +1,5 @@
1
1
  require "strscan"
2
+ require "cgi/escape"
2
3
 
3
4
  module Portage
4
5
  module Cli
@@ -8,7 +9,8 @@ module Portage
8
9
  # form into XML. `plutil -convert json` would be simpler but refuses
9
10
  # any plist holding a <date> (Safari's Reading List entries carry
10
11
  # them), and a real XML library (rexml) isn't a runtime dependency of
11
- # this gem — this is ~50 lines instead of a new one.
12
+ # this gem — this is ~50 lines instead of a new one. Entities go
13
+ # through CGI.unescapeHTML from `cgi/escape`, a default library.
12
14
  #
13
15
  # Dates, data and numbers come back as their raw text; bookmark
14
16
  # import only ever reads strings out of the tree.
@@ -16,7 +18,6 @@ module Portage
16
18
  class ParseError < StandardError; end
17
19
 
18
20
  SCALARS = "string|key|data|date|integer|real".freeze
19
- ENTITIES = { "amp" => "&", "lt" => "<", "gt" => ">", "quot" => '"', "apos" => "'" }.freeze
20
21
 
21
22
  def self.parse(xml)
22
23
  scanner = StringScanner.new(xml.to_s)
@@ -31,7 +32,7 @@ module Portage
31
32
  elsif scanner.scan(%r{<(true|false)\s*/>}) then scanner[1] == "true"
32
33
  elsif scanner.scan("<dict>") then dict(scanner)
33
34
  elsif scanner.scan("<array>") then array(scanner)
34
- elsif scanner.scan(%r{<(#{SCALARS})>(.*?)</\1>}m) then unescape(scanner[2])
35
+ elsif scanner.scan(%r{<(#{SCALARS})>(.*?)</\1>}m) then CGI.unescapeHTML(scanner[2])
35
36
  else raise ParseError, "unexpected plist content at #{scanner.pos}"
36
37
  end
37
38
  end
@@ -57,15 +58,7 @@ module Portage
57
58
  { "dict" => {}, "array" => [] }.fetch(tag, "")
58
59
  end
59
60
 
60
- def self.unescape(text)
61
- text.gsub(/&(#x?)?(\w+);/) do
62
- if Regexp.last_match(1) == "#x" then [Regexp.last_match(2).to_i(16)].pack("U")
63
- elsif Regexp.last_match(1) == "#" then [Regexp.last_match(2).to_i].pack("U")
64
- else ENTITIES.fetch(Regexp.last_match(2), Regexp.last_match(0))
65
- end
66
- end
67
- end
68
- private_class_method :value, :dict, :array, :empty, :unescape
61
+ private_class_method :value, :dict, :array, :empty
69
62
  end
70
63
  end
71
64
  end
@@ -1,22 +1,21 @@
1
- require "json"
2
- require "open3"
1
+ require "sqlite3"
3
2
  require "tmpdir"
4
3
  require "fileutils"
5
4
 
6
5
  module Portage
7
6
  module Cli
8
7
  module BrowserImport
9
- # Reads a browser's SQLite history database without a native gem:
10
- # the database (plus its `-wal` file, when the browser left one — it
11
- # holds the most recent visits until the next checkpoint) is copied
12
- # into a private tmpdir first, since a running browser holds it
13
- # locked, and the copy is queried with the system `sqlite3` CLI in
14
- # `-readonly -json` mode. The copy is deleted before this returns,
15
- # success or not. The original file is only ever read by the copy.
8
+ # Reads a browser's SQLite history database: the database (plus its
9
+ # `-wal` file, when the browser left one — it holds the most recent
10
+ # visits until the next checkpoint) is copied into a private tmpdir
11
+ # first, since a running browser holds it locked, and the copy is
12
+ # queried read-only through the sqlite3 gem. The `-shm` index is not
13
+ # copied: SQLite rebuilds it from the `-wal` in the (writable) tmpdir.
14
+ # The copy is deleted before this returns, success or not. The
15
+ # original file is only ever read by the copy.
16
16
  #
17
- # `sqlite3` ships with macOS and every mainstream Linux distro; when
18
- # it's missing, Unavailable says so rather than falling back to
19
- # anything else.
17
+ # Any SQLite error (a corrupt or non-SQLite file, a schema that
18
+ # doesn't match the query) is Unavailable, never a fallback.
20
19
  class Sqlite
21
20
  class Unavailable < StandardError; end
22
21
 
@@ -25,12 +24,6 @@ module Portage
25
24
  # around — see Importer's own handling.
26
25
  class PermissionDenied < StandardError; end
27
26
 
28
- # @param command [String] the sqlite3 executable — injectable so a
29
- # spec can prove the missing-binary path without uninstalling it.
30
- def initialize(command: "sqlite3")
31
- @command = command
32
- end
33
-
34
27
  # Copies `path` once and yields a query proc, so one copy serves
35
28
  # several queries (Firefox's history and bookmarks both live in
36
29
  # places.sqlite).
@@ -54,14 +47,15 @@ module Portage
54
47
  end
55
48
 
56
49
  def run(copy, sql)
57
- out, err, status = Open3.capture3(@command, "-readonly", "-json", copy, sql)
58
- raise Unavailable, "sqlite3 failed: #{err.strip}" unless status.success?
59
-
60
- text = out.dup.force_encoding("UTF-8").scrub
61
- text.strip.empty? ? [] : JSON.parse(text)
62
- rescue Errno::ENOENT
63
- raise Unavailable, "the sqlite3 command isn't installed (it ships with macOS and most Linux distros)"
50
+ db = SQLite3::Database.new(copy, readonly: true, results_as_hash: true)
51
+ db.execute(sql).map { |row| row.transform_values { |v| v.is_a?(String) ? scrub(v) : v } }
52
+ rescue SQLite3::Exception => e
53
+ raise Unavailable, "SQLite error: #{e.message}"
54
+ ensure
55
+ db&.close
64
56
  end
57
+
58
+ def scrub(text) = text.dup.force_encoding("UTF-8").scrub
65
59
  end
66
60
  end
67
61
  end
@@ -34,15 +34,6 @@ module Portage
34
34
  get(host, port, "/json/new?#{URI.encode_www_form_component(url)}", method: :put)
35
35
  end
36
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
37
  def self.get(host, port, path, method: :get)
47
38
  uri = URI("http://#{host}:#{port}#{path}")
48
39
  response = fetch(uri, method)
@@ -57,12 +57,13 @@ module Portage
57
57
  return report(message: "#{uri.host} is hand-off only — #{HandoffOnly::LEGAL_NOTICE}") \
58
58
  if @handoff_only.host?(uri.host)
59
59
 
60
- session = discover(origin_of(uri))
61
- return report(message: "#{origin_of(uri)} doesn't speak UCP.") unless session
60
+ origin = OfferSources.origin_of(uri.to_s)
61
+ session = discover(origin)
62
+ return report(message: "#{origin} doesn't speak UCP.") unless session
62
63
 
63
64
  wrapped = session.get_product(product_id: @origin_product_id)
64
65
  product = wrapped.is_a?(Hash) ? wrapped["product"] : nil
65
- return report(message: "Product #{@origin_product_id.inspect} not found at #{origin_of(uri)}.") unless product
66
+ return report(message: "Product #{@origin_product_id.inspect} not found at #{origin}.") unless product
66
67
 
67
68
  { host: uri.host, title: field(product, "title"), product: product }
68
69
  end
@@ -148,7 +148,8 @@ module Portage
148
148
  existing = seen[uri.host]
149
149
  return if existing && !upgradable?(existing, uri)
150
150
 
151
- seen[uri.host] = { origin: origin_of(uri), source: existing ? existing[:source] : backend.name,
151
+ seen[uri.host] = { origin: OfferSources.origin_of(uri.to_s),
152
+ source: existing ? existing[:source] : backend.name,
152
153
  handoff_only: @handoff_only.host?(uri.host) }
153
154
  end
154
155
 
@@ -209,13 +210,6 @@ module Portage
209
210
  nil
210
211
  end
211
212
 
212
- # Collapse every deep link a backend returns onto the origin, since
213
- # that's the only thing `/.well-known/ucp` hangs off.
214
- def origin_of(uri)
215
- port = uri.port == uri.default_port ? "" : ":#{uri.port}"
216
- "#{uri.scheme}://#{uri.host}#{port}"
217
- end
218
-
219
213
  # --- Step 2: keep the ones that actually speak UCP ---
220
214
 
221
215
  # A cached *miss* is the only verdict that saves work here — a cached hit
@@ -178,7 +178,7 @@ module Portage
178
178
  end
179
179
 
180
180
  # Raw uncompressed EC point encoding: 0x04 || x (32 bytes) || y (32
181
- # bytes) for P-256 — see Portage::Ucp::Security::Signature::CURVES,
181
+ # bytes) for P-256 — see Portage::Ucp::Security::EcJwk::CURVES,
182
182
  # which decodes the same layout in reverse when verifying.
183
183
  def coordinates(pkey)
184
184
  octets = pkey.public_key.to_bn.to_s(2)
@@ -1,10 +1,9 @@
1
1
  require "json"
2
- require "net/http"
3
2
  require "open3"
4
3
  require "timeout"
5
4
  require "uri"
6
- require "portage/ucp/support/connection"
7
5
  require_relative "config"
6
+ require_relative "notifier"
8
7
  require_relative "user_agent"
9
8
 
10
9
  module Portage
@@ -150,12 +149,13 @@ module Portage
150
149
  def excerpt(stderr, stdout) = (stderr.to_s.empty? ? stdout.to_s : stderr.to_s).strip[0, BODY_EXCERPT]
151
150
  end
152
151
 
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.
152
+ # POSTs the payload as JSON — https only, through Notifier.post_json
153
+ # (same connection helper as --notify-webhook, with a longer timeout),
154
+ # failures swallowed into the return value rather than raised. Never
155
+ # Notifier#call itself: that falls back to PORTAGE_NOTIFY_WEBHOOK_URL
156
+ # or config, so a blank agent URL would post to the global webhook.
156
157
  class Webhook
157
158
  TIMEOUT = 10
158
- BODY_EXCERPT = 200
159
159
 
160
160
  def initialize(url)
161
161
  @url = url.to_s
@@ -164,21 +164,7 @@ module Portage
164
164
  def call(payload)
165
165
  return "agent webhook must be https" unless @url.start_with?("https://")
166
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
167
+ Notifier.post_json(@url, payload, timeout: TIMEOUT, label: "agent webhook")
182
168
  end
183
169
  end
184
170
  end
@@ -4,10 +4,9 @@ require "portage/ucp"
4
4
  require "portage/ucp/client"
5
5
  require "portage/ucp/journal"
6
6
  require_relative "user_agent"
7
- require_relative "homepage_fetch"
7
+ require_relative "adapter_session"
8
8
  require_relative "handoff_spend_mode"
9
9
  require_relative "reconcile_notifier"
10
- require_relative "permissive_authenticator"
11
10
  require_relative "handoff_only"
12
11
  require_relative "handoff_reconciler/wire_adapters"
13
12
 
@@ -230,7 +229,7 @@ module Portage
230
229
  raise ReconnectError, "#{uri.host} is hand-off only — never reconciled automatically" \
231
230
  if @handoff_only.host?(uri.host)
232
231
 
233
- native_session(uri) || adapter_session(uri) ||
232
+ native_session(uri) || AdapterSession.call(uri) ||
234
233
  raise(ReconnectError, "no automated path back into #{store_url}")
235
234
  end
236
235
 
@@ -239,25 +238,6 @@ module Portage
239
238
  rescue Portage::Ucp::Client::DiscoveryError
240
239
  nil
241
240
  end
242
-
243
- # Mirrors Buy's own own-store loopback fallback (#adapter_flow) — a
244
- # later process re-discovers the platform from the homepage the same
245
- # way, and must not assume the original process's session or
246
- # credentials still exist, only that this platform's env vars are
247
- # still set (see the plan's non-negotiable constraint).
248
- def adapter_session(uri)
249
- body, headers = HomepageFetch.call(uri)
250
- platform = body && Portage::Ucp::Resolver.detect_platform(body, headers)
251
- return nil unless platform
252
-
253
- env = Portage::Ucp::Resolver.env_for(platform)
254
- return nil unless Portage::Ucp::Resolver.missing_env(platform, env).empty?
255
-
256
- adapter = Portage::Ucp::Resolver.build_adapter(platform, env)
257
- Portage::Ucp::Client.for_adapter(adapter, authenticator: PermissiveAuthenticator.new)
258
- rescue StandardError
259
- nil
260
- end
261
241
  end
262
242
  end
263
243
  end
@@ -13,21 +13,23 @@ module Portage
13
13
  module HomepageFetch
14
14
  REDIRECT_LIMIT = 5
15
15
 
16
+ # @param route [Symbol] Support::Connection route for every hop,
17
+ # redirects included (:store, or :payment for PaymentMethods).
16
18
  # @return [Array(String, Hash), Array(nil, Hash)] the body and response
17
19
  # headers, or [nil, {}] on any failure/redirect exhaustion — same
18
20
  # "no automated path, not a crash" posture as the rest of the buy
19
21
  # flow.
20
- def self.call(uri, limit: REDIRECT_LIMIT)
22
+ def self.call(uri, limit: REDIRECT_LIMIT, route: :store)
21
23
  return [nil, {}] if limit.zero?
22
24
 
23
- response = Portage::Ucp::Support::Connection.start(uri, route: :store, open_timeout: 5,
25
+ response = Portage::Ucp::Support::Connection.start(uri, route: route, open_timeout: 5,
24
26
  read_timeout: 5) do |http|
25
27
  http.get(uri.request_uri, UserAgent.headers)
26
28
  end
27
29
 
28
30
  case response
29
31
  when Net::HTTPRedirection
30
- call(URI.join(uri, response["location"]), limit: limit - 1)
32
+ call(URI.join(uri, response["location"]), limit: limit - 1, route: route)
31
33
  when Net::HTTPSuccess
32
34
  [response.body, response.to_hash]
33
35
  else
@@ -5,6 +5,7 @@ require_relative "../user_agent"
5
5
  require_relative "../classifier"
6
6
  require_relative "../probe_cache"
7
7
  require_relative "../handoff_only"
8
+ require_relative "../offer_sources"
8
9
  require_relative "store"
9
10
  require_relative "product_store"
10
11
  require_relative "known_cache"
@@ -103,7 +104,7 @@ module Portage
103
104
  # Opt-in: a crawl is up to 21 more requests and 20s of pauses, where
104
105
  # a plain add is one probe.
105
106
  def add(url, crawl: false)
106
- origin = origin_of(url)
107
+ origin = OfferSources.origin_of(url)
107
108
  return { added: false, message: "Not a valid http(s) URL: #{url}" } unless origin
108
109
  return store_manual_handoff_only(origin) if handoff_only_origin?(origin)
109
110
 
@@ -356,16 +357,6 @@ module Portage
356
357
  def progress(message)
357
358
  @out&.puts(message)
358
359
  end
359
-
360
- def origin_of(url)
361
- uri = URI.parse(url.to_s)
362
- return nil unless uri.host && uri.scheme.to_s.start_with?("http")
363
-
364
- port = uri.port == uri.default_port ? "" : ":#{uri.port}"
365
- "#{uri.scheme}://#{uri.host}#{port}"
366
- rescue URI::InvalidURIError
367
- nil
368
- end
369
360
  end
370
361
  end
371
362
  end
@@ -5,7 +5,6 @@ require "fileutils"
5
5
  require "timeout"
6
6
  require "portage/ucp/support/connection"
7
7
 
8
- require_relative "../known_stores_url"
9
8
  require_relative "../user_agent"
10
9
 
11
10
  module Portage
@@ -32,6 +31,14 @@ module Portage
32
31
  STORES_PATH = File.join(Dir.home, ".portage", "index", "known-stores.json").freeze
33
32
  PRODUCTS_PATH = File.join(Dir.home, ".portage", "index", "known-products.json").freeze
34
33
 
34
+ # Published on the same jsdelivr `@main` channel AgentProfileUrl
35
+ # uses for the agent profile: free GitHub, no Actions, so a new
36
+ # entry reaches every install on its next refresh as soon as a PR
37
+ # merges, with no gem/brew release to wait on.
38
+ BASE_URL = "https://cdn.jsdelivr.net/gh/tomtom87/Portage@main/portage-cli/known-stores".freeze
39
+ STORES_URL = "#{BASE_URL}/stores.json".freeze
40
+ PRODUCTS_URL = "#{BASE_URL}/products.json".freeze
41
+
35
42
  STALE_AFTER = 7 * 24 * 60 * 60
36
43
  TIMEOUT = 5
37
44
 
@@ -44,7 +51,7 @@ module Portage
44
51
  FORBIDDEN_FIELDS = %w[price amount stock].freeze
45
52
 
46
53
  def initialize(stores_path: STORES_PATH, products_path: PRODUCTS_PATH,
47
- stores_url: KnownStoresUrl::STORES, products_url: KnownStoresUrl::PRODUCTS)
54
+ stores_url: STORES_URL, products_url: PRODUCTS_URL)
48
55
  @stores_path = stores_path
49
56
  @products_path = products_path
50
57
  @stores_url = stores_url
@@ -1,6 +1,7 @@
1
1
  require "uri"
2
2
 
3
3
  require_relative "../../search_backends"
4
+ require_relative "../../offer_sources"
4
5
 
5
6
  module Portage
6
7
  module Cli
@@ -36,21 +37,11 @@ module Portage
36
37
  private
37
38
 
38
39
  def sighting_for(url)
39
- origin = origin_of(url)
40
+ origin = OfferSources.origin_of(url)
40
41
  return nil unless origin
41
42
 
42
43
  { origin: origin, url: nil, title: nil, brand: nil, gtin: nil }
43
44
  end
44
-
45
- def origin_of(url)
46
- uri = URI.parse(url.to_s)
47
- return nil unless uri.host && uri.scheme.to_s.start_with?("http")
48
-
49
- port = uri.port == uri.default_port ? "" : ":#{uri.port}"
50
- "#{uri.scheme}://#{uri.host}#{port}"
51
- rescue URI::InvalidURIError
52
- nil
53
- end
54
45
  end
55
46
  end
56
47
  end
@@ -5,6 +5,7 @@ require "timeout"
5
5
  require "portage/ucp/support/connection"
6
6
 
7
7
  require_relative "../../user_agent"
8
+ require_relative "../../offer_sources"
8
9
 
9
10
  module Portage
10
11
  module Cli
@@ -73,21 +74,11 @@ module Portage
73
74
 
74
75
  def sighting_for(row)
75
76
  url = row.dig("website", "value")
76
- origin = origin_of(url)
77
+ origin = OfferSources.origin_of(url)
77
78
  return nil unless origin
78
79
 
79
80
  { origin: origin, url: url, title: nil, brand: row.dig("itemLabel", "value"), gtin: nil }
80
81
  end
81
-
82
- def origin_of(url)
83
- uri = URI.parse(url.to_s)
84
- return nil unless uri.host && uri.scheme.to_s.start_with?("http")
85
-
86
- port = uri.port == uri.default_port ? "" : ":#{uri.port}"
87
- "#{uri.scheme}://#{uri.host}#{port}"
88
- rescue URI::InvalidURIError
89
- nil
90
- end
91
82
  end
92
83
  end
93
84
  end
@@ -3,7 +3,8 @@ require_relative "version"
3
3
 
4
4
  module Portage
5
5
  module Cli
6
- # Phase 4 of docs/plans/homebrew-distribution.md's `doctor` checks: how
6
+ # Phase 4 of homebrew-distribution.md's `doctor` checks (see
7
+ # https://github.com/tomtom87/Portage/blob/d51200f/docs/plans/homebrew-distribution.md): how
7
8
  # this copy of portage-cli was installed (Homebrew formula or plain
8
9
  # `gem install`), which Ruby it runs on, which first-party adapter gems
9
10
  # it can load, and whether the `portage` a shell would actually run is
@@ -55,21 +55,25 @@ module Portage
55
55
  def call(payload)
56
56
  return nil unless enabled?
57
57
 
58
- response = post(URI(webhook_url), JSON.generate(payload))
59
- return nil if response.is_a?(Net::HTTPSuccess)
60
-
61
- "webhook answered #{response.code}: #{response.body.to_s[0, BODY_EXCERPT]}"
62
- rescue StandardError => e
63
- "webhook POST failed: #{e.message}"
58
+ self.class.post_json(webhook_url, payload, timeout: TIMEOUT, label: "webhook")
64
59
  end
65
60
 
66
- private
67
-
68
- def post(uri, body)
69
- Portage::Ucp::Support::Connection.start(uri, route: :notify, open_timeout: TIMEOUT,
70
- read_timeout: TIMEOUT) do |http|
71
- http.post(uri.request_uri, body, UserAgent.headers.merge("Content-Type" => "application/json"))
61
+ # The one JSON POST both this and HandoffAgents::Webhook make, on the
62
+ # :notify route. `label` starts each failure message.
63
+ #
64
+ # @return [String, nil] the failure message, or nil on any 2xx.
65
+ def self.post_json(url, payload, timeout:, label:)
66
+ uri = URI(url)
67
+ response = Portage::Ucp::Support::Connection.start(uri, route: :notify, open_timeout: timeout,
68
+ read_timeout: timeout) do |http|
69
+ http.post(uri.request_uri, JSON.generate(payload),
70
+ UserAgent.headers.merge("Content-Type" => "application/json"))
72
71
  end
72
+ return nil if response.is_a?(Net::HTTPSuccess)
73
+
74
+ "#{label} answered #{response.code}: #{response.body.to_s[0, BODY_EXCERPT]}"
75
+ rescue StandardError => e
76
+ "#{label} POST failed: #{e.message}"
73
77
  end
74
78
  end
75
79
  end
@@ -63,9 +63,8 @@ module Portage
63
63
  end
64
64
 
65
65
  # Shared by every retailer source below: an offer's `store` is the
66
- # origin of the item URL the API itself returned, same idea as
67
- # ShopifyCatalog#origin_of — kept as one module method instead of
68
- # four copies.
66
+ # origin of the item URL the API itself returned. The index builder
67
+ # and its sources use it too, rather than keeping their own copies.
69
68
  def self.origin_of(url)
70
69
  uri = URI.parse(url.to_s)
71
70
  return nil unless uri.host && uri.scheme.to_s.start_with?("http")
@@ -142,7 +141,7 @@ module Portage
142
141
 
143
142
  def offer(product)
144
143
  variant = Array(product["variants"]).first
145
- origin = variant && origin_of(variant["url"])
144
+ origin = variant && OfferSources.origin_of(variant["url"])
146
145
  return nil unless origin
147
146
 
148
147
  amount, currency = price_of(product)
@@ -152,16 +151,6 @@ module Portage
152
151
  )
153
152
  end
154
153
 
155
- def origin_of(url)
156
- uri = URI.parse(url.to_s)
157
- return nil unless uri.host && uri.scheme.to_s.start_with?("http")
158
-
159
- port = uri.port == uri.default_port ? "" : ":#{uri.port}"
160
- "#{uri.scheme}://#{uri.host}#{port}"
161
- rescue URI::InvalidURIError
162
- nil
163
- end
164
-
165
154
  # Same wire shape as Find#price_of — a `price_range.min` Money
166
155
  # object, wire-hash-keyed same as every result that's passed through
167
156
  # Dispatcher#wrap's `to_wire_h`.