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
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Wiq
|
|
4
|
+
module Commands
|
|
5
|
+
class Workflows < Base
|
|
6
|
+
desc "list", "List all documented workflows"
|
|
7
|
+
long_desc <<~DESC
|
|
8
|
+
Workflows are named recipes that map a human question to a CLI
|
|
9
|
+
command sequence. Use them to bootstrap agent flows for common
|
|
10
|
+
club-admin tasks.
|
|
11
|
+
|
|
12
|
+
Each workflow carries:
|
|
13
|
+
name kebab-case slug (the lookup key)
|
|
14
|
+
category attendance | leads | roster | finance |
|
|
15
|
+
memberships | fundraising | store
|
|
16
|
+
question natural-language framing — match against this
|
|
17
|
+
parameters structured list of {name, type, required, ...}
|
|
18
|
+
recipe ordered command strings with <placeholder> and
|
|
19
|
+
[optional] brackets
|
|
20
|
+
admin_only true means non-admin coach PATs will 403
|
|
21
|
+
notes when present, agent-facing guidance
|
|
22
|
+
|
|
23
|
+
Placeholder syntax in recipes:
|
|
24
|
+
<name> required substitution (must match a parameter)
|
|
25
|
+
[--flag <name>] optional bracket — drop the whole thing when
|
|
26
|
+
the parameter isn't provided
|
|
27
|
+
|
|
28
|
+
Run `wiq workflows show <name>` for a single workflow's full detail.
|
|
29
|
+
DESC
|
|
30
|
+
def list
|
|
31
|
+
rows = Wiq::Workflows::ALL.values.map { |w| summary_row(w) }
|
|
32
|
+
render_index(
|
|
33
|
+
rows,
|
|
34
|
+
summary: "#{rows.size} documented workflows across #{Wiq::Workflows::CATEGORIES.size} categories.",
|
|
35
|
+
breadcrumbs: [
|
|
36
|
+
{ "cmd" => "wiq workflows show <name>", "description" => "Detail for one workflow" },
|
|
37
|
+
{ "cmd" => "wiq commands", "description" => "Full command tree (for ad-hoc composition)" }
|
|
38
|
+
]
|
|
39
|
+
)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
desc "show NAME", "Show a single workflow's full detail"
|
|
43
|
+
long_desc <<~DESC
|
|
44
|
+
Returns the complete workflow definition: question, parameters
|
|
45
|
+
(with types and required/default), the recipe (ordered command
|
|
46
|
+
list), admin_only flag, and notes.
|
|
47
|
+
|
|
48
|
+
Use this after `wiq workflows list` narrows you to a candidate.
|
|
49
|
+
DESC
|
|
50
|
+
def show(name)
|
|
51
|
+
workflow = Wiq::Workflows::ALL[name]
|
|
52
|
+
unless workflow
|
|
53
|
+
available = Wiq::Workflows::ALL.keys.sort.join(", ")
|
|
54
|
+
raise Wiq::Error.new(
|
|
55
|
+
"Unknown workflow #{name.inspect}.",
|
|
56
|
+
code: "workflow_not_found",
|
|
57
|
+
hint: "Available: #{available}"
|
|
58
|
+
)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
render(workflow,
|
|
62
|
+
summary: "#{workflow[:name]} — #{workflow[:question]}",
|
|
63
|
+
breadcrumbs: workflow[:recipe].map do |cmd|
|
|
64
|
+
{ "cmd" => cmd, "description" => "Run this step" }
|
|
65
|
+
end)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
default_task :list
|
|
69
|
+
|
|
70
|
+
no_commands do
|
|
71
|
+
def summary_row(workflow)
|
|
72
|
+
{
|
|
73
|
+
"name" => workflow[:name],
|
|
74
|
+
"category" => workflow[:category],
|
|
75
|
+
"question" => workflow[:question],
|
|
76
|
+
"admin_only" => workflow[:admin_only] == true,
|
|
77
|
+
"step_count" => workflow[:recipe].size,
|
|
78
|
+
"parameter_count" => workflow[:parameters].size
|
|
79
|
+
}
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Wiq
|
|
4
|
+
module Commands
|
|
5
|
+
class Wrestlers < Base
|
|
6
|
+
# Match WrestlerProfile constants in app/models/wrestler_profile.rb.
|
|
7
|
+
PROFILE_TYPES = %w[teammate alumnus guest all].freeze
|
|
8
|
+
DEFAULT_PER_PAGE = 20
|
|
9
|
+
|
|
10
|
+
desc "list", "Search wrestlers — narrow ID-discovery surface"
|
|
11
|
+
long_desc <<~DESC
|
|
12
|
+
Narrow listing + filtering, primarily for ID discovery before
|
|
13
|
+
running another command (e.g. `wiq check_ins wrestler <id>`).
|
|
14
|
+
Default page size is 20; there's no --all flag. For exhaustive
|
|
15
|
+
exports use `wiq reports run RosterReport [--append-properties …]`
|
|
16
|
+
or `wiq reports run FullExportWrestlerReport`.
|
|
17
|
+
|
|
18
|
+
Filters translate to Ransack params server-side:
|
|
19
|
+
--query free-text name search (legacy `?query=`)
|
|
20
|
+
--first-name q[first_name_cont]
|
|
21
|
+
--last-name q[last_name_cont]
|
|
22
|
+
--roster <id> q[rosters_id_eq] — filter to one roster
|
|
23
|
+
--weight-class q[weight_class_numeric_eq] — exact numeric
|
|
24
|
+
--academic-class q[academic_class_eq] (senior, junior, …)
|
|
25
|
+
--age q[age_eq]
|
|
26
|
+
--profile-type teammate (default) | alumnus | guest | all
|
|
27
|
+
|
|
28
|
+
Multi-roster intersection ("kids on both A AND B") is supported
|
|
29
|
+
by the API but not yet exposed in the CLI — use the web UI for
|
|
30
|
+
now or compose two `--roster <id>` calls and intersect
|
|
31
|
+
client-side.
|
|
32
|
+
|
|
33
|
+
--expand opts into a wider payload:
|
|
34
|
+
rosters Adds full roster details per wrestler
|
|
35
|
+
registration_answers Adds intake-form answers per wrestler
|
|
36
|
+
|
|
37
|
+
Base payload is already wider than most index endpoints (parents,
|
|
38
|
+
coach_guardians, profile_photos, basic roster refs all render by
|
|
39
|
+
default). For agents on a tight context window, prefer
|
|
40
|
+
`wiq wrestlers show <id>` once you've narrowed to a candidate.
|
|
41
|
+
DESC
|
|
42
|
+
method_option :query, type: :string, desc: "Free-text name search"
|
|
43
|
+
method_option :first_name, type: :string, desc: "First name (contains)"
|
|
44
|
+
method_option :last_name, type: :string, desc: "Last name (contains)"
|
|
45
|
+
method_option :roster, type: :numeric, desc: "Filter to a single roster id"
|
|
46
|
+
method_option :weight_class, type: :string,
|
|
47
|
+
desc: "Weight class (exact numeric, e.g. 132)"
|
|
48
|
+
method_option :academic_class, type: :string,
|
|
49
|
+
desc: "senior, junior, sophomore, freshman, …"
|
|
50
|
+
method_option :age, type: :numeric, desc: "Age in years (exact)"
|
|
51
|
+
method_option :profile_type, type: :string, enum: PROFILE_TYPES, default: "teammate",
|
|
52
|
+
desc: "Default 'teammate' matches the WIQ web UI default; " \
|
|
53
|
+
"'all' removes the filter entirely"
|
|
54
|
+
method_option :expand, type: :string,
|
|
55
|
+
desc: "CSV: rosters, registration_answers"
|
|
56
|
+
method_option :per_page, type: :numeric, default: DEFAULT_PER_PAGE,
|
|
57
|
+
desc: "Page size (default 20; narrow surface)"
|
|
58
|
+
def list
|
|
59
|
+
params = build_list_params
|
|
60
|
+
|
|
61
|
+
records, total = fetch_index("/api/v1/wrestlers", params, key: "wrestlers")
|
|
62
|
+
render_index(
|
|
63
|
+
records, total: total,
|
|
64
|
+
summary: "Listed #{records.size} wrestlers#{summary_filters_suffix}.",
|
|
65
|
+
breadcrumbs: [
|
|
66
|
+
{ "cmd" => "wiq wrestlers show <id>", "description" => "Drill into one wrestler" },
|
|
67
|
+
{ "cmd" => "wiq check_ins wrestler <id>", "description" => "See a wrestler's check-in history" },
|
|
68
|
+
{ "cmd" => "wiq reports run RosterReport --roster 0 --append-properties <q_ids>",
|
|
69
|
+
"description" => "For exhaustive exports, use a report instead" }
|
|
70
|
+
]
|
|
71
|
+
)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
desc "show ID", "Fetch a single wrestler profile"
|
|
75
|
+
long_desc <<~DESC
|
|
76
|
+
Single-wrestler drill-down. Returns the full base payload
|
|
77
|
+
(basic profile, parents, coach_guardians, profile_photos,
|
|
78
|
+
rosters refs).
|
|
79
|
+
|
|
80
|
+
Pass --expand for additional sections:
|
|
81
|
+
rosters Full roster details with tags
|
|
82
|
+
registration_answers Intake-form answers
|
|
83
|
+
DESC
|
|
84
|
+
method_option :expand, type: :string,
|
|
85
|
+
desc: "CSV: rosters, registration_answers"
|
|
86
|
+
def show(id)
|
|
87
|
+
params = {}
|
|
88
|
+
params["expand_rosters"] = true if expand_includes?("rosters")
|
|
89
|
+
params["expand_registration_answers"] = true if expand_includes?("registration_answers")
|
|
90
|
+
wrestler = client.get("/api/v1/wrestlers/#{id}", params)
|
|
91
|
+
render(wrestler,
|
|
92
|
+
summary: "Wrestler #{wrestler["id"]} — #{wrestler["full_name"] || wrestler["display_name"]}",
|
|
93
|
+
breadcrumbs: [
|
|
94
|
+
{ "cmd" => "wiq check_ins wrestler #{wrestler["id"]}",
|
|
95
|
+
"description" => "Check-in history for this wrestler" },
|
|
96
|
+
{ "cmd" => "wiq registrations answers --profile #{wrestler["id"]} --profile-type WrestlerProfile",
|
|
97
|
+
"description" => "Registration answers (subject to visibility gating)" }
|
|
98
|
+
])
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
no_commands do
|
|
102
|
+
# Extracted so the spec can verify the option → query-param mapping
|
|
103
|
+
# without round-tripping a real Faraday connection.
|
|
104
|
+
def build_list_params
|
|
105
|
+
params = { "per_page" => options[:per_page] || DEFAULT_PER_PAGE }
|
|
106
|
+
params["query"] = options[:query] if options[:query]
|
|
107
|
+
params["q[first_name_cont]"] = options[:first_name] if options[:first_name]
|
|
108
|
+
params["q[last_name_cont]"] = options[:last_name] if options[:last_name]
|
|
109
|
+
params["q[rosters_id_eq]"] = options[:roster] if options[:roster]
|
|
110
|
+
params["q[weight_class_numeric_eq]"] = options[:weight_class] if options[:weight_class]
|
|
111
|
+
params["q[academic_class_eq]"] = options[:academic_class] if options[:academic_class]
|
|
112
|
+
params["q[age_eq]"] = options[:age] if options[:age]
|
|
113
|
+
params["q[profile_type_eq]"] = options[:profile_type] if profile_type_filter?
|
|
114
|
+
params["expand_rosters"] = true if expand_includes?("rosters")
|
|
115
|
+
params["expand_registration_answers"] = true if expand_includes?("registration_answers")
|
|
116
|
+
params
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def profile_type_filter?
|
|
120
|
+
options[:profile_type] && options[:profile_type] != "all"
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def expand_includes?(part)
|
|
124
|
+
return false unless options[:expand]
|
|
125
|
+
|
|
126
|
+
options[:expand].split(",").map(&:strip).include?(part)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def summary_filters_suffix
|
|
130
|
+
bits = []
|
|
131
|
+
bits << "matching #{options[:query].inspect}" if options[:query]
|
|
132
|
+
bits << "in roster #{options[:roster]}" if options[:roster]
|
|
133
|
+
bits << "weight class #{options[:weight_class]}" if options[:weight_class]
|
|
134
|
+
bits << "profile_type=#{options[:profile_type]}" if profile_type_filter? && options[:profile_type] != "teammate"
|
|
135
|
+
bits.empty? ? "" : " (#{bits.join(", ")})"
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
data/lib/wiq/config.rb
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Wiq
|
|
6
|
+
# Resolves host + alias + token from the documented precedence chain.
|
|
7
|
+
#
|
|
8
|
+
# Host:
|
|
9
|
+
# --host > WIQ_HOST > .wiq/config.json:host > sole stored host
|
|
10
|
+
# > PRODUCTION_HOST
|
|
11
|
+
#
|
|
12
|
+
# Alias (only meaningful once host is known):
|
|
13
|
+
# --as > WIQ_ALIAS > .wiq/config.json:alias > sole alias for host > "default"
|
|
14
|
+
#
|
|
15
|
+
# Token:
|
|
16
|
+
# WIQ_TOKEN (direct override) > credentials store (host, alias)
|
|
17
|
+
class Config
|
|
18
|
+
PRODUCTION_HOST = "https://www.wrestlingiq.com"
|
|
19
|
+
|
|
20
|
+
attr_reader :host, :alias_name, :token, :sources
|
|
21
|
+
|
|
22
|
+
def self.load(options = {})
|
|
23
|
+
new(options).tap(&:resolve!)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def initialize(options = {})
|
|
27
|
+
@options = options || {}
|
|
28
|
+
@sources = {}
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def resolve!
|
|
32
|
+
@host = resolve_host
|
|
33
|
+
@alias_name = resolve_alias if @host
|
|
34
|
+
@token = resolve_token
|
|
35
|
+
self
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def require_host!
|
|
39
|
+
raise HostUnsetError unless @host
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def require_token!
|
|
43
|
+
require_host!
|
|
44
|
+
return if @token
|
|
45
|
+
|
|
46
|
+
# No env override, no usable alias-keyed entry — decide which error to raise.
|
|
47
|
+
aliases = Credentials.aliases_for(@host)
|
|
48
|
+
if aliases.empty?
|
|
49
|
+
raise NotAuthenticatedError
|
|
50
|
+
elsif @alias_name && !Credentials.for_host(@host, @alias_name)
|
|
51
|
+
raise AliasNotFoundError.new(@host, @alias_name, aliases)
|
|
52
|
+
elsif @alias_name.nil?
|
|
53
|
+
raise AmbiguousAliasError.new(@host, aliases)
|
|
54
|
+
else
|
|
55
|
+
raise NotAuthenticatedError
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def trace
|
|
60
|
+
{
|
|
61
|
+
host: @host,
|
|
62
|
+
host_source: @sources[:host],
|
|
63
|
+
alias: @alias_name,
|
|
64
|
+
alias_source: @sources[:alias],
|
|
65
|
+
token_present: !@token.nil?,
|
|
66
|
+
token_source: @sources[:token],
|
|
67
|
+
credentials_path: Credentials.path,
|
|
68
|
+
repo_config_path: repo_config_path
|
|
69
|
+
}
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
def resolve_host
|
|
75
|
+
if (val = @options[:host])
|
|
76
|
+
@sources[:host] = "--host flag"
|
|
77
|
+
return val
|
|
78
|
+
end
|
|
79
|
+
if (val = ENV["WIQ_HOST"])
|
|
80
|
+
@sources[:host] = "WIQ_HOST env"
|
|
81
|
+
return val
|
|
82
|
+
end
|
|
83
|
+
if (val = repo_config["host"])
|
|
84
|
+
@sources[:host] = "repo config (#{repo_config_path})"
|
|
85
|
+
return val
|
|
86
|
+
end
|
|
87
|
+
hosts = Credentials.hosts
|
|
88
|
+
if hosts.size == 1
|
|
89
|
+
@sources[:host] = "credentials store (sole host)"
|
|
90
|
+
return hosts.first
|
|
91
|
+
end
|
|
92
|
+
@sources[:host] = "production default"
|
|
93
|
+
PRODUCTION_HOST
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def resolve_alias
|
|
97
|
+
if (val = @options[:as])
|
|
98
|
+
@sources[:alias] = "--as flag"
|
|
99
|
+
return val
|
|
100
|
+
end
|
|
101
|
+
if (val = ENV["WIQ_ALIAS"])
|
|
102
|
+
@sources[:alias] = "WIQ_ALIAS env"
|
|
103
|
+
return val
|
|
104
|
+
end
|
|
105
|
+
if (val = repo_config["alias"])
|
|
106
|
+
@sources[:alias] = "repo config (#{repo_config_path})"
|
|
107
|
+
return val
|
|
108
|
+
end
|
|
109
|
+
aliases = Credentials.aliases_for(@host)
|
|
110
|
+
if aliases.size == 1
|
|
111
|
+
@sources[:alias] = "credentials store (sole alias)"
|
|
112
|
+
return aliases.first
|
|
113
|
+
end
|
|
114
|
+
if aliases.include?(Credentials::DEFAULT_ALIAS)
|
|
115
|
+
@sources[:alias] = "credentials store (default)"
|
|
116
|
+
return Credentials::DEFAULT_ALIAS
|
|
117
|
+
end
|
|
118
|
+
@sources[:alias] = aliases.empty? ? "no credentials stored" : "ambiguous (#{aliases.join(", ")})"
|
|
119
|
+
nil
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def resolve_token
|
|
123
|
+
return nil unless @host
|
|
124
|
+
|
|
125
|
+
if (val = ENV["WIQ_TOKEN"])
|
|
126
|
+
@sources[:token] = "WIQ_TOKEN env"
|
|
127
|
+
return val
|
|
128
|
+
end
|
|
129
|
+
return nil unless @alias_name
|
|
130
|
+
|
|
131
|
+
entry = Credentials.for_host(@host, @alias_name)
|
|
132
|
+
return nil unless entry && entry["token"]
|
|
133
|
+
|
|
134
|
+
@sources[:token] = "credentials store (alias=#{@alias_name})"
|
|
135
|
+
entry["token"]
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def repo_config
|
|
139
|
+
@repo_config ||= load_repo_config
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def repo_config_path
|
|
143
|
+
@repo_config_path
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def load_repo_config
|
|
147
|
+
dir = Dir.pwd
|
|
148
|
+
while dir != "/" && !dir.empty?
|
|
149
|
+
candidate = File.join(dir, ".wiq", "config.json")
|
|
150
|
+
if File.exist?(candidate)
|
|
151
|
+
@repo_config_path = candidate
|
|
152
|
+
begin
|
|
153
|
+
return JSON.parse(File.read(candidate))
|
|
154
|
+
rescue JSON::ParserError
|
|
155
|
+
return {}
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
parent = File.dirname(dir)
|
|
159
|
+
break if parent == dir
|
|
160
|
+
dir = parent
|
|
161
|
+
end
|
|
162
|
+
{}
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "time"
|
|
6
|
+
|
|
7
|
+
module Wiq
|
|
8
|
+
# File-backed credential store. ~/.config/wiq/credentials.json, mode 0600.
|
|
9
|
+
# Shape:
|
|
10
|
+
# {
|
|
11
|
+
# "<host>": {
|
|
12
|
+
# "<alias>": { "token": "...", "token_prefix": "...", "name": "...",
|
|
13
|
+
# "profile": {...}, "stored_at": "..." },
|
|
14
|
+
# "<alias>": { ... }
|
|
15
|
+
# }
|
|
16
|
+
# }
|
|
17
|
+
module Credentials
|
|
18
|
+
DEFAULT_PATH = File.join(Dir.home, ".config", "wiq", "credentials.json")
|
|
19
|
+
DEFAULT_ALIAS = "default"
|
|
20
|
+
|
|
21
|
+
module_function
|
|
22
|
+
|
|
23
|
+
def path
|
|
24
|
+
ENV["WIQ_CREDENTIALS_PATH"] || DEFAULT_PATH
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def load_all
|
|
28
|
+
return {} unless File.exist?(path)
|
|
29
|
+
|
|
30
|
+
JSON.parse(File.read(path))
|
|
31
|
+
rescue JSON::ParserError
|
|
32
|
+
{}
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def for_host(host, alias_name)
|
|
36
|
+
load_all.dig(host, alias_name)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def aliases_for(host)
|
|
40
|
+
Array((load_all[host] || {}).keys)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def hosts
|
|
44
|
+
load_all.keys
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Flat list across hosts × aliases. Useful for `wiq auth list`. Excludes the
|
|
48
|
+
# raw token; callers should never log tokens.
|
|
49
|
+
def all_entries
|
|
50
|
+
load_all.flat_map do |host, by_alias|
|
|
51
|
+
by_alias.map do |alias_name, entry|
|
|
52
|
+
{
|
|
53
|
+
"host" => host,
|
|
54
|
+
"alias" => alias_name,
|
|
55
|
+
"token_prefix" => entry["token_prefix"],
|
|
56
|
+
"name" => entry["name"],
|
|
57
|
+
"profile" => entry["profile"],
|
|
58
|
+
"stored_at" => entry["stored_at"]
|
|
59
|
+
}
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def store(host:, alias_name:, token:, token_prefix: nil, name: nil, profile: nil)
|
|
65
|
+
data = load_all
|
|
66
|
+
data[host] ||= {}
|
|
67
|
+
data[host][alias_name] = {
|
|
68
|
+
"token" => token,
|
|
69
|
+
"token_prefix" => token_prefix,
|
|
70
|
+
"name" => name,
|
|
71
|
+
"profile" => profile,
|
|
72
|
+
"stored_at" => Time.now.utc.iso8601
|
|
73
|
+
}.compact
|
|
74
|
+
write(data)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def remove(host, alias_name)
|
|
78
|
+
data = load_all
|
|
79
|
+
bucket = data[host]
|
|
80
|
+
return false unless bucket
|
|
81
|
+
return false unless bucket.delete(alias_name)
|
|
82
|
+
|
|
83
|
+
data.delete(host) if bucket.empty?
|
|
84
|
+
write(data)
|
|
85
|
+
true
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def write(data)
|
|
89
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
90
|
+
File.open(path, File::WRONLY | File::CREAT | File::TRUNC, 0o600) do |f|
|
|
91
|
+
f.write(JSON.pretty_generate(data))
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
data/lib/wiq/errors.rb
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Wiq
|
|
4
|
+
class Error < StandardError
|
|
5
|
+
attr_reader :code, :hint, :exit_code, :details
|
|
6
|
+
|
|
7
|
+
def initialize(message, code: "error", hint: nil, exit_code: 1, details: nil)
|
|
8
|
+
super(message)
|
|
9
|
+
@code = code
|
|
10
|
+
@hint = hint
|
|
11
|
+
@exit_code = exit_code
|
|
12
|
+
@details = details
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
class ConfigError < Error
|
|
17
|
+
def initialize(message, code: "config_error", **opts)
|
|
18
|
+
super(message, code: code, **opts)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
class HostUnsetError < ConfigError
|
|
23
|
+
def initialize
|
|
24
|
+
super(
|
|
25
|
+
"No WIQ host configured.",
|
|
26
|
+
code: "host_unset",
|
|
27
|
+
hint: "Run `wiq auth login` to configure a host and token, or pass --host."
|
|
28
|
+
)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
class NotAuthenticatedError < ConfigError
|
|
33
|
+
def initialize
|
|
34
|
+
super(
|
|
35
|
+
"No personal access token configured.",
|
|
36
|
+
code: "not_authenticated",
|
|
37
|
+
hint: "Run `wiq auth login` to store a PAT for this host."
|
|
38
|
+
)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
class AmbiguousAliasError < ConfigError
|
|
43
|
+
def initialize(host, aliases)
|
|
44
|
+
super(
|
|
45
|
+
"Multiple credential aliases stored for #{host}: #{aliases.join(", ")}.",
|
|
46
|
+
code: "ambiguous_alias",
|
|
47
|
+
hint: "Pass --as <alias>, set WIQ_ALIAS, or add `alias` to .wiq/config.json. " \
|
|
48
|
+
"Run `wiq auth list` to see what's stored."
|
|
49
|
+
)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
class AliasNotFoundError < ConfigError
|
|
54
|
+
def initialize(host, alias_name, available)
|
|
55
|
+
hint = if available.empty?
|
|
56
|
+
"No credentials stored for this host. Run `wiq auth login`."
|
|
57
|
+
else
|
|
58
|
+
"Available aliases for #{host}: #{available.join(", ")}."
|
|
59
|
+
end
|
|
60
|
+
super(
|
|
61
|
+
"No credential stored for #{host} under alias #{alias_name.inspect}.",
|
|
62
|
+
code: "alias_not_found",
|
|
63
|
+
hint: hint
|
|
64
|
+
)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
class AliasConflictError < ConfigError
|
|
69
|
+
def initialize(host, alias_name)
|
|
70
|
+
super(
|
|
71
|
+
"An entry already exists for #{host} under alias #{alias_name.inspect}.",
|
|
72
|
+
code: "alias_conflict",
|
|
73
|
+
hint: "Pass --as <other-name> to store under a different slot, or --force to overwrite."
|
|
74
|
+
)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Mirror of the HTTP error envelope from /api/v1.
|
|
79
|
+
class APIError < Error
|
|
80
|
+
attr_reader :status, :response_body, :request_id
|
|
81
|
+
|
|
82
|
+
def initialize(status:, body:, request_id: nil)
|
|
83
|
+
@status = status
|
|
84
|
+
@response_body = body
|
|
85
|
+
@request_id = request_id
|
|
86
|
+
|
|
87
|
+
code, message, hint = derive(status, body)
|
|
88
|
+
super(message, code: code, hint: hint, exit_code: 1, details: body)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
private
|
|
92
|
+
|
|
93
|
+
def derive(status, body)
|
|
94
|
+
msgs = extract_messages(body)
|
|
95
|
+
case status
|
|
96
|
+
when 401
|
|
97
|
+
["unauthorized", "Token rejected by server (401).",
|
|
98
|
+
"Run `wiq auth status` to inspect the active token; `wiq auth login` to replace it."]
|
|
99
|
+
when 403
|
|
100
|
+
["forbidden", "Server denied access (403): #{msgs}",
|
|
101
|
+
"PATs inherit the user's permissions. Confirm the minting user can see this resource in the web app."]
|
|
102
|
+
when 404
|
|
103
|
+
["not_found", "Resource not found (404).", nil]
|
|
104
|
+
when 422
|
|
105
|
+
["validation_failed", "Validation error (422): #{msgs}", nil]
|
|
106
|
+
when 429
|
|
107
|
+
["rate_limited", "Rate limited (429): #{msgs}",
|
|
108
|
+
"WIQ enforces 100 req/3s per IP. Back off and retry."]
|
|
109
|
+
else
|
|
110
|
+
["http_#{status}", "HTTP #{status}: #{msgs}", nil]
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# /api/v1 always returns { "errors": <array|hash> }. Flatten to a human string.
|
|
115
|
+
def extract_messages(body)
|
|
116
|
+
return body.to_s unless body.is_a?(Hash)
|
|
117
|
+
|
|
118
|
+
errs = body["errors"] || body[:errors]
|
|
119
|
+
return "no error body" if errs.nil?
|
|
120
|
+
|
|
121
|
+
case errs
|
|
122
|
+
when Array
|
|
123
|
+
errs.join("; ")
|
|
124
|
+
when Hash
|
|
125
|
+
errs.flat_map { |field, msgs| Array(msgs).map { |m| "#{field}: #{m}" } }.join("; ")
|
|
126
|
+
else
|
|
127
|
+
errs.to_s
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
class ReportFailedError < Error
|
|
133
|
+
def initialize(report)
|
|
134
|
+
super(
|
|
135
|
+
"Report ##{report["id"]} (#{report["type"]}) finished with status `failed`.",
|
|
136
|
+
code: "report_failed",
|
|
137
|
+
details: report["result"]
|
|
138
|
+
)
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
class ReportTimeoutError < Error
|
|
143
|
+
def initialize(report, timeout)
|
|
144
|
+
super(
|
|
145
|
+
"Report ##{report["id"]} did not finish within #{timeout}s (last status: #{report["status"]}).",
|
|
146
|
+
code: "report_timeout",
|
|
147
|
+
hint: "Pass --timeout to wait longer, or re-check later with `wiq reports show #{report["id"]}`.",
|
|
148
|
+
details: report
|
|
149
|
+
)
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
class SeasonNotFoundError < Error
|
|
154
|
+
def initialize(year)
|
|
155
|
+
super(
|
|
156
|
+
"No paid sessions overlap year #{year}.",
|
|
157
|
+
code: "season_not_found",
|
|
158
|
+
hint: "Run `wiq paid_sessions list` to see configured registration periods."
|
|
159
|
+
)
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
class SeasonUnsupportedError < Error
|
|
164
|
+
def initialize(type, matches)
|
|
165
|
+
super(
|
|
166
|
+
"Season #{matches.size > 1 ? "is ambiguous" : "cannot be applied automatically"} for report type #{type}.",
|
|
167
|
+
code: "season_unsupported_for_type",
|
|
168
|
+
hint: "Pass --paid-session <id> explicitly. Matching paid sessions: #{matches.map { |m| m["id"] }.join(", ")}"
|
|
169
|
+
)
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|