portage-cli 0.6.4 → 0.7.3
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 +216 -0
- data/README.md +263 -7
- data/lib/portage/cli/buy.rb +529 -104
- 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 +104 -1
- data/lib/portage/cli/find.rb +10 -9
- data/lib/portage/cli/handoff_reconciler/wire_adapters.rb +50 -0
- data/lib/portage/cli/handoff_reconciler.rb +249 -0
- data/lib/portage/cli/handoff_spend_mode.rb +28 -0
- data/lib/portage/cli/handoff_wait_timeout.rb +49 -0
- data/lib/portage/cli/handoff_waiter.rb +103 -0
- data/lib/portage/cli/history.rb +23 -6
- data/lib/portage/cli/homepage_fetch.rb +41 -0
- data/lib/portage/cli/macos_notifier.rb +37 -0
- data/lib/portage/cli/notifier.rb +26 -27
- data/lib/portage/cli/payment_methods.rb +6 -4
- data/lib/portage/cli/permissive_authenticator.rb +16 -0
- data/lib/portage/cli/proxy_doctor.rb +149 -0
- data/lib/portage/cli/proxy_settings/password_ref.rb +48 -0
- data/lib/portage/cli/proxy_settings.rb +350 -0
- data/lib/portage/cli/reconcile_notifier.rb +65 -0
- data/lib/portage/cli/reconcile_notify.rb +38 -0
- data/lib/portage/cli/search_backends.rb +6 -4
- data/lib/portage/cli/setting.rb +44 -0
- data/lib/portage/cli/user_agent.rb +39 -0
- data/lib/portage/cli/version.rb +1 -1
- data/lib/portage/cli/webmcp.rb +25 -0
- data/lib/portage/cli/webmcp_checkout_mode.rb +28 -0
- data/lib/portage/cli.rb +367 -27
- metadata +46 -5
data/lib/portage/cli.rb
CHANGED
|
@@ -2,6 +2,7 @@ require "optparse"
|
|
|
2
2
|
require "json"
|
|
3
3
|
|
|
4
4
|
require_relative "cli/version"
|
|
5
|
+
require_relative "cli/user_agent"
|
|
5
6
|
require_relative "cli/shipping_profile"
|
|
6
7
|
require_relative "cli/buyer_context"
|
|
7
8
|
require_relative "cli/catalog_products"
|
|
@@ -10,7 +11,12 @@ require_relative "cli/find"
|
|
|
10
11
|
require_relative "cli/compare"
|
|
11
12
|
require_relative "cli/history"
|
|
12
13
|
require_relative "cli/payment_methods"
|
|
14
|
+
require_relative "cli/proxy_settings"
|
|
13
15
|
require_relative "cli/doctor"
|
|
16
|
+
require_relative "cli/handoff_reconciler"
|
|
17
|
+
require_relative "cli/handoff_waiter"
|
|
18
|
+
require_relative "cli/reconcile_notifier"
|
|
19
|
+
require_relative "cli/reconcile_notify"
|
|
14
20
|
require_relative "cli/generate/adapter"
|
|
15
21
|
require_relative "cli/generate/agent_profile"
|
|
16
22
|
|
|
@@ -23,7 +29,9 @@ module Portage
|
|
|
23
29
|
USAGE = <<~USAGE.freeze
|
|
24
30
|
usage: portage buy <url> --query "..." [--qty N] [--payment-token TOKEN]
|
|
25
31
|
[--product-id ID] [--yes] [--dry-run]
|
|
26
|
-
[--auto-open|--no-auto-open] [--notify-webhook URL]
|
|
32
|
+
[--auto-open|--no-auto-open] [--notify-webhook URL]
|
|
33
|
+
[--decision-backend jev|laya] [--min-confidence N] [--json]
|
|
34
|
+
[--wait [--wait-timeout DURATION|off]]
|
|
27
35
|
portage buy --query "..." [--store URL] [--max-price N] [--limit N] ...
|
|
28
36
|
portage find --query "..." [--max-price N] [--limit N] [--json]
|
|
29
37
|
portage compare <url> --product-id ID [--id VALUE ...] [--results N]
|
|
@@ -42,25 +50,66 @@ module Portage
|
|
|
42
50
|
[--rolling-cap N --rolling-window-seconds N --currency CUR]
|
|
43
51
|
[--velocity-count N --velocity-window-seconds N]
|
|
44
52
|
[--allow HOST ...] [--clear-allowlist]
|
|
53
|
+
portage orders reconcile [--checkout ID] [--json]
|
|
45
54
|
portage doctor [--require FILE] [--adapter CLASS_NAME] [--json]
|
|
55
|
+
portage configure [--require FILE] [--adapter CLASS_NAME] [--json] (alias for doctor)
|
|
56
|
+
portage setup [--require FILE] [--adapter CLASS_NAME] [--json] (alias for doctor)
|
|
46
57
|
portage generate adapter NAME [--dir DIR]
|
|
47
58
|
portage generate agent-profile [--out FILE] [--key-out FILE] [--rotate]
|
|
59
|
+
portage --version
|
|
60
|
+
|
|
61
|
+
proxy flags (buy/find/compare/doctor/payment enroll):
|
|
62
|
+
[--proxy URL] [--proxy-mode forward|gateway] [--proxy-header "Name: value"]
|
|
63
|
+
[--no-proxy HOSTS] [--proxy-route ROUTE=URL|direct] [--proxy-chain URL,URL,...]
|
|
64
|
+
[--proxy-passthrough HEADER] [--proxy-ca FILE] [--no-env-proxy]
|
|
48
65
|
USAGE
|
|
49
66
|
|
|
50
67
|
COMMANDS = { "buy" => :run_buy, "find" => :run_find, "compare" => :run_compare,
|
|
51
68
|
"history" => :run_history, "payment" => :run_payment, "policy" => :run_policy,
|
|
52
|
-
"doctor" => :run_doctor, "
|
|
69
|
+
"orders" => :run_orders, "doctor" => :run_doctor, "configure" => :run_doctor,
|
|
70
|
+
"setup" => :run_doctor, "generate" => :run_generate }.freeze
|
|
71
|
+
|
|
72
|
+
VERSION_FLAGS = %w[--version -v version].freeze
|
|
53
73
|
|
|
54
74
|
# @param argv [Array<String>]
|
|
55
75
|
# @return [Integer] process exit code
|
|
56
76
|
def self.run(argv)
|
|
57
77
|
command, *rest = argv
|
|
78
|
+
return run_version if VERSION_FLAGS.include?(command)
|
|
58
79
|
return send(COMMANDS[command], rest) if COMMANDS.key?(command)
|
|
59
80
|
|
|
60
81
|
warn USAGE
|
|
61
82
|
1
|
|
62
83
|
end
|
|
63
84
|
|
|
85
|
+
# So a packaged install (Homebrew's `test do` block, `portage doctor`)
|
|
86
|
+
# can confirm which release it's running without hitting the network.
|
|
87
|
+
def self.run_version
|
|
88
|
+
puts VERSION
|
|
89
|
+
0
|
|
90
|
+
end
|
|
91
|
+
private_class_method :run_version
|
|
92
|
+
|
|
93
|
+
# --- proxy (docs/plans/proxy-support.md Phase 2) ---
|
|
94
|
+
|
|
95
|
+
# Resolves this command's `--proxy*` flags/env/config.json into a
|
|
96
|
+
# ProxyConfig and installs it as the process-wide default every
|
|
97
|
+
# Support::Connection.start call (buy/find/compare/payment/doctor alike)
|
|
98
|
+
# already reads unless it's handed its own `proxy:` — see ProxySettings'
|
|
99
|
+
# own comment for why. A bad flag or a malformed/protected-header
|
|
100
|
+
# config.json surfaces here as a clean message, never a raw core
|
|
101
|
+
# exception.
|
|
102
|
+
# @return [ProxySettings, nil] nil on a config error (already reported).
|
|
103
|
+
def self.apply_proxy_settings(proxy_flags)
|
|
104
|
+
settings = ProxySettings.new(flags: proxy_flags || {})
|
|
105
|
+
Portage::Ucp::Support::ProxyConfig.current = settings.resolve
|
|
106
|
+
settings
|
|
107
|
+
rescue ProxySettings::ConfigError => e
|
|
108
|
+
warn "portage: #{e.message}"
|
|
109
|
+
nil
|
|
110
|
+
end
|
|
111
|
+
private_class_method :apply_proxy_settings
|
|
112
|
+
|
|
64
113
|
# --- find ---
|
|
65
114
|
|
|
66
115
|
def self.run_find(argv)
|
|
@@ -68,14 +117,21 @@ module Portage
|
|
|
68
117
|
return 1 unless options
|
|
69
118
|
|
|
70
119
|
json = options.delete(:json)
|
|
120
|
+
return 1 unless apply_proxy_settings(options.delete(:proxy))
|
|
121
|
+
|
|
71
122
|
report = Find.new(**options).call
|
|
72
|
-
|
|
73
|
-
message: report[:message])
|
|
123
|
+
record_find(report)
|
|
74
124
|
puts json ? JSON.pretty_generate(report) : format_find(report)
|
|
75
125
|
report[:offers].any? ? 0 : 1
|
|
76
126
|
end
|
|
77
127
|
private_class_method :run_find
|
|
78
128
|
|
|
129
|
+
def self.record_find(report)
|
|
130
|
+
History.new.record_search(query: report[:query], offer_count: report[:offers].length,
|
|
131
|
+
message: report[:message])
|
|
132
|
+
end
|
|
133
|
+
private_class_method :record_find
|
|
134
|
+
|
|
79
135
|
def self.parse_find_options(argv)
|
|
80
136
|
opts = {}
|
|
81
137
|
find_option_parser(opts).parse!(argv)
|
|
@@ -89,11 +145,13 @@ module Portage
|
|
|
89
145
|
private_class_method :parse_find_options
|
|
90
146
|
|
|
91
147
|
def self.find_option_parser(opts)
|
|
148
|
+
opts[:proxy] = {}
|
|
92
149
|
OptionParser.new do |parser|
|
|
93
150
|
parser.on("--query QUERY") { |v| opts[:query] = v }
|
|
94
151
|
parser.on("--limit N", Integer) { |v| opts[:limit] = v }
|
|
95
152
|
parser.on("--max-price N", Float) { |v| opts[:max_price] = to_minor_units(v) }
|
|
96
153
|
parser.on("--json") { opts[:json] = true }
|
|
154
|
+
ProxySettings.add_options(parser, opts[:proxy])
|
|
97
155
|
end
|
|
98
156
|
end
|
|
99
157
|
private_class_method :find_option_parser
|
|
@@ -112,6 +170,8 @@ module Portage
|
|
|
112
170
|
|
|
113
171
|
json = options.delete(:json)
|
|
114
172
|
url = options.delete(:url)
|
|
173
|
+
return 1 unless apply_proxy_settings(options.delete(:proxy))
|
|
174
|
+
|
|
115
175
|
report = Compare.new(origin_url: url, **options).call
|
|
116
176
|
# Recorded as a search, not a purchase — compare never checks out. The
|
|
117
177
|
# query string names the compare so `portage history list` doesn't
|
|
@@ -138,12 +198,14 @@ module Portage
|
|
|
138
198
|
private_class_method :parse_compare_options
|
|
139
199
|
|
|
140
200
|
def self.compare_option_parser(opts)
|
|
201
|
+
opts[:proxy] = {}
|
|
141
202
|
OptionParser.new do |parser|
|
|
142
203
|
parser.on("--product-id ID") { |v| opts[:origin_product_id] = v }
|
|
143
204
|
parser.on("--id VALUE") { |v| opts[:identity] << v }
|
|
144
205
|
parser.on("--results N", Integer) { |v| opts[:results] = v }
|
|
145
206
|
parser.on("--max-price N", Float) { |v| opts[:max_price] = to_minor_units(v) }
|
|
146
207
|
parser.on("--json") { opts[:json] = true }
|
|
208
|
+
ProxySettings.add_options(parser, opts[:proxy])
|
|
147
209
|
end
|
|
148
210
|
end
|
|
149
211
|
private_class_method :compare_option_parser
|
|
@@ -153,8 +215,11 @@ module Portage
|
|
|
153
215
|
def self.run_buy(argv)
|
|
154
216
|
parsed = parse_buy_options(argv)
|
|
155
217
|
return 1 unless parsed
|
|
218
|
+
return 1 unless apply_proxy_settings(parsed[:proxy])
|
|
156
219
|
|
|
157
220
|
url = parsed[:buy][:url] || parsed[:store]
|
|
221
|
+
parsed[:confidence_check] = confidence_check(parsed, url)
|
|
222
|
+
return 1 unless parsed[:confidence_check]
|
|
158
223
|
return execute_buy(parsed, url) if url
|
|
159
224
|
|
|
160
225
|
buy_from_search(parsed)
|
|
@@ -168,6 +233,7 @@ module Portage
|
|
|
168
233
|
# interactive pick has to name it. Piped/CI runs list the offers and stop.
|
|
169
234
|
def self.buy_from_search(parsed)
|
|
170
235
|
report = Find.new(**parsed[:find]).call
|
|
236
|
+
record_find(report)
|
|
171
237
|
offer = pick_offer(report, parsed[:json])
|
|
172
238
|
return report[:offers].any? ? 0 : 1 unless offer
|
|
173
239
|
|
|
@@ -194,36 +260,164 @@ module Portage
|
|
|
194
260
|
private_class_method :prompt_for_offer
|
|
195
261
|
|
|
196
262
|
def self.execute_buy(parsed, url, product_id: nil)
|
|
197
|
-
options = parsed[:buy].merge(url: url)
|
|
263
|
+
options = parsed[:buy].merge(url: url, confidence_check: parsed[:confidence_check])
|
|
198
264
|
options[:product_id] ||= product_id
|
|
199
265
|
report = Buy.new(**options).call
|
|
200
|
-
|
|
201
|
-
|
|
266
|
+
record_buy(report, options[:query])
|
|
267
|
+
result = parsed[:wait] ? wait_for_handoff(report, parsed) : nil
|
|
268
|
+
print_buy_report(report, result, parsed[:json])
|
|
202
269
|
report[:checkout] || report[:browse] ? 0 : 1
|
|
203
270
|
end
|
|
204
271
|
private_class_method :execute_buy
|
|
205
272
|
|
|
206
|
-
#
|
|
207
|
-
#
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
273
|
+
# docs/plans/handoff-reconcile.md Phase 3 — `portage buy --wait`. A
|
|
274
|
+
# no-op (returns nil) whenever there's nothing to wait on: --dry-run
|
|
275
|
+
# never hands off at all, and a completed/browse-only/dead-end report
|
|
276
|
+
# has no pending shopper record either. Otherwise polls
|
|
277
|
+
# `HandoffReconciler` through `HandoffWaiter` until it settles or its
|
|
278
|
+
# deadline passes.
|
|
279
|
+
#
|
|
280
|
+
# Under --json, every event streams to stdout as it happens (NDJSON) —
|
|
281
|
+
# the initial hand-off, then a line per checkout-status change, then the
|
|
282
|
+
# settle, so a calling agent reading stdout live never has to guess
|
|
283
|
+
# whether it's still waiting. Under plain output, nothing streams here:
|
|
284
|
+
# the forced "terminal" notify channel (see #wait_notifier) prints its
|
|
285
|
+
# own line when it settles, and the final report follows exactly as it
|
|
286
|
+
# does without --wait.
|
|
287
|
+
def self.wait_for_handoff(report, parsed)
|
|
288
|
+
return nil unless report[:handoff] && report[:checkout_id]
|
|
289
|
+
|
|
290
|
+
transaction_log = Portage::Ucp::Support::TransactionLog.new
|
|
291
|
+
record = pending_shopper_record(report[:checkout_id], transaction_log)
|
|
292
|
+
return nil unless record
|
|
293
|
+
|
|
294
|
+
json = parsed[:json]
|
|
295
|
+
emit_ndjson(handoff_event(report)) if json
|
|
296
|
+
reconciler = HandoffReconciler.new(transaction_log: transaction_log, notifier: wait_notifier(json))
|
|
297
|
+
waiter = HandoffWaiter.new(reconciler: reconciler, transaction_log: transaction_log,
|
|
298
|
+
wait_timeout_override: parsed[:wait_timeout])
|
|
299
|
+
waiter.call(record) { |event, result| emit_wait_event(event, result, json) }
|
|
300
|
+
end
|
|
301
|
+
private_class_method :wait_for_handoff
|
|
302
|
+
|
|
303
|
+
def self.pending_shopper_record(checkout_id, transaction_log)
|
|
304
|
+
transaction_log.each_record.find { |r| r["checkout_id"] == checkout_id && r["settled_by"] == "shopper" }
|
|
305
|
+
end
|
|
306
|
+
private_class_method :pending_shopper_record
|
|
307
|
+
|
|
308
|
+
# `terminal` is forced on for a plain-text wait (see ReconcileNotify) so
|
|
309
|
+
# the shopper sees a line the moment it settles even with nothing
|
|
310
|
+
# configured. Never forced under --json: that channel prints plain text,
|
|
311
|
+
# which would corrupt the NDJSON stream this method's caller is also
|
|
312
|
+
# writing to the same stdout.
|
|
313
|
+
def self.wait_notifier(json)
|
|
314
|
+
ReconcileNotifier.new(channels: ReconcileNotify.resolve(extra: json ? [] : ["terminal"]))
|
|
315
|
+
end
|
|
316
|
+
private_class_method :wait_notifier
|
|
317
|
+
|
|
318
|
+
def self.handoff_event(report)
|
|
319
|
+
{ event: "handoff", checkout_id: report[:checkout_id], checkout_url: report[:checkout_url],
|
|
320
|
+
reason: report[:outcome] }
|
|
321
|
+
end
|
|
322
|
+
private_class_method :handoff_event
|
|
323
|
+
|
|
324
|
+
# Only `:settled` ever reaches stdout as `handoff_settled` — a timeout or
|
|
325
|
+
# an interrupted wait leaves `result.settled` false, and that's already
|
|
326
|
+
# visible in the final report object this event stream ends with, so a
|
|
327
|
+
# second, redundant "gave up" event isn't needed.
|
|
328
|
+
def self.emit_wait_event(event, result, json)
|
|
329
|
+
return unless json
|
|
330
|
+
|
|
331
|
+
case event
|
|
332
|
+
when :status then emit_ndjson({ event: "handoff_status", status: result.checkout_status })
|
|
333
|
+
when :settled
|
|
334
|
+
emit_ndjson({ event: "handoff_settled", result: result.status, resolution: result.resolution,
|
|
335
|
+
order_id: result.order_id, amount: result.amount, currency: result.currency }.compact)
|
|
336
|
+
end
|
|
337
|
+
end
|
|
338
|
+
private_class_method :emit_wait_event
|
|
339
|
+
|
|
340
|
+
def self.emit_ndjson(payload) = puts JSON.generate(payload)
|
|
341
|
+
private_class_method :emit_ndjson
|
|
342
|
+
|
|
343
|
+
def self.print_buy_report(report, result, json)
|
|
344
|
+
report = report.merge(reconcile: result.to_h) if result
|
|
345
|
+
puts json ? JSON.pretty_generate(report) : format_report(report)
|
|
346
|
+
end
|
|
347
|
+
private_class_method :print_buy_report
|
|
348
|
+
|
|
349
|
+
# Built before the buy starts (and before the search, when there's no
|
|
350
|
+
# URL), so a bad --min-confidence, or a bad PORTAGE_MIN_CONFIDENCE with
|
|
351
|
+
# a backend enabled, stops the run up front rather than after a
|
|
352
|
+
# checkout already exists.
|
|
353
|
+
def self.confidence_check(parsed, url)
|
|
354
|
+
ConfidenceCheck.new(**parsed[:confidence])
|
|
355
|
+
rescue ArgumentError => e
|
|
356
|
+
invalid_buy_option(e.message, url: url, json: parsed[:json])
|
|
357
|
+
end
|
|
358
|
+
private_class_method :confidence_check
|
|
359
|
+
|
|
360
|
+
# A buy refused before it started. Under --json that's a report like
|
|
361
|
+
# any other, with outcome `invalid_option`, so an agent loop reading
|
|
362
|
+
# stdout gets JSON rather than nothing and a line on stderr.
|
|
363
|
+
# @return [nil]
|
|
364
|
+
def self.invalid_buy_option(message, url:, json:)
|
|
365
|
+
unless json
|
|
366
|
+
warn message
|
|
367
|
+
return nil
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
puts JSON.pretty_generate(url: url, checkout_url: nil, products: [], warnings: [], source: "none",
|
|
371
|
+
outcome: "invalid_option", browse: false, checkout: false, message: message)
|
|
372
|
+
nil
|
|
373
|
+
end
|
|
374
|
+
private_class_method :invalid_buy_option
|
|
375
|
+
|
|
376
|
+
# A buy that created a checkout is a purchase entry, whatever its
|
|
377
|
+
# outcome. One that never got that far (no match, browse-only, dead end,
|
|
378
|
+
# a store or adapter error) is a search at that store, so "what did I
|
|
379
|
+
# already buy" never lists a checkout that doesn't exist.
|
|
380
|
+
def self.record_buy(report, query)
|
|
381
|
+
history = History.new
|
|
382
|
+
unless report[:checkout_id]
|
|
383
|
+
return history.record_search(query: query, url: report[:url], offer_count: report[:products].length,
|
|
384
|
+
message: report[:message])
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
history.record_purchase(
|
|
388
|
+
url: report[:url], query: query, outcome: report[:outcome], source: report[:source],
|
|
389
|
+
checkout_id: report[:checkout_id], checkout_status: report[:checkout_status],
|
|
390
|
+
checkout_url: report[:checkout_url], total: report_total(report), currency: report[:currency],
|
|
391
|
+
items: Array(report[:items]).map { |item| item.transform_keys(&:to_s) }, message: report[:message]
|
|
213
392
|
)
|
|
214
393
|
end
|
|
215
|
-
private_class_method :
|
|
394
|
+
private_class_method :record_buy
|
|
216
395
|
|
|
396
|
+
def self.report_total(report)
|
|
397
|
+
Portage::Ucp::Support::Totals.amount(report[:totals])
|
|
398
|
+
end
|
|
399
|
+
private_class_method :report_total
|
|
400
|
+
|
|
401
|
+
# A flag OptionParser can't read (`--min-confidence high`, `--qty two`,
|
|
402
|
+
# an unknown flag) is refused the same way as an out-of-range
|
|
403
|
+
# threshold. `--json` is looked for up front, since parsing stops at
|
|
404
|
+
# the bad flag and may never reach it.
|
|
217
405
|
def self.parse_buy_options(argv)
|
|
218
406
|
url = argv.first && !argv.first.start_with?("-") ? argv.shift : nil
|
|
219
407
|
buy = { url: url, qty: 1, yes: false, dry_run: false }
|
|
220
|
-
parsed = { buy: buy, find: {} }
|
|
221
|
-
|
|
408
|
+
parsed = { buy: buy, find: {}, confidence: {} }
|
|
409
|
+
json = argv.include?("--json")
|
|
410
|
+
parsed[:proxy] = {}
|
|
411
|
+
parser = buy_option_parser(buy, parsed)
|
|
412
|
+
ProxySettings.add_options(parser, parsed[:proxy])
|
|
413
|
+
parser.parse!(argv)
|
|
222
414
|
buy[:query] ||= ""
|
|
223
415
|
return parsed if url || !buy[:query].strip.empty?
|
|
224
416
|
|
|
225
417
|
warn USAGE
|
|
226
418
|
nil
|
|
419
|
+
rescue OptionParser::ParseError => e
|
|
420
|
+
invalid_buy_option(e.message, url: url, json: json)
|
|
227
421
|
end
|
|
228
422
|
private_class_method :parse_buy_options
|
|
229
423
|
|
|
@@ -234,14 +428,39 @@ module Portage
|
|
|
234
428
|
parser.on("--product-id ID") { |v| buy[:product_id] = v }
|
|
235
429
|
parser.on("--yes") { buy[:yes] = true }
|
|
236
430
|
parser.on("--dry-run") { buy[:dry_run] = true }
|
|
237
|
-
parser.on("--[no-]auto-open") { |v| buy[:auto_open] = v }
|
|
238
|
-
parser.on("--notify-webhook URL") { |v| buy[:notify_webhook] = v }
|
|
239
431
|
parser.on("--json") { parsed[:json] = true }
|
|
432
|
+
add_handoff_options(parser, buy)
|
|
433
|
+
add_wait_options(parser, parsed)
|
|
240
434
|
add_search_options(parser, buy, parsed)
|
|
435
|
+
add_confidence_options(parser, parsed[:confidence])
|
|
241
436
|
end
|
|
242
437
|
end
|
|
243
438
|
private_class_method :buy_option_parser
|
|
244
439
|
|
|
440
|
+
# The opt-in confidence gate in front of a `--yes` completion (see
|
|
441
|
+
# ConfidenceCheck) — both default to their PORTAGE_* env vars.
|
|
442
|
+
def self.add_confidence_options(parser, confidence)
|
|
443
|
+
parser.on("--decision-backend NAME") { |v| confidence[:backend] = v }
|
|
444
|
+
parser.on("--min-confidence N", Float) { |v| confidence[:threshold] = v }
|
|
445
|
+
end
|
|
446
|
+
private_class_method :add_confidence_options
|
|
447
|
+
|
|
448
|
+
def self.add_handoff_options(parser, buy)
|
|
449
|
+
parser.on("--[no-]auto-open") { |v| buy[:auto_open] = v }
|
|
450
|
+
parser.on("--notify-webhook URL") { |v| buy[:notify_webhook] = v }
|
|
451
|
+
end
|
|
452
|
+
private_class_method :add_handoff_options
|
|
453
|
+
|
|
454
|
+
# docs/plans/handoff-reconcile.md Phase 3 — `--wait`/`--wait-timeout`
|
|
455
|
+
# land on `parsed`, never on `buy`: they're consumed by
|
|
456
|
+
# `#wait_for_handoff` after `Buy#call` returns, and `Buy.new` has no
|
|
457
|
+
# `wait:`/`wait_timeout:` keyword to accidentally receive them.
|
|
458
|
+
def self.add_wait_options(parser, parsed)
|
|
459
|
+
parser.on("--wait") { parsed[:wait] = true }
|
|
460
|
+
parser.on("--wait-timeout DURATION") { |v| parsed[:wait_timeout] = v }
|
|
461
|
+
end
|
|
462
|
+
private_class_method :add_wait_options
|
|
463
|
+
|
|
245
464
|
# `--query` feeds both halves: it's the store search when there's no URL
|
|
246
465
|
# and the catalog search once a store is settled, so it's registered once
|
|
247
466
|
# here rather than twice on the same parser.
|
|
@@ -249,7 +468,7 @@ module Portage
|
|
|
249
468
|
parser.on("--query QUERY") { |v| parsed[:find][:query] = buy[:query] = v }
|
|
250
469
|
parser.on("--store URL") { |v| parsed[:store] = v }
|
|
251
470
|
parser.on("--limit N", Integer) { |v| parsed[:find][:limit] = v }
|
|
252
|
-
parser.on("--max-price N", Float) { |v| parsed[:find][:max_price] = to_minor_units(v) }
|
|
471
|
+
parser.on("--max-price N", Float) { |v| parsed[:find][:max_price] = buy[:max_price] = to_minor_units(v) }
|
|
253
472
|
end
|
|
254
473
|
private_class_method :add_search_options
|
|
255
474
|
|
|
@@ -310,13 +529,30 @@ module Portage
|
|
|
310
529
|
end
|
|
311
530
|
private_class_method :format_history
|
|
312
531
|
|
|
532
|
+
# `outcome` first, since it's what the entry is for. Entries recorded
|
|
533
|
+
# before `outcome` existed fall back to their checkout_status/message.
|
|
313
534
|
def self.history_purchase_line(entry)
|
|
314
|
-
|
|
535
|
+
items = Array(entry["items"]).map { |item| item_label(item) }.join(", ")
|
|
536
|
+
[
|
|
537
|
+
"#{Time.at(entry['at'])} — #{entry['outcome'] || entry['checkout_status'] || entry['message']}",
|
|
538
|
+
"#{entry['url']} (#{entry['query']})", (items unless items.empty?),
|
|
539
|
+
(format_amount(entry["total"], entry["currency"]) if entry["total"]),
|
|
540
|
+
(entry["checkout_url"] unless entry["outcome"] == "purchased")
|
|
541
|
+
].compact.join(" — ")
|
|
315
542
|
end
|
|
316
543
|
private_class_method :history_purchase_line
|
|
317
544
|
|
|
545
|
+
# Takes a report's symbol-keyed item or a history entry's string-keyed
|
|
546
|
+
# one.
|
|
547
|
+
def self.item_label(item)
|
|
548
|
+
item = item.transform_keys(&:to_s)
|
|
549
|
+
"#{item['title'] || item['id']} x#{item['quantity']}"
|
|
550
|
+
end
|
|
551
|
+
private_class_method :item_label
|
|
552
|
+
|
|
318
553
|
def self.history_search_line(entry)
|
|
319
|
-
|
|
554
|
+
where = entry["url"] ? " at #{entry['url']}" : ""
|
|
555
|
+
"#{Time.at(entry['at'])} — \"#{entry['query']}\"#{where} — #{entry['offer_count']} result(s)"
|
|
320
556
|
end
|
|
321
557
|
private_class_method :history_search_line
|
|
322
558
|
|
|
@@ -369,13 +605,14 @@ module Portage
|
|
|
369
605
|
private_class_method :run_payment_mutate
|
|
370
606
|
|
|
371
607
|
def self.parse_payment_enroll_options(argv)
|
|
372
|
-
opts = { scope_merchants: [] }
|
|
608
|
+
opts = { scope_merchants: [], proxy: {} }
|
|
373
609
|
OptionParser.new do |parser|
|
|
374
610
|
parser.on("--label NAME") { |v| opts[:label] = v }
|
|
375
611
|
parser.on("--json") { opts[:json] = true }
|
|
376
612
|
parser.on("--scope-merchant HOST") { |v| opts[:scope_merchants] << v }
|
|
377
613
|
parser.on("--scope-max-amount N", Integer) { |v| opts[:scope_max_amount] = v }
|
|
378
614
|
parser.on("--scope-currency CUR") { |v| opts[:scope_currency] = v }
|
|
615
|
+
ProxySettings.add_options(parser, opts[:proxy])
|
|
379
616
|
end.parse!(argv)
|
|
380
617
|
opts[:url] = argv.first && !argv.first.start_with?("-") ? argv.shift : nil
|
|
381
618
|
opts
|
|
@@ -400,6 +637,7 @@ module Portage
|
|
|
400
637
|
warn USAGE
|
|
401
638
|
return 1
|
|
402
639
|
end
|
|
640
|
+
return 1 unless apply_proxy_settings(opts[:proxy])
|
|
403
641
|
|
|
404
642
|
result = PaymentMethods.new.enroll(opts[:url], label: opts[:label],
|
|
405
643
|
scope: payment_enroll_scope(opts)) do |setup_url|
|
|
@@ -524,14 +762,86 @@ module Portage
|
|
|
524
762
|
end
|
|
525
763
|
private_class_method :format_payment_enroll
|
|
526
764
|
|
|
765
|
+
# --- orders ---
|
|
766
|
+
|
|
767
|
+
# docs/plans/handoff-reconcile.md Phase 1 — resolves every pending
|
|
768
|
+
# `settled_by: "shopper"` TransactionLog record (or just the one named
|
|
769
|
+
# by `--checkout`) by re-fetching that checkout from the store. Safe to
|
|
770
|
+
# run from cron/launchd: a record that's already terminal, or that
|
|
771
|
+
# doesn't belong to this run (a `--checkout` for a different id, or
|
|
772
|
+
# `settled_by: nil` dispatcher crash-evidence), is a no-op result, not
|
|
773
|
+
# an error, so a scheduled run never needs its own filtering logic.
|
|
774
|
+
def self.run_orders(argv)
|
|
775
|
+
sub = argv.first && !argv.first.start_with?("-") ? argv.shift : nil
|
|
776
|
+
return run_orders_reconcile(argv) if sub == "reconcile"
|
|
777
|
+
|
|
778
|
+
warn USAGE
|
|
779
|
+
1
|
|
780
|
+
end
|
|
781
|
+
private_class_method :run_orders
|
|
782
|
+
|
|
783
|
+
def self.run_orders_reconcile(argv)
|
|
784
|
+
opts = {}
|
|
785
|
+
OptionParser.new do |parser|
|
|
786
|
+
parser.on("--checkout ID") { |v| opts[:checkout] = v }
|
|
787
|
+
parser.on("--json") { opts[:json] = true }
|
|
788
|
+
end.parse!(argv)
|
|
789
|
+
|
|
790
|
+
transaction_log = Portage::Ucp::Support::TransactionLog.new
|
|
791
|
+
reconciler = HandoffReconciler.new(transaction_log: transaction_log)
|
|
792
|
+
results = reconcile_records(opts[:checkout], transaction_log, reconciler)
|
|
793
|
+
|
|
794
|
+
puts opts[:json] ? JSON.pretty_generate(results.map(&:to_h)) : format_reconcile(results)
|
|
795
|
+
0
|
|
796
|
+
end
|
|
797
|
+
private_class_method :run_orders_reconcile
|
|
798
|
+
|
|
799
|
+
def self.reconcile_records(checkout_id, transaction_log, reconciler)
|
|
800
|
+
return reconcile_one_checkout(checkout_id, transaction_log, reconciler) if checkout_id
|
|
801
|
+
|
|
802
|
+
HandoffReconciler.each_pending_shopper_record(transaction_log).map { |record| reconciler.call(record) }
|
|
803
|
+
end
|
|
804
|
+
private_class_method :reconcile_records
|
|
805
|
+
|
|
806
|
+
# `--checkout ID` reconciles the one named record whatever its
|
|
807
|
+
# `settled_by`/status — #call itself still refuses to settle anything
|
|
808
|
+
# that isn't a pending shopper record, this just skips the "iterate
|
|
809
|
+
# every pending record" step when the caller already knows which one.
|
|
810
|
+
def self.reconcile_one_checkout(checkout_id, transaction_log, reconciler)
|
|
811
|
+
key = transaction_log.each_record.find { |r| r["checkout_id"] == checkout_id }&.fetch("idempotency_key", nil)
|
|
812
|
+
return [] unless key
|
|
813
|
+
|
|
814
|
+
[reconciler.call(transaction_log.find(key))]
|
|
815
|
+
end
|
|
816
|
+
private_class_method :reconcile_one_checkout
|
|
817
|
+
|
|
818
|
+
def self.format_reconcile(results)
|
|
819
|
+
return "(nothing to reconcile)" if results.empty?
|
|
820
|
+
|
|
821
|
+
results.map { |r| format_reconcile_result(r) }.join("\n")
|
|
822
|
+
end
|
|
823
|
+
private_class_method :format_reconcile
|
|
824
|
+
|
|
825
|
+
def self.format_reconcile_result(result)
|
|
826
|
+
return "#{result.idempotency_key}: #{result.note}" unless result.settled
|
|
827
|
+
|
|
828
|
+
parts = ["#{result.idempotency_key}: #{result.status}"]
|
|
829
|
+
parts << "resolution: #{result.resolution}" if result.resolution
|
|
830
|
+
parts << "order: #{result.order_id}" if result.order_id
|
|
831
|
+
parts << format_amount(result.amount, result.currency) if result.amount
|
|
832
|
+
parts.join(" — ")
|
|
833
|
+
end
|
|
834
|
+
private_class_method :format_reconcile_result
|
|
835
|
+
|
|
527
836
|
# --- doctor ---
|
|
528
837
|
|
|
529
838
|
def self.parse_doctor_options(argv)
|
|
530
|
-
opts = {}
|
|
839
|
+
opts = { proxy: {} }
|
|
531
840
|
OptionParser.new do |parser|
|
|
532
841
|
parser.on("--require FILE") { |v| opts[:require] = v }
|
|
533
842
|
parser.on("--adapter CLASS_NAME") { |v| opts[:adapter] = v }
|
|
534
843
|
parser.on("--json") { opts[:json] = true }
|
|
844
|
+
ProxySettings.add_options(parser, opts[:proxy])
|
|
535
845
|
end.parse!(argv)
|
|
536
846
|
opts
|
|
537
847
|
end
|
|
@@ -541,8 +851,10 @@ module Portage
|
|
|
541
851
|
opts = parse_doctor_options(argv)
|
|
542
852
|
require File.expand_path(opts[:require]) if opts[:require]
|
|
543
853
|
adapter_class = opts[:adapter] && Object.const_get(opts[:adapter])
|
|
854
|
+
proxy_settings = apply_proxy_settings(opts[:proxy])
|
|
855
|
+
return 1 unless proxy_settings
|
|
544
856
|
|
|
545
|
-
findings = Doctor.new(adapter_class: adapter_class).call
|
|
857
|
+
findings = Doctor.new(adapter_class: adapter_class, proxy_settings: proxy_settings).call
|
|
546
858
|
puts opts[:json] ? JSON.pretty_generate(findings.map(&:to_h)) : format_doctor(findings)
|
|
547
859
|
findings.empty? ? 0 : 1
|
|
548
860
|
end
|
|
@@ -598,21 +910,44 @@ module Portage
|
|
|
598
910
|
puts "Wrote #{result[:profile_path]} (kid #{result[:kid]})"
|
|
599
911
|
puts "Wrote private key to #{result[:private_key_path]} — keep this out of version control " \
|
|
600
912
|
"and off the machine that serves the public profile"
|
|
913
|
+
puts "Next: commit #{result[:profile_path]}, then, once it's on main, run " \
|
|
914
|
+
"`bundle exec rake agent_profile:purge` from the repo root — see docs/agent-profile.md."
|
|
601
915
|
0
|
|
602
916
|
end
|
|
603
917
|
private_class_method :run_generate_agent_profile
|
|
604
918
|
|
|
605
919
|
# --- output ---
|
|
606
920
|
|
|
921
|
+
# The `[outcome]` tag leads so a caller reading text, not --json, has the
|
|
922
|
+
# same value to branch on that the JSON report carries.
|
|
607
923
|
def self.format_report(report)
|
|
608
|
-
lines = ["#{report[:message]} (source: #{report[:source]})"]
|
|
924
|
+
lines = ["[#{report[:outcome]}] #{report[:message]} (source: #{report[:source]})"]
|
|
609
925
|
report[:products].each { |p| lines << " - #{product_line(p)}" }
|
|
926
|
+
lines.concat(format_checkout(report))
|
|
610
927
|
lines << " checkout: #{report[:checkout_url]}" if report[:checkout_url]
|
|
611
928
|
lines.concat(format_handoff(report[:handoff])) if report[:handoff]
|
|
929
|
+
lines.concat(format_decisions(report[:decisions])) if report[:decisions]&.any?
|
|
612
930
|
lines.join("\n")
|
|
613
931
|
end
|
|
614
932
|
private_class_method :format_report
|
|
615
933
|
|
|
934
|
+
# What the checkout holds, as opposed to the search results above it,
|
|
935
|
+
# and where it differs from the request.
|
|
936
|
+
def self.format_checkout(report)
|
|
937
|
+
lines = Array(report[:items]).map { |item| " in checkout: #{item_label(item)}" }
|
|
938
|
+
total = report_total(report)
|
|
939
|
+
lines << " total: #{format_amount(total, report[:currency])}" if total
|
|
940
|
+
lines + Array(report[:warnings]).map { |w| " warning: #{w}" }
|
|
941
|
+
end
|
|
942
|
+
private_class_method :format_checkout
|
|
943
|
+
|
|
944
|
+
def self.format_decisions(decisions)
|
|
945
|
+
decisions.map do |name, verdict|
|
|
946
|
+
" decision #{name}: #{verdict.compact.map { |key, value| "#{key}=#{value}" }.join(' ')}"
|
|
947
|
+
end
|
|
948
|
+
end
|
|
949
|
+
private_class_method :format_decisions
|
|
950
|
+
|
|
616
951
|
def self.format_handoff(handoff)
|
|
617
952
|
lines = [" opened in browser: #{handoff[:opened]}", " notified: #{handoff[:notified]}"]
|
|
618
953
|
lines << " notify error: #{handoff[:notify_error]}" if handoff[:notify_error]
|
|
@@ -656,8 +991,13 @@ module Portage
|
|
|
656
991
|
def self.format_price(offer)
|
|
657
992
|
return "price n/a" unless offer[:amount]
|
|
658
993
|
|
|
659
|
-
|
|
994
|
+
format_amount(offer[:amount], offer[:currency])
|
|
660
995
|
end
|
|
661
996
|
private_class_method :format_price
|
|
997
|
+
|
|
998
|
+
def self.format_amount(amount, currency)
|
|
999
|
+
"#{format('%.2f', amount / 100.0)}#{" #{currency}" if currency}"
|
|
1000
|
+
end
|
|
1001
|
+
private_class_method :format_amount
|
|
662
1002
|
end
|
|
663
1003
|
end
|