From 6e3ddb5f325759fcf44358fd047022349a7bd9c1 Mon Sep 17 00:00:00 2001 From: Nimish Date: Sun, 8 Feb 2026 19:40:37 +0530 Subject: [PATCH 01/50] feat: golang src --- src/cmd/auth.go | 182 +++++++ src/cmd/auth_aws.go | 202 +++++++ src/cmd/auth_webauth.go | 200 +++++++ src/cmd/console.go | 56 ++ src/cmd/docs.go | 24 + src/cmd/dynamic_secrets.go | 20 + src/cmd/dynamic_secrets_lease_generate.go | 67 +++ src/cmd/dynamic_secrets_lease_get.go | 60 ++ src/cmd/dynamic_secrets_lease_renew.go | 66 +++ src/cmd/dynamic_secrets_lease_revoke.go | 60 ++ src/cmd/dynamic_secrets_list.go | 61 +++ src/cmd/init_cmd.go | 133 +++++ src/cmd/mcp.go | 23 + src/cmd/mcp_install.go | 51 ++ src/cmd/mcp_serve.go | 27 + src/cmd/mcp_uninstall.go | 47 ++ src/cmd/root.go | 51 ++ src/cmd/run.go | 148 +++++ src/cmd/secrets.go | 14 + src/cmd/secrets_create.go | 112 ++++ src/cmd/secrets_delete.go | 71 +++ src/cmd/secrets_export.go | 101 ++++ src/cmd/secrets_get.go | 81 +++ src/cmd/secrets_import.go | 71 +++ src/cmd/secrets_list.go | 79 +++ src/cmd/secrets_update.go | 124 +++++ src/cmd/shell.go | 169 ++++++ src/cmd/update.go | 74 +++ src/cmd/users.go | 14 + src/cmd/users_keyring.go | 32 ++ src/cmd/users_logout.go | 63 +++ src/cmd/users_switch.go | 78 +++ src/cmd/users_whoami.go | 40 ++ src/go.mod | 43 ++ src/go.sum | 87 +++ src/main.go | 7 + src/pkg/config/config.go | 207 +++++++ src/pkg/config/phase_json.go | 64 +++ src/pkg/display/tree.go | 208 +++++++ src/pkg/keyring/keyring.go | 49 ++ src/pkg/network/network.go | 254 +++++++++ src/pkg/phase/phase.go | 276 ++++++++++ src/pkg/phase/secret_referencing.go | 255 +++++++++ src/pkg/phase/secrets.go | 637 ++++++++++++++++++++++ src/pkg/util/browser.go | 20 + src/pkg/util/color.go | 70 +++ src/pkg/util/export.go | 83 +++ src/pkg/util/misc.go | 209 +++++++ src/pkg/util/spinner.go | 69 +++ 49 files changed, 5139 insertions(+) create mode 100644 src/cmd/auth.go create mode 100644 src/cmd/auth_aws.go create mode 100644 src/cmd/auth_webauth.go create mode 100644 src/cmd/console.go create mode 100644 src/cmd/docs.go create mode 100644 src/cmd/dynamic_secrets.go create mode 100644 src/cmd/dynamic_secrets_lease_generate.go create mode 100644 src/cmd/dynamic_secrets_lease_get.go create mode 100644 src/cmd/dynamic_secrets_lease_renew.go create mode 100644 src/cmd/dynamic_secrets_lease_revoke.go create mode 100644 src/cmd/dynamic_secrets_list.go create mode 100644 src/cmd/init_cmd.go create mode 100644 src/cmd/mcp.go create mode 100644 src/cmd/mcp_install.go create mode 100644 src/cmd/mcp_serve.go create mode 100644 src/cmd/mcp_uninstall.go create mode 100644 src/cmd/root.go create mode 100644 src/cmd/run.go create mode 100644 src/cmd/secrets.go create mode 100644 src/cmd/secrets_create.go create mode 100644 src/cmd/secrets_delete.go create mode 100644 src/cmd/secrets_export.go create mode 100644 src/cmd/secrets_get.go create mode 100644 src/cmd/secrets_import.go create mode 100644 src/cmd/secrets_list.go create mode 100644 src/cmd/secrets_update.go create mode 100644 src/cmd/shell.go create mode 100644 src/cmd/update.go create mode 100644 src/cmd/users.go create mode 100644 src/cmd/users_keyring.go create mode 100644 src/cmd/users_logout.go create mode 100644 src/cmd/users_switch.go create mode 100644 src/cmd/users_whoami.go create mode 100644 src/go.mod create mode 100644 src/go.sum create mode 100644 src/main.go create mode 100644 src/pkg/config/config.go create mode 100644 src/pkg/config/phase_json.go create mode 100644 src/pkg/display/tree.go create mode 100644 src/pkg/keyring/keyring.go create mode 100644 src/pkg/network/network.go create mode 100644 src/pkg/phase/phase.go create mode 100644 src/pkg/phase/secret_referencing.go create mode 100644 src/pkg/phase/secrets.go create mode 100644 src/pkg/util/browser.go create mode 100644 src/pkg/util/color.go create mode 100644 src/pkg/util/export.go create mode 100644 src/pkg/util/misc.go create mode 100644 src/pkg/util/spinner.go diff --git a/src/cmd/auth.go b/src/cmd/auth.go new file mode 100644 index 00000000..01f2cf72 --- /dev/null +++ b/src/cmd/auth.go @@ -0,0 +1,182 @@ +package cmd + +import ( + "fmt" + "os" + "strings" + "syscall" + + "github.com/manifoldco/promptui" + "github.com/phasehq/cli/pkg/config" + "github.com/phasehq/cli/pkg/keyring" + "github.com/phasehq/cli/pkg/phase" + "github.com/phasehq/cli/pkg/util" + "github.com/spf13/cobra" + "golang.org/x/term" +) + +var authCmd = &cobra.Command{ + Use: "auth", + Short: "💻 Authenticate with Phase", + RunE: runAuth, +} + +var authMode string + +func init() { + authCmd.Flags().StringVar(&authMode, "mode", "webauth", "Authentication mode (webauth, token, aws-iam)") + authCmd.Flags().String("service-account-id", "", "Service account ID (required for aws-iam mode)") + authCmd.Flags().Int("ttl", 0, "Token TTL in seconds (for aws-iam mode)") + authCmd.Flags().Bool("no-store", false, "Print token to stdout instead of storing (for aws-iam mode)") + rootCmd.AddCommand(authCmd) +} + +func runAuth(cmd *cobra.Command, args []string) error { + // Determine host + host := os.Getenv("PHASE_HOST") + if host == "" { + prompt := promptui.Select{ + Label: "Choose your Phase instance type", + Items: []string{"☁️ Phase Cloud", "🛠️ Self Hosted"}, + } + idx, _, err := prompt.Run() + if err != nil { + return fmt.Errorf("prompt cancelled") + } + + if idx == 1 { + hostPrompt := promptui.Prompt{ + Label: "Please enter your host (URL eg. https://example.com)", + } + host, err = hostPrompt.Run() + if err != nil { + return fmt.Errorf("prompt cancelled") + } + host = strings.TrimSpace(host) + if host == "" { + return fmt.Errorf("host URL is required for self-hosted instances") + } + } else { + host = config.PhaseCloudAPIHost + } + } else { + fmt.Fprintf(os.Stderr, "Using PHASE_HOST environment variable: %s\n", host) + } + + switch authMode { + case "webauth": + return runWebAuth(cmd, host) + case "aws-iam": + return runAWSIAMAuth(cmd, host) + case "token": + return runTokenAuth(cmd, host) + default: + return fmt.Errorf("unsupported auth mode: %s. Supported modes: token, webauth, aws-iam", authMode) + } +} + +func runTokenAuth(cmd *cobra.Command, host string) error { + // Get token + fmt.Print("Please enter Personal Access Token (PAT) or Service Account Token (hidden): ") + tokenBytes, err := term.ReadPassword(int(syscall.Stdin)) + if err != nil { + return fmt.Errorf("failed to read token: %w", err) + } + fmt.Println() + authToken := strings.TrimSpace(string(tokenBytes)) + if authToken == "" { + return fmt.Errorf("token is required") + } + + isPersonalToken := strings.HasPrefix(authToken, "pss_user:") + var userEmail string + if isPersonalToken { + emailPrompt := promptui.Prompt{ + Label: "Please enter your email", + } + userEmail, err = emailPrompt.Run() + if err != nil { + return fmt.Errorf("prompt cancelled") + } + userEmail = strings.TrimSpace(userEmail) + if userEmail == "" { + return fmt.Errorf("email is required for personal access tokens") + } + } + + // Validate token + p, err := phase.NewPhase(false, authToken, host) + if err != nil { + return fmt.Errorf("invalid token: %w", err) + } + + if err := p.Auth(); err != nil { + return fmt.Errorf("authentication failed: %w", err) + } + + // Get user data + userData, err := p.InitRaw() + if err != nil { + return fmt.Errorf("failed to fetch user data: %w", err) + } + + // Extract account ID (support both user_id and account_id) + accountID := "" + if uid, ok := userData["user_id"].(string); ok && uid != "" { + accountID = uid + } else if aid, ok := userData["account_id"].(string); ok && aid != "" { + accountID = aid + } + if accountID == "" { + return fmt.Errorf("neither user_id nor account_id found in authentication response") + } + + // Extract org info + var orgID, orgName *string + if org, ok := userData["organisation"].(map[string]interface{}); ok && org != nil { + if id, ok := org["id"].(string); ok { + orgID = &id + } + if name, ok := org["name"].(string); ok { + orgName = &name + } + } + + // Extract wrapped key share + var wrappedKeyShare *string + offlineEnabled, _ := userData["offline_enabled"].(bool) + if offlineEnabled { + if wks, ok := userData["wrapped_key_share"].(string); ok { + wrappedKeyShare = &wks + } + } + + // Save credentials to keyring + tokenSavedInKeyring := true + if err := keyring.SetCredentials(accountID, authToken); err != nil { + tokenSavedInKeyring = false + } + + // Build user config + userConfig := config.UserConfig{ + Host: host, + ID: accountID, + OrganizationID: orgID, + OrganizationName: orgName, + WrappedKeyShare: wrappedKeyShare, + } + if userEmail != "" { + userConfig.Email = userEmail + } + if !tokenSavedInKeyring { + userConfig.Token = authToken + } + + // Save to config + if err := config.AddUser(userConfig); err != nil { + return fmt.Errorf("failed to save config: %w", err) + } + + fmt.Println(util.BoldGreen("✅ Authentication successful.")) + return nil +} diff --git a/src/cmd/auth_aws.go b/src/cmd/auth_aws.go new file mode 100644 index 00000000..7044209a --- /dev/null +++ b/src/cmd/auth_aws.go @@ -0,0 +1,202 @@ +package cmd + +import ( + "context" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "fmt" + "net/http" + "strings" + "time" + + v4 "github.com/aws/aws-sdk-go-v2/aws/signer/v4" + awsconfig "github.com/aws/aws-sdk-go-v2/config" + "github.com/phasehq/cli/pkg/config" + "github.com/phasehq/cli/pkg/keyring" + "github.com/phasehq/cli/pkg/network" + "github.com/phasehq/cli/pkg/phase" + "github.com/phasehq/cli/pkg/util" + "github.com/spf13/cobra" +) + +func resolveRegionAndEndpoint(ctx context.Context) (string, string, error) { + // Load the full AWS SDK config which reads env vars, ~/.aws/config, + // EC2 IMDS, etc. — matching boto3's region resolution behavior. + cfg, err := awsconfig.LoadDefaultConfig(ctx) + if err != nil { + return "", "", fmt.Errorf("failed to load AWS config: %w", err) + } + + region := cfg.Region + if region == "" { + region = "us-east-1" + } + + endpoint := fmt.Sprintf("https://sts.%s.amazonaws.com", region) + if region == "us-east-1" { + endpoint = "https://sts.amazonaws.com" + } + + return region, endpoint, nil +} + +func signGetCallerIdentity(ctx context.Context, region, endpoint string) (string, map[string]string, string, error) { + body := "Action=GetCallerIdentity&Version=2011-06-15" + + cfg, err := awsconfig.LoadDefaultConfig(ctx, awsconfig.WithRegion(region)) + if err != nil { + return "", nil, "", fmt.Errorf("failed to load AWS config: %w", err) + } + + creds, err := cfg.Credentials.Retrieve(ctx) + if err != nil { + return "", nil, "", fmt.Errorf("failed to retrieve AWS credentials: %w", err) + } + + req, err := http.NewRequest("POST", endpoint, strings.NewReader(body)) + if err != nil { + return "", nil, "", err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded; charset=utf-8") + + // Compute SHA256 hash of the body for SigV4 + bodyHash := sha256.Sum256([]byte(body)) + payloadHash := hex.EncodeToString(bodyHash[:]) + + signer := v4.NewSigner() + err = signer.SignHTTP(ctx, creds, req, payloadHash, "sts", region, time.Now()) + if err != nil { + return "", nil, "", fmt.Errorf("failed to sign request: %w", err) + } + + signedHeaders := map[string]string{} + for key, values := range req.Header { + if len(values) > 0 { + signedHeaders[key] = values[0] + } + } + + return endpoint, signedHeaders, body, nil +} + +func runAWSIAMAuth(cmd *cobra.Command, host string) error { + serviceAccountID, _ := cmd.Flags().GetString("service-account-id") + if serviceAccountID == "" { + return fmt.Errorf("--service-account-id is required for aws-iam auth mode") + } + + ttlVal, _ := cmd.Flags().GetInt("ttl") + var ttl *int + if cmd.Flags().Changed("ttl") { + ttl = &ttlVal + } + + noStore, _ := cmd.Flags().GetBool("no-store") + + ctx := context.Background() + region, endpoint, err := resolveRegionAndEndpoint(ctx) + if err != nil { + return fmt.Errorf("failed to resolve AWS region: %w", err) + } + + signedURL, signedHeaders, body, err := signGetCallerIdentity(ctx, region, endpoint) + if err != nil { + return fmt.Errorf("failed to sign AWS request: %w", err) + } + + // Base64 encode the signed values + encodedURL := base64.StdEncoding.EncodeToString([]byte(signedURL)) + headersJSON, _ := json.Marshal(signedHeaders) + encodedHeaders := base64.StdEncoding.EncodeToString(headersJSON) + encodedBody := base64.StdEncoding.EncodeToString([]byte(body)) + + result, err := network.ExternalIdentityAuthAWS(host, serviceAccountID, ttl, encodedURL, encodedHeaders, encodedBody, "POST") + if err != nil { + return fmt.Errorf("AWS IAM authentication failed: %w", err) + } + + if noStore { + output, _ := json.MarshalIndent(result, "", " ") + fmt.Println(string(output)) + return nil + } + + // Extract token from response + auth, ok := result["authentication"].(map[string]interface{}) + if !ok { + return fmt.Errorf("unexpected response format: missing 'authentication' field") + } + token, ok := auth["token"].(string) + if !ok || token == "" { + return fmt.Errorf("no token found in authentication response") + } + + // Validate the token + p, err := phase.NewPhase(false, token, host) + if err != nil { + return fmt.Errorf("invalid token received: %w", err) + } + + if err := p.Auth(); err != nil { + return fmt.Errorf("token validation failed: %w", err) + } + + // Get user data + userData, err := p.InitRaw() + if err != nil { + return fmt.Errorf("failed to fetch user data: %w", err) + } + + accountID := "" + if uid, ok := userData["user_id"].(string); ok && uid != "" { + accountID = uid + } else if aid, ok := userData["account_id"].(string); ok && aid != "" { + accountID = aid + } + if accountID == "" { + return fmt.Errorf("no account ID found in response") + } + + var orgID, orgName *string + if org, ok := userData["organisation"].(map[string]interface{}); ok && org != nil { + if id, ok := org["id"].(string); ok { + orgID = &id + } + if name, ok := org["name"].(string); ok { + orgName = &name + } + } + + var wrappedKeyShare *string + offlineEnabled, _ := userData["offline_enabled"].(bool) + if offlineEnabled { + if wks, ok := userData["wrapped_key_share"].(string); ok { + wrappedKeyShare = &wks + } + } + + tokenSavedInKeyring := true + if err := keyring.SetCredentials(accountID, token); err != nil { + tokenSavedInKeyring = false + } + + userConfig := config.UserConfig{ + Host: host, + ID: accountID, + OrganizationID: orgID, + OrganizationName: orgName, + WrappedKeyShare: wrappedKeyShare, + } + if !tokenSavedInKeyring { + userConfig.Token = token + } + + if err := config.AddUser(userConfig); err != nil { + return fmt.Errorf("failed to save config: %w", err) + } + + fmt.Println(util.BoldGreen("✅ Authentication successful.")) + return nil +} diff --git a/src/cmd/auth_webauth.go b/src/cmd/auth_webauth.go new file mode 100644 index 00000000..ef7a97e1 --- /dev/null +++ b/src/cmd/auth_webauth.go @@ -0,0 +1,200 @@ +package cmd + +import ( + "encoding/base64" + "encoding/hex" + "encoding/json" + "fmt" + "math/rand" + "net" + "net/http" + "os" + "os/user" + "strings" + "time" + + "github.com/phasehq/cli/pkg/config" + "github.com/phasehq/cli/pkg/keyring" + "github.com/phasehq/cli/pkg/phase" + "github.com/phasehq/cli/pkg/util" + "github.com/phasehq/golang-sdk/phase/crypto" + "github.com/spf13/cobra" +) + +func runWebAuth(cmd *cobra.Command, host string) error { + // Pick random port + rng := rand.New(rand.NewSource(time.Now().UnixNano())) + port := 8000 + rng.Intn(12001) + + // Generate ephemeral keypair + kp, err := crypto.RandomKeyPair() + if err != nil { + return fmt.Errorf("failed to generate keypair: %w", err) + } + + pubKeyHex := hex.EncodeToString(kp.PublicKey[:]) + privKeyHex := hex.EncodeToString(kp.SecretKey[:]) + + // Build PAT name + username := "unknown" + if u, err := user.Current(); err == nil { + username = u.Username + } + hostname, _ := os.Hostname() + patName := fmt.Sprintf("%s@%s", username, hostname) + + // Encode payload + rawData := fmt.Sprintf("%d-%s-%s", port, pubKeyHex, patName) + encoded := base64.StdEncoding.EncodeToString([]byte(rawData)) + + // Channel to receive auth data + type authData struct { + Email string `json:"email"` + PSS string `json:"pss"` + } + dataCh := make(chan authData, 1) + + // Create listener + listener, err := net.Listen("tcp", fmt.Sprintf(":%d", port)) + if err != nil { + return fmt.Errorf("failed to start server on port %d: %w", port, err) + } + + // Set up HTTP handler + mux := http.NewServeMux() + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + origin := host + w.Header().Set("Access-Control-Allow-Origin", origin) + w.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type") + + if r.Method == "OPTIONS" { + w.WriteHeader(http.StatusOK) + return + } + + if r.Method == "POST" { + var data authData + if err := json.NewDecoder(r.Body).Decode(&data); err != nil { + http.Error(w, "invalid request", http.StatusBadRequest) + return + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{ + "status": "Success: CLI authentication complete", + }) + dataCh <- data + } + }) + + server := &http.Server{Handler: mux} + go server.Serve(listener) + + // Open browser + authURL := fmt.Sprintf("%s/webauth/%s", host, encoded) + fmt.Fprintf(os.Stderr, "Opening browser for authentication...\n") + if err := util.OpenBrowser(authURL); err != nil { + fmt.Fprintf(os.Stderr, "Please open this URL in your browser:\n%s\n", authURL) + } + + // Wait for data + fmt.Fprintf(os.Stderr, "Waiting for authentication...\n") + var received authData + select { + case received = <-dataCh: + case <-time.After(5 * time.Minute): + server.Close() + return fmt.Errorf("authentication timed out") + } + + // Shut down server + server.Close() + + // Decrypt email and PSS + decryptedEmail, err := crypto.DecryptAsymmetric(received.Email, privKeyHex, pubKeyHex) + if err != nil { + return fmt.Errorf("failed to decrypt email: %w", err) + } + + decryptedPSS, err := crypto.DecryptAsymmetric(received.PSS, privKeyHex, pubKeyHex) + if err != nil { + return fmt.Errorf("failed to decrypt token: %w", err) + } + + authToken := strings.TrimSpace(decryptedPSS) + userEmail := strings.TrimSpace(decryptedEmail) + + // Validate token + p, err := phase.NewPhase(false, authToken, host) + if err != nil { + return fmt.Errorf("invalid token: %w", err) + } + + if err := p.Auth(); err != nil { + return fmt.Errorf("authentication failed: %w", err) + } + + // Get user data + userData, err := p.InitRaw() + if err != nil { + return fmt.Errorf("failed to fetch user data: %w", err) + } + + // Extract account ID + accountID := "" + if uid, ok := userData["user_id"].(string); ok && uid != "" { + accountID = uid + } else if aid, ok := userData["account_id"].(string); ok && aid != "" { + accountID = aid + } + if accountID == "" { + return fmt.Errorf("neither user_id nor account_id found in authentication response") + } + + // Extract org info + var orgID, orgName *string + if org, ok := userData["organisation"].(map[string]interface{}); ok && org != nil { + if id, ok := org["id"].(string); ok { + orgID = &id + } + if name, ok := org["name"].(string); ok { + orgName = &name + } + } + + // Extract wrapped key share + var wrappedKeyShare *string + offlineEnabled, _ := userData["offline_enabled"].(bool) + if offlineEnabled { + if wks, ok := userData["wrapped_key_share"].(string); ok { + wrappedKeyShare = &wks + } + } + + // Save credentials to keyring + tokenSavedInKeyring := true + if err := keyring.SetCredentials(accountID, authToken); err != nil { + tokenSavedInKeyring = false + } + + // Build user config + userConfig := config.UserConfig{ + Host: host, + ID: accountID, + Email: userEmail, + OrganizationID: orgID, + OrganizationName: orgName, + WrappedKeyShare: wrappedKeyShare, + } + if !tokenSavedInKeyring { + userConfig.Token = authToken + } + + // Save to config + if err := config.AddUser(userConfig); err != nil { + return fmt.Errorf("failed to save config: %w", err) + } + + fmt.Println(util.BoldGreen("✅ Authentication successful.")) + return nil +} diff --git a/src/cmd/console.go b/src/cmd/console.go new file mode 100644 index 00000000..0a1ed572 --- /dev/null +++ b/src/cmd/console.go @@ -0,0 +1,56 @@ +package cmd + +import ( + "fmt" + "strconv" + + "github.com/phasehq/cli/pkg/config" + "github.com/phasehq/cli/pkg/util" + "github.com/spf13/cobra" +) + +var consoleCmd = &cobra.Command{ + Use: "console", + Short: "🖥️\u200A Open the Phase Console in your browser", + RunE: runConsole, +} + +func init() { + rootCmd.AddCommand(consoleCmd) +} + +func runConsole(cmd *cobra.Command, args []string) error { + user, err := config.GetDefaultUser() + if err != nil { + return fmt.Errorf("no user configured: %w", err) + } + + host := user.Host + orgName := "" + if user.OrganizationName != nil { + orgName = *user.OrganizationName + } + + phaseConfig := config.FindPhaseConfig(8) + if phaseConfig != nil && orgName != "" { + version := 1 + if phaseConfig.Version != "" { + if v, err := strconv.Atoi(phaseConfig.Version); err == nil { + version = v + } + } + + if version >= 2 && phaseConfig.EnvID != "" { + url := fmt.Sprintf("%s/%s/apps/%s/environments/%s", host, orgName, phaseConfig.AppID, phaseConfig.EnvID) + fmt.Printf("Opening %s\n", url) + return util.OpenBrowser(url) + } + + url := fmt.Sprintf("%s/%s/apps/%s", host, orgName, phaseConfig.AppID) + fmt.Printf("Opening %s\n", url) + return util.OpenBrowser(url) + } + + fmt.Printf("Opening %s\n", host) + return util.OpenBrowser(host) +} diff --git a/src/cmd/docs.go b/src/cmd/docs.go new file mode 100644 index 00000000..5188c57f --- /dev/null +++ b/src/cmd/docs.go @@ -0,0 +1,24 @@ +package cmd + +import ( + "fmt" + + "github.com/phasehq/cli/pkg/util" + "github.com/spf13/cobra" +) + +var docsCmd = &cobra.Command{ + Use: "docs", + Short: "📖 Open the Phase CLI Docs in your browser", + RunE: runDocs, +} + +func init() { + rootCmd.AddCommand(docsCmd) +} + +func runDocs(cmd *cobra.Command, args []string) error { + url := "https://docs.phase.dev/cli/commands" + fmt.Printf("Opening %s\n", url) + return util.OpenBrowser(url) +} diff --git a/src/cmd/dynamic_secrets.go b/src/cmd/dynamic_secrets.go new file mode 100644 index 00000000..6c5febd7 --- /dev/null +++ b/src/cmd/dynamic_secrets.go @@ -0,0 +1,20 @@ +package cmd + +import ( + "github.com/spf13/cobra" +) + +var dynamicSecretsCmd = &cobra.Command{ + Use: "dynamic-secrets", + Short: "⚡️ Manage dynamic secrets", +} + +var dynamicSecretsLeaseCmd = &cobra.Command{ + Use: "lease", + Short: "📜 Manage dynamic secret leases", +} + +func init() { + dynamicSecretsCmd.AddCommand(dynamicSecretsLeaseCmd) + rootCmd.AddCommand(dynamicSecretsCmd) +} diff --git a/src/cmd/dynamic_secrets_lease_generate.go b/src/cmd/dynamic_secrets_lease_generate.go new file mode 100644 index 00000000..888c8893 --- /dev/null +++ b/src/cmd/dynamic_secrets_lease_generate.go @@ -0,0 +1,67 @@ +package cmd + +import ( + "encoding/json" + "fmt" + + "github.com/phasehq/cli/pkg/network" + "github.com/phasehq/cli/pkg/phase" + "github.com/spf13/cobra" +) + +var dynamicSecretsLeaseGenerateCmd = &cobra.Command{ + Use: "generate ", + Short: "✨ Generate a lease (create fresh dynamic secret)", + Args: cobra.ExactArgs(1), + RunE: runDynamicSecretsLeaseGenerate, +} + +func init() { + dynamicSecretsLeaseGenerateCmd.Flags().Int("lease-ttl", 0, "Lease TTL in seconds") + dynamicSecretsLeaseGenerateCmd.Flags().String("env", "", "Environment name") + dynamicSecretsLeaseGenerateCmd.Flags().String("app", "", "Application name") + dynamicSecretsLeaseGenerateCmd.Flags().String("app-id", "", "Application ID") + dynamicSecretsLeaseCmd.AddCommand(dynamicSecretsLeaseGenerateCmd) +} + +func runDynamicSecretsLeaseGenerate(cmd *cobra.Command, args []string) error { + secretID := args[0] + leaseTTL, _ := cmd.Flags().GetInt("lease-ttl") + envName, _ := cmd.Flags().GetString("env") + appName, _ := cmd.Flags().GetString("app") + appID, _ := cmd.Flags().GetString("app-id") + + p, err := phase.NewPhase(true, "", "") + if err != nil { + return err + } + + userData, err := p.Init() + if err != nil { + return err + } + + _, resolvedAppID, resolvedEnvName, _, _, err := phase.PhaseGetContext(userData, appName, envName, appID) + if err != nil { + return err + } + + var ttlPtr *int + if cmd.Flags().Changed("lease-ttl") { + ttlPtr = &leaseTTL + } + + result, err := network.CreateDynamicSecretLease(p.TokenType, p.AppToken, p.APIHost, resolvedAppID, resolvedEnvName, secretID, ttlPtr) + if err != nil { + return err + } + + var formatted json.RawMessage + if err := json.Unmarshal(result, &formatted); err != nil { + fmt.Println(string(result)) + return nil + } + pretty, _ := json.MarshalIndent(formatted, "", " ") + fmt.Println(string(pretty)) + return nil +} diff --git a/src/cmd/dynamic_secrets_lease_get.go b/src/cmd/dynamic_secrets_lease_get.go new file mode 100644 index 00000000..37002a8c --- /dev/null +++ b/src/cmd/dynamic_secrets_lease_get.go @@ -0,0 +1,60 @@ +package cmd + +import ( + "encoding/json" + "fmt" + + "github.com/phasehq/cli/pkg/network" + "github.com/phasehq/cli/pkg/phase" + "github.com/spf13/cobra" +) + +var dynamicSecretsLeaseGetCmd = &cobra.Command{ + Use: "get ", + Short: "🔍 Get leases for a dynamic secret", + Args: cobra.ExactArgs(1), + RunE: runDynamicSecretsLeaseGet, +} + +func init() { + dynamicSecretsLeaseGetCmd.Flags().String("env", "", "Environment name") + dynamicSecretsLeaseGetCmd.Flags().String("app", "", "Application name") + dynamicSecretsLeaseGetCmd.Flags().String("app-id", "", "Application ID") + dynamicSecretsLeaseCmd.AddCommand(dynamicSecretsLeaseGetCmd) +} + +func runDynamicSecretsLeaseGet(cmd *cobra.Command, args []string) error { + secretID := args[0] + envName, _ := cmd.Flags().GetString("env") + appName, _ := cmd.Flags().GetString("app") + appID, _ := cmd.Flags().GetString("app-id") + + p, err := phase.NewPhase(true, "", "") + if err != nil { + return err + } + + userData, err := p.Init() + if err != nil { + return err + } + + _, resolvedAppID, resolvedEnvName, _, _, err := phase.PhaseGetContext(userData, appName, envName, appID) + if err != nil { + return err + } + + result, err := network.ListDynamicSecretLeases(p.TokenType, p.AppToken, p.APIHost, resolvedAppID, resolvedEnvName, secretID) + if err != nil { + return err + } + + var formatted json.RawMessage + if err := json.Unmarshal(result, &formatted); err != nil { + fmt.Println(string(result)) + return nil + } + pretty, _ := json.MarshalIndent(formatted, "", " ") + fmt.Println(string(pretty)) + return nil +} diff --git a/src/cmd/dynamic_secrets_lease_renew.go b/src/cmd/dynamic_secrets_lease_renew.go new file mode 100644 index 00000000..8e6f9a3e --- /dev/null +++ b/src/cmd/dynamic_secrets_lease_renew.go @@ -0,0 +1,66 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "strconv" + + "github.com/phasehq/cli/pkg/network" + "github.com/phasehq/cli/pkg/phase" + "github.com/spf13/cobra" +) + +var dynamicSecretsLeaseRenewCmd = &cobra.Command{ + Use: "renew ", + Short: "🔁 Renew a lease", + Args: cobra.ExactArgs(2), + RunE: runDynamicSecretsLeaseRenew, +} + +func init() { + dynamicSecretsLeaseRenewCmd.Flags().String("env", "", "Environment name") + dynamicSecretsLeaseRenewCmd.Flags().String("app", "", "Application name") + dynamicSecretsLeaseRenewCmd.Flags().String("app-id", "", "Application ID") + dynamicSecretsLeaseCmd.AddCommand(dynamicSecretsLeaseRenewCmd) +} + +func runDynamicSecretsLeaseRenew(cmd *cobra.Command, args []string) error { + leaseID := args[0] + ttl, err := strconv.Atoi(args[1]) + if err != nil { + return fmt.Errorf("invalid TTL value: %s", args[1]) + } + + envName, _ := cmd.Flags().GetString("env") + appName, _ := cmd.Flags().GetString("app") + appID, _ := cmd.Flags().GetString("app-id") + + p, err := phase.NewPhase(true, "", "") + if err != nil { + return err + } + + userData, err := p.Init() + if err != nil { + return err + } + + _, resolvedAppID, resolvedEnvName, _, _, err := phase.PhaseGetContext(userData, appName, envName, appID) + if err != nil { + return err + } + + result, err := network.RenewDynamicSecretLease(p.TokenType, p.AppToken, p.APIHost, resolvedAppID, resolvedEnvName, leaseID, ttl) + if err != nil { + return err + } + + var formatted json.RawMessage + if err := json.Unmarshal(result, &formatted); err != nil { + fmt.Println(string(result)) + return nil + } + pretty, _ := json.MarshalIndent(formatted, "", " ") + fmt.Println(string(pretty)) + return nil +} diff --git a/src/cmd/dynamic_secrets_lease_revoke.go b/src/cmd/dynamic_secrets_lease_revoke.go new file mode 100644 index 00000000..44bef7ed --- /dev/null +++ b/src/cmd/dynamic_secrets_lease_revoke.go @@ -0,0 +1,60 @@ +package cmd + +import ( + "encoding/json" + "fmt" + + "github.com/phasehq/cli/pkg/network" + "github.com/phasehq/cli/pkg/phase" + "github.com/spf13/cobra" +) + +var dynamicSecretsLeaseRevokeCmd = &cobra.Command{ + Use: "revoke ", + Short: "🗑️\u200A Revoke a lease", + Args: cobra.ExactArgs(1), + RunE: runDynamicSecretsLeaseRevoke, +} + +func init() { + dynamicSecretsLeaseRevokeCmd.Flags().String("env", "", "Environment name") + dynamicSecretsLeaseRevokeCmd.Flags().String("app", "", "Application name") + dynamicSecretsLeaseRevokeCmd.Flags().String("app-id", "", "Application ID") + dynamicSecretsLeaseCmd.AddCommand(dynamicSecretsLeaseRevokeCmd) +} + +func runDynamicSecretsLeaseRevoke(cmd *cobra.Command, args []string) error { + leaseID := args[0] + envName, _ := cmd.Flags().GetString("env") + appName, _ := cmd.Flags().GetString("app") + appID, _ := cmd.Flags().GetString("app-id") + + p, err := phase.NewPhase(true, "", "") + if err != nil { + return err + } + + userData, err := p.Init() + if err != nil { + return err + } + + _, resolvedAppID, resolvedEnvName, _, _, err := phase.PhaseGetContext(userData, appName, envName, appID) + if err != nil { + return err + } + + result, err := network.RevokeDynamicSecretLease(p.TokenType, p.AppToken, p.APIHost, resolvedAppID, resolvedEnvName, leaseID) + if err != nil { + return err + } + + var formatted json.RawMessage + if err := json.Unmarshal(result, &formatted); err != nil { + fmt.Println(string(result)) + return nil + } + pretty, _ := json.MarshalIndent(formatted, "", " ") + fmt.Println(string(pretty)) + return nil +} diff --git a/src/cmd/dynamic_secrets_list.go b/src/cmd/dynamic_secrets_list.go new file mode 100644 index 00000000..bb41623f --- /dev/null +++ b/src/cmd/dynamic_secrets_list.go @@ -0,0 +1,61 @@ +package cmd + +import ( + "encoding/json" + "fmt" + + "github.com/phasehq/cli/pkg/network" + "github.com/phasehq/cli/pkg/phase" + "github.com/spf13/cobra" +) + +var dynamicSecretsListCmd = &cobra.Command{ + Use: "list", + Short: "📇 List dynamic secrets & metadata", + RunE: runDynamicSecretsList, +} + +func init() { + dynamicSecretsListCmd.Flags().String("env", "", "Environment name") + dynamicSecretsListCmd.Flags().String("app", "", "Application name") + dynamicSecretsListCmd.Flags().String("app-id", "", "Application ID") + dynamicSecretsListCmd.Flags().String("path", "", "Path filter") + dynamicSecretsCmd.AddCommand(dynamicSecretsListCmd) +} + +func runDynamicSecretsList(cmd *cobra.Command, args []string) error { + envName, _ := cmd.Flags().GetString("env") + appName, _ := cmd.Flags().GetString("app") + appID, _ := cmd.Flags().GetString("app-id") + path, _ := cmd.Flags().GetString("path") + + p, err := phase.NewPhase(true, "", "") + if err != nil { + return err + } + + userData, err := p.Init() + if err != nil { + return err + } + + resolvedAppName, resolvedAppID, resolvedEnvName, _, _, err := phase.PhaseGetContext(userData, appName, envName, appID) + if err != nil { + return err + } + _ = resolvedAppName + + result, err := network.ListDynamicSecrets(p.TokenType, p.AppToken, p.APIHost, resolvedAppID, resolvedEnvName, path) + if err != nil { + return err + } + + var formatted json.RawMessage + if err := json.Unmarshal(result, &formatted); err != nil { + fmt.Println(string(result)) + return nil + } + pretty, _ := json.MarshalIndent(formatted, "", " ") + fmt.Println(string(pretty)) + return nil +} diff --git a/src/cmd/init_cmd.go b/src/cmd/init_cmd.go new file mode 100644 index 00000000..a0edc63e --- /dev/null +++ b/src/cmd/init_cmd.go @@ -0,0 +1,133 @@ +package cmd + +import ( + "fmt" + "os" + "sort" + + "github.com/manifoldco/promptui" + "github.com/phasehq/cli/pkg/config" + "github.com/phasehq/cli/pkg/phase" + "github.com/phasehq/cli/pkg/util" + "github.com/spf13/cobra" +) + +var initCmd = &cobra.Command{ + Use: "init", + Short: "🔗 Link your project with your Phase app", + RunE: runInit, +} + +func init() { + rootCmd.AddCommand(initCmd) +} + +func runInit(cmd *cobra.Command, args []string) error { + p, err := phase.NewPhase(true, "", "") + if err != nil { + return err + } + + data, err := p.Init() + if err != nil { + return err + } + + if len(data.Apps) == 0 { + return fmt.Errorf("no applications found") + } + + // Build app choice labels + appItems := make([]string, len(data.Apps)+1) + for i, app := range data.Apps { + appItems[i] = fmt.Sprintf("%s (%s)", app.Name, app.ID) + } + appItems[len(data.Apps)] = "Exit" + + appPrompt := promptui.Select{ + Label: "Select an App", + Items: appItems, + } + appIdx, _, err := appPrompt.Run() + if err != nil { + return fmt.Errorf("prompt cancelled") + } + if appIdx == len(data.Apps) { + return nil + } + + selectedApp := data.Apps[appIdx] + + // Sort environments + envSortOrder := map[string]int{"DEV": 1, "STAGING": 2, "PROD": 3} + envKeys := make([]struct { + idx int + sort int + }, len(selectedApp.EnvironmentKeys)) + for i, ek := range selectedApp.EnvironmentKeys { + order, ok := envSortOrder[ek.Environment.EnvType] + if !ok { + order = 4 + } + envKeys[i] = struct { + idx int + sort int + }{i, order} + } + sort.Slice(envKeys, func(i, j int) bool { + return envKeys[i].sort < envKeys[j].sort + }) + + // Build env choice labels + envItems := make([]string, len(envKeys)+1) + for i, ek := range envKeys { + env := selectedApp.EnvironmentKeys[ek.idx] + envItems[i] = env.Environment.Name + } + envItems[len(envKeys)] = "Exit" + + envPrompt := promptui.Select{ + Label: "Choose a Default Environment", + Items: envItems, + } + envIdx, _, err := envPrompt.Run() + if err != nil { + return fmt.Errorf("prompt cancelled") + } + if envIdx == len(envKeys) { + return nil + } + + selectedEnvKey := selectedApp.EnvironmentKeys[envKeys[envIdx].idx] + + // Ask about monorepo support + monorepoPrompt := promptui.Select{ + Label: "🍱 Monorepo support: Would you like this configuration to apply to subdirectories?", + Items: []string{"No", "Yes"}, + } + monorepoIdx, _, err := monorepoPrompt.Run() + if err != nil { + return fmt.Errorf("prompt cancelled") + } + monorepoSupport := monorepoIdx == 1 + + // Write .phase.json + phaseConfig := &config.PhaseJSONConfig{ + Version: "2", + PhaseApp: selectedApp.Name, + AppID: selectedApp.ID, + DefaultEnv: selectedEnvKey.Environment.Name, + EnvID: selectedEnvKey.Environment.ID, + MonorepoSupport: monorepoSupport, + } + + if err := config.WritePhaseConfig(phaseConfig); err != nil { + return fmt.Errorf("failed to write .phase.json: %w", err) + } + + // Set file permissions + os.Chmod(config.PhaseEnvConfig, 0600) + + fmt.Println(util.BoldGreen("✅ Initialization completed successfully.")) + return nil +} diff --git a/src/cmd/mcp.go b/src/cmd/mcp.go new file mode 100644 index 00000000..bc975130 --- /dev/null +++ b/src/cmd/mcp.go @@ -0,0 +1,23 @@ +package cmd + +import ( + "github.com/spf13/cobra" +) + +var mcpCmd = &cobra.Command{ + Use: "mcp", + Short: "🤖 Model Context Protocol (MCP) server for AI assistants", + Long: `🤖 Model Context Protocol (MCP) server for AI assistants + +Allows AI assistants like Claude Code, Cursor, VS Code Copilot, Zed, and OpenCode +to securely manage Phase secrets via the MCP protocol. + +Subcommands: + serve Start the MCP stdio server + install Install Phase MCP for an AI client + uninstall Uninstall Phase MCP from an AI client`, +} + +func init() { + rootCmd.AddCommand(mcpCmd) +} diff --git a/src/cmd/mcp_install.go b/src/cmd/mcp_install.go new file mode 100644 index 00000000..3bd409f3 --- /dev/null +++ b/src/cmd/mcp_install.go @@ -0,0 +1,51 @@ +package cmd + +import ( + "fmt" + "strings" + + phasemcp "github.com/phasehq/cli/pkg/mcp" + "github.com/spf13/cobra" +) + +var mcpInstallCmd = &cobra.Command{ + Use: "install [client]", + Short: "📦 Install Phase MCP server for AI clients", + Long: fmt.Sprintf(`📦 Install Phase MCP server configuration for AI clients. + +If no client is specified, installs for all detected clients. + +Supported clients: %s + +Examples: + phase mcp install # Install for all detected clients + phase mcp install claude-code # Install for Claude Code only + phase mcp install cursor --scope project # Install in project scope`, strings.Join(phasemcp.SupportedClientNames(), ", ")), + Args: cobra.MaximumNArgs(1), + RunE: runMCPInstall, +} + +func init() { + mcpInstallCmd.Flags().String("scope", "user", "Installation scope: user or project") + mcpCmd.AddCommand(mcpInstallCmd) +} + +func runMCPInstall(cmd *cobra.Command, args []string) error { + scope, _ := cmd.Flags().GetString("scope") + + var client string + if len(args) > 0 { + client = args[0] + } + + if err := phasemcp.Install(client, scope); err != nil { + return err + } + + if client != "" { + fmt.Printf("✅ Phase MCP server installed for %s (scope: %s).\n", client, scope) + } else { + fmt.Println("✅ Phase MCP server installed for all detected clients.") + } + return nil +} diff --git a/src/cmd/mcp_serve.go b/src/cmd/mcp_serve.go new file mode 100644 index 00000000..752f0eb4 --- /dev/null +++ b/src/cmd/mcp_serve.go @@ -0,0 +1,27 @@ +package cmd + +import ( + phasemcp "github.com/phasehq/cli/pkg/mcp" + "github.com/spf13/cobra" +) + +var mcpServeCmd = &cobra.Command{ + Use: "serve", + Short: "🚀 Start the Phase MCP server (stdio transport)", + Long: `🚀 Start the Phase MCP server using stdio transport. + +This command is typically invoked by AI clients (Claude Code, Cursor, etc.) +and communicates via stdin/stdout using the MCP JSON-RPC protocol. + +Requires either PHASE_SERVICE_TOKEN environment variable or an authenticated +user session (via 'phase auth').`, + RunE: runMCPServe, +} + +func init() { + mcpCmd.AddCommand(mcpServeCmd) +} + +func runMCPServe(cmd *cobra.Command, args []string) error { + return phasemcp.RunServer(cmd.Context()) +} diff --git a/src/cmd/mcp_uninstall.go b/src/cmd/mcp_uninstall.go new file mode 100644 index 00000000..3df9a767 --- /dev/null +++ b/src/cmd/mcp_uninstall.go @@ -0,0 +1,47 @@ +package cmd + +import ( + "fmt" + "strings" + + phasemcp "github.com/phasehq/cli/pkg/mcp" + "github.com/spf13/cobra" +) + +var mcpUninstallCmd = &cobra.Command{ + Use: "uninstall [client]", + Short: "🗑️\u200A Uninstall Phase MCP server from AI clients", + Long: fmt.Sprintf(`🗑️ Uninstall Phase MCP server configuration from AI clients. + +If no client is specified, uninstalls from all clients. + +Supported clients: %s + +Examples: + phase mcp uninstall # Uninstall from all clients + phase mcp uninstall claude-code # Uninstall from Claude Code only`, strings.Join(phasemcp.SupportedClientNames(), ", ")), + Args: cobra.MaximumNArgs(1), + RunE: runMCPUninstall, +} + +func init() { + mcpCmd.AddCommand(mcpUninstallCmd) +} + +func runMCPUninstall(cmd *cobra.Command, args []string) error { + var client string + if len(args) > 0 { + client = args[0] + } + + if err := phasemcp.Uninstall(client); err != nil { + return err + } + + if client != "" { + fmt.Printf("✅ Phase MCP server uninstalled from %s.\n", client) + } else { + fmt.Println("✅ Phase MCP server uninstalled from all clients.") + } + return nil +} diff --git a/src/cmd/root.go b/src/cmd/root.go new file mode 100644 index 00000000..7f0d3044 --- /dev/null +++ b/src/cmd/root.go @@ -0,0 +1,51 @@ +package cmd + +import ( + "os" + + "github.com/spf13/cobra" +) + +var Version = "2.0.0" + +const phaseASCii = ` + /$$ + | $$ + /$$$$$$ | $$$$$$$ /$$$$$$ /$$$$$$$ /$$$$$$ + /$$__ $$| $$__ $$ |____ $$ /$$_____/ /$$__ $$ + | $$ \ $$| $$ \ $$ /$$$$$$$| $$$$$$ | $$$$$$$$ + | $$ | $$| $$ | $$ /$$__ $$ \____ $$| $$_____/ + | $$$$$$$/| $$ | $$| $$$$$$$ /$$$$$$$/| $$$$$$$ + | $$____/ |__/ |__/ \_______/|_______/ \_______/ + | $$ + |__/ +` + +const description = "Keep Secrets." + +var rootCmd = &cobra.Command{ + Use: "phase", + Short: description, + Long: description + "\n" + phaseASCii, +} + +func Execute() { + if err := rootCmd.Execute(); err != nil { + os.Exit(1) + } +} + +func init() { + rootCmd.Version = Version + rootCmd.SetVersionTemplate("{{ .Version }}\n") + + // Add emojis to built-in cobra commands + rootCmd.InitDefaultCompletionCmd() + if completionCmd, _, _ := rootCmd.Find([]string{"completion"}); completionCmd != nil { + completionCmd.Short = "⌨️\u200A\u200A" + completionCmd.Short + } + rootCmd.InitDefaultHelpCmd() + if helpCmd, _, _ := rootCmd.Find([]string{"help"}); helpCmd != nil { + helpCmd.Short = "🤷\u200A" + helpCmd.Short + } +} diff --git a/src/cmd/run.go b/src/cmd/run.go new file mode 100644 index 00000000..e78b9b48 --- /dev/null +++ b/src/cmd/run.go @@ -0,0 +1,148 @@ +package cmd + +import ( + "fmt" + "os" + "os/exec" + "strings" + + "github.com/phasehq/cli/pkg/phase" + "github.com/phasehq/cli/pkg/util" + "github.com/spf13/cobra" +) + +var runCmd = &cobra.Command{ + Use: "run ", + Short: "🚀 Run and inject secrets to your app", + Args: cobra.MinimumNArgs(1), + DisableFlagParsing: false, + RunE: runRun, +} + +func init() { + runCmd.Flags().String("env", "", "Environment name") + runCmd.Flags().String("app", "", "Application name") + runCmd.Flags().String("app-id", "", "Application ID") + runCmd.Flags().String("tags", "", "Filter by tags") + runCmd.Flags().String("path", "/", "Path filter") + runCmd.Flags().String("generate-leases", "true", "Generate leases for dynamic secrets") + runCmd.Flags().Int("lease-ttl", 0, "Lease TTL in seconds") + rootCmd.AddCommand(runCmd) +} + +func runRun(cmd *cobra.Command, args []string) error { + envName, _ := cmd.Flags().GetString("env") + appName, _ := cmd.Flags().GetString("app") + appID, _ := cmd.Flags().GetString("app-id") + tags, _ := cmd.Flags().GetString("tags") + path, _ := cmd.Flags().GetString("path") + generateLeases, _ := cmd.Flags().GetString("generate-leases") + leaseTTL, _ := cmd.Flags().GetInt("lease-ttl") + + p, err := phase.NewPhase(true, "", "") + if err != nil { + return err + } + + // Fetch secrets + opts := phase.GetOptions{ + EnvName: envName, + AppName: appName, + AppID: appID, + Tag: tags, + Path: path, + Dynamic: true, + Lease: util.ParseBoolFlag(generateLeases), + } + if cmd.Flags().Changed("lease-ttl") { + opts.LeaseTTL = &leaseTTL + } + + spinner := util.NewSpinner("Fetching secrets...") + spinner.Start() + allSecrets, err := p.Get(opts) + spinner.Stop() + if err != nil { + return err + } + + // Resolve references + resolvedSecrets := map[string]string{} + for _, secret := range allSecrets { + if secret.Value == "" { + continue + } + resolvedValue := phase.ResolveAllSecrets(secret.Value, allSecrets, p, secret.Application, secret.Environment) + resolvedSecrets[secret.Key] = resolvedValue + } + + // Build environment + cleanEnv := util.CleanSubprocessEnv() + for k, v := range resolvedSecrets { + cleanEnv[k] = v + } + + // Convert to env slice + var envSlice []string + for k, v := range cleanEnv { + envSlice = append(envSlice, fmt.Sprintf("%s=%s", k, v)) + } + + secretCount := len(resolvedSecrets) + apps := map[string]bool{} + envs := map[string]bool{} + for _, s := range allSecrets { + if _, ok := resolvedSecrets[s.Key]; ok { + if s.Application != "" { + apps[s.Application] = true + } + envs[s.Environment] = true + } + } + + appNames := mapKeys(apps) + envNames := mapKeys(envs) + + if path != "" && path != "/" { + fmt.Fprintf(os.Stderr, "🚀 Injected %s secrets from Application: %s, Environment: %s, Path: %s\n", + util.BoldMagentaErr(fmt.Sprintf("%d", secretCount)), + util.BoldCyanErr(strings.Join(appNames, ", ")), + util.BoldGreenErr(strings.Join(envNames, ", ")), + util.BoldYellowErr(path)) + } else { + fmt.Fprintf(os.Stderr, "🚀 Injected %s secrets from Application: %s, Environment: %s\n", + util.BoldMagentaErr(fmt.Sprintf("%d", secretCount)), + util.BoldCyanErr(strings.Join(appNames, ", ")), + util.BoldGreenErr(strings.Join(envNames, ", "))) + } + + // Execute command + command := strings.Join(args, " ") + shell := util.GetDefaultShell() + var c *exec.Cmd + if shell != nil && len(shell) > 0 { + c = exec.Command(shell[0], "-c", command) + } else { + c = exec.Command("sh", "-c", command) + } + c.Env = envSlice + c.Stdout = os.Stdout + c.Stderr = os.Stderr + c.Stdin = os.Stdin + + if err := c.Run(); err != nil { + if exitErr, ok := err.(*exec.ExitError); ok { + os.Exit(exitErr.ExitCode()) + } + return err + } + return nil +} + +func mapKeys(m map[string]bool) []string { + var keys []string + for k := range m { + keys = append(keys, k) + } + return keys +} diff --git a/src/cmd/secrets.go b/src/cmd/secrets.go new file mode 100644 index 00000000..d3e59970 --- /dev/null +++ b/src/cmd/secrets.go @@ -0,0 +1,14 @@ +package cmd + +import ( + "github.com/spf13/cobra" +) + +var secretsCmd = &cobra.Command{ + Use: "secrets", + Short: "🗝️\u200A Manage your secrets", +} + +func init() { + rootCmd.AddCommand(secretsCmd) +} diff --git a/src/cmd/secrets_create.go b/src/cmd/secrets_create.go new file mode 100644 index 00000000..5ad48d8d --- /dev/null +++ b/src/cmd/secrets_create.go @@ -0,0 +1,112 @@ +package cmd + +import ( + "fmt" + "os" + "strings" + "syscall" + + "github.com/phasehq/cli/pkg/phase" + "github.com/phasehq/cli/pkg/util" + "github.com/spf13/cobra" + "golang.org/x/term" +) + +var secretsCreateCmd = &cobra.Command{ + Use: "create [KEY]", + Short: "💳 Create a new secret", + Args: cobra.MaximumNArgs(1), + RunE: runSecretsCreate, +} + +func init() { + secretsCreateCmd.Flags().String("env", "", "Environment name") + secretsCreateCmd.Flags().String("app", "", "Application name") + secretsCreateCmd.Flags().String("app-id", "", "Application ID") + secretsCreateCmd.Flags().String("path", "/", "Path for the secret") + secretsCreateCmd.Flags().Bool("override", false, "Create with override") + secretsCreateCmd.Flags().String("random", "", "Random type (hex, alphanumeric, key128, key256)") + secretsCreateCmd.Flags().Int("length", 32, "Length for random secret") + secretsCmd.AddCommand(secretsCreateCmd) +} + +func runSecretsCreate(cmd *cobra.Command, args []string) error { + envName, _ := cmd.Flags().GetString("env") + appName, _ := cmd.Flags().GetString("app") + appID, _ := cmd.Flags().GetString("app-id") + path, _ := cmd.Flags().GetString("path") + override, _ := cmd.Flags().GetBool("override") + randomType, _ := cmd.Flags().GetString("random") + randomLength, _ := cmd.Flags().GetInt("length") + + var key string + if len(args) > 0 { + key = args[0] + } else { + fmt.Print("🗝️\u200A Please enter the key: ") + fmt.Scanln(&key) + } + + key = strings.ReplaceAll(key, " ", "_") + key = strings.ToUpper(key) + + var value string + if override { + value = "" + } else if randomType != "" { + if (randomType == "key128" || randomType == "key256") && randomLength != 32 { + fmt.Fprintf(os.Stderr, "⚠️\u200A Warning: The length argument is ignored for '%s'. Using default lengths.\n", randomType) + } + var err error + value, err = util.GenerateRandomSecret(randomType, randomLength) + if err != nil { + return fmt.Errorf("failed to generate random secret: %w", err) + } + } else { + if term.IsTerminal(int(syscall.Stdin)) { + fmt.Print("✨ Please enter the value (hidden): ") + valueBytes, err := term.ReadPassword(int(syscall.Stdin)) + if err != nil { + return fmt.Errorf("failed to read value: %w", err) + } + fmt.Println() + value = string(valueBytes) + } else { + // Read from pipe + buf := make([]byte, 1024*1024) + n, _ := os.Stdin.Read(buf) + value = strings.TrimSpace(string(buf[:n])) + } + } + + var overrideValue string + if override { + fmt.Print("✨ Please enter the 🔏 override value (hidden): ") + ovBytes, err := term.ReadPassword(int(syscall.Stdin)) + if err != nil { + return fmt.Errorf("failed to read override value: %w", err) + } + fmt.Println() + overrideValue = string(ovBytes) + } + + p, err := phase.NewPhase(true, "", "") + if err != nil { + return err + } + + err = p.Create(phase.CreateOptions{ + KeyValuePairs: []phase.KeyValuePair{{Key: key, Value: value}}, + EnvName: envName, + AppName: appName, + AppID: appID, + Path: path, + OverrideValue: overrideValue, + }) + if err != nil { + return fmt.Errorf("failed to create secret: %w", err) + } + + listSecrets(p, envName, appName, appID, "", path, false) + return nil +} diff --git a/src/cmd/secrets_delete.go b/src/cmd/secrets_delete.go new file mode 100644 index 00000000..ce63042a --- /dev/null +++ b/src/cmd/secrets_delete.go @@ -0,0 +1,71 @@ +package cmd + +import ( + "fmt" + "os" + "strings" + + "github.com/phasehq/cli/pkg/phase" + "github.com/phasehq/cli/pkg/util" + "github.com/spf13/cobra" +) + +var secretsDeleteCmd = &cobra.Command{ + Use: "delete [KEYS...]", + Short: "🗑️\u200A Delete a secret", + RunE: runSecretsDelete, +} + +func init() { + secretsDeleteCmd.Flags().String("env", "", "Environment name") + secretsDeleteCmd.Flags().String("app", "", "Application name") + secretsDeleteCmd.Flags().String("app-id", "", "Application ID") + secretsDeleteCmd.Flags().String("path", "", "Path filter") + secretsCmd.AddCommand(secretsDeleteCmd) +} + +func runSecretsDelete(cmd *cobra.Command, args []string) error { + envName, _ := cmd.Flags().GetString("env") + appName, _ := cmd.Flags().GetString("app") + appID, _ := cmd.Flags().GetString("app-id") + path, _ := cmd.Flags().GetString("path") + + keysToDelete := args + if len(keysToDelete) == 0 { + fmt.Print("Please enter the keys to delete (separate multiple keys with a space): ") + var input string + fmt.Scanln(&input) + keysToDelete = strings.Fields(input) + } + + // Uppercase keys + for i, k := range keysToDelete { + keysToDelete[i] = strings.ToUpper(k) + } + + p, err := phase.NewPhase(true, "", "") + if err != nil { + return err + } + + keysNotFound, err := p.Delete(phase.DeleteOptions{ + EnvName: envName, + AppName: appName, + AppID: appID, + KeysToDelete: keysToDelete, + Path: path, + }) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + + if len(keysNotFound) > 0 { + fmt.Fprintf(os.Stderr, "⚠️ Warning: The following keys were not found: %s\n", strings.Join(keysNotFound, ", ")) + } else { + fmt.Println(util.BoldGreen("✅ Successfully deleted the secrets.")) + } + + listSecrets(p, envName, appName, appID, "", path, false) + return nil +} diff --git a/src/cmd/secrets_export.go b/src/cmd/secrets_export.go new file mode 100644 index 00000000..159ff5ee --- /dev/null +++ b/src/cmd/secrets_export.go @@ -0,0 +1,101 @@ +package cmd + +import ( + "fmt" + "os" + + "github.com/phasehq/cli/pkg/phase" + "github.com/phasehq/cli/pkg/util" + + "github.com/spf13/cobra" +) + +var secretsExportCmd = &cobra.Command{ + Use: "export", + Short: "🥡 Export secrets in a specific format", + RunE: runSecretsExport, +} + +func init() { + secretsExportCmd.Flags().String("format", "dotenv", "Export format (dotenv, json, csv, yaml, xml, toml, hcl, ini, java_properties, kv)") + secretsExportCmd.Flags().String("env", "", "Environment name") + secretsExportCmd.Flags().String("app", "", "Application name") + secretsExportCmd.Flags().String("app-id", "", "Application ID") + secretsExportCmd.Flags().String("tags", "", "Filter by tags") + secretsExportCmd.Flags().String("path", "", "Path filter") + secretsExportCmd.Flags().String("generate-leases", "true", "Generate leases for dynamic secrets") + secretsExportCmd.Flags().Int("lease-ttl", 0, "Lease TTL in seconds") + secretsCmd.AddCommand(secretsExportCmd) +} + +func runSecretsExport(cmd *cobra.Command, args []string) error { + format, _ := cmd.Flags().GetString("format") + envName, _ := cmd.Flags().GetString("env") + appName, _ := cmd.Flags().GetString("app") + appID, _ := cmd.Flags().GetString("app-id") + tags, _ := cmd.Flags().GetString("tags") + path, _ := cmd.Flags().GetString("path") + generateLeases, _ := cmd.Flags().GetString("generate-leases") + leaseTTL, _ := cmd.Flags().GetInt("lease-ttl") + + p, err := phase.NewPhase(true, "", "") + if err != nil { + return err + } + + opts := phase.GetOptions{ + EnvName: envName, + AppName: appName, + AppID: appID, + Tag: tags, + Path: path, + Dynamic: true, + Lease: util.ParseBoolFlag(generateLeases), + } + if cmd.Flags().Changed("lease-ttl") { + opts.LeaseTTL = &leaseTTL + } + + allSecrets, err := p.Get(opts) + if err != nil { + return err + } + + // Resolve secret references and build key-value map + secretsDict := map[string]string{} + for _, secret := range allSecrets { + if secret.Value == "" { + continue + } + resolvedValue := phase.ResolveAllSecrets(secret.Value, allSecrets, p, secret.Application, secret.Environment) + secretsDict[secret.Key] = resolvedValue + } + + switch format { + case "json": + util.ExportJSON(secretsDict) + case "csv": + util.ExportCSV(secretsDict) + case "yaml": + util.ExportYAML(secretsDict) + case "xml": + util.ExportXML(secretsDict) + case "toml": + util.ExportTOML(secretsDict) + case "hcl": + util.ExportHCL(secretsDict) + case "ini": + util.ExportINI(secretsDict) + case "java_properties": + util.ExportJavaProperties(secretsDict) + case "kv": + util.ExportKV(secretsDict) + case "dotenv": + util.ExportDotenv(secretsDict) + default: + fmt.Fprintf(os.Stderr, "Unknown format: %s, using dotenv\n", format) + util.ExportDotenv(secretsDict) + } + + return nil +} diff --git a/src/cmd/secrets_get.go b/src/cmd/secrets_get.go new file mode 100644 index 00000000..bd980255 --- /dev/null +++ b/src/cmd/secrets_get.go @@ -0,0 +1,81 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "os" + "strings" + + "github.com/phasehq/cli/pkg/phase" + "github.com/phasehq/cli/pkg/util" + "github.com/spf13/cobra" +) + +var secretsGetCmd = &cobra.Command{ + Use: "get ", + Short: "🔍 Fetch details about a secret in JSON", + Args: cobra.ExactArgs(1), + RunE: runSecretsGet, +} + +func init() { + secretsGetCmd.Flags().String("env", "", "Environment name") + secretsGetCmd.Flags().String("app", "", "Application name") + secretsGetCmd.Flags().String("app-id", "", "Application ID") + secretsGetCmd.Flags().String("path", "/", "Path filter") + secretsGetCmd.Flags().String("generate-leases", "true", "Generate leases for dynamic secrets") + secretsGetCmd.Flags().Int("lease-ttl", 0, "Lease TTL in seconds") + secretsCmd.AddCommand(secretsGetCmd) +} + +func runSecretsGet(cmd *cobra.Command, args []string) error { + key := strings.ToUpper(args[0]) + envName, _ := cmd.Flags().GetString("env") + appName, _ := cmd.Flags().GetString("app") + appID, _ := cmd.Flags().GetString("app-id") + path, _ := cmd.Flags().GetString("path") + generateLeases, _ := cmd.Flags().GetString("generate-leases") + leaseTTL, _ := cmd.Flags().GetInt("lease-ttl") + + p, err := phase.NewPhase(true, "", "") + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + + opts := phase.GetOptions{ + EnvName: envName, + AppName: appName, + AppID: appID, + Keys: []string{key}, + Path: path, + Dynamic: true, + Lease: util.ParseBoolFlag(generateLeases), + } + if cmd.Flags().Changed("lease-ttl") { + opts.LeaseTTL = &leaseTTL + } + + secrets, err := p.Get(opts) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + + var found *phase.SecretResult + for i, s := range secrets { + if s.Key == key { + found = &secrets[i] + break + } + } + + if found == nil { + fmt.Fprintf(os.Stderr, "🔍 Secret not found\n") + os.Exit(1) + } + + data, _ := json.MarshalIndent(found, "", " ") + fmt.Println(string(data)) + return nil +} diff --git a/src/cmd/secrets_import.go b/src/cmd/secrets_import.go new file mode 100644 index 00000000..4a4b429f --- /dev/null +++ b/src/cmd/secrets_import.go @@ -0,0 +1,71 @@ +package cmd + +import ( + "fmt" + + "github.com/phasehq/cli/pkg/phase" + "github.com/phasehq/cli/pkg/util" + "github.com/spf13/cobra" +) + +var secretsImportCmd = &cobra.Command{ + Use: "import ", + Short: "📩 Import secrets from a .env file", + Args: cobra.ExactArgs(1), + RunE: runSecretsImport, +} + +func init() { + secretsImportCmd.Flags().String("env", "", "Environment name") + secretsImportCmd.Flags().String("app", "", "Application name") + secretsImportCmd.Flags().String("app-id", "", "Application ID") + secretsImportCmd.Flags().String("path", "/", "Path for imported secrets") + secretsCmd.AddCommand(secretsImportCmd) +} + +func runSecretsImport(cmd *cobra.Command, args []string) error { + envFile := args[0] + envName, _ := cmd.Flags().GetString("env") + appName, _ := cmd.Flags().GetString("app") + appID, _ := cmd.Flags().GetString("app-id") + path, _ := cmd.Flags().GetString("path") + + // Parse env file + pairs, err := util.ParseEnvFile(envFile) + if err != nil { + return fmt.Errorf("failed to read file %s: %w", envFile, err) + } + + // Convert to key-value pairs + var kvPairs []phase.KeyValuePair + for _, pair := range pairs { + kvPairs = append(kvPairs, phase.KeyValuePair{ + Key: pair.Key, + Value: pair.Value, + }) + } + + p, err := phase.NewPhase(true, "", "") + if err != nil { + return err + } + + err = p.Create(phase.CreateOptions{ + KeyValuePairs: kvPairs, + EnvName: envName, + AppName: appName, + AppID: appID, + Path: path, + }) + if err != nil { + return fmt.Errorf("failed to import secrets: %w", err) + } + + fmt.Println(util.BoldGreen(fmt.Sprintf("✅ Successfully imported and encrypted %d secrets.", len(kvPairs)))) + if envName == "" { + fmt.Println("To view them please run: phase secrets list") + } else { + fmt.Printf("To view them please run: phase secrets list --env %s\n", envName) + } + return nil +} diff --git a/src/cmd/secrets_list.go b/src/cmd/secrets_list.go new file mode 100644 index 00000000..a5e19fad --- /dev/null +++ b/src/cmd/secrets_list.go @@ -0,0 +1,79 @@ +package cmd + +import ( + "fmt" + "os" + + "github.com/phasehq/cli/pkg/display" + "github.com/phasehq/cli/pkg/phase" + "github.com/phasehq/cli/pkg/util" + "github.com/spf13/cobra" +) + +var secretsListCmd = &cobra.Command{ + Use: "list", + Short: "📇 List all the secrets", + Long: `📇 List all the secrets + +Icon legend: + 🔗 Secret references another secret in the same environment + ⛓️ Cross-environment reference (secret from another environment) + 🏷️ Tag associated with the secret + 💬 Comment associated with the secret + 🔏 Personal secret override (visible only to you) + ⚡️ Dynamic secret`, + RunE: runSecretsList, +} + +func init() { + secretsListCmd.Flags().Bool("show", false, "Show decrypted secret values") + secretsListCmd.Flags().String("env", "", "Environment name") + secretsListCmd.Flags().String("app", "", "Application name") + secretsListCmd.Flags().String("app-id", "", "Application ID") + secretsListCmd.Flags().String("tags", "", "Filter by tags") + secretsListCmd.Flags().String("path", "", "Path filter") + secretsCmd.AddCommand(secretsListCmd) +} + +// listSecrets fetches and displays secrets. Used by list, create, update, and delete commands. +func listSecrets(p *phase.Phase, envName, appName, appID, tags, path string, show bool) { + spinner := util.NewSpinner("Fetching secrets...") + spinner.Start() + secrets, err := p.Get(phase.GetOptions{ + EnvName: envName, + AppName: appName, + AppID: appID, + Tag: tags, + Path: path, + }) + spinner.Stop() + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + return + } + + display.RenderSecretsTree(secrets, show) +} + +func runSecretsList(cmd *cobra.Command, args []string) error { + show, _ := cmd.Flags().GetBool("show") + envName, _ := cmd.Flags().GetString("env") + appName, _ := cmd.Flags().GetString("app") + appID, _ := cmd.Flags().GetString("app-id") + tags, _ := cmd.Flags().GetString("tags") + path, _ := cmd.Flags().GetString("path") + + p, err := phase.NewPhase(true, "", "") + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + + listSecrets(p, envName, appName, appID, tags, path, show) + + fmt.Println("🔬 To view a secret, use: phase secrets get ") + if !show { + fmt.Println("🥽 To uncover the secrets, use: phase secrets list --show") + } + return nil +} diff --git a/src/cmd/secrets_update.go b/src/cmd/secrets_update.go new file mode 100644 index 00000000..8cfd61a8 --- /dev/null +++ b/src/cmd/secrets_update.go @@ -0,0 +1,124 @@ +package cmd + +import ( + "fmt" + "os" + "strings" + "syscall" + + "github.com/phasehq/cli/pkg/phase" + "github.com/phasehq/cli/pkg/util" + "github.com/spf13/cobra" + "golang.org/x/term" +) + +var secretsUpdateCmd = &cobra.Command{ + Use: "update ", + Short: "📝 Update an existing secret", + Args: cobra.MaximumNArgs(1), + RunE: runSecretsUpdate, +} + +func init() { + secretsUpdateCmd.Flags().String("env", "", "Environment name") + secretsUpdateCmd.Flags().String("app", "", "Application name") + secretsUpdateCmd.Flags().String("app-id", "", "Application ID") + secretsUpdateCmd.Flags().String("path", "", "Source path of the secret") + secretsUpdateCmd.Flags().String("updated-path", "", "New path for the secret") + secretsUpdateCmd.Flags().Bool("override", false, "Update override value") + secretsUpdateCmd.Flags().Bool("toggle-override", false, "Toggle override state") + secretsUpdateCmd.Flags().String("random", "", "Random type (hex, alphanumeric, key128, key256)") + secretsUpdateCmd.Flags().Int("length", 32, "Length for random secret") + secretsCmd.AddCommand(secretsUpdateCmd) +} + +func runSecretsUpdate(cmd *cobra.Command, args []string) error { + envName, _ := cmd.Flags().GetString("env") + appName, _ := cmd.Flags().GetString("app") + appID, _ := cmd.Flags().GetString("app-id") + sourcePath, _ := cmd.Flags().GetString("path") + destPath, _ := cmd.Flags().GetString("updated-path") + override, _ := cmd.Flags().GetBool("override") + toggleOverride, _ := cmd.Flags().GetBool("toggle-override") + randomType, _ := cmd.Flags().GetString("random") + randomLength, _ := cmd.Flags().GetInt("length") + + var key string + if len(args) > 0 { + key = args[0] + } else { + fmt.Print("🗝️\u200A Please enter the key: ") + fmt.Scanln(&key) + } + + key = strings.ReplaceAll(key, " ", "_") + key = strings.ToUpper(key) + + var newValue string + if toggleOverride { + // No value needed for toggle + } else if randomType != "" { + if (randomType == "key128" || randomType == "key256") && randomLength != 32 { + fmt.Fprintf(os.Stderr, "⚠️\u200A Warning: The length argument is ignored for '%s'. Using default lengths.\n", randomType) + } + var err error + newValue, err = util.GenerateRandomSecret(randomType, randomLength) + if err != nil { + return fmt.Errorf("failed to generate random secret: %w", err) + } + } else if override { + fmt.Print("✨ Please enter the 🔏 override value (hidden): ") + valueBytes, err := term.ReadPassword(int(syscall.Stdin)) + if err != nil { + return fmt.Errorf("failed to read value: %w", err) + } + fmt.Println() + newValue = string(valueBytes) + } else { + if term.IsTerminal(int(syscall.Stdin)) { + fmt.Printf("✨ Please enter the new value for %s (hidden): ", key) + valueBytes, err := term.ReadPassword(int(syscall.Stdin)) + if err != nil { + return fmt.Errorf("failed to read value: %w", err) + } + fmt.Println() + newValue = string(valueBytes) + } else { + buf := make([]byte, 1024*1024) + n, _ := os.Stdin.Read(buf) + newValue = strings.TrimSpace(string(buf[:n])) + } + } + + p, err := phase.NewPhase(true, "", "") + if err != nil { + return err + } + + result, err := p.Update(phase.UpdateOptions{ + EnvName: envName, + AppName: appName, + AppID: appID, + Key: key, + Value: newValue, + SourcePath: sourcePath, + DestinationPath: destPath, + Override: override, + ToggleOverride: toggleOverride, + }) + if err != nil { + return fmt.Errorf("error updating secret: %w", err) + } + + if result == "Success" { + fmt.Println(util.BoldGreen("✅ Successfully updated the secret.")) + listPath := sourcePath + if destPath != "" { + listPath = destPath + } + listSecrets(p, envName, appName, appID, "", listPath, false) + } else { + fmt.Println(result) + } + return nil +} diff --git a/src/cmd/shell.go b/src/cmd/shell.go new file mode 100644 index 00000000..ac01c892 --- /dev/null +++ b/src/cmd/shell.go @@ -0,0 +1,169 @@ +package cmd + +import ( + "fmt" + "os" + "os/exec" + "strings" + + "github.com/phasehq/cli/pkg/phase" + "github.com/phasehq/cli/pkg/util" + "github.com/spf13/cobra" +) + +var shellCmd = &cobra.Command{ + Use: "shell", + Short: "🐚 Launch a sub-shell with secrets as environment variables", + RunE: runShell, +} + +func init() { + shellCmd.Flags().String("env", "", "Environment name") + shellCmd.Flags().String("app", "", "Application name") + shellCmd.Flags().String("app-id", "", "Application ID") + shellCmd.Flags().String("tags", "", "Filter by tags") + shellCmd.Flags().String("path", "/", "Path filter") + shellCmd.Flags().String("shell", "", "Shell to use (bash, zsh, fish, sh, powershell, pwsh, cmd)") + shellCmd.Flags().String("generate-leases", "true", "Generate leases for dynamic secrets") + shellCmd.Flags().Int("lease-ttl", 0, "Lease TTL in seconds") + rootCmd.AddCommand(shellCmd) +} + +func runShell(cmd *cobra.Command, args []string) error { + envName, _ := cmd.Flags().GetString("env") + appName, _ := cmd.Flags().GetString("app") + appID, _ := cmd.Flags().GetString("app-id") + tags, _ := cmd.Flags().GetString("tags") + path, _ := cmd.Flags().GetString("path") + shellType, _ := cmd.Flags().GetString("shell") + generateLeases, _ := cmd.Flags().GetString("generate-leases") + leaseTTL, _ := cmd.Flags().GetInt("lease-ttl") + + p, err := phase.NewPhase(true, "", "") + if err != nil { + return err + } + + opts := phase.GetOptions{ + EnvName: envName, + AppName: appName, + AppID: appID, + Tag: tags, + Path: path, + Dynamic: true, + Lease: util.ParseBoolFlag(generateLeases), + } + if cmd.Flags().Changed("lease-ttl") { + opts.LeaseTTL = &leaseTTL + } + + spinner := util.NewSpinner("Fetching secrets...") + spinner.Start() + allSecrets, err := p.Get(opts) + spinner.Stop() + if err != nil { + return err + } + + // Resolve references + resolvedSecrets := map[string]string{} + for _, secret := range allSecrets { + if secret.Value == "" { + continue + } + resolvedValue := phase.ResolveAllSecrets(secret.Value, allSecrets, p, secret.Application, secret.Environment) + resolvedSecrets[secret.Key] = resolvedValue + } + + // Build environment + cleanEnv := util.CleanSubprocessEnv() + for k, v := range resolvedSecrets { + cleanEnv[k] = v + } + + // Set Phase shell markers + cleanEnv["PHASE_SHELL"] = "true" + + // Collect env/app info for display + apps := map[string]bool{} + envs := map[string]bool{} + for _, s := range allSecrets { + if _, ok := resolvedSecrets[s.Key]; ok { + if s.Application != "" { + apps[s.Application] = true + } + envs[s.Environment] = true + } + } + appNames := mapKeys(apps) + envNames := mapKeys(envs) + if len(envNames) > 0 { + cleanEnv["PHASE_ENV"] = envNames[0] + } + if len(appNames) > 0 { + cleanEnv["PHASE_APP"] = appNames[0] + } + + // Ensure TERM is set + if cleanEnv["TERM"] == "" { + cleanEnv["TERM"] = "xterm-256color" + } + + // Convert to env slice + var envSlice []string + for k, v := range cleanEnv { + envSlice = append(envSlice, fmt.Sprintf("%s=%s", k, v)) + } + + // Determine shell + var shellArgs []string + if shellType != "" { + shellArgs, err = util.GetShellCommand(shellType) + if err != nil { + return err + } + } else { + shellArgs = util.GetDefaultShell() + if shellArgs == nil { + return fmt.Errorf("no shell found") + } + } + + secretCount := len(resolvedSecrets) + shellName := shellArgs[0] + if path != "" && path != "/" { + fmt.Fprintf(os.Stderr, "🐚 Initialized %s with %s secrets from Application: %s, Environment: %s, Path: %s\n", + util.BoldGreenErr(shellName), + util.BoldMagentaErr(fmt.Sprintf("%d", secretCount)), + util.BoldCyanErr(strings.Join(appNames, ", ")), + util.BoldGreenErr(strings.Join(envNames, ", ")), + util.BoldYellowErr(path)) + } else { + fmt.Fprintf(os.Stderr, "🐚 Initialized %s with %s secrets from Application: %s, Environment: %s\n", + util.BoldGreenErr(shellName), + util.BoldMagentaErr(fmt.Sprintf("%d", secretCount)), + util.BoldCyanErr(strings.Join(appNames, ", ")), + util.BoldGreenErr(strings.Join(envNames, ", "))) + } + fmt.Fprintf(os.Stderr, "%s Secrets are only available in this session. Type %s or press %s to exit.\n", + util.BoldYellowErr("Remember:"), + util.BoldErr("exit"), + util.BoldErr("Ctrl+D")) + + // Launch shell + c := exec.Command(shellArgs[0], shellArgs[1:]...) + c.Env = envSlice + c.Stdin = os.Stdin + c.Stdout = os.Stdout + c.Stderr = os.Stderr + + if err := c.Run(); err != nil { + if exitErr, ok := err.(*exec.ExitError); ok { + fmt.Fprintf(os.Stderr, "%s Phase secrets are no longer available.\n", util.BoldRedErr("🐚 Shell session ended.")) + os.Exit(exitErr.ExitCode()) + } + return err + } + fmt.Fprintf(os.Stderr, "%s Phase secrets are no longer available.\n", util.BoldRedErr("🐚 Shell session ended.")) + return nil +} diff --git a/src/cmd/update.go b/src/cmd/update.go new file mode 100644 index 00000000..65d69cf2 --- /dev/null +++ b/src/cmd/update.go @@ -0,0 +1,74 @@ +package cmd + +import ( + "fmt" + "io" + "net/http" + "os" + "os/exec" + "runtime" + + "github.com/phasehq/cli/pkg/util" + "github.com/spf13/cobra" +) + +func init() { + if runtime.GOOS == "linux" { + updateCmd := &cobra.Command{ + Use: "update", + Short: "🆙 Update the Phase CLI to the latest version", + RunE: runUpdate, + } + rootCmd.AddCommand(updateCmd) + } +} + +func runUpdate(cmd *cobra.Command, args []string) error { + fmt.Println("Updating Phase CLI...") + + resp, err := http.Get("https://pkg.phase.dev/install.sh") + if err != nil { + return fmt.Errorf("failed to download install script: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("failed to download install script: HTTP %d", resp.StatusCode) + } + + tmpFile, err := os.CreateTemp("", "phase-install-*.sh") + if err != nil { + return fmt.Errorf("failed to create temp file: %w", err) + } + tmpPath := tmpFile.Name() + defer os.Remove(tmpPath) + + if _, err := io.Copy(tmpFile, resp.Body); err != nil { + tmpFile.Close() + return fmt.Errorf("failed to write install script: %w", err) + } + tmpFile.Close() + + if err := os.Chmod(tmpPath, 0755); err != nil { + return fmt.Errorf("failed to make script executable: %w", err) + } + + cleanEnv := util.CleanSubprocessEnv() + var envSlice []string + for k, v := range cleanEnv { + envSlice = append(envSlice, fmt.Sprintf("%s=%s", k, v)) + } + + c := exec.Command(tmpPath) + c.Env = envSlice + c.Stdout = os.Stdout + c.Stderr = os.Stderr + c.Stdin = os.Stdin + + if err := c.Run(); err != nil { + return fmt.Errorf("update failed: %w", err) + } + + fmt.Println(util.BoldGreen("✅ Update completed successfully.")) + return nil +} diff --git a/src/cmd/users.go b/src/cmd/users.go new file mode 100644 index 00000000..ec243cd4 --- /dev/null +++ b/src/cmd/users.go @@ -0,0 +1,14 @@ +package cmd + +import ( + "github.com/spf13/cobra" +) + +var usersCmd = &cobra.Command{ + Use: "users", + Short: "👥 Manage users and accounts", +} + +func init() { + rootCmd.AddCommand(usersCmd) +} diff --git a/src/cmd/users_keyring.go b/src/cmd/users_keyring.go new file mode 100644 index 00000000..07001d7a --- /dev/null +++ b/src/cmd/users_keyring.go @@ -0,0 +1,32 @@ +package cmd + +import ( + "fmt" + "runtime" + + "github.com/spf13/cobra" +) + +var usersKeyringCmd = &cobra.Command{ + Use: "keyring", + Short: "🔐 Display information about the Phase keyring", + RunE: runUsersKeyring, +} + +func init() { + usersCmd.AddCommand(usersKeyringCmd) +} + +func runUsersKeyring(cmd *cobra.Command, args []string) error { + switch runtime.GOOS { + case "darwin": + fmt.Println("Keyring backend: macOS Keychain") + case "linux": + fmt.Println("Keyring backend: GNOME Keyring / Secret Service") + case "windows": + fmt.Println("Keyring backend: Windows Credential Manager") + default: + fmt.Printf("Keyring backend: Unknown (%s)\n", runtime.GOOS) + } + return nil +} diff --git a/src/cmd/users_logout.go b/src/cmd/users_logout.go new file mode 100644 index 00000000..98398439 --- /dev/null +++ b/src/cmd/users_logout.go @@ -0,0 +1,63 @@ +package cmd + +import ( + "fmt" + "os" + + "github.com/phasehq/cli/pkg/config" + "github.com/phasehq/cli/pkg/keyring" + "github.com/spf13/cobra" +) + +var usersLogoutCmd = &cobra.Command{ + Use: "logout", + Short: "🏃 Logout from phase-cli", + RunE: runUsersLogout, +} + +func init() { + usersLogoutCmd.Flags().Bool("purge", false, "Purge all local data") + usersCmd.AddCommand(usersLogoutCmd) +} + +func runUsersLogout(cmd *cobra.Command, args []string) error { + purge, _ := cmd.Flags().GetBool("purge") + + if purge { + // Delete all keyring entries and remove local data + ids, err := config.GetDefaultAccountID(true) + if err != nil { + return err + } + for _, id := range ids { + keyring.DeleteCredentials(id) + } + if _, err := os.Stat(config.PhaseSecretsDir); err == nil { + if err := os.RemoveAll(config.PhaseSecretsDir); err != nil { + return fmt.Errorf("failed to purge local data: %w", err) + } + fmt.Println("Logged out and purged all local data.") + } else { + fmt.Println("No local data found to purge.") + } + } else { + // Remove current user + ids, err := config.GetDefaultAccountID(false) + if err != nil { + return fmt.Errorf("no configuration found. Please run 'phase auth' to set up your configuration") + } + if len(ids) == 0 || ids[0] == "" { + return fmt.Errorf("no default user in configuration found") + } + + accountID := ids[0] + keyring.DeleteCredentials(accountID) + + if err := config.RemoveUser(accountID); err != nil { + return fmt.Errorf("failed to update config: %w", err) + } + fmt.Println("Logged out successfully.") + } + + return nil +} diff --git a/src/cmd/users_switch.go b/src/cmd/users_switch.go new file mode 100644 index 00000000..204e3ee8 --- /dev/null +++ b/src/cmd/users_switch.go @@ -0,0 +1,78 @@ +package cmd + +import ( + "fmt" + + "github.com/manifoldco/promptui" + "github.com/phasehq/cli/pkg/config" + "github.com/spf13/cobra" +) + +var usersSwitchCmd = &cobra.Command{ + Use: "switch", + Short: "🪄\u200A Switch between Phase users, orgs and hosts", + RunE: runUsersSwitch, +} + +func init() { + usersCmd.AddCommand(usersSwitchCmd) +} + +func runUsersSwitch(cmd *cobra.Command, args []string) error { + cfg, err := config.LoadConfig() + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + + if len(cfg.PhaseUsers) == 0 { + return fmt.Errorf("no users found. Please authenticate first with 'phase auth'") + } + + // Build display labels for each user + items := make([]string, len(cfg.PhaseUsers)) + for i, user := range cfg.PhaseUsers { + orgName := "N/A" + if user.OrganizationName != nil { + orgName = *user.OrganizationName + } + email := "Service Account" + if user.Email != "" { + email = user.Email + } + shortID := user.ID + if len(shortID) > 8 { + shortID = shortID[:8] + } + marker := "" + if user.ID == cfg.DefaultUser { + marker = " (current)" + } + items[i] = fmt.Sprintf("🏢 %s, ✉️ %s, ☁️ %s, 🆔 %s%s", orgName, email, user.Host, shortID, marker) + } + + prompt := promptui.Select{ + Label: "Select a user", + Items: items, + } + idx, _, err := prompt.Run() + if err != nil { + return fmt.Errorf("prompt cancelled") + } + + selectedUser := cfg.PhaseUsers[idx] + if err := config.SetDefaultUser(selectedUser.ID); err != nil { + return fmt.Errorf("failed to switch user: %w", err) + } + + orgName := "N/A" + if selectedUser.OrganizationName != nil { + orgName = *selectedUser.OrganizationName + } + email := "Service Account" + if selectedUser.Email != "" { + email = selectedUser.Email + } + + fmt.Printf("Switched to account 🙋: %s (%s)\n", email, orgName) + return nil +} diff --git a/src/cmd/users_whoami.go b/src/cmd/users_whoami.go new file mode 100644 index 00000000..b6e23270 --- /dev/null +++ b/src/cmd/users_whoami.go @@ -0,0 +1,40 @@ +package cmd + +import ( + "fmt" + + "github.com/phasehq/cli/pkg/config" + "github.com/spf13/cobra" +) + +var usersWhoamiCmd = &cobra.Command{ + Use: "whoami", + Short: "🙋 See details of the current user", + RunE: runUsersWhoami, +} + +func init() { + usersCmd.AddCommand(usersWhoamiCmd) +} + +func runUsersWhoami(cmd *cobra.Command, args []string) error { + user, err := config.GetDefaultUser() + if err != nil { + return fmt.Errorf("not logged in: %w", err) + } + + email := user.Email + if email == "" { + email = "N/A (Service Account)" + } + + fmt.Printf("✉️\u200A Email: %s\n", email) + fmt.Printf("🙋 Account ID: %s\n", user.ID) + orgName := "N/A" + if user.OrganizationName != nil { + orgName = *user.OrganizationName + } + fmt.Printf("🏢 Organization: %s\n", orgName) + fmt.Printf("☁️\u200A Host: %s\n", user.Host) + return nil +} diff --git a/src/go.mod b/src/go.mod new file mode 100644 index 00000000..90b50e8d --- /dev/null +++ b/src/go.mod @@ -0,0 +1,43 @@ +module github.com/phasehq/cli + +go 1.24.0 + +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/modelcontextprotocol/go-sdk v1.2.0 + github.com/phasehq/golang-sdk v0.0.0-00010101000000-000000000000 + 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 +) + +require ( + al.essio.dev/pkg/shellescape v1.5.1 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.19.7 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17 // indirect + github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17 // indirect + github.com/aws/aws-sdk-go-v2/service/signin v1.0.5 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.30.9 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.13 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.41.6 // indirect + github.com/aws/smithy-go v1.24.0 // indirect + github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e // indirect + github.com/danieljoos/wincred v1.2.2 // indirect + github.com/godbus/dbus/v5 v5.1.0 // indirect + github.com/google/jsonschema-go v0.3.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/spf13/pflag v1.0.5 // indirect + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect + golang.org/x/crypto v0.31.0 // indirect + golang.org/x/oauth2 v0.30.0 // indirect + golang.org/x/sys v0.40.0 // indirect +) + +replace github.com/phasehq/golang-sdk => /Users/nimish/git/phase/golang-sdk diff --git a/src/go.sum b/src/go.sum new file mode 100644 index 00000000..b6ab2968 --- /dev/null +++ b/src/go.sum @@ -0,0 +1,87 @@ +al.essio.dev/pkg/shellescape v1.5.1 h1:86HrALUujYS/h+GtqoB26SBEdkWfmMI6FubjXlsXyho= +al.essio.dev/pkg/shellescape v1.5.1/go.mod h1:6sIqp7X2P6mThCQ7twERpZTuigpr6KbZWtls1U8I890= +github.com/aws/aws-sdk-go-v2 v1.41.1 h1:ABlyEARCDLN034NhxlRUSZr4l71mh+T5KAeGh6cerhU= +github.com/aws/aws-sdk-go-v2 v1.41.1/go.mod h1:MayyLB8y+buD9hZqkCW3kX1AKq07Y5pXxtgB+rRFhz0= +github.com/aws/aws-sdk-go-v2/config v1.32.7 h1:vxUyWGUwmkQ2g19n7JY/9YL8MfAIl7bTesIUykECXmY= +github.com/aws/aws-sdk-go-v2/config v1.32.7/go.mod h1:2/Qm5vKUU/r7Y+zUk/Ptt2MDAEKAfUtKc1+3U1Mo3oY= +github.com/aws/aws-sdk-go-v2/credentials v1.19.7 h1:tHK47VqqtJxOymRrNtUXN5SP/zUTvZKeLx4tH6PGQc8= +github.com/aws/aws-sdk-go-v2/credentials v1.19.7/go.mod h1:qOZk8sPDrxhf+4Wf4oT2urYJrYt3RejHSzgAquYeppw= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17 h1:I0GyV8wiYrP8XpA70g1HBcQO1JlQxCMTW9npl5UbDHY= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17/go.mod h1:tyw7BOl5bBe/oqvoIeECFJjMdzXoa/dfVz3QQ5lgHGA= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17 h1:xOLELNKGp2vsiteLsvLPwxC+mYmO6OZ8PYgiuPJzF8U= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17/go.mod h1:5M5CI3D12dNOtH3/mk6minaRwI2/37ifCURZISxA/IQ= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17 h1:WWLqlh79iO48yLkj1v3ISRNiv+3KdQoZ6JWyfcsyQik= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17/go.mod h1:EhG22vHRrvF8oXSTYStZhJc1aUgKtnJe+aOiFEV90cM= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 h1:0ryTNEdJbzUCEWkVXEXoqlXV72J5keC1GvILMOuD00E= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4/go.mod h1:HQ4qwNZh32C3CBeO6iJLQlgtMzqeG17ziAA/3KDJFow= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17 h1:RuNSMoozM8oXlgLG/n6WLaFGoea7/CddrCfIiSA+xdY= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17/go.mod h1:F2xxQ9TZz5gDWsclCtPQscGpP0VUOc8RqgFM3vDENmU= +github.com/aws/aws-sdk-go-v2/service/signin v1.0.5 h1:VrhDvQib/i0lxvr3zqlUwLwJP4fpmpyD9wYG1vfSu+Y= +github.com/aws/aws-sdk-go-v2/service/signin v1.0.5/go.mod h1:k029+U8SY30/3/ras4G/Fnv/b88N4mAfliNn08Dem4M= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.9 h1:v6EiMvhEYBoHABfbGB4alOYmCIrcgyPPiBE1wZAEbqk= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.9/go.mod h1:yifAsgBxgJWn3ggx70A3urX2AN49Y5sJTD1UQFlfqBw= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.13 h1:gd84Omyu9JLriJVCbGApcLzVR3XtmC4ZDPcAI6Ftvds= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.13/go.mod h1:sTGThjphYE4Ohw8vJiRStAcu3rbjtXRsdNB0TvZ5wwo= +github.com/aws/aws-sdk-go-v2/service/sts v1.41.6 h1:5fFjR/ToSOzB2OQ/XqWpZBmNvmP/pJ1jOWYlFDJTjRQ= +github.com/aws/aws-sdk-go-v2/service/sts v1.41.6/go.mod h1:qgFDZQSD/Kys7nJnVqYlWKnh0SSdMjAi0uSwON4wgYQ= +github.com/aws/smithy-go v1.24.0 h1:LpilSUItNPFr1eY85RYgTIg5eIEPtvFbskaFcmmIUnk= +github.com/aws/smithy-go v1.24.0/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0= +github.com/chzyer/logex v1.1.10 h1:Swpa1K6QvQznwJRcfTfQJmTE72DqScAa40E+fbHEXEE= +github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= +github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e h1:fY5BOSpyZCqRo5OhCuC+XN+r/bBCmeuuJtjz+bCNIf8= +github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= +github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1 h1:q763qf9huN11kDQavWsoZXJNW3xEE4JJyHa5Q25/sd8= +github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= +github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/danieljoos/wincred v1.2.2 h1:774zMFJrqaeYCK2W57BgAem/MLi6mtSE47MB6BOJ0i0= +github.com/danieljoos/wincred v1.2.2/go.mod h1:w7w4Utbrz8lqeMbDAK0lkNJUv5sAOkFi7nd/ogr0Uh8= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= +github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= +github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/jsonschema-go v0.3.0 h1:6AH2TxVNtk3IlvkkhjrtbUc4S8AvO0Xii0DxIygDg+Q= +github.com/google/jsonschema-go v0.3.0/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= +github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= +github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/manifoldco/promptui v0.9.0 h1:3V4HzJk1TtXW1MTZMP7mdlwbBpIinw3HztaIlYthEiA= +github.com/manifoldco/promptui v0.9.0/go.mod h1:ka04sppxSGFAtxX0qhlYQjISsg9mR4GWtQEhdbn6Pgg= +github.com/modelcontextprotocol/go-sdk v1.2.0 h1:Y23co09300CEk8iZ/tMxIX1dVmKZkzoSBZOpJwUnc/s= +github.com/modelcontextprotocol/go-sdk v1.2.0/go.mod h1:6fM3LCm3yV7pAs8isnKLn07oKtB0MP9LHd3DfAcKw10= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0= +github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= +github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= +github.com/zalando/go-keyring v0.2.6 h1:r7Yc3+H+Ux0+M72zacZoItR3UDxeWfKTcabvkI8ua9s= +github.com/zalando/go-keyring v0.2.6/go.mod h1:2TCrxYrbUNYfNS/Kgy/LSrkSQzZ5UPVH85RwfczwvcI= +golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= +golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= +golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= +golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= +golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= +golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY= +golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww= +golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo= +golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/src/main.go b/src/main.go new file mode 100644 index 00000000..801e9bfe --- /dev/null +++ b/src/main.go @@ -0,0 +1,7 @@ +package main + +import "github.com/phasehq/cli/cmd" + +func main() { + cmd.Execute() +} diff --git a/src/pkg/config/config.go b/src/pkg/config/config.go new file mode 100644 index 00000000..dd9fc7aa --- /dev/null +++ b/src/pkg/config/config.go @@ -0,0 +1,207 @@ +package config + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" +) + +const ( + PhaseCloudAPIHost = "https://console.phase.dev" + PhaseEnvConfig = ".phase.json" +) + +var ( + PhaseSecretsDir = filepath.Join(homeDir(), ".phase", "secrets") + ConfigFilePath = filepath.Join(PhaseSecretsDir, "config.json") +) + +func homeDir() string { + home, err := os.UserHomeDir() + if err != nil { + return "" + } + return home +} + +type UserConfig struct { + Host string `json:"host"` + ID string `json:"id"` + Email string `json:"email,omitempty"` + OrganizationID *string `json:"organization_id,omitempty"` + OrganizationName *string `json:"organization_name,omitempty"` + WrappedKeyShare *string `json:"wrapped_key_share,omitempty"` + Token string `json:"token,omitempty"` +} + +type Config struct { + DefaultUser string `json:"default-user"` + PhaseUsers []UserConfig `json:"phase-users"` +} + +func LoadConfig() (*Config, error) { + data, err := os.ReadFile(ConfigFilePath) + if err != nil { + return nil, err + } + var config Config + if err := json.Unmarshal(data, &config); err != nil { + return nil, fmt.Errorf("error reading config file: %w", err) + } + return &config, nil +} + +func SaveConfig(config *Config) error { + if err := os.MkdirAll(PhaseSecretsDir, 0700); err != nil { + return err + } + data, err := json.MarshalIndent(config, "", " ") + if err != nil { + return err + } + return os.WriteFile(ConfigFilePath, data, 0600) +} + +func GetDefaultUser() (*UserConfig, error) { + config, err := LoadConfig() + if err != nil { + return nil, fmt.Errorf("please login with phase auth or supply a PHASE_SERVICE_TOKEN as an environment variable") + } + if config.DefaultUser == "" { + return nil, fmt.Errorf("no default user set") + } + for _, user := range config.PhaseUsers { + if user.ID == config.DefaultUser { + return &user, nil + } + } + return nil, fmt.Errorf("no user found in config.json with id: %s", config.DefaultUser) +} + +func GetDefaultAccountID(allIDs bool) ([]string, error) { + config, err := LoadConfig() + if err != nil { + return nil, fmt.Errorf("please login with phase auth or supply a PHASE_SERVICE_TOKEN as an environment variable") + } + if allIDs { + var ids []string + for _, user := range config.PhaseUsers { + ids = append(ids, user.ID) + } + return ids, nil + } + return []string{config.DefaultUser}, nil +} + +func GetDefaultUserHost() (string, error) { + if token := os.Getenv("PHASE_SERVICE_TOKEN"); token != "" { + host := os.Getenv("PHASE_HOST") + if host == "" { + host = PhaseCloudAPIHost + } + return host, nil + } + + config, err := LoadConfig() + if err != nil { + return "", fmt.Errorf("config file not found and no PHASE_SERVICE_TOKEN environment variable set") + } + + for _, user := range config.PhaseUsers { + if user.ID == config.DefaultUser { + return user.Host, nil + } + } + return "", fmt.Errorf("no user found in config.json with id: %s", config.DefaultUser) +} + +func GetDefaultUserToken() (string, error) { + config, err := LoadConfig() + if err != nil { + return "", fmt.Errorf("config file not found. Please login with phase auth or supply a PHASE_SERVICE_TOKEN as an environment variable") + } + if config.DefaultUser == "" { + return "", fmt.Errorf("default user ID is missing in the config file") + } + for _, user := range config.PhaseUsers { + if user.ID == config.DefaultUser { + if user.Token == "" { + return "", fmt.Errorf("token for the default user (ID: %s) is not found in the config file", config.DefaultUser) + } + return user.Token, nil + } + } + return "", fmt.Errorf("default user not found in the config file") +} + +func AddUser(user UserConfig) error { + config, err := LoadConfig() + if err != nil { + config = &Config{PhaseUsers: []UserConfig{}} + } + // Replace existing user with same ID or add new + found := false + for i, u := range config.PhaseUsers { + if u.ID == user.ID { + config.PhaseUsers[i] = user + found = true + break + } + } + if !found { + config.PhaseUsers = append(config.PhaseUsers, user) + } + config.DefaultUser = user.ID + return SaveConfig(config) +} + +func GetDefaultUserOrg() (string, error) { + user, err := GetDefaultUser() + if err != nil { + return "", err + } + if user.OrganizationName != nil && *user.OrganizationName != "" { + return *user.OrganizationName, nil + } + return "", fmt.Errorf("no organization name found for default user") +} + +func SetDefaultUser(accountID string) error { + config, err := LoadConfig() + if err != nil { + return err + } + found := false + for _, u := range config.PhaseUsers { + if u.ID == accountID { + found = true + break + } + } + if !found { + return fmt.Errorf("no user found with ID: %s", accountID) + } + config.DefaultUser = accountID + return SaveConfig(config) +} + +func RemoveUser(id string) error { + config, err := LoadConfig() + if err != nil { + return err + } + var remaining []UserConfig + for _, u := range config.PhaseUsers { + if u.ID != id { + remaining = append(remaining, u) + } + } + config.PhaseUsers = remaining + if len(remaining) == 0 { + config.DefaultUser = "" + } else if config.DefaultUser == id { + config.DefaultUser = remaining[0].ID + } + return SaveConfig(config) +} diff --git a/src/pkg/config/phase_json.go b/src/pkg/config/phase_json.go new file mode 100644 index 00000000..a800ecc4 --- /dev/null +++ b/src/pkg/config/phase_json.go @@ -0,0 +1,64 @@ +package config + +import ( + "encoding/json" + "os" + "path/filepath" + "strconv" +) + +type PhaseJSONConfig struct { + Version string `json:"version"` + PhaseApp string `json:"phaseApp"` + AppID string `json:"appId"` + DefaultEnv string `json:"defaultEnv"` + EnvID string `json:"envId"` + MonorepoSupport bool `json:"monorepoSupport,omitempty"` +} + +func FindPhaseConfig(maxDepth int) *PhaseJSONConfig { + // Check env var override for search depth + if envDepth := os.Getenv("PHASE_CONFIG_PARENT_DIR_SEARCH_DEPTH"); envDepth != "" { + if d, err := strconv.Atoi(envDepth); err == nil { + maxDepth = d + } + } + + currentDir, err := os.Getwd() + if err != nil { + return nil + } + originalDir := currentDir + + for i := 0; i <= maxDepth; i++ { + configPath := filepath.Join(currentDir, PhaseEnvConfig) + data, err := os.ReadFile(configPath) + if err == nil { + var config PhaseJSONConfig + if err := json.Unmarshal(data, &config); err == nil { + // Only use config from parent dirs if monorepoSupport is true + if currentDir == originalDir || config.MonorepoSupport { + return &config + } + } + } + + parentDir := filepath.Dir(currentDir) + if parentDir == currentDir { + break + } + currentDir = parentDir + } + return nil +} + +func WritePhaseConfig(config *PhaseJSONConfig) error { + data, err := json.MarshalIndent(config, "", " ") + if err != nil { + return err + } + if err := os.WriteFile(PhaseEnvConfig, data, 0600); err != nil { + return err + } + return nil +} diff --git a/src/pkg/display/tree.go b/src/pkg/display/tree.go new file mode 100644 index 00000000..8214a3f6 --- /dev/null +++ b/src/pkg/display/tree.go @@ -0,0 +1,208 @@ +package display + +import ( + "fmt" + "os" + "regexp" + "sort" + "strings" + + "github.com/phasehq/cli/pkg/phase" + "github.com/phasehq/cli/pkg/util" +) + +var ( + // Simplified patterns for display icons only (Go doesn't support lookaheads) + crossEnvPattern = regexp.MustCompile(`\$\{[^}]*\.[^}]+\}`) + localRefPattern = regexp.MustCompile(`\$\{[^}.]+\}`) +) + +func getTerminalWidth() int { + // Simple approach - just return 80 for portability + return 80 +} + +func censorSecret(secret string, maxLength int) string { + if len(secret) <= 6 { + return strings.Repeat("*", len(secret)) + } + censored := secret[:3] + strings.Repeat("*", len(secret)-6) + secret[len(secret)-3:] + if len(censored) > maxLength && maxLength > 6 { + return censored[:maxLength-6] + } + return censored +} + +// renderSecretRow renders a single secret row into the table. +func renderSecretRow(pathPrefix string, s phase.SecretResult, show bool, keyWidth, valueWidth int, bold, reset string) { + keyDisplay := s.Key + if len(s.Tags) > 0 { + keyDisplay += " 🏷️" + } + if s.Comment != "" { + keyDisplay += " 💬" + } + + icon := "" + if crossEnvPattern.MatchString(s.Value) { + icon += "⛓️ " + } + if localRefPattern.MatchString(s.Value) { + icon += "🔗 " + } + + personalIndicator := "" + if s.Overridden { + personalIndicator = "🔏 " + } + + var valueDisplay string + if s.IsDynamic && !show { + valueDisplay = "****************" + } else if show { + valueDisplay = s.Value + } else { + censorLen := valueWidth - len(icon) - len(personalIndicator) - 2 + if censorLen < 6 { + censorLen = 6 + } + valueDisplay = censorSecret(s.Value, censorLen) + } + valueDisplay = icon + personalIndicator + valueDisplay + + // Truncate if needed + if len(keyDisplay) > keyWidth { + keyDisplay = keyDisplay[:keyWidth-1] + "…" + } + if len(valueDisplay) > valueWidth { + valueDisplay = valueDisplay[:valueWidth-1] + "…" + } + + fmt.Fprintf(os.Stdout, " %s │ %-*s│ %-*s│\n", + pathPrefix, keyWidth, keyDisplay, valueWidth, valueDisplay) +} + +// RenderSecretsTree renders secrets in a tree view with path hierarchy +func RenderSecretsTree(secrets []phase.SecretResult, show bool) { + if len(secrets) == 0 { + fmt.Println("No secrets to display.") + return + } + + appName := secrets[0].Application + envName := secrets[0].Environment + + bold, cyan, green, magenta, reset := util.AnsiCodes() + + fmt.Printf(" %s Secrets for Application: %s%s%s%s, Environment: %s%s%s%s\n", + "🔮", bold, cyan, appName, reset, bold, green, envName, reset) + + // Organize by path + paths := map[string][]phase.SecretResult{} + for _, s := range secrets { + path := s.Path + if path == "" { + path = "/" + } + paths[path] = append(paths[path], s) + } + + // Sort paths + var sortedPaths []string + for p := range paths { + sortedPaths = append(sortedPaths, p) + } + sort.Strings(sortedPaths) + + termWidth := getTerminalWidth() + isLastPath := false + + for pi, path := range sortedPaths { + pathSecrets := paths[path] + isLastPath = pi == len(sortedPaths)-1 + pathConnector := "├" + pathPrefix := "│" + if isLastPath { + pathConnector = "└" + pathPrefix = " " + } + + fmt.Printf(" %s── %s Path: %s - %s%s%d Secrets%s\n", + pathConnector, "📁", path, bold, magenta, len(pathSecrets), reset) + + // Separate static and dynamic secrets + var staticSecrets []phase.SecretResult + dynamicGroups := map[string][]phase.SecretResult{} + var dynamicGroupOrder []string + for _, s := range pathSecrets { + if s.IsDynamic { + if _, seen := dynamicGroups[s.DynamicGroup]; !seen { + dynamicGroupOrder = append(dynamicGroupOrder, s.DynamicGroup) + } + dynamicGroups[s.DynamicGroup] = append(dynamicGroups[s.DynamicGroup], s) + } else { + staticSecrets = append(staticSecrets, s) + } + } + + // Calculate column widths across all secrets in this path + minKeyWidth := 15 + maxKeyLen := minKeyWidth + for _, s := range pathSecrets { + kl := len(s.Key) + 4 // room for tag/comment icons + if kl > maxKeyLen { + maxKeyLen = kl + } + } + keyWidth := maxKeyLen + 2 + if keyWidth > 40 { + keyWidth = 40 + } + if keyWidth < minKeyWidth { + keyWidth = minKeyWidth + } + valueWidth := termWidth - keyWidth - 10 + if valueWidth < 20 { + valueWidth = 20 + } + + // Print table header + fmt.Fprintf(os.Stdout, " %s ╭─%s┬─%s╮\n", + pathPrefix, strings.Repeat("─", keyWidth), strings.Repeat("─", valueWidth)) + fmt.Fprintf(os.Stdout, " %s │ %s%-*s%s│ %s%-*s%s│\n", + pathPrefix, bold, keyWidth, "KEY", reset, bold, valueWidth, "VALUE", reset) + fmt.Fprintf(os.Stdout, " %s ├─%s┼─%s┤\n", + pathPrefix, strings.Repeat("─", keyWidth), strings.Repeat("─", valueWidth)) + + // Print static secrets + for _, s := range staticSecrets { + renderSecretRow(pathPrefix, s, show, keyWidth, valueWidth, bold, reset) + } + + // Print dynamic secret groups + for _, groupLabel := range dynamicGroupOrder { + groupSecrets := dynamicGroups[groupLabel] + + // Section separator if there were static secrets or a previous group + if len(staticSecrets) > 0 || groupLabel != dynamicGroupOrder[0] { + fmt.Fprintf(os.Stdout, " %s ├─%s┼─%s┤\n", + pathPrefix, strings.Repeat("─", keyWidth), strings.Repeat("─", valueWidth)) + } + + // Group header row + header := fmt.Sprintf("⚡️ %s", groupLabel) + if len(header) > keyWidth+valueWidth+1 { + header = header[:keyWidth+valueWidth-2] + "…" + } + fmt.Fprintf(os.Stdout, " %s │ %s%-*s%s│\n", + pathPrefix, bold, keyWidth+valueWidth+1, header, reset) + + for _, s := range groupSecrets { + renderSecretRow(pathPrefix, s, show, keyWidth, valueWidth, bold, reset) + } + } + + fmt.Fprintf(os.Stdout, " %s ╰─%s┴─%s╯\n", + pathPrefix, strings.Repeat("─", keyWidth), strings.Repeat("─", valueWidth)) + } +} diff --git a/src/pkg/keyring/keyring.go b/src/pkg/keyring/keyring.go new file mode 100644 index 00000000..c566e543 --- /dev/null +++ b/src/pkg/keyring/keyring.go @@ -0,0 +1,49 @@ +package keyring + +import ( + "fmt" + "os" + + "github.com/phasehq/cli/pkg/config" + gokeyring "github.com/zalando/go-keyring" +) + +func GetCredentials() (string, error) { + // 1. Check PHASE_SERVICE_TOKEN env var + if pss := os.Getenv("PHASE_SERVICE_TOKEN"); pss != "" { + return pss, nil + } + + // 2. Try system keyring + ids, err := config.GetDefaultAccountID(false) + if err != nil { + return "", err + } + if len(ids) == 0 || ids[0] == "" { + return "", fmt.Errorf("no default account configured") + } + accountID := ids[0] + serviceName := fmt.Sprintf("phase-cli-user-%s", accountID) + + pss, err := gokeyring.Get(serviceName, "pss") + if err == nil && pss != "" { + return pss, nil + } + + // 3. Fallback to config file token + return config.GetDefaultUserToken() +} + +func SetCredentials(accountID, token string) error { + serviceName := fmt.Sprintf("phase-cli-user-%s", accountID) + return gokeyring.Set(serviceName, "pss", token) +} + +func DeleteCredentials(accountID string) error { + serviceName := fmt.Sprintf("phase-cli-user-%s", accountID) + err := gokeyring.Delete(serviceName, "pss") + if err == gokeyring.ErrNotFound { + return nil // Not an error if it doesn't exist + } + return err +} diff --git a/src/pkg/network/network.go b/src/pkg/network/network.go new file mode 100644 index 00000000..0638b177 --- /dev/null +++ b/src/pkg/network/network.go @@ -0,0 +1,254 @@ +package network + +import ( + "bytes" + "crypto/tls" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + + sdkmisc "github.com/phasehq/golang-sdk/phase/misc" + sdknetwork "github.com/phasehq/golang-sdk/phase/network" +) + +func createHTTPClient() *http.Client { + client := &http.Client{} + if !sdkmisc.VerifySSL { + client.Transport = &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, + } + } + return client +} + +func doRequest(req *http.Request) ([]byte, error) { + client := createHTTPClient() + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("request failed with status %d: %s", resp.StatusCode, string(body)) + } + + return body, nil +} + +// FetchPhaseSecretsWithDynamic is like the SDK's FetchPhaseSecrets but adds dynamic/lease headers. +func FetchPhaseSecretsWithDynamic(tokenType, appToken, envID, host, path string, dynamic, lease bool, leaseTTL *int) ([]map[string]interface{}, error) { + reqURL := fmt.Sprintf("%s/service/secrets/", host) + req, err := http.NewRequest("GET", reqURL, nil) + if err != nil { + return nil, err + } + + req.Header = sdknetwork.ConstructHTTPHeaders(tokenType, appToken) + req.Header.Set("Environment", envID) + if path != "" { + req.Header.Set("Path", path) + } + if dynamic { + req.Header.Set("dynamic", "true") + } + if lease { + req.Header.Set("lease", "true") + } + if leaseTTL != nil { + req.Header.Set("lease-ttl", fmt.Sprintf("%d", *leaseTTL)) + } + + body, err := doRequest(req) + if err != nil { + return nil, err + } + + var secrets []map[string]interface{} + if err := json.Unmarshal(body, &secrets); err != nil { + return nil, fmt.Errorf("failed to decode secrets response: %w", err) + } + return secrets, nil +} + +// ListDynamicSecrets lists dynamic secrets for an app/env. +func ListDynamicSecrets(tokenType, appToken, host, appID, env, path string) (json.RawMessage, error) { + reqURL := fmt.Sprintf("%s/service/public/v1/secrets/dynamic/", host) + + params := url.Values{} + params.Set("app_id", appID) + params.Set("env", env) + if path != "" { + params.Set("path", path) + } + reqURL += "?" + params.Encode() + + req, err := http.NewRequest("GET", reqURL, nil) + if err != nil { + return nil, err + } + req.Header = sdknetwork.ConstructHTTPHeaders(tokenType, appToken) + + body, err := doRequest(req) + if err != nil { + return nil, err + } + return json.RawMessage(body), nil +} + +// CreateDynamicSecretLease generates a lease for a dynamic secret. +func CreateDynamicSecretLease(tokenType, appToken, host, appID, env, secretID string, ttl *int) (json.RawMessage, error) { + reqURL := fmt.Sprintf("%s/service/public/v1/secrets/dynamic/", host) + + params := url.Values{} + params.Set("app_id", appID) + params.Set("env", env) + params.Set("id", secretID) + params.Set("lease", "true") + if ttl != nil { + params.Set("ttl", fmt.Sprintf("%d", *ttl)) + } + reqURL += "?" + params.Encode() + + req, err := http.NewRequest("GET", reqURL, nil) + if err != nil { + return nil, err + } + req.Header = sdknetwork.ConstructHTTPHeaders(tokenType, appToken) + + body, err := doRequest(req) + if err != nil { + return nil, err + } + return json.RawMessage(body), nil +} + +// ListDynamicSecretLeases lists leases for dynamic secrets. +func ListDynamicSecretLeases(tokenType, appToken, host, appID, env, secretID string) (json.RawMessage, error) { + reqURL := fmt.Sprintf("%s/service/public/v1/secrets/dynamic/leases/", host) + + params := url.Values{} + params.Set("app_id", appID) + params.Set("env", env) + if secretID != "" { + params.Set("secret_id", secretID) + } + reqURL += "?" + params.Encode() + + req, err := http.NewRequest("GET", reqURL, nil) + if err != nil { + return nil, err + } + req.Header = sdknetwork.ConstructHTTPHeaders(tokenType, appToken) + + body, err := doRequest(req) + if err != nil { + return nil, err + } + return json.RawMessage(body), nil +} + +// RenewDynamicSecretLease renews a lease for a dynamic secret. +func RenewDynamicSecretLease(tokenType, appToken, host, appID, env, leaseID string, ttl int) (json.RawMessage, error) { + reqURL := fmt.Sprintf("%s/service/public/v1/secrets/dynamic/leases/", host) + + params := url.Values{} + params.Set("app_id", appID) + params.Set("env", env) + reqURL += "?" + params.Encode() + + payload, _ := json.Marshal(map[string]interface{}{ + "lease_id": leaseID, + "ttl": ttl, + }) + + req, err := http.NewRequest("PUT", reqURL, bytes.NewBuffer(payload)) + if err != nil { + return nil, err + } + req.Header = sdknetwork.ConstructHTTPHeaders(tokenType, appToken) + req.Header.Set("Content-Type", "application/json") + + body, err := doRequest(req) + if err != nil { + return nil, err + } + return json.RawMessage(body), nil +} + +// RevokeDynamicSecretLease revokes a lease for a dynamic secret. +func RevokeDynamicSecretLease(tokenType, appToken, host, appID, env, leaseID string) (json.RawMessage, error) { + reqURL := fmt.Sprintf("%s/service/public/v1/secrets/dynamic/leases/", host) + + params := url.Values{} + params.Set("app_id", appID) + params.Set("env", env) + reqURL += "?" + params.Encode() + + payload, _ := json.Marshal(map[string]interface{}{ + "lease_id": leaseID, + }) + + req, err := http.NewRequest("DELETE", reqURL, bytes.NewBuffer(payload)) + if err != nil { + return nil, err + } + req.Header = sdknetwork.ConstructHTTPHeaders(tokenType, appToken) + req.Header.Set("Content-Type", "application/json") + + body, err := doRequest(req) + if err != nil { + return nil, err + } + return json.RawMessage(body), nil +} + +// ExternalIdentityAuthAWS performs AWS IAM authentication against the Phase API. +// encodedURL, encodedHeaders, and encodedBody are already base64-encoded. +func ExternalIdentityAuthAWS(host, serviceAccountID string, ttl *int, encodedURL, encodedHeaders, encodedBody, method string) (map[string]interface{}, error) { + reqURL := fmt.Sprintf("%s/service/public/identities/external/v1/aws/iam/auth/", host) + + payload := map[string]interface{}{ + "account": map[string]interface{}{ + "type": "service", + "id": serviceAccountID, + }, + "awsIam": map[string]interface{}{ + "httpRequestMethod": method, + "httpRequestUrl": encodedURL, + "httpRequestHeaders": encodedHeaders, + "httpRequestBody": encodedBody, + }, + } + if ttl != nil { + payload["tokenRequest"] = map[string]interface{}{ + "ttl": *ttl, + } + } + + payloadBytes, _ := json.Marshal(payload) + + req, err := http.NewRequest("POST", reqURL, bytes.NewBuffer(payloadBytes)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + + respBody, err := doRequest(req) + if err != nil { + return nil, err + } + + var result map[string]interface{} + if err := json.Unmarshal(respBody, &result); err != nil { + return nil, fmt.Errorf("failed to decode auth response: %w", err) + } + return result, nil +} diff --git a/src/pkg/phase/phase.go b/src/pkg/phase/phase.go new file mode 100644 index 00000000..b24528ee --- /dev/null +++ b/src/pkg/phase/phase.go @@ -0,0 +1,276 @@ +package phase + +import ( + "encoding/hex" + "encoding/json" + "fmt" + "os" + "runtime" + "strings" + + "github.com/phasehq/cli/pkg/config" + "github.com/phasehq/cli/pkg/keyring" + "github.com/phasehq/golang-sdk/phase/crypto" + "github.com/phasehq/golang-sdk/phase/misc" + "github.com/phasehq/golang-sdk/phase/network" +) + +func hexDecode(s string) ([]byte, error) { + return hex.DecodeString(s) +} + +type Phase struct { + Prefix string + PesVersion string + AppToken string + PssUserPublicKey string + Keyshare0 string + Keyshare1UnwrapKey string + APIHost string + TokenType string + IsServiceToken bool + IsUserToken bool +} + +func NewPhase(init bool, pss string, host string) (*Phase, error) { + if init { + creds, err := keyring.GetCredentials() + if err != nil { + return nil, err + } + pss = creds + h, err := config.GetDefaultUserHost() + if err != nil { + return nil, err + } + host = h + } else { + if pss == "" || host == "" { + return nil, fmt.Errorf("both pss and host must be provided when init is false") + } + } + + p := &Phase{ + APIHost: host, + } + + // Set user agent + setUserAgent() + + // Determine token type + p.IsServiceToken = misc.PssServicePattern.MatchString(pss) + p.IsUserToken = misc.PssUserPattern.MatchString(pss) + + if !p.IsServiceToken && !p.IsUserToken { + tokenType := "service token" + if strings.Contains(pss, "pss_user") { + tokenType = "user token" + } + return nil, fmt.Errorf("invalid Phase %s", tokenType) + } + + // Parse token segments + segments := strings.Split(pss, ":") + if len(segments) != 6 { + return nil, fmt.Errorf("invalid token format") + } + p.Prefix = segments[0] + p.PesVersion = segments[1] + p.AppToken = segments[2] + p.PssUserPublicKey = segments[3] + p.Keyshare0 = segments[4] + p.Keyshare1UnwrapKey = segments[5] + + // Determine HTTP Authorization token type + if p.IsServiceToken && p.PesVersion == "v2" { + p.TokenType = "ServiceAccount" + } else if p.IsServiceToken { + p.TokenType = "Service" + } else { + p.TokenType = "User" + } + + return p, nil +} + +func setUserAgent() { + hostname, _ := os.Hostname() + username := "unknown" + if u, err := os.UserHomeDir(); err == nil { + parts := strings.Split(u, string(os.PathSeparator)) + if len(parts) > 0 { + username = parts[len(parts)-1] + } + } + ua := fmt.Sprintf("phase-cli-go/%s %s %s %s@%s", + "0.1.0", runtime.GOOS, runtime.GOARCH, username, hostname) + network.SetUserAgent(ua) +} + +func (p *Phase) Auth() error { + _, err := network.FetchAppKey(p.TokenType, p.AppToken, p.APIHost) + if err != nil { + return fmt.Errorf("invalid Phase credentials: %w", err) + } + return nil +} + +func (p *Phase) Init() (*misc.AppKeyResponse, error) { + resp, err := network.FetchPhaseUser(p.TokenType, p.AppToken, p.APIHost) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + var userData misc.AppKeyResponse + if err := json.NewDecoder(resp.Body).Decode(&userData); err != nil { + return nil, fmt.Errorf("failed to decode user data: %w", err) + } + return &userData, nil +} + +// InitRaw returns the raw JSON response for auth flow (need user_id, offline_enabled, etc.) +func (p *Phase) InitRaw() (map[string]interface{}, error) { + resp, err := network.FetchPhaseUser(p.TokenType, p.AppToken, p.APIHost) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + var result map[string]interface{} + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + return result, nil +} + +func (p *Phase) Decrypt(phaseCiphertext string, wrappedKeyShareData map[string]interface{}) (string, error) { + segments := strings.Split(phaseCiphertext, ":") + if len(segments) != 4 || segments[0] != "ph" { + return "", fmt.Errorf("ciphertext is invalid") + } + + wrappedKeyShare, ok := wrappedKeyShareData["wrapped_key_share"].(string) + if !ok || wrappedKeyShare == "" { + return "", fmt.Errorf("wrapped key share not found in the response") + } + + // Decrypt using SDK's DecryptWrappedKeyShare which handles the full flow: + // 1. Fetch app key (wrapped keyshare) + // 2. Unwrap keyshare1 using keyshare1_unwrap_key + // 3. Reconstruct app private key from keyshare0 + keyshare1 + // 4. Decrypt the ciphertext using app private key + // + // But that function also does a network call to fetch the wrapped key share. + // Since we already have it in wrappedKeyShareData, we do the steps manually: + + wrappedKeyShareBytes, err := hexDecode(wrappedKeyShare) + if err != nil { + return "", fmt.Errorf("failed to decode wrapped key share: %w", err) + } + + unwrapKeyBytes, err := hexDecode(p.Keyshare1UnwrapKey) + if err != nil { + return "", fmt.Errorf("failed to decode keyshare1 unwrap key: %w", err) + } + + var unwrapKey [32]byte + copy(unwrapKey[:], unwrapKeyBytes) + + keyshare1Bytes, err := crypto.DecryptRaw(wrappedKeyShareBytes, unwrapKey) + if err != nil { + return "", fmt.Errorf("failed to decrypt wrapped key share: %w", err) + } + + // Reconstruct app private key + appPrivKey, err := crypto.ReconstructSecret(p.Keyshare0, string(keyshare1Bytes)) + if err != nil { + return "", fmt.Errorf("failed to reconstruct app private key: %w", err) + } + + // Decrypt the ciphertext using reconstructed app private key + plaintext, err := crypto.DecryptAsymmetric(phaseCiphertext, appPrivKey, p.PssUserPublicKey) + if err != nil { + return "", fmt.Errorf("failed to decrypt: %w", err) + } + + return plaintext, nil +} + +// findMatchingEnvironmentKey finds environment key by env ID +func (p *Phase) findMatchingEnvironmentKey(userData *misc.AppKeyResponse, envID string) *misc.EnvironmentKey { + for _, app := range userData.Apps { + for _, envKey := range app.EnvironmentKeys { + if envKey.Environment.ID == envID { + return &envKey + } + } + } + return nil +} + +// PhaseGetContext resolves app/env context from user data, using .phase.json defaults +func PhaseGetContext(userData *misc.AppKeyResponse, appName, envName, appID string) (string, string, string, string, string, error) { + // If no app context provided, check .phase.json + if appID == "" && appName == "" { + phaseConfig := config.FindPhaseConfig(8) + if phaseConfig != nil { + envName = coalesce(envName, phaseConfig.DefaultEnv) + appID = phaseConfig.AppID + } else { + envName = coalesce(envName, "Development") + } + } else { + envName = coalesce(envName, "Development") + } + + // Find the app + var application *misc.App + if appID != "" { + for i, app := range userData.Apps { + if app.ID == appID { + application = &userData.Apps[i] + break + } + } + if application == nil { + return "", "", "", "", "", fmt.Errorf("no application found with ID: '%s'", appID) + } + } else if appName != "" { + var matchingApps []misc.App + for _, app := range userData.Apps { + if strings.Contains(strings.ToLower(app.Name), strings.ToLower(appName)) { + matchingApps = append(matchingApps, app) + } + } + if len(matchingApps) == 0 { + return "", "", "", "", "", fmt.Errorf("no application found with the name '%s'", appName) + } + // Sort by name length (shortest = most specific match) - just pick the first shortest + shortest := matchingApps[0] + for _, app := range matchingApps[1:] { + if len(app.Name) < len(shortest.Name) { + shortest = app + } + } + application = &shortest + } else { + return "", "", "", "", "", fmt.Errorf("no application context provided. Please run 'phase init' or pass the '--app' or '--app-id' flag") + } + + // Find the environment + for _, envKey := range application.EnvironmentKeys { + if strings.Contains(strings.ToLower(envKey.Environment.Name), strings.ToLower(envName)) { + return application.Name, application.ID, envKey.Environment.Name, envKey.Environment.ID, envKey.IdentityKey, nil + } + } + + return "", "", "", "", "", fmt.Errorf("environment '%s' not found in application '%s'", envName, application.Name) +} + +func coalesce(a, b string) string { + if a != "" { + return a + } + return b +} diff --git a/src/pkg/phase/secret_referencing.go b/src/pkg/phase/secret_referencing.go new file mode 100644 index 00000000..1dfb526a --- /dev/null +++ b/src/pkg/phase/secret_referencing.go @@ -0,0 +1,255 @@ +package phase + +import ( + "fmt" + "regexp" + "strings" +) + +var secretRefRegex = regexp.MustCompile(`\$\{([^}]+)\}`) + +// secretsCache keyed by "app|env|path" -> key -> value +var secretsCache = map[string]map[string]string{} + +func cacheKey(app, env, path string) string { + path = normalizePath(path) + return fmt.Sprintf("%s|%s|%s", app, env, path) +} + +func normalizePath(path string) string { + if path == "" { + return "/" + } + if !strings.HasPrefix(path, "/") { + return "/" + path + } + return path +} + +func primeCacheFromList(secrets []SecretResult, fallbackAppName string) { + for _, s := range secrets { + app := s.Application + if app == "" { + app = fallbackAppName + } + if app == "" || s.Environment == "" || s.Key == "" { + continue + } + ck := cacheKey(app, s.Environment, s.Path) + if _, ok := secretsCache[ck]; !ok { + secretsCache[ck] = map[string]string{} + } + secretsCache[ck][s.Key] = s.Value + } +} + +func ensureCached(p *Phase, appName, envName, path string) { + ck := cacheKey(appName, envName, path) + if _, ok := secretsCache[ck]; ok { + return + } + fetched, err := p.Get(GetOptions{ + EnvName: envName, + AppName: appName, + Path: normalizePath(path), + }) + if err != nil { + return + } + bucket := map[string]string{} + for _, s := range fetched { + bucket[s.Key] = s.Value + } + secretsCache[ck] = bucket +} + +func getFromCache(appName, envName, path, keyName string) (string, bool) { + ck := cacheKey(appName, envName, path) + bucket, ok := secretsCache[ck] + if !ok { + return "", false + } + val, ok := bucket[keyName] + return val, ok +} + +func splitPathAndKey(ref string) (string, string) { + lastSlash := strings.LastIndex(ref, "/") + if lastSlash != -1 { + path := ref[:lastSlash] + key := ref[lastSlash+1:] + if !strings.HasPrefix(path, "/") { + path = "/" + path + } + return path, key + } + return "/", ref +} + +func parseReferenceContext(ref, currentApp, currentEnv string) (appName, envName, path, keyName string, err error) { + appName = currentApp + envName = currentEnv + refBody := ref + + isCrossApp := false + if strings.Contains(refBody, "::") { + isCrossApp = true + parts := strings.SplitN(refBody, "::", 2) + appName = parts[0] + refBody = parts[1] + } + + if strings.Contains(refBody, ".") { + parts := strings.SplitN(refBody, ".", 2) + envName = parts[0] + refBody = parts[1] + if isCrossApp && envName == "" { + return "", "", "", "", fmt.Errorf("invalid reference '%s': cross-app references must specify an environment", ref) + } + } else if isCrossApp { + return "", "", "", "", fmt.Errorf("invalid reference '%s': cross-app references must specify an environment", ref) + } + + path, keyName = splitPathAndKey(refBody) + return +} + +// ResolveAllSecrets resolves all ${...} references in a value string. +func ResolveAllSecrets(value string, allSecrets []SecretResult, p *Phase, currentApp, currentEnv string) string { + return resolveAllSecretsInternal(value, allSecrets, p, currentApp, currentEnv, nil) +} + +func resolveAllSecretsInternal(value string, allSecrets []SecretResult, p *Phase, currentApp, currentEnv string, visited map[string]bool) string { + if visited == nil { + visited = map[string]bool{} + } + + // Build in-memory lookup: env -> path -> key -> value + secretsDict := map[string]map[string]map[string]string{} + primeCacheFromList(allSecrets, currentApp) + for _, s := range allSecrets { + if _, ok := secretsDict[s.Environment]; !ok { + secretsDict[s.Environment] = map[string]map[string]string{} + } + if _, ok := secretsDict[s.Environment][s.Path]; !ok { + secretsDict[s.Environment][s.Path] = map[string]string{} + } + secretsDict[s.Environment][s.Path][s.Key] = s.Value + } + + refs := secretRefRegex.FindAllStringSubmatch(value, -1) + if len(refs) == 0 { + return value + } + + // Prefetch caches + seen := map[string]bool{} + for _, match := range refs { + ref := match[1] + app, env, path, _, err := parseReferenceContext(ref, currentApp, currentEnv) + if err != nil { + continue + } + combo := fmt.Sprintf("%s|%s|%s", app, env, path) + if !seen[combo] { + seen[combo] = true + ensureCached(p, app, env, path) + } + } + + resolved := value + for _, match := range refs { + ref := match[1] + fullRef := match[0] + + app, env, path, keyName, err := parseReferenceContext(ref, currentApp, currentEnv) + if err != nil { + continue + } + + canonical := fmt.Sprintf("%s|%s|%s|%s", app, env, path, keyName) + if visited[canonical] { + continue + } + visited[canonical] = true + + // Try in-memory dict first (same app only) + resolvedVal := "" + found := false + if app == currentApp { + resolvedVal, found = lookupInMemory(secretsDict, env, path, keyName, currentEnv) + } + + // Try cache + if !found { + resolvedVal, found = getFromCache(app, env, path, keyName) + } + + if !found { + // Leave placeholder unresolved + continue + } + + // Recursively resolve if the resolved value itself contains references + if secretRefRegex.MatchString(resolvedVal) { + resolvedVal = resolveAllSecretsInternal(resolvedVal, allSecrets, p, app, env, visited) + } + + resolved = strings.ReplaceAll(resolved, fullRef, resolvedVal) + } + + return resolved +} + +func lookupInMemory(secretsDict map[string]map[string]map[string]string, envName, path, keyName, currentEnv string) (string, bool) { + envKey := findEnvKeyCaseInsensitive(secretsDict, envName) + if envKey == "" { + return "", false + } + if pathBucket, ok := secretsDict[envKey][path]; ok { + if val, ok := pathBucket[keyName]; ok { + return val, true + } + } + // Fallback: try root path for current env + if path == "/" && strings.EqualFold(envName, currentEnv) { + if pathBucket, ok := secretsDict[envKey]["/"]; ok { + if val, ok := pathBucket[keyName]; ok { + return val, true + } + } + } + return "", false +} + +func findEnvKeyCaseInsensitive(secretsDict map[string]map[string]map[string]string, envName string) string { + // Exact match + if _, ok := secretsDict[envName]; ok { + return envName + } + // Case-insensitive exact match + for k := range secretsDict { + if strings.EqualFold(k, envName) { + return k + } + } + // Partial match + envLower := strings.ToLower(envName) + var partials []string + for k := range secretsDict { + kLower := strings.ToLower(k) + if strings.Contains(kLower, envLower) || strings.Contains(envLower, kLower) { + partials = append(partials, k) + } + } + if len(partials) > 0 { + shortest := partials[0] + for _, p := range partials[1:] { + if len(p) < len(shortest) { + shortest = p + } + } + return shortest + } + return "" +} diff --git a/src/pkg/phase/secrets.go b/src/pkg/phase/secrets.go new file mode 100644 index 00000000..b0035662 --- /dev/null +++ b/src/pkg/phase/secrets.go @@ -0,0 +1,637 @@ +package phase + +import ( + "encoding/json" + "fmt" + "strings" + + localnetwork "github.com/phasehq/cli/pkg/network" + "github.com/phasehq/golang-sdk/phase/crypto" + "github.com/phasehq/golang-sdk/phase/misc" + "github.com/phasehq/golang-sdk/phase/network" +) + +type SecretResult struct { + Key string `json:"key"` + Value string `json:"value"` + Overridden bool `json:"overridden"` + Tags []string `json:"tags"` + Comment string `json:"comment"` + Path string `json:"path"` + Application string `json:"application"` + Environment string `json:"environment"` + IsDynamic bool `json:"is_dynamic,omitempty"` + DynamicGroup string `json:"dynamic_group,omitempty"` +} + +type GetOptions struct { + EnvName string + AppName string + AppID string + Keys []string + Tag string + Path string + Dynamic bool + Lease bool + LeaseTTL *int +} + +type CreateOptions struct { + KeyValuePairs []KeyValuePair + EnvName string + AppName string + AppID string + Path string + OverrideValue string +} + +type KeyValuePair struct { + Key string + Value string +} + +type UpdateOptions struct { + EnvName string + AppName string + AppID string + Key string + Value string + SourcePath string + DestinationPath string + Override bool + ToggleOverride bool +} + +type DeleteOptions struct { + EnvName string + AppName string + AppID string + KeysToDelete []string + Path string +} + +func (p *Phase) Get(opts GetOptions) ([]SecretResult, error) { + resp, err := network.FetchPhaseUser(p.TokenType, p.AppToken, p.APIHost) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + var userData misc.AppKeyResponse + if err := json.NewDecoder(resp.Body).Decode(&userData); err != nil { + return nil, fmt.Errorf("failed to decode user data: %w", err) + } + + appName, _, envName, envID, publicKey, err := PhaseGetContext(&userData, opts.AppName, opts.EnvName, opts.AppID) + if err != nil { + return nil, err + } + + envKey := p.findMatchingEnvironmentKey(&userData, envID) + if envKey == nil { + return nil, fmt.Errorf("no environment found with id: %s", envID) + } + + // Decrypt wrapped seed to get env keypair + wrappedSeed := envKey.WrappedSeed + userDataMap := appKeyResponseToMap(&userData) + decryptedSeed, err := p.Decrypt(wrappedSeed, userDataMap) + if err != nil { + return nil, fmt.Errorf("failed to decrypt wrapped seed: %w", err) + } + + envPubKey, envPrivKey, err := crypto.GenerateEnvKeyPair(decryptedSeed) + if err != nil { + return nil, fmt.Errorf("failed to generate env key pair: %w", err) + } + _ = envPubKey // Use the identity key from the API instead + + // Fetch secrets + var secrets []map[string]interface{} + if opts.Dynamic { + secrets, err = localnetwork.FetchPhaseSecretsWithDynamic(p.TokenType, p.AppToken, envID, p.APIHost, opts.Path, true, opts.Lease, opts.LeaseTTL) + } else { + secrets, err = network.FetchPhaseSecrets(p.TokenType, p.AppToken, envID, p.APIHost, opts.Path) + } + if err != nil { + return nil, fmt.Errorf("failed to fetch secrets: %w", err) + } + + var results []SecretResult + for _, secret := range secrets { + // Handle dynamic secrets + secretType, _ := secret["type"].(string) + if secretType == "dynamic" { + dynamicResults := p.processDynamicSecret(secret, envPrivKey, publicKey, appName, envName, opts) + results = append(results, dynamicResults...) + continue + } + + // Check tag filter + if opts.Tag != "" { + secretTags := extractStringSlice(secret, "tags") + if !misc.TagMatches(secretTags, opts.Tag) { + continue + } + } + + // Determine if override is active + override, hasOverride := secret["override"].(map[string]interface{}) + useOverride := hasOverride && override != nil && getBool(override, "is_active") + + keyToDecrypt, _ := secret["key"].(string) + var valueToDecrypt string + if useOverride { + valueToDecrypt, _ = override["value"].(string) + } else { + valueToDecrypt, _ = secret["value"].(string) + } + commentToDecrypt, _ := secret["comment"].(string) + + decryptedKey, err := crypto.DecryptAsymmetric(keyToDecrypt, envPrivKey, publicKey) + if err != nil { + continue + } + + decryptedValue, err := crypto.DecryptAsymmetric(valueToDecrypt, envPrivKey, publicKey) + if err != nil { + continue + } + + var decryptedComment string + if commentToDecrypt != "" { + decryptedComment, _ = crypto.DecryptAsymmetric(commentToDecrypt, envPrivKey, publicKey) + } + + secretPath, _ := secret["path"].(string) + if secretPath == "" { + secretPath = "/" + } + + secretTags := extractStringSlice(secret, "tags") + + result := SecretResult{ + Key: decryptedKey, + Value: decryptedValue, + Overridden: useOverride, + Tags: secretTags, + Comment: decryptedComment, + Path: secretPath, + Application: appName, + Environment: envName, + } + + // Filter by keys if specified + if len(opts.Keys) > 0 { + found := false + for _, k := range opts.Keys { + if k == decryptedKey { + found = true + break + } + } + if !found { + continue + } + } + + results = append(results, result) + } + + return results, nil +} + +func (p *Phase) processDynamicSecret(secret map[string]interface{}, envPrivKey, publicKey, appName, envName string, opts GetOptions) []SecretResult { + var results []SecretResult + + // Build group label + name, _ := secret["key"].(string) + if name != "" { + decName, err := crypto.DecryptAsymmetric(name, envPrivKey, publicKey) + if err == nil { + name = decName + } + } + provider, _ := secret["provider"].(string) + groupLabel := fmt.Sprintf("%s (%s)", name, provider) + + secretPath, _ := secret["path"].(string) + if secretPath == "" { + secretPath = "/" + } + + // Build credential map from lease if present + credMap := map[string]string{} + if leaseData, ok := secret["lease"].(map[string]interface{}); ok && leaseData != nil { + if creds, ok := leaseData["credentials"].([]interface{}); ok { + for _, c := range creds { + credEntry, ok := c.(map[string]interface{}) + if !ok { + continue + } + encKey, _ := credEntry["key"].(string) + encVal, _ := credEntry["value"].(string) + if encKey == "" { + continue + } + decKey, err := crypto.DecryptAsymmetric(encKey, envPrivKey, publicKey) + if err != nil { + continue + } + decVal := "" + if encVal != "" { + decVal, _ = crypto.DecryptAsymmetric(encVal, envPrivKey, publicKey) + } + credMap[decKey] = decVal + } + } + } + + // Process key_map entries + keyMap, ok := secret["key_map"].([]interface{}) + if !ok { + return results + } + + for _, km := range keyMap { + entry, ok := km.(map[string]interface{}) + if !ok { + continue + } + encKeyName, _ := entry["key_name"].(string) + if encKeyName == "" { + continue + } + decKeyName, err := crypto.DecryptAsymmetric(encKeyName, envPrivKey, publicKey) + if err != nil { + continue + } + + value := "" + if v, exists := credMap[decKeyName]; exists { + value = v + } + + result := SecretResult{ + Key: decKeyName, + Value: value, + Path: secretPath, + Application: appName, + Environment: envName, + IsDynamic: true, + DynamicGroup: groupLabel, + } + + if len(opts.Keys) > 0 { + found := false + for _, k := range opts.Keys { + if k == decKeyName { + found = true + break + } + } + if !found { + continue + } + } + + results = append(results, result) + } + + return results +} + +func (p *Phase) Create(opts CreateOptions) error { + resp, err := network.FetchPhaseUser(p.TokenType, p.AppToken, p.APIHost) + if err != nil { + return err + } + defer resp.Body.Close() + + var userData misc.AppKeyResponse + if err := json.NewDecoder(resp.Body).Decode(&userData); err != nil { + return fmt.Errorf("failed to decode user data: %w", err) + } + + _, _, _, envID, publicKey, err := PhaseGetContext(&userData, opts.AppName, opts.EnvName, opts.AppID) + if err != nil { + return err + } + + envKey := p.findMatchingEnvironmentKey(&userData, envID) + if envKey == nil { + return fmt.Errorf("no environment found with id: %s", envID) + } + + // Decrypt salt for key digest + userDataMap := appKeyResponseToMap(&userData) + decryptedSalt, err := p.Decrypt(envKey.WrappedSalt, userDataMap) + if err != nil { + return fmt.Errorf("failed to decrypt wrapped salt: %w", err) + } + + path := opts.Path + if path == "" { + path = "/" + } + + var secrets []map[string]interface{} + for _, pair := range opts.KeyValuePairs { + encryptedKey, err := crypto.EncryptAsymmetric(pair.Key, publicKey) + if err != nil { + return fmt.Errorf("failed to encrypt key: %w", err) + } + + encryptedValue, err := crypto.EncryptAsymmetric(pair.Value, publicKey) + if err != nil { + return fmt.Errorf("failed to encrypt value: %w", err) + } + + keyDigest, err := crypto.Blake2bDigest(pair.Key, decryptedSalt) + if err != nil { + return fmt.Errorf("failed to generate key digest: %w", err) + } + + secret := map[string]interface{}{ + "key": encryptedKey, + "keyDigest": keyDigest, + "value": encryptedValue, + "path": path, + "tags": []string{}, + "comment": "", + } + + if opts.OverrideValue != "" { + encryptedOverride, err := crypto.EncryptAsymmetric(opts.OverrideValue, publicKey) + if err != nil { + return fmt.Errorf("failed to encrypt override value: %w", err) + } + secret["override"] = map[string]interface{}{ + "value": encryptedOverride, + "isActive": true, + } + } + + secrets = append(secrets, secret) + } + + return network.CreatePhaseSecrets(p.TokenType, p.AppToken, envID, secrets, p.APIHost) +} + +func (p *Phase) Update(opts UpdateOptions) (string, error) { + resp, err := network.FetchPhaseUser(p.TokenType, p.AppToken, p.APIHost) + if err != nil { + return "", err + } + defer resp.Body.Close() + + var userData misc.AppKeyResponse + if err := json.NewDecoder(resp.Body).Decode(&userData); err != nil { + return "", fmt.Errorf("failed to decode user data: %w", err) + } + + _, _, _, envID, publicKey, err := PhaseGetContext(&userData, opts.AppName, opts.EnvName, opts.AppID) + if err != nil { + return "", err + } + + envKey := p.findMatchingEnvironmentKey(&userData, envID) + if envKey == nil { + return "", fmt.Errorf("no environment found with id: %s", envID) + } + + // Fetch secrets from source path + sourcePath := opts.SourcePath + secrets, err := network.FetchPhaseSecrets(p.TokenType, p.AppToken, envID, p.APIHost, sourcePath) + if err != nil { + return "", fmt.Errorf("failed to fetch secrets: %w", err) + } + + // Decrypt seed to get env keypair + userDataMap := appKeyResponseToMap(&userData) + decryptedSeed, err := p.Decrypt(envKey.WrappedSeed, userDataMap) + if err != nil { + return "", fmt.Errorf("failed to decrypt wrapped seed: %w", err) + } + + _, envPrivKey, err := crypto.GenerateEnvKeyPair(decryptedSeed) + if err != nil { + return "", fmt.Errorf("failed to generate env key pair: %w", err) + } + + // Find matching secret + var matchingSecret map[string]interface{} + for _, secret := range secrets { + encKey, _ := secret["key"].(string) + dk, err := crypto.DecryptAsymmetric(encKey, envPrivKey, publicKey) + if err != nil { + continue + } + if dk == opts.Key { + matchingSecret = secret + break + } + } + + if matchingSecret == nil { + return fmt.Sprintf("Key '%s' doesn't exist in path '%s'.", opts.Key, sourcePath), nil + } + + // Encrypt key and value + encryptedKey, err := crypto.EncryptAsymmetric(opts.Key, publicKey) + if err != nil { + return "", fmt.Errorf("failed to encrypt key: %w", err) + } + + encryptedValue, err := crypto.EncryptAsymmetric(coalesce(opts.Value, ""), publicKey) + if err != nil { + return "", fmt.Errorf("failed to encrypt value: %w", err) + } + + // Get key digest + decryptedSalt, err := p.Decrypt(envKey.WrappedSalt, userDataMap) + if err != nil { + return "", fmt.Errorf("failed to decrypt wrapped salt: %w", err) + } + + keyDigest, err := crypto.Blake2bDigest(opts.Key, decryptedSalt) + if err != nil { + return "", fmt.Errorf("failed to generate key digest: %w", err) + } + + // Determine payload value + payloadValue := encryptedValue + if opts.Override || opts.ToggleOverride { + payloadValue, _ = matchingSecret["value"].(string) + } + + // Determine path + path := matchingSecret["path"] + if opts.DestinationPath != "" { + path = opts.DestinationPath + } + + secretID, _ := matchingSecret["id"].(string) + payload := map[string]interface{}{ + "id": secretID, + "key": encryptedKey, + "keyDigest": keyDigest, + "value": payloadValue, + "tags": matchingSecret["tags"], + "comment": matchingSecret["comment"], + "path": path, + } + + // Handle override logic + if opts.ToggleOverride { + override, hasOverride := matchingSecret["override"].(map[string]interface{}) + if !hasOverride || override == nil { + return "", fmt.Errorf("no override found for key '%s'. Create one first with --override", opts.Key) + } + currentState := getBool(override, "is_active") + payload["override"] = map[string]interface{}{ + "value": override["value"], + "isActive": !currentState, + } + } else if opts.Override { + override, hasOverride := matchingSecret["override"].(map[string]interface{}) + if !hasOverride || override == nil { + payload["override"] = map[string]interface{}{ + "value": encryptedValue, + "isActive": true, + } + } else { + val := encryptedValue + if opts.Value == "" { + val, _ = override["value"].(string) + } + payload["override"] = map[string]interface{}{ + "value": val, + "isActive": getBool(override, "is_active"), + } + } + } + + err = network.UpdatePhaseSecrets(p.TokenType, p.AppToken, envID, []map[string]interface{}{payload}, p.APIHost) + if err != nil { + return "", fmt.Errorf("failed to update secret: %w", err) + } + + return "Success", nil +} + +func (p *Phase) Delete(opts DeleteOptions) ([]string, error) { + resp, err := network.FetchPhaseUser(p.TokenType, p.AppToken, p.APIHost) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + var userData misc.AppKeyResponse + if err := json.NewDecoder(resp.Body).Decode(&userData); err != nil { + return nil, fmt.Errorf("failed to decode user data: %w", err) + } + + _, _, _, envID, publicKey, err := PhaseGetContext(&userData, opts.AppName, opts.EnvName, opts.AppID) + if err != nil { + return nil, err + } + + envKey := p.findMatchingEnvironmentKey(&userData, envID) + if envKey == nil { + return nil, fmt.Errorf("no environment found with id: %s", envID) + } + + // Decrypt seed to get env keypair + userDataMap := appKeyResponseToMap(&userData) + decryptedSeed, err := p.Decrypt(envKey.WrappedSeed, userDataMap) + if err != nil { + return nil, fmt.Errorf("failed to decrypt wrapped seed: %w", err) + } + + _, envPrivKey, err := crypto.GenerateEnvKeyPair(decryptedSeed) + if err != nil { + return nil, fmt.Errorf("failed to generate env key pair: %w", err) + } + + // Fetch secrets + secrets, err := network.FetchPhaseSecrets(p.TokenType, p.AppToken, envID, p.APIHost, opts.Path) + if err != nil { + return nil, fmt.Errorf("failed to fetch secrets: %w", err) + } + + var idsToDelete []string + var keysNotFound []string + + for _, key := range opts.KeysToDelete { + found := false + for _, secret := range secrets { + if opts.Path != "" { + secretPath, _ := secret["path"].(string) + if secretPath != opts.Path { + continue + } + } + encKey, _ := secret["key"].(string) + dk, err := crypto.DecryptAsymmetric(encKey, envPrivKey, publicKey) + if err != nil { + continue + } + if dk == key { + secretID, _ := secret["id"].(string) + idsToDelete = append(idsToDelete, secretID) + found = true + break + } + } + if !found { + keysNotFound = append(keysNotFound, key) + } + } + + if len(idsToDelete) > 0 { + if err := network.DeletePhaseSecrets(p.TokenType, p.AppToken, envID, idsToDelete, p.APIHost); err != nil { + return nil, fmt.Errorf("failed to delete secrets: %w", err) + } + } + + return keysNotFound, nil +} + +// Helper functions + +func appKeyResponseToMap(resp *misc.AppKeyResponse) map[string]interface{} { + data, _ := json.Marshal(resp) + var result map[string]interface{} + json.Unmarshal(data, &result) + return result +} + +func extractStringSlice(m map[string]interface{}, key string) []string { + raw, ok := m[key].([]interface{}) + if !ok { + return nil + } + var result []string + for _, v := range raw { + if s, ok := v.(string); ok { + result = append(result, s) + } + } + return result +} + +func getBool(m map[string]interface{}, key string) bool { + v, ok := m[key] + if !ok { + return false + } + switch b := v.(type) { + case bool: + return b + case string: + return strings.ToLower(b) == "true" + default: + return false + } +} diff --git a/src/pkg/util/browser.go b/src/pkg/util/browser.go new file mode 100644 index 00000000..0fd03a9d --- /dev/null +++ b/src/pkg/util/browser.go @@ -0,0 +1,20 @@ +package util + +import ( + "fmt" + "os/exec" + "runtime" +) + +func OpenBrowser(url string) error { + switch runtime.GOOS { + case "darwin": + return exec.Command("open", url).Start() + case "linux": + return exec.Command("xdg-open", url).Start() + case "windows": + return exec.Command("cmd", "/c", "start", url).Start() + default: + return fmt.Errorf("unsupported platform: %s", runtime.GOOS) + } +} diff --git a/src/pkg/util/color.go b/src/pkg/util/color.go new file mode 100644 index 00000000..d7968998 --- /dev/null +++ b/src/pkg/util/color.go @@ -0,0 +1,70 @@ +package util + +import ( + "fmt" + "os" + "syscall" + + "golang.org/x/term" +) + +const ( + ansiReset = "\033[0m" + ansiBold = "\033[1m" + ansiBoldGreen = "\033[1;32m" + ansiBoldCyan = "\033[1;36m" + ansiBoldMagenta = "\033[1;35m" + ansiBoldYellow = "\033[1;33m" + ansiBoldRed = "\033[1;31m" + ansiBoldWhite = "\033[1;37m" +) + +func stdoutIsTTY() bool { + return term.IsTerminal(int(syscall.Stdout)) +} + +func stderrIsTTY() bool { + return term.IsTerminal(int(syscall.Stderr)) +} + +// wrap applies ANSI codes around text if the given fd is a TTY. +func wrap(code, text string, isTTY bool) string { + if !isTTY { + return text + } + return code + text + ansiReset +} + +// --- stdout helpers --- + +func Bold(text string) string { return wrap(ansiBold, text, stdoutIsTTY()) } +func BoldGreen(text string) string { return wrap(ansiBoldGreen, text, stdoutIsTTY()) } +func BoldCyan(text string) string { return wrap(ansiBoldCyan, text, stdoutIsTTY()) } +func BoldMagenta(text string) string { return wrap(ansiBoldMagenta, text, stdoutIsTTY()) } +func BoldYellow(text string) string { return wrap(ansiBoldYellow, text, stdoutIsTTY()) } +func BoldRed(text string) string { return wrap(ansiBoldRed, text, stdoutIsTTY()) } +func BoldWhite(text string) string { return wrap(ansiBoldWhite, text, stdoutIsTTY()) } + +// --- stderr helpers --- + +func BoldErr(text string) string { return wrap(ansiBold, text, stderrIsTTY()) } +func BoldGreenErr(text string) string { return wrap(ansiBoldGreen, text, stderrIsTTY()) } +func BoldCyanErr(text string) string { return wrap(ansiBoldCyan, text, stderrIsTTY()) } +func BoldMagentaErr(text string) string { return wrap(ansiBoldMagenta, text, stderrIsTTY()) } +func BoldYellowErr(text string) string { return wrap(ansiBoldYellow, text, stderrIsTTY()) } +func BoldRedErr(text string) string { return wrap(ansiBoldRed, text, stderrIsTTY()) } +func BoldWhiteErr(text string) string { return wrap(ansiBoldWhite, text, stderrIsTTY()) } + +// AnsiCodes returns the raw ANSI prefix/reset for use in tree rendering, etc. +// Returns empty strings when stdout is not a TTY. +func AnsiCodes() (bold, cyan, green, magenta, reset string) { + if !stdoutIsTTY() { + return "", "", "", "", "" + } + return ansiBold, "\033[36m", "\033[32m", "\033[35m", ansiReset +} + +// Fprintf convenience: prints a formatted line to stderr with optional color. +func FprintStderr(format string, a ...interface{}) { + fmt.Fprintf(os.Stderr, format, a...) +} diff --git a/src/pkg/util/export.go b/src/pkg/util/export.go new file mode 100644 index 00000000..fe2fffee --- /dev/null +++ b/src/pkg/util/export.go @@ -0,0 +1,83 @@ +package util + +import ( + "encoding/csv" + "encoding/json" + "encoding/xml" + "fmt" + "os" + "strings" + + "gopkg.in/yaml.v3" +) + +func ExportDotenv(secrets map[string]string) { + for key, value := range secrets { + fmt.Printf("%s=\"%s\"\n", key, value) + } +} + +func ExportJSON(secrets map[string]string) { + data, _ := json.MarshalIndent(secrets, "", " ") + fmt.Println(string(data)) +} + +func ExportCSV(secrets map[string]string) { + w := csv.NewWriter(os.Stdout) + w.Write([]string{"Key", "Value"}) + for key, value := range secrets { + w.Write([]string{key, value}) + } + w.Flush() +} + +func ExportYAML(secrets map[string]string) { + data, _ := yaml.Marshal(secrets) + fmt.Print(string(data)) +} + +func ExportXML(secrets map[string]string) { + fmt.Println("") + for key, value := range secrets { + var escaped strings.Builder + xml.EscapeText(&escaped, []byte(value)) + fmt.Printf(" %s\n", key, escaped.String()) + } + fmt.Println("") +} + +func ExportTOML(secrets map[string]string) { + for key, value := range secrets { + fmt.Printf("%s = \"%s\"\n", key, value) + } +} + +func ExportHCL(secrets map[string]string) { + for key, value := range secrets { + escaped := strings.ReplaceAll(value, "\"", "\\\"") + fmt.Printf("variable \"%s\" {\n", key) + fmt.Printf(" default = \"%s\"\n", escaped) + fmt.Println("}") + fmt.Println() + } +} + +func ExportINI(secrets map[string]string) { + fmt.Println("[DEFAULT]") + for key, value := range secrets { + escaped := strings.ReplaceAll(value, "%", "%%") + fmt.Printf("%s = %s\n", key, escaped) + } +} + +func ExportJavaProperties(secrets map[string]string) { + for key, value := range secrets { + fmt.Printf("%s=%s\n", key, value) + } +} + +func ExportKV(secrets map[string]string) { + for key, value := range secrets { + fmt.Printf("%s=%s\n", key, value) + } +} diff --git a/src/pkg/util/misc.go b/src/pkg/util/misc.go new file mode 100644 index 00000000..1d57daf4 --- /dev/null +++ b/src/pkg/util/misc.go @@ -0,0 +1,209 @@ +package util + +import ( + "bufio" + "crypto/rand" + "encoding/base64" + "encoding/hex" + "fmt" + "math/big" + "os" + "os/exec" + "runtime" + "strings" +) + +type EnvKeyValue struct { + Key string + Value string +} + +func ParseEnvFile(path string) ([]EnvKeyValue, error) { + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer f.Close() + + var pairs []EnvKeyValue + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" || strings.HasPrefix(line, "#") || !strings.Contains(line, "=") { + continue + } + idx := strings.Index(line, "=") + key := strings.TrimSpace(line[:idx]) + value := strings.TrimSpace(line[idx+1:]) + value = sanitizeValue(value) + pairs = append(pairs, EnvKeyValue{ + Key: strings.ToUpper(key), + Value: value, + }) + } + return pairs, scanner.Err() +} + +func sanitizeValue(value string) string { + if len(value) >= 2 { + if (value[0] == '\'' && value[len(value)-1] == '\'') || + (value[0] == '"' && value[len(value)-1] == '"') { + return value[1 : len(value)-1] + } + } + return value +} + +func GetDefaultShell() []string { + if runtime.GOOS == "windows" { + if p, err := exec.LookPath("pwsh"); err == nil { + _ = p + return []string{"pwsh"} + } + if p, err := exec.LookPath("powershell"); err == nil { + _ = p + return []string{"powershell"} + } + return []string{"cmd"} + } + + shell := os.Getenv("SHELL") + if shell != "" { + if _, err := os.Stat(shell); err == nil { + return []string{shell} + } + } + + for _, sh := range []string{"/bin/zsh", "/bin/bash", "/bin/sh"} { + if _, err := os.Stat(sh); err == nil { + return []string{sh} + } + } + return nil +} + +func CleanSubprocessEnv() map[string]string { + env := map[string]string{} + for _, e := range os.Environ() { + idx := strings.Index(e, "=") + if idx < 0 { + continue + } + key := e[:idx] + value := e[idx+1:] + // Remove PyInstaller library path variables + if key == "LD_LIBRARY_PATH" || key == "DYLD_LIBRARY_PATH" { + continue + } + env[key] = value + } + return env +} + +func NormalizeTag(tag string) string { + return strings.ToLower(strings.ReplaceAll(tag, "_", " ")) +} + +func TagMatches(secretTags []string, userTag string) bool { + normalizedUserTag := NormalizeTag(userTag) + for _, tag := range secretTags { + normalizedSecretTag := NormalizeTag(tag) + if strings.Contains(normalizedSecretTag, normalizedUserTag) { + return true + } + } + return false +} + +func ParseBoolFlag(value string) bool { + switch strings.ToLower(strings.TrimSpace(value)) { + case "false", "no", "0": + return false + default: + return true + } +} + +func GetShellCommand(shellType string) ([]string, error) { + shellMap := map[string]string{ + "bash": "bash", + "zsh": "zsh", + "fish": "fish", + "sh": "sh", + "powershell": "powershell", + "pwsh": "pwsh", + "cmd": "cmd", + } + + bin, ok := shellMap[strings.ToLower(shellType)] + if !ok { + return nil, fmt.Errorf("unsupported shell type: %s", shellType) + } + + path, err := exec.LookPath(bin) + if err != nil { + return nil, fmt.Errorf("shell '%s' not found in PATH: %w", bin, err) + } + return []string{path}, nil +} + +// GenerateRandomSecret generates a random secret of the specified type and length +func GenerateRandomSecret(randomType string, length int) (string, error) { + if length <= 0 { + length = 32 + } + + switch randomType { + case "hex": + bytes := make([]byte, length/2+1) + if _, err := rand.Read(bytes); err != nil { + return "", err + } + return hex.EncodeToString(bytes)[:length], nil + case "alphanumeric": + const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + result := make([]byte, length) + for i := range result { + n, err := rand.Int(rand.Reader, big.NewInt(int64(len(chars)))) + if err != nil { + return "", err + } + result[i] = chars[n.Int64()] + } + return string(result), nil + case "key128": + bytes := make([]byte, 16) + if _, err := rand.Read(bytes); err != nil { + return "", err + } + return hex.EncodeToString(bytes), nil + case "key256": + bytes := make([]byte, 32) + if _, err := rand.Read(bytes); err != nil { + return "", err + } + return hex.EncodeToString(bytes), nil + case "base64": + bytes := make([]byte, length) + if _, err := rand.Read(bytes); err != nil { + return "", err + } + encoded := base64.StdEncoding.EncodeToString(bytes) + if len(encoded) < length { + return encoded, nil + } + return encoded[:length], nil + case "base64url": + bytes := make([]byte, length) + if _, err := rand.Read(bytes); err != nil { + return "", err + } + encoded := base64.URLEncoding.EncodeToString(bytes) + if len(encoded) < length { + return encoded, nil + } + return encoded[:length], nil + default: + return "", fmt.Errorf("unsupported random type: %s. Supported types: hex, alphanumeric, base64, base64url, key128, key256", randomType) + } +} diff --git a/src/pkg/util/spinner.go b/src/pkg/util/spinner.go new file mode 100644 index 00000000..11e1eef2 --- /dev/null +++ b/src/pkg/util/spinner.go @@ -0,0 +1,69 @@ +package util + +import ( + "fmt" + "os" + "sync" + "syscall" + "time" + + "golang.org/x/term" +) + +var spinnerFrames = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"} + +// Spinner is a transient braille-dot spinner that writes to stderr. +type Spinner struct { + message string + stop chan struct{} + done sync.WaitGroup +} + +// NewSpinner creates a spinner with the given message. +func NewSpinner(message string) *Spinner { + return &Spinner{ + message: message, + stop: make(chan struct{}), + } +} + +// Start begins the spinner animation in a background goroutine. +func (s *Spinner) Start() { + if !term.IsTerminal(int(syscall.Stderr)) { + return + } + + s.done.Add(1) + go func() { + defer s.done.Done() + i := 0 + ticker := time.NewTicker(80 * time.Millisecond) + defer ticker.Stop() + + for { + select { + case <-s.stop: + // Clear the spinner line + fmt.Fprintf(os.Stderr, "\r\033[K") + return + case <-ticker.C: + frame := spinnerFrames[i%len(spinnerFrames)] + msg := BoldGreenErr(s.message) + fmt.Fprintf(os.Stderr, "\r%s %s", frame, msg) + i++ + } + } + }() +} + +// Stop halts the spinner and clears the line. +func (s *Spinner) Stop() { + select { + case <-s.stop: + // Already stopped + return + default: + close(s.stop) + } + s.done.Wait() +} From 6e3c262a2662393247d8fe4865d334e8df41b945 Mon Sep 17 00:00:00 2001 From: Nimish Date: Sun, 8 Feb 2026 19:41:15 +0530 Subject: [PATCH 02/50] feat: add CI workflows for Go project - Introduced multiple GitHub Actions workflows for the Go project, including: - `go-attach-to-release.yml`: Attaches built assets to GitHub releases. - `go-build.yml`: Cross-compiles the Go application for various platforms. - `go-docker.yml`: Builds and tests Docker images for the application. - `go-main.yml`: Orchestrates the main CI process, including testing, building, and packaging. - `go-process-assets.yml`: Processes and packages build artifacts. - `go-test-install-post-build.yml`: Tests the installation of the application on various Linux distributions. - `go-test.yml`: Runs tests and vetting for the Go code. - `go-version.yml`: Extracts and validates the version from the Go source code. These workflows enhance the CI/CD pipeline, ensuring robust testing and deployment processes for the Go application. --- .github/workflows/go-attach-to-release.yml | 26 +++++ .github/workflows/go-build.yml | 97 ++++++++++++++++ .github/workflows/go-docker.yml | 86 ++++++++++++++ .github/workflows/go-main.yml | 71 ++++++++++++ .github/workflows/go-process-assets.yml | 92 +++++++++++++++ .../workflows/go-test-install-post-build.yml | 105 ++++++++++++++++++ .github/workflows/go-test.yml | 40 +++++++ .github/workflows/go-version.yml | 28 +++++ 8 files changed, 545 insertions(+) create mode 100644 .github/workflows/go-attach-to-release.yml create mode 100644 .github/workflows/go-build.yml create mode 100644 .github/workflows/go-docker.yml create mode 100644 .github/workflows/go-main.yml create mode 100644 .github/workflows/go-process-assets.yml create mode 100644 .github/workflows/go-test-install-post-build.yml create mode 100644 .github/workflows/go-test.yml create mode 100644 .github/workflows/go-version.yml diff --git a/.github/workflows/go-attach-to-release.yml b/.github/workflows/go-attach-to-release.yml new file mode 100644 index 00000000..76933a62 --- /dev/null +++ b/.github/workflows/go-attach-to-release.yml @@ -0,0 +1,26 @@ +name: "[Go] Attach Assets to Release" + +on: + workflow_call: + inputs: + version: + required: true + type: string + +jobs: + attach-to-release: + name: Attach Go Assets to Release + runs-on: ubuntu-latest + steps: + - name: Download processed assets + uses: actions/download-artifact@v4 + with: + name: phase-go-release + path: ./phase-go-release + + - name: Attach assets to release + uses: softprops/action-gh-release@v2 + with: + files: ./phase-go-release/* + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/go-build.yml b/.github/workflows/go-build.yml new file mode 100644 index 00000000..6f37c55f --- /dev/null +++ b/.github/workflows/go-build.yml @@ -0,0 +1,97 @@ +name: "[Go] Cross-compile" + +on: + workflow_call: + inputs: + version: + required: true + type: string + +jobs: + build: + name: Build ${{ matrix.goos }}/${{ matrix.goarch }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + # macOS + - { goos: darwin, goarch: amd64, suffix: "" } + - { goos: darwin, goarch: arm64, suffix: "" } + # Linux + - { goos: linux, goarch: amd64, suffix: "" } + - { goos: linux, goarch: arm64, suffix: "" } + - { goos: linux, goarch: "386", suffix: "" } + - { goos: linux, goarch: arm, suffix: "" } + # Windows + - { goos: windows, goarch: amd64, suffix: ".exe" } + - { goos: windows, goarch: arm64, suffix: ".exe" } + - { goos: windows, goarch: "386", suffix: ".exe" } + # FreeBSD + - { goos: freebsd, goarch: amd64, suffix: "" } + - { goos: freebsd, goarch: arm64, suffix: "" } + - { goos: freebsd, goarch: "386", suffix: "" } + - { goos: freebsd, goarch: arm, suffix: "" } + # OpenBSD + - { goos: openbsd, goarch: amd64, suffix: "" } + - { goos: openbsd, goarch: arm64, suffix: "" } + # NetBSD + - { goos: netbsd, goarch: amd64, suffix: "" } + - { goos: netbsd, goarch: arm, suffix: "" } + # Dragonfly + - { goos: dragonfly, goarch: amd64, suffix: "" } + # Linux MIPS + - { goos: linux, goarch: mips, suffix: "" } + - { goos: linux, goarch: mipsle, suffix: "" } + - { goos: linux, goarch: mips64, suffix: "" } + - { goos: linux, goarch: mips64le, suffix: "" } + # Solaris / Illumos + - { goos: solaris, goarch: amd64, suffix: "" } + - { goos: illumos, goarch: amd64, suffix: "" } + + steps: + - uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: "1.24" + cache-dependency-path: src/go.sum + + - name: Clone Go SDK + uses: actions/checkout@v4 + with: + repository: phasehq/golang-sdk + path: golang-sdk + + - name: Patch go.mod replace directive for CI + working-directory: src + run: | + go mod edit -replace github.com/phasehq/golang-sdk=../golang-sdk + + - name: Build + working-directory: src + env: + CGO_ENABLED: "0" + GOOS: ${{ matrix.goos }} + GOARCH: ${{ matrix.goarch }} + run: | + BINARY_NAME="phase${{ matrix.suffix }}" + LDFLAGS="-s -w -X github.com/phasehq/cli/cmd.Version=${{ inputs.version }}" + go build -ldflags "$LDFLAGS" -o "$BINARY_NAME" ./ + echo "Built: $BINARY_NAME for $GOOS/$GOARCH" + + - name: Prepare artifact + working-directory: src + run: | + ARTIFACT_DIR="phase-${{ matrix.goos }}-${{ matrix.goarch }}" + mkdir -p "$ARTIFACT_DIR" + cp "phase${{ matrix.suffix }}" "$ARTIFACT_DIR/" + echo "Artifact directory: $ARTIFACT_DIR" + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: phase-${{ matrix.goos }}-${{ matrix.goarch }} + path: src/phase-${{ matrix.goos }}-${{ matrix.goarch }}/ + retention-days: 7 diff --git a/.github/workflows/go-docker.yml b/.github/workflows/go-docker.yml new file mode 100644 index 00000000..9d901546 --- /dev/null +++ b/.github/workflows/go-docker.yml @@ -0,0 +1,86 @@ +name: "[Go] Docker Build, Push, and Test" + +on: + workflow_call: + inputs: + version: + required: true + type: string + secrets: + DOCKER_HUB_USERNAME: + required: true + DOCKER_HUB_PASSWORD: + required: true + +jobs: + build_push: + name: Build & Push Docker Image + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Clone Go SDK + uses: actions/checkout@v4 + with: + repository: phasehq/golang-sdk + path: golang-sdk + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_HUB_USERNAME }} + password: ${{ secrets.DOCKER_HUB_PASSWORD }} + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: . + file: Dockerfile + platforms: linux/amd64,linux/arm64 + push: true + tags: | + phasehq/cli:${{ inputs.version }} + phasehq/cli:latest + build-args: | + VERSION=${{ inputs.version }} + + pull_test: + name: Test Docker Image + needs: build_push + runs-on: ubuntu-latest + steps: + - name: Pull versioned image + run: docker pull phasehq/cli:${{ inputs.version }} + + - name: Test versioned image + run: | + echo "Testing phasehq/cli:${{ inputs.version }}" + FULL_OUTPUT=$(docker run --rm phasehq/cli:${{ inputs.version }} --version) + echo "Output: $FULL_OUTPUT" + RETURNED_VERSION=$(echo "$FULL_OUTPUT" | awk '{print $1}') + if [ "$RETURNED_VERSION" != "${{ inputs.version }}" ]; then + echo "Version mismatch: Expected ${{ inputs.version }}, got $RETURNED_VERSION" + exit 1 + fi + echo "Version check passed" + + - name: Pull latest image + run: docker pull phasehq/cli:latest + + - name: Test latest image + run: | + echo "Testing phasehq/cli:latest" + FULL_OUTPUT=$(docker run --rm phasehq/cli:latest --version) + echo "Output: $FULL_OUTPUT" + RETURNED_VERSION=$(echo "$FULL_OUTPUT" | awk '{print $1}') + if [ "$RETURNED_VERSION" != "${{ inputs.version }}" ]; then + echo "Version mismatch: Expected ${{ inputs.version }}, got $RETURNED_VERSION" + exit 1 + fi + echo "Version check passed" diff --git a/.github/workflows/go-main.yml b/.github/workflows/go-main.yml new file mode 100644 index 00000000..90f12a10 --- /dev/null +++ b/.github/workflows/go-main.yml @@ -0,0 +1,71 @@ +name: "[Go] Test, Build, Package the Phase CLI" + +on: + pull_request: + paths: + - "src/**" + - "Dockerfile" + - ".github/workflows/go-*.yml" + push: + branches: + - main + paths: + - "src/**" + - "Dockerfile" + - ".github/workflows/go-*.yml" + release: + types: [created] + +permissions: + contents: write + pull-requests: write + +jobs: + + # Run Go tests and vet + go-test: + uses: ./.github/workflows/go-test.yml + + # Extract version from Go source + go-version: + uses: ./.github/workflows/go-version.yml + + # Cross-compile for all platforms + go-build: + needs: [go-test, go-version] + uses: ./.github/workflows/go-build.yml + with: + version: ${{ needs.go-version.outputs.version }} + + # Package, hash, and zip release assets + go-process-assets: + needs: [go-build, go-version] + uses: ./.github/workflows/go-process-assets.yml + with: + version: ${{ needs.go-version.outputs.version }} + + # Attach release assets to GitHub release + go-attach-to-release: + if: github.event_name == 'release' && github.event.action == 'created' + needs: [go-process-assets, go-version] + uses: ./.github/workflows/go-attach-to-release.yml + with: + version: ${{ needs.go-version.outputs.version }} + + # Build and push Docker image + go-docker: + if: github.event_name == 'release' && github.event.action == 'created' + needs: [go-version] + uses: ./.github/workflows/go-docker.yml + with: + version: ${{ needs.go-version.outputs.version }} + secrets: + DOCKER_HUB_USERNAME: ${{ secrets.DOCKER_HUB_USERNAME }} + DOCKER_HUB_PASSWORD: ${{ secrets.DOCKER_HUB_PASSWORD }} + + # Test installation on various Linux distros + go-test-install: + needs: [go-build, go-version] + uses: ./.github/workflows/go-test-install-post-build.yml + with: + version: ${{ needs.go-version.outputs.version }} diff --git a/.github/workflows/go-process-assets.yml b/.github/workflows/go-process-assets.yml new file mode 100644 index 00000000..c9bdf8d1 --- /dev/null +++ b/.github/workflows/go-process-assets.yml @@ -0,0 +1,92 @@ +name: "[Go] Process and Package Assets" + +on: + workflow_call: + inputs: + version: + required: true + type: string + +jobs: + process-assets: + name: Process and Package Assets + runs-on: ubuntu-latest + + steps: + - name: Download all build artifacts + uses: actions/download-artifact@v4 + with: + path: artifacts + + - name: Package and hash assets + run: | + set -e + VERSION="${{ inputs.version }}" + OUTDIR="phase-go-release" + mkdir -p "$OUTDIR" + + # Map of artifact dirs to output names + # Format: artifact_name output_file + declare -A TARGETS=( + # Tier 1: Primary platforms (tar.gz for unix, zip for windows) + ["phase-darwin-amd64"]="phase_cli_darwin_amd64_${VERSION}" + ["phase-darwin-arm64"]="phase_cli_darwin_arm64_${VERSION}" + ["phase-linux-amd64"]="phase_cli_linux_amd64_${VERSION}" + ["phase-linux-arm64"]="phase_cli_linux_arm64_${VERSION}" + ["phase-linux-386"]="phase_cli_linux_386_${VERSION}" + ["phase-linux-arm"]="phase_cli_linux_arm_${VERSION}" + ["phase-windows-amd64"]="phase_cli_windows_amd64_${VERSION}" + ["phase-windows-arm64"]="phase_cli_windows_arm64_${VERSION}" + ["phase-windows-386"]="phase_cli_windows_386_${VERSION}" + # Tier 2: BSD variants + ["phase-freebsd-amd64"]="phase_cli_freebsd_amd64_${VERSION}" + ["phase-freebsd-arm64"]="phase_cli_freebsd_arm64_${VERSION}" + ["phase-freebsd-386"]="phase_cli_freebsd_386_${VERSION}" + ["phase-freebsd-arm"]="phase_cli_freebsd_arm_${VERSION}" + ["phase-openbsd-amd64"]="phase_cli_openbsd_amd64_${VERSION}" + ["phase-openbsd-arm64"]="phase_cli_openbsd_arm64_${VERSION}" + ["phase-netbsd-amd64"]="phase_cli_netbsd_amd64_${VERSION}" + ["phase-netbsd-arm"]="phase_cli_netbsd_arm_${VERSION}" + ["phase-dragonfly-amd64"]="phase_cli_dragonfly_amd64_${VERSION}" + # Tier 3: MIPS / Solaris / Illumos + ["phase-linux-mips"]="phase_cli_linux_mips_${VERSION}" + ["phase-linux-mipsle"]="phase_cli_linux_mipsle_${VERSION}" + ["phase-linux-mips64"]="phase_cli_linux_mips64_${VERSION}" + ["phase-linux-mips64le"]="phase_cli_linux_mips64le_${VERSION}" + ["phase-solaris-amd64"]="phase_cli_solaris_amd64_${VERSION}" + ["phase-illumos-amd64"]="phase_cli_illumos_amd64_${VERSION}" + ) + + for artifact_name in "${!TARGETS[@]}"; do + base_name="${TARGETS[$artifact_name]}" + artifact_path="artifacts/${artifact_name}" + + if [ ! -d "$artifact_path" ]; then + echo "Warning: Artifact $artifact_name not found, skipping" + continue + fi + + # Windows gets .zip, everything else gets .tar.gz + if [[ "$artifact_name" == *windows* ]]; then + archive="${base_name}.zip" + (cd "$artifact_path" && zip -r "../../${OUTDIR}/${archive}" .) + else + archive="${base_name}.tar.gz" + tar -czf "${OUTDIR}/${archive}" -C "$artifact_path" . + fi + + # Generate SHA256 + (cd "$OUTDIR" && sha256sum "$archive" > "${archive}.sha256") + echo "Packaged: $archive" + done + + echo "" + echo "=== Release assets ===" + ls -lh "$OUTDIR/" + + - name: Upload processed assets + uses: actions/upload-artifact@v4 + with: + name: phase-go-release + path: ./phase-go-release/ + retention-days: 7 diff --git a/.github/workflows/go-test-install-post-build.yml b/.github/workflows/go-test-install-post-build.yml new file mode 100644 index 00000000..4a6e28a0 --- /dev/null +++ b/.github/workflows/go-test-install-post-build.yml @@ -0,0 +1,105 @@ +name: "[Go] Test Installation on Linux" + +on: + workflow_call: + inputs: + version: + required: true + type: string + +jobs: + test_install_x86_64: + name: Test on Linux distros (x86_64) + continue-on-error: true + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - { image: "ubuntu:22.04", name: ubuntu-22.04 } + - { image: "ubuntu:24.04", name: ubuntu-24.04 } + - { image: "debian:bookworm", name: debian-bookworm } + - { image: "debian:trixie", name: debian-trixie } + - { image: "fedora:41", name: fedora-41 } + - { image: "fedora:42", name: fedora-42 } + - { image: "rockylinux:9", name: rocky-9 } + - { image: "amazonlinux:2023", name: amazonlinux-2023 } + - { image: "alpine:3.21", name: alpine-3.21 } + - { image: "alpine:3.22", name: alpine-3.22 } + - { image: "archlinux:latest", name: archlinux-latest } + steps: + - name: Download linux/amd64 binary + uses: actions/download-artifact@v4 + with: + name: phase-linux-amd64 + path: binary + + - name: Run tests in ${{ matrix.name }} + run: | + set -e + chmod +x binary/phase + docker run --rm \ + -v "${PWD}/binary":/binary \ + ${{ matrix.image }} \ + /bin/sh -c "\ + install -Dm755 /binary/phase /usr/local/bin/phase && \ + echo '=== Verify phase exists ===' && \ + command -v phase && \ + echo '=== phase --version ===' && \ + phase --version && \ + echo '=== phase --help (head) ===' && \ + phase --help | head -20 && \ + echo '=== version check ===' && \ + RETURNED_VERSION=\$(phase --version | awk '{print \$1}') && \ + if [ \"\$RETURNED_VERSION\" != \"${{ inputs.version }}\" ]; then \ + echo \"Version mismatch: expected ${{ inputs.version }}, got \$RETURNED_VERSION\"; \ + exit 1; \ + fi && \ + echo 'All checks passed' \ + " + shell: bash + + test_install_arm64: + name: Test on Linux distros (ARM64) + continue-on-error: true + runs-on: ubuntu-22.04-arm + strategy: + fail-fast: false + matrix: + include: + - { image: "ubuntu:24.04", name: ubuntu-24.04 } + - { image: "debian:bookworm", name: debian-bookworm } + - { image: "fedora:42", name: fedora-42 } + - { image: "alpine:3.21", name: alpine-3.21 } + - { image: "amazonlinux:2023", name: amazonlinux-2023 } + steps: + - name: Download linux/arm64 binary + uses: actions/download-artifact@v4 + with: + name: phase-linux-arm64 + path: binary + + - name: Run tests in ${{ matrix.name }} (arm64) + run: | + set -e + chmod +x binary/phase + docker run --rm \ + -v "${PWD}/binary":/binary \ + ${{ matrix.image }} \ + /bin/sh -c "\ + install -Dm755 /binary/phase /usr/local/bin/phase && \ + echo '=== Verify phase exists ===' && \ + command -v phase && \ + echo '=== phase --version ===' && \ + phase --version && \ + echo '=== phase --help (head) ===' && \ + phase --help | head -20 && \ + echo '=== version check ===' && \ + RETURNED_VERSION=\$(phase --version | awk '{print \$1}') && \ + if [ \"\$RETURNED_VERSION\" != \"${{ inputs.version }}\" ]; then \ + echo \"Version mismatch: expected ${{ inputs.version }}, got \$RETURNED_VERSION\"; \ + exit 1; \ + fi && \ + echo 'All checks passed' \ + " + shell: bash diff --git a/.github/workflows/go-test.yml b/.github/workflows/go-test.yml new file mode 100644 index 00000000..efdec184 --- /dev/null +++ b/.github/workflows/go-test.yml @@ -0,0 +1,40 @@ +name: "[Go] Test and Vet" + +on: + workflow_call: + +jobs: + test: + name: Go Test & Vet + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: "1.24" + cache-dependency-path: src/go.sum + + - name: Clone Go SDK + uses: actions/checkout@v4 + with: + repository: phasehq/golang-sdk + path: golang-sdk + + - name: Patch go.mod replace directive for CI + working-directory: src + run: | + go mod edit -replace github.com/phasehq/golang-sdk=../golang-sdk + + - name: Download dependencies + working-directory: src + run: go mod download + + - name: Vet + working-directory: src + run: go vet ./... + + - name: Build (smoke test) + working-directory: src + run: CGO_ENABLED=0 go build -o /dev/null ./ diff --git a/.github/workflows/go-version.yml b/.github/workflows/go-version.yml new file mode 100644 index 00000000..024ae138 --- /dev/null +++ b/.github/workflows/go-version.yml @@ -0,0 +1,28 @@ +name: "[Go] Validate and set version" + +on: + workflow_call: + outputs: + version: + description: "Phase CLI version extracted from Go source" + value: ${{ jobs.extract_version.outputs.version }} + +jobs: + extract_version: + name: Extract Go CLI Version + runs-on: ubuntu-latest + outputs: + version: ${{ steps.get_version.outputs.version }} + steps: + - uses: actions/checkout@v4 + + - name: Extract version from src/cmd/root.go + id: get_version + run: | + VERSION=$(grep -oP '(?<=var Version = ")[^"]*' src/cmd/root.go) + if [ -z "$VERSION" ]; then + echo "Error: Could not extract version from src/cmd/root.go" + exit 1 + fi + echo "version=$VERSION" >> $GITHUB_OUTPUT + echo "Extracted version: $VERSION" From d18034c7d7a17cf1e4e19966e6eb69194b80e2ba Mon Sep 17 00:00:00 2001 From: Nimish Date: Sun, 8 Feb 2026 19:41:28 +0530 Subject: [PATCH 03/50] feat: add .dockerignore file - Introduced a new .dockerignore file to exclude unnecessary files and directories from the Docker build context, improving build efficiency and reducing image size. The file includes common exclusions such as .git, .venv, __pycache__, and various build artifacts. --- .dockerignore | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 .dockerignore diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..cb17ba05 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,13 @@ +.git +.venv +.github +__pycache__ +*.pyc +media +tests +2-0-TODO.md +*.egg-info +dist +build +.mypy_cache +.pytest_cache From 869b8c84118d459e94d8b81eb1960f77b3bad661 Mon Sep 17 00:00:00 2001 From: Nimish Date: Sun, 8 Feb 2026 19:41:41 +0530 Subject: [PATCH 04/50] feat: refactor Dockerfile for Go application build - Introduced a multi-stage Dockerfile to build the Go application, separating the build and runtime environments. - Added a builder stage using the Go image to compile the application, including necessary dependencies and build arguments. - Updated the runtime stage to use a minimal Alpine image, ensuring a lightweight final image with only the compiled binary and CA certificates. - Enhanced the build process with a patch replace directive for the Go module, improving dependency management. --- Dockerfile | 43 ++++++++++++++++++++++++++++++------------- 1 file changed, 30 insertions(+), 13 deletions(-) diff --git a/Dockerfile b/Dockerfile index 25fc9931..124e4de7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,19 +1,36 @@ -FROM python:3.12-alpine3.19 +# Build stage: compile Go binary +FROM golang:1.24-alpine AS builder -# Set source directory -WORKDIR /app +ARG VERSION=dev +ARG TARGETOS=linux +ARG TARGETARCH=amd64 + +WORKDIR /build + +# Copy Go SDK (placed alongside by CI or Docker build context) +COPY golang-sdk/ ./golang-sdk/ # Copy source -COPY phase_cli ./phase_cli -COPY setup.py requirements.txt LICENSE README.md ./ +COPY src/ ./src/ -# Install build dependencies and the CLI -RUN apk add --no-cache --virtual .build-deps gcc musl-dev libffi-dev openssl-dev && \ - pip install --no-cache-dir . && \ - apk del .build-deps +WORKDIR /build/src -# CLI Entrypoint -ENTRYPOINT ["phase"] +# Patch replace directive for build context +RUN go mod edit -replace github.com/phasehq/golang-sdk=../golang-sdk + +# Download dependencies and build +RUN go mod download && \ + CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} \ + go build -ldflags "-s -w -X github.com/phasehq/cli/cmd.Version=${VERSION}" \ + -o /phase ./ -# Run help by default -CMD ["--help"] \ No newline at end of file +# Runtime stage: minimal scratch image +FROM alpine:3.21 + +# Install CA certificates for HTTPS API calls +RUN apk add --no-cache ca-certificates + +COPY --from=builder /phase /usr/local/bin/phase + +ENTRYPOINT ["phase"] +CMD ["--help"] From c45c07201deb828371d8d399f0d827db74ef2264 Mon Sep 17 00:00:00 2001 From: Nimish Date: Sun, 8 Feb 2026 19:45:51 +0530 Subject: [PATCH 05/50] feat: implement MCP server and client configuration management - Added new files for managing the MCP server and client configurations, including installation and uninstallation of server settings for various clients. - Introduced safety checks for command execution and output sanitization to enhance security. - Implemented functions for detecting installed clients and managing secret values securely. - Established a structured approach for handling sensitive keys and validating command safety, ensuring compliance with security best practices. --- src/pkg/mcp/install.go | 263 ++++++++++++++++++ src/pkg/mcp/safety.go | 159 +++++++++++ src/pkg/mcp/server.go | 153 +++++++++++ src/pkg/mcp/tools.go | 595 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 1170 insertions(+) create mode 100644 src/pkg/mcp/install.go create mode 100644 src/pkg/mcp/safety.go create mode 100644 src/pkg/mcp/server.go create mode 100644 src/pkg/mcp/tools.go diff --git a/src/pkg/mcp/install.go b/src/pkg/mcp/install.go new file mode 100644 index 00000000..79286182 --- /dev/null +++ b/src/pkg/mcp/install.go @@ -0,0 +1,263 @@ +package mcp + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "runtime" + "strings" +) + +type clientConfig struct { + UserConfigPath string + ProjectConfigPath string + JSONKey string + ServerConfig map[string]interface{} +} + +var supportedClients = map[string]clientConfig{ + "claude-code": { + UserConfigPath: filepath.Join(homeDir(), ".claude.json"), + ProjectConfigPath: ".mcp.json", + JSONKey: "mcpServers", + ServerConfig: map[string]interface{}{ + "command": "phase", + "args": []interface{}{"mcp", "serve"}, + }, + }, + "cursor": { + UserConfigPath: filepath.Join(homeDir(), ".cursor", "mcp.json"), + ProjectConfigPath: filepath.Join(".cursor", "mcp.json"), + JSONKey: "mcpServers", + ServerConfig: map[string]interface{}{ + "command": "phase", + "args": []interface{}{"mcp", "serve"}, + }, + }, + "vscode": { + UserConfigPath: filepath.Join(homeDir(), ".vscode", "mcp.json"), + ProjectConfigPath: filepath.Join(".vscode", "mcp.json"), + JSONKey: "servers", + ServerConfig: map[string]interface{}{ + "type": "stdio", + "command": "phase", + "args": []interface{}{"mcp", "serve"}, + }, + }, + "zed": { + UserConfigPath: filepath.Join(zedConfigDir(), "settings.json"), + ProjectConfigPath: filepath.Join(".zed", "settings.json"), + JSONKey: "context_servers", + ServerConfig: map[string]interface{}{ + "command": "phase", + "args": []interface{}{"mcp", "serve"}, + }, + }, + "opencode": { + UserConfigPath: filepath.Join(opencodeConfigDir(), "opencode.json"), + ProjectConfigPath: "opencode.json", + JSONKey: "mcp", + ServerConfig: map[string]interface{}{ + "type": "local", + "command": []interface{}{"phase", "mcp", "serve"}, + "enabled": true, + }, + }, +} + +func homeDir() string { + home, _ := os.UserHomeDir() + return home +} + +func zedConfigDir() string { + if runtime.GOOS == "darwin" { + return filepath.Join(homeDir(), ".config", "zed") + } + if xdg := os.Getenv("XDG_CONFIG_HOME"); xdg != "" { + return filepath.Join(xdg, "zed") + } + return filepath.Join(homeDir(), ".config", "zed") +} + +func opencodeConfigDir() string { + if xdg := os.Getenv("XDG_CONFIG_HOME"); xdg != "" { + return filepath.Join(xdg, "opencode") + } + return filepath.Join(homeDir(), ".config", "opencode") +} + +// SupportedClientNames returns the list of supported client names. +func SupportedClientNames() []string { + return []string{"claude-code", "cursor", "vscode", "zed", "opencode"} +} + +// DetectInstalledClients checks which AI client config directories exist. +func DetectInstalledClients() []string { + var detected []string + checks := map[string][]string{ + "claude-code": {filepath.Join(homeDir(), ".claude")}, + "cursor": {filepath.Join(homeDir(), ".cursor")}, + "vscode": {filepath.Join(homeDir(), ".vscode")}, + "zed": {zedConfigDir()}, + "opencode": {opencodeConfigDir()}, + } + for name, paths := range checks { + for _, p := range paths { + if info, err := os.Stat(p); err == nil && info.IsDir() { + detected = append(detected, name) + break + } + } + } + return detected +} + +// Install adds Phase MCP server config for the specified client (or all detected clients). +func Install(client, scope string) error { + if client != "" { + return InstallForClient(client, scope) + } + + detected := DetectInstalledClients() + if len(detected) == 0 { + return fmt.Errorf("no supported AI clients detected. Supported clients: %s", strings.Join(SupportedClientNames(), ", ")) + } + + var errors []string + for _, c := range detected { + if err := InstallForClient(c, scope); err != nil { + errors = append(errors, fmt.Sprintf("%s: %v", c, err)) + } else { + fmt.Fprintf(os.Stderr, "Installed Phase MCP server for %s\n", c) + } + } + if len(errors) > 0 { + return fmt.Errorf("some installations failed:\n%s", strings.Join(errors, "\n")) + } + return nil +} + +// Uninstall removes Phase MCP server config from the specified client (or all). +func Uninstall(client string) error { + if client != "" { + return UninstallForClient(client) + } + + var errors []string + for _, name := range SupportedClientNames() { + if err := UninstallForClient(name); err != nil { + errors = append(errors, fmt.Sprintf("%s: %v", name, err)) + } + } + if len(errors) > 0 { + return fmt.Errorf("some uninstalls failed:\n%s", strings.Join(errors, "\n")) + } + return nil +} + +// InstallForClient adds Phase MCP server to a specific client's config. +func InstallForClient(client, scope string) error { + cfg, ok := supportedClients[client] + if !ok { + return fmt.Errorf("unsupported client: %s. Supported: %s", client, strings.Join(SupportedClientNames(), ", ")) + } + + var configPath string + switch scope { + case "project": + configPath = cfg.ProjectConfigPath + default: + configPath = cfg.UserConfigPath + } + + return addToConfig(configPath, cfg.JSONKey, cfg.ServerConfig) +} + +// UninstallForClient removes Phase MCP server from a specific client's config (both scopes). +func UninstallForClient(client string) error { + cfg, ok := supportedClients[client] + if !ok { + return fmt.Errorf("unsupported client: %s. Supported: %s", client, strings.Join(SupportedClientNames(), ", ")) + } + + var errors []string + for _, path := range []string{cfg.UserConfigPath, cfg.ProjectConfigPath} { + if err := removeFromConfig(path, cfg.JSONKey); err != nil { + if !os.IsNotExist(err) { + errors = append(errors, err.Error()) + } + } + } + if len(errors) > 0 { + return fmt.Errorf("%s", strings.Join(errors, "; ")) + } + return nil +} + +func addToConfig(configPath, jsonKey string, serverConfig map[string]interface{}) error { + // Create parent directories + dir := filepath.Dir(configPath) + if err := os.MkdirAll(dir, 0700); err != nil { + return fmt.Errorf("failed to create directory %s: %w", dir, err) + } + + // Read existing config or start with empty object + config := map[string]interface{}{} + data, err := os.ReadFile(configPath) + if err == nil { + if err := json.Unmarshal(data, &config); err != nil { + return fmt.Errorf("failed to parse %s: %w", configPath, err) + } + } + + // Get or create the servers section + servers, ok := config[jsonKey].(map[string]interface{}) + if !ok { + servers = map[string]interface{}{} + } + + // Add/update phase entry + servers["phase"] = serverConfig + config[jsonKey] = servers + + // Write back + out, err := json.MarshalIndent(config, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal config: %w", err) + } + + return os.WriteFile(configPath, out, 0600) +} + +func removeFromConfig(configPath, jsonKey string) error { + data, err := os.ReadFile(configPath) + if err != nil { + return err + } + + config := map[string]interface{}{} + if err := json.Unmarshal(data, &config); err != nil { + return fmt.Errorf("failed to parse %s: %w", configPath, err) + } + + servers, ok := config[jsonKey].(map[string]interface{}) + if !ok { + return nil // Nothing to remove + } + + if _, exists := servers["phase"]; !exists { + return nil // Already removed + } + + delete(servers, "phase") + config[jsonKey] = servers + + out, err := json.MarshalIndent(config, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal config: %w", err) + } + + return os.WriteFile(configPath, out, 0600) +} diff --git a/src/pkg/mcp/safety.go b/src/pkg/mcp/safety.go new file mode 100644 index 00000000..131171e1 --- /dev/null +++ b/src/pkg/mcp/safety.go @@ -0,0 +1,159 @@ +package mcp + +import ( + "fmt" + "regexp" + "strings" +) + +// Blocked command patterns for phase_run tool +var ( + blockedPatterns = []string{ + "printenv", + "/usr/bin/env", + "declare -x", + "echo $", + "printf %s $", + "cat /proc", + "/proc/self/environ", + "xargs -0", + "eval", + "bash -c", + "sh -c", + "python -c", + "node -e", + "ruby -e", + "perl -e", + "php -r", + } + + blockedCommands = []string{ + "env", + "export", + "set", + } + + blockedRegexPatterns []*regexp.Regexp + sensitiveKeyPatterns []*regexp.Regexp + keyNamePattern *regexp.Regexp +) + +func init() { + regexes := []string{ + `\$[A-Za-z_][A-Za-z0-9_]*`, + `\$\{[^}]+\}`, + "`[^`]+`", + `\$\([^)]+\)`, + } + for _, r := range regexes { + blockedRegexPatterns = append(blockedRegexPatterns, regexp.MustCompile(r)) + } + + sensitivePatterns := []string{ + `(?i).*SECRET.*`, + `(?i).*PRIVATE[_.]?KEY.*`, + `(?i).*SIGNING[_.]?KEY.*`, + `(?i).*ENCRYPTION[_.]?KEY.*`, + `(?i).*HMAC.*`, + `(?i).*PASSWORD.*`, + `(?i).*PASSWD.*`, + `(?i).*TOKEN.*`, + `(?i).*API[_.]?KEY.*`, + `(?i).*ACCESS[_.]?KEY.*`, + `(?i).*AUTH[_.]?KEY.*`, + `(?i).*CREDENTIAL.*`, + `(?i).*CLIENT[_.]?SECRET.*`, + `(?i).*DATABASE[_.]?URL.*`, + `(?i).*CONNECTION[_.]?STRING.*`, + `(?i).*DSN$`, + `(?i).*CERTIFICATE.*`, + `(?i).*CERT[_.]?KEY.*`, + `(?i).*PEM$`, + `(?i).*WEBHOOK[_.]?SECRET.*`, + `(?i).*SALT$`, + `(?i).*HASH[_.]?KEY.*`, + `(?i).*SESSION[_.]?SECRET.*`, + `(?i).*COOKIE[_.]?SECRET.*`, + } + for _, p := range sensitivePatterns { + sensitiveKeyPatterns = append(sensitiveKeyPatterns, regexp.MustCompile("^"+p+"$")) + } + + keyNamePattern = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`) +} + +// ValidateRunCommand checks if a command is safe to execute. +// Returns nil if safe, error with reason if blocked. +func ValidateRunCommand(command string) error { + cmd := strings.TrimSpace(command) + if cmd == "" { + return fmt.Errorf("empty command") + } + + // Check blocked substrings + lower := strings.ToLower(cmd) + for _, p := range blockedPatterns { + if strings.Contains(lower, strings.ToLower(p)) { + return fmt.Errorf("command blocked: contains disallowed pattern '%s'", p) + } + } + + // Check standalone first-token commands + tokens := strings.Fields(cmd) + if len(tokens) > 0 { + first := strings.ToLower(tokens[0]) + for _, bc := range blockedCommands { + if first == bc { + return fmt.Errorf("command blocked: '%s' is not allowed as it may expose environment variables", bc) + } + } + } + + // Check regex patterns for variable expansion + for _, re := range blockedRegexPatterns { + if re.MatchString(cmd) { + return fmt.Errorf("command blocked: contains shell variable expansion or command substitution") + } + } + + return nil +} + +// SanitizeOutput truncates output and redacts credential-like patterns. +func SanitizeOutput(output string, maxLength int) string { + if maxLength <= 0 { + maxLength = 10000 + } + + result := output + if len(result) > maxLength { + result = result[:maxLength] + "\n... [output truncated]" + } + + return result +} + +// IsSensitiveKey returns true if the key name matches common sensitive key patterns. +func IsSensitiveKey(key string) bool { + for _, re := range sensitiveKeyPatterns { + if re.MatchString(key) { + return true + } + } + return false +} + +// IsSafeKeyName validates that a key name has a valid format. +// Returns (true, "") if valid, or (false, reason) if invalid. +func IsSafeKeyName(key string) (bool, string) { + if key == "" { + return false, "key name cannot be empty" + } + if len(key) > 256 { + return false, "key name exceeds maximum length of 256 characters" + } + if !keyNamePattern.MatchString(key) { + return false, "key name must match pattern: ^[A-Za-z_][A-Za-z0-9_]*$ (letters, digits, underscores; must start with letter or underscore)" + } + return true, "" +} diff --git a/src/pkg/mcp/server.go b/src/pkg/mcp/server.go new file mode 100644 index 00000000..7ff7e01f --- /dev/null +++ b/src/pkg/mcp/server.go @@ -0,0 +1,153 @@ +package mcp + +import ( + "context" + "fmt" + "io" + "log" + "os" + "strings" + + gomcp "github.com/modelcontextprotocol/go-sdk/mcp" +) + +const ServerInstructions = `# Phase Secrets Manager - MCP Server + +## Security Rules (MANDATORY) +1. NEVER display, log, or return secret VALUES in any response +2. NEVER store secret values in variables, files, or conversation context +3. NEVER use secret values in code suggestions or examples +4. When users need secrets in their app, use phase_run to inject them at runtime +5. For sensitive keys (passwords, tokens, API keys), ALWAYS use phase_secrets_create with random generation +6. NEVER use phase_secrets_set or phase_secrets_update for sensitive values + +## Workflow +1. Check auth status with phase_auth_status +2. List secrets with phase_secrets_list to see what exists +3. Create new secrets with phase_secrets_create (generates secure random values) +4. Use phase_run to execute commands with secrets injected as environment variables +5. Use phase_secrets_get to check metadata about a specific secret + +## Key Naming Convention +- Use UPPER_SNAKE_CASE for all secret keys +- Examples: DATABASE_URL, API_KEY, JWT_SECRET + +## Common Patterns +- Need a database password? → phase_secrets_create with key=DB_PASSWORD +- Need to run migrations? → phase_run with command="npm run migrate" +- Need to check what secrets exist? → phase_secrets_list +- Importing from .env? → phase_secrets_import` + +// CheckAuth verifies that Phase credentials are available. +func CheckAuth() error { + if token := os.Getenv("PHASE_SERVICE_TOKEN"); token != "" { + return nil + } + return checkAuthAvailable() +} + +// NewMCPServer creates and configures the Phase MCP server with all tools. +func NewMCPServer() *gomcp.Server { + server := gomcp.NewServer( + &gomcp.Implementation{ + Name: "phase", + Version: "2.0.0", + }, + &gomcp.ServerOptions{ + Instructions: ServerInstructions, + }, + ) + + // Tool 1: Auth status + gomcp.AddTool(server, &gomcp.Tool{ + Name: "phase_auth_status", + Description: "Check Phase authentication status and display current user/token info.", + }, handleAuthStatus) + + // Tool 2: List secrets + gomcp.AddTool(server, &gomcp.Tool{ + Name: "phase_secrets_list", + Description: "List all secrets in an environment. Returns metadata only (keys, paths, tags, comments) — values are never exposed for security.", + }, handleSecretsList) + + // Tool 3: Create secret with random value + gomcp.AddTool(server, &gomcp.Tool{ + Name: "phase_secrets_create", + Description: "Create a new secret with a securely generated random value. " + + "Use this for ALL sensitive values (passwords, tokens, API keys, signing keys). " + + "The generated value is stored securely and NEVER returned in the response. " + + "Supported random types: hex, alphanumeric, base64, base64url, key128, key256.", + }, handleSecretsCreate) + + // Tool 4: Set secret with explicit value + gomcp.AddTool(server, &gomcp.Tool{ + Name: "phase_secrets_set", + Description: "Set a secret with an explicit value. " + + "ONLY for non-sensitive configuration values (e.g., APP_NAME, LOG_LEVEL, REGION). " + + "BLOCKED for sensitive keys matching patterns like *SECRET*, *PASSWORD*, *TOKEN*, *API_KEY*, etc. " + + "For sensitive values, use phase_secrets_create instead.", + }, handleSecretsSet) + + // Tool 5: Update secret + gomcp.AddTool(server, &gomcp.Tool{ + Name: "phase_secrets_update", + Description: "Update an existing secret's value. " + + "BLOCKED for sensitive keys matching patterns like *SECRET*, *PASSWORD*, *TOKEN*, *API_KEY*, etc. " + + "For sensitive values, use phase_secrets_create to rotate with a new random value.", + }, handleSecretsUpdate) + + // Tool 6: Delete secrets + gomcp.AddTool(server, &gomcp.Tool{ + Name: "phase_secrets_delete", + Description: "Delete one or more secrets by key name. Keys are automatically uppercased.", + }, handleSecretsDelete) + + // Tool 7: Import secrets from .env file + gomcp.AddTool(server, &gomcp.Tool{ + Name: "phase_secrets_import", + Description: "Import secrets from a .env file into Phase. " + + "Parses KEY=VALUE pairs and encrypts them. Values are never returned in the response.", + }, handleSecretsImport) + + // Tool 8: Get secret metadata + gomcp.AddTool(server, &gomcp.Tool{ + Name: "phase_secrets_get", + Description: "Get metadata about a specific secret (key, path, tags, comment, environment). " + + "The secret VALUE is never returned for security.", + }, handleSecretsGet) + + // Tool 9: Run command with secrets + gomcp.AddTool(server, &gomcp.Tool{ + Name: "phase_run", + Description: "Execute a shell command with Phase secrets injected as environment variables. " + + "Commands are validated for safety — shell variable expansion, env dumping commands, and code injection are blocked. " + + "Output is sanitized and truncated. 5-minute timeout.", + }, handleRun) + + // Tool 10: Initialize project + gomcp.AddTool(server, &gomcp.Tool{ + Name: "phase_init", + Description: "Initialize a Phase project by linking it to an application. " + + "Creates a .phase.json config file with the app ID and default environment.", + }, handleInit) + + return server +} + +// RunServer starts the MCP server on stdio transport. +func RunServer(ctx context.Context) error { + // Redirect all log output to stderr — stdout is reserved for MCP protocol + log.SetOutput(os.Stderr) + + if err := CheckAuth(); err != nil { + fmt.Fprintf(os.Stderr, "Warning: Phase authentication not configured. Set PHASE_SERVICE_TOKEN or run 'phase auth'.\n") + } + + server := NewMCPServer() + err := server.Run(ctx, &gomcp.StdioTransport{}) + // EOF on stdin is normal — it means the client disconnected + if err != nil && (err == io.EOF || strings.Contains(err.Error(), "EOF")) { + return nil + } + return err +} diff --git a/src/pkg/mcp/tools.go b/src/pkg/mcp/tools.go new file mode 100644 index 00000000..205be6bf --- /dev/null +++ b/src/pkg/mcp/tools.go @@ -0,0 +1,595 @@ +package mcp + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "os" + "os/exec" + "strings" + "time" + + gomcp "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/phasehq/cli/pkg/config" + "github.com/phasehq/cli/pkg/keyring" + "github.com/phasehq/cli/pkg/phase" + "github.com/phasehq/cli/pkg/util" +) + +// SecretMetadata is a safe output struct that never includes secret values. +type SecretMetadata struct { + Key string `json:"key"` + Path string `json:"path"` + Tags []string `json:"tags"` + Comment string `json:"comment"` + Environment string `json:"environment"` + Application string `json:"application"` + Overridden bool `json:"overridden"` +} + +func newPhaseClient() (*phase.Phase, error) { + return phase.NewPhase(true, "", "") +} + +func textResult(msg string) *gomcp.CallToolResult { + return &gomcp.CallToolResult{ + Content: []gomcp.Content{ + &gomcp.TextContent{Text: msg}, + }, + } +} + +func errorResult(msg string) (*gomcp.CallToolResult, any, error) { + return &gomcp.CallToolResult{ + Content: []gomcp.Content{ + &gomcp.TextContent{Text: "Error: " + msg}, + }, + IsError: true, + }, nil, nil +} + +// --- Tool argument types --- + +type AuthStatusArgs struct{} + +type SecretsListArgs struct { + Env string `json:"env,omitempty" jsonschema:"Environment name"` + App string `json:"app,omitempty" jsonschema:"Application name"` + AppID string `json:"app_id,omitempty" jsonschema:"Application ID"` + Path string `json:"path,omitempty" jsonschema:"Secret path (default: /)"` + Tags string `json:"tags,omitempty" jsonschema:"Filter by tags"` +} + +type SecretsCreateArgs struct { + Key string `json:"key" jsonschema:"Secret key name (uppercase, letters/digits/underscores)"` + Env string `json:"env,omitempty" jsonschema:"Environment name"` + App string `json:"app,omitempty" jsonschema:"Application name"` + AppID string `json:"app_id,omitempty" jsonschema:"Application ID"` + Path string `json:"path,omitempty" jsonschema:"Secret path (default: /)"` + RandomType string `json:"random_type,omitempty" jsonschema:"Random value type: hex, alphanumeric, base64, base64url, key128, key256"` + Length int `json:"length,omitempty" jsonschema:"Length for random secret (default: 32)"` +} + +type SecretsSetArgs struct { + Key string `json:"key" jsonschema:"Secret key name"` + Value string `json:"value" jsonschema:"Secret value to set"` + Env string `json:"env,omitempty" jsonschema:"Environment name"` + App string `json:"app,omitempty" jsonschema:"Application name"` + AppID string `json:"app_id,omitempty" jsonschema:"Application ID"` + Path string `json:"path,omitempty" jsonschema:"Secret path (default: /)"` +} + +type SecretsUpdateArgs struct { + Key string `json:"key" jsonschema:"Secret key name to update"` + Value string `json:"value" jsonschema:"New secret value"` + Env string `json:"env,omitempty" jsonschema:"Environment name"` + App string `json:"app,omitempty" jsonschema:"Application name"` + AppID string `json:"app_id,omitempty" jsonschema:"Application ID"` + Path string `json:"path,omitempty" jsonschema:"Secret path"` +} + +type SecretsDeleteArgs struct { + Keys []string `json:"keys" jsonschema:"List of secret key names to delete"` + Env string `json:"env,omitempty" jsonschema:"Environment name"` + App string `json:"app,omitempty" jsonschema:"Application name"` + AppID string `json:"app_id,omitempty" jsonschema:"Application ID"` + Path string `json:"path,omitempty" jsonschema:"Secret path"` +} + +type SecretsImportArgs struct { + FilePath string `json:"file_path" jsonschema:"Path to .env file to import"` + Env string `json:"env,omitempty" jsonschema:"Environment name"` + App string `json:"app,omitempty" jsonschema:"Application name"` + AppID string `json:"app_id,omitempty" jsonschema:"Application ID"` + Path string `json:"path,omitempty" jsonschema:"Secret path (default: /)"` +} + +type SecretsGetArgs struct { + Key string `json:"key" jsonschema:"Secret key name to fetch"` + Env string `json:"env,omitempty" jsonschema:"Environment name"` + App string `json:"app,omitempty" jsonschema:"Application name"` + AppID string `json:"app_id,omitempty" jsonschema:"Application ID"` + Path string `json:"path,omitempty" jsonschema:"Secret path (default: /)"` +} + +type RunArgs struct { + Command string `json:"command" jsonschema:"Shell command to execute with secrets injected"` + Env string `json:"env,omitempty" jsonschema:"Environment name"` + App string `json:"app,omitempty" jsonschema:"Application name"` + AppID string `json:"app_id,omitempty" jsonschema:"Application ID"` + Path string `json:"path,omitempty" jsonschema:"Secret path"` + Tags string `json:"tags,omitempty" jsonschema:"Filter by tags"` +} + +type InitArgs struct { + AppID string `json:"app_id" jsonschema:"Application ID to initialize with"` +} + +// --- Tool handlers --- + +func handleAuthStatus(_ context.Context, _ *gomcp.CallToolRequest, _ AuthStatusArgs) (*gomcp.CallToolResult, any, error) { + // Check service token first + if token := os.Getenv("PHASE_SERVICE_TOKEN"); token != "" { + host := os.Getenv("PHASE_HOST") + if host == "" { + host = config.PhaseCloudAPIHost + } + return textResult(fmt.Sprintf("Authenticated via PHASE_SERVICE_TOKEN\nHost: %s\nToken type: Service Token", host)), nil, nil + } + + // Check user config + user, err := config.GetDefaultUser() + if err != nil { + return errorResult("Not authenticated. Set PHASE_SERVICE_TOKEN or run 'phase auth'.") + } + + host, _ := config.GetDefaultUserHost() + info := fmt.Sprintf("Authenticated as user\nUser ID: %s\nEmail: %s\nHost: %s", user.ID, user.Email, host) + if user.OrganizationName != nil && *user.OrganizationName != "" { + info += fmt.Sprintf("\nOrganization: %s", *user.OrganizationName) + } + + return textResult(info), nil, nil +} + +func handleSecretsList(_ context.Context, _ *gomcp.CallToolRequest, args SecretsListArgs) (*gomcp.CallToolResult, any, error) { + p, err := newPhaseClient() + if err != nil { + return errorResult(fmt.Sprintf("Failed to initialize Phase client: %v", err)) + } + + secrets, err := p.Get(phase.GetOptions{ + EnvName: args.Env, + AppName: args.App, + AppID: args.AppID, + Path: args.Path, + Tag: args.Tags, + }) + if err != nil { + return errorResult(fmt.Sprintf("Failed to list secrets: %v", err)) + } + + metadata := make([]SecretMetadata, len(secrets)) + for i, s := range secrets { + metadata[i] = SecretMetadata{ + Key: s.Key, + Path: s.Path, + Tags: s.Tags, + Comment: s.Comment, + Environment: s.Environment, + Application: s.Application, + Overridden: s.Overridden, + } + } + + data, _ := json.MarshalIndent(metadata, "", " ") + return textResult(fmt.Sprintf("Found %d secrets (values hidden for security):\n%s", len(metadata), string(data))), nil, nil +} + +func handleSecretsCreate(_ context.Context, _ *gomcp.CallToolRequest, args SecretsCreateArgs) (*gomcp.CallToolResult, any, error) { + key := strings.ToUpper(strings.ReplaceAll(args.Key, " ", "_")) + + if ok, reason := IsSafeKeyName(key); !ok { + return errorResult(fmt.Sprintf("Invalid key name: %s", reason)) + } + + randomType := args.RandomType + if randomType == "" { + randomType = "hex" + } + + length := args.Length + if length <= 0 { + length = 32 + } + + value, err := util.GenerateRandomSecret(randomType, length) + if err != nil { + return errorResult(fmt.Sprintf("Failed to generate random secret: %v", err)) + } + + p, err := newPhaseClient() + if err != nil { + return errorResult(fmt.Sprintf("Failed to initialize Phase client: %v", err)) + } + + path := args.Path + if path == "" { + path = "/" + } + + err = p.Create(phase.CreateOptions{ + KeyValuePairs: []phase.KeyValuePair{{Key: key, Value: value}}, + EnvName: args.Env, + AppName: args.App, + AppID: args.AppID, + Path: path, + }) + if err != nil { + return errorResult(fmt.Sprintf("Failed to create secret: %v", err)) + } + + return textResult(fmt.Sprintf("Successfully created secret '%s' with a random %s value (value hidden for security).", key, randomType)), nil, nil +} + +func handleSecretsSet(_ context.Context, _ *gomcp.CallToolRequest, args SecretsSetArgs) (*gomcp.CallToolResult, any, error) { + key := strings.ToUpper(strings.ReplaceAll(args.Key, " ", "_")) + + if ok, reason := IsSafeKeyName(key); !ok { + return errorResult(fmt.Sprintf("Invalid key name: %s", reason)) + } + + if IsSensitiveKey(key) { + return errorResult(fmt.Sprintf( + "Cannot set '%s' directly — this key name matches a sensitive pattern (secrets, passwords, tokens, API keys, etc.). "+ + "For security, use 'phase_secrets_create' to generate a random value instead, which ensures the value is never exposed in conversation.", + key, + )) + } + + p, err := newPhaseClient() + if err != nil { + return errorResult(fmt.Sprintf("Failed to initialize Phase client: %v", err)) + } + + path := args.Path + if path == "" { + path = "/" + } + + err = p.Create(phase.CreateOptions{ + KeyValuePairs: []phase.KeyValuePair{{Key: key, Value: args.Value}}, + EnvName: args.Env, + AppName: args.App, + AppID: args.AppID, + Path: path, + }) + if err != nil { + return errorResult(fmt.Sprintf("Failed to set secret: %v", err)) + } + + return textResult(fmt.Sprintf("Successfully set secret '%s'.", key)), nil, nil +} + +func handleSecretsUpdate(_ context.Context, _ *gomcp.CallToolRequest, args SecretsUpdateArgs) (*gomcp.CallToolResult, any, error) { + key := strings.ToUpper(strings.ReplaceAll(args.Key, " ", "_")) + + if IsSensitiveKey(key) { + return errorResult(fmt.Sprintf( + "Cannot update '%s' directly — this key name matches a sensitive pattern (secrets, passwords, tokens, API keys, etc.). "+ + "For security, use 'phase_secrets_create' to generate a random value instead.", + key, + )) + } + + p, err := newPhaseClient() + if err != nil { + return errorResult(fmt.Sprintf("Failed to initialize Phase client: %v", err)) + } + + result, err := p.Update(phase.UpdateOptions{ + EnvName: args.Env, + AppName: args.App, + AppID: args.AppID, + Key: key, + Value: args.Value, + SourcePath: args.Path, + }) + if err != nil { + return errorResult(fmt.Sprintf("Failed to update secret: %v", err)) + } + + if result == "Success" { + return textResult(fmt.Sprintf("Successfully updated secret '%s'.", key)), nil, nil + } + return textResult(result), nil, nil +} + +func handleSecretsDelete(_ context.Context, _ *gomcp.CallToolRequest, args SecretsDeleteArgs) (*gomcp.CallToolResult, any, error) { + if len(args.Keys) == 0 { + return errorResult("No keys specified for deletion.") + } + + // Uppercase all keys + keys := make([]string, len(args.Keys)) + for i, k := range args.Keys { + keys[i] = strings.ToUpper(k) + } + + p, err := newPhaseClient() + if err != nil { + return errorResult(fmt.Sprintf("Failed to initialize Phase client: %v", err)) + } + + keysNotFound, err := p.Delete(phase.DeleteOptions{ + EnvName: args.Env, + AppName: args.App, + AppID: args.AppID, + KeysToDelete: keys, + Path: args.Path, + }) + if err != nil { + return errorResult(fmt.Sprintf("Failed to delete secrets: %v", err)) + } + + deleted := len(keys) - len(keysNotFound) + msg := fmt.Sprintf("Deleted %d secret(s).", deleted) + if len(keysNotFound) > 0 { + msg += fmt.Sprintf(" Keys not found: %s", strings.Join(keysNotFound, ", ")) + } + return textResult(msg), nil, nil +} + +func handleSecretsImport(_ context.Context, _ *gomcp.CallToolRequest, args SecretsImportArgs) (*gomcp.CallToolResult, any, error) { + if args.FilePath == "" { + return errorResult("file_path is required.") + } + + pairs, err := util.ParseEnvFile(args.FilePath) + if err != nil { + return errorResult(fmt.Sprintf("Failed to read env file: %v", err)) + } + + if len(pairs) == 0 { + return textResult("No secrets found in the file."), nil, nil + } + + var kvPairs []phase.KeyValuePair + for _, pair := range pairs { + kvPairs = append(kvPairs, phase.KeyValuePair{ + Key: pair.Key, + Value: pair.Value, + }) + } + + p, err := newPhaseClient() + if err != nil { + return errorResult(fmt.Sprintf("Failed to initialize Phase client: %v", err)) + } + + path := args.Path + if path == "" { + path = "/" + } + + err = p.Create(phase.CreateOptions{ + KeyValuePairs: kvPairs, + EnvName: args.Env, + AppName: args.App, + AppID: args.AppID, + Path: path, + }) + if err != nil { + return errorResult(fmt.Sprintf("Failed to import secrets: %v", err)) + } + + return textResult(fmt.Sprintf("Successfully imported %d secrets from %s (values hidden for security).", len(kvPairs), args.FilePath)), nil, nil +} + +func handleSecretsGet(_ context.Context, _ *gomcp.CallToolRequest, args SecretsGetArgs) (*gomcp.CallToolResult, any, error) { + key := strings.ToUpper(args.Key) + + p, err := newPhaseClient() + if err != nil { + return errorResult(fmt.Sprintf("Failed to initialize Phase client: %v", err)) + } + + secrets, err := p.Get(phase.GetOptions{ + EnvName: args.Env, + AppName: args.App, + AppID: args.AppID, + Keys: []string{key}, + Path: args.Path, + }) + if err != nil { + return errorResult(fmt.Sprintf("Failed to get secret: %v", err)) + } + + for _, s := range secrets { + if s.Key == key { + meta := SecretMetadata{ + Key: s.Key, + Path: s.Path, + Tags: s.Tags, + Comment: s.Comment, + Environment: s.Environment, + Application: s.Application, + Overridden: s.Overridden, + } + data, _ := json.MarshalIndent(meta, "", " ") + return textResult(fmt.Sprintf("Secret metadata (value hidden for security):\n%s", string(data))), nil, nil + } + } + + return textResult(fmt.Sprintf("Secret '%s' not found.", key)), nil, nil +} + +func handleRun(ctx context.Context, _ *gomcp.CallToolRequest, args RunArgs) (*gomcp.CallToolResult, any, error) { + if err := ValidateRunCommand(args.Command); err != nil { + return errorResult(fmt.Sprintf("Command validation failed: %v", err)) + } + + p, err := newPhaseClient() + if err != nil { + return errorResult(fmt.Sprintf("Failed to initialize Phase client: %v", err)) + } + + secrets, err := p.Get(phase.GetOptions{ + EnvName: args.Env, + AppName: args.App, + AppID: args.AppID, + Tag: args.Tags, + Path: args.Path, + }) + if err != nil { + return errorResult(fmt.Sprintf("Failed to fetch secrets: %v", err)) + } + + // Resolve references + resolvedSecrets := map[string]string{} + for _, secret := range secrets { + if secret.Value == "" { + continue + } + resolvedValue := phase.ResolveAllSecrets(secret.Value, secrets, p, secret.Application, secret.Environment) + resolvedSecrets[secret.Key] = resolvedValue + } + + // Build environment + cleanEnv := util.CleanSubprocessEnv() + for k, v := range resolvedSecrets { + cleanEnv[k] = v + } + + var envSlice []string + for k, v := range cleanEnv { + envSlice = append(envSlice, fmt.Sprintf("%s=%s", k, v)) + } + + // Execute with 5 minute timeout + runCtx, cancel := context.WithTimeout(ctx, 5*time.Minute) + defer cancel() + + shell := util.GetDefaultShell() + var cmd *exec.Cmd + if shell != nil && len(shell) > 0 { + cmd = exec.CommandContext(runCtx, shell[0], "-c", args.Command) + } else { + cmd = exec.CommandContext(runCtx, "sh", "-c", args.Command) + } + cmd.Env = envSlice + + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + err = cmd.Run() + + output := stdout.String() + if stderr.Len() > 0 { + if output != "" { + output += "\n" + } + output += "STDERR:\n" + stderr.String() + } + + output = SanitizeOutput(output, 10000) + + if err != nil { + if runCtx.Err() == context.DeadlineExceeded { + return errorResult("Command timed out after 5 minutes.") + } + return textResult(fmt.Sprintf("Command exited with error: %v\n\nOutput:\n%s", err, output)), nil, nil + } + + return textResult(fmt.Sprintf("Command completed successfully.\n\nOutput:\n%s", output)), nil, nil +} + +func handleInit(_ context.Context, _ *gomcp.CallToolRequest, args InitArgs) (*gomcp.CallToolResult, any, error) { + if args.AppID == "" { + return errorResult("app_id is required.") + } + + p, err := newPhaseClient() + if err != nil { + return errorResult(fmt.Sprintf("Failed to initialize Phase client: %v", err)) + } + + data, err := p.Init() + if err != nil { + return errorResult(fmt.Sprintf("Failed to fetch app data: %v", err)) + } + + // Find the app by ID + var selectedApp *struct { + Name string + ID string + Envs []string + } + for _, app := range data.Apps { + if app.ID == args.AppID { + var envs []string + for _, ek := range app.EnvironmentKeys { + envs = append(envs, ek.Environment.Name) + } + selectedApp = &struct { + Name string + ID string + Envs []string + }{Name: app.Name, ID: app.ID, Envs: envs} + break + } + } + + if selectedApp == nil { + return errorResult(fmt.Sprintf("Application with ID '%s' not found.", args.AppID)) + } + + if len(selectedApp.Envs) == 0 { + return errorResult(fmt.Sprintf("No environments found for application '%s'.", selectedApp.Name)) + } + + // Pick first environment as default + defaultEnv := selectedApp.Envs[0] + + // Find the env ID + var envID string + for _, app := range data.Apps { + if app.ID == args.AppID { + for _, ek := range app.EnvironmentKeys { + if ek.Environment.Name == defaultEnv { + envID = ek.Environment.ID + break + } + } + break + } + } + + phaseConfig := &config.PhaseJSONConfig{ + Version: "2", + PhaseApp: selectedApp.Name, + AppID: selectedApp.ID, + DefaultEnv: defaultEnv, + EnvID: envID, + } + + if err := config.WritePhaseConfig(phaseConfig); err != nil { + return errorResult(fmt.Sprintf("Failed to write .phase.json: %v", err)) + } + + os.Chmod(config.PhaseEnvConfig, 0600) + + return textResult(fmt.Sprintf( + "Initialized Phase project:\n Application: %s\n Default Environment: %s\n Available Environments: %s", + selectedApp.Name, defaultEnv, strings.Join(selectedApp.Envs, ", "), + )), nil, nil +} + +// checkAuthAvailable verifies that authentication credentials are available +// without importing the keyring package at the tool handler level. +func checkAuthAvailable() error { + _, err := keyring.GetCredentials() + return err +} From 6e094df79a3f253a9e952539cafa5af7d170ad5b Mon Sep 17 00:00:00 2001 From: Nimish Date: Mon, 9 Feb 2026 14:01:51 +0530 Subject: [PATCH 06/50] chore: move secret generation to the sdk --- src/pkg/util/misc.go | 61 -------------------------------------------- 1 file changed, 61 deletions(-) diff --git a/src/pkg/util/misc.go b/src/pkg/util/misc.go index 1d57daf4..5721bad2 100644 --- a/src/pkg/util/misc.go +++ b/src/pkg/util/misc.go @@ -146,64 +146,3 @@ func GetShellCommand(shellType string) ([]string, error) { } return []string{path}, nil } - -// GenerateRandomSecret generates a random secret of the specified type and length -func GenerateRandomSecret(randomType string, length int) (string, error) { - if length <= 0 { - length = 32 - } - - switch randomType { - case "hex": - bytes := make([]byte, length/2+1) - if _, err := rand.Read(bytes); err != nil { - return "", err - } - return hex.EncodeToString(bytes)[:length], nil - case "alphanumeric": - const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" - result := make([]byte, length) - for i := range result { - n, err := rand.Int(rand.Reader, big.NewInt(int64(len(chars)))) - if err != nil { - return "", err - } - result[i] = chars[n.Int64()] - } - return string(result), nil - case "key128": - bytes := make([]byte, 16) - if _, err := rand.Read(bytes); err != nil { - return "", err - } - return hex.EncodeToString(bytes), nil - case "key256": - bytes := make([]byte, 32) - if _, err := rand.Read(bytes); err != nil { - return "", err - } - return hex.EncodeToString(bytes), nil - case "base64": - bytes := make([]byte, length) - if _, err := rand.Read(bytes); err != nil { - return "", err - } - encoded := base64.StdEncoding.EncodeToString(bytes) - if len(encoded) < length { - return encoded, nil - } - return encoded[:length], nil - case "base64url": - bytes := make([]byte, length) - if _, err := rand.Read(bytes); err != nil { - return "", err - } - encoded := base64.URLEncoding.EncodeToString(bytes) - if len(encoded) < length { - return encoded, nil - } - return encoded[:length], nil - default: - return "", fmt.Errorf("unsupported random type: %s. Supported types: hex, alphanumeric, base64, base64url, key128, key256", randomType) - } -} From 4b7d129d386c3b68bd71157d690f97689d6cf71f Mon Sep 17 00:00:00 2001 From: Nimish Date: Mon, 9 Feb 2026 14:02:11 +0530 Subject: [PATCH 07/50] chore: remove tag matches, moved to the sdk --- src/pkg/util/misc.go | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/src/pkg/util/misc.go b/src/pkg/util/misc.go index 5721bad2..b032d7ea 100644 --- a/src/pkg/util/misc.go +++ b/src/pkg/util/misc.go @@ -104,17 +104,6 @@ func NormalizeTag(tag string) string { return strings.ToLower(strings.ReplaceAll(tag, "_", " ")) } -func TagMatches(secretTags []string, userTag string) bool { - normalizedUserTag := NormalizeTag(userTag) - for _, tag := range secretTags { - normalizedSecretTag := NormalizeTag(tag) - if strings.Contains(normalizedSecretTag, normalizedUserTag) { - return true - } - } - return false -} - func ParseBoolFlag(value string) bool { switch strings.ToLower(strings.TrimSpace(value)) { case "false", "no", "0": From ea912faca3006cdf052efbb249e1dd1acb9b0d95 Mon Sep 17 00:00:00 2001 From: Nimish Date: Mon, 9 Feb 2026 14:03:20 +0530 Subject: [PATCH 08/50] fix: imports, added comments to imports --- src/pkg/util/misc.go | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/pkg/util/misc.go b/src/pkg/util/misc.go index b032d7ea..d7b1e76c 100644 --- a/src/pkg/util/misc.go +++ b/src/pkg/util/misc.go @@ -2,11 +2,7 @@ package util import ( "bufio" - "crypto/rand" - "encoding/base64" - "encoding/hex" "fmt" - "math/big" "os" "os/exec" "runtime" @@ -18,6 +14,7 @@ type EnvKeyValue struct { Value string } +// Parse secrets from a .env file func ParseEnvFile(path string) ([]EnvKeyValue, error) { f, err := os.Open(path) if err != nil { From e36587e9a4a318ca23f3ca89ebb161d064ff856f Mon Sep 17 00:00:00 2001 From: Nimish Date: Mon, 9 Feb 2026 14:08:14 +0530 Subject: [PATCH 09/50] chore: moved to sdk --- src/pkg/phase/secrets.go | 637 --------------------------------------- 1 file changed, 637 deletions(-) delete mode 100644 src/pkg/phase/secrets.go diff --git a/src/pkg/phase/secrets.go b/src/pkg/phase/secrets.go deleted file mode 100644 index b0035662..00000000 --- a/src/pkg/phase/secrets.go +++ /dev/null @@ -1,637 +0,0 @@ -package phase - -import ( - "encoding/json" - "fmt" - "strings" - - localnetwork "github.com/phasehq/cli/pkg/network" - "github.com/phasehq/golang-sdk/phase/crypto" - "github.com/phasehq/golang-sdk/phase/misc" - "github.com/phasehq/golang-sdk/phase/network" -) - -type SecretResult struct { - Key string `json:"key"` - Value string `json:"value"` - Overridden bool `json:"overridden"` - Tags []string `json:"tags"` - Comment string `json:"comment"` - Path string `json:"path"` - Application string `json:"application"` - Environment string `json:"environment"` - IsDynamic bool `json:"is_dynamic,omitempty"` - DynamicGroup string `json:"dynamic_group,omitempty"` -} - -type GetOptions struct { - EnvName string - AppName string - AppID string - Keys []string - Tag string - Path string - Dynamic bool - Lease bool - LeaseTTL *int -} - -type CreateOptions struct { - KeyValuePairs []KeyValuePair - EnvName string - AppName string - AppID string - Path string - OverrideValue string -} - -type KeyValuePair struct { - Key string - Value string -} - -type UpdateOptions struct { - EnvName string - AppName string - AppID string - Key string - Value string - SourcePath string - DestinationPath string - Override bool - ToggleOverride bool -} - -type DeleteOptions struct { - EnvName string - AppName string - AppID string - KeysToDelete []string - Path string -} - -func (p *Phase) Get(opts GetOptions) ([]SecretResult, error) { - resp, err := network.FetchPhaseUser(p.TokenType, p.AppToken, p.APIHost) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - var userData misc.AppKeyResponse - if err := json.NewDecoder(resp.Body).Decode(&userData); err != nil { - return nil, fmt.Errorf("failed to decode user data: %w", err) - } - - appName, _, envName, envID, publicKey, err := PhaseGetContext(&userData, opts.AppName, opts.EnvName, opts.AppID) - if err != nil { - return nil, err - } - - envKey := p.findMatchingEnvironmentKey(&userData, envID) - if envKey == nil { - return nil, fmt.Errorf("no environment found with id: %s", envID) - } - - // Decrypt wrapped seed to get env keypair - wrappedSeed := envKey.WrappedSeed - userDataMap := appKeyResponseToMap(&userData) - decryptedSeed, err := p.Decrypt(wrappedSeed, userDataMap) - if err != nil { - return nil, fmt.Errorf("failed to decrypt wrapped seed: %w", err) - } - - envPubKey, envPrivKey, err := crypto.GenerateEnvKeyPair(decryptedSeed) - if err != nil { - return nil, fmt.Errorf("failed to generate env key pair: %w", err) - } - _ = envPubKey // Use the identity key from the API instead - - // Fetch secrets - var secrets []map[string]interface{} - if opts.Dynamic { - secrets, err = localnetwork.FetchPhaseSecretsWithDynamic(p.TokenType, p.AppToken, envID, p.APIHost, opts.Path, true, opts.Lease, opts.LeaseTTL) - } else { - secrets, err = network.FetchPhaseSecrets(p.TokenType, p.AppToken, envID, p.APIHost, opts.Path) - } - if err != nil { - return nil, fmt.Errorf("failed to fetch secrets: %w", err) - } - - var results []SecretResult - for _, secret := range secrets { - // Handle dynamic secrets - secretType, _ := secret["type"].(string) - if secretType == "dynamic" { - dynamicResults := p.processDynamicSecret(secret, envPrivKey, publicKey, appName, envName, opts) - results = append(results, dynamicResults...) - continue - } - - // Check tag filter - if opts.Tag != "" { - secretTags := extractStringSlice(secret, "tags") - if !misc.TagMatches(secretTags, opts.Tag) { - continue - } - } - - // Determine if override is active - override, hasOverride := secret["override"].(map[string]interface{}) - useOverride := hasOverride && override != nil && getBool(override, "is_active") - - keyToDecrypt, _ := secret["key"].(string) - var valueToDecrypt string - if useOverride { - valueToDecrypt, _ = override["value"].(string) - } else { - valueToDecrypt, _ = secret["value"].(string) - } - commentToDecrypt, _ := secret["comment"].(string) - - decryptedKey, err := crypto.DecryptAsymmetric(keyToDecrypt, envPrivKey, publicKey) - if err != nil { - continue - } - - decryptedValue, err := crypto.DecryptAsymmetric(valueToDecrypt, envPrivKey, publicKey) - if err != nil { - continue - } - - var decryptedComment string - if commentToDecrypt != "" { - decryptedComment, _ = crypto.DecryptAsymmetric(commentToDecrypt, envPrivKey, publicKey) - } - - secretPath, _ := secret["path"].(string) - if secretPath == "" { - secretPath = "/" - } - - secretTags := extractStringSlice(secret, "tags") - - result := SecretResult{ - Key: decryptedKey, - Value: decryptedValue, - Overridden: useOverride, - Tags: secretTags, - Comment: decryptedComment, - Path: secretPath, - Application: appName, - Environment: envName, - } - - // Filter by keys if specified - if len(opts.Keys) > 0 { - found := false - for _, k := range opts.Keys { - if k == decryptedKey { - found = true - break - } - } - if !found { - continue - } - } - - results = append(results, result) - } - - return results, nil -} - -func (p *Phase) processDynamicSecret(secret map[string]interface{}, envPrivKey, publicKey, appName, envName string, opts GetOptions) []SecretResult { - var results []SecretResult - - // Build group label - name, _ := secret["key"].(string) - if name != "" { - decName, err := crypto.DecryptAsymmetric(name, envPrivKey, publicKey) - if err == nil { - name = decName - } - } - provider, _ := secret["provider"].(string) - groupLabel := fmt.Sprintf("%s (%s)", name, provider) - - secretPath, _ := secret["path"].(string) - if secretPath == "" { - secretPath = "/" - } - - // Build credential map from lease if present - credMap := map[string]string{} - if leaseData, ok := secret["lease"].(map[string]interface{}); ok && leaseData != nil { - if creds, ok := leaseData["credentials"].([]interface{}); ok { - for _, c := range creds { - credEntry, ok := c.(map[string]interface{}) - if !ok { - continue - } - encKey, _ := credEntry["key"].(string) - encVal, _ := credEntry["value"].(string) - if encKey == "" { - continue - } - decKey, err := crypto.DecryptAsymmetric(encKey, envPrivKey, publicKey) - if err != nil { - continue - } - decVal := "" - if encVal != "" { - decVal, _ = crypto.DecryptAsymmetric(encVal, envPrivKey, publicKey) - } - credMap[decKey] = decVal - } - } - } - - // Process key_map entries - keyMap, ok := secret["key_map"].([]interface{}) - if !ok { - return results - } - - for _, km := range keyMap { - entry, ok := km.(map[string]interface{}) - if !ok { - continue - } - encKeyName, _ := entry["key_name"].(string) - if encKeyName == "" { - continue - } - decKeyName, err := crypto.DecryptAsymmetric(encKeyName, envPrivKey, publicKey) - if err != nil { - continue - } - - value := "" - if v, exists := credMap[decKeyName]; exists { - value = v - } - - result := SecretResult{ - Key: decKeyName, - Value: value, - Path: secretPath, - Application: appName, - Environment: envName, - IsDynamic: true, - DynamicGroup: groupLabel, - } - - if len(opts.Keys) > 0 { - found := false - for _, k := range opts.Keys { - if k == decKeyName { - found = true - break - } - } - if !found { - continue - } - } - - results = append(results, result) - } - - return results -} - -func (p *Phase) Create(opts CreateOptions) error { - resp, err := network.FetchPhaseUser(p.TokenType, p.AppToken, p.APIHost) - if err != nil { - return err - } - defer resp.Body.Close() - - var userData misc.AppKeyResponse - if err := json.NewDecoder(resp.Body).Decode(&userData); err != nil { - return fmt.Errorf("failed to decode user data: %w", err) - } - - _, _, _, envID, publicKey, err := PhaseGetContext(&userData, opts.AppName, opts.EnvName, opts.AppID) - if err != nil { - return err - } - - envKey := p.findMatchingEnvironmentKey(&userData, envID) - if envKey == nil { - return fmt.Errorf("no environment found with id: %s", envID) - } - - // Decrypt salt for key digest - userDataMap := appKeyResponseToMap(&userData) - decryptedSalt, err := p.Decrypt(envKey.WrappedSalt, userDataMap) - if err != nil { - return fmt.Errorf("failed to decrypt wrapped salt: %w", err) - } - - path := opts.Path - if path == "" { - path = "/" - } - - var secrets []map[string]interface{} - for _, pair := range opts.KeyValuePairs { - encryptedKey, err := crypto.EncryptAsymmetric(pair.Key, publicKey) - if err != nil { - return fmt.Errorf("failed to encrypt key: %w", err) - } - - encryptedValue, err := crypto.EncryptAsymmetric(pair.Value, publicKey) - if err != nil { - return fmt.Errorf("failed to encrypt value: %w", err) - } - - keyDigest, err := crypto.Blake2bDigest(pair.Key, decryptedSalt) - if err != nil { - return fmt.Errorf("failed to generate key digest: %w", err) - } - - secret := map[string]interface{}{ - "key": encryptedKey, - "keyDigest": keyDigest, - "value": encryptedValue, - "path": path, - "tags": []string{}, - "comment": "", - } - - if opts.OverrideValue != "" { - encryptedOverride, err := crypto.EncryptAsymmetric(opts.OverrideValue, publicKey) - if err != nil { - return fmt.Errorf("failed to encrypt override value: %w", err) - } - secret["override"] = map[string]interface{}{ - "value": encryptedOverride, - "isActive": true, - } - } - - secrets = append(secrets, secret) - } - - return network.CreatePhaseSecrets(p.TokenType, p.AppToken, envID, secrets, p.APIHost) -} - -func (p *Phase) Update(opts UpdateOptions) (string, error) { - resp, err := network.FetchPhaseUser(p.TokenType, p.AppToken, p.APIHost) - if err != nil { - return "", err - } - defer resp.Body.Close() - - var userData misc.AppKeyResponse - if err := json.NewDecoder(resp.Body).Decode(&userData); err != nil { - return "", fmt.Errorf("failed to decode user data: %w", err) - } - - _, _, _, envID, publicKey, err := PhaseGetContext(&userData, opts.AppName, opts.EnvName, opts.AppID) - if err != nil { - return "", err - } - - envKey := p.findMatchingEnvironmentKey(&userData, envID) - if envKey == nil { - return "", fmt.Errorf("no environment found with id: %s", envID) - } - - // Fetch secrets from source path - sourcePath := opts.SourcePath - secrets, err := network.FetchPhaseSecrets(p.TokenType, p.AppToken, envID, p.APIHost, sourcePath) - if err != nil { - return "", fmt.Errorf("failed to fetch secrets: %w", err) - } - - // Decrypt seed to get env keypair - userDataMap := appKeyResponseToMap(&userData) - decryptedSeed, err := p.Decrypt(envKey.WrappedSeed, userDataMap) - if err != nil { - return "", fmt.Errorf("failed to decrypt wrapped seed: %w", err) - } - - _, envPrivKey, err := crypto.GenerateEnvKeyPair(decryptedSeed) - if err != nil { - return "", fmt.Errorf("failed to generate env key pair: %w", err) - } - - // Find matching secret - var matchingSecret map[string]interface{} - for _, secret := range secrets { - encKey, _ := secret["key"].(string) - dk, err := crypto.DecryptAsymmetric(encKey, envPrivKey, publicKey) - if err != nil { - continue - } - if dk == opts.Key { - matchingSecret = secret - break - } - } - - if matchingSecret == nil { - return fmt.Sprintf("Key '%s' doesn't exist in path '%s'.", opts.Key, sourcePath), nil - } - - // Encrypt key and value - encryptedKey, err := crypto.EncryptAsymmetric(opts.Key, publicKey) - if err != nil { - return "", fmt.Errorf("failed to encrypt key: %w", err) - } - - encryptedValue, err := crypto.EncryptAsymmetric(coalesce(opts.Value, ""), publicKey) - if err != nil { - return "", fmt.Errorf("failed to encrypt value: %w", err) - } - - // Get key digest - decryptedSalt, err := p.Decrypt(envKey.WrappedSalt, userDataMap) - if err != nil { - return "", fmt.Errorf("failed to decrypt wrapped salt: %w", err) - } - - keyDigest, err := crypto.Blake2bDigest(opts.Key, decryptedSalt) - if err != nil { - return "", fmt.Errorf("failed to generate key digest: %w", err) - } - - // Determine payload value - payloadValue := encryptedValue - if opts.Override || opts.ToggleOverride { - payloadValue, _ = matchingSecret["value"].(string) - } - - // Determine path - path := matchingSecret["path"] - if opts.DestinationPath != "" { - path = opts.DestinationPath - } - - secretID, _ := matchingSecret["id"].(string) - payload := map[string]interface{}{ - "id": secretID, - "key": encryptedKey, - "keyDigest": keyDigest, - "value": payloadValue, - "tags": matchingSecret["tags"], - "comment": matchingSecret["comment"], - "path": path, - } - - // Handle override logic - if opts.ToggleOverride { - override, hasOverride := matchingSecret["override"].(map[string]interface{}) - if !hasOverride || override == nil { - return "", fmt.Errorf("no override found for key '%s'. Create one first with --override", opts.Key) - } - currentState := getBool(override, "is_active") - payload["override"] = map[string]interface{}{ - "value": override["value"], - "isActive": !currentState, - } - } else if opts.Override { - override, hasOverride := matchingSecret["override"].(map[string]interface{}) - if !hasOverride || override == nil { - payload["override"] = map[string]interface{}{ - "value": encryptedValue, - "isActive": true, - } - } else { - val := encryptedValue - if opts.Value == "" { - val, _ = override["value"].(string) - } - payload["override"] = map[string]interface{}{ - "value": val, - "isActive": getBool(override, "is_active"), - } - } - } - - err = network.UpdatePhaseSecrets(p.TokenType, p.AppToken, envID, []map[string]interface{}{payload}, p.APIHost) - if err != nil { - return "", fmt.Errorf("failed to update secret: %w", err) - } - - return "Success", nil -} - -func (p *Phase) Delete(opts DeleteOptions) ([]string, error) { - resp, err := network.FetchPhaseUser(p.TokenType, p.AppToken, p.APIHost) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - var userData misc.AppKeyResponse - if err := json.NewDecoder(resp.Body).Decode(&userData); err != nil { - return nil, fmt.Errorf("failed to decode user data: %w", err) - } - - _, _, _, envID, publicKey, err := PhaseGetContext(&userData, opts.AppName, opts.EnvName, opts.AppID) - if err != nil { - return nil, err - } - - envKey := p.findMatchingEnvironmentKey(&userData, envID) - if envKey == nil { - return nil, fmt.Errorf("no environment found with id: %s", envID) - } - - // Decrypt seed to get env keypair - userDataMap := appKeyResponseToMap(&userData) - decryptedSeed, err := p.Decrypt(envKey.WrappedSeed, userDataMap) - if err != nil { - return nil, fmt.Errorf("failed to decrypt wrapped seed: %w", err) - } - - _, envPrivKey, err := crypto.GenerateEnvKeyPair(decryptedSeed) - if err != nil { - return nil, fmt.Errorf("failed to generate env key pair: %w", err) - } - - // Fetch secrets - secrets, err := network.FetchPhaseSecrets(p.TokenType, p.AppToken, envID, p.APIHost, opts.Path) - if err != nil { - return nil, fmt.Errorf("failed to fetch secrets: %w", err) - } - - var idsToDelete []string - var keysNotFound []string - - for _, key := range opts.KeysToDelete { - found := false - for _, secret := range secrets { - if opts.Path != "" { - secretPath, _ := secret["path"].(string) - if secretPath != opts.Path { - continue - } - } - encKey, _ := secret["key"].(string) - dk, err := crypto.DecryptAsymmetric(encKey, envPrivKey, publicKey) - if err != nil { - continue - } - if dk == key { - secretID, _ := secret["id"].(string) - idsToDelete = append(idsToDelete, secretID) - found = true - break - } - } - if !found { - keysNotFound = append(keysNotFound, key) - } - } - - if len(idsToDelete) > 0 { - if err := network.DeletePhaseSecrets(p.TokenType, p.AppToken, envID, idsToDelete, p.APIHost); err != nil { - return nil, fmt.Errorf("failed to delete secrets: %w", err) - } - } - - return keysNotFound, nil -} - -// Helper functions - -func appKeyResponseToMap(resp *misc.AppKeyResponse) map[string]interface{} { - data, _ := json.Marshal(resp) - var result map[string]interface{} - json.Unmarshal(data, &result) - return result -} - -func extractStringSlice(m map[string]interface{}, key string) []string { - raw, ok := m[key].([]interface{}) - if !ok { - return nil - } - var result []string - for _, v := range raw { - if s, ok := v.(string); ok { - result = append(result, s) - } - } - return result -} - -func getBool(m map[string]interface{}, key string) bool { - v, ok := m[key] - if !ok { - return false - } - switch b := v.(type) { - case bool: - return b - case string: - return strings.ToLower(b) == "true" - default: - return false - } -} From a3b68b1e245bf708b1976db1f4629edd59ef2249 Mon Sep 17 00:00:00 2001 From: Nimish Date: Mon, 9 Feb 2026 14:11:47 +0530 Subject: [PATCH 10/50] chore: moved secret referencing logitc to sdk --- src/pkg/phase/secret_referencing.go | 255 ---------------------------- 1 file changed, 255 deletions(-) delete mode 100644 src/pkg/phase/secret_referencing.go diff --git a/src/pkg/phase/secret_referencing.go b/src/pkg/phase/secret_referencing.go deleted file mode 100644 index 1dfb526a..00000000 --- a/src/pkg/phase/secret_referencing.go +++ /dev/null @@ -1,255 +0,0 @@ -package phase - -import ( - "fmt" - "regexp" - "strings" -) - -var secretRefRegex = regexp.MustCompile(`\$\{([^}]+)\}`) - -// secretsCache keyed by "app|env|path" -> key -> value -var secretsCache = map[string]map[string]string{} - -func cacheKey(app, env, path string) string { - path = normalizePath(path) - return fmt.Sprintf("%s|%s|%s", app, env, path) -} - -func normalizePath(path string) string { - if path == "" { - return "/" - } - if !strings.HasPrefix(path, "/") { - return "/" + path - } - return path -} - -func primeCacheFromList(secrets []SecretResult, fallbackAppName string) { - for _, s := range secrets { - app := s.Application - if app == "" { - app = fallbackAppName - } - if app == "" || s.Environment == "" || s.Key == "" { - continue - } - ck := cacheKey(app, s.Environment, s.Path) - if _, ok := secretsCache[ck]; !ok { - secretsCache[ck] = map[string]string{} - } - secretsCache[ck][s.Key] = s.Value - } -} - -func ensureCached(p *Phase, appName, envName, path string) { - ck := cacheKey(appName, envName, path) - if _, ok := secretsCache[ck]; ok { - return - } - fetched, err := p.Get(GetOptions{ - EnvName: envName, - AppName: appName, - Path: normalizePath(path), - }) - if err != nil { - return - } - bucket := map[string]string{} - for _, s := range fetched { - bucket[s.Key] = s.Value - } - secretsCache[ck] = bucket -} - -func getFromCache(appName, envName, path, keyName string) (string, bool) { - ck := cacheKey(appName, envName, path) - bucket, ok := secretsCache[ck] - if !ok { - return "", false - } - val, ok := bucket[keyName] - return val, ok -} - -func splitPathAndKey(ref string) (string, string) { - lastSlash := strings.LastIndex(ref, "/") - if lastSlash != -1 { - path := ref[:lastSlash] - key := ref[lastSlash+1:] - if !strings.HasPrefix(path, "/") { - path = "/" + path - } - return path, key - } - return "/", ref -} - -func parseReferenceContext(ref, currentApp, currentEnv string) (appName, envName, path, keyName string, err error) { - appName = currentApp - envName = currentEnv - refBody := ref - - isCrossApp := false - if strings.Contains(refBody, "::") { - isCrossApp = true - parts := strings.SplitN(refBody, "::", 2) - appName = parts[0] - refBody = parts[1] - } - - if strings.Contains(refBody, ".") { - parts := strings.SplitN(refBody, ".", 2) - envName = parts[0] - refBody = parts[1] - if isCrossApp && envName == "" { - return "", "", "", "", fmt.Errorf("invalid reference '%s': cross-app references must specify an environment", ref) - } - } else if isCrossApp { - return "", "", "", "", fmt.Errorf("invalid reference '%s': cross-app references must specify an environment", ref) - } - - path, keyName = splitPathAndKey(refBody) - return -} - -// ResolveAllSecrets resolves all ${...} references in a value string. -func ResolveAllSecrets(value string, allSecrets []SecretResult, p *Phase, currentApp, currentEnv string) string { - return resolveAllSecretsInternal(value, allSecrets, p, currentApp, currentEnv, nil) -} - -func resolveAllSecretsInternal(value string, allSecrets []SecretResult, p *Phase, currentApp, currentEnv string, visited map[string]bool) string { - if visited == nil { - visited = map[string]bool{} - } - - // Build in-memory lookup: env -> path -> key -> value - secretsDict := map[string]map[string]map[string]string{} - primeCacheFromList(allSecrets, currentApp) - for _, s := range allSecrets { - if _, ok := secretsDict[s.Environment]; !ok { - secretsDict[s.Environment] = map[string]map[string]string{} - } - if _, ok := secretsDict[s.Environment][s.Path]; !ok { - secretsDict[s.Environment][s.Path] = map[string]string{} - } - secretsDict[s.Environment][s.Path][s.Key] = s.Value - } - - refs := secretRefRegex.FindAllStringSubmatch(value, -1) - if len(refs) == 0 { - return value - } - - // Prefetch caches - seen := map[string]bool{} - for _, match := range refs { - ref := match[1] - app, env, path, _, err := parseReferenceContext(ref, currentApp, currentEnv) - if err != nil { - continue - } - combo := fmt.Sprintf("%s|%s|%s", app, env, path) - if !seen[combo] { - seen[combo] = true - ensureCached(p, app, env, path) - } - } - - resolved := value - for _, match := range refs { - ref := match[1] - fullRef := match[0] - - app, env, path, keyName, err := parseReferenceContext(ref, currentApp, currentEnv) - if err != nil { - continue - } - - canonical := fmt.Sprintf("%s|%s|%s|%s", app, env, path, keyName) - if visited[canonical] { - continue - } - visited[canonical] = true - - // Try in-memory dict first (same app only) - resolvedVal := "" - found := false - if app == currentApp { - resolvedVal, found = lookupInMemory(secretsDict, env, path, keyName, currentEnv) - } - - // Try cache - if !found { - resolvedVal, found = getFromCache(app, env, path, keyName) - } - - if !found { - // Leave placeholder unresolved - continue - } - - // Recursively resolve if the resolved value itself contains references - if secretRefRegex.MatchString(resolvedVal) { - resolvedVal = resolveAllSecretsInternal(resolvedVal, allSecrets, p, app, env, visited) - } - - resolved = strings.ReplaceAll(resolved, fullRef, resolvedVal) - } - - return resolved -} - -func lookupInMemory(secretsDict map[string]map[string]map[string]string, envName, path, keyName, currentEnv string) (string, bool) { - envKey := findEnvKeyCaseInsensitive(secretsDict, envName) - if envKey == "" { - return "", false - } - if pathBucket, ok := secretsDict[envKey][path]; ok { - if val, ok := pathBucket[keyName]; ok { - return val, true - } - } - // Fallback: try root path for current env - if path == "/" && strings.EqualFold(envName, currentEnv) { - if pathBucket, ok := secretsDict[envKey]["/"]; ok { - if val, ok := pathBucket[keyName]; ok { - return val, true - } - } - } - return "", false -} - -func findEnvKeyCaseInsensitive(secretsDict map[string]map[string]map[string]string, envName string) string { - // Exact match - if _, ok := secretsDict[envName]; ok { - return envName - } - // Case-insensitive exact match - for k := range secretsDict { - if strings.EqualFold(k, envName) { - return k - } - } - // Partial match - envLower := strings.ToLower(envName) - var partials []string - for k := range secretsDict { - kLower := strings.ToLower(k) - if strings.Contains(kLower, envLower) || strings.Contains(envLower, kLower) { - partials = append(partials, k) - } - } - if len(partials) > 0 { - shortest := partials[0] - for _, p := range partials[1:] { - if len(p) < len(shortest) { - shortest = p - } - } - return shortest - } - return "" -} From 3620f91e6f5dfaba96db5fdac1dc99ed69134bfa Mon Sep 17 00:00:00 2001 From: Nimish Date: Mon, 9 Feb 2026 14:13:17 +0530 Subject: [PATCH 11/50] chore: moved netcode to sdk --- src/pkg/network/network.go | 254 ------------------------------------- 1 file changed, 254 deletions(-) delete mode 100644 src/pkg/network/network.go diff --git a/src/pkg/network/network.go b/src/pkg/network/network.go deleted file mode 100644 index 0638b177..00000000 --- a/src/pkg/network/network.go +++ /dev/null @@ -1,254 +0,0 @@ -package network - -import ( - "bytes" - "crypto/tls" - "encoding/json" - "fmt" - "io" - "net/http" - "net/url" - - sdkmisc "github.com/phasehq/golang-sdk/phase/misc" - sdknetwork "github.com/phasehq/golang-sdk/phase/network" -) - -func createHTTPClient() *http.Client { - client := &http.Client{} - if !sdkmisc.VerifySSL { - client.Transport = &http.Transport{ - TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, - } - } - return client -} - -func doRequest(req *http.Request) ([]byte, error) { - client := createHTTPClient() - resp, err := client.Do(req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("failed to read response body: %w", err) - } - - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("request failed with status %d: %s", resp.StatusCode, string(body)) - } - - return body, nil -} - -// FetchPhaseSecretsWithDynamic is like the SDK's FetchPhaseSecrets but adds dynamic/lease headers. -func FetchPhaseSecretsWithDynamic(tokenType, appToken, envID, host, path string, dynamic, lease bool, leaseTTL *int) ([]map[string]interface{}, error) { - reqURL := fmt.Sprintf("%s/service/secrets/", host) - req, err := http.NewRequest("GET", reqURL, nil) - if err != nil { - return nil, err - } - - req.Header = sdknetwork.ConstructHTTPHeaders(tokenType, appToken) - req.Header.Set("Environment", envID) - if path != "" { - req.Header.Set("Path", path) - } - if dynamic { - req.Header.Set("dynamic", "true") - } - if lease { - req.Header.Set("lease", "true") - } - if leaseTTL != nil { - req.Header.Set("lease-ttl", fmt.Sprintf("%d", *leaseTTL)) - } - - body, err := doRequest(req) - if err != nil { - return nil, err - } - - var secrets []map[string]interface{} - if err := json.Unmarshal(body, &secrets); err != nil { - return nil, fmt.Errorf("failed to decode secrets response: %w", err) - } - return secrets, nil -} - -// ListDynamicSecrets lists dynamic secrets for an app/env. -func ListDynamicSecrets(tokenType, appToken, host, appID, env, path string) (json.RawMessage, error) { - reqURL := fmt.Sprintf("%s/service/public/v1/secrets/dynamic/", host) - - params := url.Values{} - params.Set("app_id", appID) - params.Set("env", env) - if path != "" { - params.Set("path", path) - } - reqURL += "?" + params.Encode() - - req, err := http.NewRequest("GET", reqURL, nil) - if err != nil { - return nil, err - } - req.Header = sdknetwork.ConstructHTTPHeaders(tokenType, appToken) - - body, err := doRequest(req) - if err != nil { - return nil, err - } - return json.RawMessage(body), nil -} - -// CreateDynamicSecretLease generates a lease for a dynamic secret. -func CreateDynamicSecretLease(tokenType, appToken, host, appID, env, secretID string, ttl *int) (json.RawMessage, error) { - reqURL := fmt.Sprintf("%s/service/public/v1/secrets/dynamic/", host) - - params := url.Values{} - params.Set("app_id", appID) - params.Set("env", env) - params.Set("id", secretID) - params.Set("lease", "true") - if ttl != nil { - params.Set("ttl", fmt.Sprintf("%d", *ttl)) - } - reqURL += "?" + params.Encode() - - req, err := http.NewRequest("GET", reqURL, nil) - if err != nil { - return nil, err - } - req.Header = sdknetwork.ConstructHTTPHeaders(tokenType, appToken) - - body, err := doRequest(req) - if err != nil { - return nil, err - } - return json.RawMessage(body), nil -} - -// ListDynamicSecretLeases lists leases for dynamic secrets. -func ListDynamicSecretLeases(tokenType, appToken, host, appID, env, secretID string) (json.RawMessage, error) { - reqURL := fmt.Sprintf("%s/service/public/v1/secrets/dynamic/leases/", host) - - params := url.Values{} - params.Set("app_id", appID) - params.Set("env", env) - if secretID != "" { - params.Set("secret_id", secretID) - } - reqURL += "?" + params.Encode() - - req, err := http.NewRequest("GET", reqURL, nil) - if err != nil { - return nil, err - } - req.Header = sdknetwork.ConstructHTTPHeaders(tokenType, appToken) - - body, err := doRequest(req) - if err != nil { - return nil, err - } - return json.RawMessage(body), nil -} - -// RenewDynamicSecretLease renews a lease for a dynamic secret. -func RenewDynamicSecretLease(tokenType, appToken, host, appID, env, leaseID string, ttl int) (json.RawMessage, error) { - reqURL := fmt.Sprintf("%s/service/public/v1/secrets/dynamic/leases/", host) - - params := url.Values{} - params.Set("app_id", appID) - params.Set("env", env) - reqURL += "?" + params.Encode() - - payload, _ := json.Marshal(map[string]interface{}{ - "lease_id": leaseID, - "ttl": ttl, - }) - - req, err := http.NewRequest("PUT", reqURL, bytes.NewBuffer(payload)) - if err != nil { - return nil, err - } - req.Header = sdknetwork.ConstructHTTPHeaders(tokenType, appToken) - req.Header.Set("Content-Type", "application/json") - - body, err := doRequest(req) - if err != nil { - return nil, err - } - return json.RawMessage(body), nil -} - -// RevokeDynamicSecretLease revokes a lease for a dynamic secret. -func RevokeDynamicSecretLease(tokenType, appToken, host, appID, env, leaseID string) (json.RawMessage, error) { - reqURL := fmt.Sprintf("%s/service/public/v1/secrets/dynamic/leases/", host) - - params := url.Values{} - params.Set("app_id", appID) - params.Set("env", env) - reqURL += "?" + params.Encode() - - payload, _ := json.Marshal(map[string]interface{}{ - "lease_id": leaseID, - }) - - req, err := http.NewRequest("DELETE", reqURL, bytes.NewBuffer(payload)) - if err != nil { - return nil, err - } - req.Header = sdknetwork.ConstructHTTPHeaders(tokenType, appToken) - req.Header.Set("Content-Type", "application/json") - - body, err := doRequest(req) - if err != nil { - return nil, err - } - return json.RawMessage(body), nil -} - -// ExternalIdentityAuthAWS performs AWS IAM authentication against the Phase API. -// encodedURL, encodedHeaders, and encodedBody are already base64-encoded. -func ExternalIdentityAuthAWS(host, serviceAccountID string, ttl *int, encodedURL, encodedHeaders, encodedBody, method string) (map[string]interface{}, error) { - reqURL := fmt.Sprintf("%s/service/public/identities/external/v1/aws/iam/auth/", host) - - payload := map[string]interface{}{ - "account": map[string]interface{}{ - "type": "service", - "id": serviceAccountID, - }, - "awsIam": map[string]interface{}{ - "httpRequestMethod": method, - "httpRequestUrl": encodedURL, - "httpRequestHeaders": encodedHeaders, - "httpRequestBody": encodedBody, - }, - } - if ttl != nil { - payload["tokenRequest"] = map[string]interface{}{ - "ttl": *ttl, - } - } - - payloadBytes, _ := json.Marshal(payload) - - req, err := http.NewRequest("POST", reqURL, bytes.NewBuffer(payloadBytes)) - if err != nil { - return nil, err - } - req.Header.Set("Content-Type", "application/json") - - respBody, err := doRequest(req) - if err != nil { - return nil, err - } - - var result map[string]interface{} - if err := json.Unmarshal(respBody, &result); err != nil { - return nil, fmt.Errorf("failed to decode auth response: %w", err) - } - return result, nil -} From adf5553325e5c672b25c5ef6a0de80ceab24dbca Mon Sep 17 00:00:00 2001 From: Nimish Date: Mon, 9 Feb 2026 14:15:36 +0530 Subject: [PATCH 12/50] chore: updated imports, fixed references --- src/cmd/dynamic_secrets_lease_generate.go | 2 +- src/cmd/dynamic_secrets_lease_get.go | 2 +- src/cmd/dynamic_secrets_lease_renew.go | 2 +- src/cmd/dynamic_secrets_lease_revoke.go | 2 +- src/cmd/dynamic_secrets_list.go | 2 +- src/cmd/secrets_create.go | 4 ++-- src/cmd/secrets_import.go | 16 ++++++---------- src/cmd/secrets_update.go | 3 ++- 8 files changed, 15 insertions(+), 18 deletions(-) diff --git a/src/cmd/dynamic_secrets_lease_generate.go b/src/cmd/dynamic_secrets_lease_generate.go index 888c8893..43a08faa 100644 --- a/src/cmd/dynamic_secrets_lease_generate.go +++ b/src/cmd/dynamic_secrets_lease_generate.go @@ -4,7 +4,7 @@ import ( "encoding/json" "fmt" - "github.com/phasehq/cli/pkg/network" + "github.com/phasehq/golang-sdk/phase/network" "github.com/phasehq/cli/pkg/phase" "github.com/spf13/cobra" ) diff --git a/src/cmd/dynamic_secrets_lease_get.go b/src/cmd/dynamic_secrets_lease_get.go index 37002a8c..3c53f5cf 100644 --- a/src/cmd/dynamic_secrets_lease_get.go +++ b/src/cmd/dynamic_secrets_lease_get.go @@ -4,7 +4,7 @@ import ( "encoding/json" "fmt" - "github.com/phasehq/cli/pkg/network" + "github.com/phasehq/golang-sdk/phase/network" "github.com/phasehq/cli/pkg/phase" "github.com/spf13/cobra" ) diff --git a/src/cmd/dynamic_secrets_lease_renew.go b/src/cmd/dynamic_secrets_lease_renew.go index 8e6f9a3e..a4843050 100644 --- a/src/cmd/dynamic_secrets_lease_renew.go +++ b/src/cmd/dynamic_secrets_lease_renew.go @@ -5,7 +5,7 @@ import ( "fmt" "strconv" - "github.com/phasehq/cli/pkg/network" + "github.com/phasehq/golang-sdk/phase/network" "github.com/phasehq/cli/pkg/phase" "github.com/spf13/cobra" ) diff --git a/src/cmd/dynamic_secrets_lease_revoke.go b/src/cmd/dynamic_secrets_lease_revoke.go index 44bef7ed..4a2115b0 100644 --- a/src/cmd/dynamic_secrets_lease_revoke.go +++ b/src/cmd/dynamic_secrets_lease_revoke.go @@ -4,7 +4,7 @@ import ( "encoding/json" "fmt" - "github.com/phasehq/cli/pkg/network" + "github.com/phasehq/golang-sdk/phase/network" "github.com/phasehq/cli/pkg/phase" "github.com/spf13/cobra" ) diff --git a/src/cmd/dynamic_secrets_list.go b/src/cmd/dynamic_secrets_list.go index bb41623f..ddf2d99c 100644 --- a/src/cmd/dynamic_secrets_list.go +++ b/src/cmd/dynamic_secrets_list.go @@ -4,7 +4,7 @@ import ( "encoding/json" "fmt" - "github.com/phasehq/cli/pkg/network" + "github.com/phasehq/golang-sdk/phase/network" "github.com/phasehq/cli/pkg/phase" "github.com/spf13/cobra" ) diff --git a/src/cmd/secrets_create.go b/src/cmd/secrets_create.go index 5ad48d8d..d1ccfede 100644 --- a/src/cmd/secrets_create.go +++ b/src/cmd/secrets_create.go @@ -7,7 +7,7 @@ import ( "syscall" "github.com/phasehq/cli/pkg/phase" - "github.com/phasehq/cli/pkg/util" + "github.com/phasehq/golang-sdk/phase/misc" "github.com/spf13/cobra" "golang.org/x/term" ) @@ -58,7 +58,7 @@ func runSecretsCreate(cmd *cobra.Command, args []string) error { fmt.Fprintf(os.Stderr, "⚠️\u200A Warning: The length argument is ignored for '%s'. Using default lengths.\n", randomType) } var err error - value, err = util.GenerateRandomSecret(randomType, randomLength) + value, err = misc.GenerateRandomSecret(randomType, randomLength) if err != nil { return fmt.Errorf("failed to generate random secret: %w", err) } diff --git a/src/cmd/secrets_import.go b/src/cmd/secrets_import.go index 4a4b429f..b742d5ed 100644 --- a/src/cmd/secrets_import.go +++ b/src/cmd/secrets_import.go @@ -36,20 +36,16 @@ func runSecretsImport(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to read file %s: %w", envFile, err) } - // Convert to key-value pairs - var kvPairs []phase.KeyValuePair - for _, pair := range pairs { - kvPairs = append(kvPairs, phase.KeyValuePair{ - Key: pair.Key, - Value: pair.Value, - }) - } - p, err := phase.NewPhase(true, "", "") if err != nil { return err } + kvPairs := make([]phase.KeyValuePair, len(pairs)) + for i, kv := range pairs { + kvPairs[i] = phase.KeyValuePair{Key: kv.Key, Value: kv.Value} + } + err = p.Create(phase.CreateOptions{ KeyValuePairs: kvPairs, EnvName: envName, @@ -61,7 +57,7 @@ func runSecretsImport(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to import secrets: %w", err) } - fmt.Println(util.BoldGreen(fmt.Sprintf("✅ Successfully imported and encrypted %d secrets.", len(kvPairs)))) + fmt.Println(util.BoldGreen(fmt.Sprintf("✅ Successfully imported and encrypted %d secrets.", len(pairs)))) if envName == "" { fmt.Println("To view them please run: phase secrets list") } else { diff --git a/src/cmd/secrets_update.go b/src/cmd/secrets_update.go index 8cfd61a8..dc844df7 100644 --- a/src/cmd/secrets_update.go +++ b/src/cmd/secrets_update.go @@ -8,6 +8,7 @@ import ( "github.com/phasehq/cli/pkg/phase" "github.com/phasehq/cli/pkg/util" + "github.com/phasehq/golang-sdk/phase/misc" "github.com/spf13/cobra" "golang.org/x/term" ) @@ -62,7 +63,7 @@ func runSecretsUpdate(cmd *cobra.Command, args []string) error { fmt.Fprintf(os.Stderr, "⚠️\u200A Warning: The length argument is ignored for '%s'. Using default lengths.\n", randomType) } var err error - newValue, err = util.GenerateRandomSecret(randomType, randomLength) + newValue, err = misc.GenerateRandomSecret(randomType, randomLength) if err != nil { return fmt.Errorf("failed to generate random secret: %w", err) } From 506d794dd0f2c36868ecd6f90271f4ef97ae2afd Mon Sep 17 00:00:00 2001 From: Nimish Date: Mon, 9 Feb 2026 14:16:20 +0530 Subject: [PATCH 13/50] fix: network imports --- src/cmd/auth_aws.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/cmd/auth_aws.go b/src/cmd/auth_aws.go index 7044209a..1f905c6c 100644 --- a/src/cmd/auth_aws.go +++ b/src/cmd/auth_aws.go @@ -15,9 +15,9 @@ import ( awsconfig "github.com/aws/aws-sdk-go-v2/config" "github.com/phasehq/cli/pkg/config" "github.com/phasehq/cli/pkg/keyring" - "github.com/phasehq/cli/pkg/network" "github.com/phasehq/cli/pkg/phase" "github.com/phasehq/cli/pkg/util" + "github.com/phasehq/golang-sdk/phase/network" "github.com/spf13/cobra" ) From 1533cfdfb23ef9ad22f2529fe100c0bb0a163ac4 Mon Sep 17 00:00:00 2001 From: Nimish Date: Mon, 9 Feb 2026 15:45:59 +0530 Subject: [PATCH 14/50] chore: removed tag noramalize utisl --- src/pkg/util/misc.go | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/pkg/util/misc.go b/src/pkg/util/misc.go index d7b1e76c..1fded754 100644 --- a/src/pkg/util/misc.go +++ b/src/pkg/util/misc.go @@ -97,10 +97,6 @@ func CleanSubprocessEnv() map[string]string { return env } -func NormalizeTag(tag string) string { - return strings.ToLower(strings.ReplaceAll(tag, "_", " ")) -} - func ParseBoolFlag(value string) bool { switch strings.ToLower(strings.TrimSpace(value)) { case "false", "no", "0": From 73f69205334aef6c3cd6ceb29361ad2dd8e71d09 Mon Sep 17 00:00:00 2001 From: Nimish Date: Tue, 10 Feb 2026 12:33:36 +0530 Subject: [PATCH 15/50] chore: refactor ParseEnvFile to use sdk.KeyValuePair --- src/pkg/util/misc.go | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/src/pkg/util/misc.go b/src/pkg/util/misc.go index 1fded754..3607b918 100644 --- a/src/pkg/util/misc.go +++ b/src/pkg/util/misc.go @@ -7,22 +7,19 @@ import ( "os/exec" "runtime" "strings" -) -type EnvKeyValue struct { - Key string - Value string -} + sdk "github.com/phasehq/golang-sdk/phase" +) -// Parse secrets from a .env file -func ParseEnvFile(path string) ([]EnvKeyValue, error) { +// ParseEnvFile parses a .env file +func ParseEnvFile(path string) ([]sdk.KeyValuePair, error) { f, err := os.Open(path) if err != nil { return nil, err } defer f.Close() - var pairs []EnvKeyValue + var pairs []sdk.KeyValuePair scanner := bufio.NewScanner(f) for scanner.Scan() { line := strings.TrimSpace(scanner.Text()) @@ -33,7 +30,7 @@ func ParseEnvFile(path string) ([]EnvKeyValue, error) { key := strings.TrimSpace(line[:idx]) value := strings.TrimSpace(line[idx+1:]) value = sanitizeValue(value) - pairs = append(pairs, EnvKeyValue{ + pairs = append(pairs, sdk.KeyValuePair{ Key: strings.ToUpper(key), Value: value, }) From 162675faf33b723ba66b87ed690216837a5dbd53 Mon Sep 17 00:00:00 2001 From: Nimish Date: Tue, 10 Feb 2026 12:35:45 +0530 Subject: [PATCH 16/50] feat: add version package with CLI version 2.0.0 --- src/pkg/version/version.go | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 src/pkg/version/version.go diff --git a/src/pkg/version/version.go b/src/pkg/version/version.go new file mode 100644 index 00000000..88f89638 --- /dev/null +++ b/src/pkg/version/version.go @@ -0,0 +1,4 @@ +package version + +// Version is the CLI version. Override via -ldflags at build time. +var Version = "2.0.0" From 1e96c5183bf98e953dbc737d546dbe3fcd8a964a Mon Sep 17 00:00:00 2001 From: Nimish Date: Tue, 10 Feb 2026 12:43:53 +0530 Subject: [PATCH 17/50] chore: remove CleanSubprocessEnv function from misc.go --- src/pkg/util/misc.go | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/src/pkg/util/misc.go b/src/pkg/util/misc.go index 3607b918..f9fbee6b 100644 --- a/src/pkg/util/misc.go +++ b/src/pkg/util/misc.go @@ -76,23 +76,6 @@ func GetDefaultShell() []string { return nil } -func CleanSubprocessEnv() map[string]string { - env := map[string]string{} - for _, e := range os.Environ() { - idx := strings.Index(e, "=") - if idx < 0 { - continue - } - key := e[:idx] - value := e[idx+1:] - // Remove PyInstaller library path variables - if key == "LD_LIBRARY_PATH" || key == "DYLD_LIBRARY_PATH" { - continue - } - env[key] = value - } - return env -} func ParseBoolFlag(value string) bool { switch strings.ToLower(strings.TrimSpace(value)) { From 0890788929da949f86c6388f3098058a133f388f Mon Sep 17 00:00:00 2001 From: Nimish Date: Tue, 10 Feb 2026 12:45:14 +0530 Subject: [PATCH 18/50] refactor: simplify GetShellCommand by removing shellMap and directly using shellType --- src/pkg/util/misc.go | 20 +++----------------- 1 file changed, 3 insertions(+), 17 deletions(-) diff --git a/src/pkg/util/misc.go b/src/pkg/util/misc.go index f9fbee6b..9e4fee41 100644 --- a/src/pkg/util/misc.go +++ b/src/pkg/util/misc.go @@ -87,24 +87,10 @@ func ParseBoolFlag(value string) bool { } func GetShellCommand(shellType string) ([]string, error) { - shellMap := map[string]string{ - "bash": "bash", - "zsh": "zsh", - "fish": "fish", - "sh": "sh", - "powershell": "powershell", - "pwsh": "pwsh", - "cmd": "cmd", - } - - bin, ok := shellMap[strings.ToLower(shellType)] - if !ok { - return nil, fmt.Errorf("unsupported shell type: %s", shellType) - } - - path, err := exec.LookPath(bin) + shell := strings.ToLower(shellType) + path, err := exec.LookPath(shell) if err != nil { - return nil, fmt.Errorf("shell '%s' not found in PATH: %w", bin, err) + return nil, fmt.Errorf("shell '%s' not found in PATH: %w", shell, err) } return []string{path}, nil } From 9de4aa3ad6040b8c0f9d43a101be5841aaf786c9 Mon Sep 17 00:00:00 2001 From: Nimish Date: Tue, 10 Feb 2026 13:32:22 +0530 Subject: [PATCH 19/50] refactor: update Phase struct to use sdk.Phase and simplify NewPhase, Auth, and Init functions --- src/pkg/phase/phase.go | 215 +++++++---------------------------------- 1 file changed, 33 insertions(+), 182 deletions(-) diff --git a/src/pkg/phase/phase.go b/src/pkg/phase/phase.go index b24528ee..215cb839 100644 --- a/src/pkg/phase/phase.go +++ b/src/pkg/phase/phase.go @@ -1,7 +1,6 @@ package phase import ( - "encoding/hex" "encoding/json" "fmt" "os" @@ -10,29 +9,14 @@ import ( "github.com/phasehq/cli/pkg/config" "github.com/phasehq/cli/pkg/keyring" - "github.com/phasehq/golang-sdk/phase/crypto" + "github.com/phasehq/cli/pkg/version" + sdk "github.com/phasehq/golang-sdk/phase" "github.com/phasehq/golang-sdk/phase/misc" "github.com/phasehq/golang-sdk/phase/network" ) -func hexDecode(s string) ([]byte, error) { - return hex.DecodeString(s) -} - -type Phase struct { - Prefix string - PesVersion string - AppToken string - PssUserPublicKey string - Keyshare0 string - Keyshare1UnwrapKey string - APIHost string - TokenType string - IsServiceToken bool - IsUserToken bool -} - -func NewPhase(init bool, pss string, host string) (*Phase, error) { +// Create new Phase client. Return host and token +func NewPhase(init bool, pss string, host string) (*sdk.Phase, error) { if init { creds, err := keyring.GetCredentials() if err != nil { @@ -50,47 +34,9 @@ func NewPhase(init bool, pss string, host string) (*Phase, error) { } } - p := &Phase{ - APIHost: host, - } - - // Set user agent setUserAgent() - // Determine token type - p.IsServiceToken = misc.PssServicePattern.MatchString(pss) - p.IsUserToken = misc.PssUserPattern.MatchString(pss) - - if !p.IsServiceToken && !p.IsUserToken { - tokenType := "service token" - if strings.Contains(pss, "pss_user") { - tokenType = "user token" - } - return nil, fmt.Errorf("invalid Phase %s", tokenType) - } - - // Parse token segments - segments := strings.Split(pss, ":") - if len(segments) != 6 { - return nil, fmt.Errorf("invalid token format") - } - p.Prefix = segments[0] - p.PesVersion = segments[1] - p.AppToken = segments[2] - p.PssUserPublicKey = segments[3] - p.Keyshare0 = segments[4] - p.Keyshare1UnwrapKey = segments[5] - - // Determine HTTP Authorization token type - if p.IsServiceToken && p.PesVersion == "v2" { - p.TokenType = "ServiceAccount" - } else if p.IsServiceToken { - p.TokenType = "Service" - } else { - p.TokenType = "User" - } - - return p, nil + return sdk.New(pss, host, false) } func setUserAgent() { @@ -102,21 +48,21 @@ func setUserAgent() { username = parts[len(parts)-1] } } - ua := fmt.Sprintf("phase-cli-go/%s %s %s %s@%s", - "0.1.0", runtime.GOOS, runtime.GOARCH, username, hostname) + ua := fmt.Sprintf("phase-cli/%s %s %s %s@%s", + version.Version, runtime.GOOS, runtime.GOARCH, username, hostname) network.SetUserAgent(ua) } -func (p *Phase) Auth() error { - _, err := network.FetchAppKey(p.TokenType, p.AppToken, p.APIHost) +func Auth(p *sdk.Phase) error { + _, err := network.FetchAppKey(p.TokenType, p.AppToken, p.Host) if err != nil { return fmt.Errorf("invalid Phase credentials: %w", err) } return nil } -func (p *Phase) Init() (*misc.AppKeyResponse, error) { - resp, err := network.FetchPhaseUser(p.TokenType, p.AppToken, p.APIHost) +func Init(p *sdk.Phase) (*misc.AppKeyResponse, error) { + resp, err := network.FetchPhaseUser(p.TokenType, p.AppToken, p.Host) if err != nil { return nil, err } @@ -129,90 +75,21 @@ func (p *Phase) Init() (*misc.AppKeyResponse, error) { return &userData, nil } -// InitRaw returns the raw JSON response for auth flow (need user_id, offline_enabled, etc.) -func (p *Phase) InitRaw() (map[string]interface{}, error) { - resp, err := network.FetchPhaseUser(p.TokenType, p.AppToken, p.APIHost) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - var result map[string]interface{} - if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { - return nil, fmt.Errorf("failed to decode response: %w", err) - } - return result, nil -} - -func (p *Phase) Decrypt(phaseCiphertext string, wrappedKeyShareData map[string]interface{}) (string, error) { - segments := strings.Split(phaseCiphertext, ":") - if len(segments) != 4 || segments[0] != "ph" { - return "", fmt.Errorf("ciphertext is invalid") - } - - wrappedKeyShare, ok := wrappedKeyShareData["wrapped_key_share"].(string) - if !ok || wrappedKeyShare == "" { - return "", fmt.Errorf("wrapped key share not found in the response") - } - - // Decrypt using SDK's DecryptWrappedKeyShare which handles the full flow: - // 1. Fetch app key (wrapped keyshare) - // 2. Unwrap keyshare1 using keyshare1_unwrap_key - // 3. Reconstruct app private key from keyshare0 + keyshare1 - // 4. Decrypt the ciphertext using app private key - // - // But that function also does a network call to fetch the wrapped key share. - // Since we already have it in wrappedKeyShareData, we do the steps manually: - - wrappedKeyShareBytes, err := hexDecode(wrappedKeyShare) - if err != nil { - return "", fmt.Errorf("failed to decode wrapped key share: %w", err) - } - - unwrapKeyBytes, err := hexDecode(p.Keyshare1UnwrapKey) - if err != nil { - return "", fmt.Errorf("failed to decode keyshare1 unwrap key: %w", err) - } - - var unwrapKey [32]byte - copy(unwrapKey[:], unwrapKeyBytes) - - keyshare1Bytes, err := crypto.DecryptRaw(wrappedKeyShareBytes, unwrapKey) - if err != nil { - return "", fmt.Errorf("failed to decrypt wrapped key share: %w", err) - } - - // Reconstruct app private key - appPrivKey, err := crypto.ReconstructSecret(p.Keyshare0, string(keyshare1Bytes)) - if err != nil { - return "", fmt.Errorf("failed to reconstruct app private key: %w", err) +// AccountID returns the user_id or account_id from the response. +func AccountID(data *misc.AppKeyResponse) (string, error) { + if data.UserID != "" { + return data.UserID, nil } - - // Decrypt the ciphertext using reconstructed app private key - plaintext, err := crypto.DecryptAsymmetric(phaseCiphertext, appPrivKey, p.PssUserPublicKey) - if err != nil { - return "", fmt.Errorf("failed to decrypt: %w", err) - } - - return plaintext, nil -} - -// findMatchingEnvironmentKey finds environment key by env ID -func (p *Phase) findMatchingEnvironmentKey(userData *misc.AppKeyResponse, envID string) *misc.EnvironmentKey { - for _, app := range userData.Apps { - for _, envKey := range app.EnvironmentKeys { - if envKey.Environment.ID == envID { - return &envKey - } - } + if data.AccountID != "" { + return data.AccountID, nil } - return nil + return "", fmt.Errorf("neither user_id nor account_id found in authentication response") } -// PhaseGetContext resolves app/env context from user data, using .phase.json defaults +// PhaseGetContext resolves app/env context from user data, using .phase.json defaults. func PhaseGetContext(userData *misc.AppKeyResponse, appName, envName, appID string) (string, string, string, string, string, error) { - // If no app context provided, check .phase.json if appID == "" && appName == "" { + // Find .phase.json config up to 8 dir up (current dir + 8 parent dirs) phaseConfig := config.FindPhaseConfig(8) if phaseConfig != nil { envName = coalesce(envName, phaseConfig.DefaultEnv) @@ -224,48 +101,22 @@ func PhaseGetContext(userData *misc.AppKeyResponse, appName, envName, appID stri envName = coalesce(envName, "Development") } - // Find the app - var application *misc.App - if appID != "" { - for i, app := range userData.Apps { - if app.ID == appID { - application = &userData.Apps[i] - break - } - } - if application == nil { - return "", "", "", "", "", fmt.Errorf("no application found with ID: '%s'", appID) - } - } else if appName != "" { - var matchingApps []misc.App - for _, app := range userData.Apps { - if strings.Contains(strings.ToLower(app.Name), strings.ToLower(appName)) { - matchingApps = append(matchingApps, app) - } - } - if len(matchingApps) == 0 { - return "", "", "", "", "", fmt.Errorf("no application found with the name '%s'", appName) - } - // Sort by name length (shortest = most specific match) - just pick the first shortest - shortest := matchingApps[0] - for _, app := range matchingApps[1:] { - if len(app.Name) < len(shortest.Name) { - shortest = app - } - } - application = &shortest - } else { - return "", "", "", "", "", fmt.Errorf("no application context provided. Please run 'phase init' or pass the '--app' or '--app-id' flag") - } + return misc.PhaseGetContext(userData, appName, envName, appID) +} - // Find the environment - for _, envKey := range application.EnvironmentKeys { - if strings.Contains(strings.ToLower(envKey.Environment.Name), strings.ToLower(envName)) { - return application.Name, application.ID, envKey.Environment.Name, envKey.Environment.ID, envKey.IdentityKey, nil +// GetConfig fills in appName/envName/appID from .phase.json when not provided via flags. +func GetConfig(appName, envName, appID string) (string, string, string) { + if appID == "" && appName == "" { + phaseConfig := config.FindPhaseConfig(8) + if phaseConfig != nil { + envName = coalesce(envName, phaseConfig.DefaultEnv) + appID = phaseConfig.AppID } } - - return "", "", "", "", "", fmt.Errorf("environment '%s' not found in application '%s'", envName, application.Name) + if envName == "" { + envName = "Development" + } + return appName, envName, appID } func coalesce(a, b string) string { From 47e5257cc4ebbe7eba85fe7c91f55f9ae6ec502d Mon Sep 17 00:00:00 2001 From: Nimish Date: Tue, 10 Feb 2026 13:32:42 +0530 Subject: [PATCH 20/50] refactor: update secret handling functions to use sdk options and improve client initialization --- src/pkg/mcp/tools.go | 116 ++++++++++++++++++++++--------------------- 1 file changed, 60 insertions(+), 56 deletions(-) diff --git a/src/pkg/mcp/tools.go b/src/pkg/mcp/tools.go index 205be6bf..ef857145 100644 --- a/src/pkg/mcp/tools.go +++ b/src/pkg/mcp/tools.go @@ -15,9 +15,10 @@ import ( "github.com/phasehq/cli/pkg/keyring" "github.com/phasehq/cli/pkg/phase" "github.com/phasehq/cli/pkg/util" + sdk "github.com/phasehq/golang-sdk/phase" + "github.com/phasehq/golang-sdk/phase/misc" ) -// SecretMetadata is a safe output struct that never includes secret values. type SecretMetadata struct { Key string `json:"key"` Path string `json:"path"` @@ -28,7 +29,7 @@ type SecretMetadata struct { Overridden bool `json:"overridden"` } -func newPhaseClient() (*phase.Phase, error) { +func newPhaseClient() (*sdk.Phase, error) { return phase.NewPhase(true, "", "") } @@ -159,10 +160,12 @@ func handleSecretsList(_ context.Context, _ *gomcp.CallToolRequest, args Secrets return errorResult(fmt.Sprintf("Failed to initialize Phase client: %v", err)) } - secrets, err := p.Get(phase.GetOptions{ - EnvName: args.Env, - AppName: args.App, - AppID: args.AppID, + app, env, appID := phase.GetConfig(args.App, args.Env, args.AppID) + + secrets, err := p.Get(sdk.GetOptions{ + EnvName: env, + AppName: app, + AppID: appID, Path: args.Path, Tag: args.Tags, }) @@ -204,7 +207,7 @@ func handleSecretsCreate(_ context.Context, _ *gomcp.CallToolRequest, args Secre length = 32 } - value, err := util.GenerateRandomSecret(randomType, length) + value, err := misc.GenerateRandomSecret(randomType, length) if err != nil { return errorResult(fmt.Sprintf("Failed to generate random secret: %v", err)) } @@ -219,11 +222,13 @@ func handleSecretsCreate(_ context.Context, _ *gomcp.CallToolRequest, args Secre path = "/" } - err = p.Create(phase.CreateOptions{ - KeyValuePairs: []phase.KeyValuePair{{Key: key, Value: value}}, - EnvName: args.Env, - AppName: args.App, - AppID: args.AppID, + app, env, aID := phase.GetConfig(args.App, args.Env, args.AppID) + + err = p.Create(sdk.CreateOptions{ + KeyValuePairs: []sdk.KeyValuePair{{Key: key, Value: value}}, + EnvName: env, + AppName: app, + AppID: aID, Path: path, }) if err != nil { @@ -258,11 +263,13 @@ func handleSecretsSet(_ context.Context, _ *gomcp.CallToolRequest, args SecretsS path = "/" } - err = p.Create(phase.CreateOptions{ - KeyValuePairs: []phase.KeyValuePair{{Key: key, Value: args.Value}}, - EnvName: args.Env, - AppName: args.App, - AppID: args.AppID, + app, env, aID := phase.GetConfig(args.App, args.Env, args.AppID) + + err = p.Create(sdk.CreateOptions{ + KeyValuePairs: []sdk.KeyValuePair{{Key: key, Value: args.Value}}, + EnvName: env, + AppName: app, + AppID: aID, Path: path, }) if err != nil { @@ -288,10 +295,12 @@ func handleSecretsUpdate(_ context.Context, _ *gomcp.CallToolRequest, args Secre return errorResult(fmt.Sprintf("Failed to initialize Phase client: %v", err)) } - result, err := p.Update(phase.UpdateOptions{ - EnvName: args.Env, - AppName: args.App, - AppID: args.AppID, + app, env, aID := phase.GetConfig(args.App, args.Env, args.AppID) + + result, err := p.Update(sdk.UpdateOptions{ + EnvName: env, + AppName: app, + AppID: aID, Key: key, Value: args.Value, SourcePath: args.Path, @@ -322,10 +331,12 @@ func handleSecretsDelete(_ context.Context, _ *gomcp.CallToolRequest, args Secre return errorResult(fmt.Sprintf("Failed to initialize Phase client: %v", err)) } - keysNotFound, err := p.Delete(phase.DeleteOptions{ - EnvName: args.Env, - AppName: args.App, - AppID: args.AppID, + app, env, aID := phase.GetConfig(args.App, args.Env, args.AppID) + + keysNotFound, err := p.Delete(sdk.DeleteOptions{ + EnvName: env, + AppName: app, + AppID: aID, KeysToDelete: keys, Path: args.Path, }) @@ -355,14 +366,6 @@ func handleSecretsImport(_ context.Context, _ *gomcp.CallToolRequest, args Secre return textResult("No secrets found in the file."), nil, nil } - var kvPairs []phase.KeyValuePair - for _, pair := range pairs { - kvPairs = append(kvPairs, phase.KeyValuePair{ - Key: pair.Key, - Value: pair.Value, - }) - } - p, err := newPhaseClient() if err != nil { return errorResult(fmt.Sprintf("Failed to initialize Phase client: %v", err)) @@ -373,18 +376,20 @@ func handleSecretsImport(_ context.Context, _ *gomcp.CallToolRequest, args Secre path = "/" } - err = p.Create(phase.CreateOptions{ - KeyValuePairs: kvPairs, - EnvName: args.Env, - AppName: args.App, - AppID: args.AppID, + app, env, aID := phase.GetConfig(args.App, args.Env, args.AppID) + + err = p.Create(sdk.CreateOptions{ + KeyValuePairs: pairs, + EnvName: env, + AppName: app, + AppID: aID, Path: path, }) if err != nil { return errorResult(fmt.Sprintf("Failed to import secrets: %v", err)) } - return textResult(fmt.Sprintf("Successfully imported %d secrets from %s (values hidden for security).", len(kvPairs), args.FilePath)), nil, nil + return textResult(fmt.Sprintf("Successfully imported %d secrets from %s (values hidden for security).", len(pairs), args.FilePath)), nil, nil } func handleSecretsGet(_ context.Context, _ *gomcp.CallToolRequest, args SecretsGetArgs) (*gomcp.CallToolResult, any, error) { @@ -395,10 +400,12 @@ func handleSecretsGet(_ context.Context, _ *gomcp.CallToolRequest, args SecretsG return errorResult(fmt.Sprintf("Failed to initialize Phase client: %v", err)) } - secrets, err := p.Get(phase.GetOptions{ - EnvName: args.Env, - AppName: args.App, - AppID: args.AppID, + app, env, aID := phase.GetConfig(args.App, args.Env, args.AppID) + + secrets, err := p.Get(sdk.GetOptions{ + EnvName: env, + AppName: app, + AppID: aID, Keys: []string{key}, Path: args.Path, }) @@ -435,10 +442,12 @@ func handleRun(ctx context.Context, _ *gomcp.CallToolRequest, args RunArgs) (*go return errorResult(fmt.Sprintf("Failed to initialize Phase client: %v", err)) } - secrets, err := p.Get(phase.GetOptions{ - EnvName: args.Env, - AppName: args.App, - AppID: args.AppID, + app, env, aID := phase.GetConfig(args.App, args.Env, args.AppID) + + secrets, err := p.Get(sdk.GetOptions{ + EnvName: env, + AppName: app, + AppID: aID, Tag: args.Tags, Path: args.Path, }) @@ -452,18 +461,13 @@ func handleRun(ctx context.Context, _ *gomcp.CallToolRequest, args RunArgs) (*go if secret.Value == "" { continue } - resolvedValue := phase.ResolveAllSecrets(secret.Value, secrets, p, secret.Application, secret.Environment) + resolvedValue := sdk.ResolveAllSecrets(secret.Value, secrets, p, secret.Application, secret.Environment) resolvedSecrets[secret.Key] = resolvedValue } - // Build environment - cleanEnv := util.CleanSubprocessEnv() + // Build environment: inherit current env and append secrets + envSlice := os.Environ() for k, v := range resolvedSecrets { - cleanEnv[k] = v - } - - var envSlice []string - for k, v := range cleanEnv { envSlice = append(envSlice, fmt.Sprintf("%s=%s", k, v)) } @@ -516,7 +520,7 @@ func handleInit(_ context.Context, _ *gomcp.CallToolRequest, args InitArgs) (*go return errorResult(fmt.Sprintf("Failed to initialize Phase client: %v", err)) } - data, err := p.Init() + data, err := phase.Init(p) if err != nil { return errorResult(fmt.Sprintf("Failed to fetch app data: %v", err)) } From 592c07009d1f6d614df1d106ae0aa28de0dd03e7 Mon Sep 17 00:00:00 2001 From: Nimish Date: Tue, 10 Feb 2026 13:38:12 +0530 Subject: [PATCH 21/50] chore: update imports and references --- src/cmd/dynamic_secrets_lease_generate.go | 4 ++-- src/cmd/dynamic_secrets_lease_get.go | 4 ++-- src/cmd/dynamic_secrets_lease_renew.go | 4 ++-- src/cmd/dynamic_secrets_lease_revoke.go | 4 ++-- src/cmd/dynamic_secrets_list.go | 4 ++-- src/cmd/init_cmd.go | 2 +- src/cmd/secrets_create.go | 7 +++++-- src/cmd/secrets_delete.go | 5 ++++- src/cmd/secrets_export.go | 8 +++++--- src/cmd/secrets_get.go | 7 +++++-- src/cmd/secrets_list.go | 7 +++++-- src/cmd/secrets_update.go | 5 ++++- src/pkg/display/tree.go | 12 ++++++------ 13 files changed, 45 insertions(+), 28 deletions(-) diff --git a/src/cmd/dynamic_secrets_lease_generate.go b/src/cmd/dynamic_secrets_lease_generate.go index 43a08faa..7c1dd70a 100644 --- a/src/cmd/dynamic_secrets_lease_generate.go +++ b/src/cmd/dynamic_secrets_lease_generate.go @@ -36,7 +36,7 @@ func runDynamicSecretsLeaseGenerate(cmd *cobra.Command, args []string) error { return err } - userData, err := p.Init() + userData, err := phase.Init(p) if err != nil { return err } @@ -51,7 +51,7 @@ func runDynamicSecretsLeaseGenerate(cmd *cobra.Command, args []string) error { ttlPtr = &leaseTTL } - result, err := network.CreateDynamicSecretLease(p.TokenType, p.AppToken, p.APIHost, resolvedAppID, resolvedEnvName, secretID, ttlPtr) + result, err := network.CreateDynamicSecretLease(p.TokenType, p.AppToken, p.Host, resolvedAppID, resolvedEnvName, secretID, ttlPtr) if err != nil { return err } diff --git a/src/cmd/dynamic_secrets_lease_get.go b/src/cmd/dynamic_secrets_lease_get.go index 3c53f5cf..fd0ecf65 100644 --- a/src/cmd/dynamic_secrets_lease_get.go +++ b/src/cmd/dynamic_secrets_lease_get.go @@ -34,7 +34,7 @@ func runDynamicSecretsLeaseGet(cmd *cobra.Command, args []string) error { return err } - userData, err := p.Init() + userData, err := phase.Init(p) if err != nil { return err } @@ -44,7 +44,7 @@ func runDynamicSecretsLeaseGet(cmd *cobra.Command, args []string) error { return err } - result, err := network.ListDynamicSecretLeases(p.TokenType, p.AppToken, p.APIHost, resolvedAppID, resolvedEnvName, secretID) + result, err := network.ListDynamicSecretLeases(p.TokenType, p.AppToken, p.Host, resolvedAppID, resolvedEnvName, secretID) if err != nil { return err } diff --git a/src/cmd/dynamic_secrets_lease_renew.go b/src/cmd/dynamic_secrets_lease_renew.go index a4843050..50fb8c6c 100644 --- a/src/cmd/dynamic_secrets_lease_renew.go +++ b/src/cmd/dynamic_secrets_lease_renew.go @@ -40,7 +40,7 @@ func runDynamicSecretsLeaseRenew(cmd *cobra.Command, args []string) error { return err } - userData, err := p.Init() + userData, err := phase.Init(p) if err != nil { return err } @@ -50,7 +50,7 @@ func runDynamicSecretsLeaseRenew(cmd *cobra.Command, args []string) error { return err } - result, err := network.RenewDynamicSecretLease(p.TokenType, p.AppToken, p.APIHost, resolvedAppID, resolvedEnvName, leaseID, ttl) + result, err := network.RenewDynamicSecretLease(p.TokenType, p.AppToken, p.Host, resolvedAppID, resolvedEnvName, leaseID, ttl) if err != nil { return err } diff --git a/src/cmd/dynamic_secrets_lease_revoke.go b/src/cmd/dynamic_secrets_lease_revoke.go index 4a2115b0..f54453f5 100644 --- a/src/cmd/dynamic_secrets_lease_revoke.go +++ b/src/cmd/dynamic_secrets_lease_revoke.go @@ -34,7 +34,7 @@ func runDynamicSecretsLeaseRevoke(cmd *cobra.Command, args []string) error { return err } - userData, err := p.Init() + userData, err := phase.Init(p) if err != nil { return err } @@ -44,7 +44,7 @@ func runDynamicSecretsLeaseRevoke(cmd *cobra.Command, args []string) error { return err } - result, err := network.RevokeDynamicSecretLease(p.TokenType, p.AppToken, p.APIHost, resolvedAppID, resolvedEnvName, leaseID) + result, err := network.RevokeDynamicSecretLease(p.TokenType, p.AppToken, p.Host, resolvedAppID, resolvedEnvName, leaseID) if err != nil { return err } diff --git a/src/cmd/dynamic_secrets_list.go b/src/cmd/dynamic_secrets_list.go index ddf2d99c..cdbed321 100644 --- a/src/cmd/dynamic_secrets_list.go +++ b/src/cmd/dynamic_secrets_list.go @@ -34,7 +34,7 @@ func runDynamicSecretsList(cmd *cobra.Command, args []string) error { return err } - userData, err := p.Init() + userData, err := phase.Init(p) if err != nil { return err } @@ -45,7 +45,7 @@ func runDynamicSecretsList(cmd *cobra.Command, args []string) error { } _ = resolvedAppName - result, err := network.ListDynamicSecrets(p.TokenType, p.AppToken, p.APIHost, resolvedAppID, resolvedEnvName, path) + result, err := network.ListDynamicSecrets(p.TokenType, p.AppToken, p.Host, resolvedAppID, resolvedEnvName, path) if err != nil { return err } diff --git a/src/cmd/init_cmd.go b/src/cmd/init_cmd.go index a0edc63e..e25410cc 100644 --- a/src/cmd/init_cmd.go +++ b/src/cmd/init_cmd.go @@ -28,7 +28,7 @@ func runInit(cmd *cobra.Command, args []string) error { return err } - data, err := p.Init() + data, err := phase.Init(p) if err != nil { return err } diff --git a/src/cmd/secrets_create.go b/src/cmd/secrets_create.go index d1ccfede..2c4a633e 100644 --- a/src/cmd/secrets_create.go +++ b/src/cmd/secrets_create.go @@ -7,6 +7,7 @@ import ( "syscall" "github.com/phasehq/cli/pkg/phase" + sdk "github.com/phasehq/golang-sdk/phase" "github.com/phasehq/golang-sdk/phase/misc" "github.com/spf13/cobra" "golang.org/x/term" @@ -90,13 +91,15 @@ func runSecretsCreate(cmd *cobra.Command, args []string) error { overrideValue = string(ovBytes) } + appName, envName, appID = phase.GetConfig(appName, envName, appID) + p, err := phase.NewPhase(true, "", "") if err != nil { return err } - err = p.Create(phase.CreateOptions{ - KeyValuePairs: []phase.KeyValuePair{{Key: key, Value: value}}, + err = p.Create(sdk.CreateOptions{ + KeyValuePairs: []sdk.KeyValuePair{{Key: key, Value: value}}, EnvName: envName, AppName: appName, AppID: appID, diff --git a/src/cmd/secrets_delete.go b/src/cmd/secrets_delete.go index ce63042a..8424580e 100644 --- a/src/cmd/secrets_delete.go +++ b/src/cmd/secrets_delete.go @@ -7,6 +7,7 @@ import ( "github.com/phasehq/cli/pkg/phase" "github.com/phasehq/cli/pkg/util" + sdk "github.com/phasehq/golang-sdk/phase" "github.com/spf13/cobra" ) @@ -43,12 +44,14 @@ func runSecretsDelete(cmd *cobra.Command, args []string) error { keysToDelete[i] = strings.ToUpper(k) } + appName, envName, appID = phase.GetConfig(appName, envName, appID) + p, err := phase.NewPhase(true, "", "") if err != nil { return err } - keysNotFound, err := p.Delete(phase.DeleteOptions{ + keysNotFound, err := p.Delete(sdk.DeleteOptions{ EnvName: envName, AppName: appName, AppID: appID, diff --git a/src/cmd/secrets_export.go b/src/cmd/secrets_export.go index 159ff5ee..7aef9c4d 100644 --- a/src/cmd/secrets_export.go +++ b/src/cmd/secrets_export.go @@ -6,7 +6,7 @@ import ( "github.com/phasehq/cli/pkg/phase" "github.com/phasehq/cli/pkg/util" - + sdk "github.com/phasehq/golang-sdk/phase" "github.com/spf13/cobra" ) @@ -38,12 +38,14 @@ func runSecretsExport(cmd *cobra.Command, args []string) error { generateLeases, _ := cmd.Flags().GetString("generate-leases") leaseTTL, _ := cmd.Flags().GetInt("lease-ttl") + appName, envName, appID = phase.GetConfig(appName, envName, appID) + p, err := phase.NewPhase(true, "", "") if err != nil { return err } - opts := phase.GetOptions{ + opts := sdk.GetOptions{ EnvName: envName, AppName: appName, AppID: appID, @@ -67,7 +69,7 @@ func runSecretsExport(cmd *cobra.Command, args []string) error { if secret.Value == "" { continue } - resolvedValue := phase.ResolveAllSecrets(secret.Value, allSecrets, p, secret.Application, secret.Environment) + resolvedValue := sdk.ResolveAllSecrets(secret.Value, allSecrets, p, secret.Application, secret.Environment) secretsDict[secret.Key] = resolvedValue } diff --git a/src/cmd/secrets_get.go b/src/cmd/secrets_get.go index bd980255..8aaed7dd 100644 --- a/src/cmd/secrets_get.go +++ b/src/cmd/secrets_get.go @@ -8,6 +8,7 @@ import ( "github.com/phasehq/cli/pkg/phase" "github.com/phasehq/cli/pkg/util" + sdk "github.com/phasehq/golang-sdk/phase" "github.com/spf13/cobra" ) @@ -37,13 +38,15 @@ func runSecretsGet(cmd *cobra.Command, args []string) error { generateLeases, _ := cmd.Flags().GetString("generate-leases") leaseTTL, _ := cmd.Flags().GetInt("lease-ttl") + appName, envName, appID = phase.GetConfig(appName, envName, appID) + p, err := phase.NewPhase(true, "", "") if err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) } - opts := phase.GetOptions{ + opts := sdk.GetOptions{ EnvName: envName, AppName: appName, AppID: appID, @@ -62,7 +65,7 @@ func runSecretsGet(cmd *cobra.Command, args []string) error { os.Exit(1) } - var found *phase.SecretResult + var found *sdk.SecretResult for i, s := range secrets { if s.Key == key { found = &secrets[i] diff --git a/src/cmd/secrets_list.go b/src/cmd/secrets_list.go index a5e19fad..433bb821 100644 --- a/src/cmd/secrets_list.go +++ b/src/cmd/secrets_list.go @@ -7,6 +7,7 @@ import ( "github.com/phasehq/cli/pkg/display" "github.com/phasehq/cli/pkg/phase" "github.com/phasehq/cli/pkg/util" + sdk "github.com/phasehq/golang-sdk/phase" "github.com/spf13/cobra" ) @@ -36,10 +37,10 @@ func init() { } // listSecrets fetches and displays secrets. Used by list, create, update, and delete commands. -func listSecrets(p *phase.Phase, envName, appName, appID, tags, path string, show bool) { +func listSecrets(p *sdk.Phase, envName, appName, appID, tags, path string, show bool) { spinner := util.NewSpinner("Fetching secrets...") spinner.Start() - secrets, err := p.Get(phase.GetOptions{ + secrets, err := p.Get(sdk.GetOptions{ EnvName: envName, AppName: appName, AppID: appID, @@ -63,6 +64,8 @@ func runSecretsList(cmd *cobra.Command, args []string) error { tags, _ := cmd.Flags().GetString("tags") path, _ := cmd.Flags().GetString("path") + appName, envName, appID = phase.GetConfig(appName, envName, appID) + p, err := phase.NewPhase(true, "", "") if err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) diff --git a/src/cmd/secrets_update.go b/src/cmd/secrets_update.go index dc844df7..81b1ffd7 100644 --- a/src/cmd/secrets_update.go +++ b/src/cmd/secrets_update.go @@ -8,6 +8,7 @@ import ( "github.com/phasehq/cli/pkg/phase" "github.com/phasehq/cli/pkg/util" + sdk "github.com/phasehq/golang-sdk/phase" "github.com/phasehq/golang-sdk/phase/misc" "github.com/spf13/cobra" "golang.org/x/term" @@ -91,12 +92,14 @@ func runSecretsUpdate(cmd *cobra.Command, args []string) error { } } + appName, envName, appID = phase.GetConfig(appName, envName, appID) + p, err := phase.NewPhase(true, "", "") if err != nil { return err } - result, err := p.Update(phase.UpdateOptions{ + result, err := p.Update(sdk.UpdateOptions{ EnvName: envName, AppName: appName, AppID: appID, diff --git a/src/pkg/display/tree.go b/src/pkg/display/tree.go index 8214a3f6..9f404836 100644 --- a/src/pkg/display/tree.go +++ b/src/pkg/display/tree.go @@ -7,8 +7,8 @@ import ( "sort" "strings" - "github.com/phasehq/cli/pkg/phase" "github.com/phasehq/cli/pkg/util" + sdk "github.com/phasehq/golang-sdk/phase" ) var ( @@ -34,7 +34,7 @@ func censorSecret(secret string, maxLength int) string { } // renderSecretRow renders a single secret row into the table. -func renderSecretRow(pathPrefix string, s phase.SecretResult, show bool, keyWidth, valueWidth int, bold, reset string) { +func renderSecretRow(pathPrefix string, s sdk.SecretResult, show bool, keyWidth, valueWidth int, bold, reset string) { keyDisplay := s.Key if len(s.Tags) > 0 { keyDisplay += " 🏷️" @@ -83,7 +83,7 @@ func renderSecretRow(pathPrefix string, s phase.SecretResult, show bool, keyWidt } // RenderSecretsTree renders secrets in a tree view with path hierarchy -func RenderSecretsTree(secrets []phase.SecretResult, show bool) { +func RenderSecretsTree(secrets []sdk.SecretResult, show bool) { if len(secrets) == 0 { fmt.Println("No secrets to display.") return @@ -98,7 +98,7 @@ func RenderSecretsTree(secrets []phase.SecretResult, show bool) { "🔮", bold, cyan, appName, reset, bold, green, envName, reset) // Organize by path - paths := map[string][]phase.SecretResult{} + paths := map[string][]sdk.SecretResult{} for _, s := range secrets { path := s.Path if path == "" { @@ -131,8 +131,8 @@ func RenderSecretsTree(secrets []phase.SecretResult, show bool) { pathConnector, "📁", path, bold, magenta, len(pathSecrets), reset) // Separate static and dynamic secrets - var staticSecrets []phase.SecretResult - dynamicGroups := map[string][]phase.SecretResult{} + var staticSecrets []sdk.SecretResult + dynamicGroups := map[string][]sdk.SecretResult{} var dynamicGroupOrder []string for _, s := range pathSecrets { if s.IsDynamic { From 145eee444048003182ccbd61c0156e4c65f8da4e Mon Sep 17 00:00:00 2001 From: Nimish Date: Tue, 10 Feb 2026 13:39:32 +0530 Subject: [PATCH 22/50] chore: update root command version reference to use version package --- src/cmd/root.go | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/cmd/root.go b/src/cmd/root.go index 7f0d3044..85392432 100644 --- a/src/cmd/root.go +++ b/src/cmd/root.go @@ -3,11 +3,10 @@ package cmd import ( "os" + "github.com/phasehq/cli/pkg/version" "github.com/spf13/cobra" ) -var Version = "2.0.0" - const phaseASCii = ` /$$ | $$ @@ -36,7 +35,7 @@ func Execute() { } func init() { - rootCmd.Version = Version + rootCmd.Version = version.Version rootCmd.SetVersionTemplate("{{ .Version }}\n") // Add emojis to built-in cobra commands From 2fe3150a8ac1c66d414c9efee7601ad73361303d Mon Sep 17 00:00:00 2001 From: Nimish Date: Tue, 10 Feb 2026 13:41:41 +0530 Subject: [PATCH 23/50] refactor: remove unnecessary environment variable handling in update command --- src/cmd/update.go | 7 ------- 1 file changed, 7 deletions(-) diff --git a/src/cmd/update.go b/src/cmd/update.go index 65d69cf2..a0492c01 100644 --- a/src/cmd/update.go +++ b/src/cmd/update.go @@ -53,14 +53,7 @@ func runUpdate(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to make script executable: %w", err) } - cleanEnv := util.CleanSubprocessEnv() - var envSlice []string - for k, v := range cleanEnv { - envSlice = append(envSlice, fmt.Sprintf("%s=%s", k, v)) - } - c := exec.Command(tmpPath) - c.Env = envSlice c.Stdout = os.Stdout c.Stderr = os.Stderr c.Stdin = os.Stdin From 787e646d651758fbad42d7b2223d10ff0a407d2c Mon Sep 17 00:00:00 2001 From: Nimish Date: Tue, 10 Feb 2026 13:41:58 +0530 Subject: [PATCH 24/50] refactor: update run and shell commands to utilize sdk options and streamline environment variable handling --- src/cmd/run.go | 17 +++++++---------- src/cmd/shell.go | 39 ++++++++++++++++----------------------- 2 files changed, 23 insertions(+), 33 deletions(-) diff --git a/src/cmd/run.go b/src/cmd/run.go index e78b9b48..80203323 100644 --- a/src/cmd/run.go +++ b/src/cmd/run.go @@ -8,6 +8,7 @@ import ( "github.com/phasehq/cli/pkg/phase" "github.com/phasehq/cli/pkg/util" + sdk "github.com/phasehq/golang-sdk/phase" "github.com/spf13/cobra" ) @@ -39,13 +40,15 @@ func runRun(cmd *cobra.Command, args []string) error { generateLeases, _ := cmd.Flags().GetString("generate-leases") leaseTTL, _ := cmd.Flags().GetInt("lease-ttl") + appName, envName, appID = phase.GetConfig(appName, envName, appID) + p, err := phase.NewPhase(true, "", "") if err != nil { return err } // Fetch secrets - opts := phase.GetOptions{ + opts := sdk.GetOptions{ EnvName: envName, AppName: appName, AppID: appID, @@ -72,19 +75,13 @@ func runRun(cmd *cobra.Command, args []string) error { if secret.Value == "" { continue } - resolvedValue := phase.ResolveAllSecrets(secret.Value, allSecrets, p, secret.Application, secret.Environment) + resolvedValue := sdk.ResolveAllSecrets(secret.Value, allSecrets, p, secret.Application, secret.Environment) resolvedSecrets[secret.Key] = resolvedValue } - // Build environment - cleanEnv := util.CleanSubprocessEnv() + // Build environment: inherit current env and append secrets + envSlice := os.Environ() for k, v := range resolvedSecrets { - cleanEnv[k] = v - } - - // Convert to env slice - var envSlice []string - for k, v := range cleanEnv { envSlice = append(envSlice, fmt.Sprintf("%s=%s", k, v)) } diff --git a/src/cmd/shell.go b/src/cmd/shell.go index ac01c892..68662427 100644 --- a/src/cmd/shell.go +++ b/src/cmd/shell.go @@ -8,6 +8,7 @@ import ( "github.com/phasehq/cli/pkg/phase" "github.com/phasehq/cli/pkg/util" + sdk "github.com/phasehq/golang-sdk/phase" "github.com/spf13/cobra" ) @@ -39,12 +40,14 @@ func runShell(cmd *cobra.Command, args []string) error { generateLeases, _ := cmd.Flags().GetString("generate-leases") leaseTTL, _ := cmd.Flags().GetInt("lease-ttl") + appName, envName, appID = phase.GetConfig(appName, envName, appID) + p, err := phase.NewPhase(true, "", "") if err != nil { return err } - opts := phase.GetOptions{ + opts := sdk.GetOptions{ EnvName: envName, AppName: appName, AppID: appID, @@ -71,19 +74,10 @@ func runShell(cmd *cobra.Command, args []string) error { if secret.Value == "" { continue } - resolvedValue := phase.ResolveAllSecrets(secret.Value, allSecrets, p, secret.Application, secret.Environment) + resolvedValue := sdk.ResolveAllSecrets(secret.Value, allSecrets, p, secret.Application, secret.Environment) resolvedSecrets[secret.Key] = resolvedValue } - // Build environment - cleanEnv := util.CleanSubprocessEnv() - for k, v := range resolvedSecrets { - cleanEnv[k] = v - } - - // Set Phase shell markers - cleanEnv["PHASE_SHELL"] = "true" - // Collect env/app info for display apps := map[string]bool{} envs := map[string]bool{} @@ -97,22 +91,21 @@ func runShell(cmd *cobra.Command, args []string) error { } appNames := mapKeys(apps) envNames := mapKeys(envs) + + // Build environment: inherit current env, add secrets and shell markers + envSlice := os.Environ() + for k, v := range resolvedSecrets { + envSlice = append(envSlice, fmt.Sprintf("%s=%s", k, v)) + } + envSlice = append(envSlice, "PHASE_SHELL=true") if len(envNames) > 0 { - cleanEnv["PHASE_ENV"] = envNames[0] + envSlice = append(envSlice, fmt.Sprintf("PHASE_ENV=%s", envNames[0])) } if len(appNames) > 0 { - cleanEnv["PHASE_APP"] = appNames[0] - } - - // Ensure TERM is set - if cleanEnv["TERM"] == "" { - cleanEnv["TERM"] = "xterm-256color" + envSlice = append(envSlice, fmt.Sprintf("PHASE_APP=%s", appNames[0])) } - - // Convert to env slice - var envSlice []string - for k, v := range cleanEnv { - envSlice = append(envSlice, fmt.Sprintf("%s=%s", k, v)) + if os.Getenv("TERM") == "" { + envSlice = append(envSlice, "TERM=xterm-256color") } // Determine shell From 6cc301c09d79102f230809a1c305469fbdf2f02c Mon Sep 17 00:00:00 2001 From: Nimish Date: Tue, 10 Feb 2026 13:42:24 +0530 Subject: [PATCH 25/50] refactor: streamline secrets import by utilizing sdk.CreateOptions and simplifying key-value pair handling --- src/cmd/secrets_import.go | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/src/cmd/secrets_import.go b/src/cmd/secrets_import.go index b742d5ed..79756b49 100644 --- a/src/cmd/secrets_import.go +++ b/src/cmd/secrets_import.go @@ -5,6 +5,7 @@ import ( "github.com/phasehq/cli/pkg/phase" "github.com/phasehq/cli/pkg/util" + sdk "github.com/phasehq/golang-sdk/phase" "github.com/spf13/cobra" ) @@ -36,18 +37,15 @@ func runSecretsImport(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to read file %s: %w", envFile, err) } + appName, envName, appID = phase.GetConfig(appName, envName, appID) + p, err := phase.NewPhase(true, "", "") if err != nil { return err } - kvPairs := make([]phase.KeyValuePair, len(pairs)) - for i, kv := range pairs { - kvPairs[i] = phase.KeyValuePair{Key: kv.Key, Value: kv.Value} - } - - err = p.Create(phase.CreateOptions{ - KeyValuePairs: kvPairs, + err = p.Create(sdk.CreateOptions{ + KeyValuePairs: pairs, EnvName: envName, AppName: appName, AppID: appID, From 3ad7309ac97706e9fd5c989f10ee284b33291dde Mon Sep 17 00:00:00 2001 From: Nimish Date: Tue, 10 Feb 2026 13:43:20 +0530 Subject: [PATCH 26/50] refactor: enhance authentication flow by consolidating user data extraction and utilizing phase methods --- src/cmd/auth.go | 35 ++++++++++------------------------- 1 file changed, 10 insertions(+), 25 deletions(-) diff --git a/src/cmd/auth.go b/src/cmd/auth.go index 01f2cf72..4c94a73e 100644 --- a/src/cmd/auth.go +++ b/src/cmd/auth.go @@ -110,45 +110,30 @@ func runTokenAuth(cmd *cobra.Command, host string) error { return fmt.Errorf("invalid token: %w", err) } - if err := p.Auth(); err != nil { + if err := phase.Auth(p); err != nil { return fmt.Errorf("authentication failed: %w", err) } // Get user data - userData, err := p.InitRaw() + userData, err := phase.Init(p) if err != nil { return fmt.Errorf("failed to fetch user data: %w", err) } - // Extract account ID (support both user_id and account_id) - accountID := "" - if uid, ok := userData["user_id"].(string); ok && uid != "" { - accountID = uid - } else if aid, ok := userData["account_id"].(string); ok && aid != "" { - accountID = aid - } - if accountID == "" { - return fmt.Errorf("neither user_id nor account_id found in authentication response") + accountID, err := phase.AccountID(userData) + if err != nil { + return err } - // Extract org info var orgID, orgName *string - if org, ok := userData["organisation"].(map[string]interface{}); ok && org != nil { - if id, ok := org["id"].(string); ok { - orgID = &id - } - if name, ok := org["name"].(string); ok { - orgName = &name - } + if userData.Organisation != nil { + orgID = &userData.Organisation.ID + orgName = &userData.Organisation.Name } - // Extract wrapped key share var wrappedKeyShare *string - offlineEnabled, _ := userData["offline_enabled"].(bool) - if offlineEnabled { - if wks, ok := userData["wrapped_key_share"].(string); ok { - wrappedKeyShare = &wks - } + if userData.OfflineEnabled && userData.WrappedKeyShare != "" { + wrappedKeyShare = &userData.WrappedKeyShare } // Save credentials to keyring From bcee56e02e10ac2c76b79b284d933aa340003bce Mon Sep 17 00:00:00 2001 From: Nimish Date: Tue, 10 Feb 2026 13:43:30 +0530 Subject: [PATCH 27/50] refactor: improve authentication process by consolidating user data handling and leveraging phase methods --- src/cmd/auth_webauth.go | 35 ++++++++++------------------------- 1 file changed, 10 insertions(+), 25 deletions(-) diff --git a/src/cmd/auth_webauth.go b/src/cmd/auth_webauth.go index ef7a97e1..dfb861cf 100644 --- a/src/cmd/auth_webauth.go +++ b/src/cmd/auth_webauth.go @@ -130,45 +130,30 @@ func runWebAuth(cmd *cobra.Command, host string) error { return fmt.Errorf("invalid token: %w", err) } - if err := p.Auth(); err != nil { + if err := phase.Auth(p); err != nil { return fmt.Errorf("authentication failed: %w", err) } // Get user data - userData, err := p.InitRaw() + userData, err := phase.Init(p) if err != nil { return fmt.Errorf("failed to fetch user data: %w", err) } - // Extract account ID - accountID := "" - if uid, ok := userData["user_id"].(string); ok && uid != "" { - accountID = uid - } else if aid, ok := userData["account_id"].(string); ok && aid != "" { - accountID = aid - } - if accountID == "" { - return fmt.Errorf("neither user_id nor account_id found in authentication response") + accountID, err := phase.AccountID(userData) + if err != nil { + return err } - // Extract org info var orgID, orgName *string - if org, ok := userData["organisation"].(map[string]interface{}); ok && org != nil { - if id, ok := org["id"].(string); ok { - orgID = &id - } - if name, ok := org["name"].(string); ok { - orgName = &name - } + if userData.Organisation != nil { + orgID = &userData.Organisation.ID + orgName = &userData.Organisation.Name } - // Extract wrapped key share var wrappedKeyShare *string - offlineEnabled, _ := userData["offline_enabled"].(bool) - if offlineEnabled { - if wks, ok := userData["wrapped_key_share"].(string); ok { - wrappedKeyShare = &wks - } + if userData.OfflineEnabled && userData.WrappedKeyShare != "" { + wrappedKeyShare = &userData.WrappedKeyShare } // Save credentials to keyring From bce2ba57ddad58c2313c44ac46c300827ceb245c Mon Sep 17 00:00:00 2001 From: Nimish Date: Tue, 10 Feb 2026 13:43:41 +0530 Subject: [PATCH 28/50] refactor: further streamline authentication process by enhancing user data handling and utilizing phase methods --- src/cmd/auth_aws.go | 32 ++++++++++---------------------- 1 file changed, 10 insertions(+), 22 deletions(-) diff --git a/src/cmd/auth_aws.go b/src/cmd/auth_aws.go index 1f905c6c..59acee2e 100644 --- a/src/cmd/auth_aws.go +++ b/src/cmd/auth_aws.go @@ -139,42 +139,30 @@ func runAWSIAMAuth(cmd *cobra.Command, host string) error { return fmt.Errorf("invalid token received: %w", err) } - if err := p.Auth(); err != nil { + if err := phase.Auth(p); err != nil { return fmt.Errorf("token validation failed: %w", err) } // Get user data - userData, err := p.InitRaw() + userData, err := phase.Init(p) if err != nil { return fmt.Errorf("failed to fetch user data: %w", err) } - accountID := "" - if uid, ok := userData["user_id"].(string); ok && uid != "" { - accountID = uid - } else if aid, ok := userData["account_id"].(string); ok && aid != "" { - accountID = aid - } - if accountID == "" { - return fmt.Errorf("no account ID found in response") + accountID, err := phase.AccountID(userData) + if err != nil { + return err } var orgID, orgName *string - if org, ok := userData["organisation"].(map[string]interface{}); ok && org != nil { - if id, ok := org["id"].(string); ok { - orgID = &id - } - if name, ok := org["name"].(string); ok { - orgName = &name - } + if userData.Organisation != nil { + orgID = &userData.Organisation.ID + orgName = &userData.Organisation.Name } var wrappedKeyShare *string - offlineEnabled, _ := userData["offline_enabled"].(bool) - if offlineEnabled { - if wks, ok := userData["wrapped_key_share"].(string); ok { - wrappedKeyShare = &wks - } + if userData.OfflineEnabled && userData.WrappedKeyShare != "" { + wrappedKeyShare = &userData.WrappedKeyShare } tokenSavedInKeyring := true From 2f4c4d794a2a265db72daa004b936d1236e58d2f Mon Sep 17 00:00:00 2001 From: Nimish Date: Tue, 10 Feb 2026 13:48:56 +0530 Subject: [PATCH 29/50] chore: update MCP command description to indicate BETA status --- src/cmd/mcp.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/cmd/mcp.go b/src/cmd/mcp.go index bc975130..05c30d0e 100644 --- a/src/cmd/mcp.go +++ b/src/cmd/mcp.go @@ -6,8 +6,8 @@ import ( var mcpCmd = &cobra.Command{ Use: "mcp", - Short: "🤖 Model Context Protocol (MCP) server for AI assistants", - Long: `🤖 Model Context Protocol (MCP) server for AI assistants + Short: "🤖 Model Context Protocol (MCP) server for AI assistants (BETA)", + Long: `🤖 Model Context Protocol (MCP) server for AI assistants (BETA) Allows AI assistants like Claude Code, Cursor, VS Code Copilot, Zed, and OpenCode to securely manage Phase secrets via the MCP protocol. From b046bd7cd7e98879e1e3e88fbc5c833285772270 Mon Sep 17 00:00:00 2001 From: Nimish Date: Tue, 10 Feb 2026 13:56:05 +0530 Subject: [PATCH 30/50] refactor: enhance Dockerfile to support dynamic versioning and architecture arguments --- Dockerfile | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Dockerfile b/Dockerfile index 124e4de7..0c2d2327 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,9 +1,9 @@ # Build stage: compile Go binary FROM golang:1.24-alpine AS builder -ARG VERSION=dev +ARG VERSION ARG TARGETOS=linux -ARG TARGETARCH=amd64 +ARG TARGETARCH WORKDIR /build @@ -21,7 +21,7 @@ RUN go mod edit -replace github.com/phasehq/golang-sdk=../golang-sdk # Download dependencies and build RUN go mod download && \ CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} \ - go build -ldflags "-s -w -X github.com/phasehq/cli/cmd.Version=${VERSION}" \ + go build -ldflags "-s -w${VERSION:+ -X github.com/phasehq/cli/pkg/version.Version=${VERSION}}" \ -o /phase ./ # Runtime stage: minimal scratch image From 1167d656da1a27ddc1b458cb05b19af7d8826d10 Mon Sep 17 00:00:00 2001 From: Nimish Date: Tue, 10 Feb 2026 14:58:06 +0530 Subject: [PATCH 31/50] refactor: simplify shell command execution by removing redundant nil check --- src/cmd/run.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/cmd/run.go b/src/cmd/run.go index 80203323..7896af99 100644 --- a/src/cmd/run.go +++ b/src/cmd/run.go @@ -117,7 +117,7 @@ func runRun(cmd *cobra.Command, args []string) error { command := strings.Join(args, " ") shell := util.GetDefaultShell() var c *exec.Cmd - if shell != nil && len(shell) > 0 { + if len(shell) > 0 { c = exec.Command(shell[0], "-c", command) } else { c = exec.Command("sh", "-c", command) From cba4d38bf0fd216911c4a11ebf7afe36c2dfd907 Mon Sep 17 00:00:00 2001 From: Nimish Date: Tue, 10 Feb 2026 15:00:41 +0530 Subject: [PATCH 32/50] test: add unit tests for command execution and configuration parsing --- src/cmd/run_shell_test.go | 30 +++++ src/pkg/config/phase_json_test.go | 132 ++++++++++++++++++++++ src/pkg/util/export_test.go | 177 ++++++++++++++++++++++++++++++ src/pkg/util/misc_test.go | 64 +++++++++++ 4 files changed, 403 insertions(+) create mode 100644 src/cmd/run_shell_test.go create mode 100644 src/pkg/config/phase_json_test.go create mode 100644 src/pkg/util/export_test.go create mode 100644 src/pkg/util/misc_test.go diff --git a/src/cmd/run_shell_test.go b/src/cmd/run_shell_test.go new file mode 100644 index 00000000..2b660cfa --- /dev/null +++ b/src/cmd/run_shell_test.go @@ -0,0 +1,30 @@ +package cmd + +import "testing" + +func TestRunCommandRequiresAtLeastOneArg(t *testing.T) { + if err := runCmd.Args(runCmd, []string{}); err == nil { + t.Fatal("expected error when no command args are provided") + } + if err := runCmd.Args(runCmd, []string{"echo"}); err != nil { + t.Fatalf("expected no error for one arg, got %v", err) + } +} + +func TestRunAndShellDefaultPathFlag(t *testing.T) { + runPath, err := runCmd.Flags().GetString("path") + if err != nil { + t.Fatalf("read run --path flag: %v", err) + } + if runPath != "/" { + t.Fatalf("unexpected run --path default: got %q want %q", runPath, "/") + } + + shellPath, err := shellCmd.Flags().GetString("path") + if err != nil { + t.Fatalf("read shell --path flag: %v", err) + } + if shellPath != "/" { + t.Fatalf("unexpected shell --path default: got %q want %q", shellPath, "/") + } +} diff --git a/src/pkg/config/phase_json_test.go b/src/pkg/config/phase_json_test.go new file mode 100644 index 00000000..b602e2c5 --- /dev/null +++ b/src/pkg/config/phase_json_test.go @@ -0,0 +1,132 @@ +package config + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" +) + +func writePhaseConfigFile(t *testing.T, dir string, monorepoSupport bool) { + t.Helper() + cfg := PhaseJSONConfig{ + Version: "2", + PhaseApp: "TestApp", + AppID: "00000000-0000-0000-0000-000000000000", + DefaultEnv: "Development", + EnvID: "00000000-0000-0000-0000-000000000001", + MonorepoSupport: monorepoSupport, + } + data, err := json.Marshal(cfg) + if err != nil { + t.Fatalf("marshal config: %v", err) + } + if err := os.WriteFile(filepath.Join(dir, PhaseEnvConfig), data, 0o600); err != nil { + t.Fatalf("write config: %v", err) + } +} + +func withWorkingDir(t *testing.T, dir string) { + t.Helper() + original, err := os.Getwd() + if err != nil { + t.Fatalf("getwd: %v", err) + } + if err := os.Chdir(dir); err != nil { + t.Fatalf("chdir to %s: %v", dir, err) + } + t.Cleanup(func() { + _ = os.Chdir(original) + }) +} + +func TestFindPhaseConfig_InCurrentDir(t *testing.T) { + base := t.TempDir() + writePhaseConfigFile(t, base, false) + withWorkingDir(t, base) + + cfg := FindPhaseConfig(8) + if cfg == nil { + t.Fatal("expected config, got nil") + } + if cfg.PhaseApp != "TestApp" { + t.Fatalf("unexpected phase app: %s", cfg.PhaseApp) + } +} + +func TestFindPhaseConfig_InParentWithMonorepoSupport(t *testing.T) { + base := t.TempDir() + parent := filepath.Join(base, "parent") + grandchild := filepath.Join(parent, "child", "grandchild") + if err := os.MkdirAll(grandchild, 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + + writePhaseConfigFile(t, parent, true) + withWorkingDir(t, grandchild) + + cfg := FindPhaseConfig(8) + if cfg == nil { + t.Fatal("expected parent config, got nil") + } + if cfg.AppID != "00000000-0000-0000-0000-000000000000" { + t.Fatalf("unexpected app id: %s", cfg.AppID) + } +} + +func TestFindPhaseConfig_InParentWithoutMonorepoSupport(t *testing.T) { + base := t.TempDir() + parent := filepath.Join(base, "parent") + grandchild := filepath.Join(parent, "child", "grandchild") + if err := os.MkdirAll(grandchild, 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + + writePhaseConfigFile(t, parent, false) + withWorkingDir(t, grandchild) + + cfg := FindPhaseConfig(8) + if cfg != nil { + t.Fatalf("expected nil config, got %+v", cfg) + } +} + +func TestFindPhaseConfig_RespectsMaxDepthAndEnvOverride(t *testing.T) { + base := t.TempDir() + parent := filepath.Join(base, "parent") + grandchild := filepath.Join(parent, "child", "grandchild") + if err := os.MkdirAll(grandchild, 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + + writePhaseConfigFile(t, parent, true) + withWorkingDir(t, grandchild) + + if cfg := FindPhaseConfig(1); cfg != nil { + t.Fatalf("expected nil with maxDepth=1, got %+v", cfg) + } + if cfg := FindPhaseConfig(2); cfg == nil { + t.Fatal("expected config with maxDepth=2") + } + + t.Setenv("PHASE_CONFIG_PARENT_DIR_SEARCH_DEPTH", "1") + if cfg := FindPhaseConfig(8); cfg != nil { + t.Fatalf("expected nil with env override depth=1, got %+v", cfg) + } +} + +func TestFindPhaseConfig_InvalidJSONAndNoConfig(t *testing.T) { + base := t.TempDir() + withWorkingDir(t, base) + + if cfg := FindPhaseConfig(8); cfg != nil { + t.Fatalf("expected nil with no config, got %+v", cfg) + } + + if err := os.WriteFile(filepath.Join(base, PhaseEnvConfig), []byte("{invalid"), 0o600); err != nil { + t.Fatalf("write invalid config: %v", err) + } + if cfg := FindPhaseConfig(8); cfg != nil { + t.Fatalf("expected nil with invalid json, got %+v", cfg) + } +} diff --git a/src/pkg/util/export_test.go b/src/pkg/util/export_test.go new file mode 100644 index 00000000..843b3589 --- /dev/null +++ b/src/pkg/util/export_test.go @@ -0,0 +1,177 @@ +package util + +import ( + "bytes" + "encoding/csv" + "encoding/json" + "encoding/xml" + "io" + "os" + "strings" + "testing" + + "gopkg.in/yaml.v3" +) + +var sampleSecrets = map[string]string{ + "AWS_SECRET_ACCESS_KEY": "abc/xyz", + "AWS_ACCESS_KEY_ID": "AKIA123", + "JWT_SECRET": "token.value", + "DB_PASSWORD": "pass%word", +} + +func captureStdout(t *testing.T, fn func()) string { + t.Helper() + original := os.Stdout + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("create pipe: %v", err) + } + os.Stdout = w + + fn() + + _ = w.Close() + os.Stdout = original + + var buf bytes.Buffer + if _, err := io.Copy(&buf, r); err != nil { + t.Fatalf("read stdout: %v", err) + } + _ = r.Close() + return buf.String() +} + +func parseKeyValueLines(t *testing.T, out string) map[string]string { + t.Helper() + parsed := map[string]string{} + for _, line := range strings.Split(strings.TrimSpace(out), "\n") { + if strings.TrimSpace(line) == "" { + continue + } + parts := strings.SplitN(line, "=", 2) + if len(parts) != 2 { + t.Fatalf("invalid key-value line: %q", line) + } + parsed[strings.TrimSpace(parts[0])] = strings.TrimSpace(parts[1]) + } + return parsed +} + +func TestExportJSON(t *testing.T) { + out := captureStdout(t, func() { ExportJSON(sampleSecrets) }) + + var got map[string]string + if err := json.Unmarshal([]byte(out), &got); err != nil { + t.Fatalf("unmarshal json output: %v", err) + } + if len(got) != len(sampleSecrets) { + t.Fatalf("unexpected key count: got %d want %d", len(got), len(sampleSecrets)) + } + for k, v := range sampleSecrets { + if got[k] != v { + t.Fatalf("mismatch for %s: got %q want %q", k, got[k], v) + } + } +} + +func TestExportCSV(t *testing.T) { + out := captureStdout(t, func() { ExportCSV(sampleSecrets) }) + + reader := csv.NewReader(strings.NewReader(out)) + records, err := reader.ReadAll() + if err != nil { + t.Fatalf("read csv: %v", err) + } + if len(records) < 1 || len(records[0]) != 2 || records[0][0] != "Key" || records[0][1] != "Value" { + t.Fatalf("unexpected csv header: %#v", records) + } + + got := map[string]string{} + for _, row := range records[1:] { + if len(row) != 2 { + t.Fatalf("unexpected csv row width: %#v", row) + } + got[row[0]] = row[1] + } + for k, v := range sampleSecrets { + if got[k] != v { + t.Fatalf("mismatch for %s: got %q want %q", k, got[k], v) + } + } +} + +func TestExportYAML(t *testing.T) { + out := captureStdout(t, func() { ExportYAML(sampleSecrets) }) + + var got map[string]string + if err := yaml.Unmarshal([]byte(out), &got); err != nil { + t.Fatalf("unmarshal yaml output: %v", err) + } + for k, v := range sampleSecrets { + if got[k] != v { + t.Fatalf("mismatch for %s: got %q want %q", k, got[k], v) + } + } +} + +type xmlSecrets struct { + Entries []struct { + Name string `xml:"name,attr"` + Value string `xml:",chardata"` + } `xml:"secret"` +} + +func TestExportXML(t *testing.T) { + out := captureStdout(t, func() { ExportXML(sampleSecrets) }) + + var parsed xmlSecrets + if err := xml.Unmarshal([]byte(out), &parsed); err != nil { + t.Fatalf("unmarshal xml output: %v", err) + } + got := map[string]string{} + for _, e := range parsed.Entries { + got[e.Name] = e.Value + } + for k, v := range sampleSecrets { + if got[k] != v { + t.Fatalf("mismatch for %s: got %q want %q", k, got[k], v) + } + } +} + +func TestExportDotenvAndKVLikeFormats(t *testing.T) { + dotenvOut := captureStdout(t, func() { ExportDotenv(sampleSecrets) }) + dotenv := parseKeyValueLines(t, dotenvOut) + for k, v := range sampleSecrets { + if dotenv[k] != `"`+v+`"` { + t.Fatalf("dotenv mismatch for %s: got %q want %q", k, dotenv[k], `"`+v+`"`) + } + } + + kvOut := captureStdout(t, func() { ExportKV(sampleSecrets) }) + kv := parseKeyValueLines(t, kvOut) + for k, v := range sampleSecrets { + if kv[k] != v { + t.Fatalf("kv mismatch for %s: got %q want %q", k, kv[k], v) + } + } + + javaOut := captureStdout(t, func() { ExportJavaProperties(sampleSecrets) }) + javaProps := parseKeyValueLines(t, javaOut) + for k, v := range sampleSecrets { + if javaProps[k] != v { + t.Fatalf("java properties mismatch for %s: got %q want %q", k, javaProps[k], v) + } + } +} + +func TestExportINI_EscapesPercent(t *testing.T) { + out := captureStdout(t, func() { ExportINI(sampleSecrets) }) + if !strings.HasPrefix(out, "[DEFAULT]\n") { + t.Fatalf("expected ini [DEFAULT] header, got %q", out) + } + if !strings.Contains(out, "DB_PASSWORD = pass%%word") { + t.Fatalf("expected escaped percent in ini output, got %q", out) + } +} diff --git a/src/pkg/util/misc_test.go b/src/pkg/util/misc_test.go new file mode 100644 index 00000000..ddc5bc45 --- /dev/null +++ b/src/pkg/util/misc_test.go @@ -0,0 +1,64 @@ +package util + +import ( + "os" + "path/filepath" + "testing" +) + +func TestParseBoolFlag(t *testing.T) { + falseCases := []string{"false", "FALSE", "no", "0", " no "} + for _, tc := range falseCases { + if ParseBoolFlag(tc) { + t.Fatalf("expected false for %q", tc) + } + } + + trueCases := []string{"true", "yes", "1", "", "random"} + for _, tc := range trueCases { + if !ParseBoolFlag(tc) { + t.Fatalf("expected true for %q", tc) + } + } +} + +func TestParseEnvFile(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, ".env") + content := `# comment +FOO=bar +lower_case = "quoted value" +SINGLE='abc' +NO_EQUALS +SPACED = value with spaces +` + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatalf("write env file: %v", err) + } + + pairs, err := ParseEnvFile(path) + if err != nil { + t.Fatalf("parse env file: %v", err) + } + + got := map[string]string{} + for _, p := range pairs { + got[p.Key] = p.Value + } + + want := map[string]string{ + "FOO": "bar", + "LOWER_CASE": "quoted value", + "SINGLE": "abc", + "SPACED": "value with spaces", + } + + if len(got) != len(want) { + t.Fatalf("unexpected key count: got %d want %d (%v)", len(got), len(want), got) + } + for k, v := range want { + if got[k] != v { + t.Fatalf("mismatch for %s: got %q want %q", k, got[k], v) + } + } +} From fa8859aaddf6366b1cf912ed6a280c51f3228067 Mon Sep 17 00:00:00 2001 From: Nimish Date: Wed, 25 Feb 2026 20:42:46 +0530 Subject: [PATCH 33/50] refactor: remove MCP logic (moved to feat--mcp branch) --- src/cmd/mcp.go | 23 -- src/cmd/mcp_install.go | 51 ---- src/cmd/mcp_serve.go | 27 -- src/cmd/mcp_uninstall.go | 47 --- src/pkg/mcp/install.go | 263 ----------------- src/pkg/mcp/safety.go | 159 ----------- src/pkg/mcp/server.go | 153 ---------- src/pkg/mcp/tools.go | 599 --------------------------------------- 8 files changed, 1322 deletions(-) delete mode 100644 src/cmd/mcp.go delete mode 100644 src/cmd/mcp_install.go delete mode 100644 src/cmd/mcp_serve.go delete mode 100644 src/cmd/mcp_uninstall.go delete mode 100644 src/pkg/mcp/install.go delete mode 100644 src/pkg/mcp/safety.go delete mode 100644 src/pkg/mcp/server.go delete mode 100644 src/pkg/mcp/tools.go diff --git a/src/cmd/mcp.go b/src/cmd/mcp.go deleted file mode 100644 index 05c30d0e..00000000 --- a/src/cmd/mcp.go +++ /dev/null @@ -1,23 +0,0 @@ -package cmd - -import ( - "github.com/spf13/cobra" -) - -var mcpCmd = &cobra.Command{ - Use: "mcp", - Short: "🤖 Model Context Protocol (MCP) server for AI assistants (BETA)", - Long: `🤖 Model Context Protocol (MCP) server for AI assistants (BETA) - -Allows AI assistants like Claude Code, Cursor, VS Code Copilot, Zed, and OpenCode -to securely manage Phase secrets via the MCP protocol. - -Subcommands: - serve Start the MCP stdio server - install Install Phase MCP for an AI client - uninstall Uninstall Phase MCP from an AI client`, -} - -func init() { - rootCmd.AddCommand(mcpCmd) -} diff --git a/src/cmd/mcp_install.go b/src/cmd/mcp_install.go deleted file mode 100644 index 3bd409f3..00000000 --- a/src/cmd/mcp_install.go +++ /dev/null @@ -1,51 +0,0 @@ -package cmd - -import ( - "fmt" - "strings" - - phasemcp "github.com/phasehq/cli/pkg/mcp" - "github.com/spf13/cobra" -) - -var mcpInstallCmd = &cobra.Command{ - Use: "install [client]", - Short: "📦 Install Phase MCP server for AI clients", - Long: fmt.Sprintf(`📦 Install Phase MCP server configuration for AI clients. - -If no client is specified, installs for all detected clients. - -Supported clients: %s - -Examples: - phase mcp install # Install for all detected clients - phase mcp install claude-code # Install for Claude Code only - phase mcp install cursor --scope project # Install in project scope`, strings.Join(phasemcp.SupportedClientNames(), ", ")), - Args: cobra.MaximumNArgs(1), - RunE: runMCPInstall, -} - -func init() { - mcpInstallCmd.Flags().String("scope", "user", "Installation scope: user or project") - mcpCmd.AddCommand(mcpInstallCmd) -} - -func runMCPInstall(cmd *cobra.Command, args []string) error { - scope, _ := cmd.Flags().GetString("scope") - - var client string - if len(args) > 0 { - client = args[0] - } - - if err := phasemcp.Install(client, scope); err != nil { - return err - } - - if client != "" { - fmt.Printf("✅ Phase MCP server installed for %s (scope: %s).\n", client, scope) - } else { - fmt.Println("✅ Phase MCP server installed for all detected clients.") - } - return nil -} diff --git a/src/cmd/mcp_serve.go b/src/cmd/mcp_serve.go deleted file mode 100644 index 752f0eb4..00000000 --- a/src/cmd/mcp_serve.go +++ /dev/null @@ -1,27 +0,0 @@ -package cmd - -import ( - phasemcp "github.com/phasehq/cli/pkg/mcp" - "github.com/spf13/cobra" -) - -var mcpServeCmd = &cobra.Command{ - Use: "serve", - Short: "🚀 Start the Phase MCP server (stdio transport)", - Long: `🚀 Start the Phase MCP server using stdio transport. - -This command is typically invoked by AI clients (Claude Code, Cursor, etc.) -and communicates via stdin/stdout using the MCP JSON-RPC protocol. - -Requires either PHASE_SERVICE_TOKEN environment variable or an authenticated -user session (via 'phase auth').`, - RunE: runMCPServe, -} - -func init() { - mcpCmd.AddCommand(mcpServeCmd) -} - -func runMCPServe(cmd *cobra.Command, args []string) error { - return phasemcp.RunServer(cmd.Context()) -} diff --git a/src/cmd/mcp_uninstall.go b/src/cmd/mcp_uninstall.go deleted file mode 100644 index 3df9a767..00000000 --- a/src/cmd/mcp_uninstall.go +++ /dev/null @@ -1,47 +0,0 @@ -package cmd - -import ( - "fmt" - "strings" - - phasemcp "github.com/phasehq/cli/pkg/mcp" - "github.com/spf13/cobra" -) - -var mcpUninstallCmd = &cobra.Command{ - Use: "uninstall [client]", - Short: "🗑️\u200A Uninstall Phase MCP server from AI clients", - Long: fmt.Sprintf(`🗑️ Uninstall Phase MCP server configuration from AI clients. - -If no client is specified, uninstalls from all clients. - -Supported clients: %s - -Examples: - phase mcp uninstall # Uninstall from all clients - phase mcp uninstall claude-code # Uninstall from Claude Code only`, strings.Join(phasemcp.SupportedClientNames(), ", ")), - Args: cobra.MaximumNArgs(1), - RunE: runMCPUninstall, -} - -func init() { - mcpCmd.AddCommand(mcpUninstallCmd) -} - -func runMCPUninstall(cmd *cobra.Command, args []string) error { - var client string - if len(args) > 0 { - client = args[0] - } - - if err := phasemcp.Uninstall(client); err != nil { - return err - } - - if client != "" { - fmt.Printf("✅ Phase MCP server uninstalled from %s.\n", client) - } else { - fmt.Println("✅ Phase MCP server uninstalled from all clients.") - } - return nil -} diff --git a/src/pkg/mcp/install.go b/src/pkg/mcp/install.go deleted file mode 100644 index 79286182..00000000 --- a/src/pkg/mcp/install.go +++ /dev/null @@ -1,263 +0,0 @@ -package mcp - -import ( - "encoding/json" - "fmt" - "os" - "path/filepath" - "runtime" - "strings" -) - -type clientConfig struct { - UserConfigPath string - ProjectConfigPath string - JSONKey string - ServerConfig map[string]interface{} -} - -var supportedClients = map[string]clientConfig{ - "claude-code": { - UserConfigPath: filepath.Join(homeDir(), ".claude.json"), - ProjectConfigPath: ".mcp.json", - JSONKey: "mcpServers", - ServerConfig: map[string]interface{}{ - "command": "phase", - "args": []interface{}{"mcp", "serve"}, - }, - }, - "cursor": { - UserConfigPath: filepath.Join(homeDir(), ".cursor", "mcp.json"), - ProjectConfigPath: filepath.Join(".cursor", "mcp.json"), - JSONKey: "mcpServers", - ServerConfig: map[string]interface{}{ - "command": "phase", - "args": []interface{}{"mcp", "serve"}, - }, - }, - "vscode": { - UserConfigPath: filepath.Join(homeDir(), ".vscode", "mcp.json"), - ProjectConfigPath: filepath.Join(".vscode", "mcp.json"), - JSONKey: "servers", - ServerConfig: map[string]interface{}{ - "type": "stdio", - "command": "phase", - "args": []interface{}{"mcp", "serve"}, - }, - }, - "zed": { - UserConfigPath: filepath.Join(zedConfigDir(), "settings.json"), - ProjectConfigPath: filepath.Join(".zed", "settings.json"), - JSONKey: "context_servers", - ServerConfig: map[string]interface{}{ - "command": "phase", - "args": []interface{}{"mcp", "serve"}, - }, - }, - "opencode": { - UserConfigPath: filepath.Join(opencodeConfigDir(), "opencode.json"), - ProjectConfigPath: "opencode.json", - JSONKey: "mcp", - ServerConfig: map[string]interface{}{ - "type": "local", - "command": []interface{}{"phase", "mcp", "serve"}, - "enabled": true, - }, - }, -} - -func homeDir() string { - home, _ := os.UserHomeDir() - return home -} - -func zedConfigDir() string { - if runtime.GOOS == "darwin" { - return filepath.Join(homeDir(), ".config", "zed") - } - if xdg := os.Getenv("XDG_CONFIG_HOME"); xdg != "" { - return filepath.Join(xdg, "zed") - } - return filepath.Join(homeDir(), ".config", "zed") -} - -func opencodeConfigDir() string { - if xdg := os.Getenv("XDG_CONFIG_HOME"); xdg != "" { - return filepath.Join(xdg, "opencode") - } - return filepath.Join(homeDir(), ".config", "opencode") -} - -// SupportedClientNames returns the list of supported client names. -func SupportedClientNames() []string { - return []string{"claude-code", "cursor", "vscode", "zed", "opencode"} -} - -// DetectInstalledClients checks which AI client config directories exist. -func DetectInstalledClients() []string { - var detected []string - checks := map[string][]string{ - "claude-code": {filepath.Join(homeDir(), ".claude")}, - "cursor": {filepath.Join(homeDir(), ".cursor")}, - "vscode": {filepath.Join(homeDir(), ".vscode")}, - "zed": {zedConfigDir()}, - "opencode": {opencodeConfigDir()}, - } - for name, paths := range checks { - for _, p := range paths { - if info, err := os.Stat(p); err == nil && info.IsDir() { - detected = append(detected, name) - break - } - } - } - return detected -} - -// Install adds Phase MCP server config for the specified client (or all detected clients). -func Install(client, scope string) error { - if client != "" { - return InstallForClient(client, scope) - } - - detected := DetectInstalledClients() - if len(detected) == 0 { - return fmt.Errorf("no supported AI clients detected. Supported clients: %s", strings.Join(SupportedClientNames(), ", ")) - } - - var errors []string - for _, c := range detected { - if err := InstallForClient(c, scope); err != nil { - errors = append(errors, fmt.Sprintf("%s: %v", c, err)) - } else { - fmt.Fprintf(os.Stderr, "Installed Phase MCP server for %s\n", c) - } - } - if len(errors) > 0 { - return fmt.Errorf("some installations failed:\n%s", strings.Join(errors, "\n")) - } - return nil -} - -// Uninstall removes Phase MCP server config from the specified client (or all). -func Uninstall(client string) error { - if client != "" { - return UninstallForClient(client) - } - - var errors []string - for _, name := range SupportedClientNames() { - if err := UninstallForClient(name); err != nil { - errors = append(errors, fmt.Sprintf("%s: %v", name, err)) - } - } - if len(errors) > 0 { - return fmt.Errorf("some uninstalls failed:\n%s", strings.Join(errors, "\n")) - } - return nil -} - -// InstallForClient adds Phase MCP server to a specific client's config. -func InstallForClient(client, scope string) error { - cfg, ok := supportedClients[client] - if !ok { - return fmt.Errorf("unsupported client: %s. Supported: %s", client, strings.Join(SupportedClientNames(), ", ")) - } - - var configPath string - switch scope { - case "project": - configPath = cfg.ProjectConfigPath - default: - configPath = cfg.UserConfigPath - } - - return addToConfig(configPath, cfg.JSONKey, cfg.ServerConfig) -} - -// UninstallForClient removes Phase MCP server from a specific client's config (both scopes). -func UninstallForClient(client string) error { - cfg, ok := supportedClients[client] - if !ok { - return fmt.Errorf("unsupported client: %s. Supported: %s", client, strings.Join(SupportedClientNames(), ", ")) - } - - var errors []string - for _, path := range []string{cfg.UserConfigPath, cfg.ProjectConfigPath} { - if err := removeFromConfig(path, cfg.JSONKey); err != nil { - if !os.IsNotExist(err) { - errors = append(errors, err.Error()) - } - } - } - if len(errors) > 0 { - return fmt.Errorf("%s", strings.Join(errors, "; ")) - } - return nil -} - -func addToConfig(configPath, jsonKey string, serverConfig map[string]interface{}) error { - // Create parent directories - dir := filepath.Dir(configPath) - if err := os.MkdirAll(dir, 0700); err != nil { - return fmt.Errorf("failed to create directory %s: %w", dir, err) - } - - // Read existing config or start with empty object - config := map[string]interface{}{} - data, err := os.ReadFile(configPath) - if err == nil { - if err := json.Unmarshal(data, &config); err != nil { - return fmt.Errorf("failed to parse %s: %w", configPath, err) - } - } - - // Get or create the servers section - servers, ok := config[jsonKey].(map[string]interface{}) - if !ok { - servers = map[string]interface{}{} - } - - // Add/update phase entry - servers["phase"] = serverConfig - config[jsonKey] = servers - - // Write back - out, err := json.MarshalIndent(config, "", " ") - if err != nil { - return fmt.Errorf("failed to marshal config: %w", err) - } - - return os.WriteFile(configPath, out, 0600) -} - -func removeFromConfig(configPath, jsonKey string) error { - data, err := os.ReadFile(configPath) - if err != nil { - return err - } - - config := map[string]interface{}{} - if err := json.Unmarshal(data, &config); err != nil { - return fmt.Errorf("failed to parse %s: %w", configPath, err) - } - - servers, ok := config[jsonKey].(map[string]interface{}) - if !ok { - return nil // Nothing to remove - } - - if _, exists := servers["phase"]; !exists { - return nil // Already removed - } - - delete(servers, "phase") - config[jsonKey] = servers - - out, err := json.MarshalIndent(config, "", " ") - if err != nil { - return fmt.Errorf("failed to marshal config: %w", err) - } - - return os.WriteFile(configPath, out, 0600) -} diff --git a/src/pkg/mcp/safety.go b/src/pkg/mcp/safety.go deleted file mode 100644 index 131171e1..00000000 --- a/src/pkg/mcp/safety.go +++ /dev/null @@ -1,159 +0,0 @@ -package mcp - -import ( - "fmt" - "regexp" - "strings" -) - -// Blocked command patterns for phase_run tool -var ( - blockedPatterns = []string{ - "printenv", - "/usr/bin/env", - "declare -x", - "echo $", - "printf %s $", - "cat /proc", - "/proc/self/environ", - "xargs -0", - "eval", - "bash -c", - "sh -c", - "python -c", - "node -e", - "ruby -e", - "perl -e", - "php -r", - } - - blockedCommands = []string{ - "env", - "export", - "set", - } - - blockedRegexPatterns []*regexp.Regexp - sensitiveKeyPatterns []*regexp.Regexp - keyNamePattern *regexp.Regexp -) - -func init() { - regexes := []string{ - `\$[A-Za-z_][A-Za-z0-9_]*`, - `\$\{[^}]+\}`, - "`[^`]+`", - `\$\([^)]+\)`, - } - for _, r := range regexes { - blockedRegexPatterns = append(blockedRegexPatterns, regexp.MustCompile(r)) - } - - sensitivePatterns := []string{ - `(?i).*SECRET.*`, - `(?i).*PRIVATE[_.]?KEY.*`, - `(?i).*SIGNING[_.]?KEY.*`, - `(?i).*ENCRYPTION[_.]?KEY.*`, - `(?i).*HMAC.*`, - `(?i).*PASSWORD.*`, - `(?i).*PASSWD.*`, - `(?i).*TOKEN.*`, - `(?i).*API[_.]?KEY.*`, - `(?i).*ACCESS[_.]?KEY.*`, - `(?i).*AUTH[_.]?KEY.*`, - `(?i).*CREDENTIAL.*`, - `(?i).*CLIENT[_.]?SECRET.*`, - `(?i).*DATABASE[_.]?URL.*`, - `(?i).*CONNECTION[_.]?STRING.*`, - `(?i).*DSN$`, - `(?i).*CERTIFICATE.*`, - `(?i).*CERT[_.]?KEY.*`, - `(?i).*PEM$`, - `(?i).*WEBHOOK[_.]?SECRET.*`, - `(?i).*SALT$`, - `(?i).*HASH[_.]?KEY.*`, - `(?i).*SESSION[_.]?SECRET.*`, - `(?i).*COOKIE[_.]?SECRET.*`, - } - for _, p := range sensitivePatterns { - sensitiveKeyPatterns = append(sensitiveKeyPatterns, regexp.MustCompile("^"+p+"$")) - } - - keyNamePattern = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`) -} - -// ValidateRunCommand checks if a command is safe to execute. -// Returns nil if safe, error with reason if blocked. -func ValidateRunCommand(command string) error { - cmd := strings.TrimSpace(command) - if cmd == "" { - return fmt.Errorf("empty command") - } - - // Check blocked substrings - lower := strings.ToLower(cmd) - for _, p := range blockedPatterns { - if strings.Contains(lower, strings.ToLower(p)) { - return fmt.Errorf("command blocked: contains disallowed pattern '%s'", p) - } - } - - // Check standalone first-token commands - tokens := strings.Fields(cmd) - if len(tokens) > 0 { - first := strings.ToLower(tokens[0]) - for _, bc := range blockedCommands { - if first == bc { - return fmt.Errorf("command blocked: '%s' is not allowed as it may expose environment variables", bc) - } - } - } - - // Check regex patterns for variable expansion - for _, re := range blockedRegexPatterns { - if re.MatchString(cmd) { - return fmt.Errorf("command blocked: contains shell variable expansion or command substitution") - } - } - - return nil -} - -// SanitizeOutput truncates output and redacts credential-like patterns. -func SanitizeOutput(output string, maxLength int) string { - if maxLength <= 0 { - maxLength = 10000 - } - - result := output - if len(result) > maxLength { - result = result[:maxLength] + "\n... [output truncated]" - } - - return result -} - -// IsSensitiveKey returns true if the key name matches common sensitive key patterns. -func IsSensitiveKey(key string) bool { - for _, re := range sensitiveKeyPatterns { - if re.MatchString(key) { - return true - } - } - return false -} - -// IsSafeKeyName validates that a key name has a valid format. -// Returns (true, "") if valid, or (false, reason) if invalid. -func IsSafeKeyName(key string) (bool, string) { - if key == "" { - return false, "key name cannot be empty" - } - if len(key) > 256 { - return false, "key name exceeds maximum length of 256 characters" - } - if !keyNamePattern.MatchString(key) { - return false, "key name must match pattern: ^[A-Za-z_][A-Za-z0-9_]*$ (letters, digits, underscores; must start with letter or underscore)" - } - return true, "" -} diff --git a/src/pkg/mcp/server.go b/src/pkg/mcp/server.go deleted file mode 100644 index 7ff7e01f..00000000 --- a/src/pkg/mcp/server.go +++ /dev/null @@ -1,153 +0,0 @@ -package mcp - -import ( - "context" - "fmt" - "io" - "log" - "os" - "strings" - - gomcp "github.com/modelcontextprotocol/go-sdk/mcp" -) - -const ServerInstructions = `# Phase Secrets Manager - MCP Server - -## Security Rules (MANDATORY) -1. NEVER display, log, or return secret VALUES in any response -2. NEVER store secret values in variables, files, or conversation context -3. NEVER use secret values in code suggestions or examples -4. When users need secrets in their app, use phase_run to inject them at runtime -5. For sensitive keys (passwords, tokens, API keys), ALWAYS use phase_secrets_create with random generation -6. NEVER use phase_secrets_set or phase_secrets_update for sensitive values - -## Workflow -1. Check auth status with phase_auth_status -2. List secrets with phase_secrets_list to see what exists -3. Create new secrets with phase_secrets_create (generates secure random values) -4. Use phase_run to execute commands with secrets injected as environment variables -5. Use phase_secrets_get to check metadata about a specific secret - -## Key Naming Convention -- Use UPPER_SNAKE_CASE for all secret keys -- Examples: DATABASE_URL, API_KEY, JWT_SECRET - -## Common Patterns -- Need a database password? → phase_secrets_create with key=DB_PASSWORD -- Need to run migrations? → phase_run with command="npm run migrate" -- Need to check what secrets exist? → phase_secrets_list -- Importing from .env? → phase_secrets_import` - -// CheckAuth verifies that Phase credentials are available. -func CheckAuth() error { - if token := os.Getenv("PHASE_SERVICE_TOKEN"); token != "" { - return nil - } - return checkAuthAvailable() -} - -// NewMCPServer creates and configures the Phase MCP server with all tools. -func NewMCPServer() *gomcp.Server { - server := gomcp.NewServer( - &gomcp.Implementation{ - Name: "phase", - Version: "2.0.0", - }, - &gomcp.ServerOptions{ - Instructions: ServerInstructions, - }, - ) - - // Tool 1: Auth status - gomcp.AddTool(server, &gomcp.Tool{ - Name: "phase_auth_status", - Description: "Check Phase authentication status and display current user/token info.", - }, handleAuthStatus) - - // Tool 2: List secrets - gomcp.AddTool(server, &gomcp.Tool{ - Name: "phase_secrets_list", - Description: "List all secrets in an environment. Returns metadata only (keys, paths, tags, comments) — values are never exposed for security.", - }, handleSecretsList) - - // Tool 3: Create secret with random value - gomcp.AddTool(server, &gomcp.Tool{ - Name: "phase_secrets_create", - Description: "Create a new secret with a securely generated random value. " + - "Use this for ALL sensitive values (passwords, tokens, API keys, signing keys). " + - "The generated value is stored securely and NEVER returned in the response. " + - "Supported random types: hex, alphanumeric, base64, base64url, key128, key256.", - }, handleSecretsCreate) - - // Tool 4: Set secret with explicit value - gomcp.AddTool(server, &gomcp.Tool{ - Name: "phase_secrets_set", - Description: "Set a secret with an explicit value. " + - "ONLY for non-sensitive configuration values (e.g., APP_NAME, LOG_LEVEL, REGION). " + - "BLOCKED for sensitive keys matching patterns like *SECRET*, *PASSWORD*, *TOKEN*, *API_KEY*, etc. " + - "For sensitive values, use phase_secrets_create instead.", - }, handleSecretsSet) - - // Tool 5: Update secret - gomcp.AddTool(server, &gomcp.Tool{ - Name: "phase_secrets_update", - Description: "Update an existing secret's value. " + - "BLOCKED for sensitive keys matching patterns like *SECRET*, *PASSWORD*, *TOKEN*, *API_KEY*, etc. " + - "For sensitive values, use phase_secrets_create to rotate with a new random value.", - }, handleSecretsUpdate) - - // Tool 6: Delete secrets - gomcp.AddTool(server, &gomcp.Tool{ - Name: "phase_secrets_delete", - Description: "Delete one or more secrets by key name. Keys are automatically uppercased.", - }, handleSecretsDelete) - - // Tool 7: Import secrets from .env file - gomcp.AddTool(server, &gomcp.Tool{ - Name: "phase_secrets_import", - Description: "Import secrets from a .env file into Phase. " + - "Parses KEY=VALUE pairs and encrypts them. Values are never returned in the response.", - }, handleSecretsImport) - - // Tool 8: Get secret metadata - gomcp.AddTool(server, &gomcp.Tool{ - Name: "phase_secrets_get", - Description: "Get metadata about a specific secret (key, path, tags, comment, environment). " + - "The secret VALUE is never returned for security.", - }, handleSecretsGet) - - // Tool 9: Run command with secrets - gomcp.AddTool(server, &gomcp.Tool{ - Name: "phase_run", - Description: "Execute a shell command with Phase secrets injected as environment variables. " + - "Commands are validated for safety — shell variable expansion, env dumping commands, and code injection are blocked. " + - "Output is sanitized and truncated. 5-minute timeout.", - }, handleRun) - - // Tool 10: Initialize project - gomcp.AddTool(server, &gomcp.Tool{ - Name: "phase_init", - Description: "Initialize a Phase project by linking it to an application. " + - "Creates a .phase.json config file with the app ID and default environment.", - }, handleInit) - - return server -} - -// RunServer starts the MCP server on stdio transport. -func RunServer(ctx context.Context) error { - // Redirect all log output to stderr — stdout is reserved for MCP protocol - log.SetOutput(os.Stderr) - - if err := CheckAuth(); err != nil { - fmt.Fprintf(os.Stderr, "Warning: Phase authentication not configured. Set PHASE_SERVICE_TOKEN or run 'phase auth'.\n") - } - - server := NewMCPServer() - err := server.Run(ctx, &gomcp.StdioTransport{}) - // EOF on stdin is normal — it means the client disconnected - if err != nil && (err == io.EOF || strings.Contains(err.Error(), "EOF")) { - return nil - } - return err -} diff --git a/src/pkg/mcp/tools.go b/src/pkg/mcp/tools.go deleted file mode 100644 index ef857145..00000000 --- a/src/pkg/mcp/tools.go +++ /dev/null @@ -1,599 +0,0 @@ -package mcp - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "os" - "os/exec" - "strings" - "time" - - gomcp "github.com/modelcontextprotocol/go-sdk/mcp" - "github.com/phasehq/cli/pkg/config" - "github.com/phasehq/cli/pkg/keyring" - "github.com/phasehq/cli/pkg/phase" - "github.com/phasehq/cli/pkg/util" - sdk "github.com/phasehq/golang-sdk/phase" - "github.com/phasehq/golang-sdk/phase/misc" -) - -type SecretMetadata struct { - Key string `json:"key"` - Path string `json:"path"` - Tags []string `json:"tags"` - Comment string `json:"comment"` - Environment string `json:"environment"` - Application string `json:"application"` - Overridden bool `json:"overridden"` -} - -func newPhaseClient() (*sdk.Phase, error) { - return phase.NewPhase(true, "", "") -} - -func textResult(msg string) *gomcp.CallToolResult { - return &gomcp.CallToolResult{ - Content: []gomcp.Content{ - &gomcp.TextContent{Text: msg}, - }, - } -} - -func errorResult(msg string) (*gomcp.CallToolResult, any, error) { - return &gomcp.CallToolResult{ - Content: []gomcp.Content{ - &gomcp.TextContent{Text: "Error: " + msg}, - }, - IsError: true, - }, nil, nil -} - -// --- Tool argument types --- - -type AuthStatusArgs struct{} - -type SecretsListArgs struct { - Env string `json:"env,omitempty" jsonschema:"Environment name"` - App string `json:"app,omitempty" jsonschema:"Application name"` - AppID string `json:"app_id,omitempty" jsonschema:"Application ID"` - Path string `json:"path,omitempty" jsonschema:"Secret path (default: /)"` - Tags string `json:"tags,omitempty" jsonschema:"Filter by tags"` -} - -type SecretsCreateArgs struct { - Key string `json:"key" jsonschema:"Secret key name (uppercase, letters/digits/underscores)"` - Env string `json:"env,omitempty" jsonschema:"Environment name"` - App string `json:"app,omitempty" jsonschema:"Application name"` - AppID string `json:"app_id,omitempty" jsonschema:"Application ID"` - Path string `json:"path,omitempty" jsonschema:"Secret path (default: /)"` - RandomType string `json:"random_type,omitempty" jsonschema:"Random value type: hex, alphanumeric, base64, base64url, key128, key256"` - Length int `json:"length,omitempty" jsonschema:"Length for random secret (default: 32)"` -} - -type SecretsSetArgs struct { - Key string `json:"key" jsonschema:"Secret key name"` - Value string `json:"value" jsonschema:"Secret value to set"` - Env string `json:"env,omitempty" jsonschema:"Environment name"` - App string `json:"app,omitempty" jsonschema:"Application name"` - AppID string `json:"app_id,omitempty" jsonschema:"Application ID"` - Path string `json:"path,omitempty" jsonschema:"Secret path (default: /)"` -} - -type SecretsUpdateArgs struct { - Key string `json:"key" jsonschema:"Secret key name to update"` - Value string `json:"value" jsonschema:"New secret value"` - Env string `json:"env,omitempty" jsonschema:"Environment name"` - App string `json:"app,omitempty" jsonschema:"Application name"` - AppID string `json:"app_id,omitempty" jsonschema:"Application ID"` - Path string `json:"path,omitempty" jsonschema:"Secret path"` -} - -type SecretsDeleteArgs struct { - Keys []string `json:"keys" jsonschema:"List of secret key names to delete"` - Env string `json:"env,omitempty" jsonschema:"Environment name"` - App string `json:"app,omitempty" jsonschema:"Application name"` - AppID string `json:"app_id,omitempty" jsonschema:"Application ID"` - Path string `json:"path,omitempty" jsonschema:"Secret path"` -} - -type SecretsImportArgs struct { - FilePath string `json:"file_path" jsonschema:"Path to .env file to import"` - Env string `json:"env,omitempty" jsonschema:"Environment name"` - App string `json:"app,omitempty" jsonschema:"Application name"` - AppID string `json:"app_id,omitempty" jsonschema:"Application ID"` - Path string `json:"path,omitempty" jsonschema:"Secret path (default: /)"` -} - -type SecretsGetArgs struct { - Key string `json:"key" jsonschema:"Secret key name to fetch"` - Env string `json:"env,omitempty" jsonschema:"Environment name"` - App string `json:"app,omitempty" jsonschema:"Application name"` - AppID string `json:"app_id,omitempty" jsonschema:"Application ID"` - Path string `json:"path,omitempty" jsonschema:"Secret path (default: /)"` -} - -type RunArgs struct { - Command string `json:"command" jsonschema:"Shell command to execute with secrets injected"` - Env string `json:"env,omitempty" jsonschema:"Environment name"` - App string `json:"app,omitempty" jsonschema:"Application name"` - AppID string `json:"app_id,omitempty" jsonschema:"Application ID"` - Path string `json:"path,omitempty" jsonschema:"Secret path"` - Tags string `json:"tags,omitempty" jsonschema:"Filter by tags"` -} - -type InitArgs struct { - AppID string `json:"app_id" jsonschema:"Application ID to initialize with"` -} - -// --- Tool handlers --- - -func handleAuthStatus(_ context.Context, _ *gomcp.CallToolRequest, _ AuthStatusArgs) (*gomcp.CallToolResult, any, error) { - // Check service token first - if token := os.Getenv("PHASE_SERVICE_TOKEN"); token != "" { - host := os.Getenv("PHASE_HOST") - if host == "" { - host = config.PhaseCloudAPIHost - } - return textResult(fmt.Sprintf("Authenticated via PHASE_SERVICE_TOKEN\nHost: %s\nToken type: Service Token", host)), nil, nil - } - - // Check user config - user, err := config.GetDefaultUser() - if err != nil { - return errorResult("Not authenticated. Set PHASE_SERVICE_TOKEN or run 'phase auth'.") - } - - host, _ := config.GetDefaultUserHost() - info := fmt.Sprintf("Authenticated as user\nUser ID: %s\nEmail: %s\nHost: %s", user.ID, user.Email, host) - if user.OrganizationName != nil && *user.OrganizationName != "" { - info += fmt.Sprintf("\nOrganization: %s", *user.OrganizationName) - } - - return textResult(info), nil, nil -} - -func handleSecretsList(_ context.Context, _ *gomcp.CallToolRequest, args SecretsListArgs) (*gomcp.CallToolResult, any, error) { - p, err := newPhaseClient() - if err != nil { - return errorResult(fmt.Sprintf("Failed to initialize Phase client: %v", err)) - } - - app, env, appID := phase.GetConfig(args.App, args.Env, args.AppID) - - secrets, err := p.Get(sdk.GetOptions{ - EnvName: env, - AppName: app, - AppID: appID, - Path: args.Path, - Tag: args.Tags, - }) - if err != nil { - return errorResult(fmt.Sprintf("Failed to list secrets: %v", err)) - } - - metadata := make([]SecretMetadata, len(secrets)) - for i, s := range secrets { - metadata[i] = SecretMetadata{ - Key: s.Key, - Path: s.Path, - Tags: s.Tags, - Comment: s.Comment, - Environment: s.Environment, - Application: s.Application, - Overridden: s.Overridden, - } - } - - data, _ := json.MarshalIndent(metadata, "", " ") - return textResult(fmt.Sprintf("Found %d secrets (values hidden for security):\n%s", len(metadata), string(data))), nil, nil -} - -func handleSecretsCreate(_ context.Context, _ *gomcp.CallToolRequest, args SecretsCreateArgs) (*gomcp.CallToolResult, any, error) { - key := strings.ToUpper(strings.ReplaceAll(args.Key, " ", "_")) - - if ok, reason := IsSafeKeyName(key); !ok { - return errorResult(fmt.Sprintf("Invalid key name: %s", reason)) - } - - randomType := args.RandomType - if randomType == "" { - randomType = "hex" - } - - length := args.Length - if length <= 0 { - length = 32 - } - - value, err := misc.GenerateRandomSecret(randomType, length) - if err != nil { - return errorResult(fmt.Sprintf("Failed to generate random secret: %v", err)) - } - - p, err := newPhaseClient() - if err != nil { - return errorResult(fmt.Sprintf("Failed to initialize Phase client: %v", err)) - } - - path := args.Path - if path == "" { - path = "/" - } - - app, env, aID := phase.GetConfig(args.App, args.Env, args.AppID) - - err = p.Create(sdk.CreateOptions{ - KeyValuePairs: []sdk.KeyValuePair{{Key: key, Value: value}}, - EnvName: env, - AppName: app, - AppID: aID, - Path: path, - }) - if err != nil { - return errorResult(fmt.Sprintf("Failed to create secret: %v", err)) - } - - return textResult(fmt.Sprintf("Successfully created secret '%s' with a random %s value (value hidden for security).", key, randomType)), nil, nil -} - -func handleSecretsSet(_ context.Context, _ *gomcp.CallToolRequest, args SecretsSetArgs) (*gomcp.CallToolResult, any, error) { - key := strings.ToUpper(strings.ReplaceAll(args.Key, " ", "_")) - - if ok, reason := IsSafeKeyName(key); !ok { - return errorResult(fmt.Sprintf("Invalid key name: %s", reason)) - } - - if IsSensitiveKey(key) { - return errorResult(fmt.Sprintf( - "Cannot set '%s' directly — this key name matches a sensitive pattern (secrets, passwords, tokens, API keys, etc.). "+ - "For security, use 'phase_secrets_create' to generate a random value instead, which ensures the value is never exposed in conversation.", - key, - )) - } - - p, err := newPhaseClient() - if err != nil { - return errorResult(fmt.Sprintf("Failed to initialize Phase client: %v", err)) - } - - path := args.Path - if path == "" { - path = "/" - } - - app, env, aID := phase.GetConfig(args.App, args.Env, args.AppID) - - err = p.Create(sdk.CreateOptions{ - KeyValuePairs: []sdk.KeyValuePair{{Key: key, Value: args.Value}}, - EnvName: env, - AppName: app, - AppID: aID, - Path: path, - }) - if err != nil { - return errorResult(fmt.Sprintf("Failed to set secret: %v", err)) - } - - return textResult(fmt.Sprintf("Successfully set secret '%s'.", key)), nil, nil -} - -func handleSecretsUpdate(_ context.Context, _ *gomcp.CallToolRequest, args SecretsUpdateArgs) (*gomcp.CallToolResult, any, error) { - key := strings.ToUpper(strings.ReplaceAll(args.Key, " ", "_")) - - if IsSensitiveKey(key) { - return errorResult(fmt.Sprintf( - "Cannot update '%s' directly — this key name matches a sensitive pattern (secrets, passwords, tokens, API keys, etc.). "+ - "For security, use 'phase_secrets_create' to generate a random value instead.", - key, - )) - } - - p, err := newPhaseClient() - if err != nil { - return errorResult(fmt.Sprintf("Failed to initialize Phase client: %v", err)) - } - - app, env, aID := phase.GetConfig(args.App, args.Env, args.AppID) - - result, err := p.Update(sdk.UpdateOptions{ - EnvName: env, - AppName: app, - AppID: aID, - Key: key, - Value: args.Value, - SourcePath: args.Path, - }) - if err != nil { - return errorResult(fmt.Sprintf("Failed to update secret: %v", err)) - } - - if result == "Success" { - return textResult(fmt.Sprintf("Successfully updated secret '%s'.", key)), nil, nil - } - return textResult(result), nil, nil -} - -func handleSecretsDelete(_ context.Context, _ *gomcp.CallToolRequest, args SecretsDeleteArgs) (*gomcp.CallToolResult, any, error) { - if len(args.Keys) == 0 { - return errorResult("No keys specified for deletion.") - } - - // Uppercase all keys - keys := make([]string, len(args.Keys)) - for i, k := range args.Keys { - keys[i] = strings.ToUpper(k) - } - - p, err := newPhaseClient() - if err != nil { - return errorResult(fmt.Sprintf("Failed to initialize Phase client: %v", err)) - } - - app, env, aID := phase.GetConfig(args.App, args.Env, args.AppID) - - keysNotFound, err := p.Delete(sdk.DeleteOptions{ - EnvName: env, - AppName: app, - AppID: aID, - KeysToDelete: keys, - Path: args.Path, - }) - if err != nil { - return errorResult(fmt.Sprintf("Failed to delete secrets: %v", err)) - } - - deleted := len(keys) - len(keysNotFound) - msg := fmt.Sprintf("Deleted %d secret(s).", deleted) - if len(keysNotFound) > 0 { - msg += fmt.Sprintf(" Keys not found: %s", strings.Join(keysNotFound, ", ")) - } - return textResult(msg), nil, nil -} - -func handleSecretsImport(_ context.Context, _ *gomcp.CallToolRequest, args SecretsImportArgs) (*gomcp.CallToolResult, any, error) { - if args.FilePath == "" { - return errorResult("file_path is required.") - } - - pairs, err := util.ParseEnvFile(args.FilePath) - if err != nil { - return errorResult(fmt.Sprintf("Failed to read env file: %v", err)) - } - - if len(pairs) == 0 { - return textResult("No secrets found in the file."), nil, nil - } - - p, err := newPhaseClient() - if err != nil { - return errorResult(fmt.Sprintf("Failed to initialize Phase client: %v", err)) - } - - path := args.Path - if path == "" { - path = "/" - } - - app, env, aID := phase.GetConfig(args.App, args.Env, args.AppID) - - err = p.Create(sdk.CreateOptions{ - KeyValuePairs: pairs, - EnvName: env, - AppName: app, - AppID: aID, - Path: path, - }) - if err != nil { - return errorResult(fmt.Sprintf("Failed to import secrets: %v", err)) - } - - return textResult(fmt.Sprintf("Successfully imported %d secrets from %s (values hidden for security).", len(pairs), args.FilePath)), nil, nil -} - -func handleSecretsGet(_ context.Context, _ *gomcp.CallToolRequest, args SecretsGetArgs) (*gomcp.CallToolResult, any, error) { - key := strings.ToUpper(args.Key) - - p, err := newPhaseClient() - if err != nil { - return errorResult(fmt.Sprintf("Failed to initialize Phase client: %v", err)) - } - - app, env, aID := phase.GetConfig(args.App, args.Env, args.AppID) - - secrets, err := p.Get(sdk.GetOptions{ - EnvName: env, - AppName: app, - AppID: aID, - Keys: []string{key}, - Path: args.Path, - }) - if err != nil { - return errorResult(fmt.Sprintf("Failed to get secret: %v", err)) - } - - for _, s := range secrets { - if s.Key == key { - meta := SecretMetadata{ - Key: s.Key, - Path: s.Path, - Tags: s.Tags, - Comment: s.Comment, - Environment: s.Environment, - Application: s.Application, - Overridden: s.Overridden, - } - data, _ := json.MarshalIndent(meta, "", " ") - return textResult(fmt.Sprintf("Secret metadata (value hidden for security):\n%s", string(data))), nil, nil - } - } - - return textResult(fmt.Sprintf("Secret '%s' not found.", key)), nil, nil -} - -func handleRun(ctx context.Context, _ *gomcp.CallToolRequest, args RunArgs) (*gomcp.CallToolResult, any, error) { - if err := ValidateRunCommand(args.Command); err != nil { - return errorResult(fmt.Sprintf("Command validation failed: %v", err)) - } - - p, err := newPhaseClient() - if err != nil { - return errorResult(fmt.Sprintf("Failed to initialize Phase client: %v", err)) - } - - app, env, aID := phase.GetConfig(args.App, args.Env, args.AppID) - - secrets, err := p.Get(sdk.GetOptions{ - EnvName: env, - AppName: app, - AppID: aID, - Tag: args.Tags, - Path: args.Path, - }) - if err != nil { - return errorResult(fmt.Sprintf("Failed to fetch secrets: %v", err)) - } - - // Resolve references - resolvedSecrets := map[string]string{} - for _, secret := range secrets { - if secret.Value == "" { - continue - } - resolvedValue := sdk.ResolveAllSecrets(secret.Value, secrets, p, secret.Application, secret.Environment) - resolvedSecrets[secret.Key] = resolvedValue - } - - // Build environment: inherit current env and append secrets - envSlice := os.Environ() - for k, v := range resolvedSecrets { - envSlice = append(envSlice, fmt.Sprintf("%s=%s", k, v)) - } - - // Execute with 5 minute timeout - runCtx, cancel := context.WithTimeout(ctx, 5*time.Minute) - defer cancel() - - shell := util.GetDefaultShell() - var cmd *exec.Cmd - if shell != nil && len(shell) > 0 { - cmd = exec.CommandContext(runCtx, shell[0], "-c", args.Command) - } else { - cmd = exec.CommandContext(runCtx, "sh", "-c", args.Command) - } - cmd.Env = envSlice - - var stdout, stderr bytes.Buffer - cmd.Stdout = &stdout - cmd.Stderr = &stderr - - err = cmd.Run() - - output := stdout.String() - if stderr.Len() > 0 { - if output != "" { - output += "\n" - } - output += "STDERR:\n" + stderr.String() - } - - output = SanitizeOutput(output, 10000) - - if err != nil { - if runCtx.Err() == context.DeadlineExceeded { - return errorResult("Command timed out after 5 minutes.") - } - return textResult(fmt.Sprintf("Command exited with error: %v\n\nOutput:\n%s", err, output)), nil, nil - } - - return textResult(fmt.Sprintf("Command completed successfully.\n\nOutput:\n%s", output)), nil, nil -} - -func handleInit(_ context.Context, _ *gomcp.CallToolRequest, args InitArgs) (*gomcp.CallToolResult, any, error) { - if args.AppID == "" { - return errorResult("app_id is required.") - } - - p, err := newPhaseClient() - if err != nil { - return errorResult(fmt.Sprintf("Failed to initialize Phase client: %v", err)) - } - - data, err := phase.Init(p) - if err != nil { - return errorResult(fmt.Sprintf("Failed to fetch app data: %v", err)) - } - - // Find the app by ID - var selectedApp *struct { - Name string - ID string - Envs []string - } - for _, app := range data.Apps { - if app.ID == args.AppID { - var envs []string - for _, ek := range app.EnvironmentKeys { - envs = append(envs, ek.Environment.Name) - } - selectedApp = &struct { - Name string - ID string - Envs []string - }{Name: app.Name, ID: app.ID, Envs: envs} - break - } - } - - if selectedApp == nil { - return errorResult(fmt.Sprintf("Application with ID '%s' not found.", args.AppID)) - } - - if len(selectedApp.Envs) == 0 { - return errorResult(fmt.Sprintf("No environments found for application '%s'.", selectedApp.Name)) - } - - // Pick first environment as default - defaultEnv := selectedApp.Envs[0] - - // Find the env ID - var envID string - for _, app := range data.Apps { - if app.ID == args.AppID { - for _, ek := range app.EnvironmentKeys { - if ek.Environment.Name == defaultEnv { - envID = ek.Environment.ID - break - } - } - break - } - } - - phaseConfig := &config.PhaseJSONConfig{ - Version: "2", - PhaseApp: selectedApp.Name, - AppID: selectedApp.ID, - DefaultEnv: defaultEnv, - EnvID: envID, - } - - if err := config.WritePhaseConfig(phaseConfig); err != nil { - return errorResult(fmt.Sprintf("Failed to write .phase.json: %v", err)) - } - - os.Chmod(config.PhaseEnvConfig, 0600) - - return textResult(fmt.Sprintf( - "Initialized Phase project:\n Application: %s\n Default Environment: %s\n Available Environments: %s", - selectedApp.Name, defaultEnv, strings.Join(selectedApp.Envs, ", "), - )), nil, nil -} - -// checkAuthAvailable verifies that authentication credentials are available -// without importing the keyring package at the tool handler level. -func checkAuthAvailable() error { - _, err := keyring.GetCredentials() - return err -} From 40f2f817397f55cb77d2d40f1697b621fbaf020d Mon Sep 17 00:00:00 2001 From: Nimish Date: Sat, 28 Feb 2026 14:32:58 +0530 Subject: [PATCH 34/50] chore: remove mcp deps --- src/go.mod | 4 ---- src/go.sum | 14 -------------- 2 files changed, 18 deletions(-) diff --git a/src/go.mod b/src/go.mod index 90b50e8d..bccf8c2c 100644 --- a/src/go.mod +++ b/src/go.mod @@ -6,7 +6,6 @@ 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/modelcontextprotocol/go-sdk v1.2.0 github.com/phasehq/golang-sdk v0.0.0-00010101000000-000000000000 github.com/spf13/cobra v1.8.0 github.com/zalando/go-keyring v0.2.6 @@ -31,12 +30,9 @@ require ( github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e // indirect github.com/danieljoos/wincred v1.2.2 // indirect github.com/godbus/dbus/v5 v5.1.0 // indirect - github.com/google/jsonschema-go v0.3.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/spf13/pflag v1.0.5 // indirect - github.com/yosida95/uritemplate/v3 v3.0.2 // indirect golang.org/x/crypto v0.31.0 // indirect - golang.org/x/oauth2 v0.30.0 // indirect golang.org/x/sys v0.40.0 // indirect ) diff --git a/src/go.sum b/src/go.sum index b6ab2968..cf11f28a 100644 --- a/src/go.sum +++ b/src/go.sum @@ -41,20 +41,12 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= -github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= -github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= -github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= -github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= -github.com/google/jsonschema-go v0.3.0 h1:6AH2TxVNtk3IlvkkhjrtbUc4S8AvO0Xii0DxIygDg+Q= -github.com/google/jsonschema-go v0.3.0/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/manifoldco/promptui v0.9.0 h1:3V4HzJk1TtXW1MTZMP7mdlwbBpIinw3HztaIlYthEiA= github.com/manifoldco/promptui v0.9.0/go.mod h1:ka04sppxSGFAtxX0qhlYQjISsg9mR4GWtQEhdbn6Pgg= -github.com/modelcontextprotocol/go-sdk v1.2.0 h1:Y23co09300CEk8iZ/tMxIX1dVmKZkzoSBZOpJwUnc/s= -github.com/modelcontextprotocol/go-sdk v1.2.0/go.mod h1:6fM3LCm3yV7pAs8isnKLn07oKtB0MP9LHd3DfAcKw10= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= @@ -66,21 +58,15 @@ github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= -github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= -github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= github.com/zalando/go-keyring v0.2.6 h1:r7Yc3+H+Ux0+M72zacZoItR3UDxeWfKTcabvkI8ua9s= github.com/zalando/go-keyring v0.2.6/go.mod h1:2TCrxYrbUNYfNS/Kgy/LSrkSQzZ5UPVH85RwfczwvcI= golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= -golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= -golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY= golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww= -golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo= -golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= From 9bd8f5381c39bf90c158cd41100752dd5bac9fe7 Mon Sep 17 00:00:00 2001 From: Nimish Date: Sat, 28 Feb 2026 14:36:33 +0530 Subject: [PATCH 35/50] feat: improved error handling --- src/cmd/root.go | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/cmd/root.go b/src/cmd/root.go index 85392432..265cb76c 100644 --- a/src/cmd/root.go +++ b/src/cmd/root.go @@ -1,8 +1,10 @@ package cmd import ( + "fmt" "os" + phaseerrors "github.com/phasehq/cli/pkg/errors" "github.com/phasehq/cli/pkg/version" "github.com/spf13/cobra" ) @@ -23,13 +25,16 @@ const phaseASCii = ` const description = "Keep Secrets." var rootCmd = &cobra.Command{ - Use: "phase", - Short: description, - Long: description + "\n" + phaseASCii, + Use: "phase", + Short: description, + Long: description + "\n" + phaseASCii, + SilenceUsage: true, + SilenceErrors: true, } func Execute() { if err := rootCmd.Execute(); err != nil { + fmt.Fprintf(os.Stderr, "Error: %s\n", phaseerrors.FormatSDKError(err)) os.Exit(1) } } From 51db2705fd23b30028be598930be1af21f0c2d62 Mon Sep 17 00:00:00 2001 From: Nimish Date: Sat, 28 Feb 2026 14:39:14 +0530 Subject: [PATCH 36/50] feat: correctly pass paths and improve error handling --- src/cmd/secrets_create.go | 4 +++- src/cmd/secrets_delete.go | 9 +++++---- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/src/cmd/secrets_create.go b/src/cmd/secrets_create.go index 2c4a633e..d25d038a 100644 --- a/src/cmd/secrets_create.go +++ b/src/cmd/secrets_create.go @@ -110,6 +110,8 @@ func runSecretsCreate(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to create secret: %w", err) } - listSecrets(p, envName, appName, appID, "", path, false) + if err := listSecrets(p, envName, appName, appID, "", path, false, false, false, nil); err != nil { + return err + } return nil } diff --git a/src/cmd/secrets_delete.go b/src/cmd/secrets_delete.go index 8424580e..dfe1eb2e 100644 --- a/src/cmd/secrets_delete.go +++ b/src/cmd/secrets_delete.go @@ -21,7 +21,7 @@ func init() { secretsDeleteCmd.Flags().String("env", "", "Environment name") secretsDeleteCmd.Flags().String("app", "", "Application name") secretsDeleteCmd.Flags().String("app-id", "", "Application ID") - secretsDeleteCmd.Flags().String("path", "", "Path filter") + secretsDeleteCmd.Flags().String("path", "/", "Path filter (default '/'. Pass empty string to delete from all paths)") secretsCmd.AddCommand(secretsDeleteCmd) } @@ -59,8 +59,7 @@ func runSecretsDelete(cmd *cobra.Command, args []string) error { Path: path, }) if err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) - os.Exit(1) + return fmt.Errorf("failed to delete secrets: %w", err) } if len(keysNotFound) > 0 { @@ -69,6 +68,8 @@ func runSecretsDelete(cmd *cobra.Command, args []string) error { fmt.Println(util.BoldGreen("✅ Successfully deleted the secrets.")) } - listSecrets(p, envName, appName, appID, "", path, false) + if err := listSecrets(p, envName, appName, appID, "", path, false, false, false, nil); err != nil { + return err + } return nil } From 4d1edf16e5855058dc859a29b12161f08ac363bc Mon Sep 17 00:00:00 2001 From: Nimish Date: Sat, 28 Feb 2026 14:40:06 +0530 Subject: [PATCH 37/50] feat: pass the correct path, sorts the secrets being returned, dont log warining to sderr --- src/cmd/secrets_export.go | 36 ++++++++++++++++++++---------------- 1 file changed, 20 insertions(+), 16 deletions(-) diff --git a/src/cmd/secrets_export.go b/src/cmd/secrets_export.go index 7aef9c4d..d4e00f0d 100644 --- a/src/cmd/secrets_export.go +++ b/src/cmd/secrets_export.go @@ -22,7 +22,7 @@ func init() { secretsExportCmd.Flags().String("app", "", "Application name") secretsExportCmd.Flags().String("app-id", "", "Application ID") secretsExportCmd.Flags().String("tags", "", "Filter by tags") - secretsExportCmd.Flags().String("path", "", "Path filter") + secretsExportCmd.Flags().String("path", "/", "Path filter (default '/'. Pass empty string to export from all paths)") secretsExportCmd.Flags().String("generate-leases", "true", "Generate leases for dynamic secrets") secretsExportCmd.Flags().Int("lease-ttl", 0, "Lease TTL in seconds") secretsCmd.AddCommand(secretsExportCmd) @@ -63,40 +63,44 @@ func runSecretsExport(cmd *cobra.Command, args []string) error { return err } - // Resolve secret references and build key-value map - secretsDict := map[string]string{} + // Resolve secret references and build ordered key-value slice + var secretsList []util.KeyValue for _, secret := range allSecrets { if secret.Value == "" { continue } - resolvedValue := sdk.ResolveAllSecrets(secret.Value, allSecrets, p, secret.Application, secret.Environment) - secretsDict[secret.Key] = resolvedValue + resolvedValue, err := sdk.ResolveAllSecrets(secret.Value, allSecrets, p, secret.Application, secret.Environment) + if err != nil { + fmt.Fprintf(os.Stderr, "Warning: %v\n", err) + continue + } + secretsList = append(secretsList, util.KeyValue{Key: secret.Key, Value: resolvedValue}) } switch format { case "json": - util.ExportJSON(secretsDict) + util.ExportJSON(secretsList) case "csv": - util.ExportCSV(secretsDict) + util.ExportCSV(secretsList) case "yaml": - util.ExportYAML(secretsDict) + util.ExportYAML(secretsList) case "xml": - util.ExportXML(secretsDict) + util.ExportXML(secretsList) case "toml": - util.ExportTOML(secretsDict) + util.ExportTOML(secretsList) case "hcl": - util.ExportHCL(secretsDict) + util.ExportHCL(secretsList) case "ini": - util.ExportINI(secretsDict) + util.ExportINI(secretsList) case "java_properties": - util.ExportJavaProperties(secretsDict) + util.ExportJavaProperties(secretsList) case "kv": - util.ExportKV(secretsDict) + util.ExportKV(secretsList) case "dotenv": - util.ExportDotenv(secretsDict) + util.ExportDotenv(secretsList) default: fmt.Fprintf(os.Stderr, "Unknown format: %s, using dotenv\n", format) - util.ExportDotenv(secretsDict) + util.ExportDotenv(secretsList) } return nil From 03f6025cada1a8f75f4efca83d4102943a315aed Mon Sep 17 00:00:00 2001 From: Nimish Date: Sun, 1 Mar 2026 12:21:41 +0530 Subject: [PATCH 38/50] fix: emoji table alignment, restore run injection stats, and improve error handling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace ambiguous-width emoji (🏷️→🔖, ⛓️→🌐) with consistently 2-column alternatives to fix table border misalignment across terminals - Extract shared runeWidth() helper for DRY width calculation - Add full-width terminal table rendering with value wrapping - Restore secret injection stats output to stderr in run command - Add dynamic secret lease support to secrets list command - Add structured SDK error formatting in CLI error layer --- src/cmd/run.go | 19 ++-- src/cmd/secrets_get.go | 13 ++- src/cmd/secrets_list.go | 53 +++++++---- src/cmd/secrets_update.go | 4 +- src/cmd/shell.go | 5 +- src/pkg/config/phase_json.go | 2 +- src/pkg/display/tree.go | 176 +++++++++++++++++++++++++++-------- src/pkg/errors/errors.go | 54 +++++++++++ src/pkg/util/export.go | 106 ++++++++++++++------- 9 files changed, 327 insertions(+), 105 deletions(-) create mode 100644 src/pkg/errors/errors.go diff --git a/src/cmd/run.go b/src/cmd/run.go index 7896af99..ca06ff3c 100644 --- a/src/cmd/run.go +++ b/src/cmd/run.go @@ -75,16 +75,14 @@ func runRun(cmd *cobra.Command, args []string) error { if secret.Value == "" { continue } - resolvedValue := sdk.ResolveAllSecrets(secret.Value, allSecrets, p, secret.Application, secret.Environment) + resolvedValue, err := sdk.ResolveAllSecrets(secret.Value, allSecrets, p, secret.Application, secret.Environment) + if err != nil { + return err + } resolvedSecrets[secret.Key] = resolvedValue } - // Build environment: inherit current env and append secrets - envSlice := os.Environ() - for k, v := range resolvedSecrets { - envSlice = append(envSlice, fmt.Sprintf("%s=%s", k, v)) - } - + // Print injection stats to stderr (matches Python CLI behavior) secretCount := len(resolvedSecrets) apps := map[string]bool{} envs := map[string]bool{} @@ -96,7 +94,6 @@ func runRun(cmd *cobra.Command, args []string) error { envs[s.Environment] = true } } - appNames := mapKeys(apps) envNames := mapKeys(envs) @@ -113,6 +110,12 @@ func runRun(cmd *cobra.Command, args []string) error { util.BoldGreenErr(strings.Join(envNames, ", "))) } + // Build environment: inherit current env and append secrets + envSlice := os.Environ() + for k, v := range resolvedSecrets { + envSlice = append(envSlice, fmt.Sprintf("%s=%s", k, v)) + } + // Execute command command := strings.Join(args, " ") shell := util.GetDefaultShell() diff --git a/src/cmd/secrets_get.go b/src/cmd/secrets_get.go index 8aaed7dd..bf6147f0 100644 --- a/src/cmd/secrets_get.go +++ b/src/cmd/secrets_get.go @@ -3,7 +3,6 @@ package cmd import ( "encoding/json" "fmt" - "os" "strings" "github.com/phasehq/cli/pkg/phase" @@ -24,6 +23,7 @@ func init() { secretsGetCmd.Flags().String("app", "", "Application name") secretsGetCmd.Flags().String("app-id", "", "Application ID") secretsGetCmd.Flags().String("path", "/", "Path filter") + secretsGetCmd.Flags().String("tags", "", "Filter by tags") secretsGetCmd.Flags().String("generate-leases", "true", "Generate leases for dynamic secrets") secretsGetCmd.Flags().Int("lease-ttl", 0, "Lease TTL in seconds") secretsCmd.AddCommand(secretsGetCmd) @@ -35,6 +35,7 @@ func runSecretsGet(cmd *cobra.Command, args []string) error { appName, _ := cmd.Flags().GetString("app") appID, _ := cmd.Flags().GetString("app-id") path, _ := cmd.Flags().GetString("path") + tags, _ := cmd.Flags().GetString("tags") generateLeases, _ := cmd.Flags().GetString("generate-leases") leaseTTL, _ := cmd.Flags().GetInt("lease-ttl") @@ -42,8 +43,7 @@ func runSecretsGet(cmd *cobra.Command, args []string) error { p, err := phase.NewPhase(true, "", "") if err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) - os.Exit(1) + return err } opts := sdk.GetOptions{ @@ -51,6 +51,7 @@ func runSecretsGet(cmd *cobra.Command, args []string) error { AppName: appName, AppID: appID, Keys: []string{key}, + Tag: tags, Path: path, Dynamic: true, Lease: util.ParseBoolFlag(generateLeases), @@ -61,8 +62,7 @@ func runSecretsGet(cmd *cobra.Command, args []string) error { secrets, err := p.Get(opts) if err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) - os.Exit(1) + return err } var found *sdk.SecretResult @@ -74,8 +74,7 @@ func runSecretsGet(cmd *cobra.Command, args []string) error { } if found == nil { - fmt.Fprintf(os.Stderr, "🔍 Secret not found\n") - os.Exit(1) + return fmt.Errorf("🔍 Secret not found") } data, _ := json.MarshalIndent(found, "", " ") diff --git a/src/cmd/secrets_list.go b/src/cmd/secrets_list.go index 433bb821..81720f28 100644 --- a/src/cmd/secrets_list.go +++ b/src/cmd/secrets_list.go @@ -2,7 +2,6 @@ package cmd import ( "fmt" - "os" "github.com/phasehq/cli/pkg/display" "github.com/phasehq/cli/pkg/phase" @@ -18,8 +17,8 @@ var secretsListCmd = &cobra.Command{ Icon legend: 🔗 Secret references another secret in the same environment - ⛓️ Cross-environment reference (secret from another environment) - 🏷️ Tag associated with the secret + 🌐 Cross-environment reference (secret from another environment in the same or different application) + 🔖 Tag associated with the secret 💬 Comment associated with the secret 🔏 Personal secret override (visible only to you) ⚡️ Dynamic secret`, @@ -33,27 +32,34 @@ func init() { secretsListCmd.Flags().String("app-id", "", "Application ID") secretsListCmd.Flags().String("tags", "", "Filter by tags") secretsListCmd.Flags().String("path", "", "Path filter") + secretsListCmd.Flags().String("generate-leases", "", "Generate leases for dynamic secrets (defaults to value of --show)") + secretsListCmd.Flags().Int("lease-ttl", 0, "Lease TTL in seconds") secretsCmd.AddCommand(secretsListCmd) } // listSecrets fetches and displays secrets. Used by list, create, update, and delete commands. -func listSecrets(p *sdk.Phase, envName, appName, appID, tags, path string, show bool) { +func listSecrets(p *sdk.Phase, envName, appName, appID, tags, path string, show, dynamic, lease bool, leaseTTL *int) error { + opts := sdk.GetOptions{ + EnvName: envName, + AppName: appName, + AppID: appID, + Tag: tags, + Path: path, + Dynamic: dynamic, + Lease: lease, + LeaseTTL: leaseTTL, + } + spinner := util.NewSpinner("Fetching secrets...") spinner.Start() - secrets, err := p.Get(sdk.GetOptions{ - EnvName: envName, - AppName: appName, - AppID: appID, - Tag: tags, - Path: path, - }) + secrets, err := p.Get(opts) spinner.Stop() if err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) - return + return err } display.RenderSecretsTree(secrets, show) + return nil } func runSecretsList(cmd *cobra.Command, args []string) error { @@ -63,16 +69,31 @@ func runSecretsList(cmd *cobra.Command, args []string) error { appID, _ := cmd.Flags().GetString("app-id") tags, _ := cmd.Flags().GetString("tags") path, _ := cmd.Flags().GetString("path") + generateLeases, _ := cmd.Flags().GetString("generate-leases") + leaseTTL, _ := cmd.Flags().GetInt("lease-ttl") appName, envName, appID = phase.GetConfig(appName, envName, appID) p, err := phase.NewPhase(true, "", "") if err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) - os.Exit(1) + return err } - listSecrets(p, envName, appName, appID, tags, path, show) + // Match Python behavior: lease=show unless --generate-leases explicitly set + var lease bool + if cmd.Flags().Changed("generate-leases") { + lease = util.ParseBoolFlag(generateLeases) + } else { + lease = show + } + var leaseTTLPtr *int + if cmd.Flags().Changed("lease-ttl") { + leaseTTLPtr = &leaseTTL + } + + if err := listSecrets(p, envName, appName, appID, tags, path, show, true, lease, leaseTTLPtr); err != nil { + return err + } fmt.Println("🔬 To view a secret, use: phase secrets get ") if !show { diff --git a/src/cmd/secrets_update.go b/src/cmd/secrets_update.go index 81b1ffd7..5f80506b 100644 --- a/src/cmd/secrets_update.go +++ b/src/cmd/secrets_update.go @@ -120,7 +120,9 @@ func runSecretsUpdate(cmd *cobra.Command, args []string) error { if destPath != "" { listPath = destPath } - listSecrets(p, envName, appName, appID, "", listPath, false) + if err := listSecrets(p, envName, appName, appID, "", listPath, false, false, false, nil); err != nil { + return err + } } else { fmt.Println(result) } diff --git a/src/cmd/shell.go b/src/cmd/shell.go index 68662427..d4b79214 100644 --- a/src/cmd/shell.go +++ b/src/cmd/shell.go @@ -74,7 +74,10 @@ func runShell(cmd *cobra.Command, args []string) error { if secret.Value == "" { continue } - resolvedValue := sdk.ResolveAllSecrets(secret.Value, allSecrets, p, secret.Application, secret.Environment) + resolvedValue, err := sdk.ResolveAllSecrets(secret.Value, allSecrets, p, secret.Application, secret.Environment) + if err != nil { + return err + } resolvedSecrets[secret.Key] = resolvedValue } diff --git a/src/pkg/config/phase_json.go b/src/pkg/config/phase_json.go index a800ecc4..8d11b66d 100644 --- a/src/pkg/config/phase_json.go +++ b/src/pkg/config/phase_json.go @@ -13,7 +13,7 @@ type PhaseJSONConfig struct { AppID string `json:"appId"` DefaultEnv string `json:"defaultEnv"` EnvID string `json:"envId"` - MonorepoSupport bool `json:"monorepoSupport,omitempty"` + MonorepoSupport bool `json:"monorepoSupport"` } func FindPhaseConfig(maxDepth int) *PhaseJSONConfig { diff --git a/src/pkg/display/tree.go b/src/pkg/display/tree.go index 9f404836..90003530 100644 --- a/src/pkg/display/tree.go +++ b/src/pkg/display/tree.go @@ -6,9 +6,11 @@ import ( "regexp" "sort" "strings" + "unicode/utf8" "github.com/phasehq/cli/pkg/util" sdk "github.com/phasehq/golang-sdk/phase" + "golang.org/x/term" ) var ( @@ -18,10 +20,93 @@ var ( ) func getTerminalWidth() int { - // Simple approach - just return 80 for portability + if w, _, err := term.GetSize(int(os.Stdout.Fd())); err == nil && w > 0 { + return w + } return 80 } +// runeWidth returns the terminal column width of a single rune. +// Only emoji with East Asian Width "W" (Wide) are counted as 2 columns. +// Ambiguous-width characters (EAW=A/N) that need VS16 for emoji presentation +// are avoided in our display strings; we use only EAW=W emoji for indicators. +func runeWidth(r rune) int { + switch { + case r == '\uFE0F' || r == '\u200A' || r == '\u200B' || r == '\u200D': + return 0 // variation selectors, hair space, zero-width space, ZWJ + case r >= 0x1F000: + return 2 // Supplementary emoji (nearly all EAW=W) + case r >= 0x2600 && r <= 0x27BF: + return 2 // Misc Symbols & Dingbats (⚡ etc.) + default: + return 1 + } +} + +// displayWidth returns the visual terminal column width of s. +func displayWidth(s string) int { + w := 0 + for _, r := range s { + w += runeWidth(r) + } + return w +} + +// padRight pads s with spaces to fill exactly width display columns. +func padRight(s string, width int) string { + sw := displayWidth(s) + if sw >= width { + return s + } + return s + strings.Repeat(" ", width-sw) +} + +// truncateToWidth truncates s to fit within maxWidth display columns. +func truncateToWidth(s string, maxWidth int) string { + if displayWidth(s) <= maxWidth { + return s + } + var result []byte + w := 0 + for i := 0; i < len(s); { + r, size := utf8.DecodeRuneInString(s[i:]) + rw := runeWidth(r) + if w+rw > maxWidth-1 { + break + } + result = append(result, s[i:i+size]...) + w += rw + i += size + } + return string(result) + "…" +} + +// wrapToWidth splits s into lines that each fit within maxWidth display columns. +func wrapToWidth(s string, maxWidth int) []string { + if maxWidth <= 0 || displayWidth(s) <= maxWidth { + return []string{s} + } + var lines []string + var line []byte + w := 0 + for i := 0; i < len(s); { + r, size := utf8.DecodeRuneInString(s[i:]) + rw := runeWidth(r) + if rw > 0 && w+rw > maxWidth { + lines = append(lines, string(line)) + line = nil + w = 0 + } + line = append(line, s[i:i+size]...) + w += rw + i += size + } + if len(line) > 0 { + lines = append(lines, string(line)) + } + return lines +} + func censorSecret(secret string, maxLength int) string { if len(secret) <= 6 { return strings.Repeat("*", len(secret)) @@ -33,11 +118,11 @@ func censorSecret(secret string, maxLength int) string { return censored } -// renderSecretRow renders a single secret row into the table. -func renderSecretRow(pathPrefix string, s sdk.SecretResult, show bool, keyWidth, valueWidth int, bold, reset string) { +// renderSecretRow renders a single secret row. +func renderSecretRow(pathPrefix string, s sdk.SecretResult, show bool, keyWidth, valueWidth int) { keyDisplay := s.Key if len(s.Tags) > 0 { - keyDisplay += " 🏷️" + keyDisplay += " 🔖" } if s.Comment != "" { keyDisplay += " 💬" @@ -45,7 +130,7 @@ func renderSecretRow(pathPrefix string, s sdk.SecretResult, show bool, keyWidth, icon := "" if crossEnvPattern.MatchString(s.Value) { - icon += "⛓️ " + icon += "🌐 " } if localRefPattern.MatchString(s.Value) { icon += "🔗 " @@ -62,7 +147,7 @@ func renderSecretRow(pathPrefix string, s sdk.SecretResult, show bool, keyWidth, } else if show { valueDisplay = s.Value } else { - censorLen := valueWidth - len(icon) - len(personalIndicator) - 2 + censorLen := valueWidth - displayWidth(icon) - displayWidth(personalIndicator) - 2 if censorLen < 6 { censorLen = 6 } @@ -70,16 +155,30 @@ func renderSecretRow(pathPrefix string, s sdk.SecretResult, show bool, keyWidth, } valueDisplay = icon + personalIndicator + valueDisplay - // Truncate if needed - if len(keyDisplay) > keyWidth { - keyDisplay = keyDisplay[:keyWidth-1] + "…" - } - if len(valueDisplay) > valueWidth { - valueDisplay = valueDisplay[:valueWidth-1] + "…" + // Truncate key (never wraps) + keyDisplay = truncateToWidth(keyDisplay, keyWidth) + + if !show { + valueDisplay = truncateToWidth(valueDisplay, valueWidth) } - fmt.Fprintf(os.Stdout, " %s │ %-*s│ %-*s│\n", - pathPrefix, keyWidth, keyDisplay, valueWidth, valueDisplay) + if show { + // Wrap long values within the value column + valueLines := wrapToWidth(valueDisplay, valueWidth) + for i, vline := range valueLines { + if i == 0 { + fmt.Fprintf(os.Stdout, " %s │ %s│ %s│\n", + pathPrefix, padRight(keyDisplay, keyWidth), padRight(vline, valueWidth)) + } else { + fmt.Fprintf(os.Stdout, " %s │ %s│ %s│\n", + pathPrefix, strings.Repeat(" ", keyWidth), padRight(vline, valueWidth)) + } + } + } else { + valueDisplay = truncateToWidth(valueDisplay, valueWidth) + fmt.Fprintf(os.Stdout, " %s │ %s│ %s│\n", + pathPrefix, padRight(keyDisplay, keyWidth), padRight(valueDisplay, valueWidth)) + } } // RenderSecretsTree renders secrets in a tree view with path hierarchy @@ -115,11 +214,10 @@ func RenderSecretsTree(secrets []sdk.SecretResult, show bool) { sort.Strings(sortedPaths) termWidth := getTerminalWidth() - isLastPath := false for pi, path := range sortedPaths { pathSecrets := paths[path] - isLastPath = pi == len(sortedPaths)-1 + isLastPath := pi == len(sortedPaths)-1 pathConnector := "├" pathPrefix := "│" if isLastPath { @@ -145,64 +243,66 @@ func RenderSecretsTree(secrets []sdk.SecretResult, show bool) { } } - // Calculate column widths across all secrets in this path + // Calculate column widths minKeyWidth := 15 maxKeyLen := minKeyWidth for _, s := range pathSecrets { - kl := len(s.Key) + 4 // room for tag/comment icons + kl := displayWidth(s.Key) + 4 if kl > maxKeyLen { maxKeyLen = kl } } - keyWidth := maxKeyLen + 2 + keyWidth := maxKeyLen + 6 if keyWidth > 40 { keyWidth = 40 } if keyWidth < minKeyWidth { keyWidth = minKeyWidth } - valueWidth := termWidth - keyWidth - 10 + // Full row: " X │ " + key + "│ " + value + "│" = prefix(6) + 2 + key + 2 + value + 1 + // Total = keyWidth + valueWidth + 11, must be < termWidth + valueWidth := termWidth - keyWidth - 12 if valueWidth < 20 { valueWidth = 20 } - - // Print table header - fmt.Fprintf(os.Stdout, " %s ╭─%s┬─%s╮\n", + // Table top + fmt.Fprintf(os.Stdout, " %s ┌─%s┬─%s┐\n", pathPrefix, strings.Repeat("─", keyWidth), strings.Repeat("─", valueWidth)) - fmt.Fprintf(os.Stdout, " %s │ %s%-*s%s│ %s%-*s%s│\n", - pathPrefix, bold, keyWidth, "KEY", reset, bold, valueWidth, "VALUE", reset) + fmt.Fprintf(os.Stdout, " %s │ %s%s│ %s%s│\n", + pathPrefix, bold, padRight("KEY", keyWidth)+reset, bold, padRight("VALUE", valueWidth)+reset) fmt.Fprintf(os.Stdout, " %s ├─%s┼─%s┤\n", pathPrefix, strings.Repeat("─", keyWidth), strings.Repeat("─", valueWidth)) - // Print static secrets + // Static secrets for _, s := range staticSecrets { - renderSecretRow(pathPrefix, s, show, keyWidth, valueWidth, bold, reset) + renderSecretRow(pathPrefix, s, show, keyWidth, valueWidth) } - // Print dynamic secret groups + // Dynamic secret groups for _, groupLabel := range dynamicGroupOrder { groupSecrets := dynamicGroups[groupLabel] - // Section separator if there were static secrets or a previous group if len(staticSecrets) > 0 || groupLabel != dynamicGroupOrder[0] { fmt.Fprintf(os.Stdout, " %s ├─%s┼─%s┤\n", pathPrefix, strings.Repeat("─", keyWidth), strings.Repeat("─", valueWidth)) } - // Group header row - header := fmt.Sprintf("⚡️ %s", groupLabel) - if len(header) > keyWidth+valueWidth+1 { - header = header[:keyWidth+valueWidth-2] + "…" - } - fmt.Fprintf(os.Stdout, " %s │ %s%-*s%s│\n", - pathPrefix, bold, keyWidth+valueWidth+1, header, reset) + // Group header spans both columns + header := fmt.Sprintf("⚡ %s", groupLabel) + totalInner := keyWidth + 2 + valueWidth + header = truncateToWidth(header, totalInner) + fmt.Fprintf(os.Stdout, " %s │ %s%s%s│\n", + pathPrefix, bold, padRight(header, totalInner), reset) + fmt.Fprintf(os.Stdout, " %s ├─%s┼─%s┤\n", + pathPrefix, strings.Repeat("─", keyWidth), strings.Repeat("─", valueWidth)) for _, s := range groupSecrets { - renderSecretRow(pathPrefix, s, show, keyWidth, valueWidth, bold, reset) + renderSecretRow(pathPrefix, s, show, keyWidth, valueWidth) } } - fmt.Fprintf(os.Stdout, " %s ╰─%s┴─%s╯\n", + // Table bottom + fmt.Fprintf(os.Stdout, " %s └─%s┴─%s┘\n", pathPrefix, strings.Repeat("─", keyWidth), strings.Repeat("─", valueWidth)) } } diff --git a/src/pkg/errors/errors.go b/src/pkg/errors/errors.go new file mode 100644 index 00000000..bc072c51 --- /dev/null +++ b/src/pkg/errors/errors.go @@ -0,0 +1,54 @@ +package errors + +import ( + "errors" + "fmt" + + "github.com/phasehq/golang-sdk/phase/network" +) + +// FormatSDKError wraps SDK errors with user-facing presentation (emoji, hints). +// Non-SDK errors pass through unchanged. +func FormatSDKError(err error) string { + var netErr *network.NetworkError + if errors.As(err, &netErr) { + 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) + 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" + case "timeout": + return "🗿 Network error: Request timed out. Please check your connection and try again" + default: + return fmt.Sprintf("🗿 Network error: %s", netErr.Detail) + } + } + + var sslErr *network.SSLError + if errors.As(err, &sslErr) { + return fmt.Sprintf("🗿 SSL error: %s. You may set PHASE_VERIFY_SSL=False to bypass this check", sslErr.Detail) + } + + var authErr *network.AuthorizationError + if errors.As(err, &authErr) { + if authErr.Detail != "" { + return fmt.Sprintf("🚫 Not authorized: %s", authErr.Detail) + } + return "🚫 Not authorized. Token may be expired or revoked" + } + + var rateLimitErr *network.RateLimitError + if errors.As(err, &rateLimitErr) { + return "⏳ Rate limit exceeded. Please try again later" + } + + var apiErr *network.APIError + if errors.As(err, &apiErr) { + if apiErr.Detail != "" { + return fmt.Sprintf("🗿 Request failed (HTTP %d): %s", apiErr.StatusCode, apiErr.Detail) + } + return fmt.Sprintf("🗿 Request failed with status code %d", apiErr.StatusCode) + } + + return err.Error() +} diff --git a/src/pkg/util/export.go b/src/pkg/util/export.go index fe2fffee..db81fcb1 100644 --- a/src/pkg/util/export.go +++ b/src/pkg/util/export.go @@ -11,73 +11,113 @@ import ( "gopkg.in/yaml.v3" ) -func ExportDotenv(secrets map[string]string) { - for key, value := range secrets { - fmt.Printf("%s=\"%s\"\n", key, value) +// KeyValue preserves insertion order for deterministic export output. +type KeyValue struct { + Key string + Value string +} + +func ExportDotenv(secrets []KeyValue) { + for _, kv := range secrets { + fmt.Printf("%s=\"%s\"\n", kv.Key, kv.Value) } } -func ExportJSON(secrets map[string]string) { - data, _ := json.MarshalIndent(secrets, "", " ") - fmt.Println(string(data)) +func ExportJSON(secrets []KeyValue) { + // Use json.Encoder to produce an ordered JSON object + ordered := make([]struct { + Key string + Value string + }, len(secrets)) + for i, kv := range secrets { + ordered[i].Key = kv.Key + ordered[i].Value = kv.Value + } + // Build a manually ordered JSON object to preserve key order + fmt.Print("{\n") + for i, kv := range secrets { + keyJSON, _ := json.Marshal(kv.Key) + valJSON, _ := json.Marshal(kv.Value) + fmt.Printf(" %s: %s", string(keyJSON), string(valJSON)) + if i < len(secrets)-1 { + fmt.Print(",") + } + fmt.Println() + } + fmt.Println("}") } -func ExportCSV(secrets map[string]string) { +func ExportCSV(secrets []KeyValue) { w := csv.NewWriter(os.Stdout) w.Write([]string{"Key", "Value"}) - for key, value := range secrets { - w.Write([]string{key, value}) + for _, kv := range secrets { + w.Write([]string{kv.Key, kv.Value}) } w.Flush() } -func ExportYAML(secrets map[string]string) { - data, _ := yaml.Marshal(secrets) - fmt.Print(string(data)) +func ExportYAML(secrets []KeyValue) { + // Build ordered YAML manually to preserve key order + node := &yaml.Node{ + Kind: yaml.MappingNode, + } + for _, kv := range secrets { + node.Content = append(node.Content, + &yaml.Node{Kind: yaml.ScalarNode, Value: kv.Key}, + &yaml.Node{Kind: yaml.ScalarNode, Value: kv.Value}, + ) + } + doc := &yaml.Node{ + Kind: yaml.DocumentNode, + Content: []*yaml.Node{node}, + } + enc := yaml.NewEncoder(os.Stdout) + enc.Encode(doc) + enc.Close() } -func ExportXML(secrets map[string]string) { +func ExportXML(secrets []KeyValue) { fmt.Println("") - for key, value := range secrets { + for _, kv := range secrets { var escaped strings.Builder - xml.EscapeText(&escaped, []byte(value)) - fmt.Printf(" %s\n", key, escaped.String()) + xml.EscapeText(&escaped, []byte(kv.Value)) + fmt.Printf(" %s\n", kv.Key, escaped.String()) } fmt.Println("") } -func ExportTOML(secrets map[string]string) { - for key, value := range secrets { - fmt.Printf("%s = \"%s\"\n", key, value) +func ExportTOML(secrets []KeyValue) { + for _, kv := range secrets { + fmt.Printf("%s = \"%s\"\n", kv.Key, kv.Value) } } -func ExportHCL(secrets map[string]string) { - for key, value := range secrets { - escaped := strings.ReplaceAll(value, "\"", "\\\"") - fmt.Printf("variable \"%s\" {\n", key) +func ExportHCL(secrets []KeyValue) { + for _, kv := range secrets { + escaped := strings.ReplaceAll(kv.Value, "\"", "\\\"") + fmt.Printf("variable \"%s\" {\n", kv.Key) fmt.Printf(" default = \"%s\"\n", escaped) fmt.Println("}") fmt.Println() } } -func ExportINI(secrets map[string]string) { +func ExportINI(secrets []KeyValue) { fmt.Println("[DEFAULT]") - for key, value := range secrets { - escaped := strings.ReplaceAll(value, "%", "%%") - fmt.Printf("%s = %s\n", key, escaped) + for _, kv := range secrets { + escaped := strings.ReplaceAll(kv.Value, "%", "%%") + fmt.Printf("%s = %s\n", kv.Key, escaped) } } -func ExportJavaProperties(secrets map[string]string) { - for key, value := range secrets { - fmt.Printf("%s=%s\n", key, value) +func ExportJavaProperties(secrets []KeyValue) { + for _, kv := range secrets { + fmt.Printf("%s=%s\n", kv.Key, kv.Value) } } -func ExportKV(secrets map[string]string) { - for key, value := range secrets { - fmt.Printf("%s=%s\n", key, value) +func ExportKV(secrets []KeyValue) { + for _, kv := range secrets { + fmt.Printf("%s=%s\n", kv.Key, kv.Value) } } From 539fa4897ea53f2bd4fed31d32ae4959c4635af5 Mon Sep 17 00:00:00 2001 From: Nimish Date: Sun, 1 Mar 2026 12:34:01 +0530 Subject: [PATCH 39/50] chore: updated readme --- README.md | 155 +++++++++++++++++++++++++++++++++--------------------- 1 file changed, 96 insertions(+), 59 deletions(-) diff --git a/README.md b/README.md index fceb67c1..3a1ba39e 100644 --- a/README.md +++ b/README.md @@ -1,61 +1,66 @@ # phase cli -```fish +``` λ phase --help -Securely manage application secrets and environment variables with Phase. - - /$$ - | $$ - /$$$$$$ | $$$$$$$ /$$$$$$ /$$$$$$$ /$$$$$$ - /$$__ $$| $$__ $$ |____ $$ /$$_____/ /$$__ $$ -| $$ \ $$| $$ \ $$ /$$$$$$$| $$$$$$ | $$$$$$$$ -| $$ | $$| $$ | $$ /$$__ $$ \____ $$| $$_____/ -| $$$$$$$/| $$ | $$| $$$$$$$ /$$$$$$$/| $$$$$$$ -| $$____/ |__/ |__/ \_______/|_______/ \_______/ -| $$ -|__/ - -options: - -h, --help show this help message and exit - --version, -v - show program's version number and exit -Commands: +Keep Secrets. + + /$$ + | $$ + /$$$$$$ | $$$$$$$ /$$$$$$ /$$$$$$$ /$$$$$$ + /$$__ $$| $$__ $$ |____ $$ /$$_____/ /$$__ $$ + | $$ \ $$| $$ \ $$ /$$$$$$$| $$$$$$ | $$$$$$$$ + | $$ | $$| $$ | $$ /$$__ $$ \____ $$| $$_____/ + | $$$$$$$/| $$ | $$| $$$$$$$ /$$$$$$$/| $$$$$$$ + | $$____/ |__/ |__/ \_______/|_______/ \_______/ + | $$ + |__/ - auth 💻 Authenticate with Phase - init 🔗 Link your project with your Phase app - run 🚀 Run and inject secrets to your app - shell 🐚 Launch a sub-shell with secrets as environment variables (BETA) - secrets 🗝️ Manage your secrets - secrets list 📇 List all the secrets - secrets get 🔍 Get a specific secret by key - secrets create 💳 Create a new secret - secrets update 📝 Update an existing secret - secrets delete 🗑️ Delete a secret - secrets import 📩 Import secrets from a .env file - secrets export 🥡 Export secrets in a dotenv format - dynamic-secrets ⚡️ Manage dynamic secrets - dynamic-secrets list 📇 List dynamic secrets & metadata - dynamic-secrets lease 📜 Manage dynamic secret leases - dynamic-secrets lease get 🔍 Get leases for a dynamic secret - dynamic-secrets lease renew 🔁 Renew a lease - dynamic-secrets lease revoke 🗑️ Revoke a lease - dynamic-secrets lease generate ✨ Generate a lease (create fresh dynamic secrets) - users 👥 Manage users and accounts - users whoami 🙋 See details of the current user - users switch 🪄 Switch between Phase users, orgs and hosts - users logout 🏃 Logout from phase-cli - users keyring 🔐 Display information about the Phase keyring - docs 📖 Open the Phase CLI Docs in your browser - console 🖥️ Open the Phase Console in your browser - update 🆙 Update the Phase CLI to the latest version +Commands: + auth 💻 Authenticate with Phase + init 🔗 Link your project with your Phase app + run 🚀 Run and inject secrets to your app + shell 🐚 Launch a sub-shell with secrets as environment variables + secrets list 📇 List all the secrets + secrets get 🔍 Fetch details about a secret in JSON + secrets create 💳 Create a new secret + secrets update 📝 Update an existing secret + secrets delete 🗑️ Delete a secret + secrets import 📩 Import secrets from a .env file + secrets export 🥡 Export secrets in a specific format + dynamic-secrets list 📇 List dynamic secrets & metadata + dynamic-secrets lease generate ✨ Generate a lease (create fresh dynamic secret) + dynamic-secrets lease get 🔍 Get leases for a dynamic secret + dynamic-secrets lease renew 🔁 Renew a lease + dynamic-secrets lease revoke 🗑️ Revoke a lease + users whoami 🙋 See details of the current user + users switch 🪄 Switch between Phase users, orgs and hosts + users logout 🏃 Logout from phase-cli + users keyring 🔐 Display information about the Phase keyring + console 🖥️ Open the Phase Console in your browser + docs 📖 Open the Phase CLI Docs in your browser + completion ⌨️ Generate the autocompletion script for the specified shell + +Flags: + -h, --help help for phase + -v, --version version for phase ``` ## Features -- Inject secrets to your application during runtime without any code changes -- Import your existing .env files and encrypt them -- Sync encrypted secrets with Phase cloud -- Multiple environments eg. dev, testing, staging, production +- **End-to-end encryption** — secrets are encrypted client-side before leaving your machine +- **`phase run`** — inject secrets as environment variables into any command without code changes +- **`phase shell`** — launch a sub-shell (bash, zsh, fish, etc.) with secrets preloaded +- **Dynamic secrets** — generate short-lived credentials (e.g. database passwords) with automatic lease management (generate, renew, revoke) +- **Secret references** — reference secrets across environments and apps, resolved automatically at runtime +- **Personal overrides** — override shared secrets locally without affecting your team +- **Import / Export** — import from `.env` files; export to dotenv, JSON, YAML, TOML, CSV, XML, HCL, INI, Java properties, and more +- **Path-based organisation** — organise secrets in hierarchical paths for monorepos and microservices +- **Tagging** — tag secrets and filter operations by tag +- **Random secret generation** — generate hex, alphanumeric, 128-bit, or 256-bit keys on create or update +- **Multiple auth methods** — web-based login, personal access tokens, service account tokens, and AWS IAM identity auth +- **Multi-user & multi-org** — switch between Phase accounts, orgs, and self-hosted instances +- **OS keyring integration** — credentials stored in macOS Keychain, GNOME Keyring, or Windows Credential Manager +- **Multiple environments** — dev, staging, production, and custom environments with per-project defaults via `phase init` ## See it in action @@ -119,32 +124,64 @@ phase run go run phase run npm start ``` -## Development: +## Development -### Create a virtualenv: +### Prerequisites + +- [Go](https://go.dev/dl/) 1.24 or later + +### Project structure + +``` +src/ +├── main.go # Entrypoint +├── cmd/ # Cobra command definitions +├── pkg/ +│ ├── config/ # Config file handling (~/.phase/, .phase.json) +│ ├── display/ # Output formatting (tree view, tables) +│ ├── errors/ # Error types +│ ├── keyring/ # OS keyring integration +│ ├── phase/ # Phase client helpers (auth, init) +│ ├── util/ # Misc utilities (color, spinner, browser) +│ └── version/ # Version constant +└── go.mod +``` + +### Run from source ```bash -python -m venv venv +cd src +go run main.go --help ``` -### Switch to the virtualenv: +### Build a binary ```bash -source venv/bin/activate +cd src +go build -o phase . +./phase --version ``` -### Install dependencies: +You can set the version at build time with `-ldflags`: ```bash -pip install -r requirements.txt +go build -ldflags "-X github.com/phasehq/cli/pkg/version.Version=2.0.0" -o phase . ``` -### Install the CLI in editable mode: +### Run tests ```bash -pip install -e . +cd src +go test ./... ``` +### Install locally + +Build and move the binary somewhere on your `$PATH`: + ```bash +cd src +go build -o phase . +sudo mv phase /usr/local/bin/ phase --version ``` \ No newline at end of file From ced1b788b8a7aee9622c33aa57ba1553e1b93878 Mon Sep 17 00:00:00 2001 From: Nimish Date: Sun, 1 Mar 2026 13:04:16 +0530 Subject: [PATCH 40/50] fix: update export tests to use []KeyValue instead of map[string]string --- src/pkg/util/export_test.go | 28 ++++++++++++++++++---------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/src/pkg/util/export_test.go b/src/pkg/util/export_test.go index 843b3589..0ef40716 100644 --- a/src/pkg/util/export_test.go +++ b/src/pkg/util/export_test.go @@ -13,7 +13,15 @@ import ( "gopkg.in/yaml.v3" ) -var sampleSecrets = map[string]string{ +var sampleSecrets = []KeyValue{ + {Key: "AWS_SECRET_ACCESS_KEY", Value: "abc/xyz"}, + {Key: "AWS_ACCESS_KEY_ID", Value: "AKIA123"}, + {Key: "JWT_SECRET", Value: "token.value"}, + {Key: "DB_PASSWORD", Value: "pass%word"}, +} + +// sampleSecretsMap is a convenience lookup for assertions. +var sampleSecretsMap = map[string]string{ "AWS_SECRET_ACCESS_KEY": "abc/xyz", "AWS_ACCESS_KEY_ID": "AKIA123", "JWT_SECRET": "token.value", @@ -65,10 +73,10 @@ func TestExportJSON(t *testing.T) { if err := json.Unmarshal([]byte(out), &got); err != nil { t.Fatalf("unmarshal json output: %v", err) } - if len(got) != len(sampleSecrets) { - t.Fatalf("unexpected key count: got %d want %d", len(got), len(sampleSecrets)) + if len(got) != len(sampleSecretsMap) { + t.Fatalf("unexpected key count: got %d want %d", len(got), len(sampleSecretsMap)) } - for k, v := range sampleSecrets { + for k, v := range sampleSecretsMap { if got[k] != v { t.Fatalf("mismatch for %s: got %q want %q", k, got[k], v) } @@ -94,7 +102,7 @@ func TestExportCSV(t *testing.T) { } got[row[0]] = row[1] } - for k, v := range sampleSecrets { + for k, v := range sampleSecretsMap { if got[k] != v { t.Fatalf("mismatch for %s: got %q want %q", k, got[k], v) } @@ -108,7 +116,7 @@ func TestExportYAML(t *testing.T) { if err := yaml.Unmarshal([]byte(out), &got); err != nil { t.Fatalf("unmarshal yaml output: %v", err) } - for k, v := range sampleSecrets { + for k, v := range sampleSecretsMap { if got[k] != v { t.Fatalf("mismatch for %s: got %q want %q", k, got[k], v) } @@ -133,7 +141,7 @@ func TestExportXML(t *testing.T) { for _, e := range parsed.Entries { got[e.Name] = e.Value } - for k, v := range sampleSecrets { + for k, v := range sampleSecretsMap { if got[k] != v { t.Fatalf("mismatch for %s: got %q want %q", k, got[k], v) } @@ -143,7 +151,7 @@ func TestExportXML(t *testing.T) { func TestExportDotenvAndKVLikeFormats(t *testing.T) { dotenvOut := captureStdout(t, func() { ExportDotenv(sampleSecrets) }) dotenv := parseKeyValueLines(t, dotenvOut) - for k, v := range sampleSecrets { + for k, v := range sampleSecretsMap { if dotenv[k] != `"`+v+`"` { t.Fatalf("dotenv mismatch for %s: got %q want %q", k, dotenv[k], `"`+v+`"`) } @@ -151,7 +159,7 @@ func TestExportDotenvAndKVLikeFormats(t *testing.T) { kvOut := captureStdout(t, func() { ExportKV(sampleSecrets) }) kv := parseKeyValueLines(t, kvOut) - for k, v := range sampleSecrets { + for k, v := range sampleSecretsMap { if kv[k] != v { t.Fatalf("kv mismatch for %s: got %q want %q", k, kv[k], v) } @@ -159,7 +167,7 @@ func TestExportDotenvAndKVLikeFormats(t *testing.T) { javaOut := captureStdout(t, func() { ExportJavaProperties(sampleSecrets) }) javaProps := parseKeyValueLines(t, javaOut) - for k, v := range sampleSecrets { + for k, v := range sampleSecretsMap { if javaProps[k] != v { t.Fatalf("java properties mismatch for %s: got %q want %q", k, javaProps[k], v) } From 314cf7ec6d074c4aaf572e51ac25adebfc4f38b3 Mon Sep 17 00:00:00 2001 From: Nimish Date: Sun, 1 Mar 2026 13:07:27 +0530 Subject: [PATCH 41/50] chore: update readme --- README.md | 24 +++++++++++------------- 1 file changed, 11 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 3a1ba39e..170b5c53 100644 --- a/README.md +++ b/README.md @@ -24,21 +24,21 @@ Commands: secrets get 🔍 Fetch details about a secret in JSON secrets create 💳 Create a new secret secrets update 📝 Update an existing secret - secrets delete 🗑️ Delete a secret + secrets delete 🗑️ Delete a secret secrets import 📩 Import secrets from a .env file secrets export 🥡 Export secrets in a specific format dynamic-secrets list 📇 List dynamic secrets & metadata dynamic-secrets lease generate ✨ Generate a lease (create fresh dynamic secret) dynamic-secrets lease get 🔍 Get leases for a dynamic secret dynamic-secrets lease renew 🔁 Renew a lease - dynamic-secrets lease revoke 🗑️ Revoke a lease + dynamic-secrets lease revoke 🗑️ Revoke a lease users whoami 🙋 See details of the current user - users switch 🪄 Switch between Phase users, orgs and hosts + users switch 🪄 Switch between Phase users, orgs and hosts users logout 🏃 Logout from phase-cli users keyring 🔐 Display information about the Phase keyring - console 🖥️ Open the Phase Console in your browser + console 🖥️ Open the Phase Console in your browser docs 📖 Open the Phase CLI Docs in your browser - completion ⌨️ Generate the autocompletion script for the specified shell + completion ⌨️ Generate the autocompletion script for the specified shell Flags: -h, --help help for phase @@ -62,10 +62,6 @@ Flags: - **OS keyring integration** — credentials stored in macOS Keychain, GNOME Keyring, or Windows Credential Manager - **Multiple environments** — dev, staging, production, and custom environments with per-project defaults via `phase init` -## See it in action - -[![asciicast](media/phase-cli-demo.gif)](asciinema-cli-demo) - ## Installation You can install Phase-CLI using curl: @@ -76,9 +72,11 @@ curl -fsSL https://pkg.phase.dev/install.sh | bash ## Usage -### Login +### Prerequisites + +- Create an app in the [Phase Console](https://console.phase.dev) -Create an app in the [Phase Console](https://console.phase.dev) and copy appID and pss +### Login ```bash phase auth @@ -92,7 +90,7 @@ Link the phase cli to your project phase init ``` -### Import .env +### Import .env (optional) Import and encrypt existing secrets and environment variables @@ -100,7 +98,7 @@ Import and encrypt existing secrets and environment variables phase secrets import .env ``` -## List / view secrets +### List / view secrets ```bash phase secrets list --show From 71b6ff6ade3e2efa97456cc47a7e6576a5f215a0 Mon Sep 17 00:00:00 2001 From: Nimish Date: Mon, 2 Mar 2026 14:23:14 +0530 Subject: [PATCH 42/50] feat: updated install scrip --- install.sh | 431 +++++++++++++++++++++++++---------------------------- 1 file changed, 202 insertions(+), 229 deletions(-) diff --git a/install.sh b/install.sh index 295a7ecc..6fb34b16 100755 --- a/install.sh +++ b/install.sh @@ -1,285 +1,258 @@ -#!/bin/bash +#!/bin/sh +# +# /$$ +# | $$ +# /$$$$$$ | $$$$$$$ /$$$$$$ /$$$$$$$ /$$$$$$ +# /$$__ $$| $$__ $$ |____ $$ /$$_____/ /$$__ $$ +# | $$ \ $$| $$ \ $$ /$$$$$$$| $$$$$$ | $$$$$$$$ +# | $$ | $$| $$ | $$ /$$__ $$ \____ $$| $$_____/ +# | $$$$$$$/| $$ | $$| $$$$$$$ /$$$$$$$/| $$$$$$$ +# | $$____/ |__/ |__/ \_______/|_______/ \_______/ +# | $$ +# |__/ +# +# Phase CLI installer. +# +# Usage: +# curl -fsSL https://pkg.phase.dev/install.sh | sh +# curl -fsSL https://pkg.phase.dev/install.sh | sh -s -- --version 2.0.0 +# +# Supports: Linux, macOS, FreeBSD, OpenBSD, NetBSD +# Architectures: x86_64, arm64, armv7, mips, mips64, riscv64, ppc64le, s390x set -e REPO="phasehq/cli" -BASE_URL="https://github.com/$REPO/releases/download" +INSTALL_DIR="/usr/local/bin" +BINARY_NAME="phase" -detect_os() { - if [ -f /etc/os-release ]; then - . /etc/os-release - OS=$ID - else - echo "Can't detect OS type." - exit 1 - fi -} +# --- Helpers --- -has_sudo_access() { - if sudo -n true 2>/dev/null; then - return 0 - else - return 1 - fi +die() { + echo "Error: $1" >&2 + exit 1 } -can_install_without_sudo() { - case $OS in - ubuntu|debian) - if dpkg -l >/dev/null 2>&1; then - return 0 - fi - ;; - fedora|rhel|centos|amzn|rocky) - if rpm -q rpm >/dev/null 2>&1; then - return 0 - fi - ;; - alpine) - if apk --version >/dev/null 2>&1; then - return 0 - fi - ;; - arch) - if pacman -V >/dev/null 2>&1; then - return 0 - fi - ;; - esac - return 1 +info() { + echo " $1" } -prompt_sudo() { - if [ "$EUID" -ne 0 ]; then - if command -v sudo >/dev/null 2>&1; then - echo "This operation requires elevated privileges. Please enter your sudo password if prompted." - sudo -v - if [ $? -ne 0 ]; then - echo "Failed to obtain sudo privileges. Exiting." - exit 1 - fi - else - echo "Error: This script must be run as root or with sudo privileges." - exit 1 - fi +# Run a command with elevated privileges if needed +do_install() { + if [ "$(id -u)" -eq 0 ]; then + "$@" + elif command -v sudo > /dev/null 2>&1; then + sudo "$@" + elif command -v doas > /dev/null 2>&1; then + doas "$@" + else + die "This script must be run as root or with sudo/doas available." fi } -install_tool() { - local TOOL=$1 - echo "Installing $TOOL..." - if [ "$EUID" -eq 0 ] || can_install_without_sudo; then - case $OS in - ubuntu|debian) - apt-get update && apt-get install -y $TOOL - ;; - fedora|rhel|centos|amzn|rocky) - yum install -y $TOOL - ;; - alpine) - apk add $TOOL - ;; - arch) - pacman -Sy --noconfirm $TOOL - ;; - esac +# Download a URL to a file. Prefers curl, falls back to wget. +fetch() { + if command -v curl > /dev/null 2>&1; then + curl -fsSL -o "$2" "$1" + elif command -v wget > /dev/null 2>&1; then + wget -qO "$2" "$1" else - prompt_sudo - case $OS in - ubuntu|debian) - sudo apt-get update && sudo apt-get install -y $TOOL - ;; - fedora|rhel|centos|amzn|rocky) - sudo yum install -y $TOOL - ;; - alpine) - sudo apk add $TOOL - ;; - arch) - sudo pacman -Sy --noconfirm $TOOL - ;; - esac + die "curl or wget is required to download files." fi } -check_required_tools() { - for TOOL in wget curl jq unzip; do - if ! command -v $TOOL > /dev/null; then - install_tool $TOOL - fi - done - # sha256sum is provided by coreutils on most distros - if ! command -v sha256sum > /dev/null; then - install_tool coreutils +# Download a URL to stdout. +fetch_stdout() { + if command -v curl > /dev/null 2>&1; then + curl -fsSL "$1" + elif command -v wget > /dev/null 2>&1; then + wget -qO - "$1" + else + die "curl or wget is required to download files." fi } +# --- Detection --- + +detect_platform() { + OS="$(uname -s)" + ARCH="$(uname -m)" + + case "$OS" in + Linux) OS="linux" ;; + Darwin) OS="darwin" ;; + FreeBSD) OS="freebsd" ;; + OpenBSD) OS="openbsd" ;; + NetBSD) OS="netbsd" ;; + MINGW*|MSYS*|CYGWIN*) die "Windows is not supported by this script. Use Scoop instead: scoop bucket add phasehq https://github.com/phasehq/scoop-cli.git && scoop install phase" ;; + *) die "Unsupported operating system: $OS" ;; + esac + + case "$ARCH" in + x86_64|amd64) ARCH="amd64" ;; + aarch64|arm64) ARCH="arm64" ;; + armv7l|armv6l) ARCH="arm" ;; + mips) ARCH="mips" ;; + mipsel) ARCH="mipsle" ;; + mips64) ARCH="mips64" ;; + mips64el) ARCH="mips64le" ;; + riscv64) ARCH="riscv64" ;; + ppc64le) ARCH="ppc64le" ;; + s390x) ARCH="s390x" ;; + i386|i686) ARCH="386" ;; + *) die "Unsupported architecture: $ARCH" ;; + esac +} + get_latest_version() { - curl -s "https://api.github.com/repos/$REPO/releases/latest" | jq -r .tag_name | cut -c 2- + fetch_stdout "https://api.github.com/repos/$REPO/releases/latest" | \ + sed -n 's/.*"tag_name": *"v\([^"]*\)".*/\1/p' } -wget_download() { - if wget --help 2>&1 | grep -q '\--show-progress'; then - wget --show-progress "$1" -O "$2" - else - wget "$1" -O "$2" +# --- Install --- + +cleanup_legacy() { + # Clean up any remnants of the PyInstaller-based Python CLI (v1.x). + # The old install script installed differently depending on distro/arch: + # A) DEB package (Ubuntu/Debian amd64): dpkg package "phase" -> /usr/lib/phase/ + /usr/bin/phase symlink + # B) RPM package (Fedora/RHEL amd64): rpm package "phase" -> /usr/lib/phase/ + /usr/bin/phase symlink + # C) APK package (Alpine amd64+arm64): apk package "phase" -> Python setuptools install + /usr/bin/phase + # D) Binary zip (arm64 non-Alpine, Arch): raw files -> /usr/local/bin/phase + /usr/local/bin/_internal/ + + found_legacy=false + + # --- Package manager cleanup (removes tracked files + database entry) --- + + # Scenario A: DEB package + if command -v dpkg > /dev/null 2>&1; then + if dpkg -s phase > /dev/null 2>&1; then + info "Removing legacy Phase CLI v1 DEB package..." + do_install dpkg --purge phase 2>/dev/null || do_install dpkg -r phase 2>/dev/null || true + found_legacy=true + fi fi -} -verify_checksum() { - local file="$1" - local checksum_url="$2" - local checksum_file="$TMPDIR/checksum.sha256" - - wget_download "$checksum_url" "$checksum_file" - - while IFS= read -r line; do - local expected_checksum=$(echo "$line" | awk '{print $1}') - local target_file=$(echo "$line" | awk '{print $2}') - - if [[ -e "$target_file" ]]; then - local computed_checksum=$(sha256sum "$target_file" | awk '{print $1}') - - if [[ "$expected_checksum" != "$computed_checksum" ]]; then - echo "Checksum verification failed for $target_file!" - exit 1 - fi + # Scenario B: RPM package + if command -v rpm > /dev/null 2>&1; then + if rpm -q phase > /dev/null 2>&1; then + info "Removing legacy Phase CLI v1 RPM package..." + do_install rpm -e --nodeps phase 2>/dev/null || true + found_legacy=true fi - done < "$checksum_file" -} + fi -install_from_binary() { - ARCH=$(uname -m) - case $ARCH in - x86_64) - ZIP_URL="$BASE_URL/v$VERSION/phase_cli_linux_amd64_$VERSION.zip" - CHECKSUM_URL="$BASE_URL/v$VERSION/phase_cli_linux_amd64_$VERSION.zip.sha256" - EXTRACT_DIR="Linux-binary/phase" - ;; - aarch64) - ZIP_URL="$BASE_URL/v$VERSION/phase_cli_linux_arm64_$VERSION.zip" - CHECKSUM_URL="$BASE_URL/v$VERSION/phase_cli_linux_arm64_$VERSION.zip.sha256" - EXTRACT_DIR="Linux-binary-arm64/phase" - ;; - *) - echo "Unsupported architecture: $ARCH. This script supports x86_64 and arm64." - exit 1 - ;; - esac + # Scenario C: APK package + if command -v apk > /dev/null 2>&1; then + if apk info -e phase > /dev/null 2>&1; then + info "Removing legacy Phase CLI v1 APK package..." + do_install apk del phase 2>/dev/null || true + found_legacy=true + fi + fi - wget_download "$ZIP_URL" "$TMPDIR/phase_cli_${ARCH}_$VERSION.zip" - unzip "$TMPDIR/phase_cli_${ARCH}_$VERSION.zip" -d "$TMPDIR" + # --- Filesystem cleanup (catch anything left after package removal) --- - BINARY_PATH="$TMPDIR/$EXTRACT_DIR/phase" - INTERNAL_DIR_PATH="$TMPDIR/$EXTRACT_DIR/_internal" + # Scenario D: PyInstaller _internal directory from binary zip install + if [ -d "${INSTALL_DIR}/_internal" ]; then + info "Removing legacy PyInstaller _internal directory..." + do_install rm -rf "${INSTALL_DIR}/_internal" + found_legacy=true + fi - verify_checksum "$BINARY_PATH" "$CHECKSUM_URL" - chmod +x "$BINARY_PATH" + # Stale symlink at /usr/bin/phase from DEB/RPM packages + if [ -L "/usr/bin/phase" ]; then + link_target=$(readlink "/usr/bin/phase" 2>/dev/null || true) + case "$link_target" in + *lib/phase/phase*) + info "Removing stale symlink /usr/bin/phase..." + do_install rm -f "/usr/bin/phase" + found_legacy=true + ;; + esac + fi - if [ "$EUID" -eq 0 ]; then - # Running as root, no need for sudo - mv "$BINARY_PATH" /usr/local/bin/phase - mv "$INTERNAL_DIR_PATH" /usr/local/bin/_internal - elif command -v sudo >/dev/null 2>&1; then - # Not root, but sudo is available - echo "This operation requires elevated privileges. Please enter your sudo password if prompted." - sudo mv "$BINARY_PATH" /usr/local/bin/phase - sudo mv "$INTERNAL_DIR_PATH" /usr/local/bin/_internal - else - echo "Error: This script must be run as root or with sudo privileges." - exit 1 + # Leftover /usr/lib/phase/ directory from DEB/RPM packages + if [ -d "/usr/lib/phase" ]; then + info "Removing legacy /usr/lib/phase/ directory..." + do_install rm -rf "/usr/lib/phase" + found_legacy=true fi -} -install_package() { - ARCH=$(uname -m) - # For non-Alpine ARM64 systems, fall back to binary installation - if [ "$ARCH" = "aarch64" ] && [ "$OS" != "alpine" ]; then - install_from_binary - echo "phase-cli version $VERSION successfully installed" - return + if [ "$found_legacy" = true ]; then + info "Legacy Phase CLI v1 cleanup complete." fi +} - if [ "$EUID" -ne 0 ] && ! can_install_without_sudo; then - prompt_sudo +install_binary() { + version="$1" + + asset_name="phase-cli_${OS}_${ARCH}" + download_url="https://github.com/${REPO}/releases/download/v${version}/${asset_name}" + + tmpdir=$(mktemp -d) + trap 'rm -rf "$tmpdir"' EXIT + + echo "" + info "Phase CLI v${version} (${OS}/${ARCH})" + echo "" + + info "Downloading ${download_url}..." + fetch "$download_url" "${tmpdir}/${asset_name}" + + chmod +x "${tmpdir}/${asset_name}" + + # Ensure install directory exists + if [ ! -d "$INSTALL_DIR" ]; then + do_install mkdir -p "$INSTALL_DIR" fi - case $OS in - ubuntu|debian) - PACKAGE_URL="$BASE_URL/v$VERSION/phase_cli_linux_amd64_$VERSION.deb" - wget_download $PACKAGE_URL $TMPDIR/phase_cli_linux_amd64_$VERSION.deb - verify_checksum "$TMPDIR/phase_cli_linux_amd64_$VERSION.deb" "$PACKAGE_URL.sha256" - if [ "$EUID" -eq 0 ] || can_install_without_sudo; then - dpkg -i $TMPDIR/phase_cli_linux_amd64_$VERSION.deb - else - sudo dpkg -i $TMPDIR/phase_cli_linux_amd64_$VERSION.deb - fi - ;; - - fedora|rhel|centos|amzn|rocky) - PACKAGE_URL="$BASE_URL/v$VERSION/phase_cli_linux_amd64_$VERSION.rpm" - wget_download $PACKAGE_URL $TMPDIR/phase_cli_linux_amd64_$VERSION.rpm - verify_checksum "$TMPDIR/phase_cli_linux_amd64_$VERSION.rpm" "$PACKAGE_URL.sha256" - if [ "$EUID" -eq 0 ]; then - rpm -Uvh $TMPDIR/phase_cli_linux_amd64_$VERSION.rpm - else - echo "Installing RPM package. This may require sudo privileges." - sudo rpm -Uvh $TMPDIR/phase_cli_linux_amd64_$VERSION.rpm - fi - ;; - - alpine) - case $ARCH in - x86_64) - APK_ARCH="amd64" - ;; - aarch64) - APK_ARCH="arm64" - ;; - *) - echo "Unsupported architecture for Alpine: $ARCH" - exit 1 - ;; - esac - PACKAGE_URL="$BASE_URL/v$VERSION/phase_cli_linux_${APK_ARCH}_$VERSION.apk" - wget_download $PACKAGE_URL $TMPDIR/phase_cli_linux_${APK_ARCH}_$VERSION.apk - verify_checksum "$TMPDIR/phase_cli_linux_${APK_ARCH}_$VERSION.apk" "$PACKAGE_URL.sha256" - if [ "$EUID" -eq 0 ] || can_install_without_sudo; then - apk add --allow-untrusted $TMPDIR/phase_cli_linux_${APK_ARCH}_$VERSION.apk - else - sudo apk add --allow-untrusted $TMPDIR/phase_cli_linux_${APK_ARCH}_$VERSION.apk - fi - ;; - - *) - install_from_binary - ;; - esac - echo "phase-cli version $VERSION successfully installed" + info "Installing to ${INSTALL_DIR}/${BINARY_NAME}..." + do_install install -m 755 "${tmpdir}/${asset_name}" "${INSTALL_DIR}/${BINARY_NAME}" + + cleanup_legacy + + echo "" + info "Phase CLI v${version} installed successfully." + echo "" + + # Show the installed version + "${INSTALL_DIR}/${BINARY_NAME}" --version 2>/dev/null || true } +# --- Main --- + main() { - detect_os - check_required_tools - TMPDIR=$(mktemp -d) + detect_platform - # Default to the latest version unless a specific version is requested - VERSION=$(get_latest_version) + VERSION="" - # Parse command-line arguments while [ "$#" -gt 0 ]; do case "$1" in --version) VERSION="$2" shift 2 ;; + --help|-h) + echo "Usage: install.sh [--version VERSION]" + echo "" + echo "Install the Phase CLI. If no version is specified, the latest release is installed." + exit 0 + ;; *) - break + die "Unknown option: $1. Use --help for usage." ;; esac done - install_package + if [ -z "$VERSION" ]; then + info "Fetching latest version..." + VERSION=$(get_latest_version) + if [ -z "$VERSION" ]; then + die "Could not determine latest version. Specify one with --version" + fi + fi + + install_binary "$VERSION" } main "$@" From 0eaaad7bfb511c8a33890d13cd233bbe6583c811 Mon Sep 17 00:00:00 2001 From: Nimish Date: Mon, 2 Mar 2026 17:47:53 +0530 Subject: [PATCH 43/50] fix: secret resolution now happens in the sdk --- src/cmd/run.go | 7 +------ src/cmd/secrets_export.go | 8 +------- src/cmd/shell.go | 7 +------ 3 files changed, 3 insertions(+), 19 deletions(-) diff --git a/src/cmd/run.go b/src/cmd/run.go index ca06ff3c..5b2cc91c 100644 --- a/src/cmd/run.go +++ b/src/cmd/run.go @@ -69,17 +69,12 @@ func runRun(cmd *cobra.Command, args []string) error { return err } - // Resolve references resolvedSecrets := map[string]string{} for _, secret := range allSecrets { if secret.Value == "" { continue } - resolvedValue, err := sdk.ResolveAllSecrets(secret.Value, allSecrets, p, secret.Application, secret.Environment) - if err != nil { - return err - } - resolvedSecrets[secret.Key] = resolvedValue + resolvedSecrets[secret.Key] = secret.Value } // Print injection stats to stderr (matches Python CLI behavior) diff --git a/src/cmd/secrets_export.go b/src/cmd/secrets_export.go index d4e00f0d..0827d901 100644 --- a/src/cmd/secrets_export.go +++ b/src/cmd/secrets_export.go @@ -63,18 +63,12 @@ func runSecretsExport(cmd *cobra.Command, args []string) error { return err } - // Resolve secret references and build ordered key-value slice var secretsList []util.KeyValue for _, secret := range allSecrets { if secret.Value == "" { continue } - resolvedValue, err := sdk.ResolveAllSecrets(secret.Value, allSecrets, p, secret.Application, secret.Environment) - if err != nil { - fmt.Fprintf(os.Stderr, "Warning: %v\n", err) - continue - } - secretsList = append(secretsList, util.KeyValue{Key: secret.Key, Value: resolvedValue}) + secretsList = append(secretsList, util.KeyValue{Key: secret.Key, Value: secret.Value}) } switch format { diff --git a/src/cmd/shell.go b/src/cmd/shell.go index d4b79214..c0b11bdb 100644 --- a/src/cmd/shell.go +++ b/src/cmd/shell.go @@ -68,17 +68,12 @@ func runShell(cmd *cobra.Command, args []string) error { return err } - // Resolve references resolvedSecrets := map[string]string{} for _, secret := range allSecrets { if secret.Value == "" { continue } - resolvedValue, err := sdk.ResolveAllSecrets(secret.Value, allSecrets, p, secret.Application, secret.Environment) - if err != nil { - return err - } - resolvedSecrets[secret.Key] = resolvedValue + resolvedSecrets[secret.Key] = secret.Value } // Collect env/app info for display From 63c730a6284b36e4404d76a45eabcce66971df50 Mon Sep 17 00:00:00 2001 From: Nimish Date: Mon, 2 Mar 2026 17:48:09 +0530 Subject: [PATCH 44/50] feat: cross compile cli for n targets --- scripts/build.sh | 90 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100755 scripts/build.sh diff --git a/scripts/build.sh b/scripts/build.sh new file mode 100755 index 00000000..5429362b --- /dev/null +++ b/scripts/build.sh @@ -0,0 +1,90 @@ +#!/usr/bin/env bash +# +# Cross-compile the Phase CLI for all supported platforms. +# Produces raw binaries — no archives, no checksums +# +# Usage (from repo root): +# scripts/build.sh +# VERSION=2.0.0 OUTPUT_DIR=./dist scripts/build.sh + +set -euo pipefail + +# Auto-detect the src/ directory relative to this script +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +SRC_DIR="$REPO_ROOT/src" + +if [ ! -f "$SRC_DIR/go.mod" ]; then + echo "Error: Cannot find go.mod in $SRC_DIR" >&2 + exit 1 +fi + +VERSION="${VERSION:-dev}" +OUTPUT_DIR="${OUTPUT_DIR:-$REPO_ROOT/dist}" + +# Resolve OUTPUT_DIR to absolute path +OUTPUT_DIR="$(cd "$(dirname "$OUTPUT_DIR")" 2>/dev/null && pwd)/$(basename "$OUTPUT_DIR")" || OUTPUT_DIR="$(pwd)/$OUTPUT_DIR" + +LDFLAGS="-s -w -X github.com/phasehq/cli/pkg/version.Version=${VERSION}" + +TARGETS=( + "linux/amd64" + "linux/arm64" + "darwin/amd64" + "darwin/arm64" + "windows/amd64" + "windows/arm64" + "freebsd/amd64" + "freebsd/arm64" + "openbsd/amd64" + "netbsd/amd64" + "linux/mips" + "linux/mipsle" + "linux/mips64" + "linux/mips64le" + "linux/riscv64" + "linux/ppc64le" + "linux/s390x" +) + +mkdir -p "$OUTPUT_DIR" + +echo "Building Phase CLI v${VERSION} for ${#TARGETS[@]} targets..." +echo "" + +cd "$SRC_DIR" + +FAILED=() + +for target in "${TARGETS[@]}"; do + os="${target%/*}" + arch="${target#*/}" + + output="phase-cli_${os}_${arch}" + if [ "$os" = "windows" ]; then + output="${output}.exe" + fi + + printf " %-24s" "${os}/${arch}" + + if CGO_ENABLED=0 GOOS="$os" GOARCH="$arch" \ + go build -trimpath -ldflags "$LDFLAGS" -o "${OUTPUT_DIR}/${output}" ./ 2>&1; then + echo "ok" + else + echo "FAILED" + FAILED+=("${os}/${arch}") + fi +done + +BUILT=$(find "$OUTPUT_DIR" -name 'phase-cli_*' | wc -l | tr -d ' ') +echo "" +echo "Done. ${BUILT}/${#TARGETS[@]} binaries in ${OUTPUT_DIR}/" + +if [ ${#FAILED[@]} -gt 0 ]; then + echo "" + echo "Failed targets:" + for f in "${FAILED[@]}"; do + echo " - $f" + done + exit 1 +fi From 73e57ecf2cc7262f6281c52c69b24d59c0f9b01c Mon Sep 17 00:00:00 2001 From: Nimish Date: Mon, 2 Mar 2026 18:25:50 +0530 Subject: [PATCH 45/50] ci: consolidate CI pipeline, add FPM packaging, and enhance install.sh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove all legacy Python CI workflows (pytest, pypi, process-assets, etc.) - Rename Go workflows to drop go- prefix (go-build.yml → build.yml, etc.) - Add FPM packaging step: scripts/package.sh produces .deb, .rpm, .apk for amd64 + arm64 (6 packages total) - Add package.yml workflow to run FPM in CI after build - Enhance test-install-post-build.yml to test package installs (dpkg -i, rpm -i, apk add) not just raw binary copies - Add test-install-script.yml for post-release install.sh e2e testing - Enhance install.sh cleanup_legacy() with user consent prompt, --no-cleanup flag, and /usr/bin/_internal cleanup - Add scripts/test-migration.sh for local Docker-based migration testing --- .github/workflows/attach-to-release.yml | 20 +- .github/workflows/build.yml | 244 ++------------ .github/workflows/docker.yml | 54 ++-- .github/workflows/go-attach-to-release.yml | 26 -- .github/workflows/go-build.yml | 97 ------ .github/workflows/go-docker.yml | 86 ----- .github/workflows/go-main.yml | 71 ---- .github/workflows/go-process-assets.yml | 92 ------ .../workflows/go-test-install-post-build.yml | 105 ------ .github/workflows/go-version.yml | 28 -- .github/workflows/main.yml | 79 ++--- .github/workflows/package.yml | 39 +++ .github/workflows/process-assets.yml | 34 -- .github/workflows/pypi.yml | 46 --- .github/workflows/pytest.yml | 22 -- .../test-cli-install-post-release.yml | 230 ------------- .github/workflows/test-install-post-build.yml | 306 +++++++----------- .github/workflows/test-install-script.yml | 131 ++++++++ .github/workflows/{go-test.yml => test.yml} | 2 +- .github/workflows/version.yml | 39 +-- scripts/package.sh | 105 ++++++ 21 files changed, 497 insertions(+), 1359 deletions(-) delete mode 100644 .github/workflows/go-attach-to-release.yml delete mode 100644 .github/workflows/go-build.yml delete mode 100644 .github/workflows/go-docker.yml delete mode 100644 .github/workflows/go-main.yml delete mode 100644 .github/workflows/go-process-assets.yml delete mode 100644 .github/workflows/go-test-install-post-build.yml delete mode 100644 .github/workflows/go-version.yml create mode 100644 .github/workflows/package.yml delete mode 100644 .github/workflows/process-assets.yml delete mode 100644 .github/workflows/pypi.yml delete mode 100644 .github/workflows/pytest.yml delete mode 100644 .github/workflows/test-cli-install-post-release.yml create mode 100644 .github/workflows/test-install-script.yml rename .github/workflows/{go-test.yml => test.yml} (97%) create mode 100755 scripts/package.sh diff --git a/.github/workflows/attach-to-release.yml b/.github/workflows/attach-to-release.yml index 7721909b..643c1dc9 100644 --- a/.github/workflows/attach-to-release.yml +++ b/.github/workflows/attach-to-release.yml @@ -1,4 +1,4 @@ -name: Attach Assets to Release +name: "Attach Assets to Release" on: workflow_call: @@ -10,17 +10,23 @@ on: jobs: attach-to-release: name: Attach Assets to Release - runs-on: ubuntu-24.04 + runs-on: ubuntu-latest steps: - - name: Download processed assets + - name: Download binaries uses: actions/download-artifact@v4 with: - name: phase-cli-release - path: ./phase-cli-release + name: phase-cli-binaries + path: ./release + + - name: Download packages + uses: actions/download-artifact@v4 + with: + name: phase-cli-packages + path: ./release - name: Attach assets to release uses: softprops/action-gh-release@v2 with: - files: ./phase-cli-release/* + files: ./release/* env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c8abea8a..9e6681e9 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,4 +1,4 @@ -name: Build CLI +name: "Cross-compile" on: workflow_call: @@ -6,240 +6,38 @@ on: version: required: true type: string - python_version: - required: true - type: string - alpine_version: - required: true - type: string jobs: build: - name: Build CLI - runs-on: ${{ matrix.os }} - strategy: - matrix: - # ubuntu-22.04 - context: https://github.com/phasehq/cli/issues/94 - # macos-15-intel darwin-amd64 builds (intel) - # macos-14 darwin-arm64 builds (apple silicon) - # context: https://github.com/actions/runner-images?tab=readme-ov-file#available-images - - os: [ubuntu-22.04, windows-2022, macos-15-intel, macos-14] + name: Cross-compile all targets + runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 - with: - python-version: ${{ inputs.python_version }} - - run: pip install -r requirements.txt - if: runner.os != 'Linux' - - run: pip install pyinstaller==6.16.0 - if: runner.os != 'Linux' - - run: pyinstaller --hidden-import _cffi_backend --paths ./phase_cli --name phase phase_cli/main.py - if: runner.os != 'Linux' - - name: Build Linux binary in manylinux_2_28 container (glibc 2.28 baseline) - if: matrix.os == 'ubuntu-22.04' - # Design decisions: - # - Use manylinux_2_28 for Linux builds to target glibc 2.28 baseline while maintaining wide compatibility - # - Build CPython in-container with --enable-shared (shared libpython) so PyInstaller can bundle correctly. - # - Use system OpenSSL from manylinux_2_28; avoid building OpenSSL from source for speed and simplicity. - # - Install Python from source with --enable-shared to ensure a shared libpython is available for PyInstaller. - # - Package PyInstaller onedir under /usr/lib/phase and symlink /usr/bin/phase to preserve metadata and bundled files (avoids runtime import errors on RPM-based distros). - # - Print ldd --version - run: | - docker run --rm \ - -v "${PWD}":/workspace \ - -w /workspace \ - quay.io/pypa/manylinux_2_28_x86_64 \ - /bin/bash -lc 'set -euo pipefail; \ - echo "=== ldd version (manylinux_2_28 x86_64) ==="; \ - ldd --version; \ - echo "=== Installing build deps ==="; \ - yum -y install wget tar make gcc openssl-devel bzip2-devel libffi-devel zlib-devel >/dev/null; \ - echo "Install Python from source with --enable-shared to ensure a shared libpython is available for PyInstaller."; \ - echo "=== Building Python ${{ inputs.python_version }} with --enable-shared ==="; \ - PYVER="${{ inputs.python_version }}"; \ - MAJOR=$(echo "$PYVER" | cut -d. -f1); \ - MINOR=$(echo "$PYVER" | cut -d. -f2); \ - FULLVER=$(curl -s https://www.python.org/ftp/python/ | grep -oE ">${MAJOR}\\.${MINOR}\\.[0-9]+/" | tr -d ">/" | sort -V | tail -1 || echo "$PYVER"); \ - cd /tmp; \ - wget -q https://www.python.org/ftp/python/${FULLVER}/Python-${FULLVER}.tgz; \ - tar -xzf Python-${FULLVER}.tgz; \ - cd Python-${FULLVER}; \ - ./configure --prefix=/opt/py-shared --enable-shared --with-system-openssl >/dev/null; \ - make -j$(nproc) >/dev/null; \ - make install >/dev/null; \ - export LD_LIBRARY_PATH=/opt/py-shared/lib:$LD_LIBRARY_PATH; \ - /opt/py-shared/bin/python3 --version; \ - /opt/py-shared/bin/python3 -c '\''import ssl; print("SSL OK")'\''; \ - /opt/py-shared/bin/python3 -m pip install --upgrade pip >/dev/null; \ - cd /workspace; \ - /opt/py-shared/bin/python3 -m pip install -r requirements.txt >/dev/null; \ - /opt/py-shared/bin/python3 -m pip install pyinstaller==6.16.0 >/dev/null; \ - /opt/py-shared/bin/python3 -m PyInstaller --hidden-import _cffi_backend --paths ./phase_cli --name phase phase_cli/main.py' - shell: bash - - - name: Codesign macOS build output - if: runner.os == 'macOS' - run: | - uname -a - codesign --force --deep --sign - dist/phase/phase - codesign --verify --deep --verbose=2 dist/phase/phase - - name: Print GLIBC version - if: matrix.os == 'ubuntu-22.04' - run: ldd --version - - # Set LC_ALL based on the runner OS for Linux and macOS - - name: Set LC_ALL for Linux and macOS - run: export LC_ALL=C.UTF-8 - if: runner.os != 'Windows' - shell: bash - - # Set LC_ALL for Windows - - name: Set LC_ALL for Windows - run: echo "LC_ALL=C.UTF-8" | Out-File -Append -Encoding utf8 $env:GITHUB_ENV - if: runner.os == 'Windows' - shell: pwsh - - # Build DEB and RPM packages for Linux - - run: | - sudo apt-get update - sudo apt-get install -y ruby-dev rubygems build-essential - sudo gem install --no-document fpm - # Stage files to preserve PyInstaller onedir layout - rm -rf pkgroot - mkdir -p pkgroot/usr/lib/phase - cp -a dist/phase/. pkgroot/usr/lib/phase/ - mkdir -p pkgroot/usr/bin - echo "Symlink ensures PATH-discoverable binary while keeping full onedir intact" - # This will create a symlink to the phase binary in the /usr/lib/phase/ directory - ln -sf ../lib/phase/phase pkgroot/usr/bin/phase - # Build packages from staged root - fpm -s dir -t deb -n phase -v ${{ inputs.version }} -C pkgroot . - fpm -s dir -t rpm -n phase -v ${{ inputs.version }} -C pkgroot . - if: matrix.os == 'ubuntu-22.04' - shell: bash - # Upload DEB and RPM packages - - uses: actions/upload-artifact@v4 + - name: Set up Go + uses: actions/setup-go@v5 with: - name: phase-deb - path: "*.deb" - if: matrix.os == 'ubuntu-22.04' - - uses: actions/upload-artifact@v4 - with: - name: phase-rpm - path: "*.rpm" - if: matrix.os == 'ubuntu-22.04' - - - name: Set artifact name - run: | - if [[ "${{ matrix.os }}" == "macos-14" ]]; then - echo "ARTIFACT_NAME=${{ runner.os }}-arm64-binary" >> $GITHUB_ENV - elif [[ "${{ matrix.os }}" == "macos-15-intel" ]]; then - echo "ARTIFACT_NAME=${{ runner.os }}-amd64-binary" >> $GITHUB_ENV - else - echo "ARTIFACT_NAME=${{ runner.os }}-binary" >> $GITHUB_ENV - fi - shell: bash + go-version: "1.24" + cache-dependency-path: src/go.sum - - name: Upload binary - uses: actions/upload-artifact@v4 + - name: Clone Go SDK + uses: actions/checkout@v4 with: - name: ${{ env.ARTIFACT_NAME }} - path: dist/phase* + repository: phasehq/golang-sdk + path: golang-sdk - build_arm: - name: Build Linux ARM64 - runs-on: ubuntu-22.04-arm - steps: - - uses: actions/checkout@v4 - - name: Build with PyInstaller (manylinux_2_28 aarch64 via docker) - run: | - docker run --rm \ - -v "${PWD}":/workspace \ - -w /workspace \ - quay.io/pypa/manylinux_2_28_aarch64 \ - /bin/bash -lc 'set -euo pipefail; \ - echo "=== ldd version (manylinux_2_28 aarch64) ==="; \ - ldd --version; \ - echo "=== Installing build deps ==="; \ - yum -y install wget tar make gcc openssl-devel bzip2-devel libffi-devel zlib-devel >/dev/null; \ - echo "Install Python from source with --enable-shared to ensure a shared libpython is available for PyInstaller."; \ - echo "=== Building Python ${{ inputs.python_version }} with --enable-shared ==="; \ - PYVER="${{ inputs.python_version }}"; \ - MAJOR=$(echo "$PYVER" | cut -d. -f1); \ - MINOR=$(echo "$PYVER" | cut -d. -f2); \ - FULLVER=$(curl -s https://www.python.org/ftp/python/ | grep -oE ">${MAJOR}\\.${MINOR}\\.[0-9]+/" | tr -d ">/" | sort -V | tail -1 || echo "$PYVER"); \ - cd /tmp; \ - wget -q https://www.python.org/ftp/python/${FULLVER}/Python-${FULLVER}.tgz; \ - tar -xzf Python-${FULLVER}.tgz; \ - cd Python-${FULLVER}; \ - ./configure --prefix=/opt/py-shared --enable-shared --with-system-openssl >/dev/null; \ - make -j$(nproc) >/dev/null; \ - make install >/dev/null; \ - export LD_LIBRARY_PATH=/opt/py-shared/lib:$LD_LIBRARY_PATH; \ - /opt/py-shared/bin/python3 --version; \ - /opt/py-shared/bin/python3 -c '\''import ssl; print("SSL OK")'\''; \ - /opt/py-shared/bin/python3 -m pip install --upgrade pip >/dev/null; \ - cd /workspace; \ - /opt/py-shared/bin/python3 -m pip install -r requirements.txt >/dev/null; \ - /opt/py-shared/bin/python3 -m pip install pyinstaller==6.16.0 >/dev/null; \ - /opt/py-shared/bin/python3 -m PyInstaller --hidden-import _cffi_backend --paths ./phase_cli --name phase phase_cli/main.py' - - uses: actions/upload-artifact@v4 - with: - name: Linux-binary-arm64 - path: dist/phase* + - name: Patch go.mod replace directive for CI + working-directory: src + run: go mod edit -replace github.com/phasehq/golang-sdk=../golang-sdk - build_apk: - name: Build Alpine - runs-on: ${{ matrix.os }} - strategy: - matrix: - include: - - { os: ubuntu-22.04, arch: x86_64 } - - { os: ubuntu-22.04-arm, arch: aarch64 } - steps: - - uses: actions/checkout@v4 - - name: Build Alpine package + - name: Build all targets run: | - if [ "${{ matrix.arch }}" = "aarch64" ]; then - TARGET_ARCH="arm64" - elif [ "${{ matrix.arch }}" = "x86_64" ]; then - TARGET_ARCH="amd64" - fi - - # CONTEXT: phase_cli_linux__alpine_ artifact will contain phase_cli_linux__.apk - - OUTPUT_PACKAGE_NAME="phase_cli_linux_${TARGET_ARCH}_${{ inputs.version }}.apk" - ARTIFACT_NAME="phase_cli_linux_${TARGET_ARCH}_alpine_${{ inputs.version }}" - - echo "OUTPUT_PACKAGE_NAME=$OUTPUT_PACKAGE_NAME" >> $GITHUB_ENV - echo "ARTIFACT_NAME=$ARTIFACT_NAME" >> $GITHUB_ENV + VERSION="${{ inputs.version }}" OUTPUT_DIR=dist \ + bash scripts/build.sh - mkdir -p ./output - docker run --rm \ - -v "${PWD}":/workspace \ - -w /workspace \ - --env ABUILD_USER=builder \ - --env ARCH=${{ matrix.arch }} \ - --env OUTPUT_PACKAGE_NAME="$OUTPUT_PACKAGE_NAME" \ - alpine:${{ inputs.alpine_version }} \ - /bin/sh -ec " \ - set -ex; \ - apk update; \ - apk add --no-cache alpine-sdk python3 python3-dev py3-pip build-base git curl sudo doas; \ - adduser -D builder; \ - addgroup builder abuild; \ - echo 'builder ALL=(ALL) NOPASSWD: ALL' >> /etc/sudoers; \ - echo 'permit nopass builder as root' >> /etc/doas.conf; \ - chown -R builder /workspace; \ - sudo -u builder /bin/sh -ec 'cd /workspace && export HOME=/home/builder && abuild-keygen -a -i -n'; \ - sudo -u builder /bin/sh -ec 'cd /workspace && abuild -r'; \ - SOURCE_APK=\$(find /home/builder/packages/\$ARCH -name 'phase-*.apk' -print -quit); \ - cp \"\$SOURCE_APK\" \"/workspace/output/\$OUTPUT_PACKAGE_NAME\" \ - " - - name: Upload APK artifacts + - name: Upload binaries uses: actions/upload-artifact@v4 with: - name: ${{ env.ARTIFACT_NAME }} - path: ./output/${{ env.OUTPUT_PACKAGE_NAME }} + name: phase-cli-binaries + path: dist/ + retention-days: 7 diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 7c402204..46d19077 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -1,4 +1,4 @@ -name: Docker Build, Push, and Test +name: "Docker Build, Push, and Test" on: workflow_call: @@ -14,71 +14,73 @@ on: jobs: build_push: - name: Build & Release - Docker - runs-on: ubuntu-24.04 + name: Build & Push Docker Image + runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + - name: Clone Go SDK + uses: actions/checkout@v4 + with: + repository: phasehq/golang-sdk + path: golang-sdk + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v2 + uses: docker/setup-buildx-action@v3 - name: Login to Docker Hub - uses: docker/login-action@v2 + uses: docker/login-action@v3 with: username: ${{ secrets.DOCKER_HUB_USERNAME }} password: ${{ secrets.DOCKER_HUB_PASSWORD }} - name: Build and push - uses: docker/build-push-action@v4 + uses: docker/build-push-action@v6 with: context: . + file: Dockerfile platforms: linux/amd64,linux/arm64 push: true tags: | phasehq/cli:${{ inputs.version }} phasehq/cli:latest + build-args: | + VERSION=${{ inputs.version }} pull_test: - name: Test - CLI - Docker + name: Test Docker Image needs: build_push - runs-on: ubuntu-24.04 + runs-on: ubuntu-latest steps: - name: Pull versioned image run: docker pull phasehq/cli:${{ inputs.version }} - - name: Test versioned image version + - name: Test versioned image run: | - echo "Testing versioned image: phasehq/cli:${{ inputs.version }}" + echo "Testing phasehq/cli:${{ inputs.version }}" FULL_OUTPUT=$(docker run --rm phasehq/cli:${{ inputs.version }} --version) - echo "Full output: $FULL_OUTPUT" + echo "Output: $FULL_OUTPUT" RETURNED_VERSION=$(echo "$FULL_OUTPUT" | awk '{print $1}') - echo "Parsed version: $RETURNED_VERSION" - if [ -z "$RETURNED_VERSION" ]; then - echo "Error: Could not parse version from output" - exit 1 - fi if [ "$RETURNED_VERSION" != "${{ inputs.version }}" ]; then echo "Version mismatch: Expected ${{ inputs.version }}, got $RETURNED_VERSION" exit 1 fi - echo "Version check passed for versioned image" + echo "Version check passed" - name: Pull latest image run: docker pull phasehq/cli:latest - - name: Test latest image version + - name: Test latest image run: | - echo "Testing latest image: phasehq/cli:latest" + echo "Testing phasehq/cli:latest" FULL_OUTPUT=$(docker run --rm phasehq/cli:latest --version) - echo "Full output: $FULL_OUTPUT" + echo "Output: $FULL_OUTPUT" RETURNED_VERSION=$(echo "$FULL_OUTPUT" | awk '{print $1}') - echo "Parsed version: $RETURNED_VERSION" - if [ -z "$RETURNED_VERSION" ]; then - echo "Error: Could not parse version from output" - exit 1 - fi if [ "$RETURNED_VERSION" != "${{ inputs.version }}" ]; then echo "Version mismatch: Expected ${{ inputs.version }}, got $RETURNED_VERSION" exit 1 fi - echo "Version check passed for latest image" \ No newline at end of file + echo "Version check passed" diff --git a/.github/workflows/go-attach-to-release.yml b/.github/workflows/go-attach-to-release.yml deleted file mode 100644 index 76933a62..00000000 --- a/.github/workflows/go-attach-to-release.yml +++ /dev/null @@ -1,26 +0,0 @@ -name: "[Go] Attach Assets to Release" - -on: - workflow_call: - inputs: - version: - required: true - type: string - -jobs: - attach-to-release: - name: Attach Go Assets to Release - runs-on: ubuntu-latest - steps: - - name: Download processed assets - uses: actions/download-artifact@v4 - with: - name: phase-go-release - path: ./phase-go-release - - - name: Attach assets to release - uses: softprops/action-gh-release@v2 - with: - files: ./phase-go-release/* - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/go-build.yml b/.github/workflows/go-build.yml deleted file mode 100644 index 6f37c55f..00000000 --- a/.github/workflows/go-build.yml +++ /dev/null @@ -1,97 +0,0 @@ -name: "[Go] Cross-compile" - -on: - workflow_call: - inputs: - version: - required: true - type: string - -jobs: - build: - name: Build ${{ matrix.goos }}/${{ matrix.goarch }} - runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - include: - # macOS - - { goos: darwin, goarch: amd64, suffix: "" } - - { goos: darwin, goarch: arm64, suffix: "" } - # Linux - - { goos: linux, goarch: amd64, suffix: "" } - - { goos: linux, goarch: arm64, suffix: "" } - - { goos: linux, goarch: "386", suffix: "" } - - { goos: linux, goarch: arm, suffix: "" } - # Windows - - { goos: windows, goarch: amd64, suffix: ".exe" } - - { goos: windows, goarch: arm64, suffix: ".exe" } - - { goos: windows, goarch: "386", suffix: ".exe" } - # FreeBSD - - { goos: freebsd, goarch: amd64, suffix: "" } - - { goos: freebsd, goarch: arm64, suffix: "" } - - { goos: freebsd, goarch: "386", suffix: "" } - - { goos: freebsd, goarch: arm, suffix: "" } - # OpenBSD - - { goos: openbsd, goarch: amd64, suffix: "" } - - { goos: openbsd, goarch: arm64, suffix: "" } - # NetBSD - - { goos: netbsd, goarch: amd64, suffix: "" } - - { goos: netbsd, goarch: arm, suffix: "" } - # Dragonfly - - { goos: dragonfly, goarch: amd64, suffix: "" } - # Linux MIPS - - { goos: linux, goarch: mips, suffix: "" } - - { goos: linux, goarch: mipsle, suffix: "" } - - { goos: linux, goarch: mips64, suffix: "" } - - { goos: linux, goarch: mips64le, suffix: "" } - # Solaris / Illumos - - { goos: solaris, goarch: amd64, suffix: "" } - - { goos: illumos, goarch: amd64, suffix: "" } - - steps: - - uses: actions/checkout@v4 - - - name: Set up Go - uses: actions/setup-go@v5 - with: - go-version: "1.24" - cache-dependency-path: src/go.sum - - - name: Clone Go SDK - uses: actions/checkout@v4 - with: - repository: phasehq/golang-sdk - path: golang-sdk - - - name: Patch go.mod replace directive for CI - working-directory: src - run: | - go mod edit -replace github.com/phasehq/golang-sdk=../golang-sdk - - - name: Build - working-directory: src - env: - CGO_ENABLED: "0" - GOOS: ${{ matrix.goos }} - GOARCH: ${{ matrix.goarch }} - run: | - BINARY_NAME="phase${{ matrix.suffix }}" - LDFLAGS="-s -w -X github.com/phasehq/cli/cmd.Version=${{ inputs.version }}" - go build -ldflags "$LDFLAGS" -o "$BINARY_NAME" ./ - echo "Built: $BINARY_NAME for $GOOS/$GOARCH" - - - name: Prepare artifact - working-directory: src - run: | - ARTIFACT_DIR="phase-${{ matrix.goos }}-${{ matrix.goarch }}" - mkdir -p "$ARTIFACT_DIR" - cp "phase${{ matrix.suffix }}" "$ARTIFACT_DIR/" - echo "Artifact directory: $ARTIFACT_DIR" - - - name: Upload artifact - uses: actions/upload-artifact@v4 - with: - name: phase-${{ matrix.goos }}-${{ matrix.goarch }} - path: src/phase-${{ matrix.goos }}-${{ matrix.goarch }}/ - retention-days: 7 diff --git a/.github/workflows/go-docker.yml b/.github/workflows/go-docker.yml deleted file mode 100644 index 9d901546..00000000 --- a/.github/workflows/go-docker.yml +++ /dev/null @@ -1,86 +0,0 @@ -name: "[Go] Docker Build, Push, and Test" - -on: - workflow_call: - inputs: - version: - required: true - type: string - secrets: - DOCKER_HUB_USERNAME: - required: true - DOCKER_HUB_PASSWORD: - required: true - -jobs: - build_push: - name: Build & Push Docker Image - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - name: Clone Go SDK - uses: actions/checkout@v4 - with: - repository: phasehq/golang-sdk - path: golang-sdk - - - name: Set up QEMU - uses: docker/setup-qemu-action@v3 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Login to Docker Hub - uses: docker/login-action@v3 - with: - username: ${{ secrets.DOCKER_HUB_USERNAME }} - password: ${{ secrets.DOCKER_HUB_PASSWORD }} - - - name: Build and push - uses: docker/build-push-action@v6 - with: - context: . - file: Dockerfile - platforms: linux/amd64,linux/arm64 - push: true - tags: | - phasehq/cli:${{ inputs.version }} - phasehq/cli:latest - build-args: | - VERSION=${{ inputs.version }} - - pull_test: - name: Test Docker Image - needs: build_push - runs-on: ubuntu-latest - steps: - - name: Pull versioned image - run: docker pull phasehq/cli:${{ inputs.version }} - - - name: Test versioned image - run: | - echo "Testing phasehq/cli:${{ inputs.version }}" - FULL_OUTPUT=$(docker run --rm phasehq/cli:${{ inputs.version }} --version) - echo "Output: $FULL_OUTPUT" - RETURNED_VERSION=$(echo "$FULL_OUTPUT" | awk '{print $1}') - if [ "$RETURNED_VERSION" != "${{ inputs.version }}" ]; then - echo "Version mismatch: Expected ${{ inputs.version }}, got $RETURNED_VERSION" - exit 1 - fi - echo "Version check passed" - - - name: Pull latest image - run: docker pull phasehq/cli:latest - - - name: Test latest image - run: | - echo "Testing phasehq/cli:latest" - FULL_OUTPUT=$(docker run --rm phasehq/cli:latest --version) - echo "Output: $FULL_OUTPUT" - RETURNED_VERSION=$(echo "$FULL_OUTPUT" | awk '{print $1}') - if [ "$RETURNED_VERSION" != "${{ inputs.version }}" ]; then - echo "Version mismatch: Expected ${{ inputs.version }}, got $RETURNED_VERSION" - exit 1 - fi - echo "Version check passed" diff --git a/.github/workflows/go-main.yml b/.github/workflows/go-main.yml deleted file mode 100644 index 90f12a10..00000000 --- a/.github/workflows/go-main.yml +++ /dev/null @@ -1,71 +0,0 @@ -name: "[Go] Test, Build, Package the Phase CLI" - -on: - pull_request: - paths: - - "src/**" - - "Dockerfile" - - ".github/workflows/go-*.yml" - push: - branches: - - main - paths: - - "src/**" - - "Dockerfile" - - ".github/workflows/go-*.yml" - release: - types: [created] - -permissions: - contents: write - pull-requests: write - -jobs: - - # Run Go tests and vet - go-test: - uses: ./.github/workflows/go-test.yml - - # Extract version from Go source - go-version: - uses: ./.github/workflows/go-version.yml - - # Cross-compile for all platforms - go-build: - needs: [go-test, go-version] - uses: ./.github/workflows/go-build.yml - with: - version: ${{ needs.go-version.outputs.version }} - - # Package, hash, and zip release assets - go-process-assets: - needs: [go-build, go-version] - uses: ./.github/workflows/go-process-assets.yml - with: - version: ${{ needs.go-version.outputs.version }} - - # Attach release assets to GitHub release - go-attach-to-release: - if: github.event_name == 'release' && github.event.action == 'created' - needs: [go-process-assets, go-version] - uses: ./.github/workflows/go-attach-to-release.yml - with: - version: ${{ needs.go-version.outputs.version }} - - # Build and push Docker image - go-docker: - if: github.event_name == 'release' && github.event.action == 'created' - needs: [go-version] - uses: ./.github/workflows/go-docker.yml - with: - version: ${{ needs.go-version.outputs.version }} - secrets: - DOCKER_HUB_USERNAME: ${{ secrets.DOCKER_HUB_USERNAME }} - DOCKER_HUB_PASSWORD: ${{ secrets.DOCKER_HUB_PASSWORD }} - - # Test installation on various Linux distros - go-test-install: - needs: [go-build, go-version] - uses: ./.github/workflows/go-test-install-post-build.yml - with: - version: ${{ needs.go-version.outputs.version }} diff --git a/.github/workflows/go-process-assets.yml b/.github/workflows/go-process-assets.yml deleted file mode 100644 index c9bdf8d1..00000000 --- a/.github/workflows/go-process-assets.yml +++ /dev/null @@ -1,92 +0,0 @@ -name: "[Go] Process and Package Assets" - -on: - workflow_call: - inputs: - version: - required: true - type: string - -jobs: - process-assets: - name: Process and Package Assets - runs-on: ubuntu-latest - - steps: - - name: Download all build artifacts - uses: actions/download-artifact@v4 - with: - path: artifacts - - - name: Package and hash assets - run: | - set -e - VERSION="${{ inputs.version }}" - OUTDIR="phase-go-release" - mkdir -p "$OUTDIR" - - # Map of artifact dirs to output names - # Format: artifact_name output_file - declare -A TARGETS=( - # Tier 1: Primary platforms (tar.gz for unix, zip for windows) - ["phase-darwin-amd64"]="phase_cli_darwin_amd64_${VERSION}" - ["phase-darwin-arm64"]="phase_cli_darwin_arm64_${VERSION}" - ["phase-linux-amd64"]="phase_cli_linux_amd64_${VERSION}" - ["phase-linux-arm64"]="phase_cli_linux_arm64_${VERSION}" - ["phase-linux-386"]="phase_cli_linux_386_${VERSION}" - ["phase-linux-arm"]="phase_cli_linux_arm_${VERSION}" - ["phase-windows-amd64"]="phase_cli_windows_amd64_${VERSION}" - ["phase-windows-arm64"]="phase_cli_windows_arm64_${VERSION}" - ["phase-windows-386"]="phase_cli_windows_386_${VERSION}" - # Tier 2: BSD variants - ["phase-freebsd-amd64"]="phase_cli_freebsd_amd64_${VERSION}" - ["phase-freebsd-arm64"]="phase_cli_freebsd_arm64_${VERSION}" - ["phase-freebsd-386"]="phase_cli_freebsd_386_${VERSION}" - ["phase-freebsd-arm"]="phase_cli_freebsd_arm_${VERSION}" - ["phase-openbsd-amd64"]="phase_cli_openbsd_amd64_${VERSION}" - ["phase-openbsd-arm64"]="phase_cli_openbsd_arm64_${VERSION}" - ["phase-netbsd-amd64"]="phase_cli_netbsd_amd64_${VERSION}" - ["phase-netbsd-arm"]="phase_cli_netbsd_arm_${VERSION}" - ["phase-dragonfly-amd64"]="phase_cli_dragonfly_amd64_${VERSION}" - # Tier 3: MIPS / Solaris / Illumos - ["phase-linux-mips"]="phase_cli_linux_mips_${VERSION}" - ["phase-linux-mipsle"]="phase_cli_linux_mipsle_${VERSION}" - ["phase-linux-mips64"]="phase_cli_linux_mips64_${VERSION}" - ["phase-linux-mips64le"]="phase_cli_linux_mips64le_${VERSION}" - ["phase-solaris-amd64"]="phase_cli_solaris_amd64_${VERSION}" - ["phase-illumos-amd64"]="phase_cli_illumos_amd64_${VERSION}" - ) - - for artifact_name in "${!TARGETS[@]}"; do - base_name="${TARGETS[$artifact_name]}" - artifact_path="artifacts/${artifact_name}" - - if [ ! -d "$artifact_path" ]; then - echo "Warning: Artifact $artifact_name not found, skipping" - continue - fi - - # Windows gets .zip, everything else gets .tar.gz - if [[ "$artifact_name" == *windows* ]]; then - archive="${base_name}.zip" - (cd "$artifact_path" && zip -r "../../${OUTDIR}/${archive}" .) - else - archive="${base_name}.tar.gz" - tar -czf "${OUTDIR}/${archive}" -C "$artifact_path" . - fi - - # Generate SHA256 - (cd "$OUTDIR" && sha256sum "$archive" > "${archive}.sha256") - echo "Packaged: $archive" - done - - echo "" - echo "=== Release assets ===" - ls -lh "$OUTDIR/" - - - name: Upload processed assets - uses: actions/upload-artifact@v4 - with: - name: phase-go-release - path: ./phase-go-release/ - retention-days: 7 diff --git a/.github/workflows/go-test-install-post-build.yml b/.github/workflows/go-test-install-post-build.yml deleted file mode 100644 index 4a6e28a0..00000000 --- a/.github/workflows/go-test-install-post-build.yml +++ /dev/null @@ -1,105 +0,0 @@ -name: "[Go] Test Installation on Linux" - -on: - workflow_call: - inputs: - version: - required: true - type: string - -jobs: - test_install_x86_64: - name: Test on Linux distros (x86_64) - continue-on-error: true - runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - include: - - { image: "ubuntu:22.04", name: ubuntu-22.04 } - - { image: "ubuntu:24.04", name: ubuntu-24.04 } - - { image: "debian:bookworm", name: debian-bookworm } - - { image: "debian:trixie", name: debian-trixie } - - { image: "fedora:41", name: fedora-41 } - - { image: "fedora:42", name: fedora-42 } - - { image: "rockylinux:9", name: rocky-9 } - - { image: "amazonlinux:2023", name: amazonlinux-2023 } - - { image: "alpine:3.21", name: alpine-3.21 } - - { image: "alpine:3.22", name: alpine-3.22 } - - { image: "archlinux:latest", name: archlinux-latest } - steps: - - name: Download linux/amd64 binary - uses: actions/download-artifact@v4 - with: - name: phase-linux-amd64 - path: binary - - - name: Run tests in ${{ matrix.name }} - run: | - set -e - chmod +x binary/phase - docker run --rm \ - -v "${PWD}/binary":/binary \ - ${{ matrix.image }} \ - /bin/sh -c "\ - install -Dm755 /binary/phase /usr/local/bin/phase && \ - echo '=== Verify phase exists ===' && \ - command -v phase && \ - echo '=== phase --version ===' && \ - phase --version && \ - echo '=== phase --help (head) ===' && \ - phase --help | head -20 && \ - echo '=== version check ===' && \ - RETURNED_VERSION=\$(phase --version | awk '{print \$1}') && \ - if [ \"\$RETURNED_VERSION\" != \"${{ inputs.version }}\" ]; then \ - echo \"Version mismatch: expected ${{ inputs.version }}, got \$RETURNED_VERSION\"; \ - exit 1; \ - fi && \ - echo 'All checks passed' \ - " - shell: bash - - test_install_arm64: - name: Test on Linux distros (ARM64) - continue-on-error: true - runs-on: ubuntu-22.04-arm - strategy: - fail-fast: false - matrix: - include: - - { image: "ubuntu:24.04", name: ubuntu-24.04 } - - { image: "debian:bookworm", name: debian-bookworm } - - { image: "fedora:42", name: fedora-42 } - - { image: "alpine:3.21", name: alpine-3.21 } - - { image: "amazonlinux:2023", name: amazonlinux-2023 } - steps: - - name: Download linux/arm64 binary - uses: actions/download-artifact@v4 - with: - name: phase-linux-arm64 - path: binary - - - name: Run tests in ${{ matrix.name }} (arm64) - run: | - set -e - chmod +x binary/phase - docker run --rm \ - -v "${PWD}/binary":/binary \ - ${{ matrix.image }} \ - /bin/sh -c "\ - install -Dm755 /binary/phase /usr/local/bin/phase && \ - echo '=== Verify phase exists ===' && \ - command -v phase && \ - echo '=== phase --version ===' && \ - phase --version && \ - echo '=== phase --help (head) ===' && \ - phase --help | head -20 && \ - echo '=== version check ===' && \ - RETURNED_VERSION=\$(phase --version | awk '{print \$1}') && \ - if [ \"\$RETURNED_VERSION\" != \"${{ inputs.version }}\" ]; then \ - echo \"Version mismatch: expected ${{ inputs.version }}, got \$RETURNED_VERSION\"; \ - exit 1; \ - fi && \ - echo 'All checks passed' \ - " - shell: bash diff --git a/.github/workflows/go-version.yml b/.github/workflows/go-version.yml deleted file mode 100644 index 024ae138..00000000 --- a/.github/workflows/go-version.yml +++ /dev/null @@ -1,28 +0,0 @@ -name: "[Go] Validate and set version" - -on: - workflow_call: - outputs: - version: - description: "Phase CLI version extracted from Go source" - value: ${{ jobs.extract_version.outputs.version }} - -jobs: - extract_version: - name: Extract Go CLI Version - runs-on: ubuntu-latest - outputs: - version: ${{ steps.get_version.outputs.version }} - steps: - - uses: actions/checkout@v4 - - - name: Extract version from src/cmd/root.go - id: get_version - run: | - VERSION=$(grep -oP '(?<=var Version = ")[^"]*' src/cmd/root.go) - if [ -z "$VERSION" ]; then - echo "Error: Could not extract version from src/cmd/root.go" - exit 1 - fi - echo "version=$VERSION" >> $GITHUB_OUTPUT - echo "Extracted version: $VERSION" diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 492e3fc0..7340281f 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -1,4 +1,4 @@ -name: Test, Build, Package the Phase CLI +name: "Test, Build, Package the Phase CLI" on: pull_request: @@ -14,77 +14,58 @@ permissions: jobs: - # Run Tests - pytest: - uses: ./.github/workflows/pytest.yml - with: - python_version: '3.12' + # Run Go tests and vet + test: + uses: ./.github/workflows/test.yml - # Fetch and validate version from source code + # Extract version from Go source version: uses: ./.github/workflows/version.yml - # Build and package the CLI using PyInstaller for Windows(amd64), Mac (Intel - amd64, Apple silicon arm64), Alpine linux (amd64), Linux (amd64, arm64) .deb, .rpm, binaries - - # TODO: Add arm64 support for windows, arm64 packages (deb, rpm, apk) + # Cross-compile for all platforms build: - needs: [pytest, version] + needs: [test, version] uses: ./.github/workflows/build.yml with: - python_version: '3.12' version: ${{ needs.version.outputs.version }} - alpine_version: '3.21' - # Build docker image, push it to :latest and : OS/Arch linux/amd64, linux/arm64, pull images, run the CLI and validate version - docker: - if: github.event_name == 'release' && github.event.action == 'created' - needs: [version] - uses: ./.github/workflows/docker.yml + # Package binaries into .deb, .rpm, .apk + package: + needs: [build, version] + uses: ./.github/workflows/package.yml with: version: ${{ needs.version.outputs.version }} - secrets: - DOCKER_HUB_USERNAME: ${{ secrets.DOCKER_HUB_USERNAME }} - DOCKER_HUB_PASSWORD: ${{ secrets.DOCKER_HUB_PASSWORD }} - # Build, package and upload to pypi, install, run the cli and validate version - pypi: - if: github.event_name == 'release' && github.event.action == 'created' - needs: [version] - uses: ./.github/workflows/pypi.yml - with: - version: ${{ needs.version.outputs.version }} - secrets: - PYPI_USERNAME: ${{ secrets.PYPI_USERNAME }} - PYPI_PASSWORD: ${{ secrets.PYPI_PASSWORD }} - - # Download packages, builds, binaries from build stage, rename, hash and zip the assets via the script - process-assets: - needs: [build, version] - uses: ./.github/workflows/process-assets.yml + # Test binaries and packages on Linux distros + test-install: + needs: [build, package, version] + uses: ./.github/workflows/test-install-post-build.yml with: version: ${{ needs.version.outputs.version }} - # Download packaged assets zip and attach them to a release as assets + # Attach release assets to GitHub release attach-to-release: if: github.event_name == 'release' && github.event.action == 'created' - needs: [process-assets, version] + needs: [build, package, version] uses: ./.github/workflows/attach-to-release.yml with: version: ${{ needs.version.outputs.version }} - # Install, run and validate CLI version on various Linux distros - test-install-post-build: - needs: [build, version] - uses: ./.github/workflows/test-install-post-build.yml + # Build and push Docker image + docker: + if: github.event_name == 'release' && github.event.action == 'created' + needs: [version] + uses: ./.github/workflows/docker.yml with: version: ${{ needs.version.outputs.version }} + secrets: + DOCKER_HUB_USERNAME: ${{ secrets.DOCKER_HUB_USERNAME }} + DOCKER_HUB_PASSWORD: ${{ secrets.DOCKER_HUB_PASSWORD }} - # Install, run and validate CLI version using installer script - test-cli-install-post-release: - needs: [version, attach-to-release] - if: github.event_name != 'release' || (github.event_name == 'release' && github.event.action == 'created') - uses: ./.github/workflows/test-cli-install-post-release.yml + # Test install.sh script end-to-end after release assets are published + test-install-script: + if: github.event_name == 'release' && github.event.action == 'created' + needs: [attach-to-release, version] + uses: ./.github/workflows/test-install-script.yml with: version: ${{ needs.version.outputs.version }} - python_version: '3.12' - alpine_version: '3.21' \ No newline at end of file diff --git a/.github/workflows/package.yml b/.github/workflows/package.yml new file mode 100644 index 00000000..f2429375 --- /dev/null +++ b/.github/workflows/package.yml @@ -0,0 +1,39 @@ +name: "Package (deb/rpm/apk)" + +on: + workflow_call: + inputs: + version: + required: true + type: string + +jobs: + package: + name: Build packages (deb/rpm/apk) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install FPM + run: | + sudo apt-get update -qq + sudo apt-get install -y -qq ruby ruby-dev build-essential > /dev/null + sudo gem install fpm --no-document + + - name: Download binaries + uses: actions/download-artifact@v4 + with: + name: phase-cli-binaries + path: dist + + - name: Build packages + run: | + chmod +x dist/phase-cli_linux_* + VERSION="${{ inputs.version }}" bash scripts/package.sh + + - name: Upload packages + uses: actions/upload-artifact@v4 + with: + name: phase-cli-packages + path: dist/pkg/ + retention-days: 7 diff --git a/.github/workflows/process-assets.yml b/.github/workflows/process-assets.yml deleted file mode 100644 index 5890fe7c..00000000 --- a/.github/workflows/process-assets.yml +++ /dev/null @@ -1,34 +0,0 @@ -name: Process and Package Assets - -on: - workflow_call: - inputs: - version: - required: true - type: string - -jobs: - process-assets: - name: Process and Package Assets - runs-on: ubuntu-24.04 - - steps: - - uses: actions/checkout@v4 - - - name: Download all artifacts - uses: actions/download-artifact@v4 - - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: '3.12' - - - name: Process assets - run: | - python process_assets.py . phase-cli-release/ --version ${{ inputs.version }} - - - name: Upload processed assets - uses: actions/upload-artifact@v4 - with: - name: phase-cli-release - path: ./phase-cli-release/ diff --git a/.github/workflows/pypi.yml b/.github/workflows/pypi.yml deleted file mode 100644 index 1fbbbf87..00000000 --- a/.github/workflows/pypi.yml +++ /dev/null @@ -1,46 +0,0 @@ -name: Build and Deploy to PyPI - -on: - workflow_call: - inputs: - version: - required: true - type: string - secrets: - PYPI_USERNAME: - required: true - PYPI_PASSWORD: - required: true - -jobs: - build_and_deploy: - name: Build and Deploy to PyPI - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: '3.12' - - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install setuptools wheel twine - - # Versioning is handled in setup.py - - name: Build distribution - run: python setup.py sdist bdist_wheel - - - name: Upload to PyPI - env: - TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} - TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} - run: twine upload dist/* - - - name: Upload distribution artifacts - uses: actions/upload-artifact@v4 - with: - name: dist - path: dist/ diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml deleted file mode 100644 index bd8cb0c7..00000000 --- a/.github/workflows/pytest.yml +++ /dev/null @@ -1,22 +0,0 @@ -name: Run Pytest - -on: - workflow_call: - inputs: - python_version: - required: true - type: string - -jobs: - pytest: - name: Run Tests - runs-on: ubuntu-24.04 - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 - with: - python-version: ${{ inputs.python_version }} - - run: pip install -r dev-requirements.txt - - name: Set PYTHONPATH - run: echo "PYTHONPATH=$PWD" >> $GITHUB_ENV - - run: pytest tests/*.py diff --git a/.github/workflows/test-cli-install-post-release.yml b/.github/workflows/test-cli-install-post-release.yml deleted file mode 100644 index 69897e22..00000000 --- a/.github/workflows/test-cli-install-post-release.yml +++ /dev/null @@ -1,230 +0,0 @@ -name: Test CLI Installation - -on: - workflow_call: - inputs: - version: - required: true - type: string - python_version: - required: true - type: string - alpine_version: - required: true - type: string - -jobs: - test_install_x86_64: - name: Test install on Linux distros (x86_64) - runs-on: ubuntu-22.04 - strategy: - fail-fast: false - matrix: - include: - - { image: ubuntu:20.04, name: ubuntu-20.04 } - - { image: ubuntu:22.04, name: ubuntu-22.04 } - - { image: ubuntu:24.04, name: ubuntu-24.04 } - - { image: debian:bullseye, name: debian-bullseye } - - { image: debian:bookworm, name: debian-bookworm } - - { image: debian:trixie, name: debian-trixie } - - { image: fedora:39, name: fedora-39 } - - { image: fedora:40, name: fedora-40 } - - { image: fedora:41, name: fedora-41 } - - { image: fedora:42, name: fedora-42 } - - { image: rockylinux:8, name: rocky-8 } - - { image: rockylinux:9, name: rocky-9 } - - { image: amazonlinux:2023, name: amazonlinux-2023 } - - { image: alpine:3.20, name: alpine-3.20 } - - { image: alpine:3.21, name: alpine-3.21 } - - { image: alpine:3.22, name: alpine-3.22 } - - { image: archlinux:latest, name: archlinux-latest } - steps: - - uses: actions/checkout@v4 - - name: Run tests in ${{ matrix.name }} container - run: | - set -e - echo "=== Running in ${{ matrix.image }} ===" - docker run --rm \ - -v "${PWD}":/workspace \ - -w /workspace \ - ${{ matrix.image }} \ - /bin/sh -ec "\ - echo '=== ldd version ==='; \ - if command -v ldd >/dev/null 2>&1; then ldd --version || true; else echo 'ldd not found'; fi; \ - if ! command -v find >/dev/null 2>&1; then \ - (command -v dnf >/dev/null 2>&1 && dnf install -y findutils) || \ - (command -v apt-get >/dev/null 2>&1 && apt-get update && apt-get install -y findutils) || \ - (command -v apk >/dev/null 2>&1 && apk add --no-cache findutils) || \ - (command -v yum >/dev/null 2>&1 && yum install -y findutils) || \ - (command -v pacman >/dev/null 2>&1 && pacman -Sy --noconfirm findutils) || true; \ - fi; \ - echo '=== Installing via install.sh ==='; \ - chmod +x ./install.sh; \ - ./install.sh --version '${{ inputs.version }}'; \ - # Ensure minimal locale/terminal env for prompt_toolkit/questionary - export TERM=xterm; \ - export LANG=C.UTF-8; \ - export LC_ALL=C.UTF-8; \ - export PYTHONIOENCODING=UTF-8; \ - echo '=== Verifying ==='; \ - if command -v phase >/dev/null 2>&1; then echo 'phase found'; else echo 'phase not found in PATH'; echo "PATH=$PATH"; ls -l /usr/local/bin || true; ls -l /usr/bin || true; exit 1; fi; \ - echo '=== phase (no args) ==='; \ - phase || true; \ - echo '=== phase -v ==='; \ - phase -v; \ - echo '=== phase users keyring ==='; \ - phase users keyring || true; \ - echo '=== phase --help (head) ==='; \ - phase --help | head -20 || true; \ - echo '=== ldd version (post) ==='; \ - ldd --version || true; \ - " - shell: bash - - test-alpine-apk-install: - name: Test Alpine APK Installation - strategy: - matrix: - include: - - { os: ubuntu-22.04, arch: x86_64 } - - { os: ubuntu-22.04-arm, arch: aarch64 } - runs-on: ${{ matrix.os }} - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Test in Alpine container - run: | - docker run --rm \ - -v "${PWD}":/workspace \ - -w /workspace \ - alpine:${{ inputs.alpine_version }} \ - /bin/sh -ec " - echo '=== Testing Alpine APK installation on ${{ matrix.arch }} ===' - apk add --no-cache bash - chmod +x ./install.sh - ./install.sh --version '${{ inputs.version }}' - - echo '=== Verifying installation ===' - which phase - - echo '=== Checking version ===' - installed_version=\$(phase -v) - expected_version='${{ inputs.version }}' - if [ \"\$installed_version\" != \"\$expected_version\" ]; then - echo \"Version mismatch: Expected \$expected_version, got \$installed_version\" - exit 1 - fi - echo \"CLI version matches: \$installed_version\" - - echo '=== Testing basic functionality ===' - phase --help | head -10 - " - - test_install_arm64: - name: Test install on Linux distros (ARM64) - runs-on: ubuntu-22.04-arm - strategy: - fail-fast: false - matrix: - include: - - { image: ubuntu:20.04, name: ubuntu-20.04 } - - { image: ubuntu:22.04, name: ubuntu-22.04 } - - { image: ubuntu:24.04, name: ubuntu-24.04 } - - { image: debian:bullseye, name: debian-bullseye } - - { image: debian:bookworm, name: debian-bookworm } - - { image: debian:trixie, name: debian-trixie } - - { image: fedora:39, name: fedora-39 } - - { image: fedora:40, name: fedora-40 } - - { image: fedora:41, name: fedora-41 } - - { image: fedora:42, name: fedora-42 } - - { image: amazonlinux:2023, name: amazonlinux-2023 } - - { image: alpine:3.20, name: alpine-3.20 } - - { image: alpine:3.21, name: alpine-3.21 } - - { image: alpine:3.22, name: alpine-3.22 } - steps: - - uses: actions/checkout@v4 - - name: Run tests in ${{ matrix.name }} container - run: | - set -e - echo "=== Running in ${{ matrix.image }} (arm64) ===" - docker run --rm \ - -v "${PWD}":/workspace \ - -w /workspace \ - ${{ matrix.image }} \ - /bin/sh -ec "\ - echo '=== ldd version ==='; \ - if command -v ldd >/dev/null 2>&1; then ldd --version || true; else echo 'ldd not found'; fi; \ - if ! command -v find >/dev/null 2>&1; then \ - (command -v dnf >/dev/null 2>&1 && dnf install -y findutils) || \ - (command -v apt-get >/dev/null 2>&1 && apt-get update && apt-get install -y findutils) || \ - (command -v apk >/dev/null 2>&1 && apk add --no-cache findutils) || \ - (command -v yum >/dev/null 2>&1 && yum install -y findutils) || \ - (command -v pacman >/dev/null 2>&1 && pacman -Sy --noconfirm findutils) || true; \ - fi; \ - echo '=== Installing via install.sh ==='; \ - chmod +x ./install.sh; \ - ./install.sh --version '${{ inputs.version }}'; \ - # Ensure minimal locale/terminal env for prompt_toolkit/questionary - export TERM=xterm; \ - export LANG=C.UTF-8; \ - export LC_ALL=C.UTF-8; \ - export PYTHONIOENCODING=UTF-8; \ - echo '=== Verifying ==='; \ - if command -v phase >/dev/null 2>&1; then echo 'phase found'; else echo 'phase not found in PATH'; echo "PATH=$PATH"; ls -l /usr/local/bin || true; ls -l /usr/bin || true; exit 1; fi; \ - echo '=== phase (no args) ==='; \ - phase || true; \ - echo '=== phase -v ==='; \ - phase -v; \ - echo '=== phase users keyring ==='; \ - phase users keyring || true; \ - echo '=== phase --help (head) ==='; \ - phase --help | head -20 || true; \ - echo '=== ldd version (post) ==='; \ - ldd --version || true; \ - " - shell: bash - - test-pip-install: - name: Test PyPI (pip) Installation - strategy: - matrix: - os: [ubuntu-22.04, windows-2022, macos-13, macos-14, macos-15, macos-26, ubuntu-22.04-arm] - runs-on: ${{ matrix.os }} - steps: - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: ${{ inputs.python_version }} - - - name: Install package from PyPI - run: | - python -m pip install --upgrade pip - pip install phase-cli==${{ inputs.version }} - - - name: Test installed package (Linux/macOS) - if: runner.os != 'Windows' - run: | - installed_version=$(phase -v) - echo "Installed version: $installed_version" - echo "Expected version: ${{ inputs.version }}" - if [ "$installed_version" != "${{ inputs.version }}" ]; then - echo "Version mismatch!" - exit 1 - fi - phase --help - - - name: Test installed package (Windows) - if: runner.os == 'Windows' - shell: pwsh - env: - PYTHONIOENCODING: UTF-8 - run: | - $installed_version = phase -v - echo "Installed version: $installed_version" - echo "Expected version: ${{ inputs.version }}" - if ($installed_version -ne "${{ inputs.version }}") { - echo "Version mismatch!" - exit 1 - } - phase --help diff --git a/.github/workflows/test-install-post-build.yml b/.github/workflows/test-install-post-build.yml index c851897c..13518fbe 100644 --- a/.github/workflows/test-install-post-build.yml +++ b/.github/workflows/test-install-post-build.yml @@ -1,4 +1,4 @@ -name: Test CLI Installation on Linux +name: "Test Installation on Linux" on: workflow_call: @@ -9,227 +9,153 @@ on: jobs: test_install_x86_64: - name: Test install on Linux distros (x86_64) + name: Test on Linux distros (x86_64) continue-on-error: true - runs-on: ubuntu-22.04 + runs-on: ubuntu-latest strategy: fail-fast: false matrix: include: - - { image: ubuntu:20.04, family: deb, name: ubuntu-20.04 } - - { image: ubuntu:22.04, family: deb, name: ubuntu-22.04 } - - { image: ubuntu:24.04, family: deb, name: ubuntu-24.04 } - - { image: debian:bullseye, family: deb, name: debian-bullseye } - - { image: debian:bookworm, family: deb, name: debian-bookworm } - - { image: debian:trixie, family: deb, name: debian-trixie } - - { image: fedora:39, family: rpm, name: fedora-39 } - - { image: fedora:40, family: rpm, name: fedora-40 } - - { image: fedora:41, family: rpm, name: fedora-41 } - - { image: fedora:42, family: rpm, name: fedora-42 } - - { image: rockylinux:8, family: rpm, name: rocky-8 } - - { image: rockylinux:9, family: rpm, name: rocky-9 } - - { image: amazonlinux:2023, family: rpm, name: amazonlinux-2023 } - - { image: alpine:3.20, family: alpine, name: alpine-3.20 } - - { image: alpine:3.21, family: alpine, name: alpine-3.21 } - - { image: alpine:3.22, family: alpine, name: alpine-3.22 } - - { image: archlinux:latest, family: other, name: archlinux-latest } + # DEB package installs + - { image: "ubuntu:22.04", name: ubuntu-22.04, install: deb } + - { image: "ubuntu:24.04", name: ubuntu-24.04, install: deb } + - { image: "debian:bookworm", name: debian-bookworm, install: deb } + - { image: "debian:trixie", name: debian-trixie, install: deb } + # RPM package installs + - { image: "fedora:41", name: fedora-41, install: rpm } + - { image: "fedora:42", name: fedora-42, install: rpm } + - { image: "rockylinux:9", name: rocky-9, install: rpm } + - { image: "amazonlinux:2023", name: amazonlinux-2023, install: rpm } + # APK package installs + - { image: "alpine:3.21", name: alpine-3.21, install: apk } + - { image: "alpine:3.22", name: alpine-3.22, install: apk } + # Raw binary install + - { image: "archlinux:latest", name: archlinux-latest, install: binary } steps: - - uses: actions/checkout@v4 - - name: Download DEB artifact + - name: Download binaries uses: actions/download-artifact@v4 with: - name: phase-deb - path: deb - - name: Download RPM artifact - uses: actions/download-artifact@v4 - with: - name: phase-rpm - path: rpm - - name: Download APK artifact (amd64) - uses: actions/download-artifact@v4 - with: - name: phase_cli_linux_amd64_alpine_${{ inputs.version }} - path: apk-amd64 - continue-on-error: true - - name: Download Linux x86_64 binary artifact + name: phase-cli-binaries + path: binaries + + - name: Download packages uses: actions/download-artifact@v4 with: - name: Linux-binary - path: linux-amd64 - continue-on-error: true - - name: Run tests in ${{ matrix.name }} container + name: phase-cli-packages + path: packages + + - name: Run tests in ${{ matrix.name }} run: | set -e - echo "=== Running in ${{ matrix.image }} ===" + chmod +x binaries/phase-cli_linux_amd64 docker run --rm \ - -v "${PWD}":/workspace \ - -w /workspace \ + -v "${PWD}/binaries":/binaries \ + -v "${PWD}/packages":/packages \ ${{ matrix.image }} \ - /bin/sh -ec "\ - echo '=== ldd version ==='; \ - if command -v ldd >/dev/null 2>&1; then ldd --version || true; else echo 'ldd not found'; fi; \ - echo '=== Installing package ==='; \ - case '${{ matrix.family }}' in \ - deb) \ - apt-get update; \ - apt-get install -y ca-certificates; \ - dpkg -i deb/*.deb || apt-get -f install -y; \ - ;; \ - rpm) \ - # Prefer installing local RPM without contacting repos (avoids mirror DNS failures) - if command -v rpm >/dev/null 2>&1 && rpm -Uvh --nodeps --nosignature ./rpm/*.rpm; then \ - :; \ - elif command -v dnf >/dev/null 2>&1; then \ - dnf install -y --disablerepo='*' ./rpm/*.rpm || rpm -Uvh --nodeps --nosignature ./rpm/*.rpm; \ - elif command -v microdnf >/dev/null 2>&1; then \ - microdnf --disablerepo='*' install -y ./rpm/*.rpm || rpm -Uvh --nodeps --nosignature ./rpm/*.rpm; \ - else \ - yum --disablerepo='*' install -y ./rpm/*.rpm || rpm -Uvh --nodeps --nosignature ./rpm/*.rpm; \ - fi; \ - ;; \ - alpine) \ - apk update; \ - apk add --no-cache libstdc++ findutils; \ - ALP_VER=\$(cut -d. -f1,2 /etc/alpine-release); \ - if [ "\$ALP_VER" = "3.18" ] || [ "\$ALP_VER" = "3.19" ]; then \ - echo 'Using binary artifact for Alpine < 3.20'; \ - BINDIR=\$(find ./linux-amd64 -maxdepth 2 -type f -name phase -printf '%h\n' -quit); \ - if [ -z "\$BINDIR" ]; then echo 'x86_64 binary artifact not found'; exit 1; fi; \ - install -Dm755 "\$BINDIR/phase" /usr/local/bin/phase; \ - if [ -d "\$BINDIR/_internal" ]; then \ - rm -rf /usr/local/bin/_internal; \ - cp -a "\$BINDIR/_internal" /usr/local/bin/_internal; \ - fi; \ - else \ - apk add --no-cache --allow-untrusted ./apk-amd64/*.apk; \ - fi; \ - ;; \ - other) \ - echo 'Installing from local x86_64 binary artifact'; \ - if ! command -v find >/dev/null 2>&1; then \ - (command -v dnf >/dev/null 2>&1 && dnf install -y findutils) || \ - (command -v apt-get >/dev/null 2>&1 && apt-get update && apt-get install -y findutils) || \ - (command -v apk >/dev/null 2>&1 && apk add --no-cache findutils) || \ - (command -v yum >/dev/null 2>&1 && yum install -y findutils) || \ - (command -v pacman >/dev/null 2>&1 && pacman -Sy --noconfirm findutils) || true; \ - fi; \ - BINDIR=\$(find ./linux-amd64 -maxdepth 2 -type f -name phase -printf '%h\n' -quit); \ - if [ -z "\$BINDIR" ]; then echo 'x86_64 binary artifact not found'; exit 1; fi; \ - install -Dm755 "\$BINDIR/phase" /usr/local/bin/phase; \ - if [ -d "\$BINDIR/_internal" ]; then \ - rm -rf /usr/local/bin/_internal; \ - cp -a "\$BINDIR/_internal" /usr/local/bin/_internal; \ - fi; \ - ;; \ - esac; \ - # Ensure minimal locale/terminal env for prompt_toolkit/questionary - export TERM=xterm; \ - export LANG=C.UTF-8; \ - export LC_ALL=C.UTF-8; \ - export PYTHONIOENCODING=UTF-8; \ - echo '=== Verifying ==='; \ - if command -v phase >/dev/null 2>&1; then echo 'phase found'; else echo 'phase not found in PATH'; echo "PATH=$PATH"; ls -l /usr/local/bin || true; ls -l /usr/bin || true; exit 1; fi; \ - echo '=== phase (no args) ==='; \ - phase || true; \ - echo '=== phase -v ==='; \ - phase -v; \ - echo '=== phase users keyring ==='; \ - phase users keyring || true; \ - echo '=== phase --help (head) ==='; \ - phase --help | head -20 || true; \ - echo '=== ldd version (post) ==='; \ - ldd --version || true; \ + /bin/sh -c " + echo '=== Installing (${{ matrix.install }}) ===' + case '${{ matrix.install }}' in + deb) + dpkg -i /packages/phase_${{ inputs.version }}_amd64.deb + ;; + rpm) + rpm -i /packages/phase-${{ inputs.version }}-1.x86_64.rpm + ;; + apk) + apk add --allow-untrusted /packages/phase_${{ inputs.version }}_x86_64.apk + ;; + binary) + install -Dm755 /binaries/phase-cli_linux_amd64 /usr/local/bin/phase + ;; + esac + + echo '=== Verify phase exists ===' + command -v phase + + echo '=== phase --version ===' + phase --version + + echo '=== phase --help (head) ===' + phase --help | head -20 + + echo '=== version check ===' + RETURNED_VERSION=\$(phase --version | awk '{print \$1}') + if [ \"\$RETURNED_VERSION\" != \"${{ inputs.version }}\" ]; then + echo \"Version mismatch: expected ${{ inputs.version }}, got \$RETURNED_VERSION\" + exit 1 + fi + + echo 'All checks passed' " shell: bash test_install_arm64: - name: Test install on Linux distros (ARM64) + name: Test on Linux distros (ARM64) continue-on-error: true runs-on: ubuntu-22.04-arm strategy: fail-fast: false matrix: include: - - { image: ubuntu:20.04, family: other, name: ubuntu-20.04 } - - { image: ubuntu:22.04, family: other, name: ubuntu-22.04 } - - { image: ubuntu:24.04, family: other, name: ubuntu-24.04 } - - { image: debian:bullseye, family: other, name: debian-bullseye } - - { image: debian:bookworm, family: other, name: debian-bookworm } - - { image: debian:trixie, family: other, name: debian-trixie } - - { image: fedora:39, family: other, name: fedora-39 } - - { image: fedora:40, family: other, name: fedora-40 } - - { image: fedora:41, family: other, name: fedora-41 } - - { image: fedora:42, family: other, name: fedora-42 } - - { image: amazonlinux:2023, family: other, name: amazonlinux-2023 } - - { image: alpine:3.20, family: alpine, name: alpine-3.20 } - - { image: alpine:3.21, family: alpine, name: alpine-3.21 } - - { image: alpine:3.22, family: alpine, name: alpine-3.22 } + - { image: "ubuntu:24.04", name: ubuntu-24.04, install: deb } + - { image: "debian:bookworm", name: debian-bookworm, install: deb } + - { image: "fedora:42", name: fedora-42, install: rpm } + - { image: "alpine:3.21", name: alpine-3.21, install: apk } + - { image: "amazonlinux:2023", name: amazonlinux-2023, install: rpm } steps: - - uses: actions/checkout@v4 - - name: Download APK artifact (arm64) + - name: Download binaries uses: actions/download-artifact@v4 with: - name: phase_cli_linux_arm64_alpine_${{ inputs.version }} - path: apk-arm64 - continue-on-error: true - - name: Download Linux ARM64 binary artifact + name: phase-cli-binaries + path: binaries + + - name: Download packages uses: actions/download-artifact@v4 with: - name: Linux-binary-arm64 - path: linux-arm64 - continue-on-error: true - - name: Run tests in ${{ matrix.name }} container + name: phase-cli-packages + path: packages + + - name: Run tests in ${{ matrix.name }} (arm64) run: | set -e - echo "=== Running in ${{ matrix.image }} (arm64) ===" + chmod +x binaries/phase-cli_linux_arm64 docker run --rm \ - -v "${PWD}":/workspace \ - -w /workspace \ + -v "${PWD}/binaries":/binaries \ + -v "${PWD}/packages":/packages \ ${{ matrix.image }} \ - /bin/sh -ec "\ - echo '=== ldd version ==='; \ - if command -v ldd >/dev/null 2>&1; then ldd --version || true; else echo 'ldd not found'; fi; \ - case '${{ matrix.family }}' in \ - alpine) \ - apk update; \ - apk add --no-cache libstdc++; \ - apk add --no-cache --allow-untrusted ./apk-arm64/*.apk; \ - ;; \ - other) \ - echo 'Installing from local ARM64 binary artifact'; \ - if ! command -v find >/dev/null 2>&1; then \ - (command -v dnf >/dev/null 2>&1 && dnf install -y findutils) || \ - (command -v apt-get >/dev/null 2>&1 && apt-get update && apt-get install -y findutils) || \ - (command -v apk >/dev/null 2>&1 && apk add --no-cache findutils) || \ - (command -v yum >/dev/null 2>&1 && yum install -y findutils) || \ - (command -v pacman >/dev/null 2>&1 && pacman -Sy --noconfirm findutils) || true; \ - fi; \ - BINDIR=\$(find ./linux-arm64 -maxdepth 2 -type f -name phase -printf '%h\n' -quit); \ - if [ -z "\$BINDIR" ]; then echo 'ARM64 binary artifact not found'; exit 1; fi; \ - install -Dm755 "\$BINDIR/phase" /usr/local/bin/phase; \ - if [ -d "\$BINDIR/_internal" ]; then \ - rm -rf /usr/local/bin/_internal; \ - cp -a "\$BINDIR/_internal" /usr/local/bin/_internal; \ - fi; \ - ;; \ - esac; \ - # Ensure minimal locale/terminal env for prompt_toolkit/questionary - export TERM=xterm; \ - export LANG=C.UTF-8; \ - export LC_ALL=C.UTF-8; \ - export PYTHONIOENCODING=UTF-8; \ - echo '=== Verifying ==='; \ - if command -v phase >/dev/null 2>&1; then echo 'phase found'; else echo 'phase not found in PATH'; echo "PATH=$PATH"; ls -l /usr/local/bin || true; ls -l /usr/bin || true; exit 1; fi; \ - echo '=== phase (no args) ==='; \ - phase || true; \ - echo '=== phase -v ==='; \ - phase -v; \ - echo '=== phase users keyring ==='; \ - phase users keyring || true; \ - echo '=== phase --help (head) ==='; \ - phase --help | head -20 || true; \ - echo '=== ldd version (post) ==='; \ - ldd --version || true; \ + /bin/sh -c " + echo '=== Installing (${{ matrix.install }}) ===' + case '${{ matrix.install }}' in + deb) + dpkg -i /packages/phase_${{ inputs.version }}_arm64.deb + ;; + rpm) + rpm -i /packages/phase-${{ inputs.version }}-1.aarch64.rpm + ;; + apk) + apk add --allow-untrusted /packages/phase_${{ inputs.version }}_aarch64.apk + ;; + binary) + install -Dm755 /binaries/phase-cli_linux_arm64 /usr/local/bin/phase + ;; + esac + + echo '=== Verify phase exists ===' + command -v phase + + echo '=== phase --version ===' + phase --version + + echo '=== phase --help (head) ===' + phase --help | head -20 + + echo '=== version check ===' + RETURNED_VERSION=\$(phase --version | awk '{print \$1}') + if [ \"\$RETURNED_VERSION\" != \"${{ inputs.version }}\" ]; then + echo \"Version mismatch: expected ${{ inputs.version }}, got \$RETURNED_VERSION\" + exit 1 + fi + + echo 'All checks passed' " shell: bash - diff --git a/.github/workflows/test-install-script.yml b/.github/workflows/test-install-script.yml new file mode 100644 index 00000000..8a9c2bd9 --- /dev/null +++ b/.github/workflows/test-install-script.yml @@ -0,0 +1,131 @@ +name: "Test install.sh (post-release)" + +on: + workflow_call: + inputs: + version: + required: true + type: string + +jobs: + test_install_script_x86_64: + name: Test install.sh (x86_64) + continue-on-error: true + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - { image: "ubuntu:22.04", name: ubuntu-22.04 } + - { image: "ubuntu:24.04", name: ubuntu-24.04 } + - { image: "debian:bookworm", name: debian-bookworm } + - { image: "fedora:42", name: fedora-42 } + - { image: "rockylinux:9", name: rocky-9 } + - { image: "amazonlinux:2023", name: amazonlinux-2023 } + - { image: "alpine:3.21", name: alpine-3.21 } + - { image: "alpine:3.22", name: alpine-3.22 } + - { image: "archlinux:latest", name: archlinux-latest } + steps: + - uses: actions/checkout@v4 + + - name: Test install.sh in ${{ matrix.name }} + run: | + set -e + docker run --rm \ + -v "${PWD}/install.sh":/install.sh:ro \ + ${{ matrix.image }} \ + /bin/sh -c " + # Install curl or wget (needed by install.sh) + if command -v apt-get > /dev/null 2>&1; then + apt-get update -qq && apt-get install -y -qq curl ca-certificates > /dev/null 2>&1 + elif command -v dnf > /dev/null 2>&1; then + dnf install -y curl ca-certificates > /dev/null 2>&1 + elif command -v yum > /dev/null 2>&1; then + yum install -y curl ca-certificates > /dev/null 2>&1 + elif command -v apk > /dev/null 2>&1; then + apk add --no-cache curl ca-certificates > /dev/null 2>&1 + elif command -v pacman > /dev/null 2>&1; then + pacman -Sy --noconfirm curl ca-certificates > /dev/null 2>&1 + fi + + echo '=== Running install.sh --version ${{ inputs.version }} ===' + sh /install.sh --version '${{ inputs.version }}' + + echo '=== Verify phase exists ===' + command -v phase + + echo '=== phase --version ===' + phase --version + + echo '=== phase --help (head) ===' + phase --help | head -20 + + echo '=== version check ===' + RETURNED_VERSION=\$(phase --version | awk '{print \$1}') + if [ \"\$RETURNED_VERSION\" != \"${{ inputs.version }}\" ]; then + echo \"Version mismatch: expected ${{ inputs.version }}, got \$RETURNED_VERSION\" + exit 1 + fi + + echo 'All checks passed' + " + shell: bash + + test_install_script_arm64: + name: Test install.sh (ARM64) + continue-on-error: true + runs-on: ubuntu-22.04-arm + strategy: + fail-fast: false + matrix: + include: + - { image: "ubuntu:24.04", name: ubuntu-24.04 } + - { image: "debian:bookworm", name: debian-bookworm } + - { image: "fedora:42", name: fedora-42 } + - { image: "alpine:3.21", name: alpine-3.21 } + - { image: "amazonlinux:2023", name: amazonlinux-2023 } + steps: + - uses: actions/checkout@v4 + + - name: Test install.sh in ${{ matrix.name }} (arm64) + run: | + set -e + docker run --rm \ + -v "${PWD}/install.sh":/install.sh:ro \ + ${{ matrix.image }} \ + /bin/sh -c " + # Install curl or wget (needed by install.sh) + if command -v apt-get > /dev/null 2>&1; then + apt-get update -qq && apt-get install -y -qq curl ca-certificates > /dev/null 2>&1 + elif command -v dnf > /dev/null 2>&1; then + dnf install -y curl ca-certificates > /dev/null 2>&1 + elif command -v yum > /dev/null 2>&1; then + yum install -y curl ca-certificates > /dev/null 2>&1 + elif command -v apk > /dev/null 2>&1; then + apk add --no-cache curl ca-certificates > /dev/null 2>&1 + elif command -v pacman > /dev/null 2>&1; then + pacman -Sy --noconfirm curl ca-certificates > /dev/null 2>&1 + fi + + echo '=== Running install.sh --version ${{ inputs.version }} ===' + sh /install.sh --version '${{ inputs.version }}' + + echo '=== Verify phase exists ===' + command -v phase + + echo '=== phase --version ===' + phase --version + + echo '=== phase --help (head) ===' + phase --help | head -20 + + echo '=== version check ===' + RETURNED_VERSION=\$(phase --version | awk '{print \$1}') + if [ \"\$RETURNED_VERSION\" != \"${{ inputs.version }}\" ]; then + echo \"Version mismatch: expected ${{ inputs.version }}, got \$RETURNED_VERSION\" + exit 1 + fi + + echo 'All checks passed' + " + shell: bash diff --git a/.github/workflows/go-test.yml b/.github/workflows/test.yml similarity index 97% rename from .github/workflows/go-test.yml rename to .github/workflows/test.yml index efdec184..92637928 100644 --- a/.github/workflows/go-test.yml +++ b/.github/workflows/test.yml @@ -1,4 +1,4 @@ -name: "[Go] Test and Vet" +name: "Test and Vet" on: workflow_call: diff --git a/.github/workflows/version.yml b/.github/workflows/version.yml index 2c5253de..b971b75a 100644 --- a/.github/workflows/version.yml +++ b/.github/workflows/version.yml @@ -1,41 +1,28 @@ -name: Validate and set version +name: "Validate and set version" on: workflow_call: outputs: version: - description: "Validated Phase CLI version from const.py and APKBUILD" - value: ${{ jobs.validate_version.outputs.version }} + description: "Phase CLI version extracted from Go source" + value: ${{ jobs.extract_version.outputs.version }} jobs: - validate_version: - name: Validate and Extract Version + extract_version: + name: Extract Go CLI Version runs-on: ubuntu-latest outputs: - version: ${{ steps.extract_version.outputs.version }} + version: ${{ steps.get_version.outputs.version }} steps: - uses: actions/checkout@v4 - - name: Extract version from const.py - id: extract_version + - name: Extract version from src/pkg/version/version.go + id: get_version run: | - PHASE_CLI_VERSION=$(grep -oP '(?<=__version__ = ")[^"]*' phase_cli/utils/const.py) - echo "version=$PHASE_CLI_VERSION" >> $GITHUB_OUTPUT - # echo "$PHASE_CLI_VERSION" > ./PHASE_CLI_VERSION.txt - - - name: Extract version from APKBUILD - id: extract_apkbuild_version - run: | - APKBUILD_VERSION=$(grep -oP '(?<=pkgver=)[^\s]*' APKBUILD) - echo "apkbuild_version=$APKBUILD_VERSION" >> $GITHUB_OUTPUT - - - name: Compare versions - run: | - if [ "${{ steps.extract_version.outputs.version }}" != "${{ steps.extract_apkbuild_version.outputs.apkbuild_version }}" ]; then - echo "Version mismatch detected!" - echo "const.py version: ${{ steps.extract_version.outputs.version }}" - echo "APKBUILD version: ${{ steps.extract_apkbuild_version.outputs.apkbuild_version }}" + VERSION=$(grep -oP '(?<=var Version = ")[^"]*' src/pkg/version/version.go) + if [ -z "$VERSION" ]; then + echo "Error: Could not extract version from src/pkg/version/version.go" exit 1 - else - echo "Versions match: ${{ steps.extract_version.outputs.version }}" fi + echo "version=$VERSION" >> $GITHUB_OUTPUT + echo "Extracted version: $VERSION" diff --git a/scripts/package.sh b/scripts/package.sh new file mode 100755 index 00000000..fe296242 --- /dev/null +++ b/scripts/package.sh @@ -0,0 +1,105 @@ +#!/usr/bin/env bash +# +# Package Phase CLI binaries into .deb, .rpm, .apk using FPM. +# Requires: fpm (gem install fpm) +# +# Usage (from repo root): +# scripts/package.sh +# VERSION=2.0.0 INPUT_DIR=dist scripts/package.sh + +set -euo pipefail + +VERSION="${VERSION:-dev}" +INPUT_DIR="${INPUT_DIR:-dist}" +OUTPUT_DIR="${OUTPUT_DIR:-dist/pkg}" + +PACKAGE_NAME="phase" +DESCRIPTION="Phase CLI - open-source secret manager" +MAINTAINER="Phase " +URL="https://phase.dev" +LICENSE="GPL-3.0" + +# Arch mappings: Go name → package manager name +pkg_arch() { + local fmt="$1" go_arch="$2" + case "$fmt" in + deb) echo "$go_arch" ;; # deb uses amd64/arm64 as-is + rpm|apk) + case "$go_arch" in + amd64) echo "x86_64" ;; + arm64) echo "aarch64" ;; + esac + ;; + esac +} + +TARGETS=("amd64" "arm64") + +if ! command -v fpm > /dev/null; then + echo "Error: fpm is not installed. Install with: gem install fpm" >&2 + exit 1 +fi + +mkdir -p "$OUTPUT_DIR" + +echo "Packaging Phase CLI v${VERSION}..." +echo "" + +for arch in "${TARGETS[@]}"; do + binary="${INPUT_DIR}/phase-cli_linux_${arch}" + + if [ ! -f "$binary" ]; then + echo "Warning: $binary not found, skipping $arch" + continue + fi + + # Common flags (positional arg must come last, after per-format flags) + FPM_COMMON=( + -s dir + --name "$PACKAGE_NAME" + --version "$VERSION" + --maintainer "$MAINTAINER" + --description "$DESCRIPTION" + --url "$URL" + --license "$LICENSE" + --provides phase + --replaces "phase < 2.0.0" + --force + ) + + # .deb + printf " %-12s" "deb/${arch}" + fpm "${FPM_COMMON[@]}" -t deb \ + --architecture "$(pkg_arch deb "$arch")" \ + --package "${OUTPUT_DIR}/" \ + "${binary}=/usr/bin/phase" > /dev/null + echo "ok" + + # .rpm + printf " %-12s" "rpm/${arch}" + fpm "${FPM_COMMON[@]}" -t rpm \ + --architecture "$(pkg_arch rpm "$arch")" \ + --rpm-summary "$DESCRIPTION" \ + --package "${OUTPUT_DIR}/" \ + "${binary}=/usr/bin/phase" > /dev/null + echo "ok" + + # .apk + printf " %-12s" "apk/${arch}" + fpm "${FPM_COMMON[@]}" -t apk \ + --architecture "$(pkg_arch apk "$arch")" \ + --package "${OUTPUT_DIR}/" \ + "${binary}=/usr/bin/phase" > /dev/null + echo "ok" +done + +# Generate checksums +echo "" +if ls "$OUTPUT_DIR"/*.deb "$OUTPUT_DIR"/*.rpm "$OUTPUT_DIR"/*.apk > /dev/null; then + (cd "$OUTPUT_DIR" && sha256sum *.deb *.rpm *.apk > checksums.txt) + echo "Packages:" + ls -lh "$OUTPUT_DIR/" +else + echo "No packages were produced." + exit 1 +fi From b8ed09de589511549103dbde3a0cf42da759b7bd Mon Sep 17 00:00:00 2001 From: Nimish Date: Mon, 2 Mar 2026 20:18:11 +0530 Subject: [PATCH 46/50] feat: add back base64, base64url --- src/cmd/secrets_create.go | 6 +++++- src/cmd/secrets_update.go | 8 ++++++-- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/src/cmd/secrets_create.go b/src/cmd/secrets_create.go index d25d038a..4c527899 100644 --- a/src/cmd/secrets_create.go +++ b/src/cmd/secrets_create.go @@ -26,7 +26,7 @@ func init() { secretsCreateCmd.Flags().String("app-id", "", "Application ID") secretsCreateCmd.Flags().String("path", "/", "Path for the secret") secretsCreateCmd.Flags().Bool("override", false, "Create with override") - secretsCreateCmd.Flags().String("random", "", "Random type (hex, alphanumeric, key128, key256)") + secretsCreateCmd.Flags().String("random", "", "Random type (hex, alphanumeric, base64, base64url, key128, key256)") secretsCreateCmd.Flags().Int("length", 32, "Length for random secret") secretsCmd.AddCommand(secretsCreateCmd) } @@ -55,6 +55,10 @@ func runSecretsCreate(cmd *cobra.Command, args []string) error { if override { value = "" } else if randomType != "" { + validTypes := map[string]bool{"hex": true, "alphanumeric": true, "base64": true, "base64url": true, "key128": true, "key256": true} + if !validTypes[randomType] { + return fmt.Errorf("unsupported random type: %s. Supported types: hex, alphanumeric, base64, base64url, key128, key256", randomType) + } if (randomType == "key128" || randomType == "key256") && randomLength != 32 { fmt.Fprintf(os.Stderr, "⚠️\u200A Warning: The length argument is ignored for '%s'. Using default lengths.\n", randomType) } diff --git a/src/cmd/secrets_update.go b/src/cmd/secrets_update.go index 5f80506b..7aa0526b 100644 --- a/src/cmd/secrets_update.go +++ b/src/cmd/secrets_update.go @@ -25,11 +25,11 @@ func init() { secretsUpdateCmd.Flags().String("env", "", "Environment name") secretsUpdateCmd.Flags().String("app", "", "Application name") secretsUpdateCmd.Flags().String("app-id", "", "Application ID") - secretsUpdateCmd.Flags().String("path", "", "Source path of the secret") + secretsUpdateCmd.Flags().String("path", "/", "Source path of the secret") secretsUpdateCmd.Flags().String("updated-path", "", "New path for the secret") secretsUpdateCmd.Flags().Bool("override", false, "Update override value") secretsUpdateCmd.Flags().Bool("toggle-override", false, "Toggle override state") - secretsUpdateCmd.Flags().String("random", "", "Random type (hex, alphanumeric, key128, key256)") + secretsUpdateCmd.Flags().String("random", "", "Random type (hex, alphanumeric, base64, base64url, key128, key256)") secretsUpdateCmd.Flags().Int("length", 32, "Length for random secret") secretsCmd.AddCommand(secretsUpdateCmd) } @@ -60,6 +60,10 @@ func runSecretsUpdate(cmd *cobra.Command, args []string) error { if toggleOverride { // No value needed for toggle } else if randomType != "" { + validTypes := map[string]bool{"hex": true, "alphanumeric": true, "base64": true, "base64url": true, "key128": true, "key256": true} + if !validTypes[randomType] { + return fmt.Errorf("unsupported random type: %s. Supported types: hex, alphanumeric, base64, base64url, key128, key256", randomType) + } if (randomType == "key128" || randomType == "key256") && randomLength != 32 { fmt.Fprintf(os.Stderr, "⚠️\u200A Warning: The length argument is ignored for '%s'. Using default lengths.\n", randomType) } From 9764ef3e2d0c9d9ed031b74f1d2b4d3d2a24b31a Mon Sep 17 00:00:00 2001 From: Nimish Date: Mon, 2 Mar 2026 20:19:07 +0530 Subject: [PATCH 47/50] feat: add CLI host validation --- src/cmd/auth.go | 3 +++ src/pkg/util/misc.go | 10 ++++++++++ 2 files changed, 13 insertions(+) diff --git a/src/cmd/auth.go b/src/cmd/auth.go index 4c94a73e..45129449 100644 --- a/src/cmd/auth.go +++ b/src/cmd/auth.go @@ -56,6 +56,9 @@ func runAuth(cmd *cobra.Command, args []string) error { if host == "" { return fmt.Errorf("host URL is required for self-hosted instances") } + if !util.ValidateURL(host) { + return fmt.Errorf("invalid URL. Please ensure you include the scheme (e.g., https) and domain. Keep in mind, path and port are optional") + } } else { host = config.PhaseCloudAPIHost } diff --git a/src/pkg/util/misc.go b/src/pkg/util/misc.go index 9e4fee41..73487170 100644 --- a/src/pkg/util/misc.go +++ b/src/pkg/util/misc.go @@ -3,6 +3,7 @@ package util import ( "bufio" "fmt" + "net/url" "os" "os/exec" "runtime" @@ -94,3 +95,12 @@ func GetShellCommand(shellType string) ([]string, error) { } return []string{path}, nil } + +// ValidateURL checks that a URL has both a scheme (e.g. https) and a host (e.g. example.com). +func ValidateURL(rawURL string) bool { + parsed, err := url.Parse(rawURL) + if err != nil { + return false + } + return parsed.Scheme != "" && parsed.Host != "" +} From d67cc99a7d540dc4a61a1f44d91a61035088fee1 Mon Sep 17 00:00:00 2001 From: Nimish Date: Mon, 2 Mar 2026 20:19:57 +0530 Subject: [PATCH 48/50] fix: dynamic secrets path --- src/cmd/dynamic_secrets_list.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/cmd/dynamic_secrets_list.go b/src/cmd/dynamic_secrets_list.go index cdbed321..a15a0c7b 100644 --- a/src/cmd/dynamic_secrets_list.go +++ b/src/cmd/dynamic_secrets_list.go @@ -19,7 +19,7 @@ func init() { dynamicSecretsListCmd.Flags().String("env", "", "Environment name") dynamicSecretsListCmd.Flags().String("app", "", "Application name") dynamicSecretsListCmd.Flags().String("app-id", "", "Application ID") - dynamicSecretsListCmd.Flags().String("path", "", "Path filter") + dynamicSecretsListCmd.Flags().String("path", "/", "Path filter") dynamicSecretsCmd.AddCommand(dynamicSecretsListCmd) } From c563313d6d684b9dbce368bed0bf0bc76420101d Mon Sep 17 00:00:00 2001 From: Nimish Date: Mon, 2 Mar 2026 20:21:46 +0530 Subject: [PATCH 49/50] feat: add tests for host url validation --- src/pkg/util/misc_test.go | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/src/pkg/util/misc_test.go b/src/pkg/util/misc_test.go index ddc5bc45..28c97327 100644 --- a/src/pkg/util/misc_test.go +++ b/src/pkg/util/misc_test.go @@ -6,6 +6,34 @@ import ( "testing" ) +func TestValidateURL(t *testing.T) { + valid := []string{ + "https://example.com", + "https://console.phase.dev", + "http://localhost:8080", + "https://phase.internal.company.com/api", + "https://10.0.0.1:3000", + } + for _, u := range valid { + if !ValidateURL(u) { + t.Fatalf("expected valid for %q", u) + } + } + + invalid := []string{ + "example.com", + "just-a-hostname", + "://missing-scheme", + "", + "ftp//no-colon.com", + } + for _, u := range invalid { + if ValidateURL(u) { + t.Fatalf("expected invalid for %q", u) + } + } +} + func TestParseBoolFlag(t *testing.T) { falseCases := []string{"false", "FALSE", "no", "0", " no "} for _, tc := range falseCases { From a7b6073ceb0abaa338148f6544244c14c112ec1f Mon Sep 17 00:00:00 2001 From: Nimish Date: Mon, 2 Mar 2026 20:22:21 +0530 Subject: [PATCH 50/50] feat: add the ability to export specific keys --- src/cmd/secrets_export.go | 42 ++++++++++++++++++++++++++++++++++----- 1 file changed, 37 insertions(+), 5 deletions(-) diff --git a/src/cmd/secrets_export.go b/src/cmd/secrets_export.go index 0827d901..1b29db7a 100644 --- a/src/cmd/secrets_export.go +++ b/src/cmd/secrets_export.go @@ -3,6 +3,7 @@ package cmd import ( "fmt" "os" + "strings" "github.com/phasehq/cli/pkg/phase" "github.com/phasehq/cli/pkg/util" @@ -11,7 +12,7 @@ import ( ) var secretsExportCmd = &cobra.Command{ - Use: "export", + Use: "export [keys...]", Short: "🥡 Export secrets in a specific format", RunE: runSecretsExport, } @@ -38,6 +39,12 @@ func runSecretsExport(cmd *cobra.Command, args []string) error { generateLeases, _ := cmd.Flags().GetString("generate-leases") leaseTTL, _ := cmd.Flags().GetInt("lease-ttl") + // Uppercase requested keys for filtering + var filterKeys []string + for _, k := range args { + filterKeys = append(filterKeys, strings.ToUpper(k)) + } + appName, envName, appID = phase.GetConfig(appName, envName, appID) p, err := phase.NewPhase(true, "", "") @@ -63,12 +70,37 @@ func runSecretsExport(cmd *cobra.Command, args []string) error { return err } - var secretsList []util.KeyValue + // Build a map of all secrets for key filtering + allSecretsMap := make(map[string]string) for _, secret := range allSecrets { - if secret.Value == "" { - continue + if secret.Value != "" { + allSecretsMap[secret.Key] = secret.Value + } + } + + var secretsList []util.KeyValue + if len(filterKeys) > 0 { + // Check for missing keys + var missingKeys []string + for _, key := range filterKeys { + if _, ok := allSecretsMap[key]; !ok { + missingKeys = append(missingKeys, key) + } + } + if len(missingKeys) > 0 { + return fmt.Errorf("🥡 failed to export — the following secret(s) do not exist: %s", strings.Join(missingKeys, ", ")) + } + // Export only the requested keys (in the order they were specified) + for _, key := range filterKeys { + secretsList = append(secretsList, util.KeyValue{Key: key, Value: allSecretsMap[key]}) + } + } else { + for _, secret := range allSecrets { + if secret.Value == "" { + continue + } + secretsList = append(secretsList, util.KeyValue{Key: secret.Key, Value: secret.Value}) } - secretsList = append(secretsList, util.KeyValue{Key: secret.Key, Value: secret.Value}) } switch format {