diff --git a/README.md b/README.md index 299d387..4c9c640 100644 --- a/README.md +++ b/README.md @@ -726,7 +726,7 @@ You can also download the binary manually from the release page or build from so ### Q: Where are credentials stored on Windows? -gitlink-cli uses Windows Credential Manager for secure token storage. If Credential Manager is unavailable, it automatically falls back to file storage (`~/.config/gitlink-cli/credentials`). +gitlink-cli uses Windows Credential Manager for secure token storage. If Credential Manager is unavailable, it automatically falls back to file storage under the gitlink-cli config directory (`$GITLINK_CONFIG_DIR/credentials` when set, otherwise `~/.config/gitlink-cli/credentials`). ### Q: Where can I find the full API reference? diff --git a/README.zh-CN.md b/README.zh-CN.md index a727d59..f26e753 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -601,7 +601,7 @@ npm install -g @gitlink-ai/cli ### Q: Windows 上凭证存储在哪里? -gitlink-cli 使用 Windows Credential Manager 安全存储 Token。如果 Credential Manager 不可用,会自动降级到文件存储(`~/.config/gitlink-cli/credentials`)。 +gitlink-cli 使用 Windows Credential Manager 安全存储 Token。如果 Credential Manager 不可用,会自动降级到配置目录下的文件存储;设置了 `GITLINK_CONFIG_DIR` 时路径为 `$GITLINK_CONFIG_DIR/credentials`,否则为 `~/.config/gitlink-cli/credentials`。 ### Q: 如何查看完整的 API 参考? diff --git a/cmd/auth/auth_test.go b/cmd/auth/auth_test.go index 87fc445..d09602b 100644 --- a/cmd/auth/auth_test.go +++ b/cmd/auth/auth_test.go @@ -3,6 +3,7 @@ package auth import ( "errors" "os" + "path/filepath" "testing" "github.com/spf13/cobra" @@ -11,6 +12,13 @@ import ( internalAuth "github.com/gitlink-org/gitlink-cli/internal/auth" ) +func tempConfigDir(t *testing.T) string { + t.Helper() + dir := t.TempDir() + t.Setenv("GITLINK_CONFIG_DIR", dir) + return dir +} + func TestEnvTokenVar(t *testing.T) { if envTokenVar != "GITLINK_TOKEN" { t.Fatalf("envTokenVar = %q, want GITLINK_TOKEN", envTokenVar) @@ -61,7 +69,7 @@ func TestLoginTokenFlag(t *testing.T) { func TestStatusCmdNotLoggedIn(t *testing.T) { keyring.MockInitWithError(errors.New("keychain unavailable")) - t.Setenv("HOME", t.TempDir()) + tempConfigDir(t) t.Setenv("GITLINK_TOKEN", "") _ = internalAuth.DeleteToken() @@ -76,7 +84,7 @@ func TestStatusCmdNotLoggedIn(t *testing.T) { func TestStatusCmdEnvToken(t *testing.T) { keyring.MockInitWithError(errors.New("keychain unavailable")) - t.Setenv("HOME", t.TempDir()) + tempConfigDir(t) t.Setenv("GITLINK_TOKEN", "env-token-123") _ = internalAuth.DeleteToken() @@ -86,12 +94,11 @@ func TestStatusCmdEnvToken(t *testing.T) { func TestStatusCmdStoredToken(t *testing.T) { keyring.MockInitWithError(errors.New("keychain unavailable")) - dir := t.TempDir() - t.Setenv("HOME", dir) + dir := tempConfigDir(t) t.Setenv("GITLINK_TOKEN", "") - os.MkdirAll(dir+"/.config/gitlink-cli", 0700) - os.WriteFile(dir+"/.config/gitlink-cli/credentials", []byte("cookie:test=abc"), 0600) + os.MkdirAll(dir, 0700) + os.WriteFile(filepath.Join(dir, "credentials"), []byte("cookie:test=abc"), 0600) cmd := findSub(NewAuthCmd(), "status") cmd.RunE(cmd, nil) @@ -99,12 +106,11 @@ func TestStatusCmdStoredToken(t *testing.T) { func TestStatusCmdEnvAndStoredToken(t *testing.T) { keyring.MockInitWithError(errors.New("keychain unavailable")) - dir := t.TempDir() - t.Setenv("HOME", dir) + dir := tempConfigDir(t) t.Setenv("GITLINK_TOKEN", "env-token") - os.MkdirAll(dir+"/.config/gitlink-cli", 0700) - os.WriteFile(dir+"/.config/gitlink-cli/credentials", []byte("stored-token"), 0600) + os.MkdirAll(dir, 0700) + os.WriteFile(filepath.Join(dir, "credentials"), []byte("stored-token"), 0600) cmd := findSub(NewAuthCmd(), "status") if err := cmd.RunE(cmd, nil); err != nil { @@ -114,9 +120,9 @@ func TestStatusCmdEnvAndStoredToken(t *testing.T) { func TestStatusCmdStoredTokenButLoadFails(t *testing.T) { keyring.MockInitWithError(errors.New("keychain unavailable")) - t.Setenv("HOME", t.TempDir()) + tempConfigDir(t) t.Setenv("GITLINK_TOKEN", "") - // Don't create credentials file — LoadToken returns empty + // Do not create credentials; LoadToken should return empty. cmd := findSub(NewAuthCmd(), "status") if err := cmd.RunE(cmd, nil); err != nil { @@ -124,29 +130,26 @@ func TestStatusCmdStoredTokenButLoadFails(t *testing.T) { } } -func TestLogoutCmdError(t *testing.T) { +func TestLogoutCmdNoStoredToken(t *testing.T) { keyring.MockInitWithError(errors.New("keychain unavailable")) - t.Setenv("HOME", t.TempDir()) + tempConfigDir(t) t.Setenv("GITLINK_TOKEN", "") - // Don't create credentials dir — DeleteToken will fail + // Do not create credentials; logout should be idempotent. cmd := findSub(NewAuthCmd(), "logout") - err := cmd.RunE(cmd, nil) - if err == nil { - t.Fatal("expected error when DeleteToken fails") + if err := cmd.RunE(cmd, nil); err != nil { + t.Fatalf("logout should succeed without stored token: %v", err) } } func TestLogoutCmd(t *testing.T) { keyring.MockInitWithError(errors.New("keychain unavailable")) - home := t.TempDir() - t.Setenv("HOME", home) + dir := tempConfigDir(t) t.Setenv("GITLINK_TOKEN", "") // Store a token first so DeleteToken has something to delete - credDir := home + "/.config/gitlink-cli" - os.MkdirAll(credDir, 0700) - os.WriteFile(credDir+"/credentials", []byte("some-token"), 0600) + os.MkdirAll(dir, 0700) + os.WriteFile(filepath.Join(dir, "credentials"), []byte("some-token"), 0600) cmd := findSub(NewAuthCmd(), "logout") if cmd == nil { @@ -159,8 +162,7 @@ func TestLogoutCmd(t *testing.T) { func TestLoginWithToken(t *testing.T) { keyring.MockInitWithError(errors.New("keychain unavailable")) - home := t.TempDir() - t.Setenv("HOME", home) + dir := tempConfigDir(t) t.Setenv("GITLINK_TOKEN", "") // Mock stdin @@ -185,7 +187,7 @@ func TestLoginWithToken(t *testing.T) { } // Verify token was saved to file - data, err := os.ReadFile(home + "/.config/gitlink-cli/credentials") + data, err := os.ReadFile(filepath.Join(dir, "credentials")) if err != nil { t.Fatalf("read credentials: %v", err) } @@ -196,7 +198,7 @@ func TestLoginWithToken(t *testing.T) { func TestLoginWithTokenEmpty(t *testing.T) { keyring.MockInitWithError(errors.New("keychain unavailable")) - t.Setenv("HOME", t.TempDir()) + tempConfigDir(t) t.Setenv("GITLINK_TOKEN", "") oldStdin := os.Stdin @@ -219,7 +221,7 @@ func TestLoginWithTokenEmpty(t *testing.T) { func TestLoginWithPasswordNoTerminal(t *testing.T) { keyring.MockInitWithError(errors.New("keychain unavailable")) - t.Setenv("HOME", t.TempDir()) + tempConfigDir(t) t.Setenv("GITLINK_TOKEN", "") // term.ReadPassword will fail because test has no terminal diff --git a/doc/changes/auth-config-dir-fallback.md b/doc/changes/auth-config-dir-fallback.md new file mode 100644 index 0000000..57d49dd --- /dev/null +++ b/doc/changes/auth-config-dir-fallback.md @@ -0,0 +1,7 @@ +# 认证凭据 fallback 配置目录一致性修复 + +`gitlink-cli` 的主配置文件已经支持通过 `GITLINK_CONFIG_DIR` 指定配置目录,但认证模块在系统 Keychain 不可用时仍然把 fallback 凭据写到用户 home 下的 `~/.config/gitlink-cli/credentials`。这会让 CI、Windows 测试、Agent 沙箱和多账号隔离场景出现配置目录与凭据目录不一致的问题,也会导致测试中设置临时 HOME 后仍读写真实用户目录。 + +本次修复让文件凭据路径统一复用 `internal/config.ConfigDir()`:设置 `GITLINK_CONFIG_DIR` 时,fallback 凭据保存到 `$GITLINK_CONFIG_DIR/credentials`;未设置时仍保持原有默认路径。`auth logout` 在 fallback 文件不存在时也改为幂等成功,避免用户已经没有本地凭据时退出登录反而报错。 + +测试同步改为使用 `GITLINK_CONFIG_DIR` 隔离凭据目录,覆盖默认配置目录、文件创建、保存/读取/删除、Keychain 不可用 fallback、无凭据登出等场景。该修复提升了跨平台稳定性,也让本地全量测试不再因为 Windows `HOME`/`USERPROFILE` 解析差异污染真实用户凭据目录。 diff --git a/internal/auth/token_store.go b/internal/auth/token_store.go index 35b260c..467e346 100644 --- a/internal/auth/token_store.go +++ b/internal/auth/token_store.go @@ -4,6 +4,7 @@ import ( "os" "path/filepath" + "github.com/gitlink-org/gitlink-cli/internal/config" "github.com/zalando/go-keyring" ) @@ -41,8 +42,7 @@ func DeleteToken() error { // File-based fallback func credentialPath() string { - home, _ := os.UserHomeDir() - return filepath.Join(home, ".config", "gitlink-cli", "credentials") + return filepath.Join(config.ConfigDir(), "credentials") } func storeTokenFile(token string) error { @@ -62,5 +62,9 @@ func loadTokenFile() (string, error) { } func deleteTokenFile() error { - return os.Remove(credentialPath()) + err := os.Remove(credentialPath()) + if os.IsNotExist(err) { + return nil + } + return err } diff --git a/internal/auth/token_store_test.go b/internal/auth/token_store_test.go index 49bcc5c..af57b11 100644 --- a/internal/auth/token_store_test.go +++ b/internal/auth/token_store_test.go @@ -13,11 +13,19 @@ func tempHome(t *testing.T) string { t.Helper() dir := t.TempDir() t.Setenv("HOME", dir) + t.Setenv("USERPROFILE", dir) + return dir +} + +func tempConfigDir(t *testing.T) string { + t.Helper() + dir := t.TempDir() + t.Setenv("GITLINK_CONFIG_DIR", dir) return dir } func TestStoreLoadDeleteTokenFile(t *testing.T) { - tempHome(t) + tempConfigDir(t) // First, delete any existing token _ = deleteTokenFile() @@ -55,20 +63,31 @@ func TestStoreLoadDeleteTokenFile(t *testing.T) { } func TestCredentialPath(t *testing.T) { - tempHome(t) + dir := tempConfigDir(t) got := credentialPath() - expected := filepath.Join(os.Getenv("HOME"), ".config", "gitlink-cli", "credentials") + expected := filepath.Join(dir, "credentials") + if got != expected { + t.Fatalf("credentialPath = %q, want %q", got, expected) + } +} + +func TestCredentialPathDefaultsToConfigDir(t *testing.T) { + home := tempHome(t) + t.Setenv("GITLINK_CONFIG_DIR", "") + + got := credentialPath() + expected := filepath.Join(home, ".config", "gitlink-cli", "credentials") if got != expected { t.Fatalf("credentialPath = %q, want %q", got, expected) } } func TestStoreTokenFileCreatesDir(t *testing.T) { - home := tempHome(t) + dir := tempConfigDir(t) _ = deleteTokenFile() // Config dir shouldn't exist yet - credDir := filepath.Join(home, ".config", "gitlink-cli") + credDir := dir os.RemoveAll(credDir) if err := storeTokenFile("new-token"); err != nil { @@ -86,17 +105,16 @@ func TestStoreTokenFileCreatesDir(t *testing.T) { } func TestDeleteTokenFileNonExistent(t *testing.T) { - tempHome(t) + tempConfigDir(t) _ = deleteTokenFile() - // Deleting non-existent file should return an error from os.Remove - err := deleteTokenFile() - if err == nil { - t.Fatal("expected error deleting non-existent file") + // Logout-style cleanup should be idempotent when fallback credentials do not exist. + if err := deleteTokenFile(); err != nil { + t.Fatalf("deleteTokenFile error: %v", err) } } func TestStoreLoadTokenFileEmpty(t *testing.T) { - tempHome(t) + tempConfigDir(t) _ = deleteTokenFile() if err := storeTokenFile(""); err != nil { @@ -114,14 +132,14 @@ func TestStoreLoadTokenFileEmpty(t *testing.T) { func TestStoreTokenFallback(t *testing.T) { keyring.MockInitWithError(errors.New("keychain unavailable")) - home := tempHome(t) + dir := tempConfigDir(t) _ = deleteTokenFile() if err := StoreToken("keychain-fallback-token"); err != nil { t.Fatalf("StoreToken error: %v", err) } - data, err := os.ReadFile(filepath.Join(home, ".config", "gitlink-cli", "credentials")) + data, err := os.ReadFile(filepath.Join(dir, "credentials")) if err != nil { t.Fatalf("read error: %v", err) } @@ -132,10 +150,10 @@ func TestStoreTokenFallback(t *testing.T) { func TestDeleteTokenFallback(t *testing.T) { keyring.MockInitWithError(errors.New("keychain unavailable")) - home := tempHome(t) + dir := tempConfigDir(t) _ = deleteTokenFile() - p := filepath.Join(home, ".config", "gitlink-cli", "credentials") + p := filepath.Join(dir, "credentials") os.MkdirAll(filepath.Dir(p), 0700) os.WriteFile(p, []byte("delete-me"), 0600) diff --git a/skills/gitlink-shared/SKILL.md b/skills/gitlink-shared/SKILL.md index d1f9d33..8312933 100644 --- a/skills/gitlink-shared/SKILL.md +++ b/skills/gitlink-shared/SKILL.md @@ -34,7 +34,7 @@ gitlink-cli auth logout - GitLink Token 有效期 **7 天**,过期需重新登录 - Token 存储在 OS Keychain(macOS Keychain / Linux Secret Service / Windows Credential Manager) -- Fallback 存储:`~/.config/gitlink-cli/credentials` +- Fallback 存储:`$GITLINK_CONFIG_DIR/credentials`(未设置时为 `~/.config/gitlink-cli/credentials`) ### 认证错误处理