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 +4 -4
- data/CHANGELOG.md +21 -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/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 +2 -22
- 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: 7c58c0040e6d1f58b5cf9dbbe64c91273c198a4cfc8119477e1fcbbf04c4c4dd
|
|
4
|
+
data.tar.gz: ba48da2d241487885a286eb8044e493f6727b7e7d4b96f3b9dc7d748b1644f67
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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** (
|
|
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 |
|
|
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/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,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 "
|
|
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) ||
|
|
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:
|
|
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
|
data/lib/portage/cli/notifier.rb
CHANGED
|
@@ -55,21 +55,25 @@ module Portage
|
|
|
55
55
|
def call(payload)
|
|
56
56
|
return nil unless enabled?
|
|
57
57
|
|
|
58
|
-
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
|
67
|
-
#
|
|
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`.
|