tina4ruby 3.12.3 → 3.12.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 02f359671ab67c2f4d7f3202e8e02f6fcd432690ce034d5951cb84d7c0c297c5
4
- data.tar.gz: de61dd0e21ad0c9e979a6049b67d3bba6acfe34ada9540564230a352a71927cf
3
+ metadata.gz: be4da6079e890067fdf914b4dc287a01f68eeee914a51cd97f13e1d768d3d334
4
+ data.tar.gz: 61df8b97ae56df7aba1d40feee95d5f0208b969c5166cce97d8e6b65289ed720
5
5
  SHA512:
6
- metadata.gz: 44af7a12ed2e7cc6175f59549a4984cc24c2e6a411493fc03e7ec7e742179fb162aaea3d85f89ff48989a018f2174b77a7eb12073f05f665fa705784bc698df5
7
- data.tar.gz: 677420505af7fbbbe8f5606dd563fbcce96fecb16757ff4d952fe5155b19defbf992d5e86c8bcf9b7449f5082bfcaf742551cb3c3bc92a5312d69e9269b0c31e
6
+ metadata.gz: c2b2f07c1a30c66c114b7c77845751d6b8125125392cf8adf49689b23378c73d61b79a8882013eeccc6b28ce86dd15121437ec51e228690345c08f32d10572cf
7
+ data.tar.gz: b63ed9e9ffedece9880e1e57c43e355ec6b8ed4c5418914557ebb163e020728c5b8c0b4cfa41c08f86aae70160b2862eebbc52f128a0116cb6bc700bf4b63105
@@ -83,7 +83,7 @@ module Tina4
83
83
  }.freeze
84
84
 
85
85
  # Static factory — cross-framework consistency: Database.create(url)
86
- def self.create(url, username: "", password: "", pool: 0)
86
+ def self.create(url, username: "", password: "", pool: nil)
87
87
  new(url, username: username.empty? ? nil : username,
88
88
  password: password.empty? ? nil : password,
89
89
  pool: pool)
@@ -91,7 +91,7 @@ module Tina4
91
91
 
92
92
  # Construct a Database from environment variables.
93
93
  # Returns nil if the named env var is not set.
94
- def self.from_env(env_key: "TINA4_DATABASE_URL", pool: 0)
94
+ def self.from_env(env_key: "TINA4_DATABASE_URL", pool: nil)
95
95
  url = ENV[env_key]
96
96
  return nil if url.nil? || url.strip.empty?
97
97
 
@@ -101,12 +101,18 @@ module Tina4
101
101
  pool: pool)
102
102
  end
103
103
 
104
- def initialize(connection_string = nil, username: nil, password: nil, driver_name: nil, pool: 0)
104
+ def initialize(connection_string = nil, username: nil, password: nil, driver_name: nil, pool: nil)
105
105
  @connection_string = connection_string || ENV["TINA4_DATABASE_URL"]
106
106
  @username = username || ENV["TINA4_DATABASE_USERNAME"]
107
107
  @password = password || ENV["TINA4_DATABASE_PASSWORD"]
108
108
  @driver_name = driver_name || detect_driver(@connection_string)
109
- @pool_size = pool # 0 = single connection, N>0 = N pooled connections
109
+ # TINA4_DB_POOL falls back when caller doesn't pass `pool:` explicitly.
110
+ # Default 0 = single connection, N>0 = N pooled connections (round-robin).
111
+ @pool_size = if pool.nil?
112
+ (ENV["TINA4_DB_POOL"] || "0").to_i
113
+ else
114
+ pool
115
+ end
110
116
  @connected = false
111
117
 
112
118
  # Per-instance thread-local key for the transaction adapter pin.
data/lib/tina4/env.rb CHANGED
@@ -62,7 +62,7 @@ module Tina4
62
62
  end
63
63
  lines.concat([
64
64
  "",
65
- "Run `tina4 env-migrate` to rewrite your .env automatically,",
65
+ "Run `tina4 env --migrate` to rewrite your .env automatically,",
66
66
  "or rename manually. See https://tina4.com/release/3.12.0",
67
67
  "Set TINA4_ALLOW_LEGACY_ENV=true to bypass during migration.",
68
68
  sep, ""
@@ -132,6 +132,12 @@ module Tina4
132
132
  private
133
133
 
134
134
  def resolve_env_file(root_dir)
135
+ # TINA4_ENV_FILE wins — explicit path or filename (resolved against root_dir).
136
+ explicit = ENV["TINA4_ENV_FILE"]
137
+ if explicit && !explicit.empty?
138
+ return File.absolute_path?(explicit) ? explicit : File.join(root_dir, explicit)
139
+ end
140
+
135
141
  environment = ENV["ENVIRONMENT"]
136
142
  if environment && !environment.empty?
137
143
  candidate = File.join(root_dir, ".env.#{environment}")
data/lib/tina4/frond.rb CHANGED
@@ -190,6 +190,11 @@ module Tina4
190
190
  end
191
191
 
192
192
  # Render a template file with data. Uses token caching for performance.
193
+ #
194
+ # Caching strategy:
195
+ # * TINA4_DEBUG=true — never cache (always re-read + re-tokenize).
196
+ # * TINA4_TEMPLATE_CACHE_TTL > 0 — cache entries expire after N seconds.
197
+ # * TINA4_TEMPLATE_CACHE_TTL == 0 (default in production) — permanent cache.
193
198
  def render(template, data = {})
194
199
  context = @globals.merge(stringify_keys(data))
195
200
 
@@ -197,11 +202,16 @@ module Tina4
197
202
  raise "Template not found: #{path}" unless File.exist?(path)
198
203
 
199
204
  debug_mode = ENV.fetch("TINA4_DEBUG", "").downcase == "true"
205
+ ttl = (ENV["TINA4_TEMPLATE_CACHE_TTL"] || "0").to_i
200
206
 
201
207
  unless debug_mode
202
- # Production: use permanent cache (no filesystem checks)
203
208
  cached = @compiled[template]
204
- return execute_cached(cached[0], context) if cached
209
+ if cached
210
+ # cached layout: [tokens, mtime, cached_at]
211
+ tokens, _mtime, cached_at = cached
212
+ fresh = ttl <= 0 || (Time.now.to_i - cached_at.to_i) < ttl
213
+ return execute_cached(tokens, context) if fresh
214
+ end
205
215
  end
206
216
  # Dev mode: skip cache entirely — always re-read and re-tokenize
207
217
  # so edits to partials and extended base templates are detected
@@ -210,7 +220,7 @@ module Tina4
210
220
  source = File.read(path, encoding: "utf-8")
211
221
  mtime = File.mtime(path)
212
222
  tokens = tokenize(source)
213
- @compiled[template] = [tokens, mtime]
223
+ @compiled[template] = [tokens, mtime, Time.now.to_i]
214
224
  execute_with_tokens(source, tokens, context)
215
225
  end
216
226
 
data/lib/tina4/graphql.rb CHANGED
@@ -845,6 +845,14 @@ module Tina4
845
845
  class GraphQL
846
846
  attr_reader :schema
847
847
 
848
+ # Class-level toggle for ORM auto-schema generation. Defaults to true,
849
+ # can be disabled via TINA4_GRAPHQL_AUTO_SCHEMA=false. Initializers and
850
+ # user app code can branch on this before calling `schema.from_orm(...)`.
851
+ def self.auto_schema_enabled?
852
+ val = ENV.fetch("TINA4_GRAPHQL_AUTO_SCHEMA", "true").to_s.strip.downcase
853
+ !%w[false 0 no off].include?(val)
854
+ end
855
+
848
856
  def initialize(schema = nil)
849
857
  @schema = schema || GraphQLSchema.new
850
858
  @executor = GraphQLExecutor.new(@schema)
@@ -916,7 +924,12 @@ module Tina4
916
924
  # gql.register_route # POST /graphql
917
925
  # gql.register_route("/api/graphql") # custom path
918
926
  #
919
- def register_route(path = "/graphql")
927
+ def register_route(path = nil)
928
+ # TINA4_GRAPHQL_ENDPOINT — defaults to /graphql when caller doesn't override.
929
+ path ||= ENV.fetch("TINA4_GRAPHQL_ENDPOINT", "/graphql")
930
+ path = path.to_s
931
+ path = "/#{path}" unless path.start_with?("/")
932
+
920
933
  graphql = self
921
934
  Tina4.post path, auth: false do |request, response|
922
935
  body = request.body
data/lib/tina4/health.rb CHANGED
@@ -7,8 +7,19 @@ module Tina4
7
7
  START_TIME = Process.clock_gettime(Process::CLOCK_MONOTONIC)
8
8
 
9
9
  class << self
10
+ # Return the configured health endpoint path.
11
+ # TINA4_HEALTH_PATH overrides the default "/__health" — kept consistent across all 4 frameworks.
12
+ def path
13
+ configured = ENV["TINA4_HEALTH_PATH"]
14
+ return "/__health" if configured.nil? || configured.empty?
15
+ configured.start_with?("/") ? configured : "/#{configured}"
16
+ end
17
+
10
18
  def register!
11
- Tina4::Router.add("GET", "/health", method(:handle))
19
+ # Register at the configured path. The legacy "/health" path stays
20
+ # registered for backward-compat.
21
+ Tina4::Router.add("GET", path, method(:handle))
22
+ Tina4::Router.add("GET", "/health", method(:handle)) unless path == "/health"
12
23
  end
13
24
 
14
25
  def handle(_request, response)
data/lib/tina4/log.rb CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "fileutils"
4
4
  require "json"
5
+ require "logger"
5
6
 
6
7
  module Tina4
7
8
  module Log
@@ -27,23 +28,74 @@ module Tina4
27
28
  # ANSI escape code regex for stripping from file output
28
29
  ANSI_RE = /\033\[[0-9;]*m/
29
30
 
31
+ # Defaults used when env vars are unset.
32
+ DEFAULT_ROTATE_SIZE = 10 * 1024 * 1024 # 10MB
33
+ DEFAULT_ROTATE_KEEP = 5
34
+
30
35
  class << self
31
- attr_reader :log_dir
36
+ attr_reader :log_dir, :log_file_path
32
37
 
33
38
  def configure(root_dir = Dir.pwd)
34
- @log_dir = File.join(root_dir, "logs")
39
+ # TINA4_LOG_DIR — relative or absolute. Default "logs".
40
+ log_dir_env = ENV["TINA4_LOG_DIR"]
41
+ log_dir_env = "logs" if log_dir_env.nil? || log_dir_env.empty?
42
+ @log_dir = if File.absolute_path?(log_dir_env)
43
+ log_dir_env
44
+ else
45
+ File.join(root_dir, log_dir_env)
46
+ end
35
47
  FileUtils.mkdir_p(@log_dir)
36
48
 
37
- @max_size_mb = (ENV["TINA4_LOG_MAX_SIZE"] || "10").to_i
38
- @max_size = @max_size_mb * 1024 * 1024
39
- @keep = (ENV["TINA4_LOG_KEEP"] || "5").to_i
40
- @json_mode = production?
41
- @log_file = File.join(@log_dir, "tina4.log")
49
+ # TINA4_LOG_FILE — explicit log file path (absolute or relative to log_dir).
50
+ # Default: <log_dir>/tina4.log.
51
+ log_file_env = ENV["TINA4_LOG_FILE"]
52
+ @log_file_path = if log_file_env && !log_file_env.empty?
53
+ File.absolute_path?(log_file_env) ? log_file_env : File.join(@log_dir, log_file_env)
54
+ else
55
+ File.join(@log_dir, "tina4.log")
56
+ end
57
+
58
+ # TINA4_LOG_ROTATE_SIZE — bytes per file before rotation. 0 = no rotation.
59
+ @rotate_size = (ENV["TINA4_LOG_ROTATE_SIZE"] || DEFAULT_ROTATE_SIZE).to_i
60
+ # TINA4_LOG_ROTATE_KEEP — number of rotated backups to keep.
61
+ @rotate_keep = (ENV["TINA4_LOG_ROTATE_KEEP"] || DEFAULT_ROTATE_KEEP).to_i
62
+
63
+ # TINA4_LOG_FORMAT — "text" or "json". Defaults to "json" in production, else "text".
64
+ format_env = ENV["TINA4_LOG_FORMAT"]
65
+ @format = format_env && !format_env.empty? ? format_env.downcase : (production? ? "json" : "text")
66
+ @json_mode = @format == "json"
67
+
68
+ # TINA4_LOG_OUTPUT — "stdout", "file", or "both". Defaults to "both".
69
+ output_env = ENV["TINA4_LOG_OUTPUT"]
70
+ @output = output_env && !output_env.empty? ? output_env.downcase : "both"
71
+ unless %w[stdout file both].include?(@output)
72
+ @output = "both"
73
+ end
74
+
75
+ # TINA4_LOG_CRITICAL — when true, raise on log write failures instead of swallowing.
76
+ @critical = truthy?(ENV["TINA4_LOG_CRITICAL"])
42
77
 
43
78
  @console_level = resolve_level
44
79
  @request_id = nil
45
80
  @current_context = {}
46
81
  @mutex = Mutex.new
82
+
83
+ # Build the file logger via stdlib Logger which handles rotation natively.
84
+ # Logger.new(path, shift_age, shift_size):
85
+ # shift_age = number of files to keep
86
+ # shift_size = bytes before rotation
87
+ # When @rotate_size is 0, omit rotation args.
88
+ close_file_logger
89
+ if @output != "stdout"
90
+ @file_logger = if @rotate_size > 0
91
+ ::Logger.new(@log_file_path, @rotate_keep, @rotate_size)
92
+ else
93
+ ::Logger.new(@log_file_path)
94
+ end
95
+ # We do our own formatting — strip Logger's default formatter.
96
+ @file_logger.formatter = proc { |_sev, _t, _p, msg| msg.to_s.end_with?("\n") ? msg : "#{msg}\n" }
97
+ end
98
+
47
99
  @initialized = true
48
100
  end
49
101
 
@@ -79,8 +131,19 @@ module Tina4
79
131
  log(:error, message, context)
80
132
  end
81
133
 
134
+ # Test/teardown helper — closes the underlying Logger so the file
135
+ # handle is released (Windows / tmpdir cleanup).
136
+ def close_file_logger
137
+ @file_logger&.close rescue nil
138
+ @file_logger = nil
139
+ end
140
+
82
141
  private
83
142
 
143
+ def truthy?(val)
144
+ %w[true 1 yes on].include?(val.to_s.strip.downcase)
145
+ end
146
+
84
147
  def production?
85
148
  env = ENV["TINA4_ENV"] || ENV["RACK_ENV"] || ENV["RUBY_ENV"] || "development"
86
149
  env.downcase == "production"
@@ -92,9 +155,9 @@ module Tina4
92
155
 
93
156
  formatted = format_line(level, message)
94
157
 
95
- # Console output respects TINA4_LOG_LEVEL
158
+ # Console output respects TINA4_LOG_LEVEL and TINA4_LOG_OUTPUT
96
159
  severity = SEVERITY_MAP[level] || 0
97
- if severity >= @console_level
160
+ if severity >= @console_level && @output != "file"
98
161
  if @json_mode
99
162
  $stdout.puts json_line(level, message)
100
163
  else
@@ -102,8 +165,11 @@ module Tina4
102
165
  end
103
166
  end
104
167
 
105
- # File always gets ALL levels, plain text (no ANSI)
106
- write_to_file(strip_ansi(formatted))
168
+ # File output — always full level (consumer parses themselves) unless disabled.
169
+ if @output != "stdout" && @file_logger
170
+ payload = @json_mode ? json_line(level, message) : strip_ansi(formatted)
171
+ write_to_file(payload)
172
+ end
107
173
 
108
174
  @current_context = {}
109
175
  end
@@ -166,37 +232,12 @@ module Tina4
166
232
  end
167
233
 
168
234
  def write_to_file(line)
169
- rotate_if_needed
170
- begin
171
- File.open(@log_file, "a") { |f| f.puts(line) }
172
- rescue IOError, SystemCallError
173
- # Don't crash on log write failure
174
- end
175
- end
176
-
177
- # Numbered rotation: tina4.log → tina4.log.1 → tina4.log.2 → ... → tina4.log.{keep}
178
- def rotate_if_needed
179
- return unless File.exist?(@log_file)
180
-
181
- begin
182
- return if File.size(@log_file) < @max_size
183
- rescue SystemCallError
184
- return
185
- end
186
-
187
- # Delete the oldest rotated file if it exists
188
- oldest = "#{@log_file}.#{@keep}"
189
- File.delete(oldest) if File.exist?(oldest)
190
-
191
- # Shift existing rotated files: .{n} → .{n+1}
192
- (@keep - 1).downto(1) do |n|
193
- src = "#{@log_file}.#{n}"
194
- dst = "#{@log_file}.#{n + 1}"
195
- File.rename(src, dst) if File.exist?(src)
196
- end
197
-
198
- # Rename current log to .1
199
- File.rename(@log_file, "#{@log_file}.1") rescue nil
235
+ return unless @file_logger
236
+ # Use << to bypass Logger's severity filtering — we already filtered above.
237
+ @file_logger << "#{line}\n"
238
+ rescue IOError, SystemCallError => e
239
+ raise if @critical
240
+ # Don't crash on log write failure
200
241
  end
201
242
  end
202
243
  end
data/lib/tina4/mcp.rb CHANGED
@@ -136,6 +136,30 @@ module Tina4
136
136
  ["localhost", "127.0.0.1", "0.0.0.0", "::1", ""].include?(host)
137
137
  end
138
138
 
139
+ # Resolve whether the built-in MCP dev server should be active.
140
+ #
141
+ # Precedence:
142
+ # * TINA4_MCP set explicitly → use that (truthy/falsey).
143
+ # * Otherwise: enabled only when TINA4_DEBUG=true.
144
+ def self.mcp_enabled?
145
+ explicit = ENV["TINA4_MCP"]
146
+ if explicit && !explicit.empty?
147
+ return %w[true 1 yes on].include?(explicit.to_s.strip.downcase)
148
+ end
149
+ %w[true 1 yes on].include?(ENV.fetch("TINA4_DEBUG", "").to_s.strip.downcase)
150
+ end
151
+
152
+ # Resolve the dedicated MCP port. Defaults to (server port + 2000) — keeps
153
+ # MCP tooling reachable on a stable, predictable channel separate from the
154
+ # main HTTP port and the AI test port (port + 1000).
155
+ def self.mcp_port
156
+ explicit = ENV["TINA4_MCP_PORT"]
157
+ return explicit.to_i if explicit && !explicit.empty? && explicit.to_i > 0
158
+
159
+ base_port = (ENV["TINA4_PORT"] || ENV["PORT"] || "7147").to_i
160
+ base_port + 2000
161
+ end
162
+
139
163
  # ── McpServer ─────────────────────────────────────────────────────
140
164
  class McpServer
141
165
  attr_reader :path, :name, :version
@@ -36,13 +36,14 @@ module Tina4
36
36
  #
37
37
  class Messenger
38
38
  attr_reader :host, :port, :username, :from_address, :from_name,
39
- :imap_host, :imap_port, :use_tls, :encryption
39
+ :imap_host, :imap_port, :use_tls, :encryption,
40
+ :imap_encryption, :imap_use_tls
40
41
 
41
42
  # Initialize with SMTP config.
42
43
  # Priority: constructor params > ENV (TINA4_MAIL_*) > sensible defaults
43
44
  def initialize(host: nil, port: nil, username: nil, password: nil,
44
45
  from_address: nil, from_name: nil, encryption: nil, use_tls: nil,
45
- imap_host: nil, imap_port: nil)
46
+ imap_host: nil, imap_port: nil, imap_encryption: nil)
46
47
  @host = host || ENV["TINA4_MAIL_HOST"] || "localhost"
47
48
  @port = (port || ENV["TINA4_MAIL_PORT"] || 587).to_i
48
49
  @username = username || ENV["TINA4_MAIL_USERNAME"]
@@ -53,7 +54,7 @@ module Tina4
53
54
 
54
55
  @from_name = from_name || ENV["TINA4_MAIL_FROM_NAME"] || ""
55
56
 
56
- # Encryption: constructor > .env > backward-compat use_tls > default "tls"
57
+ # SMTP encryption: constructor > .env > backward-compat use_tls > default "tls"
57
58
  env_encryption = encryption || ENV["TINA4_MAIL_ENCRYPTION"]
58
59
  if env_encryption
59
60
  @encryption = env_encryption.downcase
@@ -66,6 +67,12 @@ module Tina4
66
67
 
67
68
  @imap_host = imap_host || ENV["TINA4_MAIL_IMAP_HOST"] || @host
68
69
  @imap_port = (imap_port || ENV["TINA4_MAIL_IMAP_PORT"] || 993).to_i
70
+
71
+ # IMAP encryption: dedicated env var TINA4_MAIL_IMAP_ENCRYPTION (tls/starttls/none).
72
+ # Defaults to "tls" — IMAPS over implicit TLS on port 993 is the safe industry norm.
73
+ env_imap_enc = imap_encryption || ENV["TINA4_MAIL_IMAP_ENCRYPTION"]
74
+ @imap_encryption = (env_imap_enc && !env_imap_enc.to_s.empty?) ? env_imap_enc.to_s.downcase : "tls"
75
+ @imap_use_tls = %w[tls starttls ssl].include?(@imap_encryption)
69
76
  end
70
77
 
71
78
  # Send email using Ruby's Net::SMTP
@@ -387,7 +394,7 @@ module Tina4
387
394
  # ── IMAP helpers ─────────────────────────────────────────────────────
388
395
 
389
396
  def imap_connect(&block)
390
- imap = Net::IMAP.new(@imap_host, port: @imap_port, ssl: @use_tls)
397
+ imap = Net::IMAP.new(@imap_host, port: @imap_port, ssl: @imap_use_tls)
391
398
  imap.login(@username, @password)
392
399
  result = block.call(imap)
393
400
  imap.logout
data/lib/tina4/router.rb CHANGED
@@ -328,6 +328,14 @@ module Tina4
328
328
  nil
329
329
  end
330
330
 
331
+ # When TINA4_TRAILING_SLASH_REDIRECT is truthy, the rack app uses this
332
+ # to detect whether the *original* (un-stripped) path differed from the
333
+ # canonical form so it can issue a 301 redirect. Default false — silent
334
+ # match keeps backward compatibility.
335
+ def trailing_slash_redirect?
336
+ %w[true 1 yes on].include?(ENV.fetch("TINA4_TRAILING_SLASH_REDIRECT", "").to_s.strip.downcase)
337
+ end
338
+
331
339
  # Find a route matching method + path. Returns [route, params] or nil.
332
340
  # match(method, path) — consistent with Python, PHP, and Node.
333
341
  def match(method, path)
data/lib/tina4/session.rb CHANGED
@@ -16,6 +16,11 @@ module Tina4
16
16
 
17
17
  def initialize(env, options = {})
18
18
  @options = DEFAULT_OPTIONS.merge(options)
19
+ # TINA4_SESSION_NAME — overrides cookie_name unless caller explicitly passed one.
20
+ env_name = ENV["TINA4_SESSION_NAME"]
21
+ if !options.key?(:cookie_name) && env_name && !env_name.empty?
22
+ @options[:cookie_name] = env_name
23
+ end
19
24
  @options[:secret] ||= ENV["TINA4_SECRET"] || "tina4-default-secret"
20
25
  @handler = create_handler
21
26
  @id = extract_session_id(env) || SecureRandom.hex(32)
@@ -151,7 +156,17 @@ module Tina4
151
156
  def cookie_header(cookie_name = nil)
152
157
  name = cookie_name || @options[:cookie_name]
153
158
  samesite = ENV["TINA4_SESSION_SAMESITE"] || "Lax"
154
- "#{name}=#{@id}; Path=/; HttpOnly; SameSite=#{samesite}; Max-Age=#{@options[:max_age]}"
159
+ # HttpOnly defaults to true (existing behaviour); flip off only when explicitly false.
160
+ httponly = !%w[false 0 no off].include?((ENV["TINA4_SESSION_HTTPONLY"] || "true").to_s.strip.downcase)
161
+ # Secure defaults to false; flip on with truthy env var.
162
+ secure = %w[true 1 yes on].include?((ENV["TINA4_SESSION_SECURE"] || "false").to_s.strip.downcase)
163
+
164
+ parts = ["#{name}=#{@id}", "Path=/"]
165
+ parts << "HttpOnly" if httponly
166
+ parts << "Secure" if secure
167
+ parts << "SameSite=#{samesite}"
168
+ parts << "Max-Age=#{@options[:max_age]}"
169
+ parts.join("; ")
155
170
  end
156
171
 
157
172
  private
data/lib/tina4/swagger.rb CHANGED
@@ -13,16 +13,44 @@ module Tina4
13
13
  spec
14
14
  end
15
15
 
16
+ # TINA4_SWAGGER_ENABLED — defaults to TINA4_DEBUG. When false, callers
17
+ # can choose to skip mounting /swagger entirely in production.
18
+ def enabled?
19
+ explicit = ENV["TINA4_SWAGGER_ENABLED"]
20
+ if explicit && !explicit.empty?
21
+ return %w[true 1 yes on].include?(explicit.to_s.strip.downcase)
22
+ end
23
+ %w[true 1 yes on].include?(ENV.fetch("TINA4_DEBUG", "").to_s.strip.downcase)
24
+ end
25
+
16
26
  private
17
27
 
18
28
  def base_spec
29
+ info = {
30
+ "title" => ENV["TINA4_SWAGGER_TITLE"] || ENV["PROJECT_NAME"] || "Tina4 API",
31
+ "version" => ENV["TINA4_SWAGGER_VERSION"] || Tina4::VERSION,
32
+ "description" => ENV["TINA4_SWAGGER_DESCRIPTION"] || "Auto-generated API documentation"
33
+ }
34
+
35
+ # Optional contact block — only emitted when at least one field is set.
36
+ contact_email = ENV["TINA4_SWAGGER_CONTACT_EMAIL"]
37
+ contact_team = ENV["TINA4_SWAGGER_CONTACT_TEAM"] || ENV["SWAGGER_CONTACT_TEAM"]
38
+ contact_url = ENV["TINA4_SWAGGER_CONTACT_URL"] || ENV["SWAGGER_CONTACT_URL"]
39
+ contact = {}
40
+ contact["email"] = contact_email if contact_email && !contact_email.empty?
41
+ contact["name"] = contact_team if contact_team && !contact_team.empty?
42
+ contact["url"] = contact_url if contact_url && !contact_url.empty?
43
+ info["contact"] = contact unless contact.empty?
44
+
45
+ # Optional license block — TINA4_SWAGGER_LICENSE is the SPDX name (e.g. "MIT").
46
+ license_name = ENV["TINA4_SWAGGER_LICENSE"]
47
+ if license_name && !license_name.empty?
48
+ info["license"] = { "name" => license_name }
49
+ end
50
+
19
51
  {
20
52
  "openapi" => "3.0.3",
21
- "info" => {
22
- "title" => ENV["TINA4_SWAGGER_TITLE"] || ENV["PROJECT_NAME"] || "Tina4 API",
23
- "version" => ENV["TINA4_SWAGGER_VERSION"] || Tina4::VERSION,
24
- "description" => ENV["TINA4_SWAGGER_DESCRIPTION"] || "Auto-generated API documentation"
25
- },
53
+ "info" => info,
26
54
  "servers" => [
27
55
  { "url" => "/" }
28
56
  ],
data/lib/tina4/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Tina4
4
- VERSION = "3.12.3"
4
+ VERSION = "3.12.4"
5
5
  end
@@ -2,10 +2,16 @@
2
2
 
3
3
  module Tina4
4
4
  class WebServer
5
- def initialize(app, host: "0.0.0.0", port: 7147)
5
+ DEFAULT_HOST = "0.0.0.0"
6
+ DEFAULT_PORT = 7147
7
+
8
+ # TINA4_HOST overrides the bind address when caller doesn't pass `host:`.
9
+ def initialize(app, host: nil, port: nil)
6
10
  @app = app
7
- @host = host
8
- @port = port
11
+ env_host = ENV["TINA4_HOST"]
12
+ @host = host || (env_host && !env_host.empty? ? env_host : DEFAULT_HOST)
13
+ env_port = ENV["TINA4_PORT"] || ENV["PORT"]
14
+ @port = port || (env_port && !env_port.empty? ? env_port.to_i : DEFAULT_PORT)
9
15
  end
10
16
 
11
17
  # Kill whatever process is listening on *port*.
data/lib/tina4.rb CHANGED
@@ -119,6 +119,9 @@ module Tina4
119
119
  attr_accessor :root_dir, :database
120
120
 
121
121
  def print_banner(host: "0.0.0.0", port: 7147, server_name: nil)
122
+ # TINA4_SUPPRESS — short-circuit ALL banner output for headless / CI runs.
123
+ return if Tina4::Env.is_truthy(ENV["TINA4_SUPPRESS"])
124
+
122
125
  is_tty = $stdout.respond_to?(:isatty) && $stdout.isatty
123
126
  color = is_tty ? "\e[31m" : ""
124
127
  reset = is_tty ? "\e[0m" : ""
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: tina4ruby
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.12.3
4
+ version: 3.12.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Tina4 Team
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-05-05 00:00:00.000000000 Z
11
+ date: 2026-05-06 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rack