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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +24 -0
- data/exe/gemstack +6 -0
- data/lib/gemstack/cli/add_generator.rb +237 -0
- data/lib/gemstack/cli/app_generator.rb +110 -0
- data/lib/gemstack/cli/commands/db.rb +134 -0
- data/lib/gemstack/cli/commands/jobs.rb +96 -0
- data/lib/gemstack/cli/controller_generator.rb +73 -0
- data/lib/gemstack/cli/deploy_generator.rb +64 -0
- data/lib/gemstack/cli/doctor.rb +277 -0
- data/lib/gemstack/cli/generator.rb +122 -0
- data/lib/gemstack/cli/job_generator.rb +47 -0
- data/lib/gemstack/cli/migration_generator.rb +52 -0
- data/lib/gemstack/cli/policy_generator.rb +35 -0
- data/lib/gemstack/cli/project.rb +53 -0
- data/lib/gemstack/cli/resource_generator.rb +169 -0
- data/lib/gemstack/cli/resource_spec.rb +95 -0
- data/lib/gemstack/cli.rb +299 -0
- data/templates/app/Gemfile.tt +39 -0
- data/templates/app/README.md.tt +29 -0
- data/templates/app/app/controllers/application_controller.rb +6 -0
- data/templates/app/bin/gemstack +7 -0
- data/templates/app/config/app.rb.tt +30 -0
- data/templates/app/config/environments/development.rb +6 -0
- data/templates/app/config/environments/production.rb +8 -0
- data/templates/app/config/environments/test.rb +7 -0
- data/templates/app/config/puma.rb +21 -0
- data/templates/app/config/routes.rb +10 -0
- data/templates/app/config.ru +6 -0
- data/templates/app/db/migrations/dot_keep +0 -0
- data/templates/app/db/seeds.rb +5 -0
- data/templates/app/dot_env.example +9 -0
- data/templates/app/dot_gitignore +14 -0
- data/templates/app/dot_ruby-version.tt +1 -0
- data/templates/app/dot_tool-versions.tt +1 -0
- data/templates/app/test/health_test.rb +18 -0
- data/templates/app/test/test_helper.rb.tt +25 -0
- data/templates/auth/app/controllers/api_tokens_controller.rb +35 -0
- data/templates/auth/app/controllers/email_verifications_controller.rb +31 -0
- data/templates/auth/app/controllers/password_resets_controller.rb +40 -0
- data/templates/auth/app/controllers/registrations_controller.rb +19 -0
- data/templates/auth/app/controllers/sessions_controller.rb +29 -0
- data/templates/auth/app/mailers/auth_mailer.rb +20 -0
- data/templates/auth/app/mailers/templates/auth_mailer/email_verification.html.erb +2 -0
- data/templates/auth/app/mailers/templates/auth_mailer/email_verification.text.erb +2 -0
- data/templates/auth/app/mailers/templates/auth_mailer/password_reset.html.erb +3 -0
- data/templates/auth/app/mailers/templates/auth_mailer/password_reset.text.erb +6 -0
- data/templates/auth/app/models/auth_token.rb +15 -0
- data/templates/auth/app/models/user.rb +12 -0
- data/templates/auth/app/policies/application_policy.rb +6 -0
- data/templates/auth/app/serializers/api_token_serializer.rb +6 -0
- data/templates/auth/app/serializers/new_api_token_serializer.rb +9 -0
- data/templates/auth/app/serializers/user_serializer.rb +6 -0
- data/templates/auth/db/migrations/%timestamp%_create_auth_tables.rb +41 -0
- data/templates/auth/frontend/app/account/page.tsx +116 -0
- data/templates/auth/frontend/app/forgot-password/page.tsx +48 -0
- data/templates/auth/frontend/app/login/page.tsx +26 -0
- data/templates/auth/frontend/app/reset-password/page.tsx +11 -0
- data/templates/auth/frontend/app/signup/page.tsx +27 -0
- data/templates/auth/frontend/app/verify-email/page.tsx +11 -0
- data/templates/auth/frontend/components/auth/CredentialsForm.tsx +58 -0
- data/templates/auth/frontend/components/auth/ResetPasswordForm.tsx +43 -0
- data/templates/auth/frontend/components/auth/VerifyEmail.tsx +28 -0
- data/templates/auth/frontend/lib/auth.ts +113 -0
- data/templates/auth/test/controllers/auth_test.rb +108 -0
- data/templates/controller/app/controllers/%file_name%_controller.rb.tt +10 -0
- data/templates/controller/test/controllers/%file_name%_controller_test.rb.tt +14 -0
- data/templates/deploy/Caddyfile.tt +19 -0
- data/templates/deploy/Dockerfile.tt +62 -0
- data/templates/deploy/Procfile.tt +6 -0
- data/templates/deploy/compose.yaml.tt +80 -0
- data/templates/deploy/dot_dockerignore +14 -0
- data/templates/frontend/app/globals.css +171 -0
- data/templates/frontend/app/layout.tsx.tt +19 -0
- data/templates/frontend/app/page.tsx.tt +45 -0
- data/templates/frontend/app/providers.tsx +24 -0
- data/templates/frontend/lib/gemstack/client.ts +125 -0
- data/templates/frontend/next-env.d.ts +5 -0
- data/templates/frontend/next.config.ts +17 -0
- data/templates/frontend/package.json.tt +23 -0
- data/templates/frontend/tsconfig.json +21 -0
- data/templates/job/app/jobs/%file_name%.rb.tt +15 -0
- data/templates/job/test/jobs/%file_name%_test.rb.tt +15 -0
- data/templates/migration/db/migrations/%timestamp%_%file_name%.rb.tt +17 -0
- data/templates/policy/app/policies/%file_name%_policy.rb.tt +21 -0
- data/templates/policy/test/policies/%file_name%_policy_test.rb.tt +9 -0
- data/templates/realtime/config/channels.rb +14 -0
- data/templates/realtime/frontend/lib/gemstack/realtime.ts +120 -0
- data/templates/resource/controller/app/controllers/%plural%_controller.rb.tt +51 -0
- data/templates/resource/controller/test/controllers/%plural%_controller_test.rb.tt +74 -0
- data/templates/resource/frontend/frontend/app/%url_segment%/[id]/edit/page.tsx.tt +45 -0
- data/templates/resource/frontend/frontend/app/%url_segment%/[id]/page.tsx.tt +49 -0
- data/templates/resource/frontend/frontend/app/%url_segment%/new/page.tsx.tt +27 -0
- data/templates/resource/frontend/frontend/app/%url_segment%/page.tsx.tt +51 -0
- data/templates/resource/frontend/frontend/components/%url_segment%/%class_name%Card.tsx.tt +15 -0
- data/templates/resource/frontend/frontend/components/%url_segment%/%class_name%Form.tsx.tt +98 -0
- data/templates/resource/frontend/frontend/components/%url_segment%/%class_name%Table.tsx.tt +36 -0
- data/templates/resource/frontend/frontend/lib/format.ts +12 -0
- data/templates/resource/frontend/frontend/lib/queries/%url_segment%.ts.tt +63 -0
- data/templates/resource/migration/db/migrations/%timestamp%_create_%table%.rb.tt +18 -0
- data/templates/resource/model/app/models/%file_name%.rb.tt +15 -0
- data/templates/resource/model/test/models/%file_name%_test.rb.tt +29 -0
- data/templates/resource/serializer/app/serializers/%file_name%_serializer.rb.tt +7 -0
- data/templates/storage/app/controllers/uploads_controller.rb.tt +33 -0
- data/templates/storage/app/serializers/upload_serializer.rb +11 -0
- data/templates/storage/frontend/lib/upload.ts +52 -0
- data/templates/storage/test/controllers/uploads_test.rb.tt +30 -0
- 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
|