ruby_native 0.17.3 → 0.18.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: 0c51153c763a49fbf4d88fa288adcfe46ef69402685a9ccc8ccdf9bfe9b35da8
4
- data.tar.gz: 624eb747e1fccc0bf7e14d7b4a96822ea98fbd69ff9902cca01920e29997ac28
3
+ metadata.gz: 6943c548d9910886fefa4a121b493c1cdc40b06ab34059c4deac3fac3ac86018
4
+ data.tar.gz: def52388edde0250a098f4ffa4c02e0fe691d7aa0383b1049c2ac1c178be559b
5
5
  SHA512:
6
- metadata.gz: a4fe11e26a297f5ce16e995ad2892c5da65ba14b9703e0ea1c5f6bf1659bb43f3dfb18cdee60667159e71c8f4ca56dc44f8f7a731ce292f41416e15c46355525
7
- data.tar.gz: 8d71d20c92e2a9203d2948df069ea4ed1fb6b20e6f76a13e38bcd67dfbd860c4982829f21fba5f2102cf145cc60bf97397d348c7ce5da2ad65de5b96ea1d93a7
6
+ metadata.gz: 7bdaecaa1892409cfdde1f9e6f7161acd430065e470c35a73e95a25ace1bad76f303802226952965fa8e109b8848ee4fc42c6a6db3fefb5bc0b691140b705bb1
7
+ data.tar.gz: b67256819c46021c2de3e11e89fa5d6c7acc51064256913d5c9e5b44ebec3184c9b0b68e9461e0d4141246c32f0363da6f423fb306781287d78f3d04b23982bd
data/README.md CHANGED
@@ -18,7 +18,13 @@ Run the install generator:
18
18
  rails generate ruby_native:install
19
19
  ```
20
20
 
21
- This creates `config/ruby_native.yml`. Then:
21
+ This creates `config/ruby_native.yml` with tabs taken from your routes. If you use a coding agent, hand it the rest here:
22
+
23
+ ```bash
24
+ bundle exec ruby_native mcp install
25
+ ```
26
+
27
+ Then say: set up Ruby Native in this app. It wires the layout, checks its own work, and later drafts the listing, captures screenshots, and submits for review, stopping only for the few clicks Apple keeps for a person. By hand, the same steps are:
22
28
 
23
29
  1. Edit the config with your colors and tabs (see below).
24
30
  2. Add the stylesheet and `viewport-fit=cover` to your layout `<head>`:
@@ -84,6 +90,52 @@ Building with Inertia instead of ERB? `@ruby-native/react` and `@ruby-native/vue
84
90
 
85
91
  Every docs page serves markdown: append `.md` to the URL or request `Accept: text/markdown`. For an index of everything, fetch [rubynative.com/llms.txt](https://rubynative.com/llms.txt), or [llms-full.txt](https://rubynative.com/llms-full.txt) for the complete docs as a single file.
86
92
 
93
+ ### MCP server
94
+
95
+ Docs tell an agent what to write. They don't tell it whether what it wrote works, and Ruby Native's signals fail silently on purpose: an app that doesn't recognize a `data-native-*` attribute ignores it. A typo renders nothing, raises nothing, and logs nothing, so it survives right up until someone opens the build and notices the missing button.
96
+
97
+ `ruby_native mcp` closes that loop. It speaks [MCP](https://modelcontextprotocol.io) over stdin and stdout. Three tools answer from your working copy, with no account involved:
98
+
99
+ | Tool | Answers |
100
+ |---|---|
101
+ | `check_views` | Which signals in your templates are misspelled, duplicated, or newer than the installed gem |
102
+ | `lookup_signals` | Every `data-native-*` attribute, the gem version it needs, and the helper that emits it |
103
+ | `validate_config` | What in `config/ruby_native.yml` the apps reject outright, and what they quietly ignore |
104
+
105
+ Four more read your account, using the token `ruby_native login` already stored:
106
+
107
+ | Tool | Answers |
108
+ |---|---|
109
+ | `status` | Where the app is on the way to the App Store, and what is in the way, with who clears each thing |
110
+ | `config_errors` | What real devices reported failing over the last 48 hours, with the decode error behind each one |
111
+ | `deployed_builds` | The build your users can actually install, and which signals it's too old to understand |
112
+ | `build_status` | Whether one build succeeded, and the error if it didn't |
113
+
114
+ The pairs are the point. `validate_config` says what *will* fail to decode; `config_errors` says what did, on which devices. `check_views` compares your templates against the gem in your Gemfile; `deployed_builds` compares them against the binary in the store, which is usually the answer to "the attribute is right but nothing happens on my phone".
115
+
116
+ Full reference at [rubynative.com/docs/cli](https://rubynative.com/docs/cli#mcp-server). Register it in the project's `.mcp.json`, which Claude Code reads:
117
+
118
+ ```bash
119
+ bundle exec ruby_native mcp install
120
+ ```
121
+
122
+ Then say: set up Ruby Native in this app.
123
+
124
+ Or, in a client that takes JSON:
125
+
126
+ ```json
127
+ {
128
+ "mcpServers": {
129
+ "ruby-native": {
130
+ "command": "bundle",
131
+ "args": ["exec", "ruby_native", "mcp"]
132
+ }
133
+ }
134
+ }
135
+ ```
136
+
137
+ The rest stage things with Ruby Native and reach Apple only on a build or a click on the dashboard: `permissions_set`, `testers_add`, `icon_set`, `screenshots_setup`, `screenshots_capture`, `listing_push`, `listing_pull`, and `demo_account_set`, each the same as its CLI command. `submit` is the one tool that reaches Apple on its own: it takes two calls with a token in between, so the agent has to show you what is going before it can send it, and `withdraw` takes it back out of the queue until Apple approves. The first three tools never leave your machine; the rest send your CLI token to rubynative.com. `check_views` needs the `herb` gem, same as `ruby_native check`.
138
+
87
139
  ## License
88
140
 
89
141
  MIT.
@@ -5,7 +5,44 @@ module RubyNative
5
5
 
6
6
  desc "Creates a Ruby Native config file at config/ruby_native.yml"
7
7
 
8
+ # The tab bar only appears with two or more tabs (the shell hides it
9
+ # otherwise), and a tab pointing at a path the app does not route 404s
10
+ # on first launch. Both failures land in the first five minutes, so tabs
11
+ # come from the app's own routes, and only from routes that exist.
12
+ MAX_TABS = 4
13
+
14
+ # Route segments that are never a tab: auth, framework, and Ruby Native's
15
+ # own mounts.
16
+ SKIPPED_SEGMENTS = %w[
17
+ rails up cable native assets manifest service-worker pwa admin api webhooks
18
+ session sessions sign_in sign_up signin signup login logout password passwords
19
+ registration registrations users auth oauth
20
+ ].freeze
21
+
22
+ TAB_ICONS = {
23
+ "" => "house",
24
+ "home" => "house",
25
+ "dashboard" => "house",
26
+ "profile" => "person",
27
+ "account" => "person",
28
+ "me" => "person",
29
+ "settings" => "gear",
30
+ "preferences" => "gear",
31
+ "inbox" => "envelope",
32
+ "messages" => "envelope",
33
+ "notifications" => "bell",
34
+ "search" => "magnifyingglass",
35
+ "calendar" => "calendar",
36
+ "events" => "calendar",
37
+ "orders" => "shippingbox",
38
+ "favorites" => "star",
39
+ "bookmarks" => "star"
40
+ }.freeze
41
+
42
+ DEFAULT_ICON = "list.bullet".freeze
43
+
8
44
  def copy_config
45
+ @tabs = proposed_tabs
9
46
  template "ruby_native.yml", "config/ruby_native.yml"
10
47
  end
11
48
 
@@ -41,23 +78,79 @@ module RubyNative
41
78
 
42
79
  def print_next_steps
43
80
  say ""
44
- say "Ruby Native installed! Next steps:", :green
81
+ say "Ruby Native installed.", :green
82
+ say ""
83
+ say " Tabs, from your routes: #{@tabs.map { |tab| "#{tab[:path]} (#{tab[:title]})" }.join(", ")}"
84
+ if @tabs.size < 2
85
+ say " The tab bar stays hidden until config/ruby_native.yml has a second tab.", :yellow
86
+ else
87
+ say " Change them in config/ruby_native.yml."
88
+ end
45
89
  say ""
46
- say " 1. Edit config/ruby_native.yml with your app name, colors, and tabs"
47
- say " 2. Add to your layout <head>:"
48
- say " <%= stylesheet_link_tag :ruby_native %>"
49
- say " 3. Add viewport-fit=cover to your viewport meta tag:"
50
- say " <meta name=\"viewport\" content=\"width=device-width,initial-scale=1,viewport-fit=cover\">"
51
- say " 4. Add to your layout <body>:"
52
- say " <%= native_tabs_tag %>"
53
- say " <%= native_identity_tag(current_user&.id) %>"
54
- say " 5. Preview on your device:"
55
- say " bundle exec ruby_native preview"
90
+ say "By hand:"
91
+ say " 1. Add <%= stylesheet_link_tag :ruby_native %> to your layout <head>"
92
+ say " 2. Add viewport-fit=cover to your viewport meta tag"
93
+ say " 3. Add <%= native_tabs_tag %> and <%= native_identity_tag(current_user&.id) %> to your layout <body>"
94
+ say " 4. Preview on your phone: bundle exec ruby_native preview"
56
95
  say ""
57
96
  say " Docs: https://rubynative.com/docs"
58
- say " AI agents: fetch https://rubynative.com/llms.txt for a docs index,"
59
- say " or https://rubynative.com/llms-full.txt for the full docs as plain text."
97
+ say " Agents: https://rubynative.com/llms.txt for a docs index, llms-full.txt for all of it as plain text"
60
98
  say ""
99
+ say "Or hand it to your agent:", :green
100
+ say ""
101
+ say " bundle exec ruby_native mcp install"
102
+ say ""
103
+ say "Then, in your agent: /mcp__ruby-native__ship"
104
+ say ""
105
+ end
106
+
107
+ private
108
+
109
+ def proposed_tabs
110
+ paths = routable_paths
111
+ paths = [ "/" ] if paths.empty?
112
+
113
+ paths.first(MAX_TABS).map do |path|
114
+ segment = path.delete_prefix("/")
115
+ { title: title_for(segment), path: path, icon: TAB_ICONS.fetch(segment, DEFAULT_ICON) }
116
+ end
117
+ end
118
+
119
+ # Routes a library draws into the app for its own use. Turbo's
120
+ # historical-location routes and the PWA manifest are GET, static, and
121
+ # one segment, so without this a fresh app's tabs were three Turbo
122
+ # internals.
123
+ LIBRARY_CONTROLLERS = %r{\A(turbo|rails|active_storage|action_mailbox|action_cable|action_text)/}
124
+
125
+ # Static, single-segment GET routes, root first, in the order the routes
126
+ # file declares them. Anything with a parameter, a glob, or a second
127
+ # segment is a page you reach from a tab, not a tab.
128
+ def routable_paths
129
+ paths = Rails.application.routes.routes.filter_map do |route|
130
+ next if route.respond_to?(:internal) && route.internal
131
+ next unless route.verb.to_s.match?(/\bGET\b/)
132
+ next if route.defaults[:controller].to_s.match?(LIBRARY_CONTROLLERS)
133
+
134
+ path = route.path.spec.to_s.sub(/\(\.:format\)\z/, "")
135
+ next unless path.start_with?("/")
136
+ next if path.match?(/[:*]/)
137
+
138
+ segments = path.delete_prefix("/").split("/")
139
+ next if segments.size > 1
140
+ next if SKIPPED_SEGMENTS.include?(segments.first)
141
+
142
+ path
143
+ end.uniq
144
+
145
+ paths.partition { |path| path == "/" }.flatten
146
+ rescue StandardError
147
+ []
148
+ end
149
+
150
+ def title_for(segment)
151
+ return "Home" if segment.empty?
152
+
153
+ segment.tr("_-", " ").split.map(&:capitalize).join(" ")
61
154
  end
62
155
  end
63
156
  end
@@ -93,12 +93,11 @@ appearance:
93
93
  tabs:
94
94
  # Icons are SF Symbols on iOS and Material Icons on Android.
95
95
  # https://rubynative.com/docs/icons
96
- - title: Home
97
- path: /
98
- icon: house
99
- - title: Profile
100
- path: /profile
101
- icon: person
96
+ <% @tabs.each do |tab| -%>
97
+ - title: <%= tab[:title] %>
98
+ path: <%= tab[:path] %>
99
+ icon: <%= tab[:icon] %>
100
+ <% end -%>
102
101
 
103
102
  # Per-tab options (add to any tab above):
104
103
  #
@@ -145,7 +144,7 @@ tabs:
145
144
  # team_id: ABCD123456
146
145
  # android:
147
146
  # package: com.example.myapp
148
- # # Play Console -> Setup -> App integrity -> App signing key certificate.
147
+ # # Google Play Console -> Setup -> App integrity -> App signing key certificate.
149
148
  # cert_fingerprint: "AA:BB:CC:..."
150
149
 
151
150
  # Link only these path prefixes instead of the whole domain. Links outside
@@ -0,0 +1,76 @@
1
+ require "ruby_native/cli/command"
2
+
3
+ module RubyNative
4
+ class CLI
5
+ # The App Store Connect key. Only a person can mint it, in App Store
6
+ # Connect; this is how they hand it over without opening the dashboard.
7
+ # The file is read here and sent once; nothing reads it back, and the
8
+ # MCP tool takes a path for the same reason, so an agent never holds the
9
+ # key itself.
10
+ class Apple < Command
11
+ POLL_INTERVAL = 2
12
+ MAX_WAIT = 90
13
+
14
+ USAGE = "Usage: ruby_native apple key <path/to/AuthKey.p8> --key-id=KEY --issuer-id=ISSUER --team-id=TEAM".freeze
15
+
16
+ def run
17
+ ensure_authenticated!
18
+ app_id = app_id!
19
+
20
+ unless positional.first == "key" && positional[1] && flag("key-id") && flag("issuer-id") && flag("team-id")
21
+ puts USAGE
22
+ exit 1
23
+ end
24
+
25
+ finish(self.class.set_key(app_id, path: positional[1], key_id: flag("key-id"), issuer_id: flag("issuer-id"), team_id: flag("team-id")))
26
+ end
27
+
28
+ def self.set_key(app_id, path:, key_id:, issuer_id:, team_id:, wait: MAX_WAIT)
29
+ return Outcome.new(ok: false, text: "No file at #{path}.", data: {}) unless File.file?(path)
30
+
31
+ result = Mcp::Platform.patch("/api/v1/apps/#{app_id}/credentials",
32
+ { team_id: team_id, asc_key_id: key_id, asc_issuer_id: issuer_id, asc_key: File.read(path).strip })
33
+ return failure(result) unless result.ok?
34
+
35
+ settle(app_id, wait: wait)
36
+ end
37
+
38
+ # Validation runs against Apple in the background. Wait for it here,
39
+ # because "sent" is not the answer anybody wants; "Apple took it" is.
40
+ def self.settle(app_id, wait:)
41
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + wait
42
+
43
+ loop do
44
+ result = Mcp::Platform.get("/api/v1/apps/#{app_id}/credentials")
45
+ return failure(result) unless result.ok?
46
+
47
+ state = result.value.is_a?(Hash) ? result.value : {}
48
+ case state["state"]
49
+ when "passed"
50
+ return success("Key verified: a Team key with the Admin role, and your agreements are signed. Ruby Native can sign and ship this app.", state)
51
+ when "failed"
52
+ return Outcome.new(ok: false, text: "Apple rejected the key: #{state["error"]}", data: state)
53
+ end
54
+
55
+ if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
56
+ return success("Still checking the key with Apple. Read `ruby_native status` in a minute; it says when the key checks out.", state)
57
+ end
58
+
59
+ pause
60
+ end
61
+ end
62
+
63
+ # Two seconds between reads, and none in a test.
64
+ def self.pause
65
+ sleep POLL_INTERVAL unless @sleepless
66
+ end
67
+
68
+ def self.stub_const_sleep
69
+ @sleepless = true
70
+ yield
71
+ ensure
72
+ @sleepless = false
73
+ end
74
+ end
75
+ end
76
+ end
@@ -30,7 +30,43 @@ module RubyNative
30
30
 
31
31
  check = new([], paths: paths)
32
32
 
33
- check.send(:template_files).flat_map { |file| check.send(:check_file, file) }
33
+ template_files(paths: paths).flat_map { |file| check.send(:check_file, file) }
34
+ end
35
+
36
+ # The templates a run covers. Public so a caller can count them without
37
+ # running the checks, and so there is one definition of what gets scanned.
38
+ def self.template_files(paths: DEFAULT_PATHS)
39
+ paths.flat_map { |path| Dir.glob(File.join(path, "**", "*.html.erb")) }.sort
40
+ end
41
+
42
+ # The report `run` prints, as lines, so another front end renders
43
+ # offenses the way the CLI does instead of inventing a second format.
44
+ def self.report_lines(files, offenses)
45
+ lines = []
46
+
47
+ offenses.group_by(&:file).sort.each do |file, file_offenses|
48
+ lines << file
49
+ file_offenses.sort_by(&:line).each do |offense|
50
+ location = "#{offense.line}:".ljust(5)
51
+ marker = offense.severity.to_s.ljust(8)
52
+ lines << " #{location}#{marker}#{offense.message}"
53
+ end
54
+ lines << ""
55
+ end
56
+
57
+ lines << summary_line(files, offenses)
58
+ end
59
+
60
+ def self.summary_line(files, offenses)
61
+ errors, warnings = offenses.partition { |offense| offense.severity == :error }
62
+ summary = "Checked #{files.size} #{files.size == 1 ? "template" : "templates"}"
63
+
64
+ if offenses.empty?
65
+ "#{summary}. No problems found."
66
+ else
67
+ "#{summary}: #{errors.size} #{errors.size == 1 ? "error" : "errors"}, " \
68
+ "#{warnings.size} #{warnings.size == 1 ? "warning" : "warnings"}."
69
+ end
34
70
  end
35
71
 
36
72
  def self.herb_available?
@@ -79,7 +115,7 @@ module RubyNative
79
115
  end
80
116
 
81
117
  def template_files
82
- @paths.flat_map { |path| Dir.glob(File.join(path, "**", "*.html.erb")) }.sort
118
+ self.class.template_files(paths: @paths)
83
119
  end
84
120
 
85
121
  # --- Per-file checks ---
@@ -158,7 +194,11 @@ module RubyNative
158
194
  end
159
195
 
160
196
  used = signals_in(files)
161
- PLATFORMS.flat_map { |platform| deployed_offenses_for(platform, app_id, used) }
197
+ offenses = PLATFORMS.flat_map { |platform| deployed_offenses_for(platform, app_id, used) }
198
+ if (scan = Permissions.scan_without_camera(app_id))
199
+ offenses << Offense.new(file: scan[0], line: scan[1], severity: :warning, message: Permissions.scan_without_camera_message(*scan).sub(/\A\S+ /, ""))
200
+ end
201
+ offenses
162
202
  end
163
203
 
164
204
  def deployed_offenses_for(platform, app_id, used)
@@ -222,25 +262,7 @@ module RubyNative
222
262
  # --- Reporting ---
223
263
 
224
264
  def report(files, offenses)
225
- errors, warnings = offenses.partition { |offense| offense.severity == :error }
226
-
227
- offenses.group_by(&:file).sort.each do |file, file_offenses|
228
- puts file
229
- file_offenses.sort_by(&:line).each do |offense|
230
- location = "#{offense.line}:".ljust(5)
231
- marker = offense.severity.to_s.ljust(8)
232
- puts " #{location}#{marker}#{offense.message}"
233
- end
234
- puts ""
235
- end
236
-
237
- summary = "Checked #{files.size} #{files.size == 1 ? "template" : "templates"}"
238
- if offenses.empty?
239
- puts "#{summary}. No problems found."
240
- else
241
- puts "#{summary}: #{errors.size} #{errors.size == 1 ? "error" : "errors"}, " \
242
- "#{warnings.size} #{warnings.size == 1 ? "warning" : "warnings"}."
243
- end
265
+ puts self.class.report_lines(files, offenses)
244
266
  end
245
267
 
246
268
  def parse_paths(argv)
@@ -0,0 +1,94 @@
1
+ require "ruby_native/cli/credentials"
2
+ require "ruby_native/cli/mcp/platform"
3
+ require "ruby_native/cli/status"
4
+
5
+ module RubyNative
6
+ class CLI
7
+ # What every command that talks to rubynative.com about one app shares:
8
+ # finding the app, being logged in, and turning a refusal into something a
9
+ # person can act on. The MCP server calls the same `perform` methods, so a
10
+ # verb is a CLI command first and a tool second, and both say one thing.
11
+ class Command
12
+ Outcome = Struct.new(:ok, :text, :data, keyword_init: true)
13
+
14
+ def initialize(argv = [])
15
+ @argv = argv
16
+ end
17
+
18
+ private
19
+
20
+ def app_id!
21
+ flag = @argv.find { |arg| arg.start_with?("--app=") }
22
+ app_id = flag ? flag.split("=", 2).last : Mcp::Platform.app_id
23
+ return app_id unless app_id.to_s.strip.empty?
24
+
25
+ puts Mcp::Platform::NO_APP_ID
26
+ exit 1
27
+ end
28
+
29
+ def ensure_authenticated!
30
+ return if Credentials.token
31
+
32
+ puts Mcp::Platform::NOT_LOGGED_IN
33
+ exit 1
34
+ end
35
+
36
+ def finish(outcome)
37
+ puts outcome.text
38
+ exit 1 unless outcome.ok
39
+ end
40
+
41
+ PLATFORM_LABELS = { "ios" => "iOS", "android" => "Android" }.freeze
42
+
43
+ # Every store the app has started, unless --ios or --android names one.
44
+ # A server too old to say, or an app with neither started, means iOS.
45
+ def platforms!(app_id)
46
+ return [ "android" ] if @argv.include?("--android")
47
+ return [ "ios" ] if @argv.include?("--ios")
48
+
49
+ status = Status.fetch(app_id)
50
+ started = status.ok? ? Array(status.value["platforms"]) & PLATFORM_LABELS.keys : []
51
+ started.empty? ? [ "ios" ] : started
52
+ end
53
+
54
+ # One store prints as it always has; two get a heading each, and the
55
+ # command fails if either did.
56
+ def finish_each(platforms)
57
+ return finish(yield(platforms.first)) if platforms.one?
58
+
59
+ outcomes = platforms.map { |platform| [ platform, yield(platform) ] }
60
+ puts outcomes.map { |platform, outcome| "#{PLATFORM_LABELS[platform]}:\n#{outcome.text}" }.join("\n\n")
61
+ exit 1 unless outcomes.all? { |_, outcome| outcome.ok }
62
+ end
63
+
64
+ def positional
65
+ @argv.reject { |arg| arg.start_with?("--") }
66
+ end
67
+
68
+ def flag(name)
69
+ value = @argv.find { |arg| arg.start_with?("--#{name}=") }
70
+ value&.split("=", 2)&.last
71
+ end
72
+
73
+ def self.failure(result)
74
+ lines = [ result.error ]
75
+ result.field_errors.each do |field, messages|
76
+ Array(messages).each do |message|
77
+ next if result.error.to_s.downcase.include?(message.to_s.downcase)
78
+
79
+ lines << " #{field} #{message}"
80
+ end
81
+ end
82
+ if result.blocked_by.any?
83
+ lines << ""
84
+ lines.concat(Status.blocker_lines(result.blocked_by))
85
+ end
86
+ Outcome.new(ok: false, text: lines.join("\n").strip, data: { "error" => result.error, "blocked_by" => result.blocked_by, "errors" => result.field_errors })
87
+ end
88
+
89
+ def self.success(text, data)
90
+ Outcome.new(ok: true, text: text, data: data)
91
+ end
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,36 @@
1
+ require "ruby_native/cli/command"
2
+
3
+ module RubyNative
4
+ class CLI
5
+ # The reviewer's sign-in, staged with rubynative.com and written to App
6
+ # Store Connect when the listing is published. It stays out of
7
+ # config/ruby_native_listing.yml on purpose: that file is committed.
8
+ class DemoAccount < Command
9
+ def run
10
+ ensure_authenticated!
11
+ app_id = app_id!
12
+
13
+ unless positional.first == "set" && flag("name")
14
+ puts "Usage: ruby_native demo_account set --name=EMAIL --password=SECRET [--notes=\"...\"] [--not-required]"
15
+ exit 1
16
+ end
17
+
18
+ finish(self.class.set(app_id, name: flag("name"), password: flag("password"), notes: flag("notes"), required: !@argv.include?("--not-required")))
19
+ end
20
+
21
+ def self.set(app_id, name:, password: nil, notes: nil, required: true)
22
+ demo = { name: name, required: required }
23
+ demo[:password] = password unless password.nil?
24
+ demo[:notes] = notes unless notes.nil?
25
+
26
+ result = Mcp::Platform.patch("/api/v1/apps/#{app_id}/listing", { demo_account: demo })
27
+ return failure(result) unless result.ok?
28
+
29
+ account = result.value["demo_account"] || {}
30
+ text = "Demo account #{account["name"]}#{account["password_set"] ? "" : " (no password set)"} saved. " \
31
+ "It reaches App Store Connect when the listing is published."
32
+ success(text, result.value)
33
+ end
34
+ end
35
+ end
36
+ end
@@ -4,6 +4,8 @@ require "uri"
4
4
  require "openssl"
5
5
  require "ruby_native/cli/check"
6
6
  require "ruby_native/cli/credentials"
7
+ require "ruby_native/cli/mcp/platform"
8
+ require "ruby_native/cli/status"
7
9
  require "ruby_native/version"
8
10
 
9
11
  module RubyNative
@@ -34,6 +36,8 @@ module RubyNative
34
36
  @if_needed = argv.include?("--if-needed")
35
37
  @skip_check = argv.include?("--skip-check")
36
38
  @platform = parse_platform(argv)
39
+ @app_flag = argv.find { |arg| arg.start_with?("--app=") }&.split("=", 2)&.last
40
+ @version = argv.find { |arg| arg.start_with?("--version=") }&.split("=", 2)&.last
37
41
  end
38
42
 
39
43
  def run
@@ -46,7 +50,10 @@ module RubyNative
46
50
  return
47
51
  end
48
52
 
49
- check_signals! unless @skip_check
53
+ unless @skip_check
54
+ check_signals!
55
+ warn_scan_without_camera(app_id)
56
+ end
50
57
 
51
58
  build = trigger_build(app_id)
52
59
  return if @if_needed
@@ -83,6 +90,17 @@ module RubyNative
83
90
  exit 1
84
91
  end
85
92
 
93
+ # A warning, not a stop: the build is fine, one button in it isn't.
94
+ def warn_scan_without_camera(app_id)
95
+ file, line = Permissions.scan_without_camera(app_id)
96
+ return unless file
97
+
98
+ puts "Heads up: #{Permissions.scan_without_camera_message(file, line)}"
99
+ puts
100
+ rescue StandardError
101
+ nil
102
+ end
103
+
86
104
  def ensure_authenticated!
87
105
  return if Credentials.token
88
106
 
@@ -145,7 +163,7 @@ module RubyNative
145
163
  end
146
164
 
147
165
  def resolve_app_id!
148
- app_id = @config.dig(:ruby_native, :app_id)
166
+ app_id = (@app_flag unless @app_flag.to_s.strip.empty?) || @config.dig(:ruby_native, :app_id)
149
167
  app_id = link_app unless app_id
150
168
  unless app_id
151
169
  puts "No app selected. Run `ruby_native deploy` again."
@@ -244,6 +262,7 @@ module RubyNative
244
262
  when Net::HTTPUnprocessableEntity
245
263
  data = parse_json(response)
246
264
  puts "Cannot build: #{data&.dig("error") || "the server rejected the request (422)."}"
265
+ print_blockers(data)
247
266
  exit 1
248
267
  when Net::HTTPNotFound
249
268
  puts "The app linked in config/ruby_native.yml was not found on your account."
@@ -419,6 +438,16 @@ module RubyNative
419
438
 
420
439
  # Server-provided warnings (a lapsed payment, say) reach customers with
421
440
  # no gem release. Deduped, since every status poll repeats the string.
441
+ # A refusal names what is in the way and who clears it, the same list the
442
+ # dashboard shows. An older server sends only the sentence.
443
+ def print_blockers(data)
444
+ blockers = data.is_a?(Hash) ? data["blocked_by"] : nil
445
+ return unless blockers.is_a?(Array) && blockers.any?
446
+
447
+ puts ""
448
+ puts Status.blocker_lines(blockers)
449
+ end
450
+
422
451
  def print_notice(data)
423
452
  notice = data["notice"]
424
453
  return unless notice.is_a?(String) && !notice.strip.empty?
@@ -483,6 +512,7 @@ module RubyNative
483
512
  def build_payload
484
513
  payload = { gem_version: RubyNative::VERSION }
485
514
  payload[:platform] = @platform unless @platform == "ios"
515
+ payload[:version] = @version if @version && !@version.empty?
486
516
  payload
487
517
  end
488
518
 
@@ -538,16 +568,7 @@ module RubyNative
538
568
  end
539
569
 
540
570
  def write_app_id_to_config(app_id)
541
- raw = File.read(CONFIG_PATH)
542
-
543
- if raw.match?(/^ruby_native:/)
544
- raw = raw.gsub(/^(ruby_native:\s*\n)/, "\\1 app_id: #{app_id}\n")
545
- else
546
- raw = raw.rstrip + "\n\nruby_native:\n app_id: #{app_id}\n"
547
- end
548
-
549
- File.write(CONFIG_PATH, raw)
550
-
571
+ Mcp::Platform.write_app_id(app_id, CONFIG_PATH)
551
572
  @config = read_config
552
573
  end
553
574