fhir_packages_manager 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: c3db88fe718417bdd2a0bd82604b48928db13d036ee912094cb8891e5218d967
4
+ data.tar.gz: d84a0f807bcd2f479cd511fb100cfd9c723c6b59fca65c5dc41aa2e6dd829418
5
+ SHA512:
6
+ metadata.gz: 17d1da3f230507fbeb91cd949a90370561bcdeece97bdf433b169f16b7baadf960cc542bd9931f56b2b6afe93936a1d53865efe8f45e9be969e2f1883dcb5acf
7
+ data.tar.gz: 3b4e62d57b1b034c137869355e2d8454fc92292d3b0b59f4825abc89af0d86013afd76f563de14f95a23a03e28f1fd7783ef8b54994e7cf5137d44068bda562e
data/CHANGELOG.md ADDED
@@ -0,0 +1,43 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [0.1.0] - 2026-07-10
9
+
10
+ ### Added
11
+
12
+ - **Library**: `FhirPackagesManager::Manager`, `Registry`, `Package`, `IgnoreList`, and
13
+ `FetchResult`, providing:
14
+ - Availability checks and version resolution (including `latest`) against one or more
15
+ npm-style FHIR package registries (e.g. `packages.fhir.org`, `packages.simplifier.net`),
16
+ tried in the order given.
17
+ - A configurable ignore list (loaded from YAML or JSON) that skips a package entirely or
18
+ pins a single ignored version.
19
+ - Tarball download into a destination folder, with `HttpError`/`PackageNotFoundError` for
20
+ failure cases and a `:downloaded`/`:ignored`/`:not_found`/`:error` result per package.
21
+ - **CLI**: `fhir_packages_manager` executable with `check` and `fetch` subcommands
22
+ (`-r/--registry`, `-d/--destination`, `-i/--ignore-file`, repeatable `--registry`).
23
+ - **Docker**: a multi-stage `Dockerfile` that builds and installs the gem from source and
24
+ exposes the CLI as the image's entrypoint, plus a `docker-compose.yml` for running it
25
+ without a local Ruby install.
26
+ - **Tests**: an RSpec suite (HTTP stubbed with WebMock, so it never hits a real registry)
27
+ covering the library, CLI, and every registry edge case (redirects, missing `Location`
28
+ header, unsupported URL schemes, 404s, etc.), with 100% line and branch coverage enforced
29
+ by a SimpleCov minimum-coverage gate.
30
+ - **Code quality tooling**: RuboCop, Reek, Fasterer, Flay, Flog, and Steep (with full RBS
31
+ signatures for every class), all wired into `bundle exec rake`.
32
+ - **CI/CD** (GitHub Actions):
33
+ - `quality.yml` — runs the full test/quality suite on every push to any branch, uploading
34
+ coverage to Codecov.
35
+ - `release.yml` — on a published GitHub Release or manual dispatch, re-runs the test/quality
36
+ gate, then publishes the gem to RubyGems.org (via Trusted Publishing) and the Docker image
37
+ to GitHub Container Registry.
38
+ - `docs.yml` — publishes YARD API documentation to GitHub Pages on every push to `main`.
39
+ - **Documentation**: YARD doc comments across the public API, and a Codecov/docs badge pair
40
+ in the README.
41
+
42
+ [Unreleased]: https://github.com/projkov/fhir_packages_manager/compare/v0.1.0...HEAD
43
+ [0.1.0]: https://github.com/projkov/fhir_packages_manager/releases/tag/v0.1.0
@@ -0,0 +1,10 @@
1
+ # Code of Conduct
2
+
3
+ "fhir_packages_manager" follows [The Ruby Community Conduct Guideline](https://www.ruby-lang.org/en/conduct) in all "collaborative space", which is defined as community communications channels (such as mailing lists, submitted patches, commit comments, etc.):
4
+
5
+ * Participants will be tolerant of opposing views.
6
+ * Participants must ensure that their language and actions are free of personal attacks and disparaging personal remarks.
7
+ * When interpreting the words and actions of others, participants should always assume good intentions.
8
+ * Behaviour which can be reasonably considered harassment will not be tolerated.
9
+
10
+ If you have any concerns about behaviour within this project, please contact us at ["prozskov@gmail.com"](mailto:"prozskov@gmail.com").
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Pavel Rozhkov
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,157 @@
1
+ # FhirPackagesManager
2
+
3
+ [![codecov](https://codecov.io/gh/projkov/fhir_packages_manager/graph/badge.svg)](https://codecov.io/gh/projkov/fhir_packages_manager)
4
+ [![docs](https://img.shields.io/badge/docs-yard-blue.svg)](https://projkov.github.io/fhir_packages_manager/)
5
+
6
+ Checks the availability of FHIR implementation guide packages against npm-style
7
+ registries (e.g. `packages.fhir.org`, `packages.simplifier.net`), skips any
8
+ packages/versions on a configurable ignore list, and downloads the resulting
9
+ `.tgz` tarballs into a destination folder.
10
+
11
+ ## Installation
12
+
13
+ Install the gem and add to the application's Gemfile by executing:
14
+
15
+ ```bash
16
+ bundle add fhir_packages_manager
17
+ ```
18
+
19
+ If bundler is not being used to manage dependencies, install the gem by executing:
20
+
21
+ ```bash
22
+ gem install fhir_packages_manager
23
+ ```
24
+
25
+ ## Usage
26
+
27
+ ### Library
28
+
29
+ ```ruby
30
+ require "fhir_packages_manager"
31
+
32
+ manager = FhirPackagesManager::Manager.new(
33
+ registries: ["https://packages.fhir.org", "https://packages.simplifier.net"],
34
+ destination: "./fhir_packages",
35
+ ignore_list: FhirPackagesManager::IgnoreList.load("fhir_packages_ignore.yml") # optional
36
+ )
37
+
38
+ manager.available?("hl7.fhir.us.core", "6.1.0") # => true/false
39
+
40
+ result = manager.fetch("hl7.fhir.us.core@6.1.0")
41
+ result.status # => :downloaded, :ignored, :not_found, or :error
42
+ result.path # => "./fhir_packages/hl7.fhir.us.core-6.1.0.tgz" when downloaded
43
+
44
+ manager.fetch_all(["hl7.fhir.us.core@6.1.0", "hl7.fhir.r4.core"]) # bare name = latest
45
+ ```
46
+
47
+ The ignore list file (YAML or JSON) is a flat array where a bare string
48
+ ignores every version of a package, and a hash pins a single version:
49
+
50
+ ```yaml
51
+ - hl7.fhir.r4.core
52
+ - name: hl7.fhir.us.core
53
+ version: 3.1.0
54
+ ```
55
+
56
+ ### CLI
57
+
58
+ ```bash
59
+ exe/fhir_packages_manager check hl7.fhir.us.core@6.1.0 -r https://packages.fhir.org
60
+ exe/fhir_packages_manager fetch hl7.fhir.us.core@6.1.0 hl7.fhir.r4.core \
61
+ -r https://packages.fhir.org -r https://packages.simplifier.net \
62
+ -d ./fhir_packages -i fhir_packages_ignore.yml
63
+ ```
64
+
65
+ ### Docker
66
+
67
+ The CLI is also published as a container image, so it can be used without installing Ruby
68
+ or the gem locally:
69
+
70
+ ```bash
71
+ docker run --rm ghcr.io/projkov/fhir_packages_manager check hl7.fhir.us.core@6.1.0 -r https://packages.fhir.org
72
+
73
+ docker run --rm -v "$(pwd)/fhir_packages:/fhir_packages" ghcr.io/projkov/fhir_packages_manager \
74
+ fetch hl7.fhir.us.core@6.1.0 -r https://packages.fhir.org -d /fhir_packages
75
+ ```
76
+
77
+ Mount a volume (as above) to get downloaded `.tgz` files back out onto the host.
78
+
79
+ #### Docker Compose
80
+
81
+ A `docker-compose.yml` is included so the image name and volume mount don't need to be
82
+ retyped for every invocation:
83
+
84
+ ```yaml
85
+ services:
86
+ fhir_packages_manager:
87
+ image: ghcr.io/projkov/fhir_packages_manager:latest
88
+ build: .
89
+ volumes:
90
+ - ./fhir_packages:/fhir_packages
91
+ # - ./fhir_packages_ignore.yml:/fhir_packages_ignore.yml:ro
92
+ ```
93
+
94
+ Since the image's `ENTRYPOINT` is the CLI itself, anything passed after the service name
95
+ becomes its arguments:
96
+
97
+ ```bash
98
+ docker compose run --rm fhir_packages_manager \
99
+ check hl7.fhir.us.core@6.1.0 -r https://packages.fhir.org
100
+
101
+ docker compose run --rm fhir_packages_manager \
102
+ fetch hl7.fhir.us.core@6.1.0 -r https://packages.fhir.org -d /fhir_packages
103
+ ```
104
+
105
+ `docker compose run --rm` (rather than `up`) is used because this is a one-shot CLI, not a
106
+ long-running service — `--rm` discards the container after it exits. `docker compose pull`
107
+ fetches the published image from GHCR; `docker compose build` builds it locally from the
108
+ `Dockerfile` instead (e.g. to test an unreleased change). Uncomment the ignore-file volume
109
+ line and add `-i /fhir_packages_ignore.yml` to the command to use an ignore list.
110
+
111
+ ## Development
112
+
113
+ After checking out the repo, run `bin/setup` to install dependencies. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
114
+
115
+ To install this gem onto your local machine, run `bundle exec rake install`.
116
+
117
+ To release a new version: bump `VERSION` in `lib/fhir_packages_manager/version.rb`, add a
118
+ dated entry to `CHANGELOG.md`, and merge to `main`. Then either publish a GitHub Release (tag
119
+ `vX.Y.Z`) or manually run the `Release` workflow from the Actions tab — `.github/workflows/release.yml`
120
+ re-runs the full test/quality gate and, if it passes, publishes the gem to
121
+ [rubygems.org](https://rubygems.org) via Trusted Publishing and pushes the Docker image to
122
+ GHCR. Don't run `bundle exec rake release` locally — it would try to push using local
123
+ credentials instead of through that pipeline.
124
+
125
+ ### Tests and code quality
126
+
127
+ `bundle exec rake` runs the full suite: RSpec (with a SimpleCov gate requiring 95%+ line
128
+ coverage), RuboCop, Reek, Fasterer, Flay, Flog, and Steep. Each is also runnable on its own,
129
+ e.g. `bundle exec rspec`, `bundle exec rake rubocop`, `bundle exec rake steep`.
130
+
131
+ RSpec specs stub all HTTP calls with WebMock (see `spec/fhir_packages_manager/registry_spec.rb`
132
+ and `cli_spec.rb`), so the suite never hits a real registry.
133
+
134
+ ### Documentation
135
+
136
+ API docs are generated from the code with [YARD](https://yardoc.org) and published to
137
+ [GitHub Pages](https://projkov.github.io/fhir_packages_manager/) by
138
+ `.github/workflows/docs.yml` on every push to `main`. To build and browse them locally:
139
+
140
+ ```bash
141
+ bundle exec yard doc # writes HTML to doc/
142
+ bundle exec yard server --reload # serves it at http://localhost:8808, rebuilding on change
143
+ ```
144
+
145
+ `bundle exec yard stats --list-undoc` lists any undocumented public classes/methods.
146
+
147
+ ## Contributing
148
+
149
+ Bug reports and pull requests are welcome on GitHub at https://github.com/projkov/fhir_packages_manager. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/projkov/fhir_packages_manager/blob/main/CODE_OF_CONDUCT.md).
150
+
151
+ ## License
152
+
153
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
154
+
155
+ ## Code of Conduct
156
+
157
+ Everyone interacting in the FhirPackagesManager project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/projkov/fhir_packages_manager/blob/main/CODE_OF_CONDUCT.md).
data/Rakefile ADDED
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bundler/gem_tasks'
4
+
5
+ require 'rspec/core/rake_task'
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ require 'rubocop/rake_task'
9
+ RuboCop::RakeTask.new
10
+
11
+ require 'reek/rake/task'
12
+ Reek::Rake::Task.new do |t|
13
+ t.source_files = '{lib,exe}/**/*.rb'
14
+ end
15
+
16
+ require 'flay_task'
17
+ FlayTask.new(:flay, 200, %w[lib exe])
18
+
19
+ require 'flog_task'
20
+ FlogTask.new(:flog, 30, %w[lib exe], :max_method)
21
+
22
+ desc 'Check for slow Ruby idioms with fasterer'
23
+ task :fasterer do
24
+ sh 'bundle exec fasterer lib exe'
25
+ end
26
+
27
+ desc 'Type-check with Steep'
28
+ task :steep do
29
+ sh 'bundle exec steep check'
30
+ end
31
+
32
+ task default: %i[spec rubocop reek fasterer flay flog steep]
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require 'fhir_packages_manager'
5
+ require 'fhir_packages_manager/cli'
6
+
7
+ FhirPackagesManager::CLI.run(ARGV)
@@ -0,0 +1,136 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'optparse'
4
+
5
+ module FhirPackagesManager
6
+ # Command-line entry point backing the `fhir_packages_manager` executable.
7
+ # See the "CLI" section of the README for usage examples.
8
+ class CLI
9
+ # @return [Array<String>] the supported subcommands
10
+ COMMANDS = %w[fetch check].freeze
11
+
12
+ # @return [String] usage text shown on --help and on invalid invocations
13
+ BANNER = <<~USAGE
14
+ Usage: fhir_packages_manager COMMAND package[@version] [package[@version] ...] [options]
15
+
16
+ Commands:
17
+ fetch Download packages into the destination folder
18
+ check Report which registry (if any) has each package/version
19
+
20
+ USAGE
21
+
22
+ # Parses argv and runs the requested command (`fetch` or `check`).
23
+ #
24
+ # @param argv [Array<String>] arguments as passed to the executable, e.g. ARGV
25
+ # @return [void]
26
+ def self.run(argv)
27
+ new(argv).run
28
+ end
29
+
30
+ # @param argv [Array<String>] see {.run}
31
+ def initialize(argv)
32
+ @argv = argv.dup
33
+ @options = { destination: './fhir_packages', registries: [] } # : Hash[Symbol, untyped]
34
+ end
35
+
36
+ # @return [void]
37
+ def run
38
+ parser.parse!(@argv)
39
+ command = @argv.shift
40
+ package_specs = @argv
41
+
42
+ return usage_error(1) if !COMMANDS.include?(command) || package_specs.empty?
43
+ return usage_error('Error: at least one --registry URL is required') if @options[:registries].empty?
44
+
45
+ dispatch(command, package_specs)
46
+ end
47
+
48
+ private
49
+
50
+ def dispatch(command, package_specs)
51
+ case command
52
+ when 'fetch'
53
+ fetch(package_specs)
54
+ when 'check'
55
+ check(package_specs)
56
+ end
57
+ end
58
+
59
+ def parser
60
+ @parser ||= OptionParser.new do |opts|
61
+ opts.banner = BANNER
62
+ define_options(opts)
63
+ end
64
+ end
65
+
66
+ def define_options(opts)
67
+ opts.on('-r URL', '--registry URL', 'Registry base URL (repeatable, checked in order given)') do |value|
68
+ @options[:registries] << value
69
+ end
70
+ opts.on('-d DIR', '--destination DIR',
71
+ 'Destination folder for downloaded packages (default: ./fhir_packages)') do |value|
72
+ @options[:destination] = value
73
+ end
74
+ opts.on('-i PATH', '--ignore-file PATH', 'YAML/JSON file listing packages/versions to ignore') do |value|
75
+ @options[:ignore_file] = value
76
+ end
77
+ opts.on('-h', '--help', 'Show this help') do
78
+ puts opts
79
+ exit
80
+ end
81
+ end
82
+
83
+ def usage_error(message_or_status)
84
+ if message_or_status.is_a?(String)
85
+ warn message_or_status
86
+ else
87
+ warn parser
88
+ end
89
+ exit 1
90
+ end
91
+
92
+ def manager
93
+ @manager ||= Manager.new(
94
+ registries: @options[:registries],
95
+ destination: @options[:destination],
96
+ ignore_list: load_ignore_list
97
+ )
98
+ end
99
+
100
+ def load_ignore_list
101
+ ignore_file = @options[:ignore_file]
102
+ IgnoreList.load(ignore_file) if ignore_file
103
+ end
104
+
105
+ def fetch(package_specs)
106
+ results = manager.fetch_all(package_specs)
107
+ results.each { |result| puts fetch_line(result) }
108
+ exit 1 if results.any? { |result| result.not_found? || result.error? }
109
+ end
110
+
111
+ def fetch_line(result)
112
+ package = result.package
113
+ case result.status
114
+ when :downloaded then "OK #{package} -> #{result.path} (#{result.registry})"
115
+ when :ignored then "SKIP #{package} (ignored)"
116
+ when :not_found then "MISS #{package} (not found in any registry)"
117
+ when :error then "ERR #{package}: #{result.error}"
118
+ end
119
+ end
120
+
121
+ def check(package_specs)
122
+ package_specs.each do |spec|
123
+ package = Package.parse(spec)
124
+ found = manager.find_registry(package.name, package.version)
125
+ puts check_line(package, found)
126
+ end
127
+ end
128
+
129
+ def check_line(package, found)
130
+ return "UNAVAILABLE #{package}" unless found
131
+
132
+ registry, version = found
133
+ "AVAILABLE #{package.name}@#{version} (#{registry.base_url})"
134
+ end
135
+ end
136
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FhirPackagesManager
4
+ # Base class for all errors raised by this gem.
5
+ class Error < StandardError; end
6
+
7
+ # Raised for any non-2xx/3xx HTTP response from a registry.
8
+ class HttpError < Error
9
+ # @return [Integer, nil] the HTTP status code, or nil for a connection-level failure
10
+ attr_reader :status
11
+
12
+ # @param message [String]
13
+ # @param status [Integer, nil]
14
+ def initialize(message, status)
15
+ super(message)
16
+ @status = status
17
+ end
18
+ end
19
+
20
+ # Raised when a package or version does not exist on a registry.
21
+ class PackageNotFoundError < Error; end
22
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FhirPackagesManager
4
+ # Outcome of {Manager#fetch} for a single package.
5
+ #
6
+ # @!attribute package
7
+ # @return [Package] the package that was requested (version resolved, when downloaded)
8
+ # @!attribute status
9
+ # @return [Symbol] one of :downloaded, :ignored, :not_found, :error
10
+ # @!attribute registry
11
+ # @return [String, nil] the base URL of the registry that served the package, if downloaded
12
+ # @!attribute path
13
+ # @return [String, nil] where the tarball was written, if downloaded
14
+ # @!attribute error
15
+ # @return [String, nil] the error message, when status is :error
16
+ class FetchResult < Struct.new(:package, :status, :registry, :path, :error, keyword_init: true)
17
+ # @return [Boolean] true if the package was downloaded successfully
18
+ def downloaded?
19
+ status == :downloaded
20
+ end
21
+
22
+ # @return [Boolean] true if the package was skipped because it's on the ignore list
23
+ def ignored?
24
+ status == :ignored
25
+ end
26
+
27
+ # @return [Boolean] true if no registry had the package/version
28
+ def not_found?
29
+ status == :not_found
30
+ end
31
+
32
+ # @return [Boolean] true if downloading raised an HTTP error
33
+ def error?
34
+ status == :error
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'yaml'
4
+ require 'json'
5
+
6
+ module FhirPackagesManager
7
+ # A list of packages (optionally pinned to a version) to skip when fetching.
8
+ #
9
+ # Loaded from a YAML or JSON file containing a flat array, e.g.:
10
+ #
11
+ # - hl7.fhir.r4.core # ignore every version of this package
12
+ # - name: hl7.fhir.us.core
13
+ # version: 3.1.0 # ignore only this one version
14
+ class IgnoreList
15
+ # Loads an ignore list from a YAML or JSON file (JSON iff the extension is .json).
16
+ #
17
+ # @param path [String] path to a YAML or JSON file containing a flat array of entries
18
+ # @return [IgnoreList]
19
+ def self.load(path)
20
+ data = case File.extname(path).downcase
21
+ when '.json'
22
+ JSON.parse(File.read(path))
23
+ else
24
+ YAML.load_file(path)
25
+ end
26
+ new(data || [])
27
+ end
28
+
29
+ # @param entries [Array<String, Hash>] bare package names (ignore every version) and/or
30
+ # `{"name" => ..., "version" => ...}` hashes (ignore only that version)
31
+ # @raise [ArgumentError] if an entry is neither a String nor a Hash
32
+ def initialize(entries = [])
33
+ @entries = entries.map { |entry| normalize(entry) }
34
+ end
35
+
36
+ # @param name [String] the package name
37
+ # @param version [String, nil] the version being requested, if any
38
+ # @return [Boolean] true if this name/version is on the ignore list
39
+ def ignored?(name, version = nil)
40
+ @entries.any? { |entry| matches?(entry, name, version) }
41
+ end
42
+
43
+ private
44
+
45
+ def matches?(entry, name, version)
46
+ return false unless entry[:name] == name
47
+
48
+ ignored_version = entry[:version]
49
+ ignored_version.nil? || ignored_version == version
50
+ end
51
+
52
+ def normalize(entry)
53
+ case entry
54
+ when String
55
+ { name: entry, version: nil }
56
+ when Hash
57
+ normalize_hash(entry)
58
+ else
59
+ raise ArgumentError, "Invalid ignore list entry: #{entry.inspect}"
60
+ end
61
+ end
62
+
63
+ def normalize_hash(entry)
64
+ version = entry['version'] || entry[:version]
65
+ { name: entry['name'] || entry[:name], version: version&.to_s }
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+
5
+ module FhirPackagesManager
6
+ # Orchestrates checking availability across a set of registries, honoring an
7
+ # ignore list, and downloading tarballs into a destination folder.
8
+ class Manager
9
+ # @return [Array<Registry>] registries checked, in the order given to {#initialize}
10
+ attr_reader :registries
11
+ # @return [String] the folder packages are downloaded into
12
+ attr_reader :destination
13
+ # @return [IgnoreList, nil] packages/versions skipped by {#fetch}
14
+ attr_reader :ignore_list
15
+
16
+ # @param registries [Array<String, Registry>] one or more registry base URLs and/or
17
+ # {Registry} instances, checked in the order given
18
+ # @param destination [String] folder downloaded tarballs are written into
19
+ # @param ignore_list [IgnoreList, nil] packages/versions to skip in {#fetch}
20
+ # @raise [ArgumentError] if registries is nil or empty
21
+ def initialize(registries:, destination:, ignore_list: nil)
22
+ raise ArgumentError, 'at least one registry is required' if registries.nil? || registries.empty?
23
+
24
+ @registries = registries.map { |entry| entry.is_a?(Registry) ? entry : Registry.new(entry) }
25
+ @destination = destination.to_s
26
+ @ignore_list = ignore_list
27
+ end
28
+
29
+ # @param name [String] the package name
30
+ # @param version [String, nil] a specific version, or nil/"latest" for the newest
31
+ # @return [Boolean] true if any configured registry has this package/version
32
+ def available?(name, version = nil)
33
+ !find_registry(name, version).nil?
34
+ end
35
+
36
+ # @param name [String] the package name
37
+ # @param version [String, nil] a specific version, or nil/"latest" for the newest
38
+ # @return [Array(Registry, String), nil] the first registry that has this package/version,
39
+ # paired with the resolved version string; nil if none of them do
40
+ def find_registry(name, version = nil)
41
+ registries.each do |registry|
42
+ resolved = registry.version?(name, version)
43
+ return [registry, resolved] if resolved
44
+ end
45
+ nil
46
+ end
47
+
48
+ # Fetches a single package. Skips it if it's on the ignore list, otherwise downloads
49
+ # its tarball into {#destination}.
50
+ #
51
+ # @param package [String, Package] a "name@version" string, a bare name (latest), or a Package
52
+ # @return [FetchResult]
53
+ def fetch(package)
54
+ fetch_package(Package.parse(package))
55
+ end
56
+
57
+ # @param packages [Array<String, Package>] see {#fetch}
58
+ # @return [Array<FetchResult>] one result per package, in the same order
59
+ def fetch_all(packages)
60
+ packages.map { |package| fetch(package) }
61
+ end
62
+
63
+ private
64
+
65
+ def fetch_package(package)
66
+ return FetchResult.new(package: package, status: :ignored) if ignored?(package)
67
+
68
+ found = find_registry(package.name, package.version)
69
+ return FetchResult.new(package: package, status: :not_found) unless found
70
+
71
+ download_result(package, found)
72
+ rescue HttpError => e
73
+ FetchResult.new(package: package, status: :error, error: e.message)
74
+ end
75
+
76
+ def ignored?(package)
77
+ !!ignore_list&.ignored?(package.name, package.version)
78
+ end
79
+
80
+ def download_result(package, found)
81
+ registry, resolved_version = found
82
+ name = package.name
83
+ target = File.join(destination, "#{name}-#{resolved_version}.tgz")
84
+ registry.download(name, resolved_version, target)
85
+ FetchResult.new(package: Package.new(name, resolved_version), status: :downloaded,
86
+ registry: registry.base_url, path: target)
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FhirPackagesManager
4
+ # A requested package, e.g. "hl7.fhir.us.core@6.1.0" or "hl7.fhir.us.core"
5
+ # (version nil/"latest" means "resolve to the registry's latest version").
6
+ #
7
+ # @!attribute name
8
+ # @return [String] the FHIR package name, e.g. "hl7.fhir.us.core"
9
+ # @!attribute version
10
+ # @return [String, nil] the requested version, or nil/"latest" for the newest available
11
+ class Package < Struct.new(:name, :version)
12
+ # Parses a "name@version" (or bare "name") spec string into a Package.
13
+ #
14
+ # @param spec [String, Package] a spec string, or an existing Package (returned as-is)
15
+ # @return [Package]
16
+ def self.parse(spec)
17
+ return spec if spec.is_a?(Package)
18
+
19
+ parts = spec.to_s.split('@', 2)
20
+ new(parts[0] || '', parts[1])
21
+ end
22
+
23
+ # @return [String] "name@version", or just "name" when version is nil
24
+ def to_s
25
+ version ? "#{name}@#{version}" : name
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,136 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'net/http'
4
+ require 'uri'
5
+ require 'json'
6
+ require 'fileutils'
7
+
8
+ module FhirPackagesManager
9
+ # Client for a single FHIR package registry (e.g. https://packages.fhir.org
10
+ # or https://packages.simplifier.net). Both implement the same npm-style
11
+ # registry API: `GET /<package>` returns metadata with a "versions" map and
12
+ # "dist-tags", and `GET /<package>/<version>` streams the .tgz tarball.
13
+ class Registry
14
+ # @return [Integer] maximum HTTP redirects followed before raising {HttpError}
15
+ MAX_REDIRECTS = 5
16
+
17
+ # @return [String] the registry's base URL, with any trailing slash stripped
18
+ attr_reader :base_url
19
+
20
+ # @param base_url [String] e.g. "https://packages.fhir.org"
21
+ def initialize(base_url)
22
+ @base_url = base_url.to_s.chomp('/')
23
+ @metadata_cache = {} # : Hash[String, Hash[untyped, untyped]]
24
+ end
25
+
26
+ # Fetches (and caches) the full registry metadata document for a package name.
27
+ #
28
+ # @param name [String] the package name
29
+ # @return [Hash] the parsed npm-style registry metadata ("dist-tags", "versions", etc.)
30
+ # @raise [PackageNotFoundError] if the package doesn't exist on this registry
31
+ # @raise [HttpError] for any other non-2xx/3xx response
32
+ def metadata(name)
33
+ @metadata_cache[name] ||= JSON.parse(get("#{base_url}/#{name}"))
34
+ rescue HttpError => e
35
+ raise PackageNotFoundError, "#{name} not found on #{base_url}" if e.status == 404
36
+
37
+ raise
38
+ end
39
+
40
+ # @param name [String] the package name
41
+ # @param version [String, nil] a specific version, or nil/"latest" for the newest
42
+ # @return [String, nil] the resolved version string if it exists on this registry,
43
+ # or nil if the package or version doesn't exist (never raises)
44
+ def version?(name, version = nil)
45
+ meta = metadata(name)
46
+ resolved = resolve_version(meta, version)
47
+ resolved && meta['versions']&.key?(resolved) ? resolved : nil
48
+ rescue PackageNotFoundError
49
+ nil
50
+ end
51
+
52
+ # @param name [String] the package name
53
+ # @param version [String] an exact version, as returned by {#version?}
54
+ # @return [String] the tarball's download URL
55
+ # @raise [PackageNotFoundError] if the name/version doesn't exist on this registry
56
+ def tarball_url(name, version)
57
+ meta = metadata(name)
58
+ entry = meta.dig('versions', version)
59
+ raise PackageNotFoundError, "#{name}@#{version} not found on #{base_url}" unless entry
60
+
61
+ entry.dig('dist', 'tarball') || "#{base_url}/#{name}/#{version}"
62
+ end
63
+
64
+ # Downloads the package tarball to destination_path, creating parent directories as needed.
65
+ #
66
+ # @param name [String] the package name
67
+ # @param version [String] an exact version, as returned by {#version?}
68
+ # @param destination_path [String] where to write the downloaded .tgz file
69
+ # @return [String] destination_path
70
+ # @raise [PackageNotFoundError] if the name/version doesn't exist on this registry
71
+ # @raise [HttpError] if the download itself fails
72
+ def download(name, version, destination_path)
73
+ FileUtils.mkdir_p(File.dirname(destination_path))
74
+ download_file(tarball_url(name, version), destination_path)
75
+ destination_path
76
+ end
77
+
78
+ private
79
+
80
+ def resolve_version(meta, version)
81
+ return meta.dig('dist-tags', 'latest') if version.nil? || version == 'latest'
82
+
83
+ version
84
+ end
85
+
86
+ def get(url)
87
+ request(url, read_timeout: 30) { |http, uri| http.get(uri.request_uri, 'Accept' => 'application/json') }.body
88
+ end
89
+
90
+ def download_file(url, destination_path)
91
+ request(url, read_timeout: 120) do |http, uri|
92
+ http.request_get(uri.request_uri) do |response|
93
+ save_body(response, destination_path) if response.is_a?(Net::HTTPSuccess)
94
+ end
95
+ end
96
+ end
97
+
98
+ def save_body(response, destination_path)
99
+ File.open(destination_path, 'wb') do |file|
100
+ response.read_body { |chunk| file.write(chunk) }
101
+ end
102
+ end
103
+
104
+ # Issues a GET, yielding (http, uri) to perform it, and follows any
105
+ # redirect response by re-issuing the same block against the new location.
106
+ def request(url, read_timeout:, redirects_left: MAX_REDIRECTS, &)
107
+ raise HttpError.new("Too many redirects for #{url}", nil) if redirects_left <= 0
108
+
109
+ uri = URI(url)
110
+ raise HttpError.new("Unsupported URL scheme: #{url}", nil) unless uri.is_a?(URI::HTTP)
111
+
112
+ host = uri.host or raise HttpError.new("URL has no host: #{url}", nil)
113
+
114
+ response = Net::HTTP.start(host, uri.port, use_ssl: uri.scheme == 'https', open_timeout: 10,
115
+ read_timeout: read_timeout) do |http|
116
+ yield http, uri
117
+ end
118
+
119
+ follow(response, url, read_timeout:, redirects_left:, &)
120
+ end
121
+
122
+ def follow(response, url, read_timeout:, redirects_left:, &)
123
+ case response
124
+ when Net::HTTPSuccess
125
+ response
126
+ when Net::HTTPRedirection
127
+ location = response['location'] or raise HttpError.new("Redirect from #{url} has no Location header", nil)
128
+
129
+ request(location, read_timeout:, redirects_left: redirects_left - 1, &)
130
+ else
131
+ status = response.code.to_i
132
+ raise HttpError.new("GET #{url} failed: #{status} #{response.message}", status)
133
+ end
134
+ end
135
+ end
136
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FhirPackagesManager
4
+ # @return [String] the gem's version, following semver
5
+ VERSION = '0.1.0'
6
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'fhir_packages_manager/version'
4
+ require_relative 'fhir_packages_manager/errors'
5
+ require_relative 'fhir_packages_manager/package'
6
+ require_relative 'fhir_packages_manager/fetch_result'
7
+ require_relative 'fhir_packages_manager/registry'
8
+ require_relative 'fhir_packages_manager/ignore_list'
9
+ require_relative 'fhir_packages_manager/manager'
10
+
11
+ # Checks the availability of FHIR implementation guide packages against
12
+ # npm-style registries (e.g. packages.fhir.org, packages.simplifier.net),
13
+ # skips entries on a configurable {IgnoreList}, and downloads the resulting
14
+ # tarballs via {Manager}. See the README for usage examples.
15
+ module FhirPackagesManager
16
+ end
@@ -0,0 +1,34 @@
1
+ module FhirPackagesManager
2
+ class CLI
3
+ COMMANDS: Array[String]
4
+ BANNER: String
5
+
6
+ def self.run: (Array[String] argv) -> void
7
+
8
+ def initialize: (Array[String] argv) -> void
9
+
10
+ def run: () -> void
11
+
12
+ private
13
+
14
+ def dispatch: (String? command, Array[String] package_specs) -> void
15
+
16
+ def parser: () -> OptionParser
17
+
18
+ def define_options: (OptionParser opts) -> void
19
+
20
+ def usage_error: (String | Integer message_or_status) -> bot
21
+
22
+ def manager: () -> Manager
23
+
24
+ def load_ignore_list: () -> IgnoreList?
25
+
26
+ def fetch: (Array[String] package_specs) -> void
27
+
28
+ def fetch_line: (FetchResult result) -> String?
29
+
30
+ def check: (Array[String] package_specs) -> void
31
+
32
+ def check_line: (Package package, [Registry, String]? found) -> String
33
+ end
34
+ end
@@ -0,0 +1,13 @@
1
+ module FhirPackagesManager
2
+ class Error < StandardError
3
+ end
4
+
5
+ class HttpError < Error
6
+ attr_reader status: Integer?
7
+
8
+ def initialize: (String message, Integer? status) -> void
9
+ end
10
+
11
+ class PackageNotFoundError < Error
12
+ end
13
+ end
@@ -0,0 +1,19 @@
1
+ module FhirPackagesManager
2
+ class FetchResult < Struct[untyped]
3
+ attr_accessor package: Package
4
+ attr_accessor status: Symbol
5
+ attr_accessor registry: String?
6
+ attr_accessor path: String?
7
+ attr_accessor error: String?
8
+
9
+ def self.new: (package: Package, status: Symbol, ?registry: String?, ?path: String?, ?error: String?) -> FetchResult
10
+
11
+ def downloaded?: () -> bool
12
+
13
+ def ignored?: () -> bool
14
+
15
+ def not_found?: () -> bool
16
+
17
+ def error?: () -> bool
18
+ end
19
+ end
@@ -0,0 +1,17 @@
1
+ module FhirPackagesManager
2
+ class IgnoreList
3
+ def self.load: (String path) -> IgnoreList
4
+
5
+ def initialize: (?Array[String | Hash[untyped, untyped]] entries) -> void
6
+
7
+ def ignored?: (String name, ?String? version) -> bool
8
+
9
+ private
10
+
11
+ def matches?: (Hash[Symbol, String?] entry, String name, String? version) -> bool
12
+
13
+ def normalize: (String | Hash[untyped, untyped] entry) -> Hash[Symbol, String?]
14
+
15
+ def normalize_hash: (Hash[untyped, untyped] entry) -> Hash[Symbol, String?]
16
+ end
17
+ end
@@ -0,0 +1,25 @@
1
+ module FhirPackagesManager
2
+ class Manager
3
+ attr_reader registries: Array[Registry]
4
+ attr_reader destination: String
5
+ attr_reader ignore_list: IgnoreList?
6
+
7
+ def initialize: (registries: Array[String | Registry], destination: String, ?ignore_list: IgnoreList?) -> void
8
+
9
+ def available?: (String name, ?String? version) -> bool
10
+
11
+ def find_registry: (String name, ?String? version) -> [Registry, String]?
12
+
13
+ def fetch: (String | Package package) -> FetchResult
14
+
15
+ def fetch_all: (Array[String | Package] packages) -> Array[FetchResult]
16
+
17
+ private
18
+
19
+ def fetch_package: (Package package) -> FetchResult
20
+
21
+ def ignored?: (Package package) -> bool
22
+
23
+ def download_result: (Package package, [Registry, String] found) -> FetchResult
24
+ end
25
+ end
@@ -0,0 +1,12 @@
1
+ module FhirPackagesManager
2
+ class Package < Struct[untyped]
3
+ attr_accessor name: String
4
+ attr_accessor version: String?
5
+
6
+ def self.new: (String name, ?String? version) -> Package
7
+
8
+ def self.parse: (Package | String spec) -> Package
9
+
10
+ def to_s: () -> String
11
+ end
12
+ end
@@ -0,0 +1,31 @@
1
+ module FhirPackagesManager
2
+ class Registry
3
+ MAX_REDIRECTS: Integer
4
+
5
+ attr_reader base_url: String
6
+
7
+ def initialize: (String base_url) -> void
8
+
9
+ def metadata: (String name) -> Hash[untyped, untyped]
10
+
11
+ def version?: (String name, ?String? version) -> String?
12
+
13
+ def tarball_url: (String name, String version) -> String
14
+
15
+ def download: (String name, String version, String destination_path) -> String
16
+
17
+ private
18
+
19
+ def resolve_version: (Hash[untyped, untyped] meta, String? version) -> String?
20
+
21
+ def get: (String url) -> String
22
+
23
+ def download_file: (String url, String destination_path) -> void
24
+
25
+ def save_body: (Net::HTTPResponse response, String destination_path) -> void
26
+
27
+ def request: (String url, read_timeout: Integer, ?redirects_left: Integer) { (Net::HTTP, URI::HTTP) -> untyped } -> Net::HTTPResponse
28
+
29
+ def follow: (Net::HTTPResponse response, String url, read_timeout: Integer, redirects_left: Integer) { (Net::HTTP, URI::HTTP) -> untyped } -> Net::HTTPResponse
30
+ end
31
+ end
@@ -0,0 +1,3 @@
1
+ module FhirPackagesManager
2
+ VERSION: String
3
+ end
@@ -0,0 +1,2 @@
1
+ module FhirPackagesManager
2
+ end
metadata ADDED
@@ -0,0 +1,75 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: fhir_packages_manager
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Pavel Rozhkov
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2026-07-10 00:00:00.000000000 Z
12
+ dependencies: []
13
+ description: Resolves FHIR implementation guide packages against registries such as
14
+ packages.fhir.org and packages.simplifier.net, skips entries on a configurable ignore
15
+ list, and downloads the resulting tarballs into a folder.
16
+ email:
17
+ - prozskov@gmail.com
18
+ - pavel.r@beda.software
19
+ executables:
20
+ - fhir_packages_manager
21
+ extensions: []
22
+ extra_rdoc_files: []
23
+ files:
24
+ - CHANGELOG.md
25
+ - CODE_OF_CONDUCT.md
26
+ - LICENSE.txt
27
+ - README.md
28
+ - Rakefile
29
+ - exe/fhir_packages_manager
30
+ - lib/fhir_packages_manager.rb
31
+ - lib/fhir_packages_manager/cli.rb
32
+ - lib/fhir_packages_manager/errors.rb
33
+ - lib/fhir_packages_manager/fetch_result.rb
34
+ - lib/fhir_packages_manager/ignore_list.rb
35
+ - lib/fhir_packages_manager/manager.rb
36
+ - lib/fhir_packages_manager/package.rb
37
+ - lib/fhir_packages_manager/registry.rb
38
+ - lib/fhir_packages_manager/version.rb
39
+ - sig/fhir_packages_manager.rbs
40
+ - sig/fhir_packages_manager/cli.rbs
41
+ - sig/fhir_packages_manager/errors.rbs
42
+ - sig/fhir_packages_manager/fetch_result.rbs
43
+ - sig/fhir_packages_manager/ignore_list.rbs
44
+ - sig/fhir_packages_manager/manager.rbs
45
+ - sig/fhir_packages_manager/package.rbs
46
+ - sig/fhir_packages_manager/registry.rbs
47
+ - sig/fhir_packages_manager/version.rbs
48
+ homepage: https://github.com/projkov/fhir_packages_manager
49
+ licenses:
50
+ - MIT
51
+ metadata:
52
+ source_code_uri: https://github.com/projkov/fhir_packages_manager
53
+ documentation_uri: https://projkov.github.io/fhir_packages_manager/
54
+ changelog_uri: https://github.com/projkov/fhir_packages_manager/blob/main/CHANGELOG.md
55
+ rubygems_mfa_required: 'true'
56
+ post_install_message:
57
+ rdoc_options: []
58
+ require_paths:
59
+ - lib
60
+ required_ruby_version: !ruby/object:Gem::Requirement
61
+ requirements:
62
+ - - ">="
63
+ - !ruby/object:Gem::Version
64
+ version: 3.2.0
65
+ required_rubygems_version: !ruby/object:Gem::Requirement
66
+ requirements:
67
+ - - ">="
68
+ - !ruby/object:Gem::Version
69
+ version: '0'
70
+ requirements: []
71
+ rubygems_version: 3.5.22
72
+ signing_key:
73
+ specification_version: 4
74
+ summary: Check availability of and download FHIR packages from npm-style registries.
75
+ test_files: []