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
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.
|
|
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:
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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:
|
|
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: []
|