portage-cli 0.7.5 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +616 -0
- data/README.md +302 -5
- data/known-stores/categories.yml +1263 -0
- data/lib/portage/cli/agent_profile_url.rb +30 -0
- data/lib/portage/cli/approval_policy.rb +56 -0
- data/lib/portage/cli/approve.rb +135 -0
- data/lib/portage/cli/browser_import/categorize.rb +59 -0
- data/lib/portage/cli/browser_import/confirm.rb +35 -0
- data/lib/portage/cli/browser_import/domains.rb +47 -0
- data/lib/portage/cli/browser_import/filter.rb +91 -0
- data/lib/portage/cli/browser_import/importer.rb +248 -0
- data/lib/portage/cli/browser_import/plist_xml.rb +72 -0
- data/lib/portage/cli/browser_import/prober.rb +60 -0
- data/lib/portage/cli/browser_import/profiles.rb +114 -0
- data/lib/portage/cli/browser_import/readers.rb +179 -0
- data/lib/portage/cli/browser_import/saver.rb +62 -0
- data/lib/portage/cli/browser_import/sqlite.rb +68 -0
- data/lib/portage/cli/browser_import.rb +23 -0
- data/lib/portage/cli/browser_opener.rb +38 -0
- data/lib/portage/cli/browser_profile/allowlist.rb +40 -0
- data/lib/portage/cli/browser_profile/bridge.rb +120 -0
- data/lib/portage/cli/browser_profile/browsers.rb +69 -0
- data/lib/portage/cli/browser_profile/cdp.rb +67 -0
- data/lib/portage/cli/browser_profile/cdp_socket.rb +186 -0
- data/lib/portage/cli/browser_profile/errors.rb +26 -0
- data/lib/portage/cli/browser_profile/launcher.rb +34 -0
- data/lib/portage/cli/browser_profile/profile.rb +93 -0
- data/lib/portage/cli/browser_profile.rb +25 -0
- data/lib/portage/cli/buy.rb +559 -29
- data/lib/portage/cli/checkout_handoff.rb +5 -24
- data/lib/portage/cli/classifier.rb +158 -0
- data/lib/portage/cli/compare.rb +3 -0
- data/lib/portage/cli/doctor.rb +155 -1
- data/lib/portage/cli/dot_env.rb +55 -0
- data/lib/portage/cli/find.rb +103 -12
- data/lib/portage/cli/handoff_agents.rb +186 -0
- data/lib/portage/cli/handoff_only.rb +94 -0
- data/lib/portage/cli/handoff_reconciler.rb +15 -1
- data/lib/portage/cli/handoff_target.rb +61 -0
- data/lib/portage/cli/history.rb +47 -2
- data/lib/portage/cli/human_prompt.rb +118 -0
- data/lib/portage/cli/index/builder.rb +335 -0
- data/lib/portage/cli/index/exporter.rb +91 -0
- data/lib/portage/cli/index/known_cache.rb +155 -0
- data/lib/portage/cli/index/product_store.rb +101 -0
- data/lib/portage/cli/index/sources/browser.rb +31 -0
- data/lib/portage/cli/index/sources/shopify_catalog.rb +82 -0
- data/lib/portage/cli/index/sources/stores_file.rb +58 -0
- data/lib/portage/cli/index/sources/webmcp_sweep.rb +29 -0
- data/lib/portage/cli/index/sources/wikidata.rb +95 -0
- data/lib/portage/cli/index/sources.rb +44 -0
- data/lib/portage/cli/index/store.rb +109 -0
- data/lib/portage/cli/index.rb +20 -0
- data/lib/portage/cli/known_stores_url.rb +15 -0
- data/lib/portage/cli/money.rb +18 -0
- data/lib/portage/cli/offer_choice.rb +38 -0
- data/lib/portage/cli/offer_sources.rb +460 -0
- data/lib/portage/cli/payment_methods.rb +24 -3
- data/lib/portage/cli/pick.rb +167 -0
- data/lib/portage/cli/product_page.rb +84 -0
- data/lib/portage/cli/quotes.rb +82 -0
- data/lib/portage/cli/search_backends.rb +337 -12
- data/lib/portage/cli/setup_wizard/prompt.rb +67 -0
- data/lib/portage/cli/setup_wizard/steps/agent_profile.rb +60 -0
- data/lib/portage/cli/setup_wizard/steps/browser_import.rb +29 -0
- data/lib/portage/cli/setup_wizard/steps/handoff.rb +100 -0
- data/lib/portage/cli/setup_wizard/steps/index_build.rb +31 -0
- data/lib/portage/cli/setup_wizard/steps/policy.rb +60 -0
- data/lib/portage/cli/setup_wizard/steps/retailer_keys.rb +55 -0
- data/lib/portage/cli/setup_wizard/steps/search_keys.rb +54 -0
- data/lib/portage/cli/setup_wizard/steps/shipping.rb +51 -0
- data/lib/portage/cli/setup_wizard.rb +74 -0
- data/lib/portage/cli/version.rb +1 -1
- data/lib/portage/cli/webmcp.rb +10 -3
- data/lib/portage/cli/webmcp_autofill_confirm.rb +38 -0
- data/lib/portage/cli/webmcp_autofill_fields.rb +60 -0
- data/lib/portage/cli/webmcp_autofill_mode.rb +39 -0
- data/lib/portage/cli/webmcp_mapping_confirm.rb +68 -0
- data/lib/portage/cli/webmcp_mappings.rb +84 -0
- data/lib/portage/cli.rb +930 -43
- metadata +67 -2
data/lib/portage/cli.rb
CHANGED
|
@@ -1,18 +1,35 @@
|
|
|
1
1
|
require "optparse"
|
|
2
2
|
require "json"
|
|
3
|
+
require "uri"
|
|
3
4
|
|
|
4
5
|
require_relative "cli/version"
|
|
5
6
|
require_relative "cli/user_agent"
|
|
6
7
|
require_relative "cli/shipping_profile"
|
|
7
8
|
require_relative "cli/buyer_context"
|
|
8
9
|
require_relative "cli/catalog_products"
|
|
10
|
+
require_relative "cli/agent_profile_url"
|
|
11
|
+
require_relative "cli/offer_sources"
|
|
12
|
+
require_relative "cli/index"
|
|
13
|
+
require_relative "cli/browser_import"
|
|
14
|
+
require_relative "cli/browser_profile"
|
|
9
15
|
require_relative "cli/buy"
|
|
10
16
|
require_relative "cli/find"
|
|
11
17
|
require_relative "cli/compare"
|
|
12
18
|
require_relative "cli/history"
|
|
19
|
+
require_relative "cli/quotes"
|
|
20
|
+
require_relative "cli/money"
|
|
21
|
+
require_relative "cli/human_prompt"
|
|
22
|
+
require_relative "cli/product_page"
|
|
23
|
+
require_relative "cli/approval_policy"
|
|
24
|
+
require_relative "cli/offer_choice"
|
|
25
|
+
require_relative "cli/pick"
|
|
26
|
+
require_relative "cli/approve"
|
|
13
27
|
require_relative "cli/payment_methods"
|
|
14
28
|
require_relative "cli/proxy_settings"
|
|
29
|
+
require_relative "cli/handoff_only"
|
|
30
|
+
require_relative "cli/handoff_target"
|
|
15
31
|
require_relative "cli/doctor"
|
|
32
|
+
require_relative "cli/setup_wizard"
|
|
16
33
|
require_relative "cli/handoff_reconciler"
|
|
17
34
|
require_relative "cli/handoff_waiter"
|
|
18
35
|
require_relative "cli/reconcile_notifier"
|
|
@@ -30,12 +47,18 @@ module Portage
|
|
|
30
47
|
usage: portage buy <url> --query "..." [--qty N] [--payment-token TOKEN]
|
|
31
48
|
[--product-id ID] [--yes] [--dry-run]
|
|
32
49
|
[--auto-open|--no-auto-open] [--notify-webhook URL]
|
|
50
|
+
[--handoff-target default|print|profile|agent:NAME]
|
|
33
51
|
[--decision-backend jev|laya] [--min-confidence N] [--json]
|
|
34
52
|
[--wait [--wait-timeout DURATION|off]]
|
|
53
|
+
portage buy --offer REF [--qty N] [--yes] [--dry-run] ...
|
|
54
|
+
portage buy --quote QUOTE_ID --yes [--json] ...
|
|
35
55
|
portage buy --query "..." [--store URL] [--max-price N] [--limit N] ...
|
|
36
56
|
portage find --query "..." [--max-price N] [--limit N] [--json]
|
|
37
57
|
portage compare <url> --product-id ID [--id VALUE ...] [--results N]
|
|
38
58
|
[--max-price N] [--json]
|
|
59
|
+
portage pick [--search LAST|SEARCH_ID] [--via auto|tty|agent] [--json]
|
|
60
|
+
[--choose REF | --compare REF | --view REF]
|
|
61
|
+
portage approve QUOTE_ID [--via auto|tty|agent] [--relayed-yes | --view] [--json]
|
|
39
62
|
portage history [list] [--purchases|--searches] [--limit N] [--json]
|
|
40
63
|
portage history clear [--purchases|--searches]
|
|
41
64
|
portage payment list [--json]
|
|
@@ -50,10 +73,22 @@ module Portage
|
|
|
50
73
|
[--rolling-cap N --rolling-window-seconds N --currency CUR]
|
|
51
74
|
[--velocity-count N --velocity-window-seconds N]
|
|
52
75
|
[--allow HOST ...] [--clear-allowlist]
|
|
76
|
+
[--require-approval person|any|off] (lowering asks at a terminal)
|
|
53
77
|
portage orders reconcile [--checkout ID] [--json]
|
|
78
|
+
portage index build [--sources a,b] [--queries FILE] [--dry-run] [--export DIR] [--json]
|
|
79
|
+
portage index refresh [--sources a,b] [--queries FILE] [--dry-run] [--export DIR] [--json]
|
|
80
|
+
portage index show [--stores|--products] [--json]
|
|
81
|
+
portage index add <url> [--json]
|
|
82
|
+
portage index remove <host> [--json]
|
|
83
|
+
portage index sources [--json]
|
|
84
|
+
portage browser import [--browser chrome|edge|brave|arc|firefox|safari] [--profile-root DIR]
|
|
85
|
+
[--history-days 90] [--include-product-pages] [--max-probes 200]
|
|
86
|
+
[--exclude HOST,HOST] [--dry-run] [--yes] [--json]
|
|
87
|
+
portage browser profile init|open|status [--browser chrome|edge|brave|arc] [--port N]
|
|
88
|
+
[--url URL (open only)] [--json]
|
|
54
89
|
portage doctor [--require FILE] [--adapter CLASS_NAME] [--json]
|
|
55
90
|
portage configure [--require FILE] [--adapter CLASS_NAME] [--json] (alias for doctor)
|
|
56
|
-
portage setup [--
|
|
91
|
+
portage setup [--json] (interactive wizard on a TTY; --json/no TTY: today's doctor report)
|
|
57
92
|
portage generate adapter NAME [--dir DIR]
|
|
58
93
|
portage generate agent-profile [--out FILE] [--key-out FILE] [--rotate]
|
|
59
94
|
portage --version
|
|
@@ -65,9 +100,11 @@ module Portage
|
|
|
65
100
|
USAGE
|
|
66
101
|
|
|
67
102
|
COMMANDS = { "buy" => :run_buy, "find" => :run_find, "compare" => :run_compare,
|
|
103
|
+
"pick" => :run_pick, "approve" => :run_approve,
|
|
68
104
|
"history" => :run_history, "payment" => :run_payment, "policy" => :run_policy,
|
|
69
|
-
"orders" => :run_orders, "
|
|
70
|
-
"
|
|
105
|
+
"orders" => :run_orders, "index" => :run_index, "browser" => :run_browser,
|
|
106
|
+
"doctor" => :run_doctor,
|
|
107
|
+
"configure" => :run_doctor, "setup" => :run_setup, "generate" => :run_generate }.freeze
|
|
71
108
|
|
|
72
109
|
VERSION_FLAGS = %w[--version -v version].freeze
|
|
73
110
|
|
|
@@ -120,18 +157,26 @@ module Portage
|
|
|
120
157
|
return 1 unless apply_proxy_settings(options.delete(:proxy))
|
|
121
158
|
|
|
122
159
|
report = Find.new(**options).call
|
|
123
|
-
record_find(report)
|
|
160
|
+
report = with_search_id(report, record_find(report))
|
|
124
161
|
puts json ? JSON.pretty_generate(report) : format_find(report)
|
|
125
162
|
report[:offers].any? ? 0 : 1
|
|
126
163
|
end
|
|
127
164
|
private_class_method :run_find
|
|
128
165
|
|
|
166
|
+
# @return [Hash, nil] the saved search entry.
|
|
129
167
|
def self.record_find(report)
|
|
130
168
|
History.new.record_search(query: report[:query], offer_count: report[:offers].length,
|
|
131
|
-
message: report[:message])
|
|
169
|
+
message: report[:message], offers: report[:offers])
|
|
132
170
|
end
|
|
133
171
|
private_class_method :record_find
|
|
134
172
|
|
|
173
|
+
# docs/plans/human-pick-and-approve.md Phase 2: the saved search's id,
|
|
174
|
+
# for `portage pick --search`. Additive; absent when nothing was saved.
|
|
175
|
+
def self.with_search_id(report, entry)
|
|
176
|
+
entry.is_a?(Hash) && entry["search_id"] ? report.merge(search_id: entry["search_id"]) : report
|
|
177
|
+
end
|
|
178
|
+
private_class_method :with_search_id
|
|
179
|
+
|
|
135
180
|
def self.parse_find_options(argv)
|
|
136
181
|
opts = {}
|
|
137
182
|
find_option_parser(opts).parse!(argv)
|
|
@@ -173,16 +218,26 @@ module Portage
|
|
|
173
218
|
return 1 unless apply_proxy_settings(options.delete(:proxy))
|
|
174
219
|
|
|
175
220
|
report = Compare.new(origin_url: url, **options).call
|
|
176
|
-
|
|
177
|
-
# query string names the compare so `portage history list` doesn't
|
|
178
|
-
# read it as a plain text search for the origin product's own title.
|
|
179
|
-
History.new.record_search(query: "compare: #{url} (product #{options[:origin_product_id]})",
|
|
180
|
-
offer_count: report[:offers].length, message: report[:message])
|
|
221
|
+
report = with_search_id(report, record_compare(url, options[:origin_product_id], report))
|
|
181
222
|
puts json ? JSON.pretty_generate(report) : format_compare(report)
|
|
182
223
|
report[:offers].any? ? 0 : 1
|
|
183
224
|
end
|
|
184
225
|
private_class_method :run_compare
|
|
185
226
|
|
|
227
|
+
# Recorded as a search, not a purchase — compare never checks out. The
|
|
228
|
+
# query string names the compare so `portage history list` doesn't
|
|
229
|
+
# read it as a plain text search for the origin product's own title.
|
|
230
|
+
# Its offers are kept with their refs, like `find`'s
|
|
231
|
+
# (docs/plans/human-pick-and-approve.md Phase 2), each carrying the
|
|
232
|
+
# catalog query compare searched with (the origin product's title), so
|
|
233
|
+
# `buy --offer REF` searches for the product, not for "compare: ...".
|
|
234
|
+
def self.record_compare(url, product_id, report)
|
|
235
|
+
offers = report[:offers].map { |offer| offer.merge(query: report[:query]) }
|
|
236
|
+
History.new.record_search(query: "compare: #{url} (product #{product_id})", offer_count: offers.length,
|
|
237
|
+
message: report[:message], offers: offers)
|
|
238
|
+
end
|
|
239
|
+
private_class_method :record_compare
|
|
240
|
+
|
|
186
241
|
def self.parse_compare_options(argv)
|
|
187
242
|
url = argv.first && !argv.first.start_with?("-") ? argv.shift : nil
|
|
188
243
|
opts = { identity: [] }
|
|
@@ -220,56 +275,298 @@ module Portage
|
|
|
220
275
|
url = parsed[:buy][:url] || parsed[:store]
|
|
221
276
|
parsed[:confidence_check] = confidence_check(parsed, url)
|
|
222
277
|
return 1 unless parsed[:confidence_check]
|
|
278
|
+
|
|
279
|
+
parsed[:handoff_target] = handoff_target(parsed, url)
|
|
280
|
+
return 1 unless parsed[:handoff_target]
|
|
281
|
+
|
|
282
|
+
dispatch_buy(parsed, url)
|
|
283
|
+
end
|
|
284
|
+
private_class_method :run_buy
|
|
285
|
+
|
|
286
|
+
# A saved quote or offer names its own store; otherwise a url does, and
|
|
287
|
+
# with neither the search picks one.
|
|
288
|
+
def self.dispatch_buy(parsed, url)
|
|
289
|
+
return buy_from_quote(parsed) if parsed[:quote]
|
|
290
|
+
return buy_from_offer(parsed) if parsed[:offer]
|
|
223
291
|
return execute_buy(parsed, url) if url
|
|
224
292
|
|
|
225
293
|
buy_from_search(parsed)
|
|
226
294
|
end
|
|
227
|
-
private_class_method :
|
|
295
|
+
private_class_method :dispatch_buy
|
|
296
|
+
|
|
297
|
+
# Built and validated up front, same posture as #confidence_check — an
|
|
298
|
+
# unknown --handoff-target/PORTAGE_HANDOFF_TARGET/config.json value is a
|
|
299
|
+
# usage error, reported before a checkout is ever attempted rather than
|
|
300
|
+
# discovered mid hand-off (docs/plans/buy-skill-and-local-browser.md
|
|
301
|
+
# Phase 5).
|
|
302
|
+
def self.handoff_target(parsed, url)
|
|
303
|
+
HandoffTarget.new(override: parsed[:buy][:handoff_target])
|
|
304
|
+
rescue ArgumentError => e
|
|
305
|
+
invalid_buy_option(e.message, url: url, json: parsed[:json])
|
|
306
|
+
end
|
|
307
|
+
private_class_method :handoff_target
|
|
228
308
|
|
|
229
309
|
# `portage buy` with no URL: search first, then buy from the store the
|
|
230
310
|
# caller picks. `--yes` alone deliberately isn't enough to get here —
|
|
231
311
|
# without a URL the merchant would have been chosen by a search ranker
|
|
232
312
|
# rather than by a person, so either `--store` (handled above) or an
|
|
233
|
-
# interactive pick has to name it.
|
|
313
|
+
# interactive pick has to name it. Runs with no terminal (or under
|
|
314
|
+
# --json) list the offers and stop.
|
|
234
315
|
def self.buy_from_search(parsed)
|
|
235
316
|
report = Find.new(**parsed[:find]).call
|
|
236
|
-
record_find(report)
|
|
317
|
+
report = with_search_id(report, record_find(report))
|
|
237
318
|
offer = pick_offer(report, parsed[:json])
|
|
238
319
|
return report[:offers].any? ? 0 : 1 unless offer
|
|
239
320
|
|
|
321
|
+
parsed[:offer_ref] = offer[:offer_ref]
|
|
322
|
+
parsed[:page] = { title: offer[:title], url: offer[:url] }
|
|
240
323
|
execute_buy(parsed, offer[:store], product_id: offer[:product_id])
|
|
241
324
|
end
|
|
242
325
|
private_class_method :buy_from_search
|
|
243
326
|
|
|
327
|
+
# `--offer REF`: the store, product and query come from the saved
|
|
328
|
+
# `find` that produced the ref, as if they'd been passed as flags.
|
|
329
|
+
def self.buy_from_offer(parsed)
|
|
330
|
+
offer = History.new.offer(parsed[:offer])
|
|
331
|
+
unless offer
|
|
332
|
+
invalid_buy_option("No saved offer #{parsed[:offer]} — run `portage find` again.",
|
|
333
|
+
url: nil, json: parsed[:json], outcome: "offer_not_found")
|
|
334
|
+
return 1
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
parsed[:offer_ref] = parsed[:offer]
|
|
338
|
+
parsed[:page] = { title: offer["title"], url: offer["url"] }
|
|
339
|
+
parsed[:buy][:query] = offer["query"].to_s
|
|
340
|
+
execute_buy(parsed, offer["store"], product_id: offer["product_id"])
|
|
341
|
+
end
|
|
342
|
+
private_class_method :buy_from_offer
|
|
343
|
+
|
|
344
|
+
# `--quote QUOTE_ID`: buys what a `--dry-run` showed. Buy is handed the
|
|
345
|
+
# quoted total as a cap and refuses (`quote_changed`) if the real
|
|
346
|
+
# checkout costs more, so the person's approval always covers the total
|
|
347
|
+
# that gets charged. The quote is spent by #settle_quote, once the run
|
|
348
|
+
# purchases or hands off.
|
|
349
|
+
#
|
|
350
|
+
# docs/plans/human-pick-and-approve.md Phase 2: a real run (`--yes`, not
|
|
351
|
+
# `--dry-run`) of a quote that isn't approved enough for
|
|
352
|
+
# `--require-approval` is refused with `needs_approval` before Buy
|
|
353
|
+
# runs, so it never charges and never hands off.
|
|
354
|
+
def self.buy_from_quote(parsed)
|
|
355
|
+
quote = usable_quote(parsed)
|
|
356
|
+
return 1 unless quote
|
|
357
|
+
|
|
358
|
+
level = ApprovalPolicy.level
|
|
359
|
+
if real_run?(parsed) && !ApprovalPolicy.satisfied?(quote, level)
|
|
360
|
+
return refuse_unapproved_quote(parsed, quote, level)
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
parsed[:quote_record] = quote
|
|
364
|
+
buy = parsed[:buy]
|
|
365
|
+
buy.merge!(qty: quote["qty"], product_id: quote["product_id"], query: quote["query"].to_s)
|
|
366
|
+
buy.merge!(quote_total: quote["total"], quote_currency: quote["currency"])
|
|
367
|
+
execute_buy(parsed, quote["store"])
|
|
368
|
+
end
|
|
369
|
+
private_class_method :buy_from_quote
|
|
370
|
+
|
|
371
|
+
def self.usable_quote(parsed)
|
|
372
|
+
quote = Quotes.new.find(parsed[:quote])
|
|
373
|
+
unless quote
|
|
374
|
+
return invalid_buy_option("No saved quote #{parsed[:quote]} — run `portage buy ... --dry-run --json` " \
|
|
375
|
+
"for a new one.", url: nil, json: parsed[:json], outcome: "quote_not_found")
|
|
376
|
+
end
|
|
377
|
+
return quote unless quote["used_at"]
|
|
378
|
+
|
|
379
|
+
invalid_buy_option("Quote #{parsed[:quote]} has already been used — dry-run again for a new one.",
|
|
380
|
+
url: quote["store"], json: parsed[:json], outcome: "quote_used")
|
|
381
|
+
end
|
|
382
|
+
private_class_method :usable_quote
|
|
383
|
+
|
|
384
|
+
def self.refuse_unapproved_quote(parsed, quote, level)
|
|
385
|
+
summary = Approve.summary(quote)
|
|
386
|
+
report = { url: quote["store"], checkout_url: nil, products: [], warnings: [], source: "none", browse: false,
|
|
387
|
+
checkout: false }.merge(Approve.needs_approval(summary, message: approval_message(summary, level)))
|
|
388
|
+
print_buy_report(report, nil, parsed[:json])
|
|
389
|
+
buy_exit_code(report)
|
|
390
|
+
end
|
|
391
|
+
private_class_method :refuse_unapproved_quote
|
|
392
|
+
|
|
393
|
+
# With a terminal (and no --json) the person picks there, through the
|
|
394
|
+
# same HumanPrompt numbered pick `portage pick` uses; otherwise the
|
|
395
|
+
# offers are printed and nothing is bought.
|
|
244
396
|
def self.pick_offer(report, json)
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
397
|
+
prompt = HumanPrompt.new(json: json)
|
|
398
|
+
unless prompt.tty? && report[:offers].any?
|
|
399
|
+
puts json ? JSON.pretty_generate(report) : format_find(report)
|
|
400
|
+
return nil
|
|
401
|
+
end
|
|
248
402
|
|
|
249
|
-
prompt_for_offer(report
|
|
403
|
+
prompt_for_offer(prompt, report)
|
|
250
404
|
end
|
|
251
405
|
private_class_method :pick_offer
|
|
252
406
|
|
|
253
|
-
def self.prompt_for_offer(
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
offers[choice.to_i - 1] if choice.to_i.between?(1, offers.length)
|
|
407
|
+
def self.prompt_for_offer(prompt, report)
|
|
408
|
+
prompt.say(report[:message].to_s)
|
|
409
|
+
choices = report[:offers].map { |offer| OfferChoice.for(offer) }
|
|
410
|
+
index = prompt.choose("Pick one to buy", choices, view: OfferChoice.method(:view_message))
|
|
411
|
+
index && report[:offers][index]
|
|
259
412
|
end
|
|
260
413
|
private_class_method :prompt_for_offer
|
|
261
414
|
|
|
415
|
+
# docs/plans/human-pick-and-approve.md Phase 2: under
|
|
416
|
+
# `--require-approval any|person` a real `--yes` run with no approved
|
|
417
|
+
# `--quote` is run as a dry run instead — never charged, never handed
|
|
418
|
+
# off — which prices it and saves a quote, and the report becomes
|
|
419
|
+
# `needs_approval` naming that quote and the next steps. Done here, not
|
|
420
|
+
# in Buy, so Buy's library callers aren't governed by the CLI policy.
|
|
262
421
|
def self.execute_buy(parsed, url, product_id: nil)
|
|
263
|
-
|
|
264
|
-
|
|
422
|
+
gated = approval_gate?(parsed)
|
|
423
|
+
parsed[:buy] = parsed[:buy].merge(yes: false, dry_run: true) if gated
|
|
424
|
+
options = buy_options(parsed, url, product_id: product_id)
|
|
265
425
|
report = Buy.new(**options).call
|
|
266
426
|
record_buy(report, options[:query])
|
|
427
|
+
report = settle_quote(report, parsed, options)
|
|
428
|
+
report = needs_approval_report(report) if gated
|
|
267
429
|
result = parsed[:wait] ? wait_for_handoff(report, parsed) : nil
|
|
268
430
|
print_buy_report(report, result, parsed[:json])
|
|
269
|
-
report
|
|
431
|
+
buy_exit_code(report)
|
|
270
432
|
end
|
|
271
433
|
private_class_method :execute_buy
|
|
272
434
|
|
|
435
|
+
def self.buy_options(parsed, url, product_id: nil)
|
|
436
|
+
options = parsed[:buy].merge(url: url, confidence_check: parsed[:confidence_check],
|
|
437
|
+
handoff_target: parsed[:handoff_target], json: !parsed[:json].nil?)
|
|
438
|
+
options[:product_id] ||= product_id
|
|
439
|
+
options[:webmcp_bridge] = profile_webmcp_bridge(url) if parsed[:handoff_target].profile?
|
|
440
|
+
options
|
|
441
|
+
end
|
|
442
|
+
private_class_method :buy_options
|
|
443
|
+
|
|
444
|
+
# `needs_approval` exits 0 like Buy's own `needs_confirmation`: the run
|
|
445
|
+
# did what it could and is waiting on the person.
|
|
446
|
+
def self.buy_exit_code(report)
|
|
447
|
+
report[:outcome] == "needs_approval" || report[:checkout] || report[:browse] ? 0 : 1
|
|
448
|
+
end
|
|
449
|
+
private_class_method :buy_exit_code
|
|
450
|
+
|
|
451
|
+
def self.real_run?(parsed) = parsed[:buy][:yes] && !parsed[:buy][:dry_run]
|
|
452
|
+
private_class_method :real_run?
|
|
453
|
+
|
|
454
|
+
# An approved quote already passed ApprovalPolicy in #buy_from_quote.
|
|
455
|
+
def self.approval_gate?(parsed)
|
|
456
|
+
real_run?(parsed) && !parsed[:quote_record] && ApprovalPolicy.level != "off"
|
|
457
|
+
end
|
|
458
|
+
private_class_method :approval_gate?
|
|
459
|
+
|
|
460
|
+
# A gated run that got as far as a priced checkout saved a quote; one
|
|
461
|
+
# that didn't (no match, a dead end) is reported as it is — nothing
|
|
462
|
+
# was bought either way.
|
|
463
|
+
def self.needs_approval_report(report)
|
|
464
|
+
quote = report[:quote_id] && Quotes.new.find(report[:quote_id])
|
|
465
|
+
return report unless quote
|
|
466
|
+
|
|
467
|
+
summary = Approve.summary(quote)
|
|
468
|
+
report.merge(Approve.needs_approval(summary, message: approval_message(summary, ApprovalPolicy.level)))
|
|
469
|
+
end
|
|
470
|
+
private_class_method :needs_approval_report
|
|
471
|
+
|
|
472
|
+
def self.approval_message(summary, level)
|
|
473
|
+
id = summary[:quote_id]
|
|
474
|
+
held = if summary[:approved_by]
|
|
475
|
+
"Quote #{id} was approved by #{summary[:approved_by]}, which require_approval #{level} doesn't accept."
|
|
476
|
+
else
|
|
477
|
+
"Quote #{id} (#{Approve.describe(summary)}) needs approval first (require_approval: #{level})."
|
|
478
|
+
end
|
|
479
|
+
how = level == "person" ? "`portage approve #{id} --via tty` at a terminal" : "`portage approve #{id}`"
|
|
480
|
+
"Nothing was bought. #{held} Approve it with #{how}, then run `portage buy --quote #{id} --yes`."
|
|
481
|
+
end
|
|
482
|
+
private_class_method :approval_message
|
|
483
|
+
|
|
484
|
+
# A dry run saves a quote and reports its `quote_id`. A run of a saved
|
|
485
|
+
# quote spends it once it purchases or hands off; any other outcome
|
|
486
|
+
# (needs_confirmation, a dry run, an error) leaves it usable.
|
|
487
|
+
def self.settle_quote(report, parsed, options)
|
|
488
|
+
quote = parsed[:quote_record]
|
|
489
|
+
if quote
|
|
490
|
+
Quotes.new.consume(quote["quote_id"]) if report[:outcome] == "purchased" || report[:handoff]
|
|
491
|
+
return report[:outcome] == "quote_changed" ? report.merge(quote_id: quote["quote_id"]) : report
|
|
492
|
+
end
|
|
493
|
+
return report unless report[:outcome] == "dry_run"
|
|
494
|
+
|
|
495
|
+
saved = Quotes.new.create(offer_ref: parsed[:offer_ref], store: report[:url],
|
|
496
|
+
product_id: options[:product_id], query: options[:query], qty: options[:qty],
|
|
497
|
+
total: report_total(report), currency: report[:currency],
|
|
498
|
+
**quote_page(report, parsed))
|
|
499
|
+
saved ? report.merge(quote_id: saved["quote_id"]) : report
|
|
500
|
+
end
|
|
501
|
+
private_class_method :settle_quote
|
|
502
|
+
|
|
503
|
+
# What `portage approve` shows (docs/plans/human-pick-and-approve.md
|
|
504
|
+
# Phase 2): the title of what's in the checkout, else the picked
|
|
505
|
+
# offer's, and the offer's product page when the buy came from one.
|
|
506
|
+
def self.quote_page(report, parsed)
|
|
507
|
+
page = parsed[:page] || {}
|
|
508
|
+
{ title: Array(report[:items]).first&.dig(:title) || page[:title], url: page[:url] }
|
|
509
|
+
end
|
|
510
|
+
private_class_method :quote_page
|
|
511
|
+
|
|
512
|
+
# docs/plans/buy-skill-and-local-browser.md Phase 6: `--handoff-target
|
|
513
|
+
# profile` gives `portage buy` a browser of its own — the Portage
|
|
514
|
+
# profile — so it's attached here as Buy's `webmcp_bridge:`, exactly
|
|
515
|
+
# the seam Buy#initialize's own doc comment already names as "the only
|
|
516
|
+
# option from the portage buy CLI" before this phase existed. Never
|
|
517
|
+
# raises: any failure to attach (portage-ucp-webmcp not installed, the
|
|
518
|
+
# profile not running, a bad target) just means Buy runs with no
|
|
519
|
+
# bridge at all — its dead-end hand-off to "profile" then reports that
|
|
520
|
+
# the browser isn't attached (see #dispatch_to_target's "profile"
|
|
521
|
+
# case) rather than this crashing the whole buy.
|
|
522
|
+
def self.profile_webmcp_bridge(url)
|
|
523
|
+
return nil unless Webmcp.available?
|
|
524
|
+
|
|
525
|
+
profile = BrowserProfile::Profile.new
|
|
526
|
+
return nil unless profile.status[:running]
|
|
527
|
+
|
|
528
|
+
full_url = absolute_url(url)
|
|
529
|
+
target = browser_profile_target(profile, full_url)
|
|
530
|
+
ws_url = target && target["webSocketDebuggerUrl"]
|
|
531
|
+
return nil unless ws_url
|
|
532
|
+
|
|
533
|
+
socket = BrowserProfile::CdpSocket.connect(ws_url)
|
|
534
|
+
allowlist = BrowserProfile::Allowlist.new(hosts: [URI(full_url).host])
|
|
535
|
+
BrowserProfile::Bridge.new(socket: socket, allowlist: allowlist)
|
|
536
|
+
rescue StandardError
|
|
537
|
+
nil
|
|
538
|
+
end
|
|
539
|
+
private_class_method :profile_webmcp_bridge
|
|
540
|
+
|
|
541
|
+
# Same "bare host gets an https:// prefix" normalization Buy#initialize
|
|
542
|
+
# applies to the same `url` — done again here since this runs before
|
|
543
|
+
# Buy exists to do it, and a bare host like "shop.example" isn't a
|
|
544
|
+
# URI CDP's own `/json/new` or Runtime.evaluate's `window.location`
|
|
545
|
+
# comparisons can parse a host out of otherwise.
|
|
546
|
+
def self.absolute_url(url)
|
|
547
|
+
raw = url.to_s.strip
|
|
548
|
+
raw =~ %r{\Ahttps?://}i ? raw : "https://#{raw}"
|
|
549
|
+
end
|
|
550
|
+
private_class_method :absolute_url
|
|
551
|
+
|
|
552
|
+
# An existing tab already on this store's host, so a shopper who's
|
|
553
|
+
# mid-session there isn't yanked to a fresh one; otherwise a brand new
|
|
554
|
+
# tab navigated straight to `url`.
|
|
555
|
+
def self.browser_profile_target(profile, url)
|
|
556
|
+
host = URI(url).host
|
|
557
|
+
existing = BrowserProfile::Cdp.list(port: profile.port)
|
|
558
|
+
.find { |t| t["type"] == "page" && same_host?(t["url"], host) }
|
|
559
|
+
existing || BrowserProfile::Cdp.new_tab(port: profile.port, url: url)
|
|
560
|
+
end
|
|
561
|
+
private_class_method :browser_profile_target
|
|
562
|
+
|
|
563
|
+
def self.same_host?(url, host)
|
|
564
|
+
URI(url.to_s).host == host
|
|
565
|
+
rescue URI::InvalidURIError
|
|
566
|
+
false
|
|
567
|
+
end
|
|
568
|
+
private_class_method :same_host?
|
|
569
|
+
|
|
273
570
|
# docs/plans/handoff-reconcile.md Phase 3 — `portage buy --wait`. A
|
|
274
571
|
# no-op (returns nil) whenever there's nothing to wait on: --dry-run
|
|
275
572
|
# never hands off at all, and a completed/browse-only/dead-end report
|
|
@@ -361,14 +658,14 @@ module Portage
|
|
|
361
658
|
# any other, with outcome `invalid_option`, so an agent loop reading
|
|
362
659
|
# stdout gets JSON rather than nothing and a line on stderr.
|
|
363
660
|
# @return [nil]
|
|
364
|
-
def self.invalid_buy_option(message, url:, json:)
|
|
661
|
+
def self.invalid_buy_option(message, url:, json:, outcome: "invalid_option")
|
|
365
662
|
unless json
|
|
366
663
|
warn message
|
|
367
664
|
return nil
|
|
368
665
|
end
|
|
369
666
|
|
|
370
667
|
puts JSON.pretty_generate(url: url, checkout_url: nil, products: [], warnings: [], source: "none",
|
|
371
|
-
outcome:
|
|
668
|
+
outcome: outcome, browse: false, checkout: false, message: message)
|
|
372
669
|
nil
|
|
373
670
|
end
|
|
374
671
|
private_class_method :invalid_buy_option
|
|
@@ -411,8 +708,9 @@ module Portage
|
|
|
411
708
|
parser = buy_option_parser(buy, parsed)
|
|
412
709
|
ProxySettings.add_options(parser, parsed[:proxy])
|
|
413
710
|
parser.parse!(argv)
|
|
711
|
+
url = reinterpret_bare_query(url, buy, parsed)
|
|
414
712
|
buy[:query] ||= ""
|
|
415
|
-
return parsed if url
|
|
713
|
+
return parsed if buy_target?(url, buy, parsed)
|
|
416
714
|
|
|
417
715
|
warn USAGE
|
|
418
716
|
nil
|
|
@@ -421,6 +719,29 @@ module Portage
|
|
|
421
719
|
end
|
|
422
720
|
private_class_method :parse_buy_options
|
|
423
721
|
|
|
722
|
+
def self.buy_target?(url, buy, parsed)
|
|
723
|
+
url || !buy[:query].strip.empty? || parsed[:offer] || parsed[:quote]
|
|
724
|
+
end
|
|
725
|
+
private_class_method :buy_target?
|
|
726
|
+
|
|
727
|
+
# Bare arg is normally the store URL (`portage buy <url> --query "..."`),
|
|
728
|
+
# but `portage buy "coffee"` — no --query, and "coffee" doesn't look like
|
|
729
|
+
# a URL/domain — means the same thing as `portage buy --query "coffee"`:
|
|
730
|
+
# search first, then buy from whatever the caller picks. Only reinterpret
|
|
731
|
+
# when no --query was already given, so `portage buy shop.com --query
|
|
732
|
+
# "coffee"` keeps treating "shop.com" as the store.
|
|
733
|
+
def self.reinterpret_bare_query(url, buy, parsed)
|
|
734
|
+
return url unless url && buy[:query].to_s.strip.empty? && !url_like?(url)
|
|
735
|
+
|
|
736
|
+
buy[:url] = nil
|
|
737
|
+
parsed[:find][:query] = buy[:query] = url
|
|
738
|
+
nil
|
|
739
|
+
end
|
|
740
|
+
private_class_method :reinterpret_bare_query
|
|
741
|
+
|
|
742
|
+
def self.url_like?(text) = text.match?(%r{\A[a-z][a-z0-9+.-]*://}i) || text.include?(".")
|
|
743
|
+
private_class_method :url_like?
|
|
744
|
+
|
|
424
745
|
def self.buy_option_parser(buy, parsed)
|
|
425
746
|
OptionParser.new do |parser|
|
|
426
747
|
parser.on("--qty N", Integer) { |v| buy[:qty] = v }
|
|
@@ -428,6 +749,7 @@ module Portage
|
|
|
428
749
|
parser.on("--product-id ID") { |v| buy[:product_id] = v }
|
|
429
750
|
parser.on("--yes") { buy[:yes] = true }
|
|
430
751
|
parser.on("--dry-run") { buy[:dry_run] = true }
|
|
752
|
+
parser.on("--autofill") { buy[:autofill] = true }
|
|
431
753
|
parser.on("--json") { parsed[:json] = true }
|
|
432
754
|
add_handoff_options(parser, buy)
|
|
433
755
|
add_wait_options(parser, parsed)
|
|
@@ -448,6 +770,7 @@ module Portage
|
|
|
448
770
|
def self.add_handoff_options(parser, buy)
|
|
449
771
|
parser.on("--[no-]auto-open") { |v| buy[:auto_open] = v }
|
|
450
772
|
parser.on("--notify-webhook URL") { |v| buy[:notify_webhook] = v }
|
|
773
|
+
parser.on("--handoff-target TARGET") { |v| buy[:handoff_target] = v }
|
|
451
774
|
end
|
|
452
775
|
private_class_method :add_handoff_options
|
|
453
776
|
|
|
@@ -465,6 +788,8 @@ module Portage
|
|
|
465
788
|
# and the catalog search once a store is settled, so it's registered once
|
|
466
789
|
# here rather than twice on the same parser.
|
|
467
790
|
def self.add_search_options(parser, buy, parsed)
|
|
791
|
+
parser.on("--offer REF") { |v| parsed[:offer] = v }
|
|
792
|
+
parser.on("--quote QUOTE_ID") { |v| parsed[:quote] = v }
|
|
468
793
|
parser.on("--query QUERY") { |v| parsed[:find][:query] = buy[:query] = v }
|
|
469
794
|
parser.on("--store URL") { |v| parsed[:store] = v }
|
|
470
795
|
parser.on("--limit N", Integer) { |v| parsed[:find][:limit] = v }
|
|
@@ -472,6 +797,94 @@ module Portage
|
|
|
472
797
|
end
|
|
473
798
|
private_class_method :add_search_options
|
|
474
799
|
|
|
800
|
+
# --- pick / approve (docs/plans/human-pick-and-approve.md Phase 2) ---
|
|
801
|
+
|
|
802
|
+
# Outcomes a pick/approve run exits 0 on: an answer, a page shown, or a
|
|
803
|
+
# question handed to the agent. Everything else (cancelled, not found,
|
|
804
|
+
# used, refused, no terminal) exits 1.
|
|
805
|
+
PROMPT_OK_OUTCOMES = %w[picked approved viewed needs_pick needs_approval].freeze
|
|
806
|
+
|
|
807
|
+
def self.run_pick(argv)
|
|
808
|
+
opts = parse_prompt_options(argv) do |parser, o|
|
|
809
|
+
parser.on("--search ID") { |v| o[:search] = v }
|
|
810
|
+
parser.on("--choose REF") { |v| o[:choose] = v }
|
|
811
|
+
parser.on("--compare REF") { |v| o[:compare] = v }
|
|
812
|
+
parser.on("--view REF") { |v| o[:view] = v }
|
|
813
|
+
end
|
|
814
|
+
return 1 unless opts
|
|
815
|
+
|
|
816
|
+
pick = Pick.new(prompt: HumanPrompt.new(via: opts[:via], json: opts[:json]), comparer: method(:compare_offer),
|
|
817
|
+
**opts.slice(:search, :choose, :view, :compare))
|
|
818
|
+
print_prompt_result(pick.call, opts[:json])
|
|
819
|
+
end
|
|
820
|
+
private_class_method :run_pick
|
|
821
|
+
|
|
822
|
+
def self.run_approve(argv)
|
|
823
|
+
quote_id = argv.first && !argv.first.start_with?("-") ? argv.shift : nil
|
|
824
|
+
opts = parse_prompt_options(argv) do |parser, o|
|
|
825
|
+
parser.on("--relayed-yes") { o[:relayed_yes] = true }
|
|
826
|
+
parser.on("--view") { o[:view] = true }
|
|
827
|
+
end
|
|
828
|
+
return 1 unless opts
|
|
829
|
+
return prompt_usage(opts[:json], "portage approve needs a QUOTE_ID.") unless quote_id
|
|
830
|
+
|
|
831
|
+
approve = Approve.new(quote_id: quote_id, prompt: HumanPrompt.new(via: opts[:via], json: opts[:json]),
|
|
832
|
+
**opts.slice(:relayed_yes, :view))
|
|
833
|
+
print_prompt_result(approve.call, opts[:json])
|
|
834
|
+
end
|
|
835
|
+
private_class_method :run_approve
|
|
836
|
+
|
|
837
|
+
# `--via`/`--json` for both commands, plus whatever the block adds.
|
|
838
|
+
# @return [Hash, nil] nil on a bad flag (already reported).
|
|
839
|
+
def self.parse_prompt_options(argv)
|
|
840
|
+
opts = { via: "auto" }
|
|
841
|
+
json = argv.include?("--json")
|
|
842
|
+
OptionParser.new do |parser|
|
|
843
|
+
parser.on("--via SURFACE", HumanPrompt::VIAS) { |v| opts[:via] = v }
|
|
844
|
+
parser.on("--json") { opts[:json] = true }
|
|
845
|
+
yield parser, opts
|
|
846
|
+
end.parse!(argv)
|
|
847
|
+
opts
|
|
848
|
+
rescue OptionParser::ParseError => e
|
|
849
|
+
prompt_usage(json, e.message)
|
|
850
|
+
nil
|
|
851
|
+
end
|
|
852
|
+
private_class_method :parse_prompt_options
|
|
853
|
+
|
|
854
|
+
def self.prompt_usage(json, message)
|
|
855
|
+
json ? puts(JSON.pretty_generate(outcome: "invalid_option", message: message)) : warn("#{message}\n#{USAGE}")
|
|
856
|
+
1
|
|
857
|
+
end
|
|
858
|
+
private_class_method :prompt_usage
|
|
859
|
+
|
|
860
|
+
# Pick's "Compare an offer across stores": the same Compare run
|
|
861
|
+
# `portage compare` does, from the saved offer's store and product.
|
|
862
|
+
def self.compare_offer(offer)
|
|
863
|
+
return { offers: [], message: "Couldn't compare — see the proxy error above." } unless apply_proxy_settings({})
|
|
864
|
+
|
|
865
|
+
Compare.new(origin_url: offer["store"], origin_product_id: offer["product_id"]).call
|
|
866
|
+
end
|
|
867
|
+
private_class_method :compare_offer
|
|
868
|
+
|
|
869
|
+
def self.print_prompt_result(result, json)
|
|
870
|
+
puts json ? JSON.pretty_generate(result) : format_prompt_result(result)
|
|
871
|
+
PROMPT_OK_OUTCOMES.include?(result[:outcome]) ? 0 : 1
|
|
872
|
+
end
|
|
873
|
+
private_class_method :print_prompt_result
|
|
874
|
+
|
|
875
|
+
def self.format_prompt_result(result)
|
|
876
|
+
lines = ["[#{result[:outcome]}] #{result[:message]}"]
|
|
877
|
+
Array(result[:choices]).each_with_index { |choice, index| lines << " #{index + 1}. #{choice_line(choice)}" }
|
|
878
|
+
lines << " #{approval_line(result[:summary])}" if result[:summary]
|
|
879
|
+
lines.join("\n")
|
|
880
|
+
end
|
|
881
|
+
private_class_method :format_prompt_result
|
|
882
|
+
|
|
883
|
+
def self.choice_line(choice)
|
|
884
|
+
[choice[:label], choice[:url], ("ref #{choice[:ref]}" if choice[:ref])].compact.join(" — ")
|
|
885
|
+
end
|
|
886
|
+
private_class_method :choice_line
|
|
887
|
+
|
|
475
888
|
# --- history ---
|
|
476
889
|
|
|
477
890
|
def self.run_history(argv)
|
|
@@ -665,53 +1078,110 @@ module Portage
|
|
|
665
1078
|
end
|
|
666
1079
|
private_class_method :run_policy
|
|
667
1080
|
|
|
1081
|
+
# `require_approval` is always shown at its effective value, the
|
|
1082
|
+
# default included, so "what does `--yes` need right now" is never a
|
|
1083
|
+
# guess.
|
|
668
1084
|
def self.run_policy_show(argv)
|
|
669
1085
|
json = false
|
|
670
1086
|
OptionParser.new { |parser| parser.on("--json") { json = true } }.parse!(argv)
|
|
671
|
-
policy = Portage::Ucp::Policy.load
|
|
672
|
-
|
|
1087
|
+
policy = Portage::Ucp::Policy.load
|
|
1088
|
+
effective = policy.to_h.merge(ApprovalPolicy::KEY => ApprovalPolicy.level(policy))
|
|
1089
|
+
puts json ? JSON.pretty_generate(effective) : format_policy(policy)
|
|
673
1090
|
0
|
|
674
1091
|
end
|
|
675
1092
|
private_class_method :run_policy_show
|
|
676
1093
|
|
|
677
1094
|
def self.format_policy(policy)
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
1095
|
+
spending = policy.to_h.except(ApprovalPolicy::KEY)
|
|
1096
|
+
body = spending.empty? ? "(no policy configured — every spending check passes)" : JSON.pretty_generate(spending)
|
|
1097
|
+
default = " (default)" unless ApprovalPolicy.configured?(policy)
|
|
1098
|
+
"#{body}\nrequire_approval: #{ApprovalPolicy.level(policy)}#{default}"
|
|
681
1099
|
end
|
|
682
1100
|
private_class_method :format_policy
|
|
683
1101
|
|
|
684
1102
|
def self.parse_policy_set_options(argv)
|
|
685
1103
|
opts = { allow: [] }
|
|
686
1104
|
OptionParser.new do |parser|
|
|
687
|
-
parser
|
|
688
|
-
parser.on("--rolling-cap N", Integer) { |v| opts[:rolling_cap] = v }
|
|
689
|
-
parser.on("--rolling-window-seconds N", Integer) { |v| opts[:rolling_window_seconds] = v }
|
|
690
|
-
parser.on("--currency CUR") { |v| opts[:currency] = v }
|
|
1105
|
+
add_policy_cap_options(parser, opts)
|
|
691
1106
|
parser.on("--velocity-count N", Integer) { |v| opts[:velocity_count] = v }
|
|
692
1107
|
parser.on("--velocity-window-seconds N", Integer) { |v| opts[:velocity_window_seconds] = v }
|
|
693
1108
|
parser.on("--allow HOST") { |v| opts[:allow] << v }
|
|
694
1109
|
parser.on("--clear-allowlist") { opts[:clear_allowlist] = true }
|
|
1110
|
+
parser.on("--require-approval LEVEL", ApprovalPolicy::LEVELS) { |v| opts[:require_approval] = v }
|
|
695
1111
|
end.parse!(argv)
|
|
696
1112
|
opts
|
|
697
1113
|
end
|
|
698
1114
|
private_class_method :parse_policy_set_options
|
|
699
1115
|
|
|
1116
|
+
def self.add_policy_cap_options(parser, opts)
|
|
1117
|
+
parser.on("--per-transaction-cap N", Integer) { |v| opts[:per_transaction_cap] = v }
|
|
1118
|
+
parser.on("--rolling-cap N", Integer) { |v| opts[:rolling_cap] = v }
|
|
1119
|
+
parser.on("--rolling-window-seconds N", Integer) { |v| opts[:rolling_window_seconds] = v }
|
|
1120
|
+
parser.on("--currency CUR") { |v| opts[:currency] = v }
|
|
1121
|
+
end
|
|
1122
|
+
private_class_method :add_policy_cap_options
|
|
1123
|
+
|
|
700
1124
|
# Each `--*` group is applied independently and only when its required
|
|
701
1125
|
# fields are present — `portage policy set --allow shop.example.com`
|
|
702
1126
|
# touches only the allowlist, leaving caps/velocity untouched, so caps
|
|
703
1127
|
# and the allowlist can be configured in separate invocations.
|
|
1128
|
+
#
|
|
1129
|
+
# `--require-approval` goes first: a lowering the person doesn't confirm
|
|
1130
|
+
# at the terminal refuses the whole invocation, so nothing else in it
|
|
1131
|
+
# changes either.
|
|
704
1132
|
def self.run_policy_set(argv)
|
|
705
1133
|
opts = parse_policy_set_options(argv)
|
|
706
1134
|
policy = Portage::Ucp::Policy.load
|
|
1135
|
+
return 1 unless require_approval_applied?(policy, opts[:require_approval])
|
|
1136
|
+
|
|
707
1137
|
set_policy_cap(policy, opts)
|
|
708
1138
|
set_policy_velocity(policy, opts)
|
|
709
1139
|
set_policy_allowlist(policy, opts)
|
|
710
|
-
puts format_policy(policy
|
|
1140
|
+
puts format_policy(policy)
|
|
711
1141
|
0
|
|
1142
|
+
rescue OptionParser::ParseError => e
|
|
1143
|
+
warn "#{e.message}\n#{USAGE}"
|
|
1144
|
+
1
|
|
712
1145
|
end
|
|
713
1146
|
private_class_method :run_policy_set
|
|
714
1147
|
|
|
1148
|
+
# docs/plans/human-pick-and-approve.md Phase 2: raising the level (or
|
|
1149
|
+
# setting the same one) needs nothing; lowering it (person -> any/off,
|
|
1150
|
+
# any -> off) needs a yes typed on the tty, since an agent with a shell
|
|
1151
|
+
# can run `policy set` but can't type on /dev/tty. No terminal, no
|
|
1152
|
+
# change.
|
|
1153
|
+
# @return [Boolean] false when a lowering was refused (nothing changed).
|
|
1154
|
+
def self.require_approval_applied?(policy, level)
|
|
1155
|
+
return true unless level
|
|
1156
|
+
|
|
1157
|
+
current = ApprovalPolicy.level(policy)
|
|
1158
|
+
return false if ApprovalPolicy.lowering?(current, level) && !confirm_lowering(current, level)
|
|
1159
|
+
|
|
1160
|
+
policy.set(ApprovalPolicy::KEY, level)
|
|
1161
|
+
true
|
|
1162
|
+
end
|
|
1163
|
+
private_class_method :require_approval_applied?
|
|
1164
|
+
|
|
1165
|
+
def self.confirm_lowering(current, level)
|
|
1166
|
+
prompt = HumanPrompt.new(via: "tty")
|
|
1167
|
+
return true if prompt.confirm("Lower require_approval from #{current} to #{level}? #{lowering_effect(level)}")
|
|
1168
|
+
|
|
1169
|
+
warn "require_approval left at #{current}."
|
|
1170
|
+
false
|
|
1171
|
+
rescue HumanPrompt::NoTerminal
|
|
1172
|
+
warn "Lowering require_approval (#{current} -> #{level}) needs a yes typed at a terminal, and there's no " \
|
|
1173
|
+
"terminal here — nothing changed. Run it yourself from a terminal."
|
|
1174
|
+
false
|
|
1175
|
+
end
|
|
1176
|
+
private_class_method :confirm_lowering
|
|
1177
|
+
|
|
1178
|
+
def self.lowering_effect(level)
|
|
1179
|
+
return "`portage buy --yes` would then buy without anyone approving the total." if level == "off"
|
|
1180
|
+
|
|
1181
|
+
"An agent relaying your yes would then be enough to buy."
|
|
1182
|
+
end
|
|
1183
|
+
private_class_method :lowering_effect
|
|
1184
|
+
|
|
715
1185
|
def self.set_policy_cap(policy, opts)
|
|
716
1186
|
if opts[:per_transaction_cap]
|
|
717
1187
|
policy.set("per_transaction_cap",
|
|
@@ -757,6 +1227,9 @@ module Portage
|
|
|
757
1227
|
when "pending"
|
|
758
1228
|
"Timed out waiting for enrollment — finish it at #{result[:setup_url]}, then run " \
|
|
759
1229
|
"`portage payment enroll` again."
|
|
1230
|
+
when "handoff_only"
|
|
1231
|
+
"#{result[:host]} is hand-off only — there's nothing to set up here. Sign in and add a card " \
|
|
1232
|
+
"on #{result[:host]} yourself."
|
|
760
1233
|
else "This store doesn't support payment enrollment."
|
|
761
1234
|
end
|
|
762
1235
|
end
|
|
@@ -833,6 +1306,379 @@ module Portage
|
|
|
833
1306
|
end
|
|
834
1307
|
private_class_method :format_reconcile_result
|
|
835
1308
|
|
|
1309
|
+
# --- index (docs/plans/buy-skill-and-local-browser.md Phase 2b) ---
|
|
1310
|
+
|
|
1311
|
+
INDEX_SUBCOMMANDS = {
|
|
1312
|
+
"build" => ->(argv) { run_index_build(argv, refresh: false) },
|
|
1313
|
+
"refresh" => ->(argv) { run_index_build(argv, refresh: true) },
|
|
1314
|
+
"show" => ->(argv) { run_index_show(argv) },
|
|
1315
|
+
"add" => ->(argv) { run_index_add(argv) },
|
|
1316
|
+
"remove" => ->(argv) { run_index_remove(argv) },
|
|
1317
|
+
"sources" => ->(argv) { run_index_sources(argv) }
|
|
1318
|
+
}.freeze
|
|
1319
|
+
|
|
1320
|
+
def self.run_index(argv)
|
|
1321
|
+
sub = argv.first && !argv.first.start_with?("-") ? argv.shift : nil
|
|
1322
|
+
return INDEX_SUBCOMMANDS[sub].call(argv) if INDEX_SUBCOMMANDS.key?(sub)
|
|
1323
|
+
|
|
1324
|
+
warn USAGE
|
|
1325
|
+
1
|
|
1326
|
+
end
|
|
1327
|
+
private_class_method :run_index
|
|
1328
|
+
|
|
1329
|
+
def self.parse_index_build_options(argv)
|
|
1330
|
+
opts = { sources: nil, queries: nil, dry_run: false, json: false, export: nil }
|
|
1331
|
+
OptionParser.new do |parser|
|
|
1332
|
+
parser.on("--sources LIST") { |v| opts[:sources] = v.split(",").map(&:strip) }
|
|
1333
|
+
parser.on("--queries FILE") { |v| opts[:queries] = v }
|
|
1334
|
+
parser.on("--dry-run") { opts[:dry_run] = true }
|
|
1335
|
+
parser.on("--export DIR") { |v| opts[:export] = v }
|
|
1336
|
+
parser.on("--json") { opts[:json] = true }
|
|
1337
|
+
end.parse!(argv)
|
|
1338
|
+
opts[:queries] &&= File.readlines(opts[:queries]).map(&:strip).reject(&:empty?)
|
|
1339
|
+
opts
|
|
1340
|
+
end
|
|
1341
|
+
private_class_method :parse_index_build_options
|
|
1342
|
+
|
|
1343
|
+
def self.run_index_build(argv, refresh:)
|
|
1344
|
+
opts = parse_index_build_options(argv)
|
|
1345
|
+
builder = Index::Builder.new(sources: index_sources(opts[:sources]), out: opts[:json] ? nil : $stdout)
|
|
1346
|
+
result = if refresh
|
|
1347
|
+
builder.refresh(queries: opts[:queries], dry_run: opts[:dry_run], export: opts[:export])
|
|
1348
|
+
else
|
|
1349
|
+
builder.build(queries: opts[:queries], dry_run: opts[:dry_run], export: opts[:export])
|
|
1350
|
+
end
|
|
1351
|
+
puts opts[:json] ? JSON.pretty_generate(result) : format_index_build(result)
|
|
1352
|
+
0
|
|
1353
|
+
end
|
|
1354
|
+
private_class_method :run_index_build
|
|
1355
|
+
|
|
1356
|
+
def self.index_sources(names) = names ? Index::Sources.by_name(names) : nil
|
|
1357
|
+
private_class_method :index_sources
|
|
1358
|
+
|
|
1359
|
+
def self.format_index_build(result)
|
|
1360
|
+
lines = ["Ran #{result[:sources_run].join(', ')} — #{result[:candidates]} candidate(s)."]
|
|
1361
|
+
lines << "Checked #{result[:new_origins_checked].length} new origin(s), " \
|
|
1362
|
+
"#{result[:verified].length} verified UCP."
|
|
1363
|
+
lines << "Hit the #{Index::Builder::MAX_NEW_PROBES}-probe cap for this run." if result[:capped]
|
|
1364
|
+
lines << "#{result[:products_added]} product sighting(s) recorded." if result[:products_added]
|
|
1365
|
+
if result[:exported]
|
|
1366
|
+
lines << "Exported #{result[:exported][:stores]} store(s), #{result[:exported][:products]} " \
|
|
1367
|
+
"product(s) to #{result[:exported][:dir]}."
|
|
1368
|
+
end
|
|
1369
|
+
lines.join("\n")
|
|
1370
|
+
end
|
|
1371
|
+
private_class_method :format_index_build
|
|
1372
|
+
|
|
1373
|
+
def self.run_index_show(argv)
|
|
1374
|
+
opts = { kind: nil, json: false }
|
|
1375
|
+
OptionParser.new do |parser|
|
|
1376
|
+
parser.on("--stores") { opts[:kind] = "stores" }
|
|
1377
|
+
parser.on("--products") { opts[:kind] = "products" }
|
|
1378
|
+
parser.on("--json") { opts[:json] = true }
|
|
1379
|
+
end.parse!(argv)
|
|
1380
|
+
|
|
1381
|
+
result = { stores: opts[:kind] == "products" ? [] : Index::Store.new.all,
|
|
1382
|
+
products: opts[:kind] == "stores" ? [] : Index::ProductStore.new.all }
|
|
1383
|
+
puts opts[:json] ? JSON.pretty_generate(result) : format_index_show(result)
|
|
1384
|
+
0
|
|
1385
|
+
end
|
|
1386
|
+
private_class_method :run_index_show
|
|
1387
|
+
|
|
1388
|
+
def self.format_index_show(result)
|
|
1389
|
+
lines = ["Stores:"]
|
|
1390
|
+
result[:stores].each { |s| lines << " #{s['origin']} (#{Array(s['sources']).join(', ')})" }
|
|
1391
|
+
lines << "(none)" if result[:stores].empty?
|
|
1392
|
+
lines << "Products:"
|
|
1393
|
+
result[:products].each { |p| lines << " #{p['title'] || p['key']}" }
|
|
1394
|
+
lines << "(none)" if result[:products].empty?
|
|
1395
|
+
lines.join("\n")
|
|
1396
|
+
end
|
|
1397
|
+
private_class_method :format_index_show
|
|
1398
|
+
|
|
1399
|
+
def self.run_index_add(argv)
|
|
1400
|
+
json = argv.delete("--json") ? true : false
|
|
1401
|
+
url = argv.first && !argv.first.start_with?("-") ? argv.shift : nil
|
|
1402
|
+
unless url
|
|
1403
|
+
warn USAGE
|
|
1404
|
+
return 1
|
|
1405
|
+
end
|
|
1406
|
+
|
|
1407
|
+
result = Index::Builder.new.add(url)
|
|
1408
|
+
puts json ? JSON.pretty_generate(result) : result[:message]
|
|
1409
|
+
result[:added] ? 0 : 1
|
|
1410
|
+
end
|
|
1411
|
+
private_class_method :run_index_add
|
|
1412
|
+
|
|
1413
|
+
def self.run_index_remove(argv)
|
|
1414
|
+
json = argv.delete("--json") ? true : false
|
|
1415
|
+
host = argv.first && !argv.first.start_with?("-") ? argv.shift : nil
|
|
1416
|
+
unless host
|
|
1417
|
+
warn USAGE
|
|
1418
|
+
return 1
|
|
1419
|
+
end
|
|
1420
|
+
|
|
1421
|
+
result = Index::Builder.new.remove(host)
|
|
1422
|
+
puts json ? JSON.pretty_generate(result) : result[:message]
|
|
1423
|
+
result[:removed] ? 0 : 1
|
|
1424
|
+
end
|
|
1425
|
+
private_class_method :run_index_remove
|
|
1426
|
+
|
|
1427
|
+
def self.run_index_sources(argv)
|
|
1428
|
+
json = argv.delete("--json") ? true : false
|
|
1429
|
+
sources = Index::Sources.all.map { |s| { name: s.name, description: s.description, path: s.source_path } }
|
|
1430
|
+
puts json ? JSON.pretty_generate(sources) : format_index_sources(sources)
|
|
1431
|
+
0
|
|
1432
|
+
end
|
|
1433
|
+
private_class_method :run_index_sources
|
|
1434
|
+
|
|
1435
|
+
def self.format_index_sources(sources)
|
|
1436
|
+
sources.map { |s| "#{s[:name]}: #{s[:description]}#{" (#{s[:path]})" if s[:path]}" }.join("\n")
|
|
1437
|
+
end
|
|
1438
|
+
private_class_method :format_index_sources
|
|
1439
|
+
|
|
1440
|
+
# --- browser (docs/plans/buy-skill-and-local-browser.md Phase 3) ---
|
|
1441
|
+
|
|
1442
|
+
BROWSER_SUBCOMMANDS = { "import" => ->(argv) { run_browser_import(argv) },
|
|
1443
|
+
"profile" => ->(argv) { run_browser_profile(argv) } }.freeze
|
|
1444
|
+
|
|
1445
|
+
def self.run_browser(argv)
|
|
1446
|
+
sub = argv.first && !argv.first.start_with?("-") ? argv.shift : nil
|
|
1447
|
+
return BROWSER_SUBCOMMANDS[sub].call(argv) if BROWSER_SUBCOMMANDS.key?(sub)
|
|
1448
|
+
|
|
1449
|
+
warn USAGE
|
|
1450
|
+
1
|
|
1451
|
+
end
|
|
1452
|
+
private_class_method :run_browser
|
|
1453
|
+
|
|
1454
|
+
def self.parse_browser_import_options(argv)
|
|
1455
|
+
opts = { browser: nil, root: nil, history_days: BrowserImport::Importer::DEFAULT_HISTORY_DAYS,
|
|
1456
|
+
include_product_pages: false, max_probes: BrowserImport::Importer::MAX_PROBES, exclude: [],
|
|
1457
|
+
dry_run: false, yes: false, json: false }
|
|
1458
|
+
browser_import_option_parser(opts).parse!(argv)
|
|
1459
|
+
opts
|
|
1460
|
+
end
|
|
1461
|
+
private_class_method :parse_browser_import_options
|
|
1462
|
+
|
|
1463
|
+
def self.browser_import_option_parser(opts)
|
|
1464
|
+
OptionParser.new do |parser|
|
|
1465
|
+
parser.on("--browser NAME", BrowserImport::Profiles::BROWSERS) { |v| opts[:browser] = v }
|
|
1466
|
+
parser.on("--profile-root DIR") { |v| opts[:root] = File.expand_path(v) }
|
|
1467
|
+
parser.on("--history-days N", Integer) { |v| opts[:history_days] = v }
|
|
1468
|
+
parser.on("--max-probes N", Integer) { |v| opts[:max_probes] = v }
|
|
1469
|
+
parser.on("--exclude HOSTS", Array) { |v| opts[:exclude] = v.map(&:strip) }
|
|
1470
|
+
%i[include_product_pages dry_run yes json].each do |flag|
|
|
1471
|
+
parser.on("--#{flag.to_s.tr('_', '-')}") { opts[flag] = true }
|
|
1472
|
+
end
|
|
1473
|
+
end
|
|
1474
|
+
end
|
|
1475
|
+
private_class_method :browser_import_option_parser
|
|
1476
|
+
|
|
1477
|
+
# Reads, reduces and probes (BrowserImport::Importer#plan), shows the
|
|
1478
|
+
# list, and saves only through BrowserImport::Confirm's gate: `--yes`,
|
|
1479
|
+
# or a "y" at a real TTY prompt. Under `--json`/no TTY without `--yes`
|
|
1480
|
+
# nothing is written — the report says `saved: false,
|
|
1481
|
+
# needs_confirmation: true` and exits 0, so an agent shows the user the
|
|
1482
|
+
# list and re-runs with `--yes` only once they've approved it.
|
|
1483
|
+
def self.run_browser_import(argv)
|
|
1484
|
+
opts = parse_browser_import_options(argv)
|
|
1485
|
+
plan = browser_importer.plan(browser_import_options(opts))
|
|
1486
|
+
return report_browser_import(plan, opts, nil) if plan[:error]
|
|
1487
|
+
|
|
1488
|
+
puts format_browser_import(plan) unless opts[:json]
|
|
1489
|
+
interactive = !opts[:json] && $stdin.tty?
|
|
1490
|
+
decision = BrowserImport::Confirm.new(interactive: interactive).call(plan, yes: opts[:yes],
|
|
1491
|
+
dry_run: opts[:dry_run])
|
|
1492
|
+
saved = decision == :save ? browser_importer.save(plan) : nil
|
|
1493
|
+
report_browser_import(plan, opts, decision, saved)
|
|
1494
|
+
rescue OptionParser::ParseError => e
|
|
1495
|
+
warn "#{e.message}\n#{USAGE}"
|
|
1496
|
+
1
|
|
1497
|
+
end
|
|
1498
|
+
private_class_method :run_browser_import
|
|
1499
|
+
|
|
1500
|
+
# The real HandoffOnly list (docs/plans/buy-skill-and-local-browser.md
|
|
1501
|
+
# Phase 5) into Importer's own injectable `handoff_only_hosts:` seam
|
|
1502
|
+
# (Phase 3 left it defaulting to `[]` for exactly this) — a history/
|
|
1503
|
+
# bookmark domain on the list is kept as `handoff_only: true` and never
|
|
1504
|
+
# probed.
|
|
1505
|
+
def self.browser_importer
|
|
1506
|
+
BrowserImport::Importer.new(handoff_only_hosts: HandoffOnly.new.hosts)
|
|
1507
|
+
end
|
|
1508
|
+
private_class_method :browser_importer
|
|
1509
|
+
|
|
1510
|
+
def self.browser_import_options(opts)
|
|
1511
|
+
browser = opts[:browser] || BrowserImport::Profiles.detect || "chrome"
|
|
1512
|
+
BrowserImport::Importer::Options.new(
|
|
1513
|
+
browser: browser, root: opts[:root] || BrowserImport::Profiles.default_root(browser),
|
|
1514
|
+
history_days: opts[:history_days], include_product_pages: opts[:include_product_pages],
|
|
1515
|
+
max_probes: opts[:max_probes], exclude: opts[:exclude]
|
|
1516
|
+
)
|
|
1517
|
+
end
|
|
1518
|
+
private_class_method :browser_import_options
|
|
1519
|
+
|
|
1520
|
+
BROWSER_IMPORT_MESSAGES = {
|
|
1521
|
+
dry_run: "Dry run — nothing saved.",
|
|
1522
|
+
nothing: "No shop domains to save.",
|
|
1523
|
+
declined: "Nothing saved.",
|
|
1524
|
+
needs_confirmation: "Nothing saved: there's no terminal to confirm on. Show this list to the user, then " \
|
|
1525
|
+
"re-run with --yes once they've approved it (--exclude HOST,... drops any they don't want)."
|
|
1526
|
+
}.freeze
|
|
1527
|
+
|
|
1528
|
+
def self.report_browser_import(plan, opts, decision, saved = nil)
|
|
1529
|
+
message = plan[:message] || browser_import_message(decision, saved)
|
|
1530
|
+
if opts[:json]
|
|
1531
|
+
puts JSON.pretty_generate(plan.merge(saved: !saved.nil?, needs_confirmation: decision == :needs_confirmation,
|
|
1532
|
+
message: message))
|
|
1533
|
+
else
|
|
1534
|
+
puts message
|
|
1535
|
+
end
|
|
1536
|
+
plan[:error] ? 1 : 0
|
|
1537
|
+
end
|
|
1538
|
+
private_class_method :report_browser_import
|
|
1539
|
+
|
|
1540
|
+
def self.browser_import_message(decision, saved)
|
|
1541
|
+
return "Saved #{saved[:stores]} store(s) and #{saved[:products]} product(s) to your local index." if saved
|
|
1542
|
+
|
|
1543
|
+
BROWSER_IMPORT_MESSAGES.fetch(decision, "Nothing saved.")
|
|
1544
|
+
end
|
|
1545
|
+
private_class_method :browser_import_message
|
|
1546
|
+
|
|
1547
|
+
def self.format_browser_import(plan)
|
|
1548
|
+
lines = browser_import_counts(plan)
|
|
1549
|
+
lines << "Shops found (#{plan[:kept].length}):"
|
|
1550
|
+
plan[:kept].each { |entry| lines << " #{browser_import_line(entry)}" }
|
|
1551
|
+
lines << " (none)" if plan[:kept].empty?
|
|
1552
|
+
lines << "#{plan[:products].length} product page(s) to keep." if plan[:products].any?
|
|
1553
|
+
lines.join("\n")
|
|
1554
|
+
end
|
|
1555
|
+
private_class_method :format_browser_import
|
|
1556
|
+
|
|
1557
|
+
def self.browser_import_counts(plan)
|
|
1558
|
+
skipped = plan[:skipped].map { |reason, n| "#{n} #{reason}" }.join(", ")
|
|
1559
|
+
lines = ["Read #{plan[:rows][:history]} history and #{plan[:rows][:bookmark]} bookmark row(s) across " \
|
|
1560
|
+
"#{plan[:domains]} domain(s) from #{plan[:browser]}.",
|
|
1561
|
+
"Skipped #{skipped.empty? ? 'none' : skipped}; probed #{plan[:probed]} " \
|
|
1562
|
+
"(#{plan[:not_ucp]} without UCP, #{plan[:cached_miss]} already known not to)."]
|
|
1563
|
+
lines << "Hit the #{plan[:probed]}-probe cap; #{plan[:unprobed]} domain(s) left unprobed." if plan[:capped]
|
|
1564
|
+
lines
|
|
1565
|
+
end
|
|
1566
|
+
private_class_method :browser_import_counts
|
|
1567
|
+
|
|
1568
|
+
def self.browser_import_line(entry)
|
|
1569
|
+
categories = entry[:category_names].empty? ? "uncategorised" : entry[:category_names].join(", ")
|
|
1570
|
+
"#{entry[:domain]} — #{entry[:verdict]} — #{categories} " \
|
|
1571
|
+
"(#{entry[:sources].join(', ')}, #{entry[:visits]} visit(s))"
|
|
1572
|
+
end
|
|
1573
|
+
private_class_method :browser_import_line
|
|
1574
|
+
|
|
1575
|
+
# --- browser profile (docs/plans/buy-skill-and-local-browser.md Phase 6) ---
|
|
1576
|
+
|
|
1577
|
+
BROWSER_PROFILE_SUBCOMMANDS = {
|
|
1578
|
+
"init" => ->(argv) { run_browser_profile_init(argv) },
|
|
1579
|
+
"open" => ->(argv) { run_browser_profile_open(argv) },
|
|
1580
|
+
"status" => ->(argv) { run_browser_profile_status(argv) }
|
|
1581
|
+
}.freeze
|
|
1582
|
+
|
|
1583
|
+
def self.run_browser_profile(argv)
|
|
1584
|
+
sub = argv.first && !argv.first.start_with?("-") ? argv.shift : nil
|
|
1585
|
+
return browser_profile_usage unless BROWSER_PROFILE_SUBCOMMANDS.key?(sub)
|
|
1586
|
+
|
|
1587
|
+
BROWSER_PROFILE_SUBCOMMANDS[sub].call(argv)
|
|
1588
|
+
end
|
|
1589
|
+
private_class_method :run_browser_profile
|
|
1590
|
+
|
|
1591
|
+
def self.browser_profile_usage
|
|
1592
|
+
warn USAGE
|
|
1593
|
+
1
|
|
1594
|
+
end
|
|
1595
|
+
private_class_method :browser_profile_usage
|
|
1596
|
+
|
|
1597
|
+
def self.parse_browser_profile_options(argv)
|
|
1598
|
+
opts = { browser: nil, port: BrowserProfile::Profile::DEFAULT_PORT, url: nil, json: false }
|
|
1599
|
+
OptionParser.new do |parser|
|
|
1600
|
+
parser.on("--browser NAME", BrowserProfile::Browsers::CHROMIUM) { |v| opts[:browser] = v }
|
|
1601
|
+
parser.on("--port N", Integer) { |v| opts[:port] = v }
|
|
1602
|
+
parser.on("--url URL") { |v| opts[:url] = v }
|
|
1603
|
+
parser.on("--json") { opts[:json] = true }
|
|
1604
|
+
end.parse!(argv)
|
|
1605
|
+
opts
|
|
1606
|
+
end
|
|
1607
|
+
private_class_method :parse_browser_profile_options
|
|
1608
|
+
|
|
1609
|
+
# `--browser` names the exact browser; without it, the first Chromium
|
|
1610
|
+
# family browser BrowserImport::Profiles finds installed, falling back
|
|
1611
|
+
# to "chrome" — same "pick something reasonable, let --browser
|
|
1612
|
+
# override" posture as run_browser_import's own default.
|
|
1613
|
+
def self.browser_profile_for(opts)
|
|
1614
|
+
browser = opts[:browser] || BrowserProfile::Browsers.detect || "chrome"
|
|
1615
|
+
BrowserProfile::Profile.new(browser: browser, port: opts[:port])
|
|
1616
|
+
end
|
|
1617
|
+
private_class_method :browser_profile_for
|
|
1618
|
+
|
|
1619
|
+
def self.run_browser_profile_init(argv)
|
|
1620
|
+
opts = parse_browser_profile_options(argv)
|
|
1621
|
+
result = browser_profile_for(opts).init!
|
|
1622
|
+
puts opts[:json] ? JSON.pretty_generate(result) : "Profile ready at #{result[:dir]} (#{result[:browser]})."
|
|
1623
|
+
0
|
|
1624
|
+
rescue OptionParser::ParseError => e
|
|
1625
|
+
warn "#{e.message}\n#{USAGE}"
|
|
1626
|
+
1
|
|
1627
|
+
end
|
|
1628
|
+
private_class_method :run_browser_profile_init
|
|
1629
|
+
|
|
1630
|
+
# Launches the profile (if it isn't already running on its own port)
|
|
1631
|
+
# and either opens a new tab at --url or attaches to the first
|
|
1632
|
+
# existing one. Never touches the browser's default profile — Profile
|
|
1633
|
+
# itself only ever points --user-data-dir at its own dedicated
|
|
1634
|
+
# directory.
|
|
1635
|
+
def self.run_browser_profile_open(argv)
|
|
1636
|
+
opts = parse_browser_profile_options(argv)
|
|
1637
|
+
result = browser_profile_for(opts).open!(url: opts[:url])
|
|
1638
|
+
puts opts[:json] ? JSON.pretty_generate(result) : format_browser_profile_open(result)
|
|
1639
|
+
0
|
|
1640
|
+
rescue BrowserProfile::Error => e
|
|
1641
|
+
report_browser_profile_error(e, opts[:json])
|
|
1642
|
+
rescue OptionParser::ParseError => e
|
|
1643
|
+
warn "#{e.message}\n#{USAGE}"
|
|
1644
|
+
1
|
|
1645
|
+
end
|
|
1646
|
+
private_class_method :run_browser_profile_open
|
|
1647
|
+
|
|
1648
|
+
def self.run_browser_profile_status(argv)
|
|
1649
|
+
opts = parse_browser_profile_options(argv)
|
|
1650
|
+
result = browser_profile_for(opts).status
|
|
1651
|
+
puts opts[:json] ? JSON.pretty_generate(result) : format_browser_profile_status(result)
|
|
1652
|
+
0
|
|
1653
|
+
rescue OptionParser::ParseError => e
|
|
1654
|
+
warn "#{e.message}\n#{USAGE}"
|
|
1655
|
+
1
|
|
1656
|
+
end
|
|
1657
|
+
private_class_method :run_browser_profile_status
|
|
1658
|
+
|
|
1659
|
+
def self.format_browser_profile_open(result)
|
|
1660
|
+
tab = result.dig(:target, "url")
|
|
1661
|
+
"#{result[:browser]} profile is open (port #{result[:port]}, #{result[:dir]})#{" — tab: #{tab}" if tab}."
|
|
1662
|
+
end
|
|
1663
|
+
private_class_method :format_browser_profile_open
|
|
1664
|
+
|
|
1665
|
+
def self.format_browser_profile_status(result)
|
|
1666
|
+
return "#{result[:browser]} profile (#{result[:dir]}) isn't running." unless result[:running]
|
|
1667
|
+
|
|
1668
|
+
"#{result[:browser]} profile is running on port #{result[:port]} (#{result[:dir]})."
|
|
1669
|
+
end
|
|
1670
|
+
private_class_method :format_browser_profile_status
|
|
1671
|
+
|
|
1672
|
+
def self.report_browser_profile_error(error, json)
|
|
1673
|
+
if json
|
|
1674
|
+
puts JSON.pretty_generate(error: error.class.name.split("::").last, message: error.message)
|
|
1675
|
+
else
|
|
1676
|
+
warn error.message
|
|
1677
|
+
end
|
|
1678
|
+
1
|
|
1679
|
+
end
|
|
1680
|
+
private_class_method :report_browser_profile_error
|
|
1681
|
+
|
|
836
1682
|
# --- doctor ---
|
|
837
1683
|
|
|
838
1684
|
def self.parse_doctor_options(argv)
|
|
@@ -847,7 +1693,12 @@ module Portage
|
|
|
847
1693
|
end
|
|
848
1694
|
private_class_method :parse_doctor_options
|
|
849
1695
|
|
|
850
|
-
|
|
1696
|
+
# `wizard: :force` is `portage setup`, always offering the wizard on a
|
|
1697
|
+
# TTY; `:auto` is `doctor`/`configure`, which only offers it when
|
|
1698
|
+
# Doctor#nothing_configured? — the read-only report is what every other
|
|
1699
|
+
# run of `doctor` still gets, exactly as before this phase
|
|
1700
|
+
# (docs/plans/buy-skill-and-local-browser.md Phase 4).
|
|
1701
|
+
def self.run_doctor(argv, wizard: :auto)
|
|
851
1702
|
opts = parse_doctor_options(argv)
|
|
852
1703
|
require File.expand_path(opts[:require]) if opts[:require]
|
|
853
1704
|
adapter_class = opts[:adapter] && Object.const_get(opts[:adapter])
|
|
@@ -856,10 +1707,31 @@ module Portage
|
|
|
856
1707
|
|
|
857
1708
|
doctor = Doctor.new(adapter_class: adapter_class, proxy_settings: proxy_settings,
|
|
858
1709
|
seller: !(opts[:require] || opts[:adapter]).nil?)
|
|
1710
|
+
return run_setup_wizard if run_wizard?(wizard, opts, doctor)
|
|
1711
|
+
|
|
859
1712
|
report_doctor(doctor.call, json: opts[:json])
|
|
860
1713
|
end
|
|
861
1714
|
private_class_method :run_doctor
|
|
862
1715
|
|
|
1716
|
+
def self.run_setup(argv) = run_doctor(argv, wizard: :force)
|
|
1717
|
+
private_class_method :run_setup
|
|
1718
|
+
|
|
1719
|
+
# --json or no TTY on stdin always stays today's read-only report,
|
|
1720
|
+
# whichever command name was used — a piped/CI/agent run never blocks
|
|
1721
|
+
# on a prompt it can't answer.
|
|
1722
|
+
def self.run_wizard?(mode, opts, doctor)
|
|
1723
|
+
return false if opts[:json] || !$stdin.tty?
|
|
1724
|
+
return true if mode == :force
|
|
1725
|
+
|
|
1726
|
+
doctor.nothing_configured?
|
|
1727
|
+
end
|
|
1728
|
+
private_class_method :run_wizard?
|
|
1729
|
+
|
|
1730
|
+
def self.run_setup_wizard
|
|
1731
|
+
SetupWizard.new.call
|
|
1732
|
+
end
|
|
1733
|
+
private_class_method :run_setup_wizard
|
|
1734
|
+
|
|
863
1735
|
def self.report_doctor(findings, json:)
|
|
864
1736
|
puts json ? JSON.pretty_generate(findings.map(&:to_h)) : format_doctor(findings)
|
|
865
1737
|
findings.none?(&:warning?) ? 0 : 1
|
|
@@ -934,6 +1806,7 @@ module Portage
|
|
|
934
1806
|
lines = ["[#{report[:outcome]}] #{report[:message]} (source: #{report[:source]})"]
|
|
935
1807
|
report[:products].each { |p| lines << " - #{product_line(p)}" }
|
|
936
1808
|
lines.concat(format_checkout(report))
|
|
1809
|
+
lines.concat(format_quote(report))
|
|
937
1810
|
lines << " checkout: #{report[:checkout_url]}" if report[:checkout_url]
|
|
938
1811
|
lines.concat(format_handoff(report[:handoff])) if report[:handoff]
|
|
939
1812
|
lines.concat(format_decisions(report[:decisions])) if report[:decisions]&.any?
|
|
@@ -941,6 +1814,18 @@ module Portage
|
|
|
941
1814
|
end
|
|
942
1815
|
private_class_method :format_report
|
|
943
1816
|
|
|
1817
|
+
def self.format_quote(report)
|
|
1818
|
+
lines = report[:quote_id] ? [" quote: #{report[:quote_id]}"] : []
|
|
1819
|
+
lines << " approve: #{approval_line(report[:summary])}" if report[:summary]
|
|
1820
|
+
lines
|
|
1821
|
+
end
|
|
1822
|
+
private_class_method :format_quote
|
|
1823
|
+
|
|
1824
|
+
def self.approval_line(summary)
|
|
1825
|
+
[Approve.describe(summary), summary[:url]].compact.join(" — ")
|
|
1826
|
+
end
|
|
1827
|
+
private_class_method :approval_line
|
|
1828
|
+
|
|
944
1829
|
# What the checkout holds, as opposed to the search results above it,
|
|
945
1830
|
# and where it differs from the request.
|
|
946
1831
|
def self.format_checkout(report)
|
|
@@ -973,6 +1858,7 @@ module Portage
|
|
|
973
1858
|
def self.format_find(report)
|
|
974
1859
|
lines = [report[:message].to_s]
|
|
975
1860
|
report[:offers].each_with_index { |offer, index| lines << " #{index + 1}. #{offer_line(offer)}" }
|
|
1861
|
+
lines << " search: #{report[:search_id]} (portage pick --search #{report[:search_id]})" if report[:search_id]
|
|
976
1862
|
lines.join("\n")
|
|
977
1863
|
end
|
|
978
1864
|
private_class_method :format_find
|
|
@@ -980,6 +1866,7 @@ module Portage
|
|
|
980
1866
|
def self.offer_line(offer)
|
|
981
1867
|
parts = ["#{offer[:store]} — #{offer[:title]} (#{offer[:product_id]})", format_price(offer)]
|
|
982
1868
|
parts << "browse only" unless offer[:checkout]
|
|
1869
|
+
parts << "ref #{offer[:offer_ref]}" if offer[:offer_ref]
|
|
983
1870
|
parts.join(" — ")
|
|
984
1871
|
end
|
|
985
1872
|
private_class_method :offer_line
|
|
@@ -987,6 +1874,7 @@ module Portage
|
|
|
987
1874
|
def self.format_compare(report)
|
|
988
1875
|
lines = [report[:message].to_s]
|
|
989
1876
|
report[:offers].each_with_index { |offer, index| lines << " #{index + 1}. #{compare_offer_line(offer)}" }
|
|
1877
|
+
lines << " search: #{report[:search_id]} (portage pick --search #{report[:search_id]})" if report[:search_id]
|
|
990
1878
|
lines.join("\n")
|
|
991
1879
|
end
|
|
992
1880
|
private_class_method :format_compare
|
|
@@ -994,6 +1882,7 @@ module Portage
|
|
|
994
1882
|
def self.compare_offer_line(offer)
|
|
995
1883
|
parts = ["[#{offer[:match]}] #{offer[:store]} — #{offer[:title]} (#{offer[:product_id]})", format_price(offer)]
|
|
996
1884
|
parts << "browse only" unless offer[:checkout]
|
|
1885
|
+
parts << "ref #{offer[:offer_ref]}" if offer[:offer_ref]
|
|
997
1886
|
parts.join(" — ")
|
|
998
1887
|
end
|
|
999
1888
|
private_class_method :compare_offer_line
|
|
@@ -1005,9 +1894,7 @@ module Portage
|
|
|
1005
1894
|
end
|
|
1006
1895
|
private_class_method :format_price
|
|
1007
1896
|
|
|
1008
|
-
def self.format_amount(amount, currency)
|
|
1009
|
-
"#{format('%.2f', amount / 100.0)}#{" #{currency}" if currency}"
|
|
1010
|
-
end
|
|
1897
|
+
def self.format_amount(amount, currency) = Money.format_amount(amount, currency)
|
|
1011
1898
|
private_class_method :format_amount
|
|
1012
1899
|
end
|
|
1013
1900
|
end
|