portage-cli 0.7.0 → 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 +107 -0
- data/README.md +146 -1
- data/lib/portage/cli/buy.rb +187 -41
- data/lib/portage/cli/doctor.rb +87 -1
- data/lib/portage/cli/find.rb +2 -1
- 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/homepage_fetch.rb +41 -0
- data/lib/portage/cli/macos_notifier.rb +37 -0
- data/lib/portage/cli/notifier.rb +6 -3
- 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/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 +236 -10
- metadata +36 -6
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
require_relative "notifier"
|
|
2
|
+
require_relative "macos_notifier"
|
|
3
|
+
require_relative "reconcile_notify"
|
|
4
|
+
|
|
5
|
+
module Portage
|
|
6
|
+
module Cli
|
|
7
|
+
# docs/plans/handoff-reconcile.md Phase 3 — fans a settle event
|
|
8
|
+
# (`checkout_reconciled`, from HandoffReconciler#notify) out to whichever
|
|
9
|
+
# channels `ReconcileNotify` names. The default `HandoffReconciler`
|
|
10
|
+
# notifier for both `portage buy --wait` and `portage orders reconcile`,
|
|
11
|
+
# replacing the plain webhook-only `Notifier` those used through Phase 1.
|
|
12
|
+
#
|
|
13
|
+
# Every channel is best-effort, same posture as `Notifier#call` itself —
|
|
14
|
+
# one channel failing (a bad webhook URL, no `osascript` on this host)
|
|
15
|
+
# never stops another from firing, and this never raises out of
|
|
16
|
+
# `HandoffReconciler#notify`, which already wraps the whole call in its
|
|
17
|
+
# own rescue.
|
|
18
|
+
class ReconcileNotifier
|
|
19
|
+
def initialize(channels: ReconcileNotify.resolve, webhook: Notifier.new, macos: MacosNotifier.new)
|
|
20
|
+
@channels = channels
|
|
21
|
+
@webhook = webhook
|
|
22
|
+
@macos = macos
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def enabled?(channel) = @channels.include?(channel)
|
|
26
|
+
|
|
27
|
+
# @return [String, nil] the webhook channel's own failure message, same
|
|
28
|
+
# shape `Notifier#call` always returned — the other channels have no
|
|
29
|
+
# return-value contract to preserve, since nothing read theirs before
|
|
30
|
+
# this class existed.
|
|
31
|
+
def call(payload)
|
|
32
|
+
error = @webhook.call(payload) if enabled?("webhook")
|
|
33
|
+
notify_macos(payload) if enabled?("macos")
|
|
34
|
+
puts terminal_line(payload) if enabled?("terminal")
|
|
35
|
+
error
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def notify_macos(payload)
|
|
41
|
+
@macos.call(title: "Portage checkout #{payload[:result]}", message: macos_message(payload))
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def macos_message(payload)
|
|
45
|
+
parts = [payload[:shop], format_amount(payload[:amount], payload[:currency])].compact
|
|
46
|
+
parts.empty? ? "checkout #{payload[:checkout_id]}" : parts.join(" — ")
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def terminal_line(payload)
|
|
50
|
+
parts = ["[reconcile] #{payload[:checkout_id]}: #{payload[:result]}"]
|
|
51
|
+
parts << "resolution: #{payload[:resolution]}" if payload[:resolution]
|
|
52
|
+
parts << "order: #{payload[:order_id]}" if payload[:order_id]
|
|
53
|
+
amount = format_amount(payload[:amount], payload[:currency])
|
|
54
|
+
parts << amount if amount
|
|
55
|
+
parts.join(" — ")
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def format_amount(amount, currency)
|
|
59
|
+
return nil unless amount
|
|
60
|
+
|
|
61
|
+
"#{format('%.2f', amount / 100.0)}#{" #{currency}" if currency}"
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
require_relative "config"
|
|
2
|
+
require_relative "setting"
|
|
3
|
+
|
|
4
|
+
module Portage
|
|
5
|
+
module Cli
|
|
6
|
+
# docs/plans/handoff-reconcile.md Phase 3 — which channels fire when a
|
|
7
|
+
# handoff settles, from `portage buy --wait` or `portage orders
|
|
8
|
+
# reconcile` (see ReconcileNotifier). A comma list, same Setting
|
|
9
|
+
# precedence as every other standing preference here: an explicit
|
|
10
|
+
# `override:` beats PORTAGE_RECONCILE_NOTIFY, which beats config.json's
|
|
11
|
+
# "reconcile_notify". Unset is `webhook` only.
|
|
12
|
+
#
|
|
13
|
+
# "journal" is accepted but drives nothing here: the Phase 1 order
|
|
14
|
+
# snapshot journal write already happens unconditionally in
|
|
15
|
+
# HandoffReconciler#record_journal whenever a completed checkout carries
|
|
16
|
+
# an order. Naming it in `reconcile_notify` documents that as always-on
|
|
17
|
+
# rather than gating a second write path.
|
|
18
|
+
module ReconcileNotify
|
|
19
|
+
ENV_VAR = "PORTAGE_RECONCILE_NOTIFY".freeze
|
|
20
|
+
CONFIG_KEY = "reconcile_notify".freeze
|
|
21
|
+
CHANNELS = %w[webhook journal macos terminal].freeze
|
|
22
|
+
DEFAULT = %w[webhook].freeze
|
|
23
|
+
|
|
24
|
+
module_function
|
|
25
|
+
|
|
26
|
+
# @param extra [Array<String>] channels to force on regardless of
|
|
27
|
+
# configuration — `portage buy --wait` forces `terminal` in plain
|
|
28
|
+
# (non-`--json`) mode, so the shopper sees a line when it settles
|
|
29
|
+
# even with nothing configured.
|
|
30
|
+
# @return [Array<String>]
|
|
31
|
+
def resolve(override: nil, config: Config.load, extra: [])
|
|
32
|
+
raw = Setting.resolve(override: override, env: ENV_VAR, config: config, config_key: CONFIG_KEY)
|
|
33
|
+
configured = raw.nil? ? DEFAULT : raw.to_s.split(",").map(&:strip)
|
|
34
|
+
((configured & CHANNELS) + extra).uniq
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -2,6 +2,9 @@ require "net/http"
|
|
|
2
2
|
require "uri"
|
|
3
3
|
require "json"
|
|
4
4
|
require "yaml"
|
|
5
|
+
require "portage/ucp"
|
|
6
|
+
require "portage/ucp/support/connection"
|
|
7
|
+
require_relative "user_agent"
|
|
5
8
|
|
|
6
9
|
module Portage
|
|
7
10
|
module Cli
|
|
@@ -18,7 +21,6 @@ module Portage
|
|
|
18
21
|
module SearchBackends
|
|
19
22
|
OPEN_TIMEOUT = 5
|
|
20
23
|
READ_TIMEOUT = 5
|
|
21
|
-
USER_AGENT = "portage-find".freeze
|
|
22
24
|
|
|
23
25
|
# Reference works and marketplaces-of-links that a search backend will
|
|
24
26
|
# happily return for a product query but that are never themselves a UCP
|
|
@@ -45,9 +47,9 @@ module Portage
|
|
|
45
47
|
end
|
|
46
48
|
|
|
47
49
|
def self.request(uri, headers)
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
http.get(uri.request_uri,
|
|
50
|
+
Portage::Ucp::Support::Connection.start(uri, route: :search, open_timeout: OPEN_TIMEOUT,
|
|
51
|
+
read_timeout: READ_TIMEOUT) do |http|
|
|
52
|
+
http.get(uri.request_uri, UserAgent.headers.merge(headers))
|
|
51
53
|
end
|
|
52
54
|
end
|
|
53
55
|
private_class_method :request
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
require "portage/ucp/client"
|
|
2
|
+
require_relative "version"
|
|
3
|
+
require_relative "config"
|
|
4
|
+
require_relative "setting"
|
|
5
|
+
|
|
6
|
+
module Portage
|
|
7
|
+
module Cli
|
|
8
|
+
# Every request this CLI makes — to a store, a search backend, a
|
|
9
|
+
# payment handler, or a notify webhook — names itself, then the client
|
|
10
|
+
# gem it speaks UCP through, so a merchant reading its logs sees
|
|
11
|
+
# "portage-cli/... portage-ucp-client/... (+https://github.com/...)"
|
|
12
|
+
# rather than Ruby's default and knows where to look.
|
|
13
|
+
#
|
|
14
|
+
# A caller running many agents behind one IP (or one that just wants
|
|
15
|
+
# its own contact info in the string a merchant might grep for) can
|
|
16
|
+
# override it — same Setting precedence as Notifier's webhook URL:
|
|
17
|
+
# PORTAGE_USER_AGENT beats ~/.portage/config.json's "user_agent" key,
|
|
18
|
+
# both of which beat this default. Resolved fresh on every call rather
|
|
19
|
+
# than frozen at load time, so a config.json edit or an ENV change
|
|
20
|
+
# takes effect without restarting whatever's driving this CLI.
|
|
21
|
+
module UserAgent
|
|
22
|
+
DEFAULT = "portage-cli/#{VERSION} #{Portage::Ucp::Client::USER_AGENT}".freeze
|
|
23
|
+
ENV_VAR = "PORTAGE_USER_AGENT".freeze
|
|
24
|
+
CONFIG_KEY = "user_agent".freeze
|
|
25
|
+
|
|
26
|
+
module_function
|
|
27
|
+
|
|
28
|
+
# @param config [Config]
|
|
29
|
+
# @return [String] never blank — falls back to DEFAULT.
|
|
30
|
+
def value(config: Config.load)
|
|
31
|
+
Setting.resolve(env: ENV_VAR, config: config, config_key: CONFIG_KEY) || DEFAULT
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# @param config [Config]
|
|
35
|
+
# @return [Hash] a fresh header hash, safe for a caller to #merge into.
|
|
36
|
+
def headers(config: Config.load) = { "User-Agent" => value(config: config) }
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
data/lib/portage/cli/version.rb
CHANGED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
module Portage
|
|
2
|
+
module Cli
|
|
3
|
+
# docs/plans/handoff-reconcile.md Phase 4 — `portage-ucp-webmcp` is
|
|
4
|
+
# optional at runtime, not in portage-cli's gemspec, same posture as
|
|
5
|
+
# `Decisions.available?` for `portage-ucp-decision` and
|
|
6
|
+
# `Resolver.build_adapter` for a platform adapter gem: `require`, then
|
|
7
|
+
# rescue LoadError, so `gem install portage-cli` stays light and only a
|
|
8
|
+
# caller who actually passes `Buy.new(webmcp_bridge:)` ever needs it
|
|
9
|
+
# installed.
|
|
10
|
+
module Webmcp
|
|
11
|
+
# @return [Boolean] whether portage-ucp-webmcp could be loaded.
|
|
12
|
+
# Memoized: `require` runs once per process.
|
|
13
|
+
def self.available?
|
|
14
|
+
return @available unless @available.nil?
|
|
15
|
+
|
|
16
|
+
@available = begin
|
|
17
|
+
require "portage/ucp/webmcp"
|
|
18
|
+
true
|
|
19
|
+
rescue LoadError
|
|
20
|
+
false
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
require_relative "config"
|
|
2
|
+
require_relative "setting"
|
|
3
|
+
|
|
4
|
+
module Portage
|
|
5
|
+
module Cli
|
|
6
|
+
# docs/plans/handoff-reconcile.md Phase 4 — how `Buy`'s optional WebMCP
|
|
7
|
+
# path (see Webmcp, Buy#webmcp_flow) finishes a checkout it built in the
|
|
8
|
+
# browser. Same Setting precedence as HandoffSpendMode: no CLI flag (a
|
|
9
|
+
# WebMCP bridge is an injected collaborator, not something a shell
|
|
10
|
+
# invocation can name), so just PORTAGE_WEBMCP_CHECKOUT_MODE >
|
|
11
|
+
# config.json's "webmcp_checkout_mode". Unset, or anything not in
|
|
12
|
+
# MODES, is `express_stop`, the only mode actually implemented — see
|
|
13
|
+
# Buy#webmcp_token_unsupported_report for why `token` isn't yet.
|
|
14
|
+
module WebmcpCheckoutMode
|
|
15
|
+
ENV_VAR = "PORTAGE_WEBMCP_CHECKOUT_MODE".freeze
|
|
16
|
+
CONFIG_KEY = "webmcp_checkout_mode".freeze
|
|
17
|
+
MODES = %w[express_stop token].freeze
|
|
18
|
+
DEFAULT = "express_stop".freeze
|
|
19
|
+
|
|
20
|
+
module_function
|
|
21
|
+
|
|
22
|
+
def resolve(override: nil, config: Config.load)
|
|
23
|
+
mode = Setting.resolve(override: override, env: ENV_VAR, config: config, config_key: CONFIG_KEY)
|
|
24
|
+
MODES.include?(mode) ? mode : DEFAULT
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
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
|
|
|
@@ -25,6 +31,7 @@ module Portage
|
|
|
25
31
|
[--product-id ID] [--yes] [--dry-run]
|
|
26
32
|
[--auto-open|--no-auto-open] [--notify-webhook URL]
|
|
27
33
|
[--decision-backend jev|laya] [--min-confidence N] [--json]
|
|
34
|
+
[--wait [--wait-timeout DURATION|off]]
|
|
28
35
|
portage buy --query "..." [--store URL] [--max-price N] [--limit N] ...
|
|
29
36
|
portage find --query "..." [--max-price N] [--limit N] [--json]
|
|
30
37
|
portage compare <url> --product-id ID [--id VALUE ...] [--results N]
|
|
@@ -43,28 +50,66 @@ module Portage
|
|
|
43
50
|
[--rolling-cap N --rolling-window-seconds N --currency CUR]
|
|
44
51
|
[--velocity-count N --velocity-window-seconds N]
|
|
45
52
|
[--allow HOST ...] [--clear-allowlist]
|
|
53
|
+
portage orders reconcile [--checkout ID] [--json]
|
|
46
54
|
portage doctor [--require FILE] [--adapter CLASS_NAME] [--json]
|
|
47
55
|
portage configure [--require FILE] [--adapter CLASS_NAME] [--json] (alias for doctor)
|
|
48
56
|
portage setup [--require FILE] [--adapter CLASS_NAME] [--json] (alias for doctor)
|
|
49
57
|
portage generate adapter NAME [--dir DIR]
|
|
50
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]
|
|
51
65
|
USAGE
|
|
52
66
|
|
|
53
67
|
COMMANDS = { "buy" => :run_buy, "find" => :run_find, "compare" => :run_compare,
|
|
54
68
|
"history" => :run_history, "payment" => :run_payment, "policy" => :run_policy,
|
|
55
|
-
"
|
|
56
|
-
"generate" => :run_generate }.freeze
|
|
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
|
|
57
73
|
|
|
58
74
|
# @param argv [Array<String>]
|
|
59
75
|
# @return [Integer] process exit code
|
|
60
76
|
def self.run(argv)
|
|
61
77
|
command, *rest = argv
|
|
78
|
+
return run_version if VERSION_FLAGS.include?(command)
|
|
62
79
|
return send(COMMANDS[command], rest) if COMMANDS.key?(command)
|
|
63
80
|
|
|
64
81
|
warn USAGE
|
|
65
82
|
1
|
|
66
83
|
end
|
|
67
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
|
+
|
|
68
113
|
# --- find ---
|
|
69
114
|
|
|
70
115
|
def self.run_find(argv)
|
|
@@ -72,6 +117,8 @@ module Portage
|
|
|
72
117
|
return 1 unless options
|
|
73
118
|
|
|
74
119
|
json = options.delete(:json)
|
|
120
|
+
return 1 unless apply_proxy_settings(options.delete(:proxy))
|
|
121
|
+
|
|
75
122
|
report = Find.new(**options).call
|
|
76
123
|
record_find(report)
|
|
77
124
|
puts json ? JSON.pretty_generate(report) : format_find(report)
|
|
@@ -98,11 +145,13 @@ module Portage
|
|
|
98
145
|
private_class_method :parse_find_options
|
|
99
146
|
|
|
100
147
|
def self.find_option_parser(opts)
|
|
148
|
+
opts[:proxy] = {}
|
|
101
149
|
OptionParser.new do |parser|
|
|
102
150
|
parser.on("--query QUERY") { |v| opts[:query] = v }
|
|
103
151
|
parser.on("--limit N", Integer) { |v| opts[:limit] = v }
|
|
104
152
|
parser.on("--max-price N", Float) { |v| opts[:max_price] = to_minor_units(v) }
|
|
105
153
|
parser.on("--json") { opts[:json] = true }
|
|
154
|
+
ProxySettings.add_options(parser, opts[:proxy])
|
|
106
155
|
end
|
|
107
156
|
end
|
|
108
157
|
private_class_method :find_option_parser
|
|
@@ -121,6 +170,8 @@ module Portage
|
|
|
121
170
|
|
|
122
171
|
json = options.delete(:json)
|
|
123
172
|
url = options.delete(:url)
|
|
173
|
+
return 1 unless apply_proxy_settings(options.delete(:proxy))
|
|
174
|
+
|
|
124
175
|
report = Compare.new(origin_url: url, **options).call
|
|
125
176
|
# Recorded as a search, not a purchase — compare never checks out. The
|
|
126
177
|
# query string names the compare so `portage history list` doesn't
|
|
@@ -147,12 +198,14 @@ module Portage
|
|
|
147
198
|
private_class_method :parse_compare_options
|
|
148
199
|
|
|
149
200
|
def self.compare_option_parser(opts)
|
|
201
|
+
opts[:proxy] = {}
|
|
150
202
|
OptionParser.new do |parser|
|
|
151
203
|
parser.on("--product-id ID") { |v| opts[:origin_product_id] = v }
|
|
152
204
|
parser.on("--id VALUE") { |v| opts[:identity] << v }
|
|
153
205
|
parser.on("--results N", Integer) { |v| opts[:results] = v }
|
|
154
206
|
parser.on("--max-price N", Float) { |v| opts[:max_price] = to_minor_units(v) }
|
|
155
207
|
parser.on("--json") { opts[:json] = true }
|
|
208
|
+
ProxySettings.add_options(parser, opts[:proxy])
|
|
156
209
|
end
|
|
157
210
|
end
|
|
158
211
|
private_class_method :compare_option_parser
|
|
@@ -162,6 +215,7 @@ module Portage
|
|
|
162
215
|
def self.run_buy(argv)
|
|
163
216
|
parsed = parse_buy_options(argv)
|
|
164
217
|
return 1 unless parsed
|
|
218
|
+
return 1 unless apply_proxy_settings(parsed[:proxy])
|
|
165
219
|
|
|
166
220
|
url = parsed[:buy][:url] || parsed[:store]
|
|
167
221
|
parsed[:confidence_check] = confidence_check(parsed, url)
|
|
@@ -210,11 +264,88 @@ module Portage
|
|
|
210
264
|
options[:product_id] ||= product_id
|
|
211
265
|
report = Buy.new(**options).call
|
|
212
266
|
record_buy(report, options[:query])
|
|
213
|
-
|
|
267
|
+
result = parsed[:wait] ? wait_for_handoff(report, parsed) : nil
|
|
268
|
+
print_buy_report(report, result, parsed[:json])
|
|
214
269
|
report[:checkout] || report[:browse] ? 0 : 1
|
|
215
270
|
end
|
|
216
271
|
private_class_method :execute_buy
|
|
217
272
|
|
|
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
|
+
|
|
218
349
|
# Built before the buy starts (and before the search, when there's no
|
|
219
350
|
# URL), so a bad --min-confidence, or a bad PORTAGE_MIN_CONFIDENCE with
|
|
220
351
|
# a backend enabled, stops the run up front rather than after a
|
|
@@ -276,7 +407,10 @@ module Portage
|
|
|
276
407
|
buy = { url: url, qty: 1, yes: false, dry_run: false }
|
|
277
408
|
parsed = { buy: buy, find: {}, confidence: {} }
|
|
278
409
|
json = argv.include?("--json")
|
|
279
|
-
|
|
410
|
+
parsed[:proxy] = {}
|
|
411
|
+
parser = buy_option_parser(buy, parsed)
|
|
412
|
+
ProxySettings.add_options(parser, parsed[:proxy])
|
|
413
|
+
parser.parse!(argv)
|
|
280
414
|
buy[:query] ||= ""
|
|
281
415
|
return parsed if url || !buy[:query].strip.empty?
|
|
282
416
|
|
|
@@ -294,9 +428,9 @@ module Portage
|
|
|
294
428
|
parser.on("--product-id ID") { |v| buy[:product_id] = v }
|
|
295
429
|
parser.on("--yes") { buy[:yes] = true }
|
|
296
430
|
parser.on("--dry-run") { buy[:dry_run] = true }
|
|
297
|
-
parser.on("--[no-]auto-open") { |v| buy[:auto_open] = v }
|
|
298
|
-
parser.on("--notify-webhook URL") { |v| buy[:notify_webhook] = v }
|
|
299
431
|
parser.on("--json") { parsed[:json] = true }
|
|
432
|
+
add_handoff_options(parser, buy)
|
|
433
|
+
add_wait_options(parser, parsed)
|
|
300
434
|
add_search_options(parser, buy, parsed)
|
|
301
435
|
add_confidence_options(parser, parsed[:confidence])
|
|
302
436
|
end
|
|
@@ -311,6 +445,22 @@ module Portage
|
|
|
311
445
|
end
|
|
312
446
|
private_class_method :add_confidence_options
|
|
313
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
|
+
|
|
314
464
|
# `--query` feeds both halves: it's the store search when there's no URL
|
|
315
465
|
# and the catalog search once a store is settled, so it's registered once
|
|
316
466
|
# here rather than twice on the same parser.
|
|
@@ -318,7 +468,7 @@ module Portage
|
|
|
318
468
|
parser.on("--query QUERY") { |v| parsed[:find][:query] = buy[:query] = v }
|
|
319
469
|
parser.on("--store URL") { |v| parsed[:store] = v }
|
|
320
470
|
parser.on("--limit N", Integer) { |v| parsed[:find][:limit] = v }
|
|
321
|
-
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) }
|
|
322
472
|
end
|
|
323
473
|
private_class_method :add_search_options
|
|
324
474
|
|
|
@@ -455,13 +605,14 @@ module Portage
|
|
|
455
605
|
private_class_method :run_payment_mutate
|
|
456
606
|
|
|
457
607
|
def self.parse_payment_enroll_options(argv)
|
|
458
|
-
opts = { scope_merchants: [] }
|
|
608
|
+
opts = { scope_merchants: [], proxy: {} }
|
|
459
609
|
OptionParser.new do |parser|
|
|
460
610
|
parser.on("--label NAME") { |v| opts[:label] = v }
|
|
461
611
|
parser.on("--json") { opts[:json] = true }
|
|
462
612
|
parser.on("--scope-merchant HOST") { |v| opts[:scope_merchants] << v }
|
|
463
613
|
parser.on("--scope-max-amount N", Integer) { |v| opts[:scope_max_amount] = v }
|
|
464
614
|
parser.on("--scope-currency CUR") { |v| opts[:scope_currency] = v }
|
|
615
|
+
ProxySettings.add_options(parser, opts[:proxy])
|
|
465
616
|
end.parse!(argv)
|
|
466
617
|
opts[:url] = argv.first && !argv.first.start_with?("-") ? argv.shift : nil
|
|
467
618
|
opts
|
|
@@ -486,6 +637,7 @@ module Portage
|
|
|
486
637
|
warn USAGE
|
|
487
638
|
return 1
|
|
488
639
|
end
|
|
640
|
+
return 1 unless apply_proxy_settings(opts[:proxy])
|
|
489
641
|
|
|
490
642
|
result = PaymentMethods.new.enroll(opts[:url], label: opts[:label],
|
|
491
643
|
scope: payment_enroll_scope(opts)) do |setup_url|
|
|
@@ -610,14 +762,86 @@ module Portage
|
|
|
610
762
|
end
|
|
611
763
|
private_class_method :format_payment_enroll
|
|
612
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
|
+
|
|
613
836
|
# --- doctor ---
|
|
614
837
|
|
|
615
838
|
def self.parse_doctor_options(argv)
|
|
616
|
-
opts = {}
|
|
839
|
+
opts = { proxy: {} }
|
|
617
840
|
OptionParser.new do |parser|
|
|
618
841
|
parser.on("--require FILE") { |v| opts[:require] = v }
|
|
619
842
|
parser.on("--adapter CLASS_NAME") { |v| opts[:adapter] = v }
|
|
620
843
|
parser.on("--json") { opts[:json] = true }
|
|
844
|
+
ProxySettings.add_options(parser, opts[:proxy])
|
|
621
845
|
end.parse!(argv)
|
|
622
846
|
opts
|
|
623
847
|
end
|
|
@@ -627,8 +851,10 @@ module Portage
|
|
|
627
851
|
opts = parse_doctor_options(argv)
|
|
628
852
|
require File.expand_path(opts[:require]) if opts[:require]
|
|
629
853
|
adapter_class = opts[:adapter] && Object.const_get(opts[:adapter])
|
|
854
|
+
proxy_settings = apply_proxy_settings(opts[:proxy])
|
|
855
|
+
return 1 unless proxy_settings
|
|
630
856
|
|
|
631
|
-
findings = Doctor.new(adapter_class: adapter_class).call
|
|
857
|
+
findings = Doctor.new(adapter_class: adapter_class, proxy_settings: proxy_settings).call
|
|
632
858
|
puts opts[:json] ? JSON.pretty_generate(findings.map(&:to_h)) : format_doctor(findings)
|
|
633
859
|
findings.empty? ? 0 : 1
|
|
634
860
|
end
|