mysigner 0.3.1 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d5e0113872e921c7c4b1397411d1d67912ebdd1107ac7bfabdd2d45dc74e48b9
4
- data.tar.gz: 6ab4d50d0e33e17a48b17365fef0756b45c633ba3ad9b7deaa5e82b3ff52ea5f
3
+ metadata.gz: f948f1bd3659c76f18ab35990ab196ab301f39e4de7a42598fde06b1e391a3c9
4
+ data.tar.gz: acb32eee7a1c8c92f353fcfdef503a5f9392aa49d61ab0a5c1e4cc8781e22855
5
5
  SHA512:
6
- metadata.gz: 9d3b200af6f6f5d811238674f1bc1cfef9eacda498adbb260e64766e0ad9af1ad70c608af97333e14915b5caa5185e5efe8ea26a721901abc527a7ad42247cbd
7
- data.tar.gz: 297534dd124838601767fa3e5c2926b640f06564c97b359e70988b43605f5e0c41f5bd85fa99aa389d14a133e2c529b9d257af417c9a6f7230588942a9052c08
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
data/Gemfile.lock CHANGED
@@ -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.2)
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
data/exe/mysigner CHANGED
@@ -73,4 +73,39 @@ def rewrite_help_flag!(argv)
73
73
  ['help', first]
74
74
  end
75
75
 
76
- Mysigner::CLI.start(rewrite_help_flag!(hoist_leading_class_options!(ARGV)))
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.capitalize}"
37
+ task = "bundle#{variant_suffix(variant)}"
37
38
 
38
39
  # Build
39
40
  success = run_gradle_build(task)
40
41
 
41
- raise BuildError, 'Android build failed. Check output above for errors.' unless success
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.capitalize}"
70
+ task = "assemble#{variant_suffix(variant)}"
64
71
 
65
72
  # Build
66
73
  success = run_gradle_build(task)
67
74
 
68
- raise BuildError, 'Android build failed. Check output above for errors.' unless success
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 manually:\n " \
128
- 'export JAVA_HOME=$(/usr/libexec/java_home -v 17)'
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:\n " \
151
- 'export ANDROID_HOME=~/Library/Android/sdk'
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
- File.expand_path('~/Library/Android/sdk'),
159
- File.expand_path('~/Android/Sdk'),
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
- # Add version code override if provided (no file modification needed)
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
- cmd_parts << '--no-daemon' # Avoid daemon issues in CI
281
- cmd_parts << '-q' # Quiet mode (less noise)
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.each do |pattern|
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.each do |pattern|
369
- matches = Dir.glob(pattern)
370
- return matches.first if matches.any?
371
- end
418
+ newest_matching(patterns)
419
+ end
372
420
 
373
- nil
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
- def self.detect(directory = Dir.pwd, platform: nil)
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. Check for Expo (managed workflow - no android folder)
30
- if (File.exist?("#{directory}/app.json") || File.exist?("#{directory}/app.config.js")) &&
31
- File.exist?("#{directory}/package.json")
32
- content = File.read("#{directory}/package.json")
33
- if content.include?('expo') && !Dir.exist?("#{directory}/android")
34
- # Auto-run expo prebuild
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
- Try running manually:
45
- npx expo prebuild --platform android
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. Check for Expo (managed workflow)
122
- if (File.exist?("#{directory}/app.json") || File.exist?("#{directory}/app.config.js")) &&
123
- File.exist?("#{directory}/package.json")
124
- content = File.read("#{directory}/package.json")
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
- unless result && Dir.exist?("#{directory}/ios")
133
- raise NoProjectError, <<~ERROR
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