portage-cli 0.7.3 → 0.7.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 996ddc0acd1ec2676257f402fbb03148e8a38c40af9d5cf4fbe50d176416a334
4
- data.tar.gz: a451d4e8130b1891fb2630d9b5e9edc1efde9486afda2ca3ff863342943d4a09
3
+ metadata.gz: 58834de7ed60ddb98da33896147737f518ccb8d02d58f0d3aeceb645ecef8ea6
4
+ data.tar.gz: 24e471dfb23e48b8ff1f157dbac941442ca84571120ad754451dfb36d9c08d60
5
5
  SHA512:
6
- metadata.gz: dd25c1c868d3e70f6ef469975f0d2b24f478c1f96bf83a739d2e4a473c38d2695fbd3586e0243ac21f9d75562b080413ad173c18924024ff1135279cfe8622be
7
- data.tar.gz: e8578d6935466578e44e9e4f2e2caae61e1b9a1b2b52051934989fd28ec8ea9a163d943e3f68aac3704bdae72557d600f4694c7c81b8e9fe00b16685640a10b6
6
+ metadata.gz: 70c1a8b9db25d281091e496db0eb7e45de1e54b0afbc943a35866cc9ec2efb4a292b043a0720932ff5f3c2a93757ec3b217785d96ec4f2ece6e637132aed1d04
7
+ data.tar.gz: af42b5fb85cc582d86e60d616f38f177df92b6c53df59a7ccedcd8780855adb15f876e32ddbfda384bd579825ed90f68c5a833fd061ed79fce43e3a2c056edcd
data/CHANGELOG.md CHANGED
@@ -4,6 +4,41 @@ All notable changes to this project are documented here. Format loosely follows
4
4
  [Keep a Changelog](https://keepachangelog.com/en/1.0.0/); this project is
5
5
  pre-1.0, so APIs may still shift between minor versions.
6
6
 
7
+ ## [0.7.4] - 2026-09-25
8
+
9
+ - **`portage doctor` reports how it was installed, and warns when another
10
+ `portage` shadows it** (`docs/plans/homebrew-distribution.md` Phase 4).
11
+ New findings, all offline and without shelling out to `brew`:
12
+ - `install`: `homebrew` (with the Cellar keg) when this gem or its Ruby
13
+ lives under `HOMEBREW_PREFIX/Cellar/portage/` (`$HOMEBREW_PREFIX`,
14
+ then `/opt/homebrew`, `/usr/local`, `/home/linuxbrew/.linuxbrew`),
15
+ otherwise `gem` with the gem's own path;
16
+ - `runtime`: the Ruby version and `RbConfig.ruby` path, plus the
17
+ `portage-cli` version;
18
+ - `adapters`: each first-party adapter gem (plus `webmcp` and
19
+ `decision`), whether it loads and at which version. A gem install
20
+ without adapters is normal; a Homebrew install missing one is a
21
+ warning, since the formula bundles them all. A broken adapter is
22
+ reported, never raised;
23
+ - `path`: every `portage` on `PATH`, compared by resolved Cellar
24
+ location rather than raw path. Warns when a Homebrew install is
25
+ shadowed by an earlier `portage` (typically a `gem install` copy in a
26
+ mise/rbenv/asdf/rvm Ruby), naming the winner and how to fix it, and
27
+ when a gem install is shadowed by a Homebrew one.
28
+ - `portage doctor` also warns when the `PORTAGE_SHIP_*` address is missing
29
+ or incomplete, naming the missing variables. Without
30
+ `PORTAGE_SHIP_COUNTRY` a native UCP store gets no buyer context, which
31
+ is how a live Shopify store ended up reporting in-stock items as out of
32
+ stock.
33
+ - `doctor` findings now carry a `level` (`warning` or `info`) and, for the
34
+ new checks, structured `details`, both in `--json`. The JSON is still a
35
+ top-level array. Only warnings make doctor exit 1; the text output lists
36
+ info findings first, then the warnings or `No issues found.`.
37
+ - `ShippingProfile` treats an empty `PORTAGE_SHIP_*` value as unset, the
38
+ way `BuyerContext` and the WooCommerce billing fallback already did, so
39
+ a `.env` copied from `.env.example` with blanks left in no longer
40
+ submits an address of empty strings.
41
+
7
42
  ## [0.7.3] - 2026-09-25
8
43
 
9
44
  - **Proxy support** (`docs/plans/proxy-support.md` Phases 2-3). Every
data/README.md CHANGED
@@ -49,26 +49,81 @@ No single adapter gem is a hard dependency — install whichever
49
49
 
50
50
  ## Installation
51
51
 
52
+ **Homebrew** (macOS and Linux) — recommended for using the CLI:
53
+
54
+ ```bash
55
+ brew install tomtom87/portage/portage
56
+ ```
57
+
58
+ The formula installs `portage-cli` plus every adapter gem (Shopify, Wix,
59
+ WooCommerce, BigCommerce, Magento, Etsy, Instagram, WebMCP, Decision) into
60
+ its own directory, running on Homebrew's own `ruby`, so it doesn't depend on
61
+ or change whichever Ruby you use for anything else. It gives you `portage`
62
+ and `portage-console`.
63
+
64
+ **RubyGems** — on any Ruby ≥ 3.2, or when you only want some adapters:
65
+
66
+ ```bash
67
+ gem install portage-cli
68
+ gem install portage-ucp-shopify # optional: add only the adapters you need
69
+ ```
70
+
71
+ In an app's `Gemfile` instead:
72
+
52
73
  ```ruby
53
- # Gemfile
54
74
  gem "portage-cli"
55
75
  ```
56
76
 
77
+ ### Upgrading
78
+
79
+ - Homebrew: `brew upgrade portage`. Your config and data in `~/.portage`
80
+ (policy, payment-method metadata, transaction log, order ledger,
81
+ `config.json`) are left alone, as they are by `brew uninstall`.
82
+ - RubyGems: `gem update portage-cli` (and any adapter gems you added).
83
+
84
+ Portage has no self-update command, by design: whichever tool installed it
85
+ owns upgrades.
86
+
87
+ ### Linux: stored secrets need `secret-tool`
88
+
89
+ On Linux, stored payment tokens (`portage payment enroll`) and proxy
90
+ passwords (`password_ref`) live in the Secret Service (GNOME Keyring,
91
+ KWallet) via the `secret-tool` command. It comes from your distribution,
92
+ not from the formula or the gem:
93
+
57
94
  ```bash
58
- bundle install
95
+ sudo apt install libsecret-tools # Debian/Ubuntu
96
+ sudo dnf install libsecret # Fedora
59
97
  ```
60
98
 
61
- Or standalone:
99
+ Without it (or without a live D-Bus session, e.g. over SSH or in CI),
100
+ Portage uses the headless tier: the token comes from
101
+ `PORTAGE_PAYMENT_TOKEN` and nothing is stored locally. On macOS the
102
+ Keychain is used and nothing extra is needed.
103
+
104
+ ### Two copies on PATH
105
+
106
+ A `gem install` copy and a Homebrew copy can both be installed, and your
107
+ shell runs whichever `portage` comes first on `PATH`. A common case is an
108
+ old gem copy in a mise, rbenv, asdf or rvm Ruby's `bin`, which then keeps
109
+ running after `brew install` or `brew upgrade`. `portage doctor` warns
110
+ about this. To check:
62
111
 
63
112
  ```bash
64
- gem install portage-cli
113
+ which -a portage
65
114
  ```
66
115
 
116
+ To fix it, keep one copy: `gem uninstall portage-cli` (with the Ruby that
117
+ owns the gem copy active) to use Homebrew's, or `brew uninstall portage` to
118
+ use the gem's. Or reorder `PATH` so the one you want comes first.
119
+
67
120
  ## Usage
68
121
 
122
+ <!-- usage-start -->
69
123
  ```bash
70
124
  portage buy <url> --query "..." [--qty N] [--payment-token TOKEN] [--product-id ID]
71
- [--yes] [--dry-run] [--decision-backend jev|laya]
125
+ [--yes] [--dry-run] [--auto-open|--no-auto-open]
126
+ [--notify-webhook URL] [--decision-backend jev|laya]
72
127
  [--min-confidence N] [--json]
73
128
  [--wait [--wait-timeout DURATION|off]]
74
129
  portage buy --query "..." [--store URL] [--max-price N] [--limit N] ...
@@ -90,7 +145,12 @@ portage policy set [--per-transaction-cap N --currency CUR]
90
145
  [--velocity-count N --velocity-window-seconds N]
91
146
  [--allow HOST ...] [--clear-allowlist]
92
147
  portage orders reconcile [--checkout ID] [--json]
148
+ portage doctor [--require FILE] [--adapter CLASS_NAME] [--json] # aliases: configure, setup
149
+ portage generate adapter NAME [--dir DIR]
150
+ portage generate agent-profile [--out FILE] [--key-out FILE] [--rotate]
151
+ portage --version
93
152
  ```
153
+ <!-- usage-end -->
94
154
 
95
155
  `buy`/`find`/`compare`/`doctor`/`payment enroll` (the network-touching commands —
96
156
  `orders reconcile` doesn't take these) also accept:
@@ -342,6 +402,37 @@ plain-text `--wait` regardless of configuration), and `journal` (the order
342
402
  snapshot journal write, already unconditional — listing it just documents
343
403
  that).
344
404
 
405
+ ### Doctor
406
+
407
+ ```bash
408
+ portage doctor # aliases: portage configure, portage setup
409
+ portage doctor --json
410
+ ```
411
+
412
+ Checks this machine's setup without touching the network (apart from
413
+ probing any proxy you've configured). It first reports how Portage is
414
+ installed, then lists anything that needs fixing:
415
+
416
+ - `install`: `homebrew` (with the Cellar path) or `gem` (with the gem's
417
+ path).
418
+ - `runtime`: the Ruby version and path it runs on, and the `portage-cli`
419
+ version.
420
+ - `adapters`: which first-party adapter gems load, and at which version.
421
+ Missing adapters are expected on a gem install; on Homebrew, which
422
+ bundles them all, a missing one is a warning.
423
+ - `path`: which `portage` your shell actually runs. Warns when another copy
424
+ earlier on `PATH` shadows this one (see "Two copies on PATH" above).
425
+ - `shipping`: warns when `PORTAGE_SHIP_*` is missing or incomplete (see
426
+ "Shipping address" below), naming the variables to set.
427
+ - Seller-side checks against `Portage::Ucp.configuration` (authenticator,
428
+ rate limiter, signing keys, payment handlers; pass `--require` to load
429
+ your app's initializer first), the confidence gate's backend, the
430
+ User-Agent, and proxy settings.
431
+
432
+ With `--json` the output is an array of findings, each with `check`,
433
+ `message`, `level` (`warning` or `info`) and, for the install checks,
434
+ `details`. Doctor exits `1` when there's at least one warning, else `0`.
435
+
345
436
  ### Proxy
346
437
 
347
438
  `buy`, `find`, `compare`, `doctor`, and `payment enroll` accept the flags below,
@@ -511,12 +602,11 @@ gives up after 5 seconds and reports `notify_error` instead.
511
602
  `portage find`'s offer order comes from `Support::OfferRanking`: buyable
512
603
  first, then cheapest, then unpriced.
513
604
 
514
- ### Shipping address (own-store checkouts only)
605
+ ### Shipping address
515
606
 
516
- When buying against your own store (`portage buy`'s step 2 adapter-credentials
517
- fallback, described at the top of this file) and that adapter supports
518
- `dev.ucp.shopping.fulfillment`, set a default shipping address via env
519
- rather than a flag, same posture as adapter credentials:
607
+ Set your shipping address via env rather than a flag, the same way as
608
+ adapter credentials (`.env.example` at the repo root lists them all).
609
+ `portage doctor` warns until the required ones are set:
520
610
 
521
611
  ```bash
522
612
  export PORTAGE_SHIP_STREET="1 Main St"
@@ -529,12 +619,26 @@ export PORTAGE_SHIP_LAST_NAME="Lovelace" # optional
529
619
  export PORTAGE_SHIP_PHONE="+1..." # optional
530
620
  ```
531
621
 
532
- `street`/`city`/`country`/`postal_code` are required — a partial profile is
533
- treated as no profile at all. Once the merchant prices shipping options
534
- against that address, `portage buy` auto-picks the cheapest per fulfillment
535
- group; there's no interactive rate picker, since this drives one automated
536
- purchase. Native (non-adapter) UCP stores don't get this yet — see
537
- `portage-ucp`'s design log for why.
622
+ `street`/`city`/`country`/`postal_code` are required — a partial profile
623
+ (or one with empty values) is treated as no profile at all.
624
+
625
+ The variables are used in two ways:
626
+
627
+ - **Native UCP stores** (`buy` and `find` over HTTP) get
628
+ `PORTAGE_SHIP_COUNTRY`, `_REGION` and `_POSTAL_CODE` (plus
629
+ `PORTAGE_CURRENCY` and `PORTAGE_LANGUAGE`, if set) as UCP buyer context,
630
+ which a store uses to pick the market it prices and stocks in. These
631
+ work on their own, without the full address.
632
+ Without at least the country, a live Shopify store can report in-stock
633
+ items as out of stock.
634
+ - **Your own store** (`portage buy`'s adapter-credentials fallback,
635
+ described at the top of this file), when its adapter supports
636
+ `dev.ucp.shopping.fulfillment`, also gets the full address as the
637
+ checkout's shipping destination. Once the merchant prices shipping
638
+ options against it, `portage buy` auto-picks the cheapest per fulfillment
639
+ group; there's no interactive rate picker, since this drives one
640
+ automated purchase. Native (non-adapter) UCP stores don't get the full
641
+ address yet — see `portage-ucp`'s design log for why.
538
642
 
539
643
  ## Buying without a URL
540
644
 
@@ -3,6 +3,7 @@ require "uri"
3
3
  require_relative "confidence_check"
4
4
  require_relative "user_agent"
5
5
  require_relative "proxy_settings"
6
+ require_relative "shipping_profile"
6
7
 
7
8
  module Portage
8
9
  module Cli
@@ -16,21 +17,31 @@ module Portage
16
17
  # process, so a host app passes `--require` to load its own initializer
17
18
  # first (Rails: `--require ./config/environment`).
18
19
  class Doctor
19
- Finding = Struct.new(:check, :message, keyword_init: true)
20
+ # `level` is "warning" (something to fix; any one makes doctor exit 1)
21
+ # or "info" (a report, e.g. how portage was installed). `details` is
22
+ # structured data for `--json`; `to_h` drops it when there is none.
23
+ Finding = Struct.new(:check, :message, :level, :details, keyword_init: true) do
24
+ def initialize(level: "warning", **) = super
25
+ def warning? = level == "warning"
26
+ def to_h = super.compact
27
+ end
20
28
 
21
- def initialize(adapter_class: nil, proxy_settings: ProxySettings.new)
29
+ def initialize(adapter_class: nil, proxy_settings: ProxySettings.new, install_doctor: InstallDoctor.new)
22
30
  @adapter_class = adapter_class
23
31
  @proxy_settings = proxy_settings
32
+ @install_doctor = install_doctor
24
33
  end
25
34
 
26
35
  def call
27
36
  [
37
+ *@install_doctor.findings,
28
38
  authenticator_finding,
29
39
  rate_limiter_finding,
30
40
  signing_keys_finding,
31
41
  payment_handlers_finding,
32
42
  decision_backend_finding,
33
43
  user_agent_finding,
44
+ shipping_finding,
34
45
  proxy_finding,
35
46
  *proxy_doctor_findings,
36
47
  *capability_findings
@@ -93,6 +104,36 @@ module Portage
93
104
  "raise instead of sending.")
94
105
  end
95
106
 
107
+ # PORTAGE_SHIP_* (ShippingProfile, BuyerContext) has two jobs, and both
108
+ # fail quietly: with no complete address an own-store checkout goes
109
+ # out with no shipping destination, and with no PORTAGE_SHIP_COUNTRY a
110
+ # native UCP store gets no buyer context, so a live Shopify store
111
+ # builds a cart in no market and reports in-stock items as
112
+ # `merchandise_out_of_stock` (docs/ucp-tool-gating-investigation.md).
113
+ def shipping_finding
114
+ missing = ShippingProfile::REQUIRED.map { |key| ShippingProfile::ENV_VARS.fetch(key) }
115
+ .select { |var| ENV[var].to_s.empty? }
116
+ return if missing.empty?
117
+
118
+ Finding.new(check: "shipping", message: "#{shipping_gap(missing)} Export them in your shell; " \
119
+ ".env.example lists every PORTAGE_SHIP_* variable.")
120
+ end
121
+
122
+ def shipping_gap(missing)
123
+ country = ShippingProfile::ENV_VARS.fetch(:address_country)
124
+ gap = if missing.length == ShippingProfile::REQUIRED.length
125
+ "No shipping address set (#{missing.join(', ')})."
126
+ else
127
+ "Shipping address incomplete: missing #{missing.join(', ')}, and a partial address is " \
128
+ "treated as none."
129
+ end
130
+ gap += " `portage buy` sends no shipping destination to your own store's checkout"
131
+ return "#{gap}." unless missing.include?(country)
132
+
133
+ "#{gap}, and without #{country} a native UCP store (e.g. Shopify) gets no market to price in " \
134
+ "and can report in-stock items as out of stock."
135
+ end
136
+
96
137
  # Phase 0 of docs/plans/proxy-support.md: every raw Net::HTTP.start call
97
138
  # site in portage-cli/portage-ucp/the adapter gems resolves its proxy
98
139
  # from Ruby stdlib's own `:ENV` default, which — confirmed against a
@@ -192,3 +233,4 @@ end
192
233
  # Doctor has to exist first (this file requires proxy_settings, not
193
234
  # proxy_doctor, at the top for exactly this reason).
194
235
  require_relative "proxy_doctor"
236
+ require_relative "install_doctor"
@@ -0,0 +1,239 @@
1
+ require "rbconfig"
2
+ require_relative "version"
3
+
4
+ module Portage
5
+ module Cli
6
+ # Phase 4 of docs/plans/homebrew-distribution.md's `doctor` checks: how
7
+ # this copy of portage-cli was installed (Homebrew formula or plain
8
+ # `gem install`), which Ruby it runs on, which first-party adapter gems
9
+ # it can load, and whether the `portage` a shell would actually run is
10
+ # this one.
11
+ #
12
+ # That last check is the one that matters. The formula installs into its
13
+ # own Cellar keg, and a `gem install portage-cli` copy in a mise/rbenv/
14
+ # asdf/rvm Ruby's bin usually comes earlier on PATH, so `portage` in a
15
+ # shell silently keeps running the old gem after `brew install` or
16
+ # `brew upgrade` (Homebrew's own caveat hit exactly this on the
17
+ # maintainer's machine). Homebrew's `bin/portage` is a symlink into the
18
+ # Cellar, whose env_script wrapper execs the libexec binary, so every
19
+ # comparison here is between canonical Cellar locations, never raw PATH
20
+ # entries.
21
+ #
22
+ # Everything is read from the filesystem and the process: no `brew`
23
+ # shell-out, no network, so doctor stays fast and works offline (the
24
+ # formula's `test do` block runs it in Homebrew's sandbox).
25
+ class InstallDoctor
26
+ DEFAULT_HOMEBREW_PREFIXES = %w[/opt/homebrew /usr/local /home/linuxbrew/.linuxbrew].freeze
27
+ ADAPTER_GEMS = %w[shopify wix woocommerce bigcommerce magento etsy instagram webmcp decision]
28
+ .map { |name| "portage-ucp-#{name}" }.freeze
29
+ EXECUTABLE = "portage".freeze
30
+
31
+ def self.homebrew_prefixes
32
+ [ENV.fetch("HOMEBREW_PREFIX", nil), *DEFAULT_HOMEBREW_PREFIXES].reject { |p| p.to_s.empty? }.uniq
33
+ end
34
+
35
+ # @param path [String] PATH to search for `portage` executables.
36
+ # @param homebrew_prefixes [Array<String>] candidate HOMEBREW_PREFIXes.
37
+ # @param gem_dir [String] this gem's own directory (its `lib/`'s parent).
38
+ # @param ruby [String] the running Ruby's executable path.
39
+ # @param adapter_probe [#call] gem name -> { installed:, loadable:, version:, error: }.
40
+ def initialize(path: ENV.fetch("PATH", ""), homebrew_prefixes: self.class.homebrew_prefixes,
41
+ gem_dir: File.expand_path("../../..", __dir__), ruby: RbConfig.ruby,
42
+ adapter_probe: method(:probe_adapter))
43
+ @path = path
44
+ @homebrew_prefixes = homebrew_prefixes.map { |prefix| canonical(prefix) }
45
+ @gem_dir = canonical(gem_dir)
46
+ @ruby = ruby
47
+ @adapter_probe = adapter_probe
48
+ end
49
+
50
+ def findings
51
+ [install_finding, runtime_finding, adapters_finding, path_finding].compact
52
+ end
53
+
54
+ # @return [String, nil] the HOMEBREW_PREFIX this copy runs from, nil for a gem install.
55
+ def homebrew_prefix
56
+ return @homebrew_prefix if defined?(@homebrew_prefix)
57
+
58
+ @homebrew_prefix = @homebrew_prefixes.find do |prefix|
59
+ [@gem_dir, canonical(@ruby)].any? { |dir| under?(dir, keg_root(prefix)) }
60
+ end
61
+ end
62
+
63
+ def homebrew? = !homebrew_prefix.nil?
64
+
65
+ private
66
+
67
+ # --- install method -------------------------------------------------
68
+
69
+ def install_finding
70
+ method = homebrew? ? "homebrew" : "gem"
71
+ path = homebrew? ? installed_keg : @gem_dir
72
+ details = { method: method, path: path }
73
+ details[:prefix] = homebrew_prefix if homebrew?
74
+ info("install", "#{method} (#{path})", details)
75
+ end
76
+
77
+ def keg_root(prefix) = File.join(prefix, "Cellar", "portage")
78
+
79
+ # `<prefix>/Cellar/portage/<version>`, the keg this copy lives in.
80
+ def installed_keg
81
+ root = keg_root(homebrew_prefix)
82
+ version = @gem_dir.delete_prefix("#{root}/").split("/").first
83
+ File.join(root, version)
84
+ end
85
+
86
+ # --- runtime --------------------------------------------------------
87
+
88
+ def runtime_finding
89
+ info("runtime", "Ruby #{RUBY_VERSION} (#{@ruby}), portage-cli #{VERSION}",
90
+ { ruby_version: RUBY_VERSION, ruby_path: @ruby, portage_cli_version: VERSION })
91
+ end
92
+
93
+ # --- adapters -------------------------------------------------------
94
+
95
+ # A gem install without adapters is normal: `gem install portage-cli`
96
+ # pulls in none of them, and you add the ones you use. The formula
97
+ # bundles every one, so a Homebrew install missing one is broken.
98
+ def adapters_finding
99
+ report = AdapterReport.new(ADAPTER_GEMS.map { |name| { name: name }.merge(safe_probe(name)) })
100
+ details = { adapters: report.adapters }
101
+ return warning("adapters", report.homebrew_missing_message, details) if homebrew? && report.missing?
102
+
103
+ info("adapters", report.summary, details)
104
+ end
105
+
106
+ def safe_probe(name)
107
+ @adapter_probe.call(name)
108
+ rescue StandardError, ScriptError => e
109
+ { installed: true, loadable: false, version: nil, error: "#{e.class}: #{e.message}" }
110
+ end
111
+
112
+ def probe_adapter(name)
113
+ specs = Gem::Specification.find_all_by_name(name)
114
+ return { installed: false, loadable: false, version: nil } if specs.empty?
115
+
116
+ require "portage/ucp/#{name.delete_prefix('portage-ucp-')}"
117
+ spec = Gem.loaded_specs[name] || specs.max_by(&:version)
118
+ { installed: true, loadable: true, version: spec.version.to_s }
119
+ end
120
+
121
+ # --- PATH shadowing -------------------------------------------------
122
+
123
+ def path_finding
124
+ candidates = path_candidates
125
+ return if candidates.empty?
126
+
127
+ details = { first: candidates.first[:path], candidates: candidates }
128
+ message = shadow_message(candidates.first)
129
+ return warning("path", message, details) if message
130
+
131
+ # Only a Homebrew keg can be matched to this copy for certain; a gem
132
+ # install's binstub can sit behind a mise/rbenv shim.
133
+ suffix = homebrew? ? " (this install)" : ""
134
+ info("path", "`portage` on PATH is #{candidates.first[:path]}#{suffix}", details)
135
+ end
136
+
137
+ # Every `portage` on PATH, first match first, one entry per real file:
138
+ # the same Homebrew bin can sit on PATH twice, and `bin/portage` and
139
+ # `opt/portage/bin/portage` both resolve to one keg.
140
+ def path_candidates
141
+ candidates = @path.split(File::PATH_SEPARATOR).reject(&:empty?).filter_map { |dir| candidate(dir) }
142
+ candidates.uniq { |c| c[:realpath] }
143
+ end
144
+
145
+ def candidate(dir)
146
+ file = File.join(dir, EXECUTABLE)
147
+ return unless File.file?(file) && File.executable?(file)
148
+
149
+ real = canonical(file)
150
+ { path: file, realpath: real, kind: homebrew_keg?(real) ? "homebrew" : "other" }
151
+ end
152
+
153
+ def shadow_message(first)
154
+ if homebrew?
155
+ return if under?(first[:realpath], keg_root(homebrew_prefix))
156
+
157
+ homebrew_shadowed_message(first)
158
+ elsif first[:kind] == "homebrew"
159
+ gem_shadowed_message(first)
160
+ end
161
+ end
162
+
163
+ def homebrew_shadowed_message(first)
164
+ brew_bin = File.join(homebrew_prefix, "bin")
165
+ "`portage` on PATH runs #{first[:path]}, not this Homebrew install (#{brew_bin}/portage), " \
166
+ "so `brew upgrade portage` won't change what your shell runs. Check with `which -a portage`. " \
167
+ "Fix: if that's a `gem install` copy, run `gem uninstall portage-cli` with that Ruby active; " \
168
+ "or put #{brew_bin} ahead of #{File.dirname(first[:path])} in PATH."
169
+ end
170
+
171
+ def gem_shadowed_message(first)
172
+ "`portage` on PATH runs the Homebrew install (#{first[:path]}), not this gem install " \
173
+ "(#{@gem_dir}). Check with `which -a portage`. Fix: keep one — `brew uninstall portage` " \
174
+ "to use the gem, or `gem uninstall portage-cli` to use Homebrew; or reorder PATH."
175
+ end
176
+
177
+ def homebrew_keg?(realpath)
178
+ @homebrew_prefixes.any? { |prefix| under?(realpath, keg_root(prefix)) }
179
+ end
180
+
181
+ # --- helpers --------------------------------------------------------
182
+
183
+ def under?(path, root) = path.start_with?("#{root}/")
184
+
185
+ def canonical(path)
186
+ File.realpath(path)
187
+ rescue SystemCallError
188
+ File.expand_path(path)
189
+ end
190
+
191
+ def info(check, message, details) = finding(check, message, details, "info")
192
+ def warning(check, message, details) = finding(check, message, details, "warning")
193
+
194
+ def finding(check, message, details, level)
195
+ Doctor::Finding.new(check: check, message: message, level: level, details: details)
196
+ end
197
+ end
198
+
199
+ # The adapters line of InstallDoctor's report, one probe result per gem:
200
+ # `{ name:, installed:, loadable:, version:, error: }`.
201
+ class InstallDoctor
202
+ class AdapterReport
203
+ attr_reader :adapters
204
+
205
+ def initialize(adapters)
206
+ @adapters = adapters
207
+ end
208
+
209
+ def missing = @adapters.reject { |a| a[:loadable] }
210
+ def missing? = missing.any?
211
+
212
+ def summary
213
+ loaded, broken, absent = partitioned
214
+ parts = [loaded.empty? ? "none loadable" : loaded.map { |a| "#{short(a)} #{a[:version]}" }.join(", ")]
215
+ parts << "failed to load: #{names(broken)}" unless broken.empty?
216
+ parts << "not installed: #{names(absent)}" unless absent.empty?
217
+ parts.join("; ")
218
+ end
219
+
220
+ def homebrew_missing_message
221
+ described = missing.map { |a| a[:error] ? "#{short(a)} (#{a[:error]})" : short(a) }
222
+ "This Homebrew install bundles every adapter, but #{described.join(', ')} can't be loaded — " \
223
+ "reinstall with `brew reinstall portage`."
224
+ end
225
+
226
+ private
227
+
228
+ def partitioned
229
+ loaded, rest = @adapters.partition { |a| a[:loadable] }
230
+ broken, absent = rest.partition { |a| a[:installed] }
231
+ [loaded, broken, absent]
232
+ end
233
+
234
+ def names(adapters) = adapters.map { |a| short(a) }.join(", ")
235
+ def short(adapter) = adapter[:name].delete_prefix("portage-ucp-")
236
+ end
237
+ end
238
+ end
239
+ end
@@ -20,9 +20,14 @@ module Portage
20
20
 
21
21
  # @return [Portage::Ucp::PostalAddress, nil] nil unless every required
22
22
  # field is set — a partial profile isn't enough to submit, and this
23
- # module never guesses at a missing field.
23
+ # module never guesses at a missing field. An empty value counts as
24
+ # unset, so a `.env` copied from `.env.example` with blanks left in
25
+ # doesn't submit an address of empty strings.
24
26
  def self.from_env
25
- attrs = ENV_VARS.filter_map { |key, var| [key, ENV.fetch(var, nil)] if ENV.key?(var) }.to_h
27
+ attrs = ENV_VARS.filter_map do |key, var|
28
+ value = ENV.fetch(var, nil)
29
+ [key, value] unless value.to_s.empty?
30
+ end.to_h
26
31
  return nil unless REQUIRED.all? { |key| attrs.key?(key) }
27
32
 
28
33
  Portage::Ucp::PostalAddress.new(**attrs)
@@ -1,5 +1,5 @@
1
1
  module Portage
2
2
  module Cli
3
- VERSION = "0.7.3".freeze
3
+ VERSION = "0.7.4".freeze
4
4
  end
5
5
  end
data/lib/portage/cli.rb CHANGED
@@ -854,16 +854,24 @@ module Portage
854
854
  proxy_settings = apply_proxy_settings(opts[:proxy])
855
855
  return 1 unless proxy_settings
856
856
 
857
- findings = Doctor.new(adapter_class: adapter_class, proxy_settings: proxy_settings).call
858
- puts opts[:json] ? JSON.pretty_generate(findings.map(&:to_h)) : format_doctor(findings)
859
- findings.empty? ? 0 : 1
857
+ report_doctor(Doctor.new(adapter_class: adapter_class, proxy_settings: proxy_settings).call, json: opts[:json])
860
858
  end
861
859
  private_class_method :run_doctor
862
860
 
863
- def self.format_doctor(findings)
864
- return "No issues found." if findings.empty?
861
+ def self.report_doctor(findings, json:)
862
+ puts json ? JSON.pretty_generate(findings.map(&:to_h)) : format_doctor(findings)
863
+ findings.none?(&:warning?) ? 0 : 1
864
+ end
865
+ private_class_method :report_doctor
865
866
 
866
- findings.map { |f| "[#{f.check}] #{f.message}" }.join("\n")
867
+ # Info findings (install method, Ruby, adapters, PATH) first, then the
868
+ # warnings, which alone decide the exit code.
869
+ def self.format_doctor(findings)
870
+ info, warnings = findings.partition { |f| !f.warning? }
871
+ lines = info.map { |f| "[#{f.check}] #{f.message}" }
872
+ lines << "" unless info.empty?
873
+ lines.concat(warnings.empty? ? ["No issues found."] : warnings.map { |f| "[#{f.check}] #{f.message}" })
874
+ lines.join("\n")
867
875
  end
868
876
  private_class_method :format_doctor
869
877
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: portage-cli
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.7.3
4
+ version: 0.7.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Tom Whitbread
@@ -175,6 +175,7 @@ files:
175
175
  - lib/portage/cli/handoff_waiter.rb
176
176
  - lib/portage/cli/history.rb
177
177
  - lib/portage/cli/homepage_fetch.rb
178
+ - lib/portage/cli/install_doctor.rb
178
179
  - lib/portage/cli/macos_notifier.rb
179
180
  - lib/portage/cli/notifier.rb
180
181
  - lib/portage/cli/payment_methods.rb