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
21 changes: 21 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
version: 2
updates:
# Maintain Go dependencies in the root go.mod
- package-ecosystem: "gomod"
directory: "/"
schedule:
interval: "weekly"
# Group minor and patch updates to reduce PR noise
groups:
go-dependencies:
patterns:
- "*"
update-types:
- "minor"
- "patch"

# Maintain GitHub Actions in .github/workflows
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
138 changes: 55 additions & 83 deletions cmd/cloudstic/cmd_auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,9 @@ import (
"flag"
"fmt"
"os"
"path/filepath"
"strings"

cloudstic "github.com/cloudstic/cli"
"github.com/cloudstic/cli/internal/paths"
)

func (r *runner) runAuth(ctx context.Context) int {
Expand Down Expand Up @@ -94,9 +92,12 @@ func (r *runner) runAuthNew(ctx context.Context) int {
name := fs.String("name", "", "Auth reference name")
provider := fs.String("provider", "", "Auth provider: google|onedrive")
googleCreds := fs.String("google-credentials", "", "Path to Google service account credentials JSON file")
googleCredsRef := fs.String("google-credentials-ref", "", "Secret reference to Google service account credentials JSON")
googleTokenFile := fs.String("google-token-file", "", "Path to Google OAuth token file")
googleTokenRef := fs.String("google-token-ref", "", "Secret reference to Google OAuth token")
onedriveClientID := fs.String("onedrive-client-id", "", "OneDrive OAuth client ID")
onedriveTokenFile := fs.String("onedrive-token-file", "", "Path to OneDrive OAuth token file")
onedriveTokenRef := fs.String("onedrive-token-ref", "", "Secret reference to OneDrive OAuth token")
_ = fs.Parse(reorderArgs(fs, os.Args[3:]))

if *name == "" {
Expand Down Expand Up @@ -129,44 +130,49 @@ func (r *runner) runAuthNew(ctx context.Context) int {

auth := cloudstic.ProfileAuth{Provider: *provider}
if *provider == "google" {
if *googleTokenFile == "" {
if *googleTokenFile == "" && *googleTokenRef == "" {
def := defaultAuthTokenRef("google", *name)
if r.canPrompt() {
def := defaultAuthTokenPath("google", *name)
v, err := r.promptLine(ctx, "Google token file path", def)
v, err := r.promptLine(ctx, "Google token storage (file path or secret ref)", def)
if err != nil {
return r.fail("Failed to read google token file path: %v", err)
return r.fail("Failed to read google token storage: %v", err)
}
if strings.Contains(v, "://") {
*googleTokenRef = v
} else {
*googleTokenFile = v
}
*googleTokenFile = v
}
if *googleTokenFile == "" {
*googleTokenFile = defaultAuthTokenPath("google", *name)
}
if *googleTokenFile == "" {
return r.fail("-google-token-file is required for provider=google")
if *googleTokenFile == "" && *googleTokenRef == "" {
*googleTokenRef = def
}
Comment thread
rmanibus marked this conversation as resolved.
}
auth.GoogleCreds = *googleCreds
auth.GoogleCredsRef = *googleCredsRef
auth.GoogleTokenFile = *googleTokenFile
auth.GoogleTokenRef = *googleTokenRef
}
if *provider == "onedrive" {
if *onedriveTokenFile == "" {
if *onedriveTokenFile == "" && *onedriveTokenRef == "" {
def := defaultAuthTokenRef("onedrive", *name)
if r.canPrompt() {
def := defaultAuthTokenPath("onedrive", *name)
v, err := r.promptLine(ctx, "OneDrive token file path", def)
v, err := r.promptLine(ctx, "OneDrive token storage (file path or secret ref)", def)
if err != nil {
return r.fail("Failed to read onedrive token file path: %v", err)
return r.fail("Failed to read onedrive token storage: %v", err)
}
if strings.Contains(v, "://") {
*onedriveTokenRef = v
} else {
*onedriveTokenFile = v
}
*onedriveTokenFile = v
}
if *onedriveTokenFile == "" {
*onedriveTokenFile = defaultAuthTokenPath("onedrive", *name)
}
if *onedriveTokenFile == "" {
return r.fail("-onedrive-token-file is required for provider=onedrive")
if *onedriveTokenFile == "" && *onedriveTokenRef == "" {
*onedriveTokenRef = def
}
}
auth.OneDriveClientID = *onedriveClientID
auth.OneDriveTokenFile = *onedriveTokenFile
auth.OneDriveTokenRef = *onedriveTokenRef
}

cfg, err := loadProfilesOrInit(*profilesFile)
Expand All @@ -193,81 +199,47 @@ func (r *runner) runAuthLogin(ctx context.Context) int {
if err != nil {
return r.fail("Failed to load profiles: %v", err)
}

if *name == "" {
if r.canPrompt() {
names := sortedKeys(cfg.Auth)
picked, pickErr := r.promptSelect(ctx, "Select auth entry", names)
if pickErr != nil {
return r.fail("Failed to select auth entry: %v", pickErr)
}
*name = picked
if !r.canPrompt() {
return r.fail("usage: cloudstic auth login [-profiles-file <path>] <name>")
}
if *name == "" {
return r.fail("-name is required")
names := sortedKeys(cfg.Auth)
picked, pickErr := r.promptSelect(ctx, "Select auth entry", names)
if pickErr != nil {
return r.fail("Failed to select auth entry: %v", pickErr)
}
*name = picked
}

auth, ok := cfg.Auth[*name]
if !ok {
return r.fail("Unknown auth %q", *name)
}

g := newAuthGlobalFlags()

switch auth.Provider {
case "google":
googleCreds := auth.GoogleCreds
if googleCreds == "" {
googleCreds = os.Getenv("GOOGLE_APPLICATION_CREDENTIALS")
}
src, err := initSource(ctx, initSourceOptions{
sourceURI: "gdrive:/",
googleCreds: googleCreds,
googleTokenFile: auth.GoogleTokenFile,
globalFlags: g,
})
if err != nil {
return r.fail("Failed to initialize Google auth source: %v", err)
}
_ = src.Info()
case "onedrive":
onedriveClientID := auth.OneDriveClientID
if onedriveClientID == "" {
onedriveClientID = os.Getenv("ONEDRIVE_CLIENT_ID")
}
src, err := initSource(ctx, initSourceOptions{
sourceURI: "onedrive:/",
onedriveClientID: onedriveClientID,
onedriveTokenFile: auth.OneDriveTokenFile,
globalFlags: g,
})
if err != nil {
return r.fail("Failed to initialize OneDrive auth source: %v", err)
}
_ = src.Info()
default:
return r.fail("Unsupported auth provider %q", auth.Provider)
src, err := initSource(ctx, initSourceOptions{
sourceURI: auth.Provider + "://auth",
googleCreds: auth.GoogleCreds,
googleCredsRef: auth.GoogleCredsRef,
googleTokenFile: auth.GoogleTokenFile,
googleTokenRef: auth.GoogleTokenRef,
onedriveClientID: auth.OneDriveClientID,
onedriveTokenFile: auth.OneDriveTokenFile,
onedriveTokenRef: auth.OneDriveTokenRef,
globalFlags: &globalFlags{}, // dummy
})
if err != nil {
return r.fail("Failed to initialize auth source: %v", err)
}

_, _ = fmt.Fprintf(r.out, "Auth %q is ready\n", *name)
return 0
}
info := src.Info()

func newAuthGlobalFlags() *globalFlags {
fs := flag.NewFlagSet("auth-login-flags", flag.ContinueOnError)
return addGlobalFlags(fs)
_, _ = fmt.Fprintf(r.out, "Successfully logged in as %s (%s)\n", info.Account, info.Type)
return 0
}

func defaultAuthTokenPath(provider, name string) string {
configDir, err := paths.ConfigDir()
if err != nil {
return ""
}
safeName := strings.ReplaceAll(strings.TrimSpace(name), " ", "-")
if safeName == "" {
safeName = "default"
func defaultAuthTokenRef(provider, name string) string {
if name == "" {
name = "default"
}
file := fmt.Sprintf("%s-%s_token.json", provider, safeName)
return filepath.Join(configDir, "tokens", file)
return "config-token://" + provider + "/" + name
}
76 changes: 52 additions & 24 deletions cmd/cloudstic/cmd_auth_test.go
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
package main

import (
"bufio"
"context"
"os"
"path/filepath"
"strings"
"testing"

cloudstic "github.com/cloudstic/cli"
)

func TestRunAuthNewAndListAndShow(t *testing.T) {
Expand Down Expand Up @@ -199,28 +202,18 @@ func TestRunAuthList_MissingFile(t *testing.T) {
}
}

func TestDefaultAuthTokenPath(t *testing.T) {
tmpDir := t.TempDir()
t.Setenv("CLOUDSTIC_CONFIG_DIR", tmpDir)

got := defaultAuthTokenPath("google", "work")
want := filepath.Join("tokens", "google-work_token.json")
if !strings.HasSuffix(got, want) {
t.Fatalf("expected path ending with %q, got %q", want, got)
func TestDefaultAuthTokenRef(t *testing.T) {
if got := defaultAuthTokenRef("google", "work"); got != "config-token://google/work" {
t.Fatalf("unexpected ref: %q", got)
}

// Empty name should use "default"
got = defaultAuthTokenPath("google", "")
want = filepath.Join("tokens", "google-default_token.json")
if !strings.HasSuffix(got, want) {
t.Fatalf("expected path ending with %q, got %q", want, got)
if got := defaultAuthTokenRef("google", ""); got != "config-token://google/default" {
t.Fatalf("unexpected default ref for empty name: %q", got)
}
}

func TestRunAuthNew_OneDriveDerivesTokenFile(t *testing.T) {
func TestRunAuthNew_OneDriveDerivesTokenRef(t *testing.T) {
tmpDir := t.TempDir()
profilesPath := filepath.Join(tmpDir, "profiles.yaml")
t.Setenv("CLOUDSTIC_CONFIG_DIR", tmpDir)

os.Args = []string{
"cloudstic", "auth", "new",
Expand All @@ -230,7 +223,7 @@ func TestRunAuthNew_OneDriveDerivesTokenFile(t *testing.T) {
}
var out strings.Builder
var errOut strings.Builder
r := &runner{out: &out, errOut: &errOut}
r := &runner{out: &out, errOut: &errOut, noPrompt: true}
if code := r.runAuth(context.Background()); code != 0 {
t.Fatalf("auth new failed: %s", errOut.String())
}
Expand All @@ -239,28 +232,63 @@ func TestRunAuthNew_OneDriveDerivesTokenFile(t *testing.T) {
if err != nil {
t.Fatalf("read profiles file: %v", err)
}
if !strings.Contains(string(raw), "onedrive-od-work_token.json") {
t.Fatalf("expected derived onedrive token file path in profiles file:\n%s", string(raw))
if !strings.Contains(string(raw), "onedrive_token_ref: config-token://onedrive/od-work") {
t.Fatalf("expected derived onedrive token ref in profiles file:\n%s", string(raw))
}
}

func TestRunAuthNew_DerivesDefaultTokenFile(t *testing.T) {
func TestRunAuthNew_DerivesDefaultTokenRef(t *testing.T) {
tmpDir := t.TempDir()
profilesPath := filepath.Join(tmpDir, "profiles.yaml")
t.Setenv("CLOUDSTIC_CONFIG_DIR", tmpDir)

os.Args = []string{"cloudstic", "auth", "new", "-profiles-file", profilesPath, "-name", "g", "-provider", "google"}
var out strings.Builder
var errOut strings.Builder
r := &runner{out: &out, errOut: &errOut}
r := &runner{out: &out, errOut: &errOut, noPrompt: true}
if code := r.runAuth(context.Background()); code != 0 {
t.Fatalf("auth new failed: %s", errOut.String())
}
raw, err := os.ReadFile(profilesPath)
if err != nil {
t.Fatalf("read profiles file: %v", err)
}
if !strings.Contains(string(raw), "google-g_token.json") {
t.Fatalf("expected derived token file path in profiles file:\n%s", string(raw))
if !strings.Contains(string(raw), "google_token_ref: config-token://google/g") {
t.Fatalf("expected derived token ref in profiles file:\n%s", string(raw))
}
}

func TestPromptAuthSelection_DerivesTokenRef(t *testing.T) {
tmpDir := t.TempDir()
t.Setenv("CLOUDSTIC_CONFIG_DIR", tmpDir)
cfg := &cloudstic.ProfilesConfig{
Auth: make(map[string]cloudstic.ProfileAuth),
}

r := &runner{
out: &strings.Builder{},
errOut: &strings.Builder{},
// Mock prompt interactions:
// 1. Select option: "Create new auth"
// 2. Auth name: "my-google"
// 3. Token storage: use default (config-token://google/my-google)
lineIn: bufio.NewReader(strings.NewReader("1\nmy-google\n\n")),
}

ctx := context.Background()
name, code := r.promptAuthSelection(ctx, cfg, "google", "test-profile")
if code != 0 {
t.Fatalf("promptAuthSelection failed with code %d", code)
}
if name != "my-google" {
t.Fatalf("expected name 'my-google', got %q", name)
}

auth, ok := cfg.Auth["my-google"]
if !ok {
t.Fatal("auth entry not created in config")
}
expectedRef := "config-token://google/my-google"
if auth.GoogleTokenRef != expectedRef {
t.Fatalf("expected token ref %q, got %q", expectedRef, auth.GoogleTokenRef)
}
}
Loading
Loading