tina4ruby 3.11.15 → 3.11.17

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 (134) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +80 -80
  3. data/LICENSE.txt +21 -21
  4. data/README.md +137 -137
  5. data/exe/tina4ruby +5 -5
  6. data/lib/tina4/ai.rb +696 -696
  7. data/lib/tina4/api.rb +189 -189
  8. data/lib/tina4/auth.rb +305 -305
  9. data/lib/tina4/auto_crud.rb +244 -244
  10. data/lib/tina4/cache.rb +154 -154
  11. data/lib/tina4/cli.rb +1449 -1449
  12. data/lib/tina4/constants.rb +46 -46
  13. data/lib/tina4/container.rb +74 -74
  14. data/lib/tina4/cors.rb +74 -74
  15. data/lib/tina4/crud.rb +692 -692
  16. data/lib/tina4/database/sqlite3_adapter.rb +165 -165
  17. data/lib/tina4/database.rb +625 -625
  18. data/lib/tina4/database_result.rb +208 -208
  19. data/lib/tina4/debug.rb +8 -8
  20. data/lib/tina4/dev.rb +14 -14
  21. data/lib/tina4/dev_admin.rb +1291 -935
  22. data/lib/tina4/dev_mailbox.rb +191 -191
  23. data/lib/tina4/drivers/firebird_driver.rb +124 -124
  24. data/lib/tina4/drivers/mongodb_driver.rb +561 -561
  25. data/lib/tina4/drivers/mssql_driver.rb +112 -112
  26. data/lib/tina4/drivers/mysql_driver.rb +90 -90
  27. data/lib/tina4/drivers/odbc_driver.rb +191 -191
  28. data/lib/tina4/drivers/postgres_driver.rb +116 -116
  29. data/lib/tina4/drivers/sqlite_driver.rb +122 -122
  30. data/lib/tina4/env.rb +95 -95
  31. data/lib/tina4/error_overlay.rb +252 -252
  32. data/lib/tina4/events.rb +109 -109
  33. data/lib/tina4/field_types.rb +154 -154
  34. data/lib/tina4/frond.rb +2087 -2025
  35. data/lib/tina4/gallery/auth/meta.json +1 -1
  36. data/lib/tina4/gallery/auth/src/routes/api/gallery_auth.rb +114 -114
  37. data/lib/tina4/gallery/database/meta.json +1 -1
  38. data/lib/tina4/gallery/database/src/routes/api/gallery_db.rb +43 -43
  39. data/lib/tina4/gallery/error-overlay/meta.json +1 -1
  40. data/lib/tina4/gallery/error-overlay/src/routes/api/gallery_crash.rb +17 -17
  41. data/lib/tina4/gallery/orm/meta.json +1 -1
  42. data/lib/tina4/gallery/orm/src/routes/api/gallery_products.rb +16 -16
  43. data/lib/tina4/gallery/queue/meta.json +1 -1
  44. data/lib/tina4/gallery/queue/src/routes/api/gallery_queue.rb +325 -325
  45. data/lib/tina4/gallery/rest-api/meta.json +1 -1
  46. data/lib/tina4/gallery/rest-api/src/routes/api/gallery_hello.rb +14 -14
  47. data/lib/tina4/gallery/templates/meta.json +1 -1
  48. data/lib/tina4/gallery/templates/src/routes/gallery_page.rb +12 -12
  49. data/lib/tina4/gallery/templates/src/templates/gallery_page.twig +257 -257
  50. data/lib/tina4/graphql.rb +966 -966
  51. data/lib/tina4/health.rb +39 -39
  52. data/lib/tina4/html_element.rb +170 -170
  53. data/lib/tina4/job.rb +80 -80
  54. data/lib/tina4/localization.rb +168 -168
  55. data/lib/tina4/log.rb +203 -203
  56. data/lib/tina4/mcp.rb +871 -696
  57. data/lib/tina4/messenger.rb +587 -587
  58. data/lib/tina4/metrics.rb +793 -793
  59. data/lib/tina4/middleware.rb +445 -445
  60. data/lib/tina4/migration.rb +451 -451
  61. data/lib/tina4/orm.rb +790 -790
  62. data/lib/tina4/plan.rb +471 -0
  63. data/lib/tina4/project_index.rb +366 -0
  64. data/lib/tina4/public/css/tina4.css +2463 -2463
  65. data/lib/tina4/public/css/tina4.min.css +1 -1
  66. data/lib/tina4/public/images/logo.svg +5 -5
  67. data/lib/tina4/public/js/frond.min.js +2 -2
  68. data/lib/tina4/public/js/tina4-dev-admin.js +1264 -565
  69. data/lib/tina4/public/js/tina4-dev-admin.min.js +1264 -480
  70. data/lib/tina4/public/js/tina4.min.js +92 -92
  71. data/lib/tina4/public/js/tina4js.min.js +48 -48
  72. data/lib/tina4/public/swagger/index.html +90 -90
  73. data/lib/tina4/public/swagger/oauth2-redirect.html +63 -63
  74. data/lib/tina4/query_builder.rb +380 -380
  75. data/lib/tina4/queue.rb +366 -366
  76. data/lib/tina4/queue_backends/kafka_backend.rb +80 -80
  77. data/lib/tina4/queue_backends/lite_backend.rb +298 -298
  78. data/lib/tina4/queue_backends/mongo_backend.rb +126 -126
  79. data/lib/tina4/queue_backends/rabbitmq_backend.rb +73 -73
  80. data/lib/tina4/rack_app.rb +817 -817
  81. data/lib/tina4/rate_limiter.rb +130 -130
  82. data/lib/tina4/request.rb +268 -268
  83. data/lib/tina4/response.rb +346 -346
  84. data/lib/tina4/response_cache.rb +551 -551
  85. data/lib/tina4/router.rb +406 -406
  86. data/lib/tina4/scss/tina4css/_alerts.scss +34 -34
  87. data/lib/tina4/scss/tina4css/_badges.scss +22 -22
  88. data/lib/tina4/scss/tina4css/_buttons.scss +69 -69
  89. data/lib/tina4/scss/tina4css/_cards.scss +49 -49
  90. data/lib/tina4/scss/tina4css/_forms.scss +156 -156
  91. data/lib/tina4/scss/tina4css/_grid.scss +81 -81
  92. data/lib/tina4/scss/tina4css/_modals.scss +84 -84
  93. data/lib/tina4/scss/tina4css/_nav.scss +149 -149
  94. data/lib/tina4/scss/tina4css/_reset.scss +94 -94
  95. data/lib/tina4/scss/tina4css/_tables.scss +54 -54
  96. data/lib/tina4/scss/tina4css/_typography.scss +55 -55
  97. data/lib/tina4/scss/tina4css/_utilities.scss +197 -197
  98. data/lib/tina4/scss/tina4css/_variables.scss +117 -117
  99. data/lib/tina4/scss/tina4css/base.scss +1 -1
  100. data/lib/tina4/scss/tina4css/colors.scss +48 -48
  101. data/lib/tina4/scss/tina4css/tina4.scss +17 -17
  102. data/lib/tina4/scss_compiler.rb +178 -178
  103. data/lib/tina4/seeder.rb +567 -567
  104. data/lib/tina4/service_runner.rb +303 -303
  105. data/lib/tina4/session.rb +297 -297
  106. data/lib/tina4/session_handlers/database_handler.rb +72 -72
  107. data/lib/tina4/session_handlers/file_handler.rb +67 -67
  108. data/lib/tina4/session_handlers/mongo_handler.rb +49 -49
  109. data/lib/tina4/session_handlers/redis_handler.rb +43 -43
  110. data/lib/tina4/session_handlers/valkey_handler.rb +43 -43
  111. data/lib/tina4/shutdown.rb +84 -84
  112. data/lib/tina4/sql_translation.rb +158 -158
  113. data/lib/tina4/swagger.rb +124 -124
  114. data/lib/tina4/template.rb +894 -894
  115. data/lib/tina4/templates/base.twig +26 -26
  116. data/lib/tina4/templates/errors/302.twig +14 -14
  117. data/lib/tina4/templates/errors/401.twig +9 -9
  118. data/lib/tina4/templates/errors/403.twig +29 -29
  119. data/lib/tina4/templates/errors/404.twig +29 -29
  120. data/lib/tina4/templates/errors/500.twig +38 -38
  121. data/lib/tina4/templates/errors/502.twig +9 -9
  122. data/lib/tina4/templates/errors/503.twig +12 -12
  123. data/lib/tina4/templates/errors/base.twig +37 -37
  124. data/lib/tina4/test_client.rb +159 -159
  125. data/lib/tina4/testing.rb +340 -340
  126. data/lib/tina4/validator.rb +174 -174
  127. data/lib/tina4/version.rb +1 -1
  128. data/lib/tina4/webserver.rb +312 -312
  129. data/lib/tina4/websocket.rb +343 -343
  130. data/lib/tina4/websocket_backplane.rb +190 -190
  131. data/lib/tina4/wsdl.rb +564 -564
  132. data/lib/tina4.rb +460 -458
  133. data/lib/tina4ruby.rb +4 -4
  134. metadata +5 -3
@@ -1,935 +1,1291 @@
1
- # frozen_string_literal: true
2
-
3
- require "json"
4
- require "digest"
5
- require "tmpdir"
6
- require "net/http"
7
- require "uri"
8
- require_relative "metrics"
9
-
10
- module Tina4
11
- # Thread-safe in-memory message log for dev dashboard
12
- class MessageLog
13
- Entry = Struct.new(:timestamp, :category, :level, :message, keyword_init: true)
14
-
15
- def initialize
16
- @entries = []
17
- @mutex = Mutex.new
18
- end
19
-
20
- def log(category, level, message)
21
- @mutex.synchronize do
22
- @entries << Entry.new(
23
- timestamp: Time.now.utc.iso8601(3),
24
- category: category.to_s,
25
- level: level.to_s.upcase,
26
- message: message.to_s
27
- )
28
- # Keep last 500 entries
29
- @entries.shift if @entries.size > 500
30
- end
31
- end
32
-
33
- def get(category: nil)
34
- @mutex.synchronize do
35
- list = category ? @entries.select { |e| e.category == category.to_s } : @entries.dup
36
- list.reverse.map { |e| { timestamp: e.timestamp, category: e.category, level: e.level, message: e.message } }
37
- end
38
- end
39
-
40
- def clear(category: nil)
41
- @mutex.synchronize do
42
- if category
43
- @entries.reject! { |e| e.category == category.to_s }
44
- else
45
- @entries.clear
46
- end
47
- end
48
- end
49
-
50
- def count
51
- @mutex.synchronize do
52
- counts = Hash.new(0)
53
- @entries.each { |e| counts[e.category] += 1 }
54
- counts["total"] = @entries.size
55
- counts
56
- end
57
- end
58
- end
59
-
60
- # Thread-safe request capture for dev dashboard
61
- class RequestInspector
62
- CapturedRequest = Struct.new(:timestamp, :method, :path, :status, :duration, keyword_init: true)
63
-
64
- def initialize
65
- @requests = []
66
- @mutex = Mutex.new
67
- end
68
-
69
- def capture(method:, path:, status:, duration:)
70
- @mutex.synchronize do
71
- @requests << CapturedRequest.new(
72
- timestamp: Time.now.utc.iso8601(3),
73
- method: method.to_s,
74
- path: path.to_s,
75
- status: status.to_i,
76
- duration: duration.to_f.round(3)
77
- )
78
- # Keep last 200 entries
79
- @requests.shift if @requests.size > 200
80
- end
81
- end
82
-
83
- def get(limit: 50)
84
- @mutex.synchronize do
85
- @requests.last([limit, @requests.size].min).reverse.map do |r|
86
- { timestamp: r.timestamp, method: r.method, path: r.path, status: r.status, duration_ms: r.duration }
87
- end
88
- end
89
- end
90
-
91
- def stats
92
- @mutex.synchronize do
93
- return { total: 0, avg_ms: 0.0, errors: 0, slowest_ms: 0.0 } if @requests.empty?
94
-
95
- durations = @requests.map(&:duration)
96
- error_count = @requests.count { |r| r.status >= 400 }
97
-
98
- {
99
- total: @requests.size,
100
- avg_ms: (durations.sum / durations.size).round(2),
101
- errors: error_count,
102
- slowest_ms: durations.max.round(2)
103
- }
104
- end
105
- end
106
-
107
- def clear
108
- @mutex.synchronize { @requests.clear }
109
- end
110
- end
111
-
112
- # Thread-safe, file-persisted error tracker for the dev dashboard Error Tracker panel.
113
- #
114
- # Errors are stored in a JSON file in the system temp directory keyed by
115
- # project path, so they survive across requests and server restarts.
116
- # Duplicate errors (same type + message + file + line) are de-duplicated
117
- # the count increments and the entry is re-opened if it was resolved.
118
- class ErrorTracker
119
- MAX_ERRORS = 200
120
- private_constant :MAX_ERRORS
121
-
122
- def initialize
123
- @mutex = Mutex.new
124
- @errors = nil # lazy-loaded
125
- @registered = false
126
- @store_path = File.join(
127
- Dir.tmpdir,
128
- "tina4_dev_errors_#{Digest::MD5.hexdigest(Dir.pwd)}.json"
129
- )
130
- end
131
-
132
- # Capture a Ruby error / exception into the tracker.
133
- # @param error_type [String] e.g. "RuntimeError" or "NoMethodError"
134
- # @param message [String] exception message
135
- # @param traceback [String] formatted backtrace (optional)
136
- # @param file [String] source file (optional)
137
- # @param line [Integer] source line (optional)
138
- def capture(error_type:, message:, traceback: "", file: "", line: 0)
139
- @mutex.synchronize do
140
- load_unlocked
141
- fingerprint = Digest::MD5.hexdigest("#{error_type}|#{message}|#{file}|#{line}")
142
- now = Time.now.utc.strftime("%Y-%m-%dT%H:%M:%SZ")
143
-
144
- if @errors.key?(fingerprint)
145
- @errors[fingerprint][:count] += 1
146
- @errors[fingerprint][:last_seen] = now
147
- @errors[fingerprint][:resolved] = false # re-open resolved duplicates
148
- else
149
- @errors[fingerprint] = {
150
- id: fingerprint,
151
- error_type: error_type,
152
- message: message,
153
- traceback: traceback,
154
- file: file,
155
- line: line,
156
- first_seen: now,
157
- last_seen: now,
158
- count: 1,
159
- resolved: false
160
- }
161
- end
162
- save_unlocked
163
- end
164
- end
165
-
166
- # Capture a Ruby exception object directly.
167
- def capture_exception(exc)
168
- capture(
169
- error_type: exc.class.name,
170
- message: exc.message,
171
- traceback: (exc.backtrace || []).first(20).join("\n"),
172
- file: (exc.backtrace_locations&.first&.path || ""),
173
- line: (exc.backtrace_locations&.first&.lineno || 0)
174
- )
175
- end
176
-
177
- # Return all errors (newest first).
178
- # @param include_resolved [Boolean]
179
- def get(include_resolved: true)
180
- @mutex.synchronize do
181
- load_unlocked
182
- entries = @errors.values
183
- entries = entries.reject { |e| e[:resolved] } unless include_resolved
184
- entries.sort_by { |e| e[:last_seen] }.reverse
185
- end
186
- end
187
-
188
- # Count of unresolved errors.
189
- def unresolved_count
190
- @mutex.synchronize do
191
- load_unlocked
192
- @errors.count { |_, e| !e[:resolved] }
193
- end
194
- end
195
-
196
- # Health summary (matches Python BrokenTracker interface).
197
- def health
198
- @mutex.synchronize do
199
- load_unlocked
200
- total = @errors.size
201
- resolved = @errors.count { |_, e| e[:resolved] }
202
- unresolved = total - resolved
203
- { total: total, unresolved: unresolved, resolved: resolved, healthy: unresolved.zero? }
204
- end
205
- end
206
-
207
- # Mark a single error as resolved.
208
- def resolve(id)
209
- @mutex.synchronize do
210
- load_unlocked
211
- return false unless @errors.key?(id)
212
-
213
- @errors[id][:resolved] = true
214
- save_unlocked
215
- true
216
- end
217
- end
218
-
219
- # Remove all resolved errors.
220
- def clear_resolved
221
- @mutex.synchronize do
222
- load_unlocked
223
- @errors.reject! { |_, e| e[:resolved] }
224
- save_unlocked
225
- end
226
- end
227
-
228
- # Remove ALL errors.
229
- def clear_all
230
- @mutex.synchronize do
231
- @errors = {}
232
- save_unlocked
233
- end
234
- end
235
-
236
- # Register Ruby error handlers to feed the tracker.
237
- # Installs an at_exit hook that captures unhandled exceptions.
238
- # Safe to call multiple times only registers once.
239
- def register
240
- return if @registered
241
-
242
- @registered = true
243
- tracker = self
244
- at_exit do
245
- if (exc = $!) && !exc.is_a?(SystemExit)
246
- tracker.capture_exception(exc)
247
- end
248
- end
249
- end
250
-
251
- # Reset (for testing).
252
- def reset!
253
- @mutex.synchronize do
254
- @errors = {}
255
- @registered = false
256
- File.delete(@store_path) if File.exist?(@store_path)
257
- end
258
- end
259
-
260
- private
261
-
262
- def load_unlocked
263
- return if @errors
264
-
265
- if File.exist?(@store_path)
266
- raw = File.read(@store_path) rescue nil
267
- data = raw ? (JSON.parse(raw, symbolize_names: true) rescue nil) : nil
268
- if data.is_a?(Array)
269
- # Re-key by id
270
- @errors = {}
271
- data.each { |e| @errors[e[:id]] = e if e[:id] }
272
- else
273
- @errors = {}
274
- end
275
- else
276
- @errors = {}
277
- end
278
- end
279
-
280
- def save_unlocked
281
- # Trim to max, keeping newest last_seen
282
- if @errors.size > MAX_ERRORS
283
- sorted = @errors.values.sort_by { |e| e[:last_seen] }.last(MAX_ERRORS)
284
- @errors = {}
285
- sorted.each { |e| @errors[e[:id]] = e }
286
- end
287
-
288
- File.write(@store_path, JSON.generate(@errors.values))
289
- rescue StandardError
290
- # Best-effort persistence — never raise in a tracker
291
- end
292
- end
293
-
294
- # Developer dashboard module - only active in debug mode
295
- module DevAdmin
296
- class << self
297
- def message_log
298
- @message_log ||= MessageLog.new
299
- end
300
-
301
- def request_inspector
302
- @request_inspector ||= RequestInspector.new
303
- end
304
-
305
- def mailbox
306
- @mailbox ||= DevMailbox.new
307
- end
308
-
309
- def error_tracker
310
- @error_tracker ||= ErrorTracker.new
311
- end
312
-
313
- def enabled?
314
- Tina4::Env.is_truthy(ENV["TINA4_DEBUG"])
315
- end
316
-
317
- # Handle a /__dev request; returns [status, headers, body] or nil if not a dev path
318
- def handle_request(env)
319
- return nil unless enabled?
320
-
321
- path = env["PATH_INFO"] || "/"
322
- method = env["REQUEST_METHOD"]
323
-
324
- case [method, path]
325
- when ["GET", "/__dev"], ["GET", "/__dev/"]
326
- serve_dashboard
327
- when ["GET", "/__dev/js/tina4-dev-admin.min.js"]
328
- serve_dev_js
329
- when ["GET", "/__dev/api/mtime"]
330
- json_response({ mtime: @reload_mtime || 0, file: @reload_file || "" })
331
- when ["POST", "/__dev/api/reload"]
332
- body = read_json_body(env) || {}
333
- @reload_mtime = Time.now.to_i
334
- @reload_file = body["file"] || ""
335
- reload_type = body["type"] || "reload"
336
- Tina4::Log.info("External reload trigger: #{reload_type}#{@reload_file.empty? ? '' : " (#{@reload_file})"}")
337
- json_response({ ok: true, type: reload_type })
338
- when ["GET", "/__dev/api/status"]
339
- json_response(status_payload)
340
- when ["GET", "/__dev/api/routes"]
341
- json_response(routes_payload)
342
- when ["GET", "/__dev/api/messages"]
343
- category = query_param(env, "category")
344
- messages = message_log.get(category: category)
345
- counts = message_log.count
346
- json_response({ messages: messages, counts: counts })
347
- when ["POST", "/__dev/api/messages/clear"]
348
- body = read_json_body(env)
349
- category = body["category"] if body
350
- message_log.clear(category: category)
351
- json_response({ cleared: true })
352
- when ["GET", "/__dev/api/requests"]
353
- limit = (query_param(env, "limit") || 50).to_i
354
- json_response({ requests: request_inspector.get(limit: limit), stats: request_inspector.stats })
355
- when ["POST", "/__dev/api/requests/clear"]
356
- request_inspector.clear
357
- json_response({ cleared: true })
358
- when ["GET", "/__dev/api/system"]
359
- json_response(system_payload)
360
- when ["GET", "/__dev/api/queue/topics"]
361
- queue_dir = File.join(Dir.pwd, "data", "queue")
362
- topics = Dir.exist?(queue_dir) ? Dir.children(queue_dir).select { |d| File.directory?(File.join(queue_dir, d)) }.sort : []
363
- topics = ["default"] if topics.empty?
364
- json_response({ topics: topics })
365
- when ["GET", "/__dev/api/queue/dead-letters"]
366
- topic = query_param(env, "topic") || "default"
367
- jobs = []
368
- begin
369
- queue = Tina4::Queue.new(backend: :file, topic: topic) if defined?(Tina4::Queue)
370
- jobs = queue.respond_to?(:dead_letters) ? queue.dead_letters.map { |j| j.merge(status: "dead_letter") } : []
371
- rescue StandardError => e
372
- jobs = []
373
- end
374
- json_response({ jobs: jobs, count: jobs.size, topic: topic })
375
- when ["GET", "/__dev/api/queue"]
376
- topic = query_param(env, "topic") || "default"
377
- stats = { pending: 0, completed: 0, failed: 0, reserved: 0 }
378
- jobs = []
379
- begin
380
- if defined?(Tina4::Queue)
381
- queue = Tina4::Queue.new(backend: :file, topic: topic)
382
- stats = {
383
- pending: queue.respond_to?(:size) ? queue.size("pending") : 0,
384
- completed: queue.respond_to?(:size) ? queue.size("completed") : 0,
385
- failed: queue.respond_to?(:size) ? queue.size("failed") : 0,
386
- reserved: queue.respond_to?(:size) ? queue.size("reserved") : 0,
387
- }
388
- jobs.concat(queue.failed.map { |j| j.merge(status: "failed") }) if queue.respond_to?(:failed)
389
- jobs.concat(queue.dead_letters.map { |j| j.merge(status: "dead_letter") }) if queue.respond_to?(:dead_letters)
390
- end
391
- rescue StandardError => e
392
- # fall through to empty stats
393
- end
394
- json_response({ jobs: jobs, stats: stats })
395
- when ["GET", "/__dev/api/mailbox"]
396
- messages = mailbox.inbox
397
- json_response({ messages: messages, count: messages.size, unread: mailbox.unread_count })
398
- when ["GET", "/__dev/api/broken"]
399
- errors = error_tracker.get(include_resolved: true)
400
- h = error_tracker.health
401
- json_response({ errors: errors, count: errors.size, health: h })
402
- when ["POST", "/__dev/api/broken/resolve"]
403
- body = read_json_body(env)
404
- id = body && body["id"]
405
- resolved = id ? error_tracker.resolve(id) : false
406
- json_response({ resolved: resolved, id: id })
407
- when ["POST", "/__dev/api/broken/clear"]
408
- error_tracker.clear_resolved
409
- json_response({ cleared: true })
410
- when ["GET", "/__dev/api/websockets"]
411
- json_response({ connections: [], count: 0 })
412
- when ["POST", "/__dev/api/websockets/disconnect"]
413
- body = read_json_body(env)
414
- # TODO: disconnect WS connection by id from body["id"]
415
- json_response({ disconnected: true })
416
- when ["GET", "/__dev/api/mailbox/read"]
417
- message_id = query_param(env, "id")
418
- message = mailbox.read(message_id)
419
- if message
420
- json_response(message)
421
- else
422
- body = JSON.generate({ error: "Message not found", id: message_id })
423
- [404, { "content-type" => "application/json; charset=utf-8" }, [body]]
424
- end
425
- when ["POST", "/__dev/api/mailbox/seed"]
426
- body = read_json_body(env)
427
- count = ((body && body["count"]) || 5).to_i
428
- mailbox.seed(count: count)
429
- json_response({ seeded: count })
430
- when ["POST", "/__dev/api/mailbox/clear"]
431
- mailbox.clear
432
- json_response({ cleared: true })
433
- when ["GET", "/__dev/api/messages/search"]
434
- keyword = query_param(env, "q") || query_param(env, "keyword") || ""
435
- all_messages = message_log.get
436
- filtered = keyword.empty? ? all_messages : all_messages.select { |m| m[:message].to_s.downcase.include?(keyword.downcase) }
437
- json_response({ messages: filtered, count: filtered.size, keyword: keyword })
438
- when ["POST", "/__dev/api/queue/retry"]
439
- body = read_json_body(env)
440
- # TODO: retry failed jobs by id from body["id"]
441
- json_response({ retried: true })
442
- when ["POST", "/__dev/api/queue/purge"]
443
- # TODO: purge completed jobs
444
- json_response({ purged: true })
445
- when ["POST", "/__dev/api/queue/replay"]
446
- body = read_json_body(env)
447
- # TODO: replay a specific job by id from body["id"]
448
- json_response({ replayed: true })
449
- when ["GET", "/__dev/api/table"]
450
- table_name = query_param(env, "name")
451
- json_response(table_detail_payload(table_name))
452
- when ["POST", "/__dev/api/seed"]
453
- body = read_json_body(env)
454
- table_name = (body && body["table"]) || ""
455
- count = (body && body["count"]) || 10
456
- json_response(seed_table_data(table_name, count.to_i))
457
- when ["POST", "/__dev/api/tool"]
458
- body = read_json_body(env)
459
- tool = (body && body["tool"]) || ""
460
- json_response(run_tool(tool))
461
- when ["POST", "/__dev/api/chat"]
462
- body = read_json_body(env)
463
- message = (body && body["message"]) || ""
464
- json_response({
465
- reply: "Chat is not yet connected to an AI backend. You said: \"#{message}\"",
466
- timestamp: Time.now.utc.strftime("%Y-%m-%dT%H:%M:%SZ")
467
- })
468
- when ["GET", "/__dev/api/connections"]
469
- handle_connections_get
470
- when ["POST", "/__dev/api/connections/test"]
471
- body = read_json_body(env)
472
- handle_connections_test(body)
473
- when ["POST", "/__dev/api/connections/save"]
474
- body = read_json_body(env)
475
- handle_connections_save(body)
476
- when ["POST", "/__dev/api/query"]
477
- body = read_json_body(env)
478
- sql = (body && (body["query"] || body["sql"])) || ""
479
- json_response(run_query(sql))
480
- when ["GET", "/__dev/api/tables"]
481
- json_response(tables_payload)
482
- when ["GET", "/__dev/api/gallery"]
483
- json_response(gallery_list)
484
- when ["POST", "/__dev/api/gallery/deploy"]
485
- body = read_json_body(env)
486
- name = (body && body["name"]) || ""
487
- json_response(gallery_deploy(name))
488
- when ["GET", "/__dev/api/version-check"]
489
- json_response(version_check_payload)
490
- when ["GET", "/__dev/api/metrics"]
491
- json_response(Tina4::Metrics.quick_metrics)
492
- when ["GET", "/__dev/api/metrics/full"]
493
- json_response(Tina4::Metrics.full_analysis)
494
- when ["GET", "/__dev/api/metrics/file"]
495
- file_path = (query_param(env, "path") || "").to_s
496
- json_response(Tina4::Metrics.file_detail(file_path))
497
- when ["GET", "/__dev/api/graphql/schema"]
498
- begin
499
- gql = Tina4::GraphQL.new
500
- # Auto-discover and register all ORM subclasses
501
- ObjectSpace.each_object(Class).select { |c| c < Tina4::ORM }.each do |model_class|
502
- gql.from_orm(model_class.new)
503
- end
504
- json_response({ schema: gql.introspect, sdl: gql.schema_sdl })
505
- rescue => e
506
- json_response({ error: e.message }, 400)
507
- end
508
- else
509
- nil
510
- end
511
- end
512
-
513
-
514
- private
515
-
516
- def query_param(env, key)
517
- qs = env["QUERY_STRING"] || ""
518
- params = URI.decode_www_form(qs).to_h rescue {}
519
- params[key]
520
- end
521
-
522
- def read_json_body(env)
523
- input = env["rack.input"]
524
- return nil unless input
525
- input.rewind if input.respond_to?(:rewind)
526
- raw = input.read
527
- return nil if raw.nil? || raw.empty?
528
- JSON.parse(raw) rescue nil
529
- end
530
-
531
- def json_response(data)
532
- body = JSON.generate(data)
533
- [200, { "content-type" => "application/json; charset=utf-8" }, [body]]
534
- end
535
-
536
- def version_check_payload
537
- current = Tina4::VERSION
538
- latest = current
539
- begin
540
- uri = URI.parse("https://rubygems.org/api/v1/versions/tina4ruby/latest.json")
541
- http = Net::HTTP.new(uri.host, uri.port)
542
- http.use_ssl = true
543
- http.open_timeout = 5
544
- http.read_timeout = 5
545
- req = Net::HTTP::Get.new(uri)
546
- resp = http.request(req)
547
- if resp.is_a?(Net::HTTPSuccess)
548
- data = JSON.parse(resp.body)
549
- latest = data["version"] || current
550
- end
551
- rescue StandardError
552
- # Offline or timeout — return current as latest
553
- end
554
- { current: current, latest: latest }
555
- end
556
-
557
- def serve_dashboard
558
- spa = '<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0"><title>Tina4 Dev Admin</title></head><body><div id="app" data-framework="ruby" data-color="#ef4444"></div><script src="/__dev/js/tina4-dev-admin.min.js"></script></body></html>'
559
- [200, { "content-type" => "text/html; charset=utf-8" }, [spa]]
560
- end
561
-
562
- def serve_dev_js
563
- js_path = File.join(File.dirname(__FILE__), "public", "js", "tina4-dev-admin.min.js")
564
- if File.file?(js_path)
565
- [200, { "content-type" => "application/javascript; charset=utf-8" }, [File.read(js_path)]]
566
- else
567
- [404, { "content-type" => "text/plain" }, ["tina4-dev-admin.min.js not found"]]
568
- end
569
- end
570
-
571
- def status_payload
572
- db_table_count = 0
573
- begin
574
- db = Tina4.database
575
- db_table_count = db.tables.size if db
576
- rescue
577
- # ignore
578
- end
579
-
580
- {
581
- framework: "tina4-ruby",
582
- version: Tina4::VERSION,
583
- ruby_version: RUBY_VERSION,
584
- platform: RUBY_PLATFORM,
585
- debug: ENV["TINA4_DEBUG"] || "false",
586
- log_level: ENV["TINA4_LOG_LEVEL"] || "ERROR",
587
- database: ENV["DATABASE_URL"] || "not configured",
588
- db_tables: db_table_count,
589
- uptime: (Time.now - (defined?(@boot_time) && @boot_time ? @boot_time : (@boot_time = Time.now))).round(1),
590
- route_count: Tina4::Router.routes.size,
591
- request_stats: request_inspector.stats,
592
- message_counts: message_log.count,
593
- health: error_tracker.health
594
- }
595
- end
596
-
597
- def routes_payload
598
- internal_prefixes = ["/__dev", "/health", "/swagger"]
599
- routes = Tina4::Router.routes
600
- .reject { |route| internal_prefixes.any? { |prefix| route.path.start_with?(prefix) } }
601
- .map do |route|
602
- handler_name = ""
603
- mod = ""
604
- if route.handler.is_a?(Proc)
605
- source = route.handler.source_location
606
- if source
607
- handler_name = "#{File.basename(source[0])}:#{source[1]}"
608
- mod = File.dirname(source[0])
609
- end
610
- end
611
- {
612
- method: route.method,
613
- pattern: route.path,
614
- path: route.path,
615
- middleware: route.respond_to?(:middleware_count) ? route.middleware_count : 0,
616
- cache: route.respond_to?(:cached?) ? route.cached? : false,
617
- secure: !route.auth_handler.nil?,
618
- auth_required: !route.auth_handler.nil?,
619
- handler: handler_name,
620
- module: mod
621
- }
622
- end
623
- { routes: routes, count: routes.size }
624
- end
625
-
626
- def system_payload
627
- gc = GC.stat
628
- mem = begin
629
- if RUBY_PLATFORM.include?("darwin")
630
- `ps -o rss= -p #{Process.pid}`.strip.to_i # KB
631
- elsif RUBY_PLATFORM.include?("linux")
632
- (File.read("/proc/self/status")[/VmRSS:\s+(\d+)/, 1].to_i rescue 0)
633
- else
634
- 0
635
- end
636
- end
637
-
638
- os_release = (`uname -r`.strip rescue "unknown")
639
- host_name = (`hostname`.strip rescue "unknown")
640
-
641
- {
642
- ruby_version: RUBY_VERSION,
643
- ruby_engine: RUBY_ENGINE,
644
- os: "#{RUBY_PLATFORM} #{os_release}",
645
- architecture: RUBY_PLATFORM,
646
- memory: {
647
- current_mb: (mem / 1024.0).round(1),
648
- peak_mb: "N/A",
649
- limit: "N/A"
650
- },
651
- server: {
652
- software: "Ruby/WEBrick",
653
- hostname: host_name,
654
- document_root: Tina4.root_dir || Dir.pwd
655
- },
656
- framework: {
657
- name: "tina4-ruby",
658
- version: Tina4::VERSION,
659
- route_count: Tina4::Router.routes.size
660
- },
661
- extensions: $LOADED_FEATURES.map { |f| File.basename(f, ".rb") }.uniq.sort.first(50),
662
- gc: {
663
- count: gc[:count],
664
- heap_allocated_pages: gc[:heap_allocated_pages],
665
- heap_live_slots: gc[:heap_live_slots],
666
- total_allocated_objects: gc[:total_allocated_objects],
667
- total_freed_objects: gc[:total_freed_objects]
668
- },
669
- pid: Process.pid,
670
- thread_count: Thread.list.size,
671
- env: ENV["TINA4_ENV"] || ENV["RACK_ENV"] || ENV["RUBY_ENV"] || "development",
672
- db_tables: (begin; db = Tina4.database; db ? db.tables.size : 0; rescue; 0; end),
673
- db_connected: (begin; db = Tina4.database; !db.nil?; rescue; false; end)
674
- }
675
- end
676
-
677
- def run_tool(tool)
678
- output = case tool
679
- when "routes"
680
- routes = Tina4::Router.routes.map { |r| { method: r.method, path: r.path } }
681
- JSON.pretty_generate(routes)
682
- when "test"
683
- "Test runner not yet configured. Run: bundle exec rspec"
684
- when "migrate"
685
- "Migration runner not yet configured. Run: tina4ruby migrate"
686
- when "seed"
687
- "Seeder not yet configured. Run: tina4ruby seed"
688
- else
689
- "Unknown tool: #{tool}"
690
- end
691
- { tool: tool, output: output }
692
- end
693
-
694
- def run_query(sql)
695
- sql = sql.to_s.strip
696
- return { error: "No SQL provided" } if sql.empty?
697
-
698
- db = Tina4.database
699
- return { error: "No database configured" } unless db
700
-
701
- # Split multiple statements on semicolons
702
- statements = sql.split(";").map(&:strip).reject(&:empty?)
703
-
704
- begin
705
- if statements.size == 1
706
- first_word = statements[0].split(/[\s\t\n\r]+/, 2).first.to_s.upcase
707
- if %w[SELECT PRAGMA EXPLAIN SHOW DESCRIBE].include?(first_word)
708
- result = db.fetch(statements[0])
709
- rows = result.respond_to?(:to_a) ? result.to_a : (result.is_a?(Array) ? result : [])
710
- columns = rows.first.is_a?(Hash) ? rows.first.keys.map(&:to_s) : []
711
- return { columns: columns, rows: rows, count: rows.size }
712
- end
713
- end
714
-
715
- # Execute all statements (single write or multi-statement batch)
716
- total_affected = 0
717
- statements.each do |stmt|
718
- result = db.execute(stmt)
719
- if result == false
720
- return { error: db.get_error || "Statement failed: #{stmt}" }
721
- end
722
- total_affected += (result.respond_to?(:affected_rows) ? result.affected_rows : 0)
723
- end
724
-
725
- { affected: total_affected, success: true }
726
- rescue => e
727
- { error: e.message }
728
- end
729
- end
730
-
731
- def tables_payload
732
- db = Tina4.database
733
- return { error: "No database configured", tables: [] } unless db
734
-
735
- begin
736
- table_list = db.tables
737
- { tables: table_list }
738
- rescue => e
739
- { error: e.message, tables: [] }
740
- end
741
- end
742
-
743
- def table_detail_payload(table_name)
744
- return { error: "No table name provided" } if table_name.nil? || table_name.strip.empty?
745
-
746
- db = Tina4.database
747
- return { error: "No database configured" } unless db
748
-
749
- begin
750
- columns = db.columns(table_name)
751
- result = db.fetch("SELECT * FROM #{table_name} LIMIT 20")
752
- rows = result.respond_to?(:to_a) ? result.to_a : (result.is_a?(Array) ? result : [])
753
- { table: table_name, columns: columns, rows: rows, count: rows.size }
754
- rescue => e
755
- { error: e.message }
756
- end
757
- end
758
-
759
- def seed_table_data(table_name, count)
760
- return { error: "No table name provided" } if table_name.nil? || table_name.strip.empty?
761
-
762
- db = Tina4.database
763
- return { error: "No database configured" } unless db
764
-
765
- begin
766
- columns = db.columns(table_name)
767
- seeded = Tina4.seed_table(table_name, columns, count: count)
768
- { table: table_name, seeded: seeded }
769
- rescue => e
770
- { error: e.message }
771
- end
772
- end
773
-
774
- def handle_connections_get
775
- env_path = File.join(Dir.pwd, ".env")
776
- url = ""
777
- username = ""
778
- password = ""
779
- if File.file?(env_path)
780
- File.readlines(env_path).each do |line|
781
- line = line.strip
782
- next if line.empty? || line.start_with?("#") || !line.include?("=")
783
- key, val = line.split("=", 2)
784
- key = key.strip
785
- val = (val || "").strip.gsub(/\A["']|["']\z/, "")
786
- case key
787
- when "DATABASE_URL" then url = val
788
- when "DATABASE_USERNAME" then username = val
789
- when "DATABASE_PASSWORD" then password = val.empty? ? "" : "***"
790
- end
791
- end
792
- end
793
- json_response({ url: url, username: username, password: password })
794
- end
795
-
796
- def handle_connections_test(body)
797
- url = (body && body["url"]) || ""
798
- username = (body && body["username"]) || ""
799
- password = (body && body["password"]) || ""
800
- return json_response({ success: false, error: "No connection URL provided" }) if url.empty?
801
- begin
802
- db = Tina4::Database.new(url, username: username, password: password)
803
- version = "Connected"
804
- table_count = 0
805
- begin
806
- tables = db.tables
807
- table_count = tables.is_a?(Array) ? tables.size : 0
808
- rescue => e
809
- table_count = 0
810
- end
811
- begin
812
- url_lower = url.downcase
813
- if url_lower.include?("sqlite")
814
- row = db.fetch_one("SELECT sqlite_version() as v")
815
- version = "SQLite #{row && row[:v] || row && row['v']}" if row
816
- elsif url_lower.include?("postgres")
817
- row = db.fetch_one("SELECT version() as v")
818
- version = (row && (row[:v] || row["v"]) || "PostgreSQL").to_s.split(",").first if row
819
- elsif url_lower.include?("mysql")
820
- row = db.fetch_one("SELECT version() as v")
821
- version = "MySQL #{row && row[:v] || row && row['v']}" if row
822
- elsif url_lower.include?("mssql") || url_lower.include?("sqlserver")
823
- row = db.fetch_one("SELECT @@VERSION as v")
824
- version = (row && (row[:v] || row["v"]) || "MSSQL").to_s.split("\n").first if row
825
- elsif url_lower.include?("firebird")
826
- row = db.fetch_one("SELECT rdb$get_context('SYSTEM', 'ENGINE_VERSION') as v FROM rdb$database")
827
- version = "Firebird #{row && row[:v] || row && row['v']}" if row
828
- end
829
- rescue => e
830
- # Keep version as "Connected"
831
- end
832
- db.close if db.respond_to?(:close)
833
- json_response({ success: true, version: version, tables: table_count })
834
- rescue => e
835
- json_response({ success: false, error: e.message })
836
- end
837
- end
838
-
839
- def handle_connections_save(body)
840
- url = (body && body["url"]) || ""
841
- username = (body && body["username"]) || ""
842
- password = (body && body["password"]) || ""
843
- return json_response({ success: false, error: "No connection URL provided" }) if url.empty?
844
- begin
845
- env_path = File.join(Dir.pwd, ".env")
846
- lines = File.file?(env_path) ? File.readlines(env_path, chomp: true) : []
847
- keys_found = { "DATABASE_URL" => false, "DATABASE_USERNAME" => false, "DATABASE_PASSWORD" => false }
848
- new_lines = []
849
- lines.each do |line|
850
- stripped = line.strip
851
- if stripped.empty? || stripped.start_with?("#") || !stripped.include?("=")
852
- new_lines << line
853
- next
854
- end
855
- key = stripped.split("=", 2).first.strip
856
- case key
857
- when "DATABASE_URL"
858
- new_lines << "DATABASE_URL=#{url}"
859
- keys_found["DATABASE_URL"] = true
860
- when "DATABASE_USERNAME"
861
- new_lines << "DATABASE_USERNAME=#{username}"
862
- keys_found["DATABASE_USERNAME"] = true
863
- when "DATABASE_PASSWORD"
864
- new_lines << "DATABASE_PASSWORD=#{password}"
865
- keys_found["DATABASE_PASSWORD"] = true
866
- else
867
- new_lines << line
868
- end
869
- end
870
- values = { "DATABASE_URL" => url, "DATABASE_USERNAME" => username, "DATABASE_PASSWORD" => password }
871
- keys_found.each do |key, found|
872
- new_lines << "#{key}=#{values[key]}" unless found
873
- end
874
- File.write(env_path, new_lines.join("\n") + "\n")
875
- json_response({ success: true })
876
- rescue => e
877
- json_response({ success: false, error: e.message })
878
- end
879
- end
880
-
881
- def gallery_list
882
- gallery_dir = File.join(File.dirname(__FILE__), "gallery")
883
- items = []
884
- if Dir.exist?(gallery_dir)
885
- Dir.children(gallery_dir).sort.each do |entry|
886
- entry_path = File.join(gallery_dir, entry)
887
- meta_file = File.join(entry_path, "meta.json")
888
- next unless File.directory?(entry_path) && File.file?(meta_file)
889
-
890
- meta = JSON.parse(File.read(meta_file)) rescue next
891
- meta["id"] = entry
892
- src_dir = File.join(entry_path, "src")
893
- if Dir.exist?(src_dir)
894
- meta["files"] = Dir.glob(File.join(src_dir, "**", "*"))
895
- .select { |f| File.file?(f) }
896
- .map { |f| f.sub("#{src_dir}/", "") }
897
- end
898
- items << meta
899
- end
900
- end
901
- { gallery: items, count: items.size }
902
- end
903
-
904
- def gallery_deploy(name)
905
- return { error: "No gallery item specified" } if name.to_s.empty?
906
-
907
- gallery_src = File.join(File.dirname(__FILE__), "gallery", name, "src")
908
- return { error: "Gallery item '#{name}' not found" } unless Dir.exist?(gallery_src)
909
-
910
- require "fileutils"
911
- project_src = File.join(Tina4.root_dir || Dir.pwd, "src")
912
- copied = []
913
- Dir.glob(File.join(gallery_src, "**", "*")).each do |src_file|
914
- next unless File.file?(src_file)
915
-
916
- rel = src_file.sub("#{gallery_src}/", "")
917
- dest = File.join(project_src, rel)
918
- FileUtils.mkdir_p(File.dirname(dest))
919
- FileUtils.cp(src_file, dest)
920
- copied << rel
921
- end
922
-
923
- # Re-discover routes so new files are immediately available
924
- begin
925
- routes_dir = File.join(Tina4.root_dir || Dir.pwd, "src", "routes")
926
- Tina4::Router.load_routes(routes_dir) if Dir.exist?(routes_dir)
927
- rescue => e
928
- Tina4::Log.warning("Gallery route reload: #{e.message}")
929
- end
930
-
931
- { deployed: name, files: copied }
932
- end
933
- end
934
- end
935
- end
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "digest"
5
+ require "tmpdir"
6
+ require "net/http"
7
+ require "uri"
8
+ require "fileutils"
9
+ require "shellwords"
10
+ require_relative "metrics"
11
+
12
+ module Tina4
13
+ # Thread-safe in-memory message log for dev dashboard
14
+ class MessageLog
15
+ Entry = Struct.new(:timestamp, :category, :level, :message, keyword_init: true)
16
+
17
+ def initialize
18
+ @entries = []
19
+ @mutex = Mutex.new
20
+ end
21
+
22
+ def log(category, level, message)
23
+ @mutex.synchronize do
24
+ @entries << Entry.new(
25
+ timestamp: Time.now.utc.iso8601(3),
26
+ category: category.to_s,
27
+ level: level.to_s.upcase,
28
+ message: message.to_s
29
+ )
30
+ # Keep last 500 entries
31
+ @entries.shift if @entries.size > 500
32
+ end
33
+ end
34
+
35
+ def get(category: nil)
36
+ @mutex.synchronize do
37
+ list = category ? @entries.select { |e| e.category == category.to_s } : @entries.dup
38
+ list.reverse.map { |e| { timestamp: e.timestamp, category: e.category, level: e.level, message: e.message } }
39
+ end
40
+ end
41
+
42
+ def clear(category: nil)
43
+ @mutex.synchronize do
44
+ if category
45
+ @entries.reject! { |e| e.category == category.to_s }
46
+ else
47
+ @entries.clear
48
+ end
49
+ end
50
+ end
51
+
52
+ def count
53
+ @mutex.synchronize do
54
+ counts = Hash.new(0)
55
+ @entries.each { |e| counts[e.category] += 1 }
56
+ counts["total"] = @entries.size
57
+ counts
58
+ end
59
+ end
60
+ end
61
+
62
+ # Thread-safe request capture for dev dashboard
63
+ class RequestInspector
64
+ CapturedRequest = Struct.new(:timestamp, :method, :path, :status, :duration, keyword_init: true)
65
+
66
+ def initialize
67
+ @requests = []
68
+ @mutex = Mutex.new
69
+ end
70
+
71
+ def capture(method:, path:, status:, duration:)
72
+ @mutex.synchronize do
73
+ @requests << CapturedRequest.new(
74
+ timestamp: Time.now.utc.iso8601(3),
75
+ method: method.to_s,
76
+ path: path.to_s,
77
+ status: status.to_i,
78
+ duration: duration.to_f.round(3)
79
+ )
80
+ # Keep last 200 entries
81
+ @requests.shift if @requests.size > 200
82
+ end
83
+ end
84
+
85
+ def get(limit: 50)
86
+ @mutex.synchronize do
87
+ @requests.last([limit, @requests.size].min).reverse.map do |r|
88
+ { timestamp: r.timestamp, method: r.method, path: r.path, status: r.status, duration_ms: r.duration }
89
+ end
90
+ end
91
+ end
92
+
93
+ def stats
94
+ @mutex.synchronize do
95
+ return { total: 0, avg_ms: 0.0, errors: 0, slowest_ms: 0.0 } if @requests.empty?
96
+
97
+ durations = @requests.map(&:duration)
98
+ error_count = @requests.count { |r| r.status >= 400 }
99
+
100
+ {
101
+ total: @requests.size,
102
+ avg_ms: (durations.sum / durations.size).round(2),
103
+ errors: error_count,
104
+ slowest_ms: durations.max.round(2)
105
+ }
106
+ end
107
+ end
108
+
109
+ def clear
110
+ @mutex.synchronize { @requests.clear }
111
+ end
112
+ end
113
+
114
+ # Thread-safe, file-persisted error tracker for the dev dashboard Error Tracker panel.
115
+ #
116
+ # Errors are stored in a JSON file in the system temp directory keyed by
117
+ # project path, so they survive across requests and server restarts.
118
+ # Duplicate errors (same type + message + file + line) are de-duplicated —
119
+ # the count increments and the entry is re-opened if it was resolved.
120
+ class ErrorTracker
121
+ MAX_ERRORS = 200
122
+ private_constant :MAX_ERRORS
123
+
124
+ def initialize
125
+ @mutex = Mutex.new
126
+ @errors = nil # lazy-loaded
127
+ @registered = false
128
+ @store_path = File.join(
129
+ Dir.tmpdir,
130
+ "tina4_dev_errors_#{Digest::MD5.hexdigest(Dir.pwd)}.json"
131
+ )
132
+ end
133
+
134
+ # Capture a Ruby error / exception into the tracker.
135
+ # @param error_type [String] e.g. "RuntimeError" or "NoMethodError"
136
+ # @param message [String] exception message
137
+ # @param traceback [String] formatted backtrace (optional)
138
+ # @param file [String] source file (optional)
139
+ # @param line [Integer] source line (optional)
140
+ def capture(error_type:, message:, traceback: "", file: "", line: 0)
141
+ @mutex.synchronize do
142
+ load_unlocked
143
+ fingerprint = Digest::MD5.hexdigest("#{error_type}|#{message}|#{file}|#{line}")
144
+ now = Time.now.utc.strftime("%Y-%m-%dT%H:%M:%SZ")
145
+
146
+ if @errors.key?(fingerprint)
147
+ @errors[fingerprint][:count] += 1
148
+ @errors[fingerprint][:last_seen] = now
149
+ @errors[fingerprint][:resolved] = false # re-open resolved duplicates
150
+ else
151
+ @errors[fingerprint] = {
152
+ id: fingerprint,
153
+ error_type: error_type,
154
+ message: message,
155
+ traceback: traceback,
156
+ file: file,
157
+ line: line,
158
+ first_seen: now,
159
+ last_seen: now,
160
+ count: 1,
161
+ resolved: false
162
+ }
163
+ end
164
+ save_unlocked
165
+ end
166
+ end
167
+
168
+ # Capture a Ruby exception object directly.
169
+ def capture_exception(exc)
170
+ capture(
171
+ error_type: exc.class.name,
172
+ message: exc.message,
173
+ traceback: (exc.backtrace || []).first(20).join("\n"),
174
+ file: (exc.backtrace_locations&.first&.path || ""),
175
+ line: (exc.backtrace_locations&.first&.lineno || 0)
176
+ )
177
+ end
178
+
179
+ # Return all errors (newest first).
180
+ # @param include_resolved [Boolean]
181
+ def get(include_resolved: true)
182
+ @mutex.synchronize do
183
+ load_unlocked
184
+ entries = @errors.values
185
+ entries = entries.reject { |e| e[:resolved] } unless include_resolved
186
+ entries.sort_by { |e| e[:last_seen] }.reverse
187
+ end
188
+ end
189
+
190
+ # Count of unresolved errors.
191
+ def unresolved_count
192
+ @mutex.synchronize do
193
+ load_unlocked
194
+ @errors.count { |_, e| !e[:resolved] }
195
+ end
196
+ end
197
+
198
+ # Health summary (matches Python BrokenTracker interface).
199
+ def health
200
+ @mutex.synchronize do
201
+ load_unlocked
202
+ total = @errors.size
203
+ resolved = @errors.count { |_, e| e[:resolved] }
204
+ unresolved = total - resolved
205
+ { total: total, unresolved: unresolved, resolved: resolved, healthy: unresolved.zero? }
206
+ end
207
+ end
208
+
209
+ # Mark a single error as resolved.
210
+ def resolve(id)
211
+ @mutex.synchronize do
212
+ load_unlocked
213
+ return false unless @errors.key?(id)
214
+
215
+ @errors[id][:resolved] = true
216
+ save_unlocked
217
+ true
218
+ end
219
+ end
220
+
221
+ # Remove all resolved errors.
222
+ def clear_resolved
223
+ @mutex.synchronize do
224
+ load_unlocked
225
+ @errors.reject! { |_, e| e[:resolved] }
226
+ save_unlocked
227
+ end
228
+ end
229
+
230
+ # Remove ALL errors.
231
+ def clear_all
232
+ @mutex.synchronize do
233
+ @errors = {}
234
+ save_unlocked
235
+ end
236
+ end
237
+
238
+ # Register Ruby error handlers to feed the tracker.
239
+ # Installs an at_exit hook that captures unhandled exceptions.
240
+ # Safe to call multiple times — only registers once.
241
+ def register
242
+ return if @registered
243
+
244
+ @registered = true
245
+ tracker = self
246
+ at_exit do
247
+ if (exc = $!) && !exc.is_a?(SystemExit)
248
+ tracker.capture_exception(exc)
249
+ end
250
+ end
251
+ end
252
+
253
+ # Reset (for testing).
254
+ def reset!
255
+ @mutex.synchronize do
256
+ @errors = {}
257
+ @registered = false
258
+ File.delete(@store_path) if File.exist?(@store_path)
259
+ end
260
+ end
261
+
262
+ private
263
+
264
+ def load_unlocked
265
+ return if @errors
266
+
267
+ if File.exist?(@store_path)
268
+ raw = File.read(@store_path) rescue nil
269
+ data = raw ? (JSON.parse(raw, symbolize_names: true) rescue nil) : nil
270
+ if data.is_a?(Array)
271
+ # Re-key by id
272
+ @errors = {}
273
+ data.each { |e| @errors[e[:id]] = e if e[:id] }
274
+ else
275
+ @errors = {}
276
+ end
277
+ else
278
+ @errors = {}
279
+ end
280
+ end
281
+
282
+ def save_unlocked
283
+ # Trim to max, keeping newest last_seen
284
+ if @errors.size > MAX_ERRORS
285
+ sorted = @errors.values.sort_by { |e| e[:last_seen] }.last(MAX_ERRORS)
286
+ @errors = {}
287
+ sorted.each { |e| @errors[e[:id]] = e }
288
+ end
289
+
290
+ File.write(@store_path, JSON.generate(@errors.values))
291
+ rescue StandardError
292
+ # Best-effort persistence — never raise in a tracker
293
+ end
294
+ end
295
+
296
+ # Developer dashboard module - only active in debug mode
297
+ module DevAdmin
298
+ class << self
299
+ def message_log
300
+ @message_log ||= MessageLog.new
301
+ end
302
+
303
+ def request_inspector
304
+ @request_inspector ||= RequestInspector.new
305
+ end
306
+
307
+ def mailbox
308
+ @mailbox ||= DevMailbox.new
309
+ end
310
+
311
+ def error_tracker
312
+ @error_tracker ||= ErrorTracker.new
313
+ end
314
+
315
+ def enabled?
316
+ Tina4::Env.is_truthy(ENV["TINA4_DEBUG"])
317
+ end
318
+
319
+ # Handle a /__dev request; returns [status, headers, body] or nil if not a dev path
320
+ def handle_request(env)
321
+ return nil unless enabled?
322
+
323
+ path = env["PATH_INFO"] || "/"
324
+ method = env["REQUEST_METHOD"]
325
+
326
+ case [method, path]
327
+ when ["GET", "/__dev"], ["GET", "/__dev/"]
328
+ serve_dashboard
329
+ when ["GET", "/__dev/js/tina4-dev-admin.min.js"]
330
+ serve_dev_js
331
+ when ["GET", "/__dev/api/mtime"]
332
+ json_response({ mtime: @reload_mtime || 0, file: @reload_file || "" })
333
+ when ["POST", "/__dev/api/reload"]
334
+ body = read_json_body(env) || {}
335
+ @reload_mtime = Time.now.to_i
336
+ @reload_file = body["file"] || ""
337
+ reload_type = body["type"] || "reload"
338
+ Tina4::Log.info("External reload trigger: #{reload_type}#{@reload_file.empty? ? '' : " (#{@reload_file})"}")
339
+ json_response({ ok: true, type: reload_type })
340
+ when ["GET", "/__dev/api/status"]
341
+ json_response(status_payload)
342
+ when ["GET", "/__dev/api/routes"]
343
+ json_response(routes_payload)
344
+ when ["GET", "/__dev/api/messages"]
345
+ category = query_param(env, "category")
346
+ messages = message_log.get(category: category)
347
+ counts = message_log.count
348
+ json_response({ messages: messages, counts: counts })
349
+ when ["POST", "/__dev/api/messages/clear"]
350
+ body = read_json_body(env)
351
+ category = body["category"] if body
352
+ message_log.clear(category: category)
353
+ json_response({ cleared: true })
354
+ when ["GET", "/__dev/api/requests"]
355
+ limit = (query_param(env, "limit") || 50).to_i
356
+ json_response({ requests: request_inspector.get(limit: limit), stats: request_inspector.stats })
357
+ when ["POST", "/__dev/api/requests/clear"]
358
+ request_inspector.clear
359
+ json_response({ cleared: true })
360
+ when ["GET", "/__dev/api/system"]
361
+ json_response(system_payload)
362
+ when ["GET", "/__dev/api/queue/topics"]
363
+ queue_dir = File.join(Dir.pwd, "data", "queue")
364
+ topics = Dir.exist?(queue_dir) ? Dir.children(queue_dir).select { |d| File.directory?(File.join(queue_dir, d)) }.sort : []
365
+ topics = ["default"] if topics.empty?
366
+ json_response({ topics: topics })
367
+ when ["GET", "/__dev/api/queue/dead-letters"]
368
+ topic = query_param(env, "topic") || "default"
369
+ jobs = []
370
+ begin
371
+ queue = Tina4::Queue.new(backend: :file, topic: topic) if defined?(Tina4::Queue)
372
+ jobs = queue.respond_to?(:dead_letters) ? queue.dead_letters.map { |j| j.merge(status: "dead_letter") } : []
373
+ rescue StandardError => e
374
+ jobs = []
375
+ end
376
+ json_response({ jobs: jobs, count: jobs.size, topic: topic })
377
+ when ["GET", "/__dev/api/queue"]
378
+ topic = query_param(env, "topic") || "default"
379
+ stats = { pending: 0, completed: 0, failed: 0, reserved: 0 }
380
+ jobs = []
381
+ begin
382
+ if defined?(Tina4::Queue)
383
+ queue = Tina4::Queue.new(backend: :file, topic: topic)
384
+ stats = {
385
+ pending: queue.respond_to?(:size) ? queue.size("pending") : 0,
386
+ completed: queue.respond_to?(:size) ? queue.size("completed") : 0,
387
+ failed: queue.respond_to?(:size) ? queue.size("failed") : 0,
388
+ reserved: queue.respond_to?(:size) ? queue.size("reserved") : 0,
389
+ }
390
+ jobs.concat(queue.failed.map { |j| j.merge(status: "failed") }) if queue.respond_to?(:failed)
391
+ jobs.concat(queue.dead_letters.map { |j| j.merge(status: "dead_letter") }) if queue.respond_to?(:dead_letters)
392
+ end
393
+ rescue StandardError => e
394
+ # fall through to empty stats
395
+ end
396
+ json_response({ jobs: jobs, stats: stats })
397
+ when ["GET", "/__dev/api/mailbox"]
398
+ messages = mailbox.inbox
399
+ json_response({ messages: messages, count: messages.size, unread: mailbox.unread_count })
400
+ when ["GET", "/__dev/api/broken"]
401
+ errors = error_tracker.get(include_resolved: true)
402
+ h = error_tracker.health
403
+ json_response({ errors: errors, count: errors.size, health: h })
404
+ when ["POST", "/__dev/api/broken/resolve"]
405
+ body = read_json_body(env)
406
+ id = body && body["id"]
407
+ resolved = id ? error_tracker.resolve(id) : false
408
+ json_response({ resolved: resolved, id: id })
409
+ when ["POST", "/__dev/api/broken/clear"]
410
+ # "Clear All" button — flush every tracked error, not only the
411
+ # ones individually marked resolved. Matches PHP/Python.
412
+ error_tracker.clear_all
413
+ json_response({ cleared: true })
414
+ when ["GET", "/__dev/api/websockets"]
415
+ json_response({ connections: [], count: 0 })
416
+ when ["POST", "/__dev/api/websockets/disconnect"]
417
+ body = read_json_body(env)
418
+ # TODO: disconnect WS connection by id from body["id"]
419
+ json_response({ disconnected: true })
420
+ when ["GET", "/__dev/api/mailbox/read"]
421
+ message_id = query_param(env, "id")
422
+ message = mailbox.read(message_id)
423
+ if message
424
+ json_response(message)
425
+ else
426
+ body = JSON.generate({ error: "Message not found", id: message_id })
427
+ [404, { "content-type" => "application/json; charset=utf-8" }, [body]]
428
+ end
429
+ when ["POST", "/__dev/api/mailbox/seed"]
430
+ body = read_json_body(env)
431
+ count = ((body && body["count"]) || 5).to_i
432
+ mailbox.seed(count: count)
433
+ json_response({ seeded: count })
434
+ when ["POST", "/__dev/api/mailbox/clear"]
435
+ mailbox.clear
436
+ json_response({ cleared: true })
437
+ when ["GET", "/__dev/api/messages/search"]
438
+ keyword = query_param(env, "q") || query_param(env, "keyword") || ""
439
+ all_messages = message_log.get
440
+ filtered = keyword.empty? ? all_messages : all_messages.select { |m| m[:message].to_s.downcase.include?(keyword.downcase) }
441
+ json_response({ messages: filtered, count: filtered.size, keyword: keyword })
442
+ when ["POST", "/__dev/api/queue/retry"]
443
+ body = read_json_body(env)
444
+ # TODO: retry failed jobs by id from body["id"]
445
+ json_response({ retried: true })
446
+ when ["POST", "/__dev/api/queue/purge"]
447
+ # TODO: purge completed jobs
448
+ json_response({ purged: true })
449
+ when ["POST", "/__dev/api/queue/replay"]
450
+ body = read_json_body(env)
451
+ # TODO: replay a specific job by id from body["id"]
452
+ json_response({ replayed: true })
453
+ when ["GET", "/__dev/api/table"]
454
+ table_name = query_param(env, "name")
455
+ json_response(table_detail_payload(table_name))
456
+ when ["POST", "/__dev/api/seed"]
457
+ body = read_json_body(env)
458
+ table_name = (body && body["table"]) || ""
459
+ count = (body && body["count"]) || 10
460
+ json_response(seed_table_data(table_name, count.to_i))
461
+ when ["POST", "/__dev/api/tool"]
462
+ body = read_json_body(env)
463
+ tool = (body && body["tool"]) || ""
464
+ json_response(run_tool(tool))
465
+ when ["POST", "/__dev/api/chat"]
466
+ body = read_json_body(env)
467
+ message = (body && body["message"]) || ""
468
+ json_response({
469
+ reply: "Chat is not yet connected to an AI backend. You said: \"#{message}\"",
470
+ timestamp: Time.now.utc.strftime("%Y-%m-%dT%H:%M:%SZ")
471
+ })
472
+ when ["GET", "/__dev/api/connections"]
473
+ handle_connections_get
474
+ when ["POST", "/__dev/api/connections/test"]
475
+ body = read_json_body(env)
476
+ handle_connections_test(body)
477
+ when ["POST", "/__dev/api/connections/save"]
478
+ body = read_json_body(env)
479
+ handle_connections_save(body)
480
+ when ["POST", "/__dev/api/query"]
481
+ body = read_json_body(env)
482
+ sql = (body && (body["query"] || body["sql"])) || ""
483
+ json_response(run_query(sql))
484
+ when ["GET", "/__dev/api/tables"]
485
+ json_response(tables_payload)
486
+ when ["GET", "/__dev/api/gallery"]
487
+ json_response(gallery_list)
488
+ when ["POST", "/__dev/api/gallery/deploy"]
489
+ body = read_json_body(env)
490
+ name = (body && body["name"]) || ""
491
+ json_response(gallery_deploy(name))
492
+ when ["GET", "/__dev/api/version-check"]
493
+ json_response(version_check_payload)
494
+ when ["GET", "/__dev/api/metrics"]
495
+ json_response(Tina4::Metrics.quick_metrics)
496
+ when ["GET", "/__dev/api/metrics/full"]
497
+ json_response(Tina4::Metrics.full_analysis)
498
+ when ["GET", "/__dev/api/metrics/file"]
499
+ file_path = (query_param(env, "path") || "").to_s
500
+ json_response(Tina4::Metrics.file_detail(file_path))
501
+ when ["GET", "/__dev/api/thoughts"]
502
+ json_response(thoughts_payload)
503
+ when ["POST", "/__dev/api/supervise/create"]
504
+ body = read_json_body(env) || {}
505
+ json_response(proxy_supervisor("/supervise/create", method: "POST", body: body))
506
+ when ["GET", "/__dev/api/supervise/sessions"]
507
+ json_response(proxy_supervisor("/supervise/sessions", method: "GET", query: env["QUERY_STRING"]))
508
+ when ["GET", "/__dev/api/supervise/diff"]
509
+ json_response(proxy_supervisor("/supervise/diff", method: "GET", query: env["QUERY_STRING"]))
510
+ when ["POST", "/__dev/api/supervise/commit"]
511
+ body = read_json_body(env) || {}
512
+ json_response(proxy_supervisor("/supervise/commit", method: "POST", body: body))
513
+ when ["POST", "/__dev/api/supervise/cancel"]
514
+ body = read_json_body(env) || {}
515
+ json_response(proxy_supervisor("/supervise/cancel", method: "POST", body: body))
516
+ when ["POST", "/__dev/api/execute"]
517
+ body = read_json_body(env) || {}
518
+ execute_proxy(body)
519
+ when ["GET", "/__dev/api/files"]
520
+ json_response(files_list(env))
521
+ when ["GET", "/__dev/api/file"]
522
+ json_response(file_read_payload(query_param(env, "path")))
523
+ when ["GET", "/__dev/api/file/raw"]
524
+ file_raw_response(query_param(env, "path"))
525
+ when ["POST", "/__dev/api/file/save"]
526
+ body = read_json_body(env) || {}
527
+ json_response(file_save(body))
528
+ when ["POST", "/__dev/api/file/rename"]
529
+ body = read_json_body(env) || {}
530
+ json_response(file_rename(body))
531
+ when ["POST", "/__dev/api/file/delete"]
532
+ body = read_json_body(env) || {}
533
+ json_response(file_delete(body))
534
+ when ["GET", "/__dev/api/deps/search"]
535
+ json_response(deps_search(query_param(env, "q") || query_param(env, "query") || ""))
536
+ when ["POST", "/__dev/api/deps/install"]
537
+ body = read_json_body(env) || {}
538
+ json_response(deps_install(body))
539
+ when ["GET", "/__dev/api/git/status"]
540
+ json_response(git_status_payload)
541
+ when ["GET", "/__dev/api/mcp/tools"]
542
+ json_response(mcp_tools_list)
543
+ when ["POST", "/__dev/api/mcp/call"]
544
+ body = read_json_body(env) || {}
545
+ json_response(mcp_tool_call(body))
546
+ when ["GET", "/__dev/api/scaffold"]
547
+ json_response(scaffold_templates)
548
+ when ["POST", "/__dev/api/scaffold/run"]
549
+ body = read_json_body(env) || {}
550
+ json_response(scaffold_run(body))
551
+ when ["GET", "/__dev/api/graphql/schema"]
552
+ begin
553
+ gql = Tina4::GraphQL.new
554
+ # Auto-discover and register all ORM subclasses
555
+ ObjectSpace.each_object(Class).select { |c| c < Tina4::ORM }.each do |model_class|
556
+ gql.from_orm(model_class.new)
557
+ end
558
+ json_response({ schema: gql.introspect, sdl: gql.schema_sdl })
559
+ rescue => e
560
+ json_response({ error: e.message }, 400)
561
+ end
562
+ else
563
+ nil
564
+ end
565
+ end
566
+
567
+
568
+ private
569
+
570
+ def query_param(env, key)
571
+ qs = env["QUERY_STRING"] || ""
572
+ params = URI.decode_www_form(qs).to_h rescue {}
573
+ params[key]
574
+ end
575
+
576
+ def read_json_body(env)
577
+ input = env["rack.input"]
578
+ return nil unless input
579
+ input.rewind if input.respond_to?(:rewind)
580
+ raw = input.read
581
+ return nil if raw.nil? || raw.empty?
582
+ JSON.parse(raw) rescue nil
583
+ end
584
+
585
+ def json_response(data)
586
+ body = JSON.generate(data)
587
+ [200, { "content-type" => "application/json; charset=utf-8" }, [body]]
588
+ end
589
+
590
+ def version_check_payload
591
+ current = Tina4::VERSION
592
+ latest = current
593
+ begin
594
+ uri = URI.parse("https://rubygems.org/api/v1/versions/tina4ruby/latest.json")
595
+ http = Net::HTTP.new(uri.host, uri.port)
596
+ http.use_ssl = true
597
+ http.open_timeout = 5
598
+ http.read_timeout = 5
599
+ req = Net::HTTP::Get.new(uri)
600
+ resp = http.request(req)
601
+ if resp.is_a?(Net::HTTPSuccess)
602
+ data = JSON.parse(resp.body)
603
+ latest = data["version"] || current
604
+ end
605
+ rescue StandardError
606
+ # Offline or timeout — return current as latest
607
+ end
608
+ { current: current, latest: latest }
609
+ end
610
+
611
+ def serve_dashboard
612
+ spa = '<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0"><title>Tina4 Dev Admin</title></head><body><div id="app" data-framework="ruby" data-color="#ef4444"></div><script src="/__dev/js/tina4-dev-admin.min.js"></script></body></html>'
613
+ [200, { "content-type" => "text/html; charset=utf-8" }, [spa]]
614
+ end
615
+
616
+ def serve_dev_js
617
+ js_path = File.join(File.dirname(__FILE__), "public", "js", "tina4-dev-admin.min.js")
618
+ if File.file?(js_path)
619
+ [200, { "content-type" => "application/javascript; charset=utf-8" }, [File.read(js_path)]]
620
+ else
621
+ [404, { "content-type" => "text/plain" }, ["tina4-dev-admin.min.js not found"]]
622
+ end
623
+ end
624
+
625
+ def status_payload
626
+ db_table_count = 0
627
+ begin
628
+ db = Tina4.database
629
+ db_table_count = db.tables.size if db
630
+ rescue
631
+ # ignore
632
+ end
633
+
634
+ {
635
+ framework: "tina4-ruby",
636
+ version: Tina4::VERSION,
637
+ ruby_version: RUBY_VERSION,
638
+ platform: RUBY_PLATFORM,
639
+ debug: ENV["TINA4_DEBUG"] || "false",
640
+ log_level: ENV["TINA4_LOG_LEVEL"] || "ERROR",
641
+ database: ENV["DATABASE_URL"] || "not configured",
642
+ db_tables: db_table_count,
643
+ uptime: (Time.now - (defined?(@boot_time) && @boot_time ? @boot_time : (@boot_time = Time.now))).round(1),
644
+ route_count: Tina4::Router.routes.size,
645
+ request_stats: request_inspector.stats,
646
+ message_counts: message_log.count,
647
+ health: error_tracker.health
648
+ }
649
+ end
650
+
651
+ def routes_payload
652
+ internal_prefixes = ["/__dev", "/health", "/swagger"]
653
+ routes = Tina4::Router.routes
654
+ .reject { |route| internal_prefixes.any? { |prefix| route.path.start_with?(prefix) } }
655
+ .map do |route|
656
+ handler_name = ""
657
+ mod = ""
658
+ if route.handler.is_a?(Proc)
659
+ source = route.handler.source_location
660
+ if source
661
+ handler_name = "#{File.basename(source[0])}:#{source[1]}"
662
+ mod = File.dirname(source[0])
663
+ end
664
+ end
665
+ {
666
+ method: route.method,
667
+ pattern: route.path,
668
+ path: route.path,
669
+ middleware: route.respond_to?(:middleware_count) ? route.middleware_count : 0,
670
+ cache: route.respond_to?(:cached?) ? route.cached? : false,
671
+ secure: !route.auth_handler.nil?,
672
+ auth_required: !route.auth_handler.nil?,
673
+ handler: handler_name,
674
+ module: mod
675
+ }
676
+ end
677
+ { routes: routes, count: routes.size }
678
+ end
679
+
680
+ def system_payload
681
+ gc = GC.stat
682
+ mem = begin
683
+ if RUBY_PLATFORM.include?("darwin")
684
+ `ps -o rss= -p #{Process.pid}`.strip.to_i # KB
685
+ elsif RUBY_PLATFORM.include?("linux")
686
+ (File.read("/proc/self/status")[/VmRSS:\s+(\d+)/, 1].to_i rescue 0)
687
+ else
688
+ 0
689
+ end
690
+ end
691
+
692
+ os_release = (`uname -r`.strip rescue "unknown")
693
+ host_name = (`hostname`.strip rescue "unknown")
694
+
695
+ {
696
+ ruby_version: RUBY_VERSION,
697
+ ruby_engine: RUBY_ENGINE,
698
+ os: "#{RUBY_PLATFORM} #{os_release}",
699
+ architecture: RUBY_PLATFORM,
700
+ memory: {
701
+ current_mb: (mem / 1024.0).round(1),
702
+ peak_mb: "N/A",
703
+ limit: "N/A"
704
+ },
705
+ server: {
706
+ software: "Ruby/WEBrick",
707
+ hostname: host_name,
708
+ document_root: Tina4.root_dir || Dir.pwd
709
+ },
710
+ framework: {
711
+ name: "tina4-ruby",
712
+ version: Tina4::VERSION,
713
+ route_count: Tina4::Router.routes.size
714
+ },
715
+ extensions: $LOADED_FEATURES.map { |f| File.basename(f, ".rb") }.uniq.sort.first(50),
716
+ gc: {
717
+ count: gc[:count],
718
+ heap_allocated_pages: gc[:heap_allocated_pages],
719
+ heap_live_slots: gc[:heap_live_slots],
720
+ total_allocated_objects: gc[:total_allocated_objects],
721
+ total_freed_objects: gc[:total_freed_objects]
722
+ },
723
+ pid: Process.pid,
724
+ thread_count: Thread.list.size,
725
+ env: ENV["TINA4_ENV"] || ENV["RACK_ENV"] || ENV["RUBY_ENV"] || "development",
726
+ db_tables: (begin; db = Tina4.database; db ? db.tables.size : 0; rescue; 0; end),
727
+ db_connected: (begin; db = Tina4.database; !db.nil?; rescue; false; end)
728
+ }
729
+ end
730
+
731
+ def run_tool(tool)
732
+ output = case tool
733
+ when "routes"
734
+ routes = Tina4::Router.routes.map { |r| { method: r.method, path: r.path } }
735
+ JSON.pretty_generate(routes)
736
+ when "test"
737
+ "Test runner not yet configured. Run: bundle exec rspec"
738
+ when "migrate"
739
+ "Migration runner not yet configured. Run: tina4ruby migrate"
740
+ when "seed"
741
+ "Seeder not yet configured. Run: tina4ruby seed"
742
+ else
743
+ "Unknown tool: #{tool}"
744
+ end
745
+ { tool: tool, output: output }
746
+ end
747
+
748
+ def run_query(sql)
749
+ sql = sql.to_s.strip
750
+ return { error: "No SQL provided" } if sql.empty?
751
+
752
+ db = Tina4.database
753
+ return { error: "No database configured" } unless db
754
+
755
+ # Split multiple statements on semicolons
756
+ statements = sql.split(";").map(&:strip).reject(&:empty?)
757
+
758
+ begin
759
+ if statements.size == 1
760
+ first_word = statements[0].split(/[\s\t\n\r]+/, 2).first.to_s.upcase
761
+ if %w[SELECT PRAGMA EXPLAIN SHOW DESCRIBE].include?(first_word)
762
+ result = db.fetch(statements[0])
763
+ rows = result.respond_to?(:to_a) ? result.to_a : (result.is_a?(Array) ? result : [])
764
+ columns = rows.first.is_a?(Hash) ? rows.first.keys.map(&:to_s) : []
765
+ return { columns: columns, rows: rows, count: rows.size }
766
+ end
767
+ end
768
+
769
+ # Execute all statements (single write or multi-statement batch)
770
+ total_affected = 0
771
+ statements.each do |stmt|
772
+ result = db.execute(stmt)
773
+ if result == false
774
+ return { error: db.get_error || "Statement failed: #{stmt}" }
775
+ end
776
+ total_affected += (result.respond_to?(:affected_rows) ? result.affected_rows : 0)
777
+ end
778
+
779
+ { affected: total_affected, success: true }
780
+ rescue => e
781
+ { error: e.message }
782
+ end
783
+ end
784
+
785
+ def tables_payload
786
+ db = Tina4.database
787
+ return { error: "No database configured", tables: [] } unless db
788
+
789
+ begin
790
+ table_list = db.tables
791
+ { tables: table_list }
792
+ rescue => e
793
+ { error: e.message, tables: [] }
794
+ end
795
+ end
796
+
797
+ def table_detail_payload(table_name)
798
+ return { error: "No table name provided" } if table_name.nil? || table_name.strip.empty?
799
+
800
+ db = Tina4.database
801
+ return { error: "No database configured" } unless db
802
+
803
+ begin
804
+ columns = db.columns(table_name)
805
+ result = db.fetch("SELECT * FROM #{table_name} LIMIT 20")
806
+ rows = result.respond_to?(:to_a) ? result.to_a : (result.is_a?(Array) ? result : [])
807
+ { table: table_name, columns: columns, rows: rows, count: rows.size }
808
+ rescue => e
809
+ { error: e.message }
810
+ end
811
+ end
812
+
813
+ def seed_table_data(table_name, count)
814
+ return { error: "No table name provided" } if table_name.nil? || table_name.strip.empty?
815
+
816
+ db = Tina4.database
817
+ return { error: "No database configured" } unless db
818
+
819
+ begin
820
+ columns = db.columns(table_name)
821
+ seeded = Tina4.seed_table(table_name, columns, count: count)
822
+ { table: table_name, seeded: seeded }
823
+ rescue => e
824
+ { error: e.message }
825
+ end
826
+ end
827
+
828
+ def handle_connections_get
829
+ env_path = File.join(Dir.pwd, ".env")
830
+ url = ""
831
+ username = ""
832
+ password = ""
833
+ if File.file?(env_path)
834
+ File.readlines(env_path).each do |line|
835
+ line = line.strip
836
+ next if line.empty? || line.start_with?("#") || !line.include?("=")
837
+ key, val = line.split("=", 2)
838
+ key = key.strip
839
+ val = (val || "").strip.gsub(/\A["']|["']\z/, "")
840
+ case key
841
+ when "DATABASE_URL" then url = val
842
+ when "DATABASE_USERNAME" then username = val
843
+ when "DATABASE_PASSWORD" then password = val.empty? ? "" : "***"
844
+ end
845
+ end
846
+ end
847
+ json_response({ url: url, username: username, password: password })
848
+ end
849
+
850
+ def handle_connections_test(body)
851
+ url = (body && body["url"]) || ""
852
+ username = (body && body["username"]) || ""
853
+ password = (body && body["password"]) || ""
854
+ return json_response({ success: false, error: "No connection URL provided" }) if url.empty?
855
+ begin
856
+ db = Tina4::Database.new(url, username: username, password: password)
857
+ version = "Connected"
858
+ table_count = 0
859
+ begin
860
+ tables = db.tables
861
+ table_count = tables.is_a?(Array) ? tables.size : 0
862
+ rescue => e
863
+ table_count = 0
864
+ end
865
+ begin
866
+ url_lower = url.downcase
867
+ if url_lower.include?("sqlite")
868
+ row = db.fetch_one("SELECT sqlite_version() as v")
869
+ version = "SQLite #{row && row[:v] || row && row['v']}" if row
870
+ elsif url_lower.include?("postgres")
871
+ row = db.fetch_one("SELECT version() as v")
872
+ version = (row && (row[:v] || row["v"]) || "PostgreSQL").to_s.split(",").first if row
873
+ elsif url_lower.include?("mysql")
874
+ row = db.fetch_one("SELECT version() as v")
875
+ version = "MySQL #{row && row[:v] || row && row['v']}" if row
876
+ elsif url_lower.include?("mssql") || url_lower.include?("sqlserver")
877
+ row = db.fetch_one("SELECT @@VERSION as v")
878
+ version = (row && (row[:v] || row["v"]) || "MSSQL").to_s.split("\n").first if row
879
+ elsif url_lower.include?("firebird")
880
+ row = db.fetch_one("SELECT rdb$get_context('SYSTEM', 'ENGINE_VERSION') as v FROM rdb$database")
881
+ version = "Firebird #{row && row[:v] || row && row['v']}" if row
882
+ end
883
+ rescue => e
884
+ # Keep version as "Connected"
885
+ end
886
+ db.close if db.respond_to?(:close)
887
+ json_response({ success: true, version: version, tables: table_count })
888
+ rescue => e
889
+ json_response({ success: false, error: e.message })
890
+ end
891
+ end
892
+
893
+ def handle_connections_save(body)
894
+ url = (body && body["url"]) || ""
895
+ username = (body && body["username"]) || ""
896
+ password = (body && body["password"]) || ""
897
+ return json_response({ success: false, error: "No connection URL provided" }) if url.empty?
898
+ begin
899
+ env_path = File.join(Dir.pwd, ".env")
900
+ lines = File.file?(env_path) ? File.readlines(env_path, chomp: true) : []
901
+ keys_found = { "DATABASE_URL" => false, "DATABASE_USERNAME" => false, "DATABASE_PASSWORD" => false }
902
+ new_lines = []
903
+ lines.each do |line|
904
+ stripped = line.strip
905
+ if stripped.empty? || stripped.start_with?("#") || !stripped.include?("=")
906
+ new_lines << line
907
+ next
908
+ end
909
+ key = stripped.split("=", 2).first.strip
910
+ case key
911
+ when "DATABASE_URL"
912
+ new_lines << "DATABASE_URL=#{url}"
913
+ keys_found["DATABASE_URL"] = true
914
+ when "DATABASE_USERNAME"
915
+ new_lines << "DATABASE_USERNAME=#{username}"
916
+ keys_found["DATABASE_USERNAME"] = true
917
+ when "DATABASE_PASSWORD"
918
+ new_lines << "DATABASE_PASSWORD=#{password}"
919
+ keys_found["DATABASE_PASSWORD"] = true
920
+ else
921
+ new_lines << line
922
+ end
923
+ end
924
+ values = { "DATABASE_URL" => url, "DATABASE_USERNAME" => username, "DATABASE_PASSWORD" => password }
925
+ keys_found.each do |key, found|
926
+ new_lines << "#{key}=#{values[key]}" unless found
927
+ end
928
+ File.write(env_path, new_lines.join("\n") + "\n")
929
+ json_response({ success: true })
930
+ rescue => e
931
+ json_response({ success: false, error: e.message })
932
+ end
933
+ end
934
+
935
+ def gallery_list
936
+ gallery_dir = File.join(File.dirname(__FILE__), "gallery")
937
+ items = []
938
+ if Dir.exist?(gallery_dir)
939
+ Dir.children(gallery_dir).sort.each do |entry|
940
+ entry_path = File.join(gallery_dir, entry)
941
+ meta_file = File.join(entry_path, "meta.json")
942
+ next unless File.directory?(entry_path) && File.file?(meta_file)
943
+
944
+ meta = JSON.parse(File.read(meta_file)) rescue next
945
+ meta["id"] = entry
946
+ src_dir = File.join(entry_path, "src")
947
+ if Dir.exist?(src_dir)
948
+ meta["files"] = Dir.glob(File.join(src_dir, "**", "*"))
949
+ .select { |f| File.file?(f) }
950
+ .map { |f| f.sub("#{src_dir}/", "") }
951
+ end
952
+ items << meta
953
+ end
954
+ end
955
+ { gallery: items, count: items.size }
956
+ end
957
+
958
+ def gallery_deploy(name)
959
+ return { error: "No gallery item specified" } if name.to_s.empty?
960
+
961
+ gallery_src = File.join(File.dirname(__FILE__), "gallery", name, "src")
962
+ return { error: "Gallery item '#{name}' not found" } unless Dir.exist?(gallery_src)
963
+
964
+ require "fileutils"
965
+ project_src = File.join(Tina4.root_dir || Dir.pwd, "src")
966
+ copied = []
967
+ Dir.glob(File.join(gallery_src, "**", "*")).each do |src_file|
968
+ next unless File.file?(src_file)
969
+
970
+ rel = src_file.sub("#{gallery_src}/", "")
971
+ dest = File.join(project_src, rel)
972
+ FileUtils.mkdir_p(File.dirname(dest))
973
+ FileUtils.cp(src_file, dest)
974
+ copied << rel
975
+ end
976
+
977
+ # Re-discover routes so new files are immediately available
978
+ begin
979
+ routes_dir = File.join(Tina4.root_dir || Dir.pwd, "src", "routes")
980
+ Tina4::Router.load_routes(routes_dir) if Dir.exist?(routes_dir)
981
+ rescue => e
982
+ Tina4::Log.warning("Gallery route reload: #{e.message}")
983
+ end
984
+
985
+ { deployed: name, files: copied }
986
+ end
987
+
988
+ # ── New dev-admin surface area (parity with Python/PHP) ────
989
+
990
+ def supervisor_base
991
+ base = ENV["TINA4_SUPERVISOR_URL"].to_s.strip
992
+ return base unless base.empty?
993
+ port = (ENV["TINA4_PORT"] || ENV["PORT"] || "7147").to_i + 2000
994
+ "http://127.0.0.1:#{port}"
995
+ end
996
+
997
+ def thoughts_payload
998
+ base = supervisor_base
999
+ begin
1000
+ uri = URI.parse("#{base}/thoughts")
1001
+ req = Net::HTTP::Get.new(uri)
1002
+ resp = Net::HTTP.start(uri.host, uri.port, open_timeout: 2, read_timeout: 5) { |h| h.request(req) }
1003
+ return JSON.parse(resp.body) if resp.is_a?(Net::HTTPSuccess)
1004
+ { thoughts: [], error: "Supervisor returned #{resp.code}" }
1005
+ rescue StandardError => e
1006
+ { thoughts: [], error: e.message }
1007
+ end
1008
+ end
1009
+
1010
+ def proxy_supervisor(path, method: "GET", body: nil, query: nil)
1011
+ base = supervisor_base
1012
+ url = "#{base}#{path}"
1013
+ url += "?#{query}" if query && !query.empty?
1014
+ begin
1015
+ uri = URI.parse(url)
1016
+ req = case method.upcase
1017
+ when "POST"
1018
+ r = Net::HTTP::Post.new(uri)
1019
+ r["Content-Type"] = "application/json"
1020
+ r.body = JSON.generate(body || {})
1021
+ r
1022
+ else
1023
+ Net::HTTP::Get.new(uri)
1024
+ end
1025
+ resp = Net::HTTP.start(uri.host, uri.port, open_timeout: 2, read_timeout: 30) { |h| h.request(req) }
1026
+ begin
1027
+ JSON.parse(resp.body)
1028
+ rescue JSON::ParserError
1029
+ { body: resp.body, status: resp.code.to_i }
1030
+ end
1031
+ rescue StandardError => e
1032
+ { error: e.message, supervisor: base }
1033
+ end
1034
+ end
1035
+
1036
+ def execute_proxy(body)
1037
+ # Proxy POST /execute to the supervisor at framework_port + 2000.
1038
+ # Pass through the response stream as-is (SSE or JSON).
1039
+ base = supervisor_base
1040
+ begin
1041
+ uri = URI.parse("#{base}/execute")
1042
+ req = Net::HTTP::Post.new(uri)
1043
+ req["Content-Type"] = "application/json"
1044
+ req["Accept"] = "text/event-stream"
1045
+ req.body = JSON.generate(body || {})
1046
+ http = Net::HTTP.new(uri.host, uri.port)
1047
+ http.open_timeout = 2
1048
+ http.read_timeout = 300
1049
+ resp = http.request(req)
1050
+ ct = resp["content-type"] || "application/json; charset=utf-8"
1051
+ [resp.code.to_i, { "content-type" => ct }, [resp.body.to_s]]
1052
+ rescue StandardError => e
1053
+ body_str = JSON.generate({ error: e.message, supervisor: base })
1054
+ [502, { "content-type" => "application/json; charset=utf-8" }, [body_str]]
1055
+ end
1056
+ end
1057
+
1058
+ def safe_project_path(rel_path)
1059
+ root = File.expand_path(Dir.pwd)
1060
+ resolved = File.expand_path(rel_path.to_s, root)
1061
+ raise ArgumentError, "path escapes project directory" unless resolved.start_with?(root)
1062
+ resolved
1063
+ end
1064
+
1065
+ def files_list(env)
1066
+ rel = query_param(env, "path") || "."
1067
+ begin
1068
+ target = safe_project_path(rel)
1069
+ return { error: "Not found" } unless File.exist?(target)
1070
+ return { error: "Not a directory" } unless File.directory?(target)
1071
+ entries = Dir.children(target).sort.map do |name|
1072
+ full = File.join(target, name)
1073
+ {
1074
+ name: name,
1075
+ type: File.directory?(full) ? "dir" : "file",
1076
+ size: File.file?(full) ? File.size(full) : 0
1077
+ }
1078
+ end
1079
+ { path: rel, entries: entries, count: entries.size }
1080
+ rescue => e
1081
+ { error: e.message }
1082
+ end
1083
+ end
1084
+
1085
+ def file_read_payload(rel)
1086
+ return { error: "path required" } if rel.nil? || rel.empty?
1087
+ begin
1088
+ target = safe_project_path(rel)
1089
+ return { error: "Not found" } unless File.exist?(target)
1090
+ return { error: "Not a file" } unless File.file?(target)
1091
+ content = File.read(target, encoding: "utf-8", invalid: :replace, undef: :replace)
1092
+ { path: rel, content: content, bytes: File.size(target) }
1093
+ rescue => e
1094
+ { error: e.message }
1095
+ end
1096
+ end
1097
+
1098
+ def file_raw_response(rel)
1099
+ return json_response({ error: "path required" }) if rel.nil? || rel.empty?
1100
+ begin
1101
+ target = safe_project_path(rel)
1102
+ return json_response({ error: "Not found" }) unless File.file?(target)
1103
+ content = File.binread(target)
1104
+ ct = case File.extname(target).downcase
1105
+ when ".css" then "text/css"
1106
+ when ".js" then "application/javascript"
1107
+ when ".json" then "application/json"
1108
+ when ".html", ".htm" then "text/html"
1109
+ when ".png" then "image/png"
1110
+ when ".jpg", ".jpeg" then "image/jpeg"
1111
+ when ".gif" then "image/gif"
1112
+ when ".svg" then "image/svg+xml"
1113
+ else "text/plain; charset=utf-8"
1114
+ end
1115
+ [200, { "content-type" => ct }, [content]]
1116
+ rescue => e
1117
+ json_response({ error: e.message })
1118
+ end
1119
+ end
1120
+
1121
+ def file_save(body)
1122
+ rel = body["path"].to_s
1123
+ content = body["content"].to_s
1124
+ return { error: "path required" } if rel.empty?
1125
+ begin
1126
+ target = safe_project_path(rel)
1127
+ existed = File.exist?(target)
1128
+ FileUtils.mkdir_p(File.dirname(target))
1129
+ File.write(target, content, encoding: "utf-8")
1130
+ Tina4::Plan.record_action(existed ? "patched" : "created", rel) if defined?(Tina4::Plan)
1131
+ { saved: rel, bytes: content.bytesize }
1132
+ rescue => e
1133
+ { error: e.message }
1134
+ end
1135
+ end
1136
+
1137
+ def file_rename(body)
1138
+ from = body["from"].to_s
1139
+ to = body["to"].to_s
1140
+ return { error: "from/to required" } if from.empty? || to.empty?
1141
+ begin
1142
+ src = safe_project_path(from)
1143
+ dst = safe_project_path(to)
1144
+ return { error: "Source not found" } unless File.exist?(src)
1145
+ FileUtils.mkdir_p(File.dirname(dst))
1146
+ File.rename(src, dst)
1147
+ { renamed: { from: from, to: to } }
1148
+ rescue => e
1149
+ { error: e.message }
1150
+ end
1151
+ end
1152
+
1153
+ def file_delete(body)
1154
+ rel = body["path"].to_s
1155
+ return { error: "path required" } if rel.empty?
1156
+ begin
1157
+ target = safe_project_path(rel)
1158
+ return { error: "Not found" } unless File.exist?(target)
1159
+ if File.directory?(target)
1160
+ FileUtils.rm_rf(target)
1161
+ else
1162
+ File.delete(target)
1163
+ end
1164
+ { deleted: rel }
1165
+ rescue => e
1166
+ { error: e.message }
1167
+ end
1168
+ end
1169
+
1170
+ def deps_search(query)
1171
+ return { results: [], count: 0, error: "query required" } if query.to_s.strip.empty?
1172
+ begin
1173
+ uri = URI.parse("https://rubygems.org/api/v1/search.json?query=#{URI.encode_www_form_component(query)}")
1174
+ http = Net::HTTP.new(uri.host, uri.port)
1175
+ http.use_ssl = true
1176
+ http.open_timeout = 5
1177
+ http.read_timeout = 8
1178
+ resp = http.request(Net::HTTP::Get.new(uri))
1179
+ if resp.is_a?(Net::HTTPSuccess)
1180
+ gems = JSON.parse(resp.body)
1181
+ results = gems.first(20).map do |g|
1182
+ { name: g["name"], version: g["version"], info: g["info"].to_s[0, 200] }
1183
+ end
1184
+ { results: results, count: results.size }
1185
+ else
1186
+ { results: [], count: 0, error: "rubygems returned #{resp.code}" }
1187
+ end
1188
+ rescue => e
1189
+ { results: [], count: 0, error: e.message }
1190
+ end
1191
+ end
1192
+
1193
+ def deps_install(body)
1194
+ name = body["name"].to_s.strip
1195
+ return { ok: false, error: "name required" } if name.empty?
1196
+ # Append to Gemfile if not present — do NOT actually bundle install.
1197
+ gemfile = File.join(Dir.pwd, "Gemfile")
1198
+ return { ok: false, error: "No Gemfile at project root" } unless File.exist?(gemfile)
1199
+ content = File.read(gemfile)
1200
+ if content.include?("gem \"#{name}\"") || content.include?("gem '#{name}'")
1201
+ return { ok: true, gem: name, note: "already in Gemfile" }
1202
+ end
1203
+ File.open(gemfile, "a") { |f| f.write("\ngem \"#{name}\"\n") }
1204
+ { ok: true, gem: name, note: "added to Gemfile; run `bundle install`" }
1205
+ end
1206
+
1207
+ def git_status_payload
1208
+ begin
1209
+ inside = `cd #{Shellwords.escape(Dir.pwd)} && git rev-parse --is-inside-work-tree 2>/dev/null`.strip
1210
+ return { error: "Not a git repository" } if inside != "true"
1211
+ branch = `cd #{Shellwords.escape(Dir.pwd)} && git branch --show-current 2>/dev/null`.strip
1212
+ status = `cd #{Shellwords.escape(Dir.pwd)} && git status --porcelain 2>/dev/null`.strip.split("\n").reject(&:empty?)
1213
+ recent = `cd #{Shellwords.escape(Dir.pwd)} && git log --oneline -5 2>/dev/null`.strip.split("\n").reject(&:empty?)
1214
+ { branch: branch, status: status, recent_commits: recent }
1215
+ rescue => e
1216
+ { error: "git unavailable: #{e.message}" }
1217
+ end
1218
+ end
1219
+
1220
+ def mcp_tools_list
1221
+ return { tools: [], count: 0 } unless defined?(Tina4::McpServer)
1222
+ server = Tina4._default_mcp_server
1223
+ list = server.tools.values.map do |t|
1224
+ { name: t["name"], description: t["description"], schema: t["inputSchema"] }
1225
+ end
1226
+ { tools: list, count: list.size }
1227
+ end
1228
+
1229
+ def mcp_tool_call(body)
1230
+ tool_name = body["name"].to_s
1231
+ args = body["arguments"] || {}
1232
+ return { error: "tool name required" } if tool_name.empty?
1233
+ return { error: "MCP not loaded" } unless defined?(Tina4::McpServer)
1234
+ server = Tina4._default_mcp_server
1235
+ payload = JSON.generate({
1236
+ "jsonrpc" => "2.0",
1237
+ "id" => 1,
1238
+ "method" => "tools/call",
1239
+ "params" => { "name" => tool_name, "arguments" => args }
1240
+ })
1241
+ raw = server.handle_message(payload)
1242
+ return {} if raw.nil? || raw.empty?
1243
+ JSON.parse(raw)
1244
+ end
1245
+
1246
+ def scaffold_templates
1247
+ # Expose built-in scaffold targets for the dev-admin UI.
1248
+ { templates: [
1249
+ { id: "route", label: "Route file", target: "src/routes" },
1250
+ { id: "model", label: "ORM model", target: "src/orm" },
1251
+ { id: "migration", label: "SQL migration", target: "migrations" },
1252
+ { id: "middleware", label: "Middleware class", target: "src/app" }
1253
+ ] }
1254
+ end
1255
+
1256
+ def scaffold_run(body)
1257
+ kind = body["kind"].to_s
1258
+ name = body["name"].to_s.strip
1259
+ return { ok: false, error: "kind + name required" } if kind.empty? || name.empty?
1260
+ project = Dir.pwd
1261
+ case kind
1262
+ when "route"
1263
+ target = File.join(project, "src", "routes", "#{name}.rb")
1264
+ FileUtils.mkdir_p(File.dirname(target))
1265
+ File.write(target, "# #{name} routes\nTina4::Router.get(\"/api/#{name}\") do |req, res|\n res.call({ hello: \"#{name}\" })\nend\n") unless File.exist?(target)
1266
+ { ok: true, created: target.sub("#{project}/", "") }
1267
+ when "model"
1268
+ target = File.join(project, "src", "orm", "#{name}.rb")
1269
+ FileUtils.mkdir_p(File.dirname(target))
1270
+ cls = name.to_s.split(/[_-]/).map(&:capitalize).join
1271
+ File.write(target, "class #{cls} < Tina4::ORM\n integer_field :id, primary_key: true, auto_increment: true\n string_field :name\nend\n") unless File.exist?(target)
1272
+ { ok: true, created: target.sub("#{project}/", "") }
1273
+ when "migration"
1274
+ ts = Time.now.strftime("%Y%m%d%H%M%S")
1275
+ target = File.join(project, "migrations", "#{ts}_#{name}.sql")
1276
+ FileUtils.mkdir_p(File.dirname(target))
1277
+ File.write(target, "-- migration: #{name}\n")
1278
+ { ok: true, created: target.sub("#{project}/", "") }
1279
+ when "middleware"
1280
+ target = File.join(project, "src", "app", "#{name}.rb")
1281
+ FileUtils.mkdir_p(File.dirname(target))
1282
+ cls = name.to_s.split(/[_-]/).map(&:capitalize).join
1283
+ File.write(target, "class #{cls}\n def self.before_check(req, res); [req, res]; end\nend\n") unless File.exist?(target)
1284
+ { ok: true, created: target.sub("#{project}/", "") }
1285
+ else
1286
+ { ok: false, error: "unknown kind: #{kind}" }
1287
+ end
1288
+ end
1289
+ end
1290
+ end
1291
+ end