gemstack-cli 0.2.5 → 0.3.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +3 -25
- data/README.md +6 -17
- data/exe/gemstack +8 -1
- metadata +6 -159
- data/lib/gemstack/cli/add_generator.rb +0 -239
- data/lib/gemstack/cli/app_generator.rb +0 -142
- data/lib/gemstack/cli/commands/db.rb +0 -135
- data/lib/gemstack/cli/commands/jobs.rb +0 -96
- data/lib/gemstack/cli/controller_generator.rb +0 -73
- data/lib/gemstack/cli/deploy_generator.rb +0 -102
- data/lib/gemstack/cli/doctor.rb +0 -308
- data/lib/gemstack/cli/generator.rb +0 -144
- data/lib/gemstack/cli/job_generator.rb +0 -48
- data/lib/gemstack/cli/migration_generator.rb +0 -52
- data/lib/gemstack/cli/policy_generator.rb +0 -35
- data/lib/gemstack/cli/project.rb +0 -53
- data/lib/gemstack/cli/resource_generator.rb +0 -170
- data/lib/gemstack/cli/resource_spec.rb +0 -95
- data/lib/gemstack/cli.rb +0 -304
- data/templates/app/Gemfile.tt +0 -44
- data/templates/app/README.md.tt +0 -29
- data/templates/app/app/controllers/application_controller.rb +0 -6
- data/templates/app/app/jobs/application_job.rb +0 -9
- data/templates/app/app/mailers/application_mailer.rb +0 -8
- data/templates/app/app/mailers/templates/dot_keep +0 -0
- data/templates/app/app/models/application_model.rb +0 -19
- data/templates/app/app/serializers/application_serializer.rb +0 -6
- data/templates/app/bin/gemstack +0 -7
- data/templates/app/config/app.rb.tt +0 -54
- data/templates/app/config/database.yml.tt +0 -61
- data/templates/app/config/environments/development.rb.tt +0 -35
- data/templates/app/config/environments/production.rb.tt +0 -40
- data/templates/app/config/environments/test.rb.tt +0 -21
- data/templates/app/config/puma.rb +0 -21
- data/templates/app/config/routes.rb +0 -10
- data/templates/app/config.ru +0 -6
- data/templates/app/db/migrations/dot_keep +0 -0
- data/templates/app/db/seeds.rb +0 -5
- data/templates/app/dot_env.example.tt +0 -19
- data/templates/app/dot_gitignore +0 -16
- data/templates/app/dot_node-version.tt +0 -1
- data/templates/app/dot_nvmrc.tt +0 -1
- data/templates/app/dot_ruby-version.tt +0 -1
- data/templates/app/dot_tool-versions.tt +0 -4
- data/templates/app/test/health_test.rb +0 -18
- data/templates/app/test/test_helper.rb.tt +0 -25
- data/templates/auth/app/controllers/api_tokens_controller.rb +0 -35
- data/templates/auth/app/controllers/email_verifications_controller.rb +0 -31
- data/templates/auth/app/controllers/password_resets_controller.rb +0 -40
- data/templates/auth/app/controllers/registrations_controller.rb +0 -19
- data/templates/auth/app/controllers/sessions_controller.rb +0 -29
- data/templates/auth/app/mailers/auth_mailer.rb +0 -20
- data/templates/auth/app/mailers/templates/auth_mailer/email_verification.html.erb +0 -2
- data/templates/auth/app/mailers/templates/auth_mailer/email_verification.text.erb +0 -2
- data/templates/auth/app/mailers/templates/auth_mailer/password_reset.html.erb +0 -3
- data/templates/auth/app/mailers/templates/auth_mailer/password_reset.text.erb +0 -6
- data/templates/auth/app/models/auth_token.rb +0 -15
- data/templates/auth/app/models/user.rb +0 -12
- data/templates/auth/app/policies/application_policy.rb +0 -6
- data/templates/auth/app/serializers/api_token_serializer.rb +0 -6
- data/templates/auth/app/serializers/new_api_token_serializer.rb +0 -9
- data/templates/auth/app/serializers/user_serializer.rb +0 -6
- data/templates/auth/db/migrations/%timestamp%_create_auth_tables.rb +0 -41
- data/templates/auth/frontend/app/account/page.tsx +0 -116
- data/templates/auth/frontend/app/forgot-password/page.tsx +0 -48
- data/templates/auth/frontend/app/login/page.tsx +0 -26
- data/templates/auth/frontend/app/reset-password/page.tsx +0 -11
- data/templates/auth/frontend/app/signup/page.tsx +0 -27
- data/templates/auth/frontend/app/verify-email/page.tsx +0 -11
- data/templates/auth/frontend/components/auth/CredentialsForm.tsx +0 -58
- data/templates/auth/frontend/components/auth/ResetPasswordForm.tsx +0 -43
- data/templates/auth/frontend/components/auth/VerifyEmail.tsx +0 -28
- data/templates/auth/frontend/lib/auth.ts +0 -113
- data/templates/auth/test/controllers/auth_test.rb +0 -108
- data/templates/controller/app/controllers/%file_name%_controller.rb.tt +0 -10
- data/templates/controller/test/controllers/%file_name%_controller_test.rb.tt +0 -14
- data/templates/deploy/Caddyfile.tt +0 -19
- data/templates/deploy/Dockerfile.tt +0 -63
- data/templates/deploy/Procfile.tt +0 -6
- data/templates/deploy/compose.yaml.tt +0 -129
- data/templates/deploy/dot_dockerignore +0 -14
- data/templates/frontend/app/globals.css +0 -171
- data/templates/frontend/app/layout.tsx.tt +0 -19
- data/templates/frontend/app/page.module.css +0 -315
- data/templates/frontend/app/page.tsx.tt +0 -183
- data/templates/frontend/app/providers.tsx +0 -24
- data/templates/frontend/lib/gemstack/client.ts +0 -125
- data/templates/frontend/next-env.d.ts +0 -5
- data/templates/frontend/next.config.ts +0 -17
- data/templates/frontend/package.json.tt +0 -23
- data/templates/frontend/tsconfig.json +0 -21
- data/templates/job/app/jobs/%file_name%.rb.tt +0 -15
- data/templates/job/test/jobs/%file_name%_test.rb.tt +0 -15
- data/templates/migration/db/migrations/%timestamp%_%file_name%.rb.tt +0 -17
- data/templates/policy/app/policies/%file_name%_policy.rb.tt +0 -21
- data/templates/policy/test/policies/%file_name%_policy_test.rb.tt +0 -9
- data/templates/realtime/config/channels.rb +0 -14
- data/templates/realtime/frontend/lib/gemstack/realtime.ts +0 -120
- data/templates/resource/controller/app/controllers/%plural%_controller.rb.tt +0 -51
- data/templates/resource/controller/test/controllers/%plural%_controller_test.rb.tt +0 -74
- data/templates/resource/frontend/frontend/app/%url_segment%/[id]/edit/page.tsx.tt +0 -45
- data/templates/resource/frontend/frontend/app/%url_segment%/[id]/page.tsx.tt +0 -49
- data/templates/resource/frontend/frontend/app/%url_segment%/new/page.tsx.tt +0 -27
- data/templates/resource/frontend/frontend/app/%url_segment%/page.tsx.tt +0 -51
- data/templates/resource/frontend/frontend/components/%url_segment%/%class_name%Card.tsx.tt +0 -15
- data/templates/resource/frontend/frontend/components/%url_segment%/%class_name%Form.tsx.tt +0 -98
- data/templates/resource/frontend/frontend/components/%url_segment%/%class_name%Table.tsx.tt +0 -36
- data/templates/resource/frontend/frontend/lib/format.ts +0 -12
- data/templates/resource/frontend/frontend/lib/queries/%url_segment%.ts.tt +0 -64
- data/templates/resource/migration/db/migrations/%timestamp%_create_%table%.rb.tt +0 -18
- data/templates/resource/model/app/models/%file_name%.rb.tt +0 -15
- data/templates/resource/model/test/models/%file_name%_test.rb.tt +0 -29
- data/templates/resource/serializer/app/serializers/%file_name%_serializer.rb.tt +0 -7
- data/templates/storage/app/controllers/uploads_controller.rb.tt +0 -33
- data/templates/storage/app/serializers/upload_serializer.rb +0 -11
- data/templates/storage/frontend/lib/upload.ts +0 -52
- data/templates/storage/test/controllers/uploads_test.rb.tt +0 -30
data/lib/gemstack/cli/doctor.rb
DELETED
|
@@ -1,308 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "open3"
|
|
4
|
-
require "socket"
|
|
5
|
-
require "gemstack/dev"
|
|
6
|
-
|
|
7
|
-
module GemStack
|
|
8
|
-
class CLI < Thor
|
|
9
|
-
# `gemstack doctor` — checks that an application can run, and says how to
|
|
10
|
-
# fix what can't. Every check is independent: one
|
|
11
|
-
# failure never hides the others. `--production` checks the settings a
|
|
12
|
-
# deploy needs.
|
|
13
|
-
class Doctor
|
|
14
|
-
Result = Struct.new(:status, :title, :hint)
|
|
15
|
-
MIN_NODE = [20, 9].freeze # Next.js 16
|
|
16
|
-
|
|
17
|
-
attr_reader :results
|
|
18
|
-
|
|
19
|
-
def initialize(root:, output: $stdout, production: false, color: output.tty?, run: nil)
|
|
20
|
-
@root = root
|
|
21
|
-
@output = output
|
|
22
|
-
@production = production
|
|
23
|
-
@color = color
|
|
24
|
-
@run = run || ->(*command) { Open3.capture2e(*command) }
|
|
25
|
-
@results = []
|
|
26
|
-
end
|
|
27
|
-
|
|
28
|
-
def run
|
|
29
|
-
header
|
|
30
|
-
check_ruby
|
|
31
|
-
check_node
|
|
32
|
-
check_frontend_dependencies
|
|
33
|
-
check_git_secrets
|
|
34
|
-
booted = check_boot
|
|
35
|
-
if booted
|
|
36
|
-
database_ok = check_database
|
|
37
|
-
check_migrations_and_jobs if database_ok
|
|
38
|
-
check_cache
|
|
39
|
-
check_realtime_broker
|
|
40
|
-
check_contract if database_ok
|
|
41
|
-
end
|
|
42
|
-
check_production_settings if @production
|
|
43
|
-
check_port unless @production
|
|
44
|
-
summary
|
|
45
|
-
self
|
|
46
|
-
end
|
|
47
|
-
|
|
48
|
-
def ok? = results.none? { |result| result.status == :fail }
|
|
49
|
-
|
|
50
|
-
# ── checks ─────────────────────────────────────────────────────────
|
|
51
|
-
|
|
52
|
-
def check_ruby
|
|
53
|
-
wanted = read(".ruby-version")&.strip&.delete_prefix("ruby-")
|
|
54
|
-
if !Dev::Toolchain.ruby_ok?
|
|
55
|
-
problem("Ruby #{RUBY_VERSION}", "GemStack needs Ruby #{Dev::Toolchain::MIN_RUBY} or newer — " \
|
|
56
|
-
"#{Dev::Toolchain.ruby_hint(wanted.to_s.empty? ? "3.4" : wanted)}")
|
|
57
|
-
elsif wanted && !wanted.empty? && !RUBY_VERSION.start_with?(wanted)
|
|
58
|
-
caution("Ruby #{RUBY_VERSION}", "the app pins #{wanted} (.ruby-version) — #{Dev::Toolchain.ruby_hint(wanted)}")
|
|
59
|
-
else
|
|
60
|
-
pass("Ruby #{RUBY_VERSION}#{" (YJIT available)" if defined?(RubyVM::YJIT)}")
|
|
61
|
-
end
|
|
62
|
-
end
|
|
63
|
-
|
|
64
|
-
def check_node
|
|
65
|
-
return unless File.directory?(path("frontend"))
|
|
66
|
-
|
|
67
|
-
node = Dev::Toolchain.node(@run)
|
|
68
|
-
wanted = read(".node-version")&.strip || read(".nvmrc")&.strip || Dev::Toolchain::LTS_NODE
|
|
69
|
-
if node.nil?
|
|
70
|
-
problem("Node.js not found", Dev::Toolchain.node_hint(wanted, nil))
|
|
71
|
-
elsif !Dev::Toolchain.node_ok?(node[:version])
|
|
72
|
-
problem("Node.js #{node[:version]} (#{node[:path]})",
|
|
73
|
-
"Next.js 16 needs #{MIN_NODE.join(".")}+ — " \
|
|
74
|
-
"#{Dev::Toolchain.node_hint(wanted, Dev::Toolchain.node_manager(node[:path]))}")
|
|
75
|
-
else
|
|
76
|
-
pass("Node.js #{node[:version]}")
|
|
77
|
-
end
|
|
78
|
-
end
|
|
79
|
-
|
|
80
|
-
def check_frontend_dependencies
|
|
81
|
-
return unless File.directory?(path("frontend"))
|
|
82
|
-
|
|
83
|
-
if File.file?(path("frontend/node_modules/.package-lock.json"))
|
|
84
|
-
lock = path("frontend/package-lock.json")
|
|
85
|
-
stale = File.file?(lock) && File.mtime(lock) > File.mtime(path("frontend/node_modules/.package-lock.json"))
|
|
86
|
-
if stale
|
|
87
|
-
caution("frontend dependencies",
|
|
88
|
-
"package-lock.json changed — run: cd frontend && npm install")
|
|
89
|
-
else
|
|
90
|
-
pass("frontend dependencies installed")
|
|
91
|
-
end
|
|
92
|
-
else
|
|
93
|
-
problem("frontend dependencies missing", "run: cd frontend && npm install")
|
|
94
|
-
end
|
|
95
|
-
end
|
|
96
|
-
|
|
97
|
-
# Secrets committed to git stay in its history (and in every clone).
|
|
98
|
-
def check_git_secrets
|
|
99
|
-
return unless File.directory?(path(".git"))
|
|
100
|
-
|
|
101
|
-
out, status = @run.call("git", "-C", @root, "ls-files", "--", ".env", ".env.*", "*.pem", "*.key",
|
|
102
|
-
"tmp/*_secret")
|
|
103
|
-
return unless status.success?
|
|
104
|
-
|
|
105
|
-
tracked = out.lines.map(&:strip).reject { |file| file.empty? || file.end_with?(".example") }
|
|
106
|
-
if tracked.empty?
|
|
107
|
-
pass("no secret files tracked by git")
|
|
108
|
-
else
|
|
109
|
-
problem("secret files tracked by git: #{tracked.join(", ")}",
|
|
110
|
-
"remove them from git (git rm --cached …), rotate every secret they contain, and ask whoever " \
|
|
111
|
-
"manages your infrastructure to check where the repository was shared")
|
|
112
|
-
end
|
|
113
|
-
end
|
|
114
|
-
|
|
115
|
-
def check_boot
|
|
116
|
-
ENV["GEMSTACK_ENV"] = "production" if @production
|
|
117
|
-
ENV["GEMSTACK_ENV"] ||= "development"
|
|
118
|
-
Project.load_config!(@root)
|
|
119
|
-
config = GemStack.config
|
|
120
|
-
config.reload_code = false
|
|
121
|
-
config.eager_load = false
|
|
122
|
-
config.logger.output = nil
|
|
123
|
-
config.db.log_queries = false if config.respond_to?(:db)
|
|
124
|
-
GemStack.boot!
|
|
125
|
-
pass("application boots (#{GemStack.env}, #{GemStack.application.routes.size} routes)")
|
|
126
|
-
true
|
|
127
|
-
rescue StandardError, ScriptError => e
|
|
128
|
-
problem("application fails to boot: #{e.class}: #{e.message.lines.first&.strip}", location(e))
|
|
129
|
-
false
|
|
130
|
-
end
|
|
131
|
-
|
|
132
|
-
def check_database
|
|
133
|
-
return true unless defined?(GemStack::DB)
|
|
134
|
-
|
|
135
|
-
settings = GemStack::DB.settings
|
|
136
|
-
where = "#{GemStack::DB::Configuration.describe(settings)}, from #{settings[:source]}"
|
|
137
|
-
if settings[:adapter] == "sqlite" && !GemStack::DB::Tasks.exists?(settings)
|
|
138
|
-
problem("SQLite database file missing (#{where})", "run: gemstack db:create")
|
|
139
|
-
return false
|
|
140
|
-
end
|
|
141
|
-
GemStack::DB.connection.test_connection
|
|
142
|
-
pass("database reachable (#{where})")
|
|
143
|
-
true
|
|
144
|
-
rescue StandardError, LoadError => e
|
|
145
|
-
message = e.message.lines.first.to_s.strip
|
|
146
|
-
message = message.split(/FATAL:\s*/, 2).last if message.include?("FATAL:")
|
|
147
|
-
hint = if message.match?(/does not exist|Unknown database/) then "run: gemstack db:create"
|
|
148
|
-
elsif e.is_a?(GemStack::ConfigurationError) then "fix config/database.yml or DATABASE_URL"
|
|
149
|
-
else "is the database server running? Check config/database.yml or DATABASE_URL (#{where})"
|
|
150
|
-
end
|
|
151
|
-
problem("database: #{message}", hint)
|
|
152
|
-
false
|
|
153
|
-
end
|
|
154
|
-
|
|
155
|
-
# MySQL/SQLite apps: a jobs worker is a separate process, and the memory
|
|
156
|
-
# broker can't carry its broadcasts to the web server.
|
|
157
|
-
def check_realtime_broker
|
|
158
|
-
return unless defined?(GemStack::Realtime) && defined?(GemStack::Jobs) && GemStack::Jobs.database_queue?
|
|
159
|
-
return unless GemStack.config.realtime.broker.to_s == "memory" && jobs_used?
|
|
160
|
-
|
|
161
|
-
caution("realtime uses the in-process memory broker",
|
|
162
|
-
"broadcasts from jobs won't reach the web server — set config.realtime.broker = :redis (REDIS_URL)")
|
|
163
|
-
end
|
|
164
|
-
|
|
165
|
-
def check_migrations_and_jobs
|
|
166
|
-
pending = GemStack::DB::Migrator.new.pending
|
|
167
|
-
if pending.empty?
|
|
168
|
-
pass("migrations up to date")
|
|
169
|
-
else
|
|
170
|
-
caution("#{pending.size} pending migration(s)", "run: gemstack db:migrate")
|
|
171
|
-
end
|
|
172
|
-
return unless defined?(GemStack::Jobs) && GemStack::Jobs.database_queue?
|
|
173
|
-
return unless jobs_used?
|
|
174
|
-
|
|
175
|
-
if GemStack::DB.connection.table_exists?(:gemstack_jobs)
|
|
176
|
-
pass("jobs table present")
|
|
177
|
-
elsif pending.empty?
|
|
178
|
-
problem("background jobs are used but the gemstack_jobs table is missing",
|
|
179
|
-
"run: gemstack jobs:install && gemstack db:migrate")
|
|
180
|
-
end
|
|
181
|
-
end
|
|
182
|
-
|
|
183
|
-
def check_cache
|
|
184
|
-
return unless GemStack.config.respond_to?(:cache) && GemStack.config.cache.store.to_s == "redis"
|
|
185
|
-
|
|
186
|
-
GemStack.cache.write("gemstack:doctor", 1, expires_in: 5)
|
|
187
|
-
pass("Redis cache reachable")
|
|
188
|
-
rescue StandardError => e
|
|
189
|
-
problem("Redis cache: #{e.message.lines.first&.strip}", "is Redis running? Check REDIS_URL")
|
|
190
|
-
end
|
|
191
|
-
|
|
192
|
-
def check_contract
|
|
193
|
-
return unless File.directory?(path("frontend"))
|
|
194
|
-
|
|
195
|
-
contract = Contract.build(GemStack.application)
|
|
196
|
-
report = Contract.write(contract, root: @root, dry_run: true)
|
|
197
|
-
changed = report[:written] + report[:removed]
|
|
198
|
-
if changed.empty?
|
|
199
|
-
pass("TypeScript contract up to date")
|
|
200
|
-
else
|
|
201
|
-
caution("TypeScript contract out of date (#{changed.size} file(s))",
|
|
202
|
-
"run: gemstack contract (gemstack dev does it on save)")
|
|
203
|
-
end
|
|
204
|
-
contract[:warnings].first(3).each { |message| caution("contract: #{message}", nil) }
|
|
205
|
-
rescue StandardError => e
|
|
206
|
-
caution("contract could not be built: #{e.message.lines.first&.strip}", "run: gemstack contract")
|
|
207
|
-
end
|
|
208
|
-
|
|
209
|
-
def check_production_settings
|
|
210
|
-
secret = GemStack.config.secret_key_base.to_s
|
|
211
|
-
if secret.empty? then problem("SECRET_KEY_BASE is not set", "generate one: openssl rand -hex 64")
|
|
212
|
-
elsif secret.length < 64 then problem("SECRET_KEY_BASE is too short", "use: openssl rand -hex 64")
|
|
213
|
-
else pass("SECRET_KEY_BASE is set")
|
|
214
|
-
end
|
|
215
|
-
check_production_database if defined?(GemStack::DB)
|
|
216
|
-
if defined?(GemStack::Mail) && GemStack.config.mail.delivery.to_s == "smtp"
|
|
217
|
-
env_check("SMTP_URL", "e.g. smtp://user:password@smtp.example.com:587")
|
|
218
|
-
end
|
|
219
|
-
env_check("APP_URL", "the frontend's public URL, used in email links") if defined?(GemStack::Auth)
|
|
220
|
-
check_storage_settings if defined?(GemStack::Storage)
|
|
221
|
-
return unless GemStack.config.respond_to?(:cache) && GemStack.config.cache.store.to_s == "redis"
|
|
222
|
-
|
|
223
|
-
env_check("REDIS_URL", "the Redis used by the cache")
|
|
224
|
-
end
|
|
225
|
-
|
|
226
|
-
def check_production_database
|
|
227
|
-
settings = GemStack::DB.settings
|
|
228
|
-
if settings[:source].start_with?("default")
|
|
229
|
-
problem("no production database configured",
|
|
230
|
-
"set DATABASE_URL, or a production section in config/database.yml")
|
|
231
|
-
elsif settings[:adapter] == "sqlite"
|
|
232
|
-
caution("SQLite in production (#{settings[:database]})",
|
|
233
|
-
"keep the file on a persistent volume and run a single server; back it up")
|
|
234
|
-
else
|
|
235
|
-
pass("production database: #{GemStack::DB::Configuration.describe(settings)} (#{settings[:source]})")
|
|
236
|
-
end
|
|
237
|
-
end
|
|
238
|
-
|
|
239
|
-
def check_storage_settings
|
|
240
|
-
return unless GemStack.config.storage.service.to_s == "s3"
|
|
241
|
-
|
|
242
|
-
env_check("S3_BUCKET", "the bucket uploads go to")
|
|
243
|
-
begin
|
|
244
|
-
require "aws-sdk-s3"
|
|
245
|
-
pass("aws-sdk-s3 available")
|
|
246
|
-
rescue LoadError
|
|
247
|
-
problem("storage uses S3 but aws-sdk-s3 isn't installed", "add gem \"aws-sdk-s3\" to the Gemfile")
|
|
248
|
-
end
|
|
249
|
-
end
|
|
250
|
-
|
|
251
|
-
def check_port
|
|
252
|
-
port = Integer(ENV.fetch("PORT", 3000))
|
|
253
|
-
TCPServer.new("127.0.0.1", port).close
|
|
254
|
-
pass("port #{port} is free for gemstack dev")
|
|
255
|
-
rescue Errno::EADDRINUSE
|
|
256
|
-
caution("port #{port} is in use", "stop the other process, or run: PORT=#{port + 1} gemstack dev")
|
|
257
|
-
rescue ArgumentError, SystemCallError
|
|
258
|
-
nil
|
|
259
|
-
end
|
|
260
|
-
|
|
261
|
-
private
|
|
262
|
-
|
|
263
|
-
def header
|
|
264
|
-
@output.puts("GemStack doctor · #{File.basename(@root)}#{" · production settings" if @production}\n\n")
|
|
265
|
-
end
|
|
266
|
-
|
|
267
|
-
def summary
|
|
268
|
-
failures = results.count { |r| r.status == :fail }
|
|
269
|
-
warnings = results.count { |r| r.status == :warn }
|
|
270
|
-
line = if failures.zero? && warnings.zero? then paint("Everything looks good.", 32)
|
|
271
|
-
else
|
|
272
|
-
[(paint("#{failures} problem(s)", 31) if failures.positive?),
|
|
273
|
-
(paint("#{warnings} warning(s)", 33) if warnings.positive?)].compact.join(", ")
|
|
274
|
-
end
|
|
275
|
-
@output.puts("\n#{line}")
|
|
276
|
-
end
|
|
277
|
-
|
|
278
|
-
def record(status, title, hint)
|
|
279
|
-
@results << Result.new(status, title, hint)
|
|
280
|
-
symbol = { ok: paint("✓", 32), warn: paint("!", 33), fail: paint("✗", 31) }.fetch(status)
|
|
281
|
-
@output.puts(" #{symbol} #{title}")
|
|
282
|
-
@output.puts(" → #{hint}") if hint
|
|
283
|
-
end
|
|
284
|
-
|
|
285
|
-
def pass(title) = record(:ok, title, nil)
|
|
286
|
-
def caution(title, hint) = record(:warn, title, hint)
|
|
287
|
-
def problem(title, hint) = record(:fail, title, hint)
|
|
288
|
-
|
|
289
|
-
def env_check(name, hint)
|
|
290
|
-
ENV.fetch(name, "").strip.empty? ? problem("#{name} is not set", hint) : pass("#{name} is set")
|
|
291
|
-
end
|
|
292
|
-
|
|
293
|
-
def jobs_used?
|
|
294
|
-
Generator.background_work?(@root)
|
|
295
|
-
end
|
|
296
|
-
|
|
297
|
-
def location(error)
|
|
298
|
-
frame = Array(error.backtrace_locations || error.backtrace).map(&:to_s).find { |line| line.start_with?(@root) }
|
|
299
|
-
frame ? "at #{frame.delete_prefix("#{@root}/")}" : "run `gemstack server` to see the full error"
|
|
300
|
-
end
|
|
301
|
-
|
|
302
|
-
def redact(url) = url.to_s.sub(%r{//([^:/@]+):[^@]+@}, '//\1:***@')
|
|
303
|
-
def path(relative) = File.join(@root, relative)
|
|
304
|
-
def read(relative) = File.file?(path(relative)) ? File.read(path(relative)) : nil
|
|
305
|
-
def paint(text, code) = @color ? "\e[#{code}m#{text}\e[0m" : text
|
|
306
|
-
end
|
|
307
|
-
end
|
|
308
|
-
end
|
|
@@ -1,144 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "erb"
|
|
4
|
-
require "fileutils"
|
|
5
|
-
|
|
6
|
-
module GemStack
|
|
7
|
-
class CLI < Thor
|
|
8
|
-
# Shared machinery for generators: renders a template directory into a
|
|
9
|
-
# destination. Files ending in .tt are ERB templates evaluated against the
|
|
10
|
-
# generator; a leading "dot_" in a file name becomes "." (so dotfiles
|
|
11
|
-
# survive gem packaging). Existing files are never overwritten silently.
|
|
12
|
-
#
|
|
13
|
-
# Applications can override any template by placing a file with the same
|
|
14
|
-
# relative path in lib/templates/gemstack/<generator>/ (ARCHITECTURE §7).
|
|
15
|
-
class Generator
|
|
16
|
-
TEMPLATES = File.expand_path("../../../templates", __dir__)
|
|
17
|
-
|
|
18
|
-
attr_reader :created
|
|
19
|
-
|
|
20
|
-
def initialize(output: $stdout, force: false)
|
|
21
|
-
@output = output
|
|
22
|
-
@force = force
|
|
23
|
-
@created = []
|
|
24
|
-
end
|
|
25
|
-
|
|
26
|
-
def template_root(name) = File.join(TEMPLATES, name)
|
|
27
|
-
|
|
28
|
-
# Ruby core/stdlib names an app class must not reuse (a top-level class
|
|
29
|
-
# named Digest or Set would collide with Ruby's own). Constants already
|
|
30
|
-
# defined in this process (stdlib and gems loaded by the CLI) are
|
|
31
|
-
# checked too; the app's own code isn't loaded here, so those are always
|
|
32
|
-
# foreign.
|
|
33
|
-
RESERVED = %w[
|
|
34
|
-
Array BasicObject Benchmark Binding Class Comparable Data Date DateTime Digest Dir Encoding Enumerable
|
|
35
|
-
Enumerator Errno Exception FalseClass Fiber File FileTest FileUtils Float GC Hash IO Integer JSON Kernel
|
|
36
|
-
Logger Marshal MatchData Math Method Module Monitor Mutex NilClass Numeric Object ObjectSpace Observable
|
|
37
|
-
OpenStruct Pathname Proc Process Queue Random Range Rational Regexp Ripper Set Signal Socket String
|
|
38
|
-
StringIO Struct Symbol Thread Time Timeout TracePoint TrueClass URI Warning Zlib Rack Sequel Thor Puma
|
|
39
|
-
GemStack
|
|
40
|
-
].freeze
|
|
41
|
-
|
|
42
|
-
def self.check_constant!(name, suggestion: nil)
|
|
43
|
-
top = name.to_s.split("::").first
|
|
44
|
-
return unless RESERVED.include?(top) || Object.const_defined?(top, false)
|
|
45
|
-
|
|
46
|
-
hint = suggestion ? " — try #{suggestion}" : ""
|
|
47
|
-
raise Thor::Error, "#{name} would clash with Ruby's (or a loaded gem's) #{top} constant#{hint}"
|
|
48
|
-
end
|
|
49
|
-
|
|
50
|
-
# A migration version not used yet in db/migrations: generating several
|
|
51
|
-
# migrations within one second must not give them the same version.
|
|
52
|
-
def self.migration_timestamp(root, time = Time.now.utc)
|
|
53
|
-
loop do
|
|
54
|
-
stamp = time.strftime("%Y%m%d%H%M%S")
|
|
55
|
-
return stamp if Dir.glob(File.join(root.to_s, "db/migrations/#{stamp}_*.rb")).empty?
|
|
56
|
-
|
|
57
|
-
time += 1
|
|
58
|
-
end
|
|
59
|
-
end
|
|
60
|
-
|
|
61
|
-
# Every template file for a generator, with app overrides applied.
|
|
62
|
-
# The app's base classes (written by `gemstack new` since 0.2.1). Apps
|
|
63
|
-
# created earlier get the ones a generator's code inherits from.
|
|
64
|
-
BASE_CLASSES = {
|
|
65
|
-
model: "app/models/application_model.rb", serializer: "app/serializers/application_serializer.rb",
|
|
66
|
-
job: "app/jobs/application_job.rb", mailer: "app/mailers/application_mailer.rb"
|
|
67
|
-
}.freeze
|
|
68
|
-
|
|
69
|
-
# Whether the app has jobs or mailers of its own (base classes don't count).
|
|
70
|
-
def self.background_work?(root)
|
|
71
|
-
%w[app/jobs/*.rb app/mailers/*.rb].any? do |glob|
|
|
72
|
-
Dir.glob(File.join(root, glob)).any? { |file| !File.basename(file).start_with?("application_") }
|
|
73
|
-
end
|
|
74
|
-
end
|
|
75
|
-
|
|
76
|
-
def ensure_base_classes(root, *kinds)
|
|
77
|
-
kinds.each do |kind|
|
|
78
|
-
rel = BASE_CLASSES.fetch(kind)
|
|
79
|
-
target = File.join(root, rel)
|
|
80
|
-
write(target, File.read(File.join(template_root("app"), rel))) unless File.exist?(target)
|
|
81
|
-
end
|
|
82
|
-
end
|
|
83
|
-
|
|
84
|
-
def template_files(name, override_root: nil)
|
|
85
|
-
files = relative_files(template_root(name)).to_h { |rel| [rel, File.join(template_root(name), rel)] }
|
|
86
|
-
if override_root
|
|
87
|
-
custom = File.join(override_root, "lib/templates/gemstack", name)
|
|
88
|
-
relative_files(custom).each { |rel| files[rel] = File.join(custom, rel) } if File.directory?(custom)
|
|
89
|
-
end
|
|
90
|
-
files
|
|
91
|
-
end
|
|
92
|
-
|
|
93
|
-
def render_directory(name, destination, override_root: nil, skip: nil)
|
|
94
|
-
template_files(name, override_root: override_root).sort.each do |rel, source|
|
|
95
|
-
next if skip&.call(rel)
|
|
96
|
-
|
|
97
|
-
target = File.join(destination, output_path(rel))
|
|
98
|
-
content = File.binread(source)
|
|
99
|
-
content = render(content, source) if source.end_with?(".tt")
|
|
100
|
-
write(target, content, mode: File.stat(source).mode)
|
|
101
|
-
end
|
|
102
|
-
end
|
|
103
|
-
|
|
104
|
-
def render(content, source = "(template)")
|
|
105
|
-
erb = ERB.new(content, trim_mode: "-")
|
|
106
|
-
erb.filename = source
|
|
107
|
-
erb.result(binding)
|
|
108
|
-
end
|
|
109
|
-
|
|
110
|
-
def write(path, content, mode: nil)
|
|
111
|
-
if File.exist?(path) && !@force
|
|
112
|
-
if File.binread(path) == content
|
|
113
|
-
status("identical", path)
|
|
114
|
-
else
|
|
115
|
-
status("skip", path, "exists — not overwritten")
|
|
116
|
-
end
|
|
117
|
-
return false
|
|
118
|
-
end
|
|
119
|
-
FileUtils.mkdir_p(File.dirname(path))
|
|
120
|
-
File.binwrite(path, content)
|
|
121
|
-
File.chmod(mode & 0o777, path) if mode
|
|
122
|
-
@created << path
|
|
123
|
-
status("create", path)
|
|
124
|
-
true
|
|
125
|
-
end
|
|
126
|
-
|
|
127
|
-
def status(label, path, note = nil)
|
|
128
|
-
shown = path.delete_prefix("#{Dir.pwd}/")
|
|
129
|
-
@output.puts(" #{label.rjust(9)} #{shown}#{" (#{note})" if note}")
|
|
130
|
-
end
|
|
131
|
-
|
|
132
|
-
private
|
|
133
|
-
|
|
134
|
-
def relative_files(root)
|
|
135
|
-
Dir.glob("**/*", File::FNM_DOTMATCH, base: root)
|
|
136
|
-
.reject { |rel| File.directory?(File.join(root, rel)) || File.basename(rel) == ".DS_Store" }
|
|
137
|
-
end
|
|
138
|
-
|
|
139
|
-
def output_path(rel)
|
|
140
|
-
rel.delete_suffix(".tt").split("/").map { |part| part.sub(/\Adot_/, ".") }.join("/")
|
|
141
|
-
end
|
|
142
|
-
end
|
|
143
|
-
end
|
|
144
|
-
end
|
|
@@ -1,48 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module GemStack
|
|
4
|
-
class CLI < Thor
|
|
5
|
-
# `gemstack generate job SendWelcomeEmail [queue]`
|
|
6
|
-
#
|
|
7
|
-
# Writes app/jobs/<name>.rb and a test, and — the first time — the
|
|
8
|
-
# migration for the gemstack_jobs table, so apps get the table only once
|
|
9
|
-
# they actually use background jobs.
|
|
10
|
-
class JobGenerator < Generator
|
|
11
|
-
MIGRATION_GLOB = "db/migrations/*_create_gemstack_jobs.rb"
|
|
12
|
-
|
|
13
|
-
attr_reader :class_name, :file_name, :queue_name
|
|
14
|
-
|
|
15
|
-
def self.install_migration(root, output: $stdout, timestamp: nil)
|
|
16
|
-
existing = Dir.glob(File.join(root, MIGRATION_GLOB)).first
|
|
17
|
-
generator = new("Placeholder", root: root, output: output)
|
|
18
|
-
return generator.status("identical", existing, "jobs table migration already present") if existing
|
|
19
|
-
|
|
20
|
-
require "gemstack/jobs"
|
|
21
|
-
stamp = timestamp || Generator.migration_timestamp(root)
|
|
22
|
-
generator.write(File.join(root, "db/migrations/#{stamp}_create_gemstack_jobs.rb"), Jobs::Migration::SOURCE)
|
|
23
|
-
end
|
|
24
|
-
|
|
25
|
-
def initialize(name, root:, queue: nil, output: $stdout, force: false)
|
|
26
|
-
super(output: output, force: force)
|
|
27
|
-
@root = root
|
|
28
|
-
base = Inflector.camelize(name.to_s).delete_suffix("Job")
|
|
29
|
-
raise Thor::Error, "Invalid job name #{name.inspect}" unless base.match?(/\A[A-Z][A-Za-z0-9]*\z/)
|
|
30
|
-
|
|
31
|
-
@class_name = name.to_s.end_with?("Job") ? Inflector.camelize(name.to_s) : base
|
|
32
|
-
@file_name = Inflector.underscore(@class_name)
|
|
33
|
-
@queue_name = queue
|
|
34
|
-
Generator.check_constant!(@class_name, suggestion: "#{@class_name}Job") unless name == "Placeholder"
|
|
35
|
-
end
|
|
36
|
-
|
|
37
|
-
def run
|
|
38
|
-
ensure_base_classes(@root, :job)
|
|
39
|
-
template_files("job", override_root: @root).each do |rel, source|
|
|
40
|
-
target = File.join(@root, rel.delete_suffix(".tt").gsub("%file_name%", file_name))
|
|
41
|
-
write(target, render(File.read(source), source))
|
|
42
|
-
end
|
|
43
|
-
self.class.install_migration(@root, output: @output) if File.directory?(File.join(@root, "db"))
|
|
44
|
-
self
|
|
45
|
-
end
|
|
46
|
-
end
|
|
47
|
-
end
|
|
48
|
-
end
|
|
@@ -1,52 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module GemStack
|
|
4
|
-
class CLI < Thor
|
|
5
|
-
# `gemstack generate migration NAME [fields]`
|
|
6
|
-
#
|
|
7
|
-
# AddSkuToProducts sku:string:unique → alter_table(:products) { add_column ... }
|
|
8
|
-
# anything else → an empty `change` block
|
|
9
|
-
class MigrationGenerator < Generator
|
|
10
|
-
attr_reader :file_name, :table, :columns, :timestamp
|
|
11
|
-
|
|
12
|
-
def initialize(name, field_args, root:, output: $stdout, timestamp: nil)
|
|
13
|
-
super(output: output)
|
|
14
|
-
@root = root
|
|
15
|
-
@file_name = Inflector.underscore(name.to_s)
|
|
16
|
-
raise Thor::Error, "Invalid migration name #{name.inspect}" unless @file_name.match?(/\A[a-z][a-z0-9_]*\z/)
|
|
17
|
-
|
|
18
|
-
@timestamp = timestamp || Generator.migration_timestamp(root)
|
|
19
|
-
@table = @file_name[/\Aadd_\w+_to_(\w+)\z/, 1]
|
|
20
|
-
spec = ResourceSpec.new("Migration", field_args)
|
|
21
|
-
builder = ResourceGenerator.new(spec, root: root, output: output)
|
|
22
|
-
@columns = spec.fields.flat_map do |field|
|
|
23
|
-
[normalize(builder.migration_column(field),
|
|
24
|
-
field)] + (field.index && !field.unique ? ["add_index :#{field.column}"] : [])
|
|
25
|
-
end
|
|
26
|
-
end
|
|
27
|
-
|
|
28
|
-
def run
|
|
29
|
-
template_files("migration", override_root: @root).each do |rel, source|
|
|
30
|
-
path = rel.delete_suffix(".tt").gsub("%timestamp%", timestamp).gsub("%file_name%", file_name)
|
|
31
|
-
write(File.join(@root, path), render(File.read(source), source))
|
|
32
|
-
end
|
|
33
|
-
self
|
|
34
|
-
end
|
|
35
|
-
|
|
36
|
-
private
|
|
37
|
-
|
|
38
|
-
# Turns a create_table column line into its alter_table equivalent.
|
|
39
|
-
def normalize(line, field)
|
|
40
|
-
if field.reference?
|
|
41
|
-
line.sub(/\Aforeign_key/, "add_foreign_key")
|
|
42
|
-
elsif line.start_with?("column ")
|
|
43
|
-
line.sub(/\Acolumn :(\w+), /, 'add_column :\1, ')
|
|
44
|
-
else
|
|
45
|
-
type, rest = line.split(" ", 2)
|
|
46
|
-
name, options = rest.split(",", 2)
|
|
47
|
-
"add_column #{name}, #{type}#{",#{options}" if options}"
|
|
48
|
-
end
|
|
49
|
-
end
|
|
50
|
-
end
|
|
51
|
-
end
|
|
52
|
-
end
|
|
@@ -1,35 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module GemStack
|
|
4
|
-
class CLI < Thor
|
|
5
|
-
# `gemstack generate policy Order` → app/policies/order_policy.rb + test.
|
|
6
|
-
class PolicyGenerator < Generator
|
|
7
|
-
attr_reader :model_name, :class_name, :file_name
|
|
8
|
-
|
|
9
|
-
def initialize(name, root:, output: $stdout, force: false)
|
|
10
|
-
super(output: output, force: force)
|
|
11
|
-
@root = root
|
|
12
|
-
@model_name = Inflector.camelize(name.to_s.delete_suffix("Policy").delete_suffix("_policy"))
|
|
13
|
-
unless @model_name.match?(/\A[A-Z][A-Za-z0-9]*(::[A-Z][A-Za-z0-9]*)*\z/)
|
|
14
|
-
raise Thor::Error,
|
|
15
|
-
"Invalid policy name #{name.inspect}"
|
|
16
|
-
end
|
|
17
|
-
|
|
18
|
-
@class_name = "#{@model_name}Policy"
|
|
19
|
-
@file_name = Inflector.underscore(@model_name)
|
|
20
|
-
end
|
|
21
|
-
|
|
22
|
-
def parent_class
|
|
23
|
-
File.file?(File.join(@root, "app/policies/application_policy.rb")) ? "ApplicationPolicy" : "GemStack::Policy"
|
|
24
|
-
end
|
|
25
|
-
|
|
26
|
-
def run
|
|
27
|
-
template_files("policy", override_root: @root).each do |rel, source|
|
|
28
|
-
target = File.join(@root, rel.delete_suffix(".tt").gsub("%file_name%", file_name))
|
|
29
|
-
write(target, render(File.read(source), source))
|
|
30
|
-
end
|
|
31
|
-
self
|
|
32
|
-
end
|
|
33
|
-
end
|
|
34
|
-
end
|
|
35
|
-
end
|
data/lib/gemstack/cli/project.rb
DELETED
|
@@ -1,53 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module GemStack
|
|
4
|
-
class CLI < Thor
|
|
5
|
-
# Locates the GemStack application the CLI is run from and makes sure
|
|
6
|
-
# commands run inside that application's bundle, so the app's own
|
|
7
|
-
# GemStack version and gems are used (like a binstub would).
|
|
8
|
-
module Project
|
|
9
|
-
MARKER = "config/app.rb"
|
|
10
|
-
|
|
11
|
-
module_function
|
|
12
|
-
|
|
13
|
-
def root(start = Dir.pwd)
|
|
14
|
-
dir = File.expand_path(start)
|
|
15
|
-
loop do
|
|
16
|
-
return dir if File.file?(File.join(dir, MARKER)) && File.file?(File.join(dir, "Gemfile"))
|
|
17
|
-
|
|
18
|
-
parent = File.dirname(dir)
|
|
19
|
-
return nil if parent == dir
|
|
20
|
-
|
|
21
|
-
dir = parent
|
|
22
|
-
end
|
|
23
|
-
end
|
|
24
|
-
|
|
25
|
-
def root!
|
|
26
|
-
root || abort("Not inside a GemStack application (no #{MARKER} found). Create one with `gemstack new myapp`.")
|
|
27
|
-
end
|
|
28
|
-
|
|
29
|
-
def in_bundle?(app_root)
|
|
30
|
-
gemfile = ENV.fetch("BUNDLE_GEMFILE", nil)
|
|
31
|
-
gemfile && File.expand_path(gemfile) == File.join(app_root, "Gemfile") && defined?(Bundler)
|
|
32
|
-
end
|
|
33
|
-
|
|
34
|
-
# Re-executes the current command under `bundle exec` unless the app's
|
|
35
|
-
# bundle is already active, then chdirs to the app root.
|
|
36
|
-
def ensure_bundle!(argv)
|
|
37
|
-
app_root = root!
|
|
38
|
-
unless in_bundle?(app_root)
|
|
39
|
-
Dir.chdir(app_root)
|
|
40
|
-
ENV["BUNDLE_GEMFILE"] = File.join(app_root, "Gemfile")
|
|
41
|
-
exec("bundle", "exec", "gemstack", *argv)
|
|
42
|
-
end
|
|
43
|
-
Dir.chdir(app_root)
|
|
44
|
-
app_root
|
|
45
|
-
end
|
|
46
|
-
|
|
47
|
-
# Loads config/app.rb (configuration + gems) without booting the app.
|
|
48
|
-
def load_config!(app_root)
|
|
49
|
-
require File.join(app_root, MARKER)
|
|
50
|
-
end
|
|
51
|
-
end
|
|
52
|
-
end
|
|
53
|
-
end
|