tina4ruby 3.13.63 → 3.13.64
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/lib/tina4/cli.rb +640 -67
- data/lib/tina4/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 51edb881061c12c5202ab116cf7dc77ea41796f9c80ca3779efd7f276477e8c4
|
|
4
|
+
data.tar.gz: f90f1434a4516d9f2ac3307eade7ad8894293ad48a1f09490777b9b61ccd792c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e24b430ca3420fa69b13bc12dfb66a38f74b43c686fb662b68e45a8077d719b29380e970e20ae2620f321308f1bae692495605e302b703ad49ce8169ca3db55e
|
|
7
|
+
data.tar.gz: 30817fef46332e51ec88ee0938710047b95ac40b94ac6d1795db54bde3cae55392ebc99a1d65db9499ba704a6184068627a3f7cb5921c38e46091b7d7a5ab10d
|
data/lib/tina4/cli.rb
CHANGED
|
@@ -83,10 +83,72 @@ module Tina4
|
|
|
83
83
|
end.compact
|
|
84
84
|
end
|
|
85
85
|
|
|
86
|
+
# Canonical AI-FILL placeholder block for a LOGIC-shaped stub.
|
|
87
|
+
#
|
|
88
|
+
# A tight, grounded *fill-spec* — not a vague ``# TODO`` — so a coding agent
|
|
89
|
+
# (or dev) completes it correctly and idiomatically. `raise
|
|
90
|
+
# NotImplementedError` makes an unfilled scaffold fail LOUD, and the
|
|
91
|
+
# greppable AI-FILL banner lets a human/agent jump to every gap in a file.
|
|
92
|
+
# <= 6 comment lines; `Use:` names only REAL tina4-ruby symbols (verified in
|
|
93
|
+
# the framework source, file:line).
|
|
94
|
+
#
|
|
95
|
+
# <indent># ─── AI-FILL: <fn> ───────────────────────────
|
|
96
|
+
# <indent># Intent: <what this must do>
|
|
97
|
+
# <indent># Given: <inputs + shape>
|
|
98
|
+
# <indent># Use: <named REAL Tina4 API — the idiomatic path>
|
|
99
|
+
# <indent># Return: <exact return value + status>
|
|
100
|
+
# <indent># Ground: tina4_context("<intent>", "ruby") · skill tina4-developer-ruby
|
|
101
|
+
# <indent>raise NotImplementedError, "<feature>: <what>" # remove when done
|
|
102
|
+
# <indent># ─────────────────────────────────────────────
|
|
103
|
+
def ai_fill(fn, intent, use, raise_msg, given: nil, ret: nil, ground: nil, indent: " ")
|
|
104
|
+
bar = "─" * 60
|
|
105
|
+
head = "#{indent}# ─── AI-FILL: #{fn} "
|
|
106
|
+
head += "─" * [4, 66 - head.length].max
|
|
107
|
+
lines = [head, "#{indent}# Intent: #{intent}"]
|
|
108
|
+
lines << "#{indent}# Given: #{given}" if given
|
|
109
|
+
lines << "#{indent}# Use: #{use}"
|
|
110
|
+
lines << "#{indent}# Return: #{ret}" if ret
|
|
111
|
+
lines << "#{indent}# Ground: #{ground}" if ground
|
|
112
|
+
lines << "#{indent}raise NotImplementedError, #{raise_msg.inspect} # remove when done"
|
|
113
|
+
lines << "#{indent}# #{bar}"
|
|
114
|
+
lines.join("\n") + "\n"
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Lighter EXTEND marker for CRUD-shaped WORKING code. Marks the natural
|
|
118
|
+
# extension point in generated code that already runs — NO
|
|
119
|
+
# NotImplementedError (the boilerplate IS the feature); just a greppable hint
|
|
120
|
+
# at where custom validation / business rules go.
|
|
121
|
+
def extend_marker(note, hint = "", indent: " ")
|
|
122
|
+
head = "#{indent}# ─── EXTEND: #{note} "
|
|
123
|
+
head += "─" * [4, 66 - head.length].max
|
|
124
|
+
out = head + "\n"
|
|
125
|
+
out += "#{indent}# #{hint}\n" unless hint.to_s.empty?
|
|
126
|
+
out
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Parse a --every duration ('5m', '30s', '2h', '1d', or bare seconds) ->
|
|
130
|
+
# seconds. Falls back to 60s on an empty/unparseable value so a scaffold
|
|
131
|
+
# always has a valid interval for Tina4.service(interval: ...).
|
|
132
|
+
def parse_every(every)
|
|
133
|
+
return 60 if every.nil? || every == true
|
|
134
|
+
every = every.to_s.strip.downcase
|
|
135
|
+
return 60 if every.empty?
|
|
136
|
+
units = { "s" => 1, "m" => 60, "h" => 3600, "d" => 86400 }
|
|
137
|
+
unit = every[-1]
|
|
138
|
+
if units.key?(unit)
|
|
139
|
+
[1, (every[0..-2].to_f * units[unit]).to_i].max
|
|
140
|
+
else
|
|
141
|
+
n = every.to_f
|
|
142
|
+
n.positive? ? [1, n.to_i].max : 60
|
|
143
|
+
end
|
|
144
|
+
rescue StandardError
|
|
145
|
+
60
|
|
146
|
+
end
|
|
147
|
+
|
|
86
148
|
# Parse --key value and --flag from args. Returns [flags_hash, positional_array]
|
|
87
149
|
def parse_flags(args)
|
|
88
150
|
# Boolean-only flags that never take a value argument
|
|
89
|
-
boolean_flags = %w[no-browser no-reload production managed all clear dev json]
|
|
151
|
+
boolean_flags = %w[no-browser no-reload production managed all clear dev json public no-migration]
|
|
90
152
|
|
|
91
153
|
flags = {}
|
|
92
154
|
positional = []
|
|
@@ -593,13 +655,18 @@ module Tina4
|
|
|
593
655
|
|
|
594
656
|
# ── generate ────────────────────────────────────────────────────────
|
|
595
657
|
|
|
658
|
+
ALL_GENERATORS = "model, route, crud, migration, middleware, test, form, view, auth, " \
|
|
659
|
+
"service, queue, validator, seeder, websocket, listener"
|
|
660
|
+
|
|
596
661
|
def cmd_generate(argv)
|
|
597
662
|
what = argv.shift
|
|
598
663
|
|
|
599
664
|
unless what
|
|
600
665
|
puts "Usage: tina4ruby generate <what> <name> [options]"
|
|
601
|
-
puts " Generators:
|
|
666
|
+
puts " Generators: #{ALL_GENERATORS}"
|
|
602
667
|
puts ' Options: --fields "name:string,price:float" --model ModelName'
|
|
668
|
+
puts ' --public open a route'"'"'s writes (default: secure)'
|
|
669
|
+
puts ' --every 5m | --cron "..." service schedule'
|
|
603
670
|
exit 1
|
|
604
671
|
end
|
|
605
672
|
|
|
@@ -625,9 +692,15 @@ module Tina4
|
|
|
625
692
|
when "form" then generate_form(name, flags)
|
|
626
693
|
when "view" then generate_view(name, flags)
|
|
627
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)
|
|
628
701
|
else
|
|
629
702
|
puts "Unknown generator: #{what}"
|
|
630
|
-
puts " Available:
|
|
703
|
+
puts " Available: #{ALL_GENERATORS}"
|
|
631
704
|
exit 1
|
|
632
705
|
end
|
|
633
706
|
end
|
|
@@ -679,10 +752,27 @@ module Tina4
|
|
|
679
752
|
|
|
680
753
|
# ── Generator: route ─────────────────────────────────────────────────
|
|
681
754
|
|
|
755
|
+
# Generate a CRUD route file — SECURE BY DEFAULT.
|
|
756
|
+
#
|
|
757
|
+
# tina4ruby generate route products
|
|
758
|
+
# tina4ruby generate route products --model Product
|
|
759
|
+
# tina4ruby generate route products --model Product --public # open writes
|
|
760
|
+
#
|
|
761
|
+
# Writes (POST/PUT/DELETE) are Bearer-token-gated by default: the router sets
|
|
762
|
+
# auth_required on POST/PUT/PATCH/DELETE (lib/tina4/router.rb:24) and
|
|
763
|
+
# enforce_route_auth 401s a tokenless write (lib/tina4/rack_app.rb:1129).
|
|
764
|
+
# Reads (GET) are public by default, so no opt-out is emitted for them.
|
|
765
|
+
# `--public` chains `.no_auth` on the write handlers as the explicit opt-out
|
|
766
|
+
# — mirroring AutoCrud's `post_route.no_auth if is_public`
|
|
767
|
+
# (lib/tina4/auto_crud.rb:169). The generated routes register through
|
|
768
|
+
# `Tina4::Router.get/post/...` (no bearer auth_handler attached), so `.no_auth`
|
|
769
|
+
# genuinely opens the write on BOTH the live server and the TestClient — the
|
|
770
|
+
# same registration path AutoCrud uses.
|
|
682
771
|
def generate_route(name, flags)
|
|
683
772
|
route_path = name.sub(%r{^/}, "")
|
|
684
773
|
singular = route_path.end_with?("s") ? route_path[0..-2] : route_path
|
|
685
774
|
model = flags["model"]
|
|
775
|
+
public_writes = flags["public"] ? true : false
|
|
686
776
|
|
|
687
777
|
dir = "src/routes"
|
|
688
778
|
FileUtils.mkdir_p(dir)
|
|
@@ -692,89 +782,148 @@ module Tina4
|
|
|
692
782
|
return
|
|
693
783
|
end
|
|
694
784
|
|
|
785
|
+
# `.no_auth` is chained on the WRITE handlers only when writes go public.
|
|
786
|
+
no_auth = public_writes ? ".no_auth" : ""
|
|
787
|
+
write_doc =
|
|
788
|
+
if public_writes
|
|
789
|
+
"Public (--public): no token required."
|
|
790
|
+
else
|
|
791
|
+
"Secure by default: requires a Bearer token (use --public to open)."
|
|
792
|
+
end
|
|
793
|
+
|
|
695
794
|
if model
|
|
696
795
|
model_snake = to_snake_case(model)
|
|
796
|
+
# WORKING code (the boilerplate IS the feature) + EXTEND markers at the
|
|
797
|
+
# natural extension points (no NotImplementedError).
|
|
798
|
+
ext_create = extend_marker(
|
|
799
|
+
"validate / business rules before persist",
|
|
800
|
+
'e.g. reject invalid input; ground: tina4_context("validate before create", "ruby")'
|
|
801
|
+
)
|
|
802
|
+
ext_update = extend_marker(
|
|
803
|
+
"guard which fields / who may update",
|
|
804
|
+
'e.g. enforce ownership; ground: tina4_context("authorize update", "ruby")'
|
|
805
|
+
)
|
|
697
806
|
content = <<~RUBY
|
|
698
807
|
require_relative "../orm/#{model_snake}"
|
|
699
808
|
|
|
700
|
-
|
|
701
|
-
|
|
809
|
+
# List all #{route_path} with pagination — public read (GET is ungated).
|
|
810
|
+
Tina4::Router.get "/api/#{route_path}" do |request, response|
|
|
702
811
|
page = (request.params["page"] || 1).to_i
|
|
703
812
|
per_page = (request.params["per_page"] || 20).to_i
|
|
704
813
|
offset = (page - 1) * per_page
|
|
705
|
-
|
|
706
|
-
response.json({ data:
|
|
814
|
+
records = #{model}.all(limit: per_page, offset: offset)
|
|
815
|
+
response.json({ data: records.map(&:to_h), count: #{model}.count, page: page, per_page: per_page })
|
|
707
816
|
end
|
|
708
817
|
|
|
709
|
-
|
|
710
|
-
|
|
818
|
+
# Get a single #{singular} by ID — public read.
|
|
819
|
+
Tina4::Router.get "/api/#{route_path}/{id:int}" do |request, response|
|
|
711
820
|
item = #{model}.find(request.params["id"])
|
|
712
|
-
if item.nil?
|
|
713
|
-
|
|
714
|
-
else
|
|
715
|
-
response.json(item.to_h)
|
|
716
|
-
end
|
|
821
|
+
next response.json({ error: "Not found" }, 404) if item.nil?
|
|
822
|
+
response.json(item.to_h)
|
|
717
823
|
end
|
|
718
824
|
|
|
719
|
-
|
|
720
|
-
|
|
825
|
+
# Create a new #{singular}. #{write_doc}
|
|
826
|
+
Tina4::Router.post "/api/#{route_path}" do |request, response|
|
|
827
|
+
#{ext_create.chomp}
|
|
721
828
|
item = #{model}.create(request.body)
|
|
722
829
|
response.json(item.to_h, 201)
|
|
723
|
-
end
|
|
830
|
+
end#{no_auth}
|
|
724
831
|
|
|
725
|
-
|
|
726
|
-
|
|
832
|
+
# Update a #{singular} by ID. #{write_doc}
|
|
833
|
+
Tina4::Router.put "/api/#{route_path}/{id:int}" do |request, response|
|
|
727
834
|
item = #{model}.find(request.params["id"])
|
|
728
|
-
if item.nil?
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
item.send(setter, value) if item.respond_to?(setter)
|
|
735
|
-
end
|
|
736
|
-
item.save
|
|
737
|
-
response.json(item.to_h)
|
|
835
|
+
next response.json({ error: "Not found" }, 404) if item.nil?
|
|
836
|
+
#{ext_update.chomp}
|
|
837
|
+
request.body.each do |key, value|
|
|
838
|
+
next if key.to_s == "id"
|
|
839
|
+
setter = "#{'#'}{key}="
|
|
840
|
+
item.send(setter, value) if item.respond_to?(setter)
|
|
738
841
|
end
|
|
739
|
-
|
|
842
|
+
item.save
|
|
843
|
+
response.json(item.to_h)
|
|
844
|
+
end#{no_auth}
|
|
740
845
|
|
|
741
|
-
|
|
742
|
-
|
|
846
|
+
# Delete a #{singular} by ID. #{write_doc}
|
|
847
|
+
Tina4::Router.delete "/api/#{route_path}/{id:int}" do |request, response|
|
|
743
848
|
item = #{model}.find(request.params["id"])
|
|
744
|
-
if item.nil?
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
response.json(nil, 204)
|
|
749
|
-
end
|
|
750
|
-
end
|
|
849
|
+
next response.json({ error: "Not found" }, 404) if item.nil?
|
|
850
|
+
item.delete
|
|
851
|
+
response.json(nil, 204)
|
|
852
|
+
end#{no_auth}
|
|
751
853
|
RUBY
|
|
752
854
|
else
|
|
855
|
+
# CUSTOM route — no model. Every handler body is a LOGIC-shaped stub:
|
|
856
|
+
# AI-FILL fill-spec + raise NotImplementedError (fails loud until filled).
|
|
857
|
+
m = singular.split("_").map(&:capitalize).join # PascalCase hint
|
|
858
|
+
b_list = ai_fill(
|
|
859
|
+
"list_#{route_path}",
|
|
860
|
+
"return the #{route_path} collection (add pagination if it grows)",
|
|
861
|
+
"#{m}.all(limit:, offset:) or #{m}.where(sql, params) (require_relative \"../orm/#{to_snake_case(m)}\")",
|
|
862
|
+
"list_#{route_path}: query and return the records",
|
|
863
|
+
ret: 'response.json({ data: rows.map(&:to_h) })',
|
|
864
|
+
ground: 'tina4_context("list ORM records with pagination", "ruby")'
|
|
865
|
+
)
|
|
866
|
+
b_get = ai_fill(
|
|
867
|
+
"get_#{singular}",
|
|
868
|
+
"fetch one #{singular} by id",
|
|
869
|
+
"#{m}.find(request.params[\"id\"]) (require_relative \"../orm/#{to_snake_case(m)}\")",
|
|
870
|
+
"get_#{singular}: fetch by id or 404",
|
|
871
|
+
given: 'request.params["id"] -> Integer',
|
|
872
|
+
ret: 'response.json(item.to_h) or response.json({ error: "Not found" }, 404)',
|
|
873
|
+
ground: 'tina4_context("find ORM record by id", "ruby")'
|
|
874
|
+
)
|
|
875
|
+
b_create = ai_fill(
|
|
876
|
+
"create_#{singular}",
|
|
877
|
+
"validate the body and persist a new #{singular}",
|
|
878
|
+
"#{m}.create(request.body) (require_relative \"../orm/#{to_snake_case(m)}\")",
|
|
879
|
+
"create_#{singular}: persist and return the new record",
|
|
880
|
+
given: "request.body -> Hash of fields",
|
|
881
|
+
ret: "response.json(item.to_h, 201)",
|
|
882
|
+
ground: 'tina4_context("create ORM record and return 201", "ruby")'
|
|
883
|
+
)
|
|
884
|
+
b_update = ai_fill(
|
|
885
|
+
"update_#{singular}",
|
|
886
|
+
"load, mutate and save an existing #{singular}",
|
|
887
|
+
"#{m}.find(request.params[\"id\"]) then set fields and item.save",
|
|
888
|
+
"update_#{singular}: apply changes and return the record",
|
|
889
|
+
given: 'request.params["id"] -> Integer; request.body -> changed fields',
|
|
890
|
+
ret: "response.json(item.to_h) or 404",
|
|
891
|
+
ground: 'tina4_context("update ORM record", "ruby")'
|
|
892
|
+
)
|
|
893
|
+
b_delete = ai_fill(
|
|
894
|
+
"delete_#{singular}",
|
|
895
|
+
"delete a #{singular} by id",
|
|
896
|
+
"#{m}.find(request.params[\"id\"]) then item.delete",
|
|
897
|
+
"delete_#{singular}: delete and return 204",
|
|
898
|
+
given: 'request.params["id"] -> Integer',
|
|
899
|
+
ret: "response.json(nil, 204) or 404",
|
|
900
|
+
ground: 'tina4_context("delete ORM record", "ruby")'
|
|
901
|
+
)
|
|
753
902
|
content = <<~RUBY
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
903
|
+
# List all #{route_path} — public read (GET is ungated).
|
|
904
|
+
Tina4::Router.get "/api/#{route_path}" do |request, response|
|
|
905
|
+
#{b_list.chomp}
|
|
757
906
|
end
|
|
758
907
|
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
908
|
+
# Get a single #{singular} — public read.
|
|
909
|
+
Tina4::Router.get "/api/#{route_path}/{id:int}" do |request, response|
|
|
910
|
+
#{b_get.chomp}
|
|
762
911
|
end
|
|
763
912
|
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
end
|
|
913
|
+
# Create a new #{singular}. #{write_doc}
|
|
914
|
+
Tina4::Router.post "/api/#{route_path}" do |request, response|
|
|
915
|
+
#{b_create.chomp}
|
|
916
|
+
end#{no_auth}
|
|
768
917
|
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
end
|
|
918
|
+
# Update a #{singular}. #{write_doc}
|
|
919
|
+
Tina4::Router.put "/api/#{route_path}/{id:int}" do |request, response|
|
|
920
|
+
#{b_update.chomp}
|
|
921
|
+
end#{no_auth}
|
|
773
922
|
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
end
|
|
923
|
+
# Delete a #{singular}. #{write_doc}
|
|
924
|
+
Tina4::Router.delete "/api/#{route_path}/{id:int}" do |request, response|
|
|
925
|
+
#{b_delete.chomp}
|
|
926
|
+
end#{no_auth}
|
|
778
927
|
RUBY
|
|
779
928
|
end
|
|
780
929
|
|
|
@@ -787,14 +936,16 @@ module Tina4
|
|
|
787
936
|
def generate_crud(name, flags)
|
|
788
937
|
table = to_table_name(name)
|
|
789
938
|
route_name = "#{table}s"
|
|
939
|
+
is_public = flags["public"] ? true : false
|
|
790
940
|
|
|
791
941
|
puts "\n Generating CRUD for #{name}...\n"
|
|
792
942
|
|
|
793
943
|
# 1. Model + migration
|
|
794
944
|
generate_model(name, flags)
|
|
795
945
|
|
|
796
|
-
# 2. Routes with model
|
|
797
|
-
|
|
946
|
+
# 2. Routes with model — secure-by-default; thread --public through so
|
|
947
|
+
# `generate crud X --public` opens the writes (mirrors AutoCrud public:).
|
|
948
|
+
generate_route(route_name, { "model" => name, "public" => is_public })
|
|
798
949
|
|
|
799
950
|
# 3. Form
|
|
800
951
|
generate_form(name, flags)
|
|
@@ -802,8 +953,8 @@ module Tina4
|
|
|
802
953
|
# 4. View (list + detail)
|
|
803
954
|
generate_view(name, flags)
|
|
804
955
|
|
|
805
|
-
# 5. Test
|
|
806
|
-
generate_test(route_name, { "model" => name })
|
|
956
|
+
# 5. Test — secure-by-default gate test (behavioural, real TestClient).
|
|
957
|
+
generate_test(route_name, { "model" => name, "secure_writes" => true, "public" => is_public })
|
|
807
958
|
|
|
808
959
|
puts "\n CRUD generation complete for #{name}."
|
|
809
960
|
puts " Run: tina4ruby migrate"
|
|
@@ -932,6 +1083,85 @@ module Tina4
|
|
|
932
1083
|
return
|
|
933
1084
|
end
|
|
934
1085
|
|
|
1086
|
+
# Secure-by-default CRUD spec (emitted by `generate crud`): proves the gate
|
|
1087
|
+
# by BEHAVIOR through the real Tina4::TestClient — reads public, writes
|
|
1088
|
+
# gated — instead of assuming an anonymous create returns 201. Grounded on
|
|
1089
|
+
# spec/test_client_auth_spec.rb (real Router, real enforce_route_auth, real
|
|
1090
|
+
# JWT via Tina4::Auth.get_token / valid_token signed with real RSA keys).
|
|
1091
|
+
if model && flags["secure_writes"]
|
|
1092
|
+
model_snake = to_snake_case(model)
|
|
1093
|
+
posture = flags["public"] ? "open (--public)" : "gated"
|
|
1094
|
+
write_examples =
|
|
1095
|
+
if flags["public"]
|
|
1096
|
+
<<~RUBY.chomp
|
|
1097
|
+
it "allows an anonymous POST (201) — --public opened the write" do
|
|
1098
|
+
res = client.post("/api/#{snake}", json: { name: "test" })
|
|
1099
|
+
expect(res.status).to eq(201)
|
|
1100
|
+
end
|
|
1101
|
+
RUBY
|
|
1102
|
+
else
|
|
1103
|
+
<<~RUBY.chomp
|
|
1104
|
+
it "rejects a tokenless POST with 401 (secure by default)" do
|
|
1105
|
+
expect(client.post("/api/#{snake}", json: { name: "test" }).status).to eq(401)
|
|
1106
|
+
end
|
|
1107
|
+
|
|
1108
|
+
it "creates with a valid Bearer token (201)" do
|
|
1109
|
+
token = Tina4::Auth.get_token({ "user_id" => 1 })
|
|
1110
|
+
res = client.post("/api/#{snake}", json: { name: "test" },
|
|
1111
|
+
headers: { "Authorization" => "Bearer #{'#'}{token}" })
|
|
1112
|
+
expect(res.status).to eq(201)
|
|
1113
|
+
end
|
|
1114
|
+
RUBY
|
|
1115
|
+
end
|
|
1116
|
+
|
|
1117
|
+
content = <<~RUBY
|
|
1118
|
+
# frozen_string_literal: true
|
|
1119
|
+
#
|
|
1120
|
+
# #{model} CRUD gate — reads public, writes #{posture} (secure by default).
|
|
1121
|
+
#
|
|
1122
|
+
# Real end-to-end via Tina4::TestClient: NO mocks — real Router, real auth
|
|
1123
|
+
# gate (Tina4::RackApp.enforce_route_auth), real JWT. A real SQLite DB +
|
|
1124
|
+
# table is bound in before(:each), so the create path is exercised for real.
|
|
1125
|
+
require "spec_helper"
|
|
1126
|
+
require "tmpdir"
|
|
1127
|
+
require_relative "../src/orm/#{model_snake}"
|
|
1128
|
+
|
|
1129
|
+
RSpec.describe "#{model} CRUD (reads public, writes #{posture})" do
|
|
1130
|
+
let(:client) { Tina4::TestClient.new }
|
|
1131
|
+
|
|
1132
|
+
before(:each) do
|
|
1133
|
+
@dir = Dir.mktmpdir("#{snake}_crud")
|
|
1134
|
+
# Fresh RSA key material so get_token / valid_token agree.
|
|
1135
|
+
Tina4::Auth.instance_variable_set(:@private_key, nil)
|
|
1136
|
+
Tina4::Auth.instance_variable_set(:@public_key, nil)
|
|
1137
|
+
Tina4::Auth.instance_variable_set(:@keys_dir, nil)
|
|
1138
|
+
Tina4::Auth.setup(@dir)
|
|
1139
|
+
# A stray API key would authorise every write regardless of the JWT.
|
|
1140
|
+
@prior_api_key = ENV.delete("TINA4_API_KEY")
|
|
1141
|
+
Tina4.bind_database(Tina4::Database.new("sqlite:///" + File.join(@dir, "test.db")))
|
|
1142
|
+
#{model}.create_table
|
|
1143
|
+
# `load` (not require) re-registers the routes after spec_helper's
|
|
1144
|
+
# after(:each) Router.clear! wipes them between examples.
|
|
1145
|
+
load File.expand_path("../src/routes/#{snake}.rb", __dir__)
|
|
1146
|
+
end
|
|
1147
|
+
|
|
1148
|
+
after(:each) do
|
|
1149
|
+
ENV["TINA4_API_KEY"] = @prior_api_key if @prior_api_key
|
|
1150
|
+
FileUtils.rm_rf(@dir)
|
|
1151
|
+
end
|
|
1152
|
+
|
|
1153
|
+
it "serves GET (read) publicly" do
|
|
1154
|
+
expect(client.get("/api/#{snake}").status).to eq(200)
|
|
1155
|
+
end
|
|
1156
|
+
|
|
1157
|
+
#{write_examples}
|
|
1158
|
+
end
|
|
1159
|
+
RUBY
|
|
1160
|
+
File.write(path, content)
|
|
1161
|
+
puts " Created #{path}"
|
|
1162
|
+
return
|
|
1163
|
+
end
|
|
1164
|
+
|
|
935
1165
|
if model
|
|
936
1166
|
content = <<~RUBY
|
|
937
1167
|
# Tests for #{name} CRUD operations
|
|
@@ -1175,7 +1405,10 @@ module Tina4
|
|
|
1175
1405
|
content = <<~'RUBY'
|
|
1176
1406
|
require_relative "../orm/user"
|
|
1177
1407
|
|
|
1178
|
-
|
|
1408
|
+
# PUBLIC: register mints accounts before any token exists — clear BOTH
|
|
1409
|
+
# write gates (auth: false drops the bearer auth_handler; .no_auth
|
|
1410
|
+
# clears the router's auth_required).
|
|
1411
|
+
Tina4.post "/api/auth/register", auth: false do |request, response|
|
|
1179
1412
|
# Register a new user
|
|
1180
1413
|
email = request.body["email"].to_s
|
|
1181
1414
|
password = request.body["password"].to_s
|
|
@@ -1197,9 +1430,10 @@ module Tina4
|
|
|
1197
1430
|
role: "user",
|
|
1198
1431
|
})
|
|
1199
1432
|
response.json({ message: "Registered", id: user.id }, 201)
|
|
1200
|
-
end
|
|
1433
|
+
end.no_auth
|
|
1201
1434
|
|
|
1202
|
-
|
|
1435
|
+
# PUBLIC: login mints the token — clear BOTH write gates (see register).
|
|
1436
|
+
Tina4.post "/api/auth/login", auth: false do |request, response|
|
|
1203
1437
|
# Login with email and password
|
|
1204
1438
|
email = request.body["email"].to_s
|
|
1205
1439
|
password = request.body["password"].to_s
|
|
@@ -1216,7 +1450,7 @@ module Tina4
|
|
|
1216
1450
|
|
|
1217
1451
|
token = Tina4::Auth.get_token({ user_id: user.id, email: user.email, role: user.role })
|
|
1218
1452
|
response.json({ token: token })
|
|
1219
|
-
end
|
|
1453
|
+
end.no_auth
|
|
1220
1454
|
|
|
1221
1455
|
Tina4.get "/api/auth/me" do |request, response|
|
|
1222
1456
|
# Get current authenticated user
|
|
@@ -1306,6 +1540,333 @@ module Tina4
|
|
|
1306
1540
|
puts " GET /api/auth/me - get profile (requires token)"
|
|
1307
1541
|
end
|
|
1308
1542
|
|
|
1543
|
+
# ── Scaffolding-first logic generators (wiring + AI-FILL placeholder) ─────
|
|
1544
|
+
#
|
|
1545
|
+
# Each grounds on the REAL current tina4-ruby API (verified against the
|
|
1546
|
+
# source, file:line) and drops the ai_fill() AI-FILL placeholder (raise
|
|
1547
|
+
# NotImplementedError) where the custom logic goes:
|
|
1548
|
+
# service -> lib/tina4/service_runner.rb Tina4::ServiceRunner.register/.discover/.list
|
|
1549
|
+
# (via the Tina4.service DSL, lib/tina4.rb:431)
|
|
1550
|
+
# queue -> lib/tina4/queue.rb Tina4::Queue#push / #consume
|
|
1551
|
+
# lib/tina4/job.rb Job#payload / #complete / #fail
|
|
1552
|
+
# validator -> lib/tina4/validator.rb Tina4::Validator (#required/#email/#is_valid?)
|
|
1553
|
+
# seeder -> lib/tina4/seeder.rb Tina4::FakeData / Tina4.seed_orm
|
|
1554
|
+
# websocket -> lib/tina4/router.rb Tina4.websocket (connection, event, data)
|
|
1555
|
+
# listener -> lib/tina4/events.rb Tina4::Events.on / .emit
|
|
1556
|
+
|
|
1557
|
+
# ── Generator: service ────────────────────────────────────────────────
|
|
1558
|
+
#
|
|
1559
|
+
# tina4ruby generate service Cleanup --every 5m
|
|
1560
|
+
# tina4ruby generate service Report --cron "0 3 * * *"
|
|
1561
|
+
def generate_service(name, flags = {})
|
|
1562
|
+
snake = to_snake_case(name)
|
|
1563
|
+
cron = flags["cron"]
|
|
1564
|
+
if cron && cron != true
|
|
1565
|
+
options_kw = "timing: #{cron.inspect}" # ServiceRunner cron key is :timing
|
|
1566
|
+
note = "cron '#{cron}'"
|
|
1567
|
+
else
|
|
1568
|
+
seconds = parse_every(flags["every"])
|
|
1569
|
+
options_kw = "interval: #{seconds}"
|
|
1570
|
+
note = "every #{seconds}s"
|
|
1571
|
+
end
|
|
1572
|
+
|
|
1573
|
+
dir = "src/services"
|
|
1574
|
+
FileUtils.mkdir_p(dir)
|
|
1575
|
+
path = File.join(dir, "#{snake}.rb")
|
|
1576
|
+
if File.exist?(path)
|
|
1577
|
+
puts " File already exists: #{path}"
|
|
1578
|
+
return
|
|
1579
|
+
end
|
|
1580
|
+
|
|
1581
|
+
body = ai_fill(
|
|
1582
|
+
"#{snake}_task",
|
|
1583
|
+
"do the scheduled work for this service",
|
|
1584
|
+
"context.name / context.running (Tina4::ServiceContext); call your ORM / app code",
|
|
1585
|
+
"service:#{snake}: implement the scheduled task",
|
|
1586
|
+
given: "context -> Tina4::ServiceContext (.name, .running, .last_run, .error_count)",
|
|
1587
|
+
ground: 'tina4_context("background service scheduled task", "ruby")'
|
|
1588
|
+
)
|
|
1589
|
+
content = <<~RUBY
|
|
1590
|
+
# #{name} background service — runs #{note} via Tina4::ServiceRunner.
|
|
1591
|
+
#
|
|
1592
|
+
# Registered on load by Tina4.service(...). `tina4ruby start` does NOT
|
|
1593
|
+
# auto-start services (src/services is not on the boot auto-discover path,
|
|
1594
|
+
# lib/tina4.rb:566). Wire a runner explicitly to run it:
|
|
1595
|
+
#
|
|
1596
|
+
# Tina4::ServiceRunner.discover("src/services") # loads this file
|
|
1597
|
+
# Tina4::ServiceRunner.start
|
|
1598
|
+
#
|
|
1599
|
+
# The block receives a Tina4::ServiceContext; a daemon-style task would
|
|
1600
|
+
# loop while context.running.
|
|
1601
|
+
|
|
1602
|
+
def #{snake}_task(context)
|
|
1603
|
+
#{body.chomp}
|
|
1604
|
+
end
|
|
1605
|
+
|
|
1606
|
+
# Wiring: registers "#{snake}" on the class-level ServiceRunner registry;
|
|
1607
|
+
# appears in Tina4::ServiceRunner.list.
|
|
1608
|
+
Tina4.service("#{snake}", #{options_kw}) { |context| #{snake}_task(context) }
|
|
1609
|
+
RUBY
|
|
1610
|
+
File.write(path, content)
|
|
1611
|
+
puts " Created #{path}"
|
|
1612
|
+
end
|
|
1613
|
+
|
|
1614
|
+
# ── Generator: queue ──────────────────────────────────────────────────
|
|
1615
|
+
#
|
|
1616
|
+
# tina4ruby generate queue order-emails
|
|
1617
|
+
def generate_queue(name, flags = {})
|
|
1618
|
+
topic = name.sub(%r{^/}, "")
|
|
1619
|
+
slug = to_snake_case(topic.gsub(/[^0-9a-zA-Z]+/, "_")).gsub(/\A_+|_+\z/, "")
|
|
1620
|
+
slug = "topic" if slug.empty?
|
|
1621
|
+
|
|
1622
|
+
dir = "src/services" # consumer runs as a daemon service (see below)
|
|
1623
|
+
FileUtils.mkdir_p(dir)
|
|
1624
|
+
path = File.join(dir, "#{slug}_consumer.rb")
|
|
1625
|
+
if File.exist?(path)
|
|
1626
|
+
puts " File already exists: #{path}"
|
|
1627
|
+
return
|
|
1628
|
+
end
|
|
1629
|
+
|
|
1630
|
+
body = ai_fill(
|
|
1631
|
+
"handle_#{slug}",
|
|
1632
|
+
"process ONE #{topic} job payload",
|
|
1633
|
+
"your ORM / Tina4::Messenger code; return to ack (job.complete), raise to nack (job.fail)",
|
|
1634
|
+
"queue:#{topic}: process the job payload",
|
|
1635
|
+
given: "payload -> the pushed Hash (job.payload)",
|
|
1636
|
+
ground: 'tina4_context("process a queue job", "ruby")'
|
|
1637
|
+
)
|
|
1638
|
+
content = <<~RUBY
|
|
1639
|
+
# #{topic} queue — producer + consumer worker.
|
|
1640
|
+
#
|
|
1641
|
+
# Produce from anywhere: publish_#{slug}({ ... })
|
|
1642
|
+
# The consumer is a long-running worker wired as a daemon service so
|
|
1643
|
+
# Tina4::ServiceRunner.discover("src/services"); Tina4::ServiceRunner.start
|
|
1644
|
+
# runs it without blocking boot (consume polls forever).
|
|
1645
|
+
|
|
1646
|
+
# Enqueue a #{topic} job for the worker below to process. Returns the Tina4::Job.
|
|
1647
|
+
def publish_#{slug}(payload)
|
|
1648
|
+
Tina4::Queue.new(topic: "#{topic}").push(payload)
|
|
1649
|
+
end
|
|
1650
|
+
|
|
1651
|
+
# Process ONE #{topic} job payload (`payload` is job.payload — the pushed data).
|
|
1652
|
+
def handle_#{slug}(payload)
|
|
1653
|
+
#{body.chomp}
|
|
1654
|
+
end
|
|
1655
|
+
|
|
1656
|
+
# Long-running #{topic} worker. consume yields Jobs; ack with job.complete,
|
|
1657
|
+
# nack with job.fail. `context` is the Tina4::ServiceContext under ServiceRunner.
|
|
1658
|
+
def consume_#{slug}(context = nil)
|
|
1659
|
+
Tina4::Queue.new(topic: "#{topic}").consume do |job|
|
|
1660
|
+
handle_#{slug}(job.payload)
|
|
1661
|
+
job.complete # ack — job done, removed from the queue
|
|
1662
|
+
rescue StandardError => e
|
|
1663
|
+
job.fail(e.message) # nack — retry / dead-letter
|
|
1664
|
+
end
|
|
1665
|
+
end
|
|
1666
|
+
|
|
1667
|
+
# 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) }
|
|
1670
|
+
RUBY
|
|
1671
|
+
File.write(path, content)
|
|
1672
|
+
puts " Created #{path}"
|
|
1673
|
+
end
|
|
1674
|
+
|
|
1675
|
+
# ── Generator: validator ──────────────────────────────────────────────
|
|
1676
|
+
#
|
|
1677
|
+
# tina4ruby generate validator CreateUser
|
|
1678
|
+
def generate_validator(name, flags = {})
|
|
1679
|
+
snake = to_snake_case(name)
|
|
1680
|
+
dir = "src/validators"
|
|
1681
|
+
FileUtils.mkdir_p(dir)
|
|
1682
|
+
path = File.join(dir, "#{snake}.rb")
|
|
1683
|
+
if File.exist?(path)
|
|
1684
|
+
puts " File already exists: #{path}"
|
|
1685
|
+
return
|
|
1686
|
+
end
|
|
1687
|
+
|
|
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
|
+
)
|
|
1697
|
+
content = <<~RUBY
|
|
1698
|
+
# #{name} request validator.
|
|
1699
|
+
#
|
|
1700
|
+
# Tina4::Validator is NOT part of the default `require "tina4"` surface
|
|
1701
|
+
# (unlike Queue / Events / ServiceRunner), so require it explicitly here.
|
|
1702
|
+
# NOT auto-loaded — require this file from the route that validates:
|
|
1703
|
+
# require_relative "../validators/#{snake}"
|
|
1704
|
+
# v = validate_#{snake}(request.body)
|
|
1705
|
+
# next response.json({ error: v.errors.first[:message] }, 400) unless v.is_valid?
|
|
1706
|
+
require "tina4/validator"
|
|
1707
|
+
|
|
1708
|
+
def validate_#{snake}(data)
|
|
1709
|
+
validator = Tina4::Validator.new(data)
|
|
1710
|
+
#{body.chomp}
|
|
1711
|
+
validator
|
|
1712
|
+
end
|
|
1713
|
+
RUBY
|
|
1714
|
+
File.write(path, content)
|
|
1715
|
+
puts " Created #{path}"
|
|
1716
|
+
end
|
|
1717
|
+
|
|
1718
|
+
# ── Generator: seeder ─────────────────────────────────────────────────
|
|
1719
|
+
#
|
|
1720
|
+
# tina4ruby generate seeder Product
|
|
1721
|
+
def generate_seeder(name, flags = {})
|
|
1722
|
+
table = to_table_name(name)
|
|
1723
|
+
model_snake = to_snake_case(name)
|
|
1724
|
+
dir = "seeds"
|
|
1725
|
+
FileUtils.mkdir_p(dir)
|
|
1726
|
+
path = File.join(dir, "#{table}_seeder.rb")
|
|
1727
|
+
if File.exist?(path)
|
|
1728
|
+
puts " File already exists: #{path}"
|
|
1729
|
+
return
|
|
1730
|
+
end
|
|
1731
|
+
|
|
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
|
+
)
|
|
1741
|
+
content = <<~RUBY
|
|
1742
|
+
# Seeder for #{name} — run with: tina4ruby seed
|
|
1743
|
+
#
|
|
1744
|
+
# Executed top-to-bottom by `tina4ruby seed` (Tina4.seed_dir loads each
|
|
1745
|
+
# file in seeds/, lib/tina4/seeder.rb:890). Tina4.seed_orm auto-fills every
|
|
1746
|
+
# field by type/name; override the ones that need a specific shape via
|
|
1747
|
+
# #{table}_field_overrides.
|
|
1748
|
+
require_relative "../src/orm/#{model_snake}"
|
|
1749
|
+
|
|
1750
|
+
# Map #{name} fields -> Tina4::FakeData generators (or static values).
|
|
1751
|
+
# Each callable receives a FakeData instance:
|
|
1752
|
+
# { "email" => ->(fake) { fake.email }, "status" => "active" }
|
|
1753
|
+
def #{table}_field_overrides(fake)
|
|
1754
|
+
#{body.chomp}
|
|
1755
|
+
end
|
|
1756
|
+
|
|
1757
|
+
# 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.
|
|
1761
|
+
if Tina4.database
|
|
1762
|
+
fake = Tina4::FakeData.new
|
|
1763
|
+
summary = Tina4.seed_orm(#{name}, count: 20, overrides: #{table}_field_overrides(fake))
|
|
1764
|
+
puts "Seeded #{'#'}{summary.seeded} #{name} row(s), #{'#'}{summary.failed} failed"
|
|
1765
|
+
end
|
|
1766
|
+
RUBY
|
|
1767
|
+
File.write(path, content)
|
|
1768
|
+
puts " Created #{path}"
|
|
1769
|
+
end
|
|
1770
|
+
|
|
1771
|
+
# ── Generator: websocket ──────────────────────────────────────────────
|
|
1772
|
+
#
|
|
1773
|
+
# tina4ruby generate websocket chat
|
|
1774
|
+
# tina4ruby generate websocket /ws/rooms/{id}
|
|
1775
|
+
def generate_websocket(name, flags = {})
|
|
1776
|
+
raw = name.strip
|
|
1777
|
+
ws_path = raw.start_with?("/") ? raw : "/ws/#{raw.sub(%r{^/}, '')}"
|
|
1778
|
+
slug = to_snake_case(raw.gsub(%r{\A/+|/+\z}, "").gsub(/[^0-9a-zA-Z]+/, "_")).gsub(/\A_+|_+\z/, "")
|
|
1779
|
+
slug = "ws" if slug.empty?
|
|
1780
|
+
base = slug.start_with?("ws_") ? slug[3..] : slug
|
|
1781
|
+
base = "ws" if base.nil? || base.empty?
|
|
1782
|
+
handler = "#{base}_ws"
|
|
1783
|
+
|
|
1784
|
+
dir = "src/routes" # Tina4.websocket auto-registers on load (src/routes is discovered)
|
|
1785
|
+
FileUtils.mkdir_p(dir)
|
|
1786
|
+
path = File.join(dir, "ws_#{base}.rb")
|
|
1787
|
+
if File.exist?(path)
|
|
1788
|
+
puts " File already exists: #{path}"
|
|
1789
|
+
return
|
|
1790
|
+
end
|
|
1791
|
+
|
|
1792
|
+
body = ai_fill(
|
|
1793
|
+
handler,
|
|
1794
|
+
%(handle an inbound "message" frame on #{ws_path}),
|
|
1795
|
+
"connection.broadcast(data) or connection.send(payload) (Tina4 WebSocketConnection)",
|
|
1796
|
+
"websocket:#{ws_path}: handle the inbound message",
|
|
1797
|
+
given: "connection -> WebSocketConnection; event -> :open/:message/:close; data -> String on :message",
|
|
1798
|
+
ground: 'tina4_context("websocket broadcast message", "ruby")',
|
|
1799
|
+
indent: " "
|
|
1800
|
+
)
|
|
1801
|
+
content = <<~RUBY
|
|
1802
|
+
# #{ws_path} WebSocket route.
|
|
1803
|
+
#
|
|
1804
|
+
# Registered on load by Tina4.websocket (src/routes is auto-discovered at
|
|
1805
|
+
# boot, lib/tina4.rb:566). The server invokes the block as
|
|
1806
|
+
# (connection, event, data) for each event: :open (connect), :message
|
|
1807
|
+
# (inbound frame), :close (disconnect). Use Tina4.secure_websocket instead
|
|
1808
|
+
# to require a JWT on the upgrade; connection.broadcast / connection.send
|
|
1809
|
+
# reach the other clients.
|
|
1810
|
+
Tina4.websocket "#{ws_path}" do |connection, event, data|
|
|
1811
|
+
case event
|
|
1812
|
+
when :open
|
|
1813
|
+
connection.send('{"type":"welcome"}')
|
|
1814
|
+
when :close
|
|
1815
|
+
# client disconnected — nothing to clean up yet
|
|
1816
|
+
else # :message
|
|
1817
|
+
#{body.chomp}
|
|
1818
|
+
end
|
|
1819
|
+
end
|
|
1820
|
+
RUBY
|
|
1821
|
+
File.write(path, content)
|
|
1822
|
+
puts " Created #{path}"
|
|
1823
|
+
end
|
|
1824
|
+
|
|
1825
|
+
# ── Generator: listener ───────────────────────────────────────────────
|
|
1826
|
+
#
|
|
1827
|
+
# tina4ruby generate listener user.created
|
|
1828
|
+
def generate_listener(name, flags = {})
|
|
1829
|
+
event = name.strip
|
|
1830
|
+
slug = to_snake_case(event.gsub(/[^0-9a-zA-Z]+/, "_")).gsub(/\A_+|_+\z/, "")
|
|
1831
|
+
slug = "event" if slug.empty?
|
|
1832
|
+
|
|
1833
|
+
dir = "src/listeners"
|
|
1834
|
+
FileUtils.mkdir_p(dir)
|
|
1835
|
+
path = File.join(dir, "#{slug}.rb")
|
|
1836
|
+
if File.exist?(path)
|
|
1837
|
+
puts " File already exists: #{path}"
|
|
1838
|
+
return
|
|
1839
|
+
end
|
|
1840
|
+
|
|
1841
|
+
body = ai_fill(
|
|
1842
|
+
"on_#{slug}",
|
|
1843
|
+
"react to the '#{event}' event",
|
|
1844
|
+
"your app code — Tina4::Messenger, an ORM write, or Tina4::Events.emit(...) a follow-up",
|
|
1845
|
+
"listener:#{event}: react to the event payload",
|
|
1846
|
+
given: %(data -> whatever Tina4::Events.emit("#{event}", data) passed),
|
|
1847
|
+
ground: 'tina4_context("event listener reaction", "ruby")'
|
|
1848
|
+
)
|
|
1849
|
+
content = <<~RUBY
|
|
1850
|
+
# Listener for the '#{event}' event.
|
|
1851
|
+
#
|
|
1852
|
+
# NOT auto-loaded at boot — src/listeners is not on the auto-discover path
|
|
1853
|
+
# (lib/tina4.rb:566 scans routes/api/orm only). Require this file from
|
|
1854
|
+
# app.rb (or an initializer) so Tina4::Events.on binds it:
|
|
1855
|
+
# require_relative "src/listeners/#{slug}"
|
|
1856
|
+
# Fires when something calls Tina4::Events.emit("#{event}", data). Listeners
|
|
1857
|
+
# are isolated — a raise is logged and the other listeners still run (pass
|
|
1858
|
+
# strict: true to emit to re-raise instead).
|
|
1859
|
+
def on_#{slug}(data = nil)
|
|
1860
|
+
#{body.chomp}
|
|
1861
|
+
end
|
|
1862
|
+
|
|
1863
|
+
# Wiring: bind the reaction on the real event bus.
|
|
1864
|
+
Tina4::Events.on("#{event}") { |data| on_#{slug}(data) }
|
|
1865
|
+
RUBY
|
|
1866
|
+
File.write(path, content)
|
|
1867
|
+
puts " Created #{path}"
|
|
1868
|
+
end
|
|
1869
|
+
|
|
1309
1870
|
# ── help ──────────────────────────────────────────────────────────────
|
|
1310
1871
|
|
|
1311
1872
|
def cmd_help
|
|
@@ -1333,14 +1894,26 @@ module Tina4
|
|
|
1333
1894
|
|
|
1334
1895
|
Generators:
|
|
1335
1896
|
generate model <Name> [--fields "name:string,price:float"]
|
|
1336
|
-
generate route <name> [--model Name]
|
|
1337
|
-
generate crud <Name> [--fields "..."]
|
|
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
|
|
1338
1899
|
generate migration <description>
|
|
1339
1900
|
generate middleware <Name>
|
|
1340
1901
|
generate test <name>
|
|
1341
1902
|
generate form <Name> [--fields "..."] Form template with inputs matching model fields
|
|
1342
1903
|
generate view <Name> [--fields "..."] List + detail templates for viewing records
|
|
1343
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.
|
|
1344
1917
|
|
|
1345
1918
|
Metrics:
|
|
1346
1919
|
metrics [--top N] [--json] [--fail-on warn|error] [--path DIR]
|
data/lib/tina4/version.rb
CHANGED