portage-cli 0.6.4 → 0.7.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 +109 -0
- data/README.md +117 -6
- data/lib/portage/cli/buy.rb +350 -71
- data/lib/portage/cli/checkout_handoff.rb +6 -18
- data/lib/portage/cli/confidence_check.rb +127 -0
- data/lib/portage/cli/decisions.rb +72 -0
- data/lib/portage/cli/doctor.rb +17 -0
- data/lib/portage/cli/find.rb +8 -8
- data/lib/portage/cli/history.rb +23 -6
- data/lib/portage/cli/notifier.rb +23 -27
- data/lib/portage/cli/setting.rb +44 -0
- data/lib/portage/cli/version.rb +1 -1
- data/lib/portage/cli.rb +133 -19
- metadata +16 -5
data/lib/portage/cli.rb
CHANGED
|
@@ -23,7 +23,8 @@ module Portage
|
|
|
23
23
|
USAGE = <<~USAGE.freeze
|
|
24
24
|
usage: portage buy <url> --query "..." [--qty N] [--payment-token TOKEN]
|
|
25
25
|
[--product-id ID] [--yes] [--dry-run]
|
|
26
|
-
[--auto-open|--no-auto-open] [--notify-webhook URL]
|
|
26
|
+
[--auto-open|--no-auto-open] [--notify-webhook URL]
|
|
27
|
+
[--decision-backend jev|laya] [--min-confidence N] [--json]
|
|
27
28
|
portage buy --query "..." [--store URL] [--max-price N] [--limit N] ...
|
|
28
29
|
portage find --query "..." [--max-price N] [--limit N] [--json]
|
|
29
30
|
portage compare <url> --product-id ID [--id VALUE ...] [--results N]
|
|
@@ -43,13 +44,16 @@ module Portage
|
|
|
43
44
|
[--velocity-count N --velocity-window-seconds N]
|
|
44
45
|
[--allow HOST ...] [--clear-allowlist]
|
|
45
46
|
portage doctor [--require FILE] [--adapter CLASS_NAME] [--json]
|
|
47
|
+
portage configure [--require FILE] [--adapter CLASS_NAME] [--json] (alias for doctor)
|
|
48
|
+
portage setup [--require FILE] [--adapter CLASS_NAME] [--json] (alias for doctor)
|
|
46
49
|
portage generate adapter NAME [--dir DIR]
|
|
47
50
|
portage generate agent-profile [--out FILE] [--key-out FILE] [--rotate]
|
|
48
51
|
USAGE
|
|
49
52
|
|
|
50
53
|
COMMANDS = { "buy" => :run_buy, "find" => :run_find, "compare" => :run_compare,
|
|
51
54
|
"history" => :run_history, "payment" => :run_payment, "policy" => :run_policy,
|
|
52
|
-
"doctor" => :run_doctor, "
|
|
55
|
+
"doctor" => :run_doctor, "configure" => :run_doctor, "setup" => :run_doctor,
|
|
56
|
+
"generate" => :run_generate }.freeze
|
|
53
57
|
|
|
54
58
|
# @param argv [Array<String>]
|
|
55
59
|
# @return [Integer] process exit code
|
|
@@ -69,13 +73,18 @@ module Portage
|
|
|
69
73
|
|
|
70
74
|
json = options.delete(:json)
|
|
71
75
|
report = Find.new(**options).call
|
|
72
|
-
|
|
73
|
-
message: report[:message])
|
|
76
|
+
record_find(report)
|
|
74
77
|
puts json ? JSON.pretty_generate(report) : format_find(report)
|
|
75
78
|
report[:offers].any? ? 0 : 1
|
|
76
79
|
end
|
|
77
80
|
private_class_method :run_find
|
|
78
81
|
|
|
82
|
+
def self.record_find(report)
|
|
83
|
+
History.new.record_search(query: report[:query], offer_count: report[:offers].length,
|
|
84
|
+
message: report[:message])
|
|
85
|
+
end
|
|
86
|
+
private_class_method :record_find
|
|
87
|
+
|
|
79
88
|
def self.parse_find_options(argv)
|
|
80
89
|
opts = {}
|
|
81
90
|
find_option_parser(opts).parse!(argv)
|
|
@@ -155,6 +164,8 @@ module Portage
|
|
|
155
164
|
return 1 unless parsed
|
|
156
165
|
|
|
157
166
|
url = parsed[:buy][:url] || parsed[:store]
|
|
167
|
+
parsed[:confidence_check] = confidence_check(parsed, url)
|
|
168
|
+
return 1 unless parsed[:confidence_check]
|
|
158
169
|
return execute_buy(parsed, url) if url
|
|
159
170
|
|
|
160
171
|
buy_from_search(parsed)
|
|
@@ -168,6 +179,7 @@ module Portage
|
|
|
168
179
|
# interactive pick has to name it. Piped/CI runs list the offers and stop.
|
|
169
180
|
def self.buy_from_search(parsed)
|
|
170
181
|
report = Find.new(**parsed[:find]).call
|
|
182
|
+
record_find(report)
|
|
171
183
|
offer = pick_offer(report, parsed[:json])
|
|
172
184
|
return report[:offers].any? ? 0 : 1 unless offer
|
|
173
185
|
|
|
@@ -194,36 +206,84 @@ module Portage
|
|
|
194
206
|
private_class_method :prompt_for_offer
|
|
195
207
|
|
|
196
208
|
def self.execute_buy(parsed, url, product_id: nil)
|
|
197
|
-
options = parsed[:buy].merge(url: url)
|
|
209
|
+
options = parsed[:buy].merge(url: url, confidence_check: parsed[:confidence_check])
|
|
198
210
|
options[:product_id] ||= product_id
|
|
199
211
|
report = Buy.new(**options).call
|
|
200
|
-
|
|
212
|
+
record_buy(report, options[:query])
|
|
201
213
|
puts parsed[:json] ? JSON.pretty_generate(report) : format_report(report)
|
|
202
214
|
report[:checkout] || report[:browse] ? 0 : 1
|
|
203
215
|
end
|
|
204
216
|
private_class_method :execute_buy
|
|
205
217
|
|
|
206
|
-
#
|
|
207
|
-
#
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
218
|
+
# Built before the buy starts (and before the search, when there's no
|
|
219
|
+
# URL), so a bad --min-confidence, or a bad PORTAGE_MIN_CONFIDENCE with
|
|
220
|
+
# a backend enabled, stops the run up front rather than after a
|
|
221
|
+
# checkout already exists.
|
|
222
|
+
def self.confidence_check(parsed, url)
|
|
223
|
+
ConfidenceCheck.new(**parsed[:confidence])
|
|
224
|
+
rescue ArgumentError => e
|
|
225
|
+
invalid_buy_option(e.message, url: url, json: parsed[:json])
|
|
226
|
+
end
|
|
227
|
+
private_class_method :confidence_check
|
|
228
|
+
|
|
229
|
+
# A buy refused before it started. Under --json that's a report like
|
|
230
|
+
# any other, with outcome `invalid_option`, so an agent loop reading
|
|
231
|
+
# stdout gets JSON rather than nothing and a line on stderr.
|
|
232
|
+
# @return [nil]
|
|
233
|
+
def self.invalid_buy_option(message, url:, json:)
|
|
234
|
+
unless json
|
|
235
|
+
warn message
|
|
236
|
+
return nil
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
puts JSON.pretty_generate(url: url, checkout_url: nil, products: [], warnings: [], source: "none",
|
|
240
|
+
outcome: "invalid_option", browse: false, checkout: false, message: message)
|
|
241
|
+
nil
|
|
242
|
+
end
|
|
243
|
+
private_class_method :invalid_buy_option
|
|
244
|
+
|
|
245
|
+
# A buy that created a checkout is a purchase entry, whatever its
|
|
246
|
+
# outcome. One that never got that far (no match, browse-only, dead end,
|
|
247
|
+
# a store or adapter error) is a search at that store, so "what did I
|
|
248
|
+
# already buy" never lists a checkout that doesn't exist.
|
|
249
|
+
def self.record_buy(report, query)
|
|
250
|
+
history = History.new
|
|
251
|
+
unless report[:checkout_id]
|
|
252
|
+
return history.record_search(query: query, url: report[:url], offer_count: report[:products].length,
|
|
253
|
+
message: report[:message])
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
history.record_purchase(
|
|
257
|
+
url: report[:url], query: query, outcome: report[:outcome], source: report[:source],
|
|
258
|
+
checkout_id: report[:checkout_id], checkout_status: report[:checkout_status],
|
|
259
|
+
checkout_url: report[:checkout_url], total: report_total(report), currency: report[:currency],
|
|
260
|
+
items: Array(report[:items]).map { |item| item.transform_keys(&:to_s) }, message: report[:message]
|
|
213
261
|
)
|
|
214
262
|
end
|
|
215
|
-
private_class_method :
|
|
263
|
+
private_class_method :record_buy
|
|
264
|
+
|
|
265
|
+
def self.report_total(report)
|
|
266
|
+
Portage::Ucp::Support::Totals.amount(report[:totals])
|
|
267
|
+
end
|
|
268
|
+
private_class_method :report_total
|
|
216
269
|
|
|
270
|
+
# A flag OptionParser can't read (`--min-confidence high`, `--qty two`,
|
|
271
|
+
# an unknown flag) is refused the same way as an out-of-range
|
|
272
|
+
# threshold. `--json` is looked for up front, since parsing stops at
|
|
273
|
+
# the bad flag and may never reach it.
|
|
217
274
|
def self.parse_buy_options(argv)
|
|
218
275
|
url = argv.first && !argv.first.start_with?("-") ? argv.shift : nil
|
|
219
276
|
buy = { url: url, qty: 1, yes: false, dry_run: false }
|
|
220
|
-
parsed = { buy: buy, find: {} }
|
|
277
|
+
parsed = { buy: buy, find: {}, confidence: {} }
|
|
278
|
+
json = argv.include?("--json")
|
|
221
279
|
buy_option_parser(buy, parsed).parse!(argv)
|
|
222
280
|
buy[:query] ||= ""
|
|
223
281
|
return parsed if url || !buy[:query].strip.empty?
|
|
224
282
|
|
|
225
283
|
warn USAGE
|
|
226
284
|
nil
|
|
285
|
+
rescue OptionParser::ParseError => e
|
|
286
|
+
invalid_buy_option(e.message, url: url, json: json)
|
|
227
287
|
end
|
|
228
288
|
private_class_method :parse_buy_options
|
|
229
289
|
|
|
@@ -238,10 +298,19 @@ module Portage
|
|
|
238
298
|
parser.on("--notify-webhook URL") { |v| buy[:notify_webhook] = v }
|
|
239
299
|
parser.on("--json") { parsed[:json] = true }
|
|
240
300
|
add_search_options(parser, buy, parsed)
|
|
301
|
+
add_confidence_options(parser, parsed[:confidence])
|
|
241
302
|
end
|
|
242
303
|
end
|
|
243
304
|
private_class_method :buy_option_parser
|
|
244
305
|
|
|
306
|
+
# The opt-in confidence gate in front of a `--yes` completion (see
|
|
307
|
+
# ConfidenceCheck) — both default to their PORTAGE_* env vars.
|
|
308
|
+
def self.add_confidence_options(parser, confidence)
|
|
309
|
+
parser.on("--decision-backend NAME") { |v| confidence[:backend] = v }
|
|
310
|
+
parser.on("--min-confidence N", Float) { |v| confidence[:threshold] = v }
|
|
311
|
+
end
|
|
312
|
+
private_class_method :add_confidence_options
|
|
313
|
+
|
|
245
314
|
# `--query` feeds both halves: it's the store search when there's no URL
|
|
246
315
|
# and the catalog search once a store is settled, so it's registered once
|
|
247
316
|
# here rather than twice on the same parser.
|
|
@@ -310,13 +379,30 @@ module Portage
|
|
|
310
379
|
end
|
|
311
380
|
private_class_method :format_history
|
|
312
381
|
|
|
382
|
+
# `outcome` first, since it's what the entry is for. Entries recorded
|
|
383
|
+
# before `outcome` existed fall back to their checkout_status/message.
|
|
313
384
|
def self.history_purchase_line(entry)
|
|
314
|
-
|
|
385
|
+
items = Array(entry["items"]).map { |item| item_label(item) }.join(", ")
|
|
386
|
+
[
|
|
387
|
+
"#{Time.at(entry['at'])} — #{entry['outcome'] || entry['checkout_status'] || entry['message']}",
|
|
388
|
+
"#{entry['url']} (#{entry['query']})", (items unless items.empty?),
|
|
389
|
+
(format_amount(entry["total"], entry["currency"]) if entry["total"]),
|
|
390
|
+
(entry["checkout_url"] unless entry["outcome"] == "purchased")
|
|
391
|
+
].compact.join(" — ")
|
|
315
392
|
end
|
|
316
393
|
private_class_method :history_purchase_line
|
|
317
394
|
|
|
395
|
+
# Takes a report's symbol-keyed item or a history entry's string-keyed
|
|
396
|
+
# one.
|
|
397
|
+
def self.item_label(item)
|
|
398
|
+
item = item.transform_keys(&:to_s)
|
|
399
|
+
"#{item['title'] || item['id']} x#{item['quantity']}"
|
|
400
|
+
end
|
|
401
|
+
private_class_method :item_label
|
|
402
|
+
|
|
318
403
|
def self.history_search_line(entry)
|
|
319
|
-
|
|
404
|
+
where = entry["url"] ? " at #{entry['url']}" : ""
|
|
405
|
+
"#{Time.at(entry['at'])} — \"#{entry['query']}\"#{where} — #{entry['offer_count']} result(s)"
|
|
320
406
|
end
|
|
321
407
|
private_class_method :history_search_line
|
|
322
408
|
|
|
@@ -598,21 +684,44 @@ module Portage
|
|
|
598
684
|
puts "Wrote #{result[:profile_path]} (kid #{result[:kid]})"
|
|
599
685
|
puts "Wrote private key to #{result[:private_key_path]} — keep this out of version control " \
|
|
600
686
|
"and off the machine that serves the public profile"
|
|
687
|
+
puts "Next: commit #{result[:profile_path]}, then, once it's on main, run " \
|
|
688
|
+
"`bundle exec rake agent_profile:purge` from the repo root — see docs/agent-profile.md."
|
|
601
689
|
0
|
|
602
690
|
end
|
|
603
691
|
private_class_method :run_generate_agent_profile
|
|
604
692
|
|
|
605
693
|
# --- output ---
|
|
606
694
|
|
|
695
|
+
# The `[outcome]` tag leads so a caller reading text, not --json, has the
|
|
696
|
+
# same value to branch on that the JSON report carries.
|
|
607
697
|
def self.format_report(report)
|
|
608
|
-
lines = ["#{report[:message]} (source: #{report[:source]})"]
|
|
698
|
+
lines = ["[#{report[:outcome]}] #{report[:message]} (source: #{report[:source]})"]
|
|
609
699
|
report[:products].each { |p| lines << " - #{product_line(p)}" }
|
|
700
|
+
lines.concat(format_checkout(report))
|
|
610
701
|
lines << " checkout: #{report[:checkout_url]}" if report[:checkout_url]
|
|
611
702
|
lines.concat(format_handoff(report[:handoff])) if report[:handoff]
|
|
703
|
+
lines.concat(format_decisions(report[:decisions])) if report[:decisions]&.any?
|
|
612
704
|
lines.join("\n")
|
|
613
705
|
end
|
|
614
706
|
private_class_method :format_report
|
|
615
707
|
|
|
708
|
+
# What the checkout holds, as opposed to the search results above it,
|
|
709
|
+
# and where it differs from the request.
|
|
710
|
+
def self.format_checkout(report)
|
|
711
|
+
lines = Array(report[:items]).map { |item| " in checkout: #{item_label(item)}" }
|
|
712
|
+
total = report_total(report)
|
|
713
|
+
lines << " total: #{format_amount(total, report[:currency])}" if total
|
|
714
|
+
lines + Array(report[:warnings]).map { |w| " warning: #{w}" }
|
|
715
|
+
end
|
|
716
|
+
private_class_method :format_checkout
|
|
717
|
+
|
|
718
|
+
def self.format_decisions(decisions)
|
|
719
|
+
decisions.map do |name, verdict|
|
|
720
|
+
" decision #{name}: #{verdict.compact.map { |key, value| "#{key}=#{value}" }.join(' ')}"
|
|
721
|
+
end
|
|
722
|
+
end
|
|
723
|
+
private_class_method :format_decisions
|
|
724
|
+
|
|
616
725
|
def self.format_handoff(handoff)
|
|
617
726
|
lines = [" opened in browser: #{handoff[:opened]}", " notified: #{handoff[:notified]}"]
|
|
618
727
|
lines << " notify error: #{handoff[:notify_error]}" if handoff[:notify_error]
|
|
@@ -656,8 +765,13 @@ module Portage
|
|
|
656
765
|
def self.format_price(offer)
|
|
657
766
|
return "price n/a" unless offer[:amount]
|
|
658
767
|
|
|
659
|
-
|
|
768
|
+
format_amount(offer[:amount], offer[:currency])
|
|
660
769
|
end
|
|
661
770
|
private_class_method :format_price
|
|
771
|
+
|
|
772
|
+
def self.format_amount(amount, currency)
|
|
773
|
+
"#{format('%.2f', amount / 100.0)}#{" #{currency}" if currency}"
|
|
774
|
+
end
|
|
775
|
+
private_class_method :format_amount
|
|
662
776
|
end
|
|
663
777
|
end
|
metadata
CHANGED
|
@@ -1,14 +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.7.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Tom Whitbread
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-09-
|
|
11
|
+
date: 2026-09-23 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: portage-ucp
|
|
@@ -16,14 +16,14 @@ dependencies:
|
|
|
16
16
|
requirements:
|
|
17
17
|
- - "~>"
|
|
18
18
|
- !ruby/object:Gem::Version
|
|
19
|
-
version: '0.
|
|
19
|
+
version: '0.9'
|
|
20
20
|
type: :runtime
|
|
21
21
|
prerelease: false
|
|
22
22
|
version_requirements: !ruby/object:Gem::Requirement
|
|
23
23
|
requirements:
|
|
24
24
|
- - "~>"
|
|
25
25
|
- !ruby/object:Gem::Version
|
|
26
|
-
version: '0.
|
|
26
|
+
version: '0.9'
|
|
27
27
|
- !ruby/object:Gem::Dependency
|
|
28
28
|
name: portage-ucp-client
|
|
29
29
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -31,6 +31,9 @@ dependencies:
|
|
|
31
31
|
- - "~>"
|
|
32
32
|
- !ruby/object:Gem::Version
|
|
33
33
|
version: '0.6'
|
|
34
|
+
- - ">="
|
|
35
|
+
- !ruby/object:Gem::Version
|
|
36
|
+
version: 0.6.2
|
|
34
37
|
type: :runtime
|
|
35
38
|
prerelease: false
|
|
36
39
|
version_requirements: !ruby/object:Gem::Requirement
|
|
@@ -38,6 +41,9 @@ dependencies:
|
|
|
38
41
|
- - "~>"
|
|
39
42
|
- !ruby/object:Gem::Version
|
|
40
43
|
version: '0.6'
|
|
44
|
+
- - ">="
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: 0.6.2
|
|
41
47
|
- !ruby/object:Gem::Dependency
|
|
42
48
|
name: portage-ucp-journal
|
|
43
49
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -119,7 +125,9 @@ description: 'Ships the `portage` executable. `portage buy <url>` tries native U
|
|
|
119
125
|
no SERP scraping. Depends on portage-ucp (for platform detection via Resolver),
|
|
120
126
|
portage-ucp-client (for the actual buy calls), and portage-ucp-journal (for `portage-console`''s
|
|
121
127
|
read-only view of local purchase/transaction/order state); no single adapter gem
|
|
122
|
-
is a hard dependency.
|
|
128
|
+
is a hard dependency. Ranking, escalation and the spend-policy check use portage-ucp
|
|
129
|
+
core''s rules; portage-ucp-decision is optional and only the opt-in confidence gate
|
|
130
|
+
needs it.'
|
|
123
131
|
email:
|
|
124
132
|
executables:
|
|
125
133
|
- portage
|
|
@@ -138,8 +146,10 @@ files:
|
|
|
138
146
|
- lib/portage/cli/catalog_products.rb
|
|
139
147
|
- lib/portage/cli/checkout_handoff.rb
|
|
140
148
|
- lib/portage/cli/compare.rb
|
|
149
|
+
- lib/portage/cli/confidence_check.rb
|
|
141
150
|
- lib/portage/cli/config.rb
|
|
142
151
|
- lib/portage/cli/console.rb
|
|
152
|
+
- lib/portage/cli/decisions.rb
|
|
143
153
|
- lib/portage/cli/doctor.rb
|
|
144
154
|
- lib/portage/cli/find.rb
|
|
145
155
|
- lib/portage/cli/generate/adapter.rb
|
|
@@ -152,6 +162,7 @@ files:
|
|
|
152
162
|
- lib/portage/cli/payment_methods/secret_service_backend.rb
|
|
153
163
|
- lib/portage/cli/probe_cache.rb
|
|
154
164
|
- lib/portage/cli/search_backends.rb
|
|
165
|
+
- lib/portage/cli/setting.rb
|
|
155
166
|
- lib/portage/cli/shipping_profile.rb
|
|
156
167
|
- lib/portage/cli/version.rb
|
|
157
168
|
homepage: https://github.com/tomtom87/Portage/tree/main/portage-cli
|