portage-cli 0.7.4 → 0.8.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 +580 -0
- data/README.md +322 -19
- data/exe/portage +5 -0
- data/exe/portage-console +3 -0
- data/known-stores/categories.yml +1263 -0
- data/lib/portage/cli/agent_profile_url.rb +30 -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_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 +521 -29
- data/lib/portage/cli/classifier.rb +158 -0
- data/lib/portage/cli/compare.rb +3 -0
- data/lib/portage/cli/doctor.rb +195 -7
- data/lib/portage/cli/dot_env.rb +117 -0
- data/lib/portage/cli/find.rb +96 -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/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/offer_sources.rb +460 -0
- data/lib/portage/cli/payment_methods.rb +24 -3
- 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 +528 -6
- metadata +59 -2
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
require "yaml"
|
|
2
|
+
|
|
3
|
+
module Portage
|
|
4
|
+
module Cli
|
|
5
|
+
# "What category is this?" for anything shopping-shaped: a search query,
|
|
6
|
+
# a catalog product's title/description, a stores.yml/index entry, or
|
|
7
|
+
# (Phase 3) a browser history/bookmark entry — one classifier, so a
|
|
8
|
+
# store tagged from its product mix and a query typed by a shopper land
|
|
9
|
+
# in the same category space and can be matched against each other.
|
|
10
|
+
#
|
|
11
|
+
# Plain keyword matching against Google's own published product taxonomy
|
|
12
|
+
# (https://www.google.com/basepages/producttype/taxonomy-with-ids.en-US.txt),
|
|
13
|
+
# top two levels only (~200 nodes, `known-stores/categories.yml`, shipped
|
|
14
|
+
# in the gem so this works offline on a fresh brew/gem install). No LLM
|
|
15
|
+
# and no network call: it has to run on every `find` and stay instant.
|
|
16
|
+
# "Plain keyword" means a whole-word match (plus simple plural
|
|
17
|
+
# normalization, #word_match?), not a substring regex — a substring
|
|
18
|
+
# check matches in both directions ("carpet" contains "pet", "chair"
|
|
19
|
+
# contains "hair", "scarf" contains "car") and was a real source of
|
|
20
|
+
# false positives before this became whole-word.
|
|
21
|
+
module Classifier
|
|
22
|
+
# `known-stores/categories.yml`, from `lib/portage/cli/classifier.rb`.
|
|
23
|
+
KNOWN_PATH = File.expand_path("../../../known-stores/categories.yml", __dir__).freeze
|
|
24
|
+
|
|
25
|
+
# The user's own additions/overrides — same id overrides a shipped
|
|
26
|
+
# node's keywords, a new id extends the taxonomy. Absent by default;
|
|
27
|
+
# nothing here is required for the shipped file to work.
|
|
28
|
+
PATH = File.join(Dir.home, ".portage", "categories.yml").freeze
|
|
29
|
+
|
|
30
|
+
# `/products/<slug>`, `/collections/<slug>`, `/c/<slug>`,
|
|
31
|
+
# `/category/<slug>` — the URL shapes stores.yml/index/browser-import
|
|
32
|
+
# entries actually carry (docs/plans/buy-skill-and-local-browser.md
|
|
33
|
+
# Phase 2a/3).
|
|
34
|
+
SLUG_PATTERNS = [
|
|
35
|
+
%r{/products/([^/?#]+)},
|
|
36
|
+
%r{/collections/([^/?#]+)},
|
|
37
|
+
%r{/c/([^/?#]+)},
|
|
38
|
+
%r{/category/([^/?#]+)}
|
|
39
|
+
].freeze
|
|
40
|
+
|
|
41
|
+
# @param text [String] a query, a product title/description, or a
|
|
42
|
+
# store/product URL. Whichever it is, it's tokenized the same way
|
|
43
|
+
# (see #tokenize) so the same node keywords match all of them.
|
|
44
|
+
# @param known_path [String] override for KNOWN_PATH — specs redirect
|
|
45
|
+
# this the same way SearchBackends::Allowlist takes its own `path:`.
|
|
46
|
+
# @param user_path [String] override for PATH.
|
|
47
|
+
# @return [Array<String>] category ids, most keyword hits first. Ties
|
|
48
|
+
# keep the shipped file's own order. Empty when nothing matches.
|
|
49
|
+
def self.categories_for(text, known_path: KNOWN_PATH, user_path: PATH)
|
|
50
|
+
words = tokenize(text)
|
|
51
|
+
return [] if words.empty?
|
|
52
|
+
|
|
53
|
+
scored = nodes(known_path, user_path).filter_map { |id, node| rank(id, node, words) }
|
|
54
|
+
scored.sort_by { |(_id, score, order)| [-score, order] }.map(&:first)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# @return [Array<String>] the taxonomy names for `ids`, in order —
|
|
58
|
+
# `portage browser import` (Phase 3) shows a domain's guessed
|
|
59
|
+
# categories by name so the user can judge them before saving.
|
|
60
|
+
def self.names_for(ids, known_path: KNOWN_PATH, user_path: PATH)
|
|
61
|
+
all = nodes(known_path, user_path)
|
|
62
|
+
Array(ids).filter_map { |id| all.dig(id.to_s, "name") }
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# --- Tokenizing the input ---
|
|
66
|
+
|
|
67
|
+
# A URL slug is words joined by `-`/`_` (`hand-cut-glass` — see
|
|
68
|
+
# AGENTS.md's own product terminology); everything else is just
|
|
69
|
+
# whitespace/punctuation-split. Both a URL's slug words and the plain
|
|
70
|
+
# words of a title/description/query are kept, so "hiking boots" and
|
|
71
|
+
# "https://shop.example/products/hiking-boots-mens" tokenize the same
|
|
72
|
+
# way.
|
|
73
|
+
# Below 3 letters, a token is noise ("a", "of", "is") rather than a
|
|
74
|
+
# keyword candidate — every generated keyword is at least this long
|
|
75
|
+
# too (see the script that built known-stores/categories.yml), so
|
|
76
|
+
# nothing below this length could ever whole-word match one anyway.
|
|
77
|
+
MIN_WORD_LENGTH = 3
|
|
78
|
+
|
|
79
|
+
# Public rather than private — SearchBackends::Index (Phase 2b) tokenizes
|
|
80
|
+
# a query and a product's title/aliases the same way this module does
|
|
81
|
+
# internally, so a query and a product name land in the same word
|
|
82
|
+
# space instead of re-implementing this split.
|
|
83
|
+
def self.tokenize(text)
|
|
84
|
+
string = text.to_s
|
|
85
|
+
slug_words = SLUG_PATTERNS.filter_map { |pattern| pattern.match(string)&.[](1) }
|
|
86
|
+
.flat_map { |slug| slug.split(/[-_]/) }
|
|
87
|
+
(slug_words + string.split(/[^\p{Alpha}]+/)).map(&:downcase)
|
|
88
|
+
.select { |word| word.length >= MIN_WORD_LENGTH }
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# --- Scoring one node against the tokenized input ---
|
|
92
|
+
|
|
93
|
+
def self.rank(id, node, words)
|
|
94
|
+
keywords = Array(node["keywords"])
|
|
95
|
+
hits = keywords.count { |keyword| words.any? { |word| word_match?(word, keyword) } }
|
|
96
|
+
return nil unless hits.positive?
|
|
97
|
+
|
|
98
|
+
[id, hits, node["order"].to_i]
|
|
99
|
+
end
|
|
100
|
+
private_class_method :rank
|
|
101
|
+
|
|
102
|
+
# Whole-word only, plus the plural forms a keyword list and a real
|
|
103
|
+
# query/title actually differ by: an exact match, one plus a trailing
|
|
104
|
+
# "s" or "es" ("boot"/"boots", "watch"/"watches"), or the "y"/"ies"
|
|
105
|
+
# swap ("battery"/"batteries"). Deliberately not a substring check —
|
|
106
|
+
# substrings match in both directions regardless of word boundaries
|
|
107
|
+
# ("carpet" contains "pet", "chair" contains "hair", "scarf" contains
|
|
108
|
+
# "car"), which is real noise, not stemming.
|
|
109
|
+
# Public — see .tokenize's own comment; SearchBackends::Index (Phase
|
|
110
|
+
# 2b) matches a query's tokens against a product's title/alias tokens
|
|
111
|
+
# with this same whole-word (plus plural) rule, rather than a
|
|
112
|
+
# substring check that goes both ways ("tea" in "steam"/"teak", "bag"
|
|
113
|
+
# in "bagel").
|
|
114
|
+
def self.word_match?(word, keyword)
|
|
115
|
+
word == keyword || plural_of?(word, keyword) || plural_of?(keyword, word) || ies_y_match?(word, keyword)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# @return [Boolean] true when `plural` is `singular` plus a trailing
|
|
119
|
+
# "s" or "es" ("boot"/"boots", "watch"/"watches").
|
|
120
|
+
def self.plural_of?(plural, singular)
|
|
121
|
+
["#{singular}s", "#{singular}es"].include?(plural)
|
|
122
|
+
end
|
|
123
|
+
private_class_method :plural_of?
|
|
124
|
+
|
|
125
|
+
# @return [Boolean] true when either side is the other's "ies" plural
|
|
126
|
+
# ("battery"/"batteries").
|
|
127
|
+
def self.ies_y_match?(word, keyword)
|
|
128
|
+
(word.end_with?("ies") && keyword == "#{word[0..-4]}y") ||
|
|
129
|
+
(keyword.end_with?("ies") && word == "#{keyword[0..-4]}y")
|
|
130
|
+
end
|
|
131
|
+
private_class_method :ies_y_match?
|
|
132
|
+
|
|
133
|
+
# --- Loading and merging the two files ---
|
|
134
|
+
|
|
135
|
+
# Re-read on every call rather than cached process-wide: `find` calls
|
|
136
|
+
# this once or twice per invocation, not in a hot loop, and a cached
|
|
137
|
+
# copy would miss an edit to ~/.portage/categories.yml until the next
|
|
138
|
+
# process start.
|
|
139
|
+
def self.nodes(known_path, user_path)
|
|
140
|
+
ordered = {}
|
|
141
|
+
load_yaml(known_path).each_with_index { |(id, node), i| ordered[id] = node.merge("order" => i) }
|
|
142
|
+
load_yaml(user_path).each_with_index { |(id, node), i| ordered[id] = node.merge("order" => ordered.size + i) }
|
|
143
|
+
ordered
|
|
144
|
+
end
|
|
145
|
+
private_class_method :nodes
|
|
146
|
+
|
|
147
|
+
def self.load_yaml(path)
|
|
148
|
+
return {} unless path && File.readable?(path)
|
|
149
|
+
|
|
150
|
+
data = YAML.safe_load_file(path)
|
|
151
|
+
data.is_a?(Hash) ? data : {}
|
|
152
|
+
rescue StandardError
|
|
153
|
+
{}
|
|
154
|
+
end
|
|
155
|
+
private_class_method :load_yaml
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
data/lib/portage/cli/compare.rb
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
require_relative "find"
|
|
2
|
+
require_relative "handoff_only"
|
|
2
3
|
|
|
3
4
|
module Portage
|
|
4
5
|
module Cli
|
|
@@ -53,6 +54,8 @@ module Portage
|
|
|
53
54
|
def resolve_origin
|
|
54
55
|
uri = parse_http(@origin_url)
|
|
55
56
|
return report(message: "Not a store URL: #{@origin_url.inspect}") unless uri
|
|
57
|
+
return report(message: "#{uri.host} is hand-off only — #{HandoffOnly::LEGAL_NOTICE}") \
|
|
58
|
+
if @handoff_only.host?(uri.host)
|
|
56
59
|
|
|
57
60
|
session = discover(origin_of(uri))
|
|
58
61
|
return report(message: "#{origin_of(uri)} doesn't speak UCP.") unless session
|
data/lib/portage/cli/doctor.rb
CHANGED
|
@@ -4,6 +4,13 @@ require_relative "confidence_check"
|
|
|
4
4
|
require_relative "user_agent"
|
|
5
5
|
require_relative "proxy_settings"
|
|
6
6
|
require_relative "shipping_profile"
|
|
7
|
+
require_relative "dot_env"
|
|
8
|
+
require_relative "search_backends"
|
|
9
|
+
require_relative "agent_profile_url"
|
|
10
|
+
require_relative "index"
|
|
11
|
+
require_relative "handoff_target"
|
|
12
|
+
require_relative "handoff_only"
|
|
13
|
+
require_relative "offer_sources"
|
|
7
14
|
|
|
8
15
|
module Portage
|
|
9
16
|
module Cli
|
|
@@ -26,20 +33,50 @@ module Portage
|
|
|
26
33
|
def to_h = super.compact
|
|
27
34
|
end
|
|
28
35
|
|
|
29
|
-
|
|
36
|
+
SELLER_CHECKS = "authenticator, rate limiter, signing keys, payment handlers".freeze
|
|
37
|
+
|
|
38
|
+
# @param seller [Boolean] run the seller-side checks against
|
|
39
|
+
# Portage::Ucp.configuration. `portage doctor` passes true only when
|
|
40
|
+
# --require or --adapter loaded a seller's setup: in a bare shopper
|
|
41
|
+
# process that configuration is always the unconfigured default, so
|
|
42
|
+
# those four warnings were noise on every fresh install.
|
|
43
|
+
def initialize(adapter_class: nil, proxy_settings: ProxySettings.new, install_doctor: InstallDoctor.new,
|
|
44
|
+
seller: true, dot_env_path: DotEnv.loaded_path, index_stores: Index::Store.new,
|
|
45
|
+
index_products: Index::ProductStore.new, known_cache: Index::KnownCache.new)
|
|
30
46
|
@adapter_class = adapter_class
|
|
31
47
|
@proxy_settings = proxy_settings
|
|
32
48
|
@install_doctor = install_doctor
|
|
49
|
+
@seller = seller
|
|
50
|
+
@dot_env_path = dot_env_path
|
|
51
|
+
@index_stores = index_stores
|
|
52
|
+
@index_products = index_products
|
|
53
|
+
@known_cache = known_cache
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# `portage setup` always offers its wizard on a TTY; a bare `portage
|
|
57
|
+
# doctor`/`configure` only offers it once, on a fresh install, rather
|
|
58
|
+
# than on every run of someone who's already configured most of this
|
|
59
|
+
# (docs/plans/buy-skill-and-local-browser.md Phase 4). Conservative on
|
|
60
|
+
# purpose: true only when *all three* of the foundational settings —
|
|
61
|
+
# no `.env` file was loaded, no shipping address, no policy at all —
|
|
62
|
+
# are still untouched. Running `portage index build`/`browser import`
|
|
63
|
+
# without ever touching shipping/policy still counts as "nothing
|
|
64
|
+
# configured" here; those are the wizard's own later, opt-in steps,
|
|
65
|
+
# not what decides whether it's worth offering in the first place.
|
|
66
|
+
def nothing_configured?
|
|
67
|
+
@dot_env_path.nil? && ShippingProfile.from_env.nil? && Portage::Ucp::Policy.load.to_h.empty?
|
|
33
68
|
end
|
|
34
69
|
|
|
35
70
|
def call
|
|
36
71
|
[
|
|
37
72
|
*@install_doctor.findings,
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
signing_keys_finding,
|
|
41
|
-
payment_handlers_finding,
|
|
73
|
+
dot_env_finding,
|
|
74
|
+
*seller_findings,
|
|
42
75
|
decision_backend_finding,
|
|
76
|
+
search_backend_finding,
|
|
77
|
+
agent_profile_finding,
|
|
78
|
+
index_finding,
|
|
79
|
+
handoff_finding, retailer_offer_sources_finding,
|
|
43
80
|
user_agent_finding,
|
|
44
81
|
shipping_finding,
|
|
45
82
|
proxy_finding,
|
|
@@ -52,6 +89,31 @@ module Portage
|
|
|
52
89
|
|
|
53
90
|
def config = Portage::Ucp.configuration
|
|
54
91
|
|
|
92
|
+
def seller_findings
|
|
93
|
+
return [authenticator_finding, rate_limiter_finding, signing_keys_finding, payment_handlers_finding] if @seller
|
|
94
|
+
|
|
95
|
+
[Finding.new(check: "seller", level: "info",
|
|
96
|
+
message: "Seller checks (#{SELLER_CHECKS}) skipped — pass --require with your app's " \
|
|
97
|
+
"initializer, or --adapter, to run them.")]
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Which env file DotEnv loaded, and a warning when it's readable by
|
|
101
|
+
# other users: it's where store credentials and payment tokens end up.
|
|
102
|
+
def dot_env_finding
|
|
103
|
+
return unless @dot_env_path
|
|
104
|
+
|
|
105
|
+
mode = File.stat(@dot_env_path).mode & 0o777
|
|
106
|
+
details = { path: @dot_env_path, mode: format("%o", mode) }
|
|
107
|
+
return Finding.new(check: "env_file", level: "info", message: "Loaded #{@dot_env_path}", details: details) \
|
|
108
|
+
if mode.nobits?(0o077)
|
|
109
|
+
|
|
110
|
+
Finding.new(check: "env_file", details: details,
|
|
111
|
+
message: "#{@dot_env_path} is readable by other users (mode #{details[:mode]}) and may hold " \
|
|
112
|
+
"credentials — run `chmod 600 #{@dot_env_path}`.")
|
|
113
|
+
rescue SystemCallError
|
|
114
|
+
nil
|
|
115
|
+
end
|
|
116
|
+
|
|
55
117
|
def authenticator_finding
|
|
56
118
|
return unless config.authenticator.is_a?(Portage::Ucp::UnconfiguredAuthenticator)
|
|
57
119
|
|
|
@@ -89,6 +151,132 @@ module Portage
|
|
|
89
151
|
Finding.new(check: "decision_backend", message: e.message)
|
|
90
152
|
end
|
|
91
153
|
|
|
154
|
+
# `portage buy`/`portage find` with no URL/--store depend on a search
|
|
155
|
+
# backend to name candidate stores in the first place. Every install
|
|
156
|
+
# gets DuckDuckGo's free Instant Answer API for free, but it only
|
|
157
|
+
# resolves *entity* queries ("burton snowboards" → burton.com) — an
|
|
158
|
+
# open-ended query ("coffee", "iphone", "screens") comes back empty
|
|
159
|
+
# every time, regardless of what any store actually stocks (see
|
|
160
|
+
# SearchBackends::DuckDuckGo). That's easy to mistake for "find is
|
|
161
|
+
# broken" rather than "no real web search is configured", so this
|
|
162
|
+
# flags it up front rather than letting every URL-less search rediscover
|
|
163
|
+
# it one confusing empty result at a time.
|
|
164
|
+
def search_backend_finding
|
|
165
|
+
return if SearchBackends::Allowlist.new.available?
|
|
166
|
+
|
|
167
|
+
backends = SearchBackends.default
|
|
168
|
+
return unless SearchBackends.only_duckduckgo?(backends)
|
|
169
|
+
|
|
170
|
+
Finding.new(check: "search_backend", level: "info",
|
|
171
|
+
message: "Only DuckDuckGo's free Instant Answer API is active for `portage find`/" \
|
|
172
|
+
"`portage buy` without a URL — it resolves specific brand/product names, not " \
|
|
173
|
+
"open-ended search terms. Set BRAVE_SEARCH_API_KEY or GOOGLE_CSE_KEY+" \
|
|
174
|
+
"GOOGLE_CSE_CX for real web search, or list known stores in " \
|
|
175
|
+
"~/.portage/stores.yml.")
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# `find`/`buy`/OfferSources::ShopifyCatalog need
|
|
179
|
+
# `meta.ucp-agent.profile` on every catalog/cart/checkout call
|
|
180
|
+
# (Transports::Http#wire_meta) — PORTAGE_AGENT_PROFILE names it, and
|
|
181
|
+
# AgentProfileUrl falls back to the repo's own published profile
|
|
182
|
+
# (docs/agent-profile.md) so a fresh install without one set still
|
|
183
|
+
# works well enough to try. Always info, never a warning — the
|
|
184
|
+
# fallback isn't broken, just worth knowing about before a real
|
|
185
|
+
# deployment.
|
|
186
|
+
def agent_profile_finding
|
|
187
|
+
url = AgentProfileUrl.resolve
|
|
188
|
+
return Finding.new(check: "agent_profile", level: "info", message: "Agent profile: #{url}") \
|
|
189
|
+
unless ENV.fetch("PORTAGE_AGENT_PROFILE", nil).to_s.strip.empty?
|
|
190
|
+
|
|
191
|
+
Finding.new(check: "agent_profile", level: "info",
|
|
192
|
+
message: "PORTAGE_AGENT_PROFILE not set — falling back to the repo's own published " \
|
|
193
|
+
"profile (#{url}). Run `portage generate agent-profile` and set " \
|
|
194
|
+
"PORTAGE_AGENT_PROFILE to your own for production use.")
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# `portage index build/refresh` (Phase 2b) writes ~/.portage/index/
|
|
198
|
+
# {stores,products}.json — always info, never a warning: a fresh
|
|
199
|
+
# install with no index still works, `find` just has less to route
|
|
200
|
+
# by than one that's run `portage index build`.
|
|
201
|
+
#
|
|
202
|
+
# Also one of Phase 2c's three refresh triggers: this refreshes the
|
|
203
|
+
# known-stores cache (Index::KnownCache) whenever it's stale (more
|
|
204
|
+
# than 7 days old, which a missing cache counts as too), so a
|
|
205
|
+
# long-running install's cache doesn't just sit there getting older
|
|
206
|
+
# every time `find` happens not to trigger the lazy first-run fetch.
|
|
207
|
+
# One short-timeout GET per file, swallowed on any failure exactly
|
|
208
|
+
# like KnownCache's own #refresh! — this never turns doctor into a
|
|
209
|
+
# warning, just a report of whatever the fetch (or its absence)
|
|
210
|
+
# leaves behind.
|
|
211
|
+
def index_finding
|
|
212
|
+
refresh_known_cache_if_stale
|
|
213
|
+
return Finding.new(check: "index", level: "info", message: "#{index_message}\n#{known_cache_message}") \
|
|
214
|
+
if @index_stores.exists?
|
|
215
|
+
|
|
216
|
+
Finding.new(check: "index", level: "info",
|
|
217
|
+
message: "No local index yet — run `portage index build` to give `find` a list of " \
|
|
218
|
+
"stores/products on top of stores.yml and web search.\n#{known_cache_message}")
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def refresh_known_cache_if_stale
|
|
222
|
+
@known_cache.refresh! if @known_cache.stale?
|
|
223
|
+
rescue StandardError
|
|
224
|
+
nil
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# Phase 7: which of the five retailer OfferSources are on. Always
|
|
228
|
+
# info — every one of them is opt-in by design, and `find` behaves
|
|
229
|
+
# identically with none configured. Every one of these still ends in
|
|
230
|
+
# hand-off no matter how many are enabled, so this finding says that
|
|
231
|
+
# plainly rather than implying more keys means more automation.
|
|
232
|
+
def retailer_offer_sources_finding
|
|
233
|
+
on = OfferSources.retailers.select(&:available?).map(&:name)
|
|
234
|
+
message = if on.empty?
|
|
235
|
+
no_retailer_offer_sources_message
|
|
236
|
+
else
|
|
237
|
+
"Retailer offer sources active: #{on.join(', ')}. Every one still ends in hand-off; " \
|
|
238
|
+
"none can complete a purchase."
|
|
239
|
+
end
|
|
240
|
+
Finding.new(check: "retailer_offer_sources", level: "info", details: { active: on }, message: message)
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def no_retailer_offer_sources_message
|
|
244
|
+
"No retailer offer sources configured (Walmart, eBay, Best Buy, Etsy, Amazon) — run `portage " \
|
|
245
|
+
"setup` to add keys. Every one still ends in hand-off; none can complete a purchase."
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def index_message
|
|
249
|
+
age = @index_stores.oldest_verified_age
|
|
250
|
+
staleness = age ? "oldest entry verified #{age / 86_400} day(s) ago" : "no entries verified yet"
|
|
251
|
+
"Local index: #{@index_stores.all.length} store(s), #{@index_products.all.length} product(s) " \
|
|
252
|
+
"(#{staleness}). Run `portage index refresh` to re-verify entries older than 7 days."
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def known_cache_message
|
|
256
|
+
unless @known_cache.exists?
|
|
257
|
+
return "Known-stores list (fetched from the repo, docs/plans/buy-skill-and-local-browser.md " \
|
|
258
|
+
"Phase 2c): not fetched yet — offline, or the first fetch hasn't run."
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
age = @known_cache.age
|
|
262
|
+
days = age ? age / 86_400 : "?"
|
|
263
|
+
"Known-stores list: #{@known_cache.stores.length} store(s), #{@known_cache.products.length} " \
|
|
264
|
+
"product(s), fetched #{days} day(s) ago."
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
# docs/plans/buy-skill-and-local-browser.md Phase 5 — always info:
|
|
268
|
+
# both the current hand-off target and the hand-off-only host list are
|
|
269
|
+
# legitimate to leave at their defaults. The disclaimer runs here as
|
|
270
|
+
# well as on every `handoff_only` buy report and the `buy` skill's own
|
|
271
|
+
# reference doc (decision 5).
|
|
272
|
+
def handoff_finding
|
|
273
|
+
target = HandoffTarget.new
|
|
274
|
+
hosts = HandoffOnly.new.hosts
|
|
275
|
+
Finding.new(check: "handoff", level: "info", details: { target: target.label, handoff_only_hosts: hosts },
|
|
276
|
+
message: "Hand-off target: #{target.label}. Hand-off-only hosts (#{hosts.length}): " \
|
|
277
|
+
"#{hosts.join(', ')}. #{HandoffOnly::LEGAL_NOTICE}")
|
|
278
|
+
end
|
|
279
|
+
|
|
92
280
|
# PORTAGE_USER_AGENT / config.json's "user_agent" (UserAgent) is sent
|
|
93
281
|
# as-is on every outbound request. Net::HTTP raises ArgumentError on a
|
|
94
282
|
# header value containing CR/LF rather than send it, so a bad override
|
|
@@ -115,8 +303,8 @@ module Portage
|
|
|
115
303
|
.select { |var| ENV[var].to_s.empty? }
|
|
116
304
|
return if missing.empty?
|
|
117
305
|
|
|
118
|
-
Finding.new(check: "shipping", message: "#{shipping_gap(missing)}
|
|
119
|
-
".env.example lists every
|
|
306
|
+
Finding.new(check: "shipping", message: "#{shipping_gap(missing)} Set them in ~/.portage/.env " \
|
|
307
|
+
"(.env.example lists every variable) or your shell.")
|
|
120
308
|
end
|
|
121
309
|
|
|
122
310
|
def shipping_gap(missing)
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
require "fileutils"
|
|
2
|
+
|
|
3
|
+
module Portage
|
|
4
|
+
module Cli
|
|
5
|
+
# Loads `~/.portage/.env` (or the file PORTAGE_ENV_FILE names) into ENV
|
|
6
|
+
# before `portage`/`portage-console` run, so shipping address, search
|
|
7
|
+
# keys and adapter credentials can live in one file next to config.json
|
|
8
|
+
# instead of a shell profile. A Homebrew install has no repo checkout to
|
|
9
|
+
# keep a `.env` in, so the user's own Portage directory is the one place
|
|
10
|
+
# every install method shares.
|
|
11
|
+
#
|
|
12
|
+
# Deliberately *not* `./.env` from the working directory: `portage` run
|
|
13
|
+
# inside a cloned repo would then take that repo's PORTAGE_PROXY/
|
|
14
|
+
# PORTAGE_PROXY_CA (an intercepting proxy on store traffic), notify
|
|
15
|
+
# webhooks or store credentials without the user ever choosing them.
|
|
16
|
+
# PORTAGE_ENV_FILE=.env opts into a project file explicitly.
|
|
17
|
+
#
|
|
18
|
+
# Stdlib only (no dotenv gem, so the formula gains no resource). The real
|
|
19
|
+
# environment always wins, and an empty value is skipped, so a file
|
|
20
|
+
# copied from .env.example with blanks left in sets nothing.
|
|
21
|
+
module DotEnv
|
|
22
|
+
DEFAULT_PATH = File.join(Dir.home, ".portage", ".env").freeze
|
|
23
|
+
ESCAPES = { "n" => "\n", '"' => '"', "\\" => "\\" }.freeze
|
|
24
|
+
LINE = /\A\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)\z/
|
|
25
|
+
|
|
26
|
+
class << self
|
|
27
|
+
# The file #load read in this process, for `portage doctor`.
|
|
28
|
+
attr_reader :loaded_path
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# @return [String, nil] the path that was loaded, if any.
|
|
32
|
+
def self.load(path: ENV.fetch("PORTAGE_ENV_FILE", nil) || DEFAULT_PATH, env: ENV)
|
|
33
|
+
path = File.expand_path(path)
|
|
34
|
+
return unless File.file?(path) && File.readable?(path)
|
|
35
|
+
|
|
36
|
+
parse(File.read(path)).each { |key, value| env[key] = value unless env.key?(key) }
|
|
37
|
+
@loaded_path = path
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# @return [Hash{String => String}] non-empty assignments, in file order.
|
|
41
|
+
def self.parse(text)
|
|
42
|
+
text.each_line.filter_map do |line|
|
|
43
|
+
match = LINE.match(line.chomp)
|
|
44
|
+
next unless match
|
|
45
|
+
|
|
46
|
+
value = unquote(match[2])
|
|
47
|
+
[match[1], value] unless value.empty?
|
|
48
|
+
end.to_h
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# `"..."` (with \n, \" and \\ escapes) or `'...'` (literal); unquoted
|
|
52
|
+
# values drop a trailing ` # comment`.
|
|
53
|
+
def self.unquote(raw)
|
|
54
|
+
case raw
|
|
55
|
+
when /\A"((?:[^"\\]|\\.)*)"/
|
|
56
|
+
Regexp.last_match(1).gsub(/\\([n"\\])/) { ESCAPES.fetch(Regexp.last_match(1)) }
|
|
57
|
+
when /\A'([^']*)'/ then Regexp.last_match(1)
|
|
58
|
+
else raw.sub(/\s+#.*\z/, "").strip
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
private_class_method :unquote
|
|
62
|
+
|
|
63
|
+
# Writes `assignments` into the `.env` file at `path` (creating
|
|
64
|
+
# `~/.portage` if needed), for `portage setup`'s wizard
|
|
65
|
+
# (docs/plans/buy-skill-and-local-browser.md Phase 4) — the only
|
|
66
|
+
# writer this file has had until now. A key already on a line is
|
|
67
|
+
# replaced in place, so a re-run never duplicates it and every other
|
|
68
|
+
# line (unrelated keys, comments, blank lines) is left exactly as it
|
|
69
|
+
# was; a key that isn't already there is appended. `assignments`
|
|
70
|
+
# values are always double-quoted on write (never bare) so a value
|
|
71
|
+
# with a space — a street address — round-trips through #parse
|
|
72
|
+
# correctly.
|
|
73
|
+
#
|
|
74
|
+
# Mode 0600 from the very first byte, whether or not the file existed
|
|
75
|
+
# before: an existing file is chmod'd 600 *before* anything is
|
|
76
|
+
# written into it (an existing file could already be world-readable),
|
|
77
|
+
# and a new file is opened with mode 0600 baked into its `File.open`
|
|
78
|
+
# call — never `File.write` then `File.chmod`, which briefly leaves a
|
|
79
|
+
# brand-new file (holding a shipping address or a search API key) at
|
|
80
|
+
# the process umask's permissions, e.g. 0644, before the chmod lands.
|
|
81
|
+
#
|
|
82
|
+
# @param assignments [Hash{String => String}]
|
|
83
|
+
# @return [String] the path written.
|
|
84
|
+
def self.update!(assignments, path: ENV.fetch("PORTAGE_ENV_FILE", nil) || DEFAULT_PATH)
|
|
85
|
+
path = File.expand_path(path)
|
|
86
|
+
remaining = assignments.dup
|
|
87
|
+
lines = existing_lines(path).map { |line| replace_assignment(line, remaining) }
|
|
88
|
+
remaining.each { |key, value| lines << assignment_line(key, value) }
|
|
89
|
+
|
|
90
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
91
|
+
File.chmod(0o600, path) if File.file?(path)
|
|
92
|
+
File.open(path, File::WRONLY | File::CREAT | File::TRUNC, 0o600) { |f| f.write(lines.join) }
|
|
93
|
+
File.chmod(0o600, path)
|
|
94
|
+
path
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def self.existing_lines(path)
|
|
98
|
+
File.file?(path) ? File.readlines(path) : []
|
|
99
|
+
end
|
|
100
|
+
private_class_method :existing_lines
|
|
101
|
+
|
|
102
|
+
def self.replace_assignment(line, remaining)
|
|
103
|
+
match = LINE.match(line.chomp)
|
|
104
|
+
return line unless match && remaining.key?(match[1])
|
|
105
|
+
|
|
106
|
+
assignment_line(match[1], remaining.delete(match[1]))
|
|
107
|
+
end
|
|
108
|
+
private_class_method :replace_assignment
|
|
109
|
+
|
|
110
|
+
def self.assignment_line(key, value)
|
|
111
|
+
escaped = value.to_s.gsub("\\") { "\\\\" }.gsub('"') { "\\\"" }
|
|
112
|
+
"#{key}=\"#{escaped}\"\n"
|
|
113
|
+
end
|
|
114
|
+
private_class_method :assignment_line
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|