Repository navigation
Expand file tree
/
Copy pathtemplate.rb
More file actions
338 lines (268 loc) · 11.2 KB
/
Copy pathtemplate.rb
File metadata and controls
338 lines (268 loc) · 11.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
# frozen_string_literal: true
# Rubyroid Labs AI-native Rails template.
#
# rails new myapp \
# --database=postgresql \
# --css=tailwind \
# --skip-test \
# --skip-action-cable \
# -m https://raw.githubusercontent.com/rubyroidlabs/rails-template/main/template.rb
#
# See README.md in https://github.com/rubyroidlabs/rails-template for the full story.
require "fileutils"
require "tmpdir"
REPO_URL = ENV.fetch("RAILS_TEMPLATE_REPO", "https://github.com/rubyroidlabs/rails-template.git")
REPO_BRANCH = ENV.fetch("RAILS_TEMPLATE_BRANCH", "main")
MIN_RUBY_VERSION = "4.0"
MIN_RAILS_VERSION = "8.0"
# ---------------------------------------------------------------------------
# Source files
# ---------------------------------------------------------------------------
# `rails new -m <url>` downloads only this file, so when we're run from a URL we
# need the rest of the repo (everything under template/) fetched separately.
def template_root
@template_root ||=
if __FILE__.match?(%r{\Ahttps?://})
dir = Dir.mktmpdir("rubyroidlabs-rails-template-")
say_status :fetch, "#{REPO_URL} (#{REPO_BRANCH})", :green
unless system("git", "clone", "--quiet", "--depth", "1", "--branch", REPO_BRANCH, REPO_URL, dir)
raise Thor::Error, "Could not clone #{REPO_URL} (branch #{REPO_BRANCH})."
end
at_exit { FileUtils.remove_entry(dir, true) }
dir
else
File.expand_path(File.dirname(__FILE__))
end
end
# Ours first so our README.md.tt wins over Rails' own, but keep Rails' paths:
# `rails new` still resolves templates of its own (.kamal/secrets, for one)
# after this file has been applied.
def source_paths
[ File.join(template_root, "template"), *(defined?(super) ? super : []) ]
end
# ---------------------------------------------------------------------------
# Preconditions
# ---------------------------------------------------------------------------
def check_versions!
if Gem::Version.new(RUBY_VERSION) < Gem::Version.new(MIN_RUBY_VERSION)
raise Thor::Error, "This template needs Ruby >= #{MIN_RUBY_VERSION} (running #{RUBY_VERSION})."
end
if Gem::Version.new(Rails::VERSION::STRING) < Gem::Version.new(MIN_RAILS_VERSION)
raise Thor::Error, "This template needs Rails >= #{MIN_RAILS_VERSION} (running #{Rails::VERSION::STRING})."
end
end
def check_generator_options!
problems = []
problems << "--database=postgresql" unless options[:database].to_s == "postgresql"
problems << "--css=tailwind" unless options[:css].to_s == "tailwind"
return if problems.empty?
raise Thor::Error, <<~MSG
This template assumes PostgreSQL and Tailwind (config/database.yml, docker-compose.yml,
the CI workflow and the UI conventions are all written against them). Re-run with:
rails new #{app_name} \\
--database=postgresql \\
--css=tailwind \\
--skip-test \\
--skip-action-cable \\
-m #{__FILE__}
Missing: #{problems.join(" ")}
MSG
end
# ---------------------------------------------------------------------------
# Gemfile
# ---------------------------------------------------------------------------
# Thor's `inject_into_file` inserts verbatim, so squiggly heredocs need their
# indentation put back before they land inside a block or a class body.
def indented(text, spaces)
text.gsub(/^(?=.)/, " " * spaces)
end
# `inject_into_file` silently no-ops when the anchor is gone (a future Rails
# could reword the generated Gemfile), so fall back to appending.
def add_to_gemfile(content, after:, indent: 0)
if File.read("Gemfile").match?(after)
inject_into_file "Gemfile", indented(content, indent), after: after, verbose: false
else
append_to_file "Gemfile", "\n#{content}", verbose: false
end
end
def configure_gemfile
# Blueprinter replaces jbuilder as the serialization layer.
gsub_file "Gemfile", /^#\s*Build JSON APIs.*\ngem "jbuilder".*\n/, "", verbose: false
gsub_file "Gemfile", /^gem "jbuilder".*\n/, "", verbose: false
add_to_gemfile(<<~RUBY, after: /^gem "tailwindcss-rails".*\n/)
# Fast, declarative JSON serializer for API responses [https://github.com/procore-oss/blueprinter]
gem "blueprinter"
# Fast JSON generator, used as Blueprinter's JSON backend [https://github.com/ohler55/oj]
gem "oj"
# Rails view helpers for Heroicons SVG icons [https://github.com/bharget/heroicon]
gem "heroicon"
RUBY
add_to_gemfile(<<~RUBY, after: /^gem "image_processing".*\n/)
# Minimal authorization through OO design and pure Ruby classes [https://github.com/varvet/pundit]
gem "pundit"
# Service objects with a common interface [https://github.com/collectiveidea/interactor-rails]
gem "interactor-rails"
# Generate OpenAPI docs from request specs and serve them with Swagger UI [https://github.com/rswag/rswag]
gem "rswag-api"
gem "rswag-ui"
RUBY
add_to_gemfile(<<~RUBY, after: /^\s*gem "rubocop-rails-omakase".*\n/, indent: 2)
# RSpec for unit/model/request specs [https://github.com/rspec/rspec-rails]
gem "rspec-rails"
# RSpec matchers for testing Pundit authorization policies [https://github.com/punditrb/pundit-matchers]
gem "pundit-matchers"
# Generate OpenAPI docs from RSpec request specs [https://github.com/rswag/rswag]
gem "rswag-specs"
# Test data factories [https://github.com/thoughtbot/factory_bot_rails]
gem "factory_bot_rails"
# Fake data for factories [https://github.com/faker-ruby/faker]
gem "faker"
gem "ruby-lsp-rspec"
RUBY
end
# ---------------------------------------------------------------------------
# Application code
# ---------------------------------------------------------------------------
def copy_application_files
# RSpec: our own spec_helper/rails_helper rather than `rspec:install`, so the
# FactoryBot, Pundit matchers and support-file wiring is already in place.
copy_file ".rspec"
directory "spec"
# Pundit
copy_file "app/policies/application_policy.rb"
# Interactor
keep_file "app/interactors"
# Blueprinter + Oj
copy_file "app/blueprints/application_blueprint.rb"
copy_file "config/initializers/blueprinter.rb"
# Heroicons
copy_file "app/helpers/heroicon_helper.rb"
copy_file "config/initializers/heroicon.rb"
# rswag / OpenAPI
copy_file "config/initializers/rswag_api.rb"
copy_file "config/initializers/rswag_ui.rb"
directory "swagger"
# Local services + CI
template "docker-compose.yml.tt", "docker-compose.yml"
template "config/database.yml.tt", "config/database.yml", force: true
template ".github/workflows/ci.yml.tt", ".github/workflows/ci.yml", force: true
# Playwright + ESLint
template "package.json.tt", "package.json", force: true
copy_file "playwright.config.ts"
copy_file "tsconfig.json"
copy_file "eslint.config.mjs"
directory "e2e"
# Docs
template "README.md.tt", "README.md", force: true
copy_file "AGENTS.md"
copy_file "CLAUDE.md"
directory "docs"
# Agent tooling installs itself (see `run_agent_setup`); the template only
# copies the script that drives it. Everything the skills and graphify
# installers write — .agents/skills, .claude, .codex, .cursor,
# skills-lock.json — is generated in the new app rather than vendored here.
copy_file "bin/setup-agents"
chmod "bin/setup-agents", 0o755, verbose: false
end
def patch_generated_files
# Pundit
inject_into_class "app/controllers/application_controller.rb", "ApplicationController",
indented("include Pundit::Authorization\n\n", 2)
inject_into_file "app/controllers/application_controller.rb", before: /^end\s*\z/ do
indented(<<~RUBY, 2)
rescue_from Pundit::NotAuthorizedError, with: :user_not_authorized
private
def user_not_authorized
flash[:alert] = "You are not authorized to perform this action."
redirect_back fallback_location: "/"
end
RUBY
end
# rswag mounts
inject_into_file "config/routes.rb", after: /Rails\.application\.routes\.draw do\n/ do
indented(<<~RUBY, 2)
# Serves the generated OpenAPI docs (swagger/v1/swagger.yaml) and the Swagger UI at /api-docs
mount Rswag::Ui::Engine => "/api-docs"
mount Rswag::Api::Engine => "/api-docs"
RUBY
end
# MailHog in development
inject_into_file "config/environments/development.rb",
after: /config\.action_mailer\.default_url_options = .*\n/ do
indented(<<~RUBY, 2)
# Deliver emails to MailHog (see docker-compose.yml) so they can be viewed
# at http://localhost:8025 instead of being sent for real.
config.action_mailer.delivery_method = :smtp
config.action_mailer.smtp_settings = {
address: "localhost",
port: 1025
}
RUBY
end
# bin/ci
inject_into_file "config/ci.rb", after: /step "Style: Ruby".*\n/ do
%( step "Style: JavaScript", "npm run lint:js"\n)
end
# `--skip-test` is recommended but not enforced; drop the Minitest tree if it
# slipped through so there's exactly one test suite (spec/).
remove_dir "test" if File.directory?("test")
end
def keep_file(dir)
empty_directory dir
create_file File.join(dir, ".keep"), "", verbose: false
end
# The skills and graphify installers need network access and tools we don't
# control (uv/pipx, npx, the Claude Code CLI). The script reports and skips
# whatever is missing, and can be re-run by hand, so a failure here is a
# warning rather than a dead `rails new`.
def run_agent_setup
if ENV["SKIP_AGENT_SETUP"]
say_status :skip, "agent tooling (SKIP_AGENT_SETUP set) — run bin/setup-agents later", :yellow
return
end
say_status :agents, "installing skills and graphify", :green
return if run("bin/setup-agents", capture: false, abort_on_failure: false)
say_status :warn, "bin/setup-agents did not finish — re-run it by hand", :yellow
end
# ---------------------------------------------------------------------------
# Run
# ---------------------------------------------------------------------------
check_versions!
check_generator_options!
configure_gemfile
copy_application_files
patch_generated_files
after_bundle do
# Appended here rather than earlier so these land after the entries the
# Tailwind and importmap installers add during `rails new`.
append_to_file ".gitignore", <<~IGNORE
# Node / Playwright
/node_modules
/playwright-report
/test-results
/blob-report
/playwright/.cache
# Graphify knowledge graph
/graphify-out
# Local, per-developer agent settings
/.claude/settings.local.json
IGNORE
if File.exist?("package.json") && system("which npm > /dev/null 2>&1")
say_status :npm, "installing JavaScript dev dependencies", :green
run "npm install"
else
say_status :skip, "npm not found — run `npm install` before using Playwright/ESLint", :yellow
end
run_agent_setup
say <<~DONE
------------------------------------------------------------------------
#{app_name} is ready.
cd #{app_name}
docker compose up -d # Postgres + MailHog
bin/setup # gems, database, dev server
Agent tooling (skills + graphify) is installed by bin/setup-agents; re-run
it any time to upgrade, and after cloning this repo somewhere new.
Then read AGENTS.md — it's the contract every agent and human follows here.
------------------------------------------------------------------------
DONE
end