portage-cli 0.2.0 → 0.3.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: a4a3c3b0ce94430985a4660b316c7fafb711d8814bc9497bba70d866bf927dc1
4
+ data.tar.gz: ac8bbf8f8f08ca0eda4962adfa8d240dae05e54b571a40b8aed68a8b3dd3ea03
5
5
  SHA512:
6
- metadata.gz: 6f081ed17df4df877b15a25d3f0892c1f9fc891ce5fa82a5782acbe5a9deec9636b0b4a9b4c98f0ceea631e9711af9bb321ed1d2e65b34733f114cff35c3a5b1
7
- data.tar.gz: 120db3f4d0cbf97ce2a24e877ac3ce017ba9db39bb53bcf944249cf3e507c04a49193267253b41940d5688cd4aa55c3c420f2e1ccbc6e15541bde045585013ff
6
+ metadata.gz: 7484fb5d07dc6c4c9bcfbfb01f84909b6c0c551d3e117bb4854ca5e67049779657272ea3c5c904847d2381780b11ffd839efa0e689c9eb20d2db8bd903126737
7
+ data.tar.gz: 70e0a2624e6e20714c2898069e53440cb620044c513a2644f12576b3da96bd0b2be5cc6603a2774a4f229eb8e19d5724889b9353ef3fab628ec6c06587a03a00
data/CHANGELOG.md CHANGED
@@ -4,6 +4,31 @@ 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.3.0] - 2026-08-28
10
+
11
+ - Fixed: `find` and `buy` were treating `search_catalog`'s wire envelope
12
+ (`{"ucp" => ..., "products" => [...]}`) as the product list itself —
13
+ `Array(session.search_catalog(...))` wrapped the whole envelope Hash into a
14
+ single-element array instead of unwrapping `"products"`, so every offer
15
+ built from it was malformed against any real store. `Portage::Cli::CatalogProducts.from`
16
+ now unwraps the envelope (and the own-store adapter's raw
17
+ `CatalogSearchResult`) before either command touches the result.
18
+ - `portage compare <url> --product-id ID` (`Portage::Cli::Compare`, §22's
19
+ "find this same item elsewhere" mode) — resolves a named product, then runs
20
+ `find`'s own candidate-discovery/probe/rank pipeline against its title.
21
+ Every offer carries a `match:` tier (`confirmed`/`likely`/`unconfirmed`)
22
+ based on shared barcode/sku/`--id` identity, the origin store is excluded
23
+ by host, and `--results` truncates after ranking. Catalog-price only — no
24
+ `create_checkout` against candidate stores. Recorded to `portage history`
25
+ as a search.
26
+ - `portage history` — local purchase/search history (`Portage::Cli::History`),
27
+ logged automatically to `~/.portage/history.json` on every `find`/checkout-
28
+ reaching `buy`. `list` (`--purchases`/`--searches`, `--limit`, `--json`) and
29
+ `clear` (same scoping flags) subcommands. Separate from `ProbeCache`, which
30
+ remembers hosts, not actions.
31
+
7
32
  ## [0.2.0] - 2026-08-21
8
33
 
9
34
  - `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,10 @@ 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]
63
76
  ```
64
77
 
65
78
  - `--query` — search term. Against the store's catalog when you name a store,
@@ -82,6 +95,73 @@ Exits `0` when a checkout completed (or a dry-run/browse/search resolved
82
95
  successfully), `1` otherwise — including the "no native manifest, no adapter
83
96
  credentials" dead-end case, so it's scriptable in CI.
84
97
 
98
+ ### Compare
99
+
100
+ `portage compare <url> --product-id ID` finds other stores selling the same
101
+ item you already have. It resolves the named product, then runs `find`'s own
102
+ candidate-discovery/probe/rank pipeline against the product's title, scoring
103
+ each surviving offer instead of treating them all as equally confident hits:
104
+
105
+ - `--product-id` — required. The item to compare, at the store you name.
106
+ - `--id VALUE` — repeatable. A SKU, barcode (UPC/EAN/GTIN), or MPN you already
107
+ know, matched case-insensitively against every candidate's own identity
108
+ values. There's no way to tell the matcher which *kind* of identifier you
109
+ passed — the wire format doesn't distinguish them — so it doesn't pretend
110
+ to; passing one just adds it to the matching corpus. When omitted, the
111
+ origin product's own first variant sku/barcodes are used instead.
112
+ - `--results N` — how many ranked offers to return, default 5. Applies after
113
+ ranking, not before — a truncated result is always the *worst* N dropped,
114
+ never an arbitrary N. The underlying probe cap (candidate origins checked,
115
+ not results returned) stays `find`'s own limit and isn't exposed on this
116
+ subcommand.
117
+ - `--max-price` — same semantics as `find`'s.
118
+
119
+ Every offer carries a `match:` tier so a caller never mistakes a coincidence
120
+ for a confirmed match:
121
+
122
+ | Tier | Means |
123
+ | --- | --- |
124
+ | `confirmed` | Origin and candidate share a barcode value (UPC/EAN/GTIN) — the one identifier the spec treats as globally unique. |
125
+ | `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. |
126
+ | `unconfirmed` | Same search query, nothing shared. Could be the same item; could just have a similar title. |
127
+
128
+ The origin store itself is excluded from results, matched by host (not by
129
+ raw origin string), so an `http://`/`https://`/trailing-slash variant of your
130
+ own store's URL doesn't show up as a "competitor." A `www.` variant is
131
+ treated as a different host, same as `find`'s own candidate dedupe — worth
132
+ knowing if your store answers on both.
133
+
134
+ **Known limitation: recall, not ranking, is the ceiling.** Compare searches
135
+ the backends using the origin product's own title — a store-specific
136
+ marketing string. If a backend never surfaces the competitor for that title,
137
+ no amount of tiering helps; every offer that *does* come back may be
138
+ `unconfirmed` because nothing more specific was searched. There's no
139
+ barcode/SKU-keyed second search pass yet.
140
+
141
+ **Catalog-price only.** No `create_checkout` step runs against any candidate
142
+ store — ranking uses each store's listed price, never a landed price
143
+ (shipping/tax included). Verifying the actual cheapest landed price would
144
+ mean starting a checkout on a store the shopper hasn't chosen, which risks
145
+ abandoned carts on someone else's site; left out of scope for now.
146
+
147
+ ### History
148
+
149
+ Every `find` (and `buy`, once it reaches a search) and every `buy` that
150
+ reaches checkout is logged locally to `~/.portage/history.json` — most recent
151
+ 200 entries each, purchases and searches kept separately. Browse-only `buy`
152
+ reports (no checkout reached) aren't logged as purchases.
153
+
154
+ ```bash
155
+ portage history # both lists, most recent last
156
+ portage history list --purchases # just purchases
157
+ portage history list --searches --limit 20
158
+ portage history clear # wipe both
159
+ portage history clear --purchases # wipe just one
160
+ ```
161
+
162
+ This is a local convenience cache, not an audit log — `portage history clear`
163
+ deletes it outright, and there's no server-side record.
164
+
85
165
  ### Shipping address (own-store checkouts only)
86
166
 
87
167
  When buying against your own store (`portage buy`'s step 2 adapter-credentials
@@ -131,7 +131,7 @@ module Portage
131
131
  end
132
132
 
133
133
  def catalog_only_adapter(adapter, platform)
134
- products = Array(adapter.search_catalog(query: @query, limit: 10))
134
+ products = CatalogProducts.from(adapter.search_catalog(query: @query, limit: 10))
135
135
  checkout = redirect_checkout(adapter, products)
136
136
  build_report(
137
137
  source: "adapter:#{platform.name}", browse: true, checkout: !!checkout,
@@ -243,12 +243,13 @@ module Portage
243
243
  products.find { |product| product_id_of(product) == @product_id }
244
244
  end
245
245
 
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.
246
+ # #select_product only ever sees products from #safe_search, which reads
247
+ # through a Session native remote or the own-store loopback session
248
+ # built via Client.for_adapter alike so Dispatcher#wrap has already
249
+ # called #to_wire_h on every result; a string-keyed wire hash either
250
+ # way, never a raw Portage::Ucp::Product struct.
250
251
  def product_id_of(product)
251
- product.respond_to?(:id) ? product.id : product["id"]
252
+ product["id"]
252
253
  end
253
254
 
254
255
  def finish_checkout(session, source, products, checkout)
@@ -299,7 +300,7 @@ module Portage
299
300
  end
300
301
 
301
302
  def safe_search(session)
302
- Array(session.search_catalog(query: @query, limit: 10))
303
+ CatalogProducts.from(session.search_catalog(query: @query, limit: 10))
303
304
  end
304
305
 
305
306
  # --- 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
@@ -1,5 +1,5 @@
1
1
  module Portage
2
2
  module Cli
3
- VERSION = "0.2.0".freeze
3
+ VERSION = "0.3.0".freeze
4
4
  end
5
5
  end
data/lib/portage/cli.rb CHANGED
@@ -3,8 +3,11 @@ require "json"
3
3
 
4
4
  require_relative "cli/version"
5
5
  require_relative "cli/shipping_profile"
6
+ require_relative "cli/catalog_products"
6
7
  require_relative "cli/buy"
7
8
  require_relative "cli/find"
9
+ require_relative "cli/compare"
10
+ require_relative "cli/history"
8
11
 
9
12
  module Portage
10
13
  # `portage` — the single command-line entrypoint for acting as a shopper's
@@ -17,6 +20,10 @@ module Portage
17
20
  [--product-id ID] [--yes] [--dry-run] [--json]
18
21
  portage buy --query "..." [--store URL] [--max-price N] [--limit N] ...
19
22
  portage find --query "..." [--max-price N] [--limit N] [--json]
23
+ portage compare <url> --product-id ID [--id VALUE ...] [--results N]
24
+ [--max-price N] [--json]
25
+ portage history [list] [--purchases|--searches] [--limit N] [--json]
26
+ portage history clear [--purchases|--searches]
20
27
  USAGE
21
28
 
22
29
  # @param argv [Array<String>]
@@ -26,6 +33,8 @@ module Portage
26
33
  case command
27
34
  when "buy" then run_buy(rest)
28
35
  when "find" then run_find(rest)
36
+ when "compare" then run_compare(rest)
37
+ when "history" then run_history(rest)
29
38
  else
30
39
  warn USAGE
31
40
  1
@@ -40,6 +49,8 @@ module Portage
40
49
 
41
50
  json = options.delete(:json)
42
51
  report = Find.new(**options).call
52
+ History.new.record_search(query: report[:query], offer_count: report[:offers].length,
53
+ message: report[:message])
43
54
  puts json ? JSON.pretty_generate(report) : format_find(report)
44
55
  report[:offers].any? ? 0 : 1
45
56
  end
@@ -73,6 +84,50 @@ module Portage
73
84
  def self.to_minor_units(major) = (major * 100).round
74
85
  private_class_method :to_minor_units
75
86
 
87
+ # --- compare ---
88
+
89
+ def self.run_compare(argv)
90
+ options = parse_compare_options(argv)
91
+ return 1 unless options
92
+
93
+ json = options.delete(:json)
94
+ url = options.delete(:url)
95
+ report = Compare.new(origin_url: url, **options).call
96
+ # Recorded as a search, not a purchase — compare never checks out. The
97
+ # query string names the compare so `portage history list` doesn't
98
+ # read it as a plain text search for the origin product's own title.
99
+ History.new.record_search(query: "compare: #{url} (product #{options[:origin_product_id]})",
100
+ offer_count: report[:offers].length, message: report[:message])
101
+ puts json ? JSON.pretty_generate(report) : format_compare(report)
102
+ report[:offers].any? ? 0 : 1
103
+ end
104
+ private_class_method :run_compare
105
+
106
+ def self.parse_compare_options(argv)
107
+ url = argv.first && !argv.first.start_with?("-") ? argv.shift : nil
108
+ opts = { identity: [] }
109
+ compare_option_parser(opts).parse!(argv)
110
+ if !url || opts[:origin_product_id].to_s.strip.empty?
111
+ warn USAGE
112
+ return nil
113
+ end
114
+
115
+ opts[:url] = url
116
+ opts
117
+ end
118
+ private_class_method :parse_compare_options
119
+
120
+ def self.compare_option_parser(opts)
121
+ OptionParser.new do |parser|
122
+ parser.on("--product-id ID") { |v| opts[:origin_product_id] = v }
123
+ parser.on("--id VALUE") { |v| opts[:identity] << v }
124
+ parser.on("--results N", Integer) { |v| opts[:results] = v }
125
+ parser.on("--max-price N", Float) { |v| opts[:max_price] = to_minor_units(v) }
126
+ parser.on("--json") { opts[:json] = true }
127
+ end
128
+ end
129
+ private_class_method :compare_option_parser
130
+
76
131
  # --- buy ---
77
132
 
78
133
  def self.run_buy(argv)
@@ -122,11 +177,23 @@ module Portage
122
177
  options = parsed[:buy].merge(url: url)
123
178
  options[:product_id] ||= product_id
124
179
  report = Buy.new(**options).call
180
+ record_purchase(report, options[:query]) if report[:checkout]
125
181
  puts parsed[:json] ? JSON.pretty_generate(report) : format_report(report)
126
182
  report[:checkout] || report[:browse] ? 0 : 1
127
183
  end
128
184
  private_class_method :execute_buy
129
185
 
186
+ # Only checkout attempts land here — a browse-only report never reached a
187
+ # checkout, so it belongs to search history, not purchase history.
188
+ def self.record_purchase(report, query)
189
+ History.new.record_purchase(
190
+ url: report[:url], query: query, checkout: report[:checkout],
191
+ checkout_status: report[:checkout_status], message: report[:message],
192
+ products: report[:products].map { |p| product_line(p) }
193
+ )
194
+ end
195
+ private_class_method :record_purchase
196
+
130
197
  def self.parse_buy_options(argv)
131
198
  url = argv.first && !argv.first.start_with?("-") ? argv.shift : nil
132
199
  buy = { url: url, qty: 1, yes: false, dry_run: false }
@@ -164,6 +231,73 @@ module Portage
164
231
  end
165
232
  private_class_method :add_search_options
166
233
 
234
+ # --- history ---
235
+
236
+ def self.run_history(argv)
237
+ sub = argv.first && !argv.first.start_with?("-") ? argv.shift : "list"
238
+ case sub
239
+ when "list" then run_history_list(argv)
240
+ when "clear" then run_history_clear(argv)
241
+ else
242
+ warn USAGE
243
+ 1
244
+ end
245
+ end
246
+ private_class_method :run_history
247
+
248
+ def self.run_history_list(argv)
249
+ opts = { limit: History::MAX_ENTRIES }
250
+ history_option_parser(opts).parse!(argv)
251
+ json = opts.delete(:json)
252
+ kind = opts.delete(:kind)
253
+ history = History.new
254
+ result = { purchases: kind == "searches" ? [] : history.purchases(limit: opts[:limit]),
255
+ searches: kind == "purchases" ? [] : history.searches(limit: opts[:limit]) }
256
+ puts json ? JSON.pretty_generate(result) : format_history(result)
257
+ 0
258
+ end
259
+ private_class_method :run_history_list
260
+
261
+ def self.run_history_clear(argv)
262
+ opts = {}
263
+ history_option_parser(opts).parse!(argv)
264
+ History.new.clear(kind: opts[:kind])
265
+ puts "Cleared #{opts[:kind] || 'purchase and search'} history."
266
+ 0
267
+ end
268
+ private_class_method :run_history_clear
269
+
270
+ def self.history_option_parser(opts)
271
+ OptionParser.new do |parser|
272
+ parser.on("--purchases") { opts[:kind] = "purchases" }
273
+ parser.on("--searches") { opts[:kind] = "searches" }
274
+ parser.on("--limit N", Integer) { |v| opts[:limit] = v }
275
+ parser.on("--json") { opts[:json] = true }
276
+ end
277
+ end
278
+ private_class_method :history_option_parser
279
+
280
+ def self.format_history(result)
281
+ lines = ["Purchases:"]
282
+ result[:purchases].each { |p| lines << " #{history_purchase_line(p)}" }
283
+ lines << "(none)" if result[:purchases].empty?
284
+ lines << "Searches:"
285
+ result[:searches].each { |s| lines << " #{history_search_line(s)}" }
286
+ lines << "(none)" if result[:searches].empty?
287
+ lines.join("\n")
288
+ end
289
+ private_class_method :format_history
290
+
291
+ def self.history_purchase_line(entry)
292
+ "#{Time.at(entry['at'])} — #{entry['url']} (#{entry['query']}) — #{entry['checkout_status'] || entry['message']}"
293
+ end
294
+ private_class_method :history_purchase_line
295
+
296
+ def self.history_search_line(entry)
297
+ "#{Time.at(entry['at'])} — \"#{entry['query']}\" — #{entry['offer_count']} offer(s)"
298
+ end
299
+ private_class_method :history_search_line
300
+
167
301
  # --- output ---
168
302
 
169
303
  def self.format_report(report)
@@ -193,6 +327,20 @@ module Portage
193
327
  end
194
328
  private_class_method :offer_line
195
329
 
330
+ def self.format_compare(report)
331
+ lines = [report[:message].to_s]
332
+ report[:offers].each_with_index { |offer, index| lines << " #{index + 1}. #{compare_offer_line(offer)}" }
333
+ lines.join("\n")
334
+ end
335
+ private_class_method :format_compare
336
+
337
+ def self.compare_offer_line(offer)
338
+ parts = ["[#{offer[:match]}] #{offer[:store]} — #{offer[:title]} (#{offer[:product_id]})", format_price(offer)]
339
+ parts << "browse only" unless offer[:checkout]
340
+ parts.join(" — ")
341
+ end
342
+ private_class_method :compare_offer_line
343
+
196
344
  def self.format_price(offer)
197
345
  return "price n/a" unless offer[:amount]
198
346
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: portage-cli
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Tom Whitbread
@@ -15,14 +15,14 @@ dependencies:
15
15
  requirements:
16
16
  - - "~>"
17
17
  - !ruby/object:Gem::Version
18
- version: '0.2'
18
+ version: '0.4'
19
19
  type: :runtime
20
20
  prerelease: false
21
21
  version_requirements: !ruby/object:Gem::Requirement
22
22
  requirements:
23
23
  - - "~>"
24
24
  - !ruby/object:Gem::Version
25
- version: '0.2'
25
+ version: '0.4'
26
26
  - !ruby/object:Gem::Dependency
27
27
  name: portage-ucp-client
28
28
  requirement: !ruby/object:Gem::Requirement
@@ -114,7 +114,10 @@ files:
114
114
  - exe/portage
115
115
  - lib/portage/cli.rb
116
116
  - lib/portage/cli/buy.rb
117
+ - lib/portage/cli/catalog_products.rb
118
+ - lib/portage/cli/compare.rb
117
119
  - lib/portage/cli/find.rb
120
+ - lib/portage/cli/history.rb
118
121
  - lib/portage/cli/probe_cache.rb
119
122
  - lib/portage/cli/search_backends.rb
120
123
  - lib/portage/cli/shipping_profile.rb
@@ -140,7 +143,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
140
143
  - !ruby/object:Gem::Version
141
144
  version: '0'
142
145
  requirements: []
143
- rubygems_version: 4.0.18
146
+ rubygems_version: 3.6.9
144
147
  specification_version: 4
145
148
  summary: portage — one CLI command to buy from any store, native UCP or not
146
149
  test_files: []