tina4ruby 3.10.42 → 3.10.44
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/ai.rb +492 -134
- data/lib/tina4/cli.rb +784 -74
- data/lib/tina4/database.rb +12 -5
- data/lib/tina4/dev_admin.rb +266 -66
- data/lib/tina4/events.rb +19 -0
- data/lib/tina4/field_types.rb +6 -1
- data/lib/tina4/frond.rb +112 -81
- data/lib/tina4/metrics.rb +43 -2
- data/lib/tina4/orm.rb +17 -7
- data/lib/tina4/public/js/tina4-dev-admin.min.js +167 -28
- data/lib/tina4/query_builder.rb +8 -2
- data/lib/tina4/rack_app.rb +17 -1
- data/lib/tina4/template.rb +57 -15
- data/lib/tina4/version.rb +1 -1
- metadata +1 -1
data/lib/tina4/cli.rb
CHANGED
|
@@ -7,6 +7,22 @@ module Tina4
|
|
|
7
7
|
class CLI
|
|
8
8
|
COMMANDS = %w[init start migrate migrate:status migrate:rollback seed seed:create test version routes console generate ai help].freeze
|
|
9
9
|
|
|
10
|
+
# ── Field type mapping ──────────────────────────────────────────────
|
|
11
|
+
FIELD_TYPE_MAP = {
|
|
12
|
+
"string" => { orm: "string_field", sql: "VARCHAR(255)", default: "''" },
|
|
13
|
+
"str" => { orm: "string_field", sql: "VARCHAR(255)", default: "''" },
|
|
14
|
+
"int" => { orm: "integer_field", sql: "INTEGER", default: "0" },
|
|
15
|
+
"integer" => { orm: "integer_field", sql: "INTEGER", default: "0" },
|
|
16
|
+
"float" => { orm: "float_field", sql: "REAL", default: "0" },
|
|
17
|
+
"numeric" => { orm: "float_field", sql: "REAL", default: "0" },
|
|
18
|
+
"decimal" => { orm: "float_field", sql: "REAL", default: "0" },
|
|
19
|
+
"bool" => { orm: "boolean_field", sql: "INTEGER", default: "0" },
|
|
20
|
+
"boolean" => { orm: "boolean_field", sql: "INTEGER", default: "0" },
|
|
21
|
+
"text" => { orm: "string_field", sql: "TEXT", default: "''" },
|
|
22
|
+
"datetime" => { orm: "string_field", sql: "TEXT", default: "NULL" },
|
|
23
|
+
"blob" => { orm: "string_field", sql: "BLOB", default: "NULL" },
|
|
24
|
+
}.freeze
|
|
25
|
+
|
|
10
26
|
def self.start(argv)
|
|
11
27
|
new.run(argv)
|
|
12
28
|
end
|
|
@@ -15,7 +31,7 @@ module Tina4
|
|
|
15
31
|
command = argv.shift || "help"
|
|
16
32
|
case command
|
|
17
33
|
when "init" then cmd_init(argv)
|
|
18
|
-
when "start"
|
|
34
|
+
when "start", "serve" then cmd_start(argv)
|
|
19
35
|
when "migrate" then cmd_migrate(argv)
|
|
20
36
|
when "migrate:status" then cmd_migrate_status(argv)
|
|
21
37
|
when "migrate:rollback" then cmd_migrate_rollback(argv)
|
|
@@ -37,6 +53,76 @@ module Tina4
|
|
|
37
53
|
|
|
38
54
|
private
|
|
39
55
|
|
|
56
|
+
# ── Helpers ──────────────────────────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
# CamelCase -> snake_case: ProductCategory -> product_category
|
|
59
|
+
def to_snake_case(name)
|
|
60
|
+
name.gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
|
|
61
|
+
.gsub(/([a-z0-9])([A-Z])/, '\1_\2')
|
|
62
|
+
.downcase
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Class name -> singular table name: Product -> product
|
|
66
|
+
def to_table_name(name)
|
|
67
|
+
to_snake_case(name)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Parse "name:string,price:float" -> [["name","string"], ["price","float"]]
|
|
71
|
+
def parse_fields(fields_str)
|
|
72
|
+
return [] if fields_str.nil? || fields_str.strip.empty?
|
|
73
|
+
|
|
74
|
+
fields_str.split(",").map do |part|
|
|
75
|
+
part = part.strip
|
|
76
|
+
if part.include?(":")
|
|
77
|
+
name, type = part.split(":", 2)
|
|
78
|
+
[name.strip, type.strip.downcase]
|
|
79
|
+
elsif !part.empty?
|
|
80
|
+
[part.strip, "string"]
|
|
81
|
+
end
|
|
82
|
+
end.compact
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Parse --key value and --flag from args. Returns [flags_hash, positional_array]
|
|
86
|
+
def parse_flags(args)
|
|
87
|
+
flags = {}
|
|
88
|
+
positional = []
|
|
89
|
+
i = 0
|
|
90
|
+
while i < args.length
|
|
91
|
+
if args[i].start_with?("--")
|
|
92
|
+
key = args[i][2..]
|
|
93
|
+
if i + 1 < args.length && !args[i + 1].start_with?("--")
|
|
94
|
+
flags[key] = args[i + 1]
|
|
95
|
+
i += 2
|
|
96
|
+
else
|
|
97
|
+
flags[key] = true
|
|
98
|
+
i += 1
|
|
99
|
+
end
|
|
100
|
+
else
|
|
101
|
+
positional << args[i]
|
|
102
|
+
i += 1
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
[flags, positional]
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Kill any process listening on the given port. Returns true if killed.
|
|
109
|
+
def kill_process_on_port(port)
|
|
110
|
+
result = `lsof -ti :#{port} 2>/dev/null`.strip
|
|
111
|
+
return false if result.empty?
|
|
112
|
+
|
|
113
|
+
pids = result.split("\n")
|
|
114
|
+
pids.each do |pid|
|
|
115
|
+
Process.kill("TERM", pid.to_i)
|
|
116
|
+
rescue Errno::ESRCH, Errno::EPERM
|
|
117
|
+
# Process already gone or no permission
|
|
118
|
+
end
|
|
119
|
+
sleep 0.5
|
|
120
|
+
puts " Killed existing process on port #{port} (PID: #{pids.join(', ')})"
|
|
121
|
+
true
|
|
122
|
+
rescue Errno::ENOENT
|
|
123
|
+
false
|
|
124
|
+
end
|
|
125
|
+
|
|
40
126
|
# ── init ──────────────────────────────────────────────────────────────
|
|
41
127
|
|
|
42
128
|
def cmd_init(argv)
|
|
@@ -69,19 +155,28 @@ module Tina4
|
|
|
69
155
|
# ── start ─────────────────────────────────────────────────────────────
|
|
70
156
|
|
|
71
157
|
def cmd_start(argv)
|
|
72
|
-
options = { port: nil, host: nil, dev: false }
|
|
158
|
+
options = { port: nil, host: nil, dev: false, no_browser: false }
|
|
73
159
|
parser = OptionParser.new do |opts|
|
|
74
160
|
opts.banner = "Usage: tina4ruby start [options]"
|
|
75
161
|
opts.on("-p", "--port PORT", Integer, "Port (default: 7147)") { |v| options[:port] = v }
|
|
76
162
|
opts.on("-h", "--host HOST", "Host (default: 0.0.0.0)") { |v| options[:host] = v }
|
|
77
163
|
opts.on("-d", "--dev", "Enable dev mode with auto-reload") { options[:dev] = true }
|
|
164
|
+
opts.on("--no-browser", "Do not open browser on start") { options[:no_browser] = true }
|
|
78
165
|
end
|
|
79
166
|
parser.parse!(argv)
|
|
80
167
|
|
|
168
|
+
# --no-browser from env
|
|
169
|
+
if ENV.fetch("TINA4_OPEN_BROWSER", "").downcase.match?(/\A(false|0|no)\z/)
|
|
170
|
+
options[:no_browser] = true
|
|
171
|
+
end
|
|
172
|
+
|
|
81
173
|
# Priority: CLI flag > ENV var > default
|
|
82
174
|
options[:port] = resolve_config(:port, options[:port])
|
|
83
175
|
options[:host] = resolve_config(:host, options[:host])
|
|
84
176
|
|
|
177
|
+
# Kill existing process on port
|
|
178
|
+
kill_process_on_port(options[:port])
|
|
179
|
+
|
|
85
180
|
require_relative "../tina4"
|
|
86
181
|
|
|
87
182
|
root_dir = Dir.pwd
|
|
@@ -380,125 +475,723 @@ module Tina4
|
|
|
380
475
|
end
|
|
381
476
|
end
|
|
382
477
|
|
|
383
|
-
# ── help ──────────────────────────────────────────────────────────────
|
|
384
|
-
|
|
385
478
|
# ── generate ────────────────────────────────────────────────────────
|
|
386
479
|
|
|
387
480
|
def cmd_generate(argv)
|
|
388
481
|
what = argv.shift
|
|
389
|
-
|
|
390
|
-
unless what
|
|
391
|
-
puts "Usage: tina4ruby generate <what> <name>"
|
|
392
|
-
puts " Generators: model, route, migration, middleware"
|
|
482
|
+
|
|
483
|
+
unless what
|
|
484
|
+
puts "Usage: tina4ruby generate <what> <name> [options]"
|
|
485
|
+
puts " Generators: model, route, crud, migration, middleware, test, form, view, auth"
|
|
486
|
+
puts ' Options: --fields "name:string,price:float" --model ModelName'
|
|
393
487
|
exit 1
|
|
394
488
|
end
|
|
395
489
|
|
|
490
|
+
# Auth doesn't require a name argument
|
|
491
|
+
no_name_generators = %w[auth]
|
|
492
|
+
unless no_name_generators.include?(what)
|
|
493
|
+
if argv.empty? || argv.first.start_with?("--")
|
|
494
|
+
puts "Usage: tina4ruby generate #{what} <name> [options]"
|
|
495
|
+
exit 1
|
|
496
|
+
end
|
|
497
|
+
end
|
|
498
|
+
|
|
499
|
+
name = no_name_generators.include?(what) ? "" : argv.shift
|
|
500
|
+
flags, _positional = parse_flags(argv)
|
|
501
|
+
|
|
396
502
|
case what
|
|
397
|
-
when "model"
|
|
398
|
-
when "route"
|
|
399
|
-
when "
|
|
400
|
-
when "
|
|
503
|
+
when "model" then generate_model(name, flags)
|
|
504
|
+
when "route" then generate_route(name, flags)
|
|
505
|
+
when "crud" then generate_crud(name, flags)
|
|
506
|
+
when "migration" then generate_migration(name, flags)
|
|
507
|
+
when "middleware" then generate_middleware(name, flags)
|
|
508
|
+
when "test" then generate_test(name, flags)
|
|
509
|
+
when "form" then generate_form(name, flags)
|
|
510
|
+
when "view" then generate_view(name, flags)
|
|
511
|
+
when "auth" then generate_auth(name, flags)
|
|
401
512
|
else
|
|
402
513
|
puts "Unknown generator: #{what}"
|
|
403
|
-
puts " Available: model, route, migration, middleware"
|
|
514
|
+
puts " Available: model, route, crud, migration, middleware, test, form, view, auth"
|
|
404
515
|
exit 1
|
|
405
516
|
end
|
|
406
517
|
end
|
|
407
518
|
|
|
408
|
-
|
|
519
|
+
# ── Generator: model ─────────────────────────────────────────────────
|
|
520
|
+
|
|
521
|
+
def generate_model(name, flags)
|
|
522
|
+
fields = parse_fields(flags["fields"])
|
|
523
|
+
table = to_table_name(name)
|
|
524
|
+
snake = to_snake_case(name)
|
|
525
|
+
|
|
526
|
+
# Build field lines
|
|
527
|
+
field_lines = [" integer_field :id, primary_key: true, auto_increment: true"]
|
|
528
|
+
if fields.any?
|
|
529
|
+
fields.each do |fname, ftype|
|
|
530
|
+
info = FIELD_TYPE_MAP[ftype] || FIELD_TYPE_MAP["string"]
|
|
531
|
+
field_lines << " #{info[:orm]} :#{fname}"
|
|
532
|
+
end
|
|
533
|
+
else
|
|
534
|
+
field_lines << " string_field :name"
|
|
535
|
+
end
|
|
536
|
+
field_lines << " string_field :created_at"
|
|
537
|
+
|
|
538
|
+
# Write model file
|
|
409
539
|
dir = "src/orm"
|
|
410
540
|
FileUtils.mkdir_p(dir)
|
|
411
|
-
snake = name.gsub(/([A-Z])/) { |m| ($~.begin(0) > 0 ? "_" : "") + m.downcase }
|
|
412
541
|
path = File.join(dir, "#{snake}.rb")
|
|
413
|
-
|
|
414
|
-
|
|
542
|
+
if File.exist?(path)
|
|
543
|
+
puts " File already exists: #{path}"
|
|
544
|
+
return
|
|
545
|
+
end
|
|
546
|
+
|
|
547
|
+
content = <<~RUBY
|
|
415
548
|
class #{name} < Tina4::ORM
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
549
|
+
table_name "#{table}"
|
|
550
|
+
|
|
551
|
+
#{field_lines.join("\n")}
|
|
419
552
|
end
|
|
420
553
|
RUBY
|
|
554
|
+
|
|
555
|
+
File.write(path, content)
|
|
421
556
|
puts " Created #{path}"
|
|
557
|
+
|
|
558
|
+
# Generate matching migration (unless --no-migration)
|
|
559
|
+
unless flags["no-migration"]
|
|
560
|
+
generate_migration("create_#{table}", flags, fields_override: fields, table_override: table)
|
|
561
|
+
end
|
|
422
562
|
end
|
|
423
563
|
|
|
424
|
-
|
|
564
|
+
# ── Generator: route ─────────────────────────────────────────────────
|
|
565
|
+
|
|
566
|
+
def generate_route(name, flags)
|
|
425
567
|
route_path = name.sub(%r{^/}, "")
|
|
426
|
-
|
|
568
|
+
singular = route_path.end_with?("s") ? route_path[0..-2] : route_path
|
|
569
|
+
model = flags["model"]
|
|
570
|
+
|
|
571
|
+
dir = "src/routes"
|
|
427
572
|
FileUtils.mkdir_p(dir)
|
|
428
|
-
path =
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
end
|
|
573
|
+
path = File.join(dir, "#{route_path}.rb")
|
|
574
|
+
if File.exist?(path)
|
|
575
|
+
puts " File already exists: #{path}"
|
|
576
|
+
return
|
|
577
|
+
end
|
|
434
578
|
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
579
|
+
if model
|
|
580
|
+
model_snake = to_snake_case(model)
|
|
581
|
+
content = <<~RUBY
|
|
582
|
+
require_relative "../orm/#{model_snake}"
|
|
583
|
+
|
|
584
|
+
Tina4.get "/api/#{route_path}" do |request, response|
|
|
585
|
+
# List all #{route_path} with pagination
|
|
586
|
+
page = (request.params["page"] || 1).to_i
|
|
587
|
+
per_page = (request.params["per_page"] || 20).to_i
|
|
588
|
+
offset = (page - 1) * per_page
|
|
589
|
+
results = #{model}.all(limit: per_page, offset: offset)
|
|
590
|
+
response.json({ data: results.map(&:to_h), page: page, per_page: per_page })
|
|
591
|
+
end
|
|
438
592
|
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
593
|
+
Tina4.get "/api/#{route_path}/{id:int}" do |request, response|
|
|
594
|
+
# Get a single #{singular} by ID
|
|
595
|
+
item = #{model}.find(request.params["id"])
|
|
596
|
+
if item.nil?
|
|
597
|
+
response.json({ error: "Not found" }, 404)
|
|
598
|
+
else
|
|
599
|
+
response.json(item.to_h)
|
|
600
|
+
end
|
|
601
|
+
end
|
|
442
602
|
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
603
|
+
Tina4.post "/api/#{route_path}" do |request, response|
|
|
604
|
+
# Create a new #{singular}
|
|
605
|
+
item = #{model}.create(request.body)
|
|
606
|
+
response.json(item.to_h, 201)
|
|
607
|
+
end
|
|
446
608
|
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
609
|
+
Tina4.put "/api/#{route_path}/{id:int}" do |request, response|
|
|
610
|
+
# Update a #{singular} by ID
|
|
611
|
+
item = #{model}.find(request.params["id"])
|
|
612
|
+
if item.nil?
|
|
613
|
+
response.json({ error: "Not found" }, 404)
|
|
614
|
+
else
|
|
615
|
+
request.body.each do |key, value|
|
|
616
|
+
next if key.to_s == "id"
|
|
617
|
+
setter = "#{'#'}{key}="
|
|
618
|
+
item.send(setter, value) if item.respond_to?(setter)
|
|
619
|
+
end
|
|
620
|
+
item.save
|
|
621
|
+
response.json(item.to_h)
|
|
622
|
+
end
|
|
623
|
+
end
|
|
624
|
+
|
|
625
|
+
Tina4.delete "/api/#{route_path}/{id:int}" do |request, response|
|
|
626
|
+
# Delete a #{singular} by ID
|
|
627
|
+
item = #{model}.find(request.params["id"])
|
|
628
|
+
if item.nil?
|
|
629
|
+
response.json({ error: "Not found" }, 404)
|
|
630
|
+
else
|
|
631
|
+
item.delete
|
|
632
|
+
response.json(nil, 204)
|
|
633
|
+
end
|
|
634
|
+
end
|
|
635
|
+
RUBY
|
|
636
|
+
else
|
|
637
|
+
content = <<~RUBY
|
|
638
|
+
Tina4.get "/api/#{route_path}" do |request, response|
|
|
639
|
+
# List all #{route_path}
|
|
640
|
+
response.json({ data: [] })
|
|
641
|
+
end
|
|
642
|
+
|
|
643
|
+
Tina4.get "/api/#{route_path}/{id:int}" do |request, response|
|
|
644
|
+
# Get a single #{singular}
|
|
645
|
+
response.json({ data: {} })
|
|
646
|
+
end
|
|
647
|
+
|
|
648
|
+
Tina4.post "/api/#{route_path}" do |request, response|
|
|
649
|
+
# Create a new #{singular}
|
|
650
|
+
response.json({ data: request.body }, 201)
|
|
651
|
+
end
|
|
652
|
+
|
|
653
|
+
Tina4.put "/api/#{route_path}/{id:int}" do |request, response|
|
|
654
|
+
# Update a #{singular}
|
|
655
|
+
response.json({ data: request.body })
|
|
656
|
+
end
|
|
657
|
+
|
|
658
|
+
Tina4.delete "/api/#{route_path}/{id:int}" do |request, response|
|
|
659
|
+
# Delete a #{singular}
|
|
660
|
+
response.json(nil, 204)
|
|
661
|
+
end
|
|
662
|
+
RUBY
|
|
663
|
+
end
|
|
664
|
+
|
|
665
|
+
File.write(path, content)
|
|
451
666
|
puts " Created #{path}"
|
|
452
667
|
end
|
|
453
668
|
|
|
454
|
-
|
|
669
|
+
# ── Generator: crud ──────────────────────────────────────────────────
|
|
670
|
+
|
|
671
|
+
def generate_crud(name, flags)
|
|
672
|
+
table = to_table_name(name)
|
|
673
|
+
route_name = "#{table}s"
|
|
674
|
+
|
|
675
|
+
puts "\n Generating CRUD for #{name}...\n"
|
|
676
|
+
|
|
677
|
+
# 1. Model + migration
|
|
678
|
+
generate_model(name, flags)
|
|
679
|
+
|
|
680
|
+
# 2. Routes with model
|
|
681
|
+
generate_route(route_name, { "model" => name })
|
|
682
|
+
|
|
683
|
+
# 3. Form
|
|
684
|
+
generate_form(name, flags)
|
|
685
|
+
|
|
686
|
+
# 4. View (list + detail)
|
|
687
|
+
generate_view(name, flags)
|
|
688
|
+
|
|
689
|
+
# 5. Test
|
|
690
|
+
generate_test(route_name, { "model" => name })
|
|
691
|
+
|
|
692
|
+
puts "\n CRUD generation complete for #{name}."
|
|
693
|
+
puts " Run: tina4ruby migrate"
|
|
694
|
+
puts " Visit: /swagger to see the API docs"
|
|
695
|
+
end
|
|
696
|
+
|
|
697
|
+
# ── Generator: migration ─────────────────────────────────────────────
|
|
698
|
+
|
|
699
|
+
def generate_migration(name, flags = {}, fields_override: nil, table_override: nil)
|
|
700
|
+
now = Time.now
|
|
701
|
+
timestamp = now.strftime("%Y%m%d%H%M%S")
|
|
455
702
|
dir = "migrations"
|
|
456
703
|
FileUtils.mkdir_p(dir)
|
|
457
|
-
|
|
458
|
-
table
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
704
|
+
|
|
705
|
+
# Determine table name
|
|
706
|
+
if table_override
|
|
707
|
+
table = table_override
|
|
708
|
+
else
|
|
709
|
+
table = name.sub(/^create_/, "").sub(/^add_/, "").sub(/^drop_/, "")
|
|
710
|
+
table = to_snake_case(table)
|
|
711
|
+
end
|
|
712
|
+
|
|
713
|
+
# Build SQL columns from fields
|
|
714
|
+
fields = fields_override || parse_fields(flags["fields"])
|
|
715
|
+
is_create = name.start_with?("create_") || !fields_override.nil?
|
|
716
|
+
|
|
466
717
|
filename = "#{timestamp}_#{name}.sql"
|
|
467
718
|
path = File.join(dir, filename)
|
|
468
|
-
|
|
469
|
-
|
|
719
|
+
|
|
720
|
+
if is_create
|
|
721
|
+
col_lines = [" id INTEGER PRIMARY KEY AUTOINCREMENT"]
|
|
722
|
+
fields.each do |fname, ftype|
|
|
723
|
+
info = FIELD_TYPE_MAP[ftype] || FIELD_TYPE_MAP["string"]
|
|
724
|
+
default = info[:default] != "NULL" ? " DEFAULT #{info[:default]}" : ""
|
|
725
|
+
col_lines << " #{fname} #{info[:sql]}#{default}"
|
|
726
|
+
end
|
|
727
|
+
col_lines << " created_at TEXT DEFAULT CURRENT_TIMESTAMP"
|
|
728
|
+
|
|
729
|
+
up_sql = "CREATE TABLE IF NOT EXISTS #{table} (\n#{col_lines.join(",\n")}\n);"
|
|
730
|
+
down_sql = "DROP TABLE IF EXISTS #{table};"
|
|
731
|
+
else
|
|
732
|
+
up_sql = "-- Write your UP migration SQL here\n-- Example: ALTER TABLE #{table} ADD COLUMN new_col TEXT DEFAULT '';"
|
|
733
|
+
down_sql = "-- Write your DOWN rollback SQL here\n-- Example: ALTER TABLE #{table} DROP COLUMN new_col;"
|
|
734
|
+
end
|
|
735
|
+
|
|
736
|
+
content = <<~SQL
|
|
470
737
|
-- Migration: #{name}
|
|
471
|
-
-- Created: #{now}
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
);
|
|
738
|
+
-- Created: #{now.strftime("%Y-%m-%d %H:%M:%S")}
|
|
739
|
+
|
|
740
|
+
-- UP
|
|
741
|
+
#{up_sql}
|
|
742
|
+
|
|
743
|
+
-- DOWN
|
|
744
|
+
#{down_sql}
|
|
479
745
|
SQL
|
|
746
|
+
|
|
747
|
+
File.write(path, content)
|
|
480
748
|
puts " Created #{path}"
|
|
749
|
+
|
|
750
|
+
# Also create .down.sql for the migration runner
|
|
751
|
+
down_path = File.join(dir, "#{timestamp}_#{name}.down.sql")
|
|
752
|
+
down_content = <<~SQL
|
|
753
|
+
-- Rollback: #{name}
|
|
754
|
+
-- Created: #{now.strftime("%Y-%m-%d %H:%M:%S")}
|
|
755
|
+
|
|
756
|
+
#{down_sql}
|
|
757
|
+
SQL
|
|
758
|
+
|
|
759
|
+
File.write(down_path, down_content)
|
|
760
|
+
puts " Created #{down_path}"
|
|
481
761
|
end
|
|
482
762
|
|
|
483
|
-
|
|
763
|
+
# ── Generator: middleware ────────────────────────────────────────────
|
|
764
|
+
|
|
765
|
+
def generate_middleware(name, flags = {})
|
|
766
|
+
snake = to_snake_case(name)
|
|
484
767
|
dir = "src/middleware"
|
|
485
768
|
FileUtils.mkdir_p(dir)
|
|
486
|
-
snake = name.gsub(/([A-Z])/) { |m| ($~.begin(0) > 0 ? "_" : "") + m.downcase }
|
|
487
769
|
path = File.join(dir, "#{snake}.rb")
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
770
|
+
if File.exist?(path)
|
|
771
|
+
puts " File already exists: #{path}"
|
|
772
|
+
return
|
|
773
|
+
end
|
|
774
|
+
|
|
775
|
+
content = <<~RUBY
|
|
776
|
+
# #{name} middleware
|
|
777
|
+
#
|
|
778
|
+
# Usage in routes:
|
|
779
|
+
# require_relative "../middleware/#{snake}"
|
|
780
|
+
# Tina4.get "/api/protected", middleware: [#{name}] do |request, response|
|
|
781
|
+
# response.json({ data: "protected" })
|
|
782
|
+
# end
|
|
783
|
+
|
|
784
|
+
class #{name}
|
|
785
|
+
def self.before_#{snake}(request, response)
|
|
786
|
+
# Runs before the route handler.
|
|
787
|
+
# Return [request, response] to continue, or
|
|
788
|
+
# return [request, response.json({ error: "Unauthorized" }, 401)] to block.
|
|
789
|
+
Tina4::Log.info("#{name}: \#{request.request_method} \#{request.path}")
|
|
790
|
+
[request, response]
|
|
791
|
+
end
|
|
792
|
+
|
|
793
|
+
def self.after_#{snake}(request, response)
|
|
794
|
+
# Runs after the route handler.
|
|
795
|
+
[request, response]
|
|
496
796
|
end
|
|
497
797
|
end
|
|
498
798
|
RUBY
|
|
799
|
+
|
|
800
|
+
File.write(path, content)
|
|
801
|
+
puts " Created #{path}"
|
|
802
|
+
end
|
|
803
|
+
|
|
804
|
+
# ── Generator: test ──────────────────────────────────────────────────
|
|
805
|
+
|
|
806
|
+
def generate_test(name, flags = {})
|
|
807
|
+
model = flags["model"]
|
|
808
|
+
snake = to_snake_case(name)
|
|
809
|
+
singular = snake.end_with?("s") ? snake[0..-2] : snake
|
|
810
|
+
|
|
811
|
+
dir = "spec"
|
|
812
|
+
FileUtils.mkdir_p(dir)
|
|
813
|
+
path = File.join(dir, "#{snake}_spec.rb")
|
|
814
|
+
if File.exist?(path)
|
|
815
|
+
puts " File already exists: #{path}"
|
|
816
|
+
return
|
|
817
|
+
end
|
|
818
|
+
|
|
819
|
+
if model
|
|
820
|
+
content = <<~RUBY
|
|
821
|
+
# Tests for #{name} CRUD operations
|
|
822
|
+
RSpec.describe "#{model}" do
|
|
823
|
+
before(:each) do
|
|
824
|
+
# Set up test fixtures
|
|
825
|
+
end
|
|
826
|
+
|
|
827
|
+
after(:each) do
|
|
828
|
+
# Clean up after tests
|
|
829
|
+
end
|
|
830
|
+
|
|
831
|
+
it "lists #{snake}" do
|
|
832
|
+
# TODO: implement
|
|
833
|
+
expect(true).to be true
|
|
834
|
+
end
|
|
835
|
+
|
|
836
|
+
it "gets a single #{singular}" do
|
|
837
|
+
# TODO: implement
|
|
838
|
+
expect(true).to be true
|
|
839
|
+
end
|
|
840
|
+
|
|
841
|
+
it "creates a #{singular}" do
|
|
842
|
+
# TODO: implement
|
|
843
|
+
expect(true).to be true
|
|
844
|
+
end
|
|
845
|
+
|
|
846
|
+
it "updates a #{singular}" do
|
|
847
|
+
# TODO: implement
|
|
848
|
+
expect(true).to be true
|
|
849
|
+
end
|
|
850
|
+
|
|
851
|
+
it "deletes a #{singular}" do
|
|
852
|
+
# TODO: implement
|
|
853
|
+
expect(true).to be true
|
|
854
|
+
end
|
|
855
|
+
end
|
|
856
|
+
RUBY
|
|
857
|
+
else
|
|
858
|
+
class_name = name.split("_").map(&:capitalize).join
|
|
859
|
+
content = <<~RUBY
|
|
860
|
+
# Tests for #{name}
|
|
861
|
+
RSpec.describe "#{class_name}" do
|
|
862
|
+
before(:each) do
|
|
863
|
+
# Set up test fixtures
|
|
864
|
+
end
|
|
865
|
+
|
|
866
|
+
after(:each) do
|
|
867
|
+
# Clean up after tests
|
|
868
|
+
end
|
|
869
|
+
|
|
870
|
+
it "works as expected" do
|
|
871
|
+
# TODO: replace with real tests
|
|
872
|
+
expect(true).to be true
|
|
873
|
+
end
|
|
874
|
+
end
|
|
875
|
+
RUBY
|
|
876
|
+
end
|
|
877
|
+
|
|
878
|
+
File.write(path, content)
|
|
879
|
+
puts " Created #{path}"
|
|
880
|
+
end
|
|
881
|
+
|
|
882
|
+
# ── Generator: form ──────────────────────────────────────────────────
|
|
883
|
+
|
|
884
|
+
def generate_form(name, flags = {})
|
|
885
|
+
fields = parse_fields(flags["fields"])
|
|
886
|
+
table = to_table_name(name)
|
|
887
|
+
route_name = "#{table}s"
|
|
888
|
+
|
|
889
|
+
# Input type mapping
|
|
890
|
+
input_types = {
|
|
891
|
+
"string" => "text", "str" => "text", "text" => "textarea",
|
|
892
|
+
"int" => "number", "integer" => "number",
|
|
893
|
+
"float" => "number", "numeric" => "number", "decimal" => "number",
|
|
894
|
+
"bool" => "checkbox", "boolean" => "checkbox",
|
|
895
|
+
"datetime" => "datetime-local", "blob" => "file",
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
dir = "src/templates/forms"
|
|
899
|
+
FileUtils.mkdir_p(dir)
|
|
900
|
+
path = File.join(dir, "#{table}.twig")
|
|
901
|
+
if File.exist?(path)
|
|
902
|
+
puts " File already exists: #{path}"
|
|
903
|
+
return
|
|
904
|
+
end
|
|
905
|
+
|
|
906
|
+
# Build form fields
|
|
907
|
+
field_html = ""
|
|
908
|
+
form_fields = fields.any? ? fields : [["name", "string"]]
|
|
909
|
+
form_fields.each do |fname, ftype|
|
|
910
|
+
itype = input_types[ftype] || "text"
|
|
911
|
+
label = fname.tr("_", " ").split.map(&:capitalize).join(" ")
|
|
912
|
+
step = %w[float numeric decimal].include?(ftype) ? ' step="0.01"' : ""
|
|
913
|
+
|
|
914
|
+
if itype == "textarea"
|
|
915
|
+
field_html += <<~HTML
|
|
916
|
+
<div class="form-group mb-3">
|
|
917
|
+
<label for="#{fname}">#{label}</label>
|
|
918
|
+
<textarea id="#{fname}" name="#{fname}" class="form-control" rows="4" placeholder="#{label}">{{ item.#{fname} }}</textarea>
|
|
919
|
+
</div>
|
|
920
|
+
HTML
|
|
921
|
+
elsif itype == "checkbox"
|
|
922
|
+
field_html += <<~HTML
|
|
923
|
+
<div class="form-group mb-3">
|
|
924
|
+
<label>
|
|
925
|
+
<input type="checkbox" id="#{fname}" name="#{fname}" value="1" {% if item.#{fname} %}checked{% endif %}>
|
|
926
|
+
#{label}
|
|
927
|
+
</label>
|
|
928
|
+
</div>
|
|
929
|
+
HTML
|
|
930
|
+
else
|
|
931
|
+
field_html += <<~HTML
|
|
932
|
+
<div class="form-group mb-3">
|
|
933
|
+
<label for="#{fname}">#{label}</label>
|
|
934
|
+
<input type="#{itype}" id="#{fname}" name="#{fname}" class="form-control"#{step} value="{{ item.#{fname} }}" placeholder="#{label}">
|
|
935
|
+
</div>
|
|
936
|
+
HTML
|
|
937
|
+
end
|
|
938
|
+
end
|
|
939
|
+
|
|
940
|
+
content = <<~HTML
|
|
941
|
+
{% extends "base.twig" %}
|
|
942
|
+
{% block title %}#{name} {% if item.id %}Edit{% else %}Create{% endif %}{% endblock %}
|
|
943
|
+
{% block content %}
|
|
944
|
+
<div class="container mt-4">
|
|
945
|
+
<h1>{% if item.id %}Edit #{name}{% else %}Create #{name}{% endif %}</h1>
|
|
946
|
+
<form method="post" action="/api/#{route_name}{% if item.id %}/{{ item.id }}{% endif %}">
|
|
947
|
+
{{ form_token() }}
|
|
948
|
+
#{field_html} <button type="submit" class="btn btn-primary">
|
|
949
|
+
{% if item.id %}Update{% else %}Create{% endif %}
|
|
950
|
+
</button>
|
|
951
|
+
<a href="/api/#{route_name}" class="btn btn-secondary">Cancel</a>
|
|
952
|
+
</form>
|
|
953
|
+
</div>
|
|
954
|
+
{% endblock %}
|
|
955
|
+
HTML
|
|
956
|
+
|
|
957
|
+
File.write(path, content)
|
|
499
958
|
puts " Created #{path}"
|
|
500
959
|
end
|
|
501
960
|
|
|
961
|
+
# ── Generator: view ──────────────────────────────────────────────────
|
|
962
|
+
|
|
963
|
+
def generate_view(name, flags = {})
|
|
964
|
+
fields = parse_fields(flags["fields"])
|
|
965
|
+
table = to_table_name(name)
|
|
966
|
+
route_name = "#{table}s"
|
|
967
|
+
|
|
968
|
+
cols = fields.any? ? fields.map { |f, _| f } : ["name"]
|
|
969
|
+
|
|
970
|
+
dir = "src/templates/pages"
|
|
971
|
+
FileUtils.mkdir_p(dir)
|
|
972
|
+
|
|
973
|
+
# List view
|
|
974
|
+
list_path = File.join(dir, "#{route_name}.twig")
|
|
975
|
+
unless File.exist?(list_path)
|
|
976
|
+
th = cols.map { |c| "<th>#{c.tr('_', ' ').split.map(&:capitalize).join(' ')}</th>" }.join("\n ")
|
|
977
|
+
td = cols.map { |c| "<td>{{ item.#{c} }}</td>" }.join("\n ")
|
|
978
|
+
|
|
979
|
+
list_content = <<~HTML
|
|
980
|
+
{% extends "base.twig" %}
|
|
981
|
+
{% block title %}#{name}s{% endblock %}
|
|
982
|
+
{% block content %}
|
|
983
|
+
<div class="container mt-4">
|
|
984
|
+
<div class="d-flex justify-content-between align-items-center mb-3">
|
|
985
|
+
<h1>#{name}s</h1>
|
|
986
|
+
<a href="/#{route_name}/create" class="btn btn-primary">Add #{name}</a>
|
|
987
|
+
</div>
|
|
988
|
+
<table class="table">
|
|
989
|
+
<thead>
|
|
990
|
+
<tr>
|
|
991
|
+
<th>ID</th>
|
|
992
|
+
#{th}
|
|
993
|
+
<th>Actions</th>
|
|
994
|
+
</tr>
|
|
995
|
+
</thead>
|
|
996
|
+
<tbody>
|
|
997
|
+
{% for item in items %}
|
|
998
|
+
<tr>
|
|
999
|
+
<td>{{ item.id }}</td>
|
|
1000
|
+
#{td}
|
|
1001
|
+
<td>
|
|
1002
|
+
<a href="/#{route_name}/{{ item.id }}" class="btn btn-sm btn-primary">View</a>
|
|
1003
|
+
<a href="/#{route_name}/{{ item.id }}/edit" class="btn btn-sm btn-secondary">Edit</a>
|
|
1004
|
+
</td>
|
|
1005
|
+
</tr>
|
|
1006
|
+
{% endfor %}
|
|
1007
|
+
</tbody>
|
|
1008
|
+
</table>
|
|
1009
|
+
</div>
|
|
1010
|
+
{% endblock %}
|
|
1011
|
+
HTML
|
|
1012
|
+
|
|
1013
|
+
File.write(list_path, list_content)
|
|
1014
|
+
puts " Created #{list_path}"
|
|
1015
|
+
end
|
|
1016
|
+
|
|
1017
|
+
# Detail view
|
|
1018
|
+
detail_path = File.join(dir, "#{table}.twig")
|
|
1019
|
+
unless File.exist?(detail_path)
|
|
1020
|
+
detail_fields = cols.map do |c|
|
|
1021
|
+
" <div class=\"mb-3\"><strong>#{c.tr('_', ' ').split.map(&:capitalize).join(' ')}:</strong> {{ item.#{c} }}</div>"
|
|
1022
|
+
end.join("\n")
|
|
1023
|
+
|
|
1024
|
+
detail_content = <<~HTML
|
|
1025
|
+
{% extends "base.twig" %}
|
|
1026
|
+
{% block title %}#{name} Detail{% endblock %}
|
|
1027
|
+
{% block content %}
|
|
1028
|
+
<div class="container mt-4">
|
|
1029
|
+
<div class="d-flex justify-content-between align-items-center mb-3">
|
|
1030
|
+
<h1>#{name} \#{{ item.id }}</h1>
|
|
1031
|
+
<div>
|
|
1032
|
+
<a href="/#{route_name}/{{ item.id }}/edit" class="btn btn-secondary">Edit</a>
|
|
1033
|
+
<a href="/#{route_name}" class="btn btn-outline-secondary">Back</a>
|
|
1034
|
+
</div>
|
|
1035
|
+
</div>
|
|
1036
|
+
#{detail_fields}
|
|
1037
|
+
</div>
|
|
1038
|
+
{% endblock %}
|
|
1039
|
+
HTML
|
|
1040
|
+
|
|
1041
|
+
File.write(detail_path, detail_content)
|
|
1042
|
+
puts " Created #{detail_path}"
|
|
1043
|
+
end
|
|
1044
|
+
end
|
|
1045
|
+
|
|
1046
|
+
# ── Generator: auth ──────────────────────────────────────────────────
|
|
1047
|
+
|
|
1048
|
+
def generate_auth(_name = nil, flags = {})
|
|
1049
|
+
puts "\n Generating authentication scaffolding...\n"
|
|
1050
|
+
|
|
1051
|
+
# 1. User model + migration
|
|
1052
|
+
generate_model("User", { "fields" => "email:string,password:string,role:string" })
|
|
1053
|
+
|
|
1054
|
+
# 2. Auth routes
|
|
1055
|
+
dir = "src/routes"
|
|
1056
|
+
FileUtils.mkdir_p(dir)
|
|
1057
|
+
auth_path = File.join(dir, "auth.rb")
|
|
1058
|
+
unless File.exist?(auth_path)
|
|
1059
|
+
content = <<~'RUBY'
|
|
1060
|
+
require_relative "../orm/user"
|
|
1061
|
+
|
|
1062
|
+
Tina4.post "/api/auth/register" do |request, response|
|
|
1063
|
+
# Register a new user
|
|
1064
|
+
email = request.body["email"].to_s
|
|
1065
|
+
password = request.body["password"].to_s
|
|
1066
|
+
|
|
1067
|
+
if email.empty? || password.empty?
|
|
1068
|
+
next response.json({ error: "Email and password required" }, 400)
|
|
1069
|
+
end
|
|
1070
|
+
|
|
1071
|
+
# Check if user exists
|
|
1072
|
+
existing = User.where("email = ?", [email])
|
|
1073
|
+
unless existing.empty?
|
|
1074
|
+
next response.json({ error: "Email already registered" }, 409)
|
|
1075
|
+
end
|
|
1076
|
+
|
|
1077
|
+
# Create user with hashed password
|
|
1078
|
+
user = User.create({
|
|
1079
|
+
email: email,
|
|
1080
|
+
password: Tina4::Auth.hash_password(password),
|
|
1081
|
+
role: "user",
|
|
1082
|
+
})
|
|
1083
|
+
response.json({ message: "Registered", id: user.id }, 201)
|
|
1084
|
+
end
|
|
1085
|
+
|
|
1086
|
+
Tina4.post "/api/auth/login" do |request, response|
|
|
1087
|
+
# Login with email and password
|
|
1088
|
+
email = request.body["email"].to_s
|
|
1089
|
+
password = request.body["password"].to_s
|
|
1090
|
+
|
|
1091
|
+
users = User.where("email = ?", [email])
|
|
1092
|
+
if users.empty?
|
|
1093
|
+
next response.json({ error: "Invalid credentials" }, 401)
|
|
1094
|
+
end
|
|
1095
|
+
user = users.first
|
|
1096
|
+
|
|
1097
|
+
unless Tina4::Auth.check_password(password, user.password)
|
|
1098
|
+
next response.json({ error: "Invalid credentials" }, 401)
|
|
1099
|
+
end
|
|
1100
|
+
|
|
1101
|
+
token = Tina4::Auth.get_token({ user_id: user.id, email: user.email, role: user.role })
|
|
1102
|
+
response.json({ token: token })
|
|
1103
|
+
end
|
|
1104
|
+
|
|
1105
|
+
Tina4.get "/api/auth/me" do |request, response|
|
|
1106
|
+
# Get current authenticated user
|
|
1107
|
+
payload = Tina4::Auth.authenticate_request(request.headers)
|
|
1108
|
+
if payload.nil?
|
|
1109
|
+
next response.json({ error: "Unauthorized" }, 401)
|
|
1110
|
+
end
|
|
1111
|
+
|
|
1112
|
+
user = User.find(payload["user_id"])
|
|
1113
|
+
if user.nil?
|
|
1114
|
+
next response.json({ error: "User not found" }, 404)
|
|
1115
|
+
end
|
|
1116
|
+
|
|
1117
|
+
response.json({ id: user.id, email: user.email, role: user.role })
|
|
1118
|
+
end
|
|
1119
|
+
RUBY
|
|
1120
|
+
|
|
1121
|
+
File.write(auth_path, content)
|
|
1122
|
+
puts " Created #{auth_path}"
|
|
1123
|
+
end
|
|
1124
|
+
|
|
1125
|
+
# 3. Login template
|
|
1126
|
+
forms_dir = "src/templates/forms"
|
|
1127
|
+
FileUtils.mkdir_p(forms_dir)
|
|
1128
|
+
login_path = File.join(forms_dir, "login.twig")
|
|
1129
|
+
unless File.exist?(login_path)
|
|
1130
|
+
File.write(login_path, <<~HTML)
|
|
1131
|
+
{% extends "base.twig" %}
|
|
1132
|
+
{% block title %}Login{% endblock %}
|
|
1133
|
+
{% block content %}
|
|
1134
|
+
<div class="container mt-4" style="max-width:400px">
|
|
1135
|
+
<h1>Login</h1>
|
|
1136
|
+
<form method="post" action="/api/auth/login">
|
|
1137
|
+
{{ form_token() }}
|
|
1138
|
+
<div class="form-group mb-3">
|
|
1139
|
+
<label for="email">Email</label>
|
|
1140
|
+
<input type="email" id="email" name="email" class="form-control" placeholder="Email" required>
|
|
1141
|
+
</div>
|
|
1142
|
+
<div class="form-group mb-3">
|
|
1143
|
+
<label for="password">Password</label>
|
|
1144
|
+
<input type="password" id="password" name="password" class="form-control" placeholder="Password" required>
|
|
1145
|
+
</div>
|
|
1146
|
+
<button type="submit" class="btn btn-primary w-100">Login</button>
|
|
1147
|
+
<p class="mt-3 text-center"><a href="/register">Create an account</a></p>
|
|
1148
|
+
</form>
|
|
1149
|
+
</div>
|
|
1150
|
+
{% endblock %}
|
|
1151
|
+
HTML
|
|
1152
|
+
puts " Created #{login_path}"
|
|
1153
|
+
end
|
|
1154
|
+
|
|
1155
|
+
# 4. Register template
|
|
1156
|
+
register_path = File.join(forms_dir, "register.twig")
|
|
1157
|
+
unless File.exist?(register_path)
|
|
1158
|
+
File.write(register_path, <<~HTML)
|
|
1159
|
+
{% extends "base.twig" %}
|
|
1160
|
+
{% block title %}Register{% endblock %}
|
|
1161
|
+
{% block content %}
|
|
1162
|
+
<div class="container mt-4" style="max-width:400px">
|
|
1163
|
+
<h1>Register</h1>
|
|
1164
|
+
<form method="post" action="/api/auth/register">
|
|
1165
|
+
{{ form_token() }}
|
|
1166
|
+
<div class="form-group mb-3">
|
|
1167
|
+
<label for="email">Email</label>
|
|
1168
|
+
<input type="email" id="email" name="email" class="form-control" placeholder="Email" required>
|
|
1169
|
+
</div>
|
|
1170
|
+
<div class="form-group mb-3">
|
|
1171
|
+
<label for="password">Password</label>
|
|
1172
|
+
<input type="password" id="password" name="password" class="form-control" placeholder="Password" minlength="8" required>
|
|
1173
|
+
</div>
|
|
1174
|
+
<button type="submit" class="btn btn-primary w-100">Register</button>
|
|
1175
|
+
<p class="mt-3 text-center"><a href="/login">Already have an account?</a></p>
|
|
1176
|
+
</form>
|
|
1177
|
+
</div>
|
|
1178
|
+
{% endblock %}
|
|
1179
|
+
HTML
|
|
1180
|
+
puts " Created #{register_path}"
|
|
1181
|
+
end
|
|
1182
|
+
|
|
1183
|
+
# 5. Auth test
|
|
1184
|
+
generate_test("auth", { "model" => "User" })
|
|
1185
|
+
|
|
1186
|
+
puts "\n Authentication scaffolding complete."
|
|
1187
|
+
puts " Run: tina4ruby migrate"
|
|
1188
|
+
puts " POST /api/auth/register - create account"
|
|
1189
|
+
puts " POST /api/auth/login - get JWT token"
|
|
1190
|
+
puts " GET /api/auth/me - get profile (requires token)"
|
|
1191
|
+
end
|
|
1192
|
+
|
|
1193
|
+
# ── help ──────────────────────────────────────────────────────────────
|
|
1194
|
+
|
|
502
1195
|
def cmd_help
|
|
503
1196
|
puts <<~HELP
|
|
504
1197
|
Tina4 Ruby CLI
|
|
@@ -508,6 +1201,7 @@ module Tina4
|
|
|
508
1201
|
Commands:
|
|
509
1202
|
init [NAME] Initialize a new Tina4 project
|
|
510
1203
|
start Start the Tina4 web server
|
|
1204
|
+
serve Alias for start
|
|
511
1205
|
migrate Run database migrations
|
|
512
1206
|
migrate:status Show migration status (completed and pending)
|
|
513
1207
|
migrate:rollback Rollback the last batch of migrations
|
|
@@ -517,10 +1211,25 @@ module Tina4
|
|
|
517
1211
|
version Show Tina4 version
|
|
518
1212
|
routes List all registered routes
|
|
519
1213
|
console Start an interactive console
|
|
520
|
-
generate <what> <name> Generate scaffolding (model, route, migration, middleware)
|
|
521
1214
|
ai Detect AI tools and install context files
|
|
522
1215
|
help Show this help message
|
|
523
1216
|
|
|
1217
|
+
Generators:
|
|
1218
|
+
generate model <Name> [--fields "name:string,price:float"]
|
|
1219
|
+
generate route <name> [--model Name]
|
|
1220
|
+
generate crud <Name> [--fields "..."] Model + migration + routes + form + view + test
|
|
1221
|
+
generate migration <description>
|
|
1222
|
+
generate middleware <Name>
|
|
1223
|
+
generate test <name>
|
|
1224
|
+
generate form <Name> [--fields "..."] Form template with inputs matching model fields
|
|
1225
|
+
generate view <Name> [--fields "..."] List + detail templates for viewing records
|
|
1226
|
+
generate auth Login/register/logout routes + User model + templates
|
|
1227
|
+
|
|
1228
|
+
Field types: string, int, float, bool, text, datetime, blob
|
|
1229
|
+
Table names: singular by default (Product -> product)
|
|
1230
|
+
|
|
1231
|
+
https://tina4.com
|
|
1232
|
+
|
|
524
1233
|
Run 'tina4ruby COMMAND --help' for more information on a command.
|
|
525
1234
|
HELP
|
|
526
1235
|
end
|
|
@@ -564,9 +1273,10 @@ module Tina4
|
|
|
564
1273
|
|
|
565
1274
|
def create_project_structure(dir)
|
|
566
1275
|
%w[
|
|
567
|
-
src/routes src/orm src/templates src/templates/errors
|
|
1276
|
+
src/routes src/orm src/middleware src/templates src/templates/errors
|
|
1277
|
+
src/templates/forms src/templates/pages
|
|
568
1278
|
src/public src/public/css src/public/js src/public/images
|
|
569
|
-
migrations logs
|
|
1279
|
+
migrations logs spec seeds
|
|
570
1280
|
].each do |subdir|
|
|
571
1281
|
FileUtils.mkdir_p(File.join(dir, subdir))
|
|
572
1282
|
end
|