tina4ruby 3.13.150 → 3.13.152

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e63d80b48d2821411c3174e3307176cdf996bfc722c2c228be2b829df8a898c3
4
- data.tar.gz: 4323b78fdba05283005597a4c8489d29a8b74cb02754bc9ec7b4c88f361d0cbc
3
+ metadata.gz: b90a1ada9ba0ccebf6ac21e8a8c6d37f01f444265b6520c853183a9c905fca98
4
+ data.tar.gz: 322fb1a149eb5181881eb12f0a78d628413ca0d7cd95a5baa8525fe70e9c43c0
5
5
  SHA512:
6
- metadata.gz: 68483df7f4ae92df44d121678397b0c6a4884277e88c7321fc0ed57ba36e34ae26bb8bca3f148eed1904127907d2a0c20369ca729341131ebea409a3e47b799b
7
- data.tar.gz: aa95f3c686a89537e480bdc2d49de82a1d8cec58dfe0e1c24398f8f19ea3552ba9ec2eac8a351ef859971400ed0756afe2e8421bcd4461ee4c02ffce21e6f715
6
+ metadata.gz: 40ab4de52acfa82a004929d03593662e64517c900dbecbf7a549ecb9b70a84e591ea8f091ef29c3571992ee7d384126757444a925fbc81ddcca40d7ab40f8303
7
+ data.tar.gz: b21e7652b9fa78befd3993cab32bd44798f6886f2267f00f6efeb8d42f869f242c05feb9424033c92c5f0bd246a0af5c0b3077f5693be333b49400c106f64b2d
data/CHANGELOG.md CHANGED
@@ -6,6 +6,55 @@ number means the same thing everywhere.
6
6
  **The authoritative release notes for every shipped version live in the documentation:**
7
7
  https://tina4.com/ruby/36-releases
8
8
 
9
+ ## 3.13.152 - 2026-10-20
10
+
11
+ ### Fixed
12
+ - Database: the PostgreSQL adapter recovers after a dropped connection even
13
+ when the first reconnect attempt itself fails, so a query run over a dead
14
+ link no longer wedges once a reconnect failed (#242). Transaction control
15
+ (`BEGIN` / `COMMIT` / `ROLLBACK`) stays raw and surfaces its own failure, and
16
+ the in-transaction flag is cleared even when a commit or rollback raises.
17
+ Reconnect-after-drop is now at parity across Python, PHP and Node.js too
18
+ (#244).
19
+ - CLI: `--help` / `-h` on any command prints that command's usage and changes
20
+ nothing. Before, most handlers never looked for it and ran for real:
21
+ `generate model -h` wrote a model named `-h`, `generate auth --help` wrote the
22
+ whole auth scaffold, `lint --help` ran `bundle add rubocop`, `migrate:status
23
+ --help` booted the app. Commands that used OptionParser printed its own banner
24
+ instead; every command now prints the same format. Exception: `serve` /
25
+ `start` keep `-h` as `--host` (`-h 127.0.0.1` still works); `--help` is help
26
+ everywhere. `help --help` prints the full reference.
27
+ - CLI: an unknown option is refused with a did-you-mean (`--feilds` →
28
+ `--fields`) instead of being silently dropped, and a value option given no
29
+ value is refused instead of crashing (`serve --port` raised
30
+ `OptionParser::MissingArgument`, `--fields` generated a model with no fields
31
+ and exited 1) or being ignored (`--every`). `--name=value` works as well as
32
+ `--name value`. `test` reads no arguments, so it accepts no options.
33
+ - CLI: `generate` refuses a name that starts with `-`.
34
+ - CLI: `generate crud <Name> --skip-model` works. `generate model` has always
35
+ suggested it, but no generator had the option; it was silently ignored and
36
+ would now be refused.
37
+ - CLI: `ai --all` installs every context file; it crashed with
38
+ `uninitialized constant Tina4::VERSION`. `ai all` works too.
39
+ - CLI: the top-level help reads `Usage: tina4ruby <command> [options]`, the
40
+ same wording as the Python, PHP and Node CLIs (it said `COMMAND`).
41
+
42
+ ## 3.13.151 - 2026-10-15
43
+
44
+ ### Security
45
+ - Cross-Origin-Opener-Policy joins the default security-header set (`TINA4_COOP`,
46
+ default `same-origin-allow-popups`). It pairs with a COEP header to unlock
47
+ `SharedArrayBuffer` and WASM threads in the browser.
48
+ - S3 storage is a zero-dependency SigV4 client (no aws-sdk-s3). It now fails loud on
49
+ a missing credential, retries 429/5xx and dropped connections with capped backoff,
50
+ honours `Retry-After`, and keeps the endpoint path prefix.
51
+ - MongoDB keeps bound values out of the parsed SQL text.
52
+ - CRUD attributes are de-raw'd, the 302 redirect is guarded, CodeQL core findings
53
+ are cleared, and uploaded files are created owner-only (0600).
54
+
55
+ ### Docs
56
+ - The README built-in feature count is corrected (55 -> 140).
57
+
9
58
  ## 3.13.150 — 2026-10-11
10
59
 
11
60
  ### Dev admin
data/lib/tina4/ai.rb CHANGED
@@ -9,6 +9,7 @@
9
9
  require "fileutils"
10
10
  require "net/http"
11
11
  require "uri"
12
+ require_relative "version" # the context files name Tina4::VERSION; `ai` runs before lib/tina4 loads
12
13
 
13
14
  module Tina4
14
15
  # Tina4 AI -- Install AI coding assistant context files.
data/lib/tina4/auth.rb CHANGED
@@ -466,7 +466,7 @@ module Tina4
466
466
  # is now honoured here too (explicit > env > HS256).
467
467
  def authenticate_request(headers, secret: nil, algorithm: nil)
468
468
  auth_header = headers["HTTP_AUTHORIZATION"] || headers["Authorization"] || ""
469
- return nil unless auth_header =~ /\ABearer\s+(.+)\z/i
469
+ return nil unless auth_header =~ /\ABearer\s++(.+)\z/i
470
470
 
471
471
  token = Regexp.last_match(1)
472
472
 
@@ -518,7 +518,7 @@ module Tina4
518
518
  def bearer_auth
519
519
  lambda do |env|
520
520
  auth_header = env["HTTP_AUTHORIZATION"] || ""
521
- return false unless auth_header =~ /\ABearer\s+(.+)\z/i
521
+ return false unless auth_header =~ /\ABearer\s++(.+)\z/i
522
522
 
523
523
  token = Regexp.last_match(1)
524
524
 
@@ -664,7 +664,7 @@ module Tina4
664
664
  auth_header = env["HTTP_AUTHORIZATION"] || ""
665
665
  return true if auth_header.empty?
666
666
 
667
- if auth_header =~ /\ABearer\s+(.+)\z/i
667
+ if auth_header =~ /\ABearer\s++(.+)\z/i
668
668
  valid_token(Regexp.last_match(1))
669
669
  else
670
670
  false
data/lib/tina4/cli.rb CHANGED
@@ -7,6 +7,7 @@
7
7
 
8
8
 
9
9
  require "optparse"
10
+ require "did_you_mean" # DidYouMean::SpellChecker - suggestions for a mistyped option
10
11
  require "fileutils"
11
12
  require "set"
12
13
  require "json"
@@ -27,33 +28,43 @@ module Tina4
27
28
  # mirror of the Python master's COMMANDS / GENERATORS registries
28
29
  # (tina4_python/cli/__init__.py).
29
30
  #
30
- # GENERATORS[name] = { handler: :method_symbol, usage: str, summary: str }
31
+ # GENERATORS[name] = { handler: :method_symbol, flags: {...}, usage: str, summary: str }
31
32
  # COMMANDS[name] = { handler: :method_symbol, summary: str,
33
+ # flags: { name => bool }, # options #run accepts; true = takes a value.
34
+ # # nil = not checked (`help` prints the reference whatever follows)
35
+ # short?: { "p" => "port" }, # single-letter aliases (OptionParser ones)
32
36
  # usage?: str, # arg/flag hint for #cmd_help (human only)
33
37
  # args?: [str], # positional args for the manifest ("x?" = optional)
34
38
  # subcommands?: [str] } # sub-names for the manifest (generate)
35
39
  #
36
40
  # Handlers are instance-method symbols dispatched via #send: GENERATORS
37
- # handlers take (name, flags); COMMANDS handlers take (argv).
41
+ # handlers take (name, flags); COMMANDS handlers take (argv). `flags` is
42
+ # what #check_options holds argv to before any handler runs; `generate` and
43
+ # `queue` take theirs from GENERATORS / QUEUE_FLAGS instead.
38
44
 
39
45
  GENERATORS = {
40
- "model" => { handler: :generate_model, usage: '<Name> [--fields "name:string,price:float"] [--table-name <name>]', summary: "ORM model + matching migration" },
41
- "route" => { handler: :generate_route, usage: "<name> [--model Name] [--public]", summary: "CRUD route file, secure by default (--public opens writes)" },
42
- "crud" => { handler: :generate_crud, usage: '<Name> [--fields "..."] [--public] [--no-templates]', summary: "Model + migration + AutoCrud-backed admin page (to_crud) + editable crud/ templates + gate test" },
43
- "migration" => { handler: :generate_migration, usage: "<description>", summary: "Timestamped migration file (UP/DOWN)" },
44
- "middleware" => { handler: :generate_middleware, usage: "<Name>", summary: "Middleware with before/after hooks" },
45
- "test" => { handler: :generate_test, usage: "<name> [--model Name]", summary: "RSpec test file" },
46
- "form" => { handler: :generate_form, usage: '<Name> [--fields "..."]', summary: "Form template with inputs matching model fields" },
47
- "view" => { handler: :generate_view, usage: '<Name> [--fields "..."]', summary: "List + detail view templates" },
48
- "auth" => { handler: :generate_auth, usage: "", summary: "Login/register routes + User model + templates" },
49
- "service" => { handler: :generate_service, usage: '<Name> [--every 5m | --cron "..."]', summary: "Scheduled ServiceRunner task (src/services/)" },
50
- "queue" => { handler: :generate_queue, usage: "<topic>", summary: "Producer + consumer worker (src/services/)" },
51
- "validator" => { handler: :generate_validator, usage: "<Name>", summary: "Request-body Validator (src/validators/)" },
52
- "seeder" => { handler: :generate_seeder, usage: "<Model>", summary: "FakeData + seed_orm seeder (seeds/)" },
53
- "websocket" => { handler: :generate_websocket, usage: "<path>", summary: "Tina4.websocket handler (src/routes/)" },
54
- "listener" => { handler: :generate_listener, usage: "<event>", summary: "Tina4::Events.on listener (src/listeners/)" },
46
+ "model" => { handler: :generate_model, flags: { "fields" => true, "table-name" => true, "no-migration" => false }, usage: '<Name> [--fields "name:string,price:float"] [--table-name <name>]', summary: "ORM model + matching migration" },
47
+ "route" => { handler: :generate_route, flags: { "model" => true, "public" => false }, usage: "<name> [--model Name] [--public]", summary: "CRUD route file, secure by default (--public opens writes)" },
48
+ "crud" => { handler: :generate_crud, flags: { "fields" => true, "table-name" => true, "public" => false, "no-templates" => false, "no-migration" => false, "skip-model" => false }, usage: '<Name> [--fields "..."] [--public] [--no-templates] [--skip-model]', summary: "Model + migration + AutoCrud-backed admin page (to_crud) + editable crud/ templates + gate test" },
49
+ "migration" => { handler: :generate_migration, flags: { "fields" => true }, usage: "<description>", summary: "Timestamped migration file (UP/DOWN)" },
50
+ "middleware" => { handler: :generate_middleware, flags: {}, usage: "<Name>", summary: "Middleware with before/after hooks" },
51
+ "test" => { handler: :generate_test, flags: { "model" => true, "public" => false }, usage: "<name> [--model Name]", summary: "RSpec test file" },
52
+ "form" => { handler: :generate_form, flags: { "fields" => true, "table-name" => true }, usage: '<Name> [--fields "..."]', summary: "Form template with inputs matching model fields" },
53
+ "view" => { handler: :generate_view, flags: { "fields" => true, "table-name" => true }, usage: '<Name> [--fields "..."]', summary: "List + detail view templates" },
54
+ "auth" => { handler: :generate_auth, flags: {}, usage: "", summary: "Login/register routes + User model + templates" },
55
+ "service" => { handler: :generate_service, flags: { "every" => true, "cron" => true }, usage: '<Name> [--every 5m | --cron "..."]', summary: "Scheduled ServiceRunner task (src/services/)" },
56
+ "queue" => { handler: :generate_queue, flags: {}, usage: "<topic>", summary: "Producer + consumer worker (src/services/)" },
57
+ "validator" => { handler: :generate_validator, flags: {}, usage: "<Name>", summary: "Request-body Validator (src/validators/)" },
58
+ "seeder" => { handler: :generate_seeder, flags: { "table-name" => true }, usage: "<Model>", summary: "FakeData + seed_orm seeder (seeds/)" },
59
+ "websocket" => { handler: :generate_websocket, flags: {}, usage: "<path>", summary: "Tina4.websocket handler (src/routes/)" },
60
+ "listener" => { handler: :generate_listener, flags: {}, usage: "<event>", summary: "Tina4::Events.on listener (src/listeners/)" },
55
61
  }.freeze
56
62
 
63
+ # Generators that emit the ADR-0063 resolution + JSON envelope, and the
64
+ # options every one of them accepts on top of its own.
65
+ RESOLUTION_TARGETS = %w[model route migration middleware form view queue].freeze
66
+ RESOLUTION_FLAGS = { "json" => false, "dry-run" => false }.freeze
67
+
57
68
  # Sub-dispatch table for the top-level `queue` command — the SINGLE source
58
69
  # for its subcommands (drives #cmd_queue dispatch AND the manifest's
59
70
  # queue.subcommands). Ruby mirror of the Python master's _QUEUE_SUBCOMMANDS.
@@ -65,27 +76,43 @@ module Tina4
65
76
  "clear" => :queue_clear,
66
77
  }.freeze
67
78
 
79
+ # Options per queue subcommand (name => takes a value), checked by #run.
80
+ QUEUE_FLAGS = {
81
+ "work" => { "once" => false, "poll" => true, "services" => true },
82
+ "stats" => { "json" => false },
83
+ "retry" => {},
84
+ "clear" => {},
85
+ }.freeze
86
+
87
+ # `start` / `serve` options, as cmd_start's OptionParser defines them.
88
+ # `-h` is --host here (it predates per-command help), so only `--help` is
89
+ # help for these two.
90
+ SERVE_FLAGS = { "port" => true, "host" => true, "dev" => false, "managed" => false, "production" => false,
91
+ "no-browser" => false, "no-reload" => false, "no-kill" => false }.freeze
92
+ SERVE_SHORT = { "p" => "port", "h" => "host", "d" => "dev" }.freeze
93
+ SERVE_USAGE = "[-p PORT] [-h HOST] [--dev] [--no-browser] [--no-reload] [--no-kill] [--production]"
94
+
68
95
  COMMANDS = {
69
- "init" => { handler: :cmd_init, usage: "[NAME]", args: ["name?"], summary: "Initialize a new Tina4 project" },
70
- "start" => { handler: :cmd_start, usage: "[options]", summary: "Start the Tina4 web server" },
71
- "serve" => { handler: :cmd_start, summary: "Alias for start" },
72
- "migrate" => { handler: :cmd_migrate, usage: "[--create NAME] [--rollback N]", summary: "Run database migrations" },
73
- "migrate:create" => { handler: :cmd_migrate_create, usage: "<desc>", args: ["description"], summary: "Create a new migration file" },
74
- "migrate:status" => { handler: :cmd_migrate_status, summary: "Show migration status (completed and pending)" },
75
- "migrate:rollback" => { handler: :cmd_migrate_rollback, usage: "[-n N]", summary: "Rollback the last batch of migrations" },
76
- "seed" => { handler: :cmd_seed, usage: "[--clear]", summary: "Run all seed files in seeds/" },
77
- "seed:create" => { handler: :cmd_seed_create, usage: "NAME", args: ["name"], summary: "Create a new seed file" },
78
- "test" => { handler: :cmd_test, summary: "Run inline tests" },
96
+ "init" => { handler: :cmd_init, flags: { "template" => true }, usage: "[NAME]", args: ["name?"], summary: "Initialize a new Tina4 project" },
97
+ "start" => { handler: :cmd_start, flags: SERVE_FLAGS, short: SERVE_SHORT, usage: SERVE_USAGE, summary: "Start the Tina4 web server" },
98
+ "serve" => { handler: :cmd_start, flags: SERVE_FLAGS, short: SERVE_SHORT, usage: SERVE_USAGE, summary: "Alias for start" },
99
+ "migrate" => { handler: :cmd_migrate, flags: { "create" => true, "rollback" => true }, usage: "[--create NAME] [--rollback N]", summary: "Run database migrations" },
100
+ "migrate:create" => { handler: :cmd_migrate_create, flags: { "fields" => true, **RESOLUTION_FLAGS }, usage: "<desc>", args: ["description"], summary: "Create a new migration file" },
101
+ "migrate:status" => { handler: :cmd_migrate_status, flags: {}, summary: "Show migration status (completed and pending)" },
102
+ "migrate:rollback" => { handler: :cmd_migrate_rollback, flags: { "steps" => true }, short: { "n" => "steps" }, usage: "[-n N]", summary: "Rollback the last batch of migrations" },
103
+ "seed" => { handler: :cmd_seed, flags: { "clear" => false }, usage: "[--clear]", summary: "Run all seed files in seeds/" },
104
+ "seed:create" => { handler: :cmd_seed_create, flags: {}, usage: "NAME", args: ["name"], summary: "Create a new seed file" },
105
+ "test" => { handler: :cmd_test, flags: {}, summary: "Run inline tests" },
79
106
  "queue" => { handler: :cmd_queue, usage: "<work|stats|retry|clear> [topic]", subcommands: QUEUE_SUBCOMMANDS.keys, summary: "Run queue workers and manage jobs" },
80
- "build" => { handler: :cmd_build, usage: "[--tag NAME] [--file PATH]", summary: "Build the deployable Docker image" },
81
- "lint" => { handler: :cmd_lint, usage: "[--fix] [--no-install]", summary: "Lint src/ + app.rb (rubocop, installed dev-only on demand, else ruby -c)" },
82
- "version" => { handler: :cmd_version, summary: "Show Tina4 version" },
83
- "routes" => { handler: :cmd_routes, summary: "List all registered routes" },
84
- "console" => { handler: :cmd_console, summary: "Start an interactive console" },
107
+ "build" => { handler: :cmd_build, flags: { "tag" => true, "file" => true }, usage: "[--tag NAME] [--file PATH]", summary: "Build the deployable Docker image" },
108
+ "lint" => { handler: :cmd_lint, flags: { "fix" => false, "no-install" => false }, usage: "[--fix] [--no-install]", summary: "Lint src/ + app.rb (rubocop, installed dev-only on demand, else ruby -c)" },
109
+ "version" => { handler: :cmd_version, flags: {}, summary: "Show Tina4 version" },
110
+ "routes" => { handler: :cmd_routes, flags: {}, summary: "List all registered routes" },
111
+ "console" => { handler: :cmd_console, flags: {}, summary: "Start an interactive console" },
85
112
  "generate" => { handler: :cmd_generate, usage: "<what> <name> [options]", subcommands: GENERATORS.keys, summary: "Generate scaffolding (see Generators below)" },
86
- "ai" => { handler: :cmd_ai, usage: "[--all]", summary: "Detect AI tools and install context files" },
87
- "commands" => { handler: :cmd_commands, usage: "[--json]", summary: "List available commands (add --json for machine form)" },
88
- "help" => { handler: :cmd_help, summary: "Show this help message" },
113
+ "ai" => { handler: :cmd_ai, flags: { "all" => false }, usage: "[--all]", summary: "Detect AI tools and install context files" },
114
+ "commands" => { handler: :cmd_commands, flags: { "json" => false }, usage: "[--json]", summary: "List available commands (add --json for machine form)" },
115
+ "help" => { handler: :cmd_help, flags: nil, summary: "Show this help message" },
89
116
  }.freeze
90
117
 
91
118
  # ── Delegation to the `tina4` client ────────────────────────────────
@@ -160,16 +187,34 @@ module Tina4
160
187
  new.run(argv)
161
188
  end
162
189
 
190
+ HELP_FLAGS = %w[--help -h].freeze
191
+
163
192
  def run(argv)
164
193
  command = argv.shift || "help"
165
- command = "help" if %w[-h --help].include?(command)
194
+ command = "help" if HELP_FLAGS.include?(command)
166
195
 
167
196
  # Dispatch from the single-source-of-truth COMMANDS registry. The same
168
197
  # registry drives #cmd_help and the `commands --json` manifest, so
169
198
  # dispatch, help, and discovery never drift.
170
199
  spec = COMMANDS[command]
171
200
  if spec
172
- send(spec[:handler], argv)
201
+ # `--help` / `-h` anywhere after a command prints that command's usage
202
+ # and touches nothing. Handlers never looked for it, so it ran the
203
+ # command for real: `generate model --help` wrote a model named
204
+ # `--help`, `migrate --help` opened the database. `serve` / `start`
205
+ # keep `-h` as --host. Delegated commands are left alone - the client
206
+ # answers --help itself.
207
+ if command != "help" && argv.any? { |arg| help_flags(command).include?(arg) }
208
+ command_help(command, argv)
209
+ return
210
+ end
211
+ check_options(command, argv)
212
+ begin
213
+ send(spec[:handler], argv)
214
+ rescue OptionParser::ParseError => e
215
+ # e.g. `serve --port abc`: say so, no backtrace.
216
+ option_error(command, "#{e.message}.")
217
+ end
173
218
  elsif DELEGATED.key?(command)
174
219
  exit delegate_to_client(command, argv)
175
220
  else
@@ -183,6 +228,113 @@ module Tina4
183
228
 
184
229
  private
185
230
 
231
+ # ── Per-command help and option checking ─────────────────────────────
232
+
233
+ def help_flags(command)
234
+ COMMANDS[command][:short]&.key?("h") ? ["--help"] : HELP_FLAGS
235
+ end
236
+
237
+ # Usage for one command (or one generator), from the same registries.
238
+ def command_help(command, argv)
239
+ if command == "generate"
240
+ kind = argv.find { |arg| !arg.start_with?("-") }&.downcase
241
+ if (gen = GENERATORS[kind])
242
+ puts "Usage: tina4ruby generate #{kind} #{gen[:usage]}".rstrip
243
+ puts "", " #{gen[:summary]}"
244
+ return
245
+ end
246
+ puts "Usage: tina4ruby generate <what> <name> [options]", "", "Generators:"
247
+ pad = GENERATORS.keys.map(&:length).max
248
+ GENERATORS.each { |name, spec| puts " #{name.ljust(pad)} #{spec[:summary]}" }
249
+ return
250
+ end
251
+ spec = COMMANDS[command]
252
+ puts "Usage: tina4ruby #{command} #{spec[:usage]}".rstrip
253
+ puts "", " #{spec[:summary]}"
254
+ puts " Subcommands: #{spec[:subcommands].join(', ')}" if spec[:subcommands]
255
+ end
256
+
257
+ # The options `command` accepts ({ name => takes_value }), or nil when its
258
+ # arguments are not checked: `help` (flags: nil), or an unknown generator /
259
+ # queue subcommand, which its handler reports.
260
+ def accepted_options(command, argv)
261
+ kind = argv.first.to_s.downcase
262
+ case command
263
+ when "generate"
264
+ gen = GENERATORS[kind] or return nil
265
+ RESOLUTION_TARGETS.include?(kind) ? gen[:flags].merge(RESOLUTION_FLAGS) : gen[:flags]
266
+ when "queue"
267
+ QUEUE_FLAGS[kind]
268
+ else
269
+ COMMANDS[command].fetch(:flags, {})
270
+ end
271
+ end
272
+
273
+ def option_label(command, argv)
274
+ %w[generate queue].include?(command) ? "#{command} #{argv.first.downcase}" : command
275
+ end
276
+
277
+ def option_error(label, message)
278
+ puts message
279
+ puts %(Run "tina4ruby #{label} --help" for its options.)
280
+ exit 1
281
+ end
282
+
283
+ # Refuse an option the command doesn't have, or a value option given no
284
+ # value. Both used to slip through: `--feilds` was silently dropped (the
285
+ # model came out without its fields) and a bare `--fields` swallowed the
286
+ # next option or crashed OptionParser.
287
+ def check_options(command, argv)
288
+ accepted = accepted_options(command, argv)
289
+ return if accepted.nil?
290
+
291
+ label = option_label(command, argv)
292
+ short = COMMANDS[command][:short] || {}
293
+ i = 0
294
+ while i < argv.length
295
+ message, consumed = option_problem(argv, i, accepted, short, label)
296
+ if message
297
+ puts message
298
+ exit 1
299
+ end
300
+ i += consumed
301
+ end
302
+ end
303
+
304
+ # Judge argv[i]: [message, nil] when it is refused, else [nil, args consumed].
305
+ def option_problem(argv, i, accepted, short, label)
306
+ arg = argv[i]
307
+ return [nil, 1] unless arg.start_with?("-") && arg != "-"
308
+
309
+ name, has_value = option_name(arg, short)
310
+ return [unknown_option_message(arg, accepted, label), nil] unless name && accepted.key?(name)
311
+ return [nil, 1] unless accepted[name] && !has_value
312
+
313
+ value = argv[i + 1]
314
+ return [nil, 2] if value && !value.start_with?("-")
315
+
316
+ [%(Option --#{name} for "#{label}" needs a value: --#{name} <value>), nil]
317
+ end
318
+
319
+ # [long name, value attached?] for `--name`, `--name=value`, `-p` or `-p7147`;
320
+ # the name is nil for a short option the command doesn't alias.
321
+ def option_name(arg, short)
322
+ if arg.start_with?("--")
323
+ name, eq, = arg[2..].partition("=")
324
+ [name, !eq.empty?]
325
+ else
326
+ [short[arg[1]], arg.length > 2]
327
+ end
328
+ end
329
+
330
+ def unknown_option_message(arg, accepted, label)
331
+ bare = arg.sub(/\A-+/, "").partition("=").first
332
+ close = DidYouMean::SpellChecker.new(dictionary: accepted.keys).correct(bare).first
333
+ %(Unknown option #{arg.partition('=').first} for "#{label}".) +
334
+ (close ? " Did you mean --#{close}?" : "") +
335
+ %(\nRun "tina4ruby #{label} --help" for its options.)
336
+ end
337
+
186
338
  # Absolute path of the `tina4` client on PATH, or nil if it isn't there.
187
339
  #
188
340
  # Scans PATH directly rather than shelling out to which/where — one less
@@ -457,7 +609,7 @@ module Tina4
457
609
  # per-instance to_snake_case when only the class name is known.
458
610
  def self.to_snake_case_static(name)
459
611
  s = name.to_s
460
- s.gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
612
+ s.gsub(/([A-Z])(?=[A-Z][a-z])/, '\1_')
461
613
  .gsub(/([a-z\d])([A-Z])/, '\1_\2')
462
614
  .downcase
463
615
  end
@@ -577,7 +729,11 @@ module Tina4
577
729
  positional = []
578
730
  i = 0
579
731
  while i < args.length
580
- if args[i].start_with?("--")
732
+ if args[i].start_with?("--") && args[i].include?("=")
733
+ key, _, value = args[i][2..].partition("=")
734
+ flags[key] = value
735
+ i += 1
736
+ elsif args[i].start_with?("--")
581
737
  key = args[i][2..]
582
738
  if boolean_flags.include?(key)
583
739
  flags[key] = true
@@ -1444,6 +1600,7 @@ module Tina4
1444
1600
  opts.on("--all", "Install context for ALL AI tools (non-interactive)") { options[:all] = true }
1445
1601
  end
1446
1602
  parser.parse!(argv)
1603
+ options[:all] = true if argv.first&.downcase == "all" # the Python master's bare form
1447
1604
 
1448
1605
  require_relative "ai"
1449
1606
 
@@ -1486,13 +1643,28 @@ module Tina4
1486
1643
  exit 1
1487
1644
  end
1488
1645
 
1646
+ # Dispatch from the GENERATORS registry (single source of truth for the
1647
+ # generate subcommands; also feeds #cmd_help and the manifest).
1648
+ gen_spec = GENERATORS[what]
1649
+ unless gen_spec
1650
+ puts "Unknown generator: #{what}"
1651
+ puts " Available: #{all}"
1652
+ exit 1
1653
+ end
1654
+
1489
1655
  # Auth doesn't require a name argument
1490
1656
  no_name_generators = %w[auth]
1491
1657
  unless no_name_generators.include?(what)
1492
- if argv.empty? || argv.first.start_with?("--")
1658
+ if argv.empty?
1493
1659
  puts "Usage: tina4ruby generate #{what} <name> [options]"
1494
1660
  exit 1
1495
1661
  end
1662
+ # A name that is really a mistyped option (`--fields` with no name
1663
+ # before it, `-x`) would otherwise become a file called `--fields`.
1664
+ if argv.first.start_with?("-")
1665
+ puts "Invalid #{what} name: '#{argv.first}' - names can't start with '-'."
1666
+ exit 1
1667
+ end
1496
1668
  end
1497
1669
 
1498
1670
  name = no_name_generators.include?(what) ? "" : argv.shift
@@ -1501,21 +1673,11 @@ module Tina4
1501
1673
  json_mode = flags.delete("json") ? true : false
1502
1674
  dry_run = flags.delete("dry-run") ? true : false
1503
1675
 
1504
- # Dispatch from the GENERATORS registry (single source of truth for the
1505
- # generate subcommands; also feeds #cmd_help and the manifest).
1506
- gen_spec = GENERATORS[what]
1507
- unless gen_spec
1508
- puts "Unknown generator: #{what}"
1509
- puts " Available: #{all}"
1510
- exit 1
1511
- end
1512
-
1513
1676
  # Only the resolution-aware targets emit a resolution + JSON envelope;
1514
1677
  # every other generator keeps its existing behaviour so nothing regresses.
1515
1678
  # ADR-0063 (3.13.121): form + view joined so their baked `{# tina4:edit ... #}`
1516
1679
  # markers surface in resolution.edit_hints[] under the same v1.1 envelope.
1517
- resolution_aware = %w[model route migration middleware form view queue]
1518
- if !resolution_aware.include?(what)
1680
+ unless RESOLUTION_TARGETS.include?(what)
1519
1681
  send(gen_spec[:handler], name, flags)
1520
1682
  return
1521
1683
  end
@@ -2169,7 +2331,9 @@ module Tina4
2169
2331
 
2170
2332
  # 1. Model + migration (emit_test: false — crud emits its own broader gate
2171
2333
  # spec at step 4, so the sub-generators stay quiet to avoid double-emit).
2172
- generate_model(name, flags, emit_test: false)
2334
+ # --skip-model: the model already exists (`generate model` suggests
2335
+ # exactly this), so leave it and its migration alone.
2336
+ generate_model(name, flags, emit_test: false) unless flags["skip-model"]
2173
2337
 
2174
2338
  # 2. Admin page route: registers AutoCrud (secure-by-default; --public
2175
2339
  # opens writes) and renders the to_crud admin UI at /admin/<table>.
@@ -3739,7 +3903,7 @@ module Tina4
3739
3903
  " #{left.ljust(gap)} #{summary}"
3740
3904
  end
3741
3905
 
3742
- lines = ["Tina4 Ruby CLI", "", "Usage: tina4ruby COMMAND [options]", "", "Commands:"]
3906
+ lines = ["Tina4 Ruby CLI", "", "Usage: tina4ruby <command> [options]", "", "Commands:"]
3743
3907
  lines += command_rows.map { |left, summary| row.call(left, summary) }
3744
3908
  lines += ["", "Delegated to the #{CLIENT_BINARY} client (same behaviour in every framework):"]
3745
3909
  lines += delegated_rows.map { |left, summary| row.call(left, summary) }
@@ -3760,7 +3924,7 @@ module Tina4
3760
3924
  "",
3761
3925
  "https://tina4.com",
3762
3926
  "",
3763
- "Run 'tina4ruby COMMAND --help' for more information on a command.",
3927
+ "Run 'tina4ruby <command> --help' for more information on a command.",
3764
3928
  ]
3765
3929
  puts lines.join("\n")
3766
3930
  end
data/lib/tina4/crud.rb CHANGED
@@ -112,7 +112,7 @@ module Tina4
112
112
  verb = method.to_s.upcase
113
113
  Tina4::Template.render("crud/form.twig", {
114
114
  "wrap" => true,
115
- "form_id_attr" => "",
115
+ "form_id" => "",
116
116
  "action" => h(action),
117
117
  "form_method" => h(verb),
118
118
  "method_override" => (%w[PUT PATCH DELETE].include?(verb) ? verb : nil),
@@ -204,14 +204,14 @@ module Tina4
204
204
  "value" => "",
205
205
  "placeholder" => h("Enter #{label.downcase}"),
206
206
  "type" => "text",
207
- "required_attr" => "",
207
+ "required" => false,
208
208
  "input" => true
209
209
  }
210
210
  end
211
211
 
212
212
  Tina4::Template.render("crud/form.twig", {
213
213
  "wrap" => true,
214
- "form_id_attr" => " id=\"form-#{mode}\"",
214
+ "form_id" => "form-#{mode}",
215
215
  "action" => "",
216
216
  "form_method" => "POST",
217
217
  "method_override" => nil,
@@ -263,7 +263,7 @@ module Tina4
263
263
  "readonly" => !editable,
264
264
  "inline_script" => inline_script,
265
265
  "table_name" => h(table_name),
266
- "table_id_attr" => (table_id ? " id=\"#{h(table_id)}\"" : "")
266
+ "table_id" => (table_id ? table_id.to_s : "")
267
267
  }
268
268
  end
269
269
 
@@ -309,7 +309,7 @@ module Tina4
309
309
  name = field[:name].to_s
310
310
  label = field[:label] || name.capitalize
311
311
  value = field[:value]
312
- required = field[:required] ? " required" : ""
312
+ required = field[:required] ? true : false
313
313
 
314
314
  base = {
315
315
  "id" => h(name),
@@ -317,14 +317,14 @@ module Tina4
317
317
  "label" => h(label.to_s),
318
318
  "value" => h(value.to_s),
319
319
  "placeholder" => "",
320
- "required_attr" => required
320
+ "required" => required
321
321
  }
322
322
 
323
323
  case (field[:type] || :string).to_sym
324
324
  when :text
325
325
  base.merge("textarea" => true)
326
326
  when :boolean
327
- base.merge("checkbox" => true, "checked_attr" => (value ? " checked" : ""))
327
+ base.merge("checkbox" => true, "checked" => (value ? true : false))
328
328
  when :select
329
329
  base.merge("select" => true, "options_html" => build_options(field[:options], value))
330
330
  when :date