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.
data/lib/portage/cli.rb CHANGED
@@ -3,8 +3,12 @@ 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"
11
+ require_relative "cli/payment_methods"
8
12
 
9
13
  module Portage
10
14
  # `portage` — the single command-line entrypoint for acting as a shopper's
@@ -17,6 +21,22 @@ module Portage
17
21
  [--product-id ID] [--yes] [--dry-run] [--json]
18
22
  portage buy --query "..." [--store URL] [--max-price N] [--limit N] ...
19
23
  portage find --query "..." [--max-price N] [--limit N] [--json]
24
+ portage compare <url> --product-id ID [--id VALUE ...] [--results N]
25
+ [--max-price N] [--json]
26
+ portage history [list] [--purchases|--searches] [--limit N] [--json]
27
+ portage history clear [--purchases|--searches]
28
+ portage payment list [--json]
29
+ portage payment enroll <url> [--label NAME] [--json]
30
+ [--scope-merchant HOST ...] [--scope-max-amount N] [--scope-currency CUR]
31
+ portage payment set-default <id>
32
+ portage payment remove <id>
33
+ portage payment freeze <id>
34
+ portage payment revoke <id>
35
+ portage policy show [--json]
36
+ portage policy set [--per-transaction-cap N --currency CUR]
37
+ [--rolling-cap N --rolling-window-seconds N --currency CUR]
38
+ [--velocity-count N --velocity-window-seconds N]
39
+ [--allow HOST ...] [--clear-allowlist]
20
40
  USAGE
21
41
 
22
42
  # @param argv [Array<String>]
@@ -26,6 +46,10 @@ module Portage
26
46
  case command
27
47
  when "buy" then run_buy(rest)
28
48
  when "find" then run_find(rest)
49
+ when "compare" then run_compare(rest)
50
+ when "history" then run_history(rest)
51
+ when "payment" then run_payment(rest)
52
+ when "policy" then run_policy(rest)
29
53
  else
30
54
  warn USAGE
31
55
  1
@@ -40,6 +64,8 @@ module Portage
40
64
 
41
65
  json = options.delete(:json)
42
66
  report = Find.new(**options).call
67
+ History.new.record_search(query: report[:query], offer_count: report[:offers].length,
68
+ message: report[:message])
43
69
  puts json ? JSON.pretty_generate(report) : format_find(report)
44
70
  report[:offers].any? ? 0 : 1
45
71
  end
@@ -73,6 +99,50 @@ module Portage
73
99
  def self.to_minor_units(major) = (major * 100).round
74
100
  private_class_method :to_minor_units
75
101
 
102
+ # --- compare ---
103
+
104
+ def self.run_compare(argv)
105
+ options = parse_compare_options(argv)
106
+ return 1 unless options
107
+
108
+ json = options.delete(:json)
109
+ url = options.delete(:url)
110
+ report = Compare.new(origin_url: url, **options).call
111
+ # Recorded as a search, not a purchase — compare never checks out. The
112
+ # query string names the compare so `portage history list` doesn't
113
+ # read it as a plain text search for the origin product's own title.
114
+ History.new.record_search(query: "compare: #{url} (product #{options[:origin_product_id]})",
115
+ offer_count: report[:offers].length, message: report[:message])
116
+ puts json ? JSON.pretty_generate(report) : format_compare(report)
117
+ report[:offers].any? ? 0 : 1
118
+ end
119
+ private_class_method :run_compare
120
+
121
+ def self.parse_compare_options(argv)
122
+ url = argv.first && !argv.first.start_with?("-") ? argv.shift : nil
123
+ opts = { identity: [] }
124
+ compare_option_parser(opts).parse!(argv)
125
+ if !url || opts[:origin_product_id].to_s.strip.empty?
126
+ warn USAGE
127
+ return nil
128
+ end
129
+
130
+ opts[:url] = url
131
+ opts
132
+ end
133
+ private_class_method :parse_compare_options
134
+
135
+ def self.compare_option_parser(opts)
136
+ OptionParser.new do |parser|
137
+ parser.on("--product-id ID") { |v| opts[:origin_product_id] = v }
138
+ parser.on("--id VALUE") { |v| opts[:identity] << v }
139
+ parser.on("--results N", Integer) { |v| opts[:results] = v }
140
+ parser.on("--max-price N", Float) { |v| opts[:max_price] = to_minor_units(v) }
141
+ parser.on("--json") { opts[:json] = true }
142
+ end
143
+ end
144
+ private_class_method :compare_option_parser
145
+
76
146
  # --- buy ---
77
147
 
78
148
  def self.run_buy(argv)
@@ -122,11 +192,23 @@ module Portage
122
192
  options = parsed[:buy].merge(url: url)
123
193
  options[:product_id] ||= product_id
124
194
  report = Buy.new(**options).call
195
+ record_purchase(report, options[:query]) if report[:checkout]
125
196
  puts parsed[:json] ? JSON.pretty_generate(report) : format_report(report)
126
197
  report[:checkout] || report[:browse] ? 0 : 1
127
198
  end
128
199
  private_class_method :execute_buy
129
200
 
201
+ # Only checkout attempts land here — a browse-only report never reached a
202
+ # checkout, so it belongs to search history, not purchase history.
203
+ def self.record_purchase(report, query)
204
+ History.new.record_purchase(
205
+ url: report[:url], query: query, checkout: report[:checkout],
206
+ checkout_status: report[:checkout_status], message: report[:message],
207
+ products: report[:products].map { |p| product_line(p) }
208
+ )
209
+ end
210
+ private_class_method :record_purchase
211
+
130
212
  def self.parse_buy_options(argv)
131
213
  url = argv.first && !argv.first.start_with?("-") ? argv.shift : nil
132
214
  buy = { url: url, qty: 1, yes: false, dry_run: false }
@@ -164,6 +246,277 @@ module Portage
164
246
  end
165
247
  private_class_method :add_search_options
166
248
 
249
+ # --- history ---
250
+
251
+ def self.run_history(argv)
252
+ sub = argv.first && !argv.first.start_with?("-") ? argv.shift : "list"
253
+ case sub
254
+ when "list" then run_history_list(argv)
255
+ when "clear" then run_history_clear(argv)
256
+ else
257
+ warn USAGE
258
+ 1
259
+ end
260
+ end
261
+ private_class_method :run_history
262
+
263
+ def self.run_history_list(argv)
264
+ opts = { limit: History::MAX_ENTRIES }
265
+ history_option_parser(opts).parse!(argv)
266
+ json = opts.delete(:json)
267
+ kind = opts.delete(:kind)
268
+ history = History.new
269
+ result = { purchases: kind == "searches" ? [] : history.purchases(limit: opts[:limit]),
270
+ searches: kind == "purchases" ? [] : history.searches(limit: opts[:limit]) }
271
+ puts json ? JSON.pretty_generate(result) : format_history(result)
272
+ 0
273
+ end
274
+ private_class_method :run_history_list
275
+
276
+ def self.run_history_clear(argv)
277
+ opts = {}
278
+ history_option_parser(opts).parse!(argv)
279
+ History.new.clear(kind: opts[:kind])
280
+ puts "Cleared #{opts[:kind] || 'purchase and search'} history."
281
+ 0
282
+ end
283
+ private_class_method :run_history_clear
284
+
285
+ def self.history_option_parser(opts)
286
+ OptionParser.new do |parser|
287
+ parser.on("--purchases") { opts[:kind] = "purchases" }
288
+ parser.on("--searches") { opts[:kind] = "searches" }
289
+ parser.on("--limit N", Integer) { |v| opts[:limit] = v }
290
+ parser.on("--json") { opts[:json] = true }
291
+ end
292
+ end
293
+ private_class_method :history_option_parser
294
+
295
+ def self.format_history(result)
296
+ lines = ["Purchases:"]
297
+ result[:purchases].each { |p| lines << " #{history_purchase_line(p)}" }
298
+ lines << "(none)" if result[:purchases].empty?
299
+ lines << "Searches:"
300
+ result[:searches].each { |s| lines << " #{history_search_line(s)}" }
301
+ lines << "(none)" if result[:searches].empty?
302
+ lines.join("\n")
303
+ end
304
+ private_class_method :format_history
305
+
306
+ def self.history_purchase_line(entry)
307
+ "#{Time.at(entry['at'])} — #{entry['url']} (#{entry['query']}) — #{entry['checkout_status'] || entry['message']}"
308
+ end
309
+ private_class_method :history_purchase_line
310
+
311
+ def self.history_search_line(entry)
312
+ "#{Time.at(entry['at'])} — \"#{entry['query']}\" — #{entry['offer_count']} offer(s)"
313
+ end
314
+ private_class_method :history_search_line
315
+
316
+ # --- payment ---
317
+
318
+ # Maps each single-id subcommand to the PaymentMethods method it calls —
319
+ # collapsing what would otherwise be four near-identical `when` branches
320
+ # (each just yielding a different method to #run_payment_mutate) into one
321
+ # table lookup.
322
+ PAYMENT_MUTATIONS = { "set-default" => :make_default, "remove" => :remove,
323
+ "freeze" => :freeze_method, "revoke" => :revoke }.freeze
324
+
325
+ def self.run_payment(argv)
326
+ sub = argv.first && !argv.first.start_with?("-") ? argv.shift : nil
327
+ return run_payment_list(argv) if sub == "list"
328
+ return run_payment_enroll(argv) if sub == "enroll"
329
+ return run_payment_mutate(argv, PAYMENT_MUTATIONS[sub]) if PAYMENT_MUTATIONS.key?(sub)
330
+
331
+ warn USAGE
332
+ 1
333
+ end
334
+ private_class_method :run_payment
335
+
336
+ def self.run_payment_list(argv)
337
+ json = false
338
+ OptionParser.new { |parser| parser.on("--json") { json = true } }.parse!(argv)
339
+ methods = PaymentMethods.new.list
340
+ puts json ? JSON.pretty_generate(methods) : format_payment_list(methods)
341
+ 0
342
+ end
343
+ private_class_method :run_payment_list
344
+
345
+ # Shared by set-default/remove/freeze/revoke — each takes exactly one
346
+ # `<id>` positional arg and reports the (now-updated) entry, or fails
347
+ # cleanly for an id that isn't enrolled.
348
+ def self.run_payment_mutate(argv, method_name)
349
+ id = argv.first && !argv.first.start_with?("-") ? argv.shift : nil
350
+ unless id
351
+ warn USAGE
352
+ return 1
353
+ end
354
+
355
+ entry = PaymentMethods.new.public_send(method_name, id)
356
+ puts "#{entry['label']} (#{entry['id']}) — default: #{entry['default']}, frozen: #{entry['frozen']}"
357
+ 0
358
+ rescue PaymentMethods::UnknownMethodError
359
+ warn "No payment method enrolled with id #{id}."
360
+ 1
361
+ end
362
+ private_class_method :run_payment_mutate
363
+
364
+ def self.parse_payment_enroll_options(argv)
365
+ opts = { scope_merchants: [] }
366
+ OptionParser.new do |parser|
367
+ parser.on("--label NAME") { |v| opts[:label] = v }
368
+ parser.on("--json") { opts[:json] = true }
369
+ parser.on("--scope-merchant HOST") { |v| opts[:scope_merchants] << v }
370
+ parser.on("--scope-max-amount N", Integer) { |v| opts[:scope_max_amount] = v }
371
+ parser.on("--scope-currency CUR") { |v| opts[:scope_currency] = v }
372
+ end.parse!(argv)
373
+ opts[:url] = argv.first && !argv.first.start_with?("-") ? argv.shift : nil
374
+ opts
375
+ end
376
+ private_class_method :parse_payment_enroll_options
377
+
378
+ # nil (not `{}`) when no --scope-* flag was given at all, so
379
+ # PaymentMethods#enroll's `scope:` default of no-scope-written stays the
380
+ # behavior for a plain `portage payment enroll` — Phase 2's per-token
381
+ # scope is opt-in.
382
+ def self.payment_enroll_scope(opts)
383
+ return nil if opts[:scope_merchants].empty? && !opts[:scope_max_amount]
384
+
385
+ { merchants: opts[:scope_merchants], max_amount: opts[:scope_max_amount], currency: opts[:scope_currency] }
386
+ .compact
387
+ end
388
+ private_class_method :payment_enroll_scope
389
+
390
+ def self.run_payment_enroll(argv)
391
+ opts = parse_payment_enroll_options(argv)
392
+ unless opts[:url]
393
+ warn USAGE
394
+ return 1
395
+ end
396
+
397
+ result = PaymentMethods.new.enroll(opts[:url], label: opts[:label],
398
+ scope: payment_enroll_scope(opts)) do |setup_url|
399
+ puts "Visit this link to add a card, then wait — polling for completion:\n #{setup_url}"
400
+ end
401
+ puts opts[:json] ? JSON.pretty_generate(result) : format_payment_enroll(result)
402
+ result[:status] == "complete" ? 0 : 1
403
+ rescue PaymentMethods::NotSupportedError => e
404
+ warn e.message
405
+ 1
406
+ end
407
+ private_class_method :run_payment_enroll
408
+
409
+ # --- policy ---
410
+
411
+ def self.run_policy(argv)
412
+ sub = argv.first && !argv.first.start_with?("-") ? argv.shift : nil
413
+ case sub
414
+ when "show" then run_policy_show(argv)
415
+ when "set" then run_policy_set(argv)
416
+ else
417
+ warn USAGE
418
+ 1
419
+ end
420
+ end
421
+ private_class_method :run_policy
422
+
423
+ def self.run_policy_show(argv)
424
+ json = false
425
+ OptionParser.new { |parser| parser.on("--json") { json = true } }.parse!(argv)
426
+ policy = Portage::Ucp::Policy.load.to_h
427
+ puts json ? JSON.pretty_generate(policy) : format_policy(policy)
428
+ 0
429
+ end
430
+ private_class_method :run_policy_show
431
+
432
+ def self.format_policy(policy)
433
+ return "(no policy configured — every check passes)" if policy.empty?
434
+
435
+ JSON.pretty_generate(policy)
436
+ end
437
+ private_class_method :format_policy
438
+
439
+ def self.parse_policy_set_options(argv)
440
+ opts = { allow: [] }
441
+ OptionParser.new do |parser|
442
+ parser.on("--per-transaction-cap N", Integer) { |v| opts[:per_transaction_cap] = v }
443
+ parser.on("--rolling-cap N", Integer) { |v| opts[:rolling_cap] = v }
444
+ parser.on("--rolling-window-seconds N", Integer) { |v| opts[:rolling_window_seconds] = v }
445
+ parser.on("--currency CUR") { |v| opts[:currency] = v }
446
+ parser.on("--velocity-count N", Integer) { |v| opts[:velocity_count] = v }
447
+ parser.on("--velocity-window-seconds N", Integer) { |v| opts[:velocity_window_seconds] = v }
448
+ parser.on("--allow HOST") { |v| opts[:allow] << v }
449
+ parser.on("--clear-allowlist") { opts[:clear_allowlist] = true }
450
+ end.parse!(argv)
451
+ opts
452
+ end
453
+ private_class_method :parse_policy_set_options
454
+
455
+ # Each `--*` group is applied independently and only when its required
456
+ # fields are present — `portage policy set --allow shop.example.com`
457
+ # touches only the allowlist, leaving caps/velocity untouched, so caps
458
+ # and the allowlist can be configured in separate invocations.
459
+ def self.run_policy_set(argv)
460
+ opts = parse_policy_set_options(argv)
461
+ policy = Portage::Ucp::Policy.load
462
+ set_policy_cap(policy, opts)
463
+ set_policy_velocity(policy, opts)
464
+ set_policy_allowlist(policy, opts)
465
+ puts format_policy(policy.to_h)
466
+ 0
467
+ end
468
+ private_class_method :run_policy_set
469
+
470
+ def self.set_policy_cap(policy, opts)
471
+ if opts[:per_transaction_cap]
472
+ policy.set("per_transaction_cap",
473
+ { "amount" => opts[:per_transaction_cap], "currency" => require_currency!(opts) })
474
+ end
475
+ return unless opts[:rolling_cap] && opts[:rolling_window_seconds]
476
+
477
+ policy.set("rolling_cap", { "amount" => opts[:rolling_cap], "currency" => require_currency!(opts),
478
+ "window_seconds" => opts[:rolling_window_seconds] })
479
+ end
480
+ private_class_method :set_policy_cap
481
+
482
+ def self.set_policy_velocity(policy, opts)
483
+ return unless opts[:velocity_count] && opts[:velocity_window_seconds]
484
+
485
+ policy.set("velocity", { "count" => opts[:velocity_count], "window_seconds" => opts[:velocity_window_seconds] })
486
+ end
487
+ private_class_method :set_policy_velocity
488
+
489
+ def self.set_policy_allowlist(policy, opts)
490
+ return policy.set("merchant_allowlist", []) if opts[:clear_allowlist]
491
+ return if opts[:allow].empty?
492
+
493
+ policy.set("merchant_allowlist", (policy.merchant_allowlist + opts[:allow]).uniq)
494
+ end
495
+ private_class_method :set_policy_allowlist
496
+
497
+ def self.require_currency!(opts)
498
+ opts[:currency] || raise(ArgumentError, "--currency is required alongside a cap")
499
+ end
500
+ private_class_method :require_currency!
501
+
502
+ def self.format_payment_list(methods)
503
+ return "(no payment methods enrolled)" if methods.empty?
504
+
505
+ methods.map { |m| "#{m['label']} (#{m['id']}) — default: #{m['default']}, frozen: #{m['frozen']}" }.join("\n")
506
+ end
507
+ private_class_method :format_payment_list
508
+
509
+ def self.format_payment_enroll(result)
510
+ case result[:status]
511
+ when "complete" then "Enrolled #{result[:label]} (#{result[:id]})."
512
+ when "pending"
513
+ "Timed out waiting for enrollment — finish it at #{result[:setup_url]}, then run " \
514
+ "`portage payment enroll` again."
515
+ else "This store doesn't support payment enrollment."
516
+ end
517
+ end
518
+ private_class_method :format_payment_enroll
519
+
167
520
  # --- output ---
168
521
 
169
522
  def self.format_report(report)
@@ -193,6 +546,20 @@ module Portage
193
546
  end
194
547
  private_class_method :offer_line
195
548
 
549
+ def self.format_compare(report)
550
+ lines = [report[:message].to_s]
551
+ report[:offers].each_with_index { |offer, index| lines << " #{index + 1}. #{compare_offer_line(offer)}" }
552
+ lines.join("\n")
553
+ end
554
+ private_class_method :format_compare
555
+
556
+ def self.compare_offer_line(offer)
557
+ parts = ["[#{offer[:match]}] #{offer[:store]} — #{offer[:title]} (#{offer[:product_id]})", format_price(offer)]
558
+ parts << "browse only" unless offer[:checkout]
559
+ parts.join(" — ")
560
+ end
561
+ private_class_method :compare_offer_line
562
+
196
563
  def self.format_price(offer)
197
564
  return "price n/a" unless offer[:amount]
198
565
 
metadata CHANGED
@@ -1,13 +1,14 @@
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.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Tom Whitbread
8
+ autorequire:
8
9
  bindir: exe
9
10
  cert_chain: []
10
- date: 1980-01-02 00:00:00.000000000 Z
11
+ date: 2026-09-14 00:00:00.000000000 Z
11
12
  dependencies:
12
13
  - !ruby/object:Gem::Dependency
13
14
  name: portage-ucp
@@ -15,28 +16,28 @@ dependencies:
15
16
  requirements:
16
17
  - - "~>"
17
18
  - !ruby/object:Gem::Version
18
- version: '0.2'
19
+ version: '0.5'
19
20
  type: :runtime
20
21
  prerelease: false
21
22
  version_requirements: !ruby/object:Gem::Requirement
22
23
  requirements:
23
24
  - - "~>"
24
25
  - !ruby/object:Gem::Version
25
- version: '0.2'
26
+ version: '0.5'
26
27
  - !ruby/object:Gem::Dependency
27
28
  name: portage-ucp-client
28
29
  requirement: !ruby/object:Gem::Requirement
29
30
  requirements:
30
31
  - - "~>"
31
32
  - !ruby/object:Gem::Version
32
- version: '0.2'
33
+ version: '0.3'
33
34
  type: :runtime
34
35
  prerelease: false
35
36
  version_requirements: !ruby/object:Gem::Requirement
36
37
  requirements:
37
38
  - - "~>"
38
39
  - !ruby/object:Gem::Version
39
- version: '0.2'
40
+ version: '0.3'
40
41
  - !ruby/object:Gem::Dependency
41
42
  name: rspec
42
43
  requirement: !ruby/object:Gem::Requirement
@@ -103,6 +104,7 @@ description: 'Ships the `portage` executable. `portage buy <url>` tries native U
103
104
  ones answering /.well-known/ucp, and search their catalogs — documented APIs only,
104
105
  no SERP scraping. Depends on portage-ucp (for platform detection via Resolver) and
105
106
  portage-ucp-client (for the actual buy calls); no single adapter gem is a hard dependency.'
107
+ email:
106
108
  executables:
107
109
  - portage
108
110
  extensions: []
@@ -114,7 +116,14 @@ files:
114
116
  - exe/portage
115
117
  - lib/portage/cli.rb
116
118
  - lib/portage/cli/buy.rb
119
+ - lib/portage/cli/catalog_products.rb
120
+ - lib/portage/cli/compare.rb
117
121
  - lib/portage/cli/find.rb
122
+ - lib/portage/cli/history.rb
123
+ - lib/portage/cli/payment_methods.rb
124
+ - lib/portage/cli/payment_methods/env_backend.rb
125
+ - lib/portage/cli/payment_methods/keychain_backend.rb
126
+ - lib/portage/cli/payment_methods/secret_service_backend.rb
118
127
  - lib/portage/cli/probe_cache.rb
119
128
  - lib/portage/cli/search_backends.rb
120
129
  - lib/portage/cli/shipping_profile.rb
@@ -126,6 +135,7 @@ metadata:
126
135
  source_code_uri: https://github.com/tomtom87/Portage/tree/main/portage-cli
127
136
  changelog_uri: https://github.com/tomtom87/Portage/blob/main/portage-cli/CHANGELOG.md
128
137
  rubygems_mfa_required: 'true'
138
+ post_install_message:
129
139
  rdoc_options: []
130
140
  require_paths:
131
141
  - lib
@@ -140,7 +150,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
140
150
  - !ruby/object:Gem::Version
141
151
  version: '0'
142
152
  requirements: []
143
- rubygems_version: 4.0.18
153
+ rubygems_version: 3.5.22
154
+ signing_key:
144
155
  specification_version: 4
145
156
  summary: portage — one CLI command to buy from any store, native UCP or not
146
157
  test_files: []