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 +4 -4
- data/README.md +53 -1
- data/lib/generators/ruby_native/install_generator.rb +106 -13
- data/lib/generators/ruby_native/templates/ruby_native.yml +6 -7
- data/lib/ruby_native/cli/apple.rb +76 -0
- data/lib/ruby_native/cli/check.rb +44 -22
- data/lib/ruby_native/cli/command.rb +94 -0
- data/lib/ruby_native/cli/demo_account.rb +36 -0
- data/lib/ruby_native/cli/deploy.rb +33 -12
- data/lib/ruby_native/cli/icon.rb +29 -0
- data/lib/ruby_native/cli/link.rb +40 -0
- data/lib/ruby_native/cli/listing.rb +236 -0
- data/lib/ruby_native/cli/mcp/config_validator.rb +546 -0
- data/lib/ruby_native/cli/mcp/install.rb +54 -0
- data/lib/ruby_native/cli/mcp/platform.rb +172 -0
- data/lib/ruby_native/cli/mcp/prompts.rb +161 -0
- data/lib/ruby_native/cli/mcp.rb +1026 -0
- data/lib/ruby_native/cli/permissions.rb +80 -0
- data/lib/ruby_native/cli/preview.rb +1 -1
- data/lib/ruby_native/cli/screenshots.rb +111 -0
- data/lib/ruby_native/cli/ship.rb +16 -0
- data/lib/ruby_native/cli/status.rb +196 -0
- data/lib/ruby_native/cli/submit.rb +91 -0
- data/lib/ruby_native/cli/testers.rb +54 -0
- data/lib/ruby_native/cli.rb +70 -4
- data/lib/ruby_native/icons/material_symbols.txt +4229 -0
- data/lib/ruby_native/icons/sf_symbols.tsv +9237 -0
- data/lib/ruby_native/version.rb +1 -1
- data/lib/ruby_native.rb +9 -0
- metadata +20 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6943c548d9910886fefa4a121b493c1cdc40b06ab34059c4deac3fac3ac86018
|
|
4
|
+
data.tar.gz: def52388edde0250a098f4ffa4c02e0fe691d7aa0383b1049c2ac1c178be559b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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
|
|
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 "
|
|
47
|
-
say "
|
|
48
|
-
say "
|
|
49
|
-
say " 3. Add
|
|
50
|
-
say "
|
|
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 "
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|