tina4ruby 3.13.149 → 3.13.151

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.
data/lib/tina4/mcp.rb CHANGED
@@ -675,6 +675,37 @@ module Tina4
675
675
 
676
676
  # ── Built-in dev tools ────────────────────────────────────────────
677
677
  module McpDevTools
678
+ # Where +path+ will land once every symlink in it is followed, including the
679
+ # parts that do not exist yet: a NEW file is created through its deepest
680
+ # existing parent, and a dangling symlink through the place it names. nil
681
+ # when that cannot be worked out (a link loop, an unreadable link).
682
+ # Does +path+, once its symlinks are followed, land outside +project_root+?
683
+ # A root that no longer exists holds nothing to link through, so nothing
684
+ # under it is judged.
685
+ def self.lands_outside?(path, project_root)
686
+ real_root = File.realpath(project_root)
687
+ real = real_path_of(path)
688
+ real_prefix = "#{real_root.chomp(File::SEPARATOR)}#{File::SEPARATOR}"
689
+ !(real && (real == real_root || real.start_with?(real_prefix)))
690
+ rescue SystemCallError
691
+ false
692
+ end
693
+
694
+ def self.real_path_of(path, hops = 0)
695
+ probe = path
696
+ probe = File.dirname(probe) until File.exist?(probe) || File.symlink?(probe) || File.dirname(probe) == probe
697
+ return File.realpath(probe) if File.exist?(probe)
698
+ return nil if hops >= 40
699
+
700
+ # A dangling symlink. Its target is joined as text, not normalised, so a
701
+ # ".." after a linked component is resolved the way the OS will follow it.
702
+ target = File.readlink(probe)
703
+ base = target.start_with?(File::SEPARATOR) ? target : "#{File.dirname(probe)}#{File::SEPARATOR}#{target}"
704
+ real_path_of("#{base}#{path[probe.length..]}", hops + 1)
705
+ rescue SystemCallError
706
+ nil
707
+ end
708
+
678
709
  # Register all 24 built-in dev tools on the given McpServer.
679
710
  def self.register(server)
680
711
  project_root = File.expand_path(Dir.pwd)
@@ -794,18 +825,11 @@ module Tina4
794
825
  unless resolved == project_root || resolved.start_with?(root_prefix)
795
826
  raise ArgumentError, "Path escapes project directory: #{rel_path}"
796
827
  end
797
- # Belt-and-braces against symlink escapes: if the path already exists,
798
- # canonicalise it and re-check containment. A symlink inside the tree
799
- # pointing outside would otherwise slip past the textual check. New
800
- # paths (parent not created yet) have no realpath and rely on the
801
- # expand_path containment above.
802
- if File.exist?(resolved)
803
- real = File.realpath(resolved)
804
- real_root = File.realpath(project_root)
805
- real_prefix = "#{real_root.chomp(File::SEPARATOR)}#{File::SEPARATOR}"
806
- unless real == real_root || real.start_with?(real_prefix)
807
- raise ArgumentError, "Path escapes project directory: #{rel_path}"
808
- end
828
+ # Belt-and-braces against symlink escapes: canonicalise the path and
829
+ # re-check containment. A symlink inside the tree pointing outside
830
+ # would otherwise slip past the textual check.
831
+ if McpDevTools.lands_outside?(resolved, project_root)
832
+ raise ArgumentError, "Path escapes project directory: #{rel_path}"
809
833
  end
810
834
  resolved
811
835
  end
data/lib/tina4/metrics.rb CHANGED
@@ -50,7 +50,11 @@ module Tina4
50
50
  binary = engine_path
51
51
  raise MetricsEngineError, "tina4 not found on PATH - #{INSTALL_HINT}" unless binary
52
52
 
53
- stdout, stderr, status = Open3.capture3(binary, 'metrics', '--path', path.to_s, '--json')
53
+ # --no-history: a dashboard READ must never write .tina4-metrics.json.
54
+ # Without it every scan records history, the file watcher sees the write
55
+ # and reloads the page, which re-scans - a self-inflicted ~2-second reload
56
+ # loop that makes the dashboard unusable.
57
+ stdout, stderr, status = Open3.capture3(binary, 'metrics', '--path', path.to_s, '--json', '--no-history')
54
58
  unless status.success?
55
59
  detail = (stderr.empty? ? stdout : stderr).strip.lines.first || "exit code #{status.exitstatus}"
56
60
  raise MetricsEngineError, "tina4 metrics failed on #{path}: #{detail.strip}"
@@ -783,6 +783,7 @@ module Tina4
783
783
  # TINA4_CSP — Content-Security-Policy (default: "default-src 'self'")
784
784
  # TINA4_REFERRER_POLICY — Referrer-Policy (default: strict-origin-when-cross-origin)
785
785
  # TINA4_PERMISSIONS_POLICY — Permissions-Policy (default: camera=(), microphone=(), geolocation=())
786
+ # TINA4_COOP — Cross-Origin-Opener-Policy (default: same-origin-allow-popups)
786
787
  class SecurityHeadersMiddleware
787
788
  class << self
788
789
  # Register this middleware in the default chain (secure-by-default).
@@ -815,7 +816,15 @@ module Tina4
815
816
  ["Content-Security-Policy", Tina4::Csp.resolve_csp_header(Tina4::Csp.current_nonce)],
816
817
  ["Referrer-Policy", ENV["TINA4_REFERRER_POLICY"] || "strict-origin-when-cross-origin"],
817
818
  ["X-XSS-Protection", "0"],
818
- ["Permissions-Policy", ENV["TINA4_PERMISSIONS_POLICY"] || "camera=(), microphone=(), geolocation=()"]
819
+ ["Permissions-Policy", ENV["TINA4_PERMISSIONS_POLICY"] || "camera=(), microphone=(), geolocation=()"],
820
+ # Cross-Origin-Opener-Policy severs the window-reference link to a
821
+ # cross-origin opener (closing reverse-tab-nabbing + cross-window
822
+ # XS-Leaks that X-Frame-Options does not). Default same-origin-allow-popups:
823
+ # isolates the page from its opener while leaving popups IT opens able to
824
+ # post back, so OAuth/payment popups keep working. TINA4_COOP=same-origin
825
+ # for full isolation, unsafe-none to opt out. Secure-context only (https
826
+ # and http://localhost), inert elsewhere.
827
+ ["Cross-Origin-Opener-Policy", ENV["TINA4_COOP"] || "same-origin-allow-popups"]
819
828
  ]
820
829
  end
821
830
 
data/lib/tina4/plan.rb CHANGED
@@ -53,7 +53,7 @@ module Tina4
53
53
  end
54
54
 
55
55
  def slugify(title)
56
- slug = title.to_s.strip.downcase.gsub(/[^a-z0-9_\-]+/, "-").gsub(/\A-+|-+\z/, "")
56
+ slug = title.to_s.strip.downcase.gsub(/[^a-z0-9_\-]+/, "-").gsub(/\A-++|-++\z/, "")
57
57
  slug = slug[0, 80]
58
58
  slug.empty? ? "plan-#{Time.now.to_i}" : slug
59
59
  end
@@ -69,7 +69,7 @@ module Tina4
69
69
  section = nil # :steps | :notes | :other | nil
70
70
 
71
71
  lines.each do |raw|
72
- line = raw.sub(/[[:space:]]+\z/, "")
72
+ line = raw.sub(/[[:space:]]++\z/, "")
73
73
  if title.empty? && line.start_with?("# ")
74
74
  title = line[2..].to_s.strip
75
75
  next
@@ -407,7 +407,7 @@ module Tina4
407
407
  user_parts << "Extra context from caller: #{prompt}" unless prompt.to_s.empty?
408
408
  user_parts << "Reply with ONLY the JSON array — no explanation, no markdown fences."
409
409
 
410
- ai_url = ENV.fetch("TINA4_AI_URL", "http://localhost:11437/api/chat")
410
+ ai_url = ENV.fetch("TINA4_AI_URL", "http://localhost:11435/api/chat")
411
411
  ai_model = ENV.fetch("TINA4_AI_MODEL", "qwen2.5-coder:14b")
412
412
 
413
413
  reply = begin
data/lib/tina4/point.rb CHANGED
@@ -52,7 +52,7 @@ module Tina4
52
52
  return from_geojson(value, srid) if value.is_a?(Hash)
53
53
  if value.is_a?(String)
54
54
  text = value.strip
55
- match = text.match(/\A(?:SRID\s*=\s*(\d+)\s*;\s*)?POINT\s*(?:Z|M|ZM)?\s*\(\s*([-+0-9.eE]+)\s+([-+0-9.eE]+)(?:\s+[-+0-9.eE]+)*\s*\)\z/i)
55
+ match = text.match(/\A(?:SRID\s*=\s*(\d+)\s*;\s*)?POINT\s*(?:Z|M|ZM)?\s*\(\s*([-+0-9.eE]+)\s++([-+0-9.eE]+)(?:\s++[-+0-9.eE]+)*+\s*+\)\z/i)
56
56
  return new(match[2], match[3], srid: match[1] ? match[1].to_i : srid) if match
57
57
  raw = [text].pack("H*") if text.length >= 42 && text.length.even? && text.match?(/\A[0-9a-f]+\z/i)
58
58
  raw ||= value.b if [0, 1].include?(value.getbyte(0))
@@ -71,7 +71,7 @@ module Tina4
71
71
  text.scan(/^\s*def\s+(self\.)?([A-Za-z_][\w!?=]*)/) { |m| out["symbols"] << m[1] }
72
72
  text.scan(/^\s*require(?:_relative)?\s+['"]([^'"]+)['"]/) { |m| out["imports"] << m[0] }
73
73
  # Tina4.get "/path" OR Tina4::Router.get("/path") OR get "/path" do
74
- route_re = /(?:Tina4(?:::Router)?\.|^\s*)(get|post|put|patch|delete|any|any_method|secure_get|secure_post)\s*\(?\s*['"]([^'"]+)['"]/
74
+ route_re = /(?:Tina4(?:::Router)?\.|^\s*)(get|post|put|patch|delete|any|any_method|secure_get|secure_post)\s*+\(?\s*+['"]([^'"]+)['"]/
75
75
  text.scan(route_re) do |meth, path|
76
76
  next unless ROUTE_METHODS.include?(meth)
77
77
  out["routes"] << { "method" => meth.upcase, "path" => path, "handler" => "" }
@@ -41,8 +41,8 @@
41
41
  },
42
42
  {
43
43
  "path": "js/tina4-dev-admin.min.js",
44
- "component": "tina4-dev-admin-21700e0",
45
- "sha256": "0ca84246c26afb6a3243aafd1c65fa9486c076039e5d721b6f44661b3a59d7c2"
44
+ "component": "tina4-dev-admin-8c4617d",
45
+ "sha256": "6b05be06b5d76ee4126a11f019d104d3920c1e3234250cfe1966a33f34c1a234"
46
46
  }
47
47
  ]
48
48
  }
@@ -0,0 +1,202 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0"><title>Tina4 Dev - Split</title>
3
+ <style>
4
+ html,body{margin:0;height:100%;background:#0f172a;font-family:system-ui,-apple-system,sans-serif;overflow:hidden}
5
+ /* One top toolbar controls both panels and the preview device. */
6
+ .t4-topbar{height:40px;box-sizing:border-box;display:flex;align-items:center;gap:6px;padding:0 10px;background:#0b1220;border-bottom:1px solid #1f2937}
7
+ .t4-brand{color:#e2e8f0;font:600 12px system-ui;margin-right:6px}
8
+ .t4-spacer{flex:1 1 auto}
9
+ .t4-devbtn{background:#1e293b;color:#cbd5e1;border:1px solid #334155;border-radius:6px;padding:3px 9px;font:12px system-ui;cursor:pointer}
10
+ .t4-devbtn.active{background:#2563eb;border-color:#2563eb;color:#fff}
11
+ /* Icon toggle buttons: full opacity + highlight when the panel is shown,
12
+ dimmed when hidden. */
13
+ .t4-tg{display:inline-flex;align-items:center;justify-content:center;width:28px;height:26px;background:#1e293b;color:#94a3b8;border:1px solid #334155;border-radius:6px;cursor:pointer;opacity:.5}
14
+ .t4-tg svg{width:15px;height:15px}
15
+ .t4-tg.active{background:#334155;color:#fff;opacity:1}
16
+ .t4-dim{color:#64748b;font:11px/1 ui-monospace,monospace;margin:0 6px}
17
+ .t4-popout{background:#0f766e;color:#fff;border:1px solid #0f766e;border-radius:6px;padding:3px 10px;font:12px system-ui;cursor:pointer;margin-left:6px}
18
+ .t4-split{display:flex;height:calc(100vh - 41px);width:100vw}
19
+ .t4-left{flex:0 0 auto;width:58%;min-width:0;height:100%;overflow:hidden}
20
+ .t4-left iframe{width:100%;height:100%;border:0;display:block}
21
+ .t4-gutter{flex:0 0 6px;cursor:col-resize;background:#1f2937}
22
+ .t4-gutter:hover,.t4-gutter.drag{background:#2563eb}
23
+ .t4-right{flex:1 1 auto;min-width:0;height:100%;overflow:hidden;display:flex;flex-direction:column}
24
+ /* A LIGHT backdrop so the dark device frame stands out. */
25
+ .t4-stage{flex:1 1 auto;min-width:0;min-height:0;position:relative;overflow:hidden;display:flex;align-items:center;justify-content:center;background:radial-gradient(circle at 50% 32%,#f8fafc,#c3ccd9)}
26
+ /* The device frame - a phone/tablet bezel around the live app. overflow:hidden
27
+ + border-radius CLIP the app to the rounded frame, so content is contained.
28
+ No width/height transition: fit() measures right after a toggle, and an
29
+ animated size would be read mid-transition (the "clicking does nothing" bug). */
30
+ .t4-device{position:relative;background:#111;box-shadow:0 16px 50px rgba(15,23,42,.45);transform-origin:center center;overflow:hidden}
31
+ .t4-device iframe{width:100%;height:100%;border:0;display:block;background:#fff}
32
+ .t4-device.mobile{border:14px solid #111;border-radius:46px}
33
+ .t4-device.tablet{border:16px solid #111;border-radius:26px}
34
+ /* Desktop fills the whole preview block - no bezel, no scaling. */
35
+ .t4-device.desktop{border:0;border-radius:0;box-shadow:none;width:100%;height:100%}
36
+ .t4-hidden{display:none !important}
37
+ /* While dragging the gutter, let mouse events pass over the iframes. */
38
+ body.t4-dragging iframe{pointer-events:none}
39
+ </style></head>
40
+ <body>
41
+ <div class="t4-topbar">
42
+ <span class="t4-brand">Tina4 Dev</span>
43
+ <button class="t4-tg active" id="t4-tg-files" title="Show/hide the file navigator"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4"><rect x="1.5" y="2.5" width="13" height="11" rx="1.5"/><line x1="6" y1="2.5" x2="6" y2="13.5"/></svg></button>
44
+ <button class="t4-tg active" id="t4-tg-editor" title="Show/hide the code editor"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><polyline points="5.5,4.5 2,8 5.5,11.5"/><polyline points="10.5,4.5 14,8 10.5,11.5"/></svg></button>
45
+ <button class="t4-tg active" id="t4-tg-preview" title="Show/hide the preview"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"><rect x="1.5" y="2.5" width="13" height="9" rx="1.5"/><line x1="5.5" y1="14" x2="10.5" y2="14"/><line x1="8" y1="11.5" x2="8" y2="14"/></svg></button>
46
+ <span class="t4-spacer"></span>
47
+ <span class="t4-dim" id="t4-dim">1440 x 900</span>
48
+ <button class="t4-devbtn" data-mode="mobile">Mobile</button>
49
+ <button class="t4-devbtn" data-mode="tablet">Tablet</button>
50
+ <button class="t4-devbtn active" data-mode="desktop">Desktop</button>
51
+ <button class="t4-popout" id="t4-popout" title="Open this view in its own window - drag it to another screen">&#8689; Pop out</button>
52
+ </div>
53
+ <div class="t4-split" id="t4-split">
54
+ <div class="t4-left" id="t4-left"><iframe src="/__dev" title="Tina4 Dev Admin"></iframe></div>
55
+ <div class="t4-gutter" id="t4-gutter"></div>
56
+ <div class="t4-right" id="t4-right">
57
+ <div class="t4-stage" id="t4-stage">
58
+ <div class="t4-device desktop" id="t4-device">
59
+ <iframe id="t4-preview" src="/" title="Live preview"></iframe>
60
+ </div>
61
+ </div>
62
+ </div>
63
+ </div>
64
+ <script>
65
+ (function () {
66
+ var SIZES = { mobile: {w:390,h:844}, tablet: {w:820,h:1180}, desktop: {w:1440,h:900} };
67
+ var KEY = 'tina4_split_device';
68
+ var device = document.getElementById('t4-device');
69
+ var stage = document.getElementById('t4-stage');
70
+ var dim = document.getElementById('t4-dim');
71
+ var left = document.getElementById('t4-left');
72
+ var right = document.getElementById('t4-right');
73
+ var gutter = document.getElementById('t4-gutter');
74
+ var split = document.getElementById('t4-split');
75
+ var mode = 'desktop';
76
+ try { var saved = localStorage.getItem(KEY); if (saved && SIZES[saved]) mode = saved; } catch (_) {}
77
+ var leftWidth = '58%';
78
+
79
+ // ── device preview ──
80
+ function fit() {
81
+ if (right.classList.contains('t4-hidden')) return;
82
+ if (mode === 'desktop') {
83
+ // Desktop just fills the preview block - responsive, no frame, no scale.
84
+ device.style.transform = 'none';
85
+ device.style.width = '100%';
86
+ device.style.height = '100%';
87
+ var r0 = stage.getBoundingClientRect();
88
+ if (dim) dim.textContent = Math.round(r0.width) + ' x ' + Math.round(r0.height);
89
+ return;
90
+ }
91
+ var s = SIZES[mode];
92
+ device.style.transform = 'none';
93
+ device.style.width = s.w + 'px';
94
+ device.style.height = s.h + 'px';
95
+ var rect = stage.getBoundingClientRect();
96
+ var pad = 28;
97
+ var ow = device.offsetWidth, oh = device.offsetHeight;
98
+ var scale = Math.min((rect.width - pad) / ow, (rect.height - pad) / oh, 1);
99
+ if (!isFinite(scale) || scale <= 0) scale = 1;
100
+ device.style.transform = 'scale(' + scale + ')';
101
+ }
102
+ function setMode(m) {
103
+ if (!SIZES[m]) return;
104
+ mode = m;
105
+ device.className = 't4-device ' + m;
106
+ if (dim) dim.textContent = SIZES[m].w + ' x ' + SIZES[m].h;
107
+ document.querySelectorAll('.t4-devbtn').forEach(function (b) {
108
+ b.classList.toggle('active', b.getAttribute('data-mode') === m);
109
+ });
110
+ try { localStorage.setItem(KEY, m); } catch (_) {}
111
+ fit();
112
+ }
113
+ document.querySelectorAll('.t4-devbtn').forEach(function (b) {
114
+ b.addEventListener('click', function () { setMode(b.getAttribute('data-mode')); });
115
+ });
116
+ var pop = document.getElementById('t4-popout');
117
+ if (pop) pop.addEventListener('click', function () {
118
+ var s = SIZES[mode];
119
+ var w = Math.min(s.w, (screen.availWidth || 1920) - 40);
120
+ var h = Math.min(s.h, (screen.availHeight || 1080) - 120);
121
+ window.open('/', 'tina4preview_' + mode, 'width=' + w + ',height=' + h + ',menubar=no,toolbar=no,location=no');
122
+ });
123
+
124
+ // ── show / hide each panel (Files, Editor, Preview) ──
125
+ // Files (the nav) and Editor (the code area) are SEPARATE sections living
126
+ // inside the left /__dev SPA iframe, so hiding the editor must not take the
127
+ // file nav with it. They are same-origin, so we reach into the iframe and
128
+ // toggle its own `#editor-sidebar` (nav) and `.editor-main` (editor). The
129
+ // Preview is the right pane of this page.
130
+ var vis = { files: true, editor: true, preview: true };
131
+ function frameDoc() {
132
+ try { return document.querySelector('#t4-left iframe').contentDocument; } catch (_) { return null; }
133
+ }
134
+ function setFrameEl(sel, show) {
135
+ var d = frameDoc(); if (!d) return;
136
+ var el = d.querySelector(sel); if (el) el.style.display = show ? '' : 'none';
137
+ }
138
+ function applyPanels() {
139
+ setFrameEl('#editor-sidebar', vis.files);
140
+ setFrameEl('#editor-splitter-left', vis.files && vis.editor);
141
+ setFrameEl('.editor-main', vis.editor);
142
+ var leftEmpty = !vis.files && !vis.editor;
143
+ left.classList.toggle('t4-hidden', leftEmpty);
144
+ right.classList.toggle('t4-hidden', !vis.preview);
145
+ gutter.classList.toggle('t4-hidden', leftEmpty || !vis.preview);
146
+ if (!vis.preview) {
147
+ // Preview hidden: the editor/nav fills the whole width.
148
+ left.style.flex = '1 1 auto'; left.style.width = 'auto';
149
+ } else if (vis.files && !vis.editor) {
150
+ // Only the file nav is shown: shrink the left pane to the nav width so
151
+ // there is no empty gap where the editor used to be.
152
+ left.style.flex = '0 0 auto'; left.style.width = '250px';
153
+ } else {
154
+ left.style.flex = '0 0 auto'; left.style.width = leftWidth;
155
+ }
156
+ document.getElementById('t4-tg-files').classList.toggle('active', vis.files);
157
+ document.getElementById('t4-tg-editor').classList.toggle('active', vis.editor);
158
+ document.getElementById('t4-tg-preview').classList.toggle('active', vis.preview);
159
+ fit();
160
+ }
161
+ function toggle(name) {
162
+ // Never hide all three - at least one stays visible.
163
+ var n = { files: vis.files, editor: vis.editor, preview: vis.preview };
164
+ n[name] = !n[name];
165
+ if (!n.files && !n.editor && !n.preview) return;
166
+ vis[name] = !vis[name];
167
+ applyPanels();
168
+ }
169
+ document.getElementById('t4-tg-files').addEventListener('click', function () { toggle('files'); });
170
+ document.getElementById('t4-tg-editor').addEventListener('click', function () { toggle('editor'); });
171
+ document.getElementById('t4-tg-preview').addEventListener('click', function () { toggle('preview'); });
172
+
173
+ // ── drag the gutter to resize ──
174
+ var dragging = false;
175
+ gutter.addEventListener('mousedown', function (e) {
176
+ dragging = true;
177
+ document.body.classList.add('t4-dragging');
178
+ gutter.classList.add('drag');
179
+ e.preventDefault();
180
+ });
181
+ window.addEventListener('mousemove', function (e) {
182
+ if (!dragging) return;
183
+ var r = split.getBoundingClientRect();
184
+ var w = e.clientX - r.left;
185
+ w = Math.max(220, Math.min(w, r.width - 260));
186
+ leftWidth = w + 'px';
187
+ left.style.width = leftWidth;
188
+ fit();
189
+ });
190
+ window.addEventListener('mouseup', function () {
191
+ if (!dragging) return;
192
+ dragging = false;
193
+ document.body.classList.remove('t4-dragging');
194
+ gutter.classList.remove('drag');
195
+ });
196
+
197
+ window.addEventListener('resize', fit);
198
+ setMode(mode);
199
+ applyPanels();
200
+ })();
201
+ </script>
202
+ </body></html>