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
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 1a7919922a3f8ef313ffffa34f0a8884905d6f552772d99ef38908696640de5a
|
|
4
|
+
data.tar.gz: 65dc878039db71201b6ab2514649e52d1f0b1ede7d4d0c52299cfa7c8e68b8c8
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 6d00e50a3e84430210d7810dba99a89ebce41a7c9ddac5ede6dfe15f76d78f96ad60f15fa2ad4b24fcb2f5e8e49da602f3f7d81f736c72d2239fb92b8b3f9a68
|
|
7
|
+
data.tar.gz: 62a0bf6f9e50c21a636d27964a478aafd465f4ccf51f1012c94d57d8ad6e02e4822191bc87f928c5f3d76135ca968d55439a6adaefd801be94aa7555494d55c0
|
data/CHANGELOG.md
ADDED
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Shoaib Malik
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# gemstack-cli
|
|
2
|
+
|
|
3
|
+
GemStack command-line interface and generators.
|
|
4
|
+
|
|
5
|
+
Part of [GemStack](https://github.com/gemstack-rb/gemstack), a modular Ruby API framework for Next.js
|
|
6
|
+
applications. All GemStack gems are developed together in that repository and released with the same
|
|
7
|
+
version.
|
|
8
|
+
|
|
9
|
+
## Installation
|
|
10
|
+
|
|
11
|
+
Installed with the `gemstack` gem; you rarely need to add it yourself.
|
|
12
|
+
|
|
13
|
+
## Documentation
|
|
14
|
+
|
|
15
|
+
- [Guide](https://github.com/gemstack-rb/gemstack/blob/main/docs/getting-started.md)
|
|
16
|
+
- [All guides](https://github.com/gemstack-rb/gemstack/tree/main/docs) ·
|
|
17
|
+
[Architecture](https://github.com/gemstack-rb/gemstack/blob/main/ARCHITECTURE.md)
|
|
18
|
+
|
|
19
|
+
Source, issues and pull requests: [gemstack-rb/gemstack](https://github.com/gemstack-rb/gemstack)
|
|
20
|
+
(this gem lives in `gems/gemstack-cli`).
|
|
21
|
+
|
|
22
|
+
## License
|
|
23
|
+
|
|
24
|
+
MIT — see [LICENSE.txt](LICENSE.txt).
|
data/exe/gemstack
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GemStack
|
|
4
|
+
class CLI < Thor
|
|
5
|
+
# `gemstack add FEATURE` — opt-in modules, so apps that don't use them
|
|
6
|
+
# pay nothing: realtime, auth, storage.
|
|
7
|
+
class AddGenerator < Generator
|
|
8
|
+
FEATURES = %w[realtime auth storage].freeze
|
|
9
|
+
|
|
10
|
+
AUTH_ROUTES = <<~RUBY
|
|
11
|
+
# Authentication (gemstack add auth) — docs/authentication.md
|
|
12
|
+
post "/auth/signup", to: "registrations#create"
|
|
13
|
+
post "/auth/login", to: "sessions#create"
|
|
14
|
+
get "/auth/me", to: "sessions#show"
|
|
15
|
+
delete "/auth/logout", to: "sessions#destroy"
|
|
16
|
+
post "/auth/password/forgot", to: "password_resets#create"
|
|
17
|
+
post "/auth/password/reset", to: "password_resets#update"
|
|
18
|
+
post "/auth/email/resend", to: "email_verifications#create"
|
|
19
|
+
post "/auth/email/verify", to: "email_verifications#update"
|
|
20
|
+
get "/auth/tokens", to: "api_tokens#index"
|
|
21
|
+
post "/auth/tokens", to: "api_tokens#create"
|
|
22
|
+
delete "/auth/tokens/:id", to: "api_tokens#destroy"
|
|
23
|
+
RUBY
|
|
24
|
+
|
|
25
|
+
MAIL_ENV = <<~ENV
|
|
26
|
+
|
|
27
|
+
# Email. In development messages are saved to tmp/mail instead of being sent.
|
|
28
|
+
# SMTP_URL=smtp://user:password@smtp.example.com:587
|
|
29
|
+
# MAIL_FROM="My App <hello@example.com>"
|
|
30
|
+
# APP_URL=https://example.com # the frontend, for links in emails (default http://localhost:3000)
|
|
31
|
+
ENV
|
|
32
|
+
|
|
33
|
+
def initialize(feature, root:, output: $stdout, install: true)
|
|
34
|
+
super(output: output)
|
|
35
|
+
unless FEATURES.include?(feature)
|
|
36
|
+
raise Thor::Error,
|
|
37
|
+
"Unknown feature #{feature.inspect}. Available: #{FEATURES.join(", ")}"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
@feature = feature
|
|
41
|
+
@root = root
|
|
42
|
+
@install = install
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def run
|
|
46
|
+
public_send(:"add_#{@feature}")
|
|
47
|
+
self
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def add_realtime
|
|
51
|
+
add_gem("gemstack-realtime")
|
|
52
|
+
template_files("realtime", override_root: @root).each do |rel, source|
|
|
53
|
+
next if rel.start_with?("frontend/") && !File.directory?(File.join(@root, "frontend"))
|
|
54
|
+
|
|
55
|
+
write(File.join(@root, rel), File.read(source))
|
|
56
|
+
end
|
|
57
|
+
add_test_support('require "gemstack/realtime/testing"', "GemStack::Realtime::Testing")
|
|
58
|
+
bundle_install
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
STORAGE_ENV = <<~ENV
|
|
62
|
+
|
|
63
|
+
# File storage. Development and tests store files under storage/ (git-ignored).
|
|
64
|
+
# STORAGE_SERVICE=s3 # production default
|
|
65
|
+
# S3_BUCKET=my-app-uploads
|
|
66
|
+
# AWS_REGION=eu-west-3
|
|
67
|
+
# AWS_ACCESS_KEY_ID=... AWS_SECRET_ACCESS_KEY=... (or an instance role)
|
|
68
|
+
# S3_ENDPOINT=https://<account>.r2.cloudflarestorage.com # S3-compatible services
|
|
69
|
+
ENV
|
|
70
|
+
|
|
71
|
+
def add_storage
|
|
72
|
+
add_gem("gemstack-storage")
|
|
73
|
+
copy_feature_templates("storage")
|
|
74
|
+
add_routes(%(post "/uploads", to: "uploads#create" # direct uploads (gemstack add storage)\n),
|
|
75
|
+
marker: "uploads#create")
|
|
76
|
+
append_env_example(STORAGE_ENV, marker: "STORAGE_SERVICE")
|
|
77
|
+
append_line(".gitignore", "/storage/")
|
|
78
|
+
add_test_support('require "gemstack/storage/testing"', "GemStack::Storage::Testing")
|
|
79
|
+
status("note", "Gemfile", "add gem \"aws-sdk-s3\" for S3 in production (docs/storage.md)")
|
|
80
|
+
bundle_install
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# For templates: whether `gemstack add auth` ran.
|
|
84
|
+
def auth? = File.read(File.join(@root, "Gemfile")).match?(/^\s*gem "gemstack-auth"/)
|
|
85
|
+
|
|
86
|
+
def add_auth
|
|
87
|
+
require_database!("auth")
|
|
88
|
+
user_model = File.join(@root, "app/models/user.rb")
|
|
89
|
+
if File.file?(user_model) && !File.read(user_model).include?("GemStack::Auth::User")
|
|
90
|
+
raise Thor::Error, "app/models/user.rb already exists. Add `include GemStack::Auth::User` to it and " \
|
|
91
|
+
"see docs/authentication.md (\"Existing users table\") instead."
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
add_gem("gemstack-mail")
|
|
95
|
+
add_gem("gemstack-auth")
|
|
96
|
+
JobGenerator.install_migration(@root, output: @output) # emails are sent from background jobs
|
|
97
|
+
copy_feature_templates("auth", skip_migration: "*_create_auth_tables.rb")
|
|
98
|
+
include_in_application_controller("GemStack::Auth::Controller")
|
|
99
|
+
add_routes(AUTH_ROUTES, marker: "/auth/login")
|
|
100
|
+
append_env_example(MAIL_ENV, marker: "SMTP_URL")
|
|
101
|
+
add_test_support('require "gemstack/mail/testing"', "GemStack::Mail::Testing")
|
|
102
|
+
add_test_support('require "gemstack/auth/testing"', "GemStack::Auth::Testing")
|
|
103
|
+
bundle_install
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
private
|
|
107
|
+
|
|
108
|
+
def frontend? = File.directory?(File.join(@root, "frontend"))
|
|
109
|
+
|
|
110
|
+
def require_database!(feature)
|
|
111
|
+
return if File.read(File.join(@root, "Gemfile")).match?(/^\s*gem "gemstack-db"/)
|
|
112
|
+
|
|
113
|
+
raise Thor::Error, "#{feature} stores its data in PostgreSQL; this app was created without a database " \
|
|
114
|
+
"(add gem \"gemstack-db\" first — docs/database.md)"
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Writes a feature's templates (frontend/ files only when the app has
|
|
118
|
+
# one). %timestamp% in a path becomes a fresh migration version, unless a
|
|
119
|
+
# migration matching skip_migration already exists.
|
|
120
|
+
def copy_feature_templates(name, skip_migration: nil)
|
|
121
|
+
has_migration = skip_migration && !Dir.glob(File.join(@root, "db/migrations", skip_migration)).empty?
|
|
122
|
+
template_files(name, override_root: @root).sort.each do |rel, source|
|
|
123
|
+
next if rel.start_with?("frontend/") && !frontend?
|
|
124
|
+
|
|
125
|
+
if rel.include?("%timestamp%")
|
|
126
|
+
next status("identical", rel, "migration already present") if has_migration
|
|
127
|
+
|
|
128
|
+
rel = rel.sub("%timestamp%", Generator.migration_timestamp(@root))
|
|
129
|
+
end
|
|
130
|
+
content = File.read(source)
|
|
131
|
+
content = render(content, source) if rel.end_with?(".tt")
|
|
132
|
+
write(File.join(@root, rel.delete_suffix(".tt")), content)
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def include_in_application_controller(mixin)
|
|
137
|
+
path = File.join(@root, "app/controllers/application_controller.rb")
|
|
138
|
+
return status("skip", path, "not found — add `include #{mixin}` yourself") unless File.file?(path)
|
|
139
|
+
|
|
140
|
+
content = File.read(path)
|
|
141
|
+
return status("identical", path, mixin) if content.include?(mixin)
|
|
142
|
+
|
|
143
|
+
updated = content.sub(/^class ApplicationController < .*\n/) { |line| "#{line} include #{mixin}\n" }
|
|
144
|
+
return status("skip", path, "add `include #{mixin}` yourself") if updated == content
|
|
145
|
+
|
|
146
|
+
File.write(path, updated)
|
|
147
|
+
status("update", path, mixin)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def add_routes(block, marker:)
|
|
151
|
+
path = File.join(@root, "config/routes.rb")
|
|
152
|
+
content = File.file?(path) ? File.read(path) : ""
|
|
153
|
+
return status("identical", path, "routes") if content.include?(marker)
|
|
154
|
+
return status("skip", path, "no `GemStack.routes do` block — add the routes yourself") unless
|
|
155
|
+
content.match?(ControllerGenerator::ROUTES_BLOCK)
|
|
156
|
+
|
|
157
|
+
File.write(path, content.sub(ControllerGenerator::ROUTES_BLOCK) { |open| open + block.gsub(/^/, " ") })
|
|
158
|
+
status("route", path)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def append_line(file, line)
|
|
162
|
+
path = File.join(@root, file)
|
|
163
|
+
return unless File.file?(path)
|
|
164
|
+
|
|
165
|
+
content = File.read(path)
|
|
166
|
+
return if content.lines.map(&:strip).include?(line)
|
|
167
|
+
|
|
168
|
+
File.write(path, "#{content.rstrip}\n#{line}\n")
|
|
169
|
+
status("update", path, line)
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def append_env_example(text, marker:)
|
|
173
|
+
path = File.join(@root, ".env.example")
|
|
174
|
+
return unless File.file?(path)
|
|
175
|
+
|
|
176
|
+
content = File.read(path)
|
|
177
|
+
return if content.include?(marker)
|
|
178
|
+
|
|
179
|
+
File.write(path, "#{content.rstrip}\n#{text}")
|
|
180
|
+
status("update", path)
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# Adds the gem inside the GemStack `path` block (checkout apps) or as a
|
|
184
|
+
# versioned dependency next to gem "gemstack".
|
|
185
|
+
def add_gem(name)
|
|
186
|
+
path = File.join(@root, "Gemfile")
|
|
187
|
+
content = File.read(path)
|
|
188
|
+
return status("identical", path, name) if content.match?(/^\s*gem "#{name}"/)
|
|
189
|
+
|
|
190
|
+
updated = content.match?(/^path ".*" do\n/) ? add_to_path_block(content, name) : add_versioned(content, name)
|
|
191
|
+
if updated == content
|
|
192
|
+
raise Thor::Error,
|
|
193
|
+
"couldn't find where to add #{name} in the Gemfile; add `gem \"#{name}\"` yourself"
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
File.write(path, updated)
|
|
197
|
+
status("gemfile", path, name)
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# Keeps the block's gems in alphabetical order (Bundler/OrderedGems).
|
|
201
|
+
def add_to_path_block(content, name)
|
|
202
|
+
content.sub(/^(path ".*" do\n)((?: gem .*\n)*)/) do
|
|
203
|
+
opening = ::Regexp.last_match(1)
|
|
204
|
+
lines = ::Regexp.last_match(2) # before sort_by's regexes reset last_match
|
|
205
|
+
gems = (lines.lines + [" gem \"#{name}\"\n"]).sort_by { |line| line[/"([^"]+)"/, 1] }
|
|
206
|
+
opening + gems.join
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def add_versioned(content, name)
|
|
211
|
+
content.sub(/^gem "gemstack",.*\n/) { |line| "#{line}gem \"#{name}\", \"~> #{GemStack::VERSION}\"\n" }
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def add_test_support(require_line, mixin)
|
|
215
|
+
path = File.join(@root, "test/test_helper.rb")
|
|
216
|
+
return unless File.file?(path)
|
|
217
|
+
|
|
218
|
+
content = File.read(path)
|
|
219
|
+
return status("identical", path) if content.include?(mixin)
|
|
220
|
+
|
|
221
|
+
content = content.sub(%(require "gemstack/testing"\n)) { |line| "#{line}#{require_line}\n" }
|
|
222
|
+
content = "#{content.rstrip}\nGemStack::TestCase.include #{mixin}\n"
|
|
223
|
+
File.write(path, content)
|
|
224
|
+
status("update", path, mixin)
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def bundle_install
|
|
228
|
+
return unless @install
|
|
229
|
+
|
|
230
|
+
status("run", "bundle install")
|
|
231
|
+
install = -> { system("bundle", "install", "--quiet", chdir: @root) }
|
|
232
|
+
ok = defined?(Bundler) ? Bundler.with_unbundled_env(&install) : install.call
|
|
233
|
+
status("warning", "bundle install failed — run it yourself") unless ok
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
end
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GemStack
|
|
4
|
+
class CLI < Thor
|
|
5
|
+
# `gemstack new NAME` — a Ruby API + Next.js application containing the
|
|
6
|
+
# framework infrastructure and deliberately no business resources.
|
|
7
|
+
class AppGenerator < Generator
|
|
8
|
+
NAME = /\A[a-z][a-z0-9_-]*\z/
|
|
9
|
+
|
|
10
|
+
# The gems/ directory of the GemStack checkout this CLI runs from, if any.
|
|
11
|
+
CHECKOUT = File.expand_path("../../../..", __dir__)
|
|
12
|
+
|
|
13
|
+
attr_reader :name, :module_name, :destination, :gemstack_path, :api_path
|
|
14
|
+
|
|
15
|
+
def initialize(name, options = {}, output: $stdout)
|
|
16
|
+
super(output: output)
|
|
17
|
+
@name = File.basename(name.to_s)
|
|
18
|
+
@destination = File.expand_path(name.to_s)
|
|
19
|
+
@options = options
|
|
20
|
+
@module_name = Inflector.camelize(@name.tr("-", "_"))
|
|
21
|
+
@gemstack_path = options[:gemstack_path] ? File.expand_path(options[:gemstack_path]) : detect_checkout
|
|
22
|
+
@api_path = "/api"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def version = GemStack::VERSION
|
|
26
|
+
|
|
27
|
+
# Pins the app to the Ruby that generated it (.ruby-version, .tool-versions).
|
|
28
|
+
def ruby_version = RUBY_VERSION
|
|
29
|
+
def frontend? = !@options[:skip_frontend]
|
|
30
|
+
def database? = !@options[:skip_database]
|
|
31
|
+
|
|
32
|
+
def run
|
|
33
|
+
validate!
|
|
34
|
+
@output.puts("Creating GemStack application #{name} in #{destination}")
|
|
35
|
+
render_directory("app", destination, skip: ->(rel) { !database? && rel.start_with?("db/") })
|
|
36
|
+
render_directory("frontend", File.join(destination, "frontend")) if frontend?
|
|
37
|
+
install unless @options[:skip_install]
|
|
38
|
+
git_init unless @options[:skip_git]
|
|
39
|
+
summary
|
|
40
|
+
self
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def validate!
|
|
46
|
+
unless NAME.match?(name)
|
|
47
|
+
raise Thor::Error,
|
|
48
|
+
"Invalid name #{name.inspect}: use lowercase letters, digits, - and _, starting with a letter"
|
|
49
|
+
end
|
|
50
|
+
return unless File.exist?(destination) && !Dir.empty?(destination)
|
|
51
|
+
|
|
52
|
+
raise Thor::Error, "#{destination} already exists and is not empty"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def detect_checkout
|
|
56
|
+
File.file?(File.join(CHECKOUT, "gemstack", "gemstack.gemspec")) ? CHECKOUT : nil
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def install
|
|
60
|
+
bundled = run_step("bundle install", destination, "bundle", "install", "--quiet")
|
|
61
|
+
create_database if bundled && database?
|
|
62
|
+
return unless frontend?
|
|
63
|
+
|
|
64
|
+
run_step("npm install", File.join(destination, "frontend"), "npm", "install", "--no-fund",
|
|
65
|
+
"--no-audit")
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Best effort: a missing or password-protected PostgreSQL shouldn't fail `new`.
|
|
69
|
+
def create_database
|
|
70
|
+
@output.puts(" #{"run".rjust(9)} gemstack db:create")
|
|
71
|
+
ok = unbundled { system("bin/gemstack", "db:create", chdir: destination, out: File::NULL, err: File::NULL) }
|
|
72
|
+
return if ok
|
|
73
|
+
|
|
74
|
+
@output.puts(" #{"warning".rjust(9)} couldn't create the database — " \
|
|
75
|
+
"set DATABASE_URL in .env, then run `gemstack db:create`")
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def git_init
|
|
79
|
+
return if File.directory?(File.join(destination, ".git")) || !system("git --version", out: File::NULL)
|
|
80
|
+
|
|
81
|
+
run_step("git init", destination, "git", "init", "--quiet")
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def run_step(label, dir, *command)
|
|
85
|
+
@output.puts(" #{"run".rjust(9)} #{label}")
|
|
86
|
+
ok = unbundled { system(*command, chdir: dir) }
|
|
87
|
+
@output.puts(" #{"warning".rjust(9)} `#{label}` failed — run it manually in #{dir}") unless ok
|
|
88
|
+
ok
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Run child commands with the new app's bundle, not the CLI's.
|
|
92
|
+
def unbundled(&)
|
|
93
|
+
defined?(Bundler) ? Bundler.with_unbundled_env(&) : yield
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def summary
|
|
97
|
+
@output.puts <<~TEXT
|
|
98
|
+
|
|
99
|
+
GemStack application #{name} created.
|
|
100
|
+
|
|
101
|
+
cd #{name}
|
|
102
|
+
gemstack dev # → http://localhost:3000
|
|
103
|
+
|
|
104
|
+
Next: gemstack generate resource Product name:string price:decimal
|
|
105
|
+
Useful commands: gemstack routes · gemstack test · gemstack console · gemstack db:migrate
|
|
106
|
+
TEXT
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
@@ -0,0 +1,134 @@
|
|
|
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.db.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 use gemstack-db. Add `gem \"gemstack-db\"` to the Gemfile.")
|
|
108
|
+
end
|
|
109
|
+
GemStack.config.reload_code = false
|
|
110
|
+
GemStack.config.db.log_queries = false
|
|
111
|
+
GemStack.config.logger.level = :warn # these commands print their own summary
|
|
112
|
+
GemStack.boot! if boot
|
|
113
|
+
yield
|
|
114
|
+
rescue Sequel::DatabaseConnectionError => e
|
|
115
|
+
abort("Can't connect to #{redact(DB.config.url)}: #{e.message.lines.first.strip}\n" \
|
|
116
|
+
"Set DATABASE_URL (e.g. in .env) — see docs/models.md.")
|
|
117
|
+
rescue GemStack::Error => e
|
|
118
|
+
abort(e.message)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def redact(url) = url.sub(%r{//([^:/@]+):[^@]+@}, '//\1:***@')
|
|
122
|
+
|
|
123
|
+
def print_contract_report(report, contract, root)
|
|
124
|
+
rel = ->(path) { path.delete_prefix("#{root}/") }
|
|
125
|
+
report[:written].each { |path| say(" #{"write".rjust(9)} #{rel.call(path)}") }
|
|
126
|
+
report[:removed].each { |path| say(" #{"remove".rjust(9)} #{rel.call(path)}") }
|
|
127
|
+
contract[:warnings].each { |w| say(" #{"warning".rjust(9)} #{w}", :yellow) } unless options[:quiet]
|
|
128
|
+
return if options[:quiet] || !report[:written].empty? || !report[:removed].empty?
|
|
129
|
+
|
|
130
|
+
say("Contract up to date (#{contract[:resources].size} resources, #{contract[:types].size} types).")
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
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::Postgres.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::Postgres.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::Postgres.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::Postgres.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 use gemstack-jobs. Add `gem "gemstack-jobs"` to the Gemfile.')
|
|
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
|