Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 86 additions & 23 deletions cmd/env.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package cmd

import (
"fmt"
"time"

"github.com/InitiatDev/initiat-cli/internal/client"
"github.com/InitiatDev/initiat-cli/internal/env"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -101,6 +121,48 @@ var envSwitchCmd = &cobra.Command{
},
}

var envSyncCmd = &cobra.Command{
Use: "sync [--env <slug>]",
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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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)
}
29 changes: 29 additions & 0 deletions cmd/env_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"os"
"strings"
"testing"
"time"

"github.com/InitiatDev/initiat-cli/internal/env"
)
Expand All @@ -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{})

Expand Down Expand Up @@ -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)
}
})
}
}
6 changes: 4 additions & 2 deletions internal/env/direnv.go
Original file line number Diff line number Diff line change
Expand Up @@ -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`
)

Expand Down
12 changes: 8 additions & 4 deletions internal/env/direnv_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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`
}

Expand Down Expand Up @@ -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`
}

Expand Down
63 changes: 60 additions & 3 deletions internal/env/fs.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"path/filepath"
"runtime"
"strings"
"time"

"github.com/InitiatDev/initiat-cli/internal/file"
)
Expand All @@ -14,6 +15,7 @@ const (
InitiatDir = ".initiat"
EnvironmentsDir = "environments"
ActiveFile = "active"
SecretsFile = "secrets.env"
EnvrcFile = ".envrc"
WindowsOS = "windows"
GitignoreConfigured = "configured"
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
Loading
Loading