gemkeeper 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 45860b370cd7f5370ab01069c44b9547208b64b0de895e1e2e1f625d87873fec
4
+ data.tar.gz: 1989d795d04f3d368e5e26c419b6e8d10c59a2ae814a2d9cb28c1083d5cbf081
5
+ SHA512:
6
+ metadata.gz: 9e9ef5c779e1b6928562fbb39277f605aede5f46dc3d2d6937951b926a5fc296f2d530ffb5417548ab6155586e15a2e4eba990ad369fd7a2ec983999fb070bd5
7
+ data.tar.gz: b83947d19960c2439d7abcb49b4aa64113c1d5333bb133ab63cf6ce71d81d62ca403145c752545c1f58e02ebfcfc6f14ff92eba03903192e1b6d321136a0fe97
data/.env.example ADDED
@@ -0,0 +1 @@
1
+ RUBYGEMS_PROXY=true
data/.envrc ADDED
@@ -0,0 +1,3 @@
1
+ dotenv
2
+
3
+ PATH_add bin
data/.rubocop.yml ADDED
@@ -0,0 +1,30 @@
1
+ plugins:
2
+ - rubocop-performance
3
+
4
+ AllCops:
5
+ TargetRubyVersion: 3.1
6
+ NewCops: enable
7
+ SuggestExtensions: false
8
+ Exclude:
9
+ - cache/**/*
10
+ - vendor/**/*
11
+
12
+ Style/StringLiterals:
13
+ EnforcedStyle: double_quotes
14
+
15
+ Style/StringLiteralsInInterpolation:
16
+ EnforcedStyle: double_quotes
17
+
18
+ Style/Documentation:
19
+ Enabled: false
20
+
21
+ Metrics/MethodLength:
22
+ Max: 30
23
+
24
+ Metrics/AbcSize:
25
+ Max: 40
26
+
27
+ Metrics/ClassLength:
28
+ Max: 120
29
+ Exclude:
30
+ - test/**/*
data/AGENTS.md ADDED
@@ -0,0 +1,52 @@
1
+ # Gemkeeper
2
+
3
+ A Ruby CLI tool for managing offline development with private gem dependencies.
4
+
5
+ ## Purpose
6
+
7
+ Automate building internal gems from source and caching them in a local Geminabox server for offline Rails development when disconnected from VPN.
8
+
9
+ ## Architecture
10
+
11
+ - Ruby gem with CLI executable (`exe/gemkeeper`)
12
+ - YAML config for gem repository definitions
13
+ - Git operations to clone/pull internal repos
14
+ - Build gems at specified versions/tags
15
+ - Upload to local Geminabox server
16
+ - Geminabox proxies public gems from RubyGems.org
17
+
18
+ ## Config Example
19
+
20
+ ```yaml
21
+ port: 9292
22
+ repos_path: ./cache/repos
23
+ gems_path: ./cache/gems
24
+
25
+ gems:
26
+ - repo: git@github.com:company/internal-gem-1.git
27
+ version: latest
28
+ - repo: git@github.com:company/internal-gem-2.git
29
+ version: v2.3.1
30
+ ```
31
+
32
+ ## CLI Commands
33
+
34
+ - `gemkeeper version` - Print version
35
+ - `gemkeeper server start` - Start Geminabox server
36
+ - `gemkeeper server stop` - Stop Geminabox server
37
+ - `gemkeeper server status` - Check server status
38
+ - `gemkeeper sync` - Build and upload all configured gems
39
+ - `gemkeeper sync <gem-name>` - Sync specific gem
40
+ - `gemkeeper list` - Show locally uploaded gems
41
+
42
+ ## Development
43
+
44
+ ```bash
45
+ bundle install
46
+ bundle exec rake test # Run tests
47
+ bundle exec rubocop # Run linter
48
+ ```
49
+
50
+ ## Current Status
51
+
52
+ v1 complete - all core functionality implemented
data/CHANGELOG.md ADDED
@@ -0,0 +1,5 @@
1
+ ## [Unreleased]
2
+
3
+ ## [0.1.0] - 2026-01-29
4
+
5
+ - Initial release
data/CLAUDE.md ADDED
@@ -0,0 +1 @@
1
+ AGENTS.md
@@ -0,0 +1,132 @@
1
+ # Contributor Covenant Code of Conduct
2
+
3
+ ## Our Pledge
4
+
5
+ We as members, contributors, and leaders pledge to make participation in our
6
+ community a harassment-free experience for everyone, regardless of age, body
7
+ size, visible or invisible disability, ethnicity, sex characteristics, gender
8
+ identity and expression, level of experience, education, socio-economic status,
9
+ nationality, personal appearance, race, caste, color, religion, or sexual
10
+ identity and orientation.
11
+
12
+ We pledge to act and interact in ways that contribute to an open, welcoming,
13
+ diverse, inclusive, and healthy community.
14
+
15
+ ## Our Standards
16
+
17
+ Examples of behavior that contributes to a positive environment for our
18
+ community include:
19
+
20
+ * Demonstrating empathy and kindness toward other people
21
+ * Being respectful of differing opinions, viewpoints, and experiences
22
+ * Giving and gracefully accepting constructive feedback
23
+ * Accepting responsibility and apologizing to those affected by our mistakes,
24
+ and learning from the experience
25
+ * Focusing on what is best not just for us as individuals, but for the overall
26
+ community
27
+
28
+ Examples of unacceptable behavior include:
29
+
30
+ * The use of sexualized language or imagery, and sexual attention or advances of
31
+ any kind
32
+ * Trolling, insulting or derogatory comments, and personal or political attacks
33
+ * Public or private harassment
34
+ * Publishing others' private information, such as a physical or email address,
35
+ without their explicit permission
36
+ * Other conduct which could reasonably be considered inappropriate in a
37
+ professional setting
38
+
39
+ ## Enforcement Responsibilities
40
+
41
+ Community leaders are responsible for clarifying and enforcing our standards of
42
+ acceptable behavior and will take appropriate and fair corrective action in
43
+ response to any behavior that they deem inappropriate, threatening, offensive,
44
+ or harmful.
45
+
46
+ Community leaders have the right and responsibility to remove, edit, or reject
47
+ comments, commits, code, wiki edits, issues, and other contributions that are
48
+ not aligned to this Code of Conduct, and will communicate reasons for moderation
49
+ decisions when appropriate.
50
+
51
+ ## Scope
52
+
53
+ This Code of Conduct applies within all community spaces, and also applies when
54
+ an individual is officially representing the community in public spaces.
55
+ Examples of representing our community include using an official email address,
56
+ posting via an official social media account, or acting as an appointed
57
+ representative at an online or offline event.
58
+
59
+ ## Enforcement
60
+
61
+ Instances of abusive, harassing, or otherwise unacceptable behavior may be
62
+ reported to the community leaders responsible for enforcement at
63
+ [INSERT CONTACT METHOD].
64
+ All complaints will be reviewed and investigated promptly and fairly.
65
+
66
+ All community leaders are obligated to respect the privacy and security of the
67
+ reporter of any incident.
68
+
69
+ ## Enforcement Guidelines
70
+
71
+ Community leaders will follow these Community Impact Guidelines in determining
72
+ the consequences for any action they deem in violation of this Code of Conduct:
73
+
74
+ ### 1. Correction
75
+
76
+ **Community Impact**: Use of inappropriate language or other behavior deemed
77
+ unprofessional or unwelcome in the community.
78
+
79
+ **Consequence**: A private, written warning from community leaders, providing
80
+ clarity around the nature of the violation and an explanation of why the
81
+ behavior was inappropriate. A public apology may be requested.
82
+
83
+ ### 2. Warning
84
+
85
+ **Community Impact**: A violation through a single incident or series of
86
+ actions.
87
+
88
+ **Consequence**: A warning with consequences for continued behavior. No
89
+ interaction with the people involved, including unsolicited interaction with
90
+ those enforcing the Code of Conduct, for a specified period of time. This
91
+ includes avoiding interactions in community spaces as well as external channels
92
+ like social media. Violating these terms may lead to a temporary or permanent
93
+ ban.
94
+
95
+ ### 3. Temporary Ban
96
+
97
+ **Community Impact**: A serious violation of community standards, including
98
+ sustained inappropriate behavior.
99
+
100
+ **Consequence**: A temporary ban from any sort of interaction or public
101
+ communication with the community for a specified period of time. No public or
102
+ private interaction with the people involved, including unsolicited interaction
103
+ with those enforcing the Code of Conduct, is allowed during this period.
104
+ Violating these terms may lead to a permanent ban.
105
+
106
+ ### 4. Permanent Ban
107
+
108
+ **Community Impact**: Demonstrating a pattern of violation of community
109
+ standards, including sustained inappropriate behavior, harassment of an
110
+ individual, or aggression toward or disparagement of classes of individuals.
111
+
112
+ **Consequence**: A permanent ban from any sort of public interaction within the
113
+ community.
114
+
115
+ ## Attribution
116
+
117
+ This Code of Conduct is adapted from the [Contributor Covenant][homepage],
118
+ version 2.1, available at
119
+ [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1].
120
+
121
+ Community Impact Guidelines were inspired by
122
+ [Mozilla's code of conduct enforcement ladder][Mozilla CoC].
123
+
124
+ For answers to common questions about this code of conduct, see the FAQ at
125
+ [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at
126
+ [https://www.contributor-covenant.org/translations][translations].
127
+
128
+ [homepage]: https://www.contributor-covenant.org
129
+ [v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
130
+ [Mozilla CoC]: https://github.com/mozilla/diversity
131
+ [FAQ]: https://www.contributor-covenant.org/faq
132
+ [translations]: https://www.contributor-covenant.org/translations
data/LICENSE ADDED
@@ -0,0 +1,5 @@
1
+ Copyright 2026 Dan Brubaker Horst
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so.
4
+
5
+ THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/Makefile ADDED
@@ -0,0 +1,26 @@
1
+ .PHONY: all build test fmt clean
2
+
3
+ # Find all tool directories under cmd/
4
+ TOOLS := $(wildcard cmd/*)
5
+ BINARIES := $(notdir $(TOOLS))
6
+
7
+ all: build
8
+
9
+ build:
10
+ @for tool in $(BINARIES); do \
11
+ if [ -d "cmd/$$tool" ] && [ -f "cmd/$$tool/main.go" ]; then \
12
+ echo "Building $$tool..."; \
13
+ go build -o bin/$$tool ./cmd/$$tool; \
14
+ fi \
15
+ done
16
+
17
+ test:
18
+ go test .
19
+
20
+ fmt:
21
+ gofmt -w .
22
+
23
+ clean:
24
+ rm -f bin/*
25
+ @# Preserve agent-setup script
26
+ @git checkout bin/agent-setup 2>/dev/null || true
data/README.md ADDED
@@ -0,0 +1,19 @@
1
+ # Gemkeeper
2
+
3
+ An opinionated wrapper around [Gem in a Box][1] for local, sandboxed Ruby development.
4
+
5
+ ## Usage
6
+
7
+ This project can be run directly on your machine or in a Docker container.
8
+ For a local service, you'll need to install [Ruby][2], ideally with a [version manager][3].
9
+
10
+ ## Colophon
11
+
12
+ This project has been augmented with [Claude Code][4].
13
+ The instructions for all agents are all in `AGENTS.md`.
14
+ There is a utility script (`bin/agent-setup`) that takes care of symlinking `AGENTS.md` to `CLAUDE.md`.
15
+
16
+ [1]: https://github.com/geminabox/geminabox
17
+ [2]: https://www.ruby-lang.org/en/
18
+ [3]: https://www.ruby-lang.org/en/documentation/installation/#managers
19
+ [4]: https://claude.com/product/claude-code
data/Rakefile ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "minitest/test_task"
5
+
6
+ Minitest::TestTask.create
7
+
8
+ require "rubocop/rake_task"
9
+
10
+ RuboCop::RakeTask.new
11
+
12
+ task default: %i[test rubocop]
data/exe/gemkeeper ADDED
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "gemkeeper"
5
+ require "gemkeeper/cli"
6
+
7
+ Dry::CLI.new(Gemkeeper::CLI).call
@@ -0,0 +1,26 @@
1
+ # Gemkeeper configuration file
2
+ # Copy this to gemkeeper.yml and modify as needed
3
+
4
+ # Server port (default: 9292)
5
+ port: 9292
6
+
7
+ # Path for cloned repositories (default: ./cache/repos)
8
+ repos_path: ./cache/repos
9
+
10
+ # Path for Geminabox gem storage (default: ./cache/gems)
11
+ gems_path: ./cache/gems
12
+
13
+ # List of internal gems to sync
14
+ gems:
15
+ # Sync the latest version from the main/master branch
16
+ - repo: git@github.com:company/internal-gem.git
17
+ version: latest
18
+
19
+ # Sync a specific tag or branch
20
+ - repo: git@github.com:company/another-gem.git
21
+ version: v2.3.1
22
+
23
+ # Override the gem name if it differs from the repo name
24
+ - repo: git@github.com:company/ruby-mylib.git
25
+ name: mylib
26
+ version: latest
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gemkeeper
4
+ module CLI
5
+ module Commands
6
+ class List < Dry::CLI::Command
7
+ desc "List gems cached in Geminabox"
8
+
9
+ option :config, type: :string, desc: "Path to config file"
10
+
11
+ def call(**options)
12
+ config = Configuration.load(options[:config])
13
+
14
+ gem_files = Dir.glob(File.join(config.gems_path, "gems", "*.gem"))
15
+
16
+ if gem_files.empty?
17
+ puts "No gems cached in Geminabox"
18
+ puts " Gems directory: #{config.gems_path}"
19
+ return
20
+ end
21
+
22
+ puts "Cached gems:"
23
+ gem_files.sort.each do |gem_file|
24
+ gem_name = File.basename(gem_file, ".gem")
25
+ puts " #{gem_name}"
26
+ end
27
+ end
28
+ end
29
+ end
30
+
31
+ register "list", Commands::List
32
+ end
33
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gemkeeper
4
+ module CLI
5
+ module Commands
6
+ module Server
7
+ class Start < Dry::CLI::Command
8
+ desc "Start the Geminabox server"
9
+
10
+ option :port, type: :integer, desc: "Port to run server on"
11
+ option :config, type: :string, desc: "Path to config file"
12
+ option :foreground, type: :boolean, default: false, aliases: ["-f"],
13
+ desc: "Run in foreground (don't daemonize)"
14
+
15
+ def call(**options)
16
+ config = Configuration.load(options[:config])
17
+ config = override_port(config, options[:port]) if options[:port]
18
+
19
+ manager = ServerManager.new(config)
20
+
21
+ if options[:foreground]
22
+ puts "Starting Geminabox server at #{config.geminabox_url}"
23
+ puts "Press Ctrl+C to stop"
24
+ manager.start_foreground
25
+ else
26
+ manager.start
27
+ puts "Geminabox server started at #{config.geminabox_url}"
28
+ puts "PID: #{File.read(config.pid_file).strip}"
29
+ end
30
+ rescue ServerAlreadyRunningError => e
31
+ warn "Error: #{e.message}"
32
+ exit 1
33
+ rescue ServerError => e
34
+ warn "Error starting server: #{e.message}"
35
+ exit 1
36
+ rescue Interrupt
37
+ puts "\nShutting down..."
38
+ end
39
+
40
+ private
41
+
42
+ def override_port(config, port)
43
+ config.instance_variable_set(:@port, port)
44
+ config
45
+ end
46
+ end
47
+ end
48
+ end
49
+
50
+ register "server start", Commands::Server::Start
51
+ end
52
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gemkeeper
4
+ module CLI
5
+ module Commands
6
+ module Server
7
+ class Status < Dry::CLI::Command
8
+ desc "Check Geminabox server status"
9
+
10
+ option :config, type: :string, desc: "Path to config file"
11
+
12
+ def call(**options)
13
+ config = Configuration.load(options[:config])
14
+ manager = ServerManager.new(config)
15
+ status = manager.status
16
+
17
+ if status[:running]
18
+ puts "Geminabox server is running"
19
+ puts " PID: #{status[:pid]}"
20
+ puts " URL: #{status[:url]}"
21
+ else
22
+ puts "Geminabox server is not running"
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
28
+
29
+ register "server status", Commands::Server::Status
30
+ end
31
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gemkeeper
4
+ module CLI
5
+ module Commands
6
+ module Server
7
+ class Stop < Dry::CLI::Command
8
+ desc "Stop the Geminabox server"
9
+
10
+ option :config, type: :string, desc: "Path to config file"
11
+
12
+ def call(**options)
13
+ config = Configuration.load(options[:config])
14
+ manager = ServerManager.new(config)
15
+ manager.stop
16
+
17
+ puts "Geminabox server stopped"
18
+ rescue ServerNotRunningError => e
19
+ warn "Error: #{e.message}"
20
+ exit 1
21
+ rescue ServerError => e
22
+ warn "Error stopping server: #{e.message}"
23
+ exit 1
24
+ end
25
+ end
26
+ end
27
+ end
28
+
29
+ register "server stop", Commands::Server::Stop
30
+ end
31
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gemkeeper
4
+ module CLI
5
+ module Commands
6
+ class Sync < Dry::CLI::Command
7
+ desc "Sync gems from configured repositories"
8
+
9
+ argument :gem_name, type: :string, required: false, desc: "Specific gem to sync"
10
+ option :config, type: :string, desc: "Path to config file"
11
+
12
+ def call(gem_name: nil, **options)
13
+ config = Configuration.load(options[:config])
14
+
15
+ if config.gems.empty?
16
+ warn "No gems configured. Add gems to your gemkeeper.yml file."
17
+ exit 1
18
+ end
19
+
20
+ gems_to_sync = if gem_name
21
+ config.gems.select { |g| g.name == gem_name }
22
+ else
23
+ config.gems
24
+ end
25
+
26
+ if gems_to_sync.empty?
27
+ warn "No matching gem found: #{gem_name}"
28
+ exit 1
29
+ end
30
+
31
+ uploader = GemUploader.new(config.geminabox_url)
32
+
33
+ gems_to_sync.each do |gem_def|
34
+ sync_gem(gem_def, config, uploader)
35
+ end
36
+ rescue Error => e
37
+ warn "Error: #{e.message}"
38
+ exit 1
39
+ end
40
+
41
+ private
42
+
43
+ def sync_gem(gem_def, config, uploader)
44
+ puts "Syncing #{gem_def.name}..."
45
+
46
+ # Clone or pull the repository
47
+ local_path = File.join(config.repos_path, gem_def.name)
48
+ repo = GitRepository.new(gem_def.repo, local_path)
49
+
50
+ puts " Fetching from #{gem_def.repo}..."
51
+ repo.clone_or_pull
52
+
53
+ # Checkout the specified version
54
+ puts " Checking out #{gem_def.version}..."
55
+ repo.checkout_version(gem_def.version)
56
+
57
+ # Build the gem
58
+ puts " Building gem..."
59
+ builder = GemBuilder.new(local_path)
60
+ gem_path = builder.build
61
+
62
+ # Upload to Geminabox
63
+ puts " Uploading to Geminabox..."
64
+ result = uploader.upload(gem_path)
65
+
66
+ puts " #{result[:message]}"
67
+
68
+ # Clean up the built gem file
69
+ FileUtils.rm_f(gem_path)
70
+
71
+ puts " Done!"
72
+ rescue GitError => e
73
+ warn " Git error: #{e.message}"
74
+ raise
75
+ rescue BuildError => e
76
+ warn " Build error: #{e.message}"
77
+ raise
78
+ rescue UploadError => e
79
+ warn " Upload error: #{e.message}"
80
+ raise
81
+ end
82
+ end
83
+ end
84
+
85
+ register "sync", Commands::Sync
86
+ end
87
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gemkeeper
4
+ module CLI
5
+ module Commands
6
+ class Version < Dry::CLI::Command
7
+ desc "Print version"
8
+
9
+ def call(*)
10
+ puts "gemkeeper #{Gemkeeper::VERSION}"
11
+ end
12
+ end
13
+ end
14
+
15
+ register "version", Commands::Version, aliases: ["-v", "--version"]
16
+ end
17
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry/cli"
4
+
5
+ module Gemkeeper
6
+ module CLI
7
+ extend Dry::CLI::Registry
8
+
9
+ module Commands
10
+ extend Dry::CLI::Registry
11
+ end
12
+ end
13
+ end
14
+
15
+ require_relative "cli/commands/version"
16
+ require_relative "cli/commands/sync"
17
+ require_relative "cli/commands/list"
18
+ require_relative "cli/commands/server/start"
19
+ require_relative "cli/commands/server/stop"
20
+ require_relative "cli/commands/server/status"
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require "fileutils"
5
+
6
+ module Gemkeeper
7
+ class Configuration
8
+ DEFAULT_PORT = 9292
9
+ DEFAULT_CONFIG_FILENAME = "gemkeeper.yml"
10
+
11
+ # Config file lookup paths in order of priority
12
+ CONFIG_PATHS = [
13
+ -> { File.join(Dir.pwd, DEFAULT_CONFIG_FILENAME) }, # ./gemkeeper.yml
14
+ -> { File.expand_path("~/.config/gemkeeper/config.yml") }, # XDG config
15
+ -> { File.expand_path("~/.gemkeeper.yml") }, # Home directory
16
+ -> { "/usr/local/etc/gemkeeper.yml" }, # Homebrew (Intel)
17
+ -> { "/opt/homebrew/etc/gemkeeper.yml" } # Homebrew (Apple Silicon)
18
+ ].freeze
19
+
20
+ attr_reader :port, :repos_path, :gems_path, :pid_file, :gems
21
+
22
+ def self.load(config_path = nil)
23
+ new(config_path)
24
+ end
25
+
26
+ def self.config_search_paths
27
+ CONFIG_PATHS.map { |p| p.is_a?(Proc) ? p.call : p }
28
+ end
29
+
30
+ def initialize(config_path = nil)
31
+ @config_path = config_path || find_config_file
32
+ @config = load_config
33
+ apply_config
34
+ end
35
+
36
+ def geminabox_url
37
+ "http://localhost:#{port}"
38
+ end
39
+
40
+ def config_ru_path
41
+ File.join(cache_dir, "config.ru")
42
+ end
43
+
44
+ def cache_dir
45
+ @cache_dir ||= begin
46
+ dir = File.expand_path("./cache")
47
+ FileUtils.mkdir_p(dir)
48
+ dir
49
+ end
50
+ end
51
+
52
+ private
53
+
54
+ def find_config_file
55
+ self.class.config_search_paths.find { |path| File.exist?(path) }
56
+ end
57
+
58
+ def load_config
59
+ return {} unless @config_path && File.exist?(@config_path)
60
+
61
+ begin
62
+ YAML.safe_load_file(@config_path, permitted_classes: [], symbolize_names: true) || {}
63
+ rescue Psych::SyntaxError => e
64
+ raise InvalidConfigError, "Invalid YAML in #{@config_path}: #{e.message}"
65
+ end
66
+ end
67
+
68
+ def apply_config
69
+ @port = @config.fetch(:port, DEFAULT_PORT)
70
+ @repos_path = File.expand_path(@config.fetch(:repos_path, "./cache/repos"))
71
+ @gems_path = File.expand_path(@config.fetch(:gems_path, "./cache/gems"))
72
+ @pid_file = File.expand_path(@config.fetch(:pid_file, "./cache/gemkeeper.pid"))
73
+ @gems = (@config[:gems] || []).map { |g| GemDefinition.new(g) }
74
+
75
+ FileUtils.mkdir_p(@repos_path)
76
+ FileUtils.mkdir_p(@gems_path)
77
+ end
78
+
79
+ class GemDefinition
80
+ attr_reader :repo, :version, :name
81
+
82
+ def initialize(config)
83
+ @repo = config[:repo] or raise InvalidConfigError, "Gem definition missing 'repo'"
84
+ @version = config[:version] || "latest"
85
+ @name = config[:name] || extract_name_from_repo
86
+ end
87
+
88
+ def latest?
89
+ @version == "latest"
90
+ end
91
+
92
+ private
93
+
94
+ def extract_name_from_repo
95
+ File.basename(@repo, ".git").sub(/^ruby-/, "")
96
+ end
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gemkeeper
4
+ class Error < StandardError; end
5
+
6
+ class ConfigurationError < Error; end
7
+ class ConfigFileNotFoundError < ConfigurationError; end
8
+ class InvalidConfigError < ConfigurationError; end
9
+
10
+ class GitError < Error; end
11
+ class CloneError < GitError; end
12
+ class CheckoutError < GitError; end
13
+
14
+ class BuildError < Error; end
15
+ class GemspecNotFoundError < BuildError; end
16
+
17
+ class UploadError < Error; end
18
+
19
+ class ServerError < Error; end
20
+ class ServerAlreadyRunningError < ServerError; end
21
+ class ServerNotRunningError < ServerError; end
22
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require "fileutils"
5
+
6
+ module Gemkeeper
7
+ class GemBuilder
8
+ attr_reader :repo_path, :output_dir
9
+
10
+ def initialize(repo_path, output_dir = nil)
11
+ @repo_path = repo_path
12
+ @output_dir = output_dir
13
+ end
14
+
15
+ def build
16
+ gemspec_path = find_gemspec
17
+ raise GemspecNotFoundError, "No gemspec found in #{@repo_path}" unless gemspec_path
18
+
19
+ Dir.chdir(@repo_path) do
20
+ gem_file = run_gem_build(gemspec_path)
21
+ gem_path = File.join(@repo_path, gem_file)
22
+
23
+ if @output_dir
24
+ FileUtils.mkdir_p(@output_dir)
25
+ dest_path = File.join(@output_dir, gem_file)
26
+ FileUtils.mv(gem_path, dest_path)
27
+ dest_path
28
+ else
29
+ gem_path
30
+ end
31
+ end
32
+ end
33
+
34
+ def gem_name
35
+ gemspec_path = find_gemspec
36
+ return nil unless gemspec_path
37
+
38
+ File.basename(gemspec_path, ".gemspec")
39
+ end
40
+
41
+ private
42
+
43
+ def find_gemspec
44
+ Dir.glob(File.join(@repo_path, "*.gemspec")).first
45
+ end
46
+
47
+ def run_gem_build(gemspec_path)
48
+ stdout, stderr, status = Open3.capture3("gem", "build", gemspec_path)
49
+
50
+ raise BuildError, "Gem build failed:\n#{stderr}" unless status.success?
51
+
52
+ # Parse the output to find the generated gem file
53
+ unless stdout =~ /File:\s+(\S+\.gem)/
54
+ raise BuildError, "Could not determine built gem file from output:\n#{stdout}"
55
+ end
56
+
57
+ Regexp.last_match(1)
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "faraday"
4
+ require "faraday/multipart"
5
+
6
+ module Gemkeeper
7
+ class GemUploader
8
+ attr_reader :geminabox_url
9
+
10
+ def initialize(geminabox_url)
11
+ @geminabox_url = geminabox_url
12
+ end
13
+
14
+ def upload(gem_path)
15
+ raise UploadError, "Gem file not found: #{gem_path}" unless File.exist?(gem_path)
16
+
17
+ response = connection.post("/upload") do |req|
18
+ req.body = {
19
+ file: Faraday::Multipart::FilePart.new(
20
+ gem_path,
21
+ "application/octet-stream",
22
+ File.basename(gem_path)
23
+ )
24
+ }
25
+ end
26
+
27
+ handle_response(response, gem_path)
28
+ end
29
+
30
+ def list_gems
31
+ response = connection.get("/api/v1/gems.json")
32
+
33
+ raise UploadError, "Failed to list gems: #{response.status} #{response.body}" unless response.success?
34
+
35
+ JSON.parse(response.body)
36
+ rescue JSON::ParserError => e
37
+ raise UploadError, "Invalid JSON response: #{e.message}"
38
+ rescue Faraday::Error => e
39
+ raise UploadError, "Connection error: #{e.message}"
40
+ end
41
+
42
+ private
43
+
44
+ def connection
45
+ @connection ||= Faraday.new(url: @geminabox_url) do |f|
46
+ f.request :multipart
47
+ f.request :url_encoded
48
+ f.adapter Faraday::Adapter::NetHttp
49
+ end
50
+ end
51
+
52
+ def handle_response(response, gem_path)
53
+ case response.status
54
+ when 200, 201, 302
55
+ { success: true, message: "Uploaded #{File.basename(gem_path)}" }
56
+ when 409
57
+ { success: true, message: "#{File.basename(gem_path)} already exists", skipped: true }
58
+ else
59
+ raise UploadError, "Upload failed (#{response.status}): #{response.body}"
60
+ end
61
+ rescue Faraday::Error => e
62
+ raise UploadError, "Connection error: #{e.message}"
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require "fileutils"
5
+
6
+ module Gemkeeper
7
+ class GitRepository
8
+ attr_reader :repo_url, :local_path
9
+
10
+ def initialize(repo_url, local_path)
11
+ @repo_url = repo_url
12
+ @local_path = local_path
13
+ end
14
+
15
+ def clone_or_pull
16
+ if File.directory?(File.join(@local_path, ".git"))
17
+ pull
18
+ else
19
+ clone
20
+ end
21
+ end
22
+
23
+ def checkout_version(version)
24
+ if version == "latest"
25
+ checkout_trunk
26
+ else
27
+ checkout_ref(version)
28
+ end
29
+ end
30
+
31
+ def current_version
32
+ gemspec_path = find_gemspec
33
+ return nil unless gemspec_path
34
+
35
+ 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
45
+ end
46
+
47
+ def find_gemspec
48
+ Dir.glob(File.join(@local_path, "*.gemspec")).first
49
+ end
50
+
51
+ private
52
+
53
+ def clone
54
+ FileUtils.mkdir_p(File.dirname(@local_path))
55
+ run_git("clone", @repo_url, @local_path)
56
+ end
57
+
58
+ def pull
59
+ Dir.chdir(@local_path) do
60
+ run_git("fetch", "--all", "--tags")
61
+ trunk = detect_trunk_branch
62
+ run_git("checkout", trunk)
63
+ run_git("pull", "origin", trunk)
64
+ end
65
+ end
66
+
67
+ def checkout_trunk
68
+ Dir.chdir(@local_path) do
69
+ trunk = detect_trunk_branch
70
+ run_git("checkout", trunk)
71
+ run_git("pull", "origin", trunk)
72
+ end
73
+ end
74
+
75
+ def checkout_ref(ref)
76
+ Dir.chdir(@local_path) do
77
+ run_git("fetch", "--all", "--tags")
78
+ run_git("checkout", ref)
79
+ end
80
+ end
81
+
82
+ def detect_trunk_branch
83
+ Dir.chdir(@local_path) do
84
+ stdout, = run_git("branch", "-r")
85
+ remotes = stdout.lines.map(&:strip)
86
+
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)"
93
+ end
94
+ end
95
+ end
96
+
97
+ def run_git(*args)
98
+ cmd = ["git"] + args
99
+ stdout, stderr, status = Open3.capture3(*cmd)
100
+
101
+ raise GitError, "Git command failed: #{cmd.join(" ")}\n#{stderr}" unless status.success?
102
+
103
+ [stdout, stderr]
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,161 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require "fileutils"
5
+
6
+ module Gemkeeper
7
+ class ServerManager
8
+ attr_reader :config
9
+
10
+ def initialize(config)
11
+ @config = config
12
+ end
13
+
14
+ def start
15
+ raise ServerAlreadyRunningError, "Server is already running (PID: #{read_pid})" if running?
16
+
17
+ generate_config_ru
18
+ start_server
19
+ end
20
+
21
+ def start_foreground
22
+ raise ServerAlreadyRunningError, "Server is already running (PID: #{read_pid})" if running?
23
+
24
+ generate_config_ru
25
+ start_server_foreground
26
+ end
27
+
28
+ def stop
29
+ raise ServerNotRunningError, "Server is not running" unless running?
30
+
31
+ pid = read_pid
32
+ 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
+
44
+ cleanup_pid_file
45
+ true
46
+ rescue Errno::ESRCH
47
+ # Process already dead
48
+ cleanup_pid_file
49
+ true
50
+ end
51
+
52
+ def status
53
+ if running?
54
+ { running: true, pid: read_pid, url: config.geminabox_url }
55
+ else
56
+ { running: false }
57
+ end
58
+ end
59
+
60
+ def running?
61
+ return false unless File.exist?(config.pid_file)
62
+
63
+ pid = read_pid
64
+ return false unless pid
65
+
66
+ process_alive?(pid)
67
+ end
68
+
69
+ private
70
+
71
+ def generate_config_ru
72
+ FileUtils.mkdir_p(config.cache_dir)
73
+ FileUtils.mkdir_p(config.gems_path)
74
+
75
+ content = <<~RUBY
76
+ # frozen_string_literal: true
77
+ # Auto-generated by Gemkeeper
78
+
79
+ require "rubygems/indexer"
80
+ require "geminabox"
81
+
82
+ Geminabox.data = #{config.gems_path.inspect}
83
+ Geminabox.rubygems_proxy = true
84
+
85
+ run Geminabox::Server
86
+ RUBY
87
+
88
+ File.write(config.config_ru_path, content)
89
+ end
90
+
91
+ 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
+ ]
100
+
101
+ Dir.chdir(config.cache_dir) do
102
+ _stdout, stderr, status = Open3.capture3(*cmd)
103
+
104
+ raise ServerError, "Failed to start server:\n#{stderr}" unless status.success? || File.exist?(config.pid_file)
105
+ end
106
+
107
+ # Wait for server to be ready
108
+ wait_for_server
109
+ end
110
+
111
+ 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
+ Dir.chdir(config.cache_dir) do
120
+ system(*cmd)
121
+ end
122
+ end
123
+
124
+ def wait_for_server(timeout: 10)
125
+ require "net/http"
126
+
127
+ deadline = Time.now + timeout
128
+ uri = URI(config.geminabox_url)
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
+ sleep 0.5
138
+ end
139
+
140
+ raise ServerError, "Server failed to start within #{timeout} seconds"
141
+ end
142
+
143
+ def read_pid
144
+ return nil unless File.exist?(config.pid_file)
145
+
146
+ pid = File.read(config.pid_file).strip.to_i
147
+ pid.positive? ? pid : nil
148
+ end
149
+
150
+ def process_alive?(pid)
151
+ Process.kill(0, pid)
152
+ true
153
+ rescue Errno::ESRCH, Errno::EPERM
154
+ false
155
+ end
156
+
157
+ def cleanup_pid_file
158
+ FileUtils.rm_f(config.pid_file)
159
+ end
160
+ end
161
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gemkeeper
4
+ VERSION = "0.1.0"
5
+ end
data/lib/gemkeeper.rb ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "gemkeeper/version"
4
+ require_relative "gemkeeper/errors"
5
+ require_relative "gemkeeper/configuration"
6
+ require_relative "gemkeeper/git_repository"
7
+ require_relative "gemkeeper/gem_builder"
8
+ require_relative "gemkeeper/gem_uploader"
9
+ require_relative "gemkeeper/server_manager"
10
+
11
+ module Gemkeeper
12
+ end
data/sig/gemkeeper.rbs ADDED
@@ -0,0 +1,4 @@
1
+ module Gemkeeper
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+ end
metadata ADDED
@@ -0,0 +1,145 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: gemkeeper
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Dan Brubaker Horst
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: dry-cli
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '1.0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '1.0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: faraday
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '2.0'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '2.0'
40
+ - !ruby/object:Gem::Dependency
41
+ name: faraday-multipart
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '1.0'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '1.0'
54
+ - !ruby/object:Gem::Dependency
55
+ name: geminabox
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '2.0'
61
+ type: :runtime
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: '2.0'
68
+ - !ruby/object:Gem::Dependency
69
+ name: puma
70
+ requirement: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - "~>"
73
+ - !ruby/object:Gem::Version
74
+ version: '6.0'
75
+ type: :runtime
76
+ prerelease: false
77
+ version_requirements: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - "~>"
80
+ - !ruby/object:Gem::Version
81
+ version: '6.0'
82
+ description: An opinionated wrapper around Gem in a Box to manage private gems in
83
+ a development environment.
84
+ email:
85
+ - dan.brubaker.horst@gmail.com
86
+ executables:
87
+ - gemkeeper
88
+ extensions: []
89
+ extra_rdoc_files: []
90
+ files:
91
+ - ".env.example"
92
+ - ".envrc"
93
+ - ".rubocop.yml"
94
+ - AGENTS.md
95
+ - CHANGELOG.md
96
+ - CLAUDE.md
97
+ - CODE_OF_CONDUCT.md
98
+ - LICENSE
99
+ - Makefile
100
+ - README.md
101
+ - Rakefile
102
+ - exe/gemkeeper
103
+ - gemkeeper.yml.example
104
+ - lib/gemkeeper.rb
105
+ - lib/gemkeeper/cli.rb
106
+ - lib/gemkeeper/cli/commands/list.rb
107
+ - lib/gemkeeper/cli/commands/server/start.rb
108
+ - lib/gemkeeper/cli/commands/server/status.rb
109
+ - lib/gemkeeper/cli/commands/server/stop.rb
110
+ - lib/gemkeeper/cli/commands/sync.rb
111
+ - lib/gemkeeper/cli/commands/version.rb
112
+ - lib/gemkeeper/configuration.rb
113
+ - lib/gemkeeper/errors.rb
114
+ - lib/gemkeeper/gem_builder.rb
115
+ - lib/gemkeeper/gem_uploader.rb
116
+ - lib/gemkeeper/git_repository.rb
117
+ - lib/gemkeeper/server_manager.rb
118
+ - lib/gemkeeper/version.rb
119
+ - sig/gemkeeper.rbs
120
+ homepage: https://github.com/danhorst/gemkeeper
121
+ licenses:
122
+ - MIT
123
+ metadata:
124
+ homepage_uri: https://github.com/danhorst/gemkeeper
125
+ source_code_uri: https://github.com/danhorst/gemkeeper
126
+ changelog_uri: https://github.com/danhorst/gemkeeper/blob/main/CHANGELOG.md
127
+ rubygems_mfa_required: 'true'
128
+ rdoc_options: []
129
+ require_paths:
130
+ - lib
131
+ required_ruby_version: !ruby/object:Gem::Requirement
132
+ requirements:
133
+ - - ">="
134
+ - !ruby/object:Gem::Version
135
+ version: 3.1.0
136
+ required_rubygems_version: !ruby/object:Gem::Requirement
137
+ requirements:
138
+ - - ">="
139
+ - !ruby/object:Gem::Version
140
+ version: '0'
141
+ requirements: []
142
+ rubygems_version: 3.6.8
143
+ specification_version: 4
144
+ summary: Manage offline development with private gem dependencies
145
+ test_files: []