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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 95ef84b463d6357070f1f1261556d5d6dc44e7355d279ee987919789b2b11ca2
4
- data.tar.gz: dd408c271fd30de5ccb6f17c16ff116f0882e39f7f60775d298570e48c201294
3
+ metadata.gz: 5432ec631264ef420f357f5b662a67ae7d8a22d8f32dfc719dc568e2713f0320
4
+ data.tar.gz: 0bfad1aff49e4b87548dc0d2ec13b27671295205189404252615810a245b89eb
5
5
  SHA512:
6
- metadata.gz: e7a9dd4f5ecf64590747b052f67588194e4464d81dfe5f9455bb968132267951ed451915b6605d232688dd803c8deda7eb32f631fee4adb6672ca373acc8146e
7
- data.tar.gz: 4431a908d43403601b76d82fb5bac6ad36948d532fce131847f14d0cbbc1c45f46a4b9785613cb5f3588553301f257eb42e6d5f68bd4b4473f7c24665d52dbde
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, or `-- tina4:edit <label>` in a generated SQL file, marks a
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. Any
299
- # comment character (`#` or `--`) followed by `tina4:edit` and one or more
300
- # spaces then the label matches Twig `{# tina4:edit #}` is Wave 2 and
301
- # deliberately NOT matched here.
302
- EDIT_MARKER_RE = /(?:#|--)\s*tina4:edit[ \t]+(.+?)\s*$/.freeze
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), matching
696
- # the Python master `migrate:create`. CHEAP + database-free: it only writes
697
- # files via the static Tina4::Migration.create_migration helper no app
698
- # boot, no DB connection, no tracking table. `description` is every arg
699
- # joined with a space (parity with Python's `" ".join(args)`).
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
- description = (argv || []).join(" ").strip
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
- require_relative "log"
708
- require_relative "migration"
709
- path = Tina4::Migration.create_migration(description, migrations_dir: "migrations")
710
- puts "Created: #{path}"
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 four resolution-aware targets emit a resolution + JSON envelope;
1323
+ # Only the resolution-aware targets emit a resolution + JSON envelope;
1253
1324
  # every other generator keeps its existing behaviour so nothing regresses.
1254
- resolution_aware = %w[model route migration middleware]
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"] = collect_edit_hints_via_sandbox(gen_spec, name, flags)
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 = dry_run ? [] : run_generator_capturing_actions(gen_spec, name, flags)
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
- def collect_edit_hints_via_sandbox(gen_spec, name, flags)
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
- def run_generator_capturing_actions(gen_spec, name, flags)
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, emit_test: true)
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: #{name}
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: #{name}
2061
+ -- Rollback: #{header_text}
1897
2062
  -- Created: #{now.strftime("%Y-%m-%d %H:%M:%S")}
1898
2063
 
1899
2064
  #{down_sql}
@@ -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
- kind = body["kind"].to_s
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
- project = Dir.pwd
2302
- case kind
2303
- when "route"
2304
- target = File.join(project, "src", "routes", "#{name}.rb")
2305
- FileUtils.mkdir_p(File.dirname(target))
2306
- File.write(target, "# #{name} routes\nTina4::Router.get(\"/api/#{name}\") do |req, res|\n res.call({ hello: \"#{name}\" })\nend\n") unless File.exist?(target)
2307
- { ok: true, created: target.sub("#{project}/", "") }
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
- migration = Tina4::Migration.new(nil)
1020
- filename = migration.create(description)
1021
- { "created" => filename }
1022
- }, "Create a new migration file")
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Tina4
4
- VERSION = "3.13.120"
4
+ VERSION = "3.13.121"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: tina4ruby
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.13.120
4
+ version: 3.13.121
5
5
  platform: ruby
6
6
  authors:
7
7
  - Tina4 Team