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 +7 -0
- data/.env.example +1 -0
- data/.envrc +3 -0
- data/.rubocop.yml +30 -0
- data/AGENTS.md +52 -0
- data/CHANGELOG.md +5 -0
- data/CLAUDE.md +1 -0
- data/CODE_OF_CONDUCT.md +132 -0
- data/LICENSE +5 -0
- data/Makefile +26 -0
- data/README.md +19 -0
- data/Rakefile +12 -0
- data/exe/gemkeeper +7 -0
- data/gemkeeper.yml.example +26 -0
- data/lib/gemkeeper/cli/commands/list.rb +33 -0
- data/lib/gemkeeper/cli/commands/server/start.rb +52 -0
- data/lib/gemkeeper/cli/commands/server/status.rb +31 -0
- data/lib/gemkeeper/cli/commands/server/stop.rb +31 -0
- data/lib/gemkeeper/cli/commands/sync.rb +87 -0
- data/lib/gemkeeper/cli/commands/version.rb +17 -0
- data/lib/gemkeeper/cli.rb +20 -0
- data/lib/gemkeeper/configuration.rb +99 -0
- data/lib/gemkeeper/errors.rb +22 -0
- data/lib/gemkeeper/gem_builder.rb +60 -0
- data/lib/gemkeeper/gem_uploader.rb +65 -0
- data/lib/gemkeeper/git_repository.rb +106 -0
- data/lib/gemkeeper/server_manager.rb +161 -0
- data/lib/gemkeeper/version.rb +5 -0
- data/lib/gemkeeper.rb +12 -0
- data/sig/gemkeeper.rbs +4 -0
- metadata +145 -0
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
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
data/CLAUDE.md
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
AGENTS.md
|
data/CODE_OF_CONDUCT.md
ADDED
|
@@ -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
data/exe/gemkeeper
ADDED
|
@@ -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
|
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
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: []
|