tina4ruby 3.13.38 → 3.13.39

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: 6ed3d68931d97c88f7d7ea9128420ce4b531da04f47536c3f64abac71f3edbb3
4
- data.tar.gz: 4124c02d8ddefcb645eee1d5a52ddef31a4e2ebeecfd11fe81b7999a3784802a
3
+ metadata.gz: 62dc42240a6abd35572f17207f42c631ebf2c36cc73b94adad4b163b3a2924a5
4
+ data.tar.gz: e9ea35e0aad4bee25bc42f7ee7a37973144265ed538b5e6127e5a546278a62cf
5
5
  SHA512:
6
- metadata.gz: 6a5f5f009385d5e4ce97d0289db83d2d60d1bb2410019c88892fe5f6c22fc2c358654021965ebe4a8b2d159ac7958dd6b57ba41c8b879cf385cc6632428666c5
7
- data.tar.gz: 4a7d3a41ce8ca5e8283318d487745b90c726e03c648c4480807c8e2fd40910d7472e5371e0c910ef6e68e914d057d65faaa4955efaf585bc93d84919ac567de6
6
+ metadata.gz: 58cf9b41857e5905b5eeda37a6881f812aad230a12533051d4510724222453f344ae1a0e05120e915a69450a05788dad7468eb1b99925e44a5bd8a625dca4c59
7
+ data.tar.gz: 8ff5447f091aafe2ff0a06c7e23a8f7838190545ec96f99bc2329753ee0a9ed0714cc1a7293d127805efe504552293fbb6f08e3fa7651e8423998cc8939869f5
data/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
  <img src="https://tina4.com/logo.svg" alt="Tina4" width="200">
3
3
  </p>
4
4
  <h1 align="center">Tina4 Ruby</h1>
5
- <h3 align="center">TINA4 The Intelligent Native Application 4ramework</h3>
5
+ <h3 align="center">TINA4: The Intelligent Native Application 4ramework</h3>
6
6
  <p align="center"><em>Simple. Fast. Human. &nbsp;|&nbsp; Built for AI. Built for you.</em></p>
7
7
  <p align="center">55 built-in features. Zero runtime dependencies. One require, everything works.</p>
8
8
  <p align="center">
@@ -18,7 +18,7 @@
18
18
  ## Quick Start
19
19
 
20
20
  ```bash
21
- # With the Tina4 CLI (recommended enables SCSS + live reload)
21
+ # With the Tina4 CLI (recommended, enables SCSS + live reload)
22
22
  cargo install tina4 # or grab a binary from https://github.com/tina4stack/tina4/releases
23
23
  tina4 init ruby ./my-app
24
24
  cd my-app && tina4 serve
@@ -56,12 +56,12 @@ db = Tina4::Database.new("sqlite://app.db")
56
56
  | Category | Features |
57
57
  |----------|----------|
58
58
  | **Core HTTP** (7) | Router with path params (`{id:int}`, `{p:path}`), Server, Request/Response, Middleware pipeline, Static file serving, CORS |
59
- | **Database** (6) | SQLite, PostgreSQL, MySQL, MSSQL, Firebird unified adapter, connection pooling, query cache, transactions, race-safe ID generation, SQL dialect translation |
59
+ | **Database** (6) | SQLite, PostgreSQL, MySQL, MSSQL, Firebird: unified adapter, connection pooling, query cache, transactions, race-safe ID generation, SQL dialect translation |
60
60
  | **ORM** (7) | Active Record with typed fields, relationships (`has_one`/`has_many`/`belongs_to`), soft delete, QueryBuilder + MongoDB support, Auto-CRUD generator, migrations with rollback |
61
61
  | **Auth & Security** (5) | JWT (HS256/RS256), password hashing (PBKDF2-SHA256), API key validation, rate limiting, CSRF form tokens |
62
62
  | **Templating** (3) | Frond engine (Twig/Jinja2-compatible, pre-compiled 2.8× faster), SCSS auto-compilation, built-in CSS (~24 KB) |
63
63
  | **API & Integration** (5) | HTTP client (zero-dep), GraphQL with ORM auto-schema + GraphiQL IDE, WSDL/SOAP with auto WSDL, WebSocket (RFC 6455) + Redis backplane, MCP server (24 dev tools) |
64
- | **Background** (3) | Job queue (File/RabbitMQ/Kafka/MongoDB) with priority, delay, retry, dead letters service runner event system (on/emit/once/off) |
64
+ | **Background** (3) | Job queue (File/RabbitMQ/Kafka/MongoDB) with priority, delay, retry, dead letters; service runner; event system (on/emit/once/off) |
65
65
  | **Data & Storage** (4) | Session (File/Redis/Valkey/MongoDB/DB), response cache (LRU, TTL), seeder + 50+ fake data generators, messenger (SMTP/IMAP) |
66
66
  | **Developer Tools** (7) | Dev dashboard (11 tabs), dev toolbar, error overlay (Catppuccin Mocha), dev mailbox, hot reload + CSS hot-reload, code metrics (complexity, coupling, maintainability), AI context installer (7 tools) |
67
67
  | **Utilities** (7) | DI container (transient + singleton), HtmlElement builder, inline testing (`@tests` decorator), i18n (6 languages), Swagger/OpenAPI auto-generation, CLI scaffolding (`generate model/route/migration/middleware`), structured logging |
@@ -84,14 +84,14 @@ tina4ruby generate model <name>
84
84
 
85
85
  ## Performance
86
86
 
87
- Benchmarked with `wrk` 5,000 requests, 50 concurrent, median of 3 runs:
87
+ Benchmarked with `wrk`: 5,000 requests, 50 concurrent, median of 3 runs:
88
88
 
89
89
  | Framework | JSON req/s | Deps | Features |
90
90
  |-----------|-----------|------|----------|
91
91
  | **Tina4 Ruby** | **10,243** | 0 | 55 |
92
92
  | Sinatra | 9,548 | 5+ | ~4 |
93
93
 
94
- Tina4 Ruby outperforms Sinatra while delivering **55 features vs ~4** with zero runtime dependencies.
94
+ Tina4 Ruby outperforms Sinatra while delivering **55 features vs ~4**, with zero runtime dependencies.
95
95
 
96
96
  **Across all 4 Tina4 implementations:**
97
97
 
@@ -105,7 +105,7 @@ Tina4 Ruby outperforms Sinatra while delivering **55 features vs ~4** — with z
105
105
 
106
106
  ## Cross-Framework Parity
107
107
 
108
- Tina4 ships identical features across four languages same architecture, same conventions, same 55 features:
108
+ Tina4 ships identical features across four languages: same architecture, same conventions, same 55 features:
109
109
 
110
110
  | | Python | PHP | Ruby | Node.js |
111
111
  |---|--------|-----|------|---------|
data/lib/tina4/api.rb CHANGED
@@ -1,10 +1,17 @@
1
1
  # frozen_string_literal: true
2
2
  require "net/http"
3
+ require "openssl"
3
4
  require "uri"
4
5
  require "json"
5
6
  require "base64"
6
7
 
7
8
  module Tina4
9
+ # Statuses that warrant an automatic retry when max_retries > 0: rate-limit
10
+ # (429) plus the transient server-side 5xx family. 4xx client errors (401,
11
+ # 404, …) are NOT retried — a repeat won't succeed. Parity with the Python
12
+ # master's _RETRY_STATUSES.
13
+ API_RETRY_STATUSES = [429, 500, 502, 503, 504].freeze
14
+
8
15
  class API
9
16
  attr_reader :base_url, :headers
10
17
 
@@ -18,9 +25,16 @@ module Tina4
18
25
  # api = Tina4::API.new("https://self-signed.local", verify_ssl: false)
19
26
  #
20
27
  # Bearer wins over basic-auth when both are passed.
28
+ #
29
+ # 3.13.39: +max_retries / +retry_backoff enable opt-in automatic retry with
30
+ # exponential backoff (default max_retries: 0 = off, non-breaking) on a
31
+ # transport error (APIResponse#status == 0) or a retryable status
32
+ # (429/5xx). A retried non-idempotent request (POST/PUT/PATCH/DELETE) may be
33
+ # re-sent — retries are opt-in for exactly that reason. Parity with the
34
+ # Python master.
21
35
  def initialize(base_url, headers: {}, timeout: 30,
22
36
  bearer_token: nil, username: nil, password: nil,
23
- verify_ssl: nil)
37
+ verify_ssl: nil, max_retries: 0, retry_backoff: 0.5)
24
38
  @base_url = base_url.chomp("/")
25
39
  @headers = {
26
40
  "Content-Type" => "application/json",
@@ -28,6 +42,8 @@ module Tina4
28
42
  }.merge(headers)
29
43
  @timeout = timeout
30
44
  @verify_ssl = verify_ssl
45
+ @max_retries = [0, max_retries.to_i].max
46
+ @retry_backoff = retry_backoff.to_f
31
47
 
32
48
  # Bearer wins over basic-auth when both passed
33
49
  if bearer_token
@@ -142,9 +158,35 @@ module Tina4
142
158
  end
143
159
  end
144
160
 
161
+ # Execute the request with opt-in retry/backoff. Returns an APIResponse.
162
+ #
163
+ # With @max_retries > 0, a transport error (APIResponse#status == 0, the
164
+ # existing error sentinel) or a retryable status (429/5xx) is retried up to
165
+ # @max_retries times with exponential backoff (@retry_backoff seconds base,
166
+ # doubling each attempt); any other outcome (2xx, 3xx, other 4xx) returns at
167
+ # once. Parity with the Python master's _request.
145
168
  def execute(uri, request)
169
+ attempts = @max_retries + 1
170
+ response = nil
171
+ (0...attempts).each do |attempt|
172
+ response = attempt_request(uri, request)
173
+ code = response.status
174
+ retryable = code.zero? || API_RETRY_STATUSES.include?(code)
175
+ return response if !retryable || attempt == attempts - 1
176
+
177
+ sleep(@retry_backoff * (2**attempt))
178
+ end
179
+ response
180
+ end
181
+
182
+ # A single HTTP attempt. Returns the standardized APIResponse.
183
+ def attempt_request(uri, request)
146
184
  http = Net::HTTP.new(uri.host, uri.port)
147
185
  http.use_ssl = uri.scheme == "https"
186
+ # 3.13.39: honour verify_ssl: false (the dead-since-3.13.1 kwarg). Only
187
+ # disable verification when EXPLICITLY false — nil/true keep the secure
188
+ # default (OpenSSL::SSL::VERIFY_PEER).
189
+ http.verify_mode = OpenSSL::SSL::VERIFY_NONE if @verify_ssl == false
148
190
  http.open_timeout = @timeout
149
191
  http.read_timeout = @timeout
150
192
 
data/lib/tina4/cli.rb CHANGED
@@ -287,6 +287,10 @@ module Tina4
287
287
  status_icon = r[:status] == "success" ? "OK" : "FAIL"
288
288
  puts " [#{status_icon}] #{r[:name]}"
289
289
  end
290
+ # FAIL-FAST: a failed migration must give CI a non-zero exit (parity
291
+ # with the Python master). Only the startup auto-migration hook
292
+ # swallows failures; the explicit CLI does not.
293
+ exit 1 if results.any? { |r| r[:status] == "failed" }
290
294
  end
291
295
  end
292
296
  end
@@ -255,6 +255,17 @@ module Tina4
255
255
  end
256
256
  end
257
257
 
258
+ # Autocommit is ON by default — parity with Python/PHP/Node. A standalone
259
+ # write (execute/insert/update/delete made OUTSIDE an explicit
260
+ # start_transaction()/commit() block) commits on its own connection before
261
+ # returning, so a write actually persists. An UNSET TINA4_AUTOCOMMIT is
262
+ # treated as TRUE; set TINA4_AUTOCOMMIT=false for strict manual mode (every
263
+ # write needs an explicit commit). Inside an explicit transaction the
264
+ # framework-issued commit is suppressed (gated on the thread tx-pin), so
265
+ # explicit transactions stay atomic. Mirrors Python's
266
+ # DatabaseAdapter._autocommit ("true"/"1"/"yes", default "true").
267
+ @autocommit = truthy?(ENV.fetch("TINA4_AUTOCOMMIT", "true"))
268
+
258
269
  # Register this connection so Tina4::Database.reset_request_caches can
259
270
  # clear its request-scoped entries at the start of every HTTP request.
260
271
  Tina4::Database.register_instance(self)
@@ -282,10 +293,11 @@ module Tina4
282
293
  @driver.connect(@connection_string, username: @username, password: @password)
283
294
  @connected = true
284
295
 
285
- # Enable autocommit if TINA4_AUTOCOMMIT env var is set
286
- if truthy?(ENV["TINA4_AUTOCOMMIT"]) && @driver.respond_to?(:autocommit=)
287
- @driver.autocommit = true
288
- end
296
+ # Push the resolved autocommit setting down to the driver when it exposes a
297
+ # native toggle (default ON — see @autocommit in #initialize). The
298
+ # framework-level commit in #autocommit_standalone_write covers drivers
299
+ # that have no native setter.
300
+ @driver.autocommit = @autocommit if @driver.respond_to?(:autocommit=)
289
301
 
290
302
  Tina4::Log.info("Database connected: #{@driver_name}")
291
303
  rescue => e
@@ -488,7 +500,9 @@ module Tina4
488
500
  placeholders = drv.placeholders(columns.length)
489
501
  sql = "INSERT INTO #{table} (#{columns.join(', ')}) VALUES (#{placeholders})"
490
502
  drv.execute(sql, data.values)
491
- { success: true, last_id: drv.last_insert_id }
503
+ last_id = drv.last_insert_id
504
+ autocommit_standalone_write(drv)
505
+ { success: true, last_id: last_id }
492
506
  end
493
507
 
494
508
  def update(table, data, filter = {}, params = nil)
@@ -501,6 +515,7 @@ module Tina4
501
515
  sql = "UPDATE #{table} SET #{set_parts.join(', ')}"
502
516
  sql += " WHERE #{filter}" unless filter.empty?
503
517
  drv.execute(sql, data.values + Array(params))
518
+ autocommit_standalone_write(drv)
504
519
  return { success: true }
505
520
  end
506
521
 
@@ -510,6 +525,7 @@ module Tina4
510
525
  sql += " WHERE #{where_parts.join(' AND ')}" unless filter.empty?
511
526
  values = data.values + filter.values
512
527
  drv.execute(sql, values)
528
+ autocommit_standalone_write(drv)
513
529
  { success: true }
514
530
  end
515
531
 
@@ -528,6 +544,7 @@ module Tina4
528
544
  sql = "DELETE FROM #{table}"
529
545
  sql += " WHERE #{filter}" unless filter.empty?
530
546
  drv.execute(sql, Array(params))
547
+ autocommit_standalone_write(drv)
531
548
  return { success: true }
532
549
  end
533
550
 
@@ -536,6 +553,7 @@ module Tina4
536
553
  sql = "DELETE FROM #{table}"
537
554
  sql += " WHERE #{where_parts.join(' AND ')}" unless filter.empty?
538
555
  drv.execute(sql, filter.values)
556
+ autocommit_standalone_write(drv)
539
557
  { success: true }
540
558
  end
541
559
 
@@ -582,8 +600,10 @@ module Tina4
582
600
  # or a clean { error: } payload respectively.
583
601
  def execute(sql, params = [])
584
602
  cache_invalidate if @cache_enabled
585
- result = current_driver.execute(sql, params)
603
+ drv = current_driver
604
+ result = drv.execute(sql, params)
586
605
  @last_error = nil
606
+ autocommit_standalone_write(drv)
587
607
  sql_upper = sql.strip.upcase
588
608
  if sql_upper.include?("RETURNING") || sql_upper.start_with?("CALL ") ||
589
609
  sql_upper.start_with?("EXEC ") || sql_upper.start_with?("SELECT ")
@@ -1104,6 +1124,31 @@ module Tina4
1104
1124
  %w[true 1 yes on].include?((val || "").to_s.strip.downcase)
1105
1125
  end
1106
1126
 
1127
+ # Durability: commit a standalone write so it actually persists.
1128
+ #
1129
+ # Called after a write (execute/insert/update/delete) issued OUTSIDE an
1130
+ # explicit transaction. The commit is suppressed when autocommit is off
1131
+ # (TINA4_AUTOCOMMIT=false, strict manual mode) OR when a transaction is open
1132
+ # on this thread (the thread tx-pin is set) — so an explicit
1133
+ # start_transaction()/commit() block stays atomic and is never broken up by
1134
+ # a per-statement commit. A commit with no transaction in progress is a
1135
+ # harmless no-op on every engine (SQLite swallows the specific
1136
+ # "no transaction is active" error in its driver; PostgreSQL/MySQL/MSSQL emit
1137
+ # at most a benign warning), so this never raises in the common case. Mirrors
1138
+ # the `not self._in_transaction and self.autocommit` gate in the Python
1139
+ # master and PHP's `autoCommit && transaction === null`.
1140
+ def autocommit_standalone_write(drv)
1141
+ return unless @autocommit
1142
+ return unless Thread.current[@tx_pin_key].nil?
1143
+
1144
+ drv.commit
1145
+ rescue StandardError => e
1146
+ # A standalone write already succeeded; a follow-up commit failure here
1147
+ # must not mask that. Capture for #get_error and log, but don't raise.
1148
+ @last_error = e.message
1149
+ Tina4::Log.warning("autocommit commit after standalone write failed: #{e.message}")
1150
+ end
1151
+
1107
1152
  # "persistent" / "request" / "off" — mirrors Python connection.py.
1108
1153
  def cache_mode
1109
1154
  if @cache_persistent
@@ -312,6 +312,19 @@ module Tina4
312
312
  @error_tracker ||= ErrorTracker.new
313
313
  end
314
314
 
315
+ # Drop the lazily-memoized dev singletons so the next access rebuilds
316
+ # them from the CURRENT environment. The mailbox in particular resolves
317
+ # its directory from `TINA4_MAILBOX_DIR`/`data/mailbox` at construction
318
+ # time, so a singleton built under one env must not leak into a later
319
+ # caller (or test) running under a different env. Safe to call anytime —
320
+ # it only nils the caches.
321
+ def reset_singletons!
322
+ @message_log = nil
323
+ @request_inspector = nil
324
+ @mailbox = nil
325
+ @error_tracker = nil
326
+ end
327
+
315
328
  def enabled?
316
329
  Tina4::Env.is_truthy(ENV["TINA4_DEBUG"])
317
330
  end
@@ -637,13 +650,16 @@ module Tina4
637
650
  body = read_json_body(env) || {}
638
651
  json_response(mcp_tool_call(body))
639
652
  # JSON-RPC + SSE endpoints that real MCP clients (Claude Code/Desktop)
640
- # speak. Mounted on the same dispatch as the REST shim above and gated
641
- # on the same enabled? (TINA4_DEBUG) check, so disabled → 404. They
653
+ # speak. The dev tools expose powerful ops (DB query, file read/WRITE,
654
+ # route listing), so beyond the dev-toolbar's TINA4_DEBUG check they are
655
+ # gated on Tina4.mcp_enabled? — explicit TINA4_MCP wins on any host, else
656
+ # dev auto-enable is LOCALHOST-ONLY unless TINA4_MCP_REMOTE=true. Not
657
+ # enabled → falls through to the `else` (nil), so RackApp 404s it. They
642
658
  # share the default MCP server's tool registry with the REST shim.
643
659
  when ["POST", "/__dev/mcp"], ["POST", "/__dev/mcp/message"]
644
- mcp_jsonrpc(env)
660
+ Tina4.mcp_enabled? ? mcp_jsonrpc(env) : nil
645
661
  when ["GET", "/__dev/mcp/sse"]
646
- mcp_sse_handshake
662
+ Tina4.mcp_enabled? ? mcp_sse_handshake : nil
647
663
  when ["GET", "/__dev/api/scaffold"]
648
664
  json_response(scaffold_templates)
649
665
  when ["POST", "/__dev/api/scaffold/run"]
@@ -20,8 +20,11 @@ module Tina4
20
20
  @table_name = name
21
21
  else
22
22
  base = self.name.split("::").last.downcase
23
- # Pluralize by default (add "s") unless ORM_PLURAL_TABLE_NAMES is explicitly disabled
24
- unless ENV.fetch("TINA4_ORM_PLURAL_TABLE_NAMES", "").match?(/\A(false|0|no)\z/i)
23
+ # Pluralization is OFF by default (canonical, matching the Python
24
+ # master): the table name is the bare class name lowercased. Opt in by
25
+ # setting TINA4_ORM_PLURAL_TABLE_NAMES to a truthy value (true/1/yes/on)
26
+ # to append "s".
27
+ if ENV.fetch("TINA4_ORM_PLURAL_TABLE_NAMES", "").match?(/\A(true|1|yes|on)\z/i)
25
28
  base += "s" unless base.end_with?("s")
26
29
  end
27
30
  @table_name || base
data/lib/tina4/log.rb CHANGED
@@ -12,11 +12,12 @@ module Tina4
12
12
  "[TINA4_LOG_INFO]" => 1,
13
13
  "[TINA4_LOG_WARNING]" => 2,
14
14
  "[TINA4_LOG_ERROR]" => 3,
15
- "[TINA4_LOG_NONE]" => 4
15
+ "[TINA4_LOG_CRITICAL]" => 4,
16
+ "[TINA4_LOG_NONE]" => 5
16
17
  }.freeze
17
18
 
18
19
  SEVERITY_MAP = {
19
- debug: 0, info: 1, warn: 2, error: 3
20
+ debug: 0, info: 1, warn: 2, error: 3, critical: 4
20
21
  }.freeze
21
22
 
22
23
  COLORS = {
@@ -65,15 +66,36 @@ module Tina4
65
66
  @format = format_env && !format_env.empty? ? format_env.downcase : (production? ? "json" : "text")
66
67
  @json_mode = @format == "json"
67
68
 
68
- # TINA4_LOG_OUTPUT — "stdout", "file", or "both". Defaults to "both".
69
+ # TINA4_LOG_OUTPUT — "stdout", "file", or "both".
70
+ #
71
+ # Default (UNSET): stdout is ALWAYS on. The log FILE (tina4.log + any
72
+ # error log) is written ONLY in development — i.e. when TINA4_DEBUG is
73
+ # truthy. In production / containers (TINA4_DEBUG falsy) the logger is
74
+ # stdout-only: writing a log file inside a container just bloats the
75
+ # writable layer + disk, and 12-factor wants logs on stdout for the
76
+ # platform to capture. An explicit TINA4_LOG_OUTPUT=file/both (or an
77
+ # explicit TINA4_LOG_FILE path) overrides this and STILL writes a file.
78
+ # Mirrors the Python master (debug/__init__.py configure()).
79
+ # An explicit TINA4_LOG_FILE always wins: a path the operator named must
80
+ # be written even in production (parity with the Python master, where an
81
+ # explicit log_file builds a writer unconditionally), so the dev-gated
82
+ # default below resolves to "both" (stdout + file) rather than "stdout".
83
+ explicit_file = !(log_file_env.nil? || log_file_env.empty?)
84
+ default_output = if explicit_file || truthy?(ENV["TINA4_DEBUG"])
85
+ "both"
86
+ else
87
+ "stdout"
88
+ end
69
89
  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
90
+ @output = if output_env && !output_env.empty?
91
+ output_env.downcase
92
+ else
93
+ default_output
94
+ end
95
+ @output = default_output unless %w[stdout file both].include?(@output)
74
96
 
75
- # TINA4_LOG_CRITICAL — when true, raise on log write failures instead of swallowing.
76
- @critical = truthy?(ENV["TINA4_LOG_CRITICAL"])
97
+ # TINA4_LOG_STRICT — when true, raise on log write failures instead of swallowing.
98
+ @strict = truthy?(ENV["TINA4_LOG_STRICT"])
77
99
 
78
100
  @console_level = resolve_level
79
101
  @request_id = nil
@@ -122,6 +144,28 @@ module Tina4
122
144
  @json_mode
123
145
  end
124
146
 
147
+ # Would a message at `level` pass the configured MINIMUM CONSOLE LEVEL
148
+ # (TINA4_LOG_LEVEL)? Returns true iff `log` would print it to stdout —
149
+ # it reflects CONSOLE visibility only. The log FILE records every level
150
+ # regardless of this threshold, so this never gates file output.
151
+ #
152
+ # `level` accepts a String or Symbol and is case-insensitive
153
+ # ("INFO", :info, "Warning", :warning all work). Mirrors Python's
154
+ # Log.is_enabled. It REUSES the exact severity >= @console_level
155
+ # comparison the console branch in `log` uses (line ~167) via
156
+ # SEVERITY_MAP / resolve_level — it never re-implements level
157
+ # comparison, so it can never disagree with what the logger prints.
158
+ #
159
+ # "critical" is a FIRST-CLASS top-level severity (4 — above error 3),
160
+ # not a parity alias for error. It is evaluated with ordinary threshold
161
+ # logic (critical 4 >= @console_level), so it passes at every level
162
+ # except none (5) — matching the Python master.
163
+ def enabled?(level)
164
+ sym = normalize_level(level)
165
+ severity = SEVERITY_MAP[sym] || 0
166
+ severity >= console_level
167
+ end
168
+
125
169
  def info(message, context = {})
126
170
  log(:info, message, context)
127
171
  end
@@ -138,6 +182,14 @@ module Tina4
138
182
  log(:error, message, context)
139
183
  end
140
184
 
185
+ # critical is the HIGHEST severity (4, above error). Like every other
186
+ # level it ALWAYS emits, subject only to the TINA4_LOG_LEVEL threshold
187
+ # (which critical passes at every level except none). A critical log is
188
+ # never a silent no-op. Mirrors the Python master.
189
+ def critical(message, context = {})
190
+ log(:critical, message, context)
191
+ end
192
+
141
193
  # Test/teardown helper — closes the underlying Logger so the file
142
194
  # handle is released (Windows / tmpdir cleanup).
143
195
  def close_file_logger
@@ -181,6 +233,28 @@ module Tina4
181
233
  @current_context = {}
182
234
  end
183
235
 
236
+ # The current minimum console level as an integer (the same value
237
+ # the console branch in `log` compares against). Ensures the logger
238
+ # is configured so `enabled?` works before any log call has run.
239
+ def console_level
240
+ configure unless @initialized
241
+ @console_level
242
+ end
243
+
244
+ # Map a level (String or Symbol, case-insensitive) onto the symbol
245
+ # space used by SEVERITY_MAP. Accepts the public method names
246
+ # (debug/info/warning/error/critical) and the internal :warn symbol.
247
+ # critical is a FIRST-CLASS level (severity 4), not an alias for error.
248
+ # Unknown levels fall through to their own symbol and resolve to
249
+ # severity 0 in `enabled?`.
250
+ def normalize_level(level)
251
+ sym = level.to_s.strip.downcase.to_sym
252
+ case sym
253
+ when :warning then :warn
254
+ else sym
255
+ end
256
+ end
257
+
184
258
  def resolve_level
185
259
  # v3.13.14: default is INFO (was ALL) so a deployed app surfaces
186
260
  # request/startup/warn/error without debug noise, matching
@@ -199,6 +273,7 @@ module Tina4
199
273
  when :info then "INFO"
200
274
  when :warn then "WARNING"
201
275
  when :error then "ERROR"
276
+ when :critical then "CRITICAL"
202
277
  else level.to_s.upcase
203
278
  end
204
279
  end
@@ -278,6 +353,7 @@ module Tina4
278
353
  when :info then COLORS[:green]
279
354
  when :warn then COLORS[:yellow]
280
355
  when :error then COLORS[:red]
356
+ when :critical then COLORS[:magenta]
281
357
  else COLORS[:reset]
282
358
  end
283
359
  "#{color}#{line}#{COLORS[:reset]}"
@@ -288,7 +364,7 @@ module Tina4
288
364
  # Use << to bypass Logger's severity filtering — we already filtered above.
289
365
  @file_logger << "#{line}\n"
290
366
  rescue IOError, SystemCallError => e
291
- raise if @critical
367
+ raise if @strict
292
368
  # Don't crash on log write failure
293
369
  end
294
370
  end
data/lib/tina4/mcp.rb CHANGED
@@ -139,15 +139,35 @@ module Tina4
139
139
 
140
140
  # Resolve whether the built-in MCP dev server should be active.
141
141
  #
142
- # Precedence:
143
- # * TINA4_MCP set explicitly → use that (truthy/falsey).
144
- # * Otherwise: enabled only when TINA4_DEBUG=true.
142
+ # Resolution order (highest priority first):
143
+ # 1. TINA4_MCP set explicitly → use that (truthy/falsey). Honoured on ANY
144
+ # host. An explicit `true` is how a sysadmin opts a remote /
145
+ # debug-disabled deployment in (e.g. for a remote AI assistant); an
146
+ # explicit `false` force-disables it everywhere.
147
+ # 2. TINA4_DEBUG=true → implicit on for dev, but LOCALHOST-ONLY unless
148
+ # TINA4_MCP_REMOTE=true. The MCP dev tools expose powerful operations
149
+ # (DB query, file read/WRITE, route listing), so they never auto-expose
150
+ # on a non-localhost host without an explicit opt-in.
151
+ # 3. Otherwise off.
152
+ #
153
+ # Mirrors the Python master (tina4_python/mcp/__init__.py is_enabled). Before
154
+ # v3.13.39 is_localhost? was dead code and TINA4_MCP_REMOTE was never read, so
155
+ # the documented localhost guard was not actually enforced — a non-localhost
156
+ # TINA4_DEBUG=true deployment auto-exposed the dev tools. This wires it.
145
157
  def self.mcp_enabled?
146
158
  explicit = ENV["TINA4_MCP"]
147
159
  if explicit && !explicit.empty?
148
- return %w[true 1 yes on].include?(explicit.to_s.strip.downcase)
160
+ return truthy?(explicit)
149
161
  end
150
- %w[true 1 yes on].include?(ENV.fetch("TINA4_DEBUG", "").to_s.strip.downcase)
162
+ return false unless truthy?(ENV["TINA4_DEBUG"])
163
+
164
+ # Dev auto-enable: localhost only, unless explicitly opted into remote.
165
+ is_localhost? || truthy?(ENV["TINA4_MCP_REMOTE"])
166
+ end
167
+
168
+ # Case-insensitive truthiness for env values: true/1/yes/on.
169
+ def self.truthy?(val)
170
+ %w[true 1 yes on].include?(val.to_s.strip.downcase)
151
171
  end
152
172
 
153
173
  # Resolve the dedicated MCP port. Defaults to (server port + 2000) — keeps