portage-cli 0.4.0 → 0.5.1

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: 2dda5ce1d230eb5063b5a0a5a576acfce59be4a12abd415f85a0ba6d5d01d6d6
4
- data.tar.gz: 4c4732f171e0da0dd8480cd260e1a273e3938ad7b500d3166e86445d2ae7beca
3
+ metadata.gz: 2a0e9e7e0b7046fc20978a89fb06056bf1827df5efa45c47ddfb0b16f332d53f
4
+ data.tar.gz: 9ab0f0c9caa507147fae5069dda27c86a92e4c26f273c9cbc14cee8fbfddf368
5
5
  SHA512:
6
- metadata.gz: 15c3ec9d03069d07adf77b404741867755b24bce9dcbb15ca11005db4344195c1f9cfb741e6340609bcb43b40ea875d8f6c420017132ae98c93d9acca60b45a2
7
- data.tar.gz: d9f8dcaee95eaafa9197b313cab7ee70410f1808ad2bf5d4bd722276cd9f7fa7e7074d3080d49723c56e0ba677fa036822bfaa30ebe42471aadce46cfb0fe49d
6
+ metadata.gz: 247e88dba38ac3171cedccd90d0d1e56ff225cf8aa2526598c3d670391d79fc908fe9fe81735aa71f1c389516ae04c5c71512c61e56bc39802b92bd213d6b40a
7
+ data.tar.gz: 07d42f21ba32010310394a39a664599c09e840a58cc1be6dd5bcbed7e9d0adf46639364f2bbd70ee70f63fcbfe9407a6d04af9ea75669cc5ae6a5f0f226c5226
data/CHANGELOG.md CHANGED
@@ -4,7 +4,45 @@ 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
- ## [Unreleased]
7
+ ## [0.5.1] - 2026-09-16
8
+
9
+ - No behavior change — 0.5.0 was built and pushed with `gem build` run from
10
+ the workspace root instead of this gem's own directory, so `spec.files =
11
+ Dir[...]` resolved against the wrong working directory and packaged an
12
+ empty gem. 0.5.0 has been yanked; 0.5.1 repackages the exact same 0.5.0
13
+ code correctly.
14
+
15
+ ## [0.5.0] - 2026-09-16
16
+
17
+ - Added `portage-console` — a read-only IRB REPL over the local
18
+ `TransactionLog`/`OrderLedger`/`PurchaseJournal` stores (design-log §22
19
+ item 6, `Portage::Cli::Console`). `transactions`/`find_transaction`/
20
+ `transactions_since`, `orders`/`find_order`, and `journal`. Every result
21
+ passes through `Portage::Ucp::Observability.redact`. Deliberately a local
22
+ REPL, not the admin/web panel design-log §16 also describes — see the
23
+ README's Console section for why. New runtime dependency on
24
+ `portage-ucp-journal` (`~> 0.1`).
25
+ - `Buy#client_for` now passes a real `Portage::Ucp::Journal::PurchaseJournal`
26
+ (file-backed, `~/.portage/journal.jsonl`) into `Client.for_adapter` — the
27
+ own-store loopback path used by `portage buy` against your own store now
28
+ actually records to the purchase journal, closing the gap `portage-console`
29
+ above depends on: `journal` was empty on this path because nothing passed
30
+ `journal:` through it (design-log §37/§38).
31
+ - Added `portage generate adapter` — scaffolds a new adapter gem (gemspec,
32
+ Gemfile, Rakefile, rubocop config, lib entrypoint, version file, an
33
+ `Adapter` subclass with every capability method stubbed by reflecting off
34
+ `Portage::Ucp::Adapter`'s real method signatures, and a conformance spec),
35
+ modeled on `portage-ucp-etsy`.
36
+ - Added `portage doctor` — sanity-checks a seller's
37
+ `Portage::Ucp.configuration` (authenticator/rate-limiter left at
38
+ unconfigured fail-safe defaults, no signing keys/payment handlers
39
+ configured, adapter capabilities only half-implemented).
40
+
41
+ ## [0.4.1] - 2026-09-15
42
+
43
+ - No behavior change — widens the `portage-ucp` dependency pin to `~> 0.6`
44
+ so this gem can install alongside `portage-ucp` 0.6.0 (the pessimistic
45
+ `~> 0.5` pin published with 0.4.0 excludes it).
8
46
 
9
47
  ## [0.4.0] - 2026-09-14
10
48
 
data/README.md CHANGED
@@ -39,11 +39,13 @@ portage compare https://your-shop.example --product-id prod_123 --results 5
39
39
  ```
40
40
 
41
41
  Depends on [`portage-ucp`](https://github.com/tomtom87/Portage/tree/main/portage-ucp)
42
- (for platform detection via `Resolver`) and
42
+ (for platform detection via `Resolver`),
43
43
  [`portage-ucp-client`](https://github.com/tomtom87/Portage/tree/main/portage-ucp-client)
44
- (for the actual buy calls). No single adapter gem is a hard dependency — install
45
- whichever `portage-ucp-<platform>` gem matches the store you're integrated with,
46
- if any.
44
+ (for the actual buy calls), and
45
+ [`portage-ucp-journal`](https://github.com/tomtom87/Portage/tree/main/portage-ucp-journal)
46
+ (for `portage-console`'s read-only view of local purchase state — see below).
47
+ No single adapter gem is a hard dependency — install whichever
48
+ `portage-ucp-<platform>` gem matches the store you're integrated with, if any.
47
49
 
48
50
  ## Installation
49
51
 
@@ -313,6 +315,39 @@ Backends that have no credentials sit out; DuckDuckGo is the keyless default
313
315
  because it's the only no-key engine with a real API, and its narrowness is the
314
316
  price of not scraping.
315
317
 
318
+ ### Console
319
+
320
+ `portage-console` is a separate executable — a read-only IRB REPL over the
321
+ three local `~/.portage` stores (design-log §22 item 6): `TransactionLog`
322
+ (reserve/complete records), `OrderLedger` (settled-order snapshots), and, if
323
+ you've wired `journal:` into your own `Dispatcher` (nothing in this gem does
324
+ that for you), the `portage-ucp-journal` gem's `PurchaseJournal`.
325
+
326
+ ```bash
327
+ portage-console
328
+ ```
329
+
330
+ ```ruby
331
+ transactions # every reserved/completed transaction
332
+ transactions(shop: "your-shop.example")
333
+ find_transaction("idem_key_123")
334
+ transactions_since(Time.now - 86400, shop: "your-shop.example")
335
+
336
+ orders # every settled-order snapshot
337
+ find_order("order_123")
338
+
339
+ journal # empty unless a Dispatcher was built with journal:
340
+ ```
341
+
342
+ Every result is passed through `Portage::Ucp::Observability.redact` before
343
+ it's returned — `payment_token`/`oauth_token`/`Authorization` and the PII
344
+ fields on an order's fulfillment destinations never print, even in a REPL you
345
+ trust. This is deliberately a local REPL, not the admin/web panel design-log
346
+ §16 also describes: a browser is a new place for a token to leak, and the
347
+ process holding a web panel also holds this machine's live platform admin
348
+ credentials in its env — problems a REPL run by whoever already has shell
349
+ access to this machine doesn't have.
350
+
316
351
  ## Development
317
352
 
318
353
  ```bash
@@ -0,0 +1,11 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "irb"
5
+ require "portage/cli/console"
6
+
7
+ include Portage::Cli::Console # rubocop:disable Style/MixinUsage -- top-level REPL helpers, by design
8
+
9
+ puts "portage-console — read-only helpers: transactions, find_transaction(key), " \
10
+ "transactions_since(time, shop:), orders, find_order(id), journal. Every result is redacted."
11
+ IRB.start
@@ -3,6 +3,7 @@ require "uri"
3
3
  require "json"
4
4
  require "portage/ucp"
5
5
  require "portage/ucp/client"
6
+ require "portage/ucp/journal"
6
7
  require_relative "payment_methods"
7
8
 
8
9
  module Portage
@@ -127,8 +128,18 @@ module Portage
127
128
  Portage::Ucp::Capabilities::CHECKOUT.advertised_for?(adapter)
128
129
  end
129
130
 
131
+ # `journal:` closes design-log §37's named gap — `Mcp::Server.build`
132
+ # (and the `Loopback`/`Client.for_adapter` it sits behind) now forward
133
+ # a `journal:` kwarg straight to `Dispatcher`, but nothing on this own-
134
+ # store loopback path ever passed one, so `portage buy` against your
135
+ # own store settled real transactions/orders while leaving the journal
136
+ # `nil`. `PurchaseJournal.new`'s own default (`FileStore.new`) is
137
+ # `~/.portage/journal.jsonl` — the same "real file-backed default"
138
+ # posture `transaction_log:`/`order_ledger:` already get from
139
+ # `Dispatcher#initialize`.
130
140
  def client_for(adapter)
131
- Portage::Ucp::Client.for_adapter(adapter, authenticator: PermissiveAuthenticator.new)
141
+ Portage::Ucp::Client.for_adapter(adapter, authenticator: PermissiveAuthenticator.new,
142
+ journal: Portage::Ucp::Journal::PurchaseJournal.new)
132
143
  end
133
144
 
134
145
  def catalog_only_adapter(adapter, platform)
@@ -0,0 +1,68 @@
1
+ require "portage/ucp"
2
+ require "portage/ucp/journal"
3
+
4
+ module Portage
5
+ module Cli
6
+ # Read-only REPL helpers over the three local stores design-log §22
7
+ # names (`TransactionLog`, `OrderLedger`, `PurchaseJournal`) — the
8
+ # console remainder of item 6. Deliberately a REPL, not the web/admin
9
+ # panel §16 also described: that panel needs its own session auth
10
+ # because a browser is a new place for a token to leak, on a process
11
+ # that already holds live platform admin credentials in its env. A
12
+ # local REPL run by whoever already holds this machine's `~/.portage`
13
+ # files and env has neither problem, so this is `exe/portage-console`
14
+ # + IRB rather than a server. Every returned record still goes through
15
+ # `Observability.redact` regardless — no new leak surface isn't the
16
+ # same guarantee as never printing a secret.
17
+ #
18
+ # Each store defaults to its own real `~/.portage/*` file, injectable
19
+ # (same posture as `Dispatcher`'s own `transaction_log:`/`order_ledger:`/
20
+ # `journal:` kwargs) so a spec, or a REPL session pointed at a different
21
+ # machine's exported files, doesn't have to touch the real ones.
22
+ #
23
+ # `journal` reads `~/.portage/journal.jsonl` and comes back empty unless
24
+ # whatever built the `Dispatcher` for your purchases passed a `journal:`
25
+ # (core's own default is `nil`) — `transactions`/`orders` don't have
26
+ # that caveat, since `TransactionLog`/`OrderLedger` are Dispatcher's
27
+ # un-opt-out-able defaults.
28
+ #
29
+ # @example
30
+ # require "portage/cli/console"
31
+ # include Portage::Cli::Console
32
+ # transactions(shop: "example.myshopify.com")
33
+ module Console
34
+ def transactions(shop: nil, transaction_log: Portage::Ucp::Support::TransactionLog.new)
35
+ scope(transaction_log.all, shop: shop)
36
+ end
37
+
38
+ def find_transaction(idempotency_key, transaction_log: Portage::Ucp::Support::TransactionLog.new)
39
+ redact(transaction_log.find(idempotency_key))
40
+ end
41
+
42
+ def transactions_since(since, shop:, transaction_log: Portage::Ucp::Support::TransactionLog.new)
43
+ redact(transaction_log.completed_since(since, shop: shop))
44
+ end
45
+
46
+ def orders(order_ledger: Portage::Ucp::Support::OrderLedger.new)
47
+ scope(order_ledger.all)
48
+ end
49
+
50
+ def find_order(order_id, order_ledger: Portage::Ucp::Support::OrderLedger.new)
51
+ redact(order_ledger.find(order_id))
52
+ end
53
+
54
+ def journal(shop: nil, purchase_journal: Portage::Ucp::Journal::PurchaseJournal.new)
55
+ scope(purchase_journal.all, shop: shop)
56
+ end
57
+
58
+ private
59
+
60
+ def scope(records, shop: nil)
61
+ records = records.select { |record| record["shop"] == shop } if shop
62
+ redact(records)
63
+ end
64
+
65
+ def redact(records) = Portage::Ucp::Observability.redact(records)
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,91 @@
1
+ require "portage/ucp"
2
+
3
+ module Portage
4
+ module Cli
5
+ # `portage doctor` — sanity-checks a seller's Portage::Ucp setup for the
6
+ # footguns nothing else catches automatically: an authenticator/rate
7
+ # limiter left at their fail-safe default (Configuration#initialize),
8
+ # no signing keys or payment handlers configured, and (when an adapter
9
+ # class is given) a capability that's only half-implemented — one action
10
+ # overridden, its siblings still raising Adapter's NotImplementedError.
11
+ # Runs against whatever `Portage::Ucp.configuration` looks like in this
12
+ # process, so a host app passes `--require` to load its own initializer
13
+ # first (Rails: `--require ./config/environment`).
14
+ class Doctor
15
+ Finding = Struct.new(:check, :message, keyword_init: true)
16
+
17
+ def initialize(adapter_class: nil)
18
+ @adapter_class = adapter_class
19
+ end
20
+
21
+ def call
22
+ [
23
+ authenticator_finding,
24
+ rate_limiter_finding,
25
+ signing_keys_finding,
26
+ payment_handlers_finding,
27
+ *capability_findings
28
+ ].compact
29
+ end
30
+
31
+ private
32
+
33
+ def config = Portage::Ucp.configuration
34
+
35
+ def authenticator_finding
36
+ return unless config.authenticator.is_a?(Portage::Ucp::UnconfiguredAuthenticator)
37
+
38
+ Finding.new(check: "authenticator",
39
+ message: "UnconfiguredAuthenticator is still in place — every mutating capability " \
40
+ "call is rejected. Set config.authenticator.")
41
+ end
42
+
43
+ def rate_limiter_finding
44
+ return unless config.rate_limiter.is_a?(Portage::Ucp::NullRateLimiter)
45
+
46
+ Finding.new(check: "rate_limiter",
47
+ message: "NullRateLimiter is still in place — no rate limiting is applied. " \
48
+ "Set config.rate_limiter if that's not intentional.")
49
+ end
50
+
51
+ def signing_keys_finding
52
+ return unless Array(config.signing_keys).empty?
53
+
54
+ Finding.new(check: "signing_keys", message: "No signing_keys configured — the manifest ships unsigned.")
55
+ end
56
+
57
+ def payment_handlers_finding
58
+ return unless Array(config.payment_handlers).empty?
59
+
60
+ Finding.new(check: "payment_handlers",
61
+ message: "No payment_handlers configured — buyers can't complete a " \
62
+ "payment-enrolled checkout.")
63
+ end
64
+
65
+ # Only action-based capabilities (predicate-based ones like discount/
66
+ # fulfillment need a live instance to call their predicate method, and
67
+ # doctor never instantiates an arbitrary adapter — it may need real
68
+ # credentials to construct).
69
+ def capability_findings
70
+ return [] unless @adapter_class
71
+
72
+ Portage::Ucp::Capabilities::ALL.filter_map { |capability| partial_capability_finding(capability) }
73
+ end
74
+
75
+ def partial_capability_finding(capability)
76
+ return if capability.actions.nil? || capability.actions.empty?
77
+
78
+ overridden = capability.actions.values.select { |method_name| overridden?(method_name) }
79
+ return if overridden.empty? || overridden.length == capability.actions.length
80
+
81
+ Finding.new(check: "capability:#{capability.name}",
82
+ message: "#{capability.name} implements #{overridden.length}/#{capability.actions.length} " \
83
+ "actions (#{overridden.join(', ')}) — the rest still raise NotImplementedError.")
84
+ end
85
+
86
+ def overridden?(method_name)
87
+ @adapter_class.instance_method(method_name).owner != Portage::Ucp::Adapter
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,225 @@
1
+ require "fileutils"
2
+ require "portage/ucp"
3
+
4
+ module Portage
5
+ module Cli
6
+ module Generate
7
+ # `portage generate adapter Foo` — scaffolds a new adapter gem shaped
8
+ # like the bundled ones (portage-ucp-etsy is the template this follows
9
+ # most closely: gemspec depending on portage-ucp, Adapter subclass,
10
+ # conformance spec). Every capability method is stubbed as `= super`,
11
+ # reflected straight off Portage::Ucp::Adapter's own signatures rather
12
+ # than hand-copied, so the scaffold can't drift from the real contract
13
+ # the way a hardcoded template would the next time the contract grows.
14
+ class Adapter
15
+ def initialize(name:, dir: nil)
16
+ @module_name = camelize(name)
17
+ @slug = underscore(name)
18
+ @dir = dir || "portage-ucp-#{@slug}"
19
+ end
20
+
21
+ def call
22
+ FileUtils.mkdir_p(File.join(@dir, "lib", "portage", "ucp", @slug))
23
+ FileUtils.mkdir_p(File.join(@dir, "spec", "portage", "ucp", @slug))
24
+ write_gemspec
25
+ write_gemfile
26
+ write_rakefile
27
+ write_rubocop
28
+ write_lib_entrypoint
29
+ write_version
30
+ write_adapter
31
+ write_spec_helper
32
+ write_conformance_spec
33
+ @dir
34
+ end
35
+
36
+ private
37
+
38
+ def camelize(str) = str.to_s.split(/[_-]/).map { |w| w[0].upcase + w[1..] }.join
39
+ def underscore(str) = str.to_s.gsub(/([a-z\d])([A-Z])/, '\1_\2').tr("-", "_").downcase
40
+
41
+ def write(relative_path, content)
42
+ File.write(File.join(@dir, relative_path), content)
43
+ end
44
+
45
+ def write_gemspec
46
+ write "#{@dir}.gemspec", <<~RUBY
47
+ require_relative "lib/portage/ucp/#{@slug}/version"
48
+
49
+ Gem::Specification.new do |spec|
50
+ spec.name = "portage-ucp-#{@slug}"
51
+ spec.version = Portage::Ucp::#{@module_name}::VERSION
52
+ spec.summary = "#{@module_name} adapter for portage-ucp"
53
+ spec.description = "Implements Portage::Ucp::Adapter against #{@module_name}'s API. " \\
54
+ "Generic only — no merchant-specific business logic."
55
+ spec.authors = ["Tom Whitbread"]
56
+ spec.license = "MIT"
57
+ spec.homepage = "https://github.com/tomtom87/Portage/tree/main/#{@dir}"
58
+ spec.required_ruby_version = ">= 3.2"
59
+
60
+ spec.files = Dir["lib/**/*.rb", "README.md", "LICENSE", "CHANGELOG.md"]
61
+ spec.require_paths = ["lib"]
62
+
63
+ spec.add_dependency "portage-ucp", "~> 0.6"
64
+
65
+ spec.add_development_dependency "rspec", "~> 3.13"
66
+ spec.add_development_dependency "rubocop", "~> 1.88"
67
+ spec.add_development_dependency "webmock", "~> 3.24"
68
+ spec.metadata["source_code_uri"] = "https://github.com/tomtom87/Portage/tree/main/#{@dir}"
69
+ spec.metadata["rubygems_mfa_required"] = "true"
70
+ end
71
+ RUBY
72
+ end
73
+
74
+ def write_gemfile
75
+ write "Gemfile", <<~RUBY
76
+ source "https://rubygems.org"
77
+
78
+ gemspec
79
+
80
+ gem "portage-ucp", path: "../portage-ucp"
81
+ RUBY
82
+ end
83
+
84
+ def write_rakefile
85
+ write "Rakefile", <<~RUBY
86
+ require "bundler/gem_tasks"
87
+ require "rspec/core/rake_task"
88
+
89
+ RSpec::Core::RakeTask.new(:spec)
90
+
91
+ task default: :spec
92
+ RUBY
93
+ end
94
+
95
+ def write_rubocop
96
+ write ".rubocop.yml", <<~YAML
97
+ AllCops:
98
+ NewCops: enable
99
+ TargetRubyVersion: 3.2
100
+ SuggestExtensions: false
101
+
102
+ Style/FrozenStringLiteralComment:
103
+ Enabled: false
104
+
105
+ Style/StringLiterals:
106
+ EnforcedStyle: double_quotes
107
+
108
+ Style/Documentation:
109
+ Enabled: false
110
+
111
+ Metrics/BlockLength:
112
+ Exclude:
113
+ - "spec/**/*.rb"
114
+ - "*.gemspec"
115
+
116
+ # The scaffolded Adapter subclass is all `= super` stubs until
117
+ # each one is replaced with a real implementation — rubocop
118
+ # would otherwise flag every one of them as a useless override.
119
+ Lint/UselessMethodDefinition:
120
+ Exclude:
121
+ - "lib/**/adapter.rb"
122
+
123
+ Gemspec/DevelopmentDependencies:
124
+ Enabled: false
125
+
126
+ Gemspec/RequireMFA:
127
+ Enabled: false
128
+ YAML
129
+ end
130
+
131
+ def write_lib_entrypoint
132
+ write "lib/portage/ucp/#{@slug}.rb", <<~RUBY
133
+ require "portage/ucp"
134
+ require_relative "#{@slug}/version"
135
+ require_relative "#{@slug}/adapter"
136
+ RUBY
137
+ end
138
+
139
+ def write_version
140
+ write "lib/portage/ucp/#{@slug}/version.rb", <<~RUBY
141
+ module Portage
142
+ module Ucp
143
+ module #{@module_name}
144
+ VERSION = "0.1.0".freeze
145
+ end
146
+ end
147
+ end
148
+ RUBY
149
+ end
150
+
151
+ def write_adapter
152
+ write "lib/portage/ucp/#{@slug}/adapter.rb", <<~RUBY
153
+ module Portage
154
+ module Ucp
155
+ module #{@module_name}
156
+ # TODO: fill in a real client/constructor, then replace each
157
+ # `= super` stub below with a real implementation. Leave a
158
+ # method as `= super` to leave that capability unadvertised
159
+ # (Portage::Ucp::Capability#advertised_for? — see adapter.rb).
160
+ class Adapter < Portage::Ucp::Adapter
161
+ #{adapter_method_stubs}
162
+ end
163
+ end
164
+ end
165
+ end
166
+ RUBY
167
+ end
168
+
169
+ # One `= super` stub per public method Portage::Ucp::Adapter itself
170
+ # defines, with the same keyword signature — reflected via
171
+ # Method#parameters rather than hardcoded so this never drifts from
172
+ # the contract's actual shape.
173
+ def adapter_method_stubs
174
+ Portage::Ucp::Adapter.instance_methods(false).sort.filter_map do |name|
175
+ next if name.to_s.end_with?("_supported?")
176
+
177
+ params = Portage::Ucp::Adapter.instance_method(name).parameters
178
+ " def #{name}(#{signature(params)}) = super"
179
+ end.join("\n")
180
+ end
181
+
182
+ def signature(params)
183
+ params.map do |type, param_name|
184
+ case type
185
+ when :keyreq then "#{param_name}:"
186
+ when :key then "#{param_name}: nil"
187
+ end
188
+ end.compact.join(", ")
189
+ end
190
+
191
+ def write_spec_helper
192
+ write "spec/spec_helper.rb", <<~RUBY
193
+ require "portage/ucp/#{@slug}"
194
+ require "webmock/rspec"
195
+
196
+ WebMock.disable_net_connect!
197
+
198
+ RSpec.configure do |config|
199
+ config.expect_with(:rspec) { |c| c.syntax = :expect }
200
+ config.disable_monkey_patching!
201
+ config.order = :random
202
+ end
203
+ RUBY
204
+ end
205
+
206
+ def write_conformance_spec
207
+ write "spec/portage/ucp/#{@slug}/conformance_spec.rb", <<~RUBY
208
+ require "spec_helper"
209
+ require "portage/ucp/rspec"
210
+
211
+ RSpec.describe Portage::Ucp::#{@module_name}::Adapter do
212
+ let(:adapter) { described_class.new }
213
+ let(:existing_product_id) { "TODO-a-real-catalog-id" }
214
+
215
+ # TODO: stub whatever HTTP calls the kit's examples below need —
216
+ # see portage-ucp-etsy/spec/.../conformance_spec.rb for a worked
217
+ # example of stubbing just enough for a catalog+checkout adapter.
218
+ it_behaves_like "a portage adapter"
219
+ end
220
+ RUBY
221
+ end
222
+ end
223
+ end
224
+ end
225
+ end
@@ -1,5 +1,5 @@
1
1
  module Portage
2
2
  module Cli
3
- VERSION = "0.4.0".freeze
3
+ VERSION = "0.5.1".freeze
4
4
  end
5
5
  end
data/lib/portage/cli.rb CHANGED
@@ -9,6 +9,8 @@ require_relative "cli/find"
9
9
  require_relative "cli/compare"
10
10
  require_relative "cli/history"
11
11
  require_relative "cli/payment_methods"
12
+ require_relative "cli/doctor"
13
+ require_relative "cli/generate/adapter"
12
14
 
13
15
  module Portage
14
16
  # `portage` — the single command-line entrypoint for acting as a shopper's
@@ -37,23 +39,22 @@ module Portage
37
39
  [--rolling-cap N --rolling-window-seconds N --currency CUR]
38
40
  [--velocity-count N --velocity-window-seconds N]
39
41
  [--allow HOST ...] [--clear-allowlist]
42
+ portage doctor [--require FILE] [--adapter CLASS_NAME] [--json]
43
+ portage generate adapter NAME [--dir DIR]
40
44
  USAGE
41
45
 
46
+ COMMANDS = { "buy" => :run_buy, "find" => :run_find, "compare" => :run_compare,
47
+ "history" => :run_history, "payment" => :run_payment, "policy" => :run_policy,
48
+ "doctor" => :run_doctor, "generate" => :run_generate }.freeze
49
+
42
50
  # @param argv [Array<String>]
43
51
  # @return [Integer] process exit code
44
52
  def self.run(argv)
45
53
  command, *rest = argv
46
- case command
47
- when "buy" then run_buy(rest)
48
- when "find" then run_find(rest)
49
- when "compare" then run_compare(rest)
50
- when "history" then run_history(rest)
51
- when "payment" then run_payment(rest)
52
- when "policy" then run_policy(rest)
53
- else
54
- warn USAGE
55
- 1
56
- end
54
+ return send(COMMANDS[command], rest) if COMMANDS.key?(command)
55
+
56
+ warn USAGE
57
+ 1
57
58
  end
58
59
 
59
60
  # --- find ---
@@ -517,6 +518,54 @@ module Portage
517
518
  end
518
519
  private_class_method :format_payment_enroll
519
520
 
521
+ # --- doctor ---
522
+
523
+ def self.parse_doctor_options(argv)
524
+ opts = {}
525
+ OptionParser.new do |parser|
526
+ parser.on("--require FILE") { |v| opts[:require] = v }
527
+ parser.on("--adapter CLASS_NAME") { |v| opts[:adapter] = v }
528
+ parser.on("--json") { opts[:json] = true }
529
+ end.parse!(argv)
530
+ opts
531
+ end
532
+ private_class_method :parse_doctor_options
533
+
534
+ def self.run_doctor(argv)
535
+ opts = parse_doctor_options(argv)
536
+ require File.expand_path(opts[:require]) if opts[:require]
537
+ adapter_class = opts[:adapter] && Object.const_get(opts[:adapter])
538
+
539
+ findings = Doctor.new(adapter_class: adapter_class).call
540
+ puts opts[:json] ? JSON.pretty_generate(findings.map(&:to_h)) : format_doctor(findings)
541
+ findings.empty? ? 0 : 1
542
+ end
543
+ private_class_method :run_doctor
544
+
545
+ def self.format_doctor(findings)
546
+ return "No issues found." if findings.empty?
547
+
548
+ findings.map { |f| "[#{f.check}] #{f.message}" }.join("\n")
549
+ end
550
+ private_class_method :format_doctor
551
+
552
+ # --- generate ---
553
+
554
+ def self.run_generate(argv)
555
+ kind, name, *rest = argv
556
+ unless kind == "adapter" && name
557
+ warn USAGE
558
+ return 1
559
+ end
560
+
561
+ dir = nil
562
+ OptionParser.new { |parser| parser.on("--dir DIR") { |v| dir = v } }.parse!(rest)
563
+ path = Generate::Adapter.new(name: name, dir: dir).call
564
+ puts "Scaffolded #{path}/"
565
+ 0
566
+ end
567
+ private_class_method :run_generate
568
+
520
569
  # --- output ---
521
570
 
522
571
  def self.format_report(report)
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: portage-cli
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.0
4
+ version: 0.5.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Tom Whitbread
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-09-14 00:00:00.000000000 Z
11
+ date: 2026-09-16 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: portage-ucp
@@ -16,14 +16,14 @@ dependencies:
16
16
  requirements:
17
17
  - - "~>"
18
18
  - !ruby/object:Gem::Version
19
- version: '0.5'
19
+ version: '0.7'
20
20
  type: :runtime
21
21
  prerelease: false
22
22
  version_requirements: !ruby/object:Gem::Requirement
23
23
  requirements:
24
24
  - - "~>"
25
25
  - !ruby/object:Gem::Version
26
- version: '0.5'
26
+ version: '0.7'
27
27
  - !ruby/object:Gem::Dependency
28
28
  name: portage-ucp-client
29
29
  requirement: !ruby/object:Gem::Requirement
@@ -38,6 +38,20 @@ dependencies:
38
38
  - - "~>"
39
39
  - !ruby/object:Gem::Version
40
40
  version: '0.3'
41
+ - !ruby/object:Gem::Dependency
42
+ name: portage-ucp-journal
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '0.1'
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '0.1'
41
55
  - !ruby/object:Gem::Dependency
42
56
  name: rspec
43
57
  requirement: !ruby/object:Gem::Requirement
@@ -102,11 +116,14 @@ description: 'Ships the `portage` executable. `portage buy <url>` tries native U
102
116
  shopper. `portage find` covers the no-URL case: ask an allowlist, DuckDuckGo, Brave,
103
117
  or a Google Programmable Search engine which stores might sell something, keep the
104
118
  ones answering /.well-known/ucp, and search their catalogs — documented APIs only,
105
- no SERP scraping. Depends on portage-ucp (for platform detection via Resolver) and
106
- portage-ucp-client (for the actual buy calls); no single adapter gem is a hard dependency.'
119
+ no SERP scraping. Depends on portage-ucp (for platform detection via Resolver),
120
+ portage-ucp-client (for the actual buy calls), and portage-ucp-journal (for `portage-console`''s
121
+ read-only view of local purchase/transaction/order state); no single adapter gem
122
+ is a hard dependency.'
107
123
  email:
108
124
  executables:
109
125
  - portage
126
+ - portage-console
110
127
  extensions: []
111
128
  extra_rdoc_files: []
112
129
  files:
@@ -114,11 +131,15 @@ files:
114
131
  - LICENSE
115
132
  - README.md
116
133
  - exe/portage
134
+ - exe/portage-console
117
135
  - lib/portage/cli.rb
118
136
  - lib/portage/cli/buy.rb
119
137
  - lib/portage/cli/catalog_products.rb
120
138
  - lib/portage/cli/compare.rb
139
+ - lib/portage/cli/console.rb
140
+ - lib/portage/cli/doctor.rb
121
141
  - lib/portage/cli/find.rb
142
+ - lib/portage/cli/generate/adapter.rb
122
143
  - lib/portage/cli/history.rb
123
144
  - lib/portage/cli/payment_methods.rb
124
145
  - lib/portage/cli/payment_methods/env_backend.rb