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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fbb95d414e36bede8a58108431a36c0a439a4bb56e92afbf6a62f866021a5a74
4
- data.tar.gz: 3e5a41439c7d2e4cb7e640d632aa178731b4dd5ac9a19e872b1aec44982b6cad
3
+ metadata.gz: cf1d07c5a0807ab5f32cae3a4b3b6baec59accfd1f73d0ee07496861eb7c30ba
4
+ data.tar.gz: e2f8b53e1454516782505f2e85295e10e061bbe4161f165cf4bae22c44bc48c3
5
5
  SHA512:
6
- metadata.gz: 7a46d662af6e8926a4d591ed975a748b9c34bc81c3b8567f1a77b274b085d79bb8b4f7555d7ccfa5dc58449884dd6a8b402a90f5aee5b16a546335af506102cc
7
- data.tar.gz: e552159bd69ebc39cb17a99c83b2be30fd231ac82fd1cc96f953d3adc742928d158d469da83d328bb4f30699a4365ce2081f08b9ad8bfea6e70a03ffc46d3ca4
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
- "The key is app-level, not account-specific, and lives in Rails credentials at api.v1_key."
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.fetch("EXPLORE_BASE_URL", DEFAULT_BASE_URL),
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 whoami [--account <id-or-slug>] [--slug <slug>] [--json]
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
@@ -2,7 +2,7 @@
2
2
 
3
3
  module Explore
4
4
  module Version
5
- STRING = "0.1.0"
5
+ STRING = "0.1.3"
6
6
  end
7
7
 
8
8
  VERSION = Version::STRING
data/lib/explore.rb CHANGED
@@ -1,4 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "explore/version"
4
+ require_relative "explore/cli/config_store"
4
5
  require_relative "explore/cli"
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.0
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: