whoosh 1.4.1 → 1.6.0

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: 5baff744454d485e7f9788aca18285ece0c8901a5abea6f9c2f3fd4a22a7de75
4
- data.tar.gz: 6c2062d73b6bec5e60c0adfd897e7e8aec5d10fa366439471592fc196bfe5bbd
3
+ metadata.gz: f93acb5a2f85aecd9f3feafc165962aa721a762dad30caa2d2550ed500afbec8
4
+ data.tar.gz: e44f3b1217d9177417b21820abd6d1788969556603c2d9aff7f26d7351c703d2
5
5
  SHA512:
6
- metadata.gz: 7ef32b36e7405fe117eefc60908baf71a9553d328ccd2fd7823c715b7e37d4f01aeedc29a7cb1709e865410d407a32bc511f49353b90339702a06bb7f9a31425
7
- data.tar.gz: 75188ded90b9205cb7c779d18252ef842989ec84d76ad78e1167d4ca267e544918b39c5d2a3596abe0b2d6001620e98e4272a75bdd62491b4130127bba78487c
6
+ metadata.gz: 77dd95d3cefc8053ff9c183207273adc8b99e4df806ad1374b277dfde6a64ffb31f82765c502c05d81eb4d4a9b4f6d681a2ffb0ebaf75353369e5179eeb17758
7
+ data.tar.gz: ea988031ce4f4dd47959bd84782caa4ad019e7570f5cd2d17cba754b3851955a4520c7d5ee76815b53da7e381a9fde0a833895a7cc59386e2354dfc6f7604f74
data/README.md CHANGED
@@ -13,7 +13,7 @@
13
13
  <img src="https://img.shields.io/badge/ruby-%3E%3D%203.4.0-red" alt="Ruby">
14
14
  <img src="https://img.shields.io/badge/rack-3.0-blue" alt="Rack">
15
15
  <img src="https://img.shields.io/badge/license-MIT-green" alt="License">
16
- <img src="https://img.shields.io/badge/tests-542%20passing-brightgreen" alt="Tests">
16
+ <img src="https://img.shields.io/badge/tests-659%20passing-brightgreen" alt="Tests">
17
17
  <img src="https://img.shields.io/badge/overhead-2.5%C2%B5s-orange" alt="Performance">
18
18
  </p>
19
19
 
@@ -346,6 +346,43 @@ app.docs enabled: true, redoc: true
346
346
  - `/redoc` — ReDoc
347
347
  - `/openapi.json` — Machine-readable spec
348
348
 
349
+ ### Client Generator
350
+
351
+ Generate complete, typed, ready-to-run client apps from your Whoosh API — one command.
352
+
353
+ ```sh
354
+ whoosh generate client react_spa # React + Vite + TypeScript
355
+ whoosh generate client expo # Expo + React Native
356
+ whoosh generate client ios # SwiftUI + MVVM
357
+ whoosh generate client flutter # Dart + Riverpod + GoRouter
358
+ whoosh generate client htmx # Plain HTML + htmx, no build step
359
+ whoosh generate client telegram_bot # Ruby Telegram bot
360
+ whoosh generate client telegram_mini_app # React + Telegram WebApp SDK
361
+
362
+ whoosh generate client react_spa --oauth # Add Google/GitHub/Apple login
363
+ ```
364
+
365
+ The generator **introspects your Whoosh app** via OpenAPI — it reads your routes, schemas, and auth config, then produces a typed client with:
366
+
367
+ - API client with auth headers and automatic token refresh
368
+ - Model types matching your schemas
369
+ - Auth screens (login, register, logout)
370
+ - CRUD screens for every resource
371
+ - Navigation and routing
372
+ - Starter tests
373
+
374
+ If no Whoosh app exists yet, it scaffolds a standard backend (JWT auth + tasks CRUD) alongside the client.
375
+
376
+ | Client | Stack | Token Storage |
377
+ |--------|-------|---------------|
378
+ | `react_spa` | React 19, Vite, TypeScript, React Router | localStorage |
379
+ | `expo` | Expo SDK 52, Expo Router, TypeScript | SecureStore |
380
+ | `ios` | SwiftUI, async/await, MVVM | Keychain |
381
+ | `flutter` | Dart, Dio, Riverpod, GoRouter | flutter_secure_storage |
382
+ | `htmx` | HTML, htmx 2.x, vanilla JS | localStorage |
383
+ | `telegram_bot` | Ruby, telegram-bot-ruby | In-memory session |
384
+ | `telegram_mini_app` | React, Telegram WebApp SDK | Telegram initData |
385
+
349
386
  ### Health Checks
350
387
 
351
388
  ```ruby
@@ -377,6 +414,8 @@ whoosh generate model User name:string email:string
377
414
  whoosh generate migration add_email_to_users
378
415
  whoosh generate plugin my_tool # plugin boilerplate
379
416
  whoosh generate proto ChatRequest # .proto file
417
+ whoosh generate client react_spa # full client app (7 types)
418
+ whoosh generate client expo --oauth # with OAuth2 social login
380
419
 
381
420
  whoosh db migrate # run migrations
382
421
  whoosh db rollback # rollback
data/lib/whoosh/app.rb CHANGED
@@ -250,6 +250,31 @@ module Whoosh
250
250
  Paginate.cursor(collection, cursor: cursor, limit: limit, column: column)
251
251
  end
252
252
 
253
+ def redirect(url, status: 302)
254
+ Response.redirect(url, status: status)
255
+ end
256
+
257
+ def download(data, filename:, content_type: nil)
258
+ Response.download(data, filename: filename, content_type: content_type || "application/octet-stream")
259
+ end
260
+
261
+ def send_file(path, content_type: nil)
262
+ Response.file(path, content_type: content_type)
263
+ end
264
+
265
+ def serve_static(prefix, root:)
266
+ get "#{prefix}/:_static_path" do |req|
267
+ file_path = File.join(root, req.params[:_static_path])
268
+ real = File.realpath(file_path) rescue nil
269
+ real_root = File.realpath(root) rescue root
270
+ if real && real.start_with?(real_root) && File.file?(real)
271
+ Response.file(real)
272
+ else
273
+ Response.not_found
274
+ end
275
+ end
276
+ end
277
+
253
278
  # --- Endpoint loading ---
254
279
 
255
280
  def load_endpoints(dir)
@@ -396,66 +421,71 @@ module Whoosh
396
421
  security_headers = Middleware::SecurityHeaders::HEADERS
397
422
 
398
423
  -> (env) {
399
- # 1. RequestLimit — check content length
400
- content_length = env["CONTENT_LENGTH"]&.to_i || 0
401
- if content_length > max_bytes
402
- return [413, { "content-type" => "application/json" },
403
- [JSON.generate({ error: "request_too_large", max_bytes: max_bytes })]]
404
- end
424
+ begin
425
+ # 1. RequestLimit check content length
426
+ content_length = env["CONTENT_LENGTH"]&.to_i || 0
427
+ if content_length > max_bytes
428
+ return [413, { "content-type" => "application/json" },
429
+ [JSON.generate({ error: "request_too_large", max_bytes: max_bytes })]]
430
+ end
405
431
 
406
- # 2. Request ID (from RequestLogger)
407
- request_id = env["HTTP_X_REQUEST_ID"] || SecureRandom.uuid
408
- env["whoosh.request_id"] = request_id
409
-
410
- start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
411
-
412
- # 3. CORS preflight
413
- origin = env["HTTP_ORIGIN"]
414
- if env["REQUEST_METHOD"] == "OPTIONS" && origin
415
- cors_headers = {
416
- "access-control-allow-methods" => "GET, POST, PUT, PATCH, DELETE, OPTIONS",
417
- "access-control-allow-headers" => "Content-Type, Authorization, X-API-Key, X-Request-ID",
418
- "access-control-max-age" => "86400",
419
- "access-control-allow-origin" => "*",
420
- "access-control-expose-headers" => "X-Request-ID",
421
- "vary" => "Origin"
422
- }
423
- return [204, cors_headers, []]
424
- end
432
+ # 2. Request ID (from RequestLogger)
433
+ request_id = env["HTTP_X_REQUEST_ID"] || SecureRandom.uuid
434
+ env["whoosh.request_id"] = request_id
435
+
436
+ start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
437
+
438
+ # 3. CORS preflight
439
+ origin = env["HTTP_ORIGIN"]
440
+ if env["REQUEST_METHOD"] == "OPTIONS" && origin
441
+ cors_headers = {
442
+ "access-control-allow-methods" => "GET, POST, PUT, PATCH, DELETE, OPTIONS",
443
+ "access-control-allow-headers" => "Content-Type, Authorization, X-API-Key, X-Request-ID",
444
+ "access-control-max-age" => "86400",
445
+ "access-control-allow-origin" => "*",
446
+ "access-control-expose-headers" => "X-Request-ID",
447
+ "vary" => "Origin"
448
+ }
449
+ return [204, cors_headers, []]
450
+ end
425
451
 
426
- # 4. Handle request (core)
427
- status, headers, body = handle_request(env)
452
+ # 4. Handle request (core)
453
+ status, headers, body = handle_request(env)
428
454
 
429
- # Ensure headers are mutable (streaming returns frozen headers)
430
- headers = headers.dup if headers.frozen?
455
+ # Ensure headers are mutable (streaming returns frozen headers)
456
+ headers = headers.dup if headers.frozen?
431
457
 
432
- # 5. Security headers (inline, no allocation)
433
- security_headers.each { |k, v| headers[k] ||= v }
458
+ # 5. Security headers (inline, no allocation)
459
+ security_headers.each { |k, v| headers[k] ||= v }
434
460
 
435
- # 6. CORS headers
436
- if origin
437
- headers["access-control-allow-origin"] = "*"
438
- headers["access-control-expose-headers"] = "X-Request-ID"
439
- headers["vary"] = "Origin"
440
- end
461
+ # 6. CORS headers
462
+ if origin
463
+ headers["access-control-allow-origin"] = "*"
464
+ headers["access-control-expose-headers"] = "X-Request-ID"
465
+ headers["vary"] = "Origin"
466
+ end
441
467
 
442
- # 7. Request ID in response
443
- headers["x-request-id"] = request_id
468
+ # 7. Request ID in response
469
+ headers["x-request-id"] = request_id
444
470
 
445
- # 8. Logging + metrics
446
- duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time) * 1000).round(2)
447
- logger.info("request_complete",
448
- method: env["REQUEST_METHOD"], path: env["PATH_INFO"],
449
- status: status, duration_ms: duration_ms, request_id: request_id)
471
+ # 8. Logging + metrics
472
+ duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time) * 1000).round(2)
473
+ logger.info("request_complete",
474
+ method: env["REQUEST_METHOD"], path: env["PATH_INFO"],
475
+ status: status, duration_ms: duration_ms, request_id: request_id)
450
476
 
451
- if metrics
452
- metrics.increment("whoosh_requests_total",
453
- labels: { method: env["REQUEST_METHOD"], path: env["PATH_INFO"], status: status.to_s })
454
- metrics.observe("whoosh_request_duration_seconds",
455
- duration_ms / 1000.0, labels: { path: env["PATH_INFO"] })
456
- end
477
+ if metrics
478
+ metrics.increment("whoosh_requests_total",
479
+ labels: { method: env["REQUEST_METHOD"], path: env["PATH_INFO"], status: status.to_s })
480
+ metrics.observe("whoosh_request_duration_seconds",
481
+ duration_ms / 1000.0, labels: { path: env["PATH_INFO"] })
482
+ end
457
483
 
458
- [status, headers, body]
484
+ [status, headers, body]
485
+ rescue => e
486
+ [500, { "content-type" => "application/json" },
487
+ [JSON.generate({ error: "internal_error", message: e.message })]]
488
+ end
459
489
  }
460
490
  end
461
491
 
@@ -509,7 +539,8 @@ module Whoosh
509
539
  generator.add_route(
510
540
  method: route[:method], path: route[:path],
511
541
  request_schema: handler[:request_schema],
512
- response_schema: handler[:response_schema]
542
+ response_schema: handler[:response_schema],
543
+ query_schema: handler[:query_schema]
513
544
  )
514
545
  end
515
546
 
@@ -540,13 +571,14 @@ module Whoosh
540
571
  })
541
572
  end
542
573
 
543
- def add_route(method, path, request: nil, response: nil, **metadata, &block)
574
+ def add_route(method, path, request: nil, response: nil, query: nil, **metadata, &block)
544
575
  full_path = "#{@group_prefix}#{path}"
545
576
  merged_metadata = @group_metadata.merge(metadata)
546
577
  handler = {
547
578
  block: block,
548
579
  request_schema: request,
549
580
  response_schema: response,
581
+ query_schema: query,
550
582
  middleware: @group_middleware.dup
551
583
  }
552
584
  @router.add(method, full_path, handler, **merged_metadata)
@@ -662,6 +694,10 @@ module Whoosh
662
694
  @strategies[:jwt] = Auth::Jwt.new(secret: secret, algorithm: algorithm, expiry: expiry)
663
695
  end
664
696
 
697
+ def oauth2(provider: :custom, **opts)
698
+ @strategies[:oauth2] = Auth::OAuth2.new(provider: provider, **opts)
699
+ end
700
+
665
701
  def build
666
702
  @strategies.values.first
667
703
  end
@@ -1,18 +1,74 @@
1
- # lib/whoosh/auth/oauth2.rb
2
1
  # frozen_string_literal: true
3
2
 
3
+ require "securerandom"
4
+
4
5
  module Whoosh
5
6
  module Auth
6
7
  class OAuth2
7
- def initialize(token_url: "/oauth/token", validator: nil)
8
+ PROVIDERS = {
9
+ google: {
10
+ authorize_url: "https://accounts.google.com/o/oauth2/v2/auth",
11
+ token_url: "https://oauth2.googleapis.com/token",
12
+ userinfo_url: "https://www.googleapis.com/oauth2/v3/userinfo"
13
+ },
14
+ github: {
15
+ authorize_url: "https://github.com/login/oauth/authorize",
16
+ token_url: "https://github.com/login/oauth/access_token",
17
+ userinfo_url: "https://api.github.com/user"
18
+ },
19
+ apple: {
20
+ authorize_url: "https://appleid.apple.com/auth/authorize",
21
+ token_url: "https://appleid.apple.com/auth/token",
22
+ userinfo_url: nil
23
+ }
24
+ }.freeze
25
+
26
+ attr_reader :provider
27
+
28
+ def initialize(provider: :custom, client_id: nil, client_secret: nil,
29
+ authorize_url: nil, token_url: nil, userinfo_url: nil,
30
+ redirect_uri: nil, scopes: [], validator: nil)
31
+ @provider = provider
32
+ @client_id = client_id
33
+ @client_secret = client_secret
34
+ @authorize_url = authorize_url
8
35
  @token_url = token_url
36
+ @userinfo_url = userinfo_url
37
+ @redirect_uri = redirect_uri
38
+ @scopes = scopes
9
39
  @validator = validator
40
+ apply_provider_defaults if PROVIDERS[@provider]
41
+ end
42
+
43
+ def authorize_url(state: SecureRandom.hex(16))
44
+ params = {
45
+ client_id: @client_id, redirect_uri: @redirect_uri,
46
+ response_type: "code", scope: @scopes.join(" "), state: state
47
+ }
48
+ "#{@authorize_url}?#{URI.encode_www_form(params)}"
49
+ end
50
+
51
+ def exchange_code(code)
52
+ response = HTTP.post(@token_url, json: {
53
+ client_id: @client_id, client_secret: @client_secret,
54
+ code: code, redirect_uri: @redirect_uri, grant_type: "authorization_code"
55
+ }, headers: { "Accept" => "application/json" })
56
+ raise Errors::UnauthorizedError, "Token exchange failed: #{response.status}" unless response.ok?
57
+ response.json
58
+ end
59
+
60
+ def userinfo(access_token)
61
+ return nil unless @userinfo_url
62
+ response = HTTP.get(@userinfo_url, headers: {
63
+ "Authorization" => "Bearer #{access_token}", "Accept" => "application/json"
64
+ })
65
+ raise Errors::UnauthorizedError, "Userinfo failed" unless response.ok?
66
+ response.json
10
67
  end
11
68
 
12
69
  def authenticate(request)
13
70
  auth_header = request.headers["Authorization"]
14
- raise Errors::UnauthorizedError, "Missing authorization header" unless auth_header
15
-
71
+ raise Errors::UnauthorizedError, "Missing authorization" unless auth_header
16
72
  token = auth_header.sub(/\ABearer\s+/i, "")
17
73
  raise Errors::UnauthorizedError, "Missing token" if token.empty?
18
74
 
@@ -20,13 +76,20 @@ module Whoosh
20
76
  result = @validator.call(token)
21
77
  raise Errors::UnauthorizedError, "Invalid token" unless result
22
78
  result
79
+ elsif @userinfo_url
80
+ userinfo(token)
23
81
  else
24
82
  { token: token }
25
83
  end
26
84
  end
27
85
 
28
- def token_url
29
- @token_url
86
+ private
87
+
88
+ def apply_provider_defaults
89
+ defaults = PROVIDERS[@provider]
90
+ @authorize_url ||= defaults[:authorize_url]
91
+ @token_url ||= defaults[:token_url]
92
+ @userinfo_url ||= defaults[:userinfo_url]
30
93
  end
31
94
  end
32
95
  end
@@ -0,0 +1,237 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "whoosh/client_gen/ir"
4
+ require "whoosh/client_gen/introspector"
5
+ require "whoosh/client_gen/base_generator"
6
+ require "whoosh/client_gen/dependency_checker"
7
+ require "whoosh/client_gen/fallback_backend"
8
+
9
+ module Whoosh
10
+ module ClientGen
11
+ class Error < StandardError; end
12
+ end
13
+
14
+ module CLI
15
+ class ClientGenerator
16
+ CLIENT_TYPES = %i[react_spa expo ios flutter htmx telegram_bot telegram_mini_app].freeze
17
+
18
+ attr_reader :type, :oauth, :output_dir
19
+
20
+ def self.client_types
21
+ CLIENT_TYPES
22
+ end
23
+
24
+ def initialize(type:, oauth:, dir:, root: Dir.pwd)
25
+ @type = type.to_sym
26
+ @oauth = oauth
27
+ @output_dir = dir || default_output_dir
28
+ @root = root
29
+ end
30
+
31
+ def run
32
+ validate!
33
+ check_dependencies!
34
+ result = introspect_or_fallback
35
+
36
+ case result[:mode]
37
+ when :introspected
38
+ display_found(result[:ir])
39
+ ir = confirm_selection(result[:ir])
40
+ generate_client(ir)
41
+ when :fallback
42
+ display_fallback_prompt
43
+ generate_fallback_backend
44
+ ir = build_fallback_ir
45
+ generate_client(ir)
46
+ end
47
+
48
+ display_success
49
+ end
50
+
51
+ def validate!
52
+ unless CLIENT_TYPES.include?(@type)
53
+ raise ClientGen::Error, "Unknown client type: #{@type}. Supported: #{CLIENT_TYPES.join(", ")}"
54
+ end
55
+ end
56
+
57
+ def default_output_dir
58
+ "clients/#{@type}"
59
+ end
60
+
61
+ def check_dependencies!
62
+ result = ClientGen::DependencyChecker.check(@type)
63
+ return if result[:ok]
64
+
65
+ puts "\n⚠️ Missing dependencies for #{@type}:"
66
+ result[:missing].each do |dep|
67
+ msg = " - #{dep[:cmd]} (check: #{dep[:check]})"
68
+ msg += " — found v#{dep[:found_version]}, need v#{dep[:min_version]}+" if dep[:found_version]
69
+ puts msg
70
+ end
71
+ puts "\nInstall the missing dependencies and try again."
72
+ exit 1
73
+ end
74
+
75
+ def introspect_or_fallback
76
+ app = load_app
77
+ if app
78
+ introspector = ClientGen::Introspector.new(app, base_url: detect_base_url(app))
79
+ ir = introspector.introspect
80
+ if ir.has_resources? || ir.has_auth?
81
+ { mode: :introspected, ir: ir }
82
+ else
83
+ { mode: :fallback }
84
+ end
85
+ else
86
+ { mode: :fallback }
87
+ end
88
+ end
89
+
90
+ private
91
+
92
+ def load_app
93
+ app_file = File.join(@root, "app.rb")
94
+ return nil unless File.exist?(app_file)
95
+
96
+ require app_file
97
+ ObjectSpace.each_object(Whoosh::App).first
98
+ rescue => e
99
+ puts "⚠️ Failed to load app: #{e.message}"
100
+ puts "Run `whoosh check` to debug."
101
+ nil
102
+ end
103
+
104
+ def detect_base_url(app)
105
+ config = app.instance_variable_get(:@config)
106
+ port = config&.respond_to?(:port) ? config.port : 9292
107
+ host = config&.respond_to?(:host) ? config.host : "localhost"
108
+ "http://#{host}:#{port}"
109
+ end
110
+
111
+ def display_found(ir)
112
+ puts "\n🔍 Inspecting Whoosh app...\n\n"
113
+ puts "Found:"
114
+ puts " Auth: #{ir.auth&.type || "none"}"
115
+ ir.resources.each do |r|
116
+ puts " Resource: #{r.name} (#{r.endpoints.length} endpoints)"
117
+ end
118
+ ir.streaming.each do |s|
119
+ puts " Streaming: #{s[:type]} on #{s[:path]}"
120
+ end
121
+ puts
122
+ end
123
+
124
+ def confirm_selection(ir)
125
+ ir
126
+ end
127
+
128
+ def display_fallback_prompt
129
+ puts "\n⚠️ No Whoosh app found (or no routes defined).\n\n"
130
+ puts "Generating standard starter with:"
131
+ puts " - JWT auth (email/password login + register)"
132
+ puts " - Tasks CRUD (title, description, status, due_date)"
133
+ puts " - Matching backend endpoints"
134
+ if @oauth
135
+ puts " - OAuth2 (Google, GitHub, Apple)"
136
+ end
137
+ puts
138
+ end
139
+
140
+ def generate_fallback_backend
141
+ ClientGen::FallbackBackend.generate(root: @root, oauth: @oauth)
142
+ puts "✅ Backend endpoints generated"
143
+ end
144
+
145
+ def build_fallback_ir
146
+ ClientGen::IR::AppSpec.new(
147
+ auth: ClientGen::IR::Auth.new(
148
+ type: :jwt,
149
+ endpoints: {
150
+ login: { method: :post, path: "/auth/login" },
151
+ register: { method: :post, path: "/auth/register" },
152
+ refresh: { method: :post, path: "/auth/refresh" },
153
+ logout: { method: :delete, path: "/auth/logout" },
154
+ me: { method: :get, path: "/auth/me" }
155
+ },
156
+ oauth_providers: @oauth ? %i[google github apple] : []
157
+ ),
158
+ resources: [
159
+ ClientGen::IR::Resource.new(
160
+ name: :tasks,
161
+ endpoints: [
162
+ ClientGen::IR::Endpoint.new(method: :get, path: "/tasks", action: :index, pagination: true),
163
+ ClientGen::IR::Endpoint.new(method: :get, path: "/tasks/:id", action: :show),
164
+ ClientGen::IR::Endpoint.new(method: :post, path: "/tasks", action: :create),
165
+ ClientGen::IR::Endpoint.new(method: :put, path: "/tasks/:id", action: :update),
166
+ ClientGen::IR::Endpoint.new(method: :delete, path: "/tasks/:id", action: :destroy)
167
+ ],
168
+ fields: [
169
+ { name: :title, type: :string, required: true },
170
+ { name: :description, type: :string, required: false },
171
+ { name: :status, type: :string, required: false, enum: %w[pending in_progress done], default: "pending" },
172
+ { name: :due_date, type: :string, required: false }
173
+ ]
174
+ )
175
+ ],
176
+ streaming: [],
177
+ base_url: "http://localhost:9292"
178
+ )
179
+ end
180
+
181
+ def generate_client(ir)
182
+ generator_class = load_generator_class
183
+ output = File.join(@root, @output_dir)
184
+
185
+ if Dir.exist?(output) && !Dir.empty?(output)
186
+ puts "⚠️ #{@output_dir}/ already exists."
187
+ print "Overwrite? [y/N] "
188
+ answer = $stdin.gets&.strip&.downcase
189
+ unless answer == "y"
190
+ puts "Aborted."
191
+ exit 0
192
+ end
193
+ FileUtils.rm_rf(output)
194
+ end
195
+
196
+ generator_class.new(ir: ir, output_dir: output, platform: platform_for_type).generate
197
+ end
198
+
199
+ def load_generator_class
200
+ require "whoosh/client_gen/generators/#{@type}"
201
+ Whoosh::ClientGen::Generators.const_get(camelize(@type.to_s))
202
+ end
203
+
204
+ def platform_for_type
205
+ case @type
206
+ when :react_spa, :expo, :telegram_mini_app then :typescript
207
+ when :ios then :swift
208
+ when :flutter then :dart
209
+ when :htmx then :html
210
+ when :telegram_bot then :ruby
211
+ end
212
+ end
213
+
214
+ def camelize(str)
215
+ str.split("_").map(&:capitalize).join
216
+ end
217
+
218
+ def display_success
219
+ puts "\n✅ Generated #{@type} client in #{@output_dir}/"
220
+ case @type
221
+ when :react_spa, :telegram_mini_app
222
+ puts " Run: cd #{@output_dir} && npm install && npm run dev"
223
+ when :expo
224
+ puts " Run: cd #{@output_dir} && npm install && npx expo start"
225
+ when :ios
226
+ puts " Run: open #{@output_dir}/WhooshApp.xcodeproj"
227
+ when :flutter
228
+ puts " Run: cd #{@output_dir} && flutter pub get && flutter run"
229
+ when :htmx
230
+ puts " Run: cd #{@output_dir} && open index.html (or any static server)"
231
+ when :telegram_bot
232
+ puts " Run: cd #{@output_dir} && bundle install && ruby bot.rb"
233
+ end
234
+ end
235
+ end
236
+ end
237
+ end
@@ -600,6 +600,16 @@ module Whoosh
600
600
  require "whoosh/cli/generators"
601
601
  Whoosh::CLI::Generators.proto(name)
602
602
  end
603
+
604
+ desc "client TYPE", "Generate a client app (react_spa, expo, ios, flutter, htmx, telegram_bot, telegram_mini_app)"
605
+ option :oauth, type: :boolean, default: false, desc: "Include OAuth2 social login"
606
+ option :dir, type: :string, desc: "Output directory (default: clients/<type>)"
607
+ def client(type)
608
+ require "whoosh/cli/client_generator"
609
+ Whoosh::CLI::ClientGenerator.new(
610
+ type: type, oauth: options[:oauth], dir: options[:dir]
611
+ ).run
612
+ end
603
613
  }
604
614
  end
605
615
  end