gemstack 0.3.4 → 0.3.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3aad267b7791a6ed292930136454e90238f3ccacb61ac93fcf9e9cbd4d13a1e2
4
- data.tar.gz: d2a196ec3a4cf3fc652a7f8eba101ed02b48ddba87d53ed079835680ba65f890
3
+ metadata.gz: 30c68d4db704acae059c66f9847b0158ad178ebd7da1368c37056e5b93232306
4
+ data.tar.gz: 2b65b33d1e415354038e4f4bac03556196af8d95fc01f144c7a53e97d95527b1
5
5
  SHA512:
6
- metadata.gz: cab7c1fa05b4d071574044a98df461ccfdcb3050df12927ebf780c5b45a8b3eaa649b1fbb089e217986efc86b5aa35131dcfea4bbf5ca4491f6c17ec65f991bd
7
- data.tar.gz: 4dd5583fd43c3b2f750748e8d754c313d10089eeab9ab0ad0245999a4e7790a9db58eb151065414401505ba243ea060074712d17ac36a146cd4976089fffd393
6
+ metadata.gz: 9939a8e3bd4fb59fc2794ae86b66fc05751f8aa6463a4cd637cc8cd575ba300c2d824c02a4a245cda0a3b666e0107ab383f7e5a16499dffafda152e433b02448
7
+ data.tar.gz: 389c35595446d6c96ac811be7a978e019bb310e7cb03570b7e04033e1f2a3fda682cbecb1b1e513409933b18b5d6322375175ddc324ec23270a6a78308676af7
data/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.5
4
+
5
+ See the [GemStack changelog](https://github.com/gemstack-rb/gemstack/blob/main/CHANGELOG.md).
6
+
7
+ ## 0.3.4
8
+
9
+ See the [GemStack changelog](https://github.com/gemstack-rb/gemstack/blob/main/CHANGELOG.md).
10
+
3
11
  ## 0.3.3
4
12
 
5
13
  See the [GemStack changelog](https://github.com/gemstack-rb/gemstack/blob/main/CHANGELOG.md).
@@ -2,16 +2,25 @@
2
2
 
3
3
  module GemStack
4
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.
5
+ # `gemstack generate deploy` — a Kamal setup: one Dockerfile image for every
6
+ # process, config/deploy.yml (web, api and jobs roles; kamal-proxy routes /api
7
+ # to Ruby and handles HTTPS; database accessories), .kamal/secrets,
8
+ # bin/docker-entrypoint and .dockerignore. Nothing is deployed anywhere.
8
9
  class DeployGenerator < Generator
9
10
  def initialize(root:, output: $stdout, force: false)
10
11
  super(output: output, force: force)
11
12
  @root = root
12
13
  end
13
14
 
15
+ KAMAL_GEM = %(gem "kamal", require: false, group: :development # bundle exec kamal deploy (config/deploy.yml)\n)
16
+ # The image runs Next.js's standalone server (node frontend/server.js); the
17
+ # Dockerfile asks for that build with GEMSTACK_NEXT_OUTPUT=standalone.
18
+ STANDALONE = %( output: process.env.GEMSTACK_NEXT_OUTPUT === "standalone" ? "standalone" : undefined,\n)
19
+
14
20
  def app_name = File.basename(@root).downcase.gsub(/[^a-z0-9_-]/, "-")
21
+ # Kamal service and container names (also DNS names on the server).
22
+ def service = app_name.tr("_", "-")
23
+ def database_name = "#{app_name.tr("-", "_")}_production"
15
24
  def frontend? = File.file?(File.join(@root, "frontend/package.json"))
16
25
 
17
26
  # A worker process is needed for background jobs (and auth's emails).
@@ -50,16 +59,10 @@ module GemStack
50
59
  end
51
60
 
52
61
  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
62
+ packages = %w[libyaml-0-2 curl] +
63
+ { "postgresql" => ["libpq5"], "mysql2" => ["libmariadb3"] }.fetch(database_adapter, [])
64
+ packages << "libstdc++6" if frontend? # Node.js
65
+ packages.join(" ")
63
66
  end
64
67
 
65
68
  def ruby_version
@@ -77,10 +80,12 @@ module GemStack
77
80
  template_files("deploy", override_root: @root).sort.each do |rel, source|
78
81
  content = File.read(source)
79
82
  content = render(content, source) if rel.end_with?(".tt")
80
- write(File.join(@root, output_path(rel.delete_suffix(".tt"))), content)
83
+ write(File.join(@root, output_path(rel.delete_suffix(".tt"))), content, mode: File.stat(source).mode)
81
84
  end
82
85
  keep = File.join(@root, "vendor/.keep")
83
86
  write(keep, "") unless File.exist?(keep) # the Dockerfile copies vendor/ (e.g. vendor/cache)
87
+ add_kamal_gem
88
+ enable_standalone_frontend if frontend?
84
89
  warn_about_local_gems
85
90
  self
86
91
  end
@@ -89,6 +94,30 @@ module GemStack
89
94
 
90
95
  def gemfile = @gemfile ||= File.read(File.join(@root, "Gemfile"))
91
96
 
97
+ def add_kamal_gem
98
+ path = File.join(@root, "Gemfile")
99
+ return status("identical", path, "kamal") if gemfile.match?(/^\s*gem ["']kamal["']/)
100
+
101
+ File.write(path, "#{gemfile.chomp}\n\n#{KAMAL_GEM}")
102
+ status("gem", path, "kamal — run bundle install")
103
+ end
104
+
105
+ def enable_standalone_frontend
106
+ path = File.join(@root, "frontend/next.config.ts")
107
+ return unless File.file?(path)
108
+
109
+ config = File.read(path)
110
+ return status("identical", path, "standalone output") if config.include?("GEMSTACK_NEXT_OUTPUT")
111
+
112
+ updated = config.sub(/^const nextConfig: NextConfig = \{\n/) { |open| open + STANDALONE }
113
+ if updated == config
114
+ return status("warning", path, "add `output: \"standalone\"` for the Docker build (docs/deployment.md)")
115
+ end
116
+
117
+ File.write(path, updated)
118
+ status("update", path, "standalone output for the Docker build")
119
+ end
120
+
92
121
  # Apps generated from a GemStack checkout point at it with an absolute
93
122
  # path, which doesn't exist inside the image.
94
123
  def warn_about_local_gems
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GemStack
4
+ class CLI < Thor
5
+ class Doctor
6
+ # Secrets must never be committed: no secret files tracked by git, and
7
+ # .kamal/secrets (which is committed) may only reference secrets
8
+ # ($NAME, $(kamal secrets fetch …)); a value written in place is a leak.
9
+ module SecretChecks
10
+ def check_git_secrets
11
+ return unless File.directory?(path(".git"))
12
+
13
+ out, status = @run.call("git", "-C", @root, "ls-files", "--", ".env", ".env.*", "*.pem", "*.key",
14
+ "tmp/*_secret")
15
+ return unless status.success?
16
+
17
+ tracked = out.lines.map(&:strip).reject { |file| file.empty? || file.end_with?(".example") }
18
+ if tracked.empty?
19
+ pass("no secret files tracked by git")
20
+ else
21
+ problem("secret files tracked by git: #{tracked.join(", ")}",
22
+ "remove them from git (git rm --cached …), rotate every secret they contain, and ask whoever " \
23
+ "manages your infrastructure to check where the repository was shared")
24
+ end
25
+ end
26
+
27
+ def check_kamal_secrets
28
+ content = read(".kamal/secrets")
29
+ return unless content
30
+
31
+ literal = content.lines.filter_map do |line|
32
+ name, value = line.strip.split("=", 2)
33
+ next if name.nil? || name.start_with?("#") || value.nil?
34
+
35
+ value = value.strip.delete_prefix('"').delete_suffix('"').delete_prefix("'").delete_suffix("'")
36
+ name unless value.empty? || value.include?("$")
37
+ end
38
+ if literal.empty?
39
+ pass(".kamal/secrets only references secrets")
40
+ else
41
+ problem(".kamal/secrets contains secret values: #{literal.join(", ")}",
42
+ "replace each with a reference (NAME=$NAME, or $(kamal secrets fetch …)), rotate those " \
43
+ "secrets, and ask whoever manages your infrastructure to check where the repository was shared")
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
@@ -4,6 +4,7 @@ require "open3"
4
4
  require "socket"
5
5
  require "gemstack/dev"
6
6
  require_relative "doctor/upgrade_check"
7
+ require_relative "doctor/secret_checks"
7
8
 
8
9
  module GemStack
9
10
  class CLI < Thor
@@ -13,6 +14,7 @@ module GemStack
13
14
  # deploy needs.
14
15
  class Doctor
15
16
  include UpgradeCheck
17
+ include SecretChecks
16
18
 
17
19
  Result = Struct.new(:status, :title, :hint)
18
20
 
@@ -33,6 +35,7 @@ module GemStack
33
35
  check_node
34
36
  check_frontend_dependencies
35
37
  check_git_secrets
38
+ check_kamal_secrets
36
39
  check_gemstack_upgrade
37
40
  booted = check_boot
38
41
  if booted
@@ -98,23 +101,6 @@ module GemStack
98
101
  end
99
102
 
100
103
  # Secrets committed to git stay in its history (and in every clone).
101
- def check_git_secrets
102
- return unless File.directory?(path(".git"))
103
-
104
- out, status = @run.call("git", "-C", @root, "ls-files", "--", ".env", ".env.*", "*.pem", "*.key",
105
- "tmp/*_secret")
106
- return unless status.success?
107
-
108
- tracked = out.lines.map(&:strip).reject { |file| file.empty? || file.end_with?(".example") }
109
- if tracked.empty?
110
- pass("no secret files tracked by git")
111
- else
112
- problem("secret files tracked by git: #{tracked.join(", ")}",
113
- "remove them from git (git rm --cached …), rotate every secret they contain, and ask whoever " \
114
- "manages your infrastructure to check where the repository was shared")
115
- end
116
- end
117
-
118
104
  def check_boot
119
105
  ENV["GEMSTACK_ENV"] = "production" if @production
120
106
  ENV["GEMSTACK_ENV"] ||= "development"
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GemStack
4
+ class CLI < Thor
5
+ # The `generate` command's helpers (kept out of cli.rb for length); nested in
6
+ # CLI so the generator classes (ResourceSpec, ResourceGenerator, …) resolve.
7
+ module Helpers
8
+ # -e ENV, else GEMSTACK_ENV, else development.
9
+ def use_environment!
10
+ ENV["GEMSTACK_ENV"] = options[:environment] || ENV["GEMSTACK_ENV"] || "development"
11
+ end
12
+
13
+ def generate_model(root, name, args)
14
+ abort("Usage: gemstack generate model NAME field:type ...") unless name && !args.empty?
15
+ spec = ResourceSpec.new(name, args)
16
+ ResourceGenerator.new(
17
+ spec,
18
+ root: root,
19
+ parts: %i[migration model serializer],
20
+ tests: !options[:skip_tests],
21
+ force: options[:force]
22
+ ).run
23
+ say("\nNext: gemstack db:migrate")
24
+ end
25
+
26
+ def generate_job(root, name, args)
27
+ abort("Usage: gemstack generate job NAME [QUEUE]") unless name
28
+ JobGenerator.new(name, queue: args.first, root: root, force: options[:force]).run
29
+ say("\nNext: gemstack db:migrate (first job only) · #{Inflector.camelize(name)}.perform_later(...)")
30
+ end
31
+
32
+ def generate_policy(root, name)
33
+ abort("Usage: gemstack generate policy MODEL") unless name
34
+ PolicyGenerator.new(name, root: root, force: options[:force]).run
35
+ say("\nNext: authorize!(record) and policy_scope(#{Inflector.camelize(name)}) in controllers")
36
+ end
37
+
38
+ def generate_resource(root, name, args)
39
+ abort("Usage: gemstack generate resource NAME field:type ...") unless name
40
+ abort("--api-only and --frontend-only can't be combined") if options[:api_only] && options[:frontend_only]
41
+ interactive = args.empty? && $stdin.tty?
42
+ args = ask("Fields (e.g. name:string price:decimal description:text:optional):").split if interactive
43
+ abort("Give at least one field, e.g. `gemstack generate resource #{name} name:string`") if args.empty?
44
+
45
+ spec = ResourceSpec.new(name, args, actions: resource_actions(interactive))
46
+ parts = resource_parts(root, interactive)
47
+ tests = !options[:skip_tests] && (!interactive || yes?("Generate tests? [Y/n]"))
48
+ ResourceGenerator.new(spec, root: root, parts: parts, tests: tests, force: options[:force]).run
49
+ refresh_contract(root) unless options[:skip_contract]
50
+ page = " · open http://localhost:3000/#{spec.url_segment}" if parts.include?(:frontend)
51
+ say("\nNext: gemstack db:migrate#{page}")
52
+ end
53
+
54
+ def resource_parts(root, interactive)
55
+ parts = options[:frontend_only] ? [] : %i[migration model serializer controller]
56
+ frontend = File.directory?(File.join(root, "frontend")) && !options[:api_only]
57
+ frontend &&= yes?("Generate Next.js pages? [Y/n]") if interactive
58
+ frontend ? parts + [:frontend] : parts
59
+ end
60
+
61
+ def resource_actions(interactive)
62
+ return options[:actions].split(",").map(&:strip) if options[:actions]
63
+ return ResourceSpec::REST_ACTIONS if !interactive || yes?("Generate the full CRUD API? [Y/n]")
64
+
65
+ ask("Actions (comma-separated from #{ResourceSpec::REST_ACTIONS.join(",")}):").split(",").map(&:strip)
66
+ end
67
+
68
+ # Thor's yes? only accepts y/yes; treat an empty answer as "yes".
69
+ def yes?(question)
70
+ answer = ask(question).strip.downcase
71
+ answer.empty? || %w[y yes].include?(answer)
72
+ end
73
+
74
+ # Regenerates TypeScript types/clients in the app's own bundle.
75
+ def refresh_contract(root)
76
+ say(" #{"run".rjust(9)} gemstack contract")
77
+ ok = (defined?(Bundler) ? Bundler.with_unbundled_env { contract_command(root) } : contract_command(root))
78
+ return if ok
79
+
80
+ say(" #{"warning".rjust(9)} contract not generated — run `gemstack contract` once the database is reachable")
81
+ end
82
+
83
+ def contract_command(root)
84
+ system("bundle", "exec", "gemstack", "contract", "--quiet", chdir: root)
85
+ end
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler"
4
+ require "json"
5
+ require "net/http"
6
+
7
+ module GemStack
8
+ class CLI < Thor
9
+ # `gemstack update [VERSION]` — moves an app to the latest GemStack release
10
+ # (or VERSION): rewrites every GemStack gem's constraint in the Gemfile to
11
+ # "~> VERSION" and runs `bundle update` for them, together (the gems pin each
12
+ # other's exact version). Apps that use a GemStack checkout (`path "…"`)
13
+ # update the checkout instead.
14
+ class UpdateGenerator
15
+ # GemStack's gems, as an app's Gemfile lists them (gemstack-cli comes with gemstack).
16
+ GEMS = %w[gemstack gemstack-auth gemstack-realtime].freeze
17
+ VERSION_FORMAT = /\A\d+\.\d+\.\d+\z/
18
+ LATEST_URL = "https://rubygems.org/api/v1/versions/gemstack/latest.json"
19
+
20
+ def initialize(root:, version: nil, output: $stdout, latest: nil, bundle: nil)
21
+ @root = root
22
+ @version = version
23
+ @output = output
24
+ @latest = latest || -> { latest_release }
25
+ @bundle = bundle || ->(gems) { Bundler.with_unbundled_env { system("bundle", "update", *gems, chdir: @root) } }
26
+ end
27
+
28
+ def run
29
+ gemfile = read("Gemfile") or return failure("no Gemfile in #{@root}")
30
+ return checkout(gemfile) if gemfile.match?(/^\s*path\s+["'][^"']+["']\s+do\b/)
31
+
32
+ listed = GEMS.select { |name| gemfile.match?(gem_line(name)) }
33
+ return failure(%(no `gem "gemstack"` in the Gemfile)) unless listed.include?("gemstack")
34
+
35
+ target = @version || @latest.call
36
+ return failure("#{target.inspect} is not a version (e.g. 0.3.5)") unless target.to_s.match?(VERSION_FORMAT)
37
+
38
+ current = locked_version
39
+ if current == target
40
+ @output.puts("GemStack is already #{target}.")
41
+ return true
42
+ end
43
+ if current && Gem::Version.new(target) < Gem::Version.new(current) && !@version
44
+ @output.puts("GemStack #{current} is newer than the latest release (#{target}); nothing to do.")
45
+ return true
46
+ end
47
+
48
+ update(gemfile, listed, current, target)
49
+ end
50
+
51
+ private
52
+
53
+ def update(gemfile, listed, current, target)
54
+ @output.puts("Updating GemStack #{current || "(not installed yet)"} → #{target}")
55
+ updated = listed.reduce(gemfile) { |text, name| constrain(text, name, target) }
56
+ File.write(File.join(@root, "Gemfile"), updated)
57
+ listed.each { |name| @output.puts(%( Gemfile gem "#{name}", "~> #{target}")) }
58
+
59
+ gems = (listed + ["gemstack-cli"]).uniq
60
+ @output.puts(" run bundle update #{gems.join(" ")}")
61
+ bundled = begin
62
+ @bundle.call(gems)
63
+ rescue StandardError
64
+ File.write(File.join(@root, "Gemfile"), gemfile) # never leave a half-done update behind
65
+ raise
66
+ end
67
+ return bundle_failed(gemfile) unless bundled
68
+
69
+ installed = locked_version
70
+ unless installed == target
71
+ return failure("bundle update finished but Gemfile.lock has gemstack #{installed.inspect}, not #{target}")
72
+ end
73
+
74
+ @output.puts(<<~DONE)
75
+
76
+ GemStack #{target} is installed. Next:
77
+ - read what changed: https://github.com/gemstack-rb/gemstack/blob/main/CHANGELOG.md
78
+ - gemstack doctor
79
+ - gemstack test
80
+ DONE
81
+ true
82
+ end
83
+
84
+ # `gem "name"` with any (or no) version requirement → `gem "name", "~> target"`,
85
+ # keeping other options (require:, group:) and trailing comments.
86
+ def constrain(text, name, target)
87
+ text.gsub(/^(\s*gem\s+["']#{Regexp.escape(name)}["'])((?:\s*,\s*["'][^"']*["'])*)/) do
88
+ %(#{Regexp.last_match(1)}, "~> #{target}")
89
+ end
90
+ end
91
+
92
+ def gem_line(name) = /^\s*gem\s+["']#{Regexp.escape(name)}["']/
93
+
94
+ def checkout(gemfile)
95
+ path = gemfile[/^\s*path\s+["']([^"']+)["']/, 1]
96
+ @output.puts("This app uses GemStack from a checkout (#{path}): update it there, e.g.")
97
+ @output.puts(" git -C #{path.delete_suffix("/gems")} pull")
98
+ true
99
+ end
100
+
101
+ def bundle_failed(original)
102
+ File.write(File.join(@root, "Gemfile"), original)
103
+ failure("bundle update failed — the Gemfile is unchanged; see Bundler's message above")
104
+ end
105
+
106
+ def locked_version
107
+ read("Gemfile.lock")&.[](/^ gemstack \(([^)]+)\)/, 1)
108
+ end
109
+
110
+ def latest_release
111
+ response = Net::HTTP.get_response(URI(LATEST_URL))
112
+ raise Thor::Error, "rubygems.org answered #{response.code}" unless response.is_a?(Net::HTTPSuccess)
113
+
114
+ JSON.parse(response.body).fetch("version")
115
+ rescue SocketError, SystemCallError, Timeout::Error, JSON::ParserError => e
116
+ raise Thor::Error, "Couldn't look up the latest GemStack on rubygems.org (#{e.class}). " \
117
+ "Pass a version: gemstack update 0.3.5"
118
+ end
119
+
120
+ def read(relative)
121
+ path = File.join(@root, relative)
122
+ File.read(path) if File.file?(path)
123
+ end
124
+
125
+ def failure(message)
126
+ @output.puts("✗ #{message}")
127
+ false
128
+ end
129
+ end
130
+ end
131
+ end
data/lib/gemstack/cli.rb CHANGED
@@ -24,9 +24,11 @@ module GemStack
24
24
  require_relative "cli/doctor"
25
25
  require_relative "cli/deploy_generator"
26
26
  require_relative "cli/add_generator"
27
+ require_relative "cli/update_generator"
27
28
  require_relative "cli/commands/db"
28
29
  require_relative "cli/commands/jobs"
29
30
  require_relative "cli/console_methods"
31
+ require_relative "cli/helpers"
30
32
 
31
33
  # Commands that need the application's bundle (see Project.ensure_bundle!).
32
34
  class << self
@@ -51,7 +53,6 @@ module GemStack
51
53
  desc "new NAME", "Create a new GemStack application (Ruby API + Next.js frontend)"
52
54
  long_desc <<~DESC
53
55
  Creates NAME/ with a Ruby API, a Next.js + TypeScript frontend and no business resources.
54
-
55
56
  Then: cd NAME && gemstack dev
56
57
  DESC
57
58
  method_option :skip_install, type: :boolean, default: false, desc: "Don't run bundle install / npm install"
@@ -153,7 +154,7 @@ module GemStack
153
154
  app/policies/order_policy.rb + test (needs gemstack add auth)
154
155
 
155
156
  gemstack generate deploy
156
- Dockerfile (api + web images), compose.yaml, Caddyfile, Procfile, .dockerignore
157
+ Kamal: Dockerfile, config/deploy.yml, .kamal/secrets, bin/docker-entrypoint, .dockerignore
157
158
 
158
159
  Field syntax: name:type[:optional][:unique][:index]. Types: #{ResourceSpec::TYPES.join(", ")}.
159
160
  Fields are required unless marked :optional.
@@ -180,7 +181,8 @@ module GemStack
180
181
  when "policy" then generate_policy(root, name)
181
182
  when "deploy"
182
183
  DeployGenerator.new(root: root, force: options[:force]).run
183
- say("\nNext: docker compose up --build (see compose.yaml) · recipes: docs/deployment.md")
184
+ say("\nNext: bundle install, edit the CHANGE lines in config/deploy.yml, then " \
185
+ "bundle exec kamal setup — docs/deployment.md")
184
186
  when nil then abort("Usage: gemstack generate GENERATOR NAME. Generators: #{GENERATORS}")
185
187
  else abort("Unknown generator #{generator.inspect}. Available: #{GENERATORS}")
186
188
  end
@@ -196,12 +198,10 @@ module GemStack
196
198
  long_desc <<~DESC
197
199
  gemstack add realtime
198
200
  Adds gemstack-realtime, config/channels.rb, frontend/lib/gemstack/realtime.ts and test helpers.
199
-
200
201
  gemstack add auth
201
202
  Sign up, log in/out, password reset and email verification, API tokens and policies:
202
203
  migrations (users, sessions, auth_tokens), User model, controllers, routes, AuthMailer,
203
204
  tests and Next.js pages (/login, /signup, /forgot-password, /reset-password, /verify-email, /account).
204
-
205
205
  gemstack add storage
206
206
  File uploads straight from the browser to disk (development) or S3: config, uploads
207
207
  controller, routes and frontend/lib/upload.ts.
@@ -219,8 +219,7 @@ module GemStack
219
219
  desc "doctor", "Check that this app can run (Ruby, Node, database, migrations, contract…) and how to fix it"
220
220
  long_desc <<~DESC
221
221
  gemstack doctor checks for development
222
- gemstack doctor --production also checks the settings a deploy needs (run it with the production
223
- environment variables: SECRET_KEY_BASE, DATABASE_URL, SMTP_URL…)
222
+ gemstack doctor --production also checks the settings a deploy needs (run it with the production environment variables: SECRET_KEY_BASE, DATABASE_URL, SMTP_URL…)
224
223
  DESC
225
224
  method_option :production, type: :boolean, default: false
226
225
  def doctor
@@ -231,78 +230,19 @@ module GemStack
231
230
  desc "version", "Print the GemStack version"
232
231
  def version = say("GemStack #{GemStack::VERSION}")
233
232
 
234
- no_commands do
235
- # -e ENV, else GEMSTACK_ENV, else development.
236
- def use_environment! = ENV["GEMSTACK_ENV"] = options[:environment] || ENV["GEMSTACK_ENV"] || "development"
237
-
238
- def generate_model(root, name, args)
239
- abort("Usage: gemstack generate model NAME field:type ...") unless name && !args.empty?
240
- spec = ResourceSpec.new(name, args)
241
- ResourceGenerator.new(spec, root: root, parts: %i[migration model serializer], tests: !options[:skip_tests],
242
- force: options[:force]).run
243
- say("\nNext: gemstack db:migrate")
244
- end
245
-
246
- def generate_job(root, name, args)
247
- abort("Usage: gemstack generate job NAME [QUEUE]") unless name
248
- JobGenerator.new(name, queue: args.first, root: root, force: options[:force]).run
249
- say("\nNext: gemstack db:migrate (first job only) · #{Inflector.camelize(name)}.perform_later(...)")
250
- end
251
-
252
- def generate_policy(root, name)
253
- abort("Usage: gemstack generate policy MODEL") unless name
254
- PolicyGenerator.new(name, root: root, force: options[:force]).run
255
- say("\nNext: authorize!(record) and policy_scope(#{Inflector.camelize(name)}) in controllers")
256
- end
257
-
258
- def generate_resource(root, name, args)
259
- abort("Usage: gemstack generate resource NAME field:type ...") unless name
260
- abort("--api-only and --frontend-only can't be combined") if options[:api_only] && options[:frontend_only]
261
- interactive = args.empty? && $stdin.tty?
262
- args = ask("Fields (e.g. name:string price:decimal description:text:optional):").split if interactive
263
- abort("Give at least one field, e.g. `gemstack generate resource #{name} name:string`") if args.empty?
264
-
265
- spec = ResourceSpec.new(name, args, actions: resource_actions(interactive))
266
- parts = resource_parts(root, interactive)
267
- tests = !options[:skip_tests] && (!interactive || yes?("Generate tests? [Y/n]"))
268
- ResourceGenerator.new(spec, root: root, parts: parts, tests: tests, force: options[:force]).run
269
- refresh_contract(root) unless options[:skip_contract]
270
- page = " · open http://localhost:3000/#{spec.url_segment}" if parts.include?(:frontend)
271
- say("\nNext: gemstack db:migrate#{page}")
272
- end
273
-
274
- def resource_parts(root, interactive)
275
- parts = options[:frontend_only] ? [] : %i[migration model serializer controller]
276
- frontend = File.directory?(File.join(root, "frontend")) && !options[:api_only]
277
- frontend &&= yes?("Generate Next.js pages? [Y/n]") if interactive
278
- frontend ? parts + [:frontend] : parts
279
- end
280
-
281
- def resource_actions(interactive)
282
- return options[:actions].split(",").map(&:strip) if options[:actions]
283
- return ResourceSpec::REST_ACTIONS if !interactive || yes?("Generate the full CRUD API? [Y/n]")
284
-
285
- ask("Actions (comma-separated from #{ResourceSpec::REST_ACTIONS.join(",")}):").split(",").map(&:strip)
286
- end
287
-
288
- # Thor's yes? only accepts y/yes; treat an empty answer as "yes".
289
- def yes?(question)
290
- answer = ask(question).strip.downcase
291
- answer.empty? || %w[y yes].include?(answer)
292
- end
293
-
294
- # Regenerates TypeScript types/clients in the app's own bundle.
295
- def refresh_contract(root)
296
- say(" #{"run".rjust(9)} gemstack contract")
297
- ok = (defined?(Bundler) ? Bundler.with_unbundled_env { contract_command(root) } : contract_command(root))
298
- return if ok
299
-
300
- say(" #{"warning".rjust(9)} contract not generated — run `gemstack contract` once the database is reachable")
301
- end
233
+ desc "update [VERSION]", "Update this app to the latest GemStack release (or VERSION)"
234
+ long_desc <<~DESC
235
+ Sets every GemStack gem in the Gemfile (gemstack, gemstack-auth, gemstack-realtime) to
236
+ "~> VERSION" and runs bundle update for them. Without VERSION, uses the latest release on
237
+ rubygems.org.
302
238
 
303
- def contract_command(root)
304
- system("bundle", "exec", "gemstack", "contract", "--quiet", chdir: root)
305
- end
239
+ gemstack update # the latest release
240
+ gemstack update 0.3.5 # a specific version
241
+ DESC
242
+ def update(version = nil)
243
+ exit(1) unless UpdateGenerator.new(root: Project.root!, version: version).run
306
244
  end
245
+
246
+ include Helpers
307
247
  end
308
248
  end
@@ -2,5 +2,5 @@
2
2
 
3
3
  module GemStack
4
4
  # All GemStack gems are released together under a single version.
5
- VERSION = "0.3.4"
5
+ VERSION = "0.3.5"
6
6
  end
@@ -1,63 +1,65 @@
1
1
  # syntax=docker/dockerfile:1
2
- # Production images for <%= app_name %> (gemstack generate deploy — docs/deployment.md).
2
+ # Production image for <%= app_name %> (gemstack generate deploy — docs/deployment.md).
3
3
  #
4
- # docker build --target api -t <%= app_name %>-api . # Ruby API (Puma), also runs jobs and migrations
4
+ # One image runs every process; config/deploy.yml (Kamal) gives each role its command:
5
5
  <% if frontend? -%>
6
- # docker build --target web -t <%= app_name %>-web . # Next.js
6
+ # web node frontend/server.js Next.js
7
7
  <% end -%>
8
+ # api bundle exec puma -C config/puma.rb Ruby API (bin/docker-entrypoint migrates first)
9
+ <% if jobs? -%>
10
+ # jobs bundle exec gemstack jobs background jobs
11
+ <% end -%>
12
+ #
13
+ # docker build -t <%= service %> .
8
14
  #
9
15
  # Configuration comes from the environment at runtime (SECRET_KEY_BASE,
10
- # DATABASE_URL, …); nothing secret is baked into the images.
16
+ # DATABASE_URL, …); nothing secret is baked into the image.
11
17
 
12
18
  ARG RUBY_VERSION=<%= ruby_version %>
19
+ <% if frontend? -%>
13
20
  ARG NODE_VERSION=<%= node_version %>
21
+ <% end -%>
14
22
 
15
- # ── Ruby API ────────────────────────────────────────────────────────────────
16
- FROM ruby:${RUBY_VERSION}-slim AS api-build
23
+ FROM ruby:${RUBY_VERSION}-slim AS base
24
+ WORKDIR /app
25
+ ENV GEMSTACK_ENV=production BUNDLE_DEPLOYMENT=1 BUNDLE_WITHOUT="development:test" BUNDLE_PATH=/usr/local/bundle
26
+
27
+ # ── Ruby gems and application code ──────────────────────────────────────────
28
+ FROM base AS gems
17
29
  RUN apt-get update -qq && apt-get install --no-install-recommends -y <%= build_packages %> \
18
30
  && rm -rf /var/lib/apt/lists/*
19
- WORKDIR /app
20
- ENV BUNDLE_DEPLOYMENT=1 BUNDLE_WITHOUT="development:test" BUNDLE_PATH=/usr/local/bundle
21
31
  COPY Gemfile Gemfile.lock ./
22
32
  COPY vendor/ ./vendor/
23
33
  RUN bundle install --jobs 4 && rm -rf /usr/local/bundle/cache "${BUNDLE_PATH}"/ruby/*/cache
24
34
  COPY . .
25
- RUN rm -rf frontend tmp/* log/* .env*
26
-
27
- FROM ruby:${RUBY_VERSION}-slim AS api
28
- RUN apt-get update -qq && apt-get install --no-install-recommends -y <%= runtime_packages %> \
29
- && rm -rf /var/lib/apt/lists/* \
30
- && groupadd --system --gid 1000 app && useradd --system --uid 1000 --gid app --create-home app<% if sqlite? %> \
31
- && mkdir -p /data && chown app:app /data<% end %>
32
- WORKDIR /app
33
- ENV GEMSTACK_ENV=production BUNDLE_DEPLOYMENT=1 BUNDLE_WITHOUT="development:test" BUNDLE_PATH=/usr/local/bundle \
34
- GEMSTACK_API_HOST=0.0.0.0 GEMSTACK_API_PORT=4000
35
- COPY --from=api-build /usr/local/bundle /usr/local/bundle
36
- COPY --from=api-build --chown=app:app /app /app
37
- USER app
38
- EXPOSE 4000
39
- HEALTHCHECK --interval=10s --timeout=3s --start-period=20s CMD curl -fsS http://127.0.0.1:4000/api/health || exit 1
40
- # Other processes use the same image: `bundle exec gemstack jobs`, `bundle exec gemstack db:migrate`.
41
- CMD ["bundle", "exec", "puma", "-C", "config/puma.rb"]
35
+ RUN rm -rf frontend tmp/* log/* .env* .kamal
42
36
  <% if frontend? -%>
43
37
 
44
- # ── Next.js ─────────────────────────────────────────────────────────────────
45
- FROM node:${NODE_VERSION}-slim AS web-build
46
- WORKDIR /app
38
+ # ── Next.js (standalone build: the server and only the modules it needs) ─────
39
+ FROM node:${NODE_VERSION}-slim AS frontend
40
+ WORKDIR /frontend
47
41
  COPY frontend/package.json frontend/package-lock.json ./
48
42
  RUN npm ci
49
43
  COPY frontend/ ./
50
- ENV NEXT_TELEMETRY_DISABLED=1
51
- # GEMSTACK_API_URL is only needed here when Next.js proxies /api itself (no reverse proxy in front).
52
- ARG GEMSTACK_API_URL
53
- ENV GEMSTACK_API_URL=${GEMSTACK_API_URL}
54
- RUN npm run build && npm prune --omit=dev
44
+ ENV NEXT_TELEMETRY_DISABLED=1 GEMSTACK_NEXT_OUTPUT=standalone
45
+ RUN npm run build \
46
+ && mkdir -p public && cp -r public .next/standalone/ && cp -r .next/static .next/standalone/.next/
47
+ <% end -%>
55
48
 
56
- FROM node:${NODE_VERSION}-slim AS web
57
- WORKDIR /app
58
- ENV NODE_ENV=production NEXT_TELEMETRY_DISABLED=1 PORT=3000
59
- COPY --from=web-build --chown=node:node /app ./
60
- USER node
61
- EXPOSE 3000
62
- CMD ["npm", "start"]
49
+ # ── Runtime ─────────────────────────────────────────────────────────────────
50
+ FROM base
51
+ RUN apt-get update -qq && apt-get install --no-install-recommends -y <%= runtime_packages %> \
52
+ && rm -rf /var/lib/apt/lists/* \
53
+ && groupadd --system --gid 1000 app && useradd --system --uid 1000 --gid app --create-home app<% if sqlite? %> \
54
+ && mkdir -p /data && chown app:app /data<% end %>
55
+ COPY --from=gems /usr/local/bundle /usr/local/bundle
56
+ COPY --from=gems --chown=app:app /app /app
57
+ <% if frontend? -%>
58
+ COPY --from=frontend /usr/local/bin/node /usr/local/bin/node
59
+ COPY --from=frontend --chown=app:app /frontend/.next/standalone /app/frontend
63
60
  <% end -%>
61
+ ENV GEMSTACK_API_HOST=0.0.0.0 GEMSTACK_API_PORT=4000<% if frontend? %> NODE_ENV=production NEXT_TELEMETRY_DISABLED=1 PORT=3000<% end %>
62
+ USER app
63
+ EXPOSE <%= frontend? ? "3000 4000" : "4000" %>
64
+ ENTRYPOINT ["/app/bin/docker-entrypoint"]
65
+ CMD ["bundle", "exec", "puma", "-C", "config/puma.rb"]
@@ -0,0 +1,21 @@
1
+ #!/bin/sh
2
+ # Runs before every process in the production image (Dockerfile ENTRYPOINT).
3
+ set -e
4
+
5
+ case "$*" in
6
+ <% if database? -%>
7
+ *puma*)
8
+ # The API applies pending migrations before it starts serving, so a
9
+ # deploy never runs new code against an old schema.
10
+ bundle exec gemstack db:migrate
11
+ ;;
12
+ <% end -%>
13
+ <% if frontend? -%>
14
+ *server.js*)
15
+ # Docker sets HOSTNAME to the container's name; Next.js listens on it.
16
+ export HOSTNAME=0.0.0.0
17
+ ;;
18
+ <% end -%>
19
+ esac
20
+
21
+ exec "$@"
@@ -0,0 +1,156 @@
1
+ # Kamal deploy configuration for <%= app_name %> (gemstack generate deploy — docs/deployment.md).
2
+ #
3
+ # bundle exec kamal setup # first deploy: Docker, kamal-proxy (HTTPS) and accessories on the server
4
+ # bundle exec kamal deploy # every deploy after that, with zero downtime
5
+ #
6
+ # Replace the placeholders marked CHANGE. Secrets are read from .kamal/secrets,
7
+ # which only references environment variables, never their values.
8
+
9
+ service: <%= service %>
10
+
11
+ # CHANGE: your registry user (Docker Hub here; see registry: below for others).
12
+ image: your-user/<%= service %>
13
+
14
+ servers:
15
+ <% if frontend? -%>
16
+ web:
17
+ hosts:
18
+ - 192.168.0.1 # CHANGE: your server's IP or hostname
19
+ cmd: node frontend/server.js
20
+ env:
21
+ clear:
22
+ # Server Components call the API directly, through the alias below.
23
+ GEMSTACK_API_URL: http://<%= service %>-api:4000
24
+ api:
25
+ hosts:
26
+ - 192.168.0.1 # CHANGE
27
+ cmd: bundle exec puma -C config/puma.rb
28
+ options:
29
+ network-alias: <%= service %>-api
30
+ # The browser calls /api on the same origin: kamal-proxy sends it here.
31
+ proxy:
32
+ app_port: 4000
33
+ path_prefix: /api
34
+ strip_path_prefix: false
35
+ # HTTPS comes from the web role's certificate (same domain, root path).
36
+ ssl: false
37
+ # Don't trust X-Forwarded-* headers sent by clients (IP-based rate limits).
38
+ forward_headers: false
39
+ healthcheck:
40
+ path: /api/health
41
+ env:
42
+ clear:
43
+ WEB_CONCURRENCY: 2
44
+ <% else -%>
45
+ api:
46
+ hosts:
47
+ - 192.168.0.1 # CHANGE: your server's IP or hostname
48
+ cmd: bundle exec puma -C config/puma.rb
49
+ env:
50
+ clear:
51
+ WEB_CONCURRENCY: 2
52
+ <% end -%>
53
+ <% if jobs? -%>
54
+ jobs:
55
+ hosts:
56
+ - 192.168.0.1 # CHANGE
57
+ cmd: bundle exec gemstack jobs
58
+ <% end -%>
59
+
60
+ primary_role: <%= frontend? ? "web" : "api" %>
61
+
62
+ # HTTPS with a Let's Encrypt certificate: point the domain's DNS at the server first.
63
+ proxy:
64
+ ssl: true
65
+ host: app.example.com # CHANGE: your domain
66
+ <% if frontend? -%>
67
+ app_port: 3000
68
+ healthcheck:
69
+ path: /
70
+ <% else -%>
71
+ app_port: 4000
72
+ healthcheck:
73
+ path: /api/health
74
+ <% end -%>
75
+
76
+ registry:
77
+ # server: ghcr.io # for GitHub's registry; Docker Hub is the default
78
+ username: your-user # CHANGE
79
+ password:
80
+ - KAMAL_REGISTRY_PASSWORD
81
+
82
+ env:
83
+ clear:
84
+ GEMSTACK_ENV: production
85
+ APP_URL: https://app.example.com # CHANGE: the public URL (email links)
86
+ <% if sqlite? -%>
87
+ DATABASE_URL: sqlite3:/data/production.sqlite3
88
+ <% end -%>
89
+ <% if redis? -%>
90
+ REDIS_URL: redis://<%= service %>-redis:6379/0
91
+ <% end -%>
92
+ <% if mail? -%>
93
+ MAIL_FROM: app@example.com # CHANGE
94
+ <% end -%>
95
+ secret:
96
+ - SECRET_KEY_BASE
97
+ <% if database? && !sqlite? -%>
98
+ - DATABASE_URL
99
+ <% end -%>
100
+ <% if mail? -%>
101
+ - SMTP_URL
102
+ <% end -%>
103
+ <% if sqlite? -%>
104
+
105
+ # The SQLite file lives on this volume, shared by every role: keep them on one server.
106
+ volumes:
107
+ - "<%= service %>_data:/data"
108
+ <% end -%>
109
+
110
+ builder:
111
+ arch: amd64
112
+ <% if postgresql? || mysql? || redis? -%>
113
+
114
+ accessories:
115
+ <% if postgresql? -%>
116
+ db:
117
+ image: postgres:17
118
+ host: 192.168.0.1 # CHANGE
119
+ env:
120
+ clear:
121
+ POSTGRES_USER: app
122
+ POSTGRES_DB: <%= database_name %>
123
+ secret:
124
+ - POSTGRES_PASSWORD
125
+ directories:
126
+ - data:/var/lib/postgresql/data
127
+ <% elsif mysql? -%>
128
+ db:
129
+ image: mysql:8.4
130
+ host: 192.168.0.1 # CHANGE
131
+ env:
132
+ clear:
133
+ MYSQL_USER: app
134
+ MYSQL_DATABASE: <%= database_name %>
135
+ MYSQL_RANDOM_ROOT_PASSWORD: "yes"
136
+ secret:
137
+ - MYSQL_PASSWORD
138
+ directories:
139
+ - data:/var/lib/mysql
140
+ <% end -%>
141
+ <% if redis? -%>
142
+ redis:
143
+ image: redis:7
144
+ host: 192.168.0.1 # CHANGE
145
+ cmd: redis-server --save "" --appendonly no
146
+ <% end -%>
147
+ <% end -%>
148
+
149
+ # bundle exec kamal console | logs | migrate | shell
150
+ aliases:
151
+ console: app exec --interactive --reuse --roles=api "bundle exec gemstack console"
152
+ logs: app logs --follow
153
+ <% if database? -%>
154
+ migrate: app exec --roles=api "bundle exec gemstack db:migrate"
155
+ <% end -%>
156
+ shell: app exec --interactive --reuse --roles=api "bash"
@@ -1,5 +1,6 @@
1
1
  # Kept out of the build context (and so out of images).
2
2
  .git
3
+ .kamal/
3
4
  .env
4
5
  .env.*
5
6
  !.env.example
@@ -0,0 +1,18 @@
1
+ # Secrets for Kamal (config/deploy.yml), resolved on your machine at deploy time.
2
+ #
3
+ # This file is committed to git: only ever write REFERENCES here — environment
4
+ # variables ($NAME) or password-manager commands ($(kamal secrets fetch ...)) —
5
+ # never a secret's value. `gemstack doctor` flags values written in place.
6
+
7
+ KAMAL_REGISTRY_PASSWORD=$KAMAL_REGISTRY_PASSWORD
8
+ SECRET_KEY_BASE=$SECRET_KEY_BASE
9
+ <% if postgresql? -%>
10
+ POSTGRES_PASSWORD=$POSTGRES_PASSWORD
11
+ DATABASE_URL=postgres://app:$POSTGRES_PASSWORD@<%= service %>-db:5432/<%= database_name %>
12
+ <% elsif mysql? -%>
13
+ MYSQL_PASSWORD=$MYSQL_PASSWORD
14
+ DATABASE_URL=<%= database_adapter %>://app:$MYSQL_PASSWORD@<%= service %>-db:3306/<%= database_name %>
15
+ <% end -%>
16
+ <% if mail? -%>
17
+ SMTP_URL=$SMTP_URL
18
+ <% end -%>
@@ -2,12 +2,15 @@ import type { NextConfig } from "next";
2
2
 
3
3
  // One public origin, in every environment:
4
4
  // - `gemstack dev`: the GemStack gateway routes /api/* to Ruby before requests reach Next.js.
5
- // - Production: set GEMSTACK_API_URL (e.g. http://api.internal:4000) and Next.js proxies
6
- // /api/* to the Ruby API. Leave it unset if a reverse proxy already routes /api/*.
5
+ // - Production with Kamal (gemstack generate deploy): kamal-proxy routes /api/* to Ruby.
6
+ // - Elsewhere: set GEMSTACK_API_URL (e.g. http://api.internal:4000) at build time and
7
+ // Next.js proxies /api/* to the Ruby API.
7
8
  const apiUrl = process.env.GEMSTACK_API_URL?.replace(/\/$/, "");
8
9
  const apiPath = process.env.NEXT_PUBLIC_GEMSTACK_API_PATH ?? "/api";
9
10
 
10
11
  const nextConfig: NextConfig = {
12
+ // The production image (gemstack generate deploy) runs Next.js's standalone server.
13
+ output: process.env.GEMSTACK_NEXT_OUTPUT === "standalone" ? "standalone" : undefined,
11
14
  async rewrites() {
12
15
  if (!apiUrl) return [];
13
16
  return [{ source: `${apiPath}/:path*`, destination: `${apiUrl}${apiPath}/:path*` }];
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: gemstack
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.4
4
+ version: 0.3.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - Adware Technologies
@@ -44,14 +44,14 @@ dependencies:
44
44
  requirements:
45
45
  - - '='
46
46
  - !ruby/object:Gem::Version
47
- version: 0.3.4
47
+ version: 0.3.5
48
48
  type: :runtime
49
49
  prerelease: false
50
50
  version_requirements: !ruby/object:Gem::Requirement
51
51
  requirements:
52
52
  - - '='
53
53
  - !ruby/object:Gem::Version
54
- version: 0.3.4
54
+ version: 0.3.5
55
55
  - !ruby/object:Gem::Dependency
56
56
  name: json
57
57
  requirement: !ruby/object:Gem::Requirement
@@ -181,9 +181,11 @@ files:
181
181
  - lib/gemstack/cli/deploy_generator.rb
182
182
  - lib/gemstack/cli/destroy_generator.rb
183
183
  - lib/gemstack/cli/doctor.rb
184
+ - lib/gemstack/cli/doctor/secret_checks.rb
184
185
  - lib/gemstack/cli/doctor/upgrade_check.rb
185
186
  - lib/gemstack/cli/generation_manifest.rb
186
187
  - lib/gemstack/cli/generator.rb
188
+ - lib/gemstack/cli/helpers.rb
187
189
  - lib/gemstack/cli/job_generator.rb
188
190
  - lib/gemstack/cli/migration_changes.rb
189
191
  - lib/gemstack/cli/migration_generator.rb
@@ -193,6 +195,7 @@ files:
193
195
  - lib/gemstack/cli/project.rb
194
196
  - lib/gemstack/cli/resource_generator.rb
195
197
  - lib/gemstack/cli/resource_spec.rb
198
+ - lib/gemstack/cli/update_generator.rb
196
199
  - lib/gemstack/contract.rb
197
200
  - lib/gemstack/contract/builder.rb
198
201
  - lib/gemstack/contract/docs.rb
@@ -329,11 +332,11 @@ files:
329
332
  - templates/auth/test/controllers/auth_test.rb
330
333
  - templates/controller/app/controllers/%file_name%_controller.rb.tt
331
334
  - templates/controller/test/controllers/%file_name%_controller_test.rb.tt
332
- - templates/deploy/Caddyfile.tt
333
335
  - templates/deploy/Dockerfile.tt
334
- - templates/deploy/Procfile.tt
335
- - templates/deploy/compose.yaml.tt
336
+ - templates/deploy/bin/docker-entrypoint.tt
337
+ - templates/deploy/config/deploy.yml.tt
336
338
  - templates/deploy/dot_dockerignore
339
+ - templates/deploy/dot_kamal/secrets.tt
337
340
  - templates/frontend/app/globals.css
338
341
  - templates/frontend/app/layout.tsx.tt
339
342
  - templates/frontend/app/page.module.css
@@ -1,19 +0,0 @@
1
- # One public origin: /api/* → Ruby, everything else → Next.js (docs/deployment.md).
2
- # SITE_ADDRESS=example.com gets a Let's Encrypt certificate automatically;
3
- # "localhost" uses Caddy's local CA (trust it with `caddy trust`, or curl -k).
4
- {$SITE_ADDRESS:localhost} {
5
- encode zstd gzip
6
-
7
- handle /api/* {
8
- reverse_proxy api:4000 {
9
- # Server-Sent Events: stream responses immediately.
10
- flush_interval -1
11
- }
12
- }
13
- <% if frontend? -%>
14
-
15
- handle {
16
- reverse_proxy web:3000
17
- }
18
- <% end -%>
19
- }
@@ -1,6 +0,0 @@
1
- # Process types for Procfile platforms (Heroku, Render, Railway, Dokku…) — docs/deployment.md.
2
- release: bundle exec gemstack db:migrate
3
- web: GEMSTACK_API_PORT=${PORT:-4000} bundle exec puma -C config/puma.rb
4
- <% if jobs? -%>
5
- worker: bundle exec gemstack jobs
6
- <% end -%>
@@ -1,129 +0,0 @@
1
- # Runs <%= app_name %> in production mode on this machine, behind Caddy on https://localhost:
2
- #
3
- # export SECRET_KEY_BASE=$(openssl rand -hex 64)<% if postgresql? %> POSTGRES_PASSWORD=$(openssl rand -hex 16)<% elsif mysql? %> MYSQL_PASSWORD=$(openssl rand -hex 16)<% end %>
4
- # docker compose up --build
5
- #
6
- # It's also a map of what a real deploy needs: one image per process type,
7
- # migrations before the API starts, and a proxy sending /api to Ruby.
8
- <% if sqlite? -%>
9
- # SQLite: the database file lives on the `data` volume, shared by the API, the
10
- # jobs worker and migrations — keep them on one host.
11
- <% end -%>
12
- name: <%= app_name.tr("_", "-") %>
13
-
14
- x-api-env: &api-env
15
- GEMSTACK_ENV: production
16
- SECRET_KEY_BASE: ${SECRET_KEY_BASE:?set SECRET_KEY_BASE (openssl rand -hex 64)}
17
- <% if database? -%>
18
- DATABASE_URL: <%= database_url %>
19
- <% end -%>
20
- <% if redis? -%>
21
- REDIS_URL: redis://redis:6379/0 # realtime fan-out between the API and the jobs worker
22
- <% end -%>
23
- APP_URL: ${APP_URL:-https://localhost}
24
- <% if mail? -%>
25
- SMTP_URL: ${SMTP_URL:-} # e.g. smtp://user:pass@smtp.example.com:587 — emails retry until it is set
26
- MAIL_FROM: ${MAIL_FROM:-}
27
- <% end -%>
28
-
29
- services:
30
- <% if postgresql? -%>
31
- db:
32
- image: postgres:17
33
- environment:
34
- POSTGRES_USER: app
35
- POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}
36
- POSTGRES_DB: app
37
- volumes: [db:/var/lib/postgresql/data]
38
- healthcheck:
39
- test: ["CMD-SHELL", "pg_isready -U app -d app"]
40
- interval: 2s
41
- retries: 30
42
-
43
- <% elsif mysql? -%>
44
- db:
45
- image: mysql:8.4
46
- environment:
47
- MYSQL_DATABASE: app
48
- MYSQL_USER: app
49
- MYSQL_PASSWORD: ${MYSQL_PASSWORD:?set MYSQL_PASSWORD}
50
- MYSQL_RANDOM_ROOT_PASSWORD: "yes"
51
- volumes: [db:/var/lib/mysql]
52
- healthcheck:
53
- test: ["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 -uapp -p$$MYSQL_PASSWORD --silent"]
54
- interval: 3s
55
- retries: 40
56
-
57
- <% end -%>
58
- <% if redis? -%>
59
- redis:
60
- image: redis:7
61
- command: ["redis-server", "--save", "", "--appendonly", "no"]
62
-
63
- <% end -%>
64
- <% if database? -%>
65
- migrate:
66
- image: <%= app_name %>-api
67
- build: { context: ., target: api }
68
- healthcheck: { disable: true }
69
- command: ["bundle", "exec", "gemstack", "db:migrate"]
70
- environment: *api-env
71
- <% if sqlite? -%>
72
- volumes: [data:/data]
73
- <% else -%>
74
- depends_on: { db: { condition: service_healthy } }
75
- <% end -%>
76
-
77
- <% end -%>
78
- api:
79
- image: <%= app_name %>-api
80
- build: { context: ., target: api }
81
- environment:
82
- <<: *api-env
83
- WEB_CONCURRENCY: ${WEB_CONCURRENCY:-2}
84
- <% if sqlite? -%>
85
- volumes: [data:/data]
86
- <% end -%>
87
- <% if database? -%>
88
- depends_on: { migrate: { condition: service_completed_successfully } }
89
- <% end -%>
90
- <% if jobs? -%>
91
-
92
- jobs:
93
- image: <%= app_name %>-api
94
- build: { context: ., target: api }
95
- healthcheck: { disable: true }
96
- command: ["bundle", "exec", "gemstack", "jobs"]
97
- environment: *api-env
98
- <% if sqlite? -%>
99
- volumes: [data:/data]
100
- <% end -%>
101
- depends_on: { migrate: { condition: service_completed_successfully } }
102
- <% end -%>
103
- <% if frontend? -%>
104
-
105
- web:
106
- image: <%= app_name %>-web
107
- build: { context: ., target: web }
108
- environment:
109
- GEMSTACK_API_URL: http://api:4000 # Server Components call the API directly
110
- depends_on: [api]
111
- <% end -%>
112
-
113
- proxy:
114
- image: caddy:2
115
- ports: ["80:80", "443:443"]
116
- environment:
117
- SITE_ADDRESS: ${SITE_ADDRESS:-localhost}
118
- volumes:
119
- - ./Caddyfile:/etc/caddy/Caddyfile:ro
120
- - caddy:/data
121
- depends_on: [api<%= ", web" if frontend? %>]
122
-
123
- volumes:
124
- <% if sqlite? -%>
125
- data:
126
- <% elsif database? -%>
127
- db:
128
- <% end -%>
129
- caddy: