wiq-cli 0.1.0
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 +7 -0
- data/LICENSE +21 -0
- data/README.md +144 -0
- data/bin/wiq +22 -0
- data/docs/deferred.md +155 -0
- data/docs/wiq_api_notes.md +629 -0
- data/lib/wiq/cli.rb +89 -0
- data/lib/wiq/client.rb +127 -0
- data/lib/wiq/commands/auth.rb +257 -0
- data/lib/wiq/commands/base.rb +70 -0
- data/lib/wiq/commands/billing_profiles.rb +52 -0
- data/lib/wiq/commands/charges.rb +104 -0
- data/lib/wiq/commands/check_ins.rb +124 -0
- data/lib/wiq/commands/doctor.rb +93 -0
- data/lib/wiq/commands/events.rb +109 -0
- data/lib/wiq/commands/metrics.rb +118 -0
- data/lib/wiq/commands/paid_sessions.rb +81 -0
- data/lib/wiq/commands/prospect_families.rb +129 -0
- data/lib/wiq/commands/prospects.rb +153 -0
- data/lib/wiq/commands/registrations.rb +78 -0
- data/lib/wiq/commands/reports.rb +510 -0
- data/lib/wiq/commands/rosters.rb +81 -0
- data/lib/wiq/commands/setup.rb +82 -0
- data/lib/wiq/commands/workflows.rb +84 -0
- data/lib/wiq/commands/wrestlers.rb +140 -0
- data/lib/wiq/config.rb +165 -0
- data/lib/wiq/credentials.rb +95 -0
- data/lib/wiq/errors.rb +172 -0
- data/lib/wiq/introspection.rb +105 -0
- data/lib/wiq/output.rb +67 -0
- data/lib/wiq/pagination.rb +41 -0
- data/lib/wiq/season_resolver.rb +46 -0
- data/lib/wiq/version.rb +5 -0
- data/lib/wiq/workflows.rb +314 -0
- data/lib/wiq.rb +30 -0
- data/share/skills/wiq/SKILL.md +348 -0
- metadata +141 -0
data/lib/wiq/cli.rb
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "thor"
|
|
4
|
+
|
|
5
|
+
module Wiq
|
|
6
|
+
class CLI < Thor
|
|
7
|
+
package_name "wiq"
|
|
8
|
+
|
|
9
|
+
def self.exit_on_failure?
|
|
10
|
+
true
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
desc "version", "Print the CLI version"
|
|
14
|
+
def version
|
|
15
|
+
puts Wiq::VERSION
|
|
16
|
+
end
|
|
17
|
+
map %w[--version -v] => :version
|
|
18
|
+
|
|
19
|
+
desc "commands", "Dump the full command tree as JSON for agent discovery"
|
|
20
|
+
long_desc <<~DESC
|
|
21
|
+
Walks the Thor command registry and emits the entire CLI surface as
|
|
22
|
+
structured JSON. Schema:
|
|
23
|
+
|
|
24
|
+
{ name, version, global_options[], top_level_commands[],
|
|
25
|
+
groups: [ { name, description, commands: [
|
|
26
|
+
{ name, description, long_description, usage,
|
|
27
|
+
options: [ { name, type, required, default?, enum?, description } ]
|
|
28
|
+
}
|
|
29
|
+
] } ] }
|
|
30
|
+
|
|
31
|
+
Recommended first call for any agent: pipe to a file, cache it, then
|
|
32
|
+
pick a command and invoke it directly. No HTTP, no auth required.
|
|
33
|
+
|
|
34
|
+
`global_options` lists --host, --as, --json, --agent — these apply
|
|
35
|
+
uniformly to every command and aren't repeated per-command.
|
|
36
|
+
DESC
|
|
37
|
+
def commands
|
|
38
|
+
puts JSON.pretty_generate(Wiq::Introspection.dump_tree)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
desc "doctor", "Diagnose env, network, token, and config sources"
|
|
42
|
+
subcommand "doctor", Wiq::Commands::Doctor
|
|
43
|
+
|
|
44
|
+
desc "auth SUBCOMMAND", "Manage authentication (login, status, logout, list)"
|
|
45
|
+
subcommand "auth", Wiq::Commands::Auth
|
|
46
|
+
|
|
47
|
+
desc "check_ins SUBCOMMAND", "Attendance + check-ins (event, wrestler, summary)"
|
|
48
|
+
subcommand "check_ins", Wiq::Commands::CheckIns
|
|
49
|
+
|
|
50
|
+
desc "reports SUBCOMMAND", "Reports (run, show, types)"
|
|
51
|
+
subcommand "reports", Wiq::Commands::Reports
|
|
52
|
+
|
|
53
|
+
desc "paid_sessions SUBCOMMAND", "Paid sessions / registration data (list, show)"
|
|
54
|
+
subcommand "paid_sessions", Wiq::Commands::PaidSessions
|
|
55
|
+
|
|
56
|
+
desc "registrations SUBCOMMAND", "Registration questions + answers"
|
|
57
|
+
subcommand "registrations", Wiq::Commands::Registrations
|
|
58
|
+
|
|
59
|
+
desc "metrics SUBCOMMAND", "Dashboard metrics (list, show)"
|
|
60
|
+
subcommand "metrics", Wiq::Commands::Metrics
|
|
61
|
+
|
|
62
|
+
desc "events SUBCOMMAND", "Calendar events (list, show)"
|
|
63
|
+
subcommand "events", Wiq::Commands::Events
|
|
64
|
+
|
|
65
|
+
desc "rosters SUBCOMMAND", "Rosters (list, show)"
|
|
66
|
+
subcommand "rosters", Wiq::Commands::Rosters
|
|
67
|
+
|
|
68
|
+
desc "prospects SUBCOMMAND", "Prospect pipeline — individual kids (list, show, summary)"
|
|
69
|
+
subcommand "prospects", Wiq::Commands::Prospects
|
|
70
|
+
|
|
71
|
+
desc "prospect_families SUBCOMMAND", "Prospect pipeline — households (list, show, notes)"
|
|
72
|
+
subcommand "prospect_families", Wiq::Commands::ProspectFamilies
|
|
73
|
+
|
|
74
|
+
desc "workflows SUBCOMMAND", "Named multi-step recipes for common club-admin questions (list, show)"
|
|
75
|
+
subcommand "workflows", Wiq::Commands::Workflows
|
|
76
|
+
|
|
77
|
+
desc "setup SUBCOMMAND", "Install integration files (Claude Code skill, future: Cursor, etc.)"
|
|
78
|
+
subcommand "setup", Wiq::Commands::Setup
|
|
79
|
+
|
|
80
|
+
desc "wrestlers SUBCOMMAND", "Search wrestlers — narrow ID-discovery surface (list, show)"
|
|
81
|
+
subcommand "wrestlers", Wiq::Commands::Wrestlers
|
|
82
|
+
|
|
83
|
+
desc "charges SUBCOMMAND", "Payment history — list charges by family, status, type, date (admin only)"
|
|
84
|
+
subcommand "charges", Wiq::Commands::Charges
|
|
85
|
+
|
|
86
|
+
desc "billing_profiles SUBCOMMAND", "Look up a parent or coach's billing profile (admin only)"
|
|
87
|
+
subcommand "billing_profiles", Wiq::Commands::BillingProfiles
|
|
88
|
+
end
|
|
89
|
+
end
|
data/lib/wiq/client.rb
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "faraday"
|
|
4
|
+
require "faraday/retry"
|
|
5
|
+
require "json"
|
|
6
|
+
require "uri"
|
|
7
|
+
|
|
8
|
+
module Wiq
|
|
9
|
+
# Thin wrapper around Faraday that:
|
|
10
|
+
# - Adds Authorization: Bearer <pat>
|
|
11
|
+
# - Parses JSON requests and responses
|
|
12
|
+
# - Retries on 429 and 5xx (Retry-After honored)
|
|
13
|
+
# - Translates non-2xx responses into Wiq::APIError
|
|
14
|
+
# - Exposes paginate(path, params) that walks the Link: rel=next chain
|
|
15
|
+
class Client
|
|
16
|
+
USER_AGENT = "wiq-cli/#{Wiq::VERSION}"
|
|
17
|
+
|
|
18
|
+
attr_reader :host
|
|
19
|
+
|
|
20
|
+
def initialize(config)
|
|
21
|
+
@config = config
|
|
22
|
+
config.require_host!
|
|
23
|
+
@host = config.host
|
|
24
|
+
@token = config.token
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def get(path, params = {})
|
|
28
|
+
request(:get, path, params: params)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def post(path, body = {})
|
|
32
|
+
request(:post, path, body: body)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def put(path, body = {})
|
|
36
|
+
request(:put, path, body: body)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def delete(path, params = {})
|
|
40
|
+
request(:delete, path, params: params)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Yields each page's records until rel=next runs out.
|
|
44
|
+
# All /api/v1 index endpoints wrap their array under the resource name
|
|
45
|
+
# (e.g. `{"rosters": [...]}`) — callers must pass `key:` so we can unwrap.
|
|
46
|
+
# Yields (records_array, response, total_count).
|
|
47
|
+
def paginate(path, params = {}, key:)
|
|
48
|
+
url = absolute(path)
|
|
49
|
+
first = true
|
|
50
|
+
loop do
|
|
51
|
+
resp = first ? request(:get, path, params: params, raw: true)
|
|
52
|
+
: request(:get, url, raw: true)
|
|
53
|
+
first = false
|
|
54
|
+
records = extract_records(resp.body, key)
|
|
55
|
+
total = Pagination.total_count(resp.headers)
|
|
56
|
+
yield records, resp, total
|
|
57
|
+
url = Pagination.next_url(resp.headers["Link"] || resp.headers["link"])
|
|
58
|
+
break unless url
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Collects every page of an index into a single array.
|
|
63
|
+
def collect_all(path, params = {}, key:)
|
|
64
|
+
all = []
|
|
65
|
+
total = nil
|
|
66
|
+
paginate(path, params, key: key) do |records, _resp, t|
|
|
67
|
+
total ||= t
|
|
68
|
+
all.concat(records)
|
|
69
|
+
end
|
|
70
|
+
[all, total]
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
def extract_records(body, key)
|
|
76
|
+
return Array(body) if body.is_a?(Array)
|
|
77
|
+
return [] if body.nil?
|
|
78
|
+
raise Error.new("Expected wrapped index response with key #{key.inspect}, got #{body.class}",
|
|
79
|
+
code: "unexpected_response") unless body.is_a?(Hash)
|
|
80
|
+
|
|
81
|
+
Array(body[key.to_s] || body[key.to_sym])
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def request(method, path_or_url, params: {}, body: nil, raw: false)
|
|
85
|
+
url = path_or_url.start_with?("http") ? path_or_url : absolute(path_or_url)
|
|
86
|
+
|
|
87
|
+
response = connection.public_send(method) do |req|
|
|
88
|
+
req.url(url)
|
|
89
|
+
req.params.update(params) if params && !params.empty?
|
|
90
|
+
req.body = body if body
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
if response.status >= 400
|
|
94
|
+
raise APIError.new(
|
|
95
|
+
status: response.status,
|
|
96
|
+
body: response.body,
|
|
97
|
+
request_id: response.headers["X-Request-Id"]
|
|
98
|
+
)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
raw ? response : response.body
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def absolute(path)
|
|
105
|
+
base = @host.sub(%r{/+\z}, "")
|
|
106
|
+
path = path.start_with?("/") ? path : "/#{path}"
|
|
107
|
+
"#{base}#{path}"
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def connection
|
|
111
|
+
@connection ||= Faraday.new do |f|
|
|
112
|
+
f.request :json
|
|
113
|
+
f.request :retry,
|
|
114
|
+
max: 3,
|
|
115
|
+
interval: 0.5,
|
|
116
|
+
backoff_factor: 2,
|
|
117
|
+
retry_statuses: [429, 500, 502, 503, 504],
|
|
118
|
+
methods: %i[get head]
|
|
119
|
+
f.response :json, content_type: /\bjson\z/
|
|
120
|
+
f.headers["Accept"] = "application/json"
|
|
121
|
+
f.headers["User-Agent"] = USER_AGENT
|
|
122
|
+
f.headers["Authorization"] = "Bearer #{@token}" if @token
|
|
123
|
+
f.adapter Faraday.default_adapter
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "io/console"
|
|
4
|
+
|
|
5
|
+
module Wiq
|
|
6
|
+
module Commands
|
|
7
|
+
class Auth < Base
|
|
8
|
+
desc "login", "Store a personal access token for a WIQ host"
|
|
9
|
+
long_desc <<~DESC
|
|
10
|
+
Stores a PAT in ~/.config/wiq/credentials.json (mode 0600) keyed by
|
|
11
|
+
host + alias.
|
|
12
|
+
|
|
13
|
+
First-time login slots into "default" automatically. If "default" is
|
|
14
|
+
already taken, pass --as <alias> to create a separate slot — this is
|
|
15
|
+
how the CLI supports multiple WIQ accounts (different clubs, different
|
|
16
|
+
roles) on the same host.
|
|
17
|
+
|
|
18
|
+
Customers mint PATs at <host>/settings/personal_access_tokens. The
|
|
19
|
+
plaintext is shown once at creation; paste it into this command (or
|
|
20
|
+
use --token).
|
|
21
|
+
|
|
22
|
+
Verification: the CLI hits `GET /api/v1/personal_access_tokens` to
|
|
23
|
+
confirm the token works AND fetch the bound profile (display_name,
|
|
24
|
+
team_name, type) in one round trip. That metadata is shown after
|
|
25
|
+
login and stored alongside the token.
|
|
26
|
+
DESC
|
|
27
|
+
method_option :token, type: :string, desc: "PAT value (otherwise prompted interactively)"
|
|
28
|
+
method_option :force, type: :boolean, default: false,
|
|
29
|
+
desc: "Overwrite an existing entry at this host+alias slot"
|
|
30
|
+
def login
|
|
31
|
+
default_host = Wiq::Config::PRODUCTION_HOST
|
|
32
|
+
host = options[:host] || prompt("WIQ host URL [#{default_host}]: ")
|
|
33
|
+
host = host.to_s.strip
|
|
34
|
+
host = default_host if host.empty?
|
|
35
|
+
unless host.start_with?("http")
|
|
36
|
+
raise Wiq::ConfigError.new("Host must be an absolute URL, got #{host.inspect}.",
|
|
37
|
+
code: "invalid_host")
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
token = options[:token] || prompt_secret("Personal access token: ")
|
|
41
|
+
token = token.to_s.strip
|
|
42
|
+
unless token.start_with?("wiq_pat_")
|
|
43
|
+
raise Wiq::ConfigError.new("Token does not start with `wiq_pat_`.",
|
|
44
|
+
code: "invalid_token",
|
|
45
|
+
hint: "Mint a PAT at <host>/settings/personal_access_tokens.")
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
alias_name = pick_login_alias(host, options[:as])
|
|
49
|
+
|
|
50
|
+
if Wiq::Credentials.for_host(host, alias_name) && !options[:force]
|
|
51
|
+
raise Wiq::AliasConflictError.new(host, alias_name)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
cfg = Wiq::Config.new
|
|
55
|
+
cfg.instance_variable_set(:@host, host)
|
|
56
|
+
cfg.instance_variable_set(:@token, token)
|
|
57
|
+
client = Wiq::Client.new(cfg)
|
|
58
|
+
|
|
59
|
+
metadata = lookup_token_metadata(client, token)
|
|
60
|
+
|
|
61
|
+
Wiq::Credentials.store(
|
|
62
|
+
host: host,
|
|
63
|
+
alias_name: alias_name,
|
|
64
|
+
token: token,
|
|
65
|
+
token_prefix: metadata[:token_prefix],
|
|
66
|
+
name: metadata[:name],
|
|
67
|
+
profile: metadata[:profile]
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
render(
|
|
71
|
+
{
|
|
72
|
+
"host" => host,
|
|
73
|
+
"alias" => alias_name,
|
|
74
|
+
"token_prefix" => metadata[:token_prefix],
|
|
75
|
+
"name" => metadata[:name],
|
|
76
|
+
"profile" => metadata[:profile],
|
|
77
|
+
"stored_at" => Wiq::Credentials.for_host(host, alias_name)["stored_at"]
|
|
78
|
+
}.compact,
|
|
79
|
+
summary: profile_summary("Stored PAT for #{host} as #{alias_name.inspect}",
|
|
80
|
+
metadata[:profile]),
|
|
81
|
+
breadcrumbs: [
|
|
82
|
+
{ "cmd" => "wiq auth status --as #{alias_name}",
|
|
83
|
+
"description" => "Verify the stored token works" },
|
|
84
|
+
{ "cmd" => "wiq auth list", "description" => "See all stored credentials" }
|
|
85
|
+
]
|
|
86
|
+
)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
desc "status", "Show the configured host, alias, and bound profile"
|
|
90
|
+
long_desc <<~DESC
|
|
91
|
+
Resolves the configuration chain (--host/--as flags → env vars →
|
|
92
|
+
.wiq/config.json → credentials store) and reports which source won.
|
|
93
|
+
|
|
94
|
+
Performs a best-effort live probe against
|
|
95
|
+
`/api/v1/personal_access_tokens` to confirm the token still works
|
|
96
|
+
and surface the live last_used_at. If the probe fails (network,
|
|
97
|
+
revoked token, host unreachable), the error appears in `live_error`
|
|
98
|
+
rather than aborting the command — local state is always shown.
|
|
99
|
+
|
|
100
|
+
Useful as the first call when an agent inherits a configured shell:
|
|
101
|
+
verifies host, alias, profile binding, and team in one shot.
|
|
102
|
+
DESC
|
|
103
|
+
def status
|
|
104
|
+
cfg = Wiq::Config.load(symbolized_options)
|
|
105
|
+
raise Wiq::HostUnsetError unless cfg.host
|
|
106
|
+
|
|
107
|
+
store_entry = cfg.alias_name ? Wiq::Credentials.for_host(cfg.host, cfg.alias_name) || {} : {}
|
|
108
|
+
live_metadata = nil
|
|
109
|
+
live_error = nil
|
|
110
|
+
if cfg.token
|
|
111
|
+
begin
|
|
112
|
+
client = Wiq::Client.new(cfg)
|
|
113
|
+
live_metadata = lookup_token_metadata(client, cfg.token)
|
|
114
|
+
rescue Wiq::APIError, Faraday::Error => e
|
|
115
|
+
live_error = e.message
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
data = {
|
|
120
|
+
"host" => cfg.host,
|
|
121
|
+
"host_source" => cfg.sources[:host],
|
|
122
|
+
"alias" => cfg.alias_name,
|
|
123
|
+
"alias_source" => cfg.sources[:alias],
|
|
124
|
+
"token_present" => !cfg.token.nil?,
|
|
125
|
+
"token_source" => cfg.sources[:token],
|
|
126
|
+
"token_prefix" => store_entry["token_prefix"],
|
|
127
|
+
"stored_name" => store_entry["name"],
|
|
128
|
+
"stored_profile" => store_entry["profile"],
|
|
129
|
+
"live_name" => live_metadata&.dig(:name),
|
|
130
|
+
"live_last_used_at" => live_metadata&.dig(:last_used_at),
|
|
131
|
+
"live_profile" => live_metadata&.dig(:profile),
|
|
132
|
+
"live_error" => live_error
|
|
133
|
+
}.compact
|
|
134
|
+
|
|
135
|
+
summary =
|
|
136
|
+
if !cfg.token
|
|
137
|
+
cfg.alias_name ? "No token stored for alias #{cfg.alias_name.inspect}." \
|
|
138
|
+
: "No alias resolvable for this host."
|
|
139
|
+
else
|
|
140
|
+
profile_summary("Token reachable (alias=#{cfg.alias_name})",
|
|
141
|
+
live_metadata&.dig(:profile) || store_entry["profile"])
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
render(data, summary: summary)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
desc "logout", "Remove a stored credential slot"
|
|
148
|
+
long_desc <<~DESC
|
|
149
|
+
Removes a single stored credential by host + alias. The PAT is NOT
|
|
150
|
+
revoked server-side — to revoke, use the web UI at
|
|
151
|
+
<host>/settings/personal_access_tokens. Logout just clears the
|
|
152
|
+
local file entry.
|
|
153
|
+
DESC
|
|
154
|
+
def logout
|
|
155
|
+
cfg = Wiq::Config.load(symbolized_options)
|
|
156
|
+
raise Wiq::HostUnsetError unless cfg.host
|
|
157
|
+
unless cfg.alias_name
|
|
158
|
+
aliases = Wiq::Credentials.aliases_for(cfg.host)
|
|
159
|
+
raise Wiq::AmbiguousAliasError.new(cfg.host, aliases) unless aliases.empty?
|
|
160
|
+
|
|
161
|
+
render({ "host" => cfg.host, "removed" => false },
|
|
162
|
+
summary: "No credentials stored for #{cfg.host}.")
|
|
163
|
+
return
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
removed = Wiq::Credentials.remove(cfg.host, cfg.alias_name)
|
|
167
|
+
render(
|
|
168
|
+
{ "host" => cfg.host, "alias" => cfg.alias_name, "removed" => removed },
|
|
169
|
+
summary: removed ? "Cleared #{cfg.alias_name.inspect} for #{cfg.host}." \
|
|
170
|
+
: "No credential stored at #{cfg.host} / #{cfg.alias_name.inspect}."
|
|
171
|
+
)
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
desc "list", "List all stored credentials across hosts and aliases"
|
|
175
|
+
long_desc <<~DESC
|
|
176
|
+
Dumps every stored credential across all hosts and aliases. Never
|
|
177
|
+
includes the raw token (only token_prefix); safe to share or log.
|
|
178
|
+
|
|
179
|
+
Useful for agents inheriting a shared shell — they can see what's
|
|
180
|
+
available before invoking commands.
|
|
181
|
+
DESC
|
|
182
|
+
def list
|
|
183
|
+
entries = Wiq::Credentials.all_entries
|
|
184
|
+
render_index(
|
|
185
|
+
entries,
|
|
186
|
+
summary: "Stored credentials: #{entries.size}.",
|
|
187
|
+
breadcrumbs: [
|
|
188
|
+
{ "cmd" => "wiq auth status --as <alias>", "description" => "Inspect a specific slot" }
|
|
189
|
+
]
|
|
190
|
+
)
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
no_commands do
|
|
194
|
+
def prompt(label)
|
|
195
|
+
$stderr.print(label)
|
|
196
|
+
$stdin.gets.to_s.chomp
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def prompt_secret(label)
|
|
200
|
+
$stderr.print(label)
|
|
201
|
+
val = $stdin.noecho(&:gets).to_s.chomp
|
|
202
|
+
$stderr.puts ""
|
|
203
|
+
val
|
|
204
|
+
rescue Errno::ENOTTY, IOError
|
|
205
|
+
$stdin.gets.to_s.chomp
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# Decide which slot to write to. Explicit --as wins. Otherwise:
|
|
209
|
+
# - empty bucket → "default"
|
|
210
|
+
# - "default" free → "default"
|
|
211
|
+
# - "default" taken → demand --as
|
|
212
|
+
def pick_login_alias(host, requested)
|
|
213
|
+
return requested if requested && !requested.empty?
|
|
214
|
+
|
|
215
|
+
existing = Wiq::Credentials.aliases_for(host)
|
|
216
|
+
return Wiq::Credentials::DEFAULT_ALIAS if existing.empty?
|
|
217
|
+
return Wiq::Credentials::DEFAULT_ALIAS unless existing.include?(Wiq::Credentials::DEFAULT_ALIAS)
|
|
218
|
+
|
|
219
|
+
raise Wiq::ConfigError.new(
|
|
220
|
+
"An entry already exists at #{host} under alias \"default\".",
|
|
221
|
+
code: "alias_required",
|
|
222
|
+
hint: "Pass --as <name> to store under a different slot, or --force to overwrite default."
|
|
223
|
+
)
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def lookup_token_metadata(client, token)
|
|
227
|
+
prefix = token[0, 12]
|
|
228
|
+
rows, = client.collect_all(
|
|
229
|
+
"/api/v1/personal_access_tokens",
|
|
230
|
+
{ "per_page" => 100 },
|
|
231
|
+
key: "personal_access_tokens"
|
|
232
|
+
)
|
|
233
|
+
match = rows.find { |r| r["token_prefix"] == prefix }
|
|
234
|
+
return { token_prefix: prefix, name: nil, last_used_at: nil, profile: nil } unless match
|
|
235
|
+
|
|
236
|
+
{
|
|
237
|
+
token_prefix: match["token_prefix"],
|
|
238
|
+
name: match["name"],
|
|
239
|
+
last_used_at: match["last_used_at"],
|
|
240
|
+
profile: match["profile"]
|
|
241
|
+
}
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def profile_summary(prefix, profile)
|
|
245
|
+
return "#{prefix}." unless profile
|
|
246
|
+
|
|
247
|
+
who = profile["display_name"]
|
|
248
|
+
where = profile["team_name"]
|
|
249
|
+
kind = profile["type"]
|
|
250
|
+
tail = [who, where].compact.reject(&:empty?).join(" @ ")
|
|
251
|
+
tail += " (#{kind})" if kind && !kind.empty?
|
|
252
|
+
tail.empty? ? "#{prefix}." : "#{prefix} — #{tail}."
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "thor"
|
|
4
|
+
|
|
5
|
+
module Wiq
|
|
6
|
+
module Commands
|
|
7
|
+
# Parent class for every subcommand group. Defines the cross-cutting
|
|
8
|
+
# --host / --json / --agent options and the helpers each command uses.
|
|
9
|
+
class Base < Thor
|
|
10
|
+
class_option :host, type: :string,
|
|
11
|
+
desc: "WIQ host URL (overrides env + config + credential store)"
|
|
12
|
+
class_option :as, type: :string,
|
|
13
|
+
desc: "Credential alias to use (overrides env + config + sole/default)"
|
|
14
|
+
class_option :json, type: :boolean, default: false,
|
|
15
|
+
desc: "Output the full JSON envelope"
|
|
16
|
+
class_option :agent, type: :boolean, default: false,
|
|
17
|
+
desc: "Output bare JSON of `data` with no envelope or colors"
|
|
18
|
+
|
|
19
|
+
def self.exit_on_failure?
|
|
20
|
+
true
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
no_commands do
|
|
24
|
+
def config
|
|
25
|
+
@config ||= Wiq::Config.load(symbolized_options)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def client
|
|
29
|
+
@client ||= begin
|
|
30
|
+
config.require_token!
|
|
31
|
+
Wiq::Client.new(config)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def symbolized_options
|
|
36
|
+
@symbolized_options ||= options.each_with_object({}) { |(k, v), h| h[k.to_sym] = v }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def render(data, summary: nil, breadcrumbs: [], meta: {})
|
|
40
|
+
Wiq::Output.render(data, summary: summary, breadcrumbs: breadcrumbs,
|
|
41
|
+
meta: meta.merge("host" => config.host).compact,
|
|
42
|
+
options: symbolized_options)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def render_index(records, total: nil, page: nil, summary: nil, breadcrumbs: [])
|
|
46
|
+
meta = { "count" => records.size }
|
|
47
|
+
meta["total"] = total if total
|
|
48
|
+
meta["page"] = page if page
|
|
49
|
+
render(records, summary: summary, breadcrumbs: breadcrumbs, meta: meta)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def fetch_index(path, params, key:)
|
|
53
|
+
if options[:all]
|
|
54
|
+
records, total = client.collect_all(path, params, key: key)
|
|
55
|
+
[records, total, nil]
|
|
56
|
+
else
|
|
57
|
+
records = []
|
|
58
|
+
total = nil
|
|
59
|
+
client.paginate(path, params, key: key) do |page_records, _resp, t|
|
|
60
|
+
records = page_records
|
|
61
|
+
total = t
|
|
62
|
+
break
|
|
63
|
+
end
|
|
64
|
+
[records, total, nil]
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Wiq
|
|
4
|
+
module Commands
|
|
5
|
+
class BillingProfiles < Base
|
|
6
|
+
VALID_PROFILE_TYPES = %w[ParentProfile CoachProfile].freeze
|
|
7
|
+
|
|
8
|
+
desc "show PROFILE_ID", "Look up a parent or coach's billing profile"
|
|
9
|
+
long_desc <<~DESC
|
|
10
|
+
Fetches the billing profile (payment-method metadata + Stripe/Justifi
|
|
11
|
+
linkage) for a specific parent or coach profile.
|
|
12
|
+
|
|
13
|
+
Path: GET /api/v1/billing_profiles/<profile_type>/<profile_id>.
|
|
14
|
+
|
|
15
|
+
WrestlerProfile is not supported — wrestlers themselves don't have
|
|
16
|
+
billing profiles in WIQ. To find which family paid for a given
|
|
17
|
+
wrestler, walk via `wiq wrestlers show <id>` to find the parents
|
|
18
|
+
array, then call this for one of the parent profile ids.
|
|
19
|
+
|
|
20
|
+
Returns: id, email, billing_partner (stripe | justifi),
|
|
21
|
+
default_payment_method_{last4, brand, exp_month, exp_year}, and
|
|
22
|
+
the embedded billable profile (full_name, type).
|
|
23
|
+
|
|
24
|
+
Pass --for-update to get a setup_intent for card-on-file updates;
|
|
25
|
+
not relevant for read-only agent flows.
|
|
26
|
+
DESC
|
|
27
|
+
method_option :profile_type, type: :string, required: true,
|
|
28
|
+
enum: VALID_PROFILE_TYPES,
|
|
29
|
+
desc: "ParentProfile | CoachProfile (WrestlerProfile not supported)"
|
|
30
|
+
method_option :for_update, type: :boolean, default: false,
|
|
31
|
+
desc: "Include setup_intent + billing_partner_key for card updates"
|
|
32
|
+
def show(profile_id)
|
|
33
|
+
params = {}
|
|
34
|
+
params["for_update"] = true if options[:for_update]
|
|
35
|
+
|
|
36
|
+
billing_profile = client.get(
|
|
37
|
+
"/api/v1/billing_profiles/#{options[:profile_type]}/#{profile_id}",
|
|
38
|
+
params
|
|
39
|
+
)
|
|
40
|
+
render(billing_profile,
|
|
41
|
+
summary: "Billing profile #{billing_profile["id"]} for " \
|
|
42
|
+
"#{options[:profile_type]} #{profile_id}.",
|
|
43
|
+
breadcrumbs: [
|
|
44
|
+
{ "cmd" => "wiq charges list --billing-profile #{billing_profile["id"]}",
|
|
45
|
+
"description" => "Payment history for this family" },
|
|
46
|
+
{ "cmd" => "wiq charges list --billing-profile #{billing_profile["id"]} --status failed --since <date>",
|
|
47
|
+
"description" => "Recent failed charges" }
|
|
48
|
+
])
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|