gemkeeper 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -24,7 +24,7 @@ module Gemkeeper
24
24
  end
25
25
 
26
26
  def self.config_search_paths
27
- CONFIG_PATHS.map { |p| p.is_a?(Proc) ? p.call : p }
27
+ CONFIG_PATHS.map(&:call)
28
28
  end
29
29
 
30
30
  def initialize(config_path = nil)
@@ -51,6 +51,13 @@ module Gemkeeper
51
51
 
52
52
  private
53
53
 
54
+ def validate_port!
55
+ return if @port.is_a?(Integer) && (1..65_535).cover?(@port)
56
+
57
+ raise InvalidConfigError,
58
+ "port must be an integer between 1 and 65535, got #{@port.inspect}"
59
+ end
60
+
54
61
  def find_config_file
55
62
  self.class.config_search_paths.find { |path| File.exist?(path) }
56
63
  end
@@ -67,30 +74,48 @@ module Gemkeeper
67
74
 
68
75
  def apply_config
69
76
  @port = @config.fetch(:port, DEFAULT_PORT)
77
+ validate_port!
70
78
  @repos_path = File.expand_path(@config.fetch(:repos_path, "./cache/repos"))
71
79
  @gems_path = File.expand_path(@config.fetch(:gems_path, "./cache/gems"))
72
80
  @pid_file = File.expand_path(@config.fetch(:pid_file, "./cache/gemkeeper.pid"))
73
- @gems = (@config[:gems] || []).map { |g| GemDefinition.new(g) }
81
+ @gems = (@config[:gems] || []).map { |gem_config| GemDefinition.new(gem_config) }
74
82
 
75
83
  FileUtils.mkdir_p(@repos_path)
76
84
  FileUtils.mkdir_p(@gems_path)
77
85
  end
78
86
 
79
87
  class GemDefinition
88
+ VALID_VERSION_PATTERN = /\A[a-zA-Z0-9._-]+\z/
89
+ RESERVED_VERSIONS = %w[latest from_lockfile].freeze
90
+
80
91
  attr_reader :repo, :version, :name
81
92
 
82
93
  def initialize(config)
83
94
  @repo = config[:repo] or raise InvalidConfigError, "Gem definition missing 'repo'"
84
95
  @version = config[:version] || "latest"
85
96
  @name = config[:name] || extract_name_from_repo
97
+ validate_version!
86
98
  end
87
99
 
88
100
  def latest?
89
101
  @version == "latest"
90
102
  end
91
103
 
104
+ def from_lockfile?
105
+ @version == "from_lockfile"
106
+ end
107
+
92
108
  private
93
109
 
110
+ def validate_version!
111
+ return if RESERVED_VERSIONS.include?(@version)
112
+ return if @version.match?(VALID_VERSION_PATTERN)
113
+
114
+ raise InvalidConfigError,
115
+ "Invalid version #{@version.inspect} for gem #{@name.inspect} — " \
116
+ "must be \"latest\", \"from_lockfile\", or a tag string matching [a-zA-Z0-9._-]"
117
+ end
118
+
94
119
  def extract_name_from_repo
95
120
  File.basename(@repo, ".git").sub(/^ruby-/, "")
96
121
  end
@@ -6,6 +6,7 @@ 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
10
 
10
11
  class GitError < Error; end
11
12
  class CloneError < GitError; end
@@ -20,11 +20,13 @@ module Gemkeeper
20
20
  end
21
21
  end
22
22
 
23
+ SAFE_REF_PATTERN = /\A[a-zA-Z0-9._-]+\z/
24
+
23
25
  def checkout_version(version)
24
26
  if version == "latest"
25
27
  checkout_trunk
26
28
  else
27
- checkout_ref(version)
29
+ checkout_tag(version)
28
30
  end
29
31
  end
30
32
 
@@ -33,15 +35,9 @@ module Gemkeeper
33
35
  return nil unless gemspec_path
34
36
 
35
37
  content = File.read(gemspec_path)
36
- version_patterns = [
37
- /\.version\s*=\s*["']([^"']+)["']/,
38
- /VERSION\s*=\s*["']([^"']+)["']/
39
- ]
40
-
41
- version_patterns.each do |pattern|
42
- return Regexp.last_match(1) if content =~ pattern
43
- end
44
- nil
38
+ extract_version_from_content(content) ||
39
+ version_from_requires(content, File.dirname(gemspec_path)) ||
40
+ version_from_version_files
45
41
  end
46
42
 
47
43
  def find_gemspec
@@ -72,11 +68,56 @@ module Gemkeeper
72
68
  end
73
69
  end
74
70
 
75
- def checkout_ref(ref)
71
+ def validate_ref!(ref)
72
+ return if ref.match?(SAFE_REF_PATTERN)
73
+
74
+ raise GitError, "Unsafe ref rejected: #{ref.inspect} — must match [a-zA-Z0-9._-]"
75
+ end
76
+
77
+ def extract_version_from_content(content)
78
+ [
79
+ /\.version\s*=\s*["']([^"']+)["']/,
80
+ /\bVERSION\s*=\s*["']([^"']+)["']/
81
+ ].each do |pattern|
82
+ m = content.match(pattern)
83
+ return m[1] if m
84
+ end
85
+ nil
86
+ end
87
+
88
+ def version_from_requires(content, base_dir)
89
+ content.scan(/require_relative\s+["']([^"']+)["']/).each do |match|
90
+ base = match[0].delete_suffix(".rb")
91
+ path = File.expand_path("#{base}.rb", base_dir)
92
+ next unless File.exist?(path)
93
+
94
+ version = extract_version_from_content(File.read(path))
95
+ return version if version
96
+ end
97
+ nil
98
+ end
99
+
100
+ def version_from_version_files
101
+ Dir.glob(File.join(@local_path, "lib", "**", "version.rb")).each do |path|
102
+ version = extract_version_from_content(File.read(path))
103
+ return version if version
104
+ end
105
+ nil
106
+ end
107
+
108
+ def checkout_tag(version)
109
+ bare = version.delete_prefix("v")
110
+ validate_ref!(bare)
76
111
  Dir.chdir(@local_path) do
77
112
  run_git("fetch", "--all", "--tags")
78
- run_git("checkout", ref)
113
+ begin
114
+ run_git("checkout", "v#{bare}")
115
+ rescue GitError
116
+ run_git("checkout", bare)
117
+ end
79
118
  end
119
+ rescue GitError
120
+ raise GitError, "Could not find tag v#{bare} or #{bare} in #{@repo_url}"
80
121
  end
81
122
 
82
123
  def detect_trunk_branch
@@ -84,13 +125,11 @@ module Gemkeeper
84
125
  stdout, = run_git("branch", "-r")
85
126
  remotes = stdout.lines.map(&:strip)
86
127
 
87
- if remotes.any? { |r| r =~ %r{origin/main$} }
88
- "main"
89
- elsif remotes.any? { |r| r =~ %r{origin/master$} }
90
- "master"
91
- else
92
- raise GitError, "Cannot detect trunk branch (no main or master found)"
128
+ %w[main master].each do |branch|
129
+ return branch if remotes.any? { |remote| remote.end_with?("origin/#{branch}") }
93
130
  end
131
+
132
+ raise GitError, "Cannot detect trunk branch (no main or master found)"
94
133
  end
95
134
  end
96
135
 
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gemkeeper
4
+ class LockfileParser
5
+ LOCKFILE_NAME = "Gemfile.lock"
6
+
7
+ def self.find(start_dir = Dir.pwd)
8
+ dir = File.expand_path(start_dir)
9
+ loop do
10
+ candidate = File.join(dir, LOCKFILE_NAME)
11
+ return candidate if File.exist?(candidate)
12
+
13
+ parent = File.dirname(dir)
14
+ break if parent == dir
15
+
16
+ dir = parent
17
+ end
18
+ nil
19
+ end
20
+
21
+ def self.parse(lockfile_path)
22
+ new(lockfile_path).gem_versions
23
+ end
24
+
25
+ def initialize(lockfile_path)
26
+ @lockfile_path = lockfile_path
27
+ end
28
+
29
+ def gem_versions
30
+ content = File.read(@lockfile_path)
31
+ extract_gem_section(content)
32
+ end
33
+
34
+ private
35
+
36
+ def extract_gem_section(content)
37
+ versions = {}
38
+ in_gem_specs = false
39
+
40
+ content.each_line do |line|
41
+ if line.strip == "GEM"
42
+ in_gem_specs = true
43
+ next
44
+ end
45
+
46
+ # A new top-level section (no leading spaces) ends the GEM block
47
+ in_gem_specs = false if in_gem_specs && line =~ /\A[A-Z]/ && line.strip != "GEM"
48
+
49
+ next unless in_gem_specs
50
+
51
+ # Spec lines look like: " gem_name (version)" — exactly 4 spaces, no deeper indent
52
+ versions[Regexp.last_match(1)] = Regexp.last_match(2) if line.chomp =~ /\A ([a-zA-Z0-9_-]+) \(([^)]+)\)\z/
53
+ end
54
+
55
+ versions
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module Gemkeeper
6
+ class ManifestReader
7
+ DEFAULT_PATH = File.expand_path("~/.config/gemkeeper/manifest.yml")
8
+
9
+ attr_reader :gems, :source_url
10
+
11
+ def self.load(path = DEFAULT_PATH)
12
+ new(path)
13
+ end
14
+
15
+ def initialize(path)
16
+ @path = path
17
+ raise ManifestNotFoundError, manifest_not_found_message unless File.exist?(@path)
18
+
19
+ parse_manifest
20
+ end
21
+
22
+ def gem_names
23
+ @gems.map { |gem_entry| gem_entry[:name] }
24
+ end
25
+
26
+ def find_by_name(name)
27
+ @gems.find { |gem_entry| gem_entry[:name] == name }
28
+ end
29
+
30
+ private
31
+
32
+ def parse_manifest
33
+ data = YAML.safe_load_file(@path, permitted_classes: [], symbolize_names: true) || {}
34
+ @source_url = data[:source_url]&.to_s
35
+ @gems = (data[:gems] || []).map do |entry|
36
+ { name: entry[:name].to_s, repo: entry[:repo].to_s }
37
+ end
38
+ end
39
+
40
+ def manifest_not_found_message
41
+ "Manifest not found at #{@path}. " \
42
+ "Install your org's gem manifest, then re-run setup."
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gemkeeper
4
+ module Output
5
+ COLORS = {
6
+ green: "\e[32m",
7
+ yellow: "\e[33m",
8
+ red: "\e[31m",
9
+ dim: "\e[2m",
10
+ reset: "\e[0m"
11
+ }.freeze
12
+
13
+ module_function
14
+
15
+ def colorize(text, color)
16
+ return text unless $stdout.tty?
17
+
18
+ "#{COLORS[color]}#{text}#{COLORS[:reset]}"
19
+ end
20
+
21
+ def step(msg) = puts colorize(" #{msg}", :dim)
22
+ def success(msg) = puts colorize(msg, :green)
23
+ def skip(msg) = puts colorize(msg, :yellow)
24
+ def failure(msg) = warn colorize(msg, :red)
25
+ end
26
+ end
@@ -12,15 +12,13 @@ module Gemkeeper
12
12
  end
13
13
 
14
14
  def start
15
- raise ServerAlreadyRunningError, "Server is already running (PID: #{read_pid})" if running?
16
-
15
+ ensure_not_running!
17
16
  generate_config_ru
18
17
  start_server
19
18
  end
20
19
 
21
20
  def start_foreground
22
- raise ServerAlreadyRunningError, "Server is already running (PID: #{read_pid})" if running?
23
-
21
+ ensure_not_running!
24
22
  generate_config_ru
25
23
  start_server_foreground
26
24
  end
@@ -30,21 +28,10 @@ module Gemkeeper
30
28
 
31
29
  pid = read_pid
32
30
  Process.kill("TERM", pid)
33
-
34
- # Wait for process to stop
35
- 10.times do
36
- break unless process_alive?(pid)
37
-
38
- sleep 0.5
39
- end
40
-
41
- # Force kill if still running
42
- Process.kill("KILL", pid) if process_alive?(pid)
43
-
31
+ wait_for_process_exit(pid)
44
32
  cleanup_pid_file
45
33
  true
46
34
  rescue Errno::ESRCH
47
- # Process already dead
48
35
  cleanup_pid_file
49
36
  true
50
37
  end
@@ -68,9 +55,14 @@ module Gemkeeper
68
55
 
69
56
  private
70
57
 
58
+ def ensure_not_running!
59
+ raise ServerAlreadyRunningError, "Server is already running (PID: #{read_pid})" if running?
60
+ end
61
+
71
62
  def generate_config_ru
63
+ gems_path = config.gems_path
72
64
  FileUtils.mkdir_p(config.cache_dir)
73
- FileUtils.mkdir_p(config.gems_path)
65
+ FileUtils.mkdir_p(gems_path)
74
66
 
75
67
  content = <<~RUBY
76
68
  # frozen_string_literal: true
@@ -79,7 +71,7 @@ module Gemkeeper
79
71
  require "rubygems/indexer"
80
72
  require "geminabox"
81
73
 
82
- Geminabox.data = #{config.gems_path.inspect}
74
+ Geminabox.data = #{gems_path.inspect}
83
75
  Geminabox.rubygems_proxy = true
84
76
 
85
77
  run Geminabox::Server
@@ -88,15 +80,20 @@ module Gemkeeper
88
80
  File.write(config.config_ru_path, content)
89
81
  end
90
82
 
83
+ def build_rackup_cmd(*extra)
84
+ ["rackup", config.config_ru_path, "--host", "127.0.0.1", "-p", config.port.to_s, "-s", "puma", *extra]
85
+ end
86
+
87
+ def build_start_cmd
88
+ build_rackup_cmd("-D", "-P", config.pid_file)
89
+ end
90
+
91
+ def build_foreground_cmd
92
+ build_rackup_cmd
93
+ end
94
+
91
95
  def start_server
92
- cmd = [
93
- "rackup",
94
- config.config_ru_path,
95
- "-p", config.port.to_s,
96
- "-D",
97
- "-P", config.pid_file,
98
- "-s", "puma"
99
- ]
96
+ cmd = build_start_cmd
100
97
 
101
98
  Dir.chdir(config.cache_dir) do
102
99
  _stdout, stderr, status = Open3.capture3(*cmd)
@@ -109,41 +106,45 @@ module Gemkeeper
109
106
  end
110
107
 
111
108
  def start_server_foreground
112
- cmd = [
113
- "rackup",
114
- config.config_ru_path,
115
- "-p", config.port.to_s,
116
- "-s", "puma"
117
- ]
118
-
119
109
  Dir.chdir(config.cache_dir) do
120
- system(*cmd)
110
+ system(*build_foreground_cmd)
111
+ end
112
+ end
113
+
114
+ def wait_for_process_exit(pid)
115
+ 10.times do
116
+ return unless process_alive?(pid)
117
+
118
+ sleep 0.5
121
119
  end
120
+ Process.kill("KILL", pid) if process_alive?(pid)
122
121
  end
123
122
 
124
123
  def wait_for_server(timeout: 10)
125
124
  require "net/http"
126
125
 
127
- deadline = Time.now + timeout
128
126
  uri = URI(config.geminabox_url)
127
+ (timeout / 0.5).ceil.times do
128
+ return true if server_responding?(uri)
129
129
 
130
- while Time.now < deadline
131
- begin
132
- response = Net::HTTP.get_response(uri)
133
- return true if response.is_a?(Net::HTTPSuccess) || response.is_a?(Net::HTTPRedirection)
134
- rescue Errno::ECONNREFUSED, Errno::ECONNRESET, SocketError
135
- # Server not ready yet
136
- end
137
130
  sleep 0.5
138
131
  end
139
132
 
140
133
  raise ServerError, "Server failed to start within #{timeout} seconds"
141
134
  end
142
135
 
136
+ def server_responding?(uri)
137
+ response = Net::HTTP.get_response(uri)
138
+ response.is_a?(Net::HTTPSuccess) || response.is_a?(Net::HTTPRedirection)
139
+ rescue Errno::ECONNREFUSED, Errno::ECONNRESET, SocketError
140
+ false
141
+ end
142
+
143
143
  def read_pid
144
- return nil unless File.exist?(config.pid_file)
144
+ pid_file = config.pid_file
145
+ return nil unless File.exist?(pid_file)
145
146
 
146
- pid = File.read(config.pid_file).strip.to_i
147
+ pid = File.read(pid_file).strip.to_i
147
148
  pid.positive? ? pid : nil
148
149
  end
149
150
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Gemkeeper
4
- VERSION = "0.1.0"
4
+ VERSION = "0.2.1"
5
5
  end
data/lib/gemkeeper.rb CHANGED
@@ -2,7 +2,10 @@
2
2
 
3
3
  require_relative "gemkeeper/version"
4
4
  require_relative "gemkeeper/errors"
5
+ require_relative "gemkeeper/output"
5
6
  require_relative "gemkeeper/configuration"
7
+ require_relative "gemkeeper/lockfile_parser"
8
+ require_relative "gemkeeper/manifest_reader"
6
9
  require_relative "gemkeeper/git_repository"
7
10
  require_relative "gemkeeper/gem_builder"
8
11
  require_relative "gemkeeper/gem_uploader"
data/mise.toml ADDED
@@ -0,0 +1,6 @@
1
+ [env]
2
+ _.file = ".env"
3
+ _.path = [
4
+ "bin",
5
+ "exe",
6
+ ]
@@ -0,0 +1,75 @@
1
+ # Implementation Summary: 20260518-154733-gemkeeper-contractor-support
2
+
3
+ **Status:** Completed
4
+ **Date:** 2026-05-18
5
+
6
+ ## Overview
7
+
8
+ Implemented contractor support for gemkeeper: a `setup` command that generates config from a
9
+ Gemfile.lock + org manifest, lockfile-aware sync with idempotency and partial failure handling,
10
+ localhost-only server binding, ref injection protection, and a contractor setup sequence in the README.
11
+
12
+ Also fixed a pre-existing test failure (`test_checkout_version_with_tag`) caused by
13
+ `tag.gpgSign = true` in global git config silently breaking tag creation in test helpers.
14
+
15
+ ## Files Created
16
+
17
+ - `lib/gemkeeper/lockfile_parser.rb` — walks directories for Gemfile.lock, parses GEM section
18
+ - `lib/gemkeeper/manifest_reader.rb` — loads `~/.config/gemkeeper/manifest.yml`
19
+ - `lib/gemkeeper/cli/commands/setup.rb` — `gemkeeper setup` command (FR-1.1, FR-1.2)
20
+ - `test/gemkeeper/test_lockfile_parser.rb` — unit tests for LockfileParser
21
+ - `test/gemkeeper/test_manifest_reader.rb` — unit tests for ManifestReader
22
+ - `test/integration/test_setup_integration.rb` — integration tests for setup command
23
+ - `test/fixtures/sample.lock` — fixture Gemfile.lock with GEM and GIT sections
24
+ - `test/fixtures/sample_manifest.yml` — fixture manifest with 3 internal gems
25
+
26
+ ## Files Modified
27
+
28
+ - `lib/gemkeeper/errors.rb` — added `ManifestNotFoundError`
29
+ - `lib/gemkeeper/configuration.rb` — `from_lockfile?` predicate, version validation (AR-4.1)
30
+ - `lib/gemkeeper/git_repository.rb` — `validate_ref!` (AR-3.2), `checkout_resolved_version` (FR-2.1)
31
+ - `lib/gemkeeper/server_manager.rb` — `--host 127.0.0.1` in both cmd builders (AR-3.1); extracted `build_start_cmd` / `build_foreground_cmd` helpers
32
+ - `lib/gemkeeper/cli/commands/sync.rb` — `from_lockfile` resolution, idempotency skip, partial failure collection, auth error detection (FR-2.1–2.4)
33
+ - `lib/gemkeeper/cli.rb` — require setup command
34
+ - `lib/gemkeeper.rb` — require lockfile_parser, manifest_reader
35
+ - `test/gemkeeper/test_configuration.rb` — tests for `from_lockfile?`, validation
36
+ - `test/gemkeeper/test_git_repository.rb` — tests for ref validation
37
+ - `test/gemkeeper/test_server_manager.rb` — tests for `--host 127.0.0.1`
38
+ - `test/integration/test_cli_integration.rb` — tests for skip-cached, from_lockfile error, partial failure
39
+ - `test/integration/test_git_repository_integration.rb` — fixed `tag.gpgSign`/`commit.gpgSign` in helpers
40
+ - `README.md` — Contractor Setup section (FR-4.1), HTTPS URL examples (FR-4.2)
41
+
42
+ ## Test Results
43
+
44
+ ```
45
+ 89 runs, 207 assertions, 0 failures, 0 errors, 0 skips
46
+ bundle exec rubocop: no offenses detected
47
+ ```
48
+
49
+ Baseline before implementation: 57 runs (1 pre-existing error fixed before starting).
50
+
51
+ ## Spec Adherence
52
+
53
+ | Requirement | Status | Implementation | Test |
54
+ |-------------|--------|---------------|------|
55
+ | FR-1.1 setup command | Done | `cli/commands/setup.rb` | `test_setup_integration.rb` (7 tests) |
56
+ | FR-1.2 bundle config output | Done | `setup.rb:print_bundler_instructions` | `test_setup_prints_bundle_config_instruction` |
57
+ | FR-2.1 from_lockfile resolution | Done | `sync.rb:resolve_version`, `git_repository.rb:checkout_resolved_version` | `test_sync_from_lockfile_no_lockfile_exits_nonzero` |
58
+ | FR-2.2 skip cached versions | Done | `sync.rb:cached?` | `test_sync_skips_already_cached_gem` |
59
+ | FR-2.3 partial failure handling | Done | `sync.rb:run_sync` + `report_failures` | `test_sync_partial_failure_continues_and_exits_nonzero` |
60
+ | FR-2.4 git auth error handling | Done | `sync.rb:auth_error?` + `auth_failure_error` | Covered via partial failure path; no standalone auth-mock test |
61
+ | FR-3.1 localhost-only binding | Done | `server_manager.rb:build_start_cmd/build_foreground_cmd` | `test_start_server_command_binds_to_localhost` |
62
+ | FR-4.1 contractor setup sequence | Done | `README.md` Contractor Setup section | Documentation |
63
+ | FR-4.2 HTTPS URL examples | Done | `README.md` Configuration Options | Documentation |
64
+ | AR-3.1 --host 127.0.0.1 in rackup | Done | Both cmd builders in `server_manager.rb` | `test_start_server_*_binds_to_localhost` |
65
+ | AR-3.2 ref validation | Done | `git_repository.rb:validate_ref!` | `test_checkout_version_rejects_unsafe_ref/spaces` |
66
+ | AR-4.1 from_lockfile schema | Done | `configuration.rb:GemDefinition` | `test_from_lockfile_version_recognized`, `test_invalid_version_raises_error` |
67
+ | AR-4.2 no Geminabox patching | Done | Geminabox used as-is; no monkey-patching | — |
68
+ | AR-4.3 Bundler mirror approach | Done | `setup.rb` prints mirror cmd; README documents it | `test_setup_prints_bundle_config_instruction` |
69
+
70
+ ## Deviations from Spec
71
+
72
+ **FR-2.4 standalone test:** The auth error message format (containing "authentication" and the docs URL)
73
+ is exercised through the partial failure path in integration tests rather than a dedicated mock.
74
+ A real git auth error would propagate through the same code path.
75
+ The behavior is implemented correctly; the gap is test isolation, not coverage.