Module: Mysigner::CLI::Concerns::Helpers

Included in:
Mysigner::CLI
Defined in:
lib/mysigner/cli/concerns/helpers.rb

Class Method Summary collapse

Instance Method Summary collapse

Class Method Details

Returns:

  • (Boolean)


250
251
252
# File 'lib/mysigner/cli/concerns/helpers.rb', line 250

def banner_emitted?
  @local_only_banner_emitted == true
end

.mark_banner_emitted!Object



254
255
256
# File 'lib/mysigner/cli/concerns/helpers.rb', line 254

def mark_banner_emitted!
  @local_only_banner_emitted = true
end

.reset_banner!Object



258
259
260
# File 'lib/mysigner/cli/concerns/helpers.rb', line 258

def reset_banner!
  @local_only_banner_emitted = false
end

Instance Method Details

#blank_local_only_configObject

mysigner-22 — a Config built without touching disk, ENV, or the encryption key. We deliberately bypass Config#initialize (which auto-loads ~/.mysigner/config.yml when it exists) because the whole point of local-only is to work on a machine where that file might not exist or might be unreadable (e.g. broken Keychain key). Callers only read ‘current_organization_id` / `api_url` / etc., all of which legitimately return nil here.



134
135
136
137
138
139
140
141
142
143
144
145
146
147
# File 'lib/mysigner/cli/concerns/helpers.rb', line 134

def blank_local_only_config
  config = Config.allocate
  config.instance_variable_set(:@api_url, nil)
  config.instance_variable_set(:@user_email, nil)
  config.instance_variable_set(:@current_organization_id, nil)
  config.instance_variable_set(:@organizations, {})
  config.instance_variable_set(:@encryption_enabled, false)
  config.instance_variable_set(:@from_env, false)
  # The whole point of this sentinel is to BE a local-only config —
  # set @local_only = true so `config.local_only?` (and any caller
  # reading `config.local_only`) agrees.
  config.instance_variable_set(:@local_only, true)
  config
end

#create_client(config) ⇒ Object



113
114
115
116
117
118
119
120
121
122
123
124
125
# File 'lib/mysigner/cli/concerns/helpers.rb', line 113

def create_client(config)
  # mysigner-22 — in local-only mode every MySigner API touchpoint is
  # supposed to be bypassed at the call site. Returning nil here makes
  # an accidental `client.get(...)` fail loud (NoMethodError on nil)
  # rather than silently re-introducing a server hit.
  return nil if local_only?

  Client.new(
    api_url: config.api_url,
    api_token: config.api_token,
    user_email: config.user_email
  )
end

#emit_local_only_bannerObject

One-time banner on stderr (TTY only) so users know they’ve opted into local-only mode. Module-level guard ensures it fires at most once per CLI invocation, even if multiple commands call it. (Module instance var, not @@, to avoid the class-var smell — every instance method sees the same Helpers module object.)



220
221
222
223
224
225
226
227
228
229
230
231
# File 'lib/mysigner/cli/concerns/helpers.rb', line 220

def emit_local_only_banner
  return if Helpers.banner_emitted?
  return unless $stderr.respond_to?(:tty?) && $stderr.tty?

  Helpers.mark_banner_emitted!
  # Honest scope: credential transport is what local-only currently
  # guards. Non-credential MySigner endpoints (app/build registry,
  # keystore download, etc.) are still used. Documented in the
  # local-only docs (mysigner-45).
  warn '[mysigner] local-only mode active — signing credentials stay on this machine ' \
       '(other MySigner APIs may still be used; see docs).'
end

#error(message) ⇒ Object



149
150
151
# File 'lib/mysigner/cli/concerns/helpers.rb', line 149

def error(message)
  say "✗ Error: #{message}", :red
end

#exit_unless_local_supported!(command_name) ⇒ Object

Server-only command guard. SERVER commands (apps, orgs, sync, certificates, etc.) hit MySigner-side resources and have no local equivalent. Print a clean explanation and exit 2 when local-only mode is active, instead of letting load_config bail with the generic “Not logged in” path.



238
239
240
241
242
243
244
245
246
247
# File 'lib/mysigner/cli/concerns/helpers.rb', line 238

def exit_unless_local_supported!(command_name)
  return unless local_only?

  say "✗ `#{command_name}` manages MySigner-side resources and " \
      "isn't available in local-only mode.", :red
  say ''
  say 'Disable persistently:  mysigner config set local-only false', :yellow
  say "Override for one call: mysigner --no-local-only #{command_name}", :yellow
  exit 2
end

#format_bytes(bytes) ⇒ Object



29
30
31
32
33
34
35
36
37
# File 'lib/mysigner/cli/concerns/helpers.rb', line 29

def format_bytes(bytes)
  if bytes < 1024
    "#{bytes} B"
  elsif bytes < 1024 * 1024
    "#{(bytes / 1024.0).round(1)} KB"
  else
    "#{(bytes / (1024.0 * 1024)).round(1)} MB"
  end
end

#format_duration(seconds) ⇒ Object



15
16
17
18
19
20
21
22
23
24
25
26
27
# File 'lib/mysigner/cli/concerns/helpers.rb', line 15

def format_duration(seconds)
  if seconds < 60
    "#{seconds.round}s"
  elsif seconds < 3600
    minutes = (seconds / 60).floor
    secs = (seconds % 60).round
    "#{minutes}m #{secs}s"
  else
    hours = (seconds / 3600).floor
    minutes = ((seconds % 3600) / 60).floor
    "#{hours}h #{minutes}m"
  end
end

#load_configObject



71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
# File 'lib/mysigner/cli/concerns/helpers.rb', line 71

def load_config
  # mysigner-22 — local-only mode is allowed to run with ZERO MySigner
  # auth: no ~/.mysigner/config.yml, no API token, no login. Return a
  # blank Config sentinel and skip both the load and the
  # "Not logged in" exit. Callers must treat the returned config as
  # opaque (no api_url, no api_token, no organization_id) and must
  # also treat create_client(config) as returning nil.
  return blank_local_only_config if local_only?

  # CI/CD mode: prefer environment variables when set
  return Config.from_env if Config.env_configured?

  config = Config.new

  unless config.exists?
    error "Not logged in. Run 'mysigner login' first."
    say ''
    say 'Tip: For CI/CD, set these environment variables instead:', :yellow
    say '  export MYSIGNER_API_TOKEN=your_token', :yellow
    say '  export MYSIGNER_ORG_ID=your_org_id', :yellow
    say '  export MYSIGNER_API_URL=https://mysigner.dev  # optional', :yellow
    say '  export MYSIGNER_EMAIL=you@example.com          # optional', :yellow
    say ''
    # Discoverability: a first-time user who doesn't want a MySigner
    # account at all (BYO-credentials, no server orchestration) needs
    # to know `--local-only` exists. Without this tip the only error
    # they see is "Not logged in", which strongly implies signup is
    # the only path forward.
    say 'Tip: To skip MySigner entirely and ship locally with your own', :yellow
    say 'Apple/Google credentials, use --local-only:', :yellow
    say '  mysigner --local-only ship appstore', :yellow
    say '  (auto-discovers ASC .p8 from ~/.appstoreconnect/private_keys/,', :yellow
    say '   Google Play SA-JSON from GOOGLE_APPLICATION_CREDENTIALS / eas.json,', :yellow
    say '   keystore from key.properties / eas.json — or set them via flags / env.)', :yellow
    say '  See "Local-only mode" section in README.', :yellow
    exit 1
  end

  config.load
  config
end

#local_only?Boolean

Local-only mode is active when any of, in precedence order:

1. --local-only / --no-local-only flag on this invocation
2. MYSIGNER_LOCAL_ONLY env var
3. `local_only: true` in ~/.mysigner/config.yml

‘Config.local_only?` (class method) walks #2 then #3.

Returns:

  • (Boolean)


158
159
160
161
162
# File 'lib/mysigner/cli/concerns/helpers.rb', line 158

def local_only?
  return options[:local_only] unless options[:local_only].nil?

  Mysigner::Config.local_only?
end

#resolve_local_android_keystore_or_exitObject

mysigner-22 Phase 7 — Android keystore counterpart of the ASC/Play resolvers. Pre-resolves the keystore (path + passwords + alias) via the cascade so ‘ship android –local-only` can skip the MySigner server entirely.



204
205
206
207
208
209
210
211
212
213
# File 'lib/mysigner/cli/concerns/helpers.rb', line 204

def resolve_local_android_keystore_or_exit
  require 'mysigner/credential_resolver'
  creds = Mysigner::CredentialResolver.resolve_android_keystore(options: options.to_h)
  warn "[mysigner] Android keystore source: #{creds.source}" if $stderr.respond_to?(:tty?) && $stderr.tty?
  creds
rescue Mysigner::CredentialResolver::CredentialNotFoundError,
       Mysigner::CredentialResolver::AmbiguousCredentialsError => e
  say "✗ No local Android keystore found:\n#{e.message}", :red
  exit 1
end

#resolve_local_asc_creds_or_exitObject

mysigner-22 Phase 5 — resolve ASC creds via the cascade (flag →env → keychain → ~/.appstoreconnect → prompt), surface a clear error and exit 1 on miss. Logs the winning source on stderr so CI runs leave an audit trail of where the credential came from. Returns a Mysigner::CredentialResolver::AscCreds struct.



169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
# File 'lib/mysigner/cli/concerns/helpers.rb', line 169

def resolve_local_asc_creds_or_exit
  require 'mysigner/credential_resolver'
  creds = Mysigner::CredentialResolver.resolve_asc(options: options.to_h)
  warn "[mysigner] ASC credentials source: #{creds.source}" if $stderr.respond_to?(:tty?) && $stderr.tty?
  creds
rescue Mysigner::CredentialResolver::CredentialNotFoundError,
       Mysigner::CredentialResolver::AmbiguousCredentialsError => e
  # Preserve the historical wording the CLI specs were written
  # against ("No local ASC credentials found") so users and tests
  # have a stable identifier, while including the resolver's
  # multi-line cascade trace + override knob list right after it.
  say "✗ No local ASC credentials found via `mysigner onboard --local-only` or other sources:\n#{e.message}",
      :red
  exit 1
end

#resolve_local_play_creds_or_exitObject

mysigner-22 Phase 5 — Google Play counterpart of resolve_local_asc_creds_or_exit. Same semantics: pre-resolve so the CLI can fail fast before the build kicks off.



188
189
190
191
192
193
194
195
196
197
198
# File 'lib/mysigner/cli/concerns/helpers.rb', line 188

def resolve_local_play_creds_or_exit
  require 'mysigner/credential_resolver'
  creds = Mysigner::CredentialResolver.resolve_play(options: options.to_h)
  warn "[mysigner] Google Play credentials source: #{creds.source}" if $stderr.respond_to?(:tty?) && $stderr.tty?
  creds
rescue Mysigner::CredentialResolver::CredentialNotFoundError,
       Mysigner::CredentialResolver::AmbiguousCredentialsError => e
  say "✗ No local Google Play credentials found via `mysigner onboard --local-only` or other sources:\n#{e.message}",
      :red
  exit 1
end

#valid_ios_udid?(udid) ⇒ Boolean

Client-side UDID validity check for iOS devices. Matches the two formats Apple uses: 25-character alphanumeric (older devices pre- iPhone X) and 40-character hex, optionally with a single dash after the first 8 chars (newer). Also rejects obviously synthetic values (all zeros, single-character repeats) that Apple’s dev-portal sandbox has been known to accept even though they can never match a real device.

Returns:

  • (Boolean)


46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
# File 'lib/mysigner/cli/concerns/helpers.rb', line 46

def valid_ios_udid?(udid)
  return false if udid.nil? || udid.strip.empty?

  normalized = udid.strip.upcase

  # 25-char legacy UDID: alphanumeric
  legacy = normalized.match?(/\A[0-9A-F]{25}\z/)

  # 40-char modern UDID: hex, optional dash after first 8 chars
  modern_plain = normalized.match?(/\A[0-9A-F]{40}\z/)
  modern_dashed = normalized.match?(/\A[0-9A-F]{8}-[0-9A-F]{16}\z/) # 8-16 form some tools emit (older spec)
  modern_full_dashed = normalized.match?(/\A[0-9A-F]{8}-[0-9A-F]{32}\z/) # 8-32 (what xcrun outputs)

  return false unless legacy || modern_plain || modern_dashed || modern_full_dashed

  hex_only = normalized.delete('-')
  # Reject trivially synthetic UDIDs. A real UDID has at least 4
  # distinct hex characters among its 25/40 positions; "000…" or
  # "AAAA…" or "012345…" style sequences flunk that.
  distinct = hex_only.chars.uniq.size
  return false if distinct < 4

  true
end

#with_timing(_label) ⇒ Object

Helper for timing operations



8
9
10
11
12
13
# File 'lib/mysigner/cli/concerns/helpers.rb', line 8

def with_timing(_label)
  start = Time.now
  result = yield
  duration = Time.now - start
  [result, duration]
end