tina4ruby 3.13.120 → 3.13.121
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/cli.rb +193 -28
- data/lib/tina4/dev_admin.rb +45 -28
- data/lib/tina4/mcp.rb +97 -4
- data/lib/tina4/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 5432ec631264ef420f357f5b662a67ae7d8a22d8f32dfc719dc568e2713f0320
|
|
4
|
+
data.tar.gz: 0bfad1aff49e4b87548dc0d2ec13b27671295205189404252615810a245b89eb
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 8a2875da36ab669f4504833a97c65f85b0f37b48bd622ce692c92f567e3b65bf778950a80167b1e18df1e6617fa9bd4c39008f5efa5d0a6c624ef499789931d2
|
|
7
|
+
data.tar.gz: d090ea23d3fc4a07e1f0d249970e06300e76718464357ea815937136699b4da9daec59d4d37f425a9a0daa9e2e76698b3eea7d17ebfc6f24ebd1a8d77cb3a2d2
|
data/lib/tina4/cli.rb
CHANGED
|
@@ -292,14 +292,20 @@ module Tina4
|
|
|
292
292
|
DEFAULT_FIELDS = [["name", "string"]].freeze
|
|
293
293
|
|
|
294
294
|
# ADR-0063 (scaffolding envelope v1.1): `# tina4:edit <label>` in a generated
|
|
295
|
-
# Ruby file,
|
|
295
|
+
# Ruby file, `-- tina4:edit <label>` in a generated SQL file, or
|
|
296
|
+
# `{# tina4:edit <label> #}` in a generated Twig template marks a
|
|
296
297
|
# first-edit spot for a developer / coding agent to fill in. The scanner
|
|
297
298
|
# surfaces every match as an `edit_hints` entry in the `generate_v1_1`
|
|
298
|
-
# envelope AND under "Edit these lines:" in the human stderr block.
|
|
299
|
-
#
|
|
300
|
-
#
|
|
301
|
-
#
|
|
302
|
-
|
|
299
|
+
# envelope AND under "Edit these lines:" in the human stderr block.
|
|
300
|
+
#
|
|
301
|
+
# One regex covers all three comment styles - `//`-style (Ruby `#`), SQL
|
|
302
|
+
# (`--`), and Twig (`{# ... #}`) - so the same scanner drives
|
|
303
|
+
# model / migration / middleware / route AND the form + view Twig
|
|
304
|
+
# generators (ADR-0063 twig-scanner parity, 3.13.121). The optional
|
|
305
|
+
# `(?:\s*#\})?` tail strips the Twig closing sequence so the captured
|
|
306
|
+
# label stays clean ("add fields here", not "add fields here #}").
|
|
307
|
+
# Ported verbatim from tina4-php's `collectEditHintsFromContent`.
|
|
308
|
+
EDIT_MARKER_RE = %r{(?://|#|--|\{\#)\s*tina4:edit[ \t]+(.+?)(?:\s*\#\})?\s*$}.freeze
|
|
303
309
|
|
|
304
310
|
# ADR-0063 next-step catalogue: 5 short actionable lines per resolution-aware
|
|
305
311
|
# verb. Rendered under "Next:" in the human block AND surfaced as
|
|
@@ -354,8 +360,43 @@ module Tina4
|
|
|
354
360
|
"Chain more: order in the middleware: [] list is registration order",
|
|
355
361
|
]
|
|
356
362
|
end,
|
|
363
|
+
"form" => lambda do |res|
|
|
364
|
+
class_name = res["class_name"] || "Form"
|
|
365
|
+
file_path = res["file_path"] || "src/templates/forms/form.twig"
|
|
366
|
+
route = (res["routes"] || []).first || "/#{to_snake_case_static(class_name)}"
|
|
367
|
+
[
|
|
368
|
+
"Edit #{file_path} to restyle / add fields to the form",
|
|
369
|
+
"Render it: response.render(\"forms/#{File.basename(file_path)}\", { item: item })",
|
|
370
|
+
"Wire it to a route: tina4ruby generate route #{class_name}",
|
|
371
|
+
"Add a matching model: tina4ruby generate model #{class_name}",
|
|
372
|
+
"Try it: tina4ruby serve -> http://localhost:7147#{route}",
|
|
373
|
+
]
|
|
374
|
+
end,
|
|
375
|
+
"view" => lambda do |res|
|
|
376
|
+
class_name = res["class_name"] || "View"
|
|
377
|
+
list_path = res["file_path"] || "src/templates/pages/list.twig"
|
|
378
|
+
detail_path = (res["transformations"] || []).find { |t| t["kind"] == "detail_view" }
|
|
379
|
+
detail_path = detail_path ? detail_path["to"] : list_path.sub(/list\.twig$/, "detail.twig")
|
|
380
|
+
route = (res["routes"] || []).first || "/#{to_snake_case_static(class_name)}"
|
|
381
|
+
[
|
|
382
|
+
"Edit #{list_path} for the list view (add sort / filter / pagination)",
|
|
383
|
+
"Edit #{detail_path} for the detail view (add related records / actions)",
|
|
384
|
+
"Wire it to a route: tina4ruby generate route #{class_name}",
|
|
385
|
+
"Add a matching model: tina4ruby generate model #{class_name}",
|
|
386
|
+
"Try it: tina4ruby serve -> http://localhost:7147#{route}",
|
|
387
|
+
]
|
|
388
|
+
end,
|
|
357
389
|
}.freeze
|
|
358
390
|
|
|
391
|
+
# Static helper for the NEXT_STEPS lambdas above - avoids reaching for the
|
|
392
|
+
# per-instance to_snake_case when only the class name is known.
|
|
393
|
+
def self.to_snake_case_static(name)
|
|
394
|
+
s = name.to_s
|
|
395
|
+
s.gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
|
|
396
|
+
.gsub(/([a-z\d])([A-Z])/, '\1_\2')
|
|
397
|
+
.downcase
|
|
398
|
+
end
|
|
399
|
+
|
|
359
400
|
# ADR-0063: freeze `Time.now` for the duration of one `cmd_generate` call.
|
|
360
401
|
# Both the resolution builders (`build_migration_resolution`,
|
|
361
402
|
# `build_model_resolution`) and the actual generator (`generate_migration`)
|
|
@@ -692,22 +733,52 @@ module Tina4
|
|
|
692
733
|
|
|
693
734
|
# ── migrate:create ───────────────────────────────────────────────────
|
|
694
735
|
|
|
695
|
-
# Create a new timestamped migration file (UP .sql + .down.sql)
|
|
696
|
-
#
|
|
697
|
-
#
|
|
698
|
-
#
|
|
699
|
-
#
|
|
736
|
+
# Create a new timestamped migration file (UP .sql + .down.sql). The
|
|
737
|
+
# description can be human prose ("add users table") — it is slugified into
|
|
738
|
+
# a snake_case name ("add_users_table") and then handed to the SAME
|
|
739
|
+
# resolution-aware generator that backs `tina4ruby generate migration`, so
|
|
740
|
+
# both CLI paths emit the ADR-0063 `generate_v1_1` envelope with matching
|
|
741
|
+
# `edit_hints[]`, `next[]`, human resolution block, and the same
|
|
742
|
+
# `# tina4:edit` markers in the generated .sql files. The only intentional
|
|
743
|
+
# difference is `emit_test: false` — `migrate:create` never co-emits a
|
|
744
|
+
# test file, preserving its "just a migration" semantics. CHEAP +
|
|
745
|
+
# database-free: it never boots the app, opens a DB, or writes a tracking
|
|
746
|
+
# row.
|
|
747
|
+
#
|
|
748
|
+
# Accepts `--json` and `--dry-run` (same as `generate migration`); anything
|
|
749
|
+
# else is treated as description tokens joined with a space (parity with
|
|
750
|
+
# Python's `" ".join(args)`).
|
|
700
751
|
def cmd_migrate_create(argv)
|
|
701
|
-
|
|
752
|
+
argv = argv || []
|
|
753
|
+
flags, positional = parse_flags(argv)
|
|
754
|
+
json_mode = flags.delete("json") ? true : false
|
|
755
|
+
dry_run = flags.delete("dry-run") ? true : false
|
|
756
|
+
|
|
757
|
+
description = positional.join(" ").strip
|
|
702
758
|
if description.empty?
|
|
703
759
|
puts "Usage: tina4ruby migrate:create <description>"
|
|
704
760
|
exit 1
|
|
705
761
|
end
|
|
706
762
|
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
763
|
+
# Slugify the human description into a snake_case migration NAME so the
|
|
764
|
+
# filename matches what `generate migration <name>` would produce, and
|
|
765
|
+
# so the generator's `create_X` schema-awareness fires when the
|
|
766
|
+
# description starts with "create <thing>". Regex + downcase mirrors the
|
|
767
|
+
# Python master's `re.sub(r'[^a-z0-9]+', '_', desc, flags=re.I).lower()`
|
|
768
|
+
# convention that the four frameworks share for migration filenames.
|
|
769
|
+
name = description.gsub(/[^a-z0-9]+/i, "_").downcase.gsub(/^_|_$/, "")
|
|
770
|
+
|
|
771
|
+
# Fresh timestamp per command run so the sandbox pre-run (edit_hints)
|
|
772
|
+
# and the real generator agree on the same second — ADR-0063.
|
|
773
|
+
reset_generate_timestamp!
|
|
774
|
+
|
|
775
|
+
run_resolution_aware_generator(
|
|
776
|
+
"migration", name, flags,
|
|
777
|
+
json_mode: json_mode,
|
|
778
|
+
dry_run: dry_run,
|
|
779
|
+
gen_spec: GENERATORS["migration"],
|
|
780
|
+
generator_kwargs: { emit_test: false, description: description }
|
|
781
|
+
)
|
|
711
782
|
end
|
|
712
783
|
|
|
713
784
|
# ── migrate:status ─────────────────────────────────────────────────────
|
|
@@ -1249,22 +1320,49 @@ module Tina4
|
|
|
1249
1320
|
exit 1
|
|
1250
1321
|
end
|
|
1251
1322
|
|
|
1252
|
-
# Only the
|
|
1323
|
+
# Only the resolution-aware targets emit a resolution + JSON envelope;
|
|
1253
1324
|
# every other generator keeps its existing behaviour so nothing regresses.
|
|
1254
|
-
|
|
1325
|
+
# ADR-0063 (3.13.121): form + view joined so their baked `{# tina4:edit ... #}`
|
|
1326
|
+
# markers surface in resolution.edit_hints[] under the same v1.1 envelope.
|
|
1327
|
+
resolution_aware = %w[model route migration middleware form view]
|
|
1255
1328
|
if !resolution_aware.include?(what)
|
|
1256
1329
|
send(gen_spec[:handler], name, flags)
|
|
1257
1330
|
return
|
|
1258
1331
|
end
|
|
1259
1332
|
|
|
1333
|
+
run_resolution_aware_generator(
|
|
1334
|
+
what, name, flags,
|
|
1335
|
+
json_mode: json_mode, dry_run: dry_run, gen_spec: gen_spec
|
|
1336
|
+
)
|
|
1337
|
+
end
|
|
1338
|
+
|
|
1339
|
+
# Shared entry point for the four resolution-aware generators (model,
|
|
1340
|
+
# route, migration, middleware) — used by BOTH `cmd_generate` and by
|
|
1341
|
+
# `cmd_migrate_create`, so both CLI paths emit the SAME ADR-0063 envelope
|
|
1342
|
+
# (`edit_hints[]`, `next[]`, human resolution block, `# tina4:edit`
|
|
1343
|
+
# markers). `generator_kwargs` are forwarded verbatim to the generator
|
|
1344
|
+
# method — that is how `cmd_migrate_create` passes `emit_test: false` and
|
|
1345
|
+
# `description:` without duplicating the whole envelope-emission path.
|
|
1346
|
+
def run_resolution_aware_generator(what, name, flags,
|
|
1347
|
+
json_mode:, dry_run:,
|
|
1348
|
+
gen_spec: nil,
|
|
1349
|
+
generator_kwargs: {})
|
|
1350
|
+
gen_spec ||= GENERATORS[what]
|
|
1351
|
+
|
|
1260
1352
|
resolution = build_generate_resolution(what, name, flags)
|
|
1261
1353
|
# ADR-0063 additive v1.1 keys: curated next-steps (pure fn of resolution)
|
|
1262
1354
|
# and edit_hints (scan of generated files for `# tina4:edit <label>`).
|
|
1263
1355
|
resolution["next"] = build_next_steps(what, resolution)
|
|
1264
|
-
resolution["edit_hints"] =
|
|
1356
|
+
resolution["edit_hints"] =
|
|
1357
|
+
collect_edit_hints_via_sandbox(gen_spec, name, flags, **generator_kwargs)
|
|
1265
1358
|
|
|
1266
1359
|
if json_mode
|
|
1267
|
-
actions_taken =
|
|
1360
|
+
actions_taken =
|
|
1361
|
+
if dry_run
|
|
1362
|
+
[]
|
|
1363
|
+
else
|
|
1364
|
+
run_generator_capturing_actions(gen_spec, name, flags, **generator_kwargs)
|
|
1365
|
+
end
|
|
1268
1366
|
envelope = build_generate_envelope(what, name, flags, resolution, actions_taken, dry_run)
|
|
1269
1367
|
puts JSON.pretty_generate(envelope)
|
|
1270
1368
|
return
|
|
@@ -1277,7 +1375,7 @@ module Tina4
|
|
|
1277
1375
|
if dry_run
|
|
1278
1376
|
$stderr.puts " (dry-run — no files written)"
|
|
1279
1377
|
else
|
|
1280
|
-
send(gen_spec[:handler], name, flags)
|
|
1378
|
+
send(gen_spec[:handler], name, flags, **generator_kwargs)
|
|
1281
1379
|
end
|
|
1282
1380
|
end
|
|
1283
1381
|
|
|
@@ -1323,7 +1421,11 @@ module Tina4
|
|
|
1323
1421
|
# `generate_timestamp` is shared with the real run so a migration file
|
|
1324
1422
|
# sandboxed here has the SAME timestamped basename as the file that lands
|
|
1325
1423
|
# in the developer's cwd - the hint's `file` field matches disk verbatim.
|
|
1326
|
-
|
|
1424
|
+
# `generator_kwargs` are forwarded verbatim to the generator method so the
|
|
1425
|
+
# sandbox pre-run produces the same files (and therefore the same edit
|
|
1426
|
+
# markers) as the real run — used by `cmd_migrate_create` to thread
|
|
1427
|
+
# `emit_test: false` + `description:` through without a bespoke sandbox.
|
|
1428
|
+
def collect_edit_hints_via_sandbox(gen_spec, name, flags, **generator_kwargs)
|
|
1327
1429
|
hints = []
|
|
1328
1430
|
Dir.mktmpdir("tina4_edit_hints") do |sandbox|
|
|
1329
1431
|
# Block-form Dir.chdir composes cleanly under an outer block-form
|
|
@@ -1337,7 +1439,7 @@ module Tina4
|
|
|
1337
1439
|
# A generator failure inside the sandbox must never break the caller's
|
|
1338
1440
|
# real run - rescue everything, return whatever hints we managed to
|
|
1339
1441
|
# gather. The real run will error the same way if it is going to.
|
|
1340
|
-
send(gen_spec[:handler], name, flags.dup)
|
|
1442
|
+
send(gen_spec[:handler], name, flags.dup, **generator_kwargs)
|
|
1341
1443
|
rescue StandardError, LoadError, ScriptError
|
|
1342
1444
|
# swallow - the sandbox is best-effort
|
|
1343
1445
|
ensure
|
|
@@ -1370,6 +1472,8 @@ module Tina4
|
|
|
1370
1472
|
when "route" then build_route_resolution(name, flags)
|
|
1371
1473
|
when "migration" then build_migration_resolution(name, flags)
|
|
1372
1474
|
when "middleware" then build_middleware_resolution(name, flags)
|
|
1475
|
+
when "form" then build_form_resolution(name, flags)
|
|
1476
|
+
when "view" then build_view_resolution(name, flags)
|
|
1373
1477
|
else
|
|
1374
1478
|
{ "class_name" => name.to_s, "table_name" => nil, "file_path" => nil,
|
|
1375
1479
|
"migration_path" => nil, "transformations" => [],
|
|
@@ -1455,6 +1559,54 @@ module Tina4
|
|
|
1455
1559
|
}
|
|
1456
1560
|
end
|
|
1457
1561
|
|
|
1562
|
+
# Form generator writes ONE Twig template in src/templates/forms/<snake>.twig.
|
|
1563
|
+
# `file_path` names that primary file; the scanner walks every generated
|
|
1564
|
+
# file in the sandbox, so the twig `{# tina4:edit ... #}` marker landing
|
|
1565
|
+
# inside it flows into resolution.edit_hints[] regardless.
|
|
1566
|
+
def build_form_resolution(name, _flags)
|
|
1567
|
+
snake = to_snake_case(name)
|
|
1568
|
+
route_name = to_route_name(name)
|
|
1569
|
+
{
|
|
1570
|
+
"class_name" => name.to_s,
|
|
1571
|
+
"table_name" => nil,
|
|
1572
|
+
"file_path" => "src/templates/forms/#{snake}.twig",
|
|
1573
|
+
"migration_path" => nil,
|
|
1574
|
+
"transformations" => [],
|
|
1575
|
+
"routes" => ["/api/#{route_name}"],
|
|
1576
|
+
"test_paths" => []
|
|
1577
|
+
}
|
|
1578
|
+
end
|
|
1579
|
+
|
|
1580
|
+
# View generator writes TWO Twig templates: a list view keyed by the
|
|
1581
|
+
# (plural) route name and a detail view keyed by the (singular) class
|
|
1582
|
+
# snake. `file_path` names the list (the primary entry point); the
|
|
1583
|
+
# detail path is surfaced under `transformations` so a caller / NEXT_STEPS
|
|
1584
|
+
# lambda / human block can find it without re-deriving. Scanner picks up
|
|
1585
|
+
# both files from the sandbox walk regardless of these fields.
|
|
1586
|
+
def build_view_resolution(name, _flags)
|
|
1587
|
+
snake = to_snake_case(name)
|
|
1588
|
+
route_name = to_route_name(name)
|
|
1589
|
+
list_path = "src/templates/pages/#{route_name}.twig"
|
|
1590
|
+
detail_path = "src/templates/pages/#{snake}.twig"
|
|
1591
|
+
{
|
|
1592
|
+
"class_name" => name.to_s,
|
|
1593
|
+
"table_name" => nil,
|
|
1594
|
+
"file_path" => list_path,
|
|
1595
|
+
"migration_path" => nil,
|
|
1596
|
+
"transformations" => [
|
|
1597
|
+
{
|
|
1598
|
+
"kind" => "detail_view",
|
|
1599
|
+
"from" => name.to_s,
|
|
1600
|
+
"to" => detail_path,
|
|
1601
|
+
"reason" => "list view is #{route_name}.twig; detail view keyed by the singular class snake",
|
|
1602
|
+
"override" => nil
|
|
1603
|
+
}
|
|
1604
|
+
],
|
|
1605
|
+
"routes" => ["/#{route_name}", "/#{route_name}/{id}"],
|
|
1606
|
+
"test_paths" => []
|
|
1607
|
+
}
|
|
1608
|
+
end
|
|
1609
|
+
|
|
1458
1610
|
def build_generate_envelope(target, name, flags, resolution, actions_taken, dry_run)
|
|
1459
1611
|
{
|
|
1460
1612
|
"command" => "generate",
|
|
@@ -1521,14 +1673,17 @@ module Tina4
|
|
|
1521
1673
|
# `actions_taken` list from the " Created <path>" lines the generators
|
|
1522
1674
|
# emit. Machine callers get one clean JSON envelope on stdout; the
|
|
1523
1675
|
# generator's own log stays discoverable via the returned list.
|
|
1524
|
-
|
|
1676
|
+
# `generator_kwargs` are forwarded verbatim to the generator method so
|
|
1677
|
+
# callers (e.g. `cmd_migrate_create`) can pass overrides like
|
|
1678
|
+
# `emit_test: false` without duplicating the envelope-emission path.
|
|
1679
|
+
def run_generator_capturing_actions(gen_spec, name, flags, **generator_kwargs)
|
|
1525
1680
|
require "stringio"
|
|
1526
1681
|
require "json"
|
|
1527
1682
|
buffer = StringIO.new
|
|
1528
1683
|
original = $stdout
|
|
1529
1684
|
$stdout = buffer
|
|
1530
1685
|
begin
|
|
1531
|
-
send(gen_spec[:handler], name, flags)
|
|
1686
|
+
send(gen_spec[:handler], name, flags, **generator_kwargs)
|
|
1532
1687
|
ensure
|
|
1533
1688
|
$stdout = original
|
|
1534
1689
|
end
|
|
@@ -1823,7 +1978,8 @@ module Tina4
|
|
|
1823
1978
|
|
|
1824
1979
|
# ── Generator: migration ─────────────────────────────────────────────
|
|
1825
1980
|
|
|
1826
|
-
def generate_migration(name, flags = {}, fields_override: nil, table_override: nil,
|
|
1981
|
+
def generate_migration(name, flags = {}, fields_override: nil, table_override: nil,
|
|
1982
|
+
emit_test: true, description: nil)
|
|
1827
1983
|
# ADR-0063: `generate_timestamp` is the shared memoised `Time.now` for
|
|
1828
1984
|
# this cmd_generate run; the resolution's `migration_path` uses the SAME
|
|
1829
1985
|
# value, so envelope and disk agree.
|
|
@@ -1832,6 +1988,15 @@ module Tina4
|
|
|
1832
1988
|
dir = "migrations"
|
|
1833
1989
|
FileUtils.mkdir_p(dir)
|
|
1834
1990
|
|
|
1991
|
+
# `description` (when supplied by `cmd_migrate_create`) keeps the raw
|
|
1992
|
+
# human prose that appears in the file HEADER — the filename still uses
|
|
1993
|
+
# the snake_case `name`, so the two CLI paths write matching filenames
|
|
1994
|
+
# while the delegating path preserves the readable "add users table"
|
|
1995
|
+
# comment inside the file. Falls back to `name` when no description was
|
|
1996
|
+
# supplied (the `tina4ruby generate migration` path, where `name` is
|
|
1997
|
+
# already snake_case).
|
|
1998
|
+
header_text = description || name
|
|
1999
|
+
|
|
1835
2000
|
# Determine table name
|
|
1836
2001
|
if table_override
|
|
1837
2002
|
table = table_override
|
|
@@ -1880,7 +2045,7 @@ module Tina4
|
|
|
1880
2045
|
# rollback SQL lives solely in the sibling .down.sql. Matches the Python
|
|
1881
2046
|
# master (tina4_python/cli/__init__.py).
|
|
1882
2047
|
content = <<~SQL
|
|
1883
|
-
-- Migration: #{
|
|
2048
|
+
-- Migration: #{header_text}
|
|
1884
2049
|
-- Created: #{now.strftime("%Y-%m-%d %H:%M:%S")}
|
|
1885
2050
|
|
|
1886
2051
|
#{up_sql}
|
|
@@ -1893,7 +2058,7 @@ module Tina4
|
|
|
1893
2058
|
down_filename = "#{timestamp}_#{name}.down.sql"
|
|
1894
2059
|
down_path = File.join(dir, down_filename)
|
|
1895
2060
|
down_content = <<~SQL
|
|
1896
|
-
-- Rollback: #{
|
|
2061
|
+
-- Rollback: #{header_text}
|
|
1897
2062
|
-- Created: #{now.strftime("%Y-%m-%d %H:%M:%S")}
|
|
1898
2063
|
|
|
1899
2064
|
#{down_sql}
|
data/lib/tina4/dev_admin.rb
CHANGED
|
@@ -7,6 +7,8 @@ require "net/http"
|
|
|
7
7
|
require "uri"
|
|
8
8
|
require "fileutils"
|
|
9
9
|
require "shellwords"
|
|
10
|
+
require "rbconfig" # RbConfig.ruby for scaffold_run's Open3.capture3 call to exe/tina4ruby
|
|
11
|
+
require "open3" # scaffold_run shells to exe/tina4ruby generate <kind> <name>
|
|
10
12
|
require_relative "metrics"
|
|
11
13
|
|
|
12
14
|
module Tina4
|
|
@@ -2294,38 +2296,53 @@ module Tina4
|
|
|
2294
2296
|
] }
|
|
2295
2297
|
end
|
|
2296
2298
|
|
|
2299
|
+
# scaffold_run — shells to `exe/tina4ruby generate <kind> <name>` so the
|
|
2300
|
+
# dev-admin endpoint emits the ADR-0063 resolution envelope through the
|
|
2301
|
+
# SAME code path the CLI + MCP go through, instead of the old inline
|
|
2302
|
+
# File.write branches that skipped every marker + envelope. Mirrors PHP
|
|
2303
|
+
# (Tina4/DevAdmin.php:2506-2520, shell_exec 'php bin/tina4php generate ...')
|
|
2304
|
+
# and Node (packages/core/src/devAdmin.ts:handleScaffoldRun, execFileSync
|
|
2305
|
+
# 'npx tina4nodejs generate ...'). The captured stdout+stderr is returned
|
|
2306
|
+
# as `output`, matching PHP/Node's shape.
|
|
2307
|
+
#
|
|
2308
|
+
# ADR-0063: the four resolution-aware verbs (route / model / migration /
|
|
2309
|
+
# middleware) are the whitelist here — the same set the CLI declares in
|
|
2310
|
+
# cli.rb's `resolution_aware`. An unknown kind returns {ok:false,error}
|
|
2311
|
+
# instead of shelling to the CLI with an invalid subcommand.
|
|
2312
|
+
SCAFFOLD_ALLOWED_KINDS = %w[route model migration middleware].freeze
|
|
2313
|
+
|
|
2297
2314
|
def scaffold_run(body)
|
|
2298
|
-
|
|
2315
|
+
require "open3"
|
|
2316
|
+
|
|
2317
|
+
kind = body["kind"].to_s.strip.downcase
|
|
2299
2318
|
name = body["name"].to_s.strip
|
|
2319
|
+
|
|
2300
2320
|
return { ok: false, error: "kind + name required" } if kind.empty? || name.empty?
|
|
2301
|
-
|
|
2302
|
-
|
|
2303
|
-
|
|
2304
|
-
|
|
2305
|
-
|
|
2306
|
-
|
|
2307
|
-
{ ok:
|
|
2308
|
-
when "model"
|
|
2309
|
-
target = File.join(project, "src", "orm", "#{name}.rb")
|
|
2310
|
-
FileUtils.mkdir_p(File.dirname(target))
|
|
2311
|
-
cls = name.to_s.split(/[_-]/).map(&:capitalize).join
|
|
2312
|
-
File.write(target, "class #{cls} < Tina4::ORM\n integer_field :id, primary_key: true, auto_increment: true\n string_field :name\nend\n") unless File.exist?(target)
|
|
2313
|
-
{ ok: true, created: target.sub("#{project}/", "") }
|
|
2314
|
-
when "migration"
|
|
2315
|
-
ts = Time.now.strftime("%Y%m%d%H%M%S")
|
|
2316
|
-
target = File.join(project, "migrations", "#{ts}_#{name}.sql")
|
|
2317
|
-
FileUtils.mkdir_p(File.dirname(target))
|
|
2318
|
-
File.write(target, "-- migration: #{name}\n")
|
|
2319
|
-
{ ok: true, created: target.sub("#{project}/", "") }
|
|
2320
|
-
when "middleware"
|
|
2321
|
-
target = File.join(project, "src", "app", "#{name}.rb")
|
|
2322
|
-
FileUtils.mkdir_p(File.dirname(target))
|
|
2323
|
-
cls = name.to_s.split(/[_-]/).map(&:capitalize).join
|
|
2324
|
-
File.write(target, "class #{cls}\n def self.before_check(req, res); [req, res]; end\nend\n") unless File.exist?(target)
|
|
2325
|
-
{ ok: true, created: target.sub("#{project}/", "") }
|
|
2326
|
-
else
|
|
2327
|
-
{ ok: false, error: "unknown kind: #{kind}" }
|
|
2321
|
+
unless SCAFFOLD_ALLOWED_KINDS.include?(kind)
|
|
2322
|
+
return { ok: false, error: "unknown kind: #{kind}" }
|
|
2323
|
+
end
|
|
2324
|
+
# Same name validation the PHP + Node dev-admin endpoints apply —
|
|
2325
|
+
# keeps a shell-metacharacter payload out of the generator arg.
|
|
2326
|
+
unless name =~ /\A[A-Za-z][\w]*\z/
|
|
2327
|
+
return { ok: false, error: "name must match [A-Za-z][A-Za-z0-9_]*" }
|
|
2328
2328
|
end
|
|
2329
|
+
|
|
2330
|
+
project = Dir.pwd
|
|
2331
|
+
exe = File.join(project, "exe", "tina4ruby")
|
|
2332
|
+
|
|
2333
|
+
stdout, stderr, status = Open3.capture3(
|
|
2334
|
+
RbConfig.ruby, exe, "generate", kind, name,
|
|
2335
|
+
chdir: project
|
|
2336
|
+
)
|
|
2337
|
+
|
|
2338
|
+
ok = status.exitstatus == 0
|
|
2339
|
+
{
|
|
2340
|
+
ok: ok,
|
|
2341
|
+
kind: kind,
|
|
2342
|
+
name: name,
|
|
2343
|
+
output: stdout.to_s + stderr.to_s,
|
|
2344
|
+
error: (ok ? nil : (stderr.to_s.strip.empty? ? "generate exited #{status.exitstatus}" : stderr.to_s.strip))
|
|
2345
|
+
}.compact
|
|
2329
2346
|
end
|
|
2330
2347
|
|
|
2331
2348
|
# ── Live Docs (Live API RAG) ─────────────────────────────────
|
data/lib/tina4/mcp.rb
CHANGED
|
@@ -1015,11 +1015,104 @@ module Tina4
|
|
|
1015
1015
|
migration.respond_to?(:status) ? migration.status : { "info" => "Migration status not available" }
|
|
1016
1016
|
}, "List pending and completed migrations")
|
|
1017
1017
|
|
|
1018
|
+
# migration_create — delegates to the SAME resolution-aware helper that
|
|
1019
|
+
# backs `tina4ruby migrate:create` + `tina4ruby generate migration`, so the
|
|
1020
|
+
# MCP tool emits the ADR-0063 `generate_v1_1` envelope (edit_hints[],
|
|
1021
|
+
# next[], `-- tina4:edit` markers baked into the UP+DOWN files) alongside
|
|
1022
|
+
# the pre-existing `{created}` contract. Duplicate-slug guard mirrors the
|
|
1023
|
+
# PHP + Python MCPs: if a migration file for the same slug already lives
|
|
1024
|
+
# under migrations/, refuse (the AI should edit the existing one instead
|
|
1025
|
+
# of spawning a second migration for the same schema change).
|
|
1018
1026
|
server.register_tool("migration_create", lambda { |description:|
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1027
|
+
require "fileutils"
|
|
1028
|
+
require_relative "cli"
|
|
1029
|
+
|
|
1030
|
+
desc = (description || "").to_s
|
|
1031
|
+
slug = desc.gsub(/[^a-z0-9]+/i, "_").downcase.gsub(/^_|_$/, "")
|
|
1032
|
+
|
|
1033
|
+
# Duplicate-slug guard — walks migrations/ and compares against the
|
|
1034
|
+
# slugified basename (stripping the leading 14-digit timestamp + the
|
|
1035
|
+
# .sql / .down.sql extension). Handles the "create_orders_table" vs
|
|
1036
|
+
# "create_orders" equivalence PHP + Python already recognise.
|
|
1037
|
+
mig_dir = File.join(Dir.pwd, "migrations")
|
|
1038
|
+
if slug != "" && File.directory?(mig_dir)
|
|
1039
|
+
existing = []
|
|
1040
|
+
Dir.glob(File.join(mig_dir, "*.sql")).sort.each do |path|
|
|
1041
|
+
base = File.basename(path)
|
|
1042
|
+
after = base.sub(/\A\d{14}_/, "").sub(/\.(down\.)?sql\z/, "")
|
|
1043
|
+
existing_slug = after.gsub(/[^a-z0-9]+/i, "_").downcase.gsub(/^_|_$/, "")
|
|
1044
|
+
if existing_slug == slug ||
|
|
1045
|
+
existing_slug == "#{slug}_table" ||
|
|
1046
|
+
slug == "#{existing_slug}_table"
|
|
1047
|
+
existing << path.sub("#{Dir.pwd}/", "")
|
|
1048
|
+
end
|
|
1049
|
+
end
|
|
1050
|
+
unless existing.empty?
|
|
1051
|
+
next {
|
|
1052
|
+
"ok" => false,
|
|
1053
|
+
"error" => "Migration for #{desc.inspect} already exists: #{existing.first}. " \
|
|
1054
|
+
"Edit it with file_patch / file_write instead of creating a duplicate.",
|
|
1055
|
+
"existing" => existing
|
|
1056
|
+
}
|
|
1057
|
+
end
|
|
1058
|
+
end
|
|
1059
|
+
|
|
1060
|
+
# Delegate through the SHARED resolution-aware helper (the same entry
|
|
1061
|
+
# point cmd_migrate_create uses since 1f09a31), so the envelope shape
|
|
1062
|
+
# here is byte-for-byte the same as `tina4ruby migrate:create <desc>
|
|
1063
|
+
# --json`. Human-mode output is captured and discarded — the MCP tool
|
|
1064
|
+
# returns structured JSON only.
|
|
1065
|
+
cli = Tina4::CLI.new
|
|
1066
|
+
# `reset_generate_timestamp!` and `run_resolution_aware_generator` are
|
|
1067
|
+
# private-by-declaration in cli.rb (everything below `private` at
|
|
1068
|
+
# cli.rb:175). They are the SHARED entry points cmd_generate +
|
|
1069
|
+
# cmd_migrate_create use, so calling them through #send here is the
|
|
1070
|
+
# deliberate re-use path — not a boundary crossing.
|
|
1071
|
+
cli.send(:reset_generate_timestamp!)
|
|
1072
|
+
|
|
1073
|
+
name = slug
|
|
1074
|
+
|
|
1075
|
+
require "stringio"
|
|
1076
|
+
old_stdout = $stdout
|
|
1077
|
+
old_stderr = $stderr
|
|
1078
|
+
stdout_buf = StringIO.new
|
|
1079
|
+
stderr_buf = StringIO.new
|
|
1080
|
+
envelope = nil
|
|
1081
|
+
begin
|
|
1082
|
+
$stdout = stdout_buf
|
|
1083
|
+
$stderr = stderr_buf
|
|
1084
|
+
# json_mode: true → run_resolution_aware_generator prints the
|
|
1085
|
+
# envelope to stdout (captured here); dry_run: false → the migration
|
|
1086
|
+
# files are really written under migrations/ in Dir.pwd.
|
|
1087
|
+
cli.send(
|
|
1088
|
+
:run_resolution_aware_generator,
|
|
1089
|
+
"migration", name, {},
|
|
1090
|
+
json_mode: true,
|
|
1091
|
+
dry_run: false,
|
|
1092
|
+
gen_spec: Tina4::CLI::GENERATORS["migration"],
|
|
1093
|
+
generator_kwargs: { emit_test: false, description: desc }
|
|
1094
|
+
)
|
|
1095
|
+
envelope = JSON.parse(stdout_buf.string)
|
|
1096
|
+
ensure
|
|
1097
|
+
$stdout = old_stdout
|
|
1098
|
+
$stderr = old_stderr
|
|
1099
|
+
end
|
|
1100
|
+
|
|
1101
|
+
# actions_taken carries "wrote migrations/<ts>_<name>.sql" lines; the
|
|
1102
|
+
# first .sql (never .down.sql) is the canonical `created` filename that
|
|
1103
|
+
# earlier callers of migration_create relied on.
|
|
1104
|
+
actions = envelope["actions_taken"] || []
|
|
1105
|
+
created = actions.map { |a| a.to_s.sub(/\Awrote\s+/, "") }
|
|
1106
|
+
.find { |p| p.end_with?(".sql") && !p.end_with?(".down.sql") }
|
|
1107
|
+
created ||= (envelope.dig("resolution", "file_path"))
|
|
1108
|
+
|
|
1109
|
+
{
|
|
1110
|
+
"ok" => true,
|
|
1111
|
+
"created" => created,
|
|
1112
|
+
"resolution" => envelope["resolution"],
|
|
1113
|
+
"actions_taken" => actions
|
|
1114
|
+
}
|
|
1115
|
+
}, "Create a new migration file (emits ADR-0063 resolution envelope)")
|
|
1023
1116
|
|
|
1024
1117
|
server.register_tool("migration_run", lambda {
|
|
1025
1118
|
db = Tina4.database
|
data/lib/tina4/version.rb
CHANGED