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 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
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "periscope"
5
+
6
+ exit Periscope::CLI.new(ARGV).run
data/lib/kodeco.rb ADDED
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The gem is published as kodeco; its code lives under Periscope.
4
+ require_relative "periscope"
@@ -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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Periscope
4
+ VERSION = "0.1.0"
5
+ 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: []