mysigner 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0f998d494e2fc03d10bf8efc81bdca8675e5ff6037860a8acf750e66b5f2c2bb
4
- data.tar.gz: 8c5d7bf17a9cc3556748c6b49106a57ece2f4044e61bfc5c85287e9e7d243d5e
3
+ metadata.gz: a4f9aa0a3421715b8f46fdadb8325195ed5d6ef145f2ecd73df124733b7f3f9b
4
+ data.tar.gz: 9d53d040cbcdb39bb2387262fb989d6922f33d8ec10cb7f9709831ef07900c44
5
5
  SHA512:
6
- metadata.gz: 3c3929aaca39e935b3d2cb2030320e1955aa24f443d5f17d1bb9f2d996ef99fb10af71dd910fffb5f1c49293a23f90d4071d3055c47e82fb529f27655f29c926
7
- data.tar.gz: 3c7a4976b73851217a9f1a892c0b6d7a2e43e15ea0678d61bc7edf2d790d634822bd843c2482760b673c692edaa39d12eab41922e5c59d011854c9835493c845
6
+ metadata.gz: c9dc5c8927301802a68cbd26938a1f04db2a77662529a814bfeb079236c6406271481b4856154bf81e83a4a0cad62463108636e25829a76ed5a2837b16fd8911
7
+ data.tar.gz: 27df131dcd35996606908032114c54b431ff651cfdeab6682a7109bcb22f9a8f6ea6074b9730918d5de1220b54b2e5e657a6f9d43de3dec3c129157002cd9b1a
data/.gitignore CHANGED
@@ -40,3 +40,7 @@ service-account*.json
40
40
  /.ruby-lsp/
41
41
  *.swp
42
42
  *~
43
+
44
+ # Local design docs / plans (superpowers skill output — kept local-only)
45
+ /docs/superpowers/
46
+ CLAUDE.md
data/CHANGELOG.md CHANGED
@@ -97,6 +97,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
97
97
 
98
98
  ---
99
99
 
100
+ ## [0.3.0] - 2026-05-28
101
+
102
+ ### Added
103
+ - Persistent local-only mode: `mysigner config set local-only true` writes to `~/.mysigner/config.yml`, no flag repetition required ([mysigner-22](https://mysigner.youtrack.cloud/issue/mysigner-22) follow-up).
104
+ - `mysigner config set <key> <value>` — extensible CLI knob for tweaking `~/.mysigner/config.yml`. Settable keys: `local-only`.
105
+ - `Helpers#exit_unless_local_supported!` — every MySigner-only command now exits cleanly with an explanation when local-only is active, instead of the generic "Not logged in" error.
106
+
107
+ ### Changed
108
+ - `doctor` now announces local-only mode and skips MySigner-side checks instead of reporting "Not logged in" as an issue.
109
+ - `status` prints a local-mode credential-discovery summary when local-only is active.
110
+ - `validate` runs the local `Signing::Validator` (same one used by `ship`) and skips the server POST when local-only is active.
111
+ - `--local-only` Thor class_option no longer defaults to `false` — `--no-local-only` now correctly overrides the env var and the new persistent file setting.
112
+
113
+ ### Fixed
114
+ - Precedence bug: with `MYSIGNER_LOCAL_ONLY=1` in the environment, `--no-local-only` did not actually disable local-only mode for that invocation.
115
+
116
+ ---
117
+
100
118
  ## [0.2.0] - 2026-05-26
101
119
 
102
120
  ### Added — `--local-only` mode
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- mysigner (0.2.0)
4
+ mysigner (0.3.0)
5
5
  base64 (~> 0.2)
6
6
  faraday (~> 2.14)
7
7
  faraday-retry (~> 2.2)
data/README.md CHANGED
@@ -338,6 +338,47 @@ MYSIGNER_LOCAL_ONLY=1 mysigner ship testflight
338
338
  | CI/CD setup | API token in CI secrets | Pre-populate `~/.mysigner/credentials/` from a secret store |
339
339
  | Revocation surface | Revoke at server (audit log) | Wipe the Keychain entry / local file |
340
340
 
341
+ ### Enabling local-only mode persistently
342
+
343
+ Set it once per machine — no flag repetition required:
344
+
345
+ ```bash
346
+ mysigner config set local-only true
347
+ ```
348
+
349
+ This writes `local_only: true` to `~/.mysigner/config.yml`. Every subsequent command behaves as if `--local-only` was passed.
350
+
351
+ **Precedence (most specific wins):**
352
+
353
+ 1. `--local-only` / `--no-local-only` on the command line
354
+ 2. `MYSIGNER_LOCAL_ONLY=1` in the environment
355
+ 3. `local_only: true` in `~/.mysigner/config.yml`
356
+ 4. Default off
357
+
358
+ To temporarily override the persistent setting:
359
+
360
+ ```bash
361
+ mysigner --no-local-only sync # one-off server hit
362
+ mysigner config set local-only false # permanent disable
363
+ ```
364
+
365
+ ### What works locally
366
+
367
+ | Command | Local-only? |
368
+ |---|---|
369
+ | `ship testflight/appstore/internal/alpha/beta/production` | ✅ |
370
+ | `build`, `export`, `upload testflight` | ✅ |
371
+ | `onboard` | ✅ |
372
+ | `signing configure` | ✅ |
373
+ | `doctor`, `status`, `validate` | ✅ (limited — no MySigner-side checks) |
374
+ | `certificate check`, `device detect` | ✅ |
375
+ | `android init/add/build/list` | ✅ |
376
+ | `config`, `config set`, `version`, `help`, `tree`, `logout` | ✅ |
377
+ | `login`, `switch`, `orgs`, `sync` | ❌ MySigner-only |
378
+ | `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
+
380
+ Server-only commands in local-only mode exit 2 with a one-line explanation and the override hint.
381
+
341
382
  ### What local-only does NOT do (v1)
342
383
 
343
384
  Local-only mode in v1 guards the **API-call-time credentials** (the ASC JWT and the Google OAuth token). The `ship` pipelines still talk to My Signer for orchestration. Be honest with yourself about which of these matter for your threat model:
@@ -3,6 +3,8 @@
3
3
  module Mysigner
4
4
  class CLI < Thor
5
5
  module AuthCommands
6
+ SETTABLE_CONFIG_KEYS = %w[local-only].freeze
7
+
6
8
  def self.included(base)
7
9
  base.class_eval do
8
10
  desc 'version', 'Show version information'
@@ -32,6 +34,8 @@ module Mysigner
32
34
  grant access to the organization it was created in.
33
35
  DESC
34
36
  def login
37
+ exit_unless_local_supported!('login')
38
+
35
39
  # Check if already logged in
36
40
  config = Config.new
37
41
  if config.exists?
@@ -676,6 +680,8 @@ module Mysigner
676
680
 
677
681
  desc 'status', 'Check connection, credentials, and App Store Connect setup'
678
682
  def status
683
+ return status_local_only if local_only?
684
+
679
685
  config = load_config
680
686
 
681
687
  say '📊 My Signer Status', :cyan
@@ -750,6 +756,8 @@ module Mysigner
750
756
 
751
757
  desc 'orgs', "List all organizations you're a member of"
752
758
  def orgs
759
+ exit_unless_local_supported!('orgs')
760
+
753
761
  config = load_config
754
762
  client = create_client(config)
755
763
 
@@ -823,6 +831,8 @@ module Mysigner
823
831
  from different user accounts will be rejected.
824
832
  DESC
825
833
  def switch(target_org_id = nil)
834
+ exit_unless_local_supported!('switch')
835
+
826
836
  config = load_config
827
837
  client = create_client(config)
828
838
 
@@ -1000,27 +1010,122 @@ module Mysigner
1000
1010
  end
1001
1011
  end
1002
1012
 
1003
- desc 'config', 'Show current CLI configuration (API URL, tokens, org)'
1004
- def config
1005
- config = Config.new
1013
+ desc 'config [ACTION] [ARGS...]',
1014
+ 'Show or set CLI configuration (e.g. `mysigner config set local-only true`)'
1015
+ long_desc <<~DESC
1016
+ Without arguments: prints the current configuration.
1006
1017
 
1007
- unless config.exists?
1018
+ Set a value:
1019
+ mysigner config set local-only true
1020
+ mysigner config set local-only false
1021
+
1022
+ Settable keys: local-only
1023
+
1024
+ `set` does NOT require a MySigner login — it is the bootstrap path
1025
+ for users who want to use local-only mode from a fresh machine.
1026
+ DESC
1027
+ def config(action = nil, *args)
1028
+ return config_set(*args) if action == 'set'
1029
+
1030
+ if action && action != 'set'
1031
+ error "Unknown config action: #{action}"
1032
+ say 'Did you mean: `mysigner config set <key> <value>`?', :yellow
1033
+ exit 1
1034
+ end
1035
+
1036
+ cfg = Config.new
1037
+
1038
+ unless cfg.exists?
1008
1039
  error "No configuration found. Run 'mysigner login' first."
1009
1040
  exit 1
1010
1041
  end
1011
1042
 
1012
- config.load
1043
+ cfg.load
1013
1044
 
1014
1045
  say '⚙️ Configuration', :cyan
1015
1046
  say ''
1016
- config.display.each do |key, value|
1017
- say " #{key.to_s.ljust(20)}: #{value}"
1047
+ cfg.display.each do |key, val|
1048
+ say " #{key.to_s.ljust(20)}: #{val}"
1018
1049
  end
1050
+ say " #{'local-only'.ljust(20)}: #{cfg.local_only?}"
1019
1051
  say ''
1020
1052
  say "Config file: #{Config::CONFIG_FILE}"
1021
1053
  end
1022
1054
 
1023
1055
  no_commands do
1056
+ def status_local_only
1057
+ require 'mysigner/credential_resolver'
1058
+
1059
+ source = if options[:local_only] == true
1060
+ '--local-only flag'
1061
+ elsif ENV['MYSIGNER_LOCAL_ONLY'] && !ENV['MYSIGNER_LOCAL_ONLY'].empty?
1062
+ 'MYSIGNER_LOCAL_ONLY env var'
1063
+ else
1064
+ 'config file (~/.mysigner/config.yml)'
1065
+ end
1066
+
1067
+ say '📡 My Signer Status', :cyan
1068
+ say '=' * 50, :cyan
1069
+ say ''
1070
+ say 'Local-only mode: ENABLED', :green
1071
+ say " Source: #{source}"
1072
+ say ''
1073
+ say 'Credential discovery:'
1074
+ say " ASC keys: #{count_or_dash { Mysigner::CredentialResolver.resolve_asc(options: options.to_h) }}"
1075
+ say " Play SA-JSON: #{count_or_dash { Mysigner::CredentialResolver.resolve_play(options: options.to_h) }}"
1076
+ say " Android keystore: #{count_or_dash { Mysigner::CredentialResolver.resolve_android_keystore(options: options.to_h) }}"
1077
+ say ''
1078
+ say "Config file: #{Mysigner::Config::CONFIG_FILE}"
1079
+ end
1080
+
1081
+ def count_or_dash
1082
+ yield
1083
+ '1 (discovered)'
1084
+ rescue Mysigner::CredentialResolver::CredentialNotFoundError
1085
+ '0'
1086
+ rescue Mysigner::CredentialResolver::AmbiguousCredentialsError
1087
+ '2+ (ambiguous — pass via flag)'
1088
+ rescue StandardError => e
1089
+ warn "[mysigner] Unexpected error probing credentials: #{e.class}: #{e.message}"
1090
+ '?'
1091
+ end
1092
+
1093
+ def config_set(key = nil, value = nil)
1094
+ if key.nil? || value.nil?
1095
+ error 'Usage: mysigner config set <key> <value>'
1096
+ say "Settable keys: #{SETTABLE_CONFIG_KEYS.join(', ')}", :yellow
1097
+ exit 1
1098
+ end
1099
+
1100
+ unless SETTABLE_CONFIG_KEYS.include?(key)
1101
+ error "Unknown config key: #{key}"
1102
+ say "Settable keys: #{SETTABLE_CONFIG_KEYS.join(', ')}", :yellow
1103
+ exit 1
1104
+ end
1105
+
1106
+ case key
1107
+ when 'local-only'
1108
+ bool = parse_bool_or_exit(value, key)
1109
+ cfg = Config.new
1110
+ cfg.load if cfg.exists?
1111
+ cfg.local_only = bool
1112
+ cfg.save
1113
+ say "✓ Saved #{key}: #{bool}", :green
1114
+ say " #{key}: #{bool}"
1115
+ end
1116
+ end
1117
+
1118
+ def parse_bool_or_exit(value, key)
1119
+ case value.to_s.downcase
1120
+ when 'true', '1', 'yes' then true
1121
+ when 'false', '0', 'no' then false
1122
+ else
1123
+ error "Invalid boolean for #{key}: #{value}"
1124
+ say 'Use: true / false (also accepts 1/0, yes/no)', :yellow
1125
+ exit 1
1126
+ end
1127
+ end
1128
+
1024
1129
  # Helper method for yes/no prompts with Enter defaulting to yes.
1025
1130
  # Defaults to NO when stdin is not a TTY so automation (CI, pipes)
1026
1131
  # never silently opts-in to mutating operations.
@@ -2222,6 +2222,8 @@ module Mysigner
2222
2222
  method_option :auto_submit, type: :boolean,
2223
2223
  desc: 'Submit for review. Defaults to dashboard CLI Defaults, else true. Use --no-auto-submit to skip.'
2224
2224
  def submit(track = nil)
2225
+ exit_unless_local_supported!('submit')
2226
+
2225
2227
  config = load_config
2226
2228
  client = create_client(config)
2227
2229
 
@@ -139,6 +139,10 @@ module Mysigner
139
139
  config.instance_variable_set(:@organizations, {})
140
140
  config.instance_variable_set(:@encryption_enabled, false)
141
141
  config.instance_variable_set(:@from_env, false)
142
+ # The whole point of this sentinel is to BE a local-only config —
143
+ # set @local_only = true so `config.local_only?` (and any caller
144
+ # reading `config.local_only`) agrees.
145
+ config.instance_variable_set(:@local_only, true)
142
146
  config
143
147
  end
144
148
 
@@ -146,11 +150,15 @@ module Mysigner
146
150
  say "✗ Error: #{message}", :red
147
151
  end
148
152
 
149
- # Local-only mode is active if either the --local-only flag is set
150
- # on this invocation OR MYSIGNER_LOCAL_ONLY is truthy in ENV.
151
- # Subsequent tickets gate credential-sending behavior on this.
153
+ # Local-only mode is active when any of, in precedence order:
154
+ # 1. --local-only / --no-local-only flag on this invocation
155
+ # 2. MYSIGNER_LOCAL_ONLY env var
156
+ # 3. `local_only: true` in ~/.mysigner/config.yml
157
+ # `Config.local_only?` (class method) walks #2 then #3.
152
158
  def local_only?
153
- options[:local_only] || Mysigner::Config.local_only?
159
+ return options[:local_only] unless options[:local_only].nil?
160
+
161
+ Mysigner::Config.local_only?
154
162
  end
155
163
 
156
164
  # mysigner-22 Phase 5 — resolve ASC creds via the cascade (flag →
@@ -222,6 +230,22 @@ module Mysigner
222
230
  '(other MySigner APIs may still be used; see docs).'
223
231
  end
224
232
 
233
+ # Server-only command guard. SERVER commands (apps, orgs, sync,
234
+ # certificates, etc.) hit MySigner-side resources and have no
235
+ # local equivalent. Print a clean explanation and exit 2 when
236
+ # local-only mode is active, instead of letting load_config bail
237
+ # with the generic "Not logged in" path.
238
+ def exit_unless_local_supported!(command_name)
239
+ return unless local_only?
240
+
241
+ say "✗ `#{command_name}` manages MySigner-side resources and " \
242
+ "isn't available in local-only mode.", :red
243
+ say ''
244
+ say 'Disable persistently: mysigner config set local-only false', :yellow
245
+ say "Override for one call: mysigner --no-local-only #{command_name}", :yellow
246
+ exit 2
247
+ end
248
+
225
249
  class << self
226
250
  def banner_emitted?
227
251
  @local_only_banner_emitted == true
@@ -77,48 +77,54 @@ module Mysigner
77
77
  say ''
78
78
 
79
79
  # Check 4: My Signer Configuration
80
- say 'Checking My Signer configuration...', :yellow
81
80
  client = nil
82
81
  org_data = nil
83
82
 
84
- config = if Config.env_configured?
85
- Config.from_env
86
- else
87
- Config.new
88
- end
83
+ say 'Checking My Signer configuration...', :yellow
89
84
 
90
- if config.from_env? || config.exists?
91
- config.load unless config.from_env?
92
- say config.from_env? ? ' ✓ Configured via environment variables' : ' ✓ Logged in', :green
85
+ if local_only?
86
+ say ' ✓ Local-only mode active — MySigner login not required', :green
87
+ else
88
+ config = if Config.env_configured?
89
+ Config.from_env
90
+ else
91
+ Config.new
92
+ end
93
93
 
94
- begin
95
- client = Client.new(api_url: config.api_url, api_token: config.api_token,
96
- user_email: config.user_email)
97
- client.test_connection
98
- say ' ✓ API connection working', :green
99
-
100
- # Get organization details
101
- if config.current_organization_id
102
- org_response = client.get("/api/v1/organizations/#{config.current_organization_id}")
103
- org_data = org_response[:data]
94
+ if config.from_env? || config.exists?
95
+ config.load unless config.from_env?
96
+ say config.from_env? ? ' ✓ Configured via environment variables' : ' ✓ Logged in', :green
97
+
98
+ begin
99
+ client = Client.new(api_url: config.api_url, api_token: config.api_token,
100
+ user_email: config.user_email)
101
+ client.test_connection
102
+ say ' ✓ API connection working', :green
103
+
104
+ # Get organization details
105
+ if config.current_organization_id
106
+ org_response = client.get("/api/v1/organizations/#{config.current_organization_id}")
107
+ org_data = org_response[:data]
108
+ end
109
+ rescue Mysigner::UnauthorizedError
110
+ say ' ✗ Token is invalid or expired', :red
111
+ issues << "Token authentication failed - run 'mysigner onboard' to re-authenticate"
112
+ client = nil
113
+ rescue Mysigner::ConnectionError => e
114
+ say " ✗ Cannot connect to API: #{e.message}", :red
115
+ issues << 'API connection failed - check your network or API URL'
116
+ client = nil
117
+ rescue StandardError => e
118
+ say " ✗ API error: #{e.message}", :red
119
+ issues << 'API connection failed - check your configuration'
120
+ client = nil
104
121
  end
105
- rescue Mysigner::UnauthorizedError
106
- say ' ✗ Token is invalid or expired', :red
107
- issues << "Token authentication failed - run 'mysigner onboard' to re-authenticate"
108
- client = nil
109
- rescue Mysigner::ConnectionError => e
110
- say " ✗ Cannot connect to API: #{e.message}", :red
111
- issues << 'API connection failed - check your network or API URL'
112
- client = nil
113
- rescue StandardError => e
114
- say " ✗ API error: #{e.message}", :red
115
- issues << 'API connection failed - check your configuration'
116
- client = nil
122
+ else
123
+ say ' ✗ Not logged in', :red
124
+ issues << "Run 'mysigner onboard' to authenticate"
117
125
  end
118
- else
119
- say ' ✗ Not logged in', :red
120
- issues << "Run 'mysigner onboard' to authenticate"
121
126
  end
127
+
122
128
  say ''
123
129
 
124
130
  # Check 4a: Signing Identity in Keychain (CRITICAL)
@@ -446,7 +452,11 @@ module Mysigner
446
452
  end
447
453
  say ''
448
454
  elsif project_info && (!client || !org_data)
449
- say '⚠️ Project detected but cannot check signing (not logged in)', :yellow
455
+ if local_only?
456
+ say '⚠️ Project detected — local-only mode (signing checks limited).', :yellow
457
+ else
458
+ say '⚠️ Project detected but cannot check signing (not logged in)', :yellow
459
+ end
450
460
  say ''
451
461
  end
452
462
  end
@@ -893,6 +903,8 @@ module Mysigner
893
903
  DESC
894
904
  option :force, type: :boolean, aliases: '-f', desc: 'Force sync even if recently synced'
895
905
  def sync(platform = 'ios')
906
+ exit_unless_local_supported!('sync')
907
+
896
908
  config = load_config
897
909
  client = create_client(config)
898
910
 
@@ -12,6 +12,8 @@ module Mysigner
12
12
  method_option :page, type: :numeric, default: 1, desc: 'Page number'
13
13
  method_option :per_page, type: :numeric, default: 50, desc: 'Devices per page'
14
14
  def devices
15
+ exit_unless_local_supported!('devices')
16
+
15
17
  config = load_config
16
18
  client = create_client(config)
17
19
 
@@ -121,6 +123,7 @@ module Mysigner
121
123
  DESC
122
124
  method_option :platform, type: :string, default: 'IOS', aliases: '-p', desc: 'Platform (IOS, MAC_OS, TV_OS)'
123
125
  def device(action, *args)
126
+ exit_unless_local_supported!("device #{action}") unless action == 'detect'
124
127
  config = load_config
125
128
  client = create_client(config)
126
129
 
@@ -457,6 +460,8 @@ module Mysigner
457
460
  method_option :page, type: :numeric, default: 1, desc: 'Page number'
458
461
  method_option :per_page, type: :numeric, default: 50, desc: 'Profiles per page'
459
462
  def profiles
463
+ exit_unless_local_supported!('profiles')
464
+
460
465
  config = load_config
461
466
  client = create_client(config)
462
467
 
@@ -589,6 +594,7 @@ module Mysigner
589
594
 
590
595
  case action
591
596
  when 'download'
597
+ exit_unless_local_supported!('profile download')
592
598
  if args.empty?
593
599
  error 'Usage: mysigner profile download ID [--output path.mobileprovision]'
594
600
  say ''
@@ -703,6 +709,7 @@ module Mysigner
703
709
  exit 1
704
710
  end
705
711
  when 'delete'
712
+ exit_unless_local_supported!('profile delete')
706
713
  if args.empty?
707
714
  error 'Usage: mysigner profile delete ID'
708
715
  say ''
@@ -760,6 +767,8 @@ module Mysigner
760
767
  method_option :page, type: :numeric, default: 1, desc: 'Page number'
761
768
  method_option :per_page, type: :numeric, default: 50, desc: 'Certificates per page'
762
769
  def certificates
770
+ exit_unless_local_supported!('certificates')
771
+
763
772
  config = load_config
764
773
  client = create_client(config)
765
774
 
@@ -829,6 +838,7 @@ module Mysigner
829
838
  DESC
830
839
  method_option :output, type: :string, aliases: '-o', desc: 'Output file path (default: certificate name)'
831
840
  def certificate(action, *args)
841
+ exit_unless_local_supported!("certificate #{action}") unless action == 'check'
832
842
  config = load_config
833
843
  client = create_client(config)
834
844
 
@@ -1104,6 +1114,8 @@ module Mysigner
1104
1114
  method_option :app_id, type: :numeric, desc: 'Associate with Android app ID'
1105
1115
  method_option :output, type: :string, aliases: '-o', desc: 'Output path for download'
1106
1116
  def keystore(action, *args)
1117
+ exit_unless_local_supported!("keystore #{action}")
1118
+
1107
1119
  config = load_config
1108
1120
  client = create_client(config)
1109
1121
 
@@ -2217,6 +2229,8 @@ module Mysigner
2217
2229
  • After registering, run 'mysigner sync ios' to update local cache
2218
2230
  DESC
2219
2231
  def bundleid(action, *args)
2232
+ exit_unless_local_supported!("bundleid #{action}")
2233
+
2220
2234
  config = load_config
2221
2235
  client = create_client(config)
2222
2236
 
@@ -2419,6 +2433,8 @@ module Mysigner
2419
2433
  method_option :page, type: :numeric, default: 1, desc: 'Page number'
2420
2434
  method_option :per_page, type: :numeric, default: 50, desc: 'Apps per page'
2421
2435
  def apps
2436
+ exit_unless_local_supported!('apps')
2437
+
2422
2438
  config = load_config
2423
2439
  client = create_client(config)
2424
2440
 
@@ -2521,6 +2537,8 @@ module Mysigner
2521
2537
  method_option :page, type: :numeric, default: 1, desc: 'Page number'
2522
2538
  method_option :per_page, type: :numeric, default: 50, desc: 'Items per page'
2523
2539
  def merchant_ids
2540
+ exit_unless_local_supported!('merchant-ids')
2541
+
2524
2542
  config = load_config
2525
2543
  client = create_client(config)
2526
2544
 
@@ -2584,6 +2602,8 @@ module Mysigner
2584
2602
  DESC
2585
2603
  method_option :name, type: :string, aliases: '-n', desc: 'Friendly name for the Merchant ID'
2586
2604
  def merchant_id(action, identifier = nil)
2605
+ exit_unless_local_supported!("merchant-id #{action}")
2606
+
2587
2607
  config = load_config
2588
2608
  client = create_client(config)
2589
2609
 
@@ -2681,6 +2701,8 @@ module Mysigner
2681
2701
  desc 'tracks PACKAGE_NAME', 'List Google Play tracks for an Android app'
2682
2702
  method_option :sort, type: :boolean, desc: 'Sort by track name'
2683
2703
  def tracks(package_name = nil)
2704
+ exit_unless_local_supported!('tracks')
2705
+
2684
2706
  config = load_config
2685
2707
  client = create_client(config)
2686
2708
 
@@ -2754,6 +2776,8 @@ module Mysigner
2754
2776
 
2755
2777
  desc 'track PACKAGE_NAME TRACK_NAME', 'Show details for a specific Google Play track'
2756
2778
  def track(package_name = nil, track_name = nil)
2779
+ exit_unless_local_supported!('track')
2780
+
2757
2781
  config = load_config
2758
2782
  client = create_client(config)
2759
2783
 
@@ -2851,6 +2875,8 @@ module Mysigner
2851
2875
  method_option :page, type: :numeric, default: 1, desc: 'Page number'
2852
2876
  method_option :per_page, type: :numeric, default: 50, desc: 'Items per page'
2853
2877
  def app_groups
2878
+ exit_unless_local_supported!('app-groups')
2879
+
2854
2880
  config = load_config
2855
2881
  client = create_client(config)
2856
2882
 
@@ -2920,6 +2946,8 @@ module Mysigner
2920
2946
  DESC
2921
2947
  method_option :name, type: :string, aliases: '-n', desc: 'Friendly name for the App Group'
2922
2948
  def app_group(action, identifier = nil)
2949
+ exit_unless_local_supported!("app-group #{action}")
2950
+
2923
2951
  config = load_config
2924
2952
  client = create_client(config)
2925
2953
 
@@ -3055,6 +3083,8 @@ module Mysigner
3055
3083
  mysigner gp-credential delete 3
3056
3084
  DESC
3057
3085
  def gp_credential(action, *args)
3086
+ exit_unless_local_supported!("gp-credential #{action}")
3087
+
3058
3088
  config = load_config
3059
3089
  client = create_client(config)
3060
3090
 
@@ -3274,6 +3304,8 @@ module Mysigner
3274
3304
  method_option :release_type, type: :string, desc: 'Release type: manual, after_approval, scheduled'
3275
3305
  method_option :scheduled_date, type: :string, desc: 'Scheduled release date (ISO 8601)'
3276
3306
  def release(action, *args)
3307
+ exit_unless_local_supported!("release #{action}")
3308
+
3277
3309
  config = load_config
3278
3310
  client = create_client(config)
3279
3311
 
@@ -37,6 +37,8 @@ module Mysigner
37
37
  method_option :bundle_id, type: :string, aliases: '-b', desc: 'Bundle identifier (e.g., com.example.app)'
38
38
  method_option :type, type: :string, aliases: '-t', desc: 'Signing type: development, appstore, adhoc, inhouse'
39
39
  def validate
40
+ return validate_local_only if local_only?
41
+
40
42
  config = load_config
41
43
  client = create_client(config)
42
44
 
@@ -137,6 +139,34 @@ module Mysigner
137
139
  end
138
140
  end
139
141
 
142
+ no_commands do
143
+ def validate_local_only
144
+ say '🔍 Local-only validation', :cyan
145
+ say '=' * 50, :cyan
146
+ say ''
147
+ say 'Running local Signing::Validator (server checks skipped in local-only mode).', :yellow
148
+ say ''
149
+
150
+ project_info = Mysigner::Build::Detector.detect
151
+ parser = Mysigner::Build::Parser.new(project_info)
152
+ target_name = parser.main_target.name
153
+
154
+ validator = Mysigner::Signing::Validator.new(
155
+ parser, target_name, options[:configuration] || 'Release',
156
+ team_id: options[:team], local_only: true
157
+ )
158
+
159
+ begin
160
+ validator.validate!
161
+ rescue Mysigner::Signing::Validator::ValidationError => e
162
+ error e.message
163
+ exit 1
164
+ end
165
+
166
+ say '✓ Local validation passed.', :green
167
+ end
168
+ end
169
+
140
170
  private
141
171
 
142
172
  def detect_bundle_id_from_project
data/lib/mysigner/cli.rb CHANGED
@@ -25,7 +25,7 @@ Mysigner::Cleanup::PrivateKeysPurger.new.call
25
25
  module Mysigner
26
26
  class CLI < Thor
27
27
  class_option :verbose, type: :boolean, aliases: '-v', desc: 'Verbose output'
28
- class_option :local_only, type: :boolean, default: false,
28
+ class_option :local_only, type: :boolean,
29
29
  desc: 'Do not send credentials to the server (local-only mode)'
30
30
 
31
31
  def self.exit_on_failure?
@@ -25,7 +25,7 @@ module Mysigner
25
25
  ENV_ORG_ID = 'MYSIGNER_ORG_ID'
26
26
  ENV_LOCAL_ONLY = 'MYSIGNER_LOCAL_ONLY'
27
27
 
28
- attr_accessor :api_url, :user_email, :current_organization_id, :encryption_enabled
28
+ attr_accessor :api_url, :user_email, :current_organization_id, :encryption_enabled, :local_only
29
29
  attr_reader :organizations
30
30
 
31
31
  def initialize
@@ -34,6 +34,7 @@ module Mysigner
34
34
  @current_organization_id = nil
35
35
  @organizations = {}
36
36
  @encryption_enabled = true # Enable by default for security
37
+ @local_only = false
37
38
  @from_env = false
38
39
  load if exists?
39
40
  end
@@ -49,6 +50,7 @@ module Mysigner
49
50
  config = allocate
50
51
  config.instance_variable_set(:@encryption_enabled, false)
51
52
  config.instance_variable_set(:@from_env, true)
53
+ config.instance_variable_set(:@local_only, false)
52
54
 
53
55
  org_id = ENV.fetch(ENV_ORG_ID, nil)
54
56
  token = ENV.fetch(ENV_API_TOKEN, nil)
@@ -67,15 +69,30 @@ module Mysigner
67
69
  @from_env
68
70
  end
69
71
 
70
- # Local-only mode: when true, credentials never leave the machine.
71
- # Config-level check sees only ENV — Thor's --local-only flag is layered
72
- # on top in the CLI Helpers concern (which can read `options`).
72
+ # Local-only mode at the Config level: cascade ENV → file. The CLI
73
+ # Helpers concern layers --local-only / --no-local-only on top.
73
74
  def self.local_only?
74
- truthy_env?(ENV_LOCAL_ONLY)
75
+ truthy_env?(ENV_LOCAL_ONLY) || local_only_from_file?
75
76
  end
76
77
 
78
+ # Lightweight check that reads only ~/.mysigner/config.yml's
79
+ # `local_only:` key without invoking #load (which decrypts tokens
80
+ # via the Keychain). A user with no MySigner account still needs
81
+ # to flip this setting, so we never raise on a missing/corrupt
82
+ # or absent file — we just return false.
83
+ def self.local_only_from_file?
84
+ data = YAML.safe_load_file(CONFIG_FILE)
85
+ data.is_a?(Hash) && data['local_only'] == true
86
+ rescue Errno::ENOENT, Psych::SyntaxError
87
+ false
88
+ end
89
+
90
+ # Instance-level predicate. Merges two surfaces:
91
+ # - @local_only: set to true by Helpers#blank_local_only_config so a
92
+ # sentinel config always answers true without touching ENV or disk.
93
+ # - self.class.local_only?: the normal ENV → file cascade.
77
94
  def local_only?
78
- self.class.local_only?
95
+ @local_only || self.class.local_only?
79
96
  end
80
97
 
81
98
  # Get API token for current organization (or specific org)
@@ -135,9 +152,10 @@ module Mysigner
135
152
 
136
153
  # mysigner-51 — safe_load_file rejects arbitrary Ruby object
137
154
  # instantiation in the YAML (`!ruby/object:Foo` etc.). The config
138
- # shape is just String/Integer/Hash (api_url, user_email,
139
- # current_organization_id, organizations: {id => {name, token}}),
140
- # all in safe_load's default allowed set, so no permitted_classes
155
+ # shape is just String/Integer/Boolean/Hash (api_url, user_email,
156
+ # current_organization_id, local_only, organizations: {id => {name,
157
+ # token}}), all in safe_load's default allowed set, so no
158
+ # permitted_classes
141
159
  # extension is needed. Low risk (the file is 0600 and user-owned)
142
160
  # but cheap hardening against a future RCE if config write or read
143
161
  # ever moves outside the owner-only assumption.
@@ -147,6 +165,7 @@ module Mysigner
147
165
  @user_email = data['user_email']
148
166
  @current_organization_id = data['current_organization_id']
149
167
  @organizations = data['organizations'] || {}
168
+ @local_only = data['local_only'] == true
150
169
 
151
170
  # Auto-detect encryption from config
152
171
  @encryption_enabled = encrypted_config?
@@ -164,7 +183,8 @@ module Mysigner
164
183
  'api_url' => @api_url,
165
184
  'user_email' => @user_email,
166
185
  'current_organization_id' => @current_organization_id,
167
- 'organizations' => @organizations
186
+ 'organizations' => @organizations,
187
+ 'local_only' => @local_only
168
188
  }
169
189
 
170
190
  File.write(CONFIG_FILE, data.to_yaml)
@@ -180,6 +200,7 @@ module Mysigner
180
200
  @user_email = nil
181
201
  @current_organization_id = nil
182
202
  @organizations = {}
203
+ @local_only = false
183
204
 
184
205
  File.delete(CONFIG_FILE) if exists?
185
206
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Mysigner
4
- VERSION = '0.2.0'
4
+ VERSION = '0.3.0'
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: mysigner
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jurgen Leka