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.
Files changed (118) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +3 -25
  3. data/README.md +6 -17
  4. data/exe/gemstack +8 -1
  5. metadata +6 -159
  6. data/lib/gemstack/cli/add_generator.rb +0 -239
  7. data/lib/gemstack/cli/app_generator.rb +0 -142
  8. data/lib/gemstack/cli/commands/db.rb +0 -135
  9. data/lib/gemstack/cli/commands/jobs.rb +0 -96
  10. data/lib/gemstack/cli/controller_generator.rb +0 -73
  11. data/lib/gemstack/cli/deploy_generator.rb +0 -102
  12. data/lib/gemstack/cli/doctor.rb +0 -308
  13. data/lib/gemstack/cli/generator.rb +0 -144
  14. data/lib/gemstack/cli/job_generator.rb +0 -48
  15. data/lib/gemstack/cli/migration_generator.rb +0 -52
  16. data/lib/gemstack/cli/policy_generator.rb +0 -35
  17. data/lib/gemstack/cli/project.rb +0 -53
  18. data/lib/gemstack/cli/resource_generator.rb +0 -170
  19. data/lib/gemstack/cli/resource_spec.rb +0 -95
  20. data/lib/gemstack/cli.rb +0 -304
  21. data/templates/app/Gemfile.tt +0 -44
  22. data/templates/app/README.md.tt +0 -29
  23. data/templates/app/app/controllers/application_controller.rb +0 -6
  24. data/templates/app/app/jobs/application_job.rb +0 -9
  25. data/templates/app/app/mailers/application_mailer.rb +0 -8
  26. data/templates/app/app/mailers/templates/dot_keep +0 -0
  27. data/templates/app/app/models/application_model.rb +0 -19
  28. data/templates/app/app/serializers/application_serializer.rb +0 -6
  29. data/templates/app/bin/gemstack +0 -7
  30. data/templates/app/config/app.rb.tt +0 -54
  31. data/templates/app/config/database.yml.tt +0 -61
  32. data/templates/app/config/environments/development.rb.tt +0 -35
  33. data/templates/app/config/environments/production.rb.tt +0 -40
  34. data/templates/app/config/environments/test.rb.tt +0 -21
  35. data/templates/app/config/puma.rb +0 -21
  36. data/templates/app/config/routes.rb +0 -10
  37. data/templates/app/config.ru +0 -6
  38. data/templates/app/db/migrations/dot_keep +0 -0
  39. data/templates/app/db/seeds.rb +0 -5
  40. data/templates/app/dot_env.example.tt +0 -19
  41. data/templates/app/dot_gitignore +0 -16
  42. data/templates/app/dot_node-version.tt +0 -1
  43. data/templates/app/dot_nvmrc.tt +0 -1
  44. data/templates/app/dot_ruby-version.tt +0 -1
  45. data/templates/app/dot_tool-versions.tt +0 -4
  46. data/templates/app/test/health_test.rb +0 -18
  47. data/templates/app/test/test_helper.rb.tt +0 -25
  48. data/templates/auth/app/controllers/api_tokens_controller.rb +0 -35
  49. data/templates/auth/app/controllers/email_verifications_controller.rb +0 -31
  50. data/templates/auth/app/controllers/password_resets_controller.rb +0 -40
  51. data/templates/auth/app/controllers/registrations_controller.rb +0 -19
  52. data/templates/auth/app/controllers/sessions_controller.rb +0 -29
  53. data/templates/auth/app/mailers/auth_mailer.rb +0 -20
  54. data/templates/auth/app/mailers/templates/auth_mailer/email_verification.html.erb +0 -2
  55. data/templates/auth/app/mailers/templates/auth_mailer/email_verification.text.erb +0 -2
  56. data/templates/auth/app/mailers/templates/auth_mailer/password_reset.html.erb +0 -3
  57. data/templates/auth/app/mailers/templates/auth_mailer/password_reset.text.erb +0 -6
  58. data/templates/auth/app/models/auth_token.rb +0 -15
  59. data/templates/auth/app/models/user.rb +0 -12
  60. data/templates/auth/app/policies/application_policy.rb +0 -6
  61. data/templates/auth/app/serializers/api_token_serializer.rb +0 -6
  62. data/templates/auth/app/serializers/new_api_token_serializer.rb +0 -9
  63. data/templates/auth/app/serializers/user_serializer.rb +0 -6
  64. data/templates/auth/db/migrations/%timestamp%_create_auth_tables.rb +0 -41
  65. data/templates/auth/frontend/app/account/page.tsx +0 -116
  66. data/templates/auth/frontend/app/forgot-password/page.tsx +0 -48
  67. data/templates/auth/frontend/app/login/page.tsx +0 -26
  68. data/templates/auth/frontend/app/reset-password/page.tsx +0 -11
  69. data/templates/auth/frontend/app/signup/page.tsx +0 -27
  70. data/templates/auth/frontend/app/verify-email/page.tsx +0 -11
  71. data/templates/auth/frontend/components/auth/CredentialsForm.tsx +0 -58
  72. data/templates/auth/frontend/components/auth/ResetPasswordForm.tsx +0 -43
  73. data/templates/auth/frontend/components/auth/VerifyEmail.tsx +0 -28
  74. data/templates/auth/frontend/lib/auth.ts +0 -113
  75. data/templates/auth/test/controllers/auth_test.rb +0 -108
  76. data/templates/controller/app/controllers/%file_name%_controller.rb.tt +0 -10
  77. data/templates/controller/test/controllers/%file_name%_controller_test.rb.tt +0 -14
  78. data/templates/deploy/Caddyfile.tt +0 -19
  79. data/templates/deploy/Dockerfile.tt +0 -63
  80. data/templates/deploy/Procfile.tt +0 -6
  81. data/templates/deploy/compose.yaml.tt +0 -129
  82. data/templates/deploy/dot_dockerignore +0 -14
  83. data/templates/frontend/app/globals.css +0 -171
  84. data/templates/frontend/app/layout.tsx.tt +0 -19
  85. data/templates/frontend/app/page.module.css +0 -315
  86. data/templates/frontend/app/page.tsx.tt +0 -183
  87. data/templates/frontend/app/providers.tsx +0 -24
  88. data/templates/frontend/lib/gemstack/client.ts +0 -125
  89. data/templates/frontend/next-env.d.ts +0 -5
  90. data/templates/frontend/next.config.ts +0 -17
  91. data/templates/frontend/package.json.tt +0 -23
  92. data/templates/frontend/tsconfig.json +0 -21
  93. data/templates/job/app/jobs/%file_name%.rb.tt +0 -15
  94. data/templates/job/test/jobs/%file_name%_test.rb.tt +0 -15
  95. data/templates/migration/db/migrations/%timestamp%_%file_name%.rb.tt +0 -17
  96. data/templates/policy/app/policies/%file_name%_policy.rb.tt +0 -21
  97. data/templates/policy/test/policies/%file_name%_policy_test.rb.tt +0 -9
  98. data/templates/realtime/config/channels.rb +0 -14
  99. data/templates/realtime/frontend/lib/gemstack/realtime.ts +0 -120
  100. data/templates/resource/controller/app/controllers/%plural%_controller.rb.tt +0 -51
  101. data/templates/resource/controller/test/controllers/%plural%_controller_test.rb.tt +0 -74
  102. data/templates/resource/frontend/frontend/app/%url_segment%/[id]/edit/page.tsx.tt +0 -45
  103. data/templates/resource/frontend/frontend/app/%url_segment%/[id]/page.tsx.tt +0 -49
  104. data/templates/resource/frontend/frontend/app/%url_segment%/new/page.tsx.tt +0 -27
  105. data/templates/resource/frontend/frontend/app/%url_segment%/page.tsx.tt +0 -51
  106. data/templates/resource/frontend/frontend/components/%url_segment%/%class_name%Card.tsx.tt +0 -15
  107. data/templates/resource/frontend/frontend/components/%url_segment%/%class_name%Form.tsx.tt +0 -98
  108. data/templates/resource/frontend/frontend/components/%url_segment%/%class_name%Table.tsx.tt +0 -36
  109. data/templates/resource/frontend/frontend/lib/format.ts +0 -12
  110. data/templates/resource/frontend/frontend/lib/queries/%url_segment%.ts.tt +0 -64
  111. data/templates/resource/migration/db/migrations/%timestamp%_create_%table%.rb.tt +0 -18
  112. data/templates/resource/model/app/models/%file_name%.rb.tt +0 -15
  113. data/templates/resource/model/test/models/%file_name%_test.rb.tt +0 -29
  114. data/templates/resource/serializer/app/serializers/%file_name%_serializer.rb.tt +0 -7
  115. data/templates/storage/app/controllers/uploads_controller.rb.tt +0 -33
  116. data/templates/storage/app/serializers/upload_serializer.rb +0 -11
  117. data/templates/storage/frontend/lib/upload.ts +0 -52
  118. data/templates/storage/test/controllers/uploads_test.rb.tt +0 -30
@@ -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
@@ -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