tina4ruby 3.11.35 → 3.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/lib/tina4/auth.rb +5 -5
- data/lib/tina4/background.rb +81 -0
- data/lib/tina4/constants.rb +40 -0
- data/lib/tina4/container.rb +1 -1
- data/lib/tina4/database.rb +37 -10
- data/lib/tina4/dev_admin.rb +464 -2
- data/lib/tina4/docs.rb +636 -0
- data/lib/tina4/drivers/postgres_driver.rb +38 -4
- data/lib/tina4/env.rb +74 -3
- data/lib/tina4/field_types.rb +1 -1
- data/lib/tina4/frond.rb +62 -0
- data/lib/tina4/mcp.rb +191 -1
- data/lib/tina4/messenger.rb +13 -14
- data/lib/tina4/orm.rb +85 -12
- data/lib/tina4/plan.rb +471 -0
- data/lib/tina4/project_index.rb +366 -0
- data/lib/tina4/public/js/frond.js +600 -0
- data/lib/tina4/public/js/frond.min.js +1 -1
- data/lib/tina4/public/js/tina4-dev-admin.js +1086 -238
- data/lib/tina4/public/js/tina4-dev-admin.min.js +1142 -209
- data/lib/tina4/rack_app.rb +98 -16
- data/lib/tina4/response.rb +3 -0
- data/lib/tina4/session.rb +1 -1
- data/lib/tina4/session_handlers/database_handler.rb +1 -1
- data/lib/tina4/shutdown.rb +10 -0
- data/lib/tina4/swagger.rb +3 -3
- data/lib/tina4/version.rb +1 -1
- data/lib/tina4/webserver.rb +3 -0
- data/lib/tina4.rb +15 -1
- metadata +6 -1
data/lib/tina4/env.rb
CHANGED
|
@@ -2,14 +2,85 @@
|
|
|
2
2
|
require "digest"
|
|
3
3
|
|
|
4
4
|
module Tina4
|
|
5
|
+
# Legacy env var names that v3.12 has retired. If any of these are set in
|
|
6
|
+
# the environment we refuse to boot — silently ignoring them would cause
|
|
7
|
+
# auth/db/mail to fall back to defaults with no warning. Each maps to its
|
|
8
|
+
# new TINA4_-prefixed canonical name.
|
|
9
|
+
LEGACY_ENV_VARS = {
|
|
10
|
+
"DATABASE_URL" => "TINA4_DATABASE_URL",
|
|
11
|
+
"DATABASE_USERNAME" => "TINA4_DATABASE_USERNAME",
|
|
12
|
+
"DATABASE_PASSWORD" => "TINA4_DATABASE_PASSWORD",
|
|
13
|
+
"DB_URL" => "TINA4_DATABASE_URL",
|
|
14
|
+
"SECRET" => "TINA4_SECRET",
|
|
15
|
+
"API_KEY" => "TINA4_API_KEY",
|
|
16
|
+
"JWT_ALGORITHM" => "TINA4_JWT_ALGORITHM",
|
|
17
|
+
"SMTP_HOST" => "TINA4_MAIL_HOST",
|
|
18
|
+
"SMTP_PORT" => "TINA4_MAIL_PORT",
|
|
19
|
+
"SMTP_USERNAME" => "TINA4_MAIL_USERNAME",
|
|
20
|
+
"SMTP_PASSWORD" => "TINA4_MAIL_PASSWORD",
|
|
21
|
+
"SMTP_FROM" => "TINA4_MAIL_FROM",
|
|
22
|
+
"SMTP_FROM_NAME" => "TINA4_MAIL_FROM_NAME",
|
|
23
|
+
"IMAP_HOST" => "TINA4_MAIL_IMAP_HOST",
|
|
24
|
+
"IMAP_PORT" => "TINA4_MAIL_IMAP_PORT",
|
|
25
|
+
"IMAP_USER" => "TINA4_MAIL_IMAP_USERNAME",
|
|
26
|
+
"IMAP_PASS" => "TINA4_MAIL_IMAP_PASSWORD",
|
|
27
|
+
"HOST_NAME" => "TINA4_HOST_NAME",
|
|
28
|
+
"SWAGGER_TITLE" => "TINA4_SWAGGER_TITLE",
|
|
29
|
+
"SWAGGER_DESCRIPTION" => "TINA4_SWAGGER_DESCRIPTION",
|
|
30
|
+
"SWAGGER_VERSION" => "TINA4_SWAGGER_VERSION",
|
|
31
|
+
"ORM_PLURAL_TABLE_NAMES" => "TINA4_ORM_PLURAL_TABLE_NAMES"
|
|
32
|
+
}.freeze
|
|
33
|
+
|
|
34
|
+
# Raised by check_legacy_env_vars! when the caller opts out of process exit.
|
|
35
|
+
class LegacyEnvError < StandardError; end
|
|
36
|
+
|
|
37
|
+
# Refuse to boot if pre-3.12 un-prefixed env vars are still set.
|
|
38
|
+
#
|
|
39
|
+
# Tina4 v3.12 hard-renamed every framework-specific env var to use the
|
|
40
|
+
# TINA4_ prefix. Booting silently with a legacy DATABASE_URL or SECRET
|
|
41
|
+
# would let auth, DB, or mail fall back to insecure defaults while the
|
|
42
|
+
# user thought their config was being read. Better to die loudly with a
|
|
43
|
+
# list of names to fix.
|
|
44
|
+
#
|
|
45
|
+
# Bypass with TINA4_ALLOW_LEGACY_ENV=true in CI / migration scripts that
|
|
46
|
+
# genuinely need both names set during a transition window.
|
|
47
|
+
def self.check_legacy_env_vars!(io: $stderr, exit_on_error: true)
|
|
48
|
+
bypass = ENV["TINA4_ALLOW_LEGACY_ENV"].to_s.downcase
|
|
49
|
+
return if %w[true 1 yes].include?(bypass)
|
|
50
|
+
|
|
51
|
+
found = LEGACY_ENV_VARS.keys.select { |name| ENV.key?(name) }.sort
|
|
52
|
+
return if found.empty?
|
|
53
|
+
|
|
54
|
+
sep = "─" * 72
|
|
55
|
+
lines = ["", sep,
|
|
56
|
+
"Tina4 v3.12 requires TINA4_ prefix on all framework env vars.",
|
|
57
|
+
"Your environment still has these legacy names:",
|
|
58
|
+
""]
|
|
59
|
+
found.each do |old|
|
|
60
|
+
new_name = LEGACY_ENV_VARS[old]
|
|
61
|
+
lines << format(" %-28s → %s", old, new_name)
|
|
62
|
+
end
|
|
63
|
+
lines.concat([
|
|
64
|
+
"",
|
|
65
|
+
"Run `tina4 env-migrate` to rewrite your .env automatically,",
|
|
66
|
+
"or rename manually. See https://tina4.com/release/3.12.0",
|
|
67
|
+
"Set TINA4_ALLOW_LEGACY_ENV=true to bypass during migration.",
|
|
68
|
+
sep, ""
|
|
69
|
+
])
|
|
70
|
+
io.puts lines.join("\n")
|
|
71
|
+
raise LegacyEnvError, "Legacy env vars present: #{found.join(', ')}" unless exit_on_error
|
|
72
|
+
|
|
73
|
+
exit(2)
|
|
74
|
+
end
|
|
75
|
+
|
|
5
76
|
module Env
|
|
6
77
|
DEFAULT_ENV = {
|
|
7
78
|
"PROJECT_NAME" => "Tina4 Ruby Project",
|
|
8
|
-
"
|
|
79
|
+
"TINA4_SWAGGER_VERSION" => "1.0.0",
|
|
9
80
|
"TINA4_LOCALE" => "en",
|
|
10
81
|
"TINA4_DEBUG" => "true",
|
|
11
82
|
"TINA4_LOG_LEVEL" => "[TINA4_LOG_ALL]",
|
|
12
|
-
"
|
|
83
|
+
"TINA4_SECRET" => "tina4-secret-change-me"
|
|
13
84
|
}.freeze
|
|
14
85
|
|
|
15
86
|
# Check if a value is truthy for env boolean checks.
|
|
@@ -72,7 +143,7 @@ module Tina4
|
|
|
72
143
|
def create_default_env(path)
|
|
73
144
|
api_key = Digest::MD5.hexdigest(Time.now.to_s)
|
|
74
145
|
content = DEFAULT_ENV.map { |k, v| "#{k}=\"#{v}\"" }.join("\n")
|
|
75
|
-
content += "\
|
|
146
|
+
content += "\nTINA4_API_KEY=\"#{api_key}\"\n"
|
|
76
147
|
File.write(path, content)
|
|
77
148
|
end
|
|
78
149
|
|
data/lib/tina4/field_types.rb
CHANGED
|
@@ -21,7 +21,7 @@ module Tina4
|
|
|
21
21
|
else
|
|
22
22
|
base = self.name.split("::").last.downcase
|
|
23
23
|
# Pluralize by default (add "s") unless ORM_PLURAL_TABLE_NAMES is explicitly disabled
|
|
24
|
-
unless ENV.fetch("
|
|
24
|
+
unless ENV.fetch("TINA4_ORM_PLURAL_TABLE_NAMES", "").match?(/\A(false|0|no)\z/i)
|
|
25
25
|
base += "s" unless base.end_with?("s")
|
|
26
26
|
end
|
|
27
27
|
@table_name || base
|
data/lib/tina4/frond.rb
CHANGED
|
@@ -548,6 +548,20 @@ module Tina4
|
|
|
548
548
|
value = eval_expr(var_name, context)
|
|
549
549
|
filters.each do |fname, args|
|
|
550
550
|
next if fname == "raw" || fname == "safe"
|
|
551
|
+
|
|
552
|
+
# Filter + property-access chain: `first.groupSummary` — apply
|
|
553
|
+
# the filter, then traverse the path on the result using a
|
|
554
|
+
# synthetic context so eval_expr's dotted resolution does the
|
|
555
|
+
# work. Parity with tina4-python + tina4-php.
|
|
556
|
+
real_fname, tail_path = split_filter_name_and_path(fname)
|
|
557
|
+
if !tail_path.empty? && @filters[real_fname]
|
|
558
|
+
evaluated_args = args.map { |a| eval_filter_arg(a, context) }
|
|
559
|
+
value = @filters[real_fname].call(value, *evaluated_args)
|
|
560
|
+
value = eval_expr("__frond_filter_tmp.#{tail_path}",
|
|
561
|
+
{ "__frond_filter_tmp" => value })
|
|
562
|
+
next
|
|
563
|
+
end
|
|
564
|
+
|
|
551
565
|
fn = @filters[fname]
|
|
552
566
|
if fn
|
|
553
567
|
evaluated_args = args.map { |a| eval_filter_arg(a, context) }
|
|
@@ -611,6 +625,19 @@ module Tina4
|
|
|
611
625
|
next
|
|
612
626
|
end
|
|
613
627
|
|
|
628
|
+
# Filter + property-access chain: `first.groupSummary` — apply
|
|
629
|
+
# the filter, then traverse the path on the result. Done BEFORE
|
|
630
|
+
# the inline fast-path so cases like `items|first.name` work
|
|
631
|
+
# regardless of whether `first` is an inline filter too.
|
|
632
|
+
real_fname, tail_path = split_filter_name_and_path(fname)
|
|
633
|
+
if !tail_path.empty? && @filters[real_fname]
|
|
634
|
+
evaluated_args = args.map { |a| eval_filter_arg(a, context) }
|
|
635
|
+
value = @filters[real_fname].call(value, *evaluated_args)
|
|
636
|
+
value = eval_expr("__frond_filter_tmp.#{tail_path}",
|
|
637
|
+
{ "__frond_filter_tmp" => value })
|
|
638
|
+
next
|
|
639
|
+
end
|
|
640
|
+
|
|
614
641
|
# Inline common no-arg filters for speed (skip generic dispatch)
|
|
615
642
|
if args.empty? && INLINE_FILTERS.include?(fname)
|
|
616
643
|
value = case fname
|
|
@@ -756,6 +783,41 @@ module Tina4
|
|
|
756
783
|
# Filter chain parser
|
|
757
784
|
# -----------------------------------------------------------------------
|
|
758
785
|
|
|
786
|
+
# Split "first.groupSummary" into ["first", "groupSummary"] so a
|
|
787
|
+
# filter segment followed by property access — `{{ x | first.name }}`
|
|
788
|
+
# — applies the filter then traverses the path on the result.
|
|
789
|
+
# Returns [fname, ""] when no structural dot is present.
|
|
790
|
+
#
|
|
791
|
+
# The split point must sit outside parens/brackets/braces and quotes
|
|
792
|
+
# so filter args like `round(1.5)` or `date("Y.m.d")` don't false-
|
|
793
|
+
# trigger. Parity with tina4-python and tina4-php.
|
|
794
|
+
def split_filter_name_and_path(fname)
|
|
795
|
+
depth = 0
|
|
796
|
+
in_q = nil
|
|
797
|
+
i = 0
|
|
798
|
+
n = fname.length
|
|
799
|
+
while i < n
|
|
800
|
+
ch = fname[i]
|
|
801
|
+
if in_q
|
|
802
|
+
in_q = nil if ch == in_q && (i.zero? || fname[i - 1] != "\\")
|
|
803
|
+
i += 1
|
|
804
|
+
next
|
|
805
|
+
end
|
|
806
|
+
case ch
|
|
807
|
+
when '"', "'"
|
|
808
|
+
in_q = ch
|
|
809
|
+
when "(", "[", "{"
|
|
810
|
+
depth += 1
|
|
811
|
+
when ")", "]", "}"
|
|
812
|
+
depth -= 1
|
|
813
|
+
when "."
|
|
814
|
+
return [fname[0...i], fname[(i + 1)..]] if depth.zero?
|
|
815
|
+
end
|
|
816
|
+
i += 1
|
|
817
|
+
end
|
|
818
|
+
[fname, ""]
|
|
819
|
+
end
|
|
820
|
+
|
|
759
821
|
def parse_filter_chain(expr)
|
|
760
822
|
cached = @filter_chain_cache[expr]
|
|
761
823
|
return cached if cached
|
data/lib/tina4/mcp.rb
CHANGED
|
@@ -132,7 +132,7 @@ module Tina4
|
|
|
132
132
|
|
|
133
133
|
# Check if the server is running on localhost.
|
|
134
134
|
def self.is_localhost?
|
|
135
|
-
host = ENV.fetch("
|
|
135
|
+
host = ENV.fetch("TINA4_HOST_NAME", "localhost:7145").split(":").first
|
|
136
136
|
["localhost", "127.0.0.1", "0.0.0.0", "::1", ""].include?(host)
|
|
137
137
|
end
|
|
138
138
|
|
|
@@ -680,6 +680,196 @@ module Tina4
|
|
|
680
680
|
end
|
|
681
681
|
}, "Seed a table with fake data")
|
|
682
682
|
|
|
683
|
+
# ── File patch ────────────────────────────────────
|
|
684
|
+
server.register_tool("file_patch", lambda { |path:, old_string:, new_string:, count: 1|
|
|
685
|
+
p = safe_path.call(path)
|
|
686
|
+
return { "error" => "File not found: #{path}" } unless File.file?(p)
|
|
687
|
+
original = File.read(p, encoding: "utf-8")
|
|
688
|
+
occurrences = original.scan(old_string).size
|
|
689
|
+
return { "error" => "old_string not found in #{path}" } if occurrences.zero?
|
|
690
|
+
if occurrences != count.to_i
|
|
691
|
+
return { "error" => "old_string appears #{occurrences} times, expected #{count}. Expand old_string to make it unique, or set count explicitly." }
|
|
692
|
+
end
|
|
693
|
+
updated = original.sub(old_string, new_string)
|
|
694
|
+
# Ruby String#sub replaces first; if count > 1, do N replacements
|
|
695
|
+
if count.to_i > 1
|
|
696
|
+
updated = original.dup
|
|
697
|
+
count.to_i.times { updated.sub!(old_string, new_string) }
|
|
698
|
+
end
|
|
699
|
+
File.write(p, updated, encoding: "utf-8")
|
|
700
|
+
rel = p.sub("#{project_root}/", "")
|
|
701
|
+
Tina4::Plan.record_action("patched", rel) if defined?(Tina4::Plan)
|
|
702
|
+
{ "patched" => rel, "replacements" => count.to_i, "bytes" => updated.bytesize }
|
|
703
|
+
}, "Targeted edit: replace old_string with new_string in a file")
|
|
704
|
+
|
|
705
|
+
# ── Docs tools ────────────────────────────────────
|
|
706
|
+
framework_doc_paths = lambda do
|
|
707
|
+
gem_root = File.expand_path("..", File.dirname(__FILE__))
|
|
708
|
+
candidates = [
|
|
709
|
+
File.join(gem_root, "..", "CLAUDE.md"),
|
|
710
|
+
File.join(gem_root, "..", "AGENTS.md"),
|
|
711
|
+
File.join(gem_root, "..", "CONVENTIONS.md"),
|
|
712
|
+
File.join(gem_root, "..", "README.md"),
|
|
713
|
+
File.join(Dir.pwd, "CLAUDE.md")
|
|
714
|
+
]
|
|
715
|
+
candidates.map { |p| File.expand_path(p) }.uniq.select { |p| File.file?(p) }
|
|
716
|
+
end
|
|
717
|
+
|
|
718
|
+
server.register_tool("docs_list", lambda {
|
|
719
|
+
framework_doc_paths.call.map { |p| { "name" => File.basename(p), "bytes" => File.size(p) } }
|
|
720
|
+
}, "List framework documentation files")
|
|
721
|
+
|
|
722
|
+
server.register_tool("docs_search", lambda { |query:, limit: 5, context_lines: 4|
|
|
723
|
+
return { "error" => "query must be at least 2 characters" } if query.to_s.length < 2
|
|
724
|
+
needle = query.to_s.downcase
|
|
725
|
+
hits = []
|
|
726
|
+
framework_doc_paths.call.each do |p|
|
|
727
|
+
begin
|
|
728
|
+
lines = File.read(p, encoding: "utf-8", invalid: :replace, undef: :replace).split("\n")
|
|
729
|
+
rescue StandardError
|
|
730
|
+
next
|
|
731
|
+
end
|
|
732
|
+
lines.each_with_index do |line, i|
|
|
733
|
+
next unless line.downcase.include?(needle)
|
|
734
|
+
start_i = [0, i - context_lines.to_i].max
|
|
735
|
+
end_i = [lines.size, i + context_lines.to_i + 1].min
|
|
736
|
+
score = 1
|
|
737
|
+
score += 1 if line.include?(query.to_s)
|
|
738
|
+
score += 2 if line.lstrip.start_with?("#")
|
|
739
|
+
hits << {
|
|
740
|
+
"file" => File.basename(p),
|
|
741
|
+
"line" => i + 1,
|
|
742
|
+
"score" => score,
|
|
743
|
+
"snippet" => lines[start_i...end_i].join("\n")
|
|
744
|
+
}
|
|
745
|
+
end
|
|
746
|
+
end
|
|
747
|
+
hits.sort_by! { |h| -h["score"] }
|
|
748
|
+
hits.first([1, limit.to_i].max)
|
|
749
|
+
}, "Search Tina4 framework docs for a query string")
|
|
750
|
+
|
|
751
|
+
server.register_tool("docs_section", lambda { |file:, heading:|
|
|
752
|
+
match = framework_doc_paths.call.find { |p| File.basename(p) == file }
|
|
753
|
+
return { "error" => "Unknown doc file: #{file}. Try docs_list() first." } unless match
|
|
754
|
+
text = File.read(match, encoding: "utf-8", invalid: :replace, undef: :replace)
|
|
755
|
+
lines = text.split("\n")
|
|
756
|
+
heading_lc = heading.to_s.downcase.strip
|
|
757
|
+
start_i = -1
|
|
758
|
+
start_level = 0
|
|
759
|
+
lines.each_with_index do |line, i|
|
|
760
|
+
stripped = line.lstrip
|
|
761
|
+
next unless stripped.start_with?("#")
|
|
762
|
+
level = stripped.length - stripped.sub(/\A#+/, "").length
|
|
763
|
+
title = stripped[level..].to_s.strip.downcase
|
|
764
|
+
if title.include?(heading_lc)
|
|
765
|
+
start_i = i
|
|
766
|
+
start_level = level
|
|
767
|
+
break
|
|
768
|
+
end
|
|
769
|
+
end
|
|
770
|
+
return { "error" => "Heading '#{heading}' not found in #{file}" } if start_i < 0
|
|
771
|
+
end_i = lines.size
|
|
772
|
+
(start_i + 1).upto(lines.size - 1) do |j|
|
|
773
|
+
stripped = lines[j].lstrip
|
|
774
|
+
next unless stripped.start_with?("#")
|
|
775
|
+
level = stripped.length - stripped.sub(/\A#+/, "").length
|
|
776
|
+
if level <= start_level
|
|
777
|
+
end_i = j
|
|
778
|
+
break
|
|
779
|
+
end
|
|
780
|
+
end
|
|
781
|
+
{ "file" => file, "heading" => lines[start_i].strip, "body" => lines[start_i...end_i].join("\n") }
|
|
782
|
+
}, "Return a full markdown section from a framework doc file")
|
|
783
|
+
|
|
784
|
+
# ── Git / deps / project ──────────────────────────
|
|
785
|
+
server.register_tool("git_status", lambda {
|
|
786
|
+
Tina4::DevAdmin.send(:git_status_payload)
|
|
787
|
+
}, "Show git branch, modified/untracked files, recent commits")
|
|
788
|
+
|
|
789
|
+
server.register_tool("deps_list", lambda {
|
|
790
|
+
gemfile = File.join(Dir.pwd, "Gemfile")
|
|
791
|
+
return { "error" => "No Gemfile at project root" } unless File.file?(gemfile)
|
|
792
|
+
deps = File.read(gemfile).scan(/^\s*gem\s+["']([^"']+)["']/).flatten
|
|
793
|
+
{ "name" => File.basename(Dir.pwd), "dependencies" => deps }
|
|
794
|
+
}, "List this project's declared Ruby dependencies")
|
|
795
|
+
|
|
796
|
+
server.register_tool("project_overview", lambda {
|
|
797
|
+
{ "system" => { "framework" => "tina4-ruby", "version" => (defined?(Tina4::VERSION) ? Tina4::VERSION : "unknown"), "ruby" => RUBY_DESCRIPTION, "cwd" => project_root } }
|
|
798
|
+
}, "One-shot snapshot: system + project info")
|
|
799
|
+
|
|
800
|
+
# ── Project index ─────────────────────────────────
|
|
801
|
+
server.register_tool("index_rebuild", lambda {
|
|
802
|
+
Tina4::ProjectIndex.refresh
|
|
803
|
+
}, "Refresh the persistent project index (lazy, mtime-based)")
|
|
804
|
+
|
|
805
|
+
server.register_tool("index_search", lambda { |query:, limit: 20|
|
|
806
|
+
Tina4::ProjectIndex.search(query, limit.to_i)
|
|
807
|
+
}, "Find files by path, symbol, route, or summary")
|
|
808
|
+
|
|
809
|
+
server.register_tool("index_file", lambda { |path:|
|
|
810
|
+
Tina4::ProjectIndex.file_entry(path)
|
|
811
|
+
}, "Full index entry for one file")
|
|
812
|
+
|
|
813
|
+
server.register_tool("index_overview", lambda {
|
|
814
|
+
Tina4::ProjectIndex.overview
|
|
815
|
+
}, "Project shape: files by language, routes, models, recent edits")
|
|
816
|
+
|
|
817
|
+
# ── Plan management ───────────────────────────────
|
|
818
|
+
server.register_tool("plan_current", lambda {
|
|
819
|
+
Tina4::Plan.current
|
|
820
|
+
}, "The active plan: title, steps (done/not), next step, progress")
|
|
821
|
+
|
|
822
|
+
server.register_tool("plan_list", lambda {
|
|
823
|
+
Tina4::Plan.list_plans
|
|
824
|
+
}, "All plans in plan/ with progress and which one is active")
|
|
825
|
+
|
|
826
|
+
server.register_tool("plan_create", lambda { |title:, goal: "", steps: nil, make_current: true|
|
|
827
|
+
Tina4::Plan.create(title, goal: goal, steps: steps, make_current: make_current)
|
|
828
|
+
}, "Create a new markdown plan in plan/ and make it active")
|
|
829
|
+
|
|
830
|
+
server.register_tool("plan_switch_to", lambda { |name:|
|
|
831
|
+
Tina4::Plan.set_current(name)
|
|
832
|
+
}, "Make a different plan the active one")
|
|
833
|
+
|
|
834
|
+
server.register_tool("plan_complete_step", lambda { |index:|
|
|
835
|
+
Tina4::Plan.complete_step(index.to_i)
|
|
836
|
+
}, "Tick a step as done (call the moment the step finishes)")
|
|
837
|
+
|
|
838
|
+
server.register_tool("plan_add_step", lambda { |text:|
|
|
839
|
+
Tina4::Plan.add_step(text)
|
|
840
|
+
}, "Append a new unchecked step to the current plan")
|
|
841
|
+
|
|
842
|
+
server.register_tool("plan_note", lambda { |text:|
|
|
843
|
+
Tina4::Plan.append_note(text)
|
|
844
|
+
}, "Append a timestamped note/breadcrumb to the current plan")
|
|
845
|
+
|
|
846
|
+
server.register_tool("plan_archive", lambda { |name: ""|
|
|
847
|
+
Tina4::Plan.archive(name)
|
|
848
|
+
}, "Move a finished plan to plan/done/")
|
|
849
|
+
|
|
850
|
+
server.register_tool("plan_read", lambda { |name:|
|
|
851
|
+
Tina4::Plan.read(name)
|
|
852
|
+
}, "Full structured view of any plan by filename")
|
|
853
|
+
|
|
854
|
+
server.register_tool("plan_flesh", lambda { |name: "", prompt: ""|
|
|
855
|
+
Tina4::Plan.flesh(name, prompt)
|
|
856
|
+
}, "Auto-generate concrete build steps via AI and append them to an existing plan")
|
|
857
|
+
|
|
858
|
+
# ── Live API Docs (Live API RAG) ──────────────────
|
|
859
|
+
server.register_tool("api_search", lambda { |query:, k: 5, source: "all", include_private: false|
|
|
860
|
+
Tina4::Docs.cached(project_root).search(
|
|
861
|
+
query.to_s, k: k.to_i, source: source.to_s, include_private: include_private == true || include_private.to_s == "true"
|
|
862
|
+
)
|
|
863
|
+
}, "Search the live API index (framework + user code) for ranked hits")
|
|
864
|
+
|
|
865
|
+
server.register_tool("api_class", lambda { |name:|
|
|
866
|
+
Tina4::Docs.cached(project_root).class_spec(name.to_s)
|
|
867
|
+
}, "Full class reflection (methods, file, line) from the live API index")
|
|
868
|
+
|
|
869
|
+
server.register_tool("api_method", lambda { |class_name:, name:|
|
|
870
|
+
Tina4::Docs.cached(project_root).method_spec(class_name.to_s, name.to_s)
|
|
871
|
+
}, "Single method spec (signature, summary, file, line) from the live API index")
|
|
872
|
+
|
|
683
873
|
# ── System Tools ──────────────────────────────────
|
|
684
874
|
server.register_tool("system_info", lambda {
|
|
685
875
|
{
|
data/lib/tina4/messenger.rb
CHANGED
|
@@ -18,7 +18,7 @@ module Tina4
|
|
|
18
18
|
# Tina4 Messenger — Email sending (SMTP) and reading (IMAP).
|
|
19
19
|
#
|
|
20
20
|
# Unified .env-driven configuration with constructor override.
|
|
21
|
-
# Priority: constructor params > .env (TINA4_MAIL_*
|
|
21
|
+
# Priority: constructor params > .env (TINA4_MAIL_*) > sensible defaults
|
|
22
22
|
#
|
|
23
23
|
# # .env
|
|
24
24
|
# TINA4_MAIL_HOST=smtp.gmail.com
|
|
@@ -39,19 +39,19 @@ module Tina4
|
|
|
39
39
|
:imap_host, :imap_port, :use_tls, :encryption
|
|
40
40
|
|
|
41
41
|
# Initialize with SMTP config.
|
|
42
|
-
# Priority: constructor params > ENV (TINA4_MAIL_*
|
|
42
|
+
# Priority: constructor params > ENV (TINA4_MAIL_*) > sensible defaults
|
|
43
43
|
def initialize(host: nil, port: nil, username: nil, password: nil,
|
|
44
44
|
from_address: nil, from_name: nil, encryption: nil, use_tls: nil,
|
|
45
45
|
imap_host: nil, imap_port: nil)
|
|
46
|
-
@host = host || ENV["TINA4_MAIL_HOST"] ||
|
|
47
|
-
@port = (port || ENV["TINA4_MAIL_PORT"] ||
|
|
48
|
-
@username = username || ENV["TINA4_MAIL_USERNAME"]
|
|
49
|
-
@password = password || ENV["TINA4_MAIL_PASSWORD"]
|
|
46
|
+
@host = host || ENV["TINA4_MAIL_HOST"] || "localhost"
|
|
47
|
+
@port = (port || ENV["TINA4_MAIL_PORT"] || 587).to_i
|
|
48
|
+
@username = username || ENV["TINA4_MAIL_USERNAME"]
|
|
49
|
+
@password = password || ENV["TINA4_MAIL_PASSWORD"]
|
|
50
50
|
|
|
51
|
-
resolved_from = from_address || ENV["TINA4_MAIL_FROM"]
|
|
51
|
+
resolved_from = from_address || ENV["TINA4_MAIL_FROM"]
|
|
52
52
|
@from_address = resolved_from || @username || "noreply@localhost"
|
|
53
53
|
|
|
54
|
-
@from_name = from_name || ENV["TINA4_MAIL_FROM_NAME"] ||
|
|
54
|
+
@from_name = from_name || ENV["TINA4_MAIL_FROM_NAME"] || ""
|
|
55
55
|
|
|
56
56
|
# Encryption: constructor > .env > backward-compat use_tls > default "tls"
|
|
57
57
|
env_encryption = encryption || ENV["TINA4_MAIL_ENCRYPTION"]
|
|
@@ -64,8 +64,8 @@ module Tina4
|
|
|
64
64
|
end
|
|
65
65
|
@use_tls = %w[tls starttls].include?(@encryption)
|
|
66
66
|
|
|
67
|
-
@imap_host = imap_host || ENV["TINA4_MAIL_IMAP_HOST"] ||
|
|
68
|
-
@imap_port = (imap_port || ENV["TINA4_MAIL_IMAP_PORT"] ||
|
|
67
|
+
@imap_host = imap_host || ENV["TINA4_MAIL_IMAP_HOST"] || @host
|
|
68
|
+
@imap_port = (imap_port || ENV["TINA4_MAIL_IMAP_PORT"] || 993).to_i
|
|
69
69
|
end
|
|
70
70
|
|
|
71
71
|
# Send email using Ruby's Net::SMTP
|
|
@@ -542,8 +542,7 @@ module Tina4
|
|
|
542
542
|
def self.create_messenger(**options)
|
|
543
543
|
dev_mode = Tina4::Env.is_truthy(ENV["TINA4_DEBUG"])
|
|
544
544
|
|
|
545
|
-
smtp_configured =
|
|
546
|
-
(ENV["SMTP_HOST"] && !ENV["SMTP_HOST"].empty?)
|
|
545
|
+
smtp_configured = ENV["TINA4_MAIL_HOST"] && !ENV["TINA4_MAIL_HOST"].empty?
|
|
547
546
|
|
|
548
547
|
if dev_mode && !smtp_configured
|
|
549
548
|
mailbox_dir = options.delete(:mailbox_dir) || ENV["TINA4_MAILBOX_DIR"]
|
|
@@ -560,8 +559,8 @@ module Tina4
|
|
|
560
559
|
|
|
561
560
|
def initialize(mailbox, **options)
|
|
562
561
|
@mailbox = mailbox
|
|
563
|
-
@from_address = options[:from_address] || ENV["TINA4_MAIL_FROM"] ||
|
|
564
|
-
@from_name = options[:from_name] || ENV["TINA4_MAIL_FROM_NAME"] ||
|
|
562
|
+
@from_address = options[:from_address] || ENV["TINA4_MAIL_FROM"] || "dev@localhost"
|
|
563
|
+
@from_name = options[:from_name] || ENV["TINA4_MAIL_FROM_NAME"] || "Dev Mailer"
|
|
565
564
|
end
|
|
566
565
|
|
|
567
566
|
def send(to:, subject:, body:, html: false, cc: [], bcc: [],
|
data/lib/tina4/orm.rb
CHANGED
|
@@ -52,15 +52,31 @@ module Tina4
|
|
|
52
52
|
@field_mapping = map
|
|
53
53
|
end
|
|
54
54
|
|
|
55
|
-
# Auto-map flag
|
|
55
|
+
# Auto-map flag — defaults to TRUE for cross-framework parity (Python's
|
|
56
|
+
# ORM has auto_map=True by default). The instance variable is treated
|
|
57
|
+
# as "unset" when nil; only an explicit `false` disables it.
|
|
56
58
|
def auto_map
|
|
57
|
-
@auto_map
|
|
59
|
+
defined?(@auto_map) && !@auto_map.nil? ? @auto_map : true
|
|
58
60
|
end
|
|
59
61
|
|
|
60
62
|
def auto_map=(val)
|
|
61
63
|
@auto_map = val
|
|
62
64
|
end
|
|
63
65
|
|
|
66
|
+
# auto_crud flag — when set to true, the class registers itself with
|
|
67
|
+
# Tina4::AutoCrud which auto-generates REST endpoints from the model.
|
|
68
|
+
# Defaults to false. Cross-framework parity with Python's autoCrud.
|
|
69
|
+
def auto_crud
|
|
70
|
+
defined?(@auto_crud) && !@auto_crud.nil? ? @auto_crud : false
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def auto_crud=(val)
|
|
74
|
+
@auto_crud = val
|
|
75
|
+
if val && defined?(::Tina4::AutoCrud)
|
|
76
|
+
::Tina4::AutoCrud.models << self unless ::Tina4::AutoCrud.models.include?(self)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
64
80
|
# Relationship definitions
|
|
65
81
|
def relationship_definitions
|
|
66
82
|
@relationship_definitions ||= {}
|
|
@@ -343,8 +359,10 @@ module Tina4
|
|
|
343
359
|
instance
|
|
344
360
|
end
|
|
345
361
|
|
|
346
|
-
|
|
347
|
-
|
|
362
|
+
# find_by_id is PUBLIC — cross-framework parity with Python's
|
|
363
|
+
# MyModel.find_by_id(pk_value) and PHP's User::find($id). Spec at
|
|
364
|
+
# spec/orm_spec.rb:78 verifies public access. find_by_filter stays
|
|
365
|
+
# public for the same reason; both are part of the documented API.
|
|
348
366
|
def find_by_id(id)
|
|
349
367
|
pk = primary_key_field || :id
|
|
350
368
|
sql = "SELECT * FROM #{table_name} WHERE #{pk} = ?"
|
|
@@ -354,6 +372,34 @@ module Tina4
|
|
|
354
372
|
select_one(sql, [id])
|
|
355
373
|
end
|
|
356
374
|
|
|
375
|
+
# Clear the relationship cache on all loaded instances (class-level helper).
|
|
376
|
+
# Useful after bulk operations when you want to force relationship re-loads.
|
|
377
|
+
def clear_rel_cache # -> nil
|
|
378
|
+
@_rel_cache = {}
|
|
379
|
+
nil
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
# Return the database connection used by this model.
|
|
383
|
+
def get_db # -> Database
|
|
384
|
+
db
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
# Map a Ruby property name to its database column name using field_mapping.
|
|
388
|
+
# Returns the column name as a symbol.
|
|
389
|
+
def get_db_column(property) # -> Symbol
|
|
390
|
+
col = field_mapping[property.to_s] || property
|
|
391
|
+
col.to_sym
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
private
|
|
395
|
+
|
|
396
|
+
def auto_discover_db
|
|
397
|
+
url = ENV["TINA4_DATABASE_URL"]
|
|
398
|
+
return nil unless url
|
|
399
|
+
Tina4.database = Tina4::Database.new(url, username: ENV.fetch("TINA4_DATABASE_USERNAME", ""), password: ENV.fetch("TINA4_DATABASE_PASSWORD", ""))
|
|
400
|
+
Tina4.database
|
|
401
|
+
end
|
|
402
|
+
|
|
357
403
|
def find_by_filter(filter)
|
|
358
404
|
where_parts = filter.keys.map { |k| "#{k} = ?" }
|
|
359
405
|
sql = "SELECT * FROM #{table_name} WHERE #{where_parts.join(' AND ')}"
|
|
@@ -474,15 +520,30 @@ module Tina4
|
|
|
474
520
|
errors
|
|
475
521
|
end
|
|
476
522
|
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
523
|
+
# load — populate this instance from the database.
|
|
524
|
+
#
|
|
525
|
+
# Three forms (parity with Python's model.load(sql, params, include)):
|
|
526
|
+
# user.load # reload by primary key from instance
|
|
527
|
+
# user.load(123) # load by primary key value
|
|
528
|
+
# user.load("email = ?", ["a@b.c"]) # load by filter SQL + params (selectOne)
|
|
529
|
+
#
|
|
530
|
+
# Returns true on hit, false on miss. Always clears the relationship cache.
|
|
531
|
+
def load(arg = nil, params = nil)
|
|
481
532
|
@relationship_cache = {} # Clear relationship cache on reload
|
|
533
|
+
pk = self.class.primary_key_field || :id
|
|
482
534
|
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
535
|
+
if arg.is_a?(String)
|
|
536
|
+
# Filter-SQL form: user.load("email = ?", ["a@b.c"])
|
|
537
|
+
sql = "SELECT * FROM #{self.class.table_name} WHERE #{arg} LIMIT 1"
|
|
538
|
+
result = self.class.db.fetch_one(sql, params || [])
|
|
539
|
+
else
|
|
540
|
+
# Primary-key form: user.load OR user.load(123)
|
|
541
|
+
id = arg || __send__(pk)
|
|
542
|
+
return false unless id
|
|
543
|
+
result = self.class.db.fetch_one(
|
|
544
|
+
"SELECT * FROM #{self.class.table_name} WHERE #{pk} = ?", [id]
|
|
545
|
+
)
|
|
546
|
+
end
|
|
486
547
|
return false unless result
|
|
487
548
|
|
|
488
549
|
mapping_reverse = self.class.field_mapping.invert
|
|
@@ -505,7 +566,10 @@ module Tina4
|
|
|
505
566
|
|
|
506
567
|
# Convert to hash using Ruby attribute names.
|
|
507
568
|
# Optionally include relationships via the include keyword.
|
|
508
|
-
|
|
569
|
+
# case: "camel" converts snake_case keys to camelCase (parity with
|
|
570
|
+
# Python's to_dict(case='camel')). Default keeps native snake_case.
|
|
571
|
+
def to_h(include: nil, case: nil)
|
|
572
|
+
key_case = binding.local_variable_get(:case) # :case is a reserved word
|
|
509
573
|
hash = {}
|
|
510
574
|
self.class.field_definitions.each_key do |name|
|
|
511
575
|
hash[name] = __send__(name)
|
|
@@ -534,6 +598,15 @@ module Tina4
|
|
|
534
598
|
end
|
|
535
599
|
end
|
|
536
600
|
|
|
601
|
+
if key_case == "camel" || key_case == :camel
|
|
602
|
+
# snake_case → camelCase: split on _, capitalize all but the first
|
|
603
|
+
hash = hash.each_with_object({}) do |(k, v), out|
|
|
604
|
+
parts = k.to_s.split("_")
|
|
605
|
+
camel = parts[0] + parts[1..].map(&:capitalize).join
|
|
606
|
+
out[camel.to_sym] = v
|
|
607
|
+
end
|
|
608
|
+
end
|
|
609
|
+
|
|
537
610
|
hash
|
|
538
611
|
end
|
|
539
612
|
|