exploremyprofile 0.1.0 → 0.1.3
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 +4 -4
- data/lib/explore/cli/config_store.rb +57 -0
- data/lib/explore/cli.rb +229 -9
- data/lib/explore/profile_document.rb +280 -0
- data/lib/explore/version.rb +1 -1
- data/lib/explore.rb +1 -0
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: cf1d07c5a0807ab5f32cae3a4b3b6baec59accfd1f73d0ee07496861eb7c30ba
|
|
4
|
+
data.tar.gz: e2f8b53e1454516782505f2e85295e10e061bbe4161f165cf4bae22c44bc48c3
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 00c11864857331687dd632fbad67499942f8741a8fb873fd4f6948fb01d7fd05d87d309d5de63adc0fce60e57bc3283e56c402cc0f075f309a8223b569b9c537
|
|
7
|
+
data.tar.gz: 31cc9068c088690e1bd21e1d50f567678363a4c00dcf4cbb77635f8e2db08e6837cef9d05aa2ec41495e3fb4f1f85f180db7ea25aa9b8f8c1ab897f1b53c5645
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module Explore
|
|
7
|
+
module Cli
|
|
8
|
+
class ConfigStore
|
|
9
|
+
DEFAULT_RELATIVE_PATH = ".config/explore/credentials.json"
|
|
10
|
+
|
|
11
|
+
def initialize(env:, path: nil)
|
|
12
|
+
@env = env
|
|
13
|
+
@path = path || env["EXPLORE_CONFIG_PATH"] || default_path
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def load
|
|
17
|
+
return {} unless File.file?(path)
|
|
18
|
+
|
|
19
|
+
JSON.parse(File.read(path))
|
|
20
|
+
rescue JSON::ParserError => e
|
|
21
|
+
raise Error.new("Stored Explore credentials at #{path} are invalid JSON: #{e.message}", exit_code: 1)
|
|
22
|
+
rescue SystemCallError => e
|
|
23
|
+
raise Error.new("Could not read stored Explore credentials at #{path}: #{e.message}", exit_code: 1)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def save(config)
|
|
27
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
28
|
+
File.write(path, JSON.pretty_generate(config))
|
|
29
|
+
File.chmod(0o600, path)
|
|
30
|
+
path
|
|
31
|
+
rescue SystemCallError => e
|
|
32
|
+
raise Error.new("Could not save Explore credentials to #{path}: #{e.message}", exit_code: 1)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def delete
|
|
36
|
+
return false unless File.exist?(path)
|
|
37
|
+
|
|
38
|
+
File.delete(path)
|
|
39
|
+
true
|
|
40
|
+
rescue SystemCallError => e
|
|
41
|
+
raise Error.new("Could not remove stored Explore credentials at #{path}: #{e.message}", exit_code: 1)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
attr_reader :path
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
attr_reader :env
|
|
49
|
+
|
|
50
|
+
def default_path
|
|
51
|
+
base = env["XDG_CONFIG_HOME"].to_s
|
|
52
|
+
base = File.join(Dir.home, ".config") if base.empty?
|
|
53
|
+
File.join(base, "explore", "credentials.json")
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
data/lib/explore/cli.rb
CHANGED
|
@@ -2,6 +2,8 @@ require "json"
|
|
|
2
2
|
require "net/http"
|
|
3
3
|
require "optparse"
|
|
4
4
|
require "uri"
|
|
5
|
+
require "io/console"
|
|
6
|
+
require "explore/profile_document"
|
|
5
7
|
|
|
6
8
|
module Explore
|
|
7
9
|
module Cli
|
|
@@ -38,6 +40,10 @@ module Explore
|
|
|
38
40
|
request(Net::HTTP::Post, path, params:, json:)
|
|
39
41
|
end
|
|
40
42
|
|
|
43
|
+
def put(path, params: {}, json: nil)
|
|
44
|
+
request(Net::HTTP::Put, path, params:, json:)
|
|
45
|
+
end
|
|
46
|
+
|
|
41
47
|
def patch(path, params: {}, json: nil)
|
|
42
48
|
request(Net::HTTP::Patch, path, params:, json:)
|
|
43
49
|
end
|
|
@@ -78,20 +84,37 @@ module Explore
|
|
|
78
84
|
end
|
|
79
85
|
|
|
80
86
|
def owner_only_unauthorized_message
|
|
81
|
-
"Unauthorized: this is an owner-only command. Use a signed-in session or pass EXPLORE_API_KEY/--api-key. " \
|
|
82
|
-
"
|
|
87
|
+
"Unauthorized: this is an owner-only command. Use a signed-in session, run explore login --account <slug>, or pass EXPLORE_API_KEY/--api-key. " \
|
|
88
|
+
"#{owner_token_help_sentence} Then try explore whoami --json."
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def owner_token_help_sentence
|
|
92
|
+
if base_url.to_s != ""
|
|
93
|
+
"Create an account token in the Explore workspace at #{owner_tools_url}."
|
|
94
|
+
else
|
|
95
|
+
"Create an account token in the Explore workspace owner tools page (/account/owner-tools)."
|
|
96
|
+
end
|
|
97
|
+
rescue URI::InvalidURIError
|
|
98
|
+
"Create an account token in the Explore workspace owner tools page (/account/owner-tools)."
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def owner_tools_url
|
|
102
|
+
URI.join(base_url, "/account/owner-tools").to_s
|
|
83
103
|
end
|
|
84
104
|
end
|
|
85
105
|
|
|
86
106
|
class Runner
|
|
87
107
|
DEFAULT_BASE_URL = "http://127.0.0.1:3000".freeze
|
|
88
108
|
|
|
89
|
-
def initialize(argv:, env:, stdout:, stderr:, client_class: Client)
|
|
109
|
+
def initialize(argv:, env:, stdout:, stderr:, stdin: $stdin, client_class: Client, config_store: nil, secret_prompt: nil)
|
|
90
110
|
@argv = argv.dup
|
|
91
111
|
@env = env
|
|
92
112
|
@stdout = stdout
|
|
93
113
|
@stderr = stderr
|
|
114
|
+
@stdin = stdin
|
|
94
115
|
@client_class = client_class
|
|
116
|
+
@config_store = config_store || ConfigStore.new(env:)
|
|
117
|
+
@secret_prompt = secret_prompt
|
|
95
118
|
end
|
|
96
119
|
|
|
97
120
|
def call
|
|
@@ -102,10 +125,28 @@ module Explore
|
|
|
102
125
|
case command
|
|
103
126
|
when "version"
|
|
104
127
|
emit_version
|
|
128
|
+
when "login"
|
|
129
|
+
options = parse_options(argv, require_input: false, include_stored: false)
|
|
130
|
+
validate_whoami_scope!(options)
|
|
131
|
+
options[:api_key] = resolve_login_api_key!(options)
|
|
132
|
+
response = client(options).get("/api/agent/v1/profile", params: scope_params(options))
|
|
133
|
+
saved_path = config_store.save(login_payload(options, response))
|
|
134
|
+
emit(output_for_login(response, saved_path, options))
|
|
135
|
+
when "logout"
|
|
136
|
+
options = parse_options(argv, require_input: false, include_stored: false)
|
|
137
|
+
removed = config_store.delete
|
|
138
|
+
emit(output_for_logout(removed, options))
|
|
105
139
|
when "whoami"
|
|
106
140
|
options = parse_options(argv, require_input: false)
|
|
141
|
+
validate_whoami_scope!(options)
|
|
107
142
|
response = client(options).get("/api/agent/v1/profile", params: scope_params(options))
|
|
108
143
|
emit(output_for_whoami(response, options))
|
|
144
|
+
when "export"
|
|
145
|
+
execute_export(argv)
|
|
146
|
+
when "validate"
|
|
147
|
+
execute_validate(argv)
|
|
148
|
+
when "apply"
|
|
149
|
+
execute_apply(argv)
|
|
109
150
|
when "profile"
|
|
110
151
|
execute_read(argv.shift, "/api/agent/v1/profile")
|
|
111
152
|
when "content"
|
|
@@ -143,7 +184,60 @@ module Explore
|
|
|
143
184
|
|
|
144
185
|
private
|
|
145
186
|
|
|
146
|
-
attr_reader :argv, :env, :stdout, :stderr, :client_class
|
|
187
|
+
attr_reader :argv, :env, :stdout, :stderr, :stdin, :client_class
|
|
188
|
+
attr_reader :config_store
|
|
189
|
+
attr_reader :secret_prompt
|
|
190
|
+
|
|
191
|
+
def execute_export(arguments)
|
|
192
|
+
resource = arguments.shift
|
|
193
|
+
raise Error.new(usage, exit_code: 2) unless resource == "profile"
|
|
194
|
+
|
|
195
|
+
options = parse_options(arguments, require_input: false)
|
|
196
|
+
format = options.fetch(:format, "yaml")
|
|
197
|
+
raise Error.new("Only --format yaml is currently supported", exit_code: 2) unless format == "yaml"
|
|
198
|
+
|
|
199
|
+
response = client(options).get("/api/agent/v1/profile/document", params: scope_params(options))
|
|
200
|
+
emit(::Explore::ProfileDocument.dump_yaml(response.fetch("document")))
|
|
201
|
+
0
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def execute_validate(arguments)
|
|
205
|
+
path = arguments.shift
|
|
206
|
+
raise Error.new(usage, exit_code: 2) if blank_value?(path)
|
|
207
|
+
|
|
208
|
+
options = parse_options(arguments, require_input: false)
|
|
209
|
+
document = ::Explore::ProfileDocument.load_yaml_file(path)
|
|
210
|
+
result = ::Explore::ProfileDocument.validate(document)
|
|
211
|
+
emit_validation_result(result, options)
|
|
212
|
+
result[:ok] ? 0 : 1
|
|
213
|
+
rescue ::Explore::ProfileDocument::ValidationError => e
|
|
214
|
+
result = { ok: false, errors: e.errors }
|
|
215
|
+
emit_validation_result(result, options || { json: false })
|
|
216
|
+
1
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def execute_apply(arguments)
|
|
220
|
+
path = arguments.shift
|
|
221
|
+
raise Error.new(usage, exit_code: 2) if blank_value?(path)
|
|
222
|
+
|
|
223
|
+
options = parse_options(arguments, require_input: false)
|
|
224
|
+
document = ::Explore::ProfileDocument.load_yaml_file(path)
|
|
225
|
+
result = ::Explore::ProfileDocument.validate(document)
|
|
226
|
+
emit_validation_result(result, options) unless result[:ok]
|
|
227
|
+
return 1 unless result[:ok]
|
|
228
|
+
|
|
229
|
+
response = client(options).put(
|
|
230
|
+
"/api/agent/v1/profile/document",
|
|
231
|
+
params: scope_params(options),
|
|
232
|
+
json: { document: result.fetch(:document) }
|
|
233
|
+
)
|
|
234
|
+
emit(format_apply_output(response, options))
|
|
235
|
+
0
|
|
236
|
+
rescue ::Explore::ProfileDocument::ValidationError => e
|
|
237
|
+
result = { ok: false, errors: e.errors }
|
|
238
|
+
emit_validation_result(result, options || { json: false })
|
|
239
|
+
1
|
|
240
|
+
end
|
|
147
241
|
|
|
148
242
|
def execute_read(subcommand, path)
|
|
149
243
|
expected = {
|
|
@@ -241,11 +335,12 @@ module Explore
|
|
|
241
335
|
0
|
|
242
336
|
end
|
|
243
337
|
|
|
244
|
-
def parse_options(arguments, require_input:, require_draft_id: false)
|
|
338
|
+
def parse_options(arguments, require_input:, require_draft_id: false, include_stored: true)
|
|
339
|
+
stored = include_stored ? config_store.load : {}
|
|
245
340
|
options = {
|
|
246
|
-
base_url: env
|
|
247
|
-
api_key: env["EXPLORE_API_KEY"],
|
|
248
|
-
account: env["EXPLORE_ACCOUNT"],
|
|
341
|
+
base_url: env["EXPLORE_BASE_URL"] || stored["base_url"] || DEFAULT_BASE_URL,
|
|
342
|
+
api_key: env["EXPLORE_API_KEY"] || stored["api_key"],
|
|
343
|
+
account: env["EXPLORE_ACCOUNT"] || stored["account"],
|
|
249
344
|
slug: env["EXPLORE_SLUG"],
|
|
250
345
|
json: false
|
|
251
346
|
}
|
|
@@ -253,8 +348,10 @@ module Explore
|
|
|
253
348
|
parser = OptionParser.new do |opts|
|
|
254
349
|
opts.on("--base-url URL") { |value| options[:base_url] = value }
|
|
255
350
|
opts.on("--api-key TOKEN") { |value| options[:api_key] = value }
|
|
351
|
+
opts.on("--token-stdin") { options[:token_stdin] = true }
|
|
256
352
|
opts.on("--account ACCOUNT") { |value| options[:account] = value }
|
|
257
353
|
opts.on("--slug SLUG") { |value| options[:slug] = value }
|
|
354
|
+
opts.on("--format FORMAT") { |value| options[:format] = value }
|
|
258
355
|
opts.on("--draft ID") { |value| options[:draft_id] = value }
|
|
259
356
|
opts.on("--input PATH") { |value| options[:input] = value }
|
|
260
357
|
opts.on("--json") { options[:json] = true }
|
|
@@ -271,6 +368,68 @@ module Explore
|
|
|
271
368
|
client_class.new(base_url: options.fetch(:base_url), api_key: options[:api_key])
|
|
272
369
|
end
|
|
273
370
|
|
|
371
|
+
def resolve_login_api_key!(options)
|
|
372
|
+
return options[:api_key] if present_value?(options[:api_key])
|
|
373
|
+
|
|
374
|
+
if options[:token_stdin]
|
|
375
|
+
token = stdin.read.to_s.strip
|
|
376
|
+
return token if present_value?(token)
|
|
377
|
+
|
|
378
|
+
raise Error.new("No owner token was provided on stdin.", exit_code: 2)
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
token = prompt_for_secret_token
|
|
382
|
+
return token if present_value?(token)
|
|
383
|
+
|
|
384
|
+
raise Error.new(
|
|
385
|
+
"Missing owner token. Pass --api-key, pipe it with --token-stdin, set EXPLORE_API_KEY, or run explore login interactively in a TTY. " \
|
|
386
|
+
"#{login_owner_token_help_sentence(options.fetch(:base_url))}",
|
|
387
|
+
exit_code: 2
|
|
388
|
+
)
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
def prompt_for_secret_token
|
|
392
|
+
return secret_prompt.call if secret_prompt
|
|
393
|
+
return unless stdin.tty? && stdin.respond_to?(:noecho)
|
|
394
|
+
|
|
395
|
+
stderr.print("Explore owner token: ")
|
|
396
|
+
token = stdin.noecho(&:gets).to_s.strip
|
|
397
|
+
stderr.puts
|
|
398
|
+
token
|
|
399
|
+
end
|
|
400
|
+
|
|
401
|
+
def login_owner_token_help_sentence(base_url)
|
|
402
|
+
if base_url.to_s != ""
|
|
403
|
+
"Create an account token in the Explore workspace at #{login_owner_tools_url(base_url)}."
|
|
404
|
+
else
|
|
405
|
+
"Create an account token in the Explore workspace owner tools page (/account/owner-tools)."
|
|
406
|
+
end
|
|
407
|
+
rescue URI::InvalidURIError
|
|
408
|
+
"Create an account token in the Explore workspace owner tools page (/account/owner-tools)."
|
|
409
|
+
end
|
|
410
|
+
|
|
411
|
+
def login_owner_tools_url(base_url)
|
|
412
|
+
URI.join(base_url, "/account/owner-tools").to_s
|
|
413
|
+
end
|
|
414
|
+
|
|
415
|
+
def login_payload(options, response)
|
|
416
|
+
{
|
|
417
|
+
"base_url" => options.fetch(:base_url),
|
|
418
|
+
"api_key" => options.fetch(:api_key),
|
|
419
|
+
"account" => options[:account].to_s.empty? ? response.fetch("account_slug") : options.fetch(:account)
|
|
420
|
+
}
|
|
421
|
+
end
|
|
422
|
+
|
|
423
|
+
def validate_whoami_scope!(options)
|
|
424
|
+
return unless present_value?(options[:slug])
|
|
425
|
+
|
|
426
|
+
raise Error.new(
|
|
427
|
+
"explore whoami checks owner authentication and does not accept --slug. " \
|
|
428
|
+
"Use explore profile inspect --slug <slug> for public profile reads.",
|
|
429
|
+
exit_code: 2
|
|
430
|
+
)
|
|
431
|
+
end
|
|
432
|
+
|
|
274
433
|
def scope_params(options)
|
|
275
434
|
if present_value?(options[:slug])
|
|
276
435
|
{ slug: options[:slug] }
|
|
@@ -293,6 +452,7 @@ module Explore
|
|
|
293
452
|
return JSON.pretty_generate(response) if options[:json]
|
|
294
453
|
|
|
295
454
|
[
|
|
455
|
+
"Authenticated owner context confirmed.",
|
|
296
456
|
"Account: #{response.dig("profile", "name")}",
|
|
297
457
|
"Slug: #{response["account_slug"]}",
|
|
298
458
|
"Context: #{response.dig("context", "mode")}",
|
|
@@ -300,10 +460,44 @@ module Explore
|
|
|
300
460
|
].join("\n")
|
|
301
461
|
end
|
|
302
462
|
|
|
463
|
+
def output_for_login(response, saved_path, options)
|
|
464
|
+
payload = {
|
|
465
|
+
ok: true,
|
|
466
|
+
account_slug: response.fetch("account_slug"),
|
|
467
|
+
config_path: saved_path,
|
|
468
|
+
base_url: options.fetch(:base_url)
|
|
469
|
+
}
|
|
470
|
+
return JSON.pretty_generate(payload) if options[:json]
|
|
471
|
+
|
|
472
|
+
[
|
|
473
|
+
"Explore credentials saved.",
|
|
474
|
+
"Account: #{response.dig("profile", "name")}",
|
|
475
|
+
"Slug: #{response.fetch("account_slug")}",
|
|
476
|
+
"Base URL: #{options.fetch(:base_url)}",
|
|
477
|
+
"Config: #{saved_path}",
|
|
478
|
+
"Next: run explore whoami --json"
|
|
479
|
+
].join("\n")
|
|
480
|
+
end
|
|
481
|
+
|
|
482
|
+
def output_for_logout(removed, options)
|
|
483
|
+
payload = {
|
|
484
|
+
ok: true,
|
|
485
|
+
removed: removed,
|
|
486
|
+
config_path: config_store.path
|
|
487
|
+
}
|
|
488
|
+
return JSON.pretty_generate(payload) if options[:json]
|
|
489
|
+
|
|
490
|
+
removed ? "Removed stored Explore credentials from #{config_store.path}." : "No stored Explore credentials found at #{config_store.path}."
|
|
491
|
+
end
|
|
492
|
+
|
|
303
493
|
def emit(message)
|
|
304
494
|
stdout.puts(message)
|
|
305
495
|
end
|
|
306
496
|
|
|
497
|
+
def emit_validation_result(result, options)
|
|
498
|
+
emit(format_validation_output(result, options))
|
|
499
|
+
end
|
|
500
|
+
|
|
307
501
|
def emit_version
|
|
308
502
|
stdout.puts(Explore::VERSION)
|
|
309
503
|
0
|
|
@@ -325,7 +519,12 @@ module Explore
|
|
|
325
519
|
<<~TEXT
|
|
326
520
|
Usage:
|
|
327
521
|
explore version
|
|
328
|
-
explore
|
|
522
|
+
explore login [--api-key <token> | --token-stdin] [--account <id-or-slug>] [--base-url <url>] [--json]
|
|
523
|
+
explore logout [--json]
|
|
524
|
+
explore whoami [--account <id-or-slug>] [--json]
|
|
525
|
+
explore export profile [--account <id-or-slug>] [--format yaml]
|
|
526
|
+
explore validate <profile.yaml> [--json]
|
|
527
|
+
explore apply <profile.yaml> --account <id-or-slug> [--json]
|
|
329
528
|
explore profile inspect [--account <id-or-slug>] [--slug <slug>] [--json]
|
|
330
529
|
explore content list [--account <id-or-slug>] [--slug <slug>] [--json]
|
|
331
530
|
explore content create-draft --account <id-or-slug> --input <file.json> [--json]
|
|
@@ -342,6 +541,27 @@ module Explore
|
|
|
342
541
|
explore content propose-update --account <id-or-slug> --input <file.json> [--json]
|
|
343
542
|
TEXT
|
|
344
543
|
end
|
|
544
|
+
|
|
545
|
+
def format_validation_output(result, options)
|
|
546
|
+
return JSON.pretty_generate(result) if options[:json]
|
|
547
|
+
return "Profile document is valid." if result[:ok]
|
|
548
|
+
|
|
549
|
+
[ "Profile document is invalid:" ] + Array(result[:errors]).map do |entry|
|
|
550
|
+
"- #{entry.fetch("path")}: #{entry.fetch("message")}"
|
|
551
|
+
end.join("\n")
|
|
552
|
+
end
|
|
553
|
+
|
|
554
|
+
def format_apply_output(response, options)
|
|
555
|
+
return JSON.pretty_generate(response) if options[:json]
|
|
556
|
+
|
|
557
|
+
counts = response.fetch("applied_counts")
|
|
558
|
+
[
|
|
559
|
+
"Applied profile document for #{response.fetch("account_slug")}.",
|
|
560
|
+
"Projects: #{counts.fetch("projects")}",
|
|
561
|
+
"Experience: #{counts.fetch("experience")}",
|
|
562
|
+
"Writing: #{counts.fetch("writing")}"
|
|
563
|
+
].join("\n")
|
|
564
|
+
end
|
|
345
565
|
end
|
|
346
566
|
end
|
|
347
567
|
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
require "yaml"
|
|
2
|
+
|
|
3
|
+
module Explore
|
|
4
|
+
module ProfileDocument
|
|
5
|
+
class ValidationError < StandardError
|
|
6
|
+
attr_reader :errors
|
|
7
|
+
|
|
8
|
+
def initialize(errors)
|
|
9
|
+
@errors = errors
|
|
10
|
+
super("Invalid profile document")
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
def from_account(account)
|
|
17
|
+
{
|
|
18
|
+
"version" => 1,
|
|
19
|
+
"profile" => {
|
|
20
|
+
"name" => account.public_name.to_s,
|
|
21
|
+
"headline" => account.public_tagline.to_s,
|
|
22
|
+
"summary" => account.public_bio.to_s,
|
|
23
|
+
"location" => account.public_location.to_s
|
|
24
|
+
},
|
|
25
|
+
"links" => {
|
|
26
|
+
"email" => account.public_email.to_s,
|
|
27
|
+
"github_url" => account.public_github_url.to_s,
|
|
28
|
+
"linkedin_url" => account.public_linkedin_url.to_s,
|
|
29
|
+
"blog_url" => account.public_blog_url.presence || account.blog_url.to_s,
|
|
30
|
+
"twitter_url" => account.public_twitter_url.to_s
|
|
31
|
+
},
|
|
32
|
+
"projects" => account.account_case_studies.order(:position, :id).map do |project|
|
|
33
|
+
{
|
|
34
|
+
"title" => project.title.to_s,
|
|
35
|
+
"slug" => project.slug.to_s,
|
|
36
|
+
"summary" => project.summary.to_s,
|
|
37
|
+
"outcome" => project.outcome.to_s
|
|
38
|
+
}
|
|
39
|
+
end,
|
|
40
|
+
"experience" => account.account_experiences.order(:position, :id).map do |experience|
|
|
41
|
+
{
|
|
42
|
+
"company" => experience.company.to_s,
|
|
43
|
+
"role" => experience.role.to_s,
|
|
44
|
+
"location" => experience.location.to_s,
|
|
45
|
+
"start_date" => experience.start_date.to_s,
|
|
46
|
+
"end_date" => experience.end_date.to_s,
|
|
47
|
+
"current" => experience.current,
|
|
48
|
+
"summary" => experience.summary.to_s
|
|
49
|
+
}
|
|
50
|
+
end,
|
|
51
|
+
"writing" => account.account_posts.order(date: :desc, id: :desc).map do |post|
|
|
52
|
+
{
|
|
53
|
+
"title" => post.title.to_s,
|
|
54
|
+
"slug" => post.slug.to_s,
|
|
55
|
+
"date" => post.date.to_s,
|
|
56
|
+
"summary" => post.summary.to_s,
|
|
57
|
+
"url" => post.url.to_s
|
|
58
|
+
}
|
|
59
|
+
end
|
|
60
|
+
}
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def dump_yaml(document)
|
|
64
|
+
YAML.dump(document)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def load_yaml_file(path)
|
|
68
|
+
parsed = YAML.safe_load(File.read(path), permitted_classes: [ Date, Time ], aliases: false)
|
|
69
|
+
parsed.is_a?(Hash) ? parsed : {}
|
|
70
|
+
rescue Psych::SyntaxError => e
|
|
71
|
+
raise ValidationError.new([ error(path: "$", message: "Malformed YAML: #{e.message.lines.first.to_s.strip}") ])
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def validate(document)
|
|
75
|
+
normalized = normalize_hash(document)
|
|
76
|
+
errors = []
|
|
77
|
+
|
|
78
|
+
unless normalized["version"].to_i == 1
|
|
79
|
+
errors << error(path: "$.version", message: "must be 1")
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
profile = ensure_hash(normalized["profile"])
|
|
83
|
+
links = ensure_hash(normalized["links"])
|
|
84
|
+
projects = ensure_array(normalized["projects"])
|
|
85
|
+
experience = ensure_array(normalized["experience"])
|
|
86
|
+
writing = ensure_array(normalized["writing"])
|
|
87
|
+
|
|
88
|
+
errors.concat(validate_profile(profile))
|
|
89
|
+
errors.concat(validate_links(links))
|
|
90
|
+
errors.concat(validate_projects(projects))
|
|
91
|
+
errors.concat(validate_experience(experience))
|
|
92
|
+
errors.concat(validate_writing(writing))
|
|
93
|
+
|
|
94
|
+
{
|
|
95
|
+
ok: errors.empty?,
|
|
96
|
+
document: normalized.merge(
|
|
97
|
+
"profile" => profile,
|
|
98
|
+
"links" => links,
|
|
99
|
+
"projects" => projects,
|
|
100
|
+
"experience" => experience,
|
|
101
|
+
"writing" => writing
|
|
102
|
+
),
|
|
103
|
+
errors:
|
|
104
|
+
}
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def validate!(document)
|
|
108
|
+
result = validate(document)
|
|
109
|
+
raise ValidationError.new(result[:errors]) unless result[:ok]
|
|
110
|
+
|
|
111
|
+
result[:document]
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def error(path:, message:)
|
|
115
|
+
{ "path" => path, "message" => message }
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def normalize_hash(value)
|
|
119
|
+
case value
|
|
120
|
+
when Hash
|
|
121
|
+
value.each_with_object({}) { |(key, nested), memo| memo[key.to_s] = normalize_hash(nested) }
|
|
122
|
+
when Array
|
|
123
|
+
value.map { |item| normalize_hash(item) }
|
|
124
|
+
else
|
|
125
|
+
value
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
private_class_method :normalize_hash
|
|
129
|
+
|
|
130
|
+
def ensure_hash(value)
|
|
131
|
+
value.is_a?(Hash) ? normalize_hash(value) : {}
|
|
132
|
+
end
|
|
133
|
+
private_class_method :ensure_hash
|
|
134
|
+
|
|
135
|
+
def ensure_array(value)
|
|
136
|
+
return [] unless value.is_a?(Array)
|
|
137
|
+
|
|
138
|
+
value.map { |item| item.is_a?(Hash) ? normalize_hash(item) : item }
|
|
139
|
+
end
|
|
140
|
+
private_class_method :ensure_array
|
|
141
|
+
|
|
142
|
+
def validate_profile(profile)
|
|
143
|
+
errors = []
|
|
144
|
+
%w[name headline summary location].each do |key|
|
|
145
|
+
errors << error(path: "$.profile.#{key}", message: "must be a string") unless profile[key].is_a?(String)
|
|
146
|
+
end
|
|
147
|
+
errors << error(path: "$.profile.name", message: "is required") if profile["name"].to_s.strip.empty?
|
|
148
|
+
errors << error(path: "$.profile.headline", message: "is required") if profile["headline"].to_s.strip.empty?
|
|
149
|
+
errors << error(path: "$.profile.summary", message: "is required") if profile["summary"].to_s.strip.empty?
|
|
150
|
+
errors
|
|
151
|
+
end
|
|
152
|
+
private_class_method :validate_profile
|
|
153
|
+
|
|
154
|
+
def validate_links(links)
|
|
155
|
+
errors = []
|
|
156
|
+
%w[email github_url linkedin_url blog_url twitter_url].each do |key|
|
|
157
|
+
next if links[key].is_a?(String)
|
|
158
|
+
|
|
159
|
+
errors << error(path: "$.links.#{key}", message: "must be a string")
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
%w[github_url linkedin_url blog_url twitter_url].each do |key|
|
|
163
|
+
next if links[key].to_s.strip.empty?
|
|
164
|
+
next if http_url?(links[key])
|
|
165
|
+
|
|
166
|
+
errors << error(path: "$.links.#{key}", message: "must be a valid http or https URL")
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
if links["email"].present? && links["email"] !~ URI::MailTo::EMAIL_REGEXP
|
|
170
|
+
errors << error(path: "$.links.email", message: "must be a valid email address")
|
|
171
|
+
end
|
|
172
|
+
errors
|
|
173
|
+
end
|
|
174
|
+
private_class_method :validate_links
|
|
175
|
+
|
|
176
|
+
def validate_projects(projects)
|
|
177
|
+
validate_collection(projects, "$.projects") do |entry, index, errors|
|
|
178
|
+
require_string(errors, entry, "$.projects[#{index}].title", "title")
|
|
179
|
+
require_string(errors, entry, "$.projects[#{index}].slug", "slug")
|
|
180
|
+
require_string(errors, entry, "$.projects[#{index}].summary", "summary")
|
|
181
|
+
require_slug(errors, entry["slug"], "$.projects[#{index}].slug")
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
private_class_method :validate_projects
|
|
185
|
+
|
|
186
|
+
def validate_experience(experience)
|
|
187
|
+
validate_collection(experience, "$.experience") do |entry, index, errors|
|
|
188
|
+
require_string(errors, entry, "$.experience[#{index}].company", "company")
|
|
189
|
+
require_string(errors, entry, "$.experience[#{index}].role", "role")
|
|
190
|
+
optional_string(errors, entry, "$.experience[#{index}].location", "location")
|
|
191
|
+
optional_string(errors, entry, "$.experience[#{index}].start_date", "start_date")
|
|
192
|
+
optional_string(errors, entry, "$.experience[#{index}].end_date", "end_date")
|
|
193
|
+
optional_string(errors, entry, "$.experience[#{index}].summary", "summary")
|
|
194
|
+
unless [ true, false ].include?(entry["current"])
|
|
195
|
+
errors << error(path: "$.experience[#{index}].current", message: "must be true or false")
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
private_class_method :validate_experience
|
|
200
|
+
|
|
201
|
+
def validate_writing(writing)
|
|
202
|
+
validate_collection(writing, "$.writing") do |entry, index, errors|
|
|
203
|
+
require_string(errors, entry, "$.writing[#{index}].title", "title")
|
|
204
|
+
require_string(errors, entry, "$.writing[#{index}].slug", "slug")
|
|
205
|
+
optional_string(errors, entry, "$.writing[#{index}].date", "date")
|
|
206
|
+
optional_string(errors, entry, "$.writing[#{index}].summary", "summary")
|
|
207
|
+
optional_string(errors, entry, "$.writing[#{index}].url", "url")
|
|
208
|
+
require_slug(errors, entry["slug"], "$.writing[#{index}].slug")
|
|
209
|
+
if entry["url"].present? && !http_url?(entry["url"])
|
|
210
|
+
errors << error(path: "$.writing[#{index}].url", message: "must be a valid http or https URL")
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
private_class_method :validate_writing
|
|
215
|
+
|
|
216
|
+
def validate_collection(collection, path)
|
|
217
|
+
errors = []
|
|
218
|
+
unless collection.is_a?(Array)
|
|
219
|
+
return [ error(path:, message: "must be an array") ]
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
collection.each_with_index do |entry, index|
|
|
223
|
+
unless entry.is_a?(Hash)
|
|
224
|
+
errors << error(path: "#{path}[#{index}]", message: "must be an object")
|
|
225
|
+
next
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
yield(entry, index, errors)
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
errors.concat(duplicate_slug_errors(collection, path))
|
|
232
|
+
errors
|
|
233
|
+
end
|
|
234
|
+
private_class_method :validate_collection
|
|
235
|
+
|
|
236
|
+
def duplicate_slug_errors(collection, path)
|
|
237
|
+
seen = {}
|
|
238
|
+
collection.each_with_index.filter_map do |entry, index|
|
|
239
|
+
slug = entry["slug"].to_s.strip
|
|
240
|
+
next if slug.empty?
|
|
241
|
+
next unless seen.key?(slug)
|
|
242
|
+
|
|
243
|
+
error(path: "#{path}[#{index}].slug", message: "duplicates #{path}[#{seen[slug]}].slug")
|
|
244
|
+
ensure
|
|
245
|
+
seen[slug] ||= index unless slug.empty?
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
private_class_method :duplicate_slug_errors
|
|
249
|
+
|
|
250
|
+
def require_string(errors, entry, path, key)
|
|
251
|
+
value = entry[key]
|
|
252
|
+
if !value.is_a?(String)
|
|
253
|
+
errors << error(path:, message: "must be a string")
|
|
254
|
+
elsif value.strip.empty?
|
|
255
|
+
errors << error(path:, message: "is required")
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
private_class_method :require_string
|
|
259
|
+
|
|
260
|
+
def optional_string(errors, entry, path, key)
|
|
261
|
+
errors << error(path:, message: "must be a string") unless entry[key].is_a?(String)
|
|
262
|
+
end
|
|
263
|
+
private_class_method :optional_string
|
|
264
|
+
|
|
265
|
+
def require_slug(errors, value, path)
|
|
266
|
+
return if value.to_s.match?(/\A[a-z0-9]+(?:-[a-z0-9]+)*\z/)
|
|
267
|
+
|
|
268
|
+
errors << error(path:, message: "must use lowercase letters, numbers, and hyphens only")
|
|
269
|
+
end
|
|
270
|
+
private_class_method :require_slug
|
|
271
|
+
|
|
272
|
+
def http_url?(value)
|
|
273
|
+
parsed = URI.parse(value.to_s)
|
|
274
|
+
parsed.is_a?(URI::HTTP) && parsed.host.present?
|
|
275
|
+
rescue URI::InvalidURIError
|
|
276
|
+
false
|
|
277
|
+
end
|
|
278
|
+
private_class_method :http_url?
|
|
279
|
+
end
|
|
280
|
+
end
|
data/lib/explore/version.rb
CHANGED
data/lib/explore.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: exploremyprofile
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.1.
|
|
4
|
+
version: 0.1.3
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Johnny Butler
|
|
@@ -21,6 +21,8 @@ files:
|
|
|
21
21
|
- exe/explore
|
|
22
22
|
- lib/explore.rb
|
|
23
23
|
- lib/explore/cli.rb
|
|
24
|
+
- lib/explore/cli/config_store.rb
|
|
25
|
+
- lib/explore/profile_document.rb
|
|
24
26
|
- lib/explore/version.rb
|
|
25
27
|
homepage: https://exploremyprofile.com/agent-setup
|
|
26
28
|
licenses:
|