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)


226
227
228
# File 'lib/mysigner/cli/concerns/helpers.rb', line 226

def banner_emitted?
  @local_only_banner_emitted == true
end

.mark_banner_emitted!Object



230
231
232
# File 'lib/mysigner/cli/concerns/helpers.rb', line 230

def mark_banner_emitted!
  @local_only_banner_emitted = true
end

.reset_banner!Object



234
235
236
# File 'lib/mysigner/cli/concerns/helpers.rb', line 234

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
# 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)
  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.)



212
213
214
215
216
217
218
219
220
221
222
223
# File 'lib/mysigner/cli/concerns/helpers.rb', line 212

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



145
146
147
# File 'lib/mysigner/cli/concerns/helpers.rb', line 145

def error(message)
  say "✗ Error: #{message}", :red
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 if either the –local-only flag is set on this invocation OR MYSIGNER_LOCAL_ONLY is truthy in ENV. Subsequent tickets gate credential-sending behavior on this.

Returns:

  • (Boolean)


152
153
154
# File 'lib/mysigner/cli/concerns/helpers.rb', line 152

def local_only?
  options[:local_only] || 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.



196
197
198
199
200
201
202
203
204
205
# File 'lib/mysigner/cli/concerns/helpers.rb', line 196

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.



161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
# File 'lib/mysigner/cli/concerns/helpers.rb', line 161

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.



180
181
182
183
184
185
186
187
188
189
190
# File 'lib/mysigner/cli/concerns/helpers.rb', line 180

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