tina4ruby 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +80 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +768 -0
  5. data/exe/tina4 +4 -0
  6. data/lib/tina4/api.rb +152 -0
  7. data/lib/tina4/auth.rb +139 -0
  8. data/lib/tina4/cli.rb +349 -0
  9. data/lib/tina4/crud.rb +124 -0
  10. data/lib/tina4/database.rb +135 -0
  11. data/lib/tina4/database_result.rb +89 -0
  12. data/lib/tina4/debug.rb +83 -0
  13. data/lib/tina4/dev.rb +15 -0
  14. data/lib/tina4/dev_reload.rb +68 -0
  15. data/lib/tina4/drivers/firebird_driver.rb +94 -0
  16. data/lib/tina4/drivers/mssql_driver.rb +112 -0
  17. data/lib/tina4/drivers/mysql_driver.rb +90 -0
  18. data/lib/tina4/drivers/postgres_driver.rb +99 -0
  19. data/lib/tina4/drivers/sqlite_driver.rb +85 -0
  20. data/lib/tina4/env.rb +55 -0
  21. data/lib/tina4/field_types.rb +84 -0
  22. data/lib/tina4/graphql.rb +837 -0
  23. data/lib/tina4/localization.rb +100 -0
  24. data/lib/tina4/middleware.rb +59 -0
  25. data/lib/tina4/migration.rb +124 -0
  26. data/lib/tina4/orm.rb +168 -0
  27. data/lib/tina4/public/css/tina4.css +2286 -0
  28. data/lib/tina4/public/css/tina4.min.css +2 -0
  29. data/lib/tina4/public/js/tina4.js +134 -0
  30. data/lib/tina4/public/js/tina4helper.js +387 -0
  31. data/lib/tina4/queue.rb +117 -0
  32. data/lib/tina4/queue_backends/kafka_backend.rb +80 -0
  33. data/lib/tina4/queue_backends/lite_backend.rb +79 -0
  34. data/lib/tina4/queue_backends/rabbitmq_backend.rb +73 -0
  35. data/lib/tina4/rack_app.rb +150 -0
  36. data/lib/tina4/request.rb +158 -0
  37. data/lib/tina4/response.rb +172 -0
  38. data/lib/tina4/router.rb +148 -0
  39. data/lib/tina4/scss/tina4css/_alerts.scss +34 -0
  40. data/lib/tina4/scss/tina4css/_badges.scss +22 -0
  41. data/lib/tina4/scss/tina4css/_buttons.scss +69 -0
  42. data/lib/tina4/scss/tina4css/_cards.scss +49 -0
  43. data/lib/tina4/scss/tina4css/_forms.scss +156 -0
  44. data/lib/tina4/scss/tina4css/_grid.scss +81 -0
  45. data/lib/tina4/scss/tina4css/_modals.scss +84 -0
  46. data/lib/tina4/scss/tina4css/_nav.scss +149 -0
  47. data/lib/tina4/scss/tina4css/_reset.scss +94 -0
  48. data/lib/tina4/scss/tina4css/_tables.scss +54 -0
  49. data/lib/tina4/scss/tina4css/_typography.scss +55 -0
  50. data/lib/tina4/scss/tina4css/_utilities.scss +197 -0
  51. data/lib/tina4/scss/tina4css/_variables.scss +117 -0
  52. data/lib/tina4/scss/tina4css/base.scss +1 -0
  53. data/lib/tina4/scss/tina4css/colors.scss +48 -0
  54. data/lib/tina4/scss/tina4css/tina4.scss +17 -0
  55. data/lib/tina4/scss_compiler.rb +131 -0
  56. data/lib/tina4/seeder.rb +529 -0
  57. data/lib/tina4/session.rb +145 -0
  58. data/lib/tina4/session_handlers/file_handler.rb +55 -0
  59. data/lib/tina4/session_handlers/mongo_handler.rb +49 -0
  60. data/lib/tina4/session_handlers/redis_handler.rb +43 -0
  61. data/lib/tina4/swagger.rb +123 -0
  62. data/lib/tina4/template.rb +478 -0
  63. data/lib/tina4/templates/base.twig +26 -0
  64. data/lib/tina4/templates/errors/403.twig +22 -0
  65. data/lib/tina4/templates/errors/404.twig +22 -0
  66. data/lib/tina4/templates/errors/500.twig +22 -0
  67. data/lib/tina4/testing.rb +213 -0
  68. data/lib/tina4/version.rb +5 -0
  69. data/lib/tina4/webserver.rb +101 -0
  70. data/lib/tina4/websocket.rb +167 -0
  71. data/lib/tina4/wsdl.rb +164 -0
  72. data/lib/tina4.rb +259 -0
  73. data/lib/tina4ruby.rb +4 -0
  74. metadata +324 -0
data/exe/tina4 ADDED
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+ require_relative "../lib/tina4/cli"
4
+ Tina4::CLI.start(ARGV)
data/lib/tina4/api.rb ADDED
@@ -0,0 +1,152 @@
1
+ # frozen_string_literal: true
2
+ require "net/http"
3
+ require "uri"
4
+ require "json"
5
+
6
+ module Tina4
7
+ class API
8
+ attr_reader :base_url, :headers
9
+
10
+ def initialize(base_url, headers: {}, timeout: 30)
11
+ @base_url = base_url.chomp("/")
12
+ @headers = {
13
+ "Content-Type" => "application/json",
14
+ "Accept" => "application/json"
15
+ }.merge(headers)
16
+ @timeout = timeout
17
+ end
18
+
19
+ def get(path, params: {}, headers: {})
20
+ uri = build_uri(path, params)
21
+ request = Net::HTTP::Get.new(uri)
22
+ apply_headers(request, headers)
23
+ execute(uri, request)
24
+ end
25
+
26
+ def post(path, body: nil, headers: {})
27
+ uri = build_uri(path)
28
+ request = Net::HTTP::Post.new(uri)
29
+ request.body = body.is_a?(String) ? body : JSON.generate(body) if body
30
+ apply_headers(request, headers)
31
+ execute(uri, request)
32
+ end
33
+
34
+ def put(path, body: nil, headers: {})
35
+ uri = build_uri(path)
36
+ request = Net::HTTP::Put.new(uri)
37
+ request.body = body.is_a?(String) ? body : JSON.generate(body) if body
38
+ apply_headers(request, headers)
39
+ execute(uri, request)
40
+ end
41
+
42
+ def patch(path, body: nil, headers: {})
43
+ uri = build_uri(path)
44
+ request = Net::HTTP::Patch.new(uri)
45
+ request.body = body.is_a?(String) ? body : JSON.generate(body) if body
46
+ apply_headers(request, headers)
47
+ execute(uri, request)
48
+ end
49
+
50
+ def delete(path, headers: {})
51
+ uri = build_uri(path)
52
+ request = Net::HTTP::Delete.new(uri)
53
+ apply_headers(request, headers)
54
+ execute(uri, request)
55
+ end
56
+
57
+ def upload(path, file_path, field_name: "file", extra_fields: {}, headers: {})
58
+ uri = build_uri(path)
59
+ boundary = "----Tina4Boundary#{SecureRandom.hex(16)}"
60
+
61
+ body = build_multipart_body(boundary, file_path, field_name, extra_fields)
62
+
63
+ request = Net::HTTP::Post.new(uri)
64
+ request.body = body
65
+ request["Content-Type"] = "multipart/form-data; boundary=#{boundary}"
66
+ headers.each { |k, v| request[k] = v }
67
+ execute(uri, request)
68
+ end
69
+
70
+ private
71
+
72
+ def build_uri(path, params = {})
73
+ url = "#{@base_url}#{path}"
74
+ uri = URI.parse(url)
75
+ unless params.empty?
76
+ query = URI.encode_www_form(params)
77
+ uri.query = uri.query ? "#{uri.query}&#{query}" : query
78
+ end
79
+ uri
80
+ end
81
+
82
+ def apply_headers(request, extra_headers)
83
+ @headers.merge(extra_headers).each do |key, value|
84
+ request[key] = value
85
+ end
86
+ end
87
+
88
+ def execute(uri, request)
89
+ http = Net::HTTP.new(uri.host, uri.port)
90
+ http.use_ssl = uri.scheme == "https"
91
+ http.open_timeout = @timeout
92
+ http.read_timeout = @timeout
93
+
94
+ response = http.request(request)
95
+
96
+ APIResponse.new(
97
+ status: response.code.to_i,
98
+ body: response.body,
99
+ headers: response.to_hash
100
+ )
101
+ rescue StandardError => e
102
+ APIResponse.new(
103
+ status: 0,
104
+ body: "",
105
+ headers: {},
106
+ error: e.message
107
+ )
108
+ end
109
+
110
+ def build_multipart_body(boundary, file_path, field_name, extra_fields)
111
+ body = ""
112
+ extra_fields.each do |key, value|
113
+ body += "--#{boundary}\r\n"
114
+ body += "Content-Disposition: form-data; name=\"#{key}\"\r\n\r\n"
115
+ body += "#{value}\r\n"
116
+ end
117
+
118
+ filename = File.basename(file_path)
119
+ body += "--#{boundary}\r\n"
120
+ body += "Content-Disposition: form-data; name=\"#{field_name}\"; filename=\"#{filename}\"\r\n"
121
+ body += "Content-Type: application/octet-stream\r\n\r\n"
122
+ body += File.binread(file_path)
123
+ body += "\r\n--#{boundary}--\r\n"
124
+ body
125
+ end
126
+ end
127
+
128
+ class APIResponse
129
+ attr_reader :status, :body, :headers, :error
130
+
131
+ def initialize(status:, body:, headers:, error: nil)
132
+ @status = status
133
+ @body = body
134
+ @headers = headers
135
+ @error = error
136
+ end
137
+
138
+ def success?
139
+ @status >= 200 && @status < 300
140
+ end
141
+
142
+ def json
143
+ @json ||= JSON.parse(@body)
144
+ rescue JSON::ParserError
145
+ {}
146
+ end
147
+
148
+ def to_s
149
+ "APIResponse(status=#{@status})"
150
+ end
151
+ end
152
+ end
data/lib/tina4/auth.rb ADDED
@@ -0,0 +1,139 @@
1
+ # frozen_string_literal: true
2
+ require "openssl"
3
+ require "base64"
4
+ require "json"
5
+ require "fileutils"
6
+
7
+ module Tina4
8
+ module Auth
9
+ KEYS_DIR = ".keys"
10
+
11
+ class << self
12
+ def setup(root_dir = Dir.pwd)
13
+ @keys_dir = File.join(root_dir, KEYS_DIR)
14
+ FileUtils.mkdir_p(@keys_dir)
15
+ ensure_keys
16
+ end
17
+
18
+ def generate_token(payload, expires_in: 3600)
19
+ ensure_keys
20
+ now = Time.now.to_i
21
+ claims = payload.merge(
22
+ "iat" => now,
23
+ "exp" => now + expires_in,
24
+ "nbf" => now
25
+ )
26
+ require "jwt"
27
+ JWT.encode(claims, private_key, "RS256")
28
+ end
29
+
30
+ def validate_token(token)
31
+ ensure_keys
32
+ require "jwt"
33
+ decoded = JWT.decode(token, public_key, true, algorithm: "RS256")
34
+ { valid: true, payload: decoded[0] }
35
+ rescue JWT::ExpiredSignature
36
+ { valid: false, error: "Token expired" }
37
+ rescue JWT::DecodeError => e
38
+ { valid: false, error: e.message }
39
+ end
40
+
41
+ def hash_password(password)
42
+ require "bcrypt"
43
+ BCrypt::Password.create(password)
44
+ end
45
+
46
+ def verify_password(password, hash)
47
+ require "bcrypt"
48
+ BCrypt::Password.new(hash) == password
49
+ rescue BCrypt::Errors::InvalidHash
50
+ false
51
+ end
52
+
53
+ def auth_handler(&block)
54
+ if block_given?
55
+ @custom_handler = block
56
+ else
57
+ @custom_handler || method(:default_auth_handler)
58
+ end
59
+ end
60
+
61
+ def bearer_auth
62
+ lambda do |env|
63
+ auth_header = env["HTTP_AUTHORIZATION"] || ""
64
+ return false unless auth_header =~ /\ABearer\s+(.+)\z/i
65
+
66
+ token = Regexp.last_match(1)
67
+
68
+ # API_KEY bypass — matches tina4_python behavior
69
+ api_key = ENV["API_KEY"]
70
+ if api_key && !api_key.empty? && token == api_key
71
+ env["tina4.auth"] = { "api_key" => true }
72
+ return true
73
+ end
74
+
75
+ result = validate_token(token)
76
+ if result[:valid]
77
+ env["tina4.auth"] = result[:payload]
78
+ true
79
+ else
80
+ false
81
+ end
82
+ end
83
+ end
84
+
85
+ # Default auth handler for secured routes (POST/PUT/PATCH/DELETE)
86
+ # Used automatically unless auth: false is passed
87
+ def default_secure_auth
88
+ @default_secure_auth ||= bearer_auth
89
+ end
90
+
91
+ def private_key
92
+ @private_key ||= OpenSSL::PKey::RSA.new(File.read(private_key_path))
93
+ end
94
+
95
+ def public_key
96
+ @public_key ||= OpenSSL::PKey::RSA.new(File.read(public_key_path))
97
+ end
98
+
99
+ private
100
+
101
+ def ensure_keys
102
+ @keys_dir ||= File.join(Dir.pwd, KEYS_DIR)
103
+ FileUtils.mkdir_p(@keys_dir)
104
+ unless File.exist?(private_key_path) && File.exist?(public_key_path)
105
+ generate_keys
106
+ end
107
+ end
108
+
109
+ def generate_keys
110
+ Tina4::Debug.info("Generating RSA key pair for JWT authentication")
111
+ key = OpenSSL::PKey::RSA.generate(2048)
112
+ File.write(private_key_path, key.to_pem)
113
+ File.write(public_key_path, key.public_key.to_pem)
114
+ @private_key = nil
115
+ @public_key = nil
116
+ end
117
+
118
+ def private_key_path
119
+ File.join(@keys_dir, "private.pem")
120
+ end
121
+
122
+ def public_key_path
123
+ File.join(@keys_dir, "public.pem")
124
+ end
125
+
126
+ def default_auth_handler(env)
127
+ auth_header = env["HTTP_AUTHORIZATION"] || ""
128
+ return true if auth_header.empty?
129
+
130
+ if auth_header =~ /\ABearer\s+(.+)\z/i
131
+ result = validate_token(Regexp.last_match(1))
132
+ result[:valid]
133
+ else
134
+ false
135
+ end
136
+ end
137
+ end
138
+ end
139
+ end
data/lib/tina4/cli.rb ADDED
@@ -0,0 +1,349 @@
1
+ # frozen_string_literal: true
2
+ require "thor"
3
+ require "fileutils"
4
+
5
+ module Tina4
6
+ class CLI < Thor
7
+ desc "init [NAME]", "Initialize a new Tina4 project"
8
+ option :template, type: :string, default: "default", desc: "Project template"
9
+ def init(name = ".")
10
+ dir = name == "." ? Dir.pwd : File.join(Dir.pwd, name)
11
+ FileUtils.mkdir_p(dir)
12
+
13
+ create_project_structure(dir)
14
+ create_sample_files(dir, name == "." ? File.basename(Dir.pwd) : name)
15
+
16
+ puts "Tina4 project initialized in #{dir}"
17
+ puts "Run 'cd #{name} && bundle install && tina4 start' to get started" unless name == "."
18
+ end
19
+
20
+ desc "start", "Start the Tina4 web server"
21
+ option :port, type: :numeric, default: 7145, aliases: "-p"
22
+ option :host, type: :string, default: "0.0.0.0", aliases: "-h"
23
+ option :dev, type: :boolean, default: false, aliases: "-d", desc: "Enable dev mode with auto-reload"
24
+ def start
25
+ require_relative "../tina4"
26
+
27
+ root_dir = Dir.pwd
28
+ Tina4.initialize!(root_dir)
29
+
30
+ # Load route files
31
+ load_routes(root_dir)
32
+
33
+ if options[:dev]
34
+ Tina4::DevReload.start(root_dir: root_dir)
35
+ Tina4::ScssCompiler.compile_all(root_dir)
36
+ end
37
+
38
+ app = Tina4::RackApp.new(root_dir: root_dir)
39
+
40
+ # Try Puma first (production-grade), fall back to WEBrick
41
+ begin
42
+ require "puma"
43
+ require "puma/configuration"
44
+ require "puma/launcher"
45
+
46
+ puma_host = options[:host]
47
+ puma_port = options[:port]
48
+
49
+ config = Puma::Configuration.new do |user_config|
50
+ user_config.bind "tcp://#{puma_host}:#{puma_port}"
51
+ user_config.app app
52
+ user_config.threads 0, 16
53
+ user_config.workers 0
54
+ user_config.environment "development"
55
+ user_config.log_requests false
56
+ user_config.quiet
57
+ end
58
+
59
+ Tina4::Debug.info("Starting Puma server on http://#{puma_host}:#{puma_port}")
60
+ launcher = Puma::Launcher.new(config)
61
+ launcher.run
62
+ rescue LoadError
63
+ Tina4::Debug.info("Puma not found, falling back to WEBrick")
64
+ server = Tina4::WebServer.new(app, host: options[:host], port: options[:port])
65
+ server.start
66
+ end
67
+ end
68
+
69
+ desc "migrate", "Run database migrations"
70
+ option :create, type: :string, desc: "Create a new migration"
71
+ option :rollback, type: :numeric, desc: "Rollback N migrations"
72
+ def migrate
73
+ require_relative "../tina4"
74
+ Tina4.initialize!(Dir.pwd)
75
+
76
+ db = Tina4.database
77
+ unless db
78
+ puts "No database configured. Set DATABASE_URL in your .env file."
79
+ return
80
+ end
81
+
82
+ migration = Tina4::Migration.new(db)
83
+
84
+ if options[:create]
85
+ path = migration.create(options[:create])
86
+ puts "Created migration: #{path}"
87
+ elsif options[:rollback]
88
+ migration.rollback(options[:rollback])
89
+ puts "Rolled back #{options[:rollback]} migration(s)"
90
+ else
91
+ results = migration.run
92
+ if results.empty?
93
+ puts "No pending migrations"
94
+ else
95
+ results.each do |r|
96
+ status_icon = r[:status] == "success" ? "OK" : "FAIL"
97
+ puts " [#{status_icon}] #{r[:name]}"
98
+ end
99
+ end
100
+ end
101
+ end
102
+
103
+ desc "test", "Run inline tests"
104
+ def test
105
+ require_relative "../tina4"
106
+ Tina4.initialize!(Dir.pwd)
107
+
108
+ # Load test files
109
+ test_dirs = %w[tests test spec src/tests]
110
+ test_dirs.each do |dir|
111
+ test_dir = File.join(Dir.pwd, dir)
112
+ next unless Dir.exist?(test_dir)
113
+ Dir.glob(File.join(test_dir, "**/*_test.rb")).sort.each { |f| load f }
114
+ Dir.glob(File.join(test_dir, "**/test_*.rb")).sort.each { |f| load f }
115
+ end
116
+
117
+ # Also load inline tests from routes
118
+ load_routes(Dir.pwd)
119
+
120
+ results = Tina4::Testing.run_all
121
+ exit(1) if results[:failed] > 0 || results[:errors] > 0
122
+ end
123
+
124
+ desc "version", "Show Tina4 version"
125
+ def version
126
+ require_relative "version"
127
+ puts "Tina4 Ruby v#{Tina4::VERSION}"
128
+ end
129
+
130
+ desc "routes", "List all registered routes"
131
+ def routes
132
+ require_relative "../tina4"
133
+ Tina4.initialize!(Dir.pwd)
134
+ load_routes(Dir.pwd)
135
+
136
+ puts "\nRegistered Routes:"
137
+ puts "-" * 60
138
+ Tina4::Router.routes.each do |route|
139
+ auth = route.auth_handler ? " [AUTH]" : ""
140
+ puts " #{route.method.ljust(8)} #{route.path}#{auth}"
141
+ end
142
+ puts "-" * 60
143
+ puts "Total: #{Tina4::Router.routes.length} routes\n"
144
+ end
145
+
146
+ desc "seed", "Run all seed files in seeds/"
147
+ option :clear, type: :boolean, default: false, desc: "Clear tables before seeding"
148
+ def seed
149
+ require_relative "../tina4"
150
+ Tina4.initialize!(Dir.pwd)
151
+ load_routes(Dir.pwd)
152
+ Tina4.seed(seed_folder: "seeds", clear: options[:clear])
153
+ end
154
+
155
+ desc "seed:create NAME", "Create a new seed file"
156
+ def seed_create(name)
157
+ dir = File.join(Dir.pwd, "seeds")
158
+ FileUtils.mkdir_p(dir)
159
+
160
+ existing = Dir.glob(File.join(dir, "*.rb")).select { |f| File.basename(f)[0] =~ /\d/ }.sort
161
+ numbers = existing.map { |f| File.basename(f).match(/^(\d+)/)[1].to_i }
162
+ next_num = numbers.empty? ? 1 : numbers.max + 1
163
+
164
+ clean_name = name.strip.downcase.gsub(/[^a-z0-9]+/, "_").gsub(/^_|_$/, "")
165
+ filename = format("%03d_%s.rb", next_num, clean_name)
166
+ filepath = File.join(dir, filename)
167
+
168
+ File.write(filepath, <<~RUBY)
169
+ # Seed: #{name.strip}
170
+ #
171
+ # This file is executed by `tina4 seed`.
172
+ # Use Tina4.seed_orm or Tina4.seed_table to populate data.
173
+ #
174
+ # Examples:
175
+ # Tina4.seed_orm(User, count: 50)
176
+ # Tina4.seed_table("audit_log", { action: :string, created_at: :datetime }, count: 100)
177
+ RUBY
178
+
179
+ puts "Created seed file: #{filepath}"
180
+ end
181
+
182
+ desc "console", "Start an interactive console"
183
+ def console
184
+ require_relative "../tina4"
185
+ Tina4.initialize!(Dir.pwd)
186
+ load_routes(Dir.pwd)
187
+
188
+ require "irb"
189
+ IRB.start
190
+ end
191
+
192
+ private
193
+
194
+ def load_routes(root_dir)
195
+ route_dirs = %w[routes src/routes src/api api]
196
+ route_dirs.each do |dir|
197
+ route_dir = File.join(root_dir, dir)
198
+ next unless Dir.exist?(route_dir)
199
+ Dir.glob(File.join(route_dir, "**/*.rb")).sort.each { |f| load f }
200
+ end
201
+
202
+ # Also load app.rb if it exists
203
+ app_file = File.join(root_dir, "app.rb")
204
+ load app_file if File.exist?(app_file)
205
+
206
+ index_file = File.join(root_dir, "index.rb")
207
+ load index_file if File.exist?(index_file)
208
+ end
209
+
210
+ def create_project_structure(dir)
211
+ %w[
212
+ routes templates public public/css public/js public/images
213
+ migrations src logs
214
+ ].each do |subdir|
215
+ FileUtils.mkdir_p(File.join(dir, subdir))
216
+ end
217
+ end
218
+
219
+ def create_sample_files(dir, project_name)
220
+ # app.rb
221
+ unless File.exist?(File.join(dir, "app.rb"))
222
+ File.write(File.join(dir, "app.rb"), <<~RUBY)
223
+ require "tina4"
224
+
225
+ Tina4.get "/" do |request, response|
226
+ response.html "<h1>Welcome to #{project_name}!</h1><p>Powered by Tina4 Ruby</p>"
227
+ end
228
+
229
+ Tina4.get "/api/hello" do |request, response|
230
+ response.json({ message: "Hello from Tina4!", timestamp: Time.now.iso8601 })
231
+ end
232
+ RUBY
233
+ end
234
+
235
+ # Gemfile
236
+ unless File.exist?(File.join(dir, "Gemfile"))
237
+ File.write(File.join(dir, "Gemfile"), <<~RUBY)
238
+ source "https://rubygems.org"
239
+ gem "tina4ruby"
240
+ RUBY
241
+ end
242
+
243
+ # .gitignore
244
+ unless File.exist?(File.join(dir, ".gitignore"))
245
+ File.write(File.join(dir, ".gitignore"), <<~TEXT)
246
+ .env
247
+ .keys/
248
+ logs/
249
+ sessions/
250
+ .queue/
251
+ *.db
252
+ vendor/
253
+ TEXT
254
+ end
255
+
256
+ # Dockerfile
257
+ unless File.exist?(File.join(dir, "Dockerfile"))
258
+ File.write(File.join(dir, "Dockerfile"), <<~DOCKERFILE)
259
+ # === Build Stage ===
260
+ FROM ruby:3.3-alpine AS builder
261
+
262
+ # Install build dependencies
263
+ RUN apk add --no-cache \\
264
+ build-base \\
265
+ libffi-dev \\
266
+ gcompat
267
+
268
+ WORKDIR /app
269
+
270
+ # Copy dependency definition first (layer caching)
271
+ COPY Gemfile Gemfile.lock* ./
272
+
273
+ # Install gems
274
+ RUN bundle config set --local without 'development test' && \\
275
+ bundle install --jobs 4 --retry 3
276
+
277
+ # Copy application code
278
+ COPY . .
279
+
280
+ # === Runtime Stage ===
281
+ FROM ruby:3.3-alpine
282
+
283
+ # Runtime packages only
284
+ RUN apk add --no-cache libffi gcompat
285
+
286
+ WORKDIR /app
287
+
288
+ # Copy installed gems
289
+ COPY --from=builder /usr/local/bundle /usr/local/bundle
290
+
291
+ # Copy application code
292
+ COPY --from=builder /app /app
293
+
294
+ EXPOSE 7145
295
+
296
+ # Swagger defaults (override with env vars in docker-compose/k8s if needed)
297
+ ENV SWAGGER_TITLE="Tina4 API"
298
+ ENV SWAGGER_VERSION="0.1.0"
299
+ ENV SWAGGER_DESCRIPTION="Auto-generated API documentation"
300
+
301
+ # Start the server on all interfaces
302
+ CMD ["bundle", "exec", "tina4", "start", "-p", "7145", "-h", "0.0.0.0"]
303
+ DOCKERFILE
304
+ puts " Created Dockerfile"
305
+ end
306
+
307
+ # .dockerignore
308
+ unless File.exist?(File.join(dir, ".dockerignore"))
309
+ File.write(File.join(dir, ".dockerignore"), <<~TEXT)
310
+ .git
311
+ .env
312
+ .keys/
313
+ logs/
314
+ sessions/
315
+ .queue/
316
+ *.db
317
+ *.gem
318
+ tmp/
319
+ spec/
320
+ vendor/bundle
321
+ TEXT
322
+ puts " Created .dockerignore"
323
+ end
324
+
325
+ # Base template
326
+ templates_dir = File.join(dir, "templates")
327
+ unless File.exist?(File.join(templates_dir, "base.twig"))
328
+ File.write(File.join(templates_dir, "base.twig"), <<~HTML)
329
+ <!DOCTYPE html>
330
+ <html lang="en">
331
+ <head>
332
+ <meta charset="UTF-8">
333
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
334
+ <title>{% block title %}#{project_name}{% endblock %}</title>
335
+ <link rel="stylesheet" href="/css/tina4.min.css">
336
+ {% block head %}{% endblock %}
337
+ </head>
338
+ <body>
339
+ {% block content %}{% endblock %}
340
+ <script src="/js/tina4.js"></script>
341
+ <script src="/js/tina4helper.js"></script>
342
+ {% block scripts %}{% endblock %}
343
+ </body>
344
+ </html>
345
+ HTML
346
+ end
347
+ end
348
+ end
349
+ end