feat(api): expose token branch prefixes in the RPC and the CLI
Adds the token.branch_prefixes feature, `ermite token create --branch-prefix` and a branches column / branch_prefixes JSON field in `token list`. The CLI refuses to print the secret of a token that came back less limited than asked (a server that predates the field).
- commit
- 10270db20ca25e4b35100a7affc78a5b7c548f13
- author
- brice · · 2026-10-12 14:58 UTC
- committer
- brice · · 2026-10-12 15:01 UTC
- parent
- 616a749
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
12 files · +199 -42
M internal/adapters/client/client_test.go +2 -1
| @@ -139,10 +139,11 @@ func TestDescribeBoundsLongMessages(t *testing.T) { | |||
| 139 | 139 | func TestMissingFeatures(t *testing.T) { | |
| 140 | 140 | assert.Empty(t, client.MissingFeatures(client.UsedFeatures())) | |
| 141 | 141 | assert.Equal(t, | |
| 142 | - | []string{client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureSigningKey}, | |
| 142 | + | []string{client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureSigningKey, client.FeatureTokenBranchPrefixes}, | |
| 143 | 143 | client.MissingFeatures([]string{client.FeatureRepoUpdate, client.FeatureSession, "other"})) | |
| 144 | 144 | assert.Equal(t, []string{client.FeatureSigningKey}, client.MissingFeatures([]string{ | |
| 145 | 145 | client.FeatureRepoUpdate, client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureSession, | |
| 146 | + | client.FeatureTokenBranchPrefixes, | |
| 146 | 147 | })) | |
| 147 | 148 | assert.Equal(t, client.UsedFeatures(), client.MissingFeatures(nil)) | |
| 148 | 149 | } | |
| @@ -139,10 +139,11 @@ func TestDescribeBoundsLongMessages(t *testing.T) { | |||||
| 139 | func TestMissingFeatures(t *testing.T) { | 139 | func TestMissingFeatures(t *testing.T) { | ||
| 140 | assert.Empty(t, client.MissingFeatures(client.UsedFeatures())) | 140 | assert.Empty(t, client.MissingFeatures(client.UsedFeatures())) | ||
| 141 | assert.Equal(t, | 141 | assert.Equal(t, | ||
| 142 | - | []string{client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureSigningKey}, | 142 | + | []string{client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureSigningKey, client.FeatureTokenBranchPrefixes}, |
| 143 | client.MissingFeatures([]string{client.FeatureRepoUpdate, client.FeatureSession, "other"})) | 143 | client.MissingFeatures([]string{client.FeatureRepoUpdate, client.FeatureSession, "other"})) | ||
| 144 | assert.Equal(t, []string{client.FeatureSigningKey}, client.MissingFeatures([]string{ | 144 | assert.Equal(t, []string{client.FeatureSigningKey}, client.MissingFeatures([]string{ | ||
| 145 | client.FeatureRepoUpdate, client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureSession, | 145 | client.FeatureRepoUpdate, client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureSession, | ||
| 146 | + | client.FeatureTokenBranchPrefixes, | |||
| 146 | })) | 147 | })) | ||
| 147 | assert.Equal(t, client.UsedFeatures(), client.MissingFeatures(nil)) | 148 | assert.Equal(t, client.UsedFeatures(), client.MissingFeatures(nil)) | ||
| 148 | } | 149 | } | ||
M internal/adapters/client/features.go +6 -1
| @@ -22,11 +22,16 @@ const ( | |||
| 22 | 22 | FeatureSigningKey = "signing_key" | |
| 23 | 23 | // FeatureSession is SessionService (`ermite web login`, `web logout`). | |
| 24 | 24 | FeatureSession = "session" | |
| 25 | + | // FeatureTokenBranchPrefixes is Token.branch_prefixes and | |
| 26 | + | // CreateTokenRequest.branch_prefixes (`ermite token create --branch-prefix`). | |
| 27 | + | // A server without it ignores the field: `token create` checks the | |
| 28 | + | // token it gets back. | |
| 29 | + | FeatureTokenBranchPrefixes = "token.branch_prefixes" | |
| 25 | 30 | ) | |
| 26 | 31 | ||
| 27 | 32 | // UsedFeatures lists the features the CLI uses, in the order they are reported. | |
| 28 | 33 | func UsedFeatures() []string { | |
| 29 | - | return []string{FeatureRepoUpdate, FeatureEventsResume, FeatureMergeRequests, FeatureMergeDeleteBranch, FeatureSigningKey, FeatureSession} | |
| 34 | + | return []string{FeatureRepoUpdate, FeatureEventsResume, FeatureMergeRequests, FeatureMergeDeleteBranch, FeatureSigningKey, FeatureSession, FeatureTokenBranchPrefixes} | |
| 30 | 35 | } | |
| 31 | 36 | ||
| 32 | 37 | // MissingFeatures returns the features the CLI uses that have is lacking. | |
| @@ -22,11 +22,16 @@ const ( | |||||
| 22 | FeatureSigningKey = "signing_key" | 22 | FeatureSigningKey = "signing_key" | ||
| 23 | // FeatureSession is SessionService (`ermite web login`, `web logout`). | 23 | // FeatureSession is SessionService (`ermite web login`, `web logout`). | ||
| 24 | FeatureSession = "session" | 24 | FeatureSession = "session" | ||
| 25 | + | // FeatureTokenBranchPrefixes is Token.branch_prefixes and | |||
| 26 | + | // CreateTokenRequest.branch_prefixes (`ermite token create --branch-prefix`). | |||
| 27 | + | // A server without it ignores the field: `token create` checks the | |||
| 28 | + | // token it gets back. | |||
| 29 | + | FeatureTokenBranchPrefixes = "token.branch_prefixes" | |||
| 25 | ) | 30 | ) | ||
| 26 | 31 | ||||
| 27 | // UsedFeatures lists the features the CLI uses, in the order they are reported. | 32 | // UsedFeatures lists the features the CLI uses, in the order they are reported. | ||
| 28 | func UsedFeatures() []string { | 33 | func UsedFeatures() []string { | ||
| 29 | - | return []string{FeatureRepoUpdate, FeatureEventsResume, FeatureMergeRequests, FeatureMergeDeleteBranch, FeatureSigningKey, FeatureSession} | 34 | + | return []string{FeatureRepoUpdate, FeatureEventsResume, FeatureMergeRequests, FeatureMergeDeleteBranch, FeatureSigningKey, FeatureSession, FeatureTokenBranchPrefixes} |
| 30 | } | 35 | } | ||
| 31 | 36 | ||||
| 32 | // MissingFeatures returns the features the CLI uses that have is lacking. | 37 | // MissingFeatures returns the features the CLI uses that have is lacking. | ||
M internal/adapters/rpc/api.go +1 -1
| @@ -26,7 +26,7 @@ const MaxRequestBytes = 1 << 20 | |||
| 26 | 26 | ||
| 27 | 27 | // Features are the optional capabilities of the contract that this server | |
| 28 | 28 | // implements, as GetVersion lists them. | |
| 29 | - | var Features = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths"} | |
| 29 | + | var Features = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths", "token.branch_prefixes"} | |
| 30 | 30 | ||
| 31 | 31 | // Config configures the API. | |
| 32 | 32 | type Config struct { | |
| @@ -26,7 +26,7 @@ const MaxRequestBytes = 1 << 20 | |||||
| 26 | 26 | ||||
| 27 | // Features are the optional capabilities of the contract that this server | 27 | // Features are the optional capabilities of the contract that this server | ||
| 28 | // implements, as GetVersion lists them. | 28 | // implements, as GetVersion lists them. | ||
| 29 | - | var Features = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths"} | 29 | + | var Features = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths", "token.branch_prefixes"} |
| 30 | 30 | ||||
| 31 | // Config configures the API. | 31 | // Config configures the API. | ||
| 32 | type Config struct { | 32 | type Config struct { | ||
M internal/adapters/rpc/convert.go +2 -0
| @@ -4,6 +4,7 @@ package rpc | |||
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | 6 | "fmt" | |
| 7 | + | "slices" | |
| 7 | 8 | "time" | |
| 8 | 9 | ||
| 9 | 10 | "google.golang.org/protobuf/types/known/timestamppb" | |
| @@ -155,6 +156,7 @@ func tokenToPB(t domain.Token) *pb.Token { | |||
| 155 | 156 | for _, r := range t.Repos { | |
| 156 | 157 | out.Repos = append(out.Repos, r.String()) | |
| 157 | 158 | } | |
| 159 | + | out.BranchPrefixes = slices.Clone(t.BranchPrefixes) | |
| 158 | 160 | return out | |
| 159 | 161 | } | |
| 160 | 162 | ||
| @@ -4,6 +4,7 @@ package rpc | |||||
| 4 | 4 | ||||
| 5 | import ( | 5 | import ( | ||
| 6 | "fmt" | 6 | "fmt" | ||
| 7 | + | "slices" | |||
| 7 | "time" | 8 | "time" | ||
| 8 | 9 | ||||
| 9 | "google.golang.org/protobuf/types/known/timestamppb" | 10 | "google.golang.org/protobuf/types/known/timestamppb" | ||
| @@ -155,6 +156,7 @@ func tokenToPB(t domain.Token) *pb.Token { | |||||
| 155 | for _, r := range t.Repos { | 156 | for _, r := range t.Repos { | ||
| 156 | out.Repos = append(out.Repos, r.String()) | 157 | out.Repos = append(out.Repos, r.String()) | ||
| 157 | } | 158 | } | ||
| 159 | + | out.BranchPrefixes = slices.Clone(t.BranchPrefixes) | |||
| 158 | return out | 160 | return out | ||
| 159 | } | 161 | } | ||
| 160 | 162 | ||||
M internal/adapters/rpc/token.go +1 -0
| @@ -34,6 +34,7 @@ func (s *tokenService) CreateToken(ctx context.Context, req *connect.Request[pb. | |||
| 34 | 34 | } | |
| 35 | 35 | params.Repos = append(params.Repos, name) | |
| 36 | 36 | } | |
| 37 | + | params.BranchPrefixes = req.Msg.GetBranchPrefixes() | |
| 37 | 38 | created, err := s.tokens.Create(ctx, caller(ctx), params) | |
| 38 | 39 | if err != nil { | |
| 39 | 40 | return nil, connectError(ctx, err) | |
| @@ -34,6 +34,7 @@ func (s *tokenService) CreateToken(ctx context.Context, req *connect.Request[pb. | |||||
| 34 | } | 34 | } | ||
| 35 | params.Repos = append(params.Repos, name) | 35 | params.Repos = append(params.Repos, name) | ||
| 36 | } | 36 | } | ||
| 37 | + | params.BranchPrefixes = req.Msg.GetBranchPrefixes() | |||
| 37 | created, err := s.tokens.Create(ctx, caller(ctx), params) | 38 | created, err := s.tokens.Create(ctx, caller(ctx), params) | ||
| 38 | if err != nil { | 39 | if err != nil { | ||
| 39 | return nil, connectError(ctx, err) | 40 | return nil, connectError(ctx, err) | ||
M internal/adapters/rpc/token_test.go +31 -0
| @@ -64,6 +64,37 @@ func TestTokenServiceCreateTokenPassesParams(t *testing.T) { | |||
| 64 | 64 | require.NoError(t, err) | |
| 65 | 65 | } | |
| 66 | 66 | ||
| 67 | + | func TestTokenServiceBranchPrefixes(t *testing.T) { | |
| 68 | + | t.Parallel() | |
| 69 | + | tokens := mocks.NewTokenService(t) | |
| 70 | + | tokens.EXPECT().Create(mock.Anything, domain.Owner(), mock.MatchedBy(func(p ports.CreateTokenParams) bool { | |
| 71 | + | return assert.ObjectsAreEqual([]string{"agent/claude/", "bot/"}, p.BranchPrefixes) | |
| 72 | + | })).Return(ports.CreatedToken{ | |
| 73 | + | Token: domain.Token{ID: "t1", BranchPrefixes: []string{"agent/claude/", "bot/"}}, | |
| 74 | + | Secret: "s", | |
| 75 | + | }, nil).Once() | |
| 76 | + | resp, err := (&tokenService{tokens}).CreateToken(ownerCtx(), connect.NewRequest(&pb.CreateTokenRequest{ | |
| 77 | + | Label: "ci", Scopes: []pb.Scope{pb.Scope_SCOPE_REPO_WRITE}, BranchPrefixes: []string{"agent/claude/", "bot/"}, | |
| 78 | + | })) | |
| 79 | + | require.NoError(t, err) | |
| 80 | + | assert.Equal(t, []string{"agent/claude/", "bot/"}, resp.Msg.GetToken().GetBranchPrefixes()) | |
| 81 | + | ||
| 82 | + | // A malformed prefix is the use case's invalid_token: invalid_argument. | |
| 83 | + | tokens.EXPECT().Create(mock.Anything, domain.Owner(), mock.Anything).Return(ports.CreatedToken{}, domain.ErrInvalidToken).Once() | |
| 84 | + | _, err = (&tokenService{tokens}).CreateToken(ownerCtx(), connect.NewRequest(&pb.CreateTokenRequest{ | |
| 85 | + | Label: "ci", Scopes: []pb.Scope{pb.Scope_SCOPE_REPO_WRITE}, BranchPrefixes: []string{"agent"}, | |
| 86 | + | })) | |
| 87 | + | requireCode(t, connect.CodeInvalidArgument, err) | |
| 88 | + | ||
| 89 | + | // Listing returns them too; a token without any has none. | |
| 90 | + | tokens.EXPECT().ListPage(mock.Anything, domain.Owner(), 0, ""). | |
| 91 | + | Return(ports.TokenPage{Tokens: []domain.Token{{ID: "a", BranchPrefixes: []string{"x/"}}, {ID: "b"}}}, nil).Once() | |
| 92 | + | list, err := (&tokenService{tokens}).ListTokens(ownerCtx(), connect.NewRequest(&pb.ListTokensRequest{})) | |
| 93 | + | require.NoError(t, err) | |
| 94 | + | assert.Equal(t, []string{"x/"}, list.Msg.GetTokens()[0].GetBranchPrefixes()) | |
| 95 | + | assert.Empty(t, list.Msg.GetTokens()[1].GetBranchPrefixes()) | |
| 96 | + | } | |
| 97 | + | ||
| 67 | 98 | func TestTokenServiceRevokeToken(t *testing.T) { | |
| 68 | 99 | t.Parallel() | |
| 69 | 100 | tests := []struct { | |
| @@ -64,6 +64,37 @@ func TestTokenServiceCreateTokenPassesParams(t *testing.T) { | |||||
| 64 | require.NoError(t, err) | 64 | require.NoError(t, err) | ||
| 65 | } | 65 | } | ||
| 66 | 66 | ||||
| 67 | + | func TestTokenServiceBranchPrefixes(t *testing.T) { | |||
| 68 | + | t.Parallel() | |||
| 69 | + | tokens := mocks.NewTokenService(t) | |||
| 70 | + | tokens.EXPECT().Create(mock.Anything, domain.Owner(), mock.MatchedBy(func(p ports.CreateTokenParams) bool { | |||
| 71 | + | return assert.ObjectsAreEqual([]string{"agent/claude/", "bot/"}, p.BranchPrefixes) | |||
| 72 | + | })).Return(ports.CreatedToken{ | |||
| 73 | + | Token: domain.Token{ID: "t1", BranchPrefixes: []string{"agent/claude/", "bot/"}}, | |||
| 74 | + | Secret: "s", | |||
| 75 | + | }, nil).Once() | |||
| 76 | + | resp, err := (&tokenService{tokens}).CreateToken(ownerCtx(), connect.NewRequest(&pb.CreateTokenRequest{ | |||
| 77 | + | Label: "ci", Scopes: []pb.Scope{pb.Scope_SCOPE_REPO_WRITE}, BranchPrefixes: []string{"agent/claude/", "bot/"}, | |||
| 78 | + | })) | |||
| 79 | + | require.NoError(t, err) | |||
| 80 | + | assert.Equal(t, []string{"agent/claude/", "bot/"}, resp.Msg.GetToken().GetBranchPrefixes()) | |||
| 81 | + | ||||
| 82 | + | // A malformed prefix is the use case's invalid_token: invalid_argument. | |||
| 83 | + | tokens.EXPECT().Create(mock.Anything, domain.Owner(), mock.Anything).Return(ports.CreatedToken{}, domain.ErrInvalidToken).Once() | |||
| 84 | + | _, err = (&tokenService{tokens}).CreateToken(ownerCtx(), connect.NewRequest(&pb.CreateTokenRequest{ | |||
| 85 | + | Label: "ci", Scopes: []pb.Scope{pb.Scope_SCOPE_REPO_WRITE}, BranchPrefixes: []string{"agent"}, | |||
| 86 | + | })) | |||
| 87 | + | requireCode(t, connect.CodeInvalidArgument, err) | |||
| 88 | + | ||||
| 89 | + | // Listing returns them too; a token without any has none. | |||
| 90 | + | tokens.EXPECT().ListPage(mock.Anything, domain.Owner(), 0, ""). | |||
| 91 | + | Return(ports.TokenPage{Tokens: []domain.Token{{ID: "a", BranchPrefixes: []string{"x/"}}, {ID: "b"}}}, nil).Once() | |||
| 92 | + | list, err := (&tokenService{tokens}).ListTokens(ownerCtx(), connect.NewRequest(&pb.ListTokensRequest{})) | |||
| 93 | + | require.NoError(t, err) | |||
| 94 | + | assert.Equal(t, []string{"x/"}, list.Msg.GetTokens()[0].GetBranchPrefixes()) | |||
| 95 | + | assert.Empty(t, list.Msg.GetTokens()[1].GetBranchPrefixes()) | |||
| 96 | + | } | |||
| 97 | + | ||||
| 67 | func TestTokenServiceRevokeToken(t *testing.T) { | 98 | func TestTokenServiceRevokeToken(t *testing.T) { | ||
| 68 | t.Parallel() | 99 | t.Parallel() | ||
| 69 | tests := []struct { | 100 | tests := []struct { | ||
M internal/app/api_test.go +1 -1
| @@ -196,7 +196,7 @@ func TestAC2ReadAccess(t *testing.T) { | |||
| 196 | 196 | for _, who := range allPrincipals { | |
| 197 | 197 | resp, err := r.who[who].server.GetVersion(ctx, connect.NewRequest(&pb.GetVersionRequest{})) | |
| 198 | 198 | require.NoError(t, err, who) | |
| 199 | - | assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths"}, resp.Msg.GetFeatures(), who) | |
| 199 | + | assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths", "token.branch_prefixes"}, resp.Msg.GetFeatures(), who) | |
| 200 | 200 | assert.NotEmpty(t, resp.Msg.GetVersion()) | |
| 201 | 201 | } | |
| 202 | 202 | }) | |
| @@ -196,7 +196,7 @@ func TestAC2ReadAccess(t *testing.T) { | |||||
| 196 | for _, who := range allPrincipals { | 196 | for _, who := range allPrincipals { | ||
| 197 | resp, err := r.who[who].server.GetVersion(ctx, connect.NewRequest(&pb.GetVersionRequest{})) | 197 | resp, err := r.who[who].server.GetVersion(ctx, connect.NewRequest(&pb.GetVersionRequest{})) | ||
| 198 | require.NoError(t, err, who) | 198 | require.NoError(t, err, who) | ||
| 199 | - | assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths"}, resp.Msg.GetFeatures(), who) | 199 | + | assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths", "token.branch_prefixes"}, resp.Msg.GetFeatures(), who) |
| 200 | assert.NotEmpty(t, resp.Msg.GetVersion()) | 200 | assert.NotEmpty(t, resp.Msg.GetVersion()) | ||
| 201 | } | 201 | } | ||
| 202 | }) | 202 | }) | ||
M internal/app/cli_e2e_test.go +3 -3
| @@ -324,7 +324,7 @@ func TestAC6CLIEndToEnd(t *testing.T) { | |||
| 324 | 324 | ver := decode[versionOut](t, res.stdout) | |
| 325 | 325 | assert.Equal(t, c.url, ver.Server.URL) | |
| 326 | 326 | assert.NotEmpty(t, ver.Server.Version) | |
| 327 | - | assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths"}, ver.Server.Features) | |
| 327 | + | assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths", "token.branch_prefixes"}, ver.Server.Features) | |
| 328 | 328 | assert.Equal(t, []string{}, ver.Server.MissingFeatures) | |
| 329 | 329 | stdout, stderr, code = c.bin(t, secret, "version") | |
| 330 | 330 | require.Equal(t, 0, code, stderr) | |
| @@ -336,7 +336,7 @@ func TestAC6CLIEndToEnd(t *testing.T) { | |||
| 336 | 336 | stdout, stderr, code = c.bin(t, secret, "token", "create", "tmp", "--preset", "ci", "--repo", "proj", "--expires", "7d", "--json") | |
| 337 | 337 | require.Equal(t, 0, code, stderr) | |
| 338 | 338 | res = result{stdout: stdout} | |
| 339 | - | assert.Equal(t, []string{"created_at", "expires_at", "id", "label", "last_used_at", "repos", "scopes", "secret"}, keys(t, res.stdout)) | |
| 339 | + | assert.Equal(t, []string{"branch_prefixes", "created_at", "expires_at", "id", "label", "last_used_at", "repos", "scopes", "secret"}, keys(t, res.stdout)) | |
| 340 | 340 | tmp := decode[tokenOut](t, res.stdout) | |
| 341 | 341 | assert.Equal(t, []string{"repo:read"}, tmp.Scopes) | |
| 342 | 342 | assert.Equal(t, []string{"proj"}, tmp.Repos) | |
| @@ -366,7 +366,7 @@ func TestAC6CLIEndToEnd(t *testing.T) { | |||
| 366 | 366 | assert.Equal(t, tmp.ID, byLabel["tmp"].ID) | |
| 367 | 367 | assert.NotContains(t, res.stdout, `"secret"`) | |
| 368 | 368 | res = c.cli(t, secret, "token", "list").ok(t) | |
| 369 | - | assert.Regexp(t, `(?m)^ID\s+LABEL\s+SCOPES\s+REPOS\s+EXPIRES\s+LAST USED$`, res.stdout) | |
| 369 | + | assert.Regexp(t, `(?m)^ID\s+LABEL\s+SCOPES\s+REPOS\s+BRANCHES\s+EXPIRES\s+LAST USED$`, res.stdout) | |
| 370 | 370 | assert.Contains(t, res.stdout, tmp.ID) | |
| 371 | 371 | ||
| 372 | 372 | stdout, stderr, code = c.bin(t, secret, "token", "revoke", tmp.ID) | |
| @@ -324,7 +324,7 @@ func TestAC6CLIEndToEnd(t *testing.T) { | |||||
| 324 | ver := decode[versionOut](t, res.stdout) | 324 | ver := decode[versionOut](t, res.stdout) | ||
| 325 | assert.Equal(t, c.url, ver.Server.URL) | 325 | assert.Equal(t, c.url, ver.Server.URL) | ||
| 326 | assert.NotEmpty(t, ver.Server.Version) | 326 | assert.NotEmpty(t, ver.Server.Version) | ||
| 327 | - | assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths"}, ver.Server.Features) | 327 | + | assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths", "token.branch_prefixes"}, ver.Server.Features) |
| 328 | assert.Equal(t, []string{}, ver.Server.MissingFeatures) | 328 | assert.Equal(t, []string{}, ver.Server.MissingFeatures) | ||
| 329 | stdout, stderr, code = c.bin(t, secret, "version") | 329 | stdout, stderr, code = c.bin(t, secret, "version") | ||
| 330 | require.Equal(t, 0, code, stderr) | 330 | require.Equal(t, 0, code, stderr) | ||
| @@ -336,7 +336,7 @@ func TestAC6CLIEndToEnd(t *testing.T) { | |||||
| 336 | stdout, stderr, code = c.bin(t, secret, "token", "create", "tmp", "--preset", "ci", "--repo", "proj", "--expires", "7d", "--json") | 336 | stdout, stderr, code = c.bin(t, secret, "token", "create", "tmp", "--preset", "ci", "--repo", "proj", "--expires", "7d", "--json") | ||
| 337 | require.Equal(t, 0, code, stderr) | 337 | require.Equal(t, 0, code, stderr) | ||
| 338 | res = result{stdout: stdout} | 338 | res = result{stdout: stdout} | ||
| 339 | - | assert.Equal(t, []string{"created_at", "expires_at", "id", "label", "last_used_at", "repos", "scopes", "secret"}, keys(t, res.stdout)) | 339 | + | assert.Equal(t, []string{"branch_prefixes", "created_at", "expires_at", "id", "label", "last_used_at", "repos", "scopes", "secret"}, keys(t, res.stdout)) |
| 340 | tmp := decode[tokenOut](t, res.stdout) | 340 | tmp := decode[tokenOut](t, res.stdout) | ||
| 341 | assert.Equal(t, []string{"repo:read"}, tmp.Scopes) | 341 | assert.Equal(t, []string{"repo:read"}, tmp.Scopes) | ||
| 342 | assert.Equal(t, []string{"proj"}, tmp.Repos) | 342 | assert.Equal(t, []string{"proj"}, tmp.Repos) | ||
| @@ -366,7 +366,7 @@ func TestAC6CLIEndToEnd(t *testing.T) { | |||||
| 366 | assert.Equal(t, tmp.ID, byLabel["tmp"].ID) | 366 | assert.Equal(t, tmp.ID, byLabel["tmp"].ID) | ||
| 367 | assert.NotContains(t, res.stdout, `"secret"`) | 367 | assert.NotContains(t, res.stdout, `"secret"`) | ||
| 368 | res = c.cli(t, secret, "token", "list").ok(t) | 368 | res = c.cli(t, secret, "token", "list").ok(t) | ||
| 369 | - | assert.Regexp(t, `(?m)^ID\s+LABEL\s+SCOPES\s+REPOS\s+EXPIRES\s+LAST USED$`, res.stdout) | 369 | + | assert.Regexp(t, `(?m)^ID\s+LABEL\s+SCOPES\s+REPOS\s+BRANCHES\s+EXPIRES\s+LAST USED$`, res.stdout) |
| 370 | assert.Contains(t, res.stdout, tmp.ID) | 370 | assert.Contains(t, res.stdout, tmp.ID) | ||
| 371 | 371 | ||||
| 372 | stdout, stderr, code = c.bin(t, secret, "token", "revoke", tmp.ID) | 372 | stdout, stderr, code = c.bin(t, secret, "token", "revoke", tmp.ID) | ||
M internal/app/cli_token.go +60 -18
| @@ -21,13 +21,15 @@ import ( | |||
| 21 | 21 | // jsonToken is the stable JSON shape of a stored token. The secret is only | |
| 22 | 22 | // present in the output of `token create`. | |
| 23 | 23 | type jsonToken struct { | |
| 24 | - | ID string `json:"id"` | |
| 25 | - | Label string `json:"label"` | |
| 26 | - | Scopes []string `json:"scopes"` | |
| 27 | - | Repos []string `json:"repos"` | |
| 28 | - | ExpiresAt *string `json:"expires_at"` | |
| 29 | - | CreatedAt *string `json:"created_at"` | |
| 30 | - | LastUsedAt *string `json:"last_used_at"` | |
| 24 | + | ID string `json:"id"` | |
| 25 | + | Label string `json:"label"` | |
| 26 | + | Scopes []string `json:"scopes"` | |
| 27 | + | Repos []string `json:"repos"` | |
| 28 | + | // BranchPrefixes limits the branches the token may write; empty means all. | |
| 29 | + | BranchPrefixes []string `json:"branch_prefixes"` | |
| 30 | + | ExpiresAt *string `json:"expires_at"` | |
| 31 | + | CreatedAt *string `json:"created_at"` | |
| 32 | + | LastUsedAt *string `json:"last_used_at"` | |
| 31 | 33 | } | |
| 32 | 34 | ||
| 33 | 35 | type jsonCreatedToken struct { | |
| @@ -44,16 +46,20 @@ func toJSONToken(t *ermite.Token) jsonToken { | |||
| 44 | 46 | if repos == nil { | |
| 45 | 47 | repos = []string{} | |
| 46 | 48 | } | |
| 49 | + | prefixes := t.GetBranchPrefixes() | |
| 50 | + | if prefixes == nil { | |
| 51 | + | prefixes = []string{} | |
| 52 | + | } | |
| 47 | 53 | return jsonToken{ | |
| 48 | - | ID: t.GetId(), Label: t.GetLabel(), Scopes: scopes, Repos: repos, | |
| 54 | + | ID: t.GetId(), Label: t.GetLabel(), Scopes: scopes, Repos: repos, BranchPrefixes: prefixes, | |
| 49 | 55 | ExpiresAt: jsonTime(t.GetExpiresAt()), CreatedAt: jsonTime(t.GetCreatedAt()), LastUsedAt: jsonTime(t.GetLastUsedAt()), | |
| 50 | 56 | } | |
| 51 | 57 | } | |
| 52 | 58 | ||
| 53 | 59 | const jsonTokenHelp = ` | |
| 54 | 60 | JSON fields of a token (--json): id, label, scopes (array of names like "repo:read"), | |
| 55 | - | repos (array; empty means every repository), expires_at, created_at and last_used_at | |
| 56 | - | (RFC 3339 UTC, null when unset or never used).` | |
| 61 | + | repos (array; empty means every repository), branch_prefixes (array; empty means every branch), | |
| 62 | + | expires_at, created_at and last_used_at (RFC 3339 UTC, null when unset or never used).` | |
| 57 | 63 | ||
| 58 | 64 | func newTokenCmd(env *cliEnv) *cobra.Command { | |
| 59 | 65 | token := &cobra.Command{ | |
| @@ -67,10 +73,11 @@ func newTokenCmd(env *cliEnv) *cobra.Command { | |||
| 67 | 73 | ||
| 68 | 74 | func newTokenCreateCmd(env *cliEnv) *cobra.Command { | |
| 69 | 75 | var ( | |
| 70 | - | preset string | |
| 71 | - | scopes []string | |
| 72 | - | repos []string | |
| 73 | - | expires string | |
| 76 | + | preset string | |
| 77 | + | scopes []string | |
| 78 | + | repos []string | |
| 79 | + | prefixes []string | |
| 80 | + | expires string | |
| 74 | 81 | ) | |
| 75 | 82 | cmd := &cobra.Command{ | |
| 76 | 83 | Use: "create <label>", | |
| @@ -84,6 +91,12 @@ func newTokenCreateCmd(env *cliEnv) *cobra.Command { | |||
| 84 | 91 | " ci repo:read\n" + | |
| 85 | 92 | "A token cannot be more powerful than the one that creates it. --repo limits it to the named\n" + | |
| 86 | 93 | "repositories (default: all). --expires takes 30d, 2w, 12h or a date; default: never.\n\n" + | |
| 94 | + | "--branch-prefix limits the branches the token may write (default: all). Each prefix ends with /\n" + | |
| 95 | + | "(for example agent/claude/). The token can push, force-push and delete only branches under its\n" + | |
| 96 | + | "prefixes, and merge only into them; it cannot push tags, create repositories or delete one,\n" + | |
| 97 | + | "and it never has the rights of the owner. It can only create tokens within its own prefixes.\n" + | |
| 98 | + | "To give an AI agent its own space:\n" + | |
| 99 | + | " ermite token create claude --preset agent --scope repo:read --repo <repo> --branch-prefix agent/<name>/\n\n" + | |
| 87 | 100 | "The label is free text of up to 100 bytes, except \"owner\" and \"root\" (in any case): they are\n" + | |
| 88 | 101 | "what the audit trail shows for the admin token and the web owner, so the server refuses them." + jsonTokenHelp, | |
| 89 | 102 | Args: cobra.ExactArgs(1), | |
| @@ -91,6 +104,7 @@ func newTokenCreateCmd(env *cliEnv) *cobra.Command { | |||
| 91 | 104 | cmd.Flags().StringVar(&preset, "preset", "", "scope preset: "+strings.Join(client.PresetNames, ", ")) | |
| 92 | 105 | cmd.Flags().StringSliceVar(&scopes, "scope", nil, "scope to grant, repeatable or comma-separated (for example repo:read)") | |
| 93 | 106 | cmd.Flags().StringSliceVar(&repos, "repo", nil, "limit the token to this repository, repeatable (default: all)") | |
| 107 | + | cmd.Flags().StringSliceVar(&prefixes, "branch-prefix", nil, "limit the branches the token may write to this prefix, ending with / (for example agent/claude/); repeatable (default: all)") | |
| 94 | 108 | cmd.Flags().StringVar(&expires, "expires", "", "lifetime (30d, 2w, 12h) or date (2027-01-31); default: never") | |
| 95 | 109 | _ = cmd.RegisterFlagCompletionFunc("preset", func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) { | |
| 96 | 110 | return client.PresetNames, cobra.ShellCompDirectiveNoFileComp | |
| @@ -103,7 +117,7 @@ func newTokenCreateCmd(env *cliEnv) *cobra.Command { | |||
| 103 | 117 | }) | |
| 104 | 118 | asJSON := addJSONFlag(cmd) | |
| 105 | 119 | cmd.RunE = func(cmd *cobra.Command, args []string) error { | |
| 106 | - | req, err := buildCreateToken(args[0], preset, scopes, repos, expires, env.now()) | |
| 120 | + | req, err := buildCreateToken(args[0], preset, scopes, repos, prefixes, expires, env.now()) | |
| 107 | 121 | if err != nil { | |
| 108 | 122 | return err | |
| 109 | 123 | } | |
| @@ -115,13 +129,27 @@ func newTokenCreateCmd(env *cliEnv) *cobra.Command { | |||
| 115 | 129 | if err != nil { | |
| 116 | 130 | return c.Describe(err) | |
| 117 | 131 | } | |
| 132 | + | if err := checkBranchPrefixes(req, res.Msg.GetToken()); err != nil { | |
| 133 | + | return err | |
| 134 | + | } | |
| 118 | 135 | return printCreatedToken(cmd, res.Msg, *asJSON) | |
| 119 | 136 | } | |
| 120 | 137 | return cmd | |
| 121 | 138 | } | |
| 122 | 139 | ||
| 140 | + | // checkBranchPrefixes refuses to hand out a token that is less limited than | |
| 141 | + | // asked for. A server that predates branch prefixes ignores the field and | |
| 142 | + | // creates an unlimited token: the secret is then not printed. | |
| 143 | + | func checkBranchPrefixes(req *ermite.CreateTokenRequest, got *ermite.Token) error { | |
| 144 | + | if slices.Equal(req.GetBranchPrefixes(), got.GetBranchPrefixes()) { | |
| 145 | + | return nil | |
| 146 | + | } | |
| 147 | + | return fmt.Errorf("the server created token %s without the requested branch prefixes (it may be too old: "+ | |
| 148 | + | "see `ermite version`); its secret is not shown, revoke it with `ermite token revoke %s`", got.GetId(), got.GetId()) | |
| 149 | + | } | |
| 150 | + | ||
| 123 | 151 | // buildCreateToken maps the flags of `token create` to the request. | |
| 124 | - | func buildCreateToken(label, preset string, scopeFlags, repoFlags []string, expires string, now time.Time) (*ermite.CreateTokenRequest, error) { | |
| 152 | + | func buildCreateToken(label, preset string, scopeFlags, repoFlags, prefixFlags []string, expires string, now time.Time) (*ermite.CreateTokenRequest, error) { | |
| 125 | 153 | label = strings.TrimSpace(label) | |
| 126 | 154 | if label == "" { | |
| 127 | 155 | return nil, errors.New("the label must not be empty") | |
| @@ -136,6 +164,11 @@ func buildCreateToken(label, preset string, scopeFlags, repoFlags []string, expi | |||
| 136 | 164 | req.Repos = append(req.Repos, r) | |
| 137 | 165 | } | |
| 138 | 166 | } | |
| 167 | + | for _, bp := range prefixFlags { | |
| 168 | + | if bp = strings.TrimSpace(bp); bp != "" && !slices.Contains(req.BranchPrefixes, bp) { | |
| 169 | + | req.BranchPrefixes = append(req.BranchPrefixes, bp) | |
| 170 | + | } | |
| 171 | + | } | |
| 139 | 172 | if expires != "" { | |
| 140 | 173 | at, err := client.ParseExpiry(expires, now) | |
| 141 | 174 | if err != nil { | |
| @@ -155,10 +188,15 @@ func printCreatedToken(cmd *cobra.Command, res *ermite.CreateTokenResponse, asJS | |||
| 155 | 188 | if len(t.GetRepos()) > 0 { | |
| 156 | 189 | repos = strings.Join(t.GetRepos(), ", ") | |
| 157 | 190 | } | |
| 191 | + | branches := "all" | |
| 192 | + | if len(t.GetBranchPrefixes()) > 0 { | |
| 193 | + | branches = strings.Join(t.GetBranchPrefixes(), ", ") | |
| 194 | + | } | |
| 158 | 195 | noticef(cmd, "created token %s %q", t.GetId(), clean(t.GetLabel())) | |
| 159 | 196 | _ = fields(cmd.ErrOrStderr(), [][2]string{ | |
| 160 | 197 | {" scopes", strings.Join(client.ScopeNames(t.GetScopes()), ", ")}, | |
| 161 | 198 | {" repos", repos}, | |
| 199 | + | {" branches", branches}, | |
| 162 | 200 | {" expires", shortTime(t.GetExpiresAt(), "never")}, | |
| 163 | 201 | }) | |
| 164 | 202 | noticef(cmd, "the secret below is shown once and cannot be retrieved later") | |
| @@ -210,12 +248,16 @@ func newTokenListCmd(env *cliEnv) *cobra.Command { | |||
| 210 | 248 | if len(t.GetRepos()) > 0 { | |
| 211 | 249 | repos = strings.Join(t.GetRepos(), ",") | |
| 212 | 250 | } | |
| 251 | + | branches := "all" | |
| 252 | + | if len(t.GetBranchPrefixes()) > 0 { | |
| 253 | + | branches = strings.Join(t.GetBranchPrefixes(), ",") | |
| 254 | + | } | |
| 213 | 255 | rows = append(rows, []string{ | |
| 214 | - | t.GetId(), t.GetLabel(), strings.Join(client.ScopeNames(t.GetScopes()), ","), repos, | |
| 256 | + | t.GetId(), t.GetLabel(), strings.Join(client.ScopeNames(t.GetScopes()), ","), repos, branches, | |
| 215 | 257 | shortTime(t.GetExpiresAt(), "never"), shortTime(t.GetLastUsedAt(), "never"), | |
| 216 | 258 | }) | |
| 217 | 259 | } | |
| 218 | - | return table(cmd.OutOrStdout(), []string{"ID", "LABEL", "SCOPES", "REPOS", "EXPIRES", "LAST USED"}, rows) | |
| 260 | + | return table(cmd.OutOrStdout(), []string{"ID", "LABEL", "SCOPES", "REPOS", "BRANCHES", "EXPIRES", "LAST USED"}, rows) | |
| 219 | 261 | } | |
| 220 | 262 | return cmd | |
| 221 | 263 | } | |
| @@ -21,13 +21,15 @@ import ( | |||||
| 21 | // jsonToken is the stable JSON shape of a stored token. The secret is only | 21 | // jsonToken is the stable JSON shape of a stored token. The secret is only | ||
| 22 | // present in the output of `token create`. | 22 | // present in the output of `token create`. | ||
| 23 | type jsonToken struct { | 23 | type jsonToken struct { | ||
| 24 | - | ID string `json:"id"` | 24 | + | ID string `json:"id"` |
| 25 | - | Label string `json:"label"` | 25 | + | Label string `json:"label"` |
| 26 | - | Scopes []string `json:"scopes"` | 26 | + | Scopes []string `json:"scopes"` |
| 27 | - | Repos []string `json:"repos"` | 27 | + | Repos []string `json:"repos"` |
| 28 | - | ExpiresAt *string `json:"expires_at"` | 28 | + | // BranchPrefixes limits the branches the token may write; empty means all. |
| 29 | - | CreatedAt *string `json:"created_at"` | 29 | + | BranchPrefixes []string `json:"branch_prefixes"` |
| 30 | - | LastUsedAt *string `json:"last_used_at"` | 30 | + | ExpiresAt *string `json:"expires_at"` |
| 31 | + | CreatedAt *string `json:"created_at"` | |||
| 32 | + | LastUsedAt *string `json:"last_used_at"` | |||
| 31 | } | 33 | } | ||
| 32 | 34 | ||||
| 33 | type jsonCreatedToken struct { | 35 | type jsonCreatedToken struct { | ||
| @@ -44,16 +46,20 @@ func toJSONToken(t *ermite.Token) jsonToken { | |||||
| 44 | if repos == nil { | 46 | if repos == nil { | ||
| 45 | repos = []string{} | 47 | repos = []string{} | ||
| 46 | } | 48 | } | ||
| 49 | + | prefixes := t.GetBranchPrefixes() | |||
| 50 | + | if prefixes == nil { | |||
| 51 | + | prefixes = []string{} | |||
| 52 | + | } | |||
| 47 | return jsonToken{ | 53 | return jsonToken{ | ||
| 48 | - | ID: t.GetId(), Label: t.GetLabel(), Scopes: scopes, Repos: repos, | 54 | + | ID: t.GetId(), Label: t.GetLabel(), Scopes: scopes, Repos: repos, BranchPrefixes: prefixes, |
| 49 | ExpiresAt: jsonTime(t.GetExpiresAt()), CreatedAt: jsonTime(t.GetCreatedAt()), LastUsedAt: jsonTime(t.GetLastUsedAt()), | 55 | ExpiresAt: jsonTime(t.GetExpiresAt()), CreatedAt: jsonTime(t.GetCreatedAt()), LastUsedAt: jsonTime(t.GetLastUsedAt()), | ||
| 50 | } | 56 | } | ||
| 51 | } | 57 | } | ||
| 52 | 58 | ||||
| 53 | const jsonTokenHelp = ` | 59 | const jsonTokenHelp = ` | ||
| 54 | JSON fields of a token (--json): id, label, scopes (array of names like "repo:read"), | 60 | JSON fields of a token (--json): id, label, scopes (array of names like "repo:read"), | ||
| 55 | - | repos (array; empty means every repository), expires_at, created_at and last_used_at | 61 | + | repos (array; empty means every repository), branch_prefixes (array; empty means every branch), |
| 56 | - | (RFC 3339 UTC, null when unset or never used).` | 62 | + | expires_at, created_at and last_used_at (RFC 3339 UTC, null when unset or never used).` |
| 57 | 63 | ||||
| 58 | func newTokenCmd(env *cliEnv) *cobra.Command { | 64 | func newTokenCmd(env *cliEnv) *cobra.Command { | ||
| 59 | token := &cobra.Command{ | 65 | token := &cobra.Command{ | ||
| @@ -67,10 +73,11 @@ func newTokenCmd(env *cliEnv) *cobra.Command { | |||||
| 67 | 73 | ||||
| 68 | func newTokenCreateCmd(env *cliEnv) *cobra.Command { | 74 | func newTokenCreateCmd(env *cliEnv) *cobra.Command { | ||
| 69 | var ( | 75 | var ( | ||
| 70 | - | preset string | 76 | + | preset string |
| 71 | - | scopes []string | 77 | + | scopes []string |
| 72 | - | repos []string | 78 | + | repos []string |
| 73 | - | expires string | 79 | + | prefixes []string |
| 80 | + | expires string | |||
| 74 | ) | 81 | ) | ||
| 75 | cmd := &cobra.Command{ | 82 | cmd := &cobra.Command{ | ||
| 76 | Use: "create <label>", | 83 | Use: "create <label>", | ||
| @@ -84,6 +91,12 @@ func newTokenCreateCmd(env *cliEnv) *cobra.Command { | |||||
| 84 | " ci repo:read\n" + | 91 | " ci repo:read\n" + | ||
| 85 | "A token cannot be more powerful than the one that creates it. --repo limits it to the named\n" + | 92 | "A token cannot be more powerful than the one that creates it. --repo limits it to the named\n" + | ||
| 86 | "repositories (default: all). --expires takes 30d, 2w, 12h or a date; default: never.\n\n" + | 93 | "repositories (default: all). --expires takes 30d, 2w, 12h or a date; default: never.\n\n" + | ||
| 94 | + | "--branch-prefix limits the branches the token may write (default: all). Each prefix ends with /\n" + | |||
| 95 | + | "(for example agent/claude/). The token can push, force-push and delete only branches under its\n" + | |||
| 96 | + | "prefixes, and merge only into them; it cannot push tags, create repositories or delete one,\n" + | |||
| 97 | + | "and it never has the rights of the owner. It can only create tokens within its own prefixes.\n" + | |||
| 98 | + | "To give an AI agent its own space:\n" + | |||
| 99 | + | " ermite token create claude --preset agent --scope repo:read --repo <repo> --branch-prefix agent/<name>/\n\n" + | |||
| 87 | "The label is free text of up to 100 bytes, except \"owner\" and \"root\" (in any case): they are\n" + | 100 | "The label is free text of up to 100 bytes, except \"owner\" and \"root\" (in any case): they are\n" + | ||
| 88 | "what the audit trail shows for the admin token and the web owner, so the server refuses them." + jsonTokenHelp, | 101 | "what the audit trail shows for the admin token and the web owner, so the server refuses them." + jsonTokenHelp, | ||
| 89 | Args: cobra.ExactArgs(1), | 102 | Args: cobra.ExactArgs(1), | ||
| @@ -91,6 +104,7 @@ func newTokenCreateCmd(env *cliEnv) *cobra.Command { | |||||
| 91 | cmd.Flags().StringVar(&preset, "preset", "", "scope preset: "+strings.Join(client.PresetNames, ", ")) | 104 | cmd.Flags().StringVar(&preset, "preset", "", "scope preset: "+strings.Join(client.PresetNames, ", ")) | ||
| 92 | cmd.Flags().StringSliceVar(&scopes, "scope", nil, "scope to grant, repeatable or comma-separated (for example repo:read)") | 105 | cmd.Flags().StringSliceVar(&scopes, "scope", nil, "scope to grant, repeatable or comma-separated (for example repo:read)") | ||
| 93 | cmd.Flags().StringSliceVar(&repos, "repo", nil, "limit the token to this repository, repeatable (default: all)") | 106 | cmd.Flags().StringSliceVar(&repos, "repo", nil, "limit the token to this repository, repeatable (default: all)") | ||
| 107 | + | cmd.Flags().StringSliceVar(&prefixes, "branch-prefix", nil, "limit the branches the token may write to this prefix, ending with / (for example agent/claude/); repeatable (default: all)") | |||
| 94 | cmd.Flags().StringVar(&expires, "expires", "", "lifetime (30d, 2w, 12h) or date (2027-01-31); default: never") | 108 | cmd.Flags().StringVar(&expires, "expires", "", "lifetime (30d, 2w, 12h) or date (2027-01-31); default: never") | ||
| 95 | _ = cmd.RegisterFlagCompletionFunc("preset", func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) { | 109 | _ = cmd.RegisterFlagCompletionFunc("preset", func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) { | ||
| 96 | return client.PresetNames, cobra.ShellCompDirectiveNoFileComp | 110 | return client.PresetNames, cobra.ShellCompDirectiveNoFileComp | ||
| @@ -103,7 +117,7 @@ func newTokenCreateCmd(env *cliEnv) *cobra.Command { | |||||
| 103 | }) | 117 | }) | ||
| 104 | asJSON := addJSONFlag(cmd) | 118 | asJSON := addJSONFlag(cmd) | ||
| 105 | cmd.RunE = func(cmd *cobra.Command, args []string) error { | 119 | cmd.RunE = func(cmd *cobra.Command, args []string) error { | ||
| 106 | - | req, err := buildCreateToken(args[0], preset, scopes, repos, expires, env.now()) | 120 | + | req, err := buildCreateToken(args[0], preset, scopes, repos, prefixes, expires, env.now()) |
| 107 | if err != nil { | 121 | if err != nil { | ||
| 108 | return err | 122 | return err | ||
| 109 | } | 123 | } | ||
| @@ -115,13 +129,27 @@ func newTokenCreateCmd(env *cliEnv) *cobra.Command { | |||||
| 115 | if err != nil { | 129 | if err != nil { | ||
| 116 | return c.Describe(err) | 130 | return c.Describe(err) | ||
| 117 | } | 131 | } | ||
| 132 | + | if err := checkBranchPrefixes(req, res.Msg.GetToken()); err != nil { | |||
| 133 | + | return err | |||
| 134 | + | } | |||
| 118 | return printCreatedToken(cmd, res.Msg, *asJSON) | 135 | return printCreatedToken(cmd, res.Msg, *asJSON) | ||
| 119 | } | 136 | } | ||
| 120 | return cmd | 137 | return cmd | ||
| 121 | } | 138 | } | ||
| 122 | 139 | ||||
| 140 | + | // checkBranchPrefixes refuses to hand out a token that is less limited than | |||
| 141 | + | // asked for. A server that predates branch prefixes ignores the field and | |||
| 142 | + | // creates an unlimited token: the secret is then not printed. | |||
| 143 | + | func checkBranchPrefixes(req *ermite.CreateTokenRequest, got *ermite.Token) error { | |||
| 144 | + | if slices.Equal(req.GetBranchPrefixes(), got.GetBranchPrefixes()) { | |||
| 145 | + | return nil | |||
| 146 | + | } | |||
| 147 | + | return fmt.Errorf("the server created token %s without the requested branch prefixes (it may be too old: "+ | |||
| 148 | + | "see `ermite version`); its secret is not shown, revoke it with `ermite token revoke %s`", got.GetId(), got.GetId()) | |||
| 149 | + | } | |||
| 150 | + | ||||
| 123 | // buildCreateToken maps the flags of `token create` to the request. | 151 | // buildCreateToken maps the flags of `token create` to the request. | ||
| 124 | - | func buildCreateToken(label, preset string, scopeFlags, repoFlags []string, expires string, now time.Time) (*ermite.CreateTokenRequest, error) { | 152 | + | func buildCreateToken(label, preset string, scopeFlags, repoFlags, prefixFlags []string, expires string, now time.Time) (*ermite.CreateTokenRequest, error) { |
| 125 | label = strings.TrimSpace(label) | 153 | label = strings.TrimSpace(label) | ||
| 126 | if label == "" { | 154 | if label == "" { | ||
| 127 | return nil, errors.New("the label must not be empty") | 155 | return nil, errors.New("the label must not be empty") | ||
| @@ -136,6 +164,11 @@ func buildCreateToken(label, preset string, scopeFlags, repoFlags []string, expi | |||||
| 136 | req.Repos = append(req.Repos, r) | 164 | req.Repos = append(req.Repos, r) | ||
| 137 | } | 165 | } | ||
| 138 | } | 166 | } | ||
| 167 | + | for _, bp := range prefixFlags { | |||
| 168 | + | if bp = strings.TrimSpace(bp); bp != "" && !slices.Contains(req.BranchPrefixes, bp) { | |||
| 169 | + | req.BranchPrefixes = append(req.BranchPrefixes, bp) | |||
| 170 | + | } | |||
| 171 | + | } | |||
| 139 | if expires != "" { | 172 | if expires != "" { | ||
| 140 | at, err := client.ParseExpiry(expires, now) | 173 | at, err := client.ParseExpiry(expires, now) | ||
| 141 | if err != nil { | 174 | if err != nil { | ||
| @@ -155,10 +188,15 @@ func printCreatedToken(cmd *cobra.Command, res *ermite.CreateTokenResponse, asJS | |||||
| 155 | if len(t.GetRepos()) > 0 { | 188 | if len(t.GetRepos()) > 0 { | ||
| 156 | repos = strings.Join(t.GetRepos(), ", ") | 189 | repos = strings.Join(t.GetRepos(), ", ") | ||
| 157 | } | 190 | } | ||
| 191 | + | branches := "all" | |||
| 192 | + | if len(t.GetBranchPrefixes()) > 0 { | |||
| 193 | + | branches = strings.Join(t.GetBranchPrefixes(), ", ") | |||
| 194 | + | } | |||
| 158 | noticef(cmd, "created token %s %q", t.GetId(), clean(t.GetLabel())) | 195 | noticef(cmd, "created token %s %q", t.GetId(), clean(t.GetLabel())) | ||
| 159 | _ = fields(cmd.ErrOrStderr(), [][2]string{ | 196 | _ = fields(cmd.ErrOrStderr(), [][2]string{ | ||
| 160 | {" scopes", strings.Join(client.ScopeNames(t.GetScopes()), ", ")}, | 197 | {" scopes", strings.Join(client.ScopeNames(t.GetScopes()), ", ")}, | ||
| 161 | {" repos", repos}, | 198 | {" repos", repos}, | ||
| 199 | + | {" branches", branches}, | |||
| 162 | {" expires", shortTime(t.GetExpiresAt(), "never")}, | 200 | {" expires", shortTime(t.GetExpiresAt(), "never")}, | ||
| 163 | }) | 201 | }) | ||
| 164 | noticef(cmd, "the secret below is shown once and cannot be retrieved later") | 202 | noticef(cmd, "the secret below is shown once and cannot be retrieved later") | ||
| @@ -210,12 +248,16 @@ func newTokenListCmd(env *cliEnv) *cobra.Command { | |||||
| 210 | if len(t.GetRepos()) > 0 { | 248 | if len(t.GetRepos()) > 0 { | ||
| 211 | repos = strings.Join(t.GetRepos(), ",") | 249 | repos = strings.Join(t.GetRepos(), ",") | ||
| 212 | } | 250 | } | ||
| 251 | + | branches := "all" | |||
| 252 | + | if len(t.GetBranchPrefixes()) > 0 { | |||
| 253 | + | branches = strings.Join(t.GetBranchPrefixes(), ",") | |||
| 254 | + | } | |||
| 213 | rows = append(rows, []string{ | 255 | rows = append(rows, []string{ | ||
| 214 | - | t.GetId(), t.GetLabel(), strings.Join(client.ScopeNames(t.GetScopes()), ","), repos, | 256 | + | t.GetId(), t.GetLabel(), strings.Join(client.ScopeNames(t.GetScopes()), ","), repos, branches, |
| 215 | shortTime(t.GetExpiresAt(), "never"), shortTime(t.GetLastUsedAt(), "never"), | 257 | shortTime(t.GetExpiresAt(), "never"), shortTime(t.GetLastUsedAt(), "never"), | ||
| 216 | }) | 258 | }) | ||
| 217 | } | 259 | } | ||
| 218 | - | return table(cmd.OutOrStdout(), []string{"ID", "LABEL", "SCOPES", "REPOS", "EXPIRES", "LAST USED"}, rows) | 260 | + | return table(cmd.OutOrStdout(), []string{"ID", "LABEL", "SCOPES", "REPOS", "BRANCHES", "EXPIRES", "LAST USED"}, rows) |
| 219 | } | 261 | } | ||
| 220 | return cmd | 262 | return cmd | ||
| 221 | } | 263 | } | ||
M internal/app/cli_token_test.go +80 -6
| @@ -42,6 +42,80 @@ func TestTokenCreateMapsFlags(t *testing.T) { | |||
| 42 | 42 | assert.NotContains(t, res.stderr, "SECRETVALUE") | |
| 43 | 43 | } | |
| 44 | 44 | ||
| 45 | + | func TestTokenCreateBranchPrefixes(t *testing.T) { | |
| 46 | + | echo := func(r *ermite.CreateTokenRequest) (*ermite.CreateTokenResponse, error) { | |
| 47 | + | return &ermite.CreateTokenResponse{ | |
| 48 | + | Token: &ermite.Token{ | |
| 49 | + | Id: "tok1", Label: r.GetLabel(), Scopes: r.GetScopes(), BranchPrefixes: r.GetBranchPrefixes(), | |
| 50 | + | CreatedAt: ts("2026-10-10T12:00:00Z"), | |
| 51 | + | }, | |
| 52 | + | Secret: "ermite_S", | |
| 53 | + | }, nil | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | t.Run("maps the repeatable flag, trims and drops duplicates", func(t *testing.T) { | |
| 57 | + | f := &fakeAPI{createToken: echo} | |
| 58 | + | res := runCLI(t, envFor(f.serve(t)), "", "token", "create", "claude", "--preset", "agent", "--repo", "alpha", | |
| 59 | + | "--branch-prefix", "agent/claude/", "--branch-prefix", " bot/ ", "--branch-prefix", "agent/claude/").ok(t) | |
| 60 | + | _, reqs := f.snapshot() | |
| 61 | + | assert.Equal(t, []string{"agent/claude/", "bot/"}, reqs[0].(*ermite.CreateTokenRequest).GetBranchPrefixes()) | |
| 62 | + | assert.Equal(t, "ermite_S\n", res.stdout) | |
| 63 | + | assert.Contains(t, res.stderr, "branches") | |
| 64 | + | assert.Contains(t, res.stderr, "agent/claude/, bot/") | |
| 65 | + | }) | |
| 66 | + | ||
| 67 | + | t.Run("without the flag the token shows every branch", func(t *testing.T) { | |
| 68 | + | f := &fakeAPI{createToken: echo} | |
| 69 | + | res := runCLI(t, envFor(f.serve(t)), "", "token", "create", "x", "--preset", "ci").ok(t) | |
| 70 | + | _, reqs := f.snapshot() | |
| 71 | + | assert.Empty(t, reqs[0].(*ermite.CreateTokenRequest).GetBranchPrefixes()) | |
| 72 | + | assert.Regexp(t, `branches\s+all`, res.stderr) | |
| 73 | + | }) | |
| 74 | + | ||
| 75 | + | t.Run("json", func(t *testing.T) { | |
| 76 | + | f := &fakeAPI{createToken: echo} | |
| 77 | + | res := runCLI(t, envFor(f.serve(t)), "", "token", "create", "l", "--scope", "repo:write", "--branch-prefix", "a/", "--json").ok(t) | |
| 78 | + | assert.JSONEq(t, `{"id":"tok1","label":"l","scopes":["repo:write"],"repos":[],"branch_prefixes":["a/"],"expires_at":null,"created_at":"2026-10-10T12:00:00Z","last_used_at":null,"secret":"ermite_S"}`, res.stdout) | |
| 79 | + | }) | |
| 80 | + | ||
| 81 | + | t.Run("a server that ignores the field does not hand out an unlimited token", func(t *testing.T) { | |
| 82 | + | f := &fakeAPI{createToken: func(r *ermite.CreateTokenRequest) (*ermite.CreateTokenResponse, error) { | |
| 83 | + | return &ermite.CreateTokenResponse{ | |
| 84 | + | Token: &ermite.Token{Id: "tok9", Label: r.GetLabel(), Scopes: r.GetScopes(), CreatedAt: ts("2026-10-10T12:00:00Z")}, | |
| 85 | + | Secret: "ermite_UNLIMITED", | |
| 86 | + | }, nil | |
| 87 | + | }} | |
| 88 | + | res := runCLI(t, envFor(f.serve(t)), "", "token", "create", "x", "--preset", "agent", "--branch-prefix", "agent/x/") | |
| 89 | + | require.Error(t, res.err) | |
| 90 | + | assert.Contains(t, res.err.Error(), "without the requested branch prefixes") | |
| 91 | + | assert.Contains(t, res.err.Error(), "ermite token revoke tok9") | |
| 92 | + | assert.NotContains(t, res.stdout, "UNLIMITED") | |
| 93 | + | assert.NotContains(t, res.stderr, "UNLIMITED") | |
| 94 | + | assert.NotContains(t, res.err.Error(), "UNLIMITED") | |
| 95 | + | }) | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | func TestTokenListBranchPrefixes(t *testing.T) { | |
| 99 | + | f := &fakeAPI{listTokens: func(*ermite.ListTokensRequest) (*ermite.ListTokensResponse, error) { | |
| 100 | + | return &ermite.ListTokensResponse{Tokens: []*ermite.Token{ | |
| 101 | + | {Id: "t2", Label: "claude", Scopes: []ermite.Scope{ermite.Scope_SCOPE_REPO_WRITE}, BranchPrefixes: []string{"agent/claude/", "bot/"}, CreatedAt: ts("2026-10-01T00:00:00Z")}, | |
| 102 | + | {Id: "t1", Label: "ci", Scopes: []ermite.Scope{ermite.Scope_SCOPE_REPO_READ}, CreatedAt: ts("2026-09-01T00:00:00Z")}, | |
| 103 | + | }}, nil | |
| 104 | + | }} | |
| 105 | + | url := f.serve(t) | |
| 106 | + | res := runCLI(t, envFor(url), "", "token", "list").ok(t) | |
| 107 | + | assert.Equal(t, ""+ | |
| 108 | + | "ID LABEL SCOPES REPOS BRANCHES EXPIRES LAST USED\n"+ | |
| 109 | + | "t2 claude repo:write all agent/claude/,bot/ never never\n"+ | |
| 110 | + | "t1 ci repo:read all all never never\n", res.stdout) | |
| 111 | + | ||
| 112 | + | res = runCLI(t, envFor(url), "", "token", "list", "--json").ok(t) | |
| 113 | + | assert.JSONEq(t, `[ | |
| 114 | + | {"id":"t2","label":"claude","scopes":["repo:write"],"repos":[],"branch_prefixes":["agent/claude/","bot/"],"expires_at":null,"created_at":"2026-10-01T00:00:00Z","last_used_at":null}, | |
| 115 | + | {"id":"t1","label":"ci","scopes":["repo:read"],"repos":[],"branch_prefixes":[],"expires_at":null,"created_at":"2026-09-01T00:00:00Z","last_used_at":null} | |
| 116 | + | ]`, res.stdout) | |
| 117 | + | } | |
| 118 | + | ||
| 45 | 119 | func TestTokenCreatePresetExpansionOnTheWire(t *testing.T) { | |
| 46 | 120 | for preset, want := range map[string][]ermite.Scope{ | |
| 47 | 121 | "agent": {ermite.Scope_SCOPE_REPO_WRITE, ermite.Scope_SCOPE_MR_WRITE}, | |
| @@ -70,7 +144,7 @@ func TestTokenCreateJSON(t *testing.T) { | |||
| 70 | 144 | }, nil | |
| 71 | 145 | }} | |
| 72 | 146 | res := runCLI(t, envFor(f.serve(t)), "", "token", "create", "l", "--scope", "repo:read", "--json").ok(t) | |
| 73 | - | assert.JSONEq(t, `{"id":"tok1","label":"l","scopes":["repo:read"],"repos":[],"expires_at":null,"created_at":"2026-10-10T12:00:00Z","last_used_at":null,"secret":"ermite_S"}`, res.stdout) | |
| 147 | + | assert.JSONEq(t, `{"id":"tok1","label":"l","scopes":["repo:read"],"repos":[],"branch_prefixes":[],"expires_at":null,"created_at":"2026-10-10T12:00:00Z","last_used_at":null,"secret":"ermite_S"}`, res.stdout) | |
| 74 | 148 | assert.Empty(t, res.stderr) | |
| 75 | 149 | } | |
| 76 | 150 | ||
| @@ -106,14 +180,14 @@ func TestTokenList(t *testing.T) { | |||
| 106 | 180 | url := f.serve(t) | |
| 107 | 181 | res := runCLI(t, envFor(url), "", "token", "list").ok(t) | |
| 108 | 182 | assert.Equal(t, ""+ | |
| 109 | - | "ID LABEL SCOPES REPOS EXPIRES LAST USED\n"+ | |
| 110 | - | "t2 laptop repo:read,repo:write alpha,beta 2027-01-01 00:00 2026-10-09 08:30\n"+ | |
| 111 | - | "t1 ci repo:read all never never\n", res.stdout) | |
| 183 | + | "ID LABEL SCOPES REPOS BRANCHES EXPIRES LAST USED\n"+ | |
| 184 | + | "t2 laptop repo:read,repo:write alpha,beta all 2027-01-01 00:00 2026-10-09 08:30\n"+ | |
| 185 | + | "t1 ci repo:read all all never never\n", res.stdout) | |
| 112 | 186 | ||
| 113 | 187 | res = runCLI(t, envFor(url), "", "token", "list", "--json").ok(t) | |
| 114 | 188 | assert.JSONEq(t, `[ | |
| 115 | - | {"id":"t2","label":"laptop","scopes":["repo:read","repo:write"],"repos":["alpha","beta"],"expires_at":"2027-01-01T00:00:00Z","created_at":"2026-10-01T00:00:00Z","last_used_at":"2026-10-09T08:30:00Z"}, | |
| 116 | - | {"id":"t1","label":"ci","scopes":["repo:read"],"repos":[],"expires_at":null,"created_at":"2026-09-01T00:00:00Z","last_used_at":null} | |
| 189 | + | {"id":"t2","label":"laptop","scopes":["repo:read","repo:write"],"repos":["alpha","beta"],"branch_prefixes":[],"expires_at":"2027-01-01T00:00:00Z","created_at":"2026-10-01T00:00:00Z","last_used_at":"2026-10-09T08:30:00Z"}, | |
| 190 | + | {"id":"t1","label":"ci","scopes":["repo:read"],"repos":[],"branch_prefixes":[],"expires_at":null,"created_at":"2026-09-01T00:00:00Z","last_used_at":null} | |
| 117 | 191 | ]`, res.stdout) | |
| 118 | 192 | assert.NotContains(t, res.stdout, "secret") | |
| 119 | 193 | } | |
| @@ -42,6 +42,80 @@ func TestTokenCreateMapsFlags(t *testing.T) { | |||||
| 42 | assert.NotContains(t, res.stderr, "SECRETVALUE") | 42 | assert.NotContains(t, res.stderr, "SECRETVALUE") | ||
| 43 | } | 43 | } | ||
| 44 | 44 | ||||
| 45 | + | func TestTokenCreateBranchPrefixes(t *testing.T) { | |||
| 46 | + | echo := func(r *ermite.CreateTokenRequest) (*ermite.CreateTokenResponse, error) { | |||
| 47 | + | return &ermite.CreateTokenResponse{ | |||
| 48 | + | Token: &ermite.Token{ | |||
| 49 | + | Id: "tok1", Label: r.GetLabel(), Scopes: r.GetScopes(), BranchPrefixes: r.GetBranchPrefixes(), | |||
| 50 | + | CreatedAt: ts("2026-10-10T12:00:00Z"), | |||
| 51 | + | }, | |||
| 52 | + | Secret: "ermite_S", | |||
| 53 | + | }, nil | |||
| 54 | + | } | |||
| 55 | + | ||||
| 56 | + | t.Run("maps the repeatable flag, trims and drops duplicates", func(t *testing.T) { | |||
| 57 | + | f := &fakeAPI{createToken: echo} | |||
| 58 | + | res := runCLI(t, envFor(f.serve(t)), "", "token", "create", "claude", "--preset", "agent", "--repo", "alpha", | |||
| 59 | + | "--branch-prefix", "agent/claude/", "--branch-prefix", " bot/ ", "--branch-prefix", "agent/claude/").ok(t) | |||
| 60 | + | _, reqs := f.snapshot() | |||
| 61 | + | assert.Equal(t, []string{"agent/claude/", "bot/"}, reqs[0].(*ermite.CreateTokenRequest).GetBranchPrefixes()) | |||
| 62 | + | assert.Equal(t, "ermite_S\n", res.stdout) | |||
| 63 | + | assert.Contains(t, res.stderr, "branches") | |||
| 64 | + | assert.Contains(t, res.stderr, "agent/claude/, bot/") | |||
| 65 | + | }) | |||
| 66 | + | ||||
| 67 | + | t.Run("without the flag the token shows every branch", func(t *testing.T) { | |||
| 68 | + | f := &fakeAPI{createToken: echo} | |||
| 69 | + | res := runCLI(t, envFor(f.serve(t)), "", "token", "create", "x", "--preset", "ci").ok(t) | |||
| 70 | + | _, reqs := f.snapshot() | |||
| 71 | + | assert.Empty(t, reqs[0].(*ermite.CreateTokenRequest).GetBranchPrefixes()) | |||
| 72 | + | assert.Regexp(t, `branches\s+all`, res.stderr) | |||
| 73 | + | }) | |||
| 74 | + | ||||
| 75 | + | t.Run("json", func(t *testing.T) { | |||
| 76 | + | f := &fakeAPI{createToken: echo} | |||
| 77 | + | res := runCLI(t, envFor(f.serve(t)), "", "token", "create", "l", "--scope", "repo:write", "--branch-prefix", "a/", "--json").ok(t) | |||
| 78 | + | assert.JSONEq(t, `{"id":"tok1","label":"l","scopes":["repo:write"],"repos":[],"branch_prefixes":["a/"],"expires_at":null,"created_at":"2026-10-10T12:00:00Z","last_used_at":null,"secret":"ermite_S"}`, res.stdout) | |||
| 79 | + | }) | |||
| 80 | + | ||||
| 81 | + | t.Run("a server that ignores the field does not hand out an unlimited token", func(t *testing.T) { | |||
| 82 | + | f := &fakeAPI{createToken: func(r *ermite.CreateTokenRequest) (*ermite.CreateTokenResponse, error) { | |||
| 83 | + | return &ermite.CreateTokenResponse{ | |||
| 84 | + | Token: &ermite.Token{Id: "tok9", Label: r.GetLabel(), Scopes: r.GetScopes(), CreatedAt: ts("2026-10-10T12:00:00Z")}, | |||
| 85 | + | Secret: "ermite_UNLIMITED", | |||
| 86 | + | }, nil | |||
| 87 | + | }} | |||
| 88 | + | res := runCLI(t, envFor(f.serve(t)), "", "token", "create", "x", "--preset", "agent", "--branch-prefix", "agent/x/") | |||
| 89 | + | require.Error(t, res.err) | |||
| 90 | + | assert.Contains(t, res.err.Error(), "without the requested branch prefixes") | |||
| 91 | + | assert.Contains(t, res.err.Error(), "ermite token revoke tok9") | |||
| 92 | + | assert.NotContains(t, res.stdout, "UNLIMITED") | |||
| 93 | + | assert.NotContains(t, res.stderr, "UNLIMITED") | |||
| 94 | + | assert.NotContains(t, res.err.Error(), "UNLIMITED") | |||
| 95 | + | }) | |||
| 96 | + | } | |||
| 97 | + | ||||
| 98 | + | func TestTokenListBranchPrefixes(t *testing.T) { | |||
| 99 | + | f := &fakeAPI{listTokens: func(*ermite.ListTokensRequest) (*ermite.ListTokensResponse, error) { | |||
| 100 | + | return &ermite.ListTokensResponse{Tokens: []*ermite.Token{ | |||
| 101 | + | {Id: "t2", Label: "claude", Scopes: []ermite.Scope{ermite.Scope_SCOPE_REPO_WRITE}, BranchPrefixes: []string{"agent/claude/", "bot/"}, CreatedAt: ts("2026-10-01T00:00:00Z")}, | |||
| 102 | + | {Id: "t1", Label: "ci", Scopes: []ermite.Scope{ermite.Scope_SCOPE_REPO_READ}, CreatedAt: ts("2026-09-01T00:00:00Z")}, | |||
| 103 | + | }}, nil | |||
| 104 | + | }} | |||
| 105 | + | url := f.serve(t) | |||
| 106 | + | res := runCLI(t, envFor(url), "", "token", "list").ok(t) | |||
| 107 | + | assert.Equal(t, ""+ | |||
| 108 | + | "ID LABEL SCOPES REPOS BRANCHES EXPIRES LAST USED\n"+ | |||
| 109 | + | "t2 claude repo:write all agent/claude/,bot/ never never\n"+ | |||
| 110 | + | "t1 ci repo:read all all never never\n", res.stdout) | |||
| 111 | + | ||||
| 112 | + | res = runCLI(t, envFor(url), "", "token", "list", "--json").ok(t) | |||
| 113 | + | assert.JSONEq(t, `[ | |||
| 114 | + | {"id":"t2","label":"claude","scopes":["repo:write"],"repos":[],"branch_prefixes":["agent/claude/","bot/"],"expires_at":null,"created_at":"2026-10-01T00:00:00Z","last_used_at":null}, | |||
| 115 | + | {"id":"t1","label":"ci","scopes":["repo:read"],"repos":[],"branch_prefixes":[],"expires_at":null,"created_at":"2026-09-01T00:00:00Z","last_used_at":null} | |||
| 116 | + | ]`, res.stdout) | |||
| 117 | + | } | |||
| 118 | + | ||||
| 45 | func TestTokenCreatePresetExpansionOnTheWire(t *testing.T) { | 119 | func TestTokenCreatePresetExpansionOnTheWire(t *testing.T) { | ||
| 46 | for preset, want := range map[string][]ermite.Scope{ | 120 | for preset, want := range map[string][]ermite.Scope{ | ||
| 47 | "agent": {ermite.Scope_SCOPE_REPO_WRITE, ermite.Scope_SCOPE_MR_WRITE}, | 121 | "agent": {ermite.Scope_SCOPE_REPO_WRITE, ermite.Scope_SCOPE_MR_WRITE}, | ||
| @@ -70,7 +144,7 @@ func TestTokenCreateJSON(t *testing.T) { | |||||
| 70 | }, nil | 144 | }, nil | ||
| 71 | }} | 145 | }} | ||
| 72 | res := runCLI(t, envFor(f.serve(t)), "", "token", "create", "l", "--scope", "repo:read", "--json").ok(t) | 146 | res := runCLI(t, envFor(f.serve(t)), "", "token", "create", "l", "--scope", "repo:read", "--json").ok(t) | ||
| 73 | - | assert.JSONEq(t, `{"id":"tok1","label":"l","scopes":["repo:read"],"repos":[],"expires_at":null,"created_at":"2026-10-10T12:00:00Z","last_used_at":null,"secret":"ermite_S"}`, res.stdout) | 147 | + | assert.JSONEq(t, `{"id":"tok1","label":"l","scopes":["repo:read"],"repos":[],"branch_prefixes":[],"expires_at":null,"created_at":"2026-10-10T12:00:00Z","last_used_at":null,"secret":"ermite_S"}`, res.stdout) |
| 74 | assert.Empty(t, res.stderr) | 148 | assert.Empty(t, res.stderr) | ||
| 75 | } | 149 | } | ||
| 76 | 150 | ||||
| @@ -106,14 +180,14 @@ func TestTokenList(t *testing.T) { | |||||
| 106 | url := f.serve(t) | 180 | url := f.serve(t) | ||
| 107 | res := runCLI(t, envFor(url), "", "token", "list").ok(t) | 181 | res := runCLI(t, envFor(url), "", "token", "list").ok(t) | ||
| 108 | assert.Equal(t, ""+ | 182 | assert.Equal(t, ""+ | ||
| 109 | - | "ID LABEL SCOPES REPOS EXPIRES LAST USED\n"+ | 183 | + | "ID LABEL SCOPES REPOS BRANCHES EXPIRES LAST USED\n"+ |
| 110 | - | "t2 laptop repo:read,repo:write alpha,beta 2027-01-01 00:00 2026-10-09 08:30\n"+ | 184 | + | "t2 laptop repo:read,repo:write alpha,beta all 2027-01-01 00:00 2026-10-09 08:30\n"+ |
| 111 | - | "t1 ci repo:read all never never\n", res.stdout) | 185 | + | "t1 ci repo:read all all never never\n", res.stdout) |
| 112 | 186 | ||||
| 113 | res = runCLI(t, envFor(url), "", "token", "list", "--json").ok(t) | 187 | res = runCLI(t, envFor(url), "", "token", "list", "--json").ok(t) | ||
| 114 | assert.JSONEq(t, `[ | 188 | assert.JSONEq(t, `[ | ||
| 115 | - | {"id":"t2","label":"laptop","scopes":["repo:read","repo:write"],"repos":["alpha","beta"],"expires_at":"2027-01-01T00:00:00Z","created_at":"2026-10-01T00:00:00Z","last_used_at":"2026-10-09T08:30:00Z"}, | 189 | + | {"id":"t2","label":"laptop","scopes":["repo:read","repo:write"],"repos":["alpha","beta"],"branch_prefixes":[],"expires_at":"2027-01-01T00:00:00Z","created_at":"2026-10-01T00:00:00Z","last_used_at":"2026-10-09T08:30:00Z"}, |
| 116 | - | {"id":"t1","label":"ci","scopes":["repo:read"],"repos":[],"expires_at":null,"created_at":"2026-09-01T00:00:00Z","last_used_at":null} | 190 | + | {"id":"t1","label":"ci","scopes":["repo:read"],"repos":[],"branch_prefixes":[],"expires_at":null,"created_at":"2026-09-01T00:00:00Z","last_used_at":null} |
| 117 | ]`, res.stdout) | 191 | ]`, res.stdout) | ||
| 118 | assert.NotContains(t, res.stdout, "secret") | 192 | assert.NotContains(t, res.stdout, "secret") | ||
| 119 | } | 193 | } | ||
M internal/app/cli_version.go +7 -6
| @@ -100,10 +100,11 @@ func newVersionCmd(env *cliEnv) *cobra.Command { | |||
| 100 | 100 | ||
| 101 | 101 | // featureUse says what breaks without a feature, for the version warning. | |
| 102 | 102 | var featureUse = map[string]string{ | |
| 103 | - | client.FeatureRepoUpdate: "`ermite repo edit` needs: the command will fail on this server", | |
| 104 | - | client.FeatureEventsResume: "`ermite watch` uses to resume after a reconnection: events during an outage may be missed", | |
| 105 | - | client.FeatureMergeRequests: "`ermite mr` needs: the commands will fail on this server", | |
| 106 | - | client.FeatureMergeDeleteBranch: "`ermite mr merge --delete-branch` needs: the merge works but the branch is kept", | |
| 107 | - | client.FeatureSigningKey: "`ermite signing-key show` needs: the command will fail on this server", | |
| 108 | - | client.FeatureSession: "`ermite web login` needs: the command will fail on this server", | |
| 103 | + | client.FeatureRepoUpdate: "`ermite repo edit` needs: the command will fail on this server", | |
| 104 | + | client.FeatureEventsResume: "`ermite watch` uses to resume after a reconnection: events during an outage may be missed", | |
| 105 | + | client.FeatureMergeRequests: "`ermite mr` needs: the commands will fail on this server", | |
| 106 | + | client.FeatureMergeDeleteBranch: "`ermite mr merge --delete-branch` needs: the merge works but the branch is kept", | |
| 107 | + | client.FeatureSigningKey: "`ermite signing-key show` needs: the command will fail on this server", | |
| 108 | + | client.FeatureSession: "`ermite web login` needs: the command will fail on this server", | |
| 109 | + | client.FeatureTokenBranchPrefixes: "`ermite token create --branch-prefix` needs: the command is refused, and `token list` does not show the limits", | |
| 109 | 110 | } | |
| @@ -100,10 +100,11 @@ func newVersionCmd(env *cliEnv) *cobra.Command { | |||||
| 100 | 100 | ||||
| 101 | // featureUse says what breaks without a feature, for the version warning. | 101 | // featureUse says what breaks without a feature, for the version warning. | ||
| 102 | var featureUse = map[string]string{ | 102 | var featureUse = map[string]string{ | ||
| 103 | - | client.FeatureRepoUpdate: "`ermite repo edit` needs: the command will fail on this server", | 103 | + | client.FeatureRepoUpdate: "`ermite repo edit` needs: the command will fail on this server", |
| 104 | - | client.FeatureEventsResume: "`ermite watch` uses to resume after a reconnection: events during an outage may be missed", | 104 | + | client.FeatureEventsResume: "`ermite watch` uses to resume after a reconnection: events during an outage may be missed", |
| 105 | - | client.FeatureMergeRequests: "`ermite mr` needs: the commands will fail on this server", | 105 | + | client.FeatureMergeRequests: "`ermite mr` needs: the commands will fail on this server", |
| 106 | - | client.FeatureMergeDeleteBranch: "`ermite mr merge --delete-branch` needs: the merge works but the branch is kept", | 106 | + | client.FeatureMergeDeleteBranch: "`ermite mr merge --delete-branch` needs: the merge works but the branch is kept", |
| 107 | - | client.FeatureSigningKey: "`ermite signing-key show` needs: the command will fail on this server", | 107 | + | client.FeatureSigningKey: "`ermite signing-key show` needs: the command will fail on this server", |
| 108 | - | client.FeatureSession: "`ermite web login` needs: the command will fail on this server", | 108 | + | client.FeatureSession: "`ermite web login` needs: the command will fail on this server", |
| 109 | + | client.FeatureTokenBranchPrefixes: "`ermite token create --branch-prefix` needs: the command is refused, and `token list` does not show the limits", | |||
| 109 | } | 110 | } | ||
M internal/app/cli_version_test.go +5 -5
| @@ -14,7 +14,7 @@ import ( | |||
| 14 | 14 | ) | |
| 15 | 15 | ||
| 16 | 16 | // allFeatures are the features this client uses. | |
| 17 | - | var allFeatures = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session"} | |
| 17 | + | var allFeatures = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "token.branch_prefixes"} | |
| 18 | 18 | ||
| 19 | 19 | func versionFake(features ...string) *fakeAPI { | |
| 20 | 20 | return &fakeAPI{getVersion: func(*ermite.GetVersionRequest) (*ermite.GetVersionResponse, error) { | |
| @@ -35,15 +35,15 @@ func TestVersionWithServer(t *testing.T) { | |||
| 35 | 35 | f := versionFake(allFeatures...) | |
| 36 | 36 | url := f.serve(t) | |
| 37 | 37 | res := runCLI(t, envFor(url), "", "version").ok(t) | |
| 38 | - | assert.Equal(t, "client ermite v0.0.1\nserver v0.4.0 at "+url+"\nfeatures repo.update, events.resume, merge_requests, merge_requests.delete_branch, signing_key, session\n", res.stdout) | |
| 38 | + | assert.Equal(t, "client ermite v0.0.1\nserver v0.4.0 at "+url+"\nfeatures repo.update, events.resume, merge_requests, merge_requests.delete_branch, signing_key, session, token.branch_prefixes\n", res.stdout) | |
| 39 | 39 | assert.Empty(t, res.stderr, "no warning when every feature is there") | |
| 40 | 40 | ||
| 41 | 41 | res = runCLI(t, envFor(url), "", "version", "--json").ok(t) | |
| 42 | - | assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","events.resume","merge_requests","merge_requests.delete_branch","signing_key","session"],"missing_features":[]}}`, res.stdout) | |
| 42 | + | assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","events.resume","merge_requests","merge_requests.delete_branch","signing_key","session","token.branch_prefixes"],"missing_features":[]}}`, res.stdout) | |
| 43 | 43 | } | |
| 44 | 44 | ||
| 45 | 45 | func TestVersionWarnsAboutMissingFeatures(t *testing.T) { | |
| 46 | - | f := versionFake("repo.update", "merge_requests", "merge_requests.delete_branch", "signing_key", "session") | |
| 46 | + | f := versionFake("repo.update", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "token.branch_prefixes") | |
| 47 | 47 | url := f.serve(t) | |
| 48 | 48 | res := runCLI(t, envFor(url), "", "version").ok(t) | |
| 49 | 49 | assert.Contains(t, res.stdout, "v0.4.0") | |
| @@ -51,7 +51,7 @@ func TestVersionWarnsAboutMissingFeatures(t *testing.T) { | |||
| 51 | 51 | assert.NotContains(t, res.stderr, "repo.update") | |
| 52 | 52 | ||
| 53 | 53 | res = runCLI(t, envFor(url), "", "version", "--json").ok(t) | |
| 54 | - | assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","merge_requests","merge_requests.delete_branch","signing_key","session"],"missing_features":["events.resume"]}}`, res.stdout) | |
| 54 | + | assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","merge_requests","merge_requests.delete_branch","signing_key","session","token.branch_prefixes"],"missing_features":["events.resume"]}}`, res.stdout) | |
| 55 | 55 | assert.Contains(t, res.stderr, "events.resume", "the warning also goes to stderr with --json") | |
| 56 | 56 | ||
| 57 | 57 | f = versionFake() | |
| @@ -14,7 +14,7 @@ import ( | |||||
| 14 | ) | 14 | ) | ||
| 15 | 15 | ||||
| 16 | // allFeatures are the features this client uses. | 16 | // allFeatures are the features this client uses. | ||
| 17 | - | var allFeatures = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session"} | 17 | + | var allFeatures = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "token.branch_prefixes"} |
| 18 | 18 | ||||
| 19 | func versionFake(features ...string) *fakeAPI { | 19 | func versionFake(features ...string) *fakeAPI { | ||
| 20 | return &fakeAPI{getVersion: func(*ermite.GetVersionRequest) (*ermite.GetVersionResponse, error) { | 20 | return &fakeAPI{getVersion: func(*ermite.GetVersionRequest) (*ermite.GetVersionResponse, error) { | ||
| @@ -35,15 +35,15 @@ func TestVersionWithServer(t *testing.T) { | |||||
| 35 | f := versionFake(allFeatures...) | 35 | f := versionFake(allFeatures...) | ||
| 36 | url := f.serve(t) | 36 | url := f.serve(t) | ||
| 37 | res := runCLI(t, envFor(url), "", "version").ok(t) | 37 | res := runCLI(t, envFor(url), "", "version").ok(t) | ||
| 38 | - | assert.Equal(t, "client ermite v0.0.1\nserver v0.4.0 at "+url+"\nfeatures repo.update, events.resume, merge_requests, merge_requests.delete_branch, signing_key, session\n", res.stdout) | 38 | + | assert.Equal(t, "client ermite v0.0.1\nserver v0.4.0 at "+url+"\nfeatures repo.update, events.resume, merge_requests, merge_requests.delete_branch, signing_key, session, token.branch_prefixes\n", res.stdout) |
| 39 | assert.Empty(t, res.stderr, "no warning when every feature is there") | 39 | assert.Empty(t, res.stderr, "no warning when every feature is there") | ||
| 40 | 40 | ||||
| 41 | res = runCLI(t, envFor(url), "", "version", "--json").ok(t) | 41 | res = runCLI(t, envFor(url), "", "version", "--json").ok(t) | ||
| 42 | - | assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","events.resume","merge_requests","merge_requests.delete_branch","signing_key","session"],"missing_features":[]}}`, res.stdout) | 42 | + | assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","events.resume","merge_requests","merge_requests.delete_branch","signing_key","session","token.branch_prefixes"],"missing_features":[]}}`, res.stdout) |
| 43 | } | 43 | } | ||
| 44 | 44 | ||||
| 45 | func TestVersionWarnsAboutMissingFeatures(t *testing.T) { | 45 | func TestVersionWarnsAboutMissingFeatures(t *testing.T) { | ||
| 46 | - | f := versionFake("repo.update", "merge_requests", "merge_requests.delete_branch", "signing_key", "session") | 46 | + | f := versionFake("repo.update", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "token.branch_prefixes") |
| 47 | url := f.serve(t) | 47 | url := f.serve(t) | ||
| 48 | res := runCLI(t, envFor(url), "", "version").ok(t) | 48 | res := runCLI(t, envFor(url), "", "version").ok(t) | ||
| 49 | assert.Contains(t, res.stdout, "v0.4.0") | 49 | assert.Contains(t, res.stdout, "v0.4.0") | ||
| @@ -51,7 +51,7 @@ func TestVersionWarnsAboutMissingFeatures(t *testing.T) { | |||||
| 51 | assert.NotContains(t, res.stderr, "repo.update") | 51 | assert.NotContains(t, res.stderr, "repo.update") | ||
| 52 | 52 | ||||
| 53 | res = runCLI(t, envFor(url), "", "version", "--json").ok(t) | 53 | res = runCLI(t, envFor(url), "", "version", "--json").ok(t) | ||
| 54 | - | assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","merge_requests","merge_requests.delete_branch","signing_key","session"],"missing_features":["events.resume"]}}`, res.stdout) | 54 | + | assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","merge_requests","merge_requests.delete_branch","signing_key","session","token.branch_prefixes"],"missing_features":["events.resume"]}}`, res.stdout) |
| 55 | assert.Contains(t, res.stderr, "events.resume", "the warning also goes to stderr with --json") | 55 | assert.Contains(t, res.stderr, "events.resume", "the warning also goes to stderr with --json") | ||
| 56 | 56 | ||||
| 57 | f = versionFake() | 57 | f = versionFake() | ||