tina4ruby 3.11.15 → 3.11.16

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.
Files changed (134) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +80 -80
  3. data/LICENSE.txt +21 -21
  4. data/README.md +137 -137
  5. data/exe/tina4ruby +5 -5
  6. data/lib/tina4/ai.rb +696 -696
  7. data/lib/tina4/api.rb +189 -189
  8. data/lib/tina4/auth.rb +305 -305
  9. data/lib/tina4/auto_crud.rb +244 -244
  10. data/lib/tina4/cache.rb +154 -154
  11. data/lib/tina4/cli.rb +1449 -1449
  12. data/lib/tina4/constants.rb +46 -46
  13. data/lib/tina4/container.rb +74 -74
  14. data/lib/tina4/cors.rb +74 -74
  15. data/lib/tina4/crud.rb +692 -692
  16. data/lib/tina4/database/sqlite3_adapter.rb +165 -165
  17. data/lib/tina4/database.rb +625 -625
  18. data/lib/tina4/database_result.rb +208 -208
  19. data/lib/tina4/debug.rb +8 -8
  20. data/lib/tina4/dev.rb +14 -14
  21. data/lib/tina4/dev_admin.rb +1289 -935
  22. data/lib/tina4/dev_mailbox.rb +191 -191
  23. data/lib/tina4/drivers/firebird_driver.rb +124 -124
  24. data/lib/tina4/drivers/mongodb_driver.rb +561 -561
  25. data/lib/tina4/drivers/mssql_driver.rb +112 -112
  26. data/lib/tina4/drivers/mysql_driver.rb +90 -90
  27. data/lib/tina4/drivers/odbc_driver.rb +191 -191
  28. data/lib/tina4/drivers/postgres_driver.rb +116 -116
  29. data/lib/tina4/drivers/sqlite_driver.rb +122 -122
  30. data/lib/tina4/env.rb +95 -95
  31. data/lib/tina4/error_overlay.rb +252 -252
  32. data/lib/tina4/events.rb +109 -109
  33. data/lib/tina4/field_types.rb +154 -154
  34. data/lib/tina4/frond.rb +2087 -2025
  35. data/lib/tina4/gallery/auth/meta.json +1 -1
  36. data/lib/tina4/gallery/auth/src/routes/api/gallery_auth.rb +114 -114
  37. data/lib/tina4/gallery/database/meta.json +1 -1
  38. data/lib/tina4/gallery/database/src/routes/api/gallery_db.rb +43 -43
  39. data/lib/tina4/gallery/error-overlay/meta.json +1 -1
  40. data/lib/tina4/gallery/error-overlay/src/routes/api/gallery_crash.rb +17 -17
  41. data/lib/tina4/gallery/orm/meta.json +1 -1
  42. data/lib/tina4/gallery/orm/src/routes/api/gallery_products.rb +16 -16
  43. data/lib/tina4/gallery/queue/meta.json +1 -1
  44. data/lib/tina4/gallery/queue/src/routes/api/gallery_queue.rb +325 -325
  45. data/lib/tina4/gallery/rest-api/meta.json +1 -1
  46. data/lib/tina4/gallery/rest-api/src/routes/api/gallery_hello.rb +14 -14
  47. data/lib/tina4/gallery/templates/meta.json +1 -1
  48. data/lib/tina4/gallery/templates/src/routes/gallery_page.rb +12 -12
  49. data/lib/tina4/gallery/templates/src/templates/gallery_page.twig +257 -257
  50. data/lib/tina4/graphql.rb +966 -966
  51. data/lib/tina4/health.rb +39 -39
  52. data/lib/tina4/html_element.rb +170 -170
  53. data/lib/tina4/job.rb +80 -80
  54. data/lib/tina4/localization.rb +168 -168
  55. data/lib/tina4/log.rb +203 -203
  56. data/lib/tina4/mcp.rb +871 -696
  57. data/lib/tina4/messenger.rb +587 -587
  58. data/lib/tina4/metrics.rb +793 -793
  59. data/lib/tina4/middleware.rb +445 -445
  60. data/lib/tina4/migration.rb +451 -451
  61. data/lib/tina4/orm.rb +790 -790
  62. data/lib/tina4/plan.rb +471 -0
  63. data/lib/tina4/project_index.rb +366 -0
  64. data/lib/tina4/public/css/tina4.css +2463 -2463
  65. data/lib/tina4/public/css/tina4.min.css +1 -1
  66. data/lib/tina4/public/images/logo.svg +5 -5
  67. data/lib/tina4/public/js/frond.min.js +2 -2
  68. data/lib/tina4/public/js/tina4-dev-admin.js +1264 -565
  69. data/lib/tina4/public/js/tina4-dev-admin.min.js +1264 -480
  70. data/lib/tina4/public/js/tina4.min.js +92 -92
  71. data/lib/tina4/public/js/tina4js.min.js +48 -48
  72. data/lib/tina4/public/swagger/index.html +90 -90
  73. data/lib/tina4/public/swagger/oauth2-redirect.html +63 -63
  74. data/lib/tina4/query_builder.rb +380 -380
  75. data/lib/tina4/queue.rb +366 -366
  76. data/lib/tina4/queue_backends/kafka_backend.rb +80 -80
  77. data/lib/tina4/queue_backends/lite_backend.rb +298 -298
  78. data/lib/tina4/queue_backends/mongo_backend.rb +126 -126
  79. data/lib/tina4/queue_backends/rabbitmq_backend.rb +73 -73
  80. data/lib/tina4/rack_app.rb +817 -817
  81. data/lib/tina4/rate_limiter.rb +130 -130
  82. data/lib/tina4/request.rb +268 -268
  83. data/lib/tina4/response.rb +346 -346
  84. data/lib/tina4/response_cache.rb +551 -551
  85. data/lib/tina4/router.rb +406 -406
  86. data/lib/tina4/scss/tina4css/_alerts.scss +34 -34
  87. data/lib/tina4/scss/tina4css/_badges.scss +22 -22
  88. data/lib/tina4/scss/tina4css/_buttons.scss +69 -69
  89. data/lib/tina4/scss/tina4css/_cards.scss +49 -49
  90. data/lib/tina4/scss/tina4css/_forms.scss +156 -156
  91. data/lib/tina4/scss/tina4css/_grid.scss +81 -81
  92. data/lib/tina4/scss/tina4css/_modals.scss +84 -84
  93. data/lib/tina4/scss/tina4css/_nav.scss +149 -149
  94. data/lib/tina4/scss/tina4css/_reset.scss +94 -94
  95. data/lib/tina4/scss/tina4css/_tables.scss +54 -54
  96. data/lib/tina4/scss/tina4css/_typography.scss +55 -55
  97. data/lib/tina4/scss/tina4css/_utilities.scss +197 -197
  98. data/lib/tina4/scss/tina4css/_variables.scss +117 -117
  99. data/lib/tina4/scss/tina4css/base.scss +1 -1
  100. data/lib/tina4/scss/tina4css/colors.scss +48 -48
  101. data/lib/tina4/scss/tina4css/tina4.scss +17 -17
  102. data/lib/tina4/scss_compiler.rb +178 -178
  103. data/lib/tina4/seeder.rb +567 -567
  104. data/lib/tina4/service_runner.rb +303 -303
  105. data/lib/tina4/session.rb +297 -297
  106. data/lib/tina4/session_handlers/database_handler.rb +72 -72
  107. data/lib/tina4/session_handlers/file_handler.rb +67 -67
  108. data/lib/tina4/session_handlers/mongo_handler.rb +49 -49
  109. data/lib/tina4/session_handlers/redis_handler.rb +43 -43
  110. data/lib/tina4/session_handlers/valkey_handler.rb +43 -43
  111. data/lib/tina4/shutdown.rb +84 -84
  112. data/lib/tina4/sql_translation.rb +158 -158
  113. data/lib/tina4/swagger.rb +124 -124
  114. data/lib/tina4/template.rb +894 -894
  115. data/lib/tina4/templates/base.twig +26 -26
  116. data/lib/tina4/templates/errors/302.twig +14 -14
  117. data/lib/tina4/templates/errors/401.twig +9 -9
  118. data/lib/tina4/templates/errors/403.twig +29 -29
  119. data/lib/tina4/templates/errors/404.twig +29 -29
  120. data/lib/tina4/templates/errors/500.twig +38 -38
  121. data/lib/tina4/templates/errors/502.twig +9 -9
  122. data/lib/tina4/templates/errors/503.twig +12 -12
  123. data/lib/tina4/templates/errors/base.twig +37 -37
  124. data/lib/tina4/test_client.rb +159 -159
  125. data/lib/tina4/testing.rb +340 -340
  126. data/lib/tina4/validator.rb +174 -174
  127. data/lib/tina4/version.rb +1 -1
  128. data/lib/tina4/webserver.rb +312 -312
  129. data/lib/tina4/websocket.rb +343 -343
  130. data/lib/tina4/websocket_backplane.rb +190 -190
  131. data/lib/tina4/wsdl.rb +564 -564
  132. data/lib/tina4.rb +460 -458
  133. data/lib/tina4ruby.rb +4 -4
  134. metadata +5 -3
data/lib/tina4/cli.rb CHANGED
@@ -1,1449 +1,1449 @@
1
- # frozen_string_literal: true
2
-
3
- require "optparse"
4
- require "fileutils"
5
-
6
- module Tina4
7
- class CLI
8
- COMMANDS = %w[init start migrate migrate:status migrate:rollback seed seed:create test version routes console generate ai help].freeze
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
-
26
- def self.start(argv)
27
- new.run(argv)
28
- end
29
-
30
- def run(argv)
31
- command = argv.shift || "help"
32
- case command
33
- when "init" then cmd_init(argv)
34
- when "start", "serve" then cmd_start(argv)
35
- when "migrate" then cmd_migrate(argv)
36
- when "migrate:status" then cmd_migrate_status(argv)
37
- when "migrate:rollback" then cmd_migrate_rollback(argv)
38
- when "seed" then cmd_seed(argv)
39
- when "seed:create" then cmd_seed_create(argv)
40
- when "test" then cmd_test(argv)
41
- when "version" then cmd_version
42
- when "routes" then cmd_routes
43
- when "console" then cmd_console
44
- when "generate" then cmd_generate(argv)
45
- when "ai" then cmd_ai(argv)
46
- when "help", "-h", "--help" then cmd_help
47
- else
48
- puts "Unknown command: #{command}"
49
- cmd_help
50
- exit 1
51
- end
52
- end
53
-
54
- private
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
- # Boolean-only flags that never take a value argument
88
- boolean_flags = %w[no-browser no-reload production managed all clear dev]
89
-
90
- flags = {}
91
- positional = []
92
- i = 0
93
- while i < args.length
94
- if args[i].start_with?("--")
95
- key = args[i][2..]
96
- if boolean_flags.include?(key)
97
- flags[key] = true
98
- i += 1
99
- elsif i + 1 < args.length && !args[i + 1].start_with?("--")
100
- flags[key] = args[i + 1]
101
- i += 2
102
- else
103
- flags[key] = true
104
- i += 1
105
- end
106
- else
107
- positional << args[i]
108
- i += 1
109
- end
110
- end
111
- [flags, positional]
112
- end
113
-
114
- # Kill any process listening on the given port. Returns true if killed.
115
- def kill_process_on_port(port)
116
- result = `lsof -ti :#{port} 2>/dev/null`.strip
117
- return false if result.empty?
118
-
119
- pids = result.split("\n")
120
- pids.each do |pid|
121
- Process.kill("TERM", pid.to_i)
122
- rescue Errno::ESRCH, Errno::EPERM
123
- # Process already gone or no permission
124
- end
125
- sleep 0.5
126
- puts " Killed existing process on port #{port} (PID: #{pids.join(', ')})"
127
- true
128
- rescue Errno::ENOENT
129
- false
130
- end
131
-
132
- # ── init ──────────────────────────────────────────────────────────────
133
-
134
- def cmd_init(argv)
135
- options = { template: "default" }
136
- parser = OptionParser.new do |opts|
137
- opts.banner = "Usage: tina4ruby init [PATH] [options]"
138
- opts.on("--template TEMPLATE", "Project template (default: default)") { |v| options[:template] = v }
139
- end
140
- parser.parse!(argv)
141
-
142
- name = argv.shift || "."
143
- dir = File.expand_path(name)
144
- FileUtils.mkdir_p(dir)
145
-
146
- project_name = File.basename(dir)
147
- create_project_structure(dir)
148
- create_sample_files(dir, project_name)
149
-
150
- puts "\nProject scaffolded at #{dir}"
151
- if name == "."
152
- puts " bundle install"
153
- puts " ruby app.rb"
154
- else
155
- puts " cd #{dir}"
156
- puts " bundle install"
157
- puts " ruby app.rb"
158
- end
159
- end
160
-
161
- # ── start ─────────────────────────────────────────────────────────────
162
-
163
- def cmd_start(argv)
164
- options = { port: nil, host: nil, dev: false, no_browser: false, no_reload: false, production: false }
165
- parser = OptionParser.new do |opts|
166
- opts.banner = "Usage: tina4ruby start [options]"
167
- opts.on("-p", "--port PORT", Integer, "Port (default: 7147)") { |v| options[:port] = v }
168
- opts.on("-h", "--host HOST", "Host (default: 0.0.0.0)") { |v| options[:host] = v }
169
- opts.on("-d", "--dev", "Enable dev mode with auto-reload") { options[:dev] = true }
170
- opts.on("--production", "Use production server (Puma)") { options[:production] = true }
171
- opts.on("--no-browser", "Do not open browser on start") { options[:no_browser] = true }
172
- opts.on("--no-reload", "Disable file watcher / live-reload") { options[:no_reload] = true }
173
- end
174
- parser.parse!(argv)
175
-
176
- # --no-browser from env (TINA4_NO_BROWSER=true)
177
- no_browser_env = ENV.fetch("TINA4_NO_BROWSER", "").downcase
178
- if no_browser_env.match?(/\A(true|1|yes)\z/)
179
- options[:no_browser] = true
180
- end
181
-
182
- # --no-reload flag sets TINA4_NO_RELOAD so the existing env check picks it up
183
- if options[:no_reload]
184
- ENV["TINA4_NO_RELOAD"] = "true"
185
- end
186
-
187
- # Priority: CLI flag > ENV var > default
188
- options[:port] = resolve_config(:port, options[:port])
189
- options[:host] = resolve_config(:host, options[:host])
190
-
191
- # Kill existing process on port
192
- kill_process_on_port(options[:port])
193
-
194
- require_relative "../tina4"
195
-
196
- root_dir = Dir.pwd
197
- Tina4.initialize!(root_dir)
198
-
199
- # Register health check endpoint
200
- Tina4::Health.register!
201
-
202
- # Load route files
203
- load_routes(root_dir)
204
-
205
- # File watching is handled by the Rust CLI (tina4 serve). The framework
206
- # only needs POST /__dev/api/reload to update the mtime counter for browser polling.
207
- # No internal file watcher.
208
-
209
- app = Tina4::RackApp.new(root_dir: root_dir)
210
-
211
- is_debug = Tina4::Env.is_truthy(ENV["TINA4_DEBUG"])
212
-
213
- # Use Puma only when explicitly requested via --production flag
214
- # WEBrick is used for development (supports dev toolbar/reload)
215
- if options[:production]
216
- begin
217
- require "puma"
218
- require "puma/configuration"
219
- require "puma/launcher"
220
-
221
- puma_host = options[:host]
222
- puma_port = options[:port]
223
-
224
- config = Puma::Configuration.new do |user_config|
225
- user_config.bind "tcp://#{puma_host}:#{puma_port}"
226
- user_config.app app
227
- user_config.threads 0, 16
228
- user_config.workers 0
229
- user_config.environment "production"
230
- user_config.log_requests false
231
- user_config.quiet
232
- end
233
-
234
- Tina4::Log.info("Production server: puma")
235
-
236
- # Setup graceful shutdown (Puma manages its own signals, but we handle DB cleanup)
237
- Tina4::Shutdown.setup
238
-
239
- launcher = Puma::Launcher.new(config)
240
- launcher.run
241
- return
242
- rescue LoadError
243
- # Puma not installed, fall through to WEBrick
244
- end
245
- end
246
-
247
- Tina4::Log.info("Development server: WEBrick")
248
- server = Tina4::WebServer.new(app, host: options[:host], port: options[:port])
249
- server.start
250
- end
251
-
252
- # ── migrate ───────────────────────────────────────────────────────────
253
-
254
- def cmd_migrate(argv)
255
- options = {}
256
- parser = OptionParser.new do |opts|
257
- opts.banner = "Usage: tina4ruby migrate [options]"
258
- opts.on("--create NAME", "Create a new migration") { |v| options[:create] = v }
259
- opts.on("--rollback N", Integer, "Rollback N migrations") { |v| options[:rollback] = v }
260
- end
261
- parser.parse!(argv)
262
-
263
- require_relative "../tina4"
264
- Tina4.initialize!(Dir.pwd)
265
-
266
- db = Tina4.database
267
- unless db
268
- puts "No database configured. Set DATABASE_URL in your .env file."
269
- return
270
- end
271
-
272
- migration = Tina4::Migration.new(db)
273
-
274
- if options[:create]
275
- path = migration.create(options[:create])
276
- puts "Created migration: #{path}"
277
- elsif options[:rollback]
278
- migration.rollback(options[:rollback])
279
- puts "Rolled back #{options[:rollback]} migration(s)"
280
- else
281
- results = migration.run
282
- if results.empty?
283
- puts "No pending migrations"
284
- else
285
- results.each do |r|
286
- status_icon = r[:status] == "success" ? "OK" : "FAIL"
287
- puts " [#{status_icon}] #{r[:name]}"
288
- end
289
- end
290
- end
291
- end
292
-
293
- # ── migrate:status ─────────────────────────────────────────────────────
294
-
295
- def cmd_migrate_status(_argv)
296
- require_relative "../tina4"
297
- Tina4.initialize!(Dir.pwd)
298
-
299
- db = Tina4.database
300
- unless db
301
- puts "No database configured. Set DATABASE_URL in your .env file."
302
- return
303
- end
304
-
305
- migration = Tina4::Migration.new(db)
306
- info = migration.status
307
-
308
- puts "\nMigration Status"
309
- puts "-" * 60
310
-
311
- if info[:completed].any?
312
- puts "\nCompleted:"
313
- info[:completed].each { |name| puts " [OK] #{name}" }
314
- end
315
-
316
- if info[:pending].any?
317
- puts "\nPending:"
318
- info[:pending].each { |name| puts " [ ] #{name}" }
319
- end
320
-
321
- if info[:completed].empty? && info[:pending].empty?
322
- puts " No migrations found."
323
- end
324
-
325
- puts "-" * 60
326
- puts " Completed: #{info[:completed].length} Pending: #{info[:pending].length}\n"
327
- end
328
-
329
- # ── migrate:rollback ───────────────────────────────────────────────────
330
-
331
- def cmd_migrate_rollback(argv)
332
- options = { steps: 1 }
333
- parser = OptionParser.new do |opts|
334
- opts.banner = "Usage: tina4ruby migrate:rollback [options]"
335
- opts.on("-n", "--steps N", Integer, "Number of batches to rollback (default: 1)") { |v| options[:steps] = v }
336
- end
337
- parser.parse!(argv)
338
-
339
- require_relative "../tina4"
340
- Tina4.initialize!(Dir.pwd)
341
-
342
- db = Tina4.database
343
- unless db
344
- puts "No database configured. Set DATABASE_URL in your .env file."
345
- return
346
- end
347
-
348
- migration = Tina4::Migration.new(db)
349
- results = migration.rollback(options[:steps])
350
-
351
- if results.empty?
352
- puts "Nothing to rollback."
353
- else
354
- results.each do |r|
355
- status_icon = r[:status] == "rolled_back" ? "OK" : "FAIL"
356
- puts " [#{status_icon}] #{r[:name]}"
357
- end
358
- puts "Rolled back #{results.length} migration(s)."
359
- end
360
- end
361
-
362
- # ── seed ──────────────────────────────────────────────────────────────
363
-
364
- def cmd_seed(argv)
365
- options = { clear: false }
366
- parser = OptionParser.new do |opts|
367
- opts.banner = "Usage: tina4ruby seed [options]"
368
- opts.on("--clear", "Clear tables before seeding") { options[:clear] = true }
369
- end
370
- parser.parse!(argv)
371
-
372
- require_relative "../tina4"
373
- Tina4.initialize!(Dir.pwd)
374
- load_routes(Dir.pwd)
375
- Tina4.seed_dir(seed_folder: "seeds", clear: options[:clear])
376
- end
377
-
378
- # ── seed:create ───────────────────────────────────────────────────────
379
-
380
- def cmd_seed_create(argv)
381
- name = argv.shift
382
- unless name
383
- puts "Usage: tina4ruby seed:create NAME"
384
- exit 1
385
- end
386
-
387
- dir = File.join(Dir.pwd, "seeds")
388
- FileUtils.mkdir_p(dir)
389
-
390
- existing = Dir.glob(File.join(dir, "*.rb")).select { |f| File.basename(f)[0] =~ /\d/ }.sort
391
- numbers = existing.map { |f| File.basename(f).match(/^(\d+)/)[1].to_i }
392
- next_num = numbers.empty? ? 1 : numbers.max + 1
393
-
394
- clean_name = name.strip.downcase.gsub(/[^a-z0-9]+/, "_").gsub(/^_|_$/, "")
395
- filename = format("%03d_%s.rb", next_num, clean_name)
396
- filepath = File.join(dir, filename)
397
-
398
- File.write(filepath, <<~RUBY)
399
- # Seed: #{name.strip}
400
- #
401
- # This file is executed by `tina4ruby seed`.
402
- # Use Tina4.seed_orm or Tina4.seed_table to populate data.
403
- #
404
- # Examples:
405
- # Tina4.seed_orm(User, count: 50)
406
- # Tina4.seed_table("audit_log", { action: :string, created_at: :datetime }, count: 100)
407
- RUBY
408
-
409
- puts "Created seed file: #{filepath}"
410
- end
411
-
412
- # ── test ──────────────────────────────────────────────────────────────
413
-
414
- def cmd_test(argv)
415
- require_relative "../tina4"
416
- Tina4.initialize!(Dir.pwd)
417
-
418
- # Load test files
419
- test_dirs = %w[tests test spec src/tests]
420
- test_dirs.each do |dir|
421
- test_dir = File.join(Dir.pwd, dir)
422
- next unless Dir.exist?(test_dir)
423
- Dir.glob(File.join(test_dir, "**/*_test.rb")).sort.each { |f| load f }
424
- Dir.glob(File.join(test_dir, "**/test_*.rb")).sort.each { |f| load f }
425
- end
426
-
427
- # Also load inline tests from routes
428
- load_routes(Dir.pwd)
429
-
430
- results = Tina4::Testing.run_all
431
- exit(1) if results[:failed] > 0 || results[:errors] > 0
432
- end
433
-
434
- # ── version ───────────────────────────────────────────────────────────
435
-
436
- def cmd_version
437
- require_relative "version"
438
- puts "Tina4 Ruby v#{Tina4::VERSION}"
439
- end
440
-
441
- # ── routes ────────────────────────────────────────────────────────────
442
-
443
- def cmd_routes
444
- require_relative "../tina4"
445
- Tina4.initialize!(Dir.pwd)
446
- load_routes(Dir.pwd)
447
-
448
- puts "\nRegistered Routes:"
449
- puts "-" * 60
450
- Tina4::Router.routes.each do |route|
451
- auth = route.auth_handler ? " [AUTH]" : ""
452
- puts " #{route.method.ljust(8)} #{route.path}#{auth}"
453
- end
454
- puts "-" * 60
455
- puts "Total: #{Tina4::Router.routes.length} routes\n"
456
- end
457
-
458
- # ── console ───────────────────────────────────────────────────────────
459
-
460
- def cmd_console
461
- require_relative "../tina4"
462
- Tina4.initialize!(Dir.pwd)
463
- load_routes(Dir.pwd)
464
-
465
- require "irb"
466
- IRB.start
467
- end
468
-
469
- # ── ai ────────────────────────────────────────────────────────────────
470
-
471
- def cmd_ai(argv)
472
- options = { all: false }
473
- parser = OptionParser.new do |opts|
474
- opts.banner = "Usage: tina4ruby ai [options]"
475
- opts.on("--all", "Install context for ALL AI tools (non-interactive)") { options[:all] = true }
476
- end
477
- parser.parse!(argv)
478
-
479
- require_relative "ai"
480
-
481
- root_dir = Dir.pwd
482
-
483
- if options[:all]
484
- Tina4::AI.install_all(root_dir)
485
- else
486
- selection = Tina4::AI.show_menu(root_dir)
487
- Tina4::AI.install_selected(root_dir, selection) unless selection.empty?
488
- end
489
- end
490
-
491
- # ── generate ────────────────────────────────────────────────────────
492
-
493
- def cmd_generate(argv)
494
- what = argv.shift
495
-
496
- unless what
497
- puts "Usage: tina4ruby generate <what> <name> [options]"
498
- puts " Generators: model, route, crud, migration, middleware, test, form, view, auth"
499
- puts ' Options: --fields "name:string,price:float" --model ModelName'
500
- exit 1
501
- end
502
-
503
- # Auth doesn't require a name argument
504
- no_name_generators = %w[auth]
505
- unless no_name_generators.include?(what)
506
- if argv.empty? || argv.first.start_with?("--")
507
- puts "Usage: tina4ruby generate #{what} <name> [options]"
508
- exit 1
509
- end
510
- end
511
-
512
- name = no_name_generators.include?(what) ? "" : argv.shift
513
- flags, _positional = parse_flags(argv)
514
-
515
- case what
516
- when "model" then generate_model(name, flags)
517
- when "route" then generate_route(name, flags)
518
- when "crud" then generate_crud(name, flags)
519
- when "migration" then generate_migration(name, flags)
520
- when "middleware" then generate_middleware(name, flags)
521
- when "test" then generate_test(name, flags)
522
- when "form" then generate_form(name, flags)
523
- when "view" then generate_view(name, flags)
524
- when "auth" then generate_auth(name, flags)
525
- else
526
- puts "Unknown generator: #{what}"
527
- puts " Available: model, route, crud, migration, middleware, test, form, view, auth"
528
- exit 1
529
- end
530
- end
531
-
532
- # ── Generator: model ─────────────────────────────────────────────────
533
-
534
- def generate_model(name, flags)
535
- fields = parse_fields(flags["fields"])
536
- table = to_table_name(name)
537
- snake = to_snake_case(name)
538
-
539
- # Build field lines
540
- field_lines = [" integer_field :id, primary_key: true, auto_increment: true"]
541
- if fields.any?
542
- fields.each do |fname, ftype|
543
- info = FIELD_TYPE_MAP[ftype] || FIELD_TYPE_MAP["string"]
544
- field_lines << " #{info[:orm]} :#{fname}"
545
- end
546
- else
547
- field_lines << " string_field :name"
548
- end
549
- field_lines << " string_field :created_at"
550
-
551
- # Write model file
552
- dir = "src/orm"
553
- FileUtils.mkdir_p(dir)
554
- path = File.join(dir, "#{snake}.rb")
555
- if File.exist?(path)
556
- puts " File already exists: #{path}"
557
- return
558
- end
559
-
560
- content = <<~RUBY
561
- class #{name} < Tina4::ORM
562
- table_name "#{table}"
563
-
564
- #{field_lines.join("\n")}
565
- end
566
- RUBY
567
-
568
- File.write(path, content)
569
- puts " Created #{path}"
570
-
571
- # Generate matching migration (unless --no-migration)
572
- unless flags["no-migration"]
573
- generate_migration("create_#{table}", flags, fields_override: fields, table_override: table)
574
- end
575
- end
576
-
577
- # ── Generator: route ─────────────────────────────────────────────────
578
-
579
- def generate_route(name, flags)
580
- route_path = name.sub(%r{^/}, "")
581
- singular = route_path.end_with?("s") ? route_path[0..-2] : route_path
582
- model = flags["model"]
583
-
584
- dir = "src/routes"
585
- FileUtils.mkdir_p(dir)
586
- path = File.join(dir, "#{route_path}.rb")
587
- if File.exist?(path)
588
- puts " File already exists: #{path}"
589
- return
590
- end
591
-
592
- if model
593
- model_snake = to_snake_case(model)
594
- content = <<~RUBY
595
- require_relative "../orm/#{model_snake}"
596
-
597
- Tina4.get "/api/#{route_path}" do |request, response|
598
- # List all #{route_path} with pagination
599
- page = (request.params["page"] || 1).to_i
600
- per_page = (request.params["per_page"] || 20).to_i
601
- offset = (page - 1) * per_page
602
- results = #{model}.all(limit: per_page, offset: offset)
603
- response.json({ data: results.map(&:to_h), page: page, per_page: per_page })
604
- end
605
-
606
- Tina4.get "/api/#{route_path}/{id:int}" do |request, response|
607
- # Get a single #{singular} by ID
608
- item = #{model}.find(request.params["id"])
609
- if item.nil?
610
- response.json({ error: "Not found" }, 404)
611
- else
612
- response.json(item.to_h)
613
- end
614
- end
615
-
616
- Tina4.post "/api/#{route_path}" do |request, response|
617
- # Create a new #{singular}
618
- item = #{model}.create(request.body)
619
- response.json(item.to_h, 201)
620
- end
621
-
622
- Tina4.put "/api/#{route_path}/{id:int}" do |request, response|
623
- # Update a #{singular} by ID
624
- item = #{model}.find(request.params["id"])
625
- if item.nil?
626
- response.json({ error: "Not found" }, 404)
627
- else
628
- request.body.each do |key, value|
629
- next if key.to_s == "id"
630
- setter = "#{'#'}{key}="
631
- item.send(setter, value) if item.respond_to?(setter)
632
- end
633
- item.save
634
- response.json(item.to_h)
635
- end
636
- end
637
-
638
- Tina4.delete "/api/#{route_path}/{id:int}" do |request, response|
639
- # Delete a #{singular} by ID
640
- item = #{model}.find(request.params["id"])
641
- if item.nil?
642
- response.json({ error: "Not found" }, 404)
643
- else
644
- item.delete
645
- response.json(nil, 204)
646
- end
647
- end
648
- RUBY
649
- else
650
- content = <<~RUBY
651
- Tina4.get "/api/#{route_path}" do |request, response|
652
- # List all #{route_path}
653
- response.json({ data: [] })
654
- end
655
-
656
- Tina4.get "/api/#{route_path}/{id:int}" do |request, response|
657
- # Get a single #{singular}
658
- response.json({ data: {} })
659
- end
660
-
661
- Tina4.post "/api/#{route_path}" do |request, response|
662
- # Create a new #{singular}
663
- response.json({ data: request.body }, 201)
664
- end
665
-
666
- Tina4.put "/api/#{route_path}/{id:int}" do |request, response|
667
- # Update a #{singular}
668
- response.json({ data: request.body })
669
- end
670
-
671
- Tina4.delete "/api/#{route_path}/{id:int}" do |request, response|
672
- # Delete a #{singular}
673
- response.json(nil, 204)
674
- end
675
- RUBY
676
- end
677
-
678
- File.write(path, content)
679
- puts " Created #{path}"
680
- end
681
-
682
- # ── Generator: crud ──────────────────────────────────────────────────
683
-
684
- def generate_crud(name, flags)
685
- table = to_table_name(name)
686
- route_name = "#{table}s"
687
-
688
- puts "\n Generating CRUD for #{name}...\n"
689
-
690
- # 1. Model + migration
691
- generate_model(name, flags)
692
-
693
- # 2. Routes with model
694
- generate_route(route_name, { "model" => name })
695
-
696
- # 3. Form
697
- generate_form(name, flags)
698
-
699
- # 4. View (list + detail)
700
- generate_view(name, flags)
701
-
702
- # 5. Test
703
- generate_test(route_name, { "model" => name })
704
-
705
- puts "\n CRUD generation complete for #{name}."
706
- puts " Run: tina4ruby migrate"
707
- puts " Visit: /swagger to see the API docs"
708
- end
709
-
710
- # ── Generator: migration ─────────────────────────────────────────────
711
-
712
- def generate_migration(name, flags = {}, fields_override: nil, table_override: nil)
713
- now = Time.now
714
- timestamp = now.strftime("%Y%m%d%H%M%S")
715
- dir = "migrations"
716
- FileUtils.mkdir_p(dir)
717
-
718
- # Determine table name
719
- if table_override
720
- table = table_override
721
- else
722
- table = name.sub(/^create_/, "").sub(/^add_/, "").sub(/^drop_/, "")
723
- table = to_snake_case(table)
724
- end
725
-
726
- # Build SQL columns from fields
727
- fields = fields_override || parse_fields(flags["fields"])
728
- is_create = name.start_with?("create_") || !fields_override.nil?
729
-
730
- filename = "#{timestamp}_#{name}.sql"
731
- path = File.join(dir, filename)
732
-
733
- if is_create
734
- col_lines = [" id INTEGER PRIMARY KEY AUTOINCREMENT"]
735
- fields.each do |fname, ftype|
736
- info = FIELD_TYPE_MAP[ftype] || FIELD_TYPE_MAP["string"]
737
- default = info[:default] != "NULL" ? " DEFAULT #{info[:default]}" : ""
738
- col_lines << " #{fname} #{info[:sql]}#{default}"
739
- end
740
- col_lines << " created_at TEXT DEFAULT CURRENT_TIMESTAMP"
741
-
742
- up_sql = "CREATE TABLE IF NOT EXISTS #{table} (\n#{col_lines.join(",\n")}\n);"
743
- down_sql = "DROP TABLE IF EXISTS #{table};"
744
- else
745
- up_sql = "-- Write your UP migration SQL here\n-- Example: ALTER TABLE #{table} ADD COLUMN new_col TEXT DEFAULT '';"
746
- down_sql = "-- Write your DOWN rollback SQL here\n-- Example: ALTER TABLE #{table} DROP COLUMN new_col;"
747
- end
748
-
749
- content = <<~SQL
750
- -- Migration: #{name}
751
- -- Created: #{now.strftime("%Y-%m-%d %H:%M:%S")}
752
-
753
- -- UP
754
- #{up_sql}
755
-
756
- -- DOWN
757
- #{down_sql}
758
- SQL
759
-
760
- File.write(path, content)
761
- puts " Created #{path}"
762
-
763
- # Also create .down.sql for the migration runner
764
- down_path = File.join(dir, "#{timestamp}_#{name}.down.sql")
765
- down_content = <<~SQL
766
- -- Rollback: #{name}
767
- -- Created: #{now.strftime("%Y-%m-%d %H:%M:%S")}
768
-
769
- #{down_sql}
770
- SQL
771
-
772
- File.write(down_path, down_content)
773
- puts " Created #{down_path}"
774
- end
775
-
776
- # ── Generator: middleware ────────────────────────────────────────────
777
-
778
- def generate_middleware(name, flags = {})
779
- snake = to_snake_case(name)
780
- dir = "src/middleware"
781
- FileUtils.mkdir_p(dir)
782
- path = File.join(dir, "#{snake}.rb")
783
- if File.exist?(path)
784
- puts " File already exists: #{path}"
785
- return
786
- end
787
-
788
- content = <<~RUBY
789
- # #{name} middleware
790
- #
791
- # Usage in routes:
792
- # require_relative "../middleware/#{snake}"
793
- # Tina4.get "/api/protected", middleware: [#{name}] do |request, response|
794
- # response.json({ data: "protected" })
795
- # end
796
-
797
- class #{name}
798
- def self.before_#{snake}(request, response)
799
- # Runs before the route handler.
800
- # Return [request, response] to continue, or
801
- # return [request, response.json({ error: "Unauthorized" }, 401)] to block.
802
- Tina4::Log.info("#{name}: \#{request.request_method} \#{request.path}")
803
- [request, response]
804
- end
805
-
806
- def self.after_#{snake}(request, response)
807
- # Runs after the route handler.
808
- [request, response]
809
- end
810
- end
811
- RUBY
812
-
813
- File.write(path, content)
814
- puts " Created #{path}"
815
- end
816
-
817
- # ── Generator: test ──────────────────────────────────────────────────
818
-
819
- def generate_test(name, flags = {})
820
- model = flags["model"]
821
- snake = to_snake_case(name)
822
- singular = snake.end_with?("s") ? snake[0..-2] : snake
823
-
824
- dir = "spec"
825
- FileUtils.mkdir_p(dir)
826
- path = File.join(dir, "#{snake}_spec.rb")
827
- if File.exist?(path)
828
- puts " File already exists: #{path}"
829
- return
830
- end
831
-
832
- if model
833
- content = <<~RUBY
834
- # Tests for #{name} CRUD operations
835
- RSpec.describe "#{model}" do
836
- before(:each) do
837
- # Set up test fixtures
838
- end
839
-
840
- after(:each) do
841
- # Clean up after tests
842
- end
843
-
844
- it "lists #{snake}" do
845
- # TODO: implement
846
- expect(true).to be true
847
- end
848
-
849
- it "gets a single #{singular}" do
850
- # TODO: implement
851
- expect(true).to be true
852
- end
853
-
854
- it "creates a #{singular}" do
855
- # TODO: implement
856
- expect(true).to be true
857
- end
858
-
859
- it "updates a #{singular}" do
860
- # TODO: implement
861
- expect(true).to be true
862
- end
863
-
864
- it "deletes a #{singular}" do
865
- # TODO: implement
866
- expect(true).to be true
867
- end
868
- end
869
- RUBY
870
- else
871
- class_name = name.split("_").map(&:capitalize).join
872
- content = <<~RUBY
873
- # Tests for #{name}
874
- RSpec.describe "#{class_name}" do
875
- before(:each) do
876
- # Set up test fixtures
877
- end
878
-
879
- after(:each) do
880
- # Clean up after tests
881
- end
882
-
883
- it "works as expected" do
884
- # TODO: replace with real tests
885
- expect(true).to be true
886
- end
887
- end
888
- RUBY
889
- end
890
-
891
- File.write(path, content)
892
- puts " Created #{path}"
893
- end
894
-
895
- # ── Generator: form ──────────────────────────────────────────────────
896
-
897
- def generate_form(name, flags = {})
898
- fields = parse_fields(flags["fields"])
899
- table = to_table_name(name)
900
- route_name = "#{table}s"
901
-
902
- # Input type mapping
903
- input_types = {
904
- "string" => "text", "str" => "text", "text" => "textarea",
905
- "int" => "number", "integer" => "number",
906
- "float" => "number", "numeric" => "number", "decimal" => "number",
907
- "bool" => "checkbox", "boolean" => "checkbox",
908
- "datetime" => "datetime-local", "blob" => "file",
909
- }
910
-
911
- dir = "src/templates/forms"
912
- FileUtils.mkdir_p(dir)
913
- path = File.join(dir, "#{table}.twig")
914
- if File.exist?(path)
915
- puts " File already exists: #{path}"
916
- return
917
- end
918
-
919
- # Build form fields
920
- field_html = ""
921
- form_fields = fields.any? ? fields : [["name", "string"]]
922
- form_fields.each do |fname, ftype|
923
- itype = input_types[ftype] || "text"
924
- label = fname.tr("_", " ").split.map(&:capitalize).join(" ")
925
- step = %w[float numeric decimal].include?(ftype) ? ' step="0.01"' : ""
926
-
927
- if itype == "textarea"
928
- field_html += <<~HTML
929
- <div class="form-group mb-3">
930
- <label for="#{fname}">#{label}</label>
931
- <textarea id="#{fname}" name="#{fname}" class="form-control" rows="4" placeholder="#{label}">{{ item.#{fname} }}</textarea>
932
- </div>
933
- HTML
934
- elsif itype == "checkbox"
935
- field_html += <<~HTML
936
- <div class="form-group mb-3">
937
- <label>
938
- <input type="checkbox" id="#{fname}" name="#{fname}" value="1" {% if item.#{fname} %}checked{% endif %}>
939
- #{label}
940
- </label>
941
- </div>
942
- HTML
943
- else
944
- field_html += <<~HTML
945
- <div class="form-group mb-3">
946
- <label for="#{fname}">#{label}</label>
947
- <input type="#{itype}" id="#{fname}" name="#{fname}" class="form-control"#{step} value="{{ item.#{fname} }}" placeholder="#{label}">
948
- </div>
949
- HTML
950
- end
951
- end
952
-
953
- content = <<~HTML
954
- {% extends "base.twig" %}
955
- {% block title %}#{name} {% if item.id %}Edit{% else %}Create{% endif %}{% endblock %}
956
- {% block content %}
957
- <div class="container mt-4">
958
- <h1>{% if item.id %}Edit #{name}{% else %}Create #{name}{% endif %}</h1>
959
- <form method="post" action="/api/#{route_name}{% if item.id %}/{{ item.id }}{% endif %}">
960
- {{ form_token() }}
961
- #{field_html} <button type="submit" class="btn btn-primary">
962
- {% if item.id %}Update{% else %}Create{% endif %}
963
- </button>
964
- <a href="/api/#{route_name}" class="btn btn-secondary">Cancel</a>
965
- </form>
966
- </div>
967
- {% endblock %}
968
- HTML
969
-
970
- File.write(path, content)
971
- puts " Created #{path}"
972
- end
973
-
974
- # ── Generator: view ──────────────────────────────────────────────────
975
-
976
- def generate_view(name, flags = {})
977
- fields = parse_fields(flags["fields"])
978
- table = to_table_name(name)
979
- route_name = "#{table}s"
980
-
981
- cols = fields.any? ? fields.map { |f, _| f } : ["name"]
982
-
983
- dir = "src/templates/pages"
984
- FileUtils.mkdir_p(dir)
985
-
986
- # List view
987
- list_path = File.join(dir, "#{route_name}.twig")
988
- unless File.exist?(list_path)
989
- th = cols.map { |c| "<th>#{c.tr('_', ' ').split.map(&:capitalize).join(' ')}</th>" }.join("\n ")
990
- td = cols.map { |c| "<td>{{ item.#{c} }}</td>" }.join("\n ")
991
-
992
- list_content = <<~HTML
993
- {% extends "base.twig" %}
994
- {% block title %}#{name}s{% endblock %}
995
- {% block content %}
996
- <div class="container mt-4">
997
- <div class="d-flex justify-content-between align-items-center mb-3">
998
- <h1>#{name}s</h1>
999
- <a href="/#{route_name}/create" class="btn btn-primary">Add #{name}</a>
1000
- </div>
1001
- <table class="table">
1002
- <thead>
1003
- <tr>
1004
- <th>ID</th>
1005
- #{th}
1006
- <th>Actions</th>
1007
- </tr>
1008
- </thead>
1009
- <tbody>
1010
- {% for item in items %}
1011
- <tr>
1012
- <td>{{ item.id }}</td>
1013
- #{td}
1014
- <td>
1015
- <a href="/#{route_name}/{{ item.id }}" class="btn btn-sm btn-primary">View</a>
1016
- <a href="/#{route_name}/{{ item.id }}/edit" class="btn btn-sm btn-secondary">Edit</a>
1017
- </td>
1018
- </tr>
1019
- {% endfor %}
1020
- </tbody>
1021
- </table>
1022
- </div>
1023
- {% endblock %}
1024
- HTML
1025
-
1026
- File.write(list_path, list_content)
1027
- puts " Created #{list_path}"
1028
- end
1029
-
1030
- # Detail view
1031
- detail_path = File.join(dir, "#{table}.twig")
1032
- unless File.exist?(detail_path)
1033
- detail_fields = cols.map do |c|
1034
- " <div class=\"mb-3\"><strong>#{c.tr('_', ' ').split.map(&:capitalize).join(' ')}:</strong> {{ item.#{c} }}</div>"
1035
- end.join("\n")
1036
-
1037
- detail_content = <<~HTML
1038
- {% extends "base.twig" %}
1039
- {% block title %}#{name} Detail{% endblock %}
1040
- {% block content %}
1041
- <div class="container mt-4">
1042
- <div class="d-flex justify-content-between align-items-center mb-3">
1043
- <h1>#{name} \#{{ item.id }}</h1>
1044
- <div>
1045
- <a href="/#{route_name}/{{ item.id }}/edit" class="btn btn-secondary">Edit</a>
1046
- <a href="/#{route_name}" class="btn btn-outline-secondary">Back</a>
1047
- </div>
1048
- </div>
1049
- #{detail_fields}
1050
- </div>
1051
- {% endblock %}
1052
- HTML
1053
-
1054
- File.write(detail_path, detail_content)
1055
- puts " Created #{detail_path}"
1056
- end
1057
- end
1058
-
1059
- # ── Generator: auth ──────────────────────────────────────────────────
1060
-
1061
- def generate_auth(_name = nil, flags = {})
1062
- puts "\n Generating authentication scaffolding...\n"
1063
-
1064
- # 1. User model + migration
1065
- generate_model("User", { "fields" => "email:string,password:string,role:string" })
1066
-
1067
- # 2. Auth routes
1068
- dir = "src/routes"
1069
- FileUtils.mkdir_p(dir)
1070
- auth_path = File.join(dir, "auth.rb")
1071
- unless File.exist?(auth_path)
1072
- content = <<~'RUBY'
1073
- require_relative "../orm/user"
1074
-
1075
- Tina4.post "/api/auth/register" do |request, response|
1076
- # Register a new user
1077
- email = request.body["email"].to_s
1078
- password = request.body["password"].to_s
1079
-
1080
- if email.empty? || password.empty?
1081
- next response.json({ error: "Email and password required" }, 400)
1082
- end
1083
-
1084
- # Check if user exists
1085
- existing = User.where("email = ?", [email])
1086
- unless existing.empty?
1087
- next response.json({ error: "Email already registered" }, 409)
1088
- end
1089
-
1090
- # Create user with hashed password
1091
- user = User.create({
1092
- email: email,
1093
- password: Tina4::Auth.hash_password(password),
1094
- role: "user",
1095
- })
1096
- response.json({ message: "Registered", id: user.id }, 201)
1097
- end
1098
-
1099
- Tina4.post "/api/auth/login" do |request, response|
1100
- # Login with email and password
1101
- email = request.body["email"].to_s
1102
- password = request.body["password"].to_s
1103
-
1104
- users = User.where("email = ?", [email])
1105
- if users.empty?
1106
- next response.json({ error: "Invalid credentials" }, 401)
1107
- end
1108
- user = users.first
1109
-
1110
- unless Tina4::Auth.check_password(password, user.password)
1111
- next response.json({ error: "Invalid credentials" }, 401)
1112
- end
1113
-
1114
- token = Tina4::Auth.get_token({ user_id: user.id, email: user.email, role: user.role })
1115
- response.json({ token: token })
1116
- end
1117
-
1118
- Tina4.get "/api/auth/me" do |request, response|
1119
- # Get current authenticated user
1120
- payload = Tina4::Auth.authenticate_request(request.headers)
1121
- if payload.nil?
1122
- next response.json({ error: "Unauthorized" }, 401)
1123
- end
1124
-
1125
- user = User.find(payload["user_id"])
1126
- if user.nil?
1127
- next response.json({ error: "User not found" }, 404)
1128
- end
1129
-
1130
- response.json({ id: user.id, email: user.email, role: user.role })
1131
- end
1132
- RUBY
1133
-
1134
- File.write(auth_path, content)
1135
- puts " Created #{auth_path}"
1136
- end
1137
-
1138
- # 3. Login template
1139
- forms_dir = "src/templates/forms"
1140
- FileUtils.mkdir_p(forms_dir)
1141
- login_path = File.join(forms_dir, "login.twig")
1142
- unless File.exist?(login_path)
1143
- File.write(login_path, <<~HTML)
1144
- {% extends "base.twig" %}
1145
- {% block title %}Login{% endblock %}
1146
- {% block content %}
1147
- <div class="container mt-4" style="max-width:400px">
1148
- <h1>Login</h1>
1149
- <form method="post" action="/api/auth/login">
1150
- {{ form_token() }}
1151
- <div class="form-group mb-3">
1152
- <label for="email">Email</label>
1153
- <input type="email" id="email" name="email" class="form-control" placeholder="Email" required>
1154
- </div>
1155
- <div class="form-group mb-3">
1156
- <label for="password">Password</label>
1157
- <input type="password" id="password" name="password" class="form-control" placeholder="Password" required>
1158
- </div>
1159
- <button type="submit" class="btn btn-primary w-100">Login</button>
1160
- <p class="mt-3 text-center"><a href="/register">Create an account</a></p>
1161
- </form>
1162
- </div>
1163
- {% endblock %}
1164
- HTML
1165
- puts " Created #{login_path}"
1166
- end
1167
-
1168
- # 4. Register template
1169
- register_path = File.join(forms_dir, "register.twig")
1170
- unless File.exist?(register_path)
1171
- File.write(register_path, <<~HTML)
1172
- {% extends "base.twig" %}
1173
- {% block title %}Register{% endblock %}
1174
- {% block content %}
1175
- <div class="container mt-4" style="max-width:400px">
1176
- <h1>Register</h1>
1177
- <form method="post" action="/api/auth/register">
1178
- {{ form_token() }}
1179
- <div class="form-group mb-3">
1180
- <label for="email">Email</label>
1181
- <input type="email" id="email" name="email" class="form-control" placeholder="Email" required>
1182
- </div>
1183
- <div class="form-group mb-3">
1184
- <label for="password">Password</label>
1185
- <input type="password" id="password" name="password" class="form-control" placeholder="Password" minlength="8" required>
1186
- </div>
1187
- <button type="submit" class="btn btn-primary w-100">Register</button>
1188
- <p class="mt-3 text-center"><a href="/login">Already have an account?</a></p>
1189
- </form>
1190
- </div>
1191
- {% endblock %}
1192
- HTML
1193
- puts " Created #{register_path}"
1194
- end
1195
-
1196
- # 5. Auth test
1197
- generate_test("auth", { "model" => "User" })
1198
-
1199
- puts "\n Authentication scaffolding complete."
1200
- puts " Run: tina4ruby migrate"
1201
- puts " POST /api/auth/register - create account"
1202
- puts " POST /api/auth/login - get JWT token"
1203
- puts " GET /api/auth/me - get profile (requires token)"
1204
- end
1205
-
1206
- # ── help ──────────────────────────────────────────────────────────────
1207
-
1208
- def cmd_help
1209
- puts <<~HELP
1210
- Tina4 Ruby CLI
1211
-
1212
- Usage: tina4ruby COMMAND [options]
1213
-
1214
- Commands:
1215
- init [NAME] Initialize a new Tina4 project
1216
- start Start the Tina4 web server
1217
- serve Alias for start
1218
- migrate Run database migrations
1219
- migrate:status Show migration status (completed and pending)
1220
- migrate:rollback Rollback the last batch of migrations
1221
- seed Run all seed files in seeds/
1222
- seed:create NAME Create a new seed file
1223
- test Run inline tests
1224
- version Show Tina4 version
1225
- routes List all registered routes
1226
- console Start an interactive console
1227
- ai Detect AI tools and install context files
1228
- help Show this help message
1229
-
1230
- Generators:
1231
- generate model <Name> [--fields "name:string,price:float"]
1232
- generate route <name> [--model Name]
1233
- generate crud <Name> [--fields "..."] Model + migration + routes + form + view + test
1234
- generate migration <description>
1235
- generate middleware <Name>
1236
- generate test <name>
1237
- generate form <Name> [--fields "..."] Form template with inputs matching model fields
1238
- generate view <Name> [--fields "..."] List + detail templates for viewing records
1239
- generate auth Login/register/logout routes + User model + templates
1240
-
1241
- Field types: string, int, float, bool, text, datetime, blob
1242
- Table names: singular by default (Product -> product)
1243
-
1244
- https://tina4.com
1245
-
1246
- Run 'tina4ruby COMMAND --help' for more information on a command.
1247
- HELP
1248
- end
1249
-
1250
- # ── config resolution ──────────────────────────────────────────────────
1251
-
1252
- DEFAULT_PORT = 7147
1253
- DEFAULT_HOST = "0.0.0.0"
1254
-
1255
- # Priority: CLI flag > ENV var > default
1256
- def resolve_config(key, cli_value)
1257
- case key
1258
- when :port
1259
- return cli_value if cli_value
1260
- return ENV["PORT"].to_i if ENV["PORT"] && !ENV["PORT"].empty?
1261
- DEFAULT_PORT
1262
- when :host
1263
- return cli_value if cli_value
1264
- return ENV["HOST"] if ENV["HOST"] && !ENV["HOST"].empty?
1265
- DEFAULT_HOST
1266
- end
1267
- end
1268
-
1269
- # ── shared helpers ────────────────────────────────────────────────────
1270
-
1271
- def load_routes(root_dir)
1272
- route_dirs = %w[src/routes routes src/api api src/orm orm]
1273
- route_dirs.each do |dir|
1274
- route_dir = File.join(root_dir, dir)
1275
- next unless Dir.exist?(route_dir)
1276
- Dir.glob(File.join(route_dir, "**/*.rb")).sort.each { |f| load f }
1277
- end
1278
-
1279
- # Also load app.rb if it exists
1280
- app_file = File.join(root_dir, "app.rb")
1281
- load app_file if File.exist?(app_file)
1282
-
1283
- index_file = File.join(root_dir, "index.rb")
1284
- load index_file if File.exist?(index_file)
1285
- end
1286
-
1287
- def create_project_structure(dir)
1288
- %w[
1289
- src/routes src/orm src/middleware src/templates src/templates/errors
1290
- src/templates/forms src/templates/pages
1291
- src/public src/public/css src/public/js src/public/images
1292
- migrations logs spec seeds
1293
- ].each do |subdir|
1294
- FileUtils.mkdir_p(File.join(dir, subdir))
1295
- end
1296
-
1297
- # Copy framework public assets into the project so they're visible
1298
- framework_public = File.join(File.dirname(__FILE__), "public")
1299
- project_public = File.join(dir, "src", "public")
1300
- assets_to_copy = %w[
1301
- css/tina4.css
1302
- css/tina4.min.css
1303
- js/tina4.min.js
1304
- js/frond.min.js
1305
- images/tina4-logo-icon.webp
1306
- ]
1307
- assets_to_copy.each do |asset|
1308
- src = File.join(framework_public, asset)
1309
- dst = File.join(project_public, asset)
1310
- FileUtils.mkdir_p(File.dirname(dst))
1311
- if File.exist?(src) && !File.exist?(dst)
1312
- FileUtils.cp(src, dst)
1313
- puts " Copied #{asset}"
1314
- end
1315
- end
1316
- end
1317
-
1318
- def create_sample_files(dir, project_name)
1319
- # app.rb
1320
- unless File.exist?(File.join(dir, "app.rb"))
1321
- File.write(File.join(dir, "app.rb"), <<~RUBY)
1322
- require "tina4"
1323
- Tina4.initialize!(__dir__)
1324
- app = Tina4::RackApp.new
1325
- Tina4::WebServer.new(app, port: 7147).start
1326
- RUBY
1327
- end
1328
-
1329
- # Gemfile
1330
- unless File.exist?(File.join(dir, "Gemfile"))
1331
- File.write(File.join(dir, "Gemfile"), <<~RUBY)
1332
- source "https://rubygems.org"
1333
- gem "tina4-ruby", "~> 3.0"
1334
- RUBY
1335
- end
1336
-
1337
- # .env
1338
- unless File.exist?(File.join(dir, ".env"))
1339
- File.write(File.join(dir, ".env"), <<~TEXT)
1340
- TINA4_DEBUG=true
1341
- TINA4_LOG_LEVEL=ALL
1342
- TEXT
1343
- end
1344
-
1345
- # .gitignore
1346
- unless File.exist?(File.join(dir, ".gitignore"))
1347
- File.write(File.join(dir, ".gitignore"), <<~TEXT)
1348
- .env
1349
- .keys/
1350
- logs/
1351
- sessions/
1352
- .queue/
1353
- *.db
1354
- vendor/
1355
- TEXT
1356
- end
1357
-
1358
- # Dockerfile
1359
- unless File.exist?(File.join(dir, "Dockerfile"))
1360
- File.write(File.join(dir, "Dockerfile"), <<~DOCKERFILE)
1361
- # === Build Stage ===
1362
- FROM ruby:3.3-alpine AS builder
1363
-
1364
- # Install build dependencies
1365
- RUN apk add --no-cache \\
1366
- build-base \\
1367
- libffi-dev \\
1368
- gcompat
1369
-
1370
- WORKDIR /app
1371
-
1372
- # Copy dependency definition first (layer caching)
1373
- COPY Gemfile Gemfile.lock* ./
1374
-
1375
- # Install gems
1376
- RUN bundle config set --local without 'development test' && \\
1377
- bundle install --jobs 4 --retry 3
1378
-
1379
- # Copy application code
1380
- COPY . .
1381
-
1382
- # === Runtime Stage ===
1383
- FROM ruby:3.3-alpine
1384
-
1385
- # Runtime packages only
1386
- RUN apk add --no-cache libffi gcompat
1387
-
1388
- WORKDIR /app
1389
-
1390
- # Copy installed gems
1391
- COPY --from=builder /usr/local/bundle /usr/local/bundle
1392
-
1393
- # Copy application code
1394
- COPY --from=builder /app /app
1395
-
1396
- EXPOSE 7147
1397
-
1398
- # Swagger defaults (override with env vars in docker-compose/k8s if needed)
1399
- ENV SWAGGER_TITLE="Tina4 API"
1400
- ENV SWAGGER_VERSION="0.1.0"
1401
- ENV SWAGGER_DESCRIPTION="Auto-generated API documentation"
1402
-
1403
- # Start the server on all interfaces
1404
- CMD ["bundle", "exec", "tina4ruby", "start", "-p", "7147", "-h", "0.0.0.0"]
1405
- DOCKERFILE
1406
- end
1407
-
1408
- # .dockerignore
1409
- unless File.exist?(File.join(dir, ".dockerignore"))
1410
- File.write(File.join(dir, ".dockerignore"), <<~TEXT)
1411
- .git
1412
- .env
1413
- .keys/
1414
- logs/
1415
- sessions/
1416
- .queue/
1417
- *.db
1418
- *.gem
1419
- tmp/
1420
- spec/
1421
- vendor/bundle
1422
- TEXT
1423
- end
1424
-
1425
- # Base template
1426
- templates_dir = File.join(dir, "src", "templates")
1427
- unless File.exist?(File.join(templates_dir, "base.twig"))
1428
- File.write(File.join(templates_dir, "base.twig"), <<~HTML)
1429
- <!DOCTYPE html>
1430
- <html lang="en">
1431
- <head>
1432
- <meta charset="UTF-8">
1433
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
1434
- <title>{% block title %}#{project_name}{% endblock %}</title>
1435
- <link rel="stylesheet" href="/css/tina4.min.css">
1436
- {% block head %}{% endblock %}
1437
- </head>
1438
- <body>
1439
- {% block content %}{% endblock %}
1440
- <script src="/js/tina4.min.js"></script>
1441
- <script src="/js/frond.min.js"></script>
1442
- {% block scripts %}{% endblock %}
1443
- </body>
1444
- </html>
1445
- HTML
1446
- end
1447
- end
1448
- end
1449
- end
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+ require "fileutils"
5
+
6
+ module Tina4
7
+ class CLI
8
+ COMMANDS = %w[init start migrate migrate:status migrate:rollback seed seed:create test version routes console generate ai help].freeze
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
+
26
+ def self.start(argv)
27
+ new.run(argv)
28
+ end
29
+
30
+ def run(argv)
31
+ command = argv.shift || "help"
32
+ case command
33
+ when "init" then cmd_init(argv)
34
+ when "start", "serve" then cmd_start(argv)
35
+ when "migrate" then cmd_migrate(argv)
36
+ when "migrate:status" then cmd_migrate_status(argv)
37
+ when "migrate:rollback" then cmd_migrate_rollback(argv)
38
+ when "seed" then cmd_seed(argv)
39
+ when "seed:create" then cmd_seed_create(argv)
40
+ when "test" then cmd_test(argv)
41
+ when "version" then cmd_version
42
+ when "routes" then cmd_routes
43
+ when "console" then cmd_console
44
+ when "generate" then cmd_generate(argv)
45
+ when "ai" then cmd_ai(argv)
46
+ when "help", "-h", "--help" then cmd_help
47
+ else
48
+ puts "Unknown command: #{command}"
49
+ cmd_help
50
+ exit 1
51
+ end
52
+ end
53
+
54
+ private
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
+ # Boolean-only flags that never take a value argument
88
+ boolean_flags = %w[no-browser no-reload production managed all clear dev]
89
+
90
+ flags = {}
91
+ positional = []
92
+ i = 0
93
+ while i < args.length
94
+ if args[i].start_with?("--")
95
+ key = args[i][2..]
96
+ if boolean_flags.include?(key)
97
+ flags[key] = true
98
+ i += 1
99
+ elsif i + 1 < args.length && !args[i + 1].start_with?("--")
100
+ flags[key] = args[i + 1]
101
+ i += 2
102
+ else
103
+ flags[key] = true
104
+ i += 1
105
+ end
106
+ else
107
+ positional << args[i]
108
+ i += 1
109
+ end
110
+ end
111
+ [flags, positional]
112
+ end
113
+
114
+ # Kill any process listening on the given port. Returns true if killed.
115
+ def kill_process_on_port(port)
116
+ result = `lsof -ti :#{port} 2>/dev/null`.strip
117
+ return false if result.empty?
118
+
119
+ pids = result.split("\n")
120
+ pids.each do |pid|
121
+ Process.kill("TERM", pid.to_i)
122
+ rescue Errno::ESRCH, Errno::EPERM
123
+ # Process already gone or no permission
124
+ end
125
+ sleep 0.5
126
+ puts " Killed existing process on port #{port} (PID: #{pids.join(', ')})"
127
+ true
128
+ rescue Errno::ENOENT
129
+ false
130
+ end
131
+
132
+ # ── init ──────────────────────────────────────────────────────────────
133
+
134
+ def cmd_init(argv)
135
+ options = { template: "default" }
136
+ parser = OptionParser.new do |opts|
137
+ opts.banner = "Usage: tina4ruby init [PATH] [options]"
138
+ opts.on("--template TEMPLATE", "Project template (default: default)") { |v| options[:template] = v }
139
+ end
140
+ parser.parse!(argv)
141
+
142
+ name = argv.shift || "."
143
+ dir = File.expand_path(name)
144
+ FileUtils.mkdir_p(dir)
145
+
146
+ project_name = File.basename(dir)
147
+ create_project_structure(dir)
148
+ create_sample_files(dir, project_name)
149
+
150
+ puts "\nProject scaffolded at #{dir}"
151
+ if name == "."
152
+ puts " bundle install"
153
+ puts " ruby app.rb"
154
+ else
155
+ puts " cd #{dir}"
156
+ puts " bundle install"
157
+ puts " ruby app.rb"
158
+ end
159
+ end
160
+
161
+ # ── start ─────────────────────────────────────────────────────────────
162
+
163
+ def cmd_start(argv)
164
+ options = { port: nil, host: nil, dev: false, no_browser: false, no_reload: false, production: false }
165
+ parser = OptionParser.new do |opts|
166
+ opts.banner = "Usage: tina4ruby start [options]"
167
+ opts.on("-p", "--port PORT", Integer, "Port (default: 7147)") { |v| options[:port] = v }
168
+ opts.on("-h", "--host HOST", "Host (default: 0.0.0.0)") { |v| options[:host] = v }
169
+ opts.on("-d", "--dev", "Enable dev mode with auto-reload") { options[:dev] = true }
170
+ opts.on("--production", "Use production server (Puma)") { options[:production] = true }
171
+ opts.on("--no-browser", "Do not open browser on start") { options[:no_browser] = true }
172
+ opts.on("--no-reload", "Disable file watcher / live-reload") { options[:no_reload] = true }
173
+ end
174
+ parser.parse!(argv)
175
+
176
+ # --no-browser from env (TINA4_NO_BROWSER=true)
177
+ no_browser_env = ENV.fetch("TINA4_NO_BROWSER", "").downcase
178
+ if no_browser_env.match?(/\A(true|1|yes)\z/)
179
+ options[:no_browser] = true
180
+ end
181
+
182
+ # --no-reload flag sets TINA4_NO_RELOAD so the existing env check picks it up
183
+ if options[:no_reload]
184
+ ENV["TINA4_NO_RELOAD"] = "true"
185
+ end
186
+
187
+ # Priority: CLI flag > ENV var > default
188
+ options[:port] = resolve_config(:port, options[:port])
189
+ options[:host] = resolve_config(:host, options[:host])
190
+
191
+ # Kill existing process on port
192
+ kill_process_on_port(options[:port])
193
+
194
+ require_relative "../tina4"
195
+
196
+ root_dir = Dir.pwd
197
+ Tina4.initialize!(root_dir)
198
+
199
+ # Register health check endpoint
200
+ Tina4::Health.register!
201
+
202
+ # Load route files
203
+ load_routes(root_dir)
204
+
205
+ # File watching is handled by the Rust CLI (tina4 serve). The framework
206
+ # only needs POST /__dev/api/reload to update the mtime counter for browser polling.
207
+ # No internal file watcher.
208
+
209
+ app = Tina4::RackApp.new(root_dir: root_dir)
210
+
211
+ is_debug = Tina4::Env.is_truthy(ENV["TINA4_DEBUG"])
212
+
213
+ # Use Puma only when explicitly requested via --production flag
214
+ # WEBrick is used for development (supports dev toolbar/reload)
215
+ if options[:production]
216
+ begin
217
+ require "puma"
218
+ require "puma/configuration"
219
+ require "puma/launcher"
220
+
221
+ puma_host = options[:host]
222
+ puma_port = options[:port]
223
+
224
+ config = Puma::Configuration.new do |user_config|
225
+ user_config.bind "tcp://#{puma_host}:#{puma_port}"
226
+ user_config.app app
227
+ user_config.threads 0, 16
228
+ user_config.workers 0
229
+ user_config.environment "production"
230
+ user_config.log_requests false
231
+ user_config.quiet
232
+ end
233
+
234
+ Tina4::Log.info("Production server: puma")
235
+
236
+ # Setup graceful shutdown (Puma manages its own signals, but we handle DB cleanup)
237
+ Tina4::Shutdown.setup
238
+
239
+ launcher = Puma::Launcher.new(config)
240
+ launcher.run
241
+ return
242
+ rescue LoadError
243
+ # Puma not installed, fall through to WEBrick
244
+ end
245
+ end
246
+
247
+ Tina4::Log.info("Development server: WEBrick")
248
+ server = Tina4::WebServer.new(app, host: options[:host], port: options[:port])
249
+ server.start
250
+ end
251
+
252
+ # ── migrate ───────────────────────────────────────────────────────────
253
+
254
+ def cmd_migrate(argv)
255
+ options = {}
256
+ parser = OptionParser.new do |opts|
257
+ opts.banner = "Usage: tina4ruby migrate [options]"
258
+ opts.on("--create NAME", "Create a new migration") { |v| options[:create] = v }
259
+ opts.on("--rollback N", Integer, "Rollback N migrations") { |v| options[:rollback] = v }
260
+ end
261
+ parser.parse!(argv)
262
+
263
+ require_relative "../tina4"
264
+ Tina4.initialize!(Dir.pwd)
265
+
266
+ db = Tina4.database
267
+ unless db
268
+ puts "No database configured. Set DATABASE_URL in your .env file."
269
+ return
270
+ end
271
+
272
+ migration = Tina4::Migration.new(db)
273
+
274
+ if options[:create]
275
+ path = migration.create(options[:create])
276
+ puts "Created migration: #{path}"
277
+ elsif options[:rollback]
278
+ migration.rollback(options[:rollback])
279
+ puts "Rolled back #{options[:rollback]} migration(s)"
280
+ else
281
+ results = migration.run
282
+ if results.empty?
283
+ puts "No pending migrations"
284
+ else
285
+ results.each do |r|
286
+ status_icon = r[:status] == "success" ? "OK" : "FAIL"
287
+ puts " [#{status_icon}] #{r[:name]}"
288
+ end
289
+ end
290
+ end
291
+ end
292
+
293
+ # ── migrate:status ─────────────────────────────────────────────────────
294
+
295
+ def cmd_migrate_status(_argv)
296
+ require_relative "../tina4"
297
+ Tina4.initialize!(Dir.pwd)
298
+
299
+ db = Tina4.database
300
+ unless db
301
+ puts "No database configured. Set DATABASE_URL in your .env file."
302
+ return
303
+ end
304
+
305
+ migration = Tina4::Migration.new(db)
306
+ info = migration.status
307
+
308
+ puts "\nMigration Status"
309
+ puts "-" * 60
310
+
311
+ if info[:completed].any?
312
+ puts "\nCompleted:"
313
+ info[:completed].each { |name| puts " [OK] #{name}" }
314
+ end
315
+
316
+ if info[:pending].any?
317
+ puts "\nPending:"
318
+ info[:pending].each { |name| puts " [ ] #{name}" }
319
+ end
320
+
321
+ if info[:completed].empty? && info[:pending].empty?
322
+ puts " No migrations found."
323
+ end
324
+
325
+ puts "-" * 60
326
+ puts " Completed: #{info[:completed].length} Pending: #{info[:pending].length}\n"
327
+ end
328
+
329
+ # ── migrate:rollback ───────────────────────────────────────────────────
330
+
331
+ def cmd_migrate_rollback(argv)
332
+ options = { steps: 1 }
333
+ parser = OptionParser.new do |opts|
334
+ opts.banner = "Usage: tina4ruby migrate:rollback [options]"
335
+ opts.on("-n", "--steps N", Integer, "Number of batches to rollback (default: 1)") { |v| options[:steps] = v }
336
+ end
337
+ parser.parse!(argv)
338
+
339
+ require_relative "../tina4"
340
+ Tina4.initialize!(Dir.pwd)
341
+
342
+ db = Tina4.database
343
+ unless db
344
+ puts "No database configured. Set DATABASE_URL in your .env file."
345
+ return
346
+ end
347
+
348
+ migration = Tina4::Migration.new(db)
349
+ results = migration.rollback(options[:steps])
350
+
351
+ if results.empty?
352
+ puts "Nothing to rollback."
353
+ else
354
+ results.each do |r|
355
+ status_icon = r[:status] == "rolled_back" ? "OK" : "FAIL"
356
+ puts " [#{status_icon}] #{r[:name]}"
357
+ end
358
+ puts "Rolled back #{results.length} migration(s)."
359
+ end
360
+ end
361
+
362
+ # ── seed ──────────────────────────────────────────────────────────────
363
+
364
+ def cmd_seed(argv)
365
+ options = { clear: false }
366
+ parser = OptionParser.new do |opts|
367
+ opts.banner = "Usage: tina4ruby seed [options]"
368
+ opts.on("--clear", "Clear tables before seeding") { options[:clear] = true }
369
+ end
370
+ parser.parse!(argv)
371
+
372
+ require_relative "../tina4"
373
+ Tina4.initialize!(Dir.pwd)
374
+ load_routes(Dir.pwd)
375
+ Tina4.seed_dir(seed_folder: "seeds", clear: options[:clear])
376
+ end
377
+
378
+ # ── seed:create ───────────────────────────────────────────────────────
379
+
380
+ def cmd_seed_create(argv)
381
+ name = argv.shift
382
+ unless name
383
+ puts "Usage: tina4ruby seed:create NAME"
384
+ exit 1
385
+ end
386
+
387
+ dir = File.join(Dir.pwd, "seeds")
388
+ FileUtils.mkdir_p(dir)
389
+
390
+ existing = Dir.glob(File.join(dir, "*.rb")).select { |f| File.basename(f)[0] =~ /\d/ }.sort
391
+ numbers = existing.map { |f| File.basename(f).match(/^(\d+)/)[1].to_i }
392
+ next_num = numbers.empty? ? 1 : numbers.max + 1
393
+
394
+ clean_name = name.strip.downcase.gsub(/[^a-z0-9]+/, "_").gsub(/^_|_$/, "")
395
+ filename = format("%03d_%s.rb", next_num, clean_name)
396
+ filepath = File.join(dir, filename)
397
+
398
+ File.write(filepath, <<~RUBY)
399
+ # Seed: #{name.strip}
400
+ #
401
+ # This file is executed by `tina4ruby seed`.
402
+ # Use Tina4.seed_orm or Tina4.seed_table to populate data.
403
+ #
404
+ # Examples:
405
+ # Tina4.seed_orm(User, count: 50)
406
+ # Tina4.seed_table("audit_log", { action: :string, created_at: :datetime }, count: 100)
407
+ RUBY
408
+
409
+ puts "Created seed file: #{filepath}"
410
+ end
411
+
412
+ # ── test ──────────────────────────────────────────────────────────────
413
+
414
+ def cmd_test(argv)
415
+ require_relative "../tina4"
416
+ Tina4.initialize!(Dir.pwd)
417
+
418
+ # Load test files
419
+ test_dirs = %w[tests test spec src/tests]
420
+ test_dirs.each do |dir|
421
+ test_dir = File.join(Dir.pwd, dir)
422
+ next unless Dir.exist?(test_dir)
423
+ Dir.glob(File.join(test_dir, "**/*_test.rb")).sort.each { |f| load f }
424
+ Dir.glob(File.join(test_dir, "**/test_*.rb")).sort.each { |f| load f }
425
+ end
426
+
427
+ # Also load inline tests from routes
428
+ load_routes(Dir.pwd)
429
+
430
+ results = Tina4::Testing.run_all
431
+ exit(1) if results[:failed] > 0 || results[:errors] > 0
432
+ end
433
+
434
+ # ── version ───────────────────────────────────────────────────────────
435
+
436
+ def cmd_version
437
+ require_relative "version"
438
+ puts "Tina4 Ruby v#{Tina4::VERSION}"
439
+ end
440
+
441
+ # ── routes ────────────────────────────────────────────────────────────
442
+
443
+ def cmd_routes
444
+ require_relative "../tina4"
445
+ Tina4.initialize!(Dir.pwd)
446
+ load_routes(Dir.pwd)
447
+
448
+ puts "\nRegistered Routes:"
449
+ puts "-" * 60
450
+ Tina4::Router.routes.each do |route|
451
+ auth = route.auth_handler ? " [AUTH]" : ""
452
+ puts " #{route.method.ljust(8)} #{route.path}#{auth}"
453
+ end
454
+ puts "-" * 60
455
+ puts "Total: #{Tina4::Router.routes.length} routes\n"
456
+ end
457
+
458
+ # ── console ───────────────────────────────────────────────────────────
459
+
460
+ def cmd_console
461
+ require_relative "../tina4"
462
+ Tina4.initialize!(Dir.pwd)
463
+ load_routes(Dir.pwd)
464
+
465
+ require "irb"
466
+ IRB.start
467
+ end
468
+
469
+ # ── ai ────────────────────────────────────────────────────────────────
470
+
471
+ def cmd_ai(argv)
472
+ options = { all: false }
473
+ parser = OptionParser.new do |opts|
474
+ opts.banner = "Usage: tina4ruby ai [options]"
475
+ opts.on("--all", "Install context for ALL AI tools (non-interactive)") { options[:all] = true }
476
+ end
477
+ parser.parse!(argv)
478
+
479
+ require_relative "ai"
480
+
481
+ root_dir = Dir.pwd
482
+
483
+ if options[:all]
484
+ Tina4::AI.install_all(root_dir)
485
+ else
486
+ selection = Tina4::AI.show_menu(root_dir)
487
+ Tina4::AI.install_selected(root_dir, selection) unless selection.empty?
488
+ end
489
+ end
490
+
491
+ # ── generate ────────────────────────────────────────────────────────
492
+
493
+ def cmd_generate(argv)
494
+ what = argv.shift
495
+
496
+ unless what
497
+ puts "Usage: tina4ruby generate <what> <name> [options]"
498
+ puts " Generators: model, route, crud, migration, middleware, test, form, view, auth"
499
+ puts ' Options: --fields "name:string,price:float" --model ModelName'
500
+ exit 1
501
+ end
502
+
503
+ # Auth doesn't require a name argument
504
+ no_name_generators = %w[auth]
505
+ unless no_name_generators.include?(what)
506
+ if argv.empty? || argv.first.start_with?("--")
507
+ puts "Usage: tina4ruby generate #{what} <name> [options]"
508
+ exit 1
509
+ end
510
+ end
511
+
512
+ name = no_name_generators.include?(what) ? "" : argv.shift
513
+ flags, _positional = parse_flags(argv)
514
+
515
+ case what
516
+ when "model" then generate_model(name, flags)
517
+ when "route" then generate_route(name, flags)
518
+ when "crud" then generate_crud(name, flags)
519
+ when "migration" then generate_migration(name, flags)
520
+ when "middleware" then generate_middleware(name, flags)
521
+ when "test" then generate_test(name, flags)
522
+ when "form" then generate_form(name, flags)
523
+ when "view" then generate_view(name, flags)
524
+ when "auth" then generate_auth(name, flags)
525
+ else
526
+ puts "Unknown generator: #{what}"
527
+ puts " Available: model, route, crud, migration, middleware, test, form, view, auth"
528
+ exit 1
529
+ end
530
+ end
531
+
532
+ # ── Generator: model ─────────────────────────────────────────────────
533
+
534
+ def generate_model(name, flags)
535
+ fields = parse_fields(flags["fields"])
536
+ table = to_table_name(name)
537
+ snake = to_snake_case(name)
538
+
539
+ # Build field lines
540
+ field_lines = [" integer_field :id, primary_key: true, auto_increment: true"]
541
+ if fields.any?
542
+ fields.each do |fname, ftype|
543
+ info = FIELD_TYPE_MAP[ftype] || FIELD_TYPE_MAP["string"]
544
+ field_lines << " #{info[:orm]} :#{fname}"
545
+ end
546
+ else
547
+ field_lines << " string_field :name"
548
+ end
549
+ field_lines << " string_field :created_at"
550
+
551
+ # Write model file
552
+ dir = "src/orm"
553
+ FileUtils.mkdir_p(dir)
554
+ path = File.join(dir, "#{snake}.rb")
555
+ if File.exist?(path)
556
+ puts " File already exists: #{path}"
557
+ return
558
+ end
559
+
560
+ content = <<~RUBY
561
+ class #{name} < Tina4::ORM
562
+ table_name "#{table}"
563
+
564
+ #{field_lines.join("\n")}
565
+ end
566
+ RUBY
567
+
568
+ File.write(path, content)
569
+ puts " Created #{path}"
570
+
571
+ # Generate matching migration (unless --no-migration)
572
+ unless flags["no-migration"]
573
+ generate_migration("create_#{table}", flags, fields_override: fields, table_override: table)
574
+ end
575
+ end
576
+
577
+ # ── Generator: route ─────────────────────────────────────────────────
578
+
579
+ def generate_route(name, flags)
580
+ route_path = name.sub(%r{^/}, "")
581
+ singular = route_path.end_with?("s") ? route_path[0..-2] : route_path
582
+ model = flags["model"]
583
+
584
+ dir = "src/routes"
585
+ FileUtils.mkdir_p(dir)
586
+ path = File.join(dir, "#{route_path}.rb")
587
+ if File.exist?(path)
588
+ puts " File already exists: #{path}"
589
+ return
590
+ end
591
+
592
+ if model
593
+ model_snake = to_snake_case(model)
594
+ content = <<~RUBY
595
+ require_relative "../orm/#{model_snake}"
596
+
597
+ Tina4.get "/api/#{route_path}" do |request, response|
598
+ # List all #{route_path} with pagination
599
+ page = (request.params["page"] || 1).to_i
600
+ per_page = (request.params["per_page"] || 20).to_i
601
+ offset = (page - 1) * per_page
602
+ results = #{model}.all(limit: per_page, offset: offset)
603
+ response.json({ data: results.map(&:to_h), page: page, per_page: per_page })
604
+ end
605
+
606
+ Tina4.get "/api/#{route_path}/{id:int}" do |request, response|
607
+ # Get a single #{singular} by ID
608
+ item = #{model}.find(request.params["id"])
609
+ if item.nil?
610
+ response.json({ error: "Not found" }, 404)
611
+ else
612
+ response.json(item.to_h)
613
+ end
614
+ end
615
+
616
+ Tina4.post "/api/#{route_path}" do |request, response|
617
+ # Create a new #{singular}
618
+ item = #{model}.create(request.body)
619
+ response.json(item.to_h, 201)
620
+ end
621
+
622
+ Tina4.put "/api/#{route_path}/{id:int}" do |request, response|
623
+ # Update a #{singular} by ID
624
+ item = #{model}.find(request.params["id"])
625
+ if item.nil?
626
+ response.json({ error: "Not found" }, 404)
627
+ else
628
+ request.body.each do |key, value|
629
+ next if key.to_s == "id"
630
+ setter = "#{'#'}{key}="
631
+ item.send(setter, value) if item.respond_to?(setter)
632
+ end
633
+ item.save
634
+ response.json(item.to_h)
635
+ end
636
+ end
637
+
638
+ Tina4.delete "/api/#{route_path}/{id:int}" do |request, response|
639
+ # Delete a #{singular} by ID
640
+ item = #{model}.find(request.params["id"])
641
+ if item.nil?
642
+ response.json({ error: "Not found" }, 404)
643
+ else
644
+ item.delete
645
+ response.json(nil, 204)
646
+ end
647
+ end
648
+ RUBY
649
+ else
650
+ content = <<~RUBY
651
+ Tina4.get "/api/#{route_path}" do |request, response|
652
+ # List all #{route_path}
653
+ response.json({ data: [] })
654
+ end
655
+
656
+ Tina4.get "/api/#{route_path}/{id:int}" do |request, response|
657
+ # Get a single #{singular}
658
+ response.json({ data: {} })
659
+ end
660
+
661
+ Tina4.post "/api/#{route_path}" do |request, response|
662
+ # Create a new #{singular}
663
+ response.json({ data: request.body }, 201)
664
+ end
665
+
666
+ Tina4.put "/api/#{route_path}/{id:int}" do |request, response|
667
+ # Update a #{singular}
668
+ response.json({ data: request.body })
669
+ end
670
+
671
+ Tina4.delete "/api/#{route_path}/{id:int}" do |request, response|
672
+ # Delete a #{singular}
673
+ response.json(nil, 204)
674
+ end
675
+ RUBY
676
+ end
677
+
678
+ File.write(path, content)
679
+ puts " Created #{path}"
680
+ end
681
+
682
+ # ── Generator: crud ──────────────────────────────────────────────────
683
+
684
+ def generate_crud(name, flags)
685
+ table = to_table_name(name)
686
+ route_name = "#{table}s"
687
+
688
+ puts "\n Generating CRUD for #{name}...\n"
689
+
690
+ # 1. Model + migration
691
+ generate_model(name, flags)
692
+
693
+ # 2. Routes with model
694
+ generate_route(route_name, { "model" => name })
695
+
696
+ # 3. Form
697
+ generate_form(name, flags)
698
+
699
+ # 4. View (list + detail)
700
+ generate_view(name, flags)
701
+
702
+ # 5. Test
703
+ generate_test(route_name, { "model" => name })
704
+
705
+ puts "\n CRUD generation complete for #{name}."
706
+ puts " Run: tina4ruby migrate"
707
+ puts " Visit: /swagger to see the API docs"
708
+ end
709
+
710
+ # ── Generator: migration ─────────────────────────────────────────────
711
+
712
+ def generate_migration(name, flags = {}, fields_override: nil, table_override: nil)
713
+ now = Time.now
714
+ timestamp = now.strftime("%Y%m%d%H%M%S")
715
+ dir = "migrations"
716
+ FileUtils.mkdir_p(dir)
717
+
718
+ # Determine table name
719
+ if table_override
720
+ table = table_override
721
+ else
722
+ table = name.sub(/^create_/, "").sub(/^add_/, "").sub(/^drop_/, "")
723
+ table = to_snake_case(table)
724
+ end
725
+
726
+ # Build SQL columns from fields
727
+ fields = fields_override || parse_fields(flags["fields"])
728
+ is_create = name.start_with?("create_") || !fields_override.nil?
729
+
730
+ filename = "#{timestamp}_#{name}.sql"
731
+ path = File.join(dir, filename)
732
+
733
+ if is_create
734
+ col_lines = [" id INTEGER PRIMARY KEY AUTOINCREMENT"]
735
+ fields.each do |fname, ftype|
736
+ info = FIELD_TYPE_MAP[ftype] || FIELD_TYPE_MAP["string"]
737
+ default = info[:default] != "NULL" ? " DEFAULT #{info[:default]}" : ""
738
+ col_lines << " #{fname} #{info[:sql]}#{default}"
739
+ end
740
+ col_lines << " created_at TEXT DEFAULT CURRENT_TIMESTAMP"
741
+
742
+ up_sql = "CREATE TABLE IF NOT EXISTS #{table} (\n#{col_lines.join(",\n")}\n);"
743
+ down_sql = "DROP TABLE IF EXISTS #{table};"
744
+ else
745
+ up_sql = "-- Write your UP migration SQL here\n-- Example: ALTER TABLE #{table} ADD COLUMN new_col TEXT DEFAULT '';"
746
+ down_sql = "-- Write your DOWN rollback SQL here\n-- Example: ALTER TABLE #{table} DROP COLUMN new_col;"
747
+ end
748
+
749
+ content = <<~SQL
750
+ -- Migration: #{name}
751
+ -- Created: #{now.strftime("%Y-%m-%d %H:%M:%S")}
752
+
753
+ -- UP
754
+ #{up_sql}
755
+
756
+ -- DOWN
757
+ #{down_sql}
758
+ SQL
759
+
760
+ File.write(path, content)
761
+ puts " Created #{path}"
762
+
763
+ # Also create .down.sql for the migration runner
764
+ down_path = File.join(dir, "#{timestamp}_#{name}.down.sql")
765
+ down_content = <<~SQL
766
+ -- Rollback: #{name}
767
+ -- Created: #{now.strftime("%Y-%m-%d %H:%M:%S")}
768
+
769
+ #{down_sql}
770
+ SQL
771
+
772
+ File.write(down_path, down_content)
773
+ puts " Created #{down_path}"
774
+ end
775
+
776
+ # ── Generator: middleware ────────────────────────────────────────────
777
+
778
+ def generate_middleware(name, flags = {})
779
+ snake = to_snake_case(name)
780
+ dir = "src/middleware"
781
+ FileUtils.mkdir_p(dir)
782
+ path = File.join(dir, "#{snake}.rb")
783
+ if File.exist?(path)
784
+ puts " File already exists: #{path}"
785
+ return
786
+ end
787
+
788
+ content = <<~RUBY
789
+ # #{name} middleware
790
+ #
791
+ # Usage in routes:
792
+ # require_relative "../middleware/#{snake}"
793
+ # Tina4.get "/api/protected", middleware: [#{name}] do |request, response|
794
+ # response.json({ data: "protected" })
795
+ # end
796
+
797
+ class #{name}
798
+ def self.before_#{snake}(request, response)
799
+ # Runs before the route handler.
800
+ # Return [request, response] to continue, or
801
+ # return [request, response.json({ error: "Unauthorized" }, 401)] to block.
802
+ Tina4::Log.info("#{name}: \#{request.request_method} \#{request.path}")
803
+ [request, response]
804
+ end
805
+
806
+ def self.after_#{snake}(request, response)
807
+ # Runs after the route handler.
808
+ [request, response]
809
+ end
810
+ end
811
+ RUBY
812
+
813
+ File.write(path, content)
814
+ puts " Created #{path}"
815
+ end
816
+
817
+ # ── Generator: test ──────────────────────────────────────────────────
818
+
819
+ def generate_test(name, flags = {})
820
+ model = flags["model"]
821
+ snake = to_snake_case(name)
822
+ singular = snake.end_with?("s") ? snake[0..-2] : snake
823
+
824
+ dir = "spec"
825
+ FileUtils.mkdir_p(dir)
826
+ path = File.join(dir, "#{snake}_spec.rb")
827
+ if File.exist?(path)
828
+ puts " File already exists: #{path}"
829
+ return
830
+ end
831
+
832
+ if model
833
+ content = <<~RUBY
834
+ # Tests for #{name} CRUD operations
835
+ RSpec.describe "#{model}" do
836
+ before(:each) do
837
+ # Set up test fixtures
838
+ end
839
+
840
+ after(:each) do
841
+ # Clean up after tests
842
+ end
843
+
844
+ it "lists #{snake}" do
845
+ # TODO: implement
846
+ expect(true).to be true
847
+ end
848
+
849
+ it "gets a single #{singular}" do
850
+ # TODO: implement
851
+ expect(true).to be true
852
+ end
853
+
854
+ it "creates a #{singular}" do
855
+ # TODO: implement
856
+ expect(true).to be true
857
+ end
858
+
859
+ it "updates a #{singular}" do
860
+ # TODO: implement
861
+ expect(true).to be true
862
+ end
863
+
864
+ it "deletes a #{singular}" do
865
+ # TODO: implement
866
+ expect(true).to be true
867
+ end
868
+ end
869
+ RUBY
870
+ else
871
+ class_name = name.split("_").map(&:capitalize).join
872
+ content = <<~RUBY
873
+ # Tests for #{name}
874
+ RSpec.describe "#{class_name}" do
875
+ before(:each) do
876
+ # Set up test fixtures
877
+ end
878
+
879
+ after(:each) do
880
+ # Clean up after tests
881
+ end
882
+
883
+ it "works as expected" do
884
+ # TODO: replace with real tests
885
+ expect(true).to be true
886
+ end
887
+ end
888
+ RUBY
889
+ end
890
+
891
+ File.write(path, content)
892
+ puts " Created #{path}"
893
+ end
894
+
895
+ # ── Generator: form ──────────────────────────────────────────────────
896
+
897
+ def generate_form(name, flags = {})
898
+ fields = parse_fields(flags["fields"])
899
+ table = to_table_name(name)
900
+ route_name = "#{table}s"
901
+
902
+ # Input type mapping
903
+ input_types = {
904
+ "string" => "text", "str" => "text", "text" => "textarea",
905
+ "int" => "number", "integer" => "number",
906
+ "float" => "number", "numeric" => "number", "decimal" => "number",
907
+ "bool" => "checkbox", "boolean" => "checkbox",
908
+ "datetime" => "datetime-local", "blob" => "file",
909
+ }
910
+
911
+ dir = "src/templates/forms"
912
+ FileUtils.mkdir_p(dir)
913
+ path = File.join(dir, "#{table}.twig")
914
+ if File.exist?(path)
915
+ puts " File already exists: #{path}"
916
+ return
917
+ end
918
+
919
+ # Build form fields
920
+ field_html = ""
921
+ form_fields = fields.any? ? fields : [["name", "string"]]
922
+ form_fields.each do |fname, ftype|
923
+ itype = input_types[ftype] || "text"
924
+ label = fname.tr("_", " ").split.map(&:capitalize).join(" ")
925
+ step = %w[float numeric decimal].include?(ftype) ? ' step="0.01"' : ""
926
+
927
+ if itype == "textarea"
928
+ field_html += <<~HTML
929
+ <div class="form-group mb-3">
930
+ <label for="#{fname}">#{label}</label>
931
+ <textarea id="#{fname}" name="#{fname}" class="form-control" rows="4" placeholder="#{label}">{{ item.#{fname} }}</textarea>
932
+ </div>
933
+ HTML
934
+ elsif itype == "checkbox"
935
+ field_html += <<~HTML
936
+ <div class="form-group mb-3">
937
+ <label>
938
+ <input type="checkbox" id="#{fname}" name="#{fname}" value="1" {% if item.#{fname} %}checked{% endif %}>
939
+ #{label}
940
+ </label>
941
+ </div>
942
+ HTML
943
+ else
944
+ field_html += <<~HTML
945
+ <div class="form-group mb-3">
946
+ <label for="#{fname}">#{label}</label>
947
+ <input type="#{itype}" id="#{fname}" name="#{fname}" class="form-control"#{step} value="{{ item.#{fname} }}" placeholder="#{label}">
948
+ </div>
949
+ HTML
950
+ end
951
+ end
952
+
953
+ content = <<~HTML
954
+ {% extends "base.twig" %}
955
+ {% block title %}#{name} {% if item.id %}Edit{% else %}Create{% endif %}{% endblock %}
956
+ {% block content %}
957
+ <div class="container mt-4">
958
+ <h1>{% if item.id %}Edit #{name}{% else %}Create #{name}{% endif %}</h1>
959
+ <form method="post" action="/api/#{route_name}{% if item.id %}/{{ item.id }}{% endif %}">
960
+ {{ form_token() }}
961
+ #{field_html} <button type="submit" class="btn btn-primary">
962
+ {% if item.id %}Update{% else %}Create{% endif %}
963
+ </button>
964
+ <a href="/api/#{route_name}" class="btn btn-secondary">Cancel</a>
965
+ </form>
966
+ </div>
967
+ {% endblock %}
968
+ HTML
969
+
970
+ File.write(path, content)
971
+ puts " Created #{path}"
972
+ end
973
+
974
+ # ── Generator: view ──────────────────────────────────────────────────
975
+
976
+ def generate_view(name, flags = {})
977
+ fields = parse_fields(flags["fields"])
978
+ table = to_table_name(name)
979
+ route_name = "#{table}s"
980
+
981
+ cols = fields.any? ? fields.map { |f, _| f } : ["name"]
982
+
983
+ dir = "src/templates/pages"
984
+ FileUtils.mkdir_p(dir)
985
+
986
+ # List view
987
+ list_path = File.join(dir, "#{route_name}.twig")
988
+ unless File.exist?(list_path)
989
+ th = cols.map { |c| "<th>#{c.tr('_', ' ').split.map(&:capitalize).join(' ')}</th>" }.join("\n ")
990
+ td = cols.map { |c| "<td>{{ item.#{c} }}</td>" }.join("\n ")
991
+
992
+ list_content = <<~HTML
993
+ {% extends "base.twig" %}
994
+ {% block title %}#{name}s{% endblock %}
995
+ {% block content %}
996
+ <div class="container mt-4">
997
+ <div class="d-flex justify-content-between align-items-center mb-3">
998
+ <h1>#{name}s</h1>
999
+ <a href="/#{route_name}/create" class="btn btn-primary">Add #{name}</a>
1000
+ </div>
1001
+ <table class="table">
1002
+ <thead>
1003
+ <tr>
1004
+ <th>ID</th>
1005
+ #{th}
1006
+ <th>Actions</th>
1007
+ </tr>
1008
+ </thead>
1009
+ <tbody>
1010
+ {% for item in items %}
1011
+ <tr>
1012
+ <td>{{ item.id }}</td>
1013
+ #{td}
1014
+ <td>
1015
+ <a href="/#{route_name}/{{ item.id }}" class="btn btn-sm btn-primary">View</a>
1016
+ <a href="/#{route_name}/{{ item.id }}/edit" class="btn btn-sm btn-secondary">Edit</a>
1017
+ </td>
1018
+ </tr>
1019
+ {% endfor %}
1020
+ </tbody>
1021
+ </table>
1022
+ </div>
1023
+ {% endblock %}
1024
+ HTML
1025
+
1026
+ File.write(list_path, list_content)
1027
+ puts " Created #{list_path}"
1028
+ end
1029
+
1030
+ # Detail view
1031
+ detail_path = File.join(dir, "#{table}.twig")
1032
+ unless File.exist?(detail_path)
1033
+ detail_fields = cols.map do |c|
1034
+ " <div class=\"mb-3\"><strong>#{c.tr('_', ' ').split.map(&:capitalize).join(' ')}:</strong> {{ item.#{c} }}</div>"
1035
+ end.join("\n")
1036
+
1037
+ detail_content = <<~HTML
1038
+ {% extends "base.twig" %}
1039
+ {% block title %}#{name} Detail{% endblock %}
1040
+ {% block content %}
1041
+ <div class="container mt-4">
1042
+ <div class="d-flex justify-content-between align-items-center mb-3">
1043
+ <h1>#{name} \#{{ item.id }}</h1>
1044
+ <div>
1045
+ <a href="/#{route_name}/{{ item.id }}/edit" class="btn btn-secondary">Edit</a>
1046
+ <a href="/#{route_name}" class="btn btn-outline-secondary">Back</a>
1047
+ </div>
1048
+ </div>
1049
+ #{detail_fields}
1050
+ </div>
1051
+ {% endblock %}
1052
+ HTML
1053
+
1054
+ File.write(detail_path, detail_content)
1055
+ puts " Created #{detail_path}"
1056
+ end
1057
+ end
1058
+
1059
+ # ── Generator: auth ──────────────────────────────────────────────────
1060
+
1061
+ def generate_auth(_name = nil, flags = {})
1062
+ puts "\n Generating authentication scaffolding...\n"
1063
+
1064
+ # 1. User model + migration
1065
+ generate_model("User", { "fields" => "email:string,password:string,role:string" })
1066
+
1067
+ # 2. Auth routes
1068
+ dir = "src/routes"
1069
+ FileUtils.mkdir_p(dir)
1070
+ auth_path = File.join(dir, "auth.rb")
1071
+ unless File.exist?(auth_path)
1072
+ content = <<~'RUBY'
1073
+ require_relative "../orm/user"
1074
+
1075
+ Tina4.post "/api/auth/register" do |request, response|
1076
+ # Register a new user
1077
+ email = request.body["email"].to_s
1078
+ password = request.body["password"].to_s
1079
+
1080
+ if email.empty? || password.empty?
1081
+ next response.json({ error: "Email and password required" }, 400)
1082
+ end
1083
+
1084
+ # Check if user exists
1085
+ existing = User.where("email = ?", [email])
1086
+ unless existing.empty?
1087
+ next response.json({ error: "Email already registered" }, 409)
1088
+ end
1089
+
1090
+ # Create user with hashed password
1091
+ user = User.create({
1092
+ email: email,
1093
+ password: Tina4::Auth.hash_password(password),
1094
+ role: "user",
1095
+ })
1096
+ response.json({ message: "Registered", id: user.id }, 201)
1097
+ end
1098
+
1099
+ Tina4.post "/api/auth/login" do |request, response|
1100
+ # Login with email and password
1101
+ email = request.body["email"].to_s
1102
+ password = request.body["password"].to_s
1103
+
1104
+ users = User.where("email = ?", [email])
1105
+ if users.empty?
1106
+ next response.json({ error: "Invalid credentials" }, 401)
1107
+ end
1108
+ user = users.first
1109
+
1110
+ unless Tina4::Auth.check_password(password, user.password)
1111
+ next response.json({ error: "Invalid credentials" }, 401)
1112
+ end
1113
+
1114
+ token = Tina4::Auth.get_token({ user_id: user.id, email: user.email, role: user.role })
1115
+ response.json({ token: token })
1116
+ end
1117
+
1118
+ Tina4.get "/api/auth/me" do |request, response|
1119
+ # Get current authenticated user
1120
+ payload = Tina4::Auth.authenticate_request(request.headers)
1121
+ if payload.nil?
1122
+ next response.json({ error: "Unauthorized" }, 401)
1123
+ end
1124
+
1125
+ user = User.find(payload["user_id"])
1126
+ if user.nil?
1127
+ next response.json({ error: "User not found" }, 404)
1128
+ end
1129
+
1130
+ response.json({ id: user.id, email: user.email, role: user.role })
1131
+ end
1132
+ RUBY
1133
+
1134
+ File.write(auth_path, content)
1135
+ puts " Created #{auth_path}"
1136
+ end
1137
+
1138
+ # 3. Login template
1139
+ forms_dir = "src/templates/forms"
1140
+ FileUtils.mkdir_p(forms_dir)
1141
+ login_path = File.join(forms_dir, "login.twig")
1142
+ unless File.exist?(login_path)
1143
+ File.write(login_path, <<~HTML)
1144
+ {% extends "base.twig" %}
1145
+ {% block title %}Login{% endblock %}
1146
+ {% block content %}
1147
+ <div class="container mt-4" style="max-width:400px">
1148
+ <h1>Login</h1>
1149
+ <form method="post" action="/api/auth/login">
1150
+ {{ form_token() }}
1151
+ <div class="form-group mb-3">
1152
+ <label for="email">Email</label>
1153
+ <input type="email" id="email" name="email" class="form-control" placeholder="Email" required>
1154
+ </div>
1155
+ <div class="form-group mb-3">
1156
+ <label for="password">Password</label>
1157
+ <input type="password" id="password" name="password" class="form-control" placeholder="Password" required>
1158
+ </div>
1159
+ <button type="submit" class="btn btn-primary w-100">Login</button>
1160
+ <p class="mt-3 text-center"><a href="/register">Create an account</a></p>
1161
+ </form>
1162
+ </div>
1163
+ {% endblock %}
1164
+ HTML
1165
+ puts " Created #{login_path}"
1166
+ end
1167
+
1168
+ # 4. Register template
1169
+ register_path = File.join(forms_dir, "register.twig")
1170
+ unless File.exist?(register_path)
1171
+ File.write(register_path, <<~HTML)
1172
+ {% extends "base.twig" %}
1173
+ {% block title %}Register{% endblock %}
1174
+ {% block content %}
1175
+ <div class="container mt-4" style="max-width:400px">
1176
+ <h1>Register</h1>
1177
+ <form method="post" action="/api/auth/register">
1178
+ {{ form_token() }}
1179
+ <div class="form-group mb-3">
1180
+ <label for="email">Email</label>
1181
+ <input type="email" id="email" name="email" class="form-control" placeholder="Email" required>
1182
+ </div>
1183
+ <div class="form-group mb-3">
1184
+ <label for="password">Password</label>
1185
+ <input type="password" id="password" name="password" class="form-control" placeholder="Password" minlength="8" required>
1186
+ </div>
1187
+ <button type="submit" class="btn btn-primary w-100">Register</button>
1188
+ <p class="mt-3 text-center"><a href="/login">Already have an account?</a></p>
1189
+ </form>
1190
+ </div>
1191
+ {% endblock %}
1192
+ HTML
1193
+ puts " Created #{register_path}"
1194
+ end
1195
+
1196
+ # 5. Auth test
1197
+ generate_test("auth", { "model" => "User" })
1198
+
1199
+ puts "\n Authentication scaffolding complete."
1200
+ puts " Run: tina4ruby migrate"
1201
+ puts " POST /api/auth/register - create account"
1202
+ puts " POST /api/auth/login - get JWT token"
1203
+ puts " GET /api/auth/me - get profile (requires token)"
1204
+ end
1205
+
1206
+ # ── help ──────────────────────────────────────────────────────────────
1207
+
1208
+ def cmd_help
1209
+ puts <<~HELP
1210
+ Tina4 Ruby CLI
1211
+
1212
+ Usage: tina4ruby COMMAND [options]
1213
+
1214
+ Commands:
1215
+ init [NAME] Initialize a new Tina4 project
1216
+ start Start the Tina4 web server
1217
+ serve Alias for start
1218
+ migrate Run database migrations
1219
+ migrate:status Show migration status (completed and pending)
1220
+ migrate:rollback Rollback the last batch of migrations
1221
+ seed Run all seed files in seeds/
1222
+ seed:create NAME Create a new seed file
1223
+ test Run inline tests
1224
+ version Show Tina4 version
1225
+ routes List all registered routes
1226
+ console Start an interactive console
1227
+ ai Detect AI tools and install context files
1228
+ help Show this help message
1229
+
1230
+ Generators:
1231
+ generate model <Name> [--fields "name:string,price:float"]
1232
+ generate route <name> [--model Name]
1233
+ generate crud <Name> [--fields "..."] Model + migration + routes + form + view + test
1234
+ generate migration <description>
1235
+ generate middleware <Name>
1236
+ generate test <name>
1237
+ generate form <Name> [--fields "..."] Form template with inputs matching model fields
1238
+ generate view <Name> [--fields "..."] List + detail templates for viewing records
1239
+ generate auth Login/register/logout routes + User model + templates
1240
+
1241
+ Field types: string, int, float, bool, text, datetime, blob
1242
+ Table names: singular by default (Product -> product)
1243
+
1244
+ https://tina4.com
1245
+
1246
+ Run 'tina4ruby COMMAND --help' for more information on a command.
1247
+ HELP
1248
+ end
1249
+
1250
+ # ── config resolution ──────────────────────────────────────────────────
1251
+
1252
+ DEFAULT_PORT = 7147
1253
+ DEFAULT_HOST = "0.0.0.0"
1254
+
1255
+ # Priority: CLI flag > ENV var > default
1256
+ def resolve_config(key, cli_value)
1257
+ case key
1258
+ when :port
1259
+ return cli_value if cli_value
1260
+ return ENV["PORT"].to_i if ENV["PORT"] && !ENV["PORT"].empty?
1261
+ DEFAULT_PORT
1262
+ when :host
1263
+ return cli_value if cli_value
1264
+ return ENV["HOST"] if ENV["HOST"] && !ENV["HOST"].empty?
1265
+ DEFAULT_HOST
1266
+ end
1267
+ end
1268
+
1269
+ # ── shared helpers ────────────────────────────────────────────────────
1270
+
1271
+ def load_routes(root_dir)
1272
+ route_dirs = %w[src/routes routes src/api api src/orm orm]
1273
+ route_dirs.each do |dir|
1274
+ route_dir = File.join(root_dir, dir)
1275
+ next unless Dir.exist?(route_dir)
1276
+ Dir.glob(File.join(route_dir, "**/*.rb")).sort.each { |f| load f }
1277
+ end
1278
+
1279
+ # Also load app.rb if it exists
1280
+ app_file = File.join(root_dir, "app.rb")
1281
+ load app_file if File.exist?(app_file)
1282
+
1283
+ index_file = File.join(root_dir, "index.rb")
1284
+ load index_file if File.exist?(index_file)
1285
+ end
1286
+
1287
+ def create_project_structure(dir)
1288
+ %w[
1289
+ src/routes src/orm src/middleware src/templates src/templates/errors
1290
+ src/templates/forms src/templates/pages
1291
+ src/public src/public/css src/public/js src/public/images
1292
+ migrations logs spec seeds
1293
+ ].each do |subdir|
1294
+ FileUtils.mkdir_p(File.join(dir, subdir))
1295
+ end
1296
+
1297
+ # Copy framework public assets into the project so they're visible
1298
+ framework_public = File.join(File.dirname(__FILE__), "public")
1299
+ project_public = File.join(dir, "src", "public")
1300
+ assets_to_copy = %w[
1301
+ css/tina4.css
1302
+ css/tina4.min.css
1303
+ js/tina4.min.js
1304
+ js/frond.min.js
1305
+ images/tina4-logo-icon.webp
1306
+ ]
1307
+ assets_to_copy.each do |asset|
1308
+ src = File.join(framework_public, asset)
1309
+ dst = File.join(project_public, asset)
1310
+ FileUtils.mkdir_p(File.dirname(dst))
1311
+ if File.exist?(src) && !File.exist?(dst)
1312
+ FileUtils.cp(src, dst)
1313
+ puts " Copied #{asset}"
1314
+ end
1315
+ end
1316
+ end
1317
+
1318
+ def create_sample_files(dir, project_name)
1319
+ # app.rb
1320
+ unless File.exist?(File.join(dir, "app.rb"))
1321
+ File.write(File.join(dir, "app.rb"), <<~RUBY)
1322
+ require "tina4"
1323
+ Tina4.initialize!(__dir__)
1324
+ app = Tina4::RackApp.new
1325
+ Tina4::WebServer.new(app, port: 7147).start
1326
+ RUBY
1327
+ end
1328
+
1329
+ # Gemfile
1330
+ unless File.exist?(File.join(dir, "Gemfile"))
1331
+ File.write(File.join(dir, "Gemfile"), <<~RUBY)
1332
+ source "https://rubygems.org"
1333
+ gem "tina4-ruby", "~> 3.0"
1334
+ RUBY
1335
+ end
1336
+
1337
+ # .env
1338
+ unless File.exist?(File.join(dir, ".env"))
1339
+ File.write(File.join(dir, ".env"), <<~TEXT)
1340
+ TINA4_DEBUG=true
1341
+ TINA4_LOG_LEVEL=ALL
1342
+ TEXT
1343
+ end
1344
+
1345
+ # .gitignore
1346
+ unless File.exist?(File.join(dir, ".gitignore"))
1347
+ File.write(File.join(dir, ".gitignore"), <<~TEXT)
1348
+ .env
1349
+ .keys/
1350
+ logs/
1351
+ sessions/
1352
+ .queue/
1353
+ *.db
1354
+ vendor/
1355
+ TEXT
1356
+ end
1357
+
1358
+ # Dockerfile
1359
+ unless File.exist?(File.join(dir, "Dockerfile"))
1360
+ File.write(File.join(dir, "Dockerfile"), <<~DOCKERFILE)
1361
+ # === Build Stage ===
1362
+ FROM ruby:3.3-alpine AS builder
1363
+
1364
+ # Install build dependencies
1365
+ RUN apk add --no-cache \\
1366
+ build-base \\
1367
+ libffi-dev \\
1368
+ gcompat
1369
+
1370
+ WORKDIR /app
1371
+
1372
+ # Copy dependency definition first (layer caching)
1373
+ COPY Gemfile Gemfile.lock* ./
1374
+
1375
+ # Install gems
1376
+ RUN bundle config set --local without 'development test' && \\
1377
+ bundle install --jobs 4 --retry 3
1378
+
1379
+ # Copy application code
1380
+ COPY . .
1381
+
1382
+ # === Runtime Stage ===
1383
+ FROM ruby:3.3-alpine
1384
+
1385
+ # Runtime packages only
1386
+ RUN apk add --no-cache libffi gcompat
1387
+
1388
+ WORKDIR /app
1389
+
1390
+ # Copy installed gems
1391
+ COPY --from=builder /usr/local/bundle /usr/local/bundle
1392
+
1393
+ # Copy application code
1394
+ COPY --from=builder /app /app
1395
+
1396
+ EXPOSE 7147
1397
+
1398
+ # Swagger defaults (override with env vars in docker-compose/k8s if needed)
1399
+ ENV SWAGGER_TITLE="Tina4 API"
1400
+ ENV SWAGGER_VERSION="0.1.0"
1401
+ ENV SWAGGER_DESCRIPTION="Auto-generated API documentation"
1402
+
1403
+ # Start the server on all interfaces
1404
+ CMD ["bundle", "exec", "tina4ruby", "start", "-p", "7147", "-h", "0.0.0.0"]
1405
+ DOCKERFILE
1406
+ end
1407
+
1408
+ # .dockerignore
1409
+ unless File.exist?(File.join(dir, ".dockerignore"))
1410
+ File.write(File.join(dir, ".dockerignore"), <<~TEXT)
1411
+ .git
1412
+ .env
1413
+ .keys/
1414
+ logs/
1415
+ sessions/
1416
+ .queue/
1417
+ *.db
1418
+ *.gem
1419
+ tmp/
1420
+ spec/
1421
+ vendor/bundle
1422
+ TEXT
1423
+ end
1424
+
1425
+ # Base template
1426
+ templates_dir = File.join(dir, "src", "templates")
1427
+ unless File.exist?(File.join(templates_dir, "base.twig"))
1428
+ File.write(File.join(templates_dir, "base.twig"), <<~HTML)
1429
+ <!DOCTYPE html>
1430
+ <html lang="en">
1431
+ <head>
1432
+ <meta charset="UTF-8">
1433
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
1434
+ <title>{% block title %}#{project_name}{% endblock %}</title>
1435
+ <link rel="stylesheet" href="/css/tina4.min.css">
1436
+ {% block head %}{% endblock %}
1437
+ </head>
1438
+ <body>
1439
+ {% block content %}{% endblock %}
1440
+ <script src="/js/tina4.min.js"></script>
1441
+ <script src="/js/frond.min.js"></script>
1442
+ {% block scripts %}{% endblock %}
1443
+ </body>
1444
+ </html>
1445
+ HTML
1446
+ end
1447
+ end
1448
+ end
1449
+ end