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.
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" then cmd_start(argv)
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
- name = argv.shift
390
- unless what && name
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" then generate_model(name)
398
- when "route" then generate_route(name)
399
- when "migration" then generate_migration(name)
400
- when "middleware" then generate_middleware(name)
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
- def generate_model(name)
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
- abort " File already exists: #{path}" if File.exist?(path)
414
- File.write(path, <<~RUBY)
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
- integer_field :id, primary_key: true, auto_increment: true
417
- string_field :name
418
- string_field :email
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
- def generate_route(name)
564
+ # ── Generator: route ─────────────────────────────────────────────────
565
+
566
+ def generate_route(name, flags)
425
567
  route_path = name.sub(%r{^/}, "")
426
- dir = "src/routes/#{route_path}"
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 = dir.chomp("/") + ".rb"
429
- abort " File already exists: #{path}" if File.exist?(path)
430
- File.write(path, <<~RUBY)
431
- Tina4.get "/#{route_path}" do |request, response|
432
- response.json(data: [])
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
- Tina4.get "/#{route_path}/:id" do |request, response|
436
- response.json(data: {})
437
- end
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
- Tina4.post "/#{route_path}" do |request, response|
440
- response.json({ message: "created" }, 201)
441
- end
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
- Tina4.put "/#{route_path}/:id" do |request, response|
444
- response.json(message: "updated")
445
- end
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
- Tina4.delete "/#{route_path}/:id" do |request, response|
448
- response.json(message: "deleted")
449
- end
450
- RUBY
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
- def generate_migration(name)
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
- timestamp = Time.now.strftime("%Y%m%d%H%M%S")
458
- table = name.sub(/^create_/, "")
459
- table = if table.end_with?("s")
460
- table
461
- elsif table.end_with?("y")
462
- table[0..-2] + "ies"
463
- else
464
- table + "s"
465
- end
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
- now = Time.now.strftime("%Y-%m-%d %H:%M:%S")
469
- File.write(path, <<~SQL)
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
- CREATE TABLE #{table} (
474
- id INTEGER PRIMARY KEY AUTOINCREMENT,
475
- name TEXT NOT NULL,
476
- email TEXT NOT NULL,
477
- created_at DATETIME DEFAULT CURRENT_TIMESTAMP
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
- def generate_middleware(name)
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
- abort " File already exists: #{path}" if File.exist?(path)
489
- File.write(path, <<~RUBY)
490
- class #{name} < Tina4::Middleware
491
- def process(request, response)
492
- auth = request.headers["Authorization"]
493
- return response.json({ error: "Unauthorized" }, 401) unless auth
494
-
495
- nil
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