tina4ruby 3.11.35 → 3.11.36
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/background.rb +81 -0
- data/lib/tina4/database.rb +31 -4
- data/lib/tina4/dev_admin.rb +463 -1
- data/lib/tina4/docs.rb +636 -0
- data/lib/tina4/frond.rb +62 -0
- data/lib/tina4/mcp.rb +190 -0
- data/lib/tina4/orm.rb +57 -12
- data/lib/tina4/plan.rb +471 -0
- data/lib/tina4/project_index.rb +366 -0
- 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 +46 -1
- data/lib/tina4/response.rb +3 -0
- data/lib/tina4/shutdown.rb +10 -0
- data/lib/tina4/version.rb +1 -1
- data/lib/tina4.rb +14 -0
- metadata +5 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9615f251d5bed0d97024c8e061bdf34d26afb2677e5c6ec989005d45d3edc6c9
|
|
4
|
+
data.tar.gz: 7ef7b684a87906e932f3c929cfe57596f67bf81bf63cf35e79b8d343f2ab297a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ecf726e25d06e8ecbed12874fa4f36edf20286ea10c2c9b6c973a2d9d430dd38933a95240b8d50dd4237b82e076b634527e0c28016aa6e94f6f71a7747d126f8
|
|
7
|
+
data.tar.gz: bc0603a6370d2dc27f26ad5cee86c00f233213d57dc904075f70c5befdf28ff81b78d0c25ffa25a42f34ae9cf48d0a33a322e1147d12bc05db1f125db245538f
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tina4
|
|
4
|
+
# Periodic background task registry.
|
|
5
|
+
#
|
|
6
|
+
# Matches Python's `tina4_python.core.server.background(fn, interval)` and
|
|
7
|
+
# PHP's `$app->background($callback, $interval)` — a callback that runs
|
|
8
|
+
# periodically alongside the server lifecycle.
|
|
9
|
+
#
|
|
10
|
+
# Ruby has no asyncio event loop, so each task runs in its own thread.
|
|
11
|
+
# The GIL keeps it cooperative-enough for the periodic work this is meant
|
|
12
|
+
# for (queue draining, health checks, simulators). Errors in the callback
|
|
13
|
+
# are caught and logged so they don't kill the thread.
|
|
14
|
+
module Background
|
|
15
|
+
class << self
|
|
16
|
+
# Register a periodic callback.
|
|
17
|
+
#
|
|
18
|
+
# @param callback [#call, nil] Object responding to `call` with no args.
|
|
19
|
+
# @param interval [Float] Seconds between invocations (default 1.0).
|
|
20
|
+
# @param block [Proc] Optional block (used if callback is nil).
|
|
21
|
+
# @return [Hash] The registered task descriptor.
|
|
22
|
+
def register(callback = nil, interval: 1.0, &block)
|
|
23
|
+
cb = callback || block
|
|
24
|
+
raise ArgumentError, "background requires a callback or block" if cb.nil?
|
|
25
|
+
raise ArgumentError, "callback must respond to :call" unless cb.respond_to?(:call)
|
|
26
|
+
|
|
27
|
+
task = { callback: cb, interval: interval.to_f, thread: nil, running: false }
|
|
28
|
+
mutex.synchronize { tasks << task }
|
|
29
|
+
start_task(task)
|
|
30
|
+
task
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# All registered task descriptors. Tests use this for introspection.
|
|
34
|
+
def tasks
|
|
35
|
+
@tasks ||= []
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Stop and join every running task. Called on graceful shutdown.
|
|
39
|
+
def stop_all(timeout: 2.0)
|
|
40
|
+
snapshot = mutex.synchronize { tasks.dup }
|
|
41
|
+
snapshot.each { |task| stop_task(task, timeout: timeout) }
|
|
42
|
+
mutex.synchronize { tasks.clear }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Stop a single task. Used by tests that register, fire, then stop.
|
|
46
|
+
def stop_task(task, timeout: 2.0)
|
|
47
|
+
task[:running] = false
|
|
48
|
+
thread = task[:thread]
|
|
49
|
+
return unless thread
|
|
50
|
+
|
|
51
|
+
thread.join(timeout) || thread.kill
|
|
52
|
+
task[:thread] = nil
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def mutex
|
|
58
|
+
@mutex ||= Mutex.new
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def start_task(task)
|
|
62
|
+
task[:running] = true
|
|
63
|
+
task[:thread] = Thread.new do
|
|
64
|
+
while task[:running]
|
|
65
|
+
sleep task[:interval]
|
|
66
|
+
break unless task[:running]
|
|
67
|
+
|
|
68
|
+
begin
|
|
69
|
+
task[:callback].call
|
|
70
|
+
rescue => e
|
|
71
|
+
# Never let a callback error kill the thread — next interval still fires.
|
|
72
|
+
if defined?(Tina4::Log) && Tina4::Log.respond_to?(:error)
|
|
73
|
+
Tina4::Log.error("background task error: #{e.class}: #{e.message}")
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
data/lib/tina4/database.rb
CHANGED
|
@@ -109,6 +109,15 @@ 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
|
+
|
|
112
121
|
# Query cache — off by default, opt-in via TINA4_DB_CACHE=true
|
|
113
122
|
@cache_enabled = truthy?(ENV["TINA4_DB_CACHE"])
|
|
114
123
|
@cache_ttl = (ENV["TINA4_DB_CACHE_TTL"] || "30").to_i
|
|
@@ -161,7 +170,14 @@ module Tina4
|
|
|
161
170
|
end
|
|
162
171
|
|
|
163
172
|
# 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.
|
|
164
178
|
def current_driver
|
|
179
|
+
pinned = Thread.current[@tx_pin_key]
|
|
180
|
+
return pinned if pinned
|
|
165
181
|
if @pool
|
|
166
182
|
@pool.checkout
|
|
167
183
|
else
|
|
@@ -355,27 +371,38 @@ module Tina4
|
|
|
355
371
|
|
|
356
372
|
def transaction
|
|
357
373
|
drv = current_driver
|
|
374
|
+
Thread.current[@tx_pin_key] = drv
|
|
358
375
|
drv.begin_transaction
|
|
359
376
|
yield self
|
|
360
377
|
drv.commit
|
|
361
378
|
rescue => e
|
|
362
|
-
drv.rollback
|
|
379
|
+
drv.rollback if drv
|
|
363
380
|
raise e
|
|
381
|
+
ensure
|
|
382
|
+
Thread.current[@tx_pin_key] = nil
|
|
364
383
|
end
|
|
365
384
|
|
|
366
385
|
# 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.
|
|
367
388
|
def start_transaction
|
|
368
|
-
current_driver
|
|
389
|
+
drv = current_driver
|
|
390
|
+
Thread.current[@tx_pin_key] = drv
|
|
391
|
+
drv.begin_transaction
|
|
369
392
|
end
|
|
370
393
|
|
|
371
|
-
# Commit the current transaction
|
|
394
|
+
# Commit the current transaction and release the driver pin.
|
|
372
395
|
def commit
|
|
373
396
|
current_driver.commit
|
|
397
|
+
ensure
|
|
398
|
+
Thread.current[@tx_pin_key] = nil
|
|
374
399
|
end
|
|
375
400
|
|
|
376
|
-
# Roll back the current transaction
|
|
401
|
+
# Roll back the current transaction and release the driver pin.
|
|
377
402
|
def rollback
|
|
378
403
|
current_driver.rollback
|
|
404
|
+
ensure
|
|
405
|
+
Thread.current[@tx_pin_key] = nil
|
|
379
406
|
end
|
|
380
407
|
|
|
381
408
|
def tables
|
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
|
|
@@ -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
|