From 446f66b263d3098c42e315c820050a43a64aec22 Mon Sep 17 00:00:00 2001 From: Nimish Date: Thu, 2 Apr 2026 16:27:52 +0800 Subject: [PATCH 1/6] feat: add offline support with automatic secret caching Configure the SDK's OfflineConfig in NewPhase to enable transparent caching of encrypted API responses. Users set PHASE_OFFLINE=1 to serve secrets from cache when network is unavailable. - Always store wrapped_key_share after auth (remove OfflineEnabled gate) - pkg/offline: IsOffline() and CacheDir() helpers - pkg/phase: configure SDK OfflineConfig from PHASE_OFFLINE env var - pkg/errors: network error messages include PHASE_OFFLINE=1 hint - go.mod: local replace directive for SDK development Depends on: phasehq/golang-sdk#21 --- src/cmd/auth.go | 2 +- src/cmd/auth_aws.go | 2 +- src/cmd/auth_webauth.go | 2 +- src/go.mod | 2 ++ src/pkg/errors/errors.go | 9 +++++---- src/pkg/offline/offline.go | 18 ++++++++++++++++++ src/pkg/phase/phase.go | 26 +++++++++++++++++++++++++- 7 files changed, 53 insertions(+), 8 deletions(-) create mode 100644 src/pkg/offline/offline.go diff --git a/src/cmd/auth.go b/src/cmd/auth.go index 6d241ecf..2ed2e712 100644 --- a/src/cmd/auth.go +++ b/src/cmd/auth.go @@ -138,7 +138,7 @@ func runTokenAuth(cmd *cobra.Command, host string) error { } var wrappedKeyShare *string - if userData.OfflineEnabled && userData.WrappedKeyShare != "" { + if userData.WrappedKeyShare != "" { wrappedKeyShare = &userData.WrappedKeyShare } diff --git a/src/cmd/auth_aws.go b/src/cmd/auth_aws.go index 2deb6169..ea2245c2 100644 --- a/src/cmd/auth_aws.go +++ b/src/cmd/auth_aws.go @@ -161,7 +161,7 @@ func runAWSIAMAuth(cmd *cobra.Command, host string) error { } var wrappedKeyShare *string - if userData.OfflineEnabled && userData.WrappedKeyShare != "" { + if userData.WrappedKeyShare != "" { wrappedKeyShare = &userData.WrappedKeyShare } diff --git a/src/cmd/auth_webauth.go b/src/cmd/auth_webauth.go index 062f33ab..780e72ec 100644 --- a/src/cmd/auth_webauth.go +++ b/src/cmd/auth_webauth.go @@ -161,7 +161,7 @@ func runWebAuth(cmd *cobra.Command, host string) error { } var wrappedKeyShare *string - if userData.OfflineEnabled && userData.WrappedKeyShare != "" { + if userData.WrappedKeyShare != "" { wrappedKeyShare = &userData.WrappedKeyShare } diff --git a/src/go.mod b/src/go.mod index bce45b2f..cf0aa9d7 100644 --- a/src/go.mod +++ b/src/go.mod @@ -13,6 +13,8 @@ require ( gopkg.in/yaml.v3 v3.0.1 ) +replace github.com/phasehq/golang-sdk/v2 => ../../golang-sdk + require ( al.essio.dev/pkg/shellescape v1.5.1 // indirect github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0 // indirect diff --git a/src/pkg/errors/errors.go b/src/pkg/errors/errors.go index f3db077f..ca93c037 100644 --- a/src/pkg/errors/errors.go +++ b/src/pkg/errors/errors.go @@ -12,15 +12,16 @@ import ( func FormatSDKError(err error) string { var netErr *network.NetworkError if errors.As(err, &netErr) { + const offlineHint = ". Please set PHASE_OFFLINE=1 to use previously available cached data." switch netErr.Kind { case "dns": - return fmt.Sprintf("🗿 Network error: Could not resolve host '%s'. Please check the Phase host URL and your connection", netErr.Host) + return fmt.Sprintf("🗿 Network error: Could not resolve host '%s'%s", netErr.Host, offlineHint) case "connection": - return "🗿 Network error: Could not connect to the Phase host. Please check that the server is running and the host URL is correct" + return "🗿 Network error: Could not connect to the Phase host" + offlineHint case "timeout": - return "🗿 Network error: Request timed out. Please check your connection and try again" + return "🗿 Network error: Request timed out" + offlineHint default: - return fmt.Sprintf("🗿 Network error: %s", netErr.Detail) + return fmt.Sprintf("🗿 Network error: %s%s", netErr.Detail, offlineHint) } } diff --git a/src/pkg/offline/offline.go b/src/pkg/offline/offline.go new file mode 100644 index 00000000..b0bc9789 --- /dev/null +++ b/src/pkg/offline/offline.go @@ -0,0 +1,18 @@ +package offline + +import ( + "os" + "path/filepath" + "strings" +) + +// CacheDir returns the offline cache directory for a given account ID. +func CacheDir(secretsDir, accountID string) string { + return filepath.Join(secretsDir, "offline", accountID) +} + +// IsOffline returns true if PHASE_OFFLINE is set to "1" or "true". +func IsOffline() bool { + v := os.Getenv("PHASE_OFFLINE") + return v == "1" || strings.EqualFold(v, "true") +} diff --git a/src/pkg/phase/phase.go b/src/pkg/phase/phase.go index 581d37f5..9a62b909 100644 --- a/src/pkg/phase/phase.go +++ b/src/pkg/phase/phase.go @@ -10,6 +10,7 @@ import ( "github.com/phasehq/cli/pkg/ai" "github.com/phasehq/cli/pkg/config" "github.com/phasehq/cli/pkg/keyring" + "github.com/phasehq/cli/pkg/offline" "github.com/phasehq/cli/pkg/version" sdk "github.com/phasehq/golang-sdk/v2/phase" "github.com/phasehq/golang-sdk/v2/phase/misc" @@ -37,7 +38,20 @@ func NewPhase(init bool, pss string, host string) (*sdk.Phase, error) { setUserAgent() - return sdk.New(pss, host, false) + p, err := sdk.New(pss, host, false) + if err != nil { + return nil, err + } + + // Configure offline: always cache on success, only serve from cache when PHASE_OFFLINE=1 + if cacheDir := getCacheDir(); cacheDir != "" { + p.SetOfflineConfig(&sdk.OfflineConfig{ + CacheDir: cacheDir, + Offline: offline.IsOffline(), + }) + } + + return p, nil } func setUserAgent() { @@ -125,6 +139,16 @@ func GetConfig(appName, envName, appID string) (string, string, string) { return appName, envName, appID } +// getCacheDir returns the offline cache directory for the current default user. +// Returns empty string if no user is configured (e.g. service token without config). +func getCacheDir() string { + user, err := config.GetDefaultUser() + if err != nil || user == nil { + return "" + } + return offline.CacheDir(config.PhaseSecretsDir, user.ID) +} + func coalesce(a, b string) string { if a != "" { return a From ac681e5d76b7c508618195023a4889fa22be5307 Mon Sep 17 00:00:00 2001 From: Nimish Date: Fri, 3 Apr 2026 14:21:00 +0800 Subject: [PATCH 2/6] fix: clear offline cache on user logout Regular logout (without --purge) was not cleaning up the user's offline cache directory. Now removes ~/.phase/secrets/offline/{id}/ when logging out a specific user. Uses offline.CacheDir() to keep the path definition in one place. --- src/cmd/users_logout.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/cmd/users_logout.go b/src/cmd/users_logout.go index 73bb4537..3e6a911f 100644 --- a/src/cmd/users_logout.go +++ b/src/cmd/users_logout.go @@ -7,6 +7,7 @@ import ( "github.com/phasehq/cli/pkg/config" "github.com/phasehq/cli/pkg/keyring" + "github.com/phasehq/cli/pkg/offline" "github.com/spf13/cobra" ) @@ -55,6 +56,10 @@ func runUsersLogout(cmd *cobra.Command, args []string) error { accountID := ids[0] keyring.DeleteCredentials(accountID) + // Clean up offline cache for this user + cacheDir := offline.CacheDir(config.PhaseSecretsDir, accountID) + os.RemoveAll(cacheDir) + if err := config.RemoveUser(accountID); err != nil { return fmt.Errorf("failed to update config: %w", err) } From a169bbabfb473cb8a616ca8cccce3e32c84f034d Mon Sep 17 00:00:00 2001 From: Nimish Date: Fri, 3 Apr 2026 14:34:50 +0800 Subject: [PATCH 3/6] fix: remove OfflineEnabled gate from Azure auth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Missed in the initial offline support commit — the other three auth files (auth.go, auth_aws.go, auth_webauth.go) were already updated. --- src/cmd/auth_azure.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/cmd/auth_azure.go b/src/cmd/auth_azure.go index 5805b9c6..0d2a734d 100644 --- a/src/cmd/auth_azure.go +++ b/src/cmd/auth_azure.go @@ -77,7 +77,7 @@ func runAzureAuth(cmd *cobra.Command, host string) error { } var wrappedKeyShare *string - if userData.OfflineEnabled && userData.WrappedKeyShare != "" { + if userData.WrappedKeyShare != "" { wrappedKeyShare = &userData.WrappedKeyShare } From 1c6745a2e2446c5bbe8c3c8264186c2432891669 Mon Sep 17 00:00:00 2001 From: Nimish Date: Fri, 3 Apr 2026 14:40:27 +0800 Subject: [PATCH 4/6] fix: soften offline hint in network error messages Avoid implying cached data exists when it may not (e.g. first-time users). Changed to "if available" phrasing. --- src/pkg/errors/errors.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pkg/errors/errors.go b/src/pkg/errors/errors.go index ca93c037..31cd44e4 100644 --- a/src/pkg/errors/errors.go +++ b/src/pkg/errors/errors.go @@ -12,7 +12,7 @@ import ( func FormatSDKError(err error) string { var netErr *network.NetworkError if errors.As(err, &netErr) { - const offlineHint = ". Please set PHASE_OFFLINE=1 to use previously available cached data." + const offlineHint = ". Set PHASE_OFFLINE=1 to use cached data if available." switch netErr.Kind { case "dns": return fmt.Sprintf("🗿 Network error: Could not resolve host '%s'%s", netErr.Host, offlineHint) From 8c3e5ee907130bf14516ca5cf869e3c700dc45a0 Mon Sep 17 00:00:00 2001 From: Nimish Date: Fri, 3 Apr 2026 21:29:02 +0800 Subject: [PATCH 5/6] fix: disable caching when PHASE_SERVICE_TOKEN env var is active Prevents cache pollution when ad-hoc env var auth uses a different identity than the logged-in config user. --- src/pkg/phase/phase.go | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/pkg/phase/phase.go b/src/pkg/phase/phase.go index 9a62b909..b7be89af 100644 --- a/src/pkg/phase/phase.go +++ b/src/pkg/phase/phase.go @@ -140,8 +140,13 @@ func GetConfig(appName, envName, appID string) (string, string, string) { } // getCacheDir returns the offline cache directory for the current default user. -// Returns empty string if no user is configured (e.g. service token without config). +// Returns empty string if no user is configured or if ad-hoc env var auth is +// active (PHASE_SERVICE_TOKEN), since the token identity may differ from the +// config user and would pollute their cache. func getCacheDir() string { + if os.Getenv("PHASE_SERVICE_TOKEN") != "" { + return "" + } user, err := config.GetDefaultUser() if err != nil || user == nil { return "" From 7e8dfe0c49d7050a8cdb5bc7f9a817f9aebad63e Mon Sep 17 00:00:00 2001 From: Nimish Date: Sun, 5 Apr 2026 15:55:19 +0800 Subject: [PATCH 6/6] chore: handle golang sdk --- src/go.mod | 4 +--- src/go.sum | 2 ++ 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/go.mod b/src/go.mod index cf0aa9d7..b9718f17 100644 --- a/src/go.mod +++ b/src/go.mod @@ -6,15 +6,13 @@ require ( github.com/aws/aws-sdk-go-v2 v1.41.1 github.com/aws/aws-sdk-go-v2/config v1.32.7 github.com/manifoldco/promptui v0.9.0 - github.com/phasehq/golang-sdk/v2 v2.1.1 + github.com/phasehq/golang-sdk/v2 v2.2.0 github.com/spf13/cobra v1.8.0 github.com/zalando/go-keyring v0.2.6 golang.org/x/term v0.39.0 gopkg.in/yaml.v3 v3.0.1 ) -replace github.com/phasehq/golang-sdk/v2 => ../../golang-sdk - require ( al.essio.dev/pkg/shellescape v1.5.1 // indirect github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0 // indirect diff --git a/src/go.sum b/src/go.sum index 332c340e..09ef6ace 100644 --- a/src/go.sum +++ b/src/go.sum @@ -71,6 +71,8 @@ github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0 github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/manifoldco/promptui v0.9.0 h1:3V4HzJk1TtXW1MTZMP7mdlwbBpIinw3HztaIlYthEiA= github.com/manifoldco/promptui v0.9.0/go.mod h1:ka04sppxSGFAtxX0qhlYQjISsg9mR4GWtQEhdbn6Pgg= +github.com/phasehq/golang-sdk/v2 v2.2.0 h1:d6tpEeoSGZoxNukjq59rEGlevU+R1c4j7KacZls83cM= +github.com/phasehq/golang-sdk/v2 v2.2.0/go.mod h1:W7sFK8701qK1uuixHKOEIs9irqIUDvWPZGwg58zRyjE= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=