tina4ruby 3.13.34 → 3.13.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e94bf682abd45afc1478f5a2a14f9a5de1d669badbffd6c9c3d283a2ee97bcfd
4
- data.tar.gz: f9fabc99e68b59c138fd8e89e33291997c0de1389b35f5e58640acb6e47c8533
3
+ metadata.gz: 7266dfb9cb9605c62b61d64de0c2586ff6824f2a22ad87aae74403f1d171c495
4
+ data.tar.gz: 0f9dffee4f4242c9c03899b79742a314bde065308da3e852c1af76fb1ade6be9
5
5
  SHA512:
6
- metadata.gz: c76ab57f8ea6309d5612dd5c6f5141dff666c80e792715c5b2e3f8718f10860a25cbd149844f435bfb497ce26da7d13b1738e1bf132fb581196cf2a85717cd02
7
- data.tar.gz: fbf2ce202dee833fa5e9489abb2811649f95a03f073610850c004fc3b6a1827987be0c52c61d8d80778ae9e054beac097a441b110af52bcc62deaca68e79a3cd
6
+ metadata.gz: ad75e74701786bf08fd4fc3c143a0d5f6731dbe0aa5feb5d46afb5c2472a113daa9dd0d2962f616c0a811318fed23091e40248117aee22923ea7fa35a81b6ab5
7
+ data.tar.gz: c5622aff3bf9fb522969dbe5431ae1a2aa8416712ade87ccd277529418a1aea6a427a25a211198342cfca0bebcff81305db70e6be01a0a11cfd7a2e99ebd6a9a
@@ -392,12 +392,30 @@ module Tina4
392
392
  reload_type = body["type"] || "reload"
393
393
  Tina4::Log.info("External reload trigger: #{reload_type}#{@reload_file.empty? ? '' : " (#{@reload_file})"}")
394
394
  # Re-discover so files dropped into src/routes/ register without
395
- # a server restart. Idempotent — already-loaded files are skipped.
395
+ # a server restart. Idempotent — already-loaded files are skipped,
396
+ # changed files are re-loaded (mtime-tracked).
396
397
  begin
397
398
  Tina4::Router.rescan_routes!
398
399
  rescue StandardError => e
399
400
  Tina4::Log.error("Re-discover on reload failed: #{e.message}")
400
401
  end
402
+ # WebSocket-primary reload: push an instant message to every browser
403
+ # connected on /__dev_reload. The toolbar client (and the dev-admin
404
+ # dashboard) act on this immediately — the mtime poll above is only a
405
+ # fallback for when the socket is down. CSS changes swap stylesheets;
406
+ # everything else triggers a full page reload. We normalise the wire
407
+ # `type` to "css"/"reload" (the clients only react to css/reload/change)
408
+ # but still echo the caller's original type in the HTTP response.
409
+ # Wrapped so a broadcast failure — or zero connected clients — never
410
+ # 500s the reload endpoint.
411
+ begin
412
+ ws_type = reload_type == "css" ? "css" : "reload"
413
+ Tina4::DevReload.broadcast(
414
+ JSON.generate({ type: ws_type, file: @reload_file, mtime: @reload_mtime })
415
+ )
416
+ rescue StandardError => e
417
+ Tina4::Log.error("Dev-reload WebSocket broadcast failed: #{e.message}")
418
+ end
401
419
  json_response({ ok: true, type: reload_type })
402
420
  when ["GET", "/__dev/api/status"]
403
421
  json_response(status_payload)
@@ -614,6 +632,14 @@ module Tina4
614
632
  when ["POST", "/__dev/api/mcp/call"]
615
633
  body = read_json_body(env) || {}
616
634
  json_response(mcp_tool_call(body))
635
+ # JSON-RPC + SSE endpoints that real MCP clients (Claude Code/Desktop)
636
+ # speak. Mounted on the same dispatch as the REST shim above and gated
637
+ # on the same enabled? (TINA4_DEBUG) check, so disabled → 404. They
638
+ # share the default MCP server's tool registry with the REST shim.
639
+ when ["POST", "/__dev/mcp"], ["POST", "/__dev/mcp/message"]
640
+ mcp_jsonrpc(env)
641
+ when ["GET", "/__dev/mcp/sse"]
642
+ mcp_sse_handshake
617
643
  when ["GET", "/__dev/api/scaffold"]
618
644
  json_response(scaffold_templates)
619
645
  when ["POST", "/__dev/api/scaffold/run"]
@@ -1250,24 +1276,120 @@ module Tina4
1250
1276
  resolved
1251
1277
  end
1252
1278
 
1279
+ # Noise dirs + hidden dot-entries are hidden from the file browser,
1280
+ # except the env files — verbatim parity with PHP/Python dev-admin.
1281
+ DEV_FILES_IGNORED = %w[__pycache__ node_modules vendor .git venv .venv dist target .tina4].freeze
1282
+
1283
+ def dev_files_hidden?(name)
1284
+ return true if DEV_FILES_IGNORED.include?(name)
1285
+ name.start_with?(".") && name != ".env" && name != ".env.example"
1286
+ end
1287
+
1288
+ # Same 4-status mapping Python/PHP use for a porcelain code.
1289
+ def dev_git_status_label(code)
1290
+ return "untracked" if code == "??"
1291
+ return "modified" if code.include?("M")
1292
+ return "added" if code.include?("A")
1293
+ return "deleted" if code.include?("D")
1294
+ "clean"
1295
+ end
1296
+
1297
+ # Branch + porcelain status map for the file browser (mirrors PHP/Python
1298
+ # dev-admin). Paths git reports are relative to the repo root (always
1299
+ # forward-slash); the project root may sit inside a larger repo, so the
1300
+ # toplevel is returned too for rebasing. Empty on any error / no git.
1301
+ def dev_git_info(root)
1302
+ require "shellwords" unless defined?(Shellwords)
1303
+ esc = Shellwords.escape(root)
1304
+ inside = `cd #{esc} && git rev-parse --is-inside-work-tree 2>/dev/null`.strip
1305
+ return { branch: "", git_root: nil, status: {} } unless inside == "true"
1306
+
1307
+ branch = `cd #{esc} && git rev-parse --abbrev-ref HEAD 2>/dev/null`.strip
1308
+ top = `cd #{esc} && git rev-parse --show-toplevel 2>/dev/null`.strip
1309
+ git_root = top.empty? ? nil : top.tr("\\", "/").sub(%r{/+\z}, "")
1310
+
1311
+ status = {}
1312
+ `cd #{esc} && git status --porcelain -uall 2>/dev/null`.each_line do |line|
1313
+ line = line.chomp
1314
+ next if line.length < 4
1315
+ code = line[0, 2].strip
1316
+ path = line[3..].to_s.strip
1317
+ if (idx = path.index(" -> ")) # rename/copy — keep destination
1318
+ path = path[(idx + 4)..]
1319
+ end
1320
+ next if path.nil? || path.empty?
1321
+ status[path] = code
1322
+ end
1323
+ { branch: branch, git_root: git_root, status: status }
1324
+ rescue StandardError
1325
+ { branch: "", git_root: nil, status: {} }
1326
+ end
1327
+
1253
1328
  def files_list(env)
1329
+ # Response shape matches tina4-python / tina4-php 1:1 so the dev-admin
1330
+ # SPA works against every framework: each entry carries `is_dir`,
1331
+ # `has_children`, `git_status` and `size`; the payload carries `branch`.
1254
1332
  rel = query_param(env, "path") || "."
1255
- begin
1256
- target = safe_project_path(rel)
1257
- return { error: "Not found" } unless File.exist?(target)
1258
- return { error: "Not a directory" } unless File.directory?(target)
1259
- entries = Dir.children(target).sort.map do |name|
1260
- full = File.join(target, name)
1261
- {
1262
- name: name,
1263
- type: File.directory?(full) ? "dir" : "file",
1264
- size: File.file?(full) ? File.size(full) : 0
1265
- }
1333
+ root = File.expand_path(Dir.pwd)
1334
+ git = dev_git_info(root)
1335
+
1336
+ # Missing/invalid paths return an empty-but-valid shape (not an error
1337
+ # body): the SPA restores expanded-folder state from localStorage and
1338
+ # non-existent folders would otherwise spam the console.
1339
+ target = begin
1340
+ safe_project_path(rel)
1341
+ rescue StandardError
1342
+ nil
1343
+ end
1344
+ unless target && File.directory?(target)
1345
+ return { path: rel, branch: git[:branch], entries: [], error: "not a directory" }
1346
+ end
1347
+
1348
+ root_fwd = root.tr("\\", "/")
1349
+ cwd_in_git = ""
1350
+ if git[:git_root] && git[:git_root] != root_fwd && root_fwd.start_with?(git[:git_root])
1351
+ cwd_in_git = root_fwd[git[:git_root].length..].to_s.sub(%r{\A/+}, "")
1352
+ cwd_in_git += "/" unless cwd_in_git.empty?
1353
+ end
1354
+
1355
+ entries = Dir.children(target).sort.filter_map do |name|
1356
+ next if dev_files_hidden?(name)
1357
+ full = File.join(target, name)
1358
+ entry_rel = full[root.length..].to_s.sub(%r{\A/+}, "").tr("\\", "/")
1359
+ is_dir = File.directory?(full)
1360
+
1361
+ # git status for this entry (same mapping PHP/Python use)
1362
+ git_path = cwd_in_git + entry_rel
1363
+ label = "clean"
1364
+ if (code = git[:status][git_path])
1365
+ label = dev_git_status_label(code)
1366
+ elsif is_dir
1367
+ prefix = "#{git_path}/" # propagate dirty status from any child
1368
+ hit = git[:status].find { |gf, _| gf.start_with?(prefix) }
1369
+ label = (hit && hit[1] == "??") ? "untracked" : "modified" if hit
1266
1370
  end
1267
- { path: rel, entries: entries, count: entries.size }
1268
- rescue => e
1269
- { error: e.message }
1371
+
1372
+ # has_children: does the dir contain anything visible?
1373
+ has_children = nil
1374
+ if is_dir
1375
+ has_children = begin
1376
+ Dir.children(full).any? { |c| !dev_files_hidden?(c) }
1377
+ rescue StandardError
1378
+ false
1379
+ end
1380
+ end
1381
+
1382
+ {
1383
+ name: name,
1384
+ path: entry_rel,
1385
+ is_dir: is_dir,
1386
+ has_children: has_children,
1387
+ git_status: label,
1388
+ size: is_dir ? nil : (File.size(full) rescue 0)
1389
+ }
1270
1390
  end
1391
+
1392
+ { path: rel, branch: git[:branch], entries: entries, count: entries.size }
1271
1393
  end
1272
1394
 
1273
1395
  def file_read_payload(rel)
@@ -1431,6 +1553,29 @@ module Tina4
1431
1553
  JSON.parse(raw)
1432
1554
  end
1433
1555
 
1556
+ # Native MCP JSON-RPC endpoint (POST /__dev/mcp[/message]). Real MCP
1557
+ # clients POST a JSON-RPC 2.0 request; we hand the parsed body straight
1558
+ # to the default server's handle_message and echo the response. A
1559
+ # notification (no id) yields an empty 204, mirroring Python.
1560
+ def mcp_jsonrpc(env)
1561
+ body = read_json_body(env) || {}
1562
+ server = Tina4._default_mcp_server
1563
+ raw = server.handle_message(body)
1564
+ if raw.nil? || raw.empty?
1565
+ [204, { "content-type" => "application/json; charset=utf-8" }, []]
1566
+ else
1567
+ [200, { "content-type" => "application/json; charset=utf-8" }, [raw]]
1568
+ end
1569
+ end
1570
+
1571
+ # SSE handshake (GET /__dev/mcp/sse). Tells the client where to POST
1572
+ # JSON-RPC messages via the standard `endpoint` event, then the client
1573
+ # switches to POST /__dev/mcp/message. Body + content-type match Python.
1574
+ def mcp_sse_handshake
1575
+ body = "event: endpoint\ndata: /__dev/mcp/message\n\n"
1576
+ [200, { "content-type" => "text/event-stream" }, [body]]
1577
+ end
1578
+
1434
1579
  def scaffold_templates
1435
1580
  # Expose built-in scaffold targets for the dev-admin UI.
1436
1581
  { templates: [
data/lib/tina4/mcp.rb CHANGED
@@ -423,8 +423,17 @@ module Tina4
423
423
 
424
424
  @_default_mcp_server = nil
425
425
 
426
+ # The default dev MCP server, mounted at /__dev/mcp. Built lazily on first
427
+ # access and pre-loaded with the built-in dev tools (database_query,
428
+ # file_read, route_list, …) so both the REST shim (/__dev/api/mcp/*) and
429
+ # the JSON-RPC + SSE endpoints (/__dev/mcp[/message], /__dev/mcp/sse)
430
+ # share one fully-populated tool registry.
426
431
  def self._default_mcp_server
427
- @_default_mcp_server ||= McpServer.new("/__dev/mcp", name: "Tina4 Dev Tools")
432
+ @_default_mcp_server ||= begin
433
+ server = McpServer.new("/__dev/mcp", name: "Tina4 Dev Tools")
434
+ McpDevTools.register(server)
435
+ server
436
+ end
428
437
  end
429
438
 
430
439
  # Register a block as an MCP tool.
@@ -661,12 +670,13 @@ module Tina4
661
670
 
662
671
  # ── Route Tools ───────────────────────────────────
663
672
  server.register_tool("route_list", lambda {
664
- routes = Tina4::Router.routes
665
- routes.map do |route|
673
+ # Tina4::Router.routes returns Route objects (attr_readers), not
674
+ # Hashes — use accessors, never [] subscript.
675
+ Tina4::Router.routes.map do |route|
666
676
  {
667
- "method" => route[:method].to_s,
668
- "path" => route[:path].to_s,
669
- "auth_required" => !route[:auth_handler].nil?
677
+ "method" => route.method.to_s,
678
+ "path" => route.path.to_s,
679
+ "auth_required" => route.auth_required ? true : false
670
680
  }
671
681
  end
672
682
  }, "List all registered routes")