tina4ruby 3.11.32 → 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/database.rb +4 -31
- 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/database.rb
CHANGED
|
@@ -109,15 +109,6 @@ module Tina4
|
|
|
109
109
|
@pool_size = pool # 0 = single connection, N>0 = N pooled connections
|
|
110
110
|
@connected = false
|
|
111
111
|
|
|
112
|
-
# Per-instance thread-local key for the transaction adapter pin.
|
|
113
|
-
# Without this pin, every Database method call rotates to a different
|
|
114
|
-
# pooled connection. Inside a transaction this silently breaks atomicity:
|
|
115
|
-
# start_transaction begins on adapter A, executes autocommit on B/C, and
|
|
116
|
-
# commit/rollback land on D — a no-op. start_transaction sets the pin,
|
|
117
|
-
# commit/rollback clear it. While pinned, current_driver returns the same
|
|
118
|
-
# driver for every call so the whole transaction runs on one connection.
|
|
119
|
-
@tx_pin_key = :"tina4_pinned_adapter_#{object_id}"
|
|
120
|
-
|
|
121
112
|
# Query cache — off by default, opt-in via TINA4_DB_CACHE=true
|
|
122
113
|
@cache_enabled = truthy?(ENV["TINA4_DB_CACHE"])
|
|
123
114
|
@cache_ttl = (ENV["TINA4_DB_CACHE_TTL"] || "30").to_i
|
|
@@ -170,14 +161,7 @@ module Tina4
|
|
|
170
161
|
end
|
|
171
162
|
|
|
172
163
|
# Get the current driver — from pool (round-robin) or single connection.
|
|
173
|
-
#
|
|
174
|
-
# Inside a transaction, all calls must land on the SAME driver — otherwise
|
|
175
|
-
# start_transaction, execute, and commit each rotate to a different pooled
|
|
176
|
-
# connection and the transaction is meaningless. start_transaction pins
|
|
177
|
-
# the driver to the calling thread; commit/rollback release it.
|
|
178
164
|
def current_driver
|
|
179
|
-
pinned = Thread.current[@tx_pin_key]
|
|
180
|
-
return pinned if pinned
|
|
181
165
|
if @pool
|
|
182
166
|
@pool.checkout
|
|
183
167
|
else
|
|
@@ -371,38 +355,27 @@ module Tina4
|
|
|
371
355
|
|
|
372
356
|
def transaction
|
|
373
357
|
drv = current_driver
|
|
374
|
-
Thread.current[@tx_pin_key] = drv
|
|
375
358
|
drv.begin_transaction
|
|
376
359
|
yield self
|
|
377
360
|
drv.commit
|
|
378
361
|
rescue => e
|
|
379
|
-
drv.rollback
|
|
362
|
+
drv.rollback
|
|
380
363
|
raise e
|
|
381
|
-
ensure
|
|
382
|
-
Thread.current[@tx_pin_key] = nil
|
|
383
364
|
end
|
|
384
365
|
|
|
385
366
|
# Begin a transaction without a block — matches PHP/Python/Node API.
|
|
386
|
-
# Pins the driver to this thread for the whole transaction so executes
|
|
387
|
-
# and the final commit/rollback all run on the same connection.
|
|
388
367
|
def start_transaction
|
|
389
|
-
|
|
390
|
-
Thread.current[@tx_pin_key] = drv
|
|
391
|
-
drv.begin_transaction
|
|
368
|
+
current_driver.begin_transaction
|
|
392
369
|
end
|
|
393
370
|
|
|
394
|
-
# Commit the current transaction
|
|
371
|
+
# Commit the current transaction — matches PHP/Python/Node API.
|
|
395
372
|
def commit
|
|
396
373
|
current_driver.commit
|
|
397
|
-
ensure
|
|
398
|
-
Thread.current[@tx_pin_key] = nil
|
|
399
374
|
end
|
|
400
375
|
|
|
401
|
-
# Roll back the current transaction
|
|
376
|
+
# Roll back the current transaction — matches PHP/Python/Node API.
|
|
402
377
|
def rollback
|
|
403
378
|
current_driver.rollback
|
|
404
|
-
ensure
|
|
405
|
-
Thread.current[@tx_pin_key] = nil
|
|
406
379
|
end
|
|
407
380
|
|
|
408
381
|
def tables
|
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
|