portage-cli 0.1.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 +7 -0
- data/CHANGELOG.md +21 -0
- data/LICENSE +21 -0
- data/README.md +129 -0
- data/exe/portage +6 -0
- data/lib/portage/cli/buy.rb +282 -0
- data/lib/portage/cli/find.rb +217 -0
- data/lib/portage/cli/probe_cache.rb +64 -0
- data/lib/portage/cli/search_backends.rb +201 -0
- data/lib/portage/cli/version.rb +5 -0
- data/lib/portage/cli.rb +202 -0
- metadata +149 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: a4a32f5894d104c340e147d698772d21492c3172d6b8e102c4508e431d210a9e
|
|
4
|
+
data.tar.gz: dbf75ae4f0c5bd7996095d40c0a7e019d4cf9e0376b529356e227d5879363a4a
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: e41a7814ccdf875fae65a3ec9001c678e6cd0f951c504a0f675d3a34c3126349486df39919e514056b2529305d6560745ca4e028269c69b58e60d6610465f3fc
|
|
7
|
+
data.tar.gz: 75503266fb054b39739bcd2786083454e0afdefae6e64aaa2573ebc6b4633fd4c3956b16c61707cdbb3bc688a56e31734c4b04293678473b8ffc776a80ceb4d5
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented here. Format loosely follows
|
|
4
|
+
[Keep a Changelog](https://keepachangelog.com/en/1.0.0/); this project is
|
|
5
|
+
pre-1.0, so APIs may still shift between minor versions.
|
|
6
|
+
|
|
7
|
+
## [0.1.0] - Unreleased
|
|
8
|
+
|
|
9
|
+
- Initial pre-release. `portage buy <url>` — native UCP discovery first,
|
|
10
|
+
adapter fallback only when this process already has that platform's own
|
|
11
|
+
credentials.
|
|
12
|
+
- `portage find --query "..."` — find UCP stores that stock something without
|
|
13
|
+
knowing a URL, via an allowlist, DuckDuckGo, Brave, or a Google Programmable
|
|
14
|
+
Search engine, then probe each candidate for `/.well-known/ucp` and search
|
|
15
|
+
the survivors' catalogs. Probe results are cached in
|
|
16
|
+
`~/.portage/discovery-cache.json`.
|
|
17
|
+
- `portage buy` with no URL runs that search and buys the offer you pick.
|
|
18
|
+
`--yes` alone won't buy from a search result: the merchant has to be named by
|
|
19
|
+
`--store` or an interactive pick.
|
|
20
|
+
- `portage buy --product-id ID` buys exactly that product instead of whatever
|
|
21
|
+
the catalog search ranks first.
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Tom Whitbread
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# portage-cli
|
|
2
|
+
|
|
3
|
+
Ships the `portage` executable — one CLI command to buy from any store, native
|
|
4
|
+
UCP or not.
|
|
5
|
+
|
|
6
|
+
```bash
|
|
7
|
+
portage buy https://your-shop.example --query "snowboard" --qty 1 --payment-token spt_1a2b3c...
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
`portage buy <url>`:
|
|
11
|
+
|
|
12
|
+
1. Tries native UCP discovery first (`GET /.well-known/ucp`, then a `<link
|
|
13
|
+
rel="ucp">`-style tag on the homepage) — zero credentials, works on any
|
|
14
|
+
store that's opted in.
|
|
15
|
+
2. Falls back to a `portage-ucp-<platform>` adapter **only** when this process
|
|
16
|
+
already has that platform's own credentials in env — i.e. it's your own
|
|
17
|
+
store, or one you're integrated with.
|
|
18
|
+
3. Otherwise says so plainly and stops — never scrapes or session-hijacks as an
|
|
19
|
+
anonymous shopper. That fallback path is a ToS violation this gem
|
|
20
|
+
deliberately refuses to take.
|
|
21
|
+
|
|
22
|
+
Don't have a URL? `portage find` asks a search backend which stores might sell
|
|
23
|
+
the thing, keeps the ones that answer `/.well-known/ucp`, and lists what they
|
|
24
|
+
actually stock:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
portage find --query "burton snowboard" --max-price 400
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
`portage buy` with no URL runs that search and then buys the offer you pick.
|
|
31
|
+
|
|
32
|
+
Depends on [`portage-ucp`](https://github.com/tomtom87/Portage/tree/main/portage-ucp)
|
|
33
|
+
(for platform detection via `Resolver`) and
|
|
34
|
+
[`portage-ucp-client`](https://github.com/tomtom87/Portage/tree/main/portage-ucp-client)
|
|
35
|
+
(for the actual buy calls). No single adapter gem is a hard dependency — install
|
|
36
|
+
whichever `portage-ucp-<platform>` gem matches the store you're integrated with,
|
|
37
|
+
if any.
|
|
38
|
+
|
|
39
|
+
## Installation
|
|
40
|
+
|
|
41
|
+
```ruby
|
|
42
|
+
# Gemfile
|
|
43
|
+
gem "portage-cli"
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
bundle install
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Or standalone:
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
gem install portage-cli
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Usage
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
portage buy <url> --query "..." [--qty N] [--payment-token TOKEN] [--product-id ID]
|
|
60
|
+
[--yes] [--dry-run] [--json]
|
|
61
|
+
portage buy --query "..." [--store URL] [--max-price N] [--limit N] ...
|
|
62
|
+
portage find --query "..." [--max-price N] [--limit N] [--json]
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
- `--query` — search term. Against the store's catalog when you name a store,
|
|
66
|
+
against the search backends when you don't.
|
|
67
|
+
- `--qty` — quantity, default `1`.
|
|
68
|
+
- `--payment-token` — a tokenized payment credential (never a raw card number —
|
|
69
|
+
`PaymentTokenGuard` in the core gem rejects those before they reach the wire).
|
|
70
|
+
Omit for `--dry-run` or to just browse.
|
|
71
|
+
- `--product-id` — buy exactly this product rather than whatever the catalog
|
|
72
|
+
search ranks first. If the id isn't in the results, nothing is bought.
|
|
73
|
+
- `--store` — name the merchant without giving a full URL; skips the search.
|
|
74
|
+
- `--max-price` — in major units (`400` means 400), compared per offer in that
|
|
75
|
+
offer's own currency. No FX conversion.
|
|
76
|
+
- `--limit` — how many candidate stores to probe, capped at 12.
|
|
77
|
+
- `--yes` — skip the confirmation prompt before completing checkout.
|
|
78
|
+
- `--dry-run` — resolve and price the order without completing checkout.
|
|
79
|
+
- `--json` — machine-readable report instead of the human-readable summary.
|
|
80
|
+
|
|
81
|
+
Exits `0` when a checkout completed (or a dry-run/browse/search resolved
|
|
82
|
+
successfully), `1` otherwise — including the "no native manifest, no adapter
|
|
83
|
+
credentials" dead-end case, so it's scriptable in CI.
|
|
84
|
+
|
|
85
|
+
## Buying without a URL
|
|
86
|
+
|
|
87
|
+
`portage find` and URL-less `portage buy` share one pipeline:
|
|
88
|
+
|
|
89
|
+
1. **Ask the backends** which stores might sell it (see below).
|
|
90
|
+
2. **Probe each candidate origin** for `/.well-known/ucp`, one request each,
|
|
91
|
+
throttled, with results cached in `~/.portage/discovery-cache.json` (misses
|
|
92
|
+
for a day, hits for six hours) so repeat searches don't re-probe the same
|
|
93
|
+
hosts. A tool that fans out an unsolicited request per host per invocation
|
|
94
|
+
is a crawler; this one isn't.
|
|
95
|
+
3. **Search the survivors' catalogs** and merge the offers, buyable stores
|
|
96
|
+
first, then cheapest.
|
|
97
|
+
|
|
98
|
+
**`--yes` is not enough to buy from a search result.** With a URL you chose the
|
|
99
|
+
merchant; without one a search ranker chose it, so the merchant has to be named
|
|
100
|
+
by a person — either `--store`, or an interactive pick from the listed offers.
|
|
101
|
+
A piped or CI run with no `--store` prints the offers and stops.
|
|
102
|
+
|
|
103
|
+
### Search backends
|
|
104
|
+
|
|
105
|
+
Every backend talks to a documented API. None of them parse a results page:
|
|
106
|
+
scraping a search engine is the same class of ToS violation `portage buy`
|
|
107
|
+
already refuses to commit against a merchant.
|
|
108
|
+
|
|
109
|
+
| Backend | Credentials | Notes |
|
|
110
|
+
| --- | --- | --- |
|
|
111
|
+
| Allowlist | `~/.portage/stores.yml` (YAML array of URLs) or `PORTAGE_STORES` (comma-separated) | Stores you already trust. Checked first, costs no network call. |
|
|
112
|
+
| DuckDuckGo | none | The [Instant Answer API](https://api.duckduckgo.com/api). Answers *entity* queries, not web queries: `burton snowboards` resolves to burton.com, `snowboard` resolves to nothing. |
|
|
113
|
+
| Brave | `BRAVE_SEARCH_API_KEY` | Real web results. Set this up if you want open-ended queries to work. |
|
|
114
|
+
| Google | `GOOGLE_CSE_KEY` + `GOOGLE_CSE_CX` | Programmable Search JSON API. |
|
|
115
|
+
|
|
116
|
+
Backends that have no credentials sit out; DuckDuckGo is the keyless default
|
|
117
|
+
because it's the only no-key engine with a real API, and its narrowness is the
|
|
118
|
+
price of not scraping.
|
|
119
|
+
|
|
120
|
+
## Development
|
|
121
|
+
|
|
122
|
+
```bash
|
|
123
|
+
bundle exec rspec
|
|
124
|
+
bundle exec rubocop
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
## License
|
|
128
|
+
|
|
129
|
+
[MIT](LICENSE) — Copyright (c) 2026 Tom Whitbread.
|
data/exe/portage
ADDED
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
require "net/http"
|
|
2
|
+
require "uri"
|
|
3
|
+
require "json"
|
|
4
|
+
require "portage/ucp"
|
|
5
|
+
require "portage/ucp/client"
|
|
6
|
+
|
|
7
|
+
module Portage
|
|
8
|
+
module Cli
|
|
9
|
+
# `portage buy <url>` — the single entrypoint for "get this thing bought",
|
|
10
|
+
# regardless of whether the target store speaks native UCP, only offers a
|
|
11
|
+
# catalog, or doesn't speak UCP at all but happens to run a platform we
|
|
12
|
+
# have an adapter (and this process's own credentials) for.
|
|
13
|
+
#
|
|
14
|
+
# Never tries to buy as an anonymous shopper via scraping/session-hijacking
|
|
15
|
+
# (ToS violation, explicitly ruled out), and never falls back to an
|
|
16
|
+
# adapter unless this process already has that platform's own env vars
|
|
17
|
+
# set — i.e. it's your own store, or one you're integrated with, never a
|
|
18
|
+
# stranger's. See docs/design-log.md for the reasoning behind it.
|
|
19
|
+
class Buy
|
|
20
|
+
CART_CAP = "dev.ucp.shopping.cart".freeze
|
|
21
|
+
CHECKOUT_CAP = "dev.ucp.shopping.checkout".freeze
|
|
22
|
+
REDIRECT_LIMIT = 5
|
|
23
|
+
|
|
24
|
+
# @param product_id [String, nil] buy exactly this product instead of
|
|
25
|
+
# whatever the catalog search happens to rank first — how `portage
|
|
26
|
+
# find` hands a picked offer over without the ranking being guessed
|
|
27
|
+
# twice.
|
|
28
|
+
def initialize(url:, query:, qty: 1, payment_token: nil, yes: false, dry_run: false, product_id: nil)
|
|
29
|
+
raw = url.to_s.strip
|
|
30
|
+
raw = "https://#{raw}" unless raw =~ %r{\Ahttps?://}i
|
|
31
|
+
@uri = URI.parse(raw)
|
|
32
|
+
@query = query
|
|
33
|
+
@qty = qty
|
|
34
|
+
@payment_token = payment_token
|
|
35
|
+
@yes = yes
|
|
36
|
+
@dry_run = dry_run
|
|
37
|
+
@product_id = product_id
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def call
|
|
41
|
+
session = discover(@uri)
|
|
42
|
+
return native_flow(session) if session
|
|
43
|
+
|
|
44
|
+
body, headers = fetch_homepage(@uri)
|
|
45
|
+
|
|
46
|
+
linked = manifest_link(body)
|
|
47
|
+
if linked
|
|
48
|
+
session = discover(linked)
|
|
49
|
+
return native_flow(session) if session
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
platform = Portage::Ucp::Resolver.detect_platform(body, headers)
|
|
53
|
+
adapter_flow(platform) || dead_end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
# --- Step 1: native UCP manifest ---
|
|
59
|
+
|
|
60
|
+
def discover(url)
|
|
61
|
+
Portage::Ucp::Client.discover(url.to_s)
|
|
62
|
+
rescue Portage::Ucp::Client::DiscoveryError
|
|
63
|
+
nil
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def native_flow(session)
|
|
67
|
+
if session.advertises?(CART_CAP) && session.advertises?(CHECKOUT_CAP)
|
|
68
|
+
full_buy(session, source: "native_ucp")
|
|
69
|
+
else
|
|
70
|
+
catalog_only(session)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def catalog_only(session)
|
|
75
|
+
products = safe_search(session)
|
|
76
|
+
report = build_report(
|
|
77
|
+
source: "native_ucp", browse: true, checkout: false, products: products,
|
|
78
|
+
message: "I can browse this store but can't check out via UCP yet."
|
|
79
|
+
)
|
|
80
|
+
merge_adapter_checkout_fallback(report)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# No manifest advertised checkout — see whether a platform adapter can
|
|
84
|
+
# still cover it (fetches the homepage fresh; the "fetch once" rule in
|
|
85
|
+
# the design only covers the "no manifest at all" branch below, since
|
|
86
|
+
# this is a genuinely separate path).
|
|
87
|
+
def merge_adapter_checkout_fallback(report)
|
|
88
|
+
body, headers = fetch_homepage(@uri)
|
|
89
|
+
platform = Portage::Ucp::Resolver.detect_platform(body, headers)
|
|
90
|
+
fallback = platform && adapter_flow(platform)
|
|
91
|
+
return report unless fallback && fallback[:checkout]
|
|
92
|
+
|
|
93
|
+
report.merge(source: fallback[:source], checkout: true, checkout_url: fallback[:checkout_url])
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# --- Step 1 fallback: alternate manifest pointer in <head> ---
|
|
97
|
+
|
|
98
|
+
def manifest_link(body)
|
|
99
|
+
return nil unless body
|
|
100
|
+
|
|
101
|
+
match = body.match(/<link[^>]+rel=["']ucp["'][^>]+href=["']([^"']+)["']/i) ||
|
|
102
|
+
body.match(/<link[^>]+href=["']([^"']+)["'][^>]+rel=["']ucp["']/i)
|
|
103
|
+
match && URI.join(@uri, match[1])
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# --- Step 2/3: platform detection + adapter fallback ---
|
|
107
|
+
|
|
108
|
+
def adapter_flow(platform)
|
|
109
|
+
return nil unless platform
|
|
110
|
+
|
|
111
|
+
env = Portage::Ucp::Resolver.env_for(platform)
|
|
112
|
+
return nil if Portage::Ucp::Resolver.missing_env(platform, env).any?
|
|
113
|
+
|
|
114
|
+
adapter = Portage::Ucp::Resolver.build_adapter(platform, env)
|
|
115
|
+
if adapter_supports_checkout?(adapter)
|
|
116
|
+
full_buy(client_for(adapter), source: "adapter:#{platform.name}")
|
|
117
|
+
else
|
|
118
|
+
catalog_only_adapter(adapter, platform)
|
|
119
|
+
end
|
|
120
|
+
rescue LoadError, StandardError
|
|
121
|
+
nil
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def adapter_supports_checkout?(adapter)
|
|
125
|
+
Portage::Ucp::Capabilities::CART.advertised_for?(adapter) &&
|
|
126
|
+
Portage::Ucp::Capabilities::CHECKOUT.advertised_for?(adapter)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def client_for(adapter)
|
|
130
|
+
Portage::Ucp::Client.for_adapter(adapter, authenticator: PermissiveAuthenticator.new)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def catalog_only_adapter(adapter, platform)
|
|
134
|
+
products = Array(adapter.search_catalog(query: @query, limit: 10))
|
|
135
|
+
checkout = redirect_checkout(adapter, products)
|
|
136
|
+
build_report(
|
|
137
|
+
source: "adapter:#{platform.name}", browse: true, checkout: !!checkout,
|
|
138
|
+
products: products, checkout_url: checkout && checkout.links.first&.url,
|
|
139
|
+
message: "Found it on #{platform.name}, but checkout there isn't a live UCP transaction — " \
|
|
140
|
+
"#{checkout ? 'follow the link to buy it yourself.' : 'no checkout path at all.'}"
|
|
141
|
+
)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def redirect_checkout(adapter, products)
|
|
145
|
+
return nil if products.empty? || !Portage::Ucp::Capabilities::CHECKOUT.advertised_for?(adapter)
|
|
146
|
+
|
|
147
|
+
adapter.create_checkout(line_items: [{ product_id: products.first.id, quantity: @qty }],
|
|
148
|
+
idempotency_key: "portage-buy-#{products.first.id}")
|
|
149
|
+
rescue StandardError
|
|
150
|
+
nil
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# --- The actual buy, shared by native and adapter-loopback sources ---
|
|
154
|
+
|
|
155
|
+
def full_buy(session, source:)
|
|
156
|
+
products = safe_search(session)
|
|
157
|
+
product = select_product(products)
|
|
158
|
+
unless product
|
|
159
|
+
return build_report(source: source, browse: true, checkout: true, products: products,
|
|
160
|
+
message: no_match_message)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
checkout = session.create_checkout(line_items: [{ product_id: product_id_of(product), quantity: @qty }])
|
|
164
|
+
finish_checkout(session, source, products, checkout)
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def no_match_message
|
|
168
|
+
return "No product matched \"#{@query}\"." unless @product_id
|
|
169
|
+
|
|
170
|
+
"Product #{@product_id} isn't in this store's results for \"#{@query}\"."
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# With a --product-id, that exact product or nothing: falling back to the
|
|
174
|
+
# top search hit when the requested id isn't in the results would buy
|
|
175
|
+
# something the caller never chose.
|
|
176
|
+
def select_product(products)
|
|
177
|
+
return products.first unless @product_id
|
|
178
|
+
|
|
179
|
+
products.find { |product| product_id_of(product) == @product_id }
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# search_catalog's results are raw Portage::Ucp::Product structs over
|
|
183
|
+
# the loopback transport (Product has no #to_wire_h, see
|
|
184
|
+
# Dispatcher#wrap) but string-keyed wire hashes over stdio/HTTP (the
|
|
185
|
+
# `mcp` gem's client parses real JSON) — handle both.
|
|
186
|
+
def product_id_of(product)
|
|
187
|
+
product.respond_to?(:id) ? product.id : product["id"]
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def finish_checkout(session, source, products, checkout)
|
|
191
|
+
status = checkout["status"]
|
|
192
|
+
return escalation_report(source, products, checkout) if status == "requires_escalation"
|
|
193
|
+
return dry_run_report(source, products, checkout) if @dry_run
|
|
194
|
+
return confirmation_needed_report(source, products, checkout) unless confirmed?
|
|
195
|
+
|
|
196
|
+
complete(session, source, products, checkout)
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def complete(session, source, products, checkout)
|
|
200
|
+
unless @payment_token
|
|
201
|
+
return checkout_report(source, products, checkout,
|
|
202
|
+
message: "No --payment-token given — can't complete the purchase.")
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
completed = session.complete_checkout(checkout_id: checkout["id"], payment_token: @payment_token)
|
|
206
|
+
checkout_report(source, products, completed, message: "Purchased.")
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def confirmed?
|
|
210
|
+
@yes
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def escalation_report(source, products, checkout)
|
|
214
|
+
checkout_report(source, products, checkout,
|
|
215
|
+
checkout_url: checkout["links"]&.find { |l| l["url"] }&.fetch("url", nil),
|
|
216
|
+
message: "Checkout requires buyer escalation — visit the link to complete it.")
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def dry_run_report(source, products, checkout)
|
|
220
|
+
checkout_report(source, products, checkout, message: "Dry run — checkout created but not completed.")
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def confirmation_needed_report(source, products, checkout)
|
|
224
|
+
checkout_report(source, products, checkout, message: "Checkout ready — pass --yes to confirm the purchase.")
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# Flattens the parts of a Checkout wire hash a CLI caller actually
|
|
228
|
+
# wants to see (id/status/totals) onto the report, rather than nesting
|
|
229
|
+
# the raw hash under a key that'd collide with the boolean `checkout:`
|
|
230
|
+
# field the output struct already reserves (§ output shape).
|
|
231
|
+
def checkout_report(source, products, checkout, message:, checkout_url: nil)
|
|
232
|
+
build_report(source: source, browse: true, checkout: true, products: products, message: message,
|
|
233
|
+
checkout_url: checkout_url, checkout_id: checkout["id"], checkout_status: checkout["status"],
|
|
234
|
+
totals: checkout["totals"])
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def safe_search(session)
|
|
238
|
+
Array(session.search_catalog(query: @query, limit: 10))
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
# --- Homepage fetch (used by both the manifest-not-found path and the
|
|
242
|
+
# catalog-only-native adapter-checkout-fallback path) ---
|
|
243
|
+
|
|
244
|
+
def fetch_homepage(uri, limit = REDIRECT_LIMIT)
|
|
245
|
+
return [nil, {}] if limit.zero?
|
|
246
|
+
|
|
247
|
+
response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https",
|
|
248
|
+
open_timeout: 5, read_timeout: 5) do |http|
|
|
249
|
+
http.get(uri.request_uri, { "User-Agent" => "portage-buy" })
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
case response
|
|
253
|
+
when Net::HTTPRedirection
|
|
254
|
+
fetch_homepage(URI.join(uri, response["location"]), limit - 1)
|
|
255
|
+
when Net::HTTPSuccess
|
|
256
|
+
[response.body, response.to_hash]
|
|
257
|
+
else
|
|
258
|
+
[nil, {}]
|
|
259
|
+
end
|
|
260
|
+
rescue StandardError
|
|
261
|
+
[nil, {}]
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
def dead_end
|
|
265
|
+
build_report(source: "none", browse: false, checkout: false,
|
|
266
|
+
message: "No automated path — visit #{@uri} yourself.")
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def build_report(**fields)
|
|
270
|
+
{ url: @uri.to_s, checkout_url: nil, products: [] }.merge(fields)
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
# Loopback buy against your own store needs *some* authenticator (§9
|
|
274
|
+
# rejects anonymous mutation by default) — since this process already
|
|
275
|
+
# has this platform's own credentials (that's the gate to even reach
|
|
276
|
+
# here), authenticating this local CLI session is reasonable.
|
|
277
|
+
class PermissiveAuthenticator < Portage::Ucp::Authenticator
|
|
278
|
+
def call(_server_context) = :local_cli
|
|
279
|
+
end
|
|
280
|
+
end
|
|
281
|
+
end
|
|
282
|
+
end
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
require "uri"
|
|
2
|
+
require "portage/ucp"
|
|
3
|
+
require "portage/ucp/client"
|
|
4
|
+
|
|
5
|
+
require_relative "search_backends"
|
|
6
|
+
require_relative "probe_cache"
|
|
7
|
+
|
|
8
|
+
module Portage
|
|
9
|
+
module Cli
|
|
10
|
+
# `portage find --query "..."` — the "I don't have a URL" half of the CLI.
|
|
11
|
+
#
|
|
12
|
+
# Ask a search backend which stores might sell the thing, keep only the
|
|
13
|
+
# ones that answer `/.well-known/ucp`, ask each of those what it actually
|
|
14
|
+
# stocks, and return the merged offers. Buy then takes over from a store
|
|
15
|
+
# the caller picked.
|
|
16
|
+
#
|
|
17
|
+
# The split matters: Find never buys. Handing the merchant choice to a
|
|
18
|
+
# search ranker and the purchase decision to `--yes` in one breath is how
|
|
19
|
+
# you end up owning a counterfeit from a shop you've never heard of, so
|
|
20
|
+
# picking a store stays an explicit act (see Cli.run_buy's `--store` gate).
|
|
21
|
+
class Find
|
|
22
|
+
CART_CAP = "dev.ucp.shopping.cart".freeze
|
|
23
|
+
CHECKOUT_CAP = "dev.ucp.shopping.checkout".freeze
|
|
24
|
+
MAX_PROBES = 12
|
|
25
|
+
PER_STORE_RESULTS = 5
|
|
26
|
+
THROTTLE = 0.1
|
|
27
|
+
|
|
28
|
+
# @param max_price [Integer, nil] minor units, matching the protocol's
|
|
29
|
+
# own money representation — the CLI converts from major units.
|
|
30
|
+
def initialize(query:, limit: MAX_PROBES, max_price: nil, backends: nil, cache: nil, throttle: THROTTLE)
|
|
31
|
+
@query = query.to_s
|
|
32
|
+
@limit = [limit, MAX_PROBES].min
|
|
33
|
+
@max_price = max_price
|
|
34
|
+
@backends = backends || SearchBackends.default
|
|
35
|
+
@cache = cache || ProbeCache.new
|
|
36
|
+
@throttle = throttle
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def call
|
|
40
|
+
return report(message: "Nothing to search for — pass --query.") if @query.strip.empty?
|
|
41
|
+
|
|
42
|
+
candidates = candidate_origins
|
|
43
|
+
return report(candidates: candidates, message: no_candidates_message) if candidates.empty?
|
|
44
|
+
|
|
45
|
+
stores = probe(candidates)
|
|
46
|
+
offers = rank(stores.flat_map { |store| offers_for(store) })
|
|
47
|
+
report(candidates: candidates, stores: stores.map { |s| s.slice(:origin, :source, :checkout) },
|
|
48
|
+
offers: offers, message: summary(candidates, stores, offers))
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
# --- Step 1: ask the backends who might sell this ---
|
|
54
|
+
|
|
55
|
+
def candidate_origins
|
|
56
|
+
seen = {}
|
|
57
|
+
@backends.each do |backend|
|
|
58
|
+
urls_from(backend).each { |url| add_candidate(seen, backend, url) }
|
|
59
|
+
end
|
|
60
|
+
seen.values.first(@limit)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Keyed by host rather than by full origin: backends routinely hand back
|
|
64
|
+
# both `http://` and `https://` for the same shop, and probing one host
|
|
65
|
+
# twice over two schemes is a wasted request every time. https wins when
|
|
66
|
+
# both show up; an http-only host is still probed as it was given. The
|
|
67
|
+
# backend credited stays the one that found the host first.
|
|
68
|
+
def add_candidate(seen, backend, url)
|
|
69
|
+
uri = parse_http(url)
|
|
70
|
+
return unless uri
|
|
71
|
+
|
|
72
|
+
existing = seen[uri.host]
|
|
73
|
+
return if existing && !upgradable?(existing, uri)
|
|
74
|
+
|
|
75
|
+
seen[uri.host] = { origin: origin_of(uri), source: existing ? existing[:source] : backend.name }
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def upgradable?(existing, uri)
|
|
79
|
+
uri.scheme == "https" && existing[:origin].start_with?("http://")
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# One backend being down, rate-limited, or misconfigured shouldn't take
|
|
83
|
+
# the whole search with it.
|
|
84
|
+
def urls_from(backend)
|
|
85
|
+
Array(backend.search(@query, limit: @limit))
|
|
86
|
+
rescue StandardError
|
|
87
|
+
[]
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def parse_http(url)
|
|
91
|
+
uri = URI.parse(url.to_s)
|
|
92
|
+
uri if uri.host && uri.scheme.to_s.start_with?("http")
|
|
93
|
+
rescue URI::InvalidURIError
|
|
94
|
+
nil
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Collapse every deep link a backend returns onto the origin, since
|
|
98
|
+
# that's the only thing `/.well-known/ucp` hangs off.
|
|
99
|
+
def origin_of(uri)
|
|
100
|
+
port = uri.port == uri.default_port ? "" : ":#{uri.port}"
|
|
101
|
+
"#{uri.scheme}://#{uri.host}#{port}"
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# --- Step 2: keep the ones that actually speak UCP ---
|
|
105
|
+
|
|
106
|
+
# A cached *miss* is the only verdict that saves work here — a cached hit
|
|
107
|
+
# still has to connect, because a live session is the thing we need next.
|
|
108
|
+
def probe(candidates)
|
|
109
|
+
probed = 0
|
|
110
|
+
candidates.filter_map do |candidate|
|
|
111
|
+
next if @cache.fetch(candidate[:origin]) == false
|
|
112
|
+
|
|
113
|
+
throttle(probed)
|
|
114
|
+
probed += 1
|
|
115
|
+
session = discover(candidate[:origin])
|
|
116
|
+
@cache.record(candidate[:origin], !session.nil?)
|
|
117
|
+
session && candidate.merge(session: session, checkout: checkout?(session))
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def throttle(probed)
|
|
122
|
+
sleep(@throttle) if probed.positive? && @throttle.to_f.positive?
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def discover(origin)
|
|
126
|
+
Portage::Ucp::Client.discover(origin)
|
|
127
|
+
rescue StandardError
|
|
128
|
+
nil
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def checkout?(session)
|
|
132
|
+
session.advertises?(CART_CAP) && session.advertises?(CHECKOUT_CAP)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# --- Step 3: ask the survivors what they stock ---
|
|
136
|
+
|
|
137
|
+
def offers_for(store)
|
|
138
|
+
products = Array(store[:session].search_catalog(query: @query, limit: PER_STORE_RESULTS))
|
|
139
|
+
products.filter_map { |product| offer(store, product) }
|
|
140
|
+
rescue StandardError
|
|
141
|
+
[]
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def offer(store, product)
|
|
145
|
+
amount, currency = price_of(product)
|
|
146
|
+
return nil if @max_price && amount && amount > @max_price
|
|
147
|
+
|
|
148
|
+
{ store: store[:origin], source: store[:source], checkout: store[:checkout],
|
|
149
|
+
product_id: field(product, "id"), title: field(product, "title"),
|
|
150
|
+
amount: amount, currency: currency, url: field(product, "url") }
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Buyable first, then cheapest, then unpriced. Sorting on price alone
|
|
154
|
+
# would float a browse-only store above one you can actually check out
|
|
155
|
+
# from, which is the wrong answer to "buy me this".
|
|
156
|
+
def rank(offers)
|
|
157
|
+
offers.sort_by do |offer|
|
|
158
|
+
[offer[:checkout] ? 0 : 1, offer[:amount] ? 0 : 1, offer[:amount] || 0]
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# --- Shapes ---
|
|
163
|
+
|
|
164
|
+
# Products arrive as `Portage::Ucp::Product` structs over the loopback
|
|
165
|
+
# transport and as string-keyed wire hashes over stdio/HTTP (same split
|
|
166
|
+
# Buy#product_id_of documents), and the wire shape carries a
|
|
167
|
+
# `price_range` rather than a scalar price.
|
|
168
|
+
def price_of(product)
|
|
169
|
+
range = field(product, "price_range")
|
|
170
|
+
return [money_amount(range["min"]), range["min"]["currency"]] if range.is_a?(Hash) && range["min"].is_a?(Hash)
|
|
171
|
+
|
|
172
|
+
scalar_price(field(product, "price"))
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def scalar_price(price)
|
|
176
|
+
case price
|
|
177
|
+
when Hash then [money_amount(price), price["currency"]]
|
|
178
|
+
when Integer then [price, nil]
|
|
179
|
+
when nil then [nil, nil]
|
|
180
|
+
else [price.respond_to?(:amount_minor) ? price.amount_minor : nil,
|
|
181
|
+
price.respond_to?(:currency) ? price.currency : nil]
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def money_amount(price) = price["amount"]
|
|
186
|
+
|
|
187
|
+
def field(product, key)
|
|
188
|
+
return product[key] if product.is_a?(Hash)
|
|
189
|
+
|
|
190
|
+
product.respond_to?(key) ? product.public_send(key) : nil
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def report(**fields)
|
|
194
|
+
{ query: @query, candidates: [], stores: [], offers: [], message: nil }.merge(fields)
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def no_candidates_message
|
|
198
|
+
names = @backends.map(&:name)
|
|
199
|
+
return no_backends_message if names.empty?
|
|
200
|
+
|
|
201
|
+
"No candidate stores came back from #{names.join(', ')} for \"#{@query}\"."
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def no_backends_message
|
|
205
|
+
"No search backend available — set BRAVE_SEARCH_API_KEY, GOOGLE_CSE_KEY/GOOGLE_CSE_CX, " \
|
|
206
|
+
"or list stores in ~/.portage/stores.yml."
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def summary(candidates, stores, offers)
|
|
210
|
+
return "Found #{offers.length} offer(s) across #{stores.length} UCP store(s)." if offers.any?
|
|
211
|
+
return "#{stores.length} store(s) speak UCP but none stock \"#{@query}\"." if stores.any?
|
|
212
|
+
|
|
213
|
+
"Checked #{candidates.length} store(s); none of them speak UCP."
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "fileutils"
|
|
3
|
+
|
|
4
|
+
module Portage
|
|
5
|
+
module Cli
|
|
6
|
+
# Remembers which origins answered `/.well-known/ucp` and which didn't.
|
|
7
|
+
#
|
|
8
|
+
# Without this, every URL-less search re-probes the same dozen hosts, and a
|
|
9
|
+
# tool that fans out one unsolicited request per host per invocation is a
|
|
10
|
+
# crawler wearing a CLI's clothes. Negative results are cached too — and
|
|
11
|
+
# for longer — because "this shop doesn't speak UCP" is the answer that
|
|
12
|
+
# would otherwise be re-asked most often and changes least.
|
|
13
|
+
class ProbeCache
|
|
14
|
+
PATH = File.join(Dir.home, ".portage", "discovery-cache.json").freeze
|
|
15
|
+
HIT_TTL = 6 * 60 * 60
|
|
16
|
+
MISS_TTL = 24 * 60 * 60
|
|
17
|
+
|
|
18
|
+
def initialize(path: PATH, now: Time.now)
|
|
19
|
+
@path = path
|
|
20
|
+
@now = now.to_i
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# @return [Boolean, nil] cached verdict, or nil when unknown/expired.
|
|
24
|
+
def fetch(origin)
|
|
25
|
+
entry = store[origin]
|
|
26
|
+
return nil unless entry.is_a?(Hash) && entry.key?("ucp")
|
|
27
|
+
|
|
28
|
+
ttl = entry["ucp"] ? HIT_TTL : MISS_TTL
|
|
29
|
+
return nil if @now - entry["at"].to_i > ttl
|
|
30
|
+
|
|
31
|
+
entry["ucp"]
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def record(origin, ucp)
|
|
35
|
+
store[origin] = { "ucp" => ucp, "at" => @now }
|
|
36
|
+
write
|
|
37
|
+
ucp
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def store
|
|
43
|
+
@store ||= read
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def read
|
|
47
|
+
return {} unless File.readable?(@path)
|
|
48
|
+
|
|
49
|
+
parsed = JSON.parse(File.read(@path))
|
|
50
|
+
parsed.is_a?(Hash) ? parsed : {}
|
|
51
|
+
rescue StandardError
|
|
52
|
+
{}
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# A cache that can't be written is a slow cache, never a failed run.
|
|
56
|
+
def write
|
|
57
|
+
FileUtils.mkdir_p(File.dirname(@path))
|
|
58
|
+
File.write(@path, JSON.generate(@store))
|
|
59
|
+
rescue StandardError
|
|
60
|
+
nil
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
require "net/http"
|
|
2
|
+
require "uri"
|
|
3
|
+
require "json"
|
|
4
|
+
require "yaml"
|
|
5
|
+
|
|
6
|
+
module Portage
|
|
7
|
+
module Cli
|
|
8
|
+
# Where "which stores might sell this?" comes from when the caller never
|
|
9
|
+
# named a store.
|
|
10
|
+
#
|
|
11
|
+
# Every backend here talks to a documented machine interface and returns
|
|
12
|
+
# bare candidate URLs. None of them parse a results page: scraping a search
|
|
13
|
+
# engine's HTML is the same class of ToS violation Buy already refuses to
|
|
14
|
+
# commit against a merchant, and it would be odd to be scrupulous about the
|
|
15
|
+
# shop and cavalier about the index. That rules out the usual
|
|
16
|
+
# `html.duckduckgo.com/html/?q=` trick — see DuckDuckGo below for what we
|
|
17
|
+
# use instead and what it costs us.
|
|
18
|
+
module SearchBackends
|
|
19
|
+
OPEN_TIMEOUT = 5
|
|
20
|
+
READ_TIMEOUT = 5
|
|
21
|
+
USER_AGENT = "portage-find".freeze
|
|
22
|
+
|
|
23
|
+
# Reference works and marketplaces-of-links that a search backend will
|
|
24
|
+
# happily return for a product query but that are never themselves a UCP
|
|
25
|
+
# store — cheap to skip, and each one skipped is one fewer host we probe.
|
|
26
|
+
NON_STORE_HOSTS = %w[
|
|
27
|
+
wikipedia.org wikimedia.org duckduckgo.com google.com bing.com
|
|
28
|
+
reddit.com youtube.com facebook.com x.com twitter.com pinterest.com
|
|
29
|
+
].freeze
|
|
30
|
+
|
|
31
|
+
# Ordered cheapest/most-trusted first: your own allowlist costs no
|
|
32
|
+
# network call and needs no key, DuckDuckGo needs no key, the keyed
|
|
33
|
+
# engines only participate when their credentials are actually present.
|
|
34
|
+
def self.default
|
|
35
|
+
[Allowlist.new, DuckDuckGo.new, Brave.new, GoogleCse.new].select(&:available?)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def self.get_json(uri, params: {}, headers: {})
|
|
39
|
+
uri = uri.dup
|
|
40
|
+
uri.query = URI.encode_www_form(params) unless params.empty?
|
|
41
|
+
response = request(uri, headers)
|
|
42
|
+
response.is_a?(Net::HTTPSuccess) ? JSON.parse(response.body) : nil
|
|
43
|
+
rescue StandardError
|
|
44
|
+
nil
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def self.request(uri, headers)
|
|
48
|
+
Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https",
|
|
49
|
+
open_timeout: OPEN_TIMEOUT, read_timeout: READ_TIMEOUT) do |http|
|
|
50
|
+
http.get(uri.request_uri, { "User-Agent" => USER_AGENT }.merge(headers))
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
private_class_method :request
|
|
54
|
+
|
|
55
|
+
# @return [Boolean] true when the URL is worth spending a manifest probe on.
|
|
56
|
+
def self.store_candidate?(url)
|
|
57
|
+
host = URI.parse(url.to_s).host
|
|
58
|
+
!!host && NON_STORE_HOSTS.none? { |bad| host == bad || host.end_with?(".#{bad}") }
|
|
59
|
+
rescue URI::InvalidURIError
|
|
60
|
+
false
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Stores you've already decided you trust, listed in
|
|
64
|
+
# `~/.portage/stores.yml` (a bare YAML array of URLs) or `PORTAGE_STORES`
|
|
65
|
+
# (comma-separated — the PATH-style colon can't separate values that
|
|
66
|
+
# contain `https://`). Query-independent on purpose: the point of the file
|
|
67
|
+
# is "always consider these", and the store's own catalog search is what
|
|
68
|
+
# decides whether it stocks the thing.
|
|
69
|
+
class Allowlist
|
|
70
|
+
PATH = File.join(Dir.home, ".portage", "stores.yml").freeze
|
|
71
|
+
|
|
72
|
+
def initialize(path: PATH, env: ENV.fetch("PORTAGE_STORES", nil))
|
|
73
|
+
@path = path
|
|
74
|
+
@env = env
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def name = "allowlist"
|
|
78
|
+
|
|
79
|
+
def available? = !entries.empty?
|
|
80
|
+
|
|
81
|
+
def search(_query, limit: 10) = entries.first(limit)
|
|
82
|
+
|
|
83
|
+
private
|
|
84
|
+
|
|
85
|
+
def entries
|
|
86
|
+
@entries ||= (env_entries + file_entries).map { |e| e.to_s.strip }.reject(&:empty?).uniq
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def env_entries = @env.to_s.split(",")
|
|
90
|
+
|
|
91
|
+
def file_entries
|
|
92
|
+
return [] unless File.readable?(@path)
|
|
93
|
+
|
|
94
|
+
Array(YAML.safe_load_file(@path))
|
|
95
|
+
rescue StandardError
|
|
96
|
+
[]
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# DuckDuckGo's Instant Answer API — official, documented, no key
|
|
101
|
+
# (https://api.duckduckgo.com/api).
|
|
102
|
+
#
|
|
103
|
+
# Know what it is before you lean on it: it answers *entity* queries, not
|
|
104
|
+
# web queries. "burton snowboards" resolves to burton.com through
|
|
105
|
+
# `Results`; "snowboard" resolves to nothing at all. So it covers "buy me
|
|
106
|
+
# a <brand> thing" well and open-ended shopping not at all. It's the
|
|
107
|
+
# keyless default because it's the only no-key engine with a real API;
|
|
108
|
+
# pair it with Brave or a Google CSE for actual breadth.
|
|
109
|
+
class DuckDuckGo
|
|
110
|
+
ENDPOINT = "https://api.duckduckgo.com/".freeze
|
|
111
|
+
|
|
112
|
+
def name = "duckduckgo"
|
|
113
|
+
|
|
114
|
+
def available? = true
|
|
115
|
+
|
|
116
|
+
def search(query, limit: 10)
|
|
117
|
+
data = SearchBackends.get_json(
|
|
118
|
+
URI.parse(ENDPOINT),
|
|
119
|
+
params: { q: query, format: "json", no_html: "1", no_redirect: "1", t: "portage" }
|
|
120
|
+
)
|
|
121
|
+
return [] unless data
|
|
122
|
+
|
|
123
|
+
urls(data).select { |u| SearchBackends.store_candidate?(u) }.uniq.first(limit)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
private
|
|
127
|
+
|
|
128
|
+
# `Results` is the official-site answer and the only genuinely
|
|
129
|
+
# commercial field. `RelatedTopics` is mostly duckduckgo.com category
|
|
130
|
+
# links (filtered out downstream) but occasionally carries a real
|
|
131
|
+
# vendor, so it's worth flattening. `AbstractURL` is deliberately
|
|
132
|
+
# ignored — it's the encyclopedia entry, never the shop.
|
|
133
|
+
def urls(data)
|
|
134
|
+
direct = Array(data["Results"]).map { |r| r["FirstURL"] }
|
|
135
|
+
related = Array(data["RelatedTopics"]).flat_map { |topic| topic_urls(topic) }
|
|
136
|
+
(direct + related).compact
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def topic_urls(topic)
|
|
140
|
+
return [] unless topic.is_a?(Hash)
|
|
141
|
+
return Array(topic["Topics"]).flat_map { |t| topic_urls(t) } if topic["Topics"]
|
|
142
|
+
|
|
143
|
+
[topic["FirstURL"]].compact
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Brave Search API (https://api-dashboard.search.brave.com) — real web
|
|
148
|
+
# results, needs BRAVE_SEARCH_API_KEY. This is the backend to set up if
|
|
149
|
+
# you want URL-less buying to work for generic queries.
|
|
150
|
+
class Brave
|
|
151
|
+
ENDPOINT = "https://api.search.brave.com/res/v1/web/search".freeze
|
|
152
|
+
|
|
153
|
+
def initialize(api_key: ENV.fetch("BRAVE_SEARCH_API_KEY", nil))
|
|
154
|
+
@api_key = api_key
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def name = "brave"
|
|
158
|
+
|
|
159
|
+
def available? = !@api_key.to_s.empty?
|
|
160
|
+
|
|
161
|
+
def search(query, limit: 10)
|
|
162
|
+
data = SearchBackends.get_json(
|
|
163
|
+
URI.parse(ENDPOINT),
|
|
164
|
+
params: { q: query, count: limit },
|
|
165
|
+
headers: { "Accept" => "application/json", "X-Subscription-Token" => @api_key }
|
|
166
|
+
)
|
|
167
|
+
return [] unless data
|
|
168
|
+
|
|
169
|
+
Array(data.dig("web", "results")).map { |r| r["url"] }.compact
|
|
170
|
+
.select { |u| SearchBackends.store_candidate?(u) }.first(limit)
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# Google Programmable Search (Custom Search JSON API) — needs
|
|
175
|
+
# GOOGLE_CSE_KEY and GOOGLE_CSE_CX. The documented API, not the SERP.
|
|
176
|
+
class GoogleCse
|
|
177
|
+
ENDPOINT = "https://www.googleapis.com/customsearch/v1".freeze
|
|
178
|
+
|
|
179
|
+
def initialize(api_key: ENV.fetch("GOOGLE_CSE_KEY", nil), cx: ENV.fetch("GOOGLE_CSE_CX", nil))
|
|
180
|
+
@api_key = api_key
|
|
181
|
+
@cx = cx
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def name = "google_cse"
|
|
185
|
+
|
|
186
|
+
def available? = !@api_key.to_s.empty? && !@cx.to_s.empty?
|
|
187
|
+
|
|
188
|
+
def search(query, limit: 10)
|
|
189
|
+
data = SearchBackends.get_json(
|
|
190
|
+
URI.parse(ENDPOINT),
|
|
191
|
+
params: { key: @api_key, cx: @cx, q: query, num: [limit, 10].min }
|
|
192
|
+
)
|
|
193
|
+
return [] unless data
|
|
194
|
+
|
|
195
|
+
Array(data["items"]).map { |i| i["link"] }.compact
|
|
196
|
+
.select { |u| SearchBackends.store_candidate?(u) }.first(limit)
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
end
|
data/lib/portage/cli.rb
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
require "optparse"
|
|
2
|
+
require "json"
|
|
3
|
+
|
|
4
|
+
require_relative "cli/version"
|
|
5
|
+
require_relative "cli/buy"
|
|
6
|
+
require_relative "cli/find"
|
|
7
|
+
|
|
8
|
+
module Portage
|
|
9
|
+
# `portage` — the single command-line entrypoint for acting as a shopper's
|
|
10
|
+
# agent against any store, native-UCP or not. See Cli::Buy for the buying
|
|
11
|
+
# algorithm and Cli::Find for the "I don't have a URL" search that feeds it;
|
|
12
|
+
# this module is just argument parsing + subcommand dispatch.
|
|
13
|
+
module Cli
|
|
14
|
+
USAGE = <<~USAGE.freeze
|
|
15
|
+
usage: portage buy <url> --query "..." [--qty N] [--payment-token TOKEN]
|
|
16
|
+
[--product-id ID] [--yes] [--dry-run] [--json]
|
|
17
|
+
portage buy --query "..." [--store URL] [--max-price N] [--limit N] ...
|
|
18
|
+
portage find --query "..." [--max-price N] [--limit N] [--json]
|
|
19
|
+
USAGE
|
|
20
|
+
|
|
21
|
+
# @param argv [Array<String>]
|
|
22
|
+
# @return [Integer] process exit code
|
|
23
|
+
def self.run(argv)
|
|
24
|
+
command, *rest = argv
|
|
25
|
+
case command
|
|
26
|
+
when "buy" then run_buy(rest)
|
|
27
|
+
when "find" then run_find(rest)
|
|
28
|
+
else
|
|
29
|
+
warn USAGE
|
|
30
|
+
1
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# --- find ---
|
|
35
|
+
|
|
36
|
+
def self.run_find(argv)
|
|
37
|
+
options = parse_find_options(argv)
|
|
38
|
+
return 1 unless options
|
|
39
|
+
|
|
40
|
+
json = options.delete(:json)
|
|
41
|
+
report = Find.new(**options).call
|
|
42
|
+
puts json ? JSON.pretty_generate(report) : format_find(report)
|
|
43
|
+
report[:offers].any? ? 0 : 1
|
|
44
|
+
end
|
|
45
|
+
private_class_method :run_find
|
|
46
|
+
|
|
47
|
+
def self.parse_find_options(argv)
|
|
48
|
+
opts = {}
|
|
49
|
+
find_option_parser(opts).parse!(argv)
|
|
50
|
+
if opts[:query].to_s.strip.empty?
|
|
51
|
+
warn USAGE
|
|
52
|
+
return nil
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
opts
|
|
56
|
+
end
|
|
57
|
+
private_class_method :parse_find_options
|
|
58
|
+
|
|
59
|
+
def self.find_option_parser(opts)
|
|
60
|
+
OptionParser.new do |parser|
|
|
61
|
+
parser.on("--query QUERY") { |v| opts[:query] = v }
|
|
62
|
+
parser.on("--limit N", Integer) { |v| opts[:limit] = v }
|
|
63
|
+
parser.on("--max-price N", Float) { |v| opts[:max_price] = to_minor_units(v) }
|
|
64
|
+
parser.on("--json") { opts[:json] = true }
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
private_class_method :find_option_parser
|
|
68
|
+
|
|
69
|
+
# `--max-price 400` means 400 of whatever the offer is priced in, and the
|
|
70
|
+
# comparison happens per-offer in that offer's own currency — no FX
|
|
71
|
+
# conversion, and no attempt to handle zero-decimal currencies like JPY.
|
|
72
|
+
def self.to_minor_units(major) = (major * 100).round
|
|
73
|
+
private_class_method :to_minor_units
|
|
74
|
+
|
|
75
|
+
# --- buy ---
|
|
76
|
+
|
|
77
|
+
def self.run_buy(argv)
|
|
78
|
+
parsed = parse_buy_options(argv)
|
|
79
|
+
return 1 unless parsed
|
|
80
|
+
|
|
81
|
+
url = parsed[:buy][:url] || parsed[:store]
|
|
82
|
+
return execute_buy(parsed, url) if url
|
|
83
|
+
|
|
84
|
+
buy_from_search(parsed)
|
|
85
|
+
end
|
|
86
|
+
private_class_method :run_buy
|
|
87
|
+
|
|
88
|
+
# `portage buy` with no URL: search first, then buy from the store the
|
|
89
|
+
# caller picks. `--yes` alone deliberately isn't enough to get here —
|
|
90
|
+
# without a URL the merchant would have been chosen by a search ranker
|
|
91
|
+
# rather than by a person, so either `--store` (handled above) or an
|
|
92
|
+
# interactive pick has to name it. Piped/CI runs list the offers and stop.
|
|
93
|
+
def self.buy_from_search(parsed)
|
|
94
|
+
report = Find.new(**parsed[:find]).call
|
|
95
|
+
offer = pick_offer(report, parsed[:json])
|
|
96
|
+
return report[:offers].any? ? 0 : 1 unless offer
|
|
97
|
+
|
|
98
|
+
execute_buy(parsed, offer[:store], product_id: offer[:product_id])
|
|
99
|
+
end
|
|
100
|
+
private_class_method :buy_from_search
|
|
101
|
+
|
|
102
|
+
def self.pick_offer(report, json)
|
|
103
|
+
output = json ? JSON.pretty_generate(report) : format_find(report)
|
|
104
|
+
puts output
|
|
105
|
+
return nil unless $stdin.tty? && report[:offers].any?
|
|
106
|
+
|
|
107
|
+
prompt_for_offer(report[:offers])
|
|
108
|
+
end
|
|
109
|
+
private_class_method :pick_offer
|
|
110
|
+
|
|
111
|
+
def self.prompt_for_offer(offers)
|
|
112
|
+
print "\nPick 1-#{offers.length} to buy (Enter to quit): "
|
|
113
|
+
choice = $stdin.gets.to_s.strip
|
|
114
|
+
return nil unless choice.match?(/\A\d+\z/)
|
|
115
|
+
|
|
116
|
+
offers[choice.to_i - 1] if choice.to_i.between?(1, offers.length)
|
|
117
|
+
end
|
|
118
|
+
private_class_method :prompt_for_offer
|
|
119
|
+
|
|
120
|
+
def self.execute_buy(parsed, url, product_id: nil)
|
|
121
|
+
options = parsed[:buy].merge(url: url)
|
|
122
|
+
options[:product_id] ||= product_id
|
|
123
|
+
report = Buy.new(**options).call
|
|
124
|
+
puts parsed[:json] ? JSON.pretty_generate(report) : format_report(report)
|
|
125
|
+
report[:checkout] || report[:browse] ? 0 : 1
|
|
126
|
+
end
|
|
127
|
+
private_class_method :execute_buy
|
|
128
|
+
|
|
129
|
+
def self.parse_buy_options(argv)
|
|
130
|
+
url = argv.first && !argv.first.start_with?("-") ? argv.shift : nil
|
|
131
|
+
buy = { url: url, qty: 1, yes: false, dry_run: false }
|
|
132
|
+
parsed = { buy: buy, find: {} }
|
|
133
|
+
buy_option_parser(buy, parsed).parse!(argv)
|
|
134
|
+
buy[:query] ||= ""
|
|
135
|
+
return parsed if url || !buy[:query].strip.empty?
|
|
136
|
+
|
|
137
|
+
warn USAGE
|
|
138
|
+
nil
|
|
139
|
+
end
|
|
140
|
+
private_class_method :parse_buy_options
|
|
141
|
+
|
|
142
|
+
def self.buy_option_parser(buy, parsed)
|
|
143
|
+
OptionParser.new do |parser|
|
|
144
|
+
parser.on("--qty N", Integer) { |v| buy[:qty] = v }
|
|
145
|
+
parser.on("--payment-token TOKEN") { |v| buy[:payment_token] = v }
|
|
146
|
+
parser.on("--product-id ID") { |v| buy[:product_id] = v }
|
|
147
|
+
parser.on("--yes") { buy[:yes] = true }
|
|
148
|
+
parser.on("--dry-run") { buy[:dry_run] = true }
|
|
149
|
+
parser.on("--json") { parsed[:json] = true }
|
|
150
|
+
add_search_options(parser, buy, parsed)
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
private_class_method :buy_option_parser
|
|
154
|
+
|
|
155
|
+
# `--query` feeds both halves: it's the store search when there's no URL
|
|
156
|
+
# and the catalog search once a store is settled, so it's registered once
|
|
157
|
+
# here rather than twice on the same parser.
|
|
158
|
+
def self.add_search_options(parser, buy, parsed)
|
|
159
|
+
parser.on("--query QUERY") { |v| parsed[:find][:query] = buy[:query] = v }
|
|
160
|
+
parser.on("--store URL") { |v| parsed[:store] = v }
|
|
161
|
+
parser.on("--limit N", Integer) { |v| parsed[:find][:limit] = v }
|
|
162
|
+
parser.on("--max-price N", Float) { |v| parsed[:find][:max_price] = to_minor_units(v) }
|
|
163
|
+
end
|
|
164
|
+
private_class_method :add_search_options
|
|
165
|
+
|
|
166
|
+
# --- output ---
|
|
167
|
+
|
|
168
|
+
def self.format_report(report)
|
|
169
|
+
lines = ["#{report[:message]} (source: #{report[:source]})"]
|
|
170
|
+
report[:products].each { |p| lines << " - #{product_line(p)}" }
|
|
171
|
+
lines << " checkout: #{report[:checkout_url]}" if report[:checkout_url]
|
|
172
|
+
lines.join("\n")
|
|
173
|
+
end
|
|
174
|
+
private_class_method :format_report
|
|
175
|
+
|
|
176
|
+
def self.product_line(product)
|
|
177
|
+
product.respond_to?(:title) ? "#{product.id}: #{product.title}" : "#{product['id']}: #{product['title']}"
|
|
178
|
+
end
|
|
179
|
+
private_class_method :product_line
|
|
180
|
+
|
|
181
|
+
def self.format_find(report)
|
|
182
|
+
lines = [report[:message].to_s]
|
|
183
|
+
report[:offers].each_with_index { |offer, index| lines << " #{index + 1}. #{offer_line(offer)}" }
|
|
184
|
+
lines.join("\n")
|
|
185
|
+
end
|
|
186
|
+
private_class_method :format_find
|
|
187
|
+
|
|
188
|
+
def self.offer_line(offer)
|
|
189
|
+
parts = ["#{offer[:store]} — #{offer[:title]} (#{offer[:product_id]})", format_price(offer)]
|
|
190
|
+
parts << "browse only" unless offer[:checkout]
|
|
191
|
+
parts.join(" — ")
|
|
192
|
+
end
|
|
193
|
+
private_class_method :offer_line
|
|
194
|
+
|
|
195
|
+
def self.format_price(offer)
|
|
196
|
+
return "price n/a" unless offer[:amount]
|
|
197
|
+
|
|
198
|
+
"#{format('%.2f', offer[:amount] / 100.0)}#{" #{offer[:currency]}" if offer[:currency]}"
|
|
199
|
+
end
|
|
200
|
+
private_class_method :format_price
|
|
201
|
+
end
|
|
202
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: portage-cli
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Tom Whitbread
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: exe
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-08-14 00:00:00.000000000 Z
|
|
12
|
+
dependencies:
|
|
13
|
+
- !ruby/object:Gem::Dependency
|
|
14
|
+
name: portage-ucp
|
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
|
16
|
+
requirements:
|
|
17
|
+
- - "~>"
|
|
18
|
+
- !ruby/object:Gem::Version
|
|
19
|
+
version: '0.1'
|
|
20
|
+
type: :runtime
|
|
21
|
+
prerelease: false
|
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
23
|
+
requirements:
|
|
24
|
+
- - "~>"
|
|
25
|
+
- !ruby/object:Gem::Version
|
|
26
|
+
version: '0.1'
|
|
27
|
+
- !ruby/object:Gem::Dependency
|
|
28
|
+
name: portage-ucp-client
|
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
|
30
|
+
requirements:
|
|
31
|
+
- - "~>"
|
|
32
|
+
- !ruby/object:Gem::Version
|
|
33
|
+
version: '0.1'
|
|
34
|
+
type: :runtime
|
|
35
|
+
prerelease: false
|
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
37
|
+
requirements:
|
|
38
|
+
- - "~>"
|
|
39
|
+
- !ruby/object:Gem::Version
|
|
40
|
+
version: '0.1'
|
|
41
|
+
- !ruby/object:Gem::Dependency
|
|
42
|
+
name: rspec
|
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
|
44
|
+
requirements:
|
|
45
|
+
- - "~>"
|
|
46
|
+
- !ruby/object:Gem::Version
|
|
47
|
+
version: '3.13'
|
|
48
|
+
type: :development
|
|
49
|
+
prerelease: false
|
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
51
|
+
requirements:
|
|
52
|
+
- - "~>"
|
|
53
|
+
- !ruby/object:Gem::Version
|
|
54
|
+
version: '3.13'
|
|
55
|
+
- !ruby/object:Gem::Dependency
|
|
56
|
+
name: rubocop
|
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
|
58
|
+
requirements:
|
|
59
|
+
- - "~>"
|
|
60
|
+
- !ruby/object:Gem::Version
|
|
61
|
+
version: '1.88'
|
|
62
|
+
type: :development
|
|
63
|
+
prerelease: false
|
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
65
|
+
requirements:
|
|
66
|
+
- - "~>"
|
|
67
|
+
- !ruby/object:Gem::Version
|
|
68
|
+
version: '1.88'
|
|
69
|
+
- !ruby/object:Gem::Dependency
|
|
70
|
+
name: webmock
|
|
71
|
+
requirement: !ruby/object:Gem::Requirement
|
|
72
|
+
requirements:
|
|
73
|
+
- - "~>"
|
|
74
|
+
- !ruby/object:Gem::Version
|
|
75
|
+
version: '3.24'
|
|
76
|
+
type: :development
|
|
77
|
+
prerelease: false
|
|
78
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
79
|
+
requirements:
|
|
80
|
+
- - "~>"
|
|
81
|
+
- !ruby/object:Gem::Version
|
|
82
|
+
version: '3.24'
|
|
83
|
+
- !ruby/object:Gem::Dependency
|
|
84
|
+
name: yard
|
|
85
|
+
requirement: !ruby/object:Gem::Requirement
|
|
86
|
+
requirements:
|
|
87
|
+
- - "~>"
|
|
88
|
+
- !ruby/object:Gem::Version
|
|
89
|
+
version: '0.9'
|
|
90
|
+
type: :development
|
|
91
|
+
prerelease: false
|
|
92
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
93
|
+
requirements:
|
|
94
|
+
- - "~>"
|
|
95
|
+
- !ruby/object:Gem::Version
|
|
96
|
+
version: '0.9'
|
|
97
|
+
description: 'Ships the `portage` executable. `portage buy <url>` tries native UCP
|
|
98
|
+
discovery first (zero credentials, works on any store that''s opted in), falls back
|
|
99
|
+
to a portage-ucp-* platform adapter only when this process already has that platform''s
|
|
100
|
+
own credentials in env (i.e. it''s your own store or one you''re integrated with),
|
|
101
|
+
and otherwise says so plainly — never scrapes or session-hijacks as an anonymous
|
|
102
|
+
shopper. `portage find` covers the no-URL case: ask an allowlist, DuckDuckGo, Brave,
|
|
103
|
+
or a Google Programmable Search engine which stores might sell something, keep the
|
|
104
|
+
ones answering /.well-known/ucp, and search their catalogs — documented APIs only,
|
|
105
|
+
no SERP scraping. Depends on portage-ucp (for platform detection via Resolver) and
|
|
106
|
+
portage-ucp-client (for the actual buy calls); no single adapter gem is a hard dependency.'
|
|
107
|
+
email:
|
|
108
|
+
executables:
|
|
109
|
+
- portage
|
|
110
|
+
extensions: []
|
|
111
|
+
extra_rdoc_files: []
|
|
112
|
+
files:
|
|
113
|
+
- CHANGELOG.md
|
|
114
|
+
- LICENSE
|
|
115
|
+
- README.md
|
|
116
|
+
- exe/portage
|
|
117
|
+
- lib/portage/cli.rb
|
|
118
|
+
- lib/portage/cli/buy.rb
|
|
119
|
+
- lib/portage/cli/find.rb
|
|
120
|
+
- lib/portage/cli/probe_cache.rb
|
|
121
|
+
- lib/portage/cli/search_backends.rb
|
|
122
|
+
- lib/portage/cli/version.rb
|
|
123
|
+
homepage: https://github.com/tomtom87/Portage/tree/main/portage-cli
|
|
124
|
+
licenses:
|
|
125
|
+
- MIT
|
|
126
|
+
metadata:
|
|
127
|
+
source_code_uri: https://github.com/tomtom87/Portage/tree/main/portage-cli
|
|
128
|
+
changelog_uri: https://github.com/tomtom87/Portage/blob/main/portage-cli/CHANGELOG.md
|
|
129
|
+
rubygems_mfa_required: 'true'
|
|
130
|
+
post_install_message:
|
|
131
|
+
rdoc_options: []
|
|
132
|
+
require_paths:
|
|
133
|
+
- lib
|
|
134
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
135
|
+
requirements:
|
|
136
|
+
- - ">="
|
|
137
|
+
- !ruby/object:Gem::Version
|
|
138
|
+
version: '3.2'
|
|
139
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
140
|
+
requirements:
|
|
141
|
+
- - ">="
|
|
142
|
+
- !ruby/object:Gem::Version
|
|
143
|
+
version: '0'
|
|
144
|
+
requirements: []
|
|
145
|
+
rubygems_version: 3.5.22
|
|
146
|
+
signing_key:
|
|
147
|
+
specification_version: 4
|
|
148
|
+
summary: portage — one CLI command to buy from any store, native UCP or not
|
|
149
|
+
test_files: []
|