gemkeeper 0.3.0 → 0.4.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: f8a541f4777f6bff36eab8be7c1c7382e6e8354dd20dbe8b0e47c1d2a2d168fd
4
- data.tar.gz: 112afe0590f5582455935123ec0e35bd52e7faaf8f6811ba7ef5924df0732e73
3
+ metadata.gz: a97b09f4efb6325d8efdb7f33130bdf0f2e999c647b68838cb0db370d837d5b0
4
+ data.tar.gz: 55269457dce1d236e620b1f6a1f71b017ee724363363af2fb24e63f7bd583ad7
5
5
  SHA512:
6
- metadata.gz: 3607617a5dc756210591b2ab71bb2ebe435e63ba458cd91ab112f6d98e4f1cf429939fc6261719a3ad5496ce98372ad668341418c5578add309d9e9f5d87201d
7
- data.tar.gz: 9021ae8ed7015325522bb616b36e3729f6d4dd307a6017a226e48bf328f765d1134a92171b1ceacad33a582d5c0e1fd61c6269e89f32d4612703561cb45daeb2
6
+ metadata.gz: 630727692a8c0ec33456f0a3438a64b1aac25e14f4385803744b9ae992f97ca32ac3a914aead6b3901a102f9a4d7ad53c758cbd9e5a53baa314f9634a07799c5
7
+ data.tar.gz: e50b7fef90457e4a1941d8f96879775226a7b999cb90b579db36f82a98fb3dbd96e4cc367029b431c1d021f44b44c9521bd94ef32060a31ccb89682ed1f6be18
data/.reek.yml CHANGED
@@ -17,7 +17,8 @@ detectors:
17
17
  - Gemkeeper::ConfigurationError
18
18
  - Gemkeeper::ConfigFileNotFoundError
19
19
  - Gemkeeper::InvalidConfigError
20
- - Gemkeeper::ManifestNotFoundError
20
+ - Gemkeeper::UnresolvableGemError
21
+ - Gemkeeper::ManifestConflictError
21
22
  - Gemkeeper::GitError
22
23
  - Gemkeeper::CloneError
23
24
  - Gemkeeper::CheckoutError
data/CHANGELOG.md CHANGED
@@ -1,5 +1,25 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.4.0] - 2026-05-27
4
+
5
+ ### Added
6
+
7
+ - `gemkeeper setup` no longer requires a pre-existing manifest.
8
+ When given a Gemfile.lock, it discovers internal gems by source type and builds or updates `~/.config/gemkeeper/manifest.yml` automatically.
9
+ GIT-sourced gems (declared with `git:` in the Gemfile) are added directly — the repo URL comes from the lockfile.
10
+ Gems from private registries (e.g. GitHub Packages) have their repo URL inferred where possible or prompted for interactively; running non-interactively without a resolvable URL exits with a clear error.
11
+ - `gemkeeper setup` accepts either a Gemfile.lock or an existing `gemkeeper.yml` as its argument.
12
+ Passing a `gemkeeper.yml` updates the manifest with its repo mappings and, with `--global`, installs it to the global config path.
13
+ - The `--manifest` option controls the path used for both reading and writing the manifest, defaulting to `~/.config/gemkeeper/manifest.yml`.
14
+ - `gemkeeper setup` now automatically configures Bundler mirror settings for each private gem registry found in the lockfile, pointing them at the local Geminabox.
15
+ Uses `--local` scope by default; `--global` setup uses `--global` scope.
16
+ Pass `--skip-bundler-config` to opt out.
17
+
18
+ ### Fixed
19
+
20
+ - GIT-sourced gems (pinned in the `GIT` section of the lockfile) are now included in the generated `gemkeeper.yml`.
21
+ Previously they were silently omitted because the lockfile parser only read `GEM` sections.
22
+
3
23
  ## [0.3.0] - 2026-05-27
4
24
 
5
25
  ### Added
@@ -35,7 +55,8 @@
35
55
 
36
56
  - Initial release
37
57
 
38
- [Unreleased]: https://github.com/danhorst/gemkeeper/compare/v0.3.0...HEAD
58
+ [Unreleased]: https://github.com/danhorst/gemkeeper/compare/v0.4.0...HEAD
59
+ [0.4.0]: https://github.com/danhorst/gemkeeper/compare/0.3.0...0.4.0
39
60
  [0.3.0]: https://github.com/danhorst/gemkeeper/compare/0.2.1...0.3.0
40
61
  [0.2.1]: https://github.com/danhorst/gemkeeper/compare/0.2.0...0.2.1
41
62
  [0.2.0]: https://github.com/danhorst/gemkeeper/compare/0.1.0...0.2.0
@@ -4,10 +4,10 @@ module Gemkeeper
4
4
  module CLI
5
5
  module Commands
6
6
  class Setup < Dry::CLI::Command
7
- desc "Generate gemkeeper.yml from a Gemfile.lock and org manifest"
7
+ desc "Generate gemkeeper.yml from a Gemfile.lock or existing gemkeeper.yml"
8
8
 
9
- argument :lockfile_path, type: :string, required: true,
10
- desc: "Path to the project's Gemfile.lock"
9
+ argument :source_path, type: :string, required: true,
10
+ desc: "Path to a Gemfile.lock or gemkeeper.yml"
11
11
  option :manifest, type: :string,
12
12
  desc: "Path to gem manifest (default: ~/.config/gemkeeper/manifest.yml)"
13
13
  option :config, type: :string, desc: "Path to write gemkeeper.yml (default: ./gemkeeper.yml)"
@@ -15,27 +15,108 @@ module Gemkeeper
15
15
  desc: "Write to the global service config (for use with brew services)"
16
16
  option :force, type: :boolean, default: false,
17
17
  desc: "Overwrite existing gemkeeper.yml entirely"
18
+ option :skip_bundler_config, type: :boolean, default: false,
19
+ desc: "Skip configuring Bundler mirrors for private gem sources"
18
20
 
19
- def call(lockfile_path:, **options)
21
+ def call(source_path:, **options)
20
22
  validate_options!(options)
21
-
22
23
  output_path = resolve_output_path(options)
23
- manifest = ManifestReader.load(options[:manifest] || ManifestReader::DEFAULT_PATH)
24
+
25
+ if lockfile?(source_path)
26
+ setup_from_lockfile(source_path, output_path, options)
27
+ else
28
+ setup_from_config(source_path, output_path, options)
29
+ end
30
+ rescue UnresolvableGemError, ManifestConflictError => error
31
+ warn "Error: #{error.message}"
32
+ exit 1
33
+ end
34
+
35
+ private
36
+
37
+ def lockfile?(path)
38
+ File.extname(path) == ".lock" || File.basename(path) == "Gemfile.lock"
39
+ end
40
+
41
+ def setup_from_lockfile(lockfile_path, output_path, options)
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
49
+
24
50
  lockfile_versions = LockfileParser.parse(lockfile_path)
25
51
  global_output_path = options[:global] ? output_path : nil
26
-
27
52
  config = ConfigGenerator.new(manifest:, lockfile_versions:)
28
53
  .build(output_path, force: options[:force], global_output_path:)
29
54
 
30
55
  File.write(output_path, config.to_yaml)
31
56
  puts "Wrote #{output_path}"
32
- print_bundler_instructions(config, manifest)
33
- rescue ManifestNotFoundError => error
34
- warn "Error: #{error.message}"
35
- exit 1
57
+ configure_bundler(candidates, config, options) unless options[:skip_bundler_config]
36
58
  end
37
59
 
38
- private
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?
63
+
64
+ 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
77
+ end
78
+
79
+ def setup_from_config(source_path, output_path, options)
80
+ source = YAML.safe_load_file(source_path, permitted_classes: [], symbolize_names: false) || {}
81
+ manifest = load_manifest(options)
82
+ update_manifest_from_config(source, manifest, options)
83
+ install_global_config(source, output_path) if options[:global]
84
+ end
85
+
86
+ def update_manifest_from_config(source, manifest, options)
87
+ (source["gems"] || []).each do |entry|
88
+ repo = entry["repo"].to_s
89
+ next if repo.empty?
90
+
91
+ name = File.basename(repo, ".git").sub(/^ruby-/, "")
92
+ manifest.add_mapping(name:, repo:)
93
+ end
94
+ manifest.save(manifest_path(options))
95
+ end
96
+
97
+ def install_global_config(source, output_path)
98
+ existing = File.exist?(output_path) ? (YAML.safe_load_file(output_path) || {}) : {}
99
+ merged = existing.merge(source.except("gems")).merge("gems" => merge_gem_lists(existing, source))
100
+ File.write(output_path, merged.to_yaml)
101
+ puts "Wrote #{output_path}"
102
+ end
103
+
104
+ def merge_gem_lists(existing, source)
105
+ existing_gems = existing["gems"] || []
106
+ source_gems = source["gems"] || []
107
+ 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
111
+ end
112
+
113
+ def load_manifest(options)
114
+ ManifestReader.load(manifest_path(options))
115
+ end
116
+
117
+ def manifest_path(options)
118
+ options[:manifest] || ManifestReader::DEFAULT_PATH
119
+ end
39
120
 
40
121
  def validate_options!(options)
41
122
  return unless options[:global] && options[:config]
@@ -54,20 +135,6 @@ module Gemkeeper
54
135
  warn "Error: no writable global config path found — install Homebrew or create ~/.config/gemkeeper/"
55
136
  exit 1
56
137
  end
57
-
58
- def print_bundler_instructions(config, manifest)
59
- port = config.fetch("port", Configuration::DEFAULT_PORT)
60
- local_url = "http://localhost:#{port}"
61
- source_url = manifest.source_url
62
- puts ""
63
- puts "To point Bundler at your local Geminabox, run:"
64
- if source_url
65
- puts " bundle config set --local mirror.#{source_url} #{local_url}"
66
- else
67
- puts " bundle config set --local mirror.<your-private-gem-source-url> #{local_url}"
68
- puts " (Replace <your-private-gem-source-url> with the gem source URL from your Gemfile)"
69
- end
70
- end
71
138
  end
72
139
  end
73
140
 
@@ -6,7 +6,8 @@ module Gemkeeper
6
6
  class ConfigurationError < Error; end
7
7
  class ConfigFileNotFoundError < ConfigurationError; end
8
8
  class InvalidConfigError < ConfigurationError; end
9
- class ManifestNotFoundError < ConfigurationError; end
9
+ class UnresolvableGemError < ConfigurationError; end
10
+ class ManifestConflictError < ConfigurationError; end
10
11
 
11
12
  class GitError < Error; end
12
13
  class CloneError < GitError; end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gemkeeper
4
+ # Resolves gem candidates (from lockfile sources) to git repo URLs.
5
+ # GIT-sourced gems are added automatically; private gem registry entries
6
+ # are inferred where possible (GitHub Packages) or prompted interactively.
7
+ class GemRepoResolver
8
+ def initialize(candidates:, manifest:, input: $stdin, output: $stdout)
9
+ @candidates = candidates
10
+ @manifest = manifest
11
+ @input = input
12
+ @output = output
13
+ end
14
+
15
+ def resolve!
16
+ 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
+
34
+ raise_unresolvable(unresolvable) if unresolvable.any?
35
+ @manifest
36
+ end
37
+
38
+ private
39
+
40
+ def resolve_private_gem(candidate)
41
+ inferred = infer_repo(candidate)
42
+
43
+ if interactive?
44
+ prompt(candidate, inferred)
45
+ elsif inferred
46
+ warn "Note: auto-inferred repo for #{candidate[:name]}: #{inferred}"
47
+ inferred
48
+ end
49
+ end
50
+
51
+ def infer_repo(candidate)
52
+ remote = candidate[:remote].to_s
53
+ if (match = remote.match(%r{rubygems\.pkg\.github\.com/([^/]+)}))
54
+ "git@github.com:#{match[1]}/#{candidate[:name]}.git"
55
+ end
56
+ end
57
+
58
+ def prompt(candidate, inferred)
59
+ @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
66
+ end
67
+
68
+ def interactive?
69
+ @input.respond_to?(:isatty) && @input.isatty
70
+ end
71
+
72
+ def raise_unresolvable(candidates)
73
+ lines = candidates.map { |c| " - #{c[:name]} (from #{c[:remote]})" }.join("\n")
74
+ raise UnresolvableGemError,
75
+ "Cannot resolve repo URLs non-interactively for:\n#{lines}\n\n" \
76
+ "Run this command in a terminal to map these interactively, " \
77
+ "or add them to your manifest manually."
78
+ end
79
+ end
80
+ end
@@ -4,6 +4,7 @@ module Gemkeeper
4
4
  # Locates the nearest Gemfile.lock by walking up; callers don't need to know the search algorithm.
5
5
  class LockfileParser
6
6
  LOCKFILE_NAME = "Gemfile.lock"
7
+ VERSION_SECTION_TYPES = %w[GEM GIT].freeze
7
8
 
8
9
  def self.find(start_dir = Dir.pwd)
9
10
  dir = File.expand_path(start_dir)
@@ -23,38 +24,94 @@ module Gemkeeper
23
24
  new(lockfile_path).gem_versions
24
25
  end
25
26
 
27
+ def self.internal_sources(lockfile_path)
28
+ new(lockfile_path).internal_sources
29
+ end
30
+
26
31
  def initialize(lockfile_path)
27
32
  @lockfile_path = lockfile_path
28
33
  end
29
34
 
35
+ # Returns name => version for all GEM and GIT section gems (excludes dependency lines).
30
36
  def gem_versions
31
- content = File.read(@lockfile_path)
32
- extract_gem_section(content)
37
+ sections = parsed_sections
38
+ sections.select { |s| VERSION_SECTION_TYPES.include?(s[:type]) }
39
+ .each_with_object({}) { |section, versions| versions.merge!(versions_from_section(section)) }
40
+ end
41
+
42
+ # Returns an array of hashes describing gems from non-rubygems.org sources.
43
+ # Each hash has :name, :source_type (:git or :private_gem), and either
44
+ # :repo (for :git) or :remote (the private gem registry URL, for :private_gem).
45
+ def internal_sources
46
+ sections = parsed_sections
47
+ extract_git_sources(sections) + extract_private_gem_sources(sections)
33
48
  end
34
49
 
35
50
  private
36
51
 
37
- def extract_gem_section(content)
38
- versions = {}
39
- in_gem_specs = false
52
+ def parsed_sections
53
+ sections = []
54
+ current = nil
55
+ File.foreach(@lockfile_path) do |line|
56
+ if line.match?(/\A[A-Z]/)
57
+ sections << current if current
58
+ current = { type: line.strip, lines: [] }
59
+ elsif current
60
+ current[:lines] << line
61
+ end
62
+ end
63
+ sections << current if current
64
+ sections
65
+ end
40
66
 
41
- content.each_line do |line|
42
- stripped = line.strip
43
- if stripped == "GEM"
44
- in_gem_specs = true
45
- next
67
+ def versions_from_section(section)
68
+ in_specs = false
69
+ section[:lines].each_with_object({}) do |line, versions|
70
+ if line.strip == "specs:"
71
+ in_specs = true
72
+ elsif in_specs && (match = line.chomp.match(/\A ([a-zA-Z0-9_-]+) \(([^)]+)\)\z/))
73
+ versions[match[1]] = match[2]
46
74
  end
75
+ end
76
+ end
77
+
78
+ def extract_git_sources(sections)
79
+ sections.select { |s| s[:type] == "GIT" }.flat_map do |section|
80
+ remote = section_remote(section)
81
+ specs_from_section(section).map { |name| { name:, repo: remote, source_type: :git } }
82
+ end
83
+ end
47
84
 
48
- # A new top-level section (no leading spaces) ends the GEM block
49
- in_gem_specs = false if in_gem_specs && line =~ /\A[A-Z]/ && stripped != "GEM"
85
+ def extract_private_gem_sources(sections)
86
+ sections.select { |s| s[:type] == "GEM" }.flat_map do |section|
87
+ remote = section_remote(section)
88
+ next [] if rubygems_org?(remote)
50
89
 
51
- next unless in_gem_specs
90
+ specs_from_section(section).map { |name| { name:, remote:, source_type: :private_gem } }
91
+ end
92
+ end
52
93
 
53
- # Spec lines look like: " gem_name (version)" — exactly 4 spaces, no deeper indent
54
- versions[Regexp.last_match(1)] = Regexp.last_match(2) if line.chomp =~ /\A ([a-zA-Z0-9_-]+) \(([^)]+)\)\z/
94
+ def section_remote(section)
95
+ section[:lines].each do |line|
96
+ stripped = line.strip
97
+ return stripped.delete_prefix("remote:").strip if stripped.start_with?("remote:")
55
98
  end
99
+ nil
100
+ end
101
+
102
+ def specs_from_section(section)
103
+ in_specs = false
104
+ section[:lines].each_with_object([]) do |line, names|
105
+ if line.strip == "specs:"
106
+ in_specs = true
107
+ elsif in_specs && (match = line.match(/\A ([a-zA-Z0-9_-]+) \(/))
108
+ names << match[1]
109
+ end
110
+ end
111
+ end
56
112
 
57
- versions
113
+ def rubygems_org?(remote)
114
+ remote.to_s.include?("rubygems.org")
58
115
  end
59
116
  end
60
117
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "yaml"
4
+ require "fileutils"
4
5
 
5
6
  module Gemkeeper
6
7
  # Decouples manifest format from sync/setup commands that need the eligible gem list.
@@ -15,9 +16,9 @@ module Gemkeeper
15
16
 
16
17
  def initialize(path)
17
18
  @path = path
18
- raise ManifestNotFoundError, manifest_not_found_message unless File.exist?(@path)
19
-
20
- parse_manifest
19
+ @gems = []
20
+ @source_url = nil
21
+ parse_manifest if File.exist?(@path)
21
22
  end
22
23
 
23
24
  def gem_names
@@ -28,6 +29,30 @@ module Gemkeeper
28
29
  @gems.find { |gem_entry| gem_entry[:name] == name }
29
30
  end
30
31
 
32
+ def repo_for(name)
33
+ find_by_name(name)&.fetch(:repo)
34
+ end
35
+
36
+ # Adds a name→repo mapping. Idempotent for identical entries.
37
+ # Raises ManifestConflictError if the name exists with a different repo.
38
+ def add_mapping(name:, repo:)
39
+ existing = find_by_name(name)
40
+ if existing
41
+ raise ManifestConflictError, conflict_message(name, existing[:repo], repo) if existing[:repo] != repo
42
+ else
43
+ @gems << { name:, repo: }
44
+ end
45
+ self
46
+ end
47
+
48
+ 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)
54
+ end
55
+
31
56
  private
32
57
 
33
58
  def parse_manifest
@@ -38,9 +63,9 @@ module Gemkeeper
38
63
  end
39
64
  end
40
65
 
41
- def manifest_not_found_message
42
- "Manifest not found at #{@path}. " \
43
- "Install your org's gem manifest, then re-run setup."
66
+ def conflict_message(name, existing_repo, new_repo)
67
+ "Manifest conflict for #{name.inspect}: " \
68
+ "existing repo #{existing_repo.inspect} differs from #{new_repo.inspect}"
44
69
  end
45
70
  end
46
71
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Gemkeeper
4
- VERSION = "0.3.0"
4
+ VERSION = "0.4.0"
5
5
  end
data/lib/gemkeeper.rb CHANGED
@@ -6,6 +6,7 @@ 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/gem_repo_resolver"
9
10
  require_relative "gemkeeper/config_generator"
10
11
  require_relative "gemkeeper/git_repository"
11
12
  require_relative "gemkeeper/gem_builder"
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: gemkeeper
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dan Brubaker Horst
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-05-27 00:00:00.000000000 Z
11
+ date: 2026-05-28 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: dry-cli
@@ -107,6 +107,7 @@ files:
107
107
  - lib/gemkeeper/configuration.rb
108
108
  - lib/gemkeeper/errors.rb
109
109
  - lib/gemkeeper/gem_builder.rb
110
+ - lib/gemkeeper/gem_repo_resolver.rb
110
111
  - lib/gemkeeper/gem_uploader.rb
111
112
  - lib/gemkeeper/git_repository.rb
112
113
  - lib/gemkeeper/lockfile_parser.rb