portage-cli 0.12.0 → 0.13.1

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: b9dba6d24f2ebadd03022d53d85684d8cbe8e3110b609f1becadd4d5212ec47b
4
+ data.tar.gz: ed6cfcbb1516e7addd017c25a52343f95c71b72bde869aa7e1acd06a44eb3a48
5
5
  SHA512:
6
- metadata.gz: d79a48ad6cf39bad956cafbb2e9f3317485af02a14fa3305306a4185839ce3add20326025e9eb4cfa0e0e1709ecbade149b5b2e9109627fb9b3b6112f11e91ed
7
- data.tar.gz: 5bbf4e713f496fef30925cb4f59de1ad929e1a51b960c804f6bb60bbce39c8dd0856d3efcd5641f175a25bebfd7868358d63a827d0e6123a3d5b9763a7d27ead
6
+ metadata.gz: 54563a08ca407b2847502f2a95290598147f4e78aa47f346ce0f20d637d416afb6e5c7119ba4e19e233fe64286a83cb5b47fc1351d63a7aabac81f5065060893
7
+ data.tar.gz: 396ee2c09e74e5708fd5ade1555c412bf5b342979549b04932809760e0744b303050c0dc92b4a4e97d3e4c8f0090a92f7921cc29b020e277370e2566cdf8ca07
data/CHANGELOG.md CHANGED
@@ -6,6 +6,34 @@ pre-1.0, so APIs may still shift between minor versions.
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.13.1] - 2026-10-07
10
+
11
+ - **Fix: `portage orders reconcile` can settle a hand-off on a BigCommerce, Magento, Wix or Shopify-adapter store.** Those adapters kept checkout status in the process that created the checkout, so reconcile (a new process) only ever saw `incomplete` or not-found. With their new releases, an adapter checkout that became a paid order reads as `completed` with its `order`, and reconcile settles it and snapshots the order as it does for native UCP stores. No CLI code changed; a new spec covers the adapter loopback path end to end. WooCommerce has no such signal and still stays pending (design-log §55).
12
+
13
+ - **Fix: `portage orders reconcile` (and `buy --wait`) can read a native UCP checkout again.** `HandoffReconciler` called `get_checkout` and `get_order` without `meta.ucp-agent.profile`, so every real UCP store answered `MissingAgentProfileError`. Reconcile read that as "not found", so a pending hand-off never settled while the checkout lived and would have settled `failed`/`unknown` at expiry even if the shopper had paid. It now sends the same agent profile `buy` does (found in the handoff-reconcile Phase 0 live check, design-log §55). A store error's note is now the server's own message (`ServerError#summary`), not Shopify's whole several-kilobyte UCP envelope.
14
+
15
+ - **Fix: a store that escalates its checkout can be approved and reconciled under `require_approval`.** On a dry run, including the dry run the default `require_approval: any` makes of an unapproved `buy --yes`, a `requires_escalation` checkout was reported as `requires_escalation`, so no quote was saved, there was nothing to `portage approve`, and the report pointed at the checkout link with no pending hand-off recorded. A dry run now reports it as `dry_run` with a new `handoff_expected: true` key and a note that a real run hands it off, so the gated run returns `needs_approval` and the approved run's hand-off is recorded for reconcile (design-log §55). A plain `--yes` run outside the gate still escalates as before.
16
+
17
+ ## [0.13.0] - 2026-10-05
18
+
19
+ - **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.
20
+
21
+ - 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.
22
+
23
+ - **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.
24
+
25
+ - 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.
26
+
27
+ - **`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.
28
+
29
+ - **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.
30
+
31
+ - 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.
32
+
33
+ - 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.
34
+
35
+ - Internal: the private `Cli.format_amount` wrapper is gone; its four callers in `cli.rb` call `Cli::Money.format_amount` directly. No behaviour change.
36
+
9
37
  ## [0.12.0] - 2026-10-01
10
38
 
11
39
  - **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 +813,7 @@ pre-1.0, so APIs may still shift between minor versions.
785
813
  ## [0.7.4] - 2026-09-25
786
814
 
787
815
  - **`portage doctor` reports how it was installed, and warns when another
788
- `portage` shadows it** (`docs/plans/homebrew-distribution.md` Phase 4).
816
+ `portage` shadows it** ([plan](https://github.com/tomtom87/Portage/blob/d51200f/docs/plans/homebrew-distribution.md) Phase 4).
789
817
  New findings, all offline and without shelling out to `brew`:
790
818
  - `install`: `homebrew` (with the Cellar keg) when this gem or its Ruby
791
819
  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)
@@ -1075,8 +1075,13 @@ module Portage
1075
1075
  return quote_changed_report(source, products, checkout, warnings) if quote_exceeded?(checkout)
1076
1076
 
1077
1077
  escalation = decide_escalation(checkout, warnings)
1078
+ # A dry run never hands off, so an escalating checkout is still just
1079
+ # a priced dry run: reporting it as `requires_escalation` saved no
1080
+ # quote, and the CLI's approval gate had nothing to approve
1081
+ # (design-log §55). The real run after approval escalates and hands
1082
+ # off, which is where the pending record gets written.
1083
+ return dry_run_report(source, products, checkout, warnings, escalates: escalation[:escalate]) if @dry_run
1078
1084
  return escalated_report(source, products, checkout, warnings, escalation) if escalation[:escalate]
1079
- return dry_run_report(source, products, checkout, warnings) if @dry_run
1080
1085
  return webmcp_handoff_checkout_report(source, products, checkout, warnings) if force_handoff
1081
1086
  return confirmation_needed_report(source, products, checkout, warnings) unless confirmed?
1082
1087
 
@@ -1582,9 +1587,13 @@ module Portage
1582
1587
  # `warnings` here are #reconcile_checkout's mismatches, which a real
1583
1588
  # run of the same checkout stops on — so the report says so up front,
1584
1589
  # before anyone approves a quote for it.
1585
- def dry_run_report(source, products, checkout, warnings = [])
1590
+ def dry_run_report(source, products, checkout, warnings = [], escalates: false)
1586
1591
  message = "Dry run — checkout created but not completed."
1587
1592
  extra = {}
1593
+ if escalates
1594
+ message += " The store needs the shopper to finish this checkout, so a real run hands it off."
1595
+ extra[:handoff_expected] = true
1596
+ end
1588
1597
  if warnings.any?
1589
1598
  message += " It doesn't match the request (#{warnings.join(' ')}), so a real purchase would stop " \
1590
1599
  "with checkout_mismatch."
@@ -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,10 @@ 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 "agent_profile_url"
8
+ require_relative "adapter_session"
8
9
  require_relative "handoff_spend_mode"
9
10
  require_relative "reconcile_notifier"
10
- require_relative "permissive_authenticator"
11
11
  require_relative "handoff_only"
12
12
  require_relative "handoff_reconciler/wire_adapters"
13
13
 
@@ -93,7 +93,7 @@ module Portage
93
93
 
94
94
  def reconcile(record)
95
95
  session = reconnect(record["store_url"])
96
- checkout = session.get_checkout(checkout_id: record["checkout_id"])
96
+ checkout = session.get_checkout(checkout_id: record["checkout_id"], meta: agent_meta)
97
97
  settle_from_checkout(record, checkout)
98
98
  rescue StandardError => e
99
99
  # A reconnect failure (no automated path back into the store) and a
@@ -103,7 +103,9 @@ module Portage
103
103
  # buys nothing here: either way, this run learned nothing new about
104
104
  # whether the shopper paid, and the record's own `expires_at` is
105
105
  # still the only thing allowed to turn that into a settle.
106
- settle_not_found(record, note: e.message)
106
+ # ServerError#summary: Shopify answers a gone checkout with its whole
107
+ # UCP envelope as the message; the note only needs its messages[].
108
+ settle_not_found(record, note: e.respond_to?(:summary) ? e.summary : e.message)
107
109
  end
108
110
 
109
111
  def settle_from_checkout(record, checkout)
@@ -177,7 +179,7 @@ module Portage
177
179
  return nil unless order && order["id"]
178
180
 
179
181
  session = reconnect(record["store_url"])
180
- order_wire = session.get_order(order_id: order["id"])
182
+ order_wire = session.get_order(order_id: order["id"], meta: agent_meta)
181
183
  return order["id"] unless order_wire
182
184
 
183
185
  @order_ledger.record(idempotency_key: record["idempotency_key"], order: WireOrder.new(order_wire))
@@ -230,34 +232,20 @@ module Portage
230
232
  raise ReconnectError, "#{uri.host} is hand-off only — never reconciled automatically" \
231
233
  if @handoff_only.host?(uri.host)
232
234
 
233
- native_session(uri) || adapter_session(uri) ||
235
+ native_session(uri) || AdapterSession.call(uri) ||
234
236
  raise(ReconnectError, "no automated path back into #{store_url}")
235
237
  end
236
238
 
239
+ # Same `meta.ucp-agent.profile` Buy#agent_meta sends: a real UCP
240
+ # server rejects any call without it, so leaving it off made every
241
+ # native reconcile fail as "not found" (design-log §55).
242
+ def agent_meta = { agent_profile: AgentProfileUrl.resolve }
243
+
237
244
  def native_session(uri)
238
245
  Portage::Ucp::Client.discover(uri.to_s, headers: UserAgent.headers)
239
246
  rescue Portage::Ucp::Client::DiscoveryError
240
247
  nil
241
248
  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
249
  end
262
250
  end
263
251
  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