ruby_native 0.17.2 → 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/config/signals.yml +28 -0
- 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/signal_collector.rb +109 -7
- 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/signals.rb +39 -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.
|
data/config/signals.yml
CHANGED
|
@@ -7,6 +7,15 @@
|
|
|
7
7
|
#
|
|
8
8
|
# `owner: shell` marks a signal the app writes into the page rather than one
|
|
9
9
|
# your views emit.
|
|
10
|
+
#
|
|
11
|
+
# `helper` names the helper that can emit the signal; `always: true` means every
|
|
12
|
+
# call to that helper emits it. Without `always`, `check` cannot count a helper
|
|
13
|
+
# call against the signal: native_fab_tag emits data-native-color only when you
|
|
14
|
+
# pass `color:`, so counting it would report markup the view never renders.
|
|
15
|
+
#
|
|
16
|
+
# `renders_when` covers the helpers that can render nothing at all, naming the
|
|
17
|
+
# keyword values they need in order to emit anything. A call that passes
|
|
18
|
+
# something else, or something only known at request time, is left uncounted.
|
|
10
19
|
---
|
|
11
20
|
signals:
|
|
12
21
|
data-native-action:
|
|
@@ -19,6 +28,7 @@ signals:
|
|
|
19
28
|
data-native-badge:
|
|
20
29
|
since: "0.3.2"
|
|
21
30
|
helper: native_badge_tag
|
|
31
|
+
always: true
|
|
22
32
|
singleton: true
|
|
23
33
|
data-native-badge-home:
|
|
24
34
|
since: "0.3.2"
|
|
@@ -42,50 +52,63 @@ signals:
|
|
|
42
52
|
data-native-fab:
|
|
43
53
|
since: "0.8.0"
|
|
44
54
|
helper: native_fab_tag
|
|
55
|
+
always: true
|
|
45
56
|
singleton: true
|
|
46
57
|
data-native-form:
|
|
47
58
|
since: "0.1.2"
|
|
48
59
|
helper: native_form_tag
|
|
60
|
+
always: true
|
|
49
61
|
singleton: true
|
|
50
62
|
data-native-haptic:
|
|
51
63
|
since: "0.2.7"
|
|
52
64
|
helper: native_haptic_data
|
|
65
|
+
always: true
|
|
53
66
|
data-native-href:
|
|
54
67
|
since: "0.4.0"
|
|
55
68
|
helper: native_fab_tag
|
|
56
69
|
data-native-icon:
|
|
57
70
|
since: "0.4.0"
|
|
58
71
|
helper: native_fab_tag
|
|
72
|
+
always: true
|
|
59
73
|
data-native-identity:
|
|
60
74
|
since: "0.12.0"
|
|
61
75
|
helper: native_identity_tag
|
|
76
|
+
always: true
|
|
62
77
|
singleton: true
|
|
63
78
|
data-native-keyboard-toolbar:
|
|
64
79
|
since: "0.15.0"
|
|
65
80
|
helper: native_keyboard_tag
|
|
81
|
+
always: true
|
|
82
|
+
renders_when:
|
|
83
|
+
toolbar: false
|
|
66
84
|
singleton: true
|
|
67
85
|
data-native-menu:
|
|
68
86
|
since: "0.14.0"
|
|
69
87
|
helper: native_menu_tag
|
|
88
|
+
always: true
|
|
70
89
|
data-native-menu-item:
|
|
71
90
|
since: "0.4.0"
|
|
72
91
|
data-native-navbar:
|
|
73
92
|
since: "0.4.0"
|
|
74
93
|
helper: native_navbar_tag
|
|
94
|
+
always: true
|
|
75
95
|
singleton: true
|
|
76
96
|
data-native-overscroll-bottom:
|
|
77
97
|
since: "0.5.0"
|
|
78
98
|
helper: native_overscroll_tag
|
|
99
|
+
always: true
|
|
79
100
|
singleton: true
|
|
80
101
|
data-native-overscroll-top:
|
|
81
102
|
since: "0.5.0"
|
|
82
103
|
helper: native_overscroll_tag
|
|
104
|
+
always: true
|
|
83
105
|
singleton: true
|
|
84
106
|
data-native-position:
|
|
85
107
|
since: "0.4.0"
|
|
86
108
|
data-native-presentation:
|
|
87
109
|
since: "0.11.2"
|
|
88
110
|
helper: native_presentation_tag
|
|
111
|
+
always: true
|
|
89
112
|
singleton: true
|
|
90
113
|
data-native-price:
|
|
91
114
|
since: "0.5.3"
|
|
@@ -97,12 +120,14 @@ signals:
|
|
|
97
120
|
data-native-push:
|
|
98
121
|
since: "0.1.2"
|
|
99
122
|
helper: native_push_tag
|
|
123
|
+
always: true
|
|
100
124
|
singleton: true
|
|
101
125
|
data-native-restore:
|
|
102
126
|
since: "0.5.3"
|
|
103
127
|
data-native-review:
|
|
104
128
|
since: "0.9.3"
|
|
105
129
|
helper: native_review_tag
|
|
130
|
+
always: true
|
|
106
131
|
data-native-segment:
|
|
107
132
|
since: "0.10.12"
|
|
108
133
|
data-native-selected:
|
|
@@ -120,6 +145,9 @@ signals:
|
|
|
120
145
|
data-native-tabs:
|
|
121
146
|
since: "0.1.2"
|
|
122
147
|
helper: native_tabs_tag
|
|
148
|
+
always: true
|
|
149
|
+
renders_when:
|
|
150
|
+
enabled: true
|
|
123
151
|
singleton: true
|
|
124
152
|
data-native-title:
|
|
125
153
|
since: "0.4.0"
|
|
@@ -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
|
|
@@ -1,29 +1,131 @@
|
|
|
1
|
+
require "prism"
|
|
2
|
+
|
|
1
3
|
module RubyNative
|
|
2
4
|
class CLI
|
|
3
5
|
class Check
|
|
4
|
-
# Walks a parsed template and records every data-native-*
|
|
5
|
-
#
|
|
6
|
-
#
|
|
6
|
+
# Walks a parsed template and records every data-native-* signal with the
|
|
7
|
+
# lines it appears on, whether the view writes the attribute by hand or
|
|
8
|
+
# calls the helper that emits it. Attribute names built out of ERB cannot
|
|
9
|
+
# be resolved statically and are skipped rather than guessed at.
|
|
7
10
|
#
|
|
8
11
|
# Subclasses Herb::Visitor, so this file is only loaded once `herb` is
|
|
9
12
|
# known to be present.
|
|
10
13
|
class SignalCollector < Herb::Visitor
|
|
11
|
-
|
|
14
|
+
COMMENT_OPENING = "<%#".freeze
|
|
12
15
|
|
|
13
16
|
def initialize
|
|
14
17
|
@signals = {}
|
|
15
18
|
super
|
|
16
19
|
end
|
|
17
20
|
|
|
21
|
+
# Attributes and helper calls arrive from different visits, so the lines
|
|
22
|
+
# for one signal are not collected in document order.
|
|
23
|
+
def signals
|
|
24
|
+
@signals.transform_values(&:sort)
|
|
25
|
+
end
|
|
26
|
+
|
|
18
27
|
def visit_html_attribute_name_node(node)
|
|
19
28
|
name = node.children.filter_map { |child| child.content if child.respond_to?(:content) }.join
|
|
20
29
|
|
|
21
|
-
if name.start_with?("data-native-")
|
|
22
|
-
|
|
23
|
-
|
|
30
|
+
record(name, node.location.start.line) if name.start_with?("data-native-")
|
|
31
|
+
|
|
32
|
+
super
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def visit_erb_content_node(node)
|
|
36
|
+
record_helper_calls(node)
|
|
37
|
+
|
|
38
|
+
super
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# `<%= native_navbar_tag "Orders" do |navbar| %>` opens a block, so its
|
|
42
|
+
# Ruby arrives here instead, without the `end` that would let it parse
|
|
43
|
+
# on its own.
|
|
44
|
+
def visit_erb_block_node(node)
|
|
45
|
+
record_helper_calls(node, suffix: "\nend")
|
|
24
46
|
|
|
25
47
|
super
|
|
26
48
|
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def record_helper_calls(node, suffix: "")
|
|
53
|
+
return if node.tag_opening&.value == COMMENT_OPENING
|
|
54
|
+
|
|
55
|
+
source = node.content&.value
|
|
56
|
+
return if source.nil?
|
|
57
|
+
|
|
58
|
+
# Ruby that does not parse is the app's own syntax error, which Rails
|
|
59
|
+
# raises on render and `herb lint` reports properly. Staying quiet
|
|
60
|
+
# here keeps `check` to the one job it claims.
|
|
61
|
+
result = Prism.parse(source + suffix)
|
|
62
|
+
return unless result.success?
|
|
63
|
+
|
|
64
|
+
line = node.location.start.line
|
|
65
|
+
calls(result.value).each do |call|
|
|
66
|
+
helper = call.name.to_s
|
|
67
|
+
signals = RubyNative::Signals.signals_for_helper(helper)
|
|
68
|
+
next if signals.empty? || !renders?(call, helper)
|
|
69
|
+
|
|
70
|
+
signals.each { |signal| record(signal, line) }
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# A helper that can render nothing only counts when its arguments prove
|
|
75
|
+
# it renders. `native_tabs_tag(enabled: @show)` decides at request time,
|
|
76
|
+
# so it is skipped rather than guessed at, the same as an attribute name
|
|
77
|
+
# built out of ERB.
|
|
78
|
+
def renders?(call, helper)
|
|
79
|
+
condition = RubyNative::Signals.render_condition(helper)
|
|
80
|
+
return true unless condition
|
|
81
|
+
|
|
82
|
+
condition.all? do |keyword, required|
|
|
83
|
+
given = keyword_value(call, keyword)
|
|
84
|
+
|
|
85
|
+
given.nil? ? true : given == required
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# nil when the keyword is absent, which leaves the helper's own default
|
|
90
|
+
# in charge. :unknown for anything that is not a literal, so it never
|
|
91
|
+
# equals what the condition requires.
|
|
92
|
+
def keyword_value(call, keyword)
|
|
93
|
+
pairs = call.arguments&.arguments&.grep(Prism::KeywordHashNode)&.flat_map(&:elements)
|
|
94
|
+
return nil if pairs.nil?
|
|
95
|
+
|
|
96
|
+
assoc = pairs.find do |pair|
|
|
97
|
+
pair.is_a?(Prism::AssocNode) && pair.key.is_a?(Prism::SymbolNode) &&
|
|
98
|
+
pair.key.unescaped == keyword.to_s
|
|
99
|
+
end
|
|
100
|
+
return nil unless assoc
|
|
101
|
+
|
|
102
|
+
case assoc.value
|
|
103
|
+
when Prism::TrueNode then true
|
|
104
|
+
when Prism::FalseNode then false
|
|
105
|
+
else :unknown
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# A literal in an argument is not a call, so `placeholder: "native_tabs_tag"`
|
|
110
|
+
# reads as the string it is. That is the whole reason for parsing rather
|
|
111
|
+
# than scanning the source for helper names.
|
|
112
|
+
def calls(root)
|
|
113
|
+
found = []
|
|
114
|
+
stack = [root]
|
|
115
|
+
|
|
116
|
+
while (node = stack.pop)
|
|
117
|
+
next unless node.is_a?(Prism::Node)
|
|
118
|
+
|
|
119
|
+
found << node if node.is_a?(Prism::CallNode)
|
|
120
|
+
stack.concat(node.compact_child_nodes)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
found
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def record(name, line)
|
|
127
|
+
(@signals[name] ||= []) << line
|
|
128
|
+
end
|
|
27
129
|
end
|
|
28
130
|
end
|
|
29
131
|
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)
|