diff --git a/cmd/env.go b/cmd/env.go index 2c832cb..93a4ed4 100644 --- a/cmd/env.go +++ b/cmd/env.go @@ -2,6 +2,7 @@ package cmd import ( "fmt" + "time" "github.com/InitiatDev/initiat-cli/internal/client" "github.com/InitiatDev/initiat-cli/internal/env" @@ -36,15 +37,34 @@ var envListCmd = &cobra.Command{ } activeEnv, _ := env.GetActiveEnvironment() + localEnvs, _ := env.ListLocalEnvironments() + localEnvMap := make(map[string]env.EnvironmentInfo) + for _, localEnv := range localEnvs { + localEnvMap[localEnv.Slug] = localEnv + } fmt.Println("Environments:") for _, environment := range environments { + status := "not synced" + syncTime := "never" + + if localEnv, exists := localEnvMap[environment.Slug]; exists { + if localEnv.HasSecrets { + status = "synced" + if !localEnv.Synced.IsZero() { + syncTime = formatTimeAgo(localEnv.Synced) + } + } else { + status = "no secrets" + } + } + marker := " " if environment.Slug == activeEnv { marker = "*" } - fmt.Printf("%s %-10s (%d secrets)\n", marker, environment.Slug, environment.SecretsCount) + fmt.Printf("%s %-10s %-10s %s (%d secrets)\n", marker, environment.Slug, status, syncTime, environment.SecretsCount) } return nil @@ -101,6 +121,48 @@ var envSwitchCmd = &cobra.Command{ }, } +var envSyncCmd = &cobra.Command{ + Use: "sync [--env ]", + Short: "Sync secrets from cloud", + Long: `Sync secrets from Initiat Cloud to local environment(s).`, + RunE: func(cmd *cobra.Command, args []string) error { + if !env.IsInitCompleted() { + return fmt.Errorf("initiat environment not initialized. Run 'initiat env init' first") + } + + projectCtx, err := GetProjectContext() + if err != nil { + return fmt.Errorf("failed to get project context: %w", err) + } + + envSlug, _ := cmd.Flags().GetString("env") + + if envSlug != "" { + apiClient := client.New() + _, err = apiClient.GetEnvironment(projectCtx.OrgSlug, projectCtx.ProjectSlug, envSlug) + if err != nil { + return fmt.Errorf("environment '%s' does not exist: %w", envSlug, err) + } + + err = env.SyncEnvironment(envSlug, projectCtx.OrgSlug, projectCtx.ProjectSlug) + if err != nil { + return fmt.Errorf("failed to sync environment %s: %w", envSlug, err) + } + + fmt.Printf("Synced environment '%s'\n", envSlug) + } else { + err = env.SyncAllEnvironments(projectCtx.OrgSlug, projectCtx.ProjectSlug) + if err != nil { + return fmt.Errorf("failed to sync environments: %w", err) + } + + fmt.Printf("Synced all environments\n") + } + + return nil + }, +} + var envCurrentCmd = &cobra.Command{ Use: "current", Short: "Show current environment", @@ -160,27 +222,6 @@ var envUnsetCmd = &cobra.Command{ }, } -var envLoadCmd = &cobra.Command{ - Use: "load", - Short: "Load environment secrets (for direnv)", - Long: `Load and export secrets for the active environment. This command is intended to be used by direnv via eval.`, - Hidden: true, - RunE: func(cmd *cobra.Command, args []string) error { - projectCtx, err := GetProjectContext() - if err != nil { - return fmt.Errorf("failed to get project context: %w", err) - } - - output, err := env.LoadEnvironmentSecrets(projectCtx.OrgSlug, projectCtx.ProjectSlug) - if err != nil { - return err - } - - fmt.Print(output) - return nil - }, -} - var envInitCmd = &cobra.Command{ Use: "init", Short: "Initialize environment setup", @@ -267,13 +308,35 @@ var envInitCmd = &cobra.Command{ }, } +func formatTimeAgo(t time.Time) string { + now := time.Now() + duration := now.Sub(t) + + switch { + case duration < time.Minute: + return "just now" + case duration < time.Hour: + minutes := int(duration.Minutes()) + return fmt.Sprintf("%dm ago", minutes) + case duration < 24*time.Hour: + hours := int(duration.Hours()) + return fmt.Sprintf("%dh ago", hours) + default: + const hoursPerDay = 24 + days := int(duration.Hours() / hoursPerDay) + return fmt.Sprintf("%dd ago", days) + } +} + func init() { envCmd.AddCommand(envListCmd) envCmd.AddCommand(envSwitchCmd) + envCmd.AddCommand(envSyncCmd) envCmd.AddCommand(envCurrentCmd) envCmd.AddCommand(envUnsetCmd) envCmd.AddCommand(envInitCmd) - envCmd.AddCommand(envLoadCmd) + + envSyncCmd.Flags().String("env", "", "Sync specific environment") rootCmd.AddCommand(envCmd) } diff --git a/cmd/env_test.go b/cmd/env_test.go index cba309b..d3fac4c 100644 --- a/cmd/env_test.go +++ b/cmd/env_test.go @@ -4,6 +4,7 @@ import ( "os" "strings" "testing" + "time" "github.com/InitiatDev/initiat-cli/internal/env" ) @@ -24,6 +25,11 @@ func TestEnvListCommand(t *testing.T) { t.Fatalf("CreateEnvironmentDir failed: %v", err) } + err = env.WriteSecrets("dev", "API_KEY=secret123") + if err != nil { + t.Fatalf("WriteSecrets failed: %v", err) + } + cmd := envListCmd cmd.SetArgs([]string{}) @@ -200,3 +206,26 @@ func TestEnvInitCommand(t *testing.T) { t.Errorf("Expected .initiat directory to be created: %v", err) } } + +func TestFormatTimeAgo(t *testing.T) { + now := time.Now() + + tests := []struct { + duration time.Duration + expected string + }{ + {30 * time.Second, "just now"}, + {2 * time.Minute, "2m ago"}, + {2 * time.Hour, "2h ago"}, + {2 * 24 * time.Hour, "2d ago"}, + } + + for _, test := range tests { + t.Run(test.expected, func(t *testing.T) { + result := formatTimeAgo(now.Add(-test.duration)) + if result != test.expected { + t.Errorf("Expected '%s', got '%s'", test.expected, result) + } + }) + } +} diff --git a/internal/env/direnv.go b/internal/env/direnv.go index a89a4e1..2083044 100644 --- a/internal/env/direnv.go +++ b/internal/env/direnv.go @@ -34,10 +34,12 @@ func GenerateEnvrc() error { const ( envrcContentUnix = `if [ -e ".initiat/active" ]; then - eval "$(initiat env load 2>/dev/null)" || true + dotenv ".initiat/active/secrets.env" + export INITIAT_ENV=$(basename "$(readlink .initiat/active 2>/dev/null || cat .initiat/active)") fi` envrcContentWindows = `if [ -e ".initiat/active" ]; then - eval "$(initiat env load 2>/dev/null)" || true + dotenv ".initiat/active/secrets.env" + export INITIAT_ENV=$(cat .initiat/active) fi` ) diff --git a/internal/env/direnv_test.go b/internal/env/direnv_test.go index 6b632e1..67c8942 100644 --- a/internal/env/direnv_test.go +++ b/internal/env/direnv_test.go @@ -48,12 +48,14 @@ func TestGenerateEnvrc(t *testing.T) { } expectedContent := `if [ -e ".initiat/active" ]; then - eval "$(initiat env load 2>/dev/null)" || true + dotenv ".initiat/active/secrets.env" + export INITIAT_ENV=$(basename "$(readlink .initiat/active 2>/dev/null || cat .initiat/active)") fi` if runtime.GOOS == "windows" { expectedContent = `if [ -e ".initiat/active" ]; then - eval "$(initiat env load 2>/dev/null)" || true + dotenv ".initiat/active/secrets.env" + export INITIAT_ENV=$(cat .initiat/active) fi` } @@ -111,12 +113,14 @@ func TestGenerateEnvrcToPath(t *testing.T) { } expectedContent := `if [ -e ".initiat/active" ]; then - eval "$(initiat env load 2>/dev/null)" || true + dotenv ".initiat/active/secrets.env" + export INITIAT_ENV=$(basename "$(readlink .initiat/active 2>/dev/null || cat .initiat/active)") fi` if runtime.GOOS == "windows" { expectedContent = `if [ -e ".initiat/active" ]; then - eval "$(initiat env load 2>/dev/null)" || true + dotenv ".initiat/active/secrets.env" + export INITIAT_ENV=$(cat .initiat/active) fi` } diff --git a/internal/env/fs.go b/internal/env/fs.go index 2c03789..501ff50 100644 --- a/internal/env/fs.go +++ b/internal/env/fs.go @@ -6,6 +6,7 @@ import ( "path/filepath" "runtime" "strings" + "time" "github.com/InitiatDev/initiat-cli/internal/file" ) @@ -14,6 +15,7 @@ const ( InitiatDir = ".initiat" EnvironmentsDir = "environments" ActiveFile = "active" + SecretsFile = "secrets.env" EnvrcFile = ".envrc" WindowsOS = "windows" GitignoreConfigured = "configured" @@ -49,6 +51,14 @@ func GetEnvironmentPath(slug string) (string, error) { return fileHandler.GetSubPath(envsPath, slug) } +func GetSecretsPath(envSlug string) (string, error) { + envPath, err := GetEnvironmentPath(envSlug) + if err != nil { + return "", err + } + return fileHandler.GetFilePath(envPath, SecretsFile) +} + func CreateInitiatDir() error { initiatPath, err := GetInitiatPath() if err != nil { @@ -152,16 +162,63 @@ func ListLocalEnvironments() ([]EnvironmentInfo, error) { } envSlug := entry.Name() + envPath := fileHandler.JoinPaths(envsPath, envSlug) + secretsPath, _ := fileHandler.GetFilePath(envPath, SecretsFile) + + var info os.FileInfo + hasSecrets := false + if stat, err := os.Stat(secretsPath); err == nil { + info = stat + hasSecrets = true + } + + var synced time.Time + if hasSecrets { + synced = info.ModTime() + } + envs = append(envs, EnvironmentInfo{ - Slug: envSlug, - Name: envSlug, - IsActive: envSlug == activeEnv, + Slug: envSlug, + Name: envSlug, + IsActive: envSlug == activeEnv, + Synced: synced, + HasSecrets: hasSecrets, }) } return envs, nil } +func WriteSecrets(envSlug string, content string) error { + secretsPath, err := GetSecretsPath(envSlug) + if err != nil { + return err + } + + if err := CreateEnvironmentDir(envSlug); err != nil { + return err + } + + return fileHandler.WriteFile(secretsPath, content) +} + +func ReadSecrets(envSlug string) (string, error) { + secretsPath, err := GetSecretsPath(envSlug) + if err != nil { + return "", err + } + + content, err := fileHandler.ReadFile(secretsPath) + if err != nil { + if os.IsNotExist(err) { + return "", nil + } + return "", err + } + + return content, nil +} + func LocalEnvironmentExists(slug string) bool { envPath, err := GetEnvironmentPath(slug) if err != nil { diff --git a/internal/env/load.go b/internal/env/load.go deleted file mode 100644 index fc2cfcd..0000000 --- a/internal/env/load.go +++ /dev/null @@ -1,92 +0,0 @@ -package env - -import ( - "fmt" - "strings" - - "github.com/InitiatDev/initiat-cli/internal/client" - "github.com/InitiatDev/initiat-cli/internal/crypto" - "github.com/InitiatDev/initiat-cli/internal/storage" -) - -func LoadEnvironmentSecrets(orgSlug, projectSlug string) (string, error) { - activeEnv, err := GetActiveEnvironment() - if err != nil { - return "", fmt.Errorf("no active environment set") - } - - apiClient := client.New() - environment, err := apiClient.GetEnvironment(orgSlug, projectSlug, activeEnv) - if err != nil { - return "", fmt.Errorf("failed to get environment %s: %w", activeEnv, err) - } - - if len(environment.Secrets) == 0 { - return fmt.Sprintf("export INITIAT_ENV=%s\n", shellEscape(activeEnv)), nil - } - - projectKey, err := getProjectKey(orgSlug, projectSlug) - if err != nil { - return "", fmt.Errorf("failed to get project key: %w", err) - } - - var output strings.Builder - output.WriteString(fmt.Sprintf("export INITIAT_ENV=%s\n", shellEscape(activeEnv))) - - for _, secret := range environment.Secrets { - secretWithValue, err := apiClient.GetSecret(orgSlug, projectSlug, secret.Key) - if err != nil { - return "", fmt.Errorf("failed to get secret %s: %w", secret.Key, err) - } - - encryptedValue, err := crypto.Decode(secretWithValue.EncryptedValue) - if err != nil { - return "", fmt.Errorf("failed to decode encrypted value for %s: %w", secret.Key, err) - } - - nonce, err := crypto.Decode(secretWithValue.Nonce) - if err != nil { - return "", fmt.Errorf("failed to decode nonce for %s: %w", secret.Key, err) - } - - decryptedValue, err := crypto.DecryptSecretValue(encryptedValue, nonce, projectKey) - if err != nil { - return "", fmt.Errorf("failed to decrypt secret %s: %w", secret.Key, err) - } - - output.WriteString(fmt.Sprintf("export %s=%s\n", secret.Key, shellEscape(decryptedValue))) - } - - return output.String(), nil -} - -func shellEscape(value string) string { - value = strings.ReplaceAll(value, "\\", "\\\\") - value = strings.ReplaceAll(value, "\"", "\\\"") - value = strings.ReplaceAll(value, "$", "\\$") - value = strings.ReplaceAll(value, "`", "\\`") - value = strings.ReplaceAll(value, "\n", "\\n") - value = strings.ReplaceAll(value, "\r", "\\r") - return fmt.Sprintf("\"%s\"", value) -} - -func getProjectKey(orgSlug, projectSlug string) ([]byte, error) { - apiClient := client.New() - wrappedKey, err := apiClient.GetWrappedProjectKey(orgSlug, projectSlug) - if err != nil { - return nil, fmt.Errorf("failed to fetch wrapped project key: %w", err) - } - - store := storage.New() - devicePrivateKey, err := store.GetEncryptionPrivateKey() - if err != nil { - return nil, fmt.Errorf("failed to get device private key: %w", err) - } - - projectKey, err := crypto.UnwrapProjectKey(wrappedKey, devicePrivateKey) - if err != nil { - return nil, fmt.Errorf("failed to unwrap project key: %w", err) - } - - return projectKey, nil -} diff --git a/internal/env/load_test.go b/internal/env/load_test.go deleted file mode 100644 index 2c94a54..0000000 --- a/internal/env/load_test.go +++ /dev/null @@ -1,547 +0,0 @@ -package env - -import ( - "crypto/ed25519" - "crypto/rand" - "encoding/json" - "net/http" - "net/http/httptest" - "os" - "strings" - "testing" - - "github.com/spf13/viper" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "golang.org/x/crypto/curve25519" - - "github.com/InitiatDev/initiat-cli/internal/config" - "github.com/InitiatDev/initiat-cli/internal/crypto" - "github.com/InitiatDev/initiat-cli/internal/routes" - "github.com/InitiatDev/initiat-cli/internal/storage" - "github.com/InitiatDev/initiat-cli/internal/types" -) - -func TestShellEscape(t *testing.T) { - tests := []struct { - name string - input string - expected string - }{ - { - name: "simple value", - input: "simple", - expected: `"simple"`, - }, - { - name: "value with quotes", - input: `value"with"quotes`, - expected: `"value\"with\"quotes"`, - }, - { - name: "value with backslash", - input: `value\with\backslash`, - expected: `"value\\with\\backslash"`, - }, - { - name: "value with dollar sign", - input: "value$with$dollar", - expected: `"value\$with\$dollar"`, - }, - { - name: "value with backtick", - input: "value`with`backtick", - expected: "\"value\\`with\\`backtick\"", - }, - { - name: "value with newline", - input: "value\nwith\nnewline", - expected: `"value\nwith\nnewline"`, - }, - { - name: "value with carriage return", - input: "value\rwith\rcarriage", - expected: `"value\rwith\rcarriage"`, - }, - { - name: "value with multiple special chars", - input: `value"with\$all\nspecial\chars`, - expected: `"value\"with\\\$all\\nspecial\\chars"`, - }, - { - name: "empty string", - input: "", - expected: `""`, - }, - { - name: "value with spaces", - input: "value with spaces", - expected: `"value with spaces"`, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := shellEscape(tt.input) - assert.Equal(t, tt.expected, result) - }) - } -} - -func TestLoadEnvironmentSecrets_NoActiveEnvironment(t *testing.T) { - tempDir := t.TempDir() - originalWd, _ := os.Getwd() - os.Chdir(tempDir) - defer os.Chdir(originalWd) - - err := CreateInitiatDir() - require.NoError(t, err) - - output, err := LoadEnvironmentSecrets("test-org", "test-project") - assert.Error(t, err) - assert.Empty(t, output) - assert.Contains(t, err.Error(), "no active environment set") -} - -func TestLoadEnvironmentSecrets_NoSecrets(t *testing.T) { - tempDir := t.TempDir() - originalWd, _ := os.Getwd() - os.Chdir(tempDir) - defer os.Chdir(originalWd) - - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if strings.Contains(r.URL.Path, "/environments/dev") { - env := types.Environment{ - Slug: "dev", - Name: "Development", - Secrets: []types.Secret{}, - SecretsCount: 0, - } - response := types.APIResponse{ - Success: true, - Data: mustMarshal(types.GetEnvironmentResponse{Environment: env}), - } - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - json.NewEncoder(w).Encode(response) - return - } - w.WriteHeader(http.StatusNotFound) - })) - defer server.Close() - - setupTestEnvironment(t, server.URL) - - err := CreateInitiatDir() - require.NoError(t, err) - - err = CreateEnvironmentDir("dev") - require.NoError(t, err) - - err = SetActiveEnvironment("dev") - require.NoError(t, err) - - output, err := LoadEnvironmentSecrets("test-org", "test-project") - require.NoError(t, err) - assert.Contains(t, output, "export INITIAT_ENV=") - assert.Contains(t, output, "dev") - assert.NotContains(t, output, "export API_KEY=") -} - -func TestLoadEnvironmentSecrets_WithSecrets(t *testing.T) { - tempDir := t.TempDir() - originalWd, _ := os.Getwd() - os.Chdir(tempDir) - defer os.Chdir(originalWd) - - projectKey := make([]byte, 32) - rand.Read(projectKey) - - encryptedValue1, nonce1, err := crypto.EncryptSecretValue("secret-value-1", projectKey) - require.NoError(t, err) - - encryptedValue2, nonce2, err := crypto.EncryptSecretValue("secret-value-2", projectKey) - require.NoError(t, err) - - setupTestEnvironment(t, "") - - store := storage.New() - devicePrivateKey, err := store.GetEncryptionPrivateKey() - require.NoError(t, err) - - devicePublicKey, err := curve25519.X25519(devicePrivateKey, curve25519.Basepoint) - require.NoError(t, err) - - wrappedKey, err := crypto.WrapProjectKey(projectKey, devicePublicKey) - require.NoError(t, err) - - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if strings.Contains(r.URL.Path, "/environments/dev") { - env := types.Environment{ - Slug: "dev", - Name: "Development", - Secrets: []types.Secret{ - {Key: "API_KEY", ID: 1}, - {Key: "DB_PASSWORD", ID: 2}, - }, - SecretsCount: 2, - } - response := types.APIResponse{ - Success: true, - Data: mustMarshal(types.GetEnvironmentResponse{Environment: env}), - } - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - json.NewEncoder(w).Encode(response) - return - } - - if strings.Contains(r.URL.Path, "/secrets/API_KEY") { - secret := types.SecretWithValue{ - Secret: types.Secret{ - Key: "API_KEY", - ID: 1, - }, - EncryptedValue: crypto.Encode(encryptedValue1), - Nonce: crypto.Encode(nonce1), - } - response := types.APIResponse{ - Success: true, - Data: mustMarshal(types.GetSecretResponse{Secret: secret}), - } - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - json.NewEncoder(w).Encode(response) - return - } - - if strings.Contains(r.URL.Path, "/secrets/DB_PASSWORD") { - secret := types.SecretWithValue{ - Secret: types.Secret{ - Key: "DB_PASSWORD", - ID: 2, - }, - EncryptedValue: crypto.Encode(encryptedValue2), - Nonce: crypto.Encode(nonce2), - } - response := types.APIResponse{ - Success: true, - Data: mustMarshal(types.GetSecretResponse{Secret: secret}), - } - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - json.NewEncoder(w).Encode(response) - return - } - - if strings.Contains(r.URL.Path, routes.Project.GetProjectKey("test-org", "test-project")) { - keyResp := types.GetProjectKeyResponse{ - WrappedProjectKey: wrappedKey, - KeyVersion: 1, - } - response := types.APIResponse{ - Success: true, - Data: mustMarshal(keyResp), - } - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - json.NewEncoder(w).Encode(response) - return - } - - w.WriteHeader(http.StatusNotFound) - })) - defer server.Close() - - if err := config.Set("api.base_url", server.URL); err != nil { - t.Fatalf("Failed to set API URL: %v", err) - } - - err = CreateInitiatDir() - require.NoError(t, err) - - err = CreateEnvironmentDir("dev") - require.NoError(t, err) - - err = SetActiveEnvironment("dev") - require.NoError(t, err) - - output, err := LoadEnvironmentSecrets("test-org", "test-project") - require.NoError(t, err) - - assert.Contains(t, output, "export INITIAT_ENV=") - assert.Contains(t, output, "dev") - assert.Contains(t, output, "export API_KEY=") - assert.Contains(t, output, "export DB_PASSWORD=") - assert.Contains(t, output, "secret-value-1") - assert.Contains(t, output, "secret-value-2") - - lines := strings.Split(strings.TrimSpace(output), "\n") - assert.Equal(t, 3, len(lines)) -} - -func TestLoadEnvironmentSecrets_WithSpecialCharacters(t *testing.T) { - tempDir := t.TempDir() - originalWd, _ := os.Getwd() - os.Chdir(tempDir) - defer os.Chdir(originalWd) - - projectKey := make([]byte, 32) - rand.Read(projectKey) - - specialValue := `value"with\$special\nchars` - encryptedValue, nonce, err := crypto.EncryptSecretValue(specialValue, projectKey) - require.NoError(t, err) - - setupTestEnvironment(t, "") - - store := storage.New() - devicePrivateKey, err := store.GetEncryptionPrivateKey() - require.NoError(t, err) - - devicePublicKey, err := curve25519.X25519(devicePrivateKey, curve25519.Basepoint) - require.NoError(t, err) - - wrappedKey, err := crypto.WrapProjectKey(projectKey, devicePublicKey) - require.NoError(t, err) - - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if strings.Contains(r.URL.Path, "/environments/dev") { - env := types.Environment{ - Slug: "dev", - Name: "Development", - Secrets: []types.Secret{ - {Key: "SPECIAL_SECRET", ID: 1}, - }, - SecretsCount: 1, - } - response := types.APIResponse{ - Success: true, - Data: mustMarshal(types.GetEnvironmentResponse{Environment: env}), - } - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - json.NewEncoder(w).Encode(response) - return - } - - if strings.Contains(r.URL.Path, "/secrets/SPECIAL_SECRET") { - secret := types.SecretWithValue{ - Secret: types.Secret{ - Key: "SPECIAL_SECRET", - ID: 1, - }, - EncryptedValue: crypto.Encode(encryptedValue), - Nonce: crypto.Encode(nonce), - } - response := types.APIResponse{ - Success: true, - Data: mustMarshal(types.GetSecretResponse{Secret: secret}), - } - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - json.NewEncoder(w).Encode(response) - return - } - - if strings.Contains(r.URL.Path, routes.Project.GetProjectKey("test-org", "test-project")) { - keyResp := types.GetProjectKeyResponse{ - WrappedProjectKey: wrappedKey, - KeyVersion: 1, - } - response := types.APIResponse{ - Success: true, - Data: mustMarshal(keyResp), - } - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - json.NewEncoder(w).Encode(response) - return - } - - w.WriteHeader(http.StatusNotFound) - })) - defer server.Close() - - if err := config.Set("api.base_url", server.URL); err != nil { - t.Fatalf("Failed to set API URL: %v", err) - } - - err = CreateInitiatDir() - require.NoError(t, err) - - err = CreateEnvironmentDir("dev") - require.NoError(t, err) - - err = SetActiveEnvironment("dev") - require.NoError(t, err) - - output, err := LoadEnvironmentSecrets("test-org", "test-project") - require.NoError(t, err) - - assert.Contains(t, output, "export SPECIAL_SECRET=") - assert.Contains(t, output, `\"`) - assert.Contains(t, output, `\\$`) - assert.Contains(t, output, `\\n`) -} - -func TestLoadEnvironmentSecrets_EnvironmentNotFound(t *testing.T) { - tempDir := t.TempDir() - originalWd, _ := os.Getwd() - os.Chdir(tempDir) - defer os.Chdir(originalWd) - - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusNotFound) - })) - defer server.Close() - - setupTestEnvironment(t, server.URL) - - err := CreateInitiatDir() - require.NoError(t, err) - - err = CreateEnvironmentDir("dev") - require.NoError(t, err) - - err = SetActiveEnvironment("dev") - require.NoError(t, err) - - output, err := LoadEnvironmentSecrets("test-org", "test-project") - assert.Error(t, err) - assert.Empty(t, output) - assert.Contains(t, err.Error(), "failed to get environment") -} - -func TestLoadEnvironmentSecrets_SecretNotFound(t *testing.T) { - tempDir := t.TempDir() - originalWd, _ := os.Getwd() - os.Chdir(tempDir) - defer os.Chdir(originalWd) - - projectKey := make([]byte, 32) - rand.Read(projectKey) - - setupTestEnvironment(t, "") - - store := storage.New() - devicePrivateKey, err := store.GetEncryptionPrivateKey() - require.NoError(t, err) - - devicePublicKey, err := curve25519.X25519(devicePrivateKey, curve25519.Basepoint) - require.NoError(t, err) - - wrappedKey, err := crypto.WrapProjectKey(projectKey, devicePublicKey) - require.NoError(t, err) - - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if strings.Contains(r.URL.Path, "/environments/dev") { - env := types.Environment{ - Slug: "dev", - Name: "Development", - Secrets: []types.Secret{ - {Key: "API_KEY", ID: 1}, - }, - SecretsCount: 1, - } - response := types.APIResponse{ - Success: true, - Data: mustMarshal(types.GetEnvironmentResponse{Environment: env}), - } - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - json.NewEncoder(w).Encode(response) - return - } - - if strings.Contains(r.URL.Path, routes.Project.GetProjectKey("test-org", "test-project")) { - keyResp := types.GetProjectKeyResponse{ - WrappedProjectKey: wrappedKey, - KeyVersion: 1, - } - response := types.APIResponse{ - Success: true, - Data: mustMarshal(keyResp), - } - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - json.NewEncoder(w).Encode(response) - return - } - - w.WriteHeader(http.StatusNotFound) - })) - defer server.Close() - - if err := config.Set("api.base_url", server.URL); err != nil { - t.Fatalf("Failed to set API URL: %v", err) - } - - err = CreateInitiatDir() - require.NoError(t, err) - - err = CreateEnvironmentDir("dev") - require.NoError(t, err) - - err = SetActiveEnvironment("dev") - require.NoError(t, err) - - output, err := LoadEnvironmentSecrets("test-org", "test-project") - assert.Error(t, err) - assert.Empty(t, output) - assert.Contains(t, err.Error(), "failed to get secret") -} - -func setupTestEnvironment(t *testing.T, serverURL string) { - viper.Reset() - - if err := config.InitConfig(); err != nil { - t.Fatalf("Failed to init config: %v", err) - } - - if err := config.Set("api.base_url", serverURL); err != nil { - t.Fatalf("Failed to set API URL: %v", err) - } - - if err := config.Set("service_name", "initiat-cli-test-"+t.Name()); err != nil { - t.Fatalf("Failed to set service name: %v", err) - } - - store := storage.New() - - signingPublic, signingPrivate, err := ed25519.GenerateKey(rand.Reader) - if err != nil { - t.Fatalf("Failed to generate signing keypair: %v", err) - } - - if err := store.StoreSigningPrivateKey(signingPrivate); err != nil { - t.Fatalf("Failed to store signing private key: %v", err) - } - - encryptionPrivate := make([]byte, 32) - rand.Read(encryptionPrivate) - if err := store.StoreEncryptionPrivateKey(encryptionPrivate); err != nil { - t.Fatalf("Failed to store encryption private key: %v", err) - } - - if err := store.StoreDeviceID("test-device-123"); err != nil { - t.Fatalf("Failed to store device ID: %v", err) - } - - t.Cleanup(func() { - store.DeleteSigningPrivateKey() - store.DeleteEncryptionPrivateKey() - store.DeleteDeviceID() - store.DeleteToken() - }) - - _ = signingPublic -} - -func mustMarshal(v interface{}) []byte { - data, err := json.Marshal(v) - if err != nil { - panic(err) - } - return data -} diff --git a/internal/env/sync.go b/internal/env/sync.go new file mode 100644 index 0000000..ac7e763 --- /dev/null +++ b/internal/env/sync.go @@ -0,0 +1,148 @@ +package env + +import ( + "fmt" + "os" + "strings" + "time" + + "github.com/InitiatDev/initiat-cli/internal/client" + "github.com/InitiatDev/initiat-cli/internal/crypto" + "github.com/InitiatDev/initiat-cli/internal/storage" + "github.com/InitiatDev/initiat-cli/internal/types" +) + +func SyncEnvironment(envSlug, orgSlug, projectSlug string) error { + apiClient := client.New() + + environment, err := apiClient.GetEnvironment(orgSlug, projectSlug, envSlug) + if err != nil { + return fmt.Errorf("failed to get environment %s: %w", envSlug, err) + } + + if len(environment.Secrets) == 0 { + return WriteSecrets(envSlug, "") + } + + projectKey, err := getProjectKey(orgSlug, projectSlug) + if err != nil { + return fmt.Errorf("failed to get project key: %w", err) + } + + var envContent strings.Builder + for _, secret := range environment.Secrets { + secretWithValue, err := apiClient.GetSecret(orgSlug, projectSlug, secret.Key) + if err != nil { + return fmt.Errorf("failed to get secret %s: %w", secret.Key, err) + } + + encryptedValue, err := crypto.Decode(secretWithValue.EncryptedValue) + if err != nil { + return fmt.Errorf("failed to decode encrypted value for %s: %w", secret.Key, err) + } + + nonce, err := crypto.Decode(secretWithValue.Nonce) + if err != nil { + return fmt.Errorf("failed to decode nonce for %s: %w", secret.Key, err) + } + + decryptedValue, err := crypto.DecryptSecretValue(encryptedValue, nonce, projectKey) + if err != nil { + return fmt.Errorf("failed to decrypt secret %s: %w", secret.Key, err) + } + + envContent.WriteString(fmt.Sprintf("%s=%s\n", secret.Key, decryptedValue)) + } + + return WriteSecrets(envSlug, envContent.String()) +} + +func SyncAllEnvironments(orgSlug, projectSlug string) error { + apiClient := client.New() + + environments, err := apiClient.ListEnvironments(orgSlug, projectSlug) + if err != nil { + return fmt.Errorf("failed to list environments: %w", err) + } + + var errors []string + for _, env := range environments { + if err := SyncEnvironment(env.Slug, orgSlug, projectSlug); err != nil { + errors = append(errors, fmt.Sprintf("failed to sync %s: %v", env.Slug, err)) + } + } + + if len(errors) > 0 { + return fmt.Errorf("sync errors: %s", strings.Join(errors, "; ")) + } + + return nil +} + +func GetEnvironmentSecrets(envSlug string) ([]types.Secret, error) { + content, err := ReadSecrets(envSlug) + if err != nil { + return nil, err + } + + if content == "" { + return []types.Secret{}, nil + } + + lines := strings.Split(strings.TrimSpace(content), "\n") + var secrets []types.Secret + + for i, line := range lines { + if strings.TrimSpace(line) == "" { + continue + } + + const expectedParts = 2 + parts := strings.SplitN(line, "=", expectedParts) + if len(parts) != expectedParts { + continue + } + + secrets = append(secrets, types.Secret{ + Key: strings.TrimSpace(parts[0]), + ID: i + 1, + }) + } + + return secrets, nil +} + +func GetLastSyncTime(envSlug string) (time.Time, error) { + secretsPath, err := GetSecretsPath(envSlug) + if err != nil { + return time.Time{}, err + } + + info, err := os.Stat(secretsPath) + if err != nil { + return time.Time{}, err + } + + return info.ModTime(), nil +} + +func getProjectKey(orgSlug, projectSlug string) ([]byte, error) { + apiClient := client.New() + wrappedKey, err := apiClient.GetWrappedProjectKey(orgSlug, projectSlug) + if err != nil { + return nil, fmt.Errorf("failed to fetch wrapped project key: %w", err) + } + + store := storage.New() + devicePrivateKey, err := store.GetEncryptionPrivateKey() + if err != nil { + return nil, fmt.Errorf("failed to get device private key: %w", err) + } + + projectKey, err := crypto.UnwrapProjectKey(wrappedKey, devicePrivateKey) + if err != nil { + return nil, fmt.Errorf("failed to unwrap project key: %w", err) + } + + return projectKey, nil +} diff --git a/internal/env/sync_test.go b/internal/env/sync_test.go new file mode 100644 index 0000000..9ba3542 --- /dev/null +++ b/internal/env/sync_test.go @@ -0,0 +1,104 @@ +package env + +import ( + "os" + "testing" + "time" +) + +func TestGetEnvironmentSecrets(t *testing.T) { + tempDir := t.TempDir() + originalWd, _ := os.Getwd() + os.Chdir(tempDir) + defer os.Chdir(originalWd) + + err := CreateInitiatDir() + if err != nil { + t.Fatalf("CreateInitiatDir failed: %v", err) + } + + content := "API_KEY=secret123\nDB_URL=postgres://localhost:5432/test\n# Comment line\n\nEMPTY_VALUE=" + err = WriteSecrets("dev", content) + if err != nil { + t.Fatalf("WriteSecrets failed: %v", err) + } + + secrets, err := GetEnvironmentSecrets("dev") + if err != nil { + t.Fatalf("GetEnvironmentSecrets failed: %v", err) + } + + if len(secrets) != 3 { + t.Errorf("Expected 3 secrets, got %d", len(secrets)) + } + + expectedKeys := []string{"API_KEY", "DB_URL", "EMPTY_VALUE"} + for i, secret := range secrets { + if secret.Key != expectedKeys[i] { + t.Errorf("Expected key %s, got %s", expectedKeys[i], secret.Key) + } + } +} + +func TestGetEnvironmentSecretsEmpty(t *testing.T) { + tempDir := t.TempDir() + originalWd, _ := os.Getwd() + os.Chdir(tempDir) + defer os.Chdir(originalWd) + + err := CreateInitiatDir() + if err != nil { + t.Fatalf("CreateInitiatDir failed: %v", err) + } + + secrets, err := GetEnvironmentSecrets("dev") + if err != nil { + t.Fatalf("GetEnvironmentSecrets failed: %v", err) + } + + if len(secrets) != 0 { + t.Errorf("Expected 0 secrets, got %d", len(secrets)) + } +} + +func TestGetLastSyncTime(t *testing.T) { + tempDir := t.TempDir() + originalWd, _ := os.Getwd() + os.Chdir(tempDir) + defer os.Chdir(originalWd) + + err := CreateInitiatDir() + if err != nil { + t.Fatalf("CreateInitiatDir failed: %v", err) + } + + before := time.Now().Add(-time.Second) + + err = WriteSecrets("dev", "API_KEY=secret123") + if err != nil { + t.Fatalf("WriteSecrets failed: %v", err) + } + + after := time.Now().Add(time.Second) + + syncTime, err := GetLastSyncTime("dev") + if err != nil { + t.Fatalf("GetLastSyncTime failed: %v", err) + } + + if syncTime.Before(before) || syncTime.After(after) { + t.Errorf("Sync time %v not between %v and %v", syncTime, before, after) + } +} + +func TestGetLastSyncTimeNotFound(t *testing.T) { + tempDir := t.TempDir() + originalWd, _ := os.Getwd() + os.Chdir(tempDir) + defer os.Chdir(originalWd) + + _, err := GetLastSyncTime("nonexistent") + if err == nil { + t.Error("Expected error for nonexistent environment") + } +}