gemstack 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 (199) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +4 -0
  3. data/README.md +7 -3
  4. data/lib/gemstack/cache/memory_store.rb +69 -0
  5. data/lib/gemstack/cache/null_store.rb +18 -0
  6. data/lib/gemstack/cache/redis_store.rb +70 -0
  7. data/lib/gemstack/cache/store.rb +89 -0
  8. data/lib/gemstack/cache.rb +61 -0
  9. data/lib/gemstack/cli/add_generator.rb +258 -0
  10. data/lib/gemstack/cli/app_generator.rb +142 -0
  11. data/lib/gemstack/cli/commands/db.rb +136 -0
  12. data/lib/gemstack/cli/commands/jobs.rb +96 -0
  13. data/lib/gemstack/cli/controller_generator.rb +73 -0
  14. data/lib/gemstack/cli/deploy_generator.rb +102 -0
  15. data/lib/gemstack/cli/doctor/upgrade_check.rb +32 -0
  16. data/lib/gemstack/cli/doctor.rb +311 -0
  17. data/lib/gemstack/cli/generator.rb +161 -0
  18. data/lib/gemstack/cli/job_generator.rb +48 -0
  19. data/lib/gemstack/cli/migration_generator.rb +52 -0
  20. data/lib/gemstack/cli/policy_generator.rb +35 -0
  21. data/lib/gemstack/cli/project.rb +53 -0
  22. data/lib/gemstack/cli/resource_generator.rb +170 -0
  23. data/lib/gemstack/cli/resource_spec.rb +95 -0
  24. data/lib/gemstack/cli.rb +304 -0
  25. data/lib/gemstack/contract/builder.rb +168 -0
  26. data/lib/gemstack/contract/docs/index.html +264 -0
  27. data/lib/gemstack/contract/docs.rb +45 -0
  28. data/lib/gemstack/contract/openapi.rb +123 -0
  29. data/lib/gemstack/contract/typescript.rb +162 -0
  30. data/lib/gemstack/contract.rb +93 -0
  31. data/lib/gemstack/core.rb +129 -0
  32. data/lib/gemstack/db/configuration.rb +148 -0
  33. data/lib/gemstack/db/errors.rb +137 -0
  34. data/lib/gemstack/db/json_compat.rb +21 -0
  35. data/lib/gemstack/db/migrator.rb +80 -0
  36. data/lib/gemstack/db/model.rb +226 -0
  37. data/lib/gemstack/db/schema_types.rb +28 -0
  38. data/lib/gemstack/db/tasks.rb +94 -0
  39. data/lib/gemstack/db/testing.rb +99 -0
  40. data/lib/gemstack/db.rb +210 -0
  41. data/lib/gemstack/dev/file_watcher.rb +58 -0
  42. data/lib/gemstack/dev/gateway.rb +220 -0
  43. data/lib/gemstack/dev/managed_process.rb +96 -0
  44. data/lib/gemstack/dev/ports.rb +26 -0
  45. data/lib/gemstack/dev/supervisor.rb +267 -0
  46. data/lib/gemstack/dev/terminal.rb +40 -0
  47. data/lib/gemstack/dev/toolchain.rb +86 -0
  48. data/lib/gemstack/dev.rb +43 -0
  49. data/lib/gemstack/dotenv.rb +61 -0
  50. data/lib/gemstack/environment.rb +37 -0
  51. data/lib/gemstack/error_mapping.rb +37 -0
  52. data/lib/gemstack/errors.rb +100 -0
  53. data/lib/gemstack/http/app.rb +30 -0
  54. data/lib/gemstack/http/config.rb +84 -0
  55. data/lib/gemstack/http/controller.rb +343 -0
  56. data/lib/gemstack/http/error_page.rb +111 -0
  57. data/lib/gemstack/http/error_renderer.rb +63 -0
  58. data/lib/gemstack/http/json_codec.rb +114 -0
  59. data/lib/gemstack/http/middleware/body_limit.rb +69 -0
  60. data/lib/gemstack/http/middleware/compression.rb +127 -0
  61. data/lib/gemstack/http/middleware/cors.rb +77 -0
  62. data/lib/gemstack/http/middleware/error_handler.rb +39 -0
  63. data/lib/gemstack/http/middleware/etags.rb +24 -0
  64. data/lib/gemstack/http/middleware/health_check.rb +28 -0
  65. data/lib/gemstack/http/middleware/request_id.rb +31 -0
  66. data/lib/gemstack/http/middleware/request_logger.rb +39 -0
  67. data/lib/gemstack/http/middleware/security_headers.rb +31 -0
  68. data/lib/gemstack/http/middleware_stack.rb +96 -0
  69. data/lib/gemstack/http/page.rb +36 -0
  70. data/lib/gemstack/http/params.rb +140 -0
  71. data/lib/gemstack/http/request.rb +64 -0
  72. data/lib/gemstack/http/router.rb +315 -0
  73. data/lib/gemstack/http.rb +41 -0
  74. data/lib/gemstack/inflector.rb +133 -0
  75. data/lib/gemstack/job.rb +154 -0
  76. data/lib/gemstack/jobs/adapters/async.rb +94 -0
  77. data/lib/gemstack/jobs/adapters/database.rb +188 -0
  78. data/lib/gemstack/jobs/adapters/inline.rb +34 -0
  79. data/lib/gemstack/jobs/adapters/sidekiq.rb +65 -0
  80. data/lib/gemstack/jobs/adapters/test.rb +59 -0
  81. data/lib/gemstack/jobs/executor.rb +70 -0
  82. data/lib/gemstack/jobs/testing.rb +55 -0
  83. data/lib/gemstack/jobs/worker.rb +137 -0
  84. data/lib/gemstack/jobs.rb +144 -0
  85. data/lib/gemstack/logger.rb +131 -0
  86. data/lib/gemstack/mail/delivery_job.rb +20 -0
  87. data/lib/gemstack/mail/testing.rb +33 -0
  88. data/lib/gemstack/mail.rb +230 -0
  89. data/lib/gemstack/plugins.rb +38 -0
  90. data/lib/gemstack/schema.rb +251 -0
  91. data/lib/gemstack/serializer.rb +186 -0
  92. data/lib/gemstack/settings.rb +86 -0
  93. data/lib/gemstack/storage/endpoint.rb +112 -0
  94. data/lib/gemstack/storage/services/disk.rb +60 -0
  95. data/lib/gemstack/storage/services/s3.rb +65 -0
  96. data/lib/gemstack/storage/testing.rb +30 -0
  97. data/lib/gemstack/storage.rb +183 -0
  98. data/lib/gemstack/types.rb +163 -0
  99. data/lib/gemstack/version.rb +6 -0
  100. data/lib/gemstack.rb +1 -1
  101. data/templates/app/Gemfile.tt +27 -0
  102. data/templates/app/README.md.tt +29 -0
  103. data/templates/app/app/controllers/application_controller.rb +6 -0
  104. data/templates/app/app/jobs/application_job.rb +9 -0
  105. data/templates/app/app/mailers/application_mailer.rb +8 -0
  106. data/templates/app/app/mailers/templates/dot_keep +0 -0
  107. data/templates/app/app/models/application_model.rb +19 -0
  108. data/templates/app/app/serializers/application_serializer.rb +6 -0
  109. data/templates/app/bin/gemstack +7 -0
  110. data/templates/app/config/app.rb.tt +60 -0
  111. data/templates/app/config/database.yml.tt +61 -0
  112. data/templates/app/config/environments/development.rb.tt +35 -0
  113. data/templates/app/config/environments/production.rb.tt +40 -0
  114. data/templates/app/config/environments/test.rb.tt +21 -0
  115. data/templates/app/config/puma.rb +21 -0
  116. data/templates/app/config/routes.rb +10 -0
  117. data/templates/app/config.ru +6 -0
  118. data/templates/app/db/migrations/dot_keep +0 -0
  119. data/templates/app/db/seeds.rb +5 -0
  120. data/templates/app/dot_env.example.tt +19 -0
  121. data/templates/app/dot_gitignore +16 -0
  122. data/templates/app/dot_node-version.tt +1 -0
  123. data/templates/app/dot_nvmrc.tt +1 -0
  124. data/templates/app/dot_ruby-version.tt +1 -0
  125. data/templates/app/dot_tool-versions.tt +4 -0
  126. data/templates/app/test/health_test.rb +18 -0
  127. data/templates/app/test/test_helper.rb.tt +25 -0
  128. data/templates/auth/app/controllers/api_tokens_controller.rb +35 -0
  129. data/templates/auth/app/controllers/email_verifications_controller.rb +31 -0
  130. data/templates/auth/app/controllers/password_resets_controller.rb +40 -0
  131. data/templates/auth/app/controllers/registrations_controller.rb +19 -0
  132. data/templates/auth/app/controllers/sessions_controller.rb +29 -0
  133. data/templates/auth/app/mailers/auth_mailer.rb +20 -0
  134. data/templates/auth/app/mailers/templates/auth_mailer/email_verification.html.erb +2 -0
  135. data/templates/auth/app/mailers/templates/auth_mailer/email_verification.text.erb +2 -0
  136. data/templates/auth/app/mailers/templates/auth_mailer/password_reset.html.erb +3 -0
  137. data/templates/auth/app/mailers/templates/auth_mailer/password_reset.text.erb +6 -0
  138. data/templates/auth/app/models/auth_token.rb +15 -0
  139. data/templates/auth/app/models/user.rb +12 -0
  140. data/templates/auth/app/policies/application_policy.rb +6 -0
  141. data/templates/auth/app/serializers/api_token_serializer.rb +6 -0
  142. data/templates/auth/app/serializers/new_api_token_serializer.rb +9 -0
  143. data/templates/auth/app/serializers/user_serializer.rb +6 -0
  144. data/templates/auth/db/migrations/%timestamp%_create_auth_tables.rb +41 -0
  145. data/templates/auth/frontend/app/account/page.tsx +116 -0
  146. data/templates/auth/frontend/app/forgot-password/page.tsx +48 -0
  147. data/templates/auth/frontend/app/login/page.tsx +26 -0
  148. data/templates/auth/frontend/app/reset-password/page.tsx +11 -0
  149. data/templates/auth/frontend/app/signup/page.tsx +27 -0
  150. data/templates/auth/frontend/app/verify-email/page.tsx +11 -0
  151. data/templates/auth/frontend/components/auth/CredentialsForm.tsx +58 -0
  152. data/templates/auth/frontend/components/auth/ResetPasswordForm.tsx +43 -0
  153. data/templates/auth/frontend/components/auth/VerifyEmail.tsx +28 -0
  154. data/templates/auth/frontend/lib/auth.ts +113 -0
  155. data/templates/auth/test/controllers/auth_test.rb +108 -0
  156. data/templates/controller/app/controllers/%file_name%_controller.rb.tt +10 -0
  157. data/templates/controller/test/controllers/%file_name%_controller_test.rb.tt +14 -0
  158. data/templates/deploy/Caddyfile.tt +19 -0
  159. data/templates/deploy/Dockerfile.tt +63 -0
  160. data/templates/deploy/Procfile.tt +6 -0
  161. data/templates/deploy/compose.yaml.tt +129 -0
  162. data/templates/deploy/dot_dockerignore +14 -0
  163. data/templates/frontend/app/globals.css +171 -0
  164. data/templates/frontend/app/layout.tsx.tt +19 -0
  165. data/templates/frontend/app/page.module.css +315 -0
  166. data/templates/frontend/app/page.tsx.tt +183 -0
  167. data/templates/frontend/app/providers.tsx +24 -0
  168. data/templates/frontend/lib/gemstack/client.ts +125 -0
  169. data/templates/frontend/next-env.d.ts +5 -0
  170. data/templates/frontend/next.config.ts +17 -0
  171. data/templates/frontend/package.json.tt +23 -0
  172. data/templates/frontend/tsconfig.json +21 -0
  173. data/templates/job/app/jobs/%file_name%.rb.tt +15 -0
  174. data/templates/job/test/jobs/%file_name%_test.rb.tt +15 -0
  175. data/templates/migration/db/migrations/%timestamp%_%file_name%.rb.tt +17 -0
  176. data/templates/policy/app/policies/%file_name%_policy.rb.tt +21 -0
  177. data/templates/policy/test/policies/%file_name%_policy_test.rb.tt +9 -0
  178. data/templates/realtime/config/channels.rb +14 -0
  179. data/templates/realtime/frontend/lib/gemstack/realtime.ts +120 -0
  180. data/templates/resource/controller/app/controllers/%plural%_controller.rb.tt +51 -0
  181. data/templates/resource/controller/test/controllers/%plural%_controller_test.rb.tt +74 -0
  182. data/templates/resource/frontend/frontend/app/%url_segment%/[id]/edit/page.tsx.tt +45 -0
  183. data/templates/resource/frontend/frontend/app/%url_segment%/[id]/page.tsx.tt +49 -0
  184. data/templates/resource/frontend/frontend/app/%url_segment%/new/page.tsx.tt +27 -0
  185. data/templates/resource/frontend/frontend/app/%url_segment%/page.tsx.tt +51 -0
  186. data/templates/resource/frontend/frontend/components/%url_segment%/%class_name%Card.tsx.tt +15 -0
  187. data/templates/resource/frontend/frontend/components/%url_segment%/%class_name%Form.tsx.tt +98 -0
  188. data/templates/resource/frontend/frontend/components/%url_segment%/%class_name%Table.tsx.tt +36 -0
  189. data/templates/resource/frontend/frontend/lib/format.ts +12 -0
  190. data/templates/resource/frontend/frontend/lib/queries/%url_segment%.ts.tt +64 -0
  191. data/templates/resource/migration/db/migrations/%timestamp%_create_%table%.rb.tt +18 -0
  192. data/templates/resource/model/app/models/%file_name%.rb.tt +15 -0
  193. data/templates/resource/model/test/models/%file_name%_test.rb.tt +29 -0
  194. data/templates/resource/serializer/app/serializers/%file_name%_serializer.rb.tt +7 -0
  195. data/templates/storage/app/controllers/uploads_controller.rb.tt +33 -0
  196. data/templates/storage/app/serializers/upload_serializer.rb +11 -0
  197. data/templates/storage/frontend/lib/upload.ts +52 -0
  198. data/templates/storage/test/controllers/uploads_test.rb.tt +30 -0
  199. metadata +251 -39
@@ -0,0 +1,142 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "gemstack/dev"
4
+
5
+ module GemStack
6
+ class CLI < Thor
7
+ # `gemstack new NAME` — a Ruby API + Next.js application containing the
8
+ # framework infrastructure and deliberately no business resources.
9
+ class AppGenerator < Generator
10
+ NAME = /\A[a-z][a-z0-9_-]*\z/
11
+ # `--database` values (Rails' names) and aliases.
12
+ DATABASES = { "sqlite3" => "sqlite3", "sqlite" => "sqlite3", "postgresql" => "postgresql",
13
+ "postgres" => "postgresql", "pg" => "postgresql", "mysql2" => "mysql2", "mysql" => "mysql2",
14
+ "trilogy" => "trilogy" }.freeze
15
+ DRIVERS = { "sqlite3" => ["sqlite3", "~> 2.0"], "postgresql" => ["pg", "~> 1.5"],
16
+ "mysql2" => ["mysql2", "~> 0.5"], "trilogy" => ["trilogy", "~> 2.9"] }.freeze
17
+
18
+ # The gems/ directory of the GemStack checkout this CLI runs from, if any.
19
+ CHECKOUT = File.expand_path("../../../..", __dir__)
20
+
21
+ attr_reader :name, :module_name, :destination, :gemstack_path, :api_path
22
+
23
+ def initialize(name, options = {}, output: $stdout)
24
+ super(output: output)
25
+ @name = File.basename(name.to_s)
26
+ @destination = File.expand_path(name.to_s)
27
+ @options = options
28
+ @module_name = Inflector.camelize(@name.tr("-", "_"))
29
+ @gemstack_path = options[:gemstack_path] ? File.expand_path(options[:gemstack_path]) : detect_checkout
30
+ @api_path = "/api"
31
+ end
32
+
33
+ def version = GemStack::VERSION
34
+
35
+ # Pins the app to the Ruby that generated it, in files every version
36
+ # manager reads (.ruby-version: rbenv, rvm, chruby, asdf, mise;
37
+ # .tool-versions: asdf, mise), and Node.js likewise (.node-version,
38
+ # .nvmrc, .tool-versions).
39
+ def ruby_version = RUBY_VERSION
40
+ def node_version = @node_version ||= Dev::Toolchain.pinned_node_version
41
+ def frontend? = !@options[:skip_frontend]
42
+ def database? = !@options[:skip_database]
43
+
44
+ # sqlite3 (default), postgresql, mysql2 or trilogy.
45
+ def database_adapter
46
+ @database_adapter ||= DATABASES.fetch(@options[:database].to_s.downcase.then do |d|
47
+ d.empty? ? "sqlite3" : d
48
+ end) do
49
+ raise Thor::Error,
50
+ "Unknown --database #{@options[:database].inspect}. Use: sqlite3, postgresql, mysql2, trilogy"
51
+ end
52
+ end
53
+
54
+ def driver_gem = DRIVERS.fetch(database_adapter).first
55
+ def driver_version = DRIVERS.fetch(database_adapter).last
56
+ def database_name = name.tr("-", "_")
57
+
58
+ def run
59
+ validate!
60
+ database_adapter if database? # fail early on an unknown --database
61
+ @output.puts("Creating GemStack application #{name} in #{destination}")
62
+ render_directory("app", destination, skip: lambda { |rel|
63
+ (!database? && (rel.start_with?("db/", "app/models/") || rel == "config/database.yml.tt")) ||
64
+ (!frontend? && %w[dot_node-version.tt dot_nvmrc.tt].include?(rel))
65
+ })
66
+ render_directory("frontend", File.join(destination, "frontend")) if frontend?
67
+ install unless @options[:skip_install]
68
+ git_init unless @options[:skip_git]
69
+ summary
70
+ self
71
+ end
72
+
73
+ private
74
+
75
+ def validate!
76
+ unless NAME.match?(name)
77
+ raise Thor::Error,
78
+ "Invalid name #{name.inspect}: use lowercase letters, digits, - and _, starting with a letter"
79
+ end
80
+ return unless File.exist?(destination) && !Dir.empty?(destination)
81
+
82
+ raise Thor::Error, "#{destination} already exists and is not empty"
83
+ end
84
+
85
+ def detect_checkout
86
+ File.file?(File.join(CHECKOUT, "gemstack", "gemstack.gemspec")) ? CHECKOUT : nil
87
+ end
88
+
89
+ def install
90
+ bundled = run_step("bundle install", destination, "bundle", "install", "--quiet")
91
+ create_database if bundled && database?
92
+ return unless frontend?
93
+
94
+ run_step("npm install", File.join(destination, "frontend"), "npm", "install", "--no-fund",
95
+ "--no-audit")
96
+ # The TypeScript contract, so frontend/lib/api/generated exists from the start.
97
+ run_step("gemstack contract", destination, "bin/gemstack", "contract", "--quiet") if bundled
98
+ end
99
+
100
+ # Best effort: a missing or password-protected database server shouldn't fail `new`.
101
+ def create_database
102
+ @output.puts(" #{"run".rjust(9)} gemstack db:create")
103
+ ok = unbundled { system("bin/gemstack", "db:create", chdir: destination, out: File::NULL, err: File::NULL) }
104
+ return if ok
105
+
106
+ @output.puts(" #{"warning".rjust(9)} couldn't create the database — " \
107
+ "check config/database.yml (or set DATABASE_URL in .env), then run `gemstack db:create`")
108
+ end
109
+
110
+ def git_init
111
+ return if File.directory?(File.join(destination, ".git")) || !system("git --version", out: File::NULL)
112
+
113
+ run_step("git init", destination, "git", "init", "--quiet")
114
+ end
115
+
116
+ def run_step(label, dir, *command)
117
+ @output.puts(" #{"run".rjust(9)} #{label}")
118
+ ok = unbundled { system(*command, chdir: dir) }
119
+ @output.puts(" #{"warning".rjust(9)} `#{label}` failed — run it manually in #{dir}") unless ok
120
+ ok
121
+ end
122
+
123
+ # Run child commands with the new app's bundle, not the CLI's.
124
+ def unbundled(&)
125
+ defined?(Bundler) ? Bundler.with_unbundled_env(&) : yield
126
+ end
127
+
128
+ def summary
129
+ @output.puts <<~TEXT
130
+
131
+ GemStack application #{name} created.
132
+
133
+ cd #{name}
134
+ gemstack dev # → http://localhost:3000
135
+
136
+ Next: gemstack generate resource Product name:string price:decimal
137
+ Useful commands: gemstack routes · gemstack test · gemstack console · gemstack db:migrate
138
+ TEXT
139
+ end
140
+ end
141
+ end
142
+ end
@@ -0,0 +1,136 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GemStack
4
+ class CLI < Thor
5
+ map "db:create" => :db_create, "db:drop" => :db_drop, "db:migrate" => :db_migrate,
6
+ "db:rollback" => :db_rollback, "db:seed" => :db_seed, "db:setup" => :db_setup,
7
+ "db:status" => :db_status, "db:reset" => :db_reset
8
+
9
+ class_option :environment, aliases: "-e", type: :string, desc: "Environment (default: GEMSTACK_ENV or development)"
10
+
11
+ desc "db:create", "Create the database (config/database.yml or DATABASE_URL)"
12
+ def db_create
13
+ with_database do
14
+ result = DB::Tasks.create
15
+ say("#{result == :created ? "Created" : "Already exists"}: #{DB::Tasks.database_name}")
16
+ end
17
+ end
18
+
19
+ desc "db:drop", "Drop the database (refused in production unless GEMSTACK_ALLOW_DB_DROP=1)"
20
+ def db_drop
21
+ with_database do
22
+ result = DB::Tasks.drop
23
+ say("#{result == :dropped ? "Dropped" : "Does not exist"}: #{DB::Tasks.database_name}")
24
+ end
25
+ end
26
+
27
+ desc "db:migrate", "Apply pending migrations (--target VERSION to migrate up or down to a version)"
28
+ method_option :target, type: :numeric
29
+ def db_migrate
30
+ with_database do
31
+ changed = DB::Migrator.new.migrate(target: options[:target])
32
+ changed.empty? ? say("No pending migrations.") : changed.sort.each { |file| say(" migrated #{file}") }
33
+ end
34
+ end
35
+
36
+ desc "db:rollback", "Revert the last migration (--steps N for more)"
37
+ method_option :steps, type: :numeric, default: 1
38
+ def db_rollback
39
+ with_database do
40
+ reverted = DB::Migrator.new.rollback(steps: options[:steps])
41
+ if reverted.empty?
42
+ say("Nothing to roll back.")
43
+ else
44
+ reverted.sort.reverse_each do |file|
45
+ say(" reverted #{file}")
46
+ end
47
+ end
48
+ end
49
+ end
50
+
51
+ desc "db:status", "List migrations and whether they are applied"
52
+ def db_status
53
+ with_database do
54
+ rows = DB::Migrator.new.status.map { |m| [m.applied ? "up" : "down", m.version.to_s, m.name] }
55
+ if rows.empty?
56
+ say("No migrations in #{DB.config.migrations_path}.")
57
+ else
58
+ print_table([%w[Status Version Name],
59
+ *rows])
60
+ end
61
+ end
62
+ end
63
+
64
+ desc "db:seed", "Load db/seeds.rb"
65
+ def db_seed
66
+ with_database(boot: true) do
67
+ DB::Tasks.seed ? say("Seeded from #{DB.config.seeds_path}.") : say("No #{DB.config.seeds_path} found.")
68
+ end
69
+ end
70
+
71
+ desc "db:setup", "Create the database, migrate and seed"
72
+ def db_setup
73
+ db_create
74
+ db_migrate
75
+ db_seed
76
+ end
77
+
78
+ desc "db:reset", "Drop, recreate, migrate and seed (development/test only)"
79
+ def db_reset
80
+ db_drop
81
+ db_setup
82
+ end
83
+
84
+ desc "contract", "Generate TypeScript types, API clients and OpenAPI from the backend"
85
+ method_option :quiet, type: :boolean, default: false
86
+ def contract
87
+ root = Project.ensure_bundle!(self.class.argv)
88
+ ENV["GEMSTACK_ENV"] = options[:environment] || ENV["GEMSTACK_ENV"] || "development"
89
+ Project.load_config!(root)
90
+ require "gemstack/contract"
91
+ GemStack.config.reload_code = false
92
+ GemStack.config.logger.level = :warn
93
+ GemStack.config.db.log_queries = false if GemStack.config.respond_to?(:db)
94
+ contract = Contract.build(GemStack.boot!)
95
+ report = Contract.write(contract, root: root, typescript: File.directory?(File.join(root, "frontend")))
96
+ print_contract_report(report, contract, root)
97
+ rescue Sequel::DatabaseConnectionError => e
98
+ abort("gemstack contract: database unreachable (#{e.message.lines.first.strip}). Models need it to load.")
99
+ end
100
+
101
+ no_commands do
102
+ def with_database(boot: false)
103
+ root = Project.ensure_bundle!(self.class.argv)
104
+ ENV["GEMSTACK_ENV"] = options[:environment] || ENV["GEMSTACK_ENV"] || "development"
105
+ Project.load_config!(root)
106
+ unless defined?(GemStack::DB)
107
+ abort("This app doesn't load the database module. Add `require \"gemstack/db\"` to config/app.rb " \
108
+ "(and a config/database.yml) — see docs/database.md.")
109
+ end
110
+ GemStack.config.reload_code = false
111
+ GemStack.config.db.log_queries = false
112
+ GemStack.config.logger.level = :warn # these commands print their own summary
113
+ GemStack.boot! if boot
114
+ yield
115
+ rescue Sequel::DatabaseConnectionError => e
116
+ settings = DB.settings
117
+ abort("Can't connect to #{DB::Configuration.describe(settings)} (from #{settings[:source]}): " \
118
+ "#{e.message.lines.first.strip}\nCheck config/database.yml or DATABASE_URL — see docs/database.md.")
119
+ rescue GemStack::Error => e
120
+ abort(e.message)
121
+ end
122
+
123
+ def redact(url) = url.sub(%r{//([^:/@]+):[^@]+@}, '//\1:***@')
124
+
125
+ def print_contract_report(report, contract, root)
126
+ rel = ->(path) { path.delete_prefix("#{root}/") }
127
+ report[:written].each { |path| say(" #{"write".rjust(9)} #{rel.call(path)}") }
128
+ report[:removed].each { |path| say(" #{"remove".rjust(9)} #{rel.call(path)}") }
129
+ contract[:warnings].each { |w| say(" #{"warning".rjust(9)} #{w}", :yellow) } unless options[:quiet]
130
+ return if options[:quiet] || !report[:written].empty? || !report[:removed].empty?
131
+
132
+ say("Contract up to date (#{contract[:resources].size} resources, #{contract[:types].size} types).")
133
+ end
134
+ end
135
+ end
136
+ end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GemStack
4
+ class CLI < Thor
5
+ map "jobs:status" => :jobs_status, "jobs:retry" => :jobs_retry, "jobs:discard" => :jobs_discard,
6
+ "jobs:install" => :jobs_install, "jobs:failed" => :jobs_failed
7
+
8
+ desc "jobs", "Run a background job worker (PostgreSQL queue)"
9
+ method_option :queues, aliases: "-q", type: :string, desc: "Comma-separated queues (default: all)"
10
+ method_option :concurrency, aliases: "-c", type: :numeric,
11
+ desc: "Worker threads (default: GEMSTACK_JOB_CONCURRENCY or 5)"
12
+ def jobs
13
+ with_jobs do |config|
14
+ config.jobs.queues = options[:queues].split(",").map(&:strip) if options[:queues]
15
+ config.jobs.concurrency = options[:concurrency] if options[:concurrency]
16
+ # Each worker thread holds a connection, plus LISTEN and the reaper.
17
+ config.db.pool_size = [config.db.pool_size, config.jobs.concurrency + 2].max
18
+ GemStack.boot!
19
+ GemStack.application.eager_load!
20
+ require "gemstack/jobs/worker"
21
+ Jobs::Worker.new.run
22
+ end
23
+ end
24
+
25
+ desc "jobs:status", "Show queued, scheduled, running and failed jobs per queue"
26
+ def jobs_status
27
+ with_jobs(quiet: true) do
28
+ stats = Jobs::Adapters::Database.new.stats
29
+ return say("No jobs.") if stats.empty?
30
+
31
+ rows = stats.sort.map { |queue, s| [queue, s[:ready], s[:scheduled], s[:running], s[:failed]].map(&:to_s) }
32
+ print_table([%w[Queue Ready Scheduled Running Failed], *rows])
33
+ end
34
+ end
35
+
36
+ desc "jobs:failed", "List recently failed jobs"
37
+ method_option :limit, type: :numeric, default: 20
38
+ def jobs_failed
39
+ with_jobs(quiet: true) do
40
+ failed = Jobs::Adapters::Database.new.failed(limit: options[:limit])
41
+ return say("No failed jobs.") if failed.empty?
42
+
43
+ failed.each do |job|
44
+ say("##{job[:id]} #{job[:job_class]} (#{job[:queue]}) " \
45
+ "attempts=#{job[:attempts]} failed_at=#{job[:failed_at]}")
46
+ say(" #{job[:last_error].to_s.lines.first&.strip}")
47
+ end
48
+ end
49
+ end
50
+
51
+ desc "jobs:retry [IDS...]", "Put failed jobs back on the queue (all failed jobs without IDS)"
52
+ def jobs_retry(*ids)
53
+ with_jobs(quiet: true) do
54
+ count = Jobs::Adapters::Database.new.retry_failed(ids.empty? ? nil : ids.map(&:to_i))
55
+ say("Re-queued #{count} job(s).")
56
+ end
57
+ end
58
+
59
+ desc "jobs:discard [IDS...]", "Delete failed jobs (all failed jobs without IDS)"
60
+ def jobs_discard(*ids)
61
+ with_jobs(quiet: true) do
62
+ count = Jobs::Adapters::Database.new.discard_failed(ids.empty? ? nil : ids.map(&:to_i))
63
+ say("Deleted #{count} failed job(s).")
64
+ end
65
+ end
66
+
67
+ desc "jobs:install", "Add the gemstack_jobs table migration (done automatically by `generate job`)"
68
+ def jobs_install
69
+ JobGenerator.install_migration(Project.root!, output: $stdout)
70
+ end
71
+
72
+ no_commands do
73
+ def with_jobs(quiet: false)
74
+ root = Project.ensure_bundle!(self.class.argv)
75
+ ENV["GEMSTACK_ENV"] = options[:environment] || ENV["GEMSTACK_ENV"] || "development"
76
+ Project.load_config!(root)
77
+ unless defined?(GemStack::Jobs)
78
+ abort('This app doesn\'t load background jobs. Add `require "gemstack/jobs"` to config/app.rb.')
79
+ end
80
+ config = GemStack.config
81
+ config.reload_code = false
82
+ if quiet
83
+ config.logger.level = :warn
84
+ config.db.log_queries = false if config.respond_to?(:db)
85
+ end
86
+ yield config
87
+ rescue Sequel::DatabaseError => e
88
+ raise unless e.message.include?("gemstack_jobs")
89
+
90
+ abort("The gemstack_jobs table doesn't exist yet. Run `gemstack jobs:install && gemstack db:migrate`.")
91
+ rescue Sequel::DatabaseConnectionError => e
92
+ abort("Can't connect to the database: #{e.message.lines.first.strip}")
93
+ end
94
+ end
95
+ end
96
+ end
@@ -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,102 @@
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. 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 Generator.uses?(@root, :auth)
20
+
21
+ Generator.uses?(@root, :jobs) && Generator.background_work?(@root)
22
+ end
23
+
24
+ def mail? = Generator.uses?(@root, :mail) || Generator.uses?(@root, :auth)
25
+ def database? = Generator.uses?(@root, :db)
26
+ def realtime? = Generator.uses?(@root, :realtime)
27
+
28
+ # From config/database.yml (production, else the first adapter listed);
29
+ # apps without one use PostgreSQL, GemStack's original default.
30
+ def database_adapter
31
+ path = File.join(@root, "config/database.yml")
32
+ return "postgresql" unless File.file?(path)
33
+
34
+ text = File.read(path)
35
+ production = text[/^production:.*?(?=^\S|\z)/m].to_s
36
+ (production[/^\s+adapter:\s*(\w+)/, 1] || text[/^\s+adapter:\s*(\w+)/, 1] || "postgresql")
37
+ .then { |name| { "postgres" => "postgresql", "sqlite" => "sqlite3", "mysql" => "mysql2" }.fetch(name, name) }
38
+ end
39
+
40
+ def sqlite? = database_adapter == "sqlite3"
41
+ def mysql? = %w[mysql2 trilogy].include?(database_adapter)
42
+ def postgresql? = database_adapter == "postgresql"
43
+ # A Redis for realtime fan-out when the database can't do it (PostgreSQL uses NOTIFY).
44
+ def redis? = realtime? && !postgresql?
45
+
46
+ def build_packages
47
+ (%w[build-essential libyaml-dev
48
+ git] + { "postgresql" => ["libpq-dev"], "mysql2" => ["default-libmysqlclient-dev"] }.fetch(database_adapter,
49
+ [])).join(" ")
50
+ end
51
+
52
+ def runtime_packages
53
+ (%w[libyaml-0-2
54
+ curl] + { "postgresql" => ["libpq5"], "mysql2" => ["libmariadb3"] }.fetch(database_adapter, [])).join(" ")
55
+ end
56
+
57
+ def database_url
58
+ case database_adapter
59
+ when "sqlite3" then "sqlite3:/data/production.sqlite3"
60
+ when "postgresql" then "postgres://app:${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}@db:5432/app"
61
+ else "#{database_adapter}://app:${MYSQL_PASSWORD:?set MYSQL_PASSWORD}@db:3306/app"
62
+ end
63
+ end
64
+
65
+ def ruby_version
66
+ pinned = File.file?(File.join(@root, ".ruby-version")) && File.read(File.join(@root, ".ruby-version")).strip
67
+ pinned && !pinned.empty? ? pinned.delete_prefix("ruby-") : RUBY_VERSION
68
+ end
69
+
70
+ def node_version
71
+ tools = File.join(@root, ".tool-versions")
72
+ pinned = File.file?(tools) && File.read(tools)[/^nodejs\s+(\d+)/, 1]
73
+ pinned || "22"
74
+ end
75
+
76
+ def run
77
+ template_files("deploy", override_root: @root).sort.each do |rel, source|
78
+ content = File.read(source)
79
+ content = render(content, source) if rel.end_with?(".tt")
80
+ write(File.join(@root, output_path(rel.delete_suffix(".tt"))), content)
81
+ end
82
+ keep = File.join(@root, "vendor/.keep")
83
+ write(keep, "") unless File.exist?(keep) # the Dockerfile copies vendor/ (e.g. vendor/cache)
84
+ warn_about_local_gems
85
+ self
86
+ end
87
+
88
+ private
89
+
90
+ def gemfile = @gemfile ||= File.read(File.join(@root, "Gemfile"))
91
+
92
+ # Apps generated from a GemStack checkout point at it with an absolute
93
+ # path, which doesn't exist inside the image.
94
+ def warn_about_local_gems
95
+ return unless gemfile.match?(/^path "/)
96
+
97
+ status("warning", "Gemfile", "GemStack comes from a local path the image can't see — " \
98
+ "run `bundle cache --all` (vendor/cache) or use released gems")
99
+ end
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GemStack
4
+ class CLI < Thor
5
+ class Doctor
6
+ # `gemstack doctor` for apps generated before 0.3.0, when every module
7
+ # was its own gem: says exactly what to change to finish upgrading.
8
+ module UpgradeCheck
9
+ # Gems merged into gemstack in 0.3.0; their names still install (as shims).
10
+ RETIRED_GEMS = %w[core cache schema http db jobs mail storage contract dev].freeze
11
+ LOADED_BY_REQUIRE = %w[db jobs mail storage].freeze
12
+
13
+ # Apps generated before 0.3.0 list the retired gems in the Gemfile.
14
+ def check_gemstack_upgrade
15
+ gemfile = read("Gemfile").to_s
16
+ retired = RETIRED_GEMS.select { |name| gemfile.match?(/^\s*gem "gemstack-#{name}"/) }
17
+ return if retired.empty?
18
+
19
+ app_rb = read("config/app.rb").to_s
20
+ missing = (retired & LOADED_BY_REQUIRE).reject { |name| app_rb.include?(%(require "gemstack/#{name}")) }
21
+ gems = retired.map { |name| %(gem "gemstack-#{name}") }
22
+ requires = missing.map { |name| %(require "gemstack/#{name}") }
23
+ steps = []
24
+ steps << "remove #{gems.join(", ")} from the Gemfile" if gems.any?
25
+ steps << "add #{requires.join(", ")} to config/app.rb" if requires.any?
26
+ caution("set up for GemStack before 0.3",
27
+ "#{steps.join("; ")}, then bundle install (CHANGELOG: upgrading to 0.3)")
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end