mysigner 0.1.7 → 0.2.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.
@@ -0,0 +1,1099 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'base64'
4
+ require 'json'
5
+ require 'tempfile'
6
+
7
+ module Mysigner
8
+ # mysigner-22 Phase 5 — credential auto-discovery cascade for `--local-only`
9
+ # mode. Replaces the original Keychain-only lookup with a fastlane-style
10
+ # cascade: explicit per-command flags → env vars users already set (Apple's
11
+ # APP_STORE_CONNECT_API_KEY_* and Google's GOOGLE_APPLICATION_CREDENTIALS) →
12
+ # Keychain (`onboard --local-only` store) → standard on-disk locations →
13
+ # interactive prompt (TTY only — never in CI).
14
+ #
15
+ # The resolver is the ONLY place that knows about the cascade. Uploaders
16
+ # receive the resolved Struct and don't touch ENV / disk / prompts directly,
17
+ # which keeps the wiring testable (Rule 9: tests verify the cascade, not the
18
+ # uploader plumbing).
19
+ #
20
+ # Backward-compat contract: vault mode (no `--local-only`) never calls this
21
+ # module. The `LocalCredentials` API is unchanged — it just becomes one
22
+ # source in the cascade rather than the only one.
23
+ module CredentialResolver
24
+ # Both Structs include `source` so the caller can log which leg of the
25
+ # cascade won. The CLI prints it before each ship so non-interactive runs
26
+ # (CI) have an audit trail of where the credential came from.
27
+ AscCreds = Struct.new(:key_id, :issuer_id, :p8_pem, :source)
28
+ PlayCreds = Struct.new(:sa_json, :client_email, :source)
29
+ # mysigner-22 Phase 7 — Android signing keystore credentials.
30
+ # `keystore_path` is always an on-disk path (the Gradle signing pipeline
31
+ # expects a file, not bytes). `tmpfile` is held so the Tempfile object
32
+ # isn't GC'd before the process exits — when the resolver materializes a
33
+ # Keychain-stored .jks blob to disk, we keep the Tempfile reference here
34
+ # so the file survives until the CLI process ends.
35
+ AndroidKeystoreCreds = Struct.new(
36
+ :keystore_path, :keystore_password, :key_alias, :key_password, :source, :tmpfile
37
+ )
38
+
39
+ # Raised when every cascade step fails AND we cannot prompt (non-TTY).
40
+ # The message lists every source tried plus the exact knob (flag / env /
41
+ # onboard command / disk location) the user can set to fix it.
42
+ class CredentialNotFoundError < StandardError; end
43
+
44
+ # Raised when the cascade finds multiple candidates at a tier that can't
45
+ # auto-disambiguate (e.g. several Keychain entries, several .p8 files on
46
+ # disk). Distinct from "not found" because the fix is different — pick one
47
+ # via a flag/env, don't go re-onboard.
48
+ class AmbiguousCredentialsError < StandardError; end
49
+
50
+ # Apple's officially-blessed on-disk location for ASC private keys,
51
+ # documented in WWDC sessions and used by altool / xcrun / fastlane.
52
+ APPLE_PRIVATE_KEYS_DIR = File.expand_path('~/.appstoreconnect/private_keys').freeze
53
+
54
+ # Common file names users put service-account JSON under at a project root.
55
+ PLAY_PROJECT_FILE_NAMES = %w[
56
+ play-credentials.json
57
+ service-account.json
58
+ play-service-account.json
59
+ ].freeze
60
+
61
+ # Walk up at most this many parent directories looking for project-sniff
62
+ # files. Three levels covers the common monorepo-with-app-subdir layout
63
+ # without surprising the user by reaching into unrelated trees.
64
+ PROJECT_SNIFF_MAX_DEPTH = 3
65
+
66
+ # Project-sniff filenames for Android signing config. `android/key.properties`
67
+ # is the Flutter convention (the `flutter create` template writes it);
68
+ # `android/keystore.properties` and a root-level `key.properties` are seen
69
+ # in a few Gradle-only setups (the docs use both names interchangeably).
70
+ # First match wins.
71
+ ANDROID_KEY_PROPERTIES_FILES = [
72
+ 'android/key.properties',
73
+ 'android/keystore.properties',
74
+ 'key.properties'
75
+ ].freeze
76
+
77
+ # Human-readable labels for the four cascade pieces. Used by the
78
+ # not-found error message so each `missing` symbol prints something
79
+ # the user can map back to a flag/env/sniff-key.
80
+ ANDROID_MISSING_LABELS = {
81
+ path: 'keystore path',
82
+ keystore_password: 'keystore password',
83
+ key_alias: 'key alias',
84
+ key_password: 'key password'
85
+ }.freeze
86
+
87
+ class << self
88
+ # @param options [Hash] Thor options hash with --asc-key-path/id/issuer-id
89
+ # @param env [Hash] ENV substitute for testability
90
+ # @param stdin [IO] $stdin substitute (we check #tty? for prompt gating)
91
+ # @param stderr [IO] $stderr substitute (for the prompt itself)
92
+ # @return [AscCreds]
93
+ # @raise [CredentialNotFoundError] when nothing usable was found and we can't prompt
94
+ def resolve_asc(options: {}, env: ENV, stdin: $stdin, stderr: $stderr)
95
+ tried = []
96
+
97
+ # Tier 1: per-command CLI flags (highest precedence). If all three are
98
+ # present we short-circuit; partial flags layer in below.
99
+ flag_path = string_option(options, :asc_key_path)
100
+ flag_key_id = string_option(options, :asc_key_id)
101
+ flag_issuer = string_option(options, :asc_issuer_id)
102
+ if flag_path && flag_key_id && flag_issuer
103
+ pem = read_pem!(flag_path, label: '--asc-key-path')
104
+ tried << "flag: --asc-key-path=#{flag_path}"
105
+ return AscCreds.new(key_id: flag_key_id, issuer_id: flag_issuer, p8_pem: pem, source: :flag)
106
+ end
107
+
108
+ # Tier 2: env vars (fastlane convention).
109
+ env_path = string_env(env, 'APP_STORE_CONNECT_API_KEY_PATH')
110
+ env_key_id = string_env(env, 'APP_STORE_CONNECT_API_KEY_ID')
111
+ env_issuer = string_env(env, 'APP_STORE_CONNECT_API_KEY_ISSUER_ID')
112
+
113
+ # Tier 3: Keychain — list yields zero / one / many; "many without
114
+ # disambiguator" is an explicit ambiguous-error rather than silently
115
+ # picking the first. WHY: the old uploader took .first quietly, which
116
+ # was fine when only `onboard --local-only` could write entries but is
117
+ # dangerous now that users may layer flags/env on top.
118
+ keychain_ids = safe_list_keychain(:asc)
119
+ keychain_id = pick_keychain_id(keychain_ids, hint: flag_key_id || env_key_id, label: 'ASC')
120
+ tried << "keychain: #{keychain_ids.length} entr#{keychain_ids.length == 1 ? 'y' : 'ies'}"
121
+
122
+ # Tier 4: disk scan. Skip entirely when a higher tier (flag or env)
123
+ # already supplies a .p8 path — disk can't add anything we'd prefer.
124
+ # WHY: scan_apple_private_keys_dir raises AmbiguousCredentialsError on
125
+ # multiple .p8 files, and that error is misleading when the user has
126
+ # already pointed at one via --asc-key-path / APP_STORE_CONNECT_API_KEY_PATH.
127
+ # "Higher tier wins" means the lower tier shouldn't even probe.
128
+ disk_path, disk_key_id =
129
+ if flag_path || env_path
130
+ [nil, nil]
131
+ else
132
+ scan_apple_private_keys_dir(tried)
133
+ end
134
+
135
+ # Stitch together the highest-priority *partial* tier first, then fill
136
+ # the missing fields from lower tiers. This lets "disk found the .p8
137
+ # but no issuer_id env" continue down to env then to prompt without
138
+ # restarting the cascade. Pieces are passed as one hash to keep the
139
+ # helper under the parameter-list cop limit.
140
+ path, key_id, issuer_id, source = assemble_asc_pieces(
141
+ flag_path: flag_path, flag_key_id: flag_key_id, flag_issuer: flag_issuer,
142
+ env_path: env_path, env_key_id: env_key_id, env_issuer: env_issuer,
143
+ keychain_id: keychain_id, disk_path: disk_path, disk_key_id: disk_key_id
144
+ )
145
+
146
+ # Keychain shortcut: when keychain holds the (key_id, issuer_id, pem)
147
+ # triple we don't need disk/env for anything.
148
+ if source == :keychain
149
+ envelope = fetch_keychain_envelope(:asc, key_id, label: 'ASC')
150
+ return AscCreds.new(
151
+ key_id: key_id,
152
+ issuer_id: envelope.fetch('issuer_id'),
153
+ p8_pem: envelope.fetch('p8_pem'),
154
+ source: :keychain
155
+ )
156
+ end
157
+
158
+ # All other tiers need a PEM read from disk.
159
+ pem = path ? read_pem!(path, label: source_label(source)) : nil
160
+
161
+ # Final fallback: prompt only when STDIN is a TTY. CI must fail loud.
162
+ if path.nil? || key_id.nil? || issuer_id.nil?
163
+ unless stdin.respond_to?(:tty?) && stdin.tty?
164
+ raise CredentialNotFoundError, asc_not_found_message(
165
+ tried: tried,
166
+ missing: { path: path.nil?, key_id: key_id.nil?, issuer_id: issuer_id.nil? }
167
+ )
168
+ end
169
+
170
+ if path.nil?
171
+ path = prompt(stderr, stdin, 'Path to your App Store Connect .p8 private key:')
172
+ pem = read_pem!(File.expand_path(path), label: 'prompt')
173
+ end
174
+ key_id ||= derive_key_id_from_filename(path) || prompt(stderr, stdin, 'App Store Connect Key ID:')
175
+ issuer_id ||= prompt(stderr, stdin, 'App Store Connect Issuer ID (UUID):')
176
+ # Only overwrite source to :prompt when the .p8 path itself was
177
+ # prompted (source.nil? means no higher tier supplied it). When the
178
+ # path came from env/flag/keychain/disk and only issuer_id was
179
+ # prompted in, preserve the originating source — the audit log
180
+ # should attribute the credential to where the primary material
181
+ # (the .p8) came from; issuer_id is metadata.
182
+ source ||= :prompt
183
+ end
184
+
185
+ AscCreds.new(key_id: key_id, issuer_id: issuer_id, p8_pem: pem, source: source)
186
+ end
187
+
188
+ # @param options [Hash] Thor options with --play-credentials
189
+ # @param env [Hash]
190
+ # @param stdin [IO]
191
+ # @param stderr [IO]
192
+ # @param cwd [String] starting dir for project-sniff (Dir.pwd in prod)
193
+ # @return [PlayCreds]
194
+ # @raise [CredentialNotFoundError]
195
+ def resolve_play(options: {}, env: ENV, stdin: $stdin, stderr: $stderr, cwd: Dir.pwd)
196
+ tried = []
197
+
198
+ # Tier 1: flag.
199
+ if (flag_path = string_option(options, :play_credentials))
200
+ tried << "flag: --play-credentials=#{flag_path}"
201
+ raw, email = read_sa_json!(flag_path, label: '--play-credentials')
202
+ return PlayCreds.new(sa_json: raw, client_email: email, source: :flag)
203
+ end
204
+
205
+ # Tier 2: env (Google's documented convention).
206
+ if (env_path = string_env(env, 'GOOGLE_APPLICATION_CREDENTIALS'))
207
+ tried << "env: GOOGLE_APPLICATION_CREDENTIALS=#{env_path}"
208
+ raw, email = read_sa_json!(env_path, label: 'GOOGLE_APPLICATION_CREDENTIALS')
209
+ return PlayCreds.new(sa_json: raw, client_email: email, source: :env)
210
+ end
211
+
212
+ # Tier 3: Keychain.
213
+ keychain_ids = safe_list_keychain(:google_play)
214
+ tried << "keychain: #{keychain_ids.length} entr#{keychain_ids.length == 1 ? 'y' : 'ies'}"
215
+ if keychain_ids.length == 1
216
+ raw = fetch_keychain_raw(:google_play, keychain_ids.first, label: 'Google Play')
217
+ return PlayCreds.new(sa_json: raw, client_email: keychain_ids.first, source: :keychain)
218
+ elsif keychain_ids.length > 1
219
+ raise AmbiguousCredentialsError,
220
+ "Multiple Google Play credentials in Keychain (#{keychain_ids.join(', ')}). " \
221
+ 'Pass --play-credentials PATH to disambiguate, or remove the unused ones with ' \
222
+ '`mysigner local-credential delete google_play <client_email>`.'
223
+ end
224
+
225
+ # Tier 4: project-sniff (walk up to PROJECT_SNIFF_MAX_DEPTH dirs).
226
+ if (sniffed = sniff_project_for_play(cwd, tried))
227
+ raw, email = read_sa_json!(sniffed, label: 'project-sniff')
228
+ return PlayCreds.new(sa_json: raw, client_email: email, source: :disk)
229
+ end
230
+
231
+ # Tier 5: prompt or fail.
232
+ raise CredentialNotFoundError, play_not_found_message(tried: tried) unless stdin.respond_to?(:tty?) && stdin.tty?
233
+
234
+ path = prompt(stderr, stdin, 'Path to your Google Play service-account JSON:')
235
+ raw, email = read_sa_json!(File.expand_path(path), label: 'prompt')
236
+ PlayCreds.new(sa_json: raw, client_email: email, source: :prompt)
237
+ end
238
+
239
+ # mysigner-22 Phase 7 — Android keystore cascade.
240
+ #
241
+ # Each tier may contribute any subset of the four required pieces
242
+ # (path, keystore_password, key_alias, key_password); the resolver
243
+ # stitches them together highest-priority-first and prompts (TTY) or
244
+ # fails (non-TTY) for whatever is still missing.
245
+ #
246
+ # Priority: flag > env > keychain > project-sniff > prompt.
247
+ #
248
+ # @return [AndroidKeystoreCreds]
249
+ # @raise [CredentialNotFoundError]
250
+ # @raise [AmbiguousCredentialsError]
251
+ def resolve_android_keystore(options: {}, env: ENV, stdin: $stdin, stderr: $stderr, cwd: Dir.pwd)
252
+ tried = []
253
+ # Layered hash {path:, keystore_password:, key_alias:, key_password:, source:, tmpfile:}.
254
+ # `path_source` mirrors the ASC cascade contract: source attribution
255
+ # follows the tier that supplied the .jks (the primary material), not
256
+ # the tier that filled in a stray password field.
257
+ pieces = {}
258
+
259
+ layer_android_flag_pieces!(pieces, options)
260
+ layer_android_env_pieces!(pieces, env)
261
+ layer_android_keychain_pieces!(pieces, hint: pieces[:key_alias], tried: tried)
262
+ layer_android_sniff_pieces!(pieces, cwd: cwd, tried: tried)
263
+
264
+ # All tiers exhausted — prompt for what's still missing (TTY only).
265
+ missing = android_missing_pieces(pieces)
266
+ if missing.any?
267
+ unless stdin.respond_to?(:tty?) && stdin.tty?
268
+ raise CredentialNotFoundError, android_keystore_not_found_message(tried: tried, missing: missing)
269
+ end
270
+
271
+ fill_android_pieces_from_prompt!(pieces, missing, stdin: stdin, stderr: stderr)
272
+ end
273
+
274
+ AndroidKeystoreCreds.new(
275
+ keystore_path: pieces[:path],
276
+ keystore_password: pieces[:keystore_password],
277
+ key_alias: pieces[:key_alias],
278
+ key_password: pieces[:key_password] || pieces[:keystore_password],
279
+ source: pieces[:source] || :prompt,
280
+ tmpfile: pieces[:tmpfile]
281
+ )
282
+ end
283
+
284
+ # ---- shared helpers ------------------------------------------------
285
+
286
+ private
287
+
288
+ def string_option(options, key)
289
+ return nil if options.nil?
290
+
291
+ # Thor symbolizes; tolerate string keys for direct callers.
292
+ val = options[key] || options[key.to_s]
293
+ return nil if val.nil?
294
+
295
+ s = val.to_s.strip
296
+ s.empty? ? nil : s
297
+ end
298
+
299
+ def string_env(env, key)
300
+ val = env[key]
301
+ return nil if val.nil?
302
+
303
+ s = val.to_s.strip
304
+ s.empty? ? nil : s
305
+ end
306
+
307
+ def safe_list_keychain(kind)
308
+ # Lazy-require so production scripts that never trigger local-only
309
+ # don't pay the LocalCredentials load cost.
310
+ require 'mysigner/local_credentials'
311
+ Mysigner::LocalCredentials.list(kind: kind)
312
+ rescue StandardError
313
+ # A misconfigured Keychain shouldn't crash the cascade — treat as
314
+ # "no entries here, try the next tier."
315
+ []
316
+ end
317
+
318
+ # WHY this isn't `ids.first`: when the user has multiple ASC keys in
319
+ # Keychain (very common once they switch teams) we used to silently pick
320
+ # one. Now: if a hint (flag --asc-key-id or APP_STORE_CONNECT_API_KEY_ID)
321
+ # narrows the choice, we use it; if the hint doesn't match any keychain
322
+ # entry, we treat keychain as "not useful here" and let lower tiers
323
+ # provide the path; if no hint and one entry, that one wins; otherwise
324
+ # we raise AmbiguousCredentialsError.
325
+ def pick_keychain_id(ids, hint:, label:)
326
+ return nil if ids.empty?
327
+
328
+ if hint
329
+ return hint if ids.include?(hint)
330
+
331
+ # Hint set but no keychain match — caller will use disk/env path
332
+ # for this key_id. Don't raise here.
333
+ return nil
334
+ end
335
+
336
+ return ids.first if ids.length == 1
337
+
338
+ raise AmbiguousCredentialsError,
339
+ "Multiple #{label} credentials in Keychain (#{ids.join(', ')}). " \
340
+ 'Pass --asc-key-id KEY_ID (or set APP_STORE_CONNECT_API_KEY_ID) to disambiguate.'
341
+ end
342
+
343
+ # Android-specific keychain picker — same shape as pick_keychain_id but
344
+ # the disambiguation knob is `--key-alias` (the alias *is* the keychain
345
+ # account id for android_keystore), so the error wording differs.
346
+ def pick_android_keychain_id(ids, hint:)
347
+ return nil if ids.empty?
348
+
349
+ if hint
350
+ return hint if ids.include?(hint)
351
+
352
+ return nil
353
+ end
354
+
355
+ return ids.first if ids.length == 1
356
+
357
+ raise AmbiguousCredentialsError,
358
+ "Multiple Android keystore credentials in Keychain (#{ids.join(', ')}). " \
359
+ 'Pass --key-alias ALIAS (or set MYSIGNER_KEY_ALIAS) to disambiguate.'
360
+ end
361
+
362
+ def fetch_keychain_envelope(kind, id, label:)
363
+ require 'mysigner/local_credentials'
364
+ raw = Mysigner::LocalCredentials.fetch(kind: kind, id: id)
365
+ if raw.nil?
366
+ raise CredentialNotFoundError,
367
+ "Keychain index lists `#{id}` but the secret is missing. " \
368
+ 'Re-store with `mysigner onboard --local-only`.'
369
+ end
370
+
371
+ parsed = begin
372
+ JSON.parse(raw)
373
+ rescue JSON::ParserError => e
374
+ raise CredentialNotFoundError,
375
+ "#{label} Keychain entry `#{id}` is not valid JSON (#{e.message}). " \
376
+ 'Re-store with `mysigner onboard --local-only`.'
377
+ end
378
+
379
+ unless parsed.is_a?(Hash) && parsed['issuer_id'] && parsed['p8_pem']
380
+ raise CredentialNotFoundError,
381
+ "#{label} Keychain entry `#{id}` is missing required fields (need issuer_id + p8_pem). " \
382
+ 'Re-store with `mysigner onboard --local-only`.'
383
+ end
384
+
385
+ parsed
386
+ end
387
+
388
+ def fetch_keychain_raw(kind, id, label:)
389
+ require 'mysigner/local_credentials'
390
+ raw = Mysigner::LocalCredentials.fetch(kind: kind, id: id)
391
+ return raw if raw && !raw.empty?
392
+
393
+ raise CredentialNotFoundError,
394
+ "#{label} Keychain index lists `#{id}` but the secret is missing. " \
395
+ 'Re-store with `mysigner onboard --local-only`.'
396
+ end
397
+
398
+ def scan_apple_private_keys_dir(tried)
399
+ dir = APPLE_PRIVATE_KEYS_DIR
400
+ unless File.directory?(dir)
401
+ tried << "disk: #{dir} (not present)"
402
+ return [nil, nil]
403
+ end
404
+
405
+ files = Dir.glob(File.join(dir, 'AuthKey_*.p8'))
406
+ files_word = files.length == 1 ? 'file' : 'files'
407
+ tried << "disk: #{dir} (#{files.length} AuthKey_*.p8 #{files_word})"
408
+
409
+ if files.length == 1
410
+ path = files.first
411
+ [path, derive_key_id_from_filename(path)]
412
+ elsif files.length > 1
413
+ # Multiple files on disk is ambiguous in the SAME way as multi-
414
+ # Keychain. Surface it; let the flag/env-var pick.
415
+ raise AmbiguousCredentialsError,
416
+ "Multiple ASC private keys found in #{dir}: " \
417
+ "#{files.map { |f| File.basename(f) }.join(', ')}. " \
418
+ 'Pass --asc-key-path PATH (or set APP_STORE_CONNECT_API_KEY_PATH) to pick one.'
419
+ else
420
+ [nil, nil]
421
+ end
422
+ end
423
+
424
+ def derive_key_id_from_filename(path)
425
+ return nil if path.nil?
426
+
427
+ File.basename(path) =~ /\AAuthKey_([A-Z0-9]+)\.p8\z/i ? Regexp.last_match(1) : nil
428
+ end
429
+
430
+ # Build the "what we have, what we need" triple by overlaying tiers from
431
+ # high → low priority. Returns [path, key_id, issuer_id, winning_source].
432
+ # WHY return :source from here: the cascade can win at the highest tier
433
+ # that contributed the path (which is what we tell the user about).
434
+ # `parts` is a single Hash to keep the parameter list under the cop
435
+ # limit; every key is required so a missing one is a programmer error.
436
+ def assemble_asc_pieces(parts)
437
+ # If keychain has a complete triple, prefer it over disk because the
438
+ # user explicitly onboarded it (intent signal). The keychain branch
439
+ # short-circuits in the caller, so we just signal it here.
440
+ return [nil, parts[:keychain_id], nil, :keychain] if parts[:keychain_id]
441
+
442
+ path = parts[:flag_path] || parts[:env_path] || parts[:disk_path]
443
+ key_id = parts[:flag_key_id] || parts[:env_key_id] || derive_key_id_from_filename(path) || parts[:disk_key_id]
444
+ issuer = parts[:flag_issuer] || parts[:env_issuer]
445
+ source = source_for(path, flag_path: parts[:flag_path], env_path: parts[:env_path], disk_path: parts[:disk_path])
446
+ [path, key_id, issuer, source]
447
+ end
448
+
449
+ def source_for(path, flag_path:, env_path:, disk_path:)
450
+ return :flag if path && path == flag_path
451
+ return :env if path && path == env_path
452
+ return :disk if path && path == disk_path
453
+
454
+ nil
455
+ end
456
+
457
+ def source_label(source)
458
+ case source
459
+ when :flag then '--asc-key-path'
460
+ when :env then 'APP_STORE_CONNECT_API_KEY_PATH'
461
+ when :disk then APPLE_PRIVATE_KEYS_DIR
462
+ else 'prompt'
463
+ end
464
+ end
465
+
466
+ def read_pem!(path, label:)
467
+ expanded = File.expand_path(path)
468
+ unless File.exist?(expanded)
469
+ raise CredentialNotFoundError,
470
+ "ASC private key file not found at #{expanded} (from #{label}). " \
471
+ 'Check the path and try again.'
472
+ end
473
+
474
+ File.read(expanded)
475
+ end
476
+
477
+ def read_sa_json!(path, label:)
478
+ expanded = File.expand_path(path)
479
+ unless File.exist?(expanded)
480
+ raise CredentialNotFoundError,
481
+ "Google Play service-account JSON not found at #{expanded} (from #{label}). " \
482
+ 'Check the path and try again.'
483
+ end
484
+
485
+ raw = File.read(expanded)
486
+ parsed = begin
487
+ JSON.parse(raw)
488
+ rescue JSON::ParserError => e
489
+ raise CredentialNotFoundError,
490
+ "Google Play service-account JSON at #{expanded} is not valid JSON (#{e.message})."
491
+ end
492
+
493
+ unless parsed['type'] == 'service_account' && parsed['client_email'] && parsed['private_key']
494
+ raise CredentialNotFoundError,
495
+ "Google Play service-account JSON at #{expanded} is missing required fields " \
496
+ "(need type='service_account', client_email, private_key)."
497
+ end
498
+
499
+ [raw, parsed['client_email']]
500
+ end
501
+
502
+ # Walks `cwd` up to PROJECT_SNIFF_MAX_DEPTH parents looking for any of:
503
+ # - eas.json with submit.<profile>.android.serviceAccountKeyPath
504
+ # - PLAY_PROJECT_FILE_NAMES at the root
505
+ # First hit wins (closer-to-cwd dirs first). Records what was tried so
506
+ # the not-found error names the dirs.
507
+ def sniff_project_for_play(cwd, tried)
508
+ return nil if cwd.nil?
509
+
510
+ dir = File.expand_path(cwd)
511
+ PROJECT_SNIFF_MAX_DEPTH.times do
512
+ tried << "disk: project-sniff in #{dir}"
513
+
514
+ eas_path = File.join(dir, 'eas.json')
515
+ if File.exist?(eas_path)
516
+ sa = extract_sa_path_from_eas(eas_path, dir)
517
+ return sa if sa
518
+ end
519
+
520
+ PLAY_PROJECT_FILE_NAMES.each do |name|
521
+ candidate = File.join(dir, name)
522
+ return candidate if File.exist?(candidate)
523
+ end
524
+
525
+ parent = File.dirname(dir)
526
+ break if parent == dir # filesystem root
527
+
528
+ dir = parent
529
+ end
530
+
531
+ nil
532
+ end
533
+
534
+ # eas.json shape: submit.<profile>.android.serviceAccountKeyPath. Walk
535
+ # the production / preview / default profiles in that order — production
536
+ # is the canonical ship target, preview is the second most common in
537
+ # Expo docs.
538
+ def extract_sa_path_from_eas(eas_path, dir)
539
+ json = JSON.parse(File.read(eas_path))
540
+ submit = json['submit'] || {}
541
+ %w[production preview default].each do |profile|
542
+ path = submit.dig(profile, 'android', 'serviceAccountKeyPath')
543
+ next if path.nil? || path.to_s.strip.empty?
544
+
545
+ # EAS paths can be relative to the eas.json dir.
546
+ resolved = File.expand_path(path, dir)
547
+ return resolved if File.exist?(resolved)
548
+ end
549
+ nil
550
+ rescue JSON::ParserError
551
+ # Malformed eas.json is the user's problem in their own toolchain;
552
+ # we just skip it and let the cascade fall through.
553
+ nil
554
+ end
555
+
556
+ def prompt(stderr, stdin, label)
557
+ stderr.print "#{label} " if stderr.respond_to?(:print)
558
+ stdin.gets.to_s.strip.gsub(/\A['"]|['"]\z/, '')
559
+ end
560
+
561
+ def asc_not_found_message(tried:, missing:)
562
+ missing_pieces = []
563
+ missing_pieces << 'path to .p8' if missing[:path]
564
+ missing_pieces << 'Key ID' if missing[:key_id]
565
+ missing_pieces << 'Issuer ID' if missing[:issuer_id]
566
+
567
+ <<~MSG.strip
568
+ No usable App Store Connect credentials found (missing: #{missing_pieces.join(', ')}).
569
+
570
+ Tried in order:
571
+ #{tried.map { |t| " - #{t}" }.join("\n")}
572
+
573
+ To fix, set ANY of these:
574
+ * Per-command flags: --asc-key-path PATH --asc-key-id KEY_ID --asc-issuer-id UUID
575
+ * Environment: APP_STORE_CONNECT_API_KEY_PATH, APP_STORE_CONNECT_API_KEY_ID, APP_STORE_CONNECT_API_KEY_ISSUER_ID
576
+ * Onboard locally: mysigner onboard --local-only
577
+ * Standard location: place AuthKey_<KEY_ID>.p8 in #{APPLE_PRIVATE_KEYS_DIR}/
578
+ MSG
579
+ end
580
+
581
+ # ---- Android keystore cascade helpers ------------------------------
582
+
583
+ # Higher tiers never overwrite lower tiers when they're nil, and lower
584
+ # tiers never overwrite higher tiers when both have a value. That's the
585
+ # invariant `layer_android_*_pieces!` keeps: a key in `pieces` is set
586
+ # exactly once, by the highest tier that supplied it.
587
+ def layer_android_piece!(pieces, key, value, source:)
588
+ return if value.nil? || value.to_s.empty?
589
+ return unless pieces[key].nil?
590
+
591
+ pieces[key] = value
592
+ # Only the path drives the `:source` audit label — passwords and
593
+ # alias are metadata. Mirrors the ASC contract (path = primary
594
+ # material, issuer_id = metadata).
595
+ pieces[:source] ||= source if key == :path
596
+ end
597
+
598
+ def layer_android_flag_pieces!(pieces, options)
599
+ layer_android_piece!(pieces, :path, expand_or_nil(string_option(options, :keystore_path)), source: :flag)
600
+ layer_android_piece!(pieces, :keystore_password, string_option(options, :keystore_password), source: :flag)
601
+ layer_android_piece!(pieces, :key_alias, string_option(options, :key_alias), source: :flag)
602
+ layer_android_piece!(pieces, :key_password, string_option(options, :key_password), source: :flag)
603
+ end
604
+
605
+ def layer_android_env_pieces!(pieces, env)
606
+ layer_android_piece!(pieces, :path,
607
+ expand_or_nil(string_env(env, 'MYSIGNER_KEYSTORE_PATH') || string_env(env, 'ANDROID_KEYSTORE_PATH')),
608
+ source: :env)
609
+ layer_android_piece!(pieces, :keystore_password,
610
+ string_env(env, 'MYSIGNER_KEYSTORE_PASSWORD') || string_env(env, 'ANDROID_KEYSTORE_PASSWORD'),
611
+ source: :env)
612
+ layer_android_piece!(pieces, :key_alias,
613
+ string_env(env, 'MYSIGNER_KEY_ALIAS') || string_env(env, 'ANDROID_KEY_ALIAS'),
614
+ source: :env)
615
+ layer_android_piece!(pieces, :key_password,
616
+ string_env(env, 'MYSIGNER_KEY_PASSWORD') || string_env(env, 'ANDROID_KEY_PASSWORD'),
617
+ source: :env)
618
+ end
619
+
620
+ def layer_android_keychain_pieces!(pieces, hint:, tried:)
621
+ ids = safe_list_keychain(:android_keystore)
622
+ tried << "keychain: #{ids.length} entr#{ids.length == 1 ? 'y' : 'ies'}"
623
+
624
+ chosen_id = pick_android_keychain_id(ids, hint: hint)
625
+ return if chosen_id.nil?
626
+
627
+ envelope = fetch_keystore_envelope!(chosen_id)
628
+
629
+ # Materialize the base64-encoded .jks to a tmp file. Gradle and apksigner
630
+ # both expect a path, so we write once per process and keep the Tempfile
631
+ # alive on the Struct so GC doesn't unlink it mid-build.
632
+ tmp = materialize_keystore_tmpfile!(envelope.fetch('keystore_b64'), chosen_id)
633
+
634
+ layer_android_piece!(pieces, :path, tmp.path, source: :keychain)
635
+ layer_android_piece!(pieces, :keystore_password, envelope['keystore_password'], source: :keychain)
636
+ layer_android_piece!(pieces, :key_alias, envelope['key_alias'] || chosen_id, source: :keychain)
637
+ layer_android_piece!(pieces, :key_password, envelope['key_password'], source: :keychain)
638
+ pieces[:tmpfile] = tmp
639
+ end
640
+
641
+ def layer_android_sniff_pieces!(pieces, cwd:, tried:)
642
+ return if pieces[:path] # higher tier already supplied a path
643
+
644
+ dir = File.expand_path(cwd)
645
+ # Manual loop (not `.times`) so we can `break` out cleanly on the
646
+ # first match without a non-local return-from-iterator (Lint cop).
647
+ # Per-dir order: highest-priority project-local explicit (key.properties,
648
+ # the Flutter / Gradle-only standard) → eas.json (Expo) → inline
649
+ # `signingConfigs.release` in `android/app/build.gradle[.kts]` (the
650
+ # most common pure-Android-Studio convention; later in order because
651
+ # `key.properties` is more explicit and many Studio projects load it
652
+ # via `keystoreProperties[...]` inside the gradle file anyway, in
653
+ # which case the .properties file is the source of truth).
654
+ depth = 0
655
+ while depth < PROJECT_SNIFF_MAX_DEPTH
656
+ tried << "disk: project-sniff in #{dir}"
657
+
658
+ break if try_layer_android_key_properties!(pieces, dir)
659
+ break if try_layer_android_eas_json!(pieces, dir)
660
+ break if try_layer_android_build_gradle!(pieces, dir)
661
+
662
+ parent = File.dirname(dir)
663
+ break if parent == dir
664
+
665
+ dir = parent
666
+ depth += 1
667
+ end
668
+
669
+ # Global per-user fallback: only consulted if project-local sources
670
+ # didn't fill every piece. Scoped by `pieces[:path].nil?` so a project
671
+ # that fully self-describes its keystore via key.properties / eas.json /
672
+ # build.gradle never silently picks up a stale entry from a developer's
673
+ # ~/.gradle/gradle.properties.
674
+ try_layer_gradle_properties!(pieces, tried) if pieces[:path].nil?
675
+ end
676
+
677
+ def try_layer_android_key_properties!(pieces, dir)
678
+ ANDROID_KEY_PROPERTIES_FILES.each do |rel|
679
+ kp_path = File.join(dir, rel)
680
+ next unless File.exist?(kp_path)
681
+
682
+ kp = parse_key_properties(kp_path)
683
+ next unless kp[:storeFile]
684
+
685
+ resolved = File.expand_path(kp[:storeFile], File.dirname(kp_path))
686
+ next unless File.exist?(resolved)
687
+
688
+ layer_android_piece!(pieces, :path, resolved, source: :disk)
689
+ layer_android_piece!(pieces, :keystore_password, kp[:storePassword], source: :disk)
690
+ layer_android_piece!(pieces, :key_alias, kp[:keyAlias], source: :disk)
691
+ layer_android_piece!(pieces, :key_password, kp[:keyPassword], source: :disk)
692
+ return true
693
+ end
694
+ false
695
+ end
696
+
697
+ def try_layer_android_eas_json!(pieces, dir)
698
+ eas_path = File.join(dir, 'eas.json')
699
+ return false unless File.exist?(eas_path)
700
+
701
+ extract_keystore_from_eas!(pieces, eas_path, dir)
702
+ end
703
+
704
+ # mysigner-22 Phase 7 follow-up — pure-Android-Studio convention.
705
+ # Inline `signingConfigs { release { ... } }` in `android/app/build.gradle`
706
+ # (Groovy DSL) or `android/app/build.gradle.kts` (Kotlin DSL).
707
+ #
708
+ # We deliberately do NOT write a Groovy/Kotlin parser; we extract literal
709
+ # string values for the four `release {}` fields and skip anything that
710
+ # is a `System.getenv(...)`, `project.properties[...]`, or
711
+ # `keystoreProperties[...]` reference — those are handled by the env tier
712
+ # and the key.properties sniff respectively.
713
+ #
714
+ # WHY source :disk (not a new :gradle): the user-facing audit attribution
715
+ # is "we found credentials on disk in your project tree", same conceptual
716
+ # tier as key.properties and eas.json. Adding a new source label would
717
+ # bloat the audit log for no operational benefit — the file extension
718
+ # already disambiguates in tried_log.
719
+ def try_layer_android_build_gradle!(pieces, dir)
720
+ %w[android/app/build.gradle android/app/build.gradle.kts].each do |rel|
721
+ gradle_path = File.join(dir, rel)
722
+ next unless File.exist?(gradle_path)
723
+
724
+ release_body = extract_gradle_release_block(gradle_path)
725
+ next if release_body.nil? || release_body.empty?
726
+
727
+ extracted = extract_gradle_release_fields(release_body)
728
+ # Resolve storeFile relative to the gradle file's own dir (matches
729
+ # Gradle's `file(...)` resolution semantics).
730
+ if extracted[:storeFile]
731
+ resolved = File.expand_path(extracted[:storeFile], File.dirname(gradle_path))
732
+ layer_android_piece!(pieces, :path, resolved, source: :disk) if File.exist?(resolved)
733
+ end
734
+ layer_android_piece!(pieces, :keystore_password, extracted[:storePassword], source: :disk)
735
+ layer_android_piece!(pieces, :key_alias, extracted[:keyAlias], source: :disk)
736
+ layer_android_piece!(pieces, :key_password, extracted[:keyPassword], source: :disk)
737
+
738
+ return true if pieces[:path]
739
+ end
740
+ false
741
+ end
742
+
743
+ # Match `signingConfigs { ... release { <body> } ... }` and return the
744
+ # inner release-block body. Tolerant of whitespace; uses a brace-depth
745
+ # walk (NOT a regex with nested capture, which can't match balanced
746
+ # braces) so deeply nested debug { ... } release { ... } structures
747
+ # don't trip up the extraction.
748
+ def extract_gradle_release_block(path)
749
+ src = File.read(path)
750
+ return nil unless src
751
+
752
+ signing_body = extract_balanced_brace_body(src, /signingConfigs\s*\{/)
753
+ return nil if signing_body.nil?
754
+
755
+ # Match three release-block forms:
756
+ # Groovy DSL: release {
757
+ # Kotlin DSL: create("release") { ... } OR getByName("release") { ... }
758
+ # Both Kotlin variants are how Android Studio's New Project wizard
759
+ # actually writes signingConfigs in build.gradle.kts — bare `release {`
760
+ # is illegal Kotlin (no such top-level identifier). Without the
761
+ # `create|getByName` alternation, every modern AS Kotlin project
762
+ # silently fell through to the prompt tier.
763
+ extract_balanced_brace_body(
764
+ signing_body,
765
+ /(?:release|(?:create|getByName)\s*\(\s*["']release["']\s*\))\s*\{/
766
+ )
767
+ rescue StandardError
768
+ nil
769
+ end
770
+
771
+ # Find the first match of `opener_regex` ending at `{`, then walk forward
772
+ # counting braces until the matching `}`. Returns the body BETWEEN the
773
+ # braces (exclusive). Returns nil if no match or unbalanced braces.
774
+ def extract_balanced_brace_body(src, opener_regex)
775
+ m = src.match(opener_regex)
776
+ return nil unless m
777
+
778
+ start_idx = m.end(0) # position right after the `{`
779
+ depth = 1
780
+ idx = start_idx
781
+ while idx < src.length
782
+ ch = src[idx]
783
+ depth += 1 if ch == '{'
784
+ depth -= 1 if ch == '}'
785
+ return src[start_idx...idx] if depth.zero?
786
+
787
+ idx += 1
788
+ end
789
+ nil
790
+ end
791
+
792
+ # Pull the four literal field values from a release-block body. The
793
+ # patterns intentionally only match double / single quoted string
794
+ # literals — `System.getenv(...)`, `project.properties[...]`,
795
+ # `keystoreProperties[...]` references all fall through (no match) and
796
+ # are left for the env tier / key.properties sniff to fill.
797
+ #
798
+ # Optional `=` between identifier and value handles the Kotlin DSL form
799
+ # (`storePassword = "pw"`) without a second pattern set.
800
+ def extract_gradle_release_fields(body)
801
+ out = {}
802
+ # storeFile: must be `storeFile [= ]file("<literal>")`. We require the
803
+ # `file(` wrapper because that's what every Android signing config uses
804
+ # for path values; bare strings here would be wrong syntax.
805
+ if (m = body.match(/storeFile\s*=?\s*file\(\s*["']([^"']+)["']\s*\)/))
806
+ out[:storeFile] = m[1]
807
+ end
808
+ if (m = body.match(/storePassword\s*=?\s*["']([^"']+)["']/))
809
+ out[:storePassword] = m[1]
810
+ end
811
+ if (m = body.match(/keyAlias\s*=?\s*["']([^"']+)["']/))
812
+ out[:keyAlias] = m[1]
813
+ end
814
+ if (m = body.match(/keyPassword\s*=?\s*["']([^"']+)["']/))
815
+ out[:keyPassword] = m[1]
816
+ end
817
+ out
818
+ end
819
+
820
+ # mysigner-22 Phase 7 follow-up — Android Studio per-user convention.
821
+ # The "secure shared keystore" pattern from
822
+ # https://developer.android.com/studio/publish/app-signing#secure-shared-keystore
823
+ # stores signing config in `~/.gradle/gradle.properties` with a project-
824
+ # chosen prefix:
825
+ #
826
+ # MYAPP_RELEASE_STORE_FILE=/path/to/release.jks
827
+ # MYAPP_RELEASE_STORE_PASSWORD=...
828
+ # MYAPP_RELEASE_KEY_ALIAS=upload
829
+ # MYAPP_RELEASE_KEY_PASSWORD=...
830
+ #
831
+ # The docs use `MYAPP_RELEASE_` as the example prefix but each project
832
+ # picks its own. We scan all keys, group by prefix, and:
833
+ # - if exactly one prefix has all four fields → layer it in
834
+ # - if multiple prefixes are full → raise AmbiguousCredentialsError
835
+ # - if only partial sets exist → skip silently and let other tiers fill
836
+ def try_layer_gradle_properties!(pieces, tried)
837
+ gradle_props_path = File.expand_path('~/.gradle/gradle.properties')
838
+ return unless File.exist?(gradle_props_path)
839
+
840
+ tried << "disk: #{gradle_props_path}"
841
+
842
+ parsed = parse_key_properties(gradle_props_path)
843
+ return if parsed.empty?
844
+
845
+ full_prefixes = group_gradle_keystore_prefixes(parsed)
846
+
847
+ if full_prefixes.length > 1
848
+ raise AmbiguousCredentialsError,
849
+ "Multiple Android keystore prefixes in #{gradle_props_path} " \
850
+ "(#{full_prefixes.keys.sort.join(', ')}). " \
851
+ 'Pass --key-alias ALIAS (or set MYSIGNER_KEY_ALIAS) to disambiguate, ' \
852
+ 'or remove the unused prefix entries.'
853
+ end
854
+
855
+ return if full_prefixes.empty?
856
+
857
+ _prefix, fields = full_prefixes.first
858
+ resolved_path = File.expand_path(fields[:store_file])
859
+ return unless File.exist?(resolved_path)
860
+
861
+ layer_android_piece!(pieces, :path, resolved_path, source: :disk)
862
+ layer_android_piece!(pieces, :keystore_password, fields[:store_password], source: :disk)
863
+ layer_android_piece!(pieces, :key_alias, fields[:key_alias], source: :disk)
864
+ layer_android_piece!(pieces, :key_password, fields[:key_password], source: :disk)
865
+ end
866
+
867
+ # Walk every parsed key, bin by prefix-before-_STORE_FILE (and the three
868
+ # siblings). Returns only prefixes that have ALL four fields present
869
+ # AND non-empty — partials get filtered out so they don't trigger the
870
+ # ambiguity check (a partial set is "noise", not a competing candidate).
871
+ def group_gradle_keystore_prefixes(parsed)
872
+ buckets = Hash.new { |h, k| h[k] = {} }
873
+ parsed.each do |key, value|
874
+ next if value.nil? || value.to_s.empty?
875
+
876
+ key_str = key.to_s
877
+ case key_str
878
+ when /\A(.+)_STORE_FILE\z/ then buckets[Regexp.last_match(1)][:store_file] = value
879
+ when /\A(.+)_STORE_PASSWORD\z/ then buckets[Regexp.last_match(1)][:store_password] = value
880
+ when /\A(.+)_KEY_ALIAS\z/ then buckets[Regexp.last_match(1)][:key_alias] = value
881
+ when /\A(.+)_KEY_PASSWORD\z/ then buckets[Regexp.last_match(1)][:key_password] = value
882
+ end
883
+ end
884
+
885
+ required = %i[store_file store_password key_alias key_password]
886
+ buckets.select { |_, fields| required.all? { |f| fields[f] } }
887
+ end
888
+
889
+ # `key.properties` is INI-ish: key=value lines, `#` comments, blank
890
+ # lines OK. The Flutter docs explicitly use four keys: storeFile,
891
+ # storePassword, keyAlias, keyPassword. We deliberately don't pull in
892
+ # an ini-parser gem — the format is two lines of regex.
893
+ #
894
+ # Strips a single leading + single trailing matching quote pair after
895
+ # `.strip` — `storePassword="my pw"` is valid `.properties` syntax used
896
+ # in many Android docs examples; without this, the literal 8-char string
897
+ # `"my pw"` (quotes included) flows through to apksigner and is rejected.
898
+ # Mirrors the `prompt` helper's de-quoting for parity.
899
+ def parse_key_properties(path)
900
+ out = {}
901
+ File.foreach(path) do |line|
902
+ line = line.strip
903
+ next if line.empty? || line.start_with?('#', ';')
904
+
905
+ key, _, value = line.partition('=')
906
+ next if value.empty?
907
+
908
+ value = value.strip.gsub(/\A['"]|['"]\z/, '')
909
+ out[key.strip.to_sym] = value
910
+ end
911
+ out
912
+ rescue StandardError
913
+ {}
914
+ end
915
+
916
+ # eas.json — Expo's android keystore lives at:
917
+ # credentials.android.keystore.{keystorePath, keystorePassword, keyAlias, keyPassword}
918
+ # Same profile-walk as the Play SA-JSON variant: production → preview → default.
919
+ def extract_keystore_from_eas!(pieces, eas_path, dir)
920
+ json = JSON.parse(File.read(eas_path))
921
+ ks = json.dig('credentials', 'android', 'keystore')
922
+ return false unless ks.is_a?(Hash)
923
+
924
+ rel_path = ks['keystorePath']
925
+ return false if rel_path.nil? || rel_path.to_s.strip.empty?
926
+
927
+ resolved = File.expand_path(rel_path, dir)
928
+ return false unless File.exist?(resolved)
929
+
930
+ layer_android_piece!(pieces, :path, resolved, source: :disk)
931
+ layer_android_piece!(pieces, :keystore_password, ks['keystorePassword'], source: :disk)
932
+ layer_android_piece!(pieces, :key_alias, ks['keyAlias'], source: :disk)
933
+ layer_android_piece!(pieces, :key_password, ks['keyPassword'], source: :disk)
934
+ true
935
+ rescue JSON::ParserError
936
+ false
937
+ end
938
+
939
+ def fetch_keystore_envelope!(id)
940
+ require 'mysigner/local_credentials'
941
+ raw = Mysigner::LocalCredentials.fetch(kind: :android_keystore, id: id)
942
+ if raw.nil?
943
+ raise CredentialNotFoundError,
944
+ "Keychain index lists Android keystore `#{id}` but the secret is missing. " \
945
+ 'Re-store with `mysigner onboard --local-only`.'
946
+ end
947
+
948
+ parsed = begin
949
+ JSON.parse(raw)
950
+ rescue JSON::ParserError => e
951
+ raise CredentialNotFoundError,
952
+ "Android keystore Keychain entry `#{id}` is not valid JSON (#{e.message}). " \
953
+ 'Re-store with `mysigner onboard --local-only`.'
954
+ end
955
+
956
+ unless parsed.is_a?(Hash) && parsed['keystore_b64']
957
+ raise CredentialNotFoundError,
958
+ "Android keystore Keychain entry `#{id}` is missing required `keystore_b64`. " \
959
+ 'Re-store with `mysigner onboard --local-only`.'
960
+ end
961
+
962
+ parsed
963
+ end
964
+
965
+ # WHY Tempfile (not Tempfile.create) + held on the Struct: the build
966
+ # pipeline runs in the same Ruby process, so the Tempfile finalizer
967
+ # cleans up at process exit. If we used Tempfile.create with a block,
968
+ # the file would unlink immediately. If we just dropped the reference,
969
+ # GC could unlink mid-build. Holding it on the Struct ties lifetime
970
+ # to the caller's grip on AndroidKeystoreCreds.
971
+ def materialize_keystore_tmpfile!(b64, id)
972
+ bytes = Base64.strict_decode64(b64)
973
+ safe_id = id.to_s.gsub(/[^a-zA-Z0-9._-]/, '_')
974
+ tmp = Tempfile.new(["mysigner-keystore-#{safe_id}-", '.jks'])
975
+ tmp.binmode
976
+ tmp.write(bytes)
977
+ tmp.flush
978
+ File.chmod(0o600, tmp.path)
979
+ tmp
980
+ rescue ArgumentError => e
981
+ raise CredentialNotFoundError,
982
+ "Android keystore Keychain entry `#{id}` has invalid base64 (#{e.message}). " \
983
+ 'Re-store with `mysigner onboard --local-only`.'
984
+ end
985
+
986
+ def android_missing_pieces(pieces)
987
+ out = []
988
+ out << :path if pieces[:path].nil?
989
+ out << :keystore_password if pieces[:keystore_password].nil?
990
+ out << :key_alias if pieces[:key_alias].nil?
991
+ # key_password is optional — defaults to keystore_password when nil
992
+ # (long-standing Gradle convention). Don't force a separate prompt.
993
+ out
994
+ end
995
+
996
+ def fill_android_pieces_from_prompt!(pieces, missing, stdin:, stderr:)
997
+ if missing.include?(:path)
998
+ path = prompt(stderr, stdin, 'Path to your Android keystore (.jks / .keystore):')
999
+ pieces[:path] = File.expand_path(path)
1000
+ pieces[:source] ||= :prompt
1001
+ unless File.exist?(pieces[:path])
1002
+ raise CredentialNotFoundError,
1003
+ "Android keystore not found at #{pieces[:path]} (from prompt). " \
1004
+ 'Check the path and try again.'
1005
+ end
1006
+ end
1007
+ if missing.include?(:keystore_password)
1008
+ pieces[:keystore_password] = prompt_password(stderr, stdin, 'Keystore password:')
1009
+ # Ask for an optional distinct key_password. Without this, users
1010
+ # whose key has a different password than the keystore get a silent
1011
+ # fuse to keystore_password (via the final `||` defaulting in
1012
+ # `resolve_android_keystore`) and a downstream apksigner failure
1013
+ # ("incorrect key password") with no hint. Empty input → keep nil
1014
+ # so the existing default kicks in.
1015
+ if pieces[:key_password].nil?
1016
+ key_pw = prompt_password(stderr, stdin, 'Key password (press Enter to reuse keystore password):')
1017
+ pieces[:key_password] = key_pw unless key_pw.nil? || key_pw.empty?
1018
+ end
1019
+ end
1020
+ return unless missing.include?(:key_alias)
1021
+
1022
+ pieces[:key_alias] = prompt(stderr, stdin, 'Key alias:')
1023
+ end
1024
+
1025
+ # Password prompt — disables terminal echo so the password doesn't show
1026
+ # in the user's scrollback. Falls back to a plain prompt when echo
1027
+ # control isn't available (non-TTY tests, weird terminals). The prompt
1028
+ # is gated by the same TTY check the caller already does, so the
1029
+ # fallback path is mostly for instance_double stdin in specs.
1030
+ def prompt_password(stderr, stdin, label)
1031
+ # `noecho` lives in io/console — load lazily so test doubles that
1032
+ # don't implement it aren't required to.
1033
+ begin
1034
+ require 'io/console'
1035
+ rescue LoadError
1036
+ # If io/console is unavailable, fall back silently.
1037
+ end
1038
+
1039
+ return prompt(stderr, stdin, label) unless stdin.respond_to?(:noecho)
1040
+
1041
+ stderr.print "#{label} " if stderr.respond_to?(:print)
1042
+ value = stdin.noecho(&:gets).to_s.strip
1043
+ stderr.puts if stderr.respond_to?(:puts)
1044
+ value
1045
+ rescue IOError
1046
+ prompt(stderr, stdin, label)
1047
+ end
1048
+
1049
+ def expand_or_nil(path)
1050
+ return nil if path.nil?
1051
+
1052
+ File.expand_path(path)
1053
+ end
1054
+
1055
+ def android_keystore_not_found_message(tried:, missing:)
1056
+ missing_labels = missing.map { |m| ANDROID_MISSING_LABELS[m] }
1057
+
1058
+ <<~MSG.strip
1059
+ No usable Android keystore found (missing: #{missing_labels.join(', ')}).
1060
+
1061
+ Tried in order:
1062
+ #{tried.map { |t| " - #{t}" }.join("\n")}
1063
+
1064
+ To fix, set ANY of these:
1065
+ * Per-command flags: --keystore-path PATH --keystore-password PWD
1066
+ --key-alias ALIAS --key-password PWD
1067
+ * Environment: MYSIGNER_KEYSTORE_PATH (or ANDROID_KEYSTORE_PATH)
1068
+ MYSIGNER_KEYSTORE_PASSWORD (or ANDROID_KEYSTORE_PASSWORD)
1069
+ MYSIGNER_KEY_ALIAS (or ANDROID_KEY_ALIAS)
1070
+ MYSIGNER_KEY_PASSWORD (or ANDROID_KEY_PASSWORD)
1071
+ * Onboard locally: mysigner onboard --local-only
1072
+ * Project file: android/key.properties (Flutter convention),
1073
+ android/keystore.properties, or key.properties at project root,
1074
+ with storeFile=, storePassword=, keyAlias=, keyPassword=,
1075
+ or credentials.android.keystore.* in eas.json,
1076
+ or inline signingConfigs.release in android/app/build.gradle[.kts]
1077
+ * Global gradle: ~/.gradle/gradle.properties with PREFIX_STORE_FILE,
1078
+ PREFIX_STORE_PASSWORD, PREFIX_KEY_ALIAS, PREFIX_KEY_PASSWORD
1079
+ MSG
1080
+ end
1081
+
1082
+ def play_not_found_message(tried:)
1083
+ <<~MSG.strip
1084
+ No usable Google Play credentials found.
1085
+
1086
+ Tried in order:
1087
+ #{tried.map { |t| " - #{t}" }.join("\n")}
1088
+
1089
+ To fix, set ANY of these:
1090
+ * Per-command flag: --play-credentials PATH
1091
+ * Environment: GOOGLE_APPLICATION_CREDENTIALS
1092
+ * Onboard locally: mysigner onboard --local-only
1093
+ * Project file: place service-account.json (or play-credentials.json) at your project root,
1094
+ or add submit.<profile>.android.serviceAccountKeyPath to eas.json
1095
+ MSG
1096
+ end
1097
+ end
1098
+ end
1099
+ end