create-ruby-gem 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.
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CreateRubyGem
4
+ module Compatibility
5
+ # Static lookup table mapping Bundler version ranges to the +bundle gem+
6
+ # options each range supports.
7
+ #
8
+ # This is the single source of truth for what each Bundler version
9
+ # can do. The wizard, validator, and builder all consult it.
10
+ #
11
+ # @example Look up the entry for a specific Bundler version
12
+ # entry = Matrix.for('3.1.0')
13
+ # entry.supports_option?(:linter) #=> true
14
+ class Matrix
15
+ # A single row in the compatibility table.
16
+ #
17
+ # @!attribute [r] requirement
18
+ # @return [Gem::Requirement] Bundler version range this entry covers
19
+ # @!attribute [r] supported_options
20
+ # @return [Hash{Symbol => Array<String>, nil}] option keys to allowed
21
+ # values (+nil+ means any boolean/toggle value is accepted)
22
+ Entry = Struct.new(:requirement, :supported_options, keyword_init: true) do
23
+ # @param bundler_version [Gem::Version] version to test
24
+ # @return [Boolean]
25
+ def match?(bundler_version)
26
+ requirement.satisfied_by?(bundler_version)
27
+ end
28
+
29
+ # @param option_key [Symbol, String]
30
+ # @return [Boolean]
31
+ def supports_option?(option_key)
32
+ supported_options.key?(option_key.to_sym)
33
+ end
34
+
35
+ # @param option_key [Symbol, String]
36
+ # @return [Array<String>, nil] allowed values, or nil for toggles/flags
37
+ def allowed_values(option_key)
38
+ supported_options.fetch(option_key.to_sym)
39
+ end
40
+ end
41
+
42
+ # @return [Array<Entry>] all known Bundler compatibility entries
43
+ TABLE = [
44
+ Entry.new(
45
+ requirement: Gem::Requirement.new('>= 2.4', '< 3.0'),
46
+ supported_options: {
47
+ exe: nil,
48
+ coc: nil,
49
+ ext: %w[c],
50
+ git: nil,
51
+ github_username: nil,
52
+ mit: nil,
53
+ test: %w[minitest rspec test-unit],
54
+ ci: %w[circle github gitlab],
55
+ edit: nil,
56
+ bundle_install: nil
57
+ }
58
+ ),
59
+ Entry.new(
60
+ requirement: Gem::Requirement.new('>= 3.0', '< 4.0'),
61
+ supported_options: {
62
+ exe: nil,
63
+ coc: nil,
64
+ changelog: nil,
65
+ ext: %w[c],
66
+ git: nil,
67
+ github_username: nil,
68
+ mit: nil,
69
+ test: %w[minitest rspec test-unit],
70
+ ci: %w[circle github gitlab],
71
+ linter: %w[rubocop standard],
72
+ edit: nil,
73
+ bundle_install: nil
74
+ }
75
+ ),
76
+ Entry.new(
77
+ requirement: Gem::Requirement.new('>= 4.0', '< 5.0'),
78
+ supported_options: {
79
+ exe: nil,
80
+ coc: nil,
81
+ changelog: nil,
82
+ ext: %w[c go rust],
83
+ git: nil,
84
+ github_username: nil,
85
+ mit: nil,
86
+ test: %w[minitest rspec test-unit],
87
+ ci: %w[circle github gitlab],
88
+ linter: %w[rubocop standard],
89
+ edit: nil,
90
+ bundle_install: nil
91
+ }
92
+ )
93
+ ].freeze
94
+
95
+ # Returns human-readable version range strings for all entries.
96
+ #
97
+ # @return [Array<String>]
98
+ def self.supported_ranges
99
+ TABLE.map { |entry| entry.requirement.requirements.map(&:join).join(', ') }
100
+ end
101
+
102
+ # Finds the compatibility entry for the given Bundler version.
103
+ #
104
+ # @param bundler_version [Gem::Version, String] the Bundler version
105
+ # @return [Entry]
106
+ # @raise [UnsupportedBundlerVersionError] if no entry matches
107
+ def self.for(bundler_version)
108
+ version = Gem::Version.new(bundler_version.to_s)
109
+ entry = TABLE.find { |candidate| candidate.match?(version) }
110
+ return entry if entry
111
+
112
+ message = "Unsupported bundler version: #{version}. "
113
+ message += "Supported ranges: #{supported_ranges.join(' | ')}"
114
+ raise UnsupportedBundlerVersionError, message
115
+ end
116
+ end
117
+ end
118
+ end
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'tempfile'
5
+ require 'yaml'
6
+
7
+ module CreateRubyGem
8
+ module Config
9
+ # YAML persistence for presets and last-used options.
10
+ #
11
+ # Stores configuration at +~/.config/create-ruby-gem/config.yml+ (or
12
+ # +$XDG_CONFIG_HOME/create-ruby-gem/config.yml+). Writes are atomic
13
+ # via +Tempfile+ + rename.
14
+ class Store
15
+ # @return [Integer] current config file schema version
16
+ SCHEMA_VERSION = 1
17
+
18
+ # @param path [String, nil] override the default config file path
19
+ def initialize(path: nil)
20
+ @path = path || default_path
21
+ end
22
+
23
+ # @return [String] absolute path to the config file
24
+ attr_reader :path
25
+
26
+ # Returns the last-used option hash (empty hash if none saved).
27
+ #
28
+ # @return [Hash{String => Object}]
29
+ def last_used
30
+ data.fetch('last_used')
31
+ end
32
+
33
+ # Persists the given options as last-used.
34
+ #
35
+ # @param options [Hash{Symbol => Object}]
36
+ # @return [void]
37
+ def save_last_used(options)
38
+ payload = data
39
+ payload['last_used'] = stringify_keys(options)
40
+ write(payload)
41
+ end
42
+
43
+ # Returns a preset by name, or +nil+ if it does not exist.
44
+ #
45
+ # @param name [String]
46
+ # @return [Hash{String => Object}, nil]
47
+ def preset(name)
48
+ data.fetch('presets').fetch(name.to_s, nil)
49
+ end
50
+
51
+ # Returns all preset names sorted alphabetically.
52
+ #
53
+ # @return [Array<String>]
54
+ def preset_names
55
+ data.fetch('presets').keys.sort
56
+ end
57
+
58
+ # Saves a named preset.
59
+ #
60
+ # @param name [String]
61
+ # @param options [Hash{Symbol => Object}]
62
+ # @return [void]
63
+ def save_preset(name, options)
64
+ payload = data
65
+ payload.fetch('presets')[name.to_s] = stringify_keys(options)
66
+ write(payload)
67
+ end
68
+
69
+ # Deletes a named preset (no-op if it does not exist).
70
+ #
71
+ # @param name [String]
72
+ # @return [void]
73
+ def delete_preset(name)
74
+ payload = data
75
+ payload.fetch('presets').delete(name.to_s)
76
+ write(payload)
77
+ end
78
+
79
+ private
80
+
81
+ # @return [Hash{String => Object}]
82
+ def data
83
+ raw = load
84
+ {
85
+ 'version' => raw.fetch('version', SCHEMA_VERSION),
86
+ 'last_used' => raw.fetch('last_used', {}),
87
+ 'presets' => raw.fetch('presets', {})
88
+ }
89
+ end
90
+
91
+ # @return [Hash]
92
+ # @raise [ConfigError] if YAML is malformed
93
+ def load
94
+ return {} unless File.file?(path)
95
+
96
+ YAML.safe_load_file(path, aliases: false) || {}
97
+ rescue Psych::SyntaxError => e
98
+ raise ConfigError, "Invalid config file at #{path}: #{e.message}"
99
+ end
100
+
101
+ # @param payload [Hash]
102
+ # @return [void]
103
+ def write(payload)
104
+ FileUtils.mkdir_p(File.dirname(path))
105
+ Tempfile.create(['create-ruby-gem', '.yml'], File.dirname(path)) do |tmp|
106
+ tmp.write(YAML.dump(payload))
107
+ tmp.flush
108
+ File.rename(tmp.path, path)
109
+ end
110
+ end
111
+
112
+ # @return [String]
113
+ def default_path
114
+ config_home = ENV.fetch('XDG_CONFIG_HOME', File.join(Dir.home, '.config'))
115
+ File.join(config_home, 'create-ruby-gem', 'config.yml')
116
+ end
117
+
118
+ # @param hash [Hash{Symbol => Object}]
119
+ # @return [Hash{String => Object}]
120
+ def stringify_keys(hash)
121
+ hash.transform_keys(&:to_s)
122
+ end
123
+ end
124
+ end
125
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CreateRubyGem
4
+ module Detection
5
+ # Reads Bundler's own default settings (from +~/.bundle/config+ or env)
6
+ # to use as initial defaults in the wizard.
7
+ class BundlerDefaults
8
+ # Sentinel for "no settings argument provided".
9
+ UNSET = Object.new.freeze
10
+
11
+ # Fallback defaults used when Bundler settings are unavailable.
12
+ #
13
+ # @return [Hash{Symbol => Object}]
14
+ FALLBACKS = {
15
+ exe: false,
16
+ coc: nil,
17
+ changelog: nil,
18
+ ext: false,
19
+ git: true,
20
+ github_username: nil,
21
+ mit: nil,
22
+ test: nil,
23
+ ci: nil,
24
+ linter: nil,
25
+ edit: nil,
26
+ bundle_install: false
27
+ }.freeze
28
+
29
+ # Maps option keys to their Bundler settings keys.
30
+ #
31
+ # @return [Hash{Symbol => String}]
32
+ KEY_MAP = {
33
+ coc: 'gem.coc',
34
+ changelog: 'gem.changelog',
35
+ ext: 'gem.ext',
36
+ git: 'gem.git',
37
+ github_username: 'gem.github_username',
38
+ mit: 'gem.mit',
39
+ test: 'gem.test',
40
+ ci: 'gem.ci',
41
+ linter: 'gem.linter'
42
+ }.freeze
43
+
44
+ # @param settings [#[], nil] Bundler settings object (or nil to auto-detect)
45
+ def initialize(settings: UNSET)
46
+ @settings = settings.equal?(UNSET) ? default_settings : settings
47
+ end
48
+
49
+ # Returns a hash of default values derived from Bundler settings.
50
+ #
51
+ # @return [Hash{Symbol => Object}]
52
+ def detect
53
+ defaults = FALLBACKS.dup
54
+ return defaults unless @settings
55
+
56
+ KEY_MAP.each do |option_key, bundler_key|
57
+ value = normalize(@settings[bundler_key])
58
+ defaults[option_key] = value unless value.nil?
59
+ end
60
+ defaults
61
+ rescue StandardError
62
+ FALLBACKS.dup
63
+ end
64
+
65
+ private
66
+
67
+ # @return [Bundler::Settings, nil]
68
+ def default_settings
69
+ return nil unless defined?(::Bundler)
70
+
71
+ ::Bundler.settings
72
+ end
73
+
74
+ # @param value [String, Object, nil]
75
+ # @return [Boolean, String, nil]
76
+ def normalize(value)
77
+ case value
78
+ when 'true'
79
+ true
80
+ when 'false'
81
+ false
82
+ else
83
+ value
84
+ end
85
+ end
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CreateRubyGem
4
+ module Detection
5
+ # Detects the installed Bundler version by running +bundle --version+.
6
+ class BundlerVersion
7
+ # @return [Regexp] pattern to extract a version string
8
+ VERSION_PATTERN = /(\d+\.\d+\.\d+)/
9
+
10
+ # @param bundle_command [String] path or name of the bundle executable
11
+ def initialize(bundle_command: 'bundle')
12
+ @bundle_command = bundle_command
13
+ end
14
+
15
+ # Runs +bundle --version+ and parses the result.
16
+ #
17
+ # @return [Gem::Version]
18
+ # @raise [UnsupportedBundlerVersionError] if the version cannot be parsed
19
+ # or the executable is not found
20
+ def detect!
21
+ output = IO.popen([@bundle_command, '--version'], err: %i[child out], &:read)
22
+ version = output[VERSION_PATTERN, 1]
23
+ return Gem::Version.new(version) if version
24
+
25
+ raise UnsupportedBundlerVersionError, "Cannot parse bundler version from: #{output.inspect}"
26
+ rescue Errno::ENOENT
27
+ raise UnsupportedBundlerVersionError, "Bundler executable not found: #{@bundle_command}"
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CreateRubyGem
4
+ module Detection
5
+ # Holds detected Ruby, RubyGems, and Bundler versions.
6
+ #
7
+ # @!attribute [r] ruby
8
+ # @return [Gem::Version]
9
+ # @!attribute [r] rubygems
10
+ # @return [Gem::Version]
11
+ # @!attribute [r] bundler
12
+ # @return [Gem::Version]
13
+ RuntimeInfo = Struct.new(:ruby, :rubygems, :bundler, keyword_init: true)
14
+
15
+ # Detects the current Ruby, RubyGems, and Bundler versions.
16
+ class Runtime
17
+ # @param bundler_detector [Detection::BundlerVersion]
18
+ def initialize(bundler_detector: BundlerVersion.new)
19
+ @bundler_detector = bundler_detector
20
+ end
21
+
22
+ # @return [RuntimeInfo]
23
+ # @raise [UnsupportedBundlerVersionError] if Bundler cannot be detected
24
+ def detect!
25
+ RuntimeInfo.new(
26
+ ruby: Gem::Version.new(RUBY_VERSION),
27
+ rubygems: Gem::Version.new(Gem::VERSION),
28
+ bundler: @bundler_detector.detect!
29
+ )
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CreateRubyGem
4
+ # Base error for all create-ruby-gem failures.
5
+ class Error < StandardError; end
6
+
7
+ # Raised when the config file is corrupt or unreadable.
8
+ class ConfigError < Error; end
9
+
10
+ # Raised when CLI flags or option values are invalid.
11
+ class ValidationError < Error; end
12
+
13
+ # Raised when the detected Bundler version is outside all known ranges.
14
+ class UnsupportedBundlerVersionError < Error; end
15
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CreateRubyGem
4
+ module Options
5
+ # Registry of every +bundle gem+ option create-ruby-gem knows about.
6
+ #
7
+ # Each entry in {DEFINITIONS} describes the option's type and CLI flags.
8
+ # {ORDER} controls the sequence in which the wizard presents options.
9
+ #
10
+ # @see Options::Validator
11
+ # @see Wizard::Session
12
+ module Catalog
13
+ # Option definitions keyed by symbolic name.
14
+ #
15
+ # Each value is a Hash with +:type+ and the relevant flag keys
16
+ # (+:on+/+:off+ for toggles, +:flag+ for enums/strings, etc.).
17
+ #
18
+ # @return [Hash{Symbol => Hash}]
19
+ DEFINITIONS = {
20
+ exe: { type: :toggle, on: '--exe', off: '--no-exe' },
21
+ coc: { type: :toggle, on: '--coc', off: '--no-coc' },
22
+ changelog: { type: :toggle, on: '--changelog', off: '--no-changelog' },
23
+ ext: { type: :enum, flag: '--ext', none: '--no-ext', values: %w[c go rust] },
24
+ git: { type: :flag, on: '--git' },
25
+ github_username: { type: :string, flag: '--github-username' },
26
+ mit: { type: :toggle, on: '--mit', off: '--no-mit' },
27
+ test: { type: :enum, flag: '--test', none: '--no-test', values: %w[minitest rspec test-unit] },
28
+ ci: { type: :enum, flag: '--ci', none: '--no-ci', values: %w[circle github gitlab] },
29
+ linter: { type: :enum, flag: '--linter', none: '--no-linter', values: %w[rubocop standard] },
30
+ edit: { type: :string, flag: '--edit' },
31
+ bundle_install: { type: :toggle, on: '--bundle', off: '--no-bundle' }
32
+ }.freeze
33
+
34
+ # Wizard step order — matches the sequence users see.
35
+ #
36
+ # @return [Array<Symbol>]
37
+ ORDER = DEFINITIONS.keys.freeze
38
+
39
+ # Fetches the definition for a given option key.
40
+ #
41
+ # @param key [Symbol, String]
42
+ # @return [Hash] the option definition
43
+ # @raise [KeyError] if the key is unknown
44
+ def self.fetch(key)
45
+ DEFINITIONS.fetch(key.to_sym)
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CreateRubyGem
4
+ module Options
5
+ # Validates user-selected options against the compatibility entry
6
+ # for the detected Bundler version.
7
+ #
8
+ # Checks that: the gem name is valid, every option key is known,
9
+ # every option is supported by this Bundler version, and every
10
+ # value is valid for the option's type and allowed values.
11
+ class Validator
12
+ # @return [Regexp] pattern for valid gem names
13
+ GEM_NAME_PATTERN = /\A[a-zA-Z][a-zA-Z0-9_-]*\z/
14
+
15
+ # @param compatibility_entry [Compatibility::Matrix::Entry]
16
+ def initialize(compatibility_entry)
17
+ @compatibility_entry = compatibility_entry
18
+ end
19
+
20
+ # Validates the gem name and all options.
21
+ #
22
+ # @param gem_name [String]
23
+ # @param options [Hash{Symbol => Object}]
24
+ # @return [true]
25
+ # @raise [ValidationError] if any validation fails
26
+ def validate!(gem_name:, options:) # rubocop:disable Naming/PredicateMethod
27
+ validate_gem_name!(gem_name)
28
+
29
+ options.each do |key, value|
30
+ validate_option_key!(key)
31
+ validate_supported_option!(key)
32
+ validate_value!(key, value)
33
+ validate_supported_value!(key, value)
34
+ end
35
+
36
+ true
37
+ end
38
+
39
+ private
40
+
41
+ # @param gem_name [String]
42
+ # @raise [ValidationError]
43
+ # @return [void]
44
+ def validate_gem_name!(gem_name)
45
+ return if gem_name.is_a?(String) && gem_name.match?(GEM_NAME_PATTERN)
46
+
47
+ raise ValidationError, "Invalid gem name: #{gem_name.inspect}"
48
+ end
49
+
50
+ # @param key [Symbol]
51
+ # @raise [ValidationError]
52
+ # @return [void]
53
+ def validate_option_key!(key)
54
+ return if Catalog::DEFINITIONS.key?(key.to_sym)
55
+
56
+ raise ValidationError, "Unknown option: #{key}"
57
+ end
58
+
59
+ # @param key [Symbol]
60
+ # @raise [ValidationError]
61
+ # @return [void]
62
+ def validate_supported_option!(key)
63
+ return if @compatibility_entry.supports_option?(key)
64
+
65
+ raise ValidationError, "Option #{key} is not supported by this bundler version"
66
+ end
67
+
68
+ # @param key [Symbol]
69
+ # @param value [Object]
70
+ # @raise [ValidationError]
71
+ # @return [void]
72
+ def validate_value!(key, value)
73
+ definition = Catalog.fetch(key)
74
+ case definition[:type]
75
+ when :toggle
76
+ return if value.nil? || value == true || value == false
77
+ when :flag
78
+ return if value.nil? || value == true
79
+ when :enum
80
+ return if value.nil? || value == false || definition[:values].include?(value)
81
+ when :string
82
+ return if value.nil? || value.is_a?(String)
83
+ end
84
+
85
+ raise ValidationError, "Invalid value for #{key}: #{value.inspect}"
86
+ end
87
+
88
+ # @param key [Symbol]
89
+ # @param value [Object]
90
+ # @raise [ValidationError]
91
+ # @return [void]
92
+ def validate_supported_value!(key, value)
93
+ return if value.nil? || value == true || value == false
94
+
95
+ supported_values = @compatibility_entry.allowed_values(key)
96
+ return if supported_values.nil? || supported_values.include?(value)
97
+
98
+ raise ValidationError, "Value #{value.inspect} for #{key} is not supported by this bundler version"
99
+ end
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'cli/kit'
4
+ require 'shellwords'
5
+
6
+ module CreateRubyGem
7
+ # Executes the assembled +bundle gem+ command via the shell.
8
+ #
9
+ # Supports +--dry-run+ mode, which prints the command instead of running it.
10
+ #
11
+ # @example
12
+ # Runner.new.run!(['bundle', 'gem', 'my_gem', '--exe'])
13
+ class Runner
14
+ # @param out [IO] output stream for dry-run printing
15
+ # @param system_runner [#call, nil] callable that executes a shell command
16
+ def initialize(out: $stdout, system_runner: nil)
17
+ @out = out
18
+ @system_runner = system_runner || ->(*command) { ::CLI::Kit::System.system(*command) }
19
+ end
20
+
21
+ # Executes the command or prints it in dry-run mode.
22
+ #
23
+ # @param command [Array<String>] the command to execute
24
+ # @param dry_run [Boolean] when true, prints instead of executing
25
+ # @return [true] on success
26
+ # @raise [Error] if the command exits with a non-zero status
27
+ def run!(command, dry_run: false)
28
+ if dry_run
29
+ @out.puts(command.join(' '))
30
+ return true
31
+ end
32
+
33
+ status = @system_runner.call(*command)
34
+ return true if status.respond_to?(:success?) && status.success?
35
+
36
+ raise Error, "Command failed: #{command.shelljoin}"
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CreateRubyGem
4
+ module UI
5
+ # Raised when the user presses Ctrl+B during a prompt.
6
+ #
7
+ # @api private
8
+ class BackKeyPressed < StandardError; end
9
+
10
+ # Monkey-patches +CLI::UI::Prompt+ to intercept Ctrl+B for back-navigation.
11
+ #
12
+ # @api private
13
+ module BackNavigationPatch
14
+ # ASCII code for Ctrl+B.
15
+ CTRL_B = "\u0002"
16
+
17
+ # Patch module prepended onto +CLI::UI::Prompt.singleton_class+.
18
+ module PromptReadCharPatch
19
+ # @raise [BackKeyPressed] when the user presses Ctrl+B
20
+ # @return [String] the character read
21
+ def read_char
22
+ char = super
23
+
24
+ raise BackKeyPressed if char == BackNavigationPatch::CTRL_B
25
+
26
+ char
27
+ end
28
+ end
29
+
30
+ # Prepends the Ctrl+B patch onto +CLI::UI::Prompt+ (idempotent).
31
+ #
32
+ # @return [void]
33
+ def self.apply!
34
+ singleton = ::CLI::UI::Prompt.singleton_class
35
+ return if singleton.ancestors.include?(PromptReadCharPatch)
36
+
37
+ singleton.prepend(PromptReadCharPatch)
38
+ end
39
+ end
40
+ end
41
+ end