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 +4 -4
- data/CHANGELOG.md +35 -0
- data/README.md +120 -16
- data/lib/portage/cli/doctor.rb +44 -2
- data/lib/portage/cli/install_doctor.rb +239 -0
- data/lib/portage/cli/shipping_profile.rb +7 -2
- data/lib/portage/cli/version.rb +1 -1
- data/lib/portage/cli.rb +14 -6
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 58834de7ed60ddb98da33896147737f518ccb8d02d58f0d3aeceb645ecef8ea6
|
|
4
|
+
data.tar.gz: 24e471dfb23e48b8ff1f157dbac941442ca84571120ad754451dfb36d9c08d60
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
95
|
+
sudo apt install libsecret-tools # Debian/Ubuntu
|
|
96
|
+
sudo dnf install libsecret # Fedora
|
|
59
97
|
```
|
|
60
98
|
|
|
61
|
-
|
|
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
|
-
|
|
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] [--
|
|
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
|
|
605
|
+
### Shipping address
|
|
515
606
|
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
`
|
|
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
|
|
533
|
-
treated as no profile at all.
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
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
|
|
data/lib/portage/cli/doctor.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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)
|
data/lib/portage/cli/version.rb
CHANGED
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
|
-
|
|
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.
|
|
864
|
-
|
|
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
|
-
|
|
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.
|
|
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
|