gemkeeper 0.2.1 → 0.3.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 +4 -4
- data/.reek.yml +37 -0
- data/CHANGELOG.md +11 -1
- data/README.md +19 -1
- data/lib/gemkeeper/cli/commands/list.rb +3 -2
- data/lib/gemkeeper/cli/commands/server/start.rb +9 -7
- data/lib/gemkeeper/cli/commands/server/stop.rb +4 -4
- data/lib/gemkeeper/cli/commands/setup.rb +24 -85
- data/lib/gemkeeper/cli/commands/sync.rb +15 -11
- data/lib/gemkeeper/config_generator.rb +79 -0
- data/lib/gemkeeper/configuration.rb +31 -2
- data/lib/gemkeeper/gem_builder.rb +1 -0
- data/lib/gemkeeper/gem_uploader.rb +16 -12
- data/lib/gemkeeper/git_repository.rb +1 -0
- data/lib/gemkeeper/lockfile_parser.rb +4 -2
- data/lib/gemkeeper/manifest_reader.rb +1 -0
- data/lib/gemkeeper/output.rb +1 -0
- data/lib/gemkeeper/server_manager.rb +1 -0
- data/lib/gemkeeper/version.rb +1 -1
- data/lib/gemkeeper.rb +1 -0
- metadata +4 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f8a541f4777f6bff36eab8be7c1c7382e6e8354dd20dbe8b0e47c1d2a2d168fd
|
|
4
|
+
data.tar.gz: 112afe0590f5582455935123ec0e35bd52e7faaf8f6811ba7ef5924df0732e73
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 3607617a5dc756210591b2ab71bb2ebe435e63ba458cd91ab112f6d98e4f1cf429939fc6261719a3ad5496ce98372ad668341418c5578add309d9e9f5d87201d
|
|
7
|
+
data.tar.gz: 9021ae8ed7015325522bb616b36e3729f6d4dd307a6017a226e48bf328f765d1134a92171b1ceacad33a582d5c0e1fd61c6269e89f32d4612703561cb45daeb2
|
data/.reek.yml
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
---
|
|
2
|
+
# Reek skips test files — Minitest patterns (setup instance vars, accumulating test methods,
|
|
3
|
+
# calling the same method before/after) are idiomatic there, not smells.
|
|
4
|
+
exclude_paths:
|
|
5
|
+
- test
|
|
6
|
+
|
|
7
|
+
detectors:
|
|
8
|
+
IrresponsibleModule:
|
|
9
|
+
exclude:
|
|
10
|
+
# Pure namespace modules — no behaviour to document
|
|
11
|
+
- Gemkeeper
|
|
12
|
+
- Gemkeeper::CLI
|
|
13
|
+
- Gemkeeper::CLI::Commands
|
|
14
|
+
- Gemkeeper::CLI::Commands::Server
|
|
15
|
+
# One-liner exception subclasses — their names are the documentation
|
|
16
|
+
- Gemkeeper::Error
|
|
17
|
+
- Gemkeeper::ConfigurationError
|
|
18
|
+
- Gemkeeper::ConfigFileNotFoundError
|
|
19
|
+
- Gemkeeper::InvalidConfigError
|
|
20
|
+
- Gemkeeper::ManifestNotFoundError
|
|
21
|
+
- Gemkeeper::GitError
|
|
22
|
+
- Gemkeeper::CloneError
|
|
23
|
+
- Gemkeeper::CheckoutError
|
|
24
|
+
- Gemkeeper::BuildError
|
|
25
|
+
- Gemkeeper::GemspecNotFoundError
|
|
26
|
+
- Gemkeeper::UploadError
|
|
27
|
+
- Gemkeeper::ServerError
|
|
28
|
+
- Gemkeeper::ServerAlreadyRunningError
|
|
29
|
+
- Gemkeeper::ServerNotRunningError
|
|
30
|
+
# CLI command classes carry a dry-cli `desc` as their documentation
|
|
31
|
+
- Gemkeeper::CLI::Commands::List
|
|
32
|
+
- Gemkeeper::CLI::Commands::Setup
|
|
33
|
+
- Gemkeeper::CLI::Commands::Sync
|
|
34
|
+
- Gemkeeper::CLI::Commands::Version
|
|
35
|
+
- Gemkeeper::CLI::Commands::Server::Start
|
|
36
|
+
- Gemkeeper::CLI::Commands::Server::Stop
|
|
37
|
+
- Gemkeeper::CLI::Commands::Server::Status
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
## [Unreleased]
|
|
2
2
|
|
|
3
|
+
## [0.3.0] - 2026-05-27
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- `gemkeeper setup --global` writes the config to the system-wide location used by the Homebrew service (`/opt/homebrew/etc/gemkeeper.yml` on Apple Silicon, `/usr/local/etc/gemkeeper.yml` on Intel, `~/.config/gemkeeper/config.yml` as a fallback) rather than the current project directory.
|
|
8
|
+
Data paths (`repos_path`, `gems_path`) are written as absolute paths under the corresponding `var` directory so the daemon finds them regardless of working directory.
|
|
9
|
+
Use this flag when running gemkeeper as a shared `brew services` daemon instead of a per-project process.
|
|
10
|
+
- `--global` and `--config` are mutually exclusive; passing both exits with an error.
|
|
11
|
+
|
|
3
12
|
## [0.2.1] - 2026-05-19
|
|
4
13
|
|
|
5
14
|
### Fixed
|
|
@@ -26,6 +35,7 @@
|
|
|
26
35
|
|
|
27
36
|
- Initial release
|
|
28
37
|
|
|
29
|
-
[Unreleased]: https://github.com/danhorst/gemkeeper/compare/v0.
|
|
38
|
+
[Unreleased]: https://github.com/danhorst/gemkeeper/compare/v0.3.0...HEAD
|
|
39
|
+
[0.3.0]: https://github.com/danhorst/gemkeeper/compare/0.2.1...0.3.0
|
|
30
40
|
[0.2.1]: https://github.com/danhorst/gemkeeper/compare/0.2.0...0.2.1
|
|
31
41
|
[0.2.0]: https://github.com/danhorst/gemkeeper/compare/0.1.0...0.2.0
|
data/README.md
CHANGED
|
@@ -174,8 +174,15 @@ gemkeeper setup path/to/Gemfile.lock --manifest ~/.config/myorg/manifest.yml
|
|
|
174
174
|
|
|
175
175
|
# Overwrite existing gemkeeper.yml entirely
|
|
176
176
|
gemkeeper setup path/to/Gemfile.lock --force
|
|
177
|
+
|
|
178
|
+
# Write to the global Homebrew service config instead of the current directory
|
|
179
|
+
gemkeeper setup path/to/Gemfile.lock --global
|
|
177
180
|
```
|
|
178
181
|
|
|
182
|
+
`--global` targets the system-wide config used by `brew services` — `/opt/homebrew/etc/gemkeeper.yml` on Apple Silicon or `/usr/local/etc/gemkeeper.yml` on Intel.
|
|
183
|
+
It sets `repos_path` and `gems_path` as absolute paths under the corresponding `var` directory so the daemon finds them regardless of which directory you run commands from.
|
|
184
|
+
`--global` and `--config` are mutually exclusive.
|
|
185
|
+
|
|
179
186
|
### Gem Synchronization
|
|
180
187
|
|
|
181
188
|
```bash
|
|
@@ -208,7 +215,18 @@ All commands support:
|
|
|
208
215
|
|
|
209
216
|
### Homebrew Services (macOS)
|
|
210
217
|
|
|
211
|
-
If installed via Homebrew
|
|
218
|
+
If installed via Homebrew, gemkeeper can run as a shared system daemon — one server, all projects.
|
|
219
|
+
|
|
220
|
+
**Configure the service** from any project that has a `Gemfile.lock`:
|
|
221
|
+
|
|
222
|
+
```bash
|
|
223
|
+
gemkeeper setup path/to/Gemfile.lock --global
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
This writes `/opt/homebrew/etc/gemkeeper.yml` (Apple Silicon) or `/usr/local/etc/gemkeeper.yml` (Intel) with absolute data paths.
|
|
227
|
+
Run it again from any other project to merge its gems into the shared config.
|
|
228
|
+
|
|
229
|
+
**Manage the daemon:**
|
|
212
230
|
|
|
213
231
|
```bash
|
|
214
232
|
# Start and enable at login
|
|
@@ -11,11 +11,12 @@ module Gemkeeper
|
|
|
11
11
|
def call(**options)
|
|
12
12
|
config = Configuration.load(options[:config])
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
gems_path = config.gems_path
|
|
15
|
+
gem_files = Dir.glob(File.join(gems_path, "gems", "*.gem"))
|
|
15
16
|
|
|
16
17
|
if gem_files.empty?
|
|
17
18
|
puts "No gems cached in Geminabox"
|
|
18
|
-
puts " Gems directory: #{
|
|
19
|
+
puts " Gems directory: #{gems_path}"
|
|
19
20
|
return
|
|
20
21
|
end
|
|
21
22
|
|
|
@@ -13,25 +13,27 @@ module Gemkeeper
|
|
|
13
13
|
desc: "Run in foreground (don't daemonize)"
|
|
14
14
|
|
|
15
15
|
def call(**options)
|
|
16
|
+
port = options[:port]
|
|
16
17
|
config = Configuration.load(options[:config])
|
|
17
|
-
config = override_port(config,
|
|
18
|
+
config = override_port(config, port) if port
|
|
18
19
|
|
|
19
20
|
manager = ServerManager.new(config)
|
|
21
|
+
url = config.geminabox_url
|
|
20
22
|
|
|
21
23
|
if options[:foreground]
|
|
22
|
-
puts "Starting Geminabox server at #{
|
|
24
|
+
puts "Starting Geminabox server at #{url}"
|
|
23
25
|
puts "Press Ctrl+C to stop"
|
|
24
26
|
manager.start_foreground
|
|
25
27
|
else
|
|
26
28
|
manager.start
|
|
27
|
-
puts "Geminabox server started at #{
|
|
29
|
+
puts "Geminabox server started at #{url}"
|
|
28
30
|
puts "PID: #{File.read(config.pid_file).strip}"
|
|
29
31
|
end
|
|
30
|
-
rescue ServerAlreadyRunningError =>
|
|
31
|
-
warn "Error: #{
|
|
32
|
+
rescue ServerAlreadyRunningError => error
|
|
33
|
+
warn "Error: #{error.message}"
|
|
32
34
|
exit 1
|
|
33
|
-
rescue ServerError =>
|
|
34
|
-
warn "Error starting server: #{
|
|
35
|
+
rescue ServerError => error
|
|
36
|
+
warn "Error starting server: #{error.message}"
|
|
35
37
|
exit 1
|
|
36
38
|
rescue Interrupt
|
|
37
39
|
puts "\nShutting down..."
|
|
@@ -15,11 +15,11 @@ module Gemkeeper
|
|
|
15
15
|
manager.stop
|
|
16
16
|
|
|
17
17
|
puts "Geminabox server stopped"
|
|
18
|
-
rescue ServerNotRunningError =>
|
|
19
|
-
warn "Error: #{
|
|
18
|
+
rescue ServerNotRunningError => error
|
|
19
|
+
warn "Error: #{error.message}"
|
|
20
20
|
exit 1
|
|
21
|
-
rescue ServerError =>
|
|
22
|
-
warn "Error stopping server: #{
|
|
21
|
+
rescue ServerError => error
|
|
22
|
+
warn "Error stopping server: #{error.message}"
|
|
23
23
|
exit 1
|
|
24
24
|
end
|
|
25
25
|
end
|
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "yaml"
|
|
4
|
-
|
|
5
3
|
module Gemkeeper
|
|
6
4
|
module CLI
|
|
7
5
|
module Commands
|
|
@@ -13,110 +11,51 @@ module Gemkeeper
|
|
|
13
11
|
option :manifest, type: :string,
|
|
14
12
|
desc: "Path to gem manifest (default: ~/.config/gemkeeper/manifest.yml)"
|
|
15
13
|
option :config, type: :string, desc: "Path to write gemkeeper.yml (default: ./gemkeeper.yml)"
|
|
14
|
+
option :global, type: :boolean, default: false,
|
|
15
|
+
desc: "Write to the global service config (for use with brew services)"
|
|
16
16
|
option :force, type: :boolean, default: false,
|
|
17
17
|
desc: "Overwrite existing gemkeeper.yml entirely"
|
|
18
18
|
|
|
19
19
|
def call(lockfile_path:, **options)
|
|
20
|
-
|
|
21
|
-
output_path = options[:config] || File.join(Dir.pwd, Configuration::DEFAULT_CONFIG_FILENAME)
|
|
20
|
+
validate_options!(options)
|
|
22
21
|
|
|
23
|
-
|
|
22
|
+
output_path = resolve_output_path(options)
|
|
23
|
+
manifest = ManifestReader.load(options[:manifest] || ManifestReader::DEFAULT_PATH)
|
|
24
24
|
lockfile_versions = LockfileParser.parse(lockfile_path)
|
|
25
|
+
global_output_path = options[:global] ? output_path : nil
|
|
25
26
|
|
|
26
|
-
|
|
27
|
+
config = ConfigGenerator.new(manifest:, lockfile_versions:)
|
|
28
|
+
.build(output_path, force: options[:force], global_output_path:)
|
|
27
29
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
30
|
+
File.write(output_path, config.to_yaml)
|
|
31
|
+
puts "Wrote #{output_path}"
|
|
32
|
+
print_bundler_instructions(config, manifest)
|
|
33
|
+
rescue ManifestNotFoundError => error
|
|
34
|
+
warn "Error: #{error.message}"
|
|
32
35
|
exit 1
|
|
33
36
|
end
|
|
34
37
|
|
|
35
38
|
private
|
|
36
39
|
|
|
37
|
-
def
|
|
38
|
-
|
|
39
|
-
name = gem_entry[:name]
|
|
40
|
-
{ name: name, repo: gem_entry[:repo] } if lockfile_versions.key?(name)
|
|
41
|
-
end
|
|
42
|
-
|
|
43
|
-
warn_unmatched_internals(manifest, lockfile_versions)
|
|
44
|
-
matched
|
|
45
|
-
end
|
|
46
|
-
|
|
47
|
-
def warn_unmatched_internals(manifest, lockfile_versions)
|
|
48
|
-
lockfile_versions.each_key do |gem_name|
|
|
49
|
-
next if manifest.find_by_name(gem_name)
|
|
50
|
-
|
|
51
|
-
gem_prefix = gem_name.split("-").first
|
|
52
|
-
next unless manifest.gem_names.any? { |manifest_name| manifest_name.split("-").first == gem_prefix }
|
|
53
|
-
|
|
54
|
-
warn "Warning: #{gem_name} matches an internal name pattern but is not in the manifest — skipping"
|
|
55
|
-
end
|
|
56
|
-
end
|
|
57
|
-
|
|
58
|
-
def write_config(matched_gems, output_path, force:)
|
|
59
|
-
existing = load_existing_config(output_path) unless force
|
|
60
|
-
existing ||= {}
|
|
61
|
-
|
|
62
|
-
gem_entries = matched_gems.map do |gem_entry|
|
|
63
|
-
{ "repo" => gem_entry[:repo], "version" => "from_lockfile" }
|
|
64
|
-
end
|
|
40
|
+
def validate_options!(options)
|
|
41
|
+
return unless options[:global] && options[:config]
|
|
65
42
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
else
|
|
69
|
-
merge_config(existing, gem_entries, matched_gems)
|
|
70
|
-
end
|
|
71
|
-
|
|
72
|
-
File.write(output_path, config.to_yaml)
|
|
73
|
-
puts "Wrote #{output_path}"
|
|
43
|
+
warn "Error: --global and --config are mutually exclusive"
|
|
44
|
+
exit 1
|
|
74
45
|
end
|
|
75
46
|
|
|
76
|
-
def
|
|
77
|
-
return
|
|
47
|
+
def resolve_output_path(options)
|
|
48
|
+
return options[:config] || File.join(Dir.pwd, Configuration::DEFAULT_CONFIG_FILENAME) unless options[:global]
|
|
78
49
|
|
|
79
|
-
|
|
50
|
+
Configuration.resolve_global_path || no_global_path!
|
|
80
51
|
end
|
|
81
52
|
|
|
82
|
-
def
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
"repos_path" => "./cache/repos",
|
|
86
|
-
"gems_path" => "./cache/gems",
|
|
87
|
-
"gems" => gem_entries
|
|
88
|
-
}
|
|
89
|
-
end
|
|
90
|
-
|
|
91
|
-
def merge_config(existing, _new_gem_entries, matched_gems)
|
|
92
|
-
existing_gems = existing["gems"] || []
|
|
93
|
-
matched_names = matched_gems.map { |gem_entry| gem_entry[:name] }
|
|
94
|
-
|
|
95
|
-
# Build a lookup for new entries by repo
|
|
96
|
-
new_by_name = matched_gems.to_h do |gem_entry|
|
|
97
|
-
entry_name = gem_entry[:name]
|
|
98
|
-
[entry_name, { "repo" => gem_entry[:repo], "version" => "from_lockfile" }]
|
|
99
|
-
end
|
|
100
|
-
|
|
101
|
-
# Update existing entries for matched gems, keep others untouched
|
|
102
|
-
updated = existing_gems.map do |entry|
|
|
103
|
-
repo = entry["repo"].to_s
|
|
104
|
-
name = File.basename(repo, ".git").sub(/^ruby-/, "")
|
|
105
|
-
if matched_names.include?(name)
|
|
106
|
-
new_by_name.delete(name).merge(entry.except("version")).merge("version" => "from_lockfile")
|
|
107
|
-
else
|
|
108
|
-
entry
|
|
109
|
-
end
|
|
110
|
-
end
|
|
111
|
-
|
|
112
|
-
# Append any matched gems not already in the config
|
|
113
|
-
updated += new_by_name.values
|
|
114
|
-
|
|
115
|
-
existing.merge("gems" => updated)
|
|
53
|
+
def no_global_path!
|
|
54
|
+
warn "Error: no writable global config path found — install Homebrew or create ~/.config/gemkeeper/"
|
|
55
|
+
exit 1
|
|
116
56
|
end
|
|
117
57
|
|
|
118
|
-
def print_bundler_instructions(
|
|
119
|
-
config = load_existing_config(config_path) || {}
|
|
58
|
+
def print_bundler_instructions(config, manifest)
|
|
120
59
|
port = config.fetch("port", Configuration::DEFAULT_PORT)
|
|
121
60
|
local_url = "http://localhost:#{port}"
|
|
122
61
|
source_url = manifest.source_url
|
|
@@ -42,22 +42,25 @@ module Gemkeeper
|
|
|
42
42
|
gems_to_sync.each do |gem_def|
|
|
43
43
|
result = sync_gem(gem_def, config, uploader)
|
|
44
44
|
counts[result] += 1
|
|
45
|
-
rescue Error =>
|
|
46
|
-
failures << { name: gem_def.name, message:
|
|
45
|
+
rescue Error => error
|
|
46
|
+
failures << { name: gem_def.name, message: error.message }
|
|
47
47
|
end
|
|
48
48
|
[counts, failures]
|
|
49
49
|
end
|
|
50
50
|
|
|
51
51
|
def report_results(counts, failures, total)
|
|
52
|
+
synced = counts[:synced]
|
|
53
|
+
skipped = counts[:skipped]
|
|
54
|
+
failure_count = failures.size
|
|
52
55
|
parts = []
|
|
53
|
-
parts << Output.colorize("#{
|
|
54
|
-
parts << Output.colorize("#{
|
|
55
|
-
parts << Output.colorize("#{
|
|
56
|
+
parts << Output.colorize("#{synced} synced", :green) if synced.positive?
|
|
57
|
+
parts << Output.colorize("#{skipped} skipped", :yellow) if skipped.positive?
|
|
58
|
+
parts << Output.colorize("#{failure_count} failed", :red) if failures.any?
|
|
56
59
|
puts "\nSync complete: #{parts.join(", ")} (#{total} total)"
|
|
57
60
|
|
|
58
61
|
return if failures.empty?
|
|
59
62
|
|
|
60
|
-
warn "\nSync completed with #{
|
|
63
|
+
warn "\nSync completed with #{failure_count} failure(s):"
|
|
61
64
|
failures.each { |f| warn " #{f[:name]}: #{f[:message]}" }
|
|
62
65
|
exit 1
|
|
63
66
|
end
|
|
@@ -68,7 +71,8 @@ module Gemkeeper
|
|
|
68
71
|
gems_path = config.gems_path
|
|
69
72
|
version = resolve_version(gem_def)
|
|
70
73
|
|
|
71
|
-
|
|
74
|
+
latest = gem_def.latest?
|
|
75
|
+
return :skipped if !latest && cached?(name, version, gems_path)
|
|
72
76
|
|
|
73
77
|
puts "Syncing #{name} @ #{version}..."
|
|
74
78
|
|
|
@@ -80,14 +84,14 @@ module Gemkeeper
|
|
|
80
84
|
|
|
81
85
|
checkout_gem_version(repo, version)
|
|
82
86
|
|
|
83
|
-
if
|
|
87
|
+
if latest
|
|
84
88
|
version = repo.current_version or
|
|
85
89
|
raise BuildError, "Could not read version from gemspec in #{repo_url}"
|
|
86
90
|
return :skipped if cached?(name, version, gems_path)
|
|
87
91
|
end
|
|
88
92
|
|
|
89
93
|
Output.step("Building gem...")
|
|
90
|
-
gem_path = GemBuilder.new(local_path,
|
|
94
|
+
gem_path = GemBuilder.new(local_path, gems_path).build
|
|
91
95
|
|
|
92
96
|
Output.step("Uploading to Geminabox...")
|
|
93
97
|
result = uploader.upload(gem_path)
|
|
@@ -127,8 +131,8 @@ module Gemkeeper
|
|
|
127
131
|
|
|
128
132
|
def fetch_repo(repo, repo_url)
|
|
129
133
|
repo.clone_or_pull
|
|
130
|
-
rescue GitError =>
|
|
131
|
-
raise auth_error?(
|
|
134
|
+
rescue GitError => git_error
|
|
135
|
+
raise auth_error?(git_error) ? auth_failure_error(repo_url, git_error) : git_error
|
|
132
136
|
end
|
|
133
137
|
|
|
134
138
|
def checkout_gem_version(repo, version)
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
|
|
5
|
+
module Gemkeeper
|
|
6
|
+
# Isolates merge/build logic from CLI commands so setup-adjacent features share a single path.
|
|
7
|
+
class ConfigGenerator
|
|
8
|
+
def initialize(manifest:, lockfile_versions:)
|
|
9
|
+
@manifest = manifest
|
|
10
|
+
@lockfile_versions = lockfile_versions
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def build(output_path, force:, global_output_path: nil)
|
|
14
|
+
existing = force ? {} : (load_existing(output_path) || {})
|
|
15
|
+
matched = matched_gems
|
|
16
|
+
|
|
17
|
+
existing.empty? ? build_fresh(matched, global_output_path:) : merge(existing, matched)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
def matched_gems
|
|
23
|
+
matched = @manifest.gems.filter_map do |gem_entry|
|
|
24
|
+
name = gem_entry[:name]
|
|
25
|
+
{ name:, repo: gem_entry[:repo] } if @lockfile_versions.key?(name)
|
|
26
|
+
end
|
|
27
|
+
warn_unmatched
|
|
28
|
+
matched
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def warn_unmatched
|
|
32
|
+
@lockfile_versions.each_key do |gem_name|
|
|
33
|
+
next if @manifest.find_by_name(gem_name)
|
|
34
|
+
|
|
35
|
+
prefix = gem_name.split("-").first
|
|
36
|
+
next unless @manifest.gem_names.any? { |n| n.split("-").first == prefix }
|
|
37
|
+
|
|
38
|
+
warn "Warning: #{gem_name} matches an internal name pattern but is not in the manifest — skipping"
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def load_existing(path)
|
|
43
|
+
return nil unless File.exist?(path)
|
|
44
|
+
|
|
45
|
+
YAML.safe_load_file(path, permitted_classes: [], symbolize_names: false) || {}
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def build_fresh(matched, global_output_path: nil)
|
|
49
|
+
repos_path, gems_path = data_paths_for(global_output_path)
|
|
50
|
+
gem_entries = matched.map { |g| { "repo" => g[:repo], "version" => "from_lockfile" } }
|
|
51
|
+
{ "port" => Configuration::DEFAULT_PORT, "repos_path" => repos_path,
|
|
52
|
+
"gems_path" => gems_path, "gems" => gem_entries }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def data_paths_for(global_output_path)
|
|
56
|
+
return ["./cache/repos", "./cache/gems"] unless global_output_path
|
|
57
|
+
|
|
58
|
+
data_dir = Configuration.global_data_dir(global_output_path)
|
|
59
|
+
[File.join(data_dir, "repos"), File.join(data_dir, "gems")]
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def merge(existing, matched)
|
|
63
|
+
existing_gems = existing["gems"] || []
|
|
64
|
+
new_by_name = matched.to_h { |g| [g[:name], { "repo" => g[:repo], "version" => "from_lockfile" }] }
|
|
65
|
+
|
|
66
|
+
updated = existing_gems.map do |entry|
|
|
67
|
+
name = File.basename(entry["repo"].to_s, ".git").sub(/^ruby-/, "")
|
|
68
|
+
if new_by_name.key?(name)
|
|
69
|
+
new_by_name.delete(name).merge(entry.except("version")).merge("version" => "from_lockfile")
|
|
70
|
+
else
|
|
71
|
+
entry
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
updated += new_by_name.values
|
|
76
|
+
existing.merge("gems" => updated)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -4,6 +4,7 @@ require "yaml"
|
|
|
4
4
|
require "fileutils"
|
|
5
5
|
|
|
6
6
|
module Gemkeeper
|
|
7
|
+
# Single source of truth for resolved paths and settings; callers never touch raw YAML keys.
|
|
7
8
|
class Configuration
|
|
8
9
|
DEFAULT_PORT = 9292
|
|
9
10
|
DEFAULT_CONFIG_FILENAME = "gemkeeper.yml"
|
|
@@ -17,6 +18,33 @@ module Gemkeeper
|
|
|
17
18
|
-> { "/opt/homebrew/etc/gemkeeper.yml" } # Homebrew (Apple Silicon)
|
|
18
19
|
].freeze
|
|
19
20
|
|
|
21
|
+
# Candidate paths for the global service config, in priority order
|
|
22
|
+
GLOBAL_CONFIG_PATHS = [
|
|
23
|
+
-> { "/opt/homebrew/etc/gemkeeper.yml" },
|
|
24
|
+
-> { "/usr/local/etc/gemkeeper.yml" },
|
|
25
|
+
-> { File.expand_path("~/.config/gemkeeper/config.yml") }
|
|
26
|
+
].freeze
|
|
27
|
+
|
|
28
|
+
def self.global_config_paths
|
|
29
|
+
override = ENV.fetch("GEMKEEPER_GLOBAL_CONFIG", nil)
|
|
30
|
+
return [override] if override
|
|
31
|
+
|
|
32
|
+
GLOBAL_CONFIG_PATHS.map(&:call)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def self.resolve_global_path
|
|
36
|
+
global_config_paths.find { |path| File.directory?(File.dirname(path)) }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def self.global_data_dir(config_path)
|
|
40
|
+
config_dir = File.dirname(File.expand_path(config_path))
|
|
41
|
+
if config_dir.end_with?("/etc")
|
|
42
|
+
File.join(File.dirname(config_dir), "var", "gemkeeper")
|
|
43
|
+
else
|
|
44
|
+
config_dir
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
20
48
|
attr_reader :port, :repos_path, :gems_path, :pid_file, :gems
|
|
21
49
|
|
|
22
50
|
def self.load(config_path = nil)
|
|
@@ -67,8 +95,8 @@ module Gemkeeper
|
|
|
67
95
|
|
|
68
96
|
begin
|
|
69
97
|
YAML.safe_load_file(@config_path, permitted_classes: [], symbolize_names: true) || {}
|
|
70
|
-
rescue Psych::SyntaxError =>
|
|
71
|
-
raise InvalidConfigError, "Invalid YAML in #{@config_path}: #{
|
|
98
|
+
rescue Psych::SyntaxError => yaml_error
|
|
99
|
+
raise InvalidConfigError, "Invalid YAML in #{@config_path}: #{yaml_error.message}"
|
|
72
100
|
end
|
|
73
101
|
end
|
|
74
102
|
|
|
@@ -84,6 +112,7 @@ module Gemkeeper
|
|
|
84
112
|
FileUtils.mkdir_p(@gems_path)
|
|
85
113
|
end
|
|
86
114
|
|
|
115
|
+
# Keeps per-gem config strongly typed so callers get validated attributes instead of raw hash access.
|
|
87
116
|
class GemDefinition
|
|
88
117
|
VALID_VERSION_PATTERN = /\A[a-zA-Z0-9._-]+\z/
|
|
89
118
|
RESERVED_VERSIONS = %w[latest from_lockfile].freeze
|
|
@@ -4,6 +4,7 @@ require "faraday"
|
|
|
4
4
|
require "faraday/multipart"
|
|
5
5
|
|
|
6
6
|
module Gemkeeper
|
|
7
|
+
# Encapsulates Geminabox's HTTP API so callers never construct multipart requests directly.
|
|
7
8
|
class GemUploader
|
|
8
9
|
attr_reader :geminabox_url
|
|
9
10
|
|
|
@@ -30,13 +31,14 @@ module Gemkeeper
|
|
|
30
31
|
def list_gems
|
|
31
32
|
response = connection.get("/api/v1/gems.json")
|
|
32
33
|
|
|
33
|
-
|
|
34
|
+
body = response.body
|
|
35
|
+
raise UploadError, "Failed to list gems: #{response.status} #{body}" unless response.success?
|
|
34
36
|
|
|
35
|
-
JSON.parse(
|
|
36
|
-
rescue JSON::ParserError =>
|
|
37
|
-
raise UploadError, "Invalid JSON response: #{
|
|
38
|
-
rescue Faraday::Error =>
|
|
39
|
-
raise UploadError, "Connection error: #{
|
|
37
|
+
JSON.parse(body)
|
|
38
|
+
rescue JSON::ParserError => parse_error
|
|
39
|
+
raise UploadError, "Invalid JSON response: #{parse_error.message}"
|
|
40
|
+
rescue Faraday::Error => connection_error
|
|
41
|
+
raise UploadError, "Connection error: #{connection_error.message}"
|
|
40
42
|
end
|
|
41
43
|
|
|
42
44
|
private
|
|
@@ -50,16 +52,18 @@ module Gemkeeper
|
|
|
50
52
|
end
|
|
51
53
|
|
|
52
54
|
def handle_response(response, gem_path)
|
|
53
|
-
|
|
55
|
+
status = response.status
|
|
56
|
+
gem_name = File.basename(gem_path)
|
|
57
|
+
case status
|
|
54
58
|
when 200, 201, 302
|
|
55
|
-
{ success: true, message: "Uploaded #{
|
|
59
|
+
{ success: true, message: "Uploaded #{gem_name}" }
|
|
56
60
|
when 409
|
|
57
|
-
{ success: true, message: "#{
|
|
61
|
+
{ success: true, message: "#{gem_name} already exists", skipped: true }
|
|
58
62
|
else
|
|
59
|
-
raise UploadError, "Upload failed (#{
|
|
63
|
+
raise UploadError, "Upload failed (#{status}): #{response.body}"
|
|
60
64
|
end
|
|
61
|
-
rescue Faraday::Error =>
|
|
62
|
-
raise UploadError, "Connection error: #{
|
|
65
|
+
rescue Faraday::Error => connection_error
|
|
66
|
+
raise UploadError, "Connection error: #{connection_error.message}"
|
|
63
67
|
end
|
|
64
68
|
end
|
|
65
69
|
end
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Gemkeeper
|
|
4
|
+
# Locates the nearest Gemfile.lock by walking up; callers don't need to know the search algorithm.
|
|
4
5
|
class LockfileParser
|
|
5
6
|
LOCKFILE_NAME = "Gemfile.lock"
|
|
6
7
|
|
|
@@ -38,13 +39,14 @@ module Gemkeeper
|
|
|
38
39
|
in_gem_specs = false
|
|
39
40
|
|
|
40
41
|
content.each_line do |line|
|
|
41
|
-
|
|
42
|
+
stripped = line.strip
|
|
43
|
+
if stripped == "GEM"
|
|
42
44
|
in_gem_specs = true
|
|
43
45
|
next
|
|
44
46
|
end
|
|
45
47
|
|
|
46
48
|
# 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]/ &&
|
|
49
|
+
in_gem_specs = false if in_gem_specs && line =~ /\A[A-Z]/ && stripped != "GEM"
|
|
48
50
|
|
|
49
51
|
next unless in_gem_specs
|
|
50
52
|
|
data/lib/gemkeeper/output.rb
CHANGED
data/lib/gemkeeper/version.rb
CHANGED
data/lib/gemkeeper.rb
CHANGED
|
@@ -6,6 +6,7 @@ require_relative "gemkeeper/output"
|
|
|
6
6
|
require_relative "gemkeeper/configuration"
|
|
7
7
|
require_relative "gemkeeper/lockfile_parser"
|
|
8
8
|
require_relative "gemkeeper/manifest_reader"
|
|
9
|
+
require_relative "gemkeeper/config_generator"
|
|
9
10
|
require_relative "gemkeeper/git_repository"
|
|
10
11
|
require_relative "gemkeeper/gem_builder"
|
|
11
12
|
require_relative "gemkeeper/gem_uploader"
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: gemkeeper
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Dan Brubaker Horst
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-05-
|
|
11
|
+
date: 2026-05-27 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: dry-cli
|
|
@@ -89,6 +89,7 @@ executables:
|
|
|
89
89
|
extensions: []
|
|
90
90
|
extra_rdoc_files: []
|
|
91
91
|
files:
|
|
92
|
+
- ".reek.yml"
|
|
92
93
|
- CHANGELOG.md
|
|
93
94
|
- LICENSE
|
|
94
95
|
- README.md
|
|
@@ -102,6 +103,7 @@ files:
|
|
|
102
103
|
- lib/gemkeeper/cli/commands/setup.rb
|
|
103
104
|
- lib/gemkeeper/cli/commands/sync.rb
|
|
104
105
|
- lib/gemkeeper/cli/commands/version.rb
|
|
106
|
+
- lib/gemkeeper/config_generator.rb
|
|
105
107
|
- lib/gemkeeper/configuration.rb
|
|
106
108
|
- lib/gemkeeper/errors.rb
|
|
107
109
|
- lib/gemkeeper/gem_builder.rb
|