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,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CreateRubyGem
4
+ module UI
5
+ # Color constants for terminal output.
6
+ #
7
+ # Maps semantic roles (e.g. +:summary_label+, +:arg_name+) to ANSI 256-color
8
+ # codes or +cli-ui+ basic color names, depending on terminal capabilities.
9
+ class Palette
10
+ # ANSI reset sequence.
11
+ RESET = "\e[0m"
12
+
13
+ # Role-to-color mappings for 256-color terminals.
14
+ #
15
+ # @return [Hash{Symbol => Integer}]
16
+ ROLE_COLORS_256 = {
17
+ control_back: 45,
18
+ control_exit: 203,
19
+ summary_label: 213,
20
+ runtime_name: 111,
21
+ runtime_value: 190,
22
+ command_base: 39,
23
+ command_gem: 82,
24
+ arg_name: 117,
25
+ arg_eq: 250,
26
+ arg_value: 214
27
+ }.freeze
28
+
29
+ # Role-to-color mappings for basic (8/16-color) terminals.
30
+ #
31
+ # @return [Hash{Symbol => String}]
32
+ ROLE_COLORS_BASIC = {
33
+ control_back: 'cyan',
34
+ control_exit: 'red',
35
+ summary_label: 'magenta',
36
+ runtime_name: 'blue',
37
+ runtime_value: 'green',
38
+ command_base: 'blue',
39
+ command_gem: 'green',
40
+ arg_name: 'blue',
41
+ arg_eq: 'white',
42
+ arg_value: 'yellow'
43
+ }.freeze
44
+
45
+ # @param env [Hash] environment variables (defaults to +ENV+)
46
+ def initialize(env: ENV)
47
+ @env = env
48
+ end
49
+
50
+ # Wraps text in the appropriate ANSI color for the given role.
51
+ #
52
+ # @param role [Symbol] a key from {ROLE_COLORS_256}/{ROLE_COLORS_BASIC}
53
+ # @param text [String] the text to colorize
54
+ # @return [String]
55
+ def color(role, text)
56
+ if supports_256_colors?
57
+ "\e[38;5;#{ROLE_COLORS_256.fetch(role)}m#{text}#{RESET}"
58
+ else
59
+ "{{#{ROLE_COLORS_BASIC.fetch(role)}:#{text}}}"
60
+ end
61
+ end
62
+
63
+ private
64
+
65
+ # @return [Boolean]
66
+ def supports_256_colors?
67
+ term = @env.fetch('TERM', '')
68
+ colorterm = @env.fetch('COLORTERM', '')
69
+ term.include?('256color') || colorterm.match?(/truecolor|24bit|256/i)
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'cli/ui'
4
+ require_relative 'back_navigation_patch'
5
+
6
+ module CreateRubyGem
7
+ module UI
8
+ # Thin wrapper around +cli-ui+ for all user interaction.
9
+ #
10
+ # Every prompt the wizard issues goes through this class, making it
11
+ # easy to inject a test double.
12
+ class Prompter
13
+ # Enables the +cli-ui+ stdout router and applies the Ctrl+B patch.
14
+ #
15
+ # Call once before creating a Prompter instance. Idempotent.
16
+ #
17
+ # @return [void]
18
+ def self.setup!
19
+ ::CLI::UI::StdoutRouter.enable
20
+ BackNavigationPatch.apply!
21
+ end
22
+
23
+ # @param out [IO] output stream
24
+ def initialize(out: $stdout)
25
+ @out = out
26
+ end
27
+
28
+ # Opens a visual frame with a title.
29
+ #
30
+ # @param title [String]
31
+ # @yield block executed inside the frame
32
+ # @return [void]
33
+ def frame(title, &)
34
+ ::CLI::UI::Frame.open(title, &)
35
+ end
36
+
37
+ # Presents a single-choice list.
38
+ #
39
+ # @param question [String]
40
+ # @param options [Array<String>]
41
+ # @param default [String, nil]
42
+ # @return [String] selected option, or {Wizard::BACK} on Ctrl+B
43
+ def choose(question, options:, default: nil)
44
+ ::CLI::UI.ask(question, options: options, default: default, filter_ui: false)
45
+ rescue BackKeyPressed
46
+ Wizard::BACK
47
+ end
48
+
49
+ # Prompts for free-text input.
50
+ #
51
+ # @param question [String]
52
+ # @param default [String, nil]
53
+ # @param allow_empty [Boolean]
54
+ # @return [String]
55
+ def text(question, default: nil, allow_empty: true)
56
+ ::CLI::UI.ask(question, default: default, allow_empty: allow_empty)
57
+ end
58
+
59
+ # Prompts for yes/no confirmation.
60
+ #
61
+ # @param question [String]
62
+ # @param default [Boolean]
63
+ # @return [Boolean]
64
+ def confirm(question, default: true)
65
+ ::CLI::UI.confirm(question, default: default)
66
+ end
67
+
68
+ # Prints a formatted message to the output stream.
69
+ #
70
+ # @param message [String] message with optional +cli-ui+ formatting tags
71
+ # @return [void]
72
+ def say(message)
73
+ @out.puts(::CLI::UI.fmt(message))
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CreateRubyGem
4
+ # @return [String] current gem version
5
+ VERSION = '0.1.0'
6
+ end
@@ -0,0 +1,339 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CreateRubyGem
4
+ # Step-by-step interactive prompt loop for choosing +bundle gem+ options.
5
+ #
6
+ # Walks through each supported option in {Options::Catalog::ORDER},
7
+ # presenting only the options supported by the detected Bundler version.
8
+ # Supports back-navigation via +Ctrl+B+.
9
+ #
10
+ # @see CLI#run_interactive_wizard!
11
+ class Wizard
12
+ # Sentinel returned by the prompter when the user presses Ctrl+B.
13
+ BACK = Object.new.freeze
14
+
15
+ # Sentinel indicating the user wants Bundler's built-in default.
16
+ BUNDLER_DEFAULT = Object.new.freeze
17
+
18
+ # Human-readable labels for each option key.
19
+ #
20
+ # @return [Hash{Symbol => String}]
21
+ LABELS = {
22
+ exe: 'Create executable',
23
+ coc: 'Add CODE_OF_CONDUCT.md',
24
+ changelog: 'Add CHANGELOG.md',
25
+ ext: 'Native extension',
26
+ git: 'Initialize git',
27
+ github_username: 'GitHub username',
28
+ mit: 'Include MIT license',
29
+ test: 'Test framework',
30
+ ci: 'CI provider',
31
+ linter: 'Linter',
32
+ edit: 'Editor command',
33
+ bundle_install: 'Run bundle install'
34
+ }.freeze
35
+
36
+ # Short explanations shown below each wizard step.
37
+ #
38
+ # @return [Hash{Symbol => String}]
39
+ HELP_TEXT = {
40
+ exe: 'Adds an executable file in exe/ so users can run your gem as a command.',
41
+ coc: 'Adds a code of conduct template for contributors.',
42
+ changelog: 'Adds CHANGELOG.md to track release notes.',
43
+ ext: 'Sets up native extension scaffolding for C, Go, or Rust.',
44
+ git: 'Initializes a git repository for the new gem.',
45
+ github_username: 'Used in links and metadata for your GitHub account.',
46
+ mit: 'Adds the MIT license file.',
47
+ test: 'Chooses which test framework files to generate.',
48
+ ci: 'Chooses CI pipeline config to include.',
49
+ linter: 'Chooses linting setup for style and quality checks.',
50
+ edit: 'Sets your preferred command for opening files.',
51
+ bundle_install: 'Runs bundle install after generating the gem.'
52
+ }.freeze
53
+
54
+ # Per-choice hints displayed next to enum/string choices.
55
+ #
56
+ # @return [Hash{Symbol => Hash{String => String}}]
57
+ CHOICE_HELP = {
58
+ ext: {
59
+ 'c' => 'classic native extension path',
60
+ 'go' => 'Go-based extension via FFI/tooling',
61
+ 'rust' => 'Rust extension path',
62
+ 'none' => 'no native extension'
63
+ },
64
+ test: {
65
+ 'minitest' => 'small built-in Ruby test style',
66
+ 'rspec' => 'popular behavior-style testing',
67
+ 'test-unit' => 'xUnit-style test framework',
68
+ 'none' => 'no test framework files'
69
+ },
70
+ ci: {
71
+ 'circle' => 'CircleCI config',
72
+ 'github' => 'GitHub Actions workflow',
73
+ 'gitlab' => 'GitLab CI pipeline',
74
+ 'none' => 'no CI config'
75
+ },
76
+ linter: {
77
+ 'rubocop' => 'full-featured Ruby linting',
78
+ 'standard' => 'zero-config style linting',
79
+ 'none' => 'no linter config'
80
+ },
81
+ github_username: {
82
+ 'set' => 'enter a value now',
83
+ 'none' => 'leave unset'
84
+ },
85
+ edit: {
86
+ 'set' => 'enter a value now',
87
+ 'none' => 'leave unset'
88
+ }
89
+ }.freeze
90
+
91
+ # @param compatibility_entry [Compatibility::Matrix::Entry]
92
+ # @param defaults [Hash{Symbol => Object}] initial default values (e.g. last-used)
93
+ # @param prompter [UI::Prompter]
94
+ # @param bundler_defaults [Hash{Symbol => Object}] Bundler's own defaults
95
+ def initialize(compatibility_entry:, defaults:, prompter:, bundler_defaults: {})
96
+ @compatibility_entry = compatibility_entry
97
+ @bundler_defaults = symbolize_keys(bundler_defaults)
98
+ @prompter = prompter
99
+ @values = sanitize_defaults(defaults)
100
+ end
101
+
102
+ # Runs the wizard and returns the selected options.
103
+ #
104
+ # @return [Hash{Symbol => Object}]
105
+ def run
106
+ keys = Options::Catalog::ORDER.select { |key| @compatibility_entry.supports_option?(key) }
107
+ index = 0
108
+ while index < keys.length
109
+ key = keys[index]
110
+ answer = ask_for(key, index:, total: keys.length)
111
+ case answer
112
+ when BACK
113
+ index -= 1 if index.positive?
114
+ else
115
+ assign_value(key, answer)
116
+ index += 1
117
+ end
118
+ end
119
+ @values.dup
120
+ end
121
+
122
+ private
123
+
124
+ # @param key [Symbol]
125
+ # @param index [Integer]
126
+ # @param total [Integer]
127
+ # @return [Object] user answer or {BACK}
128
+ def ask_for(key, index:, total:)
129
+ definition = Options::Catalog.fetch(key)
130
+ label = LABELS.fetch(key)
131
+ question = render_question(index: index, total: total, key: key, label: label)
132
+ case definition[:type]
133
+ when :toggle
134
+ ask_toggle(question, key)
135
+ when :flag
136
+ ask_flag(question, key)
137
+ when :enum
138
+ ask_enum(question, key, definition)
139
+ when :string
140
+ ask_string(question, key)
141
+ else
142
+ raise Error, "Unknown option type for #{key}"
143
+ end
144
+ end
145
+
146
+ # @param question [String]
147
+ # @param key [Symbol]
148
+ # @return [true, false, nil, String]
149
+ def ask_toggle(question, key)
150
+ choices = %w[yes no]
151
+ current = @values[key]
152
+ default_choice = toggle_default_choice(current, key)
153
+ answer = choose_with_default_marker(question, key:, choices:, default_choice:)
154
+ return BACK if answer == BACK
155
+ return true if answer == 'yes'
156
+ return false if answer == 'no'
157
+
158
+ nil
159
+ end
160
+
161
+ # @param question [String]
162
+ # @param key [Symbol]
163
+ # @return [true, String, nil]
164
+ def ask_flag(question, key)
165
+ choices = %w[yes no]
166
+ current = @values[key]
167
+ default_choice = current == true ? 'yes' : flag_default_choice(key)
168
+ answer = choose_with_default_marker(question, key:, choices:, default_choice:)
169
+ return BACK if answer == BACK
170
+ return true if answer == 'yes'
171
+ return BUNDLER_DEFAULT if answer == 'no'
172
+
173
+ nil
174
+ end
175
+
176
+ # @param question [String]
177
+ # @param key [Symbol]
178
+ # @param definition [Hash]
179
+ # @return [String, false, String]
180
+ def ask_enum(question, key, definition)
181
+ choices = @compatibility_entry.allowed_values(key).dup
182
+ choices << 'none' if definition[:none]
183
+ current = @values[key]
184
+ default_choice = enum_default_choice(key, current, choices)
185
+ answer = choose_with_default_marker(question, key:, choices:, default_choice:)
186
+ return BACK if answer == BACK
187
+ return false if answer == 'none'
188
+
189
+ answer
190
+ end
191
+
192
+ # @param question [String]
193
+ # @param key [Symbol]
194
+ # @return [String, nil, String]
195
+ def ask_string(question, key)
196
+ has_current = @values[key].is_a?(String) && !@values[key].empty?
197
+ choices =
198
+ if has_current
199
+ %w[keep set]
200
+ else
201
+ %w[set none]
202
+ end
203
+ default_choice = has_current ? 'keep' : 'none'
204
+ answer = choose_with_default_marker(question, key:, choices:, default_choice:)
205
+ return BACK if answer == BACK
206
+ return BUNDLER_DEFAULT if answer == 'none'
207
+ return @values[key] if answer == 'keep'
208
+
209
+ default_value = key == :github_username ? nil : @values[key]
210
+ allow_empty = key != :github_username
211
+ value = @prompter.text("#{LABELS.fetch(key)}:", default: default_value, allow_empty: allow_empty)
212
+ value.empty? ? nil : value
213
+ end
214
+
215
+ # @param key [Symbol]
216
+ # @param value [Object]
217
+ # @return [void]
218
+ def assign_value(key, value)
219
+ if value.nil? || value == BUNDLER_DEFAULT
220
+ @values.delete(key)
221
+ else
222
+ @values[key] = value
223
+ end
224
+ end
225
+
226
+ # @param hash [Hash]
227
+ # @return [Hash{Symbol => Object}]
228
+ def symbolize_keys(hash)
229
+ hash.transform_keys(&:to_sym)
230
+ end
231
+
232
+ # Filters defaults to only supported options and removes false flags.
233
+ #
234
+ # @param hash [Hash]
235
+ # @return [Hash{Symbol => Object}]
236
+ def sanitize_defaults(hash)
237
+ symbolize_keys(hash)
238
+ .select { |key, _| @compatibility_entry.supports_option?(key) }
239
+ .each_with_object({}) do |(key, value), acc|
240
+ definition = Options::Catalog.fetch(key)
241
+ acc[key] = value unless definition[:type] == :flag && value == false
242
+ end
243
+ end
244
+
245
+ # @param current [Object]
246
+ # @param key [Symbol]
247
+ # @return [String]
248
+ def toggle_default_choice(current, key)
249
+ return 'yes' if current == true
250
+ return 'no' if current == false
251
+
252
+ @bundler_defaults[key] == true ? 'yes' : 'no'
253
+ end
254
+
255
+ # @param key [Symbol]
256
+ # @return [String]
257
+ def flag_default_choice(key)
258
+ @bundler_defaults[key] == true ? 'yes' : 'no'
259
+ end
260
+
261
+ # @param key [Symbol]
262
+ # @param current [Object]
263
+ # @param choices [Array<String>]
264
+ # @return [String]
265
+ def enum_default_choice(key, current, choices)
266
+ return 'none' if current == false && choices.include?('none')
267
+ return current if current.is_a?(String) && choices.include?(current)
268
+
269
+ bundler_default = @bundler_defaults[key]
270
+ return 'none' if bundler_default == false && choices.include?('none')
271
+ return bundler_default if bundler_default.is_a?(String) && choices.include?(bundler_default)
272
+
273
+ choices.first
274
+ end
275
+
276
+ # Presents a choice list with the default marked and reordered first.
277
+ #
278
+ # @param question [String]
279
+ # @param key [Symbol]
280
+ # @param choices [Array<String>]
281
+ # @param default_choice [String]
282
+ # @return [String] the raw choice value, or {BACK}
283
+ def choose_with_default_marker(question, key:, choices:, default_choice:)
284
+ selected_default = choices.include?(default_choice) ? default_choice : choices.first
285
+ ordered_choices = reorder_default_first(choices, selected_default)
286
+ rendered_pairs = ordered_choices.map do |choice|
287
+ [render_choice_label(key, choice, default_choice: selected_default), choice]
288
+ end
289
+ rendered = rendered_pairs.map(&:first)
290
+ answer = @prompter.choose(question, options: rendered, default: rendered.first)
291
+ return BACK if answer == BACK
292
+
293
+ rendered_index = rendered.index(answer)
294
+ return ordered_choices[rendered_index] if rendered_index
295
+
296
+ selected_default
297
+ end
298
+
299
+ # @param choices [Array<String>]
300
+ # @param default_choice [String]
301
+ # @return [Array<String>]
302
+ def reorder_default_first(choices, default_choice)
303
+ return choices.dup unless choices.include?(default_choice)
304
+
305
+ [default_choice] + choices.reject { |choice| choice == default_choice }
306
+ end
307
+
308
+ # @param index [Integer]
309
+ # @param total [Integer]
310
+ # @param key [Symbol]
311
+ # @param label [String]
312
+ # @return [String]
313
+ def render_question(index:, total:, key:, label:)
314
+ step = format('%<current>02d/%<total>02d', current: index + 1, total: total)
315
+ "{{cyan:#{step}}} {{bold:#{label}}} - #{HELP_TEXT.fetch(key)}"
316
+ end
317
+
318
+ # @param key [Symbol]
319
+ # @param choice [String]
320
+ # @param default_choice [String]
321
+ # @return [String]
322
+ def render_choice_label(key, choice, default_choice:)
323
+ label = choice
324
+ hint = choice_hint(key, choice)
325
+ label = "#{label} - #{hint}" if hint
326
+ label = "#{label} (default)" if choice == default_choice
327
+ label
328
+ end
329
+
330
+ # @param key [Symbol]
331
+ # @param choice [String]
332
+ # @return [String, nil]
333
+ def choice_hint(key, choice)
334
+ return "use #{@values[key]}" if choice == 'keep' && @values[key].is_a?(String) && !@values[key].empty?
335
+
336
+ CHOICE_HELP.dig(key, choice)
337
+ end
338
+ end
339
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'create_ruby_gem/version'
4
+ require_relative 'create_ruby_gem/error'
5
+ require_relative 'create_ruby_gem/detection/bundler_version'
6
+ require_relative 'create_ruby_gem/detection/bundler_defaults'
7
+ require_relative 'create_ruby_gem/detection/runtime'
8
+ require_relative 'create_ruby_gem/options/catalog'
9
+ require_relative 'create_ruby_gem/compatibility/matrix'
10
+ require_relative 'create_ruby_gem/options/validator'
11
+ require_relative 'create_ruby_gem/command_builder'
12
+ require_relative 'create_ruby_gem/config/store'
13
+ require_relative 'create_ruby_gem/runner'
14
+ require_relative 'create_ruby_gem/ui/palette'
15
+ require_relative 'create_ruby_gem/ui/prompter'
16
+ require_relative 'create_ruby_gem/wizard'
17
+ require_relative 'create_ruby_gem/cli'
18
+
19
+ # Interactive TUI wizard for +bundle gem+.
20
+ #
21
+ # Detects the user's Ruby/Bundler versions, shows only compatible options
22
+ # via a static compatibility matrix, and builds the correct +bundle gem+
23
+ # command. Config (presets, last-used options) is stored in
24
+ # +~/.config/create-ruby-gem/config.yml+.
25
+ #
26
+ # @see CreateRubyGem::CLI Entry point
27
+ # @see CreateRubyGem::Compatibility::Matrix Bundler version compatibility
28
+ module CreateRubyGem
29
+ end
metadata ADDED
@@ -0,0 +1,96 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: create-ruby-gem
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Leonid Svyatov
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: cli-kit
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - '='
17
+ - !ruby/object:Gem::Version
18
+ version: 5.0.1
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - '='
24
+ - !ruby/object:Gem::Version
25
+ version: 5.0.1
26
+ - !ruby/object:Gem::Dependency
27
+ name: cli-ui
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - '='
31
+ - !ruby/object:Gem::Version
32
+ version: 2.7.0
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - '='
38
+ - !ruby/object:Gem::Version
39
+ version: 2.7.0
40
+ description: Interactive CLI wizard for bundle gem. Walks you through every option,
41
+ saves presets, and remembers your choices. No more bundle gem flags look-ups!
42
+ email:
43
+ - leonid@svyatov.com
44
+ executables:
45
+ - create-ruby-gem
46
+ extensions: []
47
+ extra_rdoc_files: []
48
+ files:
49
+ - CHANGELOG.md
50
+ - LICENSE.txt
51
+ - README.md
52
+ - exe/create-ruby-gem
53
+ - lib/create_ruby_gem.rb
54
+ - lib/create_ruby_gem/cli.rb
55
+ - lib/create_ruby_gem/command_builder.rb
56
+ - lib/create_ruby_gem/compatibility/matrix.rb
57
+ - lib/create_ruby_gem/config/store.rb
58
+ - lib/create_ruby_gem/detection/bundler_defaults.rb
59
+ - lib/create_ruby_gem/detection/bundler_version.rb
60
+ - lib/create_ruby_gem/detection/runtime.rb
61
+ - lib/create_ruby_gem/error.rb
62
+ - lib/create_ruby_gem/options/catalog.rb
63
+ - lib/create_ruby_gem/options/validator.rb
64
+ - lib/create_ruby_gem/runner.rb
65
+ - lib/create_ruby_gem/ui/back_navigation_patch.rb
66
+ - lib/create_ruby_gem/ui/palette.rb
67
+ - lib/create_ruby_gem/ui/prompter.rb
68
+ - lib/create_ruby_gem/version.rb
69
+ - lib/create_ruby_gem/wizard.rb
70
+ homepage: https://github.com/svyatov/create-ruby-gem
71
+ licenses:
72
+ - MIT
73
+ metadata:
74
+ allowed_push_host: https://rubygems.org
75
+ changelog_uri: https://github.com/svyatov/create-ruby-gem/blob/main/CHANGELOG.md
76
+ documentation_uri: https://rubydoc.info/gems/create-ruby-gem
77
+ bug_tracker_uri: https://github.com/svyatov/create-ruby-gem/issues
78
+ rubygems_mfa_required: 'true'
79
+ rdoc_options: []
80
+ require_paths:
81
+ - lib
82
+ required_ruby_version: !ruby/object:Gem::Requirement
83
+ requirements:
84
+ - - ">="
85
+ - !ruby/object:Gem::Version
86
+ version: '3.2'
87
+ required_rubygems_version: !ruby/object:Gem::Requirement
88
+ requirements:
89
+ - - ">="
90
+ - !ruby/object:Gem::Version
91
+ version: '0'
92
+ requirements: []
93
+ rubygems_version: 4.0.4
94
+ specification_version: 4
95
+ summary: Create Ruby gems with an interactive CLI wizard that remembers your choices
96
+ test_files: []