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.
Files changed (73) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +580 -0
  3. data/README.md +322 -19
  4. data/exe/portage +5 -0
  5. data/exe/portage-console +3 -0
  6. data/known-stores/categories.yml +1263 -0
  7. data/lib/portage/cli/agent_profile_url.rb +30 -0
  8. data/lib/portage/cli/browser_import/categorize.rb +59 -0
  9. data/lib/portage/cli/browser_import/confirm.rb +35 -0
  10. data/lib/portage/cli/browser_import/domains.rb +47 -0
  11. data/lib/portage/cli/browser_import/filter.rb +91 -0
  12. data/lib/portage/cli/browser_import/importer.rb +248 -0
  13. data/lib/portage/cli/browser_import/plist_xml.rb +72 -0
  14. data/lib/portage/cli/browser_import/prober.rb +60 -0
  15. data/lib/portage/cli/browser_import/profiles.rb +114 -0
  16. data/lib/portage/cli/browser_import/readers.rb +179 -0
  17. data/lib/portage/cli/browser_import/saver.rb +62 -0
  18. data/lib/portage/cli/browser_import/sqlite.rb +68 -0
  19. data/lib/portage/cli/browser_import.rb +23 -0
  20. data/lib/portage/cli/browser_profile/allowlist.rb +40 -0
  21. data/lib/portage/cli/browser_profile/bridge.rb +120 -0
  22. data/lib/portage/cli/browser_profile/browsers.rb +69 -0
  23. data/lib/portage/cli/browser_profile/cdp.rb +67 -0
  24. data/lib/portage/cli/browser_profile/cdp_socket.rb +186 -0
  25. data/lib/portage/cli/browser_profile/errors.rb +26 -0
  26. data/lib/portage/cli/browser_profile/launcher.rb +34 -0
  27. data/lib/portage/cli/browser_profile/profile.rb +93 -0
  28. data/lib/portage/cli/browser_profile.rb +25 -0
  29. data/lib/portage/cli/buy.rb +521 -29
  30. data/lib/portage/cli/classifier.rb +158 -0
  31. data/lib/portage/cli/compare.rb +3 -0
  32. data/lib/portage/cli/doctor.rb +195 -7
  33. data/lib/portage/cli/dot_env.rb +117 -0
  34. data/lib/portage/cli/find.rb +96 -12
  35. data/lib/portage/cli/handoff_agents.rb +186 -0
  36. data/lib/portage/cli/handoff_only.rb +94 -0
  37. data/lib/portage/cli/handoff_reconciler.rb +15 -1
  38. data/lib/portage/cli/handoff_target.rb +61 -0
  39. data/lib/portage/cli/index/builder.rb +335 -0
  40. data/lib/portage/cli/index/exporter.rb +91 -0
  41. data/lib/portage/cli/index/known_cache.rb +155 -0
  42. data/lib/portage/cli/index/product_store.rb +101 -0
  43. data/lib/portage/cli/index/sources/browser.rb +31 -0
  44. data/lib/portage/cli/index/sources/shopify_catalog.rb +82 -0
  45. data/lib/portage/cli/index/sources/stores_file.rb +58 -0
  46. data/lib/portage/cli/index/sources/webmcp_sweep.rb +29 -0
  47. data/lib/portage/cli/index/sources/wikidata.rb +95 -0
  48. data/lib/portage/cli/index/sources.rb +44 -0
  49. data/lib/portage/cli/index/store.rb +109 -0
  50. data/lib/portage/cli/index.rb +20 -0
  51. data/lib/portage/cli/known_stores_url.rb +15 -0
  52. data/lib/portage/cli/offer_sources.rb +460 -0
  53. data/lib/portage/cli/payment_methods.rb +24 -3
  54. data/lib/portage/cli/search_backends.rb +337 -12
  55. data/lib/portage/cli/setup_wizard/prompt.rb +67 -0
  56. data/lib/portage/cli/setup_wizard/steps/agent_profile.rb +60 -0
  57. data/lib/portage/cli/setup_wizard/steps/browser_import.rb +29 -0
  58. data/lib/portage/cli/setup_wizard/steps/handoff.rb +100 -0
  59. data/lib/portage/cli/setup_wizard/steps/index_build.rb +31 -0
  60. data/lib/portage/cli/setup_wizard/steps/policy.rb +60 -0
  61. data/lib/portage/cli/setup_wizard/steps/retailer_keys.rb +55 -0
  62. data/lib/portage/cli/setup_wizard/steps/search_keys.rb +54 -0
  63. data/lib/portage/cli/setup_wizard/steps/shipping.rb +51 -0
  64. data/lib/portage/cli/setup_wizard.rb +74 -0
  65. data/lib/portage/cli/version.rb +1 -1
  66. data/lib/portage/cli/webmcp.rb +10 -3
  67. data/lib/portage/cli/webmcp_autofill_confirm.rb +38 -0
  68. data/lib/portage/cli/webmcp_autofill_fields.rb +60 -0
  69. data/lib/portage/cli/webmcp_autofill_mode.rb +39 -0
  70. data/lib/portage/cli/webmcp_mapping_confirm.rb +68 -0
  71. data/lib/portage/cli/webmcp_mappings.rb +84 -0
  72. data/lib/portage/cli.rb +528 -6
  73. 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
@@ -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
@@ -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
- def initialize(adapter_class: nil, proxy_settings: ProxySettings.new, install_doctor: InstallDoctor.new)
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
- authenticator_finding,
39
- rate_limiter_finding,
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)} Export them in your shell; " \
119
- ".env.example lists every PORTAGE_SHIP_* variable.")
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