tina4ruby 3.13.119 → 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 +417 -32
- data/lib/tina4/dev_admin.rb +45 -28
- data/lib/tina4/mcp.rb +97 -4
- data/lib/tina4/version.rb +1 -1
- metadata +2 -2
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
|
@@ -4,6 +4,8 @@ require "optparse"
|
|
|
4
4
|
require "fileutils"
|
|
5
5
|
require "set"
|
|
6
6
|
require "json"
|
|
7
|
+
require "tmpdir" # Dir.mktmpdir — used by the ADR-0063 sandbox edit-hint scan
|
|
8
|
+
require "stringio" # StringIO — captures generator stdout during the sandbox run
|
|
7
9
|
require_relative "port_takeover"
|
|
8
10
|
|
|
9
11
|
module Tina4
|
|
@@ -289,6 +291,128 @@ module Tina4
|
|
|
289
291
|
# created_at. The first write then failed with "no such column: name".
|
|
290
292
|
DEFAULT_FIELDS = [["name", "string"]].freeze
|
|
291
293
|
|
|
294
|
+
# ADR-0063 (scaffolding envelope v1.1): `# tina4:edit <label>` in a generated
|
|
295
|
+
# Ruby file, `-- tina4:edit <label>` in a generated SQL file, or
|
|
296
|
+
# `{# tina4:edit <label> #}` in a generated Twig template marks a
|
|
297
|
+
# first-edit spot for a developer / coding agent to fill in. The scanner
|
|
298
|
+
# surfaces every match as an `edit_hints` entry in the `generate_v1_1`
|
|
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
|
|
309
|
+
|
|
310
|
+
# ADR-0063 next-step catalogue: 5 short actionable lines per resolution-aware
|
|
311
|
+
# verb. Rendered under "Next:" in the human block AND surfaced as
|
|
312
|
+
# `resolution.next[]` in the envelope. Each entry is a `Proc` that consumes
|
|
313
|
+
# the resolution so paths / class names substitute correctly. Curated (not
|
|
314
|
+
# generated) so a caller sees consistent, sensible next steps.
|
|
315
|
+
NEXT_STEPS = {
|
|
316
|
+
"model" => lambda do |res|
|
|
317
|
+
table = res["table_name"] || "resource"
|
|
318
|
+
class_name = res["class_name"] || "Model"
|
|
319
|
+
file_path = res["file_path"] || "src/orm/#{table}.rb"
|
|
320
|
+
route = (res["routes"] || []).first || "/#{table}"
|
|
321
|
+
[
|
|
322
|
+
"Edit #{file_path} to add fields beyond the default 'name'",
|
|
323
|
+
"Apply the migration: tina4ruby migrate",
|
|
324
|
+
"Try it: tina4ruby serve -> curl http://localhost:7147#{route}",
|
|
325
|
+
"Add CRUD scaffolding: tina4ruby generate crud #{class_name} --skip-model",
|
|
326
|
+
"Seed sample data: tina4ruby generate seeder #{class_name} && tina4ruby seed",
|
|
327
|
+
]
|
|
328
|
+
end,
|
|
329
|
+
"route" => lambda do |res|
|
|
330
|
+
file_path = res["file_path"] || "src/routes/resource.rb"
|
|
331
|
+
route = (res["routes"] || []).first || "/api/resource"
|
|
332
|
+
test_path = (res["test_paths"] || []).first || "spec/routes/resource_spec.rb"
|
|
333
|
+
[
|
|
334
|
+
"Edit #{file_path} to customise the response",
|
|
335
|
+
"Try it: tina4ruby serve -> curl http://localhost:7147#{route}",
|
|
336
|
+
"Add real assertions: #{test_path}",
|
|
337
|
+
"Bearer-gate is on: writes require a Bearer token (rerun with --public to open)",
|
|
338
|
+
"Browse the API: tina4ruby serve -> http://localhost:7147/swagger",
|
|
339
|
+
]
|
|
340
|
+
end,
|
|
341
|
+
"migration" => lambda do |res|
|
|
342
|
+
file_path = res["file_path"] || "migrations/new_migration.sql"
|
|
343
|
+
down_path = file_path.sub(/\.sql$/, ".down.sql")
|
|
344
|
+
[
|
|
345
|
+
"Edit #{file_path} to add columns beyond id + created_at",
|
|
346
|
+
"Mirror the change in: #{down_path}",
|
|
347
|
+
"Apply it: tina4ruby migrate",
|
|
348
|
+
"Check status: tina4ruby migrate status",
|
|
349
|
+
"Rollback if wrong: tina4ruby migrate rollback",
|
|
350
|
+
]
|
|
351
|
+
end,
|
|
352
|
+
"middleware" => lambda do |res|
|
|
353
|
+
class_name = res["class_name"] || "Middleware"
|
|
354
|
+
file_path = res["file_path"] || "src/middleware/middleware.rb"
|
|
355
|
+
[
|
|
356
|
+
"Edit #{file_path} to guard the request or shape the response",
|
|
357
|
+
"Wire it into a route: middleware: [#{class_name}] on Tina4.get/post/...",
|
|
358
|
+
"Wire it globally: Tina4::Router.use(#{class_name})",
|
|
359
|
+
"Prove the gate: add a real spec covering allow + deny",
|
|
360
|
+
"Chain more: order in the middleware: [] list is registration order",
|
|
361
|
+
]
|
|
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,
|
|
389
|
+
}.freeze
|
|
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
|
+
|
|
400
|
+
# ADR-0063: freeze `Time.now` for the duration of one `cmd_generate` call.
|
|
401
|
+
# Both the resolution builders (`build_migration_resolution`,
|
|
402
|
+
# `build_model_resolution`) and the actual generator (`generate_migration`)
|
|
403
|
+
# embed a timestamp in the migration filename. If they call `Time.now`
|
|
404
|
+
# independently, the sandbox pre-run (which drives edit_hints for dry-run
|
|
405
|
+
# mode) and the real run can pick different seconds, and the envelope's
|
|
406
|
+
# `file_path` would drift from the file that actually gets written. One
|
|
407
|
+
# memoised timestamp per command run fixes that.
|
|
408
|
+
def generate_timestamp
|
|
409
|
+
@generate_timestamp ||= Time.now
|
|
410
|
+
end
|
|
411
|
+
|
|
412
|
+
def reset_generate_timestamp!
|
|
413
|
+
@generate_timestamp = nil
|
|
414
|
+
end
|
|
415
|
+
|
|
292
416
|
# Parsed --fields, or the default single `name` column when none given.
|
|
293
417
|
def fields_or_default(fields_str)
|
|
294
418
|
parsed = parse_fields(fields_str)
|
|
@@ -331,7 +455,11 @@ module Tina4
|
|
|
331
455
|
bar = "─" * 60
|
|
332
456
|
head = "#{indent}# ─── AI-FILL: #{fn} "
|
|
333
457
|
head += "─" * [4, 66 - head.length].max
|
|
334
|
-
|
|
458
|
+
# ADR-0063: prepend a `# tina4:edit <label>` machine-readable pointer so a
|
|
459
|
+
# tool can surface WHERE this stub needs custom logic without parsing the
|
|
460
|
+
# AI-FILL banner. `intent` is the developer-facing TODO — reuse it as the
|
|
461
|
+
# label (short, actionable, no period).
|
|
462
|
+
lines = ["#{indent}# tina4:edit #{intent}", head, "#{indent}# Intent: #{intent}"]
|
|
335
463
|
lines << "#{indent}# Given: #{given}" if given
|
|
336
464
|
lines << "#{indent}# Use: #{use}"
|
|
337
465
|
lines << "#{indent}# Return: #{ret}" if ret
|
|
@@ -348,7 +476,10 @@ module Tina4
|
|
|
348
476
|
def extend_marker(note, hint = "", indent: " ")
|
|
349
477
|
head = "#{indent}# ─── EXTEND: #{note} "
|
|
350
478
|
head += "─" * [4, 66 - head.length].max
|
|
351
|
-
|
|
479
|
+
# ADR-0063: `# tina4:edit <label>` is the machine-readable partner of the
|
|
480
|
+
# ─── EXTEND banner. `note` is the human-shaped TODO — reuse it verbatim.
|
|
481
|
+
out = "#{indent}# tina4:edit #{note}\n"
|
|
482
|
+
out += head + "\n"
|
|
352
483
|
out += "#{indent}# #{hint}\n" unless hint.to_s.empty?
|
|
353
484
|
out
|
|
354
485
|
end
|
|
@@ -602,22 +733,52 @@ module Tina4
|
|
|
602
733
|
|
|
603
734
|
# ── migrate:create ───────────────────────────────────────────────────
|
|
604
735
|
|
|
605
|
-
# Create a new timestamped migration file (UP .sql + .down.sql)
|
|
606
|
-
#
|
|
607
|
-
#
|
|
608
|
-
#
|
|
609
|
-
#
|
|
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)`).
|
|
610
751
|
def cmd_migrate_create(argv)
|
|
611
|
-
|
|
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
|
|
612
758
|
if description.empty?
|
|
613
759
|
puts "Usage: tina4ruby migrate:create <description>"
|
|
614
760
|
exit 1
|
|
615
761
|
end
|
|
616
762
|
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
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
|
+
)
|
|
621
782
|
end
|
|
622
783
|
|
|
623
784
|
# ── migrate:status ─────────────────────────────────────────────────────
|
|
@@ -1117,6 +1278,10 @@ module Tina4
|
|
|
1117
1278
|
# will produce before it runs.
|
|
1118
1279
|
|
|
1119
1280
|
def cmd_generate(argv)
|
|
1281
|
+
# ADR-0063: fresh timestamp per command run so the sandbox pre-run + the
|
|
1282
|
+
# real generator both agree on the migration filename.
|
|
1283
|
+
reset_generate_timestamp!
|
|
1284
|
+
|
|
1120
1285
|
what = argv.shift
|
|
1121
1286
|
all = GENERATORS.keys.join(", ") # single source: the GENERATORS registry
|
|
1122
1287
|
|
|
@@ -1126,7 +1291,7 @@ module Tina4
|
|
|
1126
1291
|
puts ' Options: --fields "name:string,price:float" --model ModelName'
|
|
1127
1292
|
puts ' --public open a route'"'"'s writes (default: secure)'
|
|
1128
1293
|
puts ' --every 5m | --cron "..." service schedule'
|
|
1129
|
-
puts ' --json emit the
|
|
1294
|
+
puts ' --json emit the generate_v1_1 resolution envelope'
|
|
1130
1295
|
puts ' --dry-run plan the scaffold; write no files'
|
|
1131
1296
|
exit 1
|
|
1132
1297
|
end
|
|
@@ -1155,18 +1320,49 @@ module Tina4
|
|
|
1155
1320
|
exit 1
|
|
1156
1321
|
end
|
|
1157
1322
|
|
|
1158
|
-
# Only the
|
|
1323
|
+
# Only the resolution-aware targets emit a resolution + JSON envelope;
|
|
1159
1324
|
# every other generator keeps its existing behaviour so nothing regresses.
|
|
1160
|
-
|
|
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]
|
|
1161
1328
|
if !resolution_aware.include?(what)
|
|
1162
1329
|
send(gen_spec[:handler], name, flags)
|
|
1163
1330
|
return
|
|
1164
1331
|
end
|
|
1165
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
|
+
|
|
1166
1352
|
resolution = build_generate_resolution(what, name, flags)
|
|
1353
|
+
# ADR-0063 additive v1.1 keys: curated next-steps (pure fn of resolution)
|
|
1354
|
+
# and edit_hints (scan of generated files for `# tina4:edit <label>`).
|
|
1355
|
+
resolution["next"] = build_next_steps(what, resolution)
|
|
1356
|
+
resolution["edit_hints"] =
|
|
1357
|
+
collect_edit_hints_via_sandbox(gen_spec, name, flags, **generator_kwargs)
|
|
1167
1358
|
|
|
1168
1359
|
if json_mode
|
|
1169
|
-
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
|
|
1170
1366
|
envelope = build_generate_envelope(what, name, flags, resolution, actions_taken, dry_run)
|
|
1171
1367
|
puts JSON.pretty_generate(envelope)
|
|
1172
1368
|
return
|
|
@@ -1179,8 +1375,84 @@ module Tina4
|
|
|
1179
1375
|
if dry_run
|
|
1180
1376
|
$stderr.puts " (dry-run — no files written)"
|
|
1181
1377
|
else
|
|
1182
|
-
send(gen_spec[:handler], name, flags)
|
|
1378
|
+
send(gen_spec[:handler], name, flags, **generator_kwargs)
|
|
1379
|
+
end
|
|
1380
|
+
end
|
|
1381
|
+
|
|
1382
|
+
# ADR-0063 helpers ────────────────────────────────────────────────────────
|
|
1383
|
+
|
|
1384
|
+
# Curated per-verb next-step list. Pure function of the resolution; never
|
|
1385
|
+
# scans the filesystem, so it works identically in dry-run and real-run.
|
|
1386
|
+
def build_next_steps(target, resolution)
|
|
1387
|
+
builder = NEXT_STEPS[target]
|
|
1388
|
+
return [] unless builder
|
|
1389
|
+
Array(builder.call(resolution))
|
|
1390
|
+
end
|
|
1391
|
+
|
|
1392
|
+
# Scan a list of files for `# tina4:edit <label>` (Ruby) or
|
|
1393
|
+
# `-- tina4:edit <label>` (SQL) lines. Returns `[{file, line, label}]` in
|
|
1394
|
+
# the same order the files were passed. Missing / unreadable files are
|
|
1395
|
+
# skipped silently (a scaffold that already exists on disk simply produces
|
|
1396
|
+
# no hint for that path — that is the intended behaviour).
|
|
1397
|
+
def scan_edit_hints_from_files(paths)
|
|
1398
|
+
hints = []
|
|
1399
|
+
paths.each do |path|
|
|
1400
|
+
next unless path
|
|
1401
|
+
next unless File.file?(path)
|
|
1402
|
+
begin
|
|
1403
|
+
content = File.read(path, encoding: "UTF-8", invalid: :replace, undef: :replace)
|
|
1404
|
+
rescue SystemCallError
|
|
1405
|
+
next
|
|
1406
|
+
end
|
|
1407
|
+
content.each_line.with_index(1) do |line, lineno|
|
|
1408
|
+
next unless (m = line.match(EDIT_MARKER_RE))
|
|
1409
|
+
hints << { "file" => path, "line" => lineno, "label" => m[1].strip }
|
|
1410
|
+
end
|
|
1411
|
+
end
|
|
1412
|
+
hints
|
|
1413
|
+
end
|
|
1414
|
+
|
|
1415
|
+
# Run the generator inside a throw-away tmpdir with `$stdout` captured, then
|
|
1416
|
+
# walk every written file and scan for `# tina4:edit` / `-- tina4:edit`
|
|
1417
|
+
# markers. Used for BOTH dry-run and real-run modes so the envelope always
|
|
1418
|
+
# ships accurate `edit_hints` before any live-cwd write happens (preserves
|
|
1419
|
+
# the "Ctrl-C between plan and write" contract of the human block).
|
|
1420
|
+
#
|
|
1421
|
+
# `generate_timestamp` is shared with the real run so a migration file
|
|
1422
|
+
# sandboxed here has the SAME timestamped basename as the file that lands
|
|
1423
|
+
# in the developer's cwd - the hint's `file` field matches disk verbatim.
|
|
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)
|
|
1429
|
+
hints = []
|
|
1430
|
+
Dir.mktmpdir("tina4_edit_hints") do |sandbox|
|
|
1431
|
+
# Block-form Dir.chdir composes cleanly under an outer block-form
|
|
1432
|
+
# Dir.chdir (Ruby 3.x warns on nested non-block chdir). No `ensure`
|
|
1433
|
+
# cwd-restore needed - the block form guarantees it.
|
|
1434
|
+
Dir.chdir(sandbox) do
|
|
1435
|
+
buffer = StringIO.new
|
|
1436
|
+
original_stdout = $stdout
|
|
1437
|
+
$stdout = buffer
|
|
1438
|
+
begin
|
|
1439
|
+
# A generator failure inside the sandbox must never break the caller's
|
|
1440
|
+
# real run - rescue everything, return whatever hints we managed to
|
|
1441
|
+
# gather. The real run will error the same way if it is going to.
|
|
1442
|
+
send(gen_spec[:handler], name, flags.dup, **generator_kwargs)
|
|
1443
|
+
rescue StandardError, LoadError, ScriptError
|
|
1444
|
+
# swallow - the sandbox is best-effort
|
|
1445
|
+
ensure
|
|
1446
|
+
$stdout = original_stdout
|
|
1447
|
+
end
|
|
1448
|
+
Dir.glob("**/*", File::FNM_DOTMATCH).sort.each do |rel_path|
|
|
1449
|
+
next if [".", ".."].include?(File.basename(rel_path))
|
|
1450
|
+
next unless File.file?(rel_path)
|
|
1451
|
+
hints.concat(scan_edit_hints_from_files([rel_path]))
|
|
1452
|
+
end
|
|
1453
|
+
end
|
|
1183
1454
|
end
|
|
1455
|
+
hints
|
|
1184
1456
|
end
|
|
1185
1457
|
|
|
1186
1458
|
# ── generate resolution helpers ────────────────────────────────────────
|
|
@@ -1200,6 +1472,8 @@ module Tina4
|
|
|
1200
1472
|
when "route" then build_route_resolution(name, flags)
|
|
1201
1473
|
when "migration" then build_migration_resolution(name, flags)
|
|
1202
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)
|
|
1203
1477
|
else
|
|
1204
1478
|
{ "class_name" => name.to_s, "table_name" => nil, "file_path" => nil,
|
|
1205
1479
|
"migration_path" => nil, "transformations" => [],
|
|
@@ -1227,7 +1501,10 @@ module Tina4
|
|
|
1227
1501
|
# The migration filename embeds a timestamp; predict it deterministically
|
|
1228
1502
|
# so a `--dry-run` and the real run agree on the same second. Two
|
|
1229
1503
|
# generations within the same second collide anyway (existing behaviour).
|
|
1230
|
-
|
|
1504
|
+
# ADR-0063: `generate_timestamp` memoises `Time.now` per `cmd_generate`
|
|
1505
|
+
# call so the sandbox pre-run + real run share one second, and the
|
|
1506
|
+
# envelope's `migration_path` matches the file that actually lands.
|
|
1507
|
+
timestamp = generate_timestamp.strftime("%Y%m%d%H%M%S")
|
|
1231
1508
|
{
|
|
1232
1509
|
"class_name" => name.to_s,
|
|
1233
1510
|
"table_name" => table,
|
|
@@ -1253,7 +1530,8 @@ module Tina4
|
|
|
1253
1530
|
end
|
|
1254
1531
|
|
|
1255
1532
|
def build_migration_resolution(name, _flags)
|
|
1256
|
-
timestamp
|
|
1533
|
+
# ADR-0063: shared memoised timestamp (see `generate_timestamp`).
|
|
1534
|
+
timestamp = generate_timestamp.strftime("%Y%m%d%H%M%S")
|
|
1257
1535
|
base = name.to_s.sub(/^create_/, "").sub(/^add_/, "").sub(/^drop_/, "")
|
|
1258
1536
|
snake_base = to_snake_case(base)
|
|
1259
1537
|
path = "migrations/#{timestamp}_#{name}.sql"
|
|
@@ -1281,6 +1559,54 @@ module Tina4
|
|
|
1281
1559
|
}
|
|
1282
1560
|
end
|
|
1283
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
|
+
|
|
1284
1610
|
def build_generate_envelope(target, name, flags, resolution, actions_taken, dry_run)
|
|
1285
1611
|
{
|
|
1286
1612
|
"command" => "generate",
|
|
@@ -1313,12 +1639,33 @@ module Tina4
|
|
|
1313
1639
|
if resolution["migration_path"] && resolution["migration_path"] != resolution["file_path"]
|
|
1314
1640
|
lines << " migration #{resolution['migration_path']}"
|
|
1315
1641
|
end
|
|
1642
|
+
# ADR-0063: surface the pre-existing test_paths (v1 field, only now printed
|
|
1643
|
+
# in the human block) alongside the two new sections.
|
|
1644
|
+
if resolution["test_paths"] && !resolution["test_paths"].empty?
|
|
1645
|
+
lines << " tests #{resolution['test_paths'].join(', ')}"
|
|
1646
|
+
end
|
|
1316
1647
|
pluralized = resolution["transformations"].find { |t| t["kind"] == "reserved_word_pluralize" }
|
|
1317
1648
|
if pluralized
|
|
1318
1649
|
lines << ""
|
|
1319
1650
|
lines << " To keep the raw name '#{pluralized['from']}' as the table:"
|
|
1320
1651
|
lines << " tina4ruby generate #{target} #{name} --table #{pluralized['from']} --quote (opt-in, ADR-0062 forthcoming)"
|
|
1321
1652
|
end
|
|
1653
|
+
# ADR-0063 v1.1: surface `edit_hints[]` under "Edit these lines:" and
|
|
1654
|
+
# `next[]` under "Next:" — both additive; missing / empty arrays elide
|
|
1655
|
+
# cleanly. The `file:line label` shape is grep-friendly for humans and
|
|
1656
|
+
# machine-parseable for tools.
|
|
1657
|
+
if resolution["edit_hints"] && !resolution["edit_hints"].empty?
|
|
1658
|
+
lines << ""
|
|
1659
|
+
lines << " Edit these lines:"
|
|
1660
|
+
resolution["edit_hints"].each do |h|
|
|
1661
|
+
lines << " #{h['file']}:#{h['line']} #{h['label']}"
|
|
1662
|
+
end
|
|
1663
|
+
end
|
|
1664
|
+
if resolution["next"] && !resolution["next"].empty?
|
|
1665
|
+
lines << ""
|
|
1666
|
+
lines << " Next:"
|
|
1667
|
+
resolution["next"].each { |step| lines << " #{step}" }
|
|
1668
|
+
end
|
|
1322
1669
|
lines.join("\n")
|
|
1323
1670
|
end
|
|
1324
1671
|
|
|
@@ -1326,14 +1673,17 @@ module Tina4
|
|
|
1326
1673
|
# `actions_taken` list from the " Created <path>" lines the generators
|
|
1327
1674
|
# emit. Machine callers get one clean JSON envelope on stdout; the
|
|
1328
1675
|
# generator's own log stays discoverable via the returned list.
|
|
1329
|
-
|
|
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)
|
|
1330
1680
|
require "stringio"
|
|
1331
1681
|
require "json"
|
|
1332
1682
|
buffer = StringIO.new
|
|
1333
1683
|
original = $stdout
|
|
1334
1684
|
$stdout = buffer
|
|
1335
1685
|
begin
|
|
1336
|
-
send(gen_spec[:handler], name, flags)
|
|
1686
|
+
send(gen_spec[:handler], name, flags, **generator_kwargs)
|
|
1337
1687
|
ensure
|
|
1338
1688
|
$stdout = original
|
|
1339
1689
|
end
|
|
@@ -1371,6 +1721,7 @@ module Tina4
|
|
|
1371
1721
|
class #{name} < Tina4::ORM
|
|
1372
1722
|
table_name "#{table}"
|
|
1373
1723
|
|
|
1724
|
+
# tina4:edit add fields beyond the default 'name'
|
|
1374
1725
|
#{field_lines.join("\n")}
|
|
1375
1726
|
end
|
|
1376
1727
|
RUBY
|
|
@@ -1627,12 +1978,25 @@ module Tina4
|
|
|
1627
1978
|
|
|
1628
1979
|
# ── Generator: migration ─────────────────────────────────────────────
|
|
1629
1980
|
|
|
1630
|
-
def generate_migration(name, flags = {}, fields_override: nil, table_override: nil,
|
|
1631
|
-
|
|
1981
|
+
def generate_migration(name, flags = {}, fields_override: nil, table_override: nil,
|
|
1982
|
+
emit_test: true, description: nil)
|
|
1983
|
+
# ADR-0063: `generate_timestamp` is the shared memoised `Time.now` for
|
|
1984
|
+
# this cmd_generate run; the resolution's `migration_path` uses the SAME
|
|
1985
|
+
# value, so envelope and disk agree.
|
|
1986
|
+
now = generate_timestamp
|
|
1632
1987
|
timestamp = now.strftime("%Y%m%d%H%M%S")
|
|
1633
1988
|
dir = "migrations"
|
|
1634
1989
|
FileUtils.mkdir_p(dir)
|
|
1635
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
|
+
|
|
1636
2000
|
# Determine table name
|
|
1637
2001
|
if table_override
|
|
1638
2002
|
table = table_override
|
|
@@ -1661,11 +2025,18 @@ module Tina4
|
|
|
1661
2025
|
end
|
|
1662
2026
|
col_lines << " created_at TEXT DEFAULT CURRENT_TIMESTAMP"
|
|
1663
2027
|
|
|
1664
|
-
|
|
1665
|
-
|
|
2028
|
+
# ADR-0063: `-- tina4:edit` marker for the SQL scanner (mirrors `# tina4:edit`
|
|
2029
|
+
# in Ruby files). The scanner regex accepts both comment prefixes.
|
|
2030
|
+
up_sql = "CREATE TABLE IF NOT EXISTS #{table} (\n" \
|
|
2031
|
+
" -- tina4:edit add columns beyond id + created_at\n" \
|
|
2032
|
+
"#{col_lines.join(",\n")}\n);"
|
|
2033
|
+
down_sql = "-- tina4:edit mirror the CREATE's added columns in the rollback\n" \
|
|
2034
|
+
"DROP TABLE IF EXISTS #{table};"
|
|
1666
2035
|
else
|
|
1667
|
-
up_sql = "--
|
|
1668
|
-
|
|
2036
|
+
up_sql = "-- tina4:edit write your UP migration SQL here\n" \
|
|
2037
|
+
"-- Example: ALTER TABLE #{table} ADD COLUMN new_col TEXT DEFAULT '';"
|
|
2038
|
+
down_sql = "-- tina4:edit write your DOWN rollback SQL here\n" \
|
|
2039
|
+
"-- Example: ALTER TABLE #{table} DROP COLUMN new_col;"
|
|
1669
2040
|
end
|
|
1670
2041
|
|
|
1671
2042
|
# The main .sql holds ONLY the UP migration. The runner executes the WHOLE
|
|
@@ -1674,7 +2045,7 @@ module Tina4
|
|
|
1674
2045
|
# rollback SQL lives solely in the sibling .down.sql. Matches the Python
|
|
1675
2046
|
# master (tina4_python/cli/__init__.py).
|
|
1676
2047
|
content = <<~SQL
|
|
1677
|
-
-- Migration: #{
|
|
2048
|
+
-- Migration: #{header_text}
|
|
1678
2049
|
-- Created: #{now.strftime("%Y-%m-%d %H:%M:%S")}
|
|
1679
2050
|
|
|
1680
2051
|
#{up_sql}
|
|
@@ -1687,7 +2058,7 @@ module Tina4
|
|
|
1687
2058
|
down_filename = "#{timestamp}_#{name}.down.sql"
|
|
1688
2059
|
down_path = File.join(dir, down_filename)
|
|
1689
2060
|
down_content = <<~SQL
|
|
1690
|
-
-- Rollback: #{
|
|
2061
|
+
-- Rollback: #{header_text}
|
|
1691
2062
|
-- Created: #{now.strftime("%Y-%m-%d %H:%M:%S")}
|
|
1692
2063
|
|
|
1693
2064
|
#{down_sql}
|
|
@@ -1727,12 +2098,14 @@ module Tina4
|
|
|
1727
2098
|
# Runs before the route handler.
|
|
1728
2099
|
# Return [request, response] to continue, or
|
|
1729
2100
|
# return [request, response.json({ error: "Unauthorized" }, 401)] to block.
|
|
2101
|
+
# tina4:edit guard the request here
|
|
1730
2102
|
Tina4::Log.info("#{name}: \#{request.method} \#{request.path}")
|
|
1731
2103
|
[request, response]
|
|
1732
2104
|
end
|
|
1733
2105
|
|
|
1734
2106
|
def self.after_#{snake}(request, response)
|
|
1735
2107
|
# Runs after the route handler.
|
|
2108
|
+
# tina4:edit post-process the response here
|
|
1736
2109
|
[request, response]
|
|
1737
2110
|
end
|
|
1738
2111
|
end
|
|
@@ -1836,6 +2209,7 @@ module Tina4
|
|
|
1836
2209
|
# Tests for #{name} CRUD operations
|
|
1837
2210
|
RSpec.describe "#{model}" do
|
|
1838
2211
|
before(:each) do
|
|
2212
|
+
# tina4:edit seed real fixtures for a real DB
|
|
1839
2213
|
# Set up test fixtures
|
|
1840
2214
|
end
|
|
1841
2215
|
|
|
@@ -1844,7 +2218,7 @@ module Tina4
|
|
|
1844
2218
|
end
|
|
1845
2219
|
|
|
1846
2220
|
it "lists #{snake}" do
|
|
1847
|
-
#
|
|
2221
|
+
# tina4:edit replace the tautology with a real assertion
|
|
1848
2222
|
expect(true).to be true
|
|
1849
2223
|
end
|
|
1850
2224
|
|
|
@@ -1875,6 +2249,7 @@ module Tina4
|
|
|
1875
2249
|
# Tests for #{name}
|
|
1876
2250
|
RSpec.describe "#{class_name}" do
|
|
1877
2251
|
before(:each) do
|
|
2252
|
+
# tina4:edit seed real fixtures for a real DB
|
|
1878
2253
|
# Set up test fixtures
|
|
1879
2254
|
end
|
|
1880
2255
|
|
|
@@ -1883,7 +2258,7 @@ module Tina4
|
|
|
1883
2258
|
end
|
|
1884
2259
|
|
|
1885
2260
|
it "works as expected" do
|
|
1886
|
-
#
|
|
2261
|
+
# tina4:edit replace the tautology with a real assertion
|
|
1887
2262
|
expect(true).to be true
|
|
1888
2263
|
end
|
|
1889
2264
|
end
|
|
@@ -1955,6 +2330,7 @@ module Tina4
|
|
|
1955
2330
|
end
|
|
1956
2331
|
|
|
1957
2332
|
content = <<~HTML
|
|
2333
|
+
{# tina4:edit restyle the form beyond the scaffolded defaults #}
|
|
1958
2334
|
{% extends "base.twig" %}
|
|
1959
2335
|
{% block title %}#{name} {% if item.id %}Edit{% else %}Create{% endif %}{% endblock %}
|
|
1960
2336
|
{% block content %}
|
|
@@ -1994,6 +2370,7 @@ module Tina4
|
|
|
1994
2370
|
td = cols.map { |c| "<td>{{ item.#{c} }}</td>" }.join("\n ")
|
|
1995
2371
|
|
|
1996
2372
|
list_content = <<~HTML
|
|
2373
|
+
{# tina4:edit add sort / filter / pagination controls to the list #}
|
|
1997
2374
|
{% extends "base.twig" %}
|
|
1998
2375
|
{% block title %}#{name}s{% endblock %}
|
|
1999
2376
|
{% block content %}
|
|
@@ -2044,6 +2421,7 @@ module Tina4
|
|
|
2044
2421
|
end.join("\n")
|
|
2045
2422
|
|
|
2046
2423
|
detail_content = <<~HTML
|
|
2424
|
+
{# tina4:edit extend the detail view with related records or actions #}
|
|
2047
2425
|
{% extends "base.twig" %}
|
|
2048
2426
|
{% block title %}#{name} Detail{% endblock %}
|
|
2049
2427
|
{% block content %}
|
|
@@ -2087,6 +2465,7 @@ module Tina4
|
|
|
2087
2465
|
# clears the router's auth_required).
|
|
2088
2466
|
Tina4.post "/api/auth/register", auth: false do |request, response|
|
|
2089
2467
|
# Register a new user
|
|
2468
|
+
# tina4:edit harden the register flow (rate limit / captcha / password rules)
|
|
2090
2469
|
email = request.body["email"].to_s
|
|
2091
2470
|
password = request.body["password"].to_s
|
|
2092
2471
|
|
|
@@ -2112,6 +2491,7 @@ module Tina4
|
|
|
2112
2491
|
# PUBLIC: login mints the token — clear BOTH write gates (see register).
|
|
2113
2492
|
Tina4.post "/api/auth/login", auth: false do |request, response|
|
|
2114
2493
|
# Login with email and password
|
|
2494
|
+
# tina4:edit harden the login flow (rate limit / lockout after N failures)
|
|
2115
2495
|
email = request.body["email"].to_s
|
|
2116
2496
|
password = request.body["password"].to_s
|
|
2117
2497
|
|
|
@@ -3146,7 +3526,12 @@ module Tina4
|
|
|
3146
3526
|
# KNOW the envelope shape before it invokes generate (see cmd_generate).
|
|
3147
3527
|
# Bump `version` when the envelope shape changes; add a new envelope
|
|
3148
3528
|
# name rather than mutating the existing one.
|
|
3149
|
-
|
|
3529
|
+
# ADR-0063: v1.1 adds two additive keys to `resolution` — `edit_hints[]`
|
|
3530
|
+
# (grep of every `# tina4:edit <label>` / `-- tina4:edit <label>` line
|
|
3531
|
+
# in the generated files) and `next[]` (curated actionable steps). Every
|
|
3532
|
+
# v1 key is preserved untouched, so a v1 caller reading v1.1 output
|
|
3533
|
+
# keeps working.
|
|
3534
|
+
"resolution_contract" => { "version" => "1.1", "envelope" => "generate_v1_1" }
|
|
3150
3535
|
}
|
|
3151
3536
|
end
|
|
3152
3537
|
|
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
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: tina4ruby
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 3.13.
|
|
4
|
+
version: 3.13.121
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Tina4 Team
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-08-
|
|
11
|
+
date: 2026-08-27 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: rack
|