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 +4 -4
- data/CHANGELOG.md +39 -1
- data/README.md +39 -4
- data/exe/portage-console +11 -0
- data/lib/portage/cli/buy.rb +12 -1
- data/lib/portage/cli/console.rb +68 -0
- data/lib/portage/cli/doctor.rb +91 -0
- data/lib/portage/cli/generate/adapter.rb +225 -0
- data/lib/portage/cli/version.rb +1 -1
- data/lib/portage/cli.rb +60 -11
- metadata +27 -6
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2a0e9e7e0b7046fc20978a89fb06056bf1827df5efa45c47ddfb0b16f332d53f
|
|
4
|
+
data.tar.gz: 9ab0f0c9caa507147fae5069dda27c86a92e4c26f273c9cbc14cee8fbfddf368
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
## [
|
|
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`)
|
|
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)
|
|
45
|
-
|
|
46
|
-
|
|
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
|
data/exe/portage-console
ADDED
|
@@ -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
|
data/lib/portage/cli/buy.rb
CHANGED
|
@@ -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
|
data/lib/portage/cli/version.rb
CHANGED
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
+
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-
|
|
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.
|
|
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.
|
|
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)
|
|
106
|
-
portage-ucp-client (for the actual buy calls)
|
|
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
|