create-rails-app 0.1.0 → 0.2.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,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CreateRailsApp
4
+ module Compatibility
5
+ # Static lookup table mapping Rails version ranges to the +rails new+
6
+ # options each range supports.
7
+ #
8
+ # This is the single source of truth for what each Rails version
9
+ # can do. The wizard, validator, and builder all consult it.
10
+ #
11
+ # @example Look up the entry for a specific Rails version
12
+ # entry = Matrix.for('8.0.1')
13
+ # entry.supports_option?(:kamal) #=> true
14
+ class Matrix
15
+ # A single row in the compatibility table.
16
+ #
17
+ # @!attribute [r] requirement
18
+ # @return [Gem::Requirement] Rails 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/skip value is accepted)
22
+ Entry = Struct.new(:requirement, :supported_options) do
23
+ # @param rails_version [Gem::Version] version to test
24
+ # @return [Boolean]
25
+ def match?(rails_version)
26
+ requirement.satisfied_by?(rails_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 skips/flags
37
+ def allowed_values(option_key)
38
+ supported_options[option_key.to_sym]
39
+ end
40
+ end
41
+
42
+ # Supported Rails series for version detection and installation.
43
+ #
44
+ # @return [Array<String>]
45
+ SUPPORTED_SERIES = %w[7.2 8.0 8.1].freeze
46
+
47
+ # Options shared across all supported Rails versions.
48
+ # Enum values are derived from Catalog to prevent drift.
49
+ COMMON_OPTIONS = {
50
+ api: nil,
51
+ database: Options::Catalog::BASE_DATABASE_VALUES,
52
+ javascript: Options::Catalog::DEFINITIONS[:javascript][:values],
53
+ css: Options::Catalog::DEFINITIONS[:css][:values],
54
+ asset_pipeline: Options::Catalog::DEFINITIONS[:asset_pipeline][:values],
55
+ active_record: nil,
56
+ action_mailer: nil,
57
+ action_mailbox: nil,
58
+ action_text: nil,
59
+ active_job: nil,
60
+ active_storage: nil,
61
+ action_cable: nil,
62
+ hotwire: nil,
63
+ jbuilder: nil,
64
+ test: nil,
65
+ system_test: nil,
66
+ brakeman: nil,
67
+ rubocop: nil,
68
+ ci: nil,
69
+ docker: nil,
70
+ devcontainer: nil,
71
+ bootsnap: nil,
72
+ dev_gems: nil,
73
+ keeps: nil,
74
+ decrypted_diffs: nil,
75
+ git: nil,
76
+ bundle: nil
77
+ }.freeze
78
+
79
+ # Options added or changed in Rails 8.0+.
80
+ RAILS_8_OPTIONS = {
81
+ kamal: nil,
82
+ thruster: nil,
83
+ solid: nil,
84
+ database: Options::Catalog::DEFINITIONS[:database][:values],
85
+ asset_pipeline: nil
86
+ }.freeze
87
+
88
+ # Options added in Rails 8.1+.
89
+ RAILS_81_OPTIONS = {
90
+ bundler_audit: nil
91
+ }.freeze
92
+
93
+ # @return [Array<Entry>] all known Rails compatibility entries
94
+ TABLE = [
95
+ Entry.new(
96
+ requirement: Gem::Requirement.new('~> 7.2.0'),
97
+ supported_options: COMMON_OPTIONS.dup.freeze
98
+ ),
99
+ Entry.new(
100
+ requirement: Gem::Requirement.new('~> 8.0.0'),
101
+ supported_options: COMMON_OPTIONS.merge(RAILS_8_OPTIONS).freeze
102
+ ),
103
+ Entry.new(
104
+ requirement: Gem::Requirement.new('~> 8.1.0'),
105
+ supported_options: COMMON_OPTIONS.merge(RAILS_8_OPTIONS).merge(RAILS_81_OPTIONS).freeze
106
+ )
107
+ ].freeze
108
+
109
+ # Returns human-readable version range strings for all entries.
110
+ #
111
+ # @return [Array<String>]
112
+ def self.supported_ranges
113
+ TABLE.map { |entry| entry.requirement.requirements.map(&:join).join(', ') }
114
+ end
115
+
116
+ # Finds the compatibility entry for the given Rails version.
117
+ #
118
+ # @param rails_version [Gem::Version, String] the Rails version
119
+ # @return [Entry]
120
+ # @raise [UnsupportedRailsVersionError] if no entry matches
121
+ def self.for(rails_version)
122
+ version = Gem::Version.new(rails_version.to_s)
123
+ entry = TABLE.find { |candidate| candidate.match?(version) }
124
+ return entry if entry
125
+
126
+ message = "Unsupported Rails version: #{version}. "
127
+ message += "Supported ranges: #{supported_ranges.join(' | ')}"
128
+ raise UnsupportedRailsVersionError, message
129
+ end
130
+ end
131
+ end
132
+ end
@@ -0,0 +1,149 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'tempfile'
5
+ require 'yaml'
6
+
7
+ module CreateRailsApp
8
+ module Config
9
+ # YAML persistence for presets and last-used options.
10
+ #
11
+ # Stores configuration at +~/.config/create-rails-app/config.yml+ (or
12
+ # +$XDG_CONFIG_HOME/create-rails-app/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['last_used'].is_a?(Hash) ? raw['last_used'] : {}),
87
+ 'presets' => (raw['presets'].is_a?(Hash) ? raw['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
+ result = YAML.safe_load_file(path, aliases: false)
97
+ unless result.nil? || result.is_a?(Hash)
98
+ raise ConfigError, "Invalid config file at #{path}: expected a YAML mapping"
99
+ end
100
+
101
+ version = result&.fetch('version', SCHEMA_VERSION) || SCHEMA_VERSION
102
+ unless version.is_a?(Integer)
103
+ raise ConfigError, "Invalid config version at #{path}: expected integer, got #{version.inspect}"
104
+ end
105
+
106
+ if version > SCHEMA_VERSION
107
+ raise ConfigError,
108
+ "Config file at #{path} has unsupported version #{version} (expected #{SCHEMA_VERSION}). " \
109
+ 'Please upgrade create-rails-app or delete the config file.'
110
+ end
111
+
112
+ result || {}
113
+ rescue Psych::SyntaxError, Psych::DisallowedClass => e
114
+ raise ConfigError, "Invalid config file at #{path}: #{e.message}"
115
+ end
116
+
117
+ # @param payload [Hash]
118
+ # @return [void]
119
+ def write(payload)
120
+ FileUtils.mkdir_p(File.dirname(path))
121
+ tmp = Tempfile.create(['create-rails-app', '.yml'], File.dirname(path))
122
+ tmp.write(YAML.dump(payload))
123
+ tmp.close
124
+ File.rename(tmp.path, path)
125
+ rescue SystemCallError => e
126
+ begin
127
+ File.unlink(tmp.path) if tmp&.path && File.exist?(tmp.path)
128
+ rescue SystemCallError # :nocov:
129
+ nil
130
+ end
131
+ raise ConfigError, "Failed to write config to #{path}: #{e.message}"
132
+ end
133
+
134
+ # @return [String]
135
+ def default_path
136
+ config_home = ENV.fetch('XDG_CONFIG_HOME', File.join(Dir.home, '.config'))
137
+ File.join(config_home, 'create-rails-app', 'config.yml')
138
+ rescue ArgumentError
139
+ raise ConfigError, 'Cannot determine home directory. Set HOME or XDG_CONFIG_HOME.'
140
+ end
141
+
142
+ # @param hash [Hash{Symbol => Object}]
143
+ # @return [Hash{String => Object}]
144
+ def stringify_keys(hash)
145
+ hash.transform_keys(&:to_s)
146
+ end
147
+ end
148
+ end
149
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'open3'
4
+ require 'timeout'
5
+
6
+ module CreateRailsApp
7
+ module Detection
8
+ # Detects locally installed Rails versions using +gem list+.
9
+ #
10
+ # Groups installed versions by supported series (7.2, 8.0, 8.1)
11
+ # and returns the latest patch version for each installed series.
12
+ class RailsVersions
13
+ # @return [Regexp] pattern to extract version strings from gem list output
14
+ VERSION_PATTERN = /\d+\.\d+\.\d+(?:\.\w+)?/
15
+
16
+ # @param gem_command [String] path or name of the gem executable
17
+ def initialize(gem_command: 'gem')
18
+ @gem_command = gem_command
19
+ end
20
+
21
+ # Detects installed Rails versions grouped by supported series.
22
+ #
23
+ # @return [Hash{String => String}] series => latest patch version
24
+ # e.g. { "8.0" => "8.0.7", "8.1" => "8.1.2" }
25
+ def detect
26
+ versions = installed_versions
27
+ group_by_series(versions)
28
+ end
29
+
30
+ private
31
+
32
+ # @return [Array<Gem::Version>] all installed Rails versions, sorted descending
33
+ def installed_versions
34
+ output, status = Timeout.timeout(10) do
35
+ if defined?(Bundler)
36
+ Bundler.with_unbundled_env do
37
+ Open3.capture2e(@gem_command, 'list', 'rails', '--local', '--exact')
38
+ end
39
+ else
40
+ Open3.capture2e(@gem_command, 'list', 'rails', '--local', '--exact')
41
+ end
42
+ end
43
+ return [] unless status.success?
44
+
45
+ rails_line = output.lines.find { |line| line.match?(/\Arails\s/) }
46
+ return [] unless rails_line
47
+
48
+ versions = rails_line.scan(VERSION_PATTERN).map { |v| Gem::Version.new(v) }
49
+ versions.sort.reverse
50
+ rescue SystemCallError, Timeout::Error
51
+ []
52
+ end
53
+
54
+ # @param versions [Array<Gem::Version>]
55
+ # @return [Hash{String => String}]
56
+ def group_by_series(versions)
57
+ result = {}
58
+ Compatibility::Matrix::SUPPORTED_SERIES.each do |series|
59
+ requirement = Gem::Requirement.new("~> #{series}.0")
60
+ match = versions.find { |v| requirement.satisfied_by?(v) }
61
+ result[series] = match.to_s if match
62
+ end
63
+ result
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CreateRailsApp
4
+ module Detection
5
+ # Holds detected Ruby and RubyGems versions.
6
+ #
7
+ # @!attribute [r] ruby
8
+ # @return [Gem::Version]
9
+ # @!attribute [r] rubygems
10
+ # @return [Gem::Version]
11
+ RuntimeInfo = Struct.new(:ruby, :rubygems)
12
+
13
+ # Detects the current Ruby and RubyGems versions.
14
+ class Runtime
15
+ # @return [RuntimeInfo]
16
+ def detect
17
+ RuntimeInfo.new(
18
+ ruby: Gem::Version.new(RUBY_VERSION),
19
+ rubygems: Gem::Version.new(Gem::VERSION)
20
+ )
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CreateRailsApp
4
+ # Base error for all create-rails-app 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 no supported Rails version can be found or installed.
14
+ class UnsupportedRailsVersionError < Error; end
15
+ end
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CreateRailsApp
4
+ module Options
5
+ # Registry of every +rails new+ option create-rails-app 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
12
+ module Catalog
13
+ # Option definitions keyed by symbolic name.
14
+ #
15
+ # Types:
16
+ # - +:flag+ — opt-in; emits +--flag+ when true, nothing when false
17
+ # - +:enum+ — emits +--flag=value+; +:none+ flag emits +--skip-flag+
18
+ # - +:skip+ — opt-out; emits nothing when true (include), +--skip-X+ when false (exclude)
19
+ #
20
+ # @return [Hash{Symbol => Hash}]
21
+ # Database adapters shared across all Rails versions.
22
+ BASE_DATABASE_VALUES = %w[sqlite3 postgresql mysql trilogy].freeze
23
+
24
+ # Database adapters added in Rails 8.0+.
25
+ MARIADB_DATABASE_VALUES = %w[mariadb-mysql mariadb-trilogy].freeze
26
+
27
+ DEFINITIONS = {
28
+ # Flags (opt-in)
29
+ api: { type: :flag, on: '--api' }.freeze,
30
+ # Enums
31
+ database: { type: :enum, flag: '--database',
32
+ values: (BASE_DATABASE_VALUES + MARIADB_DATABASE_VALUES).freeze }.freeze,
33
+ javascript: { type: :enum, flag: '--javascript', none: '--skip-javascript',
34
+ values: %w[importmap bun webpack esbuild rollup].freeze }.freeze,
35
+ css: { type: :enum, flag: '--css', none: true,
36
+ values: %w[tailwind bootstrap bulma postcss sass].freeze,
37
+ rails_default: 'none' }.freeze,
38
+ asset_pipeline: { type: :enum, flag: '--asset-pipeline', none: '--skip-asset-pipeline',
39
+ values: %w[propshaft sprockets].freeze, rails_default: 'sprockets' }.freeze,
40
+ # Skip (included by default, --skip-X to exclude)
41
+ active_record: { type: :skip, skip_flag: '--skip-active-record' }.freeze,
42
+ action_mailer: { type: :skip, skip_flag: '--skip-action-mailer' }.freeze,
43
+ action_mailbox: { type: :skip, skip_flag: '--skip-action-mailbox' }.freeze,
44
+ action_text: { type: :skip, skip_flag: '--skip-action-text' }.freeze,
45
+ active_job: { type: :skip, skip_flag: '--skip-active-job' }.freeze,
46
+ active_storage: { type: :skip, skip_flag: '--skip-active-storage' }.freeze,
47
+ action_cable: { type: :skip, skip_flag: '--skip-action-cable' }.freeze,
48
+ hotwire: { type: :skip, skip_flag: '--skip-hotwire' }.freeze,
49
+ jbuilder: { type: :skip, skip_flag: '--skip-jbuilder' }.freeze,
50
+ test: { type: :skip, skip_flag: '--skip-test' }.freeze,
51
+ system_test: { type: :skip, skip_flag: '--skip-system-test' }.freeze,
52
+ brakeman: { type: :skip, skip_flag: '--skip-brakeman' }.freeze,
53
+ bundler_audit: { type: :skip, skip_flag: '--skip-bundler-audit' }.freeze,
54
+ rubocop: { type: :skip, skip_flag: '--skip-rubocop' }.freeze,
55
+ ci: { type: :skip, skip_flag: '--skip-ci' }.freeze,
56
+ docker: { type: :skip, skip_flag: '--skip-docker' }.freeze,
57
+ kamal: { type: :skip, skip_flag: '--skip-kamal' }.freeze,
58
+ thruster: { type: :skip, skip_flag: '--skip-thruster' }.freeze,
59
+ solid: { type: :skip, skip_flag: '--skip-solid' }.freeze,
60
+ devcontainer: { type: :flag, on: '--devcontainer' }.freeze,
61
+ bootsnap: { type: :skip, skip_flag: '--skip-bootsnap' }.freeze,
62
+ dev_gems: { type: :skip, skip_flag: '--skip-dev-gems' }.freeze,
63
+ keeps: { type: :skip, skip_flag: '--skip-keeps' }.freeze,
64
+ decrypted_diffs: { type: :skip, skip_flag: '--skip-decrypted-diffs' }.freeze,
65
+ git: { type: :skip, skip_flag: '--skip-git' }.freeze,
66
+ bundle: { type: :skip, skip_flag: '--skip-bundle' }.freeze
67
+ }.freeze
68
+
69
+ # Wizard step order — matches the sequence users see.
70
+ #
71
+ # Phase 1: Core architecture (flags + enums + active_record)
72
+ # Phase 2: Skip options with auto-skip consequences + dependents
73
+ # Phase 3: Independent skips + devcontainer flag
74
+ #
75
+ # @return [Array<Symbol>]
76
+ ORDER = %i[
77
+ api
78
+ active_record
79
+ database
80
+ javascript
81
+ css
82
+ asset_pipeline
83
+ test
84
+ system_test
85
+ hotwire
86
+ jbuilder
87
+ action_mailbox
88
+ action_text
89
+ active_storage
90
+ devcontainer
91
+ action_mailer
92
+ active_job
93
+ action_cable
94
+ brakeman
95
+ bundler_audit
96
+ rubocop
97
+ ci
98
+ docker
99
+ kamal
100
+ thruster
101
+ solid
102
+ bootsnap
103
+ dev_gems
104
+ keeps
105
+ decrypted_diffs
106
+ git
107
+ bundle
108
+ ].freeze
109
+
110
+ # Fetches the definition for a given option key.
111
+ #
112
+ # @param key [Symbol, String]
113
+ # @return [Hash] the option definition
114
+ # @raise [KeyError] if the key is unknown
115
+ def self.fetch(key)
116
+ DEFINITIONS.fetch(key.to_sym)
117
+ end
118
+ end
119
+ end
120
+ end
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CreateRailsApp
4
+ module Options
5
+ # Validates user-selected options against the compatibility entry
6
+ # for the detected Rails version.
7
+ #
8
+ # Checks that: the app name is valid, every option key is known,
9
+ # every option is supported by this Rails version, and every
10
+ # value is valid for the option's type and allowed values.
11
+ class Validator
12
+ # @return [Regexp] pattern for valid Rails app names
13
+ APP_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 app name and all options.
21
+ #
22
+ # @param app_name [String]
23
+ # @param options [Hash{Symbol => Object}]
24
+ # @return [true]
25
+ # @raise [ValidationError] if any validation fails
26
+ def validate!(app_name:, options:) # rubocop:disable Naming/PredicateMethod
27
+ validate_app_name!(app_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 app_name [String]
42
+ # @raise [ValidationError]
43
+ # @return [void]
44
+ def validate_app_name!(app_name)
45
+ return if app_name.is_a?(String) && app_name.match?(APP_NAME_PATTERN)
46
+
47
+ raise ValidationError, "Invalid app name: #{app_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 Rails 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 :skip, :flag
76
+ return if value.nil? || value == true || value == false
77
+ when :enum
78
+ return if value.nil? || definition[:values].include?(value)
79
+ return if definition.key?(:none) && [true, false].include?(value)
80
+ end
81
+
82
+ raise ValidationError, "Invalid value for #{key}: #{value.inspect}"
83
+ end
84
+
85
+ # @param key [Symbol]
86
+ # @param value [Object]
87
+ # @raise [ValidationError]
88
+ # @return [void]
89
+ def validate_supported_value!(key, value)
90
+ return if value.nil? || value == true || value == false
91
+
92
+ supported_values = @compatibility_entry.allowed_values(key)
93
+ return if supported_values&.include?(value)
94
+
95
+ raise ValidationError, "Value #{value.inspect} for #{key} is not supported by this Rails version"
96
+ end
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'cli/kit'
4
+ require 'shellwords'
5
+
6
+ module CreateRailsApp
7
+ # Executes commands via the shell.
8
+ #
9
+ # Supports +--dry-run+ mode, which prints commands instead of running them.
10
+ # Can run multiple commands in sequence (e.g. gem install + rails new).
11
+ #
12
+ # @example
13
+ # Runner.new.run!(['rails', '_8.1.2_', 'new', 'myapp', '--api'])
14
+ class Runner
15
+ # @param out [IO] output stream for dry-run printing
16
+ # @param system_runner [#call, nil] callable that executes a shell command
17
+ def initialize(out: $stdout, system_runner: nil)
18
+ @out = out
19
+ @system_runner = system_runner || ->(*command) { ::CLI::Kit::System.system(*command) }
20
+ end
21
+
22
+ # Executes the command or prints it in dry-run mode.
23
+ #
24
+ # @param command [Array<String>] the command to execute
25
+ # @param dry_run [Boolean] when true, prints instead of executing
26
+ # @return [true] on success
27
+ # @raise [Error] if the command exits with a non-zero status
28
+ def run!(command, dry_run: false)
29
+ if dry_run
30
+ @out.puts(command.shelljoin)
31
+ return true
32
+ end
33
+
34
+ status = if defined?(Bundler)
35
+ Bundler.with_unbundled_env { @system_runner.call(*command) }
36
+ else
37
+ @system_runner.call(*command)
38
+ end
39
+ return true if status.respond_to?(:success?) && status.success?
40
+
41
+ code = status.respond_to?(:exitstatus) ? status.exitstatus : nil
42
+ message = 'Command failed'
43
+ message += " (exit #{code})" if code
44
+ message += ": #{command.shelljoin}"
45
+ raise Error, message
46
+ end
47
+ end
48
+ end