gemkeeper 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a97b09f4efb6325d8efdb7f33130bdf0f2e999c647b68838cb0db370d837d5b0
4
- data.tar.gz: 55269457dce1d236e620b1f6a1f71b017ee724363363af2fb24e63f7bd583ad7
3
+ metadata.gz: f619f51cc389c49dbd52a00f22a6fc3766822a9d689eaea44d5fcfbb94e5bfe2
4
+ data.tar.gz: 79e3718a784f510d72156f071551af20bba3c2d9177ed4b9a2e9791917337947
5
5
  SHA512:
6
- metadata.gz: 630727692a8c0ec33456f0a3438a64b1aac25e14f4385803744b9ae992f97ca32ac3a914aead6b3901a102f9a4d7ad53c758cbd9e5a53baa314f9634a07799c5
7
- data.tar.gz: e50b7fef90457e4a1941d8f96879775226a7b999cb90b579db36f82a98fb3dbd96e4cc367029b431c1d021f44b44c9521bd94ef32060a31ccb89682ed1f6be18
6
+ metadata.gz: 03ce5eeb1e672cc09815d80a505a0cf2974bbd378c0f3c38245168100a82b59783a45aacd2afb149b0e9814df8ecbd7114fb1eda4738dc8bfa772c5cfa104809
7
+ data.tar.gz: f26bf3d36e183c2aa7585a36349599092f84347fb45f6325b21c196e37b8893a6102afc7a169efa50a880c408c406ca09a0b80af7e5bd87dbac0b5538960e509
data/.reek.yml CHANGED
@@ -36,3 +36,6 @@ detectors:
36
36
  - Gemkeeper::CLI::Commands::Server::Start
37
37
  - Gemkeeper::CLI::Commands::Server::Stop
38
38
  - Gemkeeper::CLI::Commands::Server::Status
39
+ - Gemkeeper::CLI::Commands::Manifest
40
+ - Gemkeeper::CLI::Commands::Manifest::Generate
41
+ - Gemkeeper::CLI::Commands::Manifest::Validate
data/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.5.0] - 2026-05-27
4
+
5
+ ### Added
6
+
7
+ - `gemkeeper manifest generate LOCKFILE_PATH` builds or updates the gem manifest from a Gemfile.lock.
8
+ Merges with any existing manifest by default; `--force` overwrites it entirely.
9
+ Accepts `--manifest` to specify a non-default path.
10
+ - `gemkeeper manifest validate [PATH]` checks a manifest file for structural errors: missing fields, invalid repo URLs, duplicate names, and malformed `source_url`.
11
+ `--resolve` additionally probes each repo with `git ls-remote` (5s timeout per entry) to verify reachability.
12
+ - During interactive `gemkeeper setup`, gems with inaccessible source repos can now be skipped.
13
+ Enter nothing when no URL can be inferred, or type `skip` at any prompt.
14
+
3
15
  ## [0.4.0] - 2026-05-27
4
16
 
5
17
  ### Added
@@ -55,7 +67,8 @@
55
67
 
56
68
  - Initial release
57
69
 
58
- [Unreleased]: https://github.com/danhorst/gemkeeper/compare/v0.4.0...HEAD
70
+ [Unreleased]: https://github.com/danhorst/gemkeeper/compare/v0.5.0...HEAD
71
+ [0.5.0]: https://github.com/danhorst/gemkeeper/compare/0.4.0...0.5.0
59
72
  [0.4.0]: https://github.com/danhorst/gemkeeper/compare/0.3.0...0.4.0
60
73
  [0.3.0]: https://github.com/danhorst/gemkeeper/compare/0.2.1...0.3.0
61
74
  [0.2.1]: https://github.com/danhorst/gemkeeper/compare/0.2.0...0.2.1
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gemkeeper
4
+ # Configures Bundler mirror settings so private gem registries proxy through local Geminabox.
5
+ class BundlerMirrorConfigurator
6
+ def initialize(candidates, port:, global:)
7
+ @remotes = candidates.filter_map { |c| c[:remote] if c[:source_type] == :private_gem }.uniq
8
+ @local_url = "http://localhost:#{port}"
9
+ @scope = global ? "--global" : "--local"
10
+ end
11
+
12
+ def configure(output: $stdout)
13
+ return if @remotes.empty?
14
+
15
+ output.puts ""
16
+ @remotes.each { |remote| configure_mirror(remote, output) }
17
+ end
18
+
19
+ private
20
+
21
+ def configure_mirror(remote, output)
22
+ if system("bundle", "config", "set", @scope, "mirror.#{remote}", @local_url, out: File::NULL)
23
+ output.puts "Configured: bundle config set #{@scope} mirror.#{remote} #{@local_url}"
24
+ else
25
+ warn "Warning: failed to configure bundler mirror for #{remote}"
26
+ warn " Run manually: bundle config set #{@scope} mirror.#{remote} #{@local_url}"
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gemkeeper
4
+ module CLI
5
+ module Commands
6
+ module Manifest
7
+ class Generate < Dry::CLI::Command
8
+ desc "Build or update the gem manifest from a Gemfile.lock"
9
+
10
+ argument :lockfile_path, type: :string, required: true,
11
+ desc: "Path to the project's Gemfile.lock"
12
+ option :manifest, type: :string,
13
+ desc: "Path to write manifest (default: ~/.config/gemkeeper/manifest.yml)"
14
+ option :force, type: :boolean, default: false,
15
+ desc: "Overwrite existing manifest entirely"
16
+
17
+ def call(lockfile_path:, **options)
18
+ path = manifest_path(options)
19
+ manifest = ManifestReader.load(path)
20
+ manifest.clear! if options[:force]
21
+ result = ManifestBuilder.build(lockfile_path:, manifest:)
22
+
23
+ if result.empty?
24
+ puts "No internal gem sources found in #{lockfile_path}"
25
+ return
26
+ end
27
+
28
+ if result.any_changes?
29
+ manifest.save(path)
30
+ puts "Wrote #{path}"
31
+ end
32
+
33
+ print_summary(result, result.any_changes? ? nil : "Manifest up to date, no changes")
34
+ rescue UnresolvableGemError, ManifestConflictError => error
35
+ warn "Error: #{error.message}"
36
+ exit 1
37
+ end
38
+
39
+ private
40
+
41
+ def manifest_path(options)
42
+ options[:manifest] || ManifestReader::DEFAULT_PATH
43
+ end
44
+
45
+ def print_summary(result, no_change_message)
46
+ if no_change_message
47
+ puts no_change_message
48
+ return
49
+ end
50
+
51
+ parts = []
52
+ parts << "#{result.added_count} added" if result.added_count.positive?
53
+ parts << "#{result.skipped_count} skipped" if result.skipped_count.positive?
54
+ parts << "#{result.already_mapped_count} already mapped" if result.already_mapped_count.positive?
55
+ puts parts.join(", ") if parts.any?
56
+ end
57
+ end
58
+ end
59
+ end
60
+
61
+ register "manifest generate", Commands::Manifest::Generate
62
+ end
63
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gemkeeper
4
+ module CLI
5
+ module Commands
6
+ module Manifest
7
+ class Validate < Dry::CLI::Command
8
+ desc "Validate a gem manifest file"
9
+
10
+ argument :path, type: :string, required: false,
11
+ desc: "Path to manifest (default: ~/.config/gemkeeper/manifest.yml)"
12
+ option :resolve, type: :boolean, default: false,
13
+ desc: "Also verify each repo is reachable via git ls-remote"
14
+
15
+ def call(path: nil, **options)
16
+ manifest_path = path || ManifestReader::DEFAULT_PATH
17
+ validator = ManifestValidator.new(manifest_path)
18
+
19
+ puts "Checking #{manifest_path}..." if options[:resolve]
20
+ errors = validator.validate(resolve: options[:resolve], output: $stdout)
21
+
22
+ if errors.empty?
23
+ entry_count = entry_count(manifest_path)
24
+ puts "#{manifest_path}: valid (#{entry_count} #{entry_count == 1 ? "entry" : "entries"})"
25
+ else
26
+ warn "#{manifest_path}: #{errors.size} #{"error".then { |w| errors.size == 1 ? w : "#{w}s" }}"
27
+ errors.each { |e| warn " #{e}" }
28
+ exit 1
29
+ end
30
+ end
31
+
32
+ private
33
+
34
+ def entry_count(path)
35
+ ManifestReader.load(path).gems.size
36
+ end
37
+ end
38
+ end
39
+ end
40
+
41
+ register "manifest validate", Commands::Manifest::Validate
42
+ end
43
+ end
@@ -40,12 +40,8 @@ module Gemkeeper
40
40
 
41
41
  def setup_from_lockfile(lockfile_path, output_path, options)
42
42
  manifest = load_manifest(options)
43
- candidates = LockfileParser.internal_sources(lockfile_path)
44
-
45
- unless candidates.empty?
46
- GemRepoResolver.new(candidates:, manifest:).resolve!
47
- manifest.save(manifest_path(options))
48
- end
43
+ result = ManifestBuilder.build(lockfile_path:, manifest:)
44
+ manifest.save(manifest_path(options)) if result.any_changes?
49
45
 
50
46
  lockfile_versions = LockfileParser.parse(lockfile_path)
51
47
  global_output_path = options[:global] ? output_path : nil
@@ -54,26 +50,10 @@ module Gemkeeper
54
50
 
55
51
  File.write(output_path, config.to_yaml)
56
52
  puts "Wrote #{output_path}"
57
- configure_bundler(candidates, config, options) unless options[:skip_bundler_config]
58
- end
59
-
60
- def configure_bundler(candidates, config, options)
61
- remotes = candidates.filter_map { |c| c[:remote] if c[:source_type] == :private_gem }.uniq
62
- return if remotes.empty?
53
+ return if options[:skip_bundler_config]
63
54
 
64
55
  port = config.fetch("port", Configuration::DEFAULT_PORT)
65
- local_url = "http://localhost:#{port}"
66
- scope = options[:global] ? "--global" : "--local"
67
-
68
- puts ""
69
- remotes.each do |remote|
70
- if system("bundle", "config", "set", scope, "mirror.#{remote}", local_url, out: File::NULL)
71
- puts "Configured: bundle config set #{scope} mirror.#{remote} #{local_url}"
72
- else
73
- warn "Warning: failed to configure bundler mirror for #{remote}"
74
- warn " Run manually: bundle config set #{scope} mirror.#{remote} #{local_url}"
75
- end
76
- end
56
+ BundlerMirrorConfigurator.new(result.candidates, port:, global: options[:global]).configure
77
57
  end
78
58
 
79
59
  def setup_from_config(source_path, output_path, options)
@@ -105,9 +85,7 @@ module Gemkeeper
105
85
  existing_gems = existing["gems"] || []
106
86
  source_gems = source["gems"] || []
107
87
  existing_repos = existing_gems.to_set { |g| g["repo"] }
108
- merged = existing_gems.dup
109
- source_gems.each { |g| merged << g unless existing_repos.include?(g["repo"]) }
110
- merged
88
+ existing_gems + source_gems.reject { |g| existing_repos.include?(g["repo"]) }
111
89
  end
112
90
 
113
91
  def load_manifest(options)
@@ -12,8 +12,8 @@ module Gemkeeper
12
12
  def call(gem_name: nil, **options)
13
13
  config = Configuration.load(options[:config])
14
14
  gems_to_sync = select_gems(config, gem_name)
15
- uploader = GemUploader.new(config.geminabox_url)
16
- counts, failures = run_sync(gems_to_sync, config, uploader)
15
+ syncer = GemSyncer.new(config, GemUploader.new(config.geminabox_url))
16
+ counts, failures = run_sync(gems_to_sync, syncer)
17
17
  report_results(counts, failures, gems_to_sync.size)
18
18
  end
19
19
 
@@ -36,12 +36,11 @@ module Gemkeeper
36
36
  gems
37
37
  end
38
38
 
39
- def run_sync(gems_to_sync, config, uploader)
39
+ def run_sync(gems_to_sync, syncer)
40
40
  counts = { synced: 0, skipped: 0 }
41
41
  failures = []
42
42
  gems_to_sync.each do |gem_def|
43
- result = sync_gem(gem_def, config, uploader)
44
- counts[result] += 1
43
+ counts[syncer.sync(gem_def)] += 1
45
44
  rescue Error => error
46
45
  failures << { name: gem_def.name, message: error.message }
47
46
  end
@@ -64,100 +63,6 @@ module Gemkeeper
64
63
  failures.each { |f| warn " #{f[:name]}: #{f[:message]}" }
65
64
  exit 1
66
65
  end
67
-
68
- def sync_gem(gem_def, config, uploader)
69
- name = gem_def.name
70
- repo_url = gem_def.repo
71
- gems_path = config.gems_path
72
- version = resolve_version(gem_def)
73
-
74
- latest = gem_def.latest?
75
- return :skipped if !latest && cached?(name, version, gems_path)
76
-
77
- puts "Syncing #{name} @ #{version}..."
78
-
79
- local_path = File.join(config.repos_path, name)
80
- repo = GitRepository.new(repo_url, local_path)
81
-
82
- Output.step("Fetching from #{repo_url}...")
83
- fetch_repo(repo, repo_url)
84
-
85
- checkout_gem_version(repo, version)
86
-
87
- if latest
88
- version = repo.current_version or
89
- raise BuildError, "Could not read version from gemspec in #{repo_url}"
90
- return :skipped if cached?(name, version, gems_path)
91
- end
92
-
93
- Output.step("Building gem...")
94
- gem_path = GemBuilder.new(local_path, gems_path).build
95
-
96
- Output.step("Uploading to Geminabox...")
97
- result = uploader.upload(gem_path)
98
- Output.step(result[:message])
99
- Output.success(" Done!")
100
- :synced
101
- end
102
-
103
- def resolve_version(gem_def)
104
- return gem_def.version unless gem_def.from_lockfile?
105
-
106
- name = gem_def.name
107
- lockfile_path = LockfileParser.find
108
- unless lockfile_path
109
- raise GitError,
110
- "version: from_lockfile for #{name} — no Gemfile.lock found in " \
111
- "#{Dir.pwd} or any parent directory"
112
- end
113
-
114
- versions = LockfileParser.parse(lockfile_path)
115
- version = versions[name]
116
- raise GitError, "#{name} not found in #{lockfile_path}" unless version
117
-
118
- version
119
- end
120
-
121
- def cached?(name, version, gems_path)
122
- bare_version = version.delete_prefix("v")
123
- gem_file = File.join(gems_path, "gems", "#{name}-#{bare_version}.gem")
124
- if File.exist?(gem_file)
125
- Output.skip("Skipping #{name} @ #{bare_version} (already cached)")
126
- true
127
- else
128
- false
129
- end
130
- end
131
-
132
- def fetch_repo(repo, repo_url)
133
- repo.clone_or_pull
134
- rescue GitError => git_error
135
- raise auth_error?(git_error) ? auth_failure_error(repo_url, git_error) : git_error
136
- end
137
-
138
- def checkout_gem_version(repo, version)
139
- Output.step("Checking out #{version}...")
140
- repo.checkout_version(version)
141
- end
142
-
143
- def auth_error?(error)
144
- auth_patterns = [
145
- /authentication failed/i,
146
- /could not read from remote repository/i,
147
- /permission denied \(publickey\)/i,
148
- /repository not found/i,
149
- /fatal: credential/i
150
- ]
151
- auth_patterns.any? { |pat| error.message.match?(pat) }
152
- end
153
-
154
- def auth_failure_error(repo_url, original_error)
155
- GitError.new(
156
- "Git authentication failed for #{repo_url}.\n" \
157
- "#{original_error.message}\n" \
158
- "Configure GitHub credentials: https://docs.github.com/en/authentication"
159
- )
160
- end
161
66
  end
162
67
  end
163
68
 
data/lib/gemkeeper/cli.rb CHANGED
@@ -19,3 +19,5 @@ require_relative "cli/commands/list"
19
19
  require_relative "cli/commands/server/start"
20
20
  require_relative "cli/commands/server/stop"
21
21
  require_relative "cli/commands/server/status"
22
+ require_relative "cli/commands/manifest/generate"
23
+ require_relative "cli/commands/manifest/validate"
@@ -5,6 +5,8 @@ module Gemkeeper
5
5
  # GIT-sourced gems are added automatically; private gem registry entries
6
6
  # are inferred where possible (GitHub Packages) or prompted interactively.
7
7
  class GemRepoResolver
8
+ SKIP_INPUT = "skip"
9
+
8
10
  def initialize(candidates:, manifest:, input: $stdin, output: $stdout)
9
11
  @candidates = candidates
10
12
  @manifest = manifest
@@ -14,37 +16,36 @@ module Gemkeeper
14
16
 
15
17
  def resolve!
16
18
  unresolvable = []
17
-
18
- @candidates.each do |candidate|
19
- name = candidate[:name]
20
- next if @manifest.repo_for(name)
21
-
22
- if candidate[:source_type] == :git
23
- @manifest.add_mapping(name:, repo: candidate[:repo])
24
- else
25
- repo = resolve_private_gem(candidate)
26
- if repo
27
- @manifest.add_mapping(name:, repo:)
28
- else
29
- unresolvable << candidate
30
- end
31
- end
32
- end
33
-
19
+ @candidates.each { |candidate| resolve_candidate(candidate, unresolvable) }
34
20
  raise_unresolvable(unresolvable) if unresolvable.any?
35
21
  @manifest
36
22
  end
37
23
 
38
24
  private
39
25
 
40
- def resolve_private_gem(candidate)
41
- inferred = infer_repo(candidate)
26
+ def resolve_candidate(candidate, unresolvable)
27
+ return if @manifest.repo_for(candidate[:name])
28
+ return @manifest.add_mapping(name: candidate[:name], repo: candidate[:repo]) if candidate[:source_type] == :git
29
+
30
+ interactive? ? resolve_interactively(candidate) : resolve_non_interactively(candidate, unresolvable)
31
+ end
42
32
 
43
- if interactive?
44
- prompt(candidate, inferred)
45
- elsif inferred
46
- warn "Note: auto-inferred repo for #{candidate[:name]}: #{inferred}"
47
- inferred
33
+ def resolve_interactively(candidate)
34
+ repo = prompt(candidate, infer_repo(candidate))
35
+ if repo
36
+ @manifest.add_mapping(name: candidate[:name], repo:)
37
+ else
38
+ @output.puts " Skipping #{candidate[:name]}"
39
+ end
40
+ end
41
+
42
+ def resolve_non_interactively(candidate, unresolvable)
43
+ repo = infer_repo(candidate)
44
+ if repo
45
+ warn "Note: auto-inferred repo for #{candidate[:name]}: #{repo}"
46
+ @manifest.add_mapping(name: candidate[:name], repo:)
47
+ else
48
+ unresolvable << candidate
48
49
  end
49
50
  end
50
51
 
@@ -57,12 +58,18 @@ module Gemkeeper
57
58
 
58
59
  def prompt(candidate, inferred)
59
60
  @output.print "\n #{candidate[:name]} (from #{candidate[:remote]})"
60
- @output.print "\n Repo URL"
61
- @output.print " [#{inferred}]" if inferred
62
- @output.print ": "
63
- input = @input.gets&.strip
64
- input = inferred if input.nil? || input.empty?
65
- input
61
+ @output.print "\n Repo URL#{prompt_hint(inferred)}: "
62
+ parse_prompt_input(@input.gets&.strip, inferred)
63
+ end
64
+
65
+ def prompt_hint(inferred)
66
+ inferred ? " [#{inferred}] (or \"#{SKIP_INPUT}\" to skip)" : " (blank to skip)"
67
+ end
68
+
69
+ def parse_prompt_input(input, inferred)
70
+ return nil if input.nil? || input == SKIP_INPUT || (input.empty? && inferred.nil?)
71
+
72
+ input.empty? ? inferred : input
66
73
  end
67
74
 
68
75
  def interactive?
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gemkeeper
4
+ # Syncs a single gem: resolves version, checks cache, clones/pulls, builds, uploads.
5
+ class GemSyncer
6
+ AUTH_ERROR_PATTERNS = [
7
+ /authentication failed/i,
8
+ /could not read from remote repository/i,
9
+ /permission denied \(publickey\)/i,
10
+ /repository not found/i,
11
+ /fatal: credential/i
12
+ ].freeze
13
+
14
+ def initialize(config, uploader)
15
+ @config = config
16
+ @uploader = uploader
17
+ end
18
+
19
+ def sync(gem_def)
20
+ version = resolve_version(gem_def)
21
+ name = gem_def.name
22
+ gems_path = @config.gems_path
23
+
24
+ return :skipped if !gem_def.latest? && cached?(name, version, gems_path)
25
+
26
+ puts "Syncing #{name} @ #{version}..."
27
+ local_path = File.join(@config.repos_path, name)
28
+ repo = fetch_repo(gem_def.repo, local_path)
29
+
30
+ Output.step("Checking out #{version}...")
31
+ repo.checkout_version(version)
32
+
33
+ if gem_def.latest?
34
+ version = latest_version!(repo, name, gems_path, gem_def.repo)
35
+ return :skipped unless version
36
+ end
37
+
38
+ build_and_upload(local_path, gems_path)
39
+ :synced
40
+ end
41
+
42
+ private
43
+
44
+ def resolve_version(gem_def)
45
+ return gem_def.version unless gem_def.from_lockfile?
46
+
47
+ lockfile_path = LockfileParser.find
48
+ unless lockfile_path
49
+ raise GitError,
50
+ "version: from_lockfile for #{gem_def.name} — no Gemfile.lock found in " \
51
+ "#{Dir.pwd} or any parent directory"
52
+ end
53
+
54
+ versions = LockfileParser.parse(lockfile_path)
55
+ versions[gem_def.name] or raise GitError, "#{gem_def.name} not found in #{lockfile_path}"
56
+ end
57
+
58
+ def cached?(name, version, gems_path)
59
+ bare = version.delete_prefix("v")
60
+ gem_file = File.join(gems_path, "gems", "#{name}-#{bare}.gem")
61
+ return false unless File.exist?(gem_file)
62
+
63
+ Output.skip("Skipping #{name} @ #{bare} (already cached)")
64
+ true
65
+ end
66
+
67
+ def fetch_repo(repo_url, local_path)
68
+ repo = GitRepository.new(repo_url, local_path)
69
+ Output.step("Fetching from #{repo_url}...")
70
+ repo.clone_or_pull
71
+ repo
72
+ rescue GitError => git_error
73
+ raise auth_error?(git_error) ? auth_failure_error(repo_url, git_error) : git_error
74
+ end
75
+
76
+ def latest_version!(repo, name, gems_path, repo_url)
77
+ version = repo.current_version or
78
+ raise BuildError, "Could not read version from gemspec in #{repo_url}"
79
+ cached?(name, version, gems_path) ? nil : version
80
+ end
81
+
82
+ def build_and_upload(local_path, gems_path)
83
+ Output.step("Building gem...")
84
+ gem_path = GemBuilder.new(local_path, gems_path).build
85
+ Output.step("Uploading to Geminabox...")
86
+ result = @uploader.upload(gem_path)
87
+ Output.step(result[:message])
88
+ Output.success(" Done!")
89
+ end
90
+
91
+ def auth_error?(error)
92
+ AUTH_ERROR_PATTERNS.any? { |pat| error.message.match?(pat) }
93
+ end
94
+
95
+ def auth_failure_error(repo_url, original_error)
96
+ GitError.new(
97
+ "Git authentication failed for #{repo_url}.\n" \
98
+ "#{original_error.message}\n" \
99
+ "Configure GitHub credentials: https://docs.github.com/en/authentication"
100
+ )
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gemkeeper
4
+ # Builds or updates a manifest from a Gemfile.lock, tracking what changed.
5
+ class ManifestBuilder
6
+ Result = Struct.new(:manifest, :candidates, :added_count, :skipped_count, :already_mapped_count,
7
+ keyword_init: true) do
8
+ def any_changes?
9
+ added_count.positive?
10
+ end
11
+
12
+ def empty?
13
+ candidates.empty?
14
+ end
15
+ end
16
+
17
+ def self.build(lockfile_path:, manifest:, input: $stdin, output: $stdout)
18
+ new(lockfile_path:, manifest:).build!(input:, output:)
19
+ end
20
+
21
+ def initialize(lockfile_path:, manifest:)
22
+ @lockfile_path = lockfile_path
23
+ @manifest = manifest
24
+ end
25
+
26
+ def build!(input: $stdin, output: $stdout)
27
+ candidates = LockfileParser.internal_sources(@lockfile_path)
28
+ already_mapped_count = candidates.count { |c| @manifest.repo_for(c[:name]) }
29
+ before_size = @manifest.gems.size
30
+
31
+ GemRepoResolver.new(candidates:, manifest: @manifest, input:, output:).resolve! unless candidates.empty?
32
+
33
+ added_count = @manifest.gems.size - before_size
34
+ skipped_count = candidates.size - already_mapped_count - added_count
35
+
36
+ Result.new(manifest: @manifest, candidates:, added_count:, skipped_count:, already_mapped_count:)
37
+ end
38
+ end
39
+ end
@@ -1,8 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "yaml"
4
- require "fileutils"
5
-
6
3
  module Gemkeeper
7
4
  # Decouples manifest format from sync/setup commands that need the eligible gem list.
8
5
  class ManifestReader
@@ -18,7 +15,13 @@ module Gemkeeper
18
15
  @path = path
19
16
  @gems = []
20
17
  @source_url = nil
21
- parse_manifest if File.exist?(@path)
18
+ load_from_disk if File.exist?(@path)
19
+ end
20
+
21
+ def clear!
22
+ @gems = []
23
+ @source_url = nil
24
+ self
22
25
  end
23
26
 
24
27
  def gem_names
@@ -46,21 +49,15 @@ module Gemkeeper
46
49
  end
47
50
 
48
51
  def save(path = @path)
49
- FileUtils.mkdir_p(File.dirname(path))
50
- data = {}
51
- data["source_url"] = @source_url if @source_url
52
- data["gems"] = @gems.map { |g| { "name" => g[:name], "repo" => g[:repo] } }
53
- File.write(path, data.to_yaml)
52
+ ManifestSerializer.save(path, gems: @gems, source_url: @source_url)
54
53
  end
55
54
 
56
55
  private
57
56
 
58
- def parse_manifest
59
- data = YAML.safe_load_file(@path, permitted_classes: [], symbolize_names: true) || {}
57
+ def load_from_disk
58
+ data = ManifestSerializer.load(@path)
60
59
  @source_url = data[:source_url]&.to_s
61
- @gems = (data[:gems] || []).map do |entry|
62
- { name: entry[:name].to_s, repo: entry[:repo].to_s }
63
- end
60
+ @gems = (data[:gems] || []).map { |entry| { name: entry[:name].to_s, repo: entry[:repo].to_s } }
64
61
  end
65
62
 
66
63
  def conflict_message(name, existing_repo, new_repo)
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require "fileutils"
5
+
6
+ module Gemkeeper
7
+ # Handles YAML read/write for the gem manifest file.
8
+ module ManifestSerializer
9
+ def self.load(path)
10
+ return {} unless File.exist?(path)
11
+
12
+ YAML.safe_load_file(path, permitted_classes: [], symbolize_names: true) || {}
13
+ end
14
+
15
+ def self.save(path, gems:, source_url:)
16
+ FileUtils.mkdir_p(File.dirname(path))
17
+ data = {}
18
+ data["source_url"] = source_url if source_url
19
+ data["gems"] = gems.map { |g| { "name" => g[:name], "repo" => g[:repo] } }
20
+ File.write(path, data.to_yaml)
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "set"
4
+ require "timeout"
5
+ require "yaml"
6
+
7
+ module Gemkeeper
8
+ # Validates a manifest file structurally and, optionally, by probing git remotes.
9
+ class ManifestValidator
10
+ VALID_GIT_URL = %r{\A(git@|https?://|ssh://)}
11
+ VALID_HTTP_URL = %r{\Ahttps?://}
12
+ RESOLVE_TIMEOUT = 5
13
+
14
+ def initialize(path)
15
+ @path = path
16
+ end
17
+
18
+ def validate(resolve: false, output: $stdout)
19
+ errors = static_errors
20
+ return errors if errors.any?
21
+
22
+ errors + (resolve ? resolve_errors(output) : [])
23
+ end
24
+
25
+ private
26
+
27
+ def static_errors
28
+ return ["File not found: #{@path}"] unless File.exist?(@path)
29
+
30
+ data = load_yaml
31
+ return [data] if data.is_a?(String)
32
+
33
+ structure_errors(data) + entry_errors(data)
34
+ end
35
+
36
+ def load_yaml
37
+ @data = YAML.safe_load_file(@path, permitted_classes: [], symbolize_names: false) || {}
38
+ rescue Psych::SyntaxError => e
39
+ "Invalid YAML: #{e.message}"
40
+ end
41
+
42
+ def structure_errors(data)
43
+ errors = []
44
+ source_url = data["source_url"].to_s
45
+ errors << "source_url is not a valid HTTP(S) URL" if data.key?("source_url") && !source_url.match?(VALID_HTTP_URL)
46
+ errors << "gems must be a list" if data.key?("gems") && !data["gems"].is_a?(Array)
47
+ errors
48
+ end
49
+
50
+ def entry_errors(data)
51
+ return [] unless data["gems"].is_a?(Array)
52
+
53
+ seen = Set.new
54
+ data["gems"].each_with_index.flat_map { |entry, i| single_entry_errors(entry, i, seen) }
55
+ end
56
+
57
+ def single_entry_errors(entry, index, seen)
58
+ name = entry["name"].to_s.strip
59
+ repo = entry["repo"].to_s.strip
60
+ label = name.empty? ? "gems[#{index}]" : "gems[#{index}] (#{name})"
61
+
62
+ field_errors(label, name, repo) + duplicate_errors(label, name, seen)
63
+ end
64
+
65
+ def field_errors(label, name, repo)
66
+ errors = []
67
+ errors << "#{label}: missing name" if name.empty?
68
+ errors << "#{label}: missing repo" if repo.empty?
69
+ errors << "#{label}: repo does not look like a git URL" if !repo.empty? && !repo.match?(VALID_GIT_URL)
70
+ errors
71
+ end
72
+
73
+ def duplicate_errors(label, name, seen)
74
+ return [] if name.empty?
75
+
76
+ if seen.include?(name)
77
+ ["#{label}: duplicate name"]
78
+ else
79
+ seen << name
80
+ []
81
+ end
82
+ end
83
+
84
+ def resolve_errors(output)
85
+ (@data["gems"] || []).flat_map { |entry| probe_repo(entry["name"], entry["repo"], output) }
86
+ end
87
+
88
+ def probe_repo(name, repo, output)
89
+ reachable = Timeout.timeout(RESOLVE_TIMEOUT) do
90
+ system("git", "ls-remote", repo, "--quiet", out: File::NULL, err: File::NULL)
91
+ end
92
+ output.puts " #{name}: #{reachable ? "reachable" : "FAILED"}"
93
+ reachable ? [] : ["#{name} (#{repo}): unreachable"]
94
+ rescue Timeout::Error
95
+ output.puts " #{name}: timed out"
96
+ ["#{name} (#{repo}): timed out after #{RESOLVE_TIMEOUT}s"]
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require "fileutils"
5
+ require "uri"
6
+
7
+ module Gemkeeper
8
+ # Builds config.ru, spawns the rackup/puma process, and waits for HTTP readiness.
9
+ class RackupProcess
10
+ def initialize(config)
11
+ @config = config
12
+ end
13
+
14
+ def start
15
+ generate_config_ru
16
+ Dir.chdir(@config.cache_dir) { launch_daemon }
17
+ wait_for_server
18
+ end
19
+
20
+ def start_foreground
21
+ generate_config_ru
22
+ Dir.chdir(@config.cache_dir) { system(*build_rackup_cmd) }
23
+ end
24
+
25
+ private
26
+
27
+ def generate_config_ru
28
+ FileUtils.mkdir_p(@config.cache_dir)
29
+ FileUtils.mkdir_p(@config.gems_path)
30
+ File.write(@config.config_ru_path, config_ru_content)
31
+ end
32
+
33
+ def config_ru_content
34
+ gems_path = @config.gems_path
35
+ <<~RUBY
36
+ # frozen_string_literal: true
37
+ # Auto-generated by Gemkeeper
38
+
39
+ require "rubygems/indexer"
40
+ require "geminabox"
41
+
42
+ Geminabox.data = #{gems_path.inspect}
43
+ Geminabox.rubygems_proxy = true
44
+
45
+ run Geminabox::Server
46
+ RUBY
47
+ end
48
+
49
+ def build_rackup_cmd(*extra)
50
+ ["rackup", @config.config_ru_path, "--host", "127.0.0.1",
51
+ "-p", @config.port.to_s, "-s", "puma", *extra]
52
+ end
53
+
54
+ def launch_daemon
55
+ _stdout, stderr, status = Open3.capture3(*build_rackup_cmd("-D", "-P", @config.pid_file))
56
+ raise ServerError, "Failed to start server:\n#{stderr}" unless status.success? || File.exist?(@config.pid_file)
57
+ end
58
+
59
+ def wait_for_server
60
+ ServerReadinessProbe.new(@config.geminabox_url).wait
61
+ end
62
+ end
63
+ end
@@ -1,6 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "open3"
4
3
  require "fileutils"
5
4
 
6
5
  module Gemkeeper
@@ -14,14 +13,12 @@ module Gemkeeper
14
13
 
15
14
  def start
16
15
  ensure_not_running!
17
- generate_config_ru
18
- start_server
16
+ RackupProcess.new(config).start
19
17
  end
20
18
 
21
19
  def start_foreground
22
20
  ensure_not_running!
23
- generate_config_ru
24
- start_server_foreground
21
+ RackupProcess.new(config).start_foreground
25
22
  end
26
23
 
27
24
  def stop
@@ -60,58 +57,6 @@ module Gemkeeper
60
57
  raise ServerAlreadyRunningError, "Server is already running (PID: #{read_pid})" if running?
61
58
  end
62
59
 
63
- def generate_config_ru
64
- gems_path = config.gems_path
65
- FileUtils.mkdir_p(config.cache_dir)
66
- FileUtils.mkdir_p(gems_path)
67
-
68
- content = <<~RUBY
69
- # frozen_string_literal: true
70
- # Auto-generated by Gemkeeper
71
-
72
- require "rubygems/indexer"
73
- require "geminabox"
74
-
75
- Geminabox.data = #{gems_path.inspect}
76
- Geminabox.rubygems_proxy = true
77
-
78
- run Geminabox::Server
79
- RUBY
80
-
81
- File.write(config.config_ru_path, content)
82
- end
83
-
84
- def build_rackup_cmd(*extra)
85
- ["rackup", config.config_ru_path, "--host", "127.0.0.1", "-p", config.port.to_s, "-s", "puma", *extra]
86
- end
87
-
88
- def build_start_cmd
89
- build_rackup_cmd("-D", "-P", config.pid_file)
90
- end
91
-
92
- def build_foreground_cmd
93
- build_rackup_cmd
94
- end
95
-
96
- def start_server
97
- cmd = build_start_cmd
98
-
99
- Dir.chdir(config.cache_dir) do
100
- _stdout, stderr, status = Open3.capture3(*cmd)
101
-
102
- raise ServerError, "Failed to start server:\n#{stderr}" unless status.success? || File.exist?(config.pid_file)
103
- end
104
-
105
- # Wait for server to be ready
106
- wait_for_server
107
- end
108
-
109
- def start_server_foreground
110
- Dir.chdir(config.cache_dir) do
111
- system(*build_foreground_cmd)
112
- end
113
- end
114
-
115
60
  def wait_for_process_exit(pid)
116
61
  10.times do
117
62
  return unless process_alive?(pid)
@@ -121,26 +66,6 @@ module Gemkeeper
121
66
  Process.kill("KILL", pid) if process_alive?(pid)
122
67
  end
123
68
 
124
- def wait_for_server(timeout: 10)
125
- require "net/http"
126
-
127
- uri = URI(config.geminabox_url)
128
- (timeout / 0.5).ceil.times do
129
- return true if server_responding?(uri)
130
-
131
- sleep 0.5
132
- end
133
-
134
- raise ServerError, "Server failed to start within #{timeout} seconds"
135
- end
136
-
137
- def server_responding?(uri)
138
- response = Net::HTTP.get_response(uri)
139
- response.is_a?(Net::HTTPSuccess) || response.is_a?(Net::HTTPRedirection)
140
- rescue Errno::ECONNREFUSED, Errno::ECONNRESET, SocketError
141
- false
142
- end
143
-
144
69
  def read_pid
145
70
  pid_file = config.pid_file
146
71
  return nil unless File.exist?(pid_file)
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+
5
+ module Gemkeeper
6
+ # Polls the server's HTTP endpoint until it responds or a timeout is reached.
7
+ class ServerReadinessProbe
8
+ def initialize(url)
9
+ @uri = URI(url)
10
+ end
11
+
12
+ def wait(timeout: 10)
13
+ (timeout / 0.5).ceil.times do
14
+ return true if responding?
15
+
16
+ sleep 0.5
17
+ end
18
+ raise ServerError, "Server failed to start within #{timeout} seconds"
19
+ end
20
+
21
+ private
22
+
23
+ def responding?
24
+ response = Net::HTTP.get_response(@uri)
25
+ response.is_a?(Net::HTTPSuccess) || response.is_a?(Net::HTTPRedirection)
26
+ rescue Errno::ECONNREFUSED, Errno::ECONNRESET, SocketError
27
+ false
28
+ end
29
+ end
30
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Gemkeeper
4
- VERSION = "0.4.0"
4
+ VERSION = "0.5.0"
5
5
  end
data/lib/gemkeeper.rb CHANGED
@@ -6,7 +6,14 @@ require_relative "gemkeeper/output"
6
6
  require_relative "gemkeeper/configuration"
7
7
  require_relative "gemkeeper/lockfile_parser"
8
8
  require_relative "gemkeeper/manifest_reader"
9
+ require_relative "gemkeeper/manifest_serializer"
9
10
  require_relative "gemkeeper/gem_repo_resolver"
11
+ require_relative "gemkeeper/manifest_builder"
12
+ require_relative "gemkeeper/manifest_validator"
13
+ require_relative "gemkeeper/bundler_mirror_configurator"
14
+ require_relative "gemkeeper/gem_syncer"
15
+ require_relative "gemkeeper/rackup_process"
16
+ require_relative "gemkeeper/server_readiness_probe"
10
17
  require_relative "gemkeeper/config_generator"
11
18
  require_relative "gemkeeper/git_repository"
12
19
  require_relative "gemkeeper/gem_builder"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: gemkeeper
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.0
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dan Brubaker Horst
@@ -95,8 +95,11 @@ files:
95
95
  - README.md
96
96
  - exe/gemkeeper
97
97
  - lib/gemkeeper.rb
98
+ - lib/gemkeeper/bundler_mirror_configurator.rb
98
99
  - lib/gemkeeper/cli.rb
99
100
  - lib/gemkeeper/cli/commands/list.rb
101
+ - lib/gemkeeper/cli/commands/manifest/generate.rb
102
+ - lib/gemkeeper/cli/commands/manifest/validate.rb
100
103
  - lib/gemkeeper/cli/commands/server/start.rb
101
104
  - lib/gemkeeper/cli/commands/server/status.rb
102
105
  - lib/gemkeeper/cli/commands/server/stop.rb
@@ -108,12 +111,18 @@ files:
108
111
  - lib/gemkeeper/errors.rb
109
112
  - lib/gemkeeper/gem_builder.rb
110
113
  - lib/gemkeeper/gem_repo_resolver.rb
114
+ - lib/gemkeeper/gem_syncer.rb
111
115
  - lib/gemkeeper/gem_uploader.rb
112
116
  - lib/gemkeeper/git_repository.rb
113
117
  - lib/gemkeeper/lockfile_parser.rb
118
+ - lib/gemkeeper/manifest_builder.rb
114
119
  - lib/gemkeeper/manifest_reader.rb
120
+ - lib/gemkeeper/manifest_serializer.rb
121
+ - lib/gemkeeper/manifest_validator.rb
115
122
  - lib/gemkeeper/output.rb
123
+ - lib/gemkeeper/rackup_process.rb
116
124
  - lib/gemkeeper/server_manager.rb
125
+ - lib/gemkeeper/server_readiness_probe.rb
117
126
  - lib/gemkeeper/version.rb
118
127
  - mise.toml
119
128
  - sig/gemkeeper.rbs