tina4ruby 3.11.35 → 3.12.0
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/auth.rb +5 -5
- data/lib/tina4/background.rb +81 -0
- data/lib/tina4/constants.rb +40 -0
- data/lib/tina4/container.rb +1 -1
- data/lib/tina4/database.rb +37 -10
- data/lib/tina4/dev_admin.rb +464 -2
- data/lib/tina4/docs.rb +636 -0
- data/lib/tina4/drivers/postgres_driver.rb +38 -4
- data/lib/tina4/env.rb +74 -3
- data/lib/tina4/field_types.rb +1 -1
- data/lib/tina4/frond.rb +62 -0
- data/lib/tina4/mcp.rb +191 -1
- data/lib/tina4/messenger.rb +13 -14
- data/lib/tina4/orm.rb +85 -12
- data/lib/tina4/plan.rb +471 -0
- data/lib/tina4/project_index.rb +366 -0
- data/lib/tina4/public/js/frond.js +600 -0
- data/lib/tina4/public/js/frond.min.js +1 -1
- data/lib/tina4/public/js/tina4-dev-admin.js +1086 -238
- data/lib/tina4/public/js/tina4-dev-admin.min.js +1142 -209
- data/lib/tina4/rack_app.rb +98 -16
- data/lib/tina4/response.rb +3 -0
- data/lib/tina4/session.rb +1 -1
- data/lib/tina4/session_handlers/database_handler.rb +1 -1
- data/lib/tina4/shutdown.rb +10 -0
- data/lib/tina4/swagger.rb +3 -3
- data/lib/tina4/version.rb +1 -1
- data/lib/tina4/webserver.rb +3 -0
- data/lib/tina4.rb +15 -1
- metadata +6 -1
data/lib/tina4/dev_admin.rb
CHANGED
|
@@ -5,6 +5,8 @@ require "digest"
|
|
|
5
5
|
require "tmpdir"
|
|
6
6
|
require "net/http"
|
|
7
7
|
require "uri"
|
|
8
|
+
require "fileutils"
|
|
9
|
+
require "shellwords"
|
|
8
10
|
require_relative "metrics"
|
|
9
11
|
|
|
10
12
|
module Tina4
|
|
@@ -314,9 +316,55 @@ module Tina4
|
|
|
314
316
|
Tina4::Env.is_truthy(ENV["TINA4_DEBUG"])
|
|
315
317
|
end
|
|
316
318
|
|
|
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
|
+
|
|
317
364
|
# Handle a /__dev request; returns [status, headers, body] or nil if not a dev path
|
|
318
365
|
def handle_request(env)
|
|
319
366
|
return nil unless enabled?
|
|
367
|
+
auto_discover_mcp!
|
|
320
368
|
|
|
321
369
|
path = env["PATH_INFO"] || "/"
|
|
322
370
|
method = env["REQUEST_METHOD"]
|
|
@@ -405,7 +453,9 @@ module Tina4
|
|
|
405
453
|
resolved = id ? error_tracker.resolve(id) : false
|
|
406
454
|
json_response({ resolved: resolved, id: id })
|
|
407
455
|
when ["POST", "/__dev/api/broken/clear"]
|
|
408
|
-
|
|
456
|
+
# "Clear All" button — flush every tracked error, not only the
|
|
457
|
+
# ones individually marked resolved. Matches PHP/Python.
|
|
458
|
+
error_tracker.clear_all
|
|
409
459
|
json_response({ cleared: true })
|
|
410
460
|
when ["GET", "/__dev/api/websockets"]
|
|
411
461
|
json_response({ connections: [], count: 0 })
|
|
@@ -494,6 +544,66 @@ module Tina4
|
|
|
494
544
|
when ["GET", "/__dev/api/metrics/file"]
|
|
495
545
|
file_path = (query_param(env, "path") || "").to_s
|
|
496
546
|
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)
|
|
497
607
|
when ["GET", "/__dev/api/graphql/schema"]
|
|
498
608
|
begin
|
|
499
609
|
gql = Tina4::GraphQL.new
|
|
@@ -584,7 +694,7 @@ module Tina4
|
|
|
584
694
|
platform: RUBY_PLATFORM,
|
|
585
695
|
debug: ENV["TINA4_DEBUG"] || "false",
|
|
586
696
|
log_level: ENV["TINA4_LOG_LEVEL"] || "ERROR",
|
|
587
|
-
database: ENV["
|
|
697
|
+
database: ENV["TINA4_DATABASE_URL"] || "not configured",
|
|
588
698
|
db_tables: db_table_count,
|
|
589
699
|
uptime: (Time.now - (defined?(@boot_time) && @boot_time ? @boot_time : (@boot_time = Time.now))).round(1),
|
|
590
700
|
route_count: Tina4::Router.routes.size,
|
|
@@ -930,6 +1040,358 @@ module Tina4
|
|
|
930
1040
|
|
|
931
1041
|
{ deployed: name, files: copied }
|
|
932
1042
|
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
|
|
933
1395
|
end
|
|
934
1396
|
end
|
|
935
1397
|
end
|