tina4ruby 3.13.64 → 3.13.67

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
@@ -148,7 +208,7 @@ module Tina4
148
208
  # Parse --key value and --flag from args. Returns [flags_hash, positional_array]
149
209
  def parse_flags(args)
150
210
  # Boolean-only flags that never take a value argument
151
- boolean_flags = %w[no-browser no-reload production managed all clear dev json public no-migration]
211
+ boolean_flags = %w[no-browser no-reload production managed all clear dev json public no-migration once]
152
212
 
153
213
  flags = {}
154
214
  positional = []
@@ -361,6 +421,26 @@ module Tina4
361
421
  end
362
422
  end
363
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
+
364
444
  # ── migrate:status ─────────────────────────────────────────────────────
365
445
 
366
446
  def cmd_migrate_status(_argv)
@@ -502,16 +582,304 @@ module Tina4
502
582
  exit(1) if results[:failed] > 0 || results[:errors] > 0
503
583
  end
504
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
+
505
873
  # ── version ───────────────────────────────────────────────────────────
506
874
 
507
- def cmd_version
875
+ def cmd_version(_argv = nil)
508
876
  require_relative "version"
509
877
  puts "Tina4 Ruby v#{Tina4::VERSION}"
510
878
  end
511
879
 
512
880
  # ── routes ────────────────────────────────────────────────────────────
513
881
 
514
- def cmd_routes
882
+ def cmd_routes(_argv = nil)
515
883
  require_relative "../tina4"
516
884
  Tina4.initialize!(Dir.pwd)
517
885
  load_routes(Dir.pwd)
@@ -528,7 +896,7 @@ module Tina4
528
896
 
529
897
  # ── console ───────────────────────────────────────────────────────────
530
898
 
531
- def cmd_console
899
+ def cmd_console(_argv = nil)
532
900
  require_relative "../tina4"
533
901
  Tina4.initialize!(Dir.pwd)
534
902
  load_routes(Dir.pwd)
@@ -655,15 +1023,13 @@ module Tina4
655
1023
 
656
1024
  # ── generate ────────────────────────────────────────────────────────
657
1025
 
658
- ALL_GENERATORS = "model, route, crud, migration, middleware, test, form, view, auth, " \
659
- "service, queue, validator, seeder, websocket, listener"
660
-
661
1026
  def cmd_generate(argv)
662
1027
  what = argv.shift
1028
+ all = GENERATORS.keys.join(", ") # single source: the GENERATORS registry
663
1029
 
664
1030
  unless what
665
1031
  puts "Usage: tina4ruby generate <what> <name> [options]"
666
- puts " Generators: #{ALL_GENERATORS}"
1032
+ puts " Generators: #{all}"
667
1033
  puts ' Options: --fields "name:string,price:float" --model ModelName'
668
1034
  puts ' --public open a route'"'"'s writes (default: secure)'
669
1035
  puts ' --every 5m | --cron "..." service schedule'
@@ -682,32 +1048,21 @@ module Tina4
682
1048
  name = no_name_generators.include?(what) ? "" : argv.shift
683
1049
  flags, _positional = parse_flags(argv)
684
1050
 
685
- case what
686
- when "model" then generate_model(name, flags)
687
- when "route" then generate_route(name, flags)
688
- when "crud" then generate_crud(name, flags)
689
- when "migration" then generate_migration(name, flags)
690
- when "middleware" then generate_middleware(name, flags)
691
- when "test" then generate_test(name, flags)
692
- when "form" then generate_form(name, flags)
693
- when "view" then generate_view(name, flags)
694
- when "auth" then generate_auth(name, flags)
695
- when "service" then generate_service(name, flags)
696
- when "queue" then generate_queue(name, flags)
697
- when "validator" then generate_validator(name, flags)
698
- when "seeder" then generate_seeder(name, flags)
699
- when "websocket" then generate_websocket(name, flags)
700
- when "listener" then generate_listener(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)
701
1056
  else
702
1057
  puts "Unknown generator: #{what}"
703
- puts " Available: #{ALL_GENERATORS}"
1058
+ puts " Available: #{all}"
704
1059
  exit 1
705
1060
  end
706
1061
  end
707
1062
 
708
1063
  # ── Generator: model ─────────────────────────────────────────────────
709
1064
 
710
- def generate_model(name, flags)
1065
+ def generate_model(name, flags, emit_test: true)
711
1066
  fields = parse_fields(flags["fields"])
712
1067
  table = to_table_name(name)
713
1068
  snake = to_snake_case(name)
@@ -744,10 +1099,16 @@ module Tina4
744
1099
  File.write(path, content)
745
1100
  puts " Created #{path}"
746
1101
 
747
- # 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).
748
1105
  unless flags["no-migration"]
749
- 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)
750
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
751
1112
  end
752
1113
 
753
1114
  # ── Generator: route ─────────────────────────────────────────────────
@@ -768,7 +1129,7 @@ module Tina4
768
1129
  # `Tina4::Router.get/post/...` (no bearer auth_handler attached), so `.no_auth`
769
1130
  # genuinely opens the write on BOTH the live server and the TestClient — the
770
1131
  # same registration path AutoCrud uses.
771
- def generate_route(name, flags)
1132
+ def generate_route(name, flags, emit_test: true)
772
1133
  route_path = name.sub(%r{^/}, "")
773
1134
  singular = route_path.end_with?("s") ? route_path[0..-2] : route_path
774
1135
  model = flags["model"]
@@ -929,6 +1290,18 @@ module Tina4
929
1290
 
930
1291
  File.write(path, content)
931
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
932
1305
  end
933
1306
 
934
1307
  # ── Generator: crud ──────────────────────────────────────────────────
@@ -940,12 +1313,14 @@ module Tina4
940
1313
 
941
1314
  puts "\n Generating CRUD for #{name}...\n"
942
1315
 
943
- # 1. Model + migration
944
- 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)
945
1319
 
946
1320
  # 2. Routes with model — secure-by-default; thread --public through so
947
1321
  # `generate crud X --public` opens the writes (mirrors AutoCrud public:).
948
- generate_route(route_name, { "model" => name, "public" => is_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)
949
1324
 
950
1325
  # 3. Form
951
1326
  generate_form(name, flags)
@@ -963,7 +1338,7 @@ module Tina4
963
1338
 
964
1339
  # ── Generator: migration ─────────────────────────────────────────────
965
1340
 
966
- 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)
967
1342
  now = Time.now
968
1343
  timestamp = now.strftime("%Y%m%d%H%M%S")
969
1344
  dir = "migrations"
@@ -1000,22 +1375,24 @@ module Tina4
1000
1375
  down_sql = "-- Write your DOWN rollback SQL here\n-- Example: ALTER TABLE #{table} DROP COLUMN new_col;"
1001
1376
  end
1002
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).
1003
1383
  content = <<~SQL
1004
1384
  -- Migration: #{name}
1005
1385
  -- Created: #{now.strftime("%Y-%m-%d %H:%M:%S")}
1006
1386
 
1007
- -- UP
1008
1387
  #{up_sql}
1009
-
1010
- -- DOWN
1011
- #{down_sql}
1012
1388
  SQL
1013
1389
 
1014
1390
  File.write(path, content)
1015
1391
  puts " Created #{path}"
1016
1392
 
1017
1393
  # Also create .down.sql for the migration runner
1018
- down_path = File.join(dir, "#{timestamp}_#{name}.down.sql")
1394
+ down_filename = "#{timestamp}_#{name}.down.sql"
1395
+ down_path = File.join(dir, down_filename)
1019
1396
  down_content = <<~SQL
1020
1397
  -- Rollback: #{name}
1021
1398
  -- Created: #{now.strftime("%Y-%m-%d %H:%M:%S")}
@@ -1025,6 +1402,10 @@ module Tina4
1025
1402
 
1026
1403
  File.write(down_path, down_content)
1027
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
1028
1409
  end
1029
1410
 
1030
1411
  # ── Generator: middleware ────────────────────────────────────────────
@@ -1053,7 +1434,7 @@ module Tina4
1053
1434
  # Runs before the route handler.
1054
1435
  # Return [request, response] to continue, or
1055
1436
  # return [request, response.json({ error: "Unauthorized" }, 401)] to block.
1056
- Tina4::Log.info("#{name}: \#{request.request_method} \#{request.path}")
1437
+ Tina4::Log.info("#{name}: \#{request.method} \#{request.path}")
1057
1438
  [request, response]
1058
1439
  end
1059
1440
 
@@ -1066,6 +1447,10 @@ module Tina4
1066
1447
 
1067
1448
  File.write(path, content)
1068
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)
1069
1454
  end
1070
1455
 
1071
1456
  # ── Generator: test ──────────────────────────────────────────────────
@@ -1075,14 +1460,6 @@ module Tina4
1075
1460
  snake = to_snake_case(name)
1076
1461
  singular = snake.end_with?("s") ? snake[0..-2] : snake
1077
1462
 
1078
- dir = "spec"
1079
- FileUtils.mkdir_p(dir)
1080
- path = File.join(dir, "#{snake}_spec.rb")
1081
- if File.exist?(path)
1082
- puts " File already exists: #{path}"
1083
- return
1084
- end
1085
-
1086
1463
  # Secure-by-default CRUD spec (emitted by `generate crud`): proves the gate
1087
1464
  # by BEHAVIOR through the real Tina4::TestClient — reads public, writes
1088
1465
  # gated — instead of assuming an anonymous create returns 201. Grounded on
@@ -1157,8 +1534,7 @@ module Tina4
1157
1534
  #{write_examples}
1158
1535
  end
1159
1536
  RUBY
1160
- File.write(path, content)
1161
- puts " Created #{path}"
1537
+ write_test(snake, content)
1162
1538
  return
1163
1539
  end
1164
1540
 
@@ -1221,8 +1597,7 @@ module Tina4
1221
1597
  RUBY
1222
1598
  end
1223
1599
 
1224
- File.write(path, content)
1225
- puts " Created #{path}"
1600
+ write_test(snake, content)
1226
1601
  end
1227
1602
 
1228
1603
  # ── Generator: form ──────────────────────────────────────────────────
@@ -1394,8 +1769,9 @@ module Tina4
1394
1769
  def generate_auth(_name = nil, flags = {})
1395
1770
  puts "\n Generating authentication scaffolding...\n"
1396
1771
 
1397
- # 1. User model + migration
1398
- 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)
1399
1775
 
1400
1776
  # 2. Auth routes
1401
1777
  dir = "src/routes"
@@ -1530,8 +1906,9 @@ module Tina4
1530
1906
  puts " Created #{register_path}"
1531
1907
  end
1532
1908
 
1533
- # 5. Auth test
1534
- 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
1535
1912
 
1536
1913
  puts "\n Authentication scaffolding complete."
1537
1914
  puts " Run: tina4ruby migrate"
@@ -1609,6 +1986,9 @@ module Tina4
1609
1986
  RUBY
1610
1987
  File.write(path, content)
1611
1988
  puts " Created #{path}"
1989
+
1990
+ # Co-emit a real ServiceRunner registration/discovery spec.
1991
+ emit_service_test(name, snake)
1612
1992
  end
1613
1993
 
1614
1994
  # ── Generator: queue ──────────────────────────────────────────────────
@@ -1665,11 +2045,17 @@ module Tina4
1665
2045
  end
1666
2046
 
1667
2047
  # Wiring: consumer runs as a daemon service (manages its own poll loop).
1668
- # Discovered by Tina4::ServiceRunner.discover("src/services").
1669
- Tina4.service("#{topic}-consumer", daemon: true) { |context| consume_#{slug}(context) }
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) }
1670
2053
  RUBY
1671
2054
  File.write(path, content)
1672
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)
1673
2059
  end
1674
2060
 
1675
2061
  # ── Generator: validator ──────────────────────────────────────────────
@@ -1685,15 +2071,15 @@ module Tina4
1685
2071
  return
1686
2072
  end
1687
2073
 
1688
- body = ai_fill(
1689
- "validate_#{snake}",
1690
- "declare the validation rules for a #{name} payload",
1691
- 'validator.required("name").email("email").min_length("name", 2).integer("age")',
1692
- "validator:#{snake}: add the rule set",
1693
- given: "validator -> Tina4::Validator.new(data) (chainable)",
1694
- ret: "the same validator (caller checks .is_valid? / .errors)",
1695
- ground: 'tina4_context("validate request body with Validator", "ruby")'
1696
- )
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)
1697
2083
  content = <<~RUBY
1698
2084
  # #{name} request validator.
1699
2085
  #
@@ -1713,6 +2099,9 @@ module Tina4
1713
2099
  RUBY
1714
2100
  File.write(path, content)
1715
2101
  puts " Created #{path}"
2102
+
2103
+ # Co-emit a real valid + invalid spec against the starter rule.
2104
+ emit_validator_test(name, snake)
1716
2105
  end
1717
2106
 
1718
2107
  # ── Generator: seeder ─────────────────────────────────────────────────
@@ -1729,15 +2118,14 @@ module Tina4
1729
2118
  return
1730
2119
  end
1731
2120
 
1732
- body = ai_fill(
1733
- "#{table}_field_overrides",
1734
- "map #{name} fields to fake-data generators (only those needing a specific shape)",
1735
- "fake.name / fake.email / fake.integer(min: 1, max: 99) / fake.company (Tina4::FakeData)",
1736
- "seeder:#{name}: return the field->value overrides Hash",
1737
- given: "fake -> Tina4::FakeData instance",
1738
- ret: '{ "email" => ->(f) { f.email }, "status" => "active" } (Hash)',
1739
- ground: 'tina4_context("seed ORM model with FakeData", "ruby")'
1740
- )
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)
1741
2129
  content = <<~RUBY
1742
2130
  # Seeder for #{name} — run with: tina4ruby seed
1743
2131
  #
@@ -1755,9 +2143,9 @@ module Tina4
1755
2143
  end
1756
2144
 
1757
2145
  # Wiring: seed rows via Tina4.seed_orm. Guarded on a bound database so
1758
- # merely LOADING this file (e.g. in a spec) does not run the unfilled
1759
- # placeholder — `tina4ruby seed` binds the DB first, at which point the
1760
- # override raises LOUD until filled.
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.
1761
2149
  if Tina4.database
1762
2150
  fake = Tina4::FakeData.new
1763
2151
  summary = Tina4.seed_orm(#{name}, count: 20, overrides: #{table}_field_overrides(fake))
@@ -1766,6 +2154,9 @@ module Tina4
1766
2154
  RUBY
1767
2155
  File.write(path, content)
1768
2156
  puts " Created #{path}"
2157
+
2158
+ # Co-emit a real seeding spec (runs the seeder against real SQLite).
2159
+ emit_seeder_test(name, table)
1769
2160
  end
1770
2161
 
1771
2162
  # ── Generator: websocket ──────────────────────────────────────────────
@@ -1820,6 +2211,9 @@ module Tina4
1820
2211
  RUBY
1821
2212
  File.write(path, content)
1822
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)
1823
2217
  end
1824
2218
 
1825
2219
  # ── Generator: listener ───────────────────────────────────────────────
@@ -1865,70 +2259,604 @@ module Tina4
1865
2259
  RUBY
1866
2260
  File.write(path, content)
1867
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)
1868
2753
  end
1869
2754
 
1870
2755
  # ── help ──────────────────────────────────────────────────────────────
1871
2756
 
1872
- def cmd_help
1873
- puts <<~HELP
1874
- Tina4 Ruby CLI
1875
-
1876
- Usage: tina4ruby COMMAND [options]
1877
-
1878
- Commands:
1879
- init [NAME] Initialize a new Tina4 project
1880
- start Start the Tina4 web server
1881
- serve Alias for start
1882
- migrate Run database migrations
1883
- migrate:status Show migration status (completed and pending)
1884
- migrate:rollback Rollback the last batch of migrations
1885
- seed Run all seed files in seeds/
1886
- seed:create NAME Create a new seed file
1887
- test Run inline tests
1888
- version Show Tina4 version
1889
- routes List all registered routes
1890
- console Start an interactive console
1891
- ai Detect AI tools and install context files
1892
- metrics Rank top code-quality offenders
1893
- help Show this help message
1894
-
1895
- Generators:
1896
- generate model <Name> [--fields "name:string,price:float"]
1897
- generate route <name> [--model Name] [--public] Writes secure by default; --public opens them
1898
- generate crud <Name> [--fields "..."] [--public] Model + migration + routes + form + view + test
1899
- generate migration <description>
1900
- generate middleware <Name>
1901
- generate test <name>
1902
- generate form <Name> [--fields "..."] Form template with inputs matching model fields
1903
- generate view <Name> [--fields "..."] List + detail templates for viewing records
1904
- generate auth Login/register/logout routes + User model + templates
1905
- generate service <Name> [--every 5m | --cron "..."] Scheduled ServiceRunner task (src/services/)
1906
- generate queue <topic> Producer + consumer worker (src/services/)
1907
- generate validator <Name> Request-body Validator (src/validators/)
1908
- generate seeder <Model> FakeData + seed_orm seeder (seeds/)
1909
- generate websocket <path> Tina4.websocket handler (src/routes/)
1910
- generate listener <event> Tina4::Events.on listener (src/listeners/)
1911
-
1912
- Scaffolding-first: logic-shaped generators (route without --model, service,
1913
- queue, validator, seeder, websocket, listener) emit wiring + an AI-FILL
1914
- placeholder (raise NotImplementedError) where the custom logic goes; CRUD-
1915
- shaped ones emit working code. Writes are secure by default — use --public
1916
- to open them.
1917
-
1918
- Metrics:
1919
- metrics [--top N] [--json] [--fail-on warn|error] [--path DIR]
1920
- --top N Show only the worst N offenders (default: 20)
1921
- --json Print machine-readable JSON ({summary, offenders}) for CI
1922
- --fail-on Exit 1 if any offender at/above this severity (warn|error)
1923
- --path DIR Scan DIR (default: src/, auto-resolves to the framework)
1924
-
1925
- Field types: string, int, float, bool, text, datetime, blob
1926
- Table names: singular by default (Product -> product)
1927
-
1928
- https://tina4.com
1929
-
1930
- Run 'tina4ruby COMMAND --help' for more information on a command.
1931
- 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
1932
2860
  end
1933
2861
 
1934
2862
  # ── config resolution ──────────────────────────────────────────────────