gemstack-cli 0.1.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 (110) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +5 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +24 -0
  5. data/exe/gemstack +6 -0
  6. data/lib/gemstack/cli/add_generator.rb +237 -0
  7. data/lib/gemstack/cli/app_generator.rb +110 -0
  8. data/lib/gemstack/cli/commands/db.rb +134 -0
  9. data/lib/gemstack/cli/commands/jobs.rb +96 -0
  10. data/lib/gemstack/cli/controller_generator.rb +73 -0
  11. data/lib/gemstack/cli/deploy_generator.rb +64 -0
  12. data/lib/gemstack/cli/doctor.rb +277 -0
  13. data/lib/gemstack/cli/generator.rb +122 -0
  14. data/lib/gemstack/cli/job_generator.rb +47 -0
  15. data/lib/gemstack/cli/migration_generator.rb +52 -0
  16. data/lib/gemstack/cli/policy_generator.rb +35 -0
  17. data/lib/gemstack/cli/project.rb +53 -0
  18. data/lib/gemstack/cli/resource_generator.rb +169 -0
  19. data/lib/gemstack/cli/resource_spec.rb +95 -0
  20. data/lib/gemstack/cli.rb +299 -0
  21. data/templates/app/Gemfile.tt +39 -0
  22. data/templates/app/README.md.tt +29 -0
  23. data/templates/app/app/controllers/application_controller.rb +6 -0
  24. data/templates/app/bin/gemstack +7 -0
  25. data/templates/app/config/app.rb.tt +30 -0
  26. data/templates/app/config/environments/development.rb +6 -0
  27. data/templates/app/config/environments/production.rb +8 -0
  28. data/templates/app/config/environments/test.rb +7 -0
  29. data/templates/app/config/puma.rb +21 -0
  30. data/templates/app/config/routes.rb +10 -0
  31. data/templates/app/config.ru +6 -0
  32. data/templates/app/db/migrations/dot_keep +0 -0
  33. data/templates/app/db/seeds.rb +5 -0
  34. data/templates/app/dot_env.example +9 -0
  35. data/templates/app/dot_gitignore +14 -0
  36. data/templates/app/dot_ruby-version.tt +1 -0
  37. data/templates/app/dot_tool-versions.tt +1 -0
  38. data/templates/app/test/health_test.rb +18 -0
  39. data/templates/app/test/test_helper.rb.tt +25 -0
  40. data/templates/auth/app/controllers/api_tokens_controller.rb +35 -0
  41. data/templates/auth/app/controllers/email_verifications_controller.rb +31 -0
  42. data/templates/auth/app/controllers/password_resets_controller.rb +40 -0
  43. data/templates/auth/app/controllers/registrations_controller.rb +19 -0
  44. data/templates/auth/app/controllers/sessions_controller.rb +29 -0
  45. data/templates/auth/app/mailers/auth_mailer.rb +20 -0
  46. data/templates/auth/app/mailers/templates/auth_mailer/email_verification.html.erb +2 -0
  47. data/templates/auth/app/mailers/templates/auth_mailer/email_verification.text.erb +2 -0
  48. data/templates/auth/app/mailers/templates/auth_mailer/password_reset.html.erb +3 -0
  49. data/templates/auth/app/mailers/templates/auth_mailer/password_reset.text.erb +6 -0
  50. data/templates/auth/app/models/auth_token.rb +15 -0
  51. data/templates/auth/app/models/user.rb +12 -0
  52. data/templates/auth/app/policies/application_policy.rb +6 -0
  53. data/templates/auth/app/serializers/api_token_serializer.rb +6 -0
  54. data/templates/auth/app/serializers/new_api_token_serializer.rb +9 -0
  55. data/templates/auth/app/serializers/user_serializer.rb +6 -0
  56. data/templates/auth/db/migrations/%timestamp%_create_auth_tables.rb +41 -0
  57. data/templates/auth/frontend/app/account/page.tsx +116 -0
  58. data/templates/auth/frontend/app/forgot-password/page.tsx +48 -0
  59. data/templates/auth/frontend/app/login/page.tsx +26 -0
  60. data/templates/auth/frontend/app/reset-password/page.tsx +11 -0
  61. data/templates/auth/frontend/app/signup/page.tsx +27 -0
  62. data/templates/auth/frontend/app/verify-email/page.tsx +11 -0
  63. data/templates/auth/frontend/components/auth/CredentialsForm.tsx +58 -0
  64. data/templates/auth/frontend/components/auth/ResetPasswordForm.tsx +43 -0
  65. data/templates/auth/frontend/components/auth/VerifyEmail.tsx +28 -0
  66. data/templates/auth/frontend/lib/auth.ts +113 -0
  67. data/templates/auth/test/controllers/auth_test.rb +108 -0
  68. data/templates/controller/app/controllers/%file_name%_controller.rb.tt +10 -0
  69. data/templates/controller/test/controllers/%file_name%_controller_test.rb.tt +14 -0
  70. data/templates/deploy/Caddyfile.tt +19 -0
  71. data/templates/deploy/Dockerfile.tt +62 -0
  72. data/templates/deploy/Procfile.tt +6 -0
  73. data/templates/deploy/compose.yaml.tt +80 -0
  74. data/templates/deploy/dot_dockerignore +14 -0
  75. data/templates/frontend/app/globals.css +171 -0
  76. data/templates/frontend/app/layout.tsx.tt +19 -0
  77. data/templates/frontend/app/page.tsx.tt +45 -0
  78. data/templates/frontend/app/providers.tsx +24 -0
  79. data/templates/frontend/lib/gemstack/client.ts +125 -0
  80. data/templates/frontend/next-env.d.ts +5 -0
  81. data/templates/frontend/next.config.ts +17 -0
  82. data/templates/frontend/package.json.tt +23 -0
  83. data/templates/frontend/tsconfig.json +21 -0
  84. data/templates/job/app/jobs/%file_name%.rb.tt +15 -0
  85. data/templates/job/test/jobs/%file_name%_test.rb.tt +15 -0
  86. data/templates/migration/db/migrations/%timestamp%_%file_name%.rb.tt +17 -0
  87. data/templates/policy/app/policies/%file_name%_policy.rb.tt +21 -0
  88. data/templates/policy/test/policies/%file_name%_policy_test.rb.tt +9 -0
  89. data/templates/realtime/config/channels.rb +14 -0
  90. data/templates/realtime/frontend/lib/gemstack/realtime.ts +120 -0
  91. data/templates/resource/controller/app/controllers/%plural%_controller.rb.tt +51 -0
  92. data/templates/resource/controller/test/controllers/%plural%_controller_test.rb.tt +74 -0
  93. data/templates/resource/frontend/frontend/app/%url_segment%/[id]/edit/page.tsx.tt +45 -0
  94. data/templates/resource/frontend/frontend/app/%url_segment%/[id]/page.tsx.tt +49 -0
  95. data/templates/resource/frontend/frontend/app/%url_segment%/new/page.tsx.tt +27 -0
  96. data/templates/resource/frontend/frontend/app/%url_segment%/page.tsx.tt +51 -0
  97. data/templates/resource/frontend/frontend/components/%url_segment%/%class_name%Card.tsx.tt +15 -0
  98. data/templates/resource/frontend/frontend/components/%url_segment%/%class_name%Form.tsx.tt +98 -0
  99. data/templates/resource/frontend/frontend/components/%url_segment%/%class_name%Table.tsx.tt +36 -0
  100. data/templates/resource/frontend/frontend/lib/format.ts +12 -0
  101. data/templates/resource/frontend/frontend/lib/queries/%url_segment%.ts.tt +63 -0
  102. data/templates/resource/migration/db/migrations/%timestamp%_create_%table%.rb.tt +18 -0
  103. data/templates/resource/model/app/models/%file_name%.rb.tt +15 -0
  104. data/templates/resource/model/test/models/%file_name%_test.rb.tt +29 -0
  105. data/templates/resource/serializer/app/serializers/%file_name%_serializer.rb.tt +7 -0
  106. data/templates/storage/app/controllers/uploads_controller.rb.tt +33 -0
  107. data/templates/storage/app/serializers/upload_serializer.rb +11 -0
  108. data/templates/storage/frontend/lib/upload.ts +52 -0
  109. data/templates/storage/test/controllers/uploads_test.rb.tt +30 -0
  110. metadata +195 -0
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GemStack
4
+ class CLI < Thor
5
+ # `gemstack generate controller Products index show create`
6
+ #
7
+ # Creates the controller, a test per action and the matching routes.
8
+ # Conventional action names map to REST routes; any other action becomes
9
+ # GET /<resource>/<action>.
10
+ class ControllerGenerator < Generator
11
+ REST = {
12
+ "index" => ["get", ""], "show" => ["get", "/:id"], "create" => ["post", ""],
13
+ "update" => ["patch", "/:id"], "destroy" => ["delete", "/:id"]
14
+ }.freeze
15
+ ROUTES_BLOCK = /^GemStack\.routes do[ \t]*\n/
16
+
17
+ attr_reader :file_name, :class_name, :actions, :url_path
18
+
19
+ def initialize(name, actions, root:, output: $stdout, force: false)
20
+ super(output: output, force: force)
21
+ @root = root
22
+ @file_name = Inflector.underscore(name.to_s.delete_suffix("Controller")) # "admin/products"
23
+ @class_name = "#{Inflector.camelize(@file_name)}Controller" # "Admin::ProductsController"
24
+ @actions = (actions.empty? ? ["index"] : actions).map { |a| Inflector.underscore(a) }.uniq
25
+ @url_path = "/#{@file_name.split("/").map { |part| Inflector.dasherize(part) }.join("/")}"
26
+ validate!
27
+ end
28
+
29
+ def run
30
+ template_files("controller", override_root: @root).each do |rel, source|
31
+ target = File.join(@root, rel.delete_suffix(".tt").gsub("%file_name%", file_name))
32
+ write(target, render(File.read(source), source))
33
+ end
34
+ add_routes
35
+ self
36
+ end
37
+
38
+ def route_for(action)
39
+ verb, suffix = REST.fetch(action) { ["get", "/#{Inflector.dasherize(action)}"] }
40
+ [verb, url_path + suffix]
41
+ end
42
+
43
+ # Example path for tests: :id becomes 1.
44
+ def request_path(action) = "/api#{route_for(action)[1].sub(":id", "1")}"
45
+
46
+ def route_line(action)
47
+ verb, path = route_for(action)
48
+ %(#{verb} "#{path}", to: "#{file_name}##{action}")
49
+ end
50
+
51
+ private
52
+
53
+ def validate!
54
+ invalid = actions.grep_v(/\A[a-z_][a-z0-9_]*\z/)
55
+ raise Thor::Error, "Invalid action name(s): #{invalid.join(", ")}" unless invalid.empty?
56
+ raise Thor::Error, "Invalid controller name" unless file_name.match?(%r{\A[a-z][a-z0-9_/]*\z})
57
+ end
58
+
59
+ def add_routes
60
+ path = File.join(@root, "config/routes.rb")
61
+ return status("skip", path, "not found — add routes manually") unless File.file?(path)
62
+
63
+ content = File.read(path)
64
+ lines = actions.map { |action| route_line(action) }.reject { |line| content.include?(line) }
65
+ return status("identical", path) if lines.empty?
66
+ return status("skip", path, "no `GemStack.routes do` block found") unless content.match?(ROUTES_BLOCK)
67
+
68
+ File.write(path, content.sub(ROUTES_BLOCK) { |open| open + lines.map { |l| " #{l}\n" }.join })
69
+ status("route", path, lines.join("; "))
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GemStack
4
+ class CLI < Thor
5
+ # `gemstack generate deploy` — Dockerfile (api + web targets), compose.yaml
6
+ # (Postgres, migrations, API, jobs, Next.js, Caddy), Caddyfile, Procfile and
7
+ # .dockerignore (DECISIONS D-058). Nothing is deployed anywhere.
8
+ class DeployGenerator < Generator
9
+ def initialize(root:, output: $stdout, force: false)
10
+ super(output: output, force: force)
11
+ @root = root
12
+ end
13
+
14
+ def app_name = File.basename(@root).downcase.gsub(/[^a-z0-9_-]/, "-")
15
+ def frontend? = File.file?(File.join(@root, "frontend/package.json"))
16
+
17
+ # A worker process is needed for background jobs (and auth's emails).
18
+ def jobs?
19
+ return true if gemfile.match?(/^\s*gem "gemstack-auth"/)
20
+
21
+ gemfile.match?(/^\s*gem "gemstack-jobs"/) &&
22
+ %w[app/jobs/*.rb app/mailers/*.rb].any? { |glob| !Dir.glob(File.join(@root, glob)).empty? }
23
+ end
24
+
25
+ def mail? = gemfile.match?(/^\s*gem "gemstack-(mail|auth)"/)
26
+
27
+ def ruby_version
28
+ pinned = File.file?(File.join(@root, ".ruby-version")) && File.read(File.join(@root, ".ruby-version")).strip
29
+ pinned && !pinned.empty? ? pinned.delete_prefix("ruby-") : RUBY_VERSION
30
+ end
31
+
32
+ def node_version
33
+ tools = File.join(@root, ".tool-versions")
34
+ pinned = File.file?(tools) && File.read(tools)[/^nodejs\s+(\d+)/, 1]
35
+ pinned || "22"
36
+ end
37
+
38
+ def run
39
+ template_files("deploy", override_root: @root).sort.each do |rel, source|
40
+ content = File.read(source)
41
+ content = render(content, source) if rel.end_with?(".tt")
42
+ write(File.join(@root, output_path(rel.delete_suffix(".tt"))), content)
43
+ end
44
+ keep = File.join(@root, "vendor/.keep")
45
+ write(keep, "") unless File.exist?(keep) # the Dockerfile copies vendor/ (e.g. vendor/cache)
46
+ warn_about_local_gems
47
+ self
48
+ end
49
+
50
+ private
51
+
52
+ def gemfile = @gemfile ||= File.read(File.join(@root, "Gemfile"))
53
+
54
+ # Apps generated from a GemStack checkout point at it with an absolute
55
+ # path, which doesn't exist inside the image.
56
+ def warn_about_local_gems
57
+ return unless gemfile.match?(/^path "/)
58
+
59
+ status("warning", "Gemfile", "GemStack comes from a local path the image can't see — " \
60
+ "run `bundle cache --all` (vendor/cache) or use released gems")
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,277 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require "socket"
5
+
6
+ module GemStack
7
+ class CLI < Thor
8
+ # `gemstack doctor` — checks that an application can run, and says how to
9
+ # fix what can't (DECISIONS D-057). Every check is independent: one
10
+ # failure never hides the others. `--production` checks the settings a
11
+ # deploy needs.
12
+ class Doctor
13
+ Result = Struct.new(:status, :title, :hint)
14
+ MIN_NODE = [20, 9].freeze # Next.js 16
15
+
16
+ attr_reader :results
17
+
18
+ def initialize(root:, output: $stdout, production: false, color: output.tty?, run: nil)
19
+ @root = root
20
+ @output = output
21
+ @production = production
22
+ @color = color
23
+ @run = run || ->(*command) { Open3.capture2e(*command) }
24
+ @results = []
25
+ end
26
+
27
+ def run
28
+ header
29
+ check_ruby
30
+ check_node
31
+ check_frontend_dependencies
32
+ check_git_secrets
33
+ booted = check_boot
34
+ if booted
35
+ database_ok = check_database
36
+ check_migrations_and_jobs if database_ok
37
+ check_cache
38
+ check_contract if database_ok
39
+ end
40
+ check_production_settings if @production
41
+ check_port unless @production
42
+ summary
43
+ self
44
+ end
45
+
46
+ def ok? = results.none? { |result| result.status == :fail }
47
+
48
+ # ── checks ─────────────────────────────────────────────────────────
49
+
50
+ def check_ruby
51
+ wanted = read(".ruby-version")&.strip&.delete_prefix("ruby-")
52
+ if wanted && !wanted.empty? && !RUBY_VERSION.start_with?(wanted)
53
+ caution("Ruby #{RUBY_VERSION}",
54
+ "the app pins #{wanted} (.ruby-version) — install it: asdf install ruby #{wanted}")
55
+ elsif Gem::Version.new(RUBY_VERSION) < Gem::Version.new("4.0")
56
+ problem("Ruby #{RUBY_VERSION}", "GemStack needs Ruby 4.0 or newer")
57
+ else
58
+ pass("Ruby #{RUBY_VERSION}#{" (YJIT available)" if defined?(RubyVM::YJIT)}")
59
+ end
60
+ end
61
+
62
+ def check_node
63
+ return unless File.directory?(path("frontend"))
64
+
65
+ out, status = @run.call("node", "--version")
66
+ version = status.success? ? out.strip.delete_prefix("v") : nil
67
+ if version.nil?
68
+ problem("Node.js not found", "install Node.js #{MIN_NODE.join(".")}+ (the frontend is Next.js)")
69
+ elsif (version.split(".").map(&:to_i) <=> MIN_NODE).negative?
70
+ problem("Node.js #{version}", "Next.js 16 needs Node.js #{MIN_NODE.join(".")} or newer")
71
+ else
72
+ pass("Node.js #{version}")
73
+ end
74
+ rescue SystemCallError
75
+ problem("Node.js not found", "install Node.js #{MIN_NODE.join(".")}+ (the frontend is Next.js)")
76
+ end
77
+
78
+ def check_frontend_dependencies
79
+ return unless File.directory?(path("frontend"))
80
+
81
+ if File.file?(path("frontend/node_modules/.package-lock.json"))
82
+ lock = path("frontend/package-lock.json")
83
+ stale = File.file?(lock) && File.mtime(lock) > File.mtime(path("frontend/node_modules/.package-lock.json"))
84
+ if stale
85
+ caution("frontend dependencies",
86
+ "package-lock.json changed — run: cd frontend && npm install")
87
+ else
88
+ pass("frontend dependencies installed")
89
+ end
90
+ else
91
+ problem("frontend dependencies missing", "run: cd frontend && npm install")
92
+ end
93
+ end
94
+
95
+ # Secrets committed to git stay in its history (and in every clone).
96
+ def check_git_secrets
97
+ return unless File.directory?(path(".git"))
98
+
99
+ out, status = @run.call("git", "-C", @root, "ls-files", "--", ".env", ".env.*", "*.pem", "*.key",
100
+ "tmp/*_secret")
101
+ return unless status.success?
102
+
103
+ tracked = out.lines.map(&:strip).reject { |file| file.empty? || file.end_with?(".example") }
104
+ if tracked.empty?
105
+ pass("no secret files tracked by git")
106
+ else
107
+ problem("secret files tracked by git: #{tracked.join(", ")}",
108
+ "remove them from git (git rm --cached …), rotate every secret they contain, and ask whoever " \
109
+ "manages your infrastructure to check where the repository was shared")
110
+ end
111
+ end
112
+
113
+ def check_boot
114
+ ENV["GEMSTACK_ENV"] = "production" if @production
115
+ ENV["GEMSTACK_ENV"] ||= "development"
116
+ Project.load_config!(@root)
117
+ config = GemStack.config
118
+ config.reload_code = false
119
+ config.eager_load = false
120
+ config.logger.output = nil
121
+ config.db.log_queries = false if config.respond_to?(:db)
122
+ GemStack.boot!
123
+ pass("application boots (#{GemStack.env}, #{GemStack.application.routes.size} routes)")
124
+ true
125
+ rescue StandardError, ScriptError => e
126
+ problem("application fails to boot: #{e.class}: #{e.message.lines.first&.strip}", location(e))
127
+ false
128
+ end
129
+
130
+ def check_database
131
+ return true unless defined?(GemStack::DB)
132
+
133
+ url = GemStack::DB.config.url
134
+ GemStack::DB.connection.test_connection
135
+ pass("PostgreSQL reachable (#{redact(url)})")
136
+ true
137
+ rescue StandardError => e
138
+ message = e.message.lines.first.to_s.strip
139
+ message = message.split(/FATAL:\s*/, 2).last if message.include?("FATAL:")
140
+ hint = if message.include?("does not exist") then "run: gemstack db:create"
141
+ else "is PostgreSQL running? Set DATABASE_URL (now #{redact(url.to_s)}) in .env or the environment"
142
+ end
143
+ problem("PostgreSQL: #{message}", hint)
144
+ false
145
+ end
146
+
147
+ def check_migrations_and_jobs
148
+ pending = GemStack::DB::Migrator.new.pending
149
+ if pending.empty?
150
+ pass("migrations up to date")
151
+ else
152
+ caution("#{pending.size} pending migration(s)", "run: gemstack db:migrate")
153
+ end
154
+ return unless defined?(GemStack::Jobs) && GemStack.config.jobs.adapter.to_s == "postgres"
155
+ return unless jobs_used?
156
+
157
+ if GemStack::DB.connection.table_exists?(:gemstack_jobs)
158
+ pass("jobs table present")
159
+ elsif pending.empty?
160
+ problem("background jobs are used but the gemstack_jobs table is missing",
161
+ "run: gemstack jobs:install && gemstack db:migrate")
162
+ end
163
+ end
164
+
165
+ def check_cache
166
+ return unless GemStack.config.respond_to?(:cache) && GemStack.config.cache.store.to_s == "redis"
167
+
168
+ GemStack.cache.write("gemstack:doctor", 1, expires_in: 5)
169
+ pass("Redis cache reachable")
170
+ rescue StandardError => e
171
+ problem("Redis cache: #{e.message.lines.first&.strip}", "is Redis running? Check REDIS_URL")
172
+ end
173
+
174
+ def check_contract
175
+ return unless File.directory?(path("frontend"))
176
+
177
+ contract = Contract.build(GemStack.application)
178
+ report = Contract.write(contract, root: @root, dry_run: true)
179
+ changed = report[:written] + report[:removed]
180
+ if changed.empty?
181
+ pass("TypeScript contract up to date")
182
+ else
183
+ caution("TypeScript contract out of date (#{changed.size} file(s))",
184
+ "run: gemstack contract (gemstack dev does it on save)")
185
+ end
186
+ contract[:warnings].first(3).each { |message| caution("contract: #{message}", nil) }
187
+ rescue StandardError => e
188
+ caution("contract could not be built: #{e.message.lines.first&.strip}", "run: gemstack contract")
189
+ end
190
+
191
+ def check_production_settings
192
+ secret = GemStack.config.secret_key_base.to_s
193
+ if secret.empty? then problem("SECRET_KEY_BASE is not set", "generate one: openssl rand -hex 64")
194
+ elsif secret.length < 64 then problem("SECRET_KEY_BASE is too short", "use: openssl rand -hex 64")
195
+ else pass("SECRET_KEY_BASE is set")
196
+ end
197
+ env_check("DATABASE_URL", "the production PostgreSQL URL") if defined?(GemStack::DB)
198
+ if defined?(GemStack::Mail) && GemStack.config.mail.delivery.to_s == "smtp"
199
+ env_check("SMTP_URL", "e.g. smtp://user:password@smtp.example.com:587")
200
+ end
201
+ env_check("APP_URL", "the frontend's public URL, used in email links") if defined?(GemStack::Auth)
202
+ check_storage_settings if defined?(GemStack::Storage)
203
+ return unless GemStack.config.respond_to?(:cache) && GemStack.config.cache.store.to_s == "redis"
204
+
205
+ env_check("REDIS_URL", "the Redis used by the cache")
206
+ end
207
+
208
+ def check_storage_settings
209
+ return unless GemStack.config.storage.service.to_s == "s3"
210
+
211
+ env_check("S3_BUCKET", "the bucket uploads go to")
212
+ begin
213
+ require "aws-sdk-s3"
214
+ pass("aws-sdk-s3 available")
215
+ rescue LoadError
216
+ problem("storage uses S3 but aws-sdk-s3 isn't installed", "add gem \"aws-sdk-s3\" to the Gemfile")
217
+ end
218
+ end
219
+
220
+ def check_port
221
+ port = Integer(ENV.fetch("PORT", 3000))
222
+ TCPServer.new("127.0.0.1", port).close
223
+ pass("port #{port} is free for gemstack dev")
224
+ rescue Errno::EADDRINUSE
225
+ caution("port #{port} is in use", "stop the other process, or run: PORT=#{port + 1} gemstack dev")
226
+ rescue ArgumentError, SystemCallError
227
+ nil
228
+ end
229
+
230
+ private
231
+
232
+ def header
233
+ @output.puts("GemStack doctor · #{File.basename(@root)}#{" · production settings" if @production}\n\n")
234
+ end
235
+
236
+ def summary
237
+ failures = results.count { |r| r.status == :fail }
238
+ warnings = results.count { |r| r.status == :warn }
239
+ line = if failures.zero? && warnings.zero? then paint("Everything looks good.", 32)
240
+ else
241
+ [(paint("#{failures} problem(s)", 31) if failures.positive?),
242
+ (paint("#{warnings} warning(s)", 33) if warnings.positive?)].compact.join(", ")
243
+ end
244
+ @output.puts("\n#{line}")
245
+ end
246
+
247
+ def record(status, title, hint)
248
+ @results << Result.new(status, title, hint)
249
+ symbol = { ok: paint("✓", 32), warn: paint("!", 33), fail: paint("✗", 31) }.fetch(status)
250
+ @output.puts(" #{symbol} #{title}")
251
+ @output.puts(" → #{hint}") if hint
252
+ end
253
+
254
+ def pass(title) = record(:ok, title, nil)
255
+ def caution(title, hint) = record(:warn, title, hint)
256
+ def problem(title, hint) = record(:fail, title, hint)
257
+
258
+ def env_check(name, hint)
259
+ ENV.fetch(name, "").strip.empty? ? problem("#{name} is not set", hint) : pass("#{name} is set")
260
+ end
261
+
262
+ def jobs_used?
263
+ %w[app/jobs/*.rb app/mailers/*.rb].any? { |glob| !Dir.glob(path(glob)).empty? }
264
+ end
265
+
266
+ def location(error)
267
+ frame = Array(error.backtrace_locations || error.backtrace).map(&:to_s).find { |line| line.start_with?(@root) }
268
+ frame ? "at #{frame.delete_prefix("#{@root}/")}" : "run `gemstack server` to see the full error"
269
+ end
270
+
271
+ def redact(url) = url.to_s.sub(%r{//([^:/@]+):[^@]+@}, '//\1:***@')
272
+ def path(relative) = File.join(@root, relative)
273
+ def read(relative) = File.file?(path(relative)) ? File.read(path(relative)) : nil
274
+ def paint(text, code) = @color ? "\e[#{code}m#{text}\e[0m" : text
275
+ end
276
+ end
277
+ end
@@ -0,0 +1,122 @@
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
+ def template_files(name, override_root: nil)
63
+ files = relative_files(template_root(name)).to_h { |rel| [rel, File.join(template_root(name), rel)] }
64
+ if override_root
65
+ custom = File.join(override_root, "lib/templates/gemstack", name)
66
+ relative_files(custom).each { |rel| files[rel] = File.join(custom, rel) } if File.directory?(custom)
67
+ end
68
+ files
69
+ end
70
+
71
+ def render_directory(name, destination, override_root: nil, skip: nil)
72
+ template_files(name, override_root: override_root).sort.each do |rel, source|
73
+ next if skip&.call(rel)
74
+
75
+ target = File.join(destination, output_path(rel))
76
+ content = File.binread(source)
77
+ content = render(content, source) if source.end_with?(".tt")
78
+ write(target, content, mode: File.stat(source).mode)
79
+ end
80
+ end
81
+
82
+ def render(content, source = "(template)")
83
+ erb = ERB.new(content, trim_mode: "-")
84
+ erb.filename = source
85
+ erb.result(binding)
86
+ end
87
+
88
+ def write(path, content, mode: nil)
89
+ if File.exist?(path) && !@force
90
+ if File.binread(path) == content
91
+ status("identical", path)
92
+ else
93
+ status("skip", path, "exists — not overwritten")
94
+ end
95
+ return false
96
+ end
97
+ FileUtils.mkdir_p(File.dirname(path))
98
+ File.binwrite(path, content)
99
+ File.chmod(mode & 0o777, path) if mode
100
+ @created << path
101
+ status("create", path)
102
+ true
103
+ end
104
+
105
+ def status(label, path, note = nil)
106
+ shown = path.delete_prefix("#{Dir.pwd}/")
107
+ @output.puts(" #{label.rjust(9)} #{shown}#{" (#{note})" if note}")
108
+ end
109
+
110
+ private
111
+
112
+ def relative_files(root)
113
+ Dir.glob("**/*", File::FNM_DOTMATCH, base: root)
114
+ .reject { |rel| File.directory?(File.join(root, rel)) || File.basename(rel) == ".DS_Store" }
115
+ end
116
+
117
+ def output_path(rel)
118
+ rel.delete_suffix(".tt").split("/").map { |part| part.sub(/\Adot_/, ".") }.join("/")
119
+ end
120
+ end
121
+ end
122
+ end
@@ -0,0 +1,47 @@
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
+ template_files("job", override_root: @root).each do |rel, source|
39
+ target = File.join(@root, rel.delete_suffix(".tt").gsub("%file_name%", file_name))
40
+ write(target, render(File.read(source), source))
41
+ end
42
+ self.class.install_migration(@root, output: @output) if File.directory?(File.join(@root, "db"))
43
+ self
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,52 @@
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
@@ -0,0 +1,35 @@
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