tina4ruby 3.11.15 → 3.11.16

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 +1289 -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,1289 @@
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
+ error_tracker.clear_resolved
411
+ json_response({ cleared: true })
412
+ when ["GET", "/__dev/api/websockets"]
413
+ json_response({ connections: [], count: 0 })
414
+ when ["POST", "/__dev/api/websockets/disconnect"]
415
+ body = read_json_body(env)
416
+ # TODO: disconnect WS connection by id from body["id"]
417
+ json_response({ disconnected: true })
418
+ when ["GET", "/__dev/api/mailbox/read"]
419
+ message_id = query_param(env, "id")
420
+ message = mailbox.read(message_id)
421
+ if message
422
+ json_response(message)
423
+ else
424
+ body = JSON.generate({ error: "Message not found", id: message_id })
425
+ [404, { "content-type" => "application/json; charset=utf-8" }, [body]]
426
+ end
427
+ when ["POST", "/__dev/api/mailbox/seed"]
428
+ body = read_json_body(env)
429
+ count = ((body && body["count"]) || 5).to_i
430
+ mailbox.seed(count: count)
431
+ json_response({ seeded: count })
432
+ when ["POST", "/__dev/api/mailbox/clear"]
433
+ mailbox.clear
434
+ json_response({ cleared: true })
435
+ when ["GET", "/__dev/api/messages/search"]
436
+ keyword = query_param(env, "q") || query_param(env, "keyword") || ""
437
+ all_messages = message_log.get
438
+ filtered = keyword.empty? ? all_messages : all_messages.select { |m| m[:message].to_s.downcase.include?(keyword.downcase) }
439
+ json_response({ messages: filtered, count: filtered.size, keyword: keyword })
440
+ when ["POST", "/__dev/api/queue/retry"]
441
+ body = read_json_body(env)
442
+ # TODO: retry failed jobs by id from body["id"]
443
+ json_response({ retried: true })
444
+ when ["POST", "/__dev/api/queue/purge"]
445
+ # TODO: purge completed jobs
446
+ json_response({ purged: true })
447
+ when ["POST", "/__dev/api/queue/replay"]
448
+ body = read_json_body(env)
449
+ # TODO: replay a specific job by id from body["id"]
450
+ json_response({ replayed: true })
451
+ when ["GET", "/__dev/api/table"]
452
+ table_name = query_param(env, "name")
453
+ json_response(table_detail_payload(table_name))
454
+ when ["POST", "/__dev/api/seed"]
455
+ body = read_json_body(env)
456
+ table_name = (body && body["table"]) || ""
457
+ count = (body && body["count"]) || 10
458
+ json_response(seed_table_data(table_name, count.to_i))
459
+ when ["POST", "/__dev/api/tool"]
460
+ body = read_json_body(env)
461
+ tool = (body && body["tool"]) || ""
462
+ json_response(run_tool(tool))
463
+ when ["POST", "/__dev/api/chat"]
464
+ body = read_json_body(env)
465
+ message = (body && body["message"]) || ""
466
+ json_response({
467
+ reply: "Chat is not yet connected to an AI backend. You said: \"#{message}\"",
468
+ timestamp: Time.now.utc.strftime("%Y-%m-%dT%H:%M:%SZ")
469
+ })
470
+ when ["GET", "/__dev/api/connections"]
471
+ handle_connections_get
472
+ when ["POST", "/__dev/api/connections/test"]
473
+ body = read_json_body(env)
474
+ handle_connections_test(body)
475
+ when ["POST", "/__dev/api/connections/save"]
476
+ body = read_json_body(env)
477
+ handle_connections_save(body)
478
+ when ["POST", "/__dev/api/query"]
479
+ body = read_json_body(env)
480
+ sql = (body && (body["query"] || body["sql"])) || ""
481
+ json_response(run_query(sql))
482
+ when ["GET", "/__dev/api/tables"]
483
+ json_response(tables_payload)
484
+ when ["GET", "/__dev/api/gallery"]
485
+ json_response(gallery_list)
486
+ when ["POST", "/__dev/api/gallery/deploy"]
487
+ body = read_json_body(env)
488
+ name = (body && body["name"]) || ""
489
+ json_response(gallery_deploy(name))
490
+ when ["GET", "/__dev/api/version-check"]
491
+ json_response(version_check_payload)
492
+ when ["GET", "/__dev/api/metrics"]
493
+ json_response(Tina4::Metrics.quick_metrics)
494
+ when ["GET", "/__dev/api/metrics/full"]
495
+ json_response(Tina4::Metrics.full_analysis)
496
+ when ["GET", "/__dev/api/metrics/file"]
497
+ file_path = (query_param(env, "path") || "").to_s
498
+ json_response(Tina4::Metrics.file_detail(file_path))
499
+ when ["GET", "/__dev/api/thoughts"]
500
+ json_response(thoughts_payload)
501
+ when ["POST", "/__dev/api/supervise/create"]
502
+ body = read_json_body(env) || {}
503
+ json_response(proxy_supervisor("/supervise/create", method: "POST", body: body))
504
+ when ["GET", "/__dev/api/supervise/sessions"]
505
+ json_response(proxy_supervisor("/supervise/sessions", method: "GET", query: env["QUERY_STRING"]))
506
+ when ["GET", "/__dev/api/supervise/diff"]
507
+ json_response(proxy_supervisor("/supervise/diff", method: "GET", query: env["QUERY_STRING"]))
508
+ when ["POST", "/__dev/api/supervise/commit"]
509
+ body = read_json_body(env) || {}
510
+ json_response(proxy_supervisor("/supervise/commit", method: "POST", body: body))
511
+ when ["POST", "/__dev/api/supervise/cancel"]
512
+ body = read_json_body(env) || {}
513
+ json_response(proxy_supervisor("/supervise/cancel", method: "POST", body: body))
514
+ when ["POST", "/__dev/api/execute"]
515
+ body = read_json_body(env) || {}
516
+ execute_proxy(body)
517
+ when ["GET", "/__dev/api/files"]
518
+ json_response(files_list(env))
519
+ when ["GET", "/__dev/api/file"]
520
+ json_response(file_read_payload(query_param(env, "path")))
521
+ when ["GET", "/__dev/api/file/raw"]
522
+ file_raw_response(query_param(env, "path"))
523
+ when ["POST", "/__dev/api/file/save"]
524
+ body = read_json_body(env) || {}
525
+ json_response(file_save(body))
526
+ when ["POST", "/__dev/api/file/rename"]
527
+ body = read_json_body(env) || {}
528
+ json_response(file_rename(body))
529
+ when ["POST", "/__dev/api/file/delete"]
530
+ body = read_json_body(env) || {}
531
+ json_response(file_delete(body))
532
+ when ["GET", "/__dev/api/deps/search"]
533
+ json_response(deps_search(query_param(env, "q") || query_param(env, "query") || ""))
534
+ when ["POST", "/__dev/api/deps/install"]
535
+ body = read_json_body(env) || {}
536
+ json_response(deps_install(body))
537
+ when ["GET", "/__dev/api/git/status"]
538
+ json_response(git_status_payload)
539
+ when ["GET", "/__dev/api/mcp/tools"]
540
+ json_response(mcp_tools_list)
541
+ when ["POST", "/__dev/api/mcp/call"]
542
+ body = read_json_body(env) || {}
543
+ json_response(mcp_tool_call(body))
544
+ when ["GET", "/__dev/api/scaffold"]
545
+ json_response(scaffold_templates)
546
+ when ["POST", "/__dev/api/scaffold/run"]
547
+ body = read_json_body(env) || {}
548
+ json_response(scaffold_run(body))
549
+ when ["GET", "/__dev/api/graphql/schema"]
550
+ begin
551
+ gql = Tina4::GraphQL.new
552
+ # Auto-discover and register all ORM subclasses
553
+ ObjectSpace.each_object(Class).select { |c| c < Tina4::ORM }.each do |model_class|
554
+ gql.from_orm(model_class.new)
555
+ end
556
+ json_response({ schema: gql.introspect, sdl: gql.schema_sdl })
557
+ rescue => e
558
+ json_response({ error: e.message }, 400)
559
+ end
560
+ else
561
+ nil
562
+ end
563
+ end
564
+
565
+
566
+ private
567
+
568
+ def query_param(env, key)
569
+ qs = env["QUERY_STRING"] || ""
570
+ params = URI.decode_www_form(qs).to_h rescue {}
571
+ params[key]
572
+ end
573
+
574
+ def read_json_body(env)
575
+ input = env["rack.input"]
576
+ return nil unless input
577
+ input.rewind if input.respond_to?(:rewind)
578
+ raw = input.read
579
+ return nil if raw.nil? || raw.empty?
580
+ JSON.parse(raw) rescue nil
581
+ end
582
+
583
+ def json_response(data)
584
+ body = JSON.generate(data)
585
+ [200, { "content-type" => "application/json; charset=utf-8" }, [body]]
586
+ end
587
+
588
+ def version_check_payload
589
+ current = Tina4::VERSION
590
+ latest = current
591
+ begin
592
+ uri = URI.parse("https://rubygems.org/api/v1/versions/tina4ruby/latest.json")
593
+ http = Net::HTTP.new(uri.host, uri.port)
594
+ http.use_ssl = true
595
+ http.open_timeout = 5
596
+ http.read_timeout = 5
597
+ req = Net::HTTP::Get.new(uri)
598
+ resp = http.request(req)
599
+ if resp.is_a?(Net::HTTPSuccess)
600
+ data = JSON.parse(resp.body)
601
+ latest = data["version"] || current
602
+ end
603
+ rescue StandardError
604
+ # Offline or timeout — return current as latest
605
+ end
606
+ { current: current, latest: latest }
607
+ end
608
+
609
+ def serve_dashboard
610
+ 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>'
611
+ [200, { "content-type" => "text/html; charset=utf-8" }, [spa]]
612
+ end
613
+
614
+ def serve_dev_js
615
+ js_path = File.join(File.dirname(__FILE__), "public", "js", "tina4-dev-admin.min.js")
616
+ if File.file?(js_path)
617
+ [200, { "content-type" => "application/javascript; charset=utf-8" }, [File.read(js_path)]]
618
+ else
619
+ [404, { "content-type" => "text/plain" }, ["tina4-dev-admin.min.js not found"]]
620
+ end
621
+ end
622
+
623
+ def status_payload
624
+ db_table_count = 0
625
+ begin
626
+ db = Tina4.database
627
+ db_table_count = db.tables.size if db
628
+ rescue
629
+ # ignore
630
+ end
631
+
632
+ {
633
+ framework: "tina4-ruby",
634
+ version: Tina4::VERSION,
635
+ ruby_version: RUBY_VERSION,
636
+ platform: RUBY_PLATFORM,
637
+ debug: ENV["TINA4_DEBUG"] || "false",
638
+ log_level: ENV["TINA4_LOG_LEVEL"] || "ERROR",
639
+ database: ENV["DATABASE_URL"] || "not configured",
640
+ db_tables: db_table_count,
641
+ uptime: (Time.now - (defined?(@boot_time) && @boot_time ? @boot_time : (@boot_time = Time.now))).round(1),
642
+ route_count: Tina4::Router.routes.size,
643
+ request_stats: request_inspector.stats,
644
+ message_counts: message_log.count,
645
+ health: error_tracker.health
646
+ }
647
+ end
648
+
649
+ def routes_payload
650
+ internal_prefixes = ["/__dev", "/health", "/swagger"]
651
+ routes = Tina4::Router.routes
652
+ .reject { |route| internal_prefixes.any? { |prefix| route.path.start_with?(prefix) } }
653
+ .map do |route|
654
+ handler_name = ""
655
+ mod = ""
656
+ if route.handler.is_a?(Proc)
657
+ source = route.handler.source_location
658
+ if source
659
+ handler_name = "#{File.basename(source[0])}:#{source[1]}"
660
+ mod = File.dirname(source[0])
661
+ end
662
+ end
663
+ {
664
+ method: route.method,
665
+ pattern: route.path,
666
+ path: route.path,
667
+ middleware: route.respond_to?(:middleware_count) ? route.middleware_count : 0,
668
+ cache: route.respond_to?(:cached?) ? route.cached? : false,
669
+ secure: !route.auth_handler.nil?,
670
+ auth_required: !route.auth_handler.nil?,
671
+ handler: handler_name,
672
+ module: mod
673
+ }
674
+ end
675
+ { routes: routes, count: routes.size }
676
+ end
677
+
678
+ def system_payload
679
+ gc = GC.stat
680
+ mem = begin
681
+ if RUBY_PLATFORM.include?("darwin")
682
+ `ps -o rss= -p #{Process.pid}`.strip.to_i # KB
683
+ elsif RUBY_PLATFORM.include?("linux")
684
+ (File.read("/proc/self/status")[/VmRSS:\s+(\d+)/, 1].to_i rescue 0)
685
+ else
686
+ 0
687
+ end
688
+ end
689
+
690
+ os_release = (`uname -r`.strip rescue "unknown")
691
+ host_name = (`hostname`.strip rescue "unknown")
692
+
693
+ {
694
+ ruby_version: RUBY_VERSION,
695
+ ruby_engine: RUBY_ENGINE,
696
+ os: "#{RUBY_PLATFORM} #{os_release}",
697
+ architecture: RUBY_PLATFORM,
698
+ memory: {
699
+ current_mb: (mem / 1024.0).round(1),
700
+ peak_mb: "N/A",
701
+ limit: "N/A"
702
+ },
703
+ server: {
704
+ software: "Ruby/WEBrick",
705
+ hostname: host_name,
706
+ document_root: Tina4.root_dir || Dir.pwd
707
+ },
708
+ framework: {
709
+ name: "tina4-ruby",
710
+ version: Tina4::VERSION,
711
+ route_count: Tina4::Router.routes.size
712
+ },
713
+ extensions: $LOADED_FEATURES.map { |f| File.basename(f, ".rb") }.uniq.sort.first(50),
714
+ gc: {
715
+ count: gc[:count],
716
+ heap_allocated_pages: gc[:heap_allocated_pages],
717
+ heap_live_slots: gc[:heap_live_slots],
718
+ total_allocated_objects: gc[:total_allocated_objects],
719
+ total_freed_objects: gc[:total_freed_objects]
720
+ },
721
+ pid: Process.pid,
722
+ thread_count: Thread.list.size,
723
+ env: ENV["TINA4_ENV"] || ENV["RACK_ENV"] || ENV["RUBY_ENV"] || "development",
724
+ db_tables: (begin; db = Tina4.database; db ? db.tables.size : 0; rescue; 0; end),
725
+ db_connected: (begin; db = Tina4.database; !db.nil?; rescue; false; end)
726
+ }
727
+ end
728
+
729
+ def run_tool(tool)
730
+ output = case tool
731
+ when "routes"
732
+ routes = Tina4::Router.routes.map { |r| { method: r.method, path: r.path } }
733
+ JSON.pretty_generate(routes)
734
+ when "test"
735
+ "Test runner not yet configured. Run: bundle exec rspec"
736
+ when "migrate"
737
+ "Migration runner not yet configured. Run: tina4ruby migrate"
738
+ when "seed"
739
+ "Seeder not yet configured. Run: tina4ruby seed"
740
+ else
741
+ "Unknown tool: #{tool}"
742
+ end
743
+ { tool: tool, output: output }
744
+ end
745
+
746
+ def run_query(sql)
747
+ sql = sql.to_s.strip
748
+ return { error: "No SQL provided" } if sql.empty?
749
+
750
+ db = Tina4.database
751
+ return { error: "No database configured" } unless db
752
+
753
+ # Split multiple statements on semicolons
754
+ statements = sql.split(";").map(&:strip).reject(&:empty?)
755
+
756
+ begin
757
+ if statements.size == 1
758
+ first_word = statements[0].split(/[\s\t\n\r]+/, 2).first.to_s.upcase
759
+ if %w[SELECT PRAGMA EXPLAIN SHOW DESCRIBE].include?(first_word)
760
+ result = db.fetch(statements[0])
761
+ rows = result.respond_to?(:to_a) ? result.to_a : (result.is_a?(Array) ? result : [])
762
+ columns = rows.first.is_a?(Hash) ? rows.first.keys.map(&:to_s) : []
763
+ return { columns: columns, rows: rows, count: rows.size }
764
+ end
765
+ end
766
+
767
+ # Execute all statements (single write or multi-statement batch)
768
+ total_affected = 0
769
+ statements.each do |stmt|
770
+ result = db.execute(stmt)
771
+ if result == false
772
+ return { error: db.get_error || "Statement failed: #{stmt}" }
773
+ end
774
+ total_affected += (result.respond_to?(:affected_rows) ? result.affected_rows : 0)
775
+ end
776
+
777
+ { affected: total_affected, success: true }
778
+ rescue => e
779
+ { error: e.message }
780
+ end
781
+ end
782
+
783
+ def tables_payload
784
+ db = Tina4.database
785
+ return { error: "No database configured", tables: [] } unless db
786
+
787
+ begin
788
+ table_list = db.tables
789
+ { tables: table_list }
790
+ rescue => e
791
+ { error: e.message, tables: [] }
792
+ end
793
+ end
794
+
795
+ def table_detail_payload(table_name)
796
+ return { error: "No table name provided" } if table_name.nil? || table_name.strip.empty?
797
+
798
+ db = Tina4.database
799
+ return { error: "No database configured" } unless db
800
+
801
+ begin
802
+ columns = db.columns(table_name)
803
+ result = db.fetch("SELECT * FROM #{table_name} LIMIT 20")
804
+ rows = result.respond_to?(:to_a) ? result.to_a : (result.is_a?(Array) ? result : [])
805
+ { table: table_name, columns: columns, rows: rows, count: rows.size }
806
+ rescue => e
807
+ { error: e.message }
808
+ end
809
+ end
810
+
811
+ def seed_table_data(table_name, count)
812
+ return { error: "No table name provided" } if table_name.nil? || table_name.strip.empty?
813
+
814
+ db = Tina4.database
815
+ return { error: "No database configured" } unless db
816
+
817
+ begin
818
+ columns = db.columns(table_name)
819
+ seeded = Tina4.seed_table(table_name, columns, count: count)
820
+ { table: table_name, seeded: seeded }
821
+ rescue => e
822
+ { error: e.message }
823
+ end
824
+ end
825
+
826
+ def handle_connections_get
827
+ env_path = File.join(Dir.pwd, ".env")
828
+ url = ""
829
+ username = ""
830
+ password = ""
831
+ if File.file?(env_path)
832
+ File.readlines(env_path).each do |line|
833
+ line = line.strip
834
+ next if line.empty? || line.start_with?("#") || !line.include?("=")
835
+ key, val = line.split("=", 2)
836
+ key = key.strip
837
+ val = (val || "").strip.gsub(/\A["']|["']\z/, "")
838
+ case key
839
+ when "DATABASE_URL" then url = val
840
+ when "DATABASE_USERNAME" then username = val
841
+ when "DATABASE_PASSWORD" then password = val.empty? ? "" : "***"
842
+ end
843
+ end
844
+ end
845
+ json_response({ url: url, username: username, password: password })
846
+ end
847
+
848
+ def handle_connections_test(body)
849
+ url = (body && body["url"]) || ""
850
+ username = (body && body["username"]) || ""
851
+ password = (body && body["password"]) || ""
852
+ return json_response({ success: false, error: "No connection URL provided" }) if url.empty?
853
+ begin
854
+ db = Tina4::Database.new(url, username: username, password: password)
855
+ version = "Connected"
856
+ table_count = 0
857
+ begin
858
+ tables = db.tables
859
+ table_count = tables.is_a?(Array) ? tables.size : 0
860
+ rescue => e
861
+ table_count = 0
862
+ end
863
+ begin
864
+ url_lower = url.downcase
865
+ if url_lower.include?("sqlite")
866
+ row = db.fetch_one("SELECT sqlite_version() as v")
867
+ version = "SQLite #{row && row[:v] || row && row['v']}" if row
868
+ elsif url_lower.include?("postgres")
869
+ row = db.fetch_one("SELECT version() as v")
870
+ version = (row && (row[:v] || row["v"]) || "PostgreSQL").to_s.split(",").first if row
871
+ elsif url_lower.include?("mysql")
872
+ row = db.fetch_one("SELECT version() as v")
873
+ version = "MySQL #{row && row[:v] || row && row['v']}" if row
874
+ elsif url_lower.include?("mssql") || url_lower.include?("sqlserver")
875
+ row = db.fetch_one("SELECT @@VERSION as v")
876
+ version = (row && (row[:v] || row["v"]) || "MSSQL").to_s.split("\n").first if row
877
+ elsif url_lower.include?("firebird")
878
+ row = db.fetch_one("SELECT rdb$get_context('SYSTEM', 'ENGINE_VERSION') as v FROM rdb$database")
879
+ version = "Firebird #{row && row[:v] || row && row['v']}" if row
880
+ end
881
+ rescue => e
882
+ # Keep version as "Connected"
883
+ end
884
+ db.close if db.respond_to?(:close)
885
+ json_response({ success: true, version: version, tables: table_count })
886
+ rescue => e
887
+ json_response({ success: false, error: e.message })
888
+ end
889
+ end
890
+
891
+ def handle_connections_save(body)
892
+ url = (body && body["url"]) || ""
893
+ username = (body && body["username"]) || ""
894
+ password = (body && body["password"]) || ""
895
+ return json_response({ success: false, error: "No connection URL provided" }) if url.empty?
896
+ begin
897
+ env_path = File.join(Dir.pwd, ".env")
898
+ lines = File.file?(env_path) ? File.readlines(env_path, chomp: true) : []
899
+ keys_found = { "DATABASE_URL" => false, "DATABASE_USERNAME" => false, "DATABASE_PASSWORD" => false }
900
+ new_lines = []
901
+ lines.each do |line|
902
+ stripped = line.strip
903
+ if stripped.empty? || stripped.start_with?("#") || !stripped.include?("=")
904
+ new_lines << line
905
+ next
906
+ end
907
+ key = stripped.split("=", 2).first.strip
908
+ case key
909
+ when "DATABASE_URL"
910
+ new_lines << "DATABASE_URL=#{url}"
911
+ keys_found["DATABASE_URL"] = true
912
+ when "DATABASE_USERNAME"
913
+ new_lines << "DATABASE_USERNAME=#{username}"
914
+ keys_found["DATABASE_USERNAME"] = true
915
+ when "DATABASE_PASSWORD"
916
+ new_lines << "DATABASE_PASSWORD=#{password}"
917
+ keys_found["DATABASE_PASSWORD"] = true
918
+ else
919
+ new_lines << line
920
+ end
921
+ end
922
+ values = { "DATABASE_URL" => url, "DATABASE_USERNAME" => username, "DATABASE_PASSWORD" => password }
923
+ keys_found.each do |key, found|
924
+ new_lines << "#{key}=#{values[key]}" unless found
925
+ end
926
+ File.write(env_path, new_lines.join("\n") + "\n")
927
+ json_response({ success: true })
928
+ rescue => e
929
+ json_response({ success: false, error: e.message })
930
+ end
931
+ end
932
+
933
+ def gallery_list
934
+ gallery_dir = File.join(File.dirname(__FILE__), "gallery")
935
+ items = []
936
+ if Dir.exist?(gallery_dir)
937
+ Dir.children(gallery_dir).sort.each do |entry|
938
+ entry_path = File.join(gallery_dir, entry)
939
+ meta_file = File.join(entry_path, "meta.json")
940
+ next unless File.directory?(entry_path) && File.file?(meta_file)
941
+
942
+ meta = JSON.parse(File.read(meta_file)) rescue next
943
+ meta["id"] = entry
944
+ src_dir = File.join(entry_path, "src")
945
+ if Dir.exist?(src_dir)
946
+ meta["files"] = Dir.glob(File.join(src_dir, "**", "*"))
947
+ .select { |f| File.file?(f) }
948
+ .map { |f| f.sub("#{src_dir}/", "") }
949
+ end
950
+ items << meta
951
+ end
952
+ end
953
+ { gallery: items, count: items.size }
954
+ end
955
+
956
+ def gallery_deploy(name)
957
+ return { error: "No gallery item specified" } if name.to_s.empty?
958
+
959
+ gallery_src = File.join(File.dirname(__FILE__), "gallery", name, "src")
960
+ return { error: "Gallery item '#{name}' not found" } unless Dir.exist?(gallery_src)
961
+
962
+ require "fileutils"
963
+ project_src = File.join(Tina4.root_dir || Dir.pwd, "src")
964
+ copied = []
965
+ Dir.glob(File.join(gallery_src, "**", "*")).each do |src_file|
966
+ next unless File.file?(src_file)
967
+
968
+ rel = src_file.sub("#{gallery_src}/", "")
969
+ dest = File.join(project_src, rel)
970
+ FileUtils.mkdir_p(File.dirname(dest))
971
+ FileUtils.cp(src_file, dest)
972
+ copied << rel
973
+ end
974
+
975
+ # Re-discover routes so new files are immediately available
976
+ begin
977
+ routes_dir = File.join(Tina4.root_dir || Dir.pwd, "src", "routes")
978
+ Tina4::Router.load_routes(routes_dir) if Dir.exist?(routes_dir)
979
+ rescue => e
980
+ Tina4::Log.warning("Gallery route reload: #{e.message}")
981
+ end
982
+
983
+ { deployed: name, files: copied }
984
+ end
985
+
986
+ # ── New dev-admin surface area (parity with Python/PHP) ────
987
+
988
+ def supervisor_base
989
+ base = ENV["TINA4_SUPERVISOR_URL"].to_s.strip
990
+ return base unless base.empty?
991
+ port = (ENV["TINA4_PORT"] || ENV["PORT"] || "7147").to_i + 2000
992
+ "http://127.0.0.1:#{port}"
993
+ end
994
+
995
+ def thoughts_payload
996
+ base = supervisor_base
997
+ begin
998
+ uri = URI.parse("#{base}/thoughts")
999
+ req = Net::HTTP::Get.new(uri)
1000
+ resp = Net::HTTP.start(uri.host, uri.port, open_timeout: 2, read_timeout: 5) { |h| h.request(req) }
1001
+ return JSON.parse(resp.body) if resp.is_a?(Net::HTTPSuccess)
1002
+ { thoughts: [], error: "Supervisor returned #{resp.code}" }
1003
+ rescue StandardError => e
1004
+ { thoughts: [], error: e.message }
1005
+ end
1006
+ end
1007
+
1008
+ def proxy_supervisor(path, method: "GET", body: nil, query: nil)
1009
+ base = supervisor_base
1010
+ url = "#{base}#{path}"
1011
+ url += "?#{query}" if query && !query.empty?
1012
+ begin
1013
+ uri = URI.parse(url)
1014
+ req = case method.upcase
1015
+ when "POST"
1016
+ r = Net::HTTP::Post.new(uri)
1017
+ r["Content-Type"] = "application/json"
1018
+ r.body = JSON.generate(body || {})
1019
+ r
1020
+ else
1021
+ Net::HTTP::Get.new(uri)
1022
+ end
1023
+ resp = Net::HTTP.start(uri.host, uri.port, open_timeout: 2, read_timeout: 30) { |h| h.request(req) }
1024
+ begin
1025
+ JSON.parse(resp.body)
1026
+ rescue JSON::ParserError
1027
+ { body: resp.body, status: resp.code.to_i }
1028
+ end
1029
+ rescue StandardError => e
1030
+ { error: e.message, supervisor: base }
1031
+ end
1032
+ end
1033
+
1034
+ def execute_proxy(body)
1035
+ # Proxy POST /execute to the supervisor at framework_port + 2000.
1036
+ # Pass through the response stream as-is (SSE or JSON).
1037
+ base = supervisor_base
1038
+ begin
1039
+ uri = URI.parse("#{base}/execute")
1040
+ req = Net::HTTP::Post.new(uri)
1041
+ req["Content-Type"] = "application/json"
1042
+ req["Accept"] = "text/event-stream"
1043
+ req.body = JSON.generate(body || {})
1044
+ http = Net::HTTP.new(uri.host, uri.port)
1045
+ http.open_timeout = 2
1046
+ http.read_timeout = 300
1047
+ resp = http.request(req)
1048
+ ct = resp["content-type"] || "application/json; charset=utf-8"
1049
+ [resp.code.to_i, { "content-type" => ct }, [resp.body.to_s]]
1050
+ rescue StandardError => e
1051
+ body_str = JSON.generate({ error: e.message, supervisor: base })
1052
+ [502, { "content-type" => "application/json; charset=utf-8" }, [body_str]]
1053
+ end
1054
+ end
1055
+
1056
+ def safe_project_path(rel_path)
1057
+ root = File.expand_path(Dir.pwd)
1058
+ resolved = File.expand_path(rel_path.to_s, root)
1059
+ raise ArgumentError, "path escapes project directory" unless resolved.start_with?(root)
1060
+ resolved
1061
+ end
1062
+
1063
+ def files_list(env)
1064
+ rel = query_param(env, "path") || "."
1065
+ begin
1066
+ target = safe_project_path(rel)
1067
+ return { error: "Not found" } unless File.exist?(target)
1068
+ return { error: "Not a directory" } unless File.directory?(target)
1069
+ entries = Dir.children(target).sort.map do |name|
1070
+ full = File.join(target, name)
1071
+ {
1072
+ name: name,
1073
+ type: File.directory?(full) ? "dir" : "file",
1074
+ size: File.file?(full) ? File.size(full) : 0
1075
+ }
1076
+ end
1077
+ { path: rel, entries: entries, count: entries.size }
1078
+ rescue => e
1079
+ { error: e.message }
1080
+ end
1081
+ end
1082
+
1083
+ def file_read_payload(rel)
1084
+ return { error: "path required" } if rel.nil? || rel.empty?
1085
+ begin
1086
+ target = safe_project_path(rel)
1087
+ return { error: "Not found" } unless File.exist?(target)
1088
+ return { error: "Not a file" } unless File.file?(target)
1089
+ content = File.read(target, encoding: "utf-8", invalid: :replace, undef: :replace)
1090
+ { path: rel, content: content, bytes: File.size(target) }
1091
+ rescue => e
1092
+ { error: e.message }
1093
+ end
1094
+ end
1095
+
1096
+ def file_raw_response(rel)
1097
+ return json_response({ error: "path required" }) if rel.nil? || rel.empty?
1098
+ begin
1099
+ target = safe_project_path(rel)
1100
+ return json_response({ error: "Not found" }) unless File.file?(target)
1101
+ content = File.binread(target)
1102
+ ct = case File.extname(target).downcase
1103
+ when ".css" then "text/css"
1104
+ when ".js" then "application/javascript"
1105
+ when ".json" then "application/json"
1106
+ when ".html", ".htm" then "text/html"
1107
+ when ".png" then "image/png"
1108
+ when ".jpg", ".jpeg" then "image/jpeg"
1109
+ when ".gif" then "image/gif"
1110
+ when ".svg" then "image/svg+xml"
1111
+ else "text/plain; charset=utf-8"
1112
+ end
1113
+ [200, { "content-type" => ct }, [content]]
1114
+ rescue => e
1115
+ json_response({ error: e.message })
1116
+ end
1117
+ end
1118
+
1119
+ def file_save(body)
1120
+ rel = body["path"].to_s
1121
+ content = body["content"].to_s
1122
+ return { error: "path required" } if rel.empty?
1123
+ begin
1124
+ target = safe_project_path(rel)
1125
+ existed = File.exist?(target)
1126
+ FileUtils.mkdir_p(File.dirname(target))
1127
+ File.write(target, content, encoding: "utf-8")
1128
+ Tina4::Plan.record_action(existed ? "patched" : "created", rel) if defined?(Tina4::Plan)
1129
+ { saved: rel, bytes: content.bytesize }
1130
+ rescue => e
1131
+ { error: e.message }
1132
+ end
1133
+ end
1134
+
1135
+ def file_rename(body)
1136
+ from = body["from"].to_s
1137
+ to = body["to"].to_s
1138
+ return { error: "from/to required" } if from.empty? || to.empty?
1139
+ begin
1140
+ src = safe_project_path(from)
1141
+ dst = safe_project_path(to)
1142
+ return { error: "Source not found" } unless File.exist?(src)
1143
+ FileUtils.mkdir_p(File.dirname(dst))
1144
+ File.rename(src, dst)
1145
+ { renamed: { from: from, to: to } }
1146
+ rescue => e
1147
+ { error: e.message }
1148
+ end
1149
+ end
1150
+
1151
+ def file_delete(body)
1152
+ rel = body["path"].to_s
1153
+ return { error: "path required" } if rel.empty?
1154
+ begin
1155
+ target = safe_project_path(rel)
1156
+ return { error: "Not found" } unless File.exist?(target)
1157
+ if File.directory?(target)
1158
+ FileUtils.rm_rf(target)
1159
+ else
1160
+ File.delete(target)
1161
+ end
1162
+ { deleted: rel }
1163
+ rescue => e
1164
+ { error: e.message }
1165
+ end
1166
+ end
1167
+
1168
+ def deps_search(query)
1169
+ return { results: [], count: 0, error: "query required" } if query.to_s.strip.empty?
1170
+ begin
1171
+ uri = URI.parse("https://rubygems.org/api/v1/search.json?query=#{URI.encode_www_form_component(query)}")
1172
+ http = Net::HTTP.new(uri.host, uri.port)
1173
+ http.use_ssl = true
1174
+ http.open_timeout = 5
1175
+ http.read_timeout = 8
1176
+ resp = http.request(Net::HTTP::Get.new(uri))
1177
+ if resp.is_a?(Net::HTTPSuccess)
1178
+ gems = JSON.parse(resp.body)
1179
+ results = gems.first(20).map do |g|
1180
+ { name: g["name"], version: g["version"], info: g["info"].to_s[0, 200] }
1181
+ end
1182
+ { results: results, count: results.size }
1183
+ else
1184
+ { results: [], count: 0, error: "rubygems returned #{resp.code}" }
1185
+ end
1186
+ rescue => e
1187
+ { results: [], count: 0, error: e.message }
1188
+ end
1189
+ end
1190
+
1191
+ def deps_install(body)
1192
+ name = body["name"].to_s.strip
1193
+ return { ok: false, error: "name required" } if name.empty?
1194
+ # Append to Gemfile if not present — do NOT actually bundle install.
1195
+ gemfile = File.join(Dir.pwd, "Gemfile")
1196
+ return { ok: false, error: "No Gemfile at project root" } unless File.exist?(gemfile)
1197
+ content = File.read(gemfile)
1198
+ if content.include?("gem \"#{name}\"") || content.include?("gem '#{name}'")
1199
+ return { ok: true, gem: name, note: "already in Gemfile" }
1200
+ end
1201
+ File.open(gemfile, "a") { |f| f.write("\ngem \"#{name}\"\n") }
1202
+ { ok: true, gem: name, note: "added to Gemfile; run `bundle install`" }
1203
+ end
1204
+
1205
+ def git_status_payload
1206
+ begin
1207
+ inside = `cd #{Shellwords.escape(Dir.pwd)} && git rev-parse --is-inside-work-tree 2>/dev/null`.strip
1208
+ return { error: "Not a git repository" } if inside != "true"
1209
+ branch = `cd #{Shellwords.escape(Dir.pwd)} && git branch --show-current 2>/dev/null`.strip
1210
+ status = `cd #{Shellwords.escape(Dir.pwd)} && git status --porcelain 2>/dev/null`.strip.split("\n").reject(&:empty?)
1211
+ recent = `cd #{Shellwords.escape(Dir.pwd)} && git log --oneline -5 2>/dev/null`.strip.split("\n").reject(&:empty?)
1212
+ { branch: branch, status: status, recent_commits: recent }
1213
+ rescue => e
1214
+ { error: "git unavailable: #{e.message}" }
1215
+ end
1216
+ end
1217
+
1218
+ def mcp_tools_list
1219
+ return { tools: [], count: 0 } unless defined?(Tina4::McpServer)
1220
+ server = Tina4._default_mcp_server
1221
+ list = server.tools.values.map do |t|
1222
+ { name: t["name"], description: t["description"], schema: t["inputSchema"] }
1223
+ end
1224
+ { tools: list, count: list.size }
1225
+ end
1226
+
1227
+ def mcp_tool_call(body)
1228
+ tool_name = body["name"].to_s
1229
+ args = body["arguments"] || {}
1230
+ return { error: "tool name required" } if tool_name.empty?
1231
+ return { error: "MCP not loaded" } unless defined?(Tina4::McpServer)
1232
+ server = Tina4._default_mcp_server
1233
+ payload = JSON.generate({
1234
+ "jsonrpc" => "2.0",
1235
+ "id" => 1,
1236
+ "method" => "tools/call",
1237
+ "params" => { "name" => tool_name, "arguments" => args }
1238
+ })
1239
+ raw = server.handle_message(payload)
1240
+ return {} if raw.nil? || raw.empty?
1241
+ JSON.parse(raw)
1242
+ end
1243
+
1244
+ def scaffold_templates
1245
+ # Expose built-in scaffold targets for the dev-admin UI.
1246
+ { templates: [
1247
+ { id: "route", label: "Route file", target: "src/routes" },
1248
+ { id: "model", label: "ORM model", target: "src/orm" },
1249
+ { id: "migration", label: "SQL migration", target: "migrations" },
1250
+ { id: "middleware", label: "Middleware class", target: "src/app" }
1251
+ ] }
1252
+ end
1253
+
1254
+ def scaffold_run(body)
1255
+ kind = body["kind"].to_s
1256
+ name = body["name"].to_s.strip
1257
+ return { ok: false, error: "kind + name required" } if kind.empty? || name.empty?
1258
+ project = Dir.pwd
1259
+ case kind
1260
+ when "route"
1261
+ target = File.join(project, "src", "routes", "#{name}.rb")
1262
+ FileUtils.mkdir_p(File.dirname(target))
1263
+ File.write(target, "# #{name} routes\nTina4::Router.get(\"/api/#{name}\") do |req, res|\n res.call({ hello: \"#{name}\" })\nend\n") unless File.exist?(target)
1264
+ { ok: true, created: target.sub("#{project}/", "") }
1265
+ when "model"
1266
+ target = File.join(project, "src", "orm", "#{name}.rb")
1267
+ FileUtils.mkdir_p(File.dirname(target))
1268
+ cls = name.to_s.split(/[_-]/).map(&:capitalize).join
1269
+ 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)
1270
+ { ok: true, created: target.sub("#{project}/", "") }
1271
+ when "migration"
1272
+ ts = Time.now.strftime("%Y%m%d%H%M%S")
1273
+ target = File.join(project, "migrations", "#{ts}_#{name}.sql")
1274
+ FileUtils.mkdir_p(File.dirname(target))
1275
+ File.write(target, "-- migration: #{name}\n")
1276
+ { ok: true, created: target.sub("#{project}/", "") }
1277
+ when "middleware"
1278
+ target = File.join(project, "src", "app", "#{name}.rb")
1279
+ FileUtils.mkdir_p(File.dirname(target))
1280
+ cls = name.to_s.split(/[_-]/).map(&:capitalize).join
1281
+ File.write(target, "class #{cls}\n def self.before_check(req, res); [req, res]; end\nend\n") unless File.exist?(target)
1282
+ { ok: true, created: target.sub("#{project}/", "") }
1283
+ else
1284
+ { ok: false, error: "unknown kind: #{kind}" }
1285
+ end
1286
+ end
1287
+ end
1288
+ end
1289
+ end