tina4ruby 3.11.19 → 3.11.35
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/lib/tina4/dev_admin.rb +1 -463
- data/lib/tina4/drivers/firebird_driver.rb +71 -13
- data/lib/tina4/frond.rb +0 -62
- data/lib/tina4/mcp.rb +0 -190
- data/lib/tina4/orm.rb +66 -220
- data/lib/tina4/public/js/tina4-dev-admin.js +238 -1086
- data/lib/tina4/public/js/tina4-dev-admin.min.js +209 -1142
- data/lib/tina4/rack_app.rb +1 -46
- data/lib/tina4/response.rb +0 -3
- data/lib/tina4/shutdown.rb +0 -10
- data/lib/tina4/version.rb +1 -1
- data/lib/tina4.rb +0 -14
- metadata +2 -6
- data/lib/tina4/background.rb +0 -81
- data/lib/tina4/docs.rb +0 -636
- data/lib/tina4/plan.rb +0 -471
- data/lib/tina4/project_index.rb +0 -366
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7dfb1cf2a3f34468c680f80f120b935a59348b7bab428fa5ba8c738a2a9ddf85
|
|
4
|
+
data.tar.gz: 7ceb6e23bc01350d5de21c77fd4d7902a2a314933a5290a6db3dac272060865a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: f27c8ed10bc2c080ed765060989dafdc1c05c04bb255ac7a093cda62b692965e940b81d46f9b8f170ef0e3afd9cee44ee63d82eadc09410c34ef29338f1192ec
|
|
7
|
+
data.tar.gz: 694ef83820ce2021da6ae51013259590fd70374ac2170831d039af3cb9dfa3a30ab1bf7ff691c4c8bcdaadefdf7e5ca9901c30a8a537b69ade99c8aaa8cb1c12
|
data/lib/tina4/dev_admin.rb
CHANGED
|
@@ -5,8 +5,6 @@ require "digest"
|
|
|
5
5
|
require "tmpdir"
|
|
6
6
|
require "net/http"
|
|
7
7
|
require "uri"
|
|
8
|
-
require "fileutils"
|
|
9
|
-
require "shellwords"
|
|
10
8
|
require_relative "metrics"
|
|
11
9
|
|
|
12
10
|
module Tina4
|
|
@@ -316,55 +314,9 @@ module Tina4
|
|
|
316
314
|
Tina4::Env.is_truthy(ENV["TINA4_DEBUG"])
|
|
317
315
|
end
|
|
318
316
|
|
|
319
|
-
# Write `.tina4/mcp.json` so MCP-aware tools (Claude Code, Cursor) can
|
|
320
|
-
# auto-discover this project's live docs server. Idempotent — no-op if
|
|
321
|
-
# the file already matches the desired contents. Also appends `.tina4/`
|
|
322
|
-
# to the project's `.gitignore` if a git repo is present.
|
|
323
|
-
def auto_discover_mcp!(project_root: Dir.pwd)
|
|
324
|
-
return if @_mcp_auto_discovered
|
|
325
|
-
@_mcp_auto_discovered = true
|
|
326
|
-
return unless enabled?
|
|
327
|
-
|
|
328
|
-
port = ENV["TINA4_PORT"] || ENV["PORT"] || "7147"
|
|
329
|
-
url = "http://localhost:#{port}/__dev/api/mcp"
|
|
330
|
-
target_dir = File.join(project_root, ".tina4")
|
|
331
|
-
target = File.join(target_dir, "mcp.json")
|
|
332
|
-
payload = {
|
|
333
|
-
"mcpServers" => {
|
|
334
|
-
"tina4-live-docs" => {
|
|
335
|
-
"url" => url,
|
|
336
|
-
"description" => "Live API docs for this Tina4 project (framework + user code)",
|
|
337
|
-
},
|
|
338
|
-
},
|
|
339
|
-
}
|
|
340
|
-
begin
|
|
341
|
-
FileUtils.mkdir_p(target_dir)
|
|
342
|
-
existing = if File.file?(target)
|
|
343
|
-
(JSON.parse(File.read(target)) rescue {})
|
|
344
|
-
else
|
|
345
|
-
{}
|
|
346
|
-
end
|
|
347
|
-
if existing != payload
|
|
348
|
-
File.write(target, JSON.pretty_generate(payload))
|
|
349
|
-
end
|
|
350
|
-
|
|
351
|
-
gitignore = File.join(project_root, ".gitignore")
|
|
352
|
-
if File.directory?(File.join(project_root, ".git"))
|
|
353
|
-
current = File.file?(gitignore) ? File.read(gitignore) : ""
|
|
354
|
-
unless current.lines.map(&:strip).include?(".tina4/") || current.lines.map(&:strip).include?(".tina4")
|
|
355
|
-
prefix = (!current.empty? && !current.end_with?("\n")) ? "\n" : ""
|
|
356
|
-
File.write(gitignore, "#{current}#{prefix}.tina4/\n")
|
|
357
|
-
end
|
|
358
|
-
end
|
|
359
|
-
rescue StandardError => e
|
|
360
|
-
Tina4::Log.warning("auto_discover_mcp! failed: #{e.message}") if defined?(Tina4::Log)
|
|
361
|
-
end
|
|
362
|
-
end
|
|
363
|
-
|
|
364
317
|
# Handle a /__dev request; returns [status, headers, body] or nil if not a dev path
|
|
365
318
|
def handle_request(env)
|
|
366
319
|
return nil unless enabled?
|
|
367
|
-
auto_discover_mcp!
|
|
368
320
|
|
|
369
321
|
path = env["PATH_INFO"] || "/"
|
|
370
322
|
method = env["REQUEST_METHOD"]
|
|
@@ -453,9 +405,7 @@ module Tina4
|
|
|
453
405
|
resolved = id ? error_tracker.resolve(id) : false
|
|
454
406
|
json_response({ resolved: resolved, id: id })
|
|
455
407
|
when ["POST", "/__dev/api/broken/clear"]
|
|
456
|
-
|
|
457
|
-
# ones individually marked resolved. Matches PHP/Python.
|
|
458
|
-
error_tracker.clear_all
|
|
408
|
+
error_tracker.clear_resolved
|
|
459
409
|
json_response({ cleared: true })
|
|
460
410
|
when ["GET", "/__dev/api/websockets"]
|
|
461
411
|
json_response({ connections: [], count: 0 })
|
|
@@ -544,66 +494,6 @@ module Tina4
|
|
|
544
494
|
when ["GET", "/__dev/api/metrics/file"]
|
|
545
495
|
file_path = (query_param(env, "path") || "").to_s
|
|
546
496
|
json_response(Tina4::Metrics.file_detail(file_path))
|
|
547
|
-
when ["GET", "/__dev/api/thoughts"]
|
|
548
|
-
json_response(thoughts_payload)
|
|
549
|
-
when ["POST", "/__dev/api/supervise/create"]
|
|
550
|
-
body = read_json_body(env) || {}
|
|
551
|
-
json_response(proxy_supervisor("/supervise/create", method: "POST", body: body))
|
|
552
|
-
when ["GET", "/__dev/api/supervise/sessions"]
|
|
553
|
-
json_response(proxy_supervisor("/supervise/sessions", method: "GET", query: env["QUERY_STRING"]))
|
|
554
|
-
when ["GET", "/__dev/api/supervise/diff"]
|
|
555
|
-
json_response(proxy_supervisor("/supervise/diff", method: "GET", query: env["QUERY_STRING"]))
|
|
556
|
-
when ["POST", "/__dev/api/supervise/commit"]
|
|
557
|
-
body = read_json_body(env) || {}
|
|
558
|
-
json_response(proxy_supervisor("/supervise/commit", method: "POST", body: body))
|
|
559
|
-
when ["POST", "/__dev/api/supervise/cancel"]
|
|
560
|
-
body = read_json_body(env) || {}
|
|
561
|
-
json_response(proxy_supervisor("/supervise/cancel", method: "POST", body: body))
|
|
562
|
-
when ["POST", "/__dev/api/execute"]
|
|
563
|
-
body = read_json_body(env) || {}
|
|
564
|
-
execute_proxy(body)
|
|
565
|
-
when ["GET", "/__dev/api/files"]
|
|
566
|
-
json_response(files_list(env))
|
|
567
|
-
when ["GET", "/__dev/api/file"]
|
|
568
|
-
json_response(file_read_payload(query_param(env, "path")))
|
|
569
|
-
when ["GET", "/__dev/api/file/raw"]
|
|
570
|
-
file_raw_response(query_param(env, "path"))
|
|
571
|
-
when ["POST", "/__dev/api/file/save"]
|
|
572
|
-
body = read_json_body(env) || {}
|
|
573
|
-
json_response(file_save(body))
|
|
574
|
-
when ["POST", "/__dev/api/file/rename"]
|
|
575
|
-
body = read_json_body(env) || {}
|
|
576
|
-
json_response(file_rename(body))
|
|
577
|
-
when ["POST", "/__dev/api/file/delete"]
|
|
578
|
-
body = read_json_body(env) || {}
|
|
579
|
-
json_response(file_delete(body))
|
|
580
|
-
when ["GET", "/__dev/api/deps/search"]
|
|
581
|
-
json_response(deps_search(query_param(env, "q") || query_param(env, "query") || ""))
|
|
582
|
-
when ["POST", "/__dev/api/deps/install"]
|
|
583
|
-
body = read_json_body(env) || {}
|
|
584
|
-
json_response(deps_install(body))
|
|
585
|
-
when ["GET", "/__dev/api/git/status"]
|
|
586
|
-
json_response(git_status_payload)
|
|
587
|
-
when ["GET", "/__dev/api/mcp/tools"]
|
|
588
|
-
json_response(mcp_tools_list)
|
|
589
|
-
when ["POST", "/__dev/api/mcp/call"]
|
|
590
|
-
body = read_json_body(env) || {}
|
|
591
|
-
json_response(mcp_tool_call(body))
|
|
592
|
-
when ["GET", "/__dev/api/scaffold"]
|
|
593
|
-
json_response(scaffold_templates)
|
|
594
|
-
when ["POST", "/__dev/api/scaffold/run"]
|
|
595
|
-
body = read_json_body(env) || {}
|
|
596
|
-
json_response(scaffold_run(body))
|
|
597
|
-
when ["GET", "/__dev/api/docs/search"]
|
|
598
|
-
json_response(docs_search_payload(env))
|
|
599
|
-
when ["GET", "/__dev/api/docs/class"]
|
|
600
|
-
json_response(docs_class_payload(query_param(env, "name")))
|
|
601
|
-
when ["GET", "/__dev/api/docs/method"]
|
|
602
|
-
json_response(docs_method_payload(query_param(env, "class"), query_param(env, "name")))
|
|
603
|
-
when ["GET", "/__dev/api/docs/index"]
|
|
604
|
-
json_response(docs_index_payload(query_param(env, "source")))
|
|
605
|
-
when ["GET", "/__dev/api/docs/.well-known.json"]
|
|
606
|
-
json_response(docs_well_known_payload)
|
|
607
497
|
when ["GET", "/__dev/api/graphql/schema"]
|
|
608
498
|
begin
|
|
609
499
|
gql = Tina4::GraphQL.new
|
|
@@ -1040,358 +930,6 @@ module Tina4
|
|
|
1040
930
|
|
|
1041
931
|
{ deployed: name, files: copied }
|
|
1042
932
|
end
|
|
1043
|
-
|
|
1044
|
-
# ── New dev-admin surface area (parity with Python/PHP) ────
|
|
1045
|
-
|
|
1046
|
-
def supervisor_base
|
|
1047
|
-
base = ENV["TINA4_SUPERVISOR_URL"].to_s.strip
|
|
1048
|
-
return base unless base.empty?
|
|
1049
|
-
port = (ENV["TINA4_PORT"] || ENV["PORT"] || "7147").to_i + 2000
|
|
1050
|
-
"http://127.0.0.1:#{port}"
|
|
1051
|
-
end
|
|
1052
|
-
|
|
1053
|
-
def thoughts_payload
|
|
1054
|
-
base = supervisor_base
|
|
1055
|
-
begin
|
|
1056
|
-
uri = URI.parse("#{base}/thoughts")
|
|
1057
|
-
req = Net::HTTP::Get.new(uri)
|
|
1058
|
-
resp = Net::HTTP.start(uri.host, uri.port, open_timeout: 2, read_timeout: 5) { |h| h.request(req) }
|
|
1059
|
-
return JSON.parse(resp.body) if resp.is_a?(Net::HTTPSuccess)
|
|
1060
|
-
{ thoughts: [], error: "Supervisor returned #{resp.code}" }
|
|
1061
|
-
rescue StandardError => e
|
|
1062
|
-
{ thoughts: [], error: e.message }
|
|
1063
|
-
end
|
|
1064
|
-
end
|
|
1065
|
-
|
|
1066
|
-
def proxy_supervisor(path, method: "GET", body: nil, query: nil)
|
|
1067
|
-
base = supervisor_base
|
|
1068
|
-
url = "#{base}#{path}"
|
|
1069
|
-
url += "?#{query}" if query && !query.empty?
|
|
1070
|
-
begin
|
|
1071
|
-
uri = URI.parse(url)
|
|
1072
|
-
req = case method.upcase
|
|
1073
|
-
when "POST"
|
|
1074
|
-
r = Net::HTTP::Post.new(uri)
|
|
1075
|
-
r["Content-Type"] = "application/json"
|
|
1076
|
-
r.body = JSON.generate(body || {})
|
|
1077
|
-
r
|
|
1078
|
-
else
|
|
1079
|
-
Net::HTTP::Get.new(uri)
|
|
1080
|
-
end
|
|
1081
|
-
resp = Net::HTTP.start(uri.host, uri.port, open_timeout: 2, read_timeout: 30) { |h| h.request(req) }
|
|
1082
|
-
begin
|
|
1083
|
-
JSON.parse(resp.body)
|
|
1084
|
-
rescue JSON::ParserError
|
|
1085
|
-
{ body: resp.body, status: resp.code.to_i }
|
|
1086
|
-
end
|
|
1087
|
-
rescue StandardError => e
|
|
1088
|
-
{ error: e.message, supervisor: base }
|
|
1089
|
-
end
|
|
1090
|
-
end
|
|
1091
|
-
|
|
1092
|
-
def execute_proxy(body)
|
|
1093
|
-
# Proxy POST /execute to the supervisor at framework_port + 2000.
|
|
1094
|
-
# Pass through the response stream as-is (SSE or JSON).
|
|
1095
|
-
base = supervisor_base
|
|
1096
|
-
begin
|
|
1097
|
-
uri = URI.parse("#{base}/execute")
|
|
1098
|
-
req = Net::HTTP::Post.new(uri)
|
|
1099
|
-
req["Content-Type"] = "application/json"
|
|
1100
|
-
req["Accept"] = "text/event-stream"
|
|
1101
|
-
req.body = JSON.generate(body || {})
|
|
1102
|
-
http = Net::HTTP.new(uri.host, uri.port)
|
|
1103
|
-
http.open_timeout = 2
|
|
1104
|
-
http.read_timeout = 300
|
|
1105
|
-
resp = http.request(req)
|
|
1106
|
-
ct = resp["content-type"] || "application/json; charset=utf-8"
|
|
1107
|
-
[resp.code.to_i, { "content-type" => ct }, [resp.body.to_s]]
|
|
1108
|
-
rescue StandardError => e
|
|
1109
|
-
body_str = JSON.generate({ error: e.message, supervisor: base })
|
|
1110
|
-
[502, { "content-type" => "application/json; charset=utf-8" }, [body_str]]
|
|
1111
|
-
end
|
|
1112
|
-
end
|
|
1113
|
-
|
|
1114
|
-
def safe_project_path(rel_path)
|
|
1115
|
-
root = File.expand_path(Dir.pwd)
|
|
1116
|
-
resolved = File.expand_path(rel_path.to_s, root)
|
|
1117
|
-
raise ArgumentError, "path escapes project directory" unless resolved.start_with?(root)
|
|
1118
|
-
resolved
|
|
1119
|
-
end
|
|
1120
|
-
|
|
1121
|
-
def files_list(env)
|
|
1122
|
-
rel = query_param(env, "path") || "."
|
|
1123
|
-
begin
|
|
1124
|
-
target = safe_project_path(rel)
|
|
1125
|
-
return { error: "Not found" } unless File.exist?(target)
|
|
1126
|
-
return { error: "Not a directory" } unless File.directory?(target)
|
|
1127
|
-
entries = Dir.children(target).sort.map do |name|
|
|
1128
|
-
full = File.join(target, name)
|
|
1129
|
-
{
|
|
1130
|
-
name: name,
|
|
1131
|
-
type: File.directory?(full) ? "dir" : "file",
|
|
1132
|
-
size: File.file?(full) ? File.size(full) : 0
|
|
1133
|
-
}
|
|
1134
|
-
end
|
|
1135
|
-
{ path: rel, entries: entries, count: entries.size }
|
|
1136
|
-
rescue => e
|
|
1137
|
-
{ error: e.message }
|
|
1138
|
-
end
|
|
1139
|
-
end
|
|
1140
|
-
|
|
1141
|
-
def file_read_payload(rel)
|
|
1142
|
-
return { error: "path required" } if rel.nil? || rel.empty?
|
|
1143
|
-
begin
|
|
1144
|
-
target = safe_project_path(rel)
|
|
1145
|
-
return { error: "Not found" } unless File.exist?(target)
|
|
1146
|
-
return { error: "Not a file" } unless File.file?(target)
|
|
1147
|
-
content = File.read(target, encoding: "utf-8", invalid: :replace, undef: :replace)
|
|
1148
|
-
{ path: rel, content: content, bytes: File.size(target) }
|
|
1149
|
-
rescue => e
|
|
1150
|
-
{ error: e.message }
|
|
1151
|
-
end
|
|
1152
|
-
end
|
|
1153
|
-
|
|
1154
|
-
def file_raw_response(rel)
|
|
1155
|
-
return json_response({ error: "path required" }) if rel.nil? || rel.empty?
|
|
1156
|
-
begin
|
|
1157
|
-
target = safe_project_path(rel)
|
|
1158
|
-
return json_response({ error: "Not found" }) unless File.file?(target)
|
|
1159
|
-
content = File.binread(target)
|
|
1160
|
-
ct = case File.extname(target).downcase
|
|
1161
|
-
when ".css" then "text/css"
|
|
1162
|
-
when ".js" then "application/javascript"
|
|
1163
|
-
when ".json" then "application/json"
|
|
1164
|
-
when ".html", ".htm" then "text/html"
|
|
1165
|
-
when ".png" then "image/png"
|
|
1166
|
-
when ".jpg", ".jpeg" then "image/jpeg"
|
|
1167
|
-
when ".gif" then "image/gif"
|
|
1168
|
-
when ".svg" then "image/svg+xml"
|
|
1169
|
-
else "text/plain; charset=utf-8"
|
|
1170
|
-
end
|
|
1171
|
-
[200, { "content-type" => ct }, [content]]
|
|
1172
|
-
rescue => e
|
|
1173
|
-
json_response({ error: e.message })
|
|
1174
|
-
end
|
|
1175
|
-
end
|
|
1176
|
-
|
|
1177
|
-
def file_save(body)
|
|
1178
|
-
rel = body["path"].to_s
|
|
1179
|
-
content = body["content"].to_s
|
|
1180
|
-
return { error: "path required" } if rel.empty?
|
|
1181
|
-
begin
|
|
1182
|
-
target = safe_project_path(rel)
|
|
1183
|
-
existed = File.exist?(target)
|
|
1184
|
-
FileUtils.mkdir_p(File.dirname(target))
|
|
1185
|
-
File.write(target, content, encoding: "utf-8")
|
|
1186
|
-
Tina4::Plan.record_action(existed ? "patched" : "created", rel) if defined?(Tina4::Plan)
|
|
1187
|
-
{ saved: rel, bytes: content.bytesize }
|
|
1188
|
-
rescue => e
|
|
1189
|
-
{ error: e.message }
|
|
1190
|
-
end
|
|
1191
|
-
end
|
|
1192
|
-
|
|
1193
|
-
def file_rename(body)
|
|
1194
|
-
from = body["from"].to_s
|
|
1195
|
-
to = body["to"].to_s
|
|
1196
|
-
return { error: "from/to required" } if from.empty? || to.empty?
|
|
1197
|
-
begin
|
|
1198
|
-
src = safe_project_path(from)
|
|
1199
|
-
dst = safe_project_path(to)
|
|
1200
|
-
return { error: "Source not found" } unless File.exist?(src)
|
|
1201
|
-
FileUtils.mkdir_p(File.dirname(dst))
|
|
1202
|
-
File.rename(src, dst)
|
|
1203
|
-
{ renamed: { from: from, to: to } }
|
|
1204
|
-
rescue => e
|
|
1205
|
-
{ error: e.message }
|
|
1206
|
-
end
|
|
1207
|
-
end
|
|
1208
|
-
|
|
1209
|
-
def file_delete(body)
|
|
1210
|
-
rel = body["path"].to_s
|
|
1211
|
-
return { error: "path required" } if rel.empty?
|
|
1212
|
-
begin
|
|
1213
|
-
target = safe_project_path(rel)
|
|
1214
|
-
return { error: "Not found" } unless File.exist?(target)
|
|
1215
|
-
if File.directory?(target)
|
|
1216
|
-
FileUtils.rm_rf(target)
|
|
1217
|
-
else
|
|
1218
|
-
File.delete(target)
|
|
1219
|
-
end
|
|
1220
|
-
{ deleted: rel }
|
|
1221
|
-
rescue => e
|
|
1222
|
-
{ error: e.message }
|
|
1223
|
-
end
|
|
1224
|
-
end
|
|
1225
|
-
|
|
1226
|
-
def deps_search(query)
|
|
1227
|
-
return { results: [], count: 0, error: "query required" } if query.to_s.strip.empty?
|
|
1228
|
-
begin
|
|
1229
|
-
uri = URI.parse("https://rubygems.org/api/v1/search.json?query=#{URI.encode_www_form_component(query)}")
|
|
1230
|
-
http = Net::HTTP.new(uri.host, uri.port)
|
|
1231
|
-
http.use_ssl = true
|
|
1232
|
-
http.open_timeout = 5
|
|
1233
|
-
http.read_timeout = 8
|
|
1234
|
-
resp = http.request(Net::HTTP::Get.new(uri))
|
|
1235
|
-
if resp.is_a?(Net::HTTPSuccess)
|
|
1236
|
-
gems = JSON.parse(resp.body)
|
|
1237
|
-
results = gems.first(20).map do |g|
|
|
1238
|
-
{ name: g["name"], version: g["version"], info: g["info"].to_s[0, 200] }
|
|
1239
|
-
end
|
|
1240
|
-
{ results: results, count: results.size }
|
|
1241
|
-
else
|
|
1242
|
-
{ results: [], count: 0, error: "rubygems returned #{resp.code}" }
|
|
1243
|
-
end
|
|
1244
|
-
rescue => e
|
|
1245
|
-
{ results: [], count: 0, error: e.message }
|
|
1246
|
-
end
|
|
1247
|
-
end
|
|
1248
|
-
|
|
1249
|
-
def deps_install(body)
|
|
1250
|
-
name = body["name"].to_s.strip
|
|
1251
|
-
return { ok: false, error: "name required" } if name.empty?
|
|
1252
|
-
# Append to Gemfile if not present — do NOT actually bundle install.
|
|
1253
|
-
gemfile = File.join(Dir.pwd, "Gemfile")
|
|
1254
|
-
return { ok: false, error: "No Gemfile at project root" } unless File.exist?(gemfile)
|
|
1255
|
-
content = File.read(gemfile)
|
|
1256
|
-
if content.include?("gem \"#{name}\"") || content.include?("gem '#{name}'")
|
|
1257
|
-
return { ok: true, gem: name, note: "already in Gemfile" }
|
|
1258
|
-
end
|
|
1259
|
-
File.open(gemfile, "a") { |f| f.write("\ngem \"#{name}\"\n") }
|
|
1260
|
-
{ ok: true, gem: name, note: "added to Gemfile; run `bundle install`" }
|
|
1261
|
-
end
|
|
1262
|
-
|
|
1263
|
-
def git_status_payload
|
|
1264
|
-
begin
|
|
1265
|
-
inside = `cd #{Shellwords.escape(Dir.pwd)} && git rev-parse --is-inside-work-tree 2>/dev/null`.strip
|
|
1266
|
-
return { error: "Not a git repository" } if inside != "true"
|
|
1267
|
-
branch = `cd #{Shellwords.escape(Dir.pwd)} && git branch --show-current 2>/dev/null`.strip
|
|
1268
|
-
status = `cd #{Shellwords.escape(Dir.pwd)} && git status --porcelain 2>/dev/null`.strip.split("\n").reject(&:empty?)
|
|
1269
|
-
recent = `cd #{Shellwords.escape(Dir.pwd)} && git log --oneline -5 2>/dev/null`.strip.split("\n").reject(&:empty?)
|
|
1270
|
-
{ branch: branch, status: status, recent_commits: recent }
|
|
1271
|
-
rescue => e
|
|
1272
|
-
{ error: "git unavailable: #{e.message}" }
|
|
1273
|
-
end
|
|
1274
|
-
end
|
|
1275
|
-
|
|
1276
|
-
def mcp_tools_list
|
|
1277
|
-
return { tools: [], count: 0 } unless defined?(Tina4::McpServer)
|
|
1278
|
-
server = Tina4._default_mcp_server
|
|
1279
|
-
list = server.tools.values.map do |t|
|
|
1280
|
-
{ name: t["name"], description: t["description"], schema: t["inputSchema"] }
|
|
1281
|
-
end
|
|
1282
|
-
{ tools: list, count: list.size }
|
|
1283
|
-
end
|
|
1284
|
-
|
|
1285
|
-
def mcp_tool_call(body)
|
|
1286
|
-
tool_name = body["name"].to_s
|
|
1287
|
-
args = body["arguments"] || {}
|
|
1288
|
-
return { error: "tool name required" } if tool_name.empty?
|
|
1289
|
-
return { error: "MCP not loaded" } unless defined?(Tina4::McpServer)
|
|
1290
|
-
server = Tina4._default_mcp_server
|
|
1291
|
-
payload = JSON.generate({
|
|
1292
|
-
"jsonrpc" => "2.0",
|
|
1293
|
-
"id" => 1,
|
|
1294
|
-
"method" => "tools/call",
|
|
1295
|
-
"params" => { "name" => tool_name, "arguments" => args }
|
|
1296
|
-
})
|
|
1297
|
-
raw = server.handle_message(payload)
|
|
1298
|
-
return {} if raw.nil? || raw.empty?
|
|
1299
|
-
JSON.parse(raw)
|
|
1300
|
-
end
|
|
1301
|
-
|
|
1302
|
-
def scaffold_templates
|
|
1303
|
-
# Expose built-in scaffold targets for the dev-admin UI.
|
|
1304
|
-
{ templates: [
|
|
1305
|
-
{ id: "route", label: "Route file", target: "src/routes" },
|
|
1306
|
-
{ id: "model", label: "ORM model", target: "src/orm" },
|
|
1307
|
-
{ id: "migration", label: "SQL migration", target: "migrations" },
|
|
1308
|
-
{ id: "middleware", label: "Middleware class", target: "src/app" }
|
|
1309
|
-
] }
|
|
1310
|
-
end
|
|
1311
|
-
|
|
1312
|
-
def scaffold_run(body)
|
|
1313
|
-
kind = body["kind"].to_s
|
|
1314
|
-
name = body["name"].to_s.strip
|
|
1315
|
-
return { ok: false, error: "kind + name required" } if kind.empty? || name.empty?
|
|
1316
|
-
project = Dir.pwd
|
|
1317
|
-
case kind
|
|
1318
|
-
when "route"
|
|
1319
|
-
target = File.join(project, "src", "routes", "#{name}.rb")
|
|
1320
|
-
FileUtils.mkdir_p(File.dirname(target))
|
|
1321
|
-
File.write(target, "# #{name} routes\nTina4::Router.get(\"/api/#{name}\") do |req, res|\n res.call({ hello: \"#{name}\" })\nend\n") unless File.exist?(target)
|
|
1322
|
-
{ ok: true, created: target.sub("#{project}/", "") }
|
|
1323
|
-
when "model"
|
|
1324
|
-
target = File.join(project, "src", "orm", "#{name}.rb")
|
|
1325
|
-
FileUtils.mkdir_p(File.dirname(target))
|
|
1326
|
-
cls = name.to_s.split(/[_-]/).map(&:capitalize).join
|
|
1327
|
-
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)
|
|
1328
|
-
{ ok: true, created: target.sub("#{project}/", "") }
|
|
1329
|
-
when "migration"
|
|
1330
|
-
ts = Time.now.strftime("%Y%m%d%H%M%S")
|
|
1331
|
-
target = File.join(project, "migrations", "#{ts}_#{name}.sql")
|
|
1332
|
-
FileUtils.mkdir_p(File.dirname(target))
|
|
1333
|
-
File.write(target, "-- migration: #{name}\n")
|
|
1334
|
-
{ ok: true, created: target.sub("#{project}/", "") }
|
|
1335
|
-
when "middleware"
|
|
1336
|
-
target = File.join(project, "src", "app", "#{name}.rb")
|
|
1337
|
-
FileUtils.mkdir_p(File.dirname(target))
|
|
1338
|
-
cls = name.to_s.split(/[_-]/).map(&:capitalize).join
|
|
1339
|
-
File.write(target, "class #{cls}\n def self.before_check(req, res); [req, res]; end\nend\n") unless File.exist?(target)
|
|
1340
|
-
{ ok: true, created: target.sub("#{project}/", "") }
|
|
1341
|
-
else
|
|
1342
|
-
{ ok: false, error: "unknown kind: #{kind}" }
|
|
1343
|
-
end
|
|
1344
|
-
end
|
|
1345
|
-
|
|
1346
|
-
# ── Live Docs (Live API RAG) ─────────────────────────────────
|
|
1347
|
-
|
|
1348
|
-
def docs_search_payload(env)
|
|
1349
|
-
q = (query_param(env, "q") || "").to_s
|
|
1350
|
-
k = (query_param(env, "k") || "5").to_i
|
|
1351
|
-
source = (query_param(env, "source") || "all").to_s
|
|
1352
|
-
include_private = %w[1 true yes].include?((query_param(env, "include_private") || "").to_s.downcase)
|
|
1353
|
-
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
1354
|
-
results = Tina4::Docs.cached(Dir.pwd).search(
|
|
1355
|
-
q, k: k, source: source, include_private: include_private
|
|
1356
|
-
)
|
|
1357
|
-
took_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000.0).round(2)
|
|
1358
|
-
{ ok: true, query: q, results: results, took_ms: took_ms }
|
|
1359
|
-
end
|
|
1360
|
-
|
|
1361
|
-
def docs_class_payload(name)
|
|
1362
|
-
spec = Tina4::Docs.cached(Dir.pwd).class_spec(name.to_s)
|
|
1363
|
-
return { ok: false, error: "class not found: #{name}" } if spec.nil?
|
|
1364
|
-
{ ok: true, class: spec }
|
|
1365
|
-
end
|
|
1366
|
-
|
|
1367
|
-
def docs_method_payload(class_fqn, name)
|
|
1368
|
-
spec = Tina4::Docs.cached(Dir.pwd).method_spec(class_fqn.to_s, name.to_s)
|
|
1369
|
-
return { ok: false, error: "method not found: #{class_fqn}##{name}" } if spec.nil?
|
|
1370
|
-
{ ok: true, method: spec }
|
|
1371
|
-
end
|
|
1372
|
-
|
|
1373
|
-
def docs_index_payload(source)
|
|
1374
|
-
idx = Tina4::Docs.cached(Dir.pwd).index
|
|
1375
|
-
idx = idx.select { |e| e[:source] == source } if source && %w[framework user vendor].include?(source.to_s)
|
|
1376
|
-
{ ok: true, count: idx.size, entries: idx }
|
|
1377
|
-
end
|
|
1378
|
-
|
|
1379
|
-
def docs_well_known_payload
|
|
1380
|
-
{
|
|
1381
|
-
ok: true,
|
|
1382
|
-
service: "tina4-live-docs",
|
|
1383
|
-
version: Tina4::VERSION,
|
|
1384
|
-
framework: "tina4-ruby",
|
|
1385
|
-
endpoints: {
|
|
1386
|
-
search: "/__dev/api/docs/search?q=<query>&k=<int>&source=<framework|user|all>&include_private=<bool>",
|
|
1387
|
-
class: "/__dev/api/docs/class?name=<fqn>",
|
|
1388
|
-
method: "/__dev/api/docs/method?class=<fqn>&name=<method>",
|
|
1389
|
-
index: "/__dev/api/docs/index?source=<framework|user|all>",
|
|
1390
|
-
},
|
|
1391
|
-
mcp: { url: "/__dev/api/mcp", tools: %w[api_search api_class api_method] },
|
|
1392
|
-
spec: "https://tina4.com — Live API RAG plan/v3/22-LIVE-API-RAG.md",
|
|
1393
|
-
}
|
|
1394
|
-
end
|
|
1395
933
|
end
|
|
1396
934
|
end
|
|
1397
935
|
end
|
|
@@ -5,6 +5,20 @@ module Tina4
|
|
|
5
5
|
class FirebirdDriver
|
|
6
6
|
attr_reader :connection
|
|
7
7
|
|
|
8
|
+
# Substring markers (lowercased) that identify a dead-socket Firebird
|
|
9
|
+
# error worth reconnecting for. Idle Firebird connections die silently
|
|
10
|
+
# behind NAT timeouts, server-side ConnectionIdleTimeout, or Docker
|
|
11
|
+
# network rotation; without this the next prepare crashes the request.
|
|
12
|
+
DEAD_CONN_MARKERS = [
|
|
13
|
+
"error writing data to the connection",
|
|
14
|
+
"error reading data from the connection",
|
|
15
|
+
"connection shutdown",
|
|
16
|
+
"connection lost",
|
|
17
|
+
"network error",
|
|
18
|
+
"connection is not active",
|
|
19
|
+
"broken pipe"
|
|
20
|
+
].freeze
|
|
21
|
+
|
|
8
22
|
def connect(connection_string, username: nil, password: nil)
|
|
9
23
|
require "fb"
|
|
10
24
|
require "uri"
|
|
@@ -21,10 +35,13 @@ module Tina4
|
|
|
21
35
|
db_path || connection_string.sub(/^firebird:\/\//, "")
|
|
22
36
|
end
|
|
23
37
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
@
|
|
38
|
+
# Cache for transparent reconnect — never logged, lives only in
|
|
39
|
+
# driver memory alongside the connection it owns.
|
|
40
|
+
@connect_opts = { database: database }
|
|
41
|
+
@connect_opts[:username] = db_user if db_user
|
|
42
|
+
@connect_opts[:password] = db_pass if db_pass
|
|
43
|
+
|
|
44
|
+
open_connection
|
|
28
45
|
rescue LoadError
|
|
29
46
|
raise "Firebird driver requires the 'fb' gem. Install it with: gem install fb"
|
|
30
47
|
end
|
|
@@ -34,22 +51,35 @@ module Tina4
|
|
|
34
51
|
end
|
|
35
52
|
|
|
36
53
|
def execute_query(sql, params = [])
|
|
37
|
-
rows =
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
54
|
+
rows = with_reconnect do
|
|
55
|
+
if params.empty?
|
|
56
|
+
@connection.query(:hash, sql)
|
|
57
|
+
else
|
|
58
|
+
@connection.query(:hash, sql, *params)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
42
61
|
rows.map { |row| decode_blobs(stringify_keys(row)) }
|
|
43
62
|
end
|
|
44
63
|
|
|
45
64
|
def execute(sql, params = [])
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
65
|
+
with_reconnect do
|
|
66
|
+
if params.empty?
|
|
67
|
+
@connection.execute(sql)
|
|
68
|
+
else
|
|
69
|
+
@connection.execute(sql, *params)
|
|
70
|
+
end
|
|
50
71
|
end
|
|
51
72
|
end
|
|
52
73
|
|
|
74
|
+
# Public so specs (and curious operators) can verify the matcher
|
|
75
|
+
# behaviour without poking private methods.
|
|
76
|
+
def self.dead_connection?(error_or_message)
|
|
77
|
+
msg = error_or_message.respond_to?(:message) ? error_or_message.message : error_or_message.to_s
|
|
78
|
+
return false if msg.nil? || msg.empty?
|
|
79
|
+
lower = msg.downcase
|
|
80
|
+
DEAD_CONN_MARKERS.any? { |m| lower.include?(m) }
|
|
81
|
+
end
|
|
82
|
+
|
|
53
83
|
def last_insert_id
|
|
54
84
|
nil
|
|
55
85
|
end
|
|
@@ -103,6 +133,34 @@ module Tina4
|
|
|
103
133
|
|
|
104
134
|
private
|
|
105
135
|
|
|
136
|
+
def open_connection
|
|
137
|
+
@connection = Fb::Database.new(**@connect_opts).connect
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Force-close a stale handle and reopen using cached opts. Idempotent —
|
|
141
|
+
# safe to call when the connection is already gone.
|
|
142
|
+
def reconnect!
|
|
143
|
+
begin
|
|
144
|
+
@connection&.close
|
|
145
|
+
rescue StandardError
|
|
146
|
+
# connection already gone — nothing to clean up
|
|
147
|
+
end
|
|
148
|
+
@connection = nil
|
|
149
|
+
@transaction = nil
|
|
150
|
+
open_connection
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Run a block; if it raises with a dead-connection signature, reconnect
|
|
154
|
+
# once and retry. Skipped inside an explicit transaction — atomicity
|
|
155
|
+
# beats resilience there; the caller handles rollback.
|
|
156
|
+
def with_reconnect
|
|
157
|
+
yield
|
|
158
|
+
rescue StandardError => e
|
|
159
|
+
raise unless self.class.dead_connection?(e) && @transaction.nil?
|
|
160
|
+
reconnect!
|
|
161
|
+
yield
|
|
162
|
+
end
|
|
163
|
+
|
|
106
164
|
def stringify_keys(hash)
|
|
107
165
|
hash.each_with_object({}) { |(k, v), h| h[k.to_s] = v }
|
|
108
166
|
end
|