diff --git a/README.md b/README.md index 4cb58cc..07d47a2 100644 --- a/README.md +++ b/README.md @@ -53,11 +53,11 @@ local credential file. On success it returns: {"signed_in":true,"env":"test","basecamp_account_id":"123","basecamp_person_id":"456","display_name":"Alex"} ``` -The Basecamp account and person IDs are strings identifying the actual signed-in -principal (including the bot for API keys). No tokens are returned. +The Basecamp account and person IDs are strings identifying the signed-in +person. No tokens are returned. Missing, invalid, or unverifiable authentication exits nonzero without success -JSON. Credential precedence remains `--api-key`, `FLUX_API_KEY`, then the selected -environment's saved personal credentials. +JSON. Sign in with `flux auth login`; the CLI uses the selected environment's +saved credentials. ### Issue thread JSON @@ -224,5 +224,6 @@ go build -o flux . ./flux issues list --env test # Run against a local server -FLUX_BASE_URL=http://localhost:4040 ./flux scenes list --project next --api-key +FLUX_BASE_URL=http://localhost:4040 ./flux auth login +FLUX_BASE_URL=http://localhost:4040 ./flux scenes list --project next ``` diff --git a/cmd/ai_model_test.go b/cmd/ai_model_test.go index aeaaee1..1ddb4b7 100644 --- a/cmd/ai_model_test.go +++ b/cmd/ai_model_test.go @@ -66,7 +66,8 @@ func TestContentCommandsAIModelRequests(t *testing.T) { } { t.Run(model.name, func(t *testing.T) { isolateContentCommand(t, operation.command) - if err := rootCmd.PersistentFlags().Parse([]string{"--api-key", "fixture-key", "--env", "test", "--project", "fixture-project", "--json"}); err != nil { + saveFixtureToken(t, "test") + if err := rootCmd.PersistentFlags().Parse([]string{"--env", "test", "--project", "fixture-project", "--json"}); err != nil { t.Fatal(err) } want := map[string]interface{}{"project": "fixture-project"} diff --git a/cmd/appsignal.go b/cmd/appsignal.go index 7315688..de0eae5 100644 --- a/cmd/appsignal.go +++ b/cmd/appsignal.go @@ -33,7 +33,7 @@ var appsignalAppsCmd = &cobra.Command{ Use: "apps", Short: "List available AppSignal applications", RunE: func(cmd *cobra.Command, args []string) error { - client := api.NewClient(baseURLForEnv(getEnv()), getAPIKey()) + client := api.NewClient(baseURLForEnv(getEnv()), getAccessToken()) apps, err := client.ListAppSignalApps() if err != nil { @@ -72,7 +72,7 @@ var appsignalIncidentsListCmd = &cobra.Command{ return fmt.Errorf("--app is required") } - client := api.NewClient(baseURLForEnv(getEnv()), getAPIKey()) + client := api.NewClient(baseURLForEnv(getEnv()), getAccessToken()) opts := api.ListIncidentsOptions{ App: appsignalAppFlag, @@ -125,7 +125,7 @@ var appsignalIncidentsGetCmd = &cobra.Command{ return fmt.Errorf("invalid incident number: %s", args[0]) } - client := api.NewClient(baseURLForEnv(getEnv()), getAPIKey()) + client := api.NewClient(baseURLForEnv(getEnv()), getAccessToken()) incident, err := client.GetIncident(appsignalAppFlag, number) if err != nil { @@ -185,7 +185,7 @@ var appsignalIncidentsUpdateCmd = &cobra.Command{ return fmt.Errorf("--app is required") } - client := api.NewClient(baseURLForEnv(getEnv()), getAPIKey()) + client := api.NewClient(baseURLForEnv(getEnv()), getAccessToken()) req := api.UpdateIncidentRequest{ State: appsignalStateFlag, @@ -220,7 +220,7 @@ var appsignalIncidentsNoteCmd = &cobra.Command{ return fmt.Errorf("invalid incident number: %s", args[0]) } - client := api.NewClient(baseURLForEnv(getEnv()), getAPIKey()) + client := api.NewClient(baseURLForEnv(getEnv()), getAccessToken()) if err := client.AddIncidentNote(appsignalAppFlag, number, appsignalContentFlag); err != nil { return err @@ -240,7 +240,7 @@ var appsignalResourcesCmd = &cobra.Command{ return fmt.Errorf("--app is required") } - client := api.NewClient(baseURLForEnv(getEnv()), getAPIKey()) + client := api.NewClient(baseURLForEnv(getEnv()), getAccessToken()) resources, err := client.GetAppSignalResources(appsignalAppFlag, appsignalSectionsFlag) if err != nil { diff --git a/cmd/auth.go b/cmd/auth.go index 911f10b..37d9bce 100644 --- a/cmd/auth.go +++ b/cmd/auth.go @@ -56,7 +56,7 @@ var authStatusCmd = &cobra.Command{ RunE: func(cmd *cobra.Command, args []string) error { env := getEnv() - client := api.NewClient(baseURLForEnv(env), getAPIKey()) + client := api.NewClient(baseURLForEnv(env), getAccessToken()) identity, err := client.GetIdentity() if err != nil { return err diff --git a/cmd/auth_test.go b/cmd/auth_test.go index 7aafa98..1392678 100644 --- a/cmd/auth_test.go +++ b/cmd/auth_test.go @@ -48,11 +48,25 @@ func isolateCommand(t *testing.T) { }) } +// saveFixtureToken signs in to env with the access token "fixture-key". +func saveFixtureToken(t *testing.T, env string) { + t.Helper() + if err := auth.Save(env, &auth.Credentials{AccessToken: "fixture-key", ExpiresAt: time.Now().Add(time.Hour)}); err != nil { + t.Fatal(err) + } +} + func TestAuthStatusVerifiedIdentity(t *testing.T) { - for _, source := range []string{"personal", "environment key", "explicit key"} { - t.Run(source, func(t *testing.T) { + for _, tc := range []struct { + name, apiKeyEnv string + }{ + {name: "personal"}, + {name: "FLUX_API_KEY is ignored", apiKeyEnv: "environment-key"}, + } { + t.Run(tc.name, func(t *testing.T) { isolateCommand(t) t.Setenv("FLUX_ENV", "test") + t.Setenv("FLUX_API_KEY", tc.apiKeyEnv) if err := rootCmd.PersistentFlags().Parse([]string{"--env", "prod", "--json"}); err != nil { t.Fatal(err) } @@ -67,16 +81,6 @@ func TestAuthStatusVerifiedIdentity(t *testing.T) { t.Fatal(err) } credential := "prod-personal" - if source != "personal" { - t.Setenv("FLUX_API_KEY", "environment-key") - credential = "environment-key" - } - if source == "explicit key" { - if err := rootCmd.PersistentFlags().Set("api-key", "explicit-key"); err != nil { - t.Fatal(err) - } - credential = "explicit-key" - } requests := 0 http.DefaultTransport = roundTripFunc(func(r *http.Request) (*http.Response, error) { requests++ @@ -164,3 +168,51 @@ func TestEnvironmentAndProjectSelection(t *testing.T) { }) } } + +func TestRemovedAPIKey(t *testing.T) { + for _, tc := range []struct { + name, apiKeyEnv string + args []string + wantErr error + wantWarning bool + }{ + {name: "no key", args: []string{"projects", "list"}}, + {name: "flag", args: []string{"projects", "list", "--api-key", "old-key"}, wantErr: errAPIKeyRemoved}, + {name: "flag before command", args: []string{"--api-key=old-key", "issues", "list"}, wantErr: errAPIKeyRemoved}, + {name: "flag on auth status", args: []string{"auth", "status", "--api-key", "old-key"}, wantErr: errAPIKeyRemoved}, + {name: "environment", apiKeyEnv: "old-key", args: []string{"projects", "list"}, wantWarning: true}, + } { + t.Run(tc.name, func(t *testing.T) { + isolateCommand(t) + t.Setenv("FLUX_API_KEY", tc.apiKeyEnv) + command, _, err := rootCmd.Find(tc.args) + if err != nil { + t.Fatal(err) + } + if err := rootCmd.ParseFlags(tc.args); err != nil { + t.Fatal(err) + } + if err := command.ParseFlags(tc.args); err != nil { + t.Fatal(err) + } + var stderr bytes.Buffer + command.SetErr(&stderr) + t.Cleanup(func() { command.SetErr(nil) }) + err = rootCmd.PersistentPreRunE(command, nil) + if !errors.Is(err, tc.wantErr) { + t.Fatalf("got error %v; want %v", err, tc.wantErr) + } + warned := strings.Contains(stderr.String(), "FLUX_API_KEY is ignored") + if warned != tc.wantWarning || (tc.wantWarning && !strings.Contains(stderr.String(), "flux auth login")) { + t.Fatalf("unexpected warning output: %q", stderr.String()) + } + }) + } +} + +func TestAPIKeyFlagHidden(t *testing.T) { + flag := rootCmd.PersistentFlags().Lookup("api-key") + if flag == nil || !flag.Hidden { + t.Fatal("--api-key must stay registered but hidden, so old scripts get a helpful error") + } +} diff --git a/cmd/comments.go b/cmd/comments.go index 49de6e3..8906406 100644 --- a/cmd/comments.go +++ b/cmd/comments.go @@ -25,7 +25,7 @@ var commentsUpdateCmd = &cobra.Command{ return fmt.Errorf("--content is required") } - client := api.NewClient(baseURLForEnv(getEnv()), getAPIKey()) + client := api.NewClient(baseURLForEnv(getEnv()), getAccessToken()) result, err := client.UpdateComment(args[0], api.CommentRequest{ Content: commentContent, Project: getProject(), @@ -49,7 +49,7 @@ var commentsDeleteCmd = &cobra.Command{ Short: "Delete (trash) a comment", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - client := api.NewClient(baseURLForEnv(getEnv()), getAPIKey()) + client := api.NewClient(baseURLForEnv(getEnv()), getAccessToken()) result, err := client.DeleteComment(args[0], getProject()) if err != nil { return err diff --git a/cmd/diagrams.go b/cmd/diagrams.go index e02d683..3e1def5 100644 --- a/cmd/diagrams.go +++ b/cmd/diagrams.go @@ -36,7 +36,7 @@ var diagramsRenderCmd = &cobra.Command{ return fmt.Errorf("--file or --mermaid is required") } - client := api.NewClient(baseURLForEnv(getEnv()), getAPIKey()) + client := api.NewClient(baseURLForEnv(getEnv()), getAccessToken()) result, err := client.RenderDiagram(mermaid, diagramCaptionFlag, getProject()) if err != nil { return err diff --git a/cmd/epics.go b/cmd/epics.go index 1d6b9b6..4919e00 100644 --- a/cmd/epics.go +++ b/cmd/epics.go @@ -28,7 +28,7 @@ var epicsListCmd = &cobra.Command{ Use: "list", Short: "List epics", RunE: func(cmd *cobra.Command, args []string) error { - client := api.NewClient(baseURLForEnv(getEnv()), getAPIKey()) + client := api.NewClient(baseURLForEnv(getEnv()), getAccessToken()) opts := api.ListEpicsOptions{ Milestone: epicMilestoneFlag, @@ -73,7 +73,7 @@ var epicsGetCmd = &cobra.Command{ Short: "Get epic details", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - client := api.NewClient(baseURLForEnv(getEnv()), getAPIKey()) + client := api.NewClient(baseURLForEnv(getEnv()), getAccessToken()) epic, err := client.GetEpic(args[0], getProject()) if err != nil { @@ -131,7 +131,7 @@ var epicsCreateCmd = &cobra.Command{ return fmt.Errorf("--title is required") } - client := api.NewClient(baseURLForEnv(getEnv()), getAPIKey()) + client := api.NewClient(baseURLForEnv(getEnv()), getAccessToken()) req := api.CreateEpicRequest{ Title: epicTitleFlag, @@ -163,7 +163,7 @@ var epicsUpdateCmd = &cobra.Command{ Short: "Update an existing epic", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - client := api.NewClient(baseURLForEnv(getEnv()), getAPIKey()) + client := api.NewClient(baseURLForEnv(getEnv()), getAccessToken()) req := api.UpdateEpicRequest{ Title: epicTitleFlag, @@ -195,7 +195,7 @@ var epicsIssuesCmd = &cobra.Command{ Short: "List issues linked to an epic", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - client := api.NewClient(baseURLForEnv(getEnv()), getAPIKey()) + client := api.NewClient(baseURLForEnv(getEnv()), getAccessToken()) issues, err := client.ListEpicIssues(args[0], epicCompletedFlag, getProject()) if err != nil { @@ -242,7 +242,7 @@ var epicsCommentCmd = &cobra.Command{ return fmt.Errorf("--content is required") } - client := api.NewClient(baseURLForEnv(getEnv()), getAPIKey()) + client := api.NewClient(baseURLForEnv(getEnv()), getAccessToken()) req := api.CommentRequest{ Content: epicContentFlag, @@ -270,7 +270,7 @@ var epicsResyncCmd = &cobra.Command{ Short: "Refresh linked issue titles on an epic", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - client := api.NewClient(baseURLForEnv(getEnv()), getAPIKey()) + client := api.NewClient(baseURLForEnv(getEnv()), getAccessToken()) result, err := client.ResyncEpic(args[0], getProject()) if err != nil { return err diff --git a/cmd/images.go b/cmd/images.go index ecbc255..0f49e22 100644 --- a/cmd/images.go +++ b/cmd/images.go @@ -26,7 +26,7 @@ var imagesUploadCmd = &cobra.Command{ return fmt.Errorf("--file is required") } - client := api.NewClient(baseURLForEnv(getEnv()), getAPIKey()) + client := api.NewClient(baseURLForEnv(getEnv()), getAccessToken()) result, err := client.UploadImage(imageFileFlag, imageCaptionFlag, getProject()) if err != nil { return err diff --git a/cmd/issues.go b/cmd/issues.go index acae891..d7ea64e 100644 --- a/cmd/issues.go +++ b/cmd/issues.go @@ -45,7 +45,7 @@ var issuesListCmd = &cobra.Command{ Use: "list", Short: "List issues", RunE: func(cmd *cobra.Command, args []string) error { - client := api.NewClient(baseURLForEnv(getEnv()), getAPIKey()) + client := api.NewClient(baseURLForEnv(getEnv()), getAccessToken()) issues, err := client.ListIssues(api.ListIssuesOptions{ Stage: stageFlag, @@ -94,7 +94,7 @@ var issuesGetCmd = &cobra.Command{ Short: "Get issue details", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - client := api.NewClient(baseURLForEnv(getEnv()), getAPIKey()) + client := api.NewClient(baseURLForEnv(getEnv()), getAccessToken()) issue, err := client.GetIssue(args[0], getProject()) if err != nil { @@ -175,7 +175,7 @@ var issuesCreateCmd = &cobra.Command{ return fmt.Errorf("--app is required (web, ios, or android)") } - client := api.NewClient(baseURLForEnv(getEnv()), getAPIKey()) + client := api.NewClient(baseURLForEnv(getEnv()), getAccessToken()) if err := checkUseCaseTarget(cmd, client); err != nil { return fmt.Errorf("no issue was created: %w", err) } @@ -211,7 +211,7 @@ var issuesUpdateCmd = &cobra.Command{ Short: "Update an existing issue", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - client := api.NewClient(baseURLForEnv(getEnv()), getAPIKey()) + client := api.NewClient(baseURLForEnv(getEnv()), getAccessToken()) if err := checkUseCaseTarget(cmd, client); err != nil { return fmt.Errorf("issue %s was not updated: %w", args[0], err) } @@ -246,7 +246,7 @@ var issuesDeleteCmd = &cobra.Command{ Short: "Delete an issue", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - client := api.NewClient(baseURLForEnv(getEnv()), getAPIKey()) + client := api.NewClient(baseURLForEnv(getEnv()), getAccessToken()) if err := client.DeleteIssue(args[0], getProject()); err != nil { return err @@ -270,7 +270,7 @@ var issuesCommentCmd = &cobra.Command{ return fmt.Errorf("--content is required") } - client := api.NewClient(baseURLForEnv(getEnv()), getAPIKey()) + client := api.NewClient(baseURLForEnv(getEnv()), getAccessToken()) req := api.CommentRequest{ Content: issueCommentContentFlag, @@ -298,7 +298,7 @@ var issuesAdvanceCmd = &cobra.Command{ Short: "Advance an issue to the next stage", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - client := api.NewClient(baseURLForEnv(getEnv()), getAPIKey()) + client := api.NewClient(baseURLForEnv(getEnv()), getAccessToken()) req := api.AdvanceIssueRequest{ TargetStage: issueTargetStageFlag, @@ -344,7 +344,7 @@ var issuesLinkCmd = &cobra.Command{ return fmt.Errorf("--target-type and --target-id are required") } - client := api.NewClient(baseURLForEnv(getEnv()), getAPIKey()) + client := api.NewClient(baseURLForEnv(getEnv()), getAccessToken()) action := "link" if issueUnlinkFlag { @@ -382,7 +382,7 @@ var issuesAssignCmd = &cobra.Command{ if issueAssigneeIDsFlag == "" { return fmt.Errorf("--assignees is required") } - client := api.NewClient(baseURLForEnv(getEnv()), getAPIKey()) + client := api.NewClient(baseURLForEnv(getEnv()), getAccessToken()) req := api.AssignIssueRequest{ AssigneeIDs: issueAssigneeIDsFlag, Project: getProject(), diff --git a/cmd/milestones.go b/cmd/milestones.go index 969a5ee..c0a1b67 100644 --- a/cmd/milestones.go +++ b/cmd/milestones.go @@ -31,7 +31,7 @@ var milestonesListCmd = &cobra.Command{ Use: "list", Short: "List milestones", RunE: func(cmd *cobra.Command, args []string) error { - client := api.NewClient(baseURLForEnv(getEnv()), getAPIKey()) + client := api.NewClient(baseURLForEnv(getEnv()), getAccessToken()) opts := api.ListMilestonesOptions{ Completed: milestoneCompletedFlag, @@ -80,7 +80,7 @@ var milestonesGetCmd = &cobra.Command{ Short: "Get milestone details", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - client := api.NewClient(baseURLForEnv(getEnv()), getAPIKey()) + client := api.NewClient(baseURLForEnv(getEnv()), getAccessToken()) milestone, err := client.GetMilestone(args[0], getProject()) if err != nil { @@ -157,7 +157,7 @@ var milestonesCreateCmd = &cobra.Command{ return fmt.Errorf("--app is required (web, ios, or android)") } - client := api.NewClient(baseURLForEnv(getEnv()), getAPIKey()) + client := api.NewClient(baseURLForEnv(getEnv()), getAccessToken()) req := api.CreateMilestoneRequest{ Title: milestoneTitleFlag, @@ -193,7 +193,7 @@ var milestonesUpdateCmd = &cobra.Command{ Short: "Update an existing milestone", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - client := api.NewClient(baseURLForEnv(getEnv()), getAPIKey()) + client := api.NewClient(baseURLForEnv(getEnv()), getAccessToken()) req := api.UpdateMilestoneRequest{ Title: milestoneTitleFlag, @@ -233,7 +233,7 @@ var milestonesIssuesCmd = &cobra.Command{ Short: "List issues linked to a milestone", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - client := api.NewClient(baseURLForEnv(getEnv()), getAPIKey()) + client := api.NewClient(baseURLForEnv(getEnv()), getAccessToken()) issues, err := client.ListMilestoneIssues(args[0], milestoneCompletedFlag, getProject()) if err != nil { @@ -275,7 +275,7 @@ var milestonesCommentCmd = &cobra.Command{ return fmt.Errorf("--content is required") } - client := api.NewClient(baseURLForEnv(getEnv()), getAPIKey()) + client := api.NewClient(baseURLForEnv(getEnv()), getAccessToken()) req := api.CommentRequest{ Content: milestoneContentFlag, @@ -303,7 +303,7 @@ var milestonesResyncCmd = &cobra.Command{ Short: "Refresh linked issue titles on a milestone", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - client := api.NewClient(baseURLForEnv(getEnv()), getAPIKey()) + client := api.NewClient(baseURLForEnv(getEnv()), getAccessToken()) result, err := client.ResyncMilestone(args[0], getProject()) if err != nil { return err diff --git a/cmd/people.go b/cmd/people.go index 446cc85..5fffb0b 100644 --- a/cmd/people.go +++ b/cmd/people.go @@ -17,7 +17,7 @@ var peopleListCmd = &cobra.Command{ Use: "list", Short: "List people on the project", RunE: func(cmd *cobra.Command, args []string) error { - client := api.NewClient(baseURLForEnv(getEnv()), getAPIKey()) + client := api.NewClient(baseURLForEnv(getEnv()), getAccessToken()) people, err := client.ListPeople(getProject()) if err != nil { return err diff --git a/cmd/product.go b/cmd/product.go index 7c1b71e..2e20631 100644 --- a/cmd/product.go +++ b/cmd/product.go @@ -59,7 +59,7 @@ IDs may be Basecamp IDs or codes such as UC-NEXT-01.`, } func (p *productCommand) client() *api.Client { - return api.NewClient(baseURLForEnv(getEnv()), getAPIKey()) + return api.NewClient(baseURLForEnv(getEnv()), getAccessToken()) } func (p *productCommand) listCmd() *cobra.Command { diff --git a/cmd/product_test.go b/cmd/product_test.go index 0e339fe..90a83da 100644 --- a/cmd/product_test.go +++ b/cmd/product_test.go @@ -43,7 +43,8 @@ func runCommand(t *testing.T, argv []string, json bool, handler http.HandlerFunc t.Fatal(err) } isolateContentCommand(t, command) - persistent := []string{"--api-key", "fixture-key", "--env", "test", "--project", "fixture-project"} + saveFixtureToken(t, "test") + persistent := []string{"--env", "test", "--project", "fixture-project"} if json { persistent = append(persistent, "--json") } diff --git a/cmd/projects.go b/cmd/projects.go index 2478676..c70f13c 100644 --- a/cmd/projects.go +++ b/cmd/projects.go @@ -17,7 +17,7 @@ var projectsListCmd = &cobra.Command{ Use: "list", Short: "List available projects", RunE: func(cmd *cobra.Command, args []string) error { - client := api.NewClient(baseURLForEnv(getEnv()), getAPIKey()) + client := api.NewClient(baseURLForEnv(getEnv()), getAccessToken()) projects, err := client.ListProjects() if err != nil { diff --git a/cmd/root.go b/cmd/root.go index f21be03..45238c7 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -14,10 +14,26 @@ import ( var ( envFlag string jsonFlag bool - apiKeyFlag string projectFlag string ) +// errAPIKeyRemoved is returned for the removed --api-key flag. The server +// rejects shared API keys: Flux acts on behalf of the signed-in person. +var errAPIKeyRemoved = errors.New("API keys are no longer supported; run flux auth login") + +// rejectAPIKey stops old scripts that still pass --api-key, and warns that +// FLUX_API_KEY is ignored. +func rejectAPIKey(cmd *cobra.Command, args []string) error { + if cmd.Flags().Changed("api-key") { + cmd.SilenceUsage = true + return errAPIKeyRemoved + } + if os.Getenv("FLUX_API_KEY") != "" { + fmt.Fprintln(cmd.ErrOrStderr(), "Warning: FLUX_API_KEY is ignored. API keys are no longer supported; run flux auth login") + } + return nil +} + func getEnv() string { // Flag takes precedence, then env var, then default if rootCmd.PersistentFlags().Changed("env") { @@ -40,14 +56,9 @@ func baseURLForEnv(env string) string { return "https://eyra-flux.fly.dev" } -func getAPIKey() string { - if apiKeyFlag != "" { - return apiKeyFlag - } - if key := os.Getenv("FLUX_API_KEY"); key != "" { - return key - } - +// getAccessToken returns the saved access token for the selected +// environment, refreshing it when it expires soon. +func getAccessToken() string { env := getEnv() creds, err := auth.Load(env) if err != nil || creds == nil { @@ -144,6 +155,7 @@ var rootCmd = &cobra.Command{ Environments: prod - eyra-flux (default) - Eyra dev projects (Next, Feldspar) test - eyra-flux-test - Flux dogfooding`, + PersistentPreRunE: rejectAPIKey, } func Execute() { @@ -156,6 +168,7 @@ func Execute() { func init() { rootCmd.PersistentFlags().StringVarP(&envFlag, "env", "e", "prod", "Environment: prod or test") rootCmd.PersistentFlags().BoolVar(&jsonFlag, "json", false, "Output as JSON") - rootCmd.PersistentFlags().StringVar(&apiKeyFlag, "api-key", "", "API key for API requests (or use FLUX_API_KEY env var)") + rootCmd.PersistentFlags().String("api-key", "", "Removed: API keys are no longer supported; run flux auth login") + rootCmd.PersistentFlags().MarkHidden("api-key") //nolint:errcheck rootCmd.PersistentFlags().StringVar(&projectFlag, "project", "", `Project key, e.g. flux, next or feldspar; "flux projects list" shows all (default: next on prod, flux on test)`) } diff --git a/internal/api/client.go b/internal/api/client.go index a0f5fe0..879f381 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -36,7 +36,7 @@ const ( type Client struct { baseURL string - apiKey string + token string httpClient *http.Client // legacyDelivery is set once the server turns out to predate // /api/delivery; delivery requests then go to /api/dev. @@ -140,10 +140,10 @@ type AdvanceResult struct { UserCommentID string `json:"user_comment_id,omitempty"` } -func NewClient(baseURL, apiKey string) *Client { +func NewClient(baseURL, token string) *Client { return &Client{ baseURL: baseURL, - apiKey: apiKey, + token: token, httpClient: &http.Client{}, } } @@ -188,8 +188,8 @@ func (c *Client) send(endpoint string, build func(endpoint string) (*http.Reques if err != nil { return nil, err } - if c.apiKey != "" { - req.Header.Set("Authorization", "Bearer "+c.apiKey) + if c.token != "" { + req.Header.Set("Authorization", "Bearer "+c.token) } resp, err := c.httpClient.Do(req) if err != nil || !delivery || c.legacyDelivery || !routeMissing(resp) { @@ -286,7 +286,7 @@ func ambiguousCodeError(message string, ids []string) error { } func (c *Client) GetIdentity() (*Identity, error) { - if c.apiKey == "" { + if c.token == "" { return nil, ErrUnauthorized } diff --git a/skill/SKILL.md b/skill/SKILL.md index 3e659cb..4e1bf6b 100644 --- a/skill/SKILL.md +++ b/skill/SKILL.md @@ -246,11 +246,11 @@ flux auth logout --env prod --project --json # Sign out of production flux auth status --env prod --project --json # Check production status ``` -Credentials stored in `~/.config/flux/credentials.json`, one entry per environment. The old `FLUX_API_KEY` env var and `--api-key` flag still work for CI/CD. +Credentials stored in `~/.config/flux/credentials.json`, one entry per environment. Sign in with `flux auth login`. `auth status` verifies the active credentials through the selected server's -`GET /api/delivery/identity` endpoint. Credential precedence is `--api-key`, then -`FLUX_API_KEY`, then saved personal credentials for the selected environment. +`GET /api/delivery/identity` endpoint, using the saved credentials for the +selected environment. Missing, invalid, or unverifiable authentication exits nonzero without success JSON; a local credential file alone is not proof of authentication. @@ -329,8 +329,8 @@ Use `--json` output to chain commands: extract the `id` field from create respon {"signed_in":true,"env":"prod","basecamp_account_id":"123","basecamp_person_id":"456","display_name":"Alex"} ``` -Account and person IDs are strings. For API keys the identity is the actual bot -principal. No provider credentials or tokens are included. +Account and person IDs are strings identifying the signed-in person. No provider +credentials or tokens are included. `flux issues get --env prod --project --json` scopes the issue lookup to that project; issues outside it return an error. Each `thread` comment