sferik 0.0.1

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 (71) hide show
  1. checksums.yaml +7 -0
  2. data/.yardopts +5 -0
  3. data/CHANGELOG.md +72 -0
  4. data/LICENSE.md +16 -0
  5. data/README.md +517 -0
  6. data/exe/sferik +6 -0
  7. data/lib/sferik/api/code_endpoints.rb +37 -0
  8. data/lib/sferik/api/profile_endpoints.rb +113 -0
  9. data/lib/sferik/api/resume_endpoints.rb +41 -0
  10. data/lib/sferik/api/site_endpoints.rb +247 -0
  11. data/lib/sferik/api/talk_endpoints.rb +45 -0
  12. data/lib/sferik/api.rb +23 -0
  13. data/lib/sferik/block.rb +25 -0
  14. data/lib/sferik/body.rb +54 -0
  15. data/lib/sferik/cache.rb +361 -0
  16. data/lib/sferik/cli.rb +369 -0
  17. data/lib/sferik/client.rb +453 -0
  18. data/lib/sferik/collection.rb +82 -0
  19. data/lib/sferik/configuration.rb +129 -0
  20. data/lib/sferik/connections.rb +151 -0
  21. data/lib/sferik/contributions.rb +86 -0
  22. data/lib/sferik/day.rb +35 -0
  23. data/lib/sferik/dependency.rb +26 -0
  24. data/lib/sferik/deployment.rb +36 -0
  25. data/lib/sferik/errors.rb +234 -0
  26. data/lib/sferik/figure.rb +83 -0
  27. data/lib/sferik/finger.rb +74 -0
  28. data/lib/sferik/freshness.rb +54 -0
  29. data/lib/sferik/home/pages.rb +27 -0
  30. data/lib/sferik/home/profile.rb +51 -0
  31. data/lib/sferik/home/section.rb +27 -0
  32. data/lib/sferik/home.rb +38 -0
  33. data/lib/sferik/json_parsing.rb +28 -0
  34. data/lib/sferik/name_change.rb +49 -0
  35. data/lib/sferik/place.rb +33 -0
  36. data/lib/sferik/podcast.rb +41 -0
  37. data/lib/sferik/project.rb +49 -0
  38. data/lib/sferik/projects.rb +138 -0
  39. data/lib/sferik/push.rb +33 -0
  40. data/lib/sferik/resource.rb +373 -0
  41. data/lib/sferik/resume/award.rb +43 -0
  42. data/lib/sferik/resume/basics.rb +77 -0
  43. data/lib/sferik/resume/education.rb +59 -0
  44. data/lib/sferik/resume/location.rb +35 -0
  45. data/lib/sferik/resume/meta.rb +35 -0
  46. data/lib/sferik/resume/patent.rb +43 -0
  47. data/lib/sferik/resume/profile.rb +35 -0
  48. data/lib/sferik/resume/project.rb +27 -0
  49. data/lib/sferik/resume/skill.rb +27 -0
  50. data/lib/sferik/resume/speaking.rb +19 -0
  51. data/lib/sferik/resume/volunteer.rb +59 -0
  52. data/lib/sferik/resume/work.rb +51 -0
  53. data/lib/sferik/resume.rb +125 -0
  54. data/lib/sferik/session.rb +41 -0
  55. data/lib/sferik/social_profile.rb +49 -0
  56. data/lib/sferik/status/github.rb +38 -0
  57. data/lib/sferik/status/loaded.rb +48 -0
  58. data/lib/sferik/status.rb +33 -0
  59. data/lib/sferik/talk.rb +73 -0
  60. data/lib/sferik/talks.rb +79 -0
  61. data/lib/sferik/validation.rb +119 -0
  62. data/lib/sferik/version.rb +7 -0
  63. data/lib/sferik/web_finger/link.rb +46 -0
  64. data/lib/sferik/web_finger.rb +43 -0
  65. data/lib/sferik/who.rb +53 -0
  66. data/lib/sferik/whoami.rb +36 -0
  67. data/lib/sferik/wrapping.rb +114 -0
  68. data/lib/sferik.rb +246 -0
  69. data/sig/manifest.yaml +13 -0
  70. data/sig/sferik.rbs +825 -0
  71. metadata +118 -0
data/lib/sferik/cli.rb ADDED
@@ -0,0 +1,369 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+ require_relative "version"
5
+
6
+ module Sferik
7
+ # The sferik command: prints what the shell on sferik.net prints, in a real terminal
8
+ #
9
+ # Each command prints a resource of the site as text, the same text `curl sferik.net/finger` gets, or with --json
10
+ # as JSON. The resume also comes as a PDF with --pdf, and as LaTeX with --latex, and finger as a contact card with
11
+ # --vcard. The write command sends me what it reads from standard input, as the shell's write sferik does, and the
12
+ # check-in command logs in a terminal, as each browser tab on the site does, and prints its name: with --watch, it
13
+ # keeps it logged in until it's interrupted. It asks sferik.net, or a copy of the site at the URL that --host or
14
+ # the SFERIK_HOST environment variable names.
15
+ #
16
+ # @api public
17
+ # @example
18
+ # Sferik::CLI.new.run(["finger"]) # prints how to reach me, and returns 0
19
+ # Sferik::CLI.new.run(["talks", "--json"]) # prints my talks as JSON
20
+ # Sferik::CLI.new.run(["resume", "--pdf"]) # prints my resume as a PDF
21
+ # Sferik::CLI.new.run(["signature"]) # prints my motto
22
+ # Sferik::CLI.new.run(["write"]) # sends me a message, read from standard input
23
+ # Sferik::CLI.new.run(["check-in"]) # logs in a terminal, and prints its name
24
+ # Sferik::CLI.new.run(["check-in", "--watch"]) # and keeps it logged in, until it's interrupted
25
+ class CLI
26
+ # The commands, and the path of the resource each one prints
27
+ COMMANDS = {
28
+ "finger" => "/finger", "whoami" => "/whoami", "talks" => "/talks", "podcasts" => "/podcasts", "resume" => "/resume", "who" => "/who",
29
+ "contributions" => "/contributions", "src" => "/src", "name" => "/name", "dependency" => "/dependency"
30
+ }.freeze
31
+ private_constant :COMMANDS
32
+
33
+ # What sferik --help prints
34
+ USAGE = <<~TEXT
35
+ Usage: sferik [options] [command]
36
+
37
+ finger how to reach me
38
+ whoami who is this
39
+ talks my talks and podcasts
40
+ podcasts my podcast appearances
41
+ resume my resume, as a man page
42
+ contributions a year of GitHub contributions
43
+ src my open source projects
44
+ name my name change, as a git commit
45
+ dependency the xkcd comic, in words
46
+ who who's reading sferik.net
47
+ signature my motto
48
+ webfinger where sferik@sferik.net points to, as JSON
49
+ feed my talks, as an Atom feed
50
+ deployment which commit of the site is deployed, and when, as JSON
51
+ status whether GitHub answers the site with its token, as JSON
52
+ openapi the description of the site's API, as JSON
53
+ write send me a message, read from standard input
54
+ check-in log in a terminal, as a browser tab does, and print its name
55
+ help print this
56
+
57
+ With no command, sferik prints the home page.
58
+
59
+ Options:
60
+ --json print JSON, not text
61
+ --pdf print a PDF, for the resume: redirect it to a file
62
+ --latex print LaTeX, for the resume
63
+ --vcard print a contact card, for finger
64
+ --tty NAME for write: the terminal the message is from, as check-in names it
65
+ --token KEY for check-in: the terminal's token, to keep its name (random, by default)
66
+ --watch for check-in: stay logged in until interrupted (Ctrl-C)
67
+ --host URL ask a copy of the site at URL (or set SFERIK_HOST)
68
+ -h, --help print this
69
+ -v, --version print the version
70
+ TEXT
71
+ private_constant :USAGE
72
+
73
+ # What sferik write says before it reads a message from a terminal
74
+ PROMPT = "Type your message, then Ctrl-D to send it, or Ctrl-C to cancel. Include your email address if you'd like a reply.\n"
75
+ private_constant :PROMPT
76
+
77
+ # The options that name a format, and the media type each asks for
78
+ FORMATS = {"--json" => "application/json", "--pdf" => "application/pdf", "--latex" => "application/x-latex", "--vcard" => "text/vcard"}.freeze
79
+
80
+ # The options that are noted as they're given, and what each is noted as: its value, for one that takes a value,
81
+ # and true for one that takes none
82
+ VALUES = {"--tty NAME" => :tty, "--token KEY" => :token, "--host URL" => :host, "--watch" => :watch}.freeze
83
+
84
+ # The options that are for one command alone, and the command each is for
85
+ OWNERS = {tty: "write", token: "check-in", watch: "check-in"}.freeze
86
+
87
+ # What prints no resource of the site, by the command or option that asks for it, and the method that does each.
88
+ # Or what there is of the site in one format alone, which is printed as that: the method, then the path and the format
89
+ ACTIONS = {:usage => [:usage], "help" => [:usage], :version => [:version], "write" => [:write], "check-in" => [:check_in],
90
+ "feed" => [:only, "/talks.atom", "application/atom+xml"], "deployment" => [:only, "/version", "application/json"], "status" => [:only, "/status", "application/json"], "openapi" => [:only, "/openapi.json", "application/json"], "signature" => [:only, "/.signature", "text/plain"], "webfinger" => [:only, "/.well-known/webfinger?resource=acct%3Asferik%40sferik.net", "application/jrd+json"]}.freeze
91
+ private_constant :FORMATS, :VALUES, :OWNERS, :ACTIONS
92
+
93
+ # Initialize a new CLI
94
+ #
95
+ # @api public
96
+ # @param client [#new, nil] what makes the client for a host: the {Client} class, unless something else is given
97
+ # @param input [IO] where sferik write reads a message from
98
+ # @param out [IO] where output goes
99
+ # @param err [IO] where errors go
100
+ # @param env [#fetch] the environment, for SFERIK_HOST
101
+ # @return [CLI] a new instance
102
+ # @example
103
+ # Sferik::CLI.new(env: {"SFERIK_HOST" => "http://localhost:3745"})
104
+ def initialize(client: nil, input: $stdin, out: $stdout, err: $stderr, env: ENV)
105
+ @client = client
106
+ @input = input
107
+ @out = out
108
+ @err = err
109
+ @env = env
110
+ end
111
+
112
+ # Run a command
113
+ #
114
+ # @api public
115
+ # @param argv [Array<String>] the command line, without the program's name
116
+ # @return [Integer] the exit status: 0, 1 for a request that fails, 2 for a command line that's wrong (an unknown
117
+ # command or option, more than one format, a format for what prints no resource, or an option for another
118
+ # command), or 130 when interrupted, as check-in --watch always is
119
+ # @example
120
+ # Sferik::CLI.new.run(["talks"]) # => 0
121
+ def run(argv)
122
+ dispatch(argv)
123
+ rescue OptionParser::ParseError => e
124
+ misuse(e)
125
+ rescue Errno::EPIPE # what reads the output (head, say) has all it wants
126
+ 0
127
+ rescue Interrupt
128
+ 130
129
+ end
130
+
131
+ private
132
+
133
+ # Run the command a command line names
134
+ #
135
+ # @api private
136
+ # @param argv [Array<String>] the command line
137
+ # @return [Integer] the exit status
138
+ # @raise [OptionParser::ParseError] if an option is unknown, or --host has no URL
139
+ def dispatch(argv)
140
+ options = {accept: []} #: Hash[Symbol, untyped]
141
+ command, extra = parser(options).permute(argv) # parse would take no option after the command with POSIXLY_CORRECT set
142
+ return misuse("unexpected argument: #{extra}") if extra
143
+
144
+ option, owner = OWNERS.find { |name, its| options.key?(name) && !its.eql?(command) }
145
+ option ? misuse("--#{option} is for #{owner}") : perform(command, options)
146
+ end
147
+
148
+ # Do what a command line asks for
149
+ #
150
+ # That's to print a resource, or what an option or a command that prints none names.
151
+ #
152
+ # @api private
153
+ # @param command [String, nil] the command, or nil for the home page
154
+ # @param options [Hash{Symbol => Object}] the options of the command line
155
+ # @return [Integer] the exit status
156
+ def perform(command, options)
157
+ action = ACTIONS[options.fetch(:print, command)]
158
+ return unformatted(options) { __send__(*action, options) } if action
159
+
160
+ path = command ? COMMANDS[command] : "/"
161
+ path ? show(path, options) : misuse("unknown command: #{command}")
162
+ end
163
+
164
+ # Print the usage
165
+ #
166
+ # @api private
167
+ # @param _options [Hash{Symbol => Object}] the options of the command line, which make no difference
168
+ # @return [Integer] the exit status
169
+ def usage(_options) = say(USAGE)
170
+
171
+ # Print the version
172
+ #
173
+ # @api private
174
+ # @param _options [Hash{Symbol => Object}] the options of the command line, which make no difference
175
+ # @return [Integer] the exit status
176
+ def version(_options) = say("#{VERSION}\n")
177
+
178
+ # What reads the options of a command line
179
+ #
180
+ # @api private
181
+ # @param options [Hash{Symbol => Object}] where to note the options: the media types to :accept, which it adds each
182
+ # format named to, the :host to ask, what to :print instead of a resource (:usage or :version), the :tty a
183
+ # message is from, the :token to check in with, and whether to :watch, which is to stay logged in
184
+ # @return [OptionParser] the parser
185
+ def parser(options)
186
+ OptionParser.new do |flags|
187
+ FORMATS.each { |flag, type| flags.on(flag) { options[:accept] |= [type] } }
188
+ VALUES.each { |flag, name| flags.on(flag) { |value| options[name] = value } }
189
+ flags.on("-h", "--help") { options[:print] = :usage }
190
+ flags.on("-v", "--version") { options[:print] = :version }
191
+ end
192
+ end
193
+
194
+ # Print a resource of the site, as text or in the format an option names
195
+ #
196
+ # A PDF is printed as it is: Windows would otherwise write each of its line feeds as a carriage return and one.
197
+ # It isn't printed to a terminal, which its bytes would garble, and isn't asked for either, when that's where
198
+ # it would go. A resource comes in one format at a time, so options that name two are the command line's mistake.
199
+ #
200
+ # @api private
201
+ # @param path [String] the resource's path
202
+ # @param options [Hash{Symbol => Object}] the options of the command line
203
+ # @return [Integer] the exit status
204
+ def show(path, options)
205
+ return misuse("pick one format: --json, --pdf, --latex, or --vcard") if options.fetch(:accept).size > 1
206
+
207
+ accept = [*options.fetch(:accept), "text/plain"].first
208
+ ask(options) { |client| client.get(path, accept: accept.eql?(FORMATS.fetch("--pdf")) ? redirected(accept) : accept) }
209
+ end
210
+
211
+ # Do what takes no format, since it prints no resource of the site
212
+ #
213
+ # That's the usage, the version, sending a message, and checking in: an option that names a format for one of them is the
214
+ # command line's mistake, and nothing is done.
215
+ #
216
+ # @api private
217
+ # @param options [Hash{Symbol => Object}] the options of the command line
218
+ # @yield what to do, if no option names a format
219
+ # @yieldreturn [Integer] the exit status
220
+ # @return [Integer] the exit status
221
+ def unformatted(options) = options.fetch(:accept).any? ? misuse("--json, --pdf, --latex, and --vcard are for the commands that print a resource") : yield
222
+
223
+ # Send me the message that standard input has, and print what the server says
224
+ #
225
+ # A terminal is told how to end the message first, on standard error, so that the output is the server's alone.
226
+ # With --tty, the message says which terminal it's from.
227
+ #
228
+ # @api private
229
+ # @param options [Hash{Symbol => Object}] the options of the command line
230
+ # @return [Integer] the exit status
231
+ def write(options)
232
+ @err.print(PROMPT) if @input.tty?
233
+ ask(options) { |client| "#{client.write(@input.read, tty: options[:tty])}\n" }
234
+ end
235
+
236
+ # Log in a terminal, as each browser tab on the site does, and print its name
237
+ #
238
+ # The name is what write's --tty takes. A terminal keeps it for as long as it checks in with the same token, at
239
+ # least every three minutes: without --token, each check-in is a new terminal's. With --watch, the command goes
240
+ # on checking it in, every minute, until it's interrupted.
241
+ #
242
+ # @api private
243
+ # @param options [Hash{Symbol => Object}] the options of the command line
244
+ # @return [Integer] the exit status: 1 if every terminal is taken
245
+ def check_in(options)
246
+ ask(options) { |client| options.key?(:watch) ? stay(client, token(options)) : named(client.check_in(token(options))) }
247
+ end
248
+
249
+ # The token a terminal checks in with
250
+ #
251
+ # @api private
252
+ # @param options [Hash{Symbol => Object}] the options of the command line
253
+ # @return [String] the one --token gives, or else a random one, which is a new terminal's
254
+ def token(options) = options.fetch(:token) { SecureRandom.uuid }
255
+
256
+ # Log in a terminal, print its name, and keep it logged in until interrupted
257
+ #
258
+ # The name is printed at once, and not kept for the end, which never comes: the client checks the terminal in
259
+ # again every minute for as long as its block runs, and the block sleeps until Ctrl-C wakes it. It's flushed,
260
+ # too, since what reads it (a script that wants the name for write's --tty) may be waiting for it.
261
+ #
262
+ # @api private
263
+ # @param client [Client] the client
264
+ # @param token [String] the terminal's token
265
+ # @return [String] nothing more to print, if the sleep ends without an interrupt
266
+ # @raise [Error] if every terminal is taken
267
+ # @raise [Interrupt] when the command is interrupted, which is how it ends
268
+ def stay(client, token) = client.check_in(token) { |who| say(named(who)) && @out.flush && Kernel.sleep }.then { "" }
269
+
270
+ # The name of the terminal that checked in, as a line
271
+ #
272
+ # @api private
273
+ # @param who [Who] what the site answered the check-in with
274
+ # @return [String] the terminal's name, and a newline
275
+ # @raise [Error] if every terminal is taken
276
+ def named(who) = who.you ? "#{who.you}\n" : raise(Error, "every terminal is taken: try again in a few minutes")
277
+
278
+ # Print what there is of the site in one format alone
279
+ #
280
+ # That's the feed of talks, the deployed version, the API's description, the motto, and where the account points to.
281
+ #
282
+ # @api private
283
+ # @param path [String] the path
284
+ # @param accept [String] the media type it comes as
285
+ # @param options [Hash{Symbol => Object}] the options of the command line
286
+ # @return [Integer] the exit status
287
+ def only(path, accept, options) = ask(options) { |client| client.get(path, accept:) }
288
+
289
+ # Ask the site for something, and print its response, or what went wrong
290
+ #
291
+ # A host that isn't an http or https URL is the command line's mistake, or the environment's, not a request that
292
+ # failed, so it has the exit status of one. Only the client being built is taken for that: an ArgumentError from
293
+ # the request, or from printing its response, is a bug, and is raised as one.
294
+ #
295
+ # @api private
296
+ # @param options [Hash{Symbol => Object}] the options of the command line
297
+ # @yield [client] the request to make
298
+ # @yieldparam client [Client] a client for the host the options or the environment name, or else the configured one
299
+ # @yieldreturn [String] the body of the response
300
+ # @return [Integer] the exit status: 0, 1 for a request that fails, or 2 for a host that isn't a URL
301
+ # @raise [ArgumentError] if the request, or printing its response, raises one
302
+ def ask(options)
303
+ maker = clients # which loads the library, before anything here asks it for its host, or for an error
304
+ named = @env.fetch("SFERIK_HOST", "") # one that's set but empty names no host, as if it weren't set
305
+ host = options.fetch(:host) { named.empty? ? Sferik.host : named }
306
+ client = maker.new(host:)
307
+ print_body(yield(client))
308
+ rescue Error, ArgumentError => e
309
+ raise if e.is_a?(ArgumentError) && client # only one from building the client is about the host
310
+
311
+ @err.puts("sferik: #{e}")
312
+ e.is_a?(Error) ? 1 : 2
313
+ end
314
+
315
+ # What makes the client for a host, once the library is loaded
316
+ #
317
+ # @api private
318
+ # @return [#new] what the command was given to make clients with, or else the {Client} class
319
+ def clients = library.then { @client || Client }
320
+
321
+ # Load the library, when a command first asks the site for something
322
+ #
323
+ # Printing the usage or the version asks for nothing, and loading the client, with everything it's built of,
324
+ # takes about as long as the rest of either does: so only a command that needs it waits for it.
325
+ #
326
+ # @api private
327
+ # @return [Boolean] whether this is what loaded it: the client, its errors, and the configuration
328
+ def library = require_relative("../sferik")
329
+
330
+ # Print the body of a response: a binary one, as a PDF is, in binary mode
331
+ #
332
+ # @api private
333
+ # @param body [String] the body
334
+ # @return [Integer] the exit status
335
+ # @raise [Error] if the body is binary and the output is a terminal
336
+ def print_body(body)
337
+ redirected(@out).binmode if body.encoding.equal?(Encoding::BINARY)
338
+ say(body)
339
+ end
340
+
341
+ # What's for binary output, unless the output is a terminal: then this fails
342
+ #
343
+ # @api private
344
+ # @param binary [Object] what's for binary output: the media type to ask for it as, or where to print it
345
+ # @return [Object] what was given, if the output isn't a terminal
346
+ # @raise [Error] if it is
347
+ def redirected(binary) = @out.tty? ? raise(Error, "binary output would garble the terminal: redirect it to a file (sferik resume --pdf > resume.pdf)") : binary
348
+
349
+ # Print text
350
+ #
351
+ # @api private
352
+ # @param text [String] the text
353
+ # @return [Integer] the exit status: 0
354
+ def say(text)
355
+ @out.print(text)
356
+ 0
357
+ end
358
+
359
+ # Say what's wrong with a command line, then the usage
360
+ #
361
+ # @api private
362
+ # @param problem [#to_s] what's wrong: a message, or an error with one
363
+ # @return [Integer] the exit status: 2, which tells a command line that's wrong from a request that fails
364
+ def misuse(problem)
365
+ @err.print("sferik: #{problem}\n\n#{USAGE}")
366
+ 2
367
+ end
368
+ end
369
+ end