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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +80 -80
- data/LICENSE.txt +21 -21
- data/README.md +137 -137
- data/exe/tina4ruby +5 -5
- data/lib/tina4/ai.rb +696 -696
- data/lib/tina4/api.rb +189 -189
- data/lib/tina4/auth.rb +305 -305
- data/lib/tina4/auto_crud.rb +244 -244
- data/lib/tina4/cache.rb +154 -154
- data/lib/tina4/cli.rb +1449 -1449
- data/lib/tina4/constants.rb +46 -46
- data/lib/tina4/container.rb +74 -74
- data/lib/tina4/cors.rb +74 -74
- data/lib/tina4/crud.rb +692 -692
- data/lib/tina4/database/sqlite3_adapter.rb +165 -165
- data/lib/tina4/database.rb +625 -625
- data/lib/tina4/database_result.rb +208 -208
- data/lib/tina4/debug.rb +8 -8
- data/lib/tina4/dev.rb +14 -14
- data/lib/tina4/dev_admin.rb +1289 -935
- data/lib/tina4/dev_mailbox.rb +191 -191
- data/lib/tina4/drivers/firebird_driver.rb +124 -124
- data/lib/tina4/drivers/mongodb_driver.rb +561 -561
- data/lib/tina4/drivers/mssql_driver.rb +112 -112
- data/lib/tina4/drivers/mysql_driver.rb +90 -90
- data/lib/tina4/drivers/odbc_driver.rb +191 -191
- data/lib/tina4/drivers/postgres_driver.rb +116 -116
- data/lib/tina4/drivers/sqlite_driver.rb +122 -122
- data/lib/tina4/env.rb +95 -95
- data/lib/tina4/error_overlay.rb +252 -252
- data/lib/tina4/events.rb +109 -109
- data/lib/tina4/field_types.rb +154 -154
- data/lib/tina4/frond.rb +2087 -2025
- data/lib/tina4/gallery/auth/meta.json +1 -1
- data/lib/tina4/gallery/auth/src/routes/api/gallery_auth.rb +114 -114
- data/lib/tina4/gallery/database/meta.json +1 -1
- data/lib/tina4/gallery/database/src/routes/api/gallery_db.rb +43 -43
- data/lib/tina4/gallery/error-overlay/meta.json +1 -1
- data/lib/tina4/gallery/error-overlay/src/routes/api/gallery_crash.rb +17 -17
- data/lib/tina4/gallery/orm/meta.json +1 -1
- data/lib/tina4/gallery/orm/src/routes/api/gallery_products.rb +16 -16
- data/lib/tina4/gallery/queue/meta.json +1 -1
- data/lib/tina4/gallery/queue/src/routes/api/gallery_queue.rb +325 -325
- data/lib/tina4/gallery/rest-api/meta.json +1 -1
- data/lib/tina4/gallery/rest-api/src/routes/api/gallery_hello.rb +14 -14
- data/lib/tina4/gallery/templates/meta.json +1 -1
- data/lib/tina4/gallery/templates/src/routes/gallery_page.rb +12 -12
- data/lib/tina4/gallery/templates/src/templates/gallery_page.twig +257 -257
- data/lib/tina4/graphql.rb +966 -966
- data/lib/tina4/health.rb +39 -39
- data/lib/tina4/html_element.rb +170 -170
- data/lib/tina4/job.rb +80 -80
- data/lib/tina4/localization.rb +168 -168
- data/lib/tina4/log.rb +203 -203
- data/lib/tina4/mcp.rb +871 -696
- data/lib/tina4/messenger.rb +587 -587
- data/lib/tina4/metrics.rb +793 -793
- data/lib/tina4/middleware.rb +445 -445
- data/lib/tina4/migration.rb +451 -451
- data/lib/tina4/orm.rb +790 -790
- data/lib/tina4/plan.rb +471 -0
- data/lib/tina4/project_index.rb +366 -0
- data/lib/tina4/public/css/tina4.css +2463 -2463
- data/lib/tina4/public/css/tina4.min.css +1 -1
- data/lib/tina4/public/images/logo.svg +5 -5
- data/lib/tina4/public/js/frond.min.js +2 -2
- data/lib/tina4/public/js/tina4-dev-admin.js +1264 -565
- data/lib/tina4/public/js/tina4-dev-admin.min.js +1264 -480
- data/lib/tina4/public/js/tina4.min.js +92 -92
- data/lib/tina4/public/js/tina4js.min.js +48 -48
- data/lib/tina4/public/swagger/index.html +90 -90
- data/lib/tina4/public/swagger/oauth2-redirect.html +63 -63
- data/lib/tina4/query_builder.rb +380 -380
- data/lib/tina4/queue.rb +366 -366
- data/lib/tina4/queue_backends/kafka_backend.rb +80 -80
- data/lib/tina4/queue_backends/lite_backend.rb +298 -298
- data/lib/tina4/queue_backends/mongo_backend.rb +126 -126
- data/lib/tina4/queue_backends/rabbitmq_backend.rb +73 -73
- data/lib/tina4/rack_app.rb +817 -817
- data/lib/tina4/rate_limiter.rb +130 -130
- data/lib/tina4/request.rb +268 -268
- data/lib/tina4/response.rb +346 -346
- data/lib/tina4/response_cache.rb +551 -551
- data/lib/tina4/router.rb +406 -406
- data/lib/tina4/scss/tina4css/_alerts.scss +34 -34
- data/lib/tina4/scss/tina4css/_badges.scss +22 -22
- data/lib/tina4/scss/tina4css/_buttons.scss +69 -69
- data/lib/tina4/scss/tina4css/_cards.scss +49 -49
- data/lib/tina4/scss/tina4css/_forms.scss +156 -156
- data/lib/tina4/scss/tina4css/_grid.scss +81 -81
- data/lib/tina4/scss/tina4css/_modals.scss +84 -84
- data/lib/tina4/scss/tina4css/_nav.scss +149 -149
- data/lib/tina4/scss/tina4css/_reset.scss +94 -94
- data/lib/tina4/scss/tina4css/_tables.scss +54 -54
- data/lib/tina4/scss/tina4css/_typography.scss +55 -55
- data/lib/tina4/scss/tina4css/_utilities.scss +197 -197
- data/lib/tina4/scss/tina4css/_variables.scss +117 -117
- data/lib/tina4/scss/tina4css/base.scss +1 -1
- data/lib/tina4/scss/tina4css/colors.scss +48 -48
- data/lib/tina4/scss/tina4css/tina4.scss +17 -17
- data/lib/tina4/scss_compiler.rb +178 -178
- data/lib/tina4/seeder.rb +567 -567
- data/lib/tina4/service_runner.rb +303 -303
- data/lib/tina4/session.rb +297 -297
- data/lib/tina4/session_handlers/database_handler.rb +72 -72
- data/lib/tina4/session_handlers/file_handler.rb +67 -67
- data/lib/tina4/session_handlers/mongo_handler.rb +49 -49
- data/lib/tina4/session_handlers/redis_handler.rb +43 -43
- data/lib/tina4/session_handlers/valkey_handler.rb +43 -43
- data/lib/tina4/shutdown.rb +84 -84
- data/lib/tina4/sql_translation.rb +158 -158
- data/lib/tina4/swagger.rb +124 -124
- data/lib/tina4/template.rb +894 -894
- data/lib/tina4/templates/base.twig +26 -26
- data/lib/tina4/templates/errors/302.twig +14 -14
- data/lib/tina4/templates/errors/401.twig +9 -9
- data/lib/tina4/templates/errors/403.twig +29 -29
- data/lib/tina4/templates/errors/404.twig +29 -29
- data/lib/tina4/templates/errors/500.twig +38 -38
- data/lib/tina4/templates/errors/502.twig +9 -9
- data/lib/tina4/templates/errors/503.twig +12 -12
- data/lib/tina4/templates/errors/base.twig +37 -37
- data/lib/tina4/test_client.rb +159 -159
- data/lib/tina4/testing.rb +340 -340
- data/lib/tina4/validator.rb +174 -174
- data/lib/tina4/version.rb +1 -1
- data/lib/tina4/webserver.rb +312 -312
- data/lib/tina4/websocket.rb +343 -343
- data/lib/tina4/websocket_backplane.rb +190 -190
- data/lib/tina4/wsdl.rb +564 -564
- data/lib/tina4.rb +460 -458
- data/lib/tina4ruby.rb +4 -4
- 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
|