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.
@@ -69,6 +69,14 @@ module Mysigner
69
69
  end
70
70
 
71
71
  def load_config
72
+ # mysigner-22 — local-only mode is allowed to run with ZERO MySigner
73
+ # auth: no ~/.mysigner/config.yml, no API token, no login. Return a
74
+ # blank Config sentinel and skip both the load and the
75
+ # "Not logged in" exit. Callers must treat the returned config as
76
+ # opaque (no api_url, no api_token, no organization_id) and must
77
+ # also treat create_client(config) as returning nil.
78
+ return blank_local_only_config if local_only?
79
+
72
80
  # CI/CD mode: prefer environment variables when set
73
81
  return Config.from_env if Config.env_configured?
74
82
 
@@ -82,6 +90,19 @@ module Mysigner
82
90
  say ' export MYSIGNER_ORG_ID=your_org_id', :yellow
83
91
  say ' export MYSIGNER_API_URL=https://mysigner.dev # optional', :yellow
84
92
  say ' export MYSIGNER_EMAIL=you@example.com # optional', :yellow
93
+ say ''
94
+ # Discoverability: a first-time user who doesn't want a MySigner
95
+ # account at all (BYO-credentials, no server orchestration) needs
96
+ # to know `--local-only` exists. Without this tip the only error
97
+ # they see is "Not logged in", which strongly implies signup is
98
+ # the only path forward.
99
+ say 'Tip: To skip MySigner entirely and ship locally with your own', :yellow
100
+ say 'Apple/Google credentials, use --local-only:', :yellow
101
+ say ' mysigner --local-only ship appstore', :yellow
102
+ say ' (auto-discovers ASC .p8 from ~/.appstoreconnect/private_keys/,', :yellow
103
+ say ' Google Play SA-JSON from GOOGLE_APPLICATION_CREDENTIALS / eas.json,', :yellow
104
+ say ' keystore from key.properties / eas.json — or set them via flags / env.)', :yellow
105
+ say ' See "Local-only mode" section in README.', :yellow
85
106
  exit 1
86
107
  end
87
108
 
@@ -90,6 +111,12 @@ module Mysigner
90
111
  end
91
112
 
92
113
  def create_client(config)
114
+ # mysigner-22 — in local-only mode every MySigner API touchpoint is
115
+ # supposed to be bypassed at the call site. Returning nil here makes
116
+ # an accidental `client.get(...)` fail loud (NoMethodError on nil)
117
+ # rather than silently re-introducing a server hit.
118
+ return nil if local_only?
119
+
93
120
  Client.new(
94
121
  api_url: config.api_url,
95
122
  api_token: config.api_token,
@@ -97,9 +124,117 @@ module Mysigner
97
124
  )
98
125
  end
99
126
 
127
+ # mysigner-22 — a Config built without touching disk, ENV, or the
128
+ # encryption key. We deliberately bypass Config#initialize (which
129
+ # auto-loads ~/.mysigner/config.yml when it exists) because the whole
130
+ # point of local-only is to work on a machine where that file might
131
+ # not exist or might be unreadable (e.g. broken Keychain key).
132
+ # Callers only read `current_organization_id` / `api_url` / etc., all
133
+ # of which legitimately return nil here.
134
+ def blank_local_only_config
135
+ config = Config.allocate
136
+ config.instance_variable_set(:@api_url, nil)
137
+ config.instance_variable_set(:@user_email, nil)
138
+ config.instance_variable_set(:@current_organization_id, nil)
139
+ config.instance_variable_set(:@organizations, {})
140
+ config.instance_variable_set(:@encryption_enabled, false)
141
+ config.instance_variable_set(:@from_env, false)
142
+ config
143
+ end
144
+
100
145
  def error(message)
101
146
  say "✗ Error: #{message}", :red
102
147
  end
148
+
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.
152
+ def local_only?
153
+ options[:local_only] || Mysigner::Config.local_only?
154
+ end
155
+
156
+ # mysigner-22 Phase 5 — resolve ASC creds via the cascade (flag →
157
+ # env → keychain → ~/.appstoreconnect → prompt), surface a clear
158
+ # error and exit 1 on miss. Logs the winning source on stderr so
159
+ # CI runs leave an audit trail of where the credential came from.
160
+ # Returns a Mysigner::CredentialResolver::AscCreds struct.
161
+ def resolve_local_asc_creds_or_exit
162
+ require 'mysigner/credential_resolver'
163
+ creds = Mysigner::CredentialResolver.resolve_asc(options: options.to_h)
164
+ warn "[mysigner] ASC credentials source: #{creds.source}" if $stderr.respond_to?(:tty?) && $stderr.tty?
165
+ creds
166
+ rescue Mysigner::CredentialResolver::CredentialNotFoundError,
167
+ Mysigner::CredentialResolver::AmbiguousCredentialsError => e
168
+ # Preserve the historical wording the CLI specs were written
169
+ # against ("No local ASC credentials found") so users and tests
170
+ # have a stable identifier, while including the resolver's
171
+ # multi-line cascade trace + override knob list right after it.
172
+ say "✗ No local ASC credentials found via `mysigner onboard --local-only` or other sources:\n#{e.message}",
173
+ :red
174
+ exit 1
175
+ end
176
+
177
+ # mysigner-22 Phase 5 — Google Play counterpart of
178
+ # resolve_local_asc_creds_or_exit. Same semantics: pre-resolve so the
179
+ # CLI can fail fast before the build kicks off.
180
+ def resolve_local_play_creds_or_exit
181
+ require 'mysigner/credential_resolver'
182
+ creds = Mysigner::CredentialResolver.resolve_play(options: options.to_h)
183
+ warn "[mysigner] Google Play credentials source: #{creds.source}" if $stderr.respond_to?(:tty?) && $stderr.tty?
184
+ creds
185
+ rescue Mysigner::CredentialResolver::CredentialNotFoundError,
186
+ Mysigner::CredentialResolver::AmbiguousCredentialsError => e
187
+ say "✗ No local Google Play credentials found via `mysigner onboard --local-only` or other sources:\n#{e.message}",
188
+ :red
189
+ exit 1
190
+ end
191
+
192
+ # mysigner-22 Phase 7 — Android keystore counterpart of the ASC/Play
193
+ # resolvers. Pre-resolves the keystore (path + passwords + alias) via
194
+ # the cascade so `ship android --local-only` can skip the MySigner
195
+ # server entirely.
196
+ def resolve_local_android_keystore_or_exit
197
+ require 'mysigner/credential_resolver'
198
+ creds = Mysigner::CredentialResolver.resolve_android_keystore(options: options.to_h)
199
+ warn "[mysigner] Android keystore source: #{creds.source}" if $stderr.respond_to?(:tty?) && $stderr.tty?
200
+ creds
201
+ rescue Mysigner::CredentialResolver::CredentialNotFoundError,
202
+ Mysigner::CredentialResolver::AmbiguousCredentialsError => e
203
+ say "✗ No local Android keystore found:\n#{e.message}", :red
204
+ exit 1
205
+ end
206
+
207
+ # One-time banner on stderr (TTY only) so users know they've opted
208
+ # into local-only mode. Module-level guard ensures it fires at most
209
+ # once per CLI invocation, even if multiple commands call it.
210
+ # (Module instance var, not @@, to avoid the class-var smell — every
211
+ # instance method sees the same Helpers module object.)
212
+ def emit_local_only_banner
213
+ return if Helpers.banner_emitted?
214
+ return unless $stderr.respond_to?(:tty?) && $stderr.tty?
215
+
216
+ Helpers.mark_banner_emitted!
217
+ # Honest scope: credential transport is what local-only currently
218
+ # guards. Non-credential MySigner endpoints (app/build registry,
219
+ # keystore download, etc.) are still used. Documented in the
220
+ # local-only docs (mysigner-45).
221
+ warn '[mysigner] local-only mode active — signing credentials stay on this machine ' \
222
+ '(other MySigner APIs may still be used; see docs).'
223
+ end
224
+
225
+ class << self
226
+ def banner_emitted?
227
+ @local_only_banner_emitted == true
228
+ end
229
+
230
+ def mark_banner_emitted!
231
+ @local_only_banner_emitted = true
232
+ end
233
+
234
+ def reset_banner!
235
+ @local_only_banner_emitted = false
236
+ end
237
+ end
103
238
  end
104
239
  end
105
240
  end
data/lib/mysigner/cli.rb CHANGED
@@ -19,13 +19,14 @@ require_relative 'cleanup/private_keys_purger'
19
19
  # Phase 0: one-time cleanup of legacy plaintext .p8 files that older CLI
20
20
  # versions wrote to ~/.private_keys/ and ~/.appstoreconnect/private_keys/.
21
21
  # Idempotent — a marker file at ~/.mysigner/.private_keys_purged prevents
22
- # re-running. Skipped when MYSIGNER_USE_LEGACY_ASC=1 so users who opted
23
- # back into the legacy altool path keep their existing keys.
22
+ # re-running.
24
23
  Mysigner::Cleanup::PrivateKeysPurger.new.call
25
24
 
26
25
  module Mysigner
27
26
  class CLI < Thor
28
27
  class_option :verbose, type: :boolean, aliases: '-v', desc: 'Verbose output'
28
+ class_option :local_only, type: :boolean, default: false,
29
+ desc: 'Do not send credentials to the server (local-only mode)'
29
30
 
30
31
  def self.exit_on_failure?
31
32
  true
@@ -23,6 +23,7 @@ module Mysigner
23
23
  ENV_API_URL = 'MYSIGNER_API_URL'
24
24
  ENV_EMAIL = 'MYSIGNER_EMAIL'
25
25
  ENV_ORG_ID = 'MYSIGNER_ORG_ID'
26
+ ENV_LOCAL_ONLY = 'MYSIGNER_LOCAL_ONLY'
26
27
 
27
28
  attr_accessor :api_url, :user_email, :current_organization_id, :encryption_enabled
28
29
  attr_reader :organizations
@@ -66,6 +67,17 @@ module Mysigner
66
67
  @from_env
67
68
  end
68
69
 
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`).
73
+ def self.local_only?
74
+ truthy_env?(ENV_LOCAL_ONLY)
75
+ end
76
+
77
+ def local_only?
78
+ self.class.local_only?
79
+ end
80
+
69
81
  # Get API token for current organization (or specific org)
70
82
  def api_token(org_id = nil)
71
83
  org_id ||= @current_organization_id
@@ -121,7 +133,15 @@ module Mysigner
121
133
  def load
122
134
  return false unless exists?
123
135
 
124
- data = YAML.load_file(CONFIG_FILE)
136
+ # mysigner-51 — safe_load_file rejects arbitrary Ruby object
137
+ # 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
141
+ # extension is needed. Low risk (the file is 0600 and user-owned)
142
+ # but cheap hardening against a future RCE if config write or read
143
+ # ever moves outside the owner-only assumption.
144
+ data = YAML.safe_load_file(CONFIG_FILE)
125
145
 
126
146
  @api_url = data['api_url']
127
147
  @user_email = data['user_email']
@@ -262,6 +282,25 @@ module Mysigner
262
282
  @organizations.values.any? { |org_data| encrypted?(org_data['token']) }
263
283
  end
264
284
 
285
+ # Public accessor for the per-machine 32-byte AES-256-GCM key.
286
+ # Exposed so sibling stores (e.g. LocalCredentials) can encrypt secrets
287
+ # under the same key without duplicating the keychain/file fallback.
288
+ # The key itself is created on first read and is stable across calls.
289
+ def fetch_encryption_key
290
+ get_or_create_encryption_key
291
+ end
292
+
293
+ def self.truthy_env?(name)
294
+ raw = ENV.fetch(name, nil)
295
+ return false if raw.nil?
296
+
297
+ value = raw.strip
298
+ return false if value.empty?
299
+
300
+ value.match?(/\A(1|true|yes)\z/i)
301
+ end
302
+ private_class_method :truthy_env?
303
+
265
304
  private
266
305
 
267
306
  def ensure_config_dir_exists