ruby_native 0.11.2 → 0.12.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 +4 -4
- data/README.md +1 -1
- data/app/controllers/ruby_native/auth/start_controller.rb +7 -1
- data/app/controllers/ruby_native/iap/restores_controller.rb +70 -21
- data/lib/generators/ruby_native/iap_generator.rb +17 -2
- data/lib/generators/ruby_native/install_generator.rb +1 -0
- data/lib/generators/ruby_native/templates/add_restored_transaction_id_to_ruby_native_purchase_intents.rb +5 -0
- data/lib/ruby_native/cli/deploy.rb +8 -3
- data/lib/ruby_native/cli/login.rb +10 -5
- data/lib/ruby_native/cli/preview.rb +12 -17
- data/lib/ruby_native/helper.rb +16 -3
- data/lib/ruby_native/oauth_middleware.rb +24 -2
- data/lib/ruby_native/version.rb +1 -1
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9febf470fc9b33fbbfe136f4d7dc916f002ce532707281a65c1d9733cab09559
|
|
4
|
+
data.tar.gz: 9789db1959e99b53cc8a5d40dcf8a093032553569c854bcfcb5d54fa3610f54f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c94d0798d18f68e60d599cb59aa3296fffecb307cf907a1f0519efe61387be1c8255d46f50938c5e1f3541d3795dee4030f45a52a806aa1d19138a5e76eed964
|
|
7
|
+
data.tar.gz: 0a4387e5123e7e372521bc4eefefbbaf940d321ba09bf8e5f216471ace3f81f731f98d6f7df759fb380064b72cf96b887a4b72b433815124f503e969866f355c
|
data/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# ruby_native
|
|
2
2
|
|
|
3
|
-
[Ruby Native](https://rubynative.com) turns a Rails app into
|
|
3
|
+
[Ruby Native](https://rubynative.com) turns a Rails app into iOS and Android apps. This gem is the Rails-side integration: view helpers, a YAML config file, endpoints auto-mounted at `/native`, and a CLI for previewing and deploying builds. Full docs, including the complete helper reference and config schema, live at [rubynative.com/docs](https://rubynative.com/docs).
|
|
4
4
|
|
|
5
5
|
## Installation
|
|
6
6
|
|
|
@@ -9,9 +9,15 @@ module RubyNative
|
|
|
9
9
|
return
|
|
10
10
|
end
|
|
11
11
|
|
|
12
|
+
@callback_scheme = params[:callback_scheme].to_s
|
|
13
|
+
|
|
14
|
+
unless @callback_scheme.match?(OAuthMiddleware::CALLBACK_SCHEME)
|
|
15
|
+
head :bad_request
|
|
16
|
+
return
|
|
17
|
+
end
|
|
18
|
+
|
|
12
19
|
oauth_paths = RubyNative.config&.dig(:auth, :oauth_paths) || []
|
|
13
20
|
@oauth_path = oauth_paths.find { |p| p.end_with?(@provider) } || "/auth/#{@provider}"
|
|
14
|
-
@callback_scheme = params[:callback_scheme]
|
|
15
21
|
end
|
|
16
22
|
end
|
|
17
23
|
end
|
|
@@ -7,37 +7,86 @@ module RubyNative
|
|
|
7
7
|
skip_forgery_protection
|
|
8
8
|
|
|
9
9
|
def create
|
|
10
|
-
|
|
11
|
-
return head :bad_request if customer_id.blank?
|
|
10
|
+
return head :bad_request if params[:customer_id].blank?
|
|
12
11
|
|
|
13
12
|
transactions = Array(params[:signed_transactions])
|
|
14
13
|
return head :bad_request if transactions.empty?
|
|
15
14
|
|
|
16
|
-
transactions.each
|
|
17
|
-
transaction = decode_and_verify_jws(signed_transaction)
|
|
18
|
-
|
|
19
|
-
event = Event.new(
|
|
20
|
-
type: "subscription.created",
|
|
21
|
-
status: "active",
|
|
22
|
-
owner_token: customer_id,
|
|
23
|
-
product_id: transaction["productId"],
|
|
24
|
-
original_transaction_id: transaction["originalTransactionId"],
|
|
25
|
-
transaction_id: transaction["transactionId"],
|
|
26
|
-
purchase_date: parse_timestamp(transaction["purchaseDate"]),
|
|
27
|
-
expires_date: parse_timestamp(transaction["expiresDate"]),
|
|
28
|
-
environment: transaction["environment"]&.downcase,
|
|
29
|
-
notification_uuid: SecureRandom.uuid,
|
|
30
|
-
success_path: params[:success_path]
|
|
31
|
-
)
|
|
32
|
-
|
|
33
|
-
RubyNative.fire_subscription_callbacks(event)
|
|
34
|
-
end
|
|
15
|
+
transactions.each { |signed_transaction| restore(decode_and_verify_jws(signed_transaction)) }
|
|
35
16
|
|
|
36
17
|
head :ok
|
|
37
18
|
rescue VerificationError, JWT::DecodeError => e
|
|
38
19
|
Rails.logger.warn "[RubyNative] Restore verification failed: #{e.message}"
|
|
39
20
|
head :unprocessable_entity
|
|
40
21
|
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
# The account being restored to comes from the purchase intent the
|
|
26
|
+
# transaction was made against, never from `customer_id` in the request. A
|
|
27
|
+
# signed transaction is only proof that Apple sold it, not proof of who is
|
|
28
|
+
# asking, so honoring a caller-supplied id let one purchased subscription
|
|
29
|
+
# be replayed to grant entitlement to any number of accounts.
|
|
30
|
+
def restore(transaction)
|
|
31
|
+
transaction_id = transaction["transactionId"]
|
|
32
|
+
intent = PurchaseIntent.find_by(uuid: transaction["appAccountToken"])
|
|
33
|
+
|
|
34
|
+
unless intent
|
|
35
|
+
Rails.logger.warn "[RubyNative] Ignoring restored transaction #{transaction_id.inspect}: " \
|
|
36
|
+
"no purchase intent matches its appAccountToken"
|
|
37
|
+
return
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
if params[:customer_id] != intent.customer_id
|
|
41
|
+
Rails.logger.warn "[RubyNative] Restoring transaction #{transaction_id.inspect} to " \
|
|
42
|
+
"#{intent.customer_id.inspect} from its purchase intent, not the requested " \
|
|
43
|
+
"#{params[:customer_id].inspect}"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Apple reissues a transaction id on every renewal, so a restore of the
|
|
47
|
+
# same one is a repeat and must not fire the callback again.
|
|
48
|
+
return if dedup_available? && intent.restored_transaction_id == transaction_id
|
|
49
|
+
|
|
50
|
+
intent.update!(intent_attributes(transaction))
|
|
51
|
+
|
|
52
|
+
event = Event.new(
|
|
53
|
+
type: "subscription.created",
|
|
54
|
+
status: "active",
|
|
55
|
+
owner_token: intent.customer_id,
|
|
56
|
+
product_id: transaction["productId"],
|
|
57
|
+
original_transaction_id: transaction["originalTransactionId"],
|
|
58
|
+
transaction_id: transaction_id,
|
|
59
|
+
purchase_date: parse_timestamp(transaction["purchaseDate"]),
|
|
60
|
+
expires_date: parse_timestamp(transaction["expiresDate"]),
|
|
61
|
+
environment: transaction["environment"]&.downcase,
|
|
62
|
+
notification_uuid: SecureRandom.uuid,
|
|
63
|
+
success_path: params[:success_path]
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
RubyNative.fire_subscription_callbacks(event)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# A restore is the recovery path for a purchase whose completion POST never
|
|
70
|
+
# landed, so it settles the intent too. An unrecognized environment is
|
|
71
|
+
# dropped rather than raising on the enum.
|
|
72
|
+
def intent_attributes(transaction)
|
|
73
|
+
attributes = {status: :completed}
|
|
74
|
+
environment = transaction["environment"]&.downcase
|
|
75
|
+
attributes[:environment] = environment if PurchaseIntent.environments.key?(environment)
|
|
76
|
+
attributes[:restored_transaction_id] = transaction["transactionId"] if dedup_available?
|
|
77
|
+
attributes
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Deduping needs a column that arrives in a migration, and an app can be on
|
|
81
|
+
# this version of the gem before it has been run. Restoring to the right
|
|
82
|
+
# account is the part that matters and needs no column, so a missing one
|
|
83
|
+
# costs the repeat-callback guard and nothing else -- which is how restore
|
|
84
|
+
# behaved before the column existed anyway. Writing to it regardless would
|
|
85
|
+
# raise, and the app reports a restore as successful without reading the
|
|
86
|
+
# response, so the customer would be told "Restored!" over a 500.
|
|
87
|
+
def dedup_available?
|
|
88
|
+
PurchaseIntent.column_names.include?("restored_transaction_id")
|
|
89
|
+
end
|
|
41
90
|
end
|
|
42
91
|
end
|
|
43
92
|
end
|
|
@@ -8,13 +8,28 @@ module RubyNative
|
|
|
8
8
|
|
|
9
9
|
source_root File.expand_path("templates", __dir__)
|
|
10
10
|
|
|
11
|
+
# Must increment within a single run, or the second migration below collides
|
|
12
|
+
# with the first on a bare `Time.now` timestamp.
|
|
11
13
|
def self.next_migration_number(dirname)
|
|
12
|
-
|
|
14
|
+
ActiveRecord::Migration.next_migration_number(current_migration_number(dirname) + 1)
|
|
13
15
|
end
|
|
14
16
|
|
|
15
|
-
|
|
17
|
+
# Re-running this generator on an app that already has the first migration
|
|
18
|
+
# reports it identical and creates only what is missing, so it doubles as
|
|
19
|
+
# the upgrade path.
|
|
20
|
+
#
|
|
21
|
+
# That rests on `create_ruby_native_purchase_intents.rb` never changing.
|
|
22
|
+
# Rails compares the template against the copy the app already has, and
|
|
23
|
+
# only calls it identical when they match byte for byte; edit one character
|
|
24
|
+
# and it reports a conflict instead, which raises and aborts the run before
|
|
25
|
+
# the migrations added after it are copied. So an app upgrading would get
|
|
26
|
+
# none of them. Add a new template alongside it rather than editing it --
|
|
27
|
+
# `iap_generator_test.rb` fails if that file is touched.
|
|
28
|
+
def copy_migrations
|
|
16
29
|
migration_template "create_ruby_native_purchase_intents.rb",
|
|
17
30
|
"db/migrate/create_ruby_native_purchase_intents.rb"
|
|
31
|
+
migration_template "add_restored_transaction_id_to_ruby_native_purchase_intents.rb",
|
|
32
|
+
"db/migrate/add_restored_transaction_id_to_ruby_native_purchase_intents.rb"
|
|
18
33
|
end
|
|
19
34
|
|
|
20
35
|
def print_next_steps
|
|
@@ -40,6 +40,7 @@ module RubyNative
|
|
|
40
40
|
say " <meta name=\"viewport\" content=\"width=device-width,initial-scale=1,viewport-fit=cover\">"
|
|
41
41
|
say " 4. Add to your layout <body>:"
|
|
42
42
|
say " <%= native_tabs_tag %>"
|
|
43
|
+
say " <%= native_identity_tag(current_user&.id) %>"
|
|
43
44
|
say " 5. Preview on your device:"
|
|
44
45
|
say " bundle exec ruby_native preview"
|
|
45
46
|
say ""
|
|
@@ -79,17 +79,22 @@ module RubyNative
|
|
|
79
79
|
end
|
|
80
80
|
|
|
81
81
|
def fetch_latest_build(app_id)
|
|
82
|
-
|
|
82
|
+
# Platform-scoped, or --android --if-needed compares against the
|
|
83
|
+
# latest iOS build and skips a build Android never got.
|
|
84
|
+
uri = URI("#{HOST}/api/v1/apps/#{app_id}/builds/latest?platform=#{@platform}")
|
|
83
85
|
req = Net::HTTP::Get.new(uri)
|
|
84
86
|
req["Authorization"] = "Token #{Credentials.token}"
|
|
85
87
|
|
|
86
88
|
response = make_request(uri, req)
|
|
87
89
|
|
|
88
90
|
case response
|
|
89
|
-
|
|
90
|
-
|
|
91
|
+
# Before HTTPSuccess, its superclass: a 204 has no body to parse, and
|
|
92
|
+
# matching Success first crashed --if-needed before an app's first
|
|
93
|
+
# successful build.
|
|
91
94
|
when Net::HTTPNoContent
|
|
92
95
|
nil
|
|
96
|
+
when Net::HTTPSuccess
|
|
97
|
+
JSON.parse(response.body)
|
|
93
98
|
when Net::HTTPUnauthorized
|
|
94
99
|
raise TokenExpiredError
|
|
95
100
|
else
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
require "digest"
|
|
1
2
|
require "securerandom"
|
|
2
3
|
require "net/http"
|
|
3
4
|
require "uri"
|
|
@@ -13,14 +14,18 @@ module RubyNative
|
|
|
13
14
|
end
|
|
14
15
|
|
|
15
16
|
def run
|
|
16
|
-
|
|
17
|
-
|
|
17
|
+
# Only the hash goes through the browser. Claiming the token needs this
|
|
18
|
+
# verifier, which never leaves the machine, so a copy of the authorize
|
|
19
|
+
# URL is not enough for anyone else to collect the session.
|
|
20
|
+
verifier = SecureRandom.hex(32)
|
|
21
|
+
challenge = Digest::SHA256.hexdigest(verifier)
|
|
22
|
+
url = "#{HOST}/cli/session/new?challenge=#{challenge}"
|
|
18
23
|
|
|
19
24
|
puts "Opening browser to authorize..."
|
|
20
25
|
open_browser(url)
|
|
21
26
|
puts "Waiting for authorization..."
|
|
22
27
|
|
|
23
|
-
token = poll_for_token(
|
|
28
|
+
token = poll_for_token(verifier)
|
|
24
29
|
|
|
25
30
|
if token
|
|
26
31
|
Credentials.save(token)
|
|
@@ -44,8 +49,8 @@ module RubyNative
|
|
|
44
49
|
end
|
|
45
50
|
end
|
|
46
51
|
|
|
47
|
-
def poll_for_token(
|
|
48
|
-
uri = URI("#{HOST}/cli/session/poll?
|
|
52
|
+
def poll_for_token(verifier)
|
|
53
|
+
uri = URI("#{HOST}/cli/session/poll?verifier=#{verifier}")
|
|
49
54
|
attempts = 0
|
|
50
55
|
max_attempts = 60
|
|
51
56
|
|
|
@@ -165,25 +165,20 @@ module RubyNative
|
|
|
165
165
|
require "rqrcode"
|
|
166
166
|
|
|
167
167
|
qr = RQRCode::QRCode.new(url, level: :l)
|
|
168
|
-
modules = qr.modules
|
|
169
|
-
size = modules.length
|
|
170
|
-
quiet_h = 4
|
|
171
|
-
quiet_v = 2
|
|
172
|
-
|
|
173
|
-
dark = "██"
|
|
174
|
-
light = " "
|
|
175
168
|
|
|
169
|
+
# Painted as background colors, not block glyphs: a glyph takes the terminal's
|
|
170
|
+
# foreground color, which prints the code inverted on a dark theme, and Android
|
|
171
|
+
# (unlike iOS) will not decode an inverted code. Truecolor rather than any
|
|
172
|
+
# palette index, because terminals remap both the 16 basic colors and the 256
|
|
173
|
+
# cube, which is how "white" came out as a mid-gray in one terminal and as
|
|
174
|
+
# orange in another. 24-bit RGB is a literal value with no lookup, so the
|
|
175
|
+
# contrast and the polarity are the same everywhere.
|
|
176
176
|
puts ""
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
inside = mr >= 0 && mr < size && mc >= 0 && mc < size
|
|
183
|
-
line << (inside && modules[mr][mc] ? dark : light)
|
|
184
|
-
end
|
|
185
|
-
puts line
|
|
186
|
-
end
|
|
177
|
+
print qr.as_ansi(
|
|
178
|
+
light: "\e[48;2;255;255;255m",
|
|
179
|
+
dark: "\e[48;2;0;0;0m",
|
|
180
|
+
quiet_zone_size: 4
|
|
181
|
+
)
|
|
187
182
|
puts ""
|
|
188
183
|
puts url
|
|
189
184
|
puts ""
|
data/lib/ruby_native/helper.rb
CHANGED
|
@@ -26,6 +26,18 @@ module RubyNative
|
|
|
26
26
|
tag.div(data: { native_push: true }, hidden: true)
|
|
27
27
|
end
|
|
28
28
|
|
|
29
|
+
def native_identity_tag(value)
|
|
30
|
+
tag.div(data: { native_identity: native_identity_token(value) }, hidden: true)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# HMAC, not a bare digest: a digest of a small integer id space is enumerable,
|
|
34
|
+
# so the DOM would effectively still carry the id it was meant to hide.
|
|
35
|
+
def native_identity_token(value)
|
|
36
|
+
parts = Array(value).compact
|
|
37
|
+
return "" if parts.empty?
|
|
38
|
+
OpenSSL::HMAC.hexdigest("SHA256", Rails.application.secret_key_base, parts.join(":"))[0, 16]
|
|
39
|
+
end
|
|
40
|
+
|
|
29
41
|
def native_back_button_tag(text = nil, **options)
|
|
30
42
|
options[:class] = [options[:class], "native-back-button"].compact.join(" ")
|
|
31
43
|
default_content = tag.svg(
|
|
@@ -295,8 +307,9 @@ module RubyNative
|
|
|
295
307
|
# web side) so it works even where embedded web views don't support
|
|
296
308
|
# `navigator.share`. Icons follow the same rules as the other navbar
|
|
297
309
|
# buttons: `icon:` applies to every platform and `icons:` ({ ios:,
|
|
298
|
-
# android: }) overrides per platform.
|
|
299
|
-
|
|
310
|
+
# android: }) overrides per platform. Android defaults to the Material
|
|
311
|
+
# `share` glyph; the SF Symbol default renders as missing there.
|
|
312
|
+
def share_button(url: nil, title: "Share", icon: "square.and.arrow.up", icons: { android: "share" }, position: :trailing)
|
|
300
313
|
resolved = RubyNative::Helper.resolve_icon(icon: icon, icons: icons, platform: @context.try(:native_platform))
|
|
301
314
|
data = { native_button: "", native_share: "" }
|
|
302
315
|
data[:native_title] = title if title
|
|
@@ -352,7 +365,7 @@ module RubyNative
|
|
|
352
365
|
# opens the platform share sheet for `url:` (defaults to the current
|
|
353
366
|
# page on the web side). Icons follow the same `icon:`/`icons:` rules
|
|
354
367
|
# as the other menu items.
|
|
355
|
-
def share_item(title = "Share", url: nil, icon: "square.and.arrow.up", icons:
|
|
368
|
+
def share_item(title = "Share", url: nil, icon: "square.and.arrow.up", icons: { android: "share" }, selected: false)
|
|
356
369
|
resolved = RubyNative::Helper.resolve_icon(icon: icon, icons: icons, platform: @context.try(:native_platform))
|
|
357
370
|
data = { native_menu_item: "", native_title: title, native_share: "" }
|
|
358
371
|
data[:native_share_url] = url if url
|
|
@@ -2,6 +2,14 @@ module RubyNative
|
|
|
2
2
|
class OAuthMiddleware
|
|
3
3
|
COOKIE_NAME = "_ruby_native_oauth"
|
|
4
4
|
|
|
5
|
+
# The scheme the native app registers is always "rubynative-" plus its bundle
|
|
6
|
+
# identifier with dots as dashes, built by OAuthManager.callbackScheme on both
|
|
7
|
+
# platforms. The scheme arrives as a request param and ends up as the target of
|
|
8
|
+
# the redirect carrying the session token, so anything that could name a
|
|
9
|
+
# different origin -- a colon, slash, dot, "@", or percent escape -- is
|
|
10
|
+
# rejected outright rather than sanitized.
|
|
11
|
+
CALLBACK_SCHEME = /\Arubynative-[a-z0-9][a-z0-9_-]{0,127}\z/i
|
|
12
|
+
|
|
5
13
|
def initialize(app)
|
|
6
14
|
@app = app
|
|
7
15
|
end
|
|
@@ -10,7 +18,7 @@ module RubyNative
|
|
|
10
18
|
request = ActionDispatch::Request.new(env)
|
|
11
19
|
on_oauth_path = oauth_path?(request)
|
|
12
20
|
started_native_oauth = on_oauth_path && request.params["ruby_native"] == "1"
|
|
13
|
-
callback_scheme = request.params["callback_scheme"] if started_native_oauth
|
|
21
|
+
callback_scheme = permitted_scheme(request.params["callback_scheme"]) if started_native_oauth
|
|
14
22
|
|
|
15
23
|
status, headers, body = @app.call(env)
|
|
16
24
|
|
|
@@ -23,7 +31,7 @@ module RubyNative
|
|
|
23
31
|
set_cookie(headers, callback_scheme)
|
|
24
32
|
end
|
|
25
33
|
|
|
26
|
-
stored_scheme = read_cookie(request)
|
|
34
|
+
stored_scheme = permitted_scheme(read_cookie(request))
|
|
27
35
|
|
|
28
36
|
if stored_scheme && redirect?(status)
|
|
29
37
|
location = headers["location"] || headers["Location"]
|
|
@@ -70,6 +78,20 @@ module RubyNative
|
|
|
70
78
|
|
|
71
79
|
private
|
|
72
80
|
|
|
81
|
+
# Returns the scheme only when it matches CALLBACK_SCHEME. A rejected scheme
|
|
82
|
+
# reads as "no native app asked for this", so the flow falls through as an
|
|
83
|
+
# ordinary web sign-in and no token is ever minted.
|
|
84
|
+
def permitted_scheme(value)
|
|
85
|
+
return nil if value.blank?
|
|
86
|
+
return value if value.match?(CALLBACK_SCHEME)
|
|
87
|
+
|
|
88
|
+
Rails.logger.warn do
|
|
89
|
+
"[RubyNative] Rejected OAuth callback_scheme #{value.to_s.truncate(64).inspect}: " \
|
|
90
|
+
"expected the app's own rubynative-<bundle-id> scheme"
|
|
91
|
+
end
|
|
92
|
+
nil
|
|
93
|
+
end
|
|
94
|
+
|
|
73
95
|
def oauth_path?(request)
|
|
74
96
|
oauth_paths.any? { |p| request.path == p }
|
|
75
97
|
end
|
data/lib/ruby_native/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: ruby_native
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.12.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Joe Masilotti
|
|
@@ -86,6 +86,7 @@ files:
|
|
|
86
86
|
- exe/ruby_native
|
|
87
87
|
- lib/generators/ruby_native/iap_generator.rb
|
|
88
88
|
- lib/generators/ruby_native/install_generator.rb
|
|
89
|
+
- lib/generators/ruby_native/templates/add_restored_transaction_id_to_ruby_native_purchase_intents.rb
|
|
89
90
|
- lib/generators/ruby_native/templates/create_ruby_native_purchase_intents.rb
|
|
90
91
|
- lib/generators/ruby_native/templates/ruby_native.yml
|
|
91
92
|
- lib/ruby_native.rb
|