portage-cli 0.7.0 → 0.7.4
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 +142 -0
- data/README.md +266 -17
- data/lib/portage/cli/buy.rb +187 -41
- data/lib/portage/cli/doctor.rb +130 -2
- 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/install_doctor.rb +239 -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/shipping_profile.rb +7 -2
- 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 +249 -15
- metadata +37 -6
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "uri"
|
|
3
|
+
require "portage/ucp"
|
|
4
|
+
require_relative "config"
|
|
5
|
+
require_relative "proxy_settings/password_ref"
|
|
6
|
+
|
|
7
|
+
module Portage
|
|
8
|
+
module Cli
|
|
9
|
+
# Phase 2 of docs/plans/proxy-support.md: resolves `--proxy*` flags,
|
|
10
|
+
# `PORTAGE_PROXY*` env vars, and `~/.portage/config.json`'s "proxy"
|
|
11
|
+
# section into the `Portage::Ucp::Support::ProxyConfig` Phase 1 already
|
|
12
|
+
# built — core stays exactly as ignorant of config.json as open decision
|
|
13
|
+
# 1 says it should (docs/plans/proxy-support.md). One instance per CLI
|
|
14
|
+
# invocation, same "resolved fresh, not frozen at load time" posture as
|
|
15
|
+
# UserAgent/Notifier's own Setting-backed resolution.
|
|
16
|
+
#
|
|
17
|
+
# Precedence is *per field*, not whole-object: a bare `--proxy URL`
|
|
18
|
+
# overrides only `default.url`, leaving config.json's `default.no_proxy`,
|
|
19
|
+
# `routes`, `chains`, etc. exactly as configured. See #build_default_spec
|
|
20
|
+
# and the `resolve3` calls throughout for where each field's own
|
|
21
|
+
# flag > env > config.json chain lives.
|
|
22
|
+
#
|
|
23
|
+
# The `payment` route is force-set to `:direct` unless the caller (flag,
|
|
24
|
+
# or config.json's `routes.payment`) names a proxy for it explicitly —
|
|
25
|
+
# the plan's "payment route defaults to direct" constraint isn't
|
|
26
|
+
# enforced by core's ProxyConfig#resolved_spec itself (an unlisted route
|
|
27
|
+
# falls back to "default" same as any other), so this class is where it
|
|
28
|
+
# actually holds. That also means portage-cli's own payment traffic no
|
|
29
|
+
# longer inherits a bare $http_proxy/$https_proxy the way every other
|
|
30
|
+
# unconfigured route still does (see #build_routes) — a deliberate
|
|
31
|
+
# narrowing to protect payment tokens from an egress proxy nobody meant
|
|
32
|
+
# to hand them to, not a bug.
|
|
33
|
+
class ProxySettings
|
|
34
|
+
# Raised instead of Portage::Ucp::Support::ProxyConfig::ConfigError —
|
|
35
|
+
# a CLI-level config problem (a bad flag, malformed config.json, an
|
|
36
|
+
# unresolvable password_ref) should read as portage-cli's own error,
|
|
37
|
+
# not leak a bare core exception class/message at the command line.
|
|
38
|
+
class ConfigError < StandardError; end
|
|
39
|
+
|
|
40
|
+
ENV_URL = "PORTAGE_PROXY".freeze
|
|
41
|
+
ENV_MODE = "PORTAGE_PROXY_MODE".freeze
|
|
42
|
+
ENV_NO_PROXY = "PORTAGE_NO_PROXY".freeze
|
|
43
|
+
ENV_CA = "PORTAGE_PROXY_CA".freeze
|
|
44
|
+
ENV_HEADERS = "PORTAGE_PROXY_HEADERS".freeze
|
|
45
|
+
|
|
46
|
+
ENV_INTERPOLATION = /\$\{([A-Za-z_][A-Za-z0-9_]*)\}/
|
|
47
|
+
|
|
48
|
+
ProxyConfig = Portage::Ucp::Support::ProxyConfig
|
|
49
|
+
|
|
50
|
+
# Adds every proxy flag to `parser`, accumulating into `target` under
|
|
51
|
+
# the keys #initialize's `flags:` expects — one call site shared by
|
|
52
|
+
# every network command's own option parser (buy/find/compare/doctor/
|
|
53
|
+
# payment enroll), per the plan's "sharing one helper".
|
|
54
|
+
def self.add_options(parser, target)
|
|
55
|
+
init_array_flags!(target)
|
|
56
|
+
add_scalar_options(parser, target)
|
|
57
|
+
add_repeatable_options(parser, target)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def self.init_array_flags!(target)
|
|
61
|
+
target[:proxy_headers] ||= []
|
|
62
|
+
target[:proxy_routes] ||= []
|
|
63
|
+
target[:proxy_passthrough] ||= []
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def self.add_scalar_options(parser, target)
|
|
67
|
+
parser.on("--proxy URL") { |v| target[:proxy] = v }
|
|
68
|
+
parser.on("--proxy-mode MODE") { |v| target[:proxy_mode] = v }
|
|
69
|
+
parser.on("--no-proxy HOSTS") { |v| target[:no_proxy] = v }
|
|
70
|
+
parser.on("--proxy-chain CHAIN") { |v| target[:proxy_chain] = v }
|
|
71
|
+
parser.on("--proxy-ca FILE") { |v| target[:proxy_ca] = v }
|
|
72
|
+
parser.on("--no-env-proxy") { target[:no_env_proxy] = true }
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def self.add_repeatable_options(parser, target)
|
|
76
|
+
parser.on("--proxy-header HEADER") { |v| target[:proxy_headers] << v }
|
|
77
|
+
parser.on("--proxy-route ROUTE") { |v| target[:proxy_routes] << v }
|
|
78
|
+
parser.on("--proxy-passthrough HEADER") { |v| target[:proxy_passthrough] << v }
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# @param value [String] a header value, or any other config string.
|
|
82
|
+
def self.expand_env(value)
|
|
83
|
+
return value unless value.is_a?(String)
|
|
84
|
+
|
|
85
|
+
value.gsub(ENV_INTERPOLATION) { ENV.fetch(::Regexp.last_match(1), "") }
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def self.expand_env_hash(hash)
|
|
89
|
+
hash.to_h { |key, value| [key.to_s, expand_env(value)] }
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def self.parse_header_flag(raw)
|
|
93
|
+
name, value = raw.to_s.split(":", 2)
|
|
94
|
+
raise ConfigError, "--proxy-header must be \"Name: value\" (got #{raw.inspect})" unless name && value
|
|
95
|
+
|
|
96
|
+
[name.strip, value.strip]
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def self.resolve(flags: {}, config: Config.load) = new(flags: flags, config: config).resolve
|
|
100
|
+
|
|
101
|
+
def initialize(flags: {}, config: Config.load)
|
|
102
|
+
@flags = flags
|
|
103
|
+
@config = config
|
|
104
|
+
@proxy_json = config.get("proxy") || {}
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# @return [Portage::Ucp::Support::ProxyConfig]
|
|
108
|
+
# @raise [ConfigError]
|
|
109
|
+
def resolve
|
|
110
|
+
@resolve ||= build!
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# @return [Hash] route name -> resolved spec (a ProxyConfig::Profile,
|
|
114
|
+
# or an Array chain of them — Profile::DIRECT for "direct") — the
|
|
115
|
+
# same shape #resolve handed to ProxyConfig.new, kept around for
|
|
116
|
+
# `doctor`'s effective-routing display.
|
|
117
|
+
def routes
|
|
118
|
+
resolve
|
|
119
|
+
@routes
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# @return [Hash] `{"add" => {...}, "strip" => [...]}` from
|
|
123
|
+
# config.json's `proxy.default.forward_headers`, ${ENV}-expanded and
|
|
124
|
+
# protected-header-checked — parsed and stored, matching how the
|
|
125
|
+
# plan's `passthrough`/`inbound` sections are handled below, since
|
|
126
|
+
# core's Support::Connection has nowhere to apply an add/strip
|
|
127
|
+
# rewrite to the real target yet (that's request-building work at
|
|
128
|
+
# every call site, out of Phase 2's "CLI config + flags + doctor"
|
|
129
|
+
# scope — see the handoff report).
|
|
130
|
+
def forward_headers
|
|
131
|
+
resolve
|
|
132
|
+
@forward_headers
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# @return [Hash] config.json's `proxy.passthrough`, verbatim (Phase 3
|
|
136
|
+
# reads this; Phase 2 only parses and stores it).
|
|
137
|
+
def passthrough
|
|
138
|
+
resolve
|
|
139
|
+
@passthrough
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# @return [Hash] config.json's `proxy.inbound`, verbatim (Phase 3
|
|
143
|
+
# reads this; Phase 2 only parses and stores it).
|
|
144
|
+
def inbound
|
|
145
|
+
resolve
|
|
146
|
+
@inbound
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def no_env_proxy? = !!@flags[:no_env_proxy]
|
|
150
|
+
|
|
151
|
+
private
|
|
152
|
+
|
|
153
|
+
def build!
|
|
154
|
+
@default_spec = build_default_spec
|
|
155
|
+
@default_spec = parse_chain_flag(@flags[:proxy_chain]) if present?(@flags[:proxy_chain])
|
|
156
|
+
@routes = build_routes
|
|
157
|
+
@forward_headers = forward_headers_meta
|
|
158
|
+
@passthrough = stringify(@proxy_json["passthrough"] || {})
|
|
159
|
+
@inbound = stringify(@proxy_json["inbound"] || {})
|
|
160
|
+
ProxyConfig.new(routes: @routes, no_proxy: no_proxy_list)
|
|
161
|
+
rescue ProxyConfig::ConfigError => e
|
|
162
|
+
raise ConfigError, "invalid proxy configuration: #{e.message}"
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# --- default profile (the flag/env/config.json "default.*" fields) --
|
|
166
|
+
|
|
167
|
+
def build_default_spec
|
|
168
|
+
url = resolve3(@flags[:proxy], ENV_URL, @proxy_json.dig("default", "url"))
|
|
169
|
+
return nil unless present?(url)
|
|
170
|
+
|
|
171
|
+
raw = @proxy_json["default"] || {}
|
|
172
|
+
url = apply_password_ref(url, raw["password_ref"])
|
|
173
|
+
mode = resolve3(@flags[:proxy_mode], ENV_MODE, raw["mode"])
|
|
174
|
+
ca_file = resolve3(@flags[:proxy_ca], ENV_CA, raw["ca_file"])
|
|
175
|
+
build_profile(raw, url: url, mode: mode, proxy_headers: default_proxy_headers, ca_file: ca_file)
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def default_proxy_headers
|
|
179
|
+
headers = self.class.expand_env_hash(stringify(@proxy_json.dig("default", "proxy_headers") || {}))
|
|
180
|
+
headers.merge!(env_headers)
|
|
181
|
+
headers.merge!(flag_headers)
|
|
182
|
+
headers
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def env_headers
|
|
186
|
+
raw = ENV.fetch(ENV_HEADERS, nil)
|
|
187
|
+
return {} unless present?(raw)
|
|
188
|
+
|
|
189
|
+
parsed = JSON.parse(raw)
|
|
190
|
+
parsed.is_a?(Hash) ? stringify(parsed) : {}
|
|
191
|
+
rescue JSON::ParserError => e
|
|
192
|
+
raise ConfigError, "#{ENV_HEADERS} is not valid JSON: #{e.message}"
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def flag_headers
|
|
196
|
+
Array(@flags[:proxy_headers]).to_h { |raw| self.class.parse_header_flag(raw) }
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# --- forward_headers (parsed + protected-header-checked, not yet
|
|
200
|
+
# applied to any outgoing request — see #forward_headers above) ------
|
|
201
|
+
|
|
202
|
+
def forward_headers_meta
|
|
203
|
+
raw = @proxy_json.dig("default", "forward_headers") || {}
|
|
204
|
+
add = self.class.expand_env_hash(stringify(raw["add"] || {}))
|
|
205
|
+
strip = Array(raw["strip"]).map(&:to_s)
|
|
206
|
+
ProxyConfig.validate_headers!(add)
|
|
207
|
+
refuse_protected_strip!(strip)
|
|
208
|
+
{ "add" => add, "strip" => strip }
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def refuse_protected_strip!(strip)
|
|
212
|
+
offender = strip.find { |name| ProxyConfig.protected_header?(name) }
|
|
213
|
+
return unless offender
|
|
214
|
+
|
|
215
|
+
raise ProxyConfig::ConfigError,
|
|
216
|
+
"#{offender} is a protected header and cannot be stripped via forward_headers"
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# --- no_proxy ---------------------------------------------------
|
|
220
|
+
|
|
221
|
+
def no_proxy_list
|
|
222
|
+
flag = @flags[:no_proxy]
|
|
223
|
+
return split_list(flag) if present?(flag)
|
|
224
|
+
|
|
225
|
+
env = ENV.fetch(ENV_NO_PROXY, nil)
|
|
226
|
+
return split_list(env) if present?(env)
|
|
227
|
+
|
|
228
|
+
Array(@proxy_json.dig("default", "no_proxy"))
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def split_list(value) = value.to_s.split(",").map(&:strip).reject(&:empty?)
|
|
232
|
+
|
|
233
|
+
# --- routes -------------------------------------------------------
|
|
234
|
+
|
|
235
|
+
def build_routes
|
|
236
|
+
routes = {}
|
|
237
|
+
@proxy_json.fetch("routes", {}).each { |route, spec| routes[route.to_s] = resolve_route_spec(spec) }
|
|
238
|
+
routes["default"] = @default_spec if @default_spec
|
|
239
|
+
Array(@flags[:proxy_routes]).each { |raw| apply_route_flag!(routes, raw) }
|
|
240
|
+
routes["payment"] = ProxyConfig::Profile::DIRECT unless routes.key?("payment")
|
|
241
|
+
routes["default"] = ProxyConfig::Profile::DIRECT if no_env_proxy? && !routes.key?("default")
|
|
242
|
+
routes
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def resolve_route_spec(spec)
|
|
246
|
+
case spec
|
|
247
|
+
when Hash then resolve_route_hash(spec)
|
|
248
|
+
when Array then spec.map { |hop| resolve_route_spec(hop) }
|
|
249
|
+
when String then resolve_route_string(spec)
|
|
250
|
+
else raise ConfigError, "invalid proxy route value #{spec.inspect}"
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def resolve_route_hash(spec)
|
|
255
|
+
spec["chain"] ? resolve_chain_name(spec["chain"]) : resolve_inline_profile(spec)
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def resolve_route_string(spec)
|
|
259
|
+
return ProxyConfig::Profile::DIRECT if spec == "direct"
|
|
260
|
+
return @default_spec || ProxyConfig::Profile::DIRECT if spec == "default"
|
|
261
|
+
return resolve_inline_profile("url" => spec) if spec.include?("://")
|
|
262
|
+
|
|
263
|
+
resolve_chain_name(spec)
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
def resolve_chain_name(name)
|
|
267
|
+
chain = @proxy_json.dig("chains", name)
|
|
268
|
+
raise ConfigError, "unknown proxy profile or chain #{name.inspect}" unless chain
|
|
269
|
+
|
|
270
|
+
Array(chain).map { |hop| resolve_inline_profile(hop) }
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
def resolve_inline_profile(raw)
|
|
274
|
+
url = apply_password_ref(raw["url"], raw["password_ref"])
|
|
275
|
+
headers = self.class.expand_env_hash(stringify(raw["proxy_headers"] || {}))
|
|
276
|
+
build_profile(raw, url: url, mode: raw["mode"], proxy_headers: headers)
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
def apply_route_flag!(routes, raw)
|
|
280
|
+
route, value = raw.to_s.split("=", 2)
|
|
281
|
+
raise ConfigError, "--proxy-route must be ROUTE=VALUE (got #{raw.inspect})" unless route && value
|
|
282
|
+
|
|
283
|
+
routes[route.strip] = route_value_from_flag(value.strip)
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
def route_value_from_flag(value)
|
|
287
|
+
return ProxyConfig::Profile::DIRECT if value == "direct"
|
|
288
|
+
return @default_spec || ProxyConfig::Profile::DIRECT if value == "default"
|
|
289
|
+
return ProxyConfig::Profile.new(url: value) if value.include?("://")
|
|
290
|
+
|
|
291
|
+
raise ConfigError, "--proxy-route: expected a URL or \"direct\" (got #{value.inspect})"
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
def parse_chain_flag(raw)
|
|
295
|
+
raw.to_s.split(",").map { |hop| chain_hop_from_flag(hop.strip) }
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
def chain_hop_from_flag(hop)
|
|
299
|
+
return ProxyConfig::Profile.new(mode: :gateway, url: hop.sub(/\Agateway\+/, "")) if hop.start_with?("gateway+")
|
|
300
|
+
|
|
301
|
+
ProxyConfig::Profile.new(mode: :forward, url: hop)
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
# --- shared profile builder ------------------------------------------
|
|
305
|
+
|
|
306
|
+
# Builds a real ProxyConfig::Profile (not a plain Hash core would only
|
|
307
|
+
# coerce lazily, on first #chain_for) so a protected header or a bad
|
|
308
|
+
# mode/url raises right here, at config-load time, from any of the
|
|
309
|
+
# three sources (flag/env/config.json) that fed `proxy_headers` —
|
|
310
|
+
# exactly what the plan's "Protected headers" constraint and this
|
|
311
|
+
# phase's own step 8 require, rather than surfacing on the first real
|
|
312
|
+
# request Support::Connection makes.
|
|
313
|
+
def build_profile(raw, url:, mode:, proxy_headers:, ca_file: raw["ca_file"])
|
|
314
|
+
kwargs = { url: url, proxy_headers: proxy_headers, ca_file: ca_file,
|
|
315
|
+
target_header: raw["target_header"], target_param: raw["target_param"] }.compact
|
|
316
|
+
kwargs[:mode] = mode.to_sym if present?(mode)
|
|
317
|
+
ProxyConfig::Profile.new(**kwargs)
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
def apply_password_ref(url, password_ref)
|
|
321
|
+
return url unless present?(url) && present?(password_ref)
|
|
322
|
+
|
|
323
|
+
secret = PasswordRef.resolve(password_ref)
|
|
324
|
+
unless secret
|
|
325
|
+
raise ConfigError, "proxy password_ref #{password_ref.inspect} could not be resolved from any local " \
|
|
326
|
+
"secret backend (macOS Keychain, Linux Secret Service)"
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
uri = URI(url)
|
|
330
|
+
uri.password = URI.encode_www_form_component(secret)
|
|
331
|
+
uri.to_s
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
# --- misc -----------------------------------------------------------
|
|
335
|
+
|
|
336
|
+
def resolve3(flag, env_name, config_value)
|
|
337
|
+
return flag if present?(flag)
|
|
338
|
+
|
|
339
|
+
from_env = env_name && ENV.fetch(env_name, nil)
|
|
340
|
+
return from_env if present?(from_env)
|
|
341
|
+
|
|
342
|
+
config_value if present?(config_value)
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
def present?(value) = !(value.nil? || (value.is_a?(String) && value.strip.empty?))
|
|
346
|
+
|
|
347
|
+
def stringify(hash) = hash.to_h { |key, value| [key.to_s, value] }
|
|
348
|
+
end
|
|
349
|
+
end
|
|
350
|
+
end
|
|
@@ -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
|
|
@@ -20,9 +20,14 @@ module Portage
|
|
|
20
20
|
|
|
21
21
|
# @return [Portage::Ucp::PostalAddress, nil] nil unless every required
|
|
22
22
|
# field is set — a partial profile isn't enough to submit, and this
|
|
23
|
-
# module never guesses at a missing field.
|
|
23
|
+
# module never guesses at a missing field. An empty value counts as
|
|
24
|
+
# unset, so a `.env` copied from `.env.example` with blanks left in
|
|
25
|
+
# doesn't submit an address of empty strings.
|
|
24
26
|
def self.from_env
|
|
25
|
-
attrs = ENV_VARS.filter_map
|
|
27
|
+
attrs = ENV_VARS.filter_map do |key, var|
|
|
28
|
+
value = ENV.fetch(var, nil)
|
|
29
|
+
[key, value] unless value.to_s.empty?
|
|
30
|
+
end.to_h
|
|
26
31
|
return nil unless REQUIRED.all? { |key| attrs.key?(key) }
|
|
27
32
|
|
|
28
33
|
Portage::Ucp::PostalAddress.new(**attrs)
|
|
@@ -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
|