tina4ruby 3.13.71 → 3.13.72
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/lib/tina4/dev_admin.rb +322 -30
- data/lib/tina4/frond.rb +68 -5
- data/lib/tina4/public/js/tina4-dev-admin.js +203 -170
- data/lib/tina4/public/js/tina4-dev-admin.min.js +203 -170
- data/lib/tina4/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3ed4c522a29f92b73683272345e23d289b81698615cc9d006c72a09d19dca1a2
|
|
4
|
+
data.tar.gz: 77e3fb90d8ce8c4a7609655c512e2794fead60a70ec03c728968cea1f9f5a122
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 0dfd6b813d86acf8b6977e0132d60dc0ab735d2f312197e8bef771f20a74c607d31fb720b9496227430fb205d40ec02f185d8b7b2e459f15ee594779e53df381
|
|
7
|
+
data.tar.gz: e112213e75cbd5ffadbaca25f4a910bd409542c40bdde4f194bfc93d591e9d780b628615f6f7b3831191113d60722f087fb93c7c3c091c7876aa1316649b07f8
|
data/lib/tina4/dev_admin.rb
CHANGED
|
@@ -488,11 +488,14 @@ module Tina4
|
|
|
488
488
|
begin
|
|
489
489
|
if defined?(Tina4::Queue)
|
|
490
490
|
queue = Tina4::Queue.new(backend: :file, topic: topic)
|
|
491
|
+
# Queue#size is keyword-only (def size(status:)). Calling it
|
|
492
|
+
# positionally raises ArgumentError, which the rescue below
|
|
493
|
+
# swallows — silently zeroing every stat. Use keyword form.
|
|
491
494
|
stats = {
|
|
492
|
-
pending: queue.respond_to?(:size) ? queue.size("pending") : 0,
|
|
493
|
-
completed: queue.respond_to?(:size) ? queue.size("completed") : 0,
|
|
494
|
-
failed: queue.respond_to?(:size) ? queue.size("failed") : 0,
|
|
495
|
-
reserved: queue.respond_to?(:size) ? queue.size("reserved") : 0,
|
|
495
|
+
pending: queue.respond_to?(:size) ? queue.size(status: "pending") : 0,
|
|
496
|
+
completed: queue.respond_to?(:size) ? queue.size(status: "completed") : 0,
|
|
497
|
+
failed: queue.respond_to?(:size) ? queue.size(status: "failed") : 0,
|
|
498
|
+
reserved: queue.respond_to?(:size) ? queue.size(status: "reserved") : 0,
|
|
496
499
|
}
|
|
497
500
|
jobs.concat(queue.failed.map { |j| j.merge(status: "failed") }) if queue.respond_to?(:failed)
|
|
498
501
|
jobs.concat(queue.dead_letters.map { |j| j.merge(status: "dead_letter") }) if queue.respond_to?(:dead_letters)
|
|
@@ -519,11 +522,10 @@ module Tina4
|
|
|
519
522
|
error_tracker.clear_all
|
|
520
523
|
json_response({ cleared: true })
|
|
521
524
|
when ["GET", "/__dev/api/websockets"]
|
|
522
|
-
json_response(
|
|
525
|
+
json_response(websockets_payload)
|
|
523
526
|
when ["POST", "/__dev/api/websockets/disconnect"]
|
|
524
|
-
body = read_json_body(env)
|
|
525
|
-
|
|
526
|
-
json_response({ disconnected: true })
|
|
527
|
+
body = read_json_body(env) || {}
|
|
528
|
+
json_response(websockets_disconnect(body))
|
|
527
529
|
when ["GET", "/__dev/api/mailbox/read"]
|
|
528
530
|
message_id = query_param(env, "id")
|
|
529
531
|
message = mailbox.read(message_id)
|
|
@@ -547,16 +549,14 @@ module Tina4
|
|
|
547
549
|
filtered = keyword.empty? ? all_messages : all_messages.select { |m| m[:message].to_s.downcase.include?(keyword.downcase) }
|
|
548
550
|
json_response({ messages: filtered, count: filtered.size, keyword: keyword })
|
|
549
551
|
when ["POST", "/__dev/api/queue/retry"]
|
|
550
|
-
body = read_json_body(env)
|
|
551
|
-
|
|
552
|
-
json_response({ retried: true })
|
|
552
|
+
body = read_json_body(env) || {}
|
|
553
|
+
json_response(queue_retry(body))
|
|
553
554
|
when ["POST", "/__dev/api/queue/purge"]
|
|
554
|
-
|
|
555
|
-
json_response(
|
|
555
|
+
body = read_json_body(env) || {}
|
|
556
|
+
json_response(queue_purge(body))
|
|
556
557
|
when ["POST", "/__dev/api/queue/replay"]
|
|
557
|
-
body = read_json_body(env)
|
|
558
|
-
|
|
559
|
-
json_response({ replayed: true })
|
|
558
|
+
body = read_json_body(env) || {}
|
|
559
|
+
json_response(queue_replay(body))
|
|
560
560
|
when ["GET", "/__dev/api/table"]
|
|
561
561
|
table_name = query_param(env, "name")
|
|
562
562
|
json_response(table_detail_payload(table_name))
|
|
@@ -573,6 +573,23 @@ module Tina4
|
|
|
573
573
|
body = read_json_body(env)
|
|
574
574
|
tool = (body && body["tool"]) || ""
|
|
575
575
|
json_response(run_tool(tool))
|
|
576
|
+
# Dedicated run-chip routes (parity with the SPA's migrate/test/seed
|
|
577
|
+
# chips + Python's dev-admin tools). Not MCP-gated — same as the
|
|
578
|
+
# sibling /tool, /seed and /query operational routes, which are only
|
|
579
|
+
# reachable at all under TINA4_DEBUG (DevAdmin.enabled?).
|
|
580
|
+
when ["POST", "/__dev/api/migrate"]
|
|
581
|
+
json_response(run_migrations_payload)
|
|
582
|
+
when ["POST", "/__dev/api/test"]
|
|
583
|
+
json_response(run_tests_payload)
|
|
584
|
+
when ["POST", "/__dev/api/seed/run"]
|
|
585
|
+
json_response(run_seeds_payload)
|
|
586
|
+
# Grounding panel — configure the tina4-coder MCP token used for
|
|
587
|
+
# live-docs grounding. Self-contained (.env read/write); no proxy.
|
|
588
|
+
when ["GET", "/__dev/api/grounding/status"]
|
|
589
|
+
json_response(grounding_status_payload)
|
|
590
|
+
when ["POST", "/__dev/api/grounding/token"]
|
|
591
|
+
body = read_json_body(env) || {}
|
|
592
|
+
json_response(grounding_token_save(body))
|
|
576
593
|
when ["POST", "/__dev/api/chat"]
|
|
577
594
|
# Proxy dev-admin chat to the Rust agent's /chat endpoint.
|
|
578
595
|
# The SPA POSTs {message, thread_id?, active_file?, settings?}
|
|
@@ -878,21 +895,281 @@ module Tina4
|
|
|
878
895
|
}
|
|
879
896
|
end
|
|
880
897
|
|
|
898
|
+
# Run a named developer tool. `test`/`migrate`/`seed` reuse the same
|
|
899
|
+
# real code paths as the dedicated run-chip routes above (no stubs), so
|
|
900
|
+
# the legacy POST /tool surface and the new chips stay in lockstep.
|
|
881
901
|
def run_tool(tool)
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
902
|
+
case tool
|
|
903
|
+
when "routes"
|
|
904
|
+
routes = Tina4::Router.routes.map { |r| { method: r.method, path: r.path } }
|
|
905
|
+
{ tool: tool, output: JSON.pretty_generate(routes) }
|
|
906
|
+
when "test"
|
|
907
|
+
r = run_tests_payload
|
|
908
|
+
{ tool: tool, output: r[:output].to_s, exit_code: r[:code], ok: r[:ok] }
|
|
909
|
+
when "migrate"
|
|
910
|
+
r = run_migrations_payload
|
|
911
|
+
lines = []
|
|
912
|
+
lines << "Applied: #{Array(r[:applied]).join(', ')}" unless Array(r[:applied]).empty?
|
|
913
|
+
lines << "Skipped: #{Array(r[:skipped]).join(', ')}" unless Array(r[:skipped]).empty?
|
|
914
|
+
failed_names = Array(r[:failed]).map { |f| f.is_a?(Hash) ? f[:name] : f }
|
|
915
|
+
lines << "Failed: #{failed_names.join(', ')}" unless failed_names.empty?
|
|
916
|
+
lines << "No pending migrations" if lines.empty? && r[:error].nil?
|
|
917
|
+
lines << "Error: #{r[:error]}" if r[:error]
|
|
918
|
+
{ tool: tool, output: lines.join("\n"), result: r }
|
|
919
|
+
when "seed"
|
|
920
|
+
r = run_seeds_payload
|
|
921
|
+
{ tool: tool, output: "Seeded #{r[:seeded]} file(s), #{r[:failed]} failed", result: r }
|
|
922
|
+
else
|
|
923
|
+
{ tool: tool, output: "Unknown tool: #{tool}" }
|
|
924
|
+
end
|
|
925
|
+
end
|
|
926
|
+
|
|
927
|
+
# ── Run-chip implementations (Tier 1) ──────────────────────────
|
|
928
|
+
|
|
929
|
+
# POST /__dev/api/migrate — run pending migrations via the REAL
|
|
930
|
+
# Tina4::Migration runner (the same path `tina4ruby migrate` uses).
|
|
931
|
+
# `skipped` are pending files that never ran because an earlier
|
|
932
|
+
# migration in the batch failed (the runner stops on first failure).
|
|
933
|
+
def run_migrations_payload
|
|
934
|
+
db = Tina4.database
|
|
935
|
+
return { applied: [], skipped: [], failed: [], error: "No database configured" } unless db
|
|
936
|
+
|
|
937
|
+
require_relative "migration"
|
|
938
|
+
migration = Tina4::Migration.new(db)
|
|
939
|
+
pending_before = (migration.status[:pending] rescue [])
|
|
940
|
+
results = Array(migration.migrate)
|
|
941
|
+
|
|
942
|
+
applied = results.select { |r| r[:status] == "success" }.map { |r| r[:name] }
|
|
943
|
+
failed = results.select { |r| r[:status] == "failed" }
|
|
944
|
+
.map { |r| { name: r[:name], error: r[:error] } }
|
|
945
|
+
handled = results.map { |r| r[:name] }
|
|
946
|
+
skipped = pending_before - handled
|
|
947
|
+
{ applied: applied, skipped: skipped, failed: failed }
|
|
948
|
+
rescue => e
|
|
949
|
+
{ applied: [], skipped: [], failed: [], error: e.message }
|
|
950
|
+
end
|
|
951
|
+
|
|
952
|
+
# POST /__dev/api/test — run the project RSpec suite. Prefers
|
|
953
|
+
# `bundle exec rspec` when a Gemfile + bundler are present.
|
|
954
|
+
def run_tests_payload
|
|
955
|
+
root = Dir.pwd
|
|
956
|
+
runner = if File.exist?(File.join(root, "Gemfile")) &&
|
|
957
|
+
system("command -v bundle > /dev/null 2>&1")
|
|
958
|
+
"bundle exec rspec --no-color"
|
|
892
959
|
else
|
|
893
|
-
"
|
|
960
|
+
"rspec --no-color"
|
|
894
961
|
end
|
|
895
|
-
|
|
962
|
+
output = `cd #{Shellwords.escape(root)} && #{runner} 2>&1`
|
|
963
|
+
code = $?.exitstatus.to_i
|
|
964
|
+
{ ok: code.zero?, code: code, output: output.strip }
|
|
965
|
+
rescue => e
|
|
966
|
+
{ ok: false, code: -1, output: e.message }
|
|
967
|
+
end
|
|
968
|
+
|
|
969
|
+
# POST /__dev/api/seed/run — run every seed file in seeds/ for real
|
|
970
|
+
# (each file `load`ed in the project context, exactly like `tina4ruby
|
|
971
|
+
# seed`). Counts files that ran vs. files that raised.
|
|
972
|
+
def run_seeds_payload
|
|
973
|
+
seed_folder = File.join(Dir.pwd, "seeds")
|
|
974
|
+
return { seeded: 0, failed: 0, error: "No seeds folder" } unless Dir.exist?(seed_folder)
|
|
975
|
+
|
|
976
|
+
files = Dir.glob(File.join(seed_folder, "*.rb")).sort
|
|
977
|
+
.reject { |f| File.basename(f).start_with?("_") }
|
|
978
|
+
seeded = 0
|
|
979
|
+
failed = 0
|
|
980
|
+
files.each do |filepath|
|
|
981
|
+
begin
|
|
982
|
+
load filepath
|
|
983
|
+
seeded += 1
|
|
984
|
+
rescue StandardError => e
|
|
985
|
+
failed += 1
|
|
986
|
+
Tina4::Log.error("Seed failed: #{File.basename(filepath)} - #{e.message}") if defined?(Tina4::Log)
|
|
987
|
+
end
|
|
988
|
+
end
|
|
989
|
+
{ seeded: seeded, failed: failed }
|
|
990
|
+
rescue => e
|
|
991
|
+
{ seeded: 0, failed: 0, error: e.message }
|
|
992
|
+
end
|
|
993
|
+
|
|
994
|
+
# ── Grounding panel (Tier 2) ───────────────────────────────────
|
|
995
|
+
|
|
996
|
+
# Resolve an env value: live process ENV first (a just-saved token is
|
|
997
|
+
# reflected there), then the project .env. Returns nil when unset.
|
|
998
|
+
def grounding_env_value(key)
|
|
999
|
+
live = ENV[key]
|
|
1000
|
+
return live unless live.nil? || live.empty?
|
|
1001
|
+
|
|
1002
|
+
env_path = File.join(Dir.pwd, ".env")
|
|
1003
|
+
return nil unless File.file?(env_path)
|
|
1004
|
+
|
|
1005
|
+
File.readlines(env_path).each do |line|
|
|
1006
|
+
line = line.strip
|
|
1007
|
+
next if line.empty? || line.start_with?("#") || !line.include?("=")
|
|
1008
|
+
k, v = line.split("=", 2)
|
|
1009
|
+
next unless k.strip == key
|
|
1010
|
+
return (v || "").strip.gsub(/\A["']|["']\z/, "")
|
|
1011
|
+
end
|
|
1012
|
+
nil
|
|
1013
|
+
end
|
|
1014
|
+
|
|
1015
|
+
# GET /__dev/api/grounding/status
|
|
1016
|
+
def grounding_status_payload
|
|
1017
|
+
token = grounding_env_value("TINA4_MCP_TOKEN").to_s
|
|
1018
|
+
url = grounding_env_value("TINA4_MCP_URL").to_s
|
|
1019
|
+
url = "https://mcp.tina4.com" if url.empty?
|
|
1020
|
+
configured = !token.empty?
|
|
1021
|
+
{ configured: configured, last4: configured ? token[-4..].to_s : "", url: url }
|
|
1022
|
+
end
|
|
1023
|
+
|
|
1024
|
+
# POST /__dev/api/grounding/token — upsert TINA4_MCP_TOKEN into the
|
|
1025
|
+
# project .env (empty token clears it). Reflects into the live process
|
|
1026
|
+
# ENV so a subsequent status read returns the new state immediately.
|
|
1027
|
+
def grounding_token_save(body)
|
|
1028
|
+
token = (body && body["token"]).to_s.strip
|
|
1029
|
+
env_path = File.join(Dir.pwd, ".env")
|
|
1030
|
+
lines = File.file?(env_path) ? File.readlines(env_path, chomp: true) : []
|
|
1031
|
+
found = false
|
|
1032
|
+
new_lines = lines.map do |line|
|
|
1033
|
+
stripped = line.strip
|
|
1034
|
+
if !stripped.empty? && !stripped.start_with?("#") && stripped.include?("=") &&
|
|
1035
|
+
stripped.split("=", 2).first.strip == "TINA4_MCP_TOKEN"
|
|
1036
|
+
found = true
|
|
1037
|
+
"TINA4_MCP_TOKEN=#{token}"
|
|
1038
|
+
else
|
|
1039
|
+
line
|
|
1040
|
+
end
|
|
1041
|
+
end
|
|
1042
|
+
new_lines << "TINA4_MCP_TOKEN=#{token}" unless found
|
|
1043
|
+
File.write(env_path, new_lines.join("\n") + "\n")
|
|
1044
|
+
|
|
1045
|
+
if token.empty?
|
|
1046
|
+
ENV.delete("TINA4_MCP_TOKEN")
|
|
1047
|
+
else
|
|
1048
|
+
ENV["TINA4_MCP_TOKEN"] = token
|
|
1049
|
+
end
|
|
1050
|
+
configured = !token.empty?
|
|
1051
|
+
{ ok: true, configured: configured, last4: configured ? token[-4..].to_s : "" }
|
|
1052
|
+
rescue => e
|
|
1053
|
+
{ ok: false, error: e.message }
|
|
1054
|
+
end
|
|
1055
|
+
|
|
1056
|
+
# ── Queue run-chips (Tier 3) ───────────────────────────────────
|
|
1057
|
+
|
|
1058
|
+
# A file-backed dev Queue for +topic+ (matches the GET /queue handler).
|
|
1059
|
+
def dev_queue(topic)
|
|
1060
|
+
require_relative "queue" unless defined?(Tina4::Queue)
|
|
1061
|
+
Tina4::Queue.new(backend: :file, topic: topic)
|
|
1062
|
+
end
|
|
1063
|
+
|
|
1064
|
+
# POST /__dev/api/queue/retry — revive failed/dead-letter jobs back to
|
|
1065
|
+
# pending (real mutation via Queue#retry_failed).
|
|
1066
|
+
def queue_retry(body)
|
|
1067
|
+
topic = (body["topic"] || "default").to_s
|
|
1068
|
+
queue = dev_queue(topic)
|
|
1069
|
+
retried = queue.respond_to?(:retry_failed) ? queue.retry_failed : 0
|
|
1070
|
+
{ retried: retried, topic: topic }
|
|
1071
|
+
rescue => e
|
|
1072
|
+
{ retried: 0, error: e.message }
|
|
1073
|
+
end
|
|
1074
|
+
|
|
1075
|
+
# POST /__dev/api/queue/purge — delete jobs by status (default
|
|
1076
|
+
# "completed"). Pass {"status": "pending"} to drop queued jobs.
|
|
1077
|
+
# Real mutation via Queue#purge; returns the number removed.
|
|
1078
|
+
def queue_purge(body)
|
|
1079
|
+
topic = (body["topic"] || "default").to_s
|
|
1080
|
+
status = (body["status"] || "completed").to_s
|
|
1081
|
+
queue = dev_queue(topic)
|
|
1082
|
+
removed = queue.respond_to?(:purge) ? queue.purge(status) : 0
|
|
1083
|
+
{ purged: true, removed: removed, status: status, topic: topic }
|
|
1084
|
+
rescue => e
|
|
1085
|
+
{ purged: false, error: e.message }
|
|
1086
|
+
end
|
|
1087
|
+
|
|
1088
|
+
# POST /__dev/api/queue/replay — re-queue a specific job by id from the
|
|
1089
|
+
# dead-letter/failed store (real mutation via Queue#retry(job_id)).
|
|
1090
|
+
def queue_replay(body)
|
|
1091
|
+
topic = (body["topic"] || "default").to_s
|
|
1092
|
+
job_id = body["id"]
|
|
1093
|
+
return { replayed: false, error: "Missing job id" } if job_id.nil? || job_id.to_s.empty?
|
|
1094
|
+
|
|
1095
|
+
delay = (body["delay"] || 0).to_i
|
|
1096
|
+
queue = dev_queue(topic)
|
|
1097
|
+
result = queue.respond_to?(:retry) ? queue.retry(job_id, delay_seconds: delay) : false
|
|
1098
|
+
{ replayed: result, id: job_id, topic: topic }
|
|
1099
|
+
rescue => e
|
|
1100
|
+
{ replayed: false, error: e.message }
|
|
1101
|
+
end
|
|
1102
|
+
|
|
1103
|
+
# ── WebSocket introspection (Tier 3) ───────────────────────────
|
|
1104
|
+
|
|
1105
|
+
# The live WebSocket managers to introspect. In tina4-ruby app routes
|
|
1106
|
+
# share the process-wide engine (Tina4::WebSocket.current) while the
|
|
1107
|
+
# dev-reload channel keeps its own manager (Tina4::DevReload) — both
|
|
1108
|
+
# hold real, live connections, so the panel enumerates both.
|
|
1109
|
+
def ws_managers
|
|
1110
|
+
managers = []
|
|
1111
|
+
if defined?(Tina4::WebSocket) && Tina4::WebSocket.current
|
|
1112
|
+
managers << Tina4::WebSocket.current
|
|
1113
|
+
end
|
|
1114
|
+
managers << Tina4::DevReload.manager if defined?(Tina4::DevReload)
|
|
1115
|
+
managers.compact
|
|
1116
|
+
end
|
|
1117
|
+
|
|
1118
|
+
# GET /__dev/api/websockets — enumerate real live connections.
|
|
1119
|
+
def websockets_payload
|
|
1120
|
+
seen = {}
|
|
1121
|
+
ws_managers.each do |mgr|
|
|
1122
|
+
conns = mgr.respond_to?(:connections) ? mgr.connections : {}
|
|
1123
|
+
conns.each do |id, conn|
|
|
1124
|
+
next if seen.key?(id)
|
|
1125
|
+
seen[id] = {
|
|
1126
|
+
id: id.to_s,
|
|
1127
|
+
path: (conn.respond_to?(:path) ? conn.path : "/").to_s,
|
|
1128
|
+
ip: ws_conn_ip(conn),
|
|
1129
|
+
rooms: (conn.respond_to?(:rooms) ? conn.rooms.to_a : []),
|
|
1130
|
+
last_activity: (conn.respond_to?(:last_activity) ? conn.last_activity : nil),
|
|
1131
|
+
closed: (conn.respond_to?(:closed?) ? conn.closed? : false),
|
|
1132
|
+
}
|
|
1133
|
+
end
|
|
1134
|
+
end
|
|
1135
|
+
list = seen.values
|
|
1136
|
+
{ connections: list, count: list.size }
|
|
1137
|
+
rescue => e
|
|
1138
|
+
{ connections: [], count: 0, error: e.message }
|
|
1139
|
+
end
|
|
1140
|
+
|
|
1141
|
+
# Best-effort peer IP off the connection params (nil-safe).
|
|
1142
|
+
def ws_conn_ip(conn)
|
|
1143
|
+
return "" unless conn.respond_to?(:params) && conn.params
|
|
1144
|
+
(conn.params["ip"] || conn.params[:ip] || "").to_s
|
|
1145
|
+
end
|
|
1146
|
+
|
|
1147
|
+
# POST /__dev/api/websockets/disconnect — actually close + unregister
|
|
1148
|
+
# the given connection id across the live managers.
|
|
1149
|
+
def websockets_disconnect(body)
|
|
1150
|
+
id = body["id"]
|
|
1151
|
+
return { disconnected: false, error: "id required" } if id.nil? || id.to_s.empty?
|
|
1152
|
+
|
|
1153
|
+
disconnected = false
|
|
1154
|
+
ws_managers.each do |mgr|
|
|
1155
|
+
conns = mgr.respond_to?(:connections) ? mgr.connections : {}
|
|
1156
|
+
next unless conns.key?(id)
|
|
1157
|
+
|
|
1158
|
+
begin
|
|
1159
|
+
mgr.close(id) if mgr.respond_to?(:close)
|
|
1160
|
+
rescue StandardError
|
|
1161
|
+
# closing a half-dead socket must not abort the unregister below
|
|
1162
|
+
end
|
|
1163
|
+
if mgr.respond_to?(:unregister_connection)
|
|
1164
|
+
mgr.unregister_connection(id)
|
|
1165
|
+
else
|
|
1166
|
+
conns.delete(id)
|
|
1167
|
+
end
|
|
1168
|
+
disconnected = true
|
|
1169
|
+
end
|
|
1170
|
+
{ disconnected: disconnected, id: id }
|
|
1171
|
+
rescue => e
|
|
1172
|
+
{ disconnected: false, error: e.message }
|
|
896
1173
|
end
|
|
897
1174
|
|
|
898
1175
|
def run_query(sql)
|
|
@@ -1590,14 +1867,29 @@ module Tina4
|
|
|
1590
1867
|
def deps_install(body)
|
|
1591
1868
|
name = body["name"].to_s.strip
|
|
1592
1869
|
return { ok: false, error: "name required" } if name.empty?
|
|
1593
|
-
|
|
1870
|
+
version = body["version"].to_s.strip
|
|
1871
|
+
dev = [true, "true", 1, "1"].include?(body["dev"])
|
|
1594
1872
|
gemfile = File.join(Dir.pwd, "Gemfile")
|
|
1595
1873
|
return { ok: false, error: "No Gemfile at project root" } unless File.exist?(gemfile)
|
|
1874
|
+
|
|
1875
|
+
# Prefer `bundle add` — it updates the Gemfile AND installs the gem, so a
|
|
1876
|
+
# dev dep is deployed (not just recorded). Fall back to appending to the
|
|
1877
|
+
# Gemfile when bundler isn't available.
|
|
1878
|
+
if system("command -v bundle > /dev/null 2>&1")
|
|
1879
|
+
cmd = ["bundle", "add", name]
|
|
1880
|
+
cmd += ["--version", version] unless version.empty?
|
|
1881
|
+
cmd += ["--group", "development"] if dev
|
|
1882
|
+
out = `#{cmd.map { |c| Shellwords.escape(c) }.join(' ')} 2>&1`
|
|
1883
|
+
return { ok: true, gem: name, output: out.strip } if $?.success?
|
|
1884
|
+
return { ok: false, error: out.strip }
|
|
1885
|
+
end
|
|
1886
|
+
|
|
1596
1887
|
content = File.read(gemfile)
|
|
1597
1888
|
if content.include?("gem \"#{name}\"") || content.include?("gem '#{name}'")
|
|
1598
1889
|
return { ok: true, gem: name, note: "already in Gemfile" }
|
|
1599
1890
|
end
|
|
1600
|
-
|
|
1891
|
+
entry = dev ? "\ngroup :development do\n gem \"#{name}\"\nend\n" : "\ngem \"#{name}\"\n"
|
|
1892
|
+
File.open(gemfile, "a") { |f| f.write(entry) }
|
|
1601
1893
|
{ ok: true, gem: name, note: "added to Gemfile; run `bundle install`" }
|
|
1602
1894
|
end
|
|
1603
1895
|
|
data/lib/tina4/frond.rb
CHANGED
|
@@ -93,6 +93,19 @@ module Tina4
|
|
|
93
93
|
HASH_PAIR_RE = /\A\s*(?:["']([^"']+)["']|(\w+))\s*:\s*(.+)\z/
|
|
94
94
|
RANGE_LIT_RE = /\A(\d+)\.\.(\d+)\z/
|
|
95
95
|
ARITHMETIC_OPS = [" + ", " - ", " * ", " // ", " / ", " % ", " ** "].freeze
|
|
96
|
+
# Operators Twig binds LOOSER than the filter pipe `|`. When one of these
|
|
97
|
+
# sits at the top level alongside a pipe (e.g. `amount|number_format(2) ~
|
|
98
|
+
# ' EUR'`), the whole expression must go through eval_expr (which resolves
|
|
99
|
+
# the pipe at its correct, tighter precedence) instead of being split on
|
|
100
|
+
# the pipe as a plain filter chain. Detection is quote/paren-aware
|
|
101
|
+
# (find_outside_quotes) so operator-like text inside a string or filter
|
|
102
|
+
# args never false-triggers.
|
|
103
|
+
LOOSER_THAN_PIPE_OPS = [
|
|
104
|
+
"~", "??", " if ",
|
|
105
|
+
" not in ", " in ", " is not ", " is ", "!=", "==", ">=", "<=", ">", "<",
|
|
106
|
+
" and ", " or ", " not ",
|
|
107
|
+
" + ", " - ", " * ", " // ", " / ", " % ", " ** "
|
|
108
|
+
].freeze
|
|
96
109
|
FUNC_CALL_RE = /\A(\w+)\s*\((.*)\)\z/m
|
|
97
110
|
FILTER_WITH_ARGS_RE = /\A(\w+)\s*\((.*)\)\z/m
|
|
98
111
|
FILTER_CMP_RE = /\A(\w+)\s*(!=|==|>=|<=|>|<)\s*(.+)\z/
|
|
@@ -673,6 +686,12 @@ module Tina4
|
|
|
673
686
|
filters.each do |fname, args|
|
|
674
687
|
next if fname == "raw" || fname == "safe"
|
|
675
688
|
|
|
689
|
+
# Sandbox: a blocked filter is silently skipped (value passes through
|
|
690
|
+
# unchanged) — same gate as eval_var_inner. Without this, a filter pipe
|
|
691
|
+
# reached via eval_filter_pipe (`x|f ~ y`, #171) or a ternary condition
|
|
692
|
+
# (`x|f ? a : b`) would run a non-allow-listed filter in sandbox mode.
|
|
693
|
+
next if @sandbox && @allowed_filters && !@allowed_filters.include?(fname)
|
|
694
|
+
|
|
676
695
|
# Filter + property-access chain: `first.groupSummary` — apply
|
|
677
696
|
# the filter, then traverse the path on the result using a
|
|
678
697
|
# synthetic context so eval_expr's dotted resolution does the
|
|
@@ -723,6 +742,20 @@ module Tina4
|
|
|
723
742
|
end
|
|
724
743
|
|
|
725
744
|
def eval_var_inner(expr, context)
|
|
745
|
+
# Twig binds the filter pipe `|` TIGHTER than ~, comparison, and
|
|
746
|
+
# arithmetic. When a looser operator sits at the top level alongside a
|
|
747
|
+
# pipe (e.g. `amount|number_format(2) ~ ' EUR'`), splitting on the pipe
|
|
748
|
+
# here would swallow the tail (`~ ' EUR'`) into the filter args and drop
|
|
749
|
+
# it. Hand the whole expression to eval_expr — which resolves the pipe at
|
|
750
|
+
# the correct precedence (eval_filter_pipe) — then apply output escaping.
|
|
751
|
+
if find_outside_quotes(expr, "|") >= 0 && has_looser_than_pipe_operator?(expr)
|
|
752
|
+
value = eval_expr(expr, context)
|
|
753
|
+
if @auto_escape && value.is_a?(String) && !value.is_a?(SafeString)
|
|
754
|
+
value = Frond.escape_html(value)
|
|
755
|
+
end
|
|
756
|
+
return value
|
|
757
|
+
end
|
|
758
|
+
|
|
726
759
|
var_name, filters = parse_filter_chain(expr)
|
|
727
760
|
|
|
728
761
|
# Sandbox: check variable access
|
|
@@ -1068,12 +1101,37 @@ module Tina4
|
|
|
1068
1101
|
result = eval_arithmetic(expr, context)
|
|
1069
1102
|
return result unless result == :not_arithmetic
|
|
1070
1103
|
|
|
1104
|
+
result = eval_filter_pipe(expr, context)
|
|
1105
|
+
return result unless result == :not_filter_pipe
|
|
1106
|
+
|
|
1071
1107
|
result = eval_function_call(expr, context)
|
|
1072
1108
|
return result unless result == :not_function
|
|
1073
1109
|
|
|
1074
1110
|
resolve(expr, context)
|
|
1075
1111
|
end
|
|
1076
1112
|
|
|
1113
|
+
# ── Filter pipe: value|filter(args) ──
|
|
1114
|
+
# Reached only after every looser-binding operator (concat ~, comparison,
|
|
1115
|
+
# arithmetic, ternary, ...) has been ruled out at this level, so the pipe
|
|
1116
|
+
# binds tighter than all of them — matching Twig. This is what makes
|
|
1117
|
+
# `amount|number_format(2) ~ ' EUR'` resolve as `(amount|number_format(2))
|
|
1118
|
+
# ~ ' EUR'`, and it lets a pipe inside parens / a sub-expression resolve
|
|
1119
|
+
# (previously such a pipe fell through to `resolve` and returned empty).
|
|
1120
|
+
# Delegates to eval_var_raw, which applies the filter chain WITHOUT output
|
|
1121
|
+
# escaping (escaping happens once, at the output layer, on the final value).
|
|
1122
|
+
def eval_filter_pipe(expr, context)
|
|
1123
|
+
return :not_filter_pipe unless find_outside_quotes(expr, "|") >= 0
|
|
1124
|
+
|
|
1125
|
+
eval_var_raw(expr, context)
|
|
1126
|
+
end
|
|
1127
|
+
|
|
1128
|
+
# True when the expression carries a top-level operator that Twig binds
|
|
1129
|
+
# looser than the filter pipe. Quote/paren-aware via find_outside_quotes so
|
|
1130
|
+
# operator-like text inside a string literal or filter args never matches.
|
|
1131
|
+
def has_looser_than_pipe_operator?(expr)
|
|
1132
|
+
LOOSER_THAN_PIPE_OPS.any? { |op| find_outside_quotes(expr, op) >= 0 }
|
|
1133
|
+
end
|
|
1134
|
+
|
|
1077
1135
|
# ── Literal values: strings, numbers, booleans, null ──
|
|
1078
1136
|
|
|
1079
1137
|
def eval_literal(expr)
|
|
@@ -2172,12 +2230,17 @@ module Tina4
|
|
|
2172
2230
|
"int" => ->(v, *_a) { v.to_i },
|
|
2173
2231
|
"float" => ->(v, *_a) { v.to_f },
|
|
2174
2232
|
"number_format" => ->(v, *a) {
|
|
2175
|
-
|
|
2233
|
+
# Twig signature: number_format(decimals, decimalPoint, thousandsSep).
|
|
2234
|
+
# The defaults reproduce the historical output ("1,234.50") so a
|
|
2235
|
+
# 1-arg call is unchanged; args 2 and 3 apply the localized
|
|
2236
|
+
# separators (e.g. number_format(2, ',', '.') -> "1.234,50").
|
|
2237
|
+
decimals = a[0] ? a[0].to_i : 0
|
|
2238
|
+
decimal_point = a[1].nil? ? "." : a[1].to_s
|
|
2239
|
+
thousands_sep = a[2].nil? ? "," : a[2].to_s
|
|
2176
2240
|
formatted = format("%.#{decimals}f", v.to_f)
|
|
2177
|
-
|
|
2178
|
-
|
|
2179
|
-
|
|
2180
|
-
parts.join(".")
|
|
2241
|
+
int_part, frac_part = formatted.split(".")
|
|
2242
|
+
int_part = int_part.gsub(THOUSANDS_RE) { |digit| "#{digit}#{thousands_sep}" }
|
|
2243
|
+
frac_part ? "#{int_part}#{decimal_point}#{frac_part}" : int_part
|
|
2181
2244
|
},
|
|
2182
2245
|
|
|
2183
2246
|
# -- Date --
|