rails_engine_toolkit 0.6.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.github/workflows/ci.yml +54 -0
- data/.github/workflows/release.yml +22 -0
- data/Gemfile +11 -0
- data/LICENSE.txt +21 -0
- data/README.md +83 -0
- data/Rakefile +7 -0
- data/docs/ARCHITECTURE.md +18 -0
- data/docs/COMPATIBILITY.md +18 -0
- data/docs/CONTRIBUTING.md +26 -0
- data/docs/END_TO_END.md +26 -0
- data/docs/PUBLISHING.md +24 -0
- data/docs/RELEASE.md +32 -0
- data/docs/RELEASE_CHECKLIST.md +49 -0
- data/docs/TESTING.md +21 -0
- data/exe/engine-toolkit +6 -0
- data/lib/rails_engine_toolkit/actions/delete_engine_migration.rb +46 -0
- data/lib/rails_engine_toolkit/actions/init.rb +54 -0
- data/lib/rails_engine_toolkit/actions/install_engine_migrations.rb +83 -0
- data/lib/rails_engine_toolkit/actions/new_engine.rb +127 -0
- data/lib/rails_engine_toolkit/actions/new_engine_migration.rb +30 -0
- data/lib/rails_engine_toolkit/actions/new_engine_model.rb +30 -0
- data/lib/rails_engine_toolkit/actions/remove_engine.rb +78 -0
- data/lib/rails_engine_toolkit/actions/uninstall_engine_migrations.rb +79 -0
- data/lib/rails_engine_toolkit/actions/update_engine_readme.rb +60 -0
- data/lib/rails_engine_toolkit/cli.rb +110 -0
- data/lib/rails_engine_toolkit/config.rb +98 -0
- data/lib/rails_engine_toolkit/errors.rb +7 -0
- data/lib/rails_engine_toolkit/file_editor.rb +40 -0
- data/lib/rails_engine_toolkit/generators/install/install_generator.rb +67 -0
- data/lib/rails_engine_toolkit/project.rb +71 -0
- data/lib/rails_engine_toolkit/railtie.rb +9 -0
- data/lib/rails_engine_toolkit/route_inspector.rb +44 -0
- data/lib/rails_engine_toolkit/routes_rewriter.rb +63 -0
- data/lib/rails_engine_toolkit/templates/engine_readme.erb +37 -0
- data/lib/rails_engine_toolkit/templates/engine_toolkit_yml.erb +35 -0
- data/lib/rails_engine_toolkit/templates/gemspec.erb +25 -0
- data/lib/rails_engine_toolkit/templates/license.erb +3 -0
- data/lib/rails_engine_toolkit/templates.rb +12 -0
- data/lib/rails_engine_toolkit/utils.rb +56 -0
- data/lib/rails_engine_toolkit/version.rb +5 -0
- data/lib/rails_engine_toolkit.rb +33 -0
- data/rails_engine_toolkit.gemspec +31 -0
- data/spec/rails_engine_toolkit/cli_spec.rb +11 -0
- data/spec/rails_engine_toolkit/config_spec.rb +52 -0
- data/spec/rails_engine_toolkit/file_editor_spec.rb +26 -0
- data/spec/rails_engine_toolkit/install_engine_migrations_spec.rb +36 -0
- data/spec/rails_engine_toolkit/install_generator_spec.rb +26 -0
- data/spec/rails_engine_toolkit/new_engine_integration_spec.rb +59 -0
- data/spec/rails_engine_toolkit/new_engine_spec.rb +54 -0
- data/spec/rails_engine_toolkit/project_spec.rb +19 -0
- data/spec/rails_engine_toolkit/remove_engine_integration_spec.rb +40 -0
- data/spec/rails_engine_toolkit/remove_engine_spec.rb +72 -0
- data/spec/rails_engine_toolkit/route_inspector_spec.rb +20 -0
- data/spec/rails_engine_toolkit/routes_rewriter_spec.rb +36 -0
- data/spec/rails_engine_toolkit/uninstall_engine_migrations_spec.rb +35 -0
- data/spec/rails_engine_toolkit/update_engine_readme_spec.rb +32 -0
- data/spec/spec_helper.rb +30 -0
- data/test/fixtures/host_app/Gemfile +5 -0
- data/test/fixtures/host_app/config/application.rb +11 -0
- data/test/fixtures/host_app/config/boot.rb +3 -0
- data/test/fixtures/host_app/config/environment.rb +3 -0
- data/test/fixtures/host_app/config/routes.rb +2 -0
- metadata +140 -0
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsEngineToolkit
|
|
4
|
+
module Actions
|
|
5
|
+
class NewEngine
|
|
6
|
+
def initialize(argv, stdin:, stdout:, root:, stderr: nil)
|
|
7
|
+
@engine_name = argv[0]
|
|
8
|
+
@stdin = stdin
|
|
9
|
+
@stdout = stdout
|
|
10
|
+
@stderr = stderr
|
|
11
|
+
@root = Pathname(root)
|
|
12
|
+
@project = Project.new(@root)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def call
|
|
16
|
+
@project.validate_root!
|
|
17
|
+
validate_engine_name!
|
|
18
|
+
|
|
19
|
+
config = @project.config
|
|
20
|
+
handle_broken_references!
|
|
21
|
+
ensure_engine_does_not_exist!
|
|
22
|
+
|
|
23
|
+
description = prompt_description(config.project_name)
|
|
24
|
+
engine_class = Utils.classify(@engine_name)
|
|
25
|
+
|
|
26
|
+
generate_engine!(config)
|
|
27
|
+
rewrite_engine_files!(config, description, engine_class)
|
|
28
|
+
create_ddd_structure!(config)
|
|
29
|
+
mount_engine_route(engine_class) if config.mount_routes?
|
|
30
|
+
Utils.safe_system('bundle', 'install', chdir: @root)
|
|
31
|
+
|
|
32
|
+
@stdout.puts("Engine created: engines/#{@engine_name}")
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def validate_engine_name!
|
|
38
|
+
raise ValidationError, 'Engine name is required.' if @engine_name.to_s.empty?
|
|
39
|
+
raise ValidationError, 'Engine name must be snake_case.' unless Utils.snake_case?(@engine_name)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def handle_broken_references!
|
|
43
|
+
broken = @project.broken_engine_references
|
|
44
|
+
return if broken.empty?
|
|
45
|
+
|
|
46
|
+
message = "Broken engine references found in Gemfile (#{broken.join(', ')}). Remove them automatically?"
|
|
47
|
+
remove = Utils.ask_yes_no(
|
|
48
|
+
message,
|
|
49
|
+
default: false,
|
|
50
|
+
input: @stdin,
|
|
51
|
+
output: @stdout
|
|
52
|
+
)
|
|
53
|
+
raise ValidationError, 'Please fix the Gemfile before creating a new engine.' unless remove
|
|
54
|
+
|
|
55
|
+
@project.remove_broken_engine_references!(broken)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def ensure_engine_does_not_exist!
|
|
59
|
+
engine_path = @project.engine_path(@engine_name)
|
|
60
|
+
raise ValidationError, "Engine already exists: #{engine_path}" if engine_path.exist?
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def prompt_description(project_name)
|
|
64
|
+
default = "Provides #{@engine_name} domain for #{project_name}"
|
|
65
|
+
Utils.ask('Engine description', default: default, input: @stdin, output: @stdout)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def generate_engine!(config)
|
|
69
|
+
args = ['bundle', 'exec', 'rails', 'plugin', 'new', "engines/#{@engine_name}", '--mountable', '-d',
|
|
70
|
+
config.default_database]
|
|
71
|
+
args << '--api' if config.api_only?
|
|
72
|
+
args.concat(config.skip_flags)
|
|
73
|
+
Utils.safe_system(*args, chdir: @root)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def rewrite_engine_files!(config, description, engine_class)
|
|
77
|
+
engine_path = @project.engine_path(@engine_name)
|
|
78
|
+
engine_path.join('Gemfile').write("source \"https://rubygems.org\"\n\ngemspec\n")
|
|
79
|
+
engine_path.join("#{@engine_name}.gemspec").write(
|
|
80
|
+
Templates.render(
|
|
81
|
+
'gemspec',
|
|
82
|
+
engine_name: @engine_name,
|
|
83
|
+
engine_class: engine_class,
|
|
84
|
+
author_name: config.author_name,
|
|
85
|
+
author_email: config.author_email,
|
|
86
|
+
summary: "#{engine_class} domain engine",
|
|
87
|
+
description: description,
|
|
88
|
+
project_url: config.project_url,
|
|
89
|
+
ruby_version: config.ruby_version,
|
|
90
|
+
rails_version: config.rails_version,
|
|
91
|
+
license: config.license
|
|
92
|
+
)
|
|
93
|
+
)
|
|
94
|
+
engine_path.join('MIT-LICENSE').write(
|
|
95
|
+
Templates.render(
|
|
96
|
+
'license',
|
|
97
|
+
year: Time.now.year,
|
|
98
|
+
author_name: config.author_name,
|
|
99
|
+
license: config.license
|
|
100
|
+
)
|
|
101
|
+
)
|
|
102
|
+
engine_path.join('README.md').write(
|
|
103
|
+
Templates.render(
|
|
104
|
+
'engine_readme',
|
|
105
|
+
engine_name: @engine_name,
|
|
106
|
+
engine_class: engine_class,
|
|
107
|
+
description: description
|
|
108
|
+
)
|
|
109
|
+
)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def create_ddd_structure!(config)
|
|
113
|
+
return unless config.create_ddd_structure?
|
|
114
|
+
|
|
115
|
+
engine_path = @project.engine_path(@engine_name)
|
|
116
|
+
config.ddd_folders.each { |folder| FileUtils.mkdir_p(engine_path.join(folder)) }
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def mount_engine_route(engine_class)
|
|
120
|
+
return unless @project.routes_file.file?
|
|
121
|
+
|
|
122
|
+
rewriter = RoutesRewriter.new(@project.routes_file)
|
|
123
|
+
rewriter.add_mount(engine_class: engine_class, mount_path: "/#{@engine_name}")
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsEngineToolkit
|
|
4
|
+
module Actions
|
|
5
|
+
class NewEngineMigration
|
|
6
|
+
def initialize(argv, stdout:, root:, stdin: nil, stderr: nil)
|
|
7
|
+
@engine_name = argv.shift
|
|
8
|
+
@generator_args = argv
|
|
9
|
+
@stdout = stdout
|
|
10
|
+
@stdin = stdin
|
|
11
|
+
@stderr = stderr
|
|
12
|
+
@root = Pathname(root)
|
|
13
|
+
@project = Project.new(@root)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def call
|
|
17
|
+
@project.validate_root!
|
|
18
|
+
raise ValidationError, 'Engine name is required.' if @engine_name.to_s.empty?
|
|
19
|
+
raise ValidationError, 'Migration name is required.' if @generator_args.empty?
|
|
20
|
+
|
|
21
|
+
engine_path = @project.engine_path(@engine_name)
|
|
22
|
+
raise ValidationError, "Engine does not exist: #{engine_path}" unless engine_path.directory?
|
|
23
|
+
|
|
24
|
+
Utils.safe_system('bin/rails', 'g', 'migration', *@generator_args, chdir: engine_path)
|
|
25
|
+
UpdateEngineReadme.new([@engine_name], stdin: nil, stdout: @stdout, stderr: nil, root: @root).call
|
|
26
|
+
@stdout.puts("Migration created inside #{engine_path}")
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsEngineToolkit
|
|
4
|
+
module Actions
|
|
5
|
+
class NewEngineModel
|
|
6
|
+
def initialize(argv, stdout:, root:, stdin: nil, stderr: nil)
|
|
7
|
+
@engine_name = argv.shift
|
|
8
|
+
@generator_args = argv
|
|
9
|
+
@stdout = stdout
|
|
10
|
+
@stdin = stdin
|
|
11
|
+
@stderr = stderr
|
|
12
|
+
@root = Pathname(root)
|
|
13
|
+
@project = Project.new(@root)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def call
|
|
17
|
+
@project.validate_root!
|
|
18
|
+
raise ValidationError, 'Engine name is required.' if @engine_name.to_s.empty?
|
|
19
|
+
raise ValidationError, 'Model name is required.' if @generator_args.empty?
|
|
20
|
+
|
|
21
|
+
engine_path = @project.engine_path(@engine_name)
|
|
22
|
+
raise ValidationError, "Engine does not exist: #{engine_path}" unless engine_path.directory?
|
|
23
|
+
|
|
24
|
+
Utils.safe_system('bin/rails', 'g', 'model', *@generator_args, chdir: engine_path)
|
|
25
|
+
UpdateEngineReadme.new([@engine_name], stdin: nil, stdout: @stdout, stderr: nil, root: @root).call
|
|
26
|
+
@stdout.puts("Model created inside #{engine_path}")
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsEngineToolkit
|
|
4
|
+
module Actions
|
|
5
|
+
class RemoveEngine
|
|
6
|
+
def initialize(argv, stdin:, stdout:, root:, stderr: nil)
|
|
7
|
+
@engine_name = argv[0]
|
|
8
|
+
@stdin = stdin
|
|
9
|
+
@stdout = stdout
|
|
10
|
+
@stderr = stderr
|
|
11
|
+
@root = Pathname(root)
|
|
12
|
+
@project = Project.new(@root)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def call
|
|
16
|
+
@project.validate_root!
|
|
17
|
+
validate_engine!
|
|
18
|
+
|
|
19
|
+
warn_if_root_migrations_exist!
|
|
20
|
+
confirm_deletion!
|
|
21
|
+
|
|
22
|
+
remove_mounts
|
|
23
|
+
remove_gemfile_entry
|
|
24
|
+
FileUtils.rm_rf(@project.engine_path(@engine_name))
|
|
25
|
+
Utils.safe_system('bundle', 'install', chdir: @root)
|
|
26
|
+
@stdout.puts("Removed engine: #{@engine_name}")
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def validate_engine!
|
|
32
|
+
raise ValidationError, 'Engine name is required.' if @engine_name.to_s.empty?
|
|
33
|
+
raise ValidationError, 'Engine name must be snake_case.' unless Utils.snake_case?(@engine_name)
|
|
34
|
+
|
|
35
|
+
engine_path = @project.engine_path(@engine_name)
|
|
36
|
+
raise ValidationError, "Engine does not exist: #{engine_path}" unless engine_path.directory?
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def warn_if_root_migrations_exist!
|
|
40
|
+
installed = @project.matching_root_migrations_for_engine(@engine_name)
|
|
41
|
+
return if installed.empty?
|
|
42
|
+
|
|
43
|
+
proceed = Utils.ask_yes_no(
|
|
44
|
+
'Root migrations may belong to this engine. Continue anyway?',
|
|
45
|
+
default: false,
|
|
46
|
+
input: @stdin,
|
|
47
|
+
output: @stdout
|
|
48
|
+
)
|
|
49
|
+
raise ValidationError, 'Operation aborted.' unless proceed
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def confirm_deletion!
|
|
53
|
+
confirmed = Utils.ask(
|
|
54
|
+
"Type '#{@engine_name}' to confirm deletion",
|
|
55
|
+
input: @stdin,
|
|
56
|
+
output: @stdout
|
|
57
|
+
)
|
|
58
|
+
raise ValidationError, 'Operation aborted.' unless confirmed == @engine_name
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def remove_mounts
|
|
62
|
+
return unless @project.routes_file.file?
|
|
63
|
+
|
|
64
|
+
RoutesRewriter.new(@project.routes_file).remove_mount(
|
|
65
|
+
engine_class: Utils.classify(@engine_name),
|
|
66
|
+
mount_path: "/#{@engine_name}"
|
|
67
|
+
)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def remove_gemfile_entry
|
|
71
|
+
FileEditor.remove_lines_matching(
|
|
72
|
+
@project.gemfile,
|
|
73
|
+
%r{path:\s*["']engines/#{Regexp.escape(@engine_name)}["']}
|
|
74
|
+
)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsEngineToolkit
|
|
4
|
+
module Actions
|
|
5
|
+
class UninstallEngineMigrations
|
|
6
|
+
def initialize(argv, stdin:, stdout:, root:, stderr: nil)
|
|
7
|
+
@engine_name = argv[0]
|
|
8
|
+
@stdin = stdin
|
|
9
|
+
@stdout = stdout
|
|
10
|
+
@stderr = stderr
|
|
11
|
+
@root = Pathname(root)
|
|
12
|
+
@project = Project.new(@root)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def call
|
|
16
|
+
@project.validate_root!
|
|
17
|
+
validate_engine!
|
|
18
|
+
|
|
19
|
+
root_matches = matching_root_migrations
|
|
20
|
+
if root_matches.empty?
|
|
21
|
+
@stdout.puts("No installed root migrations found for engine '#{@engine_name}'.")
|
|
22
|
+
return
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
print_matches(root_matches)
|
|
26
|
+
confirm_uninstall!
|
|
27
|
+
root_matches.each(&:delete)
|
|
28
|
+
|
|
29
|
+
@stdout.puts("Removed #{root_matches.size} root migration file(s) for engine '#{@engine_name}'.")
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def validate_engine!
|
|
35
|
+
raise ValidationError, 'Engine name is required.' if @engine_name.to_s.empty?
|
|
36
|
+
raise ValidationError, 'Engine name must be snake_case.' unless Utils.snake_case?(@engine_name)
|
|
37
|
+
|
|
38
|
+
engine_path = @project.engine_path(@engine_name)
|
|
39
|
+
migrations_dir = engine_path.join('db/migrate')
|
|
40
|
+
raise ValidationError, "Engine does not exist: #{engine_path}" unless engine_path.directory?
|
|
41
|
+
|
|
42
|
+
return if migrations_dir.directory?
|
|
43
|
+
|
|
44
|
+
raise ValidationError,
|
|
45
|
+
"Engine migrations directory not found: #{migrations_dir}"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def matching_root_migrations
|
|
49
|
+
return [] unless @project.root_migrations_dir.directory?
|
|
50
|
+
|
|
51
|
+
engine_basenames = @project.engine_path(@engine_name).join('db/migrate').glob('*.rb').map do |file|
|
|
52
|
+
file.basename.to_s.sub(/^\d+_/, '')
|
|
53
|
+
end.sort
|
|
54
|
+
|
|
55
|
+
@project.root_migrations_dir.glob('*.rb').select do |file|
|
|
56
|
+
engine_basenames.include?(file.basename.to_s.sub(/^\d+_/, ''))
|
|
57
|
+
end.sort
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def print_matches(root_matches)
|
|
61
|
+
@stdout.puts("The following root migrations match engine '#{@engine_name}':")
|
|
62
|
+
root_matches.each { |file| @stdout.puts(" #{file.relative_path_from(@root)}") }
|
|
63
|
+
@stdout.puts('')
|
|
64
|
+
@stdout.puts('This only removes copied migration files from db/migrate.')
|
|
65
|
+
@stdout.puts('It does NOT rollback the database automatically.')
|
|
66
|
+
@stdout.puts('Run your rollback or down migrations manually if needed.')
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def confirm_uninstall!
|
|
70
|
+
confirmed = Utils.ask(
|
|
71
|
+
"Type '#{@engine_name}' to confirm uninstall",
|
|
72
|
+
input: @stdin,
|
|
73
|
+
output: @stdout
|
|
74
|
+
)
|
|
75
|
+
raise ValidationError, 'Operation aborted.' unless confirmed == @engine_name
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsEngineToolkit
|
|
4
|
+
module Actions
|
|
5
|
+
class UpdateEngineReadme
|
|
6
|
+
def initialize(argv, stdout:, root:, stdin: nil, stderr: nil)
|
|
7
|
+
@engine_name = argv[0]
|
|
8
|
+
@stdout = stdout
|
|
9
|
+
@stdin = stdin
|
|
10
|
+
@stderr = stderr
|
|
11
|
+
@root = Pathname(root)
|
|
12
|
+
@project = Project.new(@root)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def call
|
|
16
|
+
@project.validate_root!
|
|
17
|
+
validate_args!
|
|
18
|
+
|
|
19
|
+
engine_path = @project.engine_path(@engine_name)
|
|
20
|
+
readme = engine_path.join('README.md')
|
|
21
|
+
migrations_dir = engine_path.join('db/migrate')
|
|
22
|
+
|
|
23
|
+
raise ValidationError, "README not found: #{readme}" unless readme.file?
|
|
24
|
+
|
|
25
|
+
owned_tables_block = tables_from(migrations_dir).then do |tables|
|
|
26
|
+
tables.empty? ? '- None defined yet.' : tables.map { |table| "- #{table}" }.join("\n")
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
content = readme.read
|
|
30
|
+
unless content.match?(/^## Owned tables$/)
|
|
31
|
+
raise ValidationError,
|
|
32
|
+
"Could not find '## Owned tables' section in #{readme}"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
updated = content.sub(/^## Owned tables$.*?(?=^## |\z)/m, "## Owned tables\n#{owned_tables_block}\n\n")
|
|
36
|
+
readme.write(updated)
|
|
37
|
+
|
|
38
|
+
@stdout.puts("Updated README: #{readme}")
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def validate_args!
|
|
44
|
+
raise ValidationError, 'Engine name is required.' if @engine_name.to_s.empty?
|
|
45
|
+
raise ValidationError, 'Engine name must be snake_case.' unless Utils.snake_case?(@engine_name)
|
|
46
|
+
|
|
47
|
+
engine_path = @project.engine_path(@engine_name)
|
|
48
|
+
raise ValidationError, "Engine does not exist: #{engine_path}" unless engine_path.directory?
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def tables_from(migrations_dir)
|
|
52
|
+
return [] unless migrations_dir.directory?
|
|
53
|
+
|
|
54
|
+
migrations_dir.glob('*.rb').flat_map do |file|
|
|
55
|
+
file.read.scan(/create_table\s+:?"?([a-zA-Z0-9_]+)"?/).flatten
|
|
56
|
+
end.uniq.sort
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsEngineToolkit
|
|
4
|
+
class CLI < Thor
|
|
5
|
+
class_option :root, type: :string
|
|
6
|
+
|
|
7
|
+
KNOWN_COMMANDS = %w[
|
|
8
|
+
help
|
|
9
|
+
init
|
|
10
|
+
new_engine
|
|
11
|
+
update_engine_readme
|
|
12
|
+
new_engine_migration
|
|
13
|
+
delete_engine_migration
|
|
14
|
+
new_engine_model
|
|
15
|
+
install_engine_migrations
|
|
16
|
+
uninstall_engine_migrations
|
|
17
|
+
remove_engine
|
|
18
|
+
].freeze
|
|
19
|
+
|
|
20
|
+
def self.exit_on_failure?
|
|
21
|
+
true
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
desc 'init', 'Create config/engine_toolkit.yml'
|
|
25
|
+
def init
|
|
26
|
+
run_action(Actions::Init, [])
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
desc 'new_engine ENGINE_NAME', 'Create a new internal Rails engine'
|
|
30
|
+
def new_engine(engine_name)
|
|
31
|
+
run_action(Actions::NewEngine, [engine_name])
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
desc 'update_engine_readme ENGINE_NAME', 'Refresh the README owned tables section'
|
|
35
|
+
def update_engine_readme(engine_name)
|
|
36
|
+
run_action(Actions::UpdateEngineReadme, [engine_name])
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
desc 'new_engine_migration ENGINE_NAME MIGRATION_NAME [ARGS...]', 'Create a migration inside an engine'
|
|
40
|
+
def new_engine_migration(engine_name, migration_name, *args)
|
|
41
|
+
run_action(Actions::NewEngineMigration, [engine_name, migration_name, *args])
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
desc 'delete_engine_migration ENGINE_NAME PATTERN', 'Delete matching engine migration files'
|
|
45
|
+
def delete_engine_migration(engine_name, pattern)
|
|
46
|
+
run_action(Actions::DeleteEngineMigration, [engine_name, pattern])
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
desc 'new_engine_model ENGINE_NAME MODEL_NAME [ATTRS...]', 'Create a model inside an engine'
|
|
50
|
+
def new_engine_model(engine_name, model_name, *attrs)
|
|
51
|
+
run_action(Actions::NewEngineModel, [engine_name, model_name, *attrs])
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
desc 'install_engine_migrations ENGINE_NAME', "Copy only one engine's migrations into the host app"
|
|
55
|
+
def install_engine_migrations(engine_name)
|
|
56
|
+
run_action(Actions::InstallEngineMigrations, [engine_name])
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
desc 'uninstall_engine_migrations ENGINE_NAME', 'Remove root migrations that were installed from one engine'
|
|
60
|
+
def uninstall_engine_migrations(engine_name)
|
|
61
|
+
run_action(Actions::UninstallEngineMigrations, [engine_name])
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
desc 'remove_engine ENGINE_NAME', 'Remove an internal engine'
|
|
65
|
+
def remove_engine(engine_name)
|
|
66
|
+
run_action(Actions::RemoveEngine, [engine_name])
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def self.start(argv)
|
|
70
|
+
first = argv.first
|
|
71
|
+
if first && !first.start_with?('-') && !KNOWN_COMMANDS.include?(first)
|
|
72
|
+
puts(command_help_text)
|
|
73
|
+
return 1
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
super
|
|
77
|
+
0
|
|
78
|
+
rescue Error => e
|
|
79
|
+
warn("Error: #{e.message}")
|
|
80
|
+
1
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def self.command_help_text
|
|
84
|
+
<<~TEXT
|
|
85
|
+
Commands:
|
|
86
|
+
init
|
|
87
|
+
new_engine ENGINE_NAME
|
|
88
|
+
update_engine_readme ENGINE_NAME
|
|
89
|
+
new_engine_migration ENGINE_NAME MIGRATION_NAME [ARGS...]
|
|
90
|
+
delete_engine_migration ENGINE_NAME PATTERN
|
|
91
|
+
new_engine_model ENGINE_NAME MODEL_NAME [ATTRS...]
|
|
92
|
+
install_engine_migrations ENGINE_NAME
|
|
93
|
+
uninstall_engine_migrations ENGINE_NAME
|
|
94
|
+
remove_engine ENGINE_NAME
|
|
95
|
+
TEXT
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
private
|
|
99
|
+
|
|
100
|
+
def run_action(klass, argv)
|
|
101
|
+
klass.new(
|
|
102
|
+
argv,
|
|
103
|
+
stdin: $stdin,
|
|
104
|
+
stdout: $stdout,
|
|
105
|
+
stderr: $stderr,
|
|
106
|
+
root: options[:root] || Pathname.pwd
|
|
107
|
+
).call
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsEngineToolkit
|
|
4
|
+
class Config
|
|
5
|
+
DEFAULT_PATH = 'config/engine_toolkit.yml'
|
|
6
|
+
|
|
7
|
+
REQUIRED_TOP_LEVEL_KEYS = %w[project author defaults metadata].freeze
|
|
8
|
+
VALID_DATABASES = %w[postgresql mysql2 sqlite3].freeze
|
|
9
|
+
DEFAULT_DDD_FOLDERS = [
|
|
10
|
+
'app/use_cases',
|
|
11
|
+
'app/services',
|
|
12
|
+
'app/policies',
|
|
13
|
+
'app/serializers',
|
|
14
|
+
'app/repositories',
|
|
15
|
+
'app/contracts'
|
|
16
|
+
].freeze
|
|
17
|
+
SKIP_FLAG_MAPPING = {
|
|
18
|
+
'skip_asset_pipeline' => '--skip-asset-pipeline',
|
|
19
|
+
'skip_action_mailbox' => '--skip-action-mailbox',
|
|
20
|
+
'skip_action_text' => '--skip-action-text',
|
|
21
|
+
'skip_active_storage' => '--skip-active-storage',
|
|
22
|
+
'skip_hotwire' => '--skip-hotwire',
|
|
23
|
+
'skip_jbuilder' => '--skip-jbuilder',
|
|
24
|
+
'skip_system_test' => '--skip-system-test'
|
|
25
|
+
}.freeze
|
|
26
|
+
|
|
27
|
+
attr_reader :data
|
|
28
|
+
|
|
29
|
+
def self.load(project_root, path: DEFAULT_PATH)
|
|
30
|
+
full_path = Pathname(project_root).join(path)
|
|
31
|
+
raise ValidationError, "Configuration file not found: #{full_path}" unless full_path.exist?
|
|
32
|
+
|
|
33
|
+
new(YAML.safe_load(full_path.read, aliases: true) || {}, path: full_path).tap(&:validate!)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def initialize(data, path:)
|
|
37
|
+
@data = data
|
|
38
|
+
@path = path
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def validate!
|
|
42
|
+
REQUIRED_TOP_LEVEL_KEYS.each do |key|
|
|
43
|
+
raise ValidationError, "Missing config section: #{key} in #{@path}" unless data.key?(key)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
validate_string('project', 'name')
|
|
47
|
+
validate_string('project', 'slug')
|
|
48
|
+
validate_string('author', 'name')
|
|
49
|
+
validate_string('author', 'email')
|
|
50
|
+
|
|
51
|
+
raise ValidationError, 'project.slug must be snake_case' unless Utils.snake_case?(project_slug)
|
|
52
|
+
return true if VALID_DATABASES.include?(default_database)
|
|
53
|
+
|
|
54
|
+
raise ValidationError, "defaults.database must be one of: #{VALID_DATABASES.join(', ')}"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def project_name = fetch('project', 'name')
|
|
58
|
+
def project_slug = fetch('project', 'slug')
|
|
59
|
+
def project_url = fetch('project', 'url', default: '')
|
|
60
|
+
def author_name = fetch('author', 'name')
|
|
61
|
+
def author_email = fetch('author', 'email')
|
|
62
|
+
def license = fetch('metadata', 'license', default: 'MIT')
|
|
63
|
+
def ruby_version = fetch('metadata', 'ruby_version', default: '>= 3.2')
|
|
64
|
+
def rails_version = fetch('metadata', 'rails_version', default: '>= 8.1.2')
|
|
65
|
+
def default_database = fetch('defaults', 'database', default: 'postgresql')
|
|
66
|
+
def api_only? = !!fetch('defaults', 'api_only', default: true)
|
|
67
|
+
def mount_routes? = !!fetch('defaults', 'mount_routes', default: true)
|
|
68
|
+
def create_ddd_structure? = !!fetch('defaults', 'create_ddd_structure', default: true)
|
|
69
|
+
|
|
70
|
+
def skip_flags
|
|
71
|
+
defaults = data.fetch('defaults', {})
|
|
72
|
+
SKIP_FLAG_MAPPING.filter_map do |key, flag|
|
|
73
|
+
flag if defaults.fetch(key, true)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def ddd_folders
|
|
78
|
+
fetch('ddd', 'folders', default: DEFAULT_DDD_FOLDERS)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
private
|
|
82
|
+
|
|
83
|
+
def validate_string(*keys)
|
|
84
|
+
value = fetch(*keys)
|
|
85
|
+
raise ValidationError, "Missing config value: #{keys.join('.')}" if value.to_s.strip.empty?
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def fetch(*keys, default: nil)
|
|
89
|
+
cursor = data
|
|
90
|
+
keys.each do |key|
|
|
91
|
+
return default unless cursor.is_a?(Hash) && cursor.key?(key)
|
|
92
|
+
|
|
93
|
+
cursor = cursor[key]
|
|
94
|
+
end
|
|
95
|
+
cursor.nil? ? default : cursor
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsEngineToolkit
|
|
4
|
+
class FileEditor
|
|
5
|
+
def self.remove_lines_matching(path, regex)
|
|
6
|
+
return false unless path.exist?
|
|
7
|
+
|
|
8
|
+
original = path.read
|
|
9
|
+
updated = original.lines.grep_v(regex).join
|
|
10
|
+
changed = original != updated
|
|
11
|
+
path.write(updated) if changed
|
|
12
|
+
changed
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def self.insert_after_first_match(path, match_regex, text)
|
|
16
|
+
content = path.read
|
|
17
|
+
updated = content.sub(match_regex) { |m| "#{m}#{text}" }
|
|
18
|
+
changed = updated != content
|
|
19
|
+
path.write(updated) if changed
|
|
20
|
+
changed
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def self.ensure_line_after_draw_block(path, line)
|
|
24
|
+
return false unless path.exist?
|
|
25
|
+
|
|
26
|
+
content = path.read
|
|
27
|
+
return false if content.include?(line.strip)
|
|
28
|
+
|
|
29
|
+
updated = content.sub(/Rails\.application\.routes\.draw do\s*\n/, "Rails.application.routes.draw do\n#{line}")
|
|
30
|
+
changed = updated != content
|
|
31
|
+
path.write(updated) if changed
|
|
32
|
+
changed
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def self.copy_file(path_from, path_to)
|
|
36
|
+
FileUtils.mkdir_p(path_to.dirname)
|
|
37
|
+
FileUtils.cp(path_from, path_to)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|