tina4ruby 3.13.63 → 3.13.66
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/lib/tina4/cli.rb +1668 -167
- data/lib/tina4/orm.rb +3 -2
- data/lib/tina4/public/js/frond.js +19 -8
- data/lib/tina4/public/js/frond.min.js +1 -1
- data/lib/tina4/queue.rb +6 -0
- data/lib/tina4/version.rb +1 -1
- metadata +2 -2
data/lib/tina4/cli.rb
CHANGED
|
@@ -5,7 +5,75 @@ require "fileutils"
|
|
|
5
5
|
|
|
6
6
|
module Tina4
|
|
7
7
|
class CLI
|
|
8
|
-
|
|
8
|
+
# ── Command registries — the single source of truth ─────────────────
|
|
9
|
+
#
|
|
10
|
+
# ONE entry per command/generator drives dispatch (#run / #cmd_generate),
|
|
11
|
+
# the human help (#cmd_help), AND the machine-readable manifest
|
|
12
|
+
# (`commands --json`). Add a command in ONE place and it appears in
|
|
13
|
+
# dispatch, help, and discovery — there is no second list to sync. Ruby
|
|
14
|
+
# mirror of the Python master's COMMANDS / GENERATORS registries
|
|
15
|
+
# (tina4_python/cli/__init__.py).
|
|
16
|
+
#
|
|
17
|
+
# GENERATORS[name] = { handler: :method_symbol, usage: str, summary: str }
|
|
18
|
+
# COMMANDS[name] = { handler: :method_symbol, summary: str,
|
|
19
|
+
# usage?: str, # arg/flag hint for #cmd_help (human only)
|
|
20
|
+
# args?: [str], # positional args for the manifest ("x?" = optional)
|
|
21
|
+
# subcommands?: [str] } # sub-names for the manifest (generate)
|
|
22
|
+
#
|
|
23
|
+
# Handlers are instance-method symbols dispatched via #send: GENERATORS
|
|
24
|
+
# handlers take (name, flags); COMMANDS handlers take (argv).
|
|
25
|
+
|
|
26
|
+
GENERATORS = {
|
|
27
|
+
"model" => { handler: :generate_model, usage: '<Name> [--fields "name:string,price:float"]', summary: "ORM model + matching migration" },
|
|
28
|
+
"route" => { handler: :generate_route, usage: "<name> [--model Name] [--public]", summary: "CRUD route file, secure by default (--public opens writes)" },
|
|
29
|
+
"crud" => { handler: :generate_crud, usage: '<Name> [--fields "..."] [--public]', summary: "Model + migration + routes + form + view + test" },
|
|
30
|
+
"migration" => { handler: :generate_migration, usage: "<description>", summary: "Timestamped migration file (UP/DOWN)" },
|
|
31
|
+
"middleware" => { handler: :generate_middleware, usage: "<Name>", summary: "Middleware with before/after hooks" },
|
|
32
|
+
"test" => { handler: :generate_test, usage: "<name> [--model Name]", summary: "RSpec test file" },
|
|
33
|
+
"form" => { handler: :generate_form, usage: '<Name> [--fields "..."]', summary: "Form template with inputs matching model fields" },
|
|
34
|
+
"view" => { handler: :generate_view, usage: '<Name> [--fields "..."]', summary: "List + detail view templates" },
|
|
35
|
+
"auth" => { handler: :generate_auth, usage: "", summary: "Login/register routes + User model + templates" },
|
|
36
|
+
"service" => { handler: :generate_service, usage: '<Name> [--every 5m | --cron "..."]', summary: "Scheduled ServiceRunner task (src/services/)" },
|
|
37
|
+
"queue" => { handler: :generate_queue, usage: "<topic>", summary: "Producer + consumer worker (src/services/)" },
|
|
38
|
+
"validator" => { handler: :generate_validator, usage: "<Name>", summary: "Request-body Validator (src/validators/)" },
|
|
39
|
+
"seeder" => { handler: :generate_seeder, usage: "<Model>", summary: "FakeData + seed_orm seeder (seeds/)" },
|
|
40
|
+
"websocket" => { handler: :generate_websocket, usage: "<path>", summary: "Tina4.websocket handler (src/routes/)" },
|
|
41
|
+
"listener" => { handler: :generate_listener, usage: "<event>", summary: "Tina4::Events.on listener (src/listeners/)" },
|
|
42
|
+
}.freeze
|
|
43
|
+
|
|
44
|
+
# Sub-dispatch table for the top-level `queue` command — the SINGLE source
|
|
45
|
+
# for its subcommands (drives #cmd_queue dispatch AND the manifest's
|
|
46
|
+
# queue.subcommands). Ruby mirror of the Python master's _QUEUE_SUBCOMMANDS.
|
|
47
|
+
# Distinct from the `queue` GENERATOR, which SCAFFOLDS a consumer file.
|
|
48
|
+
QUEUE_SUBCOMMANDS = {
|
|
49
|
+
"work" => :queue_work,
|
|
50
|
+
"stats" => :queue_stats,
|
|
51
|
+
"retry" => :queue_retry,
|
|
52
|
+
"clear" => :queue_clear,
|
|
53
|
+
}.freeze
|
|
54
|
+
|
|
55
|
+
COMMANDS = {
|
|
56
|
+
"init" => { handler: :cmd_init, usage: "[NAME]", args: ["name?"], summary: "Initialize a new Tina4 project" },
|
|
57
|
+
"start" => { handler: :cmd_start, usage: "[options]", summary: "Start the Tina4 web server" },
|
|
58
|
+
"serve" => { handler: :cmd_start, summary: "Alias for start" },
|
|
59
|
+
"migrate" => { handler: :cmd_migrate, usage: "[--create NAME] [--rollback N]", summary: "Run database migrations" },
|
|
60
|
+
"migrate:create" => { handler: :cmd_migrate_create, usage: "<desc>", args: ["description"], summary: "Create a new migration file" },
|
|
61
|
+
"migrate:status" => { handler: :cmd_migrate_status, summary: "Show migration status (completed and pending)" },
|
|
62
|
+
"migrate:rollback" => { handler: :cmd_migrate_rollback, usage: "[-n N]", summary: "Rollback the last batch of migrations" },
|
|
63
|
+
"seed" => { handler: :cmd_seed, usage: "[--clear]", summary: "Run all seed files in seeds/" },
|
|
64
|
+
"seed:create" => { handler: :cmd_seed_create, usage: "NAME", args: ["name"], summary: "Create a new seed file" },
|
|
65
|
+
"test" => { handler: :cmd_test, summary: "Run inline tests" },
|
|
66
|
+
"queue" => { handler: :cmd_queue, usage: "<work|stats|retry|clear> [topic]", subcommands: QUEUE_SUBCOMMANDS.keys, summary: "Run queue workers and manage jobs" },
|
|
67
|
+
"build" => { handler: :cmd_build, usage: "[--tag NAME] [--file PATH]", summary: "Build the deployable Docker image" },
|
|
68
|
+
"version" => { handler: :cmd_version, summary: "Show Tina4 version" },
|
|
69
|
+
"routes" => { handler: :cmd_routes, summary: "List all registered routes" },
|
|
70
|
+
"console" => { handler: :cmd_console, summary: "Start an interactive console" },
|
|
71
|
+
"generate" => { handler: :cmd_generate, usage: "<what> <name> [options]", subcommands: GENERATORS.keys, summary: "Generate scaffolding (see Generators below)" },
|
|
72
|
+
"ai" => { handler: :cmd_ai, usage: "[--all]", summary: "Detect AI tools and install context files" },
|
|
73
|
+
"metrics" => { handler: :cmd_metrics, usage: "[--top N] [--json] [--fail-on warn|error] [--path DIR]", summary: "Rank top code-quality offenders" },
|
|
74
|
+
"commands" => { handler: :cmd_commands, usage: "[--json]", summary: "List available commands (add --json for machine form)" },
|
|
75
|
+
"help" => { handler: :cmd_help, summary: "Show this help message" },
|
|
76
|
+
}.freeze
|
|
9
77
|
|
|
10
78
|
# ── Field type mapping ──────────────────────────────────────────────
|
|
11
79
|
FIELD_TYPE_MAP = {
|
|
@@ -29,22 +97,14 @@ module Tina4
|
|
|
29
97
|
|
|
30
98
|
def run(argv)
|
|
31
99
|
command = argv.shift || "help"
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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 "metrics" then cmd_metrics(argv)
|
|
47
|
-
when "help", "-h", "--help" then cmd_help
|
|
100
|
+
command = "help" if %w[-h --help].include?(command)
|
|
101
|
+
|
|
102
|
+
# Dispatch from the single-source-of-truth COMMANDS registry. The same
|
|
103
|
+
# registry drives #cmd_help and the `commands --json` manifest, so
|
|
104
|
+
# dispatch, help, and discovery never drift.
|
|
105
|
+
spec = COMMANDS[command]
|
|
106
|
+
if spec
|
|
107
|
+
send(spec[:handler], argv)
|
|
48
108
|
else
|
|
49
109
|
puts "Unknown command: #{command}"
|
|
50
110
|
cmd_help
|
|
@@ -83,10 +143,72 @@ module Tina4
|
|
|
83
143
|
end.compact
|
|
84
144
|
end
|
|
85
145
|
|
|
146
|
+
# Canonical AI-FILL placeholder block for a LOGIC-shaped stub.
|
|
147
|
+
#
|
|
148
|
+
# A tight, grounded *fill-spec* — not a vague ``# TODO`` — so a coding agent
|
|
149
|
+
# (or dev) completes it correctly and idiomatically. `raise
|
|
150
|
+
# NotImplementedError` makes an unfilled scaffold fail LOUD, and the
|
|
151
|
+
# greppable AI-FILL banner lets a human/agent jump to every gap in a file.
|
|
152
|
+
# <= 6 comment lines; `Use:` names only REAL tina4-ruby symbols (verified in
|
|
153
|
+
# the framework source, file:line).
|
|
154
|
+
#
|
|
155
|
+
# <indent># ─── AI-FILL: <fn> ───────────────────────────
|
|
156
|
+
# <indent># Intent: <what this must do>
|
|
157
|
+
# <indent># Given: <inputs + shape>
|
|
158
|
+
# <indent># Use: <named REAL Tina4 API — the idiomatic path>
|
|
159
|
+
# <indent># Return: <exact return value + status>
|
|
160
|
+
# <indent># Ground: tina4_context("<intent>", "ruby") · skill tina4-developer-ruby
|
|
161
|
+
# <indent>raise NotImplementedError, "<feature>: <what>" # remove when done
|
|
162
|
+
# <indent># ─────────────────────────────────────────────
|
|
163
|
+
def ai_fill(fn, intent, use, raise_msg, given: nil, ret: nil, ground: nil, indent: " ")
|
|
164
|
+
bar = "─" * 60
|
|
165
|
+
head = "#{indent}# ─── AI-FILL: #{fn} "
|
|
166
|
+
head += "─" * [4, 66 - head.length].max
|
|
167
|
+
lines = [head, "#{indent}# Intent: #{intent}"]
|
|
168
|
+
lines << "#{indent}# Given: #{given}" if given
|
|
169
|
+
lines << "#{indent}# Use: #{use}"
|
|
170
|
+
lines << "#{indent}# Return: #{ret}" if ret
|
|
171
|
+
lines << "#{indent}# Ground: #{ground}" if ground
|
|
172
|
+
lines << "#{indent}raise NotImplementedError, #{raise_msg.inspect} # remove when done"
|
|
173
|
+
lines << "#{indent}# #{bar}"
|
|
174
|
+
lines.join("\n") + "\n"
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# Lighter EXTEND marker for CRUD-shaped WORKING code. Marks the natural
|
|
178
|
+
# extension point in generated code that already runs — NO
|
|
179
|
+
# NotImplementedError (the boilerplate IS the feature); just a greppable hint
|
|
180
|
+
# at where custom validation / business rules go.
|
|
181
|
+
def extend_marker(note, hint = "", indent: " ")
|
|
182
|
+
head = "#{indent}# ─── EXTEND: #{note} "
|
|
183
|
+
head += "─" * [4, 66 - head.length].max
|
|
184
|
+
out = head + "\n"
|
|
185
|
+
out += "#{indent}# #{hint}\n" unless hint.to_s.empty?
|
|
186
|
+
out
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# Parse a --every duration ('5m', '30s', '2h', '1d', or bare seconds) ->
|
|
190
|
+
# seconds. Falls back to 60s on an empty/unparseable value so a scaffold
|
|
191
|
+
# always has a valid interval for Tina4.service(interval: ...).
|
|
192
|
+
def parse_every(every)
|
|
193
|
+
return 60 if every.nil? || every == true
|
|
194
|
+
every = every.to_s.strip.downcase
|
|
195
|
+
return 60 if every.empty?
|
|
196
|
+
units = { "s" => 1, "m" => 60, "h" => 3600, "d" => 86400 }
|
|
197
|
+
unit = every[-1]
|
|
198
|
+
if units.key?(unit)
|
|
199
|
+
[1, (every[0..-2].to_f * units[unit]).to_i].max
|
|
200
|
+
else
|
|
201
|
+
n = every.to_f
|
|
202
|
+
n.positive? ? [1, n.to_i].max : 60
|
|
203
|
+
end
|
|
204
|
+
rescue StandardError
|
|
205
|
+
60
|
|
206
|
+
end
|
|
207
|
+
|
|
86
208
|
# Parse --key value and --flag from args. Returns [flags_hash, positional_array]
|
|
87
209
|
def parse_flags(args)
|
|
88
210
|
# Boolean-only flags that never take a value argument
|
|
89
|
-
boolean_flags = %w[no-browser no-reload production managed all clear dev json]
|
|
211
|
+
boolean_flags = %w[no-browser no-reload production managed all clear dev json public no-migration once]
|
|
90
212
|
|
|
91
213
|
flags = {}
|
|
92
214
|
positional = []
|
|
@@ -299,6 +421,26 @@ module Tina4
|
|
|
299
421
|
end
|
|
300
422
|
end
|
|
301
423
|
|
|
424
|
+
# ── migrate:create ───────────────────────────────────────────────────
|
|
425
|
+
|
|
426
|
+
# Create a new timestamped migration file (UP .sql + .down.sql), matching
|
|
427
|
+
# the Python master `migrate:create`. CHEAP + database-free: it only writes
|
|
428
|
+
# files via the static Tina4::Migration.create_migration helper — no app
|
|
429
|
+
# boot, no DB connection, no tracking table. `description` is every arg
|
|
430
|
+
# joined with a space (parity with Python's `" ".join(args)`).
|
|
431
|
+
def cmd_migrate_create(argv)
|
|
432
|
+
description = (argv || []).join(" ").strip
|
|
433
|
+
if description.empty?
|
|
434
|
+
puts "Usage: tina4ruby migrate:create <description>"
|
|
435
|
+
exit 1
|
|
436
|
+
end
|
|
437
|
+
|
|
438
|
+
require_relative "log"
|
|
439
|
+
require_relative "migration"
|
|
440
|
+
path = Tina4::Migration.create_migration(description, migrations_dir: "migrations")
|
|
441
|
+
puts "Created: #{path}"
|
|
442
|
+
end
|
|
443
|
+
|
|
302
444
|
# ── migrate:status ─────────────────────────────────────────────────────
|
|
303
445
|
|
|
304
446
|
def cmd_migrate_status(_argv)
|
|
@@ -440,16 +582,304 @@ module Tina4
|
|
|
440
582
|
exit(1) if results[:failed] > 0 || results[:errors] > 0
|
|
441
583
|
end
|
|
442
584
|
|
|
585
|
+
# ── queue ─────────────────────────────────────────────────────────────
|
|
586
|
+
#
|
|
587
|
+
# Top-level `queue` command — wires straight to the real Tina4::Queue (lite
|
|
588
|
+
# /file backend by default; RabbitMQ/Kafka/MongoDB via TINA4_QUEUE_BACKEND).
|
|
589
|
+
# `stats`, `retry` and `clear` operate on the queue without starting a
|
|
590
|
+
# server; `work` runs the app's consumer for a topic. Distinct from
|
|
591
|
+
# `generate queue`, which SCAFFOLDS a consumer file. Mirrors the Python
|
|
592
|
+
# master's `queue` command.
|
|
593
|
+
#
|
|
594
|
+
# tina4ruby queue work [topic] [--once] [--poll N] [--services DIR]
|
|
595
|
+
# tina4ruby queue stats [topic] [--json]
|
|
596
|
+
# tina4ruby queue retry [topic]
|
|
597
|
+
# tina4ruby queue clear [status] [topic]
|
|
598
|
+
def cmd_queue(argv)
|
|
599
|
+
argv ||= []
|
|
600
|
+
if argv.empty?
|
|
601
|
+
puts "Usage: tina4ruby queue <work|stats|retry|clear> [options]"
|
|
602
|
+
puts " Subcommands: #{QUEUE_SUBCOMMANDS.keys.join(', ')}"
|
|
603
|
+
exit 1
|
|
604
|
+
end
|
|
605
|
+
|
|
606
|
+
sub = argv[0].downcase
|
|
607
|
+
handler = QUEUE_SUBCOMMANDS[sub]
|
|
608
|
+
if handler.nil?
|
|
609
|
+
puts "Unknown queue subcommand: #{sub}"
|
|
610
|
+
puts " Available: #{QUEUE_SUBCOMMANDS.keys.join(', ')}"
|
|
611
|
+
exit 1
|
|
612
|
+
end
|
|
613
|
+
send(handler, argv[1..])
|
|
614
|
+
end
|
|
615
|
+
|
|
616
|
+
# Load the framework classes (Queue, backends, ServiceRunner) WITHOUT
|
|
617
|
+
# booting the app (no route discovery, no server, no auto-migrate), then
|
|
618
|
+
# load .env — mirrors the Python master's _load_env() before queue ops.
|
|
619
|
+
def load_queue_runtime
|
|
620
|
+
require_relative "../tina4"
|
|
621
|
+
Tina4::Env.load_env(Dir.pwd)
|
|
622
|
+
end
|
|
623
|
+
|
|
624
|
+
# Resolve the per-job handler a consumer declares for `topic`.
|
|
625
|
+
#
|
|
626
|
+
# A consumer scaffolded by `generate queue <topic>` registers a daemon
|
|
627
|
+
# service via Tina4.service(..., topic:, handle:). When its `topic` matches,
|
|
628
|
+
# `queue work` drives that per-job `handle` callable so THIS command owns the
|
|
629
|
+
# poll loop (honouring --poll / the bounded --once drain) instead of the
|
|
630
|
+
# consumer's own endless loop. Returns the callable, or nil when no consumer
|
|
631
|
+
# in `services_dir` targets this topic. Ruby parity for the Python master's
|
|
632
|
+
# _resolve_queue_handler (which reads a module-level `service` dict).
|
|
633
|
+
def resolve_queue_handler(services_dir, topic)
|
|
634
|
+
dir = File.expand_path(services_dir, Dir.pwd)
|
|
635
|
+
return nil unless Dir.exist?(dir)
|
|
636
|
+
|
|
637
|
+
Dir.glob(File.join(dir, "*.rb")).sort.each do |file|
|
|
638
|
+
next if File.basename(file).start_with?("_")
|
|
639
|
+
begin
|
|
640
|
+
load file
|
|
641
|
+
rescue StandardError, ScriptError
|
|
642
|
+
next # a broken sibling must not sink the worker
|
|
643
|
+
end
|
|
644
|
+
end
|
|
645
|
+
|
|
646
|
+
Tina4::ServiceRunner.list.each do |svc|
|
|
647
|
+
options = svc[:options] || {}
|
|
648
|
+
handle = options[:handle]
|
|
649
|
+
return handle if options[:topic].to_s == topic.to_s && handle.respond_to?(:call)
|
|
650
|
+
end
|
|
651
|
+
nil
|
|
652
|
+
end
|
|
653
|
+
|
|
654
|
+
# Run a consumer loop that pops and processes jobs on a topic.
|
|
655
|
+
#
|
|
656
|
+
# tina4ruby queue work [topic] [--once] [--poll N] [--services DIR]
|
|
657
|
+
#
|
|
658
|
+
# Long-running by default (polls every --poll seconds, 1.0 default; Ctrl-C to
|
|
659
|
+
# stop). --once does a single-pass drain — it processes every currently
|
|
660
|
+
# available job then exits (poll interval 0). The per-job handler is resolved
|
|
661
|
+
# from the app's consumer for this topic (see #resolve_queue_handler); with no
|
|
662
|
+
# handler it drains and acks with a warning rather than inventing behaviour.
|
|
663
|
+
def queue_work(argv)
|
|
664
|
+
load_queue_runtime
|
|
665
|
+
flags, positional = parse_flags(argv)
|
|
666
|
+
topic = positional[0] || "default"
|
|
667
|
+
once = flags["once"] ? true : false
|
|
668
|
+
|
|
669
|
+
poll =
|
|
670
|
+
if once
|
|
671
|
+
0.0 # single-pass: consume() returns as soon as the topic is empty
|
|
672
|
+
else
|
|
673
|
+
poll_raw = flags["poll"].to_s.strip
|
|
674
|
+
if poll_raw.empty? || poll_raw == "true"
|
|
675
|
+
1.0
|
|
676
|
+
else
|
|
677
|
+
begin
|
|
678
|
+
Float(poll_raw)
|
|
679
|
+
rescue ArgumentError
|
|
680
|
+
1.0
|
|
681
|
+
end
|
|
682
|
+
end
|
|
683
|
+
end
|
|
684
|
+
|
|
685
|
+
services_dir = flags["services"]
|
|
686
|
+
services_dir = ENV["TINA4_SERVICE_DIR"] || "src/services" unless services_dir.is_a?(String)
|
|
687
|
+
|
|
688
|
+
handler = resolve_queue_handler(services_dir, topic)
|
|
689
|
+
queue = Tina4::Queue.new(topic: topic)
|
|
690
|
+
|
|
691
|
+
if handler.nil?
|
|
692
|
+
puts " ⚠ No consumer handler found for topic '#{topic}' in #{services_dir}."
|
|
693
|
+
puts " Scaffold one with: tina4ruby generate queue #{topic}"
|
|
694
|
+
puts " Draining (consume + ack) without processing."
|
|
695
|
+
end
|
|
696
|
+
|
|
697
|
+
mode = once ? "single-pass drain" : format("polling every %gs (Ctrl-C to stop)", poll)
|
|
698
|
+
puts " Queue worker on '#{topic}' — #{mode}..."
|
|
699
|
+
|
|
700
|
+
processed = 0
|
|
701
|
+
failed = 0
|
|
702
|
+
begin
|
|
703
|
+
queue.consume(topic, poll_interval: poll) do |job|
|
|
704
|
+
begin
|
|
705
|
+
handler.call(job.payload) unless handler.nil?
|
|
706
|
+
job.complete
|
|
707
|
+
processed += 1
|
|
708
|
+
rescue StandardError, NotImplementedError => e
|
|
709
|
+
# A bad job nacks (retry / dead-letter), the worker lives on —
|
|
710
|
+
# parity with the Python master's `except Exception`. NotImplemented-
|
|
711
|
+
# Error is a ScriptError (not a StandardError) in Ruby, so name it
|
|
712
|
+
# explicitly: an UNFILLED `generate queue` scaffold stub raises it and
|
|
713
|
+
# must nack the job, not crash the worker. Interrupt (Ctrl-C) is
|
|
714
|
+
# neither, so it still propagates to the outer stop handler.
|
|
715
|
+
job.fail(e.message)
|
|
716
|
+
failed += 1
|
|
717
|
+
end
|
|
718
|
+
end
|
|
719
|
+
rescue Interrupt
|
|
720
|
+
puts "\n Interrupted — stopping worker."
|
|
721
|
+
end
|
|
722
|
+
|
|
723
|
+
puts " Processed #{processed} job(s), #{failed} failed on '#{topic}'."
|
|
724
|
+
end
|
|
725
|
+
|
|
726
|
+
# Print pending / in-flight / failed / dead-letter / completed counts.
|
|
727
|
+
#
|
|
728
|
+
# tina4ruby queue stats [topic] [--json]
|
|
729
|
+
def queue_stats(argv)
|
|
730
|
+
require "json"
|
|
731
|
+
load_queue_runtime
|
|
732
|
+
flags, positional = parse_flags(argv)
|
|
733
|
+
topic = positional[0] || "default"
|
|
734
|
+
|
|
735
|
+
queue = Tina4::Queue.new(topic: topic)
|
|
736
|
+
stats = {
|
|
737
|
+
"topic" => topic,
|
|
738
|
+
"pending" => queue.size(status: "pending"), # waiting to run
|
|
739
|
+
"reserved" => queue.size(status: "reserved"), # popped, not yet acked
|
|
740
|
+
"failed" => queue.failed.length, # failed once, still retrying
|
|
741
|
+
"dead" => queue.size(status: "dead"), # exhausted retries (dead-letter)
|
|
742
|
+
"completed" => queue.size(status: "completed"), # terminal-completed (0 on file backend)
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
if flags.key?("json")
|
|
746
|
+
puts JSON.pretty_generate(stats)
|
|
747
|
+
return
|
|
748
|
+
end
|
|
749
|
+
|
|
750
|
+
puts
|
|
751
|
+
puts " Queue '#{topic}'"
|
|
752
|
+
puts " pending #{stats['pending']}"
|
|
753
|
+
puts " reserved #{stats['reserved']} (in-flight)"
|
|
754
|
+
puts " failed #{stats['failed']} (retrying)"
|
|
755
|
+
puts " dead #{stats['dead']} (dead-letter)"
|
|
756
|
+
puts " completed #{stats['completed']}"
|
|
757
|
+
puts
|
|
758
|
+
end
|
|
759
|
+
|
|
760
|
+
# Re-queue failed and dead-letter jobs so they run again.
|
|
761
|
+
#
|
|
762
|
+
# tina4ruby queue retry [topic]
|
|
763
|
+
#
|
|
764
|
+
# Revives every dead-letter job (manual override, regardless of attempt
|
|
765
|
+
# count) and re-queues any failed-but-still-eligible jobs.
|
|
766
|
+
def queue_retry(argv)
|
|
767
|
+
load_queue_runtime
|
|
768
|
+
_flags, positional = parse_flags(argv)
|
|
769
|
+
topic = positional[0] || "default"
|
|
770
|
+
|
|
771
|
+
queue = Tina4::Queue.new(topic: topic)
|
|
772
|
+
|
|
773
|
+
# max_retries: 0 => every job in the dead-letter store, whatever its
|
|
774
|
+
# attempt count (matches what stats/size("dead") reports).
|
|
775
|
+
dead = queue.dead_letters(max_retries: 0)
|
|
776
|
+
revived = dead.count { |job| queue.retry(queue_job_id(job)) }
|
|
777
|
+
# Any failed-but-retryable jobs still under the limit.
|
|
778
|
+
requeued = queue.retry_failed
|
|
779
|
+
|
|
780
|
+
total = revived + requeued
|
|
781
|
+
puts " Re-queued #{total} job(s) on '#{topic}' (#{revived} dead-letter, #{requeued} failed)."
|
|
782
|
+
end
|
|
783
|
+
|
|
784
|
+
# Purge jobs of a given status (default: completed).
|
|
785
|
+
#
|
|
786
|
+
# tina4ruby queue clear [status] [topic]
|
|
787
|
+
#
|
|
788
|
+
# status is one of pending / reserved / completed / failed / dead. The
|
|
789
|
+
# default 'completed' clears finished jobs; pass e.g. `queue clear pending`
|
|
790
|
+
# or `queue clear dead orders` to purge another status / topic.
|
|
791
|
+
def queue_clear(argv)
|
|
792
|
+
load_queue_runtime
|
|
793
|
+
_flags, positional = parse_flags(argv)
|
|
794
|
+
status = positional[0] || "completed"
|
|
795
|
+
topic = positional[1] || "default"
|
|
796
|
+
|
|
797
|
+
queue = Tina4::Queue.new(topic: topic)
|
|
798
|
+
removed = queue.purge(status)
|
|
799
|
+
puts " Cleared #{removed} '#{status}' job(s) from '#{topic}'."
|
|
800
|
+
end
|
|
801
|
+
|
|
802
|
+
# Extract a job id from either a raw backend Hash or a Job object, so
|
|
803
|
+
# `queue retry` works regardless of what a backend's dead_letters returns.
|
|
804
|
+
def queue_job_id(job)
|
|
805
|
+
return job.id if job.respond_to?(:id)
|
|
806
|
+
job["id"] || job[:id]
|
|
807
|
+
end
|
|
808
|
+
|
|
809
|
+
# ── build ─────────────────────────────────────────────────────────────
|
|
810
|
+
#
|
|
811
|
+
# Build the deployable Docker image for this Tina4 app. A Tina4 app deploys
|
|
812
|
+
# as a container (`tina4ruby init` scaffolds a Dockerfile), so `build`
|
|
813
|
+
# produces THAT artifact — it shells out to the `docker` CLI (no new gem)
|
|
814
|
+
# and fails loud with guidance when there is no Dockerfile or docker is not
|
|
815
|
+
# on PATH. Mirrors the Python master's `build`.
|
|
816
|
+
#
|
|
817
|
+
# tina4ruby build # docker build -t <dir>:latest .
|
|
818
|
+
# tina4ruby build --tag myapp:1.2 # explicit image tag
|
|
819
|
+
# tina4ruby build --file docker/Dockerfile
|
|
820
|
+
def cmd_build(argv)
|
|
821
|
+
flags, _positional = parse_flags(argv)
|
|
822
|
+
|
|
823
|
+
tag = flags["tag"]
|
|
824
|
+
unless tag.is_a?(String) && !tag.empty?
|
|
825
|
+
# Default tag: <project-folder>:latest, lower-cased (docker repo names
|
|
826
|
+
# must be lowercase). Fall back to a sane name for an unnamed cwd.
|
|
827
|
+
base = File.basename(Dir.pwd).downcase
|
|
828
|
+
base = "tina4app" if base.empty?
|
|
829
|
+
tag = "#{base}:latest"
|
|
830
|
+
end
|
|
831
|
+
|
|
832
|
+
dockerfile = flags["file"].is_a?(String) ? flags["file"] : "Dockerfile"
|
|
833
|
+
unless File.file?(dockerfile)
|
|
834
|
+
puts " ✗ No #{dockerfile} found."
|
|
835
|
+
puts " A Tina4 app deploys as a container. Scaffold a Dockerfile first:"
|
|
836
|
+
puts " tina4 deploy docker (or: tina4ruby init)"
|
|
837
|
+
exit 1
|
|
838
|
+
end
|
|
839
|
+
|
|
840
|
+
docker = which_executable("docker")
|
|
841
|
+
if docker.nil?
|
|
842
|
+
puts " ✗ docker was not found on PATH."
|
|
843
|
+
puts " Install Docker to build the deployable image, or build manually:"
|
|
844
|
+
puts " docker build -t #{tag} -f #{dockerfile} ."
|
|
845
|
+
exit 1
|
|
846
|
+
end
|
|
847
|
+
|
|
848
|
+
puts " Building image #{tag} from #{dockerfile} ..."
|
|
849
|
+
ok = system(docker, "build", "-t", tag, "-f", dockerfile, ".")
|
|
850
|
+
unless ok
|
|
851
|
+
code = ($?&.exitstatus) || 1
|
|
852
|
+
puts " ✗ docker build failed (exit #{code})"
|
|
853
|
+
exit code
|
|
854
|
+
end
|
|
855
|
+
puts " ✓ Built image #{tag}"
|
|
856
|
+
puts " Run: docker run -p 7147:7147 #{tag}"
|
|
857
|
+
end
|
|
858
|
+
|
|
859
|
+
# Locate an executable on PATH — the stdlib-only equivalent of Python's
|
|
860
|
+
# shutil.which (no new gem). Honours PATHEXT on Windows.
|
|
861
|
+
def which_executable(cmd)
|
|
862
|
+
exts = ENV["PATHEXT"] ? ENV["PATHEXT"].split(File::PATH_SEPARATOR) : [""]
|
|
863
|
+
ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).each do |dir|
|
|
864
|
+
next if dir.empty?
|
|
865
|
+
exts.each do |ext|
|
|
866
|
+
candidate = File.join(dir, "#{cmd}#{ext}")
|
|
867
|
+
return candidate if File.executable?(candidate) && !File.directory?(candidate)
|
|
868
|
+
end
|
|
869
|
+
end
|
|
870
|
+
nil
|
|
871
|
+
end
|
|
872
|
+
|
|
443
873
|
# ── version ───────────────────────────────────────────────────────────
|
|
444
874
|
|
|
445
|
-
def cmd_version
|
|
875
|
+
def cmd_version(_argv = nil)
|
|
446
876
|
require_relative "version"
|
|
447
877
|
puts "Tina4 Ruby v#{Tina4::VERSION}"
|
|
448
878
|
end
|
|
449
879
|
|
|
450
880
|
# ── routes ────────────────────────────────────────────────────────────
|
|
451
881
|
|
|
452
|
-
def cmd_routes
|
|
882
|
+
def cmd_routes(_argv = nil)
|
|
453
883
|
require_relative "../tina4"
|
|
454
884
|
Tina4.initialize!(Dir.pwd)
|
|
455
885
|
load_routes(Dir.pwd)
|
|
@@ -466,7 +896,7 @@ module Tina4
|
|
|
466
896
|
|
|
467
897
|
# ── console ───────────────────────────────────────────────────────────
|
|
468
898
|
|
|
469
|
-
def cmd_console
|
|
899
|
+
def cmd_console(_argv = nil)
|
|
470
900
|
require_relative "../tina4"
|
|
471
901
|
Tina4.initialize!(Dir.pwd)
|
|
472
902
|
load_routes(Dir.pwd)
|
|
@@ -595,11 +1025,14 @@ module Tina4
|
|
|
595
1025
|
|
|
596
1026
|
def cmd_generate(argv)
|
|
597
1027
|
what = argv.shift
|
|
1028
|
+
all = GENERATORS.keys.join(", ") # single source: the GENERATORS registry
|
|
598
1029
|
|
|
599
1030
|
unless what
|
|
600
1031
|
puts "Usage: tina4ruby generate <what> <name> [options]"
|
|
601
|
-
puts " Generators:
|
|
1032
|
+
puts " Generators: #{all}"
|
|
602
1033
|
puts ' Options: --fields "name:string,price:float" --model ModelName'
|
|
1034
|
+
puts ' --public open a route'"'"'s writes (default: secure)'
|
|
1035
|
+
puts ' --every 5m | --cron "..." service schedule'
|
|
603
1036
|
exit 1
|
|
604
1037
|
end
|
|
605
1038
|
|
|
@@ -615,26 +1048,21 @@ module Tina4
|
|
|
615
1048
|
name = no_name_generators.include?(what) ? "" : argv.shift
|
|
616
1049
|
flags, _positional = parse_flags(argv)
|
|
617
1050
|
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
when "middleware" then generate_middleware(name, flags)
|
|
624
|
-
when "test" then generate_test(name, flags)
|
|
625
|
-
when "form" then generate_form(name, flags)
|
|
626
|
-
when "view" then generate_view(name, flags)
|
|
627
|
-
when "auth" then generate_auth(name, flags)
|
|
1051
|
+
# Dispatch from the GENERATORS registry (single source of truth for the
|
|
1052
|
+
# generate subcommands; also feeds #cmd_help and the manifest).
|
|
1053
|
+
gen_spec = GENERATORS[what]
|
|
1054
|
+
if gen_spec
|
|
1055
|
+
send(gen_spec[:handler], name, flags)
|
|
628
1056
|
else
|
|
629
1057
|
puts "Unknown generator: #{what}"
|
|
630
|
-
puts " Available:
|
|
1058
|
+
puts " Available: #{all}"
|
|
631
1059
|
exit 1
|
|
632
1060
|
end
|
|
633
1061
|
end
|
|
634
1062
|
|
|
635
1063
|
# ── Generator: model ─────────────────────────────────────────────────
|
|
636
1064
|
|
|
637
|
-
def generate_model(name, flags)
|
|
1065
|
+
def generate_model(name, flags, emit_test: true)
|
|
638
1066
|
fields = parse_fields(flags["fields"])
|
|
639
1067
|
table = to_table_name(name)
|
|
640
1068
|
snake = to_snake_case(name)
|
|
@@ -671,18 +1099,41 @@ module Tina4
|
|
|
671
1099
|
File.write(path, content)
|
|
672
1100
|
puts " Created #{path}"
|
|
673
1101
|
|
|
674
|
-
# Generate matching migration (unless --no-migration)
|
|
1102
|
+
# Generate matching migration (unless --no-migration). The model's own
|
|
1103
|
+
# co-emitted spec proves the schema through the real ORM, so the migration
|
|
1104
|
+
# sub-call does NOT also co-emit a migration spec (emit_test: false).
|
|
675
1105
|
unless flags["no-migration"]
|
|
676
|
-
generate_migration("create_#{table}", flags, fields_override: fields,
|
|
1106
|
+
generate_migration("create_#{table}", flags, fields_override: fields,
|
|
1107
|
+
table_override: table, emit_test: false)
|
|
677
1108
|
end
|
|
1109
|
+
|
|
1110
|
+
# Co-emit a real SQLite roundtrip spec next to the model.
|
|
1111
|
+
emit_model_test(name, table, fields) if emit_test
|
|
678
1112
|
end
|
|
679
1113
|
|
|
680
1114
|
# ── Generator: route ─────────────────────────────────────────────────
|
|
681
1115
|
|
|
682
|
-
|
|
1116
|
+
# Generate a CRUD route file — SECURE BY DEFAULT.
|
|
1117
|
+
#
|
|
1118
|
+
# tina4ruby generate route products
|
|
1119
|
+
# tina4ruby generate route products --model Product
|
|
1120
|
+
# tina4ruby generate route products --model Product --public # open writes
|
|
1121
|
+
#
|
|
1122
|
+
# Writes (POST/PUT/DELETE) are Bearer-token-gated by default: the router sets
|
|
1123
|
+
# auth_required on POST/PUT/PATCH/DELETE (lib/tina4/router.rb:24) and
|
|
1124
|
+
# enforce_route_auth 401s a tokenless write (lib/tina4/rack_app.rb:1129).
|
|
1125
|
+
# Reads (GET) are public by default, so no opt-out is emitted for them.
|
|
1126
|
+
# `--public` chains `.no_auth` on the write handlers as the explicit opt-out
|
|
1127
|
+
# — mirroring AutoCrud's `post_route.no_auth if is_public`
|
|
1128
|
+
# (lib/tina4/auto_crud.rb:169). The generated routes register through
|
|
1129
|
+
# `Tina4::Router.get/post/...` (no bearer auth_handler attached), so `.no_auth`
|
|
1130
|
+
# genuinely opens the write on BOTH the live server and the TestClient — the
|
|
1131
|
+
# same registration path AutoCrud uses.
|
|
1132
|
+
def generate_route(name, flags, emit_test: true)
|
|
683
1133
|
route_path = name.sub(%r{^/}, "")
|
|
684
1134
|
singular = route_path.end_with?("s") ? route_path[0..-2] : route_path
|
|
685
1135
|
model = flags["model"]
|
|
1136
|
+
public_writes = flags["public"] ? true : false
|
|
686
1137
|
|
|
687
1138
|
dir = "src/routes"
|
|
688
1139
|
FileUtils.mkdir_p(dir)
|
|
@@ -692,94 +1143,165 @@ module Tina4
|
|
|
692
1143
|
return
|
|
693
1144
|
end
|
|
694
1145
|
|
|
1146
|
+
# `.no_auth` is chained on the WRITE handlers only when writes go public.
|
|
1147
|
+
no_auth = public_writes ? ".no_auth" : ""
|
|
1148
|
+
write_doc =
|
|
1149
|
+
if public_writes
|
|
1150
|
+
"Public (--public): no token required."
|
|
1151
|
+
else
|
|
1152
|
+
"Secure by default: requires a Bearer token (use --public to open)."
|
|
1153
|
+
end
|
|
1154
|
+
|
|
695
1155
|
if model
|
|
696
1156
|
model_snake = to_snake_case(model)
|
|
1157
|
+
# WORKING code (the boilerplate IS the feature) + EXTEND markers at the
|
|
1158
|
+
# natural extension points (no NotImplementedError).
|
|
1159
|
+
ext_create = extend_marker(
|
|
1160
|
+
"validate / business rules before persist",
|
|
1161
|
+
'e.g. reject invalid input; ground: tina4_context("validate before create", "ruby")'
|
|
1162
|
+
)
|
|
1163
|
+
ext_update = extend_marker(
|
|
1164
|
+
"guard which fields / who may update",
|
|
1165
|
+
'e.g. enforce ownership; ground: tina4_context("authorize update", "ruby")'
|
|
1166
|
+
)
|
|
697
1167
|
content = <<~RUBY
|
|
698
1168
|
require_relative "../orm/#{model_snake}"
|
|
699
1169
|
|
|
700
|
-
|
|
701
|
-
|
|
1170
|
+
# List all #{route_path} with pagination — public read (GET is ungated).
|
|
1171
|
+
Tina4::Router.get "/api/#{route_path}" do |request, response|
|
|
702
1172
|
page = (request.params["page"] || 1).to_i
|
|
703
1173
|
per_page = (request.params["per_page"] || 20).to_i
|
|
704
1174
|
offset = (page - 1) * per_page
|
|
705
|
-
|
|
706
|
-
response.json({ data:
|
|
1175
|
+
records = #{model}.all(limit: per_page, offset: offset)
|
|
1176
|
+
response.json({ data: records.map(&:to_h), count: #{model}.count, page: page, per_page: per_page })
|
|
707
1177
|
end
|
|
708
1178
|
|
|
709
|
-
|
|
710
|
-
|
|
1179
|
+
# Get a single #{singular} by ID — public read.
|
|
1180
|
+
Tina4::Router.get "/api/#{route_path}/{id:int}" do |request, response|
|
|
711
1181
|
item = #{model}.find(request.params["id"])
|
|
712
|
-
if item.nil?
|
|
713
|
-
|
|
714
|
-
else
|
|
715
|
-
response.json(item.to_h)
|
|
716
|
-
end
|
|
1182
|
+
next response.json({ error: "Not found" }, 404) if item.nil?
|
|
1183
|
+
response.json(item.to_h)
|
|
717
1184
|
end
|
|
718
1185
|
|
|
719
|
-
|
|
720
|
-
|
|
1186
|
+
# Create a new #{singular}. #{write_doc}
|
|
1187
|
+
Tina4::Router.post "/api/#{route_path}" do |request, response|
|
|
1188
|
+
#{ext_create.chomp}
|
|
721
1189
|
item = #{model}.create(request.body)
|
|
722
1190
|
response.json(item.to_h, 201)
|
|
723
|
-
end
|
|
1191
|
+
end#{no_auth}
|
|
724
1192
|
|
|
725
|
-
|
|
726
|
-
|
|
1193
|
+
# Update a #{singular} by ID. #{write_doc}
|
|
1194
|
+
Tina4::Router.put "/api/#{route_path}/{id:int}" do |request, response|
|
|
727
1195
|
item = #{model}.find(request.params["id"])
|
|
728
|
-
if item.nil?
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
item.send(setter, value) if item.respond_to?(setter)
|
|
735
|
-
end
|
|
736
|
-
item.save
|
|
737
|
-
response.json(item.to_h)
|
|
1196
|
+
next response.json({ error: "Not found" }, 404) if item.nil?
|
|
1197
|
+
#{ext_update.chomp}
|
|
1198
|
+
request.body.each do |key, value|
|
|
1199
|
+
next if key.to_s == "id"
|
|
1200
|
+
setter = "#{'#'}{key}="
|
|
1201
|
+
item.send(setter, value) if item.respond_to?(setter)
|
|
738
1202
|
end
|
|
739
|
-
|
|
1203
|
+
item.save
|
|
1204
|
+
response.json(item.to_h)
|
|
1205
|
+
end#{no_auth}
|
|
740
1206
|
|
|
741
|
-
|
|
742
|
-
|
|
1207
|
+
# Delete a #{singular} by ID. #{write_doc}
|
|
1208
|
+
Tina4::Router.delete "/api/#{route_path}/{id:int}" do |request, response|
|
|
743
1209
|
item = #{model}.find(request.params["id"])
|
|
744
|
-
if item.nil?
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
response.json(nil, 204)
|
|
749
|
-
end
|
|
750
|
-
end
|
|
1210
|
+
next response.json({ error: "Not found" }, 404) if item.nil?
|
|
1211
|
+
item.delete
|
|
1212
|
+
response.json(nil, 204)
|
|
1213
|
+
end#{no_auth}
|
|
751
1214
|
RUBY
|
|
752
1215
|
else
|
|
1216
|
+
# CUSTOM route — no model. Every handler body is a LOGIC-shaped stub:
|
|
1217
|
+
# AI-FILL fill-spec + raise NotImplementedError (fails loud until filled).
|
|
1218
|
+
m = singular.split("_").map(&:capitalize).join # PascalCase hint
|
|
1219
|
+
b_list = ai_fill(
|
|
1220
|
+
"list_#{route_path}",
|
|
1221
|
+
"return the #{route_path} collection (add pagination if it grows)",
|
|
1222
|
+
"#{m}.all(limit:, offset:) or #{m}.where(sql, params) (require_relative \"../orm/#{to_snake_case(m)}\")",
|
|
1223
|
+
"list_#{route_path}: query and return the records",
|
|
1224
|
+
ret: 'response.json({ data: rows.map(&:to_h) })',
|
|
1225
|
+
ground: 'tina4_context("list ORM records with pagination", "ruby")'
|
|
1226
|
+
)
|
|
1227
|
+
b_get = ai_fill(
|
|
1228
|
+
"get_#{singular}",
|
|
1229
|
+
"fetch one #{singular} by id",
|
|
1230
|
+
"#{m}.find(request.params[\"id\"]) (require_relative \"../orm/#{to_snake_case(m)}\")",
|
|
1231
|
+
"get_#{singular}: fetch by id or 404",
|
|
1232
|
+
given: 'request.params["id"] -> Integer',
|
|
1233
|
+
ret: 'response.json(item.to_h) or response.json({ error: "Not found" }, 404)',
|
|
1234
|
+
ground: 'tina4_context("find ORM record by id", "ruby")'
|
|
1235
|
+
)
|
|
1236
|
+
b_create = ai_fill(
|
|
1237
|
+
"create_#{singular}",
|
|
1238
|
+
"validate the body and persist a new #{singular}",
|
|
1239
|
+
"#{m}.create(request.body) (require_relative \"../orm/#{to_snake_case(m)}\")",
|
|
1240
|
+
"create_#{singular}: persist and return the new record",
|
|
1241
|
+
given: "request.body -> Hash of fields",
|
|
1242
|
+
ret: "response.json(item.to_h, 201)",
|
|
1243
|
+
ground: 'tina4_context("create ORM record and return 201", "ruby")'
|
|
1244
|
+
)
|
|
1245
|
+
b_update = ai_fill(
|
|
1246
|
+
"update_#{singular}",
|
|
1247
|
+
"load, mutate and save an existing #{singular}",
|
|
1248
|
+
"#{m}.find(request.params[\"id\"]) then set fields and item.save",
|
|
1249
|
+
"update_#{singular}: apply changes and return the record",
|
|
1250
|
+
given: 'request.params["id"] -> Integer; request.body -> changed fields',
|
|
1251
|
+
ret: "response.json(item.to_h) or 404",
|
|
1252
|
+
ground: 'tina4_context("update ORM record", "ruby")'
|
|
1253
|
+
)
|
|
1254
|
+
b_delete = ai_fill(
|
|
1255
|
+
"delete_#{singular}",
|
|
1256
|
+
"delete a #{singular} by id",
|
|
1257
|
+
"#{m}.find(request.params[\"id\"]) then item.delete",
|
|
1258
|
+
"delete_#{singular}: delete and return 204",
|
|
1259
|
+
given: 'request.params["id"] -> Integer',
|
|
1260
|
+
ret: "response.json(nil, 204) or 404",
|
|
1261
|
+
ground: 'tina4_context("delete ORM record", "ruby")'
|
|
1262
|
+
)
|
|
753
1263
|
content = <<~RUBY
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
1264
|
+
# List all #{route_path} — public read (GET is ungated).
|
|
1265
|
+
Tina4::Router.get "/api/#{route_path}" do |request, response|
|
|
1266
|
+
#{b_list.chomp}
|
|
757
1267
|
end
|
|
758
1268
|
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
1269
|
+
# Get a single #{singular} — public read.
|
|
1270
|
+
Tina4::Router.get "/api/#{route_path}/{id:int}" do |request, response|
|
|
1271
|
+
#{b_get.chomp}
|
|
762
1272
|
end
|
|
763
1273
|
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
end
|
|
1274
|
+
# Create a new #{singular}. #{write_doc}
|
|
1275
|
+
Tina4::Router.post "/api/#{route_path}" do |request, response|
|
|
1276
|
+
#{b_create.chomp}
|
|
1277
|
+
end#{no_auth}
|
|
768
1278
|
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
end
|
|
1279
|
+
# Update a #{singular}. #{write_doc}
|
|
1280
|
+
Tina4::Router.put "/api/#{route_path}/{id:int}" do |request, response|
|
|
1281
|
+
#{b_update.chomp}
|
|
1282
|
+
end#{no_auth}
|
|
773
1283
|
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
end
|
|
1284
|
+
# Delete a #{singular}. #{write_doc}
|
|
1285
|
+
Tina4::Router.delete "/api/#{route_path}/{id:int}" do |request, response|
|
|
1286
|
+
#{b_delete.chomp}
|
|
1287
|
+
end#{no_auth}
|
|
778
1288
|
RUBY
|
|
779
1289
|
end
|
|
780
1290
|
|
|
781
1291
|
File.write(path, content)
|
|
782
1292
|
puts " Created #{path}"
|
|
1293
|
+
|
|
1294
|
+
# Co-emit a real spec. A --model route is working code → reuse the
|
|
1295
|
+
# secure-gate behavioural spec (reads public, writes gated) via
|
|
1296
|
+
# generate_test; a no-model route's handlers are loud stubs → the
|
|
1297
|
+
# Router-registration + live-stub spec.
|
|
1298
|
+
if emit_test
|
|
1299
|
+
if model
|
|
1300
|
+
generate_test(route_path, { "model" => model, "secure_writes" => true, "public" => public_writes })
|
|
1301
|
+
else
|
|
1302
|
+
emit_route_stub_test(route_path)
|
|
1303
|
+
end
|
|
1304
|
+
end
|
|
783
1305
|
end
|
|
784
1306
|
|
|
785
1307
|
# ── Generator: crud ──────────────────────────────────────────────────
|
|
@@ -787,14 +1309,18 @@ module Tina4
|
|
|
787
1309
|
def generate_crud(name, flags)
|
|
788
1310
|
table = to_table_name(name)
|
|
789
1311
|
route_name = "#{table}s"
|
|
1312
|
+
is_public = flags["public"] ? true : false
|
|
790
1313
|
|
|
791
1314
|
puts "\n Generating CRUD for #{name}...\n"
|
|
792
1315
|
|
|
793
|
-
# 1. Model + migration
|
|
794
|
-
|
|
1316
|
+
# 1. Model + migration (emit_test: false — crud emits its own broader gate
|
|
1317
|
+
# spec at step 5, so the sub-generators stay quiet to avoid double-emit).
|
|
1318
|
+
generate_model(name, flags, emit_test: false)
|
|
795
1319
|
|
|
796
|
-
# 2. Routes with model
|
|
797
|
-
|
|
1320
|
+
# 2. Routes with model — secure-by-default; thread --public through so
|
|
1321
|
+
# `generate crud X --public` opens the writes (mirrors AutoCrud public:).
|
|
1322
|
+
# emit_test: false — the crud gate spec at step 5 targets the same file.
|
|
1323
|
+
generate_route(route_name, { "model" => name, "public" => is_public }, emit_test: false)
|
|
798
1324
|
|
|
799
1325
|
# 3. Form
|
|
800
1326
|
generate_form(name, flags)
|
|
@@ -802,8 +1328,8 @@ module Tina4
|
|
|
802
1328
|
# 4. View (list + detail)
|
|
803
1329
|
generate_view(name, flags)
|
|
804
1330
|
|
|
805
|
-
# 5. Test
|
|
806
|
-
generate_test(route_name, { "model" => name })
|
|
1331
|
+
# 5. Test — secure-by-default gate test (behavioural, real TestClient).
|
|
1332
|
+
generate_test(route_name, { "model" => name, "secure_writes" => true, "public" => is_public })
|
|
807
1333
|
|
|
808
1334
|
puts "\n CRUD generation complete for #{name}."
|
|
809
1335
|
puts " Run: tina4ruby migrate"
|
|
@@ -812,7 +1338,7 @@ module Tina4
|
|
|
812
1338
|
|
|
813
1339
|
# ── Generator: migration ─────────────────────────────────────────────
|
|
814
1340
|
|
|
815
|
-
def generate_migration(name, flags = {}, fields_override: nil, table_override: nil)
|
|
1341
|
+
def generate_migration(name, flags = {}, fields_override: nil, table_override: nil, emit_test: true)
|
|
816
1342
|
now = Time.now
|
|
817
1343
|
timestamp = now.strftime("%Y%m%d%H%M%S")
|
|
818
1344
|
dir = "migrations"
|
|
@@ -849,22 +1375,24 @@ module Tina4
|
|
|
849
1375
|
down_sql = "-- Write your DOWN rollback SQL here\n-- Example: ALTER TABLE #{table} DROP COLUMN new_col;"
|
|
850
1376
|
end
|
|
851
1377
|
|
|
1378
|
+
# The main .sql holds ONLY the UP migration. The runner executes the WHOLE
|
|
1379
|
+
# file (comments stripped), so embedding the DOWN here would CREATE then
|
|
1380
|
+
# immediately DROP the table — the migration would silently no-op. The
|
|
1381
|
+
# rollback SQL lives solely in the sibling .down.sql. Matches the Python
|
|
1382
|
+
# master (tina4_python/cli/__init__.py).
|
|
852
1383
|
content = <<~SQL
|
|
853
1384
|
-- Migration: #{name}
|
|
854
1385
|
-- Created: #{now.strftime("%Y-%m-%d %H:%M:%S")}
|
|
855
1386
|
|
|
856
|
-
-- UP
|
|
857
1387
|
#{up_sql}
|
|
858
|
-
|
|
859
|
-
-- DOWN
|
|
860
|
-
#{down_sql}
|
|
861
1388
|
SQL
|
|
862
1389
|
|
|
863
1390
|
File.write(path, content)
|
|
864
1391
|
puts " Created #{path}"
|
|
865
1392
|
|
|
866
1393
|
# Also create .down.sql for the migration runner
|
|
867
|
-
|
|
1394
|
+
down_filename = "#{timestamp}_#{name}.down.sql"
|
|
1395
|
+
down_path = File.join(dir, down_filename)
|
|
868
1396
|
down_content = <<~SQL
|
|
869
1397
|
-- Rollback: #{name}
|
|
870
1398
|
-- Created: #{now.strftime("%Y-%m-%d %H:%M:%S")}
|
|
@@ -874,6 +1402,10 @@ module Tina4
|
|
|
874
1402
|
|
|
875
1403
|
File.write(down_path, down_content)
|
|
876
1404
|
puts " Created #{down_path}"
|
|
1405
|
+
|
|
1406
|
+
# Co-emit a real apply-UP/DOWN spec — only for CREATE migrations, whose UP
|
|
1407
|
+
# SQL is real DDL to assert against (a placeholder ALTER has nothing yet).
|
|
1408
|
+
emit_migration_test(name, table, filename, down_filename) if emit_test && is_create
|
|
877
1409
|
end
|
|
878
1410
|
|
|
879
1411
|
# ── Generator: middleware ────────────────────────────────────────────
|
|
@@ -902,7 +1434,7 @@ module Tina4
|
|
|
902
1434
|
# Runs before the route handler.
|
|
903
1435
|
# Return [request, response] to continue, or
|
|
904
1436
|
# return [request, response.json({ error: "Unauthorized" }, 401)] to block.
|
|
905
|
-
Tina4::Log.info("#{name}: \#{request.
|
|
1437
|
+
Tina4::Log.info("#{name}: \#{request.method} \#{request.path}")
|
|
906
1438
|
[request, response]
|
|
907
1439
|
end
|
|
908
1440
|
|
|
@@ -915,6 +1447,10 @@ module Tina4
|
|
|
915
1447
|
|
|
916
1448
|
File.write(path, content)
|
|
917
1449
|
puts " Created #{path}"
|
|
1450
|
+
|
|
1451
|
+
# Co-emit a real dispatch spec (drives the scaffold through the real
|
|
1452
|
+
# server middleware dispatch — Tina4::Middleware.run_before/run_after).
|
|
1453
|
+
emit_middleware_test(name, snake)
|
|
918
1454
|
end
|
|
919
1455
|
|
|
920
1456
|
# ── Generator: test ──────────────────────────────────────────────────
|
|
@@ -924,11 +1460,81 @@ module Tina4
|
|
|
924
1460
|
snake = to_snake_case(name)
|
|
925
1461
|
singular = snake.end_with?("s") ? snake[0..-2] : snake
|
|
926
1462
|
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
1463
|
+
# Secure-by-default CRUD spec (emitted by `generate crud`): proves the gate
|
|
1464
|
+
# by BEHAVIOR through the real Tina4::TestClient — reads public, writes
|
|
1465
|
+
# gated — instead of assuming an anonymous create returns 201. Grounded on
|
|
1466
|
+
# spec/test_client_auth_spec.rb (real Router, real enforce_route_auth, real
|
|
1467
|
+
# JWT via Tina4::Auth.get_token / valid_token signed with real RSA keys).
|
|
1468
|
+
if model && flags["secure_writes"]
|
|
1469
|
+
model_snake = to_snake_case(model)
|
|
1470
|
+
posture = flags["public"] ? "open (--public)" : "gated"
|
|
1471
|
+
write_examples =
|
|
1472
|
+
if flags["public"]
|
|
1473
|
+
<<~RUBY.chomp
|
|
1474
|
+
it "allows an anonymous POST (201) — --public opened the write" do
|
|
1475
|
+
res = client.post("/api/#{snake}", json: { name: "test" })
|
|
1476
|
+
expect(res.status).to eq(201)
|
|
1477
|
+
end
|
|
1478
|
+
RUBY
|
|
1479
|
+
else
|
|
1480
|
+
<<~RUBY.chomp
|
|
1481
|
+
it "rejects a tokenless POST with 401 (secure by default)" do
|
|
1482
|
+
expect(client.post("/api/#{snake}", json: { name: "test" }).status).to eq(401)
|
|
1483
|
+
end
|
|
1484
|
+
|
|
1485
|
+
it "creates with a valid Bearer token (201)" do
|
|
1486
|
+
token = Tina4::Auth.get_token({ "user_id" => 1 })
|
|
1487
|
+
res = client.post("/api/#{snake}", json: { name: "test" },
|
|
1488
|
+
headers: { "Authorization" => "Bearer #{'#'}{token}" })
|
|
1489
|
+
expect(res.status).to eq(201)
|
|
1490
|
+
end
|
|
1491
|
+
RUBY
|
|
1492
|
+
end
|
|
1493
|
+
|
|
1494
|
+
content = <<~RUBY
|
|
1495
|
+
# frozen_string_literal: true
|
|
1496
|
+
#
|
|
1497
|
+
# #{model} CRUD gate — reads public, writes #{posture} (secure by default).
|
|
1498
|
+
#
|
|
1499
|
+
# Real end-to-end via Tina4::TestClient: NO mocks — real Router, real auth
|
|
1500
|
+
# gate (Tina4::RackApp.enforce_route_auth), real JWT. A real SQLite DB +
|
|
1501
|
+
# table is bound in before(:each), so the create path is exercised for real.
|
|
1502
|
+
require "spec_helper"
|
|
1503
|
+
require "tmpdir"
|
|
1504
|
+
require_relative "../src/orm/#{model_snake}"
|
|
1505
|
+
|
|
1506
|
+
RSpec.describe "#{model} CRUD (reads public, writes #{posture})" do
|
|
1507
|
+
let(:client) { Tina4::TestClient.new }
|
|
1508
|
+
|
|
1509
|
+
before(:each) do
|
|
1510
|
+
@dir = Dir.mktmpdir("#{snake}_crud")
|
|
1511
|
+
# Fresh RSA key material so get_token / valid_token agree.
|
|
1512
|
+
Tina4::Auth.instance_variable_set(:@private_key, nil)
|
|
1513
|
+
Tina4::Auth.instance_variable_set(:@public_key, nil)
|
|
1514
|
+
Tina4::Auth.instance_variable_set(:@keys_dir, nil)
|
|
1515
|
+
Tina4::Auth.setup(@dir)
|
|
1516
|
+
# A stray API key would authorise every write regardless of the JWT.
|
|
1517
|
+
@prior_api_key = ENV.delete("TINA4_API_KEY")
|
|
1518
|
+
Tina4.bind_database(Tina4::Database.new("sqlite:///" + File.join(@dir, "test.db")))
|
|
1519
|
+
#{model}.create_table
|
|
1520
|
+
# `load` (not require) re-registers the routes after spec_helper's
|
|
1521
|
+
# after(:each) Router.clear! wipes them between examples.
|
|
1522
|
+
load File.expand_path("../src/routes/#{snake}.rb", __dir__)
|
|
1523
|
+
end
|
|
1524
|
+
|
|
1525
|
+
after(:each) do
|
|
1526
|
+
ENV["TINA4_API_KEY"] = @prior_api_key if @prior_api_key
|
|
1527
|
+
FileUtils.rm_rf(@dir)
|
|
1528
|
+
end
|
|
1529
|
+
|
|
1530
|
+
it "serves GET (read) publicly" do
|
|
1531
|
+
expect(client.get("/api/#{snake}").status).to eq(200)
|
|
1532
|
+
end
|
|
1533
|
+
|
|
1534
|
+
#{write_examples}
|
|
1535
|
+
end
|
|
1536
|
+
RUBY
|
|
1537
|
+
write_test(snake, content)
|
|
932
1538
|
return
|
|
933
1539
|
end
|
|
934
1540
|
|
|
@@ -991,8 +1597,7 @@ module Tina4
|
|
|
991
1597
|
RUBY
|
|
992
1598
|
end
|
|
993
1599
|
|
|
994
|
-
|
|
995
|
-
puts " Created #{path}"
|
|
1600
|
+
write_test(snake, content)
|
|
996
1601
|
end
|
|
997
1602
|
|
|
998
1603
|
# ── Generator: form ──────────────────────────────────────────────────
|
|
@@ -1164,8 +1769,9 @@ module Tina4
|
|
|
1164
1769
|
def generate_auth(_name = nil, flags = {})
|
|
1165
1770
|
puts "\n Generating authentication scaffolding...\n"
|
|
1166
1771
|
|
|
1167
|
-
# 1. User model + migration
|
|
1168
|
-
|
|
1772
|
+
# 1. User model + migration (emit_test: false — auth emits its own broader
|
|
1773
|
+
# register/login/me spec at step 5).
|
|
1774
|
+
generate_model("User", { "fields" => "email:string,password:string,role:string" }, emit_test: false)
|
|
1169
1775
|
|
|
1170
1776
|
# 2. Auth routes
|
|
1171
1777
|
dir = "src/routes"
|
|
@@ -1175,7 +1781,10 @@ module Tina4
|
|
|
1175
1781
|
content = <<~'RUBY'
|
|
1176
1782
|
require_relative "../orm/user"
|
|
1177
1783
|
|
|
1178
|
-
|
|
1784
|
+
# PUBLIC: register mints accounts before any token exists — clear BOTH
|
|
1785
|
+
# write gates (auth: false drops the bearer auth_handler; .no_auth
|
|
1786
|
+
# clears the router's auth_required).
|
|
1787
|
+
Tina4.post "/api/auth/register", auth: false do |request, response|
|
|
1179
1788
|
# Register a new user
|
|
1180
1789
|
email = request.body["email"].to_s
|
|
1181
1790
|
password = request.body["password"].to_s
|
|
@@ -1197,9 +1806,10 @@ module Tina4
|
|
|
1197
1806
|
role: "user",
|
|
1198
1807
|
})
|
|
1199
1808
|
response.json({ message: "Registered", id: user.id }, 201)
|
|
1200
|
-
end
|
|
1809
|
+
end.no_auth
|
|
1201
1810
|
|
|
1202
|
-
|
|
1811
|
+
# PUBLIC: login mints the token — clear BOTH write gates (see register).
|
|
1812
|
+
Tina4.post "/api/auth/login", auth: false do |request, response|
|
|
1203
1813
|
# Login with email and password
|
|
1204
1814
|
email = request.body["email"].to_s
|
|
1205
1815
|
password = request.body["password"].to_s
|
|
@@ -1216,7 +1826,7 @@ module Tina4
|
|
|
1216
1826
|
|
|
1217
1827
|
token = Tina4::Auth.get_token({ user_id: user.id, email: user.email, role: user.role })
|
|
1218
1828
|
response.json({ token: token })
|
|
1219
|
-
end
|
|
1829
|
+
end.no_auth
|
|
1220
1830
|
|
|
1221
1831
|
Tina4.get "/api/auth/me" do |request, response|
|
|
1222
1832
|
# Get current authenticated user
|
|
@@ -1296,8 +1906,9 @@ module Tina4
|
|
|
1296
1906
|
puts " Created #{register_path}"
|
|
1297
1907
|
end
|
|
1298
1908
|
|
|
1299
|
-
# 5. Auth test
|
|
1300
|
-
|
|
1909
|
+
# 5. Auth test — real register/login/me end-to-end (real Router, real Auth
|
|
1910
|
+
# JWT + PBKDF2, real SQLite). Replaces the old placeholder stub spec.
|
|
1911
|
+
emit_auth_test
|
|
1301
1912
|
|
|
1302
1913
|
puts "\n Authentication scaffolding complete."
|
|
1303
1914
|
puts " Run: tina4ruby migrate"
|
|
@@ -1306,56 +1917,946 @@ module Tina4
|
|
|
1306
1917
|
puts " GET /api/auth/me - get profile (requires token)"
|
|
1307
1918
|
end
|
|
1308
1919
|
|
|
1920
|
+
# ── Scaffolding-first logic generators (wiring + AI-FILL placeholder) ─────
|
|
1921
|
+
#
|
|
1922
|
+
# Each grounds on the REAL current tina4-ruby API (verified against the
|
|
1923
|
+
# source, file:line) and drops the ai_fill() AI-FILL placeholder (raise
|
|
1924
|
+
# NotImplementedError) where the custom logic goes:
|
|
1925
|
+
# service -> lib/tina4/service_runner.rb Tina4::ServiceRunner.register/.discover/.list
|
|
1926
|
+
# (via the Tina4.service DSL, lib/tina4.rb:431)
|
|
1927
|
+
# queue -> lib/tina4/queue.rb Tina4::Queue#push / #consume
|
|
1928
|
+
# lib/tina4/job.rb Job#payload / #complete / #fail
|
|
1929
|
+
# validator -> lib/tina4/validator.rb Tina4::Validator (#required/#email/#is_valid?)
|
|
1930
|
+
# seeder -> lib/tina4/seeder.rb Tina4::FakeData / Tina4.seed_orm
|
|
1931
|
+
# websocket -> lib/tina4/router.rb Tina4.websocket (connection, event, data)
|
|
1932
|
+
# listener -> lib/tina4/events.rb Tina4::Events.on / .emit
|
|
1933
|
+
|
|
1934
|
+
# ── Generator: service ────────────────────────────────────────────────
|
|
1935
|
+
#
|
|
1936
|
+
# tina4ruby generate service Cleanup --every 5m
|
|
1937
|
+
# tina4ruby generate service Report --cron "0 3 * * *"
|
|
1938
|
+
def generate_service(name, flags = {})
|
|
1939
|
+
snake = to_snake_case(name)
|
|
1940
|
+
cron = flags["cron"]
|
|
1941
|
+
if cron && cron != true
|
|
1942
|
+
options_kw = "timing: #{cron.inspect}" # ServiceRunner cron key is :timing
|
|
1943
|
+
note = "cron '#{cron}'"
|
|
1944
|
+
else
|
|
1945
|
+
seconds = parse_every(flags["every"])
|
|
1946
|
+
options_kw = "interval: #{seconds}"
|
|
1947
|
+
note = "every #{seconds}s"
|
|
1948
|
+
end
|
|
1949
|
+
|
|
1950
|
+
dir = "src/services"
|
|
1951
|
+
FileUtils.mkdir_p(dir)
|
|
1952
|
+
path = File.join(dir, "#{snake}.rb")
|
|
1953
|
+
if File.exist?(path)
|
|
1954
|
+
puts " File already exists: #{path}"
|
|
1955
|
+
return
|
|
1956
|
+
end
|
|
1957
|
+
|
|
1958
|
+
body = ai_fill(
|
|
1959
|
+
"#{snake}_task",
|
|
1960
|
+
"do the scheduled work for this service",
|
|
1961
|
+
"context.name / context.running (Tina4::ServiceContext); call your ORM / app code",
|
|
1962
|
+
"service:#{snake}: implement the scheduled task",
|
|
1963
|
+
given: "context -> Tina4::ServiceContext (.name, .running, .last_run, .error_count)",
|
|
1964
|
+
ground: 'tina4_context("background service scheduled task", "ruby")'
|
|
1965
|
+
)
|
|
1966
|
+
content = <<~RUBY
|
|
1967
|
+
# #{name} background service — runs #{note} via Tina4::ServiceRunner.
|
|
1968
|
+
#
|
|
1969
|
+
# Registered on load by Tina4.service(...). `tina4ruby start` does NOT
|
|
1970
|
+
# auto-start services (src/services is not on the boot auto-discover path,
|
|
1971
|
+
# lib/tina4.rb:566). Wire a runner explicitly to run it:
|
|
1972
|
+
#
|
|
1973
|
+
# Tina4::ServiceRunner.discover("src/services") # loads this file
|
|
1974
|
+
# Tina4::ServiceRunner.start
|
|
1975
|
+
#
|
|
1976
|
+
# The block receives a Tina4::ServiceContext; a daemon-style task would
|
|
1977
|
+
# loop while context.running.
|
|
1978
|
+
|
|
1979
|
+
def #{snake}_task(context)
|
|
1980
|
+
#{body.chomp}
|
|
1981
|
+
end
|
|
1982
|
+
|
|
1983
|
+
# Wiring: registers "#{snake}" on the class-level ServiceRunner registry;
|
|
1984
|
+
# appears in Tina4::ServiceRunner.list.
|
|
1985
|
+
Tina4.service("#{snake}", #{options_kw}) { |context| #{snake}_task(context) }
|
|
1986
|
+
RUBY
|
|
1987
|
+
File.write(path, content)
|
|
1988
|
+
puts " Created #{path}"
|
|
1989
|
+
|
|
1990
|
+
# Co-emit a real ServiceRunner registration/discovery spec.
|
|
1991
|
+
emit_service_test(name, snake)
|
|
1992
|
+
end
|
|
1993
|
+
|
|
1994
|
+
# ── Generator: queue ──────────────────────────────────────────────────
|
|
1995
|
+
#
|
|
1996
|
+
# tina4ruby generate queue order-emails
|
|
1997
|
+
def generate_queue(name, flags = {})
|
|
1998
|
+
topic = name.sub(%r{^/}, "")
|
|
1999
|
+
slug = to_snake_case(topic.gsub(/[^0-9a-zA-Z]+/, "_")).gsub(/\A_+|_+\z/, "")
|
|
2000
|
+
slug = "topic" if slug.empty?
|
|
2001
|
+
|
|
2002
|
+
dir = "src/services" # consumer runs as a daemon service (see below)
|
|
2003
|
+
FileUtils.mkdir_p(dir)
|
|
2004
|
+
path = File.join(dir, "#{slug}_consumer.rb")
|
|
2005
|
+
if File.exist?(path)
|
|
2006
|
+
puts " File already exists: #{path}"
|
|
2007
|
+
return
|
|
2008
|
+
end
|
|
2009
|
+
|
|
2010
|
+
body = ai_fill(
|
|
2011
|
+
"handle_#{slug}",
|
|
2012
|
+
"process ONE #{topic} job payload",
|
|
2013
|
+
"your ORM / Tina4::Messenger code; return to ack (job.complete), raise to nack (job.fail)",
|
|
2014
|
+
"queue:#{topic}: process the job payload",
|
|
2015
|
+
given: "payload -> the pushed Hash (job.payload)",
|
|
2016
|
+
ground: 'tina4_context("process a queue job", "ruby")'
|
|
2017
|
+
)
|
|
2018
|
+
content = <<~RUBY
|
|
2019
|
+
# #{topic} queue — producer + consumer worker.
|
|
2020
|
+
#
|
|
2021
|
+
# Produce from anywhere: publish_#{slug}({ ... })
|
|
2022
|
+
# The consumer is a long-running worker wired as a daemon service so
|
|
2023
|
+
# Tina4::ServiceRunner.discover("src/services"); Tina4::ServiceRunner.start
|
|
2024
|
+
# runs it without blocking boot (consume polls forever).
|
|
2025
|
+
|
|
2026
|
+
# Enqueue a #{topic} job for the worker below to process. Returns the Tina4::Job.
|
|
2027
|
+
def publish_#{slug}(payload)
|
|
2028
|
+
Tina4::Queue.new(topic: "#{topic}").push(payload)
|
|
2029
|
+
end
|
|
2030
|
+
|
|
2031
|
+
# Process ONE #{topic} job payload (`payload` is job.payload — the pushed data).
|
|
2032
|
+
def handle_#{slug}(payload)
|
|
2033
|
+
#{body.chomp}
|
|
2034
|
+
end
|
|
2035
|
+
|
|
2036
|
+
# Long-running #{topic} worker. consume yields Jobs; ack with job.complete,
|
|
2037
|
+
# nack with job.fail. `context` is the Tina4::ServiceContext under ServiceRunner.
|
|
2038
|
+
def consume_#{slug}(context = nil)
|
|
2039
|
+
Tina4::Queue.new(topic: "#{topic}").consume do |job|
|
|
2040
|
+
handle_#{slug}(job.payload)
|
|
2041
|
+
job.complete # ack — job done, removed from the queue
|
|
2042
|
+
rescue StandardError => e
|
|
2043
|
+
job.fail(e.message) # nack — retry / dead-letter
|
|
2044
|
+
end
|
|
2045
|
+
end
|
|
2046
|
+
|
|
2047
|
+
# Wiring: consumer runs as a daemon service (manages its own poll loop).
|
|
2048
|
+
# Discovered by Tina4::ServiceRunner.discover("src/services"). The `topic`
|
|
2049
|
+
# + per-job `handle` options let `tina4ruby queue work #{topic}` drive this
|
|
2050
|
+
# consumer directly (own the poll loop / bounded --once drain) without
|
|
2051
|
+
# wiring a ServiceRunner.
|
|
2052
|
+
Tina4.service("#{topic}-consumer", daemon: true, topic: "#{topic}", handle: method(:handle_#{slug})) { |context| consume_#{slug}(context) }
|
|
2053
|
+
RUBY
|
|
2054
|
+
File.write(path, content)
|
|
2055
|
+
puts " Created #{path}"
|
|
2056
|
+
|
|
2057
|
+
# Co-emit a real file-backed Queue spec (push a real job + daemon wiring).
|
|
2058
|
+
emit_queue_test(topic, slug)
|
|
2059
|
+
end
|
|
2060
|
+
|
|
2061
|
+
# ── Generator: validator ──────────────────────────────────────────────
|
|
2062
|
+
#
|
|
2063
|
+
# tina4ruby generate validator CreateUser
|
|
2064
|
+
def generate_validator(name, flags = {})
|
|
2065
|
+
snake = to_snake_case(name)
|
|
2066
|
+
dir = "src/validators"
|
|
2067
|
+
FileUtils.mkdir_p(dir)
|
|
2068
|
+
path = File.join(dir, "#{snake}.rb")
|
|
2069
|
+
if File.exist?(path)
|
|
2070
|
+
puts " File already exists: #{path}"
|
|
2071
|
+
return
|
|
2072
|
+
end
|
|
2073
|
+
|
|
2074
|
+
# Ships a working starter rule (not a loud stub): a rules-less validator
|
|
2075
|
+
# validates nothing, so there would be no negative case for the co-emitted
|
|
2076
|
+
# valid/invalid spec to assert against. `required("name")` mirrors the model
|
|
2077
|
+
# generator's default `name` field — edit it for your real payload.
|
|
2078
|
+
body = extend_marker(
|
|
2079
|
+
"add / adjust the rules for your #{name} payload",
|
|
2080
|
+
'e.g. validator.email("email").min_length("name", 2).integer("age") · ' \
|
|
2081
|
+
'ground: tina4_context("validate request body with Validator", "ruby")'
|
|
2082
|
+
) + %( validator.required("name")\n)
|
|
2083
|
+
content = <<~RUBY
|
|
2084
|
+
# #{name} request validator.
|
|
2085
|
+
#
|
|
2086
|
+
# Tina4::Validator is NOT part of the default `require "tina4"` surface
|
|
2087
|
+
# (unlike Queue / Events / ServiceRunner), so require it explicitly here.
|
|
2088
|
+
# NOT auto-loaded — require this file from the route that validates:
|
|
2089
|
+
# require_relative "../validators/#{snake}"
|
|
2090
|
+
# v = validate_#{snake}(request.body)
|
|
2091
|
+
# next response.json({ error: v.errors.first[:message] }, 400) unless v.is_valid?
|
|
2092
|
+
require "tina4/validator"
|
|
2093
|
+
|
|
2094
|
+
def validate_#{snake}(data)
|
|
2095
|
+
validator = Tina4::Validator.new(data)
|
|
2096
|
+
#{body.chomp}
|
|
2097
|
+
validator
|
|
2098
|
+
end
|
|
2099
|
+
RUBY
|
|
2100
|
+
File.write(path, content)
|
|
2101
|
+
puts " Created #{path}"
|
|
2102
|
+
|
|
2103
|
+
# Co-emit a real valid + invalid spec against the starter rule.
|
|
2104
|
+
emit_validator_test(name, snake)
|
|
2105
|
+
end
|
|
2106
|
+
|
|
2107
|
+
# ── Generator: seeder ─────────────────────────────────────────────────
|
|
2108
|
+
#
|
|
2109
|
+
# tina4ruby generate seeder Product
|
|
2110
|
+
def generate_seeder(name, flags = {})
|
|
2111
|
+
table = to_table_name(name)
|
|
2112
|
+
model_snake = to_snake_case(name)
|
|
2113
|
+
dir = "seeds"
|
|
2114
|
+
FileUtils.mkdir_p(dir)
|
|
2115
|
+
path = File.join(dir, "#{table}_seeder.rb")
|
|
2116
|
+
if File.exist?(path)
|
|
2117
|
+
puts " File already exists: #{path}"
|
|
2118
|
+
return
|
|
2119
|
+
end
|
|
2120
|
+
|
|
2121
|
+
# Ships working out of the box (not a loud stub): seed_orm auto-fills every
|
|
2122
|
+
# field by type/name, so a zero-override seeder already seeds real rows.
|
|
2123
|
+
# Return overrides only for fields that need a specific shape.
|
|
2124
|
+
body = extend_marker(
|
|
2125
|
+
"override #{name} fields that need a specific shape (optional)",
|
|
2126
|
+
'e.g. { "email" => ->(fake) { fake.email }, "status" => "active" } · ' \
|
|
2127
|
+
'ground: tina4_context("seed ORM model with FakeData", "ruby")'
|
|
2128
|
+
) + %( {}\n)
|
|
2129
|
+
content = <<~RUBY
|
|
2130
|
+
# Seeder for #{name} — run with: tina4ruby seed
|
|
2131
|
+
#
|
|
2132
|
+
# Executed top-to-bottom by `tina4ruby seed` (Tina4.seed_dir loads each
|
|
2133
|
+
# file in seeds/, lib/tina4/seeder.rb:890). Tina4.seed_orm auto-fills every
|
|
2134
|
+
# field by type/name; override the ones that need a specific shape via
|
|
2135
|
+
# #{table}_field_overrides.
|
|
2136
|
+
require_relative "../src/orm/#{model_snake}"
|
|
2137
|
+
|
|
2138
|
+
# Map #{name} fields -> Tina4::FakeData generators (or static values).
|
|
2139
|
+
# Each callable receives a FakeData instance:
|
|
2140
|
+
# { "email" => ->(fake) { fake.email }, "status" => "active" }
|
|
2141
|
+
def #{table}_field_overrides(fake)
|
|
2142
|
+
#{body.chomp}
|
|
2143
|
+
end
|
|
2144
|
+
|
|
2145
|
+
# Wiring: seed rows via Tina4.seed_orm. Guarded on a bound database so
|
|
2146
|
+
# merely LOADING this file (e.g. from another script) is a no-op — only
|
|
2147
|
+
# `tina4ruby seed` (which binds the DB first) actually seeds. seed_orm
|
|
2148
|
+
# auto-fills every field, so this works out of the box.
|
|
2149
|
+
if Tina4.database
|
|
2150
|
+
fake = Tina4::FakeData.new
|
|
2151
|
+
summary = Tina4.seed_orm(#{name}, count: 20, overrides: #{table}_field_overrides(fake))
|
|
2152
|
+
puts "Seeded #{'#'}{summary.seeded} #{name} row(s), #{'#'}{summary.failed} failed"
|
|
2153
|
+
end
|
|
2154
|
+
RUBY
|
|
2155
|
+
File.write(path, content)
|
|
2156
|
+
puts " Created #{path}"
|
|
2157
|
+
|
|
2158
|
+
# Co-emit a real seeding spec (runs the seeder against real SQLite).
|
|
2159
|
+
emit_seeder_test(name, table)
|
|
2160
|
+
end
|
|
2161
|
+
|
|
2162
|
+
# ── Generator: websocket ──────────────────────────────────────────────
|
|
2163
|
+
#
|
|
2164
|
+
# tina4ruby generate websocket chat
|
|
2165
|
+
# tina4ruby generate websocket /ws/rooms/{id}
|
|
2166
|
+
def generate_websocket(name, flags = {})
|
|
2167
|
+
raw = name.strip
|
|
2168
|
+
ws_path = raw.start_with?("/") ? raw : "/ws/#{raw.sub(%r{^/}, '')}"
|
|
2169
|
+
slug = to_snake_case(raw.gsub(%r{\A/+|/+\z}, "").gsub(/[^0-9a-zA-Z]+/, "_")).gsub(/\A_+|_+\z/, "")
|
|
2170
|
+
slug = "ws" if slug.empty?
|
|
2171
|
+
base = slug.start_with?("ws_") ? slug[3..] : slug
|
|
2172
|
+
base = "ws" if base.nil? || base.empty?
|
|
2173
|
+
handler = "#{base}_ws"
|
|
2174
|
+
|
|
2175
|
+
dir = "src/routes" # Tina4.websocket auto-registers on load (src/routes is discovered)
|
|
2176
|
+
FileUtils.mkdir_p(dir)
|
|
2177
|
+
path = File.join(dir, "ws_#{base}.rb")
|
|
2178
|
+
if File.exist?(path)
|
|
2179
|
+
puts " File already exists: #{path}"
|
|
2180
|
+
return
|
|
2181
|
+
end
|
|
2182
|
+
|
|
2183
|
+
body = ai_fill(
|
|
2184
|
+
handler,
|
|
2185
|
+
%(handle an inbound "message" frame on #{ws_path}),
|
|
2186
|
+
"connection.broadcast(data) or connection.send(payload) (Tina4 WebSocketConnection)",
|
|
2187
|
+
"websocket:#{ws_path}: handle the inbound message",
|
|
2188
|
+
given: "connection -> WebSocketConnection; event -> :open/:message/:close; data -> String on :message",
|
|
2189
|
+
ground: 'tina4_context("websocket broadcast message", "ruby")',
|
|
2190
|
+
indent: " "
|
|
2191
|
+
)
|
|
2192
|
+
content = <<~RUBY
|
|
2193
|
+
# #{ws_path} WebSocket route.
|
|
2194
|
+
#
|
|
2195
|
+
# Registered on load by Tina4.websocket (src/routes is auto-discovered at
|
|
2196
|
+
# boot, lib/tina4.rb:566). The server invokes the block as
|
|
2197
|
+
# (connection, event, data) for each event: :open (connect), :message
|
|
2198
|
+
# (inbound frame), :close (disconnect). Use Tina4.secure_websocket instead
|
|
2199
|
+
# to require a JWT on the upgrade; connection.broadcast / connection.send
|
|
2200
|
+
# reach the other clients.
|
|
2201
|
+
Tina4.websocket "#{ws_path}" do |connection, event, data|
|
|
2202
|
+
case event
|
|
2203
|
+
when :open
|
|
2204
|
+
connection.send('{"type":"welcome"}')
|
|
2205
|
+
when :close
|
|
2206
|
+
# client disconnected — nothing to clean up yet
|
|
2207
|
+
else # :message
|
|
2208
|
+
#{body.chomp}
|
|
2209
|
+
end
|
|
2210
|
+
end
|
|
2211
|
+
RUBY
|
|
2212
|
+
File.write(path, content)
|
|
2213
|
+
puts " Created #{path}"
|
|
2214
|
+
|
|
2215
|
+
# Co-emit a real handler spec (Router registration + drives the handler).
|
|
2216
|
+
emit_websocket_test(ws_path, base, handler)
|
|
2217
|
+
end
|
|
2218
|
+
|
|
2219
|
+
# ── Generator: listener ───────────────────────────────────────────────
|
|
2220
|
+
#
|
|
2221
|
+
# tina4ruby generate listener user.created
|
|
2222
|
+
def generate_listener(name, flags = {})
|
|
2223
|
+
event = name.strip
|
|
2224
|
+
slug = to_snake_case(event.gsub(/[^0-9a-zA-Z]+/, "_")).gsub(/\A_+|_+\z/, "")
|
|
2225
|
+
slug = "event" if slug.empty?
|
|
2226
|
+
|
|
2227
|
+
dir = "src/listeners"
|
|
2228
|
+
FileUtils.mkdir_p(dir)
|
|
2229
|
+
path = File.join(dir, "#{slug}.rb")
|
|
2230
|
+
if File.exist?(path)
|
|
2231
|
+
puts " File already exists: #{path}"
|
|
2232
|
+
return
|
|
2233
|
+
end
|
|
2234
|
+
|
|
2235
|
+
body = ai_fill(
|
|
2236
|
+
"on_#{slug}",
|
|
2237
|
+
"react to the '#{event}' event",
|
|
2238
|
+
"your app code — Tina4::Messenger, an ORM write, or Tina4::Events.emit(...) a follow-up",
|
|
2239
|
+
"listener:#{event}: react to the event payload",
|
|
2240
|
+
given: %(data -> whatever Tina4::Events.emit("#{event}", data) passed),
|
|
2241
|
+
ground: 'tina4_context("event listener reaction", "ruby")'
|
|
2242
|
+
)
|
|
2243
|
+
content = <<~RUBY
|
|
2244
|
+
# Listener for the '#{event}' event.
|
|
2245
|
+
#
|
|
2246
|
+
# NOT auto-loaded at boot — src/listeners is not on the auto-discover path
|
|
2247
|
+
# (lib/tina4.rb:566 scans routes/api/orm only). Require this file from
|
|
2248
|
+
# app.rb (or an initializer) so Tina4::Events.on binds it:
|
|
2249
|
+
# require_relative "src/listeners/#{slug}"
|
|
2250
|
+
# Fires when something calls Tina4::Events.emit("#{event}", data). Listeners
|
|
2251
|
+
# are isolated — a raise is logged and the other listeners still run (pass
|
|
2252
|
+
# strict: true to emit to re-raise instead).
|
|
2253
|
+
def on_#{slug}(data = nil)
|
|
2254
|
+
#{body.chomp}
|
|
2255
|
+
end
|
|
2256
|
+
|
|
2257
|
+
# Wiring: bind the reaction on the real event bus.
|
|
2258
|
+
Tina4::Events.on("#{event}") { |data| on_#{slug}(data) }
|
|
2259
|
+
RUBY
|
|
2260
|
+
File.write(path, content)
|
|
2261
|
+
puts " Created #{path}"
|
|
2262
|
+
|
|
2263
|
+
# Co-emit a real event-bus spec (emit the real event, assert it ran).
|
|
2264
|
+
emit_listener_test(event, slug)
|
|
2265
|
+
end
|
|
2266
|
+
|
|
2267
|
+
# ── co-emitted tests: every code-producing generator ships a real spec ──
|
|
2268
|
+
#
|
|
2269
|
+
# One shared writer (#write_test) + one focused builder per generator. The
|
|
2270
|
+
# builders are NOT copy-paste boilerplate — each drives a different real
|
|
2271
|
+
# subsystem (real SQLite / TestClient / Router / ServiceRunner / Queue /
|
|
2272
|
+
# event bus), grounded on the same real-collaborator patterns the
|
|
2273
|
+
# scaffolding-first acceptance matrix already proves. CRUD-shaped scaffolds
|
|
2274
|
+
# (working code) get behavioural specs; logic-shaped scaffolds (loud
|
|
2275
|
+
# NotImplementedError stubs) get wiring specs + a lock-in that the
|
|
2276
|
+
# placeholder fails loud. `test` itself (it IS the test generator) and
|
|
2277
|
+
# form/view (template-only, no Ruby logic to run) are the only exemptions.
|
|
2278
|
+
# Ruby mirror of the Python master's _write_test + _emit_* builders
|
|
2279
|
+
# (tina4_python/cli/__init__.py, owner req 2026-07-10, Phase 4).
|
|
2280
|
+
|
|
2281
|
+
# Write a co-emitted spec to spec/<name>_spec.rb — the SINGLE place a
|
|
2282
|
+
# generated spec is written (path + overwrite refusal + the "Created" line
|
|
2283
|
+
# every generator prints). Generalized from #generate_test so the generators
|
|
2284
|
+
# share one rule instead of a dozen copy-pasted write blocks.
|
|
2285
|
+
def write_test(test_name, content)
|
|
2286
|
+
dir = "spec"
|
|
2287
|
+
FileUtils.mkdir_p(dir)
|
|
2288
|
+
path = File.join(dir, "#{test_name}_spec.rb")
|
|
2289
|
+
if File.exist?(path)
|
|
2290
|
+
puts " File already exists: #{path}"
|
|
2291
|
+
return
|
|
2292
|
+
end
|
|
2293
|
+
File.write(path, content)
|
|
2294
|
+
puts " Created #{path}"
|
|
2295
|
+
end
|
|
2296
|
+
|
|
2297
|
+
# snake/kebab/dotted -> PascalCase: user_created -> UserCreated.
|
|
2298
|
+
def pascalize(name)
|
|
2299
|
+
name.to_s.split(/[^0-9a-zA-Z]+/).reject(&:empty?).map { |part| part[0].upcase + part[1..].to_s }.join
|
|
2300
|
+
end
|
|
2301
|
+
|
|
2302
|
+
# A source-text Ruby literal that is a valid value for a scaffolded field
|
|
2303
|
+
# type (used to build a real create() payload in the model spec).
|
|
2304
|
+
def sample_literal(field_type)
|
|
2305
|
+
{
|
|
2306
|
+
"int" => "1", "integer" => "1",
|
|
2307
|
+
"float" => "1.5", "numeric" => "1.5", "decimal" => "1.5",
|
|
2308
|
+
"bool" => "true", "boolean" => "true",
|
|
2309
|
+
"datetime" => '"2020-01-01 00:00:00"',
|
|
2310
|
+
"blob" => '"x"',
|
|
2311
|
+
}.fetch((field_type || "string").to_s.downcase, '"sample"')
|
|
2312
|
+
end
|
|
2313
|
+
|
|
2314
|
+
# model -> real SQLite roundtrip (create / read back / missing -> nil).
|
|
2315
|
+
def emit_model_test(model, table, fields)
|
|
2316
|
+
fields = fields.empty? ? [["name", "string"]] : fields
|
|
2317
|
+
payload = fields.map { |fname, ftype| %("#{fname}" => #{sample_literal(ftype)}) }.join(", ")
|
|
2318
|
+
# Assert a STRING field round-trips (type-safe); else just the id round-trips
|
|
2319
|
+
# (avoids datetime/bool/float equality pitfalls on the read-back).
|
|
2320
|
+
string_field = fields.find { |_f, t| %w[string str text].include?((t || "string").to_s.downcase) }
|
|
2321
|
+
value_assert = string_field ? %(\n expect(fetched.#{string_field[0]}).to eq("sample")) : ""
|
|
2322
|
+
content = <<~RUBY
|
|
2323
|
+
# frozen_string_literal: true
|
|
2324
|
+
#
|
|
2325
|
+
# Real ORM roundtrip spec for #{model} — no mocks, real SQLite.
|
|
2326
|
+
#
|
|
2327
|
+
# Generated with src/orm/#{table}.rb by `tina4ruby generate model #{model}`.
|
|
2328
|
+
# The model scaffold is working code, so this passes on generation: it
|
|
2329
|
+
# binds a real on-disk SQLite database, creates the table, saves a row and
|
|
2330
|
+
# reads it back.
|
|
2331
|
+
require "spec_helper"
|
|
2332
|
+
require "tmpdir"
|
|
2333
|
+
require_relative "../src/orm/#{table}"
|
|
2334
|
+
|
|
2335
|
+
RSpec.describe "#{model} model (real SQLite)" do
|
|
2336
|
+
before(:each) do
|
|
2337
|
+
@dir = Dir.mktmpdir("#{table}_model")
|
|
2338
|
+
Tina4.bind_database(Tina4::Database.new("sqlite:///" + File.join(@dir, "test.db")))
|
|
2339
|
+
#{model}.create_table
|
|
2340
|
+
end
|
|
2341
|
+
|
|
2342
|
+
after(:each) { FileUtils.rm_rf(@dir) }
|
|
2343
|
+
|
|
2344
|
+
it "creates a row and reads it back" do
|
|
2345
|
+
row = #{model}.create({ #{payload} })
|
|
2346
|
+
expect(row).not_to be_nil
|
|
2347
|
+
expect(row.id).not_to be_nil
|
|
2348
|
+
fetched = #{model}.find(row.id)
|
|
2349
|
+
expect(fetched).not_to be_nil
|
|
2350
|
+
expect(fetched.id).to eq(row.id)#{value_assert}
|
|
2351
|
+
end
|
|
2352
|
+
|
|
2353
|
+
it "returns nil for a missing id" do
|
|
2354
|
+
expect(#{model}.find(999999)).to be_nil
|
|
2355
|
+
end
|
|
2356
|
+
end
|
|
2357
|
+
RUBY
|
|
2358
|
+
write_test("#{table}_model", content)
|
|
2359
|
+
end
|
|
2360
|
+
|
|
2361
|
+
# route (no --model) -> real Router registration + the handler is a live loud
|
|
2362
|
+
# stub. (route --model reuses the secure-gate #generate_test spec instead.)
|
|
2363
|
+
def emit_route_stub_test(route)
|
|
2364
|
+
klass = pascalize(route)
|
|
2365
|
+
content = <<~RUBY
|
|
2366
|
+
# frozen_string_literal: true
|
|
2367
|
+
#
|
|
2368
|
+
# Routing spec for #{route} — no mocks, real Router.
|
|
2369
|
+
#
|
|
2370
|
+
# Generated with src/routes/#{route}.rb by `tina4ruby generate route
|
|
2371
|
+
# #{route}` (no --model). The handlers are AI-FILL stubs that raise until
|
|
2372
|
+
# you implement them, so this tests what IS live on generation: all five
|
|
2373
|
+
# routes register on the REAL Router, and the list handler fails loud until
|
|
2374
|
+
# filled. Fill a handler, then assert its real response here.
|
|
2375
|
+
require "spec_helper"
|
|
2376
|
+
|
|
2377
|
+
RSpec.describe "#{klass} routing (real Router)" do
|
|
2378
|
+
before(:each) { load File.expand_path("../src/routes/#{route}.rb", __dir__) }
|
|
2379
|
+
|
|
2380
|
+
it "registers all five CRUD routes" do
|
|
2381
|
+
paths = Tina4::Router.routes.map { |r| [r.method, r.path] }
|
|
2382
|
+
expect(paths).to include(["GET", "/api/#{route}"])
|
|
2383
|
+
expect(paths).to include(["GET", "/api/#{route}/{id:int}"])
|
|
2384
|
+
expect(paths).to include(["POST", "/api/#{route}"])
|
|
2385
|
+
expect(paths).to include(["PUT", "/api/#{route}/{id:int}"])
|
|
2386
|
+
expect(paths).to include(["DELETE", "/api/#{route}/{id:int}"])
|
|
2387
|
+
end
|
|
2388
|
+
|
|
2389
|
+
it "the list handler is a live stub (raises until filled)" do
|
|
2390
|
+
route, = Tina4::Router.match("GET", "/api/#{route}")
|
|
2391
|
+
expect { route.handler.call(nil, nil) }.to raise_error(NotImplementedError)
|
|
2392
|
+
end
|
|
2393
|
+
end
|
|
2394
|
+
RUBY
|
|
2395
|
+
write_test(route, content)
|
|
2396
|
+
end
|
|
2397
|
+
|
|
2398
|
+
# middleware -> a request routed THROUGH the real server dispatch.
|
|
2399
|
+
def emit_middleware_test(name, snake)
|
|
2400
|
+
content = <<~RUBY
|
|
2401
|
+
# frozen_string_literal: true
|
|
2402
|
+
#
|
|
2403
|
+
# Real dispatch spec for the #{name} middleware — no mocks.
|
|
2404
|
+
#
|
|
2405
|
+
# Generated with src/middleware/#{snake}.rb by `tina4ruby generate
|
|
2406
|
+
# middleware #{name}`. Drives the middleware through the REAL server
|
|
2407
|
+
# dispatch (Tina4::Middleware.run_before / run_after) with a real Request +
|
|
2408
|
+
# Response — the same code path the live server runs.
|
|
2409
|
+
require "spec_helper"
|
|
2410
|
+
require "stringio"
|
|
2411
|
+
require_relative "../src/middleware/#{snake}"
|
|
2412
|
+
|
|
2413
|
+
RSpec.describe "#{name} middleware (real dispatch)" do
|
|
2414
|
+
before(:each) { Tina4::Middleware.clear! if Tina4::Middleware.respond_to?(:clear!) }
|
|
2415
|
+
|
|
2416
|
+
def build_request
|
|
2417
|
+
env = {
|
|
2418
|
+
"REQUEST_METHOD" => "GET", "PATH_INFO" => "/", "QUERY_STRING" => "",
|
|
2419
|
+
"HTTP_HOST" => "localhost", "rack.input" => StringIO.new("")
|
|
2420
|
+
}
|
|
2421
|
+
Tina4::Request.new(env)
|
|
2422
|
+
end
|
|
2423
|
+
|
|
2424
|
+
it "before_* passes the request through (does not block the handler)" do
|
|
2425
|
+
response = Tina4::Response.new
|
|
2426
|
+
ok = Tina4::Middleware.run_before([#{name}], build_request, response)
|
|
2427
|
+
expect(ok).to be true # the scaffold does not halt the chain
|
|
2428
|
+
expect(response.status_code).to be < 400
|
|
2429
|
+
end
|
|
2430
|
+
|
|
2431
|
+
it "after_* runs and leaves the response intact" do
|
|
2432
|
+
response = Tina4::Response.new
|
|
2433
|
+
Tina4::Middleware.run_after([#{name}], build_request, response)
|
|
2434
|
+
expect(response.status_code).to be < 400
|
|
2435
|
+
end
|
|
2436
|
+
end
|
|
2437
|
+
RUBY
|
|
2438
|
+
write_test(snake, content)
|
|
2439
|
+
end
|
|
2440
|
+
|
|
2441
|
+
# service -> registrable / discoverable on the REAL ServiceRunner + loud stub.
|
|
2442
|
+
def emit_service_test(name, snake)
|
|
2443
|
+
content = <<~RUBY
|
|
2444
|
+
# frozen_string_literal: true
|
|
2445
|
+
#
|
|
2446
|
+
# Real ServiceRunner spec for the #{name} service — no mocks.
|
|
2447
|
+
#
|
|
2448
|
+
# Generated with src/services/#{snake}.rb by `tina4ruby generate service
|
|
2449
|
+
# #{name}`. Loading the file registers the scaffold on the REAL
|
|
2450
|
+
# class-level ServiceRunner registry; the task body is an AI-FILL stub that
|
|
2451
|
+
# raises until filled.
|
|
2452
|
+
require "spec_helper"
|
|
2453
|
+
require_relative "../src/services/#{snake}"
|
|
2454
|
+
|
|
2455
|
+
RSpec.describe "#{pascalize(snake)} service (real ServiceRunner)" do
|
|
2456
|
+
it "registers on the ServiceRunner registry" do
|
|
2457
|
+
expect(Tina4::ServiceRunner.list.any? { |s| s[:name] == "#{snake}" }).to be true
|
|
2458
|
+
end
|
|
2459
|
+
|
|
2460
|
+
it "the task is a live stub (raises until filled)" do
|
|
2461
|
+
expect { #{snake}_task(nil) }.to raise_error(NotImplementedError)
|
|
2462
|
+
end
|
|
2463
|
+
end
|
|
2464
|
+
RUBY
|
|
2465
|
+
write_test(snake, content)
|
|
2466
|
+
end
|
|
2467
|
+
|
|
2468
|
+
# queue -> push a REAL job onto the real file-backed Queue + daemon wiring.
|
|
2469
|
+
def emit_queue_test(topic, slug)
|
|
2470
|
+
content = <<~RUBY
|
|
2471
|
+
# frozen_string_literal: true
|
|
2472
|
+
#
|
|
2473
|
+
# Real file-backed Queue spec for the #{topic} worker — no mocks.
|
|
2474
|
+
#
|
|
2475
|
+
# Generated with src/services/#{slug}_consumer.rb by `tina4ruby generate
|
|
2476
|
+
# queue #{topic}`. Pushes a REAL job onto the real file-backed Queue and
|
|
2477
|
+
# asserts it is enqueued, and that the consumer is wired as a daemon
|
|
2478
|
+
# service. handle_#{slug} is an AI-FILL stub that raises until you fill it
|
|
2479
|
+
# — then assert the processed side effect here.
|
|
2480
|
+
require "spec_helper"
|
|
2481
|
+
require_relative "../src/services/#{slug}_consumer"
|
|
2482
|
+
|
|
2483
|
+
RSpec.describe "#{pascalize(slug)} queue (real file-backed Queue)" do
|
|
2484
|
+
it "publish enqueues a real job" do
|
|
2485
|
+
before_size = Tina4::Queue.new(topic: "#{topic}").size
|
|
2486
|
+
job = publish_#{slug}({ "hello" => "world" })
|
|
2487
|
+
expect(job).not_to be_nil
|
|
2488
|
+
expect(job.id).not_to be_nil
|
|
2489
|
+
expect(Tina4::Queue.new(topic: "#{topic}").size).to be >= before_size + 1
|
|
2490
|
+
end
|
|
2491
|
+
|
|
2492
|
+
it "the consumer is wired as a daemon service" do
|
|
2493
|
+
svc = Tina4::ServiceRunner.list.find { |s| s[:name] == "#{topic}-consumer" }
|
|
2494
|
+
expect(svc).not_to be_nil
|
|
2495
|
+
expect(svc[:options][:daemon]).to be true
|
|
2496
|
+
end
|
|
2497
|
+
|
|
2498
|
+
it "handle_#{slug} is a live stub (raises until filled)" do
|
|
2499
|
+
expect { handle_#{slug}({}) }.to raise_error(NotImplementedError)
|
|
2500
|
+
end
|
|
2501
|
+
end
|
|
2502
|
+
RUBY
|
|
2503
|
+
write_test(slug, content)
|
|
2504
|
+
end
|
|
2505
|
+
|
|
2506
|
+
# validator -> run the scaffold against valid + invalid real input.
|
|
2507
|
+
def emit_validator_test(name, snake)
|
|
2508
|
+
content = <<~RUBY
|
|
2509
|
+
# frozen_string_literal: true
|
|
2510
|
+
#
|
|
2511
|
+
# Real validation spec for validate_#{snake} — no mocks.
|
|
2512
|
+
#
|
|
2513
|
+
# Generated with src/validators/#{snake}.rb by `tina4ruby generate
|
|
2514
|
+
# validator #{name}`. The scaffold ships a starter rule (required "name"),
|
|
2515
|
+
# so this passes on generation — adjust the rules for your payload and
|
|
2516
|
+
# update these cases with them.
|
|
2517
|
+
require "spec_helper"
|
|
2518
|
+
require_relative "../src/validators/#{snake}"
|
|
2519
|
+
|
|
2520
|
+
RSpec.describe "#{pascalize(snake)} validator" do
|
|
2521
|
+
it "valid input passes" do
|
|
2522
|
+
expect(validate_#{snake}({ "name" => "Ada" }).is_valid?).to be true
|
|
2523
|
+
end
|
|
2524
|
+
|
|
2525
|
+
it "invalid input fails with errors" do
|
|
2526
|
+
result = validate_#{snake}({})
|
|
2527
|
+
expect(result.is_valid?).to be false
|
|
2528
|
+
expect(result.errors).not_to be_empty
|
|
2529
|
+
end
|
|
2530
|
+
end
|
|
2531
|
+
RUBY
|
|
2532
|
+
write_test(snake, content)
|
|
2533
|
+
end
|
|
2534
|
+
|
|
2535
|
+
# seeder -> run the scaffold against real SQLite, assert rows created.
|
|
2536
|
+
def emit_seeder_test(model, table)
|
|
2537
|
+
content = <<~RUBY
|
|
2538
|
+
# frozen_string_literal: true
|
|
2539
|
+
#
|
|
2540
|
+
# Real seeding spec for the #{model} seeder — no mocks, real SQLite.
|
|
2541
|
+
#
|
|
2542
|
+
# Generated with seeds/#{table}_seeder.rb by `tina4ruby generate seeder
|
|
2543
|
+
# #{model}`. Binds a real SQLite DB, creates the table, then loads the
|
|
2544
|
+
# scaffolded seeder (its guarded block auto-fills every field via FakeData
|
|
2545
|
+
# and seeds rows) and asserts rows were created.
|
|
2546
|
+
require "spec_helper"
|
|
2547
|
+
require "tmpdir"
|
|
2548
|
+
require_relative "../src/orm/#{table}"
|
|
2549
|
+
|
|
2550
|
+
RSpec.describe "#{model} seeder (real SQLite)" do
|
|
2551
|
+
before(:each) do
|
|
2552
|
+
@dir = Dir.mktmpdir("#{table}_seeder")
|
|
2553
|
+
Tina4.bind_database(Tina4::Database.new("sqlite:///" + File.join(@dir, "seed.db")))
|
|
2554
|
+
#{model}.create_table
|
|
2555
|
+
end
|
|
2556
|
+
|
|
2557
|
+
after(:each) { FileUtils.rm_rf(@dir) }
|
|
2558
|
+
|
|
2559
|
+
it "field_overrides returns a Hash" do
|
|
2560
|
+
load File.expand_path("../seeds/#{table}_seeder.rb", __dir__)
|
|
2561
|
+
expect(#{table}_field_overrides(Tina4::FakeData.new)).to be_a(Hash)
|
|
2562
|
+
end
|
|
2563
|
+
|
|
2564
|
+
it "running the seeder creates rows" do
|
|
2565
|
+
load File.expand_path("../seeds/#{table}_seeder.rb", __dir__)
|
|
2566
|
+
expect(#{model}.count).to be >= 1
|
|
2567
|
+
end
|
|
2568
|
+
end
|
|
2569
|
+
RUBY
|
|
2570
|
+
write_test("#{table}_seeder", content)
|
|
2571
|
+
end
|
|
2572
|
+
|
|
2573
|
+
# websocket -> real Router registration + drive the real handler (the
|
|
2574
|
+
# socket-free "close" event) directly (no mock socket).
|
|
2575
|
+
def emit_websocket_test(ws_path, base, _handler)
|
|
2576
|
+
content = <<~RUBY
|
|
2577
|
+
# frozen_string_literal: true
|
|
2578
|
+
#
|
|
2579
|
+
# Real handler spec for the #{ws_path} WebSocket route — no mocks.
|
|
2580
|
+
#
|
|
2581
|
+
# Generated with src/routes/ws_#{base}.rb by `tina4ruby generate websocket
|
|
2582
|
+
# ...`. Confirms the handler registers on the REAL Router and drives the
|
|
2583
|
+
# real handler for the :close event (no socket needed). The :message branch
|
|
2584
|
+
# is an AI-FILL stub that raises until you fill it; a full RFC6455 loopback
|
|
2585
|
+
# is out of scope for a unit spec, so assert its broadcast/response against
|
|
2586
|
+
# a live server once implemented.
|
|
2587
|
+
require "spec_helper"
|
|
2588
|
+
|
|
2589
|
+
RSpec.describe "#{pascalize(base)} WebSocket (real Router)" do
|
|
2590
|
+
# `load` (not require) re-registers the handler after spec_helper's
|
|
2591
|
+
# after(:each) Router.clear! wipes the ws routes between examples.
|
|
2592
|
+
before(:each) { load File.expand_path("../src/routes/ws_#{base}.rb", __dir__) }
|
|
2593
|
+
|
|
2594
|
+
def ws_route
|
|
2595
|
+
Tina4::Router.get_web_socket_routes.find { |r| r.path == "#{ws_path}" }
|
|
2596
|
+
end
|
|
2597
|
+
|
|
2598
|
+
it "registers the handler on the Router ws routes" do
|
|
2599
|
+
expect(ws_route).not_to be_nil
|
|
2600
|
+
end
|
|
2601
|
+
|
|
2602
|
+
it "handles the :close event cleanly (no connection needed)" do
|
|
2603
|
+
expect(ws_route.handler.call(nil, :close, nil)).to be_nil
|
|
2604
|
+
end
|
|
2605
|
+
|
|
2606
|
+
it "the :message branch is a live stub (raises until filled)" do
|
|
2607
|
+
expect { ws_route.handler.call(nil, :message, "hi") }.to raise_error(NotImplementedError)
|
|
2608
|
+
end
|
|
2609
|
+
end
|
|
2610
|
+
RUBY
|
|
2611
|
+
write_test("ws_#{base}", content)
|
|
2612
|
+
end
|
|
2613
|
+
|
|
2614
|
+
# listener -> emit the REAL event on the real bus, assert the listener ran.
|
|
2615
|
+
def emit_listener_test(event, slug)
|
|
2616
|
+
content = <<~RUBY
|
|
2617
|
+
# frozen_string_literal: true
|
|
2618
|
+
#
|
|
2619
|
+
# Real event-bus spec for the '#{event}' listener — no mocks.
|
|
2620
|
+
#
|
|
2621
|
+
# Generated with src/listeners/#{slug}.rb by `tina4ruby generate listener
|
|
2622
|
+
# #{event}`. Confirms the listener binds on the REAL event bus and that
|
|
2623
|
+
# emitting the event reaches it. The reaction body is an AI-FILL stub that
|
|
2624
|
+
# raises until filled, so a strict emit re-raises here (proving it ran).
|
|
2625
|
+
require "spec_helper"
|
|
2626
|
+
|
|
2627
|
+
RSpec.describe "#{pascalize(slug)} listener (real event bus)" do
|
|
2628
|
+
before(:each) do
|
|
2629
|
+
Tina4::Events.clear
|
|
2630
|
+
load File.expand_path("../src/listeners/#{slug}.rb", __dir__)
|
|
2631
|
+
end
|
|
2632
|
+
|
|
2633
|
+
it "binds on the event bus" do
|
|
2634
|
+
expect(Tina4::Events.events).to include("#{event}")
|
|
2635
|
+
expect(Tina4::Events.listeners("#{event}").length).to be >= 1
|
|
2636
|
+
end
|
|
2637
|
+
|
|
2638
|
+
it "emitting reaches the listener (strict re-raises the stub)" do
|
|
2639
|
+
expect { Tina4::Events.emit("#{event}", { "id" => 1 }, strict: true) }.to raise_error(NotImplementedError)
|
|
2640
|
+
end
|
|
2641
|
+
end
|
|
2642
|
+
RUBY
|
|
2643
|
+
write_test(slug, content)
|
|
2644
|
+
end
|
|
2645
|
+
|
|
2646
|
+
# auth -> real register / login / me end-to-end via the real TestClient.
|
|
2647
|
+
# No dynamic names (always User + /api/auth/*), so a fully-literal
|
|
2648
|
+
# single-quoted heredoc keeps the emitted `#{token}` interpolation intact.
|
|
2649
|
+
def emit_auth_test
|
|
2650
|
+
content = <<~'RUBY'
|
|
2651
|
+
# frozen_string_literal: true
|
|
2652
|
+
#
|
|
2653
|
+
# Real auth spec — register / login / me via the real TestClient.
|
|
2654
|
+
#
|
|
2655
|
+
# Generated with the auth scaffold by `tina4ruby generate auth`. No mocks:
|
|
2656
|
+
# real Router, real Auth (PBKDF2 + JWT), real SQLite. register + login are
|
|
2657
|
+
# public (.no_auth); the token from login authenticates /api/auth/me.
|
|
2658
|
+
require "spec_helper"
|
|
2659
|
+
require "tmpdir"
|
|
2660
|
+
require_relative "../src/orm/user"
|
|
2661
|
+
|
|
2662
|
+
RSpec.describe "Auth (register / login / me via real TestClient)" do
|
|
2663
|
+
let(:client) { Tina4::TestClient.new }
|
|
2664
|
+
|
|
2665
|
+
before(:each) do
|
|
2666
|
+
@dir = Dir.mktmpdir("auth_spec")
|
|
2667
|
+
# Fresh RSA key material so get_token / valid_token agree.
|
|
2668
|
+
Tina4::Auth.instance_variable_set(:@private_key, nil)
|
|
2669
|
+
Tina4::Auth.instance_variable_set(:@public_key, nil)
|
|
2670
|
+
Tina4::Auth.instance_variable_set(:@keys_dir, nil)
|
|
2671
|
+
Tina4::Auth.setup(@dir)
|
|
2672
|
+
# A stray API key would authorise every request regardless of the JWT.
|
|
2673
|
+
@prior_api_key = ENV.delete("TINA4_API_KEY")
|
|
2674
|
+
Tina4.bind_database(Tina4::Database.new("sqlite:///" + File.join(@dir, "auth.db")))
|
|
2675
|
+
User.create_table
|
|
2676
|
+
# `load` (not require) re-registers the routes after spec_helper's
|
|
2677
|
+
# after(:each) Router.clear! wipes them between examples.
|
|
2678
|
+
load File.expand_path("../src/routes/auth.rb", __dir__)
|
|
2679
|
+
end
|
|
2680
|
+
|
|
2681
|
+
after(:each) do
|
|
2682
|
+
ENV["TINA4_API_KEY"] = @prior_api_key if @prior_api_key
|
|
2683
|
+
FileUtils.rm_rf(@dir)
|
|
2684
|
+
end
|
|
2685
|
+
|
|
2686
|
+
it "register, then duplicate 409, then login, then me with the token" do
|
|
2687
|
+
reg = client.post("/api/auth/register", json: { email: "a@b.c", password: "secret12" })
|
|
2688
|
+
expect(reg.status).to eq(201)
|
|
2689
|
+
|
|
2690
|
+
dup = client.post("/api/auth/register", json: { email: "a@b.c", password: "secret12" })
|
|
2691
|
+
expect(dup.status).to eq(409)
|
|
2692
|
+
|
|
2693
|
+
login = client.post("/api/auth/login", json: { email: "a@b.c", password: "secret12" })
|
|
2694
|
+
expect(login.status).to eq(200)
|
|
2695
|
+
token = login.json["token"]
|
|
2696
|
+
expect(token).not_to be_nil
|
|
2697
|
+
|
|
2698
|
+
me = client.get("/api/auth/me", headers: { "Authorization" => "Bearer #{token}" })
|
|
2699
|
+
expect(me.status).to eq(200)
|
|
2700
|
+
expect(me.json["email"]).to eq("a@b.c")
|
|
2701
|
+
end
|
|
2702
|
+
|
|
2703
|
+
it "login with the wrong password is 401" do
|
|
2704
|
+
client.post("/api/auth/register", json: { email: "x@y.z", password: "secret12" })
|
|
2705
|
+
bad = client.post("/api/auth/login", json: { email: "x@y.z", password: "WRONG" })
|
|
2706
|
+
expect(bad.status).to eq(401)
|
|
2707
|
+
end
|
|
2708
|
+
|
|
2709
|
+
it "me without a token is 401" do
|
|
2710
|
+
expect(client.get("/api/auth/me").status).to eq(401)
|
|
2711
|
+
end
|
|
2712
|
+
end
|
|
2713
|
+
RUBY
|
|
2714
|
+
write_test("auth", content)
|
|
2715
|
+
end
|
|
2716
|
+
|
|
2717
|
+
# migration (create_*) -> apply UP then DOWN against real SQLite via the real
|
|
2718
|
+
# Migration runner, asserting the table appears then disappears. Only for
|
|
2719
|
+
# CREATE migrations — a placeholder ALTER migration has no real SQL yet.
|
|
2720
|
+
def emit_migration_test(migration_name, table, _up_file, _down_file)
|
|
2721
|
+
content = <<~RUBY
|
|
2722
|
+
# frozen_string_literal: true
|
|
2723
|
+
#
|
|
2724
|
+
# Real migration spec for #{migration_name} — no mocks, real SQLite.
|
|
2725
|
+
#
|
|
2726
|
+
# Generated with the migration by `tina4ruby generate migration
|
|
2727
|
+
# #{migration_name}`. Runs the migration through the REAL Tina4::Migration
|
|
2728
|
+
# runner against a fresh real SQLite database, asserts the table exists,
|
|
2729
|
+
# then rolls back and asserts it is gone — the raw UP/DOWN SQL the runner
|
|
2730
|
+
# executes.
|
|
2731
|
+
require "spec_helper"
|
|
2732
|
+
require "tmpdir"
|
|
2733
|
+
|
|
2734
|
+
RSpec.describe "#{pascalize(table)} migration (real SQLite up/down)" do
|
|
2735
|
+
it "UP creates the table and DOWN drops it" do
|
|
2736
|
+
dir = Dir.mktmpdir("#{table}_migration")
|
|
2737
|
+
db = Tina4::Database.new("sqlite:///" + File.join(dir, "mig.db"))
|
|
2738
|
+
migrations_dir = File.expand_path("../migrations", __dir__)
|
|
2739
|
+
migration = Tina4::Migration.new(db, migrations_dir: migrations_dir)
|
|
2740
|
+
|
|
2741
|
+
migration.run
|
|
2742
|
+
expect(db.table_exists?("#{table}")).to be true
|
|
2743
|
+
|
|
2744
|
+
migration.rollback(1)
|
|
2745
|
+
expect(db.table_exists?("#{table}")).to be false
|
|
2746
|
+
|
|
2747
|
+
db.close
|
|
2748
|
+
FileUtils.rm_rf(dir)
|
|
2749
|
+
end
|
|
2750
|
+
end
|
|
2751
|
+
RUBY
|
|
2752
|
+
write_test("#{table}_migration", content)
|
|
2753
|
+
end
|
|
2754
|
+
|
|
1309
2755
|
# ── help ──────────────────────────────────────────────────────────────
|
|
1310
2756
|
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
Metrics:
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
Field types: string, int, float, bool, text, datetime, blob
|
|
1353
|
-
Table names: singular by default (Product -> product)
|
|
1354
|
-
|
|
1355
|
-
https://tina4.com
|
|
1356
|
-
|
|
1357
|
-
Run 'tina4ruby COMMAND --help' for more information on a command.
|
|
1358
|
-
|
|
2757
|
+
# Print the human-readable command reference.
|
|
2758
|
+
#
|
|
2759
|
+
# Generated from the COMMANDS and GENERATORS registries — the SAME single
|
|
2760
|
+
# source of truth that drives dispatch (#run / #cmd_generate) and the
|
|
2761
|
+
# `commands --json` manifest — so the help text can never drift from what
|
|
2762
|
+
# the CLI actually does.
|
|
2763
|
+
def cmd_help(_argv = nil)
|
|
2764
|
+
command_rows = COMMANDS.map do |name, spec|
|
|
2765
|
+
["#{name} #{spec[:usage]}".rstrip, spec[:summary]]
|
|
2766
|
+
end
|
|
2767
|
+
generator_rows = GENERATORS.map do |name, spec|
|
|
2768
|
+
["generate #{name} #{spec[:usage]}".rstrip, spec[:summary]]
|
|
2769
|
+
end
|
|
2770
|
+
# Align summaries in a column; a left cell longer than the cap overflows
|
|
2771
|
+
# cleanly (2-space gap) rather than pushing every other summary out.
|
|
2772
|
+
pad = [46, (command_rows + generator_rows).map { |left, _| left.length }.max].min
|
|
2773
|
+
|
|
2774
|
+
row = lambda do |left, summary|
|
|
2775
|
+
gap = left.length <= pad ? pad : left.length
|
|
2776
|
+
" #{left.ljust(gap)} #{summary}"
|
|
2777
|
+
end
|
|
2778
|
+
|
|
2779
|
+
lines = ["Tina4 Ruby CLI", "", "Usage: tina4ruby COMMAND [options]", "", "Commands:"]
|
|
2780
|
+
lines += command_rows.map { |left, summary| row.call(left, summary) }
|
|
2781
|
+
lines += ["", "Generators:"]
|
|
2782
|
+
lines += generator_rows.map { |left, summary| row.call(left, summary) }
|
|
2783
|
+
lines += [
|
|
2784
|
+
"",
|
|
2785
|
+
"Scaffolding-first: logic-shaped generators (route without --model, service,",
|
|
2786
|
+
"queue, validator, seeder, websocket, listener) emit wiring + an AI-FILL",
|
|
2787
|
+
"placeholder (raise NotImplementedError) where the custom logic goes; CRUD-",
|
|
2788
|
+
"shaped ones emit working code. Writes are secure by default; use --public",
|
|
2789
|
+
"to open them.",
|
|
2790
|
+
"",
|
|
2791
|
+
"Metrics:",
|
|
2792
|
+
" metrics [--top N] [--json] [--fail-on warn|error] [--path DIR]",
|
|
2793
|
+
" --top N Show only the worst N offenders (default: 20)",
|
|
2794
|
+
" --json Print machine-readable JSON ({summary, offenders}) for CI",
|
|
2795
|
+
" --fail-on Exit 1 if any offender at/above this severity (warn|error)",
|
|
2796
|
+
" --path DIR Scan DIR (default: src/, auto-resolves to the framework)",
|
|
2797
|
+
"",
|
|
2798
|
+
"Field types: string, int, float, bool, text, datetime, blob",
|
|
2799
|
+
"Table names: singular by default (Product -> product)",
|
|
2800
|
+
"",
|
|
2801
|
+
"https://tina4.com",
|
|
2802
|
+
"",
|
|
2803
|
+
"Run 'tina4ruby COMMAND --help' for more information on a command.",
|
|
2804
|
+
]
|
|
2805
|
+
puts lines.join("\n")
|
|
2806
|
+
end
|
|
2807
|
+
|
|
2808
|
+
# ── commands (self-describing manifest) ─────────────────────────────────
|
|
2809
|
+
|
|
2810
|
+
# Build the machine-readable manifest of the CLI's command surface.
|
|
2811
|
+
#
|
|
2812
|
+
# Pure data: reads the COMMANDS registry and the framework version — no
|
|
2813
|
+
# bootstrap, no database, no migrations, no app imports. This is exactly
|
|
2814
|
+
# what `commands --json` serializes and what the tina4 client consumes to
|
|
2815
|
+
# discover which commands this framework supports.
|
|
2816
|
+
#
|
|
2817
|
+
# Shape:
|
|
2818
|
+
# { "framework" => "ruby", "version" => "<x.y.z>",
|
|
2819
|
+
# "commands" => [ { "name", "summary", "args"?, "subcommands"? }, ... ] }
|
|
2820
|
+
def commands_manifest
|
|
2821
|
+
require_relative "version"
|
|
2822
|
+
commands = COMMANDS.map do |name, spec|
|
|
2823
|
+
entry = { "name" => name, "summary" => spec[:summary] }
|
|
2824
|
+
entry["args"] = spec[:args].dup if spec[:args]
|
|
2825
|
+
entry["subcommands"] = spec[:subcommands].dup if spec[:subcommands]
|
|
2826
|
+
entry
|
|
2827
|
+
end
|
|
2828
|
+
{ "framework" => "ruby", "version" => Tina4::VERSION, "commands" => commands }
|
|
2829
|
+
end
|
|
2830
|
+
|
|
2831
|
+
# Emit the CLI's own command surface — the self-describing manifest.
|
|
2832
|
+
#
|
|
2833
|
+
# tina4ruby commands human-readable list
|
|
2834
|
+
# tina4ruby commands --json machine-readable manifest (for the tina4 client)
|
|
2835
|
+
#
|
|
2836
|
+
# CHEAP + side-effect-free by contract: it only prints the static COMMANDS
|
|
2837
|
+
# registry plus the framework version. It MUST NOT bootstrap the framework,
|
|
2838
|
+
# open a database, run migrations, or load app modules — the tina4 client
|
|
2839
|
+
# calls this on `tina4 --help`, in any directory, so it must be instant and
|
|
2840
|
+
# safe to run anywhere.
|
|
2841
|
+
def cmd_commands(argv = nil)
|
|
2842
|
+
require "json"
|
|
2843
|
+
argv = argv || []
|
|
2844
|
+
manifest = commands_manifest
|
|
2845
|
+
|
|
2846
|
+
if argv.include?("--json")
|
|
2847
|
+
puts JSON.pretty_generate(manifest)
|
|
2848
|
+
return
|
|
2849
|
+
end
|
|
2850
|
+
|
|
2851
|
+
puts "\nTina4 #{manifest['framework']} - #{manifest['version']}\n\n"
|
|
2852
|
+
width = manifest["commands"].map { |command| command["name"].length }.max
|
|
2853
|
+
manifest["commands"].each do |command|
|
|
2854
|
+
puts " #{command['name'].ljust(width)} #{command['summary']}"
|
|
2855
|
+
if command["subcommands"]
|
|
2856
|
+
puts " #{''.ljust(width)} #{command['subcommands'].join(', ')}"
|
|
2857
|
+
end
|
|
2858
|
+
end
|
|
2859
|
+
puts
|
|
1359
2860
|
end
|
|
1360
2861
|
|
|
1361
2862
|
# ── config resolution ──────────────────────────────────────────────────
|