From afdb71e2f26343e8c326ef3a929fbbefcfe26698 Mon Sep 17 00:00:00 2001 From: Miguel Martinez Trivino Date: Tue, 8 Sep 2026 19:33:42 +0200 Subject: [PATCH 01/30] feat(cli): ask for the organization and project on trace init `chainloop trace init` failed with "--project is required" before the user had any way to learn what a project is or how to name one, and gave no guidance on which organization the attestations would land in. On a terminal it now walks through both settings. A single organization is used and reported; several are offered as a list. Projects are picked from the ones the user can see, or created by typing a free-form name that is normalized to the DNS-1123 label the control plane stores. Values already in .chainloop.yml are the preselected defaults, and a choice that differs from the file is written back, so the repository stays self-describing. Flags keep their meaning and skip the matching question. Nothing is asked in CI or when output is redirected, where --project stays required, and CHAINLOOP_NO_PROMPT turns prompting off on a terminal. The picker needs an organization-wide project listing, which the control plane did not have, so this also adds ProjectService.List: org-scoped, RBAC-filtered through the visible-project set, with a name filter and offset pagination. It is backported from the platform's ProjectsService.List without the product, version and repository enrichment that has no meaning here. Assisted-by: Claude Code Signed-off-by: Miguel Martinez Trivino Chainloop-Trace-Sessions: 184822bb-e811-42fd-8e13-735227edc5ce --- app/cli/cmd/root.go | 6 + app/cli/cmd/trace_init.go | 42 +- app/cli/cmd/trace_init_identity.go | 236 +++++++++ app/cli/cmd/trace_init_identity_test.go | 475 ++++++++++++++++++ app/cli/cmd/trace_init_prompt.go | 119 +++++ app/cli/cmd/trace_init_prompt_test.go | 76 +++ app/cli/cmd/trace_init_resolve.go | 113 +++++ app/cli/cmd/trace_init_test.go | 11 +- app/cli/documentation/cli-reference.mdx | 9 + app/cli/internal/trace/config/slug.go | 79 +++ app/cli/internal/trace/config/slug_test.go | 106 ++++ app/cli/pkg/action/trace_identity.go | 98 ++++ app/cli/pkg/action/trace_identity_test.go | 120 +++++ .../api/controlplane/v1/project.pb.go | 435 ++++++++++++---- .../api/controlplane/v1/project.proto | 32 ++ .../api/controlplane/v1/project_grpc.pb.go | 39 ++ .../gen/frontend/controlplane/v1/project.ts | 330 ++++++++++++ ....ProjectServiceListRequest.jsonschema.json | 18 + ...e.v1.ProjectServiceListRequest.schema.json | 18 + ...ceListResponse.ProjectItem.jsonschema.json | 40 ++ ...erviceListResponse.ProjectItem.schema.json | 40 ++ ...ProjectServiceListResponse.jsonschema.json | 21 + ....v1.ProjectServiceListResponse.schema.json | 21 + app/controlplane/internal/service/project.go | 58 +++ app/controlplane/pkg/authz/authz.go | 7 + app/controlplane/pkg/biz/project.go | 34 ++ .../pkg/biz/project_list_integration_test.go | 190 +++++++ app/controlplane/pkg/data/project.go | 54 +- go.mod | 20 + go.sum | 40 ++ 30 files changed, 2756 insertions(+), 131 deletions(-) create mode 100644 app/cli/cmd/trace_init_identity.go create mode 100644 app/cli/cmd/trace_init_identity_test.go create mode 100644 app/cli/cmd/trace_init_prompt.go create mode 100644 app/cli/cmd/trace_init_prompt_test.go create mode 100644 app/cli/cmd/trace_init_resolve.go create mode 100644 app/cli/internal/trace/config/slug.go create mode 100644 app/cli/internal/trace/config/slug_test.go create mode 100644 app/cli/pkg/action/trace_identity.go create mode 100644 app/cli/pkg/action/trace_identity_test.go create mode 100644 app/controlplane/api/gen/jsonschema/controlplane.v1.ProjectServiceListRequest.jsonschema.json create mode 100644 app/controlplane/api/gen/jsonschema/controlplane.v1.ProjectServiceListRequest.schema.json create mode 100644 app/controlplane/api/gen/jsonschema/controlplane.v1.ProjectServiceListResponse.ProjectItem.jsonschema.json create mode 100644 app/controlplane/api/gen/jsonschema/controlplane.v1.ProjectServiceListResponse.ProjectItem.schema.json create mode 100644 app/controlplane/api/gen/jsonschema/controlplane.v1.ProjectServiceListResponse.jsonschema.json create mode 100644 app/controlplane/api/gen/jsonschema/controlplane.v1.ProjectServiceListResponse.schema.json create mode 100644 app/controlplane/pkg/biz/project_list_integration_test.go diff --git a/app/cli/cmd/root.go b/app/cli/cmd/root.go index 55e3ce8c2..ddc862189 100644 --- a/app/cli/cmd/root.go +++ b/app/cli/cmd/root.go @@ -53,6 +53,11 @@ var ( defaultCASAPI = "api.cas.chainloop.dev:443" apiToken string flagYes bool + // authTokenIsUser records whether the credentials in use are a user session + // rather than an API token. Commands that behave differently for the two, + // such as `trace init` skipping organization selection for org-scoped API + // tokens, read it instead of parsing the token again. + authTokenIsUser bool ) const ( @@ -127,6 +132,7 @@ func NewRootCmd(l zerolog.Logger) *cobra.Command { if err != nil { return err } + authTokenIsUser = isUserToken // If the auth token is not set and the command supports federated auth, we try to discover the runner and use the federated token for the runner if available if authToken == "" && cmdSupportsFederatedAuth(cmd) { diff --git a/app/cli/cmd/trace_init.go b/app/cli/cmd/trace_init.go index f4d141ef5..22ba544af 100644 --- a/app/cli/cmd/trace_init.go +++ b/app/cli/cmd/trace_init.go @@ -52,6 +52,15 @@ providers (Claude Code when none is given), and creates the Chainloop workflow the attestations target. Nothing is written to the repository until that workflow exists, so you need to be logged in. +On a terminal it asks which organization and project to use, offering what +.chainloop.yml already holds so pressing Enter keeps it. A new project can be +named freely; the name is normalized to the lowercase, dash-separated form +Chainloop stores. Passing --org or --project skips the matching question. + +Nothing is asked in CI or when the output is redirected: there --project is +required unless .chainloop.yml carries projectName. Set CHAINLOOP_NO_PROMPT to +turn the questions off on a terminal too. + The organization, project, workflow and require-trace values are saved to .chainloop.yml, and every push reads them from there.`, RunE: func(cmd *cobra.Command, _ []string) error { @@ -65,7 +74,16 @@ The organization, project, workflow and require-trace values are saved to return err } - if err := ensureTraceInitWorkflow(cmd.Context(), cfg, contract); err != nil { + // Fill in whatever the flags and .chainloop.yml did not settle, + // asking the user when there is one to ask. The executor it hands + // back is pinned to the resolved organization and reused below. + executor, err := resolveTraceIdentity(cmd.Context(), cfg, repoRoot) + if err != nil { + return err + } + defer func() { _ = executor.Close() }() + + if err := ensureTraceInitWorkflow(cmd.Context(), executor, cfg, contract); err != nil { return err } @@ -140,19 +158,10 @@ The organization, project, workflow and require-trace values are saved to // the pre-push hook, the control plane creates it implicitly with an empty // contract and no one reads the result. Anything other than the workflow // already existing is fatal, so a failed init leaves nothing behind. -func ensureTraceInitWorkflow(ctx context.Context, cfg *traceInitConfig, contractFlag string) error { - // If the repo pins an organization, use it so the workflow lands where the - // hooks will actually attest. An empty name keeps the default connection. - executor, err := action.NewAttestationExecutor(ActionOpts, Version, action.WithForcedOrganization(cfg.organization)) - if err != nil { - return err - } - defer func() { _ = executor.Close() }() - - if err := executor.CheckAuth(ctx); err != nil { - return err - } - +// +// It runs on the executor resolveTraceIdentity opened, which is already pinned +// to the resolved organization and authenticated. +func ensureTraceInitWorkflow(ctx context.Context, executor *action.AttestationExecutor, cfg *traceInitConfig, contractFlag string) error { contractName, contractRequired := config.ResolveContract(contractFlag) wf, err := executor.EnsureWorkflow(ctx, action.EnsureTraceWorkflowOpts{ ProjectName: cfg.project, @@ -200,12 +209,11 @@ func resolveTraceInitConfig(cmd *cobra.Command, repoRoot, projectFlag string) (* organization: config.LoadOrganizationFromYML(repoRoot), } + // A project may still be missing here. resolveTraceIdentity either asks for + // one or, when nobody can be asked, reports that it is required. if cfg.project == "" { cfg.project = config.LoadProjectFromYML(repoRoot) } - if cfg.project == "" { - return nil, fmt.Errorf("--project is required (or add projectName to .chainloop.yml)") - } // --org is inherited from the root command, so it is only meant for this // repository when the user passed it explicitly. diff --git a/app/cli/cmd/trace_init_identity.go b/app/cli/cmd/trace_init_identity.go new file mode 100644 index 000000000..7e8e4c343 --- /dev/null +++ b/app/cli/cmd/trace_init_identity.go @@ -0,0 +1,236 @@ +// +// Copyright 2026 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package cmd + +import ( + "cmp" + "context" + "errors" + "fmt" + "slices" + + "github.com/chainloop-dev/chainloop/app/cli/internal/trace/config" + "github.com/chainloop-dev/chainloop/app/cli/pkg/action" +) + +const ( + orgPromptTitle = "Chainloop organization for this repository" + projectPromptTitle = "Chainloop project for this repository" + newProjectPromptTitle = "Name for the new project" + + // createNewProjectOption is the first entry of the project picker. + createNewProjectOption = "Create a new project…" +) + +// errAborted is returned when the user dismisses a prompt. It stops `trace +// init` before anything is created or written, so the repository is left +// untouched. +var errAborted = errors.New("aborted") + +// ciSignals are environment variables that mean the CLI is running inside a CI +// system, where a prompt would hang the build. CI covers almost every provider; +// the rest are for the ones that do not set it. +var ciSignals = []string{ + "CI", + "CONTINUOUS_INTEGRATION", + "BUILD_NUMBER", + "BUILD_BUILDID", + "TEAMCITY_VERSION", + "JENKINS_URL", +} + +// noPromptEnvVar lets a user with a real terminal turn prompting off, for +// wrappers and automation that run the CLI attached to a pseudo-terminal. +const noPromptEnvVar = "CHAINLOOP_NO_PROMPT" + +// orgLister lists the organizations the current user belongs to. It is the +// slice of the attestation executor the organization resolver needs, so that +// resolver can be table-tested without a control plane. +type orgLister interface { + ListOrganizations(ctx context.Context) ([]*action.MembershipItem, error) +} + +// projectLister lists the projects visible in the selected organization, and +// is likewise the slice of the executor the project resolver needs. +type projectLister interface { + ListProjects(ctx context.Context) ([]string, error) +} + +// prompter asks the user to pick from a list or type a value. It keeps the +// resolution logic below independent of the terminal library, so it can be +// table-tested with a scripted fake. +type prompter interface { + Select(title string, options []string, defaultValue string) (string, error) + Input(title, defaultValue string, validate func(string) error) (string, error) +} + +// resolvedValue is one setting the interactive path settled on. +type resolvedValue struct { + value string + // save is true when the value differs from what .chainloop.yml holds and so + // has to be written back. An absent value in the file counts as different, + // which leaves an interactive init with a fully self-describing repository. + save bool + // prompted is false when the value was settled without asking, so the caller + // can report what was picked on the user's behalf. + prompted bool +} + +// isInteractive reports whether the session can show a prompt: both streams the +// prompt uses are terminals, the terminal can render one, and this is not a CI +// run. It takes its inputs so the decision can be unit tested, the same way +// colorSupported does. +func isInteractive(lookupEnv func(string) (string, bool), stdinIsTerminal, stderrIsTerminal bool) bool { + if envEnabled(lookupEnv, noPromptEnvVar) { + return false + } + + // The prompt reads from stdin and draws on stderr; either one redirected + // means there is nobody to answer. + if !stdinIsTerminal || !stderrIsTerminal { + return false + } + + if v, ok := lookupEnv("TERM"); ok && v == "dumb" { + return false + } + + for _, key := range ciSignals { + if envEnabled(lookupEnv, key) { + return false + } + } + + return true +} + +// envEnabled reports whether an environment variable is set to something that +// means "yes". Present but empty, "0" or "false" is treated as unset, since +// developers do set CI=false locally. +func envEnabled(lookupEnv func(string) (string, bool), key string) bool { + v, ok := lookupEnv(key) + return ok && v != "" && v != "0" && v != "false" +} + +// resolveInteractiveOrganization picks the organization the trace attestations +// land in. fromYML is what .chainloop.yml pins, currentOrg the one the CLI is +// pointing at; both may be empty. +func resolveInteractiveOrganization(ctx context.Context, lister orgLister, p prompter, fromYML, currentOrg string) (*resolvedValue, error) { + memberships, err := lister.ListOrganizations(ctx) + if err != nil { + return nil, fmt.Errorf("listing your organizations: %w", err) + } + + names := make([]string, 0, len(memberships)) + // serverDefault is the membership the control plane marks as the current one. + var serverDefault string + for _, m := range memberships { + if m.Org == nil || m.Org.Name == "" { + continue + } + + names = append(names, m.Org.Name) + if m.Default { + serverDefault = m.Org.Name + } + } + + switch len(names) { + case 0: + return nil, errors.New("you do not belong to any organization, create one with `chainloop organization create`") + case 1: + return &resolvedValue{value: names[0], save: names[0] != fromYML}, nil + } + + // Preselect what the repository already pins, then what the CLI points at, + // then what the server considers current. + chosen, err := p.Select(orgPromptTitle, names, firstPresent(names, fromYML, currentOrg, serverDefault)) + if err != nil { + return nil, err + } + + return &resolvedValue{value: chosen, save: chosen != fromYML, prompted: true}, nil +} + +// resolveInteractiveProject picks the project the workflow is created in, or +// collects a name for a new one. fromYML is what .chainloop.yml pins and +// repoDir the repository's directory name, used to seed a new name. +func resolveInteractiveProject(ctx context.Context, lister projectLister, p prompter, fromYML, repoDir string) (*resolvedValue, error) { + visible, err := lister.ListProjects(ctx) + if err != nil { + return nil, fmt.Errorf("listing the projects you can see: %w", err) + } + + // Nothing to choose from, so go straight to naming one. + if len(visible) == 0 { + return promptNewProject(p, cmp.Or(fromYML, config.SlugifyDNS1123(repoDir)), fromYML) + } + + // A project pinned in .chainloop.yml is offered even when the listing does + // not carry it: the user may only be able to see it through this repository, + // or it may not exist yet. Either way it is still this repository's project. + if fromYML != "" && !slices.Contains(visible, fromYML) { + visible = append(visible, fromYML) + } + + options := append([]string{createNewProjectOption}, visible...) + + chosen, err := p.Select(projectPromptTitle, options, cmp.Or(fromYML, createNewProjectOption)) + if err != nil { + return nil, err + } + + if chosen != createNewProjectOption { + return &resolvedValue{value: chosen, save: chosen != fromYML, prompted: true}, nil + } + + // They asked for a new project, so seed the name from the repository rather + // than from the project they are moving away from. + return promptNewProject(p, config.SlugifyDNS1123(repoDir), fromYML) +} + +// promptNewProject collects a free-form project name and returns it normalized +// to the DNS-1123 label the control plane stores. A name that normalizes to one +// of the existing projects simply resolves to that project. +func promptNewProject(p prompter, defaultName, fromYML string) (*resolvedValue, error) { + answer, err := p.Input(newProjectPromptTitle, defaultName, validateNewProjectName) + if err != nil { + return nil, err + } + + name := config.SlugifyDNS1123(answer) + + return &resolvedValue{value: name, save: name != fromYML, prompted: true}, nil +} + +// validateNewProjectName checks what a free-form name normalizes to, so a name +// that cannot become a valid project is refused at the prompt instead of by the +// control plane a moment later. +func validateNewProjectName(answer string) error { + return config.ValidateDNS1123Label(config.SlugifyDNS1123(answer)) +} + +// firstPresent returns the first candidate that appears in options, falling +// back to the first option. +func firstPresent(options []string, candidates ...string) string { + for _, c := range candidates { + if c != "" && slices.Contains(options, c) { + return c + } + } + + return options[0] +} diff --git a/app/cli/cmd/trace_init_identity_test.go b/app/cli/cmd/trace_init_identity_test.go new file mode 100644 index 000000000..8f083353d --- /dev/null +++ b/app/cli/cmd/trace_init_identity_test.go @@ -0,0 +1,475 @@ +// +// Copyright 2026 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package cmd + +import ( + "context" + "errors" + "testing" + + "github.com/chainloop-dev/chainloop/app/cli/pkg/action" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// --- fakes ------------------------------------------------------------------- + +type fakeOrgLister struct { + orgs []*action.MembershipItem + err error +} + +func (f *fakeOrgLister) ListOrganizations(context.Context) ([]*action.MembershipItem, error) { + return f.orgs, f.err +} + +type fakeProjectLister struct { + projects []string + err error +} + +func (f *fakeProjectLister) ListProjects(context.Context) ([]string, error) { + return f.projects, f.err +} + +// recordedPrompt captures what a prompt was shown, so tests can assert on the +// options and the preselected default rather than only on the outcome. +type recordedPrompt struct { + title string + options []string + defaultValue string +} + +// fakePrompter answers with scripted values. Input runs the real validator +// first, the way a terminal prompt would, so validation is covered through the +// same seam the huh implementation uses. +type fakePrompter struct { + selectAnswer string + selectErr error + inputAnswer string + inputErr error + + selects []recordedPrompt + inputs []recordedPrompt +} + +func (f *fakePrompter) Select(title string, options []string, defaultValue string) (string, error) { + f.selects = append(f.selects, recordedPrompt{title: title, options: options, defaultValue: defaultValue}) + if f.selectErr != nil { + return "", f.selectErr + } + + return f.selectAnswer, nil +} + +func (f *fakePrompter) Input(title, defaultValue string, validate func(string) error) (string, error) { + f.inputs = append(f.inputs, recordedPrompt{title: title, defaultValue: defaultValue}) + if f.inputErr != nil { + return "", f.inputErr + } + + if validate != nil { + if err := validate(f.inputAnswer); err != nil { + return "", err + } + } + + return f.inputAnswer, nil +} + +func membership(name string, isDefault bool) *action.MembershipItem { + return &action.MembershipItem{Default: isDefault, Org: &action.OrgItem{Name: name}} +} + +// Fixtures the table cases share. +const ( + orgAcme = "acme" + orgGlobex = "globex" + projectAPI = "backend-api" + repoDirMyRepo = "My Repo" +) + +// --- isInteractive ----------------------------------------------------------- + +func TestIsInteractive(t *testing.T) { + testCases := []struct { + name string + env map[string]string + stdin bool + stderr bool + want bool + }{ + {name: "both streams are terminals and no CI signal", stdin: true, stderr: true, want: true}, + {name: "stdin is not a terminal", stdin: false, stderr: true, want: false}, + {name: "stderr is not a terminal", stdin: true, stderr: false, want: false}, + {name: "neither is a terminal", want: false}, + { + name: "CI is set, even with a pseudo-terminal", + env: map[string]string{"CI": "true"}, + stdin: true, stderr: true, want: false, + }, + { + name: "CI explicitly set to false is not CI", + env: map[string]string{"CI": "false"}, + stdin: true, stderr: true, want: true, + }, + { + name: "CI set to an empty value is not CI", + env: map[string]string{"CI": ""}, + stdin: true, stderr: true, want: true, + }, + { + name: "Jenkins does not set CI but is still detected", + env: map[string]string{"JENKINS_URL": "https://ci.example.com"}, + stdin: true, stderr: true, want: false, + }, + { + name: "a dumb terminal cannot render a prompt", + env: map[string]string{"TERM": "dumb"}, + stdin: true, stderr: true, want: false, + }, + { + name: "the explicit opt-out wins over a real terminal", + env: map[string]string{"CHAINLOOP_NO_PROMPT": "1"}, + stdin: true, stderr: true, want: false, + }, + { + name: "the opt-out set to 0 does not opt out", + env: map[string]string{"CHAINLOOP_NO_PROMPT": "0"}, + stdin: true, stderr: true, want: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + lookupEnv := func(key string) (string, bool) { + v, ok := tc.env[key] + return v, ok + } + assert.Equal(t, tc.want, isInteractive(lookupEnv, tc.stdin, tc.stderr)) + }) + } +} + +// --- organization ------------------------------------------------------------ + +func TestResolveInteractiveOrganization(t *testing.T) { + testCases := []struct { + name string + orgs []*action.MembershipItem + fromYML string + currentOrg string + answer string + wantValue string + wantSave bool + // wantDefault is the preselected entry; empty means no prompt is expected + wantDefault string + wantErr string + }{ + { + name: "belonging to no organization is an error that points at the fix", + orgs: nil, + wantErr: "chainloop organization create", + }, + { + name: "a single organization is used without prompting", + orgs: []*action.MembershipItem{membership(orgAcme, true)}, + wantValue: orgAcme, + wantSave: true, + }, + { + name: "a single organization already pinned needs no save", + orgs: []*action.MembershipItem{membership(orgAcme, true)}, + fromYML: orgAcme, + wantValue: orgAcme, + wantSave: false, + }, + { + name: "several organizations preselect the pinned one", + orgs: []*action.MembershipItem{membership(orgAcme, true), membership(orgGlobex, false)}, + fromYML: orgGlobex, + currentOrg: orgAcme, + answer: orgGlobex, + wantValue: orgGlobex, + wantSave: false, + wantDefault: orgGlobex, + }, + { + name: "with nothing pinned the CLI's current organization is preselected", + orgs: []*action.MembershipItem{membership(orgAcme, false), membership(orgGlobex, true)}, + currentOrg: orgAcme, + answer: orgAcme, + wantValue: orgAcme, + wantSave: true, + wantDefault: orgAcme, + }, + { + name: "with no current organization the default membership is preselected", + orgs: []*action.MembershipItem{membership(orgAcme, false), membership(orgGlobex, true)}, + answer: orgGlobex, + wantValue: orgGlobex, + wantSave: true, + wantDefault: orgGlobex, + }, + { + name: "a pinned organization the user no longer belongs to falls back to the current one", + orgs: []*action.MembershipItem{membership(orgAcme, true), membership(orgGlobex, false)}, + fromYML: "left-this-one", + currentOrg: orgAcme, + answer: orgAcme, + wantValue: orgAcme, + wantSave: true, + wantDefault: orgAcme, + }, + { + name: "picking a different organization than the pinned one saves it", + orgs: []*action.MembershipItem{membership(orgAcme, true), membership(orgGlobex, false)}, + fromYML: orgAcme, + currentOrg: orgAcme, + answer: orgGlobex, + wantValue: orgGlobex, + wantSave: true, + wantDefault: orgAcme, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + p := &fakePrompter{selectAnswer: tc.answer} + got, err := resolveInteractiveOrganization(context.Background(), &fakeOrgLister{orgs: tc.orgs}, p, tc.fromYML, tc.currentOrg) + + if tc.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tc.wantErr) + return + } + + require.NoError(t, err) + assert.Equal(t, tc.wantValue, got.value) + assert.Equal(t, tc.wantSave, got.save) + + if tc.wantDefault == "" { + assert.Empty(t, p.selects, "no prompt should have been shown") + assert.False(t, got.prompted, "the value was not prompted for") + return + } + + assert.True(t, got.prompted) + require.Len(t, p.selects, 1) + assert.Equal(t, tc.wantDefault, p.selects[0].defaultValue) + assert.Len(t, p.selects[0].options, len(tc.orgs)) + }) + } +} + +func TestResolveInteractiveOrganizationErrors(t *testing.T) { + t.Run("a listing failure is returned, not swallowed", func(t *testing.T) { + _, err := resolveInteractiveOrganization(context.Background(), + &fakeOrgLister{err: errors.New("boom")}, &fakePrompter{}, "", "") + require.Error(t, err) + assert.Contains(t, err.Error(), "boom") + }) + + t.Run("an aborted prompt is returned", func(t *testing.T) { + orgs := []*action.MembershipItem{membership(orgAcme, true), membership(orgGlobex, false)} + _, err := resolveInteractiveOrganization(context.Background(), + &fakeOrgLister{orgs: orgs}, &fakePrompter{selectErr: errAborted}, "", "") + require.ErrorIs(t, err, errAborted) + }) +} + +// --- project ----------------------------------------------------------------- + +func TestResolveInteractiveProject(t *testing.T) { + testCases := []struct { + name string + projects []string + fromYML string + repoDir string + // selectAnswer is what the user picks in the list; empty means the list + // is not expected to be shown + selectAnswer string + // inputAnswer is what the user types when creating + inputAnswer string + wantValue string + wantSave bool + // wantOptions is the full option list, in order + wantOptions []string + // wantSelectDefault / wantInputDefault are the preselected values + wantSelectDefault string + wantInputDefault string + wantErr string + }{ + { + name: "with no visible projects the name is typed straight away", + repoDir: repoDirMyRepo, + inputAnswer: "my-repo", + wantValue: "my-repo", + wantSave: true, + wantInputDefault: "my-repo", + }, + { + name: "with no visible projects the pinned project is the default", + fromYML: "existing", + repoDir: repoDirMyRepo, + inputAnswer: "existing", + wantValue: "existing", + wantSave: false, + wantInputDefault: "existing", + }, + { + name: "the pinned project is preselected in the list", + projects: []string{projectAPI, "payments"}, + fromYML: "payments", + repoDir: "whatever", + selectAnswer: "payments", + wantValue: "payments", + wantSave: false, + wantOptions: []string{createNewProjectOption, projectAPI, "payments"}, + wantSelectDefault: "payments", + }, + { + name: "with nothing pinned the create entry is preselected", + projects: []string{projectAPI, "payments"}, + repoDir: repoDirMyRepo, + selectAnswer: projectAPI, + wantValue: projectAPI, + wantSave: true, + wantOptions: []string{createNewProjectOption, projectAPI, "payments"}, + wantSelectDefault: createNewProjectOption, + }, + { + name: "a pinned project that is not visible is still offered and preselected", + projects: []string{projectAPI}, + fromYML: "not-visible-to-me", + repoDir: "whatever", + selectAnswer: "not-visible-to-me", + wantValue: "not-visible-to-me", + wantSave: false, + wantOptions: []string{createNewProjectOption, projectAPI, "not-visible-to-me"}, + wantSelectDefault: "not-visible-to-me", + }, + { + name: "choosing to create opens an input prefilled with the repository name", + projects: []string{projectAPI}, + repoDir: "My Cool Project", + selectAnswer: createNewProjectOption, + inputAnswer: "My Cool Project", + wantValue: "my-cool-project", + wantSave: true, + wantOptions: []string{createNewProjectOption, projectAPI}, + wantSelectDefault: createNewProjectOption, + wantInputDefault: "my-cool-project", + }, + { + name: "a free-form new name is normalized", + projects: []string{projectAPI}, + repoDir: "repo", + selectAnswer: createNewProjectOption, + inputAnswer: " Payments API (v2)!! ", + wantValue: "payments-api-v2", + wantSave: true, + wantOptions: []string{createNewProjectOption, projectAPI}, + wantSelectDefault: createNewProjectOption, + wantInputDefault: "repo", + }, + { + name: "a new name that normalizes to an existing project resolves to it", + projects: []string{projectAPI}, + repoDir: "repo", + selectAnswer: createNewProjectOption, + inputAnswer: "Backend API", + wantValue: projectAPI, + wantSave: true, + wantOptions: []string{createNewProjectOption, projectAPI}, + }, + { + name: "a name that normalizes to nothing is rejected", + projects: []string{projectAPI}, + repoDir: "repo", + selectAnswer: createNewProjectOption, + inputAnswer: "!!!", + wantErr: "cannot be empty", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + p := &fakePrompter{selectAnswer: tc.selectAnswer, inputAnswer: tc.inputAnswer} + got, err := resolveInteractiveProject(context.Background(), + &fakeProjectLister{projects: tc.projects}, p, tc.fromYML, tc.repoDir) + + if tc.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tc.wantErr) + return + } + + require.NoError(t, err) + assert.Equal(t, tc.wantValue, got.value) + assert.Equal(t, tc.wantSave, got.save) + + if tc.wantOptions == nil { + assert.Empty(t, p.selects, "no list should have been shown") + } else { + require.Len(t, p.selects, 1) + assert.Equal(t, tc.wantOptions, p.selects[0].options) + if tc.wantSelectDefault != "" { + assert.Equal(t, tc.wantSelectDefault, p.selects[0].defaultValue) + } + } + + if tc.wantInputDefault != "" { + require.Len(t, p.inputs, 1) + assert.Equal(t, tc.wantInputDefault, p.inputs[0].defaultValue) + } + }) + } +} + +// TestResolveTraceIdentityNonInteractive covers the path a CI run takes. Test +// binaries have no terminal attached, so isInteractive is false here and the +// step never reaches the control plane. +func TestResolveTraceIdentityNonInteractive(t *testing.T) { + t.Run("a missing project is reported, naming both ways to supply one", func(t *testing.T) { + _, err := resolveTraceIdentity(context.Background(), &traceInitConfig{}, t.TempDir()) + require.Error(t, err) + assert.Contains(t, err.Error(), "--project is required in non-interactive mode") + assert.Contains(t, err.Error(), "projectName to .chainloop.yml") + }) +} + +func TestResolveInteractiveProjectErrors(t *testing.T) { + t.Run("a listing failure is returned, not swallowed", func(t *testing.T) { + _, err := resolveInteractiveProject(context.Background(), + &fakeProjectLister{err: errors.New("boom")}, &fakePrompter{}, "", "repo") + require.Error(t, err) + assert.Contains(t, err.Error(), "boom") + }) + + t.Run("an aborted list is returned", func(t *testing.T) { + _, err := resolveInteractiveProject(context.Background(), + &fakeProjectLister{projects: []string{"a"}}, &fakePrompter{selectErr: errAborted}, "", "repo") + require.ErrorIs(t, err, errAborted) + }) + + t.Run("an aborted input is returned", func(t *testing.T) { + _, err := resolveInteractiveProject(context.Background(), + &fakeProjectLister{}, &fakePrompter{inputErr: errAborted}, "", "repo") + require.ErrorIs(t, err, errAborted) + }) +} diff --git a/app/cli/cmd/trace_init_prompt.go b/app/cli/cmd/trace_init_prompt.go new file mode 100644 index 000000000..af411eaab --- /dev/null +++ b/app/cli/cmd/trace_init_prompt.go @@ -0,0 +1,119 @@ +// +// Copyright 2026 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package cmd + +import ( + "errors" + "fmt" + "io" + "os" + + "github.com/chainloop-dev/chainloop/app/cli/internal/trace/config" + + "charm.land/huh/v2" +) + +// accessibleEnvVars turn on plain, numbered prompts instead of the full +// terminal UI, for screen readers and terminals that cannot render one. +// ACCESSIBLE is the convention the Charm tools use. +var accessibleEnvVars = []string{"CHAINLOOP_ACCESSIBLE", "ACCESSIBLE"} + +// huhPrompter implements prompter against the terminal. It is deliberately +// thin: every decision lives in the resolvers in trace_init_identity.go, which +// are tested against a scripted fake instead of a terminal. +type huhPrompter struct { + accessible bool + // in and out are the streams the prompt uses. Prompts are drawn on stderr so + // stdout stays clean for anything piping the command's output. + in io.Reader + out io.Writer +} + +// newHuhPrompter builds the terminal prompter. +func newHuhPrompter(lookupEnv func(string) (string, bool)) *huhPrompter { + accessible := false + for _, key := range accessibleEnvVars { + if envEnabled(lookupEnv, key) { + accessible = true + break + } + } + + return &huhPrompter{accessible: accessible, in: os.Stdin, out: os.Stderr} +} + +// Select asks the user to pick one of options, starting on defaultValue. Long +// lists can be narrowed by typing. +func (h *huhPrompter) Select(title string, options []string, defaultValue string) (string, error) { + value := defaultValue + + field := huh.NewSelect[string](). + Title(title). + Options(huh.NewOptions(options...)...). + Filtering(true). + Value(&value) + + if err := h.run(field); err != nil { + return "", err + } + + return value, nil +} + +// Input asks the user to type a value, prefilled with defaultValue. The +// description line shows what a free-form answer will be normalized to, so the +// result is visible before it is submitted. +func (h *huhPrompter) Input(title, defaultValue string, validate func(string) error) (string, error) { + value := defaultValue + + field := huh.NewInput(). + Title(title). + Value(&value). + Validate(validate). + DescriptionFunc(func() string { + slug := config.SlugifyDNS1123(value) + if slug == "" || slug == value { + return "" + } + + return fmt.Sprintf("will be created as %q", slug) + }, &value) + + if err := h.run(field); err != nil { + return "", err + } + + return value, nil +} + +// run shows a single-field form and translates a dismissed prompt into +// errAborted, which trace init reports as a clean stop. +func (h *huhPrompter) run(field huh.Field) error { + form := huh.NewForm(huh.NewGroup(field)). + WithAccessible(h.accessible). + WithInput(h.in). + WithOutput(h.out) + + if err := form.Run(); err != nil { + if errors.Is(err, huh.ErrUserAborted) { + return errAborted + } + + return err + } + + return nil +} diff --git a/app/cli/cmd/trace_init_prompt_test.go b/app/cli/cmd/trace_init_prompt_test.go new file mode 100644 index 000000000..b98139ad2 --- /dev/null +++ b/app/cli/cmd/trace_init_prompt_test.go @@ -0,0 +1,76 @@ +// +// Copyright 2026 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package cmd + +import ( + "bytes" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// accessiblePrompter drives the real huh implementation in accessible mode, +// where prompts are plain text over the given streams. It is the only way to +// exercise the terminal code without a terminal. +func accessiblePrompter(input string) (*huhPrompter, *bytes.Buffer) { + out := &bytes.Buffer{} + + return &huhPrompter{accessible: true, in: strings.NewReader(input), out: out}, out +} + +func TestHuhPrompterSelect(t *testing.T) { + // Accessible mode numbers the options and reads the choice as a line. + p, out := accessiblePrompter("2\n") + + got, err := p.Select("Pick one", []string{"alpha", "beta", "gamma"}, "alpha") + require.NoError(t, err) + assert.Equal(t, "beta", got) + assert.Contains(t, out.String(), "Pick one") +} + +func TestHuhPrompterInput(t *testing.T) { + p, out := accessiblePrompter("my-project\n") + + got, err := p.Input("Project name", "default-name", validateNewProjectName) + require.NoError(t, err) + assert.Equal(t, "my-project", got) + assert.Contains(t, out.String(), "Project name") +} + +func TestNewHuhPrompterAccessibility(t *testing.T) { + testCases := []struct { + name string + env map[string]string + want bool + }{ + {name: "off by default", want: false}, + {name: "the Chainloop variable turns it on", env: map[string]string{"CHAINLOOP_ACCESSIBLE": "1"}, want: true}, + {name: "the Charm convention turns it on", env: map[string]string{"ACCESSIBLE": "true"}, want: true}, + {name: "set to 0 leaves it off", env: map[string]string{"ACCESSIBLE": "0"}, want: false}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + p := newHuhPrompter(func(key string) (string, bool) { + v, ok := tc.env[key] + return v, ok + }) + assert.Equal(t, tc.want, p.accessible) + }) + } +} diff --git a/app/cli/cmd/trace_init_resolve.go b/app/cli/cmd/trace_init_resolve.go new file mode 100644 index 000000000..80cbb8979 --- /dev/null +++ b/app/cli/cmd/trace_init_resolve.go @@ -0,0 +1,113 @@ +// +// Copyright 2026 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package cmd + +import ( + "context" + "errors" + "os" + "path/filepath" + + "github.com/chainloop-dev/chainloop/app/cli/pkg/action" + + "github.com/spf13/viper" + "golang.org/x/term" +) + +// resolveTraceIdentity settles the organization and project that were not fixed +// by a flag or by .chainloop.yml, prompting for them when the session is +// interactive. It updates cfg in place, marking for write-back only the values +// that differ from what the file holds. +// +// It returns an authenticated executor pinned to the resolved organization, +// which the caller reuses to create the workflow and must close. Nothing is +// written to the repository here: the caller saves only after the workflow +// exists, so an aborted or failed init leaves the repository untouched. +func resolveTraceIdentity(ctx context.Context, cfg *traceInitConfig, repoRoot string) (*action.AttestationExecutor, error) { + // An API token is bound to its own organization server-side, so there is + // nothing to choose and no membership list to read. + interactive := authTokenIsUser && isInteractive(os.LookupEnv, + term.IsTerminal(int(os.Stdin.Fd())), term.IsTerminal(int(os.Stderr.Fd()))) + + if !interactive { + if cfg.project == "" { + return nil, errors.New("--project is required in non-interactive mode (or add projectName to .chainloop.yml)") + } + + return openTraceExecutor(ctx, cfg.organization) + } + + // Authenticate before asking anything: the pickers are populated from the + // control plane, so an unauthenticated user should see the login error + // rather than answer questions that lead to one. + executor, err := openTraceExecutor(ctx, cfg.organization) + if err != nil { + return nil, err + } + + prompt := newHuhPrompter(os.LookupEnv) + + // The organization step runs on this connection whatever it is pinned to: + // listing memberships is a user-scoped call that ignores the organization. + org, err := resolveInteractiveOrganization(ctx, executor, prompt, + cfg.organization, viper.GetString(confOptions.organization.viperKey)) + if err != nil { + _ = executor.Close() + return nil, err + } + + if !org.prompted { + logger.Info().Str("organization", org.value).Msg("using your only organization") + } + + // Reopen pinned to the chosen organization so the project listing and the + // workflow creation that follows both target it. + if org.value != cfg.organization { + _ = executor.Close() + if executor, err = openTraceExecutor(ctx, org.value); err != nil { + return nil, err + } + } + + cfg.organization, cfg.saveOrganization = org.value, org.save + + project, err := resolveInteractiveProject(ctx, executor, prompt, + cfg.project, filepath.Base(repoRoot)) + if err != nil { + _ = executor.Close() + return nil, err + } + + cfg.project, cfg.saveProject = project.value, project.save + + return executor, nil +} + +// openTraceExecutor dials the control plane pinned to orgName, an empty name +// keeping the default connection, and verifies the credentials work. +func openTraceExecutor(ctx context.Context, orgName string) (*action.AttestationExecutor, error) { + executor, err := action.NewAttestationExecutor(ActionOpts, Version, action.WithForcedOrganization(orgName)) + if err != nil { + return nil, err + } + + if err := executor.CheckAuth(ctx); err != nil { + _ = executor.Close() + return nil, err + } + + return executor, nil +} diff --git a/app/cli/cmd/trace_init_test.go b/app/cli/cmd/trace_init_test.go index 1b0bb0f69..44493b8d7 100644 --- a/app/cli/cmd/trace_init_test.go +++ b/app/cli/cmd/trace_init_test.go @@ -92,9 +92,14 @@ func TestResolveTraceInitConfig(t *testing.T) { wantSaved: []string{"requireTrace"}, }, { - name: "a project name is required", - yml: "projectVersion: v1.0.0\n", - wantErr: "--project is required", + // Resolving no longer fails on a missing project: the interactive + // path asks for one, and the non-interactive path reports it. See + // TestResolveTraceIdentityNonInteractive. + name: "a missing project name is left for the identity step", + yml: "projectVersion: v1.0.0\n", + wantProject: "", + // The workflow still gets its default. + wantWorkflow: "ai-coding-session", }, } diff --git a/app/cli/documentation/cli-reference.mdx b/app/cli/documentation/cli-reference.mdx index bf1041a5f..e2ebab091 100755 --- a/app/cli/documentation/cli-reference.mdx +++ b/app/cli/documentation/cli-reference.mdx @@ -3311,6 +3311,15 @@ providers (Claude Code when none is given), and creates the Chainloop workflow the attestations target. Nothing is written to the repository until that workflow exists, so you need to be logged in. +On a terminal it asks which organization and project to use, offering what +.chainloop.yml already holds so pressing Enter keeps it. A new project can be +named freely; the name is normalized to the lowercase, dash-separated form +Chainloop stores. Passing --org or --project skips the matching question. + +Nothing is asked in CI or when the output is redirected: there --project is +required unless .chainloop.yml carries projectName. Set CHAINLOOP_NO_PROMPT to +turn the questions off on a terminal too. + The organization, project, workflow and require-trace values are saved to .chainloop.yml, and every push reads them from there. diff --git a/app/cli/internal/trace/config/slug.go b/app/cli/internal/trace/config/slug.go new file mode 100644 index 000000000..bce92d347 --- /dev/null +++ b/app/cli/internal/trace/config/slug.go @@ -0,0 +1,79 @@ +// +// Copyright 2026 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package config + +import ( + "errors" + "fmt" + "strings" + + "k8s.io/apimachinery/pkg/util/validation" +) + +// SlugifyDNS1123 turns a free-form project name into a DNS-1123 label, the form +// the control plane accepts. It lowercases, replaces every run of characters +// that are not lowercase alphanumerics with a single dash, and trims the dashes +// off both ends. Names longer than the limit are cut, and cutting never leaves a +// trailing dash behind. +// +// It returns an empty string for input that has nothing usable in it, e.g. +// "!!!". Callers are expected to reject that rather than pass it on; +// ValidateDNS1123Label produces the message for it. +func SlugifyDNS1123(name string) string { + var b strings.Builder + b.Grow(len(name)) + + // Collapse separator runs as we go, so "a b" and "a-b" produce the same slug. + pendingSeparator := false + for _, r := range strings.ToLower(name) { + if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') { + if pendingSeparator && b.Len() > 0 { + b.WriteByte('-') + } + pendingSeparator = false + b.WriteRune(r) + continue + } + + pendingSeparator = true + } + + slug := b.String() + if len(slug) > validation.DNS1123LabelMaxLength { + slug = strings.TrimRight(slug[:validation.DNS1123LabelMaxLength], "-") + } + + return slug +} + +// ValidateDNS1123Label reports whether name is a project name the control plane +// will accept, using the same rule the server applies at creation time so a bad +// name is caught at the prompt rather than by a failing API call. +func ValidateDNS1123Label(name string) error { + if name == "" { + return errors.New("the project name cannot be empty") + } + + if len(name) > validation.DNS1123LabelMaxLength { + return fmt.Errorf("the project name is %d characters long, the maximum is %d", len(name), validation.DNS1123LabelMaxLength) + } + + if errs := validation.IsDNS1123Label(name); len(errs) > 0 { + return errors.New("the project name must contain only lowercase letters, numbers and dashes, and start and end with a letter or a number") + } + + return nil +} diff --git a/app/cli/internal/trace/config/slug_test.go b/app/cli/internal/trace/config/slug_test.go new file mode 100644 index 000000000..3edeaf3b5 --- /dev/null +++ b/app/cli/internal/trace/config/slug_test.go @@ -0,0 +1,106 @@ +// +// Copyright 2026 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package config + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "k8s.io/apimachinery/pkg/util/validation" +) + +func TestSlugifyDNS1123(t *testing.T) { + testCases := []struct { + name string + input string + want string + }{ + {name: "empty input", input: "", want: ""}, + {name: "an already valid name is unchanged", input: "my-project", want: "my-project"}, + {name: "capitals are lowercased", input: "MyProject", want: "myproject"}, + {name: "spaces become dashes", input: "My Cool Project", want: "my-cool-project"}, + {name: "runs of separators collapse into one dash", input: " --Foo_Bar!! ", want: "foo-bar"}, + {name: "punctuation is dropped", input: "acme.io/payments (v2)", want: "acme-io-payments-v2"}, + {name: "leading and trailing dashes are trimmed", input: "---edge---", want: "edge"}, + {name: "digits are kept, including as the first character", input: "3D Renderer", want: "3d-renderer"}, + {name: "underscores are separators", input: "snake_case_name", want: "snake-case-name"}, + {name: "non-ascii characters are dropped", input: "café münchen", want: "caf-m-nchen"}, + {name: "a name of only punctuation normalizes to empty", input: "!!!", want: ""}, + { + name: "an over-long name is cut to 63 characters", + input: strings.Repeat("a", 70), + want: strings.Repeat("a", 63), + }, + { + // Cutting at 63 must not leave a trailing dash, which would be invalid. + name: "cutting does not leave a trailing dash", + input: strings.Repeat("a", 63) + " tail", + want: strings.Repeat("a", 63), + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + got := SlugifyDNS1123(tc.input) + assert.Equal(t, tc.want, got) + + // Whatever comes out must either be empty or be a name the server accepts. + if got != "" { + assert.Empty(t, validation.IsDNS1123Label(got), "slug %q is not a valid DNS-1123 label", got) + } + }) + } +} + +func TestSlugifyDNS1123IsIdempotent(t *testing.T) { + for _, input := range []string{"My Cool Project", " --Foo_Bar!! ", strings.Repeat("a", 70), "already-valid"} { + once := SlugifyDNS1123(input) + assert.Equal(t, once, SlugifyDNS1123(once), "slugifying %q twice changed the result", input) + } +} + +func TestValidateDNS1123Label(t *testing.T) { + testCases := []struct { + name string + input string + wantErr string + }{ + {name: "a valid name passes", input: "my-project"}, + {name: "a single character is valid", input: "a"}, + {name: "63 characters is valid", input: strings.Repeat("a", 63)}, + {name: "empty is rejected", input: "", wantErr: "cannot be empty"}, + {name: "capitals are rejected", input: "MyProject", wantErr: "lowercase"}, + {name: "spaces are rejected", input: "my project", wantErr: "lowercase"}, + {name: "a leading dash is rejected", input: "-my-project", wantErr: "lowercase"}, + {name: "a trailing dash is rejected", input: "my-project-", wantErr: "lowercase"}, + {name: "64 characters is rejected", input: strings.Repeat("a", 64), wantErr: "63"}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + err := ValidateDNS1123Label(tc.input) + if tc.wantErr == "" { + assert.NoError(t, err) + return + } + + require.Error(t, err) + assert.Contains(t, err.Error(), tc.wantErr) + }) + } +} diff --git a/app/cli/pkg/action/trace_identity.go b/app/cli/pkg/action/trace_identity.go new file mode 100644 index 000000000..3a5102815 --- /dev/null +++ b/app/cli/pkg/action/trace_identity.go @@ -0,0 +1,98 @@ +// +// Copyright 2026 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package action + +import ( + "context" + "fmt" + "slices" + + pb "github.com/chainloop-dev/chainloop/app/controlplane/api/controlplane/v1" +) + +// traceProjectPageSize is how many projects are fetched per request. It is the +// maximum the control plane accepts, so most organizations need a single call. +const traceProjectPageSize int32 = 100 + +// ListOrganizations returns the organizations the current user belongs to, so +// `trace init` can offer them. It is a UserService call, which ignores the +// organization header, and so returns the same list on any connection. +func (e *AttestationExecutor) ListOrganizations(ctx context.Context) ([]*MembershipItem, error) { + return NewMembershipList(e.actionOpts).ListOrgs(ctx) +} + +// ListProjects returns the names of the projects visible in the organization +// the executor is pinned to, sorted and deduplicated. It runs over the +// executor's connection, so it targets the organization pinned with +// WithForcedOrganization like every other trace operation. +func (e *AttestationExecutor) ListProjects(ctx context.Context) ([]string, error) { + return listAllTraceProjects(ctx, &cpTraceProjectAPI{cfg: e.actionOpts}) +} + +// traceProjectAPI is the slice of the control-plane API the project listing +// drives, so the paging can be tested without a live control plane. +type traceProjectAPI interface { + // listProjectsPage returns one page of project names plus the total number + // of pages the server reports. + listProjectsPage(ctx context.Context, page, pageSize int32) ([]string, int32, error) +} + +// listAllTraceProjects walks every page and returns the names it collected. +func listAllTraceProjects(ctx context.Context, api traceProjectAPI) ([]string, error) { + names := make([]string, 0, traceProjectPageSize) + + for page := int32(1); ; page++ { + got, totalPages, err := api.listProjectsPage(ctx, page, traceProjectPageSize) + if err != nil { + return nil, fmt.Errorf("listing projects: %w", err) + } + + names = append(names, got...) + + // Stop on the last page the server reports, and also on an empty one, so + // a server reporting more pages than it serves cannot spin here. + if len(got) == 0 || page >= totalPages { + break + } + } + + slices.Sort(names) + + return slices.Compact(names), nil +} + +// cpTraceProjectAPI implements traceProjectAPI against the control plane. +type cpTraceProjectAPI struct { + cfg *ActionsOpts +} + +func (a *cpTraceProjectAPI) listProjectsPage(ctx context.Context, page, pageSize int32) ([]string, int32, error) { + client := pb.NewProjectServiceClient(a.cfg.CPConnection) + + resp, err := client.List(ctx, &pb.ProjectServiceListRequest{ + Pagination: &pb.OffsetPaginationRequest{Page: page, PageSize: pageSize}, + }) + if err != nil { + return nil, 0, err + } + + names := make([]string, 0, len(resp.GetProjects())) + for _, p := range resp.GetProjects() { + names = append(names, p.GetName()) + } + + return names, resp.GetPagination().GetTotalPages(), nil +} diff --git a/app/cli/pkg/action/trace_identity_test.go b/app/cli/pkg/action/trace_identity_test.go new file mode 100644 index 000000000..26e2e2e84 --- /dev/null +++ b/app/cli/pkg/action/trace_identity_test.go @@ -0,0 +1,120 @@ +// +// Copyright 2026 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package action + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// fakeProjectPager serves canned pages, recording what it was asked for. +type fakeProjectPager struct { + pages [][]string + // totalPages is what the server reports; it defaults to len(pages) + totalPages int32 + err error + + requested []int32 +} + +func (f *fakeProjectPager) listProjectsPage(_ context.Context, page, _ int32) ([]string, int32, error) { + f.requested = append(f.requested, page) + if f.err != nil { + return nil, 0, f.err + } + + total := f.totalPages + if total == 0 { + total = int32(len(f.pages)) + } + + idx := int(page) - 1 + if idx < 0 || idx >= len(f.pages) { + return nil, total, nil + } + + return f.pages[idx], total, nil +} + +// Project names the table cases share. +const ( + projAlpha = "alpha" + projBeta = "beta" + projGamma = "gamma" +) + +func TestListAllTraceProjects(t *testing.T) { + testCases := []struct { + name string + pages [][]string + totalPages int32 + want []string + wantRequested []int32 + }{ + { + name: "no projects", + pages: nil, + want: []string{}, + wantRequested: []int32{1}, + }, + { + name: "a single page is returned as is", + pages: [][]string{{projAlpha, projBeta}}, + want: []string{projAlpha, projBeta}, + wantRequested: []int32{1}, + }, + { + name: "every page is fetched and concatenated", + pages: [][]string{{projAlpha, projBeta}, {projGamma}}, + want: []string{projAlpha, projBeta, projGamma}, + wantRequested: []int32{1, 2}, + }, + { + name: "a project repeated across pages appears once", + pages: [][]string{{projAlpha, projBeta}, {projBeta, projGamma}}, + want: []string{projAlpha, projBeta, projGamma}, + wantRequested: []int32{1, 2}, + }, + { + name: "an empty page stops the walk even when the server claims more", + // A server that reports more pages than it serves must not spin here. + pages: [][]string{{projAlpha}, {}}, + totalPages: 50, + want: []string{projAlpha}, + wantRequested: []int32{1, 2}, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + pager := &fakeProjectPager{pages: tc.pages, totalPages: tc.totalPages} + got, err := listAllTraceProjects(context.Background(), pager) + require.NoError(t, err) + assert.Equal(t, tc.want, got) + assert.Equal(t, tc.wantRequested, pager.requested) + }) + } +} + +func TestListAllTraceProjectsError(t *testing.T) { + _, err := listAllTraceProjects(context.Background(), &fakeProjectPager{err: errors.New("boom")}) + require.Error(t, err) + assert.Contains(t, err.Error(), "boom") +} diff --git a/app/controlplane/api/controlplane/v1/project.pb.go b/app/controlplane/api/controlplane/v1/project.pb.go index 9a5f57de5..8159fc802 100644 --- a/app/controlplane/api/controlplane/v1/project.pb.go +++ b/app/controlplane/api/controlplane/v1/project.pb.go @@ -38,6 +38,116 @@ const ( _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) ) +// ProjectServiceListRequest contains parameters for filtering and paginating the projects of an organization +type ProjectServiceListRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Filter by project name, case-insensitive substring match + Name *string `protobuf:"bytes,1,opt,name=name,proto3,oneof" json:"name,omitempty"` + // Pagination parameters to limit and offset results + Pagination *OffsetPaginationRequest `protobuf:"bytes,2,opt,name=pagination,proto3" json:"pagination,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ProjectServiceListRequest) Reset() { + *x = ProjectServiceListRequest{} + mi := &file_controlplane_v1_project_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ProjectServiceListRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ProjectServiceListRequest) ProtoMessage() {} + +func (x *ProjectServiceListRequest) ProtoReflect() protoreflect.Message { + mi := &file_controlplane_v1_project_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ProjectServiceListRequest.ProtoReflect.Descriptor instead. +func (*ProjectServiceListRequest) Descriptor() ([]byte, []int) { + return file_controlplane_v1_project_proto_rawDescGZIP(), []int{0} +} + +func (x *ProjectServiceListRequest) GetName() string { + if x != nil && x.Name != nil { + return *x.Name + } + return "" +} + +func (x *ProjectServiceListRequest) GetPagination() *OffsetPaginationRequest { + if x != nil { + return x.Pagination + } + return nil +} + +// ProjectServiceListResponse contains a paginated list of the organization's projects +type ProjectServiceListResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // List of projects matching the request criteria + Projects []*ProjectServiceListResponse_ProjectItem `protobuf:"bytes,1,rep,name=projects,proto3" json:"projects,omitempty"` + // Pagination information for the response + Pagination *OffsetPaginationResponse `protobuf:"bytes,2,opt,name=pagination,proto3" json:"pagination,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ProjectServiceListResponse) Reset() { + *x = ProjectServiceListResponse{} + mi := &file_controlplane_v1_project_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ProjectServiceListResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ProjectServiceListResponse) ProtoMessage() {} + +func (x *ProjectServiceListResponse) ProtoReflect() protoreflect.Message { + mi := &file_controlplane_v1_project_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ProjectServiceListResponse.ProtoReflect.Descriptor instead. +func (*ProjectServiceListResponse) Descriptor() ([]byte, []int) { + return file_controlplane_v1_project_proto_rawDescGZIP(), []int{1} +} + +func (x *ProjectServiceListResponse) GetProjects() []*ProjectServiceListResponse_ProjectItem { + if x != nil { + return x.Projects + } + return nil +} + +func (x *ProjectServiceListResponse) GetPagination() *OffsetPaginationResponse { + if x != nil { + return x.Pagination + } + return nil +} + // ProjectServiceListMembersRequest contains the information needed to list members of a project type ProjectServiceListMembersRequest struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -51,7 +161,7 @@ type ProjectServiceListMembersRequest struct { func (x *ProjectServiceListMembersRequest) Reset() { *x = ProjectServiceListMembersRequest{} - mi := &file_controlplane_v1_project_proto_msgTypes[0] + mi := &file_controlplane_v1_project_proto_msgTypes[2] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -63,7 +173,7 @@ func (x *ProjectServiceListMembersRequest) String() string { func (*ProjectServiceListMembersRequest) ProtoMessage() {} func (x *ProjectServiceListMembersRequest) ProtoReflect() protoreflect.Message { - mi := &file_controlplane_v1_project_proto_msgTypes[0] + mi := &file_controlplane_v1_project_proto_msgTypes[2] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -76,7 +186,7 @@ func (x *ProjectServiceListMembersRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ProjectServiceListMembersRequest.ProtoReflect.Descriptor instead. func (*ProjectServiceListMembersRequest) Descriptor() ([]byte, []int) { - return file_controlplane_v1_project_proto_rawDescGZIP(), []int{0} + return file_controlplane_v1_project_proto_rawDescGZIP(), []int{2} } func (x *ProjectServiceListMembersRequest) GetProjectReference() *IdentityReference { @@ -106,7 +216,7 @@ type ProjectServiceListMembersResponse struct { func (x *ProjectServiceListMembersResponse) Reset() { *x = ProjectServiceListMembersResponse{} - mi := &file_controlplane_v1_project_proto_msgTypes[1] + mi := &file_controlplane_v1_project_proto_msgTypes[3] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -118,7 +228,7 @@ func (x *ProjectServiceListMembersResponse) String() string { func (*ProjectServiceListMembersResponse) ProtoMessage() {} func (x *ProjectServiceListMembersResponse) ProtoReflect() protoreflect.Message { - mi := &file_controlplane_v1_project_proto_msgTypes[1] + mi := &file_controlplane_v1_project_proto_msgTypes[3] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -131,7 +241,7 @@ func (x *ProjectServiceListMembersResponse) ProtoReflect() protoreflect.Message // Deprecated: Use ProjectServiceListMembersResponse.ProtoReflect.Descriptor instead. func (*ProjectServiceListMembersResponse) Descriptor() ([]byte, []int) { - return file_controlplane_v1_project_proto_rawDescGZIP(), []int{1} + return file_controlplane_v1_project_proto_rawDescGZIP(), []int{3} } func (x *ProjectServiceListMembersResponse) GetMembers() []*ProjectMember { @@ -174,7 +284,7 @@ type ProjectMember struct { func (x *ProjectMember) Reset() { *x = ProjectMember{} - mi := &file_controlplane_v1_project_proto_msgTypes[2] + mi := &file_controlplane_v1_project_proto_msgTypes[4] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -186,7 +296,7 @@ func (x *ProjectMember) String() string { func (*ProjectMember) ProtoMessage() {} func (x *ProjectMember) ProtoReflect() protoreflect.Message { - mi := &file_controlplane_v1_project_proto_msgTypes[2] + mi := &file_controlplane_v1_project_proto_msgTypes[4] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -199,7 +309,7 @@ func (x *ProjectMember) ProtoReflect() protoreflect.Message { // Deprecated: Use ProjectMember.ProtoReflect.Descriptor instead. func (*ProjectMember) Descriptor() ([]byte, []int) { - return file_controlplane_v1_project_proto_rawDescGZIP(), []int{2} + return file_controlplane_v1_project_proto_rawDescGZIP(), []int{4} } func (x *ProjectMember) GetSubject() isProjectMember_Subject { @@ -295,7 +405,7 @@ type ProjectServiceAddMemberRequest struct { func (x *ProjectServiceAddMemberRequest) Reset() { *x = ProjectServiceAddMemberRequest{} - mi := &file_controlplane_v1_project_proto_msgTypes[3] + mi := &file_controlplane_v1_project_proto_msgTypes[5] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -307,7 +417,7 @@ func (x *ProjectServiceAddMemberRequest) String() string { func (*ProjectServiceAddMemberRequest) ProtoMessage() {} func (x *ProjectServiceAddMemberRequest) ProtoReflect() protoreflect.Message { - mi := &file_controlplane_v1_project_proto_msgTypes[3] + mi := &file_controlplane_v1_project_proto_msgTypes[5] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -320,7 +430,7 @@ func (x *ProjectServiceAddMemberRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ProjectServiceAddMemberRequest.ProtoReflect.Descriptor instead. func (*ProjectServiceAddMemberRequest) Descriptor() ([]byte, []int) { - return file_controlplane_v1_project_proto_rawDescGZIP(), []int{3} + return file_controlplane_v1_project_proto_rawDescGZIP(), []int{5} } func (x *ProjectServiceAddMemberRequest) GetProjectReference() *IdentityReference { @@ -353,7 +463,7 @@ type ProjectServiceAddMemberResponse struct { func (x *ProjectServiceAddMemberResponse) Reset() { *x = ProjectServiceAddMemberResponse{} - mi := &file_controlplane_v1_project_proto_msgTypes[4] + mi := &file_controlplane_v1_project_proto_msgTypes[6] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -365,7 +475,7 @@ func (x *ProjectServiceAddMemberResponse) String() string { func (*ProjectServiceAddMemberResponse) ProtoMessage() {} func (x *ProjectServiceAddMemberResponse) ProtoReflect() protoreflect.Message { - mi := &file_controlplane_v1_project_proto_msgTypes[4] + mi := &file_controlplane_v1_project_proto_msgTypes[6] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -378,7 +488,7 @@ func (x *ProjectServiceAddMemberResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ProjectServiceAddMemberResponse.ProtoReflect.Descriptor instead. func (*ProjectServiceAddMemberResponse) Descriptor() ([]byte, []int) { - return file_controlplane_v1_project_proto_rawDescGZIP(), []int{4} + return file_controlplane_v1_project_proto_rawDescGZIP(), []int{6} } type ProjectServiceRemoveMemberRequest struct { @@ -393,7 +503,7 @@ type ProjectServiceRemoveMemberRequest struct { func (x *ProjectServiceRemoveMemberRequest) Reset() { *x = ProjectServiceRemoveMemberRequest{} - mi := &file_controlplane_v1_project_proto_msgTypes[5] + mi := &file_controlplane_v1_project_proto_msgTypes[7] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -405,7 +515,7 @@ func (x *ProjectServiceRemoveMemberRequest) String() string { func (*ProjectServiceRemoveMemberRequest) ProtoMessage() {} func (x *ProjectServiceRemoveMemberRequest) ProtoReflect() protoreflect.Message { - mi := &file_controlplane_v1_project_proto_msgTypes[5] + mi := &file_controlplane_v1_project_proto_msgTypes[7] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -418,7 +528,7 @@ func (x *ProjectServiceRemoveMemberRequest) ProtoReflect() protoreflect.Message // Deprecated: Use ProjectServiceRemoveMemberRequest.ProtoReflect.Descriptor instead. func (*ProjectServiceRemoveMemberRequest) Descriptor() ([]byte, []int) { - return file_controlplane_v1_project_proto_rawDescGZIP(), []int{5} + return file_controlplane_v1_project_proto_rawDescGZIP(), []int{7} } func (x *ProjectServiceRemoveMemberRequest) GetProjectReference() *IdentityReference { @@ -444,7 +554,7 @@ type ProjectServiceRemoveMemberResponse struct { func (x *ProjectServiceRemoveMemberResponse) Reset() { *x = ProjectServiceRemoveMemberResponse{} - mi := &file_controlplane_v1_project_proto_msgTypes[6] + mi := &file_controlplane_v1_project_proto_msgTypes[8] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -456,7 +566,7 @@ func (x *ProjectServiceRemoveMemberResponse) String() string { func (*ProjectServiceRemoveMemberResponse) ProtoMessage() {} func (x *ProjectServiceRemoveMemberResponse) ProtoReflect() protoreflect.Message { - mi := &file_controlplane_v1_project_proto_msgTypes[6] + mi := &file_controlplane_v1_project_proto_msgTypes[8] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -469,7 +579,7 @@ func (x *ProjectServiceRemoveMemberResponse) ProtoReflect() protoreflect.Message // Deprecated: Use ProjectServiceRemoveMemberResponse.ProtoReflect.Descriptor instead. func (*ProjectServiceRemoveMemberResponse) Descriptor() ([]byte, []int) { - return file_controlplane_v1_project_proto_rawDescGZIP(), []int{6} + return file_controlplane_v1_project_proto_rawDescGZIP(), []int{8} } // ProjectMembershipReference is used to reference a user or group in the context of project membership @@ -488,7 +598,7 @@ type ProjectMembershipReference struct { func (x *ProjectMembershipReference) Reset() { *x = ProjectMembershipReference{} - mi := &file_controlplane_v1_project_proto_msgTypes[7] + mi := &file_controlplane_v1_project_proto_msgTypes[9] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -500,7 +610,7 @@ func (x *ProjectMembershipReference) String() string { func (*ProjectMembershipReference) ProtoMessage() {} func (x *ProjectMembershipReference) ProtoReflect() protoreflect.Message { - mi := &file_controlplane_v1_project_proto_msgTypes[7] + mi := &file_controlplane_v1_project_proto_msgTypes[9] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -513,7 +623,7 @@ func (x *ProjectMembershipReference) ProtoReflect() protoreflect.Message { // Deprecated: Use ProjectMembershipReference.ProtoReflect.Descriptor instead. func (*ProjectMembershipReference) Descriptor() ([]byte, []int) { - return file_controlplane_v1_project_proto_rawDescGZIP(), []int{7} + return file_controlplane_v1_project_proto_rawDescGZIP(), []int{9} } func (x *ProjectMembershipReference) GetMembershipReference() isProjectMembershipReference_MembershipReference { @@ -575,7 +685,7 @@ type ProjectServiceUpdateMemberRoleRequest struct { func (x *ProjectServiceUpdateMemberRoleRequest) Reset() { *x = ProjectServiceUpdateMemberRoleRequest{} - mi := &file_controlplane_v1_project_proto_msgTypes[8] + mi := &file_controlplane_v1_project_proto_msgTypes[10] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -587,7 +697,7 @@ func (x *ProjectServiceUpdateMemberRoleRequest) String() string { func (*ProjectServiceUpdateMemberRoleRequest) ProtoMessage() {} func (x *ProjectServiceUpdateMemberRoleRequest) ProtoReflect() protoreflect.Message { - mi := &file_controlplane_v1_project_proto_msgTypes[8] + mi := &file_controlplane_v1_project_proto_msgTypes[10] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -600,7 +710,7 @@ func (x *ProjectServiceUpdateMemberRoleRequest) ProtoReflect() protoreflect.Mess // Deprecated: Use ProjectServiceUpdateMemberRoleRequest.ProtoReflect.Descriptor instead. func (*ProjectServiceUpdateMemberRoleRequest) Descriptor() ([]byte, []int) { - return file_controlplane_v1_project_proto_rawDescGZIP(), []int{8} + return file_controlplane_v1_project_proto_rawDescGZIP(), []int{10} } func (x *ProjectServiceUpdateMemberRoleRequest) GetProjectReference() *IdentityReference { @@ -633,7 +743,7 @@ type ProjectServiceUpdateMemberRoleResponse struct { func (x *ProjectServiceUpdateMemberRoleResponse) Reset() { *x = ProjectServiceUpdateMemberRoleResponse{} - mi := &file_controlplane_v1_project_proto_msgTypes[9] + mi := &file_controlplane_v1_project_proto_msgTypes[11] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -645,7 +755,7 @@ func (x *ProjectServiceUpdateMemberRoleResponse) String() string { func (*ProjectServiceUpdateMemberRoleResponse) ProtoMessage() {} func (x *ProjectServiceUpdateMemberRoleResponse) ProtoReflect() protoreflect.Message { - mi := &file_controlplane_v1_project_proto_msgTypes[9] + mi := &file_controlplane_v1_project_proto_msgTypes[11] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -658,7 +768,7 @@ func (x *ProjectServiceUpdateMemberRoleResponse) ProtoReflect() protoreflect.Mes // Deprecated: Use ProjectServiceUpdateMemberRoleResponse.ProtoReflect.Descriptor instead. func (*ProjectServiceUpdateMemberRoleResponse) Descriptor() ([]byte, []int) { - return file_controlplane_v1_project_proto_rawDescGZIP(), []int{9} + return file_controlplane_v1_project_proto_rawDescGZIP(), []int{11} } type ProjectServiceListPendingInvitationsRequest struct { @@ -673,7 +783,7 @@ type ProjectServiceListPendingInvitationsRequest struct { func (x *ProjectServiceListPendingInvitationsRequest) Reset() { *x = ProjectServiceListPendingInvitationsRequest{} - mi := &file_controlplane_v1_project_proto_msgTypes[10] + mi := &file_controlplane_v1_project_proto_msgTypes[12] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -685,7 +795,7 @@ func (x *ProjectServiceListPendingInvitationsRequest) String() string { func (*ProjectServiceListPendingInvitationsRequest) ProtoMessage() {} func (x *ProjectServiceListPendingInvitationsRequest) ProtoReflect() protoreflect.Message { - mi := &file_controlplane_v1_project_proto_msgTypes[10] + mi := &file_controlplane_v1_project_proto_msgTypes[12] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -698,7 +808,7 @@ func (x *ProjectServiceListPendingInvitationsRequest) ProtoReflect() protoreflec // Deprecated: Use ProjectServiceListPendingInvitationsRequest.ProtoReflect.Descriptor instead. func (*ProjectServiceListPendingInvitationsRequest) Descriptor() ([]byte, []int) { - return file_controlplane_v1_project_proto_rawDescGZIP(), []int{10} + return file_controlplane_v1_project_proto_rawDescGZIP(), []int{12} } func (x *ProjectServiceListPendingInvitationsRequest) GetProjectReference() *IdentityReference { @@ -728,7 +838,7 @@ type ProjectServiceListPendingInvitationsResponse struct { func (x *ProjectServiceListPendingInvitationsResponse) Reset() { *x = ProjectServiceListPendingInvitationsResponse{} - mi := &file_controlplane_v1_project_proto_msgTypes[11] + mi := &file_controlplane_v1_project_proto_msgTypes[13] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -740,7 +850,7 @@ func (x *ProjectServiceListPendingInvitationsResponse) String() string { func (*ProjectServiceListPendingInvitationsResponse) ProtoMessage() {} func (x *ProjectServiceListPendingInvitationsResponse) ProtoReflect() protoreflect.Message { - mi := &file_controlplane_v1_project_proto_msgTypes[11] + mi := &file_controlplane_v1_project_proto_msgTypes[13] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -753,7 +863,7 @@ func (x *ProjectServiceListPendingInvitationsResponse) ProtoReflect() protorefle // Deprecated: Use ProjectServiceListPendingInvitationsResponse.ProtoReflect.Descriptor instead. func (*ProjectServiceListPendingInvitationsResponse) Descriptor() ([]byte, []int) { - return file_controlplane_v1_project_proto_rawDescGZIP(), []int{11} + return file_controlplane_v1_project_proto_rawDescGZIP(), []int{13} } func (x *ProjectServiceListPendingInvitationsResponse) GetInvitations() []*PendingProjectInvitation { @@ -787,7 +897,7 @@ type PendingProjectInvitation struct { func (x *PendingProjectInvitation) Reset() { *x = PendingProjectInvitation{} - mi := &file_controlplane_v1_project_proto_msgTypes[12] + mi := &file_controlplane_v1_project_proto_msgTypes[14] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -799,7 +909,7 @@ func (x *PendingProjectInvitation) String() string { func (*PendingProjectInvitation) ProtoMessage() {} func (x *PendingProjectInvitation) ProtoReflect() protoreflect.Message { - mi := &file_controlplane_v1_project_proto_msgTypes[12] + mi := &file_controlplane_v1_project_proto_msgTypes[14] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -812,7 +922,7 @@ func (x *PendingProjectInvitation) ProtoReflect() protoreflect.Message { // Deprecated: Use PendingProjectInvitation.ProtoReflect.Descriptor instead. func (*PendingProjectInvitation) Descriptor() ([]byte, []int) { - return file_controlplane_v1_project_proto_rawDescGZIP(), []int{12} + return file_controlplane_v1_project_proto_rawDescGZIP(), []int{14} } func (x *PendingProjectInvitation) GetUserEmail() string { @@ -843,11 +953,112 @@ func (x *PendingProjectInvitation) GetInvitationId() string { return "" } +// ProjectItem represents a project of the organization +type ProjectServiceListResponse_ProjectItem struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Unique identifier of the project + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + // Name of the project + Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"` + // Description of the project + Description string `protobuf:"bytes,3,opt,name=description,proto3" json:"description,omitempty"` + // Timestamp when the project was created + CreatedAt *timestamppb.Timestamp `protobuf:"bytes,4,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"` + // Timestamp when the project was last updated + UpdatedAt *timestamppb.Timestamp `protobuf:"bytes,5,opt,name=updated_at,json=updatedAt,proto3" json:"updated_at,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ProjectServiceListResponse_ProjectItem) Reset() { + *x = ProjectServiceListResponse_ProjectItem{} + mi := &file_controlplane_v1_project_proto_msgTypes[15] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ProjectServiceListResponse_ProjectItem) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ProjectServiceListResponse_ProjectItem) ProtoMessage() {} + +func (x *ProjectServiceListResponse_ProjectItem) ProtoReflect() protoreflect.Message { + mi := &file_controlplane_v1_project_proto_msgTypes[15] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ProjectServiceListResponse_ProjectItem.ProtoReflect.Descriptor instead. +func (*ProjectServiceListResponse_ProjectItem) Descriptor() ([]byte, []int) { + return file_controlplane_v1_project_proto_rawDescGZIP(), []int{1, 0} +} + +func (x *ProjectServiceListResponse_ProjectItem) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *ProjectServiceListResponse_ProjectItem) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *ProjectServiceListResponse_ProjectItem) GetDescription() string { + if x != nil { + return x.Description + } + return "" +} + +func (x *ProjectServiceListResponse_ProjectItem) GetCreatedAt() *timestamppb.Timestamp { + if x != nil { + return x.CreatedAt + } + return nil +} + +func (x *ProjectServiceListResponse_ProjectItem) GetUpdatedAt() *timestamppb.Timestamp { + if x != nil { + return x.UpdatedAt + } + return nil +} + var File_controlplane_v1_project_proto protoreflect.FileDescriptor const file_controlplane_v1_project_proto_rawDesc = "" + "\n" + - "\x1dcontrolplane/v1/project.proto\x12\x0fcontrolplane.v1\x1a\x1bbuf/validate/validate.proto\x1a\x1bcontrolplane/v1/group.proto\x1a controlplane/v1/pagination.proto\x1a'controlplane/v1/response_messages.proto\x1a$controlplane/v1/shared_message.proto\x1a\x1fgoogle/protobuf/timestamp.proto\"\xc5\x01\n" + + "\x1dcontrolplane/v1/project.proto\x12\x0fcontrolplane.v1\x1a\x1bbuf/validate/validate.proto\x1a\x1bcontrolplane/v1/group.proto\x1a controlplane/v1/pagination.proto\x1a'controlplane/v1/response_messages.proto\x1a$controlplane/v1/shared_message.proto\x1a\x1fgoogle/protobuf/timestamp.proto\"\x87\x01\n" + + "\x19ProjectServiceListRequest\x12\x17\n" + + "\x04name\x18\x01 \x01(\tH\x00R\x04name\x88\x01\x01\x12H\n" + + "\n" + + "pagination\x18\x02 \x01(\v2(.controlplane.v1.OffsetPaginationRequestR\n" + + "paginationB\a\n" + + "\x05_name\"\x88\x03\n" + + "\x1aProjectServiceListResponse\x12S\n" + + "\bprojects\x18\x01 \x03(\v27.controlplane.v1.ProjectServiceListResponse.ProjectItemR\bprojects\x12I\n" + + "\n" + + "pagination\x18\x02 \x01(\v2).controlplane.v1.OffsetPaginationResponseR\n" + + "pagination\x1a\xc9\x01\n" + + "\vProjectItem\x12\x0e\n" + + "\x02id\x18\x01 \x01(\tR\x02id\x12\x12\n" + + "\x04name\x18\x02 \x01(\tR\x04name\x12 \n" + + "\vdescription\x18\x03 \x01(\tR\vdescription\x129\n" + + "\n" + + "created_at\x18\x04 \x01(\v2\x1a.google.protobuf.TimestampR\tcreatedAt\x129\n" + + "\n" + + "updated_at\x18\x05 \x01(\v2\x1a.google.protobuf.TimestampR\tupdatedAt\"\xc5\x01\n" + " ProjectServiceListMembersRequest\x12W\n" + "\x11project_reference\x18\x01 \x01(\v2\".controlplane.v1.IdentityReferenceB\x06\xbaH\x03\xc8\x01\x01R\x10projectReference\x12H\n" + "\n" + @@ -909,8 +1120,9 @@ const file_controlplane_v1_project_proto_rawDesc = "" + "\n" + "created_at\x18\x03 \x01(\v2\x1a.google.protobuf.TimestampR\tcreatedAt\x12#\n" + "\rinvitation_id\x18\x04 \x01(\tR\finvitationIdB\r\n" + - "\v_invited_by2\x8f\x05\n" + - "\x0eProjectService\x12t\n" + + "\v_invited_by2\xf2\x05\n" + + "\x0eProjectService\x12a\n" + + "\x04List\x12*.controlplane.v1.ProjectServiceListRequest\x1a+.controlplane.v1.ProjectServiceListResponse\"\x00\x12t\n" + "\vListMembers\x121.controlplane.v1.ProjectServiceListMembersRequest\x1a2.controlplane.v1.ProjectServiceListMembersResponse\x12n\n" + "\tAddMember\x12/.controlplane.v1.ProjectServiceAddMemberRequest\x1a0.controlplane.v1.ProjectServiceAddMemberResponse\x12w\n" + "\fRemoveMember\x122.controlplane.v1.ProjectServiceRemoveMemberRequest\x1a3.controlplane.v1.ProjectServiceRemoveMemberResponse\x12\x83\x01\n" + @@ -929,69 +1141,79 @@ func file_controlplane_v1_project_proto_rawDescGZIP() []byte { return file_controlplane_v1_project_proto_rawDescData } -var file_controlplane_v1_project_proto_msgTypes = make([]protoimpl.MessageInfo, 13) +var file_controlplane_v1_project_proto_msgTypes = make([]protoimpl.MessageInfo, 16) var file_controlplane_v1_project_proto_goTypes = []any{ - (*ProjectServiceListMembersRequest)(nil), // 0: controlplane.v1.ProjectServiceListMembersRequest - (*ProjectServiceListMembersResponse)(nil), // 1: controlplane.v1.ProjectServiceListMembersResponse - (*ProjectMember)(nil), // 2: controlplane.v1.ProjectMember - (*ProjectServiceAddMemberRequest)(nil), // 3: controlplane.v1.ProjectServiceAddMemberRequest - (*ProjectServiceAddMemberResponse)(nil), // 4: controlplane.v1.ProjectServiceAddMemberResponse - (*ProjectServiceRemoveMemberRequest)(nil), // 5: controlplane.v1.ProjectServiceRemoveMemberRequest - (*ProjectServiceRemoveMemberResponse)(nil), // 6: controlplane.v1.ProjectServiceRemoveMemberResponse - (*ProjectMembershipReference)(nil), // 7: controlplane.v1.ProjectMembershipReference - (*ProjectServiceUpdateMemberRoleRequest)(nil), // 8: controlplane.v1.ProjectServiceUpdateMemberRoleRequest - (*ProjectServiceUpdateMemberRoleResponse)(nil), // 9: controlplane.v1.ProjectServiceUpdateMemberRoleResponse - (*ProjectServiceListPendingInvitationsRequest)(nil), // 10: controlplane.v1.ProjectServiceListPendingInvitationsRequest - (*ProjectServiceListPendingInvitationsResponse)(nil), // 11: controlplane.v1.ProjectServiceListPendingInvitationsResponse - (*PendingProjectInvitation)(nil), // 12: controlplane.v1.PendingProjectInvitation - (*IdentityReference)(nil), // 13: controlplane.v1.IdentityReference - (*OffsetPaginationRequest)(nil), // 14: controlplane.v1.OffsetPaginationRequest - (*OffsetPaginationResponse)(nil), // 15: controlplane.v1.OffsetPaginationResponse - (*User)(nil), // 16: controlplane.v1.User - (*Group)(nil), // 17: controlplane.v1.Group - (ProjectMemberRole)(0), // 18: controlplane.v1.ProjectMemberRole - (*timestamppb.Timestamp)(nil), // 19: google.protobuf.Timestamp + (*ProjectServiceListRequest)(nil), // 0: controlplane.v1.ProjectServiceListRequest + (*ProjectServiceListResponse)(nil), // 1: controlplane.v1.ProjectServiceListResponse + (*ProjectServiceListMembersRequest)(nil), // 2: controlplane.v1.ProjectServiceListMembersRequest + (*ProjectServiceListMembersResponse)(nil), // 3: controlplane.v1.ProjectServiceListMembersResponse + (*ProjectMember)(nil), // 4: controlplane.v1.ProjectMember + (*ProjectServiceAddMemberRequest)(nil), // 5: controlplane.v1.ProjectServiceAddMemberRequest + (*ProjectServiceAddMemberResponse)(nil), // 6: controlplane.v1.ProjectServiceAddMemberResponse + (*ProjectServiceRemoveMemberRequest)(nil), // 7: controlplane.v1.ProjectServiceRemoveMemberRequest + (*ProjectServiceRemoveMemberResponse)(nil), // 8: controlplane.v1.ProjectServiceRemoveMemberResponse + (*ProjectMembershipReference)(nil), // 9: controlplane.v1.ProjectMembershipReference + (*ProjectServiceUpdateMemberRoleRequest)(nil), // 10: controlplane.v1.ProjectServiceUpdateMemberRoleRequest + (*ProjectServiceUpdateMemberRoleResponse)(nil), // 11: controlplane.v1.ProjectServiceUpdateMemberRoleResponse + (*ProjectServiceListPendingInvitationsRequest)(nil), // 12: controlplane.v1.ProjectServiceListPendingInvitationsRequest + (*ProjectServiceListPendingInvitationsResponse)(nil), // 13: controlplane.v1.ProjectServiceListPendingInvitationsResponse + (*PendingProjectInvitation)(nil), // 14: controlplane.v1.PendingProjectInvitation + (*ProjectServiceListResponse_ProjectItem)(nil), // 15: controlplane.v1.ProjectServiceListResponse.ProjectItem + (*OffsetPaginationRequest)(nil), // 16: controlplane.v1.OffsetPaginationRequest + (*OffsetPaginationResponse)(nil), // 17: controlplane.v1.OffsetPaginationResponse + (*IdentityReference)(nil), // 18: controlplane.v1.IdentityReference + (*User)(nil), // 19: controlplane.v1.User + (*Group)(nil), // 20: controlplane.v1.Group + (ProjectMemberRole)(0), // 21: controlplane.v1.ProjectMemberRole + (*timestamppb.Timestamp)(nil), // 22: google.protobuf.Timestamp } var file_controlplane_v1_project_proto_depIdxs = []int32{ - 13, // 0: controlplane.v1.ProjectServiceListMembersRequest.project_reference:type_name -> controlplane.v1.IdentityReference - 14, // 1: controlplane.v1.ProjectServiceListMembersRequest.pagination:type_name -> controlplane.v1.OffsetPaginationRequest - 2, // 2: controlplane.v1.ProjectServiceListMembersResponse.members:type_name -> controlplane.v1.ProjectMember - 15, // 3: controlplane.v1.ProjectServiceListMembersResponse.pagination:type_name -> controlplane.v1.OffsetPaginationResponse - 16, // 4: controlplane.v1.ProjectMember.user:type_name -> controlplane.v1.User - 17, // 5: controlplane.v1.ProjectMember.group:type_name -> controlplane.v1.Group - 18, // 6: controlplane.v1.ProjectMember.role:type_name -> controlplane.v1.ProjectMemberRole - 19, // 7: controlplane.v1.ProjectMember.created_at:type_name -> google.protobuf.Timestamp - 19, // 8: controlplane.v1.ProjectMember.updated_at:type_name -> google.protobuf.Timestamp - 13, // 9: controlplane.v1.ProjectServiceAddMemberRequest.project_reference:type_name -> controlplane.v1.IdentityReference - 7, // 10: controlplane.v1.ProjectServiceAddMemberRequest.member_reference:type_name -> controlplane.v1.ProjectMembershipReference - 18, // 11: controlplane.v1.ProjectServiceAddMemberRequest.role:type_name -> controlplane.v1.ProjectMemberRole - 13, // 12: controlplane.v1.ProjectServiceRemoveMemberRequest.project_reference:type_name -> controlplane.v1.IdentityReference - 7, // 13: controlplane.v1.ProjectServiceRemoveMemberRequest.member_reference:type_name -> controlplane.v1.ProjectMembershipReference - 13, // 14: controlplane.v1.ProjectMembershipReference.group_reference:type_name -> controlplane.v1.IdentityReference - 13, // 15: controlplane.v1.ProjectServiceUpdateMemberRoleRequest.project_reference:type_name -> controlplane.v1.IdentityReference - 7, // 16: controlplane.v1.ProjectServiceUpdateMemberRoleRequest.member_reference:type_name -> controlplane.v1.ProjectMembershipReference - 18, // 17: controlplane.v1.ProjectServiceUpdateMemberRoleRequest.new_role:type_name -> controlplane.v1.ProjectMemberRole - 13, // 18: controlplane.v1.ProjectServiceListPendingInvitationsRequest.project_reference:type_name -> controlplane.v1.IdentityReference - 14, // 19: controlplane.v1.ProjectServiceListPendingInvitationsRequest.pagination:type_name -> controlplane.v1.OffsetPaginationRequest - 12, // 20: controlplane.v1.ProjectServiceListPendingInvitationsResponse.invitations:type_name -> controlplane.v1.PendingProjectInvitation - 15, // 21: controlplane.v1.ProjectServiceListPendingInvitationsResponse.pagination:type_name -> controlplane.v1.OffsetPaginationResponse - 16, // 22: controlplane.v1.PendingProjectInvitation.invited_by:type_name -> controlplane.v1.User - 19, // 23: controlplane.v1.PendingProjectInvitation.created_at:type_name -> google.protobuf.Timestamp - 0, // 24: controlplane.v1.ProjectService.ListMembers:input_type -> controlplane.v1.ProjectServiceListMembersRequest - 3, // 25: controlplane.v1.ProjectService.AddMember:input_type -> controlplane.v1.ProjectServiceAddMemberRequest - 5, // 26: controlplane.v1.ProjectService.RemoveMember:input_type -> controlplane.v1.ProjectServiceRemoveMemberRequest - 8, // 27: controlplane.v1.ProjectService.UpdateMemberRole:input_type -> controlplane.v1.ProjectServiceUpdateMemberRoleRequest - 10, // 28: controlplane.v1.ProjectService.ListPendingInvitations:input_type -> controlplane.v1.ProjectServiceListPendingInvitationsRequest - 1, // 29: controlplane.v1.ProjectService.ListMembers:output_type -> controlplane.v1.ProjectServiceListMembersResponse - 4, // 30: controlplane.v1.ProjectService.AddMember:output_type -> controlplane.v1.ProjectServiceAddMemberResponse - 6, // 31: controlplane.v1.ProjectService.RemoveMember:output_type -> controlplane.v1.ProjectServiceRemoveMemberResponse - 9, // 32: controlplane.v1.ProjectService.UpdateMemberRole:output_type -> controlplane.v1.ProjectServiceUpdateMemberRoleResponse - 11, // 33: controlplane.v1.ProjectService.ListPendingInvitations:output_type -> controlplane.v1.ProjectServiceListPendingInvitationsResponse - 29, // [29:34] is the sub-list for method output_type - 24, // [24:29] is the sub-list for method input_type - 24, // [24:24] is the sub-list for extension type_name - 24, // [24:24] is the sub-list for extension extendee - 0, // [0:24] is the sub-list for field type_name + 16, // 0: controlplane.v1.ProjectServiceListRequest.pagination:type_name -> controlplane.v1.OffsetPaginationRequest + 15, // 1: controlplane.v1.ProjectServiceListResponse.projects:type_name -> controlplane.v1.ProjectServiceListResponse.ProjectItem + 17, // 2: controlplane.v1.ProjectServiceListResponse.pagination:type_name -> controlplane.v1.OffsetPaginationResponse + 18, // 3: controlplane.v1.ProjectServiceListMembersRequest.project_reference:type_name -> controlplane.v1.IdentityReference + 16, // 4: controlplane.v1.ProjectServiceListMembersRequest.pagination:type_name -> controlplane.v1.OffsetPaginationRequest + 4, // 5: controlplane.v1.ProjectServiceListMembersResponse.members:type_name -> controlplane.v1.ProjectMember + 17, // 6: controlplane.v1.ProjectServiceListMembersResponse.pagination:type_name -> controlplane.v1.OffsetPaginationResponse + 19, // 7: controlplane.v1.ProjectMember.user:type_name -> controlplane.v1.User + 20, // 8: controlplane.v1.ProjectMember.group:type_name -> controlplane.v1.Group + 21, // 9: controlplane.v1.ProjectMember.role:type_name -> controlplane.v1.ProjectMemberRole + 22, // 10: controlplane.v1.ProjectMember.created_at:type_name -> google.protobuf.Timestamp + 22, // 11: controlplane.v1.ProjectMember.updated_at:type_name -> google.protobuf.Timestamp + 18, // 12: controlplane.v1.ProjectServiceAddMemberRequest.project_reference:type_name -> controlplane.v1.IdentityReference + 9, // 13: controlplane.v1.ProjectServiceAddMemberRequest.member_reference:type_name -> controlplane.v1.ProjectMembershipReference + 21, // 14: controlplane.v1.ProjectServiceAddMemberRequest.role:type_name -> controlplane.v1.ProjectMemberRole + 18, // 15: controlplane.v1.ProjectServiceRemoveMemberRequest.project_reference:type_name -> controlplane.v1.IdentityReference + 9, // 16: controlplane.v1.ProjectServiceRemoveMemberRequest.member_reference:type_name -> controlplane.v1.ProjectMembershipReference + 18, // 17: controlplane.v1.ProjectMembershipReference.group_reference:type_name -> controlplane.v1.IdentityReference + 18, // 18: controlplane.v1.ProjectServiceUpdateMemberRoleRequest.project_reference:type_name -> controlplane.v1.IdentityReference + 9, // 19: controlplane.v1.ProjectServiceUpdateMemberRoleRequest.member_reference:type_name -> controlplane.v1.ProjectMembershipReference + 21, // 20: controlplane.v1.ProjectServiceUpdateMemberRoleRequest.new_role:type_name -> controlplane.v1.ProjectMemberRole + 18, // 21: controlplane.v1.ProjectServiceListPendingInvitationsRequest.project_reference:type_name -> controlplane.v1.IdentityReference + 16, // 22: controlplane.v1.ProjectServiceListPendingInvitationsRequest.pagination:type_name -> controlplane.v1.OffsetPaginationRequest + 14, // 23: controlplane.v1.ProjectServiceListPendingInvitationsResponse.invitations:type_name -> controlplane.v1.PendingProjectInvitation + 17, // 24: controlplane.v1.ProjectServiceListPendingInvitationsResponse.pagination:type_name -> controlplane.v1.OffsetPaginationResponse + 19, // 25: controlplane.v1.PendingProjectInvitation.invited_by:type_name -> controlplane.v1.User + 22, // 26: controlplane.v1.PendingProjectInvitation.created_at:type_name -> google.protobuf.Timestamp + 22, // 27: controlplane.v1.ProjectServiceListResponse.ProjectItem.created_at:type_name -> google.protobuf.Timestamp + 22, // 28: controlplane.v1.ProjectServiceListResponse.ProjectItem.updated_at:type_name -> google.protobuf.Timestamp + 0, // 29: controlplane.v1.ProjectService.List:input_type -> controlplane.v1.ProjectServiceListRequest + 2, // 30: controlplane.v1.ProjectService.ListMembers:input_type -> controlplane.v1.ProjectServiceListMembersRequest + 5, // 31: controlplane.v1.ProjectService.AddMember:input_type -> controlplane.v1.ProjectServiceAddMemberRequest + 7, // 32: controlplane.v1.ProjectService.RemoveMember:input_type -> controlplane.v1.ProjectServiceRemoveMemberRequest + 10, // 33: controlplane.v1.ProjectService.UpdateMemberRole:input_type -> controlplane.v1.ProjectServiceUpdateMemberRoleRequest + 12, // 34: controlplane.v1.ProjectService.ListPendingInvitations:input_type -> controlplane.v1.ProjectServiceListPendingInvitationsRequest + 1, // 35: controlplane.v1.ProjectService.List:output_type -> controlplane.v1.ProjectServiceListResponse + 3, // 36: controlplane.v1.ProjectService.ListMembers:output_type -> controlplane.v1.ProjectServiceListMembersResponse + 6, // 37: controlplane.v1.ProjectService.AddMember:output_type -> controlplane.v1.ProjectServiceAddMemberResponse + 8, // 38: controlplane.v1.ProjectService.RemoveMember:output_type -> controlplane.v1.ProjectServiceRemoveMemberResponse + 11, // 39: controlplane.v1.ProjectService.UpdateMemberRole:output_type -> controlplane.v1.ProjectServiceUpdateMemberRoleResponse + 13, // 40: controlplane.v1.ProjectService.ListPendingInvitations:output_type -> controlplane.v1.ProjectServiceListPendingInvitationsResponse + 35, // [35:41] is the sub-list for method output_type + 29, // [29:35] is the sub-list for method input_type + 29, // [29:29] is the sub-list for extension type_name + 29, // [29:29] is the sub-list for extension extendee + 0, // [0:29] is the sub-list for field type_name } func init() { file_controlplane_v1_project_proto_init() } @@ -1003,22 +1225,23 @@ func file_controlplane_v1_project_proto_init() { file_controlplane_v1_pagination_proto_init() file_controlplane_v1_response_messages_proto_init() file_controlplane_v1_shared_message_proto_init() - file_controlplane_v1_project_proto_msgTypes[2].OneofWrappers = []any{ + file_controlplane_v1_project_proto_msgTypes[0].OneofWrappers = []any{} + file_controlplane_v1_project_proto_msgTypes[4].OneofWrappers = []any{ (*ProjectMember_User)(nil), (*ProjectMember_Group)(nil), } - file_controlplane_v1_project_proto_msgTypes[7].OneofWrappers = []any{ + file_controlplane_v1_project_proto_msgTypes[9].OneofWrappers = []any{ (*ProjectMembershipReference_UserEmail)(nil), (*ProjectMembershipReference_GroupReference)(nil), } - file_controlplane_v1_project_proto_msgTypes[12].OneofWrappers = []any{} + file_controlplane_v1_project_proto_msgTypes[14].OneofWrappers = []any{} type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_controlplane_v1_project_proto_rawDesc), len(file_controlplane_v1_project_proto_rawDesc)), NumEnums: 0, - NumMessages: 13, + NumMessages: 16, NumExtensions: 0, NumServices: 1, }, diff --git a/app/controlplane/api/controlplane/v1/project.proto b/app/controlplane/api/controlplane/v1/project.proto index 9badf03d8..97810a1b6 100644 --- a/app/controlplane/api/controlplane/v1/project.proto +++ b/app/controlplane/api/controlplane/v1/project.proto @@ -27,6 +27,8 @@ import "google/protobuf/timestamp.proto"; option go_package = "github.com/chainloop-dev/chainloop/app/controlplane/api/controlplane/v1;v1"; service ProjectService { + // List the projects of the current organization that the caller can see + rpc List(ProjectServiceListRequest) returns (ProjectServiceListResponse) {} // Project membership management rpc ListMembers(ProjectServiceListMembersRequest) returns (ProjectServiceListMembersResponse); rpc AddMember(ProjectServiceAddMemberRequest) returns (ProjectServiceAddMemberResponse); @@ -35,6 +37,36 @@ service ProjectService { rpc ListPendingInvitations(ProjectServiceListPendingInvitationsRequest) returns (ProjectServiceListPendingInvitationsResponse) {} } +// ProjectServiceListRequest contains parameters for filtering and paginating the projects of an organization +message ProjectServiceListRequest { + // Filter by project name, case-insensitive substring match + optional string name = 1; + // Pagination parameters to limit and offset results + OffsetPaginationRequest pagination = 2; +} + +// ProjectServiceListResponse contains a paginated list of the organization's projects +message ProjectServiceListResponse { + // List of projects matching the request criteria + repeated ProjectItem projects = 1; + // Pagination information for the response + OffsetPaginationResponse pagination = 2; + + // ProjectItem represents a project of the organization + message ProjectItem { + // Unique identifier of the project + string id = 1; + // Name of the project + string name = 2; + // Description of the project + string description = 3; + // Timestamp when the project was created + google.protobuf.Timestamp created_at = 4; + // Timestamp when the project was last updated + google.protobuf.Timestamp updated_at = 5; + } +} + // ProjectServiceListMembersRequest contains the information needed to list members of a project message ProjectServiceListMembersRequest { // IdentityReference is used to specify the project by either its ID or name diff --git a/app/controlplane/api/controlplane/v1/project_grpc.pb.go b/app/controlplane/api/controlplane/v1/project_grpc.pb.go index 06f0ecee5..f7d874c7f 100644 --- a/app/controlplane/api/controlplane/v1/project_grpc.pb.go +++ b/app/controlplane/api/controlplane/v1/project_grpc.pb.go @@ -34,6 +34,7 @@ import ( const _ = grpc.SupportPackageIsVersion7 const ( + ProjectService_List_FullMethodName = "/controlplane.v1.ProjectService/List" ProjectService_ListMembers_FullMethodName = "/controlplane.v1.ProjectService/ListMembers" ProjectService_AddMember_FullMethodName = "/controlplane.v1.ProjectService/AddMember" ProjectService_RemoveMember_FullMethodName = "/controlplane.v1.ProjectService/RemoveMember" @@ -45,6 +46,8 @@ const ( // // For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. type ProjectServiceClient interface { + // List the projects of the current organization that the caller can see + List(ctx context.Context, in *ProjectServiceListRequest, opts ...grpc.CallOption) (*ProjectServiceListResponse, error) // Project membership management ListMembers(ctx context.Context, in *ProjectServiceListMembersRequest, opts ...grpc.CallOption) (*ProjectServiceListMembersResponse, error) AddMember(ctx context.Context, in *ProjectServiceAddMemberRequest, opts ...grpc.CallOption) (*ProjectServiceAddMemberResponse, error) @@ -61,6 +64,15 @@ func NewProjectServiceClient(cc grpc.ClientConnInterface) ProjectServiceClient { return &projectServiceClient{cc} } +func (c *projectServiceClient) List(ctx context.Context, in *ProjectServiceListRequest, opts ...grpc.CallOption) (*ProjectServiceListResponse, error) { + out := new(ProjectServiceListResponse) + err := c.cc.Invoke(ctx, ProjectService_List_FullMethodName, in, out, opts...) + if err != nil { + return nil, err + } + return out, nil +} + func (c *projectServiceClient) ListMembers(ctx context.Context, in *ProjectServiceListMembersRequest, opts ...grpc.CallOption) (*ProjectServiceListMembersResponse, error) { out := new(ProjectServiceListMembersResponse) err := c.cc.Invoke(ctx, ProjectService_ListMembers_FullMethodName, in, out, opts...) @@ -110,6 +122,8 @@ func (c *projectServiceClient) ListPendingInvitations(ctx context.Context, in *P // All implementations must embed UnimplementedProjectServiceServer // for forward compatibility type ProjectServiceServer interface { + // List the projects of the current organization that the caller can see + List(context.Context, *ProjectServiceListRequest) (*ProjectServiceListResponse, error) // Project membership management ListMembers(context.Context, *ProjectServiceListMembersRequest) (*ProjectServiceListMembersResponse, error) AddMember(context.Context, *ProjectServiceAddMemberRequest) (*ProjectServiceAddMemberResponse, error) @@ -123,6 +137,9 @@ type ProjectServiceServer interface { type UnimplementedProjectServiceServer struct { } +func (UnimplementedProjectServiceServer) List(context.Context, *ProjectServiceListRequest) (*ProjectServiceListResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method List not implemented") +} func (UnimplementedProjectServiceServer) ListMembers(context.Context, *ProjectServiceListMembersRequest) (*ProjectServiceListMembersResponse, error) { return nil, status.Errorf(codes.Unimplemented, "method ListMembers not implemented") } @@ -151,6 +168,24 @@ func RegisterProjectServiceServer(s grpc.ServiceRegistrar, srv ProjectServiceSer s.RegisterService(&ProjectService_ServiceDesc, srv) } +func _ProjectService_List_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ProjectServiceListRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(ProjectServiceServer).List(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: ProjectService_List_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(ProjectServiceServer).List(ctx, req.(*ProjectServiceListRequest)) + } + return interceptor(ctx, in, info, handler) +} + func _ProjectService_ListMembers_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { in := new(ProjectServiceListMembersRequest) if err := dec(in); err != nil { @@ -248,6 +283,10 @@ var ProjectService_ServiceDesc = grpc.ServiceDesc{ ServiceName: "controlplane.v1.ProjectService", HandlerType: (*ProjectServiceServer)(nil), Methods: []grpc.MethodDesc{ + { + MethodName: "List", + Handler: _ProjectService_List_Handler, + }, { MethodName: "ListMembers", Handler: _ProjectService_ListMembers_Handler, diff --git a/app/controlplane/api/gen/frontend/controlplane/v1/project.ts b/app/controlplane/api/gen/frontend/controlplane/v1/project.ts index 2d8e217b8..e2cc98673 100644 --- a/app/controlplane/api/gen/frontend/controlplane/v1/project.ts +++ b/app/controlplane/api/gen/frontend/controlplane/v1/project.ts @@ -15,6 +15,38 @@ import { export const protobufPackage = "controlplane.v1"; +/** ProjectServiceListRequest contains parameters for filtering and paginating the projects of an organization */ +export interface ProjectServiceListRequest { + /** Filter by project name, case-insensitive substring match */ + name?: + | string + | undefined; + /** Pagination parameters to limit and offset results */ + pagination?: OffsetPaginationRequest; +} + +/** ProjectServiceListResponse contains a paginated list of the organization's projects */ +export interface ProjectServiceListResponse { + /** List of projects matching the request criteria */ + projects: ProjectServiceListResponse_ProjectItem[]; + /** Pagination information for the response */ + pagination?: OffsetPaginationResponse; +} + +/** ProjectItem represents a project of the organization */ +export interface ProjectServiceListResponse_ProjectItem { + /** Unique identifier of the project */ + id: string; + /** Name of the project */ + name: string; + /** Description of the project */ + description: string; + /** Timestamp when the project was created */ + createdAt?: Date; + /** Timestamp when the project was last updated */ + updatedAt?: Date; +} + /** ProjectServiceListMembersRequest contains the information needed to list members of a project */ export interface ProjectServiceListMembersRequest { /** IdentityReference is used to specify the project by either its ID or name */ @@ -131,6 +163,274 @@ export interface PendingProjectInvitation { invitationId: string; } +function createBaseProjectServiceListRequest(): ProjectServiceListRequest { + return { name: undefined, pagination: undefined }; +} + +export const ProjectServiceListRequest = { + encode(message: ProjectServiceListRequest, writer: _m0.Writer = _m0.Writer.create()): _m0.Writer { + if (message.name !== undefined) { + writer.uint32(10).string(message.name); + } + if (message.pagination !== undefined) { + OffsetPaginationRequest.encode(message.pagination, writer.uint32(18).fork()).ldelim(); + } + return writer; + }, + + decode(input: _m0.Reader | Uint8Array, length?: number): ProjectServiceListRequest { + const reader = input instanceof _m0.Reader ? input : _m0.Reader.create(input); + let end = length === undefined ? reader.len : reader.pos + length; + const message = createBaseProjectServiceListRequest(); + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + case 1: + if (tag !== 10) { + break; + } + + message.name = reader.string(); + continue; + case 2: + if (tag !== 18) { + break; + } + + message.pagination = OffsetPaginationRequest.decode(reader, reader.uint32()); + continue; + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skipType(tag & 7); + } + return message; + }, + + fromJSON(object: any): ProjectServiceListRequest { + return { + name: isSet(object.name) ? String(object.name) : undefined, + pagination: isSet(object.pagination) ? OffsetPaginationRequest.fromJSON(object.pagination) : undefined, + }; + }, + + toJSON(message: ProjectServiceListRequest): unknown { + const obj: any = {}; + message.name !== undefined && (obj.name = message.name); + message.pagination !== undefined && + (obj.pagination = message.pagination ? OffsetPaginationRequest.toJSON(message.pagination) : undefined); + return obj; + }, + + create, I>>(base?: I): ProjectServiceListRequest { + return ProjectServiceListRequest.fromPartial(base ?? {}); + }, + + fromPartial, I>>(object: I): ProjectServiceListRequest { + const message = createBaseProjectServiceListRequest(); + message.name = object.name ?? undefined; + message.pagination = (object.pagination !== undefined && object.pagination !== null) + ? OffsetPaginationRequest.fromPartial(object.pagination) + : undefined; + return message; + }, +}; + +function createBaseProjectServiceListResponse(): ProjectServiceListResponse { + return { projects: [], pagination: undefined }; +} + +export const ProjectServiceListResponse = { + encode(message: ProjectServiceListResponse, writer: _m0.Writer = _m0.Writer.create()): _m0.Writer { + for (const v of message.projects) { + ProjectServiceListResponse_ProjectItem.encode(v!, writer.uint32(10).fork()).ldelim(); + } + if (message.pagination !== undefined) { + OffsetPaginationResponse.encode(message.pagination, writer.uint32(18).fork()).ldelim(); + } + return writer; + }, + + decode(input: _m0.Reader | Uint8Array, length?: number): ProjectServiceListResponse { + const reader = input instanceof _m0.Reader ? input : _m0.Reader.create(input); + let end = length === undefined ? reader.len : reader.pos + length; + const message = createBaseProjectServiceListResponse(); + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + case 1: + if (tag !== 10) { + break; + } + + message.projects.push(ProjectServiceListResponse_ProjectItem.decode(reader, reader.uint32())); + continue; + case 2: + if (tag !== 18) { + break; + } + + message.pagination = OffsetPaginationResponse.decode(reader, reader.uint32()); + continue; + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skipType(tag & 7); + } + return message; + }, + + fromJSON(object: any): ProjectServiceListResponse { + return { + projects: Array.isArray(object?.projects) + ? object.projects.map((e: any) => ProjectServiceListResponse_ProjectItem.fromJSON(e)) + : [], + pagination: isSet(object.pagination) ? OffsetPaginationResponse.fromJSON(object.pagination) : undefined, + }; + }, + + toJSON(message: ProjectServiceListResponse): unknown { + const obj: any = {}; + if (message.projects) { + obj.projects = message.projects.map((e) => e ? ProjectServiceListResponse_ProjectItem.toJSON(e) : undefined); + } else { + obj.projects = []; + } + message.pagination !== undefined && + (obj.pagination = message.pagination ? OffsetPaginationResponse.toJSON(message.pagination) : undefined); + return obj; + }, + + create, I>>(base?: I): ProjectServiceListResponse { + return ProjectServiceListResponse.fromPartial(base ?? {}); + }, + + fromPartial, I>>(object: I): ProjectServiceListResponse { + const message = createBaseProjectServiceListResponse(); + message.projects = object.projects?.map((e) => ProjectServiceListResponse_ProjectItem.fromPartial(e)) || []; + message.pagination = (object.pagination !== undefined && object.pagination !== null) + ? OffsetPaginationResponse.fromPartial(object.pagination) + : undefined; + return message; + }, +}; + +function createBaseProjectServiceListResponse_ProjectItem(): ProjectServiceListResponse_ProjectItem { + return { id: "", name: "", description: "", createdAt: undefined, updatedAt: undefined }; +} + +export const ProjectServiceListResponse_ProjectItem = { + encode(message: ProjectServiceListResponse_ProjectItem, writer: _m0.Writer = _m0.Writer.create()): _m0.Writer { + if (message.id !== "") { + writer.uint32(10).string(message.id); + } + if (message.name !== "") { + writer.uint32(18).string(message.name); + } + if (message.description !== "") { + writer.uint32(26).string(message.description); + } + if (message.createdAt !== undefined) { + Timestamp.encode(toTimestamp(message.createdAt), writer.uint32(34).fork()).ldelim(); + } + if (message.updatedAt !== undefined) { + Timestamp.encode(toTimestamp(message.updatedAt), writer.uint32(42).fork()).ldelim(); + } + return writer; + }, + + decode(input: _m0.Reader | Uint8Array, length?: number): ProjectServiceListResponse_ProjectItem { + const reader = input instanceof _m0.Reader ? input : _m0.Reader.create(input); + let end = length === undefined ? reader.len : reader.pos + length; + const message = createBaseProjectServiceListResponse_ProjectItem(); + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + case 1: + if (tag !== 10) { + break; + } + + message.id = reader.string(); + continue; + case 2: + if (tag !== 18) { + break; + } + + message.name = reader.string(); + continue; + case 3: + if (tag !== 26) { + break; + } + + message.description = reader.string(); + continue; + case 4: + if (tag !== 34) { + break; + } + + message.createdAt = fromTimestamp(Timestamp.decode(reader, reader.uint32())); + continue; + case 5: + if (tag !== 42) { + break; + } + + message.updatedAt = fromTimestamp(Timestamp.decode(reader, reader.uint32())); + continue; + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skipType(tag & 7); + } + return message; + }, + + fromJSON(object: any): ProjectServiceListResponse_ProjectItem { + return { + id: isSet(object.id) ? String(object.id) : "", + name: isSet(object.name) ? String(object.name) : "", + description: isSet(object.description) ? String(object.description) : "", + createdAt: isSet(object.createdAt) ? fromJsonTimestamp(object.createdAt) : undefined, + updatedAt: isSet(object.updatedAt) ? fromJsonTimestamp(object.updatedAt) : undefined, + }; + }, + + toJSON(message: ProjectServiceListResponse_ProjectItem): unknown { + const obj: any = {}; + message.id !== undefined && (obj.id = message.id); + message.name !== undefined && (obj.name = message.name); + message.description !== undefined && (obj.description = message.description); + message.createdAt !== undefined && (obj.createdAt = message.createdAt.toISOString()); + message.updatedAt !== undefined && (obj.updatedAt = message.updatedAt.toISOString()); + return obj; + }, + + create, I>>( + base?: I, + ): ProjectServiceListResponse_ProjectItem { + return ProjectServiceListResponse_ProjectItem.fromPartial(base ?? {}); + }, + + fromPartial, I>>( + object: I, + ): ProjectServiceListResponse_ProjectItem { + const message = createBaseProjectServiceListResponse_ProjectItem(); + message.id = object.id ?? ""; + message.name = object.name ?? ""; + message.description = object.description ?? ""; + message.createdAt = object.createdAt ?? undefined; + message.updatedAt = object.updatedAt ?? undefined; + return message; + }, +}; + function createBaseProjectServiceListMembersRequest(): ProjectServiceListMembersRequest { return { projectReference: undefined, pagination: undefined }; } @@ -1213,6 +1513,8 @@ export const PendingProjectInvitation = { }; export interface ProjectService { + /** List the projects of the current organization that the caller can see */ + List(request: DeepPartial, metadata?: grpc.Metadata): Promise; /** Project membership management */ ListMembers( request: DeepPartial, @@ -1241,6 +1543,7 @@ export class ProjectServiceClientImpl implements ProjectService { constructor(rpc: Rpc) { this.rpc = rpc; + this.List = this.List.bind(this); this.ListMembers = this.ListMembers.bind(this); this.AddMember = this.AddMember.bind(this); this.RemoveMember = this.RemoveMember.bind(this); @@ -1248,6 +1551,10 @@ export class ProjectServiceClientImpl implements ProjectService { this.ListPendingInvitations = this.ListPendingInvitations.bind(this); } + List(request: DeepPartial, metadata?: grpc.Metadata): Promise { + return this.rpc.unary(ProjectServiceListDesc, ProjectServiceListRequest.fromPartial(request), metadata); + } + ListMembers( request: DeepPartial, metadata?: grpc.Metadata, @@ -1302,6 +1609,29 @@ export class ProjectServiceClientImpl implements ProjectService { export const ProjectServiceDesc = { serviceName: "controlplane.v1.ProjectService" }; +export const ProjectServiceListDesc: UnaryMethodDefinitionish = { + methodName: "List", + service: ProjectServiceDesc, + requestStream: false, + responseStream: false, + requestType: { + serializeBinary() { + return ProjectServiceListRequest.encode(this).finish(); + }, + } as any, + responseType: { + deserializeBinary(data: Uint8Array) { + const value = ProjectServiceListResponse.decode(data); + return { + ...value, + toObject() { + return value; + }, + }; + }, + } as any, +}; + export const ProjectServiceListMembersDesc: UnaryMethodDefinitionish = { methodName: "ListMembers", service: ProjectServiceDesc, diff --git a/app/controlplane/api/gen/jsonschema/controlplane.v1.ProjectServiceListRequest.jsonschema.json b/app/controlplane/api/gen/jsonschema/controlplane.v1.ProjectServiceListRequest.jsonschema.json new file mode 100644 index 000000000..8adc2c34c --- /dev/null +++ b/app/controlplane/api/gen/jsonschema/controlplane.v1.ProjectServiceListRequest.jsonschema.json @@ -0,0 +1,18 @@ +{ + "$id": "controlplane.v1.ProjectServiceListRequest.jsonschema.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "description": "ProjectServiceListRequest contains parameters for filtering and paginating the projects of an organization", + "properties": { + "name": { + "description": "Filter by project name, case-insensitive substring match", + "type": "string" + }, + "pagination": { + "$ref": "controlplane.v1.OffsetPaginationRequest.jsonschema.json", + "description": "Pagination parameters to limit and offset results" + } + }, + "title": "Project Service List Request", + "type": "object" +} diff --git a/app/controlplane/api/gen/jsonschema/controlplane.v1.ProjectServiceListRequest.schema.json b/app/controlplane/api/gen/jsonschema/controlplane.v1.ProjectServiceListRequest.schema.json new file mode 100644 index 000000000..5e89d8983 --- /dev/null +++ b/app/controlplane/api/gen/jsonschema/controlplane.v1.ProjectServiceListRequest.schema.json @@ -0,0 +1,18 @@ +{ + "$id": "controlplane.v1.ProjectServiceListRequest.schema.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "description": "ProjectServiceListRequest contains parameters for filtering and paginating the projects of an organization", + "properties": { + "name": { + "description": "Filter by project name, case-insensitive substring match", + "type": "string" + }, + "pagination": { + "$ref": "controlplane.v1.OffsetPaginationRequest.schema.json", + "description": "Pagination parameters to limit and offset results" + } + }, + "title": "Project Service List Request", + "type": "object" +} diff --git a/app/controlplane/api/gen/jsonschema/controlplane.v1.ProjectServiceListResponse.ProjectItem.jsonschema.json b/app/controlplane/api/gen/jsonschema/controlplane.v1.ProjectServiceListResponse.ProjectItem.jsonschema.json new file mode 100644 index 000000000..27e470a1f --- /dev/null +++ b/app/controlplane/api/gen/jsonschema/controlplane.v1.ProjectServiceListResponse.ProjectItem.jsonschema.json @@ -0,0 +1,40 @@ +{ + "$id": "controlplane.v1.ProjectServiceListResponse.ProjectItem.jsonschema.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "description": "ProjectItem represents a project of the organization", + "patternProperties": { + "^(created_at)$": { + "$ref": "google.protobuf.Timestamp.jsonschema.json", + "description": "Timestamp when the project was created" + }, + "^(updated_at)$": { + "$ref": "google.protobuf.Timestamp.jsonschema.json", + "description": "Timestamp when the project was last updated" + } + }, + "properties": { + "createdAt": { + "$ref": "google.protobuf.Timestamp.jsonschema.json", + "description": "Timestamp when the project was created" + }, + "description": { + "description": "Description of the project", + "type": "string" + }, + "id": { + "description": "Unique identifier of the project", + "type": "string" + }, + "name": { + "description": "Name of the project", + "type": "string" + }, + "updatedAt": { + "$ref": "google.protobuf.Timestamp.jsonschema.json", + "description": "Timestamp when the project was last updated" + } + }, + "title": "Project Item", + "type": "object" +} diff --git a/app/controlplane/api/gen/jsonschema/controlplane.v1.ProjectServiceListResponse.ProjectItem.schema.json b/app/controlplane/api/gen/jsonschema/controlplane.v1.ProjectServiceListResponse.ProjectItem.schema.json new file mode 100644 index 000000000..0e5226137 --- /dev/null +++ b/app/controlplane/api/gen/jsonschema/controlplane.v1.ProjectServiceListResponse.ProjectItem.schema.json @@ -0,0 +1,40 @@ +{ + "$id": "controlplane.v1.ProjectServiceListResponse.ProjectItem.schema.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "description": "ProjectItem represents a project of the organization", + "patternProperties": { + "^(createdAt)$": { + "$ref": "google.protobuf.Timestamp.schema.json", + "description": "Timestamp when the project was created" + }, + "^(updatedAt)$": { + "$ref": "google.protobuf.Timestamp.schema.json", + "description": "Timestamp when the project was last updated" + } + }, + "properties": { + "created_at": { + "$ref": "google.protobuf.Timestamp.schema.json", + "description": "Timestamp when the project was created" + }, + "description": { + "description": "Description of the project", + "type": "string" + }, + "id": { + "description": "Unique identifier of the project", + "type": "string" + }, + "name": { + "description": "Name of the project", + "type": "string" + }, + "updated_at": { + "$ref": "google.protobuf.Timestamp.schema.json", + "description": "Timestamp when the project was last updated" + } + }, + "title": "Project Item", + "type": "object" +} diff --git a/app/controlplane/api/gen/jsonschema/controlplane.v1.ProjectServiceListResponse.jsonschema.json b/app/controlplane/api/gen/jsonschema/controlplane.v1.ProjectServiceListResponse.jsonschema.json new file mode 100644 index 000000000..dfe138859 --- /dev/null +++ b/app/controlplane/api/gen/jsonschema/controlplane.v1.ProjectServiceListResponse.jsonschema.json @@ -0,0 +1,21 @@ +{ + "$id": "controlplane.v1.ProjectServiceListResponse.jsonschema.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "description": "ProjectServiceListResponse contains a paginated list of the organization's projects", + "properties": { + "pagination": { + "$ref": "controlplane.v1.OffsetPaginationResponse.jsonschema.json", + "description": "Pagination information for the response" + }, + "projects": { + "description": "List of projects matching the request criteria", + "items": { + "$ref": "controlplane.v1.ProjectServiceListResponse.ProjectItem.jsonschema.json" + }, + "type": "array" + } + }, + "title": "Project Service List Response", + "type": "object" +} diff --git a/app/controlplane/api/gen/jsonschema/controlplane.v1.ProjectServiceListResponse.schema.json b/app/controlplane/api/gen/jsonschema/controlplane.v1.ProjectServiceListResponse.schema.json new file mode 100644 index 000000000..08a58b94a --- /dev/null +++ b/app/controlplane/api/gen/jsonschema/controlplane.v1.ProjectServiceListResponse.schema.json @@ -0,0 +1,21 @@ +{ + "$id": "controlplane.v1.ProjectServiceListResponse.schema.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "description": "ProjectServiceListResponse contains a paginated list of the organization's projects", + "properties": { + "pagination": { + "$ref": "controlplane.v1.OffsetPaginationResponse.schema.json", + "description": "Pagination information for the response" + }, + "projects": { + "description": "List of projects matching the request criteria", + "items": { + "$ref": "controlplane.v1.ProjectServiceListResponse.ProjectItem.schema.json" + }, + "type": "array" + } + }, + "title": "Project Service List Response", + "type": "object" +} diff --git a/app/controlplane/internal/service/project.go b/app/controlplane/internal/service/project.go index 46db4bbcd..9564fb1b9 100644 --- a/app/controlplane/internal/service/project.go +++ b/app/controlplane/internal/service/project.go @@ -39,6 +39,46 @@ func NewProjectService(opts ...NewOpt) *ProjectService { } } +// List returns the projects of the current organization the caller can see. +func (s *ProjectService) List(ctx context.Context, req *pb.ProjectServiceListRequest) (*pb.ProjectServiceListResponse, error) { + currentOrg, err := requireCurrentOrg(ctx) + if err != nil { + return nil, err + } + + orgUUID, err := uuid.Parse(currentOrg.ID) + if err != nil { + return nil, handleUseCaseErr(err, s.log) + } + + // Initialize the pagination options, with default values + paginationOpts, err := initializePaginationOpts(req.GetPagination()) + if err != nil { + return nil, handleUseCaseErr(err, s.log) + } + + // visibleProjects is nil when RBAC does not apply, which the use case reads as + // "no restriction", and a possibly empty set of project IDs otherwise. + projects, total, err := s.projectUseCase.List(ctx, &biz.ProjectListOpts{ + OrganizationID: orgUUID, + Name: req.Name, + VisibleProjects: s.visibleProjects(ctx), + }, paginationOpts) + if err != nil { + return nil, handleUseCaseErr(err, s.log) + } + + result := make([]*pb.ProjectServiceListResponse_ProjectItem, 0, len(projects)) + for _, p := range projects { + result = append(result, bizProjectToPb(p)) + } + + return &pb.ProjectServiceListResponse{ + Projects: result, + Pagination: paginationToPb(total, paginationOpts.Offset(), paginationOpts.Limit()), + }, nil +} + // ListMembers lists the members of a project. func (s *ProjectService) ListMembers(ctx context.Context, req *pb.ProjectServiceListMembersRequest) (*pb.ProjectServiceListMembersResponse, error) { currentOrg, err := requireCurrentOrg(ctx) @@ -431,6 +471,24 @@ func mapAuthzRoleToProjectMemberRole(role authz.Role) pb.ProjectMemberRole { } } +// bizProjectToPb converts a biz.Project to the item returned by List. +func bizProjectToPb(p *biz.Project) *pb.ProjectServiceListResponse_ProjectItem { + item := &pb.ProjectServiceListResponse_ProjectItem{ + Id: p.ID.String(), + Name: p.Name, + Description: p.Description, + } + + if p.CreatedAt != nil { + item.CreatedAt = timestamppb.New(*p.CreatedAt) + } + if p.UpdatedAt != nil { + item.UpdatedAt = timestamppb.New(*p.UpdatedAt) + } + + return item +} + // bizOrgInvitationToPendingProjectInvitationPb converts a biz.OrgInvitation to a pb.PendingProjectInvitation protobuf message. func bizOrgInvitationToPendingProjectInvitationPb(inv *biz.OrgInvitation) *pb.PendingProjectInvitation { base := &pb.PendingProjectInvitation{ diff --git a/app/controlplane/pkg/authz/authz.go b/app/controlplane/pkg/authz/authz.go index fe230e52c..471338e60 100644 --- a/app/controlplane/pkg/authz/authz.go +++ b/app/controlplane/pkg/authz/authz.go @@ -151,6 +151,7 @@ var ( PolicyWorkflowDelete = &Policy{ResourceWorkflow, ActionDelete} // Projects PolicyProjectCreate = &Policy{ResourceProject, ActionCreate} + PolicyProjectList = &Policy{ResourceProject, ActionList} // Organization PolicyOrganizationCreate = &Policy{Organization, ActionCreate} @@ -237,6 +238,8 @@ var RolesMap = map[Role][]*Policy{ // Workflow PolicyWorkflowList, PolicyWorkflowRead, + // Project + PolicyProjectList, // Organization PolicyOrganizationRead, @@ -259,6 +262,9 @@ var RolesMap = map[Role][]*Policy{ PolicyWorkflowUpdate, PolicyWorkflowDelete, + // Listing projects is RBAC-filtered to the ones the contributor can see + PolicyProjectList, + PolicyWorkflowRunList, PolicyWorkflowRunRead, @@ -433,6 +439,7 @@ var ServerOperationsMap = map[string]*OperationPolicy{ "/controlplane.v1.GroupService/UpdateMemberMaintainerStatus": {}, // Project Memberships + "/controlplane.v1.ProjectService/List": {Policies: []*Policy{PolicyProjectList}}, "/controlplane.v1.ProjectService/ListMembers": {Policies: []*Policy{PolicyProjectListMemberships}}, "/controlplane.v1.ProjectService/AddMember": {Policies: []*Policy{PolicyProjectAddMemberships}}, "/controlplane.v1.ProjectService/RemoveMember": {Policies: []*Policy{PolicyProjectRemoveMemberships}}, diff --git a/app/controlplane/pkg/biz/project.go b/app/controlplane/pkg/biz/project.go index 24f2dea1b..24c02bed4 100644 --- a/app/controlplane/pkg/biz/project.go +++ b/app/controlplane/pkg/biz/project.go @@ -38,6 +38,9 @@ type ProjectsRepo interface { FindProjectByOrgIDAndID(ctx context.Context, orgID uuid.UUID, projectID uuid.UUID) (*Project, error) Create(ctx context.Context, orgID uuid.UUID, name string) (*Project, error) ListProjectsByOrgID(ctx context.Context, orgID uuid.UUID) ([]*Project, error) + // List retrieves the projects of an organization matching filterOpts, plus the + // total number of matches before pagination. + List(ctx context.Context, filterOpts *ProjectListOpts, paginationOpts *pagination.OffsetPaginationOpts) ([]*Project, int, error) // ListMembers retrieves a list of members in a project, optionally filtered by admin status. ListMembers(ctx context.Context, orgID uuid.UUID, projectID uuid.UUID, paginationOpts *pagination.OffsetPaginationOpts) ([]*ProjectMembership, int, error) // AddMemberToProject adds a user or group to a project with a specific role. @@ -73,6 +76,8 @@ type Project struct { ID uuid.UUID // Name is the name of the project Name string + // Description is the optional, human-provided description of the project + Description string // OrgID is the organization that this project belongs to OrgID uuid.UUID // CreatedAt is the time when the project was created @@ -81,6 +86,18 @@ type Project struct { UpdatedAt *time.Time } +// ProjectListOpts filters a project listing. +type ProjectListOpts struct { + // OrganizationID scopes the listing to one organization. Required. + OrganizationID uuid.UUID + // Name filters by project name, case-insensitive substring match. + Name *string + // VisibleProjects are the projects the caller is allowed to see when RBAC + // applies. A nil slice means no restriction; an empty one means nothing is + // visible. + VisibleProjects []uuid.UUID +} + // ProjectMembership represents a membership of a user or group in a project. type ProjectMembership struct { // User is the user who is a member of the project (nil for group memberships). @@ -233,6 +250,23 @@ func (uc *ProjectUseCase) Create(ctx context.Context, orgID, name string) (*Proj return project, nil } +// List returns the projects of an organization the caller is allowed to see, +// plus the total number of matches before pagination is applied. +func (uc *ProjectUseCase) List(ctx context.Context, filterOpts *ProjectListOpts, paginationOpts *pagination.OffsetPaginationOpts) ([]*Project, int, error) { + ctx, span := otelx.Start(ctx, projectTracer, "ProjectUseCase.List") + defer span.End() + + if filterOpts == nil || filterOpts.OrganizationID == uuid.Nil { + return nil, 0, NewErrValidationStr("organization ID is required to list projects") + } + + if paginationOpts == nil { + paginationOpts = pagination.NewDefaultOffsetPaginationOpts() + } + + return uc.projectsRepository.List(ctx, filterOpts, paginationOpts) +} + // ListMembers lists the members of a project with pagination. func (uc *ProjectUseCase) ListMembers(ctx context.Context, orgID uuid.UUID, projectRef *IdentityReference, paginationOpts *pagination.OffsetPaginationOpts) ([]*ProjectMembership, int, error) { ctx, span := otelx.Start(ctx, projectTracer, "ProjectUseCase.ListMembers") diff --git a/app/controlplane/pkg/biz/project_list_integration_test.go b/app/controlplane/pkg/biz/project_list_integration_test.go new file mode 100644 index 000000000..f91a39bb9 --- /dev/null +++ b/app/controlplane/pkg/biz/project_list_integration_test.go @@ -0,0 +1,190 @@ +// +// Copyright 2026 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package biz_test + +import ( + "context" + "testing" + + "github.com/chainloop-dev/chainloop/app/controlplane/pkg/biz" + "github.com/chainloop-dev/chainloop/app/controlplane/pkg/biz/testhelpers" + "github.com/chainloop-dev/chainloop/app/controlplane/pkg/pagination" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" +) + +// Project names the table cases share. +const ( + projAlphaService = "alpha-service" + projBetaService = "beta-service" + projGammaAPI = "gamma-api" +) + +func TestProjectList(t *testing.T) { + suite.Run(t, new(projectListIntegrationTestSuite)) +} + +type projectListIntegrationTestSuite struct { + testhelpers.UseCasesEachTestSuite + org *biz.Organization + otherOrg *biz.Organization + // projects in org, created in a deliberately unsorted order + alpha, beta, gamma *biz.Project +} + +func (s *projectListIntegrationTestSuite) SetupTest() { + var err error + ctx := context.Background() + s.TestingUseCases = testhelpers.NewTestingUseCases(s.T()) + + s.org, err = s.Organization.CreateWithRandomName(ctx) + require.NoError(s.T(), err) + s.otherOrg, err = s.Organization.CreateWithRandomName(ctx) + require.NoError(s.T(), err) + + s.gamma, err = s.Project.Create(ctx, s.org.ID, projGammaAPI) + require.NoError(s.T(), err) + s.alpha, err = s.Project.Create(ctx, s.org.ID, projAlphaService) + require.NoError(s.T(), err) + s.beta, err = s.Project.Create(ctx, s.org.ID, projBetaService) + require.NoError(s.T(), err) + + _, err = s.Project.Create(ctx, s.otherOrg.ID, "other-org-project") + require.NoError(s.T(), err) +} + +func (s *projectListIntegrationTestSuite) TearDownTest() { + ctx := context.Background() + _, _ = s.Data.DB.Project.Delete().Exec(ctx) +} + +func (s *projectListIntegrationTestSuite) TestList() { + ctx := context.Background() + orgID := uuid.MustParse(s.org.ID) + name := func(v string) *string { return &v } + + testCases := []struct { + name string + opts *biz.ProjectListOpts + // wantNames is the expected result, in order + wantNames []string + // wantCount is the total number of matches, before pagination + wantCount int + }{ + { + name: "nil visible projects lists every project in the organization, sorted by name", + opts: &biz.ProjectListOpts{OrganizationID: orgID}, + wantNames: []string{projAlphaService, projBetaService, projGammaAPI}, + wantCount: 3, + }, + { + name: "visible projects restricts the result to that set", + opts: &biz.ProjectListOpts{OrganizationID: orgID, VisibleProjects: []uuid.UUID{s.gamma.ID, s.alpha.ID}}, + wantNames: []string{projAlphaService, projGammaAPI}, + wantCount: 2, + }, + { + name: "an empty visible projects set means nothing is visible, not everything", + opts: &biz.ProjectListOpts{OrganizationID: orgID, VisibleProjects: []uuid.UUID{}}, + wantNames: []string{}, + wantCount: 0, + }, + { + name: "name filter matches a substring", + opts: &biz.ProjectListOpts{OrganizationID: orgID, Name: name("service")}, + wantNames: []string{projAlphaService, projBetaService}, + wantCount: 2, + }, + { + name: "name filter is case insensitive", + opts: &biz.ProjectListOpts{OrganizationID: orgID, Name: name("SERVICE")}, + wantNames: []string{projAlphaService, projBetaService}, + wantCount: 2, + }, + { + name: "name filter and visible projects are combined", + opts: &biz.ProjectListOpts{OrganizationID: orgID, Name: name("service"), VisibleProjects: []uuid.UUID{s.beta.ID}}, + wantNames: []string{projBetaService}, + wantCount: 1, + }, + { + name: "another organization's projects are never returned", + opts: &biz.ProjectListOpts{OrganizationID: uuid.MustParse(s.otherOrg.ID)}, + wantNames: []string{"other-org-project"}, + wantCount: 1, + }, + } + + for _, tc := range testCases { + s.Run(tc.name, func() { + got, count, err := s.Project.List(ctx, tc.opts, nil) + s.NoError(err) + s.Equal(tc.wantCount, count) + + gotNames := make([]string, 0, len(got)) + for _, p := range got { + gotNames = append(gotNames, p.Name) + } + s.Equal(tc.wantNames, gotNames) + }) + } +} + +func (s *projectListIntegrationTestSuite) TestListPagination() { + ctx := context.Background() + opts := &biz.ProjectListOpts{OrganizationID: uuid.MustParse(s.org.ID)} + + s.Run("the count is the total number of matches, not the page size", func() { + paginationOpts, err := pagination.NewOffsetPaginationOpts(1, 2) + require.NoError(s.T(), err) + + got, count, err := s.Project.List(ctx, opts, paginationOpts) + s.NoError(err) + s.Equal(3, count) + s.Len(got, 2) + s.Equal(projAlphaService, got[0].Name) + s.Equal(projBetaService, got[1].Name) + }) + + s.Run("the second page continues where the first ended", func() { + paginationOpts, err := pagination.NewOffsetPaginationOpts(2, 2) + require.NoError(s.T(), err) + + got, count, err := s.Project.List(ctx, opts, paginationOpts) + s.NoError(err) + s.Equal(3, count) + s.Len(got, 1) + s.Equal(projGammaAPI, got[0].Name) + }) +} + +func (s *projectListIntegrationTestSuite) TestListValidation() { + ctx := context.Background() + + s.Run("the organization is required", func() { + _, _, err := s.Project.List(ctx, &biz.ProjectListOpts{}, nil) + s.Error(err) + s.True(biz.IsErrValidation(err)) + }) + + s.Run("the options are required", func() { + _, _, err := s.Project.List(ctx, nil, nil) + s.Error(err) + s.True(biz.IsErrValidation(err)) + }) +} diff --git a/app/controlplane/pkg/data/project.go b/app/controlplane/pkg/data/project.go index f00ca302c..5a6f856d3 100644 --- a/app/controlplane/pkg/data/project.go +++ b/app/controlplane/pkg/data/project.go @@ -111,6 +111,49 @@ func (r *ProjectRepo) ListProjectsByOrgID(ctx context.Context, orgID uuid.UUID) return res, nil } +// List returns the projects of an organization matching filterOpts, ordered by +// name, plus the total number of matches before pagination. +func (r *ProjectRepo) List(ctx context.Context, filterOpts *biz.ProjectListOpts, paginationOpts *pagination.OffsetPaginationOpts) ([]*biz.Project, int, error) { + ctx, span := otelx.Start(ctx, projectRepoTracer, "ProjectRepo.List") + defer span.End() + + q := r.data.DB.Project.Query().Where( + project.OrganizationID(filterOpts.OrganizationID), + project.DeletedAtIsNil(), + ) + + // A nil set means RBAC does not restrict the caller. An empty one means they + // can see nothing, which project.IDIn() with no arguments expresses. + if filterOpts.VisibleProjects != nil { + q = q.Where(project.IDIn(filterOpts.VisibleProjects...)) + } + + if filterOpts.Name != nil && *filterOpts.Name != "" { + q = q.Where(project.NameContainsFold(*filterOpts.Name)) + } + + count, err := q.Count(ctx) + if err != nil { + return nil, 0, fmt.Errorf("counting projects: %w", err) + } + + prs, err := q. + Order(ent.Asc(project.FieldName)). + Offset(paginationOpts.Offset()). + Limit(paginationOpts.Limit()). + All(ctx) + if err != nil { + return nil, 0, fmt.Errorf("listing projects: %w", err) + } + + res := make([]*biz.Project, 0, len(prs)) + for _, p := range prs { + res = append(res, entProjectToBiz(p)) + } + + return res, count, nil +} + func (r *ProjectRepo) Create(ctx context.Context, orgID uuid.UUID, name string) (*biz.Project, error) { ctx, span := otelx.Start(ctx, projectRepoTracer, "ProjectRepo.Create") defer span.End() @@ -375,11 +418,12 @@ func (r *ProjectRepo) queryMembership(orgID uuid.UUID, projectID uuid.UUID, memb // entProjectToBiz converts an ent.Project to a biz.Project func entProjectToBiz(pro *ent.Project) *biz.Project { return &biz.Project{ - ID: pro.ID, - Name: pro.Name, - OrgID: pro.OrganizationID, - CreatedAt: &pro.CreatedAt, - UpdatedAt: &pro.UpdatedAt, + ID: pro.ID, + Name: pro.Name, + Description: pro.Description, + OrgID: pro.OrganizationID, + CreatedAt: &pro.CreatedAt, + UpdatedAt: &pro.UpdatedAt, } } diff --git a/go.mod b/go.mod index 09cbb1f5b..21c818825 100644 --- a/go.mod +++ b/go.mod @@ -119,6 +119,10 @@ require ( require ( cel.dev/expr v0.25.2 // indirect + charm.land/bubbles/v2 v2.0.0 // indirect + charm.land/bubbletea/v2 v2.0.2 // indirect + charm.land/huh/v2 v2.0.3 // indirect + charm.land/lipgloss/v2 v2.0.3 // indirect cloud.google.com/go/auth v0.23.0 // indirect cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect cloud.google.com/go/kms v1.33.0 // indirect @@ -143,6 +147,7 @@ require ( github.com/antithesishq/antithesis-sdk-go v0.7.2-default-no-op // indirect github.com/antlr4-go/antlr/v4 v4.13.1 // indirect github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect + github.com/atotto/clipboard v0.1.4 // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.18 // indirect github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.39 // indirect github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.17 // indirect @@ -158,8 +163,18 @@ require ( github.com/bodgit/windows v1.0.1 // indirect github.com/buger/jsonparser v1.1.2 // indirect github.com/casbin/govaluate v1.3.0 // indirect + github.com/catppuccin/go v0.2.0 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/charlievieth/fastwalk v1.0.14 // indirect + github.com/charmbracelet/colorprofile v0.4.3 // indirect + github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 // indirect + github.com/charmbracelet/x/ansi v0.11.7 // indirect + github.com/charmbracelet/x/exp/ordered v0.1.0 // indirect + github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect + github.com/charmbracelet/x/term v0.2.2 // indirect + github.com/charmbracelet/x/termios v0.1.1 // indirect + github.com/charmbracelet/x/windows v0.2.2 // indirect + github.com/clipperhouse/displaywidth v0.11.0 // indirect github.com/clipperhouse/uax29/v2 v2.7.0 // indirect github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 // indirect github.com/containerd/errdefs v1.0.0 // indirect @@ -235,6 +250,7 @@ require ( github.com/lestrrat-go/httprc/v3 v3.0.5 // indirect github.com/lestrrat-go/jwx/v3 v3.1.1 // indirect github.com/lestrrat-go/option/v2 v2.0.0 // indirect + github.com/lucasb-eyer/go-colorful v1.4.0 // indirect github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e // indirect github.com/mholt/archives v0.1.6-0.20260429171216-ef71b7a32fae // indirect github.com/miekg/dns v1.1.62 // indirect @@ -244,12 +260,14 @@ require ( github.com/minio/md5-simd v1.1.2 // indirect github.com/minio/minlz v1.1.1 // indirect github.com/mitchellh/copystructure v1.2.0 // indirect + github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect github.com/mitchellh/reflectwalk v1.0.2 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect github.com/moby/go-archive v0.3.0 // indirect github.com/moby/moby/client v0.5.1 // indirect github.com/moby/sys/user v0.4.1 // indirect github.com/moby/sys/userns v0.1.0 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect github.com/natefinch/atomic v1.0.1 // indirect github.com/nats-io/jwt/v2 v2.8.2 // indirect github.com/nats-io/nkeys v0.4.16 // indirect @@ -271,6 +289,7 @@ require ( github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9 // indirect + github.com/rivo/uniseg v0.4.7 // indirect github.com/rrethy/ahocorasick v1.0.0 // indirect github.com/rs/xid v1.6.0 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect @@ -301,6 +320,7 @@ require ( github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect github.com/xeipuuv/gojsonschema v1.2.0 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/yashtewari/glob-intersection v0.2.0 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect github.com/zclconf/go-cty-yaml v1.1.0 // indirect diff --git a/go.sum b/go.sum index 561122a50..5134aea60 100644 --- a/go.sum +++ b/go.sum @@ -10,6 +10,14 @@ buf.build/go/protoyaml v0.7.0 h1:z4oVoFicbpPefhT7WAykxUdfp0yEQlhMQ2mCZOY5V38= buf.build/go/protoyaml v0.7.0/go.mod h1:+a0cavd0uMvirb87xdu2ZMMmjlIQoiH/N2Ich5MGSQ0= cel.dev/expr v0.25.2 h1:K6j46C81hXtZQfuX60cVWQFBJahKSE2gfRbNuvr5bFs= cel.dev/expr v0.25.2/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= +charm.land/bubbles/v2 v2.0.0 h1:tE3eK/pHjmtrDiRdoC9uGNLgpopOd8fjhEe31B/ai5s= +charm.land/bubbles/v2 v2.0.0/go.mod h1:rCHoleP2XhU8um45NTuOWBPNVHxnkXKTiZqcclL/qOI= +charm.land/bubbletea/v2 v2.0.2 h1:4CRtRnuZOdFDTWSff9r8QFt/9+z6Emubz3aDMnf/dx0= +charm.land/bubbletea/v2 v2.0.2/go.mod h1:3LRff2U4WIYXy7MTxfbAQ+AdfM3D8Xuvz2wbsOD9OHQ= +charm.land/huh/v2 v2.0.3 h1:2cJsMqEPwSywGHvdlKsJyQKPtSJLVnFKyFbsYZTlLkU= +charm.land/huh/v2 v2.0.3/go.mod h1:93eEveeeqn47MwiC3tf+2atZ2l7Is88rAtmZNZ8x9Wc= +charm.land/lipgloss/v2 v2.0.3 h1:yM2zJ4Cf5Y51b7RHIwioil4ApI/aypFXXVHSwlM6RzU= +charm.land/lipgloss/v2 v2.0.3/go.mod h1:7myLU9iG/3xluAWzpY/fSxYYHCgoKTie7laxk6ATwXA= cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE= @@ -146,6 +154,8 @@ github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkY github.com/aryann/difflib v0.0.0-20170710044230-e206f873d14a/go.mod h1:DAHtR1m6lCRdSC2Tm3DSWRPvIPr6xNKyeHdqDQSQT+A= github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so= github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aws/aws-lambda-go v1.13.3/go.mod h1:4UKl9IzQMoD+QF79YdCuzCwp8VbmG4VAQwij/eHl5CU= github.com/aws/aws-sdk-go v1.27.0/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo= github.com/aws/aws-sdk-go-v2 v0.18.0/go.mod h1:JWVYvqSMppoMJC0x5wdwiImzgXTI9FuZwxzkQq9wy+g= @@ -225,6 +235,8 @@ github.com/casbin/casbin/v2 v2.135.0 h1:6BLkMQiGotYyS5yYeWgW19vxqugUlvHFkFiLnLR/ github.com/casbin/casbin/v2 v2.135.0/go.mod h1:FmcfntdXLTcYXv/hxgNntcRPqAbwOG9xsism0yXT+18= github.com/casbin/govaluate v1.3.0 h1:VA0eSY0M2lA86dYd5kPPuNZMUD9QkWnOCnavGrw9myc= github.com/casbin/govaluate v1.3.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A= +github.com/catppuccin/go v0.2.0 h1:ktBeIrIP42b/8FGiScP9sgrWOss3lw0Z5SktRoithGA= +github.com/catppuccin/go v0.2.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= github.com/cenkalti/backoff v2.2.1+incompatible/go.mod h1:90ReRw6GdpyfrHakVjL/QHaoyV4aDUVVkXQJJJ3NXXM= github.com/cenkalti/backoff/v4 v4.1.1/go.mod h1:scbssz8iZGpm3xbr14ovlUdkxfGXNInqkPWOWmG2CLw= github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= @@ -237,11 +249,29 @@ github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UF github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/charlievieth/fastwalk v1.0.14 h1:3Eh5uaFGwHZd8EGwTjJnSpBkfwfsak9h6ICgnWlhAyg= github.com/charlievieth/fastwalk v1.0.14/go.mod h1:diVcUreiU1aQ4/Wu3NbxxH4/KYdKpLDojrQ1Bb2KgNY= +github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q= +github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q= +github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 h1:eyFRbAmexyt43hVfeyBofiGSEmJ7krjLOYt/9CF5NKA= +github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8/go.mod h1:SQpCTRNBtzJkwku5ye4S3HEuthAlGy2n9VXZnWkEW98= +github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI= +github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ= +github.com/charmbracelet/x/exp/ordered v0.1.0 h1:55/qLwjIh0gL0Vni+QAWk7T/qRVP6sBf+2agPBgnOFE= +github.com/charmbracelet/x/exp/ordered v0.1.0/go.mod h1:5UHwmG+is5THxMyCJHNPCn2/ecI07aKNrW+LcResjJ8= +github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4= +github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ= +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= +github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= +github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= +github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM= +github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k= github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= github.com/clbanning/x2j v0.0.0-20191024224557-825249438eec/go.mod h1:jMjuTZXRI4dUb/I5gc9Hdhagfvm9+RyrPryS/auMzxE= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8= +github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8= @@ -798,6 +828,8 @@ github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ= github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA= github.com/lightstep/lightstep-tracer-common/golang/gogo v0.0.0-20190605223551-bc2310a04743/go.mod h1:qklhhLq1aX+mtWk9cPHPzaBjWImj5ULL6C7HFJtXQMM= github.com/lightstep/lightstep-tracer-go v0.18.1/go.mod h1:jlF1pusYV4pidLvZ+XD0UBX0ZE6WURAspgAczcDHrL4= +github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4= +github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e h1:Q6MvJtQK/iRcRtzAscm/zF23XxJlbECiGPyRicsX+Ak= github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg= github.com/lyft/protoc-gen-validate v0.0.13/go.mod h1:XbGvPuh87YZc5TdIa2/I4pLk0QoUACkjt2znoq26NVQ= @@ -851,6 +883,8 @@ github.com/mitchellh/go-testing-interface v1.0.0/go.mod h1:kRemZodwjscx+RGhAo8eI github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0= github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0= github.com/mitchellh/gox v0.4.0/go.mod h1:Sd9lOJ0+aimLBi73mGofS1ycjY8lL3uZM3JPS42BGNg= +github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= +github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= github.com/mitchellh/iochan v1.0.0/go.mod h1:JwYml1nuB7xOzsp52dPpHFffvOCDupsG0QubkSMEySY= github.com/mitchellh/mapstructure v0.0.0-20160808181253-ca63d7c062ee/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= @@ -884,6 +918,8 @@ github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3Rllmb github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8= github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= @@ -1049,6 +1085,8 @@ github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9 h1:bsUq1dX0N8A github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4= github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rodaine/protogofakeit v0.1.1 h1:ZKouljuRM3A+TArppfBqnH8tGZHOwM/pjvtXe9DaXH8= github.com/rodaine/protogofakeit v0.1.1/go.mod h1:pXn/AstBYMaSfc1/RqH3N82pBuxtWgejz1AlYpY1mI0= github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg= @@ -1242,6 +1280,8 @@ github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1: github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74= github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y= github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/xrash/smetrics v0.0.0-20240312152122-5f08fbb34913/go.mod h1:4aEEwZQutDLsQv2Deui4iYQ6DWTxR14g6m8Wv88+Xqk= github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM= github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= From 986b8d82a401f233ac39a4cfca98d76655fe831a Mon Sep 17 00:00:00 2001 From: Miguel Martinez Trivino Date: Tue, 8 Sep 2026 19:48:28 +0200 Subject: [PATCH 02/30] fix(cli): honor explicit flags and a stale pinned org on trace init Three problems from review on the interactive init path. An explicit --org or --project still opened its picker, and because the resolver compares the answer against the value it was seeded with, accepting the preselected entry cleared the save flag the flag had set. `trace init --project foo` on a repository with no projectName therefore asked a pointless question and then never wrote foo to .chainloop.yml. The identity step now skips the question for a value that came from a flag and leaves its save behavior alone. The first connection was pinned to the organization .chainloop.yml holds, so a repository pinning an organization the user has since left failed the auth check before the picker could offer another one. It now authenticates unpinned, which is enough for listing memberships, and repins once the organization is settled. go.mod was not tidied after adding huh, which left it recorded as an indirect dependency and failed the module tidy check. The interactive steps moved into resolveIdentityInteractively, which takes the listers, the prompter and a repin callback, so the flag and pinning behavior is covered by table tests rather than only by a live control plane. Assisted-by: Claude Code Signed-off-by: Miguel Martinez Trivino Chainloop-Trace-Sessions: 184822bb-e811-42fd-8e13-735227edc5ce --- app/cli/cmd/trace_init_resolve.go | 86 ++++++++--- app/cli/cmd/trace_init_steps_test.go | 167 +++++++++++++++++++++ app/cli/internal/trace/config/slug_test.go | 20 ++- go.mod | 2 +- go.sum | 12 ++ 5 files changed, 258 insertions(+), 29 deletions(-) create mode 100644 app/cli/cmd/trace_init_steps_test.go diff --git a/app/cli/cmd/trace_init_resolve.go b/app/cli/cmd/trace_init_resolve.go index 80cbb8979..972052f98 100644 --- a/app/cli/cmd/trace_init_resolve.go +++ b/app/cli/cmd/trace_init_resolve.go @@ -53,47 +53,89 @@ func resolveTraceIdentity(ctx context.Context, cfg *traceInitConfig, repoRoot st // Authenticate before asking anything: the pickers are populated from the // control plane, so an unauthenticated user should see the login error // rather than answer questions that lead to one. - executor, err := openTraceExecutor(ctx, cfg.organization) + // + // The connection starts unpinned. A .chainloop.yml pinning an organization + // the user has since left would otherwise fail this check before they get + // the chance to pick another one, and listing memberships is a user-scoped + // call that ignores the organization anyway. + executor, err := openTraceExecutor(ctx, "") if err != nil { return nil, err } - prompt := newHuhPrompter(os.LookupEnv) + // pinTo swaps the connection for one pinned to the resolved organization, + // so the project listing and the workflow creation that follows both + // target it. + pinTo := func(org string) (projectLister, error) { + if org == "" { + return executor, nil + } - // The organization step runs on this connection whatever it is pinned to: - // listing memberships is a user-scoped call that ignores the organization. - org, err := resolveInteractiveOrganization(ctx, executor, prompt, - cfg.organization, viper.GetString(confOptions.organization.viperKey)) - if err != nil { _ = executor.Close() - return nil, err + pinned, err := openTraceExecutor(ctx, org) + if err != nil { + executor = nil + return nil, err + } + + executor = pinned + + return pinned, nil } - if !org.prompted { - logger.Info().Str("organization", org.value).Msg("using your only organization") + if err := resolveIdentityInteractively(ctx, cfg, newHuhPrompter(os.LookupEnv), + viper.GetString(confOptions.organization.viperKey), filepath.Base(repoRoot), executor, pinTo); err != nil { + if executor != nil { + _ = executor.Close() + } + + return nil, err } - // Reopen pinned to the chosen organization so the project listing and the - // workflow creation that follows both target it. - if org.value != cfg.organization { - _ = executor.Close() - if executor, err = openTraceExecutor(ctx, org.value); err != nil { - return nil, err + return executor, nil +} + +// resolveIdentityInteractively asks for the settings the flags left open and +// records them on cfg. pinTo is called once the organization is settled, to +// repin the connection the project listing runs on. +// +// A value the user passed as a flag is already settled: it skips its question +// and keeps the value and the save behavior resolveTraceInitConfig gave it, +// which is what the save flags mean at this point. +func resolveIdentityInteractively(ctx context.Context, cfg *traceInitConfig, p prompter, + currentOrg, repoDir string, orgs orgLister, pinTo func(org string) (projectLister, error)) error { + askOrg, askProject := !cfg.saveOrganization, !cfg.saveProject + + if askOrg { + org, err := resolveInteractiveOrganization(ctx, orgs, p, cfg.organization, currentOrg) + if err != nil { + return err } + + if !org.prompted { + logger.Info().Str("organization", org.value).Msg("using your only organization") + } + + cfg.organization, cfg.saveOrganization = org.value, org.save } - cfg.organization, cfg.saveOrganization = org.value, org.save + projects, err := pinTo(cfg.organization) + if err != nil { + return err + } + + if !askProject { + return nil + } - project, err := resolveInteractiveProject(ctx, executor, prompt, - cfg.project, filepath.Base(repoRoot)) + project, err := resolveInteractiveProject(ctx, projects, p, cfg.project, repoDir) if err != nil { - _ = executor.Close() - return nil, err + return err } cfg.project, cfg.saveProject = project.value, project.save - return executor, nil + return nil } // openTraceExecutor dials the control plane pinned to orgName, an empty name diff --git a/app/cli/cmd/trace_init_steps_test.go b/app/cli/cmd/trace_init_steps_test.go new file mode 100644 index 000000000..fc5f5fb6d --- /dev/null +++ b/app/cli/cmd/trace_init_steps_test.go @@ -0,0 +1,167 @@ +// +// Copyright 2026 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package cmd + +import ( + "context" + "errors" + "testing" + + "github.com/chainloop-dev/chainloop/app/cli/pkg/action" + + "github.com/rs/zerolog" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// identityStepsFixture wires resolveIdentityInteractively to fakes and records +// which organization the connection was repinned to. +type identityStepsFixture struct { + orgs *fakeOrgLister + projects *fakeProjectLister + prompt *fakePrompter + + pinnedTo string + pinCalls int + pinErr error + pinReturned bool +} + +func (f *identityStepsFixture) pinTo(org string) (projectLister, error) { + f.pinCalls++ + f.pinnedTo = org + if f.pinErr != nil { + return nil, f.pinErr + } + + f.pinReturned = true + + return f.projects, nil +} + +func newIdentityStepsFixture(orgs []*action.MembershipItem, projects []string, p *fakePrompter) *identityStepsFixture { + return &identityStepsFixture{ + orgs: &fakeOrgLister{orgs: orgs}, + projects: &fakeProjectLister{projects: projects}, + prompt: p, + } +} + +func (f *identityStepsFixture) run(cfg *traceInitConfig) error { + return resolveIdentityInteractively(context.Background(), cfg, f.prompt, orgAcme, repoDirMyRepo, f.orgs, f.pinTo) +} + +// TestResolveIdentityInteractivelyFlags pins down that a value the user passed +// explicitly is never second-guessed: no question is asked for it, its value +// survives, and so does the save flag resolveTraceInitConfig set. +func TestResolveIdentityInteractivelyFlags(t *testing.T) { + logger = zerolog.Nop() + + orgs := []*action.MembershipItem{membership(orgAcme, true), membership(orgGlobex, false)} + projects := []string{projectAPI, "payments"} + + t.Run("--org skips the organization question and stays saved", func(t *testing.T) { + // saveOrganization is what resolveTraceInitConfig sets for an explicit --org. + cfg := &traceInitConfig{organization: "flag-org", saveOrganization: true, project: "yml-project"} + f := newIdentityStepsFixture(orgs, projects, &fakePrompter{selectAnswer: projectAPI}) + + require.NoError(t, f.run(cfg)) + assert.Equal(t, "flag-org", cfg.organization) + assert.True(t, cfg.saveOrganization, "an explicit --org must still be written to .chainloop.yml") + assert.Equal(t, "flag-org", f.pinnedTo, "the connection must be pinned to the flag's organization") + // Only the project question ran. + require.Len(t, f.prompt.selects, 1) + assert.Contains(t, f.prompt.selects[0].title, "project") + }) + + t.Run("--project skips the project question and stays saved", func(t *testing.T) { + cfg := &traceInitConfig{project: "flag-project", saveProject: true} + f := newIdentityStepsFixture(orgs, projects, &fakePrompter{selectAnswer: orgGlobex}) + + require.NoError(t, f.run(cfg)) + assert.Equal(t, "flag-project", cfg.project) + assert.True(t, cfg.saveProject, "an explicit --project must still be written to .chainloop.yml") + // Only the organization question ran. + require.Len(t, f.prompt.selects, 1) + assert.Contains(t, f.prompt.selects[0].title, "organization") + assert.Empty(t, f.prompt.inputs) + }) + + t.Run("both flags ask nothing at all", func(t *testing.T) { + cfg := &traceInitConfig{ + organization: "flag-org", saveOrganization: true, + project: "flag-project", saveProject: true, + } + f := newIdentityStepsFixture(orgs, projects, &fakePrompter{}) + + require.NoError(t, f.run(cfg)) + assert.Empty(t, f.prompt.selects) + assert.Empty(t, f.prompt.inputs) + assert.Equal(t, "flag-org", cfg.organization) + assert.Equal(t, "flag-project", cfg.project) + assert.True(t, cfg.saveOrganization) + assert.True(t, cfg.saveProject) + }) + + t.Run("with no flags both questions are asked", func(t *testing.T) { + cfg := &traceInitConfig{} + f := newIdentityStepsFixture(orgs, projects, &fakePrompter{selectAnswer: orgGlobex}) + + require.NoError(t, f.run(cfg)) + // The organization picker, then the project picker. + require.Len(t, f.prompt.selects, 2) + assert.Contains(t, f.prompt.selects[0].title, "organization") + assert.Contains(t, f.prompt.selects[1].title, "project") + assert.Equal(t, orgGlobex, cfg.organization) + assert.True(t, cfg.saveOrganization) + }) +} + +func TestResolveIdentityInteractivelyPinning(t *testing.T) { + logger = zerolog.Nop() + + orgs := []*action.MembershipItem{membership(orgAcme, true), membership(orgGlobex, false)} + + t.Run("the project listing runs on the chosen organization", func(t *testing.T) { + cfg := &traceInitConfig{} + f := newIdentityStepsFixture(orgs, []string{projectAPI}, &fakePrompter{selectAnswer: orgGlobex}) + + require.NoError(t, f.run(cfg)) + assert.Equal(t, orgGlobex, f.pinnedTo, "pinning must happen after the organization is picked") + assert.Equal(t, 1, f.pinCalls) + assert.True(t, f.pinReturned, "the project step must use the repinned connection") + }) + + t.Run("a pinning failure stops before the project question", func(t *testing.T) { + cfg := &traceInitConfig{} + f := newIdentityStepsFixture(orgs, []string{projectAPI}, &fakePrompter{selectAnswer: orgGlobex}) + f.pinErr = errors.New("dial failed") + + err := f.run(cfg) + require.Error(t, err) + assert.Contains(t, err.Error(), "dial failed") + // The organization question ran, the project one did not. + assert.Len(t, f.prompt.selects, 1) + }) + + t.Run("an aborted question stops before pinning", func(t *testing.T) { + cfg := &traceInitConfig{} + f := newIdentityStepsFixture(orgs, nil, &fakePrompter{selectErr: errAborted}) + + require.ErrorIs(t, f.run(cfg), errAborted) + assert.Zero(t, f.pinCalls) + }) +} diff --git a/app/cli/internal/trace/config/slug_test.go b/app/cli/internal/trace/config/slug_test.go index 3edeaf3b5..af2eb2920 100644 --- a/app/cli/internal/trace/config/slug_test.go +++ b/app/cli/internal/trace/config/slug_test.go @@ -24,6 +24,14 @@ import ( "k8s.io/apimachinery/pkg/util/validation" ) +// Fixtures the table cases share. +const ( + validName = "my-project" + // errLowercase is the fragment the DNS-1123 message uses for a name whose + // characters or edges are wrong, as opposed to its length. + errLowercase = "lowercase" +) + func TestSlugifyDNS1123(t *testing.T) { testCases := []struct { name string @@ -31,7 +39,7 @@ func TestSlugifyDNS1123(t *testing.T) { want string }{ {name: "empty input", input: "", want: ""}, - {name: "an already valid name is unchanged", input: "my-project", want: "my-project"}, + {name: "an already valid name is unchanged", input: validName, want: validName}, {name: "capitals are lowercased", input: "MyProject", want: "myproject"}, {name: "spaces become dashes", input: "My Cool Project", want: "my-cool-project"}, {name: "runs of separators collapse into one dash", input: " --Foo_Bar!! ", want: "foo-bar"}, @@ -80,14 +88,14 @@ func TestValidateDNS1123Label(t *testing.T) { input string wantErr string }{ - {name: "a valid name passes", input: "my-project"}, + {name: "a valid name passes", input: validName}, {name: "a single character is valid", input: "a"}, {name: "63 characters is valid", input: strings.Repeat("a", 63)}, {name: "empty is rejected", input: "", wantErr: "cannot be empty"}, - {name: "capitals are rejected", input: "MyProject", wantErr: "lowercase"}, - {name: "spaces are rejected", input: "my project", wantErr: "lowercase"}, - {name: "a leading dash is rejected", input: "-my-project", wantErr: "lowercase"}, - {name: "a trailing dash is rejected", input: "my-project-", wantErr: "lowercase"}, + {name: "capitals are rejected", input: "MyProject", wantErr: errLowercase}, + {name: "spaces are rejected", input: "my project", wantErr: errLowercase}, + {name: "a leading dash is rejected", input: "-my-project", wantErr: errLowercase}, + {name: "a trailing dash is rejected", input: "my-project-", wantErr: errLowercase}, {name: "64 characters is rejected", input: strings.Repeat("a", 64), wantErr: "63"}, } diff --git a/go.mod b/go.mod index 21c818825..880694dea 100644 --- a/go.mod +++ b/go.mod @@ -106,6 +106,7 @@ require ( require github.com/vektah/gqlparser/v2 v2.5.36 require ( + charm.land/huh/v2 v2.0.3 github.com/XSAM/otelsql v0.43.0 github.com/betterleaks/betterleaks v1.8.1 github.com/go-git/go-billy/v6 v6.0.0-alpha.2 @@ -121,7 +122,6 @@ require ( cel.dev/expr v0.25.2 // indirect charm.land/bubbles/v2 v2.0.0 // indirect charm.land/bubbletea/v2 v2.0.2 // indirect - charm.land/huh/v2 v2.0.3 // indirect charm.land/lipgloss/v2 v2.0.3 // indirect cloud.google.com/go/auth v0.23.0 // indirect cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect diff --git a/go.sum b/go.sum index 5134aea60..8df81f3d0 100644 --- a/go.sum +++ b/go.sum @@ -102,6 +102,8 @@ github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0 github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.57.0 h1:RoO5+d7uCmDqovLrHCr2/BuViUXvdcrNxyNM1pN9dDQ= github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.57.0/go.mod h1:YqwkQPrWSC7+byyc1VlKbWLBF5JsW5IoL6xUkemYSXk= github.com/Knetic/govaluate v3.0.1-0.20171022003610-9aa49832a739+incompatible/go.mod h1:r7JcOSlj0wfOMncg0iLm8Leh48TZaKVeNIfJntJ2wa0= +github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= +github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAwZ/2OOE= github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY= @@ -201,6 +203,8 @@ github.com/aws/aws-sdk-go-v2/service/sts v1.45.7 h1:yU/9y2r7s9kSUPbHXbpQTa4LA8kt github.com/aws/aws-sdk-go-v2/service/sts v1.45.7/go.mod h1:0lQTDEBArMevQXpxu443LVGjKxxEeSsSnrw9n8YiTMg= github.com/aws/smithy-go v1.27.9 h1:flT/ACSU1ksz3V+8wj8kN8DOB9tsc/ggWPTJXIieRpw= github.com/aws/smithy-go v1.27.9/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= +github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o= +github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w= github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= @@ -255,6 +259,12 @@ github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 h1:eyFRb github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8/go.mod h1:SQpCTRNBtzJkwku5ye4S3HEuthAlGy2n9VXZnWkEW98= github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI= github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ= +github.com/charmbracelet/x/conpty v0.1.1 h1:s1bUxjoi7EpqiXysVtC+a8RrvPPNcNvAjfi4jxsAuEs= +github.com/charmbracelet/x/conpty v0.1.1/go.mod h1:OmtR77VODEFbiTzGE9G1XiRJAga6011PIm4u5fTNZpk= +github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA= +github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0= +github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA= +github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I= github.com/charmbracelet/x/exp/ordered v0.1.0 h1:55/qLwjIh0gL0Vni+QAWk7T/qRVP6sBf+2agPBgnOFE= github.com/charmbracelet/x/exp/ordered v0.1.0/go.mod h1:5UHwmG+is5THxMyCJHNPCn2/ecI07aKNrW+LcResjJ8= github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4= @@ -265,6 +275,8 @@ github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8 github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM= github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k= +github.com/charmbracelet/x/xpty v0.1.3 h1:eGSitii4suhzrISYH50ZfufV3v085BXQwIytcOdFSsw= +github.com/charmbracelet/x/xpty v0.1.3/go.mod h1:poPYpWuLDBFCKmKLDnhBp51ATa0ooD8FhypRwEFtH3Y= github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= From 95002916788ed06cfdac77227243cbd8a06629ed Mon Sep 17 00:00:00 2001 From: Miguel Martinez Trivino Date: Tue, 8 Sep 2026 20:08:01 +0200 Subject: [PATCH 03/30] refactor(cli): tighten the trace init connection swap Open the replacement connection before dropping the old one, so the executor variable always names a live connection. The nil sentinel it used to need on a failed dial, and the caller's matching nil check, both go away. The pinning test asserted a flag that pinTo set itself, so it only restated that pinTo succeeded and would have stayed green had the repinned lister been discarded. It now asserts on the options the project picker was given, which only that lister can supply. Cover every entry of the CI signal list rather than one provider, so adding a signal that does not actually suppress prompting fails the test. Assisted-by: Claude Code Signed-off-by: Miguel Martinez Trivino Chainloop-Trace-Sessions: 184822bb-e811-42fd-8e13-735227edc5ce --- app/cli/cmd/trace_init_identity_test.go | 23 ++++++++++++++++++----- app/cli/cmd/trace_init_resolve.go | 16 +++++++--------- app/cli/cmd/trace_init_steps_test.go | 13 ++++++------- app/cli/cmd/trace_init_test.go | 7 +++++-- 4 files changed, 36 insertions(+), 23 deletions(-) diff --git a/app/cli/cmd/trace_init_identity_test.go b/app/cli/cmd/trace_init_identity_test.go index 8f083353d..6139ba83e 100644 --- a/app/cli/cmd/trace_init_identity_test.go +++ b/app/cli/cmd/trace_init_identity_test.go @@ -132,11 +132,7 @@ func TestIsInteractive(t *testing.T) { env: map[string]string{"CI": ""}, stdin: true, stderr: true, want: true, }, - { - name: "Jenkins does not set CI but is still detected", - env: map[string]string{"JENKINS_URL": "https://ci.example.com"}, - stdin: true, stderr: true, want: false, - }, + // Providers that do not set CI are covered by TestIsInteractiveCISignals. { name: "a dumb terminal cannot render a prompt", env: map[string]string{"TERM": "dumb"}, @@ -165,6 +161,23 @@ func TestIsInteractive(t *testing.T) { } } +// TestIsInteractiveCISignals covers every provider in the list, so adding one +// without it actually suppressing prompting fails here. +func TestIsInteractiveCISignals(t *testing.T) { + for _, key := range ciSignals { + t.Run(key, func(t *testing.T) { + lookupEnv := func(k string) (string, bool) { + if k == key { + return "1", true + } + + return "", false + } + assert.False(t, isInteractive(lookupEnv, true, true), "%s must disable prompting", key) + }) + } +} + // --- organization ------------------------------------------------------------ func TestResolveInteractiveOrganization(t *testing.T) { diff --git a/app/cli/cmd/trace_init_resolve.go b/app/cli/cmd/trace_init_resolve.go index 972052f98..50d3581aa 100644 --- a/app/cli/cmd/trace_init_resolve.go +++ b/app/cli/cmd/trace_init_resolve.go @@ -65,19 +65,19 @@ func resolveTraceIdentity(ctx context.Context, cfg *traceInitConfig, repoRoot st // pinTo swaps the connection for one pinned to the resolved organization, // so the project listing and the workflow creation that follows both - // target it. + // target it. The replacement is opened before the old one is dropped, so + // executor always names a live connection for the error path to close. pinTo := func(org string) (projectLister, error) { if org == "" { return executor, nil } - _ = executor.Close() pinned, err := openTraceExecutor(ctx, org) if err != nil { - executor = nil return nil, err } + _ = executor.Close() executor = pinned return pinned, nil @@ -85,10 +85,7 @@ func resolveTraceIdentity(ctx context.Context, cfg *traceInitConfig, repoRoot st if err := resolveIdentityInteractively(ctx, cfg, newHuhPrompter(os.LookupEnv), viper.GetString(confOptions.organization.viperKey), filepath.Base(repoRoot), executor, pinTo); err != nil { - if executor != nil { - _ = executor.Close() - } - + _ = executor.Close() return nil, err } @@ -96,8 +93,9 @@ func resolveTraceIdentity(ctx context.Context, cfg *traceInitConfig, repoRoot st } // resolveIdentityInteractively asks for the settings the flags left open and -// records them on cfg. pinTo is called once the organization is settled, to -// repin the connection the project listing runs on. +// records them on cfg. pinTo is called once the organization is settled, and +// always, since the caller works on the repinned connection whether or not a +// project question follows. // // A value the user passed as a flag is already settled: it skips its question // and keeps the value and the save behavior resolveTraceInitConfig gave it, diff --git a/app/cli/cmd/trace_init_steps_test.go b/app/cli/cmd/trace_init_steps_test.go index fc5f5fb6d..e0c729792 100644 --- a/app/cli/cmd/trace_init_steps_test.go +++ b/app/cli/cmd/trace_init_steps_test.go @@ -34,10 +34,9 @@ type identityStepsFixture struct { projects *fakeProjectLister prompt *fakePrompter - pinnedTo string - pinCalls int - pinErr error - pinReturned bool + pinnedTo string + pinCalls int + pinErr error } func (f *identityStepsFixture) pinTo(org string) (projectLister, error) { @@ -47,8 +46,6 @@ func (f *identityStepsFixture) pinTo(org string) (projectLister, error) { return nil, f.pinErr } - f.pinReturned = true - return f.projects, nil } @@ -142,7 +139,9 @@ func TestResolveIdentityInteractivelyPinning(t *testing.T) { require.NoError(t, f.run(cfg)) assert.Equal(t, orgGlobex, f.pinnedTo, "pinning must happen after the organization is picked") assert.Equal(t, 1, f.pinCalls) - assert.True(t, f.pinReturned, "the project step must use the repinned connection") + // The project picker offers what the repinned lister returned. + require.Len(t, f.prompt.selects, 2) + assert.Contains(t, f.prompt.selects[1].options, projectAPI, "the project step must use the repinned connection") }) t.Run("a pinning failure stops before the project question", func(t *testing.T) { diff --git a/app/cli/cmd/trace_init_test.go b/app/cli/cmd/trace_init_test.go index 44493b8d7..4c4b81421 100644 --- a/app/cli/cmd/trace_init_test.go +++ b/app/cli/cmd/trace_init_test.go @@ -39,6 +39,9 @@ func traceInitTestCmd(t *testing.T, args ...string) *cobra.Command { return cmd } +// defaultTraceWorkflow is the workflow name trace init falls back to. +const defaultTraceWorkflow = "ai-coding-session" + func TestResolveTraceInitConfig(t *testing.T) { const existingYML = "projectName: yml-project\norganization: yml-org\nworkflowName: yml-workflow\nrequireTrace: true\n" @@ -69,7 +72,7 @@ func TestResolveTraceInitConfig(t *testing.T) { name: "the workflow name falls back to the trace default", yml: "projectName: yml-project\n", wantProject: "yml-project", - wantWorkflow: "ai-coding-session", + wantWorkflow: defaultTraceWorkflow, }, { name: "flags win and are marked for saving", @@ -99,7 +102,7 @@ func TestResolveTraceInitConfig(t *testing.T) { yml: "projectVersion: v1.0.0\n", wantProject: "", // The workflow still gets its default. - wantWorkflow: "ai-coding-session", + wantWorkflow: defaultTraceWorkflow, }, } From 49c276973f40e86dc1eeb0721fa42c7b599533ec Mon Sep 17 00:00:00 2001 From: Miguel Martinez Trivino Date: Tue, 8 Sep 2026 20:27:30 +0200 Subject: [PATCH 04/30] fix(cli): show the title and cursor on the trace init pickers huh's Filtering(true) does not enable filtering, it puts the field straight into filter-input mode: titleView renders the filter box in place of the title, and the arrow keys drive the filter rather than the cursor. The picker came up as a bare "/" above an unhighlighted list, giving no clue what was being chosen or what the question was. Dropping the call restores the title and the cursor; "/" still opens the filter on demand, and the help line says so. Also ask which agents to trace. Providers were reachable only through --claude/--cursor/--opencode, so an interactive run silently got Claude Code and no hint that Cursor and opencode exist. init now offers the registered providers as a multi-select with the default ticked, refusing an empty submission. A provider flag skips the question, the same way --org and --project do, and a non-interactive run keeps today's default. The question is asked before anything is created or written, so an abort still leaves the repository untouched. `trace run` stays flag-driven and resolves its providers through the same flag type. Assisted-by: Claude Code Signed-off-by: Miguel Martinez Trivino Chainloop-Trace-Sessions: 184822bb-e811-42fd-8e13-735227edc5ce --- app/cli/cmd/trace_init.go | 57 ++++------ app/cli/cmd/trace_init_identity.go | 13 +++ app/cli/cmd/trace_init_identity_test.go | 26 +++-- app/cli/cmd/trace_init_prompt.go | 30 +++++- app/cli/cmd/trace_init_prompt_test.go | 11 ++ app/cli/cmd/trace_init_providers.go | 109 +++++++++++++++++++ app/cli/cmd/trace_init_providers_test.go | 129 +++++++++++++++++++++++ app/cli/cmd/trace_init_resolve.go | 4 +- app/cli/cmd/trace_run.go | 2 +- app/cli/documentation/cli-reference.mdx | 4 +- 10 files changed, 338 insertions(+), 47 deletions(-) create mode 100644 app/cli/cmd/trace_init_providers.go create mode 100644 app/cli/cmd/trace_init_providers_test.go diff --git a/app/cli/cmd/trace_init.go b/app/cli/cmd/trace_init.go index 22ba544af..4ef9df4f3 100644 --- a/app/cli/cmd/trace_init.go +++ b/app/cli/cmd/trace_init.go @@ -18,13 +18,11 @@ package cmd import ( "context" "fmt" + "os" - "github.com/chainloop-dev/chainloop/app/cli/internal/trace/claude" "github.com/chainloop-dev/chainloop/app/cli/internal/trace/config" - "github.com/chainloop-dev/chainloop/app/cli/internal/trace/cursor" tracegit "github.com/chainloop-dev/chainloop/app/cli/internal/trace/git" "github.com/chainloop-dev/chainloop/app/cli/internal/trace/hooks" - "github.com/chainloop-dev/chainloop/app/cli/internal/trace/opencode" "github.com/chainloop-dev/chainloop/app/cli/internal/trace/providers" "github.com/chainloop-dev/chainloop/app/cli/internal/trace/state" "github.com/chainloop-dev/chainloop/app/cli/pkg/action" @@ -55,7 +53,9 @@ workflow exists, so you need to be logged in. On a terminal it asks which organization and project to use, offering what .chainloop.yml already holds so pressing Enter keeps it. A new project can be named freely; the name is normalized to the lowercase, dash-separated form -Chainloop stores. Passing --org or --project skips the matching question. +Chainloop stores. It then asks which agents to trace, with Claude Code +ticked; use space to tick more. Passing --org, --project or a provider flag +(--claude, --cursor, --opencode) skips the matching question. Nothing is asked in CI or when the output is redirected: there --project is required unless .chainloop.yml carries projectName. Set CHAINLOOP_NO_PROMPT to @@ -83,6 +83,21 @@ The organization, project, workflow and require-trace values are saved to } defer func() { _ = executor.Close() }() + // Ask which agents to trace before anything is created or written, + // so every question is answered up front and an abort leaves the + // repository untouched. + selected, err := resolveTraceProviders(newHuhPrompter(os.LookupEnv), + traceProviderFlags{claude: claudeFlag, cursor: cursorFlag, opencode: opencodeFlag}, + traceInitCanPrompt()) + if err != nil { + return err + } + + selectedProviders := providers.ByNames(selected) + if len(selectedProviders) == 0 { + return fmt.Errorf("no trace providers selected") + } + if err := ensureTraceInitWorkflow(cmd.Context(), executor, cfg, contract); err != nil { return err } @@ -113,17 +128,11 @@ The organization, project, workflow and require-trace values are saved to return fmt.Errorf("mark trace initialized: %w", err) } - // Resolve which providers to install. Pre-push will infer the - // owning provider per-session from the recorded SessionRecord, - // so the list isn't persisted anywhere — only the agent-side + // Install the agent-side hooks for the providers resolved above. + // Pre-push infers the owning provider per-session from the recorded + // SessionRecord, so the list isn't persisted anywhere — only these // hook config files (.claude/settings.json, .cursor/hooks.json) // determine which providers can register sessions. - selected := selectedTraceProviders(claudeFlag, cursorFlag, opencodeFlag) - selectedProviders := providers.ByNames(selected) - if len(selectedProviders) == 0 { - return fmt.Errorf("no trace providers selected") - } - for _, p := range selectedProviders { if err := p.InstallHooks(repoRoot); err != nil { logger.Warn().Err(err).Str("provider", p.Name()).Msg("could not install agent hooks") @@ -281,25 +290,3 @@ func (c *traceInitConfig) save(repoRoot string) error { return nil } - -// selectedTraceProviders resolves the provider names to install based on the -// --claude, --cursor, and --opencode flags. When none is set, Claude Code is -// used as the default so existing users get the same behavior. -func selectedTraceProviders(claudeFlag, cursorFlag, opencodeFlag bool) []string { - if !claudeFlag && !cursorFlag && !opencodeFlag { - return []string{providers.DefaultProvider} - } - - var out []string - if claudeFlag { - out = append(out, claude.Name) - } - if cursorFlag { - out = append(out, cursor.Name) - } - if opencodeFlag { - out = append(out, opencode.Name) - } - - return out -} diff --git a/app/cli/cmd/trace_init_identity.go b/app/cli/cmd/trace_init_identity.go index 7e8e4c343..bb2cab8f3 100644 --- a/app/cli/cmd/trace_init_identity.go +++ b/app/cli/cmd/trace_init_identity.go @@ -20,10 +20,13 @@ import ( "context" "errors" "fmt" + "os" "slices" "github.com/chainloop-dev/chainloop/app/cli/internal/trace/config" "github.com/chainloop-dev/chainloop/app/cli/pkg/action" + + "golang.org/x/term" ) const ( @@ -74,6 +77,9 @@ type projectLister interface { // table-tested with a scripted fake. type prompter interface { Select(title string, options []string, defaultValue string) (string, error) + // MultiSelect asks for zero or more of options, starting with defaults + // ticked. Implementations refuse an empty submission. + MultiSelect(title string, options, defaults []string) ([]string, error) Input(title, defaultValue string, validate func(string) error) (string, error) } @@ -89,6 +95,13 @@ type resolvedValue struct { prompted bool } +// traceInitCanPrompt reports whether `trace init` has a user to ask, wiring +// isInteractive to the real environment and terminals. +func traceInitCanPrompt() bool { + return isInteractive(os.LookupEnv, + term.IsTerminal(int(os.Stdin.Fd())), term.IsTerminal(int(os.Stderr.Fd()))) +} + // isInteractive reports whether the session can show a prompt: both streams the // prompt uses are terminals, the terminal can render one, and this is not a CI // run. It takes its inputs so the decision can be unit tested, the same way diff --git a/app/cli/cmd/trace_init_identity_test.go b/app/cli/cmd/trace_init_identity_test.go index 6139ba83e..c4fb08062 100644 --- a/app/cli/cmd/trace_init_identity_test.go +++ b/app/cli/cmd/trace_init_identity_test.go @@ -52,19 +52,33 @@ type recordedPrompt struct { title string options []string defaultValue string + // defaults is the preselected set for a multi-select. + defaults []string } // fakePrompter answers with scripted values. Input runs the real validator // first, the way a terminal prompt would, so validation is covered through the // same seam the huh implementation uses. type fakePrompter struct { - selectAnswer string - selectErr error - inputAnswer string - inputErr error + selectAnswer string + selectErr error + multiSelectAnswer []string + multiSelectErr error + inputAnswer string + inputErr error + + selects []recordedPrompt + multiSelects []recordedPrompt + inputs []recordedPrompt +} + +func (f *fakePrompter) MultiSelect(title string, options, defaults []string) ([]string, error) { + f.multiSelects = append(f.multiSelects, recordedPrompt{title: title, options: options, defaults: defaults}) + if f.multiSelectErr != nil { + return nil, f.multiSelectErr + } - selects []recordedPrompt - inputs []recordedPrompt + return f.multiSelectAnswer, nil } func (f *fakePrompter) Select(title string, options []string, defaultValue string) (string, error) { diff --git a/app/cli/cmd/trace_init_prompt.go b/app/cli/cmd/trace_init_prompt.go index af411eaab..731a6b645 100644 --- a/app/cli/cmd/trace_init_prompt.go +++ b/app/cli/cmd/trace_init_prompt.go @@ -60,10 +60,13 @@ func newHuhPrompter(lookupEnv func(string) (string, bool)) *huhPrompter { func (h *huhPrompter) Select(title string, options []string, defaultValue string) (string, error) { value := defaultValue + // Filtering() is deliberately not set: it does not enable filtering, it puts + // the field straight into filter-input mode, which replaces the title with an + // empty "/" box and sends the arrow keys to the filter. Pressing "/" opens it + // on demand, which is what the help line offers. field := huh.NewSelect[string](). Title(title). Options(huh.NewOptions(options...)...). - Filtering(true). Value(&value) if err := h.run(field); err != nil { @@ -73,6 +76,31 @@ func (h *huhPrompter) Select(title string, options []string, defaultValue string return value, nil } +// MultiSelect asks the user to tick any number of options, with defaults +// already ticked. An empty submission is refused at the prompt, so the caller +// never has to send the user back through init to fix it. +func (h *huhPrompter) MultiSelect(title string, options, defaults []string) ([]string, error) { + value := append([]string(nil), defaults...) + + field := huh.NewMultiSelect[string](). + Title(title). + Options(huh.NewOptions(options...)...). + Value(&value). + Validate(func(selected []string) error { + if len(selected) == 0 { + return errors.New("pick at least one, with space") + } + + return nil + }) + + if err := h.run(field); err != nil { + return nil, err + } + + return value, nil +} + // Input asks the user to type a value, prefilled with defaultValue. The // description line shows what a free-form answer will be normalized to, so the // result is visible before it is submitted. diff --git a/app/cli/cmd/trace_init_prompt_test.go b/app/cli/cmd/trace_init_prompt_test.go index b98139ad2..65d81868a 100644 --- a/app/cli/cmd/trace_init_prompt_test.go +++ b/app/cli/cmd/trace_init_prompt_test.go @@ -52,6 +52,17 @@ func TestHuhPrompterInput(t *testing.T) { assert.Contains(t, out.String(), "Project name") } +func TestHuhPrompterMultiSelect(t *testing.T) { + // Accessible mode toggles an option by its number and finishes on 0, so + // this ticks the second option on top of the preselected first one. + p, out := accessiblePrompter("2\n0\n") + + got, err := p.MultiSelect("Pick agents", []string{"claude-code", "cursor", "opencode"}, []string{"claude-code"}) + require.NoError(t, err) + assert.Equal(t, []string{"claude-code", "cursor"}, got) + assert.Contains(t, out.String(), "Pick agents") +} + func TestNewHuhPrompterAccessibility(t *testing.T) { testCases := []struct { name string diff --git a/app/cli/cmd/trace_init_providers.go b/app/cli/cmd/trace_init_providers.go new file mode 100644 index 000000000..8090775d2 --- /dev/null +++ b/app/cli/cmd/trace_init_providers.go @@ -0,0 +1,109 @@ +// +// Copyright 2026 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package cmd + +import ( + "errors" + + "github.com/chainloop-dev/chainloop/app/cli/internal/trace/claude" + "github.com/chainloop-dev/chainloop/app/cli/internal/trace/cursor" + "github.com/chainloop-dev/chainloop/app/cli/internal/trace/opencode" + "github.com/chainloop-dev/chainloop/app/cli/internal/trace/providers" +) + +const providersPromptTitle = "Agents to trace in this repository" + +// traceProviderFlags records which --claude / --cursor / --opencode flags the +// user set. +type traceProviderFlags struct { + claude, cursor, opencode bool +} + +// any reports whether the user named at least one provider. +func (f traceProviderFlags) any() bool { + return f.claude || f.cursor || f.opencode +} + +// names returns the providers the flags select, in the registry's order rather +// than the order the flags happen to appear in. +func (f traceProviderFlags) names() []string { + out := make([]string, 0, 3) + for _, p := range []struct { + set bool + name string + }{ + {f.claude, claude.Name}, + {f.cursor, cursor.Name}, + {f.opencode, opencode.Name}, + } { + if p.set { + out = append(out, p.name) + } + } + + return out +} + +// namesOrDefault returns the providers the flags select, falling back to the +// default when none was named. `trace run` is flag-driven and never prompts, +// so it resolves its providers this way. +func (f traceProviderFlags) namesOrDefault() []string { + if !f.any() { + return []string{providers.DefaultProvider} + } + + return f.names() +} + +// traceProviderNames lists every registered provider, in the order the registry +// declares them. +func traceProviderNames() []string { + all := providers.All() + + names := make([]string, 0, len(all)) + for _, p := range all { + names = append(names, p.Name()) + } + + return names +} + +// resolveTraceProviders picks the agents whose hooks init installs. Flags win +// and skip the question, the same way --org and --project do. Otherwise a +// terminal session ticks them off a list with the default preselected, and a +// non-interactive one keeps that default so scripted runs are unchanged. +func resolveTraceProviders(p prompter, flags traceProviderFlags, interactive bool) ([]string, error) { + if flags.any() { + return flags.names(), nil + } + + if !interactive { + return []string{providers.DefaultProvider}, nil + } + + chosen, err := p.MultiSelect(providersPromptTitle, traceProviderNames(), []string{providers.DefaultProvider}) + if err != nil { + return nil, err + } + + // The prompt refuses an empty submission, so this only catches a prompter + // that does not, but tracing nothing would install hooks that never fire. + if len(chosen) == 0 { + return nil, errors.New("select at least one agent to trace") + } + + return chosen, nil +} diff --git a/app/cli/cmd/trace_init_providers_test.go b/app/cli/cmd/trace_init_providers_test.go new file mode 100644 index 000000000..3bc3e2540 --- /dev/null +++ b/app/cli/cmd/trace_init_providers_test.go @@ -0,0 +1,129 @@ +// +// Copyright 2026 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package cmd + +import ( + "testing" + + "github.com/chainloop-dev/chainloop/app/cli/internal/trace/providers" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestResolveTraceProviders(t *testing.T) { + testCases := []struct { + name string + flags traceProviderFlags + interactive bool + // answer is what the user ticks in the multi-select + answer []string + want []string + // wantPrompted is whether the multi-select should have been shown + wantPrompted bool + wantErr string + }{ + { + name: "a single flag wins and asks nothing", + flags: traceProviderFlags{cursor: true}, + // Interactive, to show the flag is what suppresses the question. + interactive: true, + want: []string{"cursor"}, + }, + { + name: "several flags are all honored", + flags: traceProviderFlags{claude: true, opencode: true}, + interactive: true, + want: []string{"claude-code", "opencode"}, + }, + { + name: "without a terminal the default provider is kept", + want: []string{providers.DefaultProvider}, + }, + { + name: "on a terminal the user picks", + interactive: true, + answer: []string{"cursor", "opencode"}, + want: []string{"cursor", "opencode"}, + wantPrompted: true, + }, + { + name: "picking nothing is refused", + interactive: true, + answer: []string{}, + wantPrompted: true, + wantErr: "at least one", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + p := &fakePrompter{multiSelectAnswer: tc.answer} + + got, err := resolveTraceProviders(p, tc.flags, tc.interactive) + + if tc.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tc.wantErr) + return + } + + require.NoError(t, err) + assert.Equal(t, tc.want, got) + + if !tc.wantPrompted { + assert.Empty(t, p.multiSelects, "no question should have been asked") + return + } + + require.Len(t, p.multiSelects, 1) + // Every registered provider is offered, with the default ticked. + assert.Equal(t, traceProviderNames(), p.multiSelects[0].options) + assert.Equal(t, []string{providers.DefaultProvider}, p.multiSelects[0].defaults) + }) + } +} + +func TestResolveTraceProvidersPromptError(t *testing.T) { + _, err := resolveTraceProviders(&fakePrompter{multiSelectErr: errAborted}, traceProviderFlags{}, true) + require.ErrorIs(t, err, errAborted) +} + +func TestTraceProviderFlags(t *testing.T) { + t.Run("no flag set", func(t *testing.T) { + assert.False(t, traceProviderFlags{}.any()) + assert.Empty(t, traceProviderFlags{}.names()) + }) + + t.Run("names follow the registry order, not the flag order", func(t *testing.T) { + f := traceProviderFlags{opencode: true, claude: true} + assert.True(t, f.any()) + assert.Equal(t, []string{"claude-code", "opencode"}, f.names()) + }) +} + +// TestTraceProviderNamesMatchesRegistry guards the multi-select against a +// provider being added to the registry but never offered. +func TestTraceProviderNamesMatchesRegistry(t *testing.T) { + names := traceProviderNames() + require.Len(t, names, len(providers.All())) + + for _, name := range names { + assert.NotNil(t, providers.ByName(name), "%q is offered but not registered", name) + } + + assert.Contains(t, names, providers.DefaultProvider, "the default must be offerable") +} diff --git a/app/cli/cmd/trace_init_resolve.go b/app/cli/cmd/trace_init_resolve.go index 50d3581aa..42893f673 100644 --- a/app/cli/cmd/trace_init_resolve.go +++ b/app/cli/cmd/trace_init_resolve.go @@ -24,7 +24,6 @@ import ( "github.com/chainloop-dev/chainloop/app/cli/pkg/action" "github.com/spf13/viper" - "golang.org/x/term" ) // resolveTraceIdentity settles the organization and project that were not fixed @@ -39,8 +38,7 @@ import ( func resolveTraceIdentity(ctx context.Context, cfg *traceInitConfig, repoRoot string) (*action.AttestationExecutor, error) { // An API token is bound to its own organization server-side, so there is // nothing to choose and no membership list to read. - interactive := authTokenIsUser && isInteractive(os.LookupEnv, - term.IsTerminal(int(os.Stdin.Fd())), term.IsTerminal(int(os.Stderr.Fd()))) + interactive := authTokenIsUser && traceInitCanPrompt() if !interactive { if cfg.project == "" { diff --git a/app/cli/cmd/trace_run.go b/app/cli/cmd/trace_run.go index 3659b717a..998351966 100644 --- a/app/cli/cmd/trace_run.go +++ b/app/cli/cmd/trace_run.go @@ -114,7 +114,7 @@ func newTraceRunCmd() *cobra.Command { return action.TraceRun(cmd.Context(), logger, action.TraceRunOpts{ Store: store, RepoRoot: repoRoot, - Providers: selectedTraceProviders(claudeFlag, cursorFlag, opencodeFlag), + Providers: traceProviderFlags{claude: claudeFlag, cursor: cursorFlag, opencode: opencodeFlag}.namesOrDefault(), Command: args, ProjectName: projectFlag, Organization: organization, diff --git a/app/cli/documentation/cli-reference.mdx b/app/cli/documentation/cli-reference.mdx index e2ebab091..b207f0adb 100755 --- a/app/cli/documentation/cli-reference.mdx +++ b/app/cli/documentation/cli-reference.mdx @@ -3314,7 +3314,9 @@ workflow exists, so you need to be logged in. On a terminal it asks which organization and project to use, offering what .chainloop.yml already holds so pressing Enter keeps it. A new project can be named freely; the name is normalized to the lowercase, dash-separated form -Chainloop stores. Passing --org or --project skips the matching question. +Chainloop stores. It then asks which agents to trace, with Claude Code +ticked; use space to tick more. Passing --org, --project or a provider flag +(--claude, --cursor, --opencode) skips the matching question. Nothing is asked in CI or when the output is redirected: there --project is required unless .chainloop.yml carries projectName. Set CHAINLOOP_NO_PROMPT to From 7ac2e350898cb439f5a579d7f933704a24b9681e Mon Sep 17 00:00:00 2001 From: Miguel Martinez Trivino Date: Tue, 8 Sep 2026 22:10:31 +0200 Subject: [PATCH 05/30] fix(cli): name the project when trace init creates the workflow Every project uses the same "ai-coding-session" workflow name, so the creation log said nothing about where the workflow landed. Also stop the hook-install comment enumerating each agent's config file: it listed only Claude Code and Cursor, and had already gone stale when opencode was added. Assisted-by: Claude Code Signed-off-by: Miguel Martinez Trivino Chainloop-Trace-Sessions: 184822bb-e811-42fd-8e13-735227edc5ce --- app/cli/cmd/trace_init.go | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/app/cli/cmd/trace_init.go b/app/cli/cmd/trace_init.go index 4ef9df4f3..154310163 100644 --- a/app/cli/cmd/trace_init.go +++ b/app/cli/cmd/trace_init.go @@ -130,9 +130,9 @@ The organization, project, workflow and require-trace values are saved to // Install the agent-side hooks for the providers resolved above. // Pre-push infers the owning provider per-session from the recorded - // SessionRecord, so the list isn't persisted anywhere — only these - // hook config files (.claude/settings.json, .cursor/hooks.json) - // determine which providers can register sessions. + // SessionRecord, so the list isn't persisted anywhere — only each + // agent's own hook config file, written here, determines which + // providers can register sessions. for _, p := range selectedProviders { if err := p.InstallHooks(repoRoot); err != nil { logger.Warn().Err(err).Str("provider", p.Name()).Msg("could not install agent hooks") @@ -183,7 +183,10 @@ func ensureTraceInitWorkflow(ctx context.Context, executor *action.AttestationEx } if wf.Created { + // The workflow name is the same in every project, so the project is what + // identifies what was just created. logger.Info(). + Str("project", cfg.project). Str("workflow", cfg.workflow). Str("contract", wf.ContractName). Msg("workflow created") From c61326c011ac44c9b219e4ec3690315c1c5a8cbb Mon Sep 17 00:00:00 2001 From: Miguel Martinez Trivino Date: Tue, 8 Sep 2026 22:12:48 +0200 Subject: [PATCH 06/30] test(cli): make the trace init pinning test discriminate by organization The assertion claimed the project step runs on the repinned connection, but the fixture handed back one lister for every organization and that lister answered with the same projects regardless, so it held either way. Each organization now owns a distinct project and pinTo returns a fresh lister per pin, so the options the picker was given identify the connection that listed them. Pointing the pin at the wrong organization now fails the test. Assisted-by: Claude Code Signed-off-by: Miguel Martinez Trivino Chainloop-Trace-Sessions: 184822bb-e811-42fd-8e13-735227edc5ce --- app/cli/cmd/trace_init_steps_test.go | 40 +++++++++++++++++++++------- 1 file changed, 30 insertions(+), 10 deletions(-) diff --git a/app/cli/cmd/trace_init_steps_test.go b/app/cli/cmd/trace_init_steps_test.go index e0c729792..de19244b1 100644 --- a/app/cli/cmd/trace_init_steps_test.go +++ b/app/cli/cmd/trace_init_steps_test.go @@ -30,9 +30,14 @@ import ( // identityStepsFixture wires resolveIdentityInteractively to fakes and records // which organization the connection was repinned to. type identityStepsFixture struct { - orgs *fakeOrgLister - projects *fakeProjectLister - prompt *fakePrompter + orgs *fakeOrgLister + prompt *fakePrompter + + // defaultProjects is what a pinned connection lists, unless projectsByOrg + // names that organization. Giving each organization its own projects is what + // lets a test tell which connection the project step actually ran on. + defaultProjects []string + projectsByOrg map[string][]string pinnedTo string pinCalls int @@ -46,14 +51,21 @@ func (f *identityStepsFixture) pinTo(org string) (projectLister, error) { return nil, f.pinErr } - return f.projects, nil + projects, ok := f.projectsByOrg[org] + if !ok { + projects = f.defaultProjects + } + + // A fresh lister per pin, so a stale one cannot answer for the new + // organization without the assertions noticing. + return &fakeProjectLister{projects: projects}, nil } func newIdentityStepsFixture(orgs []*action.MembershipItem, projects []string, p *fakePrompter) *identityStepsFixture { return &identityStepsFixture{ - orgs: &fakeOrgLister{orgs: orgs}, - projects: &fakeProjectLister{projects: projects}, - prompt: p, + orgs: &fakeOrgLister{orgs: orgs}, + defaultProjects: projects, + prompt: p, } } @@ -134,14 +146,22 @@ func TestResolveIdentityInteractivelyPinning(t *testing.T) { t.Run("the project listing runs on the chosen organization", func(t *testing.T) { cfg := &traceInitConfig{} - f := newIdentityStepsFixture(orgs, []string{projectAPI}, &fakePrompter{selectAnswer: orgGlobex}) + f := newIdentityStepsFixture(orgs, nil, &fakePrompter{selectAnswer: orgGlobex}) + // Each organization owns a distinct project, so the options the picker + // was given say which connection listed them. Without this the assertion + // would hold even if the project step used a stale connection. + f.projectsByOrg = map[string][]string{ + orgAcme: {"acme-only"}, + orgGlobex: {"globex-only"}, + } require.NoError(t, f.run(cfg)) assert.Equal(t, orgGlobex, f.pinnedTo, "pinning must happen after the organization is picked") assert.Equal(t, 1, f.pinCalls) - // The project picker offers what the repinned lister returned. + require.Len(t, f.prompt.selects, 2) - assert.Contains(t, f.prompt.selects[1].options, projectAPI, "the project step must use the repinned connection") + assert.Contains(t, f.prompt.selects[1].options, "globex-only", "the project step must list the chosen organization") + assert.NotContains(t, f.prompt.selects[1].options, "acme-only", "it must not list the organization left behind") }) t.Run("a pinning failure stops before the project question", func(t *testing.T) { From 9245661dec76b7b57037b2b4919c126daecb1f3c Mon Sep 17 00:00:00 2001 From: Miguel Martinez Trivino Date: Tue, 8 Sep 2026 23:01:49 +0200 Subject: [PATCH 07/30] feat: report whether the caller can create a workflow in each project The project listing is filtered by what the caller can see, which is a wider set than what they can write to. Picking a project you only view sent init all the way to workflow creation before the server refused it, and the refusal names the project by UUID because the CLI drops its own wrapping for PermissionDenied. The picker was offering a dead end. ProjectService.List now returns can_create_workflow per project and the picker offers only those. The answer is resolved server-side because a caller can hold several roles on the same project, directly and through products, and any one of them granting the permission is enough; a single role field could not say that. Each distinct role is enforced once per listing rather than once per project. Filtering happens after paging, not inside it: counting what survived the filter rather than what the server sent would end the walk on a page of read-only projects and hide everything after it. Assisted-by: Claude Code Signed-off-by: Miguel Martinez Trivino Chainloop-Trace-Sessions: 184822bb-e811-42fd-8e13-735227edc5ce --- app/cli/pkg/action/trace_identity.go | 41 +++-- app/cli/pkg/action/trace_identity_test.go | 55 ++++++- .../api/controlplane/v1/project.pb.go | 27 +++- .../api/controlplane/v1/project.proto | 7 + .../gen/frontend/controlplane/v1/project.ts | 24 ++- ...ceListResponse.ProjectItem.jsonschema.json | 8 + ...erviceListResponse.ProjectItem.schema.json | 8 + app/controlplane/internal/service/project.go | 10 +- .../service/project_permissions_test.go | 149 ++++++++++++++++++ app/controlplane/internal/service/service.go | 56 +++++++ 10 files changed, 359 insertions(+), 26 deletions(-) create mode 100644 app/controlplane/internal/service/project_permissions_test.go diff --git a/app/cli/pkg/action/trace_identity.go b/app/cli/pkg/action/trace_identity.go index 3a5102815..1d071c19c 100644 --- a/app/cli/pkg/action/trace_identity.go +++ b/app/cli/pkg/action/trace_identity.go @@ -42,15 +42,25 @@ func (e *AttestationExecutor) ListProjects(ctx context.Context) ([]string, error return listAllTraceProjects(ctx, &cpTraceProjectAPI{cfg: e.actionOpts}) } +// traceProject is one entry of the project listing. +type traceProject struct { + name string + // canCreateWorkflow is false for a project the caller can see but not add a + // workflow to, such as one they only view. + canCreateWorkflow bool +} + // traceProjectAPI is the slice of the control-plane API the project listing // drives, so the paging can be tested without a live control plane. type traceProjectAPI interface { - // listProjectsPage returns one page of project names plus the total number - // of pages the server reports. - listProjectsPage(ctx context.Context, page, pageSize int32) ([]string, int32, error) + // listProjectsPage returns one page as the server sent it, plus the total + // number of pages it reports. + listProjectsPage(ctx context.Context, page, pageSize int32) ([]traceProject, int32, error) } -// listAllTraceProjects walks every page and returns the names it collected. +// listAllTraceProjects walks every page and returns the projects the caller can +// actually create a workflow in. Offering the rest would end in a permission +// error once init tried to create the workflow. func listAllTraceProjects(ctx context.Context, api traceProjectAPI) ([]string, error) { names := make([]string, 0, traceProjectPageSize) @@ -60,10 +70,16 @@ func listAllTraceProjects(ctx context.Context, api traceProjectAPI) ([]string, e return nil, fmt.Errorf("listing projects: %w", err) } - names = append(names, got...) + for _, p := range got { + if p.canCreateWorkflow { + names = append(names, p.name) + } + } - // Stop on the last page the server reports, and also on an empty one, so - // a server reporting more pages than it serves cannot spin here. + // Stop on the last page the server reports, and also on a page the server + // returned nothing for, so a server reporting more pages than it serves + // cannot spin here. This counts what arrived, not what survived the + // filter, so a page of read-only projects does not end the walk early. if len(got) == 0 || page >= totalPages { break } @@ -79,7 +95,7 @@ type cpTraceProjectAPI struct { cfg *ActionsOpts } -func (a *cpTraceProjectAPI) listProjectsPage(ctx context.Context, page, pageSize int32) ([]string, int32, error) { +func (a *cpTraceProjectAPI) listProjectsPage(ctx context.Context, page, pageSize int32) ([]traceProject, int32, error) { client := pb.NewProjectServiceClient(a.cfg.CPConnection) resp, err := client.List(ctx, &pb.ProjectServiceListRequest{ @@ -89,10 +105,13 @@ func (a *cpTraceProjectAPI) listProjectsPage(ctx context.Context, page, pageSize return nil, 0, err } - names := make([]string, 0, len(resp.GetProjects())) + projects := make([]traceProject, 0, len(resp.GetProjects())) for _, p := range resp.GetProjects() { - names = append(names, p.GetName()) + projects = append(projects, traceProject{ + name: p.GetName(), + canCreateWorkflow: p.GetCanCreateWorkflow(), + }) } - return names, resp.GetPagination().GetTotalPages(), nil + return projects, resp.GetPagination().GetTotalPages(), nil } diff --git a/app/cli/pkg/action/trace_identity_test.go b/app/cli/pkg/action/trace_identity_test.go index 26e2e2e84..c4f3269c2 100644 --- a/app/cli/pkg/action/trace_identity_test.go +++ b/app/cli/pkg/action/trace_identity_test.go @@ -24,9 +24,29 @@ import ( "github.com/stretchr/testify/require" ) +// writable builds a page of projects the caller can add a workflow to. +func writable(names ...string) []traceProject { + page := make([]traceProject, 0, len(names)) + for _, n := range names { + page = append(page, traceProject{name: n, canCreateWorkflow: true}) + } + + return page +} + +// readOnly builds a page of projects the caller can see but not write to. +func readOnly(names ...string) []traceProject { + page := make([]traceProject, 0, len(names)) + for _, n := range names { + page = append(page, traceProject{name: n}) + } + + return page +} + // fakeProjectPager serves canned pages, recording what it was asked for. type fakeProjectPager struct { - pages [][]string + pages [][]traceProject // totalPages is what the server reports; it defaults to len(pages) totalPages int32 err error @@ -34,7 +54,7 @@ type fakeProjectPager struct { requested []int32 } -func (f *fakeProjectPager) listProjectsPage(_ context.Context, page, _ int32) ([]string, int32, error) { +func (f *fakeProjectPager) listProjectsPage(_ context.Context, page, _ int32) ([]traceProject, int32, error) { f.requested = append(f.requested, page) if f.err != nil { return nil, 0, f.err @@ -63,7 +83,7 @@ const ( func TestListAllTraceProjects(t *testing.T) { testCases := []struct { name string - pages [][]string + pages [][]traceProject totalPages int32 want []string wantRequested []int32 @@ -76,30 +96,51 @@ func TestListAllTraceProjects(t *testing.T) { }, { name: "a single page is returned as is", - pages: [][]string{{projAlpha, projBeta}}, + pages: [][]traceProject{writable(projAlpha, projBeta)}, want: []string{projAlpha, projBeta}, wantRequested: []int32{1}, }, { name: "every page is fetched and concatenated", - pages: [][]string{{projAlpha, projBeta}, {projGamma}}, + pages: [][]traceProject{writable(projAlpha, projBeta), writable(projGamma)}, want: []string{projAlpha, projBeta, projGamma}, wantRequested: []int32{1, 2}, }, { name: "a project repeated across pages appears once", - pages: [][]string{{projAlpha, projBeta}, {projBeta, projGamma}}, + pages: [][]traceProject{writable(projAlpha, projBeta), writable(projBeta, projGamma)}, want: []string{projAlpha, projBeta, projGamma}, wantRequested: []int32{1, 2}, }, { name: "an empty page stops the walk even when the server claims more", // A server that reports more pages than it serves must not spin here. - pages: [][]string{{projAlpha}, {}}, + pages: [][]traceProject{writable(projAlpha), {}}, totalPages: 50, want: []string{projAlpha}, wantRequested: []int32{1, 2}, }, + { + name: "a project the caller cannot write to is not offered", + pages: [][]traceProject{append(writable(projAlpha), readOnly(projBeta)...)}, + want: []string{projAlpha}, + wantRequested: []int32{1}, + }, + { + // The walk must count what the server sent, not what survived the + // filter, or a page of read-only projects ends it early and hides + // everything after it. + name: "a page of read-only projects does not stop the walk", + pages: [][]traceProject{readOnly(projBeta), writable(projGamma)}, + want: []string{projGamma}, + wantRequested: []int32{1, 2}, + }, + { + name: "every project being read-only yields nothing", + pages: [][]traceProject{readOnly(projAlpha, projBeta)}, + want: []string{}, + wantRequested: []int32{1}, + }, } for _, tc := range testCases { diff --git a/app/controlplane/api/controlplane/v1/project.pb.go b/app/controlplane/api/controlplane/v1/project.pb.go index 8159fc802..6ce8aa385 100644 --- a/app/controlplane/api/controlplane/v1/project.pb.go +++ b/app/controlplane/api/controlplane/v1/project.pb.go @@ -965,9 +965,16 @@ type ProjectServiceListResponse_ProjectItem struct { // Timestamp when the project was created CreatedAt *timestamppb.Timestamp `protobuf:"bytes,4,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"` // Timestamp when the project was last updated - UpdatedAt *timestamppb.Timestamp `protobuf:"bytes,5,opt,name=updated_at,json=updatedAt,proto3" json:"updated_at,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + UpdatedAt *timestamppb.Timestamp `protobuf:"bytes,5,opt,name=updated_at,json=updatedAt,proto3" json:"updated_at,omitempty"` + // Whether the caller may create a workflow in this project. The listing is + // filtered by what the caller can see, which is not the same as what they + // can write to: a project viewer can read a project but not add a workflow + // to it. This is resolved server-side because a caller can hold several + // roles on the same project, directly and through products, and any one of + // them granting the permission is enough. + CanCreateWorkflow bool `protobuf:"varint,6,opt,name=can_create_workflow,json=canCreateWorkflow,proto3" json:"can_create_workflow,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *ProjectServiceListResponse_ProjectItem) Reset() { @@ -1035,6 +1042,13 @@ func (x *ProjectServiceListResponse_ProjectItem) GetUpdatedAt() *timestamppb.Tim return nil } +func (x *ProjectServiceListResponse_ProjectItem) GetCanCreateWorkflow() bool { + if x != nil { + return x.CanCreateWorkflow + } + return false +} + var File_controlplane_v1_project_proto protoreflect.FileDescriptor const file_controlplane_v1_project_proto_rawDesc = "" + @@ -1045,12 +1059,12 @@ const file_controlplane_v1_project_proto_rawDesc = "" + "\n" + "pagination\x18\x02 \x01(\v2(.controlplane.v1.OffsetPaginationRequestR\n" + "paginationB\a\n" + - "\x05_name\"\x88\x03\n" + + "\x05_name\"\xb8\x03\n" + "\x1aProjectServiceListResponse\x12S\n" + "\bprojects\x18\x01 \x03(\v27.controlplane.v1.ProjectServiceListResponse.ProjectItemR\bprojects\x12I\n" + "\n" + "pagination\x18\x02 \x01(\v2).controlplane.v1.OffsetPaginationResponseR\n" + - "pagination\x1a\xc9\x01\n" + + "pagination\x1a\xf9\x01\n" + "\vProjectItem\x12\x0e\n" + "\x02id\x18\x01 \x01(\tR\x02id\x12\x12\n" + "\x04name\x18\x02 \x01(\tR\x04name\x12 \n" + @@ -1058,7 +1072,8 @@ const file_controlplane_v1_project_proto_rawDesc = "" + "\n" + "created_at\x18\x04 \x01(\v2\x1a.google.protobuf.TimestampR\tcreatedAt\x129\n" + "\n" + - "updated_at\x18\x05 \x01(\v2\x1a.google.protobuf.TimestampR\tupdatedAt\"\xc5\x01\n" + + "updated_at\x18\x05 \x01(\v2\x1a.google.protobuf.TimestampR\tupdatedAt\x12.\n" + + "\x13can_create_workflow\x18\x06 \x01(\bR\x11canCreateWorkflow\"\xc5\x01\n" + " ProjectServiceListMembersRequest\x12W\n" + "\x11project_reference\x18\x01 \x01(\v2\".controlplane.v1.IdentityReferenceB\x06\xbaH\x03\xc8\x01\x01R\x10projectReference\x12H\n" + "\n" + diff --git a/app/controlplane/api/controlplane/v1/project.proto b/app/controlplane/api/controlplane/v1/project.proto index 97810a1b6..37946ac9d 100644 --- a/app/controlplane/api/controlplane/v1/project.proto +++ b/app/controlplane/api/controlplane/v1/project.proto @@ -64,6 +64,13 @@ message ProjectServiceListResponse { google.protobuf.Timestamp created_at = 4; // Timestamp when the project was last updated google.protobuf.Timestamp updated_at = 5; + // Whether the caller may create a workflow in this project. The listing is + // filtered by what the caller can see, which is not the same as what they + // can write to: a project viewer can read a project but not add a workflow + // to it. This is resolved server-side because a caller can hold several + // roles on the same project, directly and through products, and any one of + // them granting the permission is enough. + bool can_create_workflow = 6; } } diff --git a/app/controlplane/api/gen/frontend/controlplane/v1/project.ts b/app/controlplane/api/gen/frontend/controlplane/v1/project.ts index e2cc98673..baf5c9ee5 100644 --- a/app/controlplane/api/gen/frontend/controlplane/v1/project.ts +++ b/app/controlplane/api/gen/frontend/controlplane/v1/project.ts @@ -45,6 +45,15 @@ export interface ProjectServiceListResponse_ProjectItem { createdAt?: Date; /** Timestamp when the project was last updated */ updatedAt?: Date; + /** + * Whether the caller may create a workflow in this project. The listing is + * filtered by what the caller can see, which is not the same as what they + * can write to: a project viewer can read a project but not add a workflow + * to it. This is resolved server-side because a caller can hold several + * roles on the same project, directly and through products, and any one of + * them granting the permission is enough. + */ + canCreateWorkflow: boolean; } /** ProjectServiceListMembersRequest contains the information needed to list members of a project */ @@ -318,7 +327,7 @@ export const ProjectServiceListResponse = { }; function createBaseProjectServiceListResponse_ProjectItem(): ProjectServiceListResponse_ProjectItem { - return { id: "", name: "", description: "", createdAt: undefined, updatedAt: undefined }; + return { id: "", name: "", description: "", createdAt: undefined, updatedAt: undefined, canCreateWorkflow: false }; } export const ProjectServiceListResponse_ProjectItem = { @@ -338,6 +347,9 @@ export const ProjectServiceListResponse_ProjectItem = { if (message.updatedAt !== undefined) { Timestamp.encode(toTimestamp(message.updatedAt), writer.uint32(42).fork()).ldelim(); } + if (message.canCreateWorkflow === true) { + writer.uint32(48).bool(message.canCreateWorkflow); + } return writer; }, @@ -383,6 +395,13 @@ export const ProjectServiceListResponse_ProjectItem = { message.updatedAt = fromTimestamp(Timestamp.decode(reader, reader.uint32())); continue; + case 6: + if (tag !== 48) { + break; + } + + message.canCreateWorkflow = reader.bool(); + continue; } if ((tag & 7) === 4 || tag === 0) { break; @@ -399,6 +418,7 @@ export const ProjectServiceListResponse_ProjectItem = { description: isSet(object.description) ? String(object.description) : "", createdAt: isSet(object.createdAt) ? fromJsonTimestamp(object.createdAt) : undefined, updatedAt: isSet(object.updatedAt) ? fromJsonTimestamp(object.updatedAt) : undefined, + canCreateWorkflow: isSet(object.canCreateWorkflow) ? Boolean(object.canCreateWorkflow) : false, }; }, @@ -409,6 +429,7 @@ export const ProjectServiceListResponse_ProjectItem = { message.description !== undefined && (obj.description = message.description); message.createdAt !== undefined && (obj.createdAt = message.createdAt.toISOString()); message.updatedAt !== undefined && (obj.updatedAt = message.updatedAt.toISOString()); + message.canCreateWorkflow !== undefined && (obj.canCreateWorkflow = message.canCreateWorkflow); return obj; }, @@ -427,6 +448,7 @@ export const ProjectServiceListResponse_ProjectItem = { message.description = object.description ?? ""; message.createdAt = object.createdAt ?? undefined; message.updatedAt = object.updatedAt ?? undefined; + message.canCreateWorkflow = object.canCreateWorkflow ?? false; return message; }, }; diff --git a/app/controlplane/api/gen/jsonschema/controlplane.v1.ProjectServiceListResponse.ProjectItem.jsonschema.json b/app/controlplane/api/gen/jsonschema/controlplane.v1.ProjectServiceListResponse.ProjectItem.jsonschema.json index 27e470a1f..2034bc1d3 100644 --- a/app/controlplane/api/gen/jsonschema/controlplane.v1.ProjectServiceListResponse.ProjectItem.jsonschema.json +++ b/app/controlplane/api/gen/jsonschema/controlplane.v1.ProjectServiceListResponse.ProjectItem.jsonschema.json @@ -4,6 +4,10 @@ "additionalProperties": false, "description": "ProjectItem represents a project of the organization", "patternProperties": { + "^(can_create_workflow)$": { + "description": "Whether the caller may create a workflow in this project. The listing is\n filtered by what the caller can see, which is not the same as what they\n can write to: a project viewer can read a project but not add a workflow\n to it. This is resolved server-side because a caller can hold several\n roles on the same project, directly and through products, and any one of\n them granting the permission is enough.", + "type": "boolean" + }, "^(created_at)$": { "$ref": "google.protobuf.Timestamp.jsonschema.json", "description": "Timestamp when the project was created" @@ -14,6 +18,10 @@ } }, "properties": { + "canCreateWorkflow": { + "description": "Whether the caller may create a workflow in this project. The listing is\n filtered by what the caller can see, which is not the same as what they\n can write to: a project viewer can read a project but not add a workflow\n to it. This is resolved server-side because a caller can hold several\n roles on the same project, directly and through products, and any one of\n them granting the permission is enough.", + "type": "boolean" + }, "createdAt": { "$ref": "google.protobuf.Timestamp.jsonschema.json", "description": "Timestamp when the project was created" diff --git a/app/controlplane/api/gen/jsonschema/controlplane.v1.ProjectServiceListResponse.ProjectItem.schema.json b/app/controlplane/api/gen/jsonschema/controlplane.v1.ProjectServiceListResponse.ProjectItem.schema.json index 0e5226137..b02b051c5 100644 --- a/app/controlplane/api/gen/jsonschema/controlplane.v1.ProjectServiceListResponse.ProjectItem.schema.json +++ b/app/controlplane/api/gen/jsonschema/controlplane.v1.ProjectServiceListResponse.ProjectItem.schema.json @@ -4,6 +4,10 @@ "additionalProperties": false, "description": "ProjectItem represents a project of the organization", "patternProperties": { + "^(canCreateWorkflow)$": { + "description": "Whether the caller may create a workflow in this project. The listing is\n filtered by what the caller can see, which is not the same as what they\n can write to: a project viewer can read a project but not add a workflow\n to it. This is resolved server-side because a caller can hold several\n roles on the same project, directly and through products, and any one of\n them granting the permission is enough.", + "type": "boolean" + }, "^(createdAt)$": { "$ref": "google.protobuf.Timestamp.schema.json", "description": "Timestamp when the project was created" @@ -14,6 +18,10 @@ } }, "properties": { + "can_create_workflow": { + "description": "Whether the caller may create a workflow in this project. The listing is\n filtered by what the caller can see, which is not the same as what they\n can write to: a project viewer can read a project but not add a workflow\n to it. This is resolved server-side because a caller can hold several\n roles on the same project, directly and through products, and any one of\n them granting the permission is enough.", + "type": "boolean" + }, "created_at": { "$ref": "google.protobuf.Timestamp.schema.json", "description": "Timestamp when the project was created" diff --git a/app/controlplane/internal/service/project.go b/app/controlplane/internal/service/project.go index 9564fb1b9..eee57b6de 100644 --- a/app/controlplane/internal/service/project.go +++ b/app/controlplane/internal/service/project.go @@ -68,9 +68,17 @@ func (s *ProjectService) List(ctx context.Context, req *pb.ProjectServiceListReq return nil, handleUseCaseErr(err, s.log) } + // Resolve, once per listing, which projects the caller may add a workflow to. + writable, err := s.projectsAllowing(ctx, authz.PolicyWorkflowCreate, projects) + if err != nil { + return nil, handleUseCaseErr(err, s.log) + } + result := make([]*pb.ProjectServiceListResponse_ProjectItem, 0, len(projects)) for _, p := range projects { - result = append(result, bizProjectToPb(p)) + item := bizProjectToPb(p) + item.CanCreateWorkflow = writable[p.ID] + result = append(result, item) } return &pb.ProjectServiceListResponse{ diff --git a/app/controlplane/internal/service/project_permissions_test.go b/app/controlplane/internal/service/project_permissions_test.go new file mode 100644 index 000000000..643decfec --- /dev/null +++ b/app/controlplane/internal/service/project_permissions_test.go @@ -0,0 +1,149 @@ +// +// Copyright 2026 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package service + +import ( + "context" + "io" + "testing" + + "github.com/chainloop-dev/chainloop/app/controlplane/internal/usercontext" + "github.com/chainloop-dev/chainloop/app/controlplane/pkg/authz" + "github.com/chainloop-dev/chainloop/app/controlplane/pkg/biz" + "github.com/chainloop-dev/chainloop/app/controlplane/pkg/usercontext/entities" + + "github.com/go-kratos/kratos/v2/log" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// newTestService builds a service with a real casbin enforcer, so the +// permission answers come from the same rules the enforcement path uses. +func newTestService(t *testing.T) *service { + t.Helper() + + logger := log.NewStdLogger(io.Discard) + enforcer, err := authz.NewCasbinEnforcer(&authz.Config{RolesMap: authz.RolesMap}) + require.NoError(t, err) + + return &service{ + log: log.NewHelper(logger), + authz: biz.NewAuthzUseCase(&biz.AuthzUseCaseConfig{ + CasbinEnforcer: enforcer, + Logger: logger, + }), + } +} + +// projectMembership is one of the caller's roles on a project. +func projectMembership(id uuid.UUID, role authz.Role) *entities.ResourceMembership { + return &entities.ResourceMembership{ + ResourceType: authz.ResourceTypeProject, + ResourceID: id, + Role: role, + } +} + +// rbacContext puts the caller's memberships on the context under a role that +// turns RBAC on, which is what makes the per-project check apply. +func rbacContext(memberships ...*entities.ResourceMembership) context.Context { + ctx := usercontext.WithAuthzSubject(context.Background(), string(authz.RoleOrgMember)) + + return entities.WithMembership(ctx, &entities.Membership{Resources: memberships}) +} + +// TestProjectsAllowing covers which projects a listing may offer as a workflow +// target. Getting this wrong either hides projects the caller can use or, worse, +// offers ones that fail at creation time. +func TestProjectsAllowing(t *testing.T) { + admin, viewer, both := uuid.New(), uuid.New(), uuid.New() + + projects := []*biz.Project{ + {ID: admin, Name: "admin-project"}, + {ID: viewer, Name: "viewer-project"}, + {ID: both, Name: "both-project"}, + } + + testCases := []struct { + name string + ctx context.Context + wantAllowed map[uuid.UUID]bool + }{ + { + name: "a project admin may create a workflow, a viewer may not", + ctx: rbacContext( + projectMembership(admin, authz.RoleProjectAdmin), + projectMembership(viewer, authz.RoleProjectViewer), + ), + wantAllowed: map[uuid.UUID]bool{admin: true, viewer: false, both: false}, + }, + { + // A caller can hold several roles on one project, directly and + // through a product. Any one of them granting the permission is + // enough, which is how the enforcement path behaves. + name: "holding both roles on a project allows it, whatever the order", + ctx: rbacContext( + projectMembership(both, authz.RoleProjectViewer), + projectMembership(both, authz.RoleProjectAdmin), + ), + wantAllowed: map[uuid.UUID]bool{admin: false, viewer: false, both: true}, + }, + { + name: "the weaker role listed last does not revoke the stronger one", + ctx: rbacContext( + projectMembership(both, authz.RoleProjectAdmin), + projectMembership(both, authz.RoleProjectViewer), + ), + wantAllowed: map[uuid.UUID]bool{admin: false, viewer: false, both: true}, + }, + { + name: "no project memberships allows nothing", + ctx: rbacContext(), + wantAllowed: map[uuid.UUID]bool{admin: false, viewer: false, both: false}, + }, + } + + s := newTestService(t) + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + got, err := s.projectsAllowing(tc.ctx, authz.PolicyWorkflowCreate, projects) + require.NoError(t, err) + + for id, want := range tc.wantAllowed { + assert.Equal(t, want, got[id], "project %s", id) + } + }) + } +} + +// TestProjectsAllowingWithoutRBAC pins down that an organization role carrying +// the permission is not narrowed by the per-project check. +func TestProjectsAllowingWithoutRBAC(t *testing.T) { + s := newTestService(t) + one, two := uuid.New(), uuid.New() + projects := []*biz.Project{{ID: one}, {ID: two}} + + // RoleAdmin is an organization role, so RBAC does not apply to it. + ctx := usercontext.WithAuthzSubject(context.Background(), string(authz.RoleAdmin)) + require.False(t, authz.RoleAdmin.RBACEnabled(), "precondition: this role bypasses RBAC") + + got, err := s.projectsAllowing(ctx, authz.PolicyWorkflowCreate, projects) + require.NoError(t, err) + assert.True(t, got[one]) + assert.True(t, got[two]) +} diff --git a/app/controlplane/internal/service/service.go b/app/controlplane/internal/service/service.go index 9e72f1319..819768e61 100644 --- a/app/controlplane/internal/service/service.go +++ b/app/controlplane/internal/service/service.go @@ -245,6 +245,62 @@ func (s *service) authorizeResource(ctx context.Context, op *authz.Policy, resou return errors.Forbidden("forbidden", defaultMessage) } +// projectsAllowing reports, per project ID, whether the caller may perform op +// on it. It answers for a whole listing in one pass, so a client does not have +// to offer an action that would be refused the moment it is taken. +// +// It mirrors authorizeResource: a caller can hold several roles on the same +// project, directly and through products, and any one of them granting the +// permission is enough. Each distinct role is enforced once, not once per +// project. +func (s *service) projectsAllowing(ctx context.Context, op *authz.Policy, projects []*biz.Project) (map[uuid.UUID]bool, error) { + allowed := make(map[uuid.UUID]bool, len(projects)) + + // Without RBAC the caller's organization role already carries the + // permission, so every visible project is fair game. + if !rbacEnabled(ctx) { + for _, p := range projects { + allowed[p.ID] = true + } + + return allowed, nil + } + + // An API token is scoped to a single project, and reaching here means the + // API-level check already accepted the operation for it. + if token := entities.CurrentAPIToken(ctx); token != nil { + for _, p := range projects { + allowed[p.ID] = token.ProjectID != nil && *token.ProjectID == p.ID + } + + return allowed, nil + } + + roleGrants := make(map[authz.Role]bool) + m := entities.CurrentMembership(ctx) + for _, rm := range m.Resources { + if rm.ResourceType != authz.ResourceTypeProject { + continue + } + + grants, seen := roleGrants[rm.Role] + if !seen { + var err error + if grants, err = s.authz.Enforce(ctx, string(rm.Role), op); err != nil { + return nil, err + } + + roleGrants[rm.Role] = grants + } + + if grants { + allowed[rm.ResourceID] = true + } + } + + return allowed, nil +} + // userHasPermissionOnProject is a helper method that checks if a policy can be applied to a project. It looks for a project // by name in the given organization and ensures that the user has a role that allows that specific operation in the project. // check authorizeResource method From f8b72f5a46758bac3a42dbf65bc23171c974e272 Mon Sep 17 00:00:00 2001 From: Miguel Martinez Trivino Date: Tue, 8 Sep 2026 23:38:31 +0200 Subject: [PATCH 08/30] fix: report write access per organization role and keep read-only projects out of the picker The workflow-create capability reported by the project listing ignored the caller's organization role, so an organization viewer, which may list projects but not create workflows, saw every project as writable. It now mirrors both checks the create call faces: the API-level one against the organization role, administrators skipping it as the middleware does, and then the per-project one. The trace init picker filtered read-only projects out of the listing, which made a project the caller can only view indistinguishable from one that does not exist. A project pinned in .chainloop.yml is offered when it is missing from the listing, so a read-only one was offered anyway and preselected. The listing now carries the capability up to the caller, which keeps the two cases apart, and a read-only pinned project is neither offered nor used to seed a new name. Dismissing a prompt also ended trace init with "ERROR aborted" and exit 1 instead of the clean stop it is meant to be. Assisted-by: Claude Code Signed-off-by: Miguel Martinez Trivino Chainloop-Trace-Sessions: 184822bb-e811-42fd-8e13-735227edc5ce --- app/cli/cmd/trace_init.go | 4 +- app/cli/cmd/trace_init_identity.go | 71 +++++++++-- app/cli/cmd/trace_init_identity_test.go | 115 +++++++++++++++--- app/cli/cmd/trace_init_prompt.go | 3 +- app/cli/cmd/trace_init_prompt_test.go | 8 +- app/cli/cmd/trace_init_providers.go | 19 ++- app/cli/cmd/trace_init_providers_test.go | 9 +- app/cli/pkg/action/trace_identity.go | 63 +++++----- app/cli/pkg/action/trace_identity_test.go | 62 +++++----- .../service/project_permissions_test.go | 42 +++++-- app/controlplane/internal/service/service.go | 45 +++++-- 11 files changed, 312 insertions(+), 129 deletions(-) diff --git a/app/cli/cmd/trace_init.go b/app/cli/cmd/trace_init.go index 154310163..40c028d1c 100644 --- a/app/cli/cmd/trace_init.go +++ b/app/cli/cmd/trace_init.go @@ -79,7 +79,7 @@ The organization, project, workflow and require-trace values are saved to // back is pinned to the resolved organization and reused below. executor, err := resolveTraceIdentity(cmd.Context(), cfg, repoRoot) if err != nil { - return err + return stopIfAborted(err) } defer func() { _ = executor.Close() }() @@ -90,7 +90,7 @@ The organization, project, workflow and require-trace values are saved to traceProviderFlags{claude: claudeFlag, cursor: cursorFlag, opencode: opencodeFlag}, traceInitCanPrompt()) if err != nil { - return err + return stopIfAborted(err) } selectedProviders := providers.ByNames(selected) diff --git a/app/cli/cmd/trace_init_identity.go b/app/cli/cmd/trace_init_identity.go index bb2cab8f3..db427c1c2 100644 --- a/app/cli/cmd/trace_init_identity.go +++ b/app/cli/cmd/trace_init_identity.go @@ -43,6 +43,20 @@ const ( // untouched. var errAborted = errors.New("aborted") +// stopIfAborted turns a dismissed prompt into a clean stop and passes every +// other error through. Dismissing a question is a decision, not a failure, and +// nothing has been created or written by the time one can be dismissed, so +// reporting it as an error would be both noisy and wrong. +func stopIfAborted(err error) error { + if !errors.Is(err, errAborted) { + return err + } + + logger.Info().Msg("aborted, nothing was changed") + + return nil +} + // ciSignals are environment variables that mean the CLI is running inside a CI // system, where a prompt would hang the build. CI covers almost every provider; // the rest are for the ones that do not set it. @@ -69,7 +83,7 @@ type orgLister interface { // projectLister lists the projects visible in the selected organization, and // is likewise the slice of the executor the project resolver needs. type projectLister interface { - ListProjects(ctx context.Context) ([]string, error) + ListProjects(ctx context.Context) ([]*action.TraceProject, error) } // prompter asks the user to pick from a list or type a value. It keeps the @@ -187,21 +201,44 @@ func resolveInteractiveProject(ctx context.Context, lister projectLister, p prom return nil, fmt.Errorf("listing the projects you can see: %w", err) } - // Nothing to choose from, so go straight to naming one. - if len(visible) == 0 { - return promptNewProject(p, cmp.Or(fromYML, config.SlugifyDNS1123(repoDir)), fromYML) + // Only the projects the caller can add a workflow to are worth offering; + // picking any other one would fail at creation time. + writable := make([]string, 0, len(visible)) + // pinnedReadOnly means .chainloop.yml points at a project the caller can see + // but not write to, which is why it is missing from the offer. + var pinnedReadOnly bool + + for _, project := range visible { + switch { + case project.CanCreateWorkflow: + writable = append(writable, project.Name) + case project.Name == fromYML: + pinnedReadOnly = true + } + } + + if pinnedReadOnly { + logger.Warn().Str("project", fromYML). + Msg("you cannot create a workflow in the project this repository points at, pick another one") + } + + // Nothing to choose from, so go straight to naming one rather than showing a + // list holding only the pinned project. + if len(writable) == 0 { + return promptNewProject(p, seedProjectName(fromYML, pinnedReadOnly, repoDir), fromYML) } - // A project pinned in .chainloop.yml is offered even when the listing does - // not carry it: the user may only be able to see it through this repository, - // or it may not exist yet. Either way it is still this repository's project. - if fromYML != "" && !slices.Contains(visible, fromYML) { - visible = append(visible, fromYML) + // A project pinned in .chainloop.yml the listing did not carry at all is + // still offered: the user may only be able to see it through this + // repository, or it may not exist yet. One the listing did carry and marked + // read-only is a different case, and stays out. + if fromYML != "" && !pinnedReadOnly && !slices.Contains(writable, fromYML) { + writable = append(writable, fromYML) } - options := append([]string{createNewProjectOption}, visible...) + options := append([]string{createNewProjectOption}, writable...) - chosen, err := p.Select(projectPromptTitle, options, cmp.Or(fromYML, createNewProjectOption)) + chosen, err := p.Select(projectPromptTitle, options, firstPresent(options, fromYML)) if err != nil { return nil, err } @@ -215,6 +252,18 @@ func resolveInteractiveProject(ctx context.Context, lister projectLister, p prom return promptNewProject(p, config.SlugifyDNS1123(repoDir), fromYML) } +// seedProjectName is what the new-project prompt starts from. The project +// .chainloop.yml pins is the best seed, unless it is one the caller cannot write +// to, in which case proposing it again would just repeat the same failure. +func seedProjectName(fromYML string, pinnedReadOnly bool, repoDir string) string { + fromRepo := config.SlugifyDNS1123(repoDir) + if pinnedReadOnly { + return fromRepo + } + + return cmp.Or(fromYML, fromRepo) +} + // promptNewProject collects a free-form project name and returns it normalized // to the DNS-1123 label the control plane stores. A name that normalizes to one // of the existing projects simply resolves to that project. diff --git a/app/cli/cmd/trace_init_identity_test.go b/app/cli/cmd/trace_init_identity_test.go index c4fb08062..046a00445 100644 --- a/app/cli/cmd/trace_init_identity_test.go +++ b/app/cli/cmd/trace_init_identity_test.go @@ -18,6 +18,7 @@ package cmd import ( "context" "errors" + "fmt" "testing" "github.com/chainloop-dev/chainloop/app/cli/pkg/action" @@ -37,13 +38,29 @@ func (f *fakeOrgLister) ListOrganizations(context.Context) ([]*action.Membership return f.orgs, f.err } +// fakeProjectLister serves a canned listing. projects are the ones the caller +// may create a workflow in, readOnly the ones it can only see. type fakeProjectLister struct { projects []string + readOnly []string err error } -func (f *fakeProjectLister) ListProjects(context.Context) ([]string, error) { - return f.projects, f.err +func (f *fakeProjectLister) ListProjects(context.Context) ([]*action.TraceProject, error) { + if f.err != nil { + return nil, f.err + } + + out := make([]*action.TraceProject, 0, len(f.projects)+len(f.readOnly)) + for _, name := range f.projects { + out = append(out, &action.TraceProject{Name: name, CanCreateWorkflow: true}) + } + + for _, name := range f.readOnly { + out = append(out, &action.TraceProject{Name: name}) + } + + return out, nil } // recordedPrompt captures what a prompt was shown, so tests can assert on the @@ -327,6 +344,8 @@ func TestResolveInteractiveProject(t *testing.T) { projects []string fromYML string repoDir string + // readOnly are projects the caller can see but not create a workflow in + readOnly []string // selectAnswer is what the user picks in the list; empty means the list // is not expected to be shown selectAnswer string @@ -432,13 +451,51 @@ func TestResolveInteractiveProject(t *testing.T) { inputAnswer: "!!!", wantErr: "cannot be empty", }, + { + // Offering it would fail at creation time, which is the dead end + // this whole prompt exists to remove. + name: "a project the caller can only view is not offered", + projects: []string{projectAPI}, + readOnly: []string{"locked"}, + repoDir: repoDirMyRepo, + selectAnswer: projectAPI, + wantValue: projectAPI, + wantSave: true, + wantOptions: []string{createNewProjectOption, projectAPI}, + wantSelectDefault: createNewProjectOption, + }, + { + // A pinned project missing from the listing is offered anyway, but + // one the listing returned as read-only must not be: it is a + // different case with the same symptom. + name: "a pinned project the caller can only view is neither offered nor preselected", + projects: []string{projectAPI}, + readOnly: []string{"payments"}, + fromYML: "payments", + repoDir: repoDirMyRepo, + selectAnswer: projectAPI, + wantValue: projectAPI, + wantSave: true, + wantOptions: []string{createNewProjectOption, projectAPI}, + wantSelectDefault: createNewProjectOption, + }, + { + name: "a pinned read-only project does not seed the new name", + readOnly: []string{"payments"}, + fromYML: "payments", + repoDir: repoDirMyRepo, + inputAnswer: "my-repo", + wantValue: "my-repo", + wantSave: true, + wantInputDefault: "my-repo", + }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { p := &fakePrompter{selectAnswer: tc.selectAnswer, inputAnswer: tc.inputAnswer} got, err := resolveInteractiveProject(context.Background(), - &fakeProjectLister{projects: tc.projects}, p, tc.fromYML, tc.repoDir) + &fakeProjectLister{projects: tc.projects, readOnly: tc.readOnly}, p, tc.fromYML, tc.repoDir) if tc.wantErr != "" { require.Error(t, err) @@ -468,18 +525,6 @@ func TestResolveInteractiveProject(t *testing.T) { } } -// TestResolveTraceIdentityNonInteractive covers the path a CI run takes. Test -// binaries have no terminal attached, so isInteractive is false here and the -// step never reaches the control plane. -func TestResolveTraceIdentityNonInteractive(t *testing.T) { - t.Run("a missing project is reported, naming both ways to supply one", func(t *testing.T) { - _, err := resolveTraceIdentity(context.Background(), &traceInitConfig{}, t.TempDir()) - require.Error(t, err) - assert.Contains(t, err.Error(), "--project is required in non-interactive mode") - assert.Contains(t, err.Error(), "projectName to .chainloop.yml") - }) -} - func TestResolveInteractiveProjectErrors(t *testing.T) { t.Run("a listing failure is returned, not swallowed", func(t *testing.T) { _, err := resolveInteractiveProject(context.Background(), @@ -500,3 +545,43 @@ func TestResolveInteractiveProjectErrors(t *testing.T) { require.ErrorIs(t, err, errAborted) }) } + +// --- non-interactive --------------------------------------------------------- + +// TestResolveTraceIdentityNonInteractive covers the path a CI run takes. Test +// binaries have no terminal attached, so isInteractive is false here and the +// step never reaches the control plane. +func TestResolveTraceIdentityNonInteractive(t *testing.T) { + t.Run("a missing project is reported, naming both ways to supply one", func(t *testing.T) { + _, err := resolveTraceIdentity(context.Background(), &traceInitConfig{}, t.TempDir()) + require.Error(t, err) + assert.Contains(t, err.Error(), "--project is required in non-interactive mode") + assert.Contains(t, err.Error(), "projectName to .chainloop.yml") + }) +} + +// --- aborting ---------------------------------------------------------------- + +// TestStopIfAborted pins down that dismissing a prompt ends the command +// quietly. Returning the error instead would exit 1 and print "ERROR aborted", +// which reads as a failure when the user simply changed their mind. +func TestStopIfAborted(t *testing.T) { + other := errors.New("boom") + + testCases := []struct { + name string + err error + want error + }{ + {name: "a dismissed prompt is not an error", err: errAborted, want: nil}, + {name: "a wrapped dismissal is recognized", err: fmt.Errorf("asking: %w", errAborted), want: nil}, + {name: "any other error is passed through", err: other, want: other}, + {name: "no error stays no error", err: nil, want: nil}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.want, stopIfAborted(tc.err)) + }) + } +} diff --git a/app/cli/cmd/trace_init_prompt.go b/app/cli/cmd/trace_init_prompt.go index 731a6b645..d9fd04c50 100644 --- a/app/cli/cmd/trace_init_prompt.go +++ b/app/cli/cmd/trace_init_prompt.go @@ -20,6 +20,7 @@ import ( "fmt" "io" "os" + "slices" "github.com/chainloop-dev/chainloop/app/cli/internal/trace/config" @@ -80,7 +81,7 @@ func (h *huhPrompter) Select(title string, options []string, defaultValue string // already ticked. An empty submission is refused at the prompt, so the caller // never has to send the user back through init to fix it. func (h *huhPrompter) MultiSelect(title string, options, defaults []string) ([]string, error) { - value := append([]string(nil), defaults...) + value := slices.Clone(defaults) field := huh.NewMultiSelect[string](). Title(title). diff --git a/app/cli/cmd/trace_init_prompt_test.go b/app/cli/cmd/trace_init_prompt_test.go index 65d81868a..a53557f73 100644 --- a/app/cli/cmd/trace_init_prompt_test.go +++ b/app/cli/cmd/trace_init_prompt_test.go @@ -57,10 +57,12 @@ func TestHuhPrompterMultiSelect(t *testing.T) { // this ticks the second option on top of the preselected first one. p, out := accessiblePrompter("2\n0\n") - got, err := p.MultiSelect("Pick agents", []string{"claude-code", "cursor", "opencode"}, []string{"claude-code"}) + // The prompter is generic, so the options here are deliberately not provider + // names: nothing about it should depend on what is being picked. + got, err := p.MultiSelect("Pick options", []string{"first", "second", "third"}, []string{"first"}) require.NoError(t, err) - assert.Equal(t, []string{"claude-code", "cursor"}, got) - assert.Contains(t, out.String(), "Pick agents") + assert.Equal(t, []string{"first", "second"}, got) + assert.Contains(t, out.String(), "Pick options") } func TestNewHuhPrompterAccessibility(t *testing.T) { diff --git a/app/cli/cmd/trace_init_providers.go b/app/cli/cmd/trace_init_providers.go index 8090775d2..335f910c3 100644 --- a/app/cli/cmd/trace_init_providers.go +++ b/app/cli/cmd/trace_init_providers.go @@ -41,17 +41,14 @@ func (f traceProviderFlags) any() bool { // than the order the flags happen to appear in. func (f traceProviderFlags) names() []string { out := make([]string, 0, 3) - for _, p := range []struct { - set bool - name string - }{ - {f.claude, claude.Name}, - {f.cursor, cursor.Name}, - {f.opencode, opencode.Name}, - } { - if p.set { - out = append(out, p.name) - } + if f.claude { + out = append(out, claude.Name) + } + if f.cursor { + out = append(out, cursor.Name) + } + if f.opencode { + out = append(out, opencode.Name) } return out diff --git a/app/cli/cmd/trace_init_providers_test.go b/app/cli/cmd/trace_init_providers_test.go index 3bc3e2540..c9972d3ce 100644 --- a/app/cli/cmd/trace_init_providers_test.go +++ b/app/cli/cmd/trace_init_providers_test.go @@ -24,6 +24,11 @@ import ( "github.com/stretchr/testify/require" ) +// providerClaudeCode is the wire name of the default provider, spelled out +// rather than taken from the package constant so that renaming it shows up here +// as a failing test instead of passing silently. +const providerClaudeCode = "claude-code" + func TestResolveTraceProviders(t *testing.T) { testCases := []struct { name string @@ -47,7 +52,7 @@ func TestResolveTraceProviders(t *testing.T) { name: "several flags are all honored", flags: traceProviderFlags{claude: true, opencode: true}, interactive: true, - want: []string{"claude-code", "opencode"}, + want: []string{providerClaudeCode, "opencode"}, }, { name: "without a terminal the default provider is kept", @@ -111,7 +116,7 @@ func TestTraceProviderFlags(t *testing.T) { t.Run("names follow the registry order, not the flag order", func(t *testing.T) { f := traceProviderFlags{opencode: true, claude: true} assert.True(t, f.any()) - assert.Equal(t, []string{"claude-code", "opencode"}, f.names()) + assert.Equal(t, []string{providerClaudeCode, "opencode"}, f.names()) }) } diff --git a/app/cli/pkg/action/trace_identity.go b/app/cli/pkg/action/trace_identity.go index 1d071c19c..5e3097274 100644 --- a/app/cli/pkg/action/trace_identity.go +++ b/app/cli/pkg/action/trace_identity.go @@ -16,6 +16,7 @@ package action import ( + "cmp" "context" "fmt" "slices" @@ -34,20 +35,21 @@ func (e *AttestationExecutor) ListOrganizations(ctx context.Context) ([]*Members return NewMembershipList(e.actionOpts).ListOrgs(ctx) } -// ListProjects returns the names of the projects visible in the organization -// the executor is pinned to, sorted and deduplicated. It runs over the -// executor's connection, so it targets the organization pinned with -// WithForcedOrganization like every other trace operation. -func (e *AttestationExecutor) ListProjects(ctx context.Context) ([]string, error) { +// ListProjects returns the projects visible in the organization the executor is +// pinned to, sorted by name and deduplicated. It runs over the executor's +// connection, so it targets the organization pinned with WithForcedOrganization +// like every other trace operation. +func (e *AttestationExecutor) ListProjects(ctx context.Context) ([]*TraceProject, error) { return listAllTraceProjects(ctx, &cpTraceProjectAPI{cfg: e.actionOpts}) } -// traceProject is one entry of the project listing. -type traceProject struct { - name string - // canCreateWorkflow is false for a project the caller can see but not add a - // workflow to, such as one they only view. - canCreateWorkflow bool +// TraceProject is one entry of the project listing. +type TraceProject struct { + Name string + // CanCreateWorkflow is false for a project the caller can see but not add a + // workflow to, such as one they only view. Offering one of those would end + // in a permission error the moment init tried to create the workflow. + CanCreateWorkflow bool } // traceProjectAPI is the slice of the control-plane API the project listing @@ -55,14 +57,14 @@ type traceProject struct { type traceProjectAPI interface { // listProjectsPage returns one page as the server sent it, plus the total // number of pages it reports. - listProjectsPage(ctx context.Context, page, pageSize int32) ([]traceProject, int32, error) + listProjectsPage(ctx context.Context, page, pageSize int32) ([]*TraceProject, int32, error) } -// listAllTraceProjects walks every page and returns the projects the caller can -// actually create a workflow in. Offering the rest would end in a permission -// error once init tried to create the workflow. -func listAllTraceProjects(ctx context.Context, api traceProjectAPI) ([]string, error) { - names := make([]string, 0, traceProjectPageSize) +// listAllTraceProjects walks every page and returns what the caller can see. +// Deciding which of those to offer is left to the caller, which needs to tell a +// project it may not write to apart from one that is missing entirely. +func listAllTraceProjects(ctx context.Context, api traceProjectAPI) ([]*TraceProject, error) { + projects := make([]*TraceProject, 0, traceProjectPageSize) for page := int32(1); ; page++ { got, totalPages, err := api.listProjectsPage(ctx, page, traceProjectPageSize) @@ -70,24 +72,23 @@ func listAllTraceProjects(ctx context.Context, api traceProjectAPI) ([]string, e return nil, fmt.Errorf("listing projects: %w", err) } - for _, p := range got { - if p.canCreateWorkflow { - names = append(names, p.name) - } - } + projects = append(projects, got...) // Stop on the last page the server reports, and also on a page the server // returned nothing for, so a server reporting more pages than it serves - // cannot spin here. This counts what arrived, not what survived the - // filter, so a page of read-only projects does not end the walk early. + // cannot spin here. if len(got) == 0 || page >= totalPages { break } } - slices.Sort(names) + slices.SortFunc(projects, func(a, b *TraceProject) int { + return cmp.Compare(a.Name, b.Name) + }) - return slices.Compact(names), nil + return slices.CompactFunc(projects, func(a, b *TraceProject) bool { + return a.Name == b.Name + }), nil } // cpTraceProjectAPI implements traceProjectAPI against the control plane. @@ -95,7 +96,7 @@ type cpTraceProjectAPI struct { cfg *ActionsOpts } -func (a *cpTraceProjectAPI) listProjectsPage(ctx context.Context, page, pageSize int32) ([]traceProject, int32, error) { +func (a *cpTraceProjectAPI) listProjectsPage(ctx context.Context, page, pageSize int32) ([]*TraceProject, int32, error) { client := pb.NewProjectServiceClient(a.cfg.CPConnection) resp, err := client.List(ctx, &pb.ProjectServiceListRequest{ @@ -105,11 +106,11 @@ func (a *cpTraceProjectAPI) listProjectsPage(ctx context.Context, page, pageSize return nil, 0, err } - projects := make([]traceProject, 0, len(resp.GetProjects())) + projects := make([]*TraceProject, 0, len(resp.GetProjects())) for _, p := range resp.GetProjects() { - projects = append(projects, traceProject{ - name: p.GetName(), - canCreateWorkflow: p.GetCanCreateWorkflow(), + projects = append(projects, &TraceProject{ + Name: p.GetName(), + CanCreateWorkflow: p.GetCanCreateWorkflow(), }) } diff --git a/app/cli/pkg/action/trace_identity_test.go b/app/cli/pkg/action/trace_identity_test.go index c4f3269c2..582a7047e 100644 --- a/app/cli/pkg/action/trace_identity_test.go +++ b/app/cli/pkg/action/trace_identity_test.go @@ -25,20 +25,20 @@ import ( ) // writable builds a page of projects the caller can add a workflow to. -func writable(names ...string) []traceProject { - page := make([]traceProject, 0, len(names)) +func writable(names ...string) []*TraceProject { + page := make([]*TraceProject, 0, len(names)) for _, n := range names { - page = append(page, traceProject{name: n, canCreateWorkflow: true}) + page = append(page, &TraceProject{Name: n, CanCreateWorkflow: true}) } return page } // readOnly builds a page of projects the caller can see but not write to. -func readOnly(names ...string) []traceProject { - page := make([]traceProject, 0, len(names)) +func readOnly(names ...string) []*TraceProject { + page := make([]*TraceProject, 0, len(names)) for _, n := range names { - page = append(page, traceProject{name: n}) + page = append(page, &TraceProject{Name: n}) } return page @@ -46,7 +46,7 @@ func readOnly(names ...string) []traceProject { // fakeProjectPager serves canned pages, recording what it was asked for. type fakeProjectPager struct { - pages [][]traceProject + pages [][]*TraceProject // totalPages is what the server reports; it defaults to len(pages) totalPages int32 err error @@ -54,7 +54,7 @@ type fakeProjectPager struct { requested []int32 } -func (f *fakeProjectPager) listProjectsPage(_ context.Context, page, _ int32) ([]traceProject, int32, error) { +func (f *fakeProjectPager) listProjectsPage(_ context.Context, page, _ int32) ([]*TraceProject, int32, error) { f.requested = append(f.requested, page) if f.err != nil { return nil, 0, f.err @@ -83,63 +83,63 @@ const ( func TestListAllTraceProjects(t *testing.T) { testCases := []struct { name string - pages [][]traceProject + pages [][]*TraceProject totalPages int32 - want []string + want []*TraceProject wantRequested []int32 }{ { name: "no projects", pages: nil, - want: []string{}, + want: []*TraceProject{}, wantRequested: []int32{1}, }, { name: "a single page is returned as is", - pages: [][]traceProject{writable(projAlpha, projBeta)}, - want: []string{projAlpha, projBeta}, + pages: [][]*TraceProject{writable(projAlpha, projBeta)}, + want: writable(projAlpha, projBeta), wantRequested: []int32{1}, }, { name: "every page is fetched and concatenated", - pages: [][]traceProject{writable(projAlpha, projBeta), writable(projGamma)}, - want: []string{projAlpha, projBeta, projGamma}, + pages: [][]*TraceProject{writable(projAlpha, projBeta), writable(projGamma)}, + want: writable(projAlpha, projBeta, projGamma), wantRequested: []int32{1, 2}, }, { name: "a project repeated across pages appears once", - pages: [][]traceProject{writable(projAlpha, projBeta), writable(projBeta, projGamma)}, - want: []string{projAlpha, projBeta, projGamma}, + pages: [][]*TraceProject{writable(projAlpha, projBeta), writable(projBeta, projGamma)}, + want: writable(projAlpha, projBeta, projGamma), wantRequested: []int32{1, 2}, }, { name: "an empty page stops the walk even when the server claims more", // A server that reports more pages than it serves must not spin here. - pages: [][]traceProject{writable(projAlpha), {}}, + pages: [][]*TraceProject{writable(projAlpha), {}}, totalPages: 50, - want: []string{projAlpha}, + want: writable(projAlpha), wantRequested: []int32{1, 2}, }, { - name: "a project the caller cannot write to is not offered", - pages: [][]traceProject{append(writable(projAlpha), readOnly(projBeta)...)}, - want: []string{projAlpha}, + // A project the caller cannot write to is reported, not dropped. The + // caller needs it to tell a read-only project apart from a missing + // one, which are offered differently. + name: "a project the caller cannot write to is still reported", + pages: [][]*TraceProject{append(writable(projAlpha), readOnly(projBeta)...)}, + want: append(writable(projAlpha), readOnly(projBeta)...), wantRequested: []int32{1}, }, { - // The walk must count what the server sent, not what survived the - // filter, or a page of read-only projects ends it early and hides - // everything after it. name: "a page of read-only projects does not stop the walk", - pages: [][]traceProject{readOnly(projBeta), writable(projGamma)}, - want: []string{projGamma}, + pages: [][]*TraceProject{readOnly(projBeta), writable(projGamma)}, + want: append(readOnly(projBeta), writable(projGamma)...), wantRequested: []int32{1, 2}, }, { - name: "every project being read-only yields nothing", - pages: [][]traceProject{readOnly(projAlpha, projBeta)}, - want: []string{}, - wantRequested: []int32{1}, + name: "projects are sorted by name across pages", + pages: [][]*TraceProject{writable(projGamma), writable(projAlpha)}, + want: writable(projAlpha, projGamma), + wantRequested: []int32{1, 2}, }, } diff --git a/app/controlplane/internal/service/project_permissions_test.go b/app/controlplane/internal/service/project_permissions_test.go index 643decfec..eaed74d84 100644 --- a/app/controlplane/internal/service/project_permissions_test.go +++ b/app/controlplane/internal/service/project_permissions_test.go @@ -131,19 +131,43 @@ func TestProjectsAllowing(t *testing.T) { } } -// TestProjectsAllowingWithoutRBAC pins down that an organization role carrying -// the permission is not narrowed by the per-project check. +// TestProjectsAllowingWithoutRBAC covers the organization roles RBAC does not +// narrow. Whether they may create a workflow is decided by the role alone, so +// it is the same answer for every project: all of them, or none. func TestProjectsAllowingWithoutRBAC(t *testing.T) { + testCases := []struct { + name string + role authz.Role + want bool + }{ + { + name: "an organization admin may create a workflow anywhere", + role: authz.RoleAdmin, + want: true, + }, + { + // An organization viewer may list projects, so it reaches here, but + // its role does not carry the permission. Reporting these as + // writable would offer a workflow the create call then refuses. + name: "an organization viewer may create a workflow nowhere", + role: authz.RoleViewer, + want: false, + }, + } + s := newTestService(t) one, two := uuid.New(), uuid.New() projects := []*biz.Project{{ID: one}, {ID: two}} - // RoleAdmin is an organization role, so RBAC does not apply to it. - ctx := usercontext.WithAuthzSubject(context.Background(), string(authz.RoleAdmin)) - require.False(t, authz.RoleAdmin.RBACEnabled(), "precondition: this role bypasses RBAC") + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + require.False(t, tc.role.RBACEnabled(), "precondition: this role bypasses RBAC") + ctx := usercontext.WithAuthzSubject(context.Background(), string(tc.role)) - got, err := s.projectsAllowing(ctx, authz.PolicyWorkflowCreate, projects) - require.NoError(t, err) - assert.True(t, got[one]) - assert.True(t, got[two]) + got, err := s.projectsAllowing(ctx, authz.PolicyWorkflowCreate, projects) + require.NoError(t, err) + assert.Equal(t, tc.want, got[one]) + assert.Equal(t, tc.want, got[two]) + }) + } } diff --git a/app/controlplane/internal/service/service.go b/app/controlplane/internal/service/service.go index 819768e61..80a78bd0c 100644 --- a/app/controlplane/internal/service/service.go +++ b/app/controlplane/internal/service/service.go @@ -249,15 +249,34 @@ func (s *service) authorizeResource(ctx context.Context, op *authz.Policy, resou // on it. It answers for a whole listing in one pass, so a client does not have // to offer an action that would be refused the moment it is taken. // -// It mirrors authorizeResource: a caller can hold several roles on the same -// project, directly and through products, and any one of them granting the -// permission is enough. Each distinct role is enforced once, not once per -// project. +// It mirrors the two checks the operation itself would face. First the +// API-level one in the authz middleware, against the caller's organization +// role. Then, when RBAC applies, authorizeResource: a caller can hold several +// roles on the same project, directly and through products, and any one of them +// granting the permission is enough. Each distinct role is enforced once, not +// once per project. func (s *service) projectsAllowing(ctx context.Context, op *authz.Policy, projects []*biz.Project) (map[uuid.UUID]bool, error) { allowed := make(map[uuid.UUID]bool, len(projects)) - // Without RBAC the caller's organization role already carries the - // permission, so every visible project is fair game. + // The organization role gates every path, so a role without the permission + // allows nothing. An organization viewer, for instance, may list projects + // but not create a workflow in any of them. Administrators skip the policy + // check here for the same reason the middleware skips it: their policies are + // not spelled out in full yet. + subject := usercontext.CurrentAuthzSubject(ctx) + if !authz.Role(subject).IsAdmin() { + orgAllows, err := s.authz.Enforce(ctx, subject, op) + if err != nil { + return nil, err + } + + if !orgAllows { + return allowed, nil + } + } + + // The organization role carries the permission and RBAC does not narrow it + // per project, so every visible project is fair game. if !rbacEnabled(ctx) { for _, p := range projects { allowed[p.ID] = true @@ -276,24 +295,24 @@ func (s *service) projectsAllowing(ctx context.Context, op *authz.Policy, projec return allowed, nil } + // roleGrants caches the enforcer's answer per role, so a caller with many + // project memberships still enforces each distinct role only once. roleGrants := make(map[authz.Role]bool) - m := entities.CurrentMembership(ctx) - for _, rm := range m.Resources { + for _, rm := range entities.CurrentMembership(ctx).Resources { if rm.ResourceType != authz.ResourceTypeProject { continue } - grants, seen := roleGrants[rm.Role] - if !seen { - var err error - if grants, err = s.authz.Enforce(ctx, string(rm.Role), op); err != nil { + if _, seen := roleGrants[rm.Role]; !seen { + grants, err := s.authz.Enforce(ctx, string(rm.Role), op) + if err != nil { return nil, err } roleGrants[rm.Role] = grants } - if grants { + if roleGrants[rm.Role] { allowed[rm.ResourceID] = true } } From c2376e8a6e93b7b9c1d7b9bf6d9fa0e0ca9ce59b Mon Sep 17 00:00:00 2001 From: Miguel Martinez Trivino Date: Tue, 8 Sep 2026 23:47:30 +0200 Subject: [PATCH 09/30] fix(cli): refuse a read-only project typed in by name on trace init Keeping a project the caller can only view out of the picker was not enough: the free-form name prompt accepted anything valid, and a name that matches an existing project resolves to that project rather than creating one. Typing the name of a read-only project therefore reached the same permission error the picker now avoids, and an organization exposing only read-only projects goes straight to that prompt. The new-project validator now knows which projects are read-only and refuses them at the prompt, saying why. Assisted-by: Claude Code Signed-off-by: Miguel Martinez Trivino Chainloop-Trace-Sessions: 184822bb-e811-42fd-8e13-735227edc5ce --- app/cli/cmd/trace_init_identity.go | 49 ++++++++++++++++--------- app/cli/cmd/trace_init_identity_test.go | 18 +++++++++ app/cli/cmd/trace_init_prompt_test.go | 2 +- 3 files changed, 51 insertions(+), 18 deletions(-) diff --git a/app/cli/cmd/trace_init_identity.go b/app/cli/cmd/trace_init_identity.go index db427c1c2..43966596a 100644 --- a/app/cli/cmd/trace_init_identity.go +++ b/app/cli/cmd/trace_init_identity.go @@ -202,21 +202,23 @@ func resolveInteractiveProject(ctx context.Context, lister projectLister, p prom } // Only the projects the caller can add a workflow to are worth offering; - // picking any other one would fail at creation time. + // picking any other one would fail at creation time. The rest are still + // worth knowing about, to refuse one that gets typed in by name. writable := make([]string, 0, len(visible)) - // pinnedReadOnly means .chainloop.yml points at a project the caller can see - // but not write to, which is why it is missing from the offer. - var pinnedReadOnly bool + readOnly := make([]string, 0, len(visible)) for _, project := range visible { - switch { - case project.CanCreateWorkflow: + if project.CanCreateWorkflow { writable = append(writable, project.Name) - case project.Name == fromYML: - pinnedReadOnly = true + continue } + + readOnly = append(readOnly, project.Name) } + // pinnedReadOnly means .chainloop.yml points at a project the caller can see + // but not write to, which is why it is missing from the offer. + pinnedReadOnly := fromYML != "" && slices.Contains(readOnly, fromYML) if pinnedReadOnly { logger.Warn().Str("project", fromYML). Msg("you cannot create a workflow in the project this repository points at, pick another one") @@ -225,7 +227,7 @@ func resolveInteractiveProject(ctx context.Context, lister projectLister, p prom // Nothing to choose from, so go straight to naming one rather than showing a // list holding only the pinned project. if len(writable) == 0 { - return promptNewProject(p, seedProjectName(fromYML, pinnedReadOnly, repoDir), fromYML) + return promptNewProject(p, seedProjectName(fromYML, pinnedReadOnly, repoDir), fromYML, readOnly) } // A project pinned in .chainloop.yml the listing did not carry at all is @@ -249,7 +251,7 @@ func resolveInteractiveProject(ctx context.Context, lister projectLister, p prom // They asked for a new project, so seed the name from the repository rather // than from the project they are moving away from. - return promptNewProject(p, config.SlugifyDNS1123(repoDir), fromYML) + return promptNewProject(p, config.SlugifyDNS1123(repoDir), fromYML, readOnly) } // seedProjectName is what the new-project prompt starts from. The project @@ -266,9 +268,10 @@ func seedProjectName(fromYML string, pinnedReadOnly bool, repoDir string) string // promptNewProject collects a free-form project name and returns it normalized // to the DNS-1123 label the control plane stores. A name that normalizes to one -// of the existing projects simply resolves to that project. -func promptNewProject(p prompter, defaultName, fromYML string) (*resolvedValue, error) { - answer, err := p.Input(newProjectPromptTitle, defaultName, validateNewProjectName) +// of the existing projects simply resolves to that project, so readOnly names +// are refused: typing one is the same dead end as picking it from the list. +func promptNewProject(p prompter, defaultName, fromYML string, readOnly []string) (*resolvedValue, error) { + answer, err := p.Input(newProjectPromptTitle, defaultName, newProjectValidator(readOnly)) if err != nil { return nil, err } @@ -278,11 +281,23 @@ func promptNewProject(p prompter, defaultName, fromYML string) (*resolvedValue, return &resolvedValue{value: name, save: name != fromYML, prompted: true}, nil } -// validateNewProjectName checks what a free-form name normalizes to, so a name +// newProjectValidator checks what a free-form name normalizes to, so a name // that cannot become a valid project is refused at the prompt instead of by the -// control plane a moment later. -func validateNewProjectName(answer string) error { - return config.ValidateDNS1123Label(config.SlugifyDNS1123(answer)) +// control plane a moment later. readOnly are the projects the caller can see but +// not write to, refused here for the same reason they are not offered. +func newProjectValidator(readOnly []string) func(string) error { + return func(answer string) error { + name := config.SlugifyDNS1123(answer) + if err := config.ValidateDNS1123Label(name); err != nil { + return err + } + + if slices.Contains(readOnly, name) { + return fmt.Errorf("you can only view the project %q, so a workflow cannot be created in it", name) + } + + return nil + } } // firstPresent returns the first candidate that appears in options, falling diff --git a/app/cli/cmd/trace_init_identity_test.go b/app/cli/cmd/trace_init_identity_test.go index 046a00445..ebb57c9bc 100644 --- a/app/cli/cmd/trace_init_identity_test.go +++ b/app/cli/cmd/trace_init_identity_test.go @@ -489,6 +489,24 @@ func TestResolveInteractiveProject(t *testing.T) { wantSave: true, wantInputDefault: "my-repo", }, + { + // Keeping a read-only project out of the list is not enough: typing + // its name reaches the same dead end. + name: "typing the name of a project the caller can only view is refused", + readOnly: []string{"payments"}, + repoDir: repoDirMyRepo, + inputAnswer: "Payments", + wantErr: "you can only view the project", + }, + { + name: "typing a read-only name is refused from the create entry too", + projects: []string{projectAPI}, + readOnly: []string{"payments"}, + repoDir: repoDirMyRepo, + selectAnswer: createNewProjectOption, + inputAnswer: "payments", + wantErr: "you can only view the project", + }, } for _, tc := range testCases { diff --git a/app/cli/cmd/trace_init_prompt_test.go b/app/cli/cmd/trace_init_prompt_test.go index a53557f73..872aeade9 100644 --- a/app/cli/cmd/trace_init_prompt_test.go +++ b/app/cli/cmd/trace_init_prompt_test.go @@ -46,7 +46,7 @@ func TestHuhPrompterSelect(t *testing.T) { func TestHuhPrompterInput(t *testing.T) { p, out := accessiblePrompter("my-project\n") - got, err := p.Input("Project name", "default-name", validateNewProjectName) + got, err := p.Input("Project name", "default-name", newProjectValidator(nil)) require.NoError(t, err) assert.Equal(t, "my-project", got) assert.Contains(t, out.String(), "Project name") From bd211d4506c9bd3824249496da869eb49fe36930 Mon Sep 17 00:00:00 2001 From: Miguel Martinez Trivino Date: Wed, 9 Sep 2026 16:58:37 +0200 Subject: [PATCH 10/30] fix(cli): treat the project listing as the source of truth on trace init A project name in .chainloop.yml was offered in the picker even when the organization did not have it, on the assumption that it was visible only through the repository or not created yet. It rendered like any other entry and was preselected, so pressing Enter sent the control plane a project it had never seen. Workflow creation then fell through to creating the project, which fails outright for anyone without that permission, and reported that they cannot create projects when they believed they were picking an existing one. The listing now decides what exists. A pinned name is offered only when the listing carries it and the caller can add a workflow to it. One the listing carries as read-only is refused as before. One the listing does not carry at all is still the name the repository is asking for, so it seeds the new-project prompt rather than posing as an existing project, and a warning says it was not found. Assisted-by: Claude Code Signed-off-by: Miguel Martinez Trivino Chainloop-Trace-Sessions: 184822bb-e811-42fd-8e13-735227edc5ce --- app/cli/cmd/trace_init_identity.go | 59 +++++++++++-------------- app/cli/cmd/trace_init_identity_test.go | 37 ++++++++++++---- 2 files changed, 55 insertions(+), 41 deletions(-) diff --git a/app/cli/cmd/trace_init_identity.go b/app/cli/cmd/trace_init_identity.go index 43966596a..2436c14f9 100644 --- a/app/cli/cmd/trace_init_identity.go +++ b/app/cli/cmd/trace_init_identity.go @@ -16,7 +16,6 @@ package cmd import ( - "cmp" "context" "errors" "fmt" @@ -216,26 +215,36 @@ func resolveInteractiveProject(ctx context.Context, lister projectLister, p prom readOnly = append(readOnly, project.Name) } - // pinnedReadOnly means .chainloop.yml points at a project the caller can see - // but not write to, which is why it is missing from the offer. - pinnedReadOnly := fromYML != "" && slices.Contains(readOnly, fromYML) - if pinnedReadOnly { - logger.Warn().Str("project", fromYML). - Msg("you cannot create a workflow in the project this repository points at, pick another one") + // newProjectSeed is what the new-project prompt starts from. The repository's + // own directory name is the default seed: a project the organization already + // has is picked from the list, so seeding its name would only propose moving + // nowhere. + newProjectSeed := config.SlugifyDNS1123(repoDir) + + // The listing is the source of truth: what .chainloop.yml names is only worth + // offering if the organization actually has it. Anything else it holds is + // stale, names a project in another organization, or was never created. + if fromYML != "" && !slices.Contains(writable, fromYML) { + if slices.Contains(readOnly, fromYML) { + logger.Warn().Str("project", fromYML). + Msg("you cannot create a workflow in the project this repository points at, pick another one") + } else { + // Not in the organization at all, so it is a name for a project that does + // not exist rather than one that can be picked. Offering it as if it were + // real is what makes selecting it fail on a project the caller may not be + // allowed to create. Creating it is what the repository is asking for, so + // it seeds the new name instead. + newProjectSeed = fromYML + + logger.Warn().Str("project", fromYML). + Msg("the project this repository points at does not exist in this organization") + } } // Nothing to choose from, so go straight to naming one rather than showing a - // list holding only the pinned project. + // list holding nothing but the create entry. if len(writable) == 0 { - return promptNewProject(p, seedProjectName(fromYML, pinnedReadOnly, repoDir), fromYML, readOnly) - } - - // A project pinned in .chainloop.yml the listing did not carry at all is - // still offered: the user may only be able to see it through this - // repository, or it may not exist yet. One the listing did carry and marked - // read-only is a different case, and stays out. - if fromYML != "" && !pinnedReadOnly && !slices.Contains(writable, fromYML) { - writable = append(writable, fromYML) + return promptNewProject(p, newProjectSeed, fromYML, readOnly) } options := append([]string{createNewProjectOption}, writable...) @@ -249,21 +258,7 @@ func resolveInteractiveProject(ctx context.Context, lister projectLister, p prom return &resolvedValue{value: chosen, save: chosen != fromYML, prompted: true}, nil } - // They asked for a new project, so seed the name from the repository rather - // than from the project they are moving away from. - return promptNewProject(p, config.SlugifyDNS1123(repoDir), fromYML, readOnly) -} - -// seedProjectName is what the new-project prompt starts from. The project -// .chainloop.yml pins is the best seed, unless it is one the caller cannot write -// to, in which case proposing it again would just repeat the same failure. -func seedProjectName(fromYML string, pinnedReadOnly bool, repoDir string) string { - fromRepo := config.SlugifyDNS1123(repoDir) - if pinnedReadOnly { - return fromRepo - } - - return cmp.Or(fromYML, fromRepo) + return promptNewProject(p, newProjectSeed, fromYML, readOnly) } // promptNewProject collects a free-form project name and returns it normalized diff --git a/app/cli/cmd/trace_init_identity_test.go b/app/cli/cmd/trace_init_identity_test.go index ebb57c9bc..79bd42cab 100644 --- a/app/cli/cmd/trace_init_identity_test.go +++ b/app/cli/cmd/trace_init_identity_test.go @@ -399,15 +399,35 @@ func TestResolveInteractiveProject(t *testing.T) { wantSelectDefault: createNewProjectOption, }, { - name: "a pinned project that is not visible is still offered and preselected", + // The listing is the source of truth. Offering a name only + // .chainloop.yml knows about makes it look like an existing project, + // and picking it asks the control plane to create one, which fails + // outright for a caller who may not create projects. + name: "a pinned project the organization does not have is not offered", projects: []string{projectAPI}, - fromYML: "not-visible-to-me", + fromYML: "gone-or-never-existed", repoDir: "whatever", - selectAnswer: "not-visible-to-me", - wantValue: "not-visible-to-me", + selectAnswer: projectAPI, + wantValue: projectAPI, + wantSave: true, + wantOptions: []string{createNewProjectOption, projectAPI}, + wantSelectDefault: createNewProjectOption, + }, + { + // It is still the name the repository is asking for, so creating is + // where it belongs: the input starts from it rather than from the + // repository's directory name. + name: "a pinned project the organization does not have seeds the new name", + projects: []string{projectAPI}, + fromYML: "gone-or-never-existed", + repoDir: repoDirMyRepo, + selectAnswer: createNewProjectOption, + inputAnswer: "gone-or-never-existed", + wantValue: "gone-or-never-existed", wantSave: false, - wantOptions: []string{createNewProjectOption, projectAPI, "not-visible-to-me"}, - wantSelectDefault: "not-visible-to-me", + wantOptions: []string{createNewProjectOption, projectAPI}, + wantSelectDefault: createNewProjectOption, + wantInputDefault: "gone-or-never-existed", }, { name: "choosing to create opens an input prefilled with the repository name", @@ -465,9 +485,8 @@ func TestResolveInteractiveProject(t *testing.T) { wantSelectDefault: createNewProjectOption, }, { - // A pinned project missing from the listing is offered anyway, but - // one the listing returned as read-only must not be: it is a - // different case with the same symptom. + // The listing did carry it, so it is a real project, but picking it + // would fail at creation time just the same. name: "a pinned project the caller can only view is neither offered nor preselected", projects: []string{projectAPI}, readOnly: []string{"payments"}, From 9ceb915a14fa84836e7a057b1803046c6a47d8d0 Mon Sep 17 00:00:00 2001 From: Miguel Martinez Trivino Date: Wed, 9 Sep 2026 17:26:05 +0200 Subject: [PATCH 11/30] fix(cli): show every agent in the picker and leave trace init with one summary line The agent picker hid its last option. huh's multi-select, given no height, sizes its viewport from the options and then subtracts the title's line from that same number, so one fewer option than there are fits. Select does not do this, which is why only this field needs a height. Asking for one line more than the options occupy cancels the subtraction out, and the field construction moved to a function so a test can render it and look for every option. trace init also narrated every step at info level, which buried the result. The per-step lines are now debug and the summary is the one thing printed on a successful run, naming the organization alongside the project, workflow and agents. It reads "repository initialized", since that is what it did. Assisted-by: Claude Code Signed-off-by: Miguel Martinez Trivino Chainloop-Trace-Sessions: 184822bb-e811-42fd-8e13-735227edc5ce --- app/cli/cmd/trace_init.go | 25 ++++++++++++++------- app/cli/cmd/trace_init_prompt.go | 31 ++++++++++++++++++++------- app/cli/cmd/trace_init_prompt_test.go | 28 ++++++++++++++++++++++++ 3 files changed, 68 insertions(+), 16 deletions(-) diff --git a/app/cli/cmd/trace_init.go b/app/cli/cmd/trace_init.go index 40c028d1c..5df0230c8 100644 --- a/app/cli/cmd/trace_init.go +++ b/app/cli/cmd/trace_init.go @@ -119,7 +119,7 @@ The organization, project, workflow and require-trace values are saved to if err != nil { return err } - logger.Info(). + logger.Debug(). Str("path", hooksDir). Msg("git hooks installed (post-commit, pre-push)") @@ -138,14 +138,23 @@ The organization, project, workflow and require-trace values are saved to logger.Warn().Err(err).Str("provider", p.Name()).Msg("could not install agent hooks") continue } - logger.Info().Str("provider", p.Name()).Msg("agent hooks installed") + logger.Debug().Str("provider", p.Name()).Msg("agent hooks installed") } - logger.Info(). + // The one line worth printing on a successful run: every step above + // logs at debug, so this is what the user is left with. The + // organization is absent when nothing pinned one and the CLI's own + // default was used, in which case naming it here would be a guess. + done := logger.Info() + if cfg.organization != "" { + done = done.Str("organization", cfg.organization) + } + + done. Str("project", cfg.project). Str("workflow", cfg.workflow). Strs("providers", selected). - Msg("trace initialized") + Msg("repository initialized") return nil }, @@ -185,7 +194,7 @@ func ensureTraceInitWorkflow(ctx context.Context, executor *action.AttestationEx if wf.Created { // The workflow name is the same in every project, so the project is what // identifies what was just created. - logger.Info(). + logger.Debug(). Str("project", cfg.project). Str("workflow", cfg.workflow). Str("contract", wf.ContractName). @@ -268,21 +277,21 @@ func (c *traceInitConfig) save(repoRoot string) error { if err := config.SaveProjectToYML(repoRoot, c.project); err != nil { return err } - logger.Info().Str("project", c.project).Msg("project name saved to .chainloop.yml") + logger.Debug().Str("project", c.project).Msg("project name saved to .chainloop.yml") } if c.saveOrganization { if err := config.SaveOrganizationToYML(repoRoot, c.organization); err != nil { return err } - logger.Info().Str("organization", c.organization).Msg("organization saved to .chainloop.yml") + logger.Debug().Str("organization", c.organization).Msg("organization saved to .chainloop.yml") } if c.saveWorkflow { if err := config.SaveWorkflowToYML(repoRoot, c.workflow); err != nil { return err } - logger.Info().Str("workflow", c.workflow).Msg("workflow saved to .chainloop.yml") + logger.Debug().Str("workflow", c.workflow).Msg("workflow saved to .chainloop.yml") } if c.saveRequireTrace { diff --git a/app/cli/cmd/trace_init_prompt.go b/app/cli/cmd/trace_init_prompt.go index d9fd04c50..9b1762137 100644 --- a/app/cli/cmd/trace_init_prompt.go +++ b/app/cli/cmd/trace_init_prompt.go @@ -83,10 +83,31 @@ func (h *huhPrompter) Select(title string, options []string, defaultValue string func (h *huhPrompter) MultiSelect(title string, options, defaults []string) ([]string, error) { value := slices.Clone(defaults) - field := huh.NewMultiSelect[string](). + if err := h.run(newMultiSelectField(title, options, &value)); err != nil { + return nil, err + } + + return value, nil +} + +// multiSelectMaxVisible caps how many options are on screen at once, so a long +// list scrolls instead of pushing the prompt off the top. It is the height huh +// itself falls back to for dynamic options. +const multiSelectMaxVisible = 10 + +// newMultiSelectField builds the multi-select. It is separate from MultiSelect +// so a test can render it without a terminal to run a form against. +func newMultiSelectField(title string, options []string, value *[]string) *huh.MultiSelect[string] { + return huh.NewMultiSelect[string](). Title(title). Options(huh.NewOptions(options...)...). - Value(&value). + // The height has to be set. Left unset, huh sizes the viewport from the + // options and then subtracts the title's line from that same number, so + // the last option is silently cut off. Asking for one line more than the + // options occupy cancels the subtraction out. Select does not share this + // quirk, which is why only this field sets a height. + Height(min(len(options), multiSelectMaxVisible) + 1). + Value(value). Validate(func(selected []string) error { if len(selected) == 0 { return errors.New("pick at least one, with space") @@ -94,12 +115,6 @@ func (h *huhPrompter) MultiSelect(title string, options, defaults []string) ([]s return nil }) - - if err := h.run(field); err != nil { - return nil, err - } - - return value, nil } // Input asks the user to type a value, prefilled with defaultValue. The diff --git a/app/cli/cmd/trace_init_prompt_test.go b/app/cli/cmd/trace_init_prompt_test.go index 872aeade9..a3444f005 100644 --- a/app/cli/cmd/trace_init_prompt_test.go +++ b/app/cli/cmd/trace_init_prompt_test.go @@ -17,9 +17,11 @@ package cmd import ( "bytes" + "fmt" "strings" "testing" + "charm.land/huh/v2" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -87,3 +89,29 @@ func TestNewHuhPrompterAccessibility(t *testing.T) { }) } } + +// TestMultiSelectFieldShowsEveryOption renders the real field and looks for +// every option in the output. Left to size itself, huh's multi-select subtracts +// the title's line from the height it derived from the options, so the last one +// never appears and the user cannot tell there is more to tick. +func TestMultiSelectFieldShowsEveryOption(t *testing.T) { + for _, count := range []int{1, 2, 3, 5, multiSelectMaxVisible} { + t.Run(fmt.Sprintf("%d options", count), func(t *testing.T) { + options := make([]string, 0, count) + for i := range count { + options = append(options, fmt.Sprintf("option-%d", i)) + } + + var value []string + form := huh.NewForm(huh.NewGroup(newMultiSelectField("Pick some", options, &value))) + // Init sizes the field, which is what this is about. Its command + // starts the cursor blinking and is nothing to run here. + _ = form.Init() + + view := form.View() + for _, o := range options { + assert.Contains(t, view, o, "every option must be on screen") + } + }) + } +} From a61bd66c277e168fe305109a732d993fca106447 Mon Sep 17 00:00:00 2001 From: Miguel Martinez Trivino Date: Wed, 9 Sep 2026 17:44:07 +0200 Subject: [PATCH 12/30] feat(cli): close trace init with what was set up and what to do next Initialization ended on a log line about what had happened and nothing about what to do with it. The most important part was invisible: everything init writes lives in the repository, so until those files are committed the tracing applies to one working copy, while committing them onboards the rest of the team without anyone else running init. The command now closes on stdout rather than through the logger, since this is the command's result and not a note about something that happened on the way there. It reports the organization, project, workflow and agents, then the files to commit as a git add line naming each selected agent's configuration, the agents to start, and a link to the guide for the rest. Assisted-by: Claude Code Signed-off-by: Miguel Martinez Trivino Chainloop-Trace-Sessions: 184822bb-e811-42fd-8e13-735227edc5ce --- app/cli/cmd/trace_init.go | 86 +++++++++++++++---- app/cli/cmd/trace_init_test.go | 107 +++++++++++++++++++++++- app/cli/internal/trace/config/config.go | 7 ++ 3 files changed, 184 insertions(+), 16 deletions(-) diff --git a/app/cli/cmd/trace_init.go b/app/cli/cmd/trace_init.go index 5df0230c8..00e37633b 100644 --- a/app/cli/cmd/trace_init.go +++ b/app/cli/cmd/trace_init.go @@ -18,8 +18,12 @@ package cmd import ( "context" "fmt" + "io" "os" + "path/filepath" + "strings" + "github.com/chainloop-dev/chainloop/app/cli/internal/trace" "github.com/chainloop-dev/chainloop/app/cli/internal/trace/config" tracegit "github.com/chainloop-dev/chainloop/app/cli/internal/trace/git" "github.com/chainloop-dev/chainloop/app/cli/internal/trace/hooks" @@ -141,20 +145,10 @@ The organization, project, workflow and require-trace values are saved to logger.Debug().Str("provider", p.Name()).Msg("agent hooks installed") } - // The one line worth printing on a successful run: every step above - // logs at debug, so this is what the user is left with. The - // organization is absent when nothing pinned one and the CLI's own - // default was used, in which case naming it here would be a guess. - done := logger.Info() - if cfg.organization != "" { - done = done.Str("organization", cfg.organization) - } - - done. - Str("project", cfg.project). - Str("workflow", cfg.workflow). - Strs("providers", selected). - Msg("repository initialized") + // What the run produced, and what to do with it. Every step above logs + // at debug, so this is what the user is left with. + writeTraceInitSummary(os.Stdout, cfg, selected) + writeTraceNextSteps(os.Stdout, repoRoot, selectedProviders) return nil }, @@ -171,6 +165,70 @@ The organization, project, workflow and require-trace values are saved to return cmd } +// traceDocsURL is the guide covering what this command set up and what can be +// done with it, which is more than belongs in a command's own output. +const traceDocsURL = "https://docs.chainloop.dev/guides/chainloop-trace" + +// writeTraceInitSummary reports what the repository was set up with. It goes to +// stdout rather than through the logger because it is the command's result, +// not a note about something that happened on the way there. +func writeTraceInitSummary(w io.Writer, cfg *traceInitConfig, agents []string) { + fmt.Fprint(w, "\nCongratulations, your repository is initialized\n\n") + + // The organization is absent when nothing pinned one and the CLI's own + // default was used, in which case naming it here would be a guess. + if cfg.organization != "" { + fmt.Fprintf(w, " organization %s\n", cfg.organization) + } + + fmt.Fprintf(w, " project %s\n", cfg.project) + fmt.Fprintf(w, " workflow %s\n", cfg.workflow) + fmt.Fprintf(w, " agents %s\n", strings.Join(agents, ", ")) +} + +// writeTraceNextSteps says what is left for the user to do. Committing comes +// first: the hooks and the configuration live in the repository, so a teammate +// who pulls them is traced without running init themselves, and until then this +// only applies to the one working copy. +func writeTraceNextSteps(w io.Writer, repoRoot string, selected []trace.Provider) { + files := []string{config.ChainloopYMLName(repoRoot)} + names := make([]string, 0, len(selected)) + + for _, p := range selected { + names = append(names, p.Name()) + + // A path outside the repository cannot be committed, so it is not worth + // naming here. + if rel, err := filepath.Rel(repoRoot, p.SettingsFile(repoRoot)); err == nil && !strings.HasPrefix(rel, "..") { + files = append(files, rel) + } + } + + fmt.Fprintf(w, ` +What's next + + 1. Commit the changes, so the rest of your team is traced without running init: + git add %s + 2. Start %s and write some code + 3. Commit and push as usual, and the AI-assisted commits are attested on push + +Learn more: %s +`, strings.Join(files, " "), joinNames(names), traceDocsURL) +} + +// joinNames renders a list the way a sentence needs it: "a", "a or b", or +// "a, b or c". +func joinNames(names []string) string { + switch len(names) { + case 0: + return "" + case 1: + return names[0] + default: + return strings.Join(names[:len(names)-1], ", ") + " or " + names[len(names)-1] + } +} + // ensureTraceInitWorkflow makes sure the workflow the trace attestations target // exists, creating it up front while someone is watching the output. Left to // the pre-push hook, the control plane creates it implicitly with an empty diff --git a/app/cli/cmd/trace_init_test.go b/app/cli/cmd/trace_init_test.go index 4c4b81421..52f6ee959 100644 --- a/app/cli/cmd/trace_init_test.go +++ b/app/cli/cmd/trace_init_test.go @@ -16,11 +16,13 @@ package cmd import ( + "bytes" "os" "path/filepath" "slices" "testing" + "github.com/chainloop-dev/chainloop/app/cli/internal/trace/providers" "github.com/spf13/cobra" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -42,6 +44,13 @@ func traceInitTestCmd(t *testing.T, args ...string) *cobra.Command { // defaultTraceWorkflow is the workflow name trace init falls back to. const defaultTraceWorkflow = "ai-coding-session" +// The files trace init leaves in the repository, which the closing message has +// to name so they get committed. +const ( + chainloopYMLName = ".chainloop.yml" + claudeSettings = ".claude/settings.json" +) + func TestResolveTraceInitConfig(t *testing.T) { const existingYML = "projectName: yml-project\norganization: yml-org\nworkflowName: yml-workflow\nrequireTrace: true\n" @@ -109,7 +118,7 @@ func TestResolveTraceInitConfig(t *testing.T) { for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { dir := t.TempDir() - path := filepath.Join(dir, ".chainloop.yml") + path := filepath.Join(dir, chainloopYMLName) require.NoError(t, os.WriteFile(path, []byte(tc.yml), 0600)) cfg, err := resolveTraceInitConfig(traceInitTestCmd(t, tc.args...), dir, tc.projectFlag) @@ -147,7 +156,7 @@ func TestResolveTraceInitConfig(t *testing.T) { func TestTraceInitConfigSave(t *testing.T) { t.Run("only the values the user passed are written", func(t *testing.T) { dir := t.TempDir() - path := filepath.Join(dir, ".chainloop.yml") + path := filepath.Join(dir, chainloopYMLName) require.NoError(t, os.WriteFile(path, []byte("projectName: yml-project\n"), 0600)) cfg := &traceInitConfig{ @@ -164,3 +173,97 @@ func TestTraceInitConfigSave(t *testing.T) { assert.NotContains(t, string(data), "organization:") }) } + +// TestWriteTraceNextSteps checks the closing message names the files that have +// to be committed. Everything init wrote lives in the repository, so leaving +// them uncommitted keeps the tracing on one working copy. +func TestWriteTraceNextSteps(t *testing.T) { + testCases := []struct { + name string + providers []string + // wantFiles are the paths the git add line must carry + wantFiles []string + // wantAgents is how the agents are named in the sentence + wantAgents string + }{ + { + name: "one agent", + providers: []string{providerClaudeCode}, + wantFiles: []string{chainloopYMLName, claudeSettings}, + wantAgents: providerClaudeCode, + }, + { + name: "two agents", + providers: []string{providerClaudeCode, "cursor"}, + wantFiles: []string{chainloopYMLName, claudeSettings, ".cursor/hooks.json"}, + wantAgents: "claude-code or cursor", + }, + { + name: "every agent", + providers: []string{providerClaudeCode, "cursor", "opencode"}, + wantFiles: []string{chainloopYMLName, claudeSettings, ".cursor/hooks.json", ".opencode"}, + wantAgents: "claude-code, cursor or opencode", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + out := &bytes.Buffer{} + writeTraceNextSteps(out, t.TempDir(), providers.ByNames(tc.providers)) + + got := out.String() + for _, f := range tc.wantFiles { + assert.Contains(t, got, f, "the file has to be committed, so it has to be named") + } + + assert.Contains(t, got, tc.wantAgents) + assert.Contains(t, got, traceDocsURL) + }) + } +} + +func TestWriteTraceInitSummary(t *testing.T) { + t.Run("every value is reported", func(t *testing.T) { + out := &bytes.Buffer{} + writeTraceInitSummary(out, &traceInitConfig{ + organization: "acme", + project: "backend-api", + workflow: defaultTraceWorkflow, + }, []string{"claude-code", "cursor"}) + + got := out.String() + assert.Contains(t, got, "your repository is initialized") + assert.Contains(t, got, "acme") + assert.Contains(t, got, "backend-api") + assert.Contains(t, got, defaultTraceWorkflow) + assert.Contains(t, got, "claude-code, cursor") + }) + + t.Run("an organization nothing pinned is left out rather than guessed at", func(t *testing.T) { + out := &bytes.Buffer{} + writeTraceInitSummary(out, &traceInitConfig{ + project: "backend-api", + workflow: defaultTraceWorkflow, + }, []string{"claude-code"}) + + assert.NotContains(t, out.String(), "organization") + }) +} + +func TestJoinNames(t *testing.T) { + testCases := []struct { + names []string + want string + }{ + {names: nil, want: ""}, + {names: []string{"a"}, want: "a"}, + {names: []string{"a", "b"}, want: "a or b"}, + {names: []string{"a", "b", "c"}, want: "a, b or c"}, + } + + for _, tc := range testCases { + t.Run(tc.want, func(t *testing.T) { + assert.Equal(t, tc.want, joinNames(tc.names)) + }) + } +} diff --git a/app/cli/internal/trace/config/config.go b/app/cli/internal/trace/config/config.go index b86a38775..b426e9181 100644 --- a/app/cli/internal/trace/config/config.go +++ b/app/cli/internal/trace/config/config.go @@ -340,6 +340,13 @@ func findChainloopYMLAny(dir string) *ChainloopYML { return nil } +// ChainloopYMLName returns the name of the Chainloop file in dir: the one that +// is there when either spelling exists, and the default otherwise. Callers use +// it to name the file, for instance when asking the user to commit it. +func ChainloopYMLName(dir string) string { + return filepath.Base(resolveChainloopYMLPath(dir)) +} + // resolveChainloopYMLPath returns the path to the existing .chainloop.yml // (or .chainloop.yaml) in dir. If neither exists, defaults to .chainloop.yml. func resolveChainloopYMLPath(dir string) string { From f11401230c5c17bff87307aa34655542cec349c2 Mon Sep 17 00:00:00 2001 From: Miguel Martinez Trivino Date: Wed, 9 Sep 2026 17:46:57 +0200 Subject: [PATCH 13/30] docs(cli): reword the commit step in the trace init next steps "the rest of your team is traced" read as though the people were the subject, and the trailing clause left the sentence hanging. The benefit is now its own sentence, and "these files" covers .chainloop.yml being modified rather than created. Assisted-by: Claude Code Signed-off-by: Miguel Martinez Trivino Chainloop-Trace-Sessions: 184822bb-e811-42fd-8e13-735227edc5ce --- app/cli/cmd/trace_init.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/cli/cmd/trace_init.go b/app/cli/cmd/trace_init.go index 00e37633b..21bfecdef 100644 --- a/app/cli/cmd/trace_init.go +++ b/app/cli/cmd/trace_init.go @@ -207,7 +207,7 @@ func writeTraceNextSteps(w io.Writer, repoRoot string, selected []trace.Provider fmt.Fprintf(w, ` What's next - 1. Commit the changes, so the rest of your team is traced without running init: + 1. Commit these files. Anyone who pulls them is set up automatically: git add %s 2. Start %s and write some code 3. Commit and push as usual, and the AI-assisted commits are attested on push From b6c854a19d6d13d794dfec0fe491383b0a5839f1 Mon Sep 17 00:00:00 2001 From: Miguel Martinez Trivino Date: Wed, 9 Sep 2026 18:22:15 +0200 Subject: [PATCH 14/30] docs(cli): call them harnesses and describe what tracing does The closing summary, the picker and the command help each said "agent" or "provider" where the user sees them. They are all harnesses now, so the question and the result use the same word. The last step also promised that "AI-assisted commits are attested on push", which describes the mechanism rather than what the user gets. It now says the AI coding sessions are recorded and stored automatically. Assisted-by: Claude Code Signed-off-by: Miguel Martinez Trivino Chainloop-Trace-Sessions: 184822bb-e811-42fd-8e13-735227edc5ce --- app/cli/cmd/trace_init.go | 20 ++++++++++---------- app/cli/cmd/trace_init_providers.go | 4 ++-- app/cli/documentation/cli-reference.mdx | 14 +++++++------- 3 files changed, 19 insertions(+), 19 deletions(-) diff --git a/app/cli/cmd/trace_init.go b/app/cli/cmd/trace_init.go index 21bfecdef..a6db7abd1 100644 --- a/app/cli/cmd/trace_init.go +++ b/app/cli/cmd/trace_init.go @@ -49,16 +49,16 @@ func newTraceInitCmd() *cobra.Command { Long: `Initialize git hooks that automatically trace AI coding sessions and create Chainloop attestations when you push. -It installs the managed git hooks plus the hooks of the selected agent -providers (Claude Code when none is given), and creates the Chainloop workflow -the attestations target. Nothing is written to the repository until that -workflow exists, so you need to be logged in. +It installs the managed git hooks plus the hooks of the selected harnesses +(Claude Code when none is given), and creates the Chainloop workflow the +attestations target. Nothing is written to the repository until that workflow +exists, so you need to be logged in. On a terminal it asks which organization and project to use, offering what .chainloop.yml already holds so pressing Enter keeps it. A new project can be named freely; the name is normalized to the lowercase, dash-separated form -Chainloop stores. It then asks which agents to trace, with Claude Code -ticked; use space to tick more. Passing --org, --project or a provider flag +Chainloop stores. It then asks which harnesses to trace, with Claude Code +ticked; use space to tick more. Passing --org, --project or a harness flag (--claude, --cursor, --opencode) skips the matching question. Nothing is asked in CI or when the output is redirected: there --project is @@ -158,7 +158,7 @@ The organization, project, workflow and require-trace values are saved to cmd.Flags().StringVar(&contract, "contract", "", traceContractFlagDesc) cmd.Flags().String("workflow", "", "chainloop workflow name used for trace attestations (defaults to \"ai-coding-session\")") cmd.Flags().Bool("require-trace", false, "block pushes when attestation fails for AI-assisted commits") - cmd.Flags().BoolVar(&claudeFlag, "claude", false, "install Claude Code hooks (default when no provider flag is set)") + cmd.Flags().BoolVar(&claudeFlag, "claude", false, "install Claude Code hooks (default when no harness flag is set)") cmd.Flags().BoolVar(&cursorFlag, "cursor", false, "install Cursor hooks") cmd.Flags().BoolVar(&opencodeFlag, "opencode", false, "install opencode hooks") @@ -172,7 +172,7 @@ const traceDocsURL = "https://docs.chainloop.dev/guides/chainloop-trace" // writeTraceInitSummary reports what the repository was set up with. It goes to // stdout rather than through the logger because it is the command's result, // not a note about something that happened on the way there. -func writeTraceInitSummary(w io.Writer, cfg *traceInitConfig, agents []string) { +func writeTraceInitSummary(w io.Writer, cfg *traceInitConfig, harnesses []string) { fmt.Fprint(w, "\nCongratulations, your repository is initialized\n\n") // The organization is absent when nothing pinned one and the CLI's own @@ -183,7 +183,7 @@ func writeTraceInitSummary(w io.Writer, cfg *traceInitConfig, agents []string) { fmt.Fprintf(w, " project %s\n", cfg.project) fmt.Fprintf(w, " workflow %s\n", cfg.workflow) - fmt.Fprintf(w, " agents %s\n", strings.Join(agents, ", ")) + fmt.Fprintf(w, " harnesses %s\n", strings.Join(harnesses, ", ")) } // writeTraceNextSteps says what is left for the user to do. Committing comes @@ -210,7 +210,7 @@ What's next 1. Commit these files. Anyone who pulls them is set up automatically: git add %s 2. Start %s and write some code - 3. Commit and push as usual, and the AI-assisted commits are attested on push + 3. Commit and push as usual. Your AI coding sessions are recorded and stored automatically Learn more: %s `, strings.Join(files, " "), joinNames(names), traceDocsURL) diff --git a/app/cli/cmd/trace_init_providers.go b/app/cli/cmd/trace_init_providers.go index 335f910c3..e4f4cfc18 100644 --- a/app/cli/cmd/trace_init_providers.go +++ b/app/cli/cmd/trace_init_providers.go @@ -24,7 +24,7 @@ import ( "github.com/chainloop-dev/chainloop/app/cli/internal/trace/providers" ) -const providersPromptTitle = "Agents to trace in this repository" +const providersPromptTitle = "Harnesses to trace in this repository" // traceProviderFlags records which --claude / --cursor / --opencode flags the // user set. @@ -99,7 +99,7 @@ func resolveTraceProviders(p prompter, flags traceProviderFlags, interactive boo // The prompt refuses an empty submission, so this only catches a prompter // that does not, but tracing nothing would install hooks that never fire. if len(chosen) == 0 { - return nil, errors.New("select at least one agent to trace") + return nil, errors.New("select at least one harness to trace") } return chosen, nil diff --git a/app/cli/documentation/cli-reference.mdx b/app/cli/documentation/cli-reference.mdx index b207f0adb..3a957d2d2 100755 --- a/app/cli/documentation/cli-reference.mdx +++ b/app/cli/documentation/cli-reference.mdx @@ -3306,16 +3306,16 @@ Synopsis Initialize git hooks that automatically trace AI coding sessions and create Chainloop attestations when you push. -It installs the managed git hooks plus the hooks of the selected agent -providers (Claude Code when none is given), and creates the Chainloop workflow -the attestations target. Nothing is written to the repository until that -workflow exists, so you need to be logged in. +It installs the managed git hooks plus the hooks of the selected harnesses +(Claude Code when none is given), and creates the Chainloop workflow the +attestations target. Nothing is written to the repository until that workflow +exists, so you need to be logged in. On a terminal it asks which organization and project to use, offering what .chainloop.yml already holds so pressing Enter keeps it. A new project can be named freely; the name is normalized to the lowercase, dash-separated form -Chainloop stores. It then asks which agents to trace, with Claude Code -ticked; use space to tick more. Passing --org, --project or a provider flag +Chainloop stores. It then asks which harnesses to trace, with Claude Code +ticked; use space to tick more. Passing --org, --project or a harness flag (--claude, --cursor, --opencode) skips the matching question. Nothing is asked in CI or when the output is redirected: there --project is @@ -3332,7 +3332,7 @@ chainloop trace init [flags] Options ``` ---claude install Claude Code hooks (default when no provider flag is set) +--claude install Claude Code hooks (default when no harness flag is set) --contract string workflow contract to attach when creating the workflow; it must already exist (default: "chainloop-ai-coding-session" when the organization has it, an empty contract otherwise) --cursor install Cursor hooks -h, --help help for init From 7ee178bb9457b943be0140b12712570c6b00b2f9 Mon Sep 17 00:00:00 2001 From: Miguel Martinez Trivino Date: Wed, 9 Sep 2026 19:27:32 +0200 Subject: [PATCH 15/30] fix(cli): make the trace init closing message tell the truth Four things it got wrong. It said anyone who pulls the committed files is set up automatically. The git hooks are written to .git/hooks, which git does not carry between clones, so a teammate still has to run init. Committing spares them answering anything, which is what the step now says. It promised the AI coding sessions are stored, but a session whose commits carry no AI attribution is skipped before any attestation is created. Storage is now tied to the commits, which is when it happens. It listed every selected harness even when installing its hooks failed, offering a file that was never written for committing. Only what installed is reported, and a run where nothing installed is an error rather than a congratulation. Its git add line named paths relative to the repository root, but git add resolves its arguments against the working directory, so the command staged nothing when init ran from a subdirectory. Assisted-by: Claude Code Signed-off-by: Miguel Martinez Trivino Chainloop-Trace-Sessions: 184822bb-e811-42fd-8e13-735227edc5ce --- app/cli/cmd/trace_init.go | 81 ++++++++++++++++++------- app/cli/cmd/trace_init_test.go | 63 +++++++++++++------ app/cli/internal/trace/config/config.go | 7 ++- 3 files changed, 106 insertions(+), 45 deletions(-) diff --git a/app/cli/cmd/trace_init.go b/app/cli/cmd/trace_init.go index a6db7abd1..e3b09cc94 100644 --- a/app/cli/cmd/trace_init.go +++ b/app/cli/cmd/trace_init.go @@ -137,18 +137,40 @@ The organization, project, workflow and require-trace values are saved to // SessionRecord, so the list isn't persisted anywhere — only each // agent's own hook config file, written here, determines which // providers can register sessions. + installed := make([]trace.Provider, 0, len(selectedProviders)) for _, p := range selectedProviders { if err := p.InstallHooks(repoRoot); err != nil { - logger.Warn().Err(err).Str("provider", p.Name()).Msg("could not install agent hooks") + logger.Warn().Err(err).Str("harness", p.Name()).Msg("could not install harness hooks") continue } - logger.Debug().Str("provider", p.Name()).Msg("agent hooks installed") + + logger.Debug().Str("harness", p.Name()).Msg("harness hooks installed") + installed = append(installed, p) + } + + // Only what installed is reported: a harness whose hooks failed records + // nothing, and naming it would claim otherwise and offer a file that was + // never written for committing. + if len(installed) == 0 { + return fmt.Errorf("no harness hooks could be installed, so no sessions would be recorded") + } + + installedNames := make([]string, 0, len(installed)) + for _, p := range installed { + installedNames = append(installedNames, p.Name()) + } + + workDir, err := os.Getwd() + if err != nil { + // Only used to render paths relative to where the user is; the + // repository root still names them correctly. + workDir = repoRoot } // What the run produced, and what to do with it. Every step above logs // at debug, so this is what the user is left with. - writeTraceInitSummary(os.Stdout, cfg, selected) - writeTraceNextSteps(os.Stdout, repoRoot, selectedProviders) + writeTraceInitSummary(os.Stdout, cfg, installedNames) + writeTraceNextSteps(os.Stdout, repoRoot, workDir, installed) return nil }, @@ -187,38 +209,53 @@ func writeTraceInitSummary(w io.Writer, cfg *traceInitConfig, harnesses []string } // writeTraceNextSteps says what is left for the user to do. Committing comes -// first: the hooks and the configuration live in the repository, so a teammate -// who pulls them is traced without running init themselves, and until then this -// only applies to the one working copy. -func writeTraceNextSteps(w io.Writer, repoRoot string, selected []trace.Provider) { - files := []string{config.ChainloopYMLName(repoRoot)} - names := make([]string, 0, len(selected)) - - for _, p := range selected { - names = append(names, p.Name()) - - // A path outside the repository cannot be committed, so it is not worth - // naming here. - if rel, err := filepath.Rel(repoRoot, p.SettingsFile(repoRoot)); err == nil && !strings.HasPrefix(rel, "..") { +// first: what init wrote into the repository is shared, and until it is +// committed this setup exists in one working copy only. +// +// It stops short of promising that a teammate who pulls is set up: the git +// hooks live in .git/hooks, which git does not carry between clones, so they +// still have to run init themselves. What they gain is having nothing to answer +// when they do. +// +// workDir is where the user ran the command, which is what `git add` resolves +// its arguments against; it is not always the repository root. +func writeTraceNextSteps(w io.Writer, repoRoot, workDir string, installed []trace.Provider) { + files := make([]string, 0, len(installed)+1) + add := func(absolute string) { + // An absolute path stages just as well, so it is the fallback for the + // rare case where no relative one exists. + if rel, err := filepath.Rel(workDir, absolute); err == nil { files = append(files, rel) + return } + + files = append(files, absolute) + } + + add(filepath.Join(repoRoot, config.ChainloopYMLName(repoRoot))) + + names := make([]string, 0, len(installed)) + for _, p := range installed { + names = append(names, p.Name()) + add(p.SettingsFile(repoRoot)) } fmt.Fprintf(w, ` What's next - 1. Commit these files. Anyone who pulls them is set up automatically: + 1. Commit these files. Teammates then only need to run chainloop trace init: git add %s 2. Start %s and write some code - 3. Commit and push as usual. Your AI coding sessions are recorded and stored automatically + 3. Commit and push as usual. The AI coding sessions behind those commits are + recorded and stored automatically Learn more: %s -`, strings.Join(files, " "), joinNames(names), traceDocsURL) +`, strings.Join(files, " "), joinWithOr(names), traceDocsURL) } -// joinNames renders a list the way a sentence needs it: "a", "a or b", or +// joinWithOr renders a list the way a sentence needs it: "a", "a or b", or // "a, b or c". -func joinNames(names []string) string { +func joinWithOr(names []string) string { switch len(names) { case 0: return "" diff --git a/app/cli/cmd/trace_init_test.go b/app/cli/cmd/trace_init_test.go index 52f6ee959..75d59d161 100644 --- a/app/cli/cmd/trace_init_test.go +++ b/app/cli/cmd/trace_init_test.go @@ -44,8 +44,9 @@ func traceInitTestCmd(t *testing.T, args ...string) *cobra.Command { // defaultTraceWorkflow is the workflow name trace init falls back to. const defaultTraceWorkflow = "ai-coding-session" -// The files trace init leaves in the repository, which the closing message has -// to name so they get committed. +// The files trace init leaves in the repository: the config it writes and the +// harness hooks it installs. The closing message has to name them so they get +// committed. const ( chainloopYMLName = ".chainloop.yml" claudeSettings = ".claude/settings.json" @@ -183,45 +184,67 @@ func TestWriteTraceNextSteps(t *testing.T) { providers []string // wantFiles are the paths the git add line must carry wantFiles []string - // wantAgents is how the agents are named in the sentence - wantAgents string + // wantHarnesses is how the harnesses are named in the sentence + wantHarnesses string }{ { - name: "one agent", - providers: []string{providerClaudeCode}, - wantFiles: []string{chainloopYMLName, claudeSettings}, - wantAgents: providerClaudeCode, + name: "one harness", + providers: []string{providerClaudeCode}, + wantFiles: []string{chainloopYMLName, claudeSettings}, + wantHarnesses: providerClaudeCode, }, { - name: "two agents", - providers: []string{providerClaudeCode, "cursor"}, - wantFiles: []string{chainloopYMLName, claudeSettings, ".cursor/hooks.json"}, - wantAgents: "claude-code or cursor", + name: "two harnesses", + providers: []string{providerClaudeCode, "cursor"}, + wantFiles: []string{chainloopYMLName, claudeSettings, ".cursor/hooks.json"}, + wantHarnesses: "claude-code or cursor", }, { - name: "every agent", - providers: []string{providerClaudeCode, "cursor", "opencode"}, - wantFiles: []string{chainloopYMLName, claudeSettings, ".cursor/hooks.json", ".opencode"}, - wantAgents: "claude-code, cursor or opencode", + name: "every harness", + providers: []string{providerClaudeCode, "cursor", "opencode"}, + // Named in full: ".opencode" alone would pass on any file under that + // directory, so it would not catch the plugin being renamed. + wantFiles: []string{ + chainloopYMLName, claudeSettings, ".cursor/hooks.json", + ".opencode/plugins/chainloop-trace.ts", + }, + wantHarnesses: "claude-code, cursor or opencode", }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { out := &bytes.Buffer{} - writeTraceNextSteps(out, t.TempDir(), providers.ByNames(tc.providers)) + repoRoot := t.TempDir() + writeTraceNextSteps(out, repoRoot, repoRoot, providers.ByNames(tc.providers)) got := out.String() for _, f := range tc.wantFiles { assert.Contains(t, got, f, "the file has to be committed, so it has to be named") } - assert.Contains(t, got, tc.wantAgents) + assert.Contains(t, got, tc.wantHarnesses) assert.Contains(t, got, traceDocsURL) }) } } +// TestWriteTraceNextStepsFromSubdirectory pins down that the git add line works +// where it is printed. Its arguments are resolved against the working +// directory, so repository-root paths would stage nothing from a subdirectory. +func TestWriteTraceNextStepsFromSubdirectory(t *testing.T) { + repoRoot := t.TempDir() + workDir := filepath.Join(repoRoot, "services", "api") + require.NoError(t, os.MkdirAll(workDir, 0750)) + + out := &bytes.Buffer{} + writeTraceNextSteps(out, repoRoot, workDir, providers.ByNames([]string{providerClaudeCode})) + + got := out.String() + assert.Contains(t, got, filepath.Join("..", "..", chainloopYMLName)) + assert.Contains(t, got, filepath.Join("..", "..", claudeSettings)) +} + func TestWriteTraceInitSummary(t *testing.T) { t.Run("every value is reported", func(t *testing.T) { out := &bytes.Buffer{} @@ -250,7 +273,7 @@ func TestWriteTraceInitSummary(t *testing.T) { }) } -func TestJoinNames(t *testing.T) { +func TestJoinWithOr(t *testing.T) { testCases := []struct { names []string want string @@ -263,7 +286,7 @@ func TestJoinNames(t *testing.T) { for _, tc := range testCases { t.Run(tc.want, func(t *testing.T) { - assert.Equal(t, tc.want, joinNames(tc.names)) + assert.Equal(t, tc.want, joinWithOr(tc.names)) }) } } diff --git a/app/cli/internal/trace/config/config.go b/app/cli/internal/trace/config/config.go index b426e9181..fe86839ef 100644 --- a/app/cli/internal/trace/config/config.go +++ b/app/cli/internal/trace/config/config.go @@ -340,9 +340,10 @@ func findChainloopYMLAny(dir string) *ChainloopYML { return nil } -// ChainloopYMLName returns the name of the Chainloop file in dir: the one that -// is there when either spelling exists, and the default otherwise. Callers use -// it to name the file, for instance when asking the user to commit it. +// ChainloopYMLName returns the base name of the Chainloop config file in dir: +// whichever of the two spellings is already there, or .chainloop.yml when +// neither is. Callers use it to name the file, for instance when asking the +// user to commit it. func ChainloopYMLName(dir string) string { return filepath.Base(resolveChainloopYMLPath(dir)) } From f2542cbef7527e59f0f762f7e0fdae99c47e3d05 Mon Sep 17 00:00:00 2001 From: Miguel Martinez Trivino Date: Wed, 9 Sep 2026 19:43:28 +0200 Subject: [PATCH 16/30] fix(cli): report the organization actually used and install harness hooks first Non-interactive init with no organization in .chainloop.yml targets the one the CLI points at, but the summary left the line out entirely. It reports that organization now, which is what the connection used rather than a guess. The check for a run where no harness hooks installed ran after the configuration, the git hooks and the initialized marker had already been written, leaving a repository that looked set up and would record nothing. Installing the harness hooks is now the first thing left behind, so failing there writes none of the rest. Assisted-by: Claude Code Signed-off-by: Miguel Martinez Trivino Chainloop-Trace-Sessions: 184822bb-e811-42fd-8e13-735227edc5ce --- app/cli/cmd/trace_init.go | 83 ++++++++++++++++++---------------- app/cli/cmd/trace_init_test.go | 14 ++---- 2 files changed, 49 insertions(+), 48 deletions(-) diff --git a/app/cli/cmd/trace_init.go b/app/cli/cmd/trace_init.go index e3b09cc94..8eeb10f8b 100644 --- a/app/cli/cmd/trace_init.go +++ b/app/cli/cmd/trace_init.go @@ -16,6 +16,7 @@ package cmd import ( + "cmp" "context" "fmt" "io" @@ -31,6 +32,7 @@ import ( "github.com/chainloop-dev/chainloop/app/cli/internal/trace/state" "github.com/chainloop-dev/chainloop/app/cli/pkg/action" "github.com/spf13/cobra" + "github.com/spf13/viper" ) // newTraceInitCmd creates the trace init subcommand. @@ -106,8 +108,36 @@ The organization, project, workflow and require-trace values are saved to return err } - // The workflow exists: from here on it is safe to leave hooks and - // configuration behind. + // The harness hooks come first of everything that is left behind. + // They are what records a session, so a run where none of them lands + // has achieved nothing, and stopping here leaves the configuration, + // the git hooks and the initialized marker unwritten rather than + // leaving a repository that looks set up and records nothing. + // + // Pre-push infers the owning harness per-session from the recorded + // SessionRecord, so the list isn't persisted anywhere — only each + // harness's own hook config file, written here, determines which of + // them can register sessions. + installed := make([]trace.Provider, 0, len(selectedProviders)) + installedNames := make([]string, 0, len(selectedProviders)) + + for _, p := range selectedProviders { + if err := p.InstallHooks(repoRoot); err != nil { + logger.Warn().Err(err).Str("harness", p.Name()).Msg("could not install harness hooks") + continue + } + + logger.Debug().Str("harness", p.Name()).Msg("harness hooks installed") + installed = append(installed, p) + installedNames = append(installedNames, p.Name()) + } + + if len(installed) == 0 { + return fmt.Errorf("no harness hooks could be installed, so no sessions would be recorded") + } + + // The workflow exists and something records into it: from here on it + // is safe to leave hooks and configuration behind. if err := cfg.save(repoRoot); err != nil { return err } @@ -132,34 +162,6 @@ The organization, project, workflow and require-trace values are saved to return fmt.Errorf("mark trace initialized: %w", err) } - // Install the agent-side hooks for the providers resolved above. - // Pre-push infers the owning provider per-session from the recorded - // SessionRecord, so the list isn't persisted anywhere — only each - // agent's own hook config file, written here, determines which - // providers can register sessions. - installed := make([]trace.Provider, 0, len(selectedProviders)) - for _, p := range selectedProviders { - if err := p.InstallHooks(repoRoot); err != nil { - logger.Warn().Err(err).Str("harness", p.Name()).Msg("could not install harness hooks") - continue - } - - logger.Debug().Str("harness", p.Name()).Msg("harness hooks installed") - installed = append(installed, p) - } - - // Only what installed is reported: a harness whose hooks failed records - // nothing, and naming it would claim otherwise and offer a file that was - // never written for committing. - if len(installed) == 0 { - return fmt.Errorf("no harness hooks could be installed, so no sessions would be recorded") - } - - installedNames := make([]string, 0, len(installed)) - for _, p := range installed { - installedNames = append(installedNames, p.Name()) - } - workDir, err := os.Getwd() if err != nil { // Only used to render paths relative to where the user is; the @@ -167,9 +169,14 @@ The organization, project, workflow and require-trace values are saved to workDir = repoRoot } + // Nothing pinned an organization when cfg has none, so the workflow + // went to the one the CLI points at. That is the one to report: it is + // what the connection used, not a guess. + organization := cmp.Or(cfg.organization, viper.GetString(confOptions.organization.viperKey)) + // What the run produced, and what to do with it. Every step above logs // at debug, so this is what the user is left with. - writeTraceInitSummary(os.Stdout, cfg, installedNames) + writeTraceInitSummary(os.Stdout, organization, cfg.project, cfg.workflow, installedNames) writeTraceNextSteps(os.Stdout, repoRoot, workDir, installed) return nil @@ -194,17 +201,17 @@ const traceDocsURL = "https://docs.chainloop.dev/guides/chainloop-trace" // writeTraceInitSummary reports what the repository was set up with. It goes to // stdout rather than through the logger because it is the command's result, // not a note about something that happened on the way there. -func writeTraceInitSummary(w io.Writer, cfg *traceInitConfig, harnesses []string) { +func writeTraceInitSummary(w io.Writer, organization, project, workflow string, harnesses []string) { fmt.Fprint(w, "\nCongratulations, your repository is initialized\n\n") - // The organization is absent when nothing pinned one and the CLI's own - // default was used, in which case naming it here would be a guess. - if cfg.organization != "" { - fmt.Fprintf(w, " organization %s\n", cfg.organization) + // Empty only when the CLI has no organization configured either, which + // leaves nothing truthful to name. + if organization != "" { + fmt.Fprintf(w, " organization %s\n", organization) } - fmt.Fprintf(w, " project %s\n", cfg.project) - fmt.Fprintf(w, " workflow %s\n", cfg.workflow) + fmt.Fprintf(w, " project %s\n", project) + fmt.Fprintf(w, " workflow %s\n", workflow) fmt.Fprintf(w, " harnesses %s\n", strings.Join(harnesses, ", ")) } diff --git a/app/cli/cmd/trace_init_test.go b/app/cli/cmd/trace_init_test.go index 75d59d161..d0f166057 100644 --- a/app/cli/cmd/trace_init_test.go +++ b/app/cli/cmd/trace_init_test.go @@ -248,11 +248,8 @@ func TestWriteTraceNextStepsFromSubdirectory(t *testing.T) { func TestWriteTraceInitSummary(t *testing.T) { t.Run("every value is reported", func(t *testing.T) { out := &bytes.Buffer{} - writeTraceInitSummary(out, &traceInitConfig{ - organization: "acme", - project: "backend-api", - workflow: defaultTraceWorkflow, - }, []string{"claude-code", "cursor"}) + writeTraceInitSummary(out, "acme", "backend-api", defaultTraceWorkflow, + []string{providerClaudeCode, "cursor"}) got := out.String() assert.Contains(t, got, "your repository is initialized") @@ -262,12 +259,9 @@ func TestWriteTraceInitSummary(t *testing.T) { assert.Contains(t, got, "claude-code, cursor") }) - t.Run("an organization nothing pinned is left out rather than guessed at", func(t *testing.T) { + t.Run("no organization anywhere leaves the line out rather than guessing", func(t *testing.T) { out := &bytes.Buffer{} - writeTraceInitSummary(out, &traceInitConfig{ - project: "backend-api", - workflow: defaultTraceWorkflow, - }, []string{"claude-code"}) + writeTraceInitSummary(out, "", "backend-api", defaultTraceWorkflow, []string{providerClaudeCode}) assert.NotContains(t, out.String(), "organization") }) From 16d8edcaa8cef03689ddacffcb192e4f3cddbb0b Mon Sep 17 00:00:00 2001 From: Miguel Martinez Trivino Date: Wed, 9 Sep 2026 19:53:15 +0200 Subject: [PATCH 17/30] feat: report whether the caller can create a project in the listing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Creating a project is a permission of the organization role, and the contributor role does not carry it. The picker offered "Create a new project…" to everyone, so a contributor who chose it typed a name and was told they are not allowed to create projects, which is the dead end this command exists to remove. It is not derivable from the projects themselves either: a contributor can administer every project it can see and still create none. ProjectService.List now reports it alongside the listing, resolved by the same check the create call enforces, with a test pinning the two together so they cannot drift apart. It sits on the response rather than on the items because it describes the caller, not a project. The picker offers the create entry only when it would work. A caller who can neither write to a visible project nor create one is told so, instead of being asked for a name that would be refused. Assisted-by: Claude Code Signed-off-by: Miguel Martinez Trivino Chainloop-Trace-Sessions: 184822bb-e811-42fd-8e13-735227edc5ce --- app/cli/cmd/trace_init_identity.go | 23 ++++++- app/cli/cmd/trace_init_identity_test.go | 53 +++++++++++++-- app/cli/pkg/action/trace_identity.go | 56 +++++++++++----- app/cli/pkg/action/trace_identity_test.go | 22 ++++--- .../api/controlplane/v1/project.pb.go | 24 +++++-- .../api/controlplane/v1/project.proto | 6 ++ .../gen/frontend/controlplane/v1/project.ts | 23 ++++++- ...ProjectServiceListResponse.jsonschema.json | 10 +++ ....v1.ProjectServiceListResponse.schema.json | 10 +++ app/controlplane/internal/service/project.go | 12 +++- .../service/project_permissions_test.go | 65 +++++++++++++++++++ app/controlplane/internal/service/service.go | 29 ++++++--- 12 files changed, 284 insertions(+), 49 deletions(-) diff --git a/app/cli/cmd/trace_init_identity.go b/app/cli/cmd/trace_init_identity.go index 2436c14f9..78e2ed038 100644 --- a/app/cli/cmd/trace_init_identity.go +++ b/app/cli/cmd/trace_init_identity.go @@ -82,7 +82,7 @@ type orgLister interface { // projectLister lists the projects visible in the selected organization, and // is likewise the slice of the executor the project resolver needs. type projectLister interface { - ListProjects(ctx context.Context) ([]*action.TraceProject, error) + ListProjects(ctx context.Context) (*action.TraceProjects, error) } // prompter asks the user to pick from a list or type a value. It keeps the @@ -195,11 +195,13 @@ func resolveInteractiveOrganization(ctx context.Context, lister orgLister, p pro // collects a name for a new one. fromYML is what .chainloop.yml pins and // repoDir the repository's directory name, used to seed a new name. func resolveInteractiveProject(ctx context.Context, lister projectLister, p prompter, fromYML, repoDir string) (*resolvedValue, error) { - visible, err := lister.ListProjects(ctx) + listing, err := lister.ListProjects(ctx) if err != nil { return nil, fmt.Errorf("listing the projects you can see: %w", err) } + visible, canCreate := listing.Projects, listing.CanCreateProject + // Only the projects the caller can add a workflow to are worth offering; // picking any other one would fail at creation time. The rest are still // worth knowing about, to refuse one that gets typed in by name. @@ -241,6 +243,23 @@ func resolveInteractiveProject(ctx context.Context, lister projectLister, p prom } } + // Creating one is a permission of the organization role, not of any project: + // an organization contributor administering every project it can see still + // cannot create another. Offering the option anyway would fail on the name + // they just typed. + if !canCreate { + if len(writable) == 0 { + return nil, errors.New("you cannot create projects in this organization, and none of the projects you can see accepts a new workflow; ask an administrator for access to one") + } + + chosen, err := p.Select(projectPromptTitle, writable, firstPresent(writable, fromYML)) + if err != nil { + return nil, err + } + + return &resolvedValue{value: chosen, save: chosen != fromYML, prompted: true}, nil + } + // Nothing to choose from, so go straight to naming one rather than showing a // list holding nothing but the create entry. if len(writable) == 0 { diff --git a/app/cli/cmd/trace_init_identity_test.go b/app/cli/cmd/trace_init_identity_test.go index 79bd42cab..f65625507 100644 --- a/app/cli/cmd/trace_init_identity_test.go +++ b/app/cli/cmd/trace_init_identity_test.go @@ -39,14 +39,16 @@ func (f *fakeOrgLister) ListOrganizations(context.Context) ([]*action.Membership } // fakeProjectLister serves a canned listing. projects are the ones the caller -// may create a workflow in, readOnly the ones it can only see. +// may create a workflow in, readOnly the ones it can only see. cannotCreate +// inverts the default so the common case stays untyped in the table. type fakeProjectLister struct { - projects []string - readOnly []string - err error + projects []string + readOnly []string + cannotCreate bool + err error } -func (f *fakeProjectLister) ListProjects(context.Context) ([]*action.TraceProject, error) { +func (f *fakeProjectLister) ListProjects(context.Context) (*action.TraceProjects, error) { if f.err != nil { return nil, f.err } @@ -60,7 +62,7 @@ func (f *fakeProjectLister) ListProjects(context.Context) ([]*action.TraceProjec out = append(out, &action.TraceProject{Name: name}) } - return out, nil + return &action.TraceProjects{Projects: out, CanCreateProject: !f.cannotCreate}, nil } // recordedPrompt captures what a prompt was shown, so tests can assert on the @@ -346,6 +348,8 @@ func TestResolveInteractiveProject(t *testing.T) { repoDir string // readOnly are projects the caller can see but not create a workflow in readOnly []string + // cannotCreate is the organization role refusing new projects + cannotCreate bool // selectAnswer is what the user picks in the list; empty means the list // is not expected to be shown selectAnswer string @@ -526,13 +530,48 @@ func TestResolveInteractiveProject(t *testing.T) { inputAnswer: "payments", wantErr: "you can only view the project", }, + { + // Creating is an organization-role permission, so a contributor that + // administers projects still cannot have the option: it would fail on + // whatever name they typed. + name: "a caller who cannot create projects is not offered the option", + projects: []string{projectAPI, "payments"}, + cannotCreate: true, + repoDir: repoDirMyRepo, + selectAnswer: projectAPI, + wantValue: projectAPI, + wantSave: true, + wantOptions: []string{projectAPI, "payments"}, + wantSelectDefault: projectAPI, + }, + { + name: "a pinned project is still preselected without the create option", + projects: []string{projectAPI, "payments"}, + cannotCreate: true, + fromYML: "payments", + repoDir: repoDirMyRepo, + selectAnswer: "payments", + wantValue: "payments", + wantSave: false, + wantOptions: []string{projectAPI, "payments"}, + wantSelectDefault: "payments", + }, + { + // Nothing to pick and nothing to create is a dead end, so it says so + // instead of asking for a name the control plane would refuse. + name: "no writable project and no permission to create says so", + readOnly: []string{"payments"}, + cannotCreate: true, + repoDir: repoDirMyRepo, + wantErr: "you cannot create projects in this organization", + }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { p := &fakePrompter{selectAnswer: tc.selectAnswer, inputAnswer: tc.inputAnswer} got, err := resolveInteractiveProject(context.Background(), - &fakeProjectLister{projects: tc.projects, readOnly: tc.readOnly}, p, tc.fromYML, tc.repoDir) + &fakeProjectLister{projects: tc.projects, readOnly: tc.readOnly, cannotCreate: tc.cannotCreate}, p, tc.fromYML, tc.repoDir) if tc.wantErr != "" { require.Error(t, err) diff --git a/app/cli/pkg/action/trace_identity.go b/app/cli/pkg/action/trace_identity.go index 5e3097274..de82edbbd 100644 --- a/app/cli/pkg/action/trace_identity.go +++ b/app/cli/pkg/action/trace_identity.go @@ -39,10 +39,20 @@ func (e *AttestationExecutor) ListOrganizations(ctx context.Context) ([]*Members // pinned to, sorted by name and deduplicated. It runs over the executor's // connection, so it targets the organization pinned with WithForcedOrganization // like every other trace operation. -func (e *AttestationExecutor) ListProjects(ctx context.Context) ([]*TraceProject, error) { +func (e *AttestationExecutor) ListProjects(ctx context.Context) (*TraceProjects, error) { return listAllTraceProjects(ctx, &cpTraceProjectAPI{cfg: e.actionOpts}) } +// TraceProjects is what a project listing offers: the projects themselves and +// whether creating another one is possible at all. +type TraceProjects struct { + Projects []*TraceProject + // CanCreateProject is false when the caller's organization role does not + // allow creating projects, which an organization contributor's does not even + // where it administers every project it can see. + CanCreateProject bool +} + // TraceProject is one entry of the project listing. type TraceProject struct { Name string @@ -55,40 +65,52 @@ type TraceProject struct { // traceProjectAPI is the slice of the control-plane API the project listing // drives, so the paging can be tested without a live control plane. type traceProjectAPI interface { - // listProjectsPage returns one page as the server sent it, plus the total - // number of pages it reports. - listProjectsPage(ctx context.Context, page, pageSize int32) ([]*TraceProject, int32, error) + // listProjectsPage returns one page as the server sent it. + listProjectsPage(ctx context.Context, page, pageSize int32) (*traceProjectPage, error) +} + +// traceProjectPage is one page of the listing. +type traceProjectPage struct { + Projects []*TraceProject + // TotalPages is what the server reports, which is what ends the walk. + TotalPages int32 + // CanCreateProject is a property of the caller rather than of the page, so + // every page carries the same answer. + CanCreateProject bool } // listAllTraceProjects walks every page and returns what the caller can see. // Deciding which of those to offer is left to the caller, which needs to tell a // project it may not write to apart from one that is missing entirely. -func listAllTraceProjects(ctx context.Context, api traceProjectAPI) ([]*TraceProject, error) { - projects := make([]*TraceProject, 0, traceProjectPageSize) +func listAllTraceProjects(ctx context.Context, api traceProjectAPI) (*TraceProjects, error) { + all := &TraceProjects{Projects: make([]*TraceProject, 0, traceProjectPageSize)} for page := int32(1); ; page++ { - got, totalPages, err := api.listProjectsPage(ctx, page, traceProjectPageSize) + got, err := api.listProjectsPage(ctx, page, traceProjectPageSize) if err != nil { return nil, fmt.Errorf("listing projects: %w", err) } - projects = append(projects, got...) + all.Projects = append(all.Projects, got.Projects...) + all.CanCreateProject = got.CanCreateProject // Stop on the last page the server reports, and also on a page the server // returned nothing for, so a server reporting more pages than it serves // cannot spin here. - if len(got) == 0 || page >= totalPages { + if len(got.Projects) == 0 || page >= got.TotalPages { break } } - slices.SortFunc(projects, func(a, b *TraceProject) int { + slices.SortFunc(all.Projects, func(a, b *TraceProject) int { return cmp.Compare(a.Name, b.Name) }) - return slices.CompactFunc(projects, func(a, b *TraceProject) bool { + all.Projects = slices.CompactFunc(all.Projects, func(a, b *TraceProject) bool { return a.Name == b.Name - }), nil + }) + + return all, nil } // cpTraceProjectAPI implements traceProjectAPI against the control plane. @@ -96,14 +118,14 @@ type cpTraceProjectAPI struct { cfg *ActionsOpts } -func (a *cpTraceProjectAPI) listProjectsPage(ctx context.Context, page, pageSize int32) ([]*TraceProject, int32, error) { +func (a *cpTraceProjectAPI) listProjectsPage(ctx context.Context, page, pageSize int32) (*traceProjectPage, error) { client := pb.NewProjectServiceClient(a.cfg.CPConnection) resp, err := client.List(ctx, &pb.ProjectServiceListRequest{ Pagination: &pb.OffsetPaginationRequest{Page: page, PageSize: pageSize}, }) if err != nil { - return nil, 0, err + return nil, err } projects := make([]*TraceProject, 0, len(resp.GetProjects())) @@ -114,5 +136,9 @@ func (a *cpTraceProjectAPI) listProjectsPage(ctx context.Context, page, pageSize }) } - return projects, resp.GetPagination().GetTotalPages(), nil + return &traceProjectPage{ + Projects: projects, + TotalPages: resp.GetPagination().GetTotalPages(), + CanCreateProject: resp.GetCanCreateProject(), + }, nil } diff --git a/app/cli/pkg/action/trace_identity_test.go b/app/cli/pkg/action/trace_identity_test.go index 582a7047e..3779994a4 100644 --- a/app/cli/pkg/action/trace_identity_test.go +++ b/app/cli/pkg/action/trace_identity_test.go @@ -48,16 +48,17 @@ func readOnly(names ...string) []*TraceProject { type fakeProjectPager struct { pages [][]*TraceProject // totalPages is what the server reports; it defaults to len(pages) - totalPages int32 - err error + totalPages int32 + canCreateProject bool + err error requested []int32 } -func (f *fakeProjectPager) listProjectsPage(_ context.Context, page, _ int32) ([]*TraceProject, int32, error) { +func (f *fakeProjectPager) listProjectsPage(_ context.Context, page, _ int32) (*traceProjectPage, error) { f.requested = append(f.requested, page) if f.err != nil { - return nil, 0, f.err + return nil, f.err } total := f.totalPages @@ -65,12 +66,14 @@ func (f *fakeProjectPager) listProjectsPage(_ context.Context, page, _ int32) ([ total = int32(len(f.pages)) } + out := &traceProjectPage{TotalPages: total, CanCreateProject: f.canCreateProject} + idx := int(page) - 1 - if idx < 0 || idx >= len(f.pages) { - return nil, total, nil + if idx >= 0 && idx < len(f.pages) { + out.Projects = f.pages[idx] } - return f.pages[idx], total, nil + return out, nil } // Project names the table cases share. @@ -145,11 +148,12 @@ func TestListAllTraceProjects(t *testing.T) { for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { - pager := &fakeProjectPager{pages: tc.pages, totalPages: tc.totalPages} + pager := &fakeProjectPager{pages: tc.pages, totalPages: tc.totalPages, canCreateProject: true} got, err := listAllTraceProjects(context.Background(), pager) require.NoError(t, err) - assert.Equal(t, tc.want, got) + assert.Equal(t, tc.want, got.Projects) assert.Equal(t, tc.wantRequested, pager.requested) + assert.True(t, got.CanCreateProject, "what the server said about creating must survive the walk") }) } } diff --git a/app/controlplane/api/controlplane/v1/project.pb.go b/app/controlplane/api/controlplane/v1/project.pb.go index 6ce8aa385..0279bd367 100644 --- a/app/controlplane/api/controlplane/v1/project.pb.go +++ b/app/controlplane/api/controlplane/v1/project.pb.go @@ -99,9 +99,15 @@ type ProjectServiceListResponse struct { // List of projects matching the request criteria Projects []*ProjectServiceListResponse_ProjectItem `protobuf:"bytes,1,rep,name=projects,proto3" json:"projects,omitempty"` // Pagination information for the response - Pagination *OffsetPaginationResponse `protobuf:"bytes,2,opt,name=pagination,proto3" json:"pagination,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + Pagination *OffsetPaginationResponse `protobuf:"bytes,2,opt,name=pagination,proto3" json:"pagination,omitempty"` + // Whether the caller may create a project in this organization. It is a + // property of their organization role, not of any project, so it does not + // belong on the items: an organization contributor, for instance, can be an + // administrator of several projects and still create none. Clients offering + // to create one need this to avoid an option that always fails. + CanCreateProject bool `protobuf:"varint,3,opt,name=can_create_project,json=canCreateProject,proto3" json:"can_create_project,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *ProjectServiceListResponse) Reset() { @@ -148,6 +154,13 @@ func (x *ProjectServiceListResponse) GetPagination() *OffsetPaginationResponse { return nil } +func (x *ProjectServiceListResponse) GetCanCreateProject() bool { + if x != nil { + return x.CanCreateProject + } + return false +} + // ProjectServiceListMembersRequest contains the information needed to list members of a project type ProjectServiceListMembersRequest struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -1059,12 +1072,13 @@ const file_controlplane_v1_project_proto_rawDesc = "" + "\n" + "pagination\x18\x02 \x01(\v2(.controlplane.v1.OffsetPaginationRequestR\n" + "paginationB\a\n" + - "\x05_name\"\xb8\x03\n" + + "\x05_name\"\xe6\x03\n" + "\x1aProjectServiceListResponse\x12S\n" + "\bprojects\x18\x01 \x03(\v27.controlplane.v1.ProjectServiceListResponse.ProjectItemR\bprojects\x12I\n" + "\n" + "pagination\x18\x02 \x01(\v2).controlplane.v1.OffsetPaginationResponseR\n" + - "pagination\x1a\xf9\x01\n" + + "pagination\x12,\n" + + "\x12can_create_project\x18\x03 \x01(\bR\x10canCreateProject\x1a\xf9\x01\n" + "\vProjectItem\x12\x0e\n" + "\x02id\x18\x01 \x01(\tR\x02id\x12\x12\n" + "\x04name\x18\x02 \x01(\tR\x04name\x12 \n" + diff --git a/app/controlplane/api/controlplane/v1/project.proto b/app/controlplane/api/controlplane/v1/project.proto index 37946ac9d..df63ef261 100644 --- a/app/controlplane/api/controlplane/v1/project.proto +++ b/app/controlplane/api/controlplane/v1/project.proto @@ -51,6 +51,12 @@ message ProjectServiceListResponse { repeated ProjectItem projects = 1; // Pagination information for the response OffsetPaginationResponse pagination = 2; + // Whether the caller may create a project in this organization. It is a + // property of their organization role, not of any project, so it does not + // belong on the items: an organization contributor, for instance, can be an + // administrator of several projects and still create none. Clients offering + // to create one need this to avoid an option that always fails. + bool can_create_project = 3; // ProjectItem represents a project of the organization message ProjectItem { diff --git a/app/controlplane/api/gen/frontend/controlplane/v1/project.ts b/app/controlplane/api/gen/frontend/controlplane/v1/project.ts index baf5c9ee5..bd634ca62 100644 --- a/app/controlplane/api/gen/frontend/controlplane/v1/project.ts +++ b/app/controlplane/api/gen/frontend/controlplane/v1/project.ts @@ -31,6 +31,14 @@ export interface ProjectServiceListResponse { projects: ProjectServiceListResponse_ProjectItem[]; /** Pagination information for the response */ pagination?: OffsetPaginationResponse; + /** + * Whether the caller may create a project in this organization. It is a + * property of their organization role, not of any project, so it does not + * belong on the items: an organization contributor, for instance, can be an + * administrator of several projects and still create none. Clients offering + * to create one need this to avoid an option that always fails. + */ + canCreateProject: boolean; } /** ProjectItem represents a project of the organization */ @@ -247,7 +255,7 @@ export const ProjectServiceListRequest = { }; function createBaseProjectServiceListResponse(): ProjectServiceListResponse { - return { projects: [], pagination: undefined }; + return { projects: [], pagination: undefined, canCreateProject: false }; } export const ProjectServiceListResponse = { @@ -258,6 +266,9 @@ export const ProjectServiceListResponse = { if (message.pagination !== undefined) { OffsetPaginationResponse.encode(message.pagination, writer.uint32(18).fork()).ldelim(); } + if (message.canCreateProject === true) { + writer.uint32(24).bool(message.canCreateProject); + } return writer; }, @@ -282,6 +293,13 @@ export const ProjectServiceListResponse = { message.pagination = OffsetPaginationResponse.decode(reader, reader.uint32()); continue; + case 3: + if (tag !== 24) { + break; + } + + message.canCreateProject = reader.bool(); + continue; } if ((tag & 7) === 4 || tag === 0) { break; @@ -297,6 +315,7 @@ export const ProjectServiceListResponse = { ? object.projects.map((e: any) => ProjectServiceListResponse_ProjectItem.fromJSON(e)) : [], pagination: isSet(object.pagination) ? OffsetPaginationResponse.fromJSON(object.pagination) : undefined, + canCreateProject: isSet(object.canCreateProject) ? Boolean(object.canCreateProject) : false, }; }, @@ -309,6 +328,7 @@ export const ProjectServiceListResponse = { } message.pagination !== undefined && (obj.pagination = message.pagination ? OffsetPaginationResponse.toJSON(message.pagination) : undefined); + message.canCreateProject !== undefined && (obj.canCreateProject = message.canCreateProject); return obj; }, @@ -322,6 +342,7 @@ export const ProjectServiceListResponse = { message.pagination = (object.pagination !== undefined && object.pagination !== null) ? OffsetPaginationResponse.fromPartial(object.pagination) : undefined; + message.canCreateProject = object.canCreateProject ?? false; return message; }, }; diff --git a/app/controlplane/api/gen/jsonschema/controlplane.v1.ProjectServiceListResponse.jsonschema.json b/app/controlplane/api/gen/jsonschema/controlplane.v1.ProjectServiceListResponse.jsonschema.json index dfe138859..1e6c86acb 100644 --- a/app/controlplane/api/gen/jsonschema/controlplane.v1.ProjectServiceListResponse.jsonschema.json +++ b/app/controlplane/api/gen/jsonschema/controlplane.v1.ProjectServiceListResponse.jsonschema.json @@ -3,7 +3,17 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "additionalProperties": false, "description": "ProjectServiceListResponse contains a paginated list of the organization's projects", + "patternProperties": { + "^(can_create_project)$": { + "description": "Whether the caller may create a project in this organization. It is a\n property of their organization role, not of any project, so it does not\n belong on the items: an organization contributor, for instance, can be an\n administrator of several projects and still create none. Clients offering\n to create one need this to avoid an option that always fails.", + "type": "boolean" + } + }, "properties": { + "canCreateProject": { + "description": "Whether the caller may create a project in this organization. It is a\n property of their organization role, not of any project, so it does not\n belong on the items: an organization contributor, for instance, can be an\n administrator of several projects and still create none. Clients offering\n to create one need this to avoid an option that always fails.", + "type": "boolean" + }, "pagination": { "$ref": "controlplane.v1.OffsetPaginationResponse.jsonschema.json", "description": "Pagination information for the response" diff --git a/app/controlplane/api/gen/jsonschema/controlplane.v1.ProjectServiceListResponse.schema.json b/app/controlplane/api/gen/jsonschema/controlplane.v1.ProjectServiceListResponse.schema.json index 08a58b94a..423753995 100644 --- a/app/controlplane/api/gen/jsonschema/controlplane.v1.ProjectServiceListResponse.schema.json +++ b/app/controlplane/api/gen/jsonschema/controlplane.v1.ProjectServiceListResponse.schema.json @@ -3,7 +3,17 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "additionalProperties": false, "description": "ProjectServiceListResponse contains a paginated list of the organization's projects", + "patternProperties": { + "^(canCreateProject)$": { + "description": "Whether the caller may create a project in this organization. It is a\n property of their organization role, not of any project, so it does not\n belong on the items: an organization contributor, for instance, can be an\n administrator of several projects and still create none. Clients offering\n to create one need this to avoid an option that always fails.", + "type": "boolean" + } + }, "properties": { + "can_create_project": { + "description": "Whether the caller may create a project in this organization. It is a\n property of their organization role, not of any project, so it does not\n belong on the items: an organization contributor, for instance, can be an\n administrator of several projects and still create none. Clients offering\n to create one need this to avoid an option that always fails.", + "type": "boolean" + }, "pagination": { "$ref": "controlplane.v1.OffsetPaginationResponse.schema.json", "description": "Pagination information for the response" diff --git a/app/controlplane/internal/service/project.go b/app/controlplane/internal/service/project.go index eee57b6de..bfe8be24f 100644 --- a/app/controlplane/internal/service/project.go +++ b/app/controlplane/internal/service/project.go @@ -74,6 +74,13 @@ func (s *ProjectService) List(ctx context.Context, req *pb.ProjectServiceListReq return nil, handleUseCaseErr(err, s.log) } + // Whether a new project is an option at all depends on the organization role + // rather than on any project, so it is answered once for the listing. + canCreateProject, err := s.canCreateProject(ctx) + if err != nil { + return nil, handleUseCaseErr(err, s.log) + } + result := make([]*pb.ProjectServiceListResponse_ProjectItem, 0, len(projects)) for _, p := range projects { item := bizProjectToPb(p) @@ -82,8 +89,9 @@ func (s *ProjectService) List(ctx context.Context, req *pb.ProjectServiceListReq } return &pb.ProjectServiceListResponse{ - Projects: result, - Pagination: paginationToPb(total, paginationOpts.Offset(), paginationOpts.Limit()), + Projects: result, + Pagination: paginationToPb(total, paginationOpts.Offset(), paginationOpts.Limit()), + CanCreateProject: canCreateProject, }, nil } diff --git a/app/controlplane/internal/service/project_permissions_test.go b/app/controlplane/internal/service/project_permissions_test.go index eaed74d84..6668a7c60 100644 --- a/app/controlplane/internal/service/project_permissions_test.go +++ b/app/controlplane/internal/service/project_permissions_test.go @@ -131,6 +131,71 @@ func TestProjectsAllowing(t *testing.T) { } } +// TestCanCreateProject covers whether a listing may offer creating a project. +// The organization role decides it on its own, so an administrator of every +// project in the organization can still be unable to create one. +func TestCanCreateProject(t *testing.T) { + testCases := []struct { + name string + ctx context.Context + want bool + }{ + { + // An organization member is the role that owns project creation. + name: "an organization member may create projects", + ctx: rbacContext(), + want: true, + }, + { + // A contributor inherits the member's workflow permissions but not + // project creation, so offering it would always fail. + name: "an organization contributor may not, whatever it administers", + ctx: entities.WithMembership( + usercontext.WithAuthzSubject(context.Background(), string(authz.RoleOrgContributor)), + &entities.Membership{Resources: []*entities.ResourceMembership{ + projectMembership(uuid.New(), authz.RoleProjectAdmin), + }}), + want: false, + }, + { + name: "an organization admin bypasses RBAC and may", + ctx: usercontext.WithAuthzSubject(context.Background(), string(authz.RoleAdmin)), + want: true, + }, + } + + s := newTestService(t) + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + got, err := s.canCreateProject(tc.ctx) + require.NoError(t, err) + assert.Equal(t, tc.want, got) + }) + } +} + +// TestCanCreateProjectMatchesEnforcement pins the reported answer to the one the +// create call enforces. They are read by a client as the same question, so they +// must not drift apart. +func TestCanCreateProjectMatchesEnforcement(t *testing.T) { + s := newTestService(t) + + for _, role := range []authz.Role{authz.RoleOrgMember, authz.RoleOrgContributor, authz.RoleAdmin, authz.RoleViewer} { + t.Run(string(role), func(t *testing.T) { + ctx := entities.WithMembership( + usercontext.WithAuthzSubject(context.Background(), string(role)), + &entities.Membership{}) + + reported, err := s.canCreateProject(ctx) + require.NoError(t, err) + + assert.Equal(t, reported, s.userCanCreateProject(ctx) == nil, + "the listing reported %v, which the create call does not agree with", reported) + }) + } +} + // TestProjectsAllowingWithoutRBAC covers the organization roles RBAC does not // narrow. Whether they may create a workflow is decided by the role alone, so // it is the same answer for every project: all of them, or none. diff --git a/app/controlplane/internal/service/service.go b/app/controlplane/internal/service/service.go index 80a78bd0c..cb2761bb2 100644 --- a/app/controlplane/internal/service/service.go +++ b/app/controlplane/internal/service/service.go @@ -350,29 +350,42 @@ func (s *service) userHasPermissionOnProject(ctx context.Context, orgID string, } func (s *service) userCanCreateProject(ctx context.Context) error { + pass, err := s.canCreateProject(ctx) + if err != nil { + return err + } + + if !pass { + return errors.Forbidden("unauthorized", "you are not allowed to create projects") + } + + return nil +} + +// canCreateProject reports what userCanCreateProject enforces, so a listing can +// tell a client whether creating one is worth offering. The two must answer +// alike: an option that the create call then refuses is the dead end the answer +// exists to avoid. +func (s *service) canCreateProject(ctx context.Context) (bool, error) { // admins always can create projects if !rbacEnabled(ctx) { - return nil + return true, nil } // Only org tokens can create projects if token := entities.CurrentAPIToken(ctx); token != nil { if token.ProjectID != nil { - return errors.Forbidden("unauthorized", "you are not allowed to create projects") + return false, nil } } orgRole := usercontext.CurrentAuthzSubject(ctx) pass, err := s.authz.Enforce(ctx, orgRole, authz.PolicyProjectCreate) if err != nil { - return handleUseCaseErr(err, s.log) - } - - if !pass { - return errors.Forbidden("unauthorized", "you are not allowed to create projects") + return false, handleUseCaseErr(err, s.log) } - return nil + return pass, nil } // visibleProjects returns projects where the user has any role (currently ProjectAdmin and ProjectViewer) From 9ce79dde3a58a8cab22524251d80378d98b14131 Mon Sep 17 00:00:00 2001 From: Miguel Martinez Trivino Date: Wed, 9 Sep 2026 22:20:20 +0200 Subject: [PATCH 18/30] refactor: fold the create-project permission into the paths it gates Reporting whether a project can be created added a branch that repeated the picker's own tail, a second place naming the installed harnesses, and a second list built beside the one it duplicated. The permission now reads as a condition on the paths already there rather than as a path of its own. canCreateProject returns its error raw, the way the checks beside it do. Its callers already convert at the boundary, so wrapping it here logged an infrastructure failure twice and masked it once. Assisted-by: Claude Code Signed-off-by: Miguel Martinez Trivino Chainloop-Trace-Sessions: 184822bb-e811-42fd-8e13-735227edc5ce --- app/cli/cmd/trace_init.go | 18 +++++++---- app/cli/cmd/trace_init_identity.go | 32 +++++++++----------- app/cli/pkg/action/trace_identity.go | 14 +++++---- app/controlplane/internal/service/service.go | 16 +++------- 4 files changed, 39 insertions(+), 41 deletions(-) diff --git a/app/cli/cmd/trace_init.go b/app/cli/cmd/trace_init.go index 8eeb10f8b..d82f46f04 100644 --- a/app/cli/cmd/trace_init.go +++ b/app/cli/cmd/trace_init.go @@ -119,7 +119,6 @@ The organization, project, workflow and require-trace values are saved to // harness's own hook config file, written here, determines which of // them can register sessions. installed := make([]trace.Provider, 0, len(selectedProviders)) - installedNames := make([]string, 0, len(selectedProviders)) for _, p := range selectedProviders { if err := p.InstallHooks(repoRoot); err != nil { @@ -129,7 +128,6 @@ The organization, project, workflow and require-trace values are saved to logger.Debug().Str("harness", p.Name()).Msg("harness hooks installed") installed = append(installed, p) - installedNames = append(installedNames, p.Name()) } if len(installed) == 0 { @@ -176,7 +174,7 @@ The organization, project, workflow and require-trace values are saved to // What the run produced, and what to do with it. Every step above logs // at debug, so this is what the user is left with. - writeTraceInitSummary(os.Stdout, organization, cfg.project, cfg.workflow, installedNames) + writeTraceInitSummary(os.Stdout, organization, cfg.project, cfg.workflow, providerNames(installed)) writeTraceNextSteps(os.Stdout, repoRoot, workDir, installed) return nil @@ -241,9 +239,7 @@ func writeTraceNextSteps(w io.Writer, repoRoot, workDir string, installed []trac add(filepath.Join(repoRoot, config.ChainloopYMLName(repoRoot))) - names := make([]string, 0, len(installed)) for _, p := range installed { - names = append(names, p.Name()) add(p.SettingsFile(repoRoot)) } @@ -257,7 +253,17 @@ What's next recorded and stored automatically Learn more: %s -`, strings.Join(files, " "), joinWithOr(names), traceDocsURL) +`, strings.Join(files, " "), joinWithOr(providerNames(installed)), traceDocsURL) +} + +// providerNames names the harnesses, in the order they were installed. +func providerNames(installed []trace.Provider) []string { + names := make([]string, 0, len(installed)) + for _, p := range installed { + names = append(names, p.Name()) + } + + return names } // joinWithOr renders a list the way a sentence needs it: "a", "a or b", or diff --git a/app/cli/cmd/trace_init_identity.go b/app/cli/cmd/trace_init_identity.go index 78e2ed038..a37f96841 100644 --- a/app/cli/cmd/trace_init_identity.go +++ b/app/cli/cmd/trace_init_identity.go @@ -200,7 +200,7 @@ func resolveInteractiveProject(ctx context.Context, lister projectLister, p prom return nil, fmt.Errorf("listing the projects you can see: %w", err) } - visible, canCreate := listing.Projects, listing.CanCreateProject + visible := listing.Projects // Only the projects the caller can add a workflow to are worth offering; // picking any other one would fail at creation time. The rest are still @@ -245,19 +245,10 @@ func resolveInteractiveProject(ctx context.Context, lister projectLister, p prom // Creating one is a permission of the organization role, not of any project: // an organization contributor administering every project it can see still - // cannot create another. Offering the option anyway would fail on the name - // they just typed. - if !canCreate { - if len(writable) == 0 { - return nil, errors.New("you cannot create projects in this organization, and none of the projects you can see accepts a new workflow; ask an administrator for access to one") - } - - chosen, err := p.Select(projectPromptTitle, writable, firstPresent(writable, fromYML)) - if err != nil { - return nil, err - } - - return &resolvedValue{value: chosen, save: chosen != fromYML, prompted: true}, nil + // cannot create another. With nothing to pick either, there is no way + // forward, so say so instead of asking. + if !listing.CanCreateProject && len(writable) == 0 { + return nil, errors.New("you cannot create projects in this organization, and none of the projects you can see accepts a new workflow; ask an administrator for access to one") } // Nothing to choose from, so go straight to naming one rather than showing a @@ -266,18 +257,23 @@ func resolveInteractiveProject(ctx context.Context, lister projectLister, p prom return promptNewProject(p, newProjectSeed, fromYML, readOnly) } - options := append([]string{createNewProjectOption}, writable...) + // The create entry is only offered to a role that may act on it; offering it + // anyway would fail on the name they just typed. + options := writable + if listing.CanCreateProject { + options = append([]string{createNewProjectOption}, writable...) + } chosen, err := p.Select(projectPromptTitle, options, firstPresent(options, fromYML)) if err != nil { return nil, err } - if chosen != createNewProjectOption { - return &resolvedValue{value: chosen, save: chosen != fromYML, prompted: true}, nil + if chosen == createNewProjectOption { + return promptNewProject(p, newProjectSeed, fromYML, readOnly) } - return promptNewProject(p, newProjectSeed, fromYML, readOnly) + return &resolvedValue{value: chosen, save: chosen != fromYML, prompted: true}, nil } // promptNewProject collects a free-form project name and returns it normalized diff --git a/app/cli/pkg/action/trace_identity.go b/app/cli/pkg/action/trace_identity.go index de82edbbd..8c91718b1 100644 --- a/app/cli/pkg/action/trace_identity.go +++ b/app/cli/pkg/action/trace_identity.go @@ -83,7 +83,9 @@ type traceProjectPage struct { // Deciding which of those to offer is left to the caller, which needs to tell a // project it may not write to apart from one that is missing entirely. func listAllTraceProjects(ctx context.Context, api traceProjectAPI) (*TraceProjects, error) { - all := &TraceProjects{Projects: make([]*TraceProject, 0, traceProjectPageSize)} + projects := make([]*TraceProject, 0, traceProjectPageSize) + // Every page repeats the same answer, so the last one read is as good as any. + var canCreateProject bool for page := int32(1); ; page++ { got, err := api.listProjectsPage(ctx, page, traceProjectPageSize) @@ -91,8 +93,8 @@ func listAllTraceProjects(ctx context.Context, api traceProjectAPI) (*TraceProje return nil, fmt.Errorf("listing projects: %w", err) } - all.Projects = append(all.Projects, got.Projects...) - all.CanCreateProject = got.CanCreateProject + projects = append(projects, got.Projects...) + canCreateProject = got.CanCreateProject // Stop on the last page the server reports, and also on a page the server // returned nothing for, so a server reporting more pages than it serves @@ -102,15 +104,15 @@ func listAllTraceProjects(ctx context.Context, api traceProjectAPI) (*TraceProje } } - slices.SortFunc(all.Projects, func(a, b *TraceProject) int { + slices.SortFunc(projects, func(a, b *TraceProject) int { return cmp.Compare(a.Name, b.Name) }) - all.Projects = slices.CompactFunc(all.Projects, func(a, b *TraceProject) bool { + projects = slices.CompactFunc(projects, func(a, b *TraceProject) bool { return a.Name == b.Name }) - return all, nil + return &TraceProjects{Projects: projects, CanCreateProject: canCreateProject}, nil } // cpTraceProjectAPI implements traceProjectAPI against the control plane. diff --git a/app/controlplane/internal/service/service.go b/app/controlplane/internal/service/service.go index cb2761bb2..a291bf39d 100644 --- a/app/controlplane/internal/service/service.go +++ b/app/controlplane/internal/service/service.go @@ -352,7 +352,7 @@ func (s *service) userHasPermissionOnProject(ctx context.Context, orgID string, func (s *service) userCanCreateProject(ctx context.Context) error { pass, err := s.canCreateProject(ctx) if err != nil { - return err + return handleUseCaseErr(err, s.log) } if !pass { @@ -365,7 +365,7 @@ func (s *service) userCanCreateProject(ctx context.Context) error { // canCreateProject reports what userCanCreateProject enforces, so a listing can // tell a client whether creating one is worth offering. The two must answer // alike: an option that the create call then refuses is the dead end the answer -// exists to avoid. +// exists to avoid. Its error is raw, for the caller to convert at the boundary. func (s *service) canCreateProject(ctx context.Context) (bool, error) { // admins always can create projects if !rbacEnabled(ctx) { @@ -373,19 +373,13 @@ func (s *service) canCreateProject(ctx context.Context) (bool, error) { } // Only org tokens can create projects - if token := entities.CurrentAPIToken(ctx); token != nil { - if token.ProjectID != nil { - return false, nil - } + if token := entities.CurrentAPIToken(ctx); token != nil && token.ProjectID != nil { + return false, nil } orgRole := usercontext.CurrentAuthzSubject(ctx) - pass, err := s.authz.Enforce(ctx, orgRole, authz.PolicyProjectCreate) - if err != nil { - return false, handleUseCaseErr(err, s.log) - } - return pass, nil + return s.authz.Enforce(ctx, orgRole, authz.PolicyProjectCreate) } // visibleProjects returns projects where the user has any role (currently ProjectAdmin and ProjectViewer) From ea2016c866e404e4b0abe26a192c505032cff893 Mon Sep 17 00:00:00 2001 From: Miguel Martinez Trivino Date: Wed, 9 Sep 2026 22:30:44 +0200 Subject: [PATCH 19/30] fix(cli): say what the pickers do and put creating a project last The titles named a setting rather than the choice being made, which reads as a label on a form the user did not open. They now say what pressing enter does, and the project picker has one for each of its two shapes: a caller who cannot create one is only selecting, and a title offering more than the list holds is the same dead end as an entry that cannot be acted on. Creating a project moves under the projects, since picking one of those is the common answer. huh draws every entry alike and has no separator or grouping, so the "+" is what sets an action apart from the names above it. The cursor starts on the first project, and on creating only when the repository points at a project the organization does not have, which is the name it is asking for. Assisted-by: Claude Code Signed-off-by: Miguel Martinez Trivino Chainloop-Trace-Sessions: 184822bb-e811-42fd-8e13-735227edc5ce --- app/cli/cmd/trace_init_identity.go | 39 ++++++++++++++++++----- app/cli/cmd/trace_init_identity_test.go | 41 ++++++++++++++++--------- app/cli/internal/trace/config/slug.go | 5 +++ 3 files changed, 62 insertions(+), 23 deletions(-) diff --git a/app/cli/cmd/trace_init_identity.go b/app/cli/cmd/trace_init_identity.go index a37f96841..d016b1d07 100644 --- a/app/cli/cmd/trace_init_identity.go +++ b/app/cli/cmd/trace_init_identity.go @@ -29,12 +29,21 @@ import ( ) const ( - orgPromptTitle = "Chainloop organization for this repository" - projectPromptTitle = "Chainloop project for this repository" + orgPromptTitle = "Select a Chainloop organization" + + // The project picker offers creating one only to a caller allowed to, so it + // has a title for each case: promising an action that is not on the list + // would be the same dead end as offering the action itself. + projectPromptTitle = "Select or create a Chainloop project" + selectProjectPromptTitle = "Select a Chainloop project" + newProjectPromptTitle = "Name for the new project" - // createNewProjectOption is the first entry of the project picker. - createNewProjectOption = "Create a new project…" + // createNewProjectOption closes the project picker, below the projects + // themselves. huh has no separator or grouping in a list — every entry is a + // label and a value, drawn alike — so the "+" is what sets an action apart + // from the names it sits under. + createNewProjectOption = "+ Create a new project…" ) // errAborted is returned when the user dismisses a prompt. It stops `trace @@ -223,6 +232,10 @@ func resolveInteractiveProject(ctx context.Context, lister projectLister, p prom // nowhere. newProjectSeed := config.SlugifyDNS1123(repoDir) + // pinnedIsMissing records that .chainloop.yml names a project the + // organization does not have, which decides where the cursor starts. + var pinnedIsMissing bool + // The listing is the source of truth: what .chainloop.yml names is only worth // offering if the organization actually has it. Anything else it holds is // stale, names a project in another organization, or was never created. @@ -231,6 +244,7 @@ func resolveInteractiveProject(ctx context.Context, lister projectLister, p prom logger.Warn().Str("project", fromYML). Msg("you cannot create a workflow in the project this repository points at, pick another one") } else { + pinnedIsMissing = true // Not in the organization at all, so it is a name for a project that does // not exist rather than one that can be picked. Offering it as if it were // real is what makes selecting it fail on a project the caller may not be @@ -258,13 +272,22 @@ func resolveInteractiveProject(ctx context.Context, lister projectLister, p prom } // The create entry is only offered to a role that may act on it; offering it - // anyway would fail on the name they just typed. - options := writable + // anyway would fail on the name they just typed. It goes last, under the + // projects, since picking one of those is the common answer. + title, options := selectProjectPromptTitle, writable if listing.CanCreateProject { - options = append([]string{createNewProjectOption}, writable...) + title = projectPromptTitle + options = append(slices.Clone(writable), createNewProjectOption) + } + + // The cursor starts on what the repository pins, and on creating when what it + // pins does not exist, since that name is what it is asking to be created. + preselect := fromYML + if pinnedIsMissing { + preselect = createNewProjectOption } - chosen, err := p.Select(projectPromptTitle, options, firstPresent(options, fromYML)) + chosen, err := p.Select(title, options, firstPresent(options, preselect)) if err != nil { return nil, err } diff --git a/app/cli/cmd/trace_init_identity_test.go b/app/cli/cmd/trace_init_identity_test.go index f65625507..19232ab1d 100644 --- a/app/cli/cmd/trace_init_identity_test.go +++ b/app/cli/cmd/trace_init_identity_test.go @@ -19,6 +19,7 @@ import ( "context" "errors" "fmt" + "slices" "testing" "github.com/chainloop-dev/chainloop/app/cli/pkg/action" @@ -389,18 +390,18 @@ func TestResolveInteractiveProject(t *testing.T) { selectAnswer: "payments", wantValue: "payments", wantSave: false, - wantOptions: []string{createNewProjectOption, projectAPI, "payments"}, + wantOptions: []string{projectAPI, "payments", createNewProjectOption}, wantSelectDefault: "payments", }, { - name: "with nothing pinned the create entry is preselected", + name: "with nothing pinned the first project is preselected", projects: []string{projectAPI, "payments"}, repoDir: repoDirMyRepo, selectAnswer: projectAPI, wantValue: projectAPI, wantSave: true, - wantOptions: []string{createNewProjectOption, projectAPI, "payments"}, - wantSelectDefault: createNewProjectOption, + wantOptions: []string{projectAPI, "payments", createNewProjectOption}, + wantSelectDefault: projectAPI, }, { // The listing is the source of truth. Offering a name only @@ -414,7 +415,7 @@ func TestResolveInteractiveProject(t *testing.T) { selectAnswer: projectAPI, wantValue: projectAPI, wantSave: true, - wantOptions: []string{createNewProjectOption, projectAPI}, + wantOptions: []string{projectAPI, createNewProjectOption}, wantSelectDefault: createNewProjectOption, }, { @@ -429,7 +430,7 @@ func TestResolveInteractiveProject(t *testing.T) { inputAnswer: "gone-or-never-existed", wantValue: "gone-or-never-existed", wantSave: false, - wantOptions: []string{createNewProjectOption, projectAPI}, + wantOptions: []string{projectAPI, createNewProjectOption}, wantSelectDefault: createNewProjectOption, wantInputDefault: "gone-or-never-existed", }, @@ -441,8 +442,8 @@ func TestResolveInteractiveProject(t *testing.T) { inputAnswer: "My Cool Project", wantValue: "my-cool-project", wantSave: true, - wantOptions: []string{createNewProjectOption, projectAPI}, - wantSelectDefault: createNewProjectOption, + wantOptions: []string{projectAPI, createNewProjectOption}, + wantSelectDefault: projectAPI, wantInputDefault: "my-cool-project", }, { @@ -453,8 +454,8 @@ func TestResolveInteractiveProject(t *testing.T) { inputAnswer: " Payments API (v2)!! ", wantValue: "payments-api-v2", wantSave: true, - wantOptions: []string{createNewProjectOption, projectAPI}, - wantSelectDefault: createNewProjectOption, + wantOptions: []string{projectAPI, createNewProjectOption}, + wantSelectDefault: projectAPI, wantInputDefault: "repo", }, { @@ -465,7 +466,7 @@ func TestResolveInteractiveProject(t *testing.T) { inputAnswer: "Backend API", wantValue: projectAPI, wantSave: true, - wantOptions: []string{createNewProjectOption, projectAPI}, + wantOptions: []string{projectAPI, createNewProjectOption}, }, { name: "a name that normalizes to nothing is rejected", @@ -485,12 +486,14 @@ func TestResolveInteractiveProject(t *testing.T) { selectAnswer: projectAPI, wantValue: projectAPI, wantSave: true, - wantOptions: []string{createNewProjectOption, projectAPI}, - wantSelectDefault: createNewProjectOption, + wantOptions: []string{projectAPI, createNewProjectOption}, + wantSelectDefault: projectAPI, }, { // The listing did carry it, so it is a real project, but picking it // would fail at creation time just the same. + // It is a real project rather than a missing one, so the cursor stays + // on the list instead of starting on creating it. name: "a pinned project the caller can only view is neither offered nor preselected", projects: []string{projectAPI}, readOnly: []string{"payments"}, @@ -499,8 +502,8 @@ func TestResolveInteractiveProject(t *testing.T) { selectAnswer: projectAPI, wantValue: projectAPI, wantSave: true, - wantOptions: []string{createNewProjectOption, projectAPI}, - wantSelectDefault: createNewProjectOption, + wantOptions: []string{projectAPI, createNewProjectOption}, + wantSelectDefault: projectAPI, }, { name: "a pinned read-only project does not seed the new name", @@ -591,6 +594,14 @@ func TestResolveInteractiveProject(t *testing.T) { if tc.wantSelectDefault != "" { assert.Equal(t, tc.wantSelectDefault, p.selects[0].defaultValue) } + + // The title offers creating a project exactly when the list does. + wantTitle := selectProjectPromptTitle + if slices.Contains(tc.wantOptions, createNewProjectOption) { + wantTitle = projectPromptTitle + } + + assert.Equal(t, wantTitle, p.selects[0].title) } if tc.wantInputDefault != "" { diff --git a/app/cli/internal/trace/config/slug.go b/app/cli/internal/trace/config/slug.go index bce92d347..3b6f443d3 100644 --- a/app/cli/internal/trace/config/slug.go +++ b/app/cli/internal/trace/config/slug.go @@ -32,6 +32,11 @@ import ( // It returns an empty string for input that has nothing usable in it, e.g. // "!!!". Callers are expected to reject that rather than pass it on; // ValidateDNS1123Label produces the message for it. +// +// materials.SanitizeMaterialName does the same to material names, and the two +// agree on everything but the length cap, which it leaves to its own caller. +// They are kept apart on purpose: importing it here would put the whole +// material crafter, some 377 packages, behind this one. func SlugifyDNS1123(name string) string { var b strings.Builder b.Grow(len(name)) From 3d77059faf66a6a8d8bd0043f78f46477455809c Mon Sep 17 00:00:00 2001 From: Miguel Martinez Trivino Date: Wed, 9 Sep 2026 22:36:40 +0200 Subject: [PATCH 20/30] fix(cli): only attach a trace contract the project may use The shipped contract is looked up by name, and the lookup answered whether the organization has one, not whether this project can attach it. A contract of the user's own carrying that name, scoped to another project, was passed to create, which the control plane refuses: "contract is scoped to a different project", on an init that had nothing to do with that contract. The lookup now returns the contract, and a project-scoped one is used only in the project it belongs to, comparing names the way the control plane does. Falling back to the default contract is right for a name nobody asked for; a contract named with --contract is reported instead, saying which project holds it rather than leaving the server to refuse it. Assisted-by: Claude Code Signed-off-by: Miguel Martinez Trivino Chainloop-Trace-Sessions: 184822bb-e811-42fd-8e13-735227edc5ce --- app/cli/cmd/trace.go | 2 +- app/cli/documentation/cli-reference.mdx | 4 +- app/cli/pkg/action/trace_workflow.go | 52 ++++++++++++------ app/cli/pkg/action/trace_workflow_test.go | 65 ++++++++++++++++++++--- 4 files changed, 97 insertions(+), 26 deletions(-) diff --git a/app/cli/cmd/trace.go b/app/cli/cmd/trace.go index a653d72be..8d2434c54 100644 --- a/app/cli/cmd/trace.go +++ b/app/cli/cmd/trace.go @@ -21,7 +21,7 @@ import ( // traceContractFlagDesc documents the --contract flag `trace init` and // `trace run` share. -const traceContractFlagDesc = `workflow contract to attach when creating the workflow; it must already exist (default: "chainloop-ai-coding-session" when the organization has it, an empty contract otherwise)` +const traceContractFlagDesc = `workflow contract to attach when creating the workflow; it must already exist and be usable by the project (default: "chainloop-ai-coding-session" when the organization has it, an empty contract otherwise)` // newTraceCmd creates the trace parent command with all subcommands. func newTraceCmd() *cobra.Command { diff --git a/app/cli/documentation/cli-reference.mdx b/app/cli/documentation/cli-reference.mdx index 3a957d2d2..18995514d 100755 --- a/app/cli/documentation/cli-reference.mdx +++ b/app/cli/documentation/cli-reference.mdx @@ -3333,7 +3333,7 @@ Options ``` --claude install Claude Code hooks (default when no harness flag is set) ---contract string workflow contract to attach when creating the workflow; it must already exist (default: "chainloop-ai-coding-session" when the organization has it, an empty contract otherwise) +--contract string workflow contract to attach when creating the workflow; it must already exist and be usable by the project (default: "chainloop-ai-coding-session" when the organization has it, an empty contract otherwise) --cursor install Cursor hooks -h, --help help for init --opencode install opencode hooks @@ -3387,7 +3387,7 @@ Options ``` --claude install Claude Code hooks (default when no provider flag is set) ---contract string workflow contract to attach when creating the workflow; it must already exist (default: "chainloop-ai-coding-session" when the organization has it, an empty contract otherwise) +--contract string workflow contract to attach when creating the workflow; it must already exist and be usable by the project (default: "chainloop-ai-coding-session" when the organization has it, an empty contract otherwise) --cursor install Cursor hooks -h, --help help for run --opencode install opencode hooks diff --git a/app/cli/pkg/action/trace_workflow.go b/app/cli/pkg/action/trace_workflow.go index d24a169ab..f044930ea 100644 --- a/app/cli/pkg/action/trace_workflow.go +++ b/app/cli/pkg/action/trace_workflow.go @@ -56,9 +56,10 @@ type traceWorkflowAPI interface { // viewWorkflow returns the workflow, or a NotFound error when the workflow // or its project does not exist. viewWorkflow(ctx context.Context, project, workflow string) (*WorkflowItem, error) - // contractExists reports whether a contract with that name exists in the - // organization. A lookup failure is returned as an error, not as false. - contractExists(ctx context.Context, name string) (bool, error) + // findContract returns the contract with that name, or nil when the + // organization does not have one. A lookup failure is returned as an error, + // not as a missing contract. + findContract(ctx context.Context, name string) (*WorkflowContractItem, error) // createWorkflow creates the workflow, attached to contract when non-empty. createWorkflow(ctx context.Context, project, workflow, contract string) (*WorkflowItem, error) } @@ -111,32 +112,52 @@ func ensureTraceWorkflow(ctx context.Context, api traceWorkflowAPI, log zerolog. } // resolveTraceContract returns the contract name to attach on creation: the -// requested one when the organization has it, or an empty string to let the -// control plane create its per-project default. A contract that cannot be found -// is only an error when the caller marked it required, because the shipped -// default is absent in organizations that never imported it. +// requested one when the organization has it and the project may use it, or an +// empty string to let the control plane create its per-project default. A +// contract that cannot be used is only an error when the caller marked it +// required, because the shipped default is absent in organizations that never +// imported it, and a contract of the user's own may happen to carry its name. func resolveTraceContract(ctx context.Context, api traceWorkflowAPI, log zerolog.Logger, opts EnsureTraceWorkflowOpts) (string, error) { if opts.ContractName == "" { return "", nil } - exists, err := api.contractExists(ctx, opts.ContractName) + contract, err := api.findContract(ctx, opts.ContractName) switch { case err != nil && opts.ContractRequired: return "", fmt.Errorf("looking up contract %q: %w", opts.ContractName, err) case err != nil: log.Debug().Err(err).Str("contract", opts.ContractName).Msg("could not look up the contract; falling back to the default one") return "", nil - case !exists && opts.ContractRequired: + case contract == nil && opts.ContractRequired: return "", fmt.Errorf("contract %q does not exist in this organization", opts.ContractName) - case !exists: + case contract == nil: log.Debug().Str("contract", opts.ContractName).Msg("contract not found; falling back to the default one") return "", nil } + // A contract scoped to a project belongs to that project alone: the control + // plane refuses to attach it anywhere else, comparing project names. The + // name is all we have to go on here, and it is the same thing it compares. + if scope := contract.ScopedEntity; scope != nil && scope.Type == contractScopeProject && scope.Name != opts.ProjectName { + if opts.ContractRequired { + return "", fmt.Errorf("contract %q is scoped to project %q, so it cannot be used in project %q", + opts.ContractName, scope.Name, opts.ProjectName) + } + + log.Debug().Str("contract", opts.ContractName).Str("scoped_to", scope.Name). + Msg("contract belongs to another project; falling back to the default one") + + return "", nil + } + return opts.ContractName, nil } +// contractScopeProject is the scope a contract carries when it belongs to a +// single project rather than to the whole organization, which has none. +const contractScopeProject = "project" + // cpTraceWorkflowAPI implements traceWorkflowAPI against the control plane, // reusing the actions the equivalent commands run. type cpTraceWorkflowAPI struct { @@ -147,16 +168,17 @@ func (a *cpTraceWorkflowAPI) viewWorkflow(ctx context.Context, project, workflow return NewWorkflowDescribe(a.cfg).Run(ctx, workflow, project) } -func (a *cpTraceWorkflowAPI) contractExists(ctx context.Context, name string) (bool, error) { - if _, err := NewWorkflowContractDescribe(a.cfg).Run(ctx, name, 0); err != nil { +func (a *cpTraceWorkflowAPI) findContract(ctx context.Context, name string) (*WorkflowContractItem, error) { + described, err := NewWorkflowContractDescribe(a.cfg).Run(ctx, name, 0) + if err != nil { if status.Code(err) == codes.NotFound { - return false, nil + return nil, nil } - return false, err + return nil, err } - return true, nil + return described.Contract, nil } func (a *cpTraceWorkflowAPI) createWorkflow(ctx context.Context, project, workflow, contract string) (*WorkflowItem, error) { diff --git a/app/cli/pkg/action/trace_workflow_test.go b/app/cli/pkg/action/trace_workflow_test.go index c4ce41da9..ec748655c 100644 --- a/app/cli/pkg/action/trace_workflow_test.go +++ b/app/cli/pkg/action/trace_workflow_test.go @@ -34,8 +34,10 @@ type fakeTraceWorkflowAPI struct { viewResult *WorkflowItem viewErr error - contractFound bool - contractErr error + // contract is what the lookup finds, nil when the organization does not + // have one by that name. + contract *WorkflowContractItem + contractErr error createErr error @@ -48,9 +50,23 @@ func (f *fakeTraceWorkflowAPI) viewWorkflow(_ context.Context, _, _ string) (*Wo return f.viewResult, f.viewErr } -func (f *fakeTraceWorkflowAPI) contractExists(_ context.Context, _ string) (bool, error) { +func (f *fakeTraceWorkflowAPI) findContract(_ context.Context, _ string) (*WorkflowContractItem, error) { f.contractCalls++ - return f.contractFound, f.contractErr + return f.contract, f.contractErr +} + +// globalContract is a contract the whole organization can attach. +func globalContract(name string) *WorkflowContractItem { + return &WorkflowContractItem{Name: name} +} + +// projectContract is a contract scoped to a single project, which the control +// plane refuses to attach to a workflow in any other one. +func projectContract(name, project string) *WorkflowContractItem { + return &WorkflowContractItem{ + Name: name, + ScopedEntity: &ScopedEntity{Type: "project", Name: project}, + } } // defaultContract stands in for the empty contract the control plane creates @@ -106,7 +122,7 @@ func TestEnsureTraceWorkflow(t *testing.T) { }, { name: "missing workflow with an existing contract is created attached to it", - api: &fakeTraceWorkflowAPI{viewErr: notFound, contractFound: true}, + api: &fakeTraceWorkflowAPI{viewErr: notFound, contract: globalContract(contract)}, opts: EnsureTraceWorkflowOpts{ProjectName: project, WorkflowName: workflow, ContractName: contract}, wantCreated: true, wantContract: contract, @@ -132,6 +148,39 @@ func TestEnsureTraceWorkflow(t *testing.T) { wantContractCall: true, wantCreateCall: true, }, + { + // The shipped contract is picked by name, so an unrelated contract of + // the user's own that happens to carry it, scoped to another project, + // must not be attached: the control plane refuses it outright. + name: "a contract scoped to another project is not used", + api: &fakeTraceWorkflowAPI{viewErr: notFound, contract: projectContract(contract, "another-project")}, + opts: EnsureTraceWorkflowOpts{ProjectName: project, WorkflowName: workflow, ContractName: contract}, + wantCreated: true, + wantContract: defaultContract, + wantContractCall: true, + wantCreateCall: true, + }, + { + // Scoped to the project being created in, it is the contract that + // project is meant to use. + name: "a contract scoped to this project is used", + api: &fakeTraceWorkflowAPI{viewErr: notFound, contract: projectContract(contract, project)}, + opts: EnsureTraceWorkflowOpts{ProjectName: project, WorkflowName: workflow, ContractName: contract}, + wantCreated: true, + wantContract: contract, + wantContractCall: true, + wantCreateCall: true, + wantCreatedWith: contract, + }, + { + // Asked for by name, so it is reported rather than silently swapped + // for the default, and before the control plane refuses it. + name: "a required contract scoped to another project fails", + api: &fakeTraceWorkflowAPI{viewErr: notFound, contract: projectContract(contract, "another-project")}, + opts: EnsureTraceWorkflowOpts{ProjectName: project, WorkflowName: workflow, ContractName: contract, ContractRequired: true}, + wantErr: `is scoped to project "another-project"`, + wantContractCall: true, + }, { name: "a required contract that does not exist fails", api: &fakeTraceWorkflowAPI{viewErr: notFound}, @@ -156,7 +205,7 @@ func TestEnsureTraceWorkflow(t *testing.T) { }, { name: "a workflow created concurrently is not an error", - api: &fakeTraceWorkflowAPI{viewErr: notFound, contractFound: true, createErr: status.Error(codes.AlreadyExists, "already exists")}, + api: &fakeTraceWorkflowAPI{viewErr: notFound, contract: globalContract(contract), createErr: status.Error(codes.AlreadyExists, "already exists")}, opts: EnsureTraceWorkflowOpts{ProjectName: project, WorkflowName: workflow, ContractName: contract}, wantContractCall: true, wantCreateCall: true, @@ -176,7 +225,7 @@ func TestEnsureTraceWorkflow(t *testing.T) { }, { name: "a create failure fails", - api: &fakeTraceWorkflowAPI{viewErr: notFound, contractFound: true, createErr: denied}, + api: &fakeTraceWorkflowAPI{viewErr: notFound, contract: globalContract(contract), createErr: denied}, opts: EnsureTraceWorkflowOpts{ProjectName: project, WorkflowName: workflow, ContractName: contract}, wantErr: forbidden, wantContractCall: true, @@ -236,7 +285,7 @@ func TestEnsureTraceWorkflow(t *testing.T) { for name, api := range map[string]*fakeTraceWorkflowAPI{ "on view": {viewErr: authErr}, "on contract": {viewErr: notFound, contractErr: authErr}, - "on create": {viewErr: notFound, contractFound: true, createErr: authErr}, + "on create": {viewErr: notFound, contract: globalContract(contract), createErr: authErr}, } { t.Run(name, func(t *testing.T) { _, err := ensureTraceWorkflow(context.Background(), api, zerolog.Nop(), From c66fbb39beffa55032b6e4553a39b6789322f227 Mon Sep 17 00:00:00 2001 From: Miguel Martinez Trivino Date: Wed, 9 Sep 2026 22:39:53 +0200 Subject: [PATCH 21/30] fix(cli): stop warning about a pinned project that does not exist yet Setting a repository up is when .chainloop.yml names a project that has not been created, so reporting it as a problem is noise at exactly the moment it is expected. The next prompt already asks for the name, prefilled with it. Kept at debug, where the rest of the init narration is. Assisted-by: Claude Code Signed-off-by: Miguel Martinez Trivino Chainloop-Trace-Sessions: 184822bb-e811-42fd-8e13-735227edc5ce --- app/cli/cmd/trace_init_identity.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/app/cli/cmd/trace_init_identity.go b/app/cli/cmd/trace_init_identity.go index d016b1d07..b6fb592e6 100644 --- a/app/cli/cmd/trace_init_identity.go +++ b/app/cli/cmd/trace_init_identity.go @@ -249,10 +249,12 @@ func resolveInteractiveProject(ctx context.Context, lister projectLister, p prom // not exist rather than one that can be picked. Offering it as if it were // real is what makes selecting it fail on a project the caller may not be // allowed to create. Creating it is what the repository is asking for, so - // it seeds the new name instead. + // it seeds the new name instead, which is the whole of what the user needs + // to know: they are being asked to create it, with the name already filled + // in. Warning about it would report a problem that the next prompt solves. newProjectSeed = fromYML - logger.Warn().Str("project", fromYML). + logger.Debug().Str("project", fromYML). Msg("the project this repository points at does not exist in this organization") } } From 4f5e6464f64793c8c8bbe93d19ff78b56e729448 Mon Sep 17 00:00:00 2001 From: Miguel Martinez Trivino Date: Wed, 9 Sep 2026 22:42:01 +0200 Subject: [PATCH 22/30] fix(cli): seed a new project name from the directory, never from a stale pin A project name in .chainloop.yml that the organization does not have was proposed back as the name to create. Nothing was ever created under it, so it is as likely to be a typo as an intention, and it arrived prefilled and ready to accept. The repository's directory name is the seed in every case now. The cursor still starts on creating when the pinned project does not exist, since nothing the repository points at can be picked. Assisted-by: Claude Code Signed-off-by: Miguel Martinez Trivino Chainloop-Trace-Sessions: 184822bb-e811-42fd-8e13-735227edc5ce --- app/cli/cmd/trace_init_identity.go | 19 +++++++++---------- app/cli/cmd/trace_init_identity_test.go | 17 ++++++++++------- 2 files changed, 19 insertions(+), 17 deletions(-) diff --git a/app/cli/cmd/trace_init_identity.go b/app/cli/cmd/trace_init_identity.go index b6fb592e6..a380f5a5c 100644 --- a/app/cli/cmd/trace_init_identity.go +++ b/app/cli/cmd/trace_init_identity.go @@ -226,10 +226,12 @@ func resolveInteractiveProject(ctx context.Context, lister projectLister, p prom readOnly = append(readOnly, project.Name) } - // newProjectSeed is what the new-project prompt starts from. The repository's - // own directory name is the default seed: a project the organization already - // has is picked from the list, so seeding its name would only propose moving - // nowhere. + // newProjectSeed is what the new-project prompt starts from: the repository's + // own directory name, whatever .chainloop.yml holds. A project the + // organization already has is picked from the list rather than typed, and a + // name it does not have is one nothing was ever created under — proposing it + // again would hand back a name that is as likely to be a typo as an + // intention. newProjectSeed := config.SlugifyDNS1123(repoDir) // pinnedIsMissing records that .chainloop.yml names a project the @@ -244,15 +246,12 @@ func resolveInteractiveProject(ctx context.Context, lister projectLister, p prom logger.Warn().Str("project", fromYML). Msg("you cannot create a workflow in the project this repository points at, pick another one") } else { - pinnedIsMissing = true // Not in the organization at all, so it is a name for a project that does // not exist rather than one that can be picked. Offering it as if it were // real is what makes selecting it fail on a project the caller may not be - // allowed to create. Creating it is what the repository is asking for, so - // it seeds the new name instead, which is the whole of what the user needs - // to know: they are being asked to create it, with the name already filled - // in. Warning about it would report a problem that the next prompt solves. - newProjectSeed = fromYML + // allowed to create. Nothing points anywhere usable, so the cursor starts + // on creating one; the name is asked for rather than carried over. + pinnedIsMissing = true logger.Debug().Str("project", fromYML). Msg("the project this repository points at does not exist in this organization") diff --git a/app/cli/cmd/trace_init_identity_test.go b/app/cli/cmd/trace_init_identity_test.go index 19232ab1d..ddef2f58b 100644 --- a/app/cli/cmd/trace_init_identity_test.go +++ b/app/cli/cmd/trace_init_identity_test.go @@ -374,13 +374,15 @@ func TestResolveInteractiveProject(t *testing.T) { wantInputDefault: "my-repo", }, { - name: "with no visible projects the pinned project is the default", + // Answering with what is already pinned writes nothing back, however + // the answer was arrived at. + name: "with no visible projects the repository name is the default", fromYML: "existing", repoDir: repoDirMyRepo, inputAnswer: "existing", wantValue: "existing", wantSave: false, - wantInputDefault: "existing", + wantInputDefault: "my-repo", }, { name: "the pinned project is preselected in the list", @@ -419,10 +421,11 @@ func TestResolveInteractiveProject(t *testing.T) { wantSelectDefault: createNewProjectOption, }, { - // It is still the name the repository is asking for, so creating is - // where it belongs: the input starts from it rather than from the - // repository's directory name. - name: "a pinned project the organization does not have seeds the new name", + // Nothing was ever created under that name, so it is as likely to be a + // typo as an intention. The cursor starts on creating, since nothing + // the repository points at can be picked, but the name is asked for + // rather than proposed back. + name: "a pinned project the organization does not have does not seed the new name", projects: []string{projectAPI}, fromYML: "gone-or-never-existed", repoDir: repoDirMyRepo, @@ -432,7 +435,7 @@ func TestResolveInteractiveProject(t *testing.T) { wantSave: false, wantOptions: []string{projectAPI, createNewProjectOption}, wantSelectDefault: createNewProjectOption, - wantInputDefault: "gone-or-never-existed", + wantInputDefault: "my-repo", }, { name: "choosing to create opens an input prefilled with the repository name", From 749032185e166cf0d049ba88437b09d949d36268 Mon Sep 17 00:00:00 2001 From: Miguel Martinez Trivino Date: Wed, 9 Sep 2026 22:57:14 +0200 Subject: [PATCH 23/30] feat(cli): create the first organization, and mark the ones only viewable Belonging to no organization ended trace init with an error naming another command to go and run. There is nothing to select in that case and every step after it needs an organization, so it now asks for a name and creates it. Only then: with one to pick, picking is the answer. An instance can restrict who may create organizations, which is reported as the ask-to-be-invited it is. An organization the caller can only view cannot hold a trace workflow. It is shown in the picker, marked, and refused when picked, rather than dropped: an organization missing from the list reads as something gone wrong, and the marker says what to do about it. Selecting one keeps the prompt open, so the picker now takes a validator the way the input already did. Organizations are named by the rule projects are, so the name check is shared, with the subject passed in for the message. Assisted-by: Claude Code Signed-off-by: Miguel Martinez Trivino Chainloop-Trace-Sessions: 184822bb-e811-42fd-8e13-735227edc5ce --- app/cli/cmd/trace_init_identity.go | 124 +++++++++++++++++--- app/cli/cmd/trace_init_identity_test.go | 130 +++++++++++++++++++-- app/cli/cmd/trace_init_prompt.go | 8 +- app/cli/cmd/trace_init_prompt_test.go | 22 +++- app/cli/cmd/trace_init_resolve.go | 2 +- app/cli/internal/trace/config/slug.go | 24 ++-- app/cli/internal/trace/config/slug_test.go | 4 +- app/cli/pkg/action/trace_identity.go | 10 ++ 8 files changed, 285 insertions(+), 39 deletions(-) diff --git a/app/cli/cmd/trace_init_identity.go b/app/cli/cmd/trace_init_identity.go index a380f5a5c..7b3c24afd 100644 --- a/app/cli/cmd/trace_init_identity.go +++ b/app/cli/cmd/trace_init_identity.go @@ -21,15 +21,25 @@ import ( "fmt" "os" "slices" + "strings" "github.com/chainloop-dev/chainloop/app/cli/internal/trace/config" "github.com/chainloop-dev/chainloop/app/cli/pkg/action" "golang.org/x/term" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" ) const ( - orgPromptTitle = "Select a Chainloop organization" + orgPromptTitle = "Select a Chainloop organization" + newOrgPromptTitle = "Name for your new organization" + + // readOnlyOrgMarker labels an organization the caller can only view. Those + // are shown rather than dropped — an organization missing from the list + // reads as something gone wrong — and refused when picked. Organization + // names are DNS-1123 labels, so no real one can end in this. + readOnlyOrgMarker = " (read-only)" // The project picker offers creating one only to a caller allowed to, so it // has a title for each case: promising an action that is not on the list @@ -81,11 +91,13 @@ var ciSignals = []string{ // wrappers and automation that run the CLI attached to a pseudo-terminal. const noPromptEnvVar = "CHAINLOOP_NO_PROMPT" -// orgLister lists the organizations the current user belongs to. It is the -// slice of the attestation executor the organization resolver needs, so that -// resolver can be table-tested without a control plane. -type orgLister interface { +// orgAPI lists the organizations the current user belongs to, and creates one +// for a user who belongs to none. It is the slice of the attestation executor +// the organization resolver needs, so that resolver can be table-tested without +// a control plane. +type orgAPI interface { ListOrganizations(ctx context.Context) ([]*action.MembershipItem, error) + CreateOrganization(ctx context.Context, name string) error } // projectLister lists the projects visible in the selected organization, and @@ -98,7 +110,9 @@ type projectLister interface { // resolution logic below independent of the terminal library, so it can be // table-tested with a scripted fake. type prompter interface { - Select(title string, options []string, defaultValue string) (string, error) + // Select asks for one of options. validate, when set, refuses an answer and + // keeps the prompt open, the way Input's does. + Select(title string, options []string, defaultValue string, validate func(string) error) (string, error) // MultiSelect asks for zero or more of options, starting with defaults // ticked. Implementations refuse an empty submission. MultiSelect(title string, options, defaults []string) ([]string, error) @@ -163,36 +177,60 @@ func envEnabled(lookupEnv func(string) (string, bool), key string) bool { // resolveInteractiveOrganization picks the organization the trace attestations // land in. fromYML is what .chainloop.yml pins, currentOrg the one the CLI is // pointing at; both may be empty. -func resolveInteractiveOrganization(ctx context.Context, lister orgLister, p prompter, fromYML, currentOrg string) (*resolvedValue, error) { - memberships, err := lister.ListOrganizations(ctx) +func resolveInteractiveOrganization(ctx context.Context, api orgAPI, p prompter, fromYML, currentOrg string) (*resolvedValue, error) { + memberships, err := api.ListOrganizations(ctx) if err != nil { return nil, fmt.Errorf("listing your organizations: %w", err) } - names := make([]string, 0, len(memberships)) + // labels are what the picker shows; usable holds only the ones that can be + // picked, which is where the cursor has to start. + labels := make([]string, 0, len(memberships)) + usable := make([]string, 0, len(memberships)) // serverDefault is the membership the control plane marks as the current one. var serverDefault string + for _, m := range memberships { if m.Org == nil || m.Org.Name == "" { continue } - names = append(names, m.Org.Name) + // An organization the caller can only view cannot hold a trace workflow, + // since creating one is not a viewer's to do. It is shown marked rather + // than left out: an organization that simply disappeared from the list + // reads as something gone wrong. + if m.Role == action.RoleViewer { + labels = append(labels, m.Org.Name+readOnlyOrgMarker) + continue + } + + labels = append(labels, m.Org.Name) + usable = append(usable, m.Org.Name) + if m.Default { serverDefault = m.Org.Name } } - switch len(names) { - case 0: - return nil, errors.New("you do not belong to any organization, create one with `chainloop organization create`") - case 1: - return &resolvedValue{value: names[0], save: names[0] != fromYML}, nil + if len(labels) == 0 { + // Belonging to none is the one case where there is nothing to pick and + // something to do about it. + return promptNewOrganization(ctx, api, p, fromYML) + } + + if len(usable) == 0 { + return nil, errors.New("you can only view the organizations you belong to, ask someone with write permissions on one of their projects to set this up") + } + + // One to pick and nothing to weigh it against, so it is not worth asking. + if len(labels) == 1 { + return &resolvedValue{value: usable[0], save: usable[0] != fromYML}, nil } // Preselect what the repository already pins, then what the CLI points at, - // then what the server considers current. - chosen, err := p.Select(orgPromptTitle, names, firstPresent(names, fromYML, currentOrg, serverDefault)) + // then what the server considers current, among the ones that can be picked. + chosen, err := p.Select(orgPromptTitle, labels, + firstPresent(usable, fromYML, currentOrg, serverDefault), refuseReadOnlyOrg) if err != nil { return nil, err } @@ -200,6 +238,54 @@ func resolveInteractiveOrganization(ctx context.Context, lister orgLister, p pro return &resolvedValue{value: chosen, save: chosen != fromYML, prompted: true}, nil } +// refuseReadOnlyOrg keeps the picker open on an organization the caller can +// only view, saying why instead of failing later on a workflow that cannot be +// created there. +func refuseReadOnlyOrg(chosen string) error { + name, readOnly := strings.CutSuffix(chosen, readOnlyOrgMarker) + if !readOnly { + return nil + } + + return fmt.Errorf("you can only view %q, ask someone with write permissions on one of its projects to set this up", name) +} + +// promptNewOrganization collects a name and creates the organization, for a +// user who belongs to none. Nothing can be selected in that case and every step +// after this one needs an organization, so creating the first one is the only +// way forward. An instance can restrict who may create them, which leaves +// nothing to do but say so. +// +// It is the one point where trace init changes anything before the repository +// is written to. An organization left behind by an init that is then abandoned +// is the user's to keep, and is waiting for them on the next run. +func promptNewOrganization(ctx context.Context, api orgAPI, p prompter, fromYML string) (*resolvedValue, error) { + answer, err := p.Input(newOrgPromptTitle, "", validateNewOrganization) + if err != nil { + return nil, err + } + + name := config.SlugifyDNS1123(answer) + if err := api.CreateOrganization(ctx, name); err != nil { + if status.Code(err) == codes.PermissionDenied { + return nil, fmt.Errorf("this instance only lets administrators create organizations, so ask to be invited to one: %w", err) + } + + return nil, fmt.Errorf("creating organization %q: %w", name, err) + } + + logger.Info().Str("organization", name).Msg("organization created") + + return &resolvedValue{value: name, save: name != fromYML, prompted: true}, nil +} + +// validateNewOrganization checks what a free-form name normalizes to, so a name +// the control plane would refuse is caught at the prompt. Organizations are +// named by the same rule projects are. +func validateNewOrganization(answer string) error { + return config.ValidateDNS1123Label(config.OrganizationSubject, config.SlugifyDNS1123(answer)) +} + // resolveInteractiveProject picks the project the workflow is created in, or // collects a name for a new one. fromYML is what .chainloop.yml pins and // repoDir the repository's directory name, used to seed a new name. @@ -288,7 +374,7 @@ func resolveInteractiveProject(ctx context.Context, lister projectLister, p prom preselect = createNewProjectOption } - chosen, err := p.Select(title, options, firstPresent(options, preselect)) + chosen, err := p.Select(title, options, firstPresent(options, preselect), nil) if err != nil { return nil, err } @@ -322,7 +408,7 @@ func promptNewProject(p prompter, defaultName, fromYML string, readOnly []string func newProjectValidator(readOnly []string) func(string) error { return func(answer string) error { name := config.SlugifyDNS1123(answer) - if err := config.ValidateDNS1123Label(name); err != nil { + if err := config.ValidateDNS1123Label(config.ProjectSubject, name); err != nil { return err } diff --git a/app/cli/cmd/trace_init_identity_test.go b/app/cli/cmd/trace_init_identity_test.go index ddef2f58b..310cbde31 100644 --- a/app/cli/cmd/trace_init_identity_test.go +++ b/app/cli/cmd/trace_init_identity_test.go @@ -26,6 +26,8 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" ) // --- fakes ------------------------------------------------------------------- @@ -33,12 +35,23 @@ import ( type fakeOrgLister struct { orgs []*action.MembershipItem err error + + // createErr is what creating an organization fails with, and created + // records the name it was asked for. + createErr error + created string } func (f *fakeOrgLister) ListOrganizations(context.Context) ([]*action.MembershipItem, error) { return f.orgs, f.err } +func (f *fakeOrgLister) CreateOrganization(_ context.Context, name string) error { + f.created = name + + return f.createErr +} + // fakeProjectLister serves a canned listing. projects are the ones the caller // may create a workflow in, readOnly the ones it can only see. cannotCreate // inverts the default so the common case stays untyped in the table. @@ -101,12 +114,20 @@ func (f *fakePrompter) MultiSelect(title string, options, defaults []string) ([] return f.multiSelectAnswer, nil } -func (f *fakePrompter) Select(title string, options []string, defaultValue string) (string, error) { +func (f *fakePrompter) Select(title string, options []string, defaultValue string, validate func(string) error) (string, error) { f.selects = append(f.selects, recordedPrompt{title: title, options: options, defaultValue: defaultValue}) if f.selectErr != nil { return "", f.selectErr } + // Run the validator the way a terminal prompt would, so what it refuses is + // covered through the same seam the huh implementation uses. + if validate != nil { + if err := validate(f.selectAnswer); err != nil { + return "", err + } + } + return f.selectAnswer, nil } @@ -129,6 +150,15 @@ func membership(name string, isDefault bool) *action.MembershipItem { return &action.MembershipItem{Default: isDefault, Org: &action.OrgItem{Name: name}} } +// viewerMembership is one the caller can only read, where no workflow can be +// created and so no tracing can be set up. +func viewerMembership(name string, isDefault bool) *action.MembershipItem { + m := membership(name, isDefault) + m.Role = action.RoleViewer + + return m +} + // Fixtures the table cases share. const ( orgAcme = "acme" @@ -221,16 +251,25 @@ func TestResolveInteractiveOrganization(t *testing.T) { fromYML string currentOrg string answer string - wantValue string - wantSave bool + // inputAnswer is what the user types when there is no organization to + // pick and one has to be created. + inputAnswer string + wantValue string + wantSave bool // wantDefault is the preselected entry; empty means no prompt is expected wantDefault string + // wantCreated is the organization the resolver asked to have created. + wantCreated string wantErr string }{ { - name: "belonging to no organization is an error that points at the fix", - orgs: nil, - wantErr: "chainloop organization create", + // Nothing to select, so the only way forward is the first one. + name: "belonging to no organization creates one", + orgs: nil, + inputAnswer: "My First Org", + wantValue: "my-first-org", + wantSave: true, + wantCreated: "my-first-org", }, { name: "a single organization is used without prompting", @@ -292,12 +331,50 @@ func TestResolveInteractiveOrganization(t *testing.T) { wantSave: true, wantDefault: orgAcme, }, + { + // Shown so it is clear the organization is still there, and why it + // cannot be used, rather than going missing from the list. + name: "an organization the user can only view is marked and refused", + orgs: []*action.MembershipItem{membership(orgAcme, true), viewerMembership(orgGlobex, false)}, + answer: orgGlobex + readOnlyOrgMarker, + wantDefault: orgAcme, + wantErr: "you can only view", + }, + { + // The cursor cannot start on an entry that would be refused, even + // when the server calls it the current one. + name: "a viewer organization is never the preselected one", + orgs: []*action.MembershipItem{viewerMembership(orgAcme, true), membership(orgGlobex, false)}, + answer: orgGlobex, + wantValue: orgGlobex, + wantSave: true, + wantDefault: orgGlobex, + }, + { + // Not the same as belonging to none: there is nothing to create here, + // only someone to ask. + name: "only viewer organizations leaves nothing to set up", + orgs: []*action.MembershipItem{viewerMembership(orgAcme, true), viewerMembership(orgGlobex, false)}, + wantErr: "write permissions on one of their projects", + }, + { + // One usable organization is still used without asking, whatever the + // caller can only view beside it. + name: "a single usable organization beside a viewer one still prompts", + orgs: []*action.MembershipItem{membership(orgAcme, false), viewerMembership(orgGlobex, false)}, + answer: orgAcme, + wantValue: orgAcme, + wantSave: true, + + wantDefault: orgAcme, + }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { - p := &fakePrompter{selectAnswer: tc.answer} - got, err := resolveInteractiveOrganization(context.Background(), &fakeOrgLister{orgs: tc.orgs}, p, tc.fromYML, tc.currentOrg) + p := &fakePrompter{selectAnswer: tc.answer, inputAnswer: tc.inputAnswer} + api := &fakeOrgLister{orgs: tc.orgs} + got, err := resolveInteractiveOrganization(context.Background(), api, p, tc.fromYML, tc.currentOrg) if tc.wantErr != "" { require.Error(t, err) @@ -308,6 +385,14 @@ func TestResolveInteractiveOrganization(t *testing.T) { require.NoError(t, err) assert.Equal(t, tc.wantValue, got.value) assert.Equal(t, tc.wantSave, got.save) + assert.Equal(t, tc.wantCreated, api.created, "organization created") + + if tc.wantCreated != "" { + assert.Empty(t, p.selects, "there was nothing to pick from") + require.Len(t, p.inputs, 1, "the name should have been asked for") + assert.True(t, got.prompted) + return + } if tc.wantDefault == "" { assert.Empty(t, p.selects, "no prompt should have been shown") @@ -337,6 +422,35 @@ func TestResolveInteractiveOrganizationErrors(t *testing.T) { &fakeOrgLister{orgs: orgs}, &fakePrompter{selectErr: errAborted}, "", "") require.ErrorIs(t, err, errAborted) }) + + // An instance can restrict creating organizations to its administrators, + // which leaves a user who belongs to none with nothing to do but ask. + t.Run("an instance that forbids creating organizations says so", func(t *testing.T) { + api := &fakeOrgLister{createErr: status.Error(codes.PermissionDenied, "restricted to instance admins")} + _, err := resolveInteractiveOrganization(context.Background(), + api, &fakePrompter{inputAnswer: "my-org"}, "", "") + require.Error(t, err) + assert.Contains(t, err.Error(), "ask to be invited") + assert.Equal(t, "my-org", api.created, "it was attempted") + }) + + t.Run("a creation failure is returned", func(t *testing.T) { + _, err := resolveInteractiveOrganization(context.Background(), + &fakeOrgLister{createErr: errors.New("boom")}, &fakePrompter{inputAnswer: "my-org"}, "", "") + require.Error(t, err) + assert.Contains(t, err.Error(), "boom") + }) + + // A name the control plane would refuse is caught at the prompt, so nothing + // is attempted with it. + t.Run("an unusable new name is refused before creating", func(t *testing.T) { + api := &fakeOrgLister{} + _, err := resolveInteractiveOrganization(context.Background(), + api, &fakePrompter{inputAnswer: "!!!"}, "", "") + require.Error(t, err) + assert.Contains(t, err.Error(), "organization name cannot be empty") + assert.Empty(t, api.created) + }) } // --- project ----------------------------------------------------------------- diff --git a/app/cli/cmd/trace_init_prompt.go b/app/cli/cmd/trace_init_prompt.go index 9b1762137..1de19bb93 100644 --- a/app/cli/cmd/trace_init_prompt.go +++ b/app/cli/cmd/trace_init_prompt.go @@ -58,7 +58,7 @@ func newHuhPrompter(lookupEnv func(string) (string, bool)) *huhPrompter { // Select asks the user to pick one of options, starting on defaultValue. Long // lists can be narrowed by typing. -func (h *huhPrompter) Select(title string, options []string, defaultValue string) (string, error) { +func (h *huhPrompter) Select(title string, options []string, defaultValue string, validate func(string) error) (string, error) { value := defaultValue // Filtering() is deliberately not set: it does not enable filtering, it puts @@ -70,6 +70,12 @@ func (h *huhPrompter) Select(title string, options []string, defaultValue string Options(huh.NewOptions(options...)...). Value(&value) + // Only when there is one: huh calls whatever it is given, so a nil validator + // panics rather than being treated as "nothing to check". + if validate != nil { + field = field.Validate(validate) + } + if err := h.run(field); err != nil { return "", err } diff --git a/app/cli/cmd/trace_init_prompt_test.go b/app/cli/cmd/trace_init_prompt_test.go index a3444f005..f590070e6 100644 --- a/app/cli/cmd/trace_init_prompt_test.go +++ b/app/cli/cmd/trace_init_prompt_test.go @@ -17,6 +17,7 @@ package cmd import ( "bytes" + "errors" "fmt" "strings" "testing" @@ -39,12 +40,31 @@ func TestHuhPrompterSelect(t *testing.T) { // Accessible mode numbers the options and reads the choice as a line. p, out := accessiblePrompter("2\n") - got, err := p.Select("Pick one", []string{"alpha", "beta", "gamma"}, "alpha") + got, err := p.Select("Pick one", []string{"alpha", "beta", "gamma"}, "alpha", nil) require.NoError(t, err) assert.Equal(t, "beta", got) assert.Contains(t, out.String(), "Pick one") } +// A validator lets the picker show an option it will not accept, which is how +// an organization the caller can only view stays visible without being usable. +func TestHuhPrompterSelectValidate(t *testing.T) { + // The refused option first, then an acceptable one: accessible mode asks + // again rather than giving up. + p, out := accessiblePrompter("2\n1\n") + + got, err := p.Select("Pick one", []string{"alpha", "beta"}, "alpha", func(chosen string) error { + if chosen == "beta" { + return errors.New("beta is not available") + } + + return nil + }) + require.NoError(t, err) + assert.Equal(t, "alpha", got) + assert.Contains(t, out.String(), "beta is not available") +} + func TestHuhPrompterInput(t *testing.T) { p, out := accessiblePrompter("my-project\n") diff --git a/app/cli/cmd/trace_init_resolve.go b/app/cli/cmd/trace_init_resolve.go index 42893f673..c2241d71c 100644 --- a/app/cli/cmd/trace_init_resolve.go +++ b/app/cli/cmd/trace_init_resolve.go @@ -99,7 +99,7 @@ func resolveTraceIdentity(ctx context.Context, cfg *traceInitConfig, repoRoot st // and keeps the value and the save behavior resolveTraceInitConfig gave it, // which is what the save flags mean at this point. func resolveIdentityInteractively(ctx context.Context, cfg *traceInitConfig, p prompter, - currentOrg, repoDir string, orgs orgLister, pinTo func(org string) (projectLister, error)) error { + currentOrg, repoDir string, orgs orgAPI, pinTo func(org string) (projectLister, error)) error { askOrg, askProject := !cfg.saveOrganization, !cfg.saveProject if askOrg { diff --git a/app/cli/internal/trace/config/slug.go b/app/cli/internal/trace/config/slug.go index 3b6f443d3..2c2af8758 100644 --- a/app/cli/internal/trace/config/slug.go +++ b/app/cli/internal/trace/config/slug.go @@ -16,7 +16,6 @@ package config import ( - "errors" "fmt" "strings" @@ -64,20 +63,29 @@ func SlugifyDNS1123(name string) string { return slug } -// ValidateDNS1123Label reports whether name is a project name the control plane -// will accept, using the same rule the server applies at creation time so a bad -// name is caught at the prompt rather than by a failing API call. -func ValidateDNS1123Label(name string) error { +// The kinds of thing ValidateDNS1123Label names in what it reports. Projects +// and organizations are named by the same rule, and the caller says which one +// it is asking about so the message names it. +const ( + ProjectSubject = "project" + OrganizationSubject = "organization" +) + +// ValidateDNS1123Label reports whether name is one the control plane will +// accept for a subject of that kind, using the same rule the server applies at +// creation time so a bad name is caught at the prompt rather than by a failing +// API call. +func ValidateDNS1123Label(subject, name string) error { if name == "" { - return errors.New("the project name cannot be empty") + return fmt.Errorf("the %s name cannot be empty", subject) } if len(name) > validation.DNS1123LabelMaxLength { - return fmt.Errorf("the project name is %d characters long, the maximum is %d", len(name), validation.DNS1123LabelMaxLength) + return fmt.Errorf("the %s name is %d characters long, the maximum is %d", subject, len(name), validation.DNS1123LabelMaxLength) } if errs := validation.IsDNS1123Label(name); len(errs) > 0 { - return errors.New("the project name must contain only lowercase letters, numbers and dashes, and start and end with a letter or a number") + return fmt.Errorf("the %s name must contain only lowercase letters, numbers and dashes, and start and end with a letter or a number", subject) } return nil diff --git a/app/cli/internal/trace/config/slug_test.go b/app/cli/internal/trace/config/slug_test.go index af2eb2920..a5dbbb3cc 100644 --- a/app/cli/internal/trace/config/slug_test.go +++ b/app/cli/internal/trace/config/slug_test.go @@ -101,7 +101,7 @@ func TestValidateDNS1123Label(t *testing.T) { for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { - err := ValidateDNS1123Label(tc.input) + err := ValidateDNS1123Label(ProjectSubject, tc.input) if tc.wantErr == "" { assert.NoError(t, err) return @@ -109,6 +109,8 @@ func TestValidateDNS1123Label(t *testing.T) { require.Error(t, err) assert.Contains(t, err.Error(), tc.wantErr) + // The rule is the same for both; only the message differs. + assert.ErrorContains(t, ValidateDNS1123Label(OrganizationSubject, tc.input), "organization") }) } } diff --git a/app/cli/pkg/action/trace_identity.go b/app/cli/pkg/action/trace_identity.go index 8c91718b1..65ba00de4 100644 --- a/app/cli/pkg/action/trace_identity.go +++ b/app/cli/pkg/action/trace_identity.go @@ -35,6 +35,16 @@ func (e *AttestationExecutor) ListOrganizations(ctx context.Context) ([]*Members return NewMembershipList(e.actionOpts).ListOrgs(ctx) } +// CreateOrganization creates one and makes it the caller's current +// organization, which is what the control plane does on creation. `trace init` +// calls it for a user who belongs to none, where there is nothing to select and +// every later step needs one. +func (e *AttestationExecutor) CreateOrganization(ctx context.Context, name string) error { + _, err := NewOrgCreate(e.actionOpts).Run(ctx, name) + + return err +} + // ListProjects returns the projects visible in the organization the executor is // pinned to, sorted by name and deduplicated. It runs over the executor's // connection, so it targets the organization pinned with WithForcedOrganization From fb3eebe4c133fba14eec41a027716648fb173aa1 Mon Sep 17 00:00:00 2001 From: Miguel Martinez Trivino Date: Wed, 9 Sep 2026 23:13:30 +0200 Subject: [PATCH 24/30] fix(cli): put the context inside the question instead of logging it above "using your only organization" was printed as a log line over the project picker, which read as something that had happened rather than as part of what was being asked. It is context the question needs: the projects on offer come from that organization, and a new one would be created in it. Both prompts now carry it under their title, inside the same frame huh draws the question in, and the log line is gone. The organization picker itself needs none: it is the question. Asking for a first organization says why there, too. Two things had to be worked around. Accessible mode draws a title and options and no description at all, so the context is folded into the title there, where it would otherwise be lost to a screen reader. And an input's DescriptionFunc is not evaluated for the first draw, only once its binding changes, so the field sets both and opens with the context rather than an empty line. Assisted-by: Claude Code Signed-off-by: Miguel Martinez Trivino Chainloop-Trace-Sessions: 184822bb-e811-42fd-8e13-735227edc5ce --- app/cli/cmd/artifact.go | 2 +- app/cli/cmd/root.go | 2 +- app/cli/cmd/trace_init_identity.go | 72 +++++++++----- app/cli/cmd/trace_init_identity_test.go | 124 +++++++++++++----------- app/cli/cmd/trace_init_prompt.go | 72 ++++++++++---- app/cli/cmd/trace_init_prompt_test.go | 87 +++++++++++++++-- app/cli/cmd/trace_init_resolve.go | 9 +- app/cli/cmd/trace_init_steps_test.go | 4 +- app/cli/internal/trace/config/slug.go | 15 +-- 9 files changed, 264 insertions(+), 123 deletions(-) diff --git a/app/cli/cmd/artifact.go b/app/cli/cmd/artifact.go index 66725b6dc..2ce0b2a6c 100644 --- a/app/cli/cmd/artifact.go +++ b/app/cli/cmd/artifact.go @@ -48,7 +48,7 @@ func wrappedArtifactConn(cpConn *grpc.ClientConn, role pb.CASCredentialsServiceG } if apiInsecure() { - logger.Warn().Msg("API contacted in insecure mode") + logger.Debug().Msg("API contacted in insecure mode") } var opts = []grpcconn.Option{ diff --git a/app/cli/cmd/root.go b/app/cli/cmd/root.go index ddc862189..6b7b9c1e9 100644 --- a/app/cli/cmd/root.go +++ b/app/cli/cmd/root.go @@ -125,7 +125,7 @@ func NewRootCmd(l zerolog.Logger) *cobra.Command { } if apiInsecure() { - logger.Warn().Msg("API contacted in insecure mode") + logger.Debug().Msg("API contacted in insecure mode") } authToken, isUserToken, err := loadAuthToken(cmd) diff --git a/app/cli/cmd/trace_init_identity.go b/app/cli/cmd/trace_init_identity.go index 7b3c24afd..fbdeeb42d 100644 --- a/app/cli/cmd/trace_init_identity.go +++ b/app/cli/cmd/trace_init_identity.go @@ -35,6 +35,10 @@ const ( orgPromptTitle = "Select a Chainloop organization" newOrgPromptTitle = "Name for your new organization" + // newOrgPromptReason says why a name is being asked for, since arriving at + // this question means the user has no organization to pick from. + newOrgPromptReason = "you do not belong to one yet, so this is the first step" + // readOnlyOrgMarker labels an organization the caller can only view. Those // are shown rather than dropped — an organization missing from the list // reads as something gone wrong — and refused when picked. Organization @@ -109,14 +113,19 @@ type projectLister interface { // prompter asks the user to pick from a list or type a value. It keeps the // resolution logic below independent of the terminal library, so it can be // table-tested with a scripted fake. +// Every prompt takes a description, drawn under its title and inside the same +// frame. Context the question needs — which organization a project is being +// picked in, why a name is being asked for at all — belongs there rather than +// in a log line above it, which reads as something that happened rather than +// as part of the question. Empty means no line. type prompter interface { // Select asks for one of options. validate, when set, refuses an answer and // keeps the prompt open, the way Input's does. - Select(title string, options []string, defaultValue string, validate func(string) error) (string, error) + Select(title, description string, options []string, defaultValue string, validate func(string) error) (string, error) // MultiSelect asks for zero or more of options, starting with defaults // ticked. Implementations refuse an empty submission. MultiSelect(title string, options, defaults []string) ([]string, error) - Input(title, defaultValue string, validate func(string) error) (string, error) + Input(title, description, defaultValue string, validate func(string) error) (string, error) } // resolvedValue is one setting the interactive path settled on. @@ -222,14 +231,15 @@ func resolveInteractiveOrganization(ctx context.Context, api orgAPI, p prompter, return nil, errors.New("you can only view the organizations you belong to, ask someone with write permissions on one of their projects to set this up") } - // One to pick and nothing to weigh it against, so it is not worth asking. + // A single entry, which the check above makes a usable one: there is nothing + // to weigh it against, so it is not worth asking. if len(labels) == 1 { return &resolvedValue{value: usable[0], save: usable[0] != fromYML}, nil } // Preselect what the repository already pins, then what the CLI points at, // then what the server considers current, among the ones that can be picked. - chosen, err := p.Select(orgPromptTitle, labels, + chosen, err := p.Select(orgPromptTitle, "", labels, firstPresent(usable, fromYML, currentOrg, serverDefault), refuseReadOnlyOrg) if err != nil { return nil, err @@ -251,16 +261,16 @@ func refuseReadOnlyOrg(chosen string) error { } // promptNewOrganization collects a name and creates the organization, for a -// user who belongs to none. Nothing can be selected in that case and every step -// after this one needs an organization, so creating the first one is the only -// way forward. An instance can restrict who may create them, which leaves -// nothing to do but say so. +// user who belongs to none. There is nothing to select in that case and every +// later step needs an organization, so creating the first one is the only way +// forward. An instance can restrict who may create them, which leaves nothing +// to do but say so. // // It is the one point where trace init changes anything before the repository // is written to. An organization left behind by an init that is then abandoned // is the user's to keep, and is waiting for them on the next run. func promptNewOrganization(ctx context.Context, api orgAPI, p prompter, fromYML string) (*resolvedValue, error) { - answer, err := p.Input(newOrgPromptTitle, "", validateNewOrganization) + answer, err := p.Input(newOrgPromptTitle, newOrgPromptReason, "", validateNewOrganization) if err != nil { return nil, err } @@ -279,6 +289,18 @@ func promptNewOrganization(ctx context.Context, api orgAPI, p prompter, fromYML return &resolvedValue{value: name, save: name != fromYML, prompted: true}, nil } +// organizationLine is the description the project prompts carry: which +// organization the projects come from, and the one a new project would be +// created in. It reports nothing when the organization is not known, which is +// the case only when no organization was ever resolved. +func organizationLine(organization string) string { + if organization == "" { + return "" + } + + return fmt.Sprintf("organization %s", organization) +} + // validateNewOrganization checks what a free-form name normalizes to, so a name // the control plane would refuse is caught at the prompt. Organizations are // named by the same rule projects are. @@ -287,9 +309,11 @@ func validateNewOrganization(answer string) error { } // resolveInteractiveProject picks the project the workflow is created in, or -// collects a name for a new one. fromYML is what .chainloop.yml pins and -// repoDir the repository's directory name, used to seed a new name. -func resolveInteractiveProject(ctx context.Context, lister projectLister, p prompter, fromYML, repoDir string) (*resolvedValue, error) { +// collects a name for a new one. fromYML is what .chainloop.yml pins, repoDir +// the repository's directory name, used to seed a new name, and organization +// the one already settled on, shown with the question since it is what the +// projects on offer are drawn from. +func resolveInteractiveProject(ctx context.Context, lister projectLister, p prompter, fromYML, repoDir, organization string) (*resolvedValue, error) { listing, err := lister.ListProjects(ctx) if err != nil { return nil, fmt.Errorf("listing the projects you can see: %w", err) @@ -313,11 +337,10 @@ func resolveInteractiveProject(ctx context.Context, lister projectLister, p prom } // newProjectSeed is what the new-project prompt starts from: the repository's - // own directory name, whatever .chainloop.yml holds. A project the + // own directory name, no matter what .chainloop.yml holds. A project the // organization already has is picked from the list rather than typed, and a // name it does not have is one nothing was ever created under — proposing it - // again would hand back a name that is as likely to be a typo as an - // intention. + // again would hand back a name as likely to be a typo as an intention. newProjectSeed := config.SlugifyDNS1123(repoDir) // pinnedIsMissing records that .chainloop.yml names a project the @@ -332,11 +355,10 @@ func resolveInteractiveProject(ctx context.Context, lister projectLister, p prom logger.Warn().Str("project", fromYML). Msg("you cannot create a workflow in the project this repository points at, pick another one") } else { - // Not in the organization at all, so it is a name for a project that does - // not exist rather than one that can be picked. Offering it as if it were - // real is what makes selecting it fail on a project the caller may not be - // allowed to create. Nothing points anywhere usable, so the cursor starts - // on creating one; the name is asked for rather than carried over. + // Not in the organization at all, so it names a project that does not + // exist rather than one that can be picked: offering it as if it were + // real is what makes picking it fail at creation time. Nothing pinned + // points anywhere usable, so the cursor starts on creating instead. pinnedIsMissing = true logger.Debug().Str("project", fromYML). @@ -355,7 +377,7 @@ func resolveInteractiveProject(ctx context.Context, lister projectLister, p prom // Nothing to choose from, so go straight to naming one rather than showing a // list holding nothing but the create entry. if len(writable) == 0 { - return promptNewProject(p, newProjectSeed, fromYML, readOnly) + return promptNewProject(p, organization, newProjectSeed, fromYML, readOnly) } // The create entry is only offered to a role that may act on it; offering it @@ -374,13 +396,13 @@ func resolveInteractiveProject(ctx context.Context, lister projectLister, p prom preselect = createNewProjectOption } - chosen, err := p.Select(title, options, firstPresent(options, preselect), nil) + chosen, err := p.Select(title, organizationLine(organization), options, firstPresent(options, preselect), nil) if err != nil { return nil, err } if chosen == createNewProjectOption { - return promptNewProject(p, newProjectSeed, fromYML, readOnly) + return promptNewProject(p, organization, newProjectSeed, fromYML, readOnly) } return &resolvedValue{value: chosen, save: chosen != fromYML, prompted: true}, nil @@ -390,8 +412,8 @@ func resolveInteractiveProject(ctx context.Context, lister projectLister, p prom // to the DNS-1123 label the control plane stores. A name that normalizes to one // of the existing projects simply resolves to that project, so readOnly names // are refused: typing one is the same dead end as picking it from the list. -func promptNewProject(p prompter, defaultName, fromYML string, readOnly []string) (*resolvedValue, error) { - answer, err := p.Input(newProjectPromptTitle, defaultName, newProjectValidator(readOnly)) +func promptNewProject(p prompter, organization, defaultName, fromYML string, readOnly []string) (*resolvedValue, error) { + answer, err := p.Input(newProjectPromptTitle, organizationLine(organization), defaultName, newProjectValidator(readOnly)) if err != nil { return nil, err } diff --git a/app/cli/cmd/trace_init_identity_test.go b/app/cli/cmd/trace_init_identity_test.go index 310cbde31..b7736c22c 100644 --- a/app/cli/cmd/trace_init_identity_test.go +++ b/app/cli/cmd/trace_init_identity_test.go @@ -32,7 +32,9 @@ import ( // --- fakes ------------------------------------------------------------------- -type fakeOrgLister struct { +// fakeOrgAPI serves a canned membership list and records what the resolver +// asked to have created. +type fakeOrgAPI struct { orgs []*action.MembershipItem err error @@ -42,11 +44,11 @@ type fakeOrgLister struct { created string } -func (f *fakeOrgLister) ListOrganizations(context.Context) ([]*action.MembershipItem, error) { +func (f *fakeOrgAPI) ListOrganizations(context.Context) ([]*action.MembershipItem, error) { return f.orgs, f.err } -func (f *fakeOrgLister) CreateOrganization(_ context.Context, name string) error { +func (f *fakeOrgAPI) CreateOrganization(_ context.Context, name string) error { f.created = name return f.createErr @@ -82,7 +84,9 @@ func (f *fakeProjectLister) ListProjects(context.Context) (*action.TraceProjects // recordedPrompt captures what a prompt was shown, so tests can assert on the // options and the preselected default rather than only on the outcome. type recordedPrompt struct { - title string + title string + // description is the line drawn under the title, inside the same frame. + description string options []string defaultValue string // defaults is the preselected set for a multi-select. @@ -114,8 +118,8 @@ func (f *fakePrompter) MultiSelect(title string, options, defaults []string) ([] return f.multiSelectAnswer, nil } -func (f *fakePrompter) Select(title string, options []string, defaultValue string, validate func(string) error) (string, error) { - f.selects = append(f.selects, recordedPrompt{title: title, options: options, defaultValue: defaultValue}) +func (f *fakePrompter) Select(title, description string, options []string, defaultValue string, validate func(string) error) (string, error) { + f.selects = append(f.selects, recordedPrompt{title: title, description: description, options: options, defaultValue: defaultValue}) if f.selectErr != nil { return "", f.selectErr } @@ -131,8 +135,8 @@ func (f *fakePrompter) Select(title string, options []string, defaultValue strin return f.selectAnswer, nil } -func (f *fakePrompter) Input(title, defaultValue string, validate func(string) error) (string, error) { - f.inputs = append(f.inputs, recordedPrompt{title: title, defaultValue: defaultValue}) +func (f *fakePrompter) Input(title, description, defaultValue string, validate func(string) error) (string, error) { + f.inputs = append(f.inputs, recordedPrompt{title: title, description: description, defaultValue: defaultValue}) if f.inputErr != nil { return "", f.inputErr } @@ -161,6 +165,10 @@ func viewerMembership(name string, isDefault bool) *action.MembershipItem { // Fixtures the table cases share. const ( + // nameWithNothingUsable normalizes to an empty string, so it is refused + // wherever a name is asked for. + nameWithNothingUsable = "!!!" + orgAcme = "acme" orgGlobex = "globex" projectAPI = "backend-api" @@ -254,8 +262,10 @@ func TestResolveInteractiveOrganization(t *testing.T) { // inputAnswer is what the user types when there is no organization to // pick and one has to be created. inputAnswer string - wantValue string - wantSave bool + // createErr is what creating the organization fails with. + createErr error + wantValue string + wantSave bool // wantDefault is the preselected entry; empty means no prompt is expected wantDefault string // wantCreated is the organization the resolver asked to have created. @@ -271,6 +281,30 @@ func TestResolveInteractiveOrganization(t *testing.T) { wantSave: true, wantCreated: "my-first-org", }, + { + // An instance can restrict creating organizations to its + // administrators, which leaves a user who belongs to none with + // nothing to do but ask. + name: "an instance that forbids creating organizations says so", + inputAnswer: "my-org", + createErr: status.Error(codes.PermissionDenied, "restricted to instance admins"), + wantCreated: "my-org", + wantErr: "ask to be invited", + }, + { + name: "a creation failure is returned", + inputAnswer: "my-org", + createErr: errors.New("boom"), + wantCreated: "my-org", + wantErr: "boom", + }, + { + // A name the control plane would refuse is caught at the prompt, so + // nothing is attempted with it. + name: "an unusable new name is refused before creating", + inputAnswer: nameWithNothingUsable, + wantErr: "organization name cannot be empty", + }, { name: "a single organization is used without prompting", orgs: []*action.MembershipItem{membership(orgAcme, true)}, @@ -358,14 +392,13 @@ func TestResolveInteractiveOrganization(t *testing.T) { wantErr: "write permissions on one of their projects", }, { - // One usable organization is still used without asking, whatever the - // caller can only view beside it. - name: "a single usable organization beside a viewer one still prompts", - orgs: []*action.MembershipItem{membership(orgAcme, false), viewerMembership(orgGlobex, false)}, - answer: orgAcme, - wantValue: orgAcme, - wantSave: true, - + // The one usable organization is not the only entry, so the picker is + // still shown: the viewer one has to be visible to be understood. + name: "a single usable organization beside a viewer one still prompts", + orgs: []*action.MembershipItem{membership(orgAcme, false), viewerMembership(orgGlobex, false)}, + answer: orgAcme, + wantValue: orgAcme, + wantSave: true, wantDefault: orgAcme, }, } @@ -373,9 +406,13 @@ func TestResolveInteractiveOrganization(t *testing.T) { for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { p := &fakePrompter{selectAnswer: tc.answer, inputAnswer: tc.inputAnswer} - api := &fakeOrgLister{orgs: tc.orgs} + api := &fakeOrgAPI{orgs: tc.orgs, createErr: tc.createErr} got, err := resolveInteractiveOrganization(context.Background(), api, p, tc.fromYML, tc.currentOrg) + // Asserted before the error check too, so a case that must not reach + // the control plane says so. + assert.Equal(t, tc.wantCreated, api.created, "organization created") + if tc.wantErr != "" { require.Error(t, err) assert.Contains(t, err.Error(), tc.wantErr) @@ -385,7 +422,6 @@ func TestResolveInteractiveOrganization(t *testing.T) { require.NoError(t, err) assert.Equal(t, tc.wantValue, got.value) assert.Equal(t, tc.wantSave, got.save) - assert.Equal(t, tc.wantCreated, api.created, "organization created") if tc.wantCreated != "" { assert.Empty(t, p.selects, "there was nothing to pick from") @@ -411,7 +447,7 @@ func TestResolveInteractiveOrganization(t *testing.T) { func TestResolveInteractiveOrganizationErrors(t *testing.T) { t.Run("a listing failure is returned, not swallowed", func(t *testing.T) { _, err := resolveInteractiveOrganization(context.Background(), - &fakeOrgLister{err: errors.New("boom")}, &fakePrompter{}, "", "") + &fakeOrgAPI{err: errors.New("boom")}, &fakePrompter{}, "", "") require.Error(t, err) assert.Contains(t, err.Error(), "boom") }) @@ -419,38 +455,9 @@ func TestResolveInteractiveOrganizationErrors(t *testing.T) { t.Run("an aborted prompt is returned", func(t *testing.T) { orgs := []*action.MembershipItem{membership(orgAcme, true), membership(orgGlobex, false)} _, err := resolveInteractiveOrganization(context.Background(), - &fakeOrgLister{orgs: orgs}, &fakePrompter{selectErr: errAborted}, "", "") + &fakeOrgAPI{orgs: orgs}, &fakePrompter{selectErr: errAborted}, "", "") require.ErrorIs(t, err, errAborted) }) - - // An instance can restrict creating organizations to its administrators, - // which leaves a user who belongs to none with nothing to do but ask. - t.Run("an instance that forbids creating organizations says so", func(t *testing.T) { - api := &fakeOrgLister{createErr: status.Error(codes.PermissionDenied, "restricted to instance admins")} - _, err := resolveInteractiveOrganization(context.Background(), - api, &fakePrompter{inputAnswer: "my-org"}, "", "") - require.Error(t, err) - assert.Contains(t, err.Error(), "ask to be invited") - assert.Equal(t, "my-org", api.created, "it was attempted") - }) - - t.Run("a creation failure is returned", func(t *testing.T) { - _, err := resolveInteractiveOrganization(context.Background(), - &fakeOrgLister{createErr: errors.New("boom")}, &fakePrompter{inputAnswer: "my-org"}, "", "") - require.Error(t, err) - assert.Contains(t, err.Error(), "boom") - }) - - // A name the control plane would refuse is caught at the prompt, so nothing - // is attempted with it. - t.Run("an unusable new name is refused before creating", func(t *testing.T) { - api := &fakeOrgLister{} - _, err := resolveInteractiveOrganization(context.Background(), - api, &fakePrompter{inputAnswer: "!!!"}, "", "") - require.Error(t, err) - assert.Contains(t, err.Error(), "organization name cannot be empty") - assert.Empty(t, api.created) - }) } // --- project ----------------------------------------------------------------- @@ -590,7 +597,7 @@ func TestResolveInteractiveProject(t *testing.T) { projects: []string{projectAPI}, repoDir: "repo", selectAnswer: createNewProjectOption, - inputAnswer: "!!!", + inputAnswer: nameWithNothingUsable, wantErr: "cannot be empty", }, { @@ -691,7 +698,7 @@ func TestResolveInteractiveProject(t *testing.T) { t.Run(tc.name, func(t *testing.T) { p := &fakePrompter{selectAnswer: tc.selectAnswer, inputAnswer: tc.inputAnswer} got, err := resolveInteractiveProject(context.Background(), - &fakeProjectLister{projects: tc.projects, readOnly: tc.readOnly, cannotCreate: tc.cannotCreate}, p, tc.fromYML, tc.repoDir) + &fakeProjectLister{projects: tc.projects, readOnly: tc.readOnly, cannotCreate: tc.cannotCreate}, p, tc.fromYML, tc.repoDir, orgAcme) if tc.wantErr != "" { require.Error(t, err) @@ -719,11 +726,16 @@ func TestResolveInteractiveProject(t *testing.T) { } assert.Equal(t, wantTitle, p.selects[0].title) + // Which organization the projects come from is part of the + // question, not a line logged before it. + assert.Equal(t, "organization "+orgAcme, p.selects[0].description) } if tc.wantInputDefault != "" { require.Len(t, p.inputs, 1) assert.Equal(t, tc.wantInputDefault, p.inputs[0].defaultValue) + assert.Equal(t, "organization "+orgAcme, p.inputs[0].description, + "a new project is created in it, so it is part of that question too") } }) } @@ -732,20 +744,20 @@ func TestResolveInteractiveProject(t *testing.T) { func TestResolveInteractiveProjectErrors(t *testing.T) { t.Run("a listing failure is returned, not swallowed", func(t *testing.T) { _, err := resolveInteractiveProject(context.Background(), - &fakeProjectLister{err: errors.New("boom")}, &fakePrompter{}, "", "repo") + &fakeProjectLister{err: errors.New("boom")}, &fakePrompter{}, "", "repo", orgAcme) require.Error(t, err) assert.Contains(t, err.Error(), "boom") }) t.Run("an aborted list is returned", func(t *testing.T) { _, err := resolveInteractiveProject(context.Background(), - &fakeProjectLister{projects: []string{"a"}}, &fakePrompter{selectErr: errAborted}, "", "repo") + &fakeProjectLister{projects: []string{"a"}}, &fakePrompter{selectErr: errAborted}, "", "repo", orgAcme) require.ErrorIs(t, err, errAborted) }) t.Run("an aborted input is returned", func(t *testing.T) { _, err := resolveInteractiveProject(context.Background(), - &fakeProjectLister{}, &fakePrompter{inputErr: errAborted}, "", "repo") + &fakeProjectLister{}, &fakePrompter{inputErr: errAborted}, "", "repo", orgAcme) require.ErrorIs(t, err, errAborted) }) } diff --git a/app/cli/cmd/trace_init_prompt.go b/app/cli/cmd/trace_init_prompt.go index 1de19bb93..49ff3cd72 100644 --- a/app/cli/cmd/trace_init_prompt.go +++ b/app/cli/cmd/trace_init_prompt.go @@ -56,10 +56,23 @@ func newHuhPrompter(lookupEnv func(string) (string, bool)) *huhPrompter { return &huhPrompter{accessible: accessible, in: os.Stdin, out: os.Stderr} } +// framed folds the description into the title when huh will not draw it. +// Accessible mode renders the title and the options and nothing else, so a +// description left in its own slot is simply lost there — and it carries the +// context the question needs, which is worth more than the layout. +func (h *huhPrompter) framed(title, description string) (string, string) { + if description == "" || !h.accessible { + return title, description + } + + return fmt.Sprintf("%s (%s)", title, description), "" +} + // Select asks the user to pick one of options, starting on defaultValue. Long // lists can be narrowed by typing. -func (h *huhPrompter) Select(title string, options []string, defaultValue string, validate func(string) error) (string, error) { +func (h *huhPrompter) Select(title, description string, options []string, defaultValue string, validate func(string) error) (string, error) { value := defaultValue + title, description = h.framed(title, description) // Filtering() is deliberately not set: it does not enable filtering, it puts // the field straight into filter-input mode, which replaces the title with an @@ -67,11 +80,12 @@ func (h *huhPrompter) Select(title string, options []string, defaultValue string // on demand, which is what the help line offers. field := huh.NewSelect[string](). Title(title). + Description(description). Options(huh.NewOptions(options...)...). Value(&value) - // Only when there is one: huh calls whatever it is given, so a nil validator - // panics rather than being treated as "nothing to check". + // Set only when there is one: huh calls whatever it is handed, so a nil + // validator panics rather than reading as "nothing to check". if validate != nil { field = field.Validate(validate) } @@ -124,29 +138,49 @@ func newMultiSelectField(title string, options []string, value *[]string) *huh.M } // Input asks the user to type a value, prefilled with defaultValue. The -// description line shows what a free-form answer will be normalized to, so the -// result is visible before it is submitted. -func (h *huhPrompter) Input(title, defaultValue string, validate func(string) error) (string, error) { +// description line gives way to what a free-form answer will be normalized to, +// so the result is visible before it is submitted: once the answer needs +// normalizing, seeing what it becomes matters more than the standing context. +func (h *huhPrompter) Input(title, description, defaultValue string, validate func(string) error) (string, error) { value := defaultValue + title, description = h.framed(title, description) + + if err := h.run(newInputField(title, description, &value, validate)); err != nil { + return "", err + } + + return value, nil +} - field := huh.NewInput(). +// newInputField builds the input. It is separate from Input so a test can +// render it without a terminal to run a form against, the same way +// newMultiSelectField is. +func newInputField(title, description string, value *string, validate func(string) error) *huh.Input { + return huh.NewInput(). Title(title). - Value(&value). + // Both: DescriptionFunc is not evaluated for the first draw, only once + // its binding changes, so on its own it leaves an empty line where the + // context should be until the user types something. + Description(description). + Value(value). Validate(validate). - DescriptionFunc(func() string { - slug := config.SlugifyDNS1123(value) - if slug == "" || slug == value { - return "" - } - - return fmt.Sprintf("will be created as %q", slug) - }, &value) + DescriptionFunc(func() string { return inputDescription(description, *value) }, value) +} - if err := h.run(field); err != nil { - return "", err +// inputDescription is the line under an input's title: the standing context, +// giving way to what a free-form answer will be normalized to once the answer +// and its label differ, since seeing what will be created matters more at that +// point than the context it will be created in. +// +// Accessible mode folds the context into the title instead and draws no +// description at all, so the normalized name is not shown there. +func inputDescription(description, value string) string { + slug := config.SlugifyDNS1123(value) + if slug == "" || slug == value { + return description } - return value, nil + return fmt.Sprintf("will be created as %q", slug) } // run shows a single-field form and translates a dismissed prompt into diff --git a/app/cli/cmd/trace_init_prompt_test.go b/app/cli/cmd/trace_init_prompt_test.go index f590070e6..91a12c6af 100644 --- a/app/cli/cmd/trace_init_prompt_test.go +++ b/app/cli/cmd/trace_init_prompt_test.go @@ -36,14 +36,22 @@ func accessiblePrompter(input string) (*huhPrompter, *bytes.Buffer) { return &huhPrompter{accessible: true, in: strings.NewReader(input), out: out}, out } +// The options the Select tests pick between, in the order they are offered. +const ( + optionAlpha = "alpha" + optionBeta = "beta" + optionGamma = "gamma" +) + func TestHuhPrompterSelect(t *testing.T) { // Accessible mode numbers the options and reads the choice as a line. p, out := accessiblePrompter("2\n") - got, err := p.Select("Pick one", []string{"alpha", "beta", "gamma"}, "alpha", nil) + got, err := p.Select("Pick one", "in some context", []string{optionAlpha, optionBeta, optionGamma}, optionAlpha, nil) require.NoError(t, err) - assert.Equal(t, "beta", got) + assert.Equal(t, optionBeta, got) assert.Contains(t, out.String(), "Pick one") + assert.Contains(t, out.String(), "in some context", "the description belongs with the question") } // A validator lets the picker show an option it will not accept, which is how @@ -53,25 +61,88 @@ func TestHuhPrompterSelectValidate(t *testing.T) { // again rather than giving up. p, out := accessiblePrompter("2\n1\n") - got, err := p.Select("Pick one", []string{"alpha", "beta"}, "alpha", func(chosen string) error { - if chosen == "beta" { - return errors.New("beta is not available") + const refusal = "beta is not available" + + got, err := p.Select("Pick one", "", []string{optionAlpha, optionBeta}, optionAlpha, func(chosen string) error { + if chosen == optionBeta { + return errors.New(refusal) } return nil }) require.NoError(t, err) - assert.Equal(t, "alpha", got) - assert.Contains(t, out.String(), "beta is not available") + assert.Equal(t, optionAlpha, got) + assert.Contains(t, out.String(), refusal) } func TestHuhPrompterInput(t *testing.T) { p, out := accessiblePrompter("my-project\n") - got, err := p.Input("Project name", "default-name", newProjectValidator(nil)) + got, err := p.Input("Project name", "in some context", "default-name", newProjectValidator(nil)) require.NoError(t, err) assert.Equal(t, "my-project", got) assert.Contains(t, out.String(), "Project name") + assert.Contains(t, out.String(), "in some context") +} + +// TestInputDescription covers the line under an input's title directly: the +// full terminal UI is what draws it, and accessible mode — the only way to +// drive a prompt without a terminal — renders no description at all. +func TestInputDescription(t *testing.T) { + const context = "organization acme" + + testCases := []struct { + name string + description string + value string + want string + }{ + {name: "an empty answer keeps the context", description: context, want: context}, + { + name: "an answer that needs no normalizing keeps it", + description: context, value: "my-project", want: context, + }, + { + name: "an answer that normalizes shows what it becomes", + description: context, value: "My Project", want: `will be created as "my-project"`, + }, + { + // Nothing usable to show yet, so the context stands rather than + // flickering to an empty line. + name: "an answer with nothing usable in it keeps the context", + description: context, value: "!!!", want: context, + }, + {name: "no context and no answer shows nothing", value: "my-project"}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.want, inputDescription(tc.description, tc.value)) + }) + } +} + +// An input's description has to be there on the first draw, before anything is +// typed: huh does not evaluate DescriptionFunc until its binding changes, so a +// field relying on that alone opens with an empty line where the context goes. +func TestInputFieldShowsItsDescriptionBeforeTyping(t *testing.T) { + const context = "organization acme" + + name := "" + form := huh.NewForm(huh.NewGroup(newInputField(newProjectPromptTitle, context, &name, nil))) + _ = form.Init() + + assert.Contains(t, form.View(), context, "the context must be on screen before anything is typed") +} + +// Accessible mode draws the title and the options and nothing else, so context +// left in the description slot would be lost to a screen reader. +func TestHuhPrompterAccessibleFoldsTheDescriptionIn(t *testing.T) { + p, out := accessiblePrompter("1\n") + + _, err := p.Select("Pick one", "organization acme", []string{optionAlpha}, optionAlpha, nil) + require.NoError(t, err) + assert.Contains(t, out.String(), "Pick one (organization acme)") } func TestHuhPrompterMultiSelect(t *testing.T) { diff --git a/app/cli/cmd/trace_init_resolve.go b/app/cli/cmd/trace_init_resolve.go index c2241d71c..96f73de6f 100644 --- a/app/cli/cmd/trace_init_resolve.go +++ b/app/cli/cmd/trace_init_resolve.go @@ -108,10 +108,9 @@ func resolveIdentityInteractively(ctx context.Context, cfg *traceInitConfig, p p return err } - if !org.prompted { - logger.Info().Str("organization", org.value).Msg("using your only organization") - } - + // An organization settled without asking is not announced here: the + // project prompt that follows names it, and the closing summary names it + // again. A line of its own would only read as something that happened. cfg.organization, cfg.saveOrganization = org.value, org.save } @@ -124,7 +123,7 @@ func resolveIdentityInteractively(ctx context.Context, cfg *traceInitConfig, p p return nil } - project, err := resolveInteractiveProject(ctx, projects, p, cfg.project, repoDir) + project, err := resolveInteractiveProject(ctx, projects, p, cfg.project, repoDir, cfg.organization) if err != nil { return err } diff --git a/app/cli/cmd/trace_init_steps_test.go b/app/cli/cmd/trace_init_steps_test.go index de19244b1..6829b701c 100644 --- a/app/cli/cmd/trace_init_steps_test.go +++ b/app/cli/cmd/trace_init_steps_test.go @@ -30,7 +30,7 @@ import ( // identityStepsFixture wires resolveIdentityInteractively to fakes and records // which organization the connection was repinned to. type identityStepsFixture struct { - orgs *fakeOrgLister + orgs *fakeOrgAPI prompt *fakePrompter // defaultProjects is what a pinned connection lists, unless projectsByOrg @@ -63,7 +63,7 @@ func (f *identityStepsFixture) pinTo(org string) (projectLister, error) { func newIdentityStepsFixture(orgs []*action.MembershipItem, projects []string, p *fakePrompter) *identityStepsFixture { return &identityStepsFixture{ - orgs: &fakeOrgLister{orgs: orgs}, + orgs: &fakeOrgAPI{orgs: orgs}, defaultProjects: projects, prompt: p, } diff --git a/app/cli/internal/trace/config/slug.go b/app/cli/internal/trace/config/slug.go index 2c2af8758..0c298b02e 100644 --- a/app/cli/internal/trace/config/slug.go +++ b/app/cli/internal/trace/config/slug.go @@ -63,19 +63,22 @@ func SlugifyDNS1123(name string) string { return slug } -// The kinds of thing ValidateDNS1123Label names in what it reports. Projects -// and organizations are named by the same rule, and the caller says which one -// it is asking about so the message names it. +// Subject is the kind of thing ValidateDNS1123Label names in what it reports. +// Projects and organizations are named by the same rule, so the caller says +// which one it is asking about and the message names it. It is a type of its +// own so it cannot be swapped with the name being validated. +type Subject string + const ( - ProjectSubject = "project" - OrganizationSubject = "organization" + ProjectSubject Subject = "project" + OrganizationSubject Subject = "organization" ) // ValidateDNS1123Label reports whether name is one the control plane will // accept for a subject of that kind, using the same rule the server applies at // creation time so a bad name is caught at the prompt rather than by a failing // API call. -func ValidateDNS1123Label(subject, name string) error { +func ValidateDNS1123Label(subject Subject, name string) error { if name == "" { return fmt.Errorf("the %s name cannot be empty", subject) } From 8ecd46e751b3d53ac99bddc8f8197aca095babb1 Mon Sep 17 00:00:00 2001 From: Miguel Martinez Trivino Date: Wed, 9 Sep 2026 23:19:48 +0200 Subject: [PATCH 25/30] fix: refuse an organization viewer a project, and undo hooks on a failed init Creating a project was reported as allowed for anyone RBAC does not narrow, which is the administrators and the organization viewer alike. A viewer can read, so the API-level check refuses it the workflow that follows, and the listing was offering an option that could only fail. Only the administrators answer yes there now; an API token outside RBAC still acts for its organization. The test pinning the reported answer to the enforced one is gone. Extracting the reported answer out of the enforcing call made the two the same code, so the test compared a function to itself and would have passed whatever it did. What is worth pinning is agreement with the create RPC as the middleware reaches it, which is the service-level test tracked in #3419. Installing the harness hooks first means a later failure left them behind, calling a repository that was never set up. The rest of the setup is one step now, and its failure takes the hooks back out again. The organization on the project prompts reads as a sentence rather than a label and a value, which looked like one more row above the options it sits over. Assisted-by: Claude Code Signed-off-by: Miguel Martinez Trivino Chainloop-Trace-Sessions: 184822bb-e811-42fd-8e13-735227edc5ce --- app/cli/cmd/trace_init.go | 62 ++++++++++++------- app/cli/cmd/trace_init_identity.go | 4 +- app/cli/cmd/trace_init_identity_test.go | 4 +- app/cli/cmd/trace_init_test.go | 20 ++++++ .../service/project_permissions_test.go | 38 ++++++------ app/controlplane/internal/service/service.go | 11 +++- 6 files changed, 92 insertions(+), 47 deletions(-) diff --git a/app/cli/cmd/trace_init.go b/app/cli/cmd/trace_init.go index d82f46f04..7d9bc2a37 100644 --- a/app/cli/cmd/trace_init.go +++ b/app/cli/cmd/trace_init.go @@ -134,31 +134,21 @@ The organization, project, workflow and require-trace values are saved to return fmt.Errorf("no harness hooks could be installed, so no sessions would be recorded") } - // The workflow exists and something records into it: from here on it - // is safe to leave hooks and configuration behind. - if err := cfg.save(repoRoot); err != nil { - return err - } - - // Create trace directory structure - store := state.NewGitStore(gitDir) - if err := store.InitTraceDir(); err != nil { - return fmt.Errorf("create trace directory: %w", err) - } + // The workflow exists and something records into it. What remains is + // local, but a failure in any of it would leave harness hooks behind + // that call a repository which is not set up, so they are taken back + // out again. UninstallHooks removes only Chainloop's own entries, + // leaving the rest of a harness's configuration alone. + if err := writeTraceInitState(cfg, repoRoot, gitDir); err != nil { + for _, p := range installed { + if uerr := p.UninstallHooks(repoRoot); uerr != nil { + logger.Debug().Err(uerr).Str("harness", p.Name()). + Msg("could not take the harness hooks back out") + } + } - // Install git hooks - hooksDir, err := hooks.Install(gitDir, false) - if err != nil { return err } - logger.Debug(). - Str("path", hooksDir). - Msg("git hooks installed (post-commit, pre-push)") - - // Mark trace as initialized - if err := store.MarkTraceInitialized(); err != nil { - return fmt.Errorf("mark trace initialized: %w", err) - } workDir, err := os.Getwd() if err != nil { @@ -279,6 +269,34 @@ func joinWithOr(names []string) string { } } +// writeTraceInitState leaves the repository set up: the configuration, the +// trace directory, the managed git hooks and the marker that says init ran. It +// is one step so its caller can undo the harness hooks if any part of it fails, +// rather than leaving a half-configured repository behind. +func writeTraceInitState(cfg *traceInitConfig, repoRoot, gitDir string) error { + if err := cfg.save(repoRoot); err != nil { + return err + } + + store := state.NewGitStore(gitDir) + if err := store.InitTraceDir(); err != nil { + return fmt.Errorf("create trace directory: %w", err) + } + + hooksDir, err := hooks.Install(gitDir, false) + if err != nil { + return err + } + + logger.Debug().Str("path", hooksDir).Msg("git hooks installed (post-commit, pre-push)") + + if err := store.MarkTraceInitialized(); err != nil { + return fmt.Errorf("mark trace initialized: %w", err) + } + + return nil +} + // ensureTraceInitWorkflow makes sure the workflow the trace attestations target // exists, creating it up front while someone is watching the output. Left to // the pre-push hook, the control plane creates it implicitly with an empty diff --git a/app/cli/cmd/trace_init_identity.go b/app/cli/cmd/trace_init_identity.go index fbdeeb42d..955eb6bc8 100644 --- a/app/cli/cmd/trace_init_identity.go +++ b/app/cli/cmd/trace_init_identity.go @@ -298,7 +298,9 @@ func organizationLine(organization string) string { return "" } - return fmt.Sprintf("organization %s", organization) + // A sentence rather than a label and a value: the columns of a key/value + // line read as one more row among the options it sits above. + return fmt.Sprintf("using the organization %s", organization) } // validateNewOrganization checks what a free-form name normalizes to, so a name diff --git a/app/cli/cmd/trace_init_identity_test.go b/app/cli/cmd/trace_init_identity_test.go index b7736c22c..ced0f1bce 100644 --- a/app/cli/cmd/trace_init_identity_test.go +++ b/app/cli/cmd/trace_init_identity_test.go @@ -728,13 +728,13 @@ func TestResolveInteractiveProject(t *testing.T) { assert.Equal(t, wantTitle, p.selects[0].title) // Which organization the projects come from is part of the // question, not a line logged before it. - assert.Equal(t, "organization "+orgAcme, p.selects[0].description) + assert.Equal(t, organizationLine(orgAcme), p.selects[0].description) } if tc.wantInputDefault != "" { require.Len(t, p.inputs, 1) assert.Equal(t, tc.wantInputDefault, p.inputs[0].defaultValue) - assert.Equal(t, "organization "+orgAcme, p.inputs[0].description, + assert.Equal(t, organizationLine(orgAcme), p.inputs[0].description, "a new project is created in it, so it is part of that question too") } }) diff --git a/app/cli/cmd/trace_init_test.go b/app/cli/cmd/trace_init_test.go index d0f166057..17fae18c8 100644 --- a/app/cli/cmd/trace_init_test.go +++ b/app/cli/cmd/trace_init_test.go @@ -175,6 +175,26 @@ func TestTraceInitConfigSave(t *testing.T) { }) } +// TestWriteTraceInitStateFailureLeavesNothing covers the step the harness hooks +// are rolled back for. Its parts run in order and each one can fail, so a +// caller that did not undo them would leave hooks calling a repository that was +// never set up. +func TestWriteTraceInitStateFailureLeavesNothing(t *testing.T) { + repoRoot := t.TempDir() + + // A file where the git directory should be: every part of the step writes + // under it, so the first of them fails. + gitDir := filepath.Join(repoRoot, "not-a-directory") + require.NoError(t, os.WriteFile(gitDir, []byte("x"), 0o600)) + + cfg := &traceInitConfig{project: "a-project", workflow: defaultTraceWorkflow} + require.Error(t, writeTraceInitState(cfg, repoRoot, gitDir), + "a git directory that cannot be written to has to fail the step") + + _, err := os.Stat(filepath.Join(repoRoot, ".chainloop", "trace")) + assert.True(t, os.IsNotExist(err), "no trace directory should have been left behind") +} + // TestWriteTraceNextSteps checks the closing message names the files that have // to be committed. Everything init wrote lives in the repository, so leaving // them uncommitted keeps the tracing on one working copy. diff --git a/app/controlplane/internal/service/project_permissions_test.go b/app/controlplane/internal/service/project_permissions_test.go index 6668a7c60..6dbd3aa09 100644 --- a/app/controlplane/internal/service/project_permissions_test.go +++ b/app/controlplane/internal/service/project_permissions_test.go @@ -162,6 +162,19 @@ func TestCanCreateProject(t *testing.T) { ctx: usercontext.WithAuthzSubject(context.Background(), string(authz.RoleAdmin)), want: true, }, + { + name: "an organization owner may", + ctx: usercontext.WithAuthzSubject(context.Background(), string(authz.RoleOwner)), + want: true, + }, + { + // A viewer sits outside RBAC like the administrators do, but reads + // rather than writes: the API-level check refuses it a workflow, so + // offering to create a project for one is a dead end. + name: "an organization viewer may not, though RBAC does not narrow it", + ctx: usercontext.WithAuthzSubject(context.Background(), string(authz.RoleViewer)), + want: false, + }, } s := newTestService(t) @@ -175,26 +188,11 @@ func TestCanCreateProject(t *testing.T) { } } -// TestCanCreateProjectMatchesEnforcement pins the reported answer to the one the -// create call enforces. They are read by a client as the same question, so they -// must not drift apart. -func TestCanCreateProjectMatchesEnforcement(t *testing.T) { - s := newTestService(t) - - for _, role := range []authz.Role{authz.RoleOrgMember, authz.RoleOrgContributor, authz.RoleAdmin, authz.RoleViewer} { - t.Run(string(role), func(t *testing.T) { - ctx := entities.WithMembership( - usercontext.WithAuthzSubject(context.Background(), string(role)), - &entities.Membership{}) - - reported, err := s.canCreateProject(ctx) - require.NoError(t, err) - - assert.Equal(t, reported, s.userCanCreateProject(ctx) == nil, - "the listing reported %v, which the create call does not agree with", reported) - }) - } -} +// The reported answer and the enforced one cannot drift apart, because +// userCanCreateProject is canCreateProject plus an error: a test comparing them +// would compare the function to itself and pass whatever either did. What is +// worth pinning is that both agree with the create RPC as the middleware +// actually reaches it, which is the service-level RBAC test tracked in #3419. // TestProjectsAllowingWithoutRBAC covers the organization roles RBAC does not // narrow. Whether they may create a workflow is decided by the role alone, so diff --git a/app/controlplane/internal/service/service.go b/app/controlplane/internal/service/service.go index a291bf39d..bed484190 100644 --- a/app/controlplane/internal/service/service.go +++ b/app/controlplane/internal/service/service.go @@ -367,9 +367,16 @@ func (s *service) userCanCreateProject(ctx context.Context) error { // alike: an option that the create call then refuses is the dead end the answer // exists to avoid. Its error is raw, for the caller to convert at the boundary. func (s *service) canCreateProject(ctx context.Context) (bool, error) { - // admins always can create projects if !rbacEnabled(ctx) { - return true, nil + // An API token outside RBAC acts for the whole organization. + if token := entities.CurrentAPIToken(ctx); token != nil { + return true, nil + } + + // The roles outside RBAC are the administrators and the organization + // viewer, and only the former may create anything. Answering yes for a + // viewer would offer an option the API-level check refuses. + return authz.Role(usercontext.CurrentAuthzSubject(ctx)).IsAdmin(), nil } // Only org tokens can create projects From ed25e2dcaccf7adf4c2fc00d5466d9c3a906fb56 Mon Sep 17 00:00:00 2001 From: Miguel Martinez Trivino Date: Wed, 9 Sep 2026 23:25:28 +0200 Subject: [PATCH 26/30] fix(cli): report the only organization as a log line again Moving it into the project prompt put it between the title and the options, where it read as one more row of the list rather than as context. It goes back above the question, where it does not compete with the options for attention. The description the prompts carry stays: asking for a first organization still says why it is asking, which is a question with nothing to pick behind it. Assisted-by: Claude Code Signed-off-by: Miguel Martinez Trivino Chainloop-Trace-Sessions: 184822bb-e811-42fd-8e13-735227edc5ce --- app/cli/cmd/trace_init_identity.go | 40 ++++++++----------------- app/cli/cmd/trace_init_identity_test.go | 13 +++----- app/cli/cmd/trace_init_resolve.go | 12 +++++--- 3 files changed, 24 insertions(+), 41 deletions(-) diff --git a/app/cli/cmd/trace_init_identity.go b/app/cli/cmd/trace_init_identity.go index 955eb6bc8..305f0d175 100644 --- a/app/cli/cmd/trace_init_identity.go +++ b/app/cli/cmd/trace_init_identity.go @@ -113,11 +113,11 @@ type projectLister interface { // prompter asks the user to pick from a list or type a value. It keeps the // resolution logic below independent of the terminal library, so it can be // table-tested with a scripted fake. +// // Every prompt takes a description, drawn under its title and inside the same -// frame. Context the question needs — which organization a project is being -// picked in, why a name is being asked for at all — belongs there rather than -// in a log line above it, which reads as something that happened rather than -// as part of the question. Empty means no line. +// frame, for what the question itself has to explain — why a name is being +// asked for at all, when arriving at it means there was nothing to pick. Empty +// means no line. type prompter interface { // Select asks for one of options. validate, when set, refuses an answer and // keeps the prompt open, the way Input's does. @@ -289,20 +289,6 @@ func promptNewOrganization(ctx context.Context, api orgAPI, p prompter, fromYML return &resolvedValue{value: name, save: name != fromYML, prompted: true}, nil } -// organizationLine is the description the project prompts carry: which -// organization the projects come from, and the one a new project would be -// created in. It reports nothing when the organization is not known, which is -// the case only when no organization was ever resolved. -func organizationLine(organization string) string { - if organization == "" { - return "" - } - - // A sentence rather than a label and a value: the columns of a key/value - // line read as one more row among the options it sits above. - return fmt.Sprintf("using the organization %s", organization) -} - // validateNewOrganization checks what a free-form name normalizes to, so a name // the control plane would refuse is caught at the prompt. Organizations are // named by the same rule projects are. @@ -311,11 +297,9 @@ func validateNewOrganization(answer string) error { } // resolveInteractiveProject picks the project the workflow is created in, or -// collects a name for a new one. fromYML is what .chainloop.yml pins, repoDir -// the repository's directory name, used to seed a new name, and organization -// the one already settled on, shown with the question since it is what the -// projects on offer are drawn from. -func resolveInteractiveProject(ctx context.Context, lister projectLister, p prompter, fromYML, repoDir, organization string) (*resolvedValue, error) { +// collects a name for a new one. fromYML is what .chainloop.yml pins and +// repoDir the repository's directory name, used to seed a new name. +func resolveInteractiveProject(ctx context.Context, lister projectLister, p prompter, fromYML, repoDir string) (*resolvedValue, error) { listing, err := lister.ListProjects(ctx) if err != nil { return nil, fmt.Errorf("listing the projects you can see: %w", err) @@ -379,7 +363,7 @@ func resolveInteractiveProject(ctx context.Context, lister projectLister, p prom // Nothing to choose from, so go straight to naming one rather than showing a // list holding nothing but the create entry. if len(writable) == 0 { - return promptNewProject(p, organization, newProjectSeed, fromYML, readOnly) + return promptNewProject(p, newProjectSeed, fromYML, readOnly) } // The create entry is only offered to a role that may act on it; offering it @@ -398,13 +382,13 @@ func resolveInteractiveProject(ctx context.Context, lister projectLister, p prom preselect = createNewProjectOption } - chosen, err := p.Select(title, organizationLine(organization), options, firstPresent(options, preselect), nil) + chosen, err := p.Select(title, "", options, firstPresent(options, preselect), nil) if err != nil { return nil, err } if chosen == createNewProjectOption { - return promptNewProject(p, organization, newProjectSeed, fromYML, readOnly) + return promptNewProject(p, newProjectSeed, fromYML, readOnly) } return &resolvedValue{value: chosen, save: chosen != fromYML, prompted: true}, nil @@ -414,8 +398,8 @@ func resolveInteractiveProject(ctx context.Context, lister projectLister, p prom // to the DNS-1123 label the control plane stores. A name that normalizes to one // of the existing projects simply resolves to that project, so readOnly names // are refused: typing one is the same dead end as picking it from the list. -func promptNewProject(p prompter, organization, defaultName, fromYML string, readOnly []string) (*resolvedValue, error) { - answer, err := p.Input(newProjectPromptTitle, organizationLine(organization), defaultName, newProjectValidator(readOnly)) +func promptNewProject(p prompter, defaultName, fromYML string, readOnly []string) (*resolvedValue, error) { + answer, err := p.Input(newProjectPromptTitle, "", defaultName, newProjectValidator(readOnly)) if err != nil { return nil, err } diff --git a/app/cli/cmd/trace_init_identity_test.go b/app/cli/cmd/trace_init_identity_test.go index ced0f1bce..42274fcda 100644 --- a/app/cli/cmd/trace_init_identity_test.go +++ b/app/cli/cmd/trace_init_identity_test.go @@ -698,7 +698,7 @@ func TestResolveInteractiveProject(t *testing.T) { t.Run(tc.name, func(t *testing.T) { p := &fakePrompter{selectAnswer: tc.selectAnswer, inputAnswer: tc.inputAnswer} got, err := resolveInteractiveProject(context.Background(), - &fakeProjectLister{projects: tc.projects, readOnly: tc.readOnly, cannotCreate: tc.cannotCreate}, p, tc.fromYML, tc.repoDir, orgAcme) + &fakeProjectLister{projects: tc.projects, readOnly: tc.readOnly, cannotCreate: tc.cannotCreate}, p, tc.fromYML, tc.repoDir) if tc.wantErr != "" { require.Error(t, err) @@ -726,16 +726,11 @@ func TestResolveInteractiveProject(t *testing.T) { } assert.Equal(t, wantTitle, p.selects[0].title) - // Which organization the projects come from is part of the - // question, not a line logged before it. - assert.Equal(t, organizationLine(orgAcme), p.selects[0].description) } if tc.wantInputDefault != "" { require.Len(t, p.inputs, 1) assert.Equal(t, tc.wantInputDefault, p.inputs[0].defaultValue) - assert.Equal(t, organizationLine(orgAcme), p.inputs[0].description, - "a new project is created in it, so it is part of that question too") } }) } @@ -744,20 +739,20 @@ func TestResolveInteractiveProject(t *testing.T) { func TestResolveInteractiveProjectErrors(t *testing.T) { t.Run("a listing failure is returned, not swallowed", func(t *testing.T) { _, err := resolveInteractiveProject(context.Background(), - &fakeProjectLister{err: errors.New("boom")}, &fakePrompter{}, "", "repo", orgAcme) + &fakeProjectLister{err: errors.New("boom")}, &fakePrompter{}, "", "repo") require.Error(t, err) assert.Contains(t, err.Error(), "boom") }) t.Run("an aborted list is returned", func(t *testing.T) { _, err := resolveInteractiveProject(context.Background(), - &fakeProjectLister{projects: []string{"a"}}, &fakePrompter{selectErr: errAborted}, "", "repo", orgAcme) + &fakeProjectLister{projects: []string{"a"}}, &fakePrompter{selectErr: errAborted}, "", "repo") require.ErrorIs(t, err, errAborted) }) t.Run("an aborted input is returned", func(t *testing.T) { _, err := resolveInteractiveProject(context.Background(), - &fakeProjectLister{}, &fakePrompter{inputErr: errAborted}, "", "repo", orgAcme) + &fakeProjectLister{}, &fakePrompter{inputErr: errAborted}, "", "repo") require.ErrorIs(t, err, errAborted) }) } diff --git a/app/cli/cmd/trace_init_resolve.go b/app/cli/cmd/trace_init_resolve.go index 96f73de6f..5c26cadab 100644 --- a/app/cli/cmd/trace_init_resolve.go +++ b/app/cli/cmd/trace_init_resolve.go @@ -108,9 +108,13 @@ func resolveIdentityInteractively(ctx context.Context, cfg *traceInitConfig, p p return err } - // An organization settled without asking is not announced here: the - // project prompt that follows names it, and the closing summary names it - // again. A line of its own would only read as something that happened. + // An organization settled without asking is still reported: the questions + // that follow are answered inside it, and nothing else on the way through + // says which one it is. + if !org.prompted { + logger.Info().Str("organization", org.value).Msg("using your only organization") + } + cfg.organization, cfg.saveOrganization = org.value, org.save } @@ -123,7 +127,7 @@ func resolveIdentityInteractively(ctx context.Context, cfg *traceInitConfig, p p return nil } - project, err := resolveInteractiveProject(ctx, projects, p, cfg.project, repoDir, cfg.organization) + project, err := resolveInteractiveProject(ctx, projects, p, cfg.project, repoDir) if err != nil { return err } From 9413e09e3c444563f8c51f112ab8936910399804 Mon Sep 17 00:00:00 2001 From: Miguel Martinez Trivino Date: Wed, 9 Sep 2026 23:53:53 +0200 Subject: [PATCH 27/30] fix(cli): put a harness configuration back rather than uninstalling it Undoing a failed init by uninstalling the hooks stripped a repository that was already set up: installing over hooks that are present changes nothing and still counts as installed, so a second run that then failed locally would take the working hooks of the first one with it. The configuration file is recorded before it is written to and put back as it was, with its mode. A file this run created is removed instead, and the directory that came with it when nothing else moved in. The test covering the state step asserted on a path the step never writes, so it passed whatever happened. It now looks under the git directory, where the state actually lives, and fails the save in a way that does not depend on which user runs it. Assisted-by: Claude Code Signed-off-by: Miguel Martinez Trivino Chainloop-Trace-Sessions: 184822bb-e811-42fd-8e13-735227edc5ce --- app/cli/cmd/trace_init.go | 85 +++++++++++++++++++++++++++++++--- app/cli/cmd/trace_init_test.go | 82 ++++++++++++++++++++++++++++---- 2 files changed, 152 insertions(+), 15 deletions(-) diff --git a/app/cli/cmd/trace_init.go b/app/cli/cmd/trace_init.go index 7d9bc2a37..077d4fe53 100644 --- a/app/cli/cmd/trace_init.go +++ b/app/cli/cmd/trace_init.go @@ -18,6 +18,7 @@ package cmd import ( "cmp" "context" + "errors" "fmt" "io" "os" @@ -119,8 +120,19 @@ The organization, project, workflow and require-trace values are saved to // harness's own hook config file, written here, determines which of // them can register sessions. installed := make([]trace.Provider, 0, len(selectedProviders)) + // before holds each harness's configuration as it stood, so a later + // failure puts back exactly what was there. Uninstalling instead + // would strip the hooks of a repository that was already set up, + // since installing over them changes nothing and still counts. + before := make([]harnessConfig, 0, len(selectedProviders)) for _, p := range selectedProviders { + snapshot, err := readHarnessConfig(p, repoRoot) + if err != nil { + logger.Warn().Err(err).Str("harness", p.Name()).Msg("could not read the harness configuration") + continue + } + if err := p.InstallHooks(repoRoot); err != nil { logger.Warn().Err(err).Str("harness", p.Name()).Msg("could not install harness hooks") continue @@ -128,6 +140,7 @@ The organization, project, workflow and require-trace values are saved to logger.Debug().Str("harness", p.Name()).Msg("harness hooks installed") installed = append(installed, p) + before = append(before, snapshot) } if len(installed) == 0 { @@ -136,14 +149,12 @@ The organization, project, workflow and require-trace values are saved to // The workflow exists and something records into it. What remains is // local, but a failure in any of it would leave harness hooks behind - // that call a repository which is not set up, so they are taken back - // out again. UninstallHooks removes only Chainloop's own entries, - // leaving the rest of a harness's configuration alone. + // calling a repository that is not set up. if err := writeTraceInitState(cfg, repoRoot, gitDir); err != nil { - for _, p := range installed { - if uerr := p.UninstallHooks(repoRoot); uerr != nil { - logger.Debug().Err(uerr).Str("harness", p.Name()). - Msg("could not take the harness hooks back out") + for _, snapshot := range before { + if rerr := snapshot.restore(); rerr != nil { + logger.Debug().Err(rerr).Str("path", snapshot.path). + Msg("could not put the harness configuration back") } } @@ -269,6 +280,66 @@ func joinWithOr(names []string) string { } } +// harnessConfig is a harness's configuration file as it stood before init +// touched it. Installing hooks merges into whatever is already there, and +// installing over hooks that are present changes nothing at all, so putting +// the file back is the only way to undo a run without taking a working setup +// with it. +type harnessConfig struct { + path string + // content is what the file held, and existed distinguishes an empty file + // from one init created. + content []byte + mode os.FileMode + existed bool +} + +// harnessSettings is the slice of a provider readHarnessConfig needs: where the +// harness keeps the configuration its hooks are written into. +type harnessSettings interface { + SettingsFile(repoRoot string) string +} + +// readHarnessConfig records a harness's configuration before it is written to. +func readHarnessConfig(p harnessSettings, repoRoot string) (harnessConfig, error) { + path := p.SettingsFile(repoRoot) + + content, err := os.ReadFile(path) + if errors.Is(err, os.ErrNotExist) { + return harnessConfig{path: path}, nil + } + + if err != nil { + return harnessConfig{}, err + } + + // The mode is kept so restoring does not quietly widen or narrow it. + info, err := os.Stat(path) + if err != nil { + return harnessConfig{}, err + } + + return harnessConfig{path: path, content: content, mode: info.Mode().Perm(), existed: true}, nil +} + +// restore puts the configuration back as it was, removing the file when init +// was what created it. An empty directory left over from creating it goes too; +// a directory holding anything else is left alone, which os.Remove does by +// refusing to remove it. +func (h harnessConfig) restore() error { + if h.existed { + return os.WriteFile(h.path, h.content, h.mode) + } + + if err := os.Remove(h.path); err != nil && !errors.Is(err, os.ErrNotExist) { + return err + } + + _ = os.Remove(filepath.Dir(h.path)) + + return nil +} + // writeTraceInitState leaves the repository set up: the configuration, the // trace directory, the managed git hooks and the marker that says init ran. It // is one step so its caller can undo the harness hooks if any part of it fails, diff --git a/app/cli/cmd/trace_init_test.go b/app/cli/cmd/trace_init_test.go index 17fae18c8..c9d0388d3 100644 --- a/app/cli/cmd/trace_init_test.go +++ b/app/cli/cmd/trace_init_test.go @@ -175,24 +175,90 @@ func TestTraceInitConfigSave(t *testing.T) { }) } +// stubProvider names a settings file, which is all readHarnessConfig reads. +type stubProvider struct{ settings string } + +func (s stubProvider) SettingsFile(string) string { return s.settings } + +// TestHarnessConfigRestore covers undoing what init wrote to a harness. The +// case that matters is the second run over a repository already set up: +// installing over hooks that are present changes nothing, so undoing by +// uninstalling would strip a working configuration instead. +func TestHarnessConfigRestore(t *testing.T) { + t.Run("a configuration that was already there comes back untouched", func(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "settings.json") + original := []byte(`{"hooks":{"SessionStart":"chainloop"},"mine":"keep me"}`) + require.NoError(t, os.WriteFile(path, original, 0o600)) + + before, err := readHarnessConfig(stubProvider{settings: path}, dir) + require.NoError(t, err) + + require.NoError(t, os.WriteFile(path, []byte(`{"hooks":{}}`), 0o600)) + require.NoError(t, before.restore()) + + got, err := os.ReadFile(path) + require.NoError(t, err) + assert.Equal(t, original, got, "the hooks that predate this run must survive it") + }) + + t.Run("a configuration this run created is removed", func(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, ".claude", "settings.json") + + before, err := readHarnessConfig(stubProvider{settings: path}, dir) + require.NoError(t, err) + + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755)) + require.NoError(t, os.WriteFile(path, []byte(`{"hooks":{}}`), 0o600)) + require.NoError(t, before.restore()) + + _, err = os.Stat(path) + assert.True(t, os.IsNotExist(err), "a file this run created should not outlive it") + + _, err = os.Stat(filepath.Dir(path)) + assert.True(t, os.IsNotExist(err), "nor the directory that came with it") + }) + + t.Run("a directory holding anything else is left alone", func(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, ".claude", "settings.json") + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755)) + + before, err := readHarnessConfig(stubProvider{settings: path}, dir) + require.NoError(t, err) + + theirs := filepath.Join(filepath.Dir(path), "theirs.json") + require.NoError(t, os.WriteFile(theirs, []byte("{}"), 0o600)) + require.NoError(t, os.WriteFile(path, []byte(`{"hooks":{}}`), 0o600)) + require.NoError(t, before.restore()) + + _, err = os.Stat(theirs) + assert.NoError(t, err, "somebody else's file must survive") + }) +} + // TestWriteTraceInitStateFailureLeavesNothing covers the step the harness hooks // are rolled back for. Its parts run in order and each one can fail, so a // caller that did not undo them would leave hooks calling a repository that was // never set up. func TestWriteTraceInitStateFailureLeavesNothing(t *testing.T) { repoRoot := t.TempDir() + gitDir := filepath.Join(repoRoot, ".git") + require.NoError(t, os.MkdirAll(gitDir, 0o755)) - // A file where the git directory should be: every part of the step writes - // under it, so the first of them fails. - gitDir := filepath.Join(repoRoot, "not-a-directory") - require.NoError(t, os.WriteFile(gitDir, []byte("x"), 0o600)) + // A directory where the configuration file goes, so saving it fails for any + // user rather than depending on who the test runs as. + require.NoError(t, os.MkdirAll(filepath.Join(repoRoot, chainloopYMLName), 0o755)) - cfg := &traceInitConfig{project: "a-project", workflow: defaultTraceWorkflow} + cfg := &traceInitConfig{project: "a-project", workflow: defaultTraceWorkflow, saveProject: true} require.Error(t, writeTraceInitState(cfg, repoRoot, gitDir), - "a git directory that cannot be written to has to fail the step") + "a configuration that cannot be written has to fail the step") - _, err := os.Stat(filepath.Join(repoRoot, ".chainloop", "trace")) - assert.True(t, os.IsNotExist(err), "no trace directory should have been left behind") + // The state lives under the git directory, which is where the rest of the + // step would have written had it run. + _, err := os.Stat(filepath.Join(gitDir, "chainloop-trace")) + assert.True(t, os.IsNotExist(err), "no trace state should have been left behind") } // TestWriteTraceNextSteps checks the closing message names the files that have From 93c62533a0ee9b86ee8d4fd62e26fae070634f99 Mon Sep 17 00:00:00 2001 From: Miguel Martinez Trivino Date: Thu, 10 Sep 2026 10:54:02 +0200 Subject: [PATCH 28/30] fix(cli): undo every file a failed init wrote, and only what it created Three gaps in the rollback, each one a way for a failed run to leave the repository changed. A harness whose hooks failed to install part-way through kept whatever the write got to, since only a successful install was recorded to undo. It is put back before the next harness is tried. .chainloop.yml was not covered at all. The step that writes it stops at the first thing that fails, so a failure after the save left the configuration behind with nothing recording into it. It is recorded alongside the harness files and put back with them. Removing the directory a created file sat in took the immediate parent whether or not this run created it, and left the ones above it. The closest directory that was already there is recorded, and only what was created below it is removed. opencode keeps its hook two levels down, so both halves of that mattered. The test for the state step failed on the first thing that step does, which made "leaves nothing" true however the step behaved. It fails on the trace directory now, after the configuration has been written, which is the case the snapshot exists for. Assisted-by: Claude Code Signed-off-by: Miguel Martinez Trivino Chainloop-Trace-Sessions: 184822bb-e811-42fd-8e13-735227edc5ce --- app/cli/cmd/trace_init.go | 128 +++++++++++++++++++++++---------- app/cli/cmd/trace_init_test.go | 75 ++++++++++++++----- 2 files changed, 147 insertions(+), 56 deletions(-) diff --git a/app/cli/cmd/trace_init.go b/app/cli/cmd/trace_init.go index 077d4fe53..576353bea 100644 --- a/app/cli/cmd/trace_init.go +++ b/app/cli/cmd/trace_init.go @@ -120,21 +120,30 @@ The organization, project, workflow and require-trace values are saved to // harness's own hook config file, written here, determines which of // them can register sessions. installed := make([]trace.Provider, 0, len(selectedProviders)) - // before holds each harness's configuration as it stood, so a later - // failure puts back exactly what was there. Uninstalling instead - // would strip the hooks of a repository that was already set up, - // since installing over them changes nothing and still counts. - before := make([]harnessConfig, 0, len(selectedProviders)) + // before holds every repository file init is about to write, as it + // stood, so a later failure puts back exactly what was there. + // Uninstalling the hooks instead would strip a repository that was + // already set up, since installing over hooks changes nothing and + // still counts as installed. + before := make([]fileSnapshot, 0, len(selectedProviders)+1) for _, p := range selectedProviders { - snapshot, err := readHarnessConfig(p, repoRoot) + snapshot, err := snapshotFile(p.SettingsFile(repoRoot)) if err != nil { logger.Warn().Err(err).Str("harness", p.Name()).Msg("could not read the harness configuration") continue } if err := p.InstallHooks(repoRoot); err != nil { + // A write that failed part-way through leaves the file as it + // got to, so put it back before moving on. + if rerr := snapshot.restore(); rerr != nil { + logger.Debug().Err(rerr).Str("path", snapshot.path). + Msg("could not put the harness configuration back") + } + logger.Warn().Err(err).Str("harness", p.Name()).Msg("could not install harness hooks") + continue } @@ -147,14 +156,24 @@ The organization, project, workflow and require-trace values are saved to return fmt.Errorf("no harness hooks could be installed, so no sessions would be recorded") } + // .chainloop.yml is written by the step below, which stops at the + // first thing that fails, so it is recorded here alongside the rest. + ymlSnapshot, err := snapshotFile(filepath.Join(repoRoot, config.ChainloopYMLName(repoRoot))) + if err != nil { + return err + } + + before = append(before, ymlSnapshot) + // The workflow exists and something records into it. What remains is - // local, but a failure in any of it would leave harness hooks behind - // calling a repository that is not set up. + // local, but a failure part-way through it would leave a repository + // half set up: harness hooks calling a configuration that is not + // there, or a configuration with nothing recording into it. if err := writeTraceInitState(cfg, repoRoot, gitDir); err != nil { for _, snapshot := range before { if rerr := snapshot.restore(); rerr != nil { logger.Debug().Err(rerr).Str("path", snapshot.path). - Msg("could not put the harness configuration back") + Msg("could not put the file back") } } @@ -280,62 +299,95 @@ func joinWithOr(names []string) string { } } -// harnessConfig is a harness's configuration file as it stood before init -// touched it. Installing hooks merges into whatever is already there, and -// installing over hooks that are present changes nothing at all, so putting -// the file back is the only way to undo a run without taking a working setup -// with it. -type harnessConfig struct { +// fileSnapshot is a file init is about to write to, as it stood beforehand. +// Every write init makes to the repository merges into or replaces something +// that may already be there — a harness's hooks sit beside the rest of its +// configuration, and installing over hooks that are present changes nothing at +// all — so putting the file back is the only way to undo a run without taking a +// working setup with it. +type fileSnapshot struct { path string // content is what the file held, and existed distinguishes an empty file // from one init created. content []byte mode os.FileMode existed bool + // existingAncestor is the closest directory that was already there, so + // restoring removes the directories init created and no others. + existingAncestor string } -// harnessSettings is the slice of a provider readHarnessConfig needs: where the -// harness keeps the configuration its hooks are written into. -type harnessSettings interface { - SettingsFile(repoRoot string) string -} - -// readHarnessConfig records a harness's configuration before it is written to. -func readHarnessConfig(p harnessSettings, repoRoot string) (harnessConfig, error) { - path := p.SettingsFile(repoRoot) - +// snapshotFile records a file before it is written to. +func snapshotFile(path string) (fileSnapshot, error) { content, err := os.ReadFile(path) if errors.Is(err, os.ErrNotExist) { - return harnessConfig{path: path}, nil + ancestor, err := closestExistingDir(filepath.Dir(path)) + if err != nil { + return fileSnapshot{}, err + } + + return fileSnapshot{path: path, existingAncestor: ancestor}, nil } if err != nil { - return harnessConfig{}, err + return fileSnapshot{}, err } // The mode is kept so restoring does not quietly widen or narrow it. info, err := os.Stat(path) if err != nil { - return harnessConfig{}, err + return fileSnapshot{}, err } - return harnessConfig{path: path, content: content, mode: info.Mode().Perm(), existed: true}, nil + return fileSnapshot{path: path, content: content, mode: info.Mode().Perm(), existed: true}, nil +} + +// closestExistingDir walks up from dir to the first directory that is there, +// which is the point below which anything is init's to remove again. +func closestExistingDir(dir string) (string, error) { + for { + info, err := os.Stat(dir) + if err == nil { + if !info.IsDir() { + return "", fmt.Errorf("%s is not a directory", dir) + } + + return dir, nil + } + + if !errors.Is(err, os.ErrNotExist) { + return "", err + } + + parent := filepath.Dir(dir) + if parent == dir { + // The root, which always exists, so this is unreachable in practice. + return dir, nil + } + + dir = parent + } } -// restore puts the configuration back as it was, removing the file when init -// was what created it. An empty directory left over from creating it goes too; -// a directory holding anything else is left alone, which os.Remove does by -// refusing to remove it. -func (h harnessConfig) restore() error { - if h.existed { - return os.WriteFile(h.path, h.content, h.mode) +// restore puts the file back as it was, removing it when init was what created +// it, along with the directories that came with it. A directory holding +// anything else stays, which os.Remove sees to by refusing to remove it. +func (f fileSnapshot) restore() error { + if f.existed { + return os.WriteFile(f.path, f.content, f.mode) } - if err := os.Remove(h.path); err != nil && !errors.Is(err, os.ErrNotExist) { + if err := os.Remove(f.path); err != nil && !errors.Is(err, os.ErrNotExist) { return err } - _ = os.Remove(filepath.Dir(h.path)) + for dir := filepath.Dir(f.path); dir != f.existingAncestor; dir = filepath.Dir(dir) { + if err := os.Remove(dir); err != nil { + // Not empty, or already gone: either way there is nothing more of + // init's to take back above it. + return nil + } + } return nil } diff --git a/app/cli/cmd/trace_init_test.go b/app/cli/cmd/trace_init_test.go index c9d0388d3..2b16b373f 100644 --- a/app/cli/cmd/trace_init_test.go +++ b/app/cli/cmd/trace_init_test.go @@ -175,23 +175,18 @@ func TestTraceInitConfigSave(t *testing.T) { }) } -// stubProvider names a settings file, which is all readHarnessConfig reads. -type stubProvider struct{ settings string } - -func (s stubProvider) SettingsFile(string) string { return s.settings } - -// TestHarnessConfigRestore covers undoing what init wrote to a harness. The +// TestFileSnapshotRestore covers undoing what init wrote to a file. The // case that matters is the second run over a repository already set up: // installing over hooks that are present changes nothing, so undoing by // uninstalling would strip a working configuration instead. -func TestHarnessConfigRestore(t *testing.T) { +func TestFileSnapshotRestore(t *testing.T) { t.Run("a configuration that was already there comes back untouched", func(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "settings.json") original := []byte(`{"hooks":{"SessionStart":"chainloop"},"mine":"keep me"}`) require.NoError(t, os.WriteFile(path, original, 0o600)) - before, err := readHarnessConfig(stubProvider{settings: path}, dir) + before, err := snapshotFile(path) require.NoError(t, err) require.NoError(t, os.WriteFile(path, []byte(`{"hooks":{}}`), 0o600)) @@ -206,7 +201,7 @@ func TestHarnessConfigRestore(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, ".claude", "settings.json") - before, err := readHarnessConfig(stubProvider{settings: path}, dir) + before, err := snapshotFile(path) require.NoError(t, err) require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755)) @@ -220,12 +215,46 @@ func TestHarnessConfigRestore(t *testing.T) { assert.True(t, os.IsNotExist(err), "nor the directory that came with it") }) + // opencode keeps its hook a level deeper, so undoing a run has to take + // every directory it created rather than only the last one. + t.Run("a nested path loses every directory this run created", func(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, ".opencode", "plugins", "chainloop-trace.ts") + + before, err := snapshotFile(path) + require.NoError(t, err) + + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755)) + require.NoError(t, os.WriteFile(path, []byte("export default {}"), 0o600)) + require.NoError(t, before.restore()) + + _, err = os.Stat(filepath.Join(dir, ".opencode")) + assert.True(t, os.IsNotExist(err), "the outer directory came with the run too") + }) + + // An empty directory that was already there is not this run's to remove. + t.Run("a directory that predates the run stays, even empty", func(t *testing.T) { + dir := t.TempDir() + existing := filepath.Join(dir, ".claude") + require.NoError(t, os.MkdirAll(existing, 0o755)) + + path := filepath.Join(existing, "settings.json") + before, err := snapshotFile(path) + require.NoError(t, err) + + require.NoError(t, os.WriteFile(path, []byte(`{"hooks":{}}`), 0o600)) + require.NoError(t, before.restore()) + + _, err = os.Stat(existing) + assert.NoError(t, err, "the directory was not created by this run") + }) + t.Run("a directory holding anything else is left alone", func(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, ".claude", "settings.json") require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755)) - before, err := readHarnessConfig(stubProvider{settings: path}, dir) + before, err := snapshotFile(path) require.NoError(t, err) theirs := filepath.Join(filepath.Dir(path), "theirs.json") @@ -247,18 +276,28 @@ func TestWriteTraceInitStateFailureLeavesNothing(t *testing.T) { gitDir := filepath.Join(repoRoot, ".git") require.NoError(t, os.MkdirAll(gitDir, 0o755)) - // A directory where the configuration file goes, so saving it fails for any - // user rather than depending on who the test runs as. - require.NoError(t, os.MkdirAll(filepath.Join(repoRoot, chainloopYMLName), 0o755)) + // A file where the trace state goes, so the step fails after saving the + // configuration rather than before it: failing on the first thing it does + // would leave nothing behind whatever the caller did about it. + require.NoError(t, os.WriteFile(filepath.Join(gitDir, "chainloop-trace"), []byte("x"), 0o600)) + + ymlPath := filepath.Join(repoRoot, chainloopYMLName) + before, err := snapshotFile(ymlPath) + require.NoError(t, err) cfg := &traceInitConfig{project: "a-project", workflow: defaultTraceWorkflow, saveProject: true} require.Error(t, writeTraceInitState(cfg, repoRoot, gitDir), - "a configuration that cannot be written has to fail the step") + "a trace directory that cannot be created has to fail the step") + + // The step is not atomic on its own: it saved the configuration before it + // failed, which is exactly why the caller snapshots it. + _, err = os.Stat(ymlPath) + require.NoError(t, err, "the save this test relies on did happen") + + require.NoError(t, before.restore()) - // The state lives under the git directory, which is where the rest of the - // step would have written had it run. - _, err := os.Stat(filepath.Join(gitDir, "chainloop-trace")) - assert.True(t, os.IsNotExist(err), "no trace state should have been left behind") + _, err = os.Stat(ymlPath) + assert.True(t, os.IsNotExist(err), "the configuration must not outlive a failed run") } // TestWriteTraceNextSteps checks the closing message names the files that have From 2b9828c0bb6f315b24c67062133577c511806ef2 Mon Sep 17 00:00:00 2001 From: Miguel Martinez Trivino Date: Thu, 10 Sep 2026 11:19:32 +0200 Subject: [PATCH 29/30] refactor(cli): move the prompter to a package of its own Asking someone to pick from a list or type a value has nothing to do with tracing, and neither do the two huh quirks worked around here: a field that hides its last option unless told its own height, and an accessible mode that draws no description at all. app/cli/pkg/prompt takes all of it, along with the aborted error, which is what a dismissed question returns. What stays behind is the one part that is Chainloop's rather than the terminal's: a name typed freely is stored as a DNS-1123 label, so the input shows what it will become. The package takes that as a callback and knows nothing about naming. Exercising it on its own found a third quirk of the same family: an input given no validator panics, the way a select does. The trace prompts always passed one, so it had never come up. Assisted-by: Claude Code Signed-off-by: Miguel Martinez Trivino Chainloop-Trace-Sessions: 184822bb-e811-42fd-8e13-735227edc5ce --- app/cli/cmd/trace_init_identity.go | 10 +- app/cli/cmd/trace_init_prompt.go | 206 ++++------------------ app/cli/cmd/trace_init_prompt_test.go | 163 +---------------- app/cli/pkg/prompt/prompt.go | 241 ++++++++++++++++++++++++++ app/cli/pkg/prompt/prompt_test.go | 173 ++++++++++++++++++ 5 files changed, 460 insertions(+), 333 deletions(-) create mode 100644 app/cli/pkg/prompt/prompt.go create mode 100644 app/cli/pkg/prompt/prompt_test.go diff --git a/app/cli/cmd/trace_init_identity.go b/app/cli/cmd/trace_init_identity.go index 305f0d175..ee5ceeadd 100644 --- a/app/cli/cmd/trace_init_identity.go +++ b/app/cli/cmd/trace_init_identity.go @@ -25,6 +25,7 @@ import ( "github.com/chainloop-dev/chainloop/app/cli/internal/trace/config" "github.com/chainloop-dev/chainloop/app/cli/pkg/action" + "github.com/chainloop-dev/chainloop/app/cli/pkg/prompt" "golang.org/x/term" "google.golang.org/grpc/codes" @@ -60,10 +61,11 @@ const ( createNewProjectOption = "+ Create a new project…" ) -// errAborted is returned when the user dismisses a prompt. It stops `trace -// init` before anything is created or written, so the repository is left -// untouched. -var errAborted = errors.New("aborted") +// errAborted is what the prompter returns when the user dismisses a question. +// It stops `trace init` before anything is created or written, so the +// repository is left untouched. It is the prompt package's error rather than +// one of its own, since that is what comes back. +var errAborted = prompt.ErrAborted // stopIfAborted turns a dismissed prompt into a clean stop and passes every // other error through. Dismissing a question is a decision, not a failure, and diff --git a/app/cli/cmd/trace_init_prompt.go b/app/cli/cmd/trace_init_prompt.go index 49ff3cd72..ee11ff7ac 100644 --- a/app/cli/cmd/trace_init_prompt.go +++ b/app/cli/cmd/trace_init_prompt.go @@ -16,165 +16,44 @@ package cmd import ( - "errors" "fmt" - "io" - "os" - "slices" "github.com/chainloop-dev/chainloop/app/cli/internal/trace/config" - - "charm.land/huh/v2" + "github.com/chainloop-dev/chainloop/app/cli/pkg/prompt" ) -// accessibleEnvVars turn on plain, numbered prompts instead of the full -// terminal UI, for screen readers and terminals that cannot render one. -// ACCESSIBLE is the convention the Charm tools use. -var accessibleEnvVars = []string{"CHAINLOOP_ACCESSIBLE", "ACCESSIBLE"} - -// huhPrompter implements prompter against the terminal. It is deliberately -// thin: every decision lives in the resolvers in trace_init_identity.go, which -// are tested against a scripted fake instead of a terminal. -type huhPrompter struct { - accessible bool - // in and out are the streams the prompt uses. Prompts are drawn on stderr so - // stdout stays clean for anything piping the command's output. - in io.Reader - out io.Writer -} - -// newHuhPrompter builds the terminal prompter. -func newHuhPrompter(lookupEnv func(string) (string, bool)) *huhPrompter { - accessible := false - for _, key := range accessibleEnvVars { - if envEnabled(lookupEnv, key) { - accessible = true - break - } - } - - return &huhPrompter{accessible: accessible, in: os.Stdin, out: os.Stderr} -} - -// framed folds the description into the title when huh will not draw it. -// Accessible mode renders the title and the options and nothing else, so a -// description left in its own slot is simply lost there — and it carries the -// context the question needs, which is worth more than the layout. -func (h *huhPrompter) framed(title, description string) (string, string) { - if description == "" || !h.accessible { - return title, description - } - - return fmt.Sprintf("%s (%s)", title, description), "" -} - -// Select asks the user to pick one of options, starting on defaultValue. Long -// lists can be narrowed by typing. -func (h *huhPrompter) Select(title, description string, options []string, defaultValue string, validate func(string) error) (string, error) { - value := defaultValue - title, description = h.framed(title, description) - - // Filtering() is deliberately not set: it does not enable filtering, it puts - // the field straight into filter-input mode, which replaces the title with an - // empty "/" box and sends the arrow keys to the filter. Pressing "/" opens it - // on demand, which is what the help line offers. - field := huh.NewSelect[string](). - Title(title). - Description(description). - Options(huh.NewOptions(options...)...). - Value(&value) - - // Set only when there is one: huh calls whatever it is handed, so a nil - // validator panics rather than reading as "nothing to check". - if validate != nil { - field = field.Validate(validate) - } - - if err := h.run(field); err != nil { - return "", err - } - - return value, nil -} - -// MultiSelect asks the user to tick any number of options, with defaults -// already ticked. An empty submission is refused at the prompt, so the caller -// never has to send the user back through init to fix it. -func (h *huhPrompter) MultiSelect(title string, options, defaults []string) ([]string, error) { - value := slices.Clone(defaults) - - if err := h.run(newMultiSelectField(title, options, &value)); err != nil { - return nil, err - } - - return value, nil -} - -// multiSelectMaxVisible caps how many options are on screen at once, so a long -// list scrolls instead of pushing the prompt off the top. It is the height huh -// itself falls back to for dynamic options. -const multiSelectMaxVisible = 10 - -// newMultiSelectField builds the multi-select. It is separate from MultiSelect -// so a test can render it without a terminal to run a form against. -func newMultiSelectField(title string, options []string, value *[]string) *huh.MultiSelect[string] { - return huh.NewMultiSelect[string](). - Title(title). - Options(huh.NewOptions(options...)...). - // The height has to be set. Left unset, huh sizes the viewport from the - // options and then subtracts the title's line from that same number, so - // the last option is silently cut off. Asking for one line more than the - // options occupy cancels the subtraction out. Select does not share this - // quirk, which is why only this field sets a height. - Height(min(len(options), multiSelectMaxVisible) + 1). - Value(value). - Validate(func(selected []string) error { - if len(selected) == 0 { - return errors.New("pick at least one, with space") - } - - return nil - }) -} - -// Input asks the user to type a value, prefilled with defaultValue. The -// description line gives way to what a free-form answer will be normalized to, -// so the result is visible before it is submitted: once the answer needs -// normalizing, seeing what it becomes matters more than the standing context. -func (h *huhPrompter) Input(title, description, defaultValue string, validate func(string) error) (string, error) { - value := defaultValue - title, description = h.framed(title, description) - - if err := h.run(newInputField(title, description, &value, validate)); err != nil { - return "", err - } - - return value, nil -} - -// newInputField builds the input. It is separate from Input so a test can -// render it without a terminal to run a form against, the same way -// newMultiSelectField is. -func newInputField(title, description string, value *string, validate func(string) error) *huh.Input { - return huh.NewInput(). - Title(title). - // Both: DescriptionFunc is not evaluated for the first draw, only once - // its binding changes, so on its own it leaves an empty line where the - // context should be until the user types something. - Description(description). - Value(value). - Validate(validate). - DescriptionFunc(func() string { return inputDescription(description, *value) }, value) -} - -// inputDescription is the line under an input's title: the standing context, -// giving way to what a free-form answer will be normalized to once the answer -// and its label differ, since seeing what will be created matters more at that -// point than the context it will be created in. -// -// Accessible mode folds the context into the title instead and draws no -// description at all, so the normalized name is not shown there. -func inputDescription(description, value string) string { +// namingPrompter is the prompt package plus the one thing about these questions +// that is Chainloop's rather than the terminal's: an answer typed freely is +// stored as a DNS-1123 label, so the input shows what it will become. Nothing +// else here knows about naming rules. +type namingPrompter struct { + *prompt.Prompter +} + +// newHuhPrompter builds the prompter `trace init` asks its questions through. +func newHuhPrompter(lookupEnv func(string) (string, bool)) namingPrompter { + return namingPrompter{prompt.New(lookupEnv)} +} + +// Input asks for a name, showing the label it will be stored as once the two +// differ. Seeing what will be created matters more at that point than the +// context it will be created in, so it takes the description's place. +func (n namingPrompter) Input(title, description, defaultValue string, validate func(string) error) (string, error) { + return n.Prompter.Input(prompt.InputOpts{ + Title: title, + Description: description, + Default: defaultValue, + Validate: validate, + Describe: func(value string) string { + return nameDescription(description, value) + }, + }) +} + +// nameDescription is the line under a name prompt's title: the standing +// context, giving way to what the answer normalizes to once the answer and its +// label differ. +func nameDescription(description, value string) string { slug := config.SlugifyDNS1123(value) if slug == "" || slug == value { return description @@ -182,22 +61,3 @@ func inputDescription(description, value string) string { return fmt.Sprintf("will be created as %q", slug) } - -// run shows a single-field form and translates a dismissed prompt into -// errAborted, which trace init reports as a clean stop. -func (h *huhPrompter) run(field huh.Field) error { - form := huh.NewForm(huh.NewGroup(field)). - WithAccessible(h.accessible). - WithInput(h.in). - WithOutput(h.out) - - if err := form.Run(); err != nil { - if errors.Is(err, huh.ErrUserAborted) { - return errAborted - } - - return err - } - - return nil -} diff --git a/app/cli/cmd/trace_init_prompt_test.go b/app/cli/cmd/trace_init_prompt_test.go index 91a12c6af..0c05f27c6 100644 --- a/app/cli/cmd/trace_init_prompt_test.go +++ b/app/cli/cmd/trace_init_prompt_test.go @@ -16,80 +16,16 @@ package cmd import ( - "bytes" - "errors" - "fmt" - "strings" "testing" - "charm.land/huh/v2" "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" ) -// accessiblePrompter drives the real huh implementation in accessible mode, -// where prompts are plain text over the given streams. It is the only way to -// exercise the terminal code without a terminal. -func accessiblePrompter(input string) (*huhPrompter, *bytes.Buffer) { - out := &bytes.Buffer{} - - return &huhPrompter{accessible: true, in: strings.NewReader(input), out: out}, out -} - -// The options the Select tests pick between, in the order they are offered. -const ( - optionAlpha = "alpha" - optionBeta = "beta" - optionGamma = "gamma" -) - -func TestHuhPrompterSelect(t *testing.T) { - // Accessible mode numbers the options and reads the choice as a line. - p, out := accessiblePrompter("2\n") - - got, err := p.Select("Pick one", "in some context", []string{optionAlpha, optionBeta, optionGamma}, optionAlpha, nil) - require.NoError(t, err) - assert.Equal(t, optionBeta, got) - assert.Contains(t, out.String(), "Pick one") - assert.Contains(t, out.String(), "in some context", "the description belongs with the question") -} - -// A validator lets the picker show an option it will not accept, which is how -// an organization the caller can only view stays visible without being usable. -func TestHuhPrompterSelectValidate(t *testing.T) { - // The refused option first, then an acceptable one: accessible mode asks - // again rather than giving up. - p, out := accessiblePrompter("2\n1\n") - - const refusal = "beta is not available" - - got, err := p.Select("Pick one", "", []string{optionAlpha, optionBeta}, optionAlpha, func(chosen string) error { - if chosen == optionBeta { - return errors.New(refusal) - } - - return nil - }) - require.NoError(t, err) - assert.Equal(t, optionAlpha, got) - assert.Contains(t, out.String(), refusal) -} - -func TestHuhPrompterInput(t *testing.T) { - p, out := accessiblePrompter("my-project\n") - - got, err := p.Input("Project name", "in some context", "default-name", newProjectValidator(nil)) - require.NoError(t, err) - assert.Equal(t, "my-project", got) - assert.Contains(t, out.String(), "Project name") - assert.Contains(t, out.String(), "in some context") -} - -// TestInputDescription covers the line under an input's title directly: the -// full terminal UI is what draws it, and accessible mode — the only way to -// drive a prompt without a terminal — renders no description at all. -func TestInputDescription(t *testing.T) { - const context = "organization acme" +// TestNameDescription covers the line under a name prompt's title, which is the +// only thing about these prompts that is Chainloop's rather than the terminal's. +// The prompt package covers the rest. +func TestNameDescription(t *testing.T) { + const context = "you do not belong to one yet" testCases := []struct { name string @@ -110,99 +46,14 @@ func TestInputDescription(t *testing.T) { // Nothing usable to show yet, so the context stands rather than // flickering to an empty line. name: "an answer with nothing usable in it keeps the context", - description: context, value: "!!!", want: context, + description: context, value: nameWithNothingUsable, want: context, }, {name: "no context and no answer shows nothing", value: "my-project"}, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { - assert.Equal(t, tc.want, inputDescription(tc.description, tc.value)) - }) - } -} - -// An input's description has to be there on the first draw, before anything is -// typed: huh does not evaluate DescriptionFunc until its binding changes, so a -// field relying on that alone opens with an empty line where the context goes. -func TestInputFieldShowsItsDescriptionBeforeTyping(t *testing.T) { - const context = "organization acme" - - name := "" - form := huh.NewForm(huh.NewGroup(newInputField(newProjectPromptTitle, context, &name, nil))) - _ = form.Init() - - assert.Contains(t, form.View(), context, "the context must be on screen before anything is typed") -} - -// Accessible mode draws the title and the options and nothing else, so context -// left in the description slot would be lost to a screen reader. -func TestHuhPrompterAccessibleFoldsTheDescriptionIn(t *testing.T) { - p, out := accessiblePrompter("1\n") - - _, err := p.Select("Pick one", "organization acme", []string{optionAlpha}, optionAlpha, nil) - require.NoError(t, err) - assert.Contains(t, out.String(), "Pick one (organization acme)") -} - -func TestHuhPrompterMultiSelect(t *testing.T) { - // Accessible mode toggles an option by its number and finishes on 0, so - // this ticks the second option on top of the preselected first one. - p, out := accessiblePrompter("2\n0\n") - - // The prompter is generic, so the options here are deliberately not provider - // names: nothing about it should depend on what is being picked. - got, err := p.MultiSelect("Pick options", []string{"first", "second", "third"}, []string{"first"}) - require.NoError(t, err) - assert.Equal(t, []string{"first", "second"}, got) - assert.Contains(t, out.String(), "Pick options") -} - -func TestNewHuhPrompterAccessibility(t *testing.T) { - testCases := []struct { - name string - env map[string]string - want bool - }{ - {name: "off by default", want: false}, - {name: "the Chainloop variable turns it on", env: map[string]string{"CHAINLOOP_ACCESSIBLE": "1"}, want: true}, - {name: "the Charm convention turns it on", env: map[string]string{"ACCESSIBLE": "true"}, want: true}, - {name: "set to 0 leaves it off", env: map[string]string{"ACCESSIBLE": "0"}, want: false}, - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - p := newHuhPrompter(func(key string) (string, bool) { - v, ok := tc.env[key] - return v, ok - }) - assert.Equal(t, tc.want, p.accessible) - }) - } -} - -// TestMultiSelectFieldShowsEveryOption renders the real field and looks for -// every option in the output. Left to size itself, huh's multi-select subtracts -// the title's line from the height it derived from the options, so the last one -// never appears and the user cannot tell there is more to tick. -func TestMultiSelectFieldShowsEveryOption(t *testing.T) { - for _, count := range []int{1, 2, 3, 5, multiSelectMaxVisible} { - t.Run(fmt.Sprintf("%d options", count), func(t *testing.T) { - options := make([]string, 0, count) - for i := range count { - options = append(options, fmt.Sprintf("option-%d", i)) - } - - var value []string - form := huh.NewForm(huh.NewGroup(newMultiSelectField("Pick some", options, &value))) - // Init sizes the field, which is what this is about. Its command - // starts the cursor blinking and is nothing to run here. - _ = form.Init() - - view := form.View() - for _, o := range options { - assert.Contains(t, view, o, "every option must be on screen") - } + assert.Equal(t, tc.want, nameDescription(tc.description, tc.value)) }) } } diff --git a/app/cli/pkg/prompt/prompt.go b/app/cli/pkg/prompt/prompt.go new file mode 100644 index 000000000..43c1c621e --- /dev/null +++ b/app/cli/pkg/prompt/prompt.go @@ -0,0 +1,241 @@ +// +// Copyright 2026 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package prompt asks a user to pick from a list or type a value, on the +// terminal or in plain text where there is no terminal to draw on. It knows +// nothing about what is being asked for: callers pass the question, the answers +// and whatever validation belongs to their own domain. +package prompt + +import ( + "errors" + "fmt" + "io" + "os" + "slices" + + "charm.land/huh/v2" +) + +// ErrAborted is returned when the user dismisses a prompt. Dismissing a +// question is a decision rather than a failure, so callers usually stop +// cleanly on it instead of reporting an error. +var ErrAborted = errors.New("aborted") + +// accessibleEnvVars turn on plain, numbered prompts instead of the full +// terminal UI, for screen readers and terminals that cannot render one. +// ACCESSIBLE is the convention the Charm tools use. +var accessibleEnvVars = []string{"CHAINLOOP_ACCESSIBLE", "ACCESSIBLE"} + +// Prompter asks the questions. It is deliberately thin: it owns the terminal +// and nothing else, so the code deciding what to ask can be tested against a +// scripted fake rather than against a terminal. +type Prompter struct { + accessible bool + // in and out are the streams the prompt uses. Prompts are drawn on stderr so + // stdout stays clean for anything piping the command's output. + in io.Reader + out io.Writer +} + +// New builds a prompter over the real terminal, in plain-text mode when the +// environment asks for it. +func New(lookupEnv func(string) (string, bool)) *Prompter { + accessible := false + for _, key := range accessibleEnvVars { + if envEnabled(lookupEnv, key) { + accessible = true + break + } + } + + return &Prompter{accessible: accessible, in: os.Stdin, out: os.Stderr} +} + +// NewForStreams builds a prompter over the given streams, in plain-text mode. +// It is how a test drives the real implementation without a terminal. +func NewForStreams(in io.Reader, out io.Writer) *Prompter { + return &Prompter{accessible: true, in: in, out: out} +} + +// Accessible reports whether prompts are drawn as plain numbered text. +func (p *Prompter) Accessible() bool { + return p.accessible +} + +// envEnabled reports whether an environment variable is set to something that +// means "yes". Present but empty, "0" or "false" is treated as unset. +func envEnabled(lookupEnv func(string) (string, bool), key string) bool { + v, ok := lookupEnv(key) + return ok && v != "" && v != "0" && v != "false" +} + +// framed folds the description into the title when huh will not draw it. +// Accessible mode renders the title and the options and nothing else, so a +// description left in its own slot is simply lost there — and it carries the +// context the question needs, which is worth more than the layout. +func (p *Prompter) framed(title, description string) (string, string) { + if description == "" || !p.accessible { + return title, description + } + + return fmt.Sprintf("%s (%s)", title, description), "" +} + +// Select asks the user to pick one of options, starting on defaultValue. Long +// lists can be narrowed by typing. description is drawn under the title, inside +// the same frame, and validate — when set — refuses an answer and keeps the +// prompt open rather than returning it. +func (p *Prompter) Select(title, description string, options []string, defaultValue string, validate func(string) error) (string, error) { + value := defaultValue + title, description = p.framed(title, description) + + // Filtering() is deliberately not set: it does not enable filtering, it puts + // the field straight into filter-input mode, which replaces the title with an + // empty "/" box and sends the arrow keys to the filter. Pressing "/" opens it + // on demand, which is what the help line offers. + field := huh.NewSelect[string](). + Title(title). + Description(description). + Options(huh.NewOptions(options...)...). + Value(&value) + + // Set only when there is one: huh calls whatever it is handed, so a nil + // validator panics rather than reading as "nothing to check". + if validate != nil { + field = field.Validate(validate) + } + + if err := p.run(field); err != nil { + return "", err + } + + return value, nil +} + +// MultiSelect asks the user to tick any number of options, with defaults +// already ticked. An empty submission is refused at the prompt, so the caller +// never has to send the user back to fix it. +func (p *Prompter) MultiSelect(title string, options, defaults []string) ([]string, error) { + value := slices.Clone(defaults) + + if err := p.run(NewMultiSelectField(title, options, &value)); err != nil { + return nil, err + } + + return value, nil +} + +// MultiSelectMaxVisible caps how many options are on screen at once, so a long +// list scrolls instead of pushing the prompt off the top. It is the height huh +// itself falls back to for dynamic options. +const MultiSelectMaxVisible = 10 + +// NewMultiSelectField builds the multi-select. It is separate from MultiSelect +// so a test can render it without a terminal to run a form against. +func NewMultiSelectField(title string, options []string, value *[]string) *huh.MultiSelect[string] { + return huh.NewMultiSelect[string](). + Title(title). + Options(huh.NewOptions(options...)...). + // The height has to be set. Left unset, huh sizes the viewport from the + // options and then subtracts the title's line from that same number, so + // the last option is silently cut off. Asking for one line more than the + // options occupy cancels the subtraction out. Select does not share this + // quirk, which is why only this field sets a height. + Height(min(len(options), MultiSelectMaxVisible) + 1). + Value(value). + Validate(func(selected []string) error { + if len(selected) == 0 { + return errors.New("pick at least one, with space") + } + + return nil + }) +} + +// InputOpts is a question asking for a typed answer. +type InputOpts struct { + Title string + // Description is the line under the title, for context the question needs. + Description string + // Default prefills the field. + Default string + // Validate refuses an answer and keeps the prompt open. + Validate func(string) error + // Describe replaces Description as the answer is typed, for showing what an + // answer will become before it is submitted. It is called with what has been + // typed so far and returns the line to draw, which is usually Description + // itself while there is nothing to add. Accessible mode draws no description + // at all, so what it returns is not shown there. + Describe func(value string) string +} + +// Input asks the user to type a value. +func (p *Prompter) Input(opts InputOpts) (string, error) { + value := opts.Default + opts.Title, opts.Description = p.framed(opts.Title, opts.Description) + + if err := p.run(NewInputField(opts, &value)); err != nil { + return "", err + } + + return value, nil +} + +// NewInputField builds the input. It is separate from Input so a test can +// render it without a terminal to run a form against, the same way +// NewMultiSelectField is. +func NewInputField(opts InputOpts, value *string) *huh.Input { + field := huh.NewInput(). + Title(opts.Title). + Description(opts.Description). + Value(value) + + // Set only when there is one: huh calls whatever it is handed, so a nil + // validator panics rather than reading as "nothing to check". Select has + // the same trap. + if opts.Validate != nil { + field = field.Validate(opts.Validate) + } + + if opts.Describe == nil { + return field + } + + // Both the static description and the function: DescriptionFunc is not + // evaluated for the first draw, only once its binding changes, so on its own + // it leaves an empty line where the context should be until the user types + // something. + return field.DescriptionFunc(func() string { return opts.Describe(*value) }, value) +} + +// run shows a single-field form and translates a dismissed prompt into +// ErrAborted. +func (p *Prompter) run(field huh.Field) error { + form := huh.NewForm(huh.NewGroup(field)). + WithAccessible(p.accessible). + WithInput(p.in). + WithOutput(p.out) + + if err := form.Run(); err != nil { + if errors.Is(err, huh.ErrUserAborted) { + return ErrAborted + } + + return err + } + + return nil +} diff --git a/app/cli/pkg/prompt/prompt_test.go b/app/cli/pkg/prompt/prompt_test.go new file mode 100644 index 000000000..d78edb63a --- /dev/null +++ b/app/cli/pkg/prompt/prompt_test.go @@ -0,0 +1,173 @@ +// +// Copyright 2026 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package prompt + +import ( + "bytes" + "errors" + "fmt" + "strings" + "testing" + + "charm.land/huh/v2" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// accessiblePrompter drives the real implementation in accessible mode, where +// prompts are plain text over the given streams. It is the only way to exercise +// the terminal code without a terminal. +func accessiblePrompter(input string) (*Prompter, *bytes.Buffer) { + out := &bytes.Buffer{} + + return NewForStreams(strings.NewReader(input), out), out +} + +// The options the Select tests pick between, in the order they are offered. +const ( + optionAlpha = "alpha" + optionBeta = "beta" + optionGamma = "gamma" +) + +func TestSelect(t *testing.T) { + // Accessible mode numbers the options and reads the choice as a line. + p, out := accessiblePrompter("2\n") + + got, err := p.Select("Pick one", "in some context", []string{optionAlpha, optionBeta, optionGamma}, optionAlpha, nil) + require.NoError(t, err) + assert.Equal(t, optionBeta, got) + assert.Contains(t, out.String(), "Pick one") +} + +// A validator lets a picker show an option it will not accept, which is how an +// entry can stay visible without being usable. +func TestSelectValidate(t *testing.T) { + // The refused option first, then an acceptable one: accessible mode asks + // again rather than giving up. + p, out := accessiblePrompter("2\n1\n") + + const refusal = "beta is not available" + + got, err := p.Select("Pick one", "", []string{optionAlpha, optionBeta}, optionAlpha, func(chosen string) error { + if chosen == optionBeta { + return errors.New(refusal) + } + + return nil + }) + require.NoError(t, err) + assert.Equal(t, optionAlpha, got) + assert.Contains(t, out.String(), refusal) +} + +func TestInput(t *testing.T) { + p, out := accessiblePrompter("typed-value\n") + + got, err := p.Input(InputOpts{Title: "A name", Description: "in some context", Default: "a-default"}) + require.NoError(t, err) + assert.Equal(t, "typed-value", got) + assert.Contains(t, out.String(), "A name") +} + +func TestMultiSelect(t *testing.T) { + // Accessible mode toggles an option by its number and finishes on 0, so + // this ticks the second option on top of the preselected first one. + p, out := accessiblePrompter("2\n0\n") + + const first, second = "first", "second" + + got, err := p.MultiSelect("Pick options", []string{first, second, "third"}, []string{first}) + require.NoError(t, err) + assert.Equal(t, []string{first, second}, got) + assert.Contains(t, out.String(), "Pick options") +} + +func TestNewAccessibility(t *testing.T) { + testCases := []struct { + name string + env map[string]string + want bool + }{ + {name: "off by default", want: false}, + {name: "the Chainloop variable turns it on", env: map[string]string{"CHAINLOOP_ACCESSIBLE": "1"}, want: true}, + {name: "the Charm convention turns it on", env: map[string]string{"ACCESSIBLE": "true"}, want: true}, + {name: "set to 0 leaves it off", env: map[string]string{"ACCESSIBLE": "0"}, want: false}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + p := New(func(key string) (string, bool) { + v, ok := tc.env[key] + return v, ok + }) + assert.Equal(t, tc.want, p.Accessible()) + }) + } +} + +// Accessible mode draws the title and the options and nothing else, so context +// left in the description slot would be lost to a screen reader. +func TestAccessibleFoldsTheDescriptionIn(t *testing.T) { + p, out := accessiblePrompter("1\n") + + _, err := p.Select("Pick one", "some context", []string{optionAlpha}, optionAlpha, nil) + require.NoError(t, err) + assert.Contains(t, out.String(), "Pick one (some context)") +} + +// TestMultiSelectFieldShowsEveryOption renders the real field and looks for +// every option in the output. Left to size itself, huh's multi-select subtracts +// the title's line from the height it derived from the options, so the last one +// never appears and the user cannot tell there is more to tick. +func TestMultiSelectFieldShowsEveryOption(t *testing.T) { + for _, count := range []int{1, 2, 3, 5, MultiSelectMaxVisible} { + t.Run(fmt.Sprintf("%d options", count), func(t *testing.T) { + options := make([]string, 0, count) + for i := range count { + options = append(options, fmt.Sprintf("option-%d", i)) + } + + var value []string + form := huh.NewForm(huh.NewGroup(NewMultiSelectField("Pick some", options, &value))) + // Init sizes the field, which is what this is about. Its command + // starts the cursor blinking and is nothing to run here. + _ = form.Init() + + view := form.View() + for _, o := range options { + assert.Contains(t, view, o, "every option must be on screen") + } + }) + } +} + +// An input's description has to be there on the first draw, before anything is +// typed: huh does not evaluate DescriptionFunc until its binding changes, so a +// field relying on that alone opens with an empty line where the context goes. +func TestInputFieldShowsItsDescriptionBeforeTyping(t *testing.T) { + const context = "some context" + + value := "" + form := huh.NewForm(huh.NewGroup(NewInputField(InputOpts{ + Title: "A name", + Description: context, + Describe: func(string) string { return "only once something is typed" }, + }, &value))) + _ = form.Init() + + assert.Contains(t, form.View(), context, "the context must be on screen before anything is typed") +} From c5a27a9f808eb89fb2840965c93ef5766aa5a421 Mon Sep 17 00:00:00 2001 From: Miguel Martinez Trivino Date: Thu, 10 Sep 2026 11:36:20 +0200 Subject: [PATCH 30/30] refactor(cli): tidy the prompt package after the move The constructor in cmd was the last thing there still named after the terminal library, now that huh is hidden behind pkg/prompt; it is named after the type it returns instead. In the package itself, the environment scan is a single ContainsFunc rather than a flag and a break, and run reports its error once instead of on two paths. Assisted-by: Claude Code Signed-off-by: Miguel Martinez Trivino Chainloop-Trace-Sessions: 184822bb-e811-42fd-8e13-735227edc5ce --- app/cli/cmd/trace_init.go | 2 +- app/cli/cmd/trace_init_prompt.go | 4 ++-- app/cli/cmd/trace_init_resolve.go | 2 +- app/cli/pkg/prompt/prompt.go | 21 +++++++-------------- app/cli/pkg/prompt/prompt_test.go | 2 ++ 5 files changed, 13 insertions(+), 18 deletions(-) diff --git a/app/cli/cmd/trace_init.go b/app/cli/cmd/trace_init.go index 576353bea..206dfc49d 100644 --- a/app/cli/cmd/trace_init.go +++ b/app/cli/cmd/trace_init.go @@ -93,7 +93,7 @@ The organization, project, workflow and require-trace values are saved to // Ask which agents to trace before anything is created or written, // so every question is answered up front and an abort leaves the // repository untouched. - selected, err := resolveTraceProviders(newHuhPrompter(os.LookupEnv), + selected, err := resolveTraceProviders(newNamingPrompter(os.LookupEnv), traceProviderFlags{claude: claudeFlag, cursor: cursorFlag, opencode: opencodeFlag}, traceInitCanPrompt()) if err != nil { diff --git a/app/cli/cmd/trace_init_prompt.go b/app/cli/cmd/trace_init_prompt.go index ee11ff7ac..f58926588 100644 --- a/app/cli/cmd/trace_init_prompt.go +++ b/app/cli/cmd/trace_init_prompt.go @@ -30,8 +30,8 @@ type namingPrompter struct { *prompt.Prompter } -// newHuhPrompter builds the prompter `trace init` asks its questions through. -func newHuhPrompter(lookupEnv func(string) (string, bool)) namingPrompter { +// newNamingPrompter builds the prompter `trace init` asks its questions through. +func newNamingPrompter(lookupEnv func(string) (string, bool)) namingPrompter { return namingPrompter{prompt.New(lookupEnv)} } diff --git a/app/cli/cmd/trace_init_resolve.go b/app/cli/cmd/trace_init_resolve.go index 5c26cadab..97004a9cd 100644 --- a/app/cli/cmd/trace_init_resolve.go +++ b/app/cli/cmd/trace_init_resolve.go @@ -81,7 +81,7 @@ func resolveTraceIdentity(ctx context.Context, cfg *traceInitConfig, repoRoot st return pinned, nil } - if err := resolveIdentityInteractively(ctx, cfg, newHuhPrompter(os.LookupEnv), + if err := resolveIdentityInteractively(ctx, cfg, newNamingPrompter(os.LookupEnv), viper.GetString(confOptions.organization.viperKey), filepath.Base(repoRoot), executor, pinTo); err != nil { _ = executor.Close() return nil, err diff --git a/app/cli/pkg/prompt/prompt.go b/app/cli/pkg/prompt/prompt.go index 43c1c621e..c4e08bf5a 100644 --- a/app/cli/pkg/prompt/prompt.go +++ b/app/cli/pkg/prompt/prompt.go @@ -53,13 +53,9 @@ type Prompter struct { // New builds a prompter over the real terminal, in plain-text mode when the // environment asks for it. func New(lookupEnv func(string) (string, bool)) *Prompter { - accessible := false - for _, key := range accessibleEnvVars { - if envEnabled(lookupEnv, key) { - accessible = true - break - } - } + accessible := slices.ContainsFunc(accessibleEnvVars, func(key string) bool { + return envEnabled(lookupEnv, key) + }) return &Prompter{accessible: accessible, in: os.Stdin, out: os.Stderr} } @@ -229,13 +225,10 @@ func (p *Prompter) run(field huh.Field) error { WithInput(p.in). WithOutput(p.out) - if err := form.Run(); err != nil { - if errors.Is(err, huh.ErrUserAborted) { - return ErrAborted - } - - return err + err := form.Run() + if errors.Is(err, huh.ErrUserAborted) { + return ErrAborted } - return nil + return err } diff --git a/app/cli/pkg/prompt/prompt_test.go b/app/cli/pkg/prompt/prompt_test.go index d78edb63a..fb8408bdc 100644 --- a/app/cli/pkg/prompt/prompt_test.go +++ b/app/cli/pkg/prompt/prompt_test.go @@ -77,6 +77,8 @@ func TestSelectValidate(t *testing.T) { func TestInput(t *testing.T) { p, out := accessiblePrompter("typed-value\n") + // No Validate and no Describe on purpose: huh panics on a nil validator, so + // a question that checks nothing has to reach the prompt all the same. got, err := p.Input(InputOpts{Title: "A name", Description: "in some context", Default: "a-default"}) require.NoError(t, err) assert.Equal(t, "typed-value", got)