Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 19 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,26 +63,36 @@ chmod +x /usr/local/bin/src

See [Sourcegraph CLI for Windows](WINDOWS.md).

## Setup with your Sourcegraph instance
## Log into your Sourcegraph instance

### Via environment variables
Run <code><strong>src login <i>SOURCEGRAPH-URL</i></strong></code> to authenticate `src` to access your Sourcegraph instance with your user credentials.

Point `src` to your instance and access token using environment variables:
<blockquote>

```sh
SRC_ENDPOINT=https://sourcegraph.example.com SRC_ACCESS_TOKEN="secret" src search 'foobar'
```
**Examples**
`src login https://sourcegraph.example.com`
`src login https://sourcegraph.com`

</blockquote>

`src` consults the following environment variables:

Sourcegraph behind a custom auth proxy? See [auth proxy configuration](./AUTH_PROXY.md) docs.
- `SRC_ENDPOINT`: the URL to your Sourcegraph instance (such as `https://sourcegraph.example.com`)
- `SRC_ACCESS_TOKEN`: your Sourcegraph access token (on your Sourcegraph instance, click your user menu in the top right, then select **Settings > Access tokens** to create one)

### Where to get an access token
For convenience, you can export these environment variables in your shell profile. You can also inline them in a single command with:

```sh
SRC_ENDPOINT=https://sourcegraph.example.com SRC_ACCESS_TOKEN=my-token src search 'foo'
```

Visit your Sourcegraph instance (or https://sourcegraph.com), click your username in the top right to open the user menu, select **Settings**, and then select **Access tokens** in the left hand menu.
Is your Sourcegraph instance behind a custom auth proxy? See [auth proxy configuration](./AUTH_PROXY.md) docs.

## Usage

`src` provides different subcommands to interact with different parts of Sourcegraph:

- `src login` - authenticate to a Sourcegraph instance with your user credentials
- `src search` - perform searches and get results in your terminal or as JSON
- `src actions` - run [campaign actions](https://docs.sourcegraph.com/user/campaigns/actions) to generate patch sets
- `src api` - run Sourcegraph GraphQL API requests
Expand Down
118 changes: 118 additions & 0 deletions cmd/src/login.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
package main

import (
"context"
"errors"
"flag"
"fmt"
"io"
"io/ioutil"
"os"
"strings"
)

func init() {
usage := `'src login' helps you authenticate 'src' to access a Sourcegraph instance with your user credentials.

Usage:

src login SOURCEGRAPH_URL

Examples:

Authenticate to a Sourcegraph instance at https://sourcegraph.example.com:

$ src login https://sourcegraph.example.com

Authenticate to Sourcegraph.com:

$ src login https://sourcegraph.com
`

flagSet := flag.NewFlagSet("login", flag.ExitOnError)
usageFunc := func() {
fmt.Fprintln(flag.CommandLine.Output(), usage)
}

handler := func(args []string) error {
if err := flagSet.Parse(args); err != nil {
return err
}
if len(args) != 1 {
return &usageError{errors.New("expected exactly one argument: the Sourcegraph URL")}
}
endpointArg := args[0]
return loginCmd(context.Background(), cfg, endpointArg, os.Stdout)
}

commands = append(commands, &command{
flagSet: flagSet,
handler: handler,
usageFunc: usageFunc,
})
}

var exitCode1 = &exitCodeError{exitCode: 1}

func loginCmd(ctx context.Context, cfg *config, endpointArg string, out io.Writer) error {
endpointArg = cleanEndpoint(endpointArg)

printProblem := func(problem string) {
fmt.Fprintf(out, "❌ Problem: %s\n", problem)
}

createAccessTokenMessage := fmt.Sprintf("\n"+`🛠 To fix: Create an access token at %s/user/settings/tokens, then set the following environment variables:

SRC_ENDPOINT=%s
SRC_ACCESS_TOKEN=(the access token you just created)

To verify that it's working, run this command again.
`, endpointArg, endpointArg)

if cfg.ConfigFilePath != "" {
fmt.Fprintln(out)
fmt.Fprintf(out, "⚠️ Warning: Configuring src with a JSON file is deprecated. Please migrate to using the env vars SRC_ENDPOINT and SRC_ACCESS_TOKEN instead, and then remove %s. See https://github.com/sourcegraph/src-cli#readme for more information.\n", cfg.ConfigFilePath)
}

noToken := cfg.AccessToken == ""
endpointConflict := endpointArg != cfg.Endpoint
if noToken || endpointConflict {
fmt.Fprintln(out)
switch {
case noToken:
printProblem("No access token is configured.")
case endpointConflict:
printProblem(fmt.Sprintf("The configured endpoint is %s, not %s.", cfg.Endpoint, endpointArg))
}
fmt.Fprintln(out, createAccessTokenMessage)
return exitCode1
}

// See if the user is already authenticated.
query := `query CurrentUser { currentUser { username } }`
var result struct {
CurrentUser *struct{ Username string }
}
client := cfg.apiClient(nil, ioutil.Discard)
if _, err := client.NewRequest(query, nil).Do(ctx, &result); err != nil {
if strings.HasPrefix(err.Error(), "error: 401 Unauthorized") || strings.HasPrefix(err.Error(), "error: 403 Forbidden") {
printProblem("Invalid access token.")
} else {
printProblem(fmt.Sprintf("Error communicating with %s: %s", endpointArg, err))
}
fmt.Fprintln(out, createAccessTokenMessage)
fmt.Fprintln(out, " (If you need to supply custom HTTP request headers, see information about SRC_HEADER_* env vars at https://github.com/sourcegraph/src-cli/blob/main/AUTH_PROXY.md.)")
return exitCode1
}

if result.CurrentUser == nil {
// This should never happen; we verified there is an access token, so there should always be
// a user.
printProblem(fmt.Sprintf("Unable to determine user on %s.", endpointArg))
return exitCode1
}
fmt.Fprintln(out)
fmt.Fprintf(out, "✔️ Authenticated as %s on %s\n", result.CurrentUser.Username, endpointArg)
fmt.Fprintln(out)
return nil
}
92 changes: 92 additions & 0 deletions cmd/src/login_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
package main

import (
"bytes"
"context"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
)

func TestLogin(t *testing.T) {
check := func(t *testing.T, cfg *config, endpointArg string) (output string, err error) {
t.Helper()

var out bytes.Buffer
err = loginCmd(context.Background(), cfg, endpointArg, &out)
return strings.TrimSpace(out.String()), err
}

t.Run("different endpoint in config vs. arg", func(t *testing.T) {
out, err := check(t, &config{Endpoint: "https://example.com"}, "https://sourcegraph.example.com")
if err != exitCode1 {
t.Fatal(err)
}
wantOut := "❌ Problem: No access token is configured.\n\n🛠 To fix: Create an access token at https://sourcegraph.example.com/user/settings/tokens, then set the following environment variables:\n\n SRC_ENDPOINT=https://sourcegraph.example.com\n SRC_ACCESS_TOKEN=(the access token you just created)\n\n To verify that it's working, run this command again."
if out != wantOut {
t.Errorf("got output %q, want %q", out, wantOut)
}
})

t.Run("no access token", func(t *testing.T) {
out, err := check(t, &config{Endpoint: "https://example.com"}, "https://sourcegraph.example.com")
if err != exitCode1 {
t.Fatal(err)
}
wantOut := "❌ Problem: No access token is configured.\n\n🛠 To fix: Create an access token at https://sourcegraph.example.com/user/settings/tokens, then set the following environment variables:\n\n SRC_ENDPOINT=https://sourcegraph.example.com\n SRC_ACCESS_TOKEN=(the access token you just created)\n\n To verify that it's working, run this command again."
if out != wantOut {
t.Errorf("got output %q, want %q", out, wantOut)
}
})

t.Run("warning when using config file", func(t *testing.T) {
out, err := check(t, &config{Endpoint: "https://example.com", ConfigFilePath: "f"}, "https://example.com")
if err != exitCode1 {
t.Fatal(err)
}
wantOut := "⚠️ Warning: Configuring src with a JSON file is deprecated. Please migrate to using the env vars SRC_ENDPOINT and SRC_ACCESS_TOKEN instead, and then remove f. See https://github.com/sourcegraph/src-cli#readme for more information.\n\n❌ Problem: No access token is configured.\n\n🛠 To fix: Create an access token at https://example.com/user/settings/tokens, then set the following environment variables:\n\n SRC_ENDPOINT=https://example.com\n SRC_ACCESS_TOKEN=(the access token you just created)\n\n To verify that it's working, run this command again."
if out != wantOut {
t.Errorf("got output %q, want %q", out, wantOut)
}
})

t.Run("invalid access token", func(t *testing.T) {
// Dummy HTTP server to return HTTP 401/403.
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "", http.StatusUnauthorized)
}))
defer s.Close()

endpoint := s.URL
out, err := check(t, &config{Endpoint: endpoint, AccessToken: "x"}, endpoint)
if err != exitCode1 {
t.Fatal(err)
}
wantOut := "❌ Problem: Invalid access token.\n\n🛠 To fix: Create an access token at $ENDPOINT/user/settings/tokens, then set the following environment variables:\n\n SRC_ENDPOINT=$ENDPOINT\n SRC_ACCESS_TOKEN=(the access token you just created)\n\n To verify that it's working, run this command again.\n\n (If you need to supply custom HTTP request headers, see information about SRC_HEADER_* env vars at https://github.com/sourcegraph/src-cli/blob/main/AUTH_PROXY.md.)"
wantOut = strings.Replace(wantOut, "$ENDPOINT", endpoint, -1)
if out != wantOut {
t.Errorf("got output %q, want %q", out, wantOut)
}
})

t.Run("valid", func(t *testing.T) {
// Dummy HTTP server to return JSON response with currentUser.
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, `{"data":{"currentUser":{"username":"alice"}}}`)
}))
defer s.Close()

endpoint := s.URL
out, err := check(t, &config{Endpoint: endpoint, AccessToken: "x"}, endpoint)
if err != nil {
t.Fatal(err)
}
wantOut := "✔️ Authenticated as alice on $ENDPOINT"
wantOut = strings.Replace(wantOut, "$ENDPOINT", endpoint, -1)
if out != wantOut {
t.Errorf("got output %q, want %q", out, wantOut)
}
})
}
28 changes: 22 additions & 6 deletions cmd/src/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,8 @@ type config struct {
Endpoint string `json:"endpoint"`
AccessToken string `json:"accessToken"`
AdditionalHeaders map[string]string `json:"additionalHeaders"`

ConfigFilePath string
}

// apiClient returns an api.Client built from the configuration.
Expand All @@ -88,26 +90,36 @@ func (c *config) apiClient(flags *api.Flags, out io.Writer) api.Client {
})
}

var testHomeDir string // used by tests to mock the user's $HOME

// readConfig reads the config file from the given path.
func readConfig() (*config, error) {
cfgPath := *configPath
userSpecified := *configPath != ""

u, err := user.Current()
if err != nil {
return nil, err
var homeDir string
if testHomeDir != "" {
homeDir = testHomeDir
} else {
u, err := user.Current()
if err != nil {
return nil, err
}
homeDir = u.HomeDir
}

if !userSpecified {
cfgPath = filepath.Join(u.HomeDir, "src-config.json")
cfgPath = filepath.Join(homeDir, "src-config.json")
} else if strings.HasPrefix(cfgPath, "~/") {
cfgPath = filepath.Join(u.HomeDir, cfgPath[2:])
cfgPath = filepath.Join(homeDir, cfgPath[2:])
}
data, err := ioutil.ReadFile(os.ExpandEnv(cfgPath))
if err != nil && (!os.IsNotExist(err) || userSpecified) {
return nil, err
}
var cfg config
if err == nil {
cfg.ConfigFilePath = cfgPath

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This stuff ended up being unnecessary, will revert.

if err := json.Unmarshal(data, &cfg); err != nil {
return nil, err
}
Expand Down Expand Up @@ -145,9 +157,13 @@ func readConfig() (*config, error) {
cfg.Endpoint = *endpoint
}

cfg.Endpoint = strings.TrimSuffix(cfg.Endpoint, "/")
cfg.Endpoint = cleanEndpoint(cfg.Endpoint)

return &cfg, nil
}

func cleanEndpoint(urlStr string) string {
return strings.TrimSuffix(urlStr, "/")
}

var errConfigMerge = errors.New("when using a configuration file, zero or all environment variables must be set")
16 changes: 9 additions & 7 deletions cmd/src/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,13 @@ func TestReadConfig(t *testing.T) {
setEnv("SRC_ACCESS_TOKEN", test.envToken)
setEnv("SRC_ENDPOINT", test.envEndpoint)

tmpDir, err := ioutil.TempDir("", "")
if err != nil {
t.Fatal(err)
}
testHomeDir = tmpDir
t.Cleanup(func() { os.RemoveAll(tmpDir) })

if test.flagEndpoint != "" {
val := test.flagEndpoint
endpoint = &val
Expand All @@ -168,23 +175,18 @@ func TestReadConfig(t *testing.T) {
if err != nil {
t.Fatal(err)
}
tmpDir, err := ioutil.TempDir("", "")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { os.RemoveAll(tmpDir) })
filePath := filepath.Join(tmpDir, "config.json")
err = ioutil.WriteFile(filePath, data, 0600)
if err != nil {
t.Fatal(err)
}
*configPath = filePath
}

if err := os.Setenv("SRC_HEADER_FOO", test.envFooHeader); err != nil {
t.Fatal(err)
}

config, err := readConfig()
if diff := cmp.Diff(test.want, config); diff != "" {
t.Errorf("config: %v", diff)
Expand Down
2 changes: 1 addition & 1 deletion internal/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,7 @@ func (r *request) do(ctx context.Context, result interface{}) (bool, error) {
if resp.StatusCode != http.StatusOK {
if resp.StatusCode == http.StatusUnauthorized && isatty.IsCygwinTerminal(os.Stdout.Fd()) {
fmt.Println("You may need to specify or update your access token to use this endpoint.")
fmt.Println("See https://github.com/sourcegraph/src-cli#authentication")
fmt.Println("See https://github.com/sourcegraph/src-cli#readme")
fmt.Println("")
}
body, err := ioutil.ReadAll(resp.Body)
Expand Down