tina4 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +61 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +662 -0
  5. data/exe/tina4 +4 -0
  6. data/lib/tina4/api.rb +152 -0
  7. data/lib/tina4/auth.rb +139 -0
  8. data/lib/tina4/cli.rb +243 -0
  9. data/lib/tina4/crud.rb +124 -0
  10. data/lib/tina4/database.rb +135 -0
  11. data/lib/tina4/database_result.rb +89 -0
  12. data/lib/tina4/debug.rb +83 -0
  13. data/lib/tina4/dev_reload.rb +68 -0
  14. data/lib/tina4/drivers/firebird_driver.rb +94 -0
  15. data/lib/tina4/drivers/mssql_driver.rb +112 -0
  16. data/lib/tina4/drivers/mysql_driver.rb +90 -0
  17. data/lib/tina4/drivers/postgres_driver.rb +99 -0
  18. data/lib/tina4/drivers/sqlite_driver.rb +85 -0
  19. data/lib/tina4/env.rb +55 -0
  20. data/lib/tina4/field_types.rb +84 -0
  21. data/lib/tina4/localization.rb +100 -0
  22. data/lib/tina4/middleware.rb +59 -0
  23. data/lib/tina4/migration.rb +124 -0
  24. data/lib/tina4/orm.rb +168 -0
  25. data/lib/tina4/queue.rb +117 -0
  26. data/lib/tina4/queue_backends/kafka_backend.rb +80 -0
  27. data/lib/tina4/queue_backends/lite_backend.rb +79 -0
  28. data/lib/tina4/queue_backends/rabbitmq_backend.rb +73 -0
  29. data/lib/tina4/rack_app.rb +150 -0
  30. data/lib/tina4/request.rb +158 -0
  31. data/lib/tina4/response.rb +172 -0
  32. data/lib/tina4/router.rb +142 -0
  33. data/lib/tina4/scss_compiler.rb +131 -0
  34. data/lib/tina4/session.rb +145 -0
  35. data/lib/tina4/session_handlers/file_handler.rb +55 -0
  36. data/lib/tina4/session_handlers/mongo_handler.rb +49 -0
  37. data/lib/tina4/session_handlers/redis_handler.rb +43 -0
  38. data/lib/tina4/swagger.rb +123 -0
  39. data/lib/tina4/template.rb +478 -0
  40. data/lib/tina4/templates/base.twig +25 -0
  41. data/lib/tina4/templates/errors/403.twig +22 -0
  42. data/lib/tina4/templates/errors/404.twig +22 -0
  43. data/lib/tina4/templates/errors/500.twig +22 -0
  44. data/lib/tina4/testing.rb +213 -0
  45. data/lib/tina4/version.rb +5 -0
  46. data/lib/tina4/webserver.rb +101 -0
  47. data/lib/tina4/websocket.rb +167 -0
  48. data/lib/tina4/wsdl.rb +164 -0
  49. data/lib/tina4.rb +233 -0
  50. metadata +303 -0
data/README.md ADDED
@@ -0,0 +1,662 @@
1
+ # Tina4 Ruby
2
+
3
+ **Simple. Fast. Human. This is not a framework.**
4
+
5
+ A lightweight, zero-configuration, Windows-friendly Ruby web framework. If you know [tina4_python](https://tina4.com) or tina4_php, you'll feel right at home.
6
+
7
+ ## Quick Start
8
+
9
+ ```bash
10
+ gem install tina4
11
+ tina4 init myapp
12
+ cd myapp
13
+ bundle install
14
+ tina4 start
15
+ ```
16
+
17
+ Your app is now running at `http://localhost:7145`.
18
+
19
+ ## Routing
20
+
21
+ Register routes using a clean Ruby DSL:
22
+
23
+ ```ruby
24
+ require "tina4"
25
+
26
+ # GET request
27
+ Tina4.get "/hello" do |request, response|
28
+ response.json({ message: "Hello World!" })
29
+ end
30
+
31
+ # POST request
32
+ Tina4.post "/api/users" do |request, response|
33
+ data = request.json_body
34
+ response.json({ created: true, name: data["name"] }, 201)
35
+ end
36
+
37
+ # Path parameters with type constraints
38
+ Tina4.get "/api/users/{id:int}" do |request, response|
39
+ user_id = request.params["id"] # auto-cast to Integer
40
+ response.json({ user_id: user_id })
41
+ end
42
+
43
+ Tina4.get "/files/{path:path}" do |request, response|
44
+ response.json({ path: request.params["path"] })
45
+ end
46
+
47
+ # PUT, PATCH, DELETE
48
+ Tina4.put "/api/users/{id:int}" do |request, response|
49
+ response.json({ updated: true })
50
+ end
51
+
52
+ Tina4.delete "/api/users/{id:int}" do |request, response|
53
+ response.json({ deleted: true })
54
+ end
55
+
56
+ # Match any HTTP method
57
+ Tina4.any "/webhook" do |request, response|
58
+ response.json({ method: request.method })
59
+ end
60
+ ```
61
+
62
+ ### Auth Defaults
63
+
64
+ Tina4 Ruby matches tina4_python's auth behavior:
65
+
66
+ - **GET** routes are **public** by default
67
+ - **POST/PUT/PATCH/DELETE** routes are **secured** by default (require `Authorization: Bearer <token>`)
68
+ - Use `auth: false` to make a write route public (equivalent to tina4_python's `@noauth()`)
69
+ - Set `API_KEY` in `.env` to allow API key bypass (token matches `API_KEY` → access granted)
70
+
71
+ ```ruby
72
+ # POST is secured by default — requires Bearer token
73
+ Tina4.post "/api/users" do |request, response|
74
+ response.json({ created: true })
75
+ end
76
+
77
+ # Make a POST route public (no auth required)
78
+ Tina4.post "/api/webhook", auth: false do |request, response|
79
+ response.json({ received: true })
80
+ end
81
+
82
+ # Custom auth handler
83
+ custom_auth = lambda do |env|
84
+ env["HTTP_X_API_KEY"] == "my-secret"
85
+ end
86
+
87
+ Tina4.post "/api/custom", auth: custom_auth do |request, response|
88
+ response.json({ ok: true })
89
+ end
90
+ ```
91
+
92
+ ### Secured Routes
93
+
94
+ For explicitly securing GET routes (which are public by default):
95
+
96
+ ```ruby
97
+ Tina4.secure_get "/api/profile" do |request, response|
98
+ response.json({ user: "authenticated" })
99
+ end
100
+
101
+ Tina4.secure_post "/api/admin/action" do |request, response|
102
+ response.json({ success: true })
103
+ end
104
+ ```
105
+
106
+ ### Route Groups
107
+
108
+ ```ruby
109
+ Tina4.group "/api/v1" do
110
+ get("/users") { |req, res| res.json(users) }
111
+ post("/users") { |req, res| res.json({ created: true }) }
112
+ end
113
+ ```
114
+
115
+ ## Request Object
116
+
117
+ ```ruby
118
+ Tina4.post "/example" do |request, response|
119
+ request.method # "POST"
120
+ request.path # "/example"
121
+ request.params # merged path + query params
122
+ request.headers # HTTP headers hash
123
+ request.cookies # parsed cookies
124
+ request.body # raw body string
125
+ request.json_body # parsed JSON body (hash)
126
+ request.bearer_token # extracted Bearer token
127
+ request.ip # client IP address
128
+ request.files # uploaded files
129
+ request.session # lazy-loaded session
130
+ end
131
+ ```
132
+
133
+ ## Response Object
134
+
135
+ ```ruby
136
+ # JSON response
137
+ response.json({ key: "value" })
138
+ response.json({ key: "value" }, 201) # custom status
139
+
140
+ # HTML response
141
+ response.html("<h1>Hello</h1>")
142
+
143
+ # Template rendering
144
+ response.render("pages/home.twig", { title: "Welcome" })
145
+
146
+ # Redirect
147
+ response.redirect("/dashboard")
148
+ response.redirect("/login", 301) # permanent redirect
149
+
150
+ # Plain text
151
+ response.text("OK")
152
+
153
+ # File download
154
+ response.file("path/to/document.pdf")
155
+
156
+ # Custom headers
157
+ response.add_header("X-Custom", "value")
158
+
159
+ # Cookies
160
+ response.set_cookie("theme", "dark", max_age: 86400)
161
+ response.delete_cookie("theme")
162
+
163
+ # CORS headers (auto-added by RackApp)
164
+ response.add_cors_headers
165
+ ```
166
+
167
+ ## Templates (Twig)
168
+
169
+ Tina4 uses a Twig-compatible template engine. Templates go in `templates/` or `src/templates/`.
170
+
171
+ ### Base template (`templates/base.twig`)
172
+
173
+ ```twig
174
+ <!DOCTYPE html>
175
+ <html>
176
+ <head>
177
+ <title>{% block title %}My App{% endblock %}</title>
178
+ </head>
179
+ <body>
180
+ {% block content %}{% endblock %}
181
+ </body>
182
+ </html>
183
+ ```
184
+
185
+ ### Child template (`templates/home.twig`)
186
+
187
+ ```twig
188
+ {% extends "base.twig" %}
189
+
190
+ {% block title %}Home{% endblock %}
191
+
192
+ {% block content %}
193
+ <h1>Hello {{ name }}!</h1>
194
+
195
+ {% if items %}
196
+ <ul>
197
+ {% for item in items %}
198
+ <li>{{ loop.index }}. {{ item | capitalize }}</li>
199
+ {% endfor %}
200
+ </ul>
201
+ {% else %}
202
+ <p>No items found.</p>
203
+ {% endif %}
204
+ {% endblock %}
205
+ ```
206
+
207
+ ### Rendering
208
+
209
+ ```ruby
210
+ Tina4.get "/home" do |request, response|
211
+ response.render("home.twig", {
212
+ name: "Alice",
213
+ items: ["apple", "banana", "cherry"]
214
+ })
215
+ end
216
+ ```
217
+
218
+ ### Filters
219
+
220
+ ```twig
221
+ {{ name | upper }} {# ALICE #}
222
+ {{ name | lower }} {# alice #}
223
+ {{ name | capitalize }} {# Alice #}
224
+ {{ "hello world" | title }} {# Hello World #}
225
+ {{ " hi " | trim }} {# hi #}
226
+ {{ items | length }} {# 3 #}
227
+ {{ items | join(", ") }} {# a, b, c #}
228
+ {{ missing | default("N/A") }} {# N/A #}
229
+ {{ html | escape }} {# &lt;b&gt;hi&lt;/b&gt; #}
230
+ {{ text | nl2br }} {# line<br>break #}
231
+ {{ 3.14159 | round(2) }} {# 3.14 #}
232
+ {{ data | json_encode }} {# {"key":"value"} #}
233
+ ```
234
+
235
+ ### Includes
236
+
237
+ ```twig
238
+ {% include "partials/header.twig" %}
239
+ ```
240
+
241
+ ### Variables and Math
242
+
243
+ ```twig
244
+ {% set greeting = "Hello" %}
245
+ {{ greeting ~ " " ~ name }} {# string concatenation #}
246
+ {{ price * quantity }} {# math #}
247
+ ```
248
+
249
+ ### Comments
250
+
251
+ ```twig
252
+ {# This is a comment and won't be rendered #}
253
+ ```
254
+
255
+ ## Database
256
+
257
+ Multi-database support with a unified API:
258
+
259
+ ```ruby
260
+ # SQLite (default, zero-config)
261
+ db = Tina4::Database.new("sqlite://app.db")
262
+
263
+ # PostgreSQL
264
+ db = Tina4::Database.new("postgresql://localhost:5432/mydb")
265
+
266
+ # MySQL
267
+ db = Tina4::Database.new("mysql://localhost:3306/mydb")
268
+
269
+ # MSSQL
270
+ db = Tina4::Database.new("mssql://localhost:1433/mydb")
271
+ ```
272
+
273
+ ### Querying
274
+
275
+ ```ruby
276
+ # Fetch multiple rows
277
+ result = db.fetch("SELECT * FROM users WHERE age > ?", [18])
278
+ result.each { |row| puts row[:name] }
279
+
280
+ # Fetch one row
281
+ user = db.fetch_one("SELECT * FROM users WHERE id = ?", [1])
282
+
283
+ # Pagination
284
+ result = db.fetch("SELECT * FROM users", [], limit: 10, skip: 20)
285
+
286
+ # Insert
287
+ db.insert("users", { name: "Alice", email: "alice@example.com" })
288
+
289
+ # Update
290
+ db.update("users", { name: "Alice Updated" }, { id: 1 })
291
+
292
+ # Delete
293
+ db.delete("users", { id: 1 })
294
+
295
+ # Raw SQL
296
+ db.execute("CREATE INDEX idx_email ON users(email)")
297
+
298
+ # Transactions
299
+ db.transaction do |tx|
300
+ tx.insert("accounts", { name: "Savings", balance: 1000 })
301
+ tx.update("accounts", { balance: 500 }, { id: 1 })
302
+ end
303
+
304
+ # Introspection
305
+ db.tables # ["users", "posts", ...]
306
+ db.table_exists?("users") # true
307
+ db.columns("users") # [{name: "id", type: "INTEGER", ...}, ...]
308
+ ```
309
+
310
+ ### DatabaseResult
311
+
312
+ ```ruby
313
+ result = db.fetch("SELECT * FROM users")
314
+ result.count # number of rows
315
+ result.empty? # true/false
316
+ result.first # first row hash
317
+ result.to_array # array of hashes
318
+ result.to_json # JSON string
319
+ result.to_csv # CSV text
320
+ result.to_paginate # { records_total:, record_count:, data: }
321
+ ```
322
+
323
+ ## ORM
324
+
325
+ Define models with a field DSL:
326
+
327
+ ```ruby
328
+ class User < Tina4::ORM
329
+ integer_field :id, primary_key: true, auto_increment: true
330
+ string_field :name, nullable: false
331
+ string_field :email, length: 255
332
+ integer_field :age, default: 0
333
+ datetime_field :created_at
334
+ end
335
+
336
+ # Set the database connection
337
+ Tina4.database = Tina4::Database.new("sqlite://app.db")
338
+ ```
339
+
340
+ ### CRUD Operations
341
+
342
+ ```ruby
343
+ # Create
344
+ user = User.new(name: "Alice", email: "alice@example.com")
345
+ user.save
346
+
347
+ # Or create in one step
348
+ user = User.create(name: "Bob", email: "bob@example.com")
349
+
350
+ # Read
351
+ user = User.find(1) # by primary key
352
+ users = User.where("age > ?", [18]) # with conditions
353
+ all_users = User.all # all records
354
+ all_users = User.all(limit: 10, order_by: "name")
355
+
356
+ # Update
357
+ user = User.find(1)
358
+ user.name = "Alice Updated"
359
+ user.save
360
+
361
+ # Delete
362
+ user.delete
363
+
364
+ # Load into existing instance
365
+ user = User.new
366
+ user.id = 1
367
+ user.load
368
+
369
+ # Serialization
370
+ user.to_hash # { id: 1, name: "Alice", ... }
371
+ user.to_json # '{"id":1,"name":"Alice",...}'
372
+ ```
373
+
374
+ ### Field Types
375
+
376
+ ```ruby
377
+ integer_field :id
378
+ string_field :name, length: 255
379
+ text_field :bio
380
+ float_field :score
381
+ decimal_field :price, precision: 10, scale: 2
382
+ boolean_field :active
383
+ date_field :birthday
384
+ datetime_field :created_at
385
+ timestamp_field :updated_at
386
+ blob_field :avatar
387
+ json_field :metadata
388
+ ```
389
+
390
+ ## Migrations
391
+
392
+ ```bash
393
+ # Create a migration
394
+ tina4 migrate --create "create users table"
395
+
396
+ # Run pending migrations
397
+ tina4 migrate
398
+
399
+ # Rollback
400
+ tina4 migrate --rollback 1
401
+ ```
402
+
403
+ Migration files are plain SQL in `migrations/`:
404
+
405
+ ```sql
406
+ -- migrations/20260313120000_create_users_table.sql
407
+ CREATE TABLE users (
408
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
409
+ name TEXT NOT NULL,
410
+ email TEXT UNIQUE,
411
+ age INTEGER DEFAULT 0,
412
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
413
+ );
414
+ ```
415
+
416
+ ## Authentication
417
+
418
+ JWT RS256 tokens with auto-generated RSA keys:
419
+
420
+ ```ruby
421
+ # Generate a token
422
+ token = Tina4::Auth.generate_token({ user_id: 42, role: "admin" })
423
+
424
+ # Validate a token
425
+ result = Tina4::Auth.validate_token(token)
426
+ if result[:valid]
427
+ payload = result[:payload]
428
+ puts payload["user_id"] # 42
429
+ end
430
+
431
+ # Password hashing (bcrypt)
432
+ hash = Tina4::Auth.hash_password("secret123")
433
+ Tina4::Auth.verify_password("secret123", hash) # true
434
+ Tina4::Auth.verify_password("wrong", hash) # false
435
+ ```
436
+
437
+ ### Protecting Routes
438
+
439
+ ```ruby
440
+ # Built-in Bearer auth
441
+ Tina4.secure_get "/api/profile" do |request, response|
442
+ # Only runs if valid JWT Bearer token is provided
443
+ response.json({ user: "authenticated" })
444
+ end
445
+
446
+ # Custom auth handler
447
+ custom_auth = lambda do |env|
448
+ api_key = env["HTTP_X_API_KEY"]
449
+ api_key == "my-secret-key"
450
+ end
451
+
452
+ Tina4.secure_get "/api/data", auth: custom_auth do |request, response|
453
+ response.json({ data: "protected" })
454
+ end
455
+ ```
456
+
457
+ ## Sessions
458
+
459
+ ```ruby
460
+ Tina4.post "/login" do |request, response|
461
+ request.session["user_id"] = 42
462
+ request.session["role"] = "admin"
463
+ request.session.save
464
+ response.json({ logged_in: true })
465
+ end
466
+
467
+ Tina4.get "/profile" do |request, response|
468
+ user_id = request.session["user_id"]
469
+ response.json({ user_id: user_id })
470
+ end
471
+
472
+ Tina4.post "/logout" do |request, response|
473
+ request.session.destroy
474
+ response.json({ logged_out: true })
475
+ end
476
+ ```
477
+
478
+ Session backends: `:file` (default), `:redis`, `:mongo`.
479
+
480
+ ## Middleware
481
+
482
+ ```ruby
483
+ # Run before every request
484
+ Tina4.before do |request, response|
485
+ puts "Request: #{request.method} #{request.path}"
486
+ end
487
+
488
+ # Run after every request
489
+ Tina4.after do |request, response|
490
+ puts "Response: #{response.status}"
491
+ end
492
+
493
+ # Pattern matching
494
+ Tina4.before("/api") do |request, response|
495
+ # Only runs for paths starting with /api
496
+ end
497
+
498
+ Tina4.before(/\/admin\/.*/) do |request, response|
499
+ # Regex pattern matching
500
+ return false unless request.session["role"] == "admin" # halts request
501
+ end
502
+ ```
503
+
504
+ ## Swagger / OpenAPI
505
+
506
+ Auto-generated API documentation at `/swagger`:
507
+
508
+ ```ruby
509
+ Tina4.get "/api/users", swagger_meta: {
510
+ summary: "List all users",
511
+ tags: ["Users"],
512
+ description: "Returns a paginated list of users"
513
+ } do |request, response|
514
+ response.json(users)
515
+ end
516
+ ```
517
+
518
+ Visit `http://localhost:7145/swagger` for the interactive Swagger UI.
519
+
520
+ ## REST API Client
521
+
522
+ ```ruby
523
+ api = Tina4::API.new("https://api.example.com", headers: {
524
+ "Authorization" => "Bearer sk-abc123"
525
+ })
526
+
527
+ # GET
528
+ response = api.get("/users", params: { page: 1 })
529
+ puts response.json # parsed response body
530
+
531
+ # POST
532
+ response = api.post("/users", body: { name: "Alice" })
533
+ puts response.success? # true for 2xx status
534
+ puts response.status # 201
535
+
536
+ # PUT, PATCH, DELETE
537
+ api.put("/users/1", body: { name: "Updated" })
538
+ api.patch("/users/1", body: { name: "Patched" })
539
+ api.delete("/users/1")
540
+
541
+ # File upload
542
+ api.upload("/files", "path/to/file.pdf")
543
+ ```
544
+
545
+ ## Environment Variables
546
+
547
+ Tina4 auto-creates and loads `.env` files:
548
+
549
+ ```env
550
+ PROJECT_NAME=My App
551
+ VERSION=1.0.0
552
+ SECRET=my-jwt-secret
553
+ API_KEY=your-api-key-here
554
+ DATABASE_URL=sqlite://app.db
555
+ TINA4_DEBUG_LEVEL=[TINA4_LOG_DEBUG]
556
+ ENVIRONMENT=development
557
+ ```
558
+
559
+ `API_KEY` enables a static bearer token bypass — any request with `Authorization: Bearer <API_KEY>` is granted access without JWT validation.
560
+
561
+ Supports environment-specific files: `.env.development`, `.env.production`, `.env.test`.
562
+
563
+ ## CLI Commands
564
+
565
+ ```bash
566
+ tina4 init [NAME] # Scaffold a new project
567
+ tina4 start # Start the web server (default port 7145)
568
+ tina4 start -p 3000 # Custom port
569
+ tina4 start -d # Dev mode with auto-reload
570
+ tina4 migrate # Run pending migrations
571
+ tina4 migrate --create "desc" # Create a migration
572
+ tina4 migrate --rollback 1 # Rollback migrations
573
+ tina4 test # Run inline tests
574
+ tina4 routes # List all registered routes
575
+ tina4 console # Interactive Ruby console
576
+ tina4 version # Show version
577
+ ```
578
+
579
+ ## Project Structure
580
+
581
+ ```
582
+ myapp/
583
+ ├── app.rb # Entry point
584
+ ├── .env # Environment config
585
+ ├── Gemfile
586
+ ├── migrations/ # SQL migrations
587
+ ├── routes/ # Auto-discovered route files
588
+ ├── templates/ # Twig/ERB templates
589
+ ├── public/ # Static files (CSS, JS, images)
590
+ │ ├── css/
591
+ │ ├── js/
592
+ │ └── images/
593
+ ├── src/ # Application code
594
+ └── logs/ # Log files
595
+ ```
596
+
597
+ Routes in `routes/` are auto-discovered at startup:
598
+
599
+ ```ruby
600
+ # routes/users.rb
601
+ Tina4.get "/api/users" do |request, response|
602
+ response.json(User.all.map(&:to_hash))
603
+ end
604
+ ```
605
+
606
+ ## Auto-Discovery
607
+
608
+ Tina4 automatically loads:
609
+ - Route files from `routes/`, `src/routes/`, `src/api/`, `api/`
610
+ - `app.rb` and `index.rb` from the project root
611
+
612
+ ## Full Example App
613
+
614
+ ```ruby
615
+ # app.rb
616
+ require "tina4"
617
+
618
+ # Database
619
+ Tina4.database = Tina4::Database.new("sqlite://app.db")
620
+
621
+ # Model
622
+ class Todo < Tina4::ORM
623
+ integer_field :id, primary_key: true, auto_increment: true
624
+ string_field :title, nullable: false
625
+ boolean_field :done, default: false
626
+ end
627
+
628
+ # Routes
629
+ Tina4.get "/" do |request, response|
630
+ response.render("home.twig", { todos: Todo.all.map(&:to_hash) })
631
+ end
632
+
633
+ Tina4.get "/api/todos" do |request, response|
634
+ response.json(Todo.all.map(&:to_hash))
635
+ end
636
+
637
+ Tina4.post "/api/todos" do |request, response|
638
+ todo = Todo.create(title: request.json_body["title"])
639
+ response.json(todo.to_hash, 201)
640
+ end
641
+
642
+ Tina4.put "/api/todos/{id:int}" do |request, response|
643
+ todo = Todo.find(request.params["id"])
644
+ todo.done = request.json_body["done"]
645
+ todo.save
646
+ response.json(todo.to_hash)
647
+ end
648
+
649
+ Tina4.delete "/api/todos/{id:int}" do |request, response|
650
+ Todo.find(request.params["id"]).delete
651
+ response.json({ deleted: true })
652
+ end
653
+ ```
654
+
655
+ ## Requirements
656
+
657
+ - Ruby >= 3.1.0
658
+ - Works on Windows, macOS, and Linux
659
+
660
+ ## License
661
+
662
+ MIT
data/exe/tina4 ADDED
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+ require_relative "../lib/tina4/cli"
4
+ Tina4::CLI.start(ARGV)