Credentials & Authentication Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Fix the silent "not a valid zip file" error during sap-devs sync by adding OS-keychain-backed token storage and authenticated HTTP requests to github.com/SAP-samples.
Architecture: A new internal/credentials package wraps zalando/go-keyring with a file fallback (<configDir>/credentials, 0600). FetchArchive gains a token parameter and auth-redirect detection. A new sap-devs config token command manages the stored token, and init prompts for a token before syncing.
Tech Stack: zalando/go-keyring (OS keychain), golang.org/x/term (hidden input), net/http + httptest for redirect detection tests.
Spec: docs/superpowers/specs/2026-04-14-credentials-auth-design.md
File Map
| Action | Path | Responsibility |
|---|---|---|
| Create | internal/credentials/credentials.go | Store, Load, Delete, Resolve, ErrNotFound, keyring interface |
| Create | internal/credentials/credentials_test.go | Unit tests (internal package — accesses unexported keyringBackend) |
| Modify | internal/sync/fetcher.go | Rename url → rawURL, add token param, auth header, redirect detection |
| Modify | internal/sync/fetcher_test.go | Update call sites to pass "" token; add redirect detection tests |
| Modify | cmd/sync.go | credentials.Resolve() at top of RunE, pass token to both FetchArchive calls |
| Modify | cmd/config.go | Add configTokenCmd, update configShowCmd to show masked token |
| Modify | cmd/init.go | Move xdg.New() to top, add Step 1 (auth prompt), renumber steps 1→5 |
| Modify | docs/user/user-guide.md | Add Authentication section |
| Modify | docs/developer/developer-guide.md | Add Credentials section, update Sync section |
| Modify | CLAUDE.md | Add internal/credentials/ to architecture table |
Task 1: Add zalando/go-keyring dependency
Files:
Modify:
go.mod,go.sum[ ] Step 1: Add the dependency
cd d:/projects/sap-devs-cli
go get github.com/zalando/go-keyring
go mod tidy- [ ] Step 2: Verify the build still compiles
go build ./...Expected: no errors.
- [ ] Step 3: Commit
git add go.mod go.sum
git commit -m "chore: add zalando/go-keyring dependency"Task 2: Create internal/credentials package
Files:
- Create:
internal/credentials/credentials.go - Create:
internal/credentials/credentials_test.go
The tests use package credentials (not package credentials_test) so they can replace the unexported keyringBackend variable with a fake.
- [ ] Step 1: Write the failing tests
Create internal/credentials/credentials_test.go:
package credentials
import (
"errors"
"os"
"path/filepath"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// fakeKeyring simulates a working in-memory keychain.
type fakeKeyring struct{ token string }
func (f *fakeKeyring) Get(_, _ string) (string, error) {
if f.token == "" {
return "", errKeyringNotFound
}
return f.token, nil
}
func (f *fakeKeyring) Set(_, _, password string) error { f.token = password; return nil }
func (f *fakeKeyring) Delete(_, _ string) error {
if f.token == "" {
return errKeyringNotFound
}
f.token = ""
return nil
}
// unavailableKeyring simulates a keychain that is always inaccessible.
type unavailableKeyring struct{ err error }
func (u unavailableKeyring) Get(_, _ string) (string, error) { return "", u.err }
func (u unavailableKeyring) Set(_, _, _ string) error { return u.err }
func (u unavailableKeyring) Delete(_, _ string) error { return u.err }
// notFoundKeyring simulates an empty keychain (no token stored).
type notFoundKeyring struct{}
func (notFoundKeyring) Get(_, _ string) (string, error) { return "", errKeyringNotFound }
func (notFoundKeyring) Set(_, _, _ string) error { return nil }
func (notFoundKeyring) Delete(_, _ string) error { return errKeyringNotFound }
func TestLoad_ErrNotFoundWhenNothing(t *testing.T) {
keyringBackend = notFoundKeyring{}
dir := t.TempDir()
_, err := Load(dir)
assert.ErrorIs(t, err, ErrNotFound)
}
func TestStoreLoad_FileRoundtrip(t *testing.T) {
keyringBackend = unavailableKeyring{err: errors.New("no keychain")}
dir := t.TempDir()
require.NoError(t, Store(dir, "mytoken"))
tok, err := Load(dir)
require.NoError(t, err)
assert.Equal(t, "mytoken", tok)
}
func TestStoreLoad_KeychainRoundtrip(t *testing.T) {
kb := &fakeKeyring{}
keyringBackend = kb
dir := t.TempDir()
require.NoError(t, Store(dir, "keychain-token"))
tok, err := Load(dir)
require.NoError(t, err)
assert.Equal(t, "keychain-token", tok)
// Nothing written to file
_, statErr := os.Stat(filepath.Join(dir, "credentials"))
assert.True(t, os.IsNotExist(statErr))
}
func TestLoad_KeychainErrorFallsBackToFile(t *testing.T) {
// First store to file
keyringBackend = unavailableKeyring{err: errors.New("no keychain")}
dir := t.TempDir()
require.NoError(t, Store(dir, "file-token"))
// Now simulate keychain access error (not "not found")
accessErr := errors.New("access denied")
keyringBackend = unavailableKeyring{err: accessErr}
// Capture stderr
old := os.Stderr
r, w, _ := os.Pipe()
os.Stderr = w
tok, err := Load(dir)
w.Close()
os.Stderr = old
buf := make([]byte, 1024)
n, _ := r.Read(buf)
stderr := string(buf[:n])
require.NoError(t, err)
assert.Equal(t, "file-token", tok)
assert.Contains(t, stderr, "keychain unavailable")
assert.NotContains(t, stderr, "file-token") // token never in warning
}
func TestDelete_RemovesFromKeychain(t *testing.T) {
kb := &fakeKeyring{token: "tok"}
keyringBackend = kb
dir := t.TempDir()
require.NoError(t, Delete(dir))
assert.Equal(t, "", kb.token)
}
func TestDelete_RemovesFromFile(t *testing.T) {
keyringBackend = notFoundKeyring{}
dir := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(dir, "credentials"), []byte("tok"), 0600))
require.NoError(t, Delete(dir))
_, err := os.Stat(filepath.Join(dir, "credentials"))
assert.True(t, os.IsNotExist(err))
}
func TestDelete_ErrNotFoundWhenNothingStored(t *testing.T) {
keyringBackend = notFoundKeyring{}
dir := t.TempDir()
err := Delete(dir)
assert.ErrorIs(t, err, ErrNotFound)
}
func TestResolve_EnvVarWins(t *testing.T) {
keyringBackend = notFoundKeyring{}
dir := t.TempDir()
t.Setenv("GITHUB_TOOLS_SAP_TOKEN", "env-token")
assert.Equal(t, "env-token", Resolve(dir))
}
func TestResolve_GHTokenFallback(t *testing.T) {
keyringBackend = notFoundKeyring{}
dir := t.TempDir()
t.Setenv("GITHUB_TOOLS_SAP_TOKEN", "")
t.Setenv("GH_TOKEN", "gh-token")
assert.Equal(t, "gh-token", Resolve(dir))
}
func TestResolve_GitHubTokenFallback(t *testing.T) {
keyringBackend = notFoundKeyring{}
dir := t.TempDir()
t.Setenv("GITHUB_TOOLS_SAP_TOKEN", "")
t.Setenv("GH_TOKEN", "")
t.Setenv("GITHUB_TOKEN", "github-token")
assert.Equal(t, "github-token", Resolve(dir))
}
func TestResolve_FileWhenKeyringUnavailable(t *testing.T) {
keyringBackend = unavailableKeyring{err: errors.New("no keychain")}
dir := t.TempDir()
t.Setenv("GITHUB_TOOLS_SAP_TOKEN", "")
t.Setenv("GH_TOKEN", "")
t.Setenv("GITHUB_TOKEN", "")
require.NoError(t, os.WriteFile(filepath.Join(dir, "credentials"), []byte("file-tok"), 0600))
assert.Equal(t, "file-tok", Resolve(dir))
}
func TestResolve_EmptyWhenNothing(t *testing.T) {
keyringBackend = notFoundKeyring{}
dir := t.TempDir()
t.Setenv("GITHUB_TOOLS_SAP_TOKEN", "")
t.Setenv("GH_TOKEN", "")
t.Setenv("GITHUB_TOKEN", "")
assert.Equal(t, "", Resolve(dir))
}
func TestResolve_KeychainErrorWarnsAndFallsBack(t *testing.T) {
accessErr := errors.New("permission denied")
keyringBackend = unavailableKeyring{err: accessErr}
dir := t.TempDir()
t.Setenv("GITHUB_TOOLS_SAP_TOKEN", "")
t.Setenv("GH_TOKEN", "")
t.Setenv("GITHUB_TOKEN", "")
require.NoError(t, os.WriteFile(filepath.Join(dir, "credentials"), []byte("file-tok"), 0600))
old := os.Stderr
r, w, _ := os.Pipe()
os.Stderr = w
tok := Resolve(dir)
w.Close()
os.Stderr = old
buf := make([]byte, 1024)
n, _ := r.Read(buf)
stderr := string(buf[:n])
assert.Equal(t, "file-tok", tok)
assert.Contains(t, stderr, "keychain unavailable")
assert.True(t, strings.Contains(stderr, "permission denied"))
assert.NotContains(t, stderr, "file-tok") // token never in warning
}
func TestCredFile_IsRestrictedPermissions(t *testing.T) {
keyringBackend = unavailableKeyring{err: errors.New("no keychain")}
dir := t.TempDir()
require.NoError(t, Store(dir, "tok"))
info, err := os.Stat(filepath.Join(dir, "credentials"))
require.NoError(t, err)
assert.Equal(t, os.FileMode(0600), info.Mode().Perm())
}- [ ] Step 2: Run tests to confirm they fail
go test ./internal/credentials/...Expected: compile error — package does not exist yet.
- [ ] Step 3: Write the implementation
Create internal/credentials/credentials.go:
package credentials
import (
"errors"
"fmt"
"os"
"path/filepath"
"strings"
goKeyring "github.com/zalando/go-keyring"
)
const (
keyringSvc = "sap-devs"
keyringUser = "github-token"
)
// ErrNotFound is returned by Load and Delete when no token is stored.
var ErrNotFound = errors.New("credentials: no token stored")
// errKeyringNotFound matches zalando/go-keyring's "not found" sentinel.
// We keep a local reference so tests can use it without importing go-keyring.
var errKeyringNotFound = goKeyring.ErrNotFound
// keyring is the interface used by Store/Load/Delete.
// The package-level keyringBackend variable is replaced in tests.
type keyring interface {
Get(service, user string) (string, error)
Set(service, user, password string) error
Delete(service, user string) error
}
// osKeyring delegates to the real OS keychain via zalando/go-keyring.
type osKeyring struct{}
func (osKeyring) Get(s, u string) (string, error) { return goKeyring.Get(s, u) }
func (osKeyring) Set(s, u, p string) error { return goKeyring.Set(s, u, p) }
func (osKeyring) Delete(s, u string) error { return goKeyring.Delete(s, u) }
var keyringBackend keyring = osKeyring{}
// Store saves the token to the OS keychain.
// Falls back to <configDir>/credentials (0600) if the keychain is unavailable.
func Store(configDir, token string) error {
if err := keyringBackend.Set(keyringSvc, keyringUser, token); err == nil {
return nil
} else {
fmt.Fprintf(os.Stderr, "keychain unavailable: %v; token stored in credentials file\n", err)
}
return writeCredFile(configDir, token)
}
// Load retrieves the token from the OS keychain or credentials file.
// Returns ErrNotFound if no token is stored.
func Load(configDir string) (string, error) {
tok, err := keyringBackend.Get(keyringSvc, keyringUser)
if err == nil {
return tok, nil
}
if errors.Is(err, goKeyring.ErrNotFound) {
// Not in keychain — try file
return readCredFile(configDir)
}
// Keychain access error — warn and fall back to file
fmt.Fprintf(os.Stderr, "keychain unavailable: %v; falling back to credentials file\n", err)
return readCredFile(configDir)
}
// Delete removes the stored token from the keychain or credentials file.
// Returns ErrNotFound if no token was stored anywhere.
func Delete(configDir string) error {
keychainErr := keyringBackend.Delete(keyringSvc, keyringUser)
if keychainErr == nil {
return nil
}
// Keychain either had "not found" or an access error — try the file.
path := credFile(configDir)
err := os.Remove(path)
if os.IsNotExist(err) {
return ErrNotFound
}
return err
}
// Resolve returns the best available token using the full priority chain:
// GITHUB_TOOLS_SAP_TOKEN → GH_TOKEN → GITHUB_TOKEN → keychain/file → "".
// Never returns an error.
func Resolve(configDir string) string {
for _, env := range []string{"GITHUB_TOOLS_SAP_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"} {
if v := os.Getenv(env); v != "" {
return v
}
}
tok, err := Load(configDir)
if err == nil {
return tok
}
return ""
}
func credFile(configDir string) string {
return filepath.Join(configDir, "credentials")
}
func writeCredFile(configDir, token string) error {
if err := os.MkdirAll(configDir, 0755); err != nil {
return err
}
return os.WriteFile(credFile(configDir), []byte(token), 0600)
}
func readCredFile(configDir string) (string, error) {
data, err := os.ReadFile(credFile(configDir))
if os.IsNotExist(err) {
return "", ErrNotFound
}
if err != nil {
return "", err
}
tok := strings.TrimSpace(string(data))
if tok == "" {
return "", ErrNotFound
}
return tok, nil
}- [ ] Step 4: Run tests to confirm they pass
go test ./internal/credentials/...Expected: all tests pass.
- [ ] Step 5: Verify build
go build ./...
go vet ./...Expected: no errors.
- [ ] Step 6: Commit
git add internal/credentials/
git commit -m "feat: add internal/credentials package with keychain and file fallback"Task 3: Update FetchArchive — signature, auth header, redirect detection
Files:
Modify:
internal/sync/fetcher.goModify:
internal/sync/fetcher_test.go[ ] Step 1: Update existing tests to pass empty token (third argument)
In internal/sync/fetcher_test.go, update the two existing FetchArchive calls:
// In TestFetcher_DownloadsAndExtractsZip:
err := sapSync.FetchArchive(srv.URL, dest, "")
// In TestFetcher_BlocksZipSlip:
err := sapSync.FetchArchive(srv.URL, dest, "")- [ ] Step 2: Add redirect detection and auth header tests
Append to internal/sync/fetcher_test.go:
func TestFetcher_AuthRedirectReturnsActionableError(t *testing.T) {
// Server that redirects to /login on the same host (simulates GHE auth wall)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/login" {
w.WriteHeader(http.StatusOK)
w.Write([]byte("<html>Login page</html>"))
return
}
http.Redirect(w, r, "/login", http.StatusFound)
}))
defer srv.Close()
dest := t.TempDir()
err := sapSync.FetchArchive(srv.URL+"/repo.zip", dest, "")
require.Error(t, err)
assert.Contains(t, err.Error(), "authentication required")
assert.Contains(t, err.Error(), "GITHUB_TOOLS_SAP_TOKEN")
assert.Contains(t, err.Error(), "sap-devs config token")
}
func TestFetcher_SendsAuthHeader(t *testing.T) {
var gotAuth string
buf := new(bytes.Buffer)
w := zip.NewWriter(buf)
f, _ := w.Create("repo-main/content/pack.yaml")
f.Write([]byte("id: test\n"))
w.Close()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotAuth = r.Header.Get("Authorization")
w.Header().Set("Content-Type", "application/zip")
w.Write(buf.Bytes())
}))
defer srv.Close()
dest := t.TempDir()
err := sapSync.FetchArchive(srv.URL, dest, "mytoken")
require.NoError(t, err)
assert.Equal(t, "token mytoken", gotAuth)
}
func TestFetcher_NoAuthHeaderWhenTokenEmpty(t *testing.T) {
var gotAuth string
buf := new(bytes.Buffer)
w := zip.NewWriter(buf)
f, _ := w.Create("repo-main/content/pack.yaml")
f.Write([]byte("id: test\n"))
w.Close()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotAuth = r.Header.Get("Authorization")
w.Header().Set("Content-Type", "application/zip")
w.Write(buf.Bytes())
}))
defer srv.Close()
dest := t.TempDir()
require.NoError(t, sapSync.FetchArchive(srv.URL, dest, ""))
assert.Equal(t, "", gotAuth)
}- [ ] Step 3: Run tests to confirm new ones fail
go test ./internal/sync/...Expected: compile error (wrong number of args to FetchArchive).
- [ ] Step 4: Update
FetchArchiveimplementation
Replace the contents of internal/sync/fetcher.go:
package sync
import (
"archive/zip"
"bytes"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
)
// FetchArchive downloads a zip archive from rawURL and extracts it to destDir.
// If token is non-empty it is sent as an Authorization header.
// Existing files are overwritten; directories are created as needed.
// GitHub/GitLab archives include a top-level directory prefix which is stripped.
func FetchArchive(rawURL, destDir, token string) error {
parsedURL, err := url.Parse(rawURL)
if err != nil {
return fmt.Errorf("parse URL: %w", err)
}
req, err := http.NewRequest(http.MethodGet, rawURL, nil) //nolint:gosec // URL comes from user config, not untrusted input
if err != nil {
return fmt.Errorf("create request: %w", err)
}
if token != "" {
req.Header.Set("Authorization", "token "+token)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return fmt.Errorf("fetch %s: %w", rawURL, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("fetch %s: HTTP %d", rawURL, resp.StatusCode)
}
data, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("read response: %w", err)
}
// Auth redirect detection: if we ended up on the login page, surface a clear error.
// Check resp.Request.URL (the final URL after redirects) for a /login path on the same host.
if resp.Request.URL.Host == parsedURL.Host && strings.Contains(resp.Request.URL.Path, "/login") {
return fmt.Errorf("authentication required for %s — set GITHUB_TOOLS_SAP_TOKEN or run 'sap-devs config token'", parsedURL.Host)
}
zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
if err != nil {
return fmt.Errorf("open zip: %w", err)
}
// Strip one leading path component (GitHub archives include repo-name-sha/ prefix)
strip := zipStripPrefix(zr)
absBase, err := filepath.Abs(destDir)
if err != nil {
return fmt.Errorf("resolve destDir: %w", err)
}
for _, f := range zr.File {
name := strings.TrimPrefix(f.Name, strip)
if name == "" || strings.HasSuffix(name, "/") {
continue
}
dest := filepath.Join(absBase, filepath.FromSlash(name))
// Zip slip guard: ensure destination is within destDir
if !strings.HasPrefix(dest, absBase+string(os.PathSeparator)) {
return fmt.Errorf("zip slip blocked: %q escapes destination", f.Name)
}
if err := extractFile(f, dest); err != nil {
return err
}
}
return nil
}
func zipStripPrefix(zr *zip.Reader) string {
for _, f := range zr.File {
parts := strings.SplitN(f.Name, "/", 2)
if len(parts) == 2 {
return parts[0] + "/"
}
}
return ""
}
func extractFile(f *zip.File, dest string) error {
if err := os.MkdirAll(filepath.Dir(dest), 0755); err != nil {
return fmt.Errorf("create dir for %s: %w", dest, err)
}
rc, err := f.Open()
if err != nil {
return fmt.Errorf("open zip entry %s: %w", f.Name, err)
}
defer rc.Close()
out, err := os.Create(dest)
if err != nil {
return fmt.Errorf("create %s: %w", dest, err)
}
defer out.Close()
if _, err = io.Copy(out, rc); err != nil {
return fmt.Errorf("write %s: %w", dest, err)
}
return nil
}- [ ] Step 5: Run tests to confirm they pass
go test ./internal/sync/...Expected: all tests pass.
Note:
go build ./...will now fail incmd/sync.gobecauseFetchArchiveis called with 2 args. Do NOT commit yet — complete Task 4 first and commit both together.
Task 4: Update cmd/sync.go — resolve and pass token
Files:
Modify:
cmd/sync.go[ ] Step 1: Update
cmd/sync.go
Add the import for credentials and resolve the token once before both FetchArchive calls. Here is the full updated syncCmd.RunE body — replace only the relevant sections:
Add to imports:
"github.com/SAP-samples/sap-devs-cli/internal/credentials"At the top of syncCmd.RunE, after loading cfg and before the categories/TTL logic, add:
token := credentials.Resolve(paths.ConfigDir)Then update both FetchArchive calls:
// Official repo fetch (was line 69):
if err := sapSync.FetchArchive(officialRepoArchive, officialCache, token); err != nil {
// Company repo fetch (was line 87):
if err := sapSync.FetchArchive(companyArchive, companyCache, token); err != nil {- [ ] Step 2: Verify build
go build ./...
go vet ./...Expected: no errors.
- [ ] Step 3: Commit Tasks 3 and 4 together
git add internal/sync/fetcher.go internal/sync/fetcher_test.go cmd/sync.go
git commit -m "feat: update FetchArchive with auth token and detect auth redirects"Task 5: Add config token command and update config show
Files:
Modify:
cmd/root.go(addRootCmd()accessor)Modify:
cmd/config.goCreate:
cmd/config_token_test.go[ ] Step 1: Add
RootCmd()accessor tocmd/root.go
The tests are in package cmd_test (external) and cannot access the unexported rootCmd variable directly. Add this exported accessor at the bottom of cmd/root.go:
// RootCmd returns the root cobra command. Used in tests.
func RootCmd() *cobra.Command { return rootCmd }- [ ] Step 2: Write the failing tests
Create cmd/config_token_test.go:
package cmd_test
import (
"bytes"
"os"
"path/filepath"
"runtime"
"testing"
"github.com/spf13/cobra"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// executeCommand runs a cobra command with given args, returns combined output and error.
func executeCommand(root *cobra.Command, args ...string) (string, error) {
buf := new(bytes.Buffer)
root.SetOut(buf)
root.SetErr(buf)
root.SetArgs(args)
_, err := root.ExecuteC()
return buf.String(), err
}
// skipOnWindows skips XDG-dependent tests on Windows (XDG_CONFIG_HOME is not honoured there).
// CI (ubuntu-latest) is the authoritative runner per project convention.
func skipOnWindows(t *testing.T) {
t.Helper()
if runtime.GOOS == "windows" {
t.Skip("XDG_CONFIG_HOME isolation not supported on Windows; run in CI")
}
}
func TestConfigToken_StoreViaArg(t *testing.T) {
skipOnWindows(t)
dir := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", dir)
out, err := executeCommand(RootCmd(), "config", "token", "ghp_testtoken")
require.NoError(t, err)
assert.Contains(t, out, "Warning: token passed as argument")
assert.Contains(t, out, "Token stored securely.")
assert.NotContains(t, out, "ghp_testtoken") // token must never appear in output
}
func TestConfigToken_NonTTYReturnsError(t *testing.T) {
skipOnWindows(t)
dir := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", dir)
// When called with no args in a non-TTY context (test stdin is not a TTY),
// term.ReadPassword fails and the command should return a non-zero exit with
// the fallback message — not block silently.
_, err := executeCommand(RootCmd(), "config", "token")
require.Error(t, err)
assert.Contains(t, err.Error(), "interactive input not available")
assert.Contains(t, err.Error(), "sap-devs config token <value>")
}
func TestConfigToken_DeleteWhenStored(t *testing.T) {
skipOnWindows(t)
dir := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", dir)
sapDir := filepath.Join(dir, "sap-devs")
require.NoError(t, os.MkdirAll(sapDir, 0755))
require.NoError(t, os.WriteFile(filepath.Join(sapDir, "credentials"), []byte("tok"), 0600))
out, err := executeCommand(RootCmd(), "config", "token", "--delete")
require.NoError(t, err)
assert.Contains(t, out, "Token removed.")
}
func TestConfigToken_DeleteWhenNothingStored(t *testing.T) {
skipOnWindows(t)
dir := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", dir)
require.NoError(t, os.MkdirAll(filepath.Join(dir, "sap-devs"), 0755))
out, err := executeCommand(RootCmd(), "config", "token", "--delete")
require.NoError(t, err)
assert.Contains(t, out, "No token was stored.")
}
func TestConfigToken_DeleteAndValueMutuallyExclusive(t *testing.T) {
skipOnWindows(t)
dir := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", dir)
_, err := executeCommand(RootCmd(), "config", "token", "ghp_abc", "--delete")
require.Error(t, err)
assert.Contains(t, err.Error(), "cannot use --delete with a token value")
}
func TestConfigShow_MasksToken(t *testing.T) {
skipOnWindows(t)
dir := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", dir)
sapDir := filepath.Join(dir, "sap-devs")
require.NoError(t, os.MkdirAll(sapDir, 0755))
require.NoError(t, os.WriteFile(filepath.Join(sapDir, "credentials"), []byte("ghp_abcdefgh"), 0600))
out, err := executeCommand(RootCmd(), "config", "show")
require.NoError(t, err)
assert.Contains(t, out, "ghp_****")
assert.NotContains(t, out, "ghp_abcdefgh")
}
func TestConfigShow_NoTokenDisplaysNotSet(t *testing.T) {
skipOnWindows(t)
dir := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", dir)
require.NoError(t, os.MkdirAll(filepath.Join(dir, "sap-devs"), 0755))
out, err := executeCommand(RootCmd(), "config", "show")
require.NoError(t, err)
assert.Contains(t, out, "(not set)")
}
func TestConfigToken_DeleteThenShowNotSet(t *testing.T) {
skipOnWindows(t)
dir := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", dir)
sapDir := filepath.Join(dir, "sap-devs")
require.NoError(t, os.MkdirAll(sapDir, 0755))
require.NoError(t, os.WriteFile(filepath.Join(sapDir, "credentials"), []byte("tok"), 0600))
_, err := executeCommand(RootCmd(), "config", "token", "--delete")
require.NoError(t, err)
out, err := executeCommand(RootCmd(), "config", "show")
require.NoError(t, err)
assert.Contains(t, out, "(not set)")
}- [ ] Step 3: Run tests to confirm they fail
go test ./cmd/... -run TestConfigToken -v
go test ./cmd/... -run TestConfigShow -vExpected: compile errors — configTokenCmd not defined, RootCmd not found.
- [ ] Step 4: Update
cmd/config.go
Replace the full file content. Note: i18n is NOT imported — it was never used in config.go. The masking logic is extracted to an unexported maskToken helper so it can be tested in isolation:
package cmd
import (
"errors"
"fmt"
"os"
"strings"
"github.com/spf13/cobra"
"golang.org/x/term"
"github.com/SAP-samples/sap-devs-cli/internal/config"
"github.com/SAP-samples/sap-devs-cli/internal/credentials"
"github.com/SAP-samples/sap-devs-cli/internal/xdg"
)
var configCmd = &cobra.Command{
Use: "config",
Short: "Manage sap-devs configuration",
}
var configShowCmd = &cobra.Command{
Use: "show",
Short: "Display current configuration",
RunE: func(cmd *cobra.Command, args []string) error {
paths, err := xdg.New()
if err != nil {
return err
}
cfg, err := config.Load(paths.ConfigDir)
if err != nil {
return err
}
fmt.Fprintf(cmd.OutOrStdout(), "company_repo: %s\n", cfg.CompanyRepo)
fmt.Fprintf(cmd.OutOrStdout(), "language: %s\n", cfg.Language)
fmt.Fprintf(cmd.OutOrStdout(), "sync.tips: %s\n", cfg.Sync.Tips)
fmt.Fprintf(cmd.OutOrStdout(), "sync.tools: %s\n", cfg.Sync.Tools)
fmt.Fprintf(cmd.OutOrStdout(), "sync.advocates: %s\n", cfg.Sync.Advocates)
fmt.Fprintf(cmd.OutOrStdout(), "sync.resources: %s\n", cfg.Sync.Resources)
fmt.Fprintf(cmd.OutOrStdout(), "sync.context: %s\n", cfg.Sync.Context)
fmt.Fprintf(cmd.OutOrStdout(), "sync.mcp: %s\n", cfg.Sync.MCP)
fmt.Fprintf(cmd.OutOrStdout(), "sync.disabled: %v\n", cfg.Sync.Disabled)
// Show token status (masked — never show the full value)
tok, loadErr := credentials.Load(paths.ConfigDir)
fmt.Fprintf(cmd.OutOrStdout(), "github_token: %s\n", maskedToken(tok, loadErr))
return nil
},
}
// maskedToken returns a display-safe representation of a stored token.
// It is extracted here so tests can verify masking logic directly.
func maskedToken(tok string, err error) string {
switch {
case err == nil:
if len(tok) < 4 {
return "(set)"
}
return tok[:4] + "****"
case errors.Is(err, credentials.ErrNotFound):
return "(not set)"
default:
return "(unavailable)"
}
}
var configSetCmd = &cobra.Command{
Use: "set <key> <value>",
Short: "Set a configuration value",
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
paths, err := xdg.New()
if err != nil {
return err
}
cfg, err := config.Load(paths.ConfigDir)
if err != nil {
return err
}
switch args[0] {
case "company_repo":
cfg.CompanyRepo = args[1]
case "language":
cfg.Language = args[1]
default:
return fmt.Errorf("unknown config key: %s", args[0])
}
if err := cfg.Save(paths.ConfigDir); err != nil {
return err
}
fmt.Fprintf(cmd.OutOrStdout(), "Set %s = %s\n", args[0], args[1])
return nil
},
}
var configCompanyCmd = &cobra.Command{
Use: "company <git-url>",
Short: "Configure the company content repo URL",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
paths, err := xdg.New()
if err != nil {
return err
}
cfg, err := config.Load(paths.ConfigDir)
if err != nil {
return err
}
cfg.CompanyRepo = args[0]
if err := cfg.Save(paths.ConfigDir); err != nil {
return err
}
fmt.Fprintf(cmd.OutOrStdout(), "Company repo set to: %s\n", args[0])
return nil
},
}
var tokenDeleteFlag bool
var configTokenCmd = &cobra.Command{
Use: "token [value]",
Short: "Store a GitHub token for authenticating with github.com/SAP-samples",
Long: `Store a Personal Access Token for authenticating with github.com/SAP-samples.
Only required when syncing content from a private GitHub Enterprise instance
(github.com/SAP-samples). Not needed if you are outside the SAP network or already
have GITHUB_TOOLS_SAP_TOKEN set in your environment.
The token is stored in the OS keychain (macOS Keychain, Windows Credential
Manager, Linux Secret Service). On systems without a keychain, it falls back
to a credentials file with restricted permissions.`,
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
if tokenDeleteFlag && len(args) > 0 {
return fmt.Errorf("cannot use --delete with a token value")
}
paths, err := xdg.New()
if err != nil {
return err
}
if tokenDeleteFlag {
err := credentials.Delete(paths.ConfigDir)
if errors.Is(err, credentials.ErrNotFound) {
fmt.Fprintln(cmd.OutOrStdout(), "No token was stored.")
return nil
}
if err != nil {
return err
}
fmt.Fprintln(cmd.OutOrStdout(), "Token removed.")
return nil
}
var token string
if len(args) == 1 {
token = args[0]
fmt.Fprintln(cmd.OutOrStdout(), "Warning: token passed as argument may be saved in shell history.")
fmt.Fprintln(cmd.OutOrStdout(), "Consider using 'sap-devs config token' without arguments for interactive entry.")
} else {
fmt.Fprint(cmd.OutOrStdout(), "Enter GitHub token (input hidden, will not appear in shell history): ")
raw, readErr := term.ReadPassword(int(os.Stdin.Fd()))
fmt.Fprintln(cmd.OutOrStdout()) // newline after hidden input
if readErr != nil {
return fmt.Errorf("interactive input not available — pass token as argument: sap-devs config token <value>")
}
token = strings.TrimSpace(string(raw))
if token == "" {
fmt.Fprintln(cmd.OutOrStdout(), "No token entered.")
return nil
}
}
if err := credentials.Store(paths.ConfigDir, token); err != nil {
return err
}
fmt.Fprintln(cmd.OutOrStdout(), "Token stored securely.")
return nil
},
}
func init() {
configTokenCmd.Flags().BoolVar(&tokenDeleteFlag, "delete", false, "Remove the stored token")
configCmd.AddCommand(configShowCmd, configSetCmd, configCompanyCmd, configTokenCmd)
rootCmd.AddCommand(configCmd)
}- [ ] Step 5: Run tests
go test ./cmd/... -run TestConfigToken -v
go test ./cmd/... -run TestConfigShow -vExpected: all pass on Linux/CI. Windows tests are skipped.
- [ ] Step 6: Run
go mod tidyto promotegolang.org/x/termfrom indirect to direct
cmd/config.go now directly imports golang.org/x/term. Run:
go mod tidyExpected: go.mod now lists golang.org/x/term as a direct dependency (no // indirect annotation).
- [ ] Step 7: Verify build
go build ./...
go vet ./...Expected: no errors.
- [ ] Step 8: Commit
git add cmd/root.go cmd/config.go cmd/config_token_test.go go.mod go.sum
git commit -m "feat: add config token command and masked token display in config show"Task 6: Update init wizard — add Step 1 (auth prompt), renumber steps
Files:
Modify:
cmd/init.go[ ] Step 1: Update
cmd/init.go
Replace the full file:
package cmd
import (
"bufio"
"fmt"
"os"
"strings"
"github.com/spf13/cobra"
"golang.org/x/term"
"github.com/SAP-samples/sap-devs-cli/internal/config"
"github.com/SAP-samples/sap-devs-cli/internal/credentials"
"github.com/SAP-samples/sap-devs-cli/internal/xdg"
)
var initCmd = &cobra.Command{
Use: "init",
Short: "First-time setup wizard",
RunE: func(cmd *cobra.Command, args []string) error {
fmt.Println("Welcome to sap-devs — your AI-first SAP developer toolkit.")
fmt.Println()
paths, err := xdg.New()
if err != nil {
return err
}
// Step 1: GitHub authentication (optional)
fmt.Println("Step 1/5: GitHub authentication (optional)")
fmt.Println()
fmt.Println(" sap-devs syncs content from github.com/SAP-samples, which requires a Personal")
fmt.Println(" Access Token if you are inside the SAP corporate network. If you are")
fmt.Println(" outside SAP or already have GITHUB_TOOLS_SAP_TOKEN set in your")
fmt.Println(" environment, press Enter to skip.")
fmt.Println()
fmt.Print(" GitHub token (press Enter to skip): ")
raw, termErr := term.ReadPassword(int(os.Stdin.Fd()))
fmt.Println() // newline after hidden input
if termErr != nil {
fmt.Println(" Note: interactive token input unavailable. Run 'sap-devs config token <value>' after setup to authenticate.")
} else {
token := strings.TrimSpace(string(raw))
if token != "" {
if storeErr := credentials.Store(paths.ConfigDir, token); storeErr != nil {
fmt.Printf(" Warning: could not store token (%v).\n", storeErr)
} else {
fmt.Println(" Token stored securely.")
}
}
}
// Step 2: Sync content
fmt.Println("\nStep 2/5: Downloading SAP developer content...")
if err := runSyncForce(); err != nil {
fmt.Printf("Warning: content sync failed (%v). Continuing with any cached content.\n", err)
}
// Step 3: Choose profile
fmt.Println("\nStep 3/5: What kind of SAP developer are you?")
loader, err := newContentLoader()
if err != nil {
return err
}
profiles, err := loader.LoadProfiles()
if err != nil {
return err
}
if len(profiles) > 0 {
for i, p := range profiles {
fmt.Printf(" [%d] %-25s %s\n", i+1, p.ID, p.Description)
}
fmt.Print("\nEnter number (or press Enter to skip): ")
choice := readLine()
if choice != "" {
idx := 0
fmt.Sscanf(choice, "%d", &idx)
if idx >= 1 && idx <= len(profiles) {
chosen := profiles[idx-1]
if err := config.SaveProfile(paths.ConfigDir, &config.Profile{ID: chosen.ID}); err != nil {
return err
}
fmt.Printf("Profile set to: %s\n", chosen.Name)
}
}
} else {
fmt.Println("No profiles found. Run 'sap-devs sync' then 'sap-devs profile list'.")
}
// Step 4: Inject into AI tools
fmt.Println("\nStep 4/5: Inject SAP context into your AI tools?")
fmt.Println(" This writes SAP developer context to your AI tool configuration files.")
fmt.Print(" Inject now? [Y/n]: ")
if answer := strings.ToLower(strings.TrimSpace(readLine())); answer == "" || answer == "y" {
if err := runInjectGlobal(); err != nil {
fmt.Printf(" Warning: inject failed (%v). You can run 'sap-devs inject' manually.\n", err)
} else {
fmt.Println(" SAP context injected into your AI tools.")
}
}
// Step 5: Shell profile hook
fmt.Println("\nStep 5/5: Add SAP tip to your terminal startup?")
fmt.Println(" This adds 'sap-devs tip' to your shell profile so you see a tip each time you open a terminal.")
fmt.Print(" Add it? [y/N]: ")
if strings.ToLower(strings.TrimSpace(readLine())) == "y" {
if err := addShellHook(); err != nil {
fmt.Printf(" Could not auto-add hook: %v\n Add 'sap-devs tip' to your shell profile manually.\n", err)
} else {
fmt.Println(" Added. Restart your terminal to see your first tip.")
}
}
fmt.Println("\nSetup complete! Run 'sap-devs --help' to explore all commands.")
fmt.Println("Run 'sap-devs inject' to re-inject after syncing new content.")
return nil
},
}
func runSyncForce() error {
syncForce = true
defer func() { syncForce = false }()
return syncCmd.RunE(syncCmd, nil)
}
func readLine() string {
scanner := bufio.NewScanner(os.Stdin)
scanner.Scan()
return strings.TrimSpace(scanner.Text())
}
func addShellHook() error {
home, err := os.UserHomeDir()
if err != nil {
return err
}
candidates := []string{".zshrc", ".bashrc", ".bash_profile"}
for _, rc := range candidates {
path := home + "/" + rc
if _, err := os.Stat(path); err == nil {
f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0644)
if err != nil {
return err
}
_, err = f.WriteString("\n# SAP developer tips\nsap-devs tip\n")
f.Close()
return err
}
}
return fmt.Errorf("no shell rc file found (.zshrc, .bashrc, .bash_profile)")
}
func runInjectGlobal() error {
prevProject, prevDryRun, prevTool := injectProject, injectDryRun, injectTool
injectProject = false
injectDryRun = false
injectTool = ""
defer func() { injectProject, injectDryRun, injectTool = prevProject, prevDryRun, prevTool }()
return injectCmd.RunE(injectCmd, nil)
}
func init() {
rootCmd.AddCommand(initCmd)
}- [ ] Step 2: Verify build
go build ./...
go vet ./...Expected: no errors.
- [ ] Step 3: Commit
git add cmd/init.go
git commit -m "feat: add auth token prompt as Step 1 of init wizard"Task 6a: Test init wizard non-TTY path
Files:
- Create:
cmd/init_test.go
The init wizard calls term.ReadPassword in Step 1. When stdin is not a TTY (piped CI input, scripted usage), ReadPassword returns an error. The wizard must skip Step 1 gracefully, print the note, and continue to Step 2 rather than blocking or returning an error.
- [ ] Step 1: Write the failing test
Create cmd/init_test.go:
package cmd_test
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestInit_NonTTYSkipsTokenPrompt verifies that when stdin is not a TTY
// (term.ReadPassword fails), initCmd.RunE does not error on the token step —
// it prints the fallback note and continues to Step 2.
//
// When run in a test environment stdin is not a TTY, so term.ReadPassword
// will always return an error here, exercising the non-TTY branch.
func TestInit_NonTTYSkipsTokenPrompt(t *testing.T) {
skipOnWindows(t)
dir := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", dir)
out, err := executeCommand(RootCmd(), "init")
// init may fail at sync or inject steps in a test environment — that is OK.
// The important thing: if it fails, the failure must NOT be the non-TTY error
// from the token step; the wizard must have continued past Step 1.
if err != nil {
assert.NotContains(t, err.Error(), "interactive input not available",
"init must not fail due to non-TTY token prompt — Step 1 should be skipped gracefully")
}
// The non-TTY note must be printed, confirming Step 1 was handled not silently dropped.
assert.Contains(t, out, "interactive token input unavailable",
"init must print the non-TTY fallback note for the token step")
}- [ ] Step 2: Run test to confirm it compiles and exercises the branch
go test ./cmd/... -run TestInit_NonTTYSkipsTokenPrompt -vExpected: test passes (the non-TTY path is hit because test stdin is not a TTY). The test may log warnings from sync or inject steps — that is expected.
- [ ] Step 3: Verify build
go build ./...
go vet ./...Expected: no errors.
- [ ] Step 4: Commit
git add cmd/init_test.go
git commit -m "test: verify init wizard non-TTY path skips token prompt gracefully"Task 7: Documentation updates
Files:
Modify:
docs/user/user-guide.mdModify:
docs/developer/developer-guide.mdModify:
CLAUDE.md[ ] Step 1: Add Authentication section to
docs/user/user-guide.md
Find the ### sync section (around line 141) and insert a new ### Authentication section immediately before it:
### Authentication
`sap-devs sync` fetches content from `github.com/SAP-samples`, which requires a Personal Access Token if you are inside the SAP corporate network.
**When you need a token:** Only when syncing from `github.com/SAP-samples` on the SAP corporate network. If you are outside SAP, no token is needed.
**Token resolution order** (first match wins):
1. `GITHUB_TOOLS_SAP_TOKEN` environment variable
2. `GH_TOKEN` environment variable
3. `GITHUB_TOKEN` environment variable
4. Token stored with `sap-devs config token`
**Storing a token (interactive — recommended for developer machines):**
```sh
sap-devs config token
# Prompts: Enter GitHub token (input hidden, will not appear in shell history):Storing a token (non-interactive — scripted or CI):
sap-devs config token ghp_yourtoken
# Warning: token passed as argument may be saved in shell history.For CI/CD, set GITHUB_TOOLS_SAP_TOKEN as a pipeline secret instead — no local storage needed.
Where tokens are stored: The OS keychain (macOS Keychain, Windows Credential Manager, Linux Secret Service). On headless systems without a keychain, a credentials file at ~/.config/sap-devs/credentials (Linux) with restricted permissions (owner read/write only). Tokens are never stored in config.yaml.
Removing a stored token:
sap-devs config token --deleteViewing token status:
sap-devs config show
# github_token: ghp_****
- [ ] **Step 2: Add Credentials section to `docs/developer/developer-guide.md`**
Find the `### Sync` section and:
a) Update the Sync description to mention the new token parameter:
```markdown
### Sync
`sap-devs sync` (`cmd/sync.go`) fetches the official repo as a `.zip` archive and extracts it to the cache. Per-category TTLs are tracked in `~/.cache/sap-devs/sync-state.json` via `sync.Engine` (`internal/sync/engine.go`). Use `--force` to ignore TTLs.
The auth token is resolved once at the top of `syncCmd.RunE` via `credentials.Resolve()` and passed to both `FetchArchive` calls (official + company repo). `FetchArchive` signature: `FetchArchive(rawURL, destDir, token string) error`.b) Add a new ### Credentials section after Sync:
### Credentials
`internal/credentials/` manages token storage and resolution.
**Functions:**
| Function | Behaviour |
| --- | --- |
| `Store(configDir, token string) error` | Saves to OS keychain; falls back to `<configDir>/credentials` (0600) if keychain unavailable. Prints an informational stderr note on fallback. |
| `Load(configDir string) (string, error)` | Reads from keychain; falls back to file on keychain error (prints stderr warning). Returns `ErrNotFound` if no token anywhere. |
| `Delete(configDir string) error` | Removes from keychain; falls back to deleting the file. Returns `ErrNotFound` if nothing stored. |
| `Resolve(configDir string) string` | Full priority chain: `GITHUB_TOOLS_SAP_TOKEN` → `GH_TOKEN` → `GITHUB_TOKEN` → `Load()` → `""`. Never errors. |
**Keychain backend:** `zalando/go-keyring` — macOS Keychain, Windows Credential Manager, Linux Secret Service (D-Bus). Falls back to credentials file when unavailable (headless Linux, CI containers).
**Security properties:**
- Token only sent in `Authorization: token <tok>` header, never in URLs or error strings
- `config show` masks the token: `<first4>****` or `(not set)`
- Credentials file is separate from `config.yaml` to prevent accidental dotfile repo exposure
**Testing:** The package uses an unexported `keyringBackend` variable (`type keyring interface`). Tests (`package credentials`) replace it with `fakeKeyring`, `unavailableKeyring`, or `notFoundKeyring` structs to exercise all paths without a real keychain. No real OS keychain is touched in CI.
**Auth redirect detection in `FetchArchive`:** After reading the response body, `FetchArchive` checks `resp.Request.URL.Host == parsedURL.Host && strings.Contains(resp.Request.URL.Path, "/login")`. If matched, it returns: `authentication required for <host> — set GITHUB_TOOLS_SAP_TOKEN or run 'sap-devs config token'`. The host in the error is always from the original URL, not the redirect target.- [ ] Step 3: Add
internal/credentials/toCLAUDE.md
In the CLAUDE.md architecture overview, find the ContentLoader line and add after the existing architecture table entries:
`internal/credentials` ([internal/credentials/credentials.go](internal/credentials/credentials.go)) provides secure token storage. `Store`/`Load`/`Delete` use the OS keychain via `zalando/go-keyring` with a `<configDir>/credentials` file fallback (0600). `Resolve()` implements the full priority chain: env vars (`GITHUB_TOOLS_SAP_TOKEN`, `GH_TOKEN`, `GITHUB_TOKEN`) → keychain → file → `""`. Used by `sync` and `config token`.- [ ] Step 4: Verify build
go build ./...
go vet ./...- [ ] Step 5: Commit
git add docs/user/user-guide.md docs/developer/developer-guide.md CLAUDE.md
git commit -m "docs: add authentication and credentials documentation"Task 8: Full verification
- [ ] Step 1: Run all tests
go test ./...Expected: all pass (CI is the authoritative runner; Windows may skip some tests per project convention).
- [ ] Step 2: Build the binary and smoke-test
VERSION=$(git describe --tags --always --dirty)
go build -ldflags "-X github.com/SAP-samples/sap-devs-cli/cmd.Version=${VERSION}" -o sap-devs .
./sap-devs config show
./sap-devs config token --help
./sap-devs init --helpExpected: config show includes github_token: (not set), config token --help shows the long description about when a token is needed.
- [ ] Step 3: Final commit if any last fixes needed, then push for CI
git log --oneline -8Verify all feature commits are present before raising a PR.