portage-cli 0.7.4 → 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: 58834de7ed60ddb98da33896147737f518ccb8d02d58f0d3aeceb645ecef8ea6
4
- data.tar.gz: 24e471dfb23e48b8ff1f157dbac941442ca84571120ad754451dfb36d9c08d60
3
+ metadata.gz: a682278ec8b34cb6e2cd649f345cb6779a7cec5d0bc22e6950721f7996a2d877
4
+ data.tar.gz: ed490b87284e6a8c9bd9ab280ea600d16877820240386d34e239309a8204b16d
5
5
  SHA512:
6
- metadata.gz: 70c1a8b9db25d281091e496db0eb7e45de1e54b0afbc943a35866cc9ec2efb4a292b043a0720932ff5f3c2a93757ec3b217785d96ec4f2ece6e637132aed1d04
7
- data.tar.gz: af42b5fb85cc582d86e60d616f38f177df92b6c53df59a7ccedcd8780855adb15f876e32ddbfda384bd579825ed90f68c5a833fd061ed79fce43e3a2c056edcd
6
+ metadata.gz: c22fd29667f7281c0173503cc9797158de1cd9d494cfcc98795bbce3ecfbaa03ed67ecb1321b3adf83000cf251168a62118db703bf1fb7b5417ba615a672b8c5
7
+ data.tar.gz: 42016dd1b716169b2dbaadef6f83a210ab8750584b23464e8c38c76aa39374988eeda97cfdc133c3edffaaf3c3ef8b34a76aa2fd57a9c96ad32292d529878df4
data/CHANGELOG.md CHANGED
@@ -4,6 +4,27 @@ 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
+
7
28
  ## [0.7.4] - 2026-09-25
8
29
 
9
30
  - **`portage doctor` reports how it was installed, and warns when another
data/README.md CHANGED
@@ -424,10 +424,15 @@ installed, then lists anything that needs fixing:
424
424
  earlier on `PATH` shadows this one (see "Two copies on PATH" above).
425
425
  - `shipping`: warns when `PORTAGE_SHIP_*` is missing or incomplete (see
426
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.
427
430
  - 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
+ 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.
431
436
 
432
437
  With `--json` the output is an array of findings, each with `check`,
433
438
  `message`, `level` (`warning` or `info`) and, for the install checks,
@@ -602,21 +607,58 @@ gives up after 5 seconds and reports `notify_error` instead.
602
607
  `portage find`'s offer order comes from `Support::OfferRanking`: buyable
603
608
  first, then cheapest, then unpriced.
604
609
 
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
+
605
647
  ### Shipping address
606
648
 
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:
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:
610
652
 
611
653
  ```bash
612
- export PORTAGE_SHIP_STREET="1 Main St"
613
- export PORTAGE_SHIP_CITY="Erie"
614
- export PORTAGE_SHIP_REGION="PA" # optional
615
- export PORTAGE_SHIP_COUNTRY="US"
616
- export PORTAGE_SHIP_POSTAL_CODE="16501"
617
- export PORTAGE_SHIP_FIRST_NAME="Ada" # optional
618
- export PORTAGE_SHIP_LAST_NAME="Lovelace" # optional
619
- 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
620
662
  ```
621
663
 
622
664
  `street`/`city`/`country`/`postal_code` are required — a partial profile
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
 
@@ -4,6 +4,7 @@ require_relative "confidence_check"
4
4
  require_relative "user_agent"
5
5
  require_relative "proxy_settings"
6
6
  require_relative "shipping_profile"
7
+ require_relative "dot_env"
7
8
 
8
9
  module Portage
9
10
  module Cli
@@ -26,19 +27,27 @@ module Portage
26
27
  def to_h = super.compact
27
28
  end
28
29
 
29
- def initialize(adapter_class: nil, proxy_settings: ProxySettings.new, install_doctor: InstallDoctor.new)
30
+ SELLER_CHECKS = "authenticator, rate limiter, signing keys, payment handlers".freeze
31
+
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)
30
39
  @adapter_class = adapter_class
31
40
  @proxy_settings = proxy_settings
32
41
  @install_doctor = install_doctor
42
+ @seller = seller
43
+ @dot_env_path = dot_env_path
33
44
  end
34
45
 
35
46
  def call
36
47
  [
37
48
  *@install_doctor.findings,
38
- authenticator_finding,
39
- rate_limiter_finding,
40
- signing_keys_finding,
41
- payment_handlers_finding,
49
+ dot_env_finding,
50
+ *seller_findings,
42
51
  decision_backend_finding,
43
52
  user_agent_finding,
44
53
  shipping_finding,
@@ -52,6 +61,31 @@ module Portage
52
61
 
53
62
  def config = Portage::Ucp.configuration
54
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
+
55
89
  def authenticator_finding
56
90
  return unless config.authenticator.is_a?(Portage::Ucp::UnconfiguredAuthenticator)
57
91
 
@@ -115,8 +149,8 @@ module Portage
115
149
  .select { |var| ENV[var].to_s.empty? }
116
150
  return if missing.empty?
117
151
 
118
- Finding.new(check: "shipping", message: "#{shipping_gap(missing)} Export them in your shell; " \
119
- ".env.example lists every PORTAGE_SHIP_* variable.")
152
+ Finding.new(check: "shipping", message: "#{shipping_gap(missing)} Set them in ~/.portage/.env " \
153
+ "(.env.example lists every variable) or your shell.")
120
154
  end
121
155
 
122
156
  def shipping_gap(missing)
@@ -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
@@ -1,5 +1,5 @@
1
1
  module Portage
2
2
  module Cli
3
- VERSION = "0.7.4".freeze
3
+ VERSION = "0.7.5".freeze
4
4
  end
5
5
  end
data/lib/portage/cli.rb CHANGED
@@ -854,7 +854,9 @@ module Portage
854
854
  proxy_settings = apply_proxy_settings(opts[:proxy])
855
855
  return 1 unless proxy_settings
856
856
 
857
- report_doctor(Doctor.new(adapter_class: adapter_class, proxy_settings: proxy_settings).call, json: opts[:json])
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])
858
860
  end
859
861
  private_class_method :run_doctor
860
862
 
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
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