featureparity 0.0.3 → 0.0.5

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: 1208887c7ed99691e476438cdcb50d36f09e3ee3b22931c7d8d9ea246438fb89
4
+ data.tar.gz: b2fb9b9d7bad99bde7e2f8b0789105072cd19647ed46f0ea4d4d6e43a28fd319
5
5
  SHA512:
6
- metadata.gz: c3d5bee44367c02c8329c10bdb3c0615978c405c30d46fb002b1ca7fe6676a61075dc83bf4ba02ba01faf828d80fa7f56af2116fd5287f662166bf547d9c5468
7
- data.tar.gz: 5b627165e1205cca331626fd46b35d7ad71b9ed666fd63ebe96569b2e2989b4b14ff35426d099a1e7dfd8afe7ac16c7984e53ec1052262a374914505c950404c
6
+ metadata.gz: 5d70c2ae5afb585a1f54da0c2efb244965a0f419001c9ed8f46d1895a5f37ee12ece8a1195627859eeeaff2fd3910cf4dfdb593fc41c69440bc97020ff31a17a
7
+ data.tar.gz: 23d9038f6850138f295501a56ea00f872ff828eab5a2822482c306a56ba39cdec2a1016761af8f4f87932a3d9f264faf0c9dad3302d2a96ea5adceb86b3e29c6
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])
data/lib/fp/client.rb CHANGED
@@ -27,6 +27,11 @@ module Fp
27
27
  get("/api/projects/#{project_id}/surfaces")
28
28
  end
29
29
 
30
+ # POST /projects/:id/surfaces
31
+ def create_surface(project_id, params)
32
+ post("/api/projects/#{project_id}/surfaces", params)
33
+ end
34
+
30
35
  # GET /requirements
31
36
  def list_requirements(project_id: nil, status: nil, category: nil)
32
37
  params = {}
@@ -67,6 +72,15 @@ module Fp
67
72
  put("/api/requirements/#{id}", params)
68
73
  end
69
74
 
75
+ # GET /projects/:project_id/evidence — the receipts already filed for a project,
76
+ # optionally narrowed to one requirement slug and/or surface.
77
+ def list_evidence(project_id, slug: nil, surface: nil)
78
+ params = {}
79
+ params[:slug] = slug if slug
80
+ params[:surface] = surface if surface
81
+ get("/api/projects/#{project_id}/evidence", params)
82
+ end
83
+
70
84
  # POST /projects/:project_id/evidence (direct reporting)
71
85
  def report_evidence(params)
72
86
  project_id = params.delete(:project_id)
@@ -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
@@ -14,6 +14,7 @@ module Fp
14
14
  COMMANDS
15
15
  projects List projects you have access to
16
16
  surfaces --project <slug> List surfaces for a project
17
+ surfaces add <key> --project ... Create a surface
17
18
  list --project <slug> List active requirements
18
19
  show <slug> --project <slug> Show requirement details
19
20
  propose --project <slug> ... Propose a new requirement (as draft)
@@ -22,6 +23,7 @@ module Fp
22
23
  matrix --project <slug> Show the parity matrix
23
24
  profile add|list Manage named profiles
24
25
  config set|get|unset|list Manage global settings
26
+ whoami Show the resolved config (profile, env, API URL, key)
25
27
  repos set|get|list|unset Map surfaces to local repo paths
26
28
  setup Interactive first-time setup
27
29
  version Show version
@@ -56,12 +58,26 @@ module Fp
56
58
  # First-time setup (interactive)
57
59
  fp setup
58
60
 
61
+ # Show the resolved configuration (which profile/env/API URL/key is in effect)
62
+ fp whoami
63
+ fp whoami --json
64
+ fp --profile staging whoami
65
+
59
66
  # Non-interactive setup for CI/automation
60
67
  fp setup --api-key fp_... --profile ci-agent --non-interactive
61
68
 
62
69
  # List projects
63
70
  fp projects
64
71
 
72
+ # List the surfaces a project tracks (requirements may only name these)
73
+ fp surfaces --project stowzilla
74
+
75
+ # Create a surface (agents can do this — no need to wait on the web app)
76
+ fp surfaces add customer_android --project stowzilla \\
77
+ --name "Customer Android App" \\
78
+ --kind android \\
79
+ --repo stowzilla/customer-android
80
+
65
81
  # List requirements for a project
66
82
  fp list --project stowzilla
67
83
 
@@ -83,6 +99,14 @@ module Fp
83
99
  --acceptance "QR code is printed and scannable" \\
84
100
  --category functional
85
101
 
102
+ # Nest a requirement under a parent (--parent takes the parent's SLUG).
103
+ # A child's --required surfaces must be a subset of its parent's.
104
+ fp propose --project stowzilla \\
105
+ --slug print_container_qr_ios \\
106
+ --name "Print QR on iOS" \\
107
+ --parent print_container_qr \\
108
+ --required customer_ios
109
+
86
110
  # Report evidence for a requirement
87
111
  fp report print_container_qr --project stowzilla \\
88
112
  --surface customer_android \\
@@ -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
@@ -13,10 +13,14 @@ module Fp
13
13
  required: :string,
14
14
  acceptance: :string,
15
15
  parent: :string,
16
- category: :string
16
+ category: :string,
17
+ source_type: :string,
18
+ source_url: :string,
19
+ source_context: :string
17
20
  }.freeze
18
21
 
19
22
  VALID_CATEGORIES = %w[functional non-functional ux performance security compliance].freeze
23
+ VALID_SOURCE_TYPES = %w[fizzy discord github_issue jira notion manual ai_chat].freeze
20
24
 
21
25
  def run(args)
22
26
  opts, = parse_flags(args, KNOWN_FLAGS)
@@ -31,6 +35,11 @@ module Fp
31
35
  exit 1
32
36
  end
33
37
 
38
+ if opts[:source_type] && !VALID_SOURCE_TYPES.include?(opts[:source_type])
39
+ output.error("Invalid source type '#{opts[:source_type]}'. Must be one of: #{VALID_SOURCE_TYPES.join(', ')}")
40
+ exit 1
41
+ end
42
+
34
43
  project_id = resolve_project_id(opts[:project])
35
44
 
36
45
  # Parse required surfaces (comma-separated)
@@ -49,6 +58,9 @@ module Fp
49
58
  params[:why] = opts[:why] if opts[:why]
50
59
  params[:acceptance] = opts[:acceptance] if opts[:acceptance]
51
60
  params[:category] = opts[:category] if opts[:category]
61
+ params[:source_type] = opts[:source_type] if opts[:source_type]
62
+ params[:source_url] = opts[:source_url] if opts[:source_url]
63
+ params[:source_context] = opts[:source_context] if opts[:source_context]
52
64
 
53
65
  # Resolve parent slug to parent_id if provided
54
66
  if opts[:parent]
@@ -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
@@ -25,21 +25,49 @@ module Fp
25
25
 
26
26
  requirement = result[:data]['requirement']
27
27
 
28
- output.success(result[:data]) do
29
- print_requirement(requirement)
28
+ # Evidence is not embedded in the requirement payload — it lives on the project's
29
+ # evidence collection, keyed by slug. Fetch it, or `fp show` reports "(none)" for
30
+ # every requirement no matter how many receipts have been filed.
31
+ evidence = fetch_evidence(project_id, slug)
32
+
33
+ # Agents only ever speak slugs, so translate the parent's ID back into one.
34
+ parent_slug = resolve_parent_slug(project_id, requirement['parent_id'])
35
+
36
+ payload = result[:data].merge('evidence' => evidence)
37
+ payload['parent_slug'] = parent_slug if parent_slug
38
+
39
+ output.success(payload) do
40
+ print_requirement(requirement, evidence: evidence, parent_slug: parent_slug)
30
41
  end
31
42
  end
32
43
 
33
44
  private
34
45
 
35
- def print_requirement(req)
46
+ def fetch_evidence(project_id, slug)
47
+ result = client.list_evidence(project_id, slug: slug)
48
+ return [] unless result[:ok]
49
+
50
+ result[:data]['evidence'] || []
51
+ end
52
+
53
+ def resolve_parent_slug(project_id, parent_id)
54
+ return nil if parent_id.nil? || parent_id.to_s.empty?
55
+
56
+ result = client.list_requirements(project_id: project_id)
57
+ return nil unless result[:ok]
58
+
59
+ parent = (result[:data]['requirements'] || []).find { |r| r['id'] == parent_id }
60
+ parent && parent['slug']
61
+ end
62
+
63
+ def print_requirement(req, evidence: [], parent_slug: nil)
36
64
  puts "#{req['title']}"
37
65
  puts "=" * req['title'].length
38
66
  puts
39
67
  puts "Slug: #{req['slug']}"
40
68
  puts "Status: #{req['status']}"
41
69
  puts "Category: #{req['category'] || '(none)'}"
42
- puts "Parent: #{req['parent_id'] || '(none)'}"
70
+ puts "Parent: #{parent_slug || req['parent_id'] || '(none)'}"
43
71
  puts
44
72
 
45
73
  surfaces = req['required_surfaces'] || []
@@ -63,26 +91,31 @@ module Fp
63
91
  puts
64
92
  end
65
93
 
66
- # Evidence receipts (will be populated when Evidence model exists)
67
- evidence = req['evidence'] || []
68
- if evidence.any?
69
- puts 'Evidence:'
70
- evidence.each do |e|
71
- puts " #{e['surface']}:"
72
- puts " Status: #{e['status']}"
73
- puts " File: #{e['file']}" if e['file']
74
- puts " Repo: #{e['repo']}" if e['repo']
75
- puts " PR: #{e['pr_url']}" if e['pr_url']
76
- puts " Work: #{e['work_item_url']}" if e['work_item_url']
77
- puts " CI: #{e['ci_url']}" if e['ci_url']
78
- puts " Title: #{e['example_title']}" if e['example_title']
79
- puts " By: #{e['reported_by']}" if e['reported_by']
80
- puts
81
- end
82
- else
83
- puts 'Evidence:'
94
+ # Evidence receipts, newest first, grouped per surface.
95
+ puts 'Evidence:'
96
+ if evidence.empty?
84
97
  puts ' (none reported)'
85
98
  puts
99
+ else
100
+ evidence
101
+ .sort_by { |e| e['reported_at'].to_s }
102
+ .reverse
103
+ .group_by { |e| e['surface'] }
104
+ .each do |surface, records|
105
+ puts " #{surface}:"
106
+ records.each do |e|
107
+ puts " Status: #{e['status']}"
108
+ puts " File: #{e['source_file']}" if e['source_file']
109
+ puts " Repo: #{e['repo']}" if e['repo']
110
+ puts " PR: #{e['pr_url']}" if e['pr_url']
111
+ puts " Work: #{e['work_item_url']}" if e['work_item_url']
112
+ puts " CI: #{e['ci_url']}" if e['ci_url']
113
+ puts " Title: #{e['example_title']}" if e['example_title']
114
+ puts " By: #{e['source']}" if e['source']
115
+ puts " At: #{e['reported_at']}" if e['reported_at']
116
+ puts
117
+ end
118
+ end
86
119
  end
87
120
 
88
121
  puts "Created: #{req['created_at']}"
@@ -2,9 +2,38 @@
2
2
 
3
3
  module Fp
4
4
  module Commands
5
- # fp surfaces --project <slug> - list available surfaces for a project
5
+ # fp surfaces --project <slug> - list available surfaces for a project
6
+ # fp surfaces add <key> --project <slug> - create a surface
7
+ #
8
+ # `add` exists because a requirement can only name surfaces that already exist, so an
9
+ # agent onboarding a project it hasn't seen before would otherwise be blocked waiting
10
+ # on a human to click through the web app.
6
11
  class Surfaces < Base
12
+ ADD_FLAGS = {
13
+ project: :string,
14
+ name: :string,
15
+ kind: :string,
16
+ audience: :string,
17
+ repo: :string,
18
+ default_branch: :string
19
+ }.freeze
20
+
21
+ VALID_KINDS = %w[api web android ios other].freeze
22
+ VALID_AUDIENCES = %w[customer ops both internal].freeze
23
+
7
24
  def run(args)
25
+ # Subcommand dispatch. Bare `fp surfaces --project X` stays a list, so existing
26
+ # callers (and the docs) keep working.
27
+ if args.first == 'add'
28
+ add(args[1..])
29
+ else
30
+ list(args)
31
+ end
32
+ end
33
+
34
+ private
35
+
36
+ def list(args)
8
37
  opts, = parse_flags(args)
9
38
  require_flag(opts, :project)
10
39
 
@@ -22,13 +51,63 @@ module Fp
22
51
  output.success({ surfaces: surfaces, project: opts[:project] }) do
23
52
  if surfaces.empty?
24
53
  puts "No surfaces configured for project '#{opts[:project]}'."
25
- puts 'Configure surfaces in the web app.'
54
+ puts "Add one with: fp surfaces add <key> --project #{opts[:project]} --name \"<Name>\""
26
55
  else
27
56
  puts "Surfaces for '#{opts[:project]}':"
28
57
  surfaces.each { |s| puts " - #{s}" }
29
58
  end
30
59
  end
31
60
  end
61
+
62
+ def add(args)
63
+ opts, positional = parse_flags(args, ADD_FLAGS)
64
+ require_flag(opts, :project)
65
+
66
+ key = positional.first
67
+ if key.nil? || key.strip.empty?
68
+ output.error('Usage: fp surfaces add <key> --project <slug> [--name "Name"] [--kind api|web|android|ios|other]')
69
+ exit 1
70
+ end
71
+
72
+ validate_choice!(opts[:kind], VALID_KINDS, '--kind')
73
+ validate_choice!(opts[:audience], VALID_AUDIENCES, '--audience')
74
+
75
+ project_id = resolve_project_id(opts[:project])
76
+
77
+ # The API derives key from name and defaults kind/audience/default_branch, but we
78
+ # send the key explicitly because here the key is what the caller actually typed —
79
+ # `add api --name "Backend API"` must produce `api`, not `backend_api`.
80
+ params = { key: key, name: opts[:name] || key }
81
+ params[:kind] = opts[:kind] if opts[:kind]
82
+ params[:audience] = opts[:audience] if opts[:audience]
83
+ params[:repo] = opts[:repo] if opts[:repo]
84
+ params[:default_branch] = opts[:default_branch] if opts[:default_branch]
85
+
86
+ result = client.create_surface(project_id, params)
87
+
88
+ unless result[:ok]
89
+ output.error(result[:error], status: result[:status])
90
+ exit 1
91
+ end
92
+
93
+ surface = result[:data]['surface'] || {}
94
+
95
+ output.success(result[:data]) do
96
+ puts "Created surface '#{surface['key'] || key}' in project '#{opts[:project]}'."
97
+ puts
98
+ puts " Name: #{surface['name']}"
99
+ puts " Kind: #{surface['kind']}"
100
+ puts " Audience: #{surface['audience']}"
101
+ puts " Repo: #{surface['repo'] || '(none)'}"
102
+ end
103
+ end
104
+
105
+ def validate_choice!(value, allowed, flag)
106
+ return if value.nil? || allowed.include?(value)
107
+
108
+ output.error("Invalid #{flag} '#{value}'. Must be one of: #{allowed.join(', ')}")
109
+ exit 1
110
+ end
32
111
  end
33
112
  end
34
113
  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/junit.rb CHANGED
@@ -34,6 +34,16 @@ module Fp
34
34
  # (matching the propose/report convention).
35
35
  MARKER_RE = /fp:([a-z0-9][a-z0-9_-]*)(?:@([a-z0-9_-]+(?:,[a-z0-9_-]+)*))?/.freeze
36
36
 
37
+ # A marker only counts when it is a comment on its own line — the documented
38
+ # convention ("the fp:<slug> comment sits immediately above its test"). Requiring a
39
+ # line-leading comment token is what keeps marker-shaped text inside string literals,
40
+ # fixtures and docs from being mistaken for a real annotation. Without it, any test
41
+ # suite that has tests *about* fp markers reports evidence for its own fixtures.
42
+ #
43
+ # Covers #, //, --, and both the opening and continuation lines of /* ... */ blocks,
44
+ # which spans every language the marker convention documents.
45
+ COMMENT_LINE_RE = %r{\A\s*(?:\#|//|--|/\*|\*)}.freeze
46
+
37
47
  module_function
38
48
 
39
49
  # Parse a JUnit XML file into an array of TestCase structs.
@@ -52,10 +62,14 @@ module Fp
52
62
  cases = []
53
63
 
54
64
  doc.each_element('//testcase') do |el|
65
+ parent = el.parent
66
+ suite_name = parent && parent.expanded_name == 'testsuite' ? parent.attributes['name'] : nil
67
+ classname = el.attributes['classname']
68
+
55
69
  cases << TestCase.new(
56
70
  name: el.attributes['name'],
57
- classname: el.attributes['classname'],
58
- file: el.attributes['file'],
71
+ classname: classname,
72
+ file: resolve_file(el.attributes['file'], classname, suite_name),
59
73
  line: (el.attributes['line'] && el.attributes['line'].to_i),
60
74
  status: testcase_status(el)
61
75
  )
@@ -64,6 +78,26 @@ module Fp
64
78
  cases
65
79
  end
66
80
 
81
+ # Runners disagree about where the source file goes. Minitest- and RSpec-style reports
82
+ # put it on the testcase's `file` attribute; vitest and jest omit `file` entirely and
83
+ # carry the path in `classname` and the enclosing `<testsuite name>`. Markers live in
84
+ # the file, so fall back through the alternatives instead of silently finding nothing.
85
+ def resolve_file(file, classname, suite_name)
86
+ return file if file && !file.empty?
87
+
88
+ [classname, suite_name].find { |candidate| path_like?(candidate) }
89
+ end
90
+
91
+ # Distinguish a source path from a bare test-class name. Deliberately conservative:
92
+ # a Java-style `com.example.FooTest` classname must not be mistaken for a file.
93
+ TEST_FILE_EXT_RE = /\.(rb|js|jsx|mjs|cjs|ts|tsx|kt|kts|swift|java|py|go|cs|php|rs|scala)\z/.freeze
94
+
95
+ def path_like?(value)
96
+ return false if value.nil? || value.empty?
97
+
98
+ value.include?('/') || value.match?(TEST_FILE_EXT_RE)
99
+ end
100
+
67
101
  # Determine present/skipped for a testcase element.
68
102
  # Failures/errors still count as "present" for agent evidence — the test
69
103
  # exists and was executed. Only skipped/pending tests become stubs.
@@ -113,6 +147,12 @@ module Fp
113
147
  markers = markers_for(cases.first, base_dir, marker_cache)
114
148
  next if markers.empty?
115
149
 
150
+ # A title is only trustworthy when we know which test the marker annotates: either
151
+ # the report carried line numbers, or the file holds a single test. Otherwise the
152
+ # marker was matched file-wide and naming any one test would be a guess — vitest
153
+ # and jest emit no line numbers, so this is the common case, not an edge one.
154
+ titles_reliable = cases.any?(&:line) || cases.length == 1
155
+
116
156
  cases.each do |tc|
117
157
  marker = marker_for(tc, cases, markers)
118
158
  next unless marker
@@ -126,7 +166,7 @@ module Fp
126
166
  if surface.nil? || surface.empty?
127
167
  missing_surface << { slug: marker[:slug], file: tc.file }
128
168
  else
129
- record_binding(bindings, marker[:slug], surface, tc)
169
+ record_binding(bindings, marker[:slug], surface, tc, titles_reliable: titles_reliable)
130
170
  end
131
171
  end
132
172
  end
@@ -163,7 +203,7 @@ module Fp
163
203
  candidate
164
204
  end
165
205
 
166
- def record_binding(bindings, slug, surface, testcase)
206
+ def record_binding(bindings, slug, surface, testcase, titles_reliable: true)
167
207
  rel_file = testcase.file
168
208
  key = [slug, surface, rel_file]
169
209
  state = testcase.stub? ? 'stub' : 'present'
@@ -173,7 +213,7 @@ module Fp
173
213
  return if existing && !(existing.state == 'stub' && state == 'present')
174
214
 
175
215
  bindings[key] = Binding.new(slug: slug, surface: surface, file: rel_file,
176
- title: testcase.name, state: state)
216
+ title: (testcase.name if titles_reliable), state: state)
177
217
  end
178
218
 
179
219
  # Load and cache the fp:<slug> markers (with line numbers) for a testcase's
@@ -198,9 +238,13 @@ module Fp
198
238
  # Scan file contents for fp:<slug>[@surface[,surface...]] markers, returning
199
239
  # [{ slug:, surfaces: [..], line: }, ...] (1-based line numbers).
200
240
  # `surfaces` is [] when the marker pins none (use the default surface).
241
+ #
242
+ # Only comment lines are considered — see COMMENT_LINE_RE.
201
243
  def scan_markers(contents)
202
244
  markers = []
203
245
  contents.each_line.with_index(1) do |line, num|
246
+ next unless line.match?(COMMENT_LINE_RE)
247
+
204
248
  line.scan(MARKER_RE) do |(slug, surface_list)|
205
249
  surfaces = surface_list ? surface_list.split(',').map(&:strip).reject(&:empty?) : []
206
250
  markers << { slug: slug, surfaces: surfaces, line: num }
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.5'
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.5
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-14 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