featureparity 0.0.3 → 0.0.4

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: 166fe16ad8636be840cc889d1104ad4e6a5f3c84cd046f0a94f291412083f344
4
- data.tar.gz: e88add572a4cfd638768ae4d65de5a207b6a64c450ca0fdce426f48e8e51427e
3
+ metadata.gz: 0b5b8ef2f8e259e3d8d7ecd0c547ad8bef6f6202e0b3564bd8d789de905661ed
4
+ data.tar.gz: 79d02f1bfdd3c95b1d94a40b2cda406d656c4970a0c6b3f60b792415b4417c62
5
5
  SHA512:
6
- metadata.gz: c3d5bee44367c02c8329c10bdb3c0615978c405c30d46fb002b1ca7fe6676a61075dc83bf4ba02ba01faf828d80fa7f56af2116fd5287f662166bf547d9c5468
7
- data.tar.gz: 5b627165e1205cca331626fd46b35d7ad71b9ed666fd63ebe96569b2e2989b4b14ff35426d099a1e7dfd8afe7ac16c7984e53ec1052262a374914505c950404c
6
+ metadata.gz: e737b8e544deaa42e6ada2f6af065c5afdeaa46afa51d5365c5f8a92fa77572e1f5c1a13715a11eb0655416a08e487f4b8e9bb7f7ffa9efc3a6042abcc1c45ad
7
+ data.tar.gz: 247e22500f20fcd047a617232020f29a87987ac2376a78176a69969a467b8d8164b4dd6a427c73c558965b7aa9543ffb2e0b659416974ec5272f76d2618c415e
data/lib/fp/cli.rb CHANGED
@@ -15,6 +15,7 @@ module Fp
15
15
  'config' => Commands::ConfigCmd,
16
16
  'repos' => Commands::Repos,
17
17
  'setup' => Commands::Setup,
18
+ 'whoami' => Commands::Whoami,
18
19
  'version' => Commands::Version,
19
20
  'help' => Commands::Help
20
21
  }.freeze
@@ -35,7 +36,7 @@ module Fp
35
36
  end
36
37
 
37
38
  # Profile/config commands don't need auth
38
- needs_auth = !%w[profile config repos setup version help].include?(command_name)
39
+ needs_auth = !%w[profile config repos setup whoami version help].include?(command_name)
39
40
 
40
41
  if needs_auth
41
42
  config = Config.new(profile: global_opts[:profile], api_url: global_opts[:api_url])
@@ -19,6 +19,9 @@ module Fp
19
19
  unset_setting(args)
20
20
  when 'list'
21
21
  list_settings
22
+ when 'whoami'
23
+ # Alias for `fp whoami` — show the fully resolved configuration.
24
+ Whoami.new(client: client, output: output, config: config).run(args)
22
25
  else
23
26
  show_usage
24
27
  end
@@ -105,17 +108,22 @@ module Fp
105
108
  puts "Global settings:"
106
109
  settings.each { |k, v| puts " #{k} = #{v}" }
107
110
  end
111
+ puts
112
+ puts "This shows only global settings. To see the fully resolved config"
113
+ puts "(profile, environment, API URL, and key — with the source of each):"
114
+ puts " fp whoami"
108
115
  end
109
116
  end
110
117
 
111
118
  def show_usage
112
- output.error('Usage: fp config <set|get|unset|list>')
119
+ output.error('Usage: fp config <set|get|unset|list|whoami>')
113
120
  puts
114
121
  puts 'Commands:'
115
122
  puts ' fp config set <key> <value> Set a global setting'
116
123
  puts ' fp config get <key> Get a setting value'
117
124
  puts ' fp config unset <key> Remove a setting'
118
125
  puts ' fp config list Show all global settings'
126
+ puts ' fp config whoami Show the resolved config (alias of `fp whoami`)'
119
127
  puts
120
128
  puts 'Available settings:'
121
129
  puts ' api_url Base URL for the FeatureParity API'
@@ -123,6 +131,7 @@ module Fp
123
131
  puts 'Examples:'
124
132
  puts ' fp config set api_url https://api.dev.featureparity.dev'
125
133
  puts ' fp config get api_url'
134
+ puts ' fp config whoami'
126
135
  exit 1
127
136
  end
128
137
  end
@@ -22,6 +22,7 @@ module Fp
22
22
  matrix --project <slug> Show the parity matrix
23
23
  profile add|list Manage named profiles
24
24
  config set|get|unset|list Manage global settings
25
+ whoami Show the resolved config (profile, env, API URL, key)
25
26
  repos set|get|list|unset Map surfaces to local repo paths
26
27
  setup Interactive first-time setup
27
28
  version Show version
@@ -56,6 +57,11 @@ module Fp
56
57
  # First-time setup (interactive)
57
58
  fp setup
58
59
 
60
+ # Show the resolved configuration (which profile/env/API URL/key is in effect)
61
+ fp whoami
62
+ fp whoami --json
63
+ fp --profile staging whoami
64
+
59
65
  # Non-interactive setup for CI/automation
60
66
  fp setup --api-key fp_... --profile ci-agent --non-interactive
61
67
 
@@ -7,7 +7,8 @@ module Fp
7
7
  class Profile < Base
8
8
  KNOWN_FLAGS = {
9
9
  api_key: :string,
10
- api_url: :string
10
+ api_url: :string,
11
+ environment: :string
11
12
  }.freeze
12
13
 
13
14
  def run(args)
@@ -42,11 +43,18 @@ module Fp
42
43
  exit 1
43
44
  end
44
45
 
45
- Config.save_profile(name, api_key: opts[:api_key], api_url: opts[:api_url])
46
+ # The global CLI parser consumes --api-url before the command runs, so
47
+ # honor it via the config object when the local flag wasn't captured.
48
+ api_url = opts[:api_url] || config&.explicit_api_url
49
+ Config.save_profile(name, api_key: opts[:api_key], api_url: api_url, environment: opts[:environment])
46
50
 
47
- output.success({ profile: name, api_url: opts[:api_url] || Config::DEFAULT_API_URL }) do
51
+ effective_url = api_url || Config.get_setting('api_url') || Config::DEFAULT_API_URL
52
+ environment = opts[:environment] || Config.environment_for(effective_url)
53
+
54
+ output.success({ profile: name, api_url: effective_url, environment: environment }) do
48
55
  puts "Profile '#{name}' saved."
49
- puts " API URL: #{opts[:api_url] || Config::DEFAULT_API_URL}"
56
+ puts " API URL: #{effective_url}"
57
+ puts " Environment: #{environment}" if environment
50
58
  puts
51
59
  puts 'Use this profile with:'
52
60
  puts " fp --profile #{name} projects"
@@ -65,11 +73,23 @@ module Fp
65
73
  puts ' fp profile add <name> --api-key fp_...'
66
74
  end
67
75
  else
68
- output.success({ profiles: profiles }) do
76
+ raw = Config.load_profiles
77
+ detailed = profiles.map do |name|
78
+ p = raw[name] || {}
79
+ url = p['api_url'] || Config.get_setting('api_url') || Config::DEFAULT_API_URL
80
+ env = p['environment'] || Config.environment_for(url)
81
+ { name: name, environment: env, api_url: url }
82
+ end
83
+
84
+ output.success({ profiles: detailed }) do
69
85
  puts 'Configured profiles:'
70
- profiles.each { |name| puts " - #{name}" }
86
+ detailed.each do |d|
87
+ tag = d[:environment] ? " (#{d[:environment]})" : ''
88
+ puts " - #{d[:name]}#{tag}"
89
+ end
71
90
  puts
72
91
  puts 'Note: API keys are never displayed. Use --profile <name> to select.'
92
+ puts 'Run `fp whoami` to see the fully resolved configuration.'
73
93
  end
74
94
  end
75
95
  end
@@ -78,7 +98,8 @@ module Fp
78
98
  output.error('Usage: fp profile <add|list>')
79
99
  puts
80
100
  puts 'Commands:'
81
- puts ' fp profile add <name> --api-key fp_... Add a named profile'
101
+ puts ' fp profile add <name> --api-key fp_... [--api-url URL] [--environment ENV]'
102
+ puts ' Add a named profile'
82
103
  puts ' fp profile list List configured profiles'
83
104
  exit 1
84
105
  end
@@ -10,6 +10,7 @@ module Fp
10
10
  api_key: :string,
11
11
  profile: :string,
12
12
  api_url: :string,
13
+ environment: :string,
13
14
  non_interactive: :boolean
14
15
  }.freeze
15
16
 
@@ -22,19 +23,22 @@ module Fp
22
23
 
23
24
  api_key = resolve_api_key(opts)
24
25
  profile_name = resolve_profile_name(opts)
25
- api_url = opts[:api_url] || Config.get_setting('api_url')
26
+ api_url = resolve_api_url(opts)
27
+ environment = opts[:environment] || Config.environment_for(api_url || Config::DEFAULT_API_URL)
26
28
 
27
29
  puts "Validating API key..."
28
30
  validate_key!(api_key, api_url)
29
31
 
30
- save_profile!(profile_name, api_key, api_url)
32
+ save_profile!(profile_name, api_key, api_url, environment)
31
33
 
32
34
  effective_url = api_url || Config::DEFAULT_API_URL
33
- domain = URI.parse(effective_url).host.sub(/^api\./, '') rescue 'featureparity.dev'
34
35
 
35
36
  puts
36
37
  puts "✅ Setup complete!"
37
38
  puts
39
+ puts " API URL: #{effective_url}"
40
+ puts " Environment: #{environment}" if environment
41
+ puts
38
42
  puts "Your profile '#{profile_name}' is ready. Usage:"
39
43
  puts
40
44
  puts " fp --profile #{profile_name} projects"
@@ -42,6 +46,9 @@ module Fp
42
46
  puts
43
47
  puts "Or set FP_API_KEY to skip profiles:"
44
48
  puts " export FP_API_KEY=#{api_key[0..6]}..."
49
+ puts
50
+ puts "Verify the resolved configuration any time with:"
51
+ puts " fp whoami"
45
52
  end
46
53
 
47
54
  private
@@ -57,7 +64,7 @@ module Fp
57
64
  exit 1
58
65
  end
59
66
 
60
- api_url = opts[:api_url] || Config.get_setting('api_url')
67
+ api_url = opts[:api_url] || config&.explicit_api_url || Config.get_setting('api_url')
61
68
  effective_url = api_url || Config::DEFAULT_API_URL
62
69
  domain = URI.parse(effective_url).host.sub(/^api\./, '') rescue 'featureparity.dev'
63
70
 
@@ -78,10 +85,17 @@ module Fp
78
85
  end
79
86
 
80
87
  def resolve_profile_name(opts)
88
+ # --profile / FP_PROFILE may be consumed by the global parser; honor it
89
+ # via config, but only when explicitly provided (not the implicit
90
+ # "default" profile fallback) so interactive setup still prompts.
81
91
  if opts[:profile]
82
92
  return opts[:profile]
83
93
  end
84
94
 
95
+ if config && %w[flag env\ (FP_PROFILE)].include?(config.profile_source)
96
+ return config.profile_name
97
+ end
98
+
85
99
  if opts[:non_interactive]
86
100
  return 'default'
87
101
  end
@@ -93,6 +107,31 @@ module Fp
93
107
  name.nil? || name.empty? ? 'default' : name
94
108
  end
95
109
 
110
+ # Resolve the API URL for setup. Priority: --api-url flag > global setting.
111
+ # When neither is set and we're interactive, prompt the user so they can
112
+ # target a non-default environment (self-hosted, staging, ephemeral PR env)
113
+ # instead of silently defaulting to production.
114
+ def resolve_api_url(opts)
115
+ return opts[:api_url] if opts[:api_url] && !opts[:api_url].empty?
116
+
117
+ # The global CLI parser consumes --api-url before setup runs; honor it.
118
+ flag_url = config&.explicit_api_url
119
+ return flag_url if flag_url
120
+
121
+ existing = Config.get_setting('api_url')
122
+ return existing if existing && !existing.empty?
123
+
124
+ # Non-interactive: fall back to the default (nil => DEFAULT_API_URL).
125
+ return nil if opts[:non_interactive]
126
+
127
+ default = Config::DEFAULT_API_URL
128
+ print "API URL (default: #{default}): "
129
+ $stdout.flush
130
+ url = $stdin.gets&.strip
131
+
132
+ url.nil? || url.empty? ? nil : url
133
+ end
134
+
96
135
  def validate_key_format!(key)
97
136
  unless key.start_with?('fp_')
98
137
  output.error("API key must start with 'fp_' prefix. Got: #{key[0..3]}...")
@@ -146,8 +185,8 @@ module Fp
146
185
  exit 1
147
186
  end
148
187
 
149
- def save_profile!(name, api_key, api_url)
150
- Config.save_profile(name, api_key: api_key, api_url: api_url)
188
+ def save_profile!(name, api_key, api_url, environment = nil)
189
+ Config.save_profile(name, api_key: api_key, api_url: api_url, environment: environment)
151
190
  puts " ✓ Profile '#{name}' saved to ~/.config/fp/config.yml"
152
191
  end
153
192
  end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fp
4
+ module Commands
5
+ # fp whoami
6
+ #
7
+ # Show the *resolved* configuration the CLI would actually use for this
8
+ # invocation: which profile is active, the API URL and where it came from,
9
+ # the environment that URL targets, and whether an API key is present
10
+ # (masked — never the full token). Purely local; makes no API calls, so it
11
+ # works even without a valid key. Honors --profile / --api-url / --json.
12
+ class Whoami < Base
13
+ def run(_args)
14
+ r = config.resolved
15
+
16
+ output.success(r) do
17
+ puts 'Resolved FeatureParity CLI configuration:'
18
+ puts
19
+ puts " Profile: #{format_value(r[:profile], r[:profile_source])}"
20
+ puts " Environment: #{r[:environment] || '(unknown)'}"
21
+ puts " API URL: #{format_value(r[:api_url], r[:api_url_source])}"
22
+ puts " API key: #{key_line(r)}"
23
+ puts " Config file: #{r[:config_file]}"
24
+
25
+ if r[:profile_environment] && r[:profile_environment] != r[:environment]
26
+ puts
27
+ puts " ⚠ Profile is tagged for environment '#{r[:profile_environment]}' " \
28
+ "but the resolved API URL points at '#{r[:environment]}'."
29
+ end
30
+
31
+ unless r[:authenticated]
32
+ puts
33
+ puts 'No API key resolved. Set one with:'
34
+ puts ' export FP_API_KEY=fp_...'
35
+ puts ' fp setup'
36
+ puts ' fp profile add <name> --api-key fp_...'
37
+ end
38
+ end
39
+ end
40
+
41
+ private
42
+
43
+ def format_value(value, source)
44
+ display = value.nil? || value.to_s.empty? ? '(none)' : value
45
+ source && source != 'none' ? "#{display} [from #{source}]" : display.to_s
46
+ end
47
+
48
+ def key_line(r)
49
+ return '(not set)' unless r[:api_key_present]
50
+
51
+ "#{r[:api_key_hint]} [from #{r[:api_key_source]}]"
52
+ end
53
+ end
54
+ end
55
+ end
data/lib/fp/commands.rb CHANGED
@@ -12,6 +12,7 @@ require_relative 'commands/profile'
12
12
  require_relative 'commands/config_cmd'
13
13
  require_relative 'commands/repos'
14
14
  require_relative 'commands/setup'
15
+ require_relative 'commands/whoami'
15
16
  require_relative 'commands/version'
16
17
  require_relative 'commands/help'
17
18
 
data/lib/fp/config.rb CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  require 'yaml'
4
4
  require 'fileutils'
5
+ require 'uri'
5
6
 
6
7
  module Fp
7
8
  # Handles configuration: profiles, API keys, API URL.
@@ -29,9 +30,12 @@ module Fp
29
30
 
30
31
  attr_reader :api_key, :api_url, :profile_name
31
32
 
33
+ # Where a resolved value came from, for `fp whoami` transparency.
34
+ attr_reader :api_key_source, :api_url_source, :profile_source
35
+
32
36
  def initialize(profile: nil, api_url: nil)
33
- @profile_name = resolve_profile_name(profile)
34
37
  @explicit_api_url = api_url
38
+ @profile_name = resolve_profile_name(profile)
35
39
  @api_key = resolve_api_key
36
40
  @api_url = resolve_api_url
37
41
  end
@@ -40,6 +44,56 @@ module Fp
40
44
  !@api_key.nil? && !@api_key.empty?
41
45
  end
42
46
 
47
+ # The API URL passed via the global --api-url flag, if any (nil otherwise).
48
+ # `profile add` / `setup` use this so `fp profile add x --api-url URL` still
49
+ # records URL even though the global parser consumes the flag first.
50
+ def explicit_api_url
51
+ return nil if @explicit_api_url.nil? || @explicit_api_url.empty?
52
+
53
+ @explicit_api_url
54
+ end
55
+
56
+ # Short, human-readable environment label derived from the API host, e.g.
57
+ # https://api.dev.featureparity.dev -> "dev"
58
+ # https://api.staging.featureparity.dev -> "staging"
59
+ # https://api.featureparity.dev -> "production"
60
+ # http://localhost:9292 -> "local"
61
+ # This is what "tags" a profile/key to an environment: the API URL it targets.
62
+ def environment
63
+ self.class.environment_for(@api_url)
64
+ end
65
+
66
+ # The environment explicitly recorded on the active profile, if any.
67
+ # Falls back to nil when unset (callers derive from api_url instead).
68
+ def profile_environment
69
+ return nil unless @profile_name
70
+
71
+ profile = self.class.load_profiles[@profile_name]
72
+ profile && profile['environment']
73
+ end
74
+
75
+ # Masked hint for display — never reveal the full key.
76
+ def masked_api_key
77
+ self.class.mask_key(@api_key)
78
+ end
79
+
80
+ # Full resolved snapshot for `fp whoami` (safe to print / JSON-encode).
81
+ def resolved
82
+ {
83
+ profile: @profile_name,
84
+ profile_source: @profile_source,
85
+ api_url: @api_url,
86
+ api_url_source: @api_url_source,
87
+ environment: environment,
88
+ profile_environment: profile_environment,
89
+ authenticated: valid?,
90
+ api_key_present: valid?,
91
+ api_key_hint: masked_api_key,
92
+ api_key_source: @api_key_source,
93
+ config_file: self.class::CONFIG_FILE
94
+ }
95
+ end
96
+
43
97
  def validation_error
44
98
  return nil if valid?
45
99
 
@@ -64,7 +118,7 @@ module Fp
64
118
  config['profiles'] || {}
65
119
  end
66
120
 
67
- def save_profile(name, api_key:, api_url: nil)
121
+ def save_profile(name, api_key:, api_url: nil, environment: nil)
68
122
  FileUtils.mkdir_p(CONFIG_DIR)
69
123
  ensure_config_file!
70
124
 
@@ -72,6 +126,10 @@ module Fp
72
126
  config['profiles'] ||= {}
73
127
  config['profiles'][name] = { 'api_key' => api_key }
74
128
  config['profiles'][name]['api_url'] = api_url if api_url
129
+ # Tag the key/profile with the environment it targets. If not given
130
+ # explicitly, derive it from the api_url so keys are always tagged.
131
+ env = environment || environment_for(api_url || get_setting('api_url') || DEFAULT_API_URL)
132
+ config['profiles'][name]['environment'] = env if env
75
133
 
76
134
  write_config!(config)
77
135
  end
@@ -82,6 +140,67 @@ module Fp
82
140
  config[key.to_s]
83
141
  end
84
142
 
143
+ # Derive a short environment label from an API URL's host. This mirrors
144
+ # the Belt DNS convention (see infrastructure/modules/app/dns.tf), which is
145
+ # what actually "tags" a profile/key to an environment: the API host tells
146
+ # you which FeatureParity environment the key talks to.
147
+ #
148
+ # Standalone environments use a dotted "api." prefix:
149
+ # api.featureparity.dev -> "production"
150
+ # api.dev.featureparity.dev -> "dev"
151
+ # api.staging.featureparity.dev -> "staging"
152
+ #
153
+ # Nested / ephemeral (per-branch) environments use a dashed "api-" prefix,
154
+ # because SSL wildcards are single-level (*.dev.featureparity.dev covers
155
+ # api-<env>.dev.featureparity.dev but not api.<env>.dev.featureparity.dev).
156
+ # The env slug is everything after "api-" up to the first dot, and may
157
+ # itself contain dashes:
158
+ # api-abc-1234.dev.featureparity.dev -> "abc-1234"
159
+ # api-fizzy123.dev.featureparity.dev -> "fizzy123"
160
+ #
161
+ # Anything local:
162
+ # localhost / 127.0.0.1 / 0.0.0.0 -> "local"
163
+ def environment_for(url)
164
+ return nil if url.nil? || url.to_s.empty?
165
+
166
+ host = begin
167
+ URI.parse(url.to_s).host
168
+ rescue URI::InvalidURIError
169
+ nil
170
+ end
171
+ return nil if host.nil? || host.empty?
172
+
173
+ return 'local' if host == 'localhost' || host.start_with?('127.') || host == '0.0.0.0'
174
+
175
+ first, *rest = host.split('.')
176
+
177
+ # Nested/ephemeral: "api-<env>" where <env> may contain dashes.
178
+ return first.sub(/\Aapi-/, '') if first.start_with?('api-')
179
+
180
+ # Standalone dotted "api." prefix: the env is the next label.
181
+ if first == 'api'
182
+ # api.featureparity.dev -> ["featureparity", "dev"] -> production
183
+ return 'production' if rest.length <= 2
184
+
185
+ return rest.first
186
+ end
187
+
188
+ # No recognizable "api" prefix — best effort: treat a bare apex
189
+ # (two labels, e.g. featureparity.dev) as production, else the left label.
190
+ return 'production' if rest.length <= 1
191
+
192
+ first
193
+ end
194
+
195
+ # Mask an API key for display: keep a short prefix + last 4 chars.
196
+ # fp_abcdefgh1234 -> fp_…1234
197
+ def mask_key(key)
198
+ return nil if key.nil? || key.empty?
199
+ return '****' if key.length <= 8
200
+
201
+ "#{key[0, 3]}…#{key[-4..]}"
202
+ end
203
+
85
204
  # Set a global setting (top-level key in config.yml)
86
205
  def set_setting(key, value)
87
206
  FileUtils.mkdir_p(CONFIG_DIR)
@@ -190,50 +309,84 @@ module Fp
190
309
 
191
310
  def resolve_profile_name(explicit_profile)
192
311
  # 1. Explicit --profile flag 2. FP_PROFILE env var
193
- name = explicit_profile || ENV['FP_PROFILE']
194
- return name if name && !name.empty?
312
+ if explicit_profile && !explicit_profile.empty?
313
+ @profile_source = 'flag'
314
+ return explicit_profile
315
+ end
316
+
317
+ if ENV['FP_PROFILE'] && !ENV['FP_PROFILE'].empty?
318
+ @profile_source = 'env (FP_PROFILE)'
319
+ return ENV['FP_PROFILE']
320
+ end
195
321
 
196
322
  # 3. Fall back to a profile literally named "default", if one exists.
197
323
  # This mirrors AWS/git/gcloud, which all consult a default profile when
198
324
  # nothing else is specified.
199
- return DEFAULT_PROFILE_NAME if self.class.profile_exists?(DEFAULT_PROFILE_NAME)
325
+ if self.class.profile_exists?(DEFAULT_PROFILE_NAME)
326
+ @profile_source = 'default profile'
327
+ return DEFAULT_PROFILE_NAME
328
+ end
200
329
 
330
+ @profile_source = 'none'
201
331
  nil
202
332
  end
203
333
 
204
334
  def resolve_api_key
205
335
  # FP_API_KEY env var always wins
206
- return ENV['FP_API_KEY'] if ENV['FP_API_KEY'] && !ENV['FP_API_KEY'].empty?
336
+ if ENV['FP_API_KEY'] && !ENV['FP_API_KEY'].empty?
337
+ @api_key_source = 'env (FP_API_KEY)'
338
+ return ENV['FP_API_KEY']
339
+ end
207
340
 
208
341
  # Otherwise, use profile if specified
209
- return nil unless @profile_name
342
+ unless @profile_name
343
+ @api_key_source = 'none'
344
+ return nil
345
+ end
210
346
 
211
347
  profiles = self.class.load_profiles
212
348
  profile = profiles[@profile_name]
213
- return nil unless profile
349
+ unless profile
350
+ @api_key_source = 'none'
351
+ return nil
352
+ end
214
353
 
354
+ @api_key_source = "profile (#{@profile_name})"
215
355
  profile['api_key']
216
356
  end
217
357
 
218
358
  def resolve_api_url
219
359
  # 1. Explicit --api-url flag
220
- return @explicit_api_url if @explicit_api_url && !@explicit_api_url.empty?
360
+ if @explicit_api_url && !@explicit_api_url.empty?
361
+ @api_url_source = 'flag'
362
+ return @explicit_api_url
363
+ end
221
364
 
222
365
  # 2. FP_API_URL env var
223
- return ENV['FP_API_URL'] if ENV['FP_API_URL'] && !ENV['FP_API_URL'].empty?
366
+ if ENV['FP_API_URL'] && !ENV['FP_API_URL'].empty?
367
+ @api_url_source = 'env (FP_API_URL)'
368
+ return ENV['FP_API_URL']
369
+ end
224
370
 
225
371
  # 3. Profile-specific URL
226
372
  if @profile_name
227
373
  profiles = self.class.load_profiles
228
374
  profile = profiles[@profile_name]
229
- return profile['api_url'] if profile && profile['api_url']
375
+ if profile && profile['api_url']
376
+ @api_url_source = "profile (#{@profile_name})"
377
+ return profile['api_url']
378
+ end
230
379
  end
231
380
 
232
381
  # 4. Global api_url setting
233
382
  global_url = self.class.get_setting('api_url')
234
- return global_url if global_url && !global_url.empty?
383
+ if global_url && !global_url.empty?
384
+ @api_url_source = 'global setting'
385
+ return global_url
386
+ end
235
387
 
236
388
  # 5. Default
389
+ @api_url_source = 'default'
237
390
  DEFAULT_API_URL
238
391
  end
239
392
  end
data/lib/fp/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Fp
4
- VERSION = '0.0.3'
4
+ VERSION = '0.0.4'
5
5
  end
@@ -159,15 +159,26 @@ fp matrix --project stowzilla --json # JSON
159
159
 
160
160
  ### fp profile
161
161
 
162
- Manage named profiles.
162
+ Manage named profiles. Each profile stores an API key and (optionally) the
163
+ API URL and environment it targets, so a key is always tagged with the
164
+ environment it talks to.
163
165
 
164
166
  ```bash
165
167
  fp profile list
166
- fp profile add staging --api-key fp_...
167
- fp profile remove staging
168
- fp profile show staging
168
+ fp profile add staging --api-key fp_... --api-url https://api.staging.featureparity.dev
169
+ fp profile add staging --api-key fp_... --environment staging
169
170
  ```
170
171
 
172
+ If `--environment` is omitted it is derived from the API URL's host, following
173
+ the Belt DNS convention (`infrastructure/modules/app/dns.tf`):
174
+
175
+ - `api.featureparity.dev` → `production`
176
+ - `api.staging.featureparity.dev` → `staging`
177
+ - `api-abc-1234.dev.featureparity.dev` → `abc-1234` (nested/ephemeral env; `api-` prefix)
178
+ - `localhost` / `127.0.0.1` → `local`
179
+
180
+ `fp profile list` shows the tag.
181
+
171
182
  ### fp config
172
183
 
173
184
  Manage global settings.
@@ -177,6 +188,21 @@ fp config list
177
188
  fp config get api_url
178
189
  fp config set api_url https://api.dev.featureparity.dev
179
190
  fp config unset api_url
191
+ fp config whoami # alias of `fp whoami`
192
+ ```
193
+
194
+ ### fp whoami
195
+
196
+ Show the *resolved* configuration this invocation would use — the active
197
+ profile, the environment, the API URL (and where each value came from), and
198
+ whether an API key is present (masked, never the full token). Makes no API
199
+ calls, so it works even without a valid key.
200
+
201
+ ```bash
202
+ fp whoami
203
+ fp whoami --json
204
+ fp --profile staging whoami # inspect a specific profile
205
+ fp --api-url http://localhost:9292 whoami
180
206
  ```
181
207
 
182
208
  ### fp repos
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: featureparity
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.3
4
+ version: 0.0.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Stowzilla
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-09-04 00:00:00.000000000 Z
11
+ date: 2026-09-12 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: net-http
@@ -108,6 +108,7 @@ files:
108
108
  - lib/fp/commands/show.rb
109
109
  - lib/fp/commands/surfaces.rb
110
110
  - lib/fp/commands/version.rb
111
+ - lib/fp/commands/whoami.rb
111
112
  - lib/fp/config.rb
112
113
  - lib/fp/junit.rb
113
114
  - lib/fp/output.rb