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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8a7b8a3138a4fdb27a32fdd32637d783ded5536e8933ad27a42ba688e550d51e
4
- data.tar.gz: 7e55c31d7d3ca91b7647152bff300c191b4593ca7e4956baf11488384ba7dd75
3
+ metadata.gz: 2dda5ce1d230eb5063b5a0a5a576acfce59be4a12abd415f85a0ba6d5d01d6d6
4
+ data.tar.gz: 4c4732f171e0da0dd8480cd260e1a273e3938ad7b500d3166e86445d2ae7beca
5
5
  SHA512:
6
- metadata.gz: 6f081ed17df4df877b15a25d3f0892c1f9fc891ce5fa82a5782acbe5a9deec9636b0b4a9b4c98f0ceea631e9711af9bb321ed1d2e65b34733f114cff35c3a5b1
7
- data.tar.gz: 120db3f4d0cbf97ce2a24e877ac3ce017ba9db39bb53bcf944249cf3e507c04a49193267253b41940d5688cd4aa55c3c420f2e1ccbc6e15541bde045585013ff
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
@@ -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 = Array(adapter.search_catalog(query: @query, limit: 10))
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
- # search_catalog's results are raw Portage::Ucp::Product structs over
247
- # the loopback transport (Product has no #to_wire_h, see
248
- # Dispatcher#wrap) but string-keyed wire hashes over stdio/HTTP (the
249
- # `mcp` gem's client parses real JSON) handle both.
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.respond_to?(:id) ? product.id : product["id"]
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 can't complete the purchase.")
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
- Array(session.search_catalog(query: @query, limit: 10))
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
@@ -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 = Array(store[:session].search_catalog(query: @query, limit: PER_STORE_RESULTS))
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
- # 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.
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