portage-cli 0.7.3 → 0.7.5

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: a682278ec8b34cb6e2cd649f345cb6779a7cec5d0bc22e6950721f7996a2d877
4
+ data.tar.gz: ed490b87284e6a8c9bd9ab280ea600d16877820240386d34e239309a8204b16d
5
5
  SHA512:
6
- metadata.gz: dd25c1c868d3e70f6ef469975f0d2b24f478c1f96bf83a739d2e4a473c38d2695fbd3586e0243ac21f9d75562b080413ad173c18924024ff1135279cfe8622be
7
- data.tar.gz: e8578d6935466578e44e9e4f2e2caae61e1b9a1b2b52051934989fd28ec8ea9a163d943e3f68aac3704bdae72557d600f4694c7c81b8e9fe00b16685640a10b6
6
+ metadata.gz: c22fd29667f7281c0173503cc9797158de1cd9d494cfcc98795bbce3ecfbaa03ed67ecb1321b3adf83000cf251168a62118db703bf1fb7b5417ba615a672b8c5
7
+ data.tar.gz: 42016dd1b716169b2dbaadef6f83a210ab8750584b23464e8c38c76aa39374988eeda97cfdc133c3edffaaf3c3ef8b34a76aa2fd57a9c96ad32292d529878df4
data/CHANGELOG.md CHANGED
@@ -4,6 +4,62 @@ 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.5] - 2026-09-25
8
+
9
+ - **`portage` loads `~/.portage/.env` on startup** (`Portage::Cli::DotEnv`),
10
+ so the shipping address, search keys and adapter credentials can live in
11
+ one file instead of a shell profile. `portage-console` does too. The
12
+ real environment always wins, empty values are skipped, and
13
+ `PORTAGE_ENV_FILE` names a different file. A `./.env` in the working
14
+ directory is never loaded automatically, so running `portage` inside a
15
+ cloned repo can't pick up that repo's proxy, webhook or credential
16
+ settings. Stdlib only, so the Homebrew formula gains no resource.
17
+ `portage doctor` reports the loaded file (`env_file`) and warns when
18
+ other users can read it.
19
+ - **`portage doctor` runs the seller-side checks only for a seller.** The
20
+ authenticator, rate limiter, signing keys and payment handlers checks
21
+ inspect `Portage::Ucp.configuration`, which in a bare `portage` process
22
+ is always the unconfigured default. So every fresh install got four
23
+ warnings that meant nothing to a shopper and made doctor exit 1. They
24
+ now run only with `--require` or `--adapter`; otherwise one info line
25
+ says they were skipped. `Doctor.new` keeps running them by default
26
+ (`seller: true`) for library callers.
27
+
28
+ ## [0.7.4] - 2026-09-25
29
+
30
+ - **`portage doctor` reports how it was installed, and warns when another
31
+ `portage` shadows it** (`docs/plans/homebrew-distribution.md` Phase 4).
32
+ New findings, all offline and without shelling out to `brew`:
33
+ - `install`: `homebrew` (with the Cellar keg) when this gem or its Ruby
34
+ lives under `HOMEBREW_PREFIX/Cellar/portage/` (`$HOMEBREW_PREFIX`,
35
+ then `/opt/homebrew`, `/usr/local`, `/home/linuxbrew/.linuxbrew`),
36
+ otherwise `gem` with the gem's own path;
37
+ - `runtime`: the Ruby version and `RbConfig.ruby` path, plus the
38
+ `portage-cli` version;
39
+ - `adapters`: each first-party adapter gem (plus `webmcp` and
40
+ `decision`), whether it loads and at which version. A gem install
41
+ without adapters is normal; a Homebrew install missing one is a
42
+ warning, since the formula bundles them all. A broken adapter is
43
+ reported, never raised;
44
+ - `path`: every `portage` on `PATH`, compared by resolved Cellar
45
+ location rather than raw path. Warns when a Homebrew install is
46
+ shadowed by an earlier `portage` (typically a `gem install` copy in a
47
+ mise/rbenv/asdf/rvm Ruby), naming the winner and how to fix it, and
48
+ when a gem install is shadowed by a Homebrew one.
49
+ - `portage doctor` also warns when the `PORTAGE_SHIP_*` address is missing
50
+ or incomplete, naming the missing variables. Without
51
+ `PORTAGE_SHIP_COUNTRY` a native UCP store gets no buyer context, which
52
+ is how a live Shopify store ended up reporting in-stock items as out of
53
+ stock.
54
+ - `doctor` findings now carry a `level` (`warning` or `info`) and, for the
55
+ new checks, structured `details`, both in `--json`. The JSON is still a
56
+ top-level array. Only warnings make doctor exit 1; the text output lists
57
+ info findings first, then the warnings or `No issues found.`.
58
+ - `ShippingProfile` treats an empty `PORTAGE_SHIP_*` value as unset, the
59
+ way `BuyerContext` and the WooCommerce billing fallback already did, so
60
+ a `.env` copied from `.env.example` with blanks left in no longer
61
+ submits an address of empty strings.
62
+
7
63
  ## [0.7.3] - 2026-09-25
8
64
 
9
65
  - **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,42 @@ 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
+ - `env_file`: which env file was loaded (see "Environment file" below).
428
+ Warns when other users can read it.
429
+ - The confidence gate's backend, the User-Agent, and proxy settings.
430
+ - Seller-side checks against `Portage::Ucp.configuration` (authenticator,
431
+ rate limiter, signing keys, payment handlers). These only run when you
432
+ pass `--require` with your app's initializer (Rails:
433
+ `--require ./config/environment`) or `--adapter`. Without them doctor
434
+ would only ever see the unconfigured defaults, so it just notes that it
435
+ skipped them.
436
+
437
+ With `--json` the output is an array of findings, each with `check`,
438
+ `message`, `level` (`warning` or `info`) and, for the install checks,
439
+ `details`. Doctor exits `1` when there's at least one warning, else `0`.
440
+
345
441
  ### Proxy
346
442
 
347
443
  `buy`, `find`, `compare`, `doctor`, and `payment enroll` accept the flags below,
@@ -511,30 +607,80 @@ gives up after 5 seconds and reports `notify_error` instead.
511
607
  `portage find`'s offer order comes from `Support::OfferRanking`: buyable
512
608
  first, then cheapest, then unpriced.
513
609
 
514
- ### Shipping address (own-store checkouts only)
610
+ ### Environment file
611
+
612
+ `portage` and `portage-console` load `~/.portage/.env` on startup, so your
613
+ shipping address, search keys and adapter credentials can live in one file
614
+ rather than your shell profile. `.env.example` at the repo root lists every
615
+ variable. Rules:
616
+
617
+ - Variables already set in your shell win over the file.
618
+ - Empty values are skipped, so blanks copied from `.env.example` set nothing.
619
+ - `KEY=value`, `export KEY=value`, `"double"` (with `\n` and `\"` escapes) and
620
+ `'single'` quotes all work; `#` starts a comment.
621
+ - `PORTAGE_ENV_FILE=path` loads a different file instead, for example
622
+ `PORTAGE_ENV_FILE=.env` for a project checkout's own.
623
+
624
+ Keep it private (`chmod 600 ~/.portage/.env`); `portage doctor` warns if
625
+ other users can read it.
626
+
627
+ #### Why `./.env` is never loaded automatically
628
+
629
+ Many tools load a `.env` from whatever directory you run them in. Portage
630
+ deliberately doesn't, because `portage` spends money and handles payment
631
+ tokens, and the directory you happen to be in isn't something you chose
632
+ to trust. If it did, running `portage` inside a cloned repo, a downloaded
633
+ project or a shared folder would silently apply that directory's
634
+ settings, for example:
635
+
636
+ - `PORTAGE_PROXY` plus `PORTAGE_PROXY_CA`, routing your store traffic
637
+ through someone else's intercepting proxy, where they can read it;
638
+ - a notify webhook that sends your checkout URLs and order details to
639
+ someone else;
640
+ - store credentials or `PORTAGE_STORES`, pointing purchases at a different
641
+ store than you think.
642
+
643
+ So only `~/.portage/.env`, a file you created in your own Portage
644
+ directory, loads automatically. To use a project's `.env`, name it on
645
+ purpose: `PORTAGE_ENV_FILE=.env portage ...`, after reading what's in it.
646
+
647
+ ### Shipping address
515
648
 
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:
649
+ Set your shipping address in `~/.portage/.env` (or your shell) rather than
650
+ a flag, the same way as adapter credentials. `portage doctor` warns until
651
+ the required ones are set:
520
652
 
521
653
  ```bash
522
- export PORTAGE_SHIP_STREET="1 Main St"
523
- export PORTAGE_SHIP_CITY="Erie"
524
- export PORTAGE_SHIP_REGION="PA" # optional
525
- export PORTAGE_SHIP_COUNTRY="US"
526
- export PORTAGE_SHIP_POSTAL_CODE="16501"
527
- export PORTAGE_SHIP_FIRST_NAME="Ada" # optional
528
- export PORTAGE_SHIP_LAST_NAME="Lovelace" # optional
529
- export PORTAGE_SHIP_PHONE="+1..." # optional
654
+ PORTAGE_SHIP_STREET="1 Main St"
655
+ PORTAGE_SHIP_CITY="Erie"
656
+ PORTAGE_SHIP_REGION="PA" # optional
657
+ PORTAGE_SHIP_COUNTRY="US"
658
+ PORTAGE_SHIP_POSTAL_CODE="16501"
659
+ PORTAGE_SHIP_FIRST_NAME="Ada" # optional
660
+ PORTAGE_SHIP_LAST_NAME="Lovelace" # optional
661
+ PORTAGE_SHIP_PHONE="+1..." # optional
530
662
  ```
531
663
 
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.
664
+ `street`/`city`/`country`/`postal_code` are required — a partial profile
665
+ (or one with empty values) is treated as no profile at all.
666
+
667
+ The variables are used in two ways:
668
+
669
+ - **Native UCP stores** (`buy` and `find` over HTTP) get
670
+ `PORTAGE_SHIP_COUNTRY`, `_REGION` and `_POSTAL_CODE` (plus
671
+ `PORTAGE_CURRENCY` and `PORTAGE_LANGUAGE`, if set) as UCP buyer context,
672
+ which a store uses to pick the market it prices and stocks in. These
673
+ work on their own, without the full address.
674
+ Without at least the country, a live Shopify store can report in-stock
675
+ items as out of stock.
676
+ - **Your own store** (`portage buy`'s adapter-credentials fallback,
677
+ described at the top of this file), when its adapter supports
678
+ `dev.ucp.shopping.fulfillment`, also gets the full address as the
679
+ checkout's shipping destination. Once the merchant prices shipping
680
+ options against it, `portage buy` auto-picks the cheapest per fulfillment
681
+ group; there's no interactive rate picker, since this drives one
682
+ automated purchase. Native (non-adapter) UCP stores don't get the full
683
+ address yet — see `portage-ucp`'s design log for why.
538
684
 
539
685
  ## Buying without a URL
540
686
 
data/exe/portage CHANGED
@@ -1,6 +1,11 @@
1
1
  #!/usr/bin/env ruby
2
2
  # frozen_string_literal: true
3
3
 
4
+ # Before anything reads ENV: ~/.portage/.env (or PORTAGE_ENV_FILE), never
5
+ # overriding the real environment. See Portage::Cli::DotEnv.
6
+ require "portage/cli/dot_env"
7
+ Portage::Cli::DotEnv.load
8
+
4
9
  require "portage/cli"
5
10
 
6
11
  exit(Portage::Cli.run(ARGV))
data/exe/portage-console CHANGED
@@ -1,6 +1,9 @@
1
1
  #!/usr/bin/env ruby
2
2
  # frozen_string_literal: true
3
3
 
4
+ require "portage/cli/dot_env"
5
+ Portage::Cli::DotEnv.load
6
+
4
7
  require "irb"
5
8
  require "portage/cli/console"
6
9
 
@@ -3,6 +3,8 @@ 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"
7
+ require_relative "dot_env"
6
8
 
7
9
  module Portage
8
10
  module Cli
@@ -16,21 +18,39 @@ module Portage
16
18
  # process, so a host app passes `--require` to load its own initializer
17
19
  # first (Rails: `--require ./config/environment`).
18
20
  class Doctor
19
- Finding = Struct.new(:check, :message, keyword_init: true)
21
+ # `level` is "warning" (something to fix; any one makes doctor exit 1)
22
+ # or "info" (a report, e.g. how portage was installed). `details` is
23
+ # structured data for `--json`; `to_h` drops it when there is none.
24
+ Finding = Struct.new(:check, :message, :level, :details, keyword_init: true) do
25
+ def initialize(level: "warning", **) = super
26
+ def warning? = level == "warning"
27
+ def to_h = super.compact
28
+ end
29
+
30
+ SELLER_CHECKS = "authenticator, rate limiter, signing keys, payment handlers".freeze
20
31
 
21
- def initialize(adapter_class: nil, proxy_settings: ProxySettings.new)
32
+ # @param seller [Boolean] run the seller-side checks against
33
+ # Portage::Ucp.configuration. `portage doctor` passes true only when
34
+ # --require or --adapter loaded a seller's setup: in a bare shopper
35
+ # process that configuration is always the unconfigured default, so
36
+ # those four warnings were noise on every fresh install.
37
+ def initialize(adapter_class: nil, proxy_settings: ProxySettings.new, install_doctor: InstallDoctor.new,
38
+ seller: true, dot_env_path: DotEnv.loaded_path)
22
39
  @adapter_class = adapter_class
23
40
  @proxy_settings = proxy_settings
41
+ @install_doctor = install_doctor
42
+ @seller = seller
43
+ @dot_env_path = dot_env_path
24
44
  end
25
45
 
26
46
  def call
27
47
  [
28
- authenticator_finding,
29
- rate_limiter_finding,
30
- signing_keys_finding,
31
- payment_handlers_finding,
48
+ *@install_doctor.findings,
49
+ dot_env_finding,
50
+ *seller_findings,
32
51
  decision_backend_finding,
33
52
  user_agent_finding,
53
+ shipping_finding,
34
54
  proxy_finding,
35
55
  *proxy_doctor_findings,
36
56
  *capability_findings
@@ -41,6 +61,31 @@ module Portage
41
61
 
42
62
  def config = Portage::Ucp.configuration
43
63
 
64
+ def seller_findings
65
+ return [authenticator_finding, rate_limiter_finding, signing_keys_finding, payment_handlers_finding] if @seller
66
+
67
+ [Finding.new(check: "seller", level: "info",
68
+ message: "Seller checks (#{SELLER_CHECKS}) skipped — pass --require with your app's " \
69
+ "initializer, or --adapter, to run them.")]
70
+ end
71
+
72
+ # Which env file DotEnv loaded, and a warning when it's readable by
73
+ # other users: it's where store credentials and payment tokens end up.
74
+ def dot_env_finding
75
+ return unless @dot_env_path
76
+
77
+ mode = File.stat(@dot_env_path).mode & 0o777
78
+ details = { path: @dot_env_path, mode: format("%o", mode) }
79
+ return Finding.new(check: "env_file", level: "info", message: "Loaded #{@dot_env_path}", details: details) \
80
+ if mode.nobits?(0o077)
81
+
82
+ Finding.new(check: "env_file", details: details,
83
+ message: "#{@dot_env_path} is readable by other users (mode #{details[:mode]}) and may hold " \
84
+ "credentials — run `chmod 600 #{@dot_env_path}`.")
85
+ rescue SystemCallError
86
+ nil
87
+ end
88
+
44
89
  def authenticator_finding
45
90
  return unless config.authenticator.is_a?(Portage::Ucp::UnconfiguredAuthenticator)
46
91
 
@@ -93,6 +138,36 @@ module Portage
93
138
  "raise instead of sending.")
94
139
  end
95
140
 
141
+ # PORTAGE_SHIP_* (ShippingProfile, BuyerContext) has two jobs, and both
142
+ # fail quietly: with no complete address an own-store checkout goes
143
+ # out with no shipping destination, and with no PORTAGE_SHIP_COUNTRY a
144
+ # native UCP store gets no buyer context, so a live Shopify store
145
+ # builds a cart in no market and reports in-stock items as
146
+ # `merchandise_out_of_stock` (docs/ucp-tool-gating-investigation.md).
147
+ def shipping_finding
148
+ missing = ShippingProfile::REQUIRED.map { |key| ShippingProfile::ENV_VARS.fetch(key) }
149
+ .select { |var| ENV[var].to_s.empty? }
150
+ return if missing.empty?
151
+
152
+ Finding.new(check: "shipping", message: "#{shipping_gap(missing)} Set them in ~/.portage/.env " \
153
+ "(.env.example lists every variable) or your shell.")
154
+ end
155
+
156
+ def shipping_gap(missing)
157
+ country = ShippingProfile::ENV_VARS.fetch(:address_country)
158
+ gap = if missing.length == ShippingProfile::REQUIRED.length
159
+ "No shipping address set (#{missing.join(', ')})."
160
+ else
161
+ "Shipping address incomplete: missing #{missing.join(', ')}, and a partial address is " \
162
+ "treated as none."
163
+ end
164
+ gap += " `portage buy` sends no shipping destination to your own store's checkout"
165
+ return "#{gap}." unless missing.include?(country)
166
+
167
+ "#{gap}, and without #{country} a native UCP store (e.g. Shopify) gets no market to price in " \
168
+ "and can report in-stock items as out of stock."
169
+ end
170
+
96
171
  # Phase 0 of docs/plans/proxy-support.md: every raw Net::HTTP.start call
97
172
  # site in portage-cli/portage-ucp/the adapter gems resolves its proxy
98
173
  # from Ruby stdlib's own `:ENV` default, which — confirmed against a
@@ -192,3 +267,4 @@ end
192
267
  # Doctor has to exist first (this file requires proxy_settings, not
193
268
  # proxy_doctor, at the top for exactly this reason).
194
269
  require_relative "proxy_doctor"
270
+ require_relative "install_doctor"
@@ -0,0 +1,62 @@
1
+ module Portage
2
+ module Cli
3
+ # Loads `~/.portage/.env` (or the file PORTAGE_ENV_FILE names) into ENV
4
+ # before `portage`/`portage-console` run, so shipping address, search
5
+ # keys and adapter credentials can live in one file next to config.json
6
+ # instead of a shell profile. A Homebrew install has no repo checkout to
7
+ # keep a `.env` in, so the user's own Portage directory is the one place
8
+ # every install method shares.
9
+ #
10
+ # Deliberately *not* `./.env` from the working directory: `portage` run
11
+ # inside a cloned repo would then take that repo's PORTAGE_PROXY/
12
+ # PORTAGE_PROXY_CA (an intercepting proxy on store traffic), notify
13
+ # webhooks or store credentials without the user ever choosing them.
14
+ # PORTAGE_ENV_FILE=.env opts into a project file explicitly.
15
+ #
16
+ # Stdlib only (no dotenv gem, so the formula gains no resource). The real
17
+ # environment always wins, and an empty value is skipped, so a file
18
+ # copied from .env.example with blanks left in sets nothing.
19
+ module DotEnv
20
+ DEFAULT_PATH = File.join(Dir.home, ".portage", ".env").freeze
21
+ ESCAPES = { "n" => "\n", '"' => '"', "\\" => "\\" }.freeze
22
+ LINE = /\A\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)\z/
23
+
24
+ class << self
25
+ # The file #load read in this process, for `portage doctor`.
26
+ attr_reader :loaded_path
27
+ end
28
+
29
+ # @return [String, nil] the path that was loaded, if any.
30
+ def self.load(path: ENV.fetch("PORTAGE_ENV_FILE", nil) || DEFAULT_PATH, env: ENV)
31
+ path = File.expand_path(path)
32
+ return unless File.file?(path) && File.readable?(path)
33
+
34
+ parse(File.read(path)).each { |key, value| env[key] = value unless env.key?(key) }
35
+ @loaded_path = path
36
+ end
37
+
38
+ # @return [Hash{String => String}] non-empty assignments, in file order.
39
+ def self.parse(text)
40
+ text.each_line.filter_map do |line|
41
+ match = LINE.match(line.chomp)
42
+ next unless match
43
+
44
+ value = unquote(match[2])
45
+ [match[1], value] unless value.empty?
46
+ end.to_h
47
+ end
48
+
49
+ # `"..."` (with \n, \" and \\ escapes) or `'...'` (literal); unquoted
50
+ # values drop a trailing ` # comment`.
51
+ def self.unquote(raw)
52
+ case raw
53
+ when /\A"((?:[^"\\]|\\.)*)"/
54
+ Regexp.last_match(1).gsub(/\\([n"\\])/) { ESCAPES.fetch(Regexp.last_match(1)) }
55
+ when /\A'([^']*)'/ then Regexp.last_match(1)
56
+ else raw.sub(/\s+#.*\z/, "").strip
57
+ end
58
+ end
59
+ private_class_method :unquote
60
+ end
61
+ end
62
+ end
@@ -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.5".freeze
4
4
  end
5
5
  end
data/lib/portage/cli.rb CHANGED
@@ -854,16 +854,26 @@ 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
+ doctor = Doctor.new(adapter_class: adapter_class, proxy_settings: proxy_settings,
858
+ seller: !(opts[:require] || opts[:adapter]).nil?)
859
+ report_doctor(doctor.call, json: opts[:json])
860
860
  end
861
861
  private_class_method :run_doctor
862
862
 
863
- def self.format_doctor(findings)
864
- return "No issues found." if findings.empty?
863
+ def self.report_doctor(findings, json:)
864
+ puts json ? JSON.pretty_generate(findings.map(&:to_h)) : format_doctor(findings)
865
+ findings.none?(&:warning?) ? 0 : 1
866
+ end
867
+ private_class_method :report_doctor
865
868
 
866
- findings.map { |f| "[#{f.check}] #{f.message}" }.join("\n")
869
+ # Info findings (install method, Ruby, adapters, PATH) first, then the
870
+ # warnings, which alone decide the exit code.
871
+ def self.format_doctor(findings)
872
+ info, warnings = findings.partition { |f| !f.warning? }
873
+ lines = info.map { |f| "[#{f.check}] #{f.message}" }
874
+ lines << "" unless info.empty?
875
+ lines.concat(warnings.empty? ? ["No issues found."] : warnings.map { |f| "[#{f.check}] #{f.message}" })
876
+ lines.join("\n")
867
877
  end
868
878
  private_class_method :format_doctor
869
879
 
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.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - Tom Whitbread
@@ -165,6 +165,7 @@ files:
165
165
  - lib/portage/cli/console.rb
166
166
  - lib/portage/cli/decisions.rb
167
167
  - lib/portage/cli/doctor.rb
168
+ - lib/portage/cli/dot_env.rb
168
169
  - lib/portage/cli/find.rb
169
170
  - lib/portage/cli/generate/adapter.rb
170
171
  - lib/portage/cli/generate/agent_profile.rb
@@ -175,6 +176,7 @@ files:
175
176
  - lib/portage/cli/handoff_waiter.rb
176
177
  - lib/portage/cli/history.rb
177
178
  - lib/portage/cli/homepage_fetch.rb
179
+ - lib/portage/cli/install_doctor.rb
178
180
  - lib/portage/cli/macos_notifier.rb
179
181
  - lib/portage/cli/notifier.rb
180
182
  - lib/portage/cli/payment_methods.rb