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.
data/lib/tina4/cli.rb CHANGED
@@ -5,7 +5,75 @@ require "fileutils"
5
5
 
6
6
  module Tina4
7
7
  class CLI
8
- COMMANDS = %w[init start migrate migrate:status migrate:rollback seed seed:create test version routes console generate ai metrics help].freeze
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
- case command
33
- when "init" then cmd_init(argv)
34
- when "start", "serve" then cmd_start(argv)
35
- when "migrate" then cmd_migrate(argv)
36
- when "migrate:status" then cmd_migrate_status(argv)
37
- when "migrate:rollback" then cmd_migrate_rollback(argv)
38
- when "seed" then cmd_seed(argv)
39
- when "seed:create" then cmd_seed_create(argv)
40
- when "test" then cmd_test(argv)
41
- when "version" then cmd_version
42
- when "routes" then cmd_routes
43
- when "console" then cmd_console
44
- when "generate" then cmd_generate(argv)
45
- when "ai" then cmd_ai(argv)
46
- when "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: model, route, crud, migration, middleware, test, form, view, auth"
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
- case what
619
- when "model" then generate_model(name, flags)
620
- when "route" then generate_route(name, flags)
621
- when "crud" then generate_crud(name, flags)
622
- when "migration" then generate_migration(name, flags)
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: model, route, crud, migration, middleware, test, form, view, auth"
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, table_override: table)
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
- def generate_route(name, flags)
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
- Tina4.get "/api/#{route_path}" do |request, response|
701
- # List all #{route_path} with pagination
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
- results = #{model}.all(limit: per_page, offset: offset)
706
- response.json({ data: results.map(&:to_h), page: page, per_page: per_page })
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
- Tina4.get "/api/#{route_path}/{id:int}" do |request, response|
710
- # Get a single #{singular} by ID
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
- response.json({ error: "Not found" }, 404)
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
- Tina4.post "/api/#{route_path}" do |request, response|
720
- # Create a new #{singular}
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
- Tina4.put "/api/#{route_path}/{id:int}" do |request, response|
726
- # Update a #{singular} by ID
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
- response.json({ error: "Not found" }, 404)
730
- else
731
- request.body.each do |key, value|
732
- next if key.to_s == "id"
733
- setter = "#{'#'}{key}="
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
- end
1203
+ item.save
1204
+ response.json(item.to_h)
1205
+ end#{no_auth}
740
1206
 
741
- Tina4.delete "/api/#{route_path}/{id:int}" do |request, response|
742
- # Delete a #{singular} by ID
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
- response.json({ error: "Not found" }, 404)
746
- else
747
- item.delete
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
- Tina4.get "/api/#{route_path}" do |request, response|
755
- # List all #{route_path}
756
- response.json({ data: [] })
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
- Tina4.get "/api/#{route_path}/{id:int}" do |request, response|
760
- # Get a single #{singular}
761
- response.json({ data: {} })
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
- Tina4.post "/api/#{route_path}" do |request, response|
765
- # Create a new #{singular}
766
- response.json({ data: request.body }, 201)
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
- Tina4.put "/api/#{route_path}/{id:int}" do |request, response|
770
- # Update a #{singular}
771
- response.json({ data: request.body })
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
- Tina4.delete "/api/#{route_path}/{id:int}" do |request, response|
775
- # Delete a #{singular}
776
- response.json(nil, 204)
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
- generate_model(name, flags)
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
- generate_route(route_name, { "model" => name })
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
- down_path = File.join(dir, "#{timestamp}_#{name}.down.sql")
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.request_method} \#{request.path}")
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
- dir = "spec"
928
- FileUtils.mkdir_p(dir)
929
- path = File.join(dir, "#{snake}_spec.rb")
930
- if File.exist?(path)
931
- puts " File already exists: #{path}"
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
- File.write(path, content)
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
- generate_model("User", { "fields" => "email:string,password:string,role:string" })
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
- Tina4.post "/api/auth/register" do |request, response|
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
- Tina4.post "/api/auth/login" do |request, response|
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
- generate_test("auth", { "model" => "User" })
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
- def cmd_help
1312
- puts <<~HELP
1313
- Tina4 Ruby CLI
1314
-
1315
- Usage: tina4ruby COMMAND [options]
1316
-
1317
- Commands:
1318
- init [NAME] Initialize a new Tina4 project
1319
- start Start the Tina4 web server
1320
- serve Alias for start
1321
- migrate Run database migrations
1322
- migrate:status Show migration status (completed and pending)
1323
- migrate:rollback Rollback the last batch of migrations
1324
- seed Run all seed files in seeds/
1325
- seed:create NAME Create a new seed file
1326
- test Run inline tests
1327
- version Show Tina4 version
1328
- routes List all registered routes
1329
- console Start an interactive console
1330
- ai Detect AI tools and install context files
1331
- metrics Rank top code-quality offenders
1332
- help Show this help message
1333
-
1334
- Generators:
1335
- generate model <Name> [--fields "name:string,price:float"]
1336
- generate route <name> [--model Name]
1337
- generate crud <Name> [--fields "..."] Model + migration + routes + form + view + test
1338
- generate migration <description>
1339
- generate middleware <Name>
1340
- generate test <name>
1341
- generate form <Name> [--fields "..."] Form template with inputs matching model fields
1342
- generate view <Name> [--fields "..."] List + detail templates for viewing records
1343
- generate auth Login/register/logout routes + User model + templates
1344
-
1345
- Metrics:
1346
- metrics [--top N] [--json] [--fail-on warn|error] [--path DIR]
1347
- --top N Show only the worst N offenders (default: 20)
1348
- --json Print machine-readable JSON ({summary, offenders}) for CI
1349
- --fail-on Exit 1 if any offender at/above this severity (warn|error)
1350
- --path DIR Scan DIR (default: src/, auto-resolves to the framework)
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
- HELP
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 ──────────────────────────────────────────────────