tina4ruby 3.13.148 → 3.13.150
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/CHANGELOG.md +26 -0
- data/lib/tina4/auto_crud.rb +49 -0
- data/lib/tina4/cli.rb +79 -20
- data/lib/tina4/crud.rb +411 -659
- data/lib/tina4/dev_admin.rb +67 -1
- data/lib/tina4/dispatch_pipeline.rb +3 -0
- data/lib/tina4/messenger.rb +52 -2
- data/lib/tina4/metrics.rb +5 -1
- data/lib/tina4/migration.rb +132 -16
- data/lib/tina4/plan.rb +1 -1
- data/lib/tina4/public/bundled-component-provenance.json +2 -2
- data/lib/tina4/public/js/tina4-dev-admin-split.html +202 -0
- data/lib/tina4/public/js/tina4-dev-admin.min.js +147 -138
- data/lib/tina4/templates/crud/form.twig +26 -0
- data/lib/tina4/templates/crud/modals.twig +31 -0
- data/lib/tina4/templates/crud/page.twig +401 -0
- data/lib/tina4/templates/crud/table.twig +69 -0
- data/lib/tina4/version.rb +1 -1
- metadata +7 -2
data/lib/tina4/dev_admin.rb
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
require "json"
|
|
10
10
|
require "digest"
|
|
11
|
+
require "ipaddr"
|
|
11
12
|
require "tmpdir"
|
|
12
13
|
require "net/http"
|
|
13
14
|
require "uri"
|
|
@@ -438,6 +439,8 @@ module Tina4
|
|
|
438
439
|
serve_dashboard
|
|
439
440
|
when ["GET", "/__dev/js/tina4-dev-admin.min.js"]
|
|
440
441
|
serve_dev_js
|
|
442
|
+
when ["GET", "/__dev/split"], ["GET", "/__dev/split/"]
|
|
443
|
+
serve_split
|
|
441
444
|
when ["GET", "/__dev/toolbar.css"]
|
|
442
445
|
# tina4stack #115: the injected toolbar's stylesheet, served as an
|
|
443
446
|
# external asset so the toolbar carries no inline style= and renders
|
|
@@ -933,6 +936,18 @@ module Tina4
|
|
|
933
936
|
end
|
|
934
937
|
end
|
|
935
938
|
|
|
939
|
+
# Detached split view — dashboard/editor LEFT, live app preview RIGHT.
|
|
940
|
+
# Served from the SHARED asset (the same tina4-dev-admin-split.html the
|
|
941
|
+
# python/php/node frameworks serve); a plain shell, no toolbar injected.
|
|
942
|
+
def serve_split
|
|
943
|
+
path = File.join(File.dirname(__FILE__), "public", "js", "tina4-dev-admin-split.html")
|
|
944
|
+
if File.file?(path)
|
|
945
|
+
[200, { "content-type" => "text/html; charset=utf-8" }, [File.read(path)]]
|
|
946
|
+
else
|
|
947
|
+
[404, { "content-type" => "text/plain" }, ["tina4-dev-admin-split.html not found"]]
|
|
948
|
+
end
|
|
949
|
+
end
|
|
950
|
+
|
|
936
951
|
def status_payload
|
|
937
952
|
db_table_count = 0
|
|
938
953
|
begin
|
|
@@ -2134,6 +2149,11 @@ module Tina4
|
|
|
2134
2149
|
# (with_mcp_gate -> 404), so the REST loopback arm skips these prefixes and
|
|
2135
2150
|
# lets the MCP gate govern (keeps a disallowed mcp/call a 404, not a 403).
|
|
2136
2151
|
DEV_MCP_PREFIXES = ["/__dev/api/mcp", "/__dev/mcp"].freeze
|
|
2152
|
+
|
|
2153
|
+
# Static dev-toolbar assets that carry no secrets and expose no actions, so
|
|
2154
|
+
# the peer gate does not cover them: wherever the toolbar is injected its
|
|
2155
|
+
# stylesheet and script must load (issue #279). Host + same-origin still apply.
|
|
2156
|
+
DEV_PUBLIC_ASSETS = ["/__dev/toolbar.css", "/__dev/toolbar.js"].freeze
|
|
2137
2157
|
# Private-key / credential basenames the file endpoints must never serve.
|
|
2138
2158
|
DEV_SECRET_BASENAMES = %w[.env .envrc id_rsa id_dsa id_ecdsa id_ed25519].freeze
|
|
2139
2159
|
DEV_SECRET_SUFFIXES = %w[.pem .key .pfx .p12 .keystore .jks].freeze
|
|
@@ -2186,9 +2206,14 @@ module Tina4
|
|
|
2186
2206
|
return dev_refused("dev-admin: refused (host not allowed)") unless dev_host_allowed?(env)
|
|
2187
2207
|
return dev_refused("dev-admin: refused (cross-origin request)") unless dev_same_origin_ok?(env)
|
|
2188
2208
|
|
|
2209
|
+
# The static toolbar assets are exempt from the peer gate (#279): they
|
|
2210
|
+
# carry no secrets and the toolbar needs them wherever it is injected.
|
|
2211
|
+
# Host + same-origin above still apply.
|
|
2212
|
+
return nil if DEV_PUBLIC_ASSETS.include?(path)
|
|
2213
|
+
|
|
2189
2214
|
unless path.start_with?(*DEV_MCP_PREFIXES)
|
|
2190
2215
|
remote_ip = (env["REMOTE_ADDR"] || "").to_s
|
|
2191
|
-
unless
|
|
2216
|
+
unless dev_peer_allowed?(remote_ip) || mcp_token_ok?(env, dedicated: true)
|
|
2192
2217
|
return dev_refused("dev-admin: refused (non-loopback peer)")
|
|
2193
2218
|
end
|
|
2194
2219
|
end
|
|
@@ -2216,6 +2241,47 @@ module Tina4
|
|
|
2216
2241
|
end
|
|
2217
2242
|
public :dev_host_allowed?
|
|
2218
2243
|
|
|
2244
|
+
# True when raw peer +ip+ falls inside +entry+ (a bare IP or a CIDR). IPv4
|
|
2245
|
+
# and IPv6; an IPv4-mapped IPv6 peer (::ffff:a.b.c.d) is matched as IPv4.
|
|
2246
|
+
def ip_in_cidr?(ip, entry)
|
|
2247
|
+
ip = ip.to_s.strip
|
|
2248
|
+
ip = ip[7..] if ip.downcase.start_with?("::ffff:") # mapped -> compare as IPv4
|
|
2249
|
+
net = IPAddr.new(entry.to_s.strip)
|
|
2250
|
+
addr = IPAddr.new(ip)
|
|
2251
|
+
return false unless net.family == addr.family # v4 peer never matches a v6 CIDR
|
|
2252
|
+
net.include?(addr)
|
|
2253
|
+
rescue IPAddr::Error, ArgumentError
|
|
2254
|
+
false
|
|
2255
|
+
end
|
|
2256
|
+
public :ip_in_cidr?
|
|
2257
|
+
|
|
2258
|
+
# True when the RAW socket peer may reach /__dev: loopback always, plus any
|
|
2259
|
+
# IP/CIDR in TINA4_DEV_ALLOWED_PEERS (comma-separated, opt-in, default none).
|
|
2260
|
+
# Reads the real socket peer ONLY, never a forwarded header, so it cannot be
|
|
2261
|
+
# spoofed by X-Forwarded-For. The documented way to reach the dev dashboard
|
|
2262
|
+
# from a Docker dev box, where requests arrive from the container-network
|
|
2263
|
+
# gateway rather than loopback (issue #279).
|
|
2264
|
+
def dev_peer_allowed?(remote_ip)
|
|
2265
|
+
return true if Tina4.is_loopback?(remote_ip)
|
|
2266
|
+
|
|
2267
|
+
configured = ENV["TINA4_DEV_ALLOWED_PEERS"].to_s.strip
|
|
2268
|
+
return false if configured.empty?
|
|
2269
|
+
|
|
2270
|
+
configured.split(",").any? { |entry| !entry.strip.empty? && ip_in_cidr?(remote_ip, entry) }
|
|
2271
|
+
end
|
|
2272
|
+
public :dev_peer_allowed?
|
|
2273
|
+
|
|
2274
|
+
# True when the toolbar may be injected for this viewer - the Host allow-list
|
|
2275
|
+
# and the raw-peer gate both pass. A viewer the /__dev gate would refuse gets
|
|
2276
|
+
# NO toolbar markup, so its stylesheet and script are never requested only to
|
|
2277
|
+
# 403 (issue #279).
|
|
2278
|
+
def dev_toolbar_allowed?(env)
|
|
2279
|
+
return false unless dev_host_allowed?(env)
|
|
2280
|
+
|
|
2281
|
+
dev_peer_allowed?((env["REMOTE_ADDR"] || "").to_s)
|
|
2282
|
+
end
|
|
2283
|
+
public :dev_toolbar_allowed?
|
|
2284
|
+
|
|
2219
2285
|
def dev_refused(message)
|
|
2220
2286
|
[403, { "content-type" => "application/json; charset=utf-8" },
|
|
2221
2287
|
[JSON.generate({ "ok" => false, "error" => message })]]
|
|
@@ -587,6 +587,9 @@ module Tina4
|
|
|
587
587
|
status, headers, body_parts = response
|
|
588
588
|
content_type = headers["content-type"] || ""
|
|
589
589
|
return nil unless content_type.include?("text/html")
|
|
590
|
+
# Do not inject the toolbar for a viewer the /__dev gate would refuse (#279):
|
|
591
|
+
# otherwise the page carries toolbar markup whose stylesheet and script 403.
|
|
592
|
+
return nil unless Tina4::DevAdmin.dev_toolbar_allowed?(ctx.env)
|
|
590
593
|
|
|
591
594
|
request_info = {
|
|
592
595
|
method: ctx.method,
|
data/lib/tina4/messenger.rb
CHANGED
|
@@ -90,14 +90,15 @@ module Tina4
|
|
|
90
90
|
|
|
91
91
|
attr_reader :host, :port, :username, :from_address, :from_name,
|
|
92
92
|
:imap_host, :imap_port, :use_tls, :encryption,
|
|
93
|
-
:imap_encryption, :imap_use_tls, :imap_username, :imap_password
|
|
93
|
+
:imap_encryption, :imap_use_tls, :imap_username, :imap_password,
|
|
94
|
+
:timeout
|
|
94
95
|
|
|
95
96
|
# Initialize with SMTP config.
|
|
96
97
|
# Priority: constructor params > ENV (TINA4_MAIL_*) > sensible defaults
|
|
97
98
|
def initialize(host: nil, port: nil, username: nil, password: nil,
|
|
98
99
|
from_address: nil, from_name: nil, encryption: nil, use_tls: nil,
|
|
99
100
|
imap_host: nil, imap_port: nil, imap_encryption: nil,
|
|
100
|
-
imap_username: nil, imap_password: nil)
|
|
101
|
+
imap_username: nil, imap_password: nil, timeout: nil)
|
|
101
102
|
# Whether a host was actually CONFIGURED, which is not the same as @host being
|
|
102
103
|
# set: it falls back to "localhost", so it is never nil and cannot answer
|
|
103
104
|
# "can this messenger send?". The capture gate needs that answer, so record it
|
|
@@ -143,7 +144,55 @@ module Tina4
|
|
|
143
144
|
# account read the wrong mailbox. Explicit constructor args win (ADR-0041).
|
|
144
145
|
@imap_username = imap_username || ENV["TINA4_MAIL_IMAP_USERNAME"] || @username
|
|
145
146
|
@imap_password = imap_password || ENV["TINA4_MAIL_IMAP_PASSWORD"] || @password
|
|
147
|
+
|
|
148
|
+
# SMTP send timeout (seconds): constructor > TINA4_MAIL_TIMEOUT > 30.
|
|
149
|
+
# Whole seconds, at least 1. See resolve_timeout. Parity with the PHP
|
|
150
|
+
# master (Messenger::resolveTimeout, tina4-php#286).
|
|
151
|
+
@timeout = resolve_timeout(timeout, ENV["TINA4_MAIL_TIMEOUT"])
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
#: Default socket timeout, in seconds - the value this class has always used.
|
|
155
|
+
TIMEOUT_DEFAULT = 30
|
|
156
|
+
#: Raw TINA4_MAIL_TIMEOUT values already warned about, so a bad one is said once.
|
|
157
|
+
@timeout_warned = {}
|
|
158
|
+
class << self
|
|
159
|
+
attr_reader :timeout_warned
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Resolve the SMTP send timeout to whole seconds (issue #278).
|
|
163
|
+
#
|
|
164
|
+
# An explicit constructor value is the caller's own instruction, so one below
|
|
165
|
+
# a second is a programming error and raises. A bad env value is a
|
|
166
|
+
# misconfiguration: warn once and use the default rather than guess. Zero and
|
|
167
|
+
# negative are garbage, not an opt-out - to the socket 0 means "do not wait at
|
|
168
|
+
# all", so no send could succeed.
|
|
169
|
+
def resolve_timeout(explicit, raw)
|
|
170
|
+
unless explicit.nil?
|
|
171
|
+
seconds = Integer(explicit)
|
|
172
|
+
raise ArgumentError, "Messenger timeout must be at least 1 second, got #{seconds}." if seconds < 1
|
|
173
|
+
|
|
174
|
+
return seconds
|
|
175
|
+
end
|
|
176
|
+
return TIMEOUT_DEFAULT if raw.nil? || raw.to_s.strip.empty?
|
|
177
|
+
|
|
178
|
+
seconds = begin
|
|
179
|
+
Integer(raw.to_s.strip)
|
|
180
|
+
rescue ArgumentError
|
|
181
|
+
nil
|
|
182
|
+
end
|
|
183
|
+
if seconds.nil? || seconds < 1
|
|
184
|
+
unless self.class.timeout_warned.key?(raw)
|
|
185
|
+
self.class.timeout_warned[raw] = true
|
|
186
|
+
Tina4::Log.warning(
|
|
187
|
+
"TINA4_MAIL_TIMEOUT must be a whole number of seconds and at least 1, got " \
|
|
188
|
+
"#{raw.inspect} - using the default of #{TIMEOUT_DEFAULT} seconds"
|
|
189
|
+
)
|
|
190
|
+
end
|
|
191
|
+
return TIMEOUT_DEFAULT
|
|
192
|
+
end
|
|
193
|
+
seconds
|
|
146
194
|
end
|
|
195
|
+
private :resolve_timeout
|
|
147
196
|
|
|
148
197
|
def normalize_encryption(value, label)
|
|
149
198
|
normalized = value.to_s.strip.downcase
|
|
@@ -486,6 +535,7 @@ module Tina4
|
|
|
486
535
|
starttls: @use_tls,
|
|
487
536
|
username: authenticate ? @username : nil,
|
|
488
537
|
password: authenticate ? @password : nil,
|
|
538
|
+
timeout: @timeout,
|
|
489
539
|
&block
|
|
490
540
|
)
|
|
491
541
|
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
|
-
|
|
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}"
|
data/lib/tina4/migration.rb
CHANGED
|
@@ -6,42 +6,51 @@
|
|
|
6
6
|
# file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
7
7
|
|
|
8
8
|
require "fileutils"
|
|
9
|
+
require "digest"
|
|
10
|
+
require "tmpdir"
|
|
9
11
|
|
|
10
12
|
module Tina4
|
|
11
13
|
class Migration
|
|
12
14
|
TRACKING_TABLE = "tina4_migration"
|
|
15
|
+
# Lock name shared by GET_LOCK (MySQL) and sp_getapplock (MSSQL).
|
|
16
|
+
LOCK_NAME = "tina4_migration_lock"
|
|
13
17
|
|
|
14
18
|
attr_reader :db, :migrations_dir
|
|
15
19
|
|
|
16
20
|
def initialize(db, migrations_dir: nil)
|
|
17
21
|
@db = db
|
|
18
22
|
@migrations_dir = migrations_dir || resolve_migrations_dir
|
|
19
|
-
|
|
23
|
+
# Ruby boots once per process (no per-request re-entry like PHP's php -S),
|
|
24
|
+
# so there is no concurrent CONSTRUCT race here — the table is ensured
|
|
25
|
+
# eagerly as before. migrate also re-ensures it UNDER the run-wide lock
|
|
26
|
+
# (idempotent), which is where the #277 apply race is actually serialized.
|
|
27
|
+
ensure_tracking_table if @db
|
|
20
28
|
end
|
|
21
29
|
|
|
22
|
-
# Run all pending migrations
|
|
30
|
+
# Run all pending migrations under a cross-process lock.
|
|
31
|
+
#
|
|
32
|
+
# The lock serializes concurrent startup migrations (#277): the hook runs per
|
|
33
|
+
# request under php -S-style servers and once per process on a long-lived
|
|
34
|
+
# server, so several workers could reach a fresh database at once and apply
|
|
35
|
+
# the same migration more than once (a data migration then inserted its rows
|
|
36
|
+
# twice). Now the winner migrates while the rest block, then re-read the
|
|
37
|
+
# applied set and find nothing pending. Every backend auto-releases the lock
|
|
38
|
+
# when the holder's session/process dies, so a crash never deadlocks boot.
|
|
23
39
|
def migrate
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
batch = next_batch_number
|
|
31
|
-
results = []
|
|
32
|
-
pending.each do |file|
|
|
33
|
-
result = run_migration(file, batch)
|
|
34
|
-
results << result
|
|
35
|
-
# Stop on failure
|
|
36
|
-
break if result[:status] == "failed"
|
|
40
|
+
lock = acquire_migration_lock
|
|
41
|
+
begin
|
|
42
|
+
ensure_tracking_table
|
|
43
|
+
run_pending
|
|
44
|
+
ensure
|
|
45
|
+
release_migration_lock(lock)
|
|
37
46
|
end
|
|
38
|
-
results
|
|
39
47
|
end
|
|
40
48
|
|
|
41
49
|
alias run migrate
|
|
42
50
|
|
|
43
51
|
# Rollback last batch (or N steps)
|
|
44
52
|
def rollback(steps = 1)
|
|
53
|
+
ensure_tracking_table
|
|
45
54
|
completed = completed_migrations_with_batch
|
|
46
55
|
return [] if completed.empty?
|
|
47
56
|
|
|
@@ -60,6 +69,7 @@ module Tina4
|
|
|
60
69
|
end
|
|
61
70
|
|
|
62
71
|
def status
|
|
72
|
+
ensure_tracking_table
|
|
63
73
|
{
|
|
64
74
|
completed: completed_migrations,
|
|
65
75
|
pending: pending_migrations.map { |f| File.basename(f) }
|
|
@@ -178,6 +188,112 @@ module Tina4
|
|
|
178
188
|
|
|
179
189
|
private
|
|
180
190
|
|
|
191
|
+
# Apply every pending migration, in order. Caller holds the run-wide lock.
|
|
192
|
+
def run_pending
|
|
193
|
+
pending = pending_migrations
|
|
194
|
+
if pending.empty?
|
|
195
|
+
Tina4::Log.info("No pending migrations")
|
|
196
|
+
return []
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
batch = next_batch_number
|
|
200
|
+
results = []
|
|
201
|
+
pending.each do |file|
|
|
202
|
+
result = run_migration(file, batch)
|
|
203
|
+
results << result
|
|
204
|
+
# Stop on failure
|
|
205
|
+
break if result[:status] == "failed"
|
|
206
|
+
end
|
|
207
|
+
results
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# A stable signed-64-bit key for PostgreSQL's pg_advisory_lock, derived from
|
|
211
|
+
# the lock name so it cannot collide with an application's own advisory-lock
|
|
212
|
+
# keys. It is a constant (not user input), so inlining it in the SQL is
|
|
213
|
+
# injection-safe.
|
|
214
|
+
def pg_advisory_key
|
|
215
|
+
Digest::SHA256.digest(LOCK_NAME)[0, 8].unpack1("q")
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# Take the run-wide migration lock, blocking until it is held.
|
|
219
|
+
#
|
|
220
|
+
# PostgreSQL/MySQL/MSSQL use a native session-scoped advisory lock; SQLite,
|
|
221
|
+
# Firebird and anything else fall back to an OS advisory file lock on a
|
|
222
|
+
# sidecar in the migrations folder. A backend that cannot lock degrades to the
|
|
223
|
+
# file lock, and finally to running unlocked (the pre-#277 behaviour) rather
|
|
224
|
+
# than blocking boot. Returns [kind, resource] for release_migration_lock.
|
|
225
|
+
def acquire_migration_lock
|
|
226
|
+
engine = (@db.respond_to?(:get_database_type) ? @db.get_database_type : "").to_s.downcase
|
|
227
|
+
|
|
228
|
+
begin
|
|
229
|
+
if engine.start_with?("postgres")
|
|
230
|
+
@db.fetch("SELECT pg_advisory_lock(#{pg_advisory_key}) AS locked")
|
|
231
|
+
return [:postgres, nil]
|
|
232
|
+
elsif engine.start_with?("mysql")
|
|
233
|
+
# -1 = wait indefinitely; GET_LOCK is connection-scoped.
|
|
234
|
+
@db.fetch("SELECT GET_LOCK('#{LOCK_NAME}', -1) AS locked")
|
|
235
|
+
return [:mysql, nil]
|
|
236
|
+
elsif engine == "mssql" || engine == "sqlserver"
|
|
237
|
+
@db.execute(
|
|
238
|
+
"DECLARE @res INT; EXEC @res = sp_getapplock @Resource = '#{LOCK_NAME}', " \
|
|
239
|
+
"@LockMode = 'Exclusive', @LockOwner = 'Session', @LockTimeout = -1"
|
|
240
|
+
)
|
|
241
|
+
return [:mssql, nil]
|
|
242
|
+
end
|
|
243
|
+
rescue => e
|
|
244
|
+
Tina4::Log.debug("DB migration lock unavailable (#{engine}): #{e.message}; using a file lock")
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
acquire_file_lock
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
# OS advisory file lock on a sidecar — the portable fallback (crash-safe: the
|
|
251
|
+
# kernel drops it when the process exits).
|
|
252
|
+
def acquire_file_lock
|
|
253
|
+
handle = File.open(file_lock_path, File::RDWR | File::CREAT, 0o644)
|
|
254
|
+
handle.flock(File::LOCK_EX)
|
|
255
|
+
[:file, handle]
|
|
256
|
+
rescue => e
|
|
257
|
+
Tina4::Log.debug("file migration lock unavailable: #{e.message}; running unlocked")
|
|
258
|
+
[:none, nil]
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
# Where the advisory lock file lives: the system temp directory, NOT the
|
|
262
|
+
# migrations folder. The lock is a runtime artifact, not a migration — a
|
|
263
|
+
# dotfile left in the tracked migrations/ directory gets committed by accident
|
|
264
|
+
# and blocks a plain rmdir of the folder. The name is derived from the ABSOLUTE
|
|
265
|
+
# migrations directory, so every worker of the SAME app lands on the SAME file
|
|
266
|
+
# and flock() serializes them, while two different apps get two different locks.
|
|
267
|
+
# This keeps the lock scope identical to before; only the file's location
|
|
268
|
+
# changed. Mirrors the PHP reference's fileLockPath() (issue #277).
|
|
269
|
+
def file_lock_path
|
|
270
|
+
key = begin
|
|
271
|
+
File.realpath(@migrations_dir)
|
|
272
|
+
rescue StandardError
|
|
273
|
+
@migrations_dir
|
|
274
|
+
end
|
|
275
|
+
File.join(Dir.tmpdir, "tina4-migration-#{Digest::SHA256.hexdigest(key)}.lock")
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def release_migration_lock(handle)
|
|
279
|
+
kind, resource = handle
|
|
280
|
+
case kind
|
|
281
|
+
when :postgres
|
|
282
|
+
@db.fetch("SELECT pg_advisory_unlock(#{pg_advisory_key}) AS released")
|
|
283
|
+
when :mysql
|
|
284
|
+
@db.fetch("SELECT RELEASE_LOCK('#{LOCK_NAME}') AS released")
|
|
285
|
+
when :mssql
|
|
286
|
+
@db.execute("EXEC sp_releaseapplock @Resource = '#{LOCK_NAME}', @LockOwner = 'Session'")
|
|
287
|
+
when :file
|
|
288
|
+
if resource
|
|
289
|
+
resource.flock(File::LOCK_UN)
|
|
290
|
+
resource.close
|
|
291
|
+
end
|
|
292
|
+
end
|
|
293
|
+
rescue => e
|
|
294
|
+
Tina4::Log.debug("migration lock release failed (#{handle&.first}): #{e.message}")
|
|
295
|
+
end
|
|
296
|
+
|
|
181
297
|
# Resolve migrations directory: prefer src/migrations, fall back to migrations/
|
|
182
298
|
def resolve_migrations_dir
|
|
183
299
|
# Canonical location is migrations/ (project root) — matches the Python reference, the CLI,
|
data/lib/tina4/plan.rb
CHANGED
|
@@ -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:
|
|
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
|
|
@@ -41,8 +41,8 @@
|
|
|
41
41
|
},
|
|
42
42
|
{
|
|
43
43
|
"path": "js/tina4-dev-admin.min.js",
|
|
44
|
-
"component": "tina4-dev-admin-
|
|
45
|
-
"sha256": "
|
|
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">⇱ 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>
|