kodeco 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/README.md +90 -0
- data/exe/kodeco +6 -0
- data/lib/kodeco.rb +4 -0
- data/lib/periscope/cli.rb +134 -0
- data/lib/periscope/client.rb +46 -0
- data/lib/periscope/commands/flags.rb +36 -0
- data/lib/periscope/commands/kerching.rb +114 -0
- data/lib/periscope/commands/user.rb +20 -0
- data/lib/periscope/config.rb +30 -0
- data/lib/periscope/credential_store.rb +76 -0
- data/lib/periscope/errors.rb +36 -0
- data/lib/periscope/format.rb +32 -0
- data/lib/periscope/http.rb +88 -0
- data/lib/periscope/login.rb +80 -0
- data/lib/periscope/session.rb +78 -0
- data/lib/periscope/user_lookup.rb +18 -0
- data/lib/periscope/user_summary.rb +108 -0
- data/lib/periscope/version.rb +5 -0
- data/lib/periscope.rb +17 -0
- metadata +57 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 9a8113bf48c09c6754ee161ccd1568779c99d1d873f943b31cb4c4901e9bd474
|
|
4
|
+
data.tar.gz: f3ec99a38bbb68e90fc594d7702d1ddad0b3a916c3768d47e706b328f960ca07
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 4a2ca478260ec6f8475be66981d8ae253dd6da00b2c3f23b249039332da5b355a18dc1783f99cd259e78264fd0fe898aabd5ac07f1a4ee8eef5631c63c042f27
|
|
7
|
+
data.tar.gz: b36b0bfb1eb73c2db436102551a85736a7825ab18803641177a1b0d2a061ad784a75c38d3e9d7a406bfec916e972134f6b82a1bc60d44781c3bbbc49cdc6f315
|
data/README.md
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# periscope
|
|
2
|
+
|
|
3
|
+
The `kodeco` command-line tool for Kodeco's API v2, for developers and agents.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
Requires Ruby 3.3 or later and has no runtime dependencies.
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
gem install kodeco
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Commands
|
|
14
|
+
|
|
15
|
+
```sh
|
|
16
|
+
kodeco login [--scopes "a b"] [--no-browser]
|
|
17
|
+
kodeco logout
|
|
18
|
+
kodeco whoami [--app guardpost|kerching|carolus]
|
|
19
|
+
kodeco user <email|rwuuid>
|
|
20
|
+
kodeco orders <email|rwuuid> [--page N]
|
|
21
|
+
kodeco order <id>
|
|
22
|
+
kodeco subscription <email|rwuuid>
|
|
23
|
+
kodeco problems [--kind overdue|stuck_in_preauth|invoice_overdue|expiry_missed] [--page N]
|
|
24
|
+
kodeco flags --app guardpost|kerching|carolus
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
`user` shows guardpost's customer summary, with kerching's and carolus's
|
|
28
|
+
sections. A section that couldn't be fetched is marked `!!` and the command
|
|
29
|
+
exits 5 after printing the rest. `orders`, `order`, `subscription` and
|
|
30
|
+
`problems` read kerching and need the `orders:read` scope; `flags` needs
|
|
31
|
+
`flags:read`. With `--json`, commands print the response's `data`, plus `meta`
|
|
32
|
+
for lists.
|
|
33
|
+
|
|
34
|
+
`login` uses guardpost's device-code flow: it prints a code and a link to
|
|
35
|
+
approve it, and opens the link in a browser when run in a terminal. The
|
|
36
|
+
default scopes are `users:read support:read orders:read learning:read
|
|
37
|
+
flags:read`. If you lack a grant for one of them, approval fails; run
|
|
38
|
+
`kodeco login --scopes "..."` and leave it out.
|
|
39
|
+
|
|
40
|
+
`logout` deletes the local credentials. guardpost has no revoke endpoint, so
|
|
41
|
+
the server session lasts up to 7 days unless an admin revokes it.
|
|
42
|
+
|
|
43
|
+
Every command takes:
|
|
44
|
+
|
|
45
|
+
* `--json`: stdout is exactly one JSON document, and errors go to stderr as
|
|
46
|
+
`{"error": {"code": ..., "message": ...}}`.
|
|
47
|
+
* `--debug`: logs each request's method, URL and status to stderr. Tokens are
|
|
48
|
+
never printed or logged.
|
|
49
|
+
|
|
50
|
+
## Exit codes
|
|
51
|
+
|
|
52
|
+
| Code | Meaning |
|
|
53
|
+
| ---- | ------- |
|
|
54
|
+
| 0 | OK |
|
|
55
|
+
| 1 | Error |
|
|
56
|
+
| 2 | Usage error |
|
|
57
|
+
| 3 | Not logged in, or authentication failed |
|
|
58
|
+
| 4 | Not found |
|
|
59
|
+
| 5 | An upstream app is unavailable |
|
|
60
|
+
|
|
61
|
+
## Configuration
|
|
62
|
+
|
|
63
|
+
Production is the default. To point an app elsewhere, set
|
|
64
|
+
`KODECO_GUARDPOST_URL`, `KODECO_KERCHING_URL` or `KODECO_CAROLUS_URL`.
|
|
65
|
+
|
|
66
|
+
Credentials live in `${XDG_CONFIG_HOME:-~/.config}/kodeco/credentials.json`
|
|
67
|
+
(directory 0700, file 0600), with one entry per guardpost URL, so logging in
|
|
68
|
+
to another guardpost never replaces your production login. The CLI refuses to
|
|
69
|
+
read the file if its permissions are wider than 0600.
|
|
70
|
+
|
|
71
|
+
Access tokens are refreshed automatically when they're within a minute of
|
|
72
|
+
expiring. Refreshes are serialised with a lock file, so concurrent `kodeco`
|
|
73
|
+
processes share one refresh rather than revoking each other's session.
|
|
74
|
+
|
|
75
|
+
## Releasing
|
|
76
|
+
|
|
77
|
+
The gem is published to rubygems.org as `kodeco`, with Trusted Publishing, so
|
|
78
|
+
no API key is stored anywhere.
|
|
79
|
+
|
|
80
|
+
1. Bump `Periscope::VERSION` in `lib/periscope/version.rb`.
|
|
81
|
+
2. Merge to `trunk`.
|
|
82
|
+
3. Run the Release workflow by hand on `trunk`. It runs the tests, tags
|
|
83
|
+
`v<version>` and publishes the gem.
|
|
84
|
+
|
|
85
|
+
## Development
|
|
86
|
+
|
|
87
|
+
```sh
|
|
88
|
+
bundle install
|
|
89
|
+
bundle exec rake # tests and RuboCop
|
|
90
|
+
```
|
data/exe/kodeco
ADDED
data/lib/kodeco.rb
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "optparse"
|
|
5
|
+
require "rbconfig"
|
|
6
|
+
|
|
7
|
+
module Periscope
|
|
8
|
+
class CLI
|
|
9
|
+
include Commands::User
|
|
10
|
+
include Commands::Kerching
|
|
11
|
+
include Commands::Flags
|
|
12
|
+
|
|
13
|
+
USAGE = <<~TEXT
|
|
14
|
+
Usage: kodeco <command> [options]
|
|
15
|
+
|
|
16
|
+
Commands:
|
|
17
|
+
login [--scopes "a b"] [--no-browser] Log in with guardpost's device-code flow
|
|
18
|
+
logout Delete the local credentials
|
|
19
|
+
whoami [--app guardpost|kerching|carolus]
|
|
20
|
+
Show who an app takes you to be
|
|
21
|
+
user <email|rwuuid> Show guardpost's customer summary
|
|
22
|
+
orders <email|rwuuid> [--page N] List a user's kerching orders
|
|
23
|
+
order <id> Show one kerching order
|
|
24
|
+
subscription <email|rwuuid> Show a user's kerching subscription
|
|
25
|
+
problems [--kind KIND] [--page N] List kerching orders that need attention
|
|
26
|
+
flags --app guardpost|kerching|carolus Show an app's feature flags
|
|
27
|
+
|
|
28
|
+
Every command takes --json (one JSON document on stdout, errors as JSON
|
|
29
|
+
on stderr) and --debug (logs each request's method, URL and status).
|
|
30
|
+
TEXT
|
|
31
|
+
|
|
32
|
+
COMMANDS = %w[login logout whoami user orders order subscription problems flags].freeze
|
|
33
|
+
LOGOUT_NOTE = "guardpost has no revoke endpoint, so the server session lasts up to 7 days " \
|
|
34
|
+
"unless an admin revokes it."
|
|
35
|
+
|
|
36
|
+
def initialize(argv, env: ENV, stdout: $stdout, stderr: $stderr, login_options: {})
|
|
37
|
+
@argv = argv.dup
|
|
38
|
+
@env = env
|
|
39
|
+
@stdout = stdout
|
|
40
|
+
@stderr = stderr
|
|
41
|
+
@login_options = login_options
|
|
42
|
+
@json = @argv.include?("--json")
|
|
43
|
+
@debug = @argv.include?("--debug")
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def run
|
|
47
|
+
command = @argv.shift
|
|
48
|
+
return usage(command.nil? ? 2 : 0) if command.nil? || %w[help --help -h].include?(command)
|
|
49
|
+
raise UsageError, "Unknown command #{command}. Run kodeco --help." unless COMMANDS.include?(command)
|
|
50
|
+
|
|
51
|
+
send(:"cmd_#{command}")
|
|
52
|
+
0
|
|
53
|
+
rescue OptionParser::ParseError => e
|
|
54
|
+
fail_with(UsageError.new(e.message))
|
|
55
|
+
rescue Error => e
|
|
56
|
+
fail_with(e)
|
|
57
|
+
rescue Interrupt
|
|
58
|
+
fail_with(Error.new("Interrupted"))
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
def cmd_login
|
|
64
|
+
opts = parse_options do |parser, o|
|
|
65
|
+
parser.on("--scopes SCOPES") { |v| o[:scopes] = v }
|
|
66
|
+
parser.on("--[no-]browser") { |v| o[:browser] = v }
|
|
67
|
+
end
|
|
68
|
+
opener = method(:open_browser) if @stdout.tty? && opts.fetch(:browser, true)
|
|
69
|
+
login = Login.new(config: config, store: store, http: http, out: @json ? @stderr : @stdout,
|
|
70
|
+
opener: opener, **@login_options)
|
|
71
|
+
credentials = login.call(opts[:scopes] || Login::DEFAULT_SCOPES)
|
|
72
|
+
result = { "logged_in" => true, "scope" => credentials["scope"], "apps" => credentials["access_tokens"].keys }
|
|
73
|
+
emit(result) { "Logged in to #{config.url('guardpost')} with scope: #{credentials['scope']}" }
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def cmd_logout
|
|
77
|
+
parse_options
|
|
78
|
+
deleted = store.with_lock { store.delete }
|
|
79
|
+
emit({ "logged_out" => true, "had_credentials" => !deleted.nil?, "note" => LOGOUT_NOTE }) do
|
|
80
|
+
deleted ? "Logged out locally. #{LOGOUT_NOTE}" : "Not logged in; there was nothing to delete."
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def cmd_whoami
|
|
85
|
+
opts = parse_options { |parser, o| parser.on("--app APP", Config::APPS) { |v| o[:app] = v } }
|
|
86
|
+
data = client.get(opts[:app] || "guardpost", "/api/v2/whoami")["data"] || {}
|
|
87
|
+
emit(data) { "type: #{data['type']}\nid: #{data['id']}\nscopes: #{Array(data['scopes']).join(' ')}" }
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Parses the remaining arguments and returns the options; commands that
|
|
91
|
+
# take positional arguments pass positional: n.
|
|
92
|
+
def parse_options(positional: 0)
|
|
93
|
+
opts = {}
|
|
94
|
+
parser = OptionParser.new
|
|
95
|
+
parser.on("--json")
|
|
96
|
+
parser.on("--debug")
|
|
97
|
+
yield parser, opts if block_given?
|
|
98
|
+
rest = parser.parse(@argv)
|
|
99
|
+
raise UsageError, "Expected #{positional} argument(s), got #{rest.size}" unless rest.size == positional
|
|
100
|
+
|
|
101
|
+
opts[:args] = rest
|
|
102
|
+
opts
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def emit(data)
|
|
106
|
+
@stdout.puts(@json ? JSON.generate(data) : yield)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def usage(status)
|
|
110
|
+
(status.zero? ? @stdout : @stderr).puts(USAGE)
|
|
111
|
+
status
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def fail_with(error)
|
|
115
|
+
if @json
|
|
116
|
+
@stderr.puts(JSON.generate(error: { code: error.code, message: error.message }))
|
|
117
|
+
else
|
|
118
|
+
@stderr.puts("kodeco: #{error.message}")
|
|
119
|
+
end
|
|
120
|
+
error.exit_code
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def open_browser(url)
|
|
124
|
+
command = RbConfig::CONFIG["host_os"].include?("darwin") ? "open" : "xdg-open"
|
|
125
|
+
system(command, url, out: File::NULL, err: File::NULL)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def config = @config ||= Config.new(@env)
|
|
129
|
+
def http = @http ||= Http.new(debug: @debug, log: @stderr)
|
|
130
|
+
def store = @store ||= CredentialStore.new(config.config_dir, config.url("guardpost"))
|
|
131
|
+
def session = @session ||= Session.new(config: config, store: store, http: http)
|
|
132
|
+
def client = @client ||= Client.new(config: config, session: session, http: http)
|
|
133
|
+
end
|
|
134
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Periscope
|
|
4
|
+
# Authenticated API v2 calls. Each app gets only its own token.
|
|
5
|
+
class Client
|
|
6
|
+
def initialize(config:, session:, http:)
|
|
7
|
+
@config = config
|
|
8
|
+
@session = session
|
|
9
|
+
@http = http
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# scope names the scope the endpoint needs, so a 403 can say which is missing.
|
|
13
|
+
def get(app, path, scope: nil)
|
|
14
|
+
handle(app, scope, @http.get(url(app, path), auth(app)))
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def post_json(app, path, payload, scope: nil)
|
|
18
|
+
handle(app, scope, @http.post_json(url(app, path), payload, auth(app)))
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def url(app, path) = "#{@config.url(app)}#{path}"
|
|
24
|
+
|
|
25
|
+
def auth(app) = { "Authorization" => "Bearer #{@session.token_for(app)}" }
|
|
26
|
+
|
|
27
|
+
def handle(app, scope, res)
|
|
28
|
+
return res.body if res.ok?
|
|
29
|
+
|
|
30
|
+
message = res.error_message || "HTTP #{res.status}"
|
|
31
|
+
case res.status
|
|
32
|
+
when 401 then raise AuthError.new("#{app} rejected the token: #{message}. Run kodeco login.", code: "auth_failed")
|
|
33
|
+
when 403 then raise AuthError.new(forbidden(app, scope, message), code: "forbidden")
|
|
34
|
+
when 404 then raise NotFoundError, message
|
|
35
|
+
when 500.. then raise UnavailableError, "#{app} unavailable (#{message})"
|
|
36
|
+
end
|
|
37
|
+
raise Error.new(message, code: res.error_code || "error")
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def forbidden(app, scope, message)
|
|
41
|
+
return "#{app} refused: #{message}" unless scope
|
|
42
|
+
|
|
43
|
+
"#{app} refused: #{message}. This needs the #{scope} scope; run kodeco login --scopes with it included."
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Periscope
|
|
4
|
+
module Commands
|
|
5
|
+
# kodeco flags --app APP
|
|
6
|
+
module Flags
|
|
7
|
+
private
|
|
8
|
+
|
|
9
|
+
def cmd_flags
|
|
10
|
+
opts = parse_options { |parser, o| parser.on("--app APP", Config::APPS) { |v| o[:app] = v } }
|
|
11
|
+
raise UsageError, "flags needs --app #{Config::APPS.join('|')}" unless opts[:app]
|
|
12
|
+
|
|
13
|
+
flags = client.get(opts[:app], "/api/v2/flags", scope: "flags:read")["data"] || {}
|
|
14
|
+
emit(flags) { flags.map { |source, values| flag_source(source, values) }.join("\n\n") }
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def flag_source(source, values)
|
|
18
|
+
lines = values.map do |name, value|
|
|
19
|
+
source == "flipper" ? [name, flipper_state(value)] : [name, Format.value(value)]
|
|
20
|
+
end
|
|
21
|
+
"#{source}\n#{Format.table(%w[NAME VALUE], lines).gsub(/^/, ' ')}"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def flipper_state(feature)
|
|
25
|
+
return feature["state"] unless feature["state"] == "conditional"
|
|
26
|
+
|
|
27
|
+
gates = []
|
|
28
|
+
gates << "groups: #{feature['groups'].join(', ')}" if feature["groups"]&.any?
|
|
29
|
+
gates << "#{feature['percentage_of_actors']}% of actors" if feature["percentage_of_actors"].to_f.positive?
|
|
30
|
+
gates << "#{feature['percentage_of_time']}% of the time" if feature["percentage_of_time"].to_f.positive?
|
|
31
|
+
gates << "#{feature['actors_count']} actors" if feature["actors_count"].to_i.positive?
|
|
32
|
+
"conditional (#{gates.join('; ')})"
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Periscope
|
|
4
|
+
module Commands
|
|
5
|
+
# kodeco orders, order, subscription and problems: kerching's read endpoints.
|
|
6
|
+
module Kerching
|
|
7
|
+
PROBLEM_KINDS = %w[overdue stuck_in_preauth invoice_overdue expiry_missed].freeze
|
|
8
|
+
|
|
9
|
+
private
|
|
10
|
+
|
|
11
|
+
def cmd_orders
|
|
12
|
+
opts = parse_options(positional: 1) { |parser, o| page_option(parser, o) }
|
|
13
|
+
rwuuid = UserLookup.rwuuid(client, opts[:args].first)
|
|
14
|
+
body = kerching("/api/v2/users/#{rwuuid}/orders#{query(page: opts[:page])}")
|
|
15
|
+
rows = body["data"].map do |o|
|
|
16
|
+
[o["id"], Format.date(o["created_at"]), o["state"], Format.products(o["products"]),
|
|
17
|
+
Format.date(o["end_date"])]
|
|
18
|
+
end
|
|
19
|
+
emit(body.slice("data", "meta")) { "#{Format.table(%w[ID CREATED STATE PRODUCTS ENDS], rows)}\n#{pages(body)}" }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def cmd_order
|
|
23
|
+
opts = parse_options(positional: 1)
|
|
24
|
+
id = opts[:args].first
|
|
25
|
+
raise UsageError, "Order id must be a number" unless id.match?(/\A\d+\z/)
|
|
26
|
+
|
|
27
|
+
order = kerching("/api/v2/orders/#{id}")["data"]
|
|
28
|
+
emit(order) { order_text(order) }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def cmd_subscription
|
|
32
|
+
opts = parse_options(positional: 1)
|
|
33
|
+
rwuuid = UserLookup.rwuuid(client, opts[:args].first)
|
|
34
|
+
sub = kerching("/api/v2/users/#{rwuuid}/subscription")["data"]
|
|
35
|
+
emit(sub) { subscription_text(sub) }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def cmd_problems
|
|
39
|
+
opts = parse_options do |parser, o|
|
|
40
|
+
page_option(parser, o)
|
|
41
|
+
parser.on("--kind KIND", PROBLEM_KINDS) { |v| o[:kind] = v }
|
|
42
|
+
end
|
|
43
|
+
body = kerching("/api/v2/orders/problems#{query(kind: opts[:kind], page: opts[:page])}")
|
|
44
|
+
counts = (body.dig("meta", "counts") || {}).map { |kind, n| "#{kind}: #{n}" }.join(" ")
|
|
45
|
+
rows = Format.table(%w[KIND SINCE ORDER RWUUID PRODUCTS HELD], body["data"].map { |o| problem_row(o) })
|
|
46
|
+
emit(body.slice("data", "meta")) { "#{counts}\n\n#{rows}\n#{pages(body)}" }
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def problem_row(order)
|
|
50
|
+
[order["kind"], Format.date(order["since"]), order["id"], order.dig("user", "rwuuid"),
|
|
51
|
+
Format.products(order["products"]), ("held" if order["held"])]
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def pages(body) = Format.pages(body["meta"] || {})
|
|
55
|
+
|
|
56
|
+
def kerching(path) = client.get("kerching", path, scope: "orders:read")
|
|
57
|
+
|
|
58
|
+
def page_option(parser, opts)
|
|
59
|
+
parser.on("--page N", Integer) do |n|
|
|
60
|
+
raise UsageError, "--page must be 1 or more" if n < 1
|
|
61
|
+
|
|
62
|
+
opts[:page] = n
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def query(params)
|
|
67
|
+
params = params.compact
|
|
68
|
+
params.empty? ? "" : "?#{URI.encode_www_form(params)}"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def order_text(order)
|
|
72
|
+
history = order["history"].map { |h| [h["created_at"], h["to_state"]] }
|
|
73
|
+
transactions = order["transactions"].map do |t|
|
|
74
|
+
[t["id"], t["created_at"], t["currency"], t["sale_gross"], t["earnings"]]
|
|
75
|
+
end
|
|
76
|
+
[order_header(order), "", "History", Format.table(%w[AT STATE], history),
|
|
77
|
+
"", "Transactions", Format.table(%w[ID AT CURRENCY GROSS EARNINGS], transactions),
|
|
78
|
+
"", "Invoice", invoice_text(order["invoice"])].join("\n")
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def order_header(order)
|
|
82
|
+
["Order ##{order['id']} (#{order['state']}#{', held' if order['held']})",
|
|
83
|
+
" Created: #{order['created_at']}", " Ends: #{order['end_date'] || '-'}",
|
|
84
|
+
" Provider: #{order['external_provider']}, #{order['payment_method']}",
|
|
85
|
+
" Price: #{order['original_price'] || '-'}#{" (coupon #{order['coupon']})" if order['coupon']}",
|
|
86
|
+
" User: #{order.dig('user', 'rwuuid') || '-'}",
|
|
87
|
+
" Products: #{Format.products(order['products'])}"].join("\n")
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def invoice_text(invoice)
|
|
91
|
+
return "(none)" unless invoice
|
|
92
|
+
|
|
93
|
+
"##{invoice['id']} #{invoice['state']}, #{invoice['amount']} #{invoice['currency']}, " \
|
|
94
|
+
"PO #{invoice['purchase_order_number'] || '-'}, reconciled #{invoice['reconciled_at'] || 'no'}"
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def subscription_text(sub)
|
|
98
|
+
history = sub["history"].map { |h| [h["created_at"], h["action"], h["from_sku"], h["to_sku"]] }
|
|
99
|
+
cancellations = sub["cancellations"].map { |c| [c["created_at"], c["survey_outcome"], c["cancellation"]] }
|
|
100
|
+
["Status: #{sub['status']}", "Order: #{subscription_order(sub['order'])}",
|
|
101
|
+
"", "History", Format.table(%w[AT ACTION FROM TO], history),
|
|
102
|
+
"", "Cancellations", Format.table(%w[AT OUTCOME REASON], cancellations)].join("\n")
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def subscription_order(order)
|
|
106
|
+
return "none" unless order
|
|
107
|
+
|
|
108
|
+
product = order["product"] || {}
|
|
109
|
+
"##{order['id']} #{order['state']}, #{product['name'] || '-'} (#{product['billing_interval'] || '-'}), " \
|
|
110
|
+
"ends #{order['end_date'] || '-'}#{', via team' if order['via_team']}"
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Periscope
|
|
4
|
+
module Commands
|
|
5
|
+
# kodeco user <email|rwuuid>
|
|
6
|
+
module User
|
|
7
|
+
private
|
|
8
|
+
|
|
9
|
+
def cmd_user
|
|
10
|
+
opts = parse_options(positional: 1)
|
|
11
|
+
rwuuid = UserLookup.rwuuid(client, opts[:args].first)
|
|
12
|
+
data = client.get("guardpost", "/api/v2/users/#{rwuuid}/summary", scope: "support:read")["data"] || {}
|
|
13
|
+
summary = UserSummary.new(data)
|
|
14
|
+
emit(data) { summary.to_s }
|
|
15
|
+
down = summary.unavailable
|
|
16
|
+
raise UnavailableError, "#{down.join(' and ')} unavailable; showing the rest of the summary" if down.any?
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Periscope
|
|
4
|
+
class Config
|
|
5
|
+
DEFAULT_URLS = {
|
|
6
|
+
"guardpost" => "https://accounts.kodeco.com",
|
|
7
|
+
"kerching" => "https://store.kodeco.com",
|
|
8
|
+
"carolus" => "https://www.kodeco.com"
|
|
9
|
+
}.freeze
|
|
10
|
+
|
|
11
|
+
APPS = DEFAULT_URLS.keys.freeze
|
|
12
|
+
|
|
13
|
+
def initialize(env = ENV)
|
|
14
|
+
@env = env
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def url(app)
|
|
18
|
+
raise UsageError, "Unknown app #{app}; use one of #{APPS.join(', ')}" unless APPS.include?(app)
|
|
19
|
+
|
|
20
|
+
override = @env["KODECO_#{app.upcase}_URL"]
|
|
21
|
+
(override.nil? || override.empty? ? DEFAULT_URLS[app] : override).chomp("/")
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def config_dir
|
|
25
|
+
base = @env["XDG_CONFIG_HOME"]
|
|
26
|
+
base = File.join(Dir.home, ".config") if base.nil? || base.empty?
|
|
27
|
+
File.join(base, "kodeco")
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module Periscope
|
|
7
|
+
# credentials.json holds one entry per guardpost URL, so a staging login
|
|
8
|
+
# never overwrites a production one. Callers hold #with_lock around writes.
|
|
9
|
+
class CredentialStore
|
|
10
|
+
attr_reader :path
|
|
11
|
+
|
|
12
|
+
def initialize(dir, key)
|
|
13
|
+
@dir = dir
|
|
14
|
+
@key = key
|
|
15
|
+
@path = File.join(dir, "credentials.json")
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def read
|
|
19
|
+
all[@key]
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def write(entry)
|
|
23
|
+
save(all.merge(@key => entry))
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Returns the deleted entry, or nil if there wasn't one.
|
|
27
|
+
def delete
|
|
28
|
+
entries = all
|
|
29
|
+
deleted = entries.delete(@key) or return
|
|
30
|
+
|
|
31
|
+
entries.empty? ? File.delete(@path) : save(entries)
|
|
32
|
+
deleted
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def with_lock
|
|
36
|
+
ensure_dir
|
|
37
|
+
File.open(File.join(@dir, "credentials.lock"), File::RDWR | File::CREAT, 0o600) do |lock|
|
|
38
|
+
lock.flock(File::LOCK_EX)
|
|
39
|
+
yield
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def all
|
|
46
|
+
return {} unless File.exist?(@path)
|
|
47
|
+
|
|
48
|
+
mode = File.stat(@path).mode & 0o777
|
|
49
|
+
if mode.anybits?(0o077)
|
|
50
|
+
raise Error, format("%<path>s has permissions %<mode>04o, wider than 0600. Fix it with: chmod 600 %<path>s",
|
|
51
|
+
path: @path, mode: mode)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
JSON.parse(File.read(@path))
|
|
55
|
+
rescue JSON::ParserError
|
|
56
|
+
raise Error, "#{@path} isn't valid JSON. Delete it and run kodeco login."
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def save(entries)
|
|
60
|
+
ensure_dir
|
|
61
|
+
tmp = "#{@path}.#{Process.pid}.#{Thread.current.object_id}.tmp"
|
|
62
|
+
File.open(tmp, File::WRONLY | File::CREAT | File::EXCL, 0o600) do |f|
|
|
63
|
+
f.write(JSON.pretty_generate(entries))
|
|
64
|
+
f.fsync
|
|
65
|
+
end
|
|
66
|
+
File.rename(tmp, @path)
|
|
67
|
+
ensure
|
|
68
|
+
FileUtils.rm_f(tmp) if tmp
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def ensure_dir
|
|
72
|
+
FileUtils.mkdir_p(@dir)
|
|
73
|
+
File.chmod(0o700, @dir)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Periscope
|
|
4
|
+
# Each error carries the exit code the CLI returns for it.
|
|
5
|
+
class Error < StandardError
|
|
6
|
+
attr_reader :code
|
|
7
|
+
|
|
8
|
+
def initialize(message, code: default_code)
|
|
9
|
+
super(message)
|
|
10
|
+
@code = code
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def exit_code = 1
|
|
14
|
+
def default_code = "error"
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
class UsageError < Error
|
|
18
|
+
def exit_code = 2
|
|
19
|
+
def default_code = "usage"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
class AuthError < Error
|
|
23
|
+
def exit_code = 3
|
|
24
|
+
def default_code = "not_logged_in"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
class NotFoundError < Error
|
|
28
|
+
def exit_code = 4
|
|
29
|
+
def default_code = "not_found"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
class UnavailableError < Error
|
|
33
|
+
def exit_code = 5
|
|
34
|
+
def default_code = "unavailable"
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Periscope
|
|
6
|
+
# Plain-text helpers for the human-readable output.
|
|
7
|
+
module Format
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
def table(headers, rows)
|
|
11
|
+
return "(none)" if rows.empty?
|
|
12
|
+
|
|
13
|
+
cells = [headers, *rows].map { |row| row.map { |cell| (cell || "-").to_s } }
|
|
14
|
+
widths = cells.transpose.map { |column| column.map(&:length).max }
|
|
15
|
+
cells.map { |row| line(row, widths) }.join("\n")
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def line(row, widths) = row.zip(widths).map { |cell, width| cell.ljust(width) }.join(" ").rstrip
|
|
19
|
+
|
|
20
|
+
def pages(meta)
|
|
21
|
+
per_page = [meta["per_page"].to_i, 1].max
|
|
22
|
+
"page #{meta['page'] || 1} of #{[(meta['total'].to_i + per_page - 1) / per_page, 1].max} " \
|
|
23
|
+
"(#{meta['total'].to_i} total)"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def date(value) = value&.slice(0, 10)
|
|
27
|
+
|
|
28
|
+
def products(products) = Array(products).map { |p| p["name"] }.join(", ")
|
|
29
|
+
|
|
30
|
+
def value(value) = value.is_a?(Hash) || value.is_a?(Array) ? JSON.generate(value) : value.to_s
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
7
|
+
module Periscope
|
|
8
|
+
# A thin Net::HTTP wrapper. With debug on it logs method, URL and status,
|
|
9
|
+
# never headers or bodies, so tokens stay out of the log.
|
|
10
|
+
class Http
|
|
11
|
+
Response = Struct.new(:status, :body, :retry_after) do
|
|
12
|
+
def ok? = status.between?(200, 299)
|
|
13
|
+
|
|
14
|
+
# OAuth errors are {error, error_description}; other API v2 errors are
|
|
15
|
+
# {error: {code, message}}.
|
|
16
|
+
def error_code
|
|
17
|
+
error = body["error"]
|
|
18
|
+
error.is_a?(Hash) ? error["code"] : error
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def error_message
|
|
22
|
+
error = body["error"]
|
|
23
|
+
error.is_a?(Hash) ? error["message"] : body["error_description"]
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
NETWORK_ERRORS = [SocketError, SystemCallError, IOError, Timeout::Error,
|
|
28
|
+
Net::HTTPBadResponse, OpenSSL::SSL::SSLError].freeze
|
|
29
|
+
|
|
30
|
+
def initialize(debug: false, log: $stderr)
|
|
31
|
+
@debug = debug
|
|
32
|
+
@log = log
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def get(url, headers = {})
|
|
36
|
+
request(Net::HTTP::Get, url, headers)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def post_form(url, params, headers = {})
|
|
40
|
+
request(Net::HTTP::Post, url, headers, URI.encode_www_form(params), "application/x-www-form-urlencoded")
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def post_json(url, payload, headers = {})
|
|
44
|
+
request(Net::HTTP::Post, url, headers, JSON.generate(payload), "application/json")
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def request(klass, url, headers, body = nil, content_type = nil)
|
|
50
|
+
uri = URI(url)
|
|
51
|
+
req = build(klass, uri, headers, body, content_type)
|
|
52
|
+
res = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https",
|
|
53
|
+
open_timeout: 10, read_timeout: 30) { |h| h.request(req) }
|
|
54
|
+
log("#{req.method} #{uri} #{res.code}")
|
|
55
|
+
Response.new(res.code.to_i, parse(res.body), res["Retry-After"])
|
|
56
|
+
rescue *NETWORK_ERRORS => e
|
|
57
|
+
unavailable(req, uri, e)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def unavailable(req, uri, error)
|
|
61
|
+
log("#{req&.method} #{uri} failed: #{error.class}")
|
|
62
|
+
raise UnavailableError, "#{uri.host} unavailable (#{error.class.name.split('::').last})"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def build(klass, uri, headers, body, content_type)
|
|
66
|
+
req = klass.new(uri)
|
|
67
|
+
req["Accept"] = "application/json"
|
|
68
|
+
req["User-Agent"] = "kodeco/#{VERSION}"
|
|
69
|
+
headers.each { |k, v| req[k] = v }
|
|
70
|
+
if body
|
|
71
|
+
req.body = body
|
|
72
|
+
req["Content-Type"] = content_type
|
|
73
|
+
end
|
|
74
|
+
req
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def parse(body)
|
|
78
|
+
parsed = body.nil? || body.empty? ? {} : JSON.parse(body)
|
|
79
|
+
parsed.is_a?(Hash) ? parsed : { "data" => parsed }
|
|
80
|
+
rescue JSON::ParserError
|
|
81
|
+
{}
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def log(line)
|
|
85
|
+
@log.puts(line) if @debug
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Periscope
|
|
4
|
+
# The OAuth device-code flow against guardpost.
|
|
5
|
+
class Login
|
|
6
|
+
DEFAULT_SCOPES = "users:read support:read orders:read learning:read flags:read"
|
|
7
|
+
DEVICE_GRANT = "urn:ietf:params:oauth:grant-type:device_code"
|
|
8
|
+
SCOPE_HINT = "If you lack a grant for one of the scopes, retry with --scopes and leave it out."
|
|
9
|
+
|
|
10
|
+
def initialize(config:, store:, http:, out:, opener: nil, sleeper: ->(s) { sleep(s) }, clock: -> { Time.now })
|
|
11
|
+
@base = config.url("guardpost")
|
|
12
|
+
@store = store
|
|
13
|
+
@http = http
|
|
14
|
+
@out = out
|
|
15
|
+
@opener = opener
|
|
16
|
+
@sleeper = sleeper
|
|
17
|
+
@clock = clock
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def call(scopes = DEFAULT_SCOPES)
|
|
21
|
+
device = start(scopes)
|
|
22
|
+
@out.puts "To log in, visit #{device['verification_uri_complete']}"
|
|
23
|
+
@out.puts "and confirm the code #{device['user_code']}."
|
|
24
|
+
@opener&.call(device["verification_uri_complete"])
|
|
25
|
+
credentials = Session.credentials_from(poll(device), now: @clock.call)
|
|
26
|
+
@store.with_lock { @store.write(credentials) }
|
|
27
|
+
credentials
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def start(scopes)
|
|
33
|
+
res = @http.post_form("#{@base}/api/v2/auth/device", scope: scopes)
|
|
34
|
+
return res.body if res.ok?
|
|
35
|
+
|
|
36
|
+
raise UsageError.new("#{res.error_message || 'Invalid scope'}. #{SCOPE_HINT}", code: "invalid_scope") if
|
|
37
|
+
res.error_code == "invalid_scope"
|
|
38
|
+
|
|
39
|
+
raise_common(res)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def poll(device)
|
|
43
|
+
interval = (device["interval"] || 5).to_i
|
|
44
|
+
loop do
|
|
45
|
+
@sleeper.call(interval)
|
|
46
|
+
res = @http.post_form("#{@base}/api/v2/auth/token", grant_type: DEVICE_GRANT,
|
|
47
|
+
device_code: device["device_code"])
|
|
48
|
+
return res.body if res.ok?
|
|
49
|
+
next @sleeper.call(res.retry_after.to_i) if res.status == 429
|
|
50
|
+
|
|
51
|
+
case res.error_code
|
|
52
|
+
when "authorization_pending" then next
|
|
53
|
+
when "slow_down" then interval += 5
|
|
54
|
+
else raise_poll_error(res)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def raise_poll_error(res)
|
|
60
|
+
case res.error_code
|
|
61
|
+
when "expired_token"
|
|
62
|
+
raise AuthError.new("The code expired before it was approved. Run kodeco login again.", code: "expired_token")
|
|
63
|
+
when "access_denied"
|
|
64
|
+
raise AuthError.new("Login wasn't approved. #{SCOPE_HINT}", code: "access_denied")
|
|
65
|
+
when "invalid_grant"
|
|
66
|
+
raise AuthError.new("The login code is no longer valid. Run kodeco login again.", code: "invalid_grant")
|
|
67
|
+
end
|
|
68
|
+
raise_common(res)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def raise_common(res)
|
|
72
|
+
if res.status >= 500 || res.error_code == "temporarily_unavailable"
|
|
73
|
+
raise UnavailableError, "guardpost unavailable (#{res.error_message || res.status})"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
raise Error.new("Login failed: #{res.error_message || res.error_code || res.status}",
|
|
77
|
+
code: res.error_code || "error")
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Periscope
|
|
4
|
+
# Hands out per-app access tokens, refreshing them when they're close to
|
|
5
|
+
# expiry. guardpost rotates the refresh token on every refresh, and
|
|
6
|
+
# presenting a rotated one revokes the session, so refreshes are serialised
|
|
7
|
+
# across processes with a flock and the file is re-read once it's held.
|
|
8
|
+
class Session
|
|
9
|
+
REFRESH_MARGIN = 60
|
|
10
|
+
TOKEN_PATH = "/api/v2/auth/token"
|
|
11
|
+
|
|
12
|
+
def self.credentials_from(body, now: Time.now)
|
|
13
|
+
expires_at = now.to_i + body.fetch("expires_in").to_i
|
|
14
|
+
{
|
|
15
|
+
"access_tokens" => body.fetch("access_tokens").transform_values do |token|
|
|
16
|
+
{ "token" => token, "expires_at" => expires_at }
|
|
17
|
+
end,
|
|
18
|
+
"refresh_token" => body.fetch("refresh_token"),
|
|
19
|
+
"scope" => body["scope"]
|
|
20
|
+
}
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def initialize(config:, store:, http:, clock: -> { Time.now })
|
|
24
|
+
@config = config
|
|
25
|
+
@store = store
|
|
26
|
+
@http = http
|
|
27
|
+
@clock = clock
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def token_for(app)
|
|
31
|
+
credentials = @store.read or raise AuthError, "Not logged in. Run kodeco login."
|
|
32
|
+
return token(credentials, app) if fresh?(credentials, app)
|
|
33
|
+
|
|
34
|
+
@store.with_lock do
|
|
35
|
+
credentials = @store.read or raise AuthError, "Not logged in. Run kodeco login."
|
|
36
|
+
credentials = refresh(credentials) unless fresh?(credentials, app)
|
|
37
|
+
token(credentials, app)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def fresh?(credentials, app)
|
|
44
|
+
expires_at = credentials.dig("access_tokens", app, "expires_at")
|
|
45
|
+
expires_at.nil? || expires_at.to_i - REFRESH_MARGIN > @clock.call.to_i
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def token(credentials, app)
|
|
49
|
+
credentials.dig("access_tokens", app, "token") or
|
|
50
|
+
raise AuthError.new("Your login has no token for #{app}. Run kodeco login again.", code: "auth_failed")
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def refresh(credentials)
|
|
54
|
+
res = @http.post_form("#{@config.url('guardpost')}#{TOKEN_PATH}",
|
|
55
|
+
grant_type: "refresh_token", refresh_token: credentials["refresh_token"])
|
|
56
|
+
if res.ok?
|
|
57
|
+
refreshed = self.class.credentials_from(res.body, now: @clock.call)
|
|
58
|
+
@store.write(refreshed)
|
|
59
|
+
return refreshed
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
raise_refresh_error(res)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def raise_refresh_error(res)
|
|
66
|
+
case res.error_code
|
|
67
|
+
when "invalid_grant"
|
|
68
|
+
@store.delete
|
|
69
|
+
raise AuthError.new("Your session has ended. Run kodeco login again.", code: "auth_failed")
|
|
70
|
+
when "temporarily_unavailable"
|
|
71
|
+
raise UnavailableError, "guardpost unavailable (#{res.error_message || res.status})"
|
|
72
|
+
end
|
|
73
|
+
raise UnavailableError, "guardpost unavailable (#{res.status})" if res.status >= 500
|
|
74
|
+
|
|
75
|
+
raise Error, "Token refresh failed: #{res.error_message || res.error_code || res.status}"
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
5
|
+
module Periscope
|
|
6
|
+
# Resolves an <email|rwuuid> argument to an rwuuid.
|
|
7
|
+
module UserLookup
|
|
8
|
+
def self.rwuuid(client, arg)
|
|
9
|
+
rwuuid = if arg.include?("@")
|
|
10
|
+
data = client.post_json("guardpost", "/api/v2/users/lookup", { email: arg }, scope: "users:read")
|
|
11
|
+
Array(data["data"]).first&.fetch("rwuuid") or raise NotFoundError, "No user with that email"
|
|
12
|
+
else
|
|
13
|
+
arg
|
|
14
|
+
end
|
|
15
|
+
URI.encode_www_form_component(rwuuid)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Periscope
|
|
6
|
+
# Renders guardpost's customer summary for people. kerching and carolus
|
|
7
|
+
# sections carry a status; carolus's list items are rendered tolerantly
|
|
8
|
+
# because their shapes are carolus's own.
|
|
9
|
+
class UserSummary
|
|
10
|
+
REMOTE = %w[kerching carolus].freeze
|
|
11
|
+
|
|
12
|
+
def initialize(data)
|
|
13
|
+
@data = data
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def unavailable
|
|
17
|
+
REMOTE.select { |app| @data.dig(app, "status") == "unavailable" }
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def to_s
|
|
21
|
+
[guardpost, *REMOTE.map { |app| remote(app) }].join("\n\n")
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def guardpost
|
|
27
|
+
user = @data["guardpost"] || {}
|
|
28
|
+
lines = ["guardpost"]
|
|
29
|
+
lines.concat(fields(user))
|
|
30
|
+
lines.concat(list("Recent email problems", user["email_problems"]) do |p|
|
|
31
|
+
"#{p['log_type']} at #{p['created_at']}"
|
|
32
|
+
end)
|
|
33
|
+
lines.concat(list("Impersonations", user["impersonations"]) do |i|
|
|
34
|
+
"by #{i['impersonator_id']} at #{i['created_at']}: #{i['reason']}"
|
|
35
|
+
end)
|
|
36
|
+
lines.join("\n")
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def fields(user)
|
|
40
|
+
sessions = user["sessions"] || {}
|
|
41
|
+
[
|
|
42
|
+
["Name", user["name"]], ["Email", user["email"]], ["Username", user["username"]],
|
|
43
|
+
["rwuuid", user["rwuuid"]], ["Access level", user["access_level"]], ["Created at", user["created_at"]],
|
|
44
|
+
["Last signed in", user["last_logged_in"] || "never"],
|
|
45
|
+
["Confirmed", user["confirmed_at"] ? "yes, #{user['confirmed_at']}" : "no"],
|
|
46
|
+
["Consent up to date", yes_no(user.dig("consent", "up_to_date"))],
|
|
47
|
+
["Teams", teams(user["memberships"])],
|
|
48
|
+
["Active sessions", "#{sessions['active_count'] || 0} (last active #{sessions['last_active_at'] || 'never'})"]
|
|
49
|
+
].map { |label, value| format(" %-19<label>s %<value>s", label: "#{label}:", value: value.nil? ? "-" : value) }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def teams(memberships)
|
|
53
|
+
return "none" if memberships.nil? || memberships.empty?
|
|
54
|
+
|
|
55
|
+
memberships.map { |m| [m["team_name"] || "team #{m['team_id']}", m["title"]].compact.join(", ") }.join("; ")
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def remote(app)
|
|
59
|
+
section = @data[app] || {}
|
|
60
|
+
case section["status"]
|
|
61
|
+
when "ok" then [app, *send(:"#{app}_lines", section["data"] || {})].join("\n")
|
|
62
|
+
when "none" then "#{app}\n No record in #{app}"
|
|
63
|
+
else "#{app}\n !! #{app} unavailable (#{section['error'] || 'error'})"
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def kerching_lines(data)
|
|
68
|
+
sub = data["subscription"] || {}
|
|
69
|
+
order = sub["order"]
|
|
70
|
+
lines = [" Subscription: #{sub['status'] || '-'}"]
|
|
71
|
+
if order
|
|
72
|
+
lines << " Order: ##{order['id']} #{order['state']}, #{order.dig('product', 'name') || '-'}, " \
|
|
73
|
+
"ends #{order['end_date'] || '-'}"
|
|
74
|
+
end
|
|
75
|
+
lines + list("Owned products", data["ownerships"], all: true) do |o|
|
|
76
|
+
"#{o.dig('product', 'name')} (#{o.dig('product', 'sku')})"
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def carolus_lines(data)
|
|
81
|
+
progress = data["progress"] || {}
|
|
82
|
+
watching = data["watching"] || {}
|
|
83
|
+
minutes = (watching["seconds_last_30_days"] || 0).to_i / 60
|
|
84
|
+
[" Progress: #{progress['in_progress_count'] || 0} in progress, " \
|
|
85
|
+
"#{progress['finished_count'] || 0} finished",
|
|
86
|
+
" Watching: #{minutes} min in the last 30 days, last on #{watching['last_watched_on'] || 'never'}"] +
|
|
87
|
+
list("Recent content", progress["recent"], all: true) { |item| describe(item) } +
|
|
88
|
+
list("Playback errors", data["playback_errors"]) { |item| describe(item) }
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# A heading and one line per item; empty lists are left out unless all: true.
|
|
92
|
+
def list(heading, items, all: false, &)
|
|
93
|
+
items = Array(items)
|
|
94
|
+
return [] if items.empty? && !all
|
|
95
|
+
return [" #{heading}: none"] if items.empty?
|
|
96
|
+
|
|
97
|
+
[" #{heading}:", *items.map { |item| " - #{yield(item)}" }]
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def describe(item)
|
|
101
|
+
return item.to_s unless item.is_a?(Hash)
|
|
102
|
+
|
|
103
|
+
item.values_at("title", "name", "content_name").compact.first || JSON.generate(item)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def yes_no(value) = { true => "yes", false => "no" }[value]
|
|
107
|
+
end
|
|
108
|
+
end
|
data/lib/periscope.rb
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "periscope/version"
|
|
4
|
+
require_relative "periscope/errors"
|
|
5
|
+
require_relative "periscope/config"
|
|
6
|
+
require_relative "periscope/http"
|
|
7
|
+
require_relative "periscope/credential_store"
|
|
8
|
+
require_relative "periscope/session"
|
|
9
|
+
require_relative "periscope/client"
|
|
10
|
+
require_relative "periscope/login"
|
|
11
|
+
require_relative "periscope/user_lookup"
|
|
12
|
+
require_relative "periscope/user_summary"
|
|
13
|
+
require_relative "periscope/format"
|
|
14
|
+
require_relative "periscope/commands/user"
|
|
15
|
+
require_relative "periscope/commands/kerching"
|
|
16
|
+
require_relative "periscope/commands/flags"
|
|
17
|
+
require_relative "periscope/cli"
|
metadata
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: kodeco
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Kodeco
|
|
8
|
+
bindir: exe
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies: []
|
|
12
|
+
executables:
|
|
13
|
+
- kodeco
|
|
14
|
+
extensions: []
|
|
15
|
+
extra_rdoc_files: []
|
|
16
|
+
files:
|
|
17
|
+
- README.md
|
|
18
|
+
- exe/kodeco
|
|
19
|
+
- lib/kodeco.rb
|
|
20
|
+
- lib/periscope.rb
|
|
21
|
+
- lib/periscope/cli.rb
|
|
22
|
+
- lib/periscope/client.rb
|
|
23
|
+
- lib/periscope/commands/flags.rb
|
|
24
|
+
- lib/periscope/commands/kerching.rb
|
|
25
|
+
- lib/periscope/commands/user.rb
|
|
26
|
+
- lib/periscope/config.rb
|
|
27
|
+
- lib/periscope/credential_store.rb
|
|
28
|
+
- lib/periscope/errors.rb
|
|
29
|
+
- lib/periscope/format.rb
|
|
30
|
+
- lib/periscope/http.rb
|
|
31
|
+
- lib/periscope/login.rb
|
|
32
|
+
- lib/periscope/session.rb
|
|
33
|
+
- lib/periscope/user_lookup.rb
|
|
34
|
+
- lib/periscope/user_summary.rb
|
|
35
|
+
- lib/periscope/version.rb
|
|
36
|
+
homepage: https://github.com/razeware/periscope
|
|
37
|
+
licenses: []
|
|
38
|
+
metadata:
|
|
39
|
+
rubygems_mfa_required: 'true'
|
|
40
|
+
rdoc_options: []
|
|
41
|
+
require_paths:
|
|
42
|
+
- lib
|
|
43
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
44
|
+
requirements:
|
|
45
|
+
- - ">="
|
|
46
|
+
- !ruby/object:Gem::Version
|
|
47
|
+
version: '3.3'
|
|
48
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
49
|
+
requirements:
|
|
50
|
+
- - ">="
|
|
51
|
+
- !ruby/object:Gem::Version
|
|
52
|
+
version: '0'
|
|
53
|
+
requirements: []
|
|
54
|
+
rubygems_version: 4.0.20
|
|
55
|
+
specification_version: 4
|
|
56
|
+
summary: The kodeco command-line tool for Kodeco's API v2
|
|
57
|
+
test_files: []
|