tina4ruby 3.13.94 → 3.13.96

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 (67) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +883 -0
  3. data/README.md +1 -1
  4. data/lib/tina4/auth.rb +166 -87
  5. data/lib/tina4/auto_crud.rb +29 -32
  6. data/lib/tina4/cache_backends/base_backend.rb +19 -0
  7. data/lib/tina4/cache_backends/database_backend.rb +29 -0
  8. data/lib/tina4/cache_backends/memcached_backend.rb +124 -13
  9. data/lib/tina4/cache_backends/memory_backend.rb +15 -0
  10. data/lib/tina4/cache_backends/redis_backend.rb +173 -52
  11. data/lib/tina4/cache_backends.rb +10 -1
  12. data/lib/tina4/cli.rb +23 -39
  13. data/lib/tina4/cors.rb +186 -30
  14. data/lib/tina4/database/sqlite3_adapter.rb +4 -1
  15. data/lib/tina4/database.rb +322 -22
  16. data/lib/tina4/database_adapter.rb +178 -0
  17. data/lib/tina4/database_result.rb +63 -17
  18. data/lib/tina4/database_url.rb +363 -0
  19. data/lib/tina4/dev.rb +0 -1
  20. data/lib/tina4/dev_admin.rb +118 -20
  21. data/lib/tina4/dispatch_pipeline.rb +605 -0
  22. data/lib/tina4/docstore.rb +274 -60
  23. data/lib/tina4/drivers/firebird_driver.rb +118 -4
  24. data/lib/tina4/drivers/mongodb_driver.rb +19 -4
  25. data/lib/tina4/drivers/mssql_driver.rb +73 -10
  26. data/lib/tina4/drivers/mysql_driver.rb +71 -4
  27. data/lib/tina4/drivers/odbc_driver.rb +40 -4
  28. data/lib/tina4/drivers/postgres_driver.rb +97 -10
  29. data/lib/tina4/drivers/sqlite_driver.rb +21 -2
  30. data/lib/tina4/env.rb +176 -34
  31. data/lib/tina4/field_types.rb +12 -0
  32. data/lib/tina4/health.rb +30 -14
  33. data/lib/tina4/job.rb +15 -5
  34. data/lib/tina4/log.rb +236 -32
  35. data/lib/tina4/mcp.rb +11 -5
  36. data/lib/tina4/messenger.rb +248 -36
  37. data/lib/tina4/metrics.rb +179 -891
  38. data/lib/tina4/middleware.rb +191 -56
  39. data/lib/tina4/migration.rb +17 -1
  40. data/lib/tina4/orm.rb +114 -17
  41. data/lib/tina4/public/css/tina4.min.css +1 -1
  42. data/lib/tina4/queue.rb +154 -9
  43. data/lib/tina4/queue_backends/kafka_backend.rb +191 -2
  44. data/lib/tina4/queue_backends/lite_backend.rb +121 -25
  45. data/lib/tina4/queue_backends/mongo_backend.rb +146 -10
  46. data/lib/tina4/queue_backends/rabbitmq_backend.rb +194 -1
  47. data/lib/tina4/rack_app.rb +94 -316
  48. data/lib/tina4/request.rb +48 -8
  49. data/lib/tina4/response.rb +42 -1
  50. data/lib/tina4/response_cache.rb +142 -24
  51. data/lib/tina4/router.rb +141 -12
  52. data/lib/tina4/session.rb +243 -29
  53. data/lib/tina4/session_handlers/database_handler.rb +185 -20
  54. data/lib/tina4/session_handlers/file_handler.rb +113 -21
  55. data/lib/tina4/session_handlers/memcached_handler.rb +183 -0
  56. data/lib/tina4/session_handlers/mongo_handler.rb +232 -15
  57. data/lib/tina4/session_handlers/mongo_wire_client.rb +300 -0
  58. data/lib/tina4/session_handlers/redis_handler.rb +20 -6
  59. data/lib/tina4/session_handlers/valkey_handler.rb +18 -4
  60. data/lib/tina4/shutdown.rb +180 -30
  61. data/lib/tina4/sql_translator.rb +110 -0
  62. data/lib/tina4/swagger.rb +50 -18
  63. data/lib/tina4/version.rb +1 -1
  64. data/lib/tina4/webserver.rb +28 -6
  65. data/lib/tina4.rb +289 -37
  66. metadata +35 -17
  67. data/lib/tina4/scss_compiler.rb +0 -349
data/lib/tina4/log.rb CHANGED
@@ -29,28 +29,105 @@ module Tina4
29
29
  # ANSI escape code regex for stripping from file output
30
30
  ANSI_RE = /\033\[[0-9;]*m/
31
31
 
32
+ # The logger must never be surprised by what it is handed. Console lines are
33
+ # capped; control characters never reach a terminal. Same numbers in all four
34
+ # frameworks (feature 2 of the feature audit).
35
+ STDOUT_MAX_CHARS = 2000
36
+ CONTROL_CHARS = /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/
37
+
32
38
  # Defaults used when env vars are unset.
33
39
  DEFAULT_ROTATE_SIZE = 10 * 1024 * 1024 # 10MB
34
40
  DEFAULT_ROTATE_KEEP = 5
35
41
 
42
+ # The error classes TINA4_LOG_STRICT must let ESCAPE stdlib ::Logger.
43
+ #
44
+ # TINA4_LOG_STRICT is documented as "raise on a log write failure instead of
45
+ # swallowing", and #write_to_file dutifully rescues IOError/SystemCallError
46
+ # and re-raises when @strict. It was a NO-OP anyway: ::Logger::LogDevice
47
+ # wraps every device write in its own `handle_write_errors`, which rescues
48
+ # and turns the failure into a bare `warn` on stderr. The real error was
49
+ # swallowed one layer BELOW Tina4 and never reached Tina4's rescue at all.
50
+ #
51
+ # MEASURED 2026-08-01 on a genuinely full 1MB HFS+ ram disk (0 KB free),
52
+ # Ruby 4.0.2: with TINA4_LOG_STRICT=true, Tina4::Log.info(...) printed
53
+ # "log writing failed. No space left on device @ rb_sys_fail_on_write" to
54
+ # stderr and returned normally. The operator got a stderr warning and strict
55
+ # mode did nothing.
56
+ #
57
+ # ::Logger has a first-class seam for exactly this — `reraise_write_errors:`
58
+ # (logger >= 1.5.0), which handle_write_errors re-raises through instead of
59
+ # warning. Listing the two classes #write_to_file already rescues keeps the
60
+ # two ends of the strict path in agreement. Note Errno::ENOSPC (and every
61
+ # other errno) is a SystemCallError, so the real disk-full case is covered.
62
+ STRICT_WRITE_ERRORS = [IOError, SystemCallError].freeze
63
+
64
+ # A log device that writes ONLY log lines.
65
+ #
66
+ # stdlib ::Logger stamps a banner as the FIRST LINE of every file it creates
67
+ # — and it creates one on every rotation too:
68
+ #
69
+ # # Logfile created on 2026-08-01 21:49:10 +0200 by logger.rb/v1.7.0
70
+ #
71
+ # In JSON mode (TINA4_LOG_FORMAT=json) that is not JSON, so any line-oriented
72
+ # shipper — Filebeat, Fluent Bit, Vector, promtail, `jq -c` — fails on line 1
73
+ # of every file and every rotated file. It is a Ruby-only artifact: no other
74
+ # Tina4 framework emits it, so a JSON pipeline that works on Python/PHP/Node
75
+ # breaks here and nowhere else.
76
+ #
77
+ # Suppressing it in the DEVICE (rather than pre-creating the file) covers
78
+ # rotation as well, and keeps ::Logger's own rotation/locking untouched —
79
+ # add_log_header is the single method whose whole body is that banner.
80
+ class HeaderlessLogDevice < ::Logger::LogDevice
81
+ def add_log_header(file); end
82
+ end
83
+
36
84
  class << self
37
85
  attr_reader :log_dir, :log_file_path
38
86
 
39
- def configure(root_dir = Dir.pwd)
40
- # TINA4_LOG_DIR — relative or absolute. Default "logs".
87
+ # configure(target = nil)
88
+ #
89
+ # Logs land in a `logs/` folder by default. The argument OVERRIDES that,
90
+ # and it accepts a DIRECTORY or a FILE PATH:
91
+ #
92
+ # configure -> ./logs/tina4.log + ./logs/error.log
93
+ # configure("/var/log/myapp") -> /var/log/myapp/tina4.log + error.log
94
+ # configure("/var/log/myapp/app.log") -> that exact file (no error.log sibling)
95
+ # TINA4_LOG_DIR=log -> ./log/
96
+ #
97
+ # A target with a file extension is a file path; anything else is a
98
+ # directory (an existing directory is always treated as one, extension or
99
+ # not). Naming a file means "one file at this path", so no error.log
100
+ # appears beside it -- same rule TINA4_LOG_FILE already followed.
101
+ #
102
+ # BREAKING for Ruby callers: the argument used to be a project ROOT with
103
+ # `logs/` appended, so `configure("/app")` wrote to /app/logs/ while the
104
+ # identical call on Python and PHP wrote to /app/. That is the same
105
+ # file-versus-directory confusion feature 1 found in loadEnv, and it made
106
+ # "put the logs exactly here" impossible to express. If you relied on the
107
+ # old behaviour, pass the parent explicitly: `configure(File.join(root, "logs"))`.
108
+ def configure(target = nil)
109
+ # Explicit argument wins, then TINA4_LOG_DIR, then ./logs.
41
110
  log_dir_env = ENV["TINA4_LOG_DIR"]
42
- log_dir_env = "logs" if log_dir_env.nil? || log_dir_env.empty?
43
- @log_dir = if File.absolute_path?(log_dir_env)
44
- log_dir_env
45
- else
46
- File.join(root_dir, log_dir_env)
47
- end
111
+ log_dir_env = nil if log_dir_env && log_dir_env.empty?
112
+ chosen = target || log_dir_env || "logs"
113
+ chosen = File.join(Dir.pwd, chosen) unless File.absolute_path?(chosen)
114
+
115
+ if target_is_file?(chosen)
116
+ @log_dir = File.dirname(chosen)
117
+ explicit_target_file = chosen
118
+ else
119
+ @log_dir = chosen
120
+ explicit_target_file = nil
121
+ end
48
122
  FileUtils.mkdir_p(@log_dir)
49
123
 
50
- # TINA4_LOG_FILE explicit log file path (absolute or relative to log_dir).
51
- # Default: <log_dir>/tina4.log.
124
+ # A file path passed to configure() wins, then TINA4_LOG_FILE (absolute
125
+ # or relative to log_dir). Default: <log_dir>/tina4.log.
52
126
  log_file_env = ENV["TINA4_LOG_FILE"]
53
- @log_file_path = if log_file_env && !log_file_env.empty?
127
+ log_file_env = nil if log_file_env && log_file_env.empty?
128
+ @log_file_path = if explicit_target_file
129
+ explicit_target_file
130
+ elsif log_file_env
54
131
  File.absolute_path?(log_file_env) ? log_file_env : File.join(@log_dir, log_file_env)
55
132
  else
56
133
  File.join(@log_dir, "tina4.log")
@@ -61,9 +138,23 @@ module Tina4
61
138
  # TINA4_LOG_ROTATE_KEEP — number of rotated backups to keep.
62
139
  @rotate_keep = (ENV["TINA4_LOG_ROTATE_KEEP"] || DEFAULT_ROTATE_KEEP).to_i
63
140
 
64
- # TINA4_LOG_FORMAT — "text" or "json". Defaults to "json" in production, else "text".
141
+ # TINA4_LOG_FORMAT — "text" or "json". TEXT IS THE DEFAULT, always.
142
+ #
143
+ # Owner decision 2026-08-01: nothing but an explicit TINA4_LOG_FORMAT=json
144
+ # selects JSON. The implicit "production means JSON" switch is DELETED in
145
+ # all four frameworks, because MEASURED, "production" meant four different
146
+ # things and it silently picked your log format:
147
+ #
148
+ # node !isTruthy(TINA4_DEBUG) -> JSON with TINA4_DEBUG unset
149
+ # ruby TINA4_ENV|RACK_ENV|RUBY_ENV == "production"
150
+ # python only via configure(production=True)
151
+ # php no switch at all — JSON was the shipped default
152
+ #
153
+ # Same machine, same .env, four formats. An OBJECT (Hash/Array) passed as
154
+ # the message is still JSON-encoded INLINE inside the text line — that is
155
+ # coerce_message's job and it is unchanged.
65
156
  format_env = ENV["TINA4_LOG_FORMAT"]
66
- @format = format_env && !format_env.empty? ? format_env.downcase : (production? ? "json" : "text")
157
+ @format = format_env && !format_env.empty? ? format_env.downcase : "text"
67
158
  @json_mode = @format == "json"
68
159
 
69
160
  # TINA4_LOG_OUTPUT — "stdout", "file", or "both".
@@ -80,7 +171,10 @@ module Tina4
80
171
  # be written even in production (parity with the Python master, where an
81
172
  # explicit log_file builds a writer unconditionally), so the dev-gated
82
173
  # default below resolves to "both" (stdout + file) rather than "stdout".
83
- explicit_file = !(log_file_env.nil? || log_file_env.empty?)
174
+ # "The operator named ONE file" — via TINA4_LOG_FILE or by passing a file
175
+ # path to configure(). Either way a file must be written even in
176
+ # production, and no error.log sibling appears next to it.
177
+ explicit_file = !log_file_env.nil? || !explicit_target_file.nil?
84
178
  default_output = if explicit_file || truthy?(ENV["TINA4_DEBUG"])
85
179
  "both"
86
180
  else
@@ -115,14 +209,35 @@ module Tina4
115
209
  # shift_size = bytes before rotation
116
210
  # When @rotate_size is 0, omit rotation args.
117
211
  close_file_logger
212
+
213
+ # TINA4_LOG_APPEND — append (default) or overwrite on startup.
214
+ #
215
+ # APPEND IS THE DEFAULT: a log you can lose by restarting the process is
216
+ # not a log. Set it false when you want one file per run (a short CLI, a
217
+ # test fixture, a container that ships logs elsewhere) and the file is
218
+ # truncated once here at configure time, never per line.
219
+ @append = ENV["TINA4_LOG_APPEND"].nil? || truthy?(ENV["TINA4_LOG_APPEND"])
220
+
118
221
  if @output != "stdout"
119
- @file_logger = if @rotate_size > 0
120
- ::Logger.new(@log_file_path, @rotate_keep, @rotate_size)
121
- else
122
- ::Logger.new(@log_file_path)
123
- end
124
- # We do our own formatting — strip Logger's default formatter.
125
- @file_logger.formatter = proc { |_sev, _t, _p, msg| msg.to_s.end_with?("\n") ? msg : "#{msg}\n" }
222
+ unless @append
223
+ [@log_file_path, File.join(@log_dir, "error.log")].each do |path|
224
+ File.write(path, "") if File.exist?(path)
225
+ end
226
+ end
227
+ @file_logger = build_file_logger(@log_file_path)
228
+
229
+ # Mirror WARNING and above into a dedicated error.log so
230
+ # `tail -f logs/error.log` gives just the stuff worth looking at.
231
+ # Ruby wrote ONE file where Python and PHP wrote two, so anyone whose
232
+ # alerting tails error.log got silence here (feature 2 of the audit,
233
+ # D3). Skipped when the operator named an explicit TINA4_LOG_FILE:
234
+ # they asked for one file at one path, so a sibling error.log
235
+ # appearing next to it would be a surprise.
236
+ @error_logger = if explicit_file
237
+ nil
238
+ else
239
+ build_file_logger(File.join(@log_dir, "error.log"))
240
+ end
126
241
  end
127
242
 
128
243
  @initialized = true
@@ -195,39 +310,41 @@ module Tina4
195
310
  def close_file_logger
196
311
  @file_logger&.close rescue nil
197
312
  @file_logger = nil
313
+ @error_logger&.close rescue nil
314
+ @error_logger = nil
198
315
  end
199
316
 
200
317
  private
201
318
 
202
319
  def truthy?(val)
203
- %w[true 1 yes on].include?(val.to_s.strip.downcase)
204
- end
205
-
206
- def production?
207
- env = ENV["TINA4_ENV"] || ENV["RACK_ENV"] || ENV["RUBY_ENV"] || "development"
208
- env.downcase == "production"
320
+ Tina4::Env.is_truthy(val)
209
321
  end
210
322
 
211
323
  def log(level, message, context = {})
212
324
  configure unless @initialized
213
325
  @current_context = context.is_a?(Hash) ? context : {}
214
326
 
327
+ # Coerce FIRST. Anything can arrive as a message: a Hash from a handler,
328
+ # a binary payload off a socket, a 10MB string. See coerce_message.
329
+ message = coerce_message(message)
215
330
  formatted = format_line(level, message)
216
331
 
217
332
  # Console output respects TINA4_LOG_LEVEL and TINA4_LOG_OUTPUT
218
333
  severity = SEVERITY_MAP[level] || 0
219
334
  if severity >= @console_level && @output != "file"
335
+ # Truncate on the CONSOLE only. The file keeps the full line so a
336
+ # consumer parsing it loses nothing; a terminal does not need 10MB.
220
337
  if @json_mode
221
- $stdout.puts json_line(level, message)
338
+ $stdout.puts truncate_for_stdout(json_line(level, message))
222
339
  else
223
- $stdout.puts colorize(level, formatted)
340
+ $stdout.puts colorize(level, truncate_for_stdout(formatted))
224
341
  end
225
342
  end
226
343
 
227
344
  # File output — always full level (consumer parses themselves) — unless disabled.
228
345
  if @output != "stdout" && @file_logger
229
346
  payload = @json_mode ? json_line(level, message) : strip_ansi(formatted)
230
- write_to_file(payload)
347
+ write_to_file(payload, level)
231
348
  end
232
349
 
233
350
  @current_context = {}
@@ -295,7 +412,10 @@ module Tina4
295
412
  fn = caller_name
296
413
  fn_str = fn ? " [#{fn}]" : ""
297
414
  ctx = @current_context && !@current_context.empty? ? " #{JSON.generate(@current_context)}" : ""
298
- "#{ts} [#{level_str.ljust(7)}]#{rid_str}#{fn_str} #{message}#{ctx}"
415
+ # Pad to 8, not 7: CRITICAL is eight characters, so a 7-wide column was
416
+ # broken by our own highest level. 8 is the only width that fits every
417
+ # level name. Cross-framework format table (feature 2 of the audit).
418
+ "#{ts} [#{level_str.ljust(8)}]#{rid_str}#{fn_str} #{message}#{ctx}"
299
419
  end
300
420
 
301
421
  def json_line(level, message)
@@ -359,10 +479,94 @@ module Tina4
359
479
  "#{color}#{line}#{COLORS[:reset]}"
360
480
  end
361
481
 
362
- def write_to_file(line)
482
+ # Build one rotating file logger. stdlib Logger handles rotation natively:
483
+ # Logger.new(path, shift_age, shift_size) — files to keep, bytes before roll.
484
+ # With @rotate_size 0 the rotation args are omitted. tina4.log and error.log
485
+ # each get their own logger so they rotate independently.
486
+ # Turn anything into a single safe line of text.
487
+ #
488
+ # A String passes through when it is valid UTF-8. Binary is described
489
+ # rather than dumped: raw bytes at a terminal garble it and can emit
490
+ # escape sequences. Anything else becomes JSON, because a Hash rendered as
491
+ # text is the whole reason the caller logged it, falling back to inspect
492
+ # for a value JSON cannot represent. The logger must never be the reason a
493
+ # request dies.
494
+ def coerce_message(message)
495
+ text = case message
496
+ when String
497
+ if message.encoding == Encoding::BINARY || !message.valid_encoding?
498
+ utf8 = message.dup.force_encoding(Encoding::UTF_8)
499
+ return "<binary #{message.bytesize} bytes>" unless utf8.valid_encoding?
500
+ utf8
501
+ else
502
+ message
503
+ end
504
+ when Hash, Array
505
+ begin
506
+ JSON.generate(message)
507
+ rescue StandardError
508
+ message.inspect
509
+ end
510
+ when nil
511
+ ""
512
+ else
513
+ message.respond_to?(:to_s) ? message.to_s : message.inspect
514
+ end
515
+ text.gsub(CONTROL_CHARS, "")
516
+ end
517
+
518
+ def truncate_for_stdout(line)
519
+ return line if line.length <= STDOUT_MAX_CHARS
520
+ "#{line[0, STDOUT_MAX_CHARS]}... (truncated, #{line.length} chars)"
521
+ end
522
+
523
+ # Is this target a FILE PATH or a DIRECTORY?
524
+ #
525
+ # An existing directory is always a directory, extension or not. Otherwise
526
+ # a basename with an extension (app.log, app.txt) is a file and anything
527
+ # else is a directory to create. That keeps `configure("/var/log/myapp")`
528
+ # a directory and `configure("/var/log/myapp/app.log")` a file without
529
+ # needing the path to exist yet.
530
+ def target_is_file?(path)
531
+ return false if File.directory?(path)
532
+ File.extname(path) != ""
533
+ end
534
+
535
+ def build_file_logger(path)
536
+ # In strict mode ask ::Logger to let a real write error through instead
537
+ # of warning on stderr and returning (see STRICT_WRITE_ERRORS). Empty
538
+ # otherwise, which is the stdlib default, so the non-strict path behaves
539
+ # byte for byte as it always has.
540
+ reraise = @strict ? STRICT_WRITE_ERRORS : []
541
+ # HeaderlessLogDevice, not a bare path: ::Logger would otherwise open the
542
+ # file with a "# Logfile created on ..." banner that is not a log line
543
+ # (and not JSON). It still does the rotation — shift_age = files kept,
544
+ # shift_size = bytes before a roll; with @rotate_size 0 both are omitted
545
+ # so rotation is off. reraise_write_errors is passed to BOTH layers or
546
+ # the outer one swallows what the inner one raises (see STRICT_WRITE_ERRORS).
547
+ device = if @rotate_size > 0
548
+ HeaderlessLogDevice.new(path, shift_age: @rotate_keep,
549
+ shift_size: @rotate_size,
550
+ reraise_write_errors: reraise)
551
+ else
552
+ HeaderlessLogDevice.new(path, reraise_write_errors: reraise)
553
+ end
554
+ logger = ::Logger.new(device, reraise_write_errors: reraise)
555
+ # We do our own formatting — strip Logger's default formatter.
556
+ logger.formatter = proc { |_sev, _t, _p, msg| msg.to_s.end_with?("\n") ? msg : "#{msg}\n" }
557
+ logger
558
+ end
559
+
560
+ def write_to_file(line, level = nil)
363
561
  return unless @file_logger
364
562
  # Use << to bypass Logger's severity filtering — we already filtered above.
365
563
  @file_logger << "#{line}\n"
564
+ # WARNING and above only. Matches the Python master and PHP, which both
565
+ # mirror warning+ rather than error+ -- error.log is "the stuff worth
566
+ # looking at", and a warning qualifies.
567
+ if @error_logger && level && (SEVERITY_MAP[level] || 0) >= SEVERITY_MAP[:warn]
568
+ @error_logger << "#{line}\n"
569
+ end
366
570
  rescue IOError, SystemCallError => e
367
571
  raise if @strict
368
572
  # Don't crash on log write failure
data/lib/tina4/mcp.rb CHANGED
@@ -199,10 +199,12 @@ module Tina4
199
199
  truthy?(ENV["TINA4_MCP_REMOTE"]) && has_valid_token
200
200
  end
201
201
 
202
- # Case-insensitive truthiness for env values: true/1/yes/on.
203
- def self.truthy?(val)
204
- %w[true 1 yes on].include?(val.to_s.strip.downcase)
205
- end
202
+ # `truthy?` above is Tina4.truthy? (lib/tina4.rb) -> Tina4::Env.is_truthy.
203
+ # This file used to define its OWN Tina4.truthy? here. Both defined the same
204
+ # method on the same module, and because mcp.rb is required BEFORE the
205
+ # definition in tina4.rb, this one was silently overwritten - dead code
206
+ # sitting on the MCP remote-access gate. It only ever looked correct because
207
+ # the two copies happened to hold the same table.
206
208
 
207
209
  # Resolve the dedicated MCP port. Defaults to (server port + 2000) — keeps
208
210
  # MCP tooling reachable on a stable, predictable channel separate from the
@@ -1036,7 +1038,11 @@ module Tina4
1036
1038
 
1037
1039
  # ── Session/Cache Tools ───────────────────────────
1038
1040
  server.register_tool("session_list", lambda {
1039
- session_dir = File.join("data", "sessions")
1041
+ # Same resolution FileHandler uses, so this tool lists the directory
1042
+ # sessions are really in. It hard-coded "data/sessions" while
1043
+ # FileHandler wrote to <cwd>/sessions, so it listed the wrong place on
1044
+ # every project; now both read TINA4_SESSION_PATH with the same default.
1045
+ session_dir = ENV["TINA4_SESSION_PATH"] || File.join("data", "sessions")
1040
1046
  return [] unless File.directory?(session_dir)
1041
1047
  Dir.glob(File.join(session_dir, "*.json")).map do |f|
1042
1048
  begin