portage-cli 0.2.0 → 0.4.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 +49 -0
- data/README.md +172 -1
- data/lib/portage/cli/buy.rb +12 -8
- data/lib/portage/cli/catalog_products.rb +29 -0
- data/lib/portage/cli/compare.rb +160 -0
- data/lib/portage/cli/find.rb +8 -10
- data/lib/portage/cli/history.rb +72 -0
- data/lib/portage/cli/payment_methods/env_backend.rb +28 -0
- data/lib/portage/cli/payment_methods/keychain_backend.rb +42 -0
- data/lib/portage/cli/payment_methods/secret_service_backend.rb +39 -0
- data/lib/portage/cli/payment_methods.rb +265 -0
- data/lib/portage/cli/version.rb +1 -1
- data/lib/portage/cli.rb +367 -0
- metadata +18 -7
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2dda5ce1d230eb5063b5a0a5a576acfce59be4a12abd415f85a0ba6d5d01d6d6
|
|
4
|
+
data.tar.gz: 4c4732f171e0da0dd8480cd260e1a273e3938ad7b500d3166e86445d2ae7beca
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 15c3ec9d03069d07adf77b404741867755b24bce9dcbb15ca11005db4344195c1f9cfb741e6340609bcb43b40ea875d8f6c420017132ae98c93d9acca60b45a2
|
|
7
|
+
data.tar.gz: d9f8dcaee95eaafa9197b313cab7ee70410f1808ad2bf5d4bd722276cd9f7fa7e7074d3080d49723c56e0ba677fa036822bfaa30ebe42471aadce46cfb0fe49d
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,55 @@ All notable changes to this project are documented here. Format loosely follows
|
|
|
4
4
|
[Keep a Changelog](https://keepachangelog.com/en/1.0.0/); this project is
|
|
5
5
|
pre-1.0, so APIs may still shift between minor versions.
|
|
6
6
|
|
|
7
|
+
## [Unreleased]
|
|
8
|
+
|
|
9
|
+
## [0.4.0] - 2026-09-14
|
|
10
|
+
|
|
11
|
+
- Added `portage payment list/enroll/set-default/remove/freeze/revoke` —
|
|
12
|
+
card-on-file storage so `buy`'s `--payment-token` dead-end can fall back to
|
|
13
|
+
a stored default (`@payment_token ||= PaymentMethods.default`) instead of
|
|
14
|
+
requiring a fresh token on every call (docs/plans/agentic-payments.md
|
|
15
|
+
Phase 1). Storage picks macOS Keychain / Linux Secret Service (`secret-tool`,
|
|
16
|
+
D-Bus session required) / a headless `PORTAGE_PAYMENT_TOKEN`-only tier, in
|
|
17
|
+
that order, with no homegrown fallback store. `enroll` is a browser handoff
|
|
18
|
+
to a gateway-hosted setup page (the new `app.portage-ucp.payment_enrollment`
|
|
19
|
+
capability in `portage-ucp`) — no raw card number ever reaches this
|
|
20
|
+
process.
|
|
21
|
+
- Added `portage payment enroll --scope-merchant/--scope-max-amount/
|
|
22
|
+
--scope-currency` — binds a Phase 2 per-token policy scope at enrollment
|
|
23
|
+
time, written to `Portage::Ucp::Policy` keyed by the same `token_ref`
|
|
24
|
+
`PolicyGuard` derives from the token at charge time.
|
|
25
|
+
- Added `portage policy show/set` — manages the Phase 2 policy file's
|
|
26
|
+
top-level caps/velocity/allowlist (`Portage::Ucp::Policy`), checked by
|
|
27
|
+
`PolicyGuard` on every `complete_checkout`.
|
|
28
|
+
- Widened the `portage-ucp` dependency pin from `~> 0.4` to `~> 0.5` and the
|
|
29
|
+
`portage-ucp-client` pin from `~> 0.2` to `~> 0.3` — this release's
|
|
30
|
+
`Policy`/`PolicyGuard`/`TokenRef` and `Session#create_payment_enrollment`
|
|
31
|
+
calls only exist from those versions on.
|
|
32
|
+
|
|
33
|
+
## [0.3.0] - 2026-08-28
|
|
34
|
+
|
|
35
|
+
- Fixed: `find` and `buy` were treating `search_catalog`'s wire envelope
|
|
36
|
+
(`{"ucp" => ..., "products" => [...]}`) as the product list itself —
|
|
37
|
+
`Array(session.search_catalog(...))` wrapped the whole envelope Hash into a
|
|
38
|
+
single-element array instead of unwrapping `"products"`, so every offer
|
|
39
|
+
built from it was malformed against any real store. `Portage::Cli::CatalogProducts.from`
|
|
40
|
+
now unwraps the envelope (and the own-store adapter's raw
|
|
41
|
+
`CatalogSearchResult`) before either command touches the result.
|
|
42
|
+
- `portage compare <url> --product-id ID` (`Portage::Cli::Compare`, §22's
|
|
43
|
+
"find this same item elsewhere" mode) — resolves a named product, then runs
|
|
44
|
+
`find`'s own candidate-discovery/probe/rank pipeline against its title.
|
|
45
|
+
Every offer carries a `match:` tier (`confirmed`/`likely`/`unconfirmed`)
|
|
46
|
+
based on shared barcode/sku/`--id` identity, the origin store is excluded
|
|
47
|
+
by host, and `--results` truncates after ranking. Catalog-price only — no
|
|
48
|
+
`create_checkout` against candidate stores. Recorded to `portage history`
|
|
49
|
+
as a search.
|
|
50
|
+
- `portage history` — local purchase/search history (`Portage::Cli::History`),
|
|
51
|
+
logged automatically to `~/.portage/history.json` on every `find`/checkout-
|
|
52
|
+
reaching `buy`. `list` (`--purchases`/`--searches`, `--limit`, `--json`) and
|
|
53
|
+
`clear` (same scoping flags) subcommands. Separate from `ProbeCache`, which
|
|
54
|
+
remembers hosts, not actions.
|
|
55
|
+
|
|
7
56
|
## [0.2.0] - 2026-08-21
|
|
8
57
|
|
|
9
58
|
- `PORTAGE_SHIP_*` env vars (`Portage::Cli::ShippingProfile`) — configure a
|
data/README.md
CHANGED
|
@@ -29,6 +29,15 @@ portage find --query "burton snowboard" --max-price 400
|
|
|
29
29
|
|
|
30
30
|
`portage buy` with no URL runs that search and then buys the offer you pick.
|
|
31
31
|
|
|
32
|
+
Already have the item and want to know where else it's sold? `portage compare`
|
|
33
|
+
resolves a product you name by URL + product id, then runs the same
|
|
34
|
+
find pipeline against its title and ranks the results by how confident the
|
|
35
|
+
match is:
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
portage compare https://your-shop.example --product-id prod_123 --results 5
|
|
39
|
+
```
|
|
40
|
+
|
|
32
41
|
Depends on [`portage-ucp`](https://github.com/tomtom87/Portage/tree/main/portage-ucp)
|
|
33
42
|
(for platform detection via `Resolver`) and
|
|
34
43
|
[`portage-ucp-client`](https://github.com/tomtom87/Portage/tree/main/portage-ucp-client)
|
|
@@ -60,6 +69,22 @@ portage buy <url> --query "..." [--qty N] [--payment-token TOKEN] [--product-id
|
|
|
60
69
|
[--yes] [--dry-run] [--json]
|
|
61
70
|
portage buy --query "..." [--store URL] [--max-price N] [--limit N] ...
|
|
62
71
|
portage find --query "..." [--max-price N] [--limit N] [--json]
|
|
72
|
+
portage compare <url> --product-id ID [--id VALUE ...] [--results N]
|
|
73
|
+
[--max-price N] [--json]
|
|
74
|
+
portage history [list] [--purchases|--searches] [--limit N] [--json]
|
|
75
|
+
portage history clear [--purchases|--searches]
|
|
76
|
+
portage payment list [--json]
|
|
77
|
+
portage payment enroll <url> [--label NAME] [--json]
|
|
78
|
+
[--scope-merchant HOST ...] [--scope-max-amount N] [--scope-currency CUR]
|
|
79
|
+
portage payment set-default <id>
|
|
80
|
+
portage payment remove <id>
|
|
81
|
+
portage payment freeze <id>
|
|
82
|
+
portage payment revoke <id>
|
|
83
|
+
portage policy show [--json]
|
|
84
|
+
portage policy set [--per-transaction-cap N --currency CUR]
|
|
85
|
+
[--rolling-cap N --rolling-window-seconds N --currency CUR]
|
|
86
|
+
[--velocity-count N --velocity-window-seconds N]
|
|
87
|
+
[--allow HOST ...] [--clear-allowlist]
|
|
63
88
|
```
|
|
64
89
|
|
|
65
90
|
- `--query` — search term. Against the store's catalog when you name a store,
|
|
@@ -67,7 +92,9 @@ portage find --query "..." [--max-price N] [--limit N] [--json]
|
|
|
67
92
|
- `--qty` — quantity, default `1`.
|
|
68
93
|
- `--payment-token` — a tokenized payment credential (never a raw card number —
|
|
69
94
|
`PaymentTokenGuard` in the core gem rejects those before they reach the wire).
|
|
70
|
-
Omit for `--dry-run` or to just browse
|
|
95
|
+
Omit for `--dry-run` or to just browse, or to fall back to whatever
|
|
96
|
+
`portage payment` has on file as the default (see "Payment" below) — the
|
|
97
|
+
flag always wins when both are present.
|
|
71
98
|
- `--product-id` — buy exactly this product rather than whatever the catalog
|
|
72
99
|
search ranks first. If the id isn't in the results, nothing is bought.
|
|
73
100
|
- `--store` — name the merchant without giving a full URL; skips the search.
|
|
@@ -82,6 +109,150 @@ Exits `0` when a checkout completed (or a dry-run/browse/search resolved
|
|
|
82
109
|
successfully), `1` otherwise — including the "no native manifest, no adapter
|
|
83
110
|
credentials" dead-end case, so it's scriptable in CI.
|
|
84
111
|
|
|
112
|
+
### Compare
|
|
113
|
+
|
|
114
|
+
`portage compare <url> --product-id ID` finds other stores selling the same
|
|
115
|
+
item you already have. It resolves the named product, then runs `find`'s own
|
|
116
|
+
candidate-discovery/probe/rank pipeline against the product's title, scoring
|
|
117
|
+
each surviving offer instead of treating them all as equally confident hits:
|
|
118
|
+
|
|
119
|
+
- `--product-id` — required. The item to compare, at the store you name.
|
|
120
|
+
- `--id VALUE` — repeatable. A SKU, barcode (UPC/EAN/GTIN), or MPN you already
|
|
121
|
+
know, matched case-insensitively against every candidate's own identity
|
|
122
|
+
values. There's no way to tell the matcher which *kind* of identifier you
|
|
123
|
+
passed — the wire format doesn't distinguish them — so it doesn't pretend
|
|
124
|
+
to; passing one just adds it to the matching corpus. When omitted, the
|
|
125
|
+
origin product's own first variant sku/barcodes are used instead.
|
|
126
|
+
- `--results N` — how many ranked offers to return, default 5. Applies after
|
|
127
|
+
ranking, not before — a truncated result is always the *worst* N dropped,
|
|
128
|
+
never an arbitrary N. The underlying probe cap (candidate origins checked,
|
|
129
|
+
not results returned) stays `find`'s own limit and isn't exposed on this
|
|
130
|
+
subcommand.
|
|
131
|
+
- `--max-price` — same semantics as `find`'s.
|
|
132
|
+
|
|
133
|
+
Every offer carries a `match:` tier so a caller never mistakes a coincidence
|
|
134
|
+
for a confirmed match:
|
|
135
|
+
|
|
136
|
+
| Tier | Means |
|
|
137
|
+
| --- | --- |
|
|
138
|
+
| `confirmed` | Origin and candidate share a barcode value (UPC/EAN/GTIN) — the one identifier the spec treats as globally unique. |
|
|
139
|
+
| `likely` | Origin and candidate share a SKU, or an explicit `--id` hit landed on the candidate — both are "some string matched", not "a global identifier matched", so they share one tier rather than a false precision gradient. |
|
|
140
|
+
| `unconfirmed` | Same search query, nothing shared. Could be the same item; could just have a similar title. |
|
|
141
|
+
|
|
142
|
+
The origin store itself is excluded from results, matched by host (not by
|
|
143
|
+
raw origin string), so an `http://`/`https://`/trailing-slash variant of your
|
|
144
|
+
own store's URL doesn't show up as a "competitor." A `www.` variant is
|
|
145
|
+
treated as a different host, same as `find`'s own candidate dedupe — worth
|
|
146
|
+
knowing if your store answers on both.
|
|
147
|
+
|
|
148
|
+
**Known limitation: recall, not ranking, is the ceiling.** Compare searches
|
|
149
|
+
the backends using the origin product's own title — a store-specific
|
|
150
|
+
marketing string. If a backend never surfaces the competitor for that title,
|
|
151
|
+
no amount of tiering helps; every offer that *does* come back may be
|
|
152
|
+
`unconfirmed` because nothing more specific was searched. There's no
|
|
153
|
+
barcode/SKU-keyed second search pass yet.
|
|
154
|
+
|
|
155
|
+
**Catalog-price only.** No `create_checkout` step runs against any candidate
|
|
156
|
+
store — ranking uses each store's listed price, never a landed price
|
|
157
|
+
(shipping/tax included). Verifying the actual cheapest landed price would
|
|
158
|
+
mean starting a checkout on a store the shopper hasn't chosen, which risks
|
|
159
|
+
abandoned carts on someone else's site; left out of scope for now.
|
|
160
|
+
|
|
161
|
+
### History
|
|
162
|
+
|
|
163
|
+
Every `find` (and `buy`, once it reaches a search) and every `buy` that
|
|
164
|
+
reaches checkout is logged locally to `~/.portage/history.json` — most recent
|
|
165
|
+
200 entries each, purchases and searches kept separately. Browse-only `buy`
|
|
166
|
+
reports (no checkout reached) aren't logged as purchases.
|
|
167
|
+
|
|
168
|
+
```bash
|
|
169
|
+
portage history # both lists, most recent last
|
|
170
|
+
portage history list --purchases # just purchases
|
|
171
|
+
portage history list --searches --limit 20
|
|
172
|
+
portage history clear # wipe both
|
|
173
|
+
portage history clear --purchases # wipe just one
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
This is a local convenience cache, not an audit log — `portage history clear`
|
|
177
|
+
deletes it outright, and there's no server-side record.
|
|
178
|
+
|
|
179
|
+
### Payment
|
|
180
|
+
|
|
181
|
+
Card-on-file storage for `--payment-token`, so an autonomous agent can
|
|
182
|
+
complete a checkout without a human handing over a fresh token every time
|
|
183
|
+
(docs/plans/agentic-payments.md Phase 1). No raw card number ever touches
|
|
184
|
+
this process — enrollment is a browser handoff to the gateway's own hosted
|
|
185
|
+
setup page:
|
|
186
|
+
|
|
187
|
+
```bash
|
|
188
|
+
portage payment enroll https://your-shop.example --label "Ops card"
|
|
189
|
+
# → prints a setup_url; visit it, enter the card there, this process polls
|
|
190
|
+
# until the gateway hands back a token, then stores it.
|
|
191
|
+
|
|
192
|
+
portage payment list
|
|
193
|
+
portage payment set-default <id>
|
|
194
|
+
portage payment freeze <id> # blocks spend, keeps the enrollment
|
|
195
|
+
portage payment revoke <id> # deletes the token and the enrollment
|
|
196
|
+
portage payment remove <id> # same as revoke — no processor-side
|
|
197
|
+
# "invalidate this token" call to differ by
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
`--scope-merchant`/`--scope-max-amount`/`--scope-currency` bind a Phase 2
|
|
201
|
+
policy scope to the token at enrollment time, rather than after the fact:
|
|
202
|
+
|
|
203
|
+
```bash
|
|
204
|
+
portage payment enroll https://your-shop.example --label "Ops card" \
|
|
205
|
+
--scope-merchant your-shop.example --scope-max-amount 5000 --scope-currency USD
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
Written to `Policy` keyed by the same `token_ref` `PolicyGuard` derives from
|
|
209
|
+
the token at charge time — enrollment is the only place a scope gets
|
|
210
|
+
attached to a specific token; `portage policy set` below only touches the
|
|
211
|
+
global caps/velocity/allowlist, not per-token scopes.
|
|
212
|
+
|
|
213
|
+
Storage picks the strongest tier your platform actually has, in order, with
|
|
214
|
+
no homegrown fallback store of its own:
|
|
215
|
+
|
|
216
|
+
1. **macOS Keychain**, via the `security` CLI.
|
|
217
|
+
2. **Linux Secret Service** (GNOME Keyring/KWallet), via `secret-tool` — only
|
|
218
|
+
when a D-Bus session is actually live.
|
|
219
|
+
3. **Headless** — no local storage at all. The token *is*
|
|
220
|
+
`PORTAGE_PAYMENT_TOKEN`; `list`/`enroll`/`freeze`/etc. don't apply, since
|
|
221
|
+
there's nothing local to manage.
|
|
222
|
+
|
|
223
|
+
**Local policy guards agent mistakes, not a compromised agent.** Anyone
|
|
224
|
+
running as the local user can read/edit `~/.portage/payment_methods.json` or
|
|
225
|
+
the Keychain/Secret Service entry directly — this is a convenience store, not
|
|
226
|
+
a security boundary. The real backstop against a rogue or compromised agent
|
|
227
|
+
is an issuer-side limit (a virtual card via Stripe Issuing, Privacy.com,
|
|
228
|
+
etc.), not anything in this gem.
|
|
229
|
+
|
|
230
|
+
### Policy
|
|
231
|
+
|
|
232
|
+
`portage policy show`/`set` manage the Phase 2 policy file
|
|
233
|
+
(`Portage::Ucp::Policy`, checked by `PolicyGuard` on every `complete_checkout`)
|
|
234
|
+
— top-level caps, velocity, and a merchant allowlist that apply regardless of
|
|
235
|
+
which token is spending:
|
|
236
|
+
|
|
237
|
+
```bash
|
|
238
|
+
portage policy show
|
|
239
|
+
portage policy show --json
|
|
240
|
+
|
|
241
|
+
portage policy set --per-transaction-cap 10000 --currency USD
|
|
242
|
+
portage policy set --rolling-cap 50000 --rolling-window-seconds 86400 --currency USD
|
|
243
|
+
portage policy set --velocity-count 5 --velocity-window-seconds 3600
|
|
244
|
+
portage policy set --allow shop.example.com --allow other-shop.example.com
|
|
245
|
+
portage policy set --clear-allowlist
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
Each `--*` group is applied independently — `portage policy set --allow
|
|
249
|
+
shop.example.com` touches only the allowlist, leaving caps/velocity as they
|
|
250
|
+
were, so caps and the allowlist can be configured in separate invocations.
|
|
251
|
+
An empty policy (nothing ever set) means every check passes; this is an
|
|
252
|
+
opt-in guardrail, not a default-deny one. Per-token scopes (merchant/amount
|
|
253
|
+
limits bound to one enrolled card) are set via `portage payment enroll
|
|
254
|
+
--scope-*` above, not here.
|
|
255
|
+
|
|
85
256
|
### Shipping address (own-store checkouts only)
|
|
86
257
|
|
|
87
258
|
When buying against your own store (`portage buy`'s step 2 adapter-credentials
|
data/lib/portage/cli/buy.rb
CHANGED
|
@@ -3,6 +3,7 @@ require "uri"
|
|
|
3
3
|
require "json"
|
|
4
4
|
require "portage/ucp"
|
|
5
5
|
require "portage/ucp/client"
|
|
6
|
+
require_relative "payment_methods"
|
|
6
7
|
|
|
7
8
|
module Portage
|
|
8
9
|
module Cli
|
|
@@ -131,7 +132,7 @@ module Portage
|
|
|
131
132
|
end
|
|
132
133
|
|
|
133
134
|
def catalog_only_adapter(adapter, platform)
|
|
134
|
-
products =
|
|
135
|
+
products = CatalogProducts.from(adapter.search_catalog(query: @query, limit: 10))
|
|
135
136
|
checkout = redirect_checkout(adapter, products)
|
|
136
137
|
build_report(
|
|
137
138
|
source: "adapter:#{platform.name}", browse: true, checkout: !!checkout,
|
|
@@ -243,12 +244,13 @@ module Portage
|
|
|
243
244
|
products.find { |product| product_id_of(product) == @product_id }
|
|
244
245
|
end
|
|
245
246
|
|
|
246
|
-
#
|
|
247
|
-
#
|
|
248
|
-
#
|
|
249
|
-
#
|
|
247
|
+
# #select_product only ever sees products from #safe_search, which reads
|
|
248
|
+
# through a Session — native remote or the own-store loopback session
|
|
249
|
+
# built via Client.for_adapter alike — so Dispatcher#wrap has already
|
|
250
|
+
# called #to_wire_h on every result; a string-keyed wire hash either
|
|
251
|
+
# way, never a raw Portage::Ucp::Product struct.
|
|
250
252
|
def product_id_of(product)
|
|
251
|
-
product
|
|
253
|
+
product["id"]
|
|
252
254
|
end
|
|
253
255
|
|
|
254
256
|
def finish_checkout(session, source, products, checkout)
|
|
@@ -261,9 +263,11 @@ module Portage
|
|
|
261
263
|
end
|
|
262
264
|
|
|
263
265
|
def complete(session, source, products, checkout)
|
|
266
|
+
@payment_token ||= PaymentMethods.default
|
|
264
267
|
unless @payment_token
|
|
265
268
|
return checkout_report(source, products, checkout,
|
|
266
|
-
message: "No --payment-token given
|
|
269
|
+
message: "No --payment-token given, and no default payment method on file — " \
|
|
270
|
+
"run `portage payment enroll` or pass --payment-token.")
|
|
267
271
|
end
|
|
268
272
|
|
|
269
273
|
completed = session.complete_checkout(checkout_id: checkout["id"], payment_token: @payment_token)
|
|
@@ -299,7 +303,7 @@ module Portage
|
|
|
299
303
|
end
|
|
300
304
|
|
|
301
305
|
def safe_search(session)
|
|
302
|
-
|
|
306
|
+
CatalogProducts.from(session.search_catalog(query: @query, limit: 10))
|
|
303
307
|
end
|
|
304
308
|
|
|
305
309
|
# --- Homepage fetch (used by both the manifest-not-found path and the
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
module Portage
|
|
2
|
+
module Cli
|
|
3
|
+
# Unwraps whatever shape a catalog search call actually returns.
|
|
4
|
+
#
|
|
5
|
+
# Every call that goes through a Portage::Ucp::Client::Session — native
|
|
6
|
+
# remote stores and the own-store loopback session built via
|
|
7
|
+
# Client.for_adapter alike — passes through Dispatcher#wrap, which calls
|
|
8
|
+
# `to_wire_h` on the result before handing it back. So a Session's
|
|
9
|
+
# `search_catalog` always comes back as the wire-shaped envelope
|
|
10
|
+
# `{"ucp" => ..., "products" => [...]}`, never a bare array and never a
|
|
11
|
+
# raw struct.
|
|
12
|
+
#
|
|
13
|
+
# The one exception is Buy's own-store catalog-only path
|
|
14
|
+
# (`Buy#catalog_only_adapter`), which calls `Adapter#search_catalog`
|
|
15
|
+
# directly — no Session, no Dispatcher — and gets back the real
|
|
16
|
+
# `Portage::Ucp::CatalogSearchResult` the adapter built.
|
|
17
|
+
module CatalogProducts
|
|
18
|
+
module_function
|
|
19
|
+
|
|
20
|
+
def from(result)
|
|
21
|
+
case result
|
|
22
|
+
when Portage::Ucp::CatalogSearchResult then result.products
|
|
23
|
+
when Hash then Array(result["products"])
|
|
24
|
+
else Array(result)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
require_relative "find"
|
|
2
|
+
|
|
3
|
+
module Portage
|
|
4
|
+
module Cli
|
|
5
|
+
# `portage compare <url> --product-id ID` — §22's "find this same item
|
|
6
|
+
# elsewhere" mode. Resolves a product a shopper already has in hand, then
|
|
7
|
+
# runs Find's own candidate-discovery/probe/rank pipeline against the
|
|
8
|
+
# product's own title, scoring each surviving offer by how confident we
|
|
9
|
+
# actually are that it's the *same* item rather than just a similarly
|
|
10
|
+
# titled one.
|
|
11
|
+
#
|
|
12
|
+
# Catalog-price only: nothing here calls create_checkout against a
|
|
13
|
+
# candidate store, so a ranked offer's price is the listed price, never a
|
|
14
|
+
# landed price. See docs/plans/portage-compare.md for why that's a
|
|
15
|
+
# deliberate scope cut, not an oversight.
|
|
16
|
+
class Compare < Find
|
|
17
|
+
RESULT_LIMIT = 5
|
|
18
|
+
TIER_RANK = { confirmed: 0, likely: 1, unconfirmed: 2 }.freeze
|
|
19
|
+
|
|
20
|
+
# `identity:` is one repeatable value, not four aliases (--mpn/--sku/
|
|
21
|
+
# --upc/--gtin) for the same untyped string corpus — the wire format
|
|
22
|
+
# doesn't distinguish barcode types, so a flag name implying it could
|
|
23
|
+
# would be lying about the matcher's actual precision. `find_options:`
|
|
24
|
+
# bundles Find's own backends:/cache:/throttle: pass-through so this
|
|
25
|
+
# initializer stays under Metrics/ParameterLists without an exclusion.
|
|
26
|
+
def initialize(origin_url:, origin_product_id:, identity: [], results: RESULT_LIMIT, max_price: nil,
|
|
27
|
+
find_options: {})
|
|
28
|
+
@origin_url = origin_url
|
|
29
|
+
@origin_product_id = origin_product_id
|
|
30
|
+
@explicit_identity = identity
|
|
31
|
+
@result_limit = results
|
|
32
|
+
# `query: nil` — the real query isn't known until #call resolves the
|
|
33
|
+
# origin product; Find reads @query at #call time, not construction
|
|
34
|
+
# time, so #call can overwrite it before invoking Find#call via super.
|
|
35
|
+
super(query: nil, max_price: max_price, **find_options)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def call
|
|
39
|
+
origin = resolve_origin
|
|
40
|
+
return origin if origin[:message]
|
|
41
|
+
|
|
42
|
+
@origin_host = origin[:host]
|
|
43
|
+
@identity = build_identity(origin[:product])
|
|
44
|
+
@query = origin[:title]
|
|
45
|
+
result = super
|
|
46
|
+
finish(result)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
# --- Origin resolution ---
|
|
52
|
+
|
|
53
|
+
def resolve_origin
|
|
54
|
+
uri = parse_http(@origin_url)
|
|
55
|
+
return report(message: "Not a store URL: #{@origin_url.inspect}") unless uri
|
|
56
|
+
|
|
57
|
+
session = discover(origin_of(uri))
|
|
58
|
+
return report(message: "#{origin_of(uri)} doesn't speak UCP.") unless session
|
|
59
|
+
|
|
60
|
+
wrapped = session.get_product(product_id: @origin_product_id)
|
|
61
|
+
product = wrapped.is_a?(Hash) ? wrapped["product"] : nil
|
|
62
|
+
return report(message: "Product #{@origin_product_id.inspect} not found at #{origin_of(uri)}.") unless product
|
|
63
|
+
|
|
64
|
+
{ host: uri.host, title: field(product, "title"), product: product }
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# --- Identity ---
|
|
68
|
+
|
|
69
|
+
# Explicit `--id` values win when given; otherwise fall back to the
|
|
70
|
+
# origin product's own first variant sku/barcodes. First-variant-only,
|
|
71
|
+
# same scope Find's own single-product reads already assume.
|
|
72
|
+
def build_identity(product)
|
|
73
|
+
variant = first_variant(product)
|
|
74
|
+
{ barcodes: normalize_all(barcode_values(variant)), sku: normalize(variant["sku"]),
|
|
75
|
+
explicit: normalize_all(@explicit_identity) }
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def first_variant(product) = Array(field(product, "variants")).first || {}
|
|
79
|
+
|
|
80
|
+
def barcode_values(variant) = Array(variant["barcodes"]).filter_map { |b| b.is_a?(Hash) ? b["value"] : nil }
|
|
81
|
+
|
|
82
|
+
def normalize(value)
|
|
83
|
+
value = value.to_s.strip
|
|
84
|
+
value.empty? ? nil : value.downcase
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def normalize_all(values) = values.map { |v| normalize(v) }.compact.uniq
|
|
88
|
+
|
|
89
|
+
# --- Candidate offers: stash identity + host for scoring/exclusion ---
|
|
90
|
+
|
|
91
|
+
# Find#offer only flattens id/title/price/url off the top-level
|
|
92
|
+
# product. Compare additionally needs each candidate's own variant
|
|
93
|
+
# sku/barcodes (to score against) and a normalized store host (to
|
|
94
|
+
# exclude the origin store by host rather than by string equality on
|
|
95
|
+
# `store:`, which misses scheme/www differences) — kept scoped to
|
|
96
|
+
# Compare rather than pushed into Find, since plain `find` has no use
|
|
97
|
+
# for either.
|
|
98
|
+
def offer(store, product)
|
|
99
|
+
base = super
|
|
100
|
+
return nil unless base
|
|
101
|
+
|
|
102
|
+
variant = first_variant(product)
|
|
103
|
+
identity_values = { barcodes: normalize_all(barcode_values(variant)), sku: normalize(variant["sku"]) }
|
|
104
|
+
base.merge(identity_values: identity_values, store_host: parse_http(store[:origin])&.host)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# --- Finish: exclude origin, score, rank, truncate, rewrite message ---
|
|
108
|
+
|
|
109
|
+
def finish(result)
|
|
110
|
+
offers = result[:offers].reject { |o| same_host?(o[:store_host], @origin_host) }
|
|
111
|
+
excluded_origin = result[:offers].length - offers.length
|
|
112
|
+
scored = offers.map { |o| o.merge(match: match_tier(o)) }
|
|
113
|
+
ranked = scored.sort_by { |o| [TIER_RANK[o[:match]], o[:checkout] ? 0 : 1, o[:amount] || 0] }
|
|
114
|
+
kept = ranked.first(@result_limit)
|
|
115
|
+
result.merge(offers: kept, message: compare_summary(kept, ranked.length - kept.length, excluded_origin))
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def same_host?(candidate_host, origin_host)
|
|
119
|
+
candidate_host && origin_host && candidate_host.downcase == origin_host.downcase
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Three tiers, honest about what the wire format can actually prove:
|
|
123
|
+
# barcodes are the one value the spec treats as globally unique, so
|
|
124
|
+
# only a shared barcode earns :confirmed. A shared SKU is merchant-
|
|
125
|
+
# internal and collides across stores constantly, so it — and an
|
|
126
|
+
# explicit --id hit that isn't a barcode match — lands in :likely.
|
|
127
|
+
# Everything else rode in on the search query alone.
|
|
128
|
+
def match_tier(offer)
|
|
129
|
+
candidate = offer[:identity_values]
|
|
130
|
+
return :confirmed if shared_barcode?(candidate[:barcodes])
|
|
131
|
+
return :likely if shared_sku?(candidate[:sku]) || explicit_hit?(candidate)
|
|
132
|
+
|
|
133
|
+
:unconfirmed
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def shared_barcode?(candidate_barcodes) = @identity[:barcodes].intersect?(Array(candidate_barcodes))
|
|
137
|
+
|
|
138
|
+
def shared_sku?(candidate_sku) = @identity[:sku] && candidate_sku && @identity[:sku] == candidate_sku
|
|
139
|
+
|
|
140
|
+
def explicit_hit?(candidate)
|
|
141
|
+
@identity[:explicit].intersect?((Array(candidate[:barcodes]) + [candidate[:sku]]).compact)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def compare_summary(kept, truncated, excluded_origin)
|
|
145
|
+
return empty_compare_summary(excluded_origin) if kept.empty?
|
|
146
|
+
|
|
147
|
+
parts = ["Found #{kept.length} offer(s) for \"#{@query}\""]
|
|
148
|
+
parts << "#{truncated} more not shown (--results #{@result_limit})" if truncated.positive?
|
|
149
|
+
parts << "#{excluded_origin} excluded (origin store)" if excluded_origin.positive?
|
|
150
|
+
"#{parts.join(', ')}."
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def empty_compare_summary(excluded_origin)
|
|
154
|
+
message = "No comparable offers found for \"#{@query}\" outside #{@origin_host}"
|
|
155
|
+
message += ", #{excluded_origin} excluded (origin store)" if excluded_origin.positive?
|
|
156
|
+
"#{message}."
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
data/lib/portage/cli/find.rb
CHANGED
|
@@ -135,7 +135,7 @@ module Portage
|
|
|
135
135
|
# --- Step 3: ask the survivors what they stock ---
|
|
136
136
|
|
|
137
137
|
def offers_for(store)
|
|
138
|
-
products =
|
|
138
|
+
products = CatalogProducts.from(store[:session].search_catalog(query: @query, limit: PER_STORE_RESULTS))
|
|
139
139
|
products.filter_map { |product| offer(store, product) }
|
|
140
140
|
rescue StandardError
|
|
141
141
|
[]
|
|
@@ -161,10 +161,12 @@ module Portage
|
|
|
161
161
|
|
|
162
162
|
# --- Shapes ---
|
|
163
163
|
|
|
164
|
-
#
|
|
165
|
-
#
|
|
166
|
-
#
|
|
167
|
-
#
|
|
164
|
+
# Every product here comes from #offers_for, which reads through
|
|
165
|
+
# Session#search_catalog — Dispatcher#wrap has already called
|
|
166
|
+
# #to_wire_h on the result, so this is always a string-keyed wire hash,
|
|
167
|
+
# never a raw Portage::Ucp::Product struct (same posture as
|
|
168
|
+
# Buy#product_id_of), and it carries a `price_range` rather than a
|
|
169
|
+
# scalar price.
|
|
168
170
|
def price_of(product)
|
|
169
171
|
range = field(product, "price_range")
|
|
170
172
|
return [money_amount(range["min"]), range["min"]["currency"]] if range.is_a?(Hash) && range["min"].is_a?(Hash)
|
|
@@ -184,11 +186,7 @@ module Portage
|
|
|
184
186
|
|
|
185
187
|
def money_amount(price) = price["amount"]
|
|
186
188
|
|
|
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
|
|
189
|
+
def field(product, key) = product[key]
|
|
192
190
|
|
|
193
191
|
def report(**fields)
|
|
194
192
|
{ query: @query, candidates: [], stores: [], offers: [], message: nil }.merge(fields)
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "fileutils"
|
|
3
|
+
|
|
4
|
+
module Portage
|
|
5
|
+
module Cli
|
|
6
|
+
# Local record of what `portage` has searched for and bought, entirely
|
|
7
|
+
# separate from ProbeCache (which remembers *hosts*, not *actions*). Two
|
|
8
|
+
# append-only, size-capped lists — purchases and searches — so `portage
|
|
9
|
+
# history list` can answer "what did I already look for" and "what did I
|
|
10
|
+
# already buy" without re-running anything, and `portage history clear`
|
|
11
|
+
# can wipe either or both.
|
|
12
|
+
class History
|
|
13
|
+
PATH = File.join(Dir.home, ".portage", "history.json").freeze
|
|
14
|
+
MAX_ENTRIES = 200
|
|
15
|
+
|
|
16
|
+
def initialize(path: PATH, now: Time.now)
|
|
17
|
+
@path = path
|
|
18
|
+
@now = now.to_i
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def record_purchase(url:, query:, checkout:, message:, checkout_status: nil, products: [])
|
|
22
|
+
append("purchases", { "url" => url, "query" => query, "checkout" => checkout,
|
|
23
|
+
"checkout_status" => checkout_status, "message" => message,
|
|
24
|
+
"products" => products, "at" => @now })
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def record_search(query:, offer_count:, message:)
|
|
28
|
+
append("searches", { "query" => query, "offer_count" => offer_count, "message" => message, "at" => @now })
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def purchases(limit: MAX_ENTRIES) = store["purchases"].last(limit)
|
|
32
|
+
|
|
33
|
+
def searches(limit: MAX_ENTRIES) = store["searches"].last(limit)
|
|
34
|
+
|
|
35
|
+
# @param kind [String, nil] "purchases", "searches", or nil for both.
|
|
36
|
+
def clear(kind: nil)
|
|
37
|
+
kinds = kind ? [kind] : %w[purchases searches]
|
|
38
|
+
kinds.each { |k| store[k] = [] }
|
|
39
|
+
write
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def append(kind, entry)
|
|
45
|
+
store[kind] = (store[kind] + [entry]).last(MAX_ENTRIES)
|
|
46
|
+
write
|
|
47
|
+
entry
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def store
|
|
51
|
+
@store ||= read
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def read
|
|
55
|
+
parsed = File.readable?(@path) ? JSON.parse(File.read(@path)) : {}
|
|
56
|
+
parsed = {} unless parsed.is_a?(Hash)
|
|
57
|
+
{ "purchases" => Array(parsed["purchases"]), "searches" => Array(parsed["searches"]) }
|
|
58
|
+
rescue StandardError
|
|
59
|
+
{ "purchases" => [], "searches" => [] }
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# A history that can't be written just doesn't remember this run —
|
|
63
|
+
# never a failed buy or search.
|
|
64
|
+
def write
|
|
65
|
+
FileUtils.mkdir_p(File.dirname(@path))
|
|
66
|
+
File.write(@path, JSON.generate(@store))
|
|
67
|
+
rescue StandardError
|
|
68
|
+
nil
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
module Portage
|
|
2
|
+
module Cli
|
|
3
|
+
class PaymentMethods
|
|
4
|
+
# Headless tier (no D-Bus session — the common case for a server-
|
|
5
|
+
# deployed agent): no local storage at all, consistent with how
|
|
6
|
+
# Resolver.env_for already resolves platform credentials. The token
|
|
7
|
+
# *is* PORTAGE_PAYMENT_TOKEN — there's no id/enrollment/freeze/revoke
|
|
8
|
+
# bookkeeping to do, since there's nowhere local to keep it.
|
|
9
|
+
class EnvBackend
|
|
10
|
+
VAR = "PORTAGE_PAYMENT_TOKEN".freeze
|
|
11
|
+
|
|
12
|
+
def self.available? = true
|
|
13
|
+
|
|
14
|
+
def read(_id) = ENV.fetch(VAR, nil)
|
|
15
|
+
|
|
16
|
+
def write(_id, _token)
|
|
17
|
+
raise NotSupportedError, "headless mode has no local storage — set #{VAR} instead"
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def delete(_id)
|
|
21
|
+
raise NotSupportedError, "headless mode has no local storage — unset #{VAR} instead"
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
class NotSupportedError < StandardError; end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|