mysigner 0.3.0 → 0.3.2
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/CHANGELOG.md +31 -0
- data/Gemfile.lock +2 -2
- data/README.md +18 -1
- data/exe/mysigner +36 -1
- data/lib/mysigner/build/android_executor.rb +101 -29
- data/lib/mysigner/build/android_parser.rb +2 -2
- data/lib/mysigner/build/detector.rb +122 -53
- data/lib/mysigner/cli/auth_commands.rb +110 -7
- data/lib/mysigner/cli/build_commands.rb +24 -9
- data/lib/mysigner/cli/concerns/error_handlers.rb +9 -12
- data/lib/mysigner/cli/concerns/helpers.rb +37 -0
- data/lib/mysigner/cli/diagnostic_commands.rb +67 -29
- data/lib/mysigner/cli/resource_commands.rb +79 -27
- data/lib/mysigner/cli/validate_commands.rb +16 -1
- data/lib/mysigner/config.rb +23 -3
- data/lib/mysigner/signing/gradle_signing_injector.rb +14 -0
- data/lib/mysigner/version.rb +1 -1
- data/mysigner.gemspec +13 -8
- metadata +11 -10
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f948f1bd3659c76f18ab35990ab196ab301f39e4de7a42598fde06b1e391a3c9
|
|
4
|
+
data.tar.gz: acb32eee7a1c8c92f353fcfdef503a5f9392aa49d61ab0a5c1e4cc8781e22855
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 0aa6736246d956c406051b2f3fa816c2820d0f64ed5edbde79f46e2704279661eb524e4b0e36e483a65a3114e0073228aa4a0192a3753dfdf250f98aa893dc57
|
|
7
|
+
data.tar.gz: 71ba8530c1f06736d3968ad230f62846d891a6848836a4822693dbdbb5845a39adba4b665b55ea160e96b335c1839ca8d3d0146626bdec5911f9a1a3f72fbdb4
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,25 @@ All notable changes to My Signer CLI will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [0.3.2] - 2026-06-25
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
- Robustness: an undecryptable stored token no longer crashes nearly every command with a raw backtrace — it degrades to a clear "re-login" message. `config show` now works.
|
|
12
|
+
- `doctor --platform android` no longer reports "All checks passed" when the JDK/Android SDK are missing (they're now reported as issues).
|
|
13
|
+
- `doctor` and `validate` no longer run a tree-mutating `npx expo prebuild` from a read-only diagnostic; `validate` no longer crashes on a project with no native iOS project.
|
|
14
|
+
- Android versionCode auto-increment now actually takes effect — it's injected via the Gradle init script (a bare `-PversionCode` is ignored by stock `build.gradle`), gated to the application module so library submodules don't break.
|
|
15
|
+
- Expo version bumps back up `android/` and restore it on failure (no data loss).
|
|
16
|
+
- `onboard --local-only` no longer crashes with `uninitialized constant StringIO`.
|
|
17
|
+
- iOS-only commands (`ship testflight/appstore`, `build`, `export`, `upload`) now fail with a clear "requires macOS" message on Linux/Windows instead of a raw backtrace; `doctor` skips iOS checks on non-macOS instead of showing red Xcode issues.
|
|
18
|
+
- Dropped the `-q` flag that hid Gradle output; AAB selected by newest mtime; camelCase build variants fixed; Linux/WSL JDK + Android SDK auto-detection.
|
|
19
|
+
- Security: faraday 2.14.2 → 2.14.3 (CVE-2026-54297).
|
|
20
|
+
|
|
21
|
+
### Changed
|
|
22
|
+
- Local-only mode (no My Signer account) is now surfaced at the front door: post-install message, `onboard` (interactive mode choice), `login`, and the README Quick Start.
|
|
23
|
+
- `ship` help explains Android tracks in plain words (incl. "production = PUBLIC — goes live to everyone") and what an AAB is; `--local-only` credential flags are documented.
|
|
24
|
+
- `onboard` now also sets up Google Play (vault) and an Android signing keystore (local-only).
|
|
25
|
+
- Connection-error guidance no longer leaks Rails/server internals to CLI users; Android build failures include a short triage block.
|
|
26
|
+
|
|
8
27
|
## [0.1.0] - 2026-01-23
|
|
9
28
|
|
|
10
29
|
### Added
|
|
@@ -97,6 +116,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
97
116
|
|
|
98
117
|
---
|
|
99
118
|
|
|
119
|
+
## [0.3.1] - 2026-05-29
|
|
120
|
+
|
|
121
|
+
### Fixed
|
|
122
|
+
- `mysigner android add PACKAGE_NAME` crashed with `NoMethodError: undefined method 'post' for nil` in local-only mode. The `android` dispatcher now gates `init` / `add` / `list` (which manage MySigner-registered records) — only `android build` is purely local. `android list`'s banner now reads "android list" instead of "apps" (it had been showing the underlying apps-command name because list is implemented as an alias).
|
|
123
|
+
- `mysigner status` reported `Source: MYSIGNER_LOCAL_ONLY env var` when `MYSIGNER_LOCAL_ONLY=0` was set in the environment AND the config file had `local_only: true`. The env value "0" is falsy per the cascade's truthy parser, so the source was actually the file. Status now uses the new `Mysigner::Config.local_only_from_env?` predicate (mirroring `local_only_from_file?`) for accurate attribution.
|
|
124
|
+
- README's local-only audit table classified `android init/add/build/list` as ✅ LOCAL across the board. Corrected: only `android build` is local; the other three are MySigner-only.
|
|
125
|
+
|
|
126
|
+
### Added
|
|
127
|
+
- `Mysigner::Config.local_only_from_env?` — public, mirrors `local_only_from_file?`. Symmetric source predicates so `mysigner status` can attribute the active source using the same truthy parser the cascade uses.
|
|
128
|
+
|
|
129
|
+
---
|
|
130
|
+
|
|
100
131
|
## [0.3.0] - 2026-05-28
|
|
101
132
|
|
|
102
133
|
### Added
|
data/Gemfile.lock
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
PATH
|
|
2
2
|
remote: .
|
|
3
3
|
specs:
|
|
4
|
-
mysigner (0.3.
|
|
4
|
+
mysigner (0.3.1)
|
|
5
5
|
base64 (~> 0.2)
|
|
6
6
|
faraday (~> 2.14)
|
|
7
7
|
faraday-retry (~> 2.2)
|
|
@@ -32,7 +32,7 @@ GEM
|
|
|
32
32
|
rexml
|
|
33
33
|
declarative (0.0.20)
|
|
34
34
|
diff-lcs (1.6.2)
|
|
35
|
-
faraday (2.14.
|
|
35
|
+
faraday (2.14.3)
|
|
36
36
|
faraday-net_http (>= 2.0, < 3.5)
|
|
37
37
|
json
|
|
38
38
|
logger
|
data/README.md
CHANGED
|
@@ -35,9 +35,23 @@ Mobile developers spend hours dealing with:
|
|
|
35
35
|
|
|
36
36
|
### Prerequisites
|
|
37
37
|
|
|
38
|
+
**Core (all platforms):**
|
|
38
39
|
- Ruby 3.2+ (recommended: 3.4.5)
|
|
40
|
+
|
|
41
|
+
**Vault mode** (default — server-orchestrated):
|
|
39
42
|
- [My Signer API](https://github.com/jurgenleka/my-signer) account and API token
|
|
40
43
|
|
|
44
|
+
**Local-only mode** (`--local-only` — no MySigner account or token required):
|
|
45
|
+
- Your own signing credentials: Apple `.p8` key / Google Play service-account JSON / Android keystore (set up via `mysigner --local-only onboard`, flags, or env vars)
|
|
46
|
+
|
|
47
|
+
**Building Android (either mode):**
|
|
48
|
+
- JDK 17+ (set `JAVA_HOME`)
|
|
49
|
+
- Android SDK (set `ANDROID_HOME`)
|
|
50
|
+
- React Native / Expo projects: Node.js ≥ 20.19.4 and `npm install` (the native `android/` is generated by `expo prebuild`)
|
|
51
|
+
|
|
52
|
+
**Building iOS (either mode):**
|
|
53
|
+
- macOS with Xcode + Command Line Tools
|
|
54
|
+
|
|
41
55
|
### Install via RubyGems
|
|
42
56
|
|
|
43
57
|
```bash
|
|
@@ -57,6 +71,8 @@ bundle exec rake install
|
|
|
57
71
|
|
|
58
72
|
## Quick Start
|
|
59
73
|
|
|
74
|
+
> **No My Signer account?** You don't need one. To sign & ship with your own Apple/Google credentials (nothing sent to a server), see the **Local-only mode** section further down and run `mysigner --local-only onboard`. The steps below are for the account-based ("vault") mode.
|
|
75
|
+
|
|
60
76
|
### 1. Get Your API Token
|
|
61
77
|
|
|
62
78
|
1. Log in to your My Signer dashboard
|
|
@@ -372,9 +388,10 @@ mysigner config set local-only false # permanent disable
|
|
|
372
388
|
| `signing configure` | ✅ |
|
|
373
389
|
| `doctor`, `status`, `validate` | ✅ (limited — no MySigner-side checks) |
|
|
374
390
|
| `certificate check`, `device detect` | ✅ |
|
|
375
|
-
| `android
|
|
391
|
+
| `android build` | ✅ |
|
|
376
392
|
| `config`, `config set`, `version`, `help`, `tree`, `logout` | ✅ |
|
|
377
393
|
| `login`, `switch`, `orgs`, `sync` | ❌ MySigner-only |
|
|
394
|
+
| `android init`, `android add`, `android list` | ❌ MySigner-only (register or list MySigner-side records) |
|
|
378
395
|
| `apps`, `devices`, `certificates`, `profiles`, `bundleid`, `app-group(s)`, `merchant-id(s)`, `keystore`, `gp-credential`, `release`, `tracks`, `track`, `submit`, `device add/update`, `certificate download`, `profile download/delete` | ❌ MySigner-only |
|
|
379
396
|
|
|
380
397
|
Server-only commands in local-only mode exit 2 with a one-line explanation and the override hint.
|
data/exe/mysigner
CHANGED
|
@@ -73,4 +73,39 @@ def rewrite_help_flag!(argv)
|
|
|
73
73
|
['help', first]
|
|
74
74
|
end
|
|
75
75
|
|
|
76
|
-
|
|
76
|
+
# Top-level safety net for an unreadable stored credential. When
|
|
77
|
+
# ~/.mysigner/config.yml holds a token that can't be decrypted (the per-machine
|
|
78
|
+
# encryption key was rotated/deleted, or the config was copied from another
|
|
79
|
+
# machine), Config#decrypt_token re-raises Mysigner::ConfigError. Without this
|
|
80
|
+
# rescue that error — plus the underlying OpenSSL::Cipher::CipherError cause —
|
|
81
|
+
# escapes as a raw Ruby backtrace on nearly every command. Convert it into one
|
|
82
|
+
# actionable line and exit 1; show the trace only under DEBUG.
|
|
83
|
+
begin
|
|
84
|
+
Mysigner::CLI.start(rewrite_help_flag!(hoist_leading_class_options!(ARGV)))
|
|
85
|
+
rescue Mysigner::ConfigError => e
|
|
86
|
+
warn "✗ #{e.message}"
|
|
87
|
+
# Only the decrypt-failure case maps to "re-login" — other ConfigErrors
|
|
88
|
+
# (Failed to load/save/clear config) must NOT advise the user to run the very
|
|
89
|
+
# operation that just failed, so we gate the guidance on the decrypt message.
|
|
90
|
+
if e.message.include?('decrypt')
|
|
91
|
+
warn ''
|
|
92
|
+
warn 'Your saved MySigner login is unreadable (the encryption key changed, or'
|
|
93
|
+
warn '~/.mysigner was copied from another machine / is corrupt).'
|
|
94
|
+
warn "Fix: run 'mysigner logout' then 'mysigner login' to re-authenticate,"
|
|
95
|
+
warn 'or run any command with --local-only to skip MySigner entirely.'
|
|
96
|
+
end
|
|
97
|
+
if ENV['DEBUG']
|
|
98
|
+
warn ''
|
|
99
|
+
warn e.full_message(highlight: false)
|
|
100
|
+
end
|
|
101
|
+
exit 1
|
|
102
|
+
rescue Errno::ENOENT => e
|
|
103
|
+
# A required external program or file wasn't found (e.g. xcodebuild on a
|
|
104
|
+
# non-Mac). Convert the raw Errno backtrace into a clear, actionable line.
|
|
105
|
+
warn "✗ A required program or file was not found: #{e.message}"
|
|
106
|
+
warn ''
|
|
107
|
+
warn 'If this was an iOS command, note that iOS builds need macOS + Xcode.'
|
|
108
|
+
warn 'On Linux or Windows you can still build Android: mysigner ship internal --platform android'
|
|
109
|
+
warn e.full_message(highlight: false) if ENV['DEBUG']
|
|
110
|
+
exit 1
|
|
111
|
+
end
|
|
@@ -31,14 +31,20 @@ module Mysigner
|
|
|
31
31
|
@key_alias = key_alias
|
|
32
32
|
@key_password = key_password || keystore_password
|
|
33
33
|
@version_code = version_code
|
|
34
|
+
@build_started_at = Time.now
|
|
34
35
|
|
|
35
36
|
# Determine task name
|
|
36
|
-
task = "bundle#{variant
|
|
37
|
+
task = "bundle#{variant_suffix(variant)}"
|
|
37
38
|
|
|
38
39
|
# Build
|
|
39
40
|
success = run_gradle_build(task)
|
|
40
41
|
|
|
41
|
-
|
|
42
|
+
unless success
|
|
43
|
+
raise BuildError, 'Android build failed — the details are in the Gradle output above. ' \
|
|
44
|
+
'Common causes: signing rejected (check keystore password/alias), a ' \
|
|
45
|
+
"missing SDK component (run 'mysigner doctor'), or a compile error in " \
|
|
46
|
+
"your app (look for 'error:' lines). Re-run with DEBUG=1 for more."
|
|
47
|
+
end
|
|
42
48
|
|
|
43
49
|
# Find output AAB
|
|
44
50
|
aab_path = find_aab_output(variant)
|
|
@@ -58,14 +64,20 @@ module Mysigner
|
|
|
58
64
|
@keystore_password = keystore_password
|
|
59
65
|
@key_alias = key_alias
|
|
60
66
|
@key_password = key_password || keystore_password
|
|
67
|
+
@build_started_at = Time.now
|
|
61
68
|
|
|
62
69
|
# Determine task name
|
|
63
|
-
task = "assemble#{variant
|
|
70
|
+
task = "assemble#{variant_suffix(variant)}"
|
|
64
71
|
|
|
65
72
|
# Build
|
|
66
73
|
success = run_gradle_build(task)
|
|
67
74
|
|
|
68
|
-
|
|
75
|
+
unless success
|
|
76
|
+
raise BuildError, 'Android build failed — the details are in the Gradle output above. ' \
|
|
77
|
+
'Common causes: signing rejected (check keystore password/alias), a ' \
|
|
78
|
+
"missing SDK component (run 'mysigner doctor'), or a compile error in " \
|
|
79
|
+
"your app (look for 'error:' lines). Re-run with DEBUG=1 for more."
|
|
80
|
+
end
|
|
69
81
|
|
|
70
82
|
# Find output APK
|
|
71
83
|
apk_path = find_apk_output(variant)
|
|
@@ -93,8 +105,11 @@ module Mysigner
|
|
|
93
105
|
|
|
94
106
|
# Phase 0: inject signing via Gradle init-script + env vars. Passwords
|
|
95
107
|
# never appear in argv (no -Pandroid.injected.signing.*=PLAINTEXT).
|
|
108
|
+
# Write the Gradle init script when we need to inject EITHER signing or
|
|
109
|
+
# a versionCode override (a bare -PversionCode is ignored by stock
|
|
110
|
+
# build.gradle, so versionCode must also flow through the init script).
|
|
96
111
|
injector = nil
|
|
97
|
-
if @keystore_path && File.exist?(@keystore_path)
|
|
112
|
+
if (@keystore_path && File.exist?(@keystore_path)) || @version_code
|
|
98
113
|
require 'mysigner/signing/gradle_signing_injector'
|
|
99
114
|
injector = Mysigner::Signing::GradleSigningInjector.new
|
|
100
115
|
@signing_init_script_path = injector.write_init_script!
|
|
@@ -124,8 +139,8 @@ module Mysigner
|
|
|
124
139
|
ENV['JAVA_HOME'] = detected
|
|
125
140
|
elsif java_home && !java_home.empty?
|
|
126
141
|
raise BuildError, "JAVA_HOME is set to invalid directory: #{java_home}\n" \
|
|
127
|
-
"Run 'mysigner doctor' to fix, or set JAVA_HOME
|
|
128
|
-
'
|
|
142
|
+
"Run 'mysigner doctor' to fix, or set JAVA_HOME to a valid JDK 17+ home\n " \
|
|
143
|
+
'(macOS: $(/usr/libexec/java_home -v 17), Linux: a dir under /usr/lib/jvm).'
|
|
129
144
|
end
|
|
130
145
|
end
|
|
131
146
|
|
|
@@ -147,19 +162,24 @@ module Mysigner
|
|
|
147
162
|
ENV['ANDROID_SDK_ROOT'] = detected
|
|
148
163
|
else
|
|
149
164
|
raise BuildError, "Android SDK not found.\n" \
|
|
150
|
-
"Run 'mysigner doctor' to diagnose, or set ANDROID_HOME
|
|
151
|
-
'
|
|
165
|
+
"Run 'mysigner doctor' to diagnose, or set ANDROID_HOME to your SDK path\n " \
|
|
166
|
+
'(macOS: ~/Library/Android/sdk, Linux: ~/Android/Sdk).'
|
|
152
167
|
end
|
|
153
168
|
end
|
|
154
169
|
|
|
155
170
|
def detect_android_home
|
|
156
|
-
# Common SDK locations
|
|
171
|
+
# Common SDK locations across macOS, Linux/WSL and Android Studio.
|
|
157
172
|
candidates = [
|
|
158
|
-
|
|
159
|
-
|
|
173
|
+
ENV.fetch('ANDROID_HOME', nil),
|
|
174
|
+
ENV.fetch('ANDROID_SDK_ROOT', nil),
|
|
175
|
+
File.expand_path('~/Library/Android/sdk'), # macOS (Android Studio)
|
|
176
|
+
File.expand_path('~/Android/Sdk'), # Linux (Android Studio)
|
|
177
|
+
File.expand_path('~/.android/sdk'),
|
|
178
|
+
'/usr/lib/android-sdk', # Debian/Ubuntu package
|
|
179
|
+
'/opt/android-sdk',
|
|
160
180
|
'/opt/homebrew/share/android-commandlinetools',
|
|
161
181
|
'/usr/local/share/android-commandlinetools'
|
|
162
|
-
]
|
|
182
|
+
].compact
|
|
163
183
|
|
|
164
184
|
candidates.each do |path|
|
|
165
185
|
return path if Dir.exist?(path) && Dir.exist?(File.join(path, 'platform-tools'))
|
|
@@ -189,10 +209,35 @@ module Mysigner
|
|
|
189
209
|
/usr/local/opt/openjdk/libexec/openjdk.jdk/Contents/Home
|
|
190
210
|
].each { |p| return p if Dir.exist?(p) }
|
|
191
211
|
|
|
192
|
-
# Try system Java
|
|
212
|
+
# Try system Java (macOS)
|
|
193
213
|
system_paths = Dir.glob('/Library/Java/JavaVirtualMachines/*/Contents/Home')
|
|
194
214
|
return system_paths.first if system_paths.any?
|
|
195
215
|
|
|
216
|
+
# Linux / WSL: resolve from the javac/java symlink, then /usr/lib/jvm.
|
|
217
|
+
detect_java_home_linux
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# JAVA_HOME detection for Linux/WSL. Follows the real javac/java binary to
|
|
221
|
+
# its JDK home, then falls back to the newest /usr/lib/jvm install.
|
|
222
|
+
def detect_java_home_linux
|
|
223
|
+
%w[javac java].each do |bin|
|
|
224
|
+
path = `command -v #{bin} 2>/dev/null`.to_s.strip
|
|
225
|
+
next if path.empty?
|
|
226
|
+
|
|
227
|
+
real = begin
|
|
228
|
+
File.realpath(path)
|
|
229
|
+
rescue StandardError
|
|
230
|
+
path
|
|
231
|
+
end
|
|
232
|
+
# .../<jdk>/bin/java -> JAVA_HOME is two levels up
|
|
233
|
+
home = File.expand_path('../..', real)
|
|
234
|
+
return home if Dir.exist?(File.join(home, 'bin'))
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
homes = Dir.glob('/usr/lib/jvm/*/bin/java').map { |p| File.expand_path('../..', p) }
|
|
238
|
+
homes.select! { |h| Dir.exist?(h) }
|
|
239
|
+
homes.max
|
|
240
|
+
rescue StandardError
|
|
196
241
|
nil
|
|
197
242
|
end
|
|
198
243
|
|
|
@@ -260,6 +305,13 @@ module Mysigner
|
|
|
260
305
|
cmd_parts << '&&' if @key_password
|
|
261
306
|
end
|
|
262
307
|
|
|
308
|
+
# Export the versionCode override so the init script applies it to
|
|
309
|
+
# android.defaultConfig (a bare -PversionCode property is inert).
|
|
310
|
+
if @version_code && @signing_init_script_path
|
|
311
|
+
cmd_parts << "export MYSIGNER_VERSION_CODE=#{shell_escape(@version_code.to_s)}"
|
|
312
|
+
cmd_parts << '&&'
|
|
313
|
+
end
|
|
314
|
+
|
|
263
315
|
# Change to android directory and run gradle
|
|
264
316
|
cmd_parts << "cd #{shell_escape(android_dir)}"
|
|
265
317
|
cmd_parts << '&&'
|
|
@@ -273,12 +325,15 @@ module Mysigner
|
|
|
273
325
|
|
|
274
326
|
cmd_parts << task
|
|
275
327
|
|
|
276
|
-
#
|
|
328
|
+
# Belt-and-suspenders: also pass -PversionCode for any build.gradle that
|
|
329
|
+
# opts into reading it. The init-script injection above is what makes it
|
|
330
|
+
# reliable for stock projects.
|
|
277
331
|
cmd_parts << "-PversionCode=#{@version_code}" if @version_code
|
|
278
332
|
|
|
279
|
-
# Standard build options
|
|
280
|
-
|
|
281
|
-
|
|
333
|
+
# Standard build options. NOTE: do NOT add -q — quiet level suppresses the
|
|
334
|
+
# `> Task …` / `BUILD …` lifecycle lines that execute_with_output parses
|
|
335
|
+
# for progress, and hides the FAILURE block on errors.
|
|
336
|
+
cmd_parts << '--no-daemon' # Avoid daemon issues in CI
|
|
282
337
|
|
|
283
338
|
cmd_parts.join(' ')
|
|
284
339
|
end
|
|
@@ -344,12 +399,7 @@ module Mysigner
|
|
|
344
399
|
File.join(android_dir, "build/app/outputs/bundle/#{variant}/*.aab")
|
|
345
400
|
]
|
|
346
401
|
|
|
347
|
-
patterns
|
|
348
|
-
matches = Dir.glob(pattern)
|
|
349
|
-
return matches.first if matches.any?
|
|
350
|
-
end
|
|
351
|
-
|
|
352
|
-
nil
|
|
402
|
+
newest_matching(patterns)
|
|
353
403
|
end
|
|
354
404
|
|
|
355
405
|
def find_apk_output(variant)
|
|
@@ -365,12 +415,34 @@ module Mysigner
|
|
|
365
415
|
File.join(android_dir, "app/build/outputs/apk/#{variant}/app-#{variant}-unsigned.apk")
|
|
366
416
|
]
|
|
367
417
|
|
|
368
|
-
patterns
|
|
369
|
-
|
|
370
|
-
return matches.first if matches.any?
|
|
371
|
-
end
|
|
418
|
+
newest_matching(patterns)
|
|
419
|
+
end
|
|
372
420
|
|
|
373
|
-
|
|
421
|
+
# Pick the freshest artifact across all candidate globs. Prefer files
|
|
422
|
+
# produced by THIS build (mtime at/after build start, minus a small skew)
|
|
423
|
+
# so a leftover artifact from a previous or wrong-flavor build is never
|
|
424
|
+
# returned; fall back to the newest overall if none look fresh.
|
|
425
|
+
def newest_matching(patterns)
|
|
426
|
+
candidates = patterns.flat_map { |p| Dir.glob(p) }.uniq.select { |f| File.file?(f) }
|
|
427
|
+
return nil if candidates.empty?
|
|
428
|
+
|
|
429
|
+
fresh = if @build_started_at
|
|
430
|
+
candidates.select { |f| File.mtime(f) >= @build_started_at - 2 }
|
|
431
|
+
else
|
|
432
|
+
candidates
|
|
433
|
+
end
|
|
434
|
+
pool = fresh.empty? ? candidates : fresh
|
|
435
|
+
pool.max_by { |f| File.mtime(f) }
|
|
436
|
+
end
|
|
437
|
+
|
|
438
|
+
# Build the Gradle task suffix from a variant. Only the first character is
|
|
439
|
+
# upcased — String#capitalize would downcase the rest and break camelCase
|
|
440
|
+
# flavored variants (e.g. "demoRelease" -> "Demorelease").
|
|
441
|
+
def variant_suffix(variant)
|
|
442
|
+
v = variant.to_s
|
|
443
|
+
return v if v.empty?
|
|
444
|
+
|
|
445
|
+
v[0].upcase + v[1..]
|
|
374
446
|
end
|
|
375
447
|
|
|
376
448
|
def shell_escape(str)
|
|
@@ -63,9 +63,9 @@ module Mysigner
|
|
|
63
63
|
# Match buildTypes block
|
|
64
64
|
if @gradle_content =~ /buildTypes\s*\{(.*?)\n\s*\}/m
|
|
65
65
|
block = ::Regexp.last_match(1)
|
|
66
|
-
# Find all type names (e.g., "release {" or "debug {")
|
|
66
|
+
# Find all type names (e.g., "release {" or "debug {"). Duplicates
|
|
67
|
+
# are removed by the trailing .uniq.
|
|
67
68
|
block.scan(/(\w+)\s*\{/) do |match|
|
|
68
|
-
types << match[0] unless %w[debug release].include?(match[0]) && types.include?(match[0])
|
|
69
69
|
types << match[0]
|
|
70
70
|
end
|
|
71
71
|
end
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
3
5
|
module Mysigner
|
|
4
6
|
module Build
|
|
5
7
|
class Detector
|
|
@@ -9,48 +11,133 @@ module Mysigner
|
|
|
9
11
|
# Returns: { platform: :ios/:android, type: :workspace/:project/:gradle, path: String, framework: :capacitor/:react_native/:flutter/:native }
|
|
10
12
|
# @param directory [String] Directory to search in
|
|
11
13
|
# @param platform [Symbol, nil] Force detection for specific platform (:ios, :android, or nil for auto-detect iOS)
|
|
12
|
-
|
|
14
|
+
# @param allow_prebuild [Boolean] when false (doctor/validate and other
|
|
15
|
+
# read-only callers), an Expo managed project with no native folder is
|
|
16
|
+
# CLASSIFIED (framework: :expo, needs_prebuild: true) instead of having
|
|
17
|
+
# `npx expo prebuild` run against it. Detection must never mutate the
|
|
18
|
+
# working tree from a diagnostic path.
|
|
19
|
+
def self.detect(directory = Dir.pwd, platform: nil, allow_prebuild: true)
|
|
13
20
|
# If platform is explicitly android, detect android
|
|
14
|
-
return detect_android(directory) if platform == :android
|
|
21
|
+
return detect_android(directory, allow_prebuild: allow_prebuild) if platform == :android
|
|
15
22
|
|
|
16
23
|
# Default behavior: detect iOS (backwards compatible)
|
|
17
|
-
detect_ios(directory)
|
|
24
|
+
detect_ios(directory, allow_prebuild: allow_prebuild)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# ── Expo helpers ─────────────────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
# True when this looks like an Expo project: an app.json/app.config.js
|
|
30
|
+
# plus `expo` as an ACTUAL dependency in package.json. We parse the JSON
|
|
31
|
+
# rather than substring-scanning the file so an unrelated package like
|
|
32
|
+
# `eslint-config-expo`, or the word "expo" in a script, doesn't trip
|
|
33
|
+
# detection (and the destructive prebuild it gates).
|
|
34
|
+
def self.expo_managed?(directory)
|
|
35
|
+
return false unless File.exist?("#{directory}/app.json") || File.exist?("#{directory}/app.config.js")
|
|
36
|
+
return false unless File.exist?("#{directory}/package.json")
|
|
37
|
+
|
|
38
|
+
pkg = begin
|
|
39
|
+
JSON.parse(File.read("#{directory}/package.json"))
|
|
40
|
+
rescue StandardError
|
|
41
|
+
nil
|
|
42
|
+
end
|
|
43
|
+
return false unless pkg.is_a?(Hash)
|
|
44
|
+
|
|
45
|
+
deps = {}
|
|
46
|
+
deps.merge!(pkg['dependencies']) if pkg['dependencies'].is_a?(Hash)
|
|
47
|
+
deps.merge!(pkg['devDependencies']) if pkg['devDependencies'].is_a?(Hash)
|
|
48
|
+
deps.key?('expo')
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Non-mutating classification for an Expo managed project that has no
|
|
52
|
+
# native folder yet. Callers must check :needs_prebuild before trying to
|
|
53
|
+
# read gradle/xcode paths off the result.
|
|
54
|
+
def self.expo_managed_result(directory, platform)
|
|
55
|
+
{
|
|
56
|
+
platform: platform,
|
|
57
|
+
type: :expo_managed,
|
|
58
|
+
framework: :expo,
|
|
59
|
+
path: File.absolute_path(directory),
|
|
60
|
+
directory: directory,
|
|
61
|
+
needs_prebuild: true
|
|
62
|
+
}
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Materialise the native project with `npx expo prebuild`, after a
|
|
66
|
+
# precheck so the user gets an actionable message instead of expo's raw
|
|
67
|
+
# ConfigError. Raises NoProjectError on any failure. The caller's
|
|
68
|
+
# `!Dir.exist?(native/)` guard guarantees we never clobber an existing
|
|
69
|
+
# native folder.
|
|
70
|
+
def self.run_expo_prebuild!(directory, platform)
|
|
71
|
+
ensure_expo_prereqs!(directory, platform)
|
|
72
|
+
|
|
73
|
+
puts "\n📦 Expo managed workflow detected (no #{platform}/ folder)"
|
|
74
|
+
puts "🔧 Running: npx expo prebuild --platform #{platform}\n\n"
|
|
75
|
+
result = system("cd #{directory} && npx expo prebuild --platform #{platform}")
|
|
76
|
+
|
|
77
|
+
native_dir = platform == :android ? 'android' : 'ios'
|
|
78
|
+
return if result && Dir.exist?("#{directory}/#{native_dir}")
|
|
79
|
+
|
|
80
|
+
raise NoProjectError, expo_prebuild_failed_message(platform)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Fail fast (before shelling out) for the two common prebuild blockers.
|
|
84
|
+
def self.ensure_expo_prereqs!(directory, platform)
|
|
85
|
+
unless Dir.exist?("#{directory}/node_modules/expo")
|
|
86
|
+
raise NoProjectError, expo_prebuild_failed_message(
|
|
87
|
+
platform, hint: 'The `expo` package is not installed. Run `npm install` ' \
|
|
88
|
+
'(or yarn/pnpm install) in the project first.'
|
|
89
|
+
)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
major = node_major_version
|
|
93
|
+
return unless major && major < 20
|
|
94
|
+
|
|
95
|
+
raise NoProjectError, expo_prebuild_failed_message(
|
|
96
|
+
platform, hint: "Node.js #{major}.x is too old for current Expo SDKs — " \
|
|
97
|
+
'install Node >= 20.19.4 (e.g. via nvm, mise, or nodejs.org).'
|
|
98
|
+
)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def self.node_major_version
|
|
102
|
+
out = `node --version 2>/dev/null`.to_s.strip
|
|
103
|
+
m = out.match(/v?(\d+)\./)
|
|
104
|
+
m && m[1].to_i
|
|
105
|
+
rescue StandardError
|
|
106
|
+
nil
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def self.expo_prebuild_failed_message(platform, hint: nil)
|
|
110
|
+
native = platform == :android ? 'Android' : 'iOS'
|
|
111
|
+
parts = ["Failed to generate #{native} project with expo prebuild."]
|
|
112
|
+
parts << hint if hint
|
|
113
|
+
parts << <<~ERROR.strip
|
|
114
|
+
Try running manually:
|
|
115
|
+
npx expo prebuild --platform #{platform}
|
|
116
|
+
|
|
117
|
+
Alternative: Use EAS Build (Expo's cloud service)
|
|
118
|
+
Learn more: https://docs.expo.dev/bare/overview/
|
|
119
|
+
ERROR
|
|
120
|
+
parts.join("\n\n")
|
|
18
121
|
end
|
|
19
122
|
|
|
20
123
|
# Detect Android project in directory
|
|
21
124
|
# Returns: { platform: :android, type: :gradle, path: String, framework: :capacitor/:react_native/:flutter/:native }
|
|
22
|
-
def self.detect_android(directory = Dir.pwd)
|
|
125
|
+
def self.detect_android(directory = Dir.pwd, allow_prebuild: true)
|
|
23
126
|
# 1. Check for Capacitor (most specific first)
|
|
24
127
|
if File.exist?("#{directory}/capacitor.config.json") ||
|
|
25
128
|
File.exist?("#{directory}/capacitor.config.ts")
|
|
26
129
|
return detect_capacitor_android(directory)
|
|
27
130
|
end
|
|
28
131
|
|
|
29
|
-
# 2.
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
puts "\n📦 Expo managed workflow detected (no android/ folder)"
|
|
36
|
-
puts "🔧 Running: npx expo prebuild --platform android\n\n"
|
|
37
|
-
|
|
38
|
-
result = system("cd #{directory} && npx expo prebuild --platform android")
|
|
39
|
-
|
|
40
|
-
unless result && Dir.exist?("#{directory}/android")
|
|
41
|
-
raise NoProjectError, <<~ERROR
|
|
42
|
-
Failed to generate Android project with expo prebuild.
|
|
132
|
+
# 2. Expo managed workflow (app.json/app.config.js + `expo` dependency,
|
|
133
|
+
# no android/ yet). Read-only callers pass allow_prebuild: false and
|
|
134
|
+
# get a non-mutating classification instead of a prebuild + possible
|
|
135
|
+
# raise.
|
|
136
|
+
if expo_managed?(directory) && !Dir.exist?("#{directory}/android")
|
|
137
|
+
return expo_managed_result(directory, :android) unless allow_prebuild
|
|
43
138
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
Alternative: Use EAS Build (Expo's cloud service)
|
|
48
|
-
Learn more: https://docs.expo.dev/bare/overview/
|
|
49
|
-
ERROR
|
|
50
|
-
end
|
|
51
|
-
|
|
52
|
-
puts "\n✓ Android project generated successfully\n\n"
|
|
53
|
-
end
|
|
139
|
+
run_expo_prebuild!(directory, :android)
|
|
140
|
+
puts "\n✓ Android project generated successfully\n\n"
|
|
54
141
|
end
|
|
55
142
|
|
|
56
143
|
# 3. Check for React Native
|
|
@@ -111,38 +198,20 @@ module Mysigner
|
|
|
111
198
|
end
|
|
112
199
|
|
|
113
200
|
# Detect iOS project in directory (original detect behavior)
|
|
114
|
-
def self.detect_ios(directory = Dir.pwd)
|
|
201
|
+
def self.detect_ios(directory = Dir.pwd, allow_prebuild: true)
|
|
115
202
|
# 1. Check for Capacitor (most specific first)
|
|
116
203
|
if File.exist?("#{directory}/capacitor.config.json") ||
|
|
117
204
|
File.exist?("#{directory}/capacitor.config.ts")
|
|
118
205
|
return detect_capacitor(directory)
|
|
119
206
|
end
|
|
120
207
|
|
|
121
|
-
# 2.
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
if content.include?('expo') && !Dir.exist?("#{directory}/ios")
|
|
126
|
-
# Auto-run expo prebuild
|
|
127
|
-
puts "\n📦 Expo managed workflow detected (no ios/ folder)"
|
|
128
|
-
puts "🔧 Running: npx expo prebuild --platform ios\n\n"
|
|
129
|
-
|
|
130
|
-
result = system("cd #{directory} && npx expo prebuild --platform ios")
|
|
208
|
+
# 2. Expo managed workflow (see detect_android for the allow_prebuild
|
|
209
|
+
# contract).
|
|
210
|
+
if expo_managed?(directory) && !Dir.exist?("#{directory}/ios")
|
|
211
|
+
return expo_managed_result(directory, :ios) unless allow_prebuild
|
|
131
212
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
Failed to generate iOS project with expo prebuild.
|
|
135
|
-
|
|
136
|
-
Try running manually:
|
|
137
|
-
npx expo prebuild --platform ios
|
|
138
|
-
|
|
139
|
-
Alternative: Use EAS Build (Expo's cloud service)
|
|
140
|
-
Learn more: https://docs.expo.dev/bare/overview/
|
|
141
|
-
ERROR
|
|
142
|
-
end
|
|
143
|
-
|
|
144
|
-
puts "\n✓ iOS project generated successfully\n\n"
|
|
145
|
-
end
|
|
213
|
+
run_expo_prebuild!(directory, :ios)
|
|
214
|
+
puts "\n✓ iOS project generated successfully\n\n"
|
|
146
215
|
end
|
|
147
216
|
|
|
148
217
|
# 3. Check for React Native
|