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.
@@ -210,6 +210,16 @@ module Mysigner
210
210
  4. Configure your CLI
211
211
  DESC
212
212
  def onboard
213
+ # mysigner-44 — local-only mode short-circuits the server-mediated
214
+ # onboarding entirely. We never POST credentials and never ask the
215
+ # user for an API token; everything is captured into the local
216
+ # Keychain-backed store. The server-mediated path below is left
217
+ # untouched for backward compatibility.
218
+ if local_only?
219
+ emit_local_only_banner
220
+ return onboard_local_only
221
+ end
222
+
213
223
  say '🚀 My Signer Setup Guide', :cyan
214
224
  say '=' * 80, :cyan
215
225
  say ''
@@ -595,6 +605,27 @@ module Mysigner
595
605
  end
596
606
 
597
607
  desc 'logout', 'Log out and clear stored credentials'
608
+ long_desc <<~DESC
609
+ Log out of MySigner. Always clears local CLI config.
610
+
611
+ By default, ALSO asks whether to delete your stored credentials
612
+ (App Store Connect .p8 keys, Apple Search Ads keys, Google Play
613
+ service-account JSON, Android keystores) on the server and in
614
+ your local Keychain. The prompt defaults to No — the safer
615
+ choice — because logging out and back in restores access to
616
+ them otherwise.
617
+
618
+ --purge Skip the prompt and DELETE the credentials.
619
+ --no-purge Skip the prompt and KEEP them on the server.
620
+
621
+ In non-interactive contexts (CI, pipes), the prompt defaults
622
+ to No as well, matching `yes_with_default?` elsewhere.
623
+
624
+ See docs/policy/credential-retention.md (server repo) for the
625
+ authoritative retention policy.
626
+ DESC
627
+ method_option :purge, type: :boolean, default: nil,
628
+ desc: 'Also delete stored credentials on the server and in local Keychain (skips prompt)'
598
629
  def logout
599
630
  config = Config.new
600
631
 
@@ -603,13 +634,44 @@ module Mysigner
603
634
  return
604
635
  end
605
636
 
606
- if yes?('Are you sure you want to logout? (y/n)')
607
- config.clear
608
- say '✓ Successfully logged out', :green
609
- say "Config file removed: #{Config::CONFIG_FILE}", :green
610
- else
637
+ # Preserve the existing "Are you sure?" gate. Tests pin this exact
638
+ # prompt and a No answer must abort the entire logout. With
639
+ # --purge / --no-purge a user has already declared intent, but
640
+ # we still require the top-level confirmation when interactive —
641
+ # less surprising than two layered changes in one release.
642
+ unless yes?('Are you sure you want to logout? (y/n)')
611
643
  say 'Logout cancelled', :yellow
644
+ return
645
+ end
646
+
647
+ # Now we know the local logout is happening. Decide whether to
648
+ # ALSO purge server + local-Keychain credentials. Resolution
649
+ # order: explicit flag > interactive prompt > non-TTY default No.
650
+ should_purge = resolve_purge_decision(options[:purge])
651
+
652
+ if should_purge
653
+ # Load the config so we have the api_url/token/email needed
654
+ # for the server call. Failure here is loud — we don't fall
655
+ # back to "local-only clear" silently, that would leave the
656
+ # user's server credentials on disk against their explicit
657
+ # request.
658
+ begin
659
+ config.load
660
+ purge_server_credentials(config)
661
+ purge_local_credentials
662
+ rescue Mysigner::ClientError, Mysigner::ConfigError => e
663
+ error "Failed to purge credentials on the server: #{e.message}"
664
+ say ''
665
+ say 'Local config was NOT cleared so you can retry. Options:', :yellow
666
+ say " • Re-run 'mysigner logout --purge' once the server is reachable", :yellow
667
+ say " • Run 'mysigner logout --no-purge' to log out locally only", :yellow
668
+ exit 1
669
+ end
612
670
  end
671
+
672
+ config.clear
673
+ say '✓ Successfully logged out', :green
674
+ say "Config file removed: #{Config::CONFIG_FILE}", :green
613
675
  end
614
676
 
615
677
  desc 'status', 'Check connection, credentials, and App Store Connect setup'
@@ -971,6 +1033,93 @@ module Mysigner
971
1033
  response.empty? || response == 'y' || response == 'yes'
972
1034
  end
973
1035
 
1036
+ # Default-NO variant of yes_with_default? — used when the
1037
+ # destructive answer must be opt-in (mysigner-47 logout purge).
1038
+ # Non-TTY also defaults to No so CI never silently wipes server
1039
+ # credentials. Only an explicit "y" or "yes" returns true.
1040
+ def no_default_yes?(statement, color = nil)
1041
+ unless $stdin.tty?
1042
+ say "#{statement} [y/N] (non-interactive: assuming no)", color
1043
+ return false
1044
+ end
1045
+ response = ask("#{statement} [y/N]", color).to_s.strip.downcase
1046
+ %w[y yes].include?(response)
1047
+ end
1048
+
1049
+ # mysigner-47 — resolve the purge decision for `mysigner logout`.
1050
+ # `flag` is options[:purge] (true / false / nil).
1051
+ # true → --purge passed; skip prompt, purge
1052
+ # false → --no-purge passed; skip prompt, keep
1053
+ # nil → no flag; ask the user (default No), CI defaults to No
1054
+ def resolve_purge_decision(flag)
1055
+ return flag unless flag.nil?
1056
+
1057
+ no_default_yes?(
1058
+ 'Also delete your stored credentials on the server? ' \
1059
+ 'They will be gone forever.',
1060
+ :yellow
1061
+ )
1062
+ end
1063
+
1064
+ # mysigner-47 — call DELETE /api/v1/organizations/:org/credentials
1065
+ # using the loaded config. Surfaces per-kind counts on success.
1066
+ # Raises Mysigner::ClientError on transport/auth failure so the
1067
+ # caller can decide whether to abort the local clear.
1068
+ def purge_server_credentials(config)
1069
+ org_id = config.current_organization_id
1070
+ if org_id.nil?
1071
+ say '⚠️ No active organization in local config; skipping server purge.', :yellow
1072
+ return
1073
+ end
1074
+
1075
+ client = Client.new(
1076
+ api_url: config.api_url,
1077
+ api_token: config.api_token,
1078
+ user_email: config.user_email
1079
+ )
1080
+
1081
+ say 'Deleting stored credentials on the server...', :yellow
1082
+ response = client.delete("/api/v1/organizations/#{org_id}/credentials")
1083
+
1084
+ deleted = response.dig(:data, 'deleted') || {}
1085
+ asc = deleted['asc'].to_i
1086
+ ads = deleted['apple_ads'].to_i
1087
+ gp = deleted['google_play'].to_i
1088
+ ks = deleted['android_keystore'].to_i
1089
+
1090
+ say '✓ Server credentials deleted:', :green
1091
+ say " • App Store Connect: #{asc}"
1092
+ say " • Apple Search Ads: #{ads}"
1093
+ say " • Google Play: #{gp}"
1094
+ say " • Android keystore: #{ks}"
1095
+ end
1096
+
1097
+ # mysigner-47 — wipe every locally stored credential the
1098
+ # LocalCredentials store knows about, across all four kinds.
1099
+ # We swallow per-entry deletion errors with a loud log line
1100
+ # rather than aborting (Rule 12 — fail loud, but a corrupted
1101
+ # Keychain entry must not block the rest of the wipe).
1102
+ def purge_local_credentials
1103
+ return unless defined?(Mysigner::LocalCredentials)
1104
+
1105
+ total = 0
1106
+ Mysigner::LocalCredentials::KINDS.each do |kind|
1107
+ ids = Mysigner::LocalCredentials.list(kind: kind)
1108
+ ids.each do |id|
1109
+ Mysigner::LocalCredentials.delete(kind: kind, id: id)
1110
+ total += 1
1111
+ rescue Mysigner::LocalCredentials::LocalCredentialsError => e
1112
+ say "⚠️ Failed to delete local credential #{kind}/#{id}: #{e.message}", :yellow
1113
+ end
1114
+ end
1115
+
1116
+ if total.positive?
1117
+ say "✓ Local Keychain / file credentials deleted: #{total}", :green
1118
+ else
1119
+ say 'No local-only credentials to delete.', :white
1120
+ end
1121
+ end
1122
+
974
1123
  # Helper method for App Store Connect credential setup
975
1124
  # Returns true if successfully configured, false otherwise
976
1125
  def setup_app_store_connect_credentials(client, _config, org_id)
@@ -1380,9 +1529,210 @@ module Mysigner
1380
1529
  false
1381
1530
  end
1382
1531
  end
1532
+
1533
+ # mysigner-44 — local-only onboarding. Captures ASC and/or Google
1534
+ # Play credentials and persists them via LocalCredentials (Keychain
1535
+ # on macOS, encrypted file fallback elsewhere). Never calls the
1536
+ # server. Raises LocalOnlyOnboardError on invalid input so callers
1537
+ # see the failure (Rule 12 — fail loud).
1538
+ def onboard_local_only
1539
+ say '🚀 My Signer Setup (local-only)', :cyan
1540
+ say '=' * 80, :cyan
1541
+ say ''
1542
+ say 'Local-only mode: credentials stay on this machine.', :bold
1543
+ say ''
1544
+
1545
+ # mysigner-22 Phase 5 — discovery first. If the user already
1546
+ # has credentials elsewhere (env vars, ~/.appstoreconnect, a
1547
+ # service-account JSON at the project root, GOOGLE_APPLICATION_
1548
+ # CREDENTIALS) we tell them they don't need to onboard for that
1549
+ # platform. We avoid prompting on discovery itself by using a
1550
+ # non-TTY stdin proxy, so the resolver fails fast on miss
1551
+ # instead of blocking the user.
1552
+ asc_already, asc_hint = discover_local_asc_silently
1553
+ play_already, play_hint = discover_local_play_silently
1554
+
1555
+ if asc_already
1556
+ say "✓ Detected App Store Connect credentials (#{asc_hint}). No onboarding needed.", :green
1557
+ say ''
1558
+ end
1559
+ if play_already
1560
+ say "✓ Detected Google Play credentials (#{play_hint}). No onboarding needed.", :green
1561
+ say ''
1562
+ end
1563
+
1564
+ stored_asc = []
1565
+ stored_play = []
1566
+
1567
+ if !asc_already && yes_with_default?('Set up App Store Connect credentials now?', :cyan)
1568
+ say ''
1569
+ stored_asc = collect_local_asc_credential
1570
+ end
1571
+ say ''
1572
+
1573
+ if !play_already && yes_with_default?('Set up Google Play credentials now?', :cyan)
1574
+ say ''
1575
+ stored_play = collect_local_google_play_credential
1576
+ end
1577
+
1578
+ say ''
1579
+ say '=' * 80, :green
1580
+ say '✓ Local-only setup complete.', :green
1581
+ say '=' * 80, :green
1582
+ say ''
1583
+ if stored_asc.empty? && stored_play.empty?
1584
+ say 'No credentials were stored.', :yellow
1585
+ say "Re-run 'mysigner --local-only onboard' when you're ready.", :yellow
1586
+ else
1587
+ say 'Stored credentials:', :cyan
1588
+ stored_asc.each { |id| say " • ASC key: #{id}" }
1589
+ stored_play.each { |id| say " • Google Play SA: #{id}" }
1590
+ say ''
1591
+ say 'To ship:', :bold
1592
+ say ' mysigner --local-only ship appstore' unless stored_asc.empty?
1593
+ say ' mysigner --local-only ship play' unless stored_play.empty?
1594
+ end
1595
+ say ''
1596
+ end
1597
+
1598
+ # Returns Array<String> of ids actually stored (empty on skip).
1599
+ def collect_local_asc_credential
1600
+ say '📱 App Store Connect (local-only)', :cyan
1601
+ say ''
1602
+
1603
+ p8_path = ask('Path to your .p8 private key:').to_s.strip.gsub(/^['"]|['"]$/, '')
1604
+ p8_path = File.expand_path(p8_path)
1605
+ raise_local_onboard_error!(".p8 file not found: #{p8_path}") unless File.exist?(p8_path)
1606
+
1607
+ p8_pem = File.read(p8_path)
1608
+ validate_p8_pem!(p8_pem)
1609
+
1610
+ # Auto-detect Key ID from filename (AuthKey_ABC123.p8 → ABC123).
1611
+ key_id = nil
1612
+ if File.basename(p8_path) =~ /AuthKey_([A-Z0-9]+)\.p8/i
1613
+ key_id = ::Regexp.last_match(1)
1614
+ say "✓ Auto-detected Key ID: #{key_id}", :green
1615
+ end
1616
+ key_id = ask('Enter your Key ID (e.g., ABC12345):').to_s.strip if key_id.nil? || key_id.empty?
1617
+ raise_local_onboard_error!('Key ID cannot be empty') if key_id.empty?
1618
+
1619
+ issuer_id = ask('Enter your Issuer ID (UUID):').to_s.strip
1620
+ raise_local_onboard_error!('Issuer ID cannot be empty') if issuer_id.empty?
1621
+
1622
+ # Storage shape matches mysigner-42's Option A: id == key_id,
1623
+ # secret is a JSON envelope so AscJwtMinter can reconstruct
1624
+ # (key_id, issuer_id, p8_pem) from one lookup.
1625
+ secret = JSON.generate('issuer_id' => issuer_id, 'p8_pem' => p8_pem)
1626
+ Mysigner::LocalCredentials.store(kind: :asc, id: key_id, secret: secret)
1627
+
1628
+ say '✓ ASC credential stored locally.', :green
1629
+ [key_id]
1630
+ end
1631
+
1632
+ # Returns Array<String> of ids actually stored (empty on skip).
1633
+ def collect_local_google_play_credential
1634
+ say '🤖 Google Play (local-only)', :cyan
1635
+ say ''
1636
+
1637
+ json_path = ask('Path to your service-account JSON:').to_s.strip.gsub(/^['"]|['"]$/, '')
1638
+ json_path = File.expand_path(json_path)
1639
+ raise_local_onboard_error!("SA-JSON file not found: #{json_path}") unless File.exist?(json_path)
1640
+
1641
+ raw = File.read(json_path)
1642
+ parsed = validate_sa_json!(raw)
1643
+ client_email = parsed['client_email']
1644
+
1645
+ Mysigner::LocalCredentials.store(kind: :google_play, id: client_email, secret: raw)
1646
+
1647
+ say "✓ Google Play credential stored locally (#{client_email}).", :green
1648
+ [client_email]
1649
+ end
1650
+
1651
+ # Verifies the file looks like an EC private key in the form
1652
+ # AscJwtMinter requires. Fails loud — any malformed input raises
1653
+ # before we touch the Keychain.
1654
+ def validate_p8_pem!(pem)
1655
+ key = OpenSSL::PKey.read(pem.to_s)
1656
+ return if key.is_a?(OpenSSL::PKey::EC)
1657
+
1658
+ raise_local_onboard_error!("invalid .p8: expected EC private key, got #{key.class}")
1659
+ rescue OpenSSL::PKey::PKeyError => e
1660
+ raise_local_onboard_error!("invalid .p8: #{e.message}")
1661
+ end
1662
+
1663
+ # Returns the parsed hash. Validates the three fields the
1664
+ # GoogleOauthMinter (and the SA JWT spec) actually need.
1665
+ def validate_sa_json!(raw)
1666
+ parsed = JSON.parse(raw)
1667
+ missing = []
1668
+ missing << "type=='service_account'" unless parsed['type'] == 'service_account'
1669
+ missing << 'client_email' if parsed['client_email'].to_s.empty?
1670
+ missing << 'private_key' if parsed['private_key'].to_s.empty?
1671
+ raise_local_onboard_error!("invalid service-account JSON (missing/wrong: #{missing.join(', ')})") if missing.any?
1672
+
1673
+ parsed
1674
+ rescue JSON::ParserError => e
1675
+ raise_local_onboard_error!("invalid service-account JSON: #{e.message}")
1676
+ end
1677
+
1678
+ def raise_local_onboard_error!(message)
1679
+ raise Mysigner::CLI::LocalOnlyOnboardError, message
1680
+ end
1681
+
1682
+ # mysigner-22 Phase 5 — silent ASC discovery for onboard. Returns
1683
+ # [bool, hint_string]. We feed the resolver a non-TTY stdin proxy
1684
+ # so the prompt tier is OFF — discovery either resolves from a
1685
+ # higher tier or returns false-with-no-hint. Any structural
1686
+ # surprises (Keychain corruption, multiple disk files needing
1687
+ # disambiguation) bubble out as "no, prompt for it" rather than
1688
+ # crashing the onboard flow.
1689
+ def discover_local_asc_silently
1690
+ require 'mysigner/credential_resolver'
1691
+ no_tty = Object.new
1692
+ def no_tty.tty?
1693
+ false
1694
+ end
1695
+ creds = Mysigner::CredentialResolver.resolve_asc(stdin: no_tty, stderr: StringIO.new)
1696
+ hint = case creds.source
1697
+ when :flag then 'from --asc-* flags'
1698
+ when :env then 'from APP_STORE_CONNECT_API_KEY_* env vars'
1699
+ when :keychain then "from Keychain (#{creds.key_id})"
1700
+ when :disk then "from #{Mysigner::CredentialResolver::APPLE_PRIVATE_KEYS_DIR}/AuthKey_#{creds.key_id}.p8"
1701
+ else 'from cascade'
1702
+ end
1703
+ [true, hint]
1704
+ rescue Mysigner::CredentialResolver::CredentialNotFoundError,
1705
+ Mysigner::CredentialResolver::AmbiguousCredentialsError
1706
+ [false, nil]
1707
+ end
1708
+
1709
+ def discover_local_play_silently
1710
+ require 'mysigner/credential_resolver'
1711
+ no_tty = Object.new
1712
+ def no_tty.tty?
1713
+ false
1714
+ end
1715
+ creds = Mysigner::CredentialResolver.resolve_play(stdin: no_tty, stderr: StringIO.new)
1716
+ hint = case creds.source
1717
+ when :flag then 'from --play-credentials flag'
1718
+ when :env then 'from GOOGLE_APPLICATION_CREDENTIALS'
1719
+ when :keychain then "from Keychain (#{creds.client_email})"
1720
+ when :disk then "from project (#{creds.client_email})"
1721
+ else 'from cascade'
1722
+ end
1723
+ [true, hint]
1724
+ rescue Mysigner::CredentialResolver::CredentialNotFoundError,
1725
+ Mysigner::CredentialResolver::AmbiguousCredentialsError
1726
+ [false, nil]
1727
+ end
1383
1728
  end
1384
1729
  end
1385
1730
  end
1386
1731
  end
1732
+
1733
+ # Raised by `onboard` in local-only mode when user input is unusable
1734
+ # (missing file, malformed PEM, malformed SA-JSON). Surfaces the failure
1735
+ # to the caller rather than silently writing a broken credential.
1736
+ class LocalOnlyOnboardError < StandardError; end
1387
1737
  end
1388
1738
  end