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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +4 -0
- data/README.md +7 -3
- data/lib/gemstack/cache/memory_store.rb +69 -0
- data/lib/gemstack/cache/null_store.rb +18 -0
- data/lib/gemstack/cache/redis_store.rb +70 -0
- data/lib/gemstack/cache/store.rb +89 -0
- data/lib/gemstack/cache.rb +61 -0
- data/lib/gemstack/cli/add_generator.rb +258 -0
- data/lib/gemstack/cli/app_generator.rb +142 -0
- data/lib/gemstack/cli/commands/db.rb +136 -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 +102 -0
- data/lib/gemstack/cli/doctor/upgrade_check.rb +32 -0
- data/lib/gemstack/cli/doctor.rb +311 -0
- data/lib/gemstack/cli/generator.rb +161 -0
- data/lib/gemstack/cli/job_generator.rb +48 -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 +170 -0
- data/lib/gemstack/cli/resource_spec.rb +95 -0
- data/lib/gemstack/cli.rb +304 -0
- data/lib/gemstack/contract/builder.rb +168 -0
- data/lib/gemstack/contract/docs/index.html +264 -0
- data/lib/gemstack/contract/docs.rb +45 -0
- data/lib/gemstack/contract/openapi.rb +123 -0
- data/lib/gemstack/contract/typescript.rb +162 -0
- data/lib/gemstack/contract.rb +93 -0
- data/lib/gemstack/core.rb +129 -0
- data/lib/gemstack/db/configuration.rb +148 -0
- data/lib/gemstack/db/errors.rb +137 -0
- data/lib/gemstack/db/json_compat.rb +21 -0
- data/lib/gemstack/db/migrator.rb +80 -0
- data/lib/gemstack/db/model.rb +226 -0
- data/lib/gemstack/db/schema_types.rb +28 -0
- data/lib/gemstack/db/tasks.rb +94 -0
- data/lib/gemstack/db/testing.rb +99 -0
- data/lib/gemstack/db.rb +210 -0
- data/lib/gemstack/dev/file_watcher.rb +58 -0
- data/lib/gemstack/dev/gateway.rb +220 -0
- data/lib/gemstack/dev/managed_process.rb +96 -0
- data/lib/gemstack/dev/ports.rb +26 -0
- data/lib/gemstack/dev/supervisor.rb +267 -0
- data/lib/gemstack/dev/terminal.rb +40 -0
- data/lib/gemstack/dev/toolchain.rb +86 -0
- data/lib/gemstack/dev.rb +43 -0
- data/lib/gemstack/dotenv.rb +61 -0
- data/lib/gemstack/environment.rb +37 -0
- data/lib/gemstack/error_mapping.rb +37 -0
- data/lib/gemstack/errors.rb +100 -0
- data/lib/gemstack/http/app.rb +30 -0
- data/lib/gemstack/http/config.rb +84 -0
- data/lib/gemstack/http/controller.rb +343 -0
- data/lib/gemstack/http/error_page.rb +111 -0
- data/lib/gemstack/http/error_renderer.rb +63 -0
- data/lib/gemstack/http/json_codec.rb +114 -0
- data/lib/gemstack/http/middleware/body_limit.rb +69 -0
- data/lib/gemstack/http/middleware/compression.rb +127 -0
- data/lib/gemstack/http/middleware/cors.rb +77 -0
- data/lib/gemstack/http/middleware/error_handler.rb +39 -0
- data/lib/gemstack/http/middleware/etags.rb +24 -0
- data/lib/gemstack/http/middleware/health_check.rb +28 -0
- data/lib/gemstack/http/middleware/request_id.rb +31 -0
- data/lib/gemstack/http/middleware/request_logger.rb +39 -0
- data/lib/gemstack/http/middleware/security_headers.rb +31 -0
- data/lib/gemstack/http/middleware_stack.rb +96 -0
- data/lib/gemstack/http/page.rb +36 -0
- data/lib/gemstack/http/params.rb +140 -0
- data/lib/gemstack/http/request.rb +64 -0
- data/lib/gemstack/http/router.rb +315 -0
- data/lib/gemstack/http.rb +41 -0
- data/lib/gemstack/inflector.rb +133 -0
- data/lib/gemstack/job.rb +154 -0
- data/lib/gemstack/jobs/adapters/async.rb +94 -0
- data/lib/gemstack/jobs/adapters/database.rb +188 -0
- data/lib/gemstack/jobs/adapters/inline.rb +34 -0
- data/lib/gemstack/jobs/adapters/sidekiq.rb +65 -0
- data/lib/gemstack/jobs/adapters/test.rb +59 -0
- data/lib/gemstack/jobs/executor.rb +70 -0
- data/lib/gemstack/jobs/testing.rb +55 -0
- data/lib/gemstack/jobs/worker.rb +137 -0
- data/lib/gemstack/jobs.rb +144 -0
- data/lib/gemstack/logger.rb +131 -0
- data/lib/gemstack/mail/delivery_job.rb +20 -0
- data/lib/gemstack/mail/testing.rb +33 -0
- data/lib/gemstack/mail.rb +230 -0
- data/lib/gemstack/plugins.rb +38 -0
- data/lib/gemstack/schema.rb +251 -0
- data/lib/gemstack/serializer.rb +186 -0
- data/lib/gemstack/settings.rb +86 -0
- data/lib/gemstack/storage/endpoint.rb +112 -0
- data/lib/gemstack/storage/services/disk.rb +60 -0
- data/lib/gemstack/storage/services/s3.rb +65 -0
- data/lib/gemstack/storage/testing.rb +30 -0
- data/lib/gemstack/storage.rb +183 -0
- data/lib/gemstack/types.rb +163 -0
- data/lib/gemstack/version.rb +6 -0
- data/lib/gemstack.rb +1 -1
- data/templates/app/Gemfile.tt +27 -0
- data/templates/app/README.md.tt +29 -0
- data/templates/app/app/controllers/application_controller.rb +6 -0
- data/templates/app/app/jobs/application_job.rb +9 -0
- data/templates/app/app/mailers/application_mailer.rb +8 -0
- data/templates/app/app/mailers/templates/dot_keep +0 -0
- data/templates/app/app/models/application_model.rb +19 -0
- data/templates/app/app/serializers/application_serializer.rb +6 -0
- data/templates/app/bin/gemstack +7 -0
- data/templates/app/config/app.rb.tt +60 -0
- data/templates/app/config/database.yml.tt +61 -0
- data/templates/app/config/environments/development.rb.tt +35 -0
- data/templates/app/config/environments/production.rb.tt +40 -0
- data/templates/app/config/environments/test.rb.tt +21 -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.tt +19 -0
- data/templates/app/dot_gitignore +16 -0
- data/templates/app/dot_node-version.tt +1 -0
- data/templates/app/dot_nvmrc.tt +1 -0
- data/templates/app/dot_ruby-version.tt +1 -0
- data/templates/app/dot_tool-versions.tt +4 -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 +63 -0
- data/templates/deploy/Procfile.tt +6 -0
- data/templates/deploy/compose.yaml.tt +129 -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.module.css +315 -0
- data/templates/frontend/app/page.tsx.tt +183 -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 +64 -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 +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
|