gemstar 0.0.2 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e9eeb335df1b34923f36572389fdf30489fd5e3d2b88e72e602d7cff4c9ace2b
4
- data.tar.gz: 34c0bf38181ce5f9516967f94acc5ad08ae57230f7d5d6adadacf6d932860125
3
+ metadata.gz: 65596efbc0ff5c10ad950ca3cc8bb19e9ca9030db58b73d046ebce447200aa07
4
+ data.tar.gz: e5ab0cbb7f3db4b3d9949011f3b6446aecb725ee1863fd61dd196660332606de
5
5
  SHA512:
6
- metadata.gz: 23fad51445796f654d61318b33129d9a9f02136162982623aecafa0afa6271e9244bc6f272fb77e2ff049c944e44164018289be5306a124311c30340572eaf04
7
- data.tar.gz: 436bb8487ba91671e6ab319775460c2b13f34d0873812edf3d0e429f8c6dba5707da5ed599033153fc86cd3bb276f04c227f8e4a029756b67c9b7989e6153484
6
+ metadata.gz: ef75cdf526345f289eff5f0a8bff44520eb1d0aea55eff617de71d585832721655fd70c680e0e27ef7584c15dd4f304d9ee3b079c70fbafc7e4ccc5c8360c4ad
7
+ data.tar.gz: 13e3d871e1772c7f8ec183a3801504123896e8bd5365bba2a3e6fae5668415dcf2a40ef29f3494a2e9bef47251a3b913c37ae122879c3e8367cba9cf4490af1e
data/CHANGELOG.md CHANGED
@@ -1,21 +1,13 @@
1
1
  # Change Log
2
2
 
3
- ## TODO
4
- - Diff:
5
- - Gems:
6
- - benchmark
7
- - brakeman
8
- - json (not using CHANGES.md?)
9
- - nio4r not using releases.md?
10
- - parser not using CHANGELOG.md?
11
- - actioncable-next uses release tag names?
12
- - paper_trail not using CHANGELOG.md?
13
- - playwright-ruby-client uses release tags?
14
- - bundler itself
15
- - use changelog files from installed gems where present
16
-
17
3
  ## Unreleased
18
4
 
5
+ - Added `gemstar server`, your interactive Gemfile.lock explorer and more.
6
+ - Default location for `diff` is now a tmp file.
7
+ - Removed Railtie from this gem.
8
+ - Improve how git root dir is determined.
9
+
10
+
19
11
  ## 0.0.2
20
12
 
21
13
  - Diff: Fix regex warnings shown in terminal.
data/README.md CHANGED
@@ -22,6 +22,19 @@ gem "gemstar", group: :development
22
22
 
23
23
  ## Usage
24
24
 
25
+ ### gemstar server
26
+
27
+ ![Gemstar diff command output](docs/server.png)
28
+
29
+ Start the interactive web UI:
30
+
31
+ ```shell
32
+ gemstar server
33
+ ```
34
+
35
+ By default, the server listens to http://127.0.0.1:2112/
36
+
37
+
25
38
  ### gemstar diff
26
39
 
27
40
  Run this after you've updated your gems.
data/lib/gemstar/cache.rb CHANGED
@@ -1,10 +1,11 @@
1
+ require_relative "config"
1
2
  require "fileutils"
2
3
  require "digest"
3
4
 
4
5
  module Gemstar
5
6
  class Cache
6
7
  MAX_CACHE_AGE = 60 * 60 * 24 * 7 # 1 week
7
- CACHE_DIR = ".gem_changelog_cache"
8
+ CACHE_DIR = File.join(Gemstar::Config.home_directory, "cache")
8
9
 
9
10
  @@initialized = false
10
11
 
@@ -18,15 +19,12 @@ module Gemstar
18
19
  def self.fetch(key, &block)
19
20
  init
20
21
 
21
- path = File.join(CACHE_DIR, Digest::SHA256.hexdigest(key))
22
+ path = path_for(key)
22
23
 
23
- if File.exist?(path)
24
- age = Time.now - File.mtime(path)
25
- if age <= MAX_CACHE_AGE
26
- content = File.read(path)
27
- return nil if content == "__404__"
28
- return content
29
- end
24
+ if fresh?(path)
25
+ content = File.read(path)
26
+ return nil if content == "__404__"
27
+ return content
30
28
  end
31
29
 
32
30
  begin
@@ -39,11 +37,50 @@ module Gemstar
39
37
  end
40
38
  end
41
39
 
40
+ def self.peek(key)
41
+ init
42
+
43
+ path = path_for(key)
44
+ return nil unless fresh?(path)
45
+
46
+ content = File.read(path)
47
+ return nil if content == "__404__"
48
+
49
+ content
50
+ end
51
+
52
+ def self.path_for(key)
53
+ File.join(CACHE_DIR, Digest::SHA256.hexdigest(key))
54
+ end
55
+
56
+ def self.fresh?(path)
57
+ return false unless File.exist?(path)
58
+
59
+ (Time.now - File.mtime(path)) <= MAX_CACHE_AGE
60
+ end
61
+
62
+ def self.flush!
63
+ init
64
+
65
+ flush_directory(CACHE_DIR)
66
+ end
67
+
68
+ def self.flush_directory(directory)
69
+ return 0 unless Dir.exist?(directory)
70
+
71
+ entries = Dir.children(directory)
72
+ entries.each do |entry|
73
+ FileUtils.rm_rf(File.join(directory, entry))
74
+ end
75
+
76
+ entries.count
77
+ end
78
+
42
79
  end
43
80
 
44
81
  def edit_gitignore
45
82
  gitignore_path = ".gitignore"
46
- ignore_entries = %w[.gem_changelog_cache/ gem_update_changelog.html]
83
+ ignore_entries = %w[gem_update_changelog.html]
47
84
 
48
85
  existing_lines = File.exist?(gitignore_path) ? File.read(gitignore_path).lines.map(&:chomp) : []
49
86
 
@@ -0,0 +1,12 @@
1
+ require "thor"
2
+
3
+ module Gemstar
4
+ class CacheCLI < Thor
5
+ package_name "gemstar cache"
6
+
7
+ desc "flush", "Clear all gemstar cache entries"
8
+ def flush
9
+ Gemstar::Commands::Cache.new({}).flush
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,120 @@
1
+ require "set"
2
+ require "thread"
3
+
4
+ module Gemstar
5
+ class CacheWarmer
6
+ DEFAULT_THREADS = 10
7
+
8
+ def initialize(io: $stderr, debug: false, thread_count: DEFAULT_THREADS)
9
+ @io = io
10
+ @debug = debug
11
+ @thread_count = thread_count
12
+ @mutex = Mutex.new
13
+ @condition = ConditionVariable.new
14
+ @queue = []
15
+ @queued = Set.new
16
+ @in_progress = Set.new
17
+ @completed = Set.new
18
+ @workers = []
19
+ @started = false
20
+ @total = 0
21
+ @completed_count = 0
22
+ end
23
+
24
+ def enqueue_many(gem_names)
25
+ names = gem_names.uniq
26
+
27
+ @mutex.synchronize do
28
+ names.each do |gem_name|
29
+ next if @completed.include?(gem_name) || @queued.include?(gem_name) || @in_progress.include?(gem_name)
30
+
31
+ @queue << gem_name
32
+ @queued << gem_name
33
+ end
34
+ @total += names.count
35
+ start_workers_unlocked unless @started
36
+ end
37
+
38
+ log "Background cache refresh queued for #{names.count} gems."
39
+ @condition.broadcast
40
+ self
41
+ end
42
+
43
+ def prioritize(gem_name)
44
+ @mutex.synchronize do
45
+ return if @completed.include?(gem_name) || @in_progress.include?(gem_name)
46
+
47
+ if @queued.include?(gem_name)
48
+ @queue.delete(gem_name)
49
+ else
50
+ @queued << gem_name
51
+ @total += 1
52
+ end
53
+
54
+ @queue.unshift(gem_name)
55
+ start_workers_unlocked unless @started
56
+ end
57
+
58
+ log "Prioritized #{gem_name}"
59
+ @condition.broadcast
60
+ end
61
+
62
+ private
63
+
64
+ def start_workers_unlocked
65
+ return if @started
66
+
67
+ @started = true
68
+ @thread_count.times do
69
+ @workers << Thread.new { worker_loop }
70
+ end
71
+ end
72
+
73
+ def worker_loop
74
+ Thread.current.name = "gemstar-cache-worker" if Thread.current.respond_to?(:name=)
75
+
76
+ loop do
77
+ gem_name = @mutex.synchronize do
78
+ while @queue.empty?
79
+ @condition.wait(@mutex)
80
+ end
81
+
82
+ next_gem = @queue.shift
83
+ @queued.delete(next_gem)
84
+ @in_progress << next_gem
85
+ next_gem
86
+ end
87
+
88
+ warm_cache_for_gem(gem_name)
89
+
90
+ current = @mutex.synchronize do
91
+ @in_progress.delete(gem_name)
92
+ @completed << gem_name
93
+ @completed_count += 1
94
+ end
95
+
96
+ log_progress(gem_name, current)
97
+ end
98
+ end
99
+
100
+ def warm_cache_for_gem(gem_name)
101
+ metadata = Gemstar::RubyGemsMetadata.new(gem_name)
102
+ metadata.meta
103
+ metadata.repo_uri
104
+ Gemstar::ChangeLog.new(metadata).sections
105
+ rescue StandardError => e
106
+ log "Cache refresh failed for #{gem_name}: #{e.class}: #{e.message}"
107
+ end
108
+
109
+ def log_progress(gem_name, current)
110
+ return unless @debug
111
+ return unless current <= 5 || (current % 25).zero?
112
+
113
+ log "Background cache refresh #{current}/#{@total}: #{gem_name}"
114
+ end
115
+
116
+ def log(message)
117
+ @io.puts(message)
118
+ end
119
+ end
120
+ end
@@ -10,21 +10,30 @@ module Gemstar
10
10
 
11
11
  attr_reader :metadata
12
12
 
13
- def content
14
- @content ||= fetch_changelog_content
13
+ def content(cache_only: false)
14
+ return @content if !cache_only && defined?(@content)
15
+
16
+ result = fetch_changelog_content(cache_only: cache_only)
17
+ @content = result unless cache_only
18
+ result
15
19
  end
16
20
 
17
- def sections
18
- @sections ||= begin
19
- s = parse_changelog_sections
20
- if s.nil? || s.empty?
21
- s = parse_github_release_sections
22
- end
21
+ def sections(cache_only: false)
22
+ return @sections if !cache_only && defined?(@sections)
23
+
24
+ result = begin
25
+ s = parse_changelog_sections(cache_only: cache_only)
26
+ if s.nil? || s.empty?
27
+ s = parse_github_release_sections(cache_only: cache_only)
28
+ end
29
+
30
+ pp @@candidates_found if Gemstar.debug? && !cache_only
23
31
 
24
- pp @@candidates_found if Gemstar.debug?
32
+ s
33
+ end
25
34
 
26
- s
27
- end
35
+ @sections = result unless cache_only
36
+ result
28
37
  end
29
38
 
30
39
  def extract_relevant_sections(old_version, new_version)
@@ -52,26 +61,30 @@ module Gemstar
52
61
  def extract_version_from_heading(line)
53
62
  return nil unless line
54
63
  heading = line.to_s
64
+ version_token = /(\d+\.\d+(?:\.\d+)?(?:[-.][A-Za-z0-9]+)*)/
55
65
  # 1) Prefer version inside parentheses after a date: "### 2025-11-07 (2.16.0)"
56
66
  # Ensure we ONLY treat it as a version if it actually looks like a version (has a dot),
57
67
  # so we don't capture dates like (2025-11-21).
58
- return $1 if heading[/\(\s*v?(\d+\.\d+(?:\.\d+)?[A-Za-z0-9.\-]*)\s*\)/]
68
+ return $1 if heading[/\(\s*v?#{version_token}(?![A-Za-z0-9])\s*\)/]
59
69
  # 2) Version-first with optional leading markers/labels: "## v1.2.6 - 2025-10-21"
60
70
  # Require a dot in the numeric token to avoid capturing dates like 2025-11-21.
61
- return $1 if heading[/^\s*(?:#+|=+)?\s*(?:Version\s+)?\[?v?(\d+\.\d+(?:\.\d+)?[A-Za-z0-9.\-]*)\]?/i]
71
+ return $1 if heading[/^\s*(?:[-*]\s+)?(?:#+|=+)?\s*(?:Version\s+)?\[*v?#{version_token}(?![A-Za-z0-9])\]*/i]
62
72
  # 3) Anywhere: first semver-like token with a dot
63
- return $1 if heading[/\bv?(\d+\.\d+(?:\.\d+)?(?:[A-Za-z0-9.\-])*)\b/]
73
+ return $1 if heading[/\bv?#{version_token}(?![A-Za-z0-9])\b/]
64
74
  nil
65
75
  end
66
76
 
67
- def changelog_uri_candidates
77
+ def changelog_uri_candidates(cache_only: false)
68
78
  candidates = []
69
79
 
70
- if @metadata.repo_uri =~ %r{https://github\.com/aws/aws-sdk-ruby}
80
+ repo_uri = @metadata.repo_uri(cache_only: cache_only)
81
+ return [] if repo_uri.nil? || repo_uri.empty?
82
+
83
+ if repo_uri =~ %r{https://github\.com/aws/aws-sdk-ruby}
71
84
  base = "https://raw.githubusercontent.com/aws/aws-sdk-ruby/refs/heads/version-3/gems/#{@metadata.gem_name}"
72
85
  aws_style = true
73
86
  else
74
- base = @metadata.repo_uri.sub("https://github.com", "https://raw.githubusercontent.com")
87
+ base = repo_uri.sub("https://github.com", "https://raw.githubusercontent.com")
75
88
  aws_style = false
76
89
  end
77
90
 
@@ -94,7 +107,8 @@ module Gemstar
94
107
  end
95
108
 
96
109
  # Add the gem's changelog_uri last as it's usually not the most parsable:
97
- candidates += [Gemstar::GitHub::github_blob_to_raw(@metadata.meta["changelog_uri"])]
110
+ meta = @metadata.meta(cache_only: cache_only)
111
+ candidates += [Gemstar::GitHub::github_blob_to_raw(meta["changelog_uri"])] if meta
98
112
 
99
113
  candidates.flatten!
100
114
  candidates.uniq!
@@ -103,15 +117,19 @@ module Gemstar
103
117
  candidates
104
118
  end
105
119
 
106
- def fetch_changelog_content
120
+ def fetch_changelog_content(cache_only: false)
107
121
  content = nil
108
122
 
109
- changelog_uri_candidates.find do |candidate|
110
- content = Cache.fetch("changelog-#{candidate}") do
111
- URI.open(candidate, read_timeout: 8)&.read
112
- rescue => e
113
- puts "#{candidate}: #{e}" if Gemstar.debug?
114
- nil
123
+ changelog_uri_candidates(cache_only: cache_only).find do |candidate|
124
+ content = if cache_only
125
+ Cache.peek("changelog-#{candidate}")
126
+ else
127
+ Cache.fetch("changelog-#{candidate}") do
128
+ URI.open(candidate, read_timeout: 8)&.read
129
+ rescue => e
130
+ puts "#{candidate}: #{e}" if Gemstar.debug?
131
+ nil
132
+ end
115
133
  end
116
134
 
117
135
  # puts "fetch_changelog_content #{candidate}:\n#{content}" if Gemstar.debug?
@@ -127,18 +145,18 @@ module Gemstar
127
145
  end
128
146
 
129
147
  VERSION_PATTERNS = [
130
- /^\s*(?:#+|=+)\s*\d{4}-\d{2}-\d{2}\s*\(\s*v?(\d[\w.\-]+)\s*\)/, # prefer this
131
- /^\s*(?:#+|=+)\s*\[?v?(\d+\.\d+(?:\.\d+)?[A-Za-z0-9.\-]*)\]?\s*(?:—|–|-)\s*\d{4}-\d{2}-\d{2}\b/,
132
- /^\s*(?:#+|=+)\s*(?:Version\s+)?(?:(?:[^\s\d][^\s]*\s+)+)\[?v?(\d+\.\d+(?:\.\d+)?[A-Za-z0-9.\-]*)\]?(?:\s*[-(].*)?/i,
133
- /^\s*(?:#+|=+)\s*(?:Version\s+)?\[?v?(\d+\.\d+(?:\.\d+)?[A-Za-z0-9.\-]*)\]?(?:\s*[-(].*)?/i,
134
- /^\s*(?:Version\s+)?v?(\d+\.\d+(?:\.\d+)?[A-Za-z0-9.\-]*)(?:\s*[-(].*)?/i
148
+ /^\s*(?:[-*]\s+)?(?:#+|=+)\s*\d{4}-\d{2}-\d{2}\s*\(\s*v?(\d+\.\d+(?:\.\d+)?(?:[-.][A-Za-z0-9]+)*)(?![A-Za-z0-9])\s*\)/, # prefer this
149
+ /^\s*(?:[-*]\s+)?(?:#+|=+)\s*\[*v?(\d+\.\d+(?:\.\d+)?(?:[-.][A-Za-z0-9]+)*)(?![A-Za-z0-9])\]*\s*(?:—|–|-)\s*\d{4}-\d{2}-\d{2}\b/,
150
+ /^\s*(?:[-*]\s+)?(?:#+|=+)\s*(?:Version\s+)?(?:(?:[^\s\d][^\s]*\s+)+)\[*v?(\d+\.\d+(?:\.\d+)?(?:[-.][A-Za-z0-9]+)*)(?![A-Za-z0-9])\]*(?:\s*[-(].*)?/i,
151
+ /^\s*(?:[-*]\s+)?(?:#+|=+)\s*(?:Version\s+)?\[*v?(\d+\.\d+(?:\.\d+)?(?:[-.][A-Za-z0-9]+)*)(?![A-Za-z0-9])\]*(?:\s*[-(].*)?/i,
152
+ /^\s*(?:[-*]\s+)?(?:Version\s+)?v?(\d+\.\d+(?:\.\d+)?(?:[-.][A-Za-z0-9]+)*)(?![A-Za-z0-9])(?:\s*[-(].*)?/i
135
153
  ]
136
154
 
137
- def parse_changelog_sections
155
+ def parse_changelog_sections(cache_only: false)
138
156
  # If the fetched content looks like a GitHub Releases HTML page, return {}
139
157
  # so that the GitHub releases scraper can handle it. This avoids
140
158
  # accidentally parsing HTML from /releases pages as a markdown changelog.
141
- c = content
159
+ c = content(cache_only: cache_only)
142
160
  return {} if c.nil? || c.strip.empty?
143
161
  if (c.include?("<html") || c.include?("<!DOCTYPE html")) &&
144
162
  (c.include?('data-test-selector="body-content"') || c.include?("/releases/tag/"))
@@ -194,24 +212,37 @@ module Gemstar
194
212
  sections
195
213
  end
196
214
 
197
- def parse_github_release_sections
215
+ def parse_github_release_sections(cache_only: false)
198
216
  begin
199
217
  require "nokogiri"
200
218
  rescue LoadError
201
219
  return {}
202
220
  end
203
221
 
204
- return {} unless @metadata&.repo_uri&.include?("github.com")
222
+ repo_uri = @metadata&.repo_uri(cache_only: cache_only)
223
+ return {} unless repo_uri&.include?("github.com")
205
224
 
206
- url = github_releases_url
225
+ url = github_releases_url(repo_uri)
207
226
  return {} unless url
208
227
 
209
- html = Cache.fetch("releases-#{url}") do
210
- begin
211
- URI.open(url, read_timeout: 8)&.read
212
- rescue => e
213
- puts "#{url}: #{e}" if Gemstar.debug?
214
- nil
228
+ html = if cache_only
229
+ Cache.peek("releases-#{url}")
230
+ else
231
+ Cache.fetch("releases-#{url}") do
232
+ begin
233
+ URI.open(url, read_timeout: 8)&.read
234
+ rescue => e
235
+ puts "#{url}: #{e}" if Gemstar.debug?
236
+ nil
237
+ end
238
+ end
239
+ end
240
+
241
+ if (html.nil? || html.strip.empty?) && cache_only
242
+ cached_content = content(cache_only: true)
243
+ if cached_content&.include?("<html") &&
244
+ (cached_content.include?('data-test-selector="body-content"') || cached_content.include?("/releases/tag/"))
245
+ html = cached_content
215
246
  end
216
247
  end
217
248
 
@@ -271,9 +302,9 @@ module Gemstar
271
302
  sections
272
303
  end
273
304
 
274
- def github_releases_url
275
- return nil unless @metadata&.repo_uri
276
- repo = @metadata.repo_uri.chomp("/")
305
+ def github_releases_url(repo_uri = @metadata&.repo_uri)
306
+ return nil unless repo_uri
307
+ repo = repo_uri.chomp("/")
277
308
  return nil if repo.empty?
278
309
  "#{repo}/releases"
279
310
  end
data/lib/gemstar/cli.rb CHANGED
@@ -19,6 +19,18 @@ module Gemstar
19
19
  Gemstar::Commands::Diff.new(options).run
20
20
  end
21
21
 
22
+ desc "server", "Start the interactive web server"
23
+ method_option :bind, type: :string, default: "127.0.0.1", desc: "Bind address"
24
+ method_option :port, type: :numeric, default: 2112, desc: "Port"
25
+ method_option :project, type: :string, repeatable: true, desc: "Project directories or Gemfile paths"
26
+ method_option :reload, type: :boolean, default: false, desc: "Restart automatically when files change"
27
+ def server
28
+ Gemstar::Commands::Server.new(options).run
29
+ end
30
+
31
+ desc "cache SUBCOMMAND ...ARGS", "Manage gemstar caches"
32
+ subcommand "cache", Gemstar::CacheCLI
33
+
22
34
  # desc "pick", "Interactively cherry-pick and upgrade gems"
23
35
  # option :gem, type: :string, desc: "Gem name to cherry-pick"
24
36
  # def pick
@@ -0,0 +1,12 @@
1
+ require_relative "command"
2
+
3
+ module Gemstar
4
+ module Commands
5
+ class Cache < Command
6
+ def flush
7
+ removed_entries = Gemstar::Cache.flush!
8
+ puts "Flushed #{removed_entries} cache entr#{removed_entries == 1 ? 'y' : 'ies'} from #{Gemstar::Cache::CACHE_DIR}"
9
+ end
10
+ end
11
+ end
12
+ end
@@ -2,7 +2,7 @@
2
2
 
3
3
  require_relative "command"
4
4
  require "concurrent-ruby"
5
- require "tempfile"
5
+ require "tmpdir"
6
6
 
7
7
  module Gemstar
8
8
  module Commands
@@ -24,7 +24,7 @@ module Gemstar
24
24
  @from = options[:from] || "HEAD"
25
25
  @to = options[:to]
26
26
  @lockfile = options[:lockfile] || "Gemfile.lock"
27
- @output_file = options[:output_file] || "gem_update_changelog.html"
27
+ @output_file = options[:output_file] || File.join(Dir.tmpdir, "gem_update_changelog.html")
28
28
 
29
29
  @git_repo = Gemstar::GitRepo.new(File.dirname(@lockfile))
30
30
  end
@@ -46,7 +46,7 @@ module Gemstar
46
46
 
47
47
  html = Outputs::HTML.new.render_diff(self)
48
48
  File.write(output_file, html)
49
- puts "✅ gem_update_changelog.html created."
49
+ puts "✅ Changelog report created: #{File.expand_path(output_file)}"
50
50
 
51
51
  if failed.any?
52
52
  puts "\n⚠️ The following gems failed to process:"
@@ -0,0 +1,136 @@
1
+ require_relative "command"
2
+ require "shellwords"
3
+
4
+ module Gemstar
5
+ module Commands
6
+ class Server < Command
7
+ DEFAULT_BIND = "127.0.0.1"
8
+ DEFAULT_PORT = 2112
9
+ RELOAD_ENV_VAR = "GEMSTAR_RELOAD_ACTIVE"
10
+ RELOAD_GLOB = "{lib/**/*.rb,lib/gemstar/web/templates/**/*,bin/gemstar,README.md}"
11
+
12
+ attr_reader :bind
13
+ attr_reader :port
14
+ attr_reader :project_inputs
15
+ attr_reader :reload
16
+
17
+ def initialize(options)
18
+ super
19
+
20
+ @bind = options[:bind] || DEFAULT_BIND
21
+ @port = (options[:port] || DEFAULT_PORT).to_i
22
+ @project_inputs = normalize_project_inputs(options[:project])
23
+ @reload = options[:reload]
24
+ end
25
+
26
+ def run
27
+ restart_with_rerun if reload_requested?
28
+
29
+ require "rackup"
30
+ require "webrick"
31
+ require "gemstar/request_logger"
32
+ require "gemstar/webrick_logger"
33
+ require "gemstar/web/app"
34
+
35
+ Gemstar::Config.ensure_home_directory!
36
+
37
+ projects = load_projects
38
+ log_loaded_projects(projects)
39
+ cache_warmer = start_background_cache_refresh(projects)
40
+ app = Gemstar::Web::App.build(projects: projects, config_home: Gemstar::Config.home_directory, cache_warmer: cache_warmer)
41
+ app = Gemstar::RequestLogger.new(app, io: $stderr) if debug_request_logging?
42
+
43
+ puts "Gemstar server listening on http://#{bind}:#{port}"
44
+ puts "Config home: #{Gemstar::Config.home_directory}"
45
+ Rackup::Server.start(
46
+ app: app,
47
+ Host: bind,
48
+ Port: port,
49
+ AccessLog: [],
50
+ Logger: Gemstar::WEBrickLogger.new($stderr, WEBrick::BasicLog::INFO)
51
+ )
52
+ end
53
+
54
+ private
55
+
56
+ def normalize_project_inputs(project_option)
57
+ inputs = Array(project_option).compact.map(&:to_s)
58
+ return ["."] if inputs.empty?
59
+
60
+ inputs.uniq
61
+ end
62
+
63
+ def reload_requested?
64
+ reload && ENV[RELOAD_ENV_VAR] != "1"
65
+ end
66
+
67
+ def restart_with_rerun
68
+ rerun_executable = find_rerun_executable
69
+ unless rerun_executable
70
+ raise Thor::Error, "The `rerun` gem is not installed. Run `bundle install` and try `gemstar server --reload` again."
71
+ end
72
+
73
+ puts "Starting gemstar server in reload mode..."
74
+ puts "Watching changes matching #{RELOAD_GLOB.inspect}"
75
+
76
+ env = ENV.to_h.merge(RELOAD_ENV_VAR => "1")
77
+ exec env, *rerun_command(rerun_executable)
78
+ end
79
+
80
+ def find_rerun_executable
81
+ Gem.bin_path("rerun", "rerun")
82
+ rescue Gem::Exception
83
+ nil
84
+ end
85
+
86
+ def rerun_command(rerun_executable)
87
+ [
88
+ rerun_executable,
89
+ "--pattern",
90
+ RELOAD_GLOB,
91
+ "--",
92
+ Gem.ruby,
93
+ File.expand_path($PROGRAM_NAME)
94
+ ] + server_arguments_without_reload
95
+ end
96
+
97
+ def server_arguments_without_reload
98
+ args = [
99
+ "server",
100
+ "--bind", bind,
101
+ "--port", port.to_s
102
+ ]
103
+ project_inputs.each do |project|
104
+ args << "--project"
105
+ args << project
106
+ end
107
+ args
108
+ end
109
+
110
+ def load_projects
111
+ project_inputs.map { |input| Gemstar::Project.from_cli_argument(input) }
112
+ end
113
+
114
+ def log_loaded_projects(projects)
115
+ return unless debug_request_logging?
116
+
117
+ $stderr.puts "[gemstar] project inputs: #{project_inputs.inspect}"
118
+ $stderr.puts "[gemstar] loaded projects (#{projects.count}): #{projects.map(&:directory).inspect}"
119
+ end
120
+
121
+ def debug_request_logging?
122
+ ENV["DEBUG"] == "1"
123
+ end
124
+
125
+ def start_background_cache_refresh(projects)
126
+ gem_names = projects.flat_map do |project|
127
+ project.current_lockfile&.specs&.keys || []
128
+ end.uniq.sort
129
+
130
+ return nil if gem_names.empty?
131
+
132
+ Gemstar::CacheWarmer.new(io: $stderr, debug: debug_request_logging? || Gemstar.debug?, thread_count: 10).enqueue_many(gem_names)
133
+ end
134
+ end
135
+ end
136
+ end