From 810d4da01d724e9c0e6dd637800c814e63ec2604 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Thu, 15 Oct 2026 21:12:59 +0100 Subject: [PATCH 1/3] feat(cli): honour XDG_CONFIG_HOME for the config directory Users who keep their home directory clean expect CLIs to store config under XDG_CONFIG_HOME. The directory is now resolved once: an existing ~/.appwrite keeps winning so current installs never lose their sessions, otherwise an absolute XDG_CONFIG_HOME selects $XDG_CONFIG_HOME/appwrite, and ~/.appwrite stays the default. The update-check cache and APNs session directory follow the same resolution as prefs.json so nothing is split across two places. The e2e harness pins XDG_CONFIG_HOME inside its sandboxed HOME so an inherited value can never point it at real credentials. --- templates/cli/internal/app/install.go.twig | 5 +- templates/cli/internal/cmd/fanout_test.go | 1 + templates/cli/internal/config/config_test.go | 78 ++++++++++++++++++++ templates/cli/internal/config/global.go | 22 +++++- tests/e2e/languages/cli/main.go | 1 + 5 files changed, 103 insertions(+), 4 deletions(-) diff --git a/templates/cli/internal/app/install.go.twig b/templates/cli/internal/app/install.go.twig index 3a67a53e63..f03069689d 100644 --- a/templates/cli/internal/app/install.go.twig +++ b/templates/cli/internal/app/install.go.twig @@ -7,6 +7,7 @@ import ( "runtime" "strings" + "github.com/{{ sdk.gitUserName }}/{{ sdk.gitRepoName | caseDash }}/internal/config" "github.com/{{ sdk.gitUserName }}/{{ sdk.gitRepoName | caseDash }}/internal/update" ) @@ -165,7 +166,7 @@ func UpdateChecker() *update.Checker { return nil } - home, err := os.UserHomeDir() + directory, err := config.GlobalDirectory(ExecutableName) if err != nil { return nil } @@ -174,7 +175,7 @@ func UpdateChecker() *update.Checker { RegistryURL: NPMRegistryURL, PrereleaseRegistryURL: NPMPrereleaseRegistryURL, // Beside the preferences, which is already the directory this CLI owns. - CachePath: filepath.Join(home, "."+ExecutableName, "update-check.json"), + CachePath: filepath.Join(directory, "update-check.json"), Current: Version, } } diff --git a/templates/cli/internal/cmd/fanout_test.go b/templates/cli/internal/cmd/fanout_test.go index 4d007a8d8d..413d38748c 100644 --- a/templates/cli/internal/cmd/fanout_test.go +++ b/templates/cli/internal/cmd/fanout_test.go @@ -118,6 +118,7 @@ func TestAllSubcommandsNeverPrompt(t *testing.T) { home := t.TempDir() t.Setenv("HOME", home) t.Setenv("USERPROFILE", home) + t.Setenv("XDG_CONFIG_HOME", "") inDirectory(t, t.TempDir()) root := NewRootCommand() diff --git a/templates/cli/internal/config/config_test.go b/templates/cli/internal/config/config_test.go index f6bf0a4a01..7b59c2c0a6 100644 --- a/templates/cli/internal/config/config_test.go +++ b/templates/cli/internal/config/config_test.go @@ -394,3 +394,81 @@ func TestMigrateLegacySessionIgnoresAPartialOrModernFile(t *testing.T) { }) } } + +func TestGlobalPathFollowsXDGConfigHome(t *testing.T) { + cases := []struct { + name string + legacy bool + xdg func(home string) string + want func(home, xdg string) string + }{ + { + name: "existing home directory wins over XDG", + legacy: true, + xdg: func(home string) string { return filepath.Join(home, "xdg") }, + want: func(home, _ string) string { return filepath.Join(home, ".appwrite", "prefs.json") }, + }, + { + name: "absolute XDG_CONFIG_HOME", + xdg: func(home string) string { return filepath.Join(home, "xdg") }, + want: func(_, xdg string) string { return filepath.Join(xdg, "appwrite", "prefs.json") }, + }, + { + name: "unset XDG_CONFIG_HOME", + xdg: func(string) string { return "" }, + want: func(home, _ string) string { return filepath.Join(home, ".appwrite", "prefs.json") }, + }, + { + name: "relative XDG_CONFIG_HOME", + xdg: func(string) string { return filepath.Join("relative", "config") }, + want: func(home, _ string) string { return filepath.Join(home, ".appwrite", "prefs.json") }, + }, + } + + for _, test := range cases { + t.Run(test.name, func(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + xdg := test.xdg(home) + t.Setenv("XDG_CONFIG_HOME", xdg) + + if test.legacy { + if err := os.MkdirAll(filepath.Join(home, ".appwrite"), 0o700); err != nil { + t.Fatal(err) + } + } + + got, err := GlobalPath("appwrite") + if err != nil { + t.Fatal(err) + } + if want := test.want(home, xdg); got != want { + t.Errorf("GlobalPath = %q, want %q", got, want) + } + }) + } +} + +func TestGlobalWriteCreatesTheXDGDirectory(t *testing.T) { + home := t.TempDir() + xdg := filepath.Join(t.TempDir(), "config") + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + t.Setenv("XDG_CONFIG_HOME", xdg) + + path, err := GlobalPath("appwrite") + if err != nil { + t.Fatal(err) + } + if err := LoadGlobal(path).Write(); err != nil { + t.Fatal(err) + } + + if _, err := os.Stat(filepath.Join(xdg, "appwrite", "prefs.json")); err != nil { + t.Errorf("prefs.json was not written under XDG_CONFIG_HOME: %v", err) + } + if _, err := os.Stat(filepath.Join(home, ".appwrite")); !os.IsNotExist(err) { + t.Errorf("the home directory was created alongside XDG_CONFIG_HOME: %v", err) + } +} diff --git a/templates/cli/internal/config/global.go b/templates/cli/internal/config/global.go index a79c53aeaa..e1bc37b98a 100644 --- a/templates/cli/internal/config/global.go +++ b/templates/cli/internal/config/global.go @@ -52,7 +52,7 @@ var ignoredAttributes = map[string]bool{ PreferenceClientID: true, } -// Global is the user-level preferences file, `~/.appwrite/prefs.json`. +// Global is the user-level preferences file, `prefs.json` in GlobalDirectory. type Global struct { path string data *Object @@ -67,12 +67,30 @@ type Session struct { // GlobalPath returns the default preferences path for the current user. func GlobalPath(executableName string) (string, error) { + directory, err := GlobalDirectory(executableName) + if err != nil { + return "", err + } + + return filepath.Join(directory, "prefs.json"), nil +} + +func GlobalDirectory(executableName string) (string, error) { home, err := homeDir() if err != nil { return "", err } - return filepath.Join(home, "."+executableName, "prefs.json"), nil + legacy := filepath.Join(home, "."+executableName) + if info, err := os.Stat(legacy); err == nil && info.IsDir() { + return legacy, nil + } + + if xdg := os.Getenv("XDG_CONFIG_HOME"); filepath.IsAbs(xdg) { + return filepath.Join(xdg, executableName), nil + } + + return legacy, nil } // LoadGlobal reads the preferences file. A missing or unparseable file yields diff --git a/tests/e2e/languages/cli/main.go b/tests/e2e/languages/cli/main.go index e98ca79f54..d6d2687158 100644 --- a/tests/e2e/languages/cli/main.go +++ b/tests/e2e/languages/cli/main.go @@ -115,6 +115,7 @@ func (h *harness) command(args ...string) *exec.Cmd { command.Env = append(os.Environ(), "HOME="+h.home, "USERPROFILE="+h.home, + "XDG_CONFIG_HOME="+filepath.Join(h.home, ".config"), ) return command From 7f66250f31c3d0e6fd243bd61728da70a5e0f4e8 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Fri, 16 Oct 2026 08:39:31 +0100 Subject: [PATCH 2/3] fix(cli): fall back to ~/.appwrite when XDG_CONFIG_HOME does not exist Issue #131 asks for the home directory whenever the XDG base directory is missing, rather than creating it. Tests that sandbox HOME now clear XDG_CONFIG_HOME too, so a developer's own XDG preferences cannot leak into them. --- templates/cli/internal/cmd/completion_test.go | 1 + templates/cli/internal/cmd/generate_test.go | 1 + templates/cli/internal/config/config_test.go | 22 ++++++++++++++----- templates/cli/internal/config/global.go | 10 ++++++--- 4 files changed, 26 insertions(+), 8 deletions(-) diff --git a/templates/cli/internal/cmd/completion_test.go b/templates/cli/internal/cmd/completion_test.go index 66524c499f..25819766ac 100644 --- a/templates/cli/internal/cmd/completion_test.go +++ b/templates/cli/internal/cmd/completion_test.go @@ -23,6 +23,7 @@ func setHome(t *testing.T) string { home := t.TempDir() t.Setenv("HOME", home) t.Setenv("USERPROFILE", home) + t.Setenv("XDG_CONFIG_HOME", "") return home } diff --git a/templates/cli/internal/cmd/generate_test.go b/templates/cli/internal/cmd/generate_test.go index 6520d341dd..5ebe9b470b 100644 --- a/templates/cli/internal/cmd/generate_test.go +++ b/templates/cli/internal/cmd/generate_test.go @@ -44,6 +44,7 @@ func TestGenerateEndpointFallsBackToTheDefault(t *testing.T) { home := t.TempDir() t.Setenv("HOME", home) t.Setenv("USERPROFILE", home) + t.Setenv("XDG_CONFIG_HOME", "") if got := resolveGenerateEndpoint(""); got != config.DefaultEndpoint { t.Errorf("endpoint = %q, want %q", got, config.DefaultEndpoint) diff --git a/templates/cli/internal/config/config_test.go b/templates/cli/internal/config/config_test.go index 7b59c2c0a6..ffbcf8ac8b 100644 --- a/templates/cli/internal/config/config_test.go +++ b/templates/cli/internal/config/config_test.go @@ -397,10 +397,11 @@ func TestMigrateLegacySessionIgnoresAPartialOrModernFile(t *testing.T) { func TestGlobalPathFollowsXDGConfigHome(t *testing.T) { cases := []struct { - name string - legacy bool - xdg func(home string) string - want func(home, xdg string) string + name string + legacy bool + missing bool + xdg func(home string) string + want func(home, xdg string) string }{ { name: "existing home directory wins over XDG", @@ -413,6 +414,12 @@ func TestGlobalPathFollowsXDGConfigHome(t *testing.T) { xdg: func(home string) string { return filepath.Join(home, "xdg") }, want: func(_, xdg string) string { return filepath.Join(xdg, "appwrite", "prefs.json") }, }, + { + name: "missing XDG_CONFIG_HOME directory", + missing: true, + xdg: func(home string) string { return filepath.Join(home, "xdg") }, + want: func(home, _ string) string { return filepath.Join(home, ".appwrite", "prefs.json") }, + }, { name: "unset XDG_CONFIG_HOME", xdg: func(string) string { return "" }, @@ -433,6 +440,11 @@ func TestGlobalPathFollowsXDGConfigHome(t *testing.T) { xdg := test.xdg(home) t.Setenv("XDG_CONFIG_HOME", xdg) + if filepath.IsAbs(xdg) && !test.missing { + if err := os.MkdirAll(xdg, 0o700); err != nil { + t.Fatal(err) + } + } if test.legacy { if err := os.MkdirAll(filepath.Join(home, ".appwrite"), 0o700); err != nil { t.Fatal(err) @@ -452,7 +464,7 @@ func TestGlobalPathFollowsXDGConfigHome(t *testing.T) { func TestGlobalWriteCreatesTheXDGDirectory(t *testing.T) { home := t.TempDir() - xdg := filepath.Join(t.TempDir(), "config") + xdg := t.TempDir() t.Setenv("HOME", home) t.Setenv("USERPROFILE", home) t.Setenv("XDG_CONFIG_HOME", xdg) diff --git a/templates/cli/internal/config/global.go b/templates/cli/internal/config/global.go index e1bc37b98a..e4fe903cc0 100644 --- a/templates/cli/internal/config/global.go +++ b/templates/cli/internal/config/global.go @@ -86,11 +86,15 @@ func GlobalDirectory(executableName string) (string, error) { return legacy, nil } - if xdg := os.Getenv("XDG_CONFIG_HOME"); filepath.IsAbs(xdg) { - return filepath.Join(xdg, executableName), nil + xdg := os.Getenv("XDG_CONFIG_HOME") + if !filepath.IsAbs(xdg) { + return legacy, nil + } + if info, err := os.Stat(xdg); err != nil || !info.IsDir() { + return legacy, nil } - return legacy, nil + return filepath.Join(xdg, executableName), nil } // LoadGlobal reads the preferences file. A missing or unparseable file yields From 5900c5d59d30f14aa8938c9757acebd7e135db74 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Fri, 16 Oct 2026 08:43:32 +0100 Subject: [PATCH 3/3] fix(cli): create the XDG config directory instead of falling back Falling back to ~/.appwrite when XDG_CONFIG_HOME does not exist yet sends a user who points it at a new path to the home directory, and once ~/.appwrite exists it wins from then on. Apps create their XDG directories, so the missing-directory case now pins that and #131's fallback wording is deliberately not followed. --- templates/cli/internal/config/config_test.go | 4 ++-- templates/cli/internal/config/global.go | 10 +++------- 2 files changed, 5 insertions(+), 9 deletions(-) diff --git a/templates/cli/internal/config/config_test.go b/templates/cli/internal/config/config_test.go index ffbcf8ac8b..8405e2e0e8 100644 --- a/templates/cli/internal/config/config_test.go +++ b/templates/cli/internal/config/config_test.go @@ -418,7 +418,7 @@ func TestGlobalPathFollowsXDGConfigHome(t *testing.T) { name: "missing XDG_CONFIG_HOME directory", missing: true, xdg: func(home string) string { return filepath.Join(home, "xdg") }, - want: func(home, _ string) string { return filepath.Join(home, ".appwrite", "prefs.json") }, + want: func(_, xdg string) string { return filepath.Join(xdg, "appwrite", "prefs.json") }, }, { name: "unset XDG_CONFIG_HOME", @@ -464,7 +464,7 @@ func TestGlobalPathFollowsXDGConfigHome(t *testing.T) { func TestGlobalWriteCreatesTheXDGDirectory(t *testing.T) { home := t.TempDir() - xdg := t.TempDir() + xdg := filepath.Join(t.TempDir(), "config") t.Setenv("HOME", home) t.Setenv("USERPROFILE", home) t.Setenv("XDG_CONFIG_HOME", xdg) diff --git a/templates/cli/internal/config/global.go b/templates/cli/internal/config/global.go index e4fe903cc0..e1bc37b98a 100644 --- a/templates/cli/internal/config/global.go +++ b/templates/cli/internal/config/global.go @@ -86,15 +86,11 @@ func GlobalDirectory(executableName string) (string, error) { return legacy, nil } - xdg := os.Getenv("XDG_CONFIG_HOME") - if !filepath.IsAbs(xdg) { - return legacy, nil - } - if info, err := os.Stat(xdg); err != nil || !info.IsDir() { - return legacy, nil + if xdg := os.Getenv("XDG_CONFIG_HOME"); filepath.IsAbs(xdg) { + return filepath.Join(xdg, executableName), nil } - return filepath.Join(xdg, executableName), nil + return legacy, nil } // LoadGlobal reads the preferences file. A missing or unparseable file yields