mnenv 0.1.1 → 0.1.3

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: '090048897f563d0162ceda95dd4f727cda725916b40e7150d815280dc9592ef6'
4
- data.tar.gz: 0fbf4dfbbff09224c9672fef91b5607289848166bb58eaeb904d6633eecfcfdf
3
+ metadata.gz: ebbe93f7754277cc8cb84ed21cf9d8c588c8dc9edd2164a51a4e175509e0afc8
4
+ data.tar.gz: d6c02ff55dbf8dbc816102e603c07a0a5e947b0570cfda4b6d08c0736f5e78da
5
5
  SHA512:
6
- metadata.gz: 4695512492c103578d75100163c8201407473f73d53c67e1ebf684e7b6f248d29012caf1f17b504e5f1b4167de34199f07b5c04b09e6749fcc0634c7bcc53a8c
7
- data.tar.gz: cea3fa81d4e70f8516d5866b5ee144ae8e734deccc09d80a3269d1deb98207f8af9468c82c89ef2e68256f9336d8e607968c9b59116d9f5644ca1c5cdf962c40
6
+ metadata.gz: 1a6b0db4fb7021a8b82af5532632c39273afe217984487f61e21b1110738a3f2cc04221de9056d18f77b5656264bbaf7224bea5a417e2455ce26a0e8f00d0695
7
+ data.tar.gz: bd4196df600ebd1fcfcafc5db7baa6dc35dcf5ceafb762a6615e3fde71f3ca4d8de315d00e372b3cf836772c40d6e6c9a784bd27faf5b0aabf325044e8aac2bb
@@ -28,17 +28,9 @@ module Mnenv
28
28
  desc 'refresh', 'Fetch and add new Snap versions (incremental)'
29
29
  def refresh
30
30
  fetcher = Snap::Fetcher.new
31
- repo = fetcher.repository
32
-
33
- # Build map of existing composite keys
34
- existing_keys = repo.all.map { |v| "#{v.version}-#{v.revision}-#{v.arch}-#{v.channel}" }
31
+ existing = fetcher.repository.all.map(&:version)
35
32
  remote_versions = fetcher.fetch_all
36
-
37
- # Find truly new versions (composite key doesn't exist)
38
- new_versions = remote_versions.reject do |v|
39
- key = "#{v.version}-#{v.revision}-#{v.arch}-#{v.channel}"
40
- existing_keys.include?(key)
41
- end
33
+ new_versions = remote_versions.reject { |v| existing.include?(v.version) }
42
34
 
43
35
  if new_versions.empty?
44
36
  puts 'No new Snap versions found'
@@ -55,22 +47,21 @@ module Mnenv
55
47
  puts "Revamped #{versions.size} Snap versions"
56
48
  end
57
49
 
58
- desc 'update VERSION', 'Update a specific Snap version (all arch/channel combinations)'
50
+ desc 'update VERSION', 'Update a specific Snap version'
59
51
  def update(version)
60
52
  fetcher = Snap::Fetcher.new
61
53
  versions = fetcher.fetch_all
62
- targets = versions.select { |v| v.version == version }
54
+ target = versions.find { |v| v.version == version }
63
55
 
64
- if targets.empty?
65
- puts "Snap version #{version} not found in current channel-map"
66
- puts 'Note: Historical versions no longer in Snap API cannot be updated'
56
+ if target.nil?
57
+ puts "Snap version #{version} not found"
67
58
  exit 1
68
59
  end
69
60
 
70
- fetcher.repository.save_all(targets)
71
- puts "Updated #{targets.size} Snap entries for version #{version}:"
72
- targets.each do |v|
73
- puts " - #{v.arch}/#{v.channel}: revision #{v.revision}"
61
+ fetcher.repository.save_all([target])
62
+ puts "Updated 1 Snap entry for version #{version}:"
63
+ target.channels.each do |v|
64
+ puts " - channel: #{v.name} arch: #{v.arch} revision: #{v.revision}"
74
65
  end
75
66
  end
76
67
 
@@ -7,36 +7,105 @@ module Mnenv
7
7
  class UninstallCommand < Thor
8
8
  namespace :uninstall
9
9
 
10
+ class_option :source, type: :string, enum: %w[gemfile binary],
11
+ desc: 'Source type to uninstall (gemfile or binary). If not specified, uninstalls all sources.'
10
12
  class_option :force, type: :boolean, aliases: '-f', default: false,
11
13
  desc: 'Force uninstallation without confirmation'
12
14
 
13
15
  desc 'VERSION', 'Uninstall a specific Metanorma version'
16
+ method_option :source, type: :string, enum: %w[gemfile binary]
14
17
  method_option :force, type: :boolean, aliases: '-f', default: false
15
18
  def uninstall(version)
16
- version_dir = File.expand_path("~/.mnenv/versions/#{version}")
19
+ source = options[:source]
20
+
21
+ if source
22
+ # Uninstall specific source
23
+ uninstall_source(version, source)
24
+ else
25
+ # Uninstall all sources for this version
26
+ uninstall_all_sources(version)
27
+ end
28
+
29
+ # Regenerate shims after uninstallation
30
+ ShimManager.new.regenerate_all
31
+ rescue StandardError => e
32
+ warn "Error: #{e.message}"
33
+ exit 1
34
+ end
35
+
36
+ private
37
+
38
+ def uninstall_source(version, source)
39
+ version_dir = Paths.version_install_dir(version, source)
17
40
 
18
41
  unless Dir.exist?(version_dir)
42
+ puts "Version #{version} (source: #{source}) is not installed."
43
+ return
44
+ end
45
+
46
+ confirm_and_remove(version, source, version_dir)
47
+ end
48
+
49
+ def uninstall_all_sources(version)
50
+ # Find all installed sources for this version
51
+ installed_sources = find_installed_sources(version)
52
+
53
+ if installed_sources.empty?
19
54
  puts "Version #{version} is not installed."
20
55
  return
21
56
  end
22
57
 
23
- unless options[:force]
58
+ if installed_sources.length == 1
59
+ # Only one source, uninstall directly
60
+ source = installed_sources.first
61
+ version_dir = Paths.version_install_dir(version, source)
62
+ confirm_and_remove(version, source, version_dir)
63
+ else
64
+ # Multiple sources, ask which to uninstall
65
+ puts "Version #{version} has multiple sources installed:"
66
+ installed_sources.each do |src|
67
+ puts " - #{src}"
68
+ end
69
+ puts ''
70
+
71
+ prompt = TTY::Prompt.new
72
+ choices = installed_sources.map { |s| { name: s, value: s } }
73
+ choices << { name: 'All sources', value: 'all' }
74
+
75
+ selected = prompt.select('Which source(s) to uninstall?', choices)
76
+
77
+ if selected == 'all'
78
+ installed_sources.each do |src|
79
+ version_dir = Paths.version_install_dir(version, src)
80
+ confirm_and_remove(version, src, version_dir, skip_confirm: options[:force])
81
+ end
82
+ else
83
+ version_dir = Paths.version_install_dir(version, selected)
84
+ confirm_and_remove(version, selected, version_dir)
85
+ end
86
+ end
87
+ end
88
+
89
+ def find_installed_sources(version)
90
+ sources = []
91
+ %w[gemfile binary].each do |source|
92
+ version_dir = Paths.version_install_dir(version, source)
93
+ sources << source if Dir.exist?(version_dir)
94
+ end
95
+ sources
96
+ end
97
+
98
+ def confirm_and_remove(version, source, version_dir, skip_confirm: false)
99
+ unless skip_confirm || options[:force]
24
100
  prompt = TTY::Prompt.new
25
- unless prompt.yes?("Uninstall Metanorma #{version}? This cannot be undone.")
101
+ unless prompt.yes?("Uninstall Metanorma #{version} (#{source})? This cannot be undone.")
26
102
  puts 'Uninstallation cancelled.'
27
103
  return
28
104
  end
29
105
  end
30
106
 
31
107
  FileUtils.rm_rf(version_dir)
32
-
33
- # Regenerate shims
34
- ShimManager.new.regenerate_all
35
-
36
- puts "Uninstalled Metanorma #{version}"
37
- rescue StandardError => e
38
- warn "Error: #{e.message}"
39
- exit 1
108
+ puts "Uninstalled Metanorma #{version} (source: #{source})"
40
109
  end
41
110
  end
42
111
  end
@@ -3,7 +3,7 @@
3
3
  require 'tty/prompt'
4
4
  require 'json'
5
5
  require_relative '../installer'
6
- require_relative '../binary_repository'
6
+ require_relative '../version_resolver'
7
7
 
8
8
  module Mnenv
9
9
  class VersionCommand < Thor
@@ -68,7 +68,7 @@ module Mnenv
68
68
  return
69
69
  end
70
70
 
71
- current_version, current_source = resolve_current
71
+ current_version, current_source = resolver.resolve
72
72
 
73
73
  case options[:format]
74
74
  when 'json'
@@ -105,6 +105,11 @@ module Mnenv
105
105
 
106
106
  private
107
107
 
108
+ # Get the shared resolver instance
109
+ def resolver
110
+ @resolver ||= VersionResolver.new
111
+ end
112
+
108
113
  # List installed versions with their sources
109
114
  # Uses Paths::INSTALLED_DIR with new naming convention: <version>-<source>
110
115
  def list_installed_versions
@@ -129,7 +134,7 @@ module Mnenv
129
134
 
130
135
  def resolve_version_and_source(version, source, interactive)
131
136
  version, source = select_version_interactive if interactive || version.nil?
132
- source ||= default_source
137
+ source ||= resolver.resolve_source
133
138
  [version, source]
134
139
  end
135
140
 
@@ -140,14 +145,12 @@ module Mnenv
140
145
  raise 'No versions installed. Run: mnenv install --list' if installed.empty?
141
146
 
142
147
  choices = installed.flat_map do |version, sources|
143
- sources.map do |source|
144
- { name: "#{version} (#{source})", value: [version, source] }
148
+ sources.map do |src|
149
+ { name: "#{version} (#{src})", value: [version, src] }
145
150
  end
146
151
  end
147
152
 
148
- version, source = prompt.select('Select a version:', choices)
149
-
150
- [version, source]
153
+ prompt.select('Select a version:', choices)
151
154
  end
152
155
 
153
156
  def verify_installed!(version, source)
@@ -160,54 +163,5 @@ module Mnenv
160
163
  def installed_versions
161
164
  list_installed_versions.keys.sort
162
165
  end
163
-
164
- def default_source
165
- if File.exist?(Paths::SOURCE_FILE)
166
- File.read(Paths::SOURCE_FILE).strip
167
- else
168
- 'gemfile'
169
- end
170
- end
171
-
172
- def resolve_version
173
- return ENV['METANORMA_VERSION'] if ENV['METANORMA_VERSION']
174
-
175
- dir = Dir.pwd
176
- loop do
177
- return File.read(File.join(dir, '.metanorma-version')).strip if File.exist?(File.join(dir,
178
- '.metanorma-version'))
179
-
180
- parent = File.dirname(dir)
181
- break if parent == dir # Reached root
182
-
183
- dir = parent
184
- end
185
-
186
- return File.read(Paths::VERSION_FILE).strip if File.exist?(Paths::VERSION_FILE)
187
-
188
- nil
189
- end
190
-
191
- def resolve_source
192
- return ENV['METANORMA_SOURCE'] if ENV['METANORMA_SOURCE']
193
-
194
- dir = Dir.pwd
195
- loop do
196
- return File.read(File.join(dir, '.metanorma-source')).strip if File.exist?(File.join(dir, '.metanorma-source'))
197
-
198
- parent = File.dirname(dir)
199
- break if parent == dir # Reached root
200
-
201
- dir = parent
202
- end
203
-
204
- return File.read(Paths::SOURCE_FILE).strip if File.exist?(Paths::SOURCE_FILE)
205
-
206
- 'gemfile'
207
- end
208
-
209
- def resolve_current
210
- [resolve_version, resolve_source]
211
- end
212
166
  end
213
167
  end
@@ -80,6 +80,8 @@ module Mnenv
80
80
  # Store relative paths (relative to data/gemfile directory)
81
81
  version.gemfile_path = "v#{version.version}/Gemfile"
82
82
  version.gemfile_lock_path = "v#{version.version}/Gemfile.lock.archived"
83
+ # Set parsed_at to now if not already set
84
+ version.parsed_at = DateTime.now if version.parsed_at.nil?
83
85
 
84
86
  repository.save(version)
85
87
  end
@@ -1,17 +1,46 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative '../source_registry'
4
+
3
5
  module Mnenv
6
+ # Factory for creating installer instances based on source type
7
+ # Uses SourceRegistry for extensibility - new sources just need to register
4
8
  class InstallerFactory
5
- def self.create(version, source:)
6
- case source.to_s
7
- when 'gemfile'
8
- Installers::GemfileInstaller.new(version, source: source)
9
- when 'binary'
10
- Installers::BinaryInstaller.new(version, source: source)
11
- else
9
+ class UnknownSourceError < StandardError; end
10
+
11
+ # Create an installer for the given version and source
12
+ # @param version [String] The version to install
13
+ # @param source [String] The source type (e.g., 'gemfile', 'binary')
14
+ # @param target_dir [String, nil] Optional custom target directory
15
+ # @return [Installer] An installer instance
16
+ # @raise [UnknownSourceError] If source is not registered
17
+ def self.create(version, source:, target_dir: nil)
18
+ installer_class = SourceRegistry.installer(source.to_s)
19
+
20
+ unless installer_class
21
+ available = SourceRegistry.all_names.join(', ')
12
22
  raise Installer::InstallationError,
13
- "Unknown source: #{source}. Use: gemfile or binary"
23
+ "Unknown source: #{source}. Available sources: #{available}"
14
24
  end
25
+
26
+ installer_class.new(version, source: source, target_dir: target_dir)
27
+ rescue SourceRegistry::UnknownSourceError
28
+ available = SourceRegistry.all_names.join(', ')
29
+ raise Installer::InstallationError,
30
+ "Unknown source: #{source}. Available sources: #{available}"
31
+ end
32
+
33
+ # Check if a source is supported
34
+ # @param source [String] The source type
35
+ # @return [Boolean]
36
+ def self.supported?(source)
37
+ SourceRegistry.registered?(source.to_s)
38
+ end
39
+
40
+ # Get list of supported sources
41
+ # @return [Array<String>]
42
+ def self.supported_sources
43
+ SourceRegistry.all_names
15
44
  end
16
45
  end
17
46
  end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mnenv
4
+ class SnapChannel < Lutaml::Model::Serializable
5
+ attribute :revision, :integer
6
+ attribute :arch, :string, default: 'amd64'
7
+ attribute :name, :string, default: 'stable'
8
+
9
+ key_value do
10
+ map 'name', to: :name
11
+ map 'revision', to: :revision
12
+ map 'arch', to: :arch
13
+ end
14
+
15
+ def to_hash
16
+ super.merge(
17
+ 'revision' => revision,
18
+ 'arch' => arch,
19
+ 'name' => name
20
+ )
21
+ end
22
+ end
23
+ end
@@ -1,29 +1,20 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative 'version'
4
+ require_relative 'snap_channel'
4
5
 
5
6
  module Mnenv
6
7
  class SnapVersion < ArtifactVersion
7
- attribute :revision, :integer
8
- attribute :arch, :string, default: 'amd64'
9
- attribute :channel, :string, default: 'stable'
8
+ attribute :channels, ::Mnenv::SnapChannel,
9
+ collection: true, default: -> { [] }
10
10
 
11
11
  key_value do
12
- map 'version', to: :version
13
- map 'published_at', to: :published_at
14
- map 'parsed_at', to: :parsed_at
15
- map 'revision', to: :revision
16
- map 'arch', to: :arch
17
- map 'channel', to: :channel
12
+ map 'channels', to: :channels
18
13
  end
19
14
 
20
- def display_name = revision ? "#{version}-#{revision}" : "v#{version}"
21
-
22
15
  def to_hash
23
16
  super.merge(
24
- 'revision' => revision,
25
- 'arch' => arch,
26
- 'channel' => channel
17
+ 'channels' => channels.map(&:to_hash)
27
18
  )
28
19
  end
29
20
  end
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mnenv
4
+ # Centralized platform detection for consistent OS/architecture/variant detection
5
+ # across the codebase. Used by binary installer, repository, and CLI.
6
+ class PlatformDetector
7
+ class UnsupportedPlatform < StandardError; end
8
+
9
+ class << self
10
+ # Detect the current operating system
11
+ # @return [String] 'linux', 'darwin', or 'windows'
12
+ # @raise [UnsupportedPlatform] if platform is not supported
13
+ def os
14
+ case RbConfig::CONFIG['host_os']
15
+ when /linux/
16
+ 'linux'
17
+ when /darwin/
18
+ 'darwin'
19
+ when /mswin|mingw|cygwin/
20
+ 'windows'
21
+ else
22
+ raise UnsupportedPlatform, "Unsupported platform: #{RbConfig::CONFIG['host_os']}"
23
+ end
24
+ end
25
+
26
+ # Detect the current CPU architecture
27
+ # @return [String] 'arm64' or 'x86_64'
28
+ def arch
29
+ case RbConfig::CONFIG['host_cpu']
30
+ when /arm64|aarch64/
31
+ 'arm64'
32
+ when /x86_64|x64/
33
+ 'x86_64'
34
+ else
35
+ 'x86_64' # Default fallback
36
+ end
37
+ end
38
+
39
+ # Detect libc variant (for Linux)
40
+ # @return [String, nil] 'musl' for Alpine/musl systems, nil for glibc
41
+ def variant
42
+ return nil unless os == 'linux'
43
+
44
+ 'musl' if musl?
45
+ end
46
+
47
+ # Check if running on a musl-based system (Alpine Linux)
48
+ # @return [Boolean]
49
+ def musl?
50
+ File.exist?('/etc/alpine-release') ||
51
+ File.symlink?('/lib/libc.musl-x86_64.so.1')
52
+ rescue StandardError
53
+ false
54
+ end
55
+
56
+ # Check if running on Windows
57
+ # @return [Boolean]
58
+ def windows?
59
+ os == 'windows'
60
+ end
61
+
62
+ # Check if running on macOS
63
+ # @return [Boolean]
64
+ def macos?
65
+ os == 'darwin'
66
+ end
67
+
68
+ # Check if running on Linux
69
+ # @return [Boolean]
70
+ def linux?
71
+ os == 'linux'
72
+ end
73
+
74
+ # Get platform info as a hash
75
+ # @return [Hash] With keys :os, :arch, :variant
76
+ def to_h
77
+ {
78
+ os: os,
79
+ arch: arch,
80
+ variant: variant
81
+ }
82
+ end
83
+
84
+ # Get a human-readable platform string
85
+ # @return [String] e.g., "linux-x86_64", "darwin-arm64", "linux-musl-x86_64"
86
+ def platform_string
87
+ parts = [os]
88
+ parts << variant if variant
89
+ parts << arch
90
+ parts.join('-')
91
+ end
92
+
93
+ # Get all possible platform strings for binary matching
94
+ # (includes variant-specific and generic versions)
95
+ # @return [Array<String>] List of platform strings in order of preference
96
+ def platform_candidates
97
+ candidates = []
98
+
99
+ # With variant (if applicable)
100
+ candidates << "#{os}-#{variant}-#{arch}" if variant
101
+
102
+ # Without variant
103
+ candidates << "#{os}-#{arch}"
104
+
105
+ candidates
106
+ end
107
+ end
108
+ end
109
+ end
@@ -62,7 +62,7 @@ module Mnenv
62
62
  end
63
63
  end
64
64
 
65
- # Override in subclasses for custom caching (e.g., SnapRepository uses composite keys)
65
+ # Override in subclasses for custom caching
66
66
  def cache_version(version)
67
67
  @versions_cache[version.version] = version
68
68
  end
@@ -8,6 +8,9 @@ require 'uri'
8
8
  require 'json'
9
9
  require 'fileutils'
10
10
  require 'net/http'
11
+ require_relative 'missing_credentials_error'
12
+ require_relative 'snapcraft_not_available_error'
13
+ require_relative 'login_failed_error'
11
14
 
12
15
  module Mnenv
13
16
  module Snap
@@ -24,45 +27,112 @@ module Mnenv
24
27
  ARCHITECTURES = %w[amd64 arm64].freeze
25
28
 
26
29
  def fetch_all
27
- # Load existing versions from YAML (single source of truth)
28
- existing_map = repository.all.to_h { |v| [snap_key(v), v] }
30
+ fetch_all_from_snapcraft
31
+ end
32
+
33
+ def fetch_all_from_cache
34
+ # Load existing versions from YAML
35
+ version_map = repository.all
29
36
 
30
37
  # Fetch current heads from snap_metadata API
31
38
  current_versions = fetch_current_heads
32
39
 
33
- # Merge: keep existing, add/update current from API
34
- version_map = {}
35
-
36
- # Add all existing versions
37
- existing_map.each_value { |v| version_map[snap_key(v)] = v }
38
-
39
- # Add/update current from API (overrides existing if same key)
40
- current_versions.each do |cv|
41
- key = snap_key_hash(cv)
42
- version_map[key] = SnapVersion.new(
43
- version: cv.fetch('version'),
44
- revision: cv.fetch('revision'),
45
- arch: cv.fetch('arch'),
46
- channel: cv.fetch('channel')
40
+ current_versions.each_key do |k|
41
+ next if repository.exists?(k)
42
+
43
+ # Add new version
44
+ version_map << SnapVersion.new(
45
+ version: k,
46
+ parsed_at: DateTime.now,
47
+ channels: current_versions[k].map do |cv|
48
+ SnapChannel.new(
49
+ name: cv.fetch('channel'),
50
+ revision: cv.fetch('revision'),
51
+ arch: cv.fetch('arch')
52
+ )
53
+ end
47
54
  )
48
55
  end
49
56
 
50
- version_map.values.sort
57
+ version_map
58
+ end
59
+
60
+ def fetch_all_from_snapcraft
61
+ raise MissingCredentialsError unless ENV['SNAPCRAFT_STORE_CREDENTIALS']
62
+ raise SnapcraftNotAvailableError unless snapcraft_available?
63
+
64
+ login_result = system('echo "$SNAPCRAFT_STORE_CREDENTIALS" | snapcraft login --with -')
65
+
66
+ raise LoginFailedError unless login_result
67
+
68
+ # delete the environment variable immediately to prevent
69
+ # duplicate login error
70
+ ENV.delete('SNAPCRAFT_STORE_CREDENTIALS')
71
+
72
+ result = `snapcraft revisions metanorma`
73
+ snap_revisions = parse_snap_revisions(result)
74
+ build_snap_version_map(snap_revisions)
51
75
  end
52
76
 
53
77
  private
54
78
 
55
- def snap_key(version)
56
- "#{version.version}-#{version.revision}-#{version.arch}-#{version.channel}"
79
+ def snapcraft_available?
80
+ system('command -v snapcraft > /dev/null 2>&1')
57
81
  end
58
82
 
59
- def snap_key_hash(hash)
60
- "#{hash.fetch('version')}-#{hash.fetch('revision')}-#{hash.fetch('arch')}-#{hash.fetch('channel')}"
83
+ def parse_snap_revisions(data)
84
+ lines = data.lines.map(&:strip).reject { |l| l.empty? || l.start_with?('Rev.') }
85
+ revisions = []
86
+ lines.each do |line|
87
+ fields = line.split(/\s{2,}/)
88
+ rev, uploaded, arches, version, channels = fields
89
+ revisions << {
90
+ version: version,
91
+ published_at: uploaded,
92
+ revision: rev,
93
+ arch: arches,
94
+ channels: channels
95
+ }
96
+ end
97
+ revisions
98
+ end
99
+
100
+ def build_snap_version_map(snap_revisions)
101
+ versions = []
102
+ latest_version = snap_revisions.first[:version]
103
+
104
+ snap_revisions.each do |sr|
105
+ snap_version = versions.find { |v| v&.version == sr[:version] }
106
+ if snap_version.nil?
107
+ snap_version = SnapVersion.new
108
+ snap_version.version = sr[:version]
109
+ snap_version.published_at = sr[:published_at]
110
+ snap_version.parsed_at = DateTime.now
111
+ end
112
+
113
+ channels = sr[:channels].split(',').map(&:strip)
114
+ channels.each do |channel|
115
+ next unless channel.start_with? 'latest/'
116
+
117
+ channel = channel.sub('latest/', '')
118
+ next if (sr[:version] == latest_version) && !channel.end_with?('*')
119
+
120
+ snap_version.channels << SnapChannel.new(
121
+ name: channel.gsub('*', ''),
122
+ revision: sr[:revision],
123
+ arch: sr[:arch]
124
+ )
125
+ end
126
+
127
+ versions << snap_version
128
+ end
129
+
130
+ versions.reverse
61
131
  end
62
132
 
63
133
  # Fetch current heads from snap_metadata API for all channel/arch combinations
64
134
  def fetch_current_heads
65
- versions = []
135
+ versions = {}
66
136
 
67
137
  CHANNELS.each do |channel|
68
138
  ARCHITECTURES.each do |arch|
@@ -88,8 +158,9 @@ module Mnenv
88
158
 
89
159
  if data['_embedded'] && data['_embedded']['clickindex:package']
90
160
  pkg = data['_embedded']['clickindex:package'][0]
91
- versions << {
92
- 'version' => pkg['version'],
161
+
162
+ versions[pkg['version']] ||= []
163
+ versions[pkg['version']] << {
93
164
  'revision' => pkg['revision'],
94
165
  'arch' => arch,
95
166
  'channel' => channel
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mnenv
4
+ module Snap
5
+ class LoginFailedError < StandardError
6
+ def initialize
7
+ super('Failed to login to snapcraft with provided credentials.')
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mnenv
4
+ module Snap
5
+ class MissingCredentialsError < StandardError
6
+ def initialize
7
+ super('Missing SNAPCRAFT_STORE_CREDENTIALS environment variable. Please set it to fetch Snap versions.')
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mnenv
4
+ module Snap
5
+ class SnapcraftNotAvailableError < StandardError
6
+ def initialize
7
+ super('Snapcraft is not available. Please install Snapcraft to fetch Snap versions.')
8
+ end
9
+ end
10
+ end
11
+ end
@@ -8,45 +8,30 @@ module Mnenv
8
8
  def version_class = SnapVersion
9
9
  def source_name = :snap
10
10
 
11
- # Override cache_version to use composite key for Snap
12
- # Since same version can have multiple revisions/arch/channels
13
- def cache_version(version)
14
- @versions_cache[snap_key(version)] = version
15
- end
16
-
17
- # Override find to work with composite keys
18
- def find(version_number)
19
- @versions_cache.values.find { |v| v.version == version_number }
20
- end
21
-
22
- # Find by specific version, revision, arch, channel
23
- def find_exact(version_number, revision, arch, channel)
24
- @versions_cache[snap_key(version_number, revision, arch, channel)]
25
- end
26
-
27
- # Get all entries for a specific version
28
- def find_all_by_version(version_number)
29
- @versions_cache.values.select { |v| v.version == version_number }.sort
30
- end
31
-
32
- def exists?(version_number)
33
- @versions_cache.values.any? { |v| v.version == version_number }
34
- end
35
-
36
- private
37
-
38
- def snap_key(*args)
39
- case args.size
40
- when 1
41
- # Single SnapVersion object
42
- v = args.first
43
- "#{v.version}-#{v.revision}-#{v.arch}-#{v.channel}"
44
- when 4
45
- # version, revision, arch, channel
46
- version_number, revision, arch, channel = args
47
- "#{version_number}-#{revision}-#{arch}-#{channel}"
48
- else
49
- raise ArgumentError, 'snap_key requires 1 or 4 arguments'
11
+ protected
12
+
13
+ def load
14
+ data = fetch_versions_data
15
+
16
+ return if data.nil? || data['versions'].nil?
17
+
18
+ data['versions'].each do |version_hash|
19
+ channels = version_hash['channels'].map do |c|
20
+ SnapChannel.new(
21
+ name: c['name'],
22
+ revision: c['revision'],
23
+ arch: c['arch']
24
+ )
25
+ end
26
+
27
+ version = SnapVersion.new.tap do |v|
28
+ v.version = version_hash['version']
29
+ v.published_at = version_hash['published_at']
30
+ v.parsed_at = version_hash['parsed_at']
31
+ v.channels = channels
32
+ end
33
+
34
+ cache_version(version)
50
35
  end
51
36
  end
52
37
  end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'source_registry'
4
+ require_relative 'gemfile_repository'
5
+ require_relative 'binary_repository'
6
+ require_relative 'models/gemfile_version'
7
+ require_relative 'models/binary_version'
8
+ require_relative 'installers/gemfile_installer'
9
+ require_relative 'installers/binary_installer'
10
+
11
+ module Mnenv
12
+ # Auto-registration of built-in sources
13
+ # This enables the plugin architecture - new sources just need to register themselves
14
+ module Sources
15
+ class << self
16
+ # Register all built-in sources
17
+ def setup
18
+ register_gemfile
19
+ register_binary
20
+ end
21
+
22
+ private
23
+
24
+ def register_gemfile
25
+ SourceRegistry.register(
26
+ name: 'gemfile',
27
+ repository: GemfileRepository,
28
+ model: GemfileVersion,
29
+ installer: Installers::GemfileInstaller
30
+ )
31
+ end
32
+
33
+ def register_binary
34
+ SourceRegistry.register(
35
+ name: 'binary',
36
+ repository: BinaryRepository,
37
+ model: BinaryVersion,
38
+ installer: Installers::BinaryInstaller
39
+ )
40
+ end
41
+ end
42
+ end
43
+ end
44
+
45
+ # Auto-setup on load
46
+ Mnenv::Sources.setup
data/lib/mnenv/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Mnenv
4
- VERSION = '0.1.1'
4
+ VERSION = '0.1.3'
5
5
  end
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mnenv
4
+ # Centralized version and source resolution with clear precedence:
5
+ # 1. Environment variables (METANORMA_VERSION, METANORMA_SOURCE)
6
+ # 2. Local files (.metanorma-version, .metanorma-source) - walk up tree
7
+ # 3. Global files (~/.mnenv/version, ~/.mnenv/source)
8
+ # 4. Defaults (gemfile for source)
9
+ #
10
+ # This class is the single source of truth for version resolution.
11
+ # The Bash resolver (lib/mnenv/resolver) is kept for shims only.
12
+ class VersionResolver
13
+ # Resolve the current Metanorma version
14
+ # @return [String, nil] The resolved version or nil if not set
15
+ def resolve_version
16
+ from_env('METANORMA_VERSION') ||
17
+ from_local_file('.metanorma-version') ||
18
+ from_global_file(Paths::VERSION_FILE)
19
+ end
20
+
21
+ # Resolve the current Metanorma source
22
+ # @return [String] The resolved source (defaults to 'gemfile')
23
+ def resolve_source
24
+ from_env('METANORMA_SOURCE') ||
25
+ from_local_file('.metanorma-source') ||
26
+ from_global_file(Paths::SOURCE_FILE) ||
27
+ 'gemfile'
28
+ end
29
+
30
+ # Resolve both version and source
31
+ # @return [Array<String, String>] Tuple of [version, source]
32
+ def resolve
33
+ [resolve_version, resolve_source]
34
+ end
35
+
36
+ # Check if a version is set anywhere
37
+ # @return [Boolean]
38
+ def version_set?
39
+ !resolve_version.nil?
40
+ end
41
+
42
+ # Check if a source is explicitly set (not default)
43
+ # @return [Boolean]
44
+ def source_set?
45
+ from_env('METANORMA_SOURCE') ||
46
+ from_local_file('.metanorma-source') ||
47
+ from_global_file(Paths::SOURCE_FILE)
48
+ end
49
+
50
+ # Get the source of version resolution (for debugging)
51
+ # @return [Symbol] :environment, :local, :global, or :none
52
+ def version_source
53
+ return :environment if from_env('METANORMA_VERSION')
54
+ return :local if from_local_file('.metanorma-version')
55
+ return :global if from_global_file(Paths::VERSION_FILE)
56
+
57
+ :none
58
+ end
59
+
60
+ # Get the source of source resolution (for debugging)
61
+ # @return [Symbol] :environment, :local, :global, or :default
62
+ def source_source
63
+ return :environment if from_env('METANORMA_SOURCE')
64
+ return :local if from_local_file('.metanorma-source')
65
+ return :global if from_global_file(Paths::SOURCE_FILE)
66
+
67
+ :default
68
+ end
69
+
70
+ private
71
+
72
+ # Read from environment variable
73
+ # @param var [String] Environment variable name
74
+ # @return [String, nil] Value or nil if not set/empty
75
+ def from_env(var)
76
+ value = ENV[var]
77
+ value&.strip&.then { |v| v unless v.empty? }
78
+ end
79
+
80
+ # Read from local file, walking up directory tree
81
+ # @param filename [String] Filename to look for
82
+ # @return [String, nil] File contents or nil if not found
83
+ def from_local_file(filename)
84
+ dir = Dir.pwd
85
+
86
+ loop do
87
+ path = File.join(dir, filename)
88
+ return File.read(path).strip if File.exist?(path)
89
+
90
+ parent = File.dirname(dir)
91
+ break if parent == dir # Reached root
92
+
93
+ dir = parent
94
+ end
95
+
96
+ nil
97
+ end
98
+
99
+ # Read from global file
100
+ # @param path [String] Full path to file
101
+ # @return [String, nil] File contents or nil if not found
102
+ def from_global_file(path)
103
+ File.read(path).strip if File.exist?(path)
104
+ rescue StandardError
105
+ nil
106
+ end
107
+ end
108
+ end
data/lib/mnenv.rb CHANGED
@@ -4,8 +4,11 @@ require 'lutaml/model'
4
4
  require 'thor'
5
5
 
6
6
  require_relative 'mnenv/paths'
7
+ require_relative 'mnenv/platform_detector'
8
+ require_relative 'mnenv/version_resolver'
7
9
  require_relative 'mnenv/models'
10
+ require_relative 'mnenv/source_registry'
11
+ require_relative 'mnenv/sources'
8
12
  require_relative 'mnenv/commands'
9
13
  require_relative 'mnenv/installer'
10
- require_relative 'mnenv/source_registry'
11
14
  require_relative 'mnenv/cli'
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: mnenv
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.1
4
+ version: 0.1.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-02-14 00:00:00.000000000 Z
11
+ date: 2026-03-16 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activesupport
@@ -206,9 +206,11 @@ files:
206
206
  - lib/mnenv/models/chocolatey_version.rb
207
207
  - lib/mnenv/models/gemfile_version.rb
208
208
  - lib/mnenv/models/homebrew_version.rb
209
+ - lib/mnenv/models/snap_channel.rb
209
210
  - lib/mnenv/models/snap_version.rb
210
211
  - lib/mnenv/models/version.rb
211
212
  - lib/mnenv/paths.rb
213
+ - lib/mnenv/platform_detector.rb
212
214
  - lib/mnenv/repository.rb
213
215
  - lib/mnenv/resolver
214
216
  - lib/mnenv/shells/base.rb
@@ -219,9 +221,14 @@ files:
219
221
  - lib/mnenv/shim_manager.rb
220
222
  - lib/mnenv/snap.rb
221
223
  - lib/mnenv/snap/fetcher.rb
224
+ - lib/mnenv/snap/login_failed_error.rb
225
+ - lib/mnenv/snap/missing_credentials_error.rb
226
+ - lib/mnenv/snap/snapcraft_not_available_error.rb
222
227
  - lib/mnenv/snap_repository.rb
223
228
  - lib/mnenv/source_registry.rb
229
+ - lib/mnenv/sources.rb
224
230
  - lib/mnenv/version.rb
231
+ - lib/mnenv/version_resolver.rb
225
232
  - lib/mnenv/versions_manager.rb
226
233
  - mnenv.gemspec
227
234
  - scripts/cross-source-switching-test.sh