opinionated_rails 0.1.4
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/CHANGELOG.md +24 -0
- data/LICENSE.txt +21 -0
- data/README.md +59 -0
- data/exe/opinionated_rails +7 -0
- data/lib/opinionated_rails/answer_shell.rb +131 -0
- data/lib/opinionated_rails/cli.rb +291 -0
- data/lib/opinionated_rails/context.rb +39 -0
- data/lib/opinionated_rails/error.rb +6 -0
- data/lib/opinionated_rails/generators/file_writer.rb +14 -0
- data/lib/opinionated_rails/generators/rails_new_invoker.rb +26 -0
- data/lib/opinionated_rails/kamal_config.rb +94 -0
- data/lib/opinionated_rails/manifest.rb +42 -0
- data/lib/opinionated_rails/pins.rb +83 -0
- data/lib/opinionated_rails/prerequisites.rb +39 -0
- data/lib/opinionated_rails/rails_version_guard.rb +49 -0
- data/lib/opinionated_rails/step.rb +41 -0
- data/lib/opinionated_rails/step_graph.rb +53 -0
- data/lib/opinionated_rails/steps/addons/auth_step.rb +170 -0
- data/lib/opinionated_rails/steps/addons/ci_step.rb +226 -0
- data/lib/opinionated_rails/steps/addons/docker_kamal_step.rb +133 -0
- data/lib/opinionated_rails/steps/addons/docs_step.rb +35 -0
- data/lib/opinionated_rails/steps/addons/factory_or_fixtures_step.rb +128 -0
- data/lib/opinionated_rails/steps/addons/faker_step.rb +55 -0
- data/lib/opinionated_rails/steps/addons/js_lint_step.rb +132 -0
- data/lib/opinionated_rails/steps/addons/mailer_step.rb +183 -0
- data/lib/opinionated_rails/steps/addons/pagination_step.rb +116 -0
- data/lib/opinionated_rails/steps/addons/pr_issue_templates_step.rb +43 -0
- data/lib/opinionated_rails/steps/addons/rubocop_step.rb +111 -0
- data/lib/opinionated_rails/steps/addons/secrets_handling_step.rb +138 -0
- data/lib/opinionated_rails/steps/addons/services_step.rb +64 -0
- data/lib/opinionated_rails/steps/addons/solid_stack_step.rb +252 -0
- data/lib/opinionated_rails/steps/addons/spa_controller_step.rb +87 -0
- data/lib/opinionated_rails/steps/addons/staging_env_step.rb +108 -0
- data/lib/opinionated_rails/steps/addons/tailwind_step.rb +148 -0
- data/lib/opinionated_rails/steps/addons/vcr_step.rb +170 -0
- data/lib/opinionated_rails/steps/core/app_name_step.rb +85 -0
- data/lib/opinionated_rails/steps/core/app_type_step.rb +51 -0
- data/lib/opinionated_rails/steps/core/database_step.rb +53 -0
- data/lib/opinionated_rails/steps/core/frontend_step.rb +282 -0
- data/lib/opinionated_rails/steps/core/testing_step.rb +82 -0
- data/lib/opinionated_rails/templates/docs/STYLE.md +39 -0
- data/lib/opinionated_rails/templates/github/ISSUE_TEMPLATE/bug_report.md +25 -0
- data/lib/opinionated_rails/templates/github/ISSUE_TEMPLATE/feature_request.md +17 -0
- data/lib/opinionated_rails/templates/github/PULL_REQUEST_TEMPLATE.md +9 -0
- data/lib/opinionated_rails/templates/rubocop/custom_overrides.yml +5 -0
- data/lib/opinionated_rails/templates/services/application_service.rb +50 -0
- data/lib/opinionated_rails/templates/services/result.rb +44 -0
- data/lib/opinionated_rails/version.rb +5 -0
- data/lib/opinionated_rails.rb +41 -0
- metadata +141 -0
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OpinionatedRails
|
|
4
|
+
module Steps
|
|
5
|
+
module Core
|
|
6
|
+
class FrontendStep < Step
|
|
7
|
+
def self.id = :frontend
|
|
8
|
+
def self.summary = "Choose the frontend stack"
|
|
9
|
+
def self.description = "Prompts for Stimulus+Hotwire, React, or no frontend for API-only apps."
|
|
10
|
+
def self.tier = :core
|
|
11
|
+
def self.prompts = "Frontend: Stimulus+Hotwire (1) or React (2), default Stimulus; React also asks for a bundler"
|
|
12
|
+
|
|
13
|
+
BABEL_CONFIG = <<~JSON
|
|
14
|
+
{
|
|
15
|
+
"presets": [
|
|
16
|
+
["@babel/preset-env", { "modules": false }],
|
|
17
|
+
["@babel/preset-react", { "runtime": "automatic" }]
|
|
18
|
+
]
|
|
19
|
+
}
|
|
20
|
+
JSON
|
|
21
|
+
|
|
22
|
+
WEBPACK_CONFIG = <<~JS
|
|
23
|
+
const path = require("path")
|
|
24
|
+
const webpack = require("webpack")
|
|
25
|
+
|
|
26
|
+
module.exports = {
|
|
27
|
+
mode: "production",
|
|
28
|
+
devtool: "source-map",
|
|
29
|
+
entry: {
|
|
30
|
+
application: "./app/javascript/application.jsx"
|
|
31
|
+
},
|
|
32
|
+
resolve: {
|
|
33
|
+
extensions: [".js", ".jsx"],
|
|
34
|
+
alias: { "@": path.resolve(__dirname, "app/javascript") }
|
|
35
|
+
},
|
|
36
|
+
module: {
|
|
37
|
+
rules: [
|
|
38
|
+
{ test: /\.jsx?$/, exclude: /node_modules/, use: "babel-loader" }
|
|
39
|
+
]
|
|
40
|
+
},
|
|
41
|
+
output: {
|
|
42
|
+
filename: "[name].js",
|
|
43
|
+
sourceMapFilename: "[file].map",
|
|
44
|
+
chunkFormat: "module",
|
|
45
|
+
path: path.resolve(__dirname, "app/assets/builds"),
|
|
46
|
+
},
|
|
47
|
+
plugins: [
|
|
48
|
+
new webpack.optimize.LimitChunkCountPlugin({
|
|
49
|
+
maxChunks: 1
|
|
50
|
+
})
|
|
51
|
+
]
|
|
52
|
+
}
|
|
53
|
+
JS
|
|
54
|
+
|
|
55
|
+
BUNDLERS = %w[esbuild bun webpack].freeze
|
|
56
|
+
|
|
57
|
+
FRONTEND_OPTIONS = %w[stimulus react].freeze
|
|
58
|
+
|
|
59
|
+
def if_condition(context)
|
|
60
|
+
context.output(:app_type, :app_type) != :api_only
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def prompt(context)
|
|
64
|
+
shell = context.shell
|
|
65
|
+
shell.say("Choose a frontend stack:")
|
|
66
|
+
shell.say(" 1. Stimulus + Hotwire (default)")
|
|
67
|
+
shell.say(" 2. React")
|
|
68
|
+
|
|
69
|
+
choice = shell.ask("Frontend (default: Stimulus):").to_s.strip
|
|
70
|
+
choice = "1" if choice.empty?
|
|
71
|
+
|
|
72
|
+
loop do
|
|
73
|
+
case choice.downcase
|
|
74
|
+
when "stimulus", "stimulus+hotwire", "1"
|
|
75
|
+
context.answer(:frontend, :stimulus_hotwire)
|
|
76
|
+
break
|
|
77
|
+
when "react", "2"
|
|
78
|
+
context.answer(:frontend, :react)
|
|
79
|
+
break
|
|
80
|
+
else
|
|
81
|
+
shell.say("Invalid. Enter 'Stimulus' or 'React', 1, or 2.", :red)
|
|
82
|
+
choice = shell.ask("Frontend (default: Stimulus):").to_s.strip
|
|
83
|
+
choice = "1" if choice.empty?
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
return unless context.get(:frontend) == :react
|
|
88
|
+
|
|
89
|
+
prompt_bundler(context)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def generate(context)
|
|
93
|
+
type = if if_condition(context)
|
|
94
|
+
context.get(:frontend) || :none
|
|
95
|
+
else
|
|
96
|
+
:none
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
data = { frontend: type }
|
|
100
|
+
data[:bundler] = context.get(:bundler) if type == :react
|
|
101
|
+
|
|
102
|
+
context.set_output(:frontend, data)
|
|
103
|
+
|
|
104
|
+
return if type == :none
|
|
105
|
+
|
|
106
|
+
install_react(context) if type == :react
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def outputs(context)
|
|
110
|
+
context.output(:frontend)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
private
|
|
114
|
+
|
|
115
|
+
def prompt_bundler(context)
|
|
116
|
+
shell = context.shell
|
|
117
|
+
shell.say("Choose a JavaScript bundler:")
|
|
118
|
+
BUNDLERS.each_with_index do |name, index|
|
|
119
|
+
shell.say(" #{index + 1}. #{name.capitalize}")
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
choice = shell.ask("Bundler (default: esbuild):").to_s.strip
|
|
123
|
+
choice = "esbuild" if choice.empty?
|
|
124
|
+
|
|
125
|
+
loop do
|
|
126
|
+
choice = BUNDLERS[choice.to_i - 1] if %w[1 2 3 4].include?(choice)
|
|
127
|
+
|
|
128
|
+
if BUNDLERS.include?(choice.downcase)
|
|
129
|
+
context.answer(:bundler, choice.downcase.to_sym)
|
|
130
|
+
break
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
shell.say("Invalid. Enter #{BUNDLERS.join(', ')}.", :red)
|
|
134
|
+
choice = shell.ask("Bundler (default: esbuild):").to_s.strip
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def install_react(context)
|
|
139
|
+
bundler = context.output(:frontend, :bundler)
|
|
140
|
+
|
|
141
|
+
update_package_json(context, bundler)
|
|
142
|
+
add_esbuild_alias(context) if bundler == :esbuild
|
|
143
|
+
configure_webpack(context) if bundler == :webpack
|
|
144
|
+
configure_bun(context) if bundler == :bun
|
|
145
|
+
warn_non_esbuild_alias(context, bundler) if bundler == :bun
|
|
146
|
+
write_js_files(context)
|
|
147
|
+
write_jsconfig(context)
|
|
148
|
+
create_images_dir(context)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def update_package_json(context, bundler)
|
|
152
|
+
package_json_path = File.join(context.app_path, "package.json")
|
|
153
|
+
package = if File.exist?(package_json_path)
|
|
154
|
+
JSON.parse(File.read(package_json_path))
|
|
155
|
+
else
|
|
156
|
+
{ "scripts" => {}, "dependencies" => {} }
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
package["dependencies"] ||= {}
|
|
160
|
+
package["dependencies"]["react"] = "18.2.0"
|
|
161
|
+
package["dependencies"]["react-dom"] = "18.2.0"
|
|
162
|
+
package["dependencies"]["react-router-dom"] = "6.23.1"
|
|
163
|
+
|
|
164
|
+
if bundler == :esbuild && package["scripts"]["build"].nil?
|
|
165
|
+
package["scripts"]["build"] = "esbuild app/javascript/*.* --bundle --sourcemap --outdir=app/assets/builds --public-path=assets"
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
File.write(package_json_path, JSON.pretty_generate(package))
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def add_esbuild_alias(context)
|
|
172
|
+
package_json_path = File.join(context.app_path, "package.json")
|
|
173
|
+
package = JSON.parse(File.read(package_json_path))
|
|
174
|
+
package["scripts"] ||= {}
|
|
175
|
+
build = package["scripts"]["build"]
|
|
176
|
+
|
|
177
|
+
if build && !build.include?("--alias:@=app/javascript")
|
|
178
|
+
package["scripts"]["build"] = "#{build} --alias:@=app/javascript"
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
File.write(package_json_path, JSON.pretty_generate(package))
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# Rails' webpack config only knows application.js and has no JSX transform; the React files
|
|
185
|
+
# are .jsx, so the build would fail. Add Babel, the .jsx entry, and the @ alias.
|
|
186
|
+
def configure_webpack(context)
|
|
187
|
+
package_json_path = File.join(context.app_path, "package.json")
|
|
188
|
+
package = JSON.parse(File.read(package_json_path))
|
|
189
|
+
package["devDependencies"] ||= {}
|
|
190
|
+
package["devDependencies"]["@babel/core"] = "8.0.7"
|
|
191
|
+
package["devDependencies"]["@babel/preset-env"] = "8.0.7"
|
|
192
|
+
package["devDependencies"]["@babel/preset-react"] = "8.0.1"
|
|
193
|
+
package["devDependencies"]["babel-loader"] = "10.1.1"
|
|
194
|
+
File.write(package_json_path, JSON.pretty_generate(package))
|
|
195
|
+
|
|
196
|
+
File.write(File.join(context.app_path, "babel.config.json"), BABEL_CONFIG)
|
|
197
|
+
File.write(File.join(context.app_path, "webpack.config.js"), WEBPACK_CONFIG)
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# Rails' bun.config.js bundles app/javascript/application.js; the React files are .jsx, which Bun
|
|
201
|
+
# builds natively once it is pointed at the right entry.
|
|
202
|
+
BUN_ENTRY = 'entrypoints: ["app/javascript/application.js"]'
|
|
203
|
+
BUN_ENTRY_JSX = 'entrypoints: ["app/javascript/application.jsx"]'
|
|
204
|
+
|
|
205
|
+
def configure_bun(context)
|
|
206
|
+
path = File.join(context.app_path, "bun.config.js")
|
|
207
|
+
content = File.exist?(path) ? File.read(path) : ""
|
|
208
|
+
return if content.include?(BUN_ENTRY_JSX)
|
|
209
|
+
|
|
210
|
+
unless content.include?(BUN_ENTRY)
|
|
211
|
+
context.shell.say(
|
|
212
|
+
"Warning: could not find `#{BUN_ENTRY}` in bun.config.js; set the entry point to " \
|
|
213
|
+
"app/javascript/application.jsx by hand.",
|
|
214
|
+
:yellow
|
|
215
|
+
)
|
|
216
|
+
return
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
File.write(path, content.sub(BUN_ENTRY, BUN_ENTRY_JSX))
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def warn_non_esbuild_alias(context, bundler)
|
|
223
|
+
context.shell.say(
|
|
224
|
+
"Warning: the @ import alias is configured for editor/TypeScript tooling only and won't resolve " \
|
|
225
|
+
"at build time until a bundler-specific config is added (current bundler: #{bundler}).",
|
|
226
|
+
:yellow
|
|
227
|
+
)
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def write_js_files(context)
|
|
231
|
+
application_js = File.join(context.app_path, "app/javascript/application.js")
|
|
232
|
+
File.delete(application_js) if File.exist?(application_js)
|
|
233
|
+
|
|
234
|
+
FileUtils.mkdir_p(File.join(context.app_path, "app/javascript"))
|
|
235
|
+
|
|
236
|
+
File.write(
|
|
237
|
+
File.join(context.app_path, "app/javascript/application.jsx"),
|
|
238
|
+
<<~'JS'
|
|
239
|
+
import React from "react";
|
|
240
|
+
import { createRoot } from "react-dom/client";
|
|
241
|
+
import { RouterProvider } from "react-router-dom";
|
|
242
|
+
import router from "./router";
|
|
243
|
+
|
|
244
|
+
const root = createRoot(document.getElementById("root"));
|
|
245
|
+
root.render(<RouterProvider router={router} />);
|
|
246
|
+
JS
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
File.write(
|
|
250
|
+
File.join(context.app_path, "app/javascript/router.jsx"),
|
|
251
|
+
<<~'JS'
|
|
252
|
+
import React from "react";
|
|
253
|
+
import { createBrowserRouter } from "react-router-dom";
|
|
254
|
+
|
|
255
|
+
const router = createBrowserRouter([
|
|
256
|
+
{ path: "/", element: <h1>Welcome</h1> }
|
|
257
|
+
]);
|
|
258
|
+
|
|
259
|
+
export default router;
|
|
260
|
+
JS
|
|
261
|
+
)
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
def write_jsconfig(context)
|
|
265
|
+
jsconfig = {
|
|
266
|
+
"compilerOptions" => {
|
|
267
|
+
"baseUrl" => ".",
|
|
268
|
+
"paths" => {
|
|
269
|
+
"@/*" => [ "app/javascript/*" ]
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
File.write(File.join(context.app_path, "jsconfig.json"), JSON.pretty_generate(jsconfig))
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
def create_images_dir(context)
|
|
277
|
+
FileUtils.mkdir_p(File.join(context.app_path, "app/javascript/images"))
|
|
278
|
+
end
|
|
279
|
+
end
|
|
280
|
+
end
|
|
281
|
+
end
|
|
282
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OpinionatedRails
|
|
4
|
+
module Steps
|
|
5
|
+
module Core
|
|
6
|
+
class TestingStep < Step
|
|
7
|
+
def self.id = :testing
|
|
8
|
+
def self.summary = "Choose the test framework"
|
|
9
|
+
def self.description = "Prompts for RSpec or Minitest as the test framework."
|
|
10
|
+
def self.tier = :core
|
|
11
|
+
def self.prompts = "Test framework: RSpec or Minitest (default: Minitest)"
|
|
12
|
+
|
|
13
|
+
RSPEC_VERSION = "8.0.4".freeze
|
|
14
|
+
|
|
15
|
+
def prompt(context)
|
|
16
|
+
shell = context.shell
|
|
17
|
+
shell.say("Choose a test framework:")
|
|
18
|
+
shell.say(" 1. Minitest")
|
|
19
|
+
shell.say(" 2. RSpec")
|
|
20
|
+
|
|
21
|
+
choice = shell.ask("Test framework (default: Minitest):").to_s.strip
|
|
22
|
+
choice = "minitest" if choice.empty?
|
|
23
|
+
|
|
24
|
+
loop do
|
|
25
|
+
case choice.downcase
|
|
26
|
+
when "minitest", "1"
|
|
27
|
+
context.answer(:testing, :minitest)
|
|
28
|
+
break
|
|
29
|
+
when "rspec", "2"
|
|
30
|
+
context.answer(:testing, :rspec)
|
|
31
|
+
break
|
|
32
|
+
else
|
|
33
|
+
shell.say("Invalid. Enter 'Minitest', 'RSpec', 1, or 2.", :red)
|
|
34
|
+
choice = shell.ask("Test framework (default: Minitest):").to_s.strip
|
|
35
|
+
choice = "minitest" if choice.empty?
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def generate(context)
|
|
41
|
+
framework = context.get(:testing)
|
|
42
|
+
raise ArgumentError, "No test framework answer" unless framework
|
|
43
|
+
|
|
44
|
+
context.set_output(:testing, { test_framework: framework })
|
|
45
|
+
install_rspec(context) if framework == :rspec
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def outputs(context)
|
|
49
|
+
context.output(:testing)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def install_rspec(context)
|
|
55
|
+
gemfile = File.join(context.app_path, "Gemfile")
|
|
56
|
+
unless File.read(gemfile).include?("gem \"rspec-rails\"")
|
|
57
|
+
File.open(gemfile, "a") do |f|
|
|
58
|
+
f.puts("\ngroup :development, :test do\n gem \"rspec-rails\", \"#{RSPEC_VERSION}\"\nend")
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
return if context.skip_bundle_install
|
|
63
|
+
|
|
64
|
+
run_in_app(context, "bundle", "install", "--quiet") ||
|
|
65
|
+
raise(OpinionatedRails::Error, "bundle install failed")
|
|
66
|
+
run_in_app(context, "bundle", "exec", "rails", "generate", "rspec:install") ||
|
|
67
|
+
raise(OpinionatedRails::Error, "rspec:install failed")
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def run_in_app(context, *command)
|
|
71
|
+
Dir.chdir(context.app_path) do
|
|
72
|
+
if defined?(Bundler)
|
|
73
|
+
Bundler.with_unbundled_env { system(*command) }
|
|
74
|
+
else
|
|
75
|
+
system(*command)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# Project Style Guide
|
|
2
|
+
|
|
3
|
+
Conventions for this codebase. Follow the shape of existing code before inventing something new.
|
|
4
|
+
|
|
5
|
+
## General
|
|
6
|
+
|
|
7
|
+
- Ruby style is enforced by RuboCop. Run `bin/rubocop` (add `-a` to autocorrect) before committing. Do not disable cops inline without a comment explaining why.
|
|
8
|
+
- Keep controllers thin: parse params, call one object, render or redirect. Put business rules in models or services.
|
|
9
|
+
- Prefer small, named methods over long ones. Comment the *why*, not the *what*.
|
|
10
|
+
|
|
11
|
+
## Services (if `app/services` exists)
|
|
12
|
+
|
|
13
|
+
- Services inherit from `ApplicationService`, expose a single public `call`, and return a `Result`.
|
|
14
|
+
- Call them as `MyService.call(...)`. Check `result.success?` / `result.failure?`; read `result.data` and `result.errors`.
|
|
15
|
+
- Wrap multi-record writes in `transaction { ... }`. Add an error to the result to roll back.
|
|
16
|
+
- Services must not render, redirect or touch `params`. Pass plain arguments in.
|
|
17
|
+
|
|
18
|
+
## Models and database
|
|
19
|
+
|
|
20
|
+
- Add database constraints (null, unique, foreign keys) and indexes in the migration, not only validations in the model.
|
|
21
|
+
- Never edit a migration that has been merged. Add a new one.
|
|
22
|
+
- Avoid N+1 queries: use `includes`/`preload` when iterating associations.
|
|
23
|
+
|
|
24
|
+
## Testing
|
|
25
|
+
|
|
26
|
+
- Every behaviour change ships with a test. Fix a bug by writing the failing test first.
|
|
27
|
+
- Keep tests independent and deterministic. Do not hit real external services; stub or record them.
|
|
28
|
+
- Run the full suite and `bin/rubocop` before opening a pull request.
|
|
29
|
+
|
|
30
|
+
## Security
|
|
31
|
+
|
|
32
|
+
- Never commit secrets. Use Rails credentials (`bin/rails credentials:edit`) or environment variables, and keep `.env*` files out of git.
|
|
33
|
+
- Always use strong parameters. Never build SQL with string interpolation.
|
|
34
|
+
- Run `bin/brakeman` and `bin/bundler-audit` regularly. CI should fail on new warnings.
|
|
35
|
+
|
|
36
|
+
## Git and pull requests
|
|
37
|
+
|
|
38
|
+
- Small, focused commits with imperative-mood messages ("Add invoice export").
|
|
39
|
+
- Pull requests describe the problem, the approach and how it was verified.
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: Bug report
|
|
3
|
+
about: Report a problem with the application
|
|
4
|
+
title: "[Bug] "
|
|
5
|
+
labels: bug
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## Description
|
|
9
|
+
|
|
10
|
+
A clear description of the bug.
|
|
11
|
+
|
|
12
|
+
## Steps to reproduce
|
|
13
|
+
|
|
14
|
+
1.
|
|
15
|
+
2.
|
|
16
|
+
3.
|
|
17
|
+
|
|
18
|
+
## Expected behavior
|
|
19
|
+
|
|
20
|
+
## Actual behavior
|
|
21
|
+
|
|
22
|
+
## Environment
|
|
23
|
+
|
|
24
|
+
- Version:
|
|
25
|
+
- OS:
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: Feature request
|
|
3
|
+
about: Suggest an idea for this project
|
|
4
|
+
title: "[Feature] "
|
|
5
|
+
labels: enhancement
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## Problem
|
|
9
|
+
|
|
10
|
+
What problem does this feature solve?
|
|
11
|
+
|
|
12
|
+
## Proposed solution
|
|
13
|
+
|
|
14
|
+
Describe the solution you'd like.
|
|
15
|
+
|
|
16
|
+
## Alternatives considered
|
|
17
|
+
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class ApplicationService
|
|
4
|
+
def self.call(params: {}, context: {}, **args)
|
|
5
|
+
new(params: params, context: context).call(**args)
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
attr_reader :result, :params, :context
|
|
9
|
+
|
|
10
|
+
def initialize(params: {}, context: {})
|
|
11
|
+
@params = params
|
|
12
|
+
@context = context
|
|
13
|
+
@result = Result.new
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
delegate :assign_data, to: :result
|
|
17
|
+
delegate :add_error, to: :result
|
|
18
|
+
delegate :success?, to: :result
|
|
19
|
+
delegate :failure?, to: :result
|
|
20
|
+
alias add_errors add_error
|
|
21
|
+
|
|
22
|
+
# Steps are usually private methods, so this deliberately uses send; never pass user input.
|
|
23
|
+
def step(method)
|
|
24
|
+
return unless success?
|
|
25
|
+
|
|
26
|
+
send(method)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def preload(*methods)
|
|
30
|
+
methods.map { |method| send(method) }
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def call
|
|
34
|
+
raise NotImplementedError, "#call method must be implemented"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Rolls back when the result has errors. ActiveRecord::Base.transaction swallows
|
|
38
|
+
# ActiveRecord::Rollback itself, and the errors are already on the result.
|
|
39
|
+
def transaction
|
|
40
|
+
ActiveRecord::Base.transaction do
|
|
41
|
+
yield
|
|
42
|
+
raise ActiveRecord::Rollback unless success?
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
## Setup solidqueue first
|
|
47
|
+
# def self.call_async(params: {}, context: {})
|
|
48
|
+
# AsyncJob.perform_later(name, params, context)
|
|
49
|
+
# end
|
|
50
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class Result
|
|
4
|
+
attr_reader :errors, :data, :status
|
|
5
|
+
|
|
6
|
+
def self.failure(*errors, status: :unprocessable_entity)
|
|
7
|
+
new(errors: errors, status: status)
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def self.success(data = nil, status: :ok)
|
|
11
|
+
new(data: data, status: status)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def initialize(errors: [], data: nil, status: :ok)
|
|
15
|
+
@errors = Array(errors)
|
|
16
|
+
@data = data
|
|
17
|
+
@status = status
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def assign_data(object)
|
|
21
|
+
@data = object
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def add_error(message, status_name = nil)
|
|
25
|
+
@status = status_name || :unprocessable_entity
|
|
26
|
+
if message.is_a?(Array)
|
|
27
|
+
@errors += message
|
|
28
|
+
else
|
|
29
|
+
@errors << message
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def success?
|
|
34
|
+
!failure?
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def failure?
|
|
38
|
+
errors.present? || status == :unprocessable_entity
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def error_string
|
|
42
|
+
errors.compact.join(" ")
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
require "date"
|
|
5
|
+
require "json"
|
|
6
|
+
require "fileutils"
|
|
7
|
+
require "tempfile"
|
|
8
|
+
# Bundler.with_unbundled_env runs the generators outside this gem's own bundle; the constant is
|
|
9
|
+
# not loaded when the installed gem runs without `bundle exec`.
|
|
10
|
+
require "bundler"
|
|
11
|
+
require "thor"
|
|
12
|
+
|
|
13
|
+
# Templates and generated files contain non-ASCII (em dashes); a C/POSIX locale
|
|
14
|
+
# (CI, Docker) would otherwise make File.read raise on them.
|
|
15
|
+
Encoding.default_external = Encoding::UTF_8
|
|
16
|
+
|
|
17
|
+
require_relative "opinionated_rails/version"
|
|
18
|
+
require_relative "opinionated_rails/error"
|
|
19
|
+
require_relative "opinionated_rails/context"
|
|
20
|
+
require_relative "opinionated_rails/answer_shell"
|
|
21
|
+
require_relative "opinionated_rails/step"
|
|
22
|
+
require_relative "opinionated_rails/step_graph"
|
|
23
|
+
require_relative "opinionated_rails/manifest"
|
|
24
|
+
require_relative "opinionated_rails/rails_version_guard"
|
|
25
|
+
require_relative "opinionated_rails/prerequisites"
|
|
26
|
+
require_relative "opinionated_rails/kamal_config"
|
|
27
|
+
require_relative "opinionated_rails/cli"
|
|
28
|
+
require_relative "opinionated_rails/generators/rails_new_invoker"
|
|
29
|
+
require_relative "opinionated_rails/generators/file_writer"
|
|
30
|
+
|
|
31
|
+
Dir[File.expand_path("opinionated_rails/steps/**/*.rb", __dir__)].sort.each do |file|
|
|
32
|
+
require file
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
module OpinionatedRails
|
|
36
|
+
def self.step_classes
|
|
37
|
+
[ Steps::Core, Steps::Addons ].flat_map do |mod|
|
|
38
|
+
mod.constants.map { |name| mod.const_get(name) }
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|