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 +4 -4
- data/CHANGELOG.md +29 -1
- data/README.md +1 -1
- data/lib/portage/cli/adapter_session.rb +34 -0
- data/lib/portage/cli/browser_import/importer.rb +1 -1
- data/lib/portage/cli/browser_import/plist_xml.rb +5 -12
- data/lib/portage/cli/browser_import/sqlite.rb +19 -25
- data/lib/portage/cli/browser_profile/cdp.rb +0 -9
- data/lib/portage/cli/buy.rb +11 -2
- data/lib/portage/cli/compare.rb +4 -3
- data/lib/portage/cli/find.rb +2 -8
- data/lib/portage/cli/generate/agent_profile.rb +1 -1
- data/lib/portage/cli/handoff_agents.rb +7 -21
- data/lib/portage/cli/handoff_reconciler.rb +13 -25
- data/lib/portage/cli/homepage_fetch.rb +5 -3
- data/lib/portage/cli/index/builder.rb +2 -11
- data/lib/portage/cli/index/known_cache.rb +9 -2
- data/lib/portage/cli/index/sources/stores_file.rb +2 -11
- data/lib/portage/cli/index/sources/wikidata.rb +2 -11
- data/lib/portage/cli/install_doctor.rb +2 -1
- data/lib/portage/cli/notifier.rb +16 -12
- data/lib/portage/cli/offer_sources.rb +3 -14
- data/lib/portage/cli/payment_methods.rb +7 -30
- data/lib/portage/cli/reconcile_notifier.rb +2 -5
- data/lib/portage/cli/setup_wizard/steps/env_keys_step.rb +40 -0
- data/lib/portage/cli/setup_wizard/steps/retailer_keys.rb +11 -23
- data/lib/portage/cli/setup_wizard/steps/search_keys.rb +10 -22
- data/lib/portage/cli/setup_wizard/steps/shipping.rb +5 -19
- data/lib/portage/cli/version.rb +1 -1
- data/lib/portage/cli.rb +5 -171
- metadata +4 -3
- data/lib/portage/cli/known_stores_url.rb +0 -15
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b9dba6d24f2ebadd03022d53d85684d8cbe8e3110b609f1becadd4d5212ec47b
|
|
4
|
+
data.tar.gz: ed6cfcbb1516e7addd017c25a52343f95c71b72bde869aa7e1acd06a44eb3a48
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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** (
|
|
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 |
|
|
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
|
|
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
|
|
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
|
-
|
|
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 "
|
|
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
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
15
|
-
#
|
|
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
|
-
#
|
|
18
|
-
#
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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)
|
data/lib/portage/cli/buy.rb
CHANGED
|
@@ -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."
|
data/lib/portage/cli/compare.rb
CHANGED
|
@@ -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
|
-
|
|
61
|
-
|
|
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 #{
|
|
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
|
data/lib/portage/cli/find.rb
CHANGED
|
@@ -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),
|
|
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::
|
|
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,
|
|
154
|
-
#
|
|
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
|
-
|
|
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 "
|
|
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
|
-
|
|
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) ||
|
|
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:
|
|
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:
|
|
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
|
|
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
|