linkedin-member-data 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.
Files changed (51) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +9 -0
  3. data/CODE_OF_CONDUCT.md +10 -0
  4. data/LICENSE.txt +21 -0
  5. data/README.md +186 -0
  6. data/doc/CHANGELOG.md +9 -0
  7. data/doc/LinkedIn/MemberData/ApiError.md +52 -0
  8. data/doc/LinkedIn/MemberData/Authorization.md +30 -0
  9. data/doc/LinkedIn/MemberData/Changelog/Page.md +28 -0
  10. data/doc/LinkedIn/MemberData/Changelog.md +96 -0
  11. data/doc/LinkedIn/MemberData/Client.md +97 -0
  12. data/doc/LinkedIn/MemberData/ConfigurationError.md +8 -0
  13. data/doc/LinkedIn/MemberData/ConnectionError.md +15 -0
  14. data/doc/LinkedIn/MemberData/Domains.md +22 -0
  15. data/doc/LinkedIn/MemberData/Error.md +8 -0
  16. data/doc/LinkedIn/MemberData/Event.md +90 -0
  17. data/doc/LinkedIn/MemberData/Forbidden.md +8 -0
  18. data/doc/LinkedIn/MemberData/NotFound.md +8 -0
  19. data/doc/LinkedIn/MemberData/RateLimited.md +25 -0
  20. data/doc/LinkedIn/MemberData/ServerError.md +12 -0
  21. data/doc/LinkedIn/MemberData/Snapshot/Page.md +43 -0
  22. data/doc/LinkedIn/MemberData/Snapshot.md +90 -0
  23. data/doc/LinkedIn/MemberData/Unauthorized.md +8 -0
  24. data/doc/LinkedIn/MemberData/VersionError.md +8 -0
  25. data/doc/LinkedIn/MemberData.md +44 -0
  26. data/doc/LinkedIn.md +7 -0
  27. data/doc/README.md +186 -0
  28. data/exe/linkedin-member-data +6 -0
  29. data/lib/linkedin/member_data/authorization.rb +30 -0
  30. data/lib/linkedin/member_data/changelog/page.rb +28 -0
  31. data/lib/linkedin/member_data/changelog.rb +146 -0
  32. data/lib/linkedin/member_data/cli/changelog_command.rb +27 -0
  33. data/lib/linkedin/member_data/cli/command.rb +59 -0
  34. data/lib/linkedin/member_data/cli/global_options.rb +27 -0
  35. data/lib/linkedin/member_data/cli/simple_commands.rb +48 -0
  36. data/lib/linkedin/member_data/cli/since_parser.rb +36 -0
  37. data/lib/linkedin/member_data/cli/snapshot_command.rb +85 -0
  38. data/lib/linkedin/member_data/cli/support.rb +89 -0
  39. data/lib/linkedin/member_data/cli.rb +115 -0
  40. data/lib/linkedin/member_data/client.rb +107 -0
  41. data/lib/linkedin/member_data/connection.rb +165 -0
  42. data/lib/linkedin/member_data/domains.rb +43 -0
  43. data/lib/linkedin/member_data/errors.rb +154 -0
  44. data/lib/linkedin/member_data/event.rb +101 -0
  45. data/lib/linkedin/member_data/snapshot/page.rb +53 -0
  46. data/lib/linkedin/member_data/snapshot.rb +114 -0
  47. data/lib/linkedin/member_data/util.rb +52 -0
  48. data/lib/linkedin/member_data/version.rb +16 -0
  49. data/lib/linkedin/member_data.rb +12 -0
  50. data/llms.txt +44 -0
  51. metadata +95 -0
@@ -0,0 +1,146 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "changelog/page"
4
+
5
+ module LinkedIn
6
+ module MemberData
7
+ # Lazy view over `GET /rest/memberChangeLogs` (last 28 days).
8
+ # Nothing is fetched until you iterate. Each iteration starts a new walk.
9
+ #
10
+ # The cursor is `startTime` = last `processedAt` seen. LinkedIn returns the
11
+ # cursor event again on the next page, so events already seen are skipped
12
+ # and iteration stops when a page brings nothing new, or when the last event
13
+ # has no processedAt (the cursor cannot move).
14
+ #
15
+ # Known limit: if more than `count` events share one processedAt, the ones
16
+ # beyond the first page are not reachable through the cursor.
17
+ # A higher `count` (max 50) makes this less likely.
18
+ #
19
+ # It includes `Enumerable`, so `first`, `to_a`, `lazy`, `select` and the rest work on events.
20
+ #
21
+ # @example Print recent events
22
+ # client.changelog(since: Time.now - 7 * 86_400).each do |event|
23
+ # puts "#{event.method} #{event.resource_name} at #{event.processed_at}"
24
+ # end
25
+ class Changelog
26
+ include Enumerable
27
+
28
+ # API path of the changelog resource.
29
+ # @return [String]
30
+ PATH = "/rest/memberChangeLogs"
31
+
32
+ # Allowed values for `count`.
33
+ # @return [Range<Integer>]
34
+ COUNT_RANGE = (1..50)
35
+
36
+ # Default for `count`.
37
+ # @return [Integer]
38
+ DEFAULT_COUNT = 10
39
+
40
+ # Cursor state of one walk. A new one is built for every iteration.
41
+ # @api private
42
+ Walk = Struct.new(:start_time, :seen_ids) do
43
+ # `page` is an undeduped Page. Returns a Page with only the fresh events
44
+ # and moves the cursor; nil when nothing is new.
45
+ def take(page)
46
+ fresh = fresh_events(page)
47
+ return if fresh.empty?
48
+
49
+ advance(page)
50
+ Page.new(events: fresh, raw: page.raw)
51
+ end
52
+
53
+ # Events of `page` whose id was not on the previous page.
54
+ # @api private
55
+ def fresh_events(page) = page.events.reject { |event| seen_ids.include?(event.id) }
56
+
57
+ # Yields each start_time to the block to fetch a Page. Stops at the first
58
+ # page with nothing new or when the cursor cannot move.
59
+ def run(yielder)
60
+ while (fresh_page = take(yield(start_time)))
61
+ yielder << fresh_page
62
+ break if start_time.nil?
63
+ end
64
+ end
65
+
66
+ # Only the previous page is remembered: that is the documented overlap.
67
+ def advance(page)
68
+ self.seen_ids = page.events.map(&:id)
69
+ self.start_time = page.next_start_time
70
+ end
71
+ end
72
+ private_constant :Walk
73
+
74
+ # Settings of this view. `since` is the first `processedAt` to fetch, in epoch milliseconds
75
+ # (`nil` starts at the oldest event). `count` is the number of events per request, from 1 to 50.
76
+ # @return [Integer, nil] `since` or `count`. `count` is never nil.
77
+ attr_reader :since, :count
78
+
79
+ # @api private
80
+ # @param connection [Connection] used for every request.
81
+ # @param since [Time, Date, Integer, nil] first `processedAt` to fetch.
82
+ # Integer is epoch milliseconds. A Date is midnight UTC.
83
+ # @param count [Integer] events per request, from 1 to 50.
84
+ # @raise [ArgumentError] when `count` is outside 1..50 or `since` has an unsupported type.
85
+ # Raised before any request.
86
+ def initialize(connection, since: nil, count: DEFAULT_COUNT)
87
+ @count = count
88
+ ensure_count
89
+ @connection = connection
90
+ @since = Util.epoch_ms(since)
91
+ end
92
+
93
+ # Yields every new event of every page, oldest first. Pages are fetched one by one while you iterate.
94
+ #
95
+ # @example
96
+ # client.changelog(count: 50).each { |event| puts event.id }
97
+ # @example Without a block, you get an Enumerator
98
+ # client.changelog.each.first(5)
99
+ # @yieldparam event [Event] one changelog event. Events already seen in the overlap are skipped.
100
+ # @return [Changelog] self, when a block is given.
101
+ # @return [Enumerator<Event>] when no block is given.
102
+ # @raise [ApiError] on a non-2xx response.
103
+ # @raise [ConnectionError] on a network failure after all retries.
104
+ def each(&block)
105
+ return enum_for(:each) unless block
106
+
107
+ pages.each { |page| page.events.each(&block) }
108
+ self
109
+ end
110
+
111
+ # Lazy Enumerator of pages with already-seen events removed.
112
+ # Stops on the first page with no new events, or when the last event has no `processedAt`.
113
+ # A yielded Page has filtered `events` but the unfiltered `raw` response.
114
+ #
115
+ # @example Cursor of each page
116
+ # client.changelog.pages.each { |page| puts "#{page.events.size} events, next #{page.next_start_time}" }
117
+ # @return [Enumerator<Changelog::Page>]
118
+ # @raise [ApiError] while iterating, on a non-2xx response.
119
+ # @raise [ConnectionError] while iterating, on a network failure after all retries.
120
+ def pages = Enumerator.new { |yielder| each_page(yielder) }
121
+
122
+ # Fetches one raw page. No overlap handling, so the cursor event comes back again.
123
+ #
124
+ # @param start_time [Integer, nil] cursor in epoch milliseconds. `nil` starts at the oldest event.
125
+ # @return [Changelog::Page]
126
+ # @raise [ApiError] on a non-2xx response.
127
+ # @raise [ConnectionError] on a network failure after all retries.
128
+ def page(start_time)
129
+ params = { q: "memberAndApplication", count: count, startTime: start_time }
130
+ Page.from_api(@connection.get(PATH, params))
131
+ end
132
+
133
+ private
134
+
135
+ def each_page(yielder)
136
+ Walk.new(since, []).run(yielder) { |start_time| page(start_time) }
137
+ end
138
+
139
+ def ensure_count
140
+ return if count.is_a?(Integer) && COUNT_RANGE.cover?(count)
141
+
142
+ raise ArgumentError, "count must be between #{COUNT_RANGE.min} and #{COUNT_RANGE.max}, got #{count.inspect}"
143
+ end
144
+ end
145
+ end
146
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LinkedIn
4
+ module MemberData
5
+ class CLI
6
+ # Raw changelog events as a JSON array.
7
+ # @api private
8
+ class ChangelogCommand < Command
9
+ # @return [Array<Array>]
10
+ OPTIONS = [
11
+ ["--since DATE", SinceParser, "ISO date or datetime"],
12
+ ["--count N", Integer, "Events per request (1..50)"],
13
+ ["--out FILE", "Write to FILE instead of stdout"]
14
+ ].freeze
15
+
16
+ private
17
+
18
+ def execute
19
+ output.progress("Fetching changelog...")
20
+ events = client.changelog(**options.slice(:since, :count)).map(&:raw)
21
+ output.write(events, options[:out])
22
+ 0
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+
5
+ module LinkedIn
6
+ module MemberData
7
+ class CLI
8
+ # Base for commands. A command parses its own options, then runs.
9
+ # `run` returns the exit code.
10
+ # @api private
11
+ class Command
12
+ # Option specs for OptionParser#on. Subclasses override.
13
+ # @return [Array<Array>]
14
+ OPTIONS = [].freeze
15
+ # Positional arguments the command accepts.
16
+ # @return [Integer]
17
+ MAX_ARGUMENTS = 0
18
+
19
+ # @param context [Context]
20
+ # @param argv [Array<String>] arguments after the command name. Options are removed from it.
21
+ def initialize(context, argv)
22
+ @context = context
23
+ @argv = argv
24
+ @options = {}
25
+ end
26
+
27
+ # Parses options, checks arguments and executes.
28
+ # @return [Integer] exit code.
29
+ # @raise [UsageError] on bad arguments.
30
+ def run
31
+ parser.parse!(argv, into: options)
32
+ context.use_token(options[:token]) if options.key?(:token)
33
+ check_arguments
34
+ execute
35
+ end
36
+
37
+ private
38
+
39
+ attr_reader :context, :argv, :options
40
+
41
+ def check_arguments
42
+ extra = argv.drop(self.class::MAX_ARGUMENTS)
43
+ raise UsageError, "unexpected argument: #{extra.first}" unless extra.empty?
44
+ end
45
+
46
+ def output = context.output
47
+
48
+ def client = context.client
49
+
50
+ def parser
51
+ parser = OptionParser.new("Usage: linkedin-member-data COMMAND [options]")
52
+ parser.on("--token TOKEN")
53
+ self.class::OPTIONS.each { |spec| parser.on(*spec) }
54
+ parser
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+
5
+ module LinkedIn
6
+ module MemberData
7
+ class CLI
8
+ # Options that come before the command name: --token and --help.
9
+ # @api private
10
+ class GlobalOptions
11
+ # @param argv [Array<String>] arguments. Global options are removed from it.
12
+ # @return [Hash{Symbol => Object}] options found, for example `{ token: "...", help: true }`.
13
+ # @raise [OptionParser::ParseError] on an unknown option.
14
+ def self.parse(argv) = {}.tap { |options| parser.order!(argv, into: options) }
15
+
16
+ def self.parser
17
+ OptionParser.new do |opts|
18
+ opts.on("--token TOKEN")
19
+ opts.on("-h", "--help")
20
+ end
21
+ end
22
+
23
+ private_class_method :parser
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LinkedIn
4
+ module MemberData
5
+ class CLI
6
+ # Prints the gem version.
7
+ # @api private
8
+ class VersionCommand < Command
9
+ private
10
+
11
+ def execute
12
+ output.line(VERSION)
13
+ 0
14
+ end
15
+ end
16
+
17
+ # Prints one snapshot domain per line. Needs no token.
18
+ # @api private
19
+ class DomainsCommand < Command
20
+ private
21
+
22
+ def execute
23
+ output.line(Domains::ALL)
24
+ 0
25
+ end
26
+ end
27
+
28
+ # Prints the authorization of the token as JSON.
29
+ # @api private
30
+ class AuthCommand < Command
31
+ private
32
+
33
+ def execute
34
+ authorization = client.authorization
35
+ return missing if authorization.nil?
36
+
37
+ output.write(authorization.raw)
38
+ 0
39
+ end
40
+
41
+ def missing
42
+ output.error("No authorization found for this token")
43
+ 1
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+ require "date"
5
+ require "optparse"
6
+
7
+ module LinkedIn
8
+ module MemberData
9
+ class CLI
10
+ # Reads --since: an ISO datetime, or an ISO date (midnight UTC).
11
+ # @api private
12
+ class SinceParser
13
+ # @param value [String] ISO 8601 datetime or date.
14
+ # @return [Time]
15
+ # @raise [UsageError] when `value` is neither.
16
+ def self.call(value)
17
+ Time.iso8601(value)
18
+ rescue ArgumentError
19
+ from_date(value)
20
+ end
21
+
22
+ def self.from_date(value)
23
+ date = Date.iso8601(value)
24
+ Time.utc(date.year, date.month, date.day)
25
+ rescue ArgumentError
26
+ raise UsageError, "--since must be an ISO date or datetime, got #{value.inspect}"
27
+ end
28
+
29
+ private_class_method :from_date
30
+
31
+ # parser.on(..., SinceParser, ...) in ChangelogCommand relies on this.
32
+ OptionParser.accept(self) { |value| call(value) }
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module LinkedIn
6
+ module MemberData
7
+ class CLI
8
+ # One domain to a file or stdout, or every domain into a directory.
9
+ # @api private
10
+ class SnapshotCommand < Command
11
+ # @return [Array<Array>]
12
+ OPTIONS = [
13
+ ["--all", "Download every domain"],
14
+ ["--out FILE", "Write to FILE instead of stdout"],
15
+ ["--out-dir DIR", "Directory for --all"]
16
+ ].freeze
17
+
18
+ # @return [Integer]
19
+ MAX_ARGUMENTS = 1
20
+
21
+ private
22
+
23
+ def check_arguments
24
+ super
25
+ options[:all] ? check_all : check_one
26
+ end
27
+
28
+ def check_all
29
+ raise UsageError, "--all cannot be used with --out" if options[:out]
30
+ raise UsageError, "--all takes no DOMAIN" unless argv.empty?
31
+ end
32
+
33
+ def check_one
34
+ raise UsageError, "--out-dir needs --all" if options[:"out-dir"]
35
+ end
36
+
37
+ def execute = options[:all] ? download_all : download_one
38
+
39
+ def download_one
40
+ domain = argv.shift
41
+ raise UsageError, "snapshot needs a DOMAIN (or --all)" if domain.nil?
42
+
43
+ output.write(fetch(domain.upcase), options[:out])
44
+ 0
45
+ end
46
+
47
+ # A failing domain is reported and the run goes on. Exit 1 at the end.
48
+ def download_all
49
+ FileUtils.mkdir_p(out_dir)
50
+ failed = Domains::ALL.reject { |domain| saved?(domain, out_dir) }
51
+ failed.empty? ? 0 : 1
52
+ end
53
+
54
+ # The key comes from OptionParser's long option name (--out-dir).
55
+ def out_dir = options.fetch(:"out-dir") { raise UsageError, "--all needs --out-dir DIR" }
56
+
57
+ # A bad token fails every domain the same way, so Unauthorized and
58
+ # Forbidden stop the run at once. Other errors are per domain.
59
+ def saved?(domain, dir)
60
+ written?(domain, dir)
61
+ rescue Unauthorized, Forbidden
62
+ raise
63
+ rescue ApiError, ConnectionError => error
64
+ !reported?(domain, error)
65
+ end
66
+
67
+ def written?(domain, dir)
68
+ output.write(fetch(domain), File.join(dir, "#{domain}.json"))
69
+ true
70
+ end
71
+
72
+ # Reports the failure on stderr; true means the domain was not saved.
73
+ def reported?(domain, error)
74
+ output.error("#{domain}: #{error.message}")
75
+ true
76
+ end
77
+
78
+ def fetch(domain)
79
+ output.progress("Fetching #{domain}...")
80
+ client.snapshot(domain).to_a
81
+ end
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module LinkedIn
6
+ module MemberData
7
+ class CLI
8
+ # Bad command line: unknown command, missing argument, bad value.
9
+ # @api private
10
+ class UsageError < StandardError; end
11
+
12
+ # Data goes to a file or stdout. Progress and errors go to stderr.
13
+ # @api private
14
+ class Output
15
+ # @param stdout [IO]
16
+ # @param stderr [IO]
17
+ def initialize(stdout, stderr)
18
+ @stdout = stdout
19
+ @stderr = stderr
20
+ end
21
+
22
+ # Files get the same bytes as stdout, including the final newline.
23
+ # @param data [Object] anything JSON can encode.
24
+ # @param path [String, nil] file to write. `nil` means stdout.
25
+ # @return [void]
26
+ def write(data, path = nil)
27
+ json = JSON.pretty_generate(data)
28
+ path ? File.write(path, "#{json}\n") : @stdout.puts(json)
29
+ end
30
+
31
+ # Prints a line to stdout.
32
+ # @param text [String, Array<String>]
33
+ # @return [void]
34
+ def line(text) = @stdout.puts(text)
35
+
36
+ # Prints a line to stderr.
37
+ # @param text [String]
38
+ # @return [void]
39
+ def error(text) = @stderr.puts(text)
40
+
41
+ alias progress error
42
+ end
43
+
44
+ # What every command needs: the output and a lazy client.
45
+ # The token is only looked up when a command asks for the client.
46
+ # @api private
47
+ class Context
48
+ # Name of the environment variable that holds the token.
49
+ # @return [String]
50
+ TOKEN_ENV = "LINKEDIN_ACCESS_TOKEN"
51
+
52
+ # @return [Output]
53
+ attr_reader :output
54
+
55
+ # @param output [Output]
56
+ # @param env [Hash, #[]] environment.
57
+ # @param client_factory [#call] takes a token and returns a `Client`.
58
+ # @param token [String, nil] token from `--token`.
59
+ def initialize(output:, env:, client_factory:, token:)
60
+ @output = output
61
+ @env = env
62
+ @client_factory = client_factory
63
+ @token = token
64
+ end
65
+
66
+ # The client, built on first use.
67
+ # @return [Client]
68
+ # @raise [ConfigurationError] when no token is set.
69
+ def client = @client ||= @client_factory.call(access_token)
70
+
71
+ # Replaces the token from `--token`.
72
+ # @param value [String]
73
+ # @return [String]
74
+ def use_token(value)
75
+ @token = value
76
+ end
77
+
78
+ private
79
+
80
+ def access_token
81
+ value = (@token || @env[TOKEN_ENV]).to_s.strip
82
+ raise ConfigurationError, "no access token: pass --token or set #{TOKEN_ENV}" if value.empty?
83
+
84
+ value
85
+ end
86
+ end
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+
5
+ require_relative "../member_data"
6
+
7
+ require_relative "cli/global_options"
8
+ require_relative "cli/support"
9
+ require_relative "cli/since_parser"
10
+ require_relative "cli/command"
11
+ require_relative "cli/simple_commands"
12
+ require_relative "cli/snapshot_command"
13
+ require_relative "cli/changelog_command"
14
+
15
+ module LinkedIn
16
+ module MemberData
17
+ # Command line entry point. Data goes to stdout or files, progress and
18
+ # errors go to stderr. Exit codes: 0 ok, 1 API error, 2 usage error.
19
+ # @api private
20
+ class CLI
21
+ # Command name => command class.
22
+ # @return [Hash{String => Class}]
23
+ COMMANDS = {
24
+ "snapshot" => SnapshotCommand,
25
+ "changelog" => ChangelogCommand,
26
+ "domains" => DomainsCommand,
27
+ "auth" => AuthCommand,
28
+ "version" => VersionCommand
29
+ }.freeze
30
+
31
+ # Builds a `Client` from a token.
32
+ # @return [Proc]
33
+ DEFAULT_CLIENT_FACTORY = ->(token) { Client.new(access_token: token) }
34
+
35
+ # Help text.
36
+ # @return [String]
37
+ USAGE = <<~TEXT
38
+ Usage: linkedin-member-data COMMAND [options]
39
+
40
+ Commands:
41
+ snapshot DOMAIN [--out FILE] Download one snapshot domain as a JSON array
42
+ snapshot --all --out-dir DIR Download every domain, one <DOMAIN>.json per domain
43
+ changelog [--since DATE] [--count N] [--out FILE]
44
+ Download changelog events (last 28 days)
45
+ domains List snapshot domains
46
+ auth Show authorization status as JSON
47
+ version Print the gem version
48
+
49
+ Options:
50
+ --token TOKEN Access token (or set LINKEDIN_ACCESS_TOKEN). Before or after the command.
51
+ -h, --help Show this help
52
+ TEXT
53
+
54
+ # @param argv [Array<String>] command line arguments. Not changed.
55
+ # @param stdout [IO] where data goes.
56
+ # @param stderr [IO] where progress and errors go.
57
+ # @param env [Hash, #[]] environment, read for `LINKEDIN_ACCESS_TOKEN`.
58
+ # @param client_factory [#call] takes a token and returns a `Client`.
59
+ def initialize(argv, stdout: $stdout, stderr: $stderr, env: ENV, client_factory: DEFAULT_CLIENT_FACTORY)
60
+ @argv = argv.dup
61
+ @output = Output.new(stdout, stderr)
62
+ @env = env
63
+ @client_factory = client_factory
64
+ end
65
+
66
+ # Runs the command.
67
+ # @return [Integer] exit code: 0 ok, 1 API, network or file error, 2 usage error.
68
+ def run
69
+ handling_errors { start }
70
+ end
71
+
72
+ private
73
+
74
+ def start
75
+ options = GlobalOptions.parse(@argv)
76
+ options[:help] ? help : dispatch(options[:token])
77
+ end
78
+
79
+ def handling_errors
80
+ yield
81
+ rescue UsageError, OptionParser::ParseError, ConfigurationError, ArgumentError => error
82
+ usage_failure(error)
83
+ rescue Error, SystemCallError => error
84
+ failure(error)
85
+ end
86
+
87
+ def dispatch(token)
88
+ name = @argv.shift
89
+ raise UsageError, "missing command" if name.nil?
90
+
91
+ COMMANDS.fetch(name) { raise UsageError, "unknown command: #{name}" }.new(context(token), @argv).run
92
+ end
93
+
94
+ def context(token)
95
+ Context.new(output: @output, env: @env, client_factory: @client_factory, token: token)
96
+ end
97
+
98
+ def help
99
+ @output.line(USAGE)
100
+ 0
101
+ end
102
+
103
+ def failure(error)
104
+ @output.error("error: #{error.message}")
105
+ 1
106
+ end
107
+
108
+ def usage_failure(error)
109
+ @output.error("error: #{error.message}")
110
+ @output.error(USAGE)
111
+ 2
112
+ end
113
+ end
114
+ end
115
+ end