kodeco 0.1.1 → 0.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 92f1b3f3a2c07ef7de80d5e2bcf2e7dd6989cd5baa29c56d085ea8fe61294e66
4
- data.tar.gz: 75794675c957b94474ece30dfaebb15c2c28f3b0798208b48242aa7989fb1e94
3
+ metadata.gz: 917b9f6e09b0d724f94ff27f1596cba5c870545dc6a1199a0ebb31cd7a68d114
4
+ data.tar.gz: c9e3e16f779d27b3353e42593cb37abf181f9e3d1459b60dc79cef5d1b20d48d
5
5
  SHA512:
6
- metadata.gz: 95eac6af137ca2809784e1adc765a4143a54bf7f0891236f105810645b5296de87c08367012c72a304361dd96a82940aa31efe85bb2036eaf58a029f7bcf1a6b
7
- data.tar.gz: 846d4357394ca12d042e4cf57476975866289452a0f3b74f764d0300e5503a0657e5efd45646ed155a531a4e1db39ddce6d39ebebfae2ed2650e078922232212
6
+ metadata.gz: a5812db82c6f20d0bb3346fd4dd501149a58f382235038b6b274b78e7bae1329cdad77791469d0f83743ac6ad55022037c06eb363ec7a4c8f0bb3b8bf01d3ed3
7
+ data.tar.gz: 105c668ab4fc503d874401e048f6cec51e5732e7df7673f91e9cf2c6e43992486fa065310b1e842213ab45b642f8a311a60cc9f7f54be25d9c8d07a27c1dfc7d
data/README.md CHANGED
@@ -22,6 +22,10 @@ kodeco order <id>
22
22
  kodeco subscription <email|rwuuid>
23
23
  kodeco problems [--kind overdue|stuck_in_preauth|invoice_overdue|expiry_missed] [--page N]
24
24
  kodeco flags --app guardpost|kerching|carolus
25
+ kodeco shortlinks list [query] [--page N]
26
+ kodeco shortlinks show <slug>
27
+ kodeco shortlinks create <url> [--channels a,b] [--campaign NAME]
28
+ kodeco shortlinks update <slug> --active=true|false
25
29
  ```
26
30
 
27
31
  `user` shows guardpost's customer summary, with kerching's and carolus's
@@ -31,10 +35,19 @@ exits 5 after printing the rest. `orders`, `order`, `subscription` and
31
35
  `flags:read`. With `--json`, commands print the response's `data`, plus `meta`
32
36
  for lists.
33
37
 
38
+ `shortlinks` manages carolus's kod.eco short links. `list` and `show` need
39
+ `shortlinks:read`, which `login` asks for by default. `create` and `update`
40
+ need `shortlinks:write`, which it never asks for by default; without it they
41
+ print the `kodeco login --scopes` line to run, and send nothing. `show` and
42
+ `update` take a slug or a short URL. Team members see and change only their
43
+ own links. Admins see every link and the fuller click counts, and can use
44
+ `--channels` (keys from carolus's `config/short_link_channels.yml`, such as
45
+ `twitter`) and `--campaign`, which needs `--channels`.
46
+
34
47
  `login` uses guardpost's device-code flow: it prints a code and a link to
35
48
  approve it, and opens the link in a browser when run in a terminal. The
36
49
  default scopes are `users:read support:read orders:read learning:read
37
- flags:read`. Scopes other than `flags:read` need a grant from a guardpost
50
+ flags:read shortlinks:read`. Scopes other than `flags:read` need a grant from a guardpost
38
51
  admin. A login gets whichever of the requested scopes you hold, and `login`
39
52
  lists any that weren't granted; a grant added later needs a fresh
40
53
  `kodeco login`.
@@ -45,7 +58,8 @@ the server session lasts up to 7 days unless an admin revokes it.
45
58
  Every command takes:
46
59
 
47
60
  * `--json`: stdout is exactly one JSON document, and errors go to stderr as
48
- `{"error": {"code": ..., "message": ...}}`.
61
+ `{"error": {"code": ..., "message": ...}}`. When an app refuses a value,
62
+ the error also has `details`, with each field's problems.
49
63
  * `--debug`: logs each request's method, URL and status to stderr. Tokens are
50
64
  never printed or logged.
51
65
 
@@ -55,7 +69,7 @@ Every command takes:
55
69
  | ---- | ------- |
56
70
  | 0 | OK |
57
71
  | 1 | Error |
58
- | 2 | Usage error |
72
+ | 2 | Usage error, or a value the app refused |
59
73
  | 3 | Not logged in, or authentication failed |
60
74
  | 4 | Not found |
61
75
  | 5 | An upstream app is unavailable |
@@ -79,10 +93,12 @@ processes share one refresh rather than revoking each other's session.
79
93
  The gem is published to rubygems.org as `kodeco`, with Trusted Publishing, so
80
94
  no API key is stored anywhere.
81
95
 
82
- 1. Bump `Periscope::VERSION` in `lib/periscope/version.rb`.
83
- 2. Merge to `trunk`.
84
- 3. Run the Release workflow by hand on `trunk`. It runs the tests, tags
85
- `v<version>` and publishes the gem.
96
+ 1. Bump `Periscope::VERSION` in `lib/periscope/version.rb`, in a PR.
97
+ 2. Merge the release PR to `trunk`.
98
+ 3. Publish a GitHub release tagged `v<version>` on `trunk`. The Release
99
+ workflow checks that the tag matches `Periscope::VERSION`, runs the tests,
100
+ then builds and pushes the gem. To re-run a failed publish, run the
101
+ workflow by hand with the tag.
86
102
 
87
103
  ## Development
88
104
 
data/lib/periscope/cli.rb CHANGED
@@ -9,6 +9,7 @@ module Periscope
9
9
  include Commands::User
10
10
  include Commands::Kerching
11
11
  include Commands::Flags
12
+ include Commands::Shortlinks
12
13
 
13
14
  USAGE = <<~TEXT
14
15
  Usage: kodeco <command> [options]
@@ -24,12 +25,18 @@ module Periscope
24
25
  subscription <email|rwuuid> Show a user's kerching subscription
25
26
  problems [--kind KIND] [--page N] List kerching orders that need attention
26
27
  flags --app guardpost|kerching|carolus Show an app's feature flags
28
+ shortlinks list [query] [--page N] List kod.eco short links
29
+ shortlinks show <slug> Show one short link and its clicks
30
+ shortlinks create <url> [--channels a,b] [--campaign NAME]
31
+ Make short links to a kodeco.com URL
32
+ shortlinks update <slug> --active=true|false
33
+ Turn a short link on or off
27
34
 
28
35
  Every command takes --json (one JSON document on stdout, errors as JSON
29
36
  on stderr) and --debug (logs each request's method, URL and status).
30
37
  TEXT
31
38
 
32
- COMMANDS = %w[login logout whoami user orders order subscription problems flags].freeze
39
+ COMMANDS = %w[login logout whoami user orders order subscription problems flags shortlinks].freeze
33
40
  LOGOUT_NOTE = "guardpost has no revoke endpoint, so the server session lasts up to 7 days " \
34
41
  "unless an admin revokes it."
35
42
 
@@ -104,7 +111,7 @@ module Periscope
104
111
  end
105
112
 
106
113
  # Parses the remaining arguments and returns the options; commands that
107
- # take positional arguments pass positional: n.
114
+ # take positional arguments pass positional: n, or a Range.
108
115
  def parse_options(positional: 0)
109
116
  opts = {}
110
117
  parser = OptionParser.new
@@ -112,15 +119,14 @@ module Periscope
112
119
  parser.on("--debug")
113
120
  yield parser, opts if block_given?
114
121
  rest = parser.parse(@argv)
115
- raise UsageError, "Expected #{positional} argument(s), got #{rest.size}" unless rest.size == positional
122
+ unless Array(positional).include?(rest.size)
123
+ raise UsageError, "Expected #{positional} argument(s), got #{rest.size}"
124
+ end
116
125
 
117
- opts[:args] = rest
118
- opts
126
+ opts.merge(args: rest)
119
127
  end
120
128
 
121
- def emit(data)
122
- @stdout.puts(@json ? JSON.generate(data) : yield)
123
- end
129
+ def emit(data) = @stdout.puts(@json ? JSON.generate(data) : yield)
124
130
 
125
131
  def usage(status)
126
132
  (status.zero? ? @stdout : @stderr).puts(USAGE)
@@ -129,7 +135,7 @@ module Periscope
129
135
 
130
136
  def fail_with(error)
131
137
  if @json
132
- @stderr.puts(JSON.generate(error: { code: error.code, message: error.message }))
138
+ @stderr.puts(JSON.generate(error: { code: error.code, message: error.message, details: error.details }.compact))
133
139
  else
134
140
  @stderr.puts("kodeco: #{error.message}")
135
141
  end
@@ -18,6 +18,10 @@ module Periscope
18
18
  handle(app, scope, @http.post_json(url(app, path), payload, auth(app)))
19
19
  end
20
20
 
21
+ def patch_json(app, path, payload, scope: nil)
22
+ handle(app, scope, @http.patch_json(url(app, path), payload, auth(app)))
23
+ end
24
+
21
25
  private
22
26
 
23
27
  def url(app, path) = "#{@config.url(app)}#{path}"
@@ -32,11 +36,20 @@ module Periscope
32
36
  when 401 then raise AuthError.new("#{app} rejected the token: #{message}. Run kodeco login.", code: "auth_failed")
33
37
  when 403 then raise AuthError.new(forbidden(app, scope, message), code: "forbidden")
34
38
  when 404 then raise NotFoundError, message
39
+ when 422 then raise refused(app, message, res)
35
40
  when 500.. then raise UnavailableError, "#{app} unavailable (#{message})"
36
41
  end
37
42
  raise Error.new(message, code: res.error_code || "error")
38
43
  end
39
44
 
45
+ # A value the app refused, with each field's problems in the message.
46
+ def refused(app, message, res)
47
+ details = res.error_details
48
+ fields = details.map { |field, problems| "#{field}: #{Array(problems).join(', ')}" } if details.is_a?(Hash)
49
+ message = "#{message} (#{fields.join('; ')})" if fields&.any?
50
+ UsageError.new("#{app} refused: #{message}", code: res.error_code || "validation_failed", details: details)
51
+ end
52
+
40
53
  def forbidden(app, scope, message)
41
54
  return "#{app} refused: #{message}" unless scope
42
55
 
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Periscope
4
+ module Commands
5
+ # kodeco shortlinks list, show, create and update: carolus's kod.eco short links.
6
+ module Shortlinks
7
+ SUBCOMMANDS = %w[list show create update].freeze
8
+ PATH = "/api/v2/short_links"
9
+ SLUG = /\A[a-z2-9]{5}\z/
10
+ UTM_HEADERS = %w[SOURCE MEDIUM CAMPAIGN].freeze
11
+ LIST_HEADERS = ["SHORT URL", "ACTIVE", "CLICKS", "7D", "BOTS", *UTM_HEADERS, "DESTINATION"].freeze
12
+ TEAM_NOTE = "Short links are for the Kodeco team, and only admins can see or change other people's links " \
13
+ "or use --channels and --campaign."
14
+ RETRY_NOTE = "It may have been applied; running the same command again is safe."
15
+
16
+ private
17
+
18
+ def cmd_shortlinks
19
+ subcommand = @argv.shift
20
+ raise UsageError, "Usage: kodeco shortlinks #{SUBCOMMANDS.join('|')}" unless SUBCOMMANDS.include?(subcommand)
21
+
22
+ send(:"shortlinks_#{subcommand}")
23
+ end
24
+
25
+ def shortlinks_list
26
+ opts = parse_options(positional: 0..1) { |parser, o| page_option(parser, o) }
27
+ path = "#{PATH}#{query(q: opts[:args].first, page: opts[:page])}"
28
+ body = carolus("shortlinks:read") { client.get("carolus", path) }
29
+ rows = body["data"].map do |link|
30
+ [link["short_url"], link["active"] ? "yes" : "no", link["clicks"], link["clicks_7d"], link["bot_clicks"],
31
+ *ShortLinkText.utm(link), link["destination_url"]]
32
+ end
33
+ emit(body.slice("data", "meta")) { "#{Format.table(LIST_HEADERS, rows)}\n#{pages(body)}" }
34
+ end
35
+
36
+ def shortlinks_show
37
+ slug = slug_arg(parse_options(positional: 1))
38
+ link = carolus("shortlinks:read", slug: slug) { client.get("carolus", "#{PATH}/#{slug}") }["data"]
39
+ emit(link) { ShortLinkText.new(link).to_s }
40
+ end
41
+
42
+ def shortlinks_create
43
+ opts = parse_options(positional: 1) do |parser, o|
44
+ parser.on("--channels LIST", Array) { |v| o[:channels] = channels(v) }
45
+ parser.on("--campaign NAME") { |v| o[:campaign] = v }
46
+ end
47
+ raise UsageError, "--campaign needs --channels: carolus uses it only for channel links" if
48
+ opts[:campaign] && !opts[:channels]
49
+
50
+ payload = { destination_url: opts[:args].first, channels: opts[:channels], utm_campaign: opts[:campaign] }
51
+ links = carolus("shortlinks:write", write: true) { client.post_json("carolus", PATH, payload.compact) }["data"]
52
+ emit(links) { create_text(links) }
53
+ end
54
+
55
+ def shortlinks_update
56
+ opts = parse_options(positional: 1) { |parser, o| parser.on("--active BOOL", TrueClass) { |v| o[:active] = v } }
57
+ slug = slug_arg(opts)
58
+ raise UsageError, "update needs --active=true or --active=false" if opts[:active].nil?
59
+
60
+ link = carolus("shortlinks:write", slug: slug, write: true) do
61
+ client.patch_json("carolus", "#{PATH}/#{slug}", { active: opts[:active] })
62
+ end["data"]
63
+ emit(link) { ShortLinkText.new(link).updated }
64
+ end
65
+
66
+ def create_text(links)
67
+ rows = links.map { |l| [l["created"] ? "created" : "existing", l["short_url"], *ShortLinkText.utm(l)] }
68
+ created = links.count { |link| link["created"] }
69
+ "#{Format.table(['STATUS', 'SHORT URL', *UTM_HEADERS], rows)}\n" \
70
+ "#{created} created, #{links.size - created} already existed"
71
+ end
72
+
73
+ def channels(list)
74
+ list = list.compact.reject(&:empty?)
75
+ raise UsageError, "--channels needs at least one channel" if list.empty?
76
+
77
+ list
78
+ end
79
+
80
+ # A slug, or a short URL on any host: the last path segment, lowercased.
81
+ def slug_arg(opts)
82
+ slug = opts[:args].first.split("/").last.to_s.downcase
83
+ raise UsageError, "#{opts[:args].first} isn't a short link slug or URL" unless slug.match?(SLUG)
84
+
85
+ slug
86
+ end
87
+
88
+ # Checks the login's scope before sending anything, then makes carolus's
89
+ # refusals say what they mean for short links.
90
+ def carolus(scope, slug: nil, write: false)
91
+ require_scope(scope)
92
+ yield
93
+ rescue AuthError => e
94
+ raise unless e.code == "forbidden"
95
+
96
+ raise AuthError.new("#{e.message.delete_suffix('.')}. #{TEAM_NOTE}", code: "forbidden")
97
+ rescue NotFoundError
98
+ raise unless slug
99
+
100
+ raise NotFoundError, "No short link #{slug}"
101
+ rescue UnavailableError => e
102
+ raise unless write
103
+
104
+ raise UnavailableError, "#{e.message}. #{RETRY_NOTE}"
105
+ end
106
+
107
+ # A new login replaces the stored one, so the suggested line keeps the scopes it has.
108
+ def require_scope(scope)
109
+ credentials = store.read or raise AuthError, "Not logged in. Run kodeco login."
110
+ held = credentials["scope"].to_s.split
111
+ return if held.include?(scope)
112
+
113
+ raise AuthError.new("This needs the #{scope} scope, which your login doesn't have. " \
114
+ "Run: kodeco login --scopes \"#{[*held, scope].join(' ')}\"", code: "missing_scope")
115
+ end
116
+ end
117
+ end
118
+ end
@@ -3,11 +3,12 @@
3
3
  module Periscope
4
4
  # Each error carries the exit code the CLI returns for it.
5
5
  class Error < StandardError
6
- attr_reader :code
6
+ attr_reader :code, :details
7
7
 
8
- def initialize(message, code: default_code)
8
+ def initialize(message, code: default_code, details: nil)
9
9
  super(message)
10
10
  @code = code
11
+ @details = details unless details.respond_to?(:empty?) && details.empty?
11
12
  end
12
13
 
13
14
  def exit_code = 1
@@ -22,6 +22,11 @@ module Periscope
22
22
  error = body["error"]
23
23
  error.is_a?(Hash) ? error["message"] : body["error_description"]
24
24
  end
25
+
26
+ def error_details
27
+ error = body["error"]
28
+ error["details"] if error.is_a?(Hash)
29
+ end
25
30
  end
26
31
 
27
32
  NETWORK_ERRORS = [SocketError, SystemCallError, IOError, Timeout::Error,
@@ -44,6 +49,10 @@ module Periscope
44
49
  request(Net::HTTP::Post, url, headers, JSON.generate(payload), "application/json")
45
50
  end
46
51
 
52
+ def patch_json(url, payload, headers = {})
53
+ request(Net::HTTP::Patch, url, headers, JSON.generate(payload), "application/json")
54
+ end
55
+
47
56
  private
48
57
 
49
58
  def request(klass, url, headers, body = nil, content_type = nil)
@@ -3,7 +3,7 @@
3
3
  module Periscope
4
4
  # The OAuth device-code flow against guardpost.
5
5
  class Login
6
- DEFAULT_SCOPES = "users:read support:read orders:read learning:read flags:read"
6
+ DEFAULT_SCOPES = "users:read support:read orders:read learning:read flags:read shortlinks:read"
7
7
  DEVICE_GRANT = "urn:ietf:params:oauth:grant-type:device_code"
8
8
  SCOPE_HINT = "If you lack a grant for one of the scopes, retry with --scopes and leave it out."
9
9
 
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Periscope
4
+ # Renders one of carolus's short links for people. Admins get extra click
5
+ # counts and the daily and bot sections; team members get neither.
6
+ class ShortLinkText
7
+ INACTIVE = "is now inactive. Visitors go to the kodeco.com home page."
8
+
9
+ # The UTM source, medium and campaign, with empty values as nil so tables print "-".
10
+ def self.utm(link)
11
+ %w[utm_source utm_medium utm_campaign].map { |key| link[key].to_s.empty? ? nil : link[key] }
12
+ end
13
+
14
+ def initialize(link)
15
+ @link = link
16
+ end
17
+
18
+ def to_s
19
+ ["#{@link['short_url']} (#{@link['active'] ? 'active' : 'inactive'})", *fields, *daily, *bots].join("\n")
20
+ end
21
+
22
+ # After a PATCH, which sends neither daily clicks nor bots.
23
+ def updated
24
+ ["#{@link['short_url']} #{@link['active'] ? 'is now active.' : INACTIVE}", *fields].join("\n")
25
+ end
26
+
27
+ private
28
+
29
+ def fields
30
+ [["Destination", @link["destination_url"]], ["Full URL", @link["full_destination_url"]], ["UTM", utm],
31
+ ["Created", "#{@link['created_at']} by #{@link['created_by'] || '-'}"], ["Updated", @link["updated_at"]],
32
+ ["Clicks", clicks]].map do |label, value|
33
+ format(" %-13<label>s%<value>s", label: "#{label}:", value: value.nil? ? "-" : value)
34
+ end
35
+ end
36
+
37
+ def utm
38
+ values = self.class.utm(@link)
39
+ values.all?(&:nil?) ? "none" : values.map { |v| v || "-" }.join(" / ")
40
+ end
41
+
42
+ def clicks
43
+ extra = { "7 days" => "clicks_7d", "30 days" => "clicks_30d", "bots" => "bot_clicks" }
44
+ .filter_map { |label, key| "#{label} #{@link[key]}" if @link.key?(key) }
45
+ extra.empty? ? @link["clicks"].to_s : "#{@link['clicks']} (#{extra.join(', ')})"
46
+ end
47
+
48
+ def daily
49
+ return [] unless @link.key?("daily_clicks")
50
+
51
+ rows = Array(@link["daily_clicks"]).last(14).map { |day| [day["date"], day["clicks"]] }
52
+ ["", "Daily clicks, last 14 days", Format.table(%w[DATE CLICKS], rows)]
53
+ end
54
+
55
+ def bots
56
+ return [] unless @link.key?("bot_breakdown")
57
+
58
+ rows = Array(@link["bot_breakdown"]).map { |bot| [bot["name"], bot["clicks"]] }
59
+ ["", "Bots", Format.table(%w[NAME CLICKS], rows)]
60
+ end
61
+ end
62
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Periscope
4
- VERSION = "0.1.1"
4
+ VERSION = "0.2.0"
5
5
  end
data/lib/periscope.rb CHANGED
@@ -11,7 +11,9 @@ require_relative "periscope/login"
11
11
  require_relative "periscope/user_lookup"
12
12
  require_relative "periscope/user_summary"
13
13
  require_relative "periscope/format"
14
+ require_relative "periscope/short_link_text"
14
15
  require_relative "periscope/commands/user"
15
16
  require_relative "periscope/commands/kerching"
16
17
  require_relative "periscope/commands/flags"
18
+ require_relative "periscope/commands/shortlinks"
17
19
  require_relative "periscope/cli"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: kodeco
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.1
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kodeco
@@ -22,6 +22,7 @@ files:
22
22
  - lib/periscope/client.rb
23
23
  - lib/periscope/commands/flags.rb
24
24
  - lib/periscope/commands/kerching.rb
25
+ - lib/periscope/commands/shortlinks.rb
25
26
  - lib/periscope/commands/user.rb
26
27
  - lib/periscope/config.rb
27
28
  - lib/periscope/credential_store.rb
@@ -30,6 +31,7 @@ files:
30
31
  - lib/periscope/http.rb
31
32
  - lib/periscope/login.rb
32
33
  - lib/periscope/session.rb
34
+ - lib/periscope/short_link_text.rb
33
35
  - lib/periscope/user_lookup.rb
34
36
  - lib/periscope/user_summary.rb
35
37
  - lib/periscope/version.rb